
在前端面试里,如果问到大文件上传,几乎每个人都能背出那套标准八股文:用 Blob.slice 对文件进行物理切片,然后用 Promise.all 并发上传,最后调一个后端的合并接口。
听起来无懈可击对吧?
但在真实的业务现场,当你的代码跑在高铁上的弱网环境、或是丢包率极高的星巴克公用 Wi-Fi 下时,这套标准答案往往会引发一场场线上灾难。上传进度条会在最后卡死,浏览器页签会突然白屏崩溃,甚至后端网关会直接把你判定为恶意攻击而拉黑。
为什么看似完美的逻辑,一碰到复杂的物理网络就会碎成渣?接下来咱们聊一聊这个话题👇
常规切片上传,有什么问题?
大多数网上的教程,教你的都是纯粹的玩具级代码 。它们最大的问题在于:完全无视了浏览器底层的并发限制和物理网络的脆弱性。
当你对一个 2GB 的大文件切出了 100 个分片,然后大手一挥,直接用 Promise.all 把 100 个请求同时砸向后端时,灾难就开始了:
首先,现代浏览器对同一个域名的 TCP 并发连接数是有严格限制的(通常是 6 个)。你发出的 100 个请求,会在浏览器底层网络栈里排起长队,互相挤占极其有限的带宽,导致大量请求在排队阶段就直接报超时错。

其次,在弱网环境下,只要其中有两三个分片因为丢包而上传失败,整个 Promise.all 就会直接进入 reject 状态。前面辛辛苦苦传完的 97 个分片状态瞬间丢失,用户只能被迫从头开始,这种体验简直是毁灭性的。
没有并发控制、没有容错重试、没有状态持久化的切片上传,就是一场灾难的开始。
简单实现原生并发队列与退回重试
真正能上生产环境的上传模块,绝对不应该依赖花里胡哨的第三方大件库,而是要老老实实地用底层的 JavaScript 去精准接管整个上传生命周期。
我们不需要引入庞大的状态机。利用极简的异步任务队列 配合递归控制,就能在纯原生的 Web API 下实现带错误重试、精准并发控制的上传逻辑:
typescript
// 大文件切片并发上传队列控制
interface ChunkTask {
chunk: Blob;
index: number;
retryCount: number;
}
export async function uploadChunksWithControl(
tasks: ChunkTask[],
maxConcurrency = 4, // 极其克制的并发数,保护网络带宽
maxRetries = 3 // 弱网环境的重试次数
): Promise<void> {
let currentIndex = 0;
let activeCount = 0;
let hasError = false;
return new Promise((resolve, reject) => {
const enqueue = async () => {
// 如果存在有不可恢复的重试耗尽,或者任务已经发完,直接退出
if (hasError || currentIndex >= tasks.length) {
if (activeCount === 0 && !hasError) resolve();
return;
}
// 获取当前要处理的任务,并移动指针
const task = tasks[currentIndex++];
activeCount++;
try {
await uploadSingleChunk(task);
} catch (err) {
// 如果遇到网络抖动,不报错,而是进行静默重试
if (task.retryCount < maxRetries) {
console.warn(`分片 ${task.index} 上传失败,正在进行第 ${task.retryCount + 1} 次重试...`);
task.retryCount++;
// 将失败任务重新插回队列末尾排队
tasks.push(task);
} else {
hasError = true;
reject(new Error(`分片 ${task.index} 弱网重试 ${maxRetries} 次后彻底失败`));
return; // 掐断后续上传,避免资源浪费
}
} finally {
activeCount--;
enqueue(); // 递归触发下一个空闲插槽的任务
}
};
// 初始化时,填充并发池,启动指定数量的任务
for (let i = 0; i < maxConcurrency && i < tasks.length; i++) {
enqueue();
}
});
}
// 模拟单个分片的独立网络请求
async function uploadSingleChunk(task: ChunkTask): Promise<void> {
const formData = new FormData();
formData.append('file', task.chunk);
formData.append('index', task.index.toString());
const res = await fetch('/api/upload-chunk', {
method: 'POST',
body: formData,
});
if (!res.ok) throw new Error('Network Error');
}
这段代码没有任何魔法。它通过 activeCount 永远将并发请求死死卡在阈值(比如 4)以内。同时,某个分片因为弱网失败了不仅不会导致全盘崩溃,反而会被打回队列重新排队。这种退回策略,是移动端弱网环境下保障上传成功率的前提。
那些在真机上才会暴露的坑
哪怕你写出了上面👆 的并发队列,一旦放到真实的业务线上去跑大文件,依然会碰到只有在深水区才会暴露的死角。这些才是拉开架构师和普通开发差距的地方🙌。
页面静默中崩溃?
很多新手在切片时,喜欢把所有的 Blob 对象转成 ArrayBuffer 或者 Base64 存在一个巨大无比的数组里。如果用户上传的是一个 5GB 的高清视频,这个低级操作会瞬间撑爆浏览器的堆内存,导致页面卡死或直接白屏抛出 OOM。

所以咱们 必须清楚一点,File.slice() 产生的只是文件物理内存的指针引用,而不是真实的数据拷贝。在真实网络请求发生之前,绝对不要去调用任何 FileReader 方法读取内容。只有在发请求的瞬间,才将引用丢进 FormData。
计算文件哈希时,尽量避免主线程卡死
为了实现断点续传和秒传校验,我们需要用文件的哈希值(如 MD5 或 SHA-256)作为全局唯一标识。但对于几个 GB 的文件,如果用纯 JavaScript 在主线程全量计算哈希,页面会整整卡死几十秒,任何按钮交互都会瘫痪的。

所以咱们对于几十兆以上的大文件,必须丢进 Web Worker 中进行分块异步计算。更高级的工程做法是抽样哈希 (仅抽取文件头部的 2MB、尾部的 2MB 加上中间若干个小片段拼接),这能在保证冲突率极低的前提下,把计算时间从几十秒压缩到几百毫秒。
状态持久化
移动端浏览器极其不靠谱,用户切出去回个微信,你的上传页面可能就被系统杀掉后台了。等用户重新点开页面,内存里的分片状态全部丢失,只能咬牙从头再传。
如果引入 IndexedDB 或 localStorage 作为本地状态机时,每成功上传一个切片,就向本地数据库持久化写入一条记录。用户刷新页面后,通过哈希比对本地记录,直接过滤掉已经成功的切片数组,无缝接管中断的上传任务。
大文件断点续传架构图(建议收藏😁)

在大厂的日常业务里,很多听起来高大上的架构优化,其实底层的逻辑非常朴素的。
我们要对抗的从来不是多么深奥的算法机制,而是浏览器可怜的并发限制、脆弱的物理网络,以及设备的物理内存上限。前端工程师不仅要会写代码,更要懂得对底层的物理极限保持敬畏🫡。
当你能把一个巨大无比的文件,在网速只有 50KB/s 并且频繁断网的恶劣环境下,稳稳当当、不漏一片地送到服务器时,你在这个圈子才算是真正摸到了前端工程的门道🫵。
好了,今天就分享到这里🙌