动手自己实现一个React拖拽上传组件

2,809 阅读4分钟

前言

自己想写一个拖拽上传组件的原因是现在github上star多的仓库都不满足自己的需求。有的是选择新图片之后会覆盖已添加的图片,有的是没用预览功能,并且不能删除已添加的图片。以上种种原因使我自己想要做一个拖拽上传组件,包含的功能有:

  • 拖拽上传
  • 多次添加文件,不会覆盖原有文件
  • 预览文件,显示文件大小
  • 支持删除
  • 文件大小限制
  • 响应式

最终效果预览:

image-20220128112126510.png

环境搭建

创建 react 项目

使用 npx creat-react-app 创建react项目。

添加依赖

本项目使用 styled-components 书写css 样式

项目结构

- file-upload-component
    - src
        - App.js
        - FileUpload.jsx
        - FileUpload.styles.js
        - index.js

代码

state

本项目使用函数式组件,组件中的状态保存用户上传的文件。

FileUpload.jsx 中,添加如下代码:

import React, { useState } from "react";
​
const FileUpload = () => {
  const [files, setFiles] = useState({});
​
  return (
   <div></div>
  )
}
​
export default FileUpload;

使用对象保存文件的原因是对象更容易添加或删除文件,并且确保不会添加重复文件。

useRef hook

由于原生 html 的上传组件样式太丑,所以在本项目是通过一个按钮来打开它,因此我们需要 useRef hook 来引用 dom。

import React, { useState, useRef } from "react";
​
const FileUpload = (props) => {
  const fileInputField = useRef(null);
  const [files, setFiles] = useState({});
​
  return (
   <input type="file" ref={fileInputField} />
  )
}
​
export default FileUpload;

props

本组件需要以下prop:

  • maxFileSizeInBytes 限制上传文件的总大小
  • updateFilesCb 回调函数,给父组件传递当前 files 状态

本组件不提供上传到服务端的功能,因此添加文件后需要将文件传递给父组件。并且由于react 组件间的单向数据流,子组件给父组件传递数据的常用方式就是回调函数。

import React, { useRef, useState } from "react";
​
const DEFAULT_MAX_FILE_SIZE_IN_BYTES = 500000;
​
const FileUpload = ({
  label,
  updateFilesCb,
  maxFileSizeInBytes = DEFAULT_MAX_FILE_SIZE_IN_BYTES,
  ...otherProps
}) => {
  const fileInputField = useRef(null);
  const [files, setFiles] = useState({});
​
  return (
   <input type="file" ref={fileInputField} />
  )
}
​
export default FileUpload;

完善 jsx

import React, { useRef, useState } from "react";

const DEFAULT_MAX_FILE_SIZE_IN_BYTES = 500000;
const KILO_BYTES_PER_BYTE = 1000;

const convertBytesToKB = (bytes) => Math.round(bytes / KILO_BYTES_PER_BYTE);

const FileUpload = ({
  label,
  updateFilesCb,
  maxFileSizeInBytes = DEFAULT_MAX_FILE_SIZE_IN_BYTES,
  ...otherProps
}) => {
  const fileInputField = useRef(null);
  const [files, setFiles] = useState({});

  return (
    <>
      <div>
        <label>{label}</label>
        <p>拖拽上传或点击以下按钮</p>
        <button type="button">
          <i className="fas fa-file-upload" />
          <span>上传{otherProps.multiple ? "文件" : "一个文件"}</span>
        </button>
        <input
          type="file"
          ref={fileInputField}
          title=""
          value=""
          {...otherProps}
        />
      </div>
      <div>
        <span>文件预览</span>
        <div>
          {Object.keys(files).map((fileName, index) => {
            let file = files[fileName];
            let isImageFile = file.type.split("/")[0] === "image";
            return (
              <div key={fileName}>
                <div>
                  {isImageFile && (
                    <img
                      src={URL.createObjectURL(file)}
                      alt={`file preview ${index}`}
                    />
                  )}
                  <div isImageFile={isImageFile}>
                    <span>{file.name}</span>
                    <span>
                      <span>{convertBytesToKB(file.size)} kb</span>
                      <i className="fas fa-trash-alt" />
                    </span>
                  </div>
                </div>
              </div>
            );
          })}
        </div>
      </div>
    </>
  );
};

export default FileUpload;

其中有几个细节需要注意:

  1. input 标签中 title="" title属性在鼠标移至input标签上时会显示,所以把title属性的内容置为空字符串。

  2. 图片预览。 为了预览图片,我们可以采用 URL.createObjectURL 方法。该方法接收一个 File类型的对象,返回该文件的一个暂时URL,我们可以把这个 url 置为 img标签的 src 属性的值。

样式

完整的样式链接:完整样式

我们把 input 标签隐藏,并使用绝对定位,将上传组件固定到整个上传框中。你可能会有疑问,文件的拖拽上传是如何实现的?实际上,input 标签默认实现了拖拽上传。我们把 input 标签固定到整个上传框中,用户拖动文件到该区域就会自动上传。

功能实现

当点击上传按钮时,打开系统的文件选择窗口,需要用到 ref.current.click()

const handleUploadBtnClick = () => {
  fileInputField.current.click();
};

处理文件上传

<FormField
  type="file"
  ref={fileInputField}
  onChange={handleNewFileUpload}
  title=""
  value=""
  {...otherProps}
/>

const handleNewFileUpload = (e) => {
    const { files: newFiles } = e.target;
    if (newFiles.length) {
        let updatedFiles = addNewFiles(newFiles);
        setFiles(updatedFiles);
        callUpdateFilesCb(updatedFiles);
    }
};

const addNewFiles = (newFiles) => {
    for (let file of newFiles) {
        if (file.size <= maxFileSizeInBytes) {
            if (!otherProps.multiple) {
                return { file };
            }
            files[file.name] = file;
        }
    }
    return { ...files };
};

const convertNestedObjectToArray = (nestedObj) =>
  Object.keys(nestedObj).map((key) => nestedObj[key]);

const callUpdateFilesCb = (files) => {
  const filesAsArray = convertNestedObjectToArray(files);
  updateFilesCb(filesAsArray);
};

你可能会注意到调用callUpdateFilesCb(updatedFiles)传递的参数是updatedFiles 而不是 files 状态中保存的值。这是因为 setFiles 是异步更新,你无法保证setFiles 先于callUpdateFilesCb 执行。

最终为们传递给父组件的文件是数组的形式。

删除文件

<RemoveFileIcon
  className="fas fa-trash-alt"
  onClick={() => removeFile(fileName)}
/>
const removeFile = (fileName) => {
  delete files[fileName];
  setFiles({ ...files });
  callUpdateFilesCb({ ...files });
};

ok,代码部分已经完成,下面来看一下如何使用。

使用

在父组件使用中使用时,为们可以向该组件传递一些属性。

比如之前提到的 maxFileSizeInBytesupdateFilesCb 等。此外,我们还可以传递 input 标签的原生属性。例如 accept="image/*" 规定imput标签只能上传图片。multiple 属性规定可以上传多个组件。

完整代码链接

React 拖拽上传组件的实现 喜欢不妨点个 star ~