Vue大文件上传实战:分片、Web Worker加密与断点续传全解析
发布时间:2026/9/19 19:22:11 作者:尧图编辑部 阅读量:1,286

去年底接手了一套视频素材管理后台大文件上传是核心需求。运营同事每天要把几百MB甚至几个G的原始素材传到平台里做转码分发。我一开始图省事前端放一个input typefile后端直接收multipartAxios一把梭上传整个文件。第一个1.2G的测试文件就翻车了浏览器标签页卡死、上传到一半断网、重来、服务端网关超时、日志里全是半截请求。更麻烦的是素材内容涉及客户版权传的过程中裸奔在公网上客户对数据安全要求很高必须做加密传输。折腾了一轮之后最终方案敲定为Vue前端分片 Web Worker承载切片加密 AES-GCM传输加密 断点续传。这套方案上线后稳定跑了三个多月几G的大文件基本都能一次传完上传期间页面照常使用进度条也不骗人。这篇文章就是我这次改造过程中所有技术选型、实现细节、踩坑经历的完整记录适合给正在做Vue大文件上传、又被传输安全卡住的朋友做参考。1. 为什么大文件上传不能直接 POST拆分的底层收益聊加密之前得先说明白为什么大文件要拆开。这不是加密带来的额外要求而是上传本身就必须这么做。很多初学者上来就拿整个File对象交给FormData小文件没问题一旦上了500MB问题会集中爆发。1.1 整包上传的致命瓶颈首先是连接稳定性的问题。按公网正常的上行带宽1MB/s算一个1GB文件需要大约17分钟不间断上传。这期间只要网络抖动一下、Wi-Fi切换一次TCP连接断掉HTTP请求直接失败。绝大多数的服务端网关Nginx、SLB这些默认的超时时间在60秒到几分钟不等大文件请求到达网关后长时间没有完整响应网关会直接掐断连接返回504。其次是浏览器内存压力。File对象本质上是磁盘文件的引用但如果你把整个文件丢进FormData浏览器为了完成请求得把文件内容读进内存一个1GB的文件在请求高峰期可能吃掉几个GB的内存。内存不够就直接白屏、崩溃。还有重试成本的问题。整包上传一旦失败没有任何增量信息可用只能从头再来。传了50%再失败这50%的流量和时间就全浪费了。1.2 分片改变了什么分片的思路很简单把一个大文件用file.slice()切成N个固定大小的块每一块单独上传。这个思路和我们平时搬家很像不会把全部家当塞进一个大集装箱直接吊走而是拆成大小均匀的箱子一箱一箱搬某箱摔了重新搬这一箱就行。分片之后带来的收益是实打实的单次HTTP请求体从GB级降到几MB到几十MB网关超时问题基本消失失败重试粒度变细某一块上传失败只需要重传这一块天然支持并发可以同时开3~5个请求并行传输整体吞吐比单连接快很多进度计算变得准确按已上传字节数/总字节数算真实进度我最终设定的分片大小是32MB。这个值是在“请求次数”和“失败粒度”之间折中的结果太小了分片数太多HTTP请求频繁建立连接反而拖慢速度太大了又退化成整包上传的问题。32MB对1GB文件来说是32个请求量级可控每片上传时长在上行2Mbps的宽带上大约是2分钟左右也在合理范围内。1.3 为什么必须引入加密层很多人会问现在都是HTTPS了不已经加密了吗直接在HTTPS上裸传大文件行不行HTTPS确实加密了传输链路它保护的是“数据在网络上传输的过程中不被窃听”。但加密传输在业务系统里往往还有另一层诉求服务端存储链路和中间环节的敏感数据保护。我的场景里有两点绕不开素材文件经过网关、对象存储代理等多个中间环节HTTPS只在浏览器到网关这一跳解密中间链路和存储层其实是明文客户方合规要求明确写到“内容数据必须端到端加密传输平台方不得留存原始明文”所以最终定的方案是在应用层再做一次加密HTTPS管链路安全应用层加密管内容安全两层各管各的。前端本地用AES-GCM把分片加密成密文再走HTTPS上传服务端解密后入库。这样即使请求日志、对象存储桶被盗拿到的也只是密文。2. 加密方案设计AES-GCM 为主、RSA 做密钥分发加密传输最关键的环节不是算法本身而是密钥怎么协商、怎么管理。我最初也纠结过直接用SM4、国密还是AES后来工程上做了取舍Web Crypto原生支持的AES-GCM性能最好代码也最简洁就定它了。2.1 对称加密与非对称加密怎么配合对称加密AES加解密速度快适合加密大数据块但问题在于加密和解密用的是同一个密钥密钥必须先安全地送到对方手里。非对称加密RSA可以安全地交换密钥但加解密速度很慢不适合直接加密大文件。所以常用的做法是“混合加密”用RSA来加密AES密钥用AES来加密实际文件数据。这个流程可以类比为“保险柜搬货”先把货放进保险柜AES加密然后把保险柜的钥匙装在信封里用对方的公钥锁上RSA加密钥匙钥匙和柜子一起运过去对方用私钥打开信封取出钥匙再打开保险柜取货。具体到我这个项目密钥协商流程是这样的前端请求POST /api/upload/init服务端返回本次上传任务的taskId、动态生成的RSA公钥、推荐分片大小前端用crypto.getRandomValues()生成一个32字节的AES-256密钥作为本次上传任务的“会话密钥”前端用RSA-OAEP算法用服务端公钥把这个AES密钥加密前端把加密后的AES密钥随首个分片一起发送给服务端或者单独调用POST /api/upload/key提交后续所有分片都用这把AES密钥加密服务端用RSA私钥解出AES密钥再用它解密每个分片选RSA-OAEP而不是RSAES-PKCS1-v1_5是因为OAEP引入了随机填充和更强的安全性校验在TLS和Web Crypto里都是推荐的填充方式。2.2 AES-GCM 比 AES-CBC 强在哪里对称加密算法里AES有几种工作模式ECB、CBC、GCM、CTR等。ECB在安全性上有结构泄露问题早就该被淘汰。CBC是常见的分组模式但它只保证机密性不保证完整性攻击者可以篡改密文导致解密出错误或恶意内容而接收方却无法感知。AES-GCM是认证加密模式它在加密的同时会生成一个认证标签GCM Tag解密时如果密文或标签被改动解密接口直接抛错。用大白话说CBC是“把信装进信封再封口”但封口可能被人剪开再粘上而你不知道GCM是“信封封口处加了一道防伪线”一旦有人动过拆开时立刻能发现。对大文件上传来说数据完整性尤其重要。网络传输中哪怕一个bit翻转整个分片都会损坏。以前的做法是额外算一个MD5或SHA-256来验完整性用GCM可以省掉这步——它自带的认证标签已经把篡改检测覆盖了。用Web Crypto实现AES-GCM加密的代码很简单// 生成会话AES密钥 const aesKey crypto.subtle.generateKey( { name: AES-GCM, length: 256 }, true, [encrypt, decrypt] ); // 每个分片生成独立的12字节IV const iv crypto.getRandomValues(new Uint8Array(12)); // 加密分片 const encryptedBuffer await crypto.subtle.encrypt( { name: AES-GCM, iv, tagLength: 128 }, aesKey, chunkArrayBuffer );留意两个细节AES-GCM要求IV不能重复使用。每加密一个分片都必须生成新的随机IV一旦同一个密钥下IV重复攻击者可以通过异或操作恢复明文的一部分。我的做法是每一片都重新调一次getRandomValues同时把IV拼在密文头部一起发给服务端服务端读出来直接用。Web Crypto的encrypt()返回结果是密文认证标签合并在一起的Buffer不需要手动拼接Tag。服务端解密时需要从尾部取出最后16字节作为AuthTag这一点后面在服务端章节单独讲。2.3 会话密钥的短期性设计每个上传任务生成的AES密钥我做了“一次性使用”设计上传任务完成后立即销毁任务过期比如24小时未完成也自动作废。这样即使某次会话的AES密钥被拖库泄露影响的也只是这一个任务的文件不会波及历史或后续上传。从安全的角度讲这也符合“最小暴露面”的原则公钥是服务端动态生成的私钥在服务端内存里不落盘AES密钥只存在前端的JS变量里不写入localStorage页面关闭即销毁。3. Vue 侧落地分片、Worker、并发三层配合前面都是方案设计这里说一下在Vue项目里具体怎么落地的。环境是Vite Vue 3语言用了TypeScript如果你还在用Vue 2也没关系核心逻辑完全一致只是Worker的注册方式略有区别。3.1 先把文件切成片分片的核心是File.prototype.slice()这个API在浏览器里是同步的性能足够好。按32MB一片来切代码大概这样function createChunks(file: File, chunkSize 32 * 1024 * 1024) { const count Math.ceil(file.size / chunkSize); const chunks: { index: number; blob: Blob }[] []; for (let i 0; i count; i) { const start i * chunkSize; const end Math.min(start chunkSize, file.size); chunks.push({ index: i, blob: file.slice(start, end) }); } return chunks; }这里有一点需要注意不要一上来就把所有分片都转成ArrayBuffer。file.slice()只是生成了文件的切片引用真正调用.arrayBuffer()时才会把数据读进内存。如果一次性读512MB进内存照样会白屏。正确做法是边读边传一个分片读进内存、加密、上传、释放再处理下一个。3.2 Web Worker把加密挪出主线程直接用crypto.subtle虽然快但加密32MB数据的过程还是会阻塞主线程浏览器会有明显的卡顿感。为了让页面在上传期间还能正常操作我把加密逻辑放进了Web Worker。Vite下注册Worker非常干净// uploader.ts const uploadWorker new Worker( new URL(../workers/uploadWorker.ts, import.meta.url), { type: module } );Worker内部接收主线程发来的分片ArrayBuffer做加密和哈希再把结果传回主线程。这里有个容易被忽视的优化点postMessage默认会复制一份数据大ArrayBuffer复制开销很高。好在我们可以把ArrayBuffer的所有权转移给Worker不复制数据这就是postMessage的第三个参数——transferable列表。// 主线程发送分片给Worker uploadWorker.postMessage( { type: processChunk, chunkIndex, chunkArrayBuffer, iv, aesKeyRaw }, [chunkArrayBuffer] // 转移所有权不再复制 );Worker端处理完同样把加密结果的所有权转移回主线程。这样整个数据处理过程中内存里只存在一份分片数据不会出现主线程一份、Worker一份的翻倍占用。Worker内部核心逻辑是这样的// uploadWorker.ts self.onmessage async (event) { const { type, chunkIndex, chunkArrayBuffer, iv, aesKeyRaw } event.data; if (type processChunk) { try { const aesKey await crypto.subtle.importKey( raw, aesKeyRaw, { name: AES-GCM }, false, [encrypt] ); // 对原始分片做AES-GCM加密 const encryptedBuffer await crypto.subtle.encrypt( { name: AES-GCM, iv, tagLength: 128 }, aesKey, chunkArrayBuffer ); // 同时对原始分片计算SHA-256用于服务端完整性比对 const hashBuffer await crypto.subtle.digest(SHA-256, chunkArrayBuffer); // 把加密后的密文和哈希返回主线程 self.postMessage( { type: processChunkDone, chunkIndex, encryptedBuffer, hashBuffer, totalBytes: chunkArrayBuffer.byteLength, }, [encryptedBuffer, hashBuffer] // 转移所有权 ); } catch (err) { self.postMessage({ type: processChunkError, chunkIndex, message: err.message, }); } } };Worker做完加密后主线程要做的就是把密文encryptedBuffer装进FormData发送。这里密文已经是“密文认证标签”的形式了不需要再额外拼接。async function uploadChunk(taskId: string, file: File, chunkIndex: number, encryptedBuffer: ArrayBuffer) { const blob new Blob([encryptedBuffer]); const formData new FormData(); formData.append(taskId, taskId); formData.append(chunkIndex, chunkIndex.toString()); formData.append(fileName, file.name); formData.append(fileChunk, blob); return axios.post(/api/upload/chunk, formData, { headers: { Content-Type: multipart/form-data }, timeout: 120000, }); }3.3 并发控制在Vue里的实现加密是串行的但上传不能串行。我实现了一个简单的“并发池”同时允许最多3个分片在上传中。串行上传速度太慢并发太多又会打爆宽带和服务器队列3是个保守但稳定的值。class UploadQueue { private tasks: Array() Promisevoid []; private activeCount 0; constructor(private maxConcurrency 3) {} add(task: () Promisevoid) { this.tasks.push(task); this.runNext(); } private runNext() { if (this.activeCount this.maxConcurrency) return; const next this.tasks.shift(); if (!next) return; this.activeCount; next() .catch((err) { // 单个分片失败记录后稍后重试 console.error([upload] chunk failed, err); }) .finally(() { this.activeCount--; this.runNext(); }); } }实际发送时我会给每个分片包一层重试逻辑失败后延时重试最多3次。重试的延时采用简单退避策略第一次等1秒第二次等3秒第三次等6秒。分片本身就是细粒度重试单元配合断点续传能覆盖绝大多数网络波动场景。3.4 进度计算要真实上传进度如果是假的比没有进度条更让人恼火。我根据已上传字节数在总字节中的比例来计算每个分片上传成功之后把该分片的大小累加到uploadedBytes进度百分比就是uploadedBytes / file.size * 100。这里要小心“先加密后上传”带来的字节数偏差加密后密文比原始大16字节GCM的Tag所以累计uploadedBytes用原始分片大小而不是加密后大小不然最后算出来会超过100%。4. 断点续传与秒传与后端配合的工程逻辑断点续传往往不是前端单方面能搞定的需要后端配合记录“哪些分片已经传过了”。这一章说清楚整个配合逻辑。4.1 服务端如何记录已上传分片我在服务端的思路很直接每创建一个上传任务就在缓存Redis或数据库表里记录这个taskId的分片状态用分片序号做键上传成功后把对应状态标记为“已接收”。同时保存每个分片的密文和哈希等所有分片都传完后再整体解密合并。前端在初始化时除了拿RSA公钥和taskId还会拿到一个已上传分片列表uploadedChunkIndexes。这样页面刷新、浏览器崩溃、网络断开后重新进入只需要把未传完的分片重新传一遍。流程是这样的async function resumeUpload(taskId: string) { const res await axios.get(/api/upload/task/${taskId}/progress); const uploadedSet new Set(res.data.uploadedChunkIndexes); // 只上传还没有完成的分片 chunks .filter((chunk) !uploadedSet.has(chunk.index)) .forEach((chunk) queue.add(() processAndUploadChunk(chunk))); }断点续传这里有个常见坑分片序号在前后端必须严格一致。一旦前端改了分片大小或者脚本版本升级之前的序号就全部对不上了。我这里的做法是前端每次上传前把分片大小32MB写入任务元数据服务端按这个值校验分片边界版本不一致就直接拒绝并返回错误避免错位拼接导致文件损坏。4.2 分片完整性验证链为了确保每个分片没有被篡改或损坏我在每个分片上传时携带两个东西chunkIndex分片序号chunkHashSHA-256哈希Worker里已经算好了服务端收到分片密文后先不解密先存起来等所有分片传完后再统一处理。解密时先用RSA私钥解开AES密钥再用AES-GCM解密每个分片。GCM的认证机制会保证只要密文被改过一个字节decrypt()就直接抛错。解密后的原始分片再计算一次SHA-256与上传时携带的chunkHash比对。如果一致说明分片在传输和存储过程中没有发生任何变化。这一条完整的验证链让整个上传过程变得非常可靠GCM防篡改 SHA-256防错乱。GCM负责“这包东西没被人动过”SHA-256负责“这包东西就是原来那份”。4.3 解密与合并的服务端实现服务端语言用的Node.js结合crypto模块可以实现完整的解密逻辑。下面这个简化版本展示了如何从分片数据中分离IV、认证标签和密文// server/decryptChunk.js const crypto require(crypto); function decryptChunk(encryptedBuffer, aesKey) { const iv encryptedBuffer.subarray(0, 12); const authTag encryptedBuffer.subarray(encryptedBuffer.length - 16); const ciphertext encryptedBuffer.subarray(12, encryptedBuffer.length - 16); const decipher crypto.createDecipheriv(aes-256-gcm, aesKey, iv); decipher.setAuthTag(authTag); return Buffer.concat([decipher.update(ciphertext), decipher.final()]); }所有分片解密成功后按chunkIndex顺序依次拼接再校验整个文件的拼接结果总字节数、文件头特征、最后一块的SHA-256校验通过后通知存储层把文件转正并触发后续的转码任务。5. 上线前必须排查的坑内存、环境与兼容性这一章是我最想写的部分因为这些问题在官方文档里基本找不到全是实打实踩出来的。5.1 浏览器内存被撑爆的元凶一开始我的实现有一个性能问题在主线程里把分片.arrayBuffer()读出来又传给了WorkerWorker加密后返回密文我再用new Blob([encryptedBuffer])包装。看起来逻辑没问题但内存峰值非常高。排查后发现是.arrayBuffer()的调用时机不对。如果你在创建一个分片的时候就调用了arrayBuffer()但实际上还没有准备好上传它这个ArrayBuffer就会一直占着内存。32个分片每个32MB累积下来就是1GB内存这还没算密文和中间对象。我的修正方式用一个简单的信号量控制同时加载到内存的分片数量不要超过并发数1分片在上传队列真正轮到它时才去执行arrayBuffer()读取上传完成的分片立即把变量引用置空让GC可以回收这套优化之后上传一个2GB文件的内存峰值控制在300MB以内流畅很多。5.2 crypto.subtle 只在安全上下文中可用这是个非常容易踩的坑crypto.subtle只在安全上下文HTTPS或localhost中可用。如果你在http协议的内网测试环境里调试会发现crypto.subtle是undefined整个Worker直接罢工。我当时是在公司内网用IP地址调试的一开始怎么也调不通后来一查才知道是这个问题。解决方案也简单本地开发用localhost访问测试环境必须挂HTTPS证书如果没有HTTPS条件就需要自己在Worker里做降级处理只在crypto.subtle存在时启用加密否则走明文并在日志里打警告我的建议是如果业务要求加密传输测试环境也要配HTTPS不然你测不出真实性能。千万别在加密逻辑里搞一个“http明文回退”上线后容易留下安全隐患。5.3 Vite打包后Worker路径找不到Vite开发环境里Worker正常打包部署后却报404这个问题也很典型。因为打包后静态资源路径可能加了CDN前缀或非根路径Worker脚本路径对不上。解决方法是不要手工拼接Worker路径全部用new URL(..., import.meta.url)写法这样Vite会替你处理路径重写和指纹。同时如果项目打包成了相对路径部署base: ./要确认Worker输出路径在dist/assets/下能被正确加载。5.4 大分片请求超时与网关限制还有一个容易忽略的地方是网关对请求体的限制。Nginx默认client_max_body_size是1M虽然分片只有32MB但如果忘了调这个参数前端传一个32MB的请求就会被Nginx直接拒绝。上线前一定要检查Nginx的client_max_body_size应用服务框架的请求体大小限制云负载均衡的请求超时时间既然都聊到这儿了顺手分享一个更诡异的坑Web Crypto的SHA-256计算对大Buffer的性能表现不稳定。在我测试的浏览器版本里对32MB数据做一次SHA-256耗时短的只有几十毫秒长的能到几百毫秒而且波动没有规律。后来我把分片改成16MB波动明显减小了。如果你发现Worker处理速度时快时慢可以考虑调小分片大小。5.5 前端加密后无法使用HTTP缓存这是个架构层面的注意事项加密传输和HTTP缓存天生互斥。密文是随机IV加密的同一文件同一分片每次加密结果都不同所以根本没法做CDN边缘缓存。如果你的场景里存在“一个文件大量用户重复下载”的情况那就要权衡一下是让下载也走加密解密还是换个策略只在上传时加密、下载时通过服务端解密后走HTTPS分发。这两种不同的业务需求会影响你加密层的位置不要照搬。6. 实测数据与体验总结最后交个底说说这套方案实际跑下来的效果。测试环境是普通办公网络上行带宽约20Mbps上传一个1.2GB的视频文件分片大小32MB并发3路总耗时约9分钟。之前整包上传时这个文件大概率会在中途断掉重传两三次都传不上去。用上Web Worker之后我在上传期间同时操作页面、打开其他菜单、切换路由都没有明显卡顿。Chrome DevTools Performance面板里能看到Worker线程占用比较高但主线程的FPS基本稳定在60左右。浏览器兼容性方面Web Crypto、Web Worker、File.slice在Chrome、Edge、Firefox、Safari现代版本上都是原生支持的不需要引入任何Polyfill。唯一需要留意的是Safari早期版本对crypto.subtle的支持不够稳定在实际项目中加了一个特性检测。作为一个做过完整踩坑过程的人我最后给的实用建议是先跑通明文分片上传再加加密层最后做断点续传。三个功能点分开验收每个环节出问题都好定位。如果一上来就同时上分片和加密遇到报错你根本分不清是分片逻辑的问题还是加解密的问题。这套思路不管是Vue还是React前端还是Node都是通用的希望这篇文章能帮你少走一段弯路。