大文件上传的前端实现方案与断点续传优化实践

博主晓红 交互 阅读 891
赞 25 收藏
二维码
手机扫码查看
反馈

先上核心代码,能跑再说

上周做项目要支持上传 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 大文件时偶尔会卡主线程。建议用 setTimeoutrequestIdleCallback 分片处理,避免页面冻结。不过我实测 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 分片上传……后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论