前言
本文要分享的是一个多级菜单效果,也就是传说中的树形结构菜单,理论上支持无限级菜单,当然数据结构要一定的要求,但这都不是什么难事,因为我们可以把数据组装成所需要的结构。下面这个例子虽然不是很完美好,但是估计也够用了。这个多级菜单是模仿 Angular 官方的左侧菜单效果来做的,效果的相似度应该达到 99%,本文内容有点多(主要是代码),因为我想把所有的代码都贴出来,尽量不让你幻想缺失的代码。好了,下面我们就开始这个菜单功能之旅吧!
这个多级菜单实现的功能如下:
- 1、可展示多级菜单,理论上可以展无限级菜单
- 2、当前菜单高亮功能
- 3、刷新后依然会自动定位到上一次点击的菜单,即使这个是子菜单,并且父菜单会自动展开
- 4、子菜单的显示隐藏有收起、展开,同时带有淡入淡出效果
Angular 多级菜单
还是老套路,费放不多说,我们直接上码。在上码前,我们不妨先看看代码文件结构概览图:
效果图看完之后,我们再来看看效果图:毕竟这是能让你有勇气把下面的一大堆代码阅读完的动力来源:
展开【教程】菜单再点【英雄编辑器菜单】,接着再点击【核心知识】-【模块与数据绑定】-【生命周期勾子】,然后刷新页面,菜单就会自动定位到【生命周期勾子】菜单并高亮,并且【核心知识】-【模块与数据绑定】菜单会自动展开并高亮。
上面点击每个菜单时都会跳转到一个空白的详情页,但这个详情页什么都没做,只是为了保证菜单能正常跳转而已,你可以通过观察导航栏中的 URL 变化来确定菜单是否已经跳转成功。
首先把最主要的代码贴出来:
<div class="level-1"><ng-template [ngIf]="menu.type === 'link'"><div><a class="link level-1" routerLink="{{menu.url}}" routerLinkActive="selected" (click)="toggleSubMenu(menu)">{{ menu.name }}</a></div></ng-template><ng-template [ngIf]="menu.type === 'button'"><div><div class="button heading" [ngClass]="{expand:menu.expand,selected:menu.isSelected}" (click)="toggleSubMenu(menu)">{{menu.name}}<div class="icon"><svg xmlns="http://www.w3.org/2000/svg" focusable="false" viewBox="0 0 24 24"><path d="M8.59 16.34l4.58-4.59-4.58-4.59L10 5.75l6 6-6 6z"></path></svg></div></div><ng-template [ngIf]="menu.expand"><div class="heading-children" [@inOut]="out"><nav-item [menu]="menu" *ngFor="let menu of menu.subMenu"></nav-item></div></ng-template></div></ng-template></div>
上面的 routerLinkActive 可以设置当前菜单高亮。[@inOut] 为绑定的动画效果,具体用法可以参考官方资料。
这个 html 中使用了 Angular 中的一个标签 <ng-template> 关于这个标签的用法可以网上搜索一下资料。
a {text-decoration: none;color: #333;}.link,.button {display: block;padding: 10px 15px;transition: background-color 0.2s ease-in-out 0s, color 0.3s ease-in-out 0.1s;-moz-user-select: none;-webkit-user-select: none;-ms-user-select: none;-khtml-user-select: none;user-select: none;}.button {position: relative;}.link:hover,.button:hover {color: #1976d2;background-color: #eee;cursor: pointer;}.icon {position: absolute;right: 0;display: inline-block;height: 24px;width: 24px;fill: currentColor;transition: -webkit-transform .15s;transition: transform .15s;transition: transform .15s, -webkit-transform .15s;transition-timing-function: ease-in-out;}.heading-children {padding-left: 14px;overflow: hidden;}.expand {display: block;}.collapsed {display: none;}.expand .icon {-webkit-transform: rotate(90deg);transform: rotate(90deg);}.selected {color: #1976d2;}
import { Component, Input, OnInit } from '@angular/core';import { ActivatedRoute, Router } from '@angular/router';import { trigger, state, style, animate, transition } from '@angular/animations';import { MenusService } from '../services/menus.services.component';@Component({selector: 'nav-item',templateUrl: './navItem.component.html',styleUrls: ['./navItem.component.css'],animations: [trigger('inOut', [state('out', style({ opacity: 0, height: 0 })),transition('void => *', [style({ opacity: 0, height: 0 }),animate(150, style({ opacity: 1, height: '*' }))]),transition('* => void', [style({ opacity: 1, height: '*' }),animate(150, style({ opacity: 0, height: 0 }))])])]})export class SideItemComponent implements OnInit {startExpand = []; // 保存刷新后当前要展开的菜单项targetUrl = ""; // 保存目标 URL,即当前 url,通过它来定位当前菜单高亮source = [];sourceItem = "";@Input() menu; // 接收父组件传入的值constructor(private _router: Router,private _activatedRoute: ActivatedRoute,private _MenusService: MenusService) { }ngOnInit() {this._MenusService.getMenu().then(data => {this.source = data;this.setCurrentMenu();});}// 展开并设置当前菜单高度setCurrentMenu() {// console.log(this._router);this.targetUrl = this._router.url; // 获取当前urlthis.targetUrl = this.targetUrl.substr(1, this.targetUrl.length); // 处理获取的 url, 即截掉 url 前的 “ /”this.setExpand(this.source);}setExpand(source) {for (var i = 0; i < source.length; i++) {this.sourceItem = JSON.stringify(source[i]); // 把菜单项转为字符串if (this.sourceItem.indexOf(this.targetUrl) > -1) { // 查找当前 URL 所对应的子菜单属于哪一个祖先菜单if (source[i].type === 'button') { // 一级导航为展开按钮this.startExpand.push(source[i]);source[i].isSelected = true;source[i].expand = true; // 设置为展开// 递归下一级菜单,以此类推this.setExpand(source[i].subMenu);}break;}}}toggleSubMenu(menuItem) {if (menuItem.type === 'link') {// 去掉刷新后展开菜单的高亮(如果有的话)if (this.startExpand.length > 0) {for (var i = 0; i < this.startExpand.length; i++) {delete this.startExpand[i].isSelected;}}this.targetUrl = menuItem.url;this.setExpand(this.source);this.startExpand = [];}menuItem.expand = !menuItem.expand;}}
通过 Router 的 url 属性拿到当前的 url,然后在遍历菜单对象的每一项(把它转为字符串),然后查找当前的这个 url 存在哪一个菜单菜单中。
上面的代码通过递归组件的方法来实现菜单的多级显示功能。
接下来我们就在 navMenu 组件中引入这个组件:
<div class="side-nav-box"><nav-item [menu]="menu" *ngFor="let menu of menus"></nav-item></div>
.side-nav-box {width: 300px;max-height: 100%;overflow-y: auto;overflow-x: hidden;font-size: 14px;}
import { Component, OnInit } from '@angular/core';import { MenusService } from '../services/menus.services.component';@Component({selector: 'nav-menu',templateUrl: './navMenu.component.html',styleUrls: ['./navMenu.component.css']})export class NavMenuComponent implements OnInit {menus = [];constructor(private _menusService: MenusService) { }ngOnInit() {this._menusService.getMenu().then(data => {this.menus = data;});}}
接下来我们在 navSide.component.ts 中引入 navMenu 组件:
<nav-menu></nav-menu>
import { Component } from '@angular/core';@Component({selector: 'nav-side',templateUrl: './navSide.component.html',styleUrls: ['./navSide.component.css']})export class NavSideComponent { }
上面就是一个完整的多级菜单组件。下面我们就把这个组件引入 app.component.ts 组件中:
<nav-side></nav-side><router-outlet></router-outlet>
import { Component } from '@angular/core';@Component({selector: 'app-root',templateUrl: './app.component.html',styleUrls: ['./app.component.css']})export class AppComponent {title = 'app';}
为了让这个例子可以运行起来,我还为它准备了一些菜单数据,和简单的路由跳转:
export const MENUS = [{ name: '快速上手', type: "link", url: "detail/quickstart" },{name: '教程',type: "button",expand: false,subMenu: [{ name: '简介', type: "link", url: "detail/tutorial" },{ name: '英雄编辑器', type: "link", url: "detail/toh-pt1" },{ name: '主从结构', type: "link", url: "detail/toh-pt2" },{ name: '多个组件', type: "link", url: "detail/toh-pt3" },{ name: '服务', type: "link", url: "detail/toh-pt4" },{ name: '路由', type: "link", url: "detail/toh-pt5" },{ name: 'HTTP', type: "link", url: "detail/toh-pt6" },]},{name: '核心知识',type: "button",expand: false,subMenu: [{ name: '架构', type: "link", url: "detail/architecture" },{name: '模板与数据绑定',type: "button",expand: false,subMenu: [{ name: '显示数据', type: "link", url: "detail/displaying-data" },{ name: '模板语法', type: "link", url: "detail/template-syntax" },{ name: '生命周期钩子', type: "link", url: "detail/lifecycle-hooks" },{ name: '组件交互', type: "link", url: "detail/component-interaction" },{ name: '组件样式', type: "link", url: "detail/component-styles" },{ name: '动态组件', type: "link", url: "detail/dynamic-component-loader" },{ name: '属性型指令', type: "link", url: "detail/attribute-directives" },{ name: '结构型指令', type: "link", url: "detail/structural-directives" },{ name: '管道', type: "link", url: "detail/pipes" },{ name: '动画', type: "link", url: "detail/animations" },]},{name: '表单',type: "button",expand: false,subMenu: [{ name: '用户输入', type: "link", url: "detail/user-input" },{ name: '模板驱动表单', type: "link", url: "detail/forms" },{ name: '表单验证', type: "link", url: "detail/form-validation" },{ name: '响应式表单', type: "link", url: "detail/reactive-forms" },{ name: '动态表单', type: "link", url: "detail/dynamic-form" }]},{ name: '引用启动', type: "link", url: "detail/bootstrapping" },{name: 'NgModules',type: "button",expand: false,subMenu: [{ name: 'NgModule', type: "link", url: "detail/ngmodule" },{ name: 'NgModule 常见问题', type: "link", url: "detail/ngmodule-faq" }]},{name: '依赖注入',type: "button",expand: false,subMenu: [{ name: '依赖注入', type: "link", url: "detail/dependency-injection" },{ name: '多级注入器', type: "link", url: "detail/hierarchical-dependency-injection" },{ name: 'DI 实例技巧', type: "link", url: "detail/dependency-injection-in-action" }]},{ name: 'HttpClient', type: "link", url: "detail/http" },{ name: '路由与导航', type: "link", url: "detail/router" },{ name: '测试', type: "link", url: "detail/testing" },{ name: '速查表', type: "link", url: "detail/cheatsheet" },]},{name: '其它技术',type: "button",expand: false,subMenu: [{ name: '国际化(i18n)', type: "link", url: "detail/i18n" },{ name: '语言服务', type: "link", url: "detail/language-service" },{ name: '安全', type: "link", url: "detail/security" },{name: '环境设置与部署',type: "button",expand: false,subMenu: [{ name: '搭建本地开发环境', type: "link", url: "detail/setup" },{ name: '搭建方式剖析', type: "link", url: "detail/setup-systemjs-anatomy" },{ name: '浏览器支持', type: "link", url: "detail/browser-support" },{ name: 'npm 包', type: "link", url: "detail/npm-packages" },{ name: 'TypeScript 配置', type: "link", url: "detail/typescript-configuration" },{ name: '预 (AoT) 编译器', type: "link", url: "detail/aot-compiler" },{ name: '预 (AoT) 编译器', type: "link", url: "detail/metadata" },{ name: '部署', type: "link", url: "detail/deployment" }]},{name: '升级',type: "button",expand: false,subMenu: [{ name: '从 AngularJS 升级', type: "link", url: "detail/upgrade" },{ name: '升级速查表', type: "link", url: "detail/ajs-quick-reference" }]},{ name: 'Visual Studio 2015 快速上手', type: "link", url: "detail/visual-studio-2015" },{ name: '风格指南', type: "link", url: "detail/styleguide" },{ name: '词汇表', type: "link", url: "detail/glossary" }]},{ name: 'API 参考手册', type: "link", url: "detail/api" }];
上面数据中的 expand: false, 其实也可以不要的,因为如果对象中不存在 expand 属性,则是 false 。即默认收起所有菜单。我们在程序中可以动态给它添加。当然在实践的开发中菜单数据结构可能会更复杂,对象属性更多,但万变不离其宗。
在这里我们还可以把这个菜单对象使用平铺式的数据结构(即,不管是子菜单还是父菜单,都同放在一个数据里)来做,而不用像上面那样父菜单嵌套着子菜单
接着,通过服务来返回这些数据:
import { Injectable } from '@angular/core';import { MENUS } from '../services/menus-mock';@Injectable()export class MenusService {getMenu(): Promise<any[]> {return Promise.resolve(MENUS);}}
加一个简单得不能再简单的详情页,用来方便点击菜单时作跳转,这里只做了一个页面,所有的菜单都会跳到这个页面:
import { Component } from '@angular/core';@Component({selector: 'detail-page',templateUrl: './detail.component.html',styleUrls: ['./detail.component.css']})export class detailComponent {}
下面就是给出所以菜单的路由:
import { NgModule } from '@angular/core';import { RouterModule, Routes } from '@angular/router';import { detailComponent } from './detail/detail.component';const appRoutes: Routes = [{ path: 'detail/quickstart', component: detailComponent },{ path: 'detail/tutorial', component: detailComponent },{ path: 'detail/toh-pt1', component: detailComponent },{ path: 'detail/toh-pt2', component: detailComponent },{ path: 'detail/toh-pt3', component: detailComponent },{ path: 'detail/toh-pt4', component: detailComponent },{ path: 'detail/toh-pt5', component: detailComponent },{ path: 'detail/toh-pt6', component: detailComponent },{ path: 'detail/architecture', component: detailComponent },{ path: 'detail/displaying-data', component: detailComponent },{ path: 'detail/template-syntax', component: detailComponent },{ path: 'detail/lifecycle-hooks', component: detailComponent },{ path: 'detail/component-interaction', component: detailComponent },{ path: 'detail/component-styles', component: detailComponent },{ path: 'detail/dynamic-component-loader', component: detailComponent },{ path: 'detail/attribute-directives', component: detailComponent },{ path: 'detail/structural-directives', component: detailComponent },{ path: 'detail/pipes', component: detailComponent },{ path: 'detail/animations', component: detailComponent },{ path: 'detail/user-input', component: detailComponent },{ path: 'detail/forms', component: detailComponent },{ path: 'detail/form-validation', component: detailComponent },{ path: 'detail/reactive-forms', component: detailComponent },{ path: 'detail/dynamic-form', component: detailComponent },{ path: 'detail/bootstrapping', component: detailComponent },{ path: 'detail/ngmodule', component: detailComponent },{ path: 'detail/ngmodule-faq', component: detailComponent },{ path: 'detail/dependency-injection', component: detailComponent },{ path: 'detail/hierarchical-dependency-injection', component: detailComponent },{ path: 'detail/dependency-injection-in-action', component: detailComponent },{ path: 'detail/http', component: detailComponent },{ path: 'detail/router', component: detailComponent },{ path: 'detail/testing', component: detailComponent },{ path: 'detail/cheatsheet', component: detailComponent },{ path: 'detail/i18n', component: detailComponent },{ path: 'detail/language-service', component: detailComponent },{ path: 'detail/security', component: detailComponent },{ path: 'detail/setup', component: detailComponent },{ path: 'detail/setup-systemjs-anatomy', component: detailComponent },{ path: 'detail/browser-support', component: detailComponent },{ path: 'detail/npm-packages', component: detailComponent },{ path: 'detail/typescript-configuration', component: detailComponent },{ path: 'detail/aot-compiler', component: detailComponent },{ path: 'detail/metadata', component: detailComponent },{ path: 'detail/deployment', component: detailComponent },{ path: 'detail/upgrade', component: detailComponent },{ path: 'detail/ajs-quick-reference', component: detailComponent },{ path: 'detail/visual-studio-2015', component: detailComponent },{ path: 'detail/styleguide', component: detailComponent },{ path: 'detail/glossary', component: detailComponent },{ path: 'detail/api', component: detailComponent }];@NgModule({imports: [RouterModule.forRoot(appRoutes)],exports: [RouterModule]})export class AppRoutesModule { }
这里我们把路由单独成一个模块,所有的菜单都会跳转到同一个详情页中,只不过每个菜单都有自己单独的路由。
接下来就是最后一步,也是最关键的一步了,那就是在app.component.ts 中引入上面这些资源:
import { BrowserModule } from '@angular/platform-browser';import { NgModule } from '@angular/core';import { BrowserAnimationsModule } from '@angular/platform-browser/animations';import { RouterModule } from '@angular/router';import { AppRoutesModule } from './nav.routes.module';import { AppComponent } from './app.component';import { NavSideComponent } from './sidenav/navSide.component';import { NavMenuComponent } from './sidenav/navMenu.component';import { SideItemComponent } from './sidenav/navItem.component';import { detailComponent } from './detail/detail.component';import { MenusService } from './services/menus.services.component';@NgModule({declarations: [AppComponent,NavSideComponent,NavMenuComponent,SideItemComponent,detailComponent],imports: [BrowserModule,BrowserAnimationsModule,AppRoutesModule],providers: [MenusService],bootstrap: [AppComponent]})export class AppModule { }
到这里这个用Angular 实现的多级菜单就已经完成了。不要看代码那么多,其实真正关键的代码非常地少。
最后,想说说这个 Angular 功能模块一个缺点:
由于使用了递归组件的方式来自动判断生成菜单,所以把调用一次组件都会生成一个组件实例,比如,我们第一次进行到这个菜单页面,当前有 5 个菜单,那么就会生成 5 个实例,这样导致的问题是,每个组件的 ngOnInit 函数都会执行一遍。这就相当于 ngOnInit 函数里的代码都会执行5次,如果你点开了一些子菜单,那么就会生成更多的实例,ngOnInit 函数里的代码就会执行更多次。不过这个对于一般的菜单来说也不是什么大问题。
有什么不懂,或者对于上面这个功能模块有什么好的改进意见的可以随时留言,一起交流探讨,我觉得交流分享是最好的提升自身能力的方式之一。
Angular 2+ 实现多级菜单功能模块(树形结构菜单)就分享到这里。