1. 先把需求拆透:信创环境下的大文件上传到底难在哪
接到这个需求的时候,我脑子里冒出来的第一个问题不是“怎么写代码”,而是“这个Demo要在什么浏览器里跑”。你可能会觉得奇怪——大文件上传嘛,网上教程一抓一大把,Vue加个切片、算个哈希、搞个进度条不就完事了?但信创环境这四个字,意味着整条链路都不一样了。操作系统可能是统信UOS或者麒麟,浏览器可能是奇安信、360安全浏览器或者Firefox的某些版本,CPU架构可能是arm64甚至龙芯,Node和npm也可能压根没装。普通的上传组件在这种环境里跑起来,经常不是报这个错就是报那个错。
先说结论:信创环境下的Vue大文件上传Demo,本质上是一套“分片上传+断点续传+秒传”的完整工程实践,而不是一个简单的表单提交。它要解决的问题有三个层面。第一,单个文件几百MB甚至几个GB,如果直接放进一个POST请求里,服务端网关和Web容器分分钟把请求拦下来;第二,内网和跨网段的网络质量不稳定,传一半断掉,从头再来谁都受不了;第三,浏览器兼容性参差不齐,有的旧内核版本连Web Worker和File.slice都不一定支持。所以,这个Demo的核心价值在于:用一套尽量少依赖、尽量兼容的代码,把大文件可靠地送进服务端,并且在你刷新页面、断开网络之后还能续上。
这篇东西适合谁看?三类人:一是正在做信创项目适配的前端工程师,需要快速落地一个可演示的模块;二是刚接触大文件上传、想搞清楚切片和合并原理的同学,可以把它当作一份带代码的拆解笔记;三是需要在投标或者评审现场做Demo演示的技术负责人,需要一份能拿得出手、断网了也不慌的脚本。我会从设计思路、前端实现、服务端合并、信创环境适配、完整跑通步骤和问题排查六个部分来讲,尽量把每一步的“为什么这么做”也说清楚。
2. 前端核心实现:分片、哈希与并发控制
2.1 文件切片:别小看File.slice这一行
分片是整条方案的基石。Vue前端拿到用户选择的文件之后,通过File.slice(start, end)把文件切成固定大小的片,然后一片一片上传。切多大?我一般建议分片大小在2MB到10MB之间,Demo里通常取5MB。为什么不定得更大?因为分片太小会导致请求数量爆炸,分片太大又失去了分片的意义——传输失败重试的成本会高。以一个500MB的文件为例,5MB一片就是100个请求,并发控制在3到6个,整体速度可观,单片的失败重试代价也不大。
切片前还得做一个兼容性检查。信创环境里国产浏览器的内核版本跨度很大,有的极速模式基于Chromium 86以上,支持标准File.slice,但个别兼容模式可能退化到比较旧的内核,标准slice方法行为异常。这时候需要做一个能力降级:
const mySlice = file.slice ? file.slice.bind(file) : file.webkitSlice ? file.webkitSlice.bind(file) : file.mozSlice ? file.mozSlice.bind(file) : null; if (!mySlice) { // 直接抛错,提示用户升级浏览器或切换到极速模式 }切片本身不会把整份文件复制进内存,File对象在系统底层是有文件句柄支撑的,slice只是指定了读取范围,真正读取是随用随读。但如果你在计算哈希时用FileReader把文件整个读成ArrayBuffer再算,那内存就危险了。后面讲哈希的部分会专门说这个问题。
分片数量除了影响请求数,还有一个作用:它是整体进度的分母。这里要小心浮点数的坑,分片数用Math.ceil计算,比如const totalChunks = Math.ceil(file.size / CHUNK_SIZE),最后一片的大小可能不足CHUNK_SIZE,上传逻辑里要按实际大小取,否则文件尾部会丢数据或者合并失败。
2.2 计算文件哈希:既要唯一标识也要“秒传”能力
哈希的作用被很多人低估了。表面上看,它只是为了给文件生成一个唯一ID,用来让同一个文件的后续分片在服务端能关联起来;实际上,有了哈希之后,秒传就变得非常自然。服务端收到前端传来的哈希值,先去查一下这个文件是不是已经存在——如果存在就直接返回“上传完成”,前端连分片都不用发了。这在大文件场景里是个很讨喜的体验优化,尤其是信创内网里用户反复传同一个大文件的情况特别多。
计算哈希的工装在信创离线环境里尽量用轻量的。我推荐spark-md5,它是纯JavaScript实现,没有原生模块,不存在编译问题,放到离线内网也能跑。用法也很直接,最关键的技巧是要增量计算,不要一次性读取整个文件:
import SparkMD5 from 'spark-md5'; export function calculateHash(file, chunkSize = 2 * 1024 * 1024) { return new Promise((resolve, reject) => { const spark = new SparkMD5.ArrayBuffer(); const reader = new FileReader(); let currentChunk = 0; const totalChunks = Math.ceil(file.size / chunkSize); const loadNext = () => { const start = currentChunk * chunkSize; const end = Math.min(start + chunkSize, file.size); reader.readAsArrayBuffer(file.slice(start, end)); }; reader.onload = (e) => { spark.append(e.target.result); currentChunk++; if (currentChunk < totalChunks) { loadNext(); } else { resolve(spark.end()); } }; reader.onerror = reject; loadNext(); }); }这段代码看起来简单,但有几个细节值得说。第一,每次只读取2MB进内存,整个算完500MB的文件,内存占用始终是几MB级别,不会把浏览器撑爆。第二,FileReader每次读取结束之后,上一次的ArrayBuffer会被垃圾回收,所以不用担心内存堆积。第三,这个计算过程非常耗时,500MB的文件可能要算好几秒甚至更久,如果你把它放在主线程上跑,页面会直接卡死,鼠标转圈,用户会以为程序坏了。所以哈希计算一定得放进Web Worker,下面会专门讲。
2.3 并发控制:手写一个上传调度器
分片切好了,哈希也拿到了,接下来就是把分片发出去。最朴素的写法是for循环里直接await一个个传,但是100个分片串行上传,速度实在太慢了。反过来,一次性把100个请求全部打出去,浏览器会崩溃,服务端也扛不住。所以需要并发控制:我同时只发N个请求,一个完成之后,从队列里补一个进来。
为什么不用现成的p-limit这类库?因为信创内网环境里装npm依赖可能很麻烦,这个Demo我希望能做到依赖极少、什么地方都能跑。手写一个并发池其实非常便宜,二十几行代码的事儿:
export async function runWithConcurrency(tasks, limit = 4) { const results = []; const executing = []; for (const task of tasks) { const p = Promise.resolve().then(task); results.push(p); if (limit <= tasks.length) { const e = p.then(() => { executing.splice(executing.indexOf(e), 1); }); executing.push(e); if (executing.length >= limit) { await Promise.race(executing); } } } return Promise.all(results); }这个调度器虽然短,但理解它需要一点点思维转换:核心思路不是“等所有请求完成”,而是“维持一个不超过limit的在飞请求池”,任何一个请求完成,就腾出一个位置给下一个任务。实际用起来,我把并发数定在4或者6,在大多数内网服务器上比较稳,不会把带宽打满,也不会被网关误判成攻击。至于为什么不用10甚至更多——你要是实测过就会发现,并发过高时服务端会开始排队,总吞吐量反而下降,多出来的只是无意义的线程开销和网络连接碎片。
上传单个分片,我强烈建议用XMLHttpRequest而不是fetch。原因很简单:fetch虽然API优雅,但它的上传进度支持不是开箱即用的状态,要拿ReadableStream去自己实现,代码复杂还有兼容性门槛。而XMLHttpRequest自带upload.onprogress,一行代码就能拿到当前分片的上传字节数。Demo追求的是能跑、能演示、能看懂,不是炫技。
2.4 用Web Worker扛住分片和哈希,避免页面卡死
我在前面提了两次“Web Worker”,现在把它单独拎出来说,是因为这既是性能问题,也是信创环境下最容易踩兼容性坑的地方。Web Worker的作用是开一个后台线程,把耗时计算从主线程上挪走。哈希计算、分片读取这些活儿全都可以扔进去,页面该渲染渲染、该响应响应。
Worker用起来不复杂,但要注意Vite和Webpack打包时的差异。在Vite项目里,直接new Worker(new URL('./hash.worker.js', import.meta.url), { type: 'module' })就可以;在Webpack项目里,通常使用new Worker(new URL('./hash.worker.js', import.meta.url))也能被处理,但老版本的Webpack可能对Worker的构建支持不太好,可能需要额外装worker-loader。我建议在Demo里把Worker封装成一个独立的工具模块,内部做能力检测和fallback:
export function createHashWorker() { if (typeof Worker !== 'undefined') { try { return new Worker(new URL('./worker/hash.worker.js', import.meta.url), { type: 'module' }); } catch (e) { return null; } } return null; }如果Worker创建失败怎么办?我通常会直接降级到主线程计算,同时弹个提示告知“当前浏览器不支持后台计算,哈希期间可能需要等待十几秒”。这是取舍:信创环境里你不可能要求用户换浏览器,适配的本质就是让功能在不太行的环境里也能用,只是体验降级。
哈希本身算完以后,其实可以顺手把分片的上传任务也交给Worker去做。有人会觉得这有点小题大做,但在超大文件场景下,如果主线程里同时跑着多个分片请求和回调,UI还是会有卡顿感。Demo里我会把“哈希计算”放Worker,把“分片上传”留在主线程统一调度。这么做的理由是:分片上传本身是异步的,并不会阻塞UI;真正阻塞UI的是大量同步的二进制数据读取和哈希运算。把最重的部分移走,性价比最高。
2.5 进度与交互:让用户知道它还在干活
大文件上传最坏的结果不是慢,而是看起来像卡死了。进度反馈不是锦上添花,是刚需。这里要分清两个层面:单分片的进度和整体进度。单个分片的进度通过XMLHttpRequest的upload.onprogress拿到,展示在第几个分片里;整体进度则简单得多,用“已成功上传的分片数/总分片数”计算,不需要统计每个分片的字节细节。
const onOverallProgress = (loadedChunks) => { const percent = Math.round((loadedChunks / totalChunks) * 100); statusText.value = `正在上传 ${loadedChunks}/${totalChunks} 分片`; };这里有个小坑:用户看到99%之后停了好一会儿,特别容易焦虑。原因是最后一片可能特别小,而服务端合并文件的时候需要一点时间。所以进度可以分两段展示——上传阶段占95%,服务端合并阶段占最后5%,不然你要花很多时间去解释“为什么进度条不动了”。Vue这边用一个ref记录状态,上传中、暂停中、已完成、出错,四种状态分别渲染不同的按钮组,这个交互不需要UI库也能写得清爽。
另外,断点续传的“断点”两个字的实现,其实也藏在分片状态里。我用的方案是:每成功上传一个分片,就把它的序号记到localStorage里,刷新页面或者重新打开浏览器之后,先读一遍localStorage,把这个文件已上传的分片过滤掉,直接传剩下的。这个方案在Demo级别够用,但它有一个明显的限制,localStorage通常只有5MB左右,大文件几百个分片的话,序号列表撑不撑得住?撑得住,几百个数字只占几KB。真正要小心的反而是服务端:断点续传依赖服务端能认得哪些分片已经存在,所以标识符和路径设计要一致,这个放到服务端那一节细说。
3. 服务端设计与合并策略
3.1 接口设计:三个接口让流程闭环
前端再花哨,服务端接不住也白搭。这个Demo的服务端我用Spring Boot写了一个最简版本,因为信创后端项目绝大多数是Java技术栈,你在演示环境里跑一个Spring Boot工程是最不违和的。核心只有三个接口:
POST /upload/chunk:接收单个分片。GET /upload/file/exists:查询文件是否已存在,用于秒传判断。GET /upload/chunk/exists:查询某文件已有哪些分片,用于断点续传。
请求参数的约定要提前定死,否则前后端联调全是坑。我用的参数是这样一组:identifier是文件哈希,fileName是原始文件名,totalChunks是总分片数,chunkIndex是当前分片号(从0开始),chunkSize是分片大小,file是二进制分片本身。注意chunkIndex类型要写成int,Java里用String接收再做转换的写法是最容易出Bug的,直接声明类型更干净。
文件尺寸参数不要漏。合并时最怕的情况是分片全齐了,但拼接出来的文件和原始文件差几个字节。传一个fileSize上来,合并完成后用File.length()对比,不一致就主动报错。这个校验不值钱,但能挡掉很多复制粘贴或者目录混乱导致的隐藏问题。
3.2 分片落盘与合并校验
服务端的落盘策略是:先按identifier建一个临时目录,所有分片都落在里面,分片文件命名为chunk_0.tmp、chunk_1.tmp这样的序号格式。全部收齐之后,再按顺序合并成最终文件,并移动到正式目录。很多人第一次写分片上传的后端,会犯一个错误:直接在Controller里用new FileOutputStream去覆盖写同一个目标文件。前端的并发是4到6个请求同时打过来的,Java的流并不是线程安全的,两个分片同时写会互相覆盖,文件直接损坏。
所以我建议临时目录缓冲方案。每个分片独立写入自己的临时文件,互不干扰。合并的时候,按序号遍历分片,用Files.copy或者流式追加,写入最终文件。核心代码大概长这样:
@PostMapping("/upload/chunk") public ResponseEntity<Map<String, Object>> uploadChunk( @RequestParam("identifier") String identifier, @RequestParam("chunkIndex") int chunkIndex, @RequestParam("fileName") String fileName, @RequestParam("fileSize") long fileSize, @RequestParam("file") MultipartFile chunk) throws IOException { if (chunk.isEmpty()) { return ResponseEntity.badRequest().body(Map.of("success", false, "message", "分片为空")); } Path chunkDir = Paths.get(UPLOAD_TEMP_DIR, identifier); Files.createDirectories(chunkDir); Path chunkFile = chunkDir.resolve("chunk_" + chunkIndex + ".tmp"); chunk.transferTo(chunkFile.toFile()); return ResponseEntity.ok(Map.of("success", true, "chunkIndex", chunkIndex)); }合并的接口我放在/upload/merge,参数是identifier、fileName、totalChunks、fileSize。合并前先做一次完整性检查:遍历目录,看看分片数量是否等于totalChunks,然后逐个把字节流追加到目标文件。追加完之后立即用目标文件长度比较fileSize,不一致就删除并报错,绝不把坏文件留着。你要明白,文件合并成功那一刻,之前的所有努力才有意义,所以宁可多检查,也不要稀里糊涂地返回成功。
3.3 秒传与断点续传的服务端支持
秒传和断点续传在服务端的实现逻辑其实都很简单,难的是设计得清晰。
秒传的查询接口返回的是一个“文件是否存在”的布尔值。存在就返回{ uploadComplete: true },前端直接跳到“上传完成”状态;不存在就返回“未上传”。这个接口要足够快,一般通过文件哈希去正式文件目录里查一下就行。如果项目量级再大一点,可以把文件元数据入库,这里Demo就用文件系统遍历。
断点续传的查询逻辑也简单:同一个identifier的临时目录里已经有哪些chunk_N.tmp,把它们全列出来返回给前端。前端拿到这份清单,把已存在的分片从上传队列里剔除。注意一个细节:如果服务端临时目录被清理过,那前端以为“已上传的部分”其实已经不存在了,合并必然失败。所以稳妥的做法是前端把“已存在分片”只是当作建议,真正把分片发过去时,如果服务端发现对应临时文件已经没了,就直接覆盖写入新的,不要返回错误。
还有一个容易被忽视的问题:临时目录的清理策略。如果用户传了一半就关了页面,临时目录就一直躺在磁盘上,日积月累会占据大量空间。我会在合并成功后把identifier临时目录清掉;另外再做一层保护,启动时扫描临时目录,删除超过24小时没有更新的目录。对于Demo来说,这层策略已经足够。
4. 信创环境下的搭建与适配实录
4.1 操作系统与浏览器的实测兼容点
信创环境不是一个单一的平台,它是“国产CPU+国产操作系统+国产浏览器+国产中间件”的组合体,组合方式千差万别。我在实际适配中主要碰到三种情况:统信UOS配上奇安信浏览器、麒麟配上360安全浏览器、以及其他发行版配上Firefox或者自带WebView壳的应用。不论哪种组合,前端代码跑不跑得动,最后都要看浏览器内核版本和特性支持,而不是看操作系统名字。
我把需要重点检测的API列出来:File.slice、Web Worker、XMLHttpRequest.upload、localStorage、Promise.finally、async/await。你可能会说,async/await不是现在标配吗?但有些旧内核浏览器会把async语法直接解析失败,所以如果你用Vite构建,默认的target是baseline-widely-available,包含了ES2015以上的很多语法,可能还是不够保险。构建的时候把build.target设成es2015或者微信的“兼容模式”,这是我在信创项目里的常规操作。
还有一类问题不是能力不支持,而是行为差异。比如某些国产浏览器的极速模式,在处理大请求时会有超时限制,默认60秒没返回就掐断连接;而分片上传一个好的地方就是单个分片体积小,传输时间短,天然规避了这类问题。这也算是大文件方案在信创环境里的一个隐性优势。
4.2 安装Node与npm依赖的三种路径
信创机器上第一步就卡住的往往是Node环境。不同于个人开发机的Windows或者macOS,信创机器上很多时候根本没装Node,或者装了但版本极其古老。我梳理了三种路径,按推荐程度排序。
路径一,直接下载Node官方编译好的tar.xz包,解压到/opt目录,然后软链到/usr/local/bin。这个方法的好处是干净,不依赖系统包管理器,而且能自己选版本。但要注意CPU架构:飞腾、鲲鹏是arm64架构,要下载linux-arm64的包;如果是龙芯这种loongarch架构,Node官方没有预编译包,就比较麻烦,可能需要找移植版或者用系统包管理器装。总之先跑uname -m看一眼架构,再决定下载哪个包。
# 以x86_64为例 wget https://npmmirror.com/mirrors/node/v18.20.4/node-v18.20.4-linux-x64.tar.xz tar -xf node-v18.20.4-linux-x64.tar.xz mv node-v18.20.4-linux-x64 /opt/node ln -s /opt/node/bin/node /usr/local/bin/node ln -s /opt/node/bin/npm /usr/local/bin/npm node -v路径二,在能联网的机器上下好依赖包,离线拷过去。前端依赖有一个好,绝大多数都是纯JavaScript,只要把node_modules目录完整拷过去,理论上就能跑。但有个前提:依赖树里不能有需要调用node-gyp编译的原生模块。好在我们的Demo用到的东西很少,spark-md5、vite、vue这几个都是纯JS,离线可运行。后端Spring Boot也一样,用Maven先把依赖打进jar,就能在信创服务器上直接java -jar跑起来,前提是服务器上有对应CPU架构的JDK。
路径三,搭一个自己的源,适合要长期做信创适配的团队。在内网机器上装一个verdaccio,把常用前端包缓存在里面,开发人员统一配置registry指向内网地址。这个方案前期投入最大,但只要搭好了,后续整个团队在隔离网里开发就跟有外网一样舒坦。
4.3 CPU架构与前端构建产物
前端构建产物是JavaScript文件,理论上与CPU架构无关,但有三个实际问题:第一,你本机开发时的依赖安装,可能因为在arm架构电脑上装某些依赖而失败;第二,构建产物要部署到信创的Web服务器上(比如东方通、宝兰德这些中间件),需要确认服务器能正常托管静态资源;第三,如果信创电脑性能比较弱,Vite冷启动大型项目会很慢,这时候不如直接用构建后的静态文件,配合一个简单的静态服务器看Demo效果。
我自己在arm64的麒麟机器上实测过,Vite的dev server可以正常启动,只是冷启动稍慢,代码热更新响应也要一两秒。这不影响Demo演示,但如果你习惯了自己的高性能开发机,要有心理预期。
还有一点要注意:构建之后的资源路径。很多信创项目部署时Web应用的访问路径可能不是根路径,而是http://ip:port/应用名/。Vite默认base: '/',会导致js和css资源404。构建前把base改成相对路径base: './',或者改成实际部署的子路径,这个细节能让你的Demo在任意中间件下都不容易出状况。
4.4 离线内网环境的依赖同步方案
信创内网往往和外网隔离,这是适配中最磨人的地方。你可能做好了全部代码,结果发现连vue依赖都装不了。我的经验是,把所有要用的依赖以npm pack的形式打包下载到U盘,再复制到内网机器上。
具体做法是:在外网机器上新建一个空目录,用npm安装,然后把node_modules压缩包直接拷进内网。这里有个隐藏陷阱:如果外网机器的Node版本和内网机器不一致,少数依赖会有版本兼容问题。稳妥起见,我用的是先写死package.json里的版本,锁版本,然后整包迁移,到内网之后不执行npm install,直接用压缩包里的node_modules。如果你要修改代码重新构建,直接在内网机器上跑npm run build即可,不需要重新安装。
如果连U盘拷贝都受限制,那就只能用歪招了——把node_modules压缩包上传到内网某个允许上传的地方,然后在内网下载。这个方法听起来不优雅,但在实际项目里使用频率相当高。你要做好一件事:把整套依赖的版本清单维护好,写个README放旁边,避免三个月后自己都想不起来当时用的哪些版本。
5. 一行一行跑通Demo:从空目录到断点续传
5.1 准备工程骨架
为了这个Demo能快速复现,我把工程拆成两个目录:frontend放Vue3+Vite的项目,server放Spring Boot的工程。你不需要从零创建Vue项目,我推荐直接手动搭,这样依赖最少。核心依赖就三个:vue、vite、@vitejs/plugin-vue,外加一个spark-md5。手动搭一个最小Vite工程的好处是,你能理解每一层结构,出了问题也能快速定位。
先在frontend目录下创建package.json:
{ "name": "vue-upload-demo", "version": "1.0.0", "scripts": { "dev": "vite", "build": "vite build" }, "dependencies": { "spark-md5": "^3.0.2", "vue": "^3.4.0" }, "devDependencies": { "@vitejs/plugin-vue": "^5.0.0", "vite": "^5.2.0" } }然后创建vite.config.js,注意加一个base: './',原因我在前面说过了——信创项目的部署路径经常不是根路径:
import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; export default defineConfig({ base: './', plugins: [vue()], build: { target: 'es2015' } });index.html、src/main.js、src/App.vue这个基本结构就不展开说了。重点是src/components/Uploader.vue,整个上传逻辑都集中在这里,配合src/utils/目录下的几个工具模块。
5.2 前端核心代码落位
我把前端逻辑拆成了四个文件:
utils/file.js:切片、生成分片列表、兼容性检测。utils/hash.js:封装spark-md5的增量哈希计算。utils/upload.js:并发调度器、单分片上传、进度汇总。worker/hash.worker.js:接收文件对象,在Worker线程里执行哈希计算。
Uploader.vue里的核心流程是:选择文件后,先做哈希,再初始化分片列表,然后查询服务端已上传分片,过滤掉已存在的,最后启动并发上传。这里我贴一下Chunk上传的核心部分,代码不长,但是把关键参数都放在明面上:
import { runWithConcurrency } from '../utils/upload'; async function uploadChunks(uploadTask) { const { file, identifier, chunkSize } = uploadTask; const totalChunks = Math.ceil(file.size / chunkSize); const startTime = Date.now(); // 查询已存在的分片 const existsResponse = await fetch( `/upload/chunk/exists?identifier=${identifier}` ); const existsData = await existsResponse.json(); const uploadedIndexSet = new Set(existsData.uploadedChunks || []); const tasks = []; for (let i = 0; i < totalChunks; i++) { if (uploadedIndexSet.has(i)) { loadedCount.value++; continue; } const chunk = file.slice(i * chunkSize, Math.min((i + 1) * chunkSize, file.size)); tasks.push(() => uploadSingleChunk(chunk, identifier, i, totalChunks, file.size)); } await runWithConcurrency(tasks, 4); await fetch('/upload/merge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier, fileName: file.name, totalChunks, fileSize: file.size }) }); }uploadSingleChunk内部用的是XMLHttpRequest,实现方式就是常规的FormData追加、xhr.open、xhr.upload.onprogress更新当前分片进度、xhr.onload解析返回值。有一个细节:如果某个分片上传失败,不要立刻抛弃它,我做了三次重试,每次重试前等待500ms、2s、5s,超过三次才真正把那个分片标记为失败,并且整体中断。这样在演示网络抖动的时候,能稳定扛住。
5.3 启动前后端并完成一次完整大文件上传
后端工程我用Spring Boot 3.x,Java 17,打包成一个可执行jar。启动方式不复杂:先在服务端启动一个端口配置,比如server.port=8080,再把上传保存路径配成配置文件里的参数,用绝对路径防止工作目录问题。
server: port: 8080 upload: temp-dir: /data/upload-temp final-dir: /data/upload-files前端开发时通过Vite代理解决跨域问题,vite.config.js里加一个proxy配置,把/upload开头的请求转发到8080端口。这样前端访问http://localhost:5173就能直连后端,不需要处理CORS。如果你用构建后的静态文件部署在同样一个中间件里,就不存在跨域问题。
跑通了以后要做的验证点比较明确:选一个1GB的测试文件,注意观察三点。第一,网络面板里同时存在的请求数不超过4个;第二,上传过程中把浏览器标签页切到别的任务,页面没有卡顿;第三,传了几十秒之后手动刷新页面,重新选择同一个文件,已传的分片被跳过,进度不是从零开始。这三点全部满足,Demo的核心闭环就成立了。
5.4 验证脚本:把Demo“打”一遍
演示环境最容易翻车的时刻,是现场网速不稳或者服务端机器性能拉胯。我平时会准备一份简单的验证清单,做完一遍心里就有底了:
- 小文件(比如1MB)上传,确认基本路径畅通。
- 500MB大文件上传,启动Worker线程,观察UI流畅度。
- 上传过程中强制刷新页面,重新选择文件,验证断点续传。
- 上传完成后重新选择同一个文件,验证秒传,接口立刻返回完成。
- 换一个信创机器的浏览器登录,验证兼容性降级逻辑是否生效。
这五项都过了,这个Demo在任何场合拿出来都足够说明问题了。需要强调一点:演示前一定要把服务端临时目录清干净,否则上一次测试的残留目录可能会干扰结果,尤其是秒传验证,会被残留文件“碰巧通过”。
6. 常见问题与排查速查表
6.1 我在这类项目里踩过的坑
分片上传的Bug有一个共同特点:不是必现的,而是和数据、网络、时机绑定在一起的。最典型的就是“普通文件能传,大文件传不上去”。排查思路要按顺序来:先看网络面板,请求有没有发出去;再确认分片大小,看看是不是超过了服务器HTTP body的限制;最后看服务端日志,是不是并发写文件时出了问题。
另外一个坑是文件名编码。信创系统上如果是Linux,文件系统默认UTF-8,中文文件名问题不大;但如果最终目录被挂载到某些特殊的存储上,中文文件名可能会乱码。我的处理是:最终落盘的文件名统一改成identifier_原始文件名的格式,即使中文乱码也不影响关联。尤其是演示场景,乱码文件名出现在屏幕上会非常尴尬。
还有一个隐蔽问题,是前端哈希计算的耗时和Worker内内存的限制。某些国产浏览器对Web Worker的内存限制比较严格,如果你在Worker里读取超大文件,可能会触发Out of Memory崩溃。我的经验是:如果文件超过2GB,可以换成抽样哈希或者分段哈希,不要固执地对整个文件做全量MD5。秒传的命中率确实会低一点,但换来的是稳定性和响应速度。
6.2 现象排查对照表
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 点击上传后无任何请求发出 | 浏览器不支持File.slice,兼容检测直接抛错 | 检查浏览器内核,开启极速模式,或提示用户更换浏览器 |
| 分片发出去但服务端收不到临时文件 | 上传临时目录没有写权限 | 检查目录权限,chmod 777 /data/upload-temp,或改用程序自动创建目录 |
| 合并后文件大小和原始文件不一致 | 分片大小计算用了round而不是ceil,最后一片截断 | 检查totalChunks计算逻辑,用Math.ceil |
| 刷新后重传,进度从0开始但请求被全部跳过 | 服务端临时目录被清理 | 调整清理策略,不要删除当天目录;或改成提前查询后自动重建 |
| 页面在算哈希时卡死 | Worker创建失败降级到了主线程 | 检查Worker创建代码,不要在主线程里用FileReader全量算 |
| 上传中出现大量失败重试,速度极慢 | 并发数过高,服务端队列堆积 | 把并发数从10降到4或6,观察网络面板吞吐量变化 |
| 秒传验证失败,每次都要重新传 | identifier生成不稳定 | 检查标识符拼接逻辑,fileName、size、hash组合是否有变更 |
排查问题的时候有一个通用技巧:把日志打全。前端在每次分片上传开始和结束时console.log一条带identifier和chunkIndex的记录;后端在Controller入口和合并方法里用log.info输出参数。两端一对照,问题出在哪一环,几乎立刻就能定位。如果Debug时发现前端和后端记录的chunkIndex错位,那八成是某个参数名写错了,Java的@RequestParam和前端FormData的字段名要逐一核对。
7. 最后补充几个实用的细节
这个Demo做下来,我有一个很深的体会:表面上你是在写一个文件上传功能,实际上你是在跟浏览器的极限、服务器的磁盘、网络的波动三方较劲。把分片、哈希、并发、合并这四个机制真正理解透了,以后不管换什么UI框架、什么后端语言,内核都是同一套思路。哪怕项目里有现成的组件,我也建议至少手写一遍这个流程,因为只有亲手踩过并发写文件的坑,才会明白为什么要先落盘再合并,而不是边传边写。
给几个我很推荐的小改进方向吧。一是给上传过程加一个“暂停/恢复”按钮,实现上比想象简单,挂起并发池的Promise就好了,但给人的感受完全是两个档次;二是增加一个“上传完成后自动校验文件完整性”的步骤,前端把文件重新读一遍算哈希,跟后端返回的哈希对不对得上;三是把后端临时目录的清理任务做成定时任务,给生产环境留后路。
最后再说一个容易忽略的点。信创环境里开发调试,一定要拿真实的目标浏览器提前做一轮冒烟测试,而不是在你的Chrome里测完就高高兴兴打包。很多兼容性问题,只有到那个浏览器里才会现出原形。Demo之所以是Demo,就是因为它可以带病运行、可以降级、可以提醒你“这里可能有坑”——把它跑通,只是第一步;把它跑进真实环境还不翻车,那才是完整体验。