vue3.0 使用el-tree节点添加自定义图标造成加载缓慢的多种解决办法

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 ⇒ "双杀"。

以上就是文章全部内容了,如果喜欢这篇文章的话,还希望三连支持一下,感谢!

相关推荐
好奇的候选人面向对象3 小时前
基于 Element Plus 的 TableColumnGroup 组件使用说明
开发语言·前端·javascript
送鱼的老默3 小时前
学习笔记-JavaScript的原型和原型链
javascript
叫我詹躲躲3 小时前
Vue 3 ref 与 reactive 选哪个?
前端·vue.js
程序员Sunday3 小时前
Vite 要收费啦?虚拟 DOM 要取消啦?尤雨溪这次玩了把大的!
前端·vue.js
云枫晖3 小时前
webpack系列-plugin
前端·webpack
啃火龙果的兔子3 小时前
前端八股文es6篇
前端·ecmascript·es6
困惑阿三3 小时前
ES6冷门API
前端·ecmascript·es6
小p3 小时前
react学习1:基本概念
前端
老前端的功夫3 小时前
ES6 模块 vs CommonJS:从历史背景到引擎实现的深度解析
前端·javascript