跳过正文
Background Image

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

·873 字·5 分钟
Hypoy
作者
Hypoy
写万行码,行万里路
目录

大文件分片上传实战: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 环境配置:开发直连、生产走反代
#

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() 按照以下顺序执行:

  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 先存入临时目录:

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

然后移动到分片目录:

1
uploads/chunks/{hash}/{chunkIndex}

6.2 秒传:/upload/check
#

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

1
uploads/{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,一起完善功能(例如流式合并、实时进度、分片过期清理等)。