标题看起来平平无奇,<el-tag>标签使用这件事,搁在很多刚上手 Vue 的开发者眼里,无非就是写一行组件、传个 type 属性就收工。但真在后台系统里泡过一段时间之后,你会发现这个小组件是 Element 家族里最被低估的成员之一——它经常出现在表格状态列、筛选条件栏、动态录入框、权限标识、接口回显这些位置,而且每一次出现都不只是“显示一段文字”那么简单。这篇文章我就围绕 el-tag 标签使用,从基础属性、动态增删、业务状态映射,到实际项目里的组合玩法与样式坑位完整梳理一遍。适合已经在用 Element UI 或 Element Plus 做项目的开发者,也适合刚把组件摸熟、想搞懂更多细节的人。
1. 先搞清楚 el-tag 在页面里到底扮演什么角色
1.1 标签的四种典型存在形式
我在做后台管理类项目时,发现 el-tag 几乎不会单独出现在页面上。它通常以四种形态存在:
- 纯展示型:只读状态标记,比如“已完成”“已启用”,不需要用户做任何操作;
- 可操作型:作为筛选条件或待确认项,旁边带一个关闭按钮,用户点击后触发删除或取消;
- 业务绑定型:后端返回一个状态值,前端把它映射成不同颜色和文案,比如订单状态、审核状态;
- 聚合容器型:多个标签聚集在一起,形成标签集合,配合输入框变成长标签录入器。
这四种形态看着简单,实际决定了你要用哪些属性、绑哪些事件。纯展示型只给一个type就够了;可操作型必须处理closable和close事件;业务绑定型需要维护一套状态映射;聚合容器型则要考虑数组更新、key 唯一性和动画反馈。很多时候项目里出问题,不是 el-tag 本身坏了,而是没想清楚当前场景属于哪一种形态,导致属性乱传、事件乱绑。
1.2 基础属性速查与新旧版本差异
先把最常用的属性理一遍,后面所有折腾都建立在这张表上:
| 属性 | 可选值 | 作用 |
|---|---|---|
| type | primary / success / info / warning / danger | 控制标签语义颜色 |
| size | large / default / small | 控制标签尺寸 |
| closable | true / false | 是否显示关闭按钮 |
| disable-transitions | true / false | 是否禁用过渡动画 |
| round | true / false | 是否启用胶囊圆角 |
| effect | dark / light / plain | 控制主题风格 |
| color | 任意颜色值 | 自定义背景色 |
| hit | true / false | 是否显示边框描边 |
这里有个特别值得注意的版本差异。如果你从 Element UI 老项目升级到 Element Plus,type的可选值和size的取值都会变。老版本里没有primary这个 type 选项,size用的是medium、small、mini;新版本里size收敛成large、default、small。我见过一次升级后标签全部变矮变胖的案例,排查到最后就是size="mini"不生效导致的。另外 Element Plus 的默认圆角更大了,同样写法的标签视觉效果会明显不同,升级上线前一定要走查一遍所有用到 el-tag 的页面。
1.3 一个常见的“无意识复杂化”案例
很多人刚开始写状态标签时,喜欢在模板里堆条件判断,比如:
<el-tag :class="status === 1 ? 'tag-active' : 'tag-inactive'"> {{ status === 1 ? '启用' : '停用' }} </el-tag>这样写确实能跑,但页面一多就麻烦了。同样的状态判断在列表页写一遍,在详情页写一遍,在筛选面板又写一遍,后面改一个枚举名要全局搜索替换,漏一处就出问题。el-tag 本身不复杂,复杂的是它要承担的状态映射逻辑没有收敛。与其在模板里到处散落判断,不如先建一张映射表,把状态枚举、显示文案、标签颜色放在一起,后面我会专门讲这块。
2. 动态标签的增删交互:从展示到可操作的关键两步
2.1 动态标签最常见的业务场景
动态标签在实际项目里主要有两类场景。第一类是标签录入器,用户可以自己输入一段文字回车生成标签,也可以点击关闭删掉,常见于给商品、文章、活动打分类标识。第二类是筛选条件面板,用户在前台设置完条件后,页面顶部生成一排已选条件标签,点击标签末尾的“×”就移除该条件并触发重新查询。
这两种场景的底层逻辑是一致的:维护一个数组,添加时把新项 push 进去,删除时把对应项过滤掉。差别只在于添加按钮是输入框还是下拉选择,删除之后要不要额外触发数据请求。先把最核心的增删闭环写出来,后面再往里面补条件判断。
2.2 从输入框到标签集合的完整实现
以 Vue 3 + Element Plus 为例,一个最精简的标签录入器长这样:
<template> <div> <el-input v-model="inputText" placeholder="输入标签后回车" @keyup.enter.prevent="handleAdd" /> <div class="tag-list"> <el-tag v-for="tag in tags" :key="tag.id" closable @close.stop="handleRemove(tag)" > {{ tag.text }} </el-tag> </div> </div> </template> <script setup> import { ref } from 'vue' const tags = ref([ { id: '1', text: '前端' }, { id: '2', text: 'Vue' }, ]) const inputText = ref('') function handleAdd() { const text = inputText.value.trim() if (!text) return tags.value.push({ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, text, }) inputText.value = '' } function handleRemove(tag) { const index = tags.value.findIndex((item) => item.id === tag.id) if (index > -1) tags.value.splice(index, 1) } </script>这段代码里有两个容易被忽略的细节。第一,@close.stop里的.stop不是随手写的,它阻止了关闭按钮的点击事件冒泡到 el-tag 根节点,否则一旦外层再绑一个@click,就会出现“点删除按钮却触发了标签点击跳转”的诡异现象。第二,添加时我用Date.now()加随机数生成唯一 id,而不是直接用数组下标当 key,删除中间项时才能保证 Vue 能准确找到对应的 DOM。
2.3 数组操作与事件绑定里的几个暗坑
把标签数组传给 el-tag 渲染只是第一步,实际操作时踩过不少坑。
最典型的是用数组下标当 key。v-for里写:key="index"短时间没事,一旦删除中间的标签,后面的标签会因为 key 位置变化被 Vue 复用,界面上可能出现“标签内容错位”“关闭状态残留”这类问题。解决方式就是给每个标签一个稳定的唯一 id,删除时通过 id 而不是下标去定位。
第二个坑和组件间的数据通信有关。如果标签数组是从父组件传下来的,千万不要在子组件里直接props.tags.push(...)。Vue 3 里虽然能 push 成功,但这类直接修改 props 的做法既违反单向数据流,又容易让父组件无法感知变化。正确方式是子组件通过事件把新的数组抛出去,上面加一层defineEmits,然后用v-model:tags同步:
<script setup> const props = defineProps({ tags: { type: Array, default: () => [] }, }) const emit = defineEmits(['update:tags']) function handleRemove(tag) { emit('update:tags', props.tags.filter((item) => item.id !== tag.id)) } function handleAdd(text) { const trimText = text.trim() if (!trimText) return emit('update:tags', [ ...props.tags, { id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, text: trimText }, ]) } </script>还有一个中文输入法导致的经典问题:中文输入法在拼音候选状态下按回车,会触发keyup.enter,结果用户还没选好汉字就把半个拼音添加成标签了。这个问题需要在输入框上监听compositionstart和compositionend,用一个标志位判断当前是否处于组词状态,如果正在组词就忽略回车新增。
2.4 批量操作时给标签补一点动画
Element Plus 的 el-tag 默认自带进入和离开动画,但如果你用v-for渲染整个标签集合,删除其中一个标签时,其他标签是一瞬间弹上去的,视觉上有点生硬。想让动画更顺滑,可以用TransitionGroup包一层:
<TransitionGroup name="tag-fade" tag="div" class="tag-list"> <el-tag v-for="tag in tags" :key="tag.id" closable @close.stop="handleRemove(tag)" > {{ tag.text }} </el-tag> </TransitionGroup>对应的 CSS 至少要覆盖进入和离开两个阶段:
.tag-fade-enter-active, .tag-fade-leave-active { transition: all 0.2s ease; } .tag-fade-enter-from, .tag-fade-leave-to { opacity: 0; transform: scale(0.92); }需要注意,使用TransitionGroup时每个子元素还是必须有稳定的 key,key 一变动画就会失效。如果只是单个标签的显示隐藏,用v-if配合 el-tag 默认动画就好,没必要额外包一层。项目里如果对动画要求不高,保留默认效果也完全够用。
3. 把标签和业务数据捆在一起:状态映射、颜色规范与点击行为
3.1 用一张映射表替换散落的模板判断
后台项目里最常见的需求是“后端返回状态数字或枚举字符串,前端显示对应文案和颜色”。比如订单有pending、approved、rejected三种状态,我通常会在文件顶部定义一份映射表:
const statusMap = { pending: { label: '待处理', type: 'warning' }, approved: { label: '已通过', type: 'success' }, rejected: { label: '已驳回', type: 'danger' }, closed: { label: '已关闭', type: 'info' }, }模板里就一行:
<el-tag :type="statusMap[row.status]?.type ?? 'info'"> {{ statusMap[row.status]?.label ?? row.status ?? '未知' }} </el-tag>这里用了空值合并运算符??,不是因为写法炫技,而是为了避免两个实际问题:一是后端偶尔会返回一个映射表里没有的状态,此时至少要显示原始值而不是空白;二是status可能为null或undefined,直接访问属性会报错,用可选链就能安全兜底。
映射表的好处在于,状态枚举是全局收敛的。后端新增一个枚举值,只需要改这一处;多个页面复用同一份状态文案,不会出现同一个状态在列表页叫“通过”、在详情页叫“已完成”这种不一致。条件判断复杂时,甚至可以基于映射表生成一个计算属性,模板里只依赖计算属性。
3.2 数据更新后标签不同步的常见原因
标签本身没有独立状态,它只是把数据渲染出来。数据变了但标签没变,多半是数据引用问题。
我用reactive维护列表时,如果直接list[0].status = 'approved',视图通常会更新;但如果后端返回的是全新对象,而你用Object.assign(existingItem, newData)时没注意嵌套字段,可能出现标签文案变了但颜色没变。更稳妥的做法是整体替换列表项:
const newData = await fetchDetail(id) const idx = list.value.findIndex((item) => item.id === id) if (idx > -1) { list.value[idx] = { ...list.value[idx], ...newData } }这样 Vue 能明确侦测到对象引用变化,强制触发重渲染。另外还要注意接口返回的状态值类型,后端用0、1表示枚举,前端映射表如果用字符串'0'、'1'做 key,一定要先String(row.status)再取值,否则会踩严格相等判断失败的坑。
3.3 标签点击事件与关闭按钮的冲突处理
el-tag 可以绑定原生click事件,用在点击标签跳转详情、切换筛选条件等场景。但前面说过,closable时关闭按钮的点击事件会冒泡到根节点。如果同时绑了@click和@close,会出现“点关闭按钮删除标签,同时也触发了标签点击跳转页面”的后果。
最省心的解决办法是给@close加.stop修饰符,让关闭事件不再冒泡;也可以保留冒泡,在 click 回调里判断点击目标是不是关闭图标:
function handleTagClick(event, tag) { if (event.target.closest('.el-tag__close')) return // 正常处理标签点击行为 }第二种方式更适合那些“点击标签就要处理逻辑,且关闭按钮也需要被一起感知”的场景。如果项目里有多个位置用到了带关闭按钮且带点击跳转的标签,我建议封装成一个公共组件,把冲突处理逻辑集中在一起。
3.4 与服务端枚举的对应规范
服务端返回的枚举,前端不要直接用魔法值散落在模板里。我一般会把状态枚举单独抽成常量文件,比如constants/status.js,里面同时定义原始枚举值、显示文案、标签配置。这样前后端联调时,接口文档里的枚举定义和前端代码是同一份认知。
还有个小细节:后端接口可能返回'PENDING'大写,也可能返回'pending'小写。为了统一,取值映射前先做一次String(value).toLowerCase(),再查映射表。这样即使后端某个接口的大小写风格不一致,前端也不会出现一半状态能显示、一半状态显示原始值的情况。
4. 在真实页面里组合使用:表格列、表单回显与筛选条件
4.1 表格列里的状态标签
表格列是 el-tag 出现频率最高的地方。用el-table-column的默认插槽,取出当前行数据渲染标签:
<el-table-column label="状态" width="100" align="center"> <template #default="{ row }"> <el-tag :type="statusMap[row.status]?.type ?? 'info'" size="small" effect="plain" > {{ statusMap[row.status]?.label ?? row.status }} </el-tag> </template> </el-table-column>这里我习惯加size="small"和effect="plain"。前者是避免默认尺寸的标签把表格行高撑得太高,后者是为了降低标签颜色的饱和度和视觉冲击力。一张表格里如果全是高饱和的纯色块,用户扫视时会觉得很吵,plain风格既保留了状态语义,又更耐看。
还有个容易被忽略的点:表格列宽度。标签文案长短差异很大时,要给列留足够宽度,同时文案过长可以配合show-overflow-tooltip处理。不要把宽度卡得太死,否则“已驳回申诉”这类文案会被截断成“已驳回…”。
4.2 表单回显里把多选值渲染成标签
编辑页面拿到的数据通常是数组,比如用户角色 ID、文章分组 ID。直接渲染字符串数组不好看,通常需要把 ID 映射成名称再用标签展示。我会先把可选列表转成 Map:
const groupMap = new Map(groups.map((group) => [group.id, group.name])) function getGroupName(id) { return groupMap.get(id) ?? id }模板里遍历数组生成标签,每个标签可关闭,关闭后同步移除对应 id:
<div class="group-tags"> <el-tag v-for="id in form.groupIds" :key="id" closable @close="removeGroup(id)" > {{ getGroupName(id) }} </el-tag> </div>用 Map 而不是普通对象的原因是,Map 查找 key 时更安全,普通对象会受原型链影响,万一 key 是toString之类的字符串就可能出差错。像这种 ID 到名称的映射,Map 是更可靠的数据结构。
如果你的表单本身就是el-select multiple,也可以直接给下拉选择加collapse-tags,让选中的选项折叠成标签形式。但自定义展示需求多时,还是用 el-tag 自己渲染更灵活,不受下拉组件内部布局限制。
4.3 筛选面板顶部的“已选条件”标签栏
筛选页是 el-tag 可操作形态的典型场景。用户设置了地区、状态、时间范围后,页面上方生成一排已选条件标签,点击关闭即取消该条件并刷新列表。
实现这类需求,我不建议单独维护一个“标签数组”,因为很容易出现“标签显示有,但筛选表单实际已清空”的不同步问题。更好的方式是让标签列表通过计算属性派生自真实的筛选条件:
const activeFilters = computed(() => { const list = [] if (filters.region) { list.push({ key: 'region', label: `地区:${filters.region}` }) } if (filters.status) { list.push({ key: 'status', label: `状态:${statusMap[filters.status]?.label ?? filters.status}`, }) } return list }) function handleRemoveFilter(filterItem) { filters[filterItem.key] = '' fetchList() }这样关闭标签只是把对应筛选字段置空,标签列表会自动重新计算。数据源只有一份,从根上避免了不同步问题。如果把标签数组单独存一个ref,每次筛选表单变化都要手动同步,漏一次就出 bug。
4.4 弹窗里的批量打标签操作
批量操作也是常见场景:表格里勾选多行数据,点击“批量打标签”按钮,弹窗里展示一组可选标签,用户点击后每个选中的行都会被打上对应标签。
这个场景里 el-tag 更多是展示层,真正要处理的核心是把选中的行 ID 和标签集合做一次关联。操作完成后,把新标签追加到对应行的 tags 数组里。需要注意去重,同一个商品被重复打上同一个标签时,不要生成两个一模一样的 el-tag。可以在追加前用some判断一下是否已经存在。弹窗关闭后记得刷新表格数据,否则用户看到的数据和接口不一致会产生困惑。
5. 样式定制与踩坑复盘:小组件也需要注意的细节
5.1 自定义颜色时别忽略文字可读性
Element Plus 的 el-tag 提供了color属性,可以直接设置背景色。但这里有个隐藏坑:设置color之后,标签的文字颜色默认是白色。如果你的背景色是浅黄色、浅蓝色这类浅色系,白字根本看不清。
最稳妥的定制方式不是单独传color,而是用 CSS 变量覆盖。给标签加一个自定义类名,然后通过类选择器统一设置背景色、文字色、边框色:
.el-tag--custom { --el-tag-bg-color: #fff3e0; --el-tag-border-color: #ffd591; --el-tag-text-color: #ed6a0c; --el-tag-hover-bg-color: #ffe7ba; }使用 CSS 变量有额外的好处:hover 时背景色可以单独控制,不会像直接传color那样,鼠标移上去突然跳成另一个奇怪的颜色。如果项目里有多个自定义色标签,维护一套 CSS 变量结构比到处写内联样式清晰得多。
5.2 覆盖组件内部样式时的作用域处理
在 Vue 单文件组件里写<style scoped>时,默认样式不会穿透到子组件内部。想调整 el-tag 内部的关闭按钮、间距或字体,必须用:deep():
<style scoped> .tag-wrapper :deep(.el-tag) { font-weight: 500; } .tag-wrapper :deep(.el-tag__close) { font-size: 12px; } </style>这里建议把:deep()的范围限制在一个带类的容器下,避免全局覆盖影响所有页面的 el-tag。不到万不得已,不要直接修改.el-tag__close这类内部类名,因为组件库升级时内部类名可能会变,升级之后覆盖失效,排查起来很浪费时间。
5.3 我实际踩过的几个标签相关坑
把常见问题集中列一下,每一条都是真实遇到过并排查过的:
- 用数组下标当 key 导致删除中间标签后,后续标签的动画和内容错位,改成唯一 id 后解决;
- 关闭按钮冒泡触发外层 click,导致“删除标签直接跳转详情页”,用
@close.stop解决; - 中文输入法回车误添加半个拼音标签,通过
compositionstart/compositionend标志位解决; - 弹窗打开时接口数据还没返回,
form.groupIds为空导致标签先渲染成空集合,等数据返回后再打开弹窗渲染标签; - 后端返回的
tags字段可能是null,直接用会报错,统一用computed(() => props.tags ?? [])做兜底; - 服务端传回大写枚举值,前端映射表用小写 key 时匹配不上,加一行
String(value).toLowerCase()解决。
这些坑单独看都不难,但它们往往组合出现:一个页面里同时有动态标签、关闭事件、状态映射和接口回显,几个问题叠加起来,排查难度就不是 1+1 而是指数增长了。
5.4 标签数量很多时的展示策略
如果一个数据项下挂了二三十个标签,全部平铺在页面上会非常占高度。我常用的处理方式是“截断 + 展开”:
const MAX_VISIBLE = 5 const visibleTags = computed(() => expanded.value ? tags.value : tags.value.slice(0, MAX_VISIBLE) )超出部分用一个“+N”文本按钮展示,点击后展开完整标签集合。这个方案比引入虚拟滚动更划算,因为标签本身非常轻量,几十个 el-tag 的渲染成本很低,瓶颈只在于页面空间。只有当标签数量可能达到几百上千时,才需要考虑懒加载或虚拟滚动,普通后台项目完全没必要为了省这点渲染时间引入额外复杂度。
在维护某后台项目时,我最后把动态标签、状态映射、关闭事件、展开折叠这一整套逻辑收拢封装成了一个 TagGroup 组件,传入items和映射配置,关闭和点击行为统一由组件内部处理。如果你也在多个页面里用到标签集合,建议尽早做类似的封装——el-tag 本身很小,但用法的复杂度一上来,散落各地反而不如集中管理省心。