☰
Vant Uploader删除图标自定义:插槽机制、事件链路与实战踩坑
2026/9/26 18:01:16 网站建设 项目流程

上个项目里,UI 验收的时候设计师给了一张截图,上传组件预览图右上角的删除按钮必须是一个红色圆底白色垃圾桶,还带一点投影。我看了下 Vant 默认的样子,黑色半透明底、白色叉号,做得倒是规整,可和设计稿就是两套语言。当时第一反应是拿 CSS 强行覆盖,点开开发者工具一看,这个删除图标是组件内部渲染的<van-icon name="clear" />,样式是写死在.van-uploader__preview-delete上的。硬改确实能改,但要是以后想换成文字、换成图片、或者想按文件状态显示不同的图标,CSS 方案就完全崩了。

后来翻了 Vant 的文档,发现 van-uploader 原生提供了preview-delete插槽,就是专门用来替换这个删除图标的。这篇文章就从插槽的底层机制讲起,把用法、事件链路、以及我在真机调试里踩过的几个坑完整捋一遍,给同样被 UI 细节折磨的朋友一份能直接抄的作业。

1. 为什么默认删除图标满足不了真实需求:一个典型的 UI 验收现场

1.1 默认删除图标的真实渲染逻辑

Vant 的 uploader 里,删除按钮并不是一个孤立的元素,它被渲染在预览图的容器内部,位置是绝对定位到右上角。默认结构大概是这样的:

<div class="van-uploader__preview"> <img class="van-uploader__preview-image" src="..." /> <div class="van-uploader__preview-delete" @click.stop="onDelete"> <van-icon name="clear" size="18" color="#fff" /> </div> </div>

默认的.van-uploader__preview-delete宽高是18px,背景是rgba(0, 0, 0, 0.45),左下角还带一个12px的圆角。也就是说,视觉上是一个右上角的小黑方块,里面放了一个白色的叉号图标。

这个设计在样式上是干净的,但放到真实项目里就很受限制。设计稿里常见的删除按钮至少有这几种:红色圆形背景加白色垃圾桶、纯文字"删除"、某个运营位的定制图标、甚至有的需求要求删除按钮悬浮在图片边缘之外,露出一半来。这些光靠改默认图标的颜色和尺寸,做起来很别扭。

1.2 用 CSS 覆盖为什么是条死路

你可能会想,既然只是图标不一样,那我把.van-uploader__preview-delete里的内容换掉不就行了?问题在于,Vant 的样式是组件内部编译出来的,默认的<van-icon name="clear" />是写死在组件模板里的。你没法通过 CSS 把name="clear"这个图标改成name="delete",因为图标字体的字符编码是固定的。你最多改改背景色、圆角、尺寸,改不了图标本身。

还有一种偏方是设置display: none隐藏默认删除图标,然后在外面自己定位一个新按钮盖上去。这个方案能出效果,但位置需要手动对齐,而且新按钮和 Vue 组件内部的状态完全隔离,比如图片上传失败时的重试状态、多选时的遮罩层,这些细节都容易对不上。所以 Vant 官方保留插槽是有道理的,替换组件的某一部分内容,插槽才是标准答案。

1.3 插槽才是标准答案

Vant 从 3.x 开始,在 uploader 上预留了preview-delete这个插槽。使用方式很简单:

<van-uploader v-model="fileList"> <template #preview-delete="{ file }"> <!-- 这里放你自定义的图标,完全按自己的设计来 --> </template> </van-uploader>

一旦写了这个插槽,Vant 默认的白色叉号就不会再渲染了,你的自定义内容会出现在原来的删除按钮位置上。而且因为内容的宿主是组件内部那个preview-delete容器,它的点击事件链路、before-delete校验、文件索引的传递都是通的,不需要你额外去调方法。

2. preview-delete 插槽的底层工作机制:组件到底往里画了什么

2.1 用一个生活类比理解"插槽"

很多新手朋友一听"插槽"就觉得玄乎,其实可以把它理解成电脑主板上的 PCI-E 插槽。主板上预留了一个物理接口,默认可能插着一张网卡,你可以拔掉它,换上显卡、声卡、扩展卡。组件里的插槽也是这个逻辑:组件内部预留了一个渲染位置,默认放一份内容,你可以把默认内容拿走,换一份自己的内容上去,组件本身的供电和通信能力还是照常工作。

preview-delete这个插槽就是 emmm,可以理解成主板右上角专门给"删除功能"预留的那个 x16 插槽。

2.2 Vant 源码里这个插槽的渲染位置

为了搞明白自定义内容会不会影响点击和删除,我专门去翻了一下 Vant 4 的 uploader 源码。在UploaderPreviewItem.vue里面,渲染逻辑大致是下面这样的:

<div class="van-uploader__preview" @click="onPreview"> <van-image v-if="!isVideo" :src="file.url" class="van-uploader__preview-image" /> <video v-else ... /> <div v-if="deletable" class="van-uploader__preview-delete" @click.stop="onDelete" > <slot name="preview-delete" :file="file"> <van-icon name="clear" size="18" color="#fff" /> </slot> </div> <div v-if="status" class="van-uploader__mask" ... /> </div>

几个关键信息:

  • 删除按钮的外层容器是.van-uploader__preview-delete,它绑定了@click.stop="onDelete"。
  • 插槽的默认内容是那个白色叉号图标,你只要提供了插槽内容,默认的 icon 就不会渲染。
  • 插槽作用域里至少能拿到file对象,这个对象里带了url、status、message、uid等信息。

也就是说,自定义内容无论长什么样,点击时都会先走外层容器的onDelete,这个事件内部会处理删除逻辑,不会冒泡到预览图导致打开预览。

2.3 插槽作用域里的 file 对象能干什么

file对象是 Vant 内部维护的文件描述,字段大致包括:

字段说明常见用途
uid文件唯一标识定位到具体文件
url文件地址预览图用
status上传状态:uploading/done/failed根据状态显示不同图标
message错误信息上传失败时提示
deletable单个文件是否可删除控制删除按钮显隐
isImage是否为图片区分图片和视频
isVideo是否为视频同上

比如我想让"上传失败"的文件显示一个重试图标,而不是删除图标,就可以在插槽里用file.status === 'failed'来判断。注意一点:这里说的"显示什么图标"只是视觉变化,点击事件依然走的是删除逻辑,真正的重试动作需要在别的环节另做处理,这个我后面会细说。

3. 动手实现自定义删除图标:从一行替换到完整交互

3.1 最小可用版本:换成 Vant 自带的垃圾桶图标

如果你的设计稿只是不想要那个叉号,想换成垃圾桶,可以这么写:

<van-uploader v-model="fileList" :max-count="6"> <template #preview-delete> <van-icon name="delete" size="18" color="#fff" /> </template> </van-uploader>

这里只用到了 Vant 图标库里的delete图标。默认的外层容器依然保留rgba(0,0,0,0.45)的黑色半透明背景,白色垃圾桶放上去,视觉上就是黑底白垃圾桶,大部分浅色调设计稿都能接受。

3.2 换成设计稿里的红色圆底垃圾桶

接下来是真正的常见需求:删除按钮是红色圆底、白色垃圾桶,可能还带一个微小的投影。代码是这样:

<van-uploader v-model="fileList" :max-count="9"> <template #preview-delete> <span class="custom-delete-btn"> <van-icon name="delete" size="14" color="#fff" /> </span> </template> </van-uploader>

对应的样式:

.custom-delete-btn { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: 50%; background: #ee0a24; box-shadow: 0 2px 6px rgba(238, 10, 36, 0.4); transform: translate(-2px, 2px); } :deep(.van-uploader__preview-delete) { background: transparent; border-radius: 0; width: 24px; height: 24px; }

注意这里的两处深调整:

  • 外层容器.van-uploader__preview-delete默认是18px × 18px,如果你的自定义按钮比它大,就会溢出造成点击区域不准。我习惯把外层容器调成和自定义内容差不多大的尺寸,然后把自带的背景去掉。
  • 默认容器是绝对定位在top: 0; right: 0的,如果你的设计稿要求按钮悬浮在图片之外,可以再配合transform微调。

3.3 换成图片或文字删除按钮

有些时候设计稿里用的不是图标库的图标,而是一张 PNG、一个 Base64 图片,或者干脆是"删除"两个字。这些情况插槽也都支持:

<template #preview-delete> <img class="custom-delete-img" src="https://your-cdn.com/delete-btn.png" alt="删除" /> </template> <template #preview-delete> <span class="custom-delete-text">删除</span> </template>

图片方案要注意:自定义图片一定要显式设置宽高,否则在小程序环境和部分 WebView 里可能出现无法预料的尺寸渲染问题。文字方案要注意:外层容器默认面积不大,文字稍微长一点就溢出,需要同步调整外层容器的宽高和文字字号。

3.4 根据文件状态显示不同内容

这个是我自己项目里用得最多的场景。文件上传中显示一个灰圈加载,上传失败显示一个红色删除按钮,上传成功显示普通的垃圾桶:

<template #preview-delete="{ file }"> <span class="custom-delete-btn" :class="{ 'custom-delete-btn--danger': file.status === 'failed', 'custom-delete-btn--loading': file.status === 'uploading' }" > <van-icon :name="file.status === 'uploading' ? 'replay' : 'delete'" size="14" color="#fff" /> </span> </template>

这里演示的是"视觉根据状态变化",但你要清楚一点:这几种状态下点击删除按钮,组件内部的删除逻辑是一样的。如果上传失败时需要触发重传而不是删除,就不要依赖这个插槽的点击链路了,应该考虑把组件整体的上传流程重新设计,或者在文件状态变化时用别的交互入口去承接重传动作。把"视觉切换"和"业务逻辑"混在一起,是这个需求里最容易踩的坑。

4. 点击事件与删除逻辑:自定义内容到底要不要自己绑事件

4.1 源码层面确认:不用自己绑删除事件

很多开发者在自定义插槽时习惯性地给内容加上@click来处理删除,结果发现删除了两次。这里我从源码角度明确说一次:不需要。

组件内部.van-uploader__preview-delete这个容器本身已经绑定了@click.stop="onDelete"。你写的自定义内容是这个容器的子节点,点击子节点会冒泡到容器上,由 Vant 内部的onDelete统一处理。这个内部处理逻辑会完成三件事:

  1. 校验该文件是否满足删除条件(如果配置了before-delete,会先执行它)。
  2. 从fileList中移除当前文件。
  3. 触发@delete事件,让你在父组件里可以拿到被删除的文件和索引。

所以你自定义的内容,只需要关心"长什么样",不需要关心"怎么删"。这反而是好消息,因为你省掉了很多状态同步的麻烦。

4.2 before-delete 如何与自定义删除图标联动

既然自定义内容自动走内部删除链路,那么配置在组件上的before-delete也会自动生效。举个例子:

import { showConfirmDialog } from 'vant'; const beforeDelete = async () => { await showConfirmDialog({ title: '删除确认', message: '删除后无法恢复,确定要继续吗?', }); };

模板里只需要把before-delete传进组件:

<van-uploader v-model="fileList" :before-delete="beforeDelete" :max-count="6" > <template #preview-delete> <span class="custom-delete-btn"> <van-icon name="delete" size="14" color="#fff" /> </span> </template> </van-uploader>

这样点击任何一个自定义删除图标时,都会先弹确认框,点击确认后才真正删除。before-delete支持返回布尔值、Promise、或者直接抛出异常来中断删除,弹窗确认是最常见的方案。

4.3 什么时候真的需要自己处理删除逻辑

有一种特殊情况建议手动处理删除逻辑:你在插槽内容里又加了一个独立的按钮,而这个按钮的业务不是删除,而是"重传""下载""分享"这类操作。这时候千万不要把它放进preview-delete插槽里,因为无论你在这个插槽里放多少个按钮,点击都会冒泡到外层容器,触发删除。

正确做法有两种:

  • 方案一:在自定义内容里写@click.stop截断冒泡,然后执行自己的逻辑。
  • 方案二:干脆不用preview-delete插槽,而是在van-uploader外面用footer插槽或者自定义操作列,完全脱离删除容器。

我实际项目里用的是方案一,因为设计稿要求删除按钮和重传按钮都叠在图片右上角,紧凑布局下只能放在同一个位置。核心是给不想触发删除的按钮加上@click.stop:

<template #preview-delete="{ file }"> <span class="custom-actions"> <van-icon v-if="file.status === 'failed'" name="replay" @click.stop="handleRetry(file)" /> <van-icon name="delete" /> </span> </template>

这样设计既享受了插槽自带删除逻辑的便利,又不会误删。

5. 踩坑实录:自定义删除图标实战中的五个高频问题

5.1 自定义内容不显示:八成是外层容器尺寸不够和背景遮挡

很多朋友替换了插槽之后,发现自定义图标"不见了",其实不是不见了,是被默认容器盖住或者限定了尺寸。

默认.van-uploader__preview-delete只有18px × 18px,如果自定义内容是一个20px的圆形按钮,它的一部分会溢出到容器外面,但容器本身没有overflow: hidden,所以溢出部分一般还是可见的。真正看不见的原因往往是:你在插槽里写了一个空的div,但没有给它任何宽高和内容,渲染出来占位是 0,而外层容器默认是透明背景的,视觉上自然什么都没有。

解决办法也是两条:给自定义内容一个确定的宽高,或者同步用深选择器放大外层容器。我个人建议两条都做,避免容器太小导致点击热区不足。

5.2 点击删除图标却触发了预览图:冒泡链路出了问题

我前面说过,Vant 内部绑的是@click.stop,正常情况下点击删除不会触发预览。但如果你在自定义内容上自己又绑了@click,并且在事件处理里调用了一些不阻止冒泡的方法,就可能出现"图片预览也被打开"的诡异现象。

排查思路很明确:

  1. 先打开控制台,看点击自定义按钮时预览是否被触发。
  2. 检查自定义内容上有没有多余的@click绑定。
  3. 如果有,加上.stop修饰符。

有一种比较隐蔽的情况是:你的自定义内容是<label>标签,label 的点击行为在某些移动端浏览器里会触发关联组件,从而引发额外事件。所以我建议自定义内容优先用span、i、button这类语义和事件模型都简单的标签。

5.3 小程序端和 H5 端的差异:插槽写法与样式作用域

Vant 在 H5 端用的是#preview-delete这种 Vue 3 插槽语法,如果项目还在用 Vue 2 的 Vant 2,写法要换成slot="preview-delete"。Vant Weapp(微信小程序版)则完全是另一套。Vant Weapp 的 uploader 同样支持preview-delete插槽,但小程序端是slot+slot-scope的旧式写法:

<van-uploader file-list="{{ fileList }}" bind:delete="onDelete"> <view slot="preview-delete" slot-scope="props"> <van-icon name="delete" size="18" /> </view> </van-uploader>

另外,小程序端的样式隔离规则比 H5 严格,page级样式和组件内部样式是隔离的。自定义内容要覆盖 Vant 组件内部的类名,在 H5 端可以用:deep(),在小程序端可能要配合externalClasses或者直接写在自定义内容自身的类名上,不要去试图覆盖.van-uploader__preview-delete的样式,很容易不生效。

5.4 多文件场景下 index 定位混乱

preview-delete插槽的作用域里只传了file,没有直接给index。如果你需要知道删除的是第几张图,有两种做法:

  • 在插槽里通过fileList.indexOf(file)自己算索引。
  • 在@delete事件里直接拿返回的index。

我更推荐第二种,因为删除事件里返回的索引是精确的、事件触发时实时计算的。插槽渲染时算好的index一旦文件列表结构变化,容易取到旧值。尤其是你同时支持"多选删除"和"排序"功能时,旧索引会让你删错文件。

5.5 单个文件禁止删除:和 deletable 属性配合

有时候不是所有文件都允许删除,比如相册封面图不允许删除,其余图片可以。Vant 的fileList里每个文件对象可以单独配置deletable:

const fileList = ref([ { url: 'https://example.com/cover.jpg', deletable: false }, { url: 'https://example.com/photo1.jpg', deletable: true }, ]);

当file.deletable === false时,外层.van-uploader__preview-delete容器根本不会渲染,preview-delete插槽里的内容也不会出现。这个优先级是"单文件配置 > 组件级配置",不需要你在插槽里写v-if来手动判断。我在项目里第一次遇到这个问题时,还傻乎乎地写了一个判断,后来发现完全多余。

6. 封装一个可复用的自定义删除图标组件:少写三遍重复代码

6.1 组件封装思路

自定义删除图标这个需求通常会在多个页面出现,但不同页面的按钮颜色、图标可能有细微差别。我习惯封装成一个轻量组件,对外暴露几个 props,内部统一处理样式和事件。设计思路是这样的:

  • type:控制按钮风格,默认danger,可选dark。
  • size:控制删除按钮尺寸,需要同步放大外层容器。
  • custom-text:如果设计稿要显示文字,传入文字内容。

这样每个页面传个type和size就能用,不会在多个页面复制十几行 CSS。

6.2 完整代码

新建一个CustomDeleteIcon.vue:

<template> <span class="custom-delete-icon" :class="`custom-delete-icon--${type}`" :style="`width: ${size}px; height: ${size}px;`" > <van-icon v-if="!text" name="delete" :size="size * 0.7" color="#fff" /> <span v-else class="custom-delete-icon__text">{{ text }}</span> </span> </template> <script setup> defineProps({ type: { type: String, default: 'danger', }, size: { type: Number, default: 20, }, text: { type: String, default: '', }, }); </script> <style scoped> .custom-delete-icon { display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; background: #ee0a24; box-shadow: 0 2px 6px rgba(238, 10, 36, 0.4); } .custom-delete-icon--dark { background: rgba(0, 0, 0, 0.45); box-shadow: none; border-radius: 0 0 0 12px; } .custom-delete-icon__text { font-size: 12px; color: #fff; line-height: 1; } </style>

然后在van-uploader里这样用:

<van-uploader v-model="fileList" :max-count="9"> <template #preview-delete> <CustomDeleteIcon :size="24" type="danger" /> </template> </van-uploader>

如果项目里多个页面都用了同一个删除按钮视觉风格,这一步能减少大量重复样式代码。需要注意封装组件里的深选择器依然要写成这样:

:deep(.van-uploader__preview-delete) { width: v-bind(size + 4 + 'px'); height: v-bind(size + 4 + 'px'); background: transparent; border-radius: 0; }

但v-bind在scoped样式里的写法要配合@vueuse或者 CSS 变量,为了简洁我这里就不展开,实际复制代码时手动调整一下外层容器尺寸即可。

6.3 配合 file 级配置实现复杂业务

封装组件的最终形态,是配合插槽作用域和单文件配置完成复杂业务。举一个我在真实项目里做过的场景:

  • 前 3 张图是"必传图",删除时必须二次确认。
  • 后 6 张图是"补充图",可以随便删,不需要确认。
  • 上传失败的图片,删除按钮要变成红色警示色。

实现时,单文件上标记一个isRequired字段,before-delete里判断当前文件索引,决定是否弹确认框。删除按钮的视觉通过preview-delete插槽和file.status动态控制。这套组合完整覆盖了大多数上传场景的交互需求,而且全部基于 Vant 的既有机制,不会产生额外的状态维护成本。

我在实际使用中还有一个体会:自定义删除图标绝对不是单纯改个 icon 那么简单,它牵涉到点击事件链路、文件状态、单文件可删配置、以及跨端差异。建议你在动手之前,先想清楚自己的业务里"删除"这个动作是纯删除、还是要联动重传、是否要二次确认。想清楚这些,再回头看插槽,很多奇奇怪怪的报错和 bug 其实都能在源码层面找到答案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询