在JavaScript中实现分块文件上传
用JavaScript实现可断点续传的分块文件上传。处理大文件、跟踪进度,并从网络中断中恢复。
《数据安全法》(DSL)要求数据处理者保障数据完整性——文件传输中的静默数据损坏正是对这一要求的直接挑战。JavaScript 分块上传通过对每个块计算 SHA-256 哈希来应对:将大文件拆分为固定大小的块(通常 5–10 MB),逐块上传并验证完整性,在服务端重组,并在 IndexedDB 中持久化上传状态以支持断点续传。
分块上传解决三个实际问题:浏览器和代理服务器会中止超过 2 GB 的请求;移动网络在上传中途断线;用户需要进度反馈。可用实现使用 File.slice() 切割块,每块搭配独立 AbortSignal 的 fetch,通过 S3 分片或自定义合并在服务端重组,以及 IndexedDB 中的本地索引以在标签页重载后恢复上传。
为何分块优于单次上传
将 4 GB 文件作为单个请求上传必然失败:Nginx 默认 client_max_body_size 为 1 MB,Cloudflare 免费层每次请求限制 100 MB,AWS API Gateway 硬性上限 10 MB,移动端 Safari 会终止持有 4 GB ArrayBuffer 的标签页。分块上传绕过了所有这些限制。还能获得真正动态的进度条、不从零重试和暂停恢复能力。代价是更多的服务端状态和更多的往返——10 GB 文件以 5 MB 为块约 2,000 次请求。
选择块大小
块大小是吞吐量与弹性的权衡。太小(低于 1 MB)则 TLS 握手时间占主导。太大(超过 100 MB)则断线会浪费数分钟上传。多数网络的甜蜜点是 5–10 MB,与 S3 的 5 MB 分片最小值对齐,且与典型 TCP 窗口大小相符。
先探测用户网络:
const downlink = navigator.connection?.downlink ?? 10;
const chunkSize = downlink > 20 ? 10 * 1024 * 1024 : 5 * 1024 * 1024;
100 Mbit 连接下,10 MB 块约 1 秒完成。4G 下,5 MB 块在进入隧道时恢复更好。
切割文件并计算哈希
File.slice() 返回引用同一底层磁盘字节的 Blob,无需复制——切割 20 GB 文件几乎零成本:
function* sliceFile(file, chunkSize) {
for (let offset = 0; offset < file.size; offset += chunkSize) {
yield {
index: Math.floor(offset / chunkSize),
blob: file.slice(offset, offset + chunkSize),
start: offset,
end: Math.min(offset + chunkSize, file.size)
};
}
}
上传前对每个块计算 SHA-256 哈希,以便服务端验证完整性:
const buffer = await chunk.blob.arrayBuffer();
const digest = await crypto.subtle.digest('SHA-256', buffer);
const hash = Array.from(new Uint8Array(digest))
.map(b => b.toString(16).padStart(2, '0')).join('');
10 GB 数据的哈希计算在现代笔记本上约需 20 秒——对于不稳定的移动网络上可能出现的静默数据损坏,这是值得的。
受控并发上传
顺序上传浪费带宽;无限并行会崩溃浏览器。3–4 个并发块在两者之间取得平衡:
async function uploadAll(file, sessionId) {
const queue = [...sliceFile(file, 5 * 1024 * 1024)];
const workers = Array.from({ length: 4 }, async () => {
while (queue.length) {
const chunk = queue.shift();
await uploadChunk(chunk, sessionId);
emitProgress(chunk.index);
}
});
await Promise.all(workers);
}
每个 uploadChunk 调用是携带 Blob 请求体和头部哈希的 PUT /upload/:sessionId/:index。每块使用独立的 AbortController,可取消单个请求而不影响整批。
不炸服务器的重试策略
网络错误需要指数退避,而非紧密重试循环。合理策略:3 次尝试,基础延迟 500 ms,加 50% 抖动:
async function uploadChunk(chunk, sessionId, attempt = 0) {
try {
const res = await fetch(`/upload/${sessionId}/${chunk.index}`, {
method: 'PUT', body: chunk.blob, headers: { 'X-Hash': chunk.hash }
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
if (attempt >= 3) throw e;
const delay = 500 * 2 ** attempt + Math.random() * 250;
await new Promise(r => setTimeout(r, delay));
return uploadChunk(chunk, sessionId, attempt + 1);
}
}
5xx 视为可重试,4xx 视为致命(408 和 429 除外)。收到 429 时,遵守 Retry-After 头部而非本地退避。
标签页重载后的断点续传
每成功上传一个块后,将上传状态持久化到 IndexedDB:
await db.put('uploads', {
sessionId, fileName: file.name, fileSize: file.size,
completedChunks: [...completedSet], updatedAt: Date.now()
}, sessionId);
用户重新打开带相同文件选择器的页面时,将文件的 size、lastModified 和文件名与已存储会话比对。若匹配,向服务器查询哪些块已接收(简单的 GET /upload/:sessionId/status 返回位图即可),然后只上传缺失的块。tus.io 协议通过 Upload-Offset 头部将这一模式正式化,tus-js-client 库提供了完善的实现,无需自行开发。
服务端的块组合
两个可靠选项:S3 分片上传(每块作为 PartNumber,最终 CompleteMultipartUpload 合并)或自定义组合器(将每块写入临时文件,最后拼接)。S3 分片在规模上更经济,因为组合期间不产生出口费,R2 读取零出口费。自定义方案调试更简单,且允许在组合时进行流式加密。对于 S3 方式,注意 10,000 分片限制——超过 50 GB 的文件需要 5 MB 以上的块才能不超出限制。
用户真正信任的进度显示
跳来跳去的进度条让人觉得出了问题。按已上传字节数除以总字节数计算进度,而非完成块数,并用 2 秒移动平均平滑以隐藏抖动。通过 fetch 配合 ReadableStream 和 Transform 计算字节数——XMLHttpRequest.upload.onprogress 在 HTTP/3 下不总是可靠触发。用剩余字节除以近期吞吐量显示预估时间,但至少钳位到 5 秒,避免"还有 2 秒……持续了 10 分钟"的恶名。
HexaTransfer 正是使用这样的分块加可断点续传流水线来处理 10 GB 上传,在每个 PUT 请求前对每块进行客户端 AES-256-GCM 加密。立即体验:https://hexatransfer.com——免费,无需注册,最大支持 10 GB。