基于Vue3 + Element Plus Tree-v2 虚拟树组件封装开发记录

在企业级后台管理系统中,树状结构组件是数据展示与交互的常用组件。当数据层级复杂或节点数量较多时,支持搜索过滤、动态展开折叠的树组件能显著提升用户体验。本文将介绍一个基于 Vue3 和 Element Plus 开发的可过滤树状组件TreeFilter,涵盖功能设计、核心实现与应用场景。

一、组件功能概览

TreeFilter组件具备以下核心功能:

  1. 搜索过滤:支持关键字模糊匹配,递归过滤节点及其子节点
  2. 全展开 / 折叠:通过下拉菜单快速操作所有节点展开状态
  3. 多选 / 单选模式 :通过multiple属性切换,适配不同业务场景
  4. 动态高度计算:根据容器尺寸自动计算树体高度,适配响应式布局
  5. 数据驱动 :支持静态数据与异步接口加载,通过requestApi实现数据获取
  6. 双向绑定 :通过modelValue实现选中状态的父子组件通信

二、核心实现解析

1. 动态高度计算

通过ResizeObserver监听容器尺寸变化,实时计算树体可用高度:

javascript 复制代码
// 计算树体高度
const calculateTreeHeight = () => {
  if (!containerRef.value || !treeContainerRef.value) return;
  const containerHeight = containerRef.value.clientHeight;
  // 扣除标题、搜索栏、内边距等固定高度
  treeHeight.value = containerHeight - titleHeight - searchHeight - padding - gapHeight;
};

// 初始化与监听
onMounted(() => {
  calculateTreeHeight();
  resizeObserver = new ResizeObserver(() => calculateTreeHeight());
  resizeObserver.observe(containerRef.value);
});
  • 优势:相比window.resize更精准,仅监听目标容器变化
  • 场景:适配卡片式布局、响应式页面缩放
2. 搜索过滤逻辑

通过computed属性生成过滤后的数据,实现递归匹配与路径保留:

javascript 复制代码
const filteredData = computed(() => {
  if (!filterText.value) return props.multiple ? treeData.value : treeAllData.value;
  
  // 递归过滤函数
  const filter = (nodes) => nodes.filter(node => {
    const match = node[props.label].toLowerCase().includes(filterText.value);
    if (node.children && node.children.length) {
      const childrenMatch = filter(node.children);
      if (childrenMatch.length > 0) return true; // 子节点匹配则保留父节点
    }
    return match;
  });
  
  // 构建包含匹配路径的树结构
  const buildFilteredTree = (nodes) => {
    const paths = getAllPaths(nodes); // 获取所有匹配路径
    const result = [];
    const addedNodes = new Set();
    paths.forEach(path => {
      path.forEach((node, index) => {
        if (!addedNodes.has(node.id)) {
          addedNodes.add(node.id);
          result.push({
            ...node,
            children: index < path.length - 1 ? [path[index+1]] : node.children // 保留路径所需子节点
          });
        }
      });
    });
    return result;
  };
  
  return props.multiple 
    ? buildFilteredTree(treeData.value) 
    : [{ id: "", label: "全部" }, ...buildFilteredTree(treeData.value)];
});
  • 关键逻辑:

    • 父节点自动展开:当子节点匹配时,自动保留父节点并展开
    • 路径完整性:确保过滤后的树结构包含从根到匹配节点的完整路径
    • 性能优化:利用computed缓存,避免重复计算
3. 节点状态管理

通过expandedKeyssetExpandedKeys实现全展开 / 折叠:

javascript 复制代码
const toggleTreeNodes = (isExpand) => {
  if (!treeRef.value) return;
  const keys = isExpand 
    ? getAllKeys(treeData.value) // 获取所有节点key
    : [];
  treeRef.value.setExpandedKeys(keys);
};
  • 辅助函数getAllKeys实现深度遍历获取所有节点 ID:
javascript 复制代码
const getAllKeys = (data) => {
  const keys = [];
  const traverse = (nodes) => {
    nodes.forEach(node => {
      keys.push(node[props.id]);
      node.children && traverse(node.children);
    });
  };
  traverse(data);
  return keys;
};
4. 样式深度定制

通过深度选择器 (:deep) 修改 Element Plus 原始样式,实现展开动画与主题适配:

scss 复制代码
.tree-container {
  :deep(.el-tree-v2__node-children) {
    transition: all 0.3s ease-in-out;
    overflow: hidden;
  }
  :deep(.el-tree-v2__node.is-expanded) {
    > .el-tree-v2__node-children {
      opacity: 1;
      transform: translateY(0);
    }
  }
  :deep(.el-tree-v2__node:not(.is-expanded)) {
    > .el-tree-v2__node-children {
      opacity: 0;
      transform: translateY(-10px);
      height: 0;
    }
  }
  :deep(.el-tree-v2__expand-icon.is-expanded) {
    transform: rotate(90deg);
  }
}
  • 动画效果:展开时伴随淡入与位移动画
  • 视觉优化:选中节点高亮、鼠标悬停状态反馈

三、典型应用场景

1. 权限管理系统
html 复制代码
<TreeFilter 
  title="权限列表"
  :data="permissionTree"
  multiple
  @change="handlePermissionChange"
/>
  • 场景:配置角色权限时,通过搜索快速定位权限节点,支持多选分配
2. 目录结构浏览
html 复制代码
<TreeFilter 
  request-api="fetchDirectoryData"
  :model-value="selectedFile"
  @change="handleFileSelect"
/>
  • 场景:文件管理系统中,通过异步接口加载目录结构,单选模式选择文件

四、组件使用指南

1. 基础用法
html 复制代码
<template>
  <TreeFilter 
    title="部门结构"
    :data="deptTree"
    :model-value="selectedDept"
    @change="onDeptSelect"
  />
</template>

<script setup>
import { ref } from 'vue';
const deptTree = ref([/* 树状数据 */]);
const selectedDept = ref(null);
</script>
2. 异步数据加载
html 复制代码
<TreeFilter 
  :request-api="fetchAsyncData"
  multiple
  :default-value="initialValues"
/>

<script setup>
const fetchAsyncData = async () => {
  const res = await axios.get('/api/tree-data');
  return res.data;
};
</script>
3. 自定义节点内容
html 复制代码
<TreeFilter :data="treeData">
  <template #default="{ node, data }">
    <span>
      <el-icon>{{ data.icon }}</el-icon>
      {{ data.label }}
      <span class="badge">{{ data.count }}</span>
    </span>
  </template>
</TreeFilter>

五、优化与扩展方向

  1. 大数据量优化

    • 集成虚拟滚动(如vue-virtual-scroller),处理万级节点渲染性能问题
    • 添加加载更多机制,按需加载子节点数据
  2. 交互增强

    • 支持键盘导航(箭头键移动焦点、Enter 选择节点)
    • 过滤时高亮匹配关键字
  3. 国际化支持

    • 提取文案为可配置项,支持多语言切换
  4. 状态持久化

    • 记住用户展开状态与搜索历史,刷新页面后恢复

六、总结

TreeFilter组件通过合理的逻辑分层与性能优化,实现了功能丰富的树状结构交互。其核心价值在于:

  • 解耦设计:数据加载、过滤逻辑与视图层分离,易于扩展
  • 响应式适配:动态高度计算与 ResizeObserver 实现灵活布局

完整组件代码

JavaScript 复制代码
<template>
    <div class="card filter" ref="containerRef">
      <h4 v-if="title" class="title sle">
        {{ title }}
      </h4>
      <div class="search">
        <el-input v-model="filterText" placeholder="输入关键字进行过滤" clearable />
        <el-dropdown trigger="click">
          <el-icon size="20"><More /></el-icon>
          <template #dropdown>
            <el-dropdown-menu>
              <el-dropdown-item @click="toggleTreeNodes(true)">展开全部</el-dropdown-item>
              <el-dropdown-item @click="toggleTreeNodes(false)">折叠全部</el-dropdown-item>
            </el-dropdown-menu>
          </template>
        </el-dropdown>
      </div>
      <div class="tree-container" ref="treeContainerRef">
        <el-tree-v2
          ref="treeRef"
          :data="filteredData"
          :props="defaultProps"
          :height="treeHeight"
          :item-size="26"
          :show-checkbox="multiple"
          :check-strictly="false"
          :highlight-current="!multiple"
          :expand-on-click-node="false"
          :check-on-click-node="multiple"
          :default-expanded-keys="expandedKeys"
          :default-checked-keys="multiple ? selectedKeys : []"
          :current-node-key="!multiple ? selectedKey : ''"
          @node-click="handleNodeClick"
          @check="handleCheckChange"
        >
          <template #default="{ node, data }">
            <span class="el-tree-node__label">
              <slot :row="{ node, data }">
                {{ data[props.label] }}
              </slot>
            </span>
          </template>
        </el-tree-v2>
      </div>
    </div>
  </template>
  
  <script setup name="TreeFilter">
  import { ref, watch, onBeforeMount, nextTick, computed, onMounted, onBeforeUnmount } from "vue";
  import { ElTreeV2 } from "element-plus";
  import { More } from "@element-plus/icons-vue";
  
  const props = defineProps({
    requestApi: {
      type: Function,
      default: undefined
    },
    data: {
      type: Array,
      default: () => []
    },
    title: {
      type: String,
      default: ''
    },
    id: {
      type: String,
      default: 'id'
    },
    label: {
      type: String,
      default: 'label'
    },
    multiple: {
      type: Boolean,
      default: false
    },
    defaultValue: {
      type: [Object, Array],
      default: undefined
    },
    modelValue: {
      type: [Object, Array],
      default: undefined
    }
  });
  
  const defaultProps = {
    children: "children",
    label: props.label,
    key: props.id
  };
  
  const containerRef = ref();
  const treeContainerRef = ref();
  const treeRef = ref();
  const treeData = ref([]);
  const treeAllData = ref([]);
  const expandedKeys = ref([]);
  const treeHeight = ref(400);
  
  // 计算树的高度
  const calculateTreeHeight = () => {
    if (!containerRef.value || !treeContainerRef.value) return;
    
    const containerHeight = containerRef.value.clientHeight;
    const titleHeight = props.title ? 40 : 0; // 标题高度
    const searchHeight = 40; // 搜索框高度
    const padding = 20 * 2; // 上下padding
    let gapHeight = 10
    if(props.title){
        gapHeight = 20;
    }
    
    treeHeight.value = containerHeight - titleHeight - searchHeight - padding - gapHeight;
  };
  
  // 监听容器大小变化
  let resizeObserver = null;
  onMounted(() => {
    calculateTreeHeight();
    resizeObserver = new ResizeObserver(() => {
      calculateTreeHeight();
    });
    resizeObserver.observe(containerRef.value);
  });
  
  onBeforeUnmount(() => {
    if (resizeObserver) {
      resizeObserver.disconnect();
    }
  });
  
  const selectedKey = ref('');
  const selectedKeys = ref([]);
  
  const setSelected = () => {
    if (props.multiple) {
      const defaultValues = Array.isArray(props.defaultValue) ? props.defaultValue : [props.defaultValue];
      selectedKeys.value = defaultValues.map(item => item?.[props.id]).filter(Boolean);
    } else {
      selectedKey.value = props.defaultValue ? props.defaultValue[props.id] : "";
    }
  };
  
  // 获取所有节点的key
  const getAllKeys = (data) => {
    const keys = [];
    const traverse = (nodes) => {
      nodes.forEach(node => {
        keys.push(node[props.id]);
        if (node.children && node.children.length) {
          traverse(node.children);
        }
      });
    };
    traverse(data);
    return keys;
  };
  
  onBeforeMount(async () => {
    setSelected();
    if (props.requestApi) {
      const { data } = await props.requestApi();
      treeData.value = data;
      treeAllData.value = [{ id: "", [props.label]: "全部" }, ...data];
      expandedKeys.value = getAllKeys(data);
    }
  });
  
  watch(
    () => props.defaultValue,
    () => nextTick(() => setSelected()),
    { deep: true, immediate: true }
  );
  
  watch(
    () => props.data,
    () => {
      if (props.data?.length) {
        treeData.value = props.data;
        treeAllData.value = [{ id: "", [props.label]: "全部" }, ...props.data];
        expandedKeys.value = getAllKeys(props.data);
        nextTick(() => {
          setSelected();
        });
      }
    },
    { deep: true, immediate: true }
  );
  
  const filterText = ref("");
  watch(filterText, val => {
    if (treeRef.value) {
      treeRef.value.filter(val);
    }
  });
  
  const filterNode = (value, data) => {
    if (!value) return true;
    const label = data[props.label];
    if (!label) return false;
    
    // 检查当前节点
    if (label.toLowerCase().includes(value.toLowerCase())) {
      return true;
    }
    
    // 检查子节点
    if (data.children && data.children.length) {
      return data.children.some(child => filterNode(value, child));
    }
    
    return false;
  };
  
  // 添加过滤后的数据计算属性
  const filteredData = computed(() => {
    if (!filterText.value) {
      return props.multiple ? treeData.value : treeAllData.value;
    }

    const filter = (nodes) => {
      return nodes.filter(node => {
        const label = node[props.label];
        if (!label) return false;

        // 检查当前节点
        const match = label.toLowerCase().includes(filterText.value.toLowerCase());
        
        // 如果有子节点,递归检查
        if (node.children && node.children.length) {
          const childrenMatch = filter(node.children);
          if (childrenMatch.length > 0) {
            // 如果子节点匹配,保留当前节点并更新子节点
            return true;
          }
        }
        
        return match;
      }).map(node => {
        if (node.children && node.children.length) {
          const filteredChildren = filter(node.children);
          if (filteredChildren.length > 0) {
            return {
              ...node,
              children: filteredChildren
            };
          }
        }
        return node;
      });
    };

    // 获取所有匹配的节点路径
    const getAllPaths = (nodes, path = []) => {
      let paths = [];
      for (const node of nodes) {
        const currentPath = [...path, node];
        const label = node[props.label];
        
        if (label && label.toLowerCase().includes(filterText.value.toLowerCase())) {
          paths.push(currentPath);
        }
        
        if (node.children && node.children.length) {
          paths = paths.concat(getAllPaths(node.children, currentPath));
        }
      }
      return paths;
    };

    // 构建包含所有匹配路径的树
    const buildFilteredTree = (nodes) => {
      const paths = getAllPaths(nodes);
      const result = [];
      const addedNodes = new Set();

      paths.forEach(path => {
        path.forEach((node, index) => {
          const nodeId = node[props.id];
          if (!addedNodes.has(nodeId)) {
            addedNodes.add(nodeId);
            const newNode = { ...node };
            
            if (index < path.length - 1) {
              // 如果不是最后一个节点,添加子节点
              newNode.children = [path[index + 1]];
            } else {
              // 如果是最后一个节点,保持原有子节点
              newNode.children = node.children || [];
            }
            
            result.push(newNode);
          }
        });
      });

      return result;
    };

    const sourceData = props.multiple ? treeData.value : treeData.value;
    const filtered = buildFilteredTree(sourceData);
    
    return props.multiple ? filtered : [{ id: "", [props.label]: "全部" }, ...filtered];
  });
  
  const toggleTreeNodes = (isExpand) => {
    if (!treeRef.value) return;
    
    if (isExpand) {
      // 获取所有节点的key
      const keys = getAllKeys(treeData.value);
      treeRef.value.setExpandedKeys(keys);
    } else {
      treeRef.value.setExpandedKeys([]);
    }
  };
  
  const emit = defineEmits(['change', 'update:modelValue']);
  
  // 更新选中状态
  const updateSelectedState = () => {
    if (props.multiple) {
      // 多选模式
      selectedKeys.value = Array.isArray(props.modelValue) 
        ? props.modelValue.map(item => item[props.id])
        : [];
    } else {
      // 单选模式
      selectedKey.value = props.modelValue ? props.modelValue[props.id] : '';
    }
  };
  
  // 监听 modelValue 变化
  watch(
    () => props.modelValue,
    () => {
      updateSelectedState();
    },
    { immediate: true, deep: true }
  );
  
  // 监听数据变化
  watch(
    () => props.data,
    () => {
      if (props.data?.length) {
        treeData.value = props.data;
        treeAllData.value = [{ id: "", [props.label]: "全部" }, ...props.data];
        expandedKeys.value = getAllKeys(props.data);
        // 数据更新后重新设置选中状态
        nextTick(() => {
          updateSelectedState();
        });
      }
    },
    { deep: true, immediate: true }
  );
  
  // 根据id查找完整数据对象
  const findNodeById = (id, nodes) => {
    for (const node of nodes) {
      if (node[props.id] === id) {
        return node;
      }
      if (node.children && node.children.length) {
        const found = findNodeById(id, node.children);
        if (found) return found;
      }
    }
    return null;
  };
  
  const handleNodeClick = (data) => {
    if (props.multiple) return;
    selectedKey.value = data[props.id];
    emit("change", data);
    emit("update:modelValue", data);
  };
  
  const handleCheckChange = (data, { checkedKeys }) => {
    selectedKeys.value = checkedKeys;
    const selectedNodes = checkedKeys.map(key => {
      return findNodeById(key, treeData.value) || findNodeById(key, treeAllData.value);
    }).filter(Boolean);
    emit("change", selectedNodes);
    emit("update:modelValue", selectedNodes);
  };
  
  defineExpose({ treeData, treeAllData, treeRef });
  </script>
  
  <style scoped lang="scss">
    @import "./index.scss";
  </style>
  

完整样式文件

scss 复制代码
.card {
    box-sizing: border-box;
    padding: 20px;
    overflow-x: hidden;
    background-color: var(--el-bg-color);
    border: 1px solid var(--el-border-color-light);
    border-radius: 6px;
    box-shadow: 0 0 12px #0000000d;
}

.filter {
    box-sizing: border-box;
    width: 100%;
    height: 100%;
    padding: 18px;
    margin-right: 10px;
    gap: 10px;
    display: flex;
    flex-direction: column;
    .title {
        margin: 0 0 15px;
        font-size: 18px;
        font-weight: bold;
        color: var(--el-color-info-dark-2);
        letter-spacing: 0.5px;
    }
    .search {
        height: 40px;
        display: flex;
        align-items: center;
        gap: 10px;
        .el-input {
            flex: 1;
        }
        .el-icon {
            cursor: pointer;
            color: var(--el-text-color-secondary);
            transition: color 0.3s;
            &:hover {
                color: var(--el-color-primary);
            }
        }
    }
    .tree-container {
        height: calc(100% - 40px);
        flex: 1;
        overflow: hidden;
        // margin: -10px 0;
        // padding: 10px 0;
         :deep(.el-tree--highlight-current) {
            .el-tree-node.is-current>.el-tree-node__content {
                background-color: var(--el-color-primary);
                .el-tree-node__label,
                .el-tree-node__expand-icon {
                    color: white;
                }
                .is-leaf {
                    color: transparent;
                }
            }
        }
         :deep(.el-tree-v2) {
            height: calc(100% + 20px);
            margin: -10px 0;
        }
         :deep(.el-tree-v2__node) {
            .el-tree-v2__node-content {
                height: 33px;
                transition: background-color 0.3s;
                &:hover {
                    background-color: var(--el-fill-color-light);
                }
            }
            &.is-current {
                >.el-tree-v2__node-content {
                    background-color: var(--el-color-primary);
                    color: white;
                    .el-tree-v2__expand-icon {
                        color: white;
                    }
                }
            }
            // 展开收起动画
            .el-tree-v2__node-children {
                transition: all 0.3s ease-in-out;
                overflow: hidden;
            }
            &.is-expanded {
                >.el-tree-v2__node-children {
                    opacity: 1;
                    transform: translateY(0);
                }
            }
            &:not(.is-expanded) {
                >.el-tree-v2__node-children {
                    opacity: 0;
                    transform: translateY(-10px);
                    height: 0;
                }
            }
        }
        // 展开图标动画
         :deep(.el-tree-v2__expand-icon) {
            transition: transform 0.3s ease-in-out;
            &.is-expanded {
                transform: rotate(90deg);
            }
        }
    }
}
相关推荐
棉花糖超人29 分钟前
【从0-1的HTML】第2篇:HTML标签
前端·html
exploration-earth37 分钟前
本地优先的状态管理与工具选型策略
开发语言·前端·javascript
OpenTiny社区1 小时前
开源之夏报名倒计时3天!还有9个前端任务有余位,快来申请吧~
前端·github
ak啊1 小时前
WebGL魔法:从立方体到逼真阴影的奇妙之旅
前端·webgl
hang_bro1 小时前
使用js方法实现阻止按钮的默认点击事件&触发默认事件
前端·react.js·html
哈贝#1 小时前
vue和uniapp聊天页面右侧滚动条自动到底部
javascript·vue.js·uni-app
用户90738703648641 小时前
pnpm是如何解决幻影依赖的?
前端
树上有只程序猿1 小时前
Claude 4提升码农生产力的5种高级方式
前端
傻球1 小时前
没想到干前端2年了还能用上高中物理运动学知识
前端·react.js·开源
咚咚咚ddd1 小时前
前端组件:pc端通用新手引导组件最佳实践(React)
前端·react.js