前端大文件上传完整方案:分片上传、断点续传、秒传与失败重试
大文件上传不是把 File 交给一个接口那么简单。只要进入弱网、移动网络切换、页面刷新、文件重复提交或多任务并发的场景,单请求上传就会暴露出明显问题:失败后需要从头开始、无法准确恢复、服务端难以清理残留数据,用户也不知道上传究竟停在了哪里。
本文从适用于 Web 工程的角度设计上传方案:以前端上传任务管理器为核心,以业务后端为控制面、对象存储或文件服务为数据面。分片上传解决传输粒度问题;断点续传解决恢复问题;秒传解决重复内容问题;重试、校验与幂等性解决可靠性问题。
本文不绑定 React、Vue 或某一家云存储。重点是可复用的协议边界、状态模型与 TypeScript 实现骨架。实际接入 S3、OSS、COS 或自建文件服务时,应将各自的传输细节收敛到适配器中。
1. 先定义目标:可靠上传不是"上传成功一次"
一个可用于生产环境的上传链路,至少要覆盖以下约束:
- 文件可能很大,不能因一次请求失败而整体重传;
- 网络可能断开、超时、切换 Wi-Fi 与蜂窝网络;
- 页面可能刷新、关闭后重新打开;
- 同一内容可能被再次上传,希望避免重复传输;
- 多个文件并行上传时,不能无限占满带宽和内存;
- 用户可以暂停、取消和恢复;
- 服务端必须能抵御重复请求、伪造分片与未完成任务堆积。
因此,上传的正确抽象不是单个 HTTP 请求,而是一个可恢复、可观测、可终止的任务。
2. 总体架构:控制面与数据面分离
推荐将职责拆成三层:
- 浏览器上传管理器:切片、计算指纹、调度并发、发送分片、维护进度和任务状态。
- 业务后端(控制面):鉴权、配额、秒传查询、创建上传任务、签发临时上传凭证、查询已上传分片、完成确认与业务绑定。
- 对象存储或文件服务(数据面):保存分片或最终对象,提供 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 片"。这只能作为恢复线索,不能作为最终依据,因为请求超时可能意味着"响应丢了",而不是"服务端没写成功"。
正确恢复流程是:
- 本地持久化任务基础信息:
uploadId、文件指纹、分片大小、分片确认记录、任务更新时间; - 页面恢复后,提示用户重新选择文件,或在可用场景下恢复受浏览器支持的本地文件引用;
- 校验所选文件是否仍与任务匹配,例如大小、修改时间和内容指纹;
- 调用
GET /upload-tasks/{id}查询服务端已确认分片; - 以服务端分片清单覆盖本地乐观状态,仅补传缺失分片;
- 所有分片齐全后,再执行幂等的完成确认。
IndexedDB 可以保存大量结构化数据,也可以存储 Blob/File;但用户清理站点数据、无痕会话结束、配额压力等都可能导致数据不可用。因此,本地数据库应被视为恢复体验的增强层,而不是上传正确性的唯一来源。(developer.mozilla.org)
另一类协议如 tus 使用服务端维护的 Upload-Offset 表示顺序上传位置,客户端恢复时先通过 HEAD 查询偏移,再从该位置继续 PATCH;若偏移不一致,服务端应拒绝错误位置的写入。它适合顺序续传模型,但不能和"可乱序并行 multipart 分片"直接混用。(tus.io)
7. 秒传:内容去重必须晚于权限判断
"秒传"本质不是前端宣布"这个文件存在",而是:
- 前端计算文件内容指纹;
- 后端在当前用户或租户的授权范围内查询是否已有同内容对象;
- 命中后创建新的业务引用或授权绑定;
- 未命中才创建上传任务。
文件名、大小、修改时间只能作为快速候选筛选,不能证明内容相同。可靠的内容去重需要完整内容哈希,例如 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% 假象"
上传至少有三个阶段:
- 哈希阶段:已读取字节 / 文件总字节;
- 传输阶段:所有分片已确认字节 / 文件总字节;
- 完成阶段:服务端校验、合并、病毒扫描或业务入库。
总体上传进度不应简单用"已完成请求数 / 总分片数"计算,因为最后一个分片可能远小于其他分片。应按字节加权:
ts
const transferred = parts.reduce(
(sum, part) => sum + part.uploadedBytes,
0
);
const progress = transferred / file.size;
对于 file.size === 0 的特殊场景,应单独定义展示逻辑,避免得到 NaN 或无穷大进度。
如果需要稳定的浏览器端上传字节进度,建议保留 XHR 传输适配器:XMLHttpRequest.upload 提供 progress、error、abort、timeout 等上传生命周期事件。(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 都只是界面和传输层的实现选择;上传可靠性本身将成为一项可复用的基础能力。