大文件上传的断点续传与分片实现技巧
我的写法,亲测靠谱
大文件上传这玩意儿,我前后端都折腾过好几轮。刚开始图省事,直接用 input 选完就扔给后端,结果用户一传个 500MB 的视频,页面直接卡死,浏览器内存飙到 2GB,我自己电脑风扇都快炸了。
后来逼着自己搞分片上传 + 断点续传,现在我们项目里稳定跑着的方案是:前端切片、并行上传、带 hash 校验、支持暂停恢复。虽然不能说完美,但至少线上没再被投诉过。
核心思路很简单:把大文件切成小块(比如每片 5MB),挨个发给服务端,最后让后端拼起来。关键是怎么切得合理、发得稳、失败能续。
function uploadFile(file) {
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB 每片
const chunks = [];
let start = 0;
// 切片
while (start < file.size) {
chunks.push(file.slice(start, start + CHUNK_SIZE));
start += CHUNK_SIZE;
}
const totalChunks = chunks.length;
const uploaded = new Array(totalChunks).fill(false);
const uploadId = Date.now(); // 可以换成更可靠的唯一 ID
// 单独上传一片
async function uploadChunk(index, chunk) {
const formData = new FormData();
formData.append('file', chunk);
formData.append('chunkIndex', index);
formData.append('totalChunks', totalChunks);
formData.append('filename', file.name);
formData.append('uploadId', uploadId);
try {
await fetch('https://jztheme.com/api/upload/chunk', {
method: 'POST',
body: formData,
});
uploaded[index] = true;
console.log(第 ${index} 片上传成功);
} catch (err) {
console.warn(第 ${index} 片上传失败, err);
throw err;
}
}
// 并行上传,最多同时传 3 个
const MAX_CONCURRENT = 3;
const queue = [];
for (let i = 0; i < chunks.length; i++) {
if (!uploaded[i]) {
queue.push(
uploadChunk(i, chunks[i])
.catch(() => {
// 失败也别断链,后面重试
})
.finally(() => {
// 移除完成的任务
const idx = queue.indexOf(promise);
if (idx > -1) queue.splice(idx, 1);
})
);
}
// 控制并发数
if (queue.length >= MAX_CONCURRENT) {
await Promise.race(queue);
}
}
// 等所有任务完成
await Promise.allSettled(queue);
// 检查是否全部成功
if (uploaded.every(Boolean)) {
await fetch('https://jztheme.com/api/upload/complete', {
method: 'POST',
body: JSON.stringify({ uploadId, filename: file.name }),
headers: { 'Content-Type': 'application/json' },
});
console.log('上传完成');
} else {
console.error('有分片上传失败,请重试');
}
}
这段代码我已经在好几个项目里用了。重点在于:控制并发数,不然一次性发几十个请求,浏览器扛不住,服务器也容易崩。我试过开 6 个并发,结果 CDN 直接限流,现在统一设成 3 个,稳妥。
还有就是 Promise.race 那里有点绕,但它是控制并发的关键——只要有一个请求结束,就立刻塞下一个进来,这样既能压满带宽,又不会超负荷。
这几种错误写法,别再踩坑了
见过太多人写的“伪分片”上传,看着像那么回事,实际一跑就出问题。
- 一次性读整个文件进内存再切片:用 FileReader 把整个几百 MB 的文件 readAsArrayBuffer(),然后手动切割 ArrayBuffer。这种写法我亲眼见过导致 Chrome 内存爆掉直接崩溃。别这么干!
File.slice()是原生支持的,底层是引用,不占内存。 - 不分节流,全量并发上传:看到有人 for 循环直接发 all Promises,
Promise.all(chunks.map(uploadChunk))。网络好还好,网速一波动,一堆 timeout,重试机制又没做,结果就是传到一半失败,还得从头来。 - 没有 uploadId 或唯一标识:每次上传都靠文件名判断上下文,多个用户同名文件?同一用户传两个同名文件?直接覆盖或者混在一起。一定要生成一个唯一的 uploadId,用来区分每一次上传行为。
- 忽略 MIME type 和扩展名校验:前端只看文件名后缀,结果用户改个 .jpg 实际是 exe,上传成功后后端处理图片时执行了恶意代码……这种低级错误真有人犯过。至少要结合 file.type 和简单 magic number 做前置过滤。
实际项目中的坑
上线之后才发现的问题,比开发阶段多多了。
第一个就是超时和网络中断。你以为用户都在 WiFi 下传?很多是手机流量,信号一弱,上传中途断了。这时候你得支持断点续传,不然人家传了 90% 断了,得重来,用户体验直接负分。
怎么实现断点?我在 localStorage 存每个 uploadId 的已上传分片列表。下次检测到同文件、同 uploadId,先调接口问服务器哪些片已经有了,跳过即可。代码略复杂,但值得加。
第二个是服务端合并逻辑不健壮。有一次后端同学合并时没按 chunkIndex 排序,结果音视频文件全乱套,播放到一半变调。提醒大家:前后端一定要约定清楚分片索引规则,并且服务端收到 complete 请求后必须严格按序拼接。
第三个是移动端兼容性。iOS Safari 对 large File API 支持不太稳定,尤其是微信内置浏览器。我遇到过 file.size 返回 NaN 的情况(文件大于 2GB)。后来加上了 size 判断容错:
if (file.size === 0 || !file.size) {
alert('文件可能过大或无法读取,请尝试其他方式上传');
return;
}
还有一个细节很多人忽略:上传进度条不准。你以为 uploaded.length / total 就是进度?错!HTTP 请求发出去不代表服务器收到了。真正的做法是在每个 chunk 成功返回后再更新进度。否则你会看到“上传完成”但实际卡在网络层。
要不要加 MD5 校验?
这个问题我纠结很久。加吧,前端算大文件 MD5 很慢;不加吧,传输过程出错没法发现。
最后折中方案:只对小文件(<100MB)做完整文件 MD5,大文件只做分片 MD5。这样每一片都能验证完整性,而且单片计算压力不大。
计算可以用 spark-md5 这种增量式库,配合 FileReader 分段读:
import SparkMD5 from 'spark-md5';
function calculateChunkHash(chunk) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
const arrayBuffer = e.target.result;
const wordArray = SparkMD5.ArrayBuffer.hash(arrayBuffer);
resolve(wordArray);
};
reader.readAsArrayBuffer(chunk);
});
}
不过说实话,现代网络环境下数据出错概率极低,除非你们走的是特殊内网通道,否则这个功能可以延后加。
最后的小建议
如果你不是做网盘类应用,其实没必要自己从零实现整套系统。推荐直接上现成 SDK,比如 Uppy 或者 Tus。Tus 协议本身就支持断点续传,server 已经有人开源好了,集成很快。
但我们项目当时因为安全合规要求,必须自研,所以才一步步趟过来。现在回头看,如果时间紧、需求不复杂,真不如引入 Uppy + 自建 endpoint 来得快。
还有一点:别忘了加取消上传功能。很多人写了 pause,但没写 cancel。pause 只是暂停请求队列,cancel 要通知服务端删除已有分片,不然磁盘迟早被垃圾文件塞满。
以上是我总结的最佳实践,有更好的方案欢迎评论区交流
这套方案跑了快两年,经历过单文件 2.3GB 的上传场景,平均成功率 98.6%,主要失败原因还是用户主动中断或网络太差。
改完之后仍有小问题,比如多设备同步上传状态麻烦、localStorage 容量限制等,但无大碍。目前够用就行,毕竟不是 every byte matters 的场景。
这个技巧的拓展用法还有很多,比如结合 Web Workers 做后台计算 hash、用 Service Worker 监听离线上传等等,后续会继续分享这类实战经验。
以上是我踩坑后的总结,希望对你有帮助。
