前端上传大文件:分片、暂停与失败续传怎样协作
来源:17golang原创
时间:2026-10-07 08:18:31 371浏览 收藏
分片、暂停和失败续传必须围绕同一个“上传会话”协作。前端用固定规则把 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} | uploadId | uploadedIndexes、会话状态 | 暂停、失败或刷新后对账 |
POST /uploads/{id}/complete | 分片总数与文件元数据 | 完整文件标识 | 校验齐全后合并 |
chunkIndex 应从 0 开始并在同一会话内保持稳定。上传单片接口必须幂等:同一个 uploadId + chunkIndex 重复到达时,要么覆盖同一临时对象,要么识别为已完成,不能额外追加一份。这样网络超时后前端即使不知道上一次是否落盘,也可以安全重试。

初始化:用 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 引用。

恢复:先查询服务端状态,再重建缺失队列
暂停发生在任意时刻:浏览器可能已经把某片发完,但成功响应尚未到达;也可能刚发送一部分就断网。因此本地 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 或桌面壳能力,但必须先检查目标浏览器支持和权限体验。跨浏览器方案仍应把“重新选文件 + 指纹校验 + 服务端对账”作为可靠基线。
清理与上线检查
- 分片规则是否由同一个
chunkSize和从 0 开始的索引稳定生成? - 上传单片接口是否对
uploadId + chunkIndex幂等? - 暂停是否先禁止新调度,再中止全部在途 XHR?
- 恢复是否先查询服务端
uploadedIndexes,而不是直接相信本地进度? - 失败重试是否有次数上限、退避和不可重试错误分类?
- 完成接口是否校验分片齐全、总大小和完整性,并保持幂等?
- 刷新续传是否只保存会话元数据,重新选文件后是否验证匹配?
- 会话过期、用户取消和合并成功后,服务端临时分片是否有清理策略?
把三种能力放在一起看,分片解决“怎样把大文件变成可重试单元”,暂停解决“怎样暂时停止调度与传输”,失败续传解决“怎样用服务端事实重建缺失集合”。它们共享的核心不是进度条,而是稳定的上传会话、幂等分片接口和可查询状态。
相关问题
为什么不用一个 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
-
255 收藏
-
459 收藏
-
282 收藏
-
183 收藏
-
381 收藏
-
385 收藏
-
289 收藏
-
133 收藏
-
153 收藏
-
108 收藏
-
141 收藏
-
477 收藏
-
343 收藏
-
106 收藏
-
文章 · 前端 | 1天前 | 前端 · 性能优化 · javascript · scheduler.postTask TaskController Prioritized Task Scheduling API TaskSignal JavaScript任务优先级148 收藏
-
文章 · 前端 | 1天前 | 前端 · View Transition API startViewTransition ViewTransitionTypeSet pageswap pagereveal195 收藏
-
363 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习