ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

信创环境下Vue大文件上传:分片、断点续传与秒传实战解析

2026/10/6 17:31:23 拓冰建站 浏览量
信创环境下Vue大文件上传:分片、断点续传与秒传实战解析 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封装成一个独立的工具模块内部做能力检测和fallbackexport 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类型要写成intJava里用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 ResponseEntityMapString, 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放Vue3Vite的项目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.xJava 17打包成一个可执行jar。启动方式不复杂先在服务端启动一个端口配置比如server.port8080再把上传保存路径配成配置文件里的参数用绝对路径防止工作目录问题。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就是因为它可以带病运行、可以降级、可以提醒你“这里可能有坑”——把它跑通只是第一步把它跑进真实环境还不翻车那才是完整体验。