Angular递归树形菜单:嵌套列表点击仅展开一级,如何实现深层展开?
解决Angular递归树形菜单展开/折叠的层级问题
嘿,我刚碰到过一模一样的问题!你现在遇到的情况,大概率是两个原因导致的:要么是所有节点共用了同一个展开状态变量,要么是点击子节点时事件冒泡触发了父节点的折叠逻辑。咱们一步步来搞定它:
问题根源分析
如果你的代码里用了类似isExpanded这样的单一变量来控制所有节点的展开/折叠,那点击子节点时,不仅子节点的状态会变,父节点的状态也会被意外触发——因为事件会向上冒泡,父节点的点击事件也会执行,最后看起来就是子节点一点击,整个层级都折叠了。
另外,如果没有给每个节点单独维护状态,所有节点会同时展开/折叠,这显然不是你想要的树形菜单效果。
解决方案:给每个节点独立维护状态 + 阻止事件冒泡
第一步:定义带独立状态的菜单数据结构
首先,确保你的菜单节点有自己的展开状态(或者用一个单独的Map来存储状态,不修改原始数据):
// 在sidebar.component.ts里定义菜单节点接口 interface MenuNode { id: string; // 每个节点唯一标识,用来跟踪状态 name: string; children?: MenuNode[]; isExpanded?: boolean; // 可选:直接在节点里存状态,适合前端生成的菜单 } // 示例菜单数据 menuNodes: MenuNode[] = [ { id: 'folder1', name: '一级文件夹', isExpanded: false, children: [ { id: 'subfolder1', name: '二级子文件夹', isExpanded: false, children: [{ id: 'file1', name: '三级文件' }] } ] } ];
如果你的菜单数据来自后端,不想修改原始数据,可以用Map来存储每个节点的展开状态:
expandedNodes = new Map<string, boolean>(); // 初始化所有节点为折叠状态 this.menuNodes.forEach(node => this.initExpandedState(node)); // 递归初始化子节点 initExpandedState(node: MenuNode): void { this.expandedNodes.set(node.id, false); node.children?.forEach(child => this.initExpandedState(child)); }
第二步:递归渲染菜单模板(两种方式可选)
方式1:使用ng-template递归(无需子组件)
在sidebar.component.html里用模板递归渲染树形结构,同时处理点击事件:
<!-- 定义递归模板 --> <ng-template #menuTemplate let-nodes> <ul class="menu-list"> <li *ngFor="let node of nodes" class="menu-item"> <!-- 点击区域:触发toggle并阻止冒泡 --> <div class="node-header" (click)="toggleNode(node, $event)"> {{ node.name }} <!-- 显示展开/折叠图标 --> <span *ngIf="node.children?.length" class="toggle-icon"> {{ getExpandedState(node) ? '▼' : '▶' }} </span> </div> <!-- 递归渲染子节点:仅当节点展开时显示 --> <ng-container *ngIf="node.children && getExpandedState(node)"> <ng-container *ngTemplateOutlet="menuTemplate; context: { $implicit: node.children }"></ng-container> </ng-container> </li> </ul> </ng-template> <!-- 渲染根节点 --> <ng-container *ngTemplateOutlet="menuTemplate; context: { $implicit: menuNodes }"></ng-container>
方式2:使用独立子组件递归
如果菜单结构复杂,可以把每个节点抽成单独的子组件(比如app-menu-node),这样代码更清晰:
子组件menu-node.component.html
<ul> <li *ngFor="let node of nodes"> <div (click)="toggleNode(node, $event)"> {{ node.name }} <span *ngIf="node.children?.length"> {{ getExpandedState(node) ? '▼' : '▶' }} </span> </div> <app-menu-node *ngIf="node.children && getExpandedState(node)" [nodes]="node.children"></app-menu-node> </li> </ul>
第三步:实现toggle逻辑并阻止事件冒泡
在sidebar.component.ts里编写toggle方法,关键是调用event.stopPropagation()阻止事件向上冒泡:
// 如果是直接在节点里存状态 toggleNode(node: MenuNode, event: Event): void { event.stopPropagation(); // 阻止事件冒泡到父节点 node.isExpanded = !node.isExpanded; } getExpandedState(node: MenuNode): boolean { return node.isExpanded ?? false; } // 如果是用Map存储状态 toggleNode(node: MenuNode, event: Event): void { event.stopPropagation(); const currentState = this.expandedNodes.get(node.id) || false; this.expandedNodes.set(node.id, !currentState); } getExpandedState(node: MenuNode): boolean { return this.expandedNodes.get(node.id) ?? false; }
关键要点总结
- 独立状态:每个节点必须有自己的展开/折叠状态,不能所有节点共用一个变量。
- 阻止冒泡:点击子节点时,一定要阻止事件冒泡,否则父节点的点击事件会被触发,导致意外折叠。
- 递归渲染:用
ng-template或者子组件实现递归,确保树形结构能正确嵌套渲染。
这样修改后,你点击任何层级的节点,只会切换当前节点的展开状态,不会影响父节点啦!
内容的提问来源于stack exchange,提问作者cosmohorst
相关产品推荐
相关产品推荐

