ARTICLE DETAIL

资讯详情

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

Vue 3 + TypeScript 大文件分片上传与断点续传跨平台实践

Vue 3 + TypeScript 大文件分片上传与断点续传跨平台实践 前段时间给内部知识库做上传模块需求是单个文件最大支持 4GB浏览器要用桌面端打包出来后也要用同一套逻辑。一开始我想着这事不难直接让axios把文件丢给后端完事。结果第一个版本传了 2GB 多的视频浏览器直接卡死请求被网关超时掐断然后整个文件从头再来。折腾了一个多星期老老实实做了一个 Vue 大文件跨平台上传的 DEMO才算把这块理顺了。这篇文章就是基于那个 DEMO 的复盘讲清楚从零搭一个 Vue 3 TypeScript 的大文件跨平台上传示例包括为什么要分片、怎么切文件、怎么算 MD5 指纹、怎么写并发控制、怎么断点续传以及浏览器、Tauri / Electron 桌面端和移动端 H5 各自要注意的差异。整个过程我会尽量给出可以直接抄的代码和参数适合正在做类似功能、或者想从 DEMO 里找完整思路的 Vue 开发者参考。1. 不直接整包上传大文件在 Web 端的三个隐藏陷阱很多人第一次做文件上传功能下意识就是把input typefile拿到的 File 对象丢进 FormData然后axios.post一发完事。小文件这么干没任何问题但一旦文件超过 1GB问题就一个接一个地冒出来。1.1 请求超时网关和浏览器的耐心都是有限的浏览器层面的 XHR / fetch 虽然默认没有严格意义上的超时时间但网络环境里真正做主的是中间那一票设备。Nginx 默认proxy_read_timeout是 60 秒意味着后端如果 60 秒内没产生响应数据连接就会被强制断开。大文件上传往往是高流量低响应的典型场景服务器一直在收数据但业务接口的响应可能要等整个 body 收完才返回这中间的 无响应 时间远超 60 秒于是 Nginx 先动手断开前端收到的就是一个 504 或者直接ERR_CONNECTION_CLOSED。就算不走 Nginx很多云厂商的负载均衡也有类似的空闲超时设置。实测下来超过 1GB 的文件用整包上传在国内网络环境下失败率非常高不是你代码的问题是链路里任何一环都可能先失去耐心。1.2 内存峰值一次把文件读进内存的代价整包上传时浏览器要读取 File 对象对应的 Blob 数据底层通常会在内存里准备文件副本。拿 2GB 文件来说内存占用冲到 1.5GB 以上是很常见的事。普通开发笔记本还能扛换到内存本来就紧张的测试机标签页直接崩溃并非夸张。分片上传天然规避了这个痛点。每个切片独立读取和发送内存里同一时间最多只存在几个切片的数据4GB 的文件内存占用也就几十 MB。1.3 重传成本90% 的时候断网最让人崩溃网络环境没有百分百稳定传了两个小时到 90%无线网络闪断一下整包上传就彻底失败用户得从头再来。这个心理打击比技术问题本身更严重。分片 断点续传的组合让已经传完的 90%变成可复用的资产。前端把文件切成小块后每个切片有独立的请求状态失败只重传失败的那几片已上传的切片通过一个指纹接口跳过。1.4 分片上传的基本逻辑架构分片上传的核心思路就八个字化整为零聚零为整。项目整包上传分片上传请求数量1 个N 个切片数失败影响整个文件重传仅重传失败切片内存峰值文件全量少量切片断点续传不支持天然支持服务端合并不需要需要额外合并逻辑实现复杂度低中等偏上表格里能看出来分片上传主要是把一个请求的故障域拆散了。每个切片都是一个独立的小文件上传任意一片出了问题影响范围都控制在小块以内。这个思路不仅适用于 Web在移动端弱网环境下几乎成了标配。2. DEMO 工程初始化与选型Vue 3 Vite TypeScript 的组合方向确定后接下来就是搭工程。我建议直接用 Vite 的 create 命令没必要手动配 webpack这个 DEMO 的重点在上传逻辑本身不在构建工具上。2.1 起一个 Vite 工程npm create vuelatest选择 TypeScript 支持、Vue Router 可以不要Pinia 建议加上传多个文件时状态管理会舒服很多。安装依赖npm install再装两个核心依赖axios负责上传请求hash-wasm负责计算文件指纹用 WebAssembly 做哈希比纯 JS 快好几倍。npm install axios hash-wasmUI 组件库我选的是naive-ui比较轻量而且它对 TypeScript 的支持很顺滑。用 Element Plus 也完全没问题但 DEMO 场景里 naive-ui 的按需引入和主题定制更省事。2.2 技术选型清单模块选型理由框架Vue 3组合式 API 写异步流程更直观构建工具Vite启动快Worker 支持好语言TypeScript上传状态的类型复杂TS 能减少低级错误状态管理Pinia文件列表、进度、状态统一管理HTTPaxios拦截器、进度回调成熟哈希hash-wasmWASM 计算快不阻塞交互UInaive-ui轻量按需引入桌面端Tauri / Electron后续单独说明差异2.3 为什么哈希计算一定要放到 Web Worker文件指纹相当于每个文件的身份证。服务端拿到指纹后能判断这个文件之前有没有传过一部分、还缺哪些切片。计算指纹要读取整个文件内容几千 MB 的读操作如果放在主线程里跑页面会在几秒到几十秒内完全无响应滚动都会卡住。解决方式是把分片和指纹计算丢进 Web Worker。Worker 运行在独立的线程里读取文件做哈希不会阻塞 UI 主线程。页面显示正在计算指纹的 loading用户还能继续操作其他区域体验完全不一样。2.4 目录结构src/ components/ UploadArea.vue // 拖拽上传区域 FileTable.vue // 文件列表与进度展示 stores/ upload.ts // Pinia 状态 utils/ worker.ts // Web Worker 入口 upload.ts // 并发控制与请求逻辑 types/ upload.ts // 类型定义 server/ index.js // Node 后端接收切片与合并把上传逻辑从组件里抽出来放到 utils是为了让桌面端和移动端复用同一套核心代码。跨平台不是重新写一遍而是把跟平台相关的部分隔离出去。3. 切片和指纹计算Worker 才是处理大文件的正确位置工程搭好核心代码先从切片开始。3.1 用 File.slice 把大文件切成小块File 对象本质上是一个 BlobBlob 有slice方法可以做字节切片。这个操作不会复制文件内容只是创建新的引用内存开销很小。// types/upload.ts export interface ChunkItem { fileHash: string; chunkIndex: number; blob: Blob; size: number; status: pending | uploading | success | error; progress: number; } const CHUNK_SIZE 5 * 1024 * 1024; // 5MB function createChunks(file: File, chunkSize CHUNK_SIZE): Blob[] { const chunks: Blob[] []; let offset 0; while (offset file.size) { const end Math.min(offset chunkSize, file.size); chunks.push(file.slice(offset, end)); offset end; } return chunks; }逻辑不复杂就是按偏移量切段。一个 4GB 的文件在 5MB 切片下会产生约 820 个切片属于合理范围。3.2 为什么用 hash-wasm 而不是 SparkMD5很多人用过spark-md5但纯 JS 计算大文件指纹的速度实在不理想。我实测过 1GB 文件在普通 M 芯片的机器上SparkMD5 大概要 20 多秒hash-wasm 只要 5 秒以内。WASM 用底层二进制指令计算对这种 CPU 密集型的哈希算法优势明显。// utils/worker.ts import { createMD5 } from hash-wasm; self.onmessage async (e: MessageEvent) { const { file, chunkSize } e.data; // 切片 const chunks: Blob[] []; let offset 0; while (offset file.size) { const end Math.min(offset chunkSize, file.size); chunks.push(file.slice(offset, end)); offset end; } // 计算文件指纹 const md5 await createMD5(); for (const chunk of chunks) { const buffer await chunk.arrayBuffer(); md5.update(new Uint8Array(buffer)); } const fileHash md5.digest(); self.postMessage({ chunks, fileHash }); };注意一个细节Worker 里需要先把每个切片转成 ArrayBuffer 再喂给哈希库。chunk.arrayBuffer()是异步的但这里不需要并发处理按顺序更新哈希即可状态量小速度也够。测试发现并发读取多个切片反而会增加内存压力收益不明显。3.3 主线程里如何调用 WorkerVite 推荐用new Worker(new URL(./worker.ts, import.meta.url), { type: module })的方式创建 Worker这样 Worker 里也能用 ES Module。export function calcFileHash(file: File, chunkSize: number) { return new Promise{ chunks: Blob[]; fileHash: string }((resolve, reject) { const worker new Worker(new URL(./worker.ts, import.meta.url), { type: module }); worker.onmessage (e) { resolve(e.data); worker.terminate(); }; worker.onerror (e) { reject(e); worker.terminate(); }; worker.postMessage({ file, chunkSize }); }); }Worker 用完立刻terminate避免内存泄漏。文件选择后先走这个函数页面拿到 hash 和切片数组再进入上传阶段。3.4 切片大小怎么选才合理切片大小没有唯一答案但有一些经验值可以参考切片大小优点缺点适用场景1MB 以下失败影响极小请求数量爆炸HTTP/1.1 并发受限弱网移动端2MB - 5MB平衡无明显短板浏览器端通用10MB - 50MB请求数少代理超时风险增加内网高速传输我在 Web 端默认用 5MB理由很直接5MB 的切片在大多数 Nginx 默认配置下都能在几秒内传完即使偶发超时重传成本也就 5MB。如果内网带宽很好且走 HTTP/2可以考虑 10MB减少请求总数。移动端 H5 建议降到 1MB - 2MB弱网环境的丢包率对小块更友好。4. 并发上传、进度统计和断点续传的实现切片创建好了接下来是上传调度。4.1 手动实现并发控制不引入额外库虽然p-limit这类库很好用但 DEMO 里我更倾向于手写一个简单的并发池逻辑清楚还能加深理解。// utils/upload.ts async function runWithConcurrencyT( items: T[], limit: number, task: (item: T) Promisevoid ): Promisevoid { const queue [...items]; const workers: Promisevoid[] []; for (let i 0; i Math.min(limit, items.length); i) { const worker (async () { while (queue.length 0) { const item queue.shift()!; await task(item); } })(); workers.push(worker); } await Promise.all(workers); }核心是让limit个 worker 从同一个队列里取任务。每个 worker 干完手里的活就去队列里拿下一个直到队列清空。这样不会出现前三个传完但后七个在排队的浪费也不会有超过limit的请求同时打出去。上传单个切片的函数async function uploadChunk(chunk: ChunkItem) { const formData new FormData(); formData.append(fileHash, chunk.fileHash); formData.append(chunkIndex, String(chunk.chunkIndex)); formData.append(chunk, chunk.blob); await axios.post(/api/upload, formData, { headers: { Content-Type: multipart/form-data }, onUploadProgress: (e) { chunk.progress (e.loaded / e.total) * 100; } }); chunk.status success; }DEMO 里我用runWithConcurrency(chunks, 3, uploadChunk)并发数 3。这个值不算高但是稳妥Nginx 和后端线程模型对这种并发的压力很小普通家庭宽带的上下行带宽也能跑满。4.2 进度条的计算别犯迷糊最常见的进度条错误是把当前请求的 progress当成整个文件的 progress。正确公式是const overallProgress successChunks currentChunkIndex / totalChunks / totalChunks;更准确的算法是统计已经上传成功的字节数加上正在上传的切片进度function calculateProgress(fileItem: UploadFileItem): number { const uploaded fileItem.chunks.reduce((sum, c) sum (c.status success ? c.size : 0), 0); const uploading fileItem.chunks.reduce((sum, c) sum (c.status uploading ? c.size * c.progress / 100 : 0), 0); return Math.round(((uploaded uploading) / fileItem.totalSize) * 100); }上传中切片的字节数按比例折算进总体进度这样进度条不会出现跳到 80% 又不动的怪异表现。4.3 prepare 接口与断点续传的完整流程断点续传的关键在于前端在开始上传前先问后端这个文件之前传过哪些切片了后端根据文件指纹返回一个已经存在的切片序号列表前端跳过这些切片。流程如下1. 前端计算 fileHash 2. POST /api/prepare { fileHash, totalChunks } 3. 后端返回 uploadedChunks: number[] 4. 前端将 uploadedChunks 对应的 chunk 标记为 success 5. 剩余切片进入并发队列 6. 上传完成后 POST /api/merge { fileHash, fileName, totalChunks }// store/upload.ts const uploadedChunks await prepareUpload(fileHash, chunks.length); chunks.forEach((chunk, index) { if (uploadedChunks.includes(index)) { chunk.status success; } });这样做的好处很明显页面刷新后不需要重传已传完的部分。用户重新选择同一个文件计算指纹后秒恢复进度。4.4 网络波动时的重试策略再稳定的网络也会有瞬时抖动。我给每个切片加了最多 3 次重试采用指数退避策略。async function uploadChunkWithRetry(chunk: ChunkItem, retryCount 3): Promisevoid { for (let attempt 1; attempt retryCount; attempt) { try { await uploadChunk(chunk); return; } catch (err) { if (attempt retryCount) { chunk.status error; throw err; } await sleep(500 * attempt); // 第1次等0.5s第2次等1s } } }先把重试逻辑做成可配置的以后接不同网络环境的项目时不用改主体代码。我习惯把retryCount放在上传配置对象里浏览器端 3 次、移动端 5 次都是有必要的量级差异。5. 跨平台适配浏览器、桌面端和移动端 H5 的差异处理标题里点名了跨平台这个部分反而最容易被只做 Web 的同学忽略。5.1 Chromium 内核和 WebView 下共享的代码基础首先要明确一点桌面端的 Electron 和 Tauri 虽然外壳是原生程序但页面都是跑在 WebView / Chromium 里的。前端 Vue 代码、axios 请求、Web Worker、File API 这些基本能力在桌面端和浏览器端是同一套标准所以核心上传逻辑不需要重写。真正有差别的地方在于文件入口。浏览器端用input typefile或拖拽都能拿到 File 对象桌面端如果只依赖 WebView 里的input[typefile]也能拿到文件但拿不到文件的绝对路径这会影响下次启动直接断点续传这类桌面端常见的需求。5.2 Tauri 和 Electron 的桌面端特殊性打包成桌面应用后用户习惯从系统对话框里选择文件甚至直接拖一个文件路径到窗口。要做这类体验input[typefile]就不够用了。Electron 里可以用dialog.showOpenDialog拿到filePath再用webUtils.getFileForPath(filePath)把路径转成 File 对象之后照样走统一的切片上传流程// Electron 主进程 const result await dialog.showOpenDialog({ properties: [openFile] }); const filePath result.filePaths[0]; // 渲染进程 const file webUtils.getFileForPath(filePath);Tauri 环境则建议走tauri-apps/plugin-dialog选择文件后拿到路径再在 Rust 侧按需读取文件并传给前端。Tauri 2 里用前端直接读很大的本地文件受限于 WebView 的 Blob URL 内存模型效率不如走原生插件。这里有个取舍如果是内部工具文件几 GB 上下优先用 Tauri 的fs插件读流式数据如果文件虽然大但启动频率低直接走统一 File API 也能接受。桌面端的另一个优势是网络环境预期比 Web 友好并发数可以调大到 6 - 8上传速度会明显提升。前提是后端接口能扛得住并且磁盘 IO 不成为瓶颈。5.3 移动端 H5 必须降级切片变小、并发变少移动端 H5 的约束比 PC 苛刻得多内存紧张、网络抖动频繁、WebView 对 Blob 的回收策略不一致。我把同一套上传逻辑搬到手机 H5 时做了三处调整切片从 5MB 降到 1MB减少单请求失败的影响范围并发数从 3 降到 2避免弱网环境下的队列拥塞关闭或减少并发预读取避免内存峰值超限导致 WebView 杀进程。移动端还有一个容易被忽视的问题切片的 ArrayBuffer 内存会被浏览器缓存一会儿循环上传几千个小切片时如果不手动clear引用比较旧的 Chrome WebView 会越用越卡。在 Worker 里处理切片时处理完立即把buffer置空或直接让局部变量出作用域就行。5.4 CORS 预检与跨域配置跨平台上传一定会遇到跨域问题。分片上传的请求如果带了自定义 Header比如X-File-Hash浏览器会先发一个 OPTIONS 预检请求后端需要处理 OPTIONS 并返回正确的Access-Control-Allow-Headers。// Node 后端跨域配置 app.use((req, res, next) { res.setHeader(Access-Control-Allow-Origin, *); res.setHeader(Access-Control-Allow-Methods, POST, OPTIONS); res.setHeader(Access-Control-Allow-Headers, Content-Type); if (req.method OPTIONS) { return res.status(200).end(); } next(); });能少用自定义 Header 就少用因为自定义 Header 每个上传请求都会触发两轮 HTTP 请求预检 实际上传切片多的时候开销非常明显。把文件指纹、切片序号等元数据放进 FormData 字段而不是 Header能省下大量不必要的预检请求。6. 后端切片接收与合并DEMO 联调必须知道的事前端写得再好后端不配合也是一场空。下面给一个最简 Node 后端足以跑通整个 DEMO。6.1 用 Express Multer 接收切片Multer 处理multipart/form-data非常顺手。需要注意的是分片上传场景里每个切片都会通过 Multer 落到磁盘因此要给切片单独指定一个临时目录。import express from express; import multer from multer; import fs from fs-extra; import path from path; const app express(); const CHUNK_DIR path.resolve(uploads/chunks); const MERGE_DIR path.resolve(uploads/files); const upload multer({ dest: CHUNK_DIR }); app.post(/api/upload, upload.single(chunk), (req, res) { const { fileHash, chunkIndex } req.body; const chunkName ${fileHash}-${chunkIndex}; fs.renameSync(req.file.path, path.join(CHUNK_DIR, chunkName)); res.json({ code: 0 }); });注意fs.renameSync在跨磁盘分区比如临时目录 /tmp 和项目目录不在同一挂载点时会报 EXDEV 错误。更稳妥的做法是直接fs.copyFileSync后删除原文件fs.copyFileSync(req.file.path, finalPath); fs.unlinkSync(req.file.path);DEMO 阶段这个细节不容易遇到但接到服务器上跑就有概率中招。6.2 合并文件的排序陷阱合并文件是整个流程里最容易出错的一环。后端收到 /api/merge 请求后要把同名fileHash的所有切片按序号拼回去。app.post(/api/merge, (req, res) { const { fileHash, fileName, totalChunks } req.body; const chunkDir CHUNK_DIR; const target path.join(MERGE_DIR, fileName); const chunkList []; for (let i 0; i Number(totalChunks); i) { const chunkPath path.join(chunkDir, ${fileHash}-${i}); chunkList.push(chunkPath); } fs.ensureDirSync(MERGE_DIR); for (const p of chunkList) { fs.appendFileSync(target, fs.readFileSync(p)); } // 合并后清理临时切片 for (const p of chunkList) { fs.unlinkSync(p); } res.json({ code: 0 }); });这里最大的坑是假设后端不记录 totalChunks而是自己跑去目录里fs.readdirSync找文件那么拿到的列表可能是[chunk-1, chunk-10, chunk-2, chunk-20]字符串排序下 chunk-10 会排在 chunk-2 前面合并出来的文件直接损坏。所以在 /api/upload 阶段就把totalChunks记录下来合并阶段按数字序号严格遍历是最稳的做法。前端传totalChunks后端按for循环按0 到 totalChunks-1的顺序合并不要依赖文件系统返回的文件名排序。如果你确实是目录扫描派请务必这样排const files fs.readdirSync(chunkDir).filter(name name.startsWith(fileHash)); files.sort((a, b) { const idxA parseInt(a.split(-)[1], 10); const idxB parseInt(b.split(-)[1], 10); return idxA - idxB; });6.3 切片的生命周期和完整性校验切片合并后临时切片必须清理否则磁盘空间会被拖垮。前面代码里已经写了合并后逐个unlinkSync这个习惯要养成。完整性校验也必须加上。最简单的方式前端为整个文件计算了 MD5 指纹后端合并后用同样的算法再次计算合并文件的 MD5看是否和前端传的fileHash一致。如果不一致说明切片在传输或合并过程中出现了数据损坏应返回失败让客户端重新处理。这个校验在 DEMO 里很容易被省略但真实场景不能缺。我自己遇到过不少次切片合并顺序没问题但某个切片传输中丢失了一部分字节导致整个文件解压失败。有 MD5 兜底能准确定位到具体文件有问题而不是让用户看到打不开的模糊错误。6.4 一组实测数据用这个 DEMO 在本地环境上传一个 1.3GB 的视频文件参数如下项目数值切片大小5MB切片数量267并发数3指纹计算耗时约 4.2 秒上传总耗时约 42 秒千兆内网重试次数0合并耗时约 3.5 秒内存峰值约 120MB单请求失败率接近 0但即使中间断了几片重试成本也不过 5MB。相比整包上传的失败率这个方案带来的稳定性是明显的。最后分享一点个人体会。做了这个 DEMO 之后我才发现难度不在 Vue 模板和组件拆分而在异步任务的状态管理和失败恢复能力。你能在取消上传后准确记录每个切片的提交状态能不能在页面刷新后几秒内恢复断点这才是真正拉开水平的地方。建议你在这个 DEMO 的基础上继续加上取消上传和恢复上传两个按钮跑通完整生命周期你会对前端异步编程有一种截然不同的体感。祝顺利。
返回列表