去年年中我接过一个挺硬的需求:军工行业的内网平台里,要做一套浏览器端的卫星视频上传功能。需求方的限制条件很短,但每一条都压手——文件动辄几十GB,必须支持断点续传,IE、老版Chrome、国产双核浏览器都要能跑,整套前端资源还不能引用公网。当时第一反应就是WebUploader,毕竟它自带分片、并发和MD5秒传,网上聊这个的教程也不少。可真等我把“单文件100GB卫星视频”和“浏览器从IE11跨度到Chrome 120”摆在一起时,才发现原生WebUploader只能算半成品,从分片策略到MD5计算方式,再到失败重试和服务端台账,全得按场景重新捋一遍。
这篇文章就把整个改造过程摊开讲,包括断点续传的底层逻辑、跨浏览器兼容的处理方式、内网部署的工程化约束,以及我实际踩过的几个大坑。内容不绕弯子,能给正在做类似大文件上传方案的人一个可参考的路线图。
1. 卫星视频上传场景里,原生WebUploader为什么顶不住
1.1 需求拆解:这个上传任务到底特殊在哪
这个需求真正难的,不是“上传”本身,而是几个约束条件叠在一起之后,把普通上传组件的短板全暴露出来了。我习惯开工前先把约束条件列成表格,看得清楚,后面设计才不会跑偏。
| 约束条件 | 表象 | 本质问题 |
|---|---|---|
| 文件超大 | 卫星视频单文件数十GB | 不能整文件读内存,必须流式处理 |
| 断点续传 | 上传中断后不能从头再来 | 需要可靠的文件标识和分片台账 |
| 跨浏览器 | IE11、老Chrome、国产双核混用 | 不能依赖单一HTML5能力,得有降级策略 |
| 内网隔离 | 无法访问公网CDN和在线依赖 | 所有资源本地化,离线可运行 |
| 数据重要 | 视频不可丢失、需要可追溯 | 服务端要能校验分片完整性,留足日志 |
这中间最容易被低估的就是“文件超大”和“跨浏览器”的组合。单说大文件,现代浏览器File API都能处理;单说老浏览器,很多上传组件也兼容。但老浏览器碰上几十GB的文件,很多常规做法就直接失效了,比如一次性把文件读进内存算MD5,在IE11和低版Chrome上几乎是必崩。
1.2 原生组件的三个软肋
WebUploader本身的定位是一个“基础上传框架”,它把文件选择、分片、队列、进度做了封装,方向是对的。但拿到这个场景里,有三个软肋绕不过去。
软肋一是MD5计算。WebUploader默认的文件唯一标识逻辑,对于超大文件非常不友好。它会把文件读进内存做计算,视频文件稍微大点,浏览器内存直接飙升,页面卡死属于常态。这块必须自己改成增量计算。
软肋二是断点续传只做了“前端一半”。组件负责切分片、传分片,但服务端支不支持分片存储、支不支持查询已上传分片、合并时怎么校验,官方示例里基本没给成熟方案。也就是说,续传能力需要你自己把服务端接口和数据模型设计出来。
软肋三是Flash降级路径。WebUploader老版本依赖Flash作为低版本浏览器的渲染内核,可现在的内网终端对Flash控件限制很严格,运维侧倾向直接封禁。所以跨浏览器兼容不能靠Flash,得靠能力检测,然后有明确的降级或不支持提示。
2. 断点续传的底层逻辑:文件指纹与分片台账
2.1 文件唯一标识:为什么必须用增量MD5
断点续传的本质,是让服务端能够识别“你传的这份文件,之前是不是已经传过一部分”。识别文件靠的是文件指纹,实际项目里最稳妥的就是MD5。
但问题在于MD5怎么算。很多刚接触的人会写:
const buffer = await file.arrayBuffer(); const md5 = SparkMD5.ArrayBuffer.hash(buffer);这段代码在10MB的小文件上没毛病,放到卫星视频上就是灾难。file.arrayBuffer()会把整个文件读进内存,一个50GB的视频,内存先吃掉50GB,再加上计算过程中的临时对象,浏览器不崩才怪。
正确做法是按片读取、增量追加摘要:
function calcFileMd5(file, chunkSize, onProgress) { const spark = new SparkMD5.ArrayBuffer(); const reader = new FileReader(); const size = file.size; let current = 0; return new Promise(function (resolve, reject) { function next() { const slice = file.slice(current, Math.min(current + chunkSize, size)); reader.onload = function (e) { spark.append(e.target.result); current += chunkSize; onProgress && onProgress(Math.min(1, current / size)); if (current < size) { next(); } else { resolve(spark.end()); } }; reader.onerror = reject; reader.readAsArrayBuffer(slice); } next(); }); }这里每次只读一个分片(比如2MB~5MB)进内存,计算完摘要之后,上一步的ArrayBuffer就能被垃圾回收,内存占用非常平稳。实测下来,计算一个60GB文件的MD5,浏览器内存峰值始终控制在200MB以内,耗时也确实不短,但至少是可接受的,而且能给出进度条。用户看到“正在校验文件”的进度,比干等强太多。
2.2 服务端分片台账接口怎么设计
文件指纹确定了,接下来就是服务端怎么记录“传到第几个分片了”。我的做法是设计三个接口,形成一个闭环。
| 接口 | 入参 | 出参 | 说明 |
|---|---|---|---|
| 文件注册/查询 | fileMd5, fileName, size | 已上传分片序号数组 | 判定是否已存在、哪些分片可跳过 |
| 分片上传 | fileMd5, chunkIndex, chunks, file | 当前分片序号 | 服务端幂等处理,重复传同一分片直接成功 |
| 分片合并 | fileMd5, chunks, fileName | 文件访问路径 | 按序号合并,缺失分片则返回失败 |
以Node.js为例,分片上传接口的核心逻辑大致是这样:
router.post('/api/video/chunk', upload.single('file'), async (req, res) => { const { fileMd5, chunkIndex } = req.body; const chunkDir = path.join(uploadRoot, fileMd5); await fs.mkdir(chunkDir, { recursive: true }); // 关键点:每个分片写独立临时文件,写完再rename,避免并发写同一文件 const tmpPath = path.join(chunkDir, chunkIndex + '.tmp'); const finalPath = path.join(chunkDir, chunkIndex); await fs.writeFile(tmpPath, req.file.buffer); await fs.rename(tmpPath, finalPath); res.json({ ok: true, chunkIndex: Number(chunkIndex) }); });合并接口要做一个非常重要的检查——分片完整性校验:
router.post('/api/video/merge', async (req, res) => { const { fileMd5, chunks, fileName } = req.body; const chunkDir = path.join(uploadRoot, fileMd5); const outputPath = path.join(uploadRoot, fileMd5 + '_' + fileName); const partPaths = []; for (let i = 0; i < Number(chunks); i++) { const p = path.join(chunkDir, String(i)); if (!fs.existsSync(p)) { return res.status(400).json({ ok: false, msg: `缺失分片 ${i}` }); } partPaths.push(p); } // 按顺序流式合并,避免一次性读入内存 await mergeFiles(partPaths, outputPath); res.json({ ok: true, url: '/download/' + fileMd5 }); });合并前逐分片检查,缺失任何一个都拒绝合并。这样“看似传完、实际漏片”的情况能被及时挡住,而不是等到用户打开视频发现花屏了才回来找。
2.3 分片参数怎么定才合理
分片大小和并发数是这套方案里最容易“拍脑袋”的参数。我给不同网络环境整理过一组参考值:
| 网络情况 | 推荐分片大小 | 并发数 | 单分片重试次数 |
|---|---|---|---|
| 千兆内网 | 8-10MB | 3 | 3 |
| 百兆内网/跨网段 | 2-5MB | 2-3 | 5 |
| 链路抖动明显 | 1-2MB | 2 | 5-8 |
分片不是越大越好。分片过大,一旦中途某个分片失败,重试成本就高;分片过小,请求数量爆炸,服务端合并时文件句柄开太多,磁盘压力也大。内网环境下我最终选了5MB分片、并发3路,这个组合在稳定性和速度上最均衡。
并发数也不要贪多。WebUploader的threads参数控制并发分片数量,设太大会导致服务端同时收到大量写请求,普通机械盘反而会因寻道开销导致整体吞吐下降。换SSD之后可以适当提到5,但3到4路通常已经能把千兆内网带宽跑满。
3. 核心改造流程:从MD5增量计算到跳过已传分片
3.1 初始化配置:一套能支撑百GB文件的options
WebUploader的初始化参数看起来文档都有,但真正应对超大文件时,有几个细节必须显式设置,否则会踩默认值的坑。我项目里的配置是这样的:
const uploader = new WebUploader.Uploader({ swf: './resources/Uploader.swf', server: '/api/video/chunk', pick: '#picker', accept: { title: 'Video', extensions: 'mp4,ts,mkv,avi', mimeTypes: 'video/*' }, chunked: true, chunkSize: 5 * 1024 * 1024, threads: 3, fileNumLimit: 1, fileSingleSizeLimit: 100 * 1024 * 1024 * 1024, duplicate: true, formData: { token: getToken() } });这里说几个容易被忽略的点。duplicate: true必须显式打开,否则同名同大小的文件会被WebUploader默认去重拦截,而卫星视频经常出现一系列命名极其相似的文件。fileSingleSizeLimit要按实际需求设大,默认值只适合普通办公文件。accept.mimeTypes不建议用video/*之外过于精确的MIME,因为不同编码的视频文件在浏览器里的MIME识别结果不完全一致。
3.2 增量MD5与文件注册
初始化之后,流程需要拆成两步走:先算MD5,再启动上传。不要一选完文件就调uploader.upload(),否则分片已经发出去了,MD5还没算出来,后续的断点续传就没有标识可用。
uploader.on('fileQueued', async function (file) { const md5 = await calcFileMd5(file.source, 5 * 1024 * 1024, function (ratio) { // 更新界面上的“校验进度” }); // 查询服务端已上传分片,作为断点续传依据 const res = await fetch(`/api/video/exist?md5=${md5}`, { method: 'GET' }); const data = await res.json(); uploadedChunks = data.chunks || []; uploader.option('formData.fileMd5', md5); // 重要:此时再真正触发上传 uploader.upload(); });这里有一个容易被忽略的体验问题:计算MD5耗时可能很长,中间用户如果把页面关了,MD5结果就丢了,下次还是得重算。后续优化时我把MD5结果缓存到了localStorage,用文件大小 + 文件修改时间做索引,但注意localStorage在超大文件条目上可能有配额限制,需要做一下容错。
3.3 分片请求的参数注入与跳过逻辑
WebUploader在uploadBeforeSend事件里会暴露当前分片的信息,这是注入自定义参数和实现跳过的关键点:
uploader.on('uploadBeforeSend', function (block, data) { data.fileMd5 = uploader.options.formData.fileMd5; data.chunkIndex = block.chunk; data.chunks = block.chunks; data.fileName = encodeURIComponent(block.file.name); // 如果服务端台账里已经有这个分片,直接标记完成 if (uploadedChunks.indexOf(Number(block.chunk)) > -1) { block.complete(); } });注意一个坑:block.chunk在不同场景下可能是字符串也可能是数字,必须用Number()统一类型再比较,否则indexOf永远返回-1,断点续传就会失效。这个坑我后面实测章节还会展开讲。
4. 跨浏览器兼容方案:能力检测与老内核适配
4.1 不迷信UA判断,用能力检测决定降级策略
军工内网的浏览器环境比公网复杂得多,Windows 7配老Chrome常见的很,Windows 10装国产双核浏览器也不稀奇。我一开始想用UA字符串区分浏览器,后来发现不靠谱——国产双核浏览器往往会把UA伪装成Chrome,但真实内核却可能是IE。正确思路是能力检测,直接探测浏览器支不支持关键的API:
const supportHtml5 = Boolean( window.File && window.Blob && window.FileReader && window.FormData && window.XMLHttpRequest ); // 分片能力需要File.slice,老浏览器可能带浏览器前缀 const supportSlice = Boolean(File.prototype.slice) || Boolean(File.prototype.webkitSlice) || Boolean(File.prototype.mozSlice);能力检测通过,就走HTML5分片通道;不通过,就明确降级。我给项目定的兼容矩阵如下:
| 浏览器类型 | 处理方式 |
|---|---|
| Chrome 60+ / Edge / Firefox | HTML5分片上传,完整功能 |
| IE11 / 老Chrome / 国产双核兼容模式 | HTML5可用,但需转译代码,部分安全提示需处理 |
| IE10及以下 | 不建议支持,直接提示升级浏览器 |
说句实在话,军工内网的老旧终端确实还存在,但基本都是IE11级别,IE8/9已经非常罕见了。与其花大精力维护Flash降级链路,不如把资源花在IE11适配和提示引导上。
4.2 老内核浏览器下最常见的三个拦路虎
第一,ES6语法。WebUploader本身是ES5写的,问题往往出在我们自己写的增补代码上。如果用了箭头函数、Promise、const/let,在IE11上连解析都会报错。解决方案是引入Babel把代码转译成ES5,并且把Promise等polyfill文件本地化打包。内网环境没法在线拉polyfill,这一步必须提前做。
第二,File.prototype.slice的兼容性。老Chrome和IE11支持的不是标准slice,而是webkitSlice或mozSlice。这个问题在文件小于2GB时不容易暴露,一旦处理超大文件,切片方法不对直接抛异常。WebUploader内部其实有兼容处理,但我们自己写增量MD5时也要用同样的兼容写法。
第三,超大文件在32位浏览器上的处理。如果终端是32位浏览器,File.size在超过2GB时可能溢出,slice处理也会有各种诡异问题。这个问题没有代码层面的完美解法,只能够在能力检测结果里把浏览器位数纳入判断,提示用户使用64位浏览器。
5. 内网部署的工程化约束与安全加固
5.1 离线部署:所有依赖本地化
内网隔离环境下,第一件事就是把所有依赖从公网CDN搬到本地。这个项目用到的前端依赖必须全部打进发布包,一个都不能漏:
| 依赖 | 用途 | 说明 |
|---|---|---|
| jquery | WebUploader依赖 | 用1.12.x版本,兼容老浏览器 |
| WebUploader js/css | 组件本体 | 版本锁定,不随意升级 |
| Uploader.swf | Flash降级 | 保留但实际很少触发 |
| SparkMD5 | 增量MD5计算 | 用压缩版即可 |
| promise polyfill | 老浏览器兼容 | 本地打包,不引用外部CDN |
发布包部署完成后,要专门做一个页面做自检,逐项探测关键文件是否可访问。否则终端用户打开页面上传时才发现某个静态资源404,排查起来非常被动。
5.2 服务器与网关配置的几个关键项
分片上传模式单个请求体只有几MB,nginx的client_max_body_size理论上不用设太大。但合并接口的请求体带文件名等信息,而且上传目录的磁盘格式直接影响超大文件合并结果。建议关注以下几个点:
client_max_body_size 50m; proxy_read_timeout 600s; proxy_send_timeout 600s;proxy_read_timeout一定要调大。内网环境虽然带宽稳定,但分片上传时偶尔会有慢速分片,默认60秒超时会让个别分片误报失败。另外上传临时目录要放在空间充足的独立分区,合并过程中会产生和最终文件同等大小的临时数据,磁盘不够会在最后一步失败,这个比网络问题更难排查。
5.3 上传插件的审计与安全
军工行业项目对数据追溯要求高,上传插件的日志必须做到分片级别。我把日志结构简化成了这样:
[2026-05-01 14:23:10] fileMd5=xxx chunkIndex=12 chunks=120 name=xxx.mp4 size=60GB ip=xxx result=ok每次分片上传都记录一行,合并时再记录一条合并结果。后续如果出现文件损坏,可以按fileMd5倒查所有分片状态。
安全方面要特别注意路径穿越。服务端接收分片时,fileMd5和fileName都不能直接拼接进文件路径,要做字符白名单过滤:
function safeFileName(name) { return String(name).replace(/[^\w.\-\u4e00-\u9fa5]/g, ''); }6. 实测踩坑实录:三类典型故障的完整排查链路
6.1 页面卡死:MD5计算导致内存溢出
现象很直接:在Chrome里上传一个30GB视频,文件刚选完,页面逐渐失去响应,任务管理器里浏览器内存飙升到2GB以上,最后直接崩溃。
一开始我怀疑是WebUploader分片太多的问题,但排查时发现文件选完到崩溃之间,网络请求根本没发出去。打日志定位后发现,卡死在MD5计算阶段。原生SparkMD5.ArrayBuffer.hash(buffer)把整个文件一次性读进内存,然后还要生成一份完整的ArrayBuffer副本,内存瞬间翻倍。
修复方案就是前面写的增量计算方式。修复后我专门拿一个50GB的文件测试,内存稳定在180MB左右,计算耗时约70秒,但页面始终流畅。这个问题属于“看起来是浏览器问题,其实是实现方式问题”的典型。
6.2 合并后视频花屏:分片并发乱序与服务端写入竞争
现象是上传结束后视频文件大小也对,但播放到某个位置花屏或卡死。第一次遇到时我以为是视频源文件本身的问题,后来让需求方发了原始文件对比,才发现合并后的文件和源文件校验不一致。
排查过程分三步:先用MD5对比原始文件和合并文件,发现不一致;然后抽查分片内容,发现某个分片文件的内容被覆盖成了另一个分片的数据;最后定位到服务端写入逻辑——并发分片请求到达时,用了同一个目标文件句柄写入,导致数据交叉污染。
修复方案是每个分片先写独立临时文件,写完后通过rename原子操作改名落地。合并时逐分片检查存在性,再按顺序流式合并。经过这三层防护,再也没出现过花屏问题。我这里得到的教训是:前端分片并发做得再好,服务端写入不严谨,一切都白搭。
6.3 断点续传不生效:序号类型不一致与服务端幂等缺失
现象是用户上传到80%,刷新页面后重新上传,又从头开始传。排查时先确认MD5计算每次一致,再确认服务端查询接口确实返回了已上传分片数组,最后发现是前端比较逻辑的问题——block.chunk在WebUploader内部是字符串类型,而服务端返回的数组元素是数字类型,indexOf永远匹配不上。
修复很简单,统一用Number()转换。但这个坑很有代表性,前后端联调时如果字段类型约定不明确,很容易出现“接口返回了数据,但前端逻辑没生效”的诡异情况。同时服务端分片接口也做了幂等处理:同一个chunkIndex重复提交,直接返回成功。有了这层兜底,即使前端跳过逻辑出错,服务端也不会产生重复数据。
7. 插件API设计与运行成效
7.1 对外暴露的接口
改造完成后,我把整套逻辑封装成了一个独立的插件对象,内部依赖WebUploader,但外部调用方完全不感知:
const uploader = new SatelliteUploader({ pick: '#picker', server: '/api/video/chunk', mergeUrl: '/api/video/merge', accept: { title: 'Video', extensions: 'mp4,ts,mkv,avi' }, chunkSize: 5 * 1024 * 1024, threads: 3, onProgress: function (percent) { /* 更新进度条 */ }, onStatus: function (status) { /* 状态:校验/上传/合并/成功/失败 */ } }); uploader.start();内部细节对外完全屏蔽,调用方不需要理解MD5、分片、断点续传这些概念,拿到插件直接初始化就行。这也方便其他业务线复用,后来平台里其他大文件上传需求都直接套了这个插件。
7.2 实际运行效果与后续扩展
这套插件上线到现在,单文件最大跑过100GB的卫星视频,千兆内网环境下稳定传输速度能达到80MB/s以上,一个多小时传完。出现过两三次传输中断的情况,重新打开页面后都能从断点续传恢复,没有再从头传过。
如果后续你的场景比这个更重,可以考虑两个扩展方向:一是给超大文件增加“分片级校验”,合并后不仅校验大小,还按分片做整体MD5校验,代价是合并耗时增加一点;二是把MD5缓存迁移到IndexedDB,解决localStorage配额问题,对数百GB级别的文件更友好。
最后分享一个个人体会:做这种偏底层的上传组件,千万别把它当黑盒用。WebUploader这类框架给了你一个起点,但真正决定方案能不能撑住场景的,是分片策略怎么设计、服务端台账怎么配合、失败恢复怎么做。把这几个点想清楚,哪怕不用WebUploader,换个组件或者自己写一套,思路也完全一样。