Vue3+Node.js 实现大文件上传:断点续传、秒传、分片上传完整教程(含源码)

Vue3 + Node.js 实现大文件上传:断点续传、秒传、分片上传完整教程(含源码

效果

正常分片上传秒传效果

www.bilibili.com/video/BV17y...

断点续传效果

www.bilibili.com/video/BV18H...

前言

前端开发中,令人头疼的除了Canvas的应用其次就是大文件上传方案了,恰巧最近碰到了令人头疼的大文件上传需求,要求上传压缩包大于1024MB的时候进行分片上传处理,可以断点续传,秒传,分片上传,前端左想右想给出解决方案:

前端在上传前首先调用文件初始化接口,向后端提交文件信息(包括文件大小、文件名、MD5 标识、分片总数等)。后端根据文件指纹(MD5)判断该文件是否已上传或已部分上传,若存在则返回已上传的分片信息,实现断点续传;若无记录,则进入常规分片上传流程。前端按序上传各分片,并通过进度条实时展示上传状态,提升用户体验。所有分片上传完成后,前端触发后端合并接口,将分片文件合并为完整文件。

之前做过一版大文件上传总结成博客了,大文件上传前端版本>>> 最后因为没有后端寸步难行,简直是闭门造车,根据对nodejs的了解学习,借助AI工具,进行了Node.js服务搭建与逻辑书写,成功了完成了从事前端以来除MockJs数据之后的真正意义上的接口。

思路

前端实现思路:

javascript 复制代码
		选择文件
		    ↓
		计算文件哈希(MD5)
		    ↓
		向服务端查询是否已存在该文件(秒传)
		    → 存在 → 提示"秒传完成"
		    ↓ 不存在
		读取本地缓存的分片上传状态(断点续传)
		    ↓
		对未上传的分片进行上传
		    ↓
		所有分片上传完成后通知服务端合并
		    ↓
		完成上传

后端实现思路:

本服务使用 Express + Multer 搭建了一个轻量级文件上传后端,支持 大文件分片上传、断点续传、秒传 和 分片合并 功能。

  1. 文件唯一标识:通过前端计算文件的MD5值作为文件指纹,用于识别是否已经上传。
  2. 分片命名规则:每个分片以 {md5}_{index}.chunk 命名,便于后端识别和管理。
  3. 存储结构分离:chunks/:临时存储所有上传的分片文件。files/:存放合并后的完整文件。
  4. 上传记录持久化:使用 uploadedFiles.json 记录已成功上传的文件MD5,实现秒传。
接口说明一:POST /api/upload/init ------ 初始化上传

目的 :判断文件是否需要上传、是否可秒传、是否支持断点续传。 流程 :接收前端传来的文件信息filenamesizemd5,检查 uploadedFiles.json,若该 md5 已存在且标记为已上传 → 返回 uploaded: true,触发秒传。否则,在 chunks/ 目录中查找匹配 md5_x.chunk 的已有分片,提取已上传的分片索引(如 [0, 1, 3])→ 返回给前端 → 实现断点续传。若无任何记录,则返回 uploaded: false, uploadedChunks: [] → 正常上传所有分片。

接口说明二:POST /api/upload/chunk ------ 上传单个分片

目的 :接收并保存单个文件分片。 流程 :使用 multer 处理文件上传,自定义 diskStorage 存储路径和文件名。文件名格式为 {md5}_{index}.chunk,从中解析出 md5chunkIndex。保存到 chunks/ 目录,便于后续合并。

** 注意:前端需将分片以 chunk 字段上传,并在 body 中携带 md5chunkIndex 等元数据。**

接口说明三:POST /api/upload/merge ------ 合并分片

目的:将所有分片按顺序合并为完整文件。 流程:校验参数md5filenametotalChunks。检查是否所有分片都已上传(即 md5_0.chunkmd5_{n-1}.chunk 都存在)。使用 fs.createWriteStream创建目标文件流,按序读取每个分片并写入。 合并完成后在 uploadedFiles.json 中记录该文件(支持秒传)。可选清理 chunks/ 目录(或保留用于调试)。返回合并成功结果。

秒传

通过MD5查找uploadedFiles.json文件,若存在则直接返回成功。

断点续传

查找已经存在的分片文件,返回已经上传的索引,前端跳过已上传的分片。

正常上传

无记录,无分片,从头开始上传所有分片。

前端实现

第一步、安装MD5,idb相关插件

typescript 复制代码
npm install spark-md5 idb

第二步、创建utils/hashUtils.ts工具文件

计算文件MD5哈希值工具calculateFileMD5MD5哈希值通常用于唯一的标识文件内容,即使文件名相同但内容不同,他们的MD5哈希值也会不同。接收一个file对象作为参数,并返回一个Promise,结果为计算得到的MD5哈希值。往往这一部分是很耗费时间的,后续再进行优化。

typescript 复制代码
import SparkMD5 from 'spark-md5';
export const calculateFileMD5 = (file: File): Promise<string> => {
  return new Promise((resolve, reject) => {
    const chunkSize = 5 * 1024 * 1024; // 分块大小为5MB
    const chunks = Math.ceil(file.size / chunkSize); // 计算分块数
    let currentChunk = 0; // 当前分块索引
    const spark = new SparkMD5.ArrayBuffer(); // 创建SparkMD5实例
    const reader = new FileReader(); // 创建FileReader实例
    console.log('开始计算文件MD5:', chunks);
    // 读取文件分块
    const loadNext = () => {
      console.log('读取分片块');
      const start = currentChunk * chunkSize; // 分块起始位置
      const end = Math.min(file.size, start + chunkSize);  // 分块结束位置
      reader.readAsArrayBuffer(file.slice(start, end)); // 读取文件分块
    };
    // 文件读取完成
    reader.onload = (e) => {
      console.log('分片块读取完成');
      spark.append(e.target?.result as ArrayBuffer); // 计算MD5
      currentChunk++; // 移动到下一个分块
      if (currentChunk < chunks) loadNext(); // 读取下一个分块
      else resolve(spark.end()); // 返回MD5值
    };
    // 文件读取错误
    reader.onerror = reject;
    loadNext(); // 开始读取第一个分块
  });
};

第三步、创建utils/uploadDB.ts工具文件

基于indexedDB的文件上传任务持久化存储

  1. 用户上传大文件的时候,被分割多个块(chunks)。
  2. 每块上传成功后,其状态会被标记为uploaded:true
  3. 如果上传过程终端,可以通过indexed数据库恢复上传任务状态。
  4. 重新开始上传时,只需要上传那些uploaded:false的块。
  5. 可以避免上传已经重复的块,节省实践和带宽。
typescript 复制代码
import { openDB } from 'idb';
// 文件分块信息,索引和是否已上传的状态
export interface ChunkRecord {
  index: number;
  uploaded: boolean;
}

// 整个上传任务的记录,包含文件名,大小,分块大小,总分块数等信息,以及所有分块上传的装填
export interface UploadTaskRecord {
  id: string; // file md5
  filename: string; // 文件名
  size: number; // 文件大小
  chunkSize: number; // 分块大小
  totalChunks: number; // 总分块数
  chunks: ChunkRecord[]; // 所有分块上传的装填
  createdAt: number; // 创建时间
  updatedAt: number; // 更新时间
}

const DB_NAME = 'FileUploadDB'; // 数据库名
const STORE_NAME = 'uploadTasks'; // 存储对象名
const VERSION = 1; // 数据库版本

// 初始化数据库
export const initDB = async () => {
  return openDB<UploadTaskRecord>(DB_NAME, VERSION, {
    upgrade(db) {
      // 创建存储对象
      if (!db.objectStoreNames.contains(STORE_NAME)) {
        db.createObjectStore(STORE_NAME, { keyPath: 'id' });
      }
    },
  });
};

// 保存上传任务
export const saveUploadTask = async (task: Omit<UploadTaskRecord, 'updatedAt'> & Partial<UploadTaskRecord>) => {
  const db = await initDB();
  const now = Date.now();
  // 创建上传任务
  await db.put(STORE_NAME, {
    ...task,
    updatedAt: now,
    createdAt: task.createdAt || now,
  });
};

// 获取上传任务
export const getUploadTask = async (id: string): Promise<UploadTaskRecord | null> => {
  const db = await initDB();
  // 获取上传任务
  return await db.get(STORE_NAME, id);
};

// 删除上传任务
export const deleteUploadTask = async (id: string) => {
  const db = await initDB();
  await db.delete(STORE_NAME, id);
};

第四步、创建utils/useLargeUpload.ts上传方法

逻辑如下:

javascript 复制代码
		选择文件
		    ↓
		计算文件哈希(MD5)
		    ↓
		向服务端查询是否已存在该文件(秒传)
		    → 存在 → 提示"秒传完成"
		    ↓ 不存在
		读取本地缓存的分片上传状态(断点续传)
		    ↓
		对未上传的分片进行上传
		    ↓
		所有分片上传完成后通知服务端合并
		    ↓
		完成上传
typescript 复制代码
/**
 * 处理大文件上传,分片上传,断点续传,妙传功能
 */
import { ref } from 'vue';
import axios from 'axios';
import { calculateFileMD5 } from './hashUtils';
import { getUploadTask, saveUploadTask, deleteUploadTask } from './uploadDB';

// 后端接口(根据你的实际地址修改)
const API_BASE = 'http://localhost:3000/api/upload';

// 上传进度回调
interface UploadProgressCallback {
  (progress: number): void; // 0 ~ 100
}

// 上传结果
interface UploadResult {
  success: boolean;
  message: string;
  fileId?: string;
}

// 控制并发量,比如 3
const CONCURRENCY = 3;

// 创建上传任务
export const useLargeUpload = () => {
  // 上传中
  const uploading = ref(false);
  const progress = ref(0); // 实时百分比(0~100)
  const uploadFile = async (
    file: File,
    onProgress?: UploadProgressCallback
  ): Promise<UploadResult> => {
    console.log('开始上传文件:', file);
    const chunkSize = 5 * 1024 * 1024; // 分块大小
    const totalChunks = Math.ceil(file.size / chunkSize); // 总分块数
    const chunks: { index: number; blob: Blob }[] = []; // 所有分块

    // 切片
    for (let i = 0; i < totalChunks; i++) {
      const start = i * chunkSize; // 分块开始位置
      console.log(`开始切片:${file.size}`);
      const end = Math.min(file.size, start + chunkSize); // 分块结束位置
      // 创建分块
      chunks.push({
        index: i,
        blob: file.slice(start, end),
      });
    }

    // 1. 计算文件 MD5
    const md5 = await calculateFileMD5(file);
    console.log('文件 MD5:', md5);

    // 2. 调用初始化接口
    const initRes = await axios.post(`${API_BASE}/init`, {
      filename: file.name,
      size: file.size,
      md5,
    });

    // 
    const { uploaded, uploadedChunks = [] } = initRes.data;

    // 秒传
    if (uploaded) {
      onProgress?.(100);
      return { success: true, message: '库中以检索到,秒传成功', fileId: initRes.data.fileId };
    }

    // 3. 获取本地断点状态(IndexedDB)
    let localTask = await getUploadTask(md5);
    let chunkStatus: { index: number; uploaded: boolean }[];

    if (localTask && localTask.totalChunks === totalChunks) {
      chunkStatus = localTask.chunks;
    } else {
      chunkStatus = Array.from({ length: totalChunks }, (_, i) => ({
        index: i,
        uploaded: false,
      }));
    }

    // 合并服务端已上传的分片(断点续传关键)
    uploadedChunks.forEach((index: number) => {
      chunkStatus[index]!.uploaded = true;
    });

    // 保存最新状态
    await saveUploadTask({
      id: md5,
      filename: file.name,
      size: file.size,
      chunkSize,
      totalChunks,
      chunks: chunkStatus,
      createdAt: 0
    });

    uploading.value = true;
    // 4. 上传未完成的分片
    const pendingChunks = chunks.filter(chunk => !chunkStatus[chunk.index]!.uploaded);

    let completed = uploadedChunks.length; // 已完成数量

    // 更新进度
    const updateProgress = () => {
      const p = Math.round((completed / totalChunks) * 100);
      progress.value = p;
      onProgress?.(p);
    };

    updateProgress(); // 初始进度
    /** 控制并发上传*/
    async function uploadWithConcurrency(
      pendingChunks: { index: number; blob: Blob }[],
      limit: number
    ) {
      let completed = uploadedChunks.length; // 已完成的分片数
      let current = 0; // 当前索引
      const results: any[] = [];

      // 启动一个分片上传
      const startUpload = async () => {
        if (current >= pendingChunks.length) return; // 没有更多分片了

        const chunk = pendingChunks[current++];
        const { index, blob } = chunk;

        const formData = new FormData();

        formData.append(
          'chunk',
          blob,
          `${md5}_${index}.chunk` //  在文件名里传 md5 和 index
        );
        formData.append('md5', md5);
        formData.append('chunkIndex', index.toString());
        formData.append('totalChunks', totalChunks.toString());
        formData.append('filename', file.name);


        try {
          await axios.post(`${API_BASE}/chunk`, formData);
          // 标记状态
          chunkStatus[index]!.uploaded = true;
          completed++;
          await saveUploadTask({
            id: md5,
            filename: file.name,
            size: file.size,
            chunkSize,
            totalChunks,
            chunks: chunkStatus,
            createdAt: Date.now(),
          });

          // 更新进度
          const p = Math.round((completed / totalChunks) * 100);
          progress.value = p;
          onProgress?.(p);

          results[index] = true; // 成功标记
        } catch (err) {
          console.error(`分片 ${index} 上传失败`, err);
          results[index] = false;
        }

        // 递归启动下一个任务
        await startUpload();
      };

      // 同时启动 limit 个任务
      const workers = Array.from({ length: limit }, startUpload);

      await Promise.all(workers);

      return results;
    }

    try {
      await uploadWithConcurrency(pendingChunks, CONCURRENCY);

      // 5. 所有分片上传完成 → 合并
      await axios.post(`${API_BASE}/merge`, {
        md5,
        filename: file.name,
        totalChunks,
      });

      await deleteUploadTask(md5);
      uploading.value = false;
      return { success: true, message: "上传成功", fileId: md5 };
    } catch (err) {
      uploading.value = false;
      return { success: false, message: "上传失败,请重试" };
    }
  };

  return {
    uploading,
    progress, // 可绑定到模板显示
    uploadFile, // (file, onProgress) => Promise
  };
};

第五步、封装vue3大文件上传组件

创建/components/largeFileUploader/index.vue文件,用于全局通用

typescript 复制代码
<template>
  <div>
    <!-- 文件选择器:只选不传 -->
    <a-upload
      :accept="accept"
      :limit="1"
      @before-upload="handleBeforeUpload"
      @custom-request="handleCustomRequest"
      :showUploadList="true"
    >
      <template #upload-button>
        <a-button
          type="primary"
          class="themeBtnColor"
          style="height: 32px; width: 400px"
          >选择压缩大文件</a-button
        >
      </template>
    </a-upload>
    <div v-if="fileInfo.size !== ''">
      文件名:{{ fileInfo.name }},大小:{{ fileInfo.size }}
      <span v-if="isUploading">上传中:{{ Math.floor(uploadProgress) }}%</span>
      <span v-if="uploadResult"> 【{{ uploadResult.message }}】 </span>
    </div>
    <!-- 显示上传状态(上传开始后) -->
    <!-- <span class="upload-status" style="margin-top: 8px"> -->
    <!-- </span> -->
    <div v-if="isUploading">
      <a-progress
        size="large"
        :percent="uploadProgress / 100"
        style="margin-top: 8px"
      />
    </div>

    <div class="upload-result" style="margin-top: 8px"></div>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
import { Message } from "@arco-design/web-vue";
import { useLargeUpload } from "../../utils/useLargeUpload";

defineProps<{
  accept?: string;
}>();
const fileInfo: any = ref({ name: "", size: "" });

// 用于存储选中的文件
const selectedFile = ref<File | null>(null);

// 上传状态
const isUploading = ref(false);
const uploadProgress = ref(0);
const uploadResult = ref<{ success: boolean; message: string } | null>(null);

const { uploadFile } = useLargeUpload();

// 阻止自动上传,仅保存文件
const handleBeforeUpload = (file: File) => {
  selectedFile.value = file;
  console.log("file", file);
  fileInfo.value = { name: file.name, size: formatBytes(file.size) };
  // Message.info("文件已选择,请点击确定开始上传");
  return false; // 关键:阻止自动上传
};

function formatBytes(bytes, decimals = 2) {
  if (bytes === 0) return "0 Bytes";

  const k = 1024;
  const dm = decimals < 0 ? 0 : decimals;
  const sizes = ["Bytes", "KB", "MB", "GB", "TB"];

  const i = Math.min(
    Math.floor(Math.log(bytes) / Math.log(k)),
    sizes.length - 1
  );
  const value = parseFloat((bytes / Math.pow(k, i)).toFixed(dm));

  return `${value} ${sizes[i]}`;
}

// customRequest 中什么都不做,或仅做校验
const handleCustomRequest = () => {
  // 这里不执行上传,仅配合 beforeUpload 使用
  return;
};

// 手动上传方法(供父组件调用)
const startUpload = async () => {
  if (!selectedFile.value) {
    Message.warning("请先选择一个文件");
    return;
  }

  isUploading.value = true;
  uploadProgress.value = 0;
  uploadResult.value = null;

  const result = await uploadFile(selectedFile.value, (progress) => {
    uploadProgress.value = progress;
  });

  if (result.success) {
    Message.success(result.message);
  } else {
    Message.error(result.message);
  }

  isUploading.value = false;
  uploadResult.value = result;

  return result; // 可供父组件判断是否上传成功
};

// 暴露方法给父组件
defineExpose({
  startUpload,
  selectedFile,
});
</script>

<style scoped>
.upload-status,
.upload-result {
  margin-top: 8px;
  font-size: 14px;
}
</style>

第五步、使用组件

引入:

typescript 复制代码
const LargeFileUploader = defineAsyncComponent(
  () => import("../../components/largeFileUploader/index.vue")
);

使用:

typescript 复制代码
 <LargeFileUploader accept=".zip,.rar,.mp4" ref="LargeFileUploaderRef" />
 <a-button type="primary" @click="handleConfirm" :loading="uploading"> 确定上传 </a-button>

对应方法:

typescript 复制代码
const LargeFileUploaderRef = ref<{
  startUpload: () => Promise<{ success: boolean; message: string }>;
  selectedFile: File | null;
} | null>(null);

const uploading = ref(false);

const handleConfirm = async () => {
  if (!LargeFileUploaderRef.value) return;

  console.log("开始上传文件:", LargeFileUploaderRef.value);
  const file = LargeFileUploaderRef.value.selectedFile;
  if (!file) {
    // 可以提示用户
    return;
  }

  uploading.value = true;
  const result = await LargeFileUploaderRef.value.startUpload();
  uploading.value = false;

  if (result?.success) {
    // 处理成功逻辑
  }
};

第六步、查看前端效果

video(video-6PFFGtnb-1757406861401)(type-bilibili)(url-[player.bilibili.com/player.html...](https://link.juejin.cn?target=https%3A%2F%2Fplayer.bilibili.com%2Fplayer.html%3Faid%3D115173373250627)(image-https%3A%2F%2Fi-blog.csdnimg.cn%2Fimg_convert%2F08b6703a6a0c3f2da6f203b2c1f261a1.jpeg)(title-%25E5%2589%258D%25E7%25AB%25AF%25E5%25A4%25A7%25E6%2596%2587%25E4%25BB%25B6%25E5%2588%2586%25E7%2589%2587%25E4%25B8%258A%25E4%25BC%25A0%25E6%2596%25AD%25E7%2582%25B9%25E7%25BB%25AD%25E4%25BC%25A0%25E7%25A7%2592%25E4%25BC%25A0%25E5%258A%259F%25E8%2583%25BDnode%25E5%2585%25A8%25E6%25A0%2588 "https://player.bilibili.com/player.html?aid=115173373250627)(image-https://i-blog.csdnimg.cn/img_convert/08b6703a6a0c3f2da6f203b2c1f261a1.jpeg)(title-%E5%89%8D%E7%AB%AF%E5%A4%A7%E6%96%87%E4%BB%B6%E5%88%86%E7%89%87%E4%B8%8A%E4%BC%A0%E6%96%AD%E7%82%B9%E7%BB%AD%E4%BC%A0%E7%A7%92%E4%BC%A0%E5%8A%9F%E8%83%BDnode%E5%85%A8%E6%A0%88"))

后端实现

第一步、初始化node项目服务

typescript 复制代码
mkdir node-upload-server
cd node-upload-server
npm init -y
npm install express multer fs-extra spark-md5 cors

node安装插件介绍:

express: Web 服务器 multer: 处理文件上传 fs-extra: 增强版 fs 操作 spark-md5: 验证分片完整性 cors: 支持跨域(前端通常是 http://localhost:5173)

第二步、创建 package.json 脚本

typescript 复制代码
{
  "name": "upload-server",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": "",
  "dependencies": {
    "cors": "^2.8.5",
    "express": "^5.1.0",
    "fs-extra": "^11.3.1",
    "multer": "^2.0.2",
    "spark-md5": "^3.0.2"
  }
}

第二步、创建项目目录结构

typescript 复制代码
node-upload-server/
├── server.js                 # 主服务
├── uploads/
│   ├── chunks/               # 存放分,临时存放,上传完成即删除,如果断开上传或者断网不删除
│   └── files/                # 存放合并后的完整文件
├── uploadedFiles.json        # 记录已上传的文件(用于秒传)
└── package.json

第三步、后端逻辑文件server.js逻辑实现

typescript 复制代码
const express = require('express');
const multer = require('multer');
const fs = require('fs-extra');
const path = require('path');
const SparkMD5 = require('spark-md5');
const cors = require('cors');

const app = express();
const PORT = 3000;

// 中间件
app.use(cors());
app.use(express.json());

// 配置存储
const CHUNKS_DIR = path.join(__dirname, 'uploads', 'chunks');
const FILES_DIR = path.join(__dirname, 'uploads', 'files');
const UPLOAD_RECORDS = path.join(__dirname, 'uploadedFiles.json');

// 确保目录存在
fs.ensureDirSync(CHUNKS_DIR);
fs.ensureDirSync(FILES_DIR);

// Multer 配置:只处理分片上传
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, CHUNKS_DIR);
  },
  filename: (req, file, cb) => {
    // 从 originalname 拿到 {md5}_{index}.chunk
    const [md5, index] = file.originalname.split('.')[0].split('_');
    console.log(`分片保存: md5=${md5}, chunkIndex=${index}`);
    cb(null, `${md5}_${index}.chunk`);
  },
});

const upload = multer({ storage });

// 读取已上传文件记录(秒传用)
const readUploadRecords = () => {
  return fs.readJsonSync(UPLOAD_RECORDS, { throws: false }) || {};
};

const writeUploadRecord = (data) => {
  fs.writeJsonSync(UPLOAD_RECORDS, data, { spaces: 2 });
};

// 接口1: /api/upload/init - 初始化上传
app.post('/api/upload/init', (req, res) => {
  const { filename, size, md5 } = req.body;

  if (!md5 || !filename) {
    return res.status(400).json({ error: 'Missing required fields' });
  }

  const records = readUploadRecords();

  // 场景1: 已存在 → 秒传
  if (records[md5] && records[md5].uploaded) {
    return res.json({
      uploaded: true,
      fileId: records[md5].fileId,
    });
  }

  // 查找已上传的分片(断点续传)
  const chunkPattern = new RegExp(`^${md5}_\\d+\\.chunk$`);
  const existingChunks = fs
    .readdirSync(CHUNKS_DIR)
    .filter(f => chunkPattern.test(f));

  const uploadedChunks = existingChunks
    .map(f => parseInt(f.split('_')[1]))
    .sort((a, b) => a - b);

  // 返回需要继续上传的分片索引
  res.json({
    uploaded: false,
    uploadedChunks,
  });
});

// 接口2: /api/upload/chunk - 上传分片
app.post('/api/upload/chunk', upload.single('chunk'), (req, res) => {
  const { md5, chunkIndex, totalChunks, filename } = req.body;

  if (!req.file) {
    return res.status(400).json({ error: 'Chunk upload failed' });
  }

  console.log(`Received chunk ${chunkIndex} for ${filename} [${md5}]`);

  res.json({ success: true });
});

// 接口3: /api/upload/merge - 合并分片
app.post('/api/upload/merge', async (req, res) => {
  const { md5, filename, totalChunks } = req.body;

  if (!md5 || !filename || !totalChunks) {
    return res.status(400).json({ error: 'Missing merge params' });
  }

  const chunkFilenames = [];
  for (let i = 0; i < totalChunks; i++) {
    chunkFilenames.push(`${md5}_${i}.chunk`);
  }

  // 检查是否所有分片都已上传
  const allExists = chunkFilenames.every(f =>
    fs.existsSync(path.join(CHUNKS_DIR, f))
  );

  if (!allExists) {
    return res.status(400).json({ error: 'Not all chunks are uploaded' });
  }

  const finalFilePath = path.join(FILES_DIR, filename);

  try {
    // 逐个合并分片
    const writeStream = fs.createWriteStream(finalFilePath);

    for (let i = 0; i < totalChunks; i++) {
      const chunkPath = path.join(CHUNKS_DIR, `${md5}_${i}.chunk`);
      const data = await fs.readFile(chunkPath);
      writeStream.write(data);

      // 可选:删除分片
      // await fs.unlink(chunkPath);
    }

    writeStream.end();

    writeStream.on('finish', () => {
      // 记录秒传信息
      const records = readUploadRecords();
      records[md5] = {
        uploaded: true,
        filename,
        fileId: md5,
        mergedAt: new Date().toISOString(),
      };
      writeUploadRecord(records);

      // 可选:清理分片
      fs.remove(CHUNKS_DIR).then(() => fs.ensureDirSync(CHUNKS_DIR));

      res.json({
        success: true,
        fileId: md5,
        message: 'Merge successful',
      });
    });

  } catch (err) {
    res.status(500).json({ error: 'Merge failed', details: err.message });
  }
});

// 可选:提供已上传文件列表(调试用)
app.get('/api/upload/files', (req, res) => {
  res.json(readUploadRecords());
});

// 启动服务
app.listen(PORT, () => {
  console.log(` Upload server running at http://localhost:${PORT}`);
  console.log(` Chunks: ${CHUNKS_DIR}`);
  console.log(` Files: ${FILES_DIR}`);
});

第四步、前端链接设置

typescript 复制代码
const API_BASE = 'http://localhost:3000/api/upload';
// 接口调用例子
 const initRes = await axios.post(`${API_BASE}/init`, {
   filename: file.name,
   size: file.size,
   md5,
 });

第五步、前端vite.config.ts配置代理避免跨域

typescript 复制代码
server: {
  host: '0.0.0.0',
  open: true, //启动服务时自动打开浏览器访问
  port: 8088, //端口号, 如果端口号被占用,会自动提升1
  proxy: {
    '/api/upload': {
      target: 'http://localhost:3000',
      changeOrigin: true,
    },
    // '/dev-api': { // 匹配 /dev-api 开头的请求,
    [env.VITE_APP_BASE_API]: { // 引用变量作为key时,要加中括号[]
      // 目标服务器
      target: env.VITE_APP_SERVICE_URL,
      // 开启代理
      changeOrigin: true,
      rewrite: path => path.replace(new RegExp(`^${env.VITE_APP_BASE_API}/`), '')
    },
  }
},

第六步、前后端功能测试流程

  1. 选择一个大文件(如 2000MB)。
  2. 第一次上传 → 分片上传 → 完成后合并。
  3. 再次上传相同文件 → 秒传。
  4. 上传一半时刷新页面 → 继续上传 → 断点续传。
  5. 检查 uploads/files/ 是否有合并后的文件。

完结~ 后续优化补充

相关推荐
崔庆才丨静觅2 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60613 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了3 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅3 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅4 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅4 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment4 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅4 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊4 小时前
jwt介绍
前端
爱敲代码的小鱼5 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax