普通视图

发现新文章,点击刷新页面。
今天 — 2025年6月8日首页

如何更好的实现业务中图片批量上传需求

2025年6月7日 17:29

作为一名前端开发人员,相信大家都做过pc端管理后台中图片上传的需求,一般无非就是按照prd实现图片(单张或者多张),然后引入elementUI等框架,然后使用其中的Upload组件去实现。这么做针对一般要求不高或者图片数量较少的情况下是没什么问题的。

But 如果你接受的是以下需求呢?

1、电商平台

  • 商家批量上传商品图片,如不同角度的商品展示图、规格图等。
  • 在编辑商品时上传多张图片方便商品展示,提升用户体验。

2、教育平台

  • 老师上传多张课件图片、讲义或作业参考材料供学生参考。
  • 学生上传多个作业文件,如笔记、照片、实验图片等。

2、房地产、旅游等行业平台

  • 房地产平台上传房源照片,如房间的各个角度、社区环境等。
  • 旅游平台上传景点照片、活动照片等,展示更丰富的视觉内容。

实现上面这些需求有一个核心点就是:提高用户上传大量文件的效率,并通过适当的并发控制避免对服务器造成负担。 想一下,如果让各位大佬实现上述需求的话,相信大家也都能将功能做出来,但是用户体验或者其他方面可能就会牺牲一点点。

WX20250607-170212@2x.png

话不多说,我这里提供一种方案仅供各位大佬参考。

具体如下:

第一步:

  • 并发限制:控制同时上传的图片数量,避免服务器压力过大。
  • 文件大小限制:在上传前检查文件大小,超出限制则阻止上传。
<template>
  <div>
    <el-upload
      ref="uploadRef"
      :http-request="customHttpRequest"
      :on-change="handleFileChange"
      :before-upload="beforeUpload"
      :file-list="fileList"
      multiple
      :limit="10"
      :auto-upload="false"
      >
      <el-button type="primary">批量上传图片</el-button>
    </el-upload>
    <div>
      <el-button type="primary" @click="startUpload">开始上传</el-button>
    </div>
  </div>
</template>

<script setup>
  import { ref } from 'vue';
  import { ElButton, ElUpload, ElMessage } from 'element-plus';

  const fileList = ref([]);
  const maxConcurrentUploads = 3; // 最大并发上传数量
  const maxSizeInMB = 2; // 文件大小限制,单位:MB
  let uploadQueue = [];
  let currentUploads = 0;

  const handleFileChange = (file, files) => {
    // 将文件加入到队列中
    uploadQueue.push(file);
  };

  // 文件大小限制检查
  const beforeUpload = (file) => {
    const isUnderLimit = file.size / 1024 / 1024 < maxSizeInMB;
    if (!isUnderLimit) {
      ElMessage.error(`文件 ${file.name} 超出大小限制(最大 ${maxSizeInMB} MB)`);
    }
    return isUnderLimit;
  };

  // 控制并发上传,限制同时上传数量
  const customHttpRequest = (options) => {
    if (currentUploads >= maxConcurrentUploads) return;

    currentUploads++;
    const { file, onProgress, onSuccess, onError } = options;

    // 创建XMLHttpRequest并配置上传进度
    const xhr = new XMLHttpRequest();
    xhr.open('POST', options.action, true);

    xhr.upload.onprogress = (event) => {
      if (event.lengthComputable) {
        const progress = (event.loaded / event.total) * 100;
        onProgress({ percent: progress });
      }
    };

    xhr.onload = () => {
      currentUploads--;
      processQueue();
      onSuccess(xhr.response);
    };

    xhr.onerror = () => {
      currentUploads--;
      processQueue();
      onError(xhr.response);
    };

    const formData = new FormData();
    formData.append('file', file);
    xhr.send(formData);
  };

  // 处理队列,限制同时上传数量
  const processQueue = () => {
    while (uploadQueue.length > 0 && currentUploads < maxConcurrentUploads) {
      const file = uploadQueue.shift();
      customHttpRequest({
        action: 'https://your-upload-endpoint',
        file,
        onProgress: (event) => console.log('progress:', event.percent),
        onSuccess: () => ElMessage.success(`文件 ${file.name} 上传成功`),
        onError: () => ElMessage.error(`文件 ${file.name} 上传失败`),
      });
    }
  };

  // 开始上传
  const startUpload = () => {
    processQueue();
  };
</script>

  • 文件大小检查beforeUpload函数会在文件加入队列前判断文件大小是否超过maxSizeInMB限制,如果超出则阻止上传,并给出提示信息。
  • 并发上传限制
  • 定义了maxConcurrentUploads限制同时上传的文件数量。
  • uploadQueue用于保存待上传的文件队列,currentUploads记录当前上传中的文件数量。
  • customHttpRequest是自定义的上传请求,在每次上传完毕后递减currentUploads,并调用processQueue继续处理队列中的文件。
  • processQueue函数会检查uploadQueue并发起新的上传请求,确保不会超过并发上传限制。

第二步:

  • 使用Web Worker对图片文件在上传前进行压缩格式转换。通过Web Worker来处理这些耗时操作,避免主线程阻塞,提升用户体验。
  • 创建一个Web Worker文件 imageWorker.js,用于处理图片的压缩和格式转换操作。使用Canvas API实现图片压缩,并将图片格式转换为JPEGPNG等常见格式。
// imageWorker.js
self.onmessage = async function (e) {
  const { file, quality, targetFormat } = e.data;

  const compressImage = async (file, quality, format) => {
    return new Promise((resolve) => {
      const img = new Image();
      img.src = URL.createObjectURL(file);
      img.onload = () => {
        const canvas = document.createElement('canvas');
        canvas.width = img.width;
        canvas.height = img.height;
        const ctx = canvas.getContext('2d');
        ctx.drawImage(img, 0, 0, img.width, img.height);

        // 设置格式和质量,转换图片
        canvas.toBlob(
          (blob) => {
            const compressedFile = new File([blob], file.name, {
              type: `image/${format}`,
              lastModified: Date.now(),
            });
            resolve(compressedFile);
          },
          `image/${format}`,
          quality
        );
      };
    });
  };

  // 调用压缩方法
  const processedFile = await compressImage(file, quality, targetFormat);
  self.postMessage(processedFile);
};

使用 Web Worker 进行图片预处理

<template>
  <div>
    <el-upload
      ref="uploadRef"
      :http-request="customHttpRequest"
      :on-change="handleFileChange"
      :before-upload="beforeUpload"
      :file-list="fileList"
      multiple
      :limit="10"
      :auto-upload="false"
    >
      <el-button type="primary">批量上传图片</el-button>
    </el-upload>
    <div>
      <el-button type="primary" @click="startUpload">开始上传</el-button>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { ElMessage } from 'element-plus';

const fileList = ref([]);
const maxConcurrentUploads = 3; // 最大并发上传数
let uploadQueue = [];
let currentUploads = 0;
let worker;

const handleFileChange = (file, files) => {
  uploadQueue.push(file);
};

// 初始化 Web Worker
onMounted(() => {
  worker = new Worker(new URL('./imageWorker.js', import.meta.url));
});

onUnmounted(() => {
  if (worker) worker.terminate();
});

// 图片预处理,压缩和格式转换
const beforeUpload = (file) => {
  return new Promise((resolve) => {
    worker.postMessage({
      file,
      quality: 0.7, // 图片压缩质量,0到1之间
      targetFormat: 'jpeg', // 目标格式,可以是 'jpeg' 或 'png'
    });

    // 监听 Web Worker 返回的压缩文件
    worker.onmessage = (e) => {
      const processedFile = e.data;
      resolve(processedFile); // 返回压缩后的文件
    };
  });
};

// 自定义上传请求,限制并发数量
const customHttpRequest = (options) => {
  if (currentUploads >= maxConcurrentUploads) return;

  currentUploads++;
  const { file, onProgress, onSuccess, onError } = options;

  const xhr = new XMLHttpRequest();
  xhr.open('POST', options.action, true);

  xhr.upload.onprogress = (event) => {
    if (event.lengthComputable) {
      const progress = (event.loaded / event.total) * 100;
      onProgress({ percent: progress });
    }
  };

  xhr.onload = () => {
    currentUploads--;
    processQueue();
    onSuccess(xhr.response);
  };

  xhr.onerror = () => {
    currentUploads--;
    processQueue();
    onError(xhr.response);
  };

  const formData = new FormData();
  formData.append('file', file);
  xhr.send(formData);
};

// 处理队列,限制同时上传数量
const processQueue = () => {
  while (uploadQueue.length > 0 && currentUploads < maxConcurrentUploads) {
    const file = uploadQueue.shift();
    customHttpRequest({
      action: 'https://your-upload-endpoint',
      file,
      onProgress: (event) => console.log('progress:', event.percent),
      onSuccess: () => ElMessage.success(`文件 ${file.name} 上传成功`),
      onError: () => ElMessage.error(`文件 ${file.name} 上传失败`),
    });
  }
};

// 开始上传
const startUpload = () => {
  processQueue();
};
</script>
  • Web Worker压缩和格式转换imageWorker.js文件中,使用Canvas API压缩图片并转换格式,通过postMessage返回处理后的文件。
  • compressImage函数将图片绘制到Canvas上,并将其转换为指定的格式和质量。
  • beforeUpload钩子中,图片会传入Web Worker进行预处理(压缩和格式转换)。
  • worker.onmessage监听预处理完成后的文件,并将其加入到上传队列。
  • 自定义上传和并发控制:通过customHttpRequestprocessQueue方法,控制同时上传的数量,确保不会超出maxConcurrentUploads的限制。

第三步:

实现上传进度管理的UI展示断点续传多文件进度管理,我们可以做以下几项优化:

  1. 上传进度展示 :为每张图片添加进度条,实时显示上传进度。
  2. 断点续传 :对已上传的数据做断点标记,当上传中断时可以从中断的部分继续上传。
  3. 多文件进度管理 :管理每个文件的上传进度状态,并在UI上展示。
<template>
  <div>
    <el-upload
      ref="uploadRef"
      :http-request="customHttpRequest"
      :on-change="handleFileChange"
      :before-upload="beforeUpload"
      :file-list="fileList"
      multiple
      :auto-upload="false"
    >
      <el-button type="primary">批量上传图片</el-button>
    </el-upload>
    <div>
      <el-button type="primary" @click="startUpload">开始上传</el-button>
    </div>
    <div v-for="(file, index) in uploadStatus" :key="file.uid" class="upload-item">
      <span>{{ file.name }}</span>
      <el-progress :percentage="file.progress" v-if="file.status === 'uploading'" />
      <span v-if="file.status === 'completed'">上传完成</span>
      <span v-if="file.status === 'failed'">上传失败</span>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { ElMessage } from 'element-plus';

const fileList = ref([]);
const uploadStatus = ref([]);
const maxConcurrentUploads = 3; // 最大并发上传数
let uploadQueue = [];
let currentUploads = 0;
let worker;

const handleFileChange = (file) => {
  uploadQueue.push(file);  // 注意可能是 uploadQueue.push(file.raw);  
  uploadStatus.value.push({
    uid: file.uid,
    name: file.name,
    progress: 0,
    status: 'pending',
  });
};

// 文件预处理,压缩和格式转换
const beforeUpload = (file) => {
  return new Promise((resolve) => {
    worker.postMessage({
      file,
      quality: 0.7,
      targetFormat: 'jpeg',
    });

    worker.onmessage = (e) => {
      const processedFile = e.data;
      resolve(processedFile);
    };
  });
};

// 自定义上传请求,限制并发数量,支持断点续传
const customHttpRequest = (options) => {
  if (currentUploads >= maxConcurrentUploads) return;

  currentUploads++;
  const { file, onProgress, onSuccess, onError } = options;
  const storedProgress = localStorage.getItem(`upload-progress-${file.uid}`) || 0;
  let uploadedBytes = parseInt(storedProgress, 10);

  // 上传进度更新
  const updateProgress = (event) => {
    const progress = ((uploadedBytes + event.loaded) / file.size) * 100;
    const fileStatus = uploadStatus.value.find((item) => item.uid === file.uid);
    if (fileStatus) fileStatus.progress = progress;
    onProgress({ percent: progress });
    localStorage.setItem(`upload-progress-${file.uid}`, uploadedBytes + event.loaded);
  };

  // 自定义分块上传实现断点续传
  const chunkSize = 1024 * 1024; // 1MB的分块大小
  const totalChunks = Math.ceil(file.size / chunkSize);
  let currentChunk = Math.floor(uploadedBytes / chunkSize);

  const uploadChunk = () => {
    if (currentChunk >= totalChunks) {
      localStorage.removeItem(`upload-progress-${file.uid}`);
      currentUploads--;
      processQueue();
      onSuccess();
      const fileStatus = uploadStatus.value.find((item) => item.uid === file.uid);
      if (fileStatus) fileStatus.status = 'completed';
      return;
    }

    const start = currentChunk * chunkSize;
    const end = Math.min(start + chunkSize, file.size);
    const chunk = file.slice(start, end);
    const formData = new FormData();
    formData.append('file', chunk);
    formData.append('filename', file.name);
    formData.append('chunkNumber', currentChunk);
    formData.append('totalChunks', totalChunks);

    const xhr = new XMLHttpRequest();
    xhr.open('POST', options.action, true);

    xhr.upload.onprogress = updateProgress;
    xhr.onload = () => {
      uploadedBytes += chunk.size;
      currentChunk++;
      uploadChunk();
    };
    xhr.onerror = () => {
      currentUploads--;
      const fileStatus = uploadStatus.value.find((item) => item.uid === file.uid);
      if (fileStatus) fileStatus.status = 'failed';
      onError();
    };

    xhr.send(formData);
  };

  uploadChunk();
};

// 处理队列,限制同时上传数量
const processQueue = () => {
  while (uploadQueue.length > 0 && currentUploads < maxConcurrentUploads) {
    const file = uploadQueue.shift();
    const fileStatus = uploadStatus.value.find((item) => item.uid === file.uid);
    if (fileStatus) fileStatus.status = 'uploading';

    customHttpRequest({
      action: 'https://your-upload-endpoint',
      file,
      onProgress: (event) => console.log('progress:', event.percent),
      onSuccess: () => ElMessage.success(`文件 ${file.name} 上传成功`),
      onError: () => ElMessage.error(`文件 ${file.name} 上传失败`),
    });
  }
};

// 开始上传
const startUpload = () => {
  processQueue();
};

// 初始化 Web Worker
onMounted(() => {
  worker = new Worker(new URL('./imageWorker.js', import.meta.url));
});

onUnmounted(() => {
  if (worker) worker.terminate();
});
</script>

<style>
.upload-item {
  display: flex;
  align-items: center;
  margin-top: 10px;
}
</style>
  • 断点续传:我们将每个文件按1MB大小分块上传。localStorage中记录上传进度,当网络中断或页面关闭后,可以从最后一次成功上传的分块继续。
  • customHttpRequest方法根据存储的进度决定从哪个分块开始上传。
  • 每完成一块上传,更新uploadedBytes并保存到localStorage中,以便下次继续上传。
  • 上传进度管理
  • uploadStatus数组用于跟踪每个文件的上传状态和进度。
  • 每次分块上传进度通过onProgress事件更新。
  • 通过el-progress展示每个文件的上传进度,更新UI。
  • 错误处理
  • 如果某个分块上传失败,标记为failed并展示在UI中,用户可以选择手动重试。

最后一步:增加错误重试机制,优化加载进度展示

  • 错误提示 :为每个文件记录错误信息并展示在UI上。
  • 错误分类和重试 :实现精细化的错误回调,通过retryCount控制每个文件的重试次数。如果超过最大重试次数,则提供手动重试选项。
  • 重试按钮 :在上传失败的文件上显示“重试”按钮,让用户在手动点击时可以重新上传该文件。
<template>
  <div>
    <el-upload
      ref="uploadRef"
      :http-request="customHttpRequest"
      :on-change="handleFileChange"
      :before-upload="beforeUpload"
      :file-list="fileList"
      multiple
      :auto-upload="false"
    >
      <el-button type="primary">批量上传图片</el-button>
    </el-upload>
    <div>
      <el-button type="primary" @click="startUpload">开始上传</el-button>
    </div>
    <div v-for="(file, index) in uploadStatus" :key="file.uid" class="upload-item">
      <span>{{ file.name }}</span>
      <el-progress :percentage="file.progress" v-if="file.status === 'uploading'" />
      <span v-if="file.status === 'completed'">上传完成</span>
      <span v-if="file.status === 'failed'" class="error-message">
        上传失败:{{ file.error }}&nbsp;
        <el-button type="text" @click="retryUpload(file)">重试</el-button>
      </span>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { ElMessage } from 'element-plus';

const fileList = ref([]);
const uploadStatus = ref([]);
const maxConcurrentUploads = 3;
const maxRetries = 3; // 最大重试次数
let uploadQueue = [];
let currentUploads = 0;
let worker;

const handleFileChange = (file) => {
  uploadQueue.push(file);
  uploadStatus.value.push({
    uid: file.uid,
    name: file.name,
    progress: 0,
    status: 'pending',
    error: null,
    retryCount: 0,
  });
};

const beforeUpload = (file) => {
  return new Promise((resolve) => {
    worker.postMessage({
      file,
      quality: 0.7,
      targetFormat: 'jpeg',
    });

    worker.onmessage = (e) => {
      const processedFile = e.data;
      resolve(processedFile);
    };
  });
};

// 自定义上传请求,限制并发数量,支持断点续传和错误处理
const customHttpRequest = (options) => {
  if (currentUploads >= maxConcurrentUploads) return;

  currentUploads++;
  const { file, onProgress, onSuccess, onError } = options;
  const storedProgress = localStorage.getItem(`upload-progress-${file.uid}`) || 0;
  let uploadedBytes = parseInt(storedProgress, 10);

  // 上传进度更新
  const updateProgress = (event) => {
    const progress = ((uploadedBytes + event.loaded) / file.size) * 100;
    const fileStatus = uploadStatus.value.find((item) => item.uid === file.uid);
    if (fileStatus) fileStatus.progress = progress;
    onProgress({ percent: progress });
    localStorage.setItem(`upload-progress-${file.uid}`, uploadedBytes + event.loaded);
  };

  // 自定义分块上传实现断点续传
  const chunkSize = 1024 * 1024; // 1MB的分块大小
  const totalChunks = Math.ceil(file.size / chunkSize);
  let currentChunk = Math.floor(uploadedBytes / chunkSize);

  const uploadChunk = () => {
    if (currentChunk >= totalChunks) {
      localStorage.removeItem(`upload-progress-${file.uid}`);
      currentUploads--;
      processQueue();
      onSuccess();
      const fileStatus = uploadStatus.value.find((item) => item.uid === file.uid);
      if (fileStatus) {
        fileStatus.status = 'completed';
        fileStatus.error = null;
      }
      return;
    }

    const start = currentChunk * chunkSize;
    const end = Math.min(start + chunkSize, file.size);
    const chunk = file.slice(start, end);
    const formData = new FormData();
    formData.append('file', chunk);
    formData.append('filename', file.name);
    formData.append('chunkNumber', currentChunk);
    formData.append('totalChunks', totalChunks);

    const xhr = new XMLHttpRequest();
    xhr.open('POST', options.action, true);

    xhr.upload.onprogress = updateProgress;
    xhr.onload = () => {
      uploadedBytes += chunk.size;
      currentChunk++;
      uploadChunk();
    };
    xhr.onerror = () => handleUploadError(file);
    xhr.send(formData);
  };

  uploadChunk();
};

// 处理上传错误,重试或记录错误
const handleUploadError = (file) => {
  const fileStatus = uploadStatus.value.find((item) => item.uid === file.uid);
  if (fileStatus.retryCount < maxRetries) {
    fileStatus.retryCount++;
    ElMessage.warning(`文件 ${file.name} 上传失败,重试第 ${fileStatus.retryCount} 次`);
    customHttpRequest({
      action: 'https://your-upload-endpoint',
      file,
      onProgress: (event) => console.log('progress:', event.percent),
      onSuccess: () => ElMessage.success(`文件 ${file.name} 上传成功`),
      onError: () => handleUploadError(file),
    });
  } else {
    fileStatus.status = 'failed';
    fileStatus.error = '网络错误或服务器问题,上传失败';
    ElMessage.error(`文件 ${file.name} 上传失败,请检查网络或稍后重试`);
  }
};

// 重试上传
const retryUpload = (file) => {
  const fileStatus = uploadStatus.value.find((item) => item.uid === file.uid);
  fileStatus.retryCount = 0;
  fileStatus.status = 'uploading';
  fileStatus.error = null;
  customHttpRequest({
    action: 'https://your-upload-endpoint',
    file,
    onProgress: (event) => console.log('progress:', event.percent),
    onSuccess: () => ElMessage.success(`文件 ${file.name} 上传成功`),
    onError: () => handleUploadError(file),
  });
};

// 处理队列,限制同时上传数量
const processQueue = () => {
  while (uploadQueue.length > 0 && currentUploads < maxConcurrentUploads) {
    const file = uploadQueue.shift();
    const fileStatus = uploadStatus.value.find((item) => item.uid === file.uid);
    if (fileStatus) fileStatus.status = 'uploading';

    customHttpRequest({
      action: 'https://your-upload-endpoint',
      file,
      onProgress: (event) => console.log('progress:', event.percent),
      onSuccess: () => ElMessage.success(`文件 ${file.name} 上传成功`),
      onError: () => handleUploadError(file),
    });
  }
};

// 开始上传
const startUpload = () => {
  processQueue();
};

// 初始化 Web Worker
onMounted(() => {
  worker = new Worker(new URL('./imageWorker.js', import.meta.url));
});

onUnmounted(() => {
  if (worker) worker.terminate();
});
</script>

<style>
.upload-item {
  display: flex;
  align-items: center;
  margin-top: 10px;
}
.error-message {
  color: red;
  font-weight: bold;
}
</style>
  1. 错误处理和重试逻辑
  • handleUploadError 方法对上传失败的文件进行错误处理。若retryCount小于maxRetries,则自动重试。超过最大重试次数时,将文件状态更新为failed并记录错误信息。
  1. 重试按钮
  • 在文件状态为failed时,显示“重试”按钮,用户可手动点击重新上传。retryUpload 方法重置重试次数,并重新调用customHttpRequest来重新上传失败文件。
  1. 上传进度和状态管理
  • 每个文件的状态在uploadStatus中管理,状态包括pendinguploadingcompletedfailed,UI根据状态更新显示。
  1. 精细的错误信息展示
  • 每个文件错误类型独立记录并展示在UI中,避免因多个文件错误导致混淆。这样可以更好地支持批量上传过程中各个文件的精细管理、进度监控以及错误处理。

经过上述处理,是不是就能更好的实现文章开头中的图片批量上传需求。

WX20250607-172202@2x.png

❌
❌