前端大文件上传完整方案:分片上传、断点续传、秒传与失败重试

前端大文件上传完整方案:分片上传、断点续传、秒传与失败重试

大文件上传不是把 File 交给一个接口那么简单。只要进入弱网、移动网络切换、页面刷新、文件重复提交或多任务并发的场景,单请求上传就会暴露出明显问题:失败后需要从头开始、无法准确恢复、服务端难以清理残留数据,用户也不知道上传究竟停在了哪里。

本文从适用于 Web 工程的角度设计上传方案:以前端上传任务管理器为核心,以业务后端为控制面、对象存储或文件服务为数据面。分片上传解决传输粒度问题;断点续传解决恢复问题;秒传解决重复内容问题;重试、校验与幂等性解决可靠性问题。

本文不绑定 React、Vue 或某一家云存储。重点是可复用的协议边界、状态模型与 TypeScript 实现骨架。实际接入 S3、OSS、COS 或自建文件服务时,应将各自的传输细节收敛到适配器中。

1. 先定义目标:可靠上传不是"上传成功一次"

一个可用于生产环境的上传链路,至少要覆盖以下约束:

  • 文件可能很大,不能因一次请求失败而整体重传;
  • 网络可能断开、超时、切换 Wi-Fi 与蜂窝网络;
  • 页面可能刷新、关闭后重新打开;
  • 同一内容可能被再次上传,希望避免重复传输;
  • 多个文件并行上传时,不能无限占满带宽和内存;
  • 用户可以暂停、取消和恢复;
  • 服务端必须能抵御重复请求、伪造分片与未完成任务堆积。

因此,上传的正确抽象不是单个 HTTP 请求,而是一个可恢复、可观测、可终止的任务

2. 总体架构:控制面与数据面分离

推荐将职责拆成三层:

  1. 浏览器上传管理器:切片、计算指纹、调度并发、发送分片、维护进度和任务状态。
  2. 业务后端(控制面):鉴权、配额、秒传查询、创建上传任务、签发临时上传凭证、查询已上传分片、完成确认与业务绑定。
  3. 对象存储或文件服务(数据面):保存分片或最终对象,提供 multipart 上传、校验、合并或中止能力。

浏览器不应拥有长期存储密钥。即使采用直传对象存储,仍应先经业务后端完成权限判断,并领取范围最小、有效期较短、绑定对象路径和操作权限的临时凭证。

两种常见传输模式

模式 链路 优点 代价
后端中转 浏览器 → 业务后端 → 存储 鉴权、扫描、审计集中;协议可完全自定义 后端带宽与连接成本高
临时凭证直传 浏览器 → 业务后端创建任务;浏览器 → 存储上传分片 数据不穿过业务后端,吞吐更高 CORS、签名、分片确认与完成流程更复杂

对象存储的 multipart 模式通常遵循"初始化---上传独立分片---完成或中止"的过程。以 S3 为例,初始化返回 uploadId;分片可独立、可乱序上传;完成时需要提交分片号及其确认值;未完成任务应显式中止或按生命周期规则清理。(docs.aws.amazon.com)

不同对象存储对最小分片大小、最大分片数、分片编号范围和校验头的要求不同。前端不应自行假设这些限制,而应使用控制面下发的 chunkSize、总分片数和上传约束。

3. 先设计任务状态机,而不是先写上传循环

建议将状态机作为上传模块的公共契约:

ts 复制代码
type UploadStatus =
  | 'idle'
  | 'hashing'
  | 'checking'
  | 'initializing'
  | 'uploading'
  | 'paused'
  | 'retrying'
  | 'completing'
  | 'completed'
  | 'failed'
  | 'canceled';

type PartStatus = 'pending' | 'uploading' | 'uploaded' | 'failed';

interface UploadPart {
  // 前端内部可从 0 开始;传输时需映射为服务端要求的 partNumber。
  index: number;
  start: number;
  end: number;
  size: number;
  status: PartStatus;
  uploadedBytes: number;
  retryCount: number;
  etag?: string;
  checksum?: string;
}

interface UploadTask {
  localId: string;
  uploadId?: string;
  fileName: string;
  fileSize: number;
  fileType: string;
  lastModified: number;
  fingerprint?: string;
  chunkSize: number;
  status: UploadStatus;
  parts: UploadPart[];
  uploadedBytes: number;
  createdAt: number;
  updatedAt: number;
}

这里有两个关键原则:

  • uploadId 是服务端上传会话的身份,不应由前端单独伪造。
  • uploaded 只能表示服务端或存储端已经确认,不能因为请求已发出就乐观标记成功。

暂停、取消和恢复也必须是状态机事件:暂停中止飞行中的请求;恢复先与服务端对账;取消除了停止浏览器请求,还要调用服务端的中止接口清理远端临时资源。

4. 分片上传:按需切片、受控并发、独立确认

浏览器可通过 File 继承的 Blob.slice(start, end) 按字节范围取得分片;end 是排他边界。它返回对应区间的 Blob,适合在真正发送时再生成分片,而不是预先把整个文件读入内存。(developer.mozilla.org)

ts 复制代码
function createParts(file: File, chunkSize: number): UploadPart[] {
  const count = Math.ceil(file.size / chunkSize);

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

    return {
      index,
      start,
      end,
      size: end - start,
      status: 'pending',
      uploadedBytes: 0,
      retryCount: 0
    };
  });
}

空文件是否允许上传、应使用零分片完成还是单独的普通上传流程,属于服务端协议约定;不要让前端默认行为与存储服务的 multipart 规则冲突。

分片大小和并发数没有固定最优值

分片越小,失败重传的损失越小,但请求数、签名次数和调度成本越高;分片越大,协议开销更低,但弱网下单次失败的代价更大。实践中应将它们做成可配置策略,而非写死常量:

  • 桌面端稳定网络:可采用较大的分片和较高并发;
  • 移动端、弱网或高延迟网络:降低并发,避免多个请求互相争夺带宽;
  • 服务端或对象存储有 part 数上限时,分片大小还必须满足总分片数约束;
  • 并发数应从保守值开始,根据失败率、有效吞吐和设备能力逐步调节。

调度器要保证任何时刻只有有限数量的分片处于 uploading。不要一次性创建所有请求,也不要把所有分片转成 ArrayBuffer 后常驻内存。

ts 复制代码
async function runPool<T>(
  jobs: (() => Promise<T>)[],
  concurrency: number
): Promise<T[]> {
  if (concurrency < 1) {
    throw new Error('concurrency must be at least 1');
  }

  const results: T[] = new Array(jobs.length);
  let cursor = 0;

  async function worker() {
    while (true) {
      const current = cursor++;
      if (current >= jobs.length) return;
      results[current] = await jobs[current]();
    }
  }

  await Promise.all(
    Array.from({ length: Math.min(concurrency, jobs.length) }, worker)
  );
  return results;
}

上面的并发池在任一任务抛错时会整体拒绝;实际上传管理器通常应在单个任务内部完成有限重试,并将最终不可恢复的错误转换为明确的任务失败状态,再决定是否停止其他尚未开始的分片。

分片上传接口的最小语义

无论后端中转还是直传,协议都应让服务端能唯一识别一次写入。以下仅为自定义分片协议示例;若使用对象存储原生 multipart API,应按其签名和请求格式实现:

text 复制代码
PUT /uploads/{uploadId}/parts/{partNumber}
Headers:
  Idempotency-Key: {uploadId}:{partNumber}:{partChecksum}
  Content-Range: bytes {start}-{endInclusive}/{fileSize}
  X-Part-Checksum: {checksum}
Body: Blob

服务端至少应校验:uploadId 的归属、分片号与字节范围、声明大小、分片校验值、任务状态和用户配额。对于同一 uploadId + partNumber 的重复上传,要么安全返回既有确认结果,要么采用覆盖语义,但必须保证完成阶段只使用最终确认的那一份分片记录。

5. 初始化、上传、完成:一条完整的协议链路

推荐将前端与业务后端的控制接口设计为:

text 复制代码
POST   /upload-tasks/check       # 秒传候选查询
POST   /upload-tasks             # 创建或恢复任务
GET    /upload-tasks/{id}        # 查询服务端已确认分片
POST   /upload-tasks/{id}/complete
POST   /upload-tasks/{id}/abort

POST /upload-tasks 的返回值应包含:

  • uploadId
  • 服务端认可的 chunkSize 与总分片数;
  • 已确认的分片清单;
  • 每个分片的上传地址、临时凭证,或按分片生成上传地址的方法;
  • 上传与完成操作的过期时间;
  • 是否已经命中秒传。

所有分片上传完成后,前端提交 partNumber + etag/checksum 清单执行完成确认。完成接口必须幂等:用户可能因网络超时没有收到成功响应,但服务端实际上已经完成合并。此时再次调用应返回同一最终文件结果,而不是创建重复文件。

完成接口不能只信任客户端提交的清单。服务端还应自行确认:所需分片是否齐全、每片确认值是否匹配、总大小是否一致、上传会话是否仍属于当前主体,以及全文件校验是否通过。

对象存储的 multipart 完成过程通常需要客户端保存每个分片成功后的 ETag;但 multipart 完成后的对象 ETag 不应被普遍当作完整文件 MD5,完整性应使用明确协商的 checksum 策略。(docs.aws.amazon.com)

6. 断点续传:本地记录用于定位,服务端状态才是事实

断点续传常见误区是:只在 localStorage 或 IndexedDB 保存"我已上传到第 N 片"。这只能作为恢复线索,不能作为最终依据,因为请求超时可能意味着"响应丢了",而不是"服务端没写成功"。

正确恢复流程是:

  1. 本地持久化任务基础信息:uploadId、文件指纹、分片大小、分片确认记录、任务更新时间;
  2. 页面恢复后,提示用户重新选择文件,或在可用场景下恢复受浏览器支持的本地文件引用;
  3. 校验所选文件是否仍与任务匹配,例如大小、修改时间和内容指纹;
  4. 调用 GET /upload-tasks/{id} 查询服务端已确认分片;
  5. 以服务端分片清单覆盖本地乐观状态,仅补传缺失分片;
  6. 所有分片齐全后,再执行幂等的完成确认。

IndexedDB 可以保存大量结构化数据,也可以存储 Blob/File;但用户清理站点数据、无痕会话结束、配额压力等都可能导致数据不可用。因此,本地数据库应被视为恢复体验的增强层,而不是上传正确性的唯一来源。(developer.mozilla.org)

另一类协议如 tus 使用服务端维护的 Upload-Offset 表示顺序上传位置,客户端恢复时先通过 HEAD 查询偏移,再从该位置继续 PATCH;若偏移不一致,服务端应拒绝错误位置的写入。它适合顺序续传模型,但不能和"可乱序并行 multipart 分片"直接混用。(tus.io)

7. 秒传:内容去重必须晚于权限判断

"秒传"本质不是前端宣布"这个文件存在",而是:

  1. 前端计算文件内容指纹;
  2. 后端在当前用户或租户的授权范围内查询是否已有同内容对象;
  3. 命中后创建新的业务引用或授权绑定;
  4. 未命中才创建上传任务。

文件名、大小、修改时间只能作为快速候选筛选,不能证明内容相同。可靠的内容去重需要完整内容哈希,例如 SHA-256。哈希可用于识别相同数据,但它不替代业务授权:跨租户全局去重如果直接暴露"某哈希存在",可能泄露其他租户是否持有某文件。

大文件哈希策略

SubtleCrypto.digest() 需要一次性传入完整数据,且不支持流式输入;对超大文件直接 await file.arrayBuffer() 再计算哈希,会带来明显内存压力。(developer.mozilla.org)

推荐策略是:

  • 在 Web Worker 中使用支持增量输入的哈希库;
  • 每次读取一个 Blob 分片,更新哈希状态后立即释放引用;
  • 把哈希进度与上传进度拆开显示;
  • 若采用首尾片段等抽样指纹,只能用于快速查找候选任务;真正秒传确认仍应依赖全量内容标识或服务端可验证的等价机制。

8. 失败重试:先判断能不能重试,再决定怎么重试

自动重试的前提是请求具有幂等语义。分片请求若由 uploadId + partNumber + checksum 唯一约束,就可以安全地重试;完成请求若有幂等键,也可以重试或改为查询任务最终状态。

建议的错误策略:

情况 默认动作
网络断开、DNS 失败、连接重置、超时 有限次数退避重试;网络恢复后先对账
HTTP 429 优先遵循 Retry-After,否则退避重试
HTTP 500、502、503、504 有限次数退避重试
401、403、临时凭证过期 刷新授权或重新初始化,不盲目重传
400、413、415、422 终止任务,展示可操作的业务错误
checksum 不匹配 标记分片损坏,重新获取上传凭证或重传该片;持续失败则终止

HTTP 语义中,503 表示服务暂不可用,服务端可以通过 Retry-After 提示重试时间;同时,客户端不应在不知道请求是否幂等时自动重试。(rfc-editor.org)

退避时间可加入随机抖动,避免网络恢复后大量分片同时重试:

ts 复制代码
function backoffMs(attempt: number, base = 500, cap = 15_000) {
  const exponential = Math.min(cap, base * 2 ** attempt);
  return Math.round(exponential * (0.5 + Math.random()));
}

当浏览器收到网络恢复信号时,不要立刻把所有失败分片重新发出。更稳妥的做法是将任务转入 retrying,先拉取服务端分片清单,再调度真正缺失的部分。

9. 进度、暂停与取消:展示真实阶段,不制造"99% 假象"

上传至少有三个阶段:

  1. 哈希阶段:已读取字节 / 文件总字节;
  2. 传输阶段:所有分片已确认字节 / 文件总字节;
  3. 完成阶段:服务端校验、合并、病毒扫描或业务入库。

总体上传进度不应简单用"已完成请求数 / 总分片数"计算,因为最后一个分片可能远小于其他分片。应按字节加权:

ts 复制代码
const transferred = parts.reduce(
  (sum, part) => sum + part.uploadedBytes,
  0
);
const progress = transferred / file.size;

对于 file.size === 0 的特殊场景,应单独定义展示逻辑,避免得到 NaN 或无穷大进度。

如果需要稳定的浏览器端上传字节进度,建议保留 XHR 传输适配器:XMLHttpRequest.upload 提供 progresserroraborttimeout 等上传生命周期事件。(developer.mozilla.org)

暂停或取消时,要中止正在飞行的请求。AbortController 可以中止一个或多个 Web 请求;但中止本地请求不等于远端分片一定未写入,所以恢复时仍必须重新对账。(developer.mozilla.org)

取消流程应是:停止调度 → 中止在途请求 → 请求服务端 abort → 删除本地任务记录。若 abort 因断网暂时失败,前端应保留待清理标记,并依赖下次联网重试及服务端任务过期清理,避免将"本地已取消"误认为"远端已清理"。

10. 一个可替换传输层的前端 API

上传管理器不应依赖具体云厂商接口。可以把业务流程与传输实现拆开:

ts 复制代码
interface UploadTransport {
  check(input: {
    fingerprint: string;
    size: number;
    name: string;
  }): Promise<{ hit: boolean; fileId?: string }>;

  initialize(input: {
    fingerprint: string;
    size: number;
    name: string;
    type: string;
  }): Promise<{
    uploadId: string;
    chunkSize: number;
    uploadedParts: Array<{ index: number; etag?: string }>;
  }>;

  uploadPart(input: {
    uploadId: string;
    part: UploadPart;
    blob: Blob;
    signal?: AbortSignal;
    onProgress: (loaded: number) => void;
  }): Promise<{ etag?: string; checksum?: string }>;

  getUploadedParts(
    uploadId: string
  ): Promise<Array<{ index: number; etag?: string }>>;

  complete(
    uploadId: string,
    parts: UploadPart[]
  ): Promise<{ fileId: string; url?: string }>;

  abort(uploadId: string): Promise<void>;
}

若采用预签名 URL 或临时凭证直传,initialize 的返回结构还应包含上传地址、凭证,或获取单个分片上传地址的方法;具体字段由适配器封装。分片 checksum 可以在上传前写入 part.checksum,也可以由适配器在读取分片时计算,但完成阶段提交的值必须与服务端协议一致。

这样,状态机、并发池、重试策略、IndexedDB 持久化和 UI 层都可以复用;切换到 S3 multipart、云厂商 SDK、自建分片接口或 tus 时,只需要替换 UploadTransport

11. 一致性、完整性与安全边界

可靠性不能只停留在前端:

  • 幂等性:初始化、分片上传、完成与取消都要有明确的重复请求语义;
  • 完整性:分片级 checksum 用于尽早发现传输损坏,完成时再校验全文件 checksum;
  • 权限uploadId、临时凭证、对象路径都必须绑定用户、租户、业务实体和过期时间;
  • 内容验证:不能只相信前端传来的 MIME 类型或扩展名,服务端应进行大小限制、内容嗅探、病毒扫描或业务规则检测;
  • 资源保护:限制单用户并发任务数、文件大小、分片数量、失败重试总量和未完成任务存活时间;
  • CORS :直传对象存储时,需要允许必要请求方法与请求头,并暴露前端需要读取的响应头;尤其要确保前端能读取完成分片所需的 ETag 或 checksum 响应头。

12. 上线前测试矩阵

不要只测"选文件后成功上传"。至少覆盖:

  • 上传中断网、恢复网络、切换 Wi-Fi/蜂窝网络;
  • 单分片超时、429、5xx、上传成功但响应丢失;
  • 分片上传成功但响应丢失后再次发送;
  • 刷新页面、关闭后重新进入、IndexedDB 被清理;
  • 临时凭证过期、登录态失效、服务端任务过期;
  • 同一个文件被重复选择、同一文件在多个页面并发上传;
  • 分片 checksum 错误、完成时全文件校验失败;
  • 用户暂停、取消后仍有在途请求返回成功;
  • 文件超限、类型不允许、配额耗尽和恶意构造的分片编号;
  • 取消接口因断网失败,以及服务端过期清理最终回收残留分片。

同时记录可观测指标:初始化成功率、秒传命中率、单分片重试率、完成耗时、取消后残留任务数、任务最终失败原因,以及按网络类型、浏览器和文件大小分组的成功率。

结语

分片上传只是大文件上传的起点。真正稳定的方案,需要把它放进一个完整任务协议中:服务端创建身份,前端受控调度,分片独立幂等,恢复时以服务端对账,秒传以内容指纹加授权为前提,失败按类型退避,取消后显式清理。

当这些边界被设计清楚后,React、Vue、原生 TypeScript 或具体对象存储 SDK 都只是界面和传输层的实现选择;上传可靠性本身将成为一项可复用的基础能力。

参考资料

相关推荐
HjhIron1 小时前
NestJS 入门指南:从工厂模式到模块化 CRUD 实战
前端·nestjs
HjhIron1 小时前
手把手教你用 Next.js 14 + Redis 从零搭建一个全栈 Markdown 笔记系统
前端·全栈·next.js
WIN赢1 小时前
【抽象思想-从复杂中抽离简单、收敛的口子】
java·前端·javascript
martindelophy1 小时前
Codex Chrome 插件 + Timeline Studio:构建可编辑的 AI 视频剪辑 Agent 工作流
前端·人工智能·chrome
whyutianict_vv2 小时前
从 Web 前端到 HarmonyOS ArkTS:一次 AI 鸿蒙全栈智能体开发的迁移实录
前端·人工智能·harmonyos
qziovv3 小时前
前端转flutter——项目架构、初始化
前端·flutter
Ali885203 小时前
Python字符串方法速查表大全
前端·python
前端_刘师兄3 小时前
FAE工程师学习路线-进程
前端