五分钟带你封装 Upload 组件

1,104 阅读9分钟

因为前后端分离以后,很多前端同学因为没有接触过后端代码,导致后端代码对他们来说就是一个黑盒子,让他们困惑不堪,当接口出了问题以后,前后端相互扯皮!就因为不懂后端代码,就要被这样欺负吗?我不服气,那就奋发图强自己写个接口,好好玩玩,看看后端到底有啥可神秘的?

本文讲述的主要内容是 ReactUpload 的封装,后端用的是 Express 实现,当然你也可以把这套理念移植到 Vue 下面封装它的 Upload。内容主打一个轻松愉快,为了要大家快速掌握封装组件的核心,我没有对代码做任何边界处理,全部以清晰可见的核心代码为主。

文章主要分为三部分

1.自己写接口,好好体验下 Antd 下面的 Upload 组件,

2.封装自己的 Upload 组件

3.总结封装全过程

一. 全新体验前后端上传文件

1. 创建项目:

npx create-vite

在创建项目的时候直接选择:reacttypescript 就好了。创建好以后,执行命令,启动项目。

npm install 
npm run dev

2. 引入 antd 试试他的 upload

npm install antd -S

复制upload的基础代码粘贴到app里面

import React from 'react';
import { UploadOutlined } from '@ant-design/icons';
import type { UploadProps } from 'antd';
import { Button,  Upload, message } from 'antd';

const props: UploadProps = {
  name: 'file',
  action: 'https://run.mocky.io/v3/435e224c-44fb-4773-9faf-380c5e6a2188',
  headers: {},
  onChange(info) {
    if (info.file.status !== 'uploading') {
      console.log(info.file, info.fileList);
    }
    if (info.file.status === 'done') {
      message.success(`${info.file.name} file uploaded successfully`);
    } else if (info.file.status === 'error') {
      message.error(`${info.file.name} file upload failed.`);
    }
  },
};

const App: React.FC = () => (
  <Upload {...props}>
    <Button icon={<UploadOutlined />}>Click to Upload</Button>
  </Upload>
);

export default App;

image.png

如果是写到这个假地址里面去,和我们平常的开发不就一样了么,有啥好玩的?你说是不是?它依旧对我来说是一个黑盒子,我哪知道文件去哪里了,它内部到底是怎么运作的?看下面,写个服务,就是这么简单

3. 在根目录下面创建 server,写后端代码

image.png

命令行运行代码:

npm i express multer cors fs -D

把他们都安装好以后,执行命令,启动服务。

 node ./server/index.js

解释下上面的代码:

  1. 因为我们前端的服务端口是5173,后端的端口是3002,是不是就存在跨域问题,跨域有2个解决办法,一个是在接口里面写上:res.setHeader('Access-Control-Allow-Origin', '*');另一个是引入cors

  2. 当后端拿到文件以后,总的存储到一个地方去吧,怎么存储就是multer该做的事情了, multer({ dest: 'uploads/'})

  3. 如果直接上传上去的话,文件名是一个hash值,我们用fs把他转化成原来的名字: fs.renameSync(oldName, newName)

4.接入接口

在app.tsx的upload组件的action里面接入我们自己的接口

image.png

5.测试

image.png

进服务端看看

image.png

它的意思就是找不到uploads/目录,在代码里面我们用multer指定了文件的去处,但是我们没有创建这个文件夹,现在创建一个再测试看看

image.png

测试

image.png

看看文件在不在

image.png

神奇不神奇?

测试下中文文件看看

image.png

image.png

处理下看看: 在Express中使用中间件multer处理文件上传时,可以设置filename属性,并通过iconv-lite模块将非UTF-8编码的文件名转换为UTF-8。

image.png

启动服务,测试如下:

image.png

看下uploads文件夹里面有没有美女图片?

image.png

现在下载的文件不但文件名是对的,连文件内容,还有扩展名都有了。接下来我们就用这个node服务作为后端,处理我们封装的 Upload

二. 封装自己的 Upload

第一步,引入 input 标签

封装的第一步就是创建 Upload 文件夹,从<input type="file"/>入手,比如:

image.png

上传成功,就说明我们的思路是对的,然后在这个基础上,一步一步的完善他的内容要他变得和 antdUpload 一模一样,你说疯狂不?

上面代码长这样,就一个原生态的上传,是不是丑了,我们做的第一件事情,就是改变他的样子。

image.png

首先App.tsx里面的 Upload 组件里面传入了一个 Button 作为他的 children,现在我们的目标是 Children 传入什么,Upload 就显示什么。同时还要把原来的文件上传器给隐藏掉。隐藏掉 input 以后,我要实现,点击了children 以后,出现上传文件时候的文件选择框。此时就用到 ref,通过 ref 拿到 input 然后执行input 的 click 事件,是不是就实现了基本功能?

import {ChangeEvent, PropsWithChildren, useRef } from 'react'
import axios from 'axios'
import './index.css';

interface IProps  extends PropsWithChildren {
}

export const Upload = (props: IProps) => {
    const {
        children,
      } = props

    const fileRef = useRef<HTMLInputElement>(null);

    const handleChange = () => {
        fileRef.current && fileRef.current.click()
    }

    const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
      // 获取file
      let file = e?.target?.files?.[0] as File;
      let formdata = new FormData()
      formdata.append('file', file)
  
      axios.post('http://localhost:3002/upload', formdata, {
          headers: {
            'Content-Type': 'multipart/form-data;charset=UTF-8'
          }
        })
        .then(response => {
          console.log('结果', response);
        })
        .catch(() => {
          console.log('Error uploading file');
        });
    }
    return (
      <div className="upload-container">
          <div className="upload-btn" onClick={handleChange}>
              {children}
              <input
                  className="upload-file"
                  type="file"
                  onChange={handleFileChange}
                  ref={fileRef}
              />
          </div>
      </div>
    )
}

export default Upload;

Upload组件

image.png

App.tsx

image.png

测试

image.png

测试代码以后,你会发现执行成功了,但是差的也还挺远的,比如页面上没有显示出来上传的文件,支持多个文件上传,上传文件类型的限制等等,慢慢来不着急,其实剩下的都只是加属性,很简单的。

第二步 添加属性

接下来我们添加这些属性:

interface IProps  extends PropsWithChildren {
    action: string; // 接口地址
    headers?: Record<string, any>; // 请求头
    name?: string; // 表单的名字,在formData的时候使用
    data?: Record<string, any>; //带给组件的数据
    accept?: string; // 文件接受的数据类型
    multiple?: boolean; //多文件上传
}

image.png

import {ChangeEvent, PropsWithChildren, useRef } from 'react'
import axios from 'axios'
import './index.css';

interface IProps  extends PropsWithChildren {
    action: string; // 接口地址
    headers?: Record<string, any>; // 请求头
    name?: string; // 表单的名字,在formData的时候使用
    data?: Record<string, any>; //带给组件的数据
    accept?: string; // 文件接受的数据类型
    multiple?: boolean; //多文件上传
}

export const Upload = (props: IProps) => {
    const {children, action, name, headers, data, accept, multiple} = props
    const fileRef = useRef<HTMLInputElement>(null);

    const handleChange = () => {
        fileRef.current && fileRef.current.click()
    }

    const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
        // 获取file
        let file = e?.target?.files?.[0] as File;
        let formdata = new FormData()
        formdata.append(name || 'file', file)
        if (data) {
            Object.keys(data).forEach(key => {
                formdata.append(key, data[key])
            })
         } 
        axios.post(action, formdata, {
            headers: {
                ...headers,
                'Content-Type': 'multipart/form-data;charset=UTF-8'
            }
        }).then(response => {
            console.log('结果', response);
        }).catch(() => {
            console.log('Error uploading file');
        });
    }
    return (
      <div className="upload-container">
          <div className="upload-btn" onClick={handleChange}>
              {children}
              <input
                  className="upload-file"
                  type="file"
                  onChange={handleFileChange}
                  ref={fileRef}
                  accept={accept}
                  multiple={multiple}
              />
          </div>
      </div>
    )
}

export default Upload;

基本的功能都已经支持了。接下来我们添加下页面的文件列表,没有列表老感觉在耍猴,是不是?

第三步 添加回调函数

就是创建一个数组,用数据来收集所有上传进来的文件,然后把他们用 ul 平铺在页面上。在此之前我们先要把Upload的回调函数接进来。(模仿使人进步,学习的第一步就是模仿,而非创造。)

image.png

先在Upload组件里面添加下ts的类型如下:


![image.png](https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/538bd07dbbab4e9fa5ba1120bd97d8d0~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgc25vd-adpeS6hg==:q75.awebp?rk3s=f64ab15b&x-expires=1763965192&x-signature=uboAN0w7jXm%2FOM4Q3gSVZZSpDss%3D)

先接入 onChange,onSuccess,onError

image.png

我们先优化下,把代码拆分到不同的函数里面去

import {ChangeEvent, PropsWithChildren, useRef, useState } from 'react'
import axios from 'axios'
import './index.css';

interface IProps  extends PropsWithChildren {
    action: string; // 接口地址
    headers?: Record<string, any>; // 请求头
    name?: string; // 表单的名字,在formData的时候使用
    data?: Record<string, any>; //带给组件的数据
    accept?: string; // 文件接受的数据类型
    multiple?: boolean; //多文件上传
    beforeUpload?: (file: File)=> boolean | Promise<File>;
    onProgress?: (percentage: number, file: File) => void;//是进度更新时的回调,可以拿到进度
    onSuccess?: (err: any, file: File) => void; //是上传成功的回调。
    onError?: (err: any, file: File)=>void;//是上传失败的回调。
    onChange?: (file: File) => void; //上传状态改变时的回调。
}

export const Upload = (props: IProps) => {
    const {children, 
        action, 
        name, 
        headers, 
        data, 
        accept,
        multiple,
        beforeUpload,
        onProgress,
        onSuccess,
        onError,
        onChange,} = props
    const fileRef = useRef<HTMLInputElement>(null);
    const [ uploadList, setUploadList ] = useState<Array<File>>([]);

    const handleChange = () => {
        fileRef.current && fileRef.current.click()
    }

    const uploadFiles = (files: FileList) => {
        let postFiles = Array.from(files)
        postFiles.forEach(file => {
            post(file)
        })
    }

    const post = (file: File) => {
        const formData = new FormData()
    
        formData.append(name || 'file', file);
        if (data) {
            Object.keys(data).forEach(key => {
                formData.append(key, data[key])
            })
        } 
    
        axios.post(action, formData, {
            headers: {
                ...headers,
                'Content-Type': 'multipart/form-data'
            },
        })
      }

    const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
        // 获取file,单文件下载就是multiple 为false的时候直接取 let file = e?.target?.files?.[0] as File;
        // 多文件用 let file = e?.target?.files
        const files = e.target.files
        if(!files) {
            return
        }

        uploadFiles(files)
    }

    return (
        <div className="upload-container">
            <div className="upload-btn" onClick={handleChange}>
                {children}
                <input
                    className="upload-file"
                    type="file"
                    onChange={handleFileChange}
                    ref={fileRef}
                    accept={accept}
                    multiple={multiple}
                />
            </div>
            <ul></ul>
        </div>
    )
}

export default Upload;

在此基础上,接入回调函数:

image.png

进度条用的是axios的 onUploadProgress,onUploadProgress 是 Axios 库中用于处理 HTTP 请求的一个配置选项之一,onUploadProgress 允许指定一个回调函数,在上传进度发生变化时被调用。这个回调函数接收一个进度事件对象作为参数,可以从中获取上传进度的信息。这对于跟踪文件上传的进度很有用,特别是在需要显示进度条时。

还有一个是:beforeUpload, 他的任务就是在上传文件之前去做一些事情。

image.png

接入app.tsx

image.png

测试:先把网速调低一些

image.png

image.png

完整代码如下:

// Upload/index.tsx
import {ChangeEvent, PropsWithChildren, useRef, useState } from 'react'
import axios from 'axios'
import './index.css';

export interface UploadProps  extends PropsWithChildren {
    action: string; // 接口地址
    headers?: Record<string, any>; // 请求头
    name?: string; // 表单的名字,在formData的时候使用
    data?: Record<string, any>; //带给组件的数据
    accept?: string; // 文件接受的数据类型
    multiple?: boolean; //多文件上传
    beforeUpload?: (file: File)=> boolean | Promise<File>;
    onProgress?: (percentage: number, file: File) => void;//是进度更新时的回调,可以拿到进度
    onSuccess?:  (data: any, file: File) => void; //是上传成功的回调。
    onError?: (err: any, file: File)=>void;//是上传失败的回调。
    onChange?: (file: File) => void; //上传状态改变时的回调。
}

export const Upload = (props: UploadProps) => {
    const {children, 
        action, 
        name, 
        headers, 
        data, 
        accept,
        multiple,
        beforeUpload,
        onSuccess,
        onError,
        onChange,
        onProgress,
    } = props
    const fileRef = useRef<HTMLInputElement>(null);

    const handleChange = () => {
        fileRef.current && fileRef.current.click()
    }

    const uploadFiles = (files: FileList) => {
        let postFiles = Array.from(files)
        postFiles.forEach(file => {
            if(!beforeUpload){
                post(file);
                return;
            }

            const result = beforeUpload(file)

            if (result && result instanceof Promise) {
                result.then(processedFile => {
                    post(processedFile)
                })

                return;
            }

            post(file);
       
        })
    }

    const post = (file: File) => {
        const formData = new FormData()
        formData.append(name || 'file', file);

        if (data) {
            Object.keys(data).forEach(key => {
                formData.append(key, data[key])
            })
        } 
        //进度直接用 axios 的 onUploadProgress 
        axios.post(action, formData, {
            headers: {
                ...headers,
                'Content-Type': 'multipart/form-data'
            },
            onUploadProgress: (e) => {
                let percentage = Math.round((e.loaded * 100) / e.total!) || 0;
                if (percentage < 100) {
                    if (onProgress) {
                        onProgress(percentage, file)
                    }
                }
            }
        }).then((res)=>{
            onSuccess?.(res.data, file)
            onChange?.(file)
        }).catch((err)=>{
            onError?.(err, file)
            onChange?.(file)
        })
      }

    const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
        // 获取file,单文件下载就是multiple 为false的时候直接取 let file = e?.target?.files?.[0] as File;
        // 多文件用 let file = e?.target?.files
        const files = e.target.files
        if(!files) {
            return
        }

        uploadFiles(files)
    }

    return (
        <div className="upload-container">
            <div className="upload-btn" onClick={handleChange}>
                {children}
                <input
                    className="upload-file"
                    type="file"
                    onChange={handleFileChange}
                    ref={fileRef}
                    accept={accept}
                    multiple={multiple}
                />
            </div>
            <ul></ul>
        </div>
    )
}

export default Upload;

App.tsx下面的代码如下:

import React from 'react';
import { UploadOutlined } from '@ant-design/icons';
// import type { UploadProps } from 'antd';
import { Button, message } from 'antd';
import Upload, {UploadProps} from './components/Upload'

const props: UploadProps = {
  name: 'file', //表单名字
  action: 'http://localhost:3002/upload',//接口
  headers: {},//请求头
  data: {},
  multiple: true,
  beforeUpload(file) {
    if(file.name.includes('1.image')) {
      return false;
    }
    return true;
  },
  onSuccess(data: any) {
    console.log('onSuccess', data);
  },
  onError(err) {
    console.log('onError', err);
  },
  onProgress(percentage) {
    console.log('onProgress', percentage);
  },
  onChange(file) {
    console.log('onChange', file);
  }
};

const App: React.FC = () => (
  <Upload {...props}>
    <Button icon={<UploadOutlined />}>上传</Button>
  </Upload>
);

export default App;

第四步 添加文件列表

上传文件以后,肯定要在页面上显示咱们都传了什么文件过来,是不是?文件列表写出来是不是就更像那么回事了?抄一下antd的样子,我们实现一个和它一样的东西出来。

image.png

我们可以用 mock 的数据写一个 UploadList 组件出来,然后够再用 stateUpload组件相互连接,开干:

// UploadList.tsx

import { FC } from 'react'
import { Progress } from 'antd';
import { CheckOutlined, CloseOutlined, DeleteOutlined, FileOutlined, LoadingOutlined } from '@ant-design/icons';

export interface UploadFile {
  uid: string;
  size: number;
  name: string;
  status?: 'ready' | 'uploading' | 'success' | 'error';
  percent?: number;
  raw?: File;
  response?: any;
  error?: any;
}

interface UploadListProps {
    fileList: UploadFile[];
  }

export const UploadList: FC<UploadListProps> = (props) => {
  const {
    fileList,
  } = props;

  return (
    <ul className="upload-list">
      {
        fileList.map(item => {
            return (
                <li className={`upload-list-item upload-list-item-${item.status}`} key={item.uid}>
                    <span className='file-name'>
                        {
                            (item.status === 'uploading' || item.status === 'ready') && 
                                <LoadingOutlined />
                        }
                        {
                            item.status === 'success' && 
                                <CheckOutlined />
                        }
                        {
                            item.status === 'error' && 
                                <CloseOutlined />
                        }
                        {item.name}
                    </span>
                    <span className="file-actions">
                        <DeleteOutlined />
                    </span>
                        {
                            item.status === 'uploading' && <Progress percent={item.percent || 0}/>
                        }
                </li>
            )
        })
      }
    </ul>
  )
}

export default UploadList;

image.png

测试看看

image.png

写个样式:之前样式用的css,我们改成scss 好写一些: npm i sass -D ,在vite的框架里面已经帮我们集成了sass需要的环境,比如node-sass,我们就不用再安装它了,直接改改文件名写代码就好。

// index.scss
.upload-btn {
    display: inline-block;
}
.upload-file {
    display: none;
}

.upload-list {
    margin: 0;
    padding: 0;
    list-style-type: none;
}
.upload-list-item {
    margin-top: 5px;
    font-size: 12px;
    line-height: 2em;

    box-sizing: border-box;
    min-width: 200px;

    position: relative;

    &-success {
        color: #1677ff;
    }
    
    &-error {
        color: #ff4d4f;
    }

    .file-name {
        .anticon {
            margin-right: 10px;
        }
    }

    .file-actions {
        display: none;

        position: absolute;
        right: 7px;
        top: 0;

        cursor: pointer;
    }

    &:hover {
        .file-actions {
            display: block;
        }
    }
}


刷新页面如下:

image.png

已经很接近antd的案例了,对不?还缺少个删除

image.png

image.png

测试如下:

image.png

现在做的事情就是接入 Upload 组件,删掉之前的 fileListmock 数据,然后重新定义。const [ fileList, setFileList ] = useState<Array<UploadFile>>([]);

我们要在什么时候加入fileList,有三个地方,第一个是发起请求前就往 fileList里面加入file数据,状态是:uploading,然后当文件上传成功以后,在执行 onSuccess 之前修改 fileList 对应的状态为:success, 当上传失败以后,在onError 之前,修改状态为 error。

image.png

image.png

测试看看:

image.png

完整代码如下:

// Upload/index.tsx
import {ChangeEvent, PropsWithChildren, useRef, useState } from 'react';
import UploadLost, {UploadFile} from './UploadList'
import {  InboxOutlined } from '@ant-design/icons';
import Dragger from './Dragger'
import axios from 'axios';
import './index.scss';

export interface UploadProps  extends PropsWithChildren {
    action: string; // 接口地址
    headers?: Record<string, any>; // 请求头
    name?: string; // 表单的名字,在formData的时候使用
    data?: Record<string, any>; //带给组件的数据
    accept?: string; // 文件接受的数据类型
    multiple?: boolean; //多文件上传
    beforeUpload?: (file: File)=> boolean | Promise<File>;
    onProgress?: (percentage: number, file: File) => void;//是进度更新时的回调,可以拿到进度
    onSuccess?:  (data: any, file: File) => void; //是上传成功的回调。
    onError?: (err: any, file: File)=>void;//是上传失败的回调。
    onChange?: (file: File) => void; //上传状态改变时的回调。
    drag?: boolean;
    onRemove?: (file: UploadFile) => void;
}

export const Upload = (props: UploadProps) => {
    const {children, 
        action, 
        name, 
        headers, 
        data, 
        accept,
        multiple,
        beforeUpload,
        onSuccess,
        onError,
        onChange,
        onProgress,
        onRemove,
        drag
    } = props
    const fileRef = useRef<HTMLInputElement>(null);
    const [ fileList, setFileList ] = useState<Array<UploadFile>>([]);

    const handleChange = () => {
        fileRef.current && fileRef.current.click()
    }

    const uploadFiles = (files: FileList) => {
        let postFiles = Array.from(files)
        postFiles.forEach(file => {
            if(!beforeUpload){
                post(file);
                return;
            }

            const result = beforeUpload(file)

            if (result && result instanceof Promise) {
                result.then(processedFile => {
                    post(processedFile)
                })

                return;
            }

            post(file);
       
        })
    }

    const handleRemove = (file: UploadFile) => {
        setFileList((prevList) => {
          return prevList.filter(item => item.uid !== file.uid)
        })
        if (onRemove) {
          onRemove(file)
        }
    }

    const post = (file: File) => {
        let uploadFile: UploadFile = {
            uid: Date.now() + 'upload-file',
            status: 'ready',
            name: file.name,
            size: file.size,
            percent: 0,
            raw: file
        }
        setFileList(prevList => {
            return [uploadFile, ...prevList]
        })

        const formData = new FormData()
        formData.append(name || 'file', file);

        if (data) {
            Object.keys(data).forEach(key => {
                formData.append(key, data[key])
            })
        } 
        //进度直接用 axios 的 onUploadProgress 
        axios.post(action, formData, {
            headers: {
                ...headers,
                'Content-Type': 'multipart/form-data'
            },
            onUploadProgress: (e) => {
                let percentage = Math.round((e.loaded * 100) / e.total!) || 0;
                if (percentage < 100) {
                    updateFileList(uploadFile, { percent: percentage, status: 'uploading'});
                    if (onProgress) {
                        onProgress(percentage, file)
                    }
                }
            }
        }).then((res)=>{
            updateFileList(uploadFile, {status: 'success', response: res.data})
            onSuccess?.(res.data, file)
            onChange?.(file)
        }).catch((err)=>{
            updateFileList(uploadFile, { status: 'error', error: err})
            onError?.(err, file)
            onChange?.(file)
        })
      }

      const updateFileList = (updateFile: UploadFile, updateObj: Partial<UploadFile>) => {
        setFileList(prevList => {
          return prevList.map(file => {
            if (file.uid === updateFile.uid) {
              return { ...file, ...updateObj }
            } else {
              return file
            }
          })
        })
      }

    const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
        // 获取file,单文件下载就是multiple 为false的时候直接取 let file = e?.target?.files?.[0] as File;
        // 多文件用 let file = e?.target?.files
        const files = e.target.files
        if(!files) {
            return
        }

        uploadFiles(files)
    }

    return (
        <div className="upload-container">
            <div className="upload-btn" onClick={handleChange}>
            {
                drag ? <Dragger onFile={(files) => {uploadFiles(files)}}>
                    <p>
                      <InboxOutlined style={{fontSize: '50px'}}/>
                    </p>
                    <p>点击或者拖拽文件到此处</p>
                    </Dragger>
                    : children
            }
                <input
                    className="upload-file"
                    type="file"
                    onChange={handleFileChange}
                    ref={fileRef}
                    accept={accept}
                    multiple={multiple}
                />
            </div>
            <UploadLost 
                fileList={fileList}
                onRemove={handleRemove}
            />
        </div>
    )
}

export default Upload;

第五步 拖拽上传文件

Upload 文件夹下面创建 Dragger.tsx.

1.上传文件的时候,样式会发生频繁的变化,所以需要引入classnames: npm i classNames from "classnames"

2.拖拽我们用的是 div 的

onDragOver={e => { handleDrag(e, true)}}
onDragLeave={e => { handleDrag(e, false)}} 
onDrop={handleDrop}

具体代码如下:

import { FC, useState, DragEvent, PropsWithChildren } from 'react'
import classNames from 'classnames'

interface DraggerProps extends PropsWithChildren{
  onFile: (files: FileList) => void;
}

export const Dragger: FC<DraggerProps> = (props) => {

  const { onFile, children } = props

  const [ dragOver, setDragOver ] = useState(false)

  const cs = classNames('upload-dragger', {
    'is-dragover': dragOver
  })

  const handleDrop = (e: DragEvent<HTMLElement>) => {
    e.preventDefault()
    setDragOver(false)
    onFile(e.dataTransfer.files)
  }
  
  const handleDrag = (e: DragEvent<HTMLElement>, over: boolean) => {
    e.preventDefault()
    setDragOver(over)
  }

  return (
    <div 
      className={cs}
      onDragOver={e => { handleDrag(e, true)}}
      onDragLeave={e => { handleDrag(e, false)}}
      onDrop={handleDrop}
    >
      {children}
    </div>
  )
}

export default Dragger;

在index.css里面加上样式

.upload-dragger {
    background: #eee;
    border: 1px dashed #aaa;
    border-radius: 4px;
    cursor: pointer;
    padding: 20px;
    width: 200px;
    height: 100px;
    text-align: center;

    &.is-dragover {
      border: 2px dashed blue;
      background: rgba(blue, .3);
    }
}

在Upload/index.js里面引入拖拽

image.png

测试如下:

image.png

完整代码如下:

服务端:server、index.js

import express from 'express';
import multer from 'multer';
import cors from 'cors';
import iconv from 'iconv-lite';

const app = express()
app.use(cors());
// app用了cors(),就不用每个接口带 res.setHeader('Access-Control-Allow-Origin', '*'); 了
//const upload = multer({ dest: 'uploads/'})

const upload = multer({
  storage: multer.diskStorage({
    destination: function (req, file, cb) {
      cb(null, 'uploads/')
    },
    filename: function (req, file, cb) {
      // 假设上传的文件名是GBK编码
      let filename = iconv.decode(file.originalname, 'UTF-8');
      console.log(filename)
      cb(null, filename);
    }
  })
});

app.post('/upload', upload.single('file'), function (req, res, next) {
    res.send(JSON.stringify({
      code: 200,
      message: 'success'
    }));
})

app.listen(3002, ()=>{
    console.log('接口是:3002')
});

App.tsx

import React from 'react';
import { UploadOutlined } from '@ant-design/icons';
// import type { UploadProps } from 'antd';
import { Button, message } from 'antd';
import Upload, {UploadProps} from './components/Upload'

const props: UploadProps = {
  name: 'file', //表单名字
  action: 'http://localhost:3002/upload',//接口
  headers: {},//请求头
  data: {},
  multiple: true,
  drag: true,
  beforeUpload(file) {
    if(file.name.includes('1.image')) {
      return false;
    }
    return true;
  },
  onSuccess(data: any) {
    console.log('onSuccess', data);
  },
  onError(err) {
    console.log('onError', err);
  },
  onProgress(percentage) {
    console.log('onProgress', percentage);
  },
  onChange(file) {
    console.log('onChange', file);
  }
};

const App: React.FC = () => (
  <Upload {...props}>
    <Button icon={<UploadOutlined />}>Upload</Button>
  </Upload>
);

export default App;

Upload/index.tsx

import {ChangeEvent, PropsWithChildren, useRef, useState } from 'react';
import UploadLost, {UploadFile} from './UploadList'
import {  InboxOutlined } from '@ant-design/icons';
import Dragger from './Dragger'
import axios from 'axios';
import './index.scss';

export interface UploadProps  extends PropsWithChildren {
    action: string; // 接口地址
    headers?: Record<string, any>; // 请求头
    name?: string; // 表单的名字,在formData的时候使用
    data?: Record<string, any>; //带给组件的数据
    accept?: string; // 文件接受的数据类型
    multiple?: boolean; //多文件上传
    beforeUpload?: (file: File)=> boolean | Promise<File>;
    onProgress?: (percentage: number, file: File) => void;//是进度更新时的回调,可以拿到进度
    onSuccess?:  (data: any, file: File) => void; //是上传成功的回调。
    onError?: (err: any, file: File)=>void;//是上传失败的回调。
    onChange?: (file: File) => void; //上传状态改变时的回调。
    drag?: boolean;
    onRemove?: (file: UploadFile) => void;
}

export const Upload = (props: UploadProps) => {
    const {children, 
        action, 
        name, 
        headers, 
        data, 
        accept,
        multiple,
        beforeUpload,
        onSuccess,
        onError,
        onChange,
        onProgress,
        onRemove,
        drag
    } = props
    const fileRef = useRef<HTMLInputElement>(null);
    const [ fileList, setFileList ] = useState<Array<UploadFile>>([]);

    const handleChange = () => {
        fileRef.current && fileRef.current.click()
    }

    const uploadFiles = (files: FileList) => {
        let postFiles = Array.from(files)
        postFiles.forEach(file => {
            if(!beforeUpload){
                post(file);
                return;
            }

            const result = beforeUpload(file)

            if (result && result instanceof Promise) {
                result.then(processedFile => {
                    post(processedFile)
                })

                return;
            }

            post(file);
       
        })
    }

    const handleRemove = (file: UploadFile) => {
        setFileList((prevList) => {
          return prevList.filter(item => item.uid !== file.uid)
        })
        if (onRemove) {
          onRemove(file)
        }
    }

    const post = (file: File) => {
        let uploadFile: UploadFile = {
            uid: Date.now() + 'upload-file',
            status: 'ready',
            name: file.name,
            size: file.size,
            percent: 0,
            raw: file
        }
        setFileList(prevList => {
            return [uploadFile, ...prevList]
        })

        const formData = new FormData()
        formData.append(name || 'file', file);

        if (data) {
            Object.keys(data).forEach(key => {
                formData.append(key, data[key])
            })
        } 
        //进度直接用 axios 的 onUploadProgress 
        axios.post(action, formData, {
            headers: {
                ...headers,
                'Content-Type': 'multipart/form-data'
            },
            onUploadProgress: (e) => {
                let percentage = Math.round((e.loaded * 100) / e.total!) || 0;
                if (percentage < 100) {
                    updateFileList(uploadFile, { percent: percentage, status: 'uploading'});
                    if (onProgress) {
                        onProgress(percentage, file)
                    }
                }
            }
        }).then((res)=>{
            updateFileList(uploadFile, {status: 'success', response: res.data})
            onSuccess?.(res.data, file)
            onChange?.(file)
        }).catch((err)=>{
            updateFileList(uploadFile, { status: 'error', error: err})
            onError?.(err, file)
            onChange?.(file)
        })
      }

      const updateFileList = (updateFile: UploadFile, updateObj: Partial<UploadFile>) => {
        setFileList(prevList => {
          return prevList.map(file => {
            if (file.uid === updateFile.uid) {
              return { ...file, ...updateObj }
            } else {
              return file
            }
          })
        })
      }

    const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
        // 获取file,单文件下载就是multiple 为false的时候直接取 let file = e?.target?.files?.[0] as File;
        // 多文件用 let file = e?.target?.files
        const files = e.target.files
        if(!files) {
            return
        }

        uploadFiles(files)
    }

    return (
        <div className="upload-container">
            <div className="upload-btn" onClick={handleChange}>
            {
                drag ? <Dragger onFile={(files) => {uploadFiles(files)}}>
                    <p>
                      <InboxOutlined style={{fontSize: '50px'}}/>
                    </p>
                    <p>点击或者拖拽文件到此处</p>
                    </Dragger>
                    : children
            }
                <input
                    className="upload-file"
                    type="file"
                    onChange={handleFileChange}
                    ref={fileRef}
                    accept={accept}
                    multiple={multiple}
                />
            </div>
            <UploadLost 
                fileList={fileList}
                onRemove={handleRemove}
            />
        </div>
    )
}

export default Upload;

Upload/index.scss

.upload-btn {
    display: inline-block;
}
.upload-file {
    display: none;
}

.upload-list {
    margin: 0;
    padding: 0;
    list-style-type: none;
}
.upload-list-item {
    margin-top: 5px;
    font-size: 12px;
    line-height: 2em;

    box-sizing: border-box;
    min-width: 200px;

    position: relative;

    &-success {
        color: #1677ff;
    }
    
    &-error {
        color: #ff4d4f;
    }

    .file-name {
        .anticon {
            margin-right: 10px;
        }
    }

    .file-actions {
        display: none;

        position: absolute;
        right: 7px;
        top: 0;

        cursor: pointer;
    }

    &:hover {
        .file-actions {
            display: block;
        }
    }
}

.upload-dragger {
    background: #eee;
    border: 1px dashed #aaa;
    border-radius: 4px;
    cursor: pointer;
    padding: 20px;
    width: 200px;
    height: 100px;
    text-align: center;

    &.is-dragover {
      border: 2px dashed blue;
      background: rgba(blue, .3);
    }
}

Upload/UploadList.tsx

import { FC } from 'react'
import { Progress } from 'antd';
import { CheckOutlined, CloseOutlined, DeleteOutlined, FileOutlined, LoadingOutlined } from '@ant-design/icons';

export interface UploadFile {
  uid: string;
  size: number;
  name: string;
  status?: 'ready' | 'uploading' | 'success' | 'error';
  percent?: number;
  raw?: File;
  response?: any;
  error?: any;
}

interface UploadListProps {
    fileList: UploadFile[];
    onRemove: (file: UploadFile)=>void;
  }

export const UploadList: FC<UploadListProps> = (props) => {
  const {
    fileList,
    onRemove
  } = props;

  return (
    <ul className="upload-list">
      {
        fileList.map(item => {
            return (
                <li className={`upload-list-item upload-list-item-${item.status}`} key={item.uid}>
                    <span className='file-name'>
                        {
                            (item.status === 'uploading' || item.status === 'ready') && 
                                <LoadingOutlined />
                        }
                        {
                            item.status === 'success' && 
                                <CheckOutlined />
                        }
                        {
                            item.status === 'error' && 
                                <DeleteOutlined onClick={() => { onRemove(item)}}/>
                        }
                        {item.name}
                    </span>
                    <span className="file-actions">
                        <DeleteOutlined />
                    </span>
                        {
                            item.status === 'uploading' && <Progress percent={item.percent || 0} size="small" />
                        }
                </li>
            )
        })
      }
    </ul>
  )
}

export default UploadList;

Upload/Dragger.tsx

import { FC, useState, DragEvent, PropsWithChildren } from 'react'
import classNames from 'classnames'

interface DraggerProps extends PropsWithChildren{
  onFile: (files: FileList) => void;
}

export const Dragger: FC<DraggerProps> = (props) => {

  const { onFile, children } = props

  const [ dragOver, setDragOver ] = useState(false)

  const cs = classNames('upload-dragger', {
    'is-dragover': dragOver
  })

  const handleDrop = (e: DragEvent<HTMLElement>) => {
    e.preventDefault()
    setDragOver(false)
    onFile(e.dataTransfer.files)
  }
  
  const handleDrag = (e: DragEvent<HTMLElement>, over: boolean) => {
    e.preventDefault()
    setDragOver(over)
  }

  return (
    <div 
      className={cs}
      onDragOver={e => { handleDrag(e, true)}}
      onDragLeave={e => { handleDrag(e, false)}}
      onDrop={handleDrop}
    >
      {children}
    </div>
  )
}

export default Dragger;