做军工行业项目的时候,我是真被卫星视频上传这事折磨过。外场设备录好的卫星视频,动辄几十个GB,编码格式五花八门,H.265、MPEG-TS、甚至一些设备直接输出MXF这种专业封装,想从工控机回传到后方机房,结果一看内网环境,简直是一锅大杂烩:有老掉牙的IE,有各种国产双核浏览器,有麒麟系统下的定制浏览器,版本跨度十几年。普通上传组件一传就断,断完又从头传,传到第二天一看进度条,还在原地踏步。
后来我下定决心,用JS对WebUploader做了一次系统性改造,把它做成了一套跑在浏览器里的超大附件分片断点续传插件。这套方案解决了三个核心痛点:超大文件怎么切、断了怎么续、不同浏览器怎么兼容。今天就把这次改造的完整思路、核心代码和踩坑记录整理出来,给正在被大文件上传折磨的同行一个能直接参考的方案。
1. 需求拆解与改造思路:为什么非要用WebUploader做底座?
1.1 卫星视频上传场景的“三个魔鬼细节”
第一个细节是文件超大。卫星视频不是普通短视频,分辨率高、帧率高、编码复杂,加上原始素材往往不允许做有损压缩,必须原样回传,所以单文件动不动就是几十GB。我见过最大的一个片段,一小时的原始记录数据,解封装出来有60多GB。这个体量,传统表单上传根本扛不住,浏览器处理大文件时连内存都保不住,更别提服务端接收时的压力。
第二个细节是跨浏览器。这个领域的内网环境跟互联网完全是两码事,终端浏览器从来不是开发者能决定的。有的岗位还在用IE8,有的单位要求全替换成国产化终端,跑着基于Chromium内核的定制浏览器,还有一部分老旧设备走的是IE内核兼容模式。Flash早就被安全策略禁掉了,HTML5在不同内核上的实现又有细微差别。这意味着插件从设计第一天起,就不能假设“用户会用Chrome”。
第三个细节是断点续传。外场和后方之间的链路质量差,带宽波动大,传着传着断网是家常便饭。做过大文件传输的人都知道,传了80%断掉比一开始就断了还让人崩溃。而且卫星视频数据完整性要求极高,任何一个分片丢失或者改动,都可能导致整个文件无法解码,所以不能只靠“传个大概”,必须做到分片级别的校验和续传。
1.2 WebUploader的定位与改造边界
既然需求这么硬,我一开始也想过从零写一个上传组件。但评估完工作量就放弃了:分片算法、并发控制、队列管理、进度回调、错误重试,这些基础工程量大且容易出bug,完全没有必要重复造轮子。
WebUploader虽然是百度开源的老项目,但它有一个非常突出的优势:它把底层能力抽成了Runtime,对外暴露了一整套事件钩子机制。分片上传、MD5计算、并发控制这些基础能力全是现成的,而且支持HTML5和Flash双运行时切换。我做的工作不是重写,而是做二次开发,把改造重点集中在三块:断点续传的业务逻辑、跨浏览器降级策略和数据完整性校验。
说得直白一点:WebUploader负责“文件怎么切、怎么传”,扩展代码负责“哪些分片不用传、传完怎么校验、出了问题怎么补救”。这个边界能让整个插件保持很强的可控性,也让我能在不影响核心上传流程的前提下快速迭代。
2. 核心细节解析:分片策略、MD5指纹与断点续传的“三重校验”
2.1 分片大小怎么定?
分片大小是整个插件最基础也最关键的一个参数。它不是拍脑袋定的,需要综合评估带宽、服务端处理能力、单请求超时时间和浏览器内存占用几个因素。
我先说结论:常规场景建议设成5MB,弱网环境降到2MB。为什么是这个数?假设链路带宽是20Mbps,5MB的分片大约需要2秒传完,这个时长对HTTP请求来说非常友好,既不容易触发服务端网关超时,单分片失败重传的成本也可控。如果分片设得太小,比如1MB,一个10GB的文件会拆成超过一万个分片,请求数量剧增,服务端日志、连接切换、落盘操作的IO开销会被瞬间放大。如果分片设得太大,比如50MB,网络稍有抖动,一个分片传很久然后失败,重传成本又太高,断点续传的粒度就失去意义了。
代码里可以这样动态设置:
// 文件超过2GB,走5MB分片;否则用2MB分片 var chunkSize = file.size > 2 * 1024 * 1024 * 1024 ? 5 * 1024 * 1024 : 2 * 1024 * 1024;这个逻辑虽然不复杂,但背后有一个容易被忽略的点:分片读取并不是一次性把整个文件读进内存的。Blob.slice切出来的是文件数据的一个引用范围,我基于这个特性做增量读取,再把数据喂给加密或者校验逻辑。很多人在大文件上传时把页面搞崩,就是因为在MD5计算阶段一次性读取了整块大文件,内存直接爆掉。
2.2 文件指纹与“秒传”背后的逻辑
断点续传不能只靠文件名加文件大小判断,这是我在实际项目中踩过坑之后才彻底想明白的。一开始我觉得,同一个文件,名字一样,大小一样,肯定就是同一个呗。但卫星视频这场景太特殊了,同一个文件名、同样大小的文件,内容可能已经被运维人员手动修复过关键帧,也可能在拷贝过程中产生了坏块,如果不做内容级校验,服务端就可能把错的文件当成已上传,导致后面合并出来的视频根本打不开。
所以必须用内容指纹。我用的是spark-md5这个库,它支持增量计算,可以把文件按分片一块一块喂进去,最后生成一个全文件的哈希值。这个哈希值有三个用途:唯一关联上传记录、实现“秒传”、作为最终完整性校验的基准。
所谓“秒传”,就是前端先把整个文件的MD5算出来,发到服务端查一下这个文件是不是已经完整存在。如果存在,前端直接跳过整个上传流程,把当前任务标记为成功,几秒钟内完成“上传”。这个功能在卫星视频场景里特别实用,因为外场经常要重复回传同一段测试片段,有了秒传,后面再传同源数据几乎是零等待。
2.3 断点续传的“三层校验”设计
断点续传听起来简单,无非就是跳过已传的分片,但我在路由设计上做了三层校验,每一层对应不同的容错目标。
第一层是全文件MD5校验。上传开始之前,先拿全文件哈希去服务端确认,如果文件已经完整存在,直接秒传;如果存在上传中的任务,就返回已收到的分片列表,前端把列表缓存下来,用来做跳过判断。
第二层是分片级MD5校验。每个分片在发送前也会计算一个小MD5,随着表单一起提交到服务端,服务端接收完成后校验分片数据是否完整,确认无误才落盘。这样做能捕捉到传输过程中比较隐蔽的数据损坏,比如网络设备导致的数据包重写、磁盘坏道引起的写入偏差。
第三层是分片序号加总大小校验。服务端合并文件时,必须按分片序号严格排序,合并完成后比对总大小和全文件MD5,任何一个分片缺失或内容不对,立即返回明确错误码,前端收到后自动重传缺失分片。
这三层校验组合起来,形成一个比较完整的闭环:前端判断、服务端分片落盘、合并前整体校验,每一层都在前面一层的基础上增加了一道保险。
3. 实操过程:WebUploader改造的关键代码与配置
3.1 初始化配置:chunked、chunkSize、threads这些参数怎么调
改造的第一步是正确初始化WebUploader。我直接贴一份生产环境可用的配置,然后逐个参数讲为什么这么设:
var uploader = new WebUploader.Uploader({ swf: 'vendor/webuploader/Uploader.swf', server: '/api/upload/chunk', pick: '#picker', accept: { title: '卫星视频文件', extensions: 'mp4,mov,avi,ts,mxf,m2ts' }, threads: 3, chunked: true, chunkSize: 5 * 1024 * 1024, fileVal: 'file', compress: false, duplicate: true, runtimeOrder: 'html5,flash' });threads参数是并发上传数,我建议设在3到5之间。并发数太小,带宽利用率上不去;并发数太大,比如10个并发同时传10个分片,服务端线程池和磁盘IO会被瞬间打满,而且弱网环境下大量超时重试反而会降低整体速度。实测下来,3个并发配合5MB分片,在常见内网带宽下表现最稳。
chunked必须设为true,这是分片上传的总开关。chunkSize对应我们前面分析的分片大小。compress必须设为false,否则WebUploader会对图片或视频做前端压缩处理,对原始卫星视频来说这是不可接受的破坏操作。duplicate设为true是为了允许选择同名文件,因为有些场景确实会用相同文件名传不同内容,不能一竿子打死。
runtimeOrder表示运行时优先级。正常环境下永远走html5,老浏览器自动降级到flash。这个数组顺序不能反,因为HTML5运行时对文件API的原生支持是现在和未来的主流。
3.2 文件指纹计算实现
用spark-md5计算全文件MD5的代码,网上有很多版本,但不少是一把梭把整个文件读完再算,对大文件完全不可用。我这里的版本是增量式的:
var SparkMD5 = require('spark-md5'); var blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice; function computeFileMD5(file) { var chunkSize = 5 * 1024 * 1024; var chunks = Math.ceil(file.size / chunkSize); var currentChunk = 0; var spark = new SparkMD5.ArrayBuffer(); var reader = new FileReader(); return new Promise(function (resolve, reject) { function loadNext() { var start = currentChunk * chunkSize; var end = start + chunkSize >= file.size ? file.size : start + chunkSize; reader.readAsArrayBuffer(blobSlice.call(file, start, end)); } reader.onload = function (e) { spark.append(e.target.result); currentChunk++; if (currentChunk < chunks) { loadNext(); } else { resolve(spark.end()); } }; reader.onerror = function (e) { reject(e); }; loadNext(); }); }这段代码的核心思路是:每次只把5MB数据读进内存,算完立即释放交给下一段,内存占用稳定在很低的水位。一个1GB的文件,用增量式计算在普通工控机上大约需要几秒到十几秒不等,取决于CPU性能,但页面全程流畅,不会出现假死。
有一个使用技巧:MD5计算过程中要给用户展示进度,不要干等。可以把currentChunk除以chunks作为进度回传给上传界面,这样用户在等待时知道系统在干活,而不是以为卡死了。
3.3 断点续传插件开发:before-send-file加before-send加skip回传
WebUploader的断点续传核心逻辑其实是围绕两个事件钩子展开的:before-send-file和before-send。前者在准备上传某个文件的第一个分片之前触发,后者在发送每一个分片之前触发。
利用这两个钩子,我把断点续传逻辑这样实现:
var fileHash = ''; uploader.on('before-send-file', function (file) { var deferred = WebUploader.Deferred(); computeFileMD5(file.source).then(function (hash) { fileHash = hash; file.md5 = hash; $.ajax({ url: '/api/upload/status', data: { md5: hash }, method: 'GET', dataType: 'json' }).then(function (res) { if (res.uploaded) { // 全文件已经传过,直接秒传 file.uploaded = true; deferred.resolve(); } else { // 保存服务端返回的已传分片列表 file.uploadedChunks = res.uploadedChunks || []; deferred.resolve(); } }).fail(function () { // 查询失败时不应该阻断上传,让分片正常重传 file.uploadedChunks = []; deferred.resolve(); }); }); return deferred.promise(); }); uploader.on('before-send', function (block) { var file = block.file; if (file.uploaded) { return; // 秒传场景直接放行 } var deferred = WebUploader.Deferred(); // 如果服务端返回的已传分片列表里包含当前分片序号,则跳过 if (file.uploadedChunks && file.uploadedChunks.indexOf(block.chunk) >= 0) { // 这里返回'skip'字符串是WebUploader约定的跳过分片信号 deferred.reject('skip'); return deferred.promise(); } // 正常上传分片,动态携带分片元信息 uploader.options.formData = { md5: fileHash, chunk: block.chunk, chunks: block.chunks, chunkSize: block.end - block.start }; deferred.resolve(); return deferred.promise(); });这里有个非常关键的小细节:deferred.reject('skip'),注意返回的参数是字符串skip,而不是简单的reject。WebUploader的内部实现会对这个特定的返回值做判断,遇到skip就会跳过当前分片的上传,直接进入下一个分片。很多人在改造时在这里踩坑,参数写错或者类型不对,导致跳过分片逻辑完全不生效。
服务端的设计也要配套。我总共搭建了三个接口:状态查询接口GET /api/upload/status,接收分片接口POST /api/upload/chunk,触发合并接口POST /api/upload/merge。其中状态查询接口返回的内容结构大致是这样的:
{ "uploaded": false, "uploadedChunks": [0, 1, 2, 5, 6] }这个返回说明:文件没有完整上传过,但分片0、1、2、5、6已经收到过。前端拿到这个列表后,下一次续传就只传缺失的分片3、4以及后面的分片。
3.4 服务端分片落盘和合并的注意事项
服务端虽然是后话,但合并逻辑的质量直接决定前端插件好不好用。这里我强调三个点。
第一,接收分片时必须以md5作为目录维度、以chunk序号作为文件名,把分片物理隔离存储。目录结构类似/tmp/upload/{md5}/{chunkIndex}。这样多个分片无论按什么顺序到达,最终合并时都能按序号直接读取,天然支持乱序到达。
第二,合并时不要把所有分片一次性读入内存再拼接。用一个512KB的缓冲流,从分片0开始按顺序循环读取写入到目标文件,避免大文件合并时服务端内存OOM。合并完成后删除临时分片目录和分片索引记录。
第三,合并完成后计算整个目标文件的MD5,和前端传过来的全文件MD5做比对。只要不一致,就要返回明确的错误码,前端收到这个错误码后自动开启全量重传或按缺失分片重传。这一步是质量保证的最后一道关卡,绝对不能省。
4. 跨浏览器兼容的设计与实现
4.1 runtimeOrder与Flash/HTML5降级策略
WebUploader在跨浏览器上最核心的设计就是Runtime抽象。同一个上传逻辑,HTML5运行时底层用XMLHttpRequest加FormData加Blob,Flash运行时底层用Flash上传组件,对外暴露的API完全一致。所以只要初始化配置里runtimeOrder写清楚了,组件本身就已经具备多浏览器适配的基础。
但在军工内网这个环境里,Flash的兼容性有很现实的问题:大量安全策略明确禁用Flash,而且新版浏览器已经完全移除Flash支持。所以我把降级策略分了三档:第一档是标准HTML5环境,走WebUploader原生的html5运行时;第二档是老的IE内核环境,能加载Flash就加载Flash兜底;第三档是两者都不支持的环境,页面给出明确提示,引导用户换用其他终端,而不是默默地在页面上加载一个根本用不了的控件。
这里有一个小的实现细节:初始化的时候我加了运行时探测逻辑,通过特性检测判断当前浏览器支持哪些能力,如果只支持Flash运行时且失败,就主动在界面上提示“当前环境不支持断点续传上传,请使用Chrome、Edge或安装补丁后的国产化浏览器”。这类提示对用户非常友好,能省掉大量远程排查的时间。
4.2 国产化浏览器与内核适配
军工和政府行业现在的终端环境越来越统一,越来越多的单位换成了国产化操作系统加国产浏览器。实际跑下来,麒麟系统配奇安信浏览器、360安全浏览器这些,绝大多数版本底层都是Chromium内核,HTML5支持度其实不错,WebUploader的html5运行时可以直接跑。
真正的坑在于一些早期版本的国产浏览器,内核比较旧,对File API的支持存在兼容问题,或者某些双核浏览器在兼容模式下默认走IE内核,导致分片上传异常。我的处理原则是:不看UA,只看能力。在页面加载后用几行代码做特性检测:
var isSupportHtml5Upload = ( window.File && window.Blob && window.FileReader && window.FormData && !!(Blob.prototype.slice || Blob.prototype.mozSlice || Blob.prototype.webkitSlice) );如果isSupportHtml5Upload为false,就不初始化WebUploader,直接显示兼容性提示。这个方案比UA判断可靠得多,因为国产浏览器型号太多,UA写什么的都有,但能力是真实存在的。
4.3 FileReader、Blob.slice与超大文件的兼容性处理
跨浏览器改造中,最坑的细节之一就是Blob.slice的来源兼容。早期WebKit内核里,Blob.slice是带浏览器前缀的,需要写成mozSlice或webkitSlice。我在MD5计算代码里专门写了这样一段兼容逻辑:
var blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;这样就覆盖了从老IE内核到新Chromium的大部分情况。
超大文件还有一个隐形门槛:老内核的浏览器对FormData上传的大小是有限制的,有的版本在提交超过2GB的文件时会出现参数丢失甚至文件损坏。幸好我们的架构走了分片,每次只提交5MB,从根上绕开了这个限制。这也是分片方案除了断点续传之外带来的额外好处。
我建议在项目里保留一个特殊能力检测:判断当前浏览器是否支持2GB以上文件的大小读取和分片切片操作。如果浏览器对File对象的size属性都返回不了正确数值,那说明这个终端确实太老了,再怎么兼容也没有意义,直接提示换终端。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
做这个插件前前后后也调了小半年,各种奇怪的问题都遇到过。我把最高频的几个整理成了一张速查表,方便大家直接对着排查。
| 现象 | 可能原因 | 排查思路 | 解决办法 |
|---|---|---|---|
| 文件传完后无法打开或画面黑帧 | 合并顺序错乱,分片内容损坏 | 检查服务端合并日志,确认是否严格按chunk序号排序;随机抽查分片MD5 | 合并前校验每片MD5,合并后比对全文件MD5 |
| 断点续传时老是跳过不该跳过的分片 | 状态查询接口返回了错误的分片列表 | 抓前端请求,查看before-send查询的分片参数和服务端返回 | 状态接口按md5精确查询,前端缓存后及时清理 |
| MD5计算时页面卡死 | 一次性读取了大块文件,内存被打满 | 打开浏览器任务管理器观察内存占用 | 改用增量式MD5计算,每次只读5MB |
| 网络中断后重试又从零开始 | 断点续传逻辑没生效 | 抓包看重新上传的请求是否带chunk参数,是否先调用了status接口 | 检查before-send钩子是否正确地return了deferred.promise |
| 新版浏览器上报Flash相关错误 | Flash已停止支持,但runtimeOrder仍优先加载flash | 查看运行时加载日志和console报错 | 将runtimeOrder改为html5优先,彻底屏蔽flash |
| 中文文件名传到服务端乱码 | 前端与后端编码不一致 | 抓包看Content-Disposition头里的文件名编码 | 前后端统一UTF-8,不要手动encodeURI后再解码 |
| 并发上传时服务端文件顺序错乱 | 并发数过高,分片不是按序号顺序到达 | 查看服务端接收日志确认接收顺序 | 服务端落盘按chunk序号命名,合并时统一排序,不依赖到达顺序 |
5.2 排查心得:日志一定要按“分片级”打
排查大文件上传问题,最怕的就是日志太粗。只记录“某某文件上传失败”这种级别,基本等于没记。我在改造过程中给前端和后端都加了分片级日志,前端在before-send、uploadSuccess和uploadError事件里输出当前文件的md5、分片序号、总分片数、请求耗时,后端在接收分片和合并时对称地记录同样的字段。一旦某次上传出了问题,两边日志一比对,几秒钟就能定位到是哪个分片出了问题,是前端没发送,还是服务端没接收,还是合并时写错了位置。
这个习惯帮我节省了大量时间。尤其是断点续传这类涉及状态同步的功能,日志只要模糊一点,排查成本立刻翻倍。
5.3 上线前一定要做的几个实测
插件开发完成后,我强烈建议多做几种破坏性测试,而不是只在好用的测试环境里传一个小文件就宣布完成。
第一,一定要用真实的几十GB大型文件做一次完整上传,验证合并后的文件用视频播放器能否正常播放,能不能正确地seek到中间某个时间点。文件的完整上传不代表内容一定能解码,这两个标准之间还有一段距离。
第二,一定要模拟断网场景。打开浏览器开发者工具的Network面板,把网络切到Offline,等几秒再恢复,触发一次断点续传,反复做几十次,确认每次都能只补传缺失的分片,而不是从头开始。
第三,一定要测试并发上传时服务端的合并结果。把threads调成5甚至8,多并发上传一个多个分片的文件,再用全文件MD5去和服务端合并出的文件MD5做对比,确保并发不影响最终结果的正确性。
第四,如果项目允许,还可以做一次浏览器兼容性矩阵测试,把内网里常见的浏览器型号、版本都过一遍,至少确认每个型号都能走到正确的运行时分支。
我在实际项目里最深刻的体会是:大文件上传插件最重要的不是“功能多”,而是“出错后还能不能兜得住”。WebUploader本身给你提供了一个很好的底座,但真正的价值是通过before-send和before-send-file这些钩子,把断点续传和完整性校验这些业务逻辑做扎实。卫星视频这类数据,传得慢一点没关系,传得稳、传完之后确定能用,才是最核心的底线。希望我这次改造的经验能给你节省一些排查的时间。