IdbI

第37章—组件实战:Upload拖拽上传

上传文件是常见的需求,我们经常用 antd 的 Upload 组件来实现。

它有一个上传按钮,下面是上传的文件列表的状态:

并且,还支持拖拽上传:

这节我们就来实现下这个 Upload 组件。

npx create-vite

用 create-vite 创建个 react 项目。

去掉 index.css 和 StrictMode

然后把开发服务跑起来:

npm install
npm run dev

访问下试试:

然后我们先用下 antd 的 Upload 组件:

npm i --save antd

改下 App.tsx

import React from 'react';
import { UploadOutlined } from '@ant-design/icons';
import type { UploadProps } from 'antd';
import { Button, message, Upload } 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;

现在接口是 mock 的,这样不过瘾,我们用 express 起个服务来接收下文件。

根目录下新建 server.js

import express from 'express';
import multer from 'multer';
import cors from 'cors';

const app = express()
app.use(cors());

const upload = multer({ 
    dest: 'uploads/'
})

app.post('/upload', upload.single('file'), function (req, res, next) {
  console.log('req.file', req.file);
  console.log('req.body', req.body);

  res.end(JSON.stringify({
    message: 'success'
  }));
})

app.listen(3333);

用 express 跑服务,然后用 cors 处理跨域请求,用 multer 来接收文件。

指定 dest 为 uploads 目录。

安装依赖,然后用 node 跑一下:

npm i --save express cors multer

node ./server.js

这里 node 能直接跑 es module 的代码是因为 package.json 里指定了 type 为 module:

也就是说默认所有 js 都是 es module 的。

然后改下上传路径:

试一下:

上传成功,服务端也接收到了文件:

只不过现在的文件名没有带后缀名,我们可以自定义一下:

import express from 'express';
import multer from 'multer';
import cors from 'cors';
import path from 'path';
import fs from 'fs';

const app = express()
app.use(cors());

const storage = multer.diskStorage({
    destination: function (req, file, cb) {
        try {
            fs.mkdirSync(path.join(process.cwd(), 'uploads'));
        }catch(e) {}
        cb(null, path.join(process.cwd(), 'uploads'))
    },
    filename: function (req, file, cb) {
        const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9) + '-' + file.originalname
        cb(null, uniqueSuffix)
    }
});
const upload = multer({ 
    dest: 'uploads/',
    storage
})

app.post('/upload', upload.single('file'), function (req, res, next) {
  console.log('req.file', req.file);
  console.log('req.body', req.body);

  res.end(JSON.stringify({
    message: 'success'
  }));
})

app.listen(3333);

自定义 storage,指定文件存储的目录以及文件名。

重新跑下服务,然后再次上传:

现在,文件保存的路径就改了

上传的图片也能正常打开:

接口搞定之后,我们自己来实现下这个 Upload 组件。

新建 Upload/index.tsx

import { FC, useRef, ChangeEvent, PropsWithChildren } from 'react'
import axios from 'axios'

import './index.scss';

export interface UploadProps extends PropsWithChildren{
  action: string;
  headers?: Record<string, any>;
  name?: string;
  data?: Record<string, any>;
  withCredentials?: boolean;
  accept?: string;
  multiple?: boolean;
}

export const Upload: FC<UploadProps> = (props) => {
  const {
    action,
    name,
    headers,
    data,
    withCredentials,
    accept,
    multiple,
    children,
  } = props

  const fileInput = useRef<HTMLInputElement>(null);

  const handleClick = () => {
    if (fileInput.current) {
        fileInput.current.click()
    }
  }

  const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
    const files = e.target.files
    if(!files) {
        return
    }
    uploadFiles(files)
    if (fileInput.current) {
        fileInput.current.value = ''
    }
  }

  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'
        },
        withCredentials
    })
  }

  return (
    <div className="upload-component">
        <div 
            className="upload-input"
            onClick={handleClick}
        >
            {children}
            <input
                className="upload-file-input"
                type="file"
                ref={fileInput}
                onChange={handleFileChange}
                accept={accept}
                multiple={multiple}
            />
        </div>
    </div>
  )
}

export default Upload;

还有 Upload/index.scss

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

这些参数都很容易理解:

action 是上传的 url

headers 是携带的请求头

data 是携带的数据

name 是文件的表单字段名

accept 是 input 接受的文件格式

multiple 是 input 可以多选

然后渲染 children 外加一个隐藏的 file input

onChange 的时候,拿到所有 files 依次上传,之后把 file input 置空:

用 axios 来发送 post 请求,携带 FormData 数据,包含 file 和其它 data 字段:

再就是点击其它区域也触发 file input 的点击:

安装用到的 axios 包:

npm install --save axios

改下 App.tsx

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

const props: UploadProps = {
  name: 'file',
  action: 'http://localhost:3333/upload'
};

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

export default App;

这里内层的 Button、Icon 还是用 antd 的,只是把 Upload 组件换成我们自己实现的。

然后测试下:

虽然界面还没加啥反馈,但请求已经发送成功了:

服务端也接受到了这个文件:

上传功能没问题,然后我们添加几个上传过程中的回调函数:

beforeUpload 是上传之前的回调,如果返回 false 就不上传,也可以返回 promise,比如在服务端校验的时候,等 resolve 之后才会上传

antd 的 Upload 组件就是这样的:

onProgress 是进度更新时的回调,可以拿到进度。

onSuccess 和 onError 是上传成功、失败时的回调。

onChange 是上传状态改变时的回调。

这几个回调分别在上传前、进度更新、成功、失败时调用:

import { FC, useRef, ChangeEvent, PropsWithChildren } from 'react'
import axios from 'axios'

import './index.scss';

export interface UploadProps extends PropsWithChildren{
  action: string;
  headers?: Record<string, any>;
  name?: string;
  data?: Record<string, any>;
  withCredentials?: boolean;
  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: FC<UploadProps> = (props) => {
  const {
    action,
    name,
    headers,
    data,
    withCredentials,
    accept,
    multiple,
    children,
    beforeUpload,
    onProgress,
    onSuccess,
    onError,
    onChange,
  } = props

  const fileInput = useRef<HTMLInputElement>(null);

  const handleClick = () => {
    if (fileInput.current) {
        fileInput.current.click()
    }
  }

  const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
    const files = e.target.files
    if(!files) {
        return
    }
    uploadFiles(files)
    if (fileInput.current) {
        fileInput.current.value = ''
    }
  }

  const uploadFiles = (files: FileList) => {
    let postFiles = Array.from(files)
    postFiles.forEach(file => {
        if (!beforeUpload) {
            post(file)
        } else {
            const result = beforeUpload(file)
            if (result && result instanceof Promise) {
                result.then(processedFile => {
                    post(processedFile)
                })
            } else if (result !== false) {
                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'
        },
        withCredentials,
        onUploadProgress: (e) => {
            let percentage = Math.round((e.loaded * 100) / e.total!) || 0;
            if (percentage < 100) {
                if (onProgress) {
                    onProgress(percentage, file)
                }
            }
        }
    }).then(resp => {
        onSuccess?.(resp.data, file)
        onChange?.(file)
    }).catch(err => {
        onError?.(err, file)
        onChange?.(file)
    })
  }

  return (
    <div className="upload-component">
        <div 
            className="upload-input"
            onClick={handleClick}
        >
            {children}
            <input
                className="upload-file-input"
                type="file"
                ref={fileInput}
                onChange={handleFileChange}
                accept={accept}
                multiple={multiple}
            />
        </div>
    </div>
  )
}

export default Upload;

在 App.tsx 里传入对应参数:

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

const props: UploadProps = {
  name: 'file',
  action: 'http://localhost:3333/upload',
  beforeUpload(file) {
    if(file.name.includes('1.image')) {
      return false;
    }
    return true;
  },
  onSuccess(ret) {
    console.log('onSuccess', ret);
  },
  onError(err) {
    console.log('onError', err);
  },
  onProgress(percentage, file) {
    console.log('onProgress', percentage);
  },
  onChange(file) {
    console.log('onChange', file);
  }
};

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

export default App;

包含 1.image 的文件返回 false,其余的返回 true

跑一下:

网速快的时候没有上传进度,改下网络设置:

几个回调函数都没问题。

接下来我们添加下面的文件列表:

新建 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' && 
                                <CloseOutlined />
                        }
                        {item.name}
                    </span>
                    <span className="file-actions">
                        <DeleteOutlined onClick={() => { onRemove(item)}}/>
                    </span>
                        {
                            item.status === 'uploading' && 
                                <Progress percent={item.percent || 0}/>
                        }
                </li>
            )
        })
      }
    </ul>
  )
}

export default UploadList;

这个组件传入 UploadFile 的数组和 onRemove 回调作为参数:

UploadFile 里除了文件信息外,还有 status、response、error

上传状态 status 有 ready、uploading、success、error 四种。

然后把 UploadFile 数组渲染出来:

显示文件名、进度、删除按钮等。

点击删除的时候调用 onRemove 回调。

然后在 index.scss 里添加对应的样式:

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

.upload-list {
    margin: 0;
    padding: 0;
    list-style-type: none;
}
.upload-list-item {
    margin-top: 5px;

    font-size: 14px;
    line-height: 2em;
    font-weight: bold;

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

    position: relative;

    &-success {
        color: blue;
    }
    
    &-error {
        color: red;
    }

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

    .file-actions {
        display: none;

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

        cursor: pointer;
    }

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

在 Upload/index.tsx 里引入试试:

用 mock 的数据渲染 UploadList

const fileList: UploadFile[] = [
    {
        uid: '11',
        size: 111,
        name: 'xxxx',
        status: 'uploading',
        percent: 50
    },
    {
        uid: '22',
        size: 111,
        name: 'yyy',
        status: 'success',
        percent: 50
    },
    {
        uid: '33',
        size: 111,
        name: 'zzz',
        status: 'error',
        percent: 50
    },
];

return (
    <div className="upload-component">
        <div 
            className="upload-input"
            onClick={handleClick}
        >
            {children}
            <input
                className="upload-file-input"
                type="file"
                ref={fileInput}
                onChange={handleFileChange}
                accept={accept}
                multiple={multiple}
            />
        </div>

        <UploadList
            fileList={fileList}
            onRemove={() => {}}
        />
    </div>
)

浏览器看一下:

没啥问题。

然后把数据变成动态的:

声明一个 fileList 的 state,并封装一个更新它的方法:

在状态改变的时候调用更新方法来更新 fileList:

并且添加一个 onRemove 的回调:

在点击删除按钮的时候调用:

import { FC, useRef, ChangeEvent, PropsWithChildren, useState } from 'react'
import axios from 'axios'

import './index.scss';
import UploadList, { UploadFile } from './UploadList';

export interface UploadProps extends PropsWithChildren{
  action: string;
  headers?: Record<string, any>;
  name?: string;
  data?: Record<string, any>;
  withCredentials?: boolean;
  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;
  onRemove?: (file: UploadFile) => void;
}

export const Upload: FC<UploadProps> = (props) => {
  const {
    action,
    name,
    headers,
    data,
    withCredentials,
    accept,
    multiple,
    children,
    beforeUpload,
    onProgress,
    onSuccess,
    onError,
    onChange,
    onRemove
  } = props

  const fileInput = useRef<HTMLInputElement>(null);

  const [ fileList, setFileList ] = useState<Array<UploadFile>>([]);

  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 handleRemove = (file: UploadFile) => {
    setFileList((prevList) => {
      return prevList.filter(item => item.uid !== file.uid)
    })
    if (onRemove) {
      onRemove(file)
    }
  }

  const handleClick = () => {
    if (fileInput.current) {
        fileInput.current.click()
    }
  }

  const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
    const files = e.target.files
    if(!files) {
        return
    }
    uploadFiles(files)
    if (fileInput.current) {
        fileInput.current.value = ''
    }
  }

  const uploadFiles = (files: FileList) => {
    let postFiles = Array.from(files)
    postFiles.forEach(file => {
        if (!beforeUpload) {
            post(file)
        } else {
            const result = beforeUpload(file)
            if (result && result instanceof Promise) {
                result.then(processedFile => {
                    post(processedFile)
                })
            } else if (result !== false) {
                post(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.post(action, formData, {
        headers: {
            ...headers,
            'Content-Type': 'multipart/form-data'
        },
        withCredentials,
        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(resp => {
        updateFileList(uploadFile, {status: 'success', response: resp.data})

        onSuccess?.(resp.data, file)
        onChange?.(file)
    }).catch(err => {
        updateFileList(uploadFile, { status: 'error', error: err})

        onError?.(err, file)
        onChange?.(file)
    })
  }

  return (
    <div className="upload-component">
        <div 
            className="upload-input"
            onClick={handleClick}
        >
            {children}
            <input
                className="upload-file-input"
                type="file"
                ref={fileInput}
                onChange={handleFileChange}
                accept={accept}
                multiple={multiple}
            />
        </div>

        <UploadList
            fileList={fileList}
            onRemove={handleRemove}
        />
    </div>
  )
}

export default Upload;

大功告成,我们测试下:

文件上传状态没问题,服务端也收到了上传的文件。

至此,我们的 Upload 组件就完成了。

然后我们再加上拖拽上传的功能:

创建 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;

因为拖拽文件到这里的时候,会有对应的样式,所以我们要在 dragover 和 dragleave 的时候分别设置不同的 dragOver 状态值,然后更改 className

然后在 drop 的时候,把文件传给 onFile 回调函数:

在 index.scss 里加上它的样式:

.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.tsx 引入 Dragger 组件:

{
    drag ? <Dragger onFile={(files) => {uploadFiles(files)}}>
            {children}
        </Dragger>
        : children
}

当传入 drag 参数的时候,渲染 dragger 组件,onFile 回调里调用 uploadFiles 方法来上传。

在 index.tsx 里试试:

浏览器访问下:

没啥问题。

可以改下 Upload 组件的 children:

const App: React.FC = () => (
  <Upload {...props} drag>
    <p>
      <InboxOutlined style={{fontSize: '50px'}}/>
    </p>
    <p>点击或者拖拽文件到此处</p>
  </Upload>
);

这样,拖拽上传就完成了。

案例代码上传了小册仓库

总结

今天我们实现了 Upload 组件。

首先用 express + multer 跑的服务端,创建 /upload 接口来接收文件。

然后在 Upload 组件里调用 axios,上传包含 file 的 FormData。

之后加上了 beforeUpload、onProgress、onSuccess、onChange 等回调函数。

最后又加上了 UploadList 来可视化展示上传文件的状态。

然后实现了 Dragger 组件,可以拖拽文件来上传。

这样,我们就实现了 Upload 组件。

返回上级