大家好,我是王嗨皮,一名
主业前端,副业全栈的程序员,在这里我会分享关于我工作中涉及的前端/全栈的常用技术如果我的文章能让您有所收获,欢迎一键三连(评论,点赞,关注),感谢!
"你做过图片批量上传吗?说说你是怎么优化的。"
每次遇到这个面试问题,总不能尬答一句"用了XXX组件库"?大图卡带宽、多图传半天、网络一断从头来、并发一高服务器直接崩------这些坑你踩过几个?
这篇文章分享一下我的 Vue3 + Node.js 图片批量上传优化方案:从并发控制-> 压缩-> 分片-> 断点续传。
方案中使用的技术:
· 前端及依赖:vue3、vite、element-plus、axios
· 后端及依赖:node、express、cors、fs-extra、nodemon、multer
核心思路:
1.并发控制:队列机制 + 最大并发数,避免同时上传大量文件导致服务端卡顿。
2.图片压缩:通过Web Worker后台压缩,不阻塞主线程。
3.分块上传:大文件切割成 1MB 分片,降低单次请求压力,提升效率。
4.断点续传:存储已上传分块编号,意外中断后从断点继续,避免重复上传。
5.双重验证:本地记录 + 后端校验,防止脏数据(记录存在但文件丢失)。
6.实时进度:每个分块上传后更新进度条。
效果预览:

1.前端结构初始化
首先使用 vite 创建一个 vue3 项目并安装依赖插件和 element-plus 组件库,项目结构如下:
项目结构
📦upload_front
┣ 📂src
┃ ┣ 📂components
┃ ┃ ┗ 📜Home.vue
┃ ┣ 📜App.vue
┃ ┣ 📜main.js
┣ 📜index.html
┣ 📜package.json
┣ 📜vite.config.js
接下来,在 components/Home.vue 中初始化页面结构,同时定义数据和事件处理函数。Home.vue 代码如下:
Home.vue
<div>
<el-upload
ref="uploadRef"
multiple
accept="image/png,image/jpeg"
:before-upload="beforeUpload"
:http-request="customHttpRequest"
:on-change="handleFileChange"
:file-list="fileList"
: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 in uploadStatus" :key="file.uid" style="margin-top: 15px;">
<div style="margin-bottom: 5px;">
<span>{{ file.name }}</span>
<span v-if="file.status === 'success'" style="color: #67c23a; margin-left: 10px;">✓ 上传成功</span>
<span v-if="file.status === 'error'" style="color: #f56c6c; margin-left: 10px;">✗ 上传失败</span>
</div>
<el-progress
:percentage="Math.floor(file.progress)"
:status="file.status === 'success' ? 'success' : file.status === 'error' ? 'exception' : ''"
/>
</div>
</div>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
----- 定义数据 -----
const fileList = ref([]) // 文件列表
const uploadStatus = ref([]) // 上传状态列表
const uploadRef = ref(null) // 上传组件引用
let uploadQueue = [] // 上传队列
let currentUploads = 0 // 当前正在上传的数量
let worker = null // Worker实例
// 配置参数
const maxConcurrentUploads = 3 // 最大并发上传数
const maxSizeInMB = 10 // 最大文件大小(MB)
const CHUNK_SIZE = 1 * 1024 * 1024
// 事件处理函数
const handleFileChange = () => {}
const beforeUpload = () => {}
const customHttpRequest = () => {}
const startUpload = () => {}
<script>
简单解释一下比较重要的数据定义和事件处理函数:
uploadStatus: 为状态列表,收集要上传的图片数据。
uploadQueue: 上传队列,结合最大并发数控制上传数量。
currentUploads : 监听正在上传的图片数量,如果小于 maxConcurrentUploads 则继续执行上传队列中的图片上传。
CHUNK_SIZE:设置分块大小(MB)。
handleFileChange:用户选择文件时触发。
beforeUpload:用户上传前对文件的校验。
customHttpRequest:上传时自定义的处理事件逻辑。
2.定义辅助事件处理函数
前端初始化结构完成后,继续再编写两个辅助函数
1️⃣ getFileIdentifier: 去重判断,生成唯一的文件标识符,用于断点续传和分块管理。
javasscript
/**
* 生成文件的唯一标识(基于原始文件信息)
* 规则:文件名_文件大小_最后修改时间
* 即使压缩后,也使用原始文件信息生成ID,确保不同文件ID不同
*/
const getFileIdentifier = (file) => {
// 如果文件有原始信息(压缩后的文件),使用原始信息
const originalFile = file._originalFile || file
const safeName = originalFile.name.replace(/[^a-zA-Z0-9.-]/g, '_')
return `${safeName}_${originalFile.size}_${originalFile.lastModified}`
}
2️⃣ updateUploadProgress: 更新文件的上传进度和状态,用于显示进度条。
javasscript
const updateUploadProgress = (uid, progress, status) => {
const statusItem = uploadStatus.value.find(item => item.uid === uid)
if (statusItem) {
statusItem.progress = progress
statusItem.status = status
}
}
3.添加选中的图片至上传列表
在 handleFileChange 事件函数中将用户上传的图片添加到 uploadStatus 数组中。
javascript
const handleFileChange = (file, fileListData) => {
fileList.value = fileListData
// 检查判断是否已经添加过这个图片,如果id相同代表该图片已存在,直接忽略
const exists = uploadStatus.value.find(item => item.uid === file.uid)
if (!exists) {
uploadStatus.value.push({
uid: file.uid, // 图片ID
name: file.name, 图片名称
progress: 0, // 上传进度,初始化为0
status: 'pending' // 上传状态,默认值为pending
})
}
}
注意:在图片上传到数组之前,添加一个判断,如果该图片已存在则直接忽略,避免相同图片重复上传。
4.创建Web Worker独立线程压缩图片
在项目根目录下新建 utils 文件夹,然后创建一个 imageWorker.js 文件,代码如下:
imageWorkder.js
// imageWorker.js - 使用现代 Web Worker API
self.onmessage = async function (e) {
const { file, quality, targetFormat, taskId } = e.data;
try {
const compressedFile = await compressImage(file, quality, targetFormat);
// 返回时带上 taskId,确保消息对应正确
self.postMessage({
success: true,
file: compressedFile,
taskId: taskId // 关键:返回任务ID
})
} catch (error) {
self.postMessage({
success: false,
error: error.message,
taskId: taskId
})
}
}
const compressImage = async (file, quality, format) => {
// 使用 createImageBitmap 替代 new Image()
const imageBitmap = await createImageBitmap(file)
// 使用 OffscreenCanvas 替代 document.createElement('canvas')
const canvas = new OffscreenCanvas(imageBitmap.width, imageBitmap.height)
const ctx = canvas.getContext('2d')
// 绘制图片到离屏画布
ctx.drawImage(imageBitmap, 0, 0)
// 转换为 Blob,支持格式转换和质量压缩
const blob = await canvas.convertToBlob({
type: `image/${format}`,
quality: quality
})
// 将 Blob 转换为 File
const compressedFile = new File([blob], file.name, {
type: `image/${format}`,
lastModified: Date.now(),
});
// 释放 ImageBitmap 资源
imageBitmap.close();
return compressedFile;
}
imageWorker.js 利用 Web Worker 独立线程压缩上传图片,避免阻塞主线程,确保图片压缩期间页面流畅不卡顿。
需要注意的是:Web Worker中不要使用 new Image 或者 document.createElement这种API,要更换为 createImageBitmap 和 OffscreenCanvas
更多具体的操作,可以查询 Web Workers 可以使用的函数和类 文档。
完成 imageWorker.js后,将其导入页面中,并在 onMounted 初始化,在 onUnMounted 组件卸载时销毁 Worker 释放内存。
Home.vue
......
import { ref, onMounted, onUnmounted } from 'vue'
let worker = null
//组件挂载时初始化Worker
onMounted(() => {
worker = new Worker(new URL('@/utils/imageWorker.js', import.meta.url))
console.log('Worker 已初始化')
})
//组件卸载时清理Wokker
onUnmounted(() => {
if (worker) {
worker.terminate()
worker = null
console.log('Worker 已清理')
}
})
5.检查文件大小并压缩
接下来在 beforeUpload 事件处理函数中,校验上传图片大小并异步调用 Workder 实现图片压缩。
javascript
const beforeUpload = (file) => {
// 检查文件大小
const isUnderLimit = file.size / 1024 / 1024 < maxSizeInMB
if (!isUnderLimit) {
ElMessage.error(`文件大小不能超过${maxSizeInMB}MB`)
return false
}
// 所有图片都使用 Worker 压缩(无论大小)
if (worker) {
return new Promise((resolve, reject) => {
// 生成唯一任务ID,避免多个文件同时压缩时混乱
const taskId = `${file.name}_${file.size}_${Date.now()}_${Math.random()}`
const handleMessage = (event) => {
// 检查是否是当前任务的响应
if (event.data.taskId === taskId) {
worker.removeEventListener('message', handleMessage)
worker.removeEventListener('error', handleError)
if (event.data.success) {
// 压缩后的文件
const compressedFile = event.data.file
// 关键:给压缩后的文件添加原始文件信息
// 这样可以确保文件ID的唯一性和一致性
compressedFile._originalFile = {
name: file.name,
size: file.size,
lastModified: file.lastModified
}
console.log(`[${file.name}] 压缩完成: ${file.size} -> ${compressedFile.size} 字节`)
resolve(compressedFile)
} else {
console.error(`[${file.name}] 压缩失败:`, event.data.error)
reject(new Error(event.data.error))
}
}
}
const handleError = (error) => {
worker.removeEventListener('message', handleMessage)
worker.removeEventListener('error', handleError)
console.error(`[${file.name}] Worker 错误:`, error)
reject(error)
}
worker.addEventListener('message', handleMessage)
worker.addEventListener('error', handleError)
console.log(`[${file.name}] 开始压缩... (任务ID: ${taskId})`)
worker.postMessage({
file,
quality: 0.8, // 压缩质量 0.8
targetFormat: 'jpeg', // 目标格式 JPEG
taskId // 传递任务ID
})
})
// 如果 Worker 未初始化,直接通过
console.log(`[${file.name}] Worker 未初始化,不压缩`)
return true
}
简述一下几个关键点:
1️⃣ Promise + taskId 异步压缩,通过 taskId 匹配,并发压缩时区分文件,防止返回结果混乱。
2️⃣ 在压缩后的文件上附加 _originalFile,确保后续生成的 fileId 基于原始文件,保证后续实现断点续传时的一致性。
6.队列控制同时上传并发数
继续定义一个处理上传队列的事件函数,并在上传自定义事件 customHttpRequest 进行调用。
javascript
const customHttpRequest = (options) => {
const { file, onProgress, onError, onSuccess } = options
// 将文件和回调函数包装到队列中
uploadQueue.push({
file,
onProgress,
onError,
onSuccess,
uid: file.uid
})
// 开始处理队列
processQueue()
}
const processQueue = () => {
while (currentUploads < maxConcurrentUploads && uploadQueue.length > 0) {
const nextFile = uploadQueue.shift()
if (nextFile) {
......
}
}
}
processQueue 的作用控制最大上传并发数,如果当前上传数量小于最大并发数,则从待上传队列中取出下一个文件,填满并发数。在保证上传效率的同时尽量避免服务器压力过大。
7.分块上传并支持断点续传
我们再来定义一个事件处理函数,将上传的文件拆分成多个分块。
从而实现分块上传 + 断点续传 + 失败重试 + 文件合并的完整流程。
javascript
const uploadFileWithChunks = async (fileData) => {
currentUploads++
const file = fileData.file
// 获取文件名(可能是压缩后的文件,需要用原始文件名)
const fileName = file._originalFile ? file._originalFile.name : file.name
// 生成文件ID(使用原始文件信息)
const fileId = getFileIdentifier(file)
try {
// 计算总分块数
const totalChunks = Math.ceil(file.size / CHUNK_SIZE)
console.log(`[${fileName}] 总共 ${totalChunks} 块,每块 1MB`)
// 从 localStorage 读取已上传的分块(断点续传)
const progressKey = `upload_${fileId}`
let uploadedChunks = []
try {
const saved = localStorage.getItem(progressKey)
if (saved) {
uploadedChunks = JSON.parse(saved).chunks || []
}
} catch (e) {
console.log(`[${fileName}] 无断点记录,从头开始`)
}
// 检查是否所有分块都已上传(文件已完成)
if (uploadedChunks.length === totalChunks) {
try {
// 第二重验证:调用后端接口确认文件是否真实存在
await request.post('/merge-chunks', {
filename: fileName,
fileId: fileId,
totalChunks
})
// 文件确实存在,跳过重复上传
localStorage.removeItem(progressKey)
currentUploads--
processQueue()
fileData.onSuccess({ success: true })
updateUploadProgress(fileData.uid, 100, 'success')
ElMessage.success(`${fileName} 已上传(跳过重复上传)`)
return
} catch (error) {
// 合并失败,可能文件已被删除,清理记录重新上传
localStorage.removeItem(progressKey)
uploadedChunks = [] // 清空已上传记录,重新开始
}
}
// 逐个上传分块
for (let i = 0; i < totalChunks; i++) {
// 跳过已上传的分块
if (uploadedChunks.includes(i)) {
const progress = ((i + 1) / totalChunks) * 100
updateUploadProgress(fileData.uid, progress, 'uploading')
continue
}
// 切割分块
const start = i * CHUNK_SIZE
const end = Math.min(start + CHUNK_SIZE, file.size)
const chunk = file.slice(start, end)
// 准备上传数据
const formData = new FormData()
formData.append('chunk', chunk)
formData.append('filename', fileName) // 使用原始文件名
formData.append('fileId', fileId)
formData.append('chunkNumber', i)
formData.append('totalChunks', totalChunks)
// 上传分块(重试3次)
let success = false
for (let retry = 0; retry < 3 && !success; retry++) {
try {
await request.post('/upload-chunk', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
success = true
// 记录到 localStorage(用于断点续传)
uploadedChunks.push(i)
localStorage.setItem(progressKey, JSON.stringify({ chunks: uploadedChunks }))
// 更新进度条
const progress = ((i + 1) / totalChunks) * 100
updateUploadProgress(fileData.uid, progress, 'uploading')
fileData.onProgress({ percent: progress })
} catch (error) {
if (retry < 2) {
await new Promise(resolve => setTimeout(resolve, 1000))
} else {
throw new Error(`分块 ${i} 上传失败`)
}
}
}
}
// 🎯 所有分块上传完成后,立即清理 localStorage
// 这样可以避免:上传完成 → 刷新页面 → localStorage残留 → 重复上传
localStorage.removeItem(progressKey)
// 合并文件
console.log(`[${fileName}] 开始合并...`)
try {
const mergeResult = await request.post('/merge-chunks', {
filename: fileName, // 使用原始文件名
fileId: fileId,
totalChunks
})
// 检查是否是跳过重复上传
if (mergeResult.message && mergeResult.message.includes('跳过重复')) {
console.log(`[${fileName}] ⚠️ 后端检测到重复上传,已跳过`)
} else {
console.log(`[${fileName}] ✓ 合并完成`)
}
currentUploads--
processQueue()
fileData.onSuccess({ success: true })
updateUploadProgress(fileData.uid, 100, 'success')
ElMessage.success(`${fileName} 上传成功`)
} catch (mergeError) {
// 合并失败的特殊处理
const errorMsg = mergeError.message || ''
// 如果是"分块目录不存在",可能是临时文件被清理了
// 但所有分块已上传,视为成功(容错处理)
if (errorMsg.includes('分块目录不存在') || errorMsg.includes('不存在')) {
currentUploads--
processQueue()
fileData.onSuccess({ success: true })
updateUploadProgress(fileData.uid, 100, 'success')
ElMessage.success(`${fileName} 上传成功`)
} else {
// 其他合并错误,正常抛出
throw mergeError
}
}
} catch (error) {
console.error(`[${fileName}] ✗ 上传失败:`, error.message)
currentUploads--
processQueue()
fileData.onError(error)
updateUploadProgress(fileData.uid, 0, 'error')
ElMessage.error(`${fileName} 上传失败`)
}
}
关键技术节点:
1️⃣ 双重验证:localstroage 本地记录 + 后端 merge-chunks 接口验证,防止"脏数据"的产生。
2️⃣ 断点续传:localStorage.setItem(progressKey, JSON.stringify({ chunks: uploadedChunks })),每上传一个分块保存编号,等下次断点续传时方便精准对应编号,跳过重复模块以节省资源。
3️⃣ 失败重试:for 循环重试3次,提高上传成功率。
最后,给 "开始上传" 按钮绑定 startUpload点击事件,触发 <el-upload> 上传程序。
javascript
const startUpload = () => {
if (uploadRef.value) {
uploadRef.value.submit()
}
}
8.Node.js后端代码
受限于篇幅过长的原因,后端代码不再做详细阐述,主要逻辑已在源码中标记注释。
也可点击查看 前后端完整源码
Node.js
import express from 'express'
import cors from 'cors'
import path from 'path'
import fs from 'fs-extra'
import multer from 'multer'
import { fileURLToPath } from 'url'
import { dirname } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const app = express()
app.use(cors())
const upload_dir = path.resolve(__dirname, 'uploads')
fs.ensureDirSync(upload_dir)
// 配置静态文件访问,使 /uploads 路径可以访问到 uploads 文件夹
app.use('/uploads', express.static(upload_dir))
// 配置 multer 存储
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, upload_dir)
},
filename: function (req, file, cb) {
const ext = path.extname(file.originalname)
const basename = path.basename(file.originalname, ext)
cb(null, `${basename}-${Date.now()}${ext}`)
}
})
const upload = multer({ storage: storage })
app.use(express.json())
// 分块上传临时目录
const temp_dir = path.resolve(__dirname, 'temp_chunks')
fs.ensureDirSync(temp_dir)
// 已完成上传的标记目录(避免重复上传)
// 注意:放在 temp_chunks 外部,避免被清理
const completed_dir = path.resolve(__dirname, 'upload_completed')
fs.ensureDirSync(completed_dir)
console.log(`完成标记目录: ${completed_dir}`)
// 上传单个分块(简化版)
app.post('/upload-chunk', upload.single('chunk'), (req, res) => {
try {
const { filename, fileId, chunkNumber, totalChunks } = req.body
if (!req.file) {
return res.status(400).json({
success: false,
message: '未接收到分块文件'
})
}
// 使用 fileId 创建分块目录
const chunkDir = path.resolve(temp_dir, fileId)
fs.ensureDirSync(chunkDir)
// 保存分块文件
const chunkPath = path.resolve(chunkDir, `chunk-${chunkNumber}`)
fs.moveSync(req.file.path, chunkPath, { overwrite: true })
console.log(`已接收: ${filename} 分块 ${chunkNumber}/${totalChunks}`)
res.json({
success: true,
message: `分块 ${chunkNumber} 上传成功`
})
} catch (error) {
console.error(`分块上传失败: ${error.message}`)
res.status(500).json({
success: false,
message: '分块上传失败',
error: error.message
})
}
})
// 合并分块(简化版)
app.post('/merge-chunks', async (req, res) => {
try {
const { filename, fileId, totalChunks } = req.body
const chunkDir = path.resolve(temp_dir, fileId)
const completedFlag = path.resolve(completed_dir, fileId)
console.log(`开始合并: ${filename} (共 ${totalChunks} 块)`)
console.log(`分块目录: ${chunkDir}`)
console.log(`完成标记: ${completedFlag}`)
// 🎯 优先检查:该文件是否已经完成过(避免重复上传)
if (fs.existsSync(completedFlag)) {
try {
const savedFilename = await fs.readFile(completedFlag, 'utf-8')
const actualFilePath = path.resolve(upload_dir, savedFilename)
// 🔍 关键检查:验证实际文件是否真实存在
if (fs.existsSync(actualFilePath)) {
console.log(`✓ 文件 ${filename} 已存在,跳过重复上传`)
console.log(` 实际文件: ${savedFilename}`)
return res.json({
success: true,
message: '文件已上传(跳过重复)',
filename: savedFilename
})
} else {
// ⚠️ 标记文件存在,但实际文件不存在(可能被删除)
console.log(`⚠️ 标记存在但文件不存在: ${savedFilename}`)
console.log(` 删除无效标记,重新上传`)
await fs.remove(completedFlag)
// 继续执行后续的合并流程
}
} catch (readError) {
console.error(`读取完成标记失败: ${readError.message}`)
// 如果读取失败,删除损坏的标记文件,继续合并流程
await fs.remove(completedFlag)
}
}
// 检查分块目录是否存在
if (!fs.existsSync(chunkDir)) {
console.log(`❌ 分块目录不存在: ${fileId}`)
return res.status(400).json({
success: false,
message: '分块目录不存在,请重新上传'
})
}
// 生成最终文件名
const ext = path.extname(filename)
const basename = path.basename(filename, ext)
const finalFileName = `${basename}-${Date.now()}${ext}`
const finalFilePath = path.resolve(upload_dir, finalFileName)
// 创建写入流,按顺序合并分块
const writeStream = fs.createWriteStream(finalFilePath)
for (let i = 0; i < totalChunks; i++) {
const chunkPath = path.resolve(chunkDir, `chunk-${i}`)
// 检查分块是否存在
if (!fs.existsSync(chunkPath)) {
writeStream.close()
return res.status(400).json({
success: false,
message: `分块 ${i} 不存在,请重新上传`
})
}
// 读取并写入分块
const chunkBuffer = await fs.readFile(chunkPath)
writeStream.write(chunkBuffer)
}
writeStream.end()
// 等待写入完成
await new Promise((resolve, reject) => {
writeStream.on('finish', resolve)
writeStream.on('error', reject)
})
// 删除临时分块目录
await fs.remove(chunkDir)
// 🎯 创建完成标记(保存最终文件名,避免重复上传)
try {
// 确保 .completed 目录存在
await fs.ensureDir(completed_dir)
await fs.writeFile(completedFlag, finalFileName, 'utf-8')
console.log(`✓ 创建完成标记: ${fileId}`)
} catch (flagError) {
// 标记创建失败不影响主流程,仅记录日志
console.error(`⚠️ 创建完成标记失败: ${flagError.message}`)
}
console.log(`合并成功: ${finalFileName}`)
res.json({
success: true,
message: '文件上传成功',
filename: finalFileName
})
} catch (error) {
console.error(`文件合并失败: ${error.message}`)
res.status(500).json({
success: false,
message: '文件合并失败',
error: error.message
})
}
})
// 上传图片文件接口(保留原有接口)
app.post('/upload', upload.single('file'), (req, res) => {
if (!req.file) {
return res.status(400).json({
success: false,
message: 'No file uploaded'
})
}
res.json({
success: true,
message: 'File uploaded successfully',
filePath: req.file.path,
filename: req.file.filename
})
})
app.listen(3000, () => {
console.log('Server is running on port 3000')
})
如果我的文章能让您有所收获,欢迎一键三连(评论,点赞,关注),感谢!