方案
前端:vue、element-ui
服务端:nodejs
分片上传
将大文件切片成多个小文件块,然后同时上传
思路
前端
前端通过的文件选择器,获取用户选择的文件
handleFileChange(e) {
const [file] = e.target.files;
// ...
}
设定分片大小,将文件切片成多个文件块
const SIZE = 10 * 1024 * 1024; // 切片大小
// 生成文件切片
createFileChunk(file, size = SIZE) {
const fileChunkList = [];
let cur = 0;
while (cur < file.size) {
fileChunkList.push({ file: file.slice(cur, cur + size) });
cur += size;
}
return fileChunkList;
},
使用 web-worker 在 worker 线程计算 hash(用于标识不同的文件内容),避免页面假死
calculateHash(fileChunkList) {
return new Promise(resolve => {
// 添加 worker 属性
this.container.worker = new Worker("/hash.js");
this.container.worker.postMessage({ fileChunkList });
this.container.worker.onmessage = e => {
const { hash } = e.data;
if (hash) {
resolve(hash);
}
};
});
}
// /public/hash.js
self.importScripts("/spark-md5.min.js"); // 导入脚本
// 生成文件 hash
self.onmessage = e => {
const { fileChunkList } = e.data;
const spark = new self.SparkMD5.ArrayBuffer();
let count = 0;
const loadNext = index => {
const reader = new FileReader();
reader.readAsArrayBuffer(fileChunkList[index].file);
reader.onload = e => {
count++;
spark.append(e.target.result);
if (count === fileChunkList.length) {
self.postMessage({
hash: spark.end()
});
self.close();
}
// 递归计算下一个切片
loadNext(count);
};
};
loadNext(0);
};
上传分片
上传
// 上传切片
async uploadChunks() {
const requestList = this.data
.map(({ chunk,hash }) => {
const formData = new FormData();
formData.append("chunk", chunk);
formData.append("hash", hash);
formData.append("filehash", this.container.hash);
return { formData };
})
.map(async ({ formData }) =>
this.request({
url: "http://localhost:3000",
data: formData
})
);
await Promise.all(requestList); // 并发切片
},
async handleUpload() {
if (!this.container.file) return;
const fileChunkList = this.createFileChunk(this.container.file);
this.container.hash = await this.calculateHash(fileChunkList);
this.data = fileChunkList.map(({ file },index) => ({
chunk: file,
hash: this.container.hash + "-" + index // hash + 数组下标
}));
await this.uploadChunks();
}
后端
接收分片
const http = require("http");
const path = require("path");
const fse = require("fs-extra");
const multiparty = require("multiparty");
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;
}
// 使用 multiparty 包处理前端传来的 FormData
// 在 multiparty.parse 的回调中,files 参数保存了 FormData 中文件,fields 参数保存了 FormData 中非文件的字段
const multipart = new multiparty.Form();
multipart.parse(req, async (err, fields, files) => {
if (err) {
return;
}
const [chunk] = files.chunk;
const [hash] = fields.hash;
const [filehash] = fields.filehash;
const chunkDir = path.resolve(UPLOAD_DIR, filehash);
// 切片目录不存在,创建切片目录
if (!fse.existsSync(chunkDir)) {
await fse.mkdirs(chunkDir);
}
// fs-extra 专用方法,类似 fs.rename 并且跨平台
// fs-extra 的 rename 方法 windows 平台会有权限问题
await fse.move(chunk.path, `${chunkDir}/${hash}`);
res.end("received file chunk");
});
});
server.listen(3000, () => console.log("正在监听 3000 端口"));
合并分片
const pipeStream = (path, writeStream) =>
new Promise(resolve => {
const readStream = fse.createReadStream(path);
readStream.on("end", () => {
fse.unlinkSync(path);
resolve();
});
readStream.pipe(writeStream);
});
const mergeFileChunk = async (filePath, filehash, size) => {
const chunkDir = path.resolve(UPLOAD_DIR, filehash);
const chunkPaths = await fse.readdir(chunkDir);
// 根据切片下标进行排序
// 否则直接读取目录的获得的顺序可能会错乱
chunkPaths.sort((a, b) => a.split("-")[1] - b.split("-")[1]);
await Promise.all(
chunkPaths.map((chunkPath, index) =>
pipeStream(
path.resolve(chunkDir, chunkPath),
// 指定位置创建可写流
fse.createWriteStream(filePath, {
start: index * size,
end: (index + 1) * size
})
)
)
);
fse.rmdirSync(chunkDir); // 合并后删除保存切片的目录
};
断点续传
在一些暂停/恢复的上传场景下,需要在上一次的位置接着上传
思路
断点续传是在分片上传的基础之上的
在用户点击暂停时,将所有正在上传的分片取消
暂停
恢复
request({
url,
method = "post",
data,
headers = {},
requestList
}) {
return new Promise(resolve => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
Object.keys(headers).forEach(key =>
xhr.setRequestHeader(key, headers[key])
);
xhr.send(data);
xhr.onload = e => {
// 将请求成功的 xhr 从列表中删除
if (requestList) {
const xhrIndex = requestList.findIndex(item => item === xhr);
requestList.splice(xhrIndex, 1);
}
resolve({
data: e.target.response
});
};
// 暴露正在上传的 xhr 给外部
requestList?.push(xhr);
});
}
handlePause() {
this.requestList.forEach(xhr => xhr?.abort());
this.requestList = [];
}
当用户点击恢复时,向服务器询问已经上传了哪些分片,然后继续上传剩余的分片即可async handleResume() {
const { uploadedList } = await this.verifyUpload(
this.container.file.name,
this.container.hash
);
await this.uploadChunks();
}
// 对之前的函数微改
async uploadChunks() {
const requestList = this.data
.filter(({ hash }) => !uploadedList.includes(hash))
.map(({ chunk,hash }) => {
// ...
})
.map(async ({ formData }) =>
// ...
);
await Promise.all(requestList); // 并发切片
},
服务端接口// 返回已经上传切片名列表
const createUploadedList = async fileHash =>
fse.existsSync(path.resolve(UPLOAD_DIR, fileHash))
? await fse.readdir(path.resolve(UPLOAD_DIR, fileHash))
: [];
server.on("request", async (req, res) => {
if (req.url === "/verifyUpload") {
const data = await resolvePost(req);
const { fileHash} = data;
res.end(
JSON.stringify({
uploadedList: await createUploadedList(fileHash)
})
);
}
});
秒传
即不管多大的文件,都是瞬间上传完成
思路
同分片上传时向服务器询问哪些分片已上传
实现秒传则是向服务器询问该文件是否在服务器已存在,若存在则不用继续上传,即秒传(用户看来)
参考文章