文件断点续传
思路脉络
- 解决方案核心是利用 Blob.prototype.slice 方法,和数组的 slice 方法相似,文件的 slice 方法可以返回原文件的某个切片
- 预先定义好单个切片大小,将文件切分为一个个切片,然后借助 http 的可并发性,同时上传多个切片。
- 这样从原本传一个大文件,变成了并发传多个小的文件切片,可以大大减少上传时间
- 另外由于是并发,传输到服务端的顺序可能会发生变化,因此还需要给每个切片记录顺序
const SIZE = 10 * 1024 * 1024; // 10MB
class fileUpload {
// 生成文件切片
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;
}
// 上传切片
async uploadChunks() {
const requestList = this.data
.map(({ chunk, hash }) => {
const formData = new FormData();
formData.append("chunk", chunk);
formData.append("hash", hash);
formData.append("filename", this.container.file.name);
return { formData };
})
.map(({ formData }) =>
this.request({
url: "http://localhost:3000",
data: formData,
})
);
// 并发请求
await Promise.all(requestList);
// 合并切片
await this.mergeRequest();
}
async mergeRequest() {
await this.request({
url: "http://localhost:3000/merge",
headers: {
"content-type": "application/json",
},
data: JSON.stringify({
filename: this.container.file.name,
}),
});
}
async handleUpload() {
if (!this.container.file) return;
const fileChunkList = this.createFileChunk(this.container.file);
this.data = fileChunkList.map(({ file }, index) => ({
chunk: file,
hash: this.container.file.name + "-" + index,
}));
await this.uploadChunks();
}
}
功能点:显示进度条
- XMLHttpRequest 原生支持上传进度的监听,只需要监听 upload.onprogress 即可,在原来的 request 基础上传入 onProgress 参数,给 XMLHttpRequest 注册监听事件
功能点:断点续传
- 断点续传的原理在于
前端/服务端
需要记住已上传的切片
,这样下次上传就可以跳过之前已上传的部分,有两种方案实现记忆的功能- 前端使用 localStorage 记录已上传的切片 hash (缺陷:
换浏览器就失去了记忆的效果
) - 服务端保存已上传的切片 hash,前端每次上传前向服务端获取已上传的切片
- 前端使用 localStorage 记录已上传的切片 hash (缺陷:
生成 hash
无论是前端还是服务端,都必须要生成文件和切片的 hash
如果上传一个超大文件,读取文件内容计算 hash 是
非常耗费时间
的,并且会引起UI 的阻塞
,导致页面假死状态,所以可以使用
web-worker
在 worker 线程计算 hash,这样用户仍可以在主界面正常的交互在 worker 线程中,接受文件切片 fileChunkList,利用 fileReader 读取每个切片的 ArrayBuffer
并不断传入 spark-md5 中,每计算完一个切片通过 postMessage 向主线程发送一个进度事件,全部完成后将最终的 hash 发送给主线程
方案一: web-worker
// web-worker 代码
// 库 spark-md5,它可以根据文件内容计算出文件的 hash
self.importScripts("/spark-md5.min.js");
// 生成文件 hash
self.onmessage = e => {
const { fileChunkList } = e.data;
const spark = new self.SparkMD5.ArrayBuffer();
let percentage = 0;
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({
percentage: 100,
hash: spark.end()
});
self.close();
} else {
percentage += 100 / fileChunkList.length;
self.postMessage({
percentage
});
// calculate recursively
loadNext(count);
}
};
};
loadNext(0);
};
// 和主线程的通讯
// 主线程使用 postMessage 给 worker 线程传入所有切片 fileChunkList,
// 并监听 worker 线程发出的 postMessage 事件拿到文件 hash
function calculateHash(fileChunkList) {
return new Promise((resolve) => {
this.container.worker = new Worker("/hash.js");
this.container.worker.postMessage({ fileChunkList });
this.container.worker.onmessage = (e) => {
const { percentage, hash } = e.data;
this.hashPercentage = percentage;
if (hash) {
resolve(hash);
}
};
});
}
方案二:requestIdleCallback 时间切片
- window.requestIdleCallback()方法将在浏览器的空闲时段内调用的函数排队。
- 这能够在主事件循环上执行后台和低优先级工作 requestIdelCallback 执行的方法,会传递一个 deadline 参数,
能够知道当前帧的剩余时间
async function calculateHashIdle(chunks) {
return new Promise((resolve) => {
const spark = newSparkMD5.ArrayBuffer();
let count = 0;
// 根据文件内容追加计算
const appendToSpark = async (file) => {
return newPromise((resolve) => {
const reader = newFileReader();
reader.readAsArrayBuffer(file);
reader.onload = (e) => {
spark.append(e.target.result);
resolve();
};
});
};
const workLoop = async (deadline) => {
// 有任务,并且当前帧还没结束
while (count < chunks.length && deadline.timeRemaining() > 1) {
await appendToSpark(chunks[count].file);
count++;
// 没有了 计算完毕
if (count < chunks.length) {
// 计算中
this.hashProgress = Number(
((100 * count) / chunks.length).toFixed(2)
);
} else {
// 计算完毕
this.hashProgress = 100;
resolve(spark.end());
}
}
window.requestIdleCallback(workLoop);
};
window.requestIdleCallback(workLoop);
});
}
文件秒传
- 所谓的文件秒传,即在服务端已经存在了上传的资源,所以当用户再次上传时会直接提示上传成功
- 文件秒传需要依赖上一步生成的 hash,即在上传前,先计算出文件 hash,并把 hash 发送给服务端进行验证,由于 hash 的唯一性,所以一旦服务端能找到 hash 相同的文件,则直接返回上传成功的信息即可
暂停上传
- 原理是使用 XMLHttpRequest 的 abort 方法,可以取消一个 xhr 请求的发送,为此需要将上传每个切片的 xhr 对象保存起来
恢复上传
由于当文件切片上传后,服务端会建立一个文件夹存储所有上传的切片,所以每次前端上传前可以调用一个接口,服务端将已上传的切片的切片名返回,前端再跳过这些已经上传切片
这个接口可以和之前秒传的验证接口合并,前端每次上传前发送一个验证的请求,返回两种结果
- 服务端已存在该文件,不需要再次上传
- 服务端不存在该文件或者已上传部分文件切片,通知前端进行上传,并把已上传的文件切片返回给前端
注意点
计算 hash 耗时的问题,不仅可以通过
web-workder
,还可以参考 React 的 FFiber 架构,通过requestIdleCallback
来利用浏览器的空闲时间计算,也不会卡死主线程文件 hash 的计算,是为了判断文件是否存在,进而实现秒传的功能,所以我们可以参考 布隆过滤器的理念, 牺牲一点点的识别率来换取时间,比如我们可以 抽样算 hash
web-workder 让 hash 计算不卡顿主线程,但是大文件由于切片过多,过多的 HTTP 链接过去,也会把浏览器打挂 , 可以通过
控制异步请求的并发数
来解决并发上传中,报错如何重试,比如每个切片我们允许重试两次,三次再终止
由于文件大小不一,每个切片的大小设置成固定的也有点略显笨拙,可以参考 TCP 协议的 慢启动策略, 设置一个初始大小,根据上传任务完成的时候,来动态调整下一个切片的大小, 确保文件切片的大小和当前网速匹配
文件碎片清理:很多人传了一半就离开了,这些切片存在就没意义了,可以考虑定期清理,当然 ,可以使用 node-schedule 来管理定时任务 比如我们每天扫一次 target,如果文件的修改时间是一个月以前了,就直接删除