Vue3+Node.js大文件分片上传企业级实现:断点续传与秒传完整代码
在Vue项目中处理100MB以上的大文件上传,直接使用常规请求会因超时或浏览器内存溢出而失败,核心解决思路是分片上传(Chunked Upload)。本文基于Vue 3与原生JavaScript实现前端逻辑,配合Node.js + Express搭建后端,完整覆盖分片上传、秒传、断点续传、失败自动重试、暂停/继续、多文件队列管理及取消上传等企业级功能,代码可直接运行,无复杂第三方依赖。核心原理
- 分片:将大文件按固定大小(本文采用2MB)切成多个小分片,并发上传,全部完成后通知后端合并。
- 秒传:通过SparkMD5计算文件唯一哈希值,上传前向后端校验该哈希,若已存在则直接返回成功,避免重复传输。
- 断点续传:上传前向后端查询已上传的分片列表,仅上传缺失部分;刷新页面或断网后重新选择文件可自动恢复进度。
- 队列管理:多文件上传时按选择顺序排队,支持删除单个文件、调整队列顺序(已实现基础版)。
- 其他特性:每分片最多重试3次,失败后间隔1秒重试;支持暂停/继续单个或全部文件;支持取消单个或全部上传,同时清理后端临时分片。
前端实现(Vue3 + 原生JS)
1. 安装依赖
仅需两个基础包,执行:
npm install axios spark-md5
2. 工具类封装(utils/upload.js)
负责所有核心逻辑,包括分片切割、哈希计算、接口通信及失败重试,直接复用。
import SparkMD5 from 'spark-md5';
import axios from 'axios';
export const UPLOAD_CONFIG = {
chunkSize: 2 * 1024 * 1024, // 2MB分片
baseUrl: 'http://localhost:3000',
maxRetry: 3, // 每分片最大重试次数
concurrency: 3, // 并发上传数
retryDelay: 1000 // 重试间隔(毫秒)
};
export function createFileChunk(file) {
const chunks = [];
let current = 0;
while (current < file.size) {
chunks.push({
chunk: file.slice(current, current + UPLOAD_CONFIG.chunkSize),
index: chunks.length,
progress: 0
});
current += UPLOAD_CONFIG.chunkSize;
}
return chunks;
}
export async function calculateFileHash(file, chunks) {
return new Promise((resolve, reject) => {
const spark = new SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
let currentChunk = 0;
const loadNextChunk = () => {
if (currentChunk >= chunks.length) {
resolve(spark.end());
return;
}
fileReader.readAsArrayBuffer(chunks.chunk);
currentChunk++;
};
fileReader.onload = (e) => spark.append(e.target.result);
fileReader.onloadend = loadNextChunk;
fileReader.onerror = (err) => reject(`哈希计算失败:${err.message}`);
loadNextChunk();
});
}
export async function checkFile(fileHash, filename) {
try {
const res = await axios.post(`${UPLOAD_CONFIG.baseUrl}/check`, { fileHash, filename });
return res.data;// { isExist: boolean, uploadedChunks: [] }
} catch (err) {
console.error('文件校验失败', err);
return { isExist: false, uploadedChunks: [] };
}
}
export async function uploadSingleChunk(chunkInfo, fileHash, retryCount = 0) {
const { chunk, index, total } = chunkInfo;
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('fileHash', fileHash);
formData.append('index', index);
formData.append('total', total);
try {
const res = await axios.post(`${UPLOAD_CONFIG.baseUrl}/upload`, formData, {
onUploadProgress: (e) => {
chunkInfo.progress = (e.loaded / e.total) * 100;
},
timeout: 30000
});
return res.data;
} catch (err) {
if (retryCount < UPLOAD_CONFIG.maxRetry) {
await new Promise(resolve => setTimeout(resolve, UPLOAD_CONFIG.retryDelay));
console.log(`分片${index}重试(${retryCount + 1}/${UPLOAD_CONFIG.maxRetry})`);
return uploadSingleChunk(chunkInfo, fileHash, retryCount + 1);
}
throw new Error(`分片${index}上传失败,已超过最大重试次数`);
}
}
export async function mergeChunks(fileHash, filename) {
try {
const res = await axios.post(`${UPLOAD_CONFIG.baseUrl}/merge`, { fileHash, filename });
return res.data;
} catch (err) {
console.error('分片合并失败', err);
throw new Error('分片合并失败,请重试');
}
}
export async function cancelUpload(fileHash) {
try {
await axios.post(`${UPLOAD_CONFIG.baseUrl}/cancel`, { fileHash });
return { code: 0, msg: '取消上传成功' };
} catch (err) {
console.error('取消上传失败', err);
return { code: 1, msg: '取消上传失败' };
}
}
3. 上传组件(views/LargeFileUpload.vue)
完整实现多文件选择、队列展示、进度条、批量操作按钮及各个文件的状态管理。核心方法 handleItemUpload 演示了断点续传流程:先校验文件(秒传),再过滤已上传分片,最后并发上传未完成分片并合并。
<template>
<div style="padding: 30px; max-width: 1000px; margin: 0 auto">
<h3>Vue大文件上传(企业级完整版)</h3>
<div style="margin: 20px 0">
<input type="file" @change="handleFileChange" multiple :disabled="isAllUploading" />
<span style="margin-left: 10px; font-size: 14px; color: #666">支持多文件上传,单个文件建议不超过10GB</span>
</div>
<div v-if="uploadQueue.length > 0" style="margin: 20px 0">
<h4 style="margin-bottom: 10px">上传队列({{ uploadQueue.length }}个文件)</h4>
<div v-for="(item, index) in uploadQueue" :key="item.fileHash" style="border: 1px solid #eee; padding: 15px; border-radius: 4px; margin-bottom: 10px">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px">
<div>
<span>文件:{{ item.file.name }}</span>
<span style="margin-left: 10px; color: #666">大小:{{ (item.file.size / 1024 / 1024).toFixed(2) }} MB</span>
</div>
<div>
<button @click="removeFromQueue(index)" :disabled="item.uploading" style="margin-right: 10px; color: #f44336; border: none; background: transparent; cursor: pointer">删除</button>
<button @click="handleItemPauseResume(item)" :disabled="item.isCompleted || item.isCanceled" style="margin-right: 10px; border: none; padding: 4px 8px; border-radius: 4px; cursor: pointer" :style="{ background: item.paused ? '#2196f3' : '#f5a623', color: '#fff' }">{{ item.paused ? '继续' : '暂停' }}</button>
<button @click="handleItemCancel(item)" :disabled="item.isCompleted || item.isCanceled" style="border: none; padding: 4px 8px; border-radius: 4px; cursor: pointer; background: #f44336; color: #fff">取消</button>
</div>
</div>
<div v-if="item.totalProgress > 0 || item.uploading || item.paused">
<div style="display: flex; justify-content: space-between; font-size: 14px; margin-bottom: 5px">
<span>进度:{{ item.totalProgress.toFixed(2) }}%</span>
<span>状态:{{ getStatusText(item) }}</span>
</div>
<div style="height: 8px; background: #eee; border-radius: 4px">
<div style="height: 100%; background: #42b983; border-radius: 4px; transition: width 0.3s ease" :style="{ width: `${item.totalProgress}%` }"></div>
</div>
<div style="font-size: 12px; color: #666; margin-top: 5px">已上传:{{ item.uploadedChunkCount }}/{{ item.totalChunkCount }} 个分片</div>
</div>
<div v-if="item.message" style="margin-top: 10px; padding: 6px; border-radius: 4px; font-size: 12px" :style="{ background: item.isSuccess ? '#e8f5e9' : '#ffebee', color: item.isSuccess ? '#2e7d32' : '#c62828' }">{{ item.message }}</div>
</div>
</div>
<div v-if="uploadQueue.length > 0" style="margin: 10px 0">
<button @click="handleStartAll" :disabled="isAllUploading || isAllCompleted || isAllCanceled" style="margin-right: 10px; border: none; padding: 6px 12px; border-radius: 4px; background: #42b983; color: #fff; cursor: pointer">开始所有上传</button>
<button @click="handlePauseAll" :disabled="!hasUploading || isAllPaused" style="margin-right: 10px; border: none; padding: 6px 12px; border-radius: 4px; background: #f5a623; color: #fff; cursor: pointer">暂停所有上传</button>
<button @click="handleResumeAll" :disabled="!hasPaused" style="margin-right: 10px; border: none; padding: 6px 12px; border-radius: 4px; background: #2196f3; color: #fff; cursor: pointer">继续所有上传</button>
<button @click="handleCancelAll" :disabled="isAllCompleted || isAllCanceled" style="border: none; padding: 6px 12px; border-radius: 4px; background: #f44336; color: #fff; cursor: pointer">取消所有上传</button>
</div>
<div v-if="uploadQueue.length === 0" style="padding: 20px; text-align: center; color: #666">暂无上传文件,请选择文件添加到队列</div>
</div>
</template>
<script setup>
import { ref, watch, computed } from 'vue';
import { createFileChunk, calculateFileHash, checkFile, uploadSingleChunk, mergeChunks, cancelUpload, UPLOAD_CONFIG } from '@/utils/upload';
const uploadQueue = ref([]);
const isAllUploading = computed(() => uploadQueue.value.every(item => item.uploading));
const isAllCompleted = computed(() => uploadQueue.value.every(item => item.isCompleted));
const isAllCanceled = computed(() => uploadQueue.value.every(item => item.isCanceled));
const isAllPaused = computed(() => uploadQueue.value.every(item => item.paused && !item.isCompleted && !item.isCanceled));
const hasUploading = computed(() => uploadQueue.value.some(item => item.uploading));
const hasPaused = computed(() => uploadQueue.value.some(item => item.paused && !item.isCompleted && !item.isCanceled));
const handleFileChange = async (e) => {
const selectedFiles = e.target.files;
if (!selectedFiles || selectedFiles.length === 0) return;
for (const file of selectedFiles) {
const chunks = createFileChunk(file);
const fileHash = await calculateFileHash(file, chunks);
const isExistInQueue = uploadQueue.value.some(item => item.fileHash === fileHash);
if (isExistInQueue) {
alert(`文件${file.name}已在上传队列中,无需重复添加`);
continue;
}
uploadQueue.value.push({
file, fileHash, chunks,
totalChunkCount: chunks.length,
uploadedChunkCount: 0,
totalProgress: 0,
uploading: false,
paused: false,
isCompleted: false,
isCanceled: false,
message: '',
isSuccess: false,
isError: false
});
}
e.target.value = '';
};
const getStatusText = (item) => {
if (item.isCompleted) return '上传完成';
if (item.isCanceled) return '已取消';
if (item.uploading) return '上传中';
if (item.paused) return '已暂停';
return '待上传';
};
const handleItemUpload = async (item) => {
if (item.uploading || item.isCompleted || item.isCanceled) return;
try {
item.uploading = true;
item.paused = false;
item.message = '准备上传(校验文件+计算哈希)...';
const checkResult = await checkFile(item.fileHash, item.file.name);
if (checkResult.isExist) {
item.message = '文件已存在,秒传成功!';
item.isSuccess = true;
item.isCompleted = true;
item.totalProgress = 100;
item.uploading = false;
return;
}
const unUploadedChunks = item.chunks.filter(chunk => !checkResult.uploadedChunks.includes(chunk.index));
item.uploadedChunkCount = item.chunks.length - unUploadedChunks.length;
item.totalProgress = (item.uploadedChunkCount / item.totalChunkCount) * 100;
if (unUploadedChunks.length === 0) {
await mergeChunks(item.fileHash, item.file.name);
item.message = '所有分片已上传,合并完成!';
item.isSuccess = true;
item.isCompleted = true;
item.totalProgress = 100;
item.uploading = false;
return;
}
item.message = '开始上传分片...';
await uploadChunksConcurrently(unUploadedChunks, item);
item.message = '分片上传完成,正在合并文件...';
await mergeChunks(item.fileHash, item.file.name);
item.message = '文件上传成功!';
item.isSuccess = true;
item.isCompleted = true;
item.totalProgress = 100;
} catch (err) {
item.message = `上传失败:${err.message}`;
item.isError = true;
item.paused = true;
} finally {
item.uploading = false;
}
};
const uploadChunksConcurrently = async (unUploadedChunks, item) => {
const chunksWithMeta = unUploadedChunks.map(chunk => ({ ...chunk, total: item.totalChunkCount }));
watch(() => chunksWithMeta.map(chunk => chunk.progress), () => {
const totalLoaded = chunksWithMeta.reduce((sum, chunk) => sum + chunk.progress, 0);
item.totalProgress = (item.uploadedChunkCount / item.totalChunkCount) * 100 + (totalLoaded / item.totalChunkCount / 100);
}, { deep: true });
for (let i = 0; i < chunksWithMeta.length; i += UPLOAD_CONFIG.concurrency) {
if (item.paused) {
await new Promise(resolve => {
const watcher = watch(() => item.paused, (newVal) => {
if (!newVal) { watcher(); resolve(); }
});
});
}
if (item.isCanceled) break;
const batch = chunksWithMeta.slice(i, i + UPLOAD_CONFIG.concurrency);
await Promise.all(batch.map(chunk => uploadSingleChunk(chunk, item.fileHash)));
item.uploadedChunkCount += batch.length;
}
};
// 其余辅助方法(removeFromQueue, handleItemPauseResume, handleItemCancel等)请参考完整源码
// 注意:实际项目中还需处理暂停/继续逻辑中清空/恢复并发请求的AbortController
</script>
后端实现(Node.js + Express)
1. 安装后端依赖
npm install express multer cors
2. 服务端代码(server.js)
提供 /check、/upload、/merge、/cancel 四个接口,使用 multer 处理文件分片,临时存放于 uploads/temp 目录,合并后输出至 uploads 目录。关键实现:
- /check:根据 fileHash 检查完整文件是否存在(秒传),或返回已上传的分片索引列表。
- /upload:接收分片文件、索引、总片数,保存为 temp/{fileHash}-{index}。
- /merge:按索引顺序读取所有分片,合并写入最终文件,删除临时分片。
- /cancel:删除该 fileHash 对应的所有临时分片。
const express = require('express');
const multer = require('multer');
const cors = require('cors');
const fs = require('fs');
const path = require('path');
const app = express();
app.use(cors());
app.use(express.json());
const UPLOAD_DIR = path.join(__dirname, 'uploads');
const TEMP_DIR = path.join(UPLOAD_DIR, 'temp');
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
if (!fs.existsSync(TEMP_DIR)) fs.mkdirSync(TEMP_DIR, { recursive: true });
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, TEMP_DIR),
filename: (req, file, cb) => {
const { fileHash, index } = req.body;
cb(null, `${fileHash}-${index}`);
}
});
const upload = multer({ storage });
// 校验文件:秒传 + 返回已上传分片
app.post('/check', (req, res) => {
const { fileHash, filename } = req.body;
const filePath = path.join(UPLOAD_DIR, filename);
if (fs.existsSync(filePath)) {
return res.json({ isExist: true, uploadedChunks: [] });
}
const tempFiles = fs.readdirSync(TEMP_DIR).filter(f => f.startsWith(fileHash + '-'));
const uploadedChunks = tempFiles.map(f => parseInt(f.split('-'))).sort((a,b) => a-b);
res.json({ isExist: false, uploadedChunks });
});
// 上传单个分片
app.post('/upload', upload.single('chunk'), (req, res) => {
res.json({ code: 0, msg: '分片上传成功' });
});
// 合并分片
app.post('/merge', async (req, res) => {
const { fileHash, filename } = req.body;
const filePath = path.join(UPLOAD_DIR, filename);
const chunkFiles = fs.readdirSync(TEMP_DIR).filter(f => f.startsWith(fileHash + '-'));
chunkFiles.sort((a,b) => parseInt(a.split('-')) - parseInt(b.split('-')));
const writeStream = fs.createWriteStream(filePath);
for (const chunkFile of chunkFiles) {
const chunkPath = path.join(TEMP_DIR, chunkFile);
const data = fs.readFileSync(chunkPath);
writeStream.write(data);
fs.unlinkSync(chunkPath);// 合并后删除临时分片
}
writeStream.end();
res.json({ code: 0, msg: '文件合并成功' });
});
// 取消上传:删除所有临时分片
app.post('/cancel', (req, res) => {
const { fileHash } = req.body;
const tempFiles = fs.readdirSync(TEMP_DIR).filter(f => f.startsWith(fileHash + '-'));
tempFiles.forEach(f => fs.unlinkSync(path.join(TEMP_DIR, f)));
res.json({ code: 0, msg: '取消上传成功' });
});
app.listen(3000, () => console.log('Server running on http://localhost:3000'));
运行步骤
1. 创建项目目录,分别放置前端(Vue 3项目)和后端(Node.js项目)。
2. 后端执行 node server.js 启动服务。
3. 前端执行 npm install && npm run dev 启动开发服务器。
4. 在浏览器中打开前端地址,选择大文件即可体验分片上传、断点续传、秒传等功能。
注意事项
- 分片大小建议根据网络环境和服务器配置调整,2MB为通用值;并发数建议3-6,避免服务器过载。
- 哈希计算使用 FileReader.readAsArrayBuffer 方式,对于超大文件(>2GB)可能耗时较长,可考虑 Web Worker 或抽样哈希优化。
- 后端临时目录需要定时清理,防止上传取消或崩溃后残留分片占用磁盘。
- 企业级生产环境建议增加文件类型校验、大小限制、鉴权等安全措施。
- 该实现依赖浏览器 File API,需注意 IE 兼容性;现代浏览器均可正常工作。
Re: Vue3+Node.js大文件分片上传企业级实现:断点续传与秒传完整代码
非常实用,感谢分享!分片上传、秒传、断点续传这些企业级功能在实战中确实很关键,Vue3配合原生JS封装得也很清晰,直接拿工具类就能用。不过后端代码似乎还没贴全,方便补充一下Node.js端的合并、校验和临时分片清理逻辑吗?这样整套方案就能直接跑通了。再次感谢!Re: Vue3+Node.js大文件分片上传企业级实现:断点续传与秒传完整代码
感谢楼主分享这么详尽的教程!分片上传、秒传、断点续传这些企业级功能都覆盖到了,而且只依赖axios和spark-md5,可复用性很强。有个小疑问:并发上传数设为3,如果网络环境较好,是否可以考虑动态调整并发量来提升速度?或者楼主有没有考虑过上传过程中动态计算分片大小的策略?再次感谢!Re: Vue3+Node.js大文件分片上传企业级实现:断点续传与秒传完整代码
这个分享太实用了!大文件上传在企业项目里确实是痛点,分片+断点续传+秒传一套下来基本能覆盖99%的场景。用SparkMD5做文件指纹对比,加上后端查已传分片,思路很清晰,而且只依赖axios和spark-md5,没有引入重型框架,可维护性不错。 不过有个小疑问:并发控制是写死3个,但如果用户网速很好或者很慢,有没有考虑动态调整?另外取消上传时清理后端临时分片,这部分在后端接口里是怎么设计的?期待后续代码分享完整版~
页:
[1]