尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Vue3大文件分块上传实战:从切片到断点续传

Vue3大文件分块上传实战:从切片到断点续传 做了多年前端跟“文件上传”死磕的次数实在不少。尤其是这两年做后台管理系统和数据中台项目动不动就是几个G的日志、压缩包、数据库备份。直接把文件怼到FormData里再post上去要么浏览器内存直接告警要么后端接口超时崩溃要么用户等了一个小时突然断网全部白搭。后来我把Vue3分块上传的技术方案吃透之后才算是真正把“大文件上传”这件事拿捏住了。这篇内容我就用一个Vue3的完整DEMO把分块上传大文件的实现思路、前后端核心代码、以及我在实际项目里踩过的一堆坑全部摊开讲清楚。这个方案适合所有正在做Vue3后台管理的同学也适合备战Vue3相关岗位面试的朋友因为“大文件分块上传”基本是前端进阶绕不开的一道坎。你不需要有很深的算法功底但需要掌握Vue3组合式API、axios和一点Node.js的基础接下来我会把每一步的“为什么这么做”也说明白而不是只贴一堆代码让你自己琢磨。1. 分块上传的整体设计为什么大文件不能一把梭1.1 大文件直传的三个绕不过去的坎先说最直接的问题为什么不能像上传普通图片那样直接把整个文件塞给后端我给你捋三个核心痛点。第一个痛点是内存。使用input[typefile]拿到文件后如果你用FileReader.readAsDataURL()去读浏览器会把整个文件以Base64字符串的形式加载到内存里。一个2GB的文件Base64之后大概1.5倍体积3GB内存没了。页面不卡死才怪。就算你用FormData直接append文件对象浏览器内部一样要处理完整的数据缓冲。前端直接内存爆掉用户体验就是“浏览器白屏风扇狂转”。第二个痛点是网络中断的成本。一个1GB文件按普通家庭宽带50Mbps的上涨速度大约需要3分钟左右。如果传输到90%的时候WiFi闪断一下整个请求直接失败。再重新传一遍用户心态当场爆炸。而分块上传的核心收益就在这里每块切片独立上传哪块失败就重传哪块成功过的切片标记为已完成断点续传天然成立。第三个痛点是服务端的限制。绝大多数网关、Nginx默认对请求体大小都有限制比如Nginx默认client_max_body_size是1MB。你虽然可以调大但把上限调到10GB意味着服务器要保留这么大的内存或临时文件空间来处理单个请求稍微有点流量进来就容易被拖垮。分块上传把一个大请求拆成若干个普通请求对服务端来说就是普通的几十MB级请求压力完全在可控范围。1.2 DEMO的核心能力清单我做的这个Vue3 DEMO不是“玩具级”代码而是冲着能够直接改造进生产项目去的。整个功能清单如下文件选择后自动切片默认每片大小设置为5MB用SparkMD5计算整个文件的指纹作为文件的唯一标识把MD5计算放到Web Worker里执行避免主线程卡顿上传前向后端发起“检查已上传切片”请求实现秒传和断点续传支持并发上传控制默认同时最多发出3个上传请求每个切片独立维护状态等待中、上传中、已上传、失败重试前端实时计算整体上传进度并支持暂停和继续操作。这些能力基本覆盖了生产环境的大文件上传需求。你可能会觉得功能有点多但实际操作到最后你会发现只要把底层切片的逻辑设计清楚了这些功能都是顺带的事。1.3 技术选型这套组合为什么够用技术栈我选的是Vue3.4 Vite5 axios SparkMD5 Web Worker后端配合用Node.js实现没有引入任何花哨的框架级组件库。原因是分块上传的核心难点其实在前端切片的计算逻辑、状态管理和与后端的协议约定上跟UI框架关系不大用轻量组合式API反而更可控。Vue3组合式API用ref和reactive来管理切片状态列表逻辑内聚性很强Vite开发体验舒服启动快而且对Web Worker原生支持到位用?worker后缀可以直接加载Worker文件axios拦截器机制很适合统一处理上传请求的重试逻辑SparkMD5计算文件唯一指纹底层是MD5算法对大文件边读边算内存占用很低Web Worker把耗时的MD5计算从主线程挪走这是提升大文件体验的关键一环。这套组合的优势在于没有引入任何重量级依赖。我在生产项目里甚至把axios换成了原生fetch都能跑核心逻辑根本不依赖具体请求库但用axios确实省事一些。2. 文件切片与指纹计算前端如何把大文件拆开2.1 切片大小怎么定才合理切片大小是整个分块上传最需要动脑子的参数。定得太小比如1MB一个1GB的文件就要切1024块每个切片都要走一遍HTTP请求握手开销和请求头浪费太明显。定得太大比如50MB单块上传时间拉长一旦出错重传成本也会变高。我实测下来常规业务场景下5MB到20MB是最舒服的区间。具体的逻辑是这样的如果文件小于200MB切片大小可以定为5MB块数少管理清晰如果文件超过2GB我会动态把切片大小上调到20MB避免切片数量过多导致的内存开销和无谓的请求数量。总之切片的目标是让“总块数”控制在几十到几百的区间既方便并发控制单块失败重传的成本又不会太高。切片代码非常直接就是利用Blob的slice方法function createChunks(file, chunkSize 5 * 1024 * 1024) { const chunks []; let cur 0; while (cur file.size) { chunks.push({ chunk: file.slice(cur, cur chunkSize), index: chunks.length }); cur chunkSize; } return chunks; }这里有个容易被忽略的细节file.slice(cur, cur chunkSize)返回的是一个新的Blob对象它只是原文件在某个字节区间上的引用并没有复制后台数据所以切片过程很快不会卡顿也不会翻倍内存。每一个Blob后续都可以直接作为FormData的一部分发送给服务端后端自己也会接收到一个二进制片段。2.2 用SparkMD5计算全文件指纹的两种方式为什么要算整个文件所有切片拼接起来的MD5因为秒传和断点续传都需要一个“这个文件是谁”的唯一标识。只取第一个切片的MD5是不够的因为不同文件的前5MB完全可能相同只算很小的抽样索引片段更不靠谱。最稳妥的做法是把所有切片的MD5拼起来再做一次MD5或者全文件读一遍算MD5。我倾向用后一种简单粗暴且可靠。SparkMD5支持的增量模式非常契合大文件场景import SparkMD5 from spark-md5; // 基于FileReader逐个分块读取每读一块就append一次 function calcFileMD5(file, chunkSize 5 * 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 1; if (currentChunk totalChunks) { loadNext(); } else { resolve(spark.end()); } }; reader.onerror (err) reject(err); loadNext(); }); }注意这里用的是readAsArrayBuffer不是readAsDataURL前者的内存占用小很多。但即便如此这个计算过程依然需要把整个文件的数据块依次读进内存如果你的文件是2GB分块读取过程中内存占用其实不低主要表现为GB级别的临时ArrayBuffer会被反复创建回收。2.3 用Web Worker把MD5计算搬到后台线程主线程如何不卡韶单里的readAsArrayBuffer操作虽然比readAsDataURL好但还是会让主线程运算量陡增。用户选完一个2GB文件页面会卡顿两到三秒体验很糟糕。所以我把MD5计算整体封装到了一个Web Worker里面。在Vite项目里使用Worker最简单的方式是创建一个md5.worker.js文件// md5.worker.js import SparkMD5 from spark-md5; self.onmessage (e) { const { file, chunkSize } e.data; 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 (ev) { spark.append(ev.target.result); currentChunk 1; if (currentChunk totalChunks) { loadNext(); } else { // 把计算结果post回主线程 self.postMessage({ type: md5, md5: spark.end() }); } reader null; // 释放引用 }; reader.onerror (err) { self.postMessage({ type: error, message: err.message }); }; loadNext(); };在主线程里使用Workerimport Md5Worker from ./md5.worker.js?worker; function useMd5() { const worker new Md5Worker(); return new Promise((resolve, reject) { worker.postMessage({ file, chunkSize }); worker.onmessage (e) { if (e.data.type md5) { resolve(e.data.md5); worker.terminate(); } }; worker.onerror reject; }); }这样一来MD5计算花再多时间主线程的页面也能保持流畅。这里有一个关键点通过postMessage传给Worker的file对象底层是结构化克隆不是把文件拷贝一份内存开销完全可控。计算完后手动执行worker.terminate()释放Worker实例避免后续上传过程中还有后台线程在跑。3. 分块上传核心逻辑并发控制、进度与断点续传3.1 前后端协议接口参数怎么设计才能对接流畅分块上传的接口协议必须先约定好否则前后端各写各的联调的时候全是坑。我的DEMO总共设计了三个接口接口方法参数作用/api/upload/checkPOST{ fileMd5, fileName, chunkSize }查询该文件在服务端是否已经存在以及哪些切片已经上传成功/api/upload/chunkPOSTFormData: file(切片Blob), fileMd5, chunkIndex, chunkTotal上传单个切片/api/upload/mergePOST{ fileMd5, fileName, chunkSize, chunkTotal }所有切片上传完成后通知后端合并这里需要重点解释的是/check接口。前端拿到文件后先算MD5再问后端“这个文件的情况”。后端返回两种情况如果fileMd5已经存在于服务器且完整直接返回uploaded: true前端连传都不用传这就是秒传如果文件不存在返回uploadedChunks: [2,5,7]这样的已传切片下标数组前端只上传缺失的块就是断点续传。这种协议设计的好处是上传流程变成了“先查缺补漏再传剩余部分”不是每次从头苦哈哈地一遍遍传。在大文件上传的面试里能画出这个三方交互流程前端、后端、存储的候选人通常都能拿到不错的评价。3.2 并发控制为什么默认只开3个并发而不是一次性全发如果不做并发控制把100个切片全部一起axios.post浏览器单域名并发连接数是有限的HTTP/1.1通常是6个左右多出来的请求会排长队反而拖慢速度。并发太高的另一个问题是后端同时收到大量写操作磁盘IO容易被打满进而拖垮整台服务器。我默认把并发数设为3上传过程中动态取需要上传的切片。实现方式不复杂用队列加递归即可async function uploadAll(chunks, fileMd5, concurrency 3) { const tasks chunks.filter(c !c.uploaded).map((c, index) ({ ...c, taskIndex: index })); let currentIndex 0; const workers new Array(concurrency).fill(null).map(async () { while (currentIndex tasks.length) { const task tasks[currentIndex]; currentIndex 1; await uploadSingle(task, fileMd5); } }); await Promise.all(workers); }简洁的队列模型可以工作但我在生产项目里更推荐给每个切片状态单独维护写成响应式对象列表。这样每一个切片的进度、状态都能实时显示在页面上排障时能看到具体是哪块失败了。3.3 暂停、恢复与秒传这几行代码最考验细节暂停的思路极其简单维护一个isPaused标志每次切片上传前检查它暂停后就不继续取新任务正在上传中的请求不可能立即中断但可以等它完成或直接通过AbortController取消。更关键的是恢复。用户点击“恢复上传”之后第一步还是调/check接口把服务端已经收到的切片列表拿回来前端把uploadedChunks中对应的切片标记为已上传然后只把缺失的切片加入队列。这样即使页面不小心刷新了也没关系再次选择同一个文件流程一样能接上。秒传的体验也确实很爽用户拖入一个曾经传过的文件前端花一两秒算完MD5然后/check直接返回“文件已存在”进度条立刻跳到100%。我记忆里最深刻的一次是同事误以为秒传功能没做进度条其实逻辑早就跑通了他只是没等MD5算完。3.4 进度条怎么算才准整体进度不能简单用“已上传切片的数量 / 总切片数”来算因为大文件的切片不存在大小不均。但依然要考虑到每个切片本身的上传时间和失败重试的影响。经验做法是这样const progress ref(0); const uploadedSize ref(0); function updateProgress() { const doneSize chunkList.value .filter(c c.status done) .reduce((sum, c) sum c.chunk.size, 0); uploadedSize.value doneSize; progress.value Math.floor((doneSize / file.value.size) * 100); }用“字节数”而不是“切片个数”来驱动进度条天然规避了各切片大小不相同的问题。至于某一块失败重试导致进度暂时不动那是正常的进度条本来就是表示“净完成量”不是瞬时速度图。如果想做得更细腻可以在每个切片状态变化后单独刷新UI配合“重试失败切片”按钮交互体验就很完整了。3.5 完整上传流程的代码骨架把上面这些串起来最核心的上传入口大概是这样的async function handleUpload() { const file fileInput.value.files[0]; if (!file) return; isUploading.value true; isPaused.value false; // 计算全文件MD5 fileMd5.value await calcFileMD5InWorker(file, chunkSize.value); // 查询服务端已上传状态 const checkRes await axios.post(/api/upload/check, { fileMd5: fileMd5.value, fileName: file.name, chunkSize: chunkSize.value }); if (checkRes.data.uploaded) { progress.value 100; return; } // 创建切片列表并标记完成后台已经接收的块 chunkList.value createChunks(file, chunkSize.value); const uploadedSet new Set(checkRes.data.uploadedChunks || []); chunkList.value.forEach((c, index) { if (uploadedSet.has(index)) c.status done; }); // 并发上传剩余切片 await uploadAll(chunkList.value, fileMd5.value); // 通知后端合并 await axios.post(/api/upload/merge, { fileMd5: fileMd5.value, fileName: file.name, chunkSize: chunkSize.value, chunkTotal: chunkList.value.length }); isUploading.value false; }这里要注意fileMd5必须同时当作切片的标识后端靠它把属于同一个文件的切片归拢到同一个临时目录里。否则不同文件、同一下标编号的切片就会互相覆盖这是分块上传最容易出的隐蔽问题。4. 后端合并Node.js侧的接收与拼装4.1 后端接口设计不要裸写接收逻辑很多初学者搞分块上传只关注前端把文件切好分开发出去却忽略了后端拿到这些零散切片之后该怎么处理。后端如果不提前设计等到联调时到处补洞体验极差。我使用的Node.js后端方案很简单用Express或Koa都行关键是三个能力接收切片、合并切片、检查已传状态。这里不直接引大文件存储框架因为作为DEMO里展示原理最合适的就是三个路由。4.2 三个核心路由的实现路由一检查已上传切片// POST /api/upload/check const checkUpload async (req, res) { const { fileMd5, fileName, chunkSize } req.body; const fileDir path.join(UPLOAD_DIR, fileMd5); if (!fs.existsSync(fileDir)) { return res.json({ uploaded: false, uploadedChunks: [] }); } // 如果完整文件已存在标记秒传 const files fs.readdirSync(fileDir); const totalSize files.reduce((sum, name) sum fs.statSync(path.join(fileDir, name)).size, 0); if (totalSize fileSizeMap[fileMd5]) { return res.json({ uploaded: true }); } // 否则返回已存在的切片下标 const uploadedChunks files .filter(name name.endsWith(.part)) .map(name Number.parseInt(name.split(-)[1], 10)) .filter(Number.isInteger); res.json({ uploaded: false, uploadedChunks }); };路由二接收单个切片// POST /api/upload/chunk const uploadChunk async (req, res) { const file req.files.chunk; const { fileMd5, chunkIndex } req.body; const chunkDir path.join(UPLOAD_DIR, fileMd5); if (!fs.existsSync(chunkDir)) fs.mkdirSync(chunkDir, { recursive: true }); const chunkPath path.join(chunkDir, chunk-${chunkIndex}.part); await fs.promises.rename(file.tempFilePath, chunkPath); // 或写流 res.json({ code: 0, message: ok }); };路由三合并切片// POST /api/upload/merge const mergeChunks async (req, res) { const { fileMd5, fileName, chunkTotal } req.body; const chunkDir path.join(UPLOAD_DIR, fileMd5); const targetPath path.join(FILE_OUTPUT_DIR, ${fileMd5}-${fileName}); const writeStream fs.createWriteStream(targetPath); for (let i 0; i chunkTotal; i) { const chunkPath path.join(chunkDir, chunk-${i}.part); if (!fs.existsSync(chunkPath)) { return res.status(400).json({ code: 1, message: 缺少切片 ${i} }); } const data await fs.promises.readFile(chunkPath); writeStream.write(data); } writeStream.end(); res.json({ code: 0, url: /files/${fileMd5}-${fileName} }); };这里有个我要特别提一下的细节合并顺序绝不能依赖文件名排序必须严格用chunkIndex的顺序遍历。切序号0到N-1用0~9的整数排序时字符串会变成1、10、2这种反直觉顺序如果你图省事直接fs.readdirSync().sort()合并生产环境文件大概率损坏。4.3 完整性校验MD5二次确认不能省合并完成后不急着返回“成功”建议再校验一次整个文件的MD5是否等于前端传过来的fileMd5。校验通过后再把临时目录清掉。这一步基本能挡住99%的偶发损坏。const crypto require(crypto); const hash crypto.createHash(md5); const stream fs.createReadStream(targetPath); stream.on(data, (data) hash.update(data)); stream.on(end, () { const realMd5 hash.digest(hex); if (realMd5 ! fileMd5) { fs.unlinkSync(targetPath); return res.status(500).json({ code: 1, message: 文件校验失败 }); } fs.rmSync(chunkDir, { recursive: true, force: true }); res.json({ code: 0, url: /files/${fileMd5}-${fileName} }); });大多数MD5校验只是测一下“我有病还是没病”在这里它更像是一个最终质量门禁。尤其是有可能切片上传过程中出现路由重发、后端写入脏数据的情况双重MD5是成本最低的保障。5. 实用的踩坑记录与排查技巧5.1 内存爆掉的坑FileReader别一次性读完整文件很多新手在“计算MD5”这一关会犯一个毛病直接reader.readAsArrayBuffer(file)一个1GB的文件瞬间1GB进内存。整页面冻结运气不好标签页直接崩溃。正确姿势一定是在Worker里分块读取每读完一块就spark.append并释放引用读下一块时旧的ArrayBuffer才有机会被垃圾回收。这里我建议chunkSize在MD5计算阶段用2MB到4MB就行不要用上传切片的大小内存占用更平滑。5.2 切片顺序错乱的坑字符串排序惹的祸有一次我的合并结果出现文件后半部分乱码排查后发现果然是后端合并时用了readdirSync().sort()而文件名是chunk-0.part、chunk-1.part、chunk-10.part这种格式。不带任何参数地排序字典序就是0、1、10、2、3……顺序错乱直接导致合并内容交叉错位。解法我已经写在合并接口里永远用前端传过来的chunkTotal按顺序从0循环到N-1单个去fs.existsSync再读取。对于切片特别多的情况建议改用流式管道而不是把所有分片都读取进内存否则并发合并几百个大块时内存又紧张了。5.3 并发数太高的坑朴素地开满速度反而慢年轻的时候我喜欢把并发数直接拉满想着“越多越快”。但上传到一半浏览器开始报错后端连着报错超时。原因很简单单浏览器的连接数上限阀门死死卡着后端也被高并发连接的握手压力拖垮了。我的经验是本地局域网环境并发可以开到5公网弱网环境必须控制在3以内。并发数不是性能瓶颈的衡量标准单切片的大小才是因为切片小了块数就多并发连接数大但每块都在抢带宽最终总吞吐量并没有提升。如果真需要提速可以从CDN分片直传、后端分片落多副本这类架构上想方案而不是莽着开高并发。5.4 请求超时设置大文件很容易被默认超时卡脖子axios默认没有超时时间但如果某些项目里有人全局设置了timeout: 30000那30秒内单块没传完请求就被掐断了。如果网络不好单个5MB切片传40秒是完全可能的于是全线飘红。好在上传分成小块后单次请求的数据量已经降下来了但依然建议给上传接口单独设置一个更宽松的超时时间const instance axios.create({ timeout: 120000 });另外Nginx层的client_max_body_size要放开到至少20m否则单块上传超过1MB默认值直接413。记得后端接口拆了块网关的body限制也要跟着调。5.5 避坑速查表问题现象可能原因解决方案浏览器卡死MD5计算占用主线程使用Web Worker移动端降低计算分块到2MB上传后文件损坏后端合并按文件名排序严格按chunkIndex从0到N-1读取切片秒传不生效后端没存文件大小映射检查时对比已传总字节数而非只看目录存在请求413Nginx限制请求体大小调大client_max_body_size与切片大小匹配上传到一半全部失败并发开太高或网络抖动并发降到3加重试机制相同文件重复上传前端没做检查直接传上传前调用/check完成秒传5.6 断点续传的边缘情况真正靠谱的是服务端兜底断点续传听着简单最容易漏过的坑是用户传完一部分切片后刷新页面浏览器里的JS状态全没了再选择同一个文件时前端可能因为MD5计算还没完成而用户已经点了上传导致重复上传同一批切片。幸好后端根据fileMd5做了文件维度存储重复上传的切片只会覆盖同名.part文件不会产生错误文件。所以只要不同时修改合并文件重复写入是无害的。但如果你做的是生产级功能我强烈建议在后端合并前做“切片数量总校验”比如统计目录下的.part文件数量等于chunkTotal才允许合并。这个代价很小却能让整个断点续传流程的可靠性提升一个等级。6. 实操心得与延伸玩法6.1 我个人的习惯写法与建议分块上传这个功能代码量并不大但坑确实不少。我最想强调的一个点写之前先和对接后端把接口协议定死尤其是check接口的返回结构和合并接口的校验规则。很多时候功能写到一半出bug不是前端代码不行而是前后端对“已完成切片”的定义不一致或者合并时缺少校验。与其在联调阶段扯皮不如第一步就把协议写成文档。其次MD5计算用Worker这一步实际上比分块上传本身还重要。用户选择一个2GB的文件你让他盯着页面卡三秒钟体验直接掉到助理水平。把Worker挂上去之后所有计算在后台推进进度条还能显示“正在校验文件”用户等待期间整个页面依然是可操作的这个好感度提升立竿见影。6.2 还能怎么扩展从DEMO到生产级还要补什么如果你已经把我上面这套DEMO跑通了其实已经掌握了分块上传的核心原理。但真要落到生产我建议继续补四个方向。第一是失败重试机制。目前DEMO里单块失败直接报错生产上至少要加“每块最多重试3次指数退避间隔再发起”的逻辑。第二是文件夹上传把目录里所有文件递归遍历每个文件独立计算MD5批量上传时还能共享同一个并发池。第三是存储层的升级后端如果直接用本地磁盘存切片迁移时容易遇到磁盘空间不足生产上通常把切片放到对象存储的临时目录里合并时服务端流式拉取。第四是进度条的“速度预测”可以维护一个滑动窗口算出近5秒的平均上传速度再按剩余字节预估剩余时间这样体验更专业。6.3 最后分享一个测试技巧测试大文件上传时很多人手头没有合适的大文件我分享一个命令行秒级生成测试文件的方案Windows上用fsutil file createnew test.bin 1073741824直接生成1GB文件macOS/Linux上用mkfile 1g test.bin或者fallocate -l 1G test.bin。这个文件内容全是随机字节传到后端后你不用打开它靠MD5校验判断完整性就够用了。分块上传这件事核心本质就是“把大问题拆成小问题逐个击破再合并结果”。前端负责拆和传服务端负责收和拼中间再靠一个文件指纹把两岸连起来。你只要把切片的并发、进度、状态管理这三件事捋顺了剩下就是抄代码和填接口的体力活。我把能写的坑都写在上文了你跟着做一遍基本就能把这块短板补上。
返回列表