文件上传 是业务上一个很常见的需求功能,但它涵盖的知识点其实很多。笔者结合自己的工作积累,总结了一篇关于文件上传的常见场景和实现,或许会对正在阅读的你有所帮助。
一、文件上传方式
在 Web 网站上传本地文件的方式有三种:
- 选择上传,点击唤起 浏览器文件选择控件 选择本地文件进行上传;
- 拖拽上传,将需要上传的文件拖拽到 页面的上传区域 进行上传;
- 粘贴上传(剪贴板),复制需要上传的文件,在页面的上传区域 使用快捷键(如:ctrl + v)粘贴进行上传。
1、选择上传
我们需要有一个 <input type="file" />
元素,通过触发其 click()
事件,唤起浏览器文件选择控件,并选择文件进行上传。其中 input 元素可以是一个事先写好的占位元素,或是 JS 动态创建元素。
jsx
import React, { useRef } from "react";
const FileUpload = () => {
const fileRef = useRef(null);
const handleChangeFile = event => {
const files = event.target.files;
console.log("files: ", Array.from(files)); // 拿到 files 文件对象,是一个数组
event.target.value = ""; // 最好清除一下 value,避免在某些情况下选择文件后,不会触发 change 事件。
}
return (
<div>
<button onClick={() => fileRef.current.click()}>点击上传</button>
<input style={{ display: "none" }} type="file" ref={fileRef} onChange={handleChangeFile} />
</div>
)
}
export default FileUpload;
2、拖拽上传
首先要提供一个 上传区域(容器),用户拖拽文件到这个容器内进行上传。
为了让容器能够接收到拖拽过来的文件,需要借助 HTML5 拖拽 API drag/drop
事件,这里用到两个相关事件:
- dragover, 阻止图片在浏览器上的默认行为,我们知道,图片拖拽到页面区域后,默认行为会在新标签页打开预览图片;
- drop , 当拖拽后鼠标在上传区域松开时触发的事件,接收拖拽给容器的图片内容,类似于
<input type="file" /> change
事件。
jsx
import React, { useRef, useEffect } from "react";
const FileUpload = () => {
const fileContainer = useRef(null);
const handleDragover = event => event.preventDefault();
const handleChangeFile = event => {
event.preventDefault();
const files = event.dataTransfer.files; // 拖拽上传是从 event.dataTransfer 中拿到 files 文件对象,是一个数组
console.log("files: ", Array.from(files)); // 拿到 files 文件对象,是一个数组
}
useEffect(() => {
const container = fileContainer.current;
container.addEventListener('dragover', handleDragover); // 阻止图片拖拽到页面区域后,默认行为在新标签页打开图片。
container.addEventListener('drop', handleChangeFile);
// 做好事件清除工作
return () => {
container.removeEventListener('dragover', handleDragover);
container.removeEventListener('drop', handleChangeFile);
}
}, []);
return (
<div className="file-upload-container" ref={fileContainer}></div>
)
}
export default FileUpload;
3、粘贴上传
同样也需要提供一个上传区域(容器),用于将复制的图片在 上传区域 内粘贴上传。
监听 paste
事件可以拿到粘贴上的文件对象,具体实现如下:
jsx
import React, { useRef, useEffect } from "react";
const FileUpload = () => {
const containerRef = useRef(null);
useEffect(() => {
// 为上传区域绑定 paste 粘贴监听事件
containerRef.current.addEventListener("paste", handlePasteFile);
return () => {
containerRef.current.removeEventListener("paste", handlePasteFile);
}
}, []);
const handlePasteFile = (e) => {
// 检查剪切板是否包含文件数据
if (e.clipboardData && e.clipboardData.files && e.clipboardData.files.length > 0) {
// 获取文件对象
const files = Array.from(e.clipboardData.files);
console.log("files: ", Array.from(files)); // 拿到 files 文件对象,是一个数组
}
}
return (
<div ref={containerRef} className="file-upload-container"></div>
);
};
export default FileUpload;
二、多文件上传
对于 拖拽上传 ,上传文件的数量取决于你 要拖拽几个文件 到容器内,本身支持多文件上传。
对于 粘贴上传 ,上传的文件数量取决于你 复制了几个文件,本身支持多文件上传。
对于 选择上传 ,会唤起浏览器控件来选择本地文件,默认只支持选择单个文件,若想实现 如配合组合键 command/ctrl
来多选文件,可通过为 <input />
元素设置 multiple
属性。
jsx
<input
style={{ display: "none" }}
type="file"
multiple
ref={fileRef}
onChange={handleChangeFile} />
当开启多文件上传后,change
事件中拿到的 files
则是一个 length > 1 的对象。
三、文件上传限制
一般,结合业务场景会对用户选择的文件进行以下几种情况限制:
- 文件大小限制,比如不能超过 20M;
- 文件类型限制,比如只能上传 图片;
- 文件数量限制,比如只能上传 5 个。
1、文件信息
在对文件做限制前,我们先来认识一下在 change/drop
事件中 files
上传文件对象包含哪些可用信息。
- name, 文件名称,包含文件类型后缀;
- type, 文件类型,常见的有 image/xxx, video/xxx, audio/xxx, application/pdf 等;
- size, 文件大小,单位是 B(Byte) 字节;
2、文件大小限制
根据文件对象 file.size
属性,比较文件大小是否超过给定限制,如下面给定上传文件的大小不能超过 5 MB。
jsx
const handleChangeFile = event => {
if (event.target.files) {
const files = Array.from(event.target.files);
const file = files[0]; // 假设只允许上传单个文件,取出第一个文件
const limitSize = 1024 * 1024 * 5; // 限制文件大小为 5 MB
if (file.size > limitSize) {
console.log("文件大小超过限制");
// message.info() 提示文件大小超过限制。
}
}
event.target.value = "";
}
3、文件类型限制
做文件类型限制,需要有两层处理:
- 第一层限制,考虑 选择文件 场景,打开浏览器的选择文件控件,对不满足类型的文件禁止选择,通过给
<input />
元素设置accept
属性指定允许上传的文件类型 ;- (但要注意,浏览器文件选择控件的类型是可以被用户修改为「选择所有文件」,所以第二层限制一定要做。);
- 第二层限制,考虑 拖拽、粘贴上传 场景,同时兼顾 选择上传,需要在事件中判断文件类型是否满足条件。
下面以 只允许上传图片 限制为例:
jsx
// 限制只能上传图片
const imageAccept = "image/png,image/jpg,image/jpeg,image/webp,image/gif";
// 限制可以上传 图片、视频、音频、PDF
const accept = ["image/*", "video/*", "audio/*", "application/pdf"];
// change/drop 事件触发回调,这里以 change 事件为例,判断 file.type 是否满足上传文件类型
const handleChangeFile = event => {
if (event.target.files) {
const files = Array.from(event.target.files);
const file = files[0]; // 假设只允许上传单个文件,取出第一个文件
if (!imageAccept.includes(file.type)) {
console.log("文件类型不符合条件");
}
}
event.target.value = "";
}
<input style={{ display: "none" }} type="file" accept={imageAccept} ref={fileRef} onChange={handleChangeFile} />
4、文件数量限制
files
对象是一个伪对象,通过 Array.from
可将其转换为数组,通过判断数组长度即可实现文件数量的限制。
jsx
const handleChangeFile = event => {
if (event.target.files) {
const limitNum = 1;
let files = Array.from(event.target.files);
if (files.length > limitNum) {
console.log("最多上传 5 个文件");
files = files.slice(0, limitNum); // 截取前 5 个文件
}
}
event.target.value = "";
}
四、预览文件
在前端 <img />、<video />、<audio />
标签都可以通过 src
传递一个文件地址进行预览。通常这个地址是一个在线地址,这要求先将本地文件上传到服务器后生成新 url 做预览。
当我们期望,在用户选择本地文件以后立即能够在页面上预览所选文件,可以通过 URL.createObjectURL
创建一个 blob 内存 url
作为元素的 src 属性使用。
如,预览所选的图片文件。
jsx
const [previewUrl, setPreviewUrl] = useState("");
const handleChangeFile = event => {
if (event.target.files) {
const file = Array.from(event.target.files)[0];
previewUrl && URL.revokeObjectURL(previewUrl); // 记得不用时释放内存 url
const url = URL.createObjectURL(file); // 创建 blob 内存 url(格式类似于:blob:http://127.0.0.1:5173/5101d6ab-821e-4957-93ea-f029533a182a)
setPreviewUrl(url);
}
event.target.value = "";
}
<img width={500} src={previewUrl} />
Tips :当你上传并预览一个特别大的图片(比如 5MB)时,并且为图片设置了填充方式 object-fit: cover;
,你会发现页面操作卡顿,可以结合 CSS3 属性 translate3d(0, 0, 0)
触发 GPU 加速提供渲染性能。
五、单文件上传至服务器
一般小文件会采用单文件上传方式,一次性全部给到后台接口上传到服务器。
下面,我们,
- 在客户端我们封装一个
Promise ajax
,后续采用FormData
格式上传文件; - 在服务端通过
Node http
模块启动本地服务( http://localhost:8080 ),采用multiparty
处理FormData
数据。
1、客户端代码
jsx
// 1. 封装 Promise ajax
const request = ({ url, method = "POST", data, headers = {} }) => {
return new Promise((resolve) => {
// 1、创建 xhr 对象
const xhr = new XMLHttpRequest();
// 2、建立连接
xhr.open(method, url);
// 设置请求头
Object.keys(headers).forEach((key) =>
xhr.setRequestHeader(key, headers[key])
);
// 3. 发送数据(上传数据使用 POST 请求,且 FormData 数据不需要使用 JSON.stringify 做转换,故无需设置 headers content-type)
xhr.send(data);
// 4. 接收数据
xhr.onload = (e) => {
resolve({
data: e.target.response,
});
};
});
};
// change 事件里拿到上传文件,请求后台接口
const handleChangeFile = (event) => {
if (event.target.files) {
const file = Array.from(event.target.files)[0];
const formData = new FormData(); // FormData
formData.append("fileName", file.name);
formData.append("file", file);
request({
url: "http://localhost:8080/upload/single",
data: formData,
});
}
event.target.value = "";
};
2、服务端代码
js
// server/app.js
const http = require('http');
const path = require('path');
const fs = require('fs-extra'); // 文件处理模块
const multiparty = require('multiparty'); // 处理 FormData 对象的模块
const server = http.createServer();
// 文件存放目录
const UPLOAD_DIR = path.resolve(__dirname, '.', 'target');
server.on('request', async (req, res) => {
// 跨域处理
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Headers", "*");
if (req.method === "OPTIONS") {
res.status = 200;
res.end();
return;
}
if (req.url === '/upload/single' && req.method === "POST") {
const multipart = new multiparty.Form() // 解析 FormData 对象
multipart.parse(req, async (err, fields, files) => {
if (err) return;
const [fileName] = fields.fileName; // 是一个数组,取出第一项 文件名
const [file] = files.file; // 是一个数组,取出第一项 文件体
const filePath = `${UPLOAD_DIR}/${fileName}`; // 文件存放位置
await fs.remove(filePath); // 删除文件,如果存在
await fs.move(file.path, filePath); // 保存文件
res.end(JSON.stringify({
code: 0,
message: 'upload success.'
}))
});
}
})
server.listen(8080, () => console.log('服务已启动, port 8080.'));
上传成功后,在服务端 target 目录下就能看到上传的文件。
六、目录上传
<input type="file" />
除了可以选择上传 单/多文件外,还可以支持上传一个目录,将目录下所有 文件/子目录 内容递归进行上传。
这在云文件平台比较常见,如 飞书的云文档,可将本地的一个目录作为上传任务,上传到云文档中。
通过设置 webkitdirectory
属性来支持选择上传目录。
jsx
<input
webkitdirectory="true"
style={{ display: "none" }}
type="file"
ref={fileRef}
onChange={handleChangeFile} />
注意 ,上传文件 和 上传目录 需要使用两个
<input />
元素来实现;当给元素设置了webkitdirectory
属性以后,将无法选择文件,只能选择目录。
在你选择目录之后,它会弹出确认框是否要将目录中的文件进行上传,比如我本地的 folder 文件夹,它下面有 6 个文件:
在确认上传以后,change
事件中可以获取到该目录下的文件列表(注意,即使是存在多级目录,也只有 **扁平** 后的文件,没有子目录
),列表中每个文件对象上都包含一个 webkitRelativePath
属性,代表文件的相对路径。
对应本地文件夹 folder 的 Tree 形式展示如下:
bash
.folder
├── image
│ └── image1.jpeg
├── other
│ └── zip
│ └── zip1.zip
├── pdf
│ └── pdf1.pdf
└── video
└── video1.mp4
由于文件列表中没有子目录的数据,我们该如何构建此目录的原始结构呢?基于 webkitRelativePath
属性,我们可以推断出此文件的层级目录,来构建文件夹的内容层级。
这里利用一个 Map 数据结构来实现:
jsx
const handleChangeFile = (event) => {
if (event.target.files) {
const files = Array.from(event.target.files).filter(file => file.name !== ".DS_Store");
const folderTree = {}; // 目录结构树。如果是目录,值为对象;如果是文件,文件名作为属性 key,值为常量字符串 "file"。
let rootFolderName = ""; // 目录名称
files.forEach(file => {
const [folderName, ...fileLevel] = file.webkitRelativePath.split("/");
// 1. 拿到上传的目录名称
if (!rootFolderName) {
rootFolderName = folderName;
}
// 2. 构建目录 Tree
fileLevel.reduce((pre, cur, index) => {
const isFolder = index < fileLevel.length - 1; // 非最后一层级
if (isFolder) { // 是目录
if (!pre[cur]) { // 若目录不存在,则创建目录(以 对象{} 表示)
pre[cur] = {};
}
} else { // 是文件(以 常量字符串 "file" 表示)
pre[cur] = "file";
}
return pre[cur];
}, folderTree);
});
console.log(`${rootFolderName} folderTree: `, folderTree);
}
event.target.value = "";
};
输入如下:
尽管 webkitdirectory
属性可以实现选择目录的功能,但在实际项目中我们还需要考虑它的兼容性。当所处浏览器环境不支持 目录上传 时,我们在网站不开放「上传文件夹」入口,这在 云文件 服务平台非常必要。
js
const isSupportedDirectoryUpload = (function () {
let el = document.createElement('input');
el.type = 'file';
return 'webkitdirectory' in el || 'directory' in el;
})();
七、上传进度
上传文件是一个比较耗时的过程,网站都会在页面给用户呈现进度展示。
1、单文件,单进度
单文件单进度这个好理解,展示单个文件的上传进度。
我们需要一种方式能够监听文件上传的进度,XMLHttpRequest.upload.onprogress
可以实现。
不过要注意它的绑定需要在
xhr.send
发送数据前做好。
我们改造上面 Promise ajax
,传入 onProgress
监听进度事件:
diff
const request = ({
url,
method = "POST",
data,
headers = {},
+ onProgress
}) => {
return new Promise((resolve) => {
// 1、创建 xhr 对象
const xhr = new XMLHttpRequest();
// 2、建立连接
xhr.open(method, url);
// 设置请求头
Object.keys(headers).forEach((key) =>
xhr.setRequestHeader(key, headers[key])
);
// 绑定 upload.onprogress 监听上传进度事件(必须放在 xhr.send 之前)
+ xhr.upload.onprogress = onProgress;
// 3. 发送数据(上传数据使用 POST 请求,且 FormData 数据不需要使用 JSON.stringify 做转换,也无需设置 headers content-type)
xhr.send(data);
// 4. 接收数据
xhr.onload = (e) => {
resolve({
data: e.target.response,
});
};
});
};
在 onProgress event
事件对象中有两个信息可以用于计算进度,它们的单位和文件大小一样都是 Byte 字节。
- event.loaded, 已上传的文件大小;
- event.total, 总的文件大小。
通过 event.loaded / event.total
就可以计算出上传进度 progress。
此外,event.total
可以使用文件大小 file.size
来代替,不过根据测试发现 event.total
要比实际文件大小 file.size
要大一点。
进度实现代码如下:
jsx
const [progress, setProgress] = useState(0);
const handleChangeFile = async (event) => {
if (event.target.files) {
const file = Array.from(event.target.files)[0];
const formData = new FormData();
formData.append("filename", file.name);
formData.append("file", file);
await request({
url: "http://localhost:8080/upload/single",
data: formData,
onProgress: (event) => {
const loadedSize = Math.min(event.loaded, file.size); // 已上传进度(event.loaded 在上传完成以后有可能比 fize.size 大一点,所以取最小值)
const progress = Math.floor((loadedSize / file.size) * 100);
setProgress(progress); // 设置进度
}
});
}
event.target.value = "";
};
// 视图展示进度
<h2>上传进度:{progress}</h2>
2、多文件,单进度
多文件同时上传共用一个进度展示,可以有两种方式实现:
- 根据总文件大小计算,已上传的大小 / 所有文件总大小;
- 求已上传进度的平均值,已上传的进度 / 上传的文件数量。
小提示:在多任务下,最好为每个文件定义一个独立数据结构,来记录这个文件上传过程所需的数据状态。便于串联每个独立任务的数据信息。
1、方式一:已上传的大小 / 所有文件总大小
jsx
const [, forceUpdate] = useReducer(v => v + 1, 0); // 配合数据修改更新视图
const uploadTasks = useRef([]); // 记录每个上传文件的数据信息
const handleChangeFile = async (event) => {
if (event.target.files) {
const files = Array.from(event.target.files);
// 1、多任务下,最好为每个文件定义一个独立数据,来记录这个文件上传过程所需的数据状态。
* uploadTasks.current = files.map(file => ({
file,
name: file.name,
* size: file.size,
* loaded: 0, // 单个文件已上传的大小
}));
const uploadRequests = uploadTasks.current.map(task => {
const formData = new FormData();
formData.append("filename", task.file.name);
formData.append("file", task.file);
return request({
url: "http://localhost:8080/upload/single",
data: formData,
onProgress: (event) => {
// 2、记录每个文件已上传的大小
* task.loaded = Math.min(event.loaded, task.file.size);
forceUpdate(true); // 更新视图,渲染 progress
}
});
});
Promise.all(uploadRequests); // 多文件并行上传
}
event.target.value = "";
};
// 方式一:用多个文件已上传的大小 / 多个文件总大小
let totalSize = 0, loadedSize = 0;
uploadTasks.current.forEach(task => {
totalSize += task.size;
loadedSize += task.loaded;
});
const progress = Math.floor((loadedSize / totalSize) * 100) || 0;
2、方式二:求已上传进度的平均值
jsx
const [, forceUpdate] = useReducer(v => v + 1, 0);
const uploadTasks = useRef([]); // 记录每个上传文件的数据信息
const handleChangeFile = async (event) => {
if (event.target.files) {
const files = Array.from(event.target.files);
// 1、多任务下,最好为每个文件定义一个独立数据,来记录这个文件上传过程所需的数据状态。
* uploadTasks.current = files.map(file => ({
file,
name: file.name,
* progress: 0, // 该文件的上传进度
}));
const uploadRequests = uploadTasks.current.map(task => {
const formData = new FormData();
formData.append("filename", task.file.name);
formData.append("file", task.file);
return request({
url: "http://localhost:8080/upload/single",
data: formData,
onProgress: (event) => {
const loaded = Math.min(event.loaded, task.file.size);
const progress = Math.floor((loaded / task.file.size) * 100);
// 2、记录每个任务的上传进度
* task.progress = progress;
forceUpdate(true); // 更新视图,渲染 progress
}
});
});
Promise.all(uploadRequests); // 多文件并行上传
}
event.target.value = "";
};
// 方式二:已上传的进度 / 上传的文件数量。
const allProgress = uploadTasks.current.reduce((pre, cur) => {
const res = pre + cur.progress;
return res;
}, 0);
const progress = Math.floor(allProgress / uploadTasks.current.length) || 0;
八、上传状态
若想实现一个完整的上传文件功能模块,上传状态一定要考虑进来:将文件处于上传的各个阶段,在视图上呈现给用户,是一个很不错的使用体验。
常见的文件上传视图状态有:
- 等待上传(多文件上传时,可能需要排队上传)
- 生成 sha1 阶段(上传的准备阶段,前端处理中)
- 上传阶段(上传中,展示上传进度)
- 上传暂停(可用于恢复上传)
- 服务端处理阶段(解析中)
- 上传成功
- 上传失败
这里简单以 4 个上传状态为例,向用户展示上传过程。
diff
+ // 1. 默认 wait 等待状态
+ // wait(等待上传)、inProgress(上传中)、process(服务端解析中)、success(上传成功)
+ const [uploadStatus, setUploadStatus] = useState("wait");
const handleChangeFile = async (event) => {
if (event.target.files) {
const file = Array.from(event.target.files)[0];
const formData = new FormData();
formData.append("filename", file.name);
formData.append("file", file);
await request({
url: "http://localhost:8080/upload/single",
data: formData,
onProgress: (event) => {
const loadedSize = Math.min(event.loaded, file.size); // 已上传进度(event.loaded 在上传完成以后有可能比 fize.size 大一点,所以取最小值)
const progress = Math.floor((loadedSize / file.size) * 100);
+ // 2. 根据上传进度控制 inProgress 上传中 和 process 服务端解析中
+ setUploadStatus(progress === 100 ? "process" : "inProgress");
setProgress(progress); // 设置进度
}
});
+ // 3. 上传完成
setUploadStatus("success");
}
event.target.value = "";
};
+ // 视图渲染上传状态
<h2>
{(() => {
switch (uploadStatus) {
case "wait": return "等待上传";
case "inProgress": return "上传中";
case "process": return "服务端解析中";
case "success": return "上传成功";
}
})()}
</h2>
九、取消/暂停 上传
上传中的文件可以被随时取消上传,即中断 HTTP 请求。取消请求由客户端发起,服务端可以监听到上传终止做一些重置动作。
1、客户端暂停上传
ajax XMLHttpRequest 有一个方法 xhr.abort
可用于终止所发起的 xhr 请求。
我们在 request 方法基础上扩展 abortRef
参数,用于保存 xhr.abort
。
注意,保存时一定要 把 xhr 保存进来 ,因为只有通过
xhr.abort
方式调用才可正常被取消,仅通过 abort 函数自执行将会报错:Uncaught TypeError: Illegal invocation
。
diff
const request = ({
url,
method = "POST",
data,
headers = {},
onProgress,
+ abortRef
}) => {
return new Promise((resolve) => {
// 1、创建 xhr 对象
const xhr = new XMLHttpRequest();
// 2、建立连接
xhr.open(method, url);
// 设置请求头
Object.keys(headers).forEach((key) =>
xhr.setRequestHeader(key, headers[key])
);
// 存储 xhr.abort,用于中断请求
+ abortRef && (abortRef.current = () => xhr.abort());
// 绑定 upload.onprogress 监听上传进度事件(必须放在 xhr.send 之前)
xhr.upload.onprogress = onProgress;
// 3. 发送数据(上传数据使用 POST 请求,且 FormData 数据不需要使用 JSON.stringify 做转换,也无需设置 headers content-type)
xhr.send(data);
// 4. 接收数据
xhr.onload = (e) => {
resolve({
data: e.target.response,
});
};
});
};
点击取消按钮 执行 xhr.abort()
来 取消/暂停 上传。
diff
const FileUpload = () => {
const fileRef = useRef(null);
+ const abortRef = useRef(null);
const handleChangeFile = (event) => {
if (event.target.files) {
const file = Array.from(event.target.files)[0];
const formData = new FormData();
formData.append("filename", file.name);
formData.append("file", file);
request({
url: "http://localhost:8080/upload/single",
data: formData,
+ abortRef: abortRef,
});
}
event.target.value = "";
};
return (
<div>
<button onClick={() => fileRef.current.click()}>点击上传</button>
+ <button onClick={() => abortRef.current && abortRef.current()}>点击 取消/暂停 上传</button>
<input style={{ display: "none" }} multiple type="file" ref={fileRef} onChange={handleChangeFile} />
</div>
);
};
2、服务端监听上传暂停
在 Node 中可以通过 req.on('aborted', cb)
监听请求中断;同时,multipart.parse()
中的 err
信息为 Error: Request aborted,所以判断 err 不存在时停止上传,非常有必要。
diff
// server/app.js
server.on('request', async (req, res) => {
// 跨域处理
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Headers", "*");
if (req.method === "OPTIONS") {
res.status = 200;
res.end();
return;
}
+ req.on('aborted', () => {
+ console.log("监听到请求终止。");
+ });
if (req.url === '/upload/single') {
const multipart = new multiparty.Form() // 解析 FormData 对象
multipart.parse(req, async (err, fields, files) => {
console.log("info: ", err, fields);
* if (err) return; // 若上传被中断,err 信息输出为:Error: Request aborted
...
});
}
})
十、切片上传(大文件)
切片上传,也叫分段上传,将一个大文件,按照预定的切片大小(如:10M),切分成一个个切片,借助 http 并发特性同时上传多个切片,从而大大减少大文件的上传时间。
1、思路
在前端客户端 ,对文件拆分为切片的方案是利用 Blob.prototype.slice
方法,和数组的 slice
用法相似,它返回原文件的某一块切片内容。
接下来是将每个文件切片 并行 上传到服务器。注意,因为是并行,每个切片要记录上各自的编号顺序。
在服务端,负责接收前端传输的切片,并在接收到所有切片后合并这些切片为一个完整文件。这里涉及两个过程:
- 何时合并切片 ,这里需要前端配合,在所有切片上传成功以后,主动发送一个 merge 合并切片请求,通知服务端进行切片合并;
- 如何合并切片 ,服务端以 Nodejs 为例,可通过
读写流(readStream/writeStream)
,将所有切片的流传输到最终文件的流里。
2、客户端实现
在客户端,我们需要实现:
- 创建切片
- 生成文件 hash
- 并发上传切片(控制异步并发请求的数量)
- 发起合并切片请求
1、基础结构
jsx
// 定义切片大小为 10M
const SIZE = 10 * 1024 * 1024;
const FileUpload = () => {
const fileRef = useRef(null);
const handleChangeFile = async (event) => {
if (event.target.files) {
const file = Array.from(event.target.files)[0];
...
}
};
return (
<div>
<button onClick={() => fileRef.current.click()}>点击上传</button>
<input style={{ display: "none" }} multiple type="file" ref={fileRef} onChange={handleChangeFile} />
</div>
);
};
export default FileUpload;
2、创建文件切片
文件 file 继承自 Blob
对象,使用 Blob.prototype.slice
将文件分成多个小块。
diff
const FileUpload = () => {
const fileRef = useRef(null);
+ const uploadFile = useRef({ file: null, hash: "" });
// 生成文件切片
+ const createFileChunks = (file, chunkSize = SIZE) => {
+ const chunks = [];
+ let cur = 0;
+ while (cur < file.size) {
+ chunks.push(file.slice(cur, cur + chunkSize));
+ cur += chunkSize;
+ }
+ return chunks;
+ }
const handleChangeFile = async (event) => {
if (event.target.files) {
const file = Array.from(event.target.files)[0];
+ uploadFile.current.file = file;
+ // 1、对大文件拆分切片
+ const chunks = createFileChunks(file);
...
}
event.target.value = "";
};
return ...;
};
3、生成文件唯一标识 hash 值
hash 值是根据文件内容生成的唯一标识,只要文件内容不变,生成的 hash 值永远是同一个。
生成 hash 值的目的是,在服务端可基于 hash 作为存储名称,也为后续实现 「秒传」 做铺垫。
生成 hash 过程如下:
基于切片来生成文件 hash 值
,这样可以控制生成 hash 的 进度 展示在页面上;hash 值的生成算法工具
,使用 spark-md5 第三方包来完成;利用 web worker 特性执行 hash 生成工作
,worker 用于开启一个新的工作线程来计算文件 hash,避免因为计算文件 hash(比较耗时)导致阻塞页面主流程的交互。
public/hash.js
作为 web worker 的执行文件,内容如下:
js
// 导入 spark-md5
self.importScripts("/spark-md5.min.js");
// 生成文件 hash
self.onmessage = e => {
const { chunks } = e.data;
const spark = new self.SparkMD5.ArrayBuffer();
let percentage = 0; // 进度
let count = 0; // 记录访问切片次数,用作递归的终止条件
const loadNext = index => {
const reader = new FileReader();
reader.readAsArrayBuffer(chunks[index]);
reader.onload = e => {
count++;
spark.append(e.target.result);
if (count === chunks.length) {
self.postMessage({
percentage: 100,
hash: spark.end()
});
self.close();
} else {
percentage += 100 / chunks.length;
self.postMessage({
percentage
});
loadNext(count);
}
};
};
loadNext(0);
};
在拿到切片以后,根据切片来计算文件的 hash:
jsx
// 生成文件 hash
const calculateHash = (chunks) => {
return new Promise(resolve => {
// 添加 worker 属性
const worker = new Worker("/hash.js");
worker.postMessage({ chunks });
worker.onmessage = e => {
const { percentage, hash } = e.data;
console.log("生成 hash 的进度:", percentage);
if (hash) {
console.log("生成的 hash 值:", hash);
resolve(hash);
}
};
});
}
const handleChangeFile = async (event) => {
if (event.target.files) {
const file = Array.from(event.target.files)[0];
uploadFile.current.file = file;
// 1、对大文件拆分切片
const chunks = createFileChunks(file);
// 2、根据切片,生成文件总的 hash(文件唯一标识(根据文件内容生成的 hash))
const hash = await calculateHash(chunks);
uploadFile.current.hash = hash;
...
}
};
4、并发上传切片(控制异步并发请求的数量)
尽管并发上传文件切片可以提高上传效率,但一个大文件若生成了几百个切片请求,同时并发进行 TPC
连接也可能会导致浏览器卡死。
所以并发请求的数量我们需要控制。我们可以模拟一个队列,比如同时上传 5 个切片,当有一个切片请求完成以后,从队列中再取出一个发起请求。
diff
+ const uploadFileChunks = useRef([]);
const handleChangeFile = async (event) => {
if (event.target.files) {
const file = Array.from(event.target.files)[0];
uploadFile.current.file = file;
// 1、对大文件拆分切片
const chunks = createFileChunks(file);
// 2、根据切片,生成文件总的 hash(文件唯一标识(根据文件内容生成的 hash))
const hash = await calculateHash(chunks);
uploadFile.current.hash = hash;
+ // 3、映射 chunks 结构
+ uploadFileChunks.current = chunks.map((chunk, index) => ({
+ fileName: file.name,
+ fileHash: hash,
+ chunk,
+ hash: `${hash}-${index}`, // 切片要记录各自的顺序
+ }));
+ // 4、异步请求并发控制策略
+ await startUploadRequest(uploadFileChunks.current);
...
}
};
+ const startUploadRequest = async (chunks, max = 5) => {
+ // max = 5:假设并发请求数量最大是 5(有 5 个请求通道)
+ return new Promise(resolve => {
+ const total = chunks.length; // 请求总数量
+ let index = 0; // 发起请求的数量
+ let counter = 0; // 完成请求的数量
+ const start = () => {
+ // 有请求任务,并且有请求通道
+ while (index < total && max > 0) {
+ const chunk = chunks[index];
+ max --; // 进入请求,占用一个通道
+ index ++;
+ createChunkRequest(chunk).then(() => {
+ max ++; // 请求结束,释放一个通道
+ counter ++;
+ if (counter === total) {
+ resolve();
+ } else if (index < total) {
+ start(); // 继续取出剩余请求并发出
+ }
+ })
+ }
+ }
+ start();
+ });
+ }
+ const createChunkRequest = (chunk) => {
+ const formData = new FormData();
+ formData.append("fileName", chunk.fileName);
+ formData.append("fileHash", chunk.fileHash);
+ formData.append("chunk", chunk.chunk);
+ formData.append("hash", chunk.hash);
+ return request({
+ url: "http://localhost:8080/upload/chunks",
+ data: formData,
+ });
+ }
5、合并切片
最后,在切片全部上传完成以后,发起 merge 合并切片请求,并将要合并的文件信息作为参数传递给服务端。
jsx
const handleChangeFile = async (event) => {
if (event.target.files) {
...
// 4、异步请求并发控制策略
await startUploadRequest(uploadFileChunks.current);
// 5、合并切片
await mergeChunks();
}
};
const mergeChunks = async () => {
await request({
url: "http://localhost:8080/merge/chunks",
headers: {
"content-type": "application/json"
},
data: JSON.stringify({
fileName: uploadFile.current.file.name,
fileHash: uploadFile.current.hash,
chunkSize: SIZE, // 切片大小
})
});
}
3、服务端实现
服务端需要做以下事情:
- 接收切片并存储;
- 合并切片为完整文件。
1、存储切片
target 目录用于存储所有上传文件及其切片。采用 固定前缀 + 文件 hash 作为每个文件的切片的存放目录,每个切片命名采用客户端传递过来的 hash + 切片索引 命名。
js
// server/app.js
const http = require('http');
const path = require('path');
const fs = require('fs-extra'); // 文件处理模块
const multiparty = require('multiparty'); // 处理 FormData 对象
const server = http.createServer();
// 文件存放目录
const UPLOAD_DIR = path.resolve(__dirname, '.', 'target');
server.on('request', async (req, res) => {
// 跨域处理
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Headers", "*");
if (req.method === "OPTIONS") {
res.status = 200;
res.end();
return;
}
if (req.url === "/upload/chunks" && req.method === "POST") {
const multipart = new multiparty.Form() // 解析 FormData 对象
multipart.parse(req, async (err, fields, files) => {
if (err) return; // 若上传被中断,err 信息输出为:
// const [fileName] = fields.fileName; // 是一个数组,取出第一项 文件名
const [fileHash] = fields.fileHash; // 是一个数组,取出第一项
const [chunk] = files.chunk; // chunk 文件是在 files 中读取
const [hash] = fields.hash;
// 创建 chunk 文件目录(固定前缀 + hash 作为切片文件夹名)
const chunkDir = path.resolve(UPLOAD_DIR, `chunkDir_${fileHash}`);
if (!fs.existsSync(chunkDir)) {
await fs.mkdirs(chunkDir);
}
// 存储切片(使用 hash(客户端设置的 fileHash + index) 作为切片名称)
await fs.move(chunk.path, `${chunkDir}/${hash}`);
res.end(JSON.stringify({
code: 0,
message: 'upload success.'
}));
});
}
})
server.listen(8080, () => console.log('服务已启动, port 8080.'));
2、合并切片
读取该文件的切片目录下的所有切片,以 文件 hash + 文件后缀 作为合并后的文件名。合并切片的方式采用 读写流(readStream/writeStream)
来完成,不过注意:合并时要根据切片索引进行排序,以保证切片合并的顺序正确。
jsx
server.on('request', async (req, res) => {
...
// 提取后缀名
const extractExt = fileName =>
fileName.slice(fileName.lastIndexOf("."), fileName.length);
// 提取 request.body
const resolvePost = req => {
return new Promise(resolve => {
let chunk = "";
req.on("data", data => {
chunk += data;
});
req.on("end", () => {
resolve(JSON.parse(chunk));
});
});
}
// 写入文件流
const pipeStream = (path, writeStream) => {
return new Promise(resolve => {
const readStream = fs.createReadStream(path);
readStream.on("end", () => {
fs.unlinkSync(path);
resolve();
});
readStream.pipe(writeStream);
});
}
// 合并切片
const mergeFileChunk = async (filePath, fileHash, chunkSize) => {
const chunkDir = path.resolve(UPLOAD_DIR, `chunkDir_${fileHash}`);;
const chunkPaths = await fs.readdir(chunkDir);
// 根据切片下标进行排序,然后进行合并
chunkPaths.sort((a, b) => a.split("-")[1] - b.split("-")[1]);
// 并发合并写入文件
await Promise.all(
chunkPaths.map((chunkPath, index) =>
pipeStream(
path.resolve(chunkDir, chunkPath),
// 根据 chunkSize 在指定位置创建可写流
fs.createWriteStream(filePath, {
start: index * chunkSize
})
)
)
);
// 合并后删除保存切片的目录
fs.rmdirSync(chunkDir);
};
if (req.url === "/merge/chunks" && req.method === "POST") {
const data = await resolvePost(req);
const { fileHash, fileName, chunkSize } = data;
const ext = extractExt(fileName);
// 使用 hash + 扩展名,作为合并后的文件名。
const filePath = path.resolve(UPLOAD_DIR, `${fileHash}${ext}`);
await mergeFileChunk(filePath, fileHash, chunkSize);
res.end(
JSON.stringify({
code: 0,
message: "file merged success"
})
);
}
})
十一、秒传(大文件)
所谓文件秒传,即在服务端已经存在了上传的资源,当用户再次上传时会直接提示上传成功。这一般用在较大的文件中,避免重复消耗时间和流量。
秒传功能需要依赖文件 hash,根据 hash 判定是否文件是否存在,进而实现秒传。
具体过程:在上传文件之前,需要先将 hash 值发送给服务端验证文件是否被上传过,一旦找到与 hash 相同的文件,直接返回上传成功信息。
1、客户端
在上传切片之前,先将生成的文件 hash 传给后台校验文件是否存在,若存在终止后续上传过程。
diff
+ // 验证文件是否已上传
+ const checkUpload = async (fileName, fileHash) => {
+ const { data } = await request({
+ url: "http://localhost:8080/check/upload",
+ headers: {
+ "content-type": "application/json"
+ },
+ // 服务端根据 文件 hash + 文件后缀 查找是否已被上传过
+ data: JSON.stringify({
+ fileName,
+ fileHash,
+ })
+ });
+ return JSON.parse(data);
+ }
const handleChangeFile = async (event) => {
if (event.target.files) {
const file = Array.from(event.target.files)[0];
uploadFile.current.file = file;
// 1、对大文件拆分切片
const chunks = createFileChunks(file);
// 2、根据切片,生成文件总的 hash(文件唯一标识(根据文件内容生成的 hash))
const hash = await calculateHash(chunks);
uploadFile.current.hash = hash;
// 校验文件是否上传过
+ const { shouldUpload } = await checkUpload(file.name, hash);
+ if (!shouldUpload) {
+ console.log("文件在服务器上已被上传过,进入秒传场景。");
+ return;
+ }
...
}
event.target.value = "";
};
2、服务端
diff
server.on('request', async (req, res) => {
...
// 提取后缀名
const extractExt = fileName =>
fileName.slice(fileName.lastIndexOf("."), fileName.length);
// 提取 request.body
const resolvePost = req => {
return new Promise(resolve => {
let chunk = "";
req.on("data", data => {
chunk += data;
});
req.on("end", () => {
resolve(JSON.parse(chunk));
});
});
}
+ if (req.url === "/check/upload" && req.method === "POST") {
+ const data = await resolvePost(req);
+ const { fileHash, fileName } = data;
+ const ext = extractExt(fileName);
+ // 根据 文件 hash 和 文件后缀,在服务器上查找相同文件
+ const filePath = path.resolve(UPLOAD_DIR, `${fileHash}${ext}`);
+ const shouldUpload = !fs.existsSync(filePath);
+ res.end(
+ JSON.stringify({
+ code: 0,
+ shouldUpload
+ })
+ );
+ }
...
})
十二、断点续传(大文件)
即在文件暂停上传以后,可以继续上次上传的位置来完成上传。
1、思路
按照上面的实现,服务端会建立一个文件夹存储所有上传的切片。那么在恢复上传之前可以向服务端查询一次,服务端将已上传过的切片返回,前端上传时则可以跳过它们,只将未上传的切片进行上传。这个逻辑可以和 秒传 接口放在一起。
2、客户端取消上传
创建 requestTasks
时,我们将 chunk.abortRef
绑定到 xhr.abort
函数的引用,在需要取消的时候执行它。(关于 xhr.abort
在上面 取消/暂停上传 一节有介绍)
diff
const handleChangeFile = async (event) => {
if (event.target.files) {
...
// 3、映射 chunks 结构
uploadFileChunks.current = chunks.map((chunk, index) => ({
fileName: file.name,
fileHash: hash,
chunk,
hash: `${hash}-${index}`, // 切片要记录各自的顺序
+ abortRef: { current: null }
}));
// 4、创建请求任务
const requestTasks = uploadFileChunks.current.map(chunk => {
const formData = new FormData();
formData.append("fileName", chunk.fileName);
formData.append("fileHash", chunk.fileHash);
formData.append("chunk", chunk.chunk);
formData.append("hash", chunk.hash);
return request({
url: "http://localhost:8080/upload/chunks",
data: formData,
+ abortRef: chunk.abortRef,
});
});
// 5、并发上传
await Promise.all(requestTasks);
// 6、合并切片
await mergeChunks();
}
event.target.value = "";
};
+ const handlePause = () => {
+ uploadFileChunks.current.forEach(chunk => {
+ chunk.abortRef.current(); // 中断切片上传请求
+ });
+ }
+ <button onClick={handlePause}>暂停</button>
3、服务端返回已上传的切片列表
我们先来看服务端的实现:当客户端继续上传时先请求 checkUpload
,服务端根据文件 hash 返回已上传的切片列表。
diff
server.on('request', async (req, res) => {
...
// 返回已上传的所有切片名
+ const createUploadedList = async (fileHash) =>
+ fs.existsSync(path.resolve(UPLOAD_DIR, `chunkDir_${fileHash}`))
+ ? await fs.readdir(path.resolve(UPLOAD_DIR, `chunkDir_${fileHash}`))
+ : [];
if (req.url === "/check/upload" && req.method === "POST") {
const data = await resolvePost(req);
const { fileHash, fileName } = data;
const ext = extractExt(fileName);
// 根据 文件 hash 和 文件后缀,在服务器上查找相同文件
const filePath = path.resolve(UPLOAD_DIR, `${fileHash}${ext}`);
const shouldUpload = !fs.existsSync(filePath);
res.end(
JSON.stringify({
code: 0,
shouldUpload,
+ uploadedChunkList: await createUploadedList(fileHash),
})
);
}
})
4、客户端跳过已上传切片
客户端在拿到已上传切片列表后,可过滤掉已上传的部分,只上传未上传的切片。
diff
<button onClick={handleResume}>继续</button>
const handleResume = async () => {
+ const { uploadedChunkList } = await checkUpload(
+ uploadFile.current.file.name,
+ uploadFile.current.hash,
+ );
+ // 上传切片,过滤已上传的切片
+ const chunksState = uploadFileChunks.current
+ .filter(({ hash }) => !uploadedChunkList.includes(hash));
// 异步请求并发控制策略
await startUploadRequest(chunksState);
// 合并切片
await mergeSlices();
}
十三、扩展和思考
大文件上传 借鉴了 @yeyan1996
作者的思路和代码,以上内容基本可以覆盖日常开发文件上传场景。但还有 优化 或者是 进一步提升的空间。比如:
- 计算
hash
除了使用web-workder
让出主线程进行计算外,还可以参考React Fiber 架构
利用浏览器空闲时间来计算 hash; - 完整计算一个文件
hash
非常耗时,采用 抽样 hash 牺牲一点命中率来提升计算 hash 效率; - 根据网络情况,动态调整切片大小;
- 报错重试机制;
- 了解服务端如何做 文件碎片清理 工作;
- 使用体验优化,如:上传中离开页面给出拦截提示(
beforeunload
)、展示上传网速、展示上传剩余时间 等。
以上扩展内容可以参考 大圣老师
这篇文章(字节跳动面试官,我也实现了大文件上传和断点续传)给出的思路,再结合自己的思考来完善功能。