angular13 + ng-zorro通过路由重用实现Tab页签

929 阅读4分钟

最近正在使用Angular开发Ai-Admin快速开发平台,UI框架选择了Ant Design的ng-zorro。

在UI设计过程中,我们想要实现动态Tab标签页,即点击菜单可以添加一个新的标签页(或打开已打开的),同时满足路由、菜单、Tab页签三者联动。通过调研,最后决定使用路由懒加载的方式实现。

效果.gif

路由重用

在基于Angular的SPA应用中,各个页面之间的切换是通过路由导航控制的。路由对组件的操作是无状态的,即路由退出时组件状态也一并删除。换句话说,当用户离开一个页面时,之前在该页面的所有操作(例如各种输入框输入的信息)也会被销毁,用户再次返回该页面时,看到的是将是一个全新的页面。如果想要保留用户的操作,则需要用到Angular的路由重用。

我们可以这样理解路由重用,路由跳转时记录路由当前的快照并将快照保存起来,重新进入该路由时再取出快照。

Angular中如何实现路由重用?

  • 一、定义路由重用策略 RouteReuseStrategy提供了自定义路由重用的方法。 routereuse.jpg 这个接口共定义了5种方法:

  • shouldDetach 路由离开时是否需要保存页面,也是自定义路由重用策略最重要的一个方法。返回true时,路由离开时保存页面信息,当路由再次激活时,会直接显示保存的页面;返回false时,路由离开时直接销毁组件。

  • store 如果shouldDetach返回true,调用该方法保存页面。

  • shouldAttach 路由进入时是否有页面可以重用。 true: 重用页面,false:生成新的页面。

  • retrieve 路由激活时获取保存的页面,如果返回null,则生成新页面

  • shouldReuseRoute 决定跳转后是否可以使用跳转前的路由页面,即跳转前跳转后使用相同的页面

自定义路由重用策略

import {ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy} from '@angular/router';

export class AiRouteReuseStrategy implements RouteReuseStrategy {
    /** 
     * 用于保存路由快照
     **/
    public static routeSnapshots: { [key: string]: DetachedRouteHandle } = {};

    /** 
     * 允许所有路由重用
     * 如果你有路由不想被重用,可以在这个方法中加业务逻辑判断 
     **/
    shouldDetach(route: ActivatedRouteSnapshot): boolean {
        return true;
    }

    /** 
     * 以url为key保存路由,key也可以使用其他属性,能确保唯一即可
     **/
    store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void {
        const url = this.getFullRouteUrl(route);
        AiRouteReuseStrategy.routeSnapshots[url] = handle;
    }

     /** 
      * 缓存中存在则允许还原路由
     **/
    shouldAttach(route: ActivatedRouteSnapshot): boolean {
        const url = this.getFullRouteUrl(route);
        return !!AiRouteReuseStrategy.routeSnapshots[url];
    }

    /** 
     * 从缓存中获取快照,没有返回null
    **/
    retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null {
        const url = this.getFullRouteUrl(route);
        return route.routeConfig ? AiRouteReuseStrategy.routeSnapshots[url] : null;
    }

    /** 
     * 进入路由触发,判断是否同一路由 
    **/
    shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
        return future.routeConfig === curr.routeConfig &&
            JSON.stringify(future.params) === JSON.stringify(curr.params);
    }

    private getFullRouteUrl(route: ActivatedRouteSnapshot): string {
        return this.getFullRouteUrlPaths(route).filter(Boolean).join('/').replace(/\//g, '_');
    }
    
    private getFullRouteUrlPaths(route: ActivatedRouteSnapshot): string[] {
        const paths = this.getRouteUrlPaths(route);
        return route.parent
            ? [...this.getFullRouteUrlPaths(route.parent), ...paths]
            : paths;
    }

    private getRouteUrlPaths(route: ActivatedRouteSnapshot): string[] {
        return route.url.map(urlSegment => urlSegment.path);
    }
}
  • 二、路由重用注入到app.module中
@NgModule({
  declarations: [
    AppComponent,
    // ...
  ],
  imports: [
  	// ...
    // 导入路由模块
    AppRoutingModule,
    // ...
  ],
  providers: [
  	// ...
    // 注册路由重用服务提供商
    {provide: RouteReuseStrategy, useClass: SimpleReuseStrategy},
    // ...
  ],
  bootstrap: [AppComponent]
})
export class AppModule {
}

结合ng-zorro实现多tab页签

  • 一、菜单栏即Tab页签组件实现 可参考ng-zorro官网
<nz-layout class="app-layout">

...

    <ul nz-menu nzMode="inline" [nzInlineCollapsed]="isCollapsed">
      <ng-container *ngTemplateOutlet="menuTpl; context: { $implicit: menus }"></ng-container>
      <ng-template #menuTpl let-menus>
        <ng-container *ngFor="let menu of menus">
          <li
            *ngIf="!menu.children"
            nz-menu-item
            nzMatchRouter
            [nzPaddingLeft]="menu.level * 24"
          >
            <i nz-icon [nzType]="menu.icon" *ngIf="menu.icon"></i>
            <a class="menu-btn" [routerLink]="['/', menu.path]">{{ menu.title }}</a>
          </li>
          <li
            *ngIf="menu.children"
            nz-submenu
            [nzPaddingLeft]="menu.level * 24"
            [nzTitle]="menu.title"
            [nzIcon]="menu.icon"
            [nzDisabled]="menu.disabled"
          >
            <ul>
              <ng-container *ngTemplateOutlet="menuTpl; context: { $implicit: menu.children }"></ng-container>
            </ul>
          </li>
        </ng-container>
      </ng-template>
    </ul>
  </nz-sider>

...

  <nz-layout>
    <nz-content>
      <div class="inner-content">
        <nz-tabset
          [(nzSelectedIndex)]="activatedMenuIndex"
          nzType="card"
        >
          <nz-tab
            *ngFor="let tab of tabs"
            [nzTitle]="titleTemplate"
            (nzSelect)="toggleTab(tab.path)">
            <ng-template #titleTemplate>
              <div>
                {{ tab.title }}
                <i
                  class="tab-remove-btn"
                  nz-icon nzType="close"
                  nzTheme="outline"
                  (click)="closeTab(tab.path)"
                ></i>
              </div>
            </ng-template>
          </nz-tab>
        </nz-tabset>
        <router-outlet></router-outlet>
      </div>
    </nz-content>
  </nz-layout>
</nz-layout>
  • 二、添加路由监听 在app.component.ts中添加路由监听
export class AppComponent {
  // 当前打开的Tab页
  activatedMenuIndex = -1;
  // 存放所有菜单信息
  menus: SystemMenu[] = [];
  // 存放已打开的Tab页信息
  tabs: { path: string, title: string }[] = [];

  constructor(
    private themeService: ThemeService,
    private sysMenuService: SystemMenuService,
    private router: Router
  ) {
    // 监听路由事件,只订阅 ActivationEnd 事件
    this.router.events.pipe(filter(e => e instanceof ActivationEnd))
	    .subscribe((e) => {
        // 这里不强转VS Code编译通不过,有没有大佬有解决方法
        const thisEvt = <ActivationEnd>e; 
        // 当前激活的路由
        const activatedRoutePath = thisEvt.snapshot.routeConfig?.path;
        const routeData = thisEvt.snapshot.routeConfig?.data;
        let menuTitle = '新标签页';
        if(routeData) {
          menuTitle = routeData['title'];
        }

        // 该路由是否已激活,激活过则直接打开
        let isExist = false;
        this.tabs.every((t, i) => {
          if(activatedRoutePath === t.path) {
            this.activatedMenuIndex = i;
            isExist = true;
            return false;
          }
          return true;
        });

        // 指定路由不在tabs中存在(未激活或激活后关闭)
        if(!isExist) {
          this.activeMenu(activatedRoutePath, menuTitle);
        }
	    });
  }

  // 点击菜单激活指定路由并保存tab页签
  activeMenu(menuPath: string | undefined, menuTitle: string): void {
    if(!menuPath) return;
    let menuIndex = -1;
    this.tabs.every((t, i) => {
      if(menuPath === t.path) {
        menuIndex = i;
        return false;
      }
      return true;
    });

    if(menuIndex === -1) {
      this.tabs.push({path: menuPath, title: menuTitle});
      menuIndex = this.tabs.length - 1;
      this.activatedMenuIndex = menuIndex;
    }
  }

  // 激活路由
  activeRoute(path: string): void {
    this.router.navigateByUrl(path).finally();
  }

  // 切换tab,激活对应路由
  toggleTab(path: string): void {
    this.activeRoute(path);
  }

  // tab页签关闭,从缓存中删除对应信息
  closeTab(path: string): void {
    if (1 === this.tabs.length) return;

    let selectedIndex = -1;
    this.tabs.every((t, i) => {
      if(t.path === path) {
        selectedIndex = i;
        return false;
      }

      return true;
    });
    this.tabs.splice(selectedIndex, 1);

    if(selectedIndex === this.activatedMenuIndex)  {
      let prevIndex = this.activatedMenuIndex - 1;
      this.activatedMenuIndex = prevIndex > 0 ? prevIndex : 0;
      this.activeRoute(this.tabs[this.activatedMenuIndex].path);
    }else if (this.activatedMenuIndex > selectedIndex) {
	    this.activatedMenuIndex -= 1;
	  }
  }

}

附:路由配置信息

const routes: Routes = [
  {path: '', redirectTo: '/home', pathMatch: 'full'},
  { path: 'home', component: HomeComponent, data: {title: '首页'} },
  { path: 'user', component: UserComponent, data: {title: '用户管理'} },
  { path: 'department', component: DepartmentComponent, data: {title: '部门管理'} }
];

项目地址(全代码下载):

github.com/Frank-Z20/a…

gitee.com/chou-xf/ai-…