前言
部分场景需要大文件切片上传的方案。因为公司涉及到很多模型和视频需要上传,本来我以为这个功能很简单,最开始做的能用就行,在不断实践下发现这个功能可以深挖下去,最终封装了一个足够完善的库 large-file-upload。现在已经可以做到在1秒内算完1G文件的hash 和大量请求使cpu过高时如何避免浏览器卡顿。基本已经算最佳实践。
ts
npm i large-file-upload
api
- 文件切片
createFileChunks
- 计算hash
generateFileHash
- 并发控制器
UploadHelper
为什么需要大文件切片上传
首先如果一个5G的文件直接整体上传,中途出现网络问题就会白白的浪费时间,客户体验也不好,而且单次上传到后台一般会等待时间过长,客户可能不愿意等这么久直接就退出了。
整体思路
要先给文件切片分成更小的块,通常是5M到10M,之后看有没有秒传或者断点续传的需求,如果有的话,需要获得文件的唯一标识,一般是采用计算文件hash的方式。如果没有,像B站的上传视频,因为不需要秒传和续传,刷新后目前已上传的部分不再保留,采用的是一个随机生成UUID的方式。之后需要一个请求的并发控制器,以避免浏览器卡顿,同时这一步可以带上切片的序号以便后端使用。所有切片上传完成后前端发送请求通知后台,可以按照序号进行合并,具体看需求。
创建文件切片
文件的切片非常简单,拿到file文件后直接调用原生的slice
方法即可。
ts
import { createFileChunks } from 'large-file-upload';
// 可以传入指定的分片大小,没传会根据文件自动指定。
const { fileChunks, chunkSize } = createFileChunks(file)
源码如下:
ts
export function createFileChunks(file: File, customChunkSize?: number): FileChunkResult {
if (!file || !file.size) {
throw new Error('File not found or size is 0');
}
const size = file.size;
let chunkSize: number;
if (typeof customChunkSize === 'number' && customChunkSize >= 1) {
chunkSize = Math.floor(customChunkSize) * BASESIZE;
} else if (customChunkSize !== undefined) {
chunkSize = 4 * BASESIZE;
} else {
if (size < 100 * BASESIZE) {
chunkSize = 1 * BASESIZE;
} else if (size < 1024 * BASESIZE) {
chunkSize = 5 * BASESIZE;
} else {
chunkSize = 10 * BASESIZE;
}
}
const fileChunks: Blob[] = [];
for (let start = 0; start < size; start += chunkSize) {
const end = Math.min(start + chunkSize, size);
fileChunks.push(file.slice(start, end));
}
return { fileChunks, chunkSize: chunkSize / BASESIZE };
}
计算文件hash
计算hash最重要的就是标识文件的唯一值,这个过程非常耗时,1G的文件大概10秒左右,严格来说没什么能优化的地方,类似1+1,能压缩的空间不多,所以想要快就要换种方案,最终我采用的多线程计算hash加抽样的方法。1G的文件1秒算完,30G的文件需要6秒左右。
ts
import { generateFileHash } from 'large-file-upload';
async function hashLargeFile(file: File) {
const hash = await generateFileHash(file);
console.log('Generated hash for the large file:', hash);
}
核心思路是计算部分移到web worker,让主线程专注其他工作,同时借用了wasm加速计算,在这附上源码地址
核心代码
多开web worker充分利用并发能力,之后将文件等分,每部分抽样后分配到不同的线程后计算hash,然后发回主线程合并。具体的实现可以参考源码,这个功能一定要快,不然等太久只会降低用户体验。
大量请求的并发上传
请求的并发控制方案有很多,我这边有试过发布订阅模式,Promise.all
也试过,最终决定参考流行的并发库p-limit再改一份并发方案,添加暂停,继续,错误请求重试等功能,比较重点的是低优先级模式 ,用来解决当大量请求发起的时候,导致cpu上升,如果爆满后浏览器必然卡顿,无法优化,在有动画业务的时候会严重影响用户体验。附上源码地址
ts
import { UploadHelper, createFileChunks } from 'large-file-upload';
async function uploadFile(file: File) {
const { fileChunks } = await createFileChunks(file);
const hash = await generateFileHash(file);
const fileArr = fileChunks.map((chunk, index) => {
return {
blob: chunk,
index,
};
});
const uploadHelper = new UploadHelper(fileArr, {
maxConcurrentTasks: 3,
});
uploadHelper.onProgressChange(progress => {
console.log(`Progress: ${progress}/${fileChunks.length}`);
});
// Execute the upload tasks with an async function passed to run
const { results, errorTasks } = await uploadHelper.run(async ({ data, signal }) => {
const formData = new FormData();
formData.append('chunk', data.blob);
formData.append('index', data.index);
formData.append('hash', hash);
// Simulate an upload request using fetch or any HTTP client
const response = await fetch('/upload', {
method: 'POST',
body: formData,
signal,
});
if (!response.ok) {
throw new Error(`Upload failed with status ${response.status}`);
}
return await response.json();
});
if (errorTasks.length > 0) {
console.log('Some chunks failed to upload:', errorTasks);
// Optionally retry failed tasks
// await uploadHelper.retryTasks(errorTasks);
} else {
console.log('All chunks uploaded successfully');
}
}
请求队列的实现参考的p-limit
,使用链表进行实现,性能要更好一点。当单个任务完成后会自动请求下一个直至完成。基本主流的并发控制方案,我测过三四种,在时间上差不多太多,可能有些逻辑会更合理一点,如果需要自己实现的话,不用纠结。另外在这里指出用web worker发请求的方案,基本完全没用,时间上是基本一样的,性能,内存均没有太大提升,不用浪费时间,这里可以参考这个web worker方案实现
重点 并发方案实践下来最重要的点是如何避免cpu爆满导致电脑的卡顿,而不是浏览器的,这里采用requestIdleCallback
来降低请求任务的优先级,同时设置保底时间,避免任务长时间不运行。
结尾
基本的流程已经总结完毕,这个库可以直接使用,如果用vite
框架的话需要避免预构建,因为采用了web worker
,作为库安装时会有资源路径不对的问题。
这个库可能还有不足的地方,非常欢迎掘友们提出建议。如果有什么需求需要实现的,我也很乐意研究然后分享出来,可以的话帮忙给项目点点star,感激不尽。