三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Vue Element UI el-tree组件全选、展开等基础功能封装实战

Vue Element UI el-tree组件全选、展开等基础功能封装实战

1. 项目概述:el-tree基础功能封装实战

在Vue项目里用Element UI的el-tree组件,几乎是处理树形数据的标配。但每次新开一个页面,涉及到全选、清空、展开、收缩这些基础操作,你是不是又要去翻文档,或者从老项目里复制粘贴一遍代码?更头疼的是,不同页面的tree数据源、选中逻辑可能还不一样,复制过来的代码总得改改才能用,一来二去,项目里就散落着各种大同小异的“轮子”。

今天,我们就来彻底解决这个问题。我将基于一个真实的后台管理系统场景,分享如何对el-tree的这些基础功能进行一次高质量的封装。目标很明确:封装一个通用的TreeOperate组件,它不关心你的数据从哪里来(是权限树、部门树还是分类树),只提供一套稳定、易用的操作方法。你只需要传入一个tree的ref,就能像调用函数一样,轻松实现全选、清空、展开所有节点、收缩所有节点,甚至获取当前所有选中节点。这不仅能极大提升开发效率,更能保证项目里树形控件操作逻辑的一致性,减少潜在的bug。

2. 核心思路与设计考量

2.1 为什么需要封装?

直接使用el-tree的原生方法当然可以,但存在几个明显问题:

  1. 代码重复:每个用到tree的页面,你都要写一遍this.$refs.tree.setCheckedKeys([])来清空,写一遍递归来展开所有节点。
  2. 依赖Ref:操作严重依赖模板中ref="tree"的获取,如果ref名称不一致或获取时机不对(比如tree还未渲染),就容易报错。
  3. 逻辑分散:全选逻辑可能混杂在业务代码里,比如全选前需要过滤掉某些禁用的节点,这个逻辑散落在各处难以维护。
  4. 体验不一致:不同开发者实现“展开全部”的细节可能不同(比如是否展开懒加载的节点),导致用户操作体验有差异。

封装的核心思想是面向接口编程。我们不直接操作DOM或组件实例,而是通过一个统一的“操作器”来发起指令,由这个操作器内部去处理与具体el-tree实例的交互。

2.2 方案选型:Composition API vs Mixins

在Vue 3和Vue 2.7+的生态下,我们有多种选择。

  • Option API (Vue 2传统方式):在mixins里定义方法,混入到组件中。缺点明显:命名冲突、数据来源不清晰、类型支持差。
  • Composition API (推荐):使用refcomputed和自定义组合式函数,逻辑可以更灵活地组合和复用,并且天然对TypeScript友好。

我们的封装将主要采用Composition API的思想,即使你的项目暂时是Vue 2,也可以使用@vue/composition-api插件来实现。我们会创建一个自定义Hook(或Composable函数),例如useTreeOperations,它接收一个tree组件的引用,返回一系列操作方法。

2.3 设计目标与接口定义

我们希望最终的使用方式尽可能简洁。理想状态下,在Vue组件中应该是这样的:

<template> <el-tree ref="treeRef" ... /> <button @click="operateTree.selectAll()">全选</button> <button @click="operateTree.clear()">清空</button> </template> <script setup> import { ref, onMounted } from 'vue' import { useTreeOperations } from '@/hooks/useTreeOperations' const treeRef = ref(null) // el-tree的引用 let operateTree = null onMounted(() => { // 初始化操作器,传入tree引用 operateTree = useTreeOperations(treeRef) }) </script>

为了实现这个目标,我们的useTreeOperations需要提供以下接口:

  • selectAll(options): 全选。可配置是否包含半选节点、是否跳过禁用节点。
  • clear(): 清空所有选中。
  • expandAll(): 展开所有节点。
  • collapseAll(): 收缩所有节点。
  • getCheckedNodes(): 获取所有选中的节点数据。
  • getCheckedKeys(): 获取所有选中的节点key。

3. 核心功能实现与源码解析

接下来,我们深入每个功能的实现细节。我会先给出代码,然后解释关键点和注意事项。

3.1 初始化与Ref处理

首先,我们需要安全地获取el-tree的组件实例。el-tree通过Vue的ref暴露出来,但它的类型可能是nullundefined,特别是在组件尚未挂载时。我们的操作函数需要处理这种边界情况。

// hooks/useTreeOperations.js import { getCurrentInstance, onMounted } from 'vue' export function useTreeOperations(treeRef) { // 内部保存tree实例 let treeInstance = null // 一个初始化方法,确保在组件mounted后调用 const init = (ref) => { if (!ref || !ref.value) { console.warn('Tree reference is not available.') return } treeInstance = ref.value // 验证treeInstance是否具有el-tree的必要方法 if (treeInstance && typeof treeInstance.setCheckedKeys === 'function') { console.log('Tree operations initialized successfully.') } else { console.error('The provided ref is not a valid el-tree instance.') treeInstance = null } } // 如果传入的是ref,立即初始化;也可以在onMounted后手动调用init if (treeRef) { // 注意:如果treeRef是在setup中定义的,此时可能还未绑定到组件实例上。 // 更稳妥的做法是在onMounted钩子中调用init。 } // 提供手动初始化的方法 const initialize = (ref) => init(ref) // 后续所有操作都先检查treeInstance是否存在 const checkInstance = () => { if (!treeInstance) { throw new Error('Tree operations are not initialized. Please call initialize() first.') } return true } return { initialize, // 其他操作方法... } }

注意:这里有一个关键细节。在Vue 3的<script setup>中,模板ref在组件挂载前是null。因此,最安全的做法不是在useTreeOperations内部立即初始化,而是暴露一个initialize方法,由调用者在组件的onMounted生命周期中执行。或者,我们可以利用watchwatchEffect来监听treeRef的变化,但为了逻辑清晰,我们采用手动初始化。

3.2 全选功能实现

el-tree本身没有直接的全选API。我们需要自己计算所有可选节点的key。这里涉及到几个关键点:数据源node-key配置、以及禁用节点的处理。

// 在useTreeOperations函数内部 const selectAll = (options = {}) => { if (!checkInstance()) return const { includeHalfChecked = false, // 是否包含当前半选的节点 skipDisabled = true, // 是否跳过被disabled的节点 data = null, // 可传入自定义数据,默认使用treeInstance.store.data } = options try { const store = treeInstance.store const treeData = data || store.data const allKeys = [] // 递归遍历函数 const traverse = (nodes) => { if (!nodes || !Array.isArray(nodes)) return nodes.forEach(node => { // 如果配置了跳过禁用节点,且当前节点被禁用,则跳过 if (skipDisabled && node.disabled) { // 即使跳过当前节点,仍需递归其子节点,因为子节点可能未被禁用 if (node.children && node.children.length > 0) { traverse(node.children) } return } // 获取节点key,使用配置的node-key属性 const nodeKey = treeInstance.nodeKey ? node[treeInstance.nodeKey] : node.id if (nodeKey !== undefined) { allKeys.push(nodeKey) } // 递归遍历子节点 if (node.children && node.children.length > 0) { traverse(node.children) } }) } traverse(treeData) // 如果需要包含半选节点,需要获取当前半选状态的key let keysToSet = [...allKeys] if (includeHalfChecked) { const halfCheckedKeys = treeInstance.getHalfCheckedKeys ? treeInstance.getHalfCheckedKeys() : [] // 合并去重 keysToSet = Array.from(new Set([...keysToSet, ...halfCheckedKeys])) } // 调用el-tree的API设置选中 treeInstance.setCheckedKeys(keysToSet) console.log(`Select all completed. Total ${keysToSet.length} nodes selected.`) } catch (error) { console.error('Failed to select all nodes:', error) } }

实操要点

  1. node-key是核心:el-tree通过node-key属性来唯一标识节点。我们的遍历函数必须使用这个key来收集节点标识。通常这个属性会设置为id
  2. 递归遍历:树形数据是嵌套的,递归是最直接的遍历方式。确保你的递归终止条件正确,避免无限循环。
  3. 禁用节点的处理:业务上,禁用的节点(如无权限的部门)通常不允许被选中。我们的skipDisabled参数提供了这个灵活性。注意,即使父节点被禁用,其子节点也可能未被禁用,所以递归不能因为遇到禁用节点就完全停止。
  4. 半选状态:在父子节点关联选中(check-strictlyfalse)时,如果只选了部分子节点,父节点会处于半选状态。getHalfCheckedKeys()方法可以获取这些节点的key。是否包含它们取决于业务,例如“全选”操作可能希望把半选节点也变为全选。

3.3 清空功能实现

清空功能相对简单,直接调用el-tree的setCheckedKeys([])方法即可。但需要考虑是否同时清空半选状态。

const clear = (clearHalfChecked = true) => { if (!checkInstance()) return try { treeInstance.setCheckedKeys([]) if (clearHalfChecked && treeInstance.setHalfCheckedKeys) { // 注意:element-ui的el-tree可能没有setHalfCheckedKeys方法 // element-plus的el-tree有getHalfCheckedKeys,但设置半选通常不是直接API // 更常见的做法是,清空选中后,半选状态自然消失。 // 这里我们主要处理选中状态的清空。 } console.log('All checked nodes cleared.') } catch (error) { console.error('Failed to clear checked nodes:', error) } }

注意:在Element UI(对应Vue 2)中,getHalfCheckedKeyssetHalfCheckedKeys可能不存在。在Element Plus(对应Vue 3)中,这些API是存在的。封装时要注意版本差异。一个更兼容的做法是,清空选中键后,半选状态通常会随之更新,不需要额外处理。

3.4 展开与收缩功能实现

展开和收缩需要遍历所有节点,并修改它们的expanded状态。el-tree提供了store对象,里面包含了所有节点的数据模型(Node对象),我们可以通过它来操作。

const expandAll = () => { if (!checkInstance()) return const store = treeInstance.store if (!store) return // 遍历所有节点,将其展开 const expandNode = (node) => { if (node && node.expanded !== true) { node.expanded = true // 对于非懒加载树,还需要触发视图更新。直接操作node后,可能需要调用tree的updateKeyChildren。 // 更标准的方法是使用store的`_setExpanded`方法或直接调用node的expand方法。 } // 递归处理子节点 if (node.childNodes) { node.childNodes.forEach(child => expandNode(child)) } } // 从根节点开始遍历 store.root.childNodes.forEach(node => expandNode(node)) // 对于el-tree,更简单的方式是直接操作data并触发更新,但可能不响应式。 // 另一种可靠但“黑盒”的方式:利用el-tree的`default-expand-all`属性在数据变化时重置,但这不够动态。 }

上面的方法直接操作node.expanded可能无法触发视图更新。更可靠、更推荐的做法是利用el-tree的updateKeyChildren方法,或者采用一种更巧妙的方法:通过改变一个响应式变量,触发tree的重新渲染,并结合default-expand-all属性。

这里分享一个我在实际项目中验证有效的方案:

const expandAll = () => { if (!checkInstance()) return try { // 思路:获取当前所有节点的key,然后通过setCurrentKey等API间接触发? // 实际上,el-tree没有直接的expandAll API。 // 方法:递归调用每个节点的expand方法。store里的每个node有一个expand方法。 const expand = (nodes) => { nodes.forEach(node => { node.expand() // 调用节点的expand方法 if (node.childNodes && node.childNodes.length > 0) { expand(node.childNodes) } }) } expand(treeInstance.store.root.childNodes) } catch (error) { console.error('Failed to expand all nodes:', error) } } const collapseAll = () => { if (!checkInstance()) return try { const collapse = (nodes) => { nodes.forEach(node => { node.collapse() // 调用节点的collapse方法 if (node.childNodes && node.childNodes.length > 0) { collapse(node.childNodes) } }) } collapse(treeInstance.store.root.childNodes) } catch (error) { console.error('Failed to collapse all nodes:', error) } }

关键解析

  1. storenode对象:el-tree内部使用一个Store类管理状态,每个节点对应一个Node对象。node.expand()node.collapse()是这些内部对象的方法,它们会改变节点的expanded属性并触发视图更新。
  2. 递归调用:我们需要从根节点的直接子节点(store.root.childNodes)开始,递归地对每一个节点调用expand()collapse()
  3. 性能考量:对于节点数量巨大的树(如超过1000个),一次性展开所有节点可能导致页面卡顿。在生产环境中,可以考虑加入防抖或懒加载优化。

3.5 获取选中状态

el-tree提供了getCheckedNodesgetCheckedKeys方法,封装它们主要是为了统一错误处理和可能的格式转换。

const getCheckedNodes = (leafOnly = false, includeHalfChecked = false) => { if (!checkInstance()) return [] try { let nodes = [] if (includeHalfChecked && treeInstance.getHalfCheckedNodes) { const halfNodes = treeInstance.getHalfCheckedNodes() nodes = [...halfNodes] } const checkedNodes = treeInstance.getCheckedNodes(leafOnly) // 合并去重,基于节点唯一标识(如id) const mergedMap = new Map() ;[...nodes, ...checkedNodes].forEach(node => { const key = node[treeInstance.nodeKey] || node.id if (key !== undefined) { mergedMap.set(key, node) } }) return Array.from(mergedMap.values()) } catch (error) { console.error('Failed to get checked nodes:', error) return [] } } const getCheckedKeys = (leafOnly = false) => { if (!checkInstance()) return [] try { return treeInstance.getCheckedKeys(leafOnly) } catch (error) { console.error('Failed to get checked keys:', error) return [] } }

4. 完整封装与组件集成示例

现在,我们将上述所有功能整合到useTreeOperations这个自定义Hook中,并提供一个在Vue组件中集成的完整示例。

4.1 完整的Hook代码

// src/hooks/useTreeOperations.js import { ref, onMounted } from 'vue' /** * el-tree通用操作Hook * @param {Ref} treeRef - el-tree组件的模板引用 * @returns {Object} 操作对象 */ export function useTreeOperations(treeRef) { const treeInstance = ref(null) const isInitialized = ref(false) // 初始化,应在onMounted后调用 const initialize = (ref) => { if (!ref || !ref.value) { console.warn('[useTreeOperations] Tree ref is invalid.') return false } treeInstance.value = ref.value // 基础验证 if (treeInstance.value && typeof treeInstance.value.setCheckedKeys === 'function') { isInitialized.value = true console.log('[useTreeOperations] Initialized successfully.') return true } else { console.error('[useTreeOperations] The ref is not a valid el-tree instance.') treeInstance.value = null return false } } const checkInstance = () => { if (!isInitialized.value || !treeInstance.value) { console.error('[useTreeOperations] Not initialized or instance lost. Call initialize() first.') return false } return true } // 全选 const selectAll = (options = {}) => { if (!checkInstance()) return const { includeHalfChecked = false, skipDisabled = true, customData } = options const instance = treeInstance.value const store = instance.store const treeData = customData || store.data const allKeys = [] const traverse = (nodes) => { if (!Array.isArray(nodes)) return nodes.forEach(node => { if (skipDisabled && node.disabled) { if (node.children) traverse(node.children) return } const nodeKey = instance.nodeKey ? node[instance.nodeKey] : node.id if (nodeKey !== undefined) allKeys.push(nodeKey) if (node.children) traverse(node.children) }) } traverse(treeData) let keysToSet = allKeys if (includeHalfChecked && instance.getHalfCheckedKeys) { const halfKeys = instance.getHalfCheckedKeys() keysToSet = Array.from(new Set([...allKeys, ...halfKeys])) } try { instance.setCheckedKeys(keysToSet) } catch (err) { console.error('[useTreeOperations] selectAll error:', err) } } // 清空 const clear = () => { if (!checkInstance()) return try { treeInstance.value.setCheckedKeys([]) } catch (err) { console.error('[useTreeOperations] clear error:', err) } } // 展开全部 const expandAll = () => { if (!checkInstance()) return const expand = (nodes) => { nodes.forEach(node => { // 确保node有expand方法且未展开 if (node.expand && !node.expanded) { node.expand() } if (node.childNodes) expand(node.childNodes) }) } try { expand(treeInstance.value.store.root.childNodes) } catch (err) { console.error('[useTreeOperations] expandAll error:', err) } } // 收缩全部 const collapseAll = () => { if (!checkInstance()) return const collapse = (nodes) => { nodes.forEach(node => { if (node.collapse && node.expanded) { node.collapse() } if (node.childNodes) collapse(node.childNodes) }) } try { collapse(treeInstance.value.store.root.childNodes) } catch (err) { console.error('[useTreeOperations] collapseAll error:', err) } } // 获取选中节点和key(封装原生方法,增加容错) const getCheckedNodes = (leafOnly = false) => { return checkInstance() ? treeInstance.value.getCheckedNodes(leafOnly) : [] } const getCheckedKeys = (leafOnly = false) => { return checkInstance() ? treeInstance.value.getCheckedKeys(leafOnly) : [] } const getHalfCheckedNodes = () => { return checkInstance() && treeInstance.value.getHalfCheckedNodes ? treeInstance.value.getHalfCheckedNodes() : [] } const getHalfCheckedKeys = () => { return checkInstance() && treeInstance.value.getHalfCheckedKeys ? treeInstance.value.getHalfCheckedKeys() : [] } return { initialize, isInitialized, selectAll, clear, expandAll, collapseAll, getCheckedNodes, getCheckedKeys, getHalfCheckedNodes, getHalfCheckedKeys, } }

4.2 在Vue组件中的使用示例

<template> <div class="tree-demo"> <div class="operation-bar"> <el-button @click="handleSelectAll">全选</el-button> <el-button @click="handleClear">清空</el-button> <el-button @click="handleExpandAll">展开全部</el-button> <el-button @click="handleCollapseAll">收缩全部</el-button> <el-button @click="logChecked">打印选中项</el-button> </div> <el-tree ref="treeRef" :data="treeData" node-key="id" show-checkbox default-expand-all :props="defaultProps" /> </div> </template> <script setup> import { ref, onMounted } from 'vue' import { ElMessage } from 'element-plus' // 或 element-ui import { useTreeOperations } from '@/hooks/useTreeOperations' // 模拟树形数据 const treeData = ref([ { id: 1, label: '一级 1', children: [ { id: 4, label: '二级 1-1', children: [{ id: 9, label: '三级 1-1-1' }, { id: 10, label: '三级 1-1-2' }] }, ], }, { id: 2, label: '一级 2', children: [ { id: 5, label: '二级 2-1' }, { id: 6, label: '二级 2-2', disabled: true }, // 禁用的节点 ], }, { id: 3, label: '一级 3', children: [{ id: 7, label: '二级 3-1' }, { id: 8, label: '二级 3-2' }] }, ]) const defaultProps = { children: 'children', label: 'label' } // 模板引用 const treeRef = ref(null) // 初始化操作Hook const treeOps = useTreeOperations() // 组件挂载后初始化操作器 onMounted(() => { if (treeRef.value) { const success = treeOps.initialize(treeRef) if (!success) { ElMessage.warning('树组件初始化失败') } } }) // 按钮事件处理 const handleSelectAll = () => { treeOps.selectAll({ skipDisabled: true }) // 跳过禁用节点 } const handleClear = () => { treeOps.clear() } const handleExpandAll = () => { treeOps.expandAll() } const handleCollapseAll = () => { treeOps.collapseAll() } const logChecked = () => { const nodes = treeOps.getCheckedNodes() const keys = treeOps.getCheckedKeys() console.log('选中的节点:', nodes) console.log('选中的键:', keys) ElMessage.info(`选中了 ${nodes.length} 个节点`) } </script> <style scoped> .tree-demo { padding: 20px; } .operation-bar { margin-bottom: 20px; } </style>

5. 进阶优化与常见问题排查

5.1 性能优化:应对大数据量树

当树节点数量庞大(比如超过5000个)时,全选、展开全部等操作可能会阻塞UI线程。我们可以采用以下策略优化:

  1. 分片操作:将全选遍历操作拆分成多个小任务,使用setTimeoutrequestAnimationFrame放入事件循环的不同阶段执行,避免长时间占用主线程。
    const selectAllLazy = (options) => { // ... 获取allKeys const chunkSize = 100 // 每批处理100个节点 let index = 0 const setKeysChunk = () => { const chunk = allKeys.slice(index, index + chunkSize) if (chunk.length > 0) { // 注意:setCheckedKeys会替换所有选中项,不能分批设置。 // 对于全选,可以一次性设置所有key,但遍历收集key的过程可以分片。 // 这里演示的是遍历数据收集key的分片。 index += chunkSize setTimeout(setKeysChunk, 0) // 下一帧继续 } else { // 收集完所有key后,一次性设置 treeInstance.value.setCheckedKeys(allKeys) } } setKeysChunk() }
  2. 虚拟滚动:考虑使用支持虚拟滚动的树组件(如Element Plus的el-tree-v2)来从根本上解决渲染大量DOM节点的性能问题。我们的操作Hook需要适配不同组件版本。
  3. 防抖操作:为频繁触发的方法(如搜索后展开对应节点)添加防抖。

5.2 兼容性处理:Element UI vs Element Plus

我们的Hook需要处理两个主要版本的差异:

功能/方法Element UI (Vue 2)Element Plus (Vue 3)我们的处理策略
获取半选节点getHalfCheckedNodes()getHalfCheckedNodes()方法名相同,直接调用。
获取半选键getHalfCheckedKeys()getHalfCheckedKeys()方法名相同,直接调用。
节点对象node.expand()存在node.expand()存在通用。
Ref类型this.$refs.treetreeRef.value通过Vue的ref统一处理。
组合式API需插件@vue/composition-api原生支持Hook内部使用Composition API,在Vue2项目中需确保安装插件。

在Hook中,对于可能不存在的方法,使用可选链操作符?.或进行条件判断,增强健壮性。

const halfCheckedKeys = treeInstance.value.getHalfCheckedKeys?.() || []

5.3 常见问题与排查技巧

在实际使用中,你可能会遇到以下问题:

  1. 操作无效,控制台无报错

    • 检查点:首先确认treeOps.isInitialized是否为true。确保在onMounted之后才调用initialize
    • 检查点:确认传入的treeRef是否正确指向了el-tree组件。在复杂组件结构中,ref可能被覆盖。
    • 检查点:检查el-tree的node-key属性是否设置,且值在数据中唯一。这是所有选中操作的基础。
  2. 全选时选中了不想选的节点

    • 检查点:确认skipDisabled参数是否传入了true。如果业务上某些节点应被跳过,但数据中disabled字段不为true,则需要在遍历逻辑中添加自定义过滤条件。
    • 检查点:检查数据中是否有重复的node-key值,这会导致选中状态错乱。
  3. 展开/收缩全部对懒加载节点无效

    • 原因:懒加载节点的子数据在未展开时不存在于node.childNodes中,因此expand()方法可能无法加载子节点。
    • 解决方案:对于懒加载树,展开全部是一个复杂需求,通常需要结合业务逻辑。一种思路是递归调用el-tree的load方法,但需注意性能。在封装时,可以为expandAll增加一个lazyLoad参数,提示用户此功能对懒加载树有限制。
  4. TypeScript类型支持为了让Hook更好用,可以为其添加TypeScript类型定义。

    // useTreeOperations.ts import type { Ref } from 'vue' import type ElTree from 'element-plus/es/components/tree' // 或 element-ui的类型 export interface TreeOperations { initialize: (ref: Ref<InstanceType<typeof ElTree> | null>) => boolean selectAll: (options?: SelectAllOptions) => void clear: () => void // ... 其他方法 } export function useTreeOperations(treeRef?: Ref): TreeOperations { ... }

5.4 封装后的价值与扩展思路

通过这次封装,我们得到了一个与业务逻辑解耦的树操作工具。它带来了几个明显的好处:

  • 高复用:任何页面的el-tree都可以直接使用,无需重复编写逻辑。
  • 易维护:所有树操作逻辑集中在一处,bug修复和功能升级只需修改Hook文件。
  • 可测试:可以单独为useTreeOperations编写单元测试,保证核心逻辑的稳定性。

你还可以在此基础上进行扩展:

  • 选中策略模式:将全选算法(如是否跳过叶子节点、是否只选父节点等)抽象为策略,通过参数切换。
  • 操作历史记录:实现简单的撤销/重做功能,记录选中状态的变化。
  • 与状态管理集成:将当前选中的节点key列表同步到Pinia或Vuex中,供其他组件使用。

最后,封装没有银弹,最重要的是贴合自己项目的实际需求。希望这个详细的el-tree功能封装总结,能帮你下次面对树形控件时,更加游刃有余。

← 返回列表