【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


<<:  Day7 Map and Struct

>>:  DAY10 - 如何挑选自学的教材

17 - Visual Studio Code - 代码编辑器与它的插件

一般功能丰富的 IDE ,都会针对它所支援的语言提供许多强大的辅助功能,例如 PyCharm ,但它...

[Day 15] 资料产品生命周期管理-预测模型

尽管都是模型,但预测模型目的在於预测未来,所以开发方式也会和描述型模型有所差异。 Initiatio...

2022新年挑战 - 7 days for Javascript(Day 1 - Developer Set Up)

因为在工作上, 基本上碰不到Javascript, 感觉再不复习一下, 就要忘光光了 (汗) 所以决...

Day14 实作文章预览功能

接下来我们会开始实作各个页面的逻辑,每个页面需要的资料不一样,适用的渲染模式也不一样,於是今天我们会...

Day12 再靠近一点点 就不闪躲

Cursor and Zoom-in 今天继续增加图表功能,其中两个很常需要的功能就是游标和区域放...