前言
本文要分享的是一个多级菜单效果,也就是传说中的树形结构菜单,理论上支持无限级菜单,当然数据结构要一定的要求,但这都不是什么难事,因为我们可以把数据组装成所需要的结构。下面这个例子虽然不是很完美好,但是估计也够用了。这个多级菜单是模仿 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; // 获取当前url
this.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+ 实现多级菜单功能模块(树形结构菜单)就分享到这里。