上传这块的坑不在"能不能传上去",而在四种边界:断了能不能接上、传一半取消干不干净、进度条准不准、内存炸不炸。四个都要处理,才算实现完。
下面按一条真实链路走:判定 → 哈希 → 切片 → 并发 → 合并 → 恢复,最后落在排错与边界。
1. 判定:直传还是分片
const SMALL = 5 * 1024 * 1024; // 5 MB 以下直传
const CHUNK = 5 * 1024 * 1024; // 分片大小
const MAX_CONCURRENCY = 3;
export function planUpload(file: File) {
const chunked = file.size > SMALL;
return {
chunked,
chunkSize: chunked ? CHUNK : file.size,
total: chunked ? Math.ceil(file.size / CHUNK) : 1,
};
}
| 场景 | 建议方式 | 理由 |
|---|---|---|
| 小于 5 MB | 单次 FormData |
少一次合并与校验 |
| 5–100 MB | 5 MB 分片 | 失败只重传一片 |
| 100 MB–2 GB | 5–10 MB 分片 + 并发 3 | 兼顾吞吐与内存 |
| 弱网/移动端 | 2–4 MB 分片 | 单片失败成本低 |
| 断点续传必须支持 | 无论大小都分片 | 单片才能续 |
分片大小不是越小越好:片太小会让请求数随体积线性爆炸,握手与鉴权开销吃掉收益。5 MB 是个常见起点,单请求超时明显时再往下调。
2. 流程:五步,顺序不能换
| 步 | 做什么 | 失败后怎么办 |
|---|---|---|
| 1 计算哈希 | 得到文件指纹 | 可降级为抽样哈希 |
| 2 预检查 | 问服务端"这个哈希存在吗" | 网络错就重试一次 |
| 3 切片上传 | 并发传 N 片 | 单片重试,不整包重来 |
| 4 合并 | 通知服务端按序合并 | 幂等接口,可重复调 |
| 5 校验 | 对合并后的大小/哈希 | 不一致就重传坏片 |
预检查放在哈希之后,顺序反了就没法秒传。合并与校验必须由服务端做——前端只能提供期望值。
async function upload(file: File) {
const hash = await hashFile(file);
const pre = await api.precheck({ hash, size: file.size, name: file.name });
if (pre.exists) return pre.url; // 秒传
const done = new Set<number>(pre.uploaded); // 续传缺口
await runPool(buildTasks(file, done), MAX_CONCURRENCY);
return api.merge({ hash, total: chunkCount(file), name: file.name });
}
3. 哈希:最容易被低估的一段
把整个文件 arrayBuffer() 读进内存算哈希,1 GB 文件就是 1 GB 堆内存加一次 GC 风暴。而且 crypto.subtle.digest 没有流式接口——它必须拿到完整 buffer。
| 方案 | 内存 | 计算量 | 碰撞风险 | 适用 |
|---|---|---|---|---|
subtle.digest 全量 |
等于文件大小 | 全文件 | 无 | 小文件 |
| 分片增量哈希(自实现或 wasm) | 常量 | 全文件 | 无 | 大文件首选 |
| 抽样哈希(首/中/尾各 1 MB + size) | 极小 | 常量 | 有,可能误判秒传 | 超大文件、体验优先 |
放 Worker 里算,主线程才不卡:
// hash.worker.ts
self.onmessage = async ({ data }: MessageEvent<{ chunks: Blob[] }>) => {
const bufs = await Promise.all(data.chunks.map((c) => c.arrayBuffer()));
const merged = new Blob(bufs);
const buf = await merged.arrayBuffer();
const digest = await crypto.subtle.digest('SHA-256', buf);
const hex = [...new Uint8Array(digest)]
.map((b) => b.toString(16).padStart(2, '0')).join('');
self.postMessage(hex);
};
抽样哈希的取舍写清楚:拿 size + mtime + 首尾各 1 MB 做指纹,统计上够用但会误判——同一模板导出的两个不同文件长度相同时可能撞上。要么接受"秒传后服务端再校验大小",要么老实用全量哈希。
4. 并发池:N 个并发 + 单片重试
async function runPool<T>(tasks: (() => Promise<T>)[], limit: number) {
const queue = [...tasks];
const workers = Array.from({ length: Math.min(limit, queue.length) }, async () => {
while (queue.length) {
const task = queue.shift()!;
for (let attempt = 0; attempt < 3; attempt++) {
try { await task(); break; }
catch (e) {
if (attempt === 2) throw e;
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt)); // 指数退避
}
}
}
});
await Promise.all(workers);
}
| 参数 | 常见取值 | 影响 |
|---|---|---|
| 并发数 | 3(浏览器同域 6 连接) | 太高会挤占同域其他请求 |
| 单片重试 | 3 次,指数退避 | 偶发 5xx 与超时都能覆盖 |
| 单片超时 | 30–60 s | 太短会误杀大分片 |
| 整体超时 | 无(靠单片超时兜底) | 长传中断点续传接 |
并发数设在 3 而不是 6,是给页面其他请求留余量。全占满同域连接池,用户切页都会卡。
5. 断点续传:状态在服务端
前端刷新页面后没有任何本地状态可依赖,已传分片列表必须由服务端给。
// 预检查响应
type PrecheckResp = {
exists: boolean;
uploaded: number[]; // 已完整落盘的分片索引
chunkSize: number; // 服务端确认的片大小,必须一致
};
// 恢复:只看 uploaded,别信 localStorage
const remaining = range(total).filter((i) => !pre.uploaded.includes(i));
| 要持久化的 | 存哪 | 为什么 |
|---|---|---|
| 文件标识(hash) | 服务端 | 刷新后仍能索引到同一任务 |
| 已传分片列表 | 服务端 | 唯一可信源 |
| 文件名/大小 | 服务端 | 合并时要用 |
| 上传会话 ID | sessionStorage |
便于前端关联,丢了也能重建 |
chunkSize 必须由服务端回传并比对:前端换过版本改了片大小,索引就对不上,合并出来是坏文件。
6. 进度与取消
进度按字节累加,不能按片数——片大小一致时看似等价,最后一片往往更小,按片数会提前跳到 100% 再卡住。
let loaded = 0;
const total = file.size;
function onChunkDone(size: number) {
loaded += size; // 按字节
setPercent(Math.min(99, (loaded / total) * 100)); // 合并校验前不给 100
}
取消用 AbortController,并且要把已传分片告诉服务端清掉,否则留下垃圾分片占空间:
const ac = new AbortController();
async function cancel(hash: string) {
ac.abort();
await api.abortSession({ hash }); // 服务端清理临时分片目录
}
| 状态 | 判定依据 | 前端动作 |
|---|---|---|
| 用户取消 | err.name === 'AbortError' |
调清理接口,别重试 |
| 网络中断 | TypeError: Failed to fetch |
重试,超限后转续传 |
| 服务端 5xx | 状态码 | 退避重试 |
| 单片 4xx(如 413) | 状态码 | 不重试,调小片大小或提示 |
进度条抖动来自"既显示已传字节又显示剩余时间估算"。要么只显示百分比,要么对速率做滑动平均,别把瞬时值直接画出来。
7. 内存边界与秒传的判据
File.slice(start, end) 返回的是懒引用,不复制数据,切片本身几乎零成本。真正的内存风险在三处:
| 风险点 | 触发条件 | 处理 |
|---|---|---|
| 整文件读进内存 | 全量哈希、FileReader.readAsArrayBuffer |
分片读、Worker 里算 |
同时持有全部 Blob |
预先生成所有分片的数组 | 用时再 slice |
xhr 上传进度事件堆积 |
高并发 + 大文件 | 节流回调 |
秒传判据只有一条:服务端按 hash 查到完整文件。前端任何"看起来一样"都不算。误判风险全部来自抽样哈希,所以抽样必须配一条兜底:命中秒传后服务端返回文件大小/摘要,前端比对不一致就退回正常上传。
8. 排错:六种"传不上去"
| 现象 | 原因 | 处理 |
|---|---|---|
| 请求数暴涨、页面卡死 | 分片过小(如 256 KB) | 调到 2–5 MB |
| 合并后文件损坏 | 分片乱序或索引错位 | 合并按索引排序,服务端校验总大小 |
| 服务端偶发丢片 | 并发写入竞争、未按序落盘 | 每片独立文件名 + 最后按索引拼 |
| 进度条回跳 | 混用"已传片数/总片数"与字节比例 | 统一用字节 |
| 刷新后续传从头开始 | 只存了 localStorage 状态 | 预检查读服务端 uploaded |
| 取消后空间不释放 | 没调清理接口 | abort 后调 abortSession |
另一类隐性问题是鉴权过期:长传过程中 token 到期,后续分片全部 401。做法是上传请求走一次"刷新票据 + 重放"的拦截器,别让单片失败直接进入重试三次的退避。
9. 边界:什么时候不该自己做
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 需要断点、秒传、多端同步 | 对象存储直传 + 预签名 URL | 省掉合并与清理逻辑 |
| 超大文件(数十 GB) | 分片直传对象存储(multipart) | 服务端不落中转盘 |
| 内网、文件小于 10 MB | 单请求直传 | 分片纯属增加复杂度 |
| 需要上传后立即转码 | 交给异步任务队列 | 前端只负责传到位置 |
一句话:分片是把网络风险切小,不是把复杂度转移给前端。能交给对象存储 multipart 的,就别自己在业务服务上实现合并。
10. 自测
- 为什么预检查必须放在哈希计算之后?
- 全量哈希的内存峰值由什么决定?
crypto.subtle.digest有没有流式接口? - 抽样哈希在什么情况下会误判秒传?兜底怎么做?
- 进度为什么必须按字节累加,按分片数会错在哪?
- 刷新页面后要恢复上传,唯一可信的状态源是什么?
- 用户取消上传后,除了
abort()还必须做什么?