demo
大文件上传
- 使用Blob.prototype.slice方法将大文件分割成指定大小文件切片。
- 使用formData并行上传所有文件切片数据(由于并行上传,将文件切片的顺序告诉服务端)。
- 所有切片全部上传后通知服务端进行文件合并。
- 服务端使用multiparty处理前端传的formData数据,存储所有文件切片数据。
- 服务端根据文件切片的顺序使用读写流进行文件合并,合并成功后删除文件切片数据。
前端代码
import React, { useState } from 'react';
import { Button, message } from 'antd';
import request from '@/utils/request';
const SIZE = 10 * 1024 * 1024;
const BigFileUpload = () => {
const [uploadStatus, setUploadStatus] = useState('');
const [selectFile, setSelectFile] = useState<any>(null);
const onUploadChange = (e: any) => {
const [file] = e.target.files;
if (file) {
setSelectFile(file);
}
};
const createFileChunk = (file: any) => {
const fileChunkList = [];
let cur = 0;
while (cur < file.size) {
fileChunkList.push({ file: file.slice(cur, cur + SIZE) });
cur += SIZE;
}
return fileChunkList;
};
const uploadFile = () => {
if (selectFile) {
setUploadStatus('loading');
const fileChunkList = createFileChunk(selectFile);
const fileChunkData: any = fileChunkList.map(({ file }, index) => ({
index,
chunk: file,
size: file.size
}));
uploadChunks(fileChunkData);
}
};
const uploadChunks = async (fileChunkData: any) => {
const requestList = fileChunkData
.map(({ chunk, index }: { chunk: any; index: number }) => {
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('filename', selectFile.name);
formData.append('index', String(index));
return { formData, index };
})
.map(({ formData }: { formData: any; index: number }) =>
request({
url: '/upload',
method: 'POST',
data: formData
})
);
await Promise.all(requestList);
await mergeFile();
};
const mergeFile = async () => {
await request({
url: '/merge',
method: 'POST',
data: JSON.stringify({
size: SIZE,
filename: selectFile.name
})
});
message.success('上传成功');
setUploadStatus('');
};
return (
<div>
{/* 正在上传中时disabled */}
<input
type="file"
onChange={onUploadChange}
disabled={uploadStatus === 'loading'}
/>
{/* 没有选择文件或正在上传中时disabled */}
<Button
onClick={uploadFile}
disabled={!selectFile || uploadStatus === 'loading'}
>
上传
</Button>
</div>
);
};
export default BigFileUpload;
服务端代码
const multiparty = require("multiparty")
const fse = require("fs-extra")
const path = require("path")
// 大文件存储目录
const UPLOAD_DIR = path.resolve(__dirname, "..", "files")
// 写入文件流
const pipeStream = (path, writeStream) =>
new Promise(resolve => {
const readStream = fse.createReadStream(path)
readStream.on("end", () => {
fse.unlinkSync(path)
resolve()
})
readStream.pipe(writeStream)
})
// 提取POST请求参数
const resolvePost = req =>
new Promise(resolve => {
let chunk = ""
req.on("data", data => {
chunk += data
})
req.on("end", () => {
resolve(JSON.parse(chunk))
})
})
// 创建临时文件夹用于临时存储chunk (添加 chunkDir 前缀与文件名做区分)
const getChunkDir = fileName => path.resolve(UPLOAD_DIR, `chunkDir_${fileName}`)
// 合并切片
const mergeFileChunk = async (filePath, filename, size) => {
const chunkDir = getChunkDir(filename)
const chunkPaths = await fse.readdir(chunkDir)
// 根据切片下标进行排序,否则直接读取目录的获得的顺序会错乱
chunkPaths.sort((a, b) => a - b)
// 并发写入文件
await Promise.all(
chunkPaths.map((chunkPath, index) =>
pipeStream(
path.resolve(chunkDir, chunkPath),
// 根据 size 在指定位置创建可写流
fse.createWriteStream(filePath, {
start: index * size
})
)
)
)
// 合并后删除保存切片的目录
fse.rmdirSync(chunkDir)
}
module.exports = class {
// 处理文件切片
async handleFormData(req, res) {
const multipart = new multiparty.Form()
multipart.parse(req, async (err, fields, files) => {
if (err) {
console.error(err)
res.status = 500
res.end(
JSON.stringify({
code: 100,
message: "file error"
})
)
return
}
const [chunk] = files.chunk
const [filename] = fields.filename
const [index] = fields.index
const filePath = path.resolve(
UPLOAD_DIR,
`${filename}`
)
const chunkDir = getChunkDir(filename)
const chunkPath = path.resolve(chunkDir, index)
// 最终合并后的文件已经存在直接返回
if (fse.existsSync(filePath)) {
res.end(
JSON.stringify({
code: 0,
message: "file exist"
})
)
return
}
// 切片存在直接返回
if (fse.existsSync(chunkPath)) {
res.end(
JSON.stringify({
code: 0,
message: "chunk exist"
})
)
return
}
// 切片目录不存在,创建切片目录
if (!fse.existsSync(chunkDir)) {
await fse.mkdirs(chunkDir)
}
await fse.move(chunk.path, chunkPath)
res.end(
JSON.stringify({
code: 0,
message: "success"
})
)
})
}
// 合并切片
async handleMerge(req, res) {
const data = await resolvePost(req)
const { filename, size } = data
const filePath = path.resolve(UPLOAD_DIR, `${filename}`)
await mergeFileChunk(filePath, filename, size)
res.end(
JSON.stringify({
code: 0,
message: "success"
})
)
}
}
显示上传进度条
- 使用axios的onUploadProgress方法获取到切片上传的进度,可以独立显示每个文件切片的上传进度。
- 根据每个切片的上传进度计算出整个文件的上传进度可以显示整个文件的上传进度。
断点续传
- 记住已上传的切片,下次上传时跳过已上传的文件切片。
- 前端使用localStorage存储已上传的切片(切换浏览器后失效)
- 服务端存储,前端每次上传前询问服务端已上传的部分(建议使用)。
- 使用spark-md5计算文件hash值作为文件的唯一标识,因为使用文件名作为标识时,文件名一修改就失去效果。计算hash是非常耗费时间,使用web-worker在worker线程计算hash,这样用户仍可以在主界面正常的交互。
- 暂停上传使用axios的CancelToken取消请求。
- 恢复上传时询问服务端已经成功上传的文件切片,重新上传时过滤掉已经上传的文件切片。
前端代码
import React, { useEffect, useState, useRef } from 'react';
import { Button, message, Table, Progress } from 'antd';
import axios from 'axios';
import request from '@/utils/request';
const { CancelToken } = axios;
const SIZE = 100 * 1024 * 1024;
const BigFileContinueUpload = () => {
const cancelRequestRef = useRef<any>([]);
const [uploadStatus, setUploadStatus] = useState('');
const [selectFile, setSelectFile] = useState<any>(null);
const [selectFileHash, setSelectFileHash] = useState<any>(null);
const [chunkData, setChunkData] = useState<any>([]);
const [totalPercentage, setTotalPercentage] = useState<number>(0);
useEffect(() => {
let percentage = 0;
chunkData.forEach((item: any) => (percentage += item.percentage));
setTotalPercentage(percentage / 3);
}, [chunkData]);
const onUploadChange = (e: any) => {
const [file] = e.target.files;
if (file) {
setSelectFile(file);
}
};
const calculateHash = (fileChunkList: any) => {
return new Promise(resolve => {
const worker = new Worker('/hash.js');
worker.postMessage({ fileChunkList });
worker.onmessage = e => {
const { hash } = e.data;
if (hash) {
resolve(hash);
}
};
});
};
const createFileChunk = (file: any) => {
const fileChunkList = [];
let cur = 0;
while (cur < file.size) {
fileChunkList.push({ file: file.slice(cur, cur + SIZE) });
cur += SIZE;
}
return fileChunkList;
};
const uploadFile = async () => {
if (selectFile) {
setUploadStatus('loading');
const fileChunkList = createFileChunk(selectFile);
const fileHash = await calculateHash(fileChunkList);
setSelectFileHash(fileHash);
const fileChunkData: any = fileChunkList.map(({ file }, index) => ({
index,
chunk: file,
size: file.size,
percentage: 0
}));
setChunkData(fileChunkData);
uploadChunks(fileChunkData, fileHash);
}
};
const uploadChunks = async (
fileChunkData: any,
fileHash: any,
hasUploadedChunk: any = []
) => {
const requestList = fileChunkData
.filter(
({ index }: { index: number }) =>
!hasUploadedChunk.includes(String(index))
)
.map(({ chunk, index }: { chunk: any; index: number }) => {
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('filename', selectFile.name);
formData.append('filehash', fileHash);
formData.append('index', String(index));
return { formData, index };
})
.map(({ formData, index }: { formData: any; index: number }) =>
request({
url: '/upload',
method: 'POST',
data: formData,
onUploadProgress: function (progressEvent: any) {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
setChunkData((prev: any) => {
const newData = [...prev];
newData[index].percentage = percentCompleted;
return newData;
});
},
cancelToken: new CancelToken((cancel: any) => {
cancelRequestRef.current.push(cancel);
})
})
);
await Promise.all(requestList);
await mergeFile(fileHash);
};
const mergeFile = async (fileHash: any) => {
await request({
url: '/merge',
method: 'POST',
data: JSON.stringify({
size: SIZE,
filename: selectFile.name,
fileHash: fileHash
})
});
message.success('上传成功');
setUploadStatus('');
};
const pauseUpload = () => {
(cancelRequestRef.current || []).forEach((cancel: any) => cancel());
};
const resumeUpload = async () => {
const res = await request({
url: '/verify',
method: 'POST',
data: JSON.stringify({
fileHash: selectFileHash
})
});
uploadChunks(chunkData, selectFileHash, res.data?.uploadedList || []);
};
const columns = [
{
title: '切片',
dataIndex: 'index',
render: (value: number) => `${selectFile.name}-${value}`
},
{
title: '切片大小',
dataIndex: 'size'
},
{
title: '进度',
dataIndex: 'percentage',
render: (value: number) => <Progress percent={value} size="small" />
}
];
return (
<div>
{/* 正在上传中时disabled */}
<input
type="file"
onChange={onUploadChange}
disabled={uploadStatus === 'loading'}
/>
{/* 没有选择文件或正在上传中时disabled */}
<Button
onClick={uploadFile}
disabled={!selectFile || uploadStatus === 'loading'}
type="primary"
>
上传
</Button>
<Button onClick={pauseUpload}>暂停上传</Button>
<Button onClick={resumeUpload}>继续上传</Button>
<h6>总上传进度</h6>
<Progress percent={totalPercentage} style={{ width: '90%' }} />
<h6>文件切片上传进度</h6>
<Table columns={columns} dataSource={chunkData} />
</div>
);
};
export default BigFileContinueUpload;
服务端代码
/**
* 大文件断点续传
*/
/* eslint-disable */
const multiparty = require("multiparty")
const fse = require("fs-extra")
const path = require("path")
// 大文件存储目录
const UPLOAD_DIR = path.resolve(__dirname, "..", "files")
// 写入文件流
const pipeStream = (path, writeStream) =>
new Promise(resolve => {
const readStream = fse.createReadStream(path)
readStream.on("end", () => {
fse.unlinkSync(path)
resolve()
})
readStream.pipe(writeStream)
})
// 提取POST请求参数
const resolvePost = req =>
new Promise(resolve => {
let chunk = ""
req.on("data", data => {
chunk += data
})
req.on("end", () => {
resolve(JSON.parse(chunk))
})
})
// 创建临时文件夹用于临时存储chunk (添加 chunkDir 前缀与文件hash做区分)
const getChunkDir = fileHash => path.resolve(UPLOAD_DIR, `chunkDir_${fileHash}`)
// 返回已上传的所有切片名
const createUploadedList = async fileHash =>
fse.existsSync(getChunkDir(fileHash))
? await fse.readdir(getChunkDir(fileHash))
: []
// 合并切片
const mergeFileChunk = async (filePath, size, fileHash) => {
const chunkDir = getChunkDir(fileHash)
const chunkPaths = await fse.readdir(chunkDir)
// 根据切片下标进行排序,否则直接读取目录的获得的顺序会错乱
chunkPaths.sort((a, b) => a - b)
// 并发写入文件
await Promise.all(
chunkPaths.map((chunkPath, index) =>
pipeStream(
path.resolve(chunkDir, chunkPath),
// 根据 size 在指定位置创建可写流
fse.createWriteStream(filePath, {
start: index * size
})
)
)
)
// 合并后删除保存切片的目录
fse.rmdirSync(chunkDir)
}
module.exports = class {
// 获取已上传的文件切片
async handleVerifyUpload(req, res) {
const data = await resolvePost(req)
const { fileHash } = data
res.end(
JSON.stringify({
uploadedList: await createUploadedList(fileHash)
})
)
}
// 处理文件切片
async handleFormData(req, res) {
const multipart = new multiparty.Form()
multipart.parse(req, async (err, fields, files) => {
if (err) {
console.error(err)
res.status = 500
res.end(
JSON.stringify({
code: 100,
message: "file error"
})
)
return
}
const [chunk] = files.chunk
const [filename] = fields.filename
const [index] = fields.index
const [filehash] = fields.filehash
const filePath = path.resolve(
UPLOAD_DIR,
`${filename}`
)
const chunkDir = getChunkDir(filehash)
const chunkPath = path.resolve(chunkDir, index)
// 最终合并后的文件已经存在直接返回
if (fse.existsSync(filePath)) {
res.end(
JSON.stringify({
code: 0,
message: "file exist"
})
)
return
}
// 切片存在直接返回
if (fse.existsSync(chunkPath)) {
res.end(
JSON.stringify({
code: 0,
message: "chunk exist"
})
)
return
}
// 切片目录不存在,创建切片目录
if (!fse.existsSync(chunkDir)) {
await fse.mkdirs(chunkDir)
}
await fse.move(chunk.path, chunkPath)
res.end(
JSON.stringify({
code: 0,
message: "success"
})
)
})
}
// 合并切片
async handleMerge(req, res) {
const data = await resolvePost(req)
const { filename, size, fileHash } = data
const filePath = path.resolve(UPLOAD_DIR, `${filename}`)
await mergeFileChunk(filePath, size, fileHash)
res.end(
JSON.stringify({
code: 0,
message: "success"
})
)
}
}