查看: 102|回复: 1

Vue3 + Element Plus 批量文件上传并发控制:队列调度、失败重试与分片上传

[复制链接]
发表于 3 小时前 | 显示全部楼层 |阅读模式
在 Vue 项目中实现批量文件上传时,并发问题是最常见的坑:浏览器默认同域并发连接数限制(通常 6 个)、服务器 QPS 限流、上传进度混乱、失败后难以重试等。本文针对不同场景给出四种实用的并发控制方案,所有代码基于 Vue3 组合式 API + Element Plus + axios,可直接复制到项目中调整使用。

核心痛点回顾
- 浏览器同域并发请求数有限(Chrome 约 6 个),超出后排队阻塞;
- 服务器瞬时大量请求可能被限流或拒绝;
- 无控制的并发导致进度统计不准,失败后重试成本高。

方案一:固定并发数 + 队列调度(基础版)
适用场景:中小批量文件(10-50 个)、单文件小于 100MB,追求简单易实现。

核心思路:将所有文件放入队列,设定最大并发数(建议 3-5),通过“正在上传计数”循环调度,一次只并发指定数量的文件。
  1. <!-- 模板部分(Element Plus) -->
  2. <el-upload
  3.   ref="uploadRef"
  4.   multiple
  5.   accept="*"
  6.   :auto-upload="false"
  7.   :on-change="handleFileChange"
  8.   :file-list="fileList"
  9. >
  10.   <el-button type="primary">选择批量文件</el-button>
  11. </el-upload>
  12. <el-button type="success" @click="startBatchUpload" style="margin-top: 10px;">
  13.   开始上传
  14. </el-button>
  15. <div v-for="file in uploadStatus" :key="file.uid" style="margin-top: 10px;">
  16.   <span>{{ file.name }}</span>
  17.   <el-progress :percentage="file.progress" :status="file.status" style="width: 300px; margin-left: 10px;" />
  18. </div>
  19. <script setup>
  20. import { ref } from 'vue';
  21. import { ElUpload, ElButton, ElProgress, ElMessage } from 'element-plus';
  22. import axios from 'axios';
  23. const fileList = ref([]);
  24. const uploadStatus = ref([]);
  25. let uploadQueue = [];
  26. let currentUploads = 0;
  27. const maxConcurrent = 3; // 根据服务器调整
  28. const uploadApi = '/api/upload/file';
  29. const handleFileChange = (uploadFile) => {
  30.   // 去重
  31.   if (uploadQueue.some(item => item.uid === uploadFile.uid)) return;
  32.   uploadQueue.push(uploadFile);
  33.   uploadStatus.value.push({
  34.     uid: uploadFile.uid,
  35.     name: uploadFile.name,
  36.     progress: 0,
  37.     status: 'ready'
  38.   });
  39. };
  40. const uploadSingleFile = async (file) => {
  41.   const statusItem = uploadStatus.value.find(item => item.uid === file.uid);
  42.   statusItem.status = 'uploading';
  43.   const formData = new FormData();
  44.   formData.append('file', file.raw);
  45.   try {
  46.     const response = await axios.post(uploadApi, formData, {
  47.       headers: { 'Content-Type': 'multipart/form-data' },
  48.       onUploadProgress: (progressEvent) => {
  49.         const progress = Math.round((progressEvent.loaded / progressEvent.total) * 100);
  50.         statusItem.progress = progress;
  51.       }
  52.     });
  53.     statusItem.status = 'success';
  54.     statusItem.progress = 100;
  55.     ElMessage.success(`${file.name} 上传成功`);
  56.     return response.data;
  57.   } catch (error) {
  58.     statusItem.status = 'error';
  59.     ElMessage.error(`${file.name} 上传失败,请重试`);
  60.     throw error;
  61.   }
  62. };
  63. const processQueue = async () => {
  64.   while (currentUploads < maxConcurrent && uploadQueue.length > 0) {
  65.     const file = uploadQueue.shift();
  66.     currentUploads++;
  67.     try {
  68.       await uploadSingleFile(file);
  69.     } catch (error) {
  70.       // 可根据需要决定是否将失败文件放回队列头部
  71.       // uploadQueue.unshift(file);
  72.     } finally {
  73.       currentUploads--;
  74.       processQueue();
  75.     }
  76.   }
  77. };
  78. const startBatchUpload = () => {
  79.   if (uploadQueue.length === 0) {
  80.     ElMessage.warning('请先选择文件');
  81.     return;
  82.   }
  83.   processQueue();
  84. };
  85. </script>
复制代码

要点:上传完成(无论成功或失败)必须释放并发槽位(currentUploads--),并递归调用调度函数;maxConcurrent 建议根据服务器性能设置为 3-5,避免压垮服务器。

方案二:并发控制 + 失败重试(进阶版)
适用场景:文件较多(50-200 个)、网络不稳定、对成功率要求高。

在方案一的基础上增加重试机制与指数退避策略。
  1. // 重试配置
  2. const retryConfig = {
  3.   maxRetry: 3,
  4.   retryDelay: 1000 // 基础间隔 ms
  5. };
  6. // 重写 uploadSingleFile 函数
  7. const uploadSingleFile = async (file, retryCount = 0) => {
  8.   const statusItem = uploadStatus.value.find(item => item.uid === file.uid);
  9.   statusItem.status = 'uploading';
  10.   const formData = new FormData();
  11.   formData.append('file', file.raw);
  12.   try {
  13.     const response = await axios.post(uploadApi, formData, {
  14.       headers: { 'Content-Type': 'multipart/form-data' },
  15.       onUploadProgress: (progressEvent) => {
  16.         const progress = Math.round((progressEvent.loaded / progressEvent.total) * 100);
  17.         statusItem.progress = progress;
  18.       }
  19.     });
  20.     statusItem.status = 'success';
  21.     statusItem.progress = 100;
  22.     ElMessage.success(`${file.name} 上传成功`);
  23.     return response.data;
  24.   } catch (error) {
  25.     if (retryCount < retryConfig.maxRetry) {
  26.       const delay = retryConfig.retryDelay * Math.pow(2, retryCount); // 指数退避
  27.       statusItem.status = `retry(${retryCount + 1}/${retryConfig.maxRetry})`;
  28.       ElMessage.warning(`${file.name} 上传失败,${delay}ms后重试(${retryCount + 1}/${retryConfig.maxRetry})`);
  29.       await new Promise(resolve => setTimeout(resolve, delay));
  30.       return uploadSingleFile(file, retryCount + 1);
  31.     }
  32.     statusItem.status = 'error';
  33.     ElMessage.error(`${file.name} 上传失败,已达到最大重试次数`);
  34.     throw error;
  35.   }
  36. };
  37. // 可选:取消所有上传
  38. const cancelAllUpload = () => {
  39.   uploadQueue = [];
  40.   currentUploads = 0;
  41.   uploadStatus.value.forEach(item => {
  42.     if (item.status === 'uploading' || item.status.includes('retry')) {
  43.       item.status = 'cancelled';
  44.     }
  45.   });
  46.   ElMessage.info('已取消所有上传任务');
  47. };
复制代码

核心改进:采用指数退避(1s→2s→4s)避免频繁重试增加服务器压力;retryCount 记录重试次数,超过上限后标记失败;新增全局取消功能提升用户体验。

方案三:分片上传 + 全局并发控制(高级版)
适用场景:批量大文件(单文件超过 100MB),需稳定高效上传。

将大文件切割为 1-5MB 的分片,通过全局并发限流器控制所有分片的总并发数。
  1. // 分片配置
  2. const chunkConfig = {
  3.   chunkSize: 1 * 1024 * 1024, // 1MB
  4.   maxConcurrent: 3
  5. };
  6. // 全局并发限流器
  7. class ConcurrencyLimiter {
  8.   constructor(maxConcurrent) {
  9.     this.maxConcurrent = maxConcurrent;
  10.     this.activeCount = 0;
  11.     this.pendingQueue = [];
  12.   }
  13.   enqueue(runTask, fileId) {
  14.     return new Promise((resolve, reject) => {
  15.       this.pendingQueue.push({ runTask, resolve, reject, fileId });
  16.       this.tryStartNext();
  17.     });
  18.   }
  19.   tryStartNext() {
  20.     while (this.activeCount < this.maxConcurrent && this.pendingQueue.length > 0) {
  21.       const { runTask, resolve, reject, fileId } = this.pendingQueue.shift();
  22.       this.activeCount++;
  23.       Promise.resolve()
  24.         .then(() => runTask())
  25.         .then(res => {
  26.           this.activeCount--;
  27.           resolve(res);
  28.           this.tryStartNext();
  29.         })
  30.         .catch(err => {
  31.           this.activeCount--;
  32.           reject(err);
  33.           this.tryStartNext();
  34.         });
  35.     }
  36.   }
  37. }
  38. const limiter = new ConcurrencyLimiter(chunkConfig.maxConcurrent);
  39. // 生成文件唯一标识(用于分片合并、断点续传)
  40. const getFileIdentifier = (file) => {
  41.   const originalFile = file._originalFile || file;
  42.   return `${originalFile.name}_${originalFile.size}_${originalFile.lastModified}`;
  43. };
  44. // 切割分片
  45. const splitFileIntoChunks = (file) => {
  46.   const chunks = [];
  47.   let start = 0;
  48.   while (start < file.size) {
  49.     const end = Math.min(start + chunkConfig.chunkSize, file.size);
  50.     chunks.push(file.slice(start, end));
  51.     start = end;
  52.   }
  53.   return chunks;
  54. };
  55. // 上传单个分片
  56. const uploadChunk = async (chunk, fileId, chunkIndex, totalChunks) => {
  57.   const formData = new FormData();
  58.   formData.append('chunk', chunk);
  59.   formData.append('fileId', fileId);
  60.   formData.append('chunkIndex', chunkIndex);
  61.   formData.append('totalChunks', totalChunks);
  62.   const response = await axios.post('/api/upload/chunk', formData, {
  63.     headers: { 'Content-Type': 'multipart/form-data' }
  64.   });
  65.   return response.data;
  66. };
  67. // 上传大文件(分片并发)
  68. const uploadLargeFile = async (file) => {
  69.   const fileId = getFileIdentifier(file);
  70.   const chunks = splitFileIntoChunks(file.raw);
  71.   const totalChunks = chunks.length;
  72.   const statusItem = uploadStatus.value.find(item => item.uid === file.uid);
  73.   statusItem.status = 'uploading';
  74.   const chunkTasks = chunks.map((chunk, index) => {
  75.     return limiter.enqueue(() => uploadChunk(chunk, fileId, index, totalChunks), fileId)
  76.       .then(() => {
  77.         const progress = Math.round(((index + 1) / totalChunks) * 100);
  78.         statusItem.progress = progress;
  79.       });
  80.   });
  81.   try {
  82.     await Promise.all(chunkTasks);
  83.     await axios.post('/api/upload/merge', { fileId, fileName: file.name });
  84.     statusItem.status = 'success';
  85.     statusItem.progress = 100;
  86.     ElMessage.success(`${file.name} 上传成功`);
  87.   } catch (error) {
  88.     statusItem.status = 'error';
  89.     ElMessage.error(`${file.name} 上传失败,请重试`);
  90.     throw error;
  91.   }
  92. };
  93. // 修改队列调度,按文件大小选择分片或直接上传
  94. const processQueue = async () => {
  95.   while (uploadQueue.length > 0) {
  96.     const file = uploadQueue.shift();
  97.     if (file.size > chunkConfig.chunkSize) {
  98.       await uploadLargeFile(file);
  99.     } else {
  100.       await uploadSingleFile(file);
  101.     }
  102.   }
  103. };
复制代码

要点:分片大小建议 1-5MB,太小增加请求数,太易超时;全局限流器确保所有文件的分片总并发不超标;需配合后端提供分片上传、合并接口。

方案四:极简版——使用成熟插件
若不想手写复杂逻辑,可直接使用 vue-upload-component(兼容 Vue2/3)或 Element Plus Upload 组件(自带并发控制)。

以 vue-upload-component 为例:
  1. // 安装
  2. npm install vue-upload-component --save
  3. // 全局注册(main.js)
  4. import Vue from 'vue';
  5. import UploadComponent from 'vue-upload-component';
  6. Vue.component('file-upload', UploadComponent);
  7. // 页面使用
  8. <template>
  9.   <file-upload
  10.     v-model="files"
  11.     url="/api/upload/file"
  12.     :multiple="true"
  13.     :concurrency="3"
  14.     :retry="3"
  15.     @input-file="handleFile"
  16.   >
  17.     <el-button type="primary">批量上传</el-button>
  18.   </file-upload>
  19. </template>
  20. <script>
  21. export default {
  22.   data() {
  23.     return { files: [] };
  24.   },
  25.   methods: {
  26.     handleFile(file) {
  27.       console.log(file.progress, file.status);
  28.     }
  29.   }
  30. };
  31. </script>
复制代码
Element Plus 的 Upload 组件通过 limit 和 http-request 也可快速实现并发控制。

方案对比与选型建议
- 方案一:开发成本低,适合中小文件;
- 方案二:稳定性高,适合批量多、网络不固定场景;
- 方案三:支持大文件,但需前后端配合;
- 方案四:极速落地,无需定制化。

优化补充(必看)
- 前端:上传前校验文件大小和格式,减少无效请求;可用 Web Worker 切分文件,避免阻塞主线程;
- 后端:配置合适的文件大小限制和 QPS 限流,提供分片合并接口,支持文件去重;
- 进度优化:可增加全局平均进度条;单个进度基于上传字节或分片数计算;
- 跨域:后端 CORS 放行 multipart/form-data 请求。

总结:根据文件大小、数量和服务性能选择合适方案,亦可组合使用(如并发控制 + 分片上传 + 失败重试),实现高效稳定的批量文件上传。
回复

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: Vue3 + Element Plus 批量文件上传并发控制:队列调度、失败重试与分片上传

感谢楼主的详细分享!方案一逻辑清晰,固定并发数加队列调度确实是中小批量上传最稳的入门写法。`currentUploads`计数器配合`processQueue`循环调度的思路也很直观,避免了手动管理Promise池的麻烦。 有几个小点想请教一下: 1. 失败重试部分,代码里注释了可以将失败文件放回队列头部,但实际项目里通常需要限制重试次数,否则网络抖动会导致死循环。楼主有没有建议的重试策略(比如指数退避)? 2. 对于单文件超过100MB的情况,方案一可能不太适用,标题里提到了分片上传,后续方案会涉及断点续传和分片并发吗?很期待看到相关实现。 3. 另外,`processQueue`函数里只展示了while循环的开始部分,后续的递归或异步控制(比如等待当前任务完成后再调度)是怎样的?是直接`processQueue()`递归调用,还是监听`currentUploads`变化? 感谢分享,已经收藏了,坐等方案二到四!
回复 支持 反对

使用道具 举报

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

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

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

官方核心成员

关注微信公众号

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

GMT+8, 2026-6-11 23:07 , Processed in 0.039305 second(s), 17 queries , Gzip On, Redis On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部