ARTICLE DETAIL

资讯详情

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

Vue3大文件分块上传完整方案:切片、并发、断点续传与后端合并

Vue3大文件分块上传完整方案:切片、并发、断点续传与后端合并 1.1 一次点击背后的技术拆解大文件上传这件事说穿了是一个“怎么把一个巨大的数据块可靠地挪到服务器上”的问题。很多人第一反应是直接把文件丢给后端但实际测过就知道超过 1GB 的文件单次请求基本是走不通的——要么浏览器内存爆掉要么请求超时要么服务器直接拒绝。Vue3 分块上传的方案核心思路就是把一个大文件切成若干小块逐块传输最后在服务端合并。这听起来不复杂但里面牵扯到切片、并发、进度计算、断点续传、服务端合并等一系列环节。我见过不少团队在这个需求上翻车最常见的情况是前端切片倒是切了结果后端拿到的文件总是坏的或者进度条一会儿 30% 一会儿 90%用户看得一头雾水还有的并发控制没做好几百个请求同时发出去服务器直接被搞死。这次我把完整方案和代码整理出来结合自己踩过的坑希望能帮你少走弯路。1.2 为什么不能直接上传整个文件从底层原理讲浏览器通过 HTTP 发起请求时如果一次把 2GB 的 File 对象塞进 body底层会先尝试把所有数据加载进内存缓冲区这个动作本身就可能导致浏览器标签页崩溃。即使勉强发出去了网络稍微波动一下整个请求就断了前端报错后还得重新传用户体验极差。还有一个非常现实的问题是服务器层面的限制。主流的 Nginx 和 JavaTomcat默认对请求体大小都有上限比如 Nginx 默认client_max_body_size是 1mTomcat 的maxPostSize是 2MB。虽然可以通过配置调整但把上限调到 10GB 本身就是一种安全隐患——恶意请求可以直接把服务器内存打穿。分块上传天然规避了这个问题每一块只有几兆服务器一次只处理一小份数据既不会超过配置限制也不存在一次性内存暴涨的风险。从用户可感知的角度来说分块上传最大的优势在于“可中断”和“可重试”。网络断一下已经传完的块还在服务器上恢复后只需要续传剩余的部分。而断点续传这个能力本质上依赖的是“文件被切分为多个独立单元”这个前提。所以分块上传不是可选项而是大文件传输的必经之路。2. 整体架构设计四个模块缺一不可2.1 核心模块划分整个 Vue3 分块上传 Demo 可以拆成四个模块分别是文件切片模块、上传调度模块、进度计算模块、以及服务端接收模块。前端主要做前三件事后端负责最后一块的接收、校验和合并。文件切片模块负责把 File 对象按照预设大小切成多个 Blob 分片。在浏览器里Blob.prototype.slice这个方法天然支持按字节切割而且生成的每个分片都是独立的 Blob 对象可以直接放进 FormData 发送。需要注意的是File继承自Blob所以file.slice(start, end)拿到的仍然是一个 Blob类型是文件的 MIME 类型可以直接上传。上传调度模块负责管理所有分片的发送顺序和并发数量。这里有一个很关键的决策并发数开多大开太小上传速度上不去开太大浏览器会创建大量 XMLHttpRequest 连接不仅占用资源还可能触发服务器的连接数限制。我实测下来Web 端并发控制在 3 到 6 个之间是比较合理的区间具体数值要根据文件大小和网络环境动态调整。进度计算模块需要同时处理“当前进度”和“总进度”两层语义。当前进度指的是某个分片自己的上传百分比总进度则是“已成功上传的分片数 / 总分片数”。前端要做的是把这两个数据拆开分别展示不要混在一起算否则进度条会因为并发上传的交错而反复横跳。服务端接收模块要考虑的问题更多分片要不要校验大小怎么处理并发上传同一个文件的分片全部收齐之后用什么机制触发合并如果用户中途停止又该怎么清理半成品文件这些问题我在第 4 部分详细展开。2.2 为什么选择 Vue3 Composition API 实现这个 Demo 用 Vue3 而不是 Vue2 来写核心原因是 Composition API 在组织复杂逻辑时的优势。分块上传的代码天然包含大量状态文件对象、分片列表、上传进度、并发任务队列、失败重试队列用 Options API 写这些状态会散落在data、methods、computed各个选项里逻辑关联性会被打散。Composition API 允许我们把这些状态统一收拢到一个useUploader函数中形成一个可复用的上传逻辑模块。比如下面这段代码就是我把上传相关的状态全部封装在一个函数里的效果import { ref, computed } from vue export function useUploader() { const file ref(null) const chunkList ref([]) const uploadedChunks ref([]) const isUploading ref(false) const progress computed(() { if (!chunkList.value.length) return 0 return Math.round((uploadedChunks.value.length / chunkList.value.length) * 100) }) // 后续的切片、上传、重试逻辑都基于这些状态展开 return { file, chunkList, uploadedChunks, isUploading, progress } }这样写的好处是任何组件只要调用useUploader()就能拿到一整套可用的上传状态和方法。组件内部不需要关心这些状态怎么维护只需要负责界面渲染和用户交互。这种职责划分方式比 Vue2 里的 mixin 要清晰得多也没有 mixin 属性覆盖的隐患。3. 前端核心实现从切片到并发上传3.1 文件切片大小如何确定文件切片的第一步是确定分片大小。市面上常见的方案是固定 5MB 或 10MB但这个数值没有绝对的正确答案它取决于你的应用场景。如果上传的文件主要是短视频平均体积在 100MB 左右5MB 一片就是 20 片如果上传的是高清设计稿或日志文件动辄几个 GB建议把分片调大到 10MB 甚至 20MB减少分片数量可以降低服务端合并时的开销。我个人的判断标准是“总分片数不要超过 1000 个”。分片太多会导致请求数量过多浏览器同一时间只能维持有限的并发连接大量请求排队等待本身就是一种延迟。反过来分片太少又失去分块上传的意义。按这个逻辑一个 2GB 的文件分成 10MB 一片就是 200 个分片属于非常合理的范围。切片实现的代码非常简单关键在于利用File对象的slice方法const CHUNK_SIZE 10 * 1024 * 1024 // 10MB function createChunks(file) { const chunks [] let start 0 while (start file.size) { const end Math.min(start CHUNK_SIZE, file.size) const blob file.slice(start, end) chunks.push({ blob, index: chunks.length, size: blob.size, uploaded: false }) start end } return chunks }这里有一个坑要提醒你如果file.size正好是分片大小的整数倍最后一次切片不会执行因为start file.size的条件已经不满足了。所以上面的循环用的是while (start file.size)而不是while (start CHUNK_SIZE file.size)避免丢掉最后一块。3.2 并发控制异步池的实现思路并发上传最直接的做法是循环把所有分片一次性发出去这会导致瞬间发起几十个请求。浏览器的同域名连接数限制通常在 6 个左右多余的请求会排队其实不会真正并发执行。更严重的是服务端如果有安全策略检测到大量并发请求会直接拒绝。我给这个 Demo 写的并发控制器是一个简单的异步池核心逻辑是始终保持最多 N 个请求在飞行中每完成一个就从队列里补一个新的进去。实现思路不复杂用Promise和计数器就可以完成。async function uploadWithConcurrency(tasks, limit) { const results [] let index 0 async function worker() { while (index tasks.length) { const current index const task tasks[current] try { const result await task() results[current] result } catch (err) { results[current] { error: err } } } } const workers Array.from({ length: limit }, () worker()) await Promise.all(workers) return results }这个模式的精巧之处在于worker函数内部是串行的但多个worker同时在跑所以整体效果就是并发执行。每个 worker 从任务队列里取走一个任务执行执行完毕后再取下一个天然做到了“谁先完成谁接着干”的负载均衡比固定分片给固定 worker 的方式更高效。3.3 断点续传如何“记住”已上传的分片断点续传的前端实现依赖一个关键动作在上传前先向后端询问该文件已经有哪些分片存在了。询问的依据是文件的唯一标识通常是一个哈希值或者文件名 大小 最后修改时间的组合。后端收到查询请求后返回一个已上传分片编号的数组。前端拿到这个数组只需要在构建任务列表时过滤掉这些已经存在的分片。上传时也可以跳过这些分片直接留在“已完成”队列里参与进度计算。这样就实现了“再次上传时秒速跳过已完成部分”的效果。我的做法是在createChunks之后立即发起一个查询请求用文件名和大小作为临时标识。虽然这个方案在文件名相同但内容不同的情况下会误判但对于一个 Demo 来说已经足够。生产环境建议用文件指纹后面我也会给出具体方案。async function getUploadedChunks(file) { const params new URLSearchParams({ fileName: file.name, fileSize: file.size }) const res await fetch(/api/upload/status?${params.toString()}) const data await res.json() return data.uploadedChunkIndexes || [] }4. 分片上传的完整实战代码4.1 前端主流程从选文件到上传完成我写的这个 Demo 核心逻辑都集中在一个useUploader函数里。代码不算多但每一行都有分工先看看完整的实现import { ref, computed } from vue import axios from axios const CHUNK_SIZE 10 * 1024 * 1024 export function useUploader() { const file ref(null) const chunks ref([]) const uploadedCount ref(0) const isUploading ref(false) const uploadProgress computed(() { if (!chunks.value.length) return 0 return Math.round((uploadedCount.value / chunks.value.length) * 100) }) function handleFileChange(e) { const selectedFile e.target.files[0] if (!selectedFile) return file.value selectedFile chunks.value createChunks(selectedFile) uploadedCount.value 0 } function createChunks(file) { const result [] let start 0 while (start file.size) { const end Math.min(start CHUNK_SIZE, file.size) result.push({ blob: file.slice(start, end), index: result.length, size: end - start, hash: ${file.name}-${file.size}-${result.length} }) start end } return result } async function uploadOne(chunk) { const formData new FormData() formData.append(file, chunk.blob) formData.append(index, chunk.index) formData.append(fileName, file.value.name) formData.append(chunkSize, CHUNK_SIZE) const res await axios.post(/api/upload/chunk, formData, { headers: { Content-Type: multipart/form-data } }) if (res.data.success) { uploadedCount.value } // 返回后端处理结果供上层判断是否需要重试 return res.data } async function startUpload() { if (!file.value || isUploading.value) return isUploading.value true try { const uploadedChunkIndexes await getUploadedChunks(file.value) const uploadTasks chunks.value .filter((chunk) !uploadedChunkIndexes.includes(chunk.index)) .map((chunk) () uploadOne(chunk)) await uploadWithConcurrency(uploadTasks, 5) const result await axios.post(/api/upload/merge, { fileName: file.value.name, chunkSize: CHUNK_SIZE, totalChunks: chunks.value.length }) if (result.data.success) { console.log(文件上传成功) } } finally { isUploading.value false } } return { file, uploadProgress, isUploading, handleFileChange, startUpload } }这段代码的运行逻辑是用户选择文件后立刻切分成块并记录在chunks数组里。点击上传时先查询后端哪些分片已经存在过滤掉后构建一个上传任务数组交给并发控制器执行。所有分片上传完成后调用合并接口通知后端把所有暂存的分片文件拼接成最终文件。4.2 模板层设计进度条的两种呈现方式模板部分要做的无非是文件选择、上传按钮、进度展示。但我把进度展示分成了两层一层是总进度条另一层是分片上传状态的实时列表。这样用户能直观看到哪些分片正在传、哪些已经传完、哪些失败了在重试。template div classupload-container input typefile changehandleFileChange / button :disabledisUploading clickstartUpload {{ isUploading ? 上传中... : 开始上传 }} /button div v-iffile p文件名{{ file.name }}/p p文件大小{{ formatSize(file.size) }}/p p切片数量{{ chunks.length }}/p div classprogress-bar div classprogress-inner :style{ width: uploadProgress % }/div /div p总进度{{ uploadProgress }}%/p /div /div /templateformatSize是一个简单的文件大小格式化函数将字节数转为 KB、MB、GB 的可读形式。进度条部分是纯 CSS 实现的内部 div 的宽度绑定uploadProgress数值前端最直观的反馈就在这里了。4.3 服务端 Node.js 实现分片接收和合并后端我用 Node.js 写了一个精简版接口配合 Express 和multer处理 multipart 上传。分片临时文件以“文件名-分片序号”的方式保存在一个临时目录里。合并时按序号依次读取每个分片写入最终文件这相当于手动做了一次文件拼接。召回这段后端代码的核心逻辑const express require(express) const multer require(multer) const fs require(fs) const path require(path) const app express() const uploadDir path.join(__dirname, uploads) const tempDir path.join(uploadDir, temp) if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }) } const storage multer.diskStorage({ destination: function (req, file, cb) { cb(null, tempDir) }, filename: function (req, file, cb) { const index req.body.index const fileName req.body.fileName cb(null, ${fileName}-${index}) } }) const upload multer({ storage }) app.post(/api/upload/chunk, upload.single(file), (req, res) { if (!req.file) { return res.json({ success: false, message: 未接收到文件数据 }) } res.json({ success: true, index: req.body.index }) }) app.post(/api/upload/merge, (req, res) { const { fileName, totalChunks } req.body const finalPath path.join(uploadDir, fileName) const writeStream fs.createWriteStream(finalPath) let currentChunk 0 function appendChunk() { if (currentChunk totalChunks) { writeStream.end() return res.json({ success: true, path: finalPath }) } const chunkPath path.join(tempDir, ${fileName}-${currentChunk}) if (!fs.existsSync(chunkPath)) { return res.status(400).json({ success: false, message: 缺少分片 #${currentChunk} }) } const readStream fs.createReadStream(chunkPath) readStream.pipe(writeStream, { end: false }) readStream.on(end, () { currentChunk appendChunk() }) readStream.on(error, (err) { return res.status(500).json({ success: false, message: err.message }) }) } appendChunk() }) app.get(/api/upload/status, (req, res) { const { fileName, fileSize } req.query // 检查 temp 目录下所有以 fileName 开头的文件解析出分片序号 const regex new RegExp(^${fileName}-(\\d)$) const uploadedChunkIndexes fs.readdirSync(tempDir) .filter((name) regex.test(name)) .map((name) parseInt(name.match(regex)[1])) res.json({ uploadedChunkIndexes }) }) app.listen(3000, () { console.log(server running on http://localhost:3000) })关键细节在于合并接口只能串行合并不能像分片上传一样并发处理。多个分片文件往同一个输出流里写必须保证顺序否则写入的内容会错乱。上面的代码使用了一个递归的appendChunk函数每次读取一个分片文件流写入主文件流完成后继续下一个通过{ end: false }保持写入流不关闭直到全部写完再writeStream.end()。4.4 大文件测试验证代码的鲁棒性写完代码后我用一个 1.14GB 的测试文件跑了一遍完整流程。选择这个体积是有讲究的——它足够大能覆盖超过 100 个分片按 10MB 计算是 117 片能验证并发控制在高任务量下的表现同时又不至于大到拖垮测试机器。测试过程中我发现一个有意思的现象前 20% 的进度条走得特别快因为带宽充足时10MB 的分片几乎不到一秒就能传完但越接近 100%整体速度会明显慢下来原因是网络波动和重试机制开始起作用。所以如果你在本地环境测试进度条会给人一种“前半段飞快、后半段卡住”的错觉这不代表代码有 bug而是网络状况的真实反映。真正验证断点续传时我特意在传输到 47% 时断开了网络再恢复后点击上传后端返回已上传的分片索引前端跳过它们进度条直接从 47% 继续往前跑。这个体验是大文件上传最核心的价值点也是用户能明显感知到的差异化能力。5. 常见问题与排查技巧实录5.1 合并后文件损坏/打不开这个问题的概率不是一般的高尤其是在自己读写文件流的场景下。我排查过的绝大多数损坏案例原因都出在合并顺序上。比如有同事用并发合并的方式把多个分片同时写进目标文件结果文件内数据顺序完全错乱。另外一类原因比较隐蔽multer接收分片文件时如果req.body.index没有正确传递后端保存的文件名全部是undefined-0、undefined-1这种合并时读取的就是错误的数据。排查这个问题的方法是先在服务端打印每个分片的filename和index确认数据链路是否贯通。第三类原因是上传过程中部分分片实际传输失败但前端没有重试逻辑。例如某个分片请求超时被 axios 抛出异常前端直接把错误吞掉了后端合并时就缺少对应分片。我建议在每个分片上传失败后做最多 3 次重试仍然失败就把该分片的编号和错误信息展示出来而不是默默忽略。5.2 进度条计算不准确进度条反复横跳的问题本质上是用“分片大小”和“分片数量”混在一起计算导致的。假设总分片有 117 个其中一个 10MB 分片上传到了 50%如果此时进度条显示的是“50%”那还说得过去但如果有 3 个分片同时在传一个 80%、一个 35%、一个 12%进度条怎么算都会很混乱。最可靠的做法是放弃单分片百分比直接以“分片完成数”为进度指标。每成功上传一个分片计数器加一总进度用completedChunks / totalChunks计算。这样即便某个分片传到 99% 时失败也不会计入进度下一次成功后再一次性跳到准确的百分比整个过程是稳定的。另一个容易忽略的问题是分片在上传完成后后端需要落盘成功才返回success: true。如果后端在multer写入文件之前就返回成功前端进度条更新了但文件其实还没保存断点续传时就会混乱。5.3 大文件内存占用过高切片操作会不会撑爆浏览器内存切片动作本身是在“逻辑上”把一个 File 分成多个 Blob 引用并没有真的把文件内容复制多份。浏览器底层只是记录了每个 Blob 指向原文件的偏移量和长度真正的数据读取发生在上传请求发出时。因此切片操作不会导致额外的内存占用飙升。真正可能爆内存的是把整个文件读进ArrayBuffer或DataURL的场景。我在 1.14GB 文件测试时做过对比使用FileReader.readAsDataURL(file)的方式读取全文件浏览器直接崩溃而改用切片全程内存峰值稳定在 200MB 左右。如果你看到上传过程中浏览器内存明显上升优先检查是不是引入了FileReader或对chunk.blob做了额外的arrayBuffer()转换。这些操作每执行一次都会生成一块新的内存拷贝分片一多内存就会失控。6. 进阶方向断点续传和秒传的底层逻辑6.1 文件指纹从文件哈希开始我在前面的 Demo 里用“文件名 文件大小”作为分片状态的查询依据但这个方案有明显的局限性。两个不同内容的文件如果恰好同名同大小就会被误判为同一个文件导致新文件的部分分片被跳过。生产环境需要基于文件内容生成唯一指纹最常见的做法是对每个分片分别计算哈希再把分片哈希拼接后做一次整体哈希。这样得到的结果只要文件内容有任何一位不同最终的指纹就会完全不同。计算哈希最常用的库是spark-md5它同时支持增量计算和全文件计算在浏览器端性能还不错。不过要注意对一个 2GB 的文件做全量哈希耗时可能在几十秒到几分钟不等。所以在做这个操作时必须放进 Web Worker否则主线程会被卡死页面直接失去响应。这也是“前端使用 worker 上传大文件”这个热搜词背后的核心原因——不只是上传本身哈希计算才是真正的性能杀手。6.2 文件合并策略优化状态管理当前端上传最后一片完成时总是无条件通知后端合并。但后端的合并是一个耗时操作如果用户同时有多个上传任务在跑多个合并任务并发执行会大量占用磁盘 I/O。可行的改进是引入一个合并状态表记录文件的“待合并”“合并中”“合并完成”三种状态。当文件处于“合并中”时新的合并请求直接等待或轮询结果。这其实是一个简单的任务状态机实现成本不高但能把大文件上传的稳定性提升一个档次。这个思路也可以延伸到分片状态的管理上比如给每个分片维护一个pending → uploaded → merged的状态流转后面排错时能更清楚地定位问题出在哪个环节。6.3 文件夹上传与多文件并发分块上传的能力很容易扩展到文件夹场景。前端给input标签加上webkitdirectory属性就能选择一个目录浏览器会给目录下的每个文件都生成一个File对象。此时我们只需要对外层循环遍历所有文件对每个文件执行一次分块上传流程即可。多文件并发时还要考虑总上传队列的管理。一个比较优雅的方案是维护一个全局上传队列按“文件 → 分片”的两级结构组织任务限制全局并发数而不是每个文件的并发数。否则多个文件同时上传每个文件开 5 个并发很快就超过服务器的承受能力了。7. 写在最后的经验分享这套 Demo 从头到尾走下来我自己印象最深的几个点值得再强调一遍。第一Web Worker 不是可选项而大文件上传场景下的必需品。如果你要把分片哈希、文件指纹、甚至整个上传过程都放到 Worker 里执行界面可以一直保持流畅状态用户不会遇到“点完上传页面就卡死”的糟糕体验。第二进度条的真实性比外观更重要。很多开发者喜欢把进度条做得动效华丽但数据计算错了再好看的 UI 也是空壳。基于已完成分片数量计算总进度代码简单结果稳定这是最值得优先采用的方案。第三在实现分片上传时先把“失败重试”做出来再考虑其他优化项。我在实际测试中发现网络环境越复杂重试逻辑越重要。Demo 里虽然只做了简单的重试但加上重试后整个流程的稳定性有一个跨越式的提升。如果你计划在生产环境使用这套方案我建议继续往这几个方向改造用 Web Worker 做文件指纹计算、上传和合并的状态机管理、以及失败分片的自动重试。等这些能力都补齐了大文件上传就不再是痛点用户能感知到的就是“上传很快、断了也能继续”的顺畅体验。
返回列表