
上个月我们刚处理掉一个让人血压飙升的线上问题卫星视频模块里最常用的那些动辄2GB以上的MP4、TS文件用WebUploader传一半网络闪断文件列表里多了一条红色失败记录用户只能从头开始。基层的同事直接在群里说“传个附件跟长征一样”。问题本质不复杂——原版WebUploader自带分片能力但没有真正可用的断点续传更没有针对超大文件做跨浏览器层面的完整改造。所以我把这个老组件重新拆了一遍给它包了一层支持分片、断点续传、跨浏览器适配的上传插件目前已经在涉密内网环境跑了半年多上传失败率基本归零。这篇东西我尽量把完整思路和能直接复用的代码写清楚适合正在接手老系统、又被迫处理超大附件上传场景的前端开发者看。文章涉及的核心关键词是JS、WebUploader、分片、断点续传、跨浏览器我会把每一步设计的原因也讲透而不是只贴代码。1. WebUploader明明已过时这项目为什么还得靠它1.1 老系统的历史包袱为什么不能随便重写很多做B端系统的同学会有疑问WebUploader最后一次像样的大版本更新已经停在0.1.x项目仓库里全是历史遗留代码连官方文档都给人“半废弃”的感觉为什么还在用答案是存量系统的技术栈和运维方式根本不支持你轻易换掉上传组件。我接手的这套系统前端是jQuery时代的老单页应用后端是一套Java服务上传模块十年前就用WebUploader跑通了基础流程。项目里除了上传还有一堆老插件依赖jQuery的全局事件机制贸然引入Vue、React或者新版上传组件如uppy、filepond牵一发动全身。再加上涉密内网环境本身对依赖引入有严格审查任何新组件都要经过安全扫描和软件供应链审核周期长、流程多。所以“改造WebUploader”是性价比最高的路径而不是推翻重来。1.2 WebUploader的底子好在哪坏在哪WebUploader虽然老但设计思路放在今天看也不落后它抽象了Uploader核心类通过事件机制暴露上传生命周期扩展性不差。内置分片上传能力chunked: true之后文件会自动切成若干块逐片发送。队列管理、并发控制、进度回调都有成熟实现。文件切片、xhr上传、进度计算这些脏活它都帮你处理好了。问题在于它只干了“切”和“传”没干“断点续传”。具体表现在刷新页面之后之前已上传的分片不会被记录用户必须重传。服务端没有统一的文件指纹校验同名、同内容的文件可能被重复上传。分片参数默认只带chunk和chunks没有文件级唯一标识供服务端做续传判断。老版本对现代浏览器支持存在一些边界bug比如before-send返回false时队列状态和进度条计算会出现不一致。所以我的目标很明确保留WebUploader的分片、并发和事件机制自己补上文件指纹、状态查询、分片跳过和合并校验这四个环节。1.3 为什么“分片上传”和“断点续传”是两回事这一点很多新人容易混。分片上传只是把大文件切成小块降低单次请求失败的影响面但用户在刷新或断网后仍然需要重传所有分片。断点续传的前提是客户端在重新上传前能向服务端确认“这个文件我传到哪个分片了”然后只上传缺失的部分。这要求前后端对一个文件有唯一的识别依据也就是文件指纹。指纹不只是给服务端看的也是客户端自己判断“这个文件是不是传过”的依据。所以两者缺一不可分片解决传输稳定性指纹加状态查询解决续传能力。2. 分片与并发超大附件上传的第一层设计2.1 分片大小怎么定10MB是不是最佳值分片大小是首先需要明确的设计决策它直接影响两个指标分片数量和单片上传耗时。一个2GB视频文件各分片大小对应的分片数如下分片大小分片数量单片理想耗时10MB/s内网带宽优劣势2MB10240.2s分片太多请求数和服务端文件句柄开销巨大容易触发安全网关限制10MB2051s分片数量适中单片传输失败重传成本低适合内网环境50MB415s分片少但单片失败重传带宽浪费大断电续传粒度太粗100MB2110s分片更少但网络抖动导致整片重传的概率大幅上升实测下来内网环境下10MB分片是个“甜点值”。2GB文件产生约205个分片对服务端临时目录的文件管理压力不大单分片传输失败后重传成本也可控。如果是百兆局域网、网络质量很差可以降到5MB如果是万兆内网、链路稳定可以提到20MB。这个值最好做成配置项不要硬编码死。2.2 并发数与内网安全网关的拉扯WebUploader的threads参数控制并发上传的线程数默认是3。很多同学一上来就把它调到6、8觉得并发越高越快但在涉密内网里这是个大坑。内网安全设备防火墙、入侵检测、审计网关通常会对同一IP的并发连接数和单连接速率做策略限制。并发过高时安全设备可能直接丢弃部分连接表现为分片上传大量超时或返回403。我们最开始按4并发测试一切正常调到6之后每小时都会出现分片上传失败排查了很久才发现是安全设备限流。所以我现在的建议是涉密或政企内网环境并发数不要超过4默认3即可。如果确实要调高先在测试环境压测观察安全设备日志。并发数不是越高越快因为服务器端的磁盘写入、临时文件IO也会成为瓶颈。2.3 卫星视频的特殊性大文件之外的三个需求点卫星视频和普通大文件上传不太一样主要体现在三个方面。第一是体积大且数量多。一段4K或更高分辨率的卫星视频单个文件从几百MB到几十GB都可能出现而且经常是一批一批地上传。分片和续传能力不是“锦上添花”是刚需。第二是文件命名有规则但内容与文件名可能不严格绑定。项目里遇到过同一个文件名在不同批次下内容完全不同如果服务端只按文件名判断是否已存在就会出现错误的“秒传”。所以必须用文件内容指纹而不是文件名。第三是完整性要求高。卫星视频后续可能涉及抽帧、判读、分析任何数据损坏都会影响结论。因此传输完成后不能只靠“分片传完”就算完还要在服务端做合并后的整体校验比如总大小核对和文件级SHA-256校验。3. 断点续传的完整链路指纹、状态查询与分片跳过3.1 文件指纹计算SparkMD5的正确打开方式断点续传的第一步是让每个文件有唯一的身份标识。我用的方案是MD5指纹计算库是SparkMD5。之所以选SparkMD5而不是直接读整个文件到内存里算是因为大文件一次性读取会导致浏览器内存暴涨2GB文件会直接压垮前端页面。SparkMD5支持ArrayBuffer增量计算可以分块读取、逐块追加摘要内存占用基本恒定。核心代码如下import SparkMD5 from spark-md5; function calcFileHash(file) { return new Promise((resolve, reject) { const chunkSize 5 * 1024 * 1024; // 每次读取5MB const spark new SparkMD5.ArrayBuffer(); const reader new FileReader(); let current 0; const total file.size; const loadNext () { const slice file.slice(current, current chunkSize); reader.onload (e) { spark.append(e.target.result); current chunkSize; if (current total) { loadNext(); } else { resolve(spark.end()); } }; reader.onerror () reject(new Error(文件读取失败)); reader.readAsArrayBuffer(slice); }; loadNext(); }); }注意这里的file是WebUploader封装后的文件对象需要先调用file.getSource()拿到原生File对象再切片。如果直接用WebUploader的file.slice部分版本存在兼容问题。还要给用户一个“正在计算指纹”的UI提示因为大文件MD5计算需要几秒到几十秒不是瞬间完成的。如果什么都不提示用户会以为系统卡死了。3.2 服务端状态查询秒传与续传的分水岭指纹计算完之后前端带着fileHash和fileName请求查询接口。服务端返回两类信息这个文件是否已经完整上传过如果没传完哪些分片已经存在。查询接口的响应设计如下{ code: 0, data: { fileId: satellite_20240312_abcdef123456, completed: false, uploadedChunks: [0, 1, 2, 3, 5, 7, 9] } }如果completed为true说明文件之前已经完整传过可以直接走“秒传”逻辑前端标记为完成即可不用真正上传。如果completed为false就把uploadedChunks记录到一个Set中作为后续跳过分片的依据。这里有一个很容易踩的坑查询接口必须用fileHash做唯一标识不能只查文件名。卫星视频文件经常是多个用户同时上传同一个名字但不同内容的文件只按文件名查会造成严重的串数据。3.3 before-send拦截与分片跳过实现WebUploader在每一个分片发送前会触发before-send事件。我们在这个事件里判断当前分片是否已经上传过如果上传过直接返回falseWebUploader就会跳过该分片。关键代码uploader.on(before-send, (file, chunk) { const meta fileMetaMap.get(file.id); if (!meta) { return true; } // 该分片已存在跳过 if (meta.uploadedChunkSet.has(chunk.index)) { return false; } return true; });这里要补充一个容易被忽略的细节chunk.index从0开始。服务端存储分片时也要以0作为起始编号两边对齐否则会出现分片错位。跳过逻辑看似简单但它是整个断点续传方案能落地的基础。用户断网重传时已传过的分片被跳过前端只发送缺失的几十片速度自然快。3.4 合并触发与完整性校验所有分片上传完成后WebUploader会触发after-send-file事件。我们在这个事件里调用合并接口。uploader.on(after-send-file, (file) { const meta fileMetaMap.get(file.id); const uploadChunkCount uploader.md5File || Math.ceil(file.size / chunkSize); return request(/api/upload/merge, { method: POST, body: JSON.stringify({ fileHash: meta.hash, fileName: meta.name, fileSize: meta.size, totalChunks: uploadChunkCount }) }).then((res) { if (res.code ! 0) { throw new Error(合并失败); } }); });合并接口内部要做三件事按分片编号顺序读取临时分片文件依序合并成一个大文件。按最后一个分片的实际大小截断最终文件防止多写脏字节。计算合并后文件的SHA-256与客户端上报信息对比不一致则标记失败。服务端校验通过后返回文件的访问地址和最终校验值前端提示上传完成。4. 跨浏览器兼容从File API老包袱到国产内核适配4.1 老版本WebUploader的runtime问题WebUploader设计之初支持HTML5和Flash两种runtime。当浏览器不支持File API时会自动降级到Flash通过flash控件上传文件。但现实情况是Flash在2020年后已经被主流浏览器全面禁用涉密内网的老机器就算装了Flash插件浏览器安全策略也会拦截。所以我们的兼容目标不再是Flash而是“所有使用Chromium内核的现代浏览器”。WebUploader的HTML5模式在现代浏览器上大体可用但有个历史包袱它的runtime检测逻辑是为2015年左右的浏览器写的对某些新增的File API能力比如File.prototype.arrayBuffer、Blob.stream()没有利用反而在某些边界条件下会报错。我们的改造不是重写runtime而是给缺失能力打垫片。4.2 兼容切片与Blob读取的垫片File API在不同内核版本里最大的差异是Blob.slice方法的兼容性。老版本WebKit内核使用blob.webkitSliceFirefox老版本使用blob.mozSlice现代标准方法是blob.slice。WebUploader内部会处理一部分但如果直接操作原生File对象建议写一个兼容垫片function safeSlice(blob, start, end) { if (blob.slice) { return blob.slice(start, end); } if (blob.webkitSlice) { return blob.webkitSlice(start, end); } if (blob.mozSlice) { return blob.mozSlice(start, end); } throw new Error(当前浏览器不支持文件切片); }另外在读取分片内容时优先使用FileReader.readAsArrayBuffer不要使用readAsDataURL因为DataURL会产生Base64编码额外增加约33%的内存和带宽占用对大文件是多余的负担。4.3 跨域上传的坑不是浏览器配置问题标题相关的热搜里有一条很典型的报错“跨域访问被拒绝请检查浏览器配置!”。在WebUploader相关的问题搜索中非常高频。实际上大多数情况下不是浏览器配置问题而是后端没有正确处理跨域请求。当前端页面部署在上传服务不同的域名或端口时浏览器会发起CORS预检请求后端需要在响应中明确允许跨域并对OPTIONS请求返回204。一个标准的Nginx配置片段如下location /api/upload/ { if ($request_method OPTIONS) { add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods GET,POST,PUT,OPTIONS; add_header Access-Control-Allow-Headers Content-Type,Authorization,X-Requested-With; add_header Access-Control-Max-Age 86400; return 204; } add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods GET,POST,PUT,OPTIONS; add_header Access-Control-Allow-Headers Content-Type,Authorization,X-Requested-With; # 其余代理逻辑 }如果内网系统用的是自签HTTPS证书还要确保页面能正常加载否则上传请求可能因为混合内容或证书错误而直接中断这时报错信息也会误导为“浏览器配置问题”。4.4 国产内核浏览器的实测结论现在涉密内网环境里360安全浏览器、奇安信浏览器、红莲花浏览器、以及统信UOS和麒麟系统自带的浏览器基本都属于Chromium内核File API支持度没问题。实测中需要注意以下几点360浏览器的兼容模式IE内核不建议支持直接提示用户切换到极速模式。部分国产浏览器的安全策略默认拦截非用户主动触发的多文件下载但这不影响上传。在Linux版国产浏览器上navigator.plugins可能为空数组导致WebUploader的runtime检测误判为不支持。需要在初始化前手动指定使用HTML5 runtime。加载WebUploader时可以通过参数显式禁用Flashconst uploader WebUploader.create({ swf: , // 不配置Flash路径 runtimeOrder: html5, // 其他参数 });这样能避免浏览器在检测到Flash插件时试图加载已经失效的swf减少无谓的报错。5. 走一遍真代码插件骨架与核心实现5.1 插件初始化与参数透传我把改造后的上传能力封装成一个独立类SatelliteUploader构造时接收业务参数内部创建WebUploader实例。这样业务页面只需要关心文件选中、进度和完成回调不需要直接接触WebUploader的底层细节。class SatelliteUploader { constructor(options) { this.options Object.assign({ pick: null, // 选择按钮DOM选择器 uploadUrl: , // 分片上传接口 statusUrl: , // 状态查询接口 mergeUrl: , // 合并接口 bizType: satellite_video, chunkSize: 10 * 1024 * 1024, threads: 3, accept: { title: 卫星视频, extensions: mp4,ts,mxf,dat,bin } }, options); this.uploader null; this.fileMetaMap new Map(); this.initUploader(); } initUploader() { this.uploader WebUploader.create({ pick: this.options.pick, swf: , server: this.options.uploadUrl, runtimeOrder: html5, chunked: true, chunkSize: this.options.chunkSize, threads: this.options.threads, duplicate: true, accept: this.options.accept, formData: { bizType: this.options.bizType } }); this.bindEvents(); } }几点说明duplicate: true是为了允许用户在同一个页面重复选择同一个文件否则WebUploader默认会把重复的文件过滤掉导致续传场景下用户刷新后无法再次添加同名文件。formData里只放了文件级别的参数分片级别的参数靠WebUploader自动携带和我们在事件里补充避免并发状态竞争。5.2 文件加入队列后的指纹计算与状态查询当用户选择文件后fileQueued事件触发。这里的流程是先计算MD5再查询服务端状态最后决定是否真正上传。bindEvents() { const uploader this.uploader; uploader.on(fileQueued, (file) { const rawFile file.getSource(); const meta { hash: , name: rawFile.name, size: rawFile.size, uploadedChunkSet: new Set(), status: checking }; this.fileMetaMap.set(file.id, meta); this.emit(hashStart, file, meta); calcFileHash(rawFile).then((hash) { meta.hash hash; uploader.option(formData, { bizType: this.options.bizType, fileHash: hash }); return this.queryStatus(hash, meta.name); }).then((status) { if (status.completed) { meta.status completed; this.emit(complete, file, meta); uploader.removeFile(file, true); return Promise.reject(SECONDS_PASS); // 终止后续 } status.uploadedChunks.forEach((index) { meta.uploadedChunkSet.add(index); }); meta.status uploading; uploader.upload(file); }).catch((err) { if (err ! SECONDS_PASS) { this.emit(error, file, err); } }); }); }注意这里我用Promise.reject(SECONDS_PASS)来中断流程但实际写法建议用标志位控制避免Promise链的catch误报错。业务代码可以根据自己的规范调整。5.3 分片上传事件与手动返回true的必要性在before-send事件里跳过已有分片之后需要在uploadAccept事件里做分片上传完成后的服务端结果校验。uploader.on(before-send, (file, chunk) { const meta this.fileMetaMap.get(file.id); if (meta meta.uploadedChunkSet.has(chunk.index)) { return false; } return true; }); uploader.on(uploadAccept, (file, chunk, ret) { if (ret ret.code ! 0) { return false; // 让WebUploader认为该分片失败触发重试 } });uploadAccept返回false会触发WebUploader对该分片的重试逻辑这是处理“服务端接收到分片但落盘失败”的关键钩子。5.4 进度、重试与错误处理进度条的准确度影响用户体验。WebUploader自带的uploadProgress在续传时会因为跳过已传分片而出现跳变也就是用户看到进度从0%突然跳到40%然后又慢慢前进。我采用的分片计数方式而不是字节方式uploader.on(uploadProgress, (file, percentage) { const meta this.fileMetaMap.get(file.id); if (!meta) return; const started this.uploader.isUploading(); if (!started) return; // 根据已成功分片数计算进度 // 这里简单处理百分比直接透传但记录日志 this.emit(progress, file, { percentage: Math.floor(percentage * 100), uploadedChunks: meta.uploadedChunkSet.size }); });实际要做得更精细的话可以自己维护每个分片的上传状态计算“已成功分片数/总分片数”这样进度不会跳变。出错重试方面WebUploader自带失败分片重试机制可以在初始化参数里配置retry。const uploader WebUploader.create({ // ... retry: 3, // 单个分片失败重试次数 });对于断网场景监听error事件后给用户一个“网络异常已暂停上传”的提示并轮询网络状态恢复后调用uploader.upload()继续。6. 后端配合接口契约与分片管理6.1 接口定义一览前端改造只是半程后端必须配套支持。三个核心接口的契约如下接口方法请求参数返回说明/api/upload/statusGETfileHash,fileName返回completed、uploadedChunks/api/upload/chunkPOST分片文件、fileHash,chunkIndex,totalChunks,fileName,fileSize返回code,chunkIndex/api/upload/mergePOSTfileHash,fileName,fileSize,totalChunks返回code,fileUrl,checkSum分片上传接口接收的是multipart/form-data格式文件字段名叫file其他参数一起提交。服务端在保存分片时需要校验chunkIndex是否在[0, totalChunks-1]范围内避免恶意请求写入越界分片。6.2 服务端分片落盘结构服务端的分片文件存储目录建议以fileHash为一级目录分片按固定宽度数字命名。/storage/satellite_video/abcdef123456/ chunks/ 0000000000.part 0000000001.part 0000000002.part complete/ final.bin固定宽度命名的好处是合并时按字符串排序就是分片顺序不需要额外读取数据库记录。但要注意如果分片数超过10位命名宽度要适当调整或者用数据库记录分片序号。另外服务端必须记录每个分片的上传时间、来源IP便于审计。涉密环境的审计要求通常比较严格这部分日志不能省。6.3 合并实现要点顺序、截断与清理合并分片的核心逻辑不复杂但有两个容易出错的点。第一个是截断。最后一个分片很可能不是完整大小直接按顺序写入时一定要用文件实际大小截断否则最终文件末尾会多出填充字节。// Java伪代码 try (FileOutputStream fos new FileOutputStream(finalFile)) { for (int i 0; i totalChunks; i) { File part chunkFile(i); try (FileInputStream fis new FileInputStream(part)) { byte[] buffer new byte[8192]; int len; while ((len fis.read(buffer)) ! -1) { fos.write(buffer, 0, len); } } } } // 按fileSize截断 try (RandomAccessFile raf new RandomAccessFile(finalFile, rw)) { raf.setLength(fileSize); }第二个是清理。合并完成后临时分片目录要及时清理否则大量大文件分片会占满磁盘。清理时建议先标记状态再由定时任务兜底防止合并过程中删除分片导致文件损坏。7. 实测结论与踩坑实录7.1 一个2GB文件的实测数据我用一台普通的国产化台式机8核CPU、16GB内存千兆内网对2GB的TS格式卫星视频做了完整测试。分片大小10MB并发3纯内网环境无安全网关限速。从点击上传到全部完成总耗时大约2分40秒。期间手动拔掉网线20秒模拟断网恢复后重新选择同一个文件状态查询发现已传了137个分片最终只补传了剩余的68个分片再次完成耗时不到1分钟。断点续传的效果非常明显。对比一下没有续传的方案同样的2GB文件断网后重新传一遍需要2分40秒起步。对于经常断网的老旧内网网络量级差异很容易体会。7.2 踩坑记录formData竞态、进度条跳变、重名覆盖把我实际遇到的三个坑记录在这里希望能帮你少走弯路。坑一在before-send里动态改formData导致并发参数错乱。我之前试图在before-send里给每个分片动态塞chunkIndex字段结果并发请求时分片A还没发出去分片B的before-send已经把formData改掉了服务端收到分片A但里面带的是分片B的序号导致合并时文件错乱。后来改为依赖WebUploader自动携带的chunk、chunks参数文件级参数固定在初始化时的formData里问题解决。坑二续传时进度条跳变。前文已经提到WebUploader自带的进度百分比会把跳过的分片也计算到总量里但跳过动作发生在before-send阶段进度显示上会有一个突然跳到高位的“假象”。处理方案是忽略自带进度自己维护分片成功计数器。坑三只按文件名查询导致错误秒传。这个坑出现在早期版本里。我用文件名做状态查询的标识结果两个不同内容但相同名字的文件在服务端被误判为已存在直接秒传成功。后续一律改为MD5指纹查询重名覆盖问题彻底解决。7.3 还能往哪优化当前方案已经能稳定支撑日常使用但仍有几个优化方向值得考虑MD5计算改成Web Worker避免大文件指纹计算阻塞主线程页面长时间无响应。合并校验增加抽帧验证对卫星视频做首帧或中间帧提取确认文件不是“空包”。服务端分片记录增加Redis缓存避免每个分片都写数据库提升并发性能。增加重连后的自动续传而不是让用户手动重新选择文件体验能再上一个台阶。我在实际改造中发现把断点续传做好之后用户对上传模块的抱怨几乎消失了。最后再分享一个小经验改造WebUploader这类老组件时不要想着一步到位重写所有逻辑。先把分片、状态查询、分片跳过这三条链路打通再考虑进度、重试、界面的优化。服务端的分片目录规范一定要按fileHash压实不然后期排查问题会让你想哭。这套方案已经跑了大半年事实证明老组件不是不能用关键要看你怎么往里面补东西。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。