大文件上传的前端实现方案与断点续传优化实践
先上核心代码,能跑再说
上周做项目要支持上传 2GB 的视频文件,用户直接拖个大文件进来,浏览器卡死、内存爆掉、进度条不动……折腾了三天,最后搞定了。今天不讲理论,直接给你能跑的代码,亲测有效。
核心思路就三点:切片(chunk)、断点续传、并发控制。别整那些花里胡哨的,直接看代码:
// 切片大小设为 5MB,这个值我反复试过,太大容易失败,太小请求太多
const CHUNK_SIZE = 5 * 1024 * 1024;
async function uploadFile(file) {
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const uploadedChunks = await checkUploadedChunks(file); // 先查哪些已经传了
const promises = [];
for (let i = 0; i < totalChunks; i++) {
if (uploadedChunks.includes(i)) continue; // 跳过已上传的
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
// 控制并发,最多同时发 3 个请求
if (promises.length >= 3) {
await Promise.race(promises);
}
const promise = uploadChunk(chunk, i, file.name)
.then(() => console.log(Chunk ${i} success))
.catch(err => console.error(Chunk ${i} failed:, err));
promises.push(promise);
}
await Promise.allSettled(promises);
await mergeChunks(file.name, totalChunks); // 合并
}
上面这段代码,重点在 Promise.race 控制并发——我一开始没加,结果同时发 100 个请求,Nginx 直接 502。后来改成 3 并发,稳得一批。
踩坑提醒:这三点一定注意
你以为切片上传很简单?我踩过的坑比你写的代码还多。
- 切片大小别乱设:我试过 1MB、10MB、50MB。1MB 请求太多,服务器扛不住;50MB 一失败就得重传大块,体验差。最后定在 5MB,平衡了成功率和效率。如果你用的是 HTTP/2,可以适当调大,但别超过 10MB。
- 断点续传必须校验文件指纹:光靠文件名判断“是否已上传”是大忌!用户可能上传两个同名但内容不同的文件。我用的是
file.slice(0, 1024).arrayBuffer()取前 1KB 做 hash,再结合文件大小生成唯一 ID。这样即使重命名,也能识别出是同一个文件。 - <前端别自己算 MD5:曾经为了生成整个文件的 MD5,让用户等 2 分钟……后来改用后端在合并时校验。前端只负责传,校验交给服务端,省事又安全。
还有个细节:Chrome 在 slice 大文件时偶尔会卡主线程。建议用 setTimeout 或 requestIdleCallback 分片处理,避免页面冻结。不过我实测 2GB 文件在现代浏览器里问题不大,除非你的用户还在用 IE(那别传了)。
这个场景最好用:拖拽上传 + 进度条
用户最喜欢拖个文件到页面就自动上传。配合进度条,体验直接拉满。下面是我封装的简易组件:
<div id="drop-area" style="border: 2px dashed #ccc; padding: 20px; text-align: center;">
拖文件到这里上传
</div>
<progress id="progress" value="0" max="100" style="width: 100%; margin-top: 10px;"></progress>
const dropArea = document.getElementById('drop-area');
const progress = document.getElementById('progress');
dropArea.addEventListener('dragover', e => {
e.preventDefault();
dropArea.style.backgroundColor = '#f0f0f0';
});
dropArea.addEventListener('drop', async e => {
e.preventDefault();
dropArea.style.backgroundColor = '';
const file = e.dataTransfer.files[0];
if (!file) return;
let totalUploaded = 0;
const totalSize = file.size;
// 重写 uploadChunk,加入进度回调
async function uploadChunkWithProgress(chunk, index, filename) {
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('index', index);
formData.append('filename', filename);
const res = await fetch('https://jztheme.com/api/upload-chunk', {
method: 'POST',
body: formData
});
if (res.ok) {
totalUploaded += chunk.size;
progress.value = (totalUploaded / totalSize) * 100;
}
return res;
}
// 替换原来的 uploadChunk 调用
// ...(其他逻辑不变)
});
注意:进度条是按字节累加的,不是按 chunk 数量。因为每个 chunk 大小可能不同(最后一块通常更小)。别犯我一开始的错误——用 chunk 数量算进度,结果 99% 卡半天。
高级技巧:失败自动重试 + 暂停恢复
网络不稳定时,单个 chunk 失败不能让整个上传报废。我的方案是:每个 chunk 最多重试 3 次。
async function uploadChunkWithRetry(chunk, index, filename, retries = 3) {
try {
return await uploadChunk(chunk, index, filename);
} catch (err) {
if (retries > 0) {
console.warn(Chunk ${index} failed, retrying... (${3 - retries + 1}/3));
await new Promise(r => setTimeout(r, 1000)); // 等 1 秒再试
return uploadChunkWithRetry(chunk, index, filename, retries - 1);
}
throw err;
}
}
至于暂停/恢复?其实很简单:把当前的 uploadedChunks 数组存到 localStorage,暂停时记录状态,恢复时读取并跳过已传的。不过要注意,如果用户关了页面,第二天再开,文件可能被删了或修改了——所以加个有效期,比如 24 小时,过期就清掉记录。
最后说点实在的
这套方案上线后,2GB 视频上传成功率从 60% 提升到 98%。虽然仍有 2% 的极端情况(比如用户拔网线),但业务方已经很满意了。
有人问:“为什么不直接用第三方库,比如 Uppy?” 我的回答是:Uppy 很好,但定制性差。我们有个需求是“上传过程中允许用户编辑视频元数据”,这就得自己控制流程。而且自己写一遍,才知道底层怎么运作,以后排查问题快得多。
以上是我踩坑后的总结,希望对你有帮助。这个技术的拓展用法还有很多——比如结合 Web Worker 计算 hash、用 Service Worker 缓存上传状态、甚至 P2P 分片上传……后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。

暂无评论