Vue3+Node.js 实现大文件上传:断点续传、秒传、分片上传完整教程(含源码)
Vue3 + Node.js 实现大文件上传:断点续传、秒传、分片上传完整教程(含源码
效果
正常分片上传妙传效果
前端大文件分片上传断点续传秒传功能node全栈
断点续传效果
大文件上传断点续传
前言
前端开发中,令人头疼的除了Canvas
的应用其次就是大文件上传方案了,恰巧最近碰到了令人头疼的大文件上传需求,要求上传压缩包大于1024MB
的时候进行分片上传处理,可以断点续传,秒传,分片上传,前端左向右想给出解决方案:
前端在上传前首先调用文件初始化接口,向后端提交文件信息(包括文件大小、文件名、MD5 标识、分片总数等)。后端根据文件指纹(MD5)判断该文件是否已上传或已部分上传,若存在则返回已上传的分片信息,实现断点续传;若无记录,则进入常规分片上传流程。前端按序上传各分片,并通过进度条实时展示上传状态,提升用户体验。所有分片上传完成后,前端触发后端合并接口,将分片文件合并为完整文件。
之前做过一版大文件上传总结成博客了,大文件上传前端版本>>> 最后因为没有后端寸步难行,简直是闭门造车,根据对nodejs
的了解学习,借助AI
工具,进行了Node.js
服务搭建与逻辑书写,成功了完成了从事前端以来除MockJs
数据之后的真正意义上的接口。
思路
前端实现思路:
选择文件↓计算文件哈希(MD5)↓向服务端查询是否已存在该文件(秒传)→ 存在 → 提示“秒传完成”↓ 不存在读取本地缓存的分片上传状态(断点续传)↓对未上传的分片进行上传↓所有分片上传完成后通知服务端合并↓完成上传
后端实现思路:
本服务使用 Express + Multer
搭建了一个轻量级文件上传后端,支持 大文件分片上传、断点续传、秒传 和 分片合并 功能。
- 文件唯一标识:通过前端计算文件的MD5值作为文件指纹,用于识别是否已经上传。
- 分片命名规则:每个分片以 {md5}_{index}.chunk 命名,便于后端识别和管理。
- 存储结构分离:
chunks/:
临时存储所有上传的分片文件。files/:
存放合并后的完整文件。 - 上传记录持久化:使用
uploadedFiles.json
记录已成功上传的文件MD5
,实现秒传。
接口说明一:POST /api/upload/init —— 初始化上传
目的:判断文件是否需要上传、是否可秒传、是否支持断点续传。
流程:接收前端传来的文件信息filename
、size
、md5
,检查 uploadedFiles.json
,若该 md5
已存在且标记为已上传 → 返回 uploaded: true
,触发秒传。否则,在 chunks/
目录中查找匹配 md5_x.chunk
的已有分片,提取已上传的分片索引(如 [0, 1, 3]
)→ 返回给前端 → 实现断点续传。若无任何记录,则返回 uploaded: false, uploadedChunks: []
→ 正常上传所有分片。
接口说明二:POST /api/upload/chunk —— 上传单个分片
目的:接收并保存单个文件分片。
流程:使用 multer
处理文件上传,自定义 diskStorage
存储路径和文件名。文件名格式为 {md5}_{index}.chunk
,从中解析出 md5
和 chunkIndex
。保存到 chunks/
目录,便于后续合并。
** 注意:前端需将分片以 chunk
字段上传,并在 body
中携带 md5
、chunkIndex
等元数据。**
接口说明三:POST /api/upload/merge —— 合并分片
目的:将所有分片按顺序合并为完整文件。
流程:校验参数md5
、filename
、totalChunks
。检查是否所有分片都已上传(即 md5_0.chunk
到 md5_{n-1}.chunk
都存在)。使用 fs.createWriteStream
创建目标文件流,按序读取每个分片并写入。
合并完成后在 uploadedFiles.json
中记录该文件(支持秒传)。可选清理 chunks/
目录(或保留用于调试)。返回合并成功结果。
秒传
通过MD5
查找uploadedFiles.json
文件,若存在则直接返回成功。
断点续传
查找已经存在的分片文件,返回已经上传的索引,前端跳过已上传的分片。
正常上传
无记录,无分片,从头开始上传所有分片。
前端实现
第一步、安装MD5,idb相关插件
npm install spark-md5 idb
第二步、创建utils/hashUtils.ts工具文件
计算文件MD5
哈希值工具calculateFileMD5
, MD5
哈希值通常用于唯一的标识文件内容,即使文件名相同但内容不同,他们的MD5
哈希值也会不同。接收一个file
对象作为参数,并返回一个Promise
,结果为计算得到的MD5
哈希值。往往这一部分是很耗费时间的,后续再进行优化。
import SparkMD5 from 'spark-md5';
export const calculateFileMD5 = (file: File): Promise<string> => {return new Promise((resolve, reject) => {const chunkSize = 5 * 1024 * 1024; // 分块大小为5MBconst 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); // 计算MD5currentChunk++; // 移动到下一个分块if (currentChunk < chunks) loadNext(); // 读取下一个分块else resolve(spark.end()); // 返回MD5值};// 文件读取错误reader.onerror = reject;loadNext(); // 开始读取第一个分块});
};
第三步、创建utils/uploadDB.ts工具文件
基于indexedDB
的文件上传任务持久化存储
- 用户上传大文件的时候,被分割多个块(
chunks
)。- 每块上传成功后,其状态会被标记为
uploaded:true
。- 如果上传过程终端,可以通过
indexed
数据库恢复上传任务状态。- 重新开始上传时,只需要上传那些
uploaded:false
的块。- 可以避免上传已经重复的块,节省实践和带宽。
import { openDB } from 'idb';
// 文件分块信息,索引和是否已上传的状态
export interface ChunkRecord {index: number;uploaded: boolean;
}// 整个上传任务的记录,包含文件名,大小,分块大小,总分块数等信息,以及所有分块上传的装填
export interface UploadTaskRecord {id: string; // file md5filename: 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上传方法
逻辑如下:
选择文件↓计算文件哈希(MD5)↓向服务端查询是否已存在该文件(秒传)→ 存在 → 提示“秒传完成”↓ 不存在读取本地缓存的分片上传状态(断点续传)↓对未上传的分片进行上传↓所有分片上传完成后通知服务端合并↓完成上传
/*** 处理大文件上传,分片上传,断点续传,妙传功能*/
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. 计算文件 MD5const 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
文件,用于全局通用
<template><div><!-- 文件选择器:只选不传 --><a-upload:accept="accept":limit="1"@before-upload="handleBeforeUpload"@custom-request="handleCustomRequest":showUploadList="true"><template #upload-button><a-buttontype="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-progresssize="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>
第五步、使用组件
引入:
const LargeFileUploader = defineAsyncComponent(() => import("../../components/largeFileUploader/index.vue")
);
使用:
<LargeFileUploader accept=".zip,.rar,.mp4" ref="LargeFileUploaderRef" /><a-button type="primary" @click="handleConfirm" :loading="uploading"> 确定上传 </a-button>
对应方法:
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) {// 处理成功逻辑}
};
第六步、查看前端效果
前端大文件分片上传断点续传秒传功能node全栈
后端实现
第一步、初始化node项目服务
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 脚本
{"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"}
}
第二步、创建项目目录结构
node-upload-server/
├── server.js # 主服务
├── uploads/
│ ├── chunks/ # 存放分,临时存放,上传完成即删除,如果断开上传或者断网不删除
│ └── files/ # 存放合并后的完整文件
├── uploadedFiles.json # 记录已上传的文件(用于秒传)
└── package.json
第三步、后端逻辑文件server.js逻辑实现
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}.chunkconst [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}`);
});
第四步、前端链接设置
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配置代理避免跨域
server: {host: '0.0.0.0',open: true, //启动服务时自动打开浏览器访问port: 8088, //端口号, 如果端口号被占用,会自动提升1proxy: {'/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}/`), '')},}
},
第六步、前后端功能测试流程
- 选择一个大文件(如 2000MB)。
- 第一次上传 → 分片上传 → 完成后合并。
- 再次上传相同文件 → 秒传。
- 上传一半时刷新页面 → 继续上传 → 断点续传。
- 检查 uploads/files/ 是否有合并后的文件。