Vue2与Vue3大文件断点续传实现差异解析

📅 发布时间:2026/9/20 0:53:30
Vue2与Vue3大文件断点续传实现差异解析
很多开发做上传功能第一反应就是扔一个input typefile然后 form 提交或者用 axios 直接 post 整个文件。但一旦文件到了 GB 级别这套方案基本就废了网络稍微波动一下一次十来分钟的上传直接断掉用户血压当场拉满。大文件断点续传就是为这种场景设计的——把文件切成小片一片传完再传下一片断了也从断掉的位置继续。而 Vue2 和 Vue3 在实现这套机制时表面看只是 API 写法不同实际上从状态组织、响应式更新到生态选择差异比想象中大得多。这篇文章就用一个完整的上传模块当例子把 Vue2 与 Vue3 在实现大文件断点续传上的区别讲透。适合正在做上传模块、想把旧项目升级到 Vue3或者准备在 Vue3 里从零写文件上传的人阅读。1. 先拆清楚大文件断点续传到底在解决什么问题1.1 你面对的是哪一种“大文件”“大”不是一个绝对值是相对网络环境和服务器限制而言的。局域网里传 500MB可能几秒钟就完事根本不需要断点续传但从跨地域链路传 2GB长时间占着带宽中间任意一次 TCP 抖动、代理超时或者后端重启都会让整个上传中断。断点续传要解决的痛点主要有三个单次 HTTP 请求上传超大文件很容易触发代理层或者服务端的超时时间网络波动导致整个文件上传失败时之前传的内容全部作废UI 上没有办法给出真实的上传进度反馈用户只能干等。严格来说HTTP 协议本身是支持断点续传的——下载场景用 Range 请求头就能做到浏览器里下载大文件中断后继续底层就是这个机制。但上传方向没有一个通用的标准协议所以前端通常自己把文件切成片一片一片上传并在本地或者服务端记录“哪些片已经传过了”。这就是为什么大文件上传的断点续传八成的工作量都在前端。1.2 断点续传的完整链路一个典型的大文件断点续传流程长这样用户选择文件前端按固定大小切片比如 2MB 一片计算整个文件的哈希值一般是 MD5用于秒传校验、断点登记和后端去重用哈希调一次“初始化上传”接口服务端返回一个 uploadId或者直接告诉你这个文件哪些分片已经存在前端把分片并发上传每个分片都带上 uploadId、分片序号、总分片数这些参数全部上传完之后前端再调一次合并接口让服务端把临时分片合并成完整文件如果中途失败下次打开页面前端带着文件哈希问服务端“哪些分片已经好了”只补传缺失的份。环节前端职责后端职责切片File.slice 切割文件生成分片数组无哈希计算用 spark-md5 读取分片计算 MD5无初始化上传传 fileHash 换取 uploadId返回 uploadId 和已上传分片列表分片上传按分片数组 并发控制上传接收分片落盘或存对象存储合并通知调 merge 接口合并分片校验文件完整性断点恢复查询已传分片过滤后补传返回未完成文件的已上传分片列表在这个链路里Vue 本身并不直接参与切片、哈希和网络请求这些全是浏览器 API 和 HTTP 客户端库的事。那 Vue2 和 Vue3 的差异到底在哪核心在两点一是你如何组织和复用这些流程里的代码逻辑二是当分片数量多、进度更新频繁时响应式系统能不能撑得住。下面逐个展开。2. 代码组织差异从“散装状态”到“逻辑聚合”2.1 选项式 API 里上传逻辑是怎么“散”开的Vue2 里写一个上传组件最自然的做法是把所有状态放 data所有操作放 methods生命周期钩子里再做初始化。export default { data() { return { file: null, chunkSize: 2 * 1024 * 1024, chunkList: [], uploadedSet: [], progress: 0, uploading: false } }, methods: { onFileChange() {}, sliceFile() {}, calcHash() {}, uploadChunk() {}, handleSuccess() {} }, watch: { progress() { // 更新进度条 } }, mounted() { // 初始化逻辑 }, beforeDestroy() { // 取消未完成的上传 } }问题很明显文件切片、哈希计算、并发控制、进度更新、取消上传这些逻辑被拆分到了 data、methods、watch、mounted 等各个角落里。单个组件里先写着还行一旦你要做批量上传组件或者另一个页面的视频上传组件想复用这套逻辑就只能在复制粘贴和 mixin 之间选一个。mixin 能解决复用问题但会引入新的麻烦两个 mixin 可能都定义了uploadChunk方法后加载的覆盖先加载的。等业务复杂到某个程度你很难搞清楚某个字段到底是哪个 mixin 提供的。这属于 Vue2 时代的结构性问题跟代码水平关系不大。2.2 组合式 API 让上传逻辑收敛成一个 HookVue3 推荐的写法是把整段上传逻辑收拢到一个组合式函数里官方叫 Composable社区里更习惯叫 Hook// useUploader.js import { ref, readonly } from vue import SparkMD5 from spark-md5 import axios from axios export function useUploader(options {}) { const chunkSize options.chunkSize || 2 * 1024 * 1024 const concurrency options.concurrency || 3 const progress ref(0) const uploading ref(false) const chunkList ref([]) const uploadedSet ref(new Set()) async function startUpload(file) { // 切片、哈希、并发上传、进度更新都在这里 } async function cancelUpload() { // 取消逻辑 } async function calcHash(file) { // 哈希计算 } return { progress: readonly(progress), uploading: readonly(uploading), chunkList, startUpload, cancelUpload } }组件里就非常干净const { progress, uploading, chunkList, startUpload } useUploader()所有和上传相关的状态和方法都通过函数返回逻辑天然隔离。不同的组件各自调用useUploader()就是各自独立的状态不存在 mixin 那种互相污染的问题。即便你需要在两个组件之间共享同一个上传任务也可以把useUploader()的结果放到一个模块级别的变量里再导出组合式 API 的灵活性比 options API 高很多。2.3 新老项目怎么选新项目直接上 Vue3 Vite Composition API后面维护上传模块会省很多心。像 Element Plus、Naive UI 这些组件库也都很成熟。老项目如果业务稳定、团队对 Vue2 很熟没必要为了一个上传功能强行升级整个框架。Vue2 里完整实现断点续传完全做得到就是代码组织会乱一些。折中方案Vue 2.7 开始原生支持部分 Composition API也可以用官方维护的vue/composition-api插件在 2.6 里体验组合式写法。但这只是过渡手段不建议新模块用它。我个人的建议是如果你正在领导一个 Vue2 老项目又确实要新增一个比较复杂的文件上传模块与其在旧组件里塞一堆方法和 data不如把这个模块单独用 Vue3 的思路做一个“逻辑层”再在 Vue2 组件里调用。这样至少状态管理能清爽一点后续真要迁移到 Vue3 时逻辑代码基本可以原样搬走。3. 响应式系统差异进度更新和任务列表的隐藏坑3.1 defineProperty 和 Proxy 对分片状态的影响Vue2 的响应式基于Object.defineProperty它必须在初始化时就知道对象上有哪些 key所以有几个老生常谈的限制给 data 里的对象新增一个属性页面不会更新必须用this.$set通过下标直接修改数组元素this.list[0] x不会触发更新对Set、Map这类数据结构基本没法做到细粒度响应式常见做法是整个替换。这些限制在断点续传场景里会实打实踩到。比如你要维护一个“已上传分片集合”Vue2 里用数组或者 Set 记录// Vue2 中这样写页面不会感知变化 this.uploadedSet.push(chunkIndex) // 想更新只能整体替换 this.uploadedSet [...this.uploadedSet, chunkIndex]或者当你拿到后端返回的“已上传分片列表”需要把分片状态对象改成done// Vue2 中不生效 this.chunkTasks[index].status done // 必须这样 this.$set(this.chunkTasks, index, { ...this.chunkTasks[index], status: done })Vue3 底层换成了 Proxy新增属性、数组下标变化、Set和Map的增删改都能被拦截。于是同样的逻辑直接写就行chunkTasks.value[index].status done uploadedSet.value.add(chunkIndex)不需要额外的更新 API。这种体验差异在写上传任务列表时非常明显尤其是一个文件切成几百上千片每片都要维护状态Vue2 里到处都是$set和克隆数组Vue3 里就是很自然的赋值操作。3.2 大量分片状态下的初始化与更新性能分片数量多了以后性能也要注意。按 1MB 一片切一个 2GB 文件会产生 2048 个分片对象每个对象又包含 index、blob、status 等字段。如果你把这些对象全部维护成响应式框架在初始化时要花多少时间、更新时要触发多少 watcher是能感知到的。Vue2 初始化 data 时会递归遍历对象的所有属性并调用defineProperty对象层级越深、数组越长初始化越慢。Vue3 的 Proxy 是懒代理只有当你真正访问到某个属性时它才会递归地做代理。所以同样一个有几千个元素的数组Vue3 的初始化开销更小。这里有一个我在项目里常用的优化技巧和框架无关但很实用不要让所有分片数据都成为响应式数据。分片的核心信息比如 index、blob、size一旦创建就不会变没必要响应式只有那些会在渲染层出现的字段比如 status、当前进度才需要响应式。在 Vue3 里你可以用一个普通数组存这些数据只把status抽出来放进一个reactive对象里。如果完全用 Vue2 写建议把分片列表放在 data 之外比如组件的this上挂一个非响应式属性只在需要展示的时候手动同步到 data 字段。3.3 更新进度条时如何避免过度渲染断点续传的进度更新频率很高每个分片完成之后都要算一次。假设你分片完成回调里做了两个操作修改分片状态更新 totalProgress。这两件事都会触发视图更新如果分片很多、切得很小每秒可能触发几十次渲染。Vue 本身有批量更新机制同一个事件循环里的多次赋值只会合并成一次渲染。但在并发上传的情况下多个请求的完成回调是异步的、分散在不同的事件循环里所以该节流还是得节流。最简单的方式是手动控制进度条更新频率function useThrottle(fn, delay 100) { let timer null return function (...args) { if (timer) return timer setTimeout(() { fn.apply(this, args) timer null }, delay) } } const updateProgress useThrottle((p) { progress.value p }, 200)上传分片状态本身不需要节流但进度条数值一秒内更新 20 次和 5 次肉眼看起来没有区别渲染开销却少得多。分片越细这个优化越值得做。4. 完整代码对比Vue2 与 Vue3 实现断点续传的核心写法4.1 文件切片与哈希计算文件切片在 Vue2 和 Vue3 里没有任何区别底层都是File.prototype.slice。一个最基础的切片函数长这样function createChunks(file, chunkSize 2 * 1024 * 1024) { const chunks [] let start 0 while (start file.size) { const end Math.min(start chunkSize, file.size) chunks.push({ index: chunks.length, blob: file.slice(start, end), size: end - start }) start end } return chunks }哈希计算通常用 spark-md5。这里要特别注意大文件不能一次性readAsArrayBuffer整个文件内存会直接爆掉必须逐片读取import SparkMD5 from spark-md5 function calcFileHash(file, chunkSize 2 * 1024 * 1024) { return new Promise((resolve, reject) { const spark new SparkMD5.ArrayBuffer() const reader new FileReader() let offset 0 function readNext() { const slice file.slice(offset, offset chunkSize) reader.readAsArrayBuffer(slice) } reader.onload (e) { spark.append(e.target.result) offset chunkSize if (offset file.size) { readNext() } else { resolve(spark.end()) } } reader.onerror (e) reject(e) readNext() }) }这段代码放在 Vue2 的 methods 里和放在 Vue3 的普通函数里都能跑框架无关。但在计算超大文件哈希时界面会有明显卡顿因为逐片读取和 MD5 运算都发生在主线程。建议把哈希计算放进 Web Worker。Vue3 Vite 的写法更顺手// hash.worker.js import SparkMD5 from spark-md5 self.onmessage (e) { const { file, chunkSize } e.data // 逐片计算 hash self.postMessage({ hash }) } // 在主线程中引入 const worker new Worker(new URL(./hash.worker.js, import.meta.url), { type: module })Vue2 的 Web Worker 写法要稍微注意构建工具的配置但思路一样。哈希计算这个大文件的并发上传值得放 Worker 内部做整个文件级的状态管理主线程只负责把最终进度同步到响应式数据里。4.2 并发上传与断点恢复并发控制的核心逻辑很简单维护一个队列限制同时执行的请求数量在 N 以内。下面这个实现是框架无关的但要注意在 Vue2 和 Vue3 中更新状态的写法不同。async function uploadChunksInParallel(chunks, uploadId, fileHash, concurrency 3) { let index 0 async function worker() { while (index chunks.length) { const current index const { blob, index: chunkIndex } chunks[current] const formData new FormData() formData.append(uploadId, uploadId) formData.append(fileHash, fileHash) formData.append(chunkIndex, chunkIndex) formData.append(chunk, blob) try { await axios.post(/api/upload/chunk, formData) // 在 Vue2 中这里要 this.$set 更新状态 // 在 Vue3 中直接赋值即可 } catch (e) { // 可以在这里做重试也可以直接抛出中断上传 throw e } } } const workers Array.from({ length: concurrency }, () worker()) await Promise.all(workers) }断点恢复的关键是“查询已上传分片”这一步。上传开始前先用文件哈希调一次接口const { uploadId, uploaded } await initUpload(fileHash) // uploaded 是后端返回的已完成分片序号数组 const needUpload chunkList.filter(t !uploaded.includes(t.index)) await uploadChunksInParallel(needUpload, uploadId, fileHash)如果后端还没有对应记录uploaded就是一个空数组前端会从 0 开始全量上传。上传中断后后端在下次initUpload时返回已经落到磁盘的分片序号前端只补缺失部分这就是断点续传的核心原理。有的团队还会在前端 localStorage 存一份已上传分片记录作为辅助但我建议以后端返回为准。因为 localStorage 只能代表“浏览器认为传过了”服务端可能因为磁盘清理、任务重启等原因丢了分片。以后端为准才能保证最终合并时不会缺块。4.3 Vue2 组件里的上传实现export default { data() { return { file: null, chunkSize: 2 * 1024 * 1024, concurrency: 3, chunkTasks: [], uploadId: , fileHash: , progress: 0 } }, methods: { onFileChange(e) { this.file e.target.files[0] }, createChunks(file) { const tasks [] let start 0 while (start file.size) { const end Math.min(start this.chunkSize, file.size) tasks.push({ index: tasks.length, blob: file.slice(start, end), status: pending }) start end } return tasks }, async upload() { this.chunkTasks this.createChunks(this.file) this.fileHash await this.calcFileHash(this.file) const { uploadId, uploaded } await this.initUpload(this.fileHash) this.uploadId uploadId const needUpload this.chunkTasks.filter(t !uploaded.includes(t.index)) await this.runTasks(needUpload) }, async runTasks(tasks) { let index 0 const worker async () { while (index tasks.length) { const current tasks[index] const formData new FormData() // formData.append ... await this.$http.post(/api/upload/chunk, formData) this.$set(this.chunkTasks, current.index, { ...current, status: done }) this.progress this.chunkTasks.filter(t t.status done).length / this.chunkTasks.length * 100 } } const workers Array.from({ length: this.concurrency }, () worker()) await Promise.all(workers) } } }这段代码里最显眼的是this.$set。它之所以存在就是因为 Vue2 的响应式系统无法感知数组下标的直接修改。如果你在 Vue2 里写出this.chunkTasks[index].status done页面不会更新这个坑很多人踩过。4.4 Vue3 组合式 Hook 里的上传实现下面是用 Vue3 组合式 API 重写同样逻辑的样子// useUploader.js import { ref, computed } from vue import axios from axios export function useUploader() { const chunkSize 2 * 1024 * 1024 const concurrency 3 const chunkTasks ref([]) const uploadId ref() const fileHash ref() const progress ref(0) const uploadedCount computed( () chunkTasks.value.filter(t t.status done).length ) function createChunks(file) { const tasks [] let start 0 while (start file.size) { const end Math.min(start chunkSize, file.size) tasks.push({ index: tasks.length, blob: file.slice(start, end), status: pending }) start end } chunkTasks.value tasks } async function upload(file) { createChunks(file) fileHash.value await calcFileHash(file) const res await initUpload(fileHash.value) uploadId.value res.uploadId const uploaded res.uploaded || [] const needUpload chunkTasks.value.filter(t !uploaded.includes(t.index)) await runTasks(needUpload) } async function runTasks(tasks) { let index 0 const worker async () { while (index tasks.length) { const current tasks[index] const formData new FormData() // formData.append ... await axios.post(/api/upload/chunk, formData) chunkTasks.value[current.index].status done progress.value uploadedCount.value / chunkTasks.value.length * 100 } } const workers Array.from({ length: concurrency }, () worker()) await Promise.all(workers) } return { chunkTasks, uploadId, progress, uploadedCount, upload } }组件里的调用const { chunkTasks, progress, uploadedCount, upload } useUploader()对比 Vue2 版本最直观的变化是状态和方法在同一个函数作用域内不再散落各处更新分片状态直接chunkTasks.value[current.index].status done不需要$set用computed推导uploadedCount语义更清晰如果另一个组件也需要显示上传进度直接 importuseUploader再取同一份返回值即可。5. 第三方库与外围生态适配5.1 哈希计算与请求库的引入方式spark-md5、axios 这些库在 Vue2 和 Vue3 中都能正常使用区别主要在“怎么引入”。Vue2 时代很多人习惯把 axios 挂到 Vue 原型上组件里通过this.$http使用Vue3 里用app.config.globalProperties也能做类似的事。但我更建议在组合式函数或工具模块里直接import axios from axios来用。原因很简单直接 import 是纯 JavaScript 层面的依赖单元测试好写函数外传也好做。挂在原型上属于框架耦合一旦你把这个模块拿到非 Vue 环境或者换框架就要改代码目录。断点续传的逻辑本身就是框架无关的保持它独立更干净。5.2 EventBus 方案的退出Vue2 里跨组件传递上传进度最常见的做法是 EventBus一个组件this.$emit(uploadProgress, 50)另一个组件this.$on(uploadProgress, handler)。Vue3 移除实例上的$emit、$on、$off之后再用 EventBus 需要额外引入 mitt 这样的库。但我想说的是断点续传这种场景其实最适合用响应式共享对象不需要事件总线。在 Vue3 里你只要创建一个模块级reactive对象任何组件 import 同一个对象就能保持同步// uploadStore.js import { reactive } from vue export const uploadStore reactive({ progress: 0, chunkList: [], uploading: false })Vue2 里对应的是 Vuex 或者 EventBus但 Vuex 这种重量级管理工具用在上传模块里有点重。Vue3 里你甚至可以继续用 Pinia也可以直接用reactive手搓一个超轻量 store选择权在你。5.3 组件库选型的影响如果你用 Element UI 或者 Ant Design Vue 2.x那是 Vue2 生态Vue3 对应的是 Element Plus、Naive UI、Ant Design Vue 3.x 这些。但断点续传通常不建议完全依赖组件库自带的el-upload因为它们的默认上传接口是整文件单请求适合小文件不适合切片场景。更常见的做法是用组件库的上传组件做 UI 外壳用自定义http-request钩子接管真正的上传行为。比如 Element Plus 中const customRequest (options) { // options.file 就是选中的文件 startUpload(options.file) }这样你完美避开组件库里封装好的上传逻辑切片、哈希、断点恢复全部自己控制组件库只负责弹窗和拖拽区域的交互效果分工明确。6. 高频问题排查与避坑记录6.1 常见问题速查表现象Vue2 / Vue3原因处理方式新增分片状态后页面不更新Vue2defineProperty 无法监听新增属性用this.$set或整体替换数组用 Set 记录已上传分片无效Vue2Set 变化不触发响应式更新改用数组或操作后整个替换 Setreactive 对象的属性解构后丢失响应式Vue3解构出来的是普通值用toRefs/toRef转换大文件计算哈希时页面卡死两者主线程大量计算阻塞渲染哈希计算放到 Web Worker上传中断后再次上传又从 0 开始两者没有查询已上传分片或后端没存确认initUpload返回已传到分片序号并发数太高导致浏览器崩溃两者同时建立了几十个连接并发数控制在 3~5 个进度条先涨后跌两者断点恢复后从历史进度重新计算进度按“总字节数”计算不是按“本次请求数”6.2 断点续传的“断点”到底记在哪这是最容易理解偏的地方。很多人以为断点续传就是把切片存到浏览器本地下次打开继续传。实际上服务端已上传的分片列表才是权威数据因为只有服务端知道哪些分片真正落到了磁盘上。前端的 localStorage 只能作为辅助用来记住 uploadId 和上一次的进度快照帮助用户快速恢复状态但不能作为“哪些分片传过了”的最终依据。秒传逻辑也一样服务端根据 fileHash 判断库里已有这个文件直接返回“不用传了”前端才跳过上传。所以在动手之前先把后端接口定清楚。一个标准的断点续传最少需要这几个接口initUpload(fileHash)初始化返回 uploadId 与已上传分片列表uploadChunk(uploadId, chunkIndex, chunk)上传单个分片merge(uploadId)分片传完后通知合并如果支持取消任务再加一个abort(uploadId)。6.3 切片大小的经验值切片大小没有绝对标准但有几个经验参考2MB 左右适合网络环境一般、后端合并性能有限的项目5MB 左右比较均衡请求数不至于太多单片传完的耗时也不会太长10MB 以上后端性能和内网带宽都不错时可以选但弱网下重传代价大。太小的切片会导致请求数爆炸。一个 2GB 文件如果每片 256KB就是 8192 个请求光请求握手的时间就够喝一壶。太大会失去断点续传的意义一片传很久中途断了损失太大。6.4 本地调试技巧调试断点续传不用真去准备一个特别不稳定的环境。Chrome 开发者工具 Network 面板支持网络节流可以模拟 Slow 3G 或者自定义限速在弱网下观察上传失败的捕获逻辑。把网络断掉再恢复看前端是否正确重试缺失分片。还要注意一点如果本地开发服务器和后端在同一台机器网络质量太好很多问题永远复现不出来。建议至少把后端部署到远程服务器上调试一次才能真正测出并发、超时、断点恢复这些边的逻辑。6.5 其他容易忽略的细节File.slice在旧版 Safari 里可能需要File.prototype.slice.call(file, start, end)不过现代浏览器基本都支持。用户在上传过程中切换网络比如从 Wi-Fi 切到 4G既有的 HTTP 请求可能直接失败。更稳的做法是在上传函数外再包一层重试逻辑对失败的分片做指数退避。上传结束后记得把响应式分片列表清空否则组件卸载后再触发更新会报内存泄漏警告。最后说点我自己的体会。之前接手一个 Vue2 老项目要大文件断点续传当时最费劲的不是切片也不是并发而是状态散得到处都是每更新一个分片状态都得小心翼翼用$set。后来用 Vue3 重写上传模块发现把之前的逻辑原样搬进useUploader这个组合式函数里手感完全不同代码从三百多行缩到两百行不到。如果你正在老项目里做这件事没必要立刻升级框架但如果能说服团队把新模块用 Vue3 来做这个功能会写得舒服很多。断点续传真正考验的其实是前后端约定和工程细节Vue 版本的差异只是在帮你把这些细节组织得更清楚。