老项目里最折磨人的,往往不是业务逻辑有多绕,而是辛辛苦苦传了几个小时的大文件,突然因为断网、浏览器崩溃、手滑关了页面,一切归零。这两年做卫星视频相关的业务系统,我几乎天天在跟这种“超大附件”较劲:文件动辄几个GB,还时不时要跨浏览器、跨网络环境,前端方案用WebUploader做底座,但又不能直接用默认行为——默认那套顶多算失败重试,谈不上真正的断点续传。
这篇文章把我实际改造WebUploader、封装一个可复用插件的完整过程整理出来,重点讲清楚:默认断点续传和真断点的区别、分片参数怎么算、MD5指纹怎么算不卡界面、跨浏览器和跨域到底坑在哪儿,以及最后线上常见的几个诡异问题怎么排查。如果你也接手过类似的“大文件上传”需求,这篇可以直接当参考手册用。
1. 先搞清楚:WebUploader默认的“断点续传”到底够不够用
1.1 它是断点重试,不是断点续传
很多资料把WebUploader自带的分片重传机制直接等同于断点续传,这个说法其实有误导。WebUploader默认行为是:一个大文件被切成多个分片,某个分片失败后会在当前上传任务内自动重试。注意重点——“当前任务内”。
也就是说,只要页面不刷新、浏览器不退出、上传队列没被重置,失败了它可以接着传。但只要用户刷新页面、关掉浏览器,或者网络断开时间过长导致会话失效,所有分片状态就清空了,下次打开还是从头开始。
真断点续传的核心是两件事:一是能识别“同一个文件”,二是知道“这个文件已经传过哪些分片”。识别文件靠的是内容指纹,比如MD5;已传分片信息要么记在服务端,要么记在浏览器本地。WebUploader默认两者都没做,所以必须自己改造。
1.2 卫星视频场景的四个硬约束
我这边业务里的文件类型很集中:卫星视频、遥感影像、长时间序列的回放数据。这类文件有几个共同特点,直接决定了方案设计。
第一是量大。单个文件十几GB、二十几GB都不稀奇,图片序列打包后几百个文件、每个几百MB也常见。这种体量下,如果分片大小设计不合理,分片数量分分钟破千,服务端临时文件数量、合并开销都会上来。
第二是链路不稳定。卫星视频往往在偏远地区的接收站落地,再回传到中心机房,网络可能是卫星链路、专线、甚至多级代理转发。丢包、断流、限速都是常态,用户能接受慢,但不能接受“断了就白传”。
第三是业务流程强依赖完整文件。视频要能完整播放、能被算法分析,才叫传完。某个分片缺失,整个文件就没法用。所以上传完成后的完整性校验绝不能省。
第四是使用环境杂。客户现场可能有不同年代、不同内核的浏览器,有的还在用老旧的IE内核访问系统,有的单位内网做了多层域名跳转,跨域问题很常见。方案必须在这些条件下都能顶住。
1.3 为什么还要选WebUploader做底座
既然有这么多不够用的地方,为什么不直接用XMLHttpRequest或者fetch重写一套?
原因很现实:WebUploader的生命周期、文件队列、并发控制、失败重试、进度事件这些基础能力是现成的,而且经过大量项目验证,踩坑成本比从零写低得多。我要做的是在它上面做“手脚”,而不是推翻重来。再加上团队里其他同事不一定都懂二进制分片那套底层细节,基于WebUploader封装成插件,大家用起来成本最低。
另外,WebUploader内部对分片的处理用的是Blob.slice,底层虽然也走HTML5,但对外API封装得比较友好,重写个别行为时侵入性可控。所以我的结论是:改造它,而不是替换它。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 插件设计:从文件指纹到分片状态的完整链路
2.1 分片参数怎么定:既看带宽也看链路易失性
分片大小和并发数是改造里最先要定死的参数,这俩直接决定上传速度和失败代价。我见过不少项目图省事直接写死64MB、分片数不做检查,结果弱网环境下一片要传十几分钟,中途断了重试成本高到无法接受。
我通常按两个维度评估:
一是链路质量。单位内网带宽充裕、稳定性好,分片可以给到64MB到128MB,并发控制在3到5个,这样分片数量少,服务端合并压力小。如果用户可能在弱网、卫星链路上传,分片尽量控制在8MB到16MB,并发数也可以想办法根据当前失败率动态调低。
二是单分片超时时间。WebUploader默认失败重试是“立即重试”,但如果链路已经断了,反复重连只会反复超时。建议在监听uploadError时做退避处理,连续失败3次就暂停该文件的自动重试,等用户手动点击或者隔一段时间之后再恢复。
给一个实际计算例子:文件25GB,如果按64MB分片,分片数量是 25 * 1024 / 64 = 400片。3并发在千兆内网每个分片大约1-2秒,理论总耗时大概3-5分钟,实际加网络交互和服务端写盘,10分钟左右属于正常。如果是4Mbps的卫星链路,64MB一片要传大约128秒,400片串行就是14个小时,这时候就必须缩小分片,否则一个断线就非常痛苦。
所以分片大小不应该硬编码。插件里我做成可配置项,同时支持根据文件大小和网络类型(通过配置传入)自动调整。
2.2 文件指纹:MD5计算的前置准备
断点续传的前提是识别“同一个文件”。最简单的办法是文件名+大小,但很不严谨:同名不同内容的文件太常见了,比如同一时间段的视频处理参数不同,重新导出后名字没变、内容变了,如果不刷新指纹,服务端直接命中旧文件,返回秒传,那就出大事故了。
所以必须计算文件内容指纹。前端常用MD5,文件几十GB时一次性把整个文件读进内存再算,浏览器直接卡死。正确做法是分块读取、增量计算。WebUploader自带md5File方法,底层也用了SparkMD5,但它是在主线程跑的,大文件一样卡。
我这边用Web Worker来做:把File对象丢给Worker,Worker内部按2MB一块读文件,每读一块算一次增量MD5,同时向上抛进度事件,主线程只负责展示计算进度和接收最终hash。注意兼容性:老浏览器对Worker传递File对象的支持不完整,可以用文件路径或临时URL绕一下,实在不行就退化到主线程计算,加一个“计算中,请勿关闭页面”的提示。
MD5算完后,后续所有接口都带上md5参数。这个值就是文件在系统里的唯一身份。
2.3 “已传分片”的两种记录方式:服务端为主,LocalStorage为辅
拿到MD5之后,下一步是查服务端:“这个文件的已上传分片有哪些?”服务端根据md5查上传会话表,返回一个已上传分片索引数组。
这条链路上有个容易忽略的坑:服务端不能只记录“有没有这个文件的会话”,因为不同浏览器的上传任务可能用同一个md5。比如用户A传了一半退出,用户B拿到了同一个文件又要传,这时候要让B能续上A的进度,但也要能区分不同的“上传实例”。所以服务端表里最好有一个uploadId字段,接口设计成:
- 先用md5查询:如果存在未完成会话,你得决定是新建会话还是继续使用旧会话。
- 前端拿到uploadId之后,所有分片请求都带上它,避免并发场景下互相覆盖。
本地LocalStorage怎么配合?它的作用不是存分片内容,而是存“这个文件跟哪个uploadId绑定”,顺便把一次上传的元信息(文件名、大小、md5、分片大小、总分片数、浏览器类型)存下来。这样即使服务端临时会话被清理过,前端还能提供足够信息让服务端重建会话,或者至少给用户一个明确的续传/重传选择。注意LocalStorage有5MB限制,千万不要把分片列表往里塞,只存元信息就够。
3. 核心改造:一个可直接落地的WebUploader插件实现
3.1 实例化配置与关键参数
先给一份实例化配置,后面所有改造都建立在这份配置之上:
javascript复制const uploader = WebUploader.create({
swf: '/static/Uploader.swf',
server: '/api/upload/chunk',
pick: '#picker',
accept: {
title: '视频文件',
extensions: 'mp4,mov,avi,tif,tiff'
},
chunked: true,
chunkSize: 64 * 1024 * 1024, // 64MB,内网环境
threads: 3,
duplicate: true, // 允许重复选择同名文件,因为是否真重复靠md5判断
auto: false, // 不自动上传,先走指纹和续传判断
formData: {} // 动态塞入,不要在create时写死
});
这里有个细节:duplicate必须设为true。如果设为false,用户第二次选择同名文件会被WebUploader直接拦截,但这两个同名文件内容可能完全不同,拦截反而阻断了正常的覆盖上传流程。
threads并发数不一定要固定,可以在uploadStart时根据文件大小动态调整。我试过大型文件3并发、小型文件1并发,整体资源占用更合理,弱网环境也不会因为并发太高导致路由器或代理队列崩溃。
3.2 续传检测和跳过已传分片
文件加入队列后,流程是这样:
- 触发MD5计算(Worker方式),算完拿到hash。
- 带着md5、文件名、大小请求服务端状态接口,拿到uploadId和已传分片列表。
- 把uploadId写进formData,把已传分片列表挂到file对象上。
- 调用uploader.upload(file)开始上传。
第4步触发后,WebUploader会逐分片发送。此时在before-send回调里判断当前分片是否已经传过:
javascript复制uploader.on('before-send', function (block) {
const file = block.file;
const uploadedChunks = file._uploadedChunks || [];
// 这个分片已经传过,直接跳过
if (uploadedChunks.indexOf(block.chunk) > -1) {
return false;
}
// 正常分片,把指纹和分片信息塞进formData
uploader.option('formData', {
uploadId: file._uploadId,
md5: file._md5,
chunkIndex: block.chunk,
totalChunks: block.chunks,
ext: getFileExt(file.name)
});
});
before-send返回false表示“这个分片不传,直接当作成功处理”,WebUploader会把该分片标记为成功,然后继续下一个。实测这个行为在1.x版本里是可靠的,但要注意:必须确认返回false之前不要改变全局formData,否则跳过的分片也会把formData改掉,导致后续分片识别错乱。
服务端那边,即使前端已经跳过已传分片,接口也要做幂等处理:某个分片已存在时直接返回成功,而不是报错。因为并发场景下,前后端状态可能不同步,服务端必须能兼容重复上传。
3.3 上传进度、失败重试与合并触发
进度展示不能只看总百分比,因为大文件单次上传耗时太长,用户盯着一个缓慢变化的总进度条很容易焦躁。我在插件里同时展示两层进度:
- 分片级:当前传到第几个分片、总共多少分片,例如“127/400”。
- 总体级:已传数据量 / 总数据量,加上最近几个分片的平均速率,用来估算剩余时间。
WebUploader的uploadProgress事件给的是文件总体百分比:
javascript复制uploader.on('uploadProgress', function (file, percentage) {
const sentMB = (file.size * percentage / 1024 / 1024).toFixed(1);
const remainMB = (file.size * (1 - percentage) / 1024 / 1024).toFixed(1);
// 更新UI,展示“已传xxMB/剩余xxMB”
});
失败重试这一块,我踩过一个坑:默认fail事件会在失败后立即重试,如果服务端因为网络抖动返回超时,重试大概率还是失败,而且会把上传队列卡住。我的做法是监听到失败后延迟重试,连续失败3次就标记该文件为“异常暂停”,此时允许用户选择重试或者放弃,而不是无脑循环。
所有分片都传完以后,uploadFinished事件触发,但这个时候还不能告诉用户“上传成功”。必须由前端主动调用合并接口:
javascript复制uploader.on('uploadFinished', async function () {
const file = currentUploadFile;
const resp = await fetch('/api/upload/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
uploadId: file._uploadId,
md5: file._md5,
fileName: file.name,
totalChunks: file._totalChunks,
fileSize: file.size
})
});
const result = await resp.json();
if (result.ok) {
// 通知业务层:整个上传链路完成
}
});
3.4 服务端配合:分片接收与有序合并
前端再怎么改,服务端不配合都是白搭。这里给一个Node.js风格的简易伪代码,核心是分片落盘和最后合并:
javascript复制// 上传一个分片
app.post('/api/upload/chunk', upload.single('file'), (req, res) => {
const { uploadId, chunkIndex } = req.body;
const dir = path.join(UPLOAD_DIR, uploadId);
if (!fs.existsSync(dir)) fs.mkdirSync(dir);
const chunkPath = path.join(dir, String(chunkIndex).padStart(6, '0'));
// 如果已存在,说明是重复上传,直接返回成功
if (!fs.existsSync(chunkPath)) {
fs.renameSync(req.file.path, chunkPath);
} else {
fs.unlinkSync(req.file.path); // 清理临时文件
}
res.json({ ok: true });
});
// 合并
app.post('/api/upload/merge', async (req, res) => {
const { uploadId, fileName, totalChunks } = req.body;
const dir = path.join(UPLOAD_DIR, uploadId);
const count = fs.readdirSync(dir).length;
if (count !== totalChunks) {
return res.status(400).json({ error: '分片缺失,请重新上传' });
}
const outPath = path.join(FINAL_DIR, uploadId + '_' + fileName);
const writeStream = fs.createWriteStream(outPath);
for (let i = 0; i < totalChunks; i++) {
const chunkPath = path.join(dir, String(i).padStart(6, '0'));
const data = fs.readFileSync(chunkPath);
writeStream.write(data);
}
writeStream.end();
writeStream.on('finish', () => {
// 建议再次计算整个文件的md5,和前端上传前算的hash比对
res.json({ ok: true, path: outPath });
});
});
这里有几个服务端容易忽略的点:
第一,分片文件名一定要统一补零排序。如果1、2、10、100这样的分片名不补零,字典序合并时会变成1、10、100、2,文件直接损坏。
第二,合并时不要一次性把全部分片读进内存。我上面用的是writeStream逐片写入,虽然每片还是会readFileSync进内存,但只要单分片大小可控(64MB以内),问题不大。更稳妥的做法是createReadStream配合pipe,避免瞬时内存飙高。
第三,合并完成后要做整体校验。最靠谱的方式是服务端对合并后的文件重新计算一次MD5,跟前端提交的md5比对,一致才返回成功。这个校验在卫星视频场景下尤其重要,因为哪怕一个分片错位,视频文件就废了。
4. 跨浏览器与跨域兼容:最容易翻车的一环
4.1 从Flash到HTML5,runtime选择的现实考量
WebUploader早期核心优势之一就是兼容老IE:它内部有两种runtime,高版本浏览器走HTML5,低版本IE走Flash。Flash方案曾经很香,但现在已经过时了。Adobe Flash组件本身存在安全漏洞,主流浏览器也在逐步封禁,内网系统如果还强制走Flash,用户那边就会遇到“浏览器提示插件不安全”“需要手动允许Flash运行”这类麻烦。
我的建议是:
- 已统一使用Chrome/Edge/Firefox内核的环境,直接设为runtimeOrder: 'html5',彻底不加载Flash。
- 还在用IE10/11又不允许升级的现场,可以保留flash作为fallback,但一定要处理好跨域策略文件。
- 如果现场存在大量低版本IE8/9,说实话WebUploader的Flash模式也救不了太多,最好推动业务方升级浏览器。
兼容性自查时重点看这几个点:Blob.slice、FileReader、FormData、XMLHttpRequest Level 2。IE10以下基本不支持FormData传二进制,所以Flash几乎是唯一选择;IE10以上可以走HTML5,但进度事件偶尔会有total为0的情况,需要取文件总大小兜底。
4.2 CORS与Cookie携带:跨域上传不再白屏
很多单位内网会用多个域名部署不同子系统,上传页可能挂在a系统,上传接口却在b系统,跨域问题就来了。WebUploader如果走Flash runtime,跨域受flashplayer的安全策略限制,必须有crossdomain.xml,否则浏览器弹“跨域访问被拒绝,请检查浏览器配置!”,这个提示在很多老项目里被当作浏览器问题,其实根源是服务端没有配置跨域策略。
现在走HTML5 runtime,处理跨域的方式就现代化很多,但要注意带Cookie。很多内网系统依赖单点登录,上传接口要识别当前用户,而默认CORS请求是不携带Cookie的。后端响应头要这么设置:
text复制Access-Control-Allow-Origin: https://你的页面域名
Access-Control-Allow-Credentials: true
注意Access-Control-Allow-Origin不能简单用*,必须明确指定页面所在的域名,并且开启withCredentials。如果你用的是fetch,直接设置credentials: 'include';WebUploader底层用的是XHR,对应的是xhr.withCredentials = true,可以通过before-send-file里改写:
javascript复制uploader.on('before-send-file', function (file) {
uploader.options.server = '/api/upload/chunk';
// WebUploader内部XHR实例可以在这里拦一下
// 如果自定义方式,也可以在jQuery.ajaxSettings或axios实例里统一处理
});
更稳妥的做法是,上传接口和页面走同域,用Nginx把/api/upload反向代理到后端服务,这样前端不用跟CORS搏斗,最不容易出问题。
4.3 兼容性自查清单
这块我整理了表格,线上出问题先过一遍:
| 检查项 | 正常状态 | 异常表现 | 处理方式 |
|---|---|---|---|
| 浏览器内核版本 | Chrome/Edge 80+ | 某些老内核不支持XHR Level2 | 统一升级浏览器,或降级Flash |
| Blob.slice前缀 | 现代浏览器无前缀 | 老浏览器需要webkitSlice | WebUploader内部处理,一般不用管 |
| FormData传二进制 | iOS/Android Safari 10+正常 | 个别版本传空文件 | 用File对象直接append,别用Blob |
| 上传接口跨域 | CORS头正确 | 提示跨域被拒绝 | 设置具体Origin + Cookie |
| 服务端分片顺序 | 按补零文件名合并 | 文件播放花屏 | 用padStart统一补6位零 |
| 页面刷新后状态 | 从服务端拉取已传分片 | 进度清零 | 检查localStorage与接口返回 |
5. 上线后的高频问题与排查实录
5.1 上传到99%卡住不动
这是上线后反馈最多的问题。现象是分片进度到了最后一片,总进度显示99%,然后一直转圈。
排查思路:99%说明前端所有分片已经上传完,但uploadFinished没触发,或者触发了但合并接口失败。常见的坑是最后一片上传完以后,WebUploader需要等所有并发线程的返回结果,如果某个分片的回调因为网络原因丢了,上传队列会卡死。我遇到过一次是服务端的临时目录权限问题:分片写进去失败,但接口返回错误码被前端某个拦截器吞掉,导致WebUploader一直等不到response。
解决方法是给上传接口加超时检查和明确返回:无论成功失败,服务端都要返回一个可识别的JSON,前端拿到非ok状态时主动走失败重试,而不是傻等。另外,合并接口的调用可以做个防御:如果uploadFinished后5秒内没收到合并响应,主动请求一次合并状态,避免服务端合并进行中却因为代理超时断了连接。
5.2 刷新页面后进度清零、重复上传
这个问题基本可以断定是续传状态没有持久化。排查顺序:
- 文件选择后有没有计算MD5?如果没有,刷新后连“同一个文件”都识别不出来。
- 本地LocalStorage里有没有保存md5和uploadId的映射?
- 有没有调服务端状态接口拿已传分片列表?
- before-send里有没有根据已传列表跳过?
这四个环节任何一个断了,用户刷新后就会从零开始。此外还有一种情况:MD5计算使用的是前端算法,如果不同浏览器的实现有差异导致hash不一致,服务端也会认为不是同一个文件。SparkMD5在主流浏览器里表现一致,但低版本IE如果走Flash算出来的md5和HTML5算出来的不一样,就悲剧了。我的建议是不要依赖Flash算md5,统一在主线程或Worker里用SparkMD5的ArrayBuffer模式算。
5.3 上传进度条不准,偶尔还往回跳
进度回跳一般不是Bug,是网络重传造成的。某个分片之前报成功,但后期校验发现文件没有完整落盘,触发重传,总进度自然会往回退。这时候要先确认服务端分片是否真的落盘了,不能前端报成功就以为万事大吉。
还有一种情况是并发线程的完成顺序和分片顺序不一致,WebUploader报进度时按已完成的字节数累加,而某些分片在重传,会出现短暂的“卡在同一个百分比”。这种情况用当前分片索引替代总字节数来展示“第x片/共y片”,用户看着更直观,也不会觉得一直卡着。
5.4 浏览器崩溃、临时文件残留
大文件上传最怕浏览器中途崩溃,但现场往往避免不了。崩溃后最直接的影响是已传分片还算不算数。只要服务端分片已落盘,就算数;前端刷新后重新计算MD5、查询已传列表、跳过已传分片,就能续上。所以我前面强调服务端记录分片状态这一步绝不能省,这也是整条方案里最核心的“保险丝”。
服务端要定期清理超过一定时间(比如24小时或48小时)未完成合并的临时目录,否则大量超大文件的分片会把磁盘塞满。我见过客户现场因为没人管临时目录,跑了三个月之后磁盘满了,所有上传全部失败,排查了很久才发现是磁盘满了,而不是代码问题。
另外,杀毒软件也可能锁住临时文件。某些安全软件会把分片目录当成可疑文件扫描,导致写入失败或合并时报文件被占用。解决办法是把上传临时目录加到白名单,或者在服务端合并时写个重试机制。
6. 最后分享两个小经验
第一个是关于分片大小的后续扩展。不要把这个参数做成一成不变的配置,最好是插件能根据当前网络情况和文件大小自动调整。比如文件超过10GB时用64MB分片,低于1GB时用4MB分片;弱网环境下自动降到8MB个分片。这样做的好处是既保证大文件效率,又避免小文件被切成分太多片导致服务端文件数爆炸。
第二个是关于用户体验的细节。我后面又加了一个“上传报告”功能:上传完成后弹出一份摘要,包含文件指纹、总分片数、重传分片数、平均速度、总耗时。这个功能业务方非常喜欢,因为卫星视频文件动辄几个GB,数据接收方需要知道文件是否完整、是否经过重传、耗时多久。其实要实现它很简单,在uploadProgress和重试逻辑里做埋点统计就行。
改造WebUploader这条路,我把能踩的坑基本踩了一遍,上面写到的细节都是真金白银换来的经验。如果你也在处理超大文件上传,希望这份笔记能帮你少走几步弯路。
