上传图片组件完整实现笔记

0 阅读9分钟

一、业务场景

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 关键设计点

  1. 隐藏原生 input:完全由代码手动控制触发,el-upload 只负责展示和预览。
  2. @click.stop 拦截:自定义触发区域上的 @click.stop 阻止 el-upload 自身弹出文件选择框。
  3. 先校验再生成预览链接:校验通过后才用 URL.createObjectURL 生成 Blob URL,不浪费资源。
  4. innerFileList 副本:不直接修改 props,保证单向数据流,组件内部自由增删改,最后确认时才通知父组件。
  5. 区分新/旧文件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() 清理资源

为什么只传 rawFile 的项?

  • 用户通过组件新添加的图片:raw 是实际的 File 对象,需要真正上传到服务器。
  • 回显的已有图片:它们可能只有 url(远程地址),rawundefined,这些图片早就存在服务端,不需要重复上传。
  • 通过这个过滤,我们能精准地向父组件提供“本次新增”的文件列表。

3.3.6 关闭弹窗 —— 资源释放

无论是点击取消、确定、或者弹窗右上角关闭,最终都会走到 handleClose 方法: revokeBlobUrls 清理所有预览用的 Blob URL,避免内存泄漏;重置 innerFileList;通知父组件关闭弹窗。下次再打开时,组件会重新初始化,状态干净。

3.4 设计亮点总结

  1. 无闪动体验:校验前置,非法文件根本不进入视图。
  2. 统一处理单/多文件:通过 limit 参数动态决定添加逻辑(替换或追加),代码复用度高。
  3. 精准区分新旧图片:利用 raw 字段的存在性决定是否需要上传,避免重复提交。
  4. 内存安全:所有 Blob URL 在销毁前都通过 URL.revokeObjectURL 手动释放,无内存泄漏风险。
  5. 职责清晰:组件只负责文件选择、预览、初步校验;真正的上传动作由父组件在确认后自主完成。