2138 字
11 分钟

大文件分片上传实战:Vue3 + NestJS 实现秒传、断点续传、并发与失败重试(附完整代码)

2026-01-24

大文件分片上传实战:Vue3 + NestJS 实现秒传、断点续传、并发与失败重试(附完整代码)#

最近在回顾项目里“大文件上传”相关的实现,顺手把大文件 分片上传 这一块重新整理了一遍。实际踩过坑之后会发现,上传并不是“选文件 → 发请求”这么简单:文件越大,越容易遇到网络抖动导致失败、页面刷新后进度丢失、失败后只能重传、重复上传浪费带宽,以及单请求上传速度慢且不可控等问题。

因此这次复盘里,把上传流程拆成了一条更可控的链路:先用 hash 作为文件唯一指纹,支持 秒传断点续传;再把文件按固定大小进行 切片,配合 并发上传 提升吞吐,遇到失败则通过 指数退避重试 提高弱网场景下的成功率;最后由服务端按序 合并分片并清理临时文件,形成完整闭环。基于这套思路,实现了一套大文件分片上传方案,核心能力包括:

  • 文件分片:默认 5MB(可配置)
  • Hash 指纹:使用 SparkMD5,并放到 Web Worker 里计算,避免阻塞 UI
  • 并发上传:默认 3 并发
  • 失败重试:默认 3 次,指数退避
  • 断点续传:服务端返回已上传分片列表,前端跳过已完成分片
  • 秒传:服务端已存在相同 hash 的文件时直接返回成功
  • 安全合并:服务端按序合并并清理临时分片目录

技术栈:

  • 前端:Vue 3 + TypeScript + Vite
  • 后端:NestJS + TypeScript
  • Hash:SparkMD5(Web Worker)

1. 整体流程与接口设计#

整个系统围绕四个接口构建:

端点方法功能
/upload/checkPOST秒传检测:是否已存在同 hash 文件
/upload/chunksGET查询已上传分片:断点续传的基础
/upload/chunkPOST上传单个分片
/upload/mergePOST合并分片并返回最终文件 URL

上传时序:

  1. 选择文件
  2. Web Worker 计算文件 hash
  3. 秒传检测:命中则直接成功
  4. 查询已上传分片:断点续传
  5. 5MB 切片
  6. 并发上传未完成分片(默认 3)
  7. 请求合并
  8. 返回文件访问地址

2. 前端:API 封装(api.ts)#

2.1 环境配置:开发直连、生产走反代#

const BASE_URL = import.meta.env.PROD ? '' : 'http://localhost:3000';
  • 开发环境直接请求后端 3000
  • 生产环境使用相对路径,交给 Nginx 反向代理转发

2.2 秒传检测:checkFile#

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#

export async function getUploadedChunks(hash: string) {
const response = await fetch(`${BASE_URL}/upload/chunks?hash=${hash}`);
return response.json();
}

该接口返回已上传的分片索引数组,前端据此跳过已完成分片,继续上传剩余部分。

2.4 分片上传:uploadChunk(XHR 支持进度)#

为了拿到上传进度,分片上传使用 XMLHttpRequest

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#

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 分块计算:

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#

status: 'hashing' | 'checking' | 'uploading' | 'merging' | 'success' | 'error'
percentage, uploadedChunks, totalChunks, uploadedBytes, totalBytes, message

组件只需要订阅 onProgress,无需关心内部实现细节。

4.2 文件分片:sliceFile#

默认分片大小为 5MB(可配置):

const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;

分片函数:

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 单分片失败重试:指数退避#

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:

const DEFAULT_CONCURRENCY = 3;

并发调度逻辑按任务队列执行,同时限制最大并行数。

4.5 主流程:uploadFile#

上传入口 uploadFile() 按照以下顺序执行:

  1. 计算 hash(Worker)
  2. 秒传检测(存在则直接返回)
  3. 获取已上传分片列表(断点续传)
  4. 切片并过滤掉已存在分片
  5. 并发上传剩余分片(失败自动重试)
  6. 合并分片
  7. 返回最终文件 URL

此外,已上传的字节数会根据已存在分片提前累加,让断点续传的进度展示更准确。


5. 前端 UI:Hy-Upload.vue#

组件层提供了较完整的上传体验:

  • 点击选择文件 / 拖拽上传
  • 自动上传(autoUpload)
  • 上传状态图标与提示文案
  • 进度条与百分比展示
  • 上传成功展示 URL
  • 失败后支持一键重置重新上传

组件内部不关心上传细节,只需要调用 uploadFile() 并把进度回传即可。


6. 后端:NestJS 分片存储与合并#

后端使用 UploadController + UploadService 实现分片落盘、断点查询、最终合并与清理。

6.1 接收分片:/upload/chunk#

使用 multer 先存入临时目录:

@UseInterceptors(FileInterceptor('file', { dest: 'uploads/temp' }))

然后移动到分片目录:

Terminal window
uploads/chunks/{hash}/{chunkIndex}

6.2 秒传:/upload/check#

最终文件路径采用 hash + ext 的方式生成:

Terminal window
uploads/{hash}{ext}

如果文件存在,返回可访问 URL:

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实时累计,让进度更平滑、更可信;同时加入 分片过期清理机制,避免中断上传后分片长期残留占用磁盘。等后续逐步把这些点落地,整体的稳定性、可维护性以及使用体验都会进一步提升。

项目地址#

完整代码已开源:

Theproudcold
/
large-file-sharding-upload
Waiting for api.github.com...
00K
0K
0K
Waiting...

欢迎 Star / 提 Issue / PR,一起完善功能(例如流式合并、实时进度、分片过期清理等)。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

大文件分片上传实战:Vue3 + NestJS 实现秒传、断点续传、并发与失败重试(附完整代码)
https://blog.hypoy.cn/posts/front-end/vue3-nestjs-chunked-upload-resume-retry/
作者
Hypoy
发布于
2026-01-24
许可协议
CC BY-NC-SA 4.0
最后更新于 2026-01-24,距今已过 40 天

部分内容可能已过时

Profile Image of the Author
Hypoy
Hello, I'm Hypoy.
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签
站点统计
文章
10
分类
5
标签
23
总字数
10,584
运行时长
0
最后活动
0 天前

目录