网站首页 » 前端开发 » Angular 2+ » Angular 实现树形菜单(多级菜单)功能模块
上一篇:
下一篇:

Angular 实现树形菜单(多级菜单)功能模块

前言

本文要分享的是一个多级菜单效果,也就是传说中的树形结构菜单,理论上支持无限级菜单,当然数据结构要一定的要求,但这都不是什么难事,因为我们可以把数据组装成所需要的结构。下面这个例子虽然不是很完美好,但是估计也够用了。这个多级菜单是模仿 Angular 官方的左侧菜单效果来做的,效果的相似度应该达到 99%,本文内容有点多(主要是代码),因为我想把所有的代码都贴出来,尽量不让你幻想缺失的代码。好了,下面我们就开始这个菜单功能之旅吧!

这个多级菜单实现的功能如下:

  • 1、可展示多级菜单,理论上可以展无限级菜单
  • 2、当前菜单高亮功能
  • 3、刷新后依然会自动定位到上一次点击的菜单,即使这个是子菜单,并且父菜单会自动展开
  • 4、子菜单的显示隐藏有收起、展开,同时带有淡入淡出效果

Angular 多级菜单

还是老套路,费放不多说,我们直接上码。在上码前,我们不妨先看看代码文件结构概览图:

Angular 2+ 实现多级菜单功能模块

效果图看完之后,我们再来看看效果图:毕竟这是能让你有勇气把下面的一大堆代码阅读完的动力来源:

Angular 2+ 实现多级菜单功能模块

展开【教程】菜单再点【英雄编辑器菜单】,接着再点击【核心知识】-【模块与数据绑定】-【生命周期勾子】,然后刷新页面,菜单就会自动定位到【生命周期勾子】菜单并高亮,并且【核心知识】-【模块与数据绑定】菜单会自动展开并高亮。

上面点击每个菜单时都会跳转到一个空白的详情页,但这个详情页什么都没做,只是为了保证菜单能正常跳转而已,你可以通过观察导航栏中的 URL 变化来确定菜单是否已经跳转成功。

首先把最主要的代码贴出来:

navItem.component.html
<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> 关于这个标签的用法可以网上搜索一下资料。

navItem.component.css
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;
}
navItem.component.ts
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 组件中引入这个组件:

navMenu.component.html
<div class="side-nav-box">
  <nav-item [menu]="menu" *ngFor="let menu of menus"></nav-item>
</div>
navMenu.component.css
.side-nav-box {
  width: 300px;
  max-height: 100%;
  overflow-y: auto;
  overflow-x: hidden;
  font-size: 14px;
}
navMenu.component.ts
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 组件:

navSide.component.html
<nav-menu></nav-menu>
navSide.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'nav-side',
  templateUrl: './navSide.component.html',
  styleUrls: ['./navSide.component.css']
})
export class NavSideComponent { }

上面就是一个完整的多级菜单组件。下面我们就把这个组件引入 app.component.ts 组件中:

app.component.html
<nav-side></nav-side>
<router-outlet></router-outlet>
app.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'app';
}

为了让这个例子可以运行起来,我还为它准备了一些菜单数据,和简单的路由跳转:

menus-mock.ts
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 。即默认收起所有菜单。我们在程序中可以动态给它添加。当然在实践的开发中菜单数据结构可能会更复杂,对象属性更多,但万变不离其宗。

在这里我们还可以把这个菜单对象使用平铺式的数据结构(即,不管是子菜单还是父菜单,都同放在一个数据里)来做,而不用像上面那样父菜单嵌套着子菜单

接着,通过服务来返回这些数据:

menus.services.ts
import { Injectable } from '@angular/core';
import { MENUS } from '../services/menus-mock';

@Injectable()
export class MenusService {
  getMenu(): Promise<any[]> {
    return Promise.resolve(MENUS);
  }
}

加一个简单得不能再简单的详情页,用来方便点击菜单时作跳转,这里只做了一个页面,所有的菜单都会跳到这个页面:

detail.component.ts
import { Component } from '@angular/core';
@Component({
  selector: 'detail-page',
  templateUrl: './detail.component.html',
  styleUrls: ['./detail.component.css']
})
export class detailComponent {}

下面就是给出所以菜单的路由:

navRouter.module.ts
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 中引入上面这些资源:

app.module.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+ 实现多级菜单功能模块(树形结构菜单)就分享到这里。

  • 微信扫一扫,赏我

  • 支付宝扫一扫,赏我

声明

原创文章,不经本站同意,不得以任何形式转载,如有不便,请多多包涵!

本文永久链接:http://yunkus.com/angular-functional-module-tree-view-menu/

评论2
  1. zyp 2018年4月24日 下午8:59 回复

    老大?怎么实现一次只打开一个结点,其他结点收缩。谢谢。

    • 朝夕熊 2018年4月30日 下午1:47 回复

      在点击的时候,处理下就好拉,至于实现的方式也有很多种,比如:递归遍历,遍历所有的菜单如果是展开则收起,在本例中,你可以遍历修改expand 的属性值来实现收起效果,在这个过程中,同时你还需要修改 isSelected 属性值来去掉旧菜单的高亮。你还可以使用其它的一些方法实现,这里就不多说了。

发表评论

电子邮件地址不会被公开。 必填项已用*标注

评论 END