大文件上传的断点续传与分片实现技巧

ლ舒昕 交互 阅读 2,467
赞 22 收藏
二维码
手机扫码查看
反馈

我的写法,亲测靠谱

大文件上传这玩意儿,我前后端都折腾过好几轮。刚开始图省事,直接用 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 监听离线上传等等,后续会继续分享这类实战经验。

以上是我踩坑后的总结,希望对你有帮助。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论
百里倩利
文章里的快速入门指南太实用了,让我能在短时间内掌握核心内容,快速上手项目。
点赞
2026-03-27 16:25