前端大文件上传全方案:切片、秒传、断点续传与 Worker 并行 Hash 计算实践
上传一个 10MB 的图片,没人会多想。但当产品经理说"我们要支持上传 2GB 的视频"时,你会发现:一个普通的 <input type="file"> 加 FormData,能把浏览器干崩、把用户等到关掉页面、把后端网关超时搞到怀疑人生。
这不是一个"调 API"的问题,这是一个系统设计问题。
大文件上传到底难在哪?
先把问题摊开:
| 痛点 | 根因 |
|---|---|
| 上传超时 | 单次请求体积太大,网关/Nginx 有 body size 和超时限制 |
| 失败要重来 | 没有断点机制,传了 90% 断网 = 白传 |
| 重复上传浪费带宽 | 同一个文件换个名字又传一遍 |
| 上传期间页面卡死 | 主线程做大文件 Hash 计算,UI 直接冻住 |
| 内存爆炸 | 一次性读取整个大文件到内存 |
一句话总结:大文件上传的本质问题是------如何把一个"大而脆弱"的操作,拆成"小而可靠"的操作。
这跟分布式系统的思路一模一样:拆分、校验、重试、幂等。
整体架构一览
先看全局,再看细节:
swift
用户选择文件
│
▼
Worker 并行计算文件 Hash(不阻塞 UI)
│
▼
拿 Hash 问服务端:"这文件传过没?"
│
├── 已存在 → 秒传完成 ✅
│
├── 部分存在 → 返回已有分片列表 → 断点续传(只传缺失的)
│
└── 不存在 → 全量分片上传
│
▼
并发上传分片(控制并发数)
│
▼
全部完成 → 通知服务端合并
下面逐个拆解。
第一步:文件切片
切片的原理没什么魔法,File 对象继承自 Blob,而 Blob 天生支持 slice。
ts
function createChunks(file: File, chunkSize = 5 * 1024 * 1024) {
const chunks: Blob[] = []
let cur = 0
while (cur < file.size) {
// Blob.slice 不会把数据读进内存,只是创建一个"引用切片"
chunks.push(file.slice(cur, cur + chunkSize))
cur += chunkSize
}
return chunks // 一个 2GB 文件 → 400 个 5MB 的 Blob 引用
}
关键点:slice 是零拷贝的。它不会真的把文件内容读到内存里,只是标记了起止偏移量。所以哪怕切 1000 片,内存开销也几乎为零。
切片大小怎么定?
这是个工程权衡题:
| 切片大小 | 优点 | 缺点 |
|---|---|---|
| 1MB | 失败重传成本低 | 请求数太多,HTTP 开销大 |
| 5MB | 均衡选择 | 大多数场景够用 |
| 10MB+ | 请求数少 | 弱网下单片失败概率高 |
实际项目中,5MB 是最常见的默认值。如果你的用户群体网络质量差(比如东南亚市场),可以降到 2MB。
第二步:文件 Hash 计算------把主线程解放出来
为什么需要 Hash?两个目的:
- 秒传判断:同一个文件无论改什么文件名,Hash 都一样
- 分片校验:确保传上去的每一片内容没有损坏
但问题来了:对一个 2GB 文件做 Hash 计算,主线程直接卡死,用户以为页面崩了。
方案:Web Worker + 分片增量计算
思路类似流式处理------不是一口气读完整个文件,而是一片一片喂给 Hash 算法。
ts
// hash-worker.ts ------ 运行在 Worker 线程
import SparkMD5 from 'spark-md5'
self.onmessage = async (e: MessageEvent) => {
const { chunks } = e.data
const spark = new SparkMD5.ArrayBuffer()
let completed = 0
for (const chunk of chunks) {
// FileReader 读取每一片的 ArrayBuffer
const buffer = await readAsArrayBuffer(chunk)
spark.append(buffer) // 增量喂给 MD5 算法
completed++
// 向主线程报告进度(用户能看到 Hash 计算的百分比)
self.postMessage({ type: 'progress', percent: (completed / chunks.length) * 100 })
}
// 所有分片都喂完了,输出最终 Hash
self.postMessage({ type: 'done', hash: spark.end() })
}
function readAsArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
return new Promise((resolve) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as ArrayBuffer)
reader.readAsArrayBuffer(blob)
})
}
主线程这边:
ts
function calculateHash(file: File): Promise<string> {
return new Promise((resolve) => {
const chunks = createChunks(file)
const worker = new Worker(new URL('./hash-worker.ts', import.meta.url))
worker.postMessage({ chunks })
worker.onmessage = (e) => {
if (e.data.type === 'progress') {
// 更新进度条,用户知道"正在计算中"而不是"页面卡了"
updateProgress(e.data.percent)
}
if (e.data.type === 'done') {
resolve(e.data.hash)
worker.terminate() // 用完就关,别占资源
}
}
})
}
进阶:多 Worker 并行计算
单个 Worker 是串行读片的。如果机器是 8 核 CPU,只用 1 个 Worker 是浪费。
ts
async function parallelHash(file: File, workerCount = 4): Promise<string> {
const chunks = createChunks(file)
// 把分片均匀分配给多个 Worker,类似 Map-Reduce 的 Map 阶段
const groups = Array.from({ length: workerCount }, () => [] as Blob[])
chunks.forEach((chunk, i) => groups[i % workerCount].push(chunk))
// 每个 Worker 独立计算自己那一组的 Hash
const partialHashes = await Promise.all(
groups.map(group => runWorker(group))
)
// 最后把所有子 Hash 合并成最终 Hash(类似 Reduce 阶段)
const spark = new SparkMD5()
partialHashes.forEach(h => spark.append(h))
return spark.end()
}
实测下来,4 个 Worker 并行计算 2GB 文件,速度比单 Worker 快 2~3 倍。但别开太多------Worker 线程也要占内存,开 8 个以上收益递减,还可能让低端设备更卡。
第三步:秒传------最快的上传就是不上传
原理极其简单:
ts
async function tryInstantUpload(hash: string, fileName: string) {
const res = await fetch('/api/upload/check', {
method: 'POST',
body: JSON.stringify({ hash, fileName }),
})
const data = await res.json()
if (data.exists) {
// 服务端已经有这个文件了,直接返回文件地址
// 用户:哇,2GB 文件 0.3 秒传完了???
return { success: true, url: data.url }
}
// 返回已上传的分片索引列表,为断点续传做准备
return { success: false, uploadedChunks: data.uploadedChunks || [] }
}
服务端用文件 Hash 作为唯一标识。张三传过一次,李四再传同一个文件,直接复用------本质上是内容寻址,和 Git 的对象存储思路完全一样。
⚠️ 注意:秒传的前提是你用的 Hash 算法碰撞率足够低。MD5 在安全领域已经不推荐了,但在文件去重场景下够用。如果你实在不放心,可以用 SHA-256,就是计算慢一些。
第四步:断点续传
用户传到 80% 断网了,重新打开页面,难道要从头再来?
不需要。服务端已经存了哪些分片成功落盘了,客户端只需要补传缺失的部分。
ts
async function uploadWithResume(file: File) {
const chunks = createChunks(file)
const hash = await calculateHash(file)
// 问服务端:这个文件的哪些分片已经传过了?
const { success, url, uploadedChunks } = await tryInstantUpload(hash, file.name)
if (success) return url // 秒传命中,收工
// 过滤掉已上传的分片,只传剩下的
const pendingChunks = chunks
.map((chunk, index) => ({ chunk, index }))
.filter(({ index }) => !uploadedChunks.includes(index))
// 并发上传(控制并发数,别把浏览器和服务端打满)
await concurrentUpload(pendingChunks, hash)
// 所有分片传完,通知服务端合并
await fetch('/api/upload/merge', {
method: 'POST',
body: JSON.stringify({ hash, fileName: file.name, totalChunks: chunks.length }),
})
}
断点续传能工作的前提是:同一个文件每次计算出的 Hash 一致,分片方式一致。所以切片大小要固定(或者存储在某处),否则恢复时分片对不上,写到这里我开始怀疑人生。
第五步:并发控制
一口气把 400 个分片同时发出去?浏览器同域名最多 6 个并发连接,剩下 394 个在排队。而且并发太高,服务端也扛不住。
ts
async function concurrentUpload(
tasks: { chunk: Blob; index: number }[],
hash: string,
maxConcurrency = 4
) {
const pool: Promise<void>[] = []
const errors: number[] = []
for (const task of tasks) {
const p = uploadChunk(task, hash)
.catch(() => {
errors.push(task.index) // 失败的记下来,后面重试
})
.finally(() => {
pool.splice(pool.indexOf(p), 1) // 完成一个,让出一个坑位
})
pool.push(p)
// 池子满了就等,等一个完成再放下一个进去
if (pool.length >= maxConcurrency) {
await Promise.race(pool)
}
}
await Promise.all(pool) // 等最后一批跑完
// 失败的重试一轮(生产环境建议加指数退避)
if (errors.length) {
const retryTasks = tasks.filter(t => errors.includes(t.index))
await concurrentUpload(retryTasks, hash, 2) // 重试时降低并发
}
}
function uploadChunk(task: { chunk: Blob; index: number }, hash: string) {
const form = new FormData()
form.append('chunk', task.chunk)
form.append('hash', hash)
form.append('index', String(task.index))
return fetch('/api/upload/chunk', { method: 'POST', body: form })
.then(res => { if (!res.ok) throw new Error(`Chunk ${task.index} failed`) })
}
并发数设多少合适?经验值:
- 强网环境(内网/Wi-Fi):4~6 并发
- 弱网/移动端:2~3 并发
- 可以根据上传速度动态调整(测几个分片的平均耗时,快就加,慢就减)
设计权衡:为什么不用其他方案?
为什么不用 tus 协议?
tus 是一个开源的断点续传协议,有现成的客户端和服务端库。如果你不需要秒传和自定义分片策略,tus 是非常好的选择。但它的 Hash 策略和分片逻辑不够灵活,大厂通常会自建。
为什么不用 WebSocket 传?
WebSocket 适合双向通信,但不适合大数据量传输。HTTP 有天然的分段、重试、CDN 缓存能力,WS 没有。用 WS 传大文件是用螺丝刀锤钉子。
为什么不在服务端算 Hash?
可以,但有两个问题:
- 文件要先完整传到服务端才能算------秒传就没意义了
- 服务端 CPU 资源更贵,客户端有大量闲置算力(Worker)
边界与踩坑
1. Safari 的 Worker 限制
Safari 对 Worker 中使用 import 的支持一直磕磕绊绊。如果用 Vite 打包,Worker 中的第三方库导入可能报错。解决方案:把 spark-md5 的代码内联到 Worker 文件中,或者用 ?worker&inline 模式。
2. 分片顺序不等于合并顺序
并发上传意味着分片到达服务端的顺序是乱的。服务端合并时必须按 index 排序,否则文件就废了。这不是 bug,这是特性------并发的代价。
3. Hash 碰撞的理论风险
MD5 的碰撞概率约为 2^(-128),对于文件去重来说足够安全。但如果你在做金融、医疗等对数据完整性要求极高的系统,建议用 SHA-256,或者 Hash + 文件大小双重校验。
4. 内存泄漏
Worker 用完一定要 terminate()。曾经遇到一个线上问题:用户连续上传 10 个大文件,10 个 Worker 全开着没关,Chrome 占了 4GB 内存。用户的风扇开始起飞。
5. 移动端的坑
移动浏览器对 Worker 数量有更严格的限制,有些低端安卓机开 2 个 Worker 就开始吃力。建议做能力检测:
ts
// navigator.hardwareConcurrency 返回 CPU 逻辑核心数
const workerCount = Math.min(navigator.hardwareConcurrency || 2, 4)
总结:大文件上传的通用模型
退一步看,大文件上传的核心策略可以抽象成一个通用模型:
- 分治:大任务拆成小任务(切片)
- 幂等:每个小任务可以安全重试(分片 + Hash 索引)
- 去重:基于内容寻址跳过重复工作(秒传)
- 并行:利用多核/多连接提高吞吐(Worker + 并发池)
- 渐进:保存中间状态,失败后从断点恢复(断点续传)
这五个原则不止适用于文件上传。消息队列、分布式计算、大数据 ETL,甚至你在做任何"大规模、不可靠环境下的数据传输"时,都是这套思路。
下次再遇到类似的问题,别急着写代码。先问自己:能不能拆?能不能跳过?能不能断点恢复?能不能并行?
把这四个问题回答清楚,方案就出来了。