iT邦幫忙

2021 iThome 鐵人賽

DAY 9
0
Modern Web

30 天擁有一套自己手刻的 React UI 元件庫系列 第 9

【Day09】數據輸入元件 - Upload

元件介紹

Upload 是一個上傳元件。幫助我們能夠發佈文字、圖片、影片、檔案到後端伺服器上。

參考設計 & 屬性分析

客製化上傳元件樣式

我們原生的 html 元件 <input type="file"> 就能夠幫助我們選擇本地的檔案以準備上傳到伺服器:

雖然功能上是已經有了,但我們仍可以看到有一些上傳檔案的元件有經過美化,例如 Antd 的元件:

那到底我們應該怎麼做才能夠客製化上傳檔案元件的樣式呢?其實如果我們只要去檢視其 html 代碼,就能夠略知一二:

從這邊的程式我們不難發現,這個元件當中其實也有 <input type="file">,但比較特別的是,這個 input 元件的 style 居然被設為 display: none;,意思就是在畫面上他被隱藏了,並且 input 元件下面居然有一個 button 元件,而他就是我們畫面上看到的上傳按鈕樣式:

從上面這些觀察,我們可以發現,要做出不同樣式的上傳元件,其實並不是直接去複寫 input 元件,而是表面上做出另外的 button 元件,透過 ref 來操作看不見的 input 元件的 DOM,藉此觸發 input 的點擊事件來開啟選取視窗。

限制檔案類型

如果今天我們想要讓人只選擇圖片類型,不想讓人選擇到其他檔案類型,那應該怎麼做呢?其實原生的 input 就提供我們 accept 屬性,讓我們透過他來限制上傳檔案的類型。

<input type="file" accept="image/*" />

甚至我們也可以直接限制上傳檔案的副檔名,如果有多種副檔名,可以用逗號隔開:

<input type="file" accept="text/html,.txt,.csv" />

選取多個檔案

input file 元件當中,如果沒有特別設定,預設是只能選取一個檔案,因此,如果我們透過 onChange 事件來把 event.target.files 印出來在螢幕看看,我們可以看見下面內容:

const handleOnChange = (event) => {
    console.log('files: ', event.target.files);
};

<input type="file" onChange={handleOnChange} accept="image/*" />

如果我們需要選擇多個檔案,input file 元件提供我們一個名為 multiple 的 boolean 值,當他為 true 時,則可支援我們上傳多個檔案,範例示意如下:

const handleOnChange = (event) => {
    console.log('files: ', event.target.files);
};

<input type="file" onChange={handleOnChange} accept="image/*" multiple />

顯示選取的檔案

有一些網站設計會希望我們在上傳檔案之前,可以先預覽要上傳的檔案,例如說檔案的檔名,檔案的大小,若上傳的檔案為圖片檔,甚至我們會想要先預覽圖片。

透過 onChange 事件我們拿到 event 物件,在 event.target.files 當中我們可以很容易地取得檔名、檔案大小、檔案類型。

但若是要在上傳之前預覽圖片呢?一般我們知道 HTML <img> tag 若要顯示圖片,需要在 src 屬性當中傳入圖片的網址,範例如下:

<img src="https://via.placeholder.com/300/09f/fff.png" alt="" />

但由於我們的圖片還沒上傳到 server ,我們哪裡來的圖片網址呢?因此,在 HTML <img> tag 中若要顯示圖片,有另外一條路,就是需要將我們準備上傳的圖片檔案轉換成 base64 string 的編碼,然後把它塞進 src 當中,用這樣的方式,我們同樣也可以在畫面上顯示圖片,範例示意如下:

<img src="data:image/jpeg;base64,/9j/4AAQSkZJRg......" alt="">

為了將圖片轉換成 base64 string ,我們在 MDN Web Docs 可以看到範例的做法:

function previewFile() {
  const preview = document.querySelector('img');
  const file = document.querySelector('input[type=file]').files[0];
  const reader = new FileReader();

  reader.addEventListener("load", function () {
    // convert image file to base64 string
    preview.src = reader.result;
  }, false);

  if (file) {
    reader.readAsDataURL(file);
  }
}

https://developer.mozilla.org/en-US/docs/Web/API/FileReader/readAsDataURL

FileReader 是 HTML5 的新 Javascript 物件,可以用來讀取 input type="file" 的 file 資料,並且在上述範例中,我們監聽 load 事件,他會在我們讀取操作成功完成時調用。

我們讀取檔案的方式是調用 readAsDataURL 函式,這個方法會讀取我們指定的 file 物件,並且在讀取完成之後,透過 reader.result 屬性我們就會得到一個 data URL 格式的 base64 string 了。拿到這個 base64 string 之後,我們再透過 setState 方法將其存到我們 React 元件的 state 當中,之後就可以透過 React 來操作,把他塞進 image src 來顯示圖片了,下面是示意範例:

import React, { useState } from 'react';

const UploadPreview = () => {
  const [imageSrc, setImageSrc] = useState('');

  const handleOnPreview = (event) => {
    const file = event.target.files[0];
    const reader = new FileReader();
    reader.addEventListener("load", function () {
      // convert image file to base64 string
      setImageSrc(reader.result)
    }, false);

    if (file) {
      reader.readAsDataURL(file);
    }
  };

  return (
    <>
      <input type="file" onChange={handleOnPreview} accept="image/*" />
      <img src={imageSrc} alt="" />
    </>
  );
};

export default UploadPreview;

清空選取的檔案

除了上傳前預覽之外,另一個常會需要的操作,就是清空/重設選取的檔案,這邊提供三個方法來達到同樣的目的:

  1. 將 input 的 value 這個屬性設為 empty 或是 null.
    在前面講到 Uncontrolled component 的文章中我們有提過,在 React 當中透過 useRef 這個 Hook 可以讓我們直接操作 input 的 DOM,我們可以取得 input element 當前的 value,反之我們也可以清空他。
const handleRemoveFile = () => {
  inputRef.current.value = '';
};
  1. 創建另外一個新的 input element 並將欲清空的元件取代掉
    在 React 中,key 這個屬性幫助 React 分辨哪些元件被改變,因此當我們希望清空 input 時更新 input element 的 key,等於是強迫更新了 input element,也能夠做到清空 input value 的效果,範例如下示意:
import React, { useState } from 'react';


const ResetInputSample = () => {
  const [inputElemKey, setInputElemKey] = useState(Math.random());

  const handleClearByUpdateKey = () => {
    setInputElemKey(Math.random());
  };

  return (
    <>
      <input key={inputElemKey} type="file" accept="image/*" />
      <button onClick={handleClearByUpdateKey}>Reset</button>
    </>
  );
};

export default ResetInputSample;

https://stackoverflow.com/questions/42192346/how-to-reset-reactjs-file-input

  1. 透過 form.reset() 這個方法來重置該表單內的所有資料
    我們可以直接用 useRef 來操作 form ,透過 js 的函式 form.reset() 來重設表單,另一種方式也可以透過 form 裡面的 <input type="reset" /> 元件來重設表單,下面為示意範例:
import React, { useRef, useState } from 'react';

const ResetFormSample = () => {
  const formRef = useRef();
  const [imageSrc, setImageSrc] = useState('');

  const handleOnPreview = (event) => {...};

  const handleOnResetForm = () => {
    formRef.current.reset();
  };

  return (
    <>
      <form ref={formRef} action="...">
        <input type="file" onChange={handleOnPreview} accept="image/*" />
        <button onClick={handleOnResetForm}>Reset</button>
      </form>
      <img src={imageSrc} alt="" />
    </>
  );
};

export default ResetFormSample;

https://stackoverflow.com/questions/1703228/how-can-i-clear-an-html-file-input-with-javascript/16222877

介面設計

屬性 說明 類型 默認值
resetKey 重設鍵值,鍵值被改變時 input value 會被重設 number
accept 限制檔案類型 string, ex: image/*
multiple 是否選取多個檔案 boolean false
onChange 選取上傳檔案時的 callback function (files) => {}
children 內容,這邊指的是上傳按鈕外觀 ReactNode

元件實作

目前我是希望能夠用下面這樣的方式來使用 Upload 元件:

<Upload onChange={handleOnChange}>
  <CustomUploadButton />
</Upload>

我們希望上傳按鈕的樣式透過 children 傳入,藉此能夠隨意改變上傳按鈕,但仍能擁有同樣的上傳邏輯,避免每次只要樣式有點調整,就要整個把上傳功能重寫一次。

在下面的程式碼當中,我們希望點擊 children 的時候,能夠觸發被 display: none; 設為隱藏的 <input type="file" />

<>
  <input
    key={resetKey}
    ref={inputFileRef}
    type="file"
    style={{ display: 'none' }}
    onChange={handleOnChange}
    {...props}
  />
  {
    React.cloneElement(children, {
      onClick: handleOnClickUpload,
    })
  }
</>

所以我們透過 React.cloneElement 給 children 一個 onClick 事件,這個 onClick 事件會藉由 useRef 來觸發 <input type="file" /> 的點擊:

const inputFileRef = useRef();

const handleOnClickUpload = () => {
  inputFileRef.current.click();
};

在 input 被點擊之後,會跳出一個如下的選取視窗,於是我們就能夠開始選擇我們要上傳的檔案

在選擇了我們要上傳的檔案之後,input 的 onChange 事件就會被觸發(這邊的觸發如上述程式碼,我們用 handleOnChange 來接),因此我們就能夠呼叫外部透過 props 傳入的 onChange callback 來取得被選取的檔案了:

const handleOnChange = (event) => {
  if (typeof onChange === 'function') {
    onChange(event?.target?.files);
  }
};

到目前為止,我希望做到的 Upload 小元件就已經完成了!

當然,實務上的 Upload 可能沒有這麼單純,或許我們會蠻需要一些附加功能,但因為即使是同一個網站,光是上傳圖片也會有許多不同的情境,因此我希望把這些附加功能再另外包一層來做,下面舉一些上傳的情境。

  1. 選取欲上傳的檔案之後能夠預覽檔案詳請,當反悔時,能夠透過重設按鈕清除欲上傳的內容

首先如過想要預覽檔案詳情,在我們元件分析時就已經有提過,我們可以透過 onChange 觸發時拿到的 event.target.files 來把詳情取出來,files 的結構如下:

再來,我們要清空被選取的檔案時,上面元件分析時也提過三個方法。因為這邊我們想要透過一個不被 Upload 元件包覆住的按鈕來清空 input 的內容,所以我們選則上述清空方法的方法二,藉由 React 框架的特性,我們強制改變 input 的 key 來強迫更新 input element,藉此達成清空 input 選取內容的效果。

因此,我們需要在 Upload 元件中給他一個 props resetKey,並藉由 重設按鈕 來改變這個 resetKey

<input
  key={resetKey}
  {...props}
/>
  1. 上傳圖片前能夠先預覽

預覽的功能我們在上面元件分析也已經詳細講解,透過 FileReader 將圖片讀取出來成 base64 string ,並放入 img src 即可:

<Upload
  {...args}
  resetKey={resetKey}
  onChange={handleOnPreview}
>
  <Button
    variant="outlined"
    startIcon={<CloudUploadIcon />}
  >
    上傳圖片
  </Button>
</Upload>
<img src={imageSrc} alt="" style={{ marginTop: 20 }} />
const handleOnPreview = (files) => {
  const file = files[0];
  const reader = new FileReader();
  reader.addEventListener('load', () => {
    // convert image file to base64 string
    setImageSrc(reader.result);
  }, false);

  if (file) {
    reader.readAsDataURL(file);
  }
};

  1. 選取多個檔案

要選取多個檔案,我們一樣用 input file 的原生屬性 multiple ,將其設為 true 之後我們就能夠在選擇視窗當中一次選取多個檔案

由於在選擇多個檔案之後,files 就能夠一次拿到多個檔案的詳情資料,所以我們就能夠按照自己喜歡的樣式來預覽以及管理,如下是一個簡單的示意範例:

  1. 照片牆

當然透過我們的 Upload 元件也能夠做到如下的照片牆功能,首先我們可以看到,藉由改變 children 我們能夠把上傳按鈕很容易的客製成虛線方框的可點擊區域。

再來我們一樣用老套的方式,藉由 onChange 事件我們取得選取的圖片檔案,並把這些檔案存進去 React 的 state 當中,這樣我們就能夠透過 React 來管理這些上傳後的圖片了:


Upload 元件原始碼:
Source code

Upload stories 原始碼:
Source code

Storybook:
Upload


參考

Adding a click handler to React children for methods on the child component
https://stackoverflow.com/questions/51957614/adding-a-click-handler-to-react-children-for-methods-on-the-child-component


上一篇
【Day08】數據輸入元件 - Rate
下一篇
【Day10】數據展示元件 - Chip / Tag
系列文
30 天擁有一套自己手刻的 React UI 元件庫30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言