大文件分片上传实战:Vue3 + NestJS 实现秒传、断点续传、并发与失败重试(附完整代码)
#最近在回顾项目里“大文件上传”相关的实现,顺手把大文件 分片上传 这一块重新整理了一遍。实际踩过坑之后会发现,上传并不是“选文件 → 发请求”这么简单:文件越大,越容易遇到网络抖动导致失败、页面刷新后进度丢失、失败后只能重传、重复上传浪费带宽,以及单请求上传速度慢且不可控等问题。
因此这次复盘里,把上传流程拆成了一条更可控的链路:先用 hash 作为文件唯一指纹,支持 秒传 与 断点续传;再把文件按固定大小进行 切片,配合 并发上传 提升吞吐,遇到失败则通过 指数退避重试 提高弱网场景下的成功率;最后由服务端按序 合并分片并清理临时文件,形成完整闭环。基于这套思路,实现了一套大文件分片上传方案,核心能力包括:
- 文件分片:默认 5MB(可配置)
- Hash 指纹:使用 SparkMD5,并放到 Web Worker 里计算,避免阻塞 UI
- 并发上传:默认 3 并发
- 失败重试:默认 3 次,指数退避
- 断点续传:服务端返回已上传分片列表,前端跳过已完成分片
- 秒传:服务端已存在相同 hash 的文件时直接返回成功
- 安全合并:服务端按序合并并清理临时分片目录
技术栈:
- 前端:Vue 3 + TypeScript + Vite
- 后端:NestJS + TypeScript
- Hash:SparkMD5(Web Worker)
1. 整体流程与接口设计
#整个系统围绕四个接口构建:
| 端点 | 方法 | 功能 |
|---|
/upload/check | POST | 秒传检测:是否已存在同 hash 文件 |
/upload/chunks | GET | 查询已上传分片:断点续传的基础 |
/upload/chunk | POST | 上传单个分片 |
/upload/merge | POST | 合并分片并返回最终文件 URL |
上传时序:
- 选择文件
- Web Worker 计算文件 hash
- 秒传检测:命中则直接成功
- 查询已上传分片:断点续传
- 5MB 切片
- 并发上传未完成分片(默认 3)
- 请求合并
- 返回文件访问地址
2. 前端:API 封装(api.ts)
#2.1 环境配置:开发直连、生产走反代
#1
| const BASE_URL = import.meta.env.PROD ? '' : 'http://localhost:3000';
|
- 开发环境直接请求后端
3000 - 生产环境使用相对路径,交给 Nginx 反向代理转发
2.2 秒传检测:checkFile
#1
2
3
4
5
6
7
8
| export async function checkFile(hash: string, filename: string) {
const response = await fetch(`${BASE_URL}/upload/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hash, filename }),
});
return response.json();
}
|
服务端如果已有同 hash 文件,会返回 { exists: true, url },前端可以直接结束上传流程。
2.3 断点续传:getUploadedChunks
#1
2
3
4
| export async function getUploadedChunks(hash: string) {
const response = await fetch(`${BASE_URL}/upload/chunks?hash=${hash}`);
return response.json();
}
|
该接口返回已上传的分片索引数组,前端据此跳过已完成分片,继续上传剩余部分。
2.4 分片上传:uploadChunk(XHR 支持进度)
#为了拿到上传进度,分片上传使用 XMLHttpRequest:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| export async function uploadChunk(hash, chunkIndex, chunk, filename, onProgress?) {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('hash', hash);
formData.append('chunkIndex', String(chunkIndex));
formData.append('filename', filename);
formData.append('file', chunk);
const xhr = new XMLHttpRequest();
xhr.open('POST', `${BASE_URL}/upload/chunk`);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && onProgress) {
onProgress(event.loaded, event.total);
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(`Upload failed with status ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error('Network error'));
xhr.send(formData);
});
}
|
2.5 合并分片:mergeChunks
#1
2
3
4
5
6
7
8
| export async function mergeChunks(hash: string, filename: string, totalChunks: number) {
const response = await fetch(`${BASE_URL}/upload/merge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hash, filename, totalChunks }),
});
return response.json();
}
|
3. 前端:Worker 计算 Hash(hash-worker.ts)
#大文件 MD5 的计算如果放在主线程,会造成明显卡顿。为了解决这个问题,采用 Web Worker 在后台分块读取文件并计算 SparkMD5。
Worker 内部按 2MB 分块计算:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
| import SparkMD5 from 'spark-md5';
self.onmessage = async (e: MessageEvent<File>) => {
const file = e.data;
const chunkSize = 2 * 1024 * 1024;
const chunks = Math.ceil(file.size / chunkSize);
const spark = new SparkMD5.ArrayBuffer();
let currentChunk = 0;
const loadNext = () => {
const start = currentChunk * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
spark.append(event.target.result as ArrayBuffer);
}
currentChunk++;
self.postMessage({
type: 'progress',
progress: Math.round((currentChunk / chunks) * 100),
});
if (currentChunk < chunks) {
loadNext();
} else {
self.postMessage({
type: 'complete',
hash: spark.end(),
});
}
};
reader.onerror = () => {
self.postMessage({ type: 'error', error: 'Failed to read file' });
};
reader.readAsArrayBuffer(file.slice(start, end));
};
loadNext();
};
export {};
|
主线程侧通过 calculateHash() 监听 progress / complete / error 三种消息,实现 hash 进度展示与结果回传。
4. 前端:上传引擎(uploader.ts)
#上传引擎负责把“hash → 秒传 → 断点 → 并发 → 重试 → 合并”的全流程串起来,同时以统一的 UploadProgress 对外输出进度。
4.1 进度结构:UploadProgress
#1
2
| status: 'hashing' | 'checking' | 'uploading' | 'merging' | 'success' | 'error'
percentage, uploadedChunks, totalChunks, uploadedBytes, totalBytes, message
|
组件只需要订阅 onProgress,无需关心内部实现细节。
4.2 文件分片:sliceFile
#默认分片大小为 5MB(可配置):
1
| const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;
|
分片函数:
1
2
3
4
5
6
7
8
9
10
| function sliceFile(file: File, chunkSize: number): Blob[] {
const chunks: Blob[] = [];
let start = 0;
while (start < file.size) {
const end = Math.min(start + chunkSize, file.size);
chunks.push(file.slice(start, end));
start = end;
}
return chunks;
}
|
4.3 单分片失败重试:指数退避
# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| async function uploadChunkWithRetry(hash, chunkIndex, chunk, filename, maxRetries) {
let lastError: Error | null = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
await uploadChunk(hash, chunkIndex, chunk, filename);
return;
} catch (error) {
lastError = error as Error;
if (attempt < maxRetries) {
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
}
}
}
throw lastError;
}
|
4.4 并发上传:runConcurrent
#并发控制默认 3:
1
| const DEFAULT_CONCURRENCY = 3;
|
并发调度逻辑按任务队列执行,同时限制最大并行数。
4.5 主流程:uploadFile
#上传入口 uploadFile() 按照以下顺序执行:
- 计算 hash(Worker)
- 秒传检测(存在则直接返回)
- 获取已上传分片列表(断点续传)
- 切片并过滤掉已存在分片
- 并发上传剩余分片(失败自动重试)
- 合并分片
- 返回最终文件 URL
此外,已上传的字节数会根据已存在分片提前累加,让断点续传的进度展示更准确。
5. 前端 UI:Hy-Upload.vue
#组件层提供了较完整的上传体验:
- 点击选择文件 / 拖拽上传
- 自动上传(autoUpload)
- 上传状态图标与提示文案
- 进度条与百分比展示
- 上传成功展示 URL
- 失败后支持一键重置重新上传
组件内部不关心上传细节,只需要调用 uploadFile() 并把进度回传即可。
6. 后端:NestJS 分片存储与合并
#后端使用 UploadController + UploadService 实现分片落盘、断点查询、最终合并与清理。
6.1 接收分片:/upload/chunk
#使用 multer 先存入临时目录:
1
| @UseInterceptors(FileInterceptor('file', { dest: 'uploads/temp' }))
|
然后移动到分片目录:
1
| uploads/chunks/{hash}/{chunkIndex}
|
6.2 秒传:/upload/check
#最终文件路径采用 hash + ext 的方式生成:
如果文件存在,返回可访问 URL:
1
| return { exists: true, url: `/uploads/${hash}${ext}` };
|
6.3 断点续传:/upload/chunks
#读取 uploads/chunks/{hash} 目录下已有分片文件名,转成 index 数组返回,供前端跳过已完成分片。
6.4 合并:/upload/merge
#合并前会校验:
- 分片数量是否完整
- 是否缺少某个分片(0..totalChunks-1)
合并成功后清理 chunk 目录,返回最终 URL。
8. 总结
#这套大文件分片上传方案的核心思路,是把“上传”拆成一条可控、可恢复的链路:以 hash 作为文件唯一指纹,让相同文件具备 秒传 能力,并且在页面刷新后通过查询已上传分片实现 断点续传;hash 计算放到 Web Worker 中执行,避免大文件指纹计算阻塞主线程;上传阶段通过 并发 提升吞吐,通过 失败重试(指数退避) 增强弱网场景下的成功率;最终由服务端 按序合并分片并清理临时目录,实现从上传到落盘的完整闭环。
受时间精力所限,一些更贴近生产环境的能力暂时还未补齐,不过后续的可扩展方向已经梳理清楚:合并阶段可以升级为 流式合并,降低内存峰值并提升并发稳定性;进度展示可以改为基于 onprogress 的 实时累计,让进度更平滑、更可信;同时加入 分片过期清理机制,避免中断上传后分片长期残留占用磁盘。等后续逐步把这些点落地,整体的稳定性、可维护性以及使用体验都会进一步提升。
项目地址
#完整代码已开源:
欢迎 Star / 提 Issue / PR,一起完善功能(例如流式合并、实时进度、分片过期清理等)。