查看: 77|回复: 1

Vue + Node.js 大文件断点续传实战:分片上传与并发控制详解

[复制链接]
发表于 昨天 23:00 | 显示全部楼层 |阅读模式
背景
大文件上传一直是前端开发的痛点,网络不稳定时失败重传整个文件既耗时又浪费带宽。断点续传机制通过将文件切分为小块、记录已上传片段,在中断后从断点继续上传,显著提升用户体验。本文结合 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. 文件分片
  1. const chunkSize = 1 * 1024 * 1024; // 1MB
  2. const chunks = [];
  3. let start = 0;
  4. while (start < file.size) {
  5.   const end = Math.min(start + chunkSize, file.size);
  6.   const chunk = file.slice(start, end);
  7.   chunks.push(chunk);
  8.   start = end;
  9. }
复制代码

2. 计算文件哈希
通过 FileReader 读取文件为 ArrayBuffer,使用 SparkMD5 计算 MD5 值:
  1. const calculateFileHash = async (file) => {
  2.   return new Promise((resolve) => {
  3.     const spark = new SparkMD5.ArrayBuffer();
  4.     const reader = new FileReader();
  5.     reader.readAsArrayBuffer(file);
  6.     reader.onload = (e) => {
  7.       spark.append(e.target.result);
  8.       resolve(spark.end());
  9.     };
  10.   });
  11. };
复制代码

3. 查询已上传分片
上传前先向后端发起 GET 请求,传入文件哈希,获取已上传的分片索引列表:
  1. const fileHash = await calculateFileHash(file);
  2. const { data } = await axios.get('/api/check', {
  3.   params: { fileHash }
  4. });
复制代码

4. 并发上传分片
使用 p-limit 限制并发数(如3),对每个未上传的分片生成任务。每个分片上传失败时自动重试最多3次:
  1. import pLimit from 'p-limit';
  2. const limit = pLimit(3);
  3. const tasks = chunks.map((chunk, index) => {
  4.   if (uploadedChunkIndexes.includes(index)) return Promise.resolve();
  5.   return limit(() => {
  6.     const MAX_RETRY = 3;
  7.     const attempt = (retry) => {
  8.       const formData = new FormData();
  9.       formData.append('chunk', chunk);
  10.       formData.append('hash', fileHash);
  11.       formData.append('index', index);
  12.       return axios.post('/api/upload', formData, {
  13.         onUploadProgress: (e) => {
  14.           const percent = Math.round((e.loaded / e.total) * 100);
  15.           updateProgress(index, percent);
  16.         }
  17.       }).then(() => {
  18.         uploadedChunkIndexes.push(index);
  19.         updateProgress();
  20.       }).catch((error) => {
  21.         if (retry < MAX_RETRY - 1) return attempt(retry + 1);
  22.         throw error;
  23.       });
  24.     };
  25.     return attempt(0);
  26.   });
  27. });
  28. await Promise.all(tasks);
复制代码

5. 通知合并文件
所有分片上传成功后,向服务端发送合并请求:
  1. await axios.post('/api/merge', {
  2.   fileName: file.name,
  3.   fileHash: fileHash,
  4.   chunkCount: chunks.length
  5. });
复制代码

6. 前端页面交互
使用 Element Plus 的 el-upload 组件实现文件拖拽选择,配合 el-progress 显示总体进度。示例简化如下:
  1. <template>
  2.   <div class="upload-container">
  3.     <el-upload
  4.       drag
  5.       :auto-upload="false"
  6.       :on-change="handleFileChange"
  7.       :show-file-list="false">
  8.       <el-icon><upload-filled /></el-icon>
  9.       <div>将文件拖到此处或<em>点击选择</em></div>
  10.     </el-upload>
  11.     <el-progress :percentage="totalProgress" :status="uploadStatus" :stroke-width="16" />
  12.     <el-button type="primary" @click="startUpload" :disabled="isUploading">
  13.       {{ isUploading ? '上传中...' : '开始上传' }}
  14.     </el-button>
  15.     <el-button @click="pauseUpload" :disabled="!isUploading">暂停</el-button>
  16.   </div>
  17. </template>
复制代码

后端实现(Node.js Express)
项目结构如下:
  1. ├── server.js
  2. ├── uploads/
  3. │   ├── temp/       # 分片临时存储
  4. │   └── merged/     # 合并后文件目录
  5. └── package.json
复制代码
依赖安装:express multer cors fs-extra。

服务端核心代码(server.js):
  1. const express = require('express');
  2. const multer = require('multer');
  3. const fs = require('fs');
  4. const path = require('path');
  5. const cors = require('cors');
  6. const fse = require('fs-extra');
  7. const app = express();
  8. app.use(cors());
  9. app.use(express.json());
  10. const UPLOAD_DIR = path.resolve(__dirname, 'uploads');
  11. const TEMP_DIR = path.join(UPLOAD_DIR, 'temp');
  12. const MERGED_DIR = path.join(UPLOAD_DIR, 'merged');
  13. fse.ensureDirSync(TEMP_DIR);
  14. fse.ensureDirSync(MERGED_DIR);
  15. const chunkUpload = multer({ dest: TEMP_DIR });
  16. // 1. 检查已上传分片
  17. app.get('/api/check', (req, res) => {
  18.   const { fileHash } = req.query;
  19.   const chunkDir = path.resolve(TEMP_DIR, fileHash);
  20.   let uploadedChunks = [];
  21.   if (fse.existsSync(chunkDir)) {
  22.     uploadedChunks = fse.readdirSync(chunkDir).map(name => parseInt(name));
  23.   }
  24.   res.json({ code: 0, data: { uploadedIndexes: uploadedChunks } });
  25. });
  26. // 2. 上传分片
  27. app.post('/api/upload', chunkUpload.single('chunk'), async (req, res) => {
  28.   const { hash, index } = req.body;
  29.   const chunkPath = req.file.path;
  30.   const chunkDir = path.resolve(TEMP_DIR, hash);
  31.   try {
  32.     await fse.ensureDir(chunkDir);
  33.     const destPath = path.join(chunkDir, index);
  34.     await fse.move(chunkPath, destPath);
  35.     res.json({ code: 0, message: '分片上传成功' });
  36.   } catch (err) {
  37.     res.status(500).json({ code: 1, message: '分片上传失败' });
  38.   }
  39. });
  40. // 3. 合并分片
  41. app.post('/api/merge', async (req, res) => {
  42.   const { fileName, fileHash, chunkCount } = req.body;
  43.   const chunkDir = path.join(TEMP_DIR, fileHash);
  44.   const filePath = path.join(MERGED_DIR, fileName);
  45.   try {
  46.     const chunkPaths = await fse.readdir(chunkDir);
  47.     if (chunkPaths.length !== chunkCount) {
  48.       return res.status(400).json({ code: 1, message: '分片数量不匹配' });
  49.     }
  50.     const writeStream = fs.createWriteStream(filePath);
  51.     for (let i = 0; i < chunkCount; i++) {
  52.       const chunkPath = path.join(chunkDir, i.toString());
  53.       const buffer = await fse.readFile(chunkPath);
  54.       writeStream.write(buffer);
  55.     }
  56.     writeStream.end();
  57.     await fse.remove(chunkDir);
  58.     res.json({ code: 0, message: '文件合并成功', data: { path: filePath } });
  59.   } catch (err) {
  60.     res.status(500).json({ code: 1, message: '文件合并失败' });
  61.   }
  62. });
  63. // 定时清理临时分片(每小时)
  64. setInterval(() => fse.emptyDirSync(TEMP_DIR), 3600000);
  65. 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 项目中即可运行。
回复

使用道具 举报

发表于 昨天 23:10 | 显示全部楼层

Re: Vue + Node.js 大文件断点续传实战:分片上传与并发控制详解

感谢楼主分享,很详细的实战方案。特别是分片并发控制用 `p-limit` 配合重试机制,以及通过 spark-md5 计算哈希来标识文件的部分,非常清晰实用。想请教一下后端合并文件时,是直接用 `fs.createWriteStream` 按顺序写入分片,还是先读入内存再写入?对于超大文件(比如几个G)合并时,有没有考虑内存或 IO 方面的优化?期待更多细节。
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-6-13 01:04 , Processed in 0.038540 second(s), 18 queries , Gzip On, Redis On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部