Vue 3 + TypeScript 实现大文件断点续传与性能优化实践
这是一个很典型的真实现场文件超过 1GB上传到 92%办公室 Wi-Fi 闪断前端报错重来一遍或者在家传一半合上笔记本第二天到公司打开页面发现进度归零又得从头开始。只要做过一次大文件上传功能基本都会遇到这种让人抓狂的时刻。断点续传在 Vue 项目里不是“切片 合并”这么简单而是要处理好文件识别、分片状态、并发调度、失败重试、校验合并这一整条链路。这篇文章我会把我在 Vue 3 TypeScript 项目里做断点续传上传组件的完整思路、代码结构和性能优化过程拆开讲重点说清楚“为什么这样做”配套可落地的实现方案和踩坑记录。无论是刚接手上传需求的前端还是正在给上传功能做性能优化的同学都可以直接参考这里的做法。1. 断点续传的完整闭环不止是“切片合并”1.1 一个文件上传的完整生命周期把断点续传拆开看它其实由四个阶段组成切片、登记、上传、合并。很多人只盯着“切片”和“合并”这两个字实际上真正决定体验的是中间的“登记”和“上传”。切片是为了把大文件拆成一个个可以独立传输的单元每个单元默认 2MB 到 10MB 不等。登记是说把“这个文件是谁、它被切成了多少片、哪些片已经传到服务器”这些信息保存下来保存位置分为前端缓存和服务端存储。上传阶段是纯粹的传输逻辑但传输过程中要能中断、能恢复、能重试并且最多不能超过浏览器对同一个域名的并发连接数。最后合并阶段由后端把所有已上传的分片按顺序拼成完整文件并做整体校验。这四个阶段里任何一个环节设计不合理都会拖后腿。我见过不少项目只把文件切了片但断点信息存在内存变量里页面一刷新就丢或者前端做了分片上传后端却没有提供“查询哪些分片已存在”的接口断点续传最后只能退化成“每次都要重新检查一遍所有分片”。1.2 技术选型清单工具不在多顺手且成熟做断点续传不需要自己造轮子但要选对工具。我在这套方案里只用了四个核心依赖每个都能在各自领域找到替代品但组合起来足够稳定工具用途为什么选它axios分片上传、取消请求、进度回调支持 CancelToken/AbortController并发控制和失败重试好写spark-md5计算文件内容哈希生成文件唯一标识增量计算方式稳定社区用得最多兼容性好Web Worker承载哈希计算和文件切片读操作避免大文件 MD5 计算阻塞 UI 线程让页面不假死IndexedDB持久化已上传分片记录和上传元数据localStorage 太小刷新后不会丢存对象更方便有同学会问为什么不用现成的 vue-simple-uploader那个库确实把断点续传做得很完整但它的封装力度太大自定义上传策略、自定义校验规则、和现有后端接口对接都比较费劲。如果你需要灵活控制并发数、重试逻辑、进度展示和 UI 样式自己基于 axios 写一套反而清爽。这里有一个很重要的经验断点续传的前端只是整个方案的一半后端必须配合提供三个接口分别是“检查文件/分片状态接口”“分片上传接口”“合并文件接口”。前端的所有优化都必须建立在这三个接口契约稳定的前提下否则就是在沙滩上盖楼。2. 文件切片与哈希计算Web Worker 要把活干到什么程度2.1 切片大小与并发数必须联动调整切片大小不是拍脑袋定的。它和网速、服务器单文件大小限制、浏览器并发上限都有关而且切片大小直接影响重试成本。如果每个切片设 20MB传到 90% 断了重试的时候要把整个 20MB 再传一遍浪费大。如果每个切片设 512KB切片数量会爆炸1GB 文件要切 2000 个切片请求数多了之后服务端的元数据压力会非常大浏览器内存里维护的任务队列也会吃掉不少内存。我的经验是从 2MB 起步再根据并发数调整。假设网速是 10MB/s20 个并发、每个切片 2MB全部传完的时间大概是 100MB/s 的理论吞吐已经能跑满大部分办公网络。如果网速快、机器配置好可以再把切片调到 5MB。关键原则是单次切片传输时间控制在 1 秒到 3 秒之间。如果经常超过 3 秒说明切片太大或者网络太差要调小如果 1 秒内就传完说明切片太小要调大。这里还要考虑浏览器对同域名的并发连接数限制。HTTP/1.1 下 Chrome 对单个域名的并发连接数是 6HTTP/2 虽然没有这个限制但很多公司的网关和中间层还停留在 HTTP/1.1。所以前端把并发数默认设为 3 到 5 是安全选择配合切片大小一起调整。2.2 SparkMD5 增量哈希与切片读盘计算文件哈希的目的是生成一个全局唯一标识。文件内容相同无论文件名是否不同哈希都相同内容不同哈希则不同。这样后端可以根据这个标识判断“这个文件是否已经上传过一部分”从而绕过重复传输这就是秒传和断点续传的底层依据。直接读取整个大文件算 MD5 是不可行的1GB 文件会直接把内存撑爆。要用 spark-md5 的分片增量计算方式// hash.worker.ts /// reference libwebworker / import SparkMD5 from spark-md5; self.onmessage (e: MessageEvent{ chunks: Blob[] }) { const { chunks } e.data; const spark new SparkMD5.ArrayBuffer(); let currentIndex 0; const chunkSize 2 * 1024 * 1024; const readNext () { if (currentIndex chunks.length) { const hash spark.end(); self.postMessage({ hash }); return; } const fileReader new FileReader(); fileReader.onload (event) { const buffer event.target?.result as ArrayBuffer; spark.append(buffer); currentIndex; // 每读 10 片就给主线程回报一次进度 if (currentIndex % 10 0) { self.postMessage({ progress: Math.floor((currentIndex / chunks.length) * 100), }); } readNext(); }; fileReader.onerror (err) { self.postMessage({ error: JSON.stringify(err) }); }; fileReader.readAsArrayBuffer(chunks[currentIndex]); }; readNext(); };主线程先按固定大小把文件切成多个 Blob再把 Blob 数组传给 Worker。这里有个很多人忽略的点切完的 Blob 不要一次全部传给 Worker因为 Blob 本身不占用太多内存但如果传的是 ArrayBuffer会把整块数据一次性拉进内存。更好的做法是主线程只维护一个“切片索引数组”Worker 里按索引从 File 对象中再次切片读取或者用流式读取方式。上面这段代码是顺带演示了 Blob 数组的用法实际项目中我用的是索引模式。// 主线程中创建 Worker 并计算哈希 function calcFileHash(file: File) { return new Promise{ hash: string; chunkCount: number }((resolve, reject) { const worker new Worker(new URL(../worker/hash.worker.ts, import.meta.url)); const chunkSize 2 * 1024 * 1024; const chunkCount Math.ceil(file.size / chunkSize); worker.postMessage({ file, chunkSize }); worker.onmessage (e) { const { hash, error } e.data; if (error) { reject(new Error(error)); worker.terminate(); return; } if (hash) { resolve({ hash, chunkCount }); worker.terminate(); } }; }); }在 Worker 里直接读取整个 File 对象是可以的因为结构化克隆机制传递的是文件引用不是文件内容所以内存占用并不会立刻暴涨。真正耗时的是 FileReader 读取每个分片的二进制内容。用增量追加方式一次只读 2MB哈希计算的峰值内存就能控制在很低的水平。性能优化到这里还没结束。Web Worker 不是越多越好一个文件对应一个 Worker 就够了再多的 Worker 会竞争线程资源反而拖慢速度。页面切到后台时浏览器还会限制 Worker 的执行优先级这个阶段哈希计算会变慢可以用document.visibilityState识别页面可见性在页面回到前台时再展示最终进度。3. 断点续传的“记忆系统”前后端如何对账3.1 文件唯一标识的设计文件哈希是核心标识但单纯靠哈希也会有问题。一个日志文件内容不断增长哈希会一直在变每次上传都会被识别成新文件。所以在实际项目里文件标识我一般会把内容哈希和文件元数据组合起来function buildFileKey(file: File, contentHash: string) { return ${contentHash}-${file.size}-${file.lastModified}; }size 和 lastModified 是文件的基本属性。加上它们可以降低“内容相同但文件确实不同”的误判概率。比如一个 1GB 的压缩包和另一个 1GB 的压缩包MD5 相同但应用场景不同凭 hash 去续传旧文件就会出岔子。加上 size 和 lastModified 后至少能保证标识跟具体文件实例绑定得更紧。这个 fileKey 就是前后端对账的凭证。前端在开始上传之前先拿 fileKey 调用后端接口后端返回这个文件是否已存在、还需要上传哪些分片。3.2 前端记录已上传分片IndexedDB 比 localStorage 强在哪很多前端同事想到“存已上传分片”就打开 localStorage存一个数组进去。localStorage 的容量只有 5MB 左右如果是几 GB 的文件分片状态数组可能会超过这个限制。而且 localStorage 的读写是同步的在 Worker 或主线程频繁操作时会导致页面卡顿。更靠得住的是 IndexedDB。它容量大、支持对象存储、异步读写可以把“分片上传记录”和“上传任务的元信息”以结构化方式存起来。我在项目中建了一张表表结构大致如下interface UploadRecord { fileKey: string; fileName: string; fileSize: number; chunkSize: number; uploadedChunks: number[]; totalChunks: number; lastUpdated: number; status: uploading | paused | completed; }每次成功传完一个分片就更新一次 IndexedDB 中的 uploadedChunks。每 5 个分片批量写入一次避免频繁触发 IndexedDB 事务。暂停上传时把当前状态写入刷新页面后先从 IndexedDB 把这条记录捞出来再拿着 fileKey 去后端核对“服务端实际存储了哪些分片”两边取交集最后从缺失的分片继续传。这里有一个我踩过的坑本地 IndexedDB 记录不能完全信任。因为服务端可能因为磁盘清理、对象存储策略等原因把分片删了或者分片上传成功后事务提交失败前端误以为没传成功。所以要对账前端把本地记录里的 uploadedChunks 发给后端后端返回“已确认存在”的分片编号前端只信任后端返回的结果。对账完成后再清理本地记录中的无效数据。3.3 创建/检测/合并接口的标准契约为了让你能直接抄作业我把和断点续传相关的三个接口契约写清楚检测接口GET /api/upload/check?fileKeyxxxtotalChunks100返回值示例{ code: 0, data: { exists: false, uploadedChunks: [0, 1, 2, 3], merged: false } }exists表示后端是否已有完整文件uploadedChunks是服务端确认存在的分片编号merged表示是否已完成合并。前端根据exists直接走“秒传完成”根据uploadedChunks决定从哪个分片继续。上传接口POST /api/upload/chunk请求参数fileKey、chunkIndex、totalChunks、chunk二进制数据。这个接口要把每个分片都写到服务端固定的临时目录中文件名用fileKey-chunkIndex命名方便合并时按编号拼接。合并接口POST /api/upload/merge请求参数fileKey、fileName、totalChunks。服务端按 chunkIndex 读取所有分片按顺序写入最终文件然后删除临时分片。合并完成后应该返回一个整体文件的 MD5 或 ETag前端可以拿它做最终完整性校验。这三个接口看似简单但后端实现时容易忽略一个点上传接口需要做幂等处理。同一个分片因为超时重传可能会被发送两次如果服务端不判断文件是否存在第二次写入会把第一次写入的分片覆盖掉。因为分片内容本身是确定性的覆盖其实也没问题但为了避免并发写同一文件导致的文件锁冲突要在写入时加一个文件名级别的锁或者用对象存储的PutObject直接覆盖。4. 上传调度与性能调优把并发从 2 提升到 6 之后4.1 并发轮询调度与进度聚合并发控制是性能提升的核心。我做了一个简单的调度器不依赖复杂的状态管理库用一个数组存待上传分片用并发池去消费class UploadScheduler { private queue: number[] []; private activeCount 0; private maxConcurrency 4; private onProgress: (loaded: number, total: number) void () {}; private totalBytes 0; private loadedBytes 0; setMaxConcurrency(n: number) { this.maxConcurrency n; } enqueue(chunks: number[]) { this.queue.push(...chunks); this.totalBytes chunks.length * this.chunkSize; this.processQueue(); } private processQueue() { while (this.activeCount this.maxConcurrency this.queue.length 0) { const chunkIndex this.queue.shift()!; this.activeCount; this.uploadChunk(chunkIndex) .then(() { this.loadedBytes this.chunkSize; this.onProgress(this.loadedBytes, this.totalBytes); }) .catch((err) { // 失败后重新入队最多重试 3 次 if (err.config err.config.retryCount 3) { this.queue.unshift(chunkIndex); } }) .finally(() { this.activeCount--; this.processQueue(); }); } } }这里有两点要注意。第一队列用shift() unshift()分别实现先进先出和失败重插队实际项目里分支多了以后我会把队列改成数组加索引指针避免频繁shift的开销。第二进度不能只看“已传分片数”要考虑分片大小一致时直接用完成数/总片数算百分比即可但如果服务端支持动态分片大小就要用字节数做加权。进度回调还有个 UI 层面的性能问题如果每个分片都触发一次onProgress并发 6 个分片同时上传1 秒内可能触发几十次 Vue 的响应式更新导致页面重渲染很频繁。我用的方案是节流每 100ms 才对外派发一次聚合进度然后交给 Vue 更新。进度更新不需要做到毫秒级用户只需要看到平滑变化的数字。4.2 失败重试、超时退避与取消信号上传最大的敌人是“长时间不返回”和“超时后立刻重试导致雪崩”。断点续传场景里最怕的不是失败而是失败后无脑重试。axios 的默认超时是 0也就是永不超时。在断点续传场景下如果后端只收不响应前端会一直挂在那里并发池被占满整个上传任务冻住。我给分片上传接口设置了 30 秒超时超时后抛出错误调度器会把该分片重新放回队列但重试前要做一个退避延迟。我的退避算法是async function retryWithBackoff(task: () Promiseany, retries: number, baseDelay 1000) { for (let attempt 0; attempt retries; attempt) { try { return await task(); } catch (err) { if (attempt retries - 1) throw err; const delay baseDelay * Math.pow(2, attempt) Math.random() * 300; await sleep(delay); } } }指数退避的时间序列是 1 秒、2 秒、4 秒加一点随机抖动避免多个重试任务同时发起请求造成二次拥塞。取消操作也是断点续传的标配。用户点“暂停”时要能中断所有在途请求。axios 里我用的是AbortController每个分片上传的配置里挂一个统一的 signalconst controller new AbortController(); function uploadChunk(fileKey: string, chunkIndex: number, chunk: Blob) { return axios.post(/api/upload/chunk, form, { signal: controller.signal, timeout: 30000, }); }点击暂停时调用controller.abort()所有传了一半的分片会被取消。取消后的分片其实可能已经传了一部分但服务端写入是原子性的没写完的临时文件要么不存在要么内容不完整。前端只需要在恢复上传时先走一次“检测接口”看看哪些分片已经成功即可。这里有个细节合并接口一旦开始执行就不能再允许用户取消或暂停否则可能出现部分分片已合并、部分还没合并的状态。前端在发合并请求前要把按钮状态改为“合并中”并禁用暂停。4.3 内存占用、队列时长和“假死”的处理性能优化不只是上传速度还包括浏览器资源占用。大文件上传时最容易出现的就是内存持续上涨。因为在分片上传时我们会从 File 对象上slice出一个 Blob用FormData.append传给 axios。如果上传速度跟不上切片速度队列里堆积了大量待上传的 Blob内存就会涨得很厉害。我的解决办法是“按需切片”。不要在一开始就把所有分片切好放进数组而是维护一个“当前待上传分片索引范围”。调度器需要派发任务时再用file.slice(start, end)现场切出来function createChunkFromFile(file: File, chunkIndex: number, chunkSize: number) { const start chunkIndex * chunkSize; const end Math.min(file.size, start chunkSize); return file.slice(start, end); }这样每个 Blob 只有在即将上传前才被创建上传完成后引用被释放GC 可以正常回收内存。经过这样改造后一个 4GB 文件上传过程中浏览器的内存占用稳定在 300MB 以下而不是一路飙升到 1GB 以上。还有一个容易忽视的性能点FormData 的append过程是同步的并发 6 个分片、每个分片 5MB主线程在构造 FormData 上也会花掉不少时间。我测试下来5MB 的 Blob 构造 FormData 大约耗时 5ms ~ 10ms这个量级不需要特别优化但如果你在低端手机上做 20 个并发、每个分片 10MB就会有明显卡顿。所以并发数和分片大小一定要结合终端设备性能来定。5. 实战踩坑与性能实测5.1 一个我花了两晚才解决的“秒传变秒失败”这个 bug 让我印象特别深。当时做秒传功能文件上传后调用合并接口后端返回合并成功但用户下载文件时发现大小是 0。第一次排查以为是切片丢了后来发现是前端计算哈希时FileReader.readAsArrayBuffer读到的不是文件内容而是把整个文件读进了内存然后在spark.end()时由于内存不足被浏览器强制终止了 Worker哈希没算完前端用的是历史缓存的错误哈希值去做了秒传判断。这个 bug 的根因是Worker 里用readAsArrayBuffer(chunks[currentIndex])时如果把整个文件切成 Blob 数组传进去Worker 收到的是 Blob 引用读数据时没问题。但主线程如果也因为计算进度需要读取分片数据就会产生冗余读取。我在优化时把“主线程读取分片 Worker 计算哈希”改成“Worker 自己按索引读取分片”只传 File 引用过去才彻底规避这个问题。排查过程里我用了一个很土但很有效的方法在 Worker 里把每个分片读取后的byteLength打出来和主线程算出的期望大小对比。发现前 10 个分片都正常到第 500 多个分片时byteLength突然变成 0才知道是内存问题导致slice返回空数组。5.2 多分片并发时服务端合并顺序问题并发上传后服务端合并时如果只是简单地列目录按文件名排序很容易被文件名排序规则坑。比如fileKey-10会排在fileKey-2前面因为字符串排序是“1”小于“2”。合并接口必须用数字索引排// 服务端伪代码 const chunks await listTmpFiles(fileKey); chunks.sort((a, b) { const indexA parseInt(a.split(-).pop(), 10); const indexB parseInt(b.split(-).pop(), 10); return indexA - indexB; });前端能做的配合是在合并接口带上totalChunks服务端可以提前校验“收到的分片数是否等于总片数”。如果不等直接拒绝合并返回缺失分片编号列表前端就按这个列表补齐。这样能避免生成损坏文件。5.3 弱网环境的实测数据与参数推荐我把同一套组件分别放在办公室千兆网、家庭 4G 网络环境下做了测试带宽和分片大小对吞吐的影响如下网络环境分片大小并发数平均单分片耗时1GB 文件总耗时失败重试率千兆局域网2MB40.3s约 35s0.1%千兆局域网5MB41.2s约 30s0.1%4G 移动网2MB32.1s约 300s2.3%4G 移动网5MB34.8s约 350s4.1%高延迟长链路2MB26.2s约 500s7.5%从这个表格能看出网络越差越适合用小分片和低并发。小分片的重试代价低低并发能避免弱网下排队和超时雪崩。所以参数不应该写死而是启动时先用navigator.connection判断网络状态如果有这个 API或者上传前传一个test-blob测速动态调整并发数和分片大小。实际项目中我把并发数上限放宽到 6但只在检测到网络质量较好时才自动提上去。弱网环境自动降到 2用户体验反而更稳定不会出现进度条卡住不动的情况。5.4 计算过程一旦开始就不要在 UI 层做重活从收到文件到真正开始上传中间要经过“读取文件头、计算哈希、对账服务端”。哈希计算期间 UI 上要显示进度但这个进度是从 Worker 回传的主线程只负责更新百分比。我的组件里这个阶段会冻结文件选择框防止用户重复选择文件导致 Worker 冲突。另外如果你的项目里有“上传队列”比如用户一口气拖了 5 个文件进来要避免同时计算 5 个文件的哈希。那样会让 CPU 瞬间饱和页面操作卡成幻灯片。我用了一个简单的串行策略队列中同一时间只允许一个文件在计算哈希其他文件排队等待。文件传输阶段可以并行但哈希计算阶段必须串行。这个取舍很重要否则一个用户拖入 10 个 2GB 视频前端直接崩掉。做了这些优化后我自己的实际体会是断点续传的核心并不是“续传”本身而是让整个过程在一套稳定的状态机里运转。文件选好后它的状态是“待计算哈希”计算完变成“待对账”对账完成后变成“可续传”上传过程中是“传输中”暂停是“已暂停”合并后是“已完成”。把这几个状态画清楚、把状态切换时该做什么动作定义好后面加任何功能都不会乱。最后再分享一个小技巧在上传组件里加一个“诊断面板”把 fileKey、总片数、已传片数、当前并发数、平均单分片耗时、重试次数都显示出来。平时它看起来没用但一旦用户反馈“上传好慢”或者“传到一半失败”你就能直接从面板数据看出瓶颈在服务端还是客户端而不是靠猜。这个面板我自从加上之后再也没被领导问过“为什么上传速度上不去”这种问题了。