前端大文件分片上传
数据上传是经典的数据传输场景,本文介绍如何实现一个可恢复、解耦服务商、安全的大文件分片上传方案。
背景
如果你是用阿里云的OSS服务,在你的前端项目中可能见过ali-oss
的代码:
import OSS from "ali-oss";
const store = new OSS({
region: "oss-cn-hangzhou",
accessKeyId: "<access-key-id>",
accessKeySecret: "<access-key-secret>",
bucket: "<bucket-name>",
stsToken: "<security-token>",
});
使用阿里云官方提供的数据传输库,可以满足大部分的上传功能需求,然而他的缺点是:
- 强耦合云服务商,与阿里云绑定。
- 客户端获取
ak
/sk
等安全凭证可能会存在安全风险,有些甚至硬编码在项目中。
目标
我们的目的是实现一个可恢复、解耦服务商、安全的大文件分片上传方案。分片上传增加了上传成功率,断点续传增强了用户体验。既然要造轮子,这两者我都要,并且在此功能基础上规避掉上面提到的两个问题。
- 上传过程中可暂停/恢复上传。
- 上传过程中被中断后(页面被关闭后),再次上传时可续传。
- 提供并发上传能力。
- 上传确保安全,与后端之间不传输
ak
/sk
。 - 确保上传数据的正确性。
- 解耦,可以服务于任何云服务商。
方案选型
基于预签名 URL 提供上传服务 应该是OSS的基本能力之一,比如阿里云、AWS、Minio都提供了这个能力。使用预签名 URL 可以让客户端在未拥有安全凭证或权限的情况下进行上传。而这个能力恰好可以把前端跟运营商之间解耦,也无需传递ak
/sk
等安全凭证。
基于预签名URL设计
时序图

前端流程图
方案实现
开始上传
完成上传任务的创建,获取本次上传任务ID。
const { uploadId } = await this.api.createUploadTask({
fileName: "",
});
fileId计算
- 非阻塞:计算文件
MD5
是一个耗时任务,借助Web Worker
的能力,将计算过程放入Worker
线程中运行,避免卡死主线程。 - 流式处理:通过
Stream
的方式流式处理文件读取,规避内存抖动。 - 高性能:借助
WASM
的高性能,快速计算文件MD5
。基于WASM
的文件摘要算法性能对比图: - 低内存:通过增量计算方式,进一步加快
MD5
的计算,规避内存抖动。
// worker.js
self.importScripts("https://cdn.jsdelivr.net/npm/hash-wasm@4");
self.onmessage = async (e) => {
const file = e.data;
const stream = file.stream();
const reader = stream.getReader();
const hasher = await self.hashwasm.createMD5();
while (true) {
const { done, value } = await reader.read();
if (done) {
const md5 = hasher.digest();
self.postMessage({ data: md5 });
break;
}
hasher.update(value);
}
};
const handleFileChange = async (event: Event) => {
const input = event.target as HTMLInputElement;
if (input.files) {
const file = input.files[0];
const myWorker = new Worker(new URL("./worker.js", import.meta.url));
myWorker.postMessage(file);
myWorker.onmessage = (e) => {
const { data } = e.data;
console.log("md5 结果:", data);
};
myWorker.onerror = (error) => {
console.error("Worker error:", error);
};
}
};
是否可续传
const { uploadId } = getLocalTask(fileId);
if (uploadId) {
// 根据响应结果获取已上传的分片数据
const res = this.api.getUploadTask({
uploadId,
});
} else {
setLocalTask({
uploadId: "",
fileId: "",
partSize: "",
// ...
});
}
切片
将原始文件分割成多个小片段,提高上传稳定性。
sliceFile = (): UploadChunk[] => {
const file = this.file;
let current = 0;
let idx = 1;
let chunks: UploadChunk[] = [];
const { partSize, uploadId, retryTimes } = this.options;
while (current < file.size) {
// 已上传的分片可以跳过
const chunk = file.slice(current, current + partSize);
chunks.push({
id: uploadId,
index: idx,
start: current,
end: current + chunk.size,
data: chunk,
retryTimes: retryTimes,
});
current += partSize;
idx++;
}
return chunks;
};
切片的预签名与上传
由异步任务队列来控制并发上传分片,每个分片上传包含错误重试机制。
const uploadChunkTasks = chunks.map((chunk) => {
return () => {
let lastPartProgress = 0;
return new Promise<void>(async (resolve, reject) => {
try {
const onProgress = (newPartProgress: number) => {
totalProgress = totalProgress - lastPartProgress + newPartProgress;
lastPartProgress = newPartProgress;
this.emitProgress(totalProgress, this.file.size);
};
// 分片签名
const { url, method } = await this.createChunkSignedUrl(chunk.index);
// 分片上传
await pRetry(
this.uploadChunk.bind(this, {
url,
chunk: chunk.data,
onProgress,
method,
}),
{
retries: retryTimes,
onFailedAttempt: (error) => {
console.log("onFailedAttempt - ", error);
},
}
);
resolve();
} catch (error) {
reject(error);
}
});
};
});
this.queue.addAll(uploadChunkTasks);
完成上传
完成任务上传,服务侧接收到通知后开始处理分片合并,并检验文件完整性。
await this.api.completeFileUpload(uploadId);
removeLocalTask(fileId);
总结
通过这个思路,即可实现前端大文件上传方案,且拥有:
- 解耦云服务商,上传方案具备通用能力。
- 安全,无需跟后端之间传输
ak
/sk
,避免秘钥泄露后对云存储安全造成影响。 - “轻便”的可恢复上传,浏览器无需缓存原始文件,避免磁盘空间占用过大。
- 由异步任务队列控制并发。
- 有分片上传失败补偿机制。
- 不阻塞线程,避免UI卡顿。
Q & A
为什么不校验每个分片数据的 HASH 值?
分片的校验成功并不能代表文件整体的完整性,文件的完整性就应该校验文件整体。
既然前端解耦了云服务商,那么整体复杂度是不是降低了?
从工程角度来看,复杂度不会消失,只是转移到了后端。
为什么不缓存原始文件?
这是平衡性的选择,不缓存原始文件的代价是:如果用户上传中断了,那么需要重新选取下本地文件进行上传。它的好处是在多文件上传过程中不会占据Chrome过多的磁盘空间,不需要考虑磁盘空间不足的边缘场景。另一个成熟的可恢复的文件上传方案也是采取这种方式。
为什么分片上传比分片下载复杂?
HTTP协议原生支持Range请求头,允许客户端获取文件的部分数据,在这个标准化的前提下,分片下载非常容易。而分片上传目前还没有标准化,社区上已经有团队在推进:Resumable Uploads for HTTP。该提案目前还处于Draft阶段,不过已经更新了至少9个版本,期待分片上传早日实现标准化。