原文摘要
你给一家在线教育平台做「课程视频批量上传」功能。
需求听起来很朴素:讲师后台一次性拖 20 个 4K 视频,浏览器要稳、要快、要能断网续传。
你第一版直接 <input type="file"> + FormData,结果上线当天就炸:
- 讲师 A 上传 4.7 GB 的
.mov,Chrome 直接 内存溢出 崩溃; - 讲师 B 网断了 3 分钟,重新上传发现进度条归零,心态跟着归零;
- 运营同学疯狂 @ 前端:“你们是不是没做分片?”
解决方案:三层防线,把 4 GB 切成 2 MB 的“薯片”
1. 表面用法:分片 + 并发,浏览器再也不卡
// upload.js
const CHUNK_SIZE = 2 * 1024 * 1024; // 🔍 2 MB 一片,内存友好
export async function* sliceFile(file) {
let cur = 0;
while (cur < file.size) {
yield file.slice(cur, cur + CHUNK_SIZE);
cur += CHUNK_SIZE;
}
}
// uploader.js
import pLimit from 'p-limit';
const limit = pLimit(5); // 🔍 最多 5 并发,防止占满带宽
export async function upload(file) {
const hash = await calcHash(file); // 🔍 秒传、断点续传都靠它
const tasks = [];
for await (const chunk of sliceFile(file)) {
tasks.push(limit(() => uploadChunk({ hash, chunk })));
}
await Promise.all(tasks);
await mergeChunks(hash, file.name); // 🔍 通知后端合并
}
逐行拆解:
sliceFile用file.slice生成 Blob 片段,不占额外内存;p-limit控制并发,避免 100 个请求同时打爆浏览器;calcHash用 WebWorker 算 MD5,页面不卡顿(后面细讲)。
2. 底层机制:断点续传到底续在哪?
| 角色 | 存储位置 | 内容 | 生命周期 |
|---|---|---|---|
| 前端 | IndexedDB | hash → 已上传分片索引数组 | 浏览器本地,清缓存即失效 |
| 后端 | Redis / MySQL | hash → 已接收分片索引数组 | 可配置 TTL,支持跨端续传 |
sequenceDiagram
participant F as 前端
participant B as 后端
F->>B: POST /prepare {hash, totalChunks}
B-->>F: 200 OK {uploaded:[0,3,7]}
loop 上传剩余分片
F->>B: POST /upload {hash, index, chunkData}
B-->>F: 200 OK
end
F->>B: POST /merge {hash}
B-->>F: 200 OK
Note over B: 按顺序写磁盘
- 前端先
POST /prepare带 hash + 总分片数; - 后端返回已上传索引
[0, 3, 7]; - 前端跳过这 3 片,只传剩余;
- 全部完成后
POST /merge,后端按顺序写磁盘。
3. 设计哲学:把“上传”做成可插拔的协议
interface Uploader {
prepare(file: File): Promise<PrepareResp>;
upload(chunk: Blob, index: number): Promise<void>;
merge(): Promise<string>; // 🔍 返回文件 URL
}
我们实现了三套:
BrowserUploader:纯前端分片;TusUploader:遵循 tus.io 协议,天然断点续传;AliOssUploader:直传 OSS,用 OSS 的断点 SDK。
| 方案 | 并发控制 | 断点续传 | 秒传 | 代码量 |
|---|---|---|---|---|
| 自研 | 手动 | 自己实现 | 手动 | 300 行 |
| tus | 内置 | 协议级 | 需后端 | 100 行 |
| OSS | 内置 | SDK 级 | 自动 | 50 行 |
应用扩展:拿来即用的配置片段
1. WebWorker 算 Hash(防卡顿)
// hash.worker.js
importScripts('spark-md5.min.js');
self.onmessage = ({ data: file }) => {
const spark = new SparkMD5.ArrayBuffer();
const reader = new FileReaderSync();
for (let i = 0; i < file.size; i += CHUNK_SIZE) {
spark.append(reader.readAsArrayBuffer(file.slice(i, i + CHUNK_SIZE)));
}
self.postMessage(spark.end());
};
2. 环境适配
| 环境 | 适配点 |
|---|---|
| 浏览器 | 需兼容 Safari 14 以下无 File.prototype.slice(用 webkitSlice 兜底) |
| Node | 用 fs.createReadStream 分片,Hash 用 crypto.createHash('md5') |
| Electron | 渲染进程直接走浏览器方案,主进程可复用 Node 逻辑 |
举一反三:3 个变体场景
- 秒传
上传前先算 hash → 调后端/exists?hash=xxx→ 已存在直接返回 URL,0 流量完成。 - 加密上传
在uploadChunk里加一层AES-GCM加密,后端存加密块,下载时由前端解密。 - P2P 协同上传
用 WebRTC 把同局域网学员的浏览器变成 CDN,分片互传后再统一上报,节省 70% 出口带宽。
小结
大文件上传的核心不是“传”,而是“断”。
把 4 GB 切成 2 MB 的薯片,再配上一张能续命的“进度表”,浏览器就能稳稳地吃下任何体积的视频。
进一步信息揣测
- 浏览器内存限制陷阱:直接处理大文件(如4.7GB的.mov)会导致Chrome内存溢出崩溃,需强制分片(如2MB/片)避免内存占用,即使需求文档未明确提及。
- 断点续传的隐藏成本:单纯前端记录进度无效(如网断后进度归零),需结合IndexedDB(前端)和Redis/MySQL(后端)双端存储分片索引,且后端需设计TTL机制支持跨端续传。
- 并发控制的潜规则:无限制并发上传会占满用户带宽(尤其在家用网络场景),需用类似
p-limit工具限制并发数(如5个),但具体数值需根据用户平均带宽实测调整。 - 秒传的哈希计算技巧:文件哈希(如MD5)是秒传/续传的关键,但主线程计算会阻塞页面,必须用WebWorker后台计算,且大文件哈希可能耗时较长需优化算法(如抽样哈希)。
- 分片上传的协议设计:后端需提供
/prepare接口预校验分片状态(返回已上传分片索引),避免重复传输,此逻辑通常不会在公开API文档中详细说明。 - 浏览器API的坑:
file.slice生成的Blob不占内存,但直接操作大文件仍可能触发垃圾回收问题,需实测不同浏览器的内存管理差异。 - 运营侧的监控需求:实际部署后需监控分片失败率(如网断、哈希冲突),但此类需求初期常被忽略,直到用户投诉(如运营同学质问“没做分片?”)才会暴露。
- 合并请求的时序问题:所有分片上传完成后,前端需主动触发
/merge接口,但合并大文件可能耗时较长,需设计异步通知机制(如WebSocket),而非简单轮询。