文件切片上传
想法与步骤
- 切片上传: 将文件切成多个小块,然后通过上传文件的接口发送给服务端,当切片发送完成后,再通过接口请求让后端进行合并
- 计算 md5 值,作为唯一标识,是断点续传与秒传的基础
- 断点续传: 先请求接口,返回已上传完成的切片,本地对文件切割后与已接口返回的已上传切片进行对比,只发送已上传的内容
- 秒传:请求校验接口,判断是否已存在该文件
想法中存在的问题
- 用什么东西对文件切片 ?
- 文件怎么读取?
- 大文件计算 md5 会不会长时间占用主线程,导致页面卡顿?
- 切片过多,会不会占用很多网络请求 ?
读法统一
文章下面对 Blob 对象【类文件对象】统称为文件
尝试
文件切片
// 单个切片大小
const chunkSize = 1 * 1024 * 1024;
/**
* file: 文件上传时,通过e.target.files[0]拿到的值
*/
createFileChunk(file: File) {
let current = 0;
// 保存与返回所有切片的参数
let chunks: Array<Blob> = [];
while (current < file.size) {
// 文件进行切片
const chunk = file.slice(current, current + chunkSize);
chunks.push(chunk);
current = current + chunkSize;
}
return chunks;
}
这里解决了第一个问题,用什么对文件进行切片,我们可以利用 File 中的 slice 进行切片
计算 MD5 值
如果我们直接使用 md5 库对文件进行 md5 计算,文件比较大的时候,可能比较慢。所以推荐使用库spark-md5,这个库提供了一种增量计算 md5 的方法,并且比计算速度比原先的 md5 库更快
/**
* 计算md5值
*/
calculationChunksMd5(chunks: Array<Blob>) {
return new Promise((resolve) => {
// 创建FileReader对文件进行读取
const reader = new FileReader();
// 创建sparkMd5的ArrayBuffer对象,进行增量计算md5值
const spark = new sparkMD5.ArrayBuffer();
let readIndex = 0;
function loadNext() {
// 递归调用结束条件,当没有chunk可以读取的时候
if (chunks[readIndex]) {
return reader.readAsArrayBuffer(chunks[readIndex]);
}
// 最终的md5值
resolve(spark.end());
}
reader.onload = (ev: ProgressEvent<FileReader>) => {
readIndex++;
// 获取读取到的内容,这里可以计算一下进度
const result = ev.target?.result as ArrayBuffer;
// 将内容添加到spark中,进行计算
spark.append(result);
// 继续下一个文件读取
loadNext();
};
// 启动计算
loadNext();
});
}
这里需要注意一点:FileReader 是无法并行读取多个文件的,所以需要使用递归的方式,读取完成后进行下一次读取,如果使用 forEach 直接全部读取,会报:
DOMException: Failed to execute 'readAsArrayBuffer' on 'FileReader': The object is already busy reading Blobs. 翻译:DomeException:未能在“FileReader”上执行“readAsArrayBuffer”:对象已在忙于读取Blob。
我们这里已经解决了第二个问题:文件怎么读取。但是我们还没有解决掉第三个问题,大文件读取 md5 卡顿的问题
使用 webWorker 优化代码
calculationChunksMd5 方法修改
/**
* 计算md5值
*/
calculationChunksMd5(chunks: Array<Blob>): Promise<string> {
return new Promise((resolve) => {
const worker = new Worker("/calculationMd5.js");
worker.postMessage(chunks);
worker.onmessage = (e: MessageEvent<any>) => {
resolve(e.data);
};
});
}
注意点:
calculationMd5.js 文件必须同源【文档中有】
创建 worker 后,通过 postMessage 发送消息
通过 onmessage 来接收创建新线程的消息
内容保存在参数的 data 里
新建文件calculationMd5.js,并将我们的计算逻辑放入这个文件中
self.importScripts("spark-md5.min.js");
onmessage = function (e) {
let chunks = e.data;
const reader = new FileReader();
const spark = new SparkMD5.ArrayBuffer();
let readIndex = 0;
function loadNext() {
if (chunks[readIndex]) {
return reader.readAsArrayBuffer(chunks[readIndex]);
}
postMessage(spark.end());
}
reader.onload = (ev) => {
readIndex++;
// 获取读取到的内容,这里可以计算一下进度
const result = ev.target?.result;
spark.append(result);
loadNext();
};
loadNext();
};
注意五点:
- worker 文件中的引入使用的是
self.importScripts("spark-md5.min.js")- 使用 onmessage 方法获取主线程传过来的消息
- 传递过来的内容在参数的 data 中
- 通过 postMessage 方法将计算结果传递给主线程
- spark-md5.min.js 导出的是 SparkMD5
通过浏览器中的性能分析工具,我录制了上传过程中的 5 秒做一个简单对比,我们能看到效果
未使用webWorker
4491 毫秒 正在执行脚本
1 毫秒 渲染
1 毫秒 绘制
790 毫秒 系统
267 毫秒 空闲
5550 毫秒 总计
使用webworker
130 毫秒 正在执行脚本
2 毫秒 渲染
1 毫秒 绘制
32 毫秒 系统
6398 毫秒 空闲
6562 毫秒 总计
到此我们的切片上传基本完成,只需要将计算的 hash 和文件流,当然你可以再计算 MD5 的时候给出进度,让用户感觉更加友好
创建请求参数列表
type formDatasType = {
formData: FormData; // 切片上传请求的参数
index: number; // 当前切片是第几个
error: number; // 当前切片上传错误次数
progress: number; // 当前切片上传进度
};
/**
* 创建请求参数数组
*/
createPostFormData(
chunks: Array<Blob>,
fileHash: string
): Array<formDatasType> {
const formDatas = chunks.map((chunk, index) => {
// 请求的参数创建
const formData = new FormData();
formData.append("chunk", chunk);
formData.append("hash", fileHash);
formData.append("fileName", fileHash + index);
// 添加额外参数,可以做错误重试,进度等操作
return { formData, index, error: 0, progress: 0 };
});
return formDatas;
}
这里并没有直接创建请求,然后用 promise.all 一次性发出。是因为想解决第四个问题,我们要控制请求并发数
上传切片文件并控制并发数
/**
* 将文件上传服务器
*/
postToServer(postFormData: Array<formDatasType>, limt: number = 3) {
return new Promise((resolve) => {
let len = postFormData.length;
let counter = 0;
const startPost: Function = async () => {
// 注意这个方法会改变原数组
const formDatas = postFormData.shift();
if (!formDatas) {
return;
}
await this.$http.post("/uploadfile", formDatas.formData, {
onUploadProgress: (progress: any) => {
// 这里可以获取切片上传进度: Number(((progress.loaded / progress.total) * 100).toFixed(2));
},
});
// 所有请求都已结束,我们需要结束外面的Promise
if (counter == len - 1) {
resolve(true);
return;
}
counter++;
// 请求还未结束,继续启动任务
startPost();
};
// 初始启动limt个任务
for (let index = 0; index < limt; index++) {
startPost();
}
});
}
稍微解释一下代码:
- 这种方式控制并发数,并没有使用采用先分组,然后使用 Promise.all 一起请求,是因为这样子能将并发请求控制在三个,当有一个请求完成,就会开启下一个请求,这样始终保持三个请求在运行,更好一点
postFormData.shift这个方法会改变原数组,如果你需要计算进度等,记得一定要 copy 一份下来进行本次操作,否则会丢失数据- 这个方法是没有错误重试的,我们等会可以尝试实现一下错误重试功能
添加错误重试
postToServer(postFormData: Array<formDatasType>, limt: number = 3) {
return new Promise((resolve) => {
let len = postFormData.length;
let counter = 0;
let isStop = false;
const startPost: Function = async () => {
// 注意这个方法会改变原数组
const formDatas = postFormData.shift();
if (!formDatas || isStop) return;
try {
await this.$http.post("/uploadfile", formDatas.formData, {
onUploadProgress: (progress: any) => {
// 这里可以获取进度: Number(((progress.loaded / progress.total) * 100).toFixed(2));
},
});
// 所有请求都已结束,我们需要结束外面的Promise
if (counter == len - 1) {
resolve(true);
return;
}
counter++;
// 请求还未结束,继续启动任务
startPost();
} catch (error) {
// 超过三次就放弃了
if (formDatas.error > 3) {
return (isStop = true);
}
// 将错误的内容放入数据列表中,然后立马进行重试
formDatas.error++;
postFormData.unshift(formDatas);
// 继续启动任务
startPost();
}
};
// 初始启动limt个任务
for (let index = 0; index < limt; index++) {
startPost();
}
});
}
方法调用过程
async uploadFile(e: any) {
const chunks = this.createFileChunk(e.target.files[0]);
const fileHash: string = await this.calculationChunksMd5(chunks);
console.log("是否已上传? 是否已上传部分? 如果上传了部分,那么上传了那部分");
const postFormData = this.createPostFormData(chunks, fileHash);
await this.postToServer(postFormData);
console.log("将hash和文件后缀发送给后端,让其合并文件");
}
总结
到此就完成了切片上传,断点续传和秒传没有细代码演示出来,因为这一部分前端没有什么难点,主要是后端找文件,逻辑再说一遍
断点续传与秒传逻辑
- 计算出文件 hash 值【fileHash】
- 带上文件 hash 值与文件后缀,请求后端校验文件是否存在的接口
- 后端返回类似这样的数据{ hasFile : 是否存在文件, fileList:已存在的切片文件名列表}
- hasFile === true ? "秒传成功" : "进入断点续传"
- 用 fileList 过滤 postFormData,并记录已存在的进度
- 将过滤的数据传入 postToServer,完成上传
- 带上文件 hash + 文件后缀,请求接口进行文件合并
回答一下刚开始的问题
-
用什么东西对文件切片 ?
直接调用类文件对象的 slice 方法即可进行切片
-
文件怎么读取?
使用FileReader读取文件为 ArrayBuffer 数据
-
大文件计算 md5 会不会长时间占用主线程,导致页面卡顿?
-
切片过多,会不会占用很多网络请求 ?
我们通过请求并发数量控制,将文件请求数控制在三个
还可以完善
在当前示例中,我并没有管进度问题。实际上计算 md5,切片文件上传,都是比较耗时操作,需要计算进度的,可以在已上代码标注计算进度的位置添加上进度并显示在页面上,用户体验更加友好
其他内容拓展【布隆过滤器】
以上内容中,如果是大文件通过 md5 计算 hash 是比较耗时的。如果精度要求不高的话,其实可以使用抽样方式计算 md5
抽样规则:
- 取文件前 2M
- 中间切片取前两个字节,后两个字节(注意:这里的中间部分,不是说中间就单独一块,而是说将中间部份分若干份,然后取一份中的前 2 和后 2)
- 取文件后 2M
抽样的优缺点:
- 优点:抽样数据量少,计算速度快
- 缺点:抽样导致精度丢失
最后
有什么说的不对的地方希望评论区指出,期待你的反馈!!!