做过图片站、网盘、素材库的前端多半都撞过同一堵墙:用户传一张几百 MB 的 RAW 大图,或者一段几个 G 的视频,一个 FormData 直接 POST 上去,要么请求超时,要么中途断网前功尽弃,要么内存直接被撑爆。
单请求整包上传的问题不是"慢",而是"脆"------它把一次网络往返变成了一个不能失败的原子操作。文件越大,失败概率越高,而失败的代价是从头再来。分片上传的核心思路很朴素:把大文件切成很多小块,每块独立上传、独立重试、可以并发、可以断点续跑,最后在服务端拼回去。
下面按"切片 → 秒传 → 断点续传 → 并发 → 进度 → 重试 → 合并"的顺序,把整条链路拆开讲,每一步都给能跑的代码。
一、用 File.slice 把文件切成分片
File 继承自 Blob,Blob.prototype.slice 能在不读取内容的前提下拿到一段切片,几乎零成本------它只是记录了偏移量,真正的字节要等你 read 或上传时才会被读。
js
// 把一个 File/Blob 按固定大小切成分片数组
// chunkSize 常见取值 2MB~5MB,太小请求数爆炸,太大失去分片意义
function createChunks(file, chunkSize = 5 * 1024 * 1024) {
const chunks = [];
let start = 0;
let index = 0;
while (start < file.size) {
const end = Math.min(start + chunkSize, file.size);
chunks.push({
index, // 分片序号,后端按它排序合并
start,
end,
blob: file.slice(start, end), // 惰性切片,不占内存
});
start = end;
index += 1;
}
return chunks;
}
分片大小没有标准答案,要结合平均文件体积和后端网关限制来定。5MB 是个稳妥的起点。
二、算文件 hash 做"秒传"
秒传的原理是:如果服务器上已经存在一份内容完全相同的文件,就没必要再传一遍。判断"内容相同"要靠内容指纹,也就是对整个文件算一个 hash(常用 SHA-256 或 MD5),先拿这个 hash 问后端"你有没有",有就直接秒过。
浏览器算 hash 的库一般用 spark-md5 或 hash-wasm。下面用增量的方式喂分片,避免一次性把整个大文件读进内存:
js
import SparkMD5 from 'spark-md5';
// 增量读取每个分片,边读边算,内存占用只跟单个分片相关
function calcFileHash(chunks) {
return new Promise((resolve, reject) => {
const spark = new SparkMD5.ArrayBuffer();
let current = 0;
const loadNext = () => {
const reader = new FileReader();
reader.onload = (e) => {
spark.append(e.target.result); // 累加当前分片
current += 1;
if (current < chunks.length) {
loadNext();
} else {
resolve(spark.end()); // 最终指纹
}
};
reader.onerror = reject;
reader.readAsArrayBuffer(chunks[current].blob);
};
loadNext();
});
}
拿到 hash 后,先问后端:
js
async function checkInstantUpload(hash, filename) {
const res = await fetch('/api/upload/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hash, filename }),
});
// uploaded: 服务端已有完整文件 -> 秒传成功
// uploadedChunks: 已经传过的分片序号列表 -> 用于断点续传
return res.json(); // { uploaded, uploadedChunks }
}
诚实说一句:对几个 G 的大文件,主线程上全量算 hash 会明显卡顿甚至卡死界面。后面第七节会讲怎么处理。
三、断点续传:只传没传过的分片
秒传的接口顺便把"已传过哪些分片"也返回了。断点续传就是拿这份清单做差集,只上传缺失的分片。这样即使上次传到一半断网,这次也能接着传。
js
async function uploadFile(file) {
const chunks = createChunks(file);
const hash = await calcFileHash(chunks);
const { uploaded, uploadedChunks = [] } = await checkInstantUpload(hash, file.name);
if (uploaded) return { hash, instant: true }; // 秒传直接返回
// 过滤掉服务端已确认收到的分片
const done = new Set(uploadedChunks);
const pending = chunks.filter((c) => !done.has(c.index));
await uploadChunksWithConcurrency(pending, hash, file.name, chunks.length);
await mergeChunks(hash, file.name, chunks.length); // 通知后端合并
return { hash, instant: false };
}
这里"已传过哪些分片"的清单来自服务端,而不是前端 localStorage------服务端才是唯一可信来源,前端记录只能做辅助提示,不能当合并依据。
四、并发控制:别让几百个请求同时冲出去
一个大文件切完可能有几百个分片。如果 Promise.all 一次性全发出去,浏览器会卡在连接数上限,请求排队反而更慢,还可能触发后端限流。合理做法是维持一个固定大小的并发窗口,比如同时最多 4~6 个在传。
js
// 简易并发池:始终保持最多 limit 个任务在跑
async function runWithLimit(tasks, limit = 4) {
const results = [];
const executing = new Set();
for (const task of tasks) {
const p = task().then((r) => {
executing.delete(p);
return r;
});
executing.add(p);
results.push(p);
// 池子满了就等任意一个先完成,再放新的进来
if (executing.size >= limit) {
await Promise.race(executing);
}
}
return Promise.all(results);
}
上传单个分片本身也是个任务,用 FormData 带上 hash 和 index 让后端能对号入座:
js
function uploadOneChunk(chunk, hash, filename, onProgress) {
const form = new FormData();
form.append('hash', hash);
form.append('index', String(chunk.index));
form.append('filename', filename);
form.append('chunk', chunk.blob);
return fetch('/api/upload/chunk', { method: 'POST', body: form })
.then((res) => {
if (!res.ok) throw new Error(`chunk ${chunk.index} failed`);
onProgress?.(chunk.index);
return chunk.index;
});
}
五、进度合并:把每片进度汇成总进度
整体进度不是"传完几片"这么粗,用户想看到平滑的百分比。思路是记录每个分片各自的已传字节,再累加除以总大小。fetch 本身拿不到上传进度,要用 XMLHttpRequest 的 upload.onprogress。
js
function uploadWithProgress(chunk, hash, filename, onChunkProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const form = new FormData();
form.append('hash', hash);
form.append('index', String(chunk.index));
form.append('chunk', chunk.blob);
// 单片上传进度回调,把已传字节报给上层
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onChunkProgress(chunk.index, e.loaded);
};
xhr.onload = () => (xhr.status < 300 ? resolve(chunk.index) : reject(new Error(String(xhr.status))));
xhr.onerror = () => reject(new Error('network error'));
xhr.open('POST', '/api/upload/chunk');
xhr.send(form);
});
}
上层维护一张 loadedMap,把各片字节加起来算总进度:
js
const loadedMap = new Map(); // index -> 已传字节
function makeProgressHandler(totalSize, onTotal) {
return (index, loaded) => {
loadedMap.set(index, loaded);
let sum = 0;
for (const v of loadedMap.values()) sum += v;
onTotal(Math.min(100, Math.round((sum / totalSize) * 100)));
};
}
六、失败重试:分片级别的自动重试
分片上传最大的好处就是失败面被缩小了------一片失败只重传一片。给单片包一层带上限的重试,配合指数退避,能扛住大部分瞬时抖动。
js
// 失败自动重试,最多 retries 次,每次等待翻倍
async function withRetry(fn, retries = 3, delay = 1000) {
for (let i = 0; i <= retries; i++) {
try {
return await fn();
} catch (err) {
if (i === retries) throw err; // 重试用尽,抛给上层
await new Promise((r) => setTimeout(r, delay * 2 ** i));
}
}
}
把它套在上传任务外层,并发池里每个任务就都自带了重试能力。
七、诚实的工程边界
分片续传这套东西看着漂亮,落地时有几个坑必须提前知道:
- 算 hash 会卡主线程。 对上 GB 的文件,全量 MD5/SHA 计算是 CPU 密集操作,放主线程会冻结 UI。正解是搬到 Web Worker 里算,或者用抽样 hash(只取头、尾和几个固定位置的字节算指纹)换速度,代价是极小概率误判需要后端二次校验。
- 别把整个文件读进内存。 一定要用
slice+ 增量读,逐片处理。一次性readAsArrayBuffer整个大文件,手机浏览器直接崩。 - 服务端存储成本别忽略。 分片先落临时目录,合并后要清理;断点续传的半成品分片得有过期回收机制,否则磁盘会被没传完的垃圾撑满。
- 合并的原子性。 后端按 index 顺序拼接时,要校验分片数量和总大小,合并成功再删临时片,避免拼出损坏文件。
后端合并的逻辑(伪代码示意)就是按序号排好、逐片追加、校验完整性:
js
// Node 端合并示意:按 index 升序把分片流式追加到目标文件
const fs = require('fs');
const path = require('path');
async function mergeOnServer(hash, filename, total, chunkDir) {
const target = fs.createWriteStream(path.resolve('/uploads', filename));
for (let i = 0; i < total; i++) {
const chunkPath = path.join(chunkDir, `${hash}-${i}`);
if (!fs.existsSync(chunkPath)) throw new Error(`missing chunk ${i}`); // 完整性校验
await new Promise((resolve, reject) => {
const rs = fs.createReadStream(chunkPath);
rs.on('end', resolve);
rs.on('error', reject);
rs.pipe(target, { end: false }); // 流式追加,内存友好
});
fs.unlinkSync(chunkPath); // 追加完就清理临时片
}
target.end();
}
八、上传前先给图片瘦身
还有个常被忽略的优化:很多"大图片"根本不需要原尺寸上传。一张手机拍的照片压到合理质量往往能砍掉一大半体积,分片数、上传时长、服务端存储全都跟着降。上传前顺手压一道,性价比极高。
在线压图/处理工具可以先收藏几个备用,需求不同各有取舍:squoosh.app 适合逐张手动调质量和格式,tinypng.com 对 PNG/JPG 批量压缩很省心,tudingai.cn 偏在线修图和格式处理,cloudconvert.com 走批量格式转换。前端接入自动压缩也可以用 canvas.toBlob 或 browser-image-compression,把压缩后的 Blob 再喂进上面的分片流程即可。
小结
分片上传不是一个单点技术,而是一套组合拳:slice 负责切,hash 负责秒传和去重,服务端清单负责断点续传,并发池负责吞吐,XHR 进度负责体验,重试负责稳定,后端合并负责收口。每一环单独看都不复杂,难的是把边界情况(卡顿、内存、脏分片、合并原子性)都照顾到。把这套骨架搭起来,再大的文件也只是"多切几刀"而已。