大白话在vue2和vue3中展示实现可折叠树形菜单组件的代码示例,分析树形结构展示和折叠功能的实现逻辑
前端小伙伴们,有没有被“树形菜单”搞到挠头过?做权限管理要展示部门层级,做文件管理器要渲染目录结构,点父节点展开子节点,点子节点还要触发操作……今天咱们用大白话聊Vue2和Vue3里的可折叠树形菜单,从递归渲染到折叠逻辑,手把手教你写出能打能抗的组件!
一、树形菜单的"开发痛点"
先说说我最近接的项目:给企业做后台管理系统,需要展示组织架构树——总公司下有分公司,分公司下有部门,部门下还有小组。需求是:
- 点击父节点能折叠/展开子节点;
- 点击叶子节点能选中并显示详情;
- 支持动态增删节点(比如新成立一个部门)。
结果开发时遇到这些坑:
- 递归渲染不会写:节点套节点,不知道怎么用组件循环自己;
- 折叠状态乱套:点父节点,子节点没反应,或者所有节点一起展开;
- 动态更新不生效:新增一个子节点,页面死活不显示,得刷新才出来。
二、树形菜单的"两个核心"
要搞定树形菜单,得先明白它的底层逻辑——递归渲染+状态管理。就像俄罗斯套娃,每个套娃里可能还有小套娃,我们需要:
- 递归组件:用一个组件循环调用自己,处理无限层级;
- 状态记录:每个节点存一个
isOpen
状态,控制折叠/展开。
1. 递归组件:自己调自己的"套娃组件"
树形结构的特点是每个节点可能有子节点,形成层级关系。Vue的递归组件允许组件在模板中调用自己,只要:
- 组件定义
name
属性(递归时用这个名字); - 子节点通过
v-if
或v-show
控制显示(折叠时隐藏)。
2. 状态管理:每个节点的"折叠开关"
每个节点需要一个isOpen
(布尔值)记录是否展开:
isOpen: true
→ 展开,显示子节点;isOpen: false
→ 折叠,隐藏子节点。
Vue2和Vue3的响应式原理不同:
- Vue2:用
Object.defineProperty
劫持属性,修改对象/数组时需用this.$set
触发响应式; - Vue3:用
Proxy
代理整个对象,直接修改属性即可触发更新(更省心)。
三、代码示例:Vue2和Vue3的实现对比
示例1:Vue2版本(选项式API)
先看Vue2怎么实现。核心是递归组件+$emit
传递事件,用this.$set
更新响应式状态。
步骤1:准备树形数据(父节点+子节点)
// 模拟树形数据(实际可能从接口获取)
const treeData = [
{
id: 1,
label: '总公司',
isOpen: false, // 初始折叠
children: [
{
id: 11,
label: '北京分公司',
isOpen: false,
children: [{ id: 111, label: '技术部' }, { id: 112, label: '财务部' }]
},
{
id: 12,
label: '上海分公司',
isOpen: false,
children: [{ id: 121, label: '市场部' }]
}
]
}
];
步骤2:编写递归组件(TreeItem.vue)
<template>
<!-- 每个节点的容器 -->
<div class="tree-node">
<!-- 点击区域:图标+节点名称 -->
<div
class="node-header"
@click="toggle"
:class="{ 'is-open': node.isOpen }"
>
<!-- 有子节点时显示折叠图标(三角),否则显示空 -->
<span class="icon">{{ node.children?.length ? (node.isOpen ? '▼' : '▶') : '' }}</span>
<span class="label">{{ node.label }}</span>
</div>
<!-- 子节点区域:isOpen为true时显示,递归渲染TreeItem -->
<div class="children" v-if="node.children?.length && node.isOpen">
<!-- 循环子节点,每个子节点用TreeItem组件渲染 -->
<TreeItem
v-for="child in node.children"
:key="child.id"
:node="child"
@toggle-node="handleChildToggle"
/>
</div>
</div>
</template>
<script>
export default {
name: 'TreeItem', // 关键:递归组件必须定义name
props: {
node: {
type: Object,
required: true
}
},
methods: {
// 点击时切换当前节点的isOpen状态
toggle() {
if (this.node.children?.length) { // 有子节点才能折叠
this.$emit('toggle-node', this.node.id); // 通知父组件更新状态
}
},
// 子节点触发折叠时,更新父组件状态(Vue2需用$set保证响应式)
handleChildToggle(childId) {
this.$emit('toggle-node', childId);
}
}
};
</script>
<style scoped>
.tree-node {
padding-left: 16px; /* 层级缩进 */
}
.node-header {
cursor: pointer;
padding: 4px 0;
display: flex;
align-items: center;
gap: 8px;
}
.icon {
font-size: 12px;
width: 14px;
}
.children {
/* 子节点区域的过渡动画 */
transition: all 0.3s;
}
.is-open .icon {
transform: rotate(90deg); /* 展开时三角旋转 */
}
</style>
步骤3:父组件使用(Tree.vue)
<template>
<div class="tree-container">
<!-- 根节点循环渲染TreeItem -->
<TreeItem
v-for="rootNode in treeData"
:key="rootNode.id"
:node="rootNode"
@toggle-node="toggleNode"
/>
</div>
</template>
<script>
import TreeItem from './TreeItem.vue';
import { treeData } from './data.js';
export default {
name: 'Tree',
components: { TreeItem },
data() {
return {
treeData: treeData
};
},
methods: {
// 处理节点折叠事件(Vue2用$set更新对象属性)
toggleNode(nodeId) {
// 递归查找目标节点并切换isOpen
this.toggleNodeRecursive(this.treeData, nodeId);
},
toggleNodeRecursive(nodes, targetId) {
for (const node of nodes) {
if (node.id === targetId) {
// Vue2中修改对象属性需用this.$set触发响应式
this.$set(node, 'isOpen', !node.isOpen);
return;
}
if (node.children) {
this.toggleNodeRecursive(node.children, targetId);
}
}
}
}
};
</script>
示例2:Vue3版本(组合式API)
Vue3用组合式API和reactive
/ref
管理状态,响应式更灵活,不需要this.$set
。
步骤1:树形数据(和Vue2相同)
// 数据结构和Vue2完全一致
const treeData = [/* 和上面一样 */];
步骤2:递归组件(TreeItem.vue)
<template>
<!-- 结构和Vue2类似,逻辑更简洁 -->
<div class="tree-node">
<div
class="node-header"
@click="toggle"
:class="{ 'is-open': node.isOpen }"
>
<span class="icon">{{ node.children?.length ? (node.isOpen ? '▼' : '▶') : '' }}</span>
<span class="label">{{ node.label }}</span>
</div>
<div class="children" v-if="node.children?.length && node.isOpen">
<!-- 递归渲染子节点 -->
<TreeItem
v-for="child in node.children"
:key="child.id"
:node="child"
@toggle-node="handleChildToggle"
/>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue';
// 定义Props和事件
const props = defineProps({
node: {
type: Object,
required: true
}
});
const emits = defineEmits(['toggle-node']);
// 点击时触发折叠事件
const toggle = () => {
if (props.node.children?.length) {
emits('toggle-node', props.node.id);
}
};
// 子节点事件传递
const handleChildToggle = (childId) => {
emits('toggle-node', childId);
};
</script>
<style scoped>
/* 和Vue2的CSS完全一致 */
</style>
步骤3:父组件使用(Tree.vue)
<template>
<div class="tree-container">
<TreeItem
v-for="rootNode in treeData"
:key="rootNode.id"
:node="rootNode"
@toggle-node="toggleNode"
/>
</div>
</template>
<script setup>
import { ref } from 'vue';
import TreeItem from './TreeItem.vue';
import { treeData } from './data.js';
// 用ref管理树形数据(自动响应式)
const treeDataRef = ref([...treeData]);
// 处理折叠事件(Vue3直接修改对象属性即可触发更新)
const toggleNode = (targetId) => {
// 递归查找并切换isOpen
const toggleRecursive = (nodes) => {
for (const node of nodes) {
if (node.id === targetId) {
node.isOpen = !node.isOpen; // Vue3的Proxy自动响应式
return;
}
if (node.children) {
toggleRecursive(node.children);
}
}
};
toggleRecursive(treeDataRef.value);
};
</script>
四、Vue2 vs Vue3实现差异
用表格对比两者的核心差异,帮你快速理解:
对比项 | Vue2(选项式API) | Vue3(组合式API) |
---|---|---|
响应式更新 | 需用this.$set 修改对象属性 | 直接修改属性(Proxy自动劫持) |
组件API | data /methods /props | defineProps /defineEmits /setup |
状态管理 | 依赖this 上下文 | 更灵活(ref/reactive) |
递归组件写法 | 需定义name 属性 | 无需显式name (自动识别) |
事件传递 | $emit +父组件$on | defineEmits +emits 函数 |
动态节点更新 | 可能漏用$set 导致不更新 | 直接修改即可(不易出错) |
五、面试题回答方法
正常回答(结构化):
“实现Vue可折叠树形菜单的核心是递归组件+状态管理:
- 递归组件:通过组件自调用渲染无限层级,每个节点渲染时判断是否有子节点,有则递归渲染子组件;
- 状态管理:每个节点维护
isOpen
状态,点击时切换该状态;- Vue2注意点:修改对象/数组属性时需用
this.$set
触发响应式;- Vue3优势:使用
Proxy
实现响应式,直接修改属性即可更新视图;- 事件传递:子节点通过
$emit
/emits
通知父节点更新状态,父节点递归查找目标节点并修改isOpen
。”
大白话回答(接地气):
“树形菜单就像套娃,每个套娃里可能还有小套娃。用Vue的递归组件,让每个节点自己调用自己渲染子节点。每个套娃有个‘开关’(
isOpen
),点击就打开或关闭。Vue2里改开关得用$set
喊一声‘视图该更新啦’,Vue3直接改开关就行,视图自己会看。子套娃点击时,告诉父套娃‘我要变’,父套娃就顺着藤找到那个套娃,把开关状态改了~”
六、总结:3个核心步骤+2个避坑指南
3个核心步骤:
- 定义树形数据:每个节点包含
id
、label
、children
和isOpen
; - 编写递归组件:用
name
(Vue2)或自动识别(Vue3)实现自调用; - 管理折叠状态:点击时切换
isOpen
,Vue2用$set
,Vue3直接修改。
2个避坑指南:
- Vue2的
$set
:添加/修改对象属性时,必须用this.$set(obj, key, value)
,否则视图不更新; - 避免过度递归:如果树形层级极深(比如100层),可能导致性能问题,建议限制层级或用虚拟滚动;
- 事件冒泡:点击子节点时,父节点可能被误触发,可用
stop
修饰符(@click.stop
)阻止冒泡。
七、扩展思考:4个高频问题解答
问题1:如何默认展开某个节点?
解答:初始化时设置isOpen: true
。比如:
const treeData = [
{
id: 1,
label: '总公司',
isOpen: true, // 默认展开
children: [/* ... */]
}
];
问题2:如何动态添加一个子节点?
解答:找到目标父节点,用push
添加子节点(Vue3直接push
,Vue2需用this.$set
或this.$forceUpdate
):
// Vue3示例:找到id=11的节点,添加子节点
const parentNode = findNode(treeDataRef.value, 11);
parentNode.children.push({ id: 113, label: '新部门', isOpen: false });
问题3:如何实现全选/反选?
解答:给每个节点添加isChecked
状态,递归修改所有子节点的isChecked
:
// 递归全选函数
const checkAll = (node, checked) => {
node.isChecked = checked;
if (node.children) {
node.children.forEach(child => checkAll(child, checked));
}
};
问题4:如何优化性能?
解答:
- 虚拟滚动:层级过深时,只渲染可见区域的节点;
- 缓存渲染:用
keep-alive
缓存已展开的节点; - 异步加载:子节点数据量大时,点击父节点再请求子数据(需添加
isLoading
状态)。
结尾:树形菜单的终极目标——“丝滑又听话”
可折叠树形菜单是前端最常用的组件之一,掌握它的递归渲染和状态管理,能让你的页面更灵活、用户体验更流畅。记住:递归组件是骨架,状态管理是灵魂,Vue2和Vue3的差异在响应式实现~
下次遇到树形菜单需求,你可以拍着胸脯说:“这题我会,3步搞定!” 如果这篇文章帮你理清了思路,记得点个收藏,咱们下期不见不散!