大批量文件上传:前端分组并发与断点重试

在前端开发中,经常会有大批量文件上传的需求。在面对“数千张高清图片批量上传”的需求时,传统的前端上传方案往往会出现各种各样的问题:

  • 一次性全部上传:极易触发服务器 413 Payload Too Large 报错,且巨大的 HTTP 包体容易导致连接超时。
  • 暴力循环:瞬间发起数千个 HTTP 请求,不仅会挤爆浏览器的并发限制,导致大量请求排队挂起,还可能瞬间压垮后端服务。

针对这些问题,就需要前端分批上传,下面介绍一下一种支持分组、控制并发、且具备自动重试机制的大批量文件上传策略

1. 核心设计思路

  • 分批: 将 大批量文件切分成若干个小批次(比如每批 50-100 个),
  • 并发控制: 一次性全部并发(太卡)。我们维护一个并发池(例如限制同时 3 个请求)。当池子满了,必须等其中一个请求结束,才能塞入下一个。这最大化利用了带宽,同时保护了浏览器和服务器。
  • 失败重试: 网络是不稳定的。如果某一批次失败,不应该直接报错停止,而是应该将失败的批次收集起来,在当前轮次结束后自动重试,直到达到最大重试次数。

2. 代码实战

下面是实现该逻辑的完整代码封装。我们使用了 axios 发送请求,利用 Promise.race 实现并发控制。

const handleBatchUpload = async (Files) => {
      const UploadFiles = Array.from(Files) //将<input>接收到是filesList转为数组
      if (UploadFiles.length === 0) return;

      const MAX_RETRY_ROUNDS = 3;// 控制失败重试次数
      const BATCH_SIZE = 50; // 每一组包含多少张图片
      let batchList = [];

      for (let i = 0; i < UploadFiles.length; i += BATCH_SIZE) {
        // slice 切割数组,生成一组
        let chunkFiles = UploadFiles.slice(i, i + BATCH_SIZE);
        batchList.push({
          index: i / BATCH_SIZE, // 批次序号
          files: chunkFiles      // 这一批包含的真实文件数组
        });
      }

      // --- 核心函数:控制并发、分组上传、错误重试 ---
      const uploadBatches = async function (list, retryCount) {
        // 递归结束条件:列表为空
        if (list.length === 0) {
          console.log('所有批次上传完成');
          // 如果有需要,可以在这里调用合并接口,或者直接提示成功
          return;
        }
        if (retryCount > MAX_RETRY_ROUNDS) {
          console.error(`重试 ${MAX_RETRY_ROUNDS} 次后仍有文件失败,停止上传。失败文件:`, list);
          return;
        }

        let pool = [];         // 并发池
        let max = 3;           // 最大并发量 
        let finish = 0;        // 本轮完成的数量
        let failList = [];     // 失败的批次列表

        console.log(`开始处理列表,剩余批次: ${list.length}`);

        for (let i = 0; i < list.length; i++) {
          let batchItem = list[i];

          let formData = new FormData();
          // 核心差异:这里要循环把这一组的所有文件都 append 进去
          batchItem.files.forEach(file => {
            // 'files' 必须与后端接口接收的字段名一致
            formData.append('files', file);
          });
          // 可以附带批次信息
          formData.append('batchIndex', batchItem.index);

          // 发起请求
          let task = axios({
            method: 'post',
            url: 'http://*****/upload-batch',
            data: formData
          });

          task.then((data) => {
            console.log(`批次 ${batchItem.index} 上传成功`);
            // 成功后,从并发池移除
            let idx = pool.findIndex(t => t === task);
            if (idx !== -1) pool.splice(idx, 1);
          }).catch((err) => {
            console.error(`批次 ${batchItem.index} 上传失败`, err);
            // 失败了,将整个批次加入失败列表
            failList.push(batchItem);

            // 即使失败也要从池中移除
            let idx = pool.findIndex(t => t === task);
            if (idx !== -1) pool.splice(idx, 1);
          }).finally(() => {
            finish++;
            // 当本轮所有请求都结束(无论成功失败)
            if (finish === list.length) {
              // 递归调用:如果有失败的,只传失败的
              console.log(`本轮结束,有 ${failList.length} 个批次失败,准备重试...`);
              uploadBatches(failList, retryCount + 1);
            }
          });

          pool.push(task);
          // 并发控制:池子满了就等待最快的一个结束
          if (pool.length === max) {
            await Promise.race(pool);
          }
        }
      }
      // --- 3. 启动上传 ---
      uploadBatches(batchList, 0);
    }

3. 关键技术点解析

A. 使用 Promise.race 控制并发

for 循环中,我们不断将任务推入 pool

  • pool.length === max 时,意味着并发满了。
  • await Promise.race(pool) 会在池子中任意一个请求完成(resolve 或 reject)时立即解除阻塞。
  • 解除阻塞后,循环继续,下一个任务入池。
B. 使用递归重试
  1. 定义 failList 收集本轮失败的批次。
  2. finally 中判断 finish === list.length(本轮是否跑完)。
  3. 如果跑完了且 failList 有数据,直接调用 uploadBatches(failList, retryCount + 1)

4.性能与稳定性分析

直觉认为:“一次性把所有文件扔给服务器最快,因为建立连接(TCP Handshake)的次数最少”。虽然分批上传增加了少量的 HTTP 握手开销(Overhead),但它有着极高的稳定性用户体验的流畅度。并且合理的分批策略和并发控制反而比一次性上传更快

在真实公网的环境下,我对1000张图片进行测试,进行分批上次和一次性上传时间测试,结果如下:

(1)分批上传:用时12.75s

(2)一次性上传:用时13.07s