在 Vue 项目中实现批量文件上传时,并发问题是最常见的坑:浏览器默认同域并发连接数限制(通常 6 个)、服务器 QPS 限流、上传进度混乱、失败后难以重试等。本文针对不同场景给出四种实用的并发控制方案,所有代码基于 Vue3 组合式 API + Element Plus + axios,可直接复制到项目中调整使用。
核心痛点回顾
- 浏览器同域并发请求数有限(Chrome 约 6 个),超出后排队阻塞;
- 服务器瞬时大量请求可能被限流或拒绝;
- 无控制的并发导致进度统计不准,失败后重试成本高。
方案一:固定并发数 + 队列调度(基础版)
适用场景:中小批量文件(10-50 个)、单文件小于 100MB,追求简单易实现。
核心思路:将所有文件放入队列,设定最大并发数(建议 3-5),通过“正在上传计数”循环调度,一次只并发指定数量的文件。
- <!-- 模板部分(Element Plus) -->
- <el-upload
- ref="uploadRef"
- multiple
- accept="*"
- :auto-upload="false"
- :on-change="handleFileChange"
- :file-list="fileList"
- >
- <el-button type="primary">选择批量文件</el-button>
- </el-upload>
- <el-button type="success" @click="startBatchUpload" style="margin-top: 10px;">
- 开始上传
- </el-button>
- <div v-for="file in uploadStatus" :key="file.uid" style="margin-top: 10px;">
- <span>{{ file.name }}</span>
- <el-progress :percentage="file.progress" :status="file.status" style="width: 300px; margin-left: 10px;" />
- </div>
- <script setup>
- import { ref } from 'vue';
- import { ElUpload, ElButton, ElProgress, ElMessage } from 'element-plus';
- import axios from 'axios';
- const fileList = ref([]);
- const uploadStatus = ref([]);
- let uploadQueue = [];
- let currentUploads = 0;
- const maxConcurrent = 3; // 根据服务器调整
- const uploadApi = '/api/upload/file';
- const handleFileChange = (uploadFile) => {
- // 去重
- if (uploadQueue.some(item => item.uid === uploadFile.uid)) return;
- uploadQueue.push(uploadFile);
- uploadStatus.value.push({
- uid: uploadFile.uid,
- name: uploadFile.name,
- progress: 0,
- status: 'ready'
- });
- };
- const uploadSingleFile = async (file) => {
- const statusItem = uploadStatus.value.find(item => item.uid === file.uid);
- statusItem.status = 'uploading';
- const formData = new FormData();
- formData.append('file', file.raw);
- try {
- const response = await axios.post(uploadApi, formData, {
- headers: { 'Content-Type': 'multipart/form-data' },
- onUploadProgress: (progressEvent) => {
- const progress = Math.round((progressEvent.loaded / progressEvent.total) * 100);
- statusItem.progress = progress;
- }
- });
- statusItem.status = 'success';
- statusItem.progress = 100;
- ElMessage.success(`${file.name} 上传成功`);
- return response.data;
- } catch (error) {
- statusItem.status = 'error';
- ElMessage.error(`${file.name} 上传失败,请重试`);
- throw error;
- }
- };
- const processQueue = async () => {
- while (currentUploads < maxConcurrent && uploadQueue.length > 0) {
- const file = uploadQueue.shift();
- currentUploads++;
- try {
- await uploadSingleFile(file);
- } catch (error) {
- // 可根据需要决定是否将失败文件放回队列头部
- // uploadQueue.unshift(file);
- } finally {
- currentUploads--;
- processQueue();
- }
- }
- };
- const startBatchUpload = () => {
- if (uploadQueue.length === 0) {
- ElMessage.warning('请先选择文件');
- return;
- }
- processQueue();
- };
- </script>
复制代码
要点:上传完成(无论成功或失败)必须释放并发槽位(currentUploads--),并递归调用调度函数;maxConcurrent 建议根据服务器性能设置为 3-5,避免压垮服务器。
方案二:并发控制 + 失败重试(进阶版)
适用场景:文件较多(50-200 个)、网络不稳定、对成功率要求高。
在方案一的基础上增加重试机制与指数退避策略。
- // 重试配置
- const retryConfig = {
- maxRetry: 3,
- retryDelay: 1000 // 基础间隔 ms
- };
- // 重写 uploadSingleFile 函数
- const uploadSingleFile = async (file, retryCount = 0) => {
- const statusItem = uploadStatus.value.find(item => item.uid === file.uid);
- statusItem.status = 'uploading';
- const formData = new FormData();
- formData.append('file', file.raw);
- try {
- const response = await axios.post(uploadApi, formData, {
- headers: { 'Content-Type': 'multipart/form-data' },
- onUploadProgress: (progressEvent) => {
- const progress = Math.round((progressEvent.loaded / progressEvent.total) * 100);
- statusItem.progress = progress;
- }
- });
- statusItem.status = 'success';
- statusItem.progress = 100;
- ElMessage.success(`${file.name} 上传成功`);
- return response.data;
- } catch (error) {
- if (retryCount < retryConfig.maxRetry) {
- const delay = retryConfig.retryDelay * Math.pow(2, retryCount); // 指数退避
- statusItem.status = `retry(${retryCount + 1}/${retryConfig.maxRetry})`;
- ElMessage.warning(`${file.name} 上传失败,${delay}ms后重试(${retryCount + 1}/${retryConfig.maxRetry})`);
- await new Promise(resolve => setTimeout(resolve, delay));
- return uploadSingleFile(file, retryCount + 1);
- }
- statusItem.status = 'error';
- ElMessage.error(`${file.name} 上传失败,已达到最大重试次数`);
- throw error;
- }
- };
- // 可选:取消所有上传
- const cancelAllUpload = () => {
- uploadQueue = [];
- currentUploads = 0;
- uploadStatus.value.forEach(item => {
- if (item.status === 'uploading' || item.status.includes('retry')) {
- item.status = 'cancelled';
- }
- });
- ElMessage.info('已取消所有上传任务');
- };
复制代码
核心改进:采用指数退避(1s→2s→4s)避免频繁重试增加服务器压力;retryCount 记录重试次数,超过上限后标记失败;新增全局取消功能提升用户体验。
方案三:分片上传 + 全局并发控制(高级版)
适用场景:批量大文件(单文件超过 100MB),需稳定高效上传。
将大文件切割为 1-5MB 的分片,通过全局并发限流器控制所有分片的总并发数。
- // 分片配置
- const chunkConfig = {
- chunkSize: 1 * 1024 * 1024, // 1MB
- maxConcurrent: 3
- };
- // 全局并发限流器
- class ConcurrencyLimiter {
- constructor(maxConcurrent) {
- this.maxConcurrent = maxConcurrent;
- this.activeCount = 0;
- this.pendingQueue = [];
- }
- enqueue(runTask, fileId) {
- return new Promise((resolve, reject) => {
- this.pendingQueue.push({ runTask, resolve, reject, fileId });
- this.tryStartNext();
- });
- }
- tryStartNext() {
- while (this.activeCount < this.maxConcurrent && this.pendingQueue.length > 0) {
- const { runTask, resolve, reject, fileId } = this.pendingQueue.shift();
- this.activeCount++;
- Promise.resolve()
- .then(() => runTask())
- .then(res => {
- this.activeCount--;
- resolve(res);
- this.tryStartNext();
- })
- .catch(err => {
- this.activeCount--;
- reject(err);
- this.tryStartNext();
- });
- }
- }
- }
- const limiter = new ConcurrencyLimiter(chunkConfig.maxConcurrent);
- // 生成文件唯一标识(用于分片合并、断点续传)
- const getFileIdentifier = (file) => {
- const originalFile = file._originalFile || file;
- return `${originalFile.name}_${originalFile.size}_${originalFile.lastModified}`;
- };
- // 切割分片
- const splitFileIntoChunks = (file) => {
- const chunks = [];
- let start = 0;
- while (start < file.size) {
- const end = Math.min(start + chunkConfig.chunkSize, file.size);
- chunks.push(file.slice(start, end));
- start = end;
- }
- return chunks;
- };
- // 上传单个分片
- const uploadChunk = async (chunk, fileId, chunkIndex, totalChunks) => {
- const formData = new FormData();
- formData.append('chunk', chunk);
- formData.append('fileId', fileId);
- formData.append('chunkIndex', chunkIndex);
- formData.append('totalChunks', totalChunks);
- const response = await axios.post('/api/upload/chunk', formData, {
- headers: { 'Content-Type': 'multipart/form-data' }
- });
- return response.data;
- };
- // 上传大文件(分片并发)
- const uploadLargeFile = async (file) => {
- const fileId = getFileIdentifier(file);
- const chunks = splitFileIntoChunks(file.raw);
- const totalChunks = chunks.length;
- const statusItem = uploadStatus.value.find(item => item.uid === file.uid);
- statusItem.status = 'uploading';
- const chunkTasks = chunks.map((chunk, index) => {
- return limiter.enqueue(() => uploadChunk(chunk, fileId, index, totalChunks), fileId)
- .then(() => {
- const progress = Math.round(((index + 1) / totalChunks) * 100);
- statusItem.progress = progress;
- });
- });
- try {
- await Promise.all(chunkTasks);
- await axios.post('/api/upload/merge', { fileId, fileName: file.name });
- statusItem.status = 'success';
- statusItem.progress = 100;
- ElMessage.success(`${file.name} 上传成功`);
- } catch (error) {
- statusItem.status = 'error';
- ElMessage.error(`${file.name} 上传失败,请重试`);
- throw error;
- }
- };
- // 修改队列调度,按文件大小选择分片或直接上传
- const processQueue = async () => {
- while (uploadQueue.length > 0) {
- const file = uploadQueue.shift();
- if (file.size > chunkConfig.chunkSize) {
- await uploadLargeFile(file);
- } else {
- await uploadSingleFile(file);
- }
- }
- };
复制代码
要点:分片大小建议 1-5MB,太小增加请求数,太易超时;全局限流器确保所有文件的分片总并发不超标;需配合后端提供分片上传、合并接口。
方案四:极简版——使用成熟插件
若不想手写复杂逻辑,可直接使用 vue-upload-component(兼容 Vue2/3)或 Element Plus Upload 组件(自带并发控制)。
以 vue-upload-component 为例:- // 安装
- npm install vue-upload-component --save
- // 全局注册(main.js)
- import Vue from 'vue';
- import UploadComponent from 'vue-upload-component';
- Vue.component('file-upload', UploadComponent);
- // 页面使用
- <template>
- <file-upload
- v-model="files"
- url="/api/upload/file"
- :multiple="true"
- :concurrency="3"
- :retry="3"
- @input-file="handleFile"
- >
- <el-button type="primary">批量上传</el-button>
- </file-upload>
- </template>
- <script>
- export default {
- data() {
- return { files: [] };
- },
- methods: {
- handleFile(file) {
- console.log(file.progress, file.status);
- }
- }
- };
- </script>
复制代码 Element Plus 的 Upload 组件通过 limit 和 http-request 也可快速实现并发控制。
方案对比与选型建议
- 方案一:开发成本低,适合中小文件;
- 方案二:稳定性高,适合批量多、网络不固定场景;
- 方案三:支持大文件,但需前后端配合;
- 方案四:极速落地,无需定制化。
优化补充(必看)
- 前端:上传前校验文件大小和格式,减少无效请求;可用 Web Worker 切分文件,避免阻塞主线程;
- 后端:配置合适的文件大小限制和 QPS 限流,提供分片合并接口,支持文件去重;
- 进度优化:可增加全局平均进度条;单个进度基于上传字节或分片数计算;
- 跨域:后端 CORS 放行 multipart/form-data 请求。
总结:根据文件大小、数量和服务性能选择合适方案,亦可组合使用(如并发控制 + 分片上传 + 失败重试),实现高效稳定的批量文件上传。 |