2508 字
约 8 分钟
13
S14-X10 · 文件上传与分片续传:前端视角的实现要点

上传这块的坑不在"能不能传上去",而在四种边界:断了能不能接上、传一半取消干不干净、进度条准不准、内存炸不炸。四个都要处理,才算实现完。

下面按一条真实链路走:判定 → 哈希 → 切片 → 并发 → 合并 → 恢复,最后落在排错与边界。


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. 自测

  1. 为什么预检查必须放在哈希计算之后?
  2. 全量哈希的内存峰值由什么决定?crypto.subtle.digest 有没有流式接口?
  3. 抽样哈希在什么情况下会误判秒传?兜底怎么做?
  4. 进度为什么必须按字节累加,按分片数会错在哪?
  5. 刷新页面后要恢复上传,唯一可信的状态源是什么?
  6. 用户取消上传后,除了 abort() 还必须做什么?
S14-X10 · 文件上传与分片续传:前端视角的实现要点
https://www.clxhxhhr.top/posts/5816/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。