
最近在做一个内网项目需求一句话就能说完浏览器里上传卫星视频文件单文件少说几个GB多则上百GB断网、断电、刷新浏览器都不能让用户从头来过还得适配从IE老古董到最新Chrome的全套浏览器。刚开始我天真地以为这就是给WebUploader加两行配置的事真上手才发现原生WebUploader在“超大附件跨浏览器断点续传”这个组合拳面前撑不过三个回合。这篇文章把我改造WebUploader、实现跨浏览器超大附件分片断点续传插件的完整过程写下来包括方案取舍、源码改动点、服务端协议设计以及十几次实测才发现的坑。如果你正在做政企、军工、广电类的上传需求这篇应该能帮你少走不少弯路。1. 为什么原生WebUploader扛不住卫星视频需求场景与能力边界1.1 卫星视频上传场景的真实形态先说清楚这个需求到底长什么样。卫星视频和普通视频文件不一样格式五花八门TS、MXF、MP4都有码率高单文件动辄5GB起步我这边接到的文件最大的到了180GB。这些文件从采集设备拷贝到办公电脑再通过浏览器上传到数据管理平台中间还有一系列人工整理动作用户不太可能用FTP或者专用客户端浏览器上传是他们唯一接受的操作方式。客户端环境的复杂度也超出预期。明明已经是2024年了工作站里依然是Windows 7 IE11占了一大片另外一批新机器是Win10 Chrome/Edge。网络条件看着是内网千兆但经过多层交换设备和安全网关之后实际有效带宽只有几十Mbps到几百Mbps而且并不稳定断流是常态。这个场景下用户的核心诉求只有三个第一能传超大的文件第二传一半断了能接着传而不是从头再来第三不管用什么浏览器操作方式一致。这三个诉求恰好全部踩在原生WebUploader的能力边界之外。1.2 原生WebUploader的真实能力边界WebUploader在中小文件上传场景里确实好用但它的定位决定了它没有为超大文件做专门优化。我翻了源码几个关键限制非常明确fileSizeLimit默认值是2GB超过直接拦截必须在初始化配置里改成足够大的值默认分片大小chunkSize是5MB对于GB级文件会产生海量分片请求服务端根本扛不住它所谓的“分片上传”只解决了“把大文件切成小块传”的问题没有提供断点续传能力页面一刷新之前传的分片全部作废没有秒传概念同一个文件重复上传依然全量重新传一遍对超大文件的并发控制和内存管理没有专门优化线程数开高了容易把浏览器搞崩。我举个具体的数一个50GB的文件用默认的5MB分片会产生10240个分片请求。服务端每收到一个分片就要打开文件、写入数据、关闭文件一万多个分片排队合并光文件句柄的开销就能把服务拖垮。所以结论很直接原生WebUploader只能当一个基础框架用真正解决超大附件上传必须在它之上做大量改造。2. 改造前的方案设计分片策略、并发参数和服务端协议怎么定2.1 分片大小不是拍脑袋定的是一步步算出来的分片大小是这次改造里最重要的一个参数。分片太小请求数量爆炸分片太大一个分片传半天断了重传的成本也高。我根据内网环境的实测数据推算单连接的有效传输速度大概在7.5MB/s到30MB/s之间取一个中等偏保守的值10MB/s来算。结合“单个分片在网络上的传输时间控制在5到10秒”这个目标分片大小取50MB比较合适。10MB/s的单连接速度下一个50MB分片需要5秒左右配合4个并发整体吞吐可以做到接近30MB/s。50GB的文件切成1000个分片服务端合并时的排序开销完全可控。同时要注意一个细节Blob.slice切割出来的分片大小并不总是精确等于设定的chunkSize最后一个分片通常会小于设定值处理时不能写死长度必须用实际的分片长度去计算和处理。2.2 并发数受制于浏览器连接池并发数不建议无脑调高。HTTP/1.1协议下浏览器对同一域名的并发连接数限制一般是6个其中还包括页面本身加载资源的连接。如果并发开8个页面其他请求就只能排队等待上传反而变慢还会出现资源加载超时的现象。我最后把threads配置成4再加上页面本身的请求正好能卡在6个连接的临界值以内。如果是HTTP/2协议环境可以放宽到8但为了兼顾老系统保持4最稳妥。2.3 服务端协议重新设计一个接口拆成四个原生WebUploader只接收一个server上传地址断点续传做不到。改造的第一步就是把上传链路拆成四个接口接口方法作用关键参数/api/upload/initPOST初始化上传建立上传任务fileName, fileSize, fileMd5/api/upload/statusGET查询已上传的分片列表fileMd5/api/upload/chunkPOST上传单个分片uploadId, chunkIndex, chunkMd5, file/api/upload/mergePOST触发分片合并与整体校验uploadId, totalChunks这个协议设计的关键是幂等性。同一个chunkIndex允许重复提交服务端通过chunkMd5判断这个分片是否已经存在存在就直接返回成功不重复写盘。有了幂等性前端在断网重试时就可以放心大胆地重发分片不用担心产生脏数据。另一个关键点文件指纹fileMd5必须在初始化时传过去服务端用它来关联同一个文件的多次上传尝试。后面接续上传时前端拿到同样的fileMd5去查status接口就能知道哪些分片已经传过了。2.4 为什么还是选WebUploader而不是重写一个上传组件方案评估的时候我也认真考虑过Resumable.js、Plupload以及完全基于XMLHttpRequest自己写一套但最后仍然选了WebUploader作为底座。最大的原因是WebUploader把HTML5和Flash两套运行时统一成了一组API。这个能力在当前的项目场景里是稀缺的因为还要兼容老IE浏览器。Resumable.js只支持HTML5老浏览器直接没法用Plupload虽然支持Flash但架构偏底层分片续传还是得自己写一堆逻辑。与其从零开始踩一遍浏览器兼容的坑不如把WebUploader当作一个经过验证的运行时抽象层在其上做业务增强。封装方式上我没有把改造逻辑散落在页面代码里而是封装成一个独立的SatVideoUploader类对外暴露init、upload、cancel、retry、getProgress几个方法内部管理WebUploader实例。这样页面侧调用很简单将来换底座或者扩展新能力也容易。3. 断点续传与秒传的实现链路指纹、跳过逻辑和服务端状态查询3.1 文件指纹计算全量还是抽样这是个安全取舍断点续传的前提是能唯一标识一个文件。文件名不靠谱同一个文件在多次上传时可能被重命名或者不同用户手里的文件名字完全不一样。我用的是MD5指纹。计算方式上有一个安全取舍。全量MD5非常可靠但50GB的文件逐字节读完再计算即使放进Web Worker后台处理耗时也可能达到5到10分钟抽样MD5只取头、中、尾几个分片的数据计算几秒钟就能出结果但理论上存在碰撞风险。军工场景对这种完整性校验要求很高我最终采用了一个折中方案前端全量计算MD5作为主指纹同时在服务端合并完成后对整文件计算SM3摘要做最终比对。SM3是国家密码算法合规性更好但前端JS计算结果太慢所以让服务端来做这一步前端用MD5只是用来做断点续传的关联查询。计算过程放在Web Worker里避免阻塞浏览器主线程。代码大概长这样// md5-worker.js importScripts(/static/js/spark-md5.min.js); self.onmessage function(e) { var file e.data.file; var chunkSize 10 * 1024 * 1024; var blobSlice File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice; var chunks Math.ceil(file.size / chunkSize); var spark new SparkMD5.ArrayBuffer(); var currentChunk 0; var reader new FileReader(); reader.onload function(event) { spark.append(event.target.result); currentChunk; if (currentChunk chunks) { loadNext(); } else { self.postMessage({ md5: spark.end() }); } }; function loadNext() { var start currentChunk * chunkSize; var end Math.min(start chunkSize, file.size); reader.readAsArrayBuffer(blobSlice.call(file, start, end)); } loadNext(); };主线程的调用逻辑需要注意一个点在Web Worker里不能直接传File对象给postMessage某些浏览器会报错。我的做法是把File对象传过去因为结构化的克隆算法实际上是支持File的但在老版本浏览器上表现不一致稳妥起见我在主线程先把文件的size和name取出来传给Worker然后在Worker里通过file对象操作。实测下来这个方案在Chrome和Firefox里都能正常工作。3.2 查询已完成分片并跳过续传的核心逻辑拿到MD5之后前端调用/api/upload/status接口服务端返回该文件已完成的分片索引数组。前端要做的事情很明确把已完成的分片状态标记为done这样WebUploader在遍历分片时就会自动跳过它们。这里有一个流程控制上的关键细节。MD5计算是异步的耗时可能几分钟而WebUploader默认会在文件进入队列后自动开始上传。如果不做控制会出现“分片都传了一半了MD5还没算完”的竞态。我的解决办法是初始化时设置auto: false等待MD5计算完成、状态查询返回之后再手动调用uploader.upload()。跳过逻辑的代码实现uploader.on(fileQueued, function(file) { // 暂停队列等待指纹计算完成 uploader.stop(true); computeFileMd5(file).then(function(md5) { file.__md5 md5; return fetch(/api/upload/status?fileMd5 md5); }).then(function(res) { return res.json(); }).then(function(data) { var doneChunks data.doneChunks || []; doneChunks.forEach(function(index) { var chunk file._chunks[index]; if (chunk) { chunk.state done; file.uploadedChunks; } }); // 全部传完就直接触发合并 if (file.uploadedChunks file._chunks.length) { triggerMerge(file); return; } uploader.upload(file); }); });file._chunks是WebUploader内部的分片数组每个分片对象有一个state属性取值为pending、uploading、done、error。把已上传的分片状态直接改成done是最简单可靠的跳过方式不需要去动WebUploader的上传主循环后续版本升级也相对不敏感。3.3 秒传的实现所有的分片都在就不传了秒传其实是断点续传的一个特殊情形当status接口返回的已完成分片数等于总分片数时说明这个文件之前已经完整传过了前端不需要再传任何一个分片直接调用merge接口就行。这里要注意的是服务端必须对这种情况做防御即使前端错误地把所有分片都重传一遍服务端根据chunkMd5去重也不会产生重复数据。最终合并时以文件指纹为粒度加锁同一时刻只允许一个合并任务操作同一个文件避免并发覆盖。3.4 进度计算让用户看到真实的百分比进度条是用户感知断点续传价值最直观的东西。WebUploader的uploadProgress事件只返回当前文件的上传进度但这个进度没有考虑“已经跳过的分片”。如果不做处理用户会看到进度条从0开始到了某个位置突然跳到100%体验非常割裂。我的处理是重写进度计算逻辑uploader.on(uploadProgress, function(file, percentage) { var skipCount file._chunks.filter(function(c) { return c.state done; }).length; var totalChunks file._chunks.length; var realPercentage skipCount / totalChunks (percentage * (totalChunks - skipCount) / totalChunks); updateProgressBar(realPercentage); });合并阶段也要单独处理。50GB文件的合并可能需要几分钟前端在调用merge接口后改用轮询方式查询合并任务状态界面显示“服务端正在合并文件”并提供已合并的分片数或百分比避免用户误以为卡死了。4. 跨浏览器兼容层HTML5运行时和Flash回退的细节屏蔽4.1 WebUploader的运行时切换机制WebUploader内部通过Runtime机制屏蔽浏览器差异。它会先检测浏览器能力如果支持FileReader、Blob.slice、FormData这些HTML5 API就启用HTML5运行时否则看有没有Flash插件有就回退到Flash运行时两者都没有直接提示不支持。这个机制本身很成熟但在超大文件场景下两个运行时表现出来的能力差异非常大。如果只是当成一个黑盒来用会踩到很多隐蔽的坑。差异点HTML5运行时Flash运行时并发上传支持多线程并发单线程串行超大文件支持支持64位大小200GB没问题对4GB以上文件读取不稳定跨域请求支持CORS不支持跨域直接失败分片读取原生Blob.slice稳定由Flash模拟实现性能差文件信息获取完整包含size和type部分浏览器只给sizetype为空4.2 兼容层要处理的具体差异点Flash运行时最烦人的问题是并发。如果初始化配置里写死threads: 4在Flash模式下WebUploader会自动把并发降到1这个不需要额外处理但你要有心理预期老浏览器上的上传速度会非常慢用户需要耐心等。跨域问题更棘手。Flash上传有跨域限制如果前端页面和上传接口不在同一个域在HTML5模式下通过CORS配置能解决但在Flash模式下请求根本发不出去。客户端环境里页面系统和文件存储服务有可能做了域隔离我的处理方法是前置一个Nginx反向代理把/api/upload/*代理到实际的上传服务让浏览器始终请求同源的地址。还有一个隐蔽的兼容点老IE环境里文件的name属性可能不包含扩展名。比如用户选了一个video_20240101.ts但file.name返回的是video_20240101没有.ts后缀。处理办法是在beforeFileQueued事件里根据文件的type字段视频格式通常能识别出来补全扩展名或者干脆不依赖前端文件名由服务端根据上传时的originalName参数做处理。4.3 Flash模式在超大文件上的使用边界这里必须说一个底层的技术事实Flash运行时在读取大于4GB的文件时经常出现读取失败或读取到错误数据的情况这是Flash播放器自身的限制不是WebUploader能解决的。所以我在插件里加了一个判断逻辑单文件大于4GB且当前运行在Flash模式下时弹窗提示用户此文件建议使用Chrome或Edge浏览器上传并提供普通上传之外的备选方案。这不是推卸责任而是避免用户花一两个小时传到一半Flash读取崩溃导致前功尽弃。产品层面接受这个降级策略。4.4 中文文件名和编码问题的处理中文文件名是另一个高频坑。老浏览器在提交文件时中文文件名用本地编码传输服务端如果不统一编码格式会出现乱码严重的直接导致分片关联失败。我的处理是在前端把文件名做一次URL编码放进formData一起提交服务端拿到后再做URL解码uploader.on(uploadBeforeSend, function(block, data) { data.originalName encodeURIComponent(block.file.name); data.uploadId uploadId; data.chunkIndex block.chunk; data.chunkMd5 block.file.__chunkMd5s[block.chunk]; });服务端在merge阶段用uploadId关联之前存储的元信息而不是直接用文件名作为拼接依据。这样即使文件名里有特殊字符也不会影响分片合并。5. 军工场景的可靠性与合规加固校验、加密和审计日志5.1 分片级校验与整文件校验的双层保障超大文件传输最怕一件事文件传完了但数据在传输过程中发生了静默损坏。网络层的TCP校验能挡掉一部分但应用层的数据完整性校验必须有。我在设计里做了两层校验。第一层是分片级每个分片在上传前前端用SparkMD5计算这个分片的MD5值随请求一起提交服务端收到分片后先算一次MD5不一致就直接拒绝并返回错误码让前端重传这个分片。第二层是整文件级所有分片合并完成后服务端对完整文件计算SM3摘要和前端计算的文件MD5做交叉校验不一致就把文件移到隔离目录禁止进入正式存储。双层校验的代价是额外的CPU和IO开销但在军工行业的合规审计要求面前这个代价是必须付出的。5.2 传输链路与临时数据的安全隔离传输链路上前端到服务端之间通过HTTPS加密内部网络再经过加密网关。分片上传的临时文件存放在专门的隔离目录操作系统权限只开放给上传服务本身业务系统其他模块无权访问。合并校验完成后文件才被移动到正式的存储区域。前端本地不持久化任何文件内容只保存分片索引和文件指纹。localStorage里存的也是脱敏后的信息不包含文件实际数据。这样即使浏览器被其他网站拿到也无法通过本地残留数据还原已经上传的文件内容。5.3 审计日志与重试策略军工项目的运维审计要求每个关键动作都可追溯。我在上传链路的每个关键节点埋了点文件入队时间、操作用户、文件大小指纹计算完成时间、MD5值每个分片的上传开始、成功、失败时间线合并任务的开始、结束、结果、耗时失败重试的记录和最终状态。这些日志统一结构化输出方便后续接入审计平台。重试策略上分片上传失败后做指数退避重试第一次间隔2秒第二次间隔4秒最多重试5次。WebUploader自带的重试机制比较简单默认失败就直接标记错误我通过监听uploadError事件接管了重试逻辑重试耗尽后再弹出明确的错误提示。6. 实测中的坑并发调优、Flash短板的真实数据6.1 并发线程数不是越大越好我在测试环境做了一组并发对比实验测试条件是服务端百兆内网、客户端千兆接入、50GB测试文件、50MB分片、HTML5运行时。threads平均上传速度浏览器内存占用稳定性322MB/s450MB稳定428MB/s520MB稳定630MB/s680MB偶发卡顿828MB/s850MB内存告警页面响应迟缓结论很明显线程数从4调到8速度几乎没有提升内存却涨了60%以上因为每个分片读取时都会在内存里保留一个ArrayBuffer副本。4并发是这个场景下的甜点位。6.2 服务端合并接口的耗时问题第一次联调时50GB文件的合并操作直接在HTTP请求里同步执行结果前端等了120秒后连接超时服务端合并任务被中断下一次调用时又从头开始合并。这个问题的解法是异步化merge接口收到请求后立即返回一个taskId合并过程在后台单独跑前端通过轮询/api/upload/merge/status?taskIdxx获取合并进度。合并完成后再通知前端跳转下一步。这样即使轮询断掉也不影响后台合并任务继续执行。6.3 localStorage容量告警和索引丢失问题断点续传的已上传分片索引我最开始放在localStorage里。后来发现两个问题第一存储空间有5MB的上限分片索引数组几百个就占一半空间了第二多个文件同时上传时localStorage的key是全局共享的不同文件之间会互相覆盖。解决方案是localStorage的key用upload_progress_{fileMd5}的格式避免冲突考虑到5MB上限只存储小于200字节的摘要信息分片索引用压缩后的二进制字符串存储。如果文件分片数实在太多超过存储容量的临界值就降级为“刷新后从最后一个分片继续传”虽然做不到精确跳过但总比完全从零开始强。6.4 匹配文件类型与分片重传的边界条件最后补一个容易被忽略的边界用户在上传过程中手动刷新了页面重新选择同一个文件后fileMd5没有变status接口正常跳过了已完成分片。但如果用户选的是另一个同名文件、大小也相同、只是内容变了这时两个文件的MD5不一样前端会当成一个新文件上传服务端需要根据fileMd5和fileSize的组合来区分初始化时把这两个参数都传过去。整个项目做完我的体会是WebUploader只是一个起点真正做好大文件断点续传功夫在前端之外。分片策略、服务端协议、兼容降级、校验审计这些设计好了插件才能在复杂的真实环境里站得住。最后再分享一个小细节不要只盯着“上传完成”这个结果把每个文件的上传耗时、重传次数、断点恢复次数都记录成指标后期调优全靠这些数据说话。