ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Vue大文件上传实战:分片、断点续传与秒传方案详解

Vue大文件上传实战:分片、断点续传与秒传方案详解 直接切入正题吧。做前端时间久了谁都会碰到上传大文件的场景尤其是Vue项目里处理视频、压缩包、设计稿这些动辄几个G的文件。很多新手上来就一个input[typefile]加FormData丢给后端小文件没事文件一上G就原形毕露要么请求超时要么内存爆掉要么传了一半断网全部重来。这篇博客就围绕Vue大文件上传这件事从底层原理讲到能直接落地的示例代码把切片、断点续传、秒传、并发控制这些坑全部趟一遍帮你少走弯路。这篇内容适合谁看如果你正在用Vue 2或Vue 3开发后台管理系统或者是独立开发者要自己搭一套文件上传服务又或者只是好奇前端怎么处理几百MB甚至几个G的文件都可以花几分钟过一遍。我不讲虚的代码贴出来就能用后端逻辑也给你捋清楚。读完之后你至少能自己写一个不依赖第三方插件、稳定可靠的分片上传组件还能顺手解决面试里被追问的“大文件上传原理”这种经典问题。1. 大文件上传的核心原理先搞清楚浏览器卡在哪1.1 为什么不能直接拿FormData一把梭很多人最初的实现长这样用户选完文件前端把整个File对象塞进FormData然后axios.post发给后端。这种方案读小文件没问题但是一旦文件超过几百MB你会遇到几个鬼问题。第一请求体太大导致超时。不管是浏览器还是服务端比如Nginx默认的client_max_body_size只有1MB对单次请求的体积都有限制你能传上去纯属运气好。第二内存占用爆炸。浏览器要把整个文件读进内存再发出去用户机器差一点传个大文件直接把浏览器标签页干崩溃。第三失败成本极高。网络抖一下断了整个文件要重传用户骂娘你也只能忍着。所以大文件上传的突破口就是把一个大文件切成很多小块一块一块传传完在后端拼回去。这就是分片上传Multipart Upload的基本思路。1.2 分片、断点续传、秒传的底层逻辑分片上传看起来简单但背后有几件事必须搞清楚。分片的核心是Blob.slice()方法。浏览器里的File对象继承自Blob所以你可以像切西瓜一样用file.slice(start, end)把文件切成若干块每一块都是一个独立的Blob可以单独作为FormData的一部分发送。切片大小一般选2MB到10MB之间太小吃亏在网络请求次数太多太大又失去了分片的意义。断点续传就得记录“哪些分片已经传上去了”。前端把切好的分片编上序号传到一半断了下次重新上传之前先问一下后端哪些分片已经存在只传缺失的部分就行。秒传更简单本质就是“服务端已经有这个文件了前端没必要再传一遍”做法是对整个文件做一次hash计算先把hash发给后端后端查一下数据库如果存在就直接返回成功。说白了大文件上传就是个“化整为零再化零为整”的过程。前端负责切和传后端负责收和拼中间再加上状态记录和查询就构成了完整的方案。2. 前端方案选型为什么推荐用切片而不是Stream2.1 浏览器端的File API能力边界有同学会问浏览器能不能像Node.js那样直接用Stream流式上传答案是可以但兼容性和实用性都不太行。XMLHttpRequest和fetch确实支持ReadableStream作为请求体但你把一个ReadableStream传给后端后端收到的还是整块数据服务端只能等流结束才能处理传输过程中无法做进度控制、无法跳过失败分片断点续传就更别想了。所以前端大文件上传的正统做法还是“切块”。用file.slice()把文件切成数组然后用Promise.all配合并发控制一批一批地传。每一块都是独立请求线上失败就单独重试这一块不影响别的分片。这就是分片方案优于流式方案的核心原因。另外浏览器原生还提供FileReader有人想拿它把文件读成Base64再传。我劝你别这么干因为Base64会额外膨胀约33%的体积而且大文件读进内存直接卡死页面。自己做练习可以生产环境千万别这么写。2.2 Web Worker在什么时候真正有用很多教程爱提Web Worker说把文件切片逻辑丢进Worker里避免阻塞主线程。这话没错但得看你的文件有多大。如果你处理的是几十MB的文件切片操作本身只需要几十毫秒用不用Worker感知不强。但文件超过1GB尤其是还要做hash计算后面会讲主线程会卡得连滚动都掉帧这时候Web Worker就是救命稻草。具体做法是用worker.postMessage({ file, chunkSize })把File对象传给WorkerWorker里执行切片和hash计算计算完再把分片列表postMessage回主线程。File对象在Chrome、Firefox、Safari里都是支持结构化克隆传给Worker的不用担心。如果你对Worker不熟还有一个折中方案把hash计算拆成异步分片处理每算几MB就await new Promise(r setTimeout(r))让出主线程。但说实话这是治标不治本真的遇上一个2GB的视频文件该卡还是卡。2.3 并发控制别把服务器打趴下分片上传还有一个细节被很多人忽略并发数。如果前端一次性把几百个分片全部发出去服务器瞬间收到大量请求连接数直接被打满反而拖慢上传速度甚至触发服务端的限流策略。我常用的方案是基于p-limit或者自己写一个简单的并发池。核心逻辑就是维护一个任务队列同时最多跑N个请求一般3~6个比较合适一个请求结束了从队列里取下一个继续。这样既利用了并行传输的带宽优势又不会给服务器造成太大压力。实测在普通带宽环境下5个并发通常比逐个传快3到4倍比全部一窝蜂也稳得多。3. 完整示例代码Vue3 分片上传实战3.1 项目结构和依赖准备先说明一下下面的代码我基于Vue 3的script setup语法来写同时兼容Vue 2的Options API思路你把setup里的逻辑搬到methods里一样能用。依赖方面只需要axios不需要其他重型UI库Element Plus或者Ant Design Vue可以有但非必须。建议目录结构大概是src/ views/Upload.vue utils/fileUpload.js worker/hashWorker.jsutils/fileUpload.js放核心上传逻辑worker/hashWorker.js放计算文件hash的Worker脚本Upload.vue是页面组件负责渲染上传界面、调起文件选择、展示进度。3.2 核心代码切片、hash、并发上传一条龙先来看看utils/fileUpload.js我封装了一个uploadFile方法接收文件、切片大小、并发数、以及一组回调函数用于通知页面更新进度。注意方法内部不依赖具体UI组件这样你在任何Vue组件里都能复用。import axios from axios const CHUNK_SIZE 5 * 1024 * 1024 // 5MB一个分片 const CONCURRENCY 5 // 同时上传的分片数 // 计算文件hash通过Web Worker避免阻塞主线程 export function calcFileHash(file) { return new Promise((resolve) { const worker new Worker(new URL(./hashWorker.js, import.meta.url)) worker.postMessage({ file }) worker.onmessage (e) { const { hash } e.data worker.terminate() resolve(hash) } }) } // 生成分片列表 export function createChunks(file, chunkSize CHUNK_SIZE) { const chunks [] let cur 0 while (cur file.size) { chunks.push({ index: chunks.length, start: cur, end: Math.min(cur chunkSize, file.size), blob: file.slice(cur, cur chunkSize) }) cur chunkSize } return chunks } // 并发池控制同时执行的异步任务数量 async function asyncPool(poolLimit, tasks, callback) { const results [] const executing new Set() for (const task of tasks) { const p Promise.resolve().then(() callback(task)) results.push(p) executing.add(p) const clean () executing.delete(p) p.then(clean).catch(clean) if (executing.size poolLimit) { await Promise.race(executing) } } return Promise.all(results) } // 上传单个分片 async function uploadChunk(chunk, fileHash, fileName, onProgress) { const formData new FormData() formData.append(chunk, chunk.blob) formData.append(hash, fileHash) formData.append(index, chunk.index) formData.append(fileName, fileName) const res await axios.post(/api/upload/chunk, formData, { onUploadProgress: (e) { if (onProgress) { onProgress(chunk.index, e.loaded, e.total) } } }) return res.data } // 主入口分片 并发上传 通知合并 export async function uploadFile(file, options {}) { const { chunkSize CHUNK_SIZE, concurrency CONCURRENCY, onChunkProgress, onFileProgress, onMergeStart, onMergeEnd } options const chunks createChunks(file, chunkSize) const fileHash await calcFileHash(file) let uploadedBytes 0 // 先问一下后端这个文件之前传过哪些分片断点续传拿已上传列表 const { data: uploadedRes } await axios.post(/api/upload/check, { hash: fileHash }) const uploadedIndexes uploadedRes.uploadedIndexes || [] const isExists uploadedRes.isExists // 如果为true说明秒传直接返回 if (isExists) { onMergeEnd onMergeEnd({ isExists: true }) return { isExists: true } } const tasks chunks.filter((chunk) !uploadedIndexes.includes(chunk.index)) await asyncPool(concurrency, tasks, async (chunk) { await uploadChunk(chunk, fileHash, file.name, (index, loaded) { onChunkProgress onChunkProgress(index, loaded) }) uploadedBytes chunk.end - chunk.start onFileProgress onFileProgress(uploadedBytes / file.size) }) onMergeStart onMergeStart() const { data: mergeRes } await axios.post(/api/upload/merge, { hash: fileHash, fileName: file.name, chunkSize }) onMergeEnd onMergeEnd(mergeRes) return mergeRes }这套代码逻辑很直白先计算hash再向后端查询已上传分片然后把缺失的分片丢进并发池执行上传每传完一个分片更新总进度最后通知后端合并分片。3.3 HashWorker脚本怎么写hashWorker.js里面不能直接用import引入外部库可以用importScripts但为了不额外引入CDN我直接用crypto.subtle配合FileReader来算hash。注意这里的hash思路不是一次读完整个文件而是分片读取每读完一个分片就调用crypto.subtle.digest更新哈希上下文。不过crypto.subtle是一锤子买卖不能增量累加所以这里用的是分片后对每个分片做hash再用所有分片的hash拼接出最终字符串也叫“分片hash合并”。严格来说它没有对整个文件做一次标准MD5但用于判断文件是否一致已经足够了。const CHUNK_HASH_SIZE 2 * 1024 * 1024 // hash计算时每片的读取大小 self.onmessage async (e) { const { file } e.data const chunks [] let cur 0 while (cur file.size) { const blob file.slice(cur, cur CHUNK_HASH_SIZE) const buffer await blob.arrayBuffer() const hashBuffer await crypto.subtle.digest(SHA-256, buffer) const hashArray Array.from(new Uint8Array(hashBuffer)) const hashHex hashArray.map((b) b.toString(16).padStart(2, 0)).join() chunks.push(hashHex) cur CHUNK_HASH_SIZE // 每处理完一块往主线程回报一次进度 self.postMessage({ type: hashProgress, progress: cur / file.size }) } // 最终把所有分块hash拼起来再算一次作为文件指纹 const finalStr chunks.join() const finalBuffer new TextEncoder().encode(finalStr) const finalHashBuffer await crypto.subtle.digest(SHA-256, finalBuffer) const finalHashArray Array.from(new Uint8Array(finalHashBuffer)) const finalHash finalHashArray.map((b) b.toString(16).padStart(2, 0)).join() self.postMessage({ type: done, hash: finalHash, chunkCount: chunks.length }) }是的这个实现细节跟很多文章说的“对整个文件算一次MD5”不一样但效果是等价的文件内容稍有变化最终hash九成九会变。优点是内存占用很低不会卡死大文件。如果你的项目里后端只认MD5你可以改成用SparkMD5这个库但要注意大文件算MD5比较慢Worker里算也建议分批读文件。3.4 页面组件的调用方式页面组件就比较常规了写一个Upload.vue选完文件调uploadFile把回调里的进度信息绑定到进度条上。template div classupload-wrap input typefile changehandleChange / div v-iffile p文件名{{ file.name }}/p p大小{{ formatSize(file.size) }}/p p上传进度{{ percent }}%/p div classprogress div classprogress-bar :style{ width: percent % }/div /div /div /div /template script setup import { ref } from vue import { uploadFile } from ../utils/fileUpload const file ref(null) const percent ref(0) const handleChange async (e) { const selectedFile e.target.files[0] if (!selectedFile) return file.value selectedFile await uploadFile(selectedFile, { onFileProgress: (p) { percent.value Math.floor(p * 100) }, onMergeEnd: () { alert(上传完成) } }) } const formatSize (size) { if (size 1024) return size B if (size 1024 * 1024) return (size / 1024).toFixed(2) KB if (size 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(2) MB return (size / (1024 * 1024 * 1024)).toFixed(2) GB } /script注意进度回调用的是onFileProgress它拿到的p是0到1的小数页面里转成百分比展示就行。如果你想展示更细的“当前分片号/总分片数”在onChunkProgress回调里拿index就好。4. 断点续传与秒传的关键细节4.1 文件指纹hash计算的前后端约定断点续传最核心的依赖就是文件hash。前端算好hash传给后端后端查数据库才知道“这个文件之前传过没有”、“传了哪些分片”。所以hash算法、编码、格式必须前后端对齐。我上面的代码用的是SHA-256加十六进制字符串后端只要在分片上传和合并接口里同样以这个字符串作为文件唯一标识就行。需要注意计算hash的读取粒度我用的是2MB一个chunk来做hash计算不需要跟上传切片大小5MB保持一致两者互不干扰。但你要跟后端确认upload/check接口到底是用hash判断秒传还是用hash 文件名判断我建议只用hash因为同一个文件可能改了个名再上传按文件名判断会导致用户等半天白等。4.2 断点续传的完整状态流断点续传的完整流程是这样每次上传前前端带着fileHash调用check接口。后端返回这么几个信息文件是否存在、已上传分片的index数组、分片大小。前端拿到已上传分片列表后把本地切好的分片过滤一遍剩下没传过的分片继续传。全程不需要前端本地持久化任何状态一切以后端数据库为准。这里有个坑如果前端改过切片大小比如上次传的时候是5MB一片这次调成10MB一片那么后端已存分片的边界就对不上了。解决的办法有两个。第一前端把chunkSize也发给后端后端在check接口里对比当前chunkSize和之前记录的是否一致不一致就直接返回空数组让前端重传。第二后端在合并的时候用分片index和固定大小去计算文件字节偏移跟实际的chunk.size再次比对不一致就报错。反正前后端约定好同一文件的切片大小必须恒定。4.3 服务端合并分片的两种姿势后端合并分片有两条路。一条是每个分片到达服务端后直接追加写入同一个文件也就是分片流式合并适合分片数不多且顺序严格可控的场景但对并发上传不友好。另一条是每个分片暂时存成独立的小文件或者写对象存储的分片等所有分片都传完了再按index顺序读取、合并写入最终文件。大多数私有化部署场景都选第二种。Java用RandomAccessFile或者FileOutputStream循环读写Node.js用fs.createWriteStream配合pipe循环合并Python用pathlib读取分片再write_bytes追加。合并完之后最好对最终文件算一次hash跟前端传过来的fileHash比对如果一致才返回成功不一致说明中间有分片损坏需要重传。我自己踩过的坑是合并时磁盘空间不够。一个10GB的文件分片存储可能要占10GB合并时又要额外10GB空间如果你不删分片文件服务器分分钟磁盘打满。所以合并完成后要把已合并的分片全部清理掉最好写成定时任务兜底清理超过24小时的分片残留。5. 常见问题与排查技巧实录5.1 上传失败重试与容错机制网络抖动导致某个分片上传失败是高频问题。最直接的办法就是在uploadChunk外层包一层重试逻辑失败的分片最多重试3次每次间隔2秒超3次就直接抛错终止整个任务并告诉用户“第X个分片上传失败”。不要一股脑把所有失败分片全重试因为一个分片失败往往意味着网络已经不稳全部重试只会加剧问题。代码逻辑可以这样改造给uploadChunk套个retry函数async function uploadChunkWithRetry(chunk, fileHash, fileName, retryCount 3) { for (let i 0; i retryCount; i) { try { return await uploadChunk(chunk, fileHash, fileName) } catch (err) { if (i retryCount - 1) { throw new Error(分片 ${chunk.index} 上传失败) } await new Promise((resolve) setTimeout(resolve, 2000)) } } }另外断点续传天然是重试机制的好搭档哪怕前端重试也失败了用户刷新页面重新选择同一个文件还能从断点处继续传不用重头开始。5.2 大文件导致页面卡顿和内存泄漏大文件上传遇到的另一个大坑就是页面卡死。原因一般是两个一是hash计算时一次性把整个文件读进了内存二是上传时把全部分片的Blob引用都挂在chunks数组里不释放。对于第一个原因用我上面给的Worker方案可以缓解因为读文件的操作在Worker线程主线程不会卡但对于第二个原因你确实要注意createChunks返回的chunks数组里每个元素的blob都持有文件部分内容引用如果文件很大全部分片的Blob加起来等于整个文件的大小。这本身不算严重因为File的slice是懒读取的真正拷贝进内存是在调用formData.append的时候。但如果你手动把每个blob转成ArrayBuffer存起来那就是灾难千万不要这么干。还有一个小细节URL.createObjectURL(file)做本地预览是很好的但用完记得URL.revokeObjectURL释放否则会常驻内存。这在传大量小文件时尤其明显。5.3 和后端联调时最容易翻车的几个约定联调大文件上传最容易扯皮的就是几个字段命名和边界条件。前端传的formData字段名后端一定要对得上。我建议一开始就写一个接口文档把以下字段定死字段名类型含义chunkFile分片二进制hashstring整个文件的唯一指纹indexnumber分片索引从0开始fileNamestring原始文件名chunkSizenumber可选切片大小主要用于校验另外check接口的返回结构也建议统一{ code: 0, data: { isExists: false, uploadedIndexes: [0, 1, 2], chunkSize: 5242880 } }uploadedIndexes只返回已完整收到的分片index千万不要把正在上传中的也标成已完成否则前端会跳过这些分片最后合并出来的文件就是损坏的。5.4 上传进度不准的排查思路有朋友会遇到进度条跳到99%就卡住不动或者进度忽高忽低的问题。99%卡住多半是onUploadProgress算的是单分片进度而你的总进度是用“已上传字节数 / 总字节数”算出来的两者之间存在时间差。最后那1%实际上是等待合并接口返回所以你应该在onMergeStart里把进度强制设为99%合并完成后再跳到100%。还有一种情况是进度突然倒退。这是因为并发请求返回顺序不固定一个分片的onUploadProgress回调晚于另一个分片触发导致累加的uploadedBytes重复。避免方法是只依赖每个分片完成后的增量累加不要再从onUploadProgress里拿e.loaded去计算总进度。每个分片完成时加上固定大小chunk.end - chunk.start这样永远准确。6. 断点续传的本地缓存方案补充断点续传不一定非要依赖后端存分片记录前端也可以自己做一个轻量级的“待传列表”缓存。思路是计算完hash后把分片列表的状态存到localStorage或者IndexedDB里每次刷新页面后自动读取优先询问后端哪些分片已经存在然后只传缺失的。这个方案在后端不支持check接口时特别有用。不过要提醒一下localStorage的容量一般只有5MB存不了太多信息所以只适合存“分片index列表”这种轻量数据。IndexedDB容量更大但API比较啰嗦可以在项目里封装一个小工具来读写。另外本地缓存只是辅助手段真正可靠的判断还是以后端磁盘上的分片文件为准因为用户可能换浏览器、清缓存后端状态才是唯一真理。7. 从示例代码到可上线还需要补哪些工程化能力示例代码能跑通但如果要上生产环境还有几个点需要补。第一个是文件类型和大小限制。不是什么文件都允许传肯定要限制扩展名和MIME类型前端过滤一遍后端再兜底校验一遍。不要相信前端的任何校验因为绕过前端太容易了。第二个是上传任务的取消与恢复。用户传了一半想取消或者切换了文件你要能通过AbortController中断所有分片请求。axios支持signal参数新建一个AbortController传给所有分片请求取消时调用controller.abort()就行。恢复的话重新执行uploadFile自动走断点续传逻辑。第三个是分片上传完成后的通知。有的系统需要上传完成后触发文件转码、病毒扫描、内容审核等后续流程合并接口不要同步等所有流程跑完应该立即返回“已接收”后台再慢慢处理前端通过轮询或者WebSocket接收处理结果。否则用户传一个10GB的视频合并完还要等转码完成才看到成功提示体验极差。第四个是权限与鉴权。分片上传接口必须带token不能裸奔在公网。上传是典型的“重资源”操作没有鉴权就等于给别人免费当存储服务器。8. 我实际用下来的几点体会坦白讲大文件上传这个功能原理不难难的是各种边界情况处理。我在一个后台管理系统里做过一版原来用简单的FormData直传运维天天抱怨Nginx日志里全是413错误。改成切片并发上传之后几乎没有再收到过上传失败相关的工单。但调试过程中也花了不少时间印象最深的一次是后端把分片字段名从chunk改成了file前端没同步结果每个分片都上传成功但后端一个都没存下来最后合并出来的文件比原始文件短了一大截。所以联调时第一步一定是先打印请求体确认字段拼接对不对别急着调逻辑。另一个让我记忆深刻的坑是并发数设得太高。一开始图快直接并发10个分片结果上传到一半后端Tomcat的连接线程池被打满连别的业务接口都跟着变慢。后来把并发降到5加上了队列控制整个世界安静了。这其实也是很多教程不会告诉你的经验并发不是越高越好得看服务端和带宽的承受能力。如果你打算在公司项目里落地这套方案我的建议是先做一个小文件跑通全流程然后拿一个1GB的真实文件去测试断点续传、秒传、失败重试这些场景。折腾一轮下来你对大文件上传的整个链路会非常清楚面试遇到相关问题也能聊得很有底气。
返回列表