vue3.0 使用el-tree节点添加自定义图标造成加载缓慢
Vue 3 + Element Plus(el-tree / el-tree-v2) 的"给节点添加自定义图标后加载/渲染变慢"的系统性解决方案清单。按"先见效快 → 再结构优化 → 最后细节打磨"的顺序给出,可按需组合。
一、常见卡顿成因(对号入座)
节点很多(上千) + 每个节点都用插槽渲染图标 ⇒ 创建/更新 VNode 数暴涨。
动态图标组件()或内联大 SVG 重复挂载 ⇒ 频繁解析/patch。
树整量渲染(未懒加载、未虚拟滚动) ⇒ 首屏一次性创建大量节点。
响应式过深(把整棵树数据做成深度响应式;图标映射表每次 render 都新建) ⇒ 依赖追踪开销大。
频繁 filter / highlight / expand 时,对每个节点都跑开销大的函数或计算。
二、速选方案(优先做这些,立竿见影)
大量数据:改用 (虚拟滚动),或给 开启 render-after-expand + lazy。
图标插槽:用 类名 + CSS 背景 / 字体图标 / SVG sprite ,少用每节点一个 Vue 组件。
静态组件:图标组件用 markRaw 缓存;映射表提升到模块级并避免每次 render 重新创建。
数据响应式:树数据用 markRaw 或 shallowRef 装载,避免深层响应式拖慢初次渲染。
禁用无谓计算:减少或延后 filter-node-method、default-expanded-keys;展开/搜索时防抖。
三、详细方案与代码示例
方案1:大量数据直接用虚拟树 el-tree-v2
场景:上千节点以上。
c
<template>
<el-tree-v2
:data="treeData" <!-- 数量大也没事 -->
:props="props"
:height="500"
:item-size="28"
:expand-on-click-node="false"
:indent="16"
>
<!-- 自定义图标:避免每项一个组件,使用类名或 <use> -->
<template #prefix="{ node, data }">
<i class="icon" :class="iconClassMap[data.type]"></i>
<!-- 或 -->
<svg class="icon">
<use :href="`#icon-${data.type}`"></use>
</svg>
</template>
</el-tree-v2>
</template>
<script setup>
import { markRaw } from 'vue'
const props = markRaw({ label: 'label', children: 'children' })
const treeData = /* 大量节点的纯对象数组,尽量 markRaw 见方案4 */
const iconClassMap = markRaw({ folder:'i-folder', file:'i-file', ... })
</script>
要点:el-tree-v2 只渲染"可视区域"节点,大幅减少 VNode 数量。图标用类名/SVG ,基本不产生组件开销。
方案2:el-tree 延迟渲染 + 懒加载
场景:数据不是特别大,但仍想优化首屏。
c
<el-tree
:data="treeData"
:props="props"
node-key="id"
lazy
:load="loadNode" <!-- 按需拉取 children -->
render-after-expand <!-- 默认 true;确认开启 -->
>
<template #icon="{ data }">
<i class="icon" :class="iconClassMap[data.type]"></i>
</template>
</el-tree>
const loadNode = (node, resolve) => {
// 异步或同步生成 children;只在展开时创建
if (node.level === 0) return resolve(rootChildren)
resolve(fetchChildren(node.data.id))
}
要点:初次页面仅渲染根节点;每次展开时再构建子节点和图标,显著减轻首屏压力。
方案3:用 类名 + CSS 背景 或 SVG sprite 代替每节点一个组件
不要这样(每个节点都创建 Vue 组件 / 动态 :is):
c
<template #icon="{ data }">
<component :is="iconMap[data.type]" />
</template>
推荐:
<template #icon="{ data }">
<!-- A. 字体/背景图 -->
<i class="tree-icon" :class="`ti-${data.type}`"></i>
<!-- B. SVG sprite -->
<!-- 先全局引入一次 symbols.svg,里面是 <symbol id="icon-file"> -->
<svg class="tree-icon">
<use :href="`#icon-${data.type}`"></use>
</svg>
</template>
收益:避免为每节点创建/patch 一个 Vue 组件实例;图标开销近似于一个普通元素。
方案4:让大对象"非深度响应式"
c
import { markRaw, shallowRef } from 'vue'
// 1) 配置对象、映射表:markRaw(不会被深度代理)
c
export const props = markRaw({ label: 'label', children: 'children' })
export const iconClassMap = markRaw({ folder:'i-folder', file:'i-file' })
// 2) 树数据:如果初始化后变化不多,用 shallowRef 包装
c
const rawData = getHugeTree() // 纯普通对象/数组
const treeData = shallowRef(markRaw(rawData))
// 后续若需要替换整棵树:treeData.value = markRaw(newHugeTree)
收益:避免对庞大树结构进行深度依赖收集;初次 mount 和后续 diff 都更快。
方案5:缓存图标组件(必须用组件时)
有些场景需要真正的组件(如带复杂交互的图标),也要缓存 + 标记原始:
c
import { h, markRaw } from 'vue'
import { FolderIcon, FileIcon } from '@/icons' // 这些最好是函数式组件或极轻组件
// 只创建一次映射;不要在渲染函数里 new Map()
const IconCompMap = markRaw({
folder: markRaw(FolderIcon),
file: markRaw(FileIcon),
})
const renderIcon = (type:string) => h(IconCompMap[type] || IconCompMap.file)
<template #icon="{ data }">
<!-- 注意:不要在模板里 :is="...复杂表达式" -->
<RenderIcon :type="data.type" />
</template>
<script setup>
import { defineComponent, h } from 'vue'
import { IconCompMap } from './iconsMap'
const RenderIcon = defineComponent({
props: { type: { type: String, required: true } },
setup(props){ return () => h(IconCompMap[props.type] ?? IconCompMap.file) }
})
</script>
收益:映射和组件都是"原始对象",不会每次渲染被代理/克隆;h 创建的 VNode 也更可控。
方案6:避免内联大 SVG 重复渲染
把大 SVG 的 等定义放入 sprite 或 外部 symbol:
构建时用 svg-sprite-loader/vite-plugin-svg-icons 生成 集合,页面只需 .
相比每节点一个长
, 的 DOM 和解析成本极低。
方案7:减少默认展开和频繁计算
控制 default-expanded-keys 数量;一次性展开过多会导致瞬时创建大量节点与图标。
filter-node-method 做纯同步且轻量的判断,不要做正则大匹配/复杂运算;搜索输入加 debounce(200~300ms)。
需要高亮命中时,尽量只对已展开的分支做高亮标记而不是全树。
方案8:事件与更新去抖节流
展开/折叠、勾选、搜索联动这些操作,若触发外部请求或重算统计,统一加防抖/节流,避免"每点一次全树抖三抖"。
方案9:确保 props、函数不"每次新建"
c
// Bad:在模板或 <script setup> 顶层每次渲染重新创建对象/函数
const props = { label:'label', children:'children' } // ❌
const iconMap = { file:..., folder:... } // ❌
const filterNode = (value, data) => { ... } // ❌
// Good:模块级常量 + markRaw;或用 const 声明后不再改动
export const props = markRaw({ label:'label', children:'children' })
export const filterNode = (value, data) => simpleCompare(value, data)
收益:Vue 比较 props 引用不变就不会触发额外更新;函数引用稳定有利于优化。
方案10:生产构建与测量
确认在 生产构建模式下测试(dev 模式有额外开销)。
使用 Chrome Performance 记录渲染时间轴,关注 Recalculate Style / Layout / Scripting 的占比。
对比替换前后节点数量(Elements 面板可估一个数量级),观察首屏时间与展开时延。
四、排查与优化清单(拷走即用)
节点数 ≥ 1000?→ 用 el-tree-v2。
首屏慢?→ render-after-expand + lazy + 只给根数据。
图标如何画?→ 类名/CSS 背景 或 ;尽量别用"每节点一个组件"。
组件必须用?→ markRaw 缓存映射与组件;用 h() 渲染轻组件。
树数据是否深度响应式?→ markRaw + shallowRef。
是否一次性展开太多?→ 减少 default-expanded-keys。
搜索/过滤是否重?→ 简化算法 + 输入防抖。
是否每次渲染都 new 映射/函数?→ 提升为模块常量。
是否使用内联大 SVG?→ 改 sprite。
仅在展开的分支高亮/计算,避免全树反复遍历。
五、一个综合示例(兼顾体验与性能)
c
<template>
<el-input v-model="kw" placeholder="搜索" @input="onInput" class="mb-2" />
<el-tree
:data="treeData" :props="props" node-key="id"
lazy :load="loadNode" render-after-expand
:filter-node-method="filterNode"
:expand-on-click-node="false"
>
<template #icon="{ data }">
<!-- 使用 sprite 的 <use>,极轻 -->
<svg class="tree-icon"><use :href="`#icon-${data.type}`"></use></svg>
</template>
</el-tree>
</template>
<script setup lang="ts">
import { shallowRef, markRaw } from 'vue'
import { debounce } from 'lodash-es'
// 常量与映射
const props = markRaw({ label:'label', children:'children' })
const treeData = shallowRef(markRaw([{ id:1, label:'根', hasChildren:true }]))
const loadNode = (node, resolve) => {
// 只在展开时装载 children
if (node.level === 0) return resolve(getChildren(null))
resolve(getChildren(node.data.id))
}
const filterNode = (value:string, data:any) => {
if (!value) return true
// 尽量简单:小写包含匹配
return String(data.label).toLowerCase().includes(value.toLowerCase())
}
const kw = shallowRef('')
const onInput = debounce(() => {
// 调用 tree 的 filter(value),这里省略 ref 绑定
// treeRef.value!.filter(kw.value)
}, 250)
// 伪接口
function getChildren(parentId:number|null){
// 返回轻对象数组,不要带多余响应式字段
return new Array(50).fill(0).map((_,i)=>({
id: `${parentId ?? 'r'}-${i}`,
label: `节点 ${parentId ?? 'root'}-${i}`,
type: i % 3 ? 'file' : 'folder',
hasChildren: i % 3 === 0
}))
}
</script>
<style scoped>
.tree-icon { width:16px; height:16px; margin-right:4px; vertical-align:middle; }
</style>
六、常见坑位提醒
在插槽里写 :is="iconMap[data.type]" 且 iconMap 每次 render 都新建 ⇒ 必卡。
filter-node-method 里跑正则或跨字段复杂逻辑 ⇒ 大数据下雪上加霜。
把整棵树 reactive(),然后频繁 splice/push ⇒ 依赖追踪巨大;尽量"整替换"+ shallowRef(markRaw(...))。
大量 default-expanded-keys + 首屏就跑 filter ⇒ "双杀"。
以上就是文章全部内容了,如果喜欢这篇文章的话,还希望三连支持一下,感谢!