我们单位接的这个军工行业内部数据归档系统,里面最让人头疼的一块,就是把卫星视频素材传到服务器。单文件动辄几GB到几十GB,TS、MP4、MXF这些格式全都有,而且整套系统部署在内网隔离环境里,带宽有限,网络说断就断。浏览器环境更是离谱,有Windows 7配IE11的,有360安全浏览器开着兼容模式当IE用的,还有UOS系统里的Firefox。说白了,这就是一个教科书级的“跨浏览器超大附件分片断点续传”需求,绕都绕不开。
我先把结论放前面:直接拿WebUploader原版根本没有交付的可能。它确实支持分片、支持并发,但它不记录上传状态,页面一刷新,之前传的所有分片就全废了,用户得重新选文件从头传。我们要做的,就是在JS层面给WebUploader补上状态持久化、文件唯一标识、服务端分片状态查询和跳过已传分片的能力,让这个半残的库真正具备“断点续传”的战斗力。
下面我把整个改造过程拆开讲,从需求分析、方案选型、代码实现到兼容性踩坑,一条条说清楚。
1. 先说清楚:这个任务到底难在哪里
1.1 项目现场的真实痛点
先说文件大小。卫星视频素材和普通办公文件完全是两个物种。地面站接收的原始数据、任务回传的素材、编目后的成品视频,码率都很高,一个成片轻松上GB,原始数据几十GB都不稀奇。这么大的文件,用传统form表单提交,基本等于给浏览器判死刑:要么超时,要么内存爆掉,要么页面直接卡死。必须分片处理,这是物理定律,不是偏好问题。
再说网络环境。内网网络虽然没有公网那种乱七八糟的干扰,但它有自己的麻烦:跨网段限速、高峰时段拥塞、防火墙会话超时、专线抖动。这些因素叠加起来,一个几GB的文件在传输过程中大概率会中断。中断后如果没法从断点继续传,之前传的几十个分片就全部白费,用户心态直接爆炸。
最后说浏览器兼容。客户现场有IE11,有360安全浏览器的兼容模式,有老版本Firefox,你不能要求所有人都装Chrome。而且军工行业对安全审计很严格,上传过程要可追溯,数据要完整性校验,不能用那种只图功能不看细节的轮子。
1.2 为什么选WebUploader而不是别的方案
业界做分片上传的选择很多:原生XMLHttpRequest自己写、Plupload、WebUploader、Resumable.js、axios配后端SDK,都有人用。我最后选了WebUploader,主要有两个原因。
第一,客户现有系统里本来就有WebUploader,之前的文档上传模块就是用它做的,接口约定、UI交互风格都已经定好了。如果上新方案,光说服客户改交互、适配新接口就能耗掉一大半工期。
第二,WebUploader底层是Plupload,分片、并发、文件筛选、HTML5/Flash自动降级这些基础能力都是现成的。我们只需要在它外面包一层状态管理,比从零手写稳得多。手写一个分片工具不难,难的是把各种边界情况和老浏览器兼容处理到位,这活我干过,没一个月下不来。
但必须提醒一句:WebUploader官方已经停止维护很久了,新项目我一般不建议直接用,除非有历史包袱,或者你能接受自己维护它。下面的改造思路,本质上是在给一个“半残”的库续命,核心逻辑对Plupload、Resumable.js同样适用,换库不换思路。
1.3 改造的核心边界
我们要做的不是推翻重写,而是在WebUploader外面加一层轻量级的“状态管理层”。前端负责记录本地状态、计算文件唯一标识、请求服务端查询状态、过滤已传分片;服务端负责按fileId落盘分片、查询分片状态、合并分片、校验完整性。这个分工是整个方案的地基。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体方案设计:分片、续传、状态管理
2.1 完整流程拆解
我把整个上传流程拆成五个环节:
- 选择文件后,先本地计算一个fileId作为文件唯一标识;
- 拿着fileId去服务端查询:这个文件之前传过没有,已经传了哪些分片;
- 根据服务端返回的已传分片列表,把未上传的分片过滤出来;
- 用WebUploader的并发能力上传这些剩余分片;
- 全部完成之后,通知服务端合并分片,生成最终文件。
很多人把“分片”和“断点续传”混为一谈,其实分片只是手段,断点续传才是目标。分片只是把大文件切开,真正能不能续传,取决于你有没有把“哪些分片传过了”这个状态持久化下来。WebUploader默认不保存这个状态,所以必须我们自己加。
2.2 分片大小和并发数怎么定
分片大小是第一个要拍板的事,选不好后面全遭罪。
选太小了,分片数量上去了,服务端要处理的请求数暴增,文件碎片化严重,合并的时候容易出问题;选太大了,如果某个分片挂在途中,重试成本就高了,而且单个请求的内存占用也高。
我在现场验证下来的经验是:普通内网带宽下,5MB一个分片最稳。比如20GB的视频,5MB一个分片就是4096个分片,配合3路并发,虽然分片数量多,但服务端用线程池处理完全顶得住。如果客户网段是万兆内网,可以调到10MB甚至20MB,减少请求数量;如果带宽只有几十兆,建议用2MB,让单个分片传输时间短一点,出错重试的压力也小。
分片数的计算公式很简单:Math.ceil(file.size / chunkSize),这个值要传给服务端,合并分片时要用。
2.3 断点续传的状态数据结构
断点续传的核心,就是用一个数据结构把上传状态记录下来。我选localStorage存储,原因很实在:内网环境下,同一个浏览器基本对应同一个用户,只要用户不清浏览器数据,状态就能保留。没选IndexedDB,因为localStorage的API更简单,而且我们的状态数据量很小,完全够用。
每条状态记录我这样设计:
js复制{
fileId: "demo_video_1699999999999",
fileName: "20240813_satellite_001.mxf",
fileSize: 21474836480,
chunkSize: 5242880,
totalChunks: 4096,
uploadedChunks: [0, 1, 3, 4, 5, 6],
lastModified: 1699999999999,
uploadTime: 1699999999999
}
uploadedChunks是整个改造最关键的字段,记录的是这个文件已经成功传到服务端的分片索引。每次一个分片上传成功,就把它push进数组并同步写入localStorage。下次再选这个文件,先查本地记录,再去服务端校验,然后跳过这些分片继续传。
存储上我建议用一个固定key,里面放一个map结构,不要一个文件一个key,否则localStorage会被塞爆。上传完成或删除任务时,把对应记录删掉,防止无限增长。
2.4 文件唯一标识fileId怎么生成
fileId的生成,我一开始用的是md5加文件大小,后来发现对大文件极不友好。一个10GB文件算完整MD5,浏览器里能卡几十秒甚至内存溢出,用户早把页面关了。
所以我采用了折中方案:优先用size + lastModified + 文件名拼接生成fileId。这个ID只用来做断点续传的关联,不保证内容唯一,但如果同一台机器上同一个文件被反复上传,它能精准匹配到之前的状态。
如果业务需要秒传功能,判断服务器上是否已有相同文件,那就必须用MD5。但MD5计算一定要有进度提示,并且放在选择文件后的空闲时间慢慢算,而且要分片读取,不能一次性把整个文件读进内存。后面代码部分我会给出一个可用的分片MD5计算实现。
2.5 MD5计算的正确姿势
对于军工行业的数据审计要求,MD5计算不能省,但也不能蛮干。我实际用的是SparkMD5的ArrayBuffer模式,配合FileReader分片读取,每次只读一个chunk大小的数据,算完一个chunk就append进去,最后拿到完整文件的MD5。
这里有个非常重要的经验:计算完后要把ArrayBuffer变量置空,手动让垃圾回收有机会回收内存。否则连续读多个大文件,浏览器内存会持续走高,最后直接白屏。
如果确认业务不需要秒传,可以在上传完成前不计算MD5,由服务端在合并完分片后统一计算。这样能省掉前端大量计算时间,但审计日志里的MD5字段就只能由服务端单独落库。我们项目是前后端都算了MD5,前端算的用来在合并时对比,服务端算的作为最终权威数据。
3. 改造实操:代码与接口联调
3.1 初始化WebUploader的关键配置
直接看代码,注释里写清楚每个参数为什么这么配:
js复制var uploader = WebUploader.create({
swf: '/static/Uploader.swf',
server: '/api/upload/chunk',
pick: '#picker',
accept: {
title: 'Video',
extensions: 'mp4,mov,avi,ts,mxf,m2ts'
},
fileVal: 'file',
chunked: true,
chunkSize: 5 * 1024 * 1024,
threads: 3,
duplicate: true,
fileNumLimit: 10,
fileSingleSizeLimit: 1024 * 1024 * 1024 * 1024,
formData: {
bizType: 'satellite-video'
}
});
有几个配置必须重点说明。
duplicate要设true,否则用户重新选择同一个文件时,WebUploader会提示“文件已选择过”,根本进不了后续的续传判断逻辑。这是续传功能能跑通的前置条件。
fileSingleSizeLimit虽然默认值已经够大,但我还是显式设置了一个1TB的上限。因为很多老版本WebUploader对超过4GB的文件会有内部计算溢出,显式设置可以在前端就过滤掉一些极端情况,避免后续分片计算出现负数这种诡异bug。
threads并发数我建议3到4,不是越多越快。内网带宽虽然够,但并发太高会导致服务端临时目录同时存在大量分片文件,磁盘IO会先成为瓶颈。军工内网服务器性能普遍比较保守,4路并发是我们压测后得出的最佳平衡点。
3.2 本地状态管理模块:localStorage封装
先写一个独立的状态管理模块,保持代码清晰:
js复制var uploadStateStore = {
key: 'sat_video_upload_state',
getAll: function () {
try {
return JSON.parse(localStorage.getItem(this.key)) || {};
} catch (e) {
return {};
}
},
save: function (record) {
var all = this.getAll();
all[record.fileId] = record;
localStorage.setItem(this.key, JSON.stringify(all));
},
get: function (fileId) {
return this.getAll()[fileId] || null;
},
remove: function (fileId) {
var all = this.getAll();
delete all[fileId];
localStorage.setItem(this.key, JSON.stringify(all));
}
};
JSON.parse包了try-catch,因为localStorage里的数据可能被其他页面逻辑污染,一旦解析失败就当作空状态处理,不能因为一个坏数据导致整个上传功能挂掉。
3.3 生成fileId并恢复历史上传记录
文件选完,进入队列之前,我们需要完成fileId的生成和历史状态的恢复。这个动作放在beforeFileQueued事件里:
js复制uploader.on('beforeFileQueued', function (file) {
var fileId = file.size + '_' + (file.lastModified || Date.now()) + '_' + file.name;
file.fileId = fileId;
var history = uploadStateStore.get(fileId);
if (history) {
if (window.confirm('检测到该文件之前上传过,是否从断点处继续?')) {
file._resume = history;
} else {
uploadStateStore.remove(fileId);
}
}
return true;
});
这里要注意:file.lastModified在旧版IE里可能返回undefined,所以要用|| Date.now()兜底。虽然这样会让同一个文件在不同时间的fileId不一致,但在IE下保证功能可用比理论上的唯一性更重要。
我的做法是:把file.fileId绑定到每个文件对象上,后面的查询、上传、状态记录都直接用这个属性,避免重复计算。
3.4 向服务端查询已传分片列表
上传流程启动前,必须跟服务端确认哪些分片已经传过了。这个动作放在uploadStart事件里,此时文件已经进入队列但还没开始传输,是查询状态的最佳时机:
js复制uploader.on('uploadStart', function (file) {
var deferred = WebUploader.Deferred();
$.ajax({
url: '/api/upload/status',
method: 'GET',
data: { fileId: file.fileId },
dataType: 'json'
}).done(function (resp) {
if (resp.code === 0) {
var uploaded = resp.data.uploadedChunks || [];
uploader.options._uploadedMap[file.fileId] = new Set(uploaded);
} else {
uploader.options._uploadedMap[file.fileId] = new Set();
}
deferred.resolve();
}).fail(function () {
// 查询失败不能阻断上传,当作全新上传处理,宁可重传几个分片也不能卡死
uploader.options._uploadedMap[file.fileId] = new Set();
deferred.resolve();
});
return deferred.promise();
});
这里我特意把所有文件的上传状态map挂在uploader.options._uploadedMap下面,方便在beforeSend里快速查询。查询失败就当作全新上传,这是现场踩坑踩出来的原则:内网环境接口偶尔超时很常见,如果因为查不到状态就把整个上传流程卡住,用户会非常崩溃。
3.5 beforeSend里跳过已传分片
beforeSend钩子是WebUploader在每个分片发起请求前都会触发的,这是我们做续传最关键的拦截点:
js复制uploader.on('beforeSend', function (block, data) {
var file = block.file;
var uploadedMap = uploader.options._uploadedMap[file.fileId];
if (uploadedMap && uploadedMap.has(block.chunk)) {
uploader.skipBlock(block);
return false;
}
data.fileId = file.fileId;
data.totalChunks = block.chunks;
data.chunkSize = block.end - block.start;
return true;
});
block.chunk是分片序号,从0开始。block.chunks是总分片数。这两个字段是WebUploader分片机制的关键参数,也是服务端合并分片时的依据,必须通过data对象传给后端。
skipBlock是WebUploader内部方法,用来跳过某个分片。用了之后,被跳过的分片会算作完成但不会触发uploadSuccess事件,所以服务端合并前必须自己校验分片完整性,不能依赖前端的状态。
3.6 分片上传成功后的状态维护
js复制uploader.on('uploadSuccess', function (file, response) {
if (response.code !== 0) {
uploader.trigger('uploadError', file, response);
return;
}
var idx = response.data.chunkIndex;
var record = uploadStateStore.get(file.fileId) || {
fileId: file.fileId,
fileName: file.name,
fileSize: file.size,
chunkSize: uploader.options.chunkSize,
totalChunks: Math.ceil(file.size / uploader.options.chunkSize),
uploadedChunks: []
};
if (record.uploadedChunks.indexOf(idx) === -1) {
record.uploadedChunks.push(idx);
}
uploadStateStore.save(record);
});
uploader.on('uploadError', function (file) {
addToast('分片上传失败,请稍后点击重试');
});
注意,这里有个细节:服务端必须返回这个分片的chunkIndex,前端不能用本地循环变量记录。因为在并发场景下,一个分片可能被重复执行,如果前端用本地变量记录,很容易和实际到达服务端的索引对不上。以服务端返回的chunkIndex为准,这是铁律。
3.7 全部完成后的合并与二次校验
uploadFinished事件触发说明所有分片都传完了,接下来就是通知服务端合并:
js复制uploader.on('uploadFinished', function () {
var file = uploader.getFiles('finished')[0];
if (!file) return;
$.ajax({
url: '/api/upload/merge',
method: 'POST',
data: {
fileId: file.fileId,
fileName: file.name,
totalChunks: Math.ceil(file.size / uploader.options.chunkSize),
md5: file._md5 || ''
},
dataType: 'json'
}).done(function (resp) {
if (resp.code === 0) {
uploadStateStore.remove(file.fileId);
showSuccess(resp.data.fileUrl);
} else {
showError('文件合并失败:' + resp.message);
}
});
});
merge请求里带了md5字段。如果前端做了MD5计算就传上去,服务端会用这个值跟合并结果做对比;如果前端没算MD5,就传空字符串,由服务端在合并完成后自行计算。两种方式我们都跑通过,有前端MD5的场景多一步完整性对比,审计日志更完整,后续排查问题也容易。
3.8 服务端接口契约
前端改完了,服务端必须跟上,否则一切都是白扯。我直接列出接口契约,方便拿给后端同事对:
| 接口 | 方法 | 关键参数 | 返回 |
|---|---|---|---|
| /api/upload/chunk | POST | fileId, chunkIndex, file(分片二进制) | code, data.chunkIndex |
| /api/upload/status | GET | fileId | code, data.uploadedChunks[] |
| /api/upload/merge | POST | fileId, fileName, totalChunks | code, data.fileUrl |
服务端要做四件事:
chunk接口收到分片后,以fileId为目录、chunkIndex为文件名落盘;status接口扫描这个目录,返回里面存在的分片索引集合;merge接口检查分片数量是否等于totalChunks,有缺失就直接返回错误;合并时严格按照chunkIndex从小到大依次追加,合并完成后删除临时分片目录。
军工内网的安全审计还要求记录每次上传的完整性,也就是这个文件从哪台机器、什么时间、传了哪几个分片、最终MD5是多少,都要能在服务端日志里查到。所以前端传到服务端的fileId、chunkIndex、totalChunks,后端都要落库,不能只放在临时文件名里。这个审计要求不是可选项,是硬性门槛,改造前就要跟后端明确。
3.9 失败重试的自动策略
网络闪断导致单个分片失败,是最常见的问题。我建议在WebUploader失败队列的基础上加一个自动重试策略:
js复制uploader.on('uploadError', function (file) {
var retryCount = file._retryCount || 0;
if (retryCount < 3) {
file._retryCount = retryCount + 1;
setTimeout(function () {
uploader.retry(file);
}, 3000);
}
});
手动点击重试按钮调用uploader.retry(),后台自动重试最多3次,间隔3秒。如果3次都失败,就停在失败队列里,等用户手动处理。这样既解决了瞬时抖动问题,又不会无限重试浪费资源。
4. 跨浏览器兼容性处理与常见问题
4.1 不同浏览器的兼容矩阵
跨浏览器是军工项目里最容易被低估的环节。我整理了一下实际现场遇到过的浏览器情况:
| 浏览器 | 内核 | WebUploader模式 | 主要注意点 |
|---|---|---|---|
| IE11 | Trident 7 | HTML5 | FileReader可用,但大文件分片读取内存高 |
| IE9 | Trident 5 | Flash | 必须安装Flash,File API不完整 |
| 360安全浏览器极速模式 | Chromium | HTML5 | 接近Chrome,坑少 |
| 360安全浏览器兼容模式 | Trident 7 | HTML5 | 本质是IE内核,按IE处理 |
| Chrome 56+ | Chromium | HTML5 | 最稳定,但要注意老版本没有新API |
| Firefox 52 ESR | Gecko | HTML5 | 分片正常,大文件内存管理需要留意 |
| UOS自带浏览器 | Chromium | HTML5 | 新系统内网常用,注意权限配置 |
最难受的是IE11下用WebUploader传大文件。IE11的FileReader读一个10MB ArrayBuffer没问题,但如果你在onprogress回调里做大量DOM操作,浏览器会卡死。解决方案就是给进度回调节流,间隔100ms再更新一次进度条,减少DOM操作频率。
4.2 军工现场三个硬性兼容坑
第一个坑:360安全浏览器兼容模式。这个模式本质是IE内核,但UA字符串跟IE不完全一样,很多判断IE的逻辑会失灵。我的方案是直接检测navigator.userAgent里的Trident字段,只要存在就按IE处理。对于双核浏览器,最好在页面加载时就检测内核,如果是兼容模式就在页面上提示用户切换极速模式,因为IE内核下WebUploader的稳定性完全不可控。
第二个坑:SWF加载失败。内网环境往往有域名限制和跨域策略,SWF文件如果跟页面不在同一个域,WebUploader的Flash模式会直接白屏。解决方法是把Uploader.swf部署在与页面相同的域下,并且确认服务端配置了crossdomain.xml。这里容易犯的错误是把SWF放到CDN路径,内网访问不到,一万个人一万个不认。
第三个坑:分片数据错乱。在IE11下,WebUploader的chunked配合threads大于1时,偶尔会出现分片数据错乱。这个问题的根本原因是老版本浏览器对Blob.slice的兼容实现有缺陷。我们最后的策略是检测到IE内核就把threads降为1,牺牲速度换稳定。这个选择非常痛苦,但你要知道军工现场的稳定优先原则永远排在体验前面。
js复制function isIE() {
return navigator.userAgent.indexOf('Trident') > -1;
}
if (isIE()) {
uploader.options.threads = 1;
}
实际测试中,IE下threads=1配合5MB分片,20GB文件上传速度确实会慢很多,但至少不会因为分片错乱导致合并失败,再配合断点续传功能,整体体验可以接受。
4.3 内存优化和MD5计算性能
做超大文件上传,前端内存管理是硬功夫,尤其是MD5计算。
正确做法是分片读取、分批计算,每次只读一个chunk大小的数据,计算完一个chunk就append进SparkMD5,然后手动把ArrayBuffer置空:
js复制function calcFileMd5(file, chunkSize, onProgress) {
return new Promise(function (resolve, reject) {
var spark = new SparkMD5.ArrayBuffer();
var reader = new FileReader();
var index = 0;
var total = Math.ceil(file.size / chunkSize);
function load() {
var start = index * chunkSize;
var end = Math.min(start + chunkSize, file.size);
var chunk = file.slice(start, end);
reader.onload = function (e) {
spark.append(e.target.result);
e.target.result = null;
index++;
onProgress && onProgress(index, total);
if (index < total) {
load();
} else {
resolve(spark.end());
}
};
reader.onerror = reject;
reader.readAsArrayBuffer(chunk);
}
load();
});
}
这里e.target.result = null是必须的,否则垃圾回收机制不会及时释放大块内存。我用10GB文件实测过,加上这行和去掉这行,内存峰值能差出1.5GB。
如果业务对秒传没有强需求,我的建议是:不在前端做全量MD5,只在上传完成后由服务端计算,前端展示上传结果即可。这样能大幅减少等待时间,尤其是几十GB的文件,全量MD5计算时间几乎等于再传一遍。
4.4 常见问题排查速查表
我把项目上线后现场反馈比较多的问题整理成一个表,给后面接手这个系统的同事留的:
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 刷新后点续传,已传分片又被重传 | fileId生成规则不稳定 | 检查fileId拼接字段,确保同一文件多次计算一致 |
| 合并后视频花屏或无法播放 | 分片顺序错乱或缺失 | merge接口必须校验totalChunks,按索引排序后合并 |
| 上传到90%左右卡住 | 网络闪断或服务端临时目录被清理 | 加自动重试,超时重试3次并提示用户 |
| localStorage里的记录越来越多 | 上传完成或删除任务时没清理 | 在uploadFinished和removeFile回调中remove状态 |
| 服务端保存的分片长期占用磁盘 | 没有做过期清理 | 服务端加定时任务,清理超过24小时的临时分片 |
| 360兼容模式下不能选文件 | 内核切换导致Flash失效 | 引导用户切到极速模式,或检查SWF路径 |
| SWF加载失败 | 内网域名限制或跨域策略 | 将SWF部署在同域,配置crossdomain.xml |
| 大文件计算MD5时浏览器崩溃 | 一次性读取整个文件导致内存爆掉 | 采用分片读取+逐片append+手动释放内存的方式 |
4.5 服务端临时目录的磁盘规划
还有一个大家容易忽略的问题:服务端临时分片目录的磁盘空间规划。
一个50GB的视频传到一半断了,那25GB的分片临时文件就会一直占着磁盘。如果同时有多个用户在上传,磁盘空间很快就会被占满。我们必须做两件事:一是给用户提示“断点续传期间请勿清理临时目录”,二是在服务端配置定期清理策略,清理超过24小时未完成的分片目录。时间窗口要留够,否则用户第二天想续传,发现分片被清理了,照样得从头传。
我在现场遇到过最极端的情况是:一个用户断点续传跨了周末,48小时后回来续传,服务端临时目录里的分片已经因为清理策略被删掉了,前端这边的状态还在,结果就是前端认为自己有4096个分片都传完了,服务端只有2000个,合并必失败。最后我的处理是:merge接口发现分片缺失后,返回401和已存在分片列表,前端清空本地状态并重新从服务端同步已传分片,让用户继续传缺失的部分。这个逻辑非常关键,能处理大量边界情况。
我在实际做完这套改造后,最大的体会是:不要把分片续传想成一个前端插件的事,它是一个前后端联动的协议问题。前端负责记录状态、请求分片、处理重试;服务端负责按fileId落盘、查询、合并、校验、清理。两头缺一个,断点续传都跑不通。WebUploader虽然老,但它分片、并发、文件筛选这些基础能力很扎实,只要我们把状态管理补上,它就能应付军工内网这种严格环境下的超大文件上传场景。
最后再分享一个小技巧:所有关键接口,包括status和merge,都要加上“查询失败不阻断上传”的逻辑。内网环境里偶尔会出现某个接口超时,如果你因为查不到状态就卡住整个上传流程,用户会非常崩溃。宁可让它重新传几个分片,也不能让整个任务死掉。这也是我们上线后总结出的最实用的一条经验。
