这几十年里,我经手过很多文件上传类的项目,但真正让我觉得"上传组件这东西不能拿来就用"的,是那次军工行业的卫星视频接入任务。客户环境是涉密内网,数据不能出域,终端五花八门,单个视频文件动辄十几GB,偶尔还有几十GB的主战装备试验记录。最开始拿原生WebUploader直接上,传一半网络抖动,整个上传任务报废,只能从头再来。那种挫败感,相信做过大文件传输的人都懂。
后来我基于WebUploader做了一整套改造,核心就是把分片上传和断点续传真正打通,适配国产化终端和各类浏览器,最后封装成独立插件,业务系统接入成本极低。这篇文章就把整个改造过程、踩过的坑、还有前后端联调时容易忽略的细节一次说清楚。如果你也在做超大附件上传,尤其是政企内网、卫星遥感数据、视频监控录像这类对完整性和稳定性要求极高的场景,这套方案可以直接参考。
1. 军工卫星视频上传的真实痛点:为什么现成组件接不住
先说需求。卫星视频和普通文件不一样,它的单文件体量大、编码格式特殊、对完整性要求极高。一个小时的卫星视频,码率稍高一点就是几个GB起步,长周期的遥感影像拼接视频甚至能到上百GB。这种文件在公网上传都费劲,放到带宽受限、还经常有网络波动的涉密内网里,难度直接翻倍。
我踩的第一个坑,是把WebUploader原版配置完就丢给测试了。当时想着WebUploader本身支持分片上传,应该能扛住。结果测试环境模拟了一次断网,网页一刷新,上传记录全部丢失,文件又从0%开始。业务方直接急了——几十GB的文件,传一次要好几个小时,断一次就全白费,这活儿根本没法干。
1.1 需求拆解:不是一个上传组件,是一套可靠传输方案
把这个需求拆开看,真实要解决的问题有这么几层:
- 超大文件:必须分片,不能整文件上传。服务端和浏览器都有内存限制,整传必挂。
- 断点续传:网络中断、浏览器崩溃、电脑重启后,上传任务能从中断位置继续,而不是从头再来。
- 跨浏览器:军工内网终端环境复杂,Chrome、Firefox、旧版Edge、国产化信创浏览器(基于Chromium内核的麒麟、UOS等)都要能跑。
- 完整性校验:卫星视频传完后,必须确认每一个分片都正确、合并后的文件没有损坏。视频文件一旦数据损坏,后期解译分析就全废了。
- 涉密安全:全程在内网环境运行,不能依赖任何外部服务,所有逻辑必须可控可审计。
这些需求叠加在一起,直接套现成组件是不够的。WebUploader本身只是个上传库,它给了你分片、并发的壳子,但断点续传的服务端配合、文件指纹校验、失败重试策略、国产化浏览器兼容,都要自己补齐。
1.2 为什么坚持在WebUploader基础上改,而不是重写
你可能觉得,WebUploader官方都停止维护好几年了,为什么不干脆用Resumable.js或者自研一套?我做这个判断的时候,其实有现实的考量。
军工项目的技术选型有一条潜规则:优先用经过验证的技术,而不是最新的技术。WebUploader虽然是老东西,但它的分片机制、上传队列、UI组件都是现成的,项目团队对它也熟悉,维护成本低。而自研一套上传内核,从文件切片、并发控制、进度计算到失败重试,没有一两个月打磨不出一版稳定的,还不算测试成本。
再一个,WebUploader的架构其实很清晰,上传逻辑和UI逻辑是解耦的。我完全可以用它的Uploader实例管理文件队列,把底层的分片读取、MD5计算、续传逻辑替换成自己写的。这样业务系统接入时,前端调用方式基本不变,改动面控制到最小。
这是典型的"换心脏不换外壳"思路。骨架保留,但核心的传输策略、校验机制全部重写。
2. WebUploader原版的边界:哪些能力能用,哪些必须替换
既然决定在WebUploader基础上改造,第一步就是摸清它的家底。我梳理了原版组件的核心能力边界,这里直接给结论。
2.1 原版能做什么
- HTML5 + Flash双内核适配:老版本依赖Flash做降级,不过Flash已死,现在基本只走HTML5路线。
- 分片上传:通过
chunked: true开启,支持配置分片大小chunkSize。 - 并发控制:
threads属性控制同时上传的分片数。 - MD5计算:内置了SparkMD5的封装,可以算文件MD5实现"秒传"逻辑。
- 上传队列:文件选择后进队列,有进度、成功、失败等事件。
这些能力在普通文件上传场景下够用,但放到几十GB的卫星视频面前,就有几个致命短板。
2.2 原版的三大致命短板
短板一:MD5计算方式在超大文件场景下不可用。
WebUploader的MD5计算是一次性加载整个文件来算的。一个30GB的视频文件,加载进内存直接浏览器崩溃,就算内存扛得住,算完MD5也得几十分钟。这期间UI完全卡死,用户根本没法操作。所以原版"秒传"机制在超大文件面前是个摆设,必须换成增量计算或者抽样指纹方案。
短板二:断点续传依赖服务端,而且续传标识不可靠。
WebUploader的续传思路是:上传前计算文件MD5,传给服务端查重,服务端返回已上传的分片信息。这个思路本身没错,但MD5计算问题没解决,续传就无从谈起。另外,文件的唯一标识如果只用文件名,同名文件会在很多业务场景下互相覆盖,需要外加文件大小、修改时间等维度。
短板三:对超大文件的内存管理有缺陷。
原版在上传过程中会持有队列里每个文件的引用,分片数据也常驻内存。传几十个分片可能没事,但传几百个分片时,浏览器的内存占用会一路飙升,到最后直接白屏。这就是典型的"能跑demo,扛不住生产"。
2.3 改造范围界定
我在动手前画了一条明确的边界线:
保留:WebUploader的UI组件、文件队列管理、上传进度事件、文件选择逻辑。这些是成熟稳定的,重写它们纯属浪费时间。
替换:文件MD5计算逻辑(改增量抽样)、分片上传前的断点查询逻辑、分片失败后的重试策略、内存释放机制。
新增:分片大小的动态调整策略、和服务端的check/merge接口约定、断点信息在浏览器本地持久化存储。
这条边界线很重要。没有边界地乱改,会让组件变得不可维护;边界划得太小,又解决不了真正的痛点。按这个范围改造,大概一个月就能出一版能进内网测试的插件。
3. 分片策略与文件指纹:超大文件先算全量MD5是想不开
这一章是整个改造的核心,也是我耗费最多时间反复调优的部分。分片策略和文件指纹算法,直接决定了上传的稳定性和续传的可靠性。
3.1 分片大小:不能固定,必须按文件体量动态调
WebUploader默认的chunkSize是2MB,小文件没问题,但放在30GB的视频文件上,会产生上万个分片。分片数量一多,服务端要为每个分片创建临时文件、记录写入状态,前端也要处理海量的进度事件,效率和稳定性双输。
我按文件大小做了分片动态策略,规则很简单:
| 文件大小 | 分片大小 | 预估分片数 |
|---|---|---|
| < 2GB | 2MB | ≤ 1024 |
| 2GB ~ 10GB | 10MB | 200 ~ 1000 |
| 10GB ~ 50GB | 20MB | 500 ~ 2500 |
| > 50GB | 50MB | 按实际体量 |
这里有一个权衡逻辑。分片越大,分片数量越少,服务端临时文件管理和前端进度事件压力越小,但单分片失败后重试的代价也越大。我实测下来,50MB分片在内网千兆环境下传输很稳,重试成本完全可接受。如果是公网环境,建议分片控制在10MB以内,因为公网丢包率高,大分片失败概率显著上升。
实现上,我在初始化WebUploader之前,根据文件大小动态计算分片配置:
function calcChunkConfig(fileSize) { let chunkSize = 2 * 1024 * 1024; // 默认2MB if (fileSize > 10 * 1024 * 1024 * 1024) { chunkSize = 50 * 1024 * 1024; } else if (fileSize > 2 * 1024 * 1024 * 1024) { chunkSize = 20 * 1024 * 1024; } else if (fileSize > 500 * 1024 * 1024) { chunkSize = 10 * 1024 * 1024; } return { chunked: true, chunkSize: chunkSize, threads: 3 }; }threads并发数我也固定推荐3。原版默认是3,实际上这个值在内外网环境都算均衡。并发太高会占满内网带宽,影响其他业务系统;太低则无法充分利用带宽。
3.2 文件唯一标识:放弃全量MD5,改用组合指纹
文件唯一标识是断点续传的基础,服务端要凭它识别"这是同一个文件"。原版方案是全量计算文件MD5,这在超大文件场景下行不通。我采用的方案是组合指纹:
文件大小 + 文件名 + 最后修改时间 + 抽样分片MD5
这个思路的核心是:用元数据信息快速锁定候选文件,再用少量抽样分片的MD5做二次校验。比如取文件的第一个分片、中间一个分片和最后一个分片,分别计算MD5,拼到一起。这样算一个几十GB文件的指纹,耗时只在毫秒级,因为最多只读取几个分片的数据量。
async function generateFileFingerprint(file, chunkSize) { const sampleCount = 3; // 抽样3个分片 const totalChunks = Math.ceil(file.size / chunkSize); const sampleIndexes = [0, Math.floor(totalChunks / 2), totalChunks - 1 ]; const sampleMd5s = []; const md5 = new SparkMD5.ArrayBuffer(); for (const index of sampleIndexes) { const blob = file.slice(index * chunkSize, (index + 1) * chunkSize); const buffer = await blob.arrayBuffer(); md5.append(buffer); } const sampleHash = md5.end(); return `${file.size}-${file.name}-${file.lastModified}-${sampleHash}`; }这里有个细节:为什么不像原版一样直接对整个文件做MD5?除了性能原因,还有一个可靠性考虑。全量MD5要等整个文件读完才能生成,而在断点续传场景中,我们希望在用户选择文件后、开始上传前,就立刻查询续传信息。抽样指纹可以在几百毫秒内完成,用户几乎无感知。
当然,抽样指纹的碰撞概率比全量MD5高,但对于断点续传这个用途足够了——就算指纹碰撞导致误判,最多是让一个分片被错误跳过,最终合并时我们还有全量校验兜底。
3.3 传输后的完整性校验:全量MD5后置
很多人会忽略这一点:分片上传完成后,怎么确认整个文件没损坏?
我的方案是在服务端合并完所有分片之后,再异步计算合并文件的MD5,并和前端计算的全量文件MD5做比对(全量MD5可以在上传后台线程计算,不阻塞主流程)。如果一致,标记文件上传成功;不一致,触发重新上传整个文件。
这个校验后置的方案,既避开了上传前计算全量MD5的性能问题,又保证了卫星视频这类对完整性要求极高的文件的传输质量。
4. 断点续传实现:前后端联动的完整链路
断点续传不是前端单独能完成的事情,它需要前端和服务端形成一套完整的协议。这一章我把完整的链路拆开讲。
4.1 前后端接口约定
我设计了三组接口,分工非常明确:
- 查询接口
POST /api/upload/check:前端上传前调用,传入文件指纹,服务端返回该文件的md5、已上传分片序号列表。 - 分片上传接口
POST /api/upload/chunk:前端逐个上传分片,分片和文件信息放在表单里。 - 合并接口
POST /api/upload/merge:所有分片上传完成后调用,服务端按分片序号合并临时文件。
接口入参我用的是分片序号(chunkIndex),而不是字节范围(byteRange)。分片序号更直观,也更容易做数据去重。但分片大小如果中途调整过,字节范围会错位,所以分片大小在文件指纹里就固定下来,前端和服务端共用同一套配置。
4.2 前端的关键逻辑:查已传、跳分片、续传
每次选择文件后,前端生成文件指纹,然后调用check接口查询已传分片。服务端返回类似这样的数据:
{ "code": 0, "data": { "fileId": "abc123", "uploadedChunks": [0, 1, 2, 5, 6, 9], "md5": "" } }前端拿到uploadedChunks列表后,在初始化上传队列时直接把已传的分片从待上传列表中剔除。WebUploader默认不支持跳过指定分片,我改造时是监听uploadStart事件,在分片开始前判断:
uploader.on('uploadStart', function(file) { const chunkList = uploader.getChunks(file); chunkList.forEach(function(chunk, index) { if (uploadedChunks.includes(index)) { // 标记该分片已完成,跳过上传 uploader.skipChunk(file, index); } }); });这里有一个容易被忽略的问题:如果不做任何处理,用户不知道哪些分片已经被跳过,进度条会从0%重新开始。我在插件里做了叠加处理——把已传分片的字节数直接加到初始进度上,用户刷新后看到的是"已上传62%,剩余38%继续",体验上就顺畅很多。
4.3 服务端合并的实现要点
服务端合并逻辑是整个续传的收官环节。我见过不少项目在合并阶段出问题,核心原因就是分片落盘顺序和合并顺序不一致。
我的方案是:每个分片上传时,以{fileId}_{chunkIndex}.part命名,落盘到临时目录。合并时,按chunkIndex升序遍历所有分片文件,用文件流追加写入最终文件。合并完成后,校验文件大小是否等于所有分片大小之和,再异步计算MD5。
// Java服务端伪代码 public boolean mergeChunks(String fileId, String fileName, long totalSize) { File tmpDir = new File(uploadTmpPath + "/" + fileId); File[] partFiles = tmpDir.listFiles((dir, name) -> name.matches(".*\\.part$")); Arrays.sort(partFiles, (a, b) -> { int aIdx = Integer.parseInt(a.getName().split("_")[1]); int bIdx = Integer.parseInt(b.getName().split("_")[1]); return Integer.compare(aIdx, bIdx); }); try (FileOutputStream fos = new FileOutputStream(finalPath + "/" + fileName)) { for (File part : partFiles) { Files.copy(part.toPath(), fos); part.delete(); } } // 合并完成后校验大小 File finalFile = new File(finalPath + "/" + fileName); return finalFile.length() == totalSize; }这个逻辑不算复杂,但非常重要。分片上传是并发的,服务端接收分片的顺序完全随机,如果不按chunkIndex排序就合并,视频文件必然损坏。
4.4 断点状态在前端的持久化
服务端已经能通过check接口返回已传分片列表,那前端还需要持久化状态吗?我建议在浏览器本地也存一份,用IndexedDB,不用localStorage——localStorage容量上限一般是5MB,从几万个分片状态轻而易举就撑爆了。
本地状态存什么?文件指纹、当前分片进度、上传时间。这样即使用户在check服务端不可用的情况下(比如内网临时故障),也能在前端手动恢复大部分续传状态,双保险。
5. 跨浏览器与国产化终端适配:兼容细节一箩筐
"跨浏览器"这个词在公网项目里可能只是指Chrome和Safari,但在军工内网里,它是一个非常沉重的词。我列一下实际遇到的终端环境:Windows 7/10上的Chrome 70+、Firefox 68+、旧版Edge,以及基于Chromium的国产化信创浏览器(麒麟、UOS)。IE11在部分老机器上还存在,但我们已经明确不重点支持,只保证能选文件、能提示升级浏览器。
5.1 Blob.slice的兼容陷阱
WebUploader内部使用Blob.slice()方法切片,这个API在几乎所有现代浏览器都支持。但在老版本Firefox上,可能需要mozSlice,老版本Chrome上可能是webkitSlice。我在改造时写了一个兼容函数:
const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;这一行代码能救很多老终端。另外,某些终端环境(尤其是国产系统)的浏览器对Blob.slice的边界处理有细微差异,比如最后一个分片不足chunkSize时,有些浏览器可能返回空切片。所以每个分片在读取后要判断一下实际字节数,再决定是否上传。
5.2 FileReader的性能与兼容
读取分片数据时,我用的是FileReader.readAsArrayBuffer,这是最通用的方式。要注意,readAsDataURL会把二进制数据转成Base64,体积膨胀约33%,上传流量白白多出三分之一,在超大体量文件上必须避开。
在国产化浏览器上,FileReader对ArrayBuffer的内存管理有时会有问题,我遇到过一个情况:连续读取几十个50MB的ArrayBuffer,浏览器内存只升不降,最终崩溃。排查发现是浏览器内核没有及时回收FileReader的缓存。解决方法是显式将读取完的Buffer置为null,并调用reader.abort()释放内部状态。
5.3 分片状态存储:localStorage不够用
前面提到用IndexedDB存储前端续传状态,这里展开讲一下。IndexedDB是异步API,代码复杂度比localStorage高不少,很多人嫌麻烦就直接用localStorage,直到某天传10GB文件时发现localStorage被写满、所有状态丢失,才追悔莫及。
我用IndexedDB封装了一个非常轻量的存储模块,只存状态对象的JSON序列化,读写都是毫秒级。如果嫌IndexedDB操作麻烦,也可以用idb-keyval这类不到1KB的小工具库,它能帮我们规避兼容性坑。
5.4 信创浏览器的专项验证
国产化终端这块,我的经验是:基于Chromium内核的信创浏览器,HTML5上传能力基本没问题,但有两个坑需要提前做兼容:
一是版本号判定不可靠。很多信创浏览器把自己的UA伪装成Chrome,但实际Chromium版本很老。我建议不要依赖UA做兼容判断,而是直接做特性检测,比如检测File.prototype.slice是否存在。
二是大文件上传时浏览器可能出现假死。信创终端一般用的是国产CPU(龙芯、飞腾、鲲鹏),性能比主流x86差一截。在大文件的ArrayBuffer读取和MD5计算时,建议用Web Worker在后台线程处理,避免主线程卡顿。我对比过,用Web Worker计算MD5,一个大分片的计算时间从300ms降到10ms,而且UI完全无卡顿。
6. 实测踩坑记录:军工内网环境下最隐蔽的五个问题
这个章节不讲流程,只讲真相。下面五个问题,每一个我都真实遇到过,每一个都能让"看起来正常"的上传任务在半夜突然失败。
6.1 内存爆炸:分片Blob引用不释放导致的浏览器崩溃
现象:上传10GB视频时,浏览器内存持续攀升,到50%左右直接标签页崩溃。
根因:WebUploader内部在分片上传时会持有每个分片的Blob引用。1000个分片的Blob全部堆在内存里,每个50MB就是50GB的虚拟内存占用,不崩才怪。
解决:我在上传流程中做了两件事。第一,每次读取分片后,立即把该分片从队列中标记为已释放,实际上就是把引用设为null。第二,在uploadFinished事件中主动调用uploader.removeFile(file, true),让WebUploader彻底释放该文件的所有内部状态。
验证:改造后上传30GB文件,浏览器内存稳定在2GB以内,长时间运行无崩溃。
6.2 MD5计算卡死UI:用户以为程序死了
现象:测试反馈"选完文件后页面就死了,鼠标都动不了"。
根因:虽然我们用了抽样指纹方案,但在计算抽样分片的MD5时,还是在主线程里做的。几个分片的ArrayBuffer加起来几十MB,SparkMD5计算时一次性遍历,主线程被阻塞。
解决:把指纹计算整体放入Web Worker。计算过程中UI完全无感,计算完成后通过postMessage把指纹返回主线程。
验证:即使抽样分片达到150MB(30GB文件的5%,即15个100MB分片),计算也在几百毫秒内完成,UI零卡顿。
6.3 分片乱序合并导致视频花屏、中断
现象:偶尔出现合并后的视频文件大小正确,但播放到某个时间点就花屏或者直接跳帧。
根因:服务端合并时,直接按文件系统遍历临时目录的顺序合并,没有按chunkIndex排序。并发上传下,分片落盘的顺序和写入顺序不一致,导致合并后的文件内部数据错位。
解决:合并前必须解析分片文件名中的chunkIndex,按升序排序后再合并。同时增加了合并前的元数据校验:先校验分片总数是否等于预期总数,再校验每个分片的大小是否符合预期。
验证:加入排序校验后,连续测试了20次随机中断再续传,合并后的视频文件哈希全部一致,无一次花屏。
6.4 临时分片碎片堆积:服务器磁盘被塞满了
现象:内网服务器运行一个月后,磁盘满了。查下来发现临时上传目录里有几十GB的.part文件。
根因:用户传一半不传了,或者上传失败后没有清理临时分片。服务端只负责收分片和合并,没有处理"孤儿分片"。
解决:服务端增加了两级清理机制。第一级,合并成功后立即删除分片文件。第二级,定时任务每天扫描临时目录,删除超过24小时尚未合并的分片文件。同时在前端增加了"取消上传"时主动通知服务端清理分片的接口。
验证:清理机制上线后,临时目录的占用稳定在几百MB以内。注意,这个清理逻辑要谨慎配置"24小时"这个阈值——内网用户经常传一半就下班走了,第二天接着传,如果我清理得太激进,会把续传的底料给铲了。
6.5 duplicate重名问题:同名文件无法再次上传
现象:业务同事反馈,同一个视频文件传第二次,前端直接提示"文件已存在",无法重新上传。
根因:WebUploader默认通过文件名+大小判断重复文件,duplicate属性默认值为false,即不允许重复文件加入队列。但卫星视频经常有"同一镜头多次回传"的场景,文件名可能相同但内容不同(时间戳在文件名里,但业务方可能会手误重命名)。
解决:在初始化时设置duplicate: true,同时用我们的文件指纹来区分文件,如果指纹不同,即使文件名相同也视为新文件。指纹相同的情况下,直接走秒传校验,不占用带宽。
验证:同名不同内容文件可正常排队上传,同名同内容文件秒传,符合业务预期。
7. 插件封装与业务接入:把改造能力固化成标准API
完成所有底层改造后,最后一步是把这个改造过程固化成标准插件,让业务系统不用关心内部实现细节,直接调用就能用。这部分功夫花在"接口设计"上,比在业务代码里塞一堆底层逻辑强得多。
7.1 对外API设计
插件对外暴露的方法非常简洁,尽可能贴近业务方的直觉:
const uploader = new SatelliteUploader({ server: '/api/upload/chunk', checkServer: '/api/upload/check', mergeServer: '/api/upload/merge', accept: '.mp4,.ts,.avi,.mxf' }); uploader.on('uploadProgress', function(percent, file) { console.log(`${file.name}: ${percent.toFixed(2)}%`); }); uploader.on('uploadComplete', function(file) { console.log(`${file.name} 上传完成`); }); uploader.upload(file);底层初始化WebUploader、文件指纹计算、断点续传逻辑、重试策略全部封装在插件内部。业务方只需要关心三个事件:progress、complete、error。
7.2 事件体系与失败重试策略
事件体系我参考了WebUploader的设计,但简化了一层。对外暴露的核心事件:
uploadStart:文件开始上传。uploadProgress:上传进度,返回百分比。uploadComplete:上传完成,包含服务端的校验结果。uploadError:上传失败,包含错误码和重试信息。uploadRetry:自动重试通知。
失败重试这里我采用了渐进式重试。分片失败首先立即重试1次,如果仍然失败,等待10秒后重试第2次,再失败等待30秒重试第3次。3次重试后仍然失败,上报错误并暂停该文件上传。这个策略在弱网环境下比较有效,既不会因为频繁重试而加重网络负担,也不会因为不重试而让用户手动操作。
7.3 插件集成时的注意事项
业务系统接入插件时,有几个细节容易遗漏:
- 跨域配置:内网系统如果前后端分离,分片上传接口的CORS要配置允许带自定义Header(比如X-File-Id),否则大文件请求会直接失败。
- 请求超时时间:分片上传请求的超时时间建议设置在5分钟以上。内网虽然有波动,但正常上传一个50MB分片也就几十秒,5分钟足够宽裕。
- Nginx上传大小限制:如果业务系统前面挂了Nginx,默认的
client_max_body_size是1MB,不调大分片上传必然失败。这个坑我帮别人排查过很多次。
7.4 插件的扩展方向
这个插件做完之后,我还在持续迭代,目前有几个方向已经验证过:
- 秒传能力:文件指纹一致的情况下,前端直接跳过上传,服务端将已有文件关联到当前用户,秒级完成。
- 断点下载:既然上传能断点续传,下载也可以做对称的分片下载和合并,这套逻辑稍作调整就能复用。
- 上传加密:在分片读取时对数据进行AES加密,服务端合并前解密,满足更高的安全要求。
卫星视频、遥感影像、试验记录这类超大体量文件,未来只会越来越多。上传组件不是简单的UI控件,它本质上是一套分布式可靠传输的最小闭环。我把这套改造方案沉淀成插件后,业务系统的接入成本从原来的几天降低到半小时,故障率也显著下降。如果你手头也有类似的需求,建议参考这个思路,先把"传输可靠性"这四个字吃透,再动手写代码。