如果你在业务系统里被“上传1GB的视频素材”或者“打包出来的设计稿压缩包有好几个G”这种需求找上门,第一反应多半是先翻nginx配置、调后端接口超时时间。但真正做下去就会发现,问题远不止服务器超时这一件事:文件切不切片、怎么切、怎么传、传一半断了怎么办、换台电脑换种浏览器怎么办、最后落到信创环境里又怎么办。这篇文章我就以自己做过的一个Vue大文件上传项目为底子,把这些年踩过的坑、验证过可行的方案、以及跨平台和信创适配的一套思路完整梳理一遍。
大文件上传这件事,适合谁看?只要你在做企业级后台、内部系统、政务项目、教育平台这类“用户手里真的会握着一个1GB以上文件”的场景,后面的内容都能直接抄作业。我会把方案选型、分片策略、哈希计算、并发控制、秒传与断点续传、进度计算、服务端合并,以及跨浏览器和信创环境适配的细节全部拆开讲,尽量说人话。
1. 为什么大文件上传这么难:先理清需求场景
1.1 大文件上传的三个核心痛点
先说主流程。一个100MB的安装包,用最原始的form表单直接扔给后端,大概率也能成,顶多慢一点。但一旦文件到了1GB、5GB,事情就完全不一样了。
第一个痛点是网络抖动导致的全量重传。上传到80%,办公室Wi-Fi断了,浏览器直接给你把请求断开,后端收到的文件不完整,用户只能从头再来一遍。这种体验放在内部系统里,一次两次还能忍,天天用就一定会被吐槽。
第二个痛点是服务器和后端中间件扛不住。nginx默认的client_max_body_size是1MB,哪怕你改成100MB,也架不住同时几个大文件一起进来。后端如果直接拿内存接整个文件,Java的MultipartFile也好、Node的multer也好,面对1GB文件时内存占用和GC压力会非常吓人,分分钟OOM给你看。
第三个痛点是浏览器本身。以前端视角来看,一次性读入1GB的文件,然后直接塞给FormData提交,浏览器内存占用会瞬间暴涨,页面直接白屏或卡死。尤其企业用户手上那些老旧办公电脑,内存8GB都算顶配了,根本扛不住这种操作。
所以,大文件上传的工程化方案,本质上是把“一个大请求”拆成“很多个小请求”,再配合后端把分片重组回来。分片上传是绕不开的底子,断点续传和秒传是在这个底子上长出来的配套设施。
1.2 跨平台和信创环境到底卡在哪里
跨平台这三个字,在普通互联网项目里可能只是“Chrome、Safari、Firefox测一遍”。但在企业级项目里,尤其是有信创要求的场景,平台矩阵会变得非常复杂。我列一下我们当时实际需要兼容的环境,你感受一下:
- 操作系统:Windows 7/10/11、macOS、统信UOS、银河麒麟、还有部分Kylin衍生的定制版。
- 浏览器:Chrome、Firefox、Edge、360安全浏览器、360极速浏览器、奇安信浏览器、红莲花浏览器,以及一些政务内网里内核版本低到吓人的定制浏览器。
- 终端类型:PC、笔记本、平板、还有部分国产化一体机。
- CPU架构:x86常见,但还有飞腾、龙芯、兆芯、鲲鹏、海光这种Arm或自研架构的机器。
这些环境组合在一起,前端代码稍有不慎就会出问题。比如某些浏览器不支持Web Worker,某些低版本内核不支持ES6的Promise,某些国产系统里的浏览器对IndexedDB的支持有bug,在FileReader读大文件时还会出现内存溢出的现象。
更麻烦的是,很多信创终端是在专网或政务内网里跑的,没法随便访问公网的CDN加载第三方脚本和JS文件,所有依赖都得提前打进包体里,部署到内网。这就意味着,你用的库不能太大,最好也别依赖太新的浏览器API,而且得在最旧的内核上都能跑得动。
所以,跨平台兼容和信创适配,不是一个单独的功能点,它会影响你在架构设计里的每一个选择:要不要用Web Worker、用什么方式算哈希、用fetch还是XHR、第三方上传组件能不能直接引入、构建目标要降到哪个ES版本。
2. 方案选型:自研切片上传的完整设计思路
2.1 为什么不用现成的大厂上传组件
打开npm一搜,大文件上传相关库不少,vue-simple-uploader、uppy、filepond,甚至在Element Plus基础上也可以改造。我一开始也想直接用vue-simple-uploader,毕竟它在普通Web项目里真的很成熟。
但考虑两个现实问题后,我还是决定自研核心上传逻辑:
第一,第三方库的兼容性控制不可控。vue-simple-uploader底层依赖webuploader,webuploader当年在微信、百度网盘时代很勇,但代码已经很久不更新了,对现代浏览器是能跑,但对信创环境里的某些奇怪内核,出了问题你真不知道是它内部哪个环节挂了。
第二,上传功能是整个业务系统里最不能出错的链路,出了问题你没法等库的作者修。自研之后,所有逻辑都在你手里,分片大小、并发数、重试策略、接口字段,全部可以按现场情况随手调。哪怕以后要为特殊项目定制,也只需要改自己代码。
当然,自研不是说完全不用现成的东西,哈希算法我们用SparkMD5,这个大可放心,它是纯JS实现,不依赖原生模块,内网部署也没有压力。UI层面还是用Element Plus的el-upload做外壳,只是把http-request这个入口换成自己的上传实现。
2.2 切片大小、并发数、重试策略怎么定
这部分是很多人容易拍脑袋的地方。我见过有人不管什么文件都切成5MB一片,也见过有人把并发数设成20,结果把公司出口带宽打满,所有人都上不了网。参数的设定,其实是要结合网络环境、服务器限制、浏览器Tab内存一起算的。
切片大小,核心考量是“单片失败重传的成本”和“请求数量”的平衡。切片越小,单次失败重传的代价越低,但请求数量会成倍增加,后端合并文件时的IO压力也会变大。切片太大,又回到了“大请求”的老路上,某一小段网络抖动就会导致整个分片失败。
我们验证下来,普通企业内网建议用5MB到10MB一档。对于1GB文件,10MB就是100个分片,请求数完全可控。如果是百兆甚至更差的网络,建议降到2MB到5MB。弱网场景下分片小一点,重传成本更低,反而能提升整体成功率。
并发数,我当时上线用的是4到6个并发。很多教程喜欢把并发吹到10个,但在信创的低配终端上,10个并发加上哈希计算,CPU直接就飙到100%,页面滚动都掉帧。4到6个并发既能充分利用带宽,又不会把低端设备的浏览器压垮。
重试策略,单片失败重试3次,每次间隔按指数退避——1秒、2秒、4秒。超过3次,标记这个分片上传失败,但是不中断整个队列,等其他分片都传完再把失败的分片统一再来一轮。这样在网络一时波动的时候,整体进度不会跟着崩掉。
2.3 唯一标识与秒传断点续传的判断逻辑
分片上传只是一个骨架,要让体验真正好起来,还得解决“重复传”和“断了重传”两个问题。这两个问题的核心都是:服务端怎么识别同一个文件。
我的做法是用文件内容生成MD5作为唯一标识。用户选中文件后,前端先计算整个文件的MD5,然后请求后端的一个查询接口,把MD5传过去。后端如果发现这个MD5已经存在,直接返回“秒传”,连分片都不用传。否则返回这个文件已经上传过的分片编号列表,前端只需要把剩余的分片传完,就是断点续传。
这里有个细节需要注意:全文件MD5在大文件场景下真的很慢。1GB文件用SparkMD5在主线程算,可能会卡住浏览器好几秒甚至十来秒。所以,哈希计算这一块我放到Web Worker里做,具体方案下一章细说。如果遇到超大文件,比如10GB,全量MD5就有点扛不住了,可以做一个妥协:取文件前2MB、中间2MB、最后2MB,拼起来算一个“分段哈希”。对绝大多数业务场景来说,这个抽样哈希的碰撞概率已经足够低,而且计算速度快了不止一个量级。
断点续传的语义,建议以后端返回“已上传分片列表”为准,而不是纯依赖localStorage。因为用户可能清缓存,也可能换浏览器、换终端接着传。localStorage最多用来保存“当前页面历史选择过的文件任务列表”,让用户在刷新页面后还能看到昨天传了一半的东西,真正状态判定还是得问后端。
3. 核心环节实现:Vue3里手写一个可落地的大文件上传组件
3.1 前端分片与Worker计算哈希
先说分片的整体流程。用户选择文件后,我们从File对象里读取size,按预设的chunkSize计算总分片数,然后用File.prototype.slice切出每个分片Blob。File.slice在所有现代浏览器和大部分旧浏览器里都支持,兼容性非常稳定,不需要额外polyfill。
计算哈希这一步,我单独写一个worker.js,专门负责接收整个文件对象,然后逐片读取内容并累加计算MD5。worker里不能用DOM和主线程的FileReader?实际上不是,Worker里确实可以用FileReader,也可以用File对象直接当消息传给Worker,但某些浏览器在Worker里读取File的效率和稳定性不如ArrayBuffer,所以更稳妥的做法是在主线程把文件用FileReader读成ArrayBuffer再传给Worker。不过,直接把整个大文件读成ArrayBuffer会在主线程里产生一个大对象,内存依然会涨一下,只是时间很短,配合分片读的话内存压力还能接受。
我的做法:在Worker内部用FileReader逐个读取每个分片,再把读到的ArrayBuffer转发给SparkMD5,累加计算哈希,全部读完后把最终MD5 postMessage回主线程。这样主线程全程不参与大文件读取,页面不会卡顿。
// worker.js importScripts('/spark-md5.min.js'); self.onmessage = async (e) => { const { file, chunkSize } = e.data; const totalChunks = Math.ceil(file.size / chunkSize); const spark = new self.SparkMD5.ArrayBuffer(); let currentChunk = 0; const loadNext = () => { const start = currentChunk * chunkSize; const end = Math.min(file.size, start + chunkSize); const chunk = file.slice(start, end); const reader = new FileReader(); reader.onload = (event) => { spark.append(event.target.result); currentChunk++; // 告诉主线程当前算到第几片了,方便展示“计算hash中 12%” self.postMessage({ type: 'progress', percent: currentChunk / totalChunks }); if (currentChunk < totalChunks) { loadNext(); } else { self.postMessage({ type: 'done', md5: spark.end(), totalChunks }); } }; reader.onerror = () => self.postMessage({ type: 'error', chunkIndex: currentChunk }); reader.readAsArrayBuffer(chunk); }; loadNext(); };这里有个小坑:FileReader在线程里逐个读分片,如果单个分片是10MB,1GB就是100次readAsArrayBuffer,在普通电脑上大概一两秒能完成。如果是低端信创设备,可能要花上十几秒。所以前端界面上要在“计算哈希”这个阶段给出明确的进度提示,不然用户以为页面卡死了。
3.2 并发控制与进度计算
分片准备好、哈希也拿到之后,就进入核心上传阶段。我先定义一个任务队列,把所有分片按index排队,然后启动N个并发任务同时跑,每个任务完成一个分片后,从队列尾部再取一个出来。这个“线程池”逻辑不复杂,但它是整个上传稳定性的基石。
请求这块,我推荐用XMLHttpRequest而不是fetch,原因是XHR自带的upload.onprogress能够拿到非常可靠的上传字节进度,而fetch的流式上传进度到今天都还绕一大圈。别看代码老,在兼容性和功能性上,XHR依然是最稳的“上传硬通货”。
function uploadChunk({ file, md5, chunkIndex, totalChunks, chunkSize }) { return new Promise((resolve, reject) => { const start = chunkIndex * chunkSize; const end = Math.min(file.size, start + chunkSize); const formData = new FormData(); formData.append('file', file.slice(start, end)); formData.append('md5', md5); formData.append('chunkIndex', chunkIndex); formData.append('totalChunks', totalChunks); formData.append('filename', file.name); const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/upload/chunk'); xhr.timeout = 60000; xhr.onload = () => (xhr.status === 200 ? resolve() : reject(new Error(`HTTP ${xhr.status}`))); xhr.onerror = () => reject(new Error('network error')); xhr.ontimeout = () => reject(new Error('timeout')); xhr.send(formData); }); }并发池的写法市面上有很多种,最简单的就是一个递归循环:每个并发槽位不断取下一个任务,直到队列清空。我在生产环境里用下来,这种方式最直观,也不容易出死锁。
进度计算我这里给一个公式,避免你走弯路:
- 哈希阶段进度:哈希计算本身占整体进度的10%。
- 上传阶段进度:已完成分片数占总分片数的比例,占整体进度的90%。
如果用户看到的进度条在“计算哈希”阶段卡住几秒不动,这是正常的,别慌。如果非要做得更细,可以给每个分片的上传进度也加权进去,但我实测下来,分片多的时候单个分片字节进度反而让进度条变得很跳,不如用“分片完成数”来得稳。
3.3 服务端合并逻辑要与前端对齐
前端传完所有分片后,会调用一个“通知合并”的接口。后端在这个接口里,把该MD5对应的所有临时分片,按chunkIndex顺序合并成最终文件,再计算一次MD5,和前端传上来的MD5比对,一致则确认上传成功。
服务端合并的代码,我用Node.js举例。重点有两个:第一,临时分片要按chunkIndex排序,二进制流的写入顺序千万不能错;第二,不要直接用fs.readFile把分片一次性读进内存再写,要用流式管道(stream pipeline),否则合并1GB文件时服务器内存又要爆一次。
// 简易示例:koa 路由中的合并逻辑 const fs = require('fs'); const path = require('path'); const { pipeline } = require('stream/promises'); router.post('/api/upload/merge', async (ctx) => { const { md5, filename, totalChunks } = ctx.request.body; const chunkDir = path.join('/data/tmp', md5); const outputPath = path.join('/data/uploads', `${md5}_${filename}`); await fs.promises.mkdir(chunkDir, { recursive: true }); // 按 chunkIndex 依次写入最终文件 const writeStream = fs.createWriteStream(outputPath); for (let i = 0; i < totalChunks; i++) { const chunkPath = path.join(chunkDir, `${i}`); await pipeline(fs.createReadStream(chunkPath), writeStream, { end: false }); } writeStream.end(); // 清理临时目录 await fs.promises.rm(chunkDir, { recursive: true, force: true }); ctx.body = { code: 0, data: { filePath: outputPath } }; });当然,生产环境里还得考虑多实例部署的情况。如果后端开了多个实例,分片落在不同机器上,合并的时候就会找不到文件。这时候要么用共享存储(NFS、MinIO),要么直接用支持分片合并的对象存储服务,比如MinIO的分片上传接口,后端只做代理。这个属于架构层面的问题,前期就要和后端同事对齐,别等到压测才暴露。
4. 跨平台兼容性的细节打磨
4.1 不同浏览器内核的兼容差异
兼容性这块最好的办法不是靠记忆,而是先列一张“最低支持清单”,再针对清单逐项做测试。我整理一份大致的内核兼容矩阵,都是项目里实际验证过的:
| 浏览器环境 | 内核 | File/Blob | FormData | XHR上传 | Web Worker | 建议 |
|---|---|---|---|---|---|---|
| Chrome 90+ | Blink 90 | 完整支持 | 完整支持 | 完整支持 | 完整支持 | 默认目标 |
| Edge 79+ | Chromium 79 | 完整支持 | 完整支持 | 完整支持 | 完整支持 | 默认目标 |
| Firefox ESR 68+ | Gecko 68 | 完整支持 | 完整支持 | 完整支持 | 完整支持 | 重点兼容 |
| 360极速浏览器 | Chromium 78左右 | 完整支持 | 完整支持 | 完整支持 | 完整支持 | 重点兼容 |
| 奇安信/红莲花等 | Chromium 55-70 | 部分支持 | 完整支持 | 完整支持 | 视版本而定 | 需降级 |
| 老版本IE内核 | Trident | 不支持FileReader | 不完整 | 部分支持 | 不支持 | 需彻底降级 |
从表里可以看出,File和FormData其实兼容性都还好,真正要命的还是老内核下的Web Worker。这个我在后面4.3会详细说降级方案。
另外一个容易踩的坑是fetch。低版本Chromium的fetch虽然能用,但对进度监听不友好,而且某些国产浏览器对fetch的abort实现有问题,取消上传时状态码处理不对,会导致重试逻辑误判。所以我在代码里干脆统一用XHR,省心。
还有个小细节:某些老内核浏览器在处理中文字符串和特殊符号文件名的时候,FormData里的filename会乱码。解决办法是前端在append的时候给filename做一次encodeURIComponent,后端接收后再decode一次。别嫌麻烦,这个问题在政务系统里特别常见,因为用户经常传“会议纪要_v2_最终版(不要改).docx”这种文件名。
4.2 移动端WebView与桌面端的差异
现在的业务系统越来越喜欢在平板上办公,移动端WebView也是跨平台里绕不开的一环。Android端的WebView,尤其是国产ROM自带的版本,经常比Chrome App的内核要旧好大一截,iOS端的WKWebView相对统一,但也存在分片内存管理的坑。
移动端最大的区别在于内存和网络。手机浏览器对单个Blob对象的可用内存远低于桌面端,如果你还是按10MB一片去切,遇到老旧中端机,读取分片时很容易触发内存警告,甚至直接把WebView杀掉。我们验证下来,移动端建议把切片大小降到2MB到4MB,并发数降到2到3,这样能显著降低崩溃率。
移动端的网络切换也很麻烦。用户在4G和Wi-Fi之间切换,正在进行的XHR请求会直接中断。这个问题的处理逻辑其实和桌面端断点重传是一致的:只要分片状态在后端有记录,前端在恢复网络后重新拉一次“已上传分片列表”,把剩下的续传就行。唯一要留意的是,切换网络后IP会变,如果后端也用IP做临时分片路径的命名维度,那就会出问题。所以,临时分片路径的命名维度统一用MD5,千万不要掺和IP或者会话ID。
4.3 兼容性降级策略:没有Worker怎么办
Worker在信创环境里的支持情况确实比较魔幻。同样是基于Chromium内核的浏览器,有的版本把Worker禁用或者配置掉了,有的版本一创建Worker就报SecurityError。所以我们的代码里必须有一个feature detect,然后准备一套主线程降级方案。
判断Worker是否可用,不能只看全局对象里有没有Worker,还要在try/catch里实际创建一下,马上terminate掉。因为有些浏览器在安全策略限制下,构造函数存在但一用就抛异常。
如果Worker不可用,哈希计算就退回到主线程执行。主线程算整个文件的MD5,页面会有明显的卡顿,这个没办法完全避免,但可以通过两个手段优化体验:
- 用requestIdleCallback把分片读取拆到浏览器空闲时间片里执行,每读一部分就主动让出主线程,让页面还能响应用户操作。
- 界面上强提示“当前浏览器正在计算文件指纹,请勿关闭页面”,同时用进度条告诉用户没死机。
降级代码的核心思路是把worker.js里的读文件逻辑原封不动搬到主线程函数里,只是把postMessage换成回调。数据流转逻辑完全一致,所以Worker和降级版本可以共用大部分代码。这也是我建议把分片逻辑独立成一个模块的原因,不要在组件里堆一坨不可拆分的东西。
5. 信创环境适配:从浏览器到服务器端的一整套调整
5.1 国产芯片和操作系统下的性能特征
先明确一点:信创环境不是“不能用”,而是“性能差异很大”。同样是打开一个Vue3应用,在飞腾CPU+麒麟系统上的渲染速度,明显比Intel酷睿+Windows慢一截,这是客观存在的硬件生态差异。
这种差异对上传功能的影响,主要集中在大文件读取和哈希计算上。我在飞腾D2000和龙芯3A5000的机器上实测过,1GB文件全量MD5计算,普通桌面电脑大概2到3秒,信创机器可能要15到20秒。分片读取和FileReader的耗时也会拉长,页面感知到的“卡顿窗口”更长。
所以,在信创环境里,前端参数要做差异化调整。我的做法是:探测navigator.platform、userAgent里的CPU架构信息,或者直接用一个后端下发的“环境配置开关”,如果是信创终端,就把默认分片从10MB下调到5MB、并发从6下调到3,哈希方式从全量MD5改成抽样哈希。这样在低性能设备上,整体上传进度反而更流畅。
另外,内存要特别敏感。信创终端很多还是8GB内存甚至更少,浏览器、Office、企业IM同时开着,留给网页的内存非常有限。分片读取时尽量使用Blob.slice + FormData直接发送,不要多此一举把分片转成base64再传。base64会把体积膨胀约33%,而且会让浏览器内存翻倍,在信创环境里经常是压垮页面的最后一根稻草。
5.2 旧内核浏览器的降级与Polyfill
信创浏览器最大的前端隐患就是内核版本落后。很多政务内网里的浏览器,看着牌子不一样,其实内核还是Chromium 55甚至更低。这个版本连async/await都支持得很勉强,ES6的很多语法特性也缺胳膊少腿。
所以,构建目标必须降级。Vue3项目用Vite构建的话,build.target不要默认设置成‘modules’或‘esnext’,要显式设置成‘es2015’甚至更低(比如chrome55)。同时需要引入@vitejs/plugin-legacy插件,给旧浏览器输出ES5版本的bundle,再配合core-js做polyfill。
不要以为这在信创环境里是可有可无的优化。我们一开始没配legacy插件,结果在红莲花浏览器上页面直接白屏,打开控制台就是一堆“Unexpected token ?.”之类的语法错误。加了legacy插件之后,至少90%以上的功能都能跑了。
但注意,Vite的legacy插件不是万能的,有一些API层面的polyfill还得自己处理。比如Object.entries、Array.prototype.includes、String.prototype.startsWith,这些在Core-js里都有。真正麻烦的是像IndexedDB这种重API,做不了完整polyfill,所以上传组件的状态存储就别依赖它,老老实实用后端存分片状态。
5.3 服务器端与网关的适配调整
前端做得再完善,服务器端不支持也白搭。信创项目里,服务器可能是国产中间件,比如东方通TongWeb、金蝶天燕,也可能还是nginx+Java或Node后端。无论哪种,上传接口都有几个典型的“隐形炸弹”。
第一个是请求体大小限制。nginx里默认只有1MB,很多运维会改成100MB,但改了之后还是上传不了大文件。这是因为分片上传时,单次请求体积其实只有切片大小,所以nginx层的client_max_body_size只要大于切片大小就行。真正要留意的是网关层有没有额外限制,比如某些政务云网关默认限制了单请求大小和后端超时时间,这个不排查好,分片上传会一直报413或者504。
第二个是超时设置。前端每个分片的XHR超时时间,我建议和后端接口响应时间匹配起来。分片上传接口本身很快,如果后端还要额外做病毒扫描或文件类型检测,耗时可能变长。前端timeout设60秒是合理值,后端网关的proxy_read_timeout也要相应拉长,最好大于前端timeout,不然前端还在等,网关先把连接掐了,前端就会收到一个connection reset错误。
第三个是HTTPS证书和域名访问。信创内网里,很多系统用的是自签证书或者IP直连,浏览器会拦截混合内容的请求。如果前端页面是HTTPS,上传接口是HTTP,浏览器会直接block掉整个请求。这个问题不解决,前端代码再对也传不上去。稳妥做法是:所有上传请求走同一个域名,统一HTTPS,证书放到内网各终端信任列表里。
6. 常见问题排查与避坑技巧实录
6.1 上传过程中浏览器崩溃或白屏
表现很多样:有的用户说传到一半标签页直接关掉了,有的说整个网页灰掉然后“无响应”,还有的控制台里出现一堆OOM相关的错误。
大多数时候,这不是某一个原因造成的,是“分片读取方式 + 并发数 + 设备内存”共同作用的结果。排查时按顺序过一遍:
- 确认前端是不是把整个文件读成了base64或单一大ArrayBuffer再上传,如果是,改成Blob.slice方式直接放入FormData。
- 确认并发数是不是太高。低端机器上6个并发同时发10MB分片,瞬时内存开销很容易突破1GB。先降到2并发试试,如果稳定了,说明参数需要按设备动态调整。
- 确认是否有其他插件干扰,比如某些安全控件、浏览器助手会拦截xhr请求,导致内存泄漏。
还有一个很隐蔽的问题:用户在一个页面里连续上传好几个大文件,前端没有在切换文件时释放掉上一个文件的引用,导致文件对象一直被挂载在内存里。解决方法是,每次选择新文件后,把上一个File对象、分片数组、进度定时器全部置空。
6.2 文件合并后无法打开或校验失败
这个问题的现象是:前端提示上传100%成功,但是下载下来打开,提示“文件已损坏”或者格式不对。
排查方向主要看服务端,但前端也可能埋雷。常见原因有:
- 分片上传顺序和合并顺序不一致。前端分片编号从0开始,后端合并时也从0开始,这个约定必须统一,最好在前端请求参数里带上totalChunks,后端循环时严格按0到totalChunks-1遍历。
- 后端处理时进行了字符串操作。有些后端框架会自动把request body按UTF-8字符串解析,导致二进制数据被转码破坏。上传分片接口必须确保是裸二进制流接收(multipart/form-data中的file字段),不能经过任何文本编码转换。
- 分片被重复上传覆盖。断点续传时,如果前后两次传同一分片,后端临时文件被覆盖,一般情况下没问题,就怕覆盖过程中文件写入是半截子完成的。所以分片写入最好用“先写临时文件,写完再rename成正式分片”的策略,避免并发写导致文件错乱。
6.3 进度条乱跳与断点续传失效
进度乱跳最常见的原因是:前端进度统计的是“已发起的请求数”,而不是“后端确认写入成功的分片数”。XHR的onload触发后,不代表后端已经落盘,如果后端返回的是202异步处理,前端就要等待后端明确回执后再把分片标记为完成。
断点续传失效的排查思路就更直接了,先抓接口看两次上传传的md5是否一致,再看后端查“已上传分片列表”时用的字段是否等于前端upload时FormData里的字段。很多前后端字段不一致的问题,比如前端传chunkIndex,后端拿chunk,都会造成重复传或者续传失败。
还有一个很多新手会踩的坑:秒传判断用的是“文件名+大小”而不是文件内容指纹。两个不同的文件,只要文件名和大小一样,就会被误判成同一个文件,直接秒传了一个错误文件。所以我们的方案里,秒传判断必须基于MD5,存在冲突风险时宁可多做一次后端校验。
6.4 跨域、网关限制与上传超时
开发环境里大概率是Vite代理转发到后端,生产环境里大概率是nginx反代到后端,两个场景本身就有差异。Vite代理默认解决跨域问题,但生产环境如果前端和后端不同域,就得上CORS。
CORS在这里的坑有两个。第一个是preflight预检请求,XHR发送multipart/form-data请求时,如果附带自定义header(比如Authorization),浏览器会先发一个OPTIONS预检。后端必须正确响应OPTIONS,并且Access-Control-Allow-Headers里要包含所有自定义header。第二个是Credentials,如果接口要带Cookie认证,前端xhr.withCredentials = true,后端也要相应加上Access-Control-Allow-Credentials。两者缺一个,上传请求都会在半路被拦,而且报错信息常常是笼统的“network error”,排查半天才发现是CORS。
网关超时这一块,我在前面5.3已经提到过。这里再补一个经验:如果项目里用了Spring Cloud Gateway或者Kong这类网关,它们通常有自己的请求体超时和连接超时配置,优先级甚至高于后端服务本身。上线前至少用1GB文件走一遍全流程压测,不然等到用户报障就晚了。
还有一个容易被忽略的“软超时”:有些安全设备会检测长时间占用连接的行为,大文件上传过程中如果某个请求耗时超过几十秒,可能会被安全设备掐断。分片上传本身已经把这个风险降得很低了,但如果切片设置得过大(比如50MB一片),弱网环境下单片耗时很长,照样会触发这种软超时。所以切片上限我建议控制在20MB以内。
最后分享一点经验
这类上传功能做完,我自己最大的感受是:不要迷信某个库,也不要过度设计。很多团队一上来就追求极致的秒传体验、炫酷的拖拽动画,结果主流程都还没跑通。真正能上线稳住的功能,往往是把最笨但最可靠的基础链路打磨好了:分片、哈希、并发、重试、合并、校验,每一步都有明确的日志和状态位,出了问题能顺着日志一步步排查到具体分片。
如果你接下来要做大文件上传,我的建议是先准备一到几台不同规格的测试终端,把低配信创设备、老旧系统、不同浏览器都列一张矩阵表,每改一版都跑一遍全流程。这个功能最怕的不是代码写不出来,而是“在我电脑上是好的”这种开发环境幻觉。多留一点时间给异常场景,比多写几个功能点重要得多。