当前位置:首页 > 文章列表 > 文章 > 前端 > 前端上传大文件:分片、暂停与失败续传怎样协作

前端上传大文件:分片、暂停与失败续传怎样协作

来源:17golang原创 2026-10-07 08:18:31 0浏览 收藏

分片、暂停和失败续传必须围绕同一个“上传会话”协作。前端用固定规则把 File 切成带索引的 Blob,服务端用 uploadId + chunkIndex 幂等保存分片;暂停时停止调度并中止在途请求,恢复时先查询服务端已接收索引,只补传缺失分片。最后,只有服务端确认全部分片齐全,前端才请求合并。

浏览器侧的核心能力来自 File API 与 XMLHttpRequest。W3C File API 规定 Blob.slice(start, end) 返回指定字节范围的新 Blob;MDN 说明 XMLHttpRequest.upload 可以监听上传进度,xhr.abort() 可以中止已发送请求。断点续传协议 tus 也采用“创建上传资源、查询当前位置、从已确认位置继续”的核心思路。

参考资料:https://www.w3.org/TR/FileAPI/、https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/upload、https://tus.io/protocols/resumable-upload

前置条件:先统一前后端上传会话契约

前端暂停按钮只是控制入口,真正的续传能力来自服务端可查询的上传状态。一个最小契约需要四类接口:

接口输入关键返回职责
POST /uploads文件名、大小、类型、分片总数uploadId、建议分片大小、已存在索引创建或复用上传会话
PUT /uploads/{id}/parts/{index}单个 Blob 与字节范围元数据服务端确认的分片索引幂等写入一个分片
GET /uploads/{id}uploadIduploadedIndexes、会话状态暂停、失败或刷新后对账
POST /uploads/{id}/complete分片总数与文件元数据完整文件标识校验齐全后合并

chunkIndex 应从 0 开始并在同一会话内保持稳定。上传单片接口必须幂等:同一个 uploadId + chunkIndex 重复到达时,要么覆盖同一临时对象,要么识别为已完成,不能额外追加一份。这样网络超时后前端即使不知道上一次是否落盘,也可以安全重试。

File、分片索引、上传会话与服务端分片存储的静态契约结构
图1:分片上传契约结构图。客户端用 File 与 Blob.slice 产生固定 chunkIndex,uploadId 连接服务端已接收集合、分片存储和最终完整文件。此图为静态结构图,不是运行截图。

初始化:用 Blob.slice 建立稳定分片表

不要先把整个文件读入内存再切数组。File 继承自 Blob,可以直接按字节范围调用 slice()。下面把文件切成固定大小的描述对象,真正发送时才取得对应 Blob:

function buildParts(file, chunkSize) {
  const total = Math.ceil(file.size / chunkSize);

  return Array.from({ length: total }, (_, index) => {
    const start = index * chunkSize;
    const end = Math.min(start + chunkSize, file.size);

    return {
      index,
      start,
      end,
      size: end - start,
      // 中文说明:Blob 只引用当前字节范围,不必先复制整个文件
      blob: file.slice(start, end, file.type),
    };
  });
}

function createFileFingerprint(file) {
  // 中文说明:该指纹用于重新选文件时比对,不是密码学完整性校验
  return `${file.name}:${file.size}:${file.lastModified}`;
}

分片大小没有通用固定值。较小分片失败重传成本低,但请求数量、鉴权和服务端临时对象更多;较大分片请求少,但单片失败代价更高。让初始化接口返回服务端允许的 chunkSize,前端再据此构造分片,能避免两端配置不一致。

上面的文件指纹只适合“用户重新选择后是否像同一个文件”的快速判断。文件名、大小和修改时间可能碰撞,不能替代服务端完整性校验。需要强完整性时,应由协议约定分片摘要或完整文件摘要,并由服务端在合并前验证。

编写代码:实现有限并发调度器

控制器至少要维护四类状态:completed 表示服务端已确认的分片;inFlight 保存当前 XHR,供暂停时中止;retryCount 记录每片重试次数;paused 阻止继续调度。不要把“进度条到 100%”当成完成集合,它只是字节传输过程的视图。

class ChunkUploadController {
  constructor({ file, uploadId, chunkSize, concurrency = 3 }) {
    this.file = file;
    this.uploadId = uploadId;
    this.parts = buildParts(file, chunkSize);
    this.concurrency = concurrency;
    this.completed = new Set();
    this.inFlight = new Map();
    this.retryCount = new Map();
    this.paused = false;
  }

  getMissingParts() {
    // 中文说明:调度依据是服务端已确认集合,不依据进度条百分比
    return this.parts.filter((part) => !this.completed.has(part.index));
  }

  async start() {
    this.paused = false;
    const queue = this.getMissingParts();

    const workers = Array.from(
      { length: Math.min(this.concurrency, queue.length) },
      () => this.consume(queue),
    );
    await Promise.all(workers);

    if (!this.paused && this.completed.size === this.parts.length) {
      // 中文说明:仅在全部索引得到服务端确认后请求完成合并
      await this.completeUpload();
    }
  }

  async consume(queue) {
    while (!this.paused) {
      const part = queue.shift();
      if (!part) return;

      try {
        await this.uploadWithRetry(part);
      } catch (error) {
        if (error.name !== "AbortError") throw error;
        // 中文说明:用户暂停引起的 abort 不计为网络失败
        return;
      }
    }
  }
}

共享数组的 shift() 在浏览器单线程事件循环中可作为简单任务池;每个 worker 在一次 Promise 完成后再取下一片,因此并发数不会超过配置。生产项目还应在控制器外层处理鉴权刷新、文件大小上限和服务端会话过期。

运行上传:用 XHR 统一进度、成功和中止

MDN 的文件上传示例仍使用 XMLHttpRequest 获取上传进度,因为 xhr.upload 会发出 progress 事件,且 abort() 能直接终止在途请求。监听器应在 send() 前注册;跨域上传监听 upload 事件会触发 CORS 预检,服务端需要正确响应。

function sendPart({ uploadId, part, onProgress }) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    const url = `/uploads/${encodeURIComponent(uploadId)}/parts/${part.index}`;

    xhr.upload.addEventListener("progress", (event) => {
      if (!event.lengthComputable) return;
      // 中文说明:这里只汇报当前分片已发送字节,不直接标记分片完成
      onProgress?.(part.index, event.loaded, event.total);
    });

    xhr.addEventListener("load", () => {
      if (xhr.status >= 200 && xhr.status  {
      reject(new Error(`分片 ${part.index} 网络错误`));
    });

    xhr.addEventListener("abort", () => {
      reject(new DOMException("上传已暂停", "AbortError"));
    });

    xhr.open("PUT", url, true);
    xhr.setRequestHeader("Content-Type", "application/octet-stream");
    xhr.setRequestHeader("X-Chunk-Start", String(part.start));
    xhr.setRequestHeader("X-Chunk-End", String(part.end));
    xhr.send(part.blob);

    // 中文说明:调用方保存 XHR 引用,暂停时才能中止该请求
    part.xhr = xhr;
  });
}

如果接口跨域,Content-Type 和自定义请求头都可能参与预检。要在服务端显式允许所需方法与请求头,不要为了绕开预检而删除必要的身份或范围信息。

暂停:停止调度并中止所有在途请求

只把 paused 设为 true 不够,因为已经发出的请求仍在上传;只调用 abort() 也不够,因为 worker 可能马上取下一片。正确暂停同时完成两件事:先关闭调度开关,再中止所有在途请求。

ChunkUploadController.prototype.pause = function pause() {
  this.paused = true;

  for (const xhr of this.inFlight.values()) {
    // 中文说明:abort 会触发 AbortError,调度器将其识别为用户暂停
    xhr.abort();
  }
  this.inFlight.clear();
};

ChunkUploadController.prototype.uploadOnce = async function uploadOnce(part) {
  const task = sendPart({
    uploadId: this.uploadId,
    part,
    onProgress: (index, loaded, total) => {
      // 中文说明:界面可聚合各分片进度,但完成状态仍以后端确认为准
      this.onPartProgress?.({ index, loaded, total });
    },
  });

  this.inFlight.set(part.index, part.xhr);
  try {
    const result = await task;
    this.completed.add(result.index);
  } finally {
    // 中文说明:成功、失败和暂停都要移除在途引用
    this.inFlight.delete(part.index);
  }
};

实际实现时,sendPart 最好直接返回 { promise, xhr },避免把 XHR 临时挂在 part 上。这里拆开书写是为了突出资源所有权:控制器必须在请求完成前拿到 XHR 引用。

上传控制器、暂停标记、待传队列、在途 XHR 与恢复状态的静态关系
图2:上传控制器状态关系图。UploadController 持有暂停标记、待传队列和在途 XHR;completed 由 server status 对账更新,retryCount 只服务失败分片,local session 保存续传会话。此图为静态关系图,不是运行证据。

恢复:先查询服务端状态,再重建缺失队列

暂停发生在任意时刻:浏览器可能已经把某片发完,但成功响应尚未到达;也可能刚发送一部分就断网。因此本地 completed 不能作为恢复的唯一依据。恢复前必须查询服务端:

ChunkUploadController.prototype.syncServerStatus = async function syncServerStatus() {
  const response = await fetch(`/uploads/${encodeURIComponent(this.uploadId)}`, {
    method: "GET",
    headers: {
      // 中文说明:按项目方式携带鉴权,不要把凭据写进本地持久化记录
      Accept: "application/json",
    },
  });

  if (!response.ok) {
    throw new Error(`查询上传状态失败:HTTP ${response.status}`);
  }

  const data = await response.json();
  // 中文说明:用服务端事实覆盖本地集合,避免漏传或重复判断
  this.completed = new Set(data.uploadedIndexes);
};

ChunkUploadController.prototype.resume = async function resume() {
  await this.syncServerStatus();
  this.paused = false;
  await this.start();
};

如果服务端返回会话已过期,前端应创建新会话,而不是继续向旧 uploadId 发送。tus 规范也为可过期上传定义了过期信息与失效响应;自定义协议至少应有等价的状态码或业务状态。

失败续传:只重试当前分片,并设置上限

“失败续传”不是把整个上传重新开始,而是让失败分片回到待传集合。退避重试可以吸收短暂网络抖动,但认证失败、文件超限、会话不存在和服务端明确拒绝不应盲目重试。

function sleep(ms) {
  // 中文说明:退避等待只服务当前失败分片
  return new Promise((resolve) => setTimeout(resolve, ms));
}

ChunkUploadController.prototype.uploadWithRetry = async function uploadWithRetry(part) {
  const maxAttempts = 4;

  for (let attempt = 1; attempt 

重试前可以先查询当前分片是否已经落盘。如果服务端已确认,就直接加入 completed,无需再次发送。是否每次失败都查询取决于接口成本;至少在网络恢复、用户点击继续或页面刷新后做一次完整对账。

完成合并:服务端校验齐全后再生成文件

前端看到所有 Promise 成功,只能说明它收到了成功响应;最终完整性仍由服务端负责。完成接口应检查分片索引是否齐全、每片范围是否合法、总字节数是否匹配,并以幂等方式返回同一个完整文件结果。

ChunkUploadController.prototype.completeUpload = async function completeUpload() {
  const response = await fetch(
    `/uploads/${encodeURIComponent(this.uploadId)}/complete`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        // 中文说明:服务端据此检查索引范围与预期文件大小
        totalChunks: this.parts.length,
        fileSize: this.file.size,
        fileName: this.file.name,
      }),
    },
  );

  if (!response.ok) {
    throw new Error(`合并失败:HTTP ${response.status}`);
  }

  const result = await response.json();
  // 中文说明:合并成功后清理本地会话,避免下次误续传
  localStorage.removeItem(`upload:${this.uploadId}`);
  return result;
};

不要在每上传一片后立即触发一次合并,也不要让多个并发 worker 各自判断并请求完成。合并属于会话级动作,应由调度器在所有分片确认后只调用一次;服务端仍要保证重复 complete 请求安全。

扩展实验:页面刷新后怎样继续

普通 localStorage 可以保存 uploadId、文件指纹、分片大小和创建时间,但不能可靠保存用户选择的 File 对象。刷新后通常需要用户重新选择文件,再比较指纹并查询服务端状态。

function saveUploadSession(controller) {
  const record = {
    uploadId: controller.uploadId,
    fingerprint: createFileFingerprint(controller.file),
    chunkSize: controller.parts[0]?.size ?? 0,
    savedAt: Date.now(),
  };

  // 中文说明:只保存续传元数据,不保存文件内容或鉴权凭据
  localStorage.setItem(`upload:${controller.uploadId}`, JSON.stringify(record));
}

function assertSameFile(file, record) {
  if (createFileFingerprint(file) !== record.fingerprint) {
    // 中文说明:文件不匹配时拒绝续传,避免把分片写进错误会话
    throw new Error("重新选择的文件与原上传任务不匹配");
  }
}

如果产品需要无需重新选择文件的刷新恢复,可以评估 File System Access API 或桌面壳能力,但必须先检查目标浏览器支持和权限体验。跨浏览器方案仍应把“重新选文件 + 指纹校验 + 服务端对账”作为可靠基线。

清理与上线检查

  1. 分片规则是否由同一个 chunkSize 和从 0 开始的索引稳定生成?
  2. 上传单片接口是否对 uploadId + chunkIndex 幂等?
  3. 暂停是否先禁止新调度,再中止全部在途 XHR?
  4. 恢复是否先查询服务端 uploadedIndexes,而不是直接相信本地进度?
  5. 失败重试是否有次数上限、退避和不可重试错误分类?
  6. 完成接口是否校验分片齐全、总大小和完整性,并保持幂等?
  7. 刷新续传是否只保存会话元数据,重新选文件后是否验证匹配?
  8. 会话过期、用户取消和合并成功后,服务端临时分片是否有清理策略?

把三种能力放在一起看,分片解决“怎样把大文件变成可重试单元”,暂停解决“怎样暂时停止调度与传输”,失败续传解决“怎样用服务端事实重建缺失集合”。它们共享的核心不是进度条,而是稳定的上传会话、幂等分片接口和可查询状态。

相关问题

为什么不用一个 fetch 直接上传整个文件?
一次请求实现最简单,但失败通常需要重传全部内容,也难以实现可靠的单片重试。大文件和不稳定网络更适合分片协议。

暂停后已经上传一半的分片怎么办?
前端把它视为未确认。恢复时查询服务端;服务端若已完整接收就加入 completed,否则重传该分片。

并发数越大越快吗?
不一定。并发过高会增加连接、内存、磁盘和服务端合并压力。应从 2–4 个并发开始,结合网络和服务端容量调整。

必须自己设计协议吗?
不必须。若项目需要跨客户端和成熟生态,可以评估 tus 等已有断点续传协议;自定义接口也应保持会话创建、状态查询、偏移或分片确认、完成校验等基本能力。

参考资料

  • W3C File API:https://www.w3.org/TR/FileAPI/
  • MDN Blob:https://developer.mozilla.org/en-US/docs/Web/API/Blob
  • MDN XMLHttpRequest upload:https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/upload
  • MDN 使用 XMLHttpRequest:https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest_API/Using_XMLHttpRequest
  • tus resumable upload protocol:https://tus.io/protocols/resumable-upload
版本声明
本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
只在 Context 中携带请求级元数据而不传业务参数只在 Context 中携带请求级元数据而不传业务参数
上一篇
只在 Context 中携带请求级元数据而不传业务参数
施工分包进场前要核对哪些人员与安全资料
下一篇
施工分包进场前要核对哪些人员与安全资料
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之JavaScript设计模式
    前端进阶之JavaScript设计模式
    设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
    543次学习
  • GO语言核心编程课程
    GO语言核心编程课程
    本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
    516次学习
  • 简单聊聊mysql8与网络通信
    简单聊聊mysql8与网络通信
    如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
    500次学习
  • JavaScript正则表达式基础与实战
    JavaScript正则表达式基础与实战
    在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
    487次学习
  • 从零制作响应式网站—Grid布局
    从零制作响应式网站—Grid布局
    本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
    485次学习
查看更多
AI推荐
  • PubMedQA数据集详解:生物医学问答基准、功能与应用指南
    PubMedQA
    深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
    361次使用
  • H2O EvalGPT:开源LLM大模型评估与排行榜工具
    H2O EvalGPT
    H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
    417次使用
  • LMArena是什么?伯克利AI模型评估平台使用指南与功能解析
    LMArena
    LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
    430次使用
  • 斯坦福HELM:大语言模型Holistic Evaluation整体评估框架详解
    HELM
    深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
    384次使用
  • MMBench详解:多模态大模型基准测试、功能特点与使用指南
    MMBench
    MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
    209次使用
微信登录更方便
  • 密码登录
  • 注册账号
登录即同意 用户协议 和 隐私政策
返回登录
  • 重置密码