Vue + Node.js 大文件断点续传实战:分片上传与并发控制详解
背景大文件上传一直是前端开发的痛点,网络不稳定时失败重传整个文件既耗时又浪费带宽。断点续传机制通过将文件切分为小块、记录已上传片段,在中断后从断点继续上传,显著提升用户体验。本文结合 Vue 3 + Element Plus 前端和 Node.js (Express) 后端,实现一套完整的分片上传、哈希校验、并发控制和错误重试方案。
技术选型
前端:Vue 3 + Element Plus + p-limit + spark-md5
后端:Node.js + Express + multer + fs-extra
核心原理
1. 分片上传:使用 File.slice() 将大文件切割为固定大小(如1MB)的小块。
2. 文件唯一标识:通过 spark-md5 计算整个文件的 MD5 哈希值作为标识,用于后端判别分片归属。
3. 断点记录:服务端按哈希值创建目录,以分片索引命名文件;前端通过查询接口获取已上传分片列表。
4. 合并请求:所有分片上传完成后,前端通知后端按索引顺序合并文件。
5. 并发控制:使用 p-limit 库限制同时上传的分片数,防止并发过多导致网络拥塞。
前端实现
1. 文件分片
const chunkSize = 1 * 1024 * 1024; // 1MB
const chunks = [];
let start = 0;
while (start < file.size) {
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
chunks.push(chunk);
start = end;
}
2. 计算文件哈希
通过 FileReader 读取文件为 ArrayBuffer,使用 SparkMD5 计算 MD5 值:
const calculateFileHash = async (file) => {
return new Promise((resolve) => {
const spark = new SparkMD5.ArrayBuffer();
const reader = new FileReader();
reader.readAsArrayBuffer(file);
reader.onload = (e) => {
spark.append(e.target.result);
resolve(spark.end());
};
});
};
3. 查询已上传分片
上传前先向后端发起 GET 请求,传入文件哈希,获取已上传的分片索引列表:
const fileHash = await calculateFileHash(file);
const { data } = await axios.get('/api/check', {
params: { fileHash }
});
4. 并发上传分片
使用 p-limit 限制并发数(如3),对每个未上传的分片生成任务。每个分片上传失败时自动重试最多3次:
import pLimit from 'p-limit';
const limit = pLimit(3);
const tasks = chunks.map((chunk, index) => {
if (uploadedChunkIndexes.includes(index)) return Promise.resolve();
return limit(() => {
const MAX_RETRY = 3;
const attempt = (retry) => {
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('hash', fileHash);
formData.append('index', index);
return axios.post('/api/upload', formData, {
onUploadProgress: (e) => {
const percent = Math.round((e.loaded / e.total) * 100);
updateProgress(index, percent);
}
}).then(() => {
uploadedChunkIndexes.push(index);
updateProgress();
}).catch((error) => {
if (retry < MAX_RETRY - 1) return attempt(retry + 1);
throw error;
});
};
return attempt(0);
});
});
await Promise.all(tasks);
5. 通知合并文件
所有分片上传成功后,向服务端发送合并请求:
await axios.post('/api/merge', {
fileName: file.name,
fileHash: fileHash,
chunkCount: chunks.length
});
6. 前端页面交互
使用 Element Plus 的 el-upload 组件实现文件拖拽选择,配合 el-progress 显示总体进度。示例简化如下:
<template>
<div class="upload-container">
<el-upload
drag
:auto-upload="false"
:on-change="handleFileChange"
:show-file-list="false">
<el-icon><upload-filled /></el-icon>
<div>将文件拖到此处或<em>点击选择</em></div>
</el-upload>
<el-progress :percentage="totalProgress" :status="uploadStatus" :stroke-width="16" />
<el-button type="primary" @click="startUpload" :disabled="isUploading">
{{ isUploading ? '上传中...' : '开始上传' }}
</el-button>
<el-button @click="pauseUpload" :disabled="!isUploading">暂停</el-button>
</div>
</template>
后端实现(Node.js Express)
项目结构如下:
├── server.js
├── uploads/
│ ├── temp/ # 分片临时存储
│ └── merged/ # 合并后文件目录
└── package.json
依赖安装:express multer cors fs-extra。
服务端核心代码(server.js):
const express = require('express');
const multer = require('multer');
const fs = require('fs');
const path = require('path');
const cors = require('cors');
const fse = require('fs-extra');
const app = express();
app.use(cors());
app.use(express.json());
const UPLOAD_DIR = path.resolve(__dirname, 'uploads');
const TEMP_DIR = path.join(UPLOAD_DIR, 'temp');
const MERGED_DIR = path.join(UPLOAD_DIR, 'merged');
fse.ensureDirSync(TEMP_DIR);
fse.ensureDirSync(MERGED_DIR);
const chunkUpload = multer({ dest: TEMP_DIR });
// 1. 检查已上传分片
app.get('/api/check', (req, res) => {
const { fileHash } = req.query;
const chunkDir = path.resolve(TEMP_DIR, fileHash);
let uploadedChunks = [];
if (fse.existsSync(chunkDir)) {
uploadedChunks = fse.readdirSync(chunkDir).map(name => parseInt(name));
}
res.json({ code: 0, data: { uploadedIndexes: uploadedChunks } });
});
// 2. 上传分片
app.post('/api/upload', chunkUpload.single('chunk'), async (req, res) => {
const { hash, index } = req.body;
const chunkPath = req.file.path;
const chunkDir = path.resolve(TEMP_DIR, hash);
try {
await fse.ensureDir(chunkDir);
const destPath = path.join(chunkDir, index);
await fse.move(chunkPath, destPath);
res.json({ code: 0, message: '分片上传成功' });
} catch (err) {
res.status(500).json({ code: 1, message: '分片上传失败' });
}
});
// 3. 合并分片
app.post('/api/merge', async (req, res) => {
const { fileName, fileHash, chunkCount } = req.body;
const chunkDir = path.join(TEMP_DIR, fileHash);
const filePath = path.join(MERGED_DIR, fileName);
try {
const chunkPaths = await fse.readdir(chunkDir);
if (chunkPaths.length !== chunkCount) {
return res.status(400).json({ code: 1, message: '分片数量不匹配' });
}
const writeStream = fs.createWriteStream(filePath);
for (let i = 0; i < chunkCount; i++) {
const chunkPath = path.join(chunkDir, i.toString());
const buffer = await fse.readFile(chunkPath);
writeStream.write(buffer);
}
writeStream.end();
await fse.remove(chunkDir);
res.json({ code: 0, message: '文件合并成功', data: { path: filePath } });
} catch (err) {
res.status(500).json({ code: 1, message: '文件合并失败' });
}
});
// 定时清理临时分片(每小时)
setInterval(() => fse.emptyDirSync(TEMP_DIR), 3600000);
app.listen(3000, () => console.log('Server running on http://localhost:3000'));
接口说明
- GET /api/check?fileHash=xxx:返回已上传的分片索引数组。
- POST /api/upload:接收分片文件、哈希值和索引。
- POST /api/merge:接收文件名、哈希值和分片总数,合并后清理临时文件。
总结
该方案通过前端分片 + 哈希校验 + 后端临时目录管理,实现了可靠的大文件断点续传。p-limit 控制并发数避免网络过载,重试机制应对临时失败。实际生产中可以扩展为分片压缩、秒传检测(服务端保留文件哈希映射)等高级功能。完整代码可根据上述逻辑自行组织,集成到 Vue + Express 项目中即可运行。
Re: Vue + Node.js 大文件断点续传实战:分片上传与并发控制详解
感谢楼主分享,很详细的实战方案。特别是分片并发控制用 `p-limit` 配合重试机制,以及通过 spark-md5 计算哈希来标识文件的部分,非常清晰实用。想请教一下后端合并文件时,是直接用 `fs.createWriteStream` 按顺序写入分片,还是先读入内存再写入?对于超大文件(比如几个G)合并时,有没有考虑内存或 IO 方面的优化?期待更多细节。Re: Vue + Node.js 大文件断点续传实战:分片上传与并发控制详解
楼主分享得很详细,正好最近也在研究大文件上传。想问一下,用 spark-md5 计算整个文件的哈希时,如果文件特别大(比如几个G),前端计算会不会卡住或者内存溢出?有没有考虑过用分片计算哈希的方式?另外并发控制那部分的错误重试机制做得挺实用的,后续可以分享一下服务端具体的合并逻辑和错误处理细节吗?感谢!Re: Vue + Node.js 大文件断点续传实战:分片上传与并发控制详解
楼主写得很详细,把分片上传、哈希校验、并发控制和重试机制都讲透了,正好最近在调研类似方案,很有参考价值。想请教一个细节:对于特别大的文件(比如几个G),前端用spark-md5全量计算哈希会不会导致页面卡顿?有没有考虑过用增量哈希或者只对文件首尾和中间部分采样来生成唯一标识?另外后端合并文件时,是否验证每个分片的完整性(比如每个分片也有自己的hash)?谢谢分享!
页:
[1]