跳到主要内容

组件实战: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, 0.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 组件。