ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Vue大文件上传实战:分片、断点续传与秒传原理及代码实现

Vue大文件上传实战:分片、断点续传与秒传原理及代码实现 做前端时间久了你会发现一个规律凡是涉及上传的需求小文件随便写写就完了一旦文件上了 1GB、甚至几个 GB普通的上传方式就各种翻车。我之前接了一个内部系统的活用户要传几十 GB 的监控录像一开始项目经理觉得不就是个上传吗结果联调阶段天天被测试报 bug传一半断网了要重新传、nginx 直接 413、后端内存爆掉。后来老老实实改成分片上传问题才算真正解决。这篇教程就围绕 Vue 大文件上传把我从原理到示例代码的完整思路过一遍。适合刚接触大文件上传、想搞懂切片逻辑的人也适合已经做过一版但被边界问题折磨的朋友。看完你能搞清楚三件事大文件上传到底难在哪、分片/断点续传/秒传是怎么配合的、以及一套可以直接抄进 Vue 工程的代码长什么样。1. 为什么大文件不能一把梭三个绕不开的硬瓶颈1.1 请求体限制和网关超时先说最直观的大多数上传场景走的都是普通 POST 请求整个文件塞进请求体里一次发出去。问题是几乎所有服务端中间件都对请求体大小有限制。nginx 默认的client_max_body_size只有 1MB后端框架也各有各的限制你以为自己写好了上传接口结果一发大文件直接 413页面报错用户一脸懵。就算你把 nginx 和后端的限制都调大了网关和反向代理的超时时间也是个大问题。一个 1GB 的文件在普通家用宽带的 20Mbps 上传速率下理论上就要传六七分钟这期间如果代理超时时间设的是 60 秒请求早就被掐断了。调大超时是一种办法但这等于把风险往后挪网络一抖动整个请求就废了而这个废的代价是整个文件重来。1.2 断网重传一次请求失败全量重来这是我实际项目里被测试追着打最多的情况。普通上传是一次性请求只要中途网络断一下、或者后端服务重启了一下、甚至只是手机切了个 Wi-Fi整个请求报错用户就要从头再传一次。几个 GB 的文件传了 40 分钟突然失败重来换谁都要骂人。分片上传的优势就在这里每个分片是独立的小请求失败了只重传那一片其他已经传成功的分片不用动。再加上已传分片的记录机制用户关掉页面重新打开都能接着传这就是断点续传的底层来源。1.3 内存和线程占用浏览器和后端都难受还有一个容易忽略的点。浏览器读取一个大文件如果直接通过 FormData 提交整个文件会被读进内存或者触发复杂的 multipart 组装流程后端接收的时候如果用的是框架里默认的 bodyParser也可能把整个请求体缓冲到内存里。文件一上 GB两边内存直接吃紧多几个用户同时传后端就可能 OOM。分片以后单次请求只有几 MB内存压力小一个数量级。这也是分片上传能扛高并发的根本原因。所以切片的本质不是炫技而是把一个大请求拆成多个小请求让每一个环节的资源和时间都处于可控范围。2. 分片上传的核心原理切片、指纹、并发、合并是怎么串起来的2.1 先画一遍完整流程分片上传的完整流程可以分成这么几步用户选择文件后前端把文件按固定大小切成多个分片计算整个文件的唯一指纹一般用 MD5这个指纹就是后面所有接口的文件标识调用后端校验接口告诉后端文件指纹、文件名、分片总数让后端判断文件是否已经完整存在存在就直接秒传成功、有哪些分片已经传过不完整则返回已传分片列表用于断点续传前端把未传的分片逐个上传每个分片都带上文件指纹和分片序号所有分片传完后前端调用合并接口通知后端把所有分片按顺序合并成完整文件后端合并完成返回文件地址上传结束。这套流程里最核心的技术点是三个切片、指纹、并发控制。下面逐个说。2.2 切片Blob.slice 是把大文件切成小块的唯一姿势浏览器里File对象继承自Blob而Blob原生支持slice方法可以直接从原文件里截取一段二进制数据不占用整份拷贝底层是引用原文件的片段。所以前端切片做起来非常简单根据分片大小算出总片数循环slice就能拿到所有分片。这里有个细节File.slice在不同浏览器里的参数名曾经有差异webkitSlice、mozSlice但现代浏览器都支持slice了老项目兼容到 IE 才需要 polyfill现在基本可以放心直接用。分片大小怎么定后面会专门讲通常取 5MB 到 20MB 比较常见。太小会导致请求数过多、HTTP 往返开销大太大会失去分片的意义。2.3 指纹整个文件算一个 MD5干什么用每个分片本身并没有身份我们把一堆分片发给后端后端怎么知道这些分片属于同一个文件、以及拼起来顺序对不对答案就是文件指纹。前端把整个文件读一遍用 SparkMD5 这类库算出一个 MD5 值。这个值相当于文件的身份证号——内容相同的文件MD5 一定相同。后端拿到指纹后可以拿它做三件事用它作为分片存储的目录名把同一个文件的所有分片放在一起检查指纹是否已存在且分片齐全实现秒传检查哪些分片已经上传过实现断点续传。算 MD5 的代价是必须把文件完整读一遍。几个 GB 的文件在浏览器里算 MD5主线程会被 FileReader 的读操作卡住页面直接失去响应。所以进阶做法是把算哈希的活儿丢给 Web Worker文章后面会单独说。2.4 并发控制不是越快越好是够用且不崩分片上传天然支持并发因为切片之间互不依赖。但并发数不是越大越好浏览器对同一域名的并发连接数是有限制的HTTP/1.1 通常是 6 个左右而且前端同时把太多分片读进内存发起请求也会带来内存压力。通常的做法是维护一个并发池限制同时执行的请求数比如 3 到 6 个。核心逻辑不复杂用一个Set存放当前正在执行的请求满了就await Promise.race等其中任意一个完成后腾出位置再塞进下一个。核心代码在下一章直接给出。3. Vue 里可复用的上传组件完整示例代码与逐段解析3.1 组件结构和数据定义我用 Vue 3 Composition API 的写法Vue 2 的 Options API 逻辑也可以平移核心都在那堆函数里。先看组件的模板template div classuploader input typefile accept*/* :disableduploading changehandleFileChange / div v-iffile classinfo span文件名{{ file.name }}/span span大小{{ formatSize(file.size) }}/span /div div classprogress v-iffile !uploaded div classprogress-bar :style{ width: progress % }/div span{{ progress.toFixed(1) }}%/span /div div classactions button :disabled!file || uploading clickstartUpload开始上传/button button :disabled!uploading clickpauseUpload暂停/button /div /div /template我特意加了暂停按钮这个后面讲断点续传时会用到。状态数据在 script 里是这样import { ref, computed } from vue import axios from axios import SparkMD5 from spark-md5 const CHUNK_SIZE 5 * 1024 * 1024 // 5MB const MAX_CONCURRENCY 3 const file ref(null) const chunks ref([]) const fileHash ref() const uploading ref(false) const uploadedChunks ref(new Set()) // 已成功上传的分片 index 集合 const settledCount ref(0) // 已结束请求的分片数 const totalProgress ref(0) const progress computed(() totalProgress.value) function formatSize(size) { if (size 1024) return size B if (size 1024 * 1024) return (size / 1024).toFixed(1) KB if (size 1024 * 1024 * 1024) return (size / 1024 / 1024).toFixed(1) MB return (size / 1024 / 1024 / 1024).toFixed(2) GB }3.2 创建分片与计算指纹选中文件后第一步就是切片并算哈希function handleFileChange(e) { file.value e.target.files[0] chunks.value createChunks(file.value) fileHash.value uploadedChunks.value new Set() settledCount.value 0 totalProgress.value 0 } function createChunks(file, chunkSize CHUNK_SIZE) { const list [] let cur 0 while (cur file.size) { const end Math.min(cur chunkSize, file.size) list.push({ index: list.length, file: file.slice(cur, end), size: end - cur }) cur end } return list } function calculateHash(chunks) { return new Promise((resolve, reject) { const spark new SparkMD5.ArrayBuffer() let index 0 const total chunks.length function next() { const reader new FileReader() reader.onload (e) { spark.append(e.target.result) index if (index total) { next() } else { resolve(spark.end()) } } reader.onerror reject reader.readAsArrayBuffer(chunks[index].file) } next() }) }这里有几个细节值得说明。第一FileReader 一次只读一个分片读完一个再读下一个避免一次性把所有分片都载入内存。第二SparkMD5.ArrayBuffer支持增量追加数据正好配合分片读取性能比直接把整个文件一次性读进来要好。第三如果你算完哈希发现进度条卡了很久说明文件太大、主线程被读操作占住了后面会讲用 Web Worker 优化。3.3 并发上传控制器算完哈希以后先校验再上传。核心是下面这个并发控制器async function startUpload() { if (!file.value || uploading.value) return uploading.value true fileHash.value await calculateHash(chunks.value) // 1. 先向后端校验看秒传和已传分片情况 const checkRes await axios.post(/api/check, { fileHash: fileHash.value, fileName: file.value.name, totalChunks: chunks.value.length }) if (checkRes.data.data.uploaded) { // 文件已完整存在直接秒传成功 uploading.value false totalProgress.value 100 return } // 2. 合并后端记录和 localStorage 记录作为已传分片集合 const localUploaded getLocalUploaded(fileHash.value) uploadedChunks.value new Set([ ...(checkRes.data.data.uploadedChunks || []), ...localUploaded ]) // 3. 只挑未传的分片放入并发池 const pool new Set() const pending chunks.value.filter(c !uploadedChunks.value.has(c.index)) for (const chunk of pending) { if (pool.size MAX_CONCURRENCY) { await Promise.race(pool) } const task uploadChunk(chunk).then(() { uploadedChunks.value.add(chunk.index) settledCount.value saveLocalUploaded(fileHash.value, uploadedChunks.value) }).catch((err) { // 单片失败重试一次 return uploadChunk(chunk).then(() { uploadedChunks.value.add(chunk.index) settledCount.value saveLocalUploaded(fileHash.value, uploadedChunks.value) }) }).finally(() { pool.delete(task) totalProgress.value (settledCount.value / chunks.value.length) * 100 }) pool.add(task) } await Promise.all(pool) // 4. 全部传完调合并接口 await axios.post(/api/merge, { fileHash: fileHash.value, fileName: file.value.name, totalChunks: chunks.value.length }) uploading.value false } async function uploadChunk(chunk) { const formData new FormData() formData.append(file, chunk.file) formData.append(fileHash, fileHash.value) formData.append(chunkIndex, chunk.index) formData.append(totalChunks, chunks.value.length) return axios.post(/api/upload, formData, { timeout: 120000 }) }注意pool.delete(task)的写法task是在const声明之后才被赋值的但由于finally回调是异步执行的等它真正跑起来时task早就赋值完毕了所以闭包引用没有任何问题。这个写法在社区里也很常见用来在Promise.race的等待中精确移除已经结束的请求。3.4 进度条按分片完成数算还是按字节数算进度的计算有两种口径按分片个数算和按已上传字节数算。分片大小相同的情况下两者基本等价但更精确的做法是按字节数因为实际网络传输中每个分片的耗时差异很大。刚才的示例里我按分片个数计算胜在简单如果你要精确到小数位可以记录每个分片的size然后const uploadedBytes [...uploadedChunks.value].reduce((sum, idx) { return sum chunks.value[idx].size }, 0) totalProgress.value (uploadedBytes / file.value.size) * 100如果你的产品要求显示每个分片的实时网速那还要配合 axios 的onUploadProgress事件拿到当前分片的loaded和total再做累计。这属于锦上添花核心逻辑不复杂只是要注意onUploadProgress高频率触发时怎么节流更新进度条不然 UI 频繁刷新会掉帧。4. 断点续传与秒传的落地localStorage 配合后端校验4.1 断点续传到底断在哪、续在哪断点续传的前提是前端知道哪些分片已经传成功了。这需要两层记录。一层是后端的记录。每次分片上传成功后端把分片信息写进存储数据库、Redis哪怕是文件系统里的一个列表都行。下次前端带着 fileHash 来校验后端返回已传分片列表。这是最可靠的记录。另一层是前端的本地记录。把已传分片的 index 集合存进 localStoragekey 可以设计成upload_${fileHash}。这样即使后端没做记录有些临时方案或 mock 后端或者用户关掉页面又重新打开前端也能跳过已传分片。我在示例里用了一个简单的 localStorage 封装function getLocalUploaded(fileHash) { const key upload_${fileHash} const data localStorage.getItem(key) return data ? JSON.parse(data) : [] } function saveLocalUploaded(fileHash, uploadedSet) { localStorage.setItem(upload_${fileHash}, JSON.stringify([...uploadedSet])) } function clearLocalUploaded(fileHash) { localStorage.removeItem(upload_${fileHash}) }注意localStorage 的可存储空间有限一般 5MB如果你的分片数非常多比如几万片存 index 数组可能超限。这种情况建议只存最后一个连续完成的 index或者用 IndexedDB但绝大多数场景index数组都够用。4.2 秒传的实现后端到底怎么判断秒传不是前端魔法关键在后端。前端调用/api/check时带过去 fileHash后端做的事很简单查一下这个 fileHash 对应的完整文件是否已经存在存在就直接返回uploaded: true前端都不用上传任何分片直接提示上传成功不存在就看有哪些分片已经传过返回uploadedChunks列表。所以前端拿到 check 结果后只需要上传缺失的分片。秒传的存储成本由后端承担但它的价值巨大同一份文件被多人多次上传时服务器几乎零费用返回成功对网盘类产品是刚需。这里有个注意事项MD5 校验不是绝对安全理论上有碰撞可能而且内容相同不等于文件合法。业务层面如果对文件有安全要求后端还要对合并后的文件做二次扫描别把秒传当成安全绕过通道。4.3 暂停与继续顺序上传模式下的最简单实现上面的并发池代码里塞暂停逻辑会让代码变得很绕。如果你的项目对并发要求不高可以用一个更直白的顺序上传模式暂停起来非常清爽let paused false async function uploadSequential(chunks, fileHash) { const uploadedSet new Set(getLocalUploaded(fileHash)) for (let i 0; i chunks.length; i) { if (paused) break if (uploadedSet.has(chunks[i].index)) continue try { await uploadChunk(chunks[i]) uploadedSet.add(chunks[i].index) saveLocalUploaded(fileHash, uploadedSet) } catch (err) { // 失败不中断重试一次后再失败就停下 await uploadChunk(chunks[i]) uploadedSet.add(chunks[i].index) saveLocalUploaded(fileHash, uploadedSet) } } if (!paused) { await axios.post(/api/merge, { fileHash, fileName: file.value.name, totalChunks: chunks.length }) } } function pauseUpload() { paused true }继续上传时把paused置回 false重新调uploadSequential已经从 localStorage 读出的uploadedSet会跳过已传分片实现续传。这个模式虽然比并发慢但逻辑清晰、错误处理直观适合中小文件、对速度不敏感的场景。想要并发又想要优雅暂停可以研究一下 p-limit 库配合信号量中断不过那属于另一个话题了。5. 后端接口约定与联调清单前端写得再欢也得有人接5.1 三个接口的职责划分前端代码再好后端不配合也白搭。我跟后端联调时一般会把接口约定写成下面这种表格双方照着签接口方法入参返回/api/checkPOSTfileHash, fileName, totalChunks{ uploaded: boolean, uploadedChunks: number[] }/api/uploadPOSTfile(表单), fileHash, chunkIndex, totalChunks{ ok: true }/api/mergePOSTfileHash, fileName, totalChunks{ url: string }这里有个容易被忽略的约定totalChunks必须由前端传因为后端并不知道这个文件总共有多少片merge 的时候也建议把totalChunks传过去方便后端校验分片是否收齐避免前端漏传导致合并出半个文件。5.2 分片合并的顺序问题后端收到的是乱序到达的分片它的存储目录里分片可能是 0、2、1、4、3 这样的顺序。合并时必须按chunkIndex升序读取分片再拼接绝不能按文件修改时间或名字排序拼。我在实际项目里见过一次线上事故后端按分片文件的 inode 时间排序合并结果视频传到一半就花屏查了半天才发现是合并顺序错了。另外合并时一般用流式写入Node 的createWriteStream、Java 的FileChannel、Go 的os包都能处理千万别用 readFile 把整个文件读进内存再 write大文件分分钟炸内存。5.3 文件重名的处理分片存储的目录建议用 fileHash 命名比如uploads/${fileHash}/下面放所有分片。这样的话不同用户上传同名但内容不同的文件也不会互相覆盖。合并后的最终文件名可以用${fileHash}_${originalName}保存既保留原名又避免重名冲突。如果业务需要保留原始文件名给用户下载那就在数据库里多存一个字段别拿原名直接当磁盘文件名。还有一点如果后端做了定时清理建议清理规则以最后活跃时间为准而不是以创建时间为准。因为断点续传的用户可能隔了一天才继续传过早清理会把已传的分片删掉前面的功夫全白费。6. 我实测踩过的坑并发数、分片大小、哈希耗时和异常恢复6.1 并发数选多少合适并发数我是从 6 调到 3 再调到 6 的。第一次做的时候以为并发越高越好直接开了 10 个结果自家测试服务器上传时带宽被打满其他接口全卡死。后来压测发现对于后端处理能力一般的情况3~5 个并发是比较稳的区间如果后端在云上、带宽充足6~8 个也能跑。关键是做压测别拍脑袋。要注意浏览器 HTTP/1.1 对同一域名的并发连接限制一般是 6如果业务里有其他请求也打到同一个服务前端包一层代理域名或者升级 HTTP/2 可以缓解但这些属于架构层面的事做上传组件时先守住别超过 6这条线就行。6.2 分片大小怎么选分片大小我试过 1MB、5MB、10MB、20MB。结论是1MB 分片数为 1000 个1GB 文件请求数量太多浏览器和服务器都累20MB 分片在弱网环境下单片失败重传成本高。5MB 到 10MB 是主流选择。再结合一个因素后端如果对单次请求体有 10MB 限制那就选 8MB 以内如果有 50MB 限制选 20MB 也能跑。总之分片大小要跟后端限制对齐别前端切得很开心、后端一接就 413。6.3 哈希计算的卡顿与 Web Worker 优化拖一个 5GB 的文件进来主线程算 MD5页面会卡到让人怀疑电脑死机。因为 FileReader 的readAsArrayBuffer会不断占用主线程。优化方案是把读文件和算哈希放进 Web Worker// worker.js importScripts(https://cdn.jsdelivr.net/npm/spark-md53.0.2/spark-md5.min.js) self.onmessage function (e) { const { chunks } e.data const spark new self.SparkMD5.ArrayBuffer() let index 0 function next() { const reader new FileReader() reader.onload (ev) { spark.append(ev.target.result) index if (index chunks.length) { next() } else { self.postMessage({ hash: spark.end() }) } } reader.readAsArrayBuffer(chunks[index].file) } next() }主线程里这样调用const worker new Worker(/worker.js) worker.postMessage({ chunks: chunks.value }) worker.onmessage (e) { fileHash.value e.data.hash worker.terminate() }注意 Web Worker 里无法直接操作 DOM但File是可以在postMessage里传递的结构化克隆所以 chunks 数组传进去没问题。这一个改动就能让大文件在上传前期的卡死阶段变成顺滑的等待。很多团队讨论的前端使用 worker 上传大文件就是这个思路它优化不止哈希后续分片的读取和预处理同样可以搬到 worker 里做当然那属于更深的性能优化先把哈希这关过了收益最大。6.4 其他几个高频异常413 Request Entity Too Large先查网关和后端限制再查分片大小是否超了后端单请求限制。网络中断导致单片上传失败加上失败重试机制指数退避重试三次三次还失败就停下来提示用户别让它无限重试。合并后文件损坏优先检查后端的合并顺序其次检查是否有分片遗漏。localStorage 存满分片数量大时不存全量数组改成增量记录或 IndexedDB。秒传误判前端算错哈希或者后端存储碰巧有相同哈希文件导致用户传了新文件却秒传成旧文件。排查时先对比两个文件的 MD5 是否一致。这个方案在我手上的项目里稳定跑了一年多几十 GB 的录像文件没再出过传一半丢了的投诉。中间只改过两次一次是并发数从 6 降到 4另一次是给后端加了一个临时存储清理任务。如果你也是第一次接大文件上传的需求别急着上各种花哨功能先把切片、合并、断点续传这三板斧打磨稳后面的优化都是顺手的事。
返回列表