从nz-breadcrumb源码窥探Angular路由

图源Behance | <Daily UI | #056 | Breadcrumbs> | Rabee Balakrishnan

|| 0 前言

在对接设计师所给的设计图进行页面的过程中,有一块区域使用到了面包屑的设计。而项目用的组件库是ng-zorro-antd,自然而然的,便用到了nz-breadcrumb这个控件。

虽说ng-zorro给出的面包屑控件,根据文档的指示,基本可以解决大部分需求。但是遇到一些深层定制化的需求的时候,就不仅仅是config data那么简单了。

举例一个实际场景:在供应商列表页面,点击供应商名称,跳转至供应商详细,按照面包屑的生成结构,就是供应商列表>供应商详细。但如果要求要求显示供应商列表>:供应商名称。那这个面包屑要动态显示。因为供应商详细是静态唯一的,而:供应商名称是多样可变的。

最后,去ng-zorro开发团队的开发博客源码地址拜读了一圈,结合发布者订阅模式进行了二次开发,实现重撒面包屑,实现了预期目标。另外解决了页面刷新时,面包屑会消失的问题。

在阅读控件源码以及查找资料的过程中,发现Angular路由机制的门道还蛮深的。想着不如借此机会,琢磨一下控件代码的实现逻辑,进而深化一下自己的知识结构。因此有了本文的记录。

本文中存在的任何错误与不足,欢迎指正,在此提前感谢。

接下来文章会从以下两个点做探讨:
  • 各模块中的Routes,本质上就是一个不断嵌套的路由树。
  • 为什么ng-zorro的文档要指出:懒加载的时候,data属性要绑在父路由?


|| 项目结构

// 这里只列出重要部分:src-->app下的文件结构
|-- app
    |-- component 组件
        |-- breadcrumb 面包屑组件
        |-- component.module.ts
    |-- layout
        |-- basic-layout 基础渲染页 // /page/*的component都从这里面的<router-outlet></router-outlet>进行映射
        |-- layout.module.ts
    |-- page
        |-- agreement-list 协议管理页
        |-- vendor-list 供应商列表页
        |-- vendor 供应商详细页
        |-- page.module.ts
    |-- app.component.ts
    |-- app.modules.ts
    |-- app-routing.module.ts 根路由模块


|| 1 routing-module中的routes,本质上就是不断嵌套的路由树

1.1先来看一下app-routing.module.tspage.module.ts中的routes
// app-routing.module.ts中的routes
const routes: Routes = [
  {path: '', redirectTo: 'page', pathMatch: 'full'},
  {path: 'page', loadChildren: () => import('./page/page.module').then(m => m.PageModule), data: {breadcrumb: '供应商列表'}}
  {path: '**', redirectTo: 'page'}
]


// page.module.ts中的routes
const routes: Routes = [
  // router执行顺序会按照顺序自上而下执行,请尽量扁平化的实现
  {path: '', redirectTo: 'vendor-list', pathMatch: 'full'},

  // 复用的basic-layout.component可以重复写入
  {
    // 因为子路由的渲染出口是在父路由的页面上
    // 因此当嵌套路由配置完成之后,在嵌套的父级页面上,我们需要定义一个 <router-outlet> 标签用来指定子路由的渲染出口
    path: 'vendor-list', component: BasicLayoutComponent,
    children: [
      {path: '', component: VendorListComponent},
      {path: 'vendor/:type/:id', component: VendorComponent, data: {breadcrumb: '供应商详情'}},
    ]
  }, {
    path: 'agreement-list', component: BasicLayoutComponent, data: {breadcrumb: '协议管理'},
    children: [
      {path: '', component: AgreementListComponent}
    ]
  },
  {path: '**', redirectTo: 'vendor-list'},
]
1.2根据两个路由的从属关系,将loadChildren的部分转化成路由结构,拼出来应该是这样的:
{ path: 'page',
    data: {breadcrumb: '供应商列表'},
    children: [
      {path: '', redirectTo: 'vendor-list', pathMatch: 'full'}, 
      {
        path: 'vendor-list', component: BasicLayoutComponent,
        children: [
          {path: '', component: VendorListComponent},
          {path: 'vendor/:type/:id', component: VendorComponent, data: {breadcrumb: '供应商详情'}},
        ]
      },
      {
        path: 'agreement-list', component: BasicLayoutComponent, data: {breadcrumb: '协议管理'},
        children: [
          {path: '', component: AgreementListComponent},
        ]
      },
      {path: '**', redirectTo: 'vendor-list'}
    ]}
1.3转化成视图的话就长下面这样:
项目的路由树结构图示


|| 2 为什么懒加载的时候,data属性要绑在父路由?

2.1先了解一下懒加载以及data属性的定义。

懒加载,顾名思义,是不急于加载的意思。只有当一个路由完全匹配,才会加载对应切割的代码模块。

app-routing.module.ts中:

//app-routing.module.ts
{path: 'page', loadChildren: () => import('./page/page.module').then(m => m.PageModule), data: {breadcrumb: '供应商列表'}},

其中,loadChildren后跟的箭头函数是个异步加载方法,也就是说,当我们输入路由“/page”的时候,才会加载page.module。

再来看看data这个属性。

根据node_modules/@angular/router/router.d.ts中,对于data的解释:

node_modules/@angular/router/router.d.ts

data只是个静态资源。所以在加载对应路由之前,会先加载静态资源。即data会在loadChildren之前被加载。

另外需要注意的是,实际上,懒加载这件事,不是路由去实现的,而是由webpack进行分割打包。而Angular又对底层的webpack语句进行了封装,通过辨识 loadChildrenwebpack去切割代码块。

2.2为什么需要在父层路由写data?

先上breadcrumb.component.htmlbreadcrumb.component.ts代码:

<!--breadcrumb.component.html start-->
<nz-breadcrumb>
  <nz-breadcrumb-item>首页</nz-breadcrumb-item>
  <nz-breadcrumb-item *ngFor="let breadcrumb of breadcrumbs">
    <a [routerLink]="[breadcrumb.url, breadcrumb.params]">{{breadcrumb.label}}</a>
  </nz-breadcrumb-item>
</nz-breadcrumb>
<!--breadcrumb.component.html end-->
// breadcrumb.component.ts
import {ActivatedRoute, NavigationEnd, Router, PRIMARY_OUTLET} from '@angular/router';
import {filter} from 'rxjs/operators';
import { startWith } from 'rxjs/internal/operators/startWith';

···
constructor(
    private activatedRoute: ActivatedRoute, // 当前路由服务
    private router: Router
  ) { }

ngOnInit() {
    // console.log(this.activatedRoute.snapshot.params);
    console.log(this.activatedRoute.data.subscribe(data => console.log(data)));
    this.router.events.pipe(
      filter(event => event instanceof NavigationEnd),
      // 执行初始化渲染。因为对于app-routing下的子组件来说,子组件的生命周期在NavigationEnd结束后才执行init,错过了路由变化的监听
      // startWith: 在subscribe开始之前,一开始就同步发出,常被用来保存程式的起始状态。
      startWith(true)
    ).subscribe(event => {
      console.log(this.activatedRoute);
      const root: ActivatedRoute = this.activatedRoute.root;
      this.childIndex = 1;
      this.breadcrumbs = this.getBreadcrumbs(root);
    });
  }

getBreadcrumbs(
    route: ActivatedRoute,
    url: string = '',
    breadcrumbs: BreadcrumbItem[] = []): BreadcrumbItem[] | undefined {

    const ROUTE_DATA_BREADCRUMB = 'breadcrumb';

    // get children route
    const children: ActivatedRoute[] = route.children;\

    // if there is no children route, return
    if (children.length === 0) {
      return breadcrumbs;
    }

    // 这里的遍历打印出来,发现会执行多次,是因为会从app-routing.module.ts作为第一层算起,从上往下找,直到找到component所在的层级,则遍历结束。
    for (const child of children) {
      console.log('第' + (this.childIndex++) + '次遍历');
      if (child.outlet === PRIMARY_OUTLET) {
        console.log(child.snapshot.url, route.snapshot.data);
        const routeUrl: string = child.snapshot.url.map(segment => segment.path).join('/');
        const nextUrl =  url.replace(/\/$/, '') + `${routeUrl}`;

        // 此处要做routeUrl的非空判断,否则会因为some.module.ts的path为''而塞入一个虚路由,出现双皮奶
        if (routeUrl !== '' && route.snapshot.data.hasOwnProperty(ROUTE_DATA_BREADCRUMB)) {
          const breadcrumb: BreadcrumbItem = {
            label: child.snapshot.data[ROUTE_DATA_BREADCRUMB],
            params: child.snapshot.params,
            url: nextUrl
          };
          breadcrumbs.push(breadcrumb);
          console.log(breadcrumb);
        }
        // 递归
        return this.getBreadcrumbs(child, url, breadcrumbs);
      }
    }

  }
···

breadcrumb.component.ts中,面包屑的生成逻辑为:通过this.activatedRoute.root获取当前路由的根路由,然后从根路由向下获取所有子节点。因此,在输入locahost:4200/page/vendor-list的时候,获取的所有子节点如下图所示。而标红部分也就是ActivatedRoute

ActivatedRoute(包含当前激活插座的组件的路由信息)

接下来,让我们修改data的放置位置,对比上文1.1中,app-routing.module.tspage.module.tsdata放置的位置。观察breadcrumb.component.tsconsole.log(child.snapshot.url, route.snapshot.data)的输出结果,来进行对比。

// breadcrumb.component.ts
···
console.log(child.snapshot.url, route.snapshot.data);
···

另外,需要说明的是,child.snapshot.url不为空且route.snapshot.data中有breadcrumb才会被推进Array。

// breadcrumb.component.ts
···
if (routeUrl !== '' && route.snapshot.data.hasOwnProperty(ROUTE_DATA_BREADCRUMB)) {
···
2.2.1 根据1.1中的data的放置位置,输出结果为:
url(不可为空) data(要有breadcrumb) 结果
'page' {} 剔除
‘vendor-list’ {breadcrumb:’供应商列表’} 存入Array
'' {breadcrumb:’供应商列表’} 剔除
2.2.2 修改data放置位置
// app-routing.module.ts 去除data
{path: 'page', loadChildren: () => import('./page/page.module').then(m => m.PageModule)},

// page.module.ts 放置data
{path: 'vendor-list', component: BasicLayoutComponent, data: {breadcrumb: '供应商列表'},

输出结果为:
url(不可为空) data(要有breadcrumb) 结果
'page' {} 剔除
‘vendor-list’ {} 剔除
'' {breadcrumb:’供应商列表’} 剔除

再回想一下,上文中指出的面包屑生成逻辑:从根路由层层扒拉出pathdata中的breadcrumb。从和2.2.2可知,从根路由的data中就没有breadcrumb,自然拼不出面包屑。

2.2.3当然,有可能你已经发现,在2.2.1中,我在父层路由绑定了data,为什么path'page'的时候,输出的data没有breadcrumb?

经过一翻研究,在此做出如下解释:

ActivatedRoute的定义:包含当前激活插座的组件的路由信息。
也就是说,就是根据路径path追踪到真正实例化的组件component部分,才是真正的激活路由。

②在node_modules/@angular/router/router.d.ts中,有这么一段话:

翻译:路由器将无组件父级的参数,数据和解析合并为子级的参数,数据和解析。

app-routing.ts中,懒加载路由是没有指定component的,所以data流向了子路由,在breadcrumb.component.ts的遍历方法中,'page'层没打印出{breadcrumb: 'xxx'},而它的子路由'vendor-list'''会拿到。

接下来,我们通过上述两点,结合1.2agreement-list的嵌套路由,以及面包屑组件中的实现逻辑url不为空,且data中有breadcrumb

···
 {
        path: 'agreement-list', component: BasicLayoutComponent, data: {breadcrumb: '协议管理'},
        children: [
          {path: '', component: AgreementListComponent},
        ]
},
···

由此可知,真正实例化的组件是AgreementListComponent这一层,BasicLayoutComponent只是作为映射层,但是AgreementListComponent层的path为空,所以要将data放到父层路由。


|| 3 其他补充

3.1为什么刷新页面,面包屑会丢失

NG-ZORRO 开发博客:自动生成面包屑一文中给出了原因和答案。

对于component的非子组件,ngOninit钩子在路由事件之前执行;而component的子组件,子组件路由结束事件在生命周期执行钩子前完成。
而从各个component的ngOninit执行顺序和路由监听可知,除了app.component.ts的ngOninit在路由变化前执行,其余xxxxx.component.tsngOninit都在路由周期结束之后才执行。
因为breadcrumb.component.tsngOninit执行在路由事件之后,ngOninit中是执行监听路由变化的方法,生命周期在路由事件之后,两者相互错过,所以出现刷新页面导致面包屑丢失。

解决方案:

监听路由事件的时候,增加pipe,使用rxjs的startWith(true),强制在初始化的时候先执行。


startWith():一开始就同步发出,常被用来保存程式的起始状态。


|| 4 参考资料

[01]真香官网
[02]NG-ZORRO 开发博客:自动生成面包屑
[03]Angular路由管理过程浅谈
[04]Lazy Loading Angular - Code Splitting NgModules with Webpack
[05]顺便可以深化了解的内容:重新认识angular生命周期


|| 5 总结

文章脉络写在开头,结尾不再赘述。文章中有任何不足,可以直接评论区指出啦。

很感激在琢磨的过程中,前辈们的思路提供,当中有好几次的琢磨方向都劈了叉(比如以为路由中的component不可以被重复使用,以为懒加载是路由做的事情等等),感谢他们提供的一些思考方向,让我少走更多的弯路。



**
最后一趴,和正文没太大关系了。随意比比一下自己的研究目的和心情,顺带聊下这段时间的部分反思。

*分析了下自己所执行的工作角色,根本上来说就是去不断的解决问题。

除了技术实现的问题,还有更多需要结合业务场景去思考解决的问题。解决问题的过程让人痛苦与快乐并发,问题解决的那一刻是高光时刻。

*然后吧,自己又是个一个极其喜欢记录问题/兴趣点,然后去花时间琢磨琢磨自得其乐的人。

就是喜欢琢磨事,只要感兴趣的,什么事都可以。

这里的问题记录,指的是为什么答案提供者是怎样的解法。比如,为什么ng-zorro提出懒加载的时候要把data放在父路由。

无论是业务或是技术,都建议具体问题具体分析,从一个感兴趣的点/问题点去切入并拓宽,再回头看理论,比起一开始拿着一个框架图去走马观花扫览一通,更有助于扎实的吸收。

*在给出解决办法前的那一段思考过程十分珍贵。站在什么思考角度,用怎样的思考逻辑。这有助于对某一个问题的解决方案有了更深的认知,去更好的构建个人知识体系。

*这应该说得上是我对沉淀的执着。

但如果开发节奏时常保持在一个完成就用,下一个的状态。经常完成一个需求之后,就马不停蹄的继续开发下一个需求环节。这样过于动态的开发节奏和沉淀的静态需求之间就成了矛盾。

加上一些其他琐碎和意外裹挟而来。敏感体质放大那些焦虑和失落感。而在处理疏通自己的负面情绪,是我极其不擅长的领域。只会看它累在问题记录表上越堆越高,直到它们自己一股脑的发出来。这种内耗是极其要命的。

有相当一段时间,对于常看的学习网站,我连打开浏览都做不到。觉得自己是一块快速掉电的电池,工作和生活中的琐碎一般耗掉90%的电量,红色的余量支撑一个积极的心态都岌岌可危。看动画片不香吗?厨房它不香吗?出去走走不好吗?为什么不去做一些可以忘记我当前角色的事情呢。

*不得不说,逃避虽然可耻但是有效。

毕竟再怎么鼓励自己,给自己打气,和自己说:坚持住,保持那样的吸收状态,你会更强云云。这些都抵不过在心头真实盘踞的失落焦躁的心情。想要沉淀,就得先把这些盖在上面的情绪清理掉,否则就不能恢复我想要的节奏。

*学会有效的休息和有效的工作,使两者进行互相的正向反馈互相制衡,这个能力太重要了。

很是佩服那些工作再忙,也依旧在社区辛勤分享的作者们。时间挤挤是会有,但是拿挤出的时间选择学习、积累、内化,和梳理、产出、分享,并能够保持积极的心态和节奏,这点是真真厉害。

*只要自己想做的事在进行中,那慢一点也不要紧。耐心点。只要你在做着喜欢的事想做的事,确认做的事情源于自己的选择。那无论你是选择看更多的影片或者是报更多的学习课程,其实都可以。想做就去做。

比比到这,差不多了。比正文都长了。

最后,时间进入今年的下半程了,大家都要健康好运哇。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 227,367评论 6 532
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 98,001评论 3 413
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 175,213评论 0 373
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 62,535评论 1 308
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 71,317评论 6 405
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 54,868评论 1 321
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 42,963评论 3 440
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 42,090评论 0 285
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 48,599评论 1 331
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 40,549评论 3 354
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 42,712评论 1 367
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 38,233评论 5 356
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 43,961评论 3 346
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 34,353评论 0 25
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 35,607评论 1 281
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 51,321评论 3 389
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 47,686评论 2 370