一、业务场景
1.1 需求背景
- 实现一个图片上传弹窗组件
- 支持单文件上传(如头像,
limit=1)和多文件上传(如商品多图,limit>1) - 关闭自动上传,用户点击“确定”后才真正提交文件给后端
- 支持预览已添加的图片(点击可查看大图)
- 支持删除不需要的图片
- 支持回显已有图片(编辑场景)
1.2 核心痛点
el-upload 在 auto-upload="false" 模式下,无法实现“添加前校验”
// ❌ 传统做法:先添加 → 再校验 → 校验失败再删除
:on-change="(file, fileList) => {
// 文件已经显示在列表里了
if (!validate(file)) {
// 需要从列表删除
// ⚠️ 页面会闪一下:出现 → 消失
}
}"
用户体验问题: 文件先出现在列表,瞬间又被删除,视觉闪动严重。
二、完整代码实现
2.1 上传弹窗组件(upload.vue)
<script setup>
import { ref, watch } from 'vue'
import { ElMessage, genFileId } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
// ==================== Props & Emits ====================
const props = defineProps({
visible: Boolean, // 弹窗显示状态
title: String, // 弹窗标题
limit: { // 上传数量限制:1=单文件,>1=多文件,Infinity=无限制
type: Number,
default: Infinity
},
fileList: Array, // 父组件传入的已有文件列表(用于回显)
disabled: { // 禁用状态:禁止上传和提交
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:visible', 'confirm'])
// ==================== 内部状态 ====================
const innerFileList = ref([]) // 内部维护的文件列表(不直接修改 props)
const uploadRef = ref() // el-upload 实例引用
const inputRef = ref(null) // 隐藏 input 的引用
// ==================== Blob URL 管理 ====================
/**
* 清理 Blob URL(释放内存)
* @param {Array} files - 需要清理的文件列表
*/
function revokeBlobUrls(files) {
files.forEach(file => {
if (file.url && file.url.startsWith('blob:')) {
URL.revokeObjectURL(file.url)
}
})
}
/**
* 安全地替换列表(先清理旧 URL,再赋值新列表)
* 用于:单文件模式替换、初始化回显
* @param {Array} newList - 新的文件列表
*/
function safeReplaceFileList(newList) {
revokeBlobUrls(innerFileList.value) // 清理旧的
innerFileList.value = newList // 赋值新的
}
// ==================== 初始化逻辑(回显)====================
/**
* 打开弹窗时,从 props.fileList 初始化内部列表
*/
function initFileList() {
const formattedList = (props.fileList ?? [])
.filter(item => item.url) // 只保留有 url 的项
.map((item) => ({
uid: item.uid || genFileId(),
name: item.name || 'image.png',
url: item.url,
raw: item.raw instanceof File ? item.raw : undefined
}))
safeReplaceFileList(formattedList)
}
// 监听弹窗打开,初始化列表
watch(() => props.visible, (newVal) => {
if (newVal) {
initFileList()
}
})
// ==================== 文件选择流程(核心)====================
/**
* 手动触发文件选择(绕过 el-upload 默认行为)
*/
function triggerUpload() {
if (inputRef.value && !props.disabled) {
inputRef.value.click()
}
}
/**
* 处理 input change 事件(用户选好文件后)
* @param {Event} e - input change 事件
*/
function handleFileSelect(e) {
processFiles(e.target.files)
e.target.value = '' // 清空,允许重复选同一文件
}
/**
* 批量处理选中的文件
* 流程:校验 → 构造对象 → 添加到列表
* @param {FileList} files - 用户选择的文件列表
*/
function processFiles(files) {
if (!files || files.length === 0) return
for (let i = 0; i < files.length; i++) {
const rawFile = files[i]
// 1️⃣ 先校验,失败直接跳过(不会添加到列表,无闪动)
if (!validateFile(rawFile)) continue
// 2️⃣ 校验通过,构造对象(生成 Blob URL 用于预览)
const uploadFile = createUploadFile(rawFile)
// 3️⃣ 添加到列表
const success = addFileToList(uploadFile)
if (!success) break // 超限则停止
// 单文件模式,添加一个就结束
if (props.limit === 1) break
}
}
// ==================== 校验逻辑(可扩展)====================
/**
* 通用文件校验
* @param {File} rawFile - 原始文件对象
* @returns {boolean} - 校验是否通过
*/
function validateFile(rawFile) {
// 类型检查
const allowTypes = ['image/png', 'image/jpeg']
if (!allowTypes.includes(rawFile.type)) {
ElMessage.error('上传的文件必须是 png 或者 jpg 图片!')
return false
}
// 大小检查(5MB)
if (rawFile.size / 1024 / 1024 > 5) {
ElMessage.error('文件大小超过5MB,请使用微信截图保存后重新上传')
return false
}
return true
}
// ==================== 构造文件对象 ====================
/**
* 构造标准文件对象(生成 Blob URL 用于预览)
* @param {File} rawFile - 原始文件对象
* @returns {Object} - 标准文件对象 { uid, name, url, raw }
*/
function createUploadFile(rawFile) {
return {
uid: genFileId(),
name: rawFile.name,
url: URL.createObjectURL(rawFile), // ⚠️ 这里只是预览链接,还没上传
raw: rawFile
}
}
// ==================== 添加到列表(区分单/多文件模式)====================
/**
* 添加单个文件到列表
* @param {Object} uploadFile - 标准文件对象
* @returns {boolean} - 是否添加成功
*/
function addFileToList(uploadFile) {
// 单文件模式:直接替换
if (props.limit === 1) {
safeReplaceFileList([uploadFile])
return true
}
// 多文件模式:检查是否超限
if (props.limit > 0 && innerFileList.value.length >= props.limit) {
ElMessage.warning(`最多只能上传 ${props.limit} 个文件`)
return false
}
// 追加到列表
innerFileList.value.push(uploadFile)
return true
}
// ==================== 删除和预览(复用 el-upload 功能)====================
/**
* 删除文件(el-upload 的 on-remove 回调)
* @param {Object} file - 被删除的文件
* @param {Array} fileList - 删除后的新列表
*/
function handleRemove(file, fileList) {
revokeBlobUrls([file]) // 清理被删文件的 URL
innerFileList.value = fileList // 使用 el-upload 传来的新列表
}
/**
* 预览文件(el-upload 的 on-preview 回调)
* @param {Object} file - 要预览的文件
*/
function handlePreview(file) {
window.open(file.url, '_blank')
}
// ==================== 确认和关闭 ====================
/**
* 确认提交(此时才真正上传)
* 把整个列表交给父组件,父组件决定怎么上传
*/
function confirmDialog() {
// 提取出所有包含 File 对象的项(即新上传的文件)
const newFiles = innerFileList.value
.filter(f => f?.raw instanceof File)
.map(f => f?.raw)
emit('confirm', newFiles)
handleClose() // 复用关闭逻辑
}
/**
* 关闭弹窗(统一入口)
* 清理资源 + 重置状态 + 通知父组件
*/
function handleClose() {
revokeBlobUrls(innerFileList.value) // 清理所有 Blob URL
innerFileList.value = []
emit('update:visible', false)
}
</script>
<template>
<el-dialog
:model-value="visible"
draggable
:title="title"
width="600px"
@close="handleClose"
>
<!-- ✅ 隐藏的 input,自己控制什么时候触发 -->
<input
ref="inputRef"
type="file"
accept=".jpg,.jpeg,.png"
:multiple="limit > 1"
style="display: none"
@change="handleFileSelect"
/>
<!-- ✅ el-upload 只负责展示和预览,不负责选择 -->
<el-upload
ref="uploadRef"
:file-list="innerFileList"
class="ImageSmall-uploader"
list-type="picture-card"
:on-preview="handlePreview"
:on-remove="handleRemove"
:disabled="disabled"
>
<!-- ✅ 自定义触发器,阻止 el-upload 默认行为 -->
<div class="custom-upload-trigger" @click.stop="triggerUpload">
<el-icon><Plus /></el-icon>
<span>点击上传</span>
</div>
</el-upload>
<template #footer>
<div style="display: flex; justify-content: flex-end">
<el-button @click="handleClose">取消</el-button>
<el-button
type="primary"
:disabled="disabled"
@click="confirmDialog"
>
确定
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped>
/* 上传容器布局 */
.ImageSmall-uploader {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
/* 覆盖 el-upload 默认上传按钮样式 */
.ImageSmall-uploader :deep(.el-upload--picture-card) {
width: 178px;
height: 178px;
border: 1px dashed var(--el-border-color);
border-radius: 6px;
display: inline-flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: pointer;
transition: var(--el-transition-duration-fast);
color: #8c939d;
}
.ImageSmall-uploader :deep(.el-upload--picture-card:hover) {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
/* 自定义触发器内容布局 */
.custom-upload-trigger {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.custom-upload-trigger .el-icon {
font-size: 28px;
margin-bottom: 8px;
}
/* 文件列表项尺寸 */
:deep(.el-upload-list--picture-card .el-upload-list__item) {
width: 178px;
height: 178px;
}
</style>
2.2 父组件使用示例(App.vue)
<script setup>
import { ref } from 'vue'
import UploadComponent from './components/upload.vue'
const showDialog = ref(false)
// 初始回显的文件列表(模拟后端数据)
const fileList = ref([
{
url: 'https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg',
name: '示例图片1.jpg'
}
])
// ✅ 点击确定后,这里才真正上传
const handleConfirm = (files) => {
console.log('提交的文件:', files)
if (files.length > 0) {
console.log('这是新上传的文件,需要上传到服务器')
uploadFilesToServer(files)
} else {
console.log('没有新文件,只有回显的已有文件')
}
}
async function uploadFilesToServer(files) {
const formData = new FormData()
files.forEach(file => formData.append('files', file))
// TODO: 调用后端接口上传
// await fetch('/api/upload', { method: 'POST', body: formData })
}
</script>
<template>
<div style="padding: 20px;">
<el-button type="primary" @click="showDialog = true">
打开上传对话框
</el-button>
<p style="margin-top: 20px;">当前文件数量:{{ fileList.length }}</p>
<!-- 单文件模式示例 -->
<UploadComponent
v-model:visible="showDialog"
title="图片"
:limit="1"
:file-list="fileList"
@confirm="handleConfirm"
/>
</div>
</template>
三、设计思路与核心流程解析
3.1 整体思路
完全接管文件选择流程,实现“添加前校验”
传统用法中,el-upload 直接弹出文件框并直接把文件加入列表,加入后再校验就会导致“闪动”。我们的方案是用一个隐藏的原生 input 替代 el-upload 的文件选择,整个流程变成:
用户点击触发器 → 手动触发隐藏 input → 校验 → 通过才添加到 innerFileList → el-upload 展示
校验失败的压根不进入列表,从源头解决闪动问题。
3.2 关键设计点
- 隐藏原生 input:完全由代码手动控制触发,el-upload 只负责展示和预览。
@click.stop拦截:自定义触发区域上的@click.stop阻止 el-upload 自身弹出文件选择框。- 先校验再生成预览链接:校验通过后才用
URL.createObjectURL生成 Blob URL,不浪费资源。 innerFileList副本:不直接修改 props,保证单向数据流,组件内部自由增删改,最后确认时才通知父组件。- 区分新/旧文件:
raw字段存储原始File对象。通过本地上传的文件一定有raw,回显的远程图片则可能没有raw。提交时只提取有raw的项,避免重复上传已存在的图片。
3.3 详细流程拆解
3.3.1 弹窗初始化 —— 回显已有图片
父组件改变 visible → watch 触发 → initFileList()
→ 过滤出有 url 的项,格式化为 {uid, name, url, raw?}
→ safeReplaceFileList 替换内部列表(先清理旧的 Blob URL)
raw 字段只有在父组件传入的对象中明确是 File 实例时才会保留,否则为 undefined。这样内部列表就能区分“已有图片”和“新添加的图片”。
3.3.2 触发文件选择 —— 绕过 el-upload
点击“点击上传”区域 → @click.stop 阻止冒泡 → triggerUpload()
→ 找到隐藏 inputRef → .click() → 系统文件对话框弹出
el-upload 默认会在整个容器上监听点击,我们不想要它的默认行为,因此在自己的触发 div 上 @click.stop 阻断事件,不让 el-upload 处理。
3.3.3 文件处理流水线 —— 核心
用户选择文件 → input change 事件 → handleFileSelect → processFiles
循环每个文件:
① validateFile(rawFile) // 校验类型、大小
不通过 → ElMessage 提示,continue 跳过,文件不会进入可视列表
② createUploadFile(rawFile) // 生成 { uid, name, url: blob, raw }
③ addFileToList(uploadFile)
单文件模式 → safeReplaceFileList 直接替换
多文件模式 → 检查数量限制,push 或提示超限
这一步是“无闪动”的关键:校验未通过的文件根本没有机会进入 innerFileList,也就不会出现在 el-upload 显示的图片列表中。
3.3.4 预览与删除 —— 利用 el-upload 已有能力
- 预览:
on-preview直接调用window.open(file.url),url可能是远程地址或 Blob URL。 - 删除:
on-remove回调中,先revokeBlobUrls释放被删除文件的 Blob URL,然后更新内部列表为 el-upload 传来的新列表。
因为 el-upload 在 picture-card 模式下已经提供了点击预览、删除按钮等 UI,我们只需补充资源清理即可。
3.3.5 确认提交 —— 真正上传
点击“确定” → confirmDialog()
→ innerFileList 中过滤出 raw instanceof File 的项
→ 提取它们的 raw 对象(真实 File)
→ emit('confirm', files) 传给父组件
→ 同时调用 handleClose() 清理资源
为什么只传 raw 是 File 的项?
- 用户通过组件新添加的图片:
raw是实际的File对象,需要真正上传到服务器。 - 回显的已有图片:它们可能只有
url(远程地址),raw为undefined,这些图片早就存在服务端,不需要重复上传。 - 通过这个过滤,我们能精准地向父组件提供“本次新增”的文件列表。
3.3.6 关闭弹窗 —— 资源释放
无论是点击取消、确定、或者弹窗右上角关闭,最终都会走到 handleClose 方法: revokeBlobUrls 清理所有预览用的 Blob URL,避免内存泄漏;重置 innerFileList;通知父组件关闭弹窗。下次再打开时,组件会重新初始化,状态干净。
3.4 设计亮点总结
- 无闪动体验:校验前置,非法文件根本不进入视图。
- 统一处理单/多文件:通过
limit参数动态决定添加逻辑(替换或追加),代码复用度高。 - 精准区分新旧图片:利用
raw字段的存在性决定是否需要上传,避免重复提交。 - 内存安全:所有 Blob URL 在销毁前都通过
URL.revokeObjectURL手动释放,无内存泄漏风险。 - 职责清晰:组件只负责文件选择、预览、初步校验;真正的上传动作由父组件在确认后自主完成。