最近在整改后台管理系统的一批列表页时,被一个不起眼的小细节堵了很久:数据为空时,ElTree 默认显示的那句“暂无数据”,怎么看怎么别扭。说实话,组件本身没什么毛病,但那个光秃秃的文本节点放在现代后台里,就像一群人穿着正装,唯独它穿着拖鞋。于是我把目光移到了 ElEmpty 上,折腾了一轮之后,终于把 ElTree 的空状态提示从“生硬文本”升级成了“优雅插画”。这篇就把替换思路、实现步骤、踩坑记录和封装方案完整写出来。
1. 先搞清楚:ElTree 生硬的“暂无数据”到底是怎么来的
1.1 它只是一个文本节点,不是什么设计
ElTree 在数据源为空时,内部其实走的是很朴素的一套逻辑:判断data数组长度为 0,然后渲染一个.el-tree__empty-block容器,里面塞一个.el-tree__empty-text的 span,文案默认是“暂无数据”,也可以通过empty-text属性自定义。
也就是说,它从头到尾就是一行居中显示的纯文本。没有图标、没有插画、没有引导性提示、没有操作按钮,也没有任何视觉层次。放在信息密度高的后台页面里,这个空态区域会显得特别“秃”,尤其是当页面其他地方都用 ElEmpty、ElTable 自带的空状态插画时,ElTree 这里画风突变,用户一眼就能感受到粗糙。
有人可能会说:那我给empty-text传一段自定义 HTML 字符串不就行了?不行,empty-text是纯字符串属性,传标签进去只会原样显示成文本,这是很多人踩的第一个误区。
1.2 和 ElEmpty 摆在一起,差距一目了然
ElEmpty 是 Element Plus 专门设计的空状态组件,它的能力远超一行文本:
| 能力 | ElTree 默认空文本 | ElEmpty |
|---|---|---|
| 视觉元素 | 纯文字 | SVG 插画 + 文本 |
| 自定义插画 | 不支持 | image属性或image插槽 |
| 描述文案 | 单一字符串 | description属性或description插槽 |
| 操作按钮 | 不支持 | 默认插槽可放按钮、链接 |
| 尺寸控制 | 固定 | image-size可调 |
| 动效 | 无 | 内置插画动画 |
ElEmpty 的默认插画是一组 SVG 图形,内部带着轻微的空态动效,视觉上能自然引导用户“这里是空的、接下来该做什么”。它本来就是 Element Plus 生态里设计语言的一部分,和表格、卡片、弹窗的空状态是同一套体系。所以把 ElTree 的空提示替换成 ElEmpty,本质上不是“换个好看的图”,而是让整个页面的空状态设计语言保持一致。
1.3 不是所有项目都需要折腾这一步
说句实在话,如果你的项目是内部工具、数据大概率不会为空、UI 也没有统一规范,那保持默认文本完全没问题,没必要为改而改。但如果你遇到下面几种情况,替换就很有必要:
- 项目有设计规范,要求所有空状态必须带插画和引导文案;
- 树形数据经常为空,比如“组织架构”“分类管理”这类模块刚初始化时什么都没有,用户需要被引导去创建;
- 搜索过滤场景,用户输入关键词后树查无结果,这时候一个“没有找到相关数据”的插画比一行干巴巴的文字友好得多;
- 需要复用空状态组件,统一维护一套 EmptyTree,而不是每个页面自己写一遍判断。
我当时就是因为第三条——树形结构常被拿来当筛选器用,用户搜不到结果时面对一行文字完全不知道该怎么办,才下决心改的。
2. 动手前先选型:三种替换姿势的取舍
2.1 方案A:v-if/v-else 直接替换,简单粗暴但要注意副作用
最直接的思路:数据为空时不渲染 ElTree,改渲染 ElEmpty。
<el-empty v-if="!treeData.length" description="暂无数据" :image-size="120" /> <el-tree v-else ref="treeRef" :data="treeData" node-key="id" default-expand-all />这段代码写起来非常快,效果也立竿见影。但副作用同样明显:ElTree 被彻底卸载了。如果你在树上有展开状态、选中状态、搜索过滤后的临时视图,切换的瞬间这些状态全部清零。常见场景就是:用户搜索后没有结果,然后清空搜索词,树恢复到全量数据——但展开状态可能已经丢失,用户体验非常分裂。
如果只是普通展示型树,比如只读的分类列表,这个方案完全够用。但如果树上有交互状态,就得考虑方案B或方案C。
2.2 方案B:保留树组件,用 ElEmpty 做覆盖层
既然状态不能丢,那就别卸载 ElTree。思路是:ElTree 一直在 DOM 里,数据为空时它内部会渲染那个空文本块;我们通过 CSS 把默认空文本藏起来,再在同一个容器里绝对定位放置 ElEmpty。
<div class="tree-wrapper"> <el-tree :data="treeData" :empty-text="''" /> <el-empty v-if="!treeData.length" class="tree-empty-mask" description="暂无数据" /> </div>.tree-wrapper { position: relative; min-height: 200px; } .tree-empty-mask { position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; align-items: center; justify-content: center; background: #fff; }注意我特意把默认empty-text传了空字符串,这样就算遮罩层因为某种原因没覆盖住,也不会露出原生的文本。这个方案的优点是 ElTree 实例始终存在,展开、选中状态都保得住;缺点是遮罩层是个额外的心智负担,你需要保证布局层级正确,否则可能出现空态盖不住或者位置偏移的问题。
2.3 方案C:封装 EmptyTree 公共组件,一劳永逸
如果项目里有多个页面用到树形空状态,我强烈建议直接封装一个EmptyTree。它把 ElTree、ElEmpty、Loading 三件事揉在一起,对外暴露的 API 尽量贴近 ElTree 本身,调用方几乎无感知。
<script setup> import { computed, ref } from 'vue' const props = defineProps({ data: { type: Array, default: () => [] }, loading: { type: Boolean, default: false }, emptyText: { type: String, default: '暂无数据' }, emptyImageSize: { type: Number, default: 120 }, showEmpty: { type: Boolean, default: true }, }) const treeRef = ref(null) defineExpose({ getTreeRef: () => treeRef.value, }) </script> <template> <div v-loading="loading" class="empty-tree-wrapper"> <el-tree v-if="data.length" ref="treeRef" v-bind="$attrs" :data="data" /> <el-empty v-else-if="showEmpty" :description="emptyText" :image-size="emptyImageSize" > <slot name="empty-action" /> </el-empty> </div> </template> <style scoped> .empty-tree-wrapper { min-height: 200px; } </style>调用方只需要传data、loading,空状态自动接管。我实践下来,这个方案维护成本最低,后面说的踩坑点也都能在封装层一次性解决。
2.4 我的建议:按项目阶段选,别过度设计
- 只有一两个页面需要改,且树没有复杂状态:选方案A;
- 树上有展开、选中、搜索状态,但改动范围小:选方案B;
- 全后台多个模块都要统一空状态:直接上方案C。
我那次改造属于第三种,前后有组织架构、角色授权、分类管理三个页面都要用,所以我一步到位封装了 EmptyTree。别一上来就搞抽象,先看需求范围。
3. 基础实操:把列表页的空文本换成 ElEmpty
3.1 环境确认:版本、按需引入和样式
动手前先确认两件事:Element Plus 的版本,以及项目是否按需引入。
我使用的是element-plus@2.x,ElEmpty 和 ElTree 都属于稳定组件,不需要额外安装依赖。如果你的项目用 Vite +unplugin-vue-components,并且配置了ElementPlusResolver,那么组件会自动按需引入,样式也会自动加载:
// vite.config.ts import Vue from '@vitejs/plugin-vue' import Components from 'unplugin-vue-components/vite' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default { plugins: [ Vue(), Components({ resolvers: [ElementPlusResolver({ importStyle: 'css' })], }), ], }这里有个细节:ElementPlusResolver默认的importStyle会根据环境自动判断,但在 Vite 下通常建议明确写成'css',避免出现样式没有随组件自动载入的问题。下面踩坑部分我会详细说。
如果项目是全局引入,也就是app.use(ElementPlus)那种,那什么都不用管,直接写组件名即可。
3.2 最小改动示例:数据为空时显示 ElEmpty
先来个最基础、能直接抄的版本。假设有一个简单的部门树:
<template> <div class="page-container"> <el-empty v-if="!departmentList.length" description="还没有部门数据" :image-size="120" /> <el-tree v-else :data="departmentList" node-key="id" default-expand-all :props="{ label: 'name', children: 'children' }" /> </div> </template> <script setup> import { ref, onMounted } from 'vue' import { getDepartmentTree } from '@/api/department' const departmentList = ref([]) const fetchData = async () => { const { data } = await getDepartmentTree() departmentList.value = data || [] } onMounted(fetchData) </script>这里我把description从默认的“暂无数据”改成了“还没有部门数据”,更贴近业务语义。image-size设为 120 是为了让插画在树区域的视觉占比适中,太小显得小气,太大又会挤压页面其他内容。
提示:
v-if="!departmentList.length"这段判断的时机一定要在数据赋值之后。如果你在fetchData里先departmentList.value = []又异步赋值,中间可能会出现空态闪现。最好的办法是配合loading状态,下面会讲。
3.3 与接口 Loading 配合:别让空态闪一下
直接v-if判断有个隐患:接口慢的时候,页面先渲染的是空态,然后数据回来才突然变成树。用户会觉得页面“闪了一下”,观感很差。
解决办法是引入v-loading,把加载态、空态、数据态三者串起来:
<template> <div v-loading="loading" class="tree-panel"> <el-empty v-if="!loading && !departmentList.length" description="还没有部门数据" :image-size="120" /> <el-tree v-show="!loading && departmentList.length" :data="departmentList" node-key="id" :props="{ label: 'name', children: 'children' }" /> </div> </template> <script setup> import { ref } from 'vue' const loading = ref(false) const departmentList = ref([]) const fetchData = async () => { loading.value = true try { const { data } = await getDepartmentTree() departmentList.value = data || [] } finally { loading.value = false } } </script>这里我特意用v-show而不是v-if控制 ElTree,因为v-loading指令需要一个稳定的容器,v-show只是隐藏,不会反复销毁重建树实例。组合起来的效果是:加载中转圈 → 有数据显示树 → 无数据显示 ElEmpty,整个过程没有一次空态闪烁。
4. 进阶封装:一个能复用、又能保住树状态的 EmptyTree
4.1 组件骨架:数据、加载、空态三种状态一把管
封装组件的目的不是炫技,而是把“空态判断逻辑、加载逻辑、树渲染逻辑”收敛到一个地方。我最终沉淀的EmptyTree.vue结构是这样的:
<script setup> import { ref, computed } from 'vue' const props = defineProps({ data: { type: Array, default: () => [] }, loading: { type: Boolean, default: false }, emptyText: { type: String, default: '暂无数据' }, emptyImageSize: { type: Number, default: 120 }, }) const treeRef = ref(null) const isEmpty = computed(() => !props.loading && !props.data.length) defineExpose({ getTreeRef: () => treeRef.value, }) </script> <template> <div v-loading="loading" class="empty-tree-root"> <el-tree v-show="!isEmpty" ref="treeRef" v-bind="$attrs" :data="data" > <template v-for="(_, slotName) in $slots" :key="slotName" #[slotName]="slotProps"> <slot :name="slotName" v-bind="slotProps" /> </template> </el-tree> <el-empty v-if="isEmpty" :description="emptyText" :image-size="emptyImageSize" > <slot name="empty-action" /> </el-empty> </div> </template> <style scoped> .empty-tree-root { position: relative; min-height: 200px; width: 100%; } </style>几个设计要点说明一下:
v-show保留 ElTree 实例,展开状态、选中状态不会因为空态切换而丢失;- 透传
$attrs,调用方可以继续传node-key、default-expand-all、props等原生属性; - 手动循环
$slots并透传给 ElTree,保持树节点自定义插槽能力,比如树节点前面加图标; - 提供一个
empty-action插槽,用于在空态里放“新建”“刷新”按钮; getTreeRef方法让父组件能拿到内部树实例,去调用setCurrentKey、getCheckedNodes这类方法。
4.2 透传与暴露:ref、插槽、方法不能丢
封装组件最容易翻车的地方就是“把路堵死”。封装后调用方可能需要用到 ElTree 的很多能力,比如:
- 获取当前选中节点:
treeRef.value.getCurrentNode() - 设置默认展开:
default-expanded-keys - 获取勾选节点:
treeRef.value.getCheckedKeys()
所以defineExpose里不能只暴露树本身,还要考虑把常用方法直接透出去。我这边提供了两套口子:
defineExpose({ // 直接拿内部树实例 getTreeRef: () => treeRef.value, // 常用方法快捷透传 getCheckedKeys: (...args) => treeRef.value?.getCheckedKeys(...args), setCurrentKey: (...args) => treeRef.value?.setCurrentKey(...args), getCurrentNode: (...args) => treeRef.value?.getCurrentNode(...args), })这样父组件使用起来:
const emptyTreeRef = ref(null) const handleSubmit = () => { const checkedKeys = emptyTreeRef.value.getCheckedKeys() console.log(checkedKeys) }体验上跟直接用 ElTree 几乎没区别,这就是封装的成功标准——调用方不需要知道它背后还有空态切换这一层。
4.3 树状态保持:默认展开、选中、搜索过滤都不影响
封装完成后,我最担心的“搜索过滤”场景也验证通过了。流程是这样的:
- 用户输入关键词,前端对树数据做过滤,过滤后
data为空; - EmptyTree 检测到
data.length === 0,用v-show隐藏树、显示 ElEmpty; - 用户清空关键词,原始数据恢复,树重新显示,展开状态还在。
因为全程没有销毁 ElTree 实例,展开状态保留得非常好。这一点比方案A的v-if体验强太多。唯一要注意的是,如果你在过滤后确实想让树“收起全部”,那需要在过滤逻辑里显式调用treeRef.value.store.defaultExpandKeys.clear()之类的内部方法,但我的建议是别碰内部 API,直接控制透传给树的default-expanded-keys属性反而更安全。
5. 踩坑记录:这些实际操作中的细节,文档里基本不会写
5.1 按需引入后 ElEmpty 样式缺失
第一次改造时,我是用unplugin-vue-components按需引入的,ElEmpty 能渲染,但插画整体变形、尺寸不受控。排查半天,发现是ElementPlusResolver的importStyle没有显式指定,某些版本下样式没被正确注入。
解决方式很简单:
ElementPlusResolver({ importStyle: 'css' })如果你用了 Vite 的optimizeDeps,改完配置后记得重启 dev server,并且清一下node_modules/.vite缓存。这个坑耗了我大概半小时,最后就是把配置从默认改成了显式'css'。
5.2 v-if 切换导致树状态丢失
这就是方案A的典型痛点。我一开始图省事,在树和 ElEmpty 之间用了v-if / v-else,结果用户反馈:搜索无结果后清空关键词,之前展开的节点全部收起了,选中的节点也没了。
原因不难理解:v-if切换时 ElTree 被销毁重建,所有内部状态归零。后来我改成v-show保留实例,问题立刻消失。这里给一个判断标准:如果你的树只是纯展示、无任何用户交互状态,那v-if没问题;只要涉及展开、选中、勾选,优先v-show。
5.3 flex 布局下空态被压缩成一团
另一个经典问题是布局挤压。EmptyTree 的父容器如果用了display: flex,且没有给子元素设置flex: 1或固定高度,ElEmpty 会被压缩得很小,插画挤成一团,看起来像渲染错误。
我当时的父容器结构是这样的:
.tree-panel { display: flex; flex-direction: column; height: 100%; }但 EmptyTree 根节点如果没有flex: 1和min-height: 0,ElEmpty 的内容就会塌缩。修正方案:
.empty-tree-root { flex: 1; min-height: 0; width: 100%; display: flex; flex-direction: column; justify-content: center; }同时 ElEmpty 在 flex 容器里最好也让它自己撑开,必要时给它加一个min-height。这个坑最容易出现在“左侧树形菜单 + 右侧内容区”这种经典后台布局里,排查时看 Elements 面板很容易发现高度变成 0 或几十像素。
5.4 自定义插画的路径问题
如果默认插画不符合需求,可以用image属性塞一张自定义 SVG 或直接传image插槽:
<el-empty description="暂无部门"> <template #image> <img src="/src/assets/empty-department.svg" alt="空状态插画" /> </template> </el-empty>这里要注意 Vite 的静态资源路径问题。放到public目录里的图片,路径写/empty-department.svg即可;放到src/assets里,必须用import引入或通过new URL处理,直接写相对路径很容易在打包后 404。
我的习惯是把空状态插画统一放在src/assets/empty/目录,然后封装一层:
import emptyTreeImg from '@/assets/empty/tree.svg' defineProps({ emptyImage: { type: String, default: emptyTreeImg }, })路径集中管理,后面设计换图只需要改一个文件。
6. 再往前走一步:让空状态根据场景说话
6.1 区分无数据、无搜索结果、加载失败
改完基础版本后,我又做了一轮细化:不同场景应该显示不同的空状态。比如:
| 场景 | 文案 | 插画 | 操作 |
|---|---|---|---|
| 模块初始化无数据 | 还没有任何数据,点击右上角创建吧 | 默认插画 | “新建”按钮 |
| 搜索过滤无结果 | 没有找到相关数据,换个关键词试试 | 放大镜插画 | “清空筛选”按钮 |
| 接口加载失败 | 数据加载失败,请稍后重试 | 警示插画 | “重新加载”按钮 |
实现上不需要改 EmptyTree 组件本身,调用方根据自己的状态传不同的description和image就行,比如:
<empty-tree :data="filteredTree" :loading="loading" :empty-text="searchKeyword ? '没有找到相关结果' : '暂无数据'" > <template #empty-action> <el-button v-if="searchKeyword" type="primary" @click="clearSearch" > 清空筛选 </el-button> </template> </empty-tree>用户看到的不再是冷冰冰的“暂无数据”,而是带可执行动作的引导,这个改动对后台系统的易用性提升非常明显。
6.2 空状态里加操作按钮
ElEmpty 的默认插槽就是为操作按钮准备的。在 EmptyTree 里我预留了empty-action插槽,实际页面里最常见的是“新建”按钮:
<template #empty-action> <el-button type="primary" @click="openCreateDialog"> 新建部门 </el-button> </template>按钮和文案之间 ElEmpty 会自动留白,不需要额外写间距。不过要注意,如果空状态是搜索导致的,按钮文案就不要写“新建”,而是“清空筛选”,否则用户会误以为要新建一条数据才能解决搜索无结果的问题。这块的语义设计一定要贴合场景。
6.3 尺寸、间距和无障碍
最后补充几个容易被忽略的细节:
image-size建议不超过 200,后台界面里插画太大反而喧宾夺主;- ElEmpty 默认的
description样式偏小,如果你觉得不明显,可以在封装组件里用 CSS 覆盖:.empty-tree-root :deep(.el-empty__description) { margin-top: 16px; } .empty-tree-root :deep(.el-empty__description p) { font-size: 14px; color: var(--el-text-color-secondary); } - 空状态容器最好加
aria-label,屏幕阅读器用户也能知道当前区域没有内容。ElEmpty 本身对语义支持一般,我在封装组件里给 ElEmpty 外层加了role="status",成本很低,但对可访问性是有实际帮助的。
这套改造做完后,我个人的体会是:ElEmpty 替换 ElTree 原生空文本,表面上是视觉升级,本质上是一次“状态管理”的梳理。你被迫去思考加载中、有数据、无数据、搜索无结果、加载失败这几种状态各自该怎么呈现,而这个思考过程对任何组件都适用。如果你手头正好有页面还在用 ElTree 的默认空文本,不妨按这篇文章的思路先挑一个页面试试,重点体验一下v-show和空态插槽的配合,你会发现后台界面那种“毛坯感”一下就被治好了。