上个月我们刚处理掉一个让人血压飙升的线上问题:卫星视频模块里最常用的那些动辄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内网带宽) | 优劣势 |
|---|---|---|---|
| 2MB | 1024 | 0.2s | 分片太多,请求数和服务端文件句柄开销巨大,容易触发安全网关限制 |
| 10MB | 205 | 1s | 分片数量适中,单片传输失败重传成本低,适合内网环境 |
| 50MB | 41 | 5s | 分片少,但单片失败重传带宽浪费大,断电续传粒度太粗 |
| 100MB | 21 | 10s | 分片更少,但网络抖动导致整片重传的概率大幅上升 |
实测下来,内网环境下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事件。我们在这个事件里判断当前分片是否已经上传过,如果上传过,直接返回false,WebUploader就会跳过该分片。
关键代码:
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.webkitSlice,Firefox老版本使用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时,可以通过参数显式禁用Flash:
const 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/status | GET | fileHash,fileName | 返回completed、uploadedChunks |
/api/upload/chunk | POST | 分片文件、fileHash,chunkIndex,totalChunks,fileName,fileSize | 返回code,chunkIndex |
/api/upload/merge | POST | fileHash,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压实,不然后期排查问题会让你想哭。这套方案已经跑了大半年,事实证明老组件不是不能用,关键要看你怎么往里面补东西。