在企业级后台管理系统中,树状结构组件是数据展示与交互的常用组件。当数据层级复杂或节点数量较多时,支持搜索过滤、动态展开折叠的树组件能显著提升用户体验。本文将介绍一个基于 Vue3 和 Element Plus 开发的可过滤树状组件TreeFilter
,涵盖功能设计、核心实现与应用场景。
一、组件功能概览
TreeFilter
组件具备以下核心功能:
- 搜索过滤:支持关键字模糊匹配,递归过滤节点及其子节点
- 全展开 / 折叠:通过下拉菜单快速操作所有节点展开状态
- 多选 / 单选模式 :通过
multiple
属性切换,适配不同业务场景 - 动态高度计算:根据容器尺寸自动计算树体高度,适配响应式布局
- 数据驱动 :支持静态数据与异步接口加载,通过
requestApi
实现数据获取 - 双向绑定 :通过
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. 节点状态管理
通过expandedKeys
和setExpandedKeys
实现全展开 / 折叠:
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>
五、优化与扩展方向
-
大数据量优化
- 集成虚拟滚动(如
vue-virtual-scroller
),处理万级节点渲染性能问题 - 添加加载更多机制,按需加载子节点数据
- 集成虚拟滚动(如
-
交互增强
- 支持键盘导航(箭头键移动焦点、Enter 选择节点)
- 过滤时高亮匹配关键字
-
国际化支持
- 提取文案为可配置项,支持多语言切换
-
状态持久化
- 记住用户展开状态与搜索历史,刷新页面后恢复
六、总结
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);
}
}
}
}