先说一句实在话:如果只是把 WebUploader 的 chunked: true 打开,然后就拿去买几 GB 的卫星视频上传,用户一定会来骂人。我去年在一个完全物理隔离的内网环境里做卫星视频归档系统,业务方要求把 2GB 左右的视频素材稳定传到服务端,断网不能重来,换浏览器不能换出问题。一开始接的是老项目组传下来的 WebUploader 方案,原生分片能跑,但稳定性根本撑不住长传。最后我们只能动手改造 WebUploader,保留它的文件队列和界面,把传输内核换成一套能跨浏览器、能分段续传的调度器。
这篇文章主要面向两类人:一类是接手了老 WebUploader 项目、但需要在视频/大文件场景下做断点续传的前端;另一类是知道 WebUploader 已经老化,但不想彻底重写一个上传组件、想用 JS 低成本改造的团队。文章里不会去装高大上的架构,只会讲我在这个项目的真实决策过程:为什么原生分片不行、分片参数怎么定、代码怎么改、兼容老浏览器踩了哪些坑,以及最后完整跑通一个 2GB 视频上传的实测链路。
1. 为什么直接用 WebUploader 原生 chunked 模式传不动这种视频
1.1 先看清楚 WebUploader 原生能力的天花板
WebUploader 是百度 FEX 团队开源的老牌上传组件,它最成功的点是 UI 组件相对完整,文件选择、拖拽、队列、上传进度、图片压缩一应俱全。在 2015 年前后,用它的 HTML5 Runtime 跑普通文件上传是很舒服的。但它的“分片上传”在原生实现里只做了很基础的一件事:把文件用 Blob.slice() 切成若干块,然后用内部线程池逐个 POST 到服务端。这个机制对几十 MB 的 Office 文档没问题,对 2GB 视频就很勉强了。
原生的 chunked 模式有几个硬伤:
- 分片状态全在内存里。页面不刷新、进程不退出,它内部能记录哪些块传完了;但只要 F5 一刷新,整个上传任务的所有状态清零。视频传了两小时,断在 73%,刷新之后从第 0 片重新开始。
- 没有服务端注册与索引同步机制。你无法在重启页面后问服务端“我哪几个块已经传上去了”,也就没有真正意义上的续传。
- 失败重试很天真。原生组件的策略是某个分片失败后立即重试几次,不行就整个任务失败。可卫星视频这种超大文件,稍微断网几秒或者内网网关抽风,就会让用户陷入“重试失败——重新上传”的循环。
- 合并触发缺失。原生 chunked 只是把数据片发到服务端,它并不关心服务端是否需要做一个“分片合并”的收尾行为。你仍然要在后端自己写合并逻辑,却拿不到前端一个可靠的“所有分片都完成”信号。
换个更直白的说法:原生 chunked 是一个“能在切块传输的上传器”,不是一个“能恢复任务的上传协议”。做视频级超大附件时,我们需要的是后者。
1.2 什么场景必须放弃原生模式
如果一个系统满足下面任一条件,我建议趁早改造:
- 单文件长期在 500MB 以上,甚至几个 GB。
- 用户网络不稳定,有断网、休眠唤醒、代理切换等场景。
- 浏览器环境多样,存在老版本 Chrome、IE 兼容模式、国产双核浏览器。
- 业务要求上传过程可审计、可中断恢复、merge 完成后有完整性校验。
我在着手改之前也犹豫过,要不要直接抛掉 WebUploader,用原生 XMLHttpRequest 重写一个上传组件。后来想明白了一件事:WebUploader 的文件选择、类型过滤、重复文件提示、进度展示等 UI 能力仍然好用,真正缺的是一个可靠传输内核。 所以最终方案不是推倒重来,而是把它的上传动作接管过来,我们自己来做分片调度。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 改造第一步:确定分片大小、文件指纹和传输协议
2.1 分片大小不能拍脑袋,我用这三个指标倒推
分片大小是很多团队一开始就搞错的地方。有人喜欢抄网上的 1MB、2MB,有人觉得性能好就开 32MB,结果要么请求数爆炸,要么上传失败一次就血亏。
我设计分片大小时主要看三个约束:
第一,单个分片请求必须在底层网关的超时窗口内完成。 我们内网环境里虽然走交换机,但整套安全链路里有一些网络审计设备,单连接吞吐并不高,单流跑不到千兆满速。如果分片太大,比如 32MB,在吞吐只有 5MB/s 的链路上要 6 秒多,碰上审计设备处理慢就可能超时。
第二,切分总片数不能多到服务端无法承受。 用 1MB 分片传一个 2GB 视频是 2048 个请求。2048 次 HTTP 往返、2048 次服务端写临时文件,即使每个请求快,整个任务的处理开销也非常惊人。我见过用 512KB 分片跑一个 1.5GB 文件,传到一半服务端磁盘文件句柄数暴涨。
第三,单分片内存加载不能造成浏览器卡死。 WebUploader 默认的 FormData 方式会把当前分片塞进内存,虽然 Blob 切片不是全文件进内存,但 FormData 构造时仍可能产生较大的缓冲。分片越大,内存波动越明显。
我们最终把分片大小定在了 8MB。拿一个典型的 2GiB 视频算一遍:
- 文件大小:2 × 1024MB = 2048MB
- 单个分片:8MB
- 总片数:2048 ÷ 8 = 256 片
256 个 HTTP 请求,对一个严谨的上传任务来说不大不小。即使某个分片失败重传,损失也只有 8MB 数据,在千兆内网里也就是几十毫秒的重传成本,在较差的跨网链路上也就是一两秒的事。
并发数我控制在 2 到 3 个。不要看到系统配置好就开 6 并发,后面兼容老内核那节会讲原因:很多浏览器对同域名的并发连接数是有限制的,尤其是在 IE 兼容模式和国产浏览器兼容内核里,并发太高反而会排队卡死。
| 参数 | 取值 | 说明 |
|---|---|---|
| 单个分片大小 | 8MiB | 兼顾传输效率和失败重传代价 |
| 并发上传数 | 2~3 | 兼容旧内核连接数限制,降低服务端压力 |
| 单分片超时 | 120s | 网络恢复时间阈值,超过则进入重试 |
| 最大重试次数 | 3 | 结合指数退避,避免无限重试 |
2.2 文件唯一标识:从随机 uid 到 fileMd5
WebUploader 内部会给每个文件生成一个随机 uid,这个 uid 只在当前页面有效。要断点续传,必须有能跨会话识别的业务标识。我采用的方案是:文件大小 + 完整内容哈希。
大文件做完整哈希,很多人第一反应是慢。实际上我们用 Web Worker 后台算,不会卡住主界面。以 2GiB 视频为例,在普通 x86 台式机的浏览器里跑 SparkMD5,整体耗时大约在 15 到 30 秒。用户在大文件上传前等待这个哈希时间是可以接受的,尤其我们可以在界面上明确显示“正在分析文件”。
这里有一个非常现实的问题:企业在高保密环境里对完整性要求极高,所以不能用“文件名 + 文件大小 + 修改时间”这种弱校验,否则一个同名同大小的文件被换掉,续传后可能产物是坏的。我们最终还是坚持全量 MD5,加上文件大小作为辅助判重条件。
如果你的业务实在接受不了大文件哈希等待,可以做折中:先用“文件名 + 文件大小 + 最后修改时间”注册任务并立即上传,同时后台并行计算哈希,但每个分片都记录归属任务,在合并前必须校验哈希。这个方案要后端配合,复杂度更高,我当时没有在第一期采用。
2.3 前后端约定的上传流程
在我看来,“断点续传”不是一个前端 API,而是一套前后端协作的流程。我们当时约定的流程很清晰:
- 前端在文件入队后计算文件 MD5,得到
fileMd5和fileSize。 - 前端向后端发起注册请求
/api/upload/register,提交fileMd5、fileName、fileSize、chunkSize。 - 后端检查任务是否存在,如果已存在且完整,直接返回
merged: true实现秒传;如果存在但不完整,返回已收到的分片序号数组uploadedChunks;如果任务不存在,则初始化任务,返回空数组。 - 前端比较本地待传分片集合和后端返回的
uploadedChunks,只上传缺失的部分。 - 每个分片上传成功后,前端把对应分片标记为完成。
- 所有分片完成后,前端调用
/api/upload/merge,后端按分片序号顺序合并,并对完整文件做 MD5 校验,返回成功。
这个流程把“前端续传”和“后端状态持久化”解耦了。服务端存的是任务状态,前端存的是文件指纹,双方通过 fileMd5 对齐。页面刷新后,用户重新选择相同文件,前端重新计算 MD5,服务端返回上传过的分片,上传任务就能从断点继续。即使是电脑断电重启,第二天重新选同一个文件,只要服务端临时片还在,也一样能续。
3. WebUploader 改造落地:接管队列,自己做分片调度
3.1 把 Uploader 从“上传器”降级成“调度器”
技术实现上最关键的一步是:保留 WebUploader 的对象和 UI 逻辑,但它的自动上传行为我们完全不用。
我在代码里始终把 WebUploader 当成一个“文件收集器和任务展示器”,真正的上传活动由自己的 ChunkUploader 类控制。核心原则有三条:
- 文件选择、拖拽、类型过滤、重复文件提示继续用 WebUploader。
server配置虽然保留,但auto: false,绝不直接调用uploader.upload()走原生上传。- 文件入队后,我们主动从 WebUploader 的封装文件里拿到浏览器原生 File 对象。基于原生 File 对象完成 MD5、切片、上传、进度更新等全部操作。
这样做的原因很简单:原生上传器和我们的调度器可能同时监听同一个文件,导致重复上传;而原生分片逻辑无法精确控制“从第 73 片开始传”。直接接管后,所有状态都由我们自己掌控。
3.2 切分与上传的核心代码骨架
下面是从项目里抽取出的简化版骨架。rawFile 是 WebUploader 队列里的原生 File,fileMd5 是前面计算好的文件哈希。
javascript复制const CHUNK_SIZE = 8 * 1024 * 1024; // 8MiB
const MAX_RETRY = 3;
const CONCURRENCY = 3;
class ChunkTask {
constructor(rawFile, fileMd5, chunkIndex, chunkTotal) {
this.rawFile = rawFile;
this.fileMd5 = fileMd5;
this.chunkIndex = chunkIndex;
this.chunkTotal = chunkTotal;
this.startByte = chunkIndex * CHUNK_SIZE;
this.endByte = Math.min(rawFile.size, this.startByte + CHUNK_SIZE);
this.blob = this.sliceFile(rawFile, this.startByte, this.endByte);
}
sliceFile(rawFile, start, end) {
// 额外的兼容处理,老内核兼容细节后面讲
if (rawFile.slice) {
return rawFile.slice(start, end);
}
if (rawFile.webkitSlice) {
return rawFile.webkitSlice(start, end);
}
throw new Error('当前浏览器不支持文件切片');
}
}
上传单个分片我用 FormData。统一用 multipart 格式,是为了兼容各种后端框架。很多内网项目后端是 Java 系,Spring MVC 对 multipart 的支持最完善,直接能拿到 MultipartFile。非要追求极致性能可以用 xhr.send(blob) 走裸二进制流,但会让服务端解析复杂化,我当时没走那条路。
javascript复制function uploadChunk(task) {
const formData = new FormData();
formData.append('fileMd5', task.fileMd5);
formData.append('chunkIndex', task.chunkIndex);
formData.append('chunkTotal', task.chunkTotal);
formData.append('chunk', task.blob, `${task.fileMd5}_${task.chunkIndex}.part`);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload/ch
