你是否曾苦苦等待一个几G的大文件上传,却在进度条到99%时因为网络波动而崩溃重来?在现代Web应用中,传统的单次上传方式早已力不从心。本文介绍作者使用的一个 生产级 的解决方案,实现支持 分片、取消、重试 的大文件上传。
一、为什么要做分片上传?
1. 解决大文件上传的稳定性问题(核心意义)
这是分片上传最首要的目的。
- 传统单次上传的弊端:如果一个2GB的文件一次性上传,网络波动导致在传输到90%时中断,整个上传就失败了,用户必须从头开始重传,体验极差。
- 分片上传的优势 :如果将2GB文件切成100个20MB的分片。即使传输到第99个分片时中断,也只需要重新上传这失败的1个分片(第99片),而不是整个文件。这极大地增强了上传的容错能力,节省了时间和带宽。
2. 提升上传速度和用户体验
- 并发上传 :浏览器可以同时发起多个HTTP请求,分别上传不同的文件分片。这充分利用了用户的网络带宽,将串行上传变为并行上传,显著提高了大文件的上传速度。
- 进度条更精确:可以实时计算已成功上传的分片数量和总大小的比例,向用户展示一个非常精确和流畅的上传进度条,提升了用户体验。
3. 绕过各种大小限制
- 客户端限制:某些浏览器、服务器或中间件(如Nginx)对单个请求的Body大小有默认限制。分片上传确保了每个请求的体积都很小,可以轻松绕过这些限制。
- 服务端处理:一次性将超大文件加载到服务器内存中进行处理是不现实且危险的,容易导致内存溢出。分片上传后,服务器每次只处理一个小分片,最后再按需合并,对服务器更友好。
4. 便于做额外的校验和控制
- MD5校验 :不仅可以对整个文件做校验,还可以对每一个分片单独做MD5校验。服务端收到分片后立即验证其MD5,如果校验失败,可以立即要求客户端重传该分片,保证了每个分片数据的准确性。
- 暂停和重试:因为上传状态(每个分片是否成功)被记录在服务端,用户可以随时暂停上传,并在之后继续上传。
二、如何实现分片上传?
核心流程:化整为零,再化零为整
分片上传的核心思想是"分而治之",其流程如下图所示:

核心实现细节
以下基于 React + antd 实现,我们将聚焦几个关键的技术细节。整体设计思路可参考这篇优秀的文章:前端大文件上传深入研究和实现。
🔍 MD5计算:全量 vs 抽样
计算整个文件的MD5以保证唯一性准确,但大文件全量计算非常耗时。我们采用了一种均衡策略:
- 小文件:直接全量计算。
- 大文件 :采用抽样计算,在精度和速度间取得尽量的平衡。
- 如何定义大小文件 :根据实际情况,给分片数设定一个阈值,低于该阈值采用全量计算,高于该阈值采用抽样计算
下面我们来介绍一下抽样计算方案:

-
切片 :按固定大小(如
2MB
)将文件切分成多个切片。 -
采样:
- 取第一个 和最后一个切片的全部内容。
- 对于中间的所有切片,每片取首、中、尾各x个字节。
-
组合 :将这些采样的字节数据合并成一个新的
Blob
对象。 -
计算 :使用
spark-md5
库计算这个采样Blob
的hash值,作为文件的唯一标识。
这种方法极大地减少了计算量,虽有小概率的hash碰撞,但在实际业务中完全可以接受
🧵 并发控制:线程池
为了避免浏览器同时发起过多HTTP请求,实现了一个简单的线程池 来控制并发数。它采用 队列 + 工作者 模型。
ts
class ThreadPool {
maxThread: number; // 最大并发数
queue: Array<Worker>; // 当前执行队列
workers: Array<Worker>; // 待执行的工作者队列
errorList: Array<Error>; // 错误收集列表
constructor(maxThread: number, fns: Array<() => Promise<void>>) {
this.maxThread = maxThread;
this.queue = [];
// 将所有任务函数包装成 worker
this.workers = fns.map((item, ind) => {
return this.createWorker({ fn: item, ind });
});
this.errorList = [];
}
// Worker 包装机制
createWorker(worker: { fn: () => Promise<void>; ind: number }) {
// 1. 补足队列 - 如果当前队列未满,从待执行队列中取任务
this.fullQueue();
// 2. 执行当前任务
await worker.fn().catch(() => {
// 错误收集
...
})
this.queue = this.queue.filter((item) => item.ind !== worker.ind);
// 4. 递归执行下一个任务 - 这里有个关键的递归调用
await this.workers?.[0]?.fn();
}
// 队列补充机制
fullQueue(){
// 1. 从待执行队列取出
// 2. 加入执行队列
while...
}
async start() {
// 初始填充队列
this.fullQueue();
// 并发执行队列中的所有任务
return await Promise.allSettled(this.queue.map(async (item) => await item.fn()));
}
}
执行过程解析(假设 maxThread=3)
ini
时间轴: 0 1 2 3 4 5 6
任务队列: [T1, T2, T3, T4, T5, T6, T7, T8] (假设 maxThread=3)
执行过程:
T0: queue=[T1,T2,T3], workers=[T4,T5,T6,T7,T8]
↓ 并发执行T1,T2,T3
T1: T1完成 → queue=[T2,T3], workers=[T4,T5,T6,T7,T8]
→ fullQueue() → queue=[T2,T3,T4], workers=[T5,T6,T7,T8]
→ 启动T4
T2: T2完成 → queue=[T3,T4], workers=[T5,T6,T7,T8]
→ fullQueue() → queue=[T3,T4,T5], workers=[T6,T7,T8]
→ 启动T5
... 依此类推,直到所有任务完成
重试与暂停
分片上传阶段
利用线程池和错误收集实现:
- 失败重试 :每个分片任务失败时,将错误信息(分片索引等)存入
errorList
。所有分片首次尝试后,只需重试errorList
中的任务即可。 - 暂停 :给所有上传请求设置统一的
cancelToken
,使用cancelTokenSource.cancel
终止所有上传的请求 - 关于暂停机制的补充说明 :需要特别注意的是,在上传阶段执行暂停操作时,前端实际所做的是中止(cancel) 发出的 HTTP 请求------即前端不再等待和接收后端的响应。然而,此时请求可能已经到达服务器,后端可能仍在处理并持续写入对应的分片 直到完成。对于这些已生成的完整分片,通常有两种处理方式:在后续重试上传同一分片时直接覆盖,或者统一清理
ts
const fn = async(index:number)=>{
try{
// 上传逻辑
...
}catch(error){
// 收集上传失败信息
status.onUploadErr.push({
lifeCircle: 'chunkEnd',
error,
})
}
}
-----------
this.cancelTokenSource = axios.CancelToken.source();
// 设置统一的cancelToken
const res = await axios.post(url,data,{
cancelToken: this.cancelTokenSource.token
})
// 取消所有上传
cancel() {
this.cancelTokenSource.cancel('请求取消');
}
分片合并阶段
合并请求是串行 的,上一个合并完成才能执行下一个,失败则终止。我们通过 for循环 + async/await
实现串行,并用一个标志位 isContinueMerge
控制暂停。
ts
for (index = p.startIndex; index < this.mergeParams.total; index++) {
try {
await mergeFn(index);
} catch (error) {
// ...错误处理
// 跳出循环,后续的 merge 不会执行
break;
}
}
async mergeFn(){
if (!this.isContinueMerge) {
// 当前批次的请求,应该暂停了
throw new Error('合并暂停');
}
}
注意:合并请求的暂停是"软暂停",即取消下一个合并请求,而非中止当前正在进行的请求。合并过程中不支持暂停,所有使用标志位控制
上传速度与进度计算
实时计算速度与进度,给用户良好的反馈。
ts
const beforeUpload = Date.now()
await requestFn()
const afterUpload = Date.now();
// 计算当前分片上传耗时
const duration = (afterUpload - beforeUpload) / 1000; // 秒
if (duration > 0 && chunkSize > 0) {
speed = chunkSize / duration; // 分片上传速度 byte/s
this.totalBytes += chunkSize; // 已上传的文件大小
}
用户体验与交互设计
-
模态框管理:上传过程通常放在模态框中:
- 上传过程中,禁用遮罩层关闭,右上角关闭按钮点击会出现需取消的提示。
- 提供清晰的取消、重试按钮
-
资源清理 :弹窗关闭后,调用服务端清理接口,删除暂存的无效分片,避免存储资源浪费。
展望与优化空间
- Web Worker 解放主线程 :计算MD5是CPU密集型任务,会阻塞UI渲染导致页面卡顿。将其放入 Web Worker 中运行,可解决部分性能问题,保证页面流畅。
- 更健壮的重试机制:为每个分片上传增加重试策略,避免网络抖动造成的瞬时失败。
- 上传速度平滑与预估:计算平均速度而非仅当前分片速度,并基于此预估剩余时间,体验更佳。
- 上传进度的实时感知:目前分片上传依靠前端存储的状态来感知上传状态,如果用户刷新浏览器重置前端状态,没有相应的接口机制去实时获取上传状态(如上传信息、上传阶段等等)从而造成上传状态丢失