angular7 ng-zorro 实现Tab页签 + 路由重用

使用angular7/ng-zorro实现Tab页签 + 路由重用

  • 路由重用
    • 定义重用策略
    • 注册服务提供商
    • 定义路由信息
    • 监听路由事件
  • 工具函数
  • 结合SpringBoot2.x使用遇到的问题
    • 无法识别新窗口路由跳转

angular7 ng-zorro 实现Tab页签 + 路由重用_第1张图片

路由重用

定义重用策略

RouteReuseStrategy【官方说明】 提供一种自定义何时重用路由的方法
angular7 ng-zorro 实现Tab页签 + 路由重用_第2张图片

// 创建重用策略
import {ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy} from '@angular/router';

/**
 * 路由重用策略
 */
export class SimpleReuseStrategy implements RouteReuseStrategy {

  // 保存路由快照
  // [key:string] 键为字符串类型
  // DetachedRouteHandle 值为路由处理器
  public static snapshots: { [key: string]: DetachedRouteHandle } = {};

  /**
   * 从缓存中获取快照
   * @param {ActivatedRouteSnapshot} route
   * @return {DetachedRouteHandle | null}
   */
  retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null {
    return route.routeConfig ? SimpleReuseStrategy.snapshots[route.routeConfig.path] : null;
  }

  /**
   * 是否允许还原
   * @param {ActivatedRouteSnapshot} route
   * @return {boolean} true-允许还原
   */
  shouldAttach(route: ActivatedRouteSnapshot): boolean {
    return route.routeConfig && SimpleReuseStrategy.snapshots[route.routeConfig.path];
  }

  /**
   * 确定是否应该分离此路由(及其子树)以便以后重用
   * @param {ActivatedRouteSnapshot} route
   * @return {boolean}
   */
  shouldDetach(route: ActivatedRouteSnapshot): boolean {
    // useCache 为自定义数据
    return route.routeConfig && route.routeConfig.data && route.routeConfig.data.useCache;
  }

  /**
   * 进入路由触发, 判断是否为同一路由
   * @param {ActivatedRouteSnapshot} future
   * @param {ActivatedRouteSnapshot} curr
   * @return {boolean}
   */
  shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
    // future - 未来的(下一个)路由快照
    return future.routeConfig === curr.routeConfig;
  }

  /**
   * 保存路由
   * @param {ActivatedRouteSnapshot} route
   * @param {DetachedRouteHandle | null} handle
   */
  store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle | null): void {
    // 通过 Route.path 映射路由快照, 一定要确保它的唯一性
    // 也可以通过 route.routeConfig.data.uid 或其他可以确定唯一性的数据作为映射key
    // 作者这里能够确保 path 的唯一性
    SimpleReuseStrategy.snapshots[route.routeConfig.path] = handle;
  }

}

注册服务提供商

app.module.ts中注册路由重影提供商

@NgModule({
  declarations: [
    AppComponent,
    // ...
  ],
  imports: [
  	// ...
    // 导入路由模块
    AppRoutingModule,
    // ...
  ],
  providers: [
  	// ...
    // 注册路由重用服务提供商
    {provide: RouteReuseStrategy, useClass: SimpleReuseStrategy},
    // ...
  ],
  bootstrap: [AppComponent]
})
export class AppModule {
}

定义路由信息

在路由定义中需要用到静态数据定义 Route.data

const routes: Routes = [
  {
    path: 'm', component: DashboardComponent, children: <Routes>[
      {path: 'gr', component: GameRecordComponent, data: {uid: 101, useCache: true}},
      {path: 'mgr/um', component: UserManageComponent, data: {uid: 10401, useCache: true}},
      // 玩家信息不做路由重用(不缓存页面数据), useCache=false
      {path: 'pi', component: PlayerInfoComponent, data: {uid: 105, useCache: false}}, 
      // 这里不必指定默认默认路由, 
      // 如果做rbac控制, 很可能当前用户没有那个权限
	  // {path: '', redirectTo: 'gr', pathMatch: 'full'}
    ]
  },
  {path: '', redirectTo: '/m', pathMatch: 'full'}
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule {
}

监听路由事件

在目标组件如: dashboard-component.ts添加路由监听


<nz-layout>

  
  <nz-sider class="bg-eee" [(nzCollapsed)]="isCollapsed" nzTheme='light'>
    <ul nz-menu [nzMode]="'inline'" nzTheme='light' [nzInlineCollapsed]="isCollapsed">
      <ng-container *ngFor="let menu of menus">
        <ng-container *ngIf="!menu.child">
          <li nz-menu-item nz-tooltip nzPlacement="right"
              [nzTitle]="isCollapsed ? menu.title : ''" (click)="actionTab(menu)">
            <span title>
              <i nz-icon type="tag">i>
              <span>{{menu.title}}span>
              <a *ngIf="menu.newPage" target="_blank" (click)="waitForAction(menu)" 
              	 [routerLink]="menu.path" class="float-right">
                <i nz-icon nzType="select" nzTheme="outline" nzTitle="新窗口打开">i>
              a>
            span>
          li>
        ng-container>
        <ng-container *ngIf="menu.child">
          <li nz-submenu>
            <span title><i nz-icon type="appstore">i><span>{{menu.title}}span>span>
            <ul>
              <li *ngFor="let cm of menu.child" nz-menu-item [routerLink]="cm.path" 
              	  (click)="actionTab(cm)">
                <i nz-icon type="tag">i> {{cm.title}}
              li>
            ul>
          li>
        ng-container>
      ng-container>
    ul>
  nz-sider>
  <nz-content class="main-right bg-white flex-v">
  	
    <nz-tabset [nzType]="'card'" [(nzSelectedIndex)]="selectedIndex">
      
      <nz-tab *ngFor="let tab of tabs;let i=index;" [nzTitle]="titleTemplate" 
      		  (nzSelect)="tabSelect(tab, i)">
        <ng-template #titleTemplate>
          <div>
          	{{ tab.name }}<i nz-icon type="close" class="ant-tabs-close-x" (click)="closeTab(tab)">i>
          div>
        ng-template>
      nz-tab>
    nz-tabset>
    
    
    <div class="flex-1 overflow-auto content-box">
      <router-outlet class="main-content">router-outlet>
    div>
    
  nz-content>
nz-layout>
@Component({
  selector: 'app-dashboard',
  templateUrl: './dashboard.component.html',
  styleUrls: ['./dashboard.component.css']
})
export class DashboardComponent implements OnInit {
	// 定义左侧菜单以及相关路由链接
	menus: Menus = [
	  {path: '/m/gr', title: '游戏记录', uid: 101},
	  // ...
	  // newPage指示是否需要添加 [新窗口打开] 功能
	  {path: '/m/pi', title: '玩家信息', uid: 105, newPage: true},
	  {
	    title: '权限配置', uid: 104, child: [
	      {path: '/m/mgr/um', title: '用户列表', uid: 10401},
	      // ...
	    ]
	  }
	];
	// uid映射
	// 用于快速查找, 而不用每次都去 forEach(menus)
	menusMap: { [uid: string]: Menu } = {};
  
	// ...
	constructor(
	  // 路由器控制器
	  private router: Router,
	  // 页面标题服务
	  private titleService: Title,
	  // 当前组件相关的路由器
	  private activatedRoute: ActivatedRoute
	) {	
	  // 监听路由事件
	  // 只订阅 ActivationEnd 事件
	  this.router.events.pipe(filter(e => e instanceof ActivationEnd))
	    .subscribe((e: ActivationEnd) => {
	      const snapshot = e.snapshot;
	      const isSkip = !(snapshot['_routerState'].url 
	      					&& snapshot.routeConfig.data 
	      					&& snapshot.routeConfig.data.useCache);
	      if (isSkip) return;
	
		  // 获取路由配置中自定义的唯一标记
		  // uid: Unique Identity
	      const uid = snapshot.routeConfig.data.uid;

		  // 从当前激活的已存在的 tab 缓存中直接激活
	      let exist = false;
	      eachA(this.tabs, (tab, i) => {
	        if (uid === tab.uid) {
	          // mvvm 模式直接激活指定 tab
	          this.selectedIndex = i;
	          exist = true;
	          return false;
	        }
	      });
	
		  // 指定路由没有在 tab 缓存中找到(或已经从ui中关闭/删除)
	      // 首次进入没找到 tab, 从menu中获取
	      if (!exist)
	        this.actionTab(this.menusMap[uid]);
	
	    });
	}

	ngOnInit(){
		this.initMenusMap(this.menus);
	}

	// 初始化uid菜单映射
	initMenusMap(ms: Array<Menu>) {
	  if (Array.isArray(ms))
	    eachA(ms, m => {
	      this.menusMap[m.uid] = m;
	      // 递归子孙菜单
	      this.initMenusMap(m.child);
	    });
	}
	
	// 点击菜单激活指定路由并保存tab页签
	// 这里命名不准确, 应修改为 actionMenu
	actionTab(menu) {
	  if (!menu) return;
	  
	  // 如果已经存在该tab页签, 直接将它选中
	  // 如果没有打开就添加该tab页签, 并将它选中
	  // pushUniqueA 为自定义方法, 后面有说明
	  this.selectedIndex = pushUniqueA(
	  							this.tabs, 
	  							{name: menu.title, path: menu.path, uid: menu.uid}, 
	  							'uid');
	  
	  // 将当前页面标题切换为菜单名字
	  let tab = this.tabs[this.selectedIndex];
	  this.titleService.setTitle(tab.name);

	  // 激活这个tab对应的路由视图
	  this.activeRoute(tab);
	}
	
	// 关闭tab页签
	closeTab(tab) {
	  // 最后一个不允许关闭
	  if (1 === this.tabs.length) return;
	
	  // 删除tab
	  let optionIndex = indexA(this.tabs, tab, 'uid');
	  removeA(this.tabs, tab, 'uid');
	
	  // 正在关闭激活tab
	  if (this.selectedIndex === optionIndex) {
	    // 激活上一个tab
	    let nextIndex = this.selectedIndex - 1;
	    this.selectedIndex = nextIndex > 0 ? nextIndex : 0;
	    // 激活上一个路由
	    this.activeRoute(this.tabs[nextIndex]);
	  } 
	  // 正在关闭激活tab的左侧tab
	  else if (this.selectedIndex > optionIndex) {
	    // 关闭前面的tab, 索引下标前移一位
	    this.selectedIndex -= 1;
	  } 
	  // 正在关闭激活tab的右侧tab
	  else {
	    // 关闭后面的tab, 不作任何处理
	  }
	}
	
	// 切换tab选项卡
	tabSelect(tab) {
	  // 激活选项卡对应的路由
	  this.activeRoute(tab);
	}
	
	// 激活tab所关联的路由
	activeRoute(tab) {
	  this.router.navigateByUrl(tab.path).finally();
	  this.titleService.setTitle(this.tabs[this.selectedIndex].name);
	}
	// ...
}

工具函数

一些常用的工具函数

//////////////////////////////////////////// other
/**
 * 对象克隆
 * @param o
 * @return {any}
 */
export function clone(o: any) {
  if (isObject(o))
    return JSON.parse(JSON.stringify(o));
  return o;
}

/**
 * 展开对象属性为ognl表达式
 * @param {array|object} o 数组或对象
 * @param {boolean} skipNullOrUndef=true 跳过值为null或undefined的属性
 * @return {?} 将多级属性展开为一级属性后的对象
 */
export function extJson(o: any, skipNullOrUndef = true): { [prop: string]: any } {
  skipNullOrUndef = true || (undefined === skipNullOrUndef);
  const ret = {};
  ext(clone(o), null, ret);
  return ret;

  function ext(json, prefix, ret) {
    // const isArr = Array.isArray(json);
    eachO(json, (v, k) => {
      if (skipNullOrUndef && (isNullOrUndefined(v) || '' === v))
        return true;

      // 数组或数字属性名都是用中括号'[]'
      let nk;
      if (!isNaN(k)) nk = prefix ? (prefix + "[" + k + "]") : k;
      else nk = prefix ? (prefix + "." + k) : k;

      // 赋值
      if (!isObject(v)) ret[nk] = v;
      else ext(v, nk, ret);
    });
    return ret;
  }
}

/**
 * 增量步骤执行
 * @param {number} star 起始值
 * @param {number} end 结束边界值
 * @param {number} step 步长
 * @param {(v) => (boolean | void)} f 步长处理回调函数
 */
export function incrStep(star: number, end: number, step: number, f: (v) => boolean | void) {
  while (star <= end) {
    if (false === f(star)) break;
    star += step;
  }
}

//////////////////////////////////////////// object
/**
 * 对象属性遍历
 * @param {object} o 目标对象
 * @param {(v: T, k: (string | any)) => (boolean | any)} f 属性处理函数
 */
export function eachO<T>(o: object, f: (v: T, k: string | any) => boolean | any) {
  for (const k in o)
    if (false === f(o[k], k))
      break;
}

/**
 * 清空对象属性
 * @param {object} o 目标对象
 * @param {(v: any, k: string) => (boolean | void)} f   true-跳过当前属性, 
 * 														false-跳过后续所有, 
 * 														undefined-删除当前属性
 */
export function clearO(o: object, f?: (v: any, k: string) => boolean | void) {
  f = !isFunction(f) ? f : () => undefined;
  eachO(o, (v, k) => {
    let r = f(v, k);
    if (true === r) return;
    if (false === r) return false;
    delete o[k];
  });
}

//////////////////////////////////////////// Array
/**
 * 数组遍历
 * @param {Array} a
 * @param {((e: T, i?: number) => boolean) | void | any} f
 */
export function eachA<T>(a: Array<T>, f?: (((e: T, i?: number) => boolean) | void | any)) {
  if (!isFunction(f)) f = _ => true;
  for (let i = 0, len = a.length; i < len; i++)
    if (false === f(a[i], i))
      break;
}

/**
 * 追加唯一目标值, 如果校验存在则跳过
 * @param {Array} a 数组
 * @param {T} e 新元素
 * @param {string | ((el: T, i: number) => boolean)} c 唯一值属性名或比较器函数(返回true表示存在)
 * @return {number} 与e匹配的元素索引
 */
export function pushUniqueA<T>(a: Array<T>, e: T, c?: string | ((el: T, i: number) => boolean)): number {
  let foundIndex = indexA(a, e, c);
  if (-1 !== foundIndex)
    return foundIndex;
  return a.push(e) - 1;
}

/**
 * 查找索引
 * @param {Array} a 数组
 * @param {T} e 查找条件
 * @param {string | ((el: T, i: number) => boolean)} k 唯一值属性名或比较器函数(返回true表示找到)
 * @return {number} 索引, -1表示未找到
 */
export function indexA<T>(a: Array<T>, e: T, k?: string | ((el: T, i: number) => boolean)): number {
  let fn: (el: T, i: number) => boolean;
  if (!(k instanceof Function)) {
    if (isNullOrUndefined(k)) fn = el => el === e;
    else if (isString(k)) fn = el => el[k + ''] === e[k + ''];
  }

  let foundIdx = -1;
  eachA(a, (el, i) => {
    if (true === fn(el, i)) {
      foundIdx = i;
      return false;
    }
  });
  return foundIdx;
}

/**
 * 查找目标值
 * @param {Array} a 数组
 * @param {T} e 查找条件
 * @param {string | ((el: T, i: number) => boolean)} k 唯一值属性名或比较器函数(返回true表示找到)
 * @return {T | null} 查找成功返回目标值, 否则返回null
 */
export function findA<T>(a: Array<T>, e: T, k?: string | ((el: T, i: number) => boolean)): T | null {
  const i = indexA(a, e, k);
  return -1 !== i ? a[i] : null;
}

/**
 * 删除
 * @param {Array} a 数组
 * @param {T} e 查找条件
 * @param {string | ((el: T, i: number) => boolean)} k 唯一值属性名或比较器函数(返回true表示找到)
 * @return {T | null} 删除成功返回被删除目标值, 否则返回null
 */
export function removeA<T>(a: Array<T>, e: T, k?: string | ((el: T, i: number) => boolean)): T | null {
  const i = indexA(a, e, k);
  if (-1 === i) return null;
  return a.splice(i, 1)[0];
}

/**
 * 合并
 * @param {Array} t 目标数组
 * @param {Array} s 元素组
 */
export function concatA<T>(t: Array<T>, s: Array<T>) {
  if (!isArray(t) || !isArray(s)) throw '无效数组参数';
  Array.prototype.push.apply(t, s);
}

//////////////////////////////////////////// NzForm
/**
 * angular表单校验
 * @param {FormGroup} fm 表单组
 * @return {boolean} true-校验通过, false-校验失败
 */
export function validNgForm(fm: FormGroup): boolean {
  eachO<FormControl>(fm.controls, c => {
    c.markAsDirty();
    c.updateValueAndValidity();
  });
  return fm.valid;
}

结合SpringBoot2.x使用遇到的问题

无法识别新窗口路由跳转

由于angular的前端路由特性导致页面控制从SpringBoot2Controller全面交由Router控制, 导致服务器无法识别浏览器新窗口的路径. 未解决该问题各种百度/Google都没有找到合适的解决方案, 以下是针对作者实际项目整理的解决思路, 仅供参考:

  1. SpringBoot2不接管视图跳转
  2. 在 a[routerLink=path][target=_blank] 跳转前通过 (click) 事件缓存路由信息
  3. 在目标页面提取缓存信息并做路由跳转, 删除session中保存的临时数据

【注意】:开启新窗口sessionStorage中的数据会得到新的拷贝

前端关键代码

<a *ngIf="menu.newPage" target="_blank" 
	(click)="waitForAction(menu)" 
	[routerLink]="menu.path" 
	class="float-right">
		<i nz-icon nzType="select" nzTheme="outline" nzTitle="新窗口打开">i>
a>

点击事件处理

waitForAction(menu: Menu) {
	// 将路由信息缓存在 sessionStorage 中
	// 由于是通过 a 标签跳转, 两个页面都在同一域名下因此可以共享sessionStorage数据
	this.cached.session.set('menu', menu, true);
	// 在作者当前项目中, SpringBoot转发的页面总是在当前函数所在的组件
	// 因此该缓存信息使用一次后将不再有效
	this.cached.session.lazyDel('menu');
}

[新窗口]从缓存中获取路由信息并跳转

ngOnInit() {
    this.userService.checkLogin().subscribe(r => {
      if (!r.flag) {
        this.notificationService.warning('警告', '用户未登录或登录信息已过期');
        this.router.navigateByUrl('/login').finally();
        return;
      }

      this.menuManageService.listBy().subscribe(
        handleResult(this.notificationService, ({data}) => {
          this.menus = data;
          this.initMenusMap(this.menus);

          // 激活指定路由
          // 该路由信息使用一次即可
          // 注意: 由于在新窗口中, 这里的session.del将无法删除其他窗口中的sessionStorage数据
          const menu = this.cached.session.del<Menu>('menu');
          if (null != menu) this.actionTab(menu);
          
          else this.actionTab(this.menus[0]);
        })
      );

    });

  }

你可能感兴趣的:(angular)