尧图精选

Vue 3 + Node.js 实现大文件断点续传与分片上传实战

🕒 发布时间:2026/10/2 9:55:13 📁 来源:尧图网络
大文件上传这活儿看着简单真做起来全是坑。几 GB 的安装包、视频素材、数据集传到一半断网后端收到的文件没法用用户又得从头来一次脾气再好也得崩溃。所以这几年“断点续传”成了大文件上传方案的标配尤其在前端 Vue 工程里配合 JavaScript 的 Blob 切片和发送异步请求能把整个上传过程拆成可控的小任务强悍又有韧劲。我这次就把自己摸索出来的一套 Vue 3 Node.js 断点续传 DEMO 拆开揉碎了讲一遍。从分片思路、哈希计算到后端合并全流程走通代码可以直接抄坑也会提前帮你们踩了。1. 断点续传的核心逻辑分片、记录、重传1.1 先把“断点续传”这个词拆开看很多人一听到“断点续传”脑袋里浮现的是迅雷下载那个进度条。本质上它确实就是那个意思文件不是被当作一个整体传输而是切成 N 个小块逐一上传如果中间哪一块没传成功下次重新发起时只补传失败的那几块而不是整个文件再来一遍。这里头有两个关键动作分片slice前端用 Blob API 把大文件按偏移量切成多个二进制块。记录与校验后端记录哪些分片已收到前端在每次恢复上传前向后端要一个“已上传分片清单”只补缺的部分。用生活类比的话就好比搬家。整栋楼的家具一趟一辆车拉走路上爆胎一次全完蛋。切片上传就像把家具先装箱每个箱子独立搬运哪个箱子丢了就补哪个箱子其他箱子不用返工。1.2 断点续传 Solve 什么问题规模超过 2GB 的文件用传统单次上传会有三个致命问题网络稳定性不可控移动网络、公司代理断连、服务器主动超时都可能中途断开。服务端接收压力大一次 PUT 或 POST 一个大 Body后端如果没做好流式接收内存直接被顶爆。重试成本太高失败后全量重传浪费带宽和时间尤其在弱网环境基本等于放弃。断点续传让整个上传过程变成“多批次小任务”失败可重试并发可控制进度可恢复。这套思路同时兼顾了弱网友好和服务器资源开销的平衡。2. 技术选型与整体设计思路2.1 前端为什么用 Vue 3 Composition API其实断点续传核心是 JavaScript 的能力和框架本身关系不大。用 Vue 3 主要图它生态成熟、组件化开发方便还有一个很重要的原因Composition API 的ref、reactive在处理上传进度这种高频状态变更时比 Options API 更顺手状态逻辑抽出来复用也容易。另外 Vue 3 搭配 Element Plus 的el-upload和el-progress做上传界面可以少写一堆 CSS 和交互逻辑。我这套 DEMO 不依赖太重的组件库但思路完全兼容 Element Plus。2.2 分片大小怎么定分片大小是断点续传方案里最需要权衡的一个参数。常见建议是 1MB 到 20MB 之间得看实际场景文件大小分片大小分片数量并发数建议100MB2MB5031GB5MB2003-55GB10MB5005我自己的经验是普通 Web 服务5MB 一片最均衡。太小会导致请求次数爆炸HTTP 握手开销占大头太大会失去“断点”的意义网络波动时单块失败重传成本高。要注意分片大小要结合服务端的接收超时时间和带宽情况调整。内网环境带宽充足可以放大到 20MB/片公网弱网环境建议 2MB/片更稳妥。2.3 哈希计算是个绕不开的环节断点续传里经常要做文件指纹Hash计算原因在于文件内容没变才能判定同一文件。两个不同文件如果正好同名同大小直接用文件名做标识是危险的容易出现“文件 A 的断点续传到文件 B”的错乱。比较稳妥的做法是用SparkMD5对文件计算 MD5。计算过程可以放在Web Worker中执行避免计算大文件时主线程卡死拖慢页面滚动和渲染。我见过不少项目图省事直接在主线程算 2GB 文件的 MD5计算期间整个页面像冻住一样用户直接以为死机了。3. 前端环境安装与项目初始化3.1 创建 Vue 3 项目并安装依赖先建项目建议直接用 Vite开发时热更新快构建产物也干净。npm init vite-app chunk-upload-demo cd chunk-upload-demo npm install npm install spark-md5 axiosspark-md5用来计算文件指纹axios用来发请求。如果项目本身有自己封装的请求库换成自己的就行原理都一样。3.2 封装核心上传模块我得提醒一下千万别把上传逻辑全塞进组件里那组件会变成一坨“会动的意大利面”。最好单独抽一个useUploader.js模块把所有状态和逻辑封装进去组件里只做界面渲染和事件绑定。import { ref, reactive } from vue export function useUploader() { const file ref(null) const fileHash ref() const chunkSize 5 * 1024 * 1024 // 5MB const uploadedChunks reactive(new Set()) const progress ref(0) const uploading ref(false) const paused ref(false) const selectFile (rawFile) { file.value rawFile uploadedChunks.clear() progress.value 0 fileHash.value } return { file, fileHash, chunkSize, uploadedChunks, progress, uploading, paused, selectFile } }这个模块专门管理“上传状态”。后续所有方法都围绕这个状态展开组件里只需要引入仓库管理。4. 分片与文件指纹计算4.1 用 SparkMD5 计算文件唯一标识这一步的目的是给文件生成一个唯一的 chunk 任务 ID。我在实际项目里用的格式是${fileHash}-${lastModified}-${file.size}SparkMD5的增量计算写法如下import SparkMD5 from spark-md5 const calculateHash (file, chunkSize) { return new Promise((resolve, reject) { const blobSlice File.prototype.slice const chunks Math.ceil(file.size / chunkSize) const spark new SparkMD5.ArrayBuffer() const fileReader new FileReader() let currentChunk 0 fileReader.onerror reject fileReader.onload (e) { spark.append(e.target.result) currentChunk if (currentChunk chunks) { loadNext() } else { resolve(spark.end()) } } const loadNext () { const start currentChunk * chunkSize const end start chunkSize file.size ? file.size : start chunkSize fileReader.readAsArrayBuffer(blobSlice.call(file, start, end)) } loadNext() }) }注意我在计算 Hash 的时候用了FileReader.readAsArrayBuffer比readAsBinaryString靠谱后者在内存占用和兼容性上都有隐患。计算过程如果用 Worker 做这里直接在整个 Worker 文件里封装相同逻辑即可。4.2 计算超时的处理大文件 Hash 计算有可能会很久。如果文件有几 GB即使开 Worker计算 MD5 也需要数十秒。应对方案一般是先做一个“快速校验”比如查后端是否存在“秒传文件”存在就直接返回上传成功跳过切片不存在再进入完整计算流程。“秒传”的逻辑在后端是一个很有用的优化我在第 6 节会专门提到。5. 分片上传与前台上传进度实现5.1 分片切片逻辑文件切片很简单用file.slice(start, end)就能获取一段二进制块。切片数据要加上额外的元信息让后端知道这是哪个文件的第几片。const createChunk (file, index, chunkSize) { const start index * chunkSize const end Math.min(file.size, start chunkSize) return file.slice(start, end) }这里有个细节切片时不要用end file.size作为边界判断直接start chunkSize会导致最后一个切片越界。上面代码用Math.min兜底看起来简单实际能省掉不少边界 bug。5.2 上传单个分片每个分片通过 FormData 发送。这里我要强调分片数据本身在 FormData 里就是二进制后端拿到的也是Stream或Buffer不要尝试转成 base64那会让体积膨胀 33%纯属浪费。import axios from axios const uploadChunk (formData) { return axios.post(/api/upload/chunk, formData, { headers: { Content-Type: multipart/form-data }, timeout: 120000 }) }超时时间要给足分片上传经常在弱网环境下发生“慢请求”120 秒比较合适。如果请求被服务器提前断开axios 会抛错我们在上层捕获后标记该分片为“未完成”状态等待重试即可。5.3 并发控制与进度条并发控制是断点续传的进阶操作。通过并发上传上传总耗时比挨个传快很多同时又能避免一次性发起上百个请求把浏览器和服务端都打垮。const concurrentUpload async (tasks, limit 3) { const queue [...tasks] const workers new Array(limit).fill(null).map(async () { while (queue.length) { const task queue.shift() await task() } }) await Promise.all(workers) }进度条逻辑是根据“已成功分片数 / 总分片数”计算的。注意这里的进度不是拿“已发请求数”计算而是拿“后端确认写入成功的分片数”计算保证进度条反映的是真实落地状态而不是“请求发出去但可能失败”的假进度。const updateProgress () { const totalChunks Math.ceil(file.value.size / chunkSize) progress.value Math.round((uploadedChunks.size / totalChunks) * 100) }5.4 暂停、恢复与取消暂停的本质是终止当前未完成的分片请求。这个操作要跟“取消”区分开取消是彻底终止该文件上传任务暂停只是临时中断之后还能从断点继续。我用一个AbortController来管理每个分片请求的中断const pause () { paused.value true // 中断所有未完成请求 activeControllers.forEach(controller controller.abort()) } const resume async () { paused.value false // 重新请求后端已上传分片列表过滤后继续上传 await fetchUploadedChunks() await uploadRemainingChunks() }中断请求时要注意axios 的abort会直接让 Promise reject上层要区分“主动中断”和“网络错误”。主动中断不应触发错误提示应静默处理。6. 后端实现分片接收与合并6.1 Node.js 后端选型与接口设计后端我用的 Express。接口就三个接口作用POST /api/upload/chunk接收单个分片GET /api/upload/status查询文件已上传的分片列表POST /api/upload/merge合并全部分片三个接口各司其职前端流程自然就串起来了。6.2 接收单个分片后端接收分片时要记录两个关键信息文件标识和分片索引。临时文件存到./upload_temp/{fileHash}/{index}.part。const uploadChunk async (req, res) { const { fileHash, chunkIndex } req.body const chunkFile req.files.chunk const chunkDir path.resolve(./upload_temp/${fileHash}) if (!fs.existsSync(chunkDir)) { fs.mkdirSync(chunkDir, { recursive: true }) } const chunkPath path.join(chunkDir, ${chunkIndex}.part) await fs.promises.rename(chunkFile.path, chunkPath) res.json({ code: 0, message: chunk uploaded }) }用rename移动临时文件比fs.writeFile重新写入要快而且更安全。如果跨磁盘分区导致 rename 失败再回退到copyFile unlink。6.3 查询已上传分片断点续传的关键接口。前端发起恢复上传时先问后端“这个文件的哪些分片已经存在”然后只传缺失的部分。const getUploadStatus async (req, res) { const { fileHash } req.query const chunkDir path.resolve(./upload_temp/${fileHash}) if (!fs.existsSync(chunkDir)) { return res.json({ code: 0, data: { uploadedChunks: [] } }) } const files await fs.promises.readdir(chunkDir) const uploadedChunks files .filter(f f.endsWith(.part)) .map(f parseInt(f.split(.)[0], 10)) res.json({ code: 0, data: { uploadedChunks } }) }这个接口还有一个作用并发场景去重。假如多个分片并发上传服务端可能收到重复分片前端并发队列里重复执行后端要幂等处理——同名分片已存在就跳过不重复保存。上面用rename覆盖同名文件时Windows 会报 EEXIST 错误所以可以先检查存在性。6.4 分片合并所有分片都齐了后端执行合并。合并方式是流式把每个.part文件按索引顺序写入最终文件。const mergeChunks async (req, res) { const { fileHash, fileName, totalChunks } req.body const chunkDir path.resolve(./upload_temp/${fileHash}) const outputPath path.resolve(./upload_output/${fileName}) await fs.promises.mkdir(path.dirname(outputPath), { recursive: true }) // 校验分片数量是否匹配 const files await fs.promises.readdir(chunkDir) if (files.length ! Number(totalChunks)) { return res.status(400).json({ code: 1, message: chunk count mismatch }) } // 按索引排序后流式合并 const sortedFiles files .filter(f f.endsWith(.part)) .sort((a, b) { return parseInt(a.split(.)[0], 10) - parseInt(b.split(.)[0], 10) }) const outputStream fs.createWriteStream(outputPath) for (const file of sortedFiles) { const chunkStream fs.createReadStream(path.join(chunkDir, file)) await new Promise((resolve, reject) { chunkStream.pipe(outputStream, { end: false }) chunkStream.on(end, resolve) chunkStream.on(error, reject) }) } outputStream.end() // 清理临时目录 await fs.promises.rm(chunkDir, { recursive: true, force: true }) res.json({ code: 0, message: merge done }) }合并时用流式管道不把整个文件加载进内存这一点很重要。要是直接把所有分片读进 Buffer 拼起来2GB 文件就能把服务器内存打爆。6.5 秒传检测我前面提过“秒传”后端实现就是在合并前先检查数据库或一个 JSON 映射表里是否已有相同 fileHash 的文件记录。如果有直接返回成功前端跳过整个上传流程。实际项目里秒传检测一般放在查询状态接口之前或者放在上传流程最开头const checkExists async (req, res) { const { fileHash } req.query // fileMap 文件标识与实际文件路径的映射表 const record fileMap[fileHash] if (record) { return res.json({ code: 0, data: { exists: true, path: record.path } }) } res.json({ code: 0, data: { exists: false } }) }这个功能特别适合企业内部的资料分享场景同一个文件被上传几十次每次都省了带宽和存储的重复写入。7. 前端交互组件设计与 Demo 页面7.1 上传面板组件页面结构不复杂一个文件选择按钮、一个进度条、一组控制按钮。重点是组件里把useUploader暴露的方法绑定到界面事件上。template div classupload-panel input typefile changehandleFileChange / el-progress :percentageprogress v-iffile / div classactions button clickstartUpload :disableduploading开始上传/button button clickpause :disabled!uploading暂停/button button clickresume :disabled!paused继续/button /div /div /template script setup import { useUploader } from ../composables/useUploader const { file, progress, uploading, paused, selectFile, startUpload, pause, resume } useUploader() const handleFileChange (e) { selectFile(e.target.files[0]) } /script注意el-progress如果不引入 Element Plus可以用原生 div 写个简单的进度条样式自己控制效果也不差。7.2 分片上传流程串联整个上传流程是这样的用户选择文件。计算文件 HashWorker 后台执行或主线程异步执行。请求/api/upload/status获取已上传分片索引。把未上传的分片放入并发队列逐个上传。所有分片上传完成后请求/api/upload/merge合并。显示最终下载链接。伪代码const startUpload async () { uploading.value true fileHash.value await calculateHash(file.value, chunkSize) // 查已上传分片 const { uploadedChunks } await fetchStatus(fileHash.value) const chunks [] const total Math.ceil(file.value.size / chunkSize) for (let i 0; i total; i) { if (uploadedChunks.includes(i)) { uploadedChunksSet.add(i) } else { chunks.push(i) } } await concurrentUpload(chunks.map(index () uploadOneChunk(index)), 3) await merge() uploading.value false }这一步是最容易出错的地方我见过很多项目在“查询已上传分片”之后没有过滤直接把全部分片重新上传一遍浪费带宽。上面代码先查状态再过滤才是真正意义上的“续传”。7.3 使用 Web Worker 避免卡顿计算 Hash 如果文件超过 1GB主线程会明显卡顿。我建议把 Hash 计算放进 Worker。Vite 对 Worker 的支持很友好直接用new Worker(new URL(./hashWorker.js, import.meta.url), { type: module })就行。Worker 文件内容就是把calculateHash逻辑搬进去完成后postMessage返回结果// hashWorker.js import SparkMD5 from spark-md5 self.onmessage (e) { const { file, chunkSize } e.data const hash calculateHash(file, chunkSize) self.postMessage({ hash }) }如果不想引入 Worker也可以用requestIdleCallback分片计算但处理起来麻烦得多而且主线程依然会被切片读文件占据大量时间所以还是 Worker 最省心。8. 常见问题与排查技巧实录8.1 分片上传了但服务器上文件不可用这个情况十有八九是合并顺序错了。我排查的顺序是检查临时目录里分片文件的大小是否每个都等于设置的 chunkSize最后一片除外。检查命名是否包含前导零比如1.part、2.part、10.part被字符串排序后10 会在 2 前面。检查合并流的管道关闭时机管道没 close 就结束写入文件会截断。字符串排序问题是最容易踩的坑。如果分片文件名不带前导零一定要用数字排序不能默认字符串排序。// 错误示例字符串排序会导致 10 排在 2 前面 files.sort() // 正确示例 files.sort((a, b) parseInt(a, 10) - parseInt(b, 10))8.2 进度条回跳或卡住不动进度条卡住通常有两个原因并发队列里有某个分片请求长期挂起或者后端写入速度远低于上传速度导致服务端 socket 阻塞。处理办法给每个分片请求设置超时超时后主动重试 3 次。后端在接收分片时如果磁盘 IO 太慢前端并发数要调低比如从 5 降到 3。进度条以“成功后端确认”为准不用axios.onUploadProgress的上传字节数。因为onUploadProgress只代表字节进入发送缓冲区不代表服务器读完。8.3 暂停后恢复却从头开始这个问题的根因通常出在fileHash没保存。用户暂停后页面刷新或组件销毁hash 状态丢了重新选择文件时 hash 又变了尤其如果文件修改时间变了。解决办法是持久化把fileHash存到localStorage文件 MD5 计算完成后立即保存。恢复上传时从 localStorage 读取优先和后端状态接口比对。const saveHash (hash) { localStorage.setItem(upload_hash_${file.value.name}, hash) } const loadHash (name) { return localStorage.getItem(upload_hash_${name}) }注意file.lastModified变化会导致同样的文件内容算出不同的 hash所以持久化时最好连同lastModified一起保存作为文件变更的校验依据。8.4 服务器返回 413 Request Entity Too Large这个错误是服务器限制了单个请求体大小。很多人只看前端忽略了后端配置。Express 用multer接收分片时大小限制设置const upload multer({ storage: multer.diskStorage({ destination: ./upload_temp, filename: (req, file, cb) { cb(null, ${Date.now()}-${file.originalname}) } }), limits: { fileSize: 20 * 1024 * 1024 } // 单个分片限制 20MB })如果分片设置为 10MB这个限制设置为 20MB 就没问题要始终留一倍余量防止 FormData 额外字段导致的体积偏移。8.5 并发数过高导致浏览器内存飙升并发数不是越大越好。我实测过5MB 分片、并发 3 个100MB 文件上传流畅度最好并发 10 个虽然速度快一点但浏览器内存占用高了差不多 300MB低端设备会直接卡死。我把不同环境的建议写成一个参考移动端 4G 网络并发 2分片 2MBPC 有线网络并发 3-4分片 5MB本地内网服务并发 5-6分片 10MB这个组合要灵活调整没有银弹。8.6 合并时提示“分片数量不匹配”这个错误通常由两种情况引起并发上传过程中某些分片请求失败但没重试成功前端以为成功、后端没写入。前端计算总片数时用了Math.ceil(size / chunkSize)但某个分片确实没发出去。排查方式在mergeChunks接口打日志把files.length和totalChunks对比输出。再在前端上传完成循环里加一个“分片上传结果集合”的断言确保每一片都有成功响应。8.7 计算 Hash 时间长、用户以为页面挂了解决方案我在前面提过 Worker。这里再给一个补充技巧计算 Hash 前用一个Loading状态提示用户文案写清楚“正在分析文件请勿关闭”配合 Worker 后台运行用户感知会好很多。如果文件大到连 MD5 计算都不现实比如 50GB 级别的数据集可以退一步只计算首尾分片的校验哈希或者干脆用文件大小 首片哈希 末片哈希组合成标识。安全性和准确性都有折扣但实用性拉满。9. 优化方向与扩展建议9.1 上传进度与服务端写入分离大文件场景下前端进度条到 100% 不代表文件立刻可用。后端还有合并时间。所以实际做项目时我一般把状态机做成四态状态含义uploading分片上传中merging分片合并中success上传完成failed上传失败前端在上传完最后一片后可以轮询/api/upload/status或/api/upload/merge-status接口直到返回 success 再展示成功图标。别小看这一步用户端体验差异巨大。9.2 断点续传 并发去重前端并发可能导致两个相同的分片同时上传。我在后端加了一个简单检查分片文件存在就跳过写盘直接返回成功。这个幂等处理极大提升了系统的容错率尤其在高并发场景。9.3 用 IndexedDB 保存上传任务如果项目要支持“关闭浏览器后仍能恢复任务”前端本地状态就得用 IndexedDB 持久化localStorage 存字符串存大任务列表太吃力。IndexedDB 可以保存 Blob 切片引用、状态机数据源下次打开页面直接恢复。这套改造听着复杂其实核心就是多维护一张“任务表”。10. 写在最后的经验断点续传这个 DEMO本质是一个“状态管理 异步并发 文件 IO”的组合拳。前端玩的是切片、并发、异步恢复后端玩的是文件流、幂等、合并。我做过的几次线上问题排查得出的经验是前端问题看着像后端后端问题往往要从前端验证。比如用户说“文件坏了”结果根因是前端并发重试时偶然重复上传同一个分片用户说“上传卡住了”根因是后端磁盘满了没返回响应。所以做这类系统日志一定要打“分片索引、文件 Hash、请求耗时”三段关键信息否则排查起来像大海捞针。如果你第一次写这个 DEMO建议先跟着这篇文章把链路跑通然后尝试回答这几个问题你的并发控制能应对服务器返回 500 吗你的断点恢复能应对用户手动刷新页面吗你的合并逻辑能处理分片文件被中途清空吗把这三个场景想明白你的断点续传就算真正落地了。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →