ElTree空状态优化:用ElEmpty替换默认暂无数据的完整方案
最近在整改后台管理系统的一批列表页时被一个不起眼的小细节堵了很久数据为空时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 方案Av-if/v-else 直接替换简单粗暴但要注意副作用最直接的思路数据为空时不渲染 ElTree改渲染 ElEmpty。el-empty v-if!treeData.length description暂无数据 :image-size120 / el-tree v-else reftreeRef :datatreeData node-keyid default-expand-all /这段代码写起来非常快效果也立竿见影。但副作用同样明显ElTree 被彻底卸载了。如果你在树上有展开状态、选中状态、搜索过滤后的临时视图切换的瞬间这些状态全部清零。常见场景就是用户搜索后没有结果然后清空搜索词树恢复到全量数据——但展开状态可能已经丢失用户体验非常分裂。如果只是普通展示型树比如只读的分类列表这个方案完全够用。但如果树上有交互状态就得考虑方案B或方案C。2.2 方案B保留树组件用 ElEmpty 做覆盖层既然状态不能丢那就别卸载 ElTree。思路是ElTree 一直在 DOM 里数据为空时它内部会渲染那个空文本块我们通过 CSS 把默认空文本藏起来再在同一个容器里绝对定位放置 ElEmpty。div classtree-wrapper el-tree :datatreeData :empty-text / el-empty v-if!treeData.length classtree-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-loadingloading classempty-tree-wrapper el-tree v-ifdata.length reftreeRef v-bind$attrs :datadata / el-empty v-else-ifshowEmpty :descriptionemptyText :image-sizeemptyImageSize slot nameempty-action / /el-empty /div /template style scoped .empty-tree-wrapper { min-height: 200px; } /style调用方只需要传data、loading空状态自动接管。我实践下来这个方案维护成本最低后面说的踩坑点也都能在封装层一次性解决。2.4 我的建议按项目阶段选别过度设计只有一两个页面需要改且树没有复杂状态选方案A树上有展开、选中、搜索状态但改动范围小选方案B全后台多个模块都要统一空状态直接上方案C。我那次改造属于第三种前后有组织架构、角色授权、分类管理三个页面都要用所以我一步到位封装了 EmptyTree。别一上来就搞抽象先看需求范围。3. 基础实操把列表页的空文本换成 ElEmpty3.1 环境确认版本、按需引入和样式动手前先确认两件事Element Plus 的版本以及项目是否按需引入。我使用的是element-plus2.xElEmpty 和 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 classpage-container el-empty v-if!departmentList.length description还没有部门数据 :image-size120 / el-tree v-else :datadepartmentList node-keyid 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-loadingloading classtree-panel el-empty v-if!loading !departmentList.length description还没有部门数据 :image-size120 / el-tree v-show!loading departmentList.length :datadepartmentList node-keyid :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. 进阶封装一个能复用、又能保住树状态的 EmptyTree4.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-loadingloading classempty-tree-root el-tree v-show!isEmpty reftreeRef v-bind$attrs :datadata template v-for(_, slotName) in $slots :keyslotName #[slotName]slotProps slot :nameslotName v-bindslotProps / /template /el-tree el-empty v-ifisEmpty :descriptionemptyText :image-sizeemptyImageSize slot nameempty-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: 0ElEmpty 的内容就会塌缩。修正方案.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 :datafilteredTree :loadingloading :empty-textsearchKeyword ? 没有找到相关结果 : 暂无数据 template #empty-action el-button v-ifsearchKeyword typeprimary clickclearSearch 清空筛选 /el-button /template /empty-tree用户看到的不再是冷冰冰的“暂无数据”而是带可执行动作的引导这个改动对后台系统的易用性提升非常明显。6.2 空状态里加操作按钮ElEmpty 的默认插槽就是为操作按钮准备的。在 EmptyTree 里我预留了empty-action插槽实际页面里最常见的是“新建”按钮template #empty-action el-button typeprimary clickopenCreateDialog 新建部门 /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 外层加了rolestatus成本很低但对可访问性是有实际帮助的。这套改造做完后我个人的体会是ElEmpty 替换 ElTree 原生空文本表面上是视觉升级本质上是一次“状态管理”的梳理。你被迫去思考加载中、有数据、无数据、搜索无结果、加载失败这几种状态各自该怎么呈现而这个思考过程对任何组件都适用。如果你手头正好有页面还在用 ElTree 的默认空文本不妨按这篇文章的思路先挑一个页面试试重点体验一下v-show和空态插槽的配合你会发现后台界面那种“毛坯感”一下就被治好了。