大文件直传基本都会翻车:反向代理 60 秒超时、浏览器把整文件读进内存、传到 95% 断网就得从头再来。要稳,得把文件切成片、一片一片传、断了只补没传完的那几片。下面这套分片 + 续传实现,我在一个 500MB 视频上传需求里踩过坑后定型,前端切片、续传询问、服务端合并的代码都能直接抄。
直传为什么必败
标准 multipart 一次 POST 整个文件,问题集中在三处:代理有请求体大小和时间限制、浏览器会整文件加载进内存、失败只能重头传。把这三处量化对比一下就清楚:
| 维度 | 一次直传 | 分片续传 |
|---|---|---|
| 超时风险 | 受反向代理 body 限制,常 60–300s 一刀切 | 单分片体积小,几乎不超时 |
| 内存占用 | 整文件塞进 ArrayBuffer,移动端直接崩 | 只持切片引用,不占内存 |
| 断网代价 | 从 0 重来 | 只补缺失分片 |
结论很直接:文件超过几十 MB,就别直传。
分片上传的核心思路
浏览器用 Blob.slice() 把文件切成固定大小块,每块带序号和会话 ID 单独请求,服务端按序号落盘。关键是【标识符】——用文件名 + 大小,或内容 hash 生成,让服务端能认出”这是同一个半截上传”,断网回来还能接着传。
切片前端代码:
const CHUNK = 5 * 1024 * 1024; // 5MB 一片
const total = Math.ceil(file.size / CHUNK);
const id = file.name + '-' + file.size; // 简化标识符
for (let i = 0; i < total; i++) {
const blob = file.slice(i * CHUNK, (i + 1) * CHUNK);
const fd = new FormData();
fd.append('chunk', blob);
fd.append('index', i);
fd.append('id', id);
await fetch('/upload', { method: 'POST', body: fd });
}
断点续传怎么落地
续传的本质是”上传前先问服务端:你收到第几片了”。这里用 XMLHttpRequest 而不是 fetch,因为 xhr.upload.onprogress 能在传输途中回报字节进度,进度条才顺滑;fetch 要等响应回来才知道结果,慢连接下会卡 30 秒没动静。
async function uploadWithResume(file, id) {
const res = await fetch(`/upload/status?id=${id}`);
const done = new Set(await res.json()); // 已收分片序号
for (let i = 0; i < total; i++) {
if (done.has(i)) continue; // 已传的跳过
await sendChunk(file, id, i); // 只传缺失的
}
await fetch('/upload/merge', {
method: 'POST',
body: JSON.stringify({ id })
});
}
踩坑实录
合并文件时我图省事用 fs.readdirSync 读目录后直接排序拼接,结果 chunk_1、chunk_10、chunk_11 按字符串字典序排成了 1→10→11→2,文件全乱、解压直接报错。改成按数值 parseInt(index) 升序再 appendFileSync 才正常。一句教训:分片名带数字,永远按数值排序,别信字符串排序。
选型:自研还是直接上 tus
常见问题
分片大小设多少合适?
1–10MB 是甜区。太小请求数爆炸、太大单分片重试成本高;移动端网络不稳可降到 256KB–1MB。
并发上传分片会更快吗?
理论上会,但 Node 单进程 + 磁盘顺序写时并发反而乱序、合并易错。先顺序跑通,再按服务端能力开 2–3 路并发。
怎么防止重复文件重复传?
标识符用内容 hash(如 SparkMD5),服务端先查该 hash 是否已存在,命中就秒传——这就是常说的”极速上传”。
你在大文件上传上还踩过什么坑?欢迎在 fenij.com 留言聊聊,我整理成下一篇。