☰
Vue 3 + TypeScript 大文件分片上传与断点续传跨平台实践
2026/10/7 11:53:15 网站建设 项目流程

前段时间给内部知识库做上传模块,需求是单个文件最大支持 4GB,浏览器要用,桌面端打包出来后也要用同一套逻辑。一开始我想着这事不难,直接让axios把文件丢给后端完事。结果第一个版本传了 2GB 多的视频,浏览器直接卡死,请求被网关超时掐断,然后整个文件从头再来。折腾了一个多星期,老老实实做了一个 Vue 大文件跨平台上传的 DEMO,才算把这块理顺了。

这篇文章就是基于那个 DEMO 的复盘,讲清楚从零搭一个 Vue 3 + TypeScript 的大文件跨平台上传示例,包括为什么要分片、怎么切文件、怎么算 MD5 指纹、怎么写并发控制、怎么断点续传,以及浏览器、Tauri / Electron 桌面端和移动端 H5 各自要注意的差异。整个过程我会尽量给出可以直接抄的代码和参数,适合正在做类似功能、或者想从 DEMO 里找完整思路的 Vue 开发者参考。

1. 不直接整包上传:大文件在 Web 端的三个隐藏陷阱

很多人第一次做文件上传功能,下意识就是把<input type="file">拿到的 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 vue@latest

选择 TypeScript 支持、Vue Router 可以不要,Pinia 建议加上,传多个文件时状态管理会舒服很多。安装依赖:

npm install

再装两个核心依赖:axios负责上传请求,hash-wasm负责计算文件指纹,用 WebAssembly 做哈希比纯 JS 快好几倍。

npm install axios hash-wasm

UI 组件库我选的是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 对象本质上是一个 Blob,Blob 有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 主线程里如何调用 Worker

Vite 推荐用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 runWithConcurrency<T>( items: T[], limit: number, task: (item: T) => Promise<void> ): Promise<void> { const queue = [...items]; const workers: Promise<void>[] = []; 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): Promise<void> { 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 type="file">或拖拽都能拿到 File 对象;桌面端如果只依赖 WebView 里的input[type=file],也能拿到文件,但拿不到文件的绝对路径,这会影响"下次启动直接断点续传"这类桌面端常见的需求。

5.2 Tauri 和 Electron 的桌面端特殊性

打包成桌面应用后,用户习惯从系统对话框里选择文件,甚至直接拖一个文件路径到窗口。要做这类体验,input[type=file]就不够用了。

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 时,做了三处调整:

  1. 切片从 5MB 降到 1MB,减少单请求失败的影响范围;
  2. 并发数从 3 降到 2,避免弱网环境下的队列拥塞;
  3. 关闭或减少并发预读取,避免内存峰值超限导致 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 的基础上继续加上"取消上传"和"恢复上传"两个按钮,跑通完整生命周期,你会对前端异步编程有一种截然不同的体感。祝顺利。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询