背景
大文件上传一直是前端开发的痛点,网络不稳定时失败重传整个文件既耗时又浪费带宽。断点续传机制通过将文件切分为小块、记录已上传片段,在中断后从断点继续上传,显著提升用户体验。本文结合 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 项目中即可运行。 |