Angular 18+ 高级教程 – Routing 路由 (原理篇)

修改中...

 

前言

Angular 是 Single Page Application (SPA) 单页面应用,所谓的单页面是站在服务端的角度看,不管游览器请求什么路径,一律返回 index.html 即可。

那站在客户端的角度,它就不是单页面了,不同的路径所呈现的内容都是不同的。

简单的说,就是把原本服务端负责的 Routing + 多页面,转交给了客户端负责。

Angular 自带了一套完整的 Routing 方案,这套方案使用起来是简单的,尽管其中的功能和配置比较多比较杂,但不用担心,我们逐个学习就可以了。

然而,要想深入理解整套 Routing 机制原理就不是那么简单了,这个难度大概和 NodeInjectorChange DetectionDynamic Component 不相上下。

我们将和以前一样,由浅入深。先过一篇所有的功能 (不需要懂原理),然后再逛一逛源码 (讲解原理),开始吧🚀。

 

参考

Angular Router: Getting to know UrlTree, ActivatedRouteSnapshot and ActivatedRoute

 

游览器 の 导航

在讲解 Angular Routing 之前,我们先了解一下游览的导航流程和体验。

URL > HTTP > HTML > render

当用户在游览器键入 URL (e.g. https://www.domain.com/path) 并回车 (enter) 后,游览器会向服务端发送 HTTP 请求。

服务端接收到 HTTP 请求后会返回 (response) HTML 格式的内容。

游览器接收到 HTML 内容后会渲染出页面。

整个过程如下:

不同的 URL 代表不同的页面,服务端会返回不同的 HTML 内容。

<a href> > HTTP > HTML > render

在游览器地址栏键入 URL 通常用于第一次的页面访问,后续从首页切换到 about 页,则是透过点击 HTML 内容里的 <a href="/about">  element。

当游览器监听到 <a> 被点击后,它会把 href 属性值键入地址栏,然后向服务端发送 HTTP 请求。

之后的流程就是一样的了,服务端接收 HTTP 请求,返回 HTML 内容,游览器渲染新页面的 HTML 内容。

整个过程如下:

back / forward > HTTP (disk cache) > HTML > render & restore scroll position

游览器还有一种方式能操控导航,那就是 back / forward button。

当用户点击 back button 时,游览器会发送 HTTP 请求,但是这个请求不会去到服务端,而是直接从 disk cache 里读取缓存的 HTML 内容。

接着游览器会渲染这个 HTML 并且在渲染完成后移动 scrollbar 到上次离开的位置。

forward 也是一模一样的流程。

整个过程如下:

hash > scroll to

游览器导航还有一个重要的交互体验,那就是 URL # hash 会让游览器自动 scroll to 到指定的 element。

整个过程如下:

总结

以上就是游览器的导航流程与体验。我们还没有提到 Routing 哦。

不过可以看到,导航是由游览器和服务端相互配合完成的。

Angular 要想完全接管这项任务需要做的事可不少呢。

 

服务端 の Routing

在讲解 Angular Routing 之前,我们先了解一下服务端 Routing 的发展史,毕竟 Angular 是取代它嘛,我们有必要了解前世今生。

石器时代

上古时代,服务端是没有 Routing 这一概念的。

游览器发 HTTP 请求,对于服务端来说,就是简单的远程文件读取 (下载 > 解析 > 显示)。

这是服务端保存的文件

游览器想访问图片就发送 HTTP 请求到 https://www.domian.com/image.jpg

想访问 JavaScript 就发送 HTTP 请求到 https://www.domian.com/script.js

想要 CSS 就 https://www.domian.com/style.css

想要首页就 https://www.domian.com/index.html

about 页 https://www.domian.com/about.html

product 页 https://www.domian.com/product/index.html

product detail 页 https://www.domian.com/product/detail.html

服务端接收到 HTTP 请求后,会依据 URL 地址找到对应的文件,然后读取文件内容 (比如 index.html 里的 HTML),接着把内容以 HTTP 形式返回。

游览器接收内容 > 渲染内容 > 结束。整个过程简单明了。

青铜时代

石器时代的方案虽然简单明了,但是它有个大问题 -- 页面 URL 不漂亮。

比如说 https://www.domian.com/index.html,用户怎么懂得键入 “index.html” 这几个字呢?

因此,服务端需要引入 Routing 概念。

用户键入 https://www.domian.com,游览器发送 HTTP 请求,服务端接收后依据 URL 地址去找到对应的文件 (默认文件是 index.html,这是一个潜规则)。

https://www.domian.com/product 页面对应的文件地址是 product\index.html。

https://www.domian.com/about 页面对应的文件地址是 about\index.html。

页面 URL 与文件的 mapping 关系就是所谓的 Routing。

铁器时代

一个 URL 对应一个 .html 文件,这个方案最大的问题是 HTML 内容难以被复用。

比如说,企业网站所有的页面都有相同的 header 和 footer。

那每一个 index.html 都要重复写上 header 和 footer 的代码,若要修改就必须批量修改,这对管理是大扣分。

再比如,产品页面

上面是两个不同的产品,但是它们的 HTML 代码有 99% 是一模一样的,只有 h1 和 img src 不同而已。

倘若有 1000 个产品就需要 1000 个 index.html 文件,然后里面的内容 99% 都是一样的,这显然不符合管理原则。

于是服务端又引入了模板引擎和 MVC (Model–view–controller) 概念。

我拿 ASP.NET Core 举例。

游览器发送 HTTP 请求来到服务端后,会进入 ASP.NET Core 应用程序。

ASP.NET Core 应用程序就是我们写的代码,里面已经定义好了 Routing,Controller,Model,View (这几个东西只是名词,里面其实就是一般的命令代码而已)。

Routing 的职责是解析 URL 然后把资料传给 Controller。

Controller 依据 URL 资料生成出数据 (通常是去数据库获取数据),然后再传给对应的 View。

传递的数据就叫 Model,所谓的 View 就是一个 HTML 模板引擎。

View 的职责是生成最终的 HTML 内容然后返回 HTTP。

具体例子:

  1. 用户键入 URL https://www.domain.com/product/iphone-14

  2. 游览器发 HTTP 请求

  3. ASP.NET Core 应用程序接收 HTTP 请求

  4. Routing 负责从 URL 解析出有用的资料,比如 product 表示 product 页面,iphone-14 表示特定产品。

  5. Routing 把解析出的资料交给对应的 Controller (e.g. ProductController),不同页面通常由不同的 Controller 负责。

  6. ProductController 去数据库提取 iphone-14 数据 (e.g. price),然后把这些数据 (Model) 交给 ProductView。

  7. ProductView 负责把 iphone-14 数据 binding 到 ProductTemplate 里,生产出最终的 HTML 内容。

  8. 最后,返回带 HTML 内容的 HTTP 请求。

  9. 游览器接收到 HTML 内容,渲染出页面。

总结

以上就是传统 (非 SPA) 网站的服务端 Routing。

Angular 是 SPA,它只有在第一次访问页面时才会发 HTTP 到服务端请求 HTML 内容,后续切换页面就不会再发 HTTP 请求 HTML 内容了。

因此,Angular Routing 也需要具备类似服务端 Routing 的功能,比如说解析 URL 提取出有用的资料传递给对应的 Controller (组件),组件在去获取相关的数据 (Ajax),在把 Model (组件实例) 传给 View (组件 Template) 做 bindding 和渲染。

好,搞清楚 Angular Routing 所需要涵盖的功能后,我们就可以开始逐个学习啦。Go Go Go 🚀

 

The Simplest Angular Routing Example

我们以企业网站作为最简单的例子,看看 Angular Routing 如何取代服务端的 Routing。

Create project

首先,创建一个项目

ng new my-shop --skip-tests --style=scss --ssr=false --routing

关键是要加 "--routing"  command,它会帮我们创建一些和 Rouing 有关的 files。

Create page component

创建三个页面

ng g c home
ng g c product
ng g c about

Routing 的核心任务是依据不同的 URL 显示不同的页面。

Angular 没有 "页面" 这个概念,它只有组件。

所以 Angular Routing 是依据不同的 URL 显示不同的组件。

Define routes (URL 与组件的配对)

app.config.ts

import { Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { ProductComponent } from './product/product.component';
import { AboutComponent } from './about/about.component';

export const routes: Routes = [
  { path: '', component: HomeComponent },
  { path: 'product', component: ProductComponent },
  { path: 'about', component: AboutComponent }
];

app.config.ts 是 CLI --routing command 创建的文件。

它负责 define URL 与组件的配对关系。

这是一个 Route 对象

{ path: 'product', component: ProductComponent }

path 就是 URL,component 就是这个 URL 对应要显示的组件。

提醒:path 不需要也不可以 starts with slash '/' 哦。

ProvideRouter with Routes

我们需要把 define 好的 Routes (URL 与组件的配对关系) 告知 Angular。

在 app.config.ts 提供 DI Provider。

import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';

// 1. 这是我们 define 好的 Routes (URL 与组件的配对关系)
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  // 2. 调用 provideRouter 函数并且传入 routes
  providers: [provideZoneChangeDetection(), provideRouter(routes)]
};

Router Outlet

App Template

<header>header</header>
<main>
  <router-outlet />
</main>
<footer>footer</footer>

<router-outler /> 是 Angular Routing 中的一个指令。

它有点类似我们学过的 ngComponentOutlet,Angular Routing 在配对 URL 拿到对应的组件后,会动态创建这个组件,然后插入到这个 <router-outlet /> 的位置上。

它底层使用的就是 Dynamic Component 手法,createComponent 函数 + ViewContainerRef.insert。

效果

不同 URL 显示不同的组件。

 

404 Not Found Page

创建 NotFound 组件

ng g c not-found

添加 Route

export const routes: Routes = [
  { path: '', component: HomeComponent },
  { path: 'product', component: ProductComponent },
  { path: 'about', component: AboutComponent },
  // 添加一个 Route,配对 404 not found page
  { path: '**', component: NotFoundComponent }
];

'**' 双星号是 Angular Routing 配对的潜规则,意思是配对任何 URL。

效果

不管什么 URL 都会被 ** 配对到,然后输出 NotFound 组件。

Routes 的配对次序

Routes 配对次序的规则是:第一个配对成功的 Route 会被选用。

假如我们把 ** Route 放到第一行

export const routes: Routes = [
  // 1. 把 ** Route 移到第一行
  { path: '**', component: NotFoundComponent },
  { path: '', component: HomeComponent },
  { path: 'product', component: ProductComponent },
  { path: 'about', component: AboutComponent },
];

效果

由于 ** 是配对 whatever,所以一定会配对成功,那结果就一定是输出 NotFound 组件,后续 define 的 home, product, about Route 永远没有上场的机会。

因此,记得把 ** Route 放到最后一行。

 

Query Parameters

URL 里面除了有 path 还有其它东西会影响到页面内容

比如 query string (a.k.a query parameters)

/product?page=2

当页面有 pagination 时,query param 经常用来表示想访问的页数。

因此,我们需要在 Product 组件里获取到这个 query param (page=2)。

withComponentInputBinding

app.config.ts

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection(), 
    // 1. 加上 withComponentInputBinding 函数
    provideRouter(routes, withComponentInputBinding())
  ]
};

在 providerRouter 时加上 withComponentInputBinding。

然后,在 Product 组件内就可以透过 input 获取到 query param 了。

export class ProductComponent implements OnInit {
  readonly page = input<string>();
  ngOnInit() {
    console.log(this.page()); // '2'
  }
}

这里有几个知识点:

  1. input 要 OnInit 阶段才能获取到

  2. this.page() 返回的是 string (已经 URL decode 好的 string)

  3. 如果 URL 没有 define page query param,那 page() 返回 undefined

  4. 如果 URL 是 ?page=,那 page() 返回 empty string

  5.  我们可以利用 input transform 把它转成 number 类型,也可以用 alias 取别名

    readonly pageNumber = input(undefined, { alias: 'page', transform: numberAttribute });

    提醒:如果 URL 是 ?page= 或者 ?page=not-a-number,那 pageNumber 将会是 NaN 哦。

  6. 当用户翻页时 (/product?page=2 去到 /product?page=3),Product 组件不会被 destroy / re-create,只有 pageNumber 会发布新的值而已。

    constructor() {
      effect(() => {
        // 1. will call every time user change page
        console.log(this.pageNumber());
      })
    }

    我们可以用 effect 去监听这个翻页,然后修改 ViewModel 渲染出新页面内容。

multiple same key

URL -- /product?page=2&page=3

当出现 2 个 page 时,Angular Routing 会如何处理呢?

export class ProductComponent implements OnInit {
  page = input();
 
  ngOnInit() {
    console.log(this.page()); // ['2', '3']
  }
}

答案是返回 array。

注:尽量避开这种情况,避免不必要的混淆

ActivateRoute の queryParam

除了透过 input,我们还有一个比较传统 (withComponentInputBinding 是 v16 推出的) 的方法可以拿到 query param。

那就是 ActivateRoute 对象。

export class ProductComponent {
  constructor() {
    const activatedRoute = inject(ActivatedRoute);
    const page = activatedRoute.snapshot.queryParamMap.get('page'); 
    console.log(page); // '2'
  }
}

它和 input 有一些不同之处:

  1. 同样是返回 encoded string,但如果没有 URL 没有 define page,它返回的是 null,而不是 undefined。

  2. 在 constructor 阶段就可以拿到 query param 了。

    input 之所以要等到 OnInit 阶段才可以拿到 query param,纯粹是为了统一规范而已 (普通 binding 的 input 一定要到 OnInit 阶段才能使用)

上面我们使用的是 snapshot query param,它只读取一次。如果我们想监听翻页,我们就不要使用 snapshot 版。

constructor() {
  const activatedRoute = inject(ActivatedRoute);
  const page = activatedRoute.queryParamMap.pipe(takeUntilDestroyed()).subscribe(queryParamMap => {
    // 1. will call every time user change page
    const page = queryParamMap.get('page');
    console.log(page);  
  })
}

把 snapshot 去掉后,queryParamMap 返回的是 RxJS Observable,subscribe 它就可以监听到每次的翻页了。

提醒:当组件 destroy 的时候,记得 unsubscribe 订阅。(虽然在一些情况下,确实可以不需要 unsubscribe,但个人建议,统一管理,每一次都做 unsubscribe 会比较整齐)

get all query params

URL -- /product?page=2&orderBy=price

constructor() {
  const activatedRoute = inject(ActivatedRoute);
  // get all query params
  console.log(activatedRoute.snapshot.queryParams); // { page: '2', orderBy: 'price' }
}

透过 activatedRoute queryParams 可以获取所有的 key value,这个无法透过 input 的方式获取,所以有时候还是老方法要强一些。

使用 queryParamMap.keys 也可以拿到所有的 keys,然后再透过 for loop keys + queryParamMap.get 获取 value。

const activatedRoute = inject(ActivatedRoute);
const keys = activatedRoute.snapshot.queryParamMap.keys;
for (const key of keys) {
  const value = activatedRoute.snapshot.queryParamMap.get(key);
  console.log([key, value]); // ['page': '2'], ['orderBy': 'price']
}

multiple same key

URL -- /product?page=2&page=3

当出现 2 个 page 时

constructor() {
  const activatedRoute = inject(ActivatedRoute);
  // URL: product?page=2&page=3 
  console.log(activatedRoute.snapshot.queryParams); // { page: ['2', '3'] }
}

queryParams 的 value 会变成 array。

如果使用 queryParamMap 的话,它分两个方法。

constructor() {
  const activatedRoute = inject(ActivatedRoute);
  console.log(activatedRoute.snapshot.queryParamMap.get('page')); // '2' 
  console.log(activatedRoute.snapshot.queryParamMap.getAll('page')); // ['2', '3']
}

get 返回第一个 value,getAll 返回 array。

提醒:假如 URL 没有 define page query param,getAll 会返回 empty array。

 

用 Param 实现抽象 Page

Multiple URL map to one component

下面是两个不同的 URL

  1. /product/iphone-4

  2. /product/iphone-5

按理说,每一个 URL 都对应一个不同内容的页面,但有一个概念叫抽象 page。

它的意思是,虽然 URL 不同,但是页面设计是相同的,只是小部分内容不一样而已。

像这样

这种情况下,这两个 URL 应该要配对到同一个页面 (组件)。

其实这个抽象 page 的概念和上一个 part 我们提到的翻页大同小异。不同页数的 URL 配对到的是同一个组件。

它俩的区别只是在 URL 表达形式上不一样,一个是透过 query param,另一个是透过 segment。

这一段一段的在 Angular Routing 被称之为 segment。

我们来看个例子

app.routes.ts

export const routes: Routes = [
  { path: '', component: HomeComponent },
  // 1. 加上 :productName
  { path: 'product/:productName', component: ProductComponent },
  { path: 'about', component: AboutComponent },
  { path: '**', component: NotFoundComponent },
];

:whatever (例子中的 :productName) 是 Angular Routing 的潜规则写法,它代表配对 /product/whatever。

比如:

  1. /product/iphone-4 配对成功

  2. /product/iphone-5 配对成功

  3. /about/iphone-5 配对失败,开头必须是 /product

  4. /product 配对失败,必须要有 2 个 segment,这里只有 1 个 product,不行。

  5. /product/iphone-5/detail 配对失败,必须要刚刚好 2 个 segment,这里多了一个 detail 也不行。

Read segment in component

在 Product 组件里,我们需要拿到 URL 中的 segment 2。

也就是 'iphone-4' 这个字,这样我们才可以输出对应的内容。

Product 组件

export class ProductComponent implements OnInit {
  productName = input.required<string>();
  ngOnInit() {
    console.log(this.productName()); // 'iphone-4'
  }
}

使用 input 就可以拿到 productName 了。

注:这里 input 加了 .required,因为它一定会有。没有的话 (e.g. /product) 是配对不到 Prodcut 组件的。

ActivateRoute の param

除了透过 input,我们也可以透过 ActivateRoute 对象获取到 segment productName。

export class ProductComponent {
  constructor() {
    const activatedRoute = inject(ActivatedRoute);
    const productName = activatedRoute.snapshot.paramMap.get('productName'); 
    console.log(productName); // 'iphone-4'

    console.log(activatedRoute.snapshot.params); // { productName: 'iphone-4' } 

    // 监听从 /product/iphone-14 切换到 /product/iphone-15 (组件不会被 detroyed,只是 paramMap 会发布而已)
    activatedRoute.paramMap.subscribe(paramMap => console.log(paramMap.get('productName')));
  }
}

方式和 queryParam 一模一样,唯一的区别是名字是 param 而不是 queryParam。

queryParam 指的是 URL ? 问号后面的 key value,而 param 指的是 URL path 里面的 key value。

 

Multilayer / Nested Router Outlet

Router Outlet 是可以嵌套使用的。

这三个 URL 都指向 product 页面。

在 product 页面里,又再细分出 3 个子页面,product description, product reviews 和 related products。

红框是最上层的 <router-outlet />,它对应的 URL segment 是 "/product"。

黄框则是子层 <router-outlet />,它对应的 URL segment 是 "/", "/reviews", "/related-products"。

我们来看看具体实现代码

首先是创建 3 个新组件:ProductDescription, ProductReviews 和 RelatedProducts 组件。

接着在 Product Template 添加一个子层 <router-outlet />

待会儿三个新组件就会被创建和插入到这里。

最后是 define routes (URL 与组件的配对)

export const routes: Routes = [
  { path: '', component: HomeComponent },
  { path: 'about', component: AboutComponent },
  { 
    path: 'product', // 配对 URL "/product"
    component: ProductComponent,
    // 1. 关键在这里
    children: [
      { path: '', component: ProductDescriptionComponent },             // 配对 URL "/product"
      { path: 'reviews', component: ProductReviewsComponent },          // 配对 URL "/product/reviews"
      { path: 'related-products', component: RelatedProductsComponent } // 配对 URL "/product/related-products"
    ]
  }
];

在 product route 里添加一个 children 属性,它的类型也是 Routes,简单说就是一个嵌套的概念。

效果

 

Route and URL Matching 详解

我们先看简单的

export const routes: Routes = [
  { path: '', component: HomeComponent },          // "/""
  { path: 'about', component: AboutComponent },    // "/about"
  { path: 'product', component: ProductComponent } // "/product"
];

注释写的是每个 Route 各自配对的 URL。

上面这三个太直观了,我们看难一点的。

{ 
  path: 'product', 
  component: ProductComponent,
  children: [
    { path: '', component: ProductDescriptionComponent },             // 配对 URL "/product"
    { path: 'reviews', component: ProductReviewsComponent },          // 配对 URL "/product/reviews"
    { path: 'related-products', component: RelatedProductsComponent } // 配对 URL "/product/related-products"
  ]
} 

嵌套的 Route 配对方式是把上层的 path 加上下层的 path。

再来一个

{ 
  path: 'product/:productName', // 配对 URL "/product/iphone-14" 
  component: ProductComponent,
  children: [
    { path: '', component: ProductDescriptionComponent },             // 配对 URL "/product/iphone-14"
    { path: 'reviews', component: ProductReviewsComponent },          // 配对 URL "/product/iphone-14/reviews"
    { path: 'related-products', component: RelatedProductsComponent } // 配对 URL "/product/iphone-14/related-products"
  ]
} 

从这个例子中我们可以看到 path 不一定是一个 segment,它也可以是 multiple segments (e.g. "product/:productName" 这里就有 2 个 segments)

或者 "/product/phone/:productName" 3 个 segments 也可以。

Default matching rules

我们来看一看 Route 和 URL 之间的 matching 规则。

example 1:

URL:"/product"

Route:{ path: 'product' }

这是最简单的,URL 一个 segment,Route 也是一个 segment,然后它俩的值相等,这样就 match 到了。

example 2:

URL:“/product/iphone14”

Route: { path: 'product/:productName' }

URL 两个 segments,Route 也是两个 segments,第一个 segment 它俩的值相等,第二个 segment,route 用了特殊语法 ":productName",它可以配对任意 URL segment 值。

所以最终就 match 到了。

example 3:

URL:“/product/iphone14”

Route:

{
  path: '', // empty path
  children: [
    { 
      path: '', // empty path
      children: 'product/:productName'
    }
  ]
} 

猜,配对的到吗?

答案是配对的到。

因为 empty path 的意思是不进行配对,往 children 继续尝试配对。

而最底层的 route.path 配对成功,所以整个 route 就算成功了。

假如最底层配对失败,或者 empty path 后没有 children (没有 children 等同于配对失败了),那整个 Route 就配对失败了。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

首先一个长长的 URL "/product/iphone-14/related-products",接着 for loop routes 进行配对。

配对的规则是,必须把 URL 每一个 segment 给消化掉。

第一个 route 

{ path: '', component: HomeComponent }

route.path 是 empty string, empty path 可以匹配任意 segment,但是它无法消化 segment。然后这个 route 没有 children,所以到这就结束了。

最终 URL segments 没有被消化完,结论是 unmatched,继续尝试下一个 route。

第二个 route

{ path: 'about', component: AboutComponent }

route.path 是 'about',于 URL 配不上,结论是 unmatched,继续尝试下一个 route。

第三个 route

{ 
  path: 'product/:productName',  
  component: ProductComponent,
  children: [
    { path: '', component: ProductDescriptionComponent },              
    { path: 'reviews', component: ProductReviewsComponent },          
    { path: 'related-products', component: RelatedProductsComponent }  
  ]
} 

URL 的前半段 "/product/iphone-14" 可以和 route.path 的 "product/:productName" 配对。

这个配对成功可以消化掉 URL 前 2 个 segments,剩下 "/related-products"。

route.children 有值,所以可以继续拿子层来配对。

子层第一个 

{ path: '', component: ProductDescriptionComponent }

empty 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

Location Strategy

什么是 Location Strategy?

我们来看个例子。

app.config.ts

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection(), 
    // 1. 添加 withHashLocation
    provideRouter(routes, withHashLocation())
  ]
};

加上一个 withHashLocation。

效果

所有的 path 都被放到了 # hash 的后面。

包括 query string 也在 # hash 的后面

Hash location strategy and path location strategy

Angular Routing 有两种 location strategy,一个种是 path,一个种是 hash。

path 是默认的,它长这样

http://localhost:4200/product?page=2

就是很正常的 URL。

hash 长这样

http://localhost:4200/#/product?page=2

在 domain 和 path 之间多了一个 #/

When to use hash location strategy?

如果用户在游览器地址栏键入 path URL -- http://localhost:4200/product,游览器会发送 HTTP 请求到服务端。

服务端会认为游览器想访问 “/product” 的内容。

如果 URL 是 http://localhost:4200/about,则表示游览器想访问 "/about" 的内容。 

好,我们换成 hash URL。

如果用户在游览器地址栏键入 hash URL -- http://localhost:4200/#/product,游览器会发送 HTTP 请求到服务端。

但是,游览器不会把 hash 发送到服务端,因为 URL 中的 hash 本来就不是设计给服务端的,它纯粹用来让游览器 auto scroll to target element 而已。

因此,服务端接收到的 URL 地址是 http://localhost:4200/ (without hash) 而它会认为游览器想访问的是 "/" 内容。

如果 URL 是 http://localhost:4200/#/about 呢?同样的,hash 不会被发送到服务端,服务端依然会认为游览器想访问的是 "/" 内容。

所以,看出 hash ULR 的用意了吗?

它可以让服务端 Routing 变得很简单,因为游览器永远只会发送一个 URL -- http://localhost:4200/,服务端 Routing 只需要处理这一个 URL 就行了。

相反,服务端 Routing 处理 path URL 就比较繁琐,Routing 需要把所有可能的 URL 统一返回 "/" 内容,因为是 SPA 嘛,不管 URL 是什么,都返回同一个 HTML 内容。

结论:虽然 hash URL 有它的好处,但也很不直观,毕竟 hash 不是那样用的。况且服务端 Routing 处理 path URL 也没有那么难,所以现实中很少有人真的会去使用 hash location strategy。

 

Angular Routing 替代游览器导航 の RouterLink 指令

本篇开头有提到,Angular Routing 主要有两项任务。

  1. 像服务端 Routing 那样,将 URL 与组件做配对输出。

  2. 取代游览器的导航功能。

上两 part 我们主要讲的是 URL 与组件配对,这一 part 我们聚焦在取代游览器导航这部分。

当用户点击 <a href> element 时,游览器会更新地址栏并发送 HTTP 请求到服务端,这是游览器的默认导航行为。

那作为 SPA 的 Angular Routing 自然不希望游览器发请求到服务端,因此当 <a> element 被点击时,Angular Routing 会透过 event.preventDefault 方法阻止游览器的导航行为。

然后透过 History API 手动去更新地址栏并触发 Routing 配对,接着创建对应的新组件 (e.g. About 组件),最后替换掉原来的旧组件 (e.g. Home 组件),这样就实现了替代浏览器导航功能。

RouterLink 指令

上述提到的 event.preventDefault 和 History API 是由 Angular Routing 的 RouterLink 指令负责的。

App Template

<a routerLink="/product">go product page</a>

我们需要把 <a href> 换成 <a routerLink>

效果

RouterLink 会用 routerLink 属性值生成 href 属性,并且监听 <a> 点击事件,然后 event.preventDefault,接着用 History API 更新地址栏并触发 Routing 配对。

RouterLink with query param

<a routerLink="/product?page=2">go product page</a>

这样的结果是

RouterLink 误把 query param 当成 path 的一部分了。

我们若想写 query param 需要透过 @Input queryParams。像这样

<a routerLink="/product" [queryParams]="{ page : 2 }">go product page</a>

效果

queryParamsHandling

by default,RouterLink 会覆盖掉当前的 query param。

比如说,当前是 /product?page=2

<a routerLink="/product" [queryParams]="{ orderBy: 'price' }">order by price</a>

点击 <a> 以后会变成 /product?orderBy=price

原本的 page 就没了。

如果我们不希望它被覆盖掉,可以设置 @Input queryParamsHandling="merge"

<a routerLink="/product" [queryParams]="{ orderBy: 'price' }" queryParamsHandling="merge">order by price</a>

顾名思义,merge 就是保留原本的,添加新的进去。

效果

Angular v18.2 以后,我们可以通过全局 config,配置 default queryParamsHandling。

export const appConfig: ApplicationConfig = {
  providers: [
    provideExperimentalZonelessChangeDetection(), 
    provideRouter(
      routes, 
      withRouterConfig({
        defaultQueryParamsHandling: 'merge' // 原本是 replace,这里改成 merge
      })
    )
  ],
};

null / undefined / empty string

null 和 undefined 等于没有 query param,同时它也具有删除 query param 的功能。

[queryParams]="{ page : undefined }" = '/product'
[queryParams]="{ page : null }"      = '/product'
[queryParams]="{ page : '' }"        = '/product?page='

empty string 则是一个正式的 value。

 

Angular Routing 替代游览器导航 の Scrolling

上面我们有提到,游览器除了会依据 URL 发送请求和显示内容之外,它还兼顾了一些自动 scrolling 效果。

  1. 导航到新页面后会 scroll to top

  2. 如果 URL 包含 hash # (a.k.a fragment),那会 scroll to hash 指定的 element (依据 element id)

  3. back / forward 会 scroll to 之前离开页面时的位置 (俗称 restore scroll position)。

Angular Routing 要想替代游览器导航,自然也需要实现这些自动 scrolling。

我们搭个环境来测试看看

App Template

<header>
  <ul>
    <li><a routerLink="/">Home</a></li>
    <li><a routerLink="/about">About</a></li>
    <li><a routerLink="/contact">Contact</a></li>
  </ul>
</header>

<main>
  <router-outlet />
</main>

<footer>Footer</footer>

App Styles

header {
  height: 64px; 
  background-color: pink;

  ul {
    position: fixed;
    top: 0;
    left: 0;
    display: flex;
    list-style: none;
    
    li a {
      display: block;
      padding: 16px;
      font-size: 24px;
    }
  }
  
}

main {
  font-size: 24px;
}


footer {
  height: 64px;
  background-color: lightblue;
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 24px;
}
View Code

app.routes.ts

export const routes: Routes = [
  { path: '', component: HomeComponent },
  { path: 'about', component: AboutComponent },
  { path: 'contact', component: ContactComponent }
];

三个页面,长得一模一样就好

Section Template

<section [style.--bg-color]="'lightyellow'">{{ page() }} section 1</section>
<section [style.--bg-color]="'lightblue'">{{ page() }} section 2</section>
<section [style.--bg-color]="'lightgreen'">{{ page() }} section 3</section>

Section Styles

section {
  min-height: 512px;
  display: flex;
  align-items: center;

  background-color: var(--bg-color);
}
View Code

Section 组件

export class SectionsComponent {
  readonly page = input.required<string>();
}
View Code

效果

可以看到,默认情况下,导航发生后 scrollbar 是不会移动的,并没有自动 scroll to top。

withInMemoryScrolling

我们可以透过 withInMemoryScrolling 函数去配置 scrolling behaviour。

app.config.ts

export const appConfig: ApplicationConfig = {
  providers: [
    provideExperimentalZonelessChangeDetection(), 
    provideRouter(
      routes,
      withInMemoryScrolling({ scrollPositionRestoration: 'top' })
    )
  ]
};

设置 scrollPositionRestoration: 'top'

效果

每一次导航以后都会自动 scroll to top。

好,我们继续看 URL 带有 hash (a.k.a frament) 的例子。

App Template

<header>
  <ul>
    <li><a routerLink="/">Home</a></li>
    <li><a routerLink="/about" fragment="section2">About</a></li>
    <li><a routerLink="/contact" fragment="section3">Contact</a></li>
  </ul>
</header>

在 RouterLink 指令加上 @Input frament。它会创建出 href="/about#section2"

Section Template 加上对应的 id

<section id="section1" [style.--bg-color]="'lightyellow'">{{ page() }} section 1</section>
<section id="section2" [style.--bg-color]="'lightblue'">{{ page() }} section 2</section>
<section id="section3" [style.--bg-color]="'lightgreen'">{{ page() }} section 3</section>

看效果

可以看到,默认情况下,并不会自动 scroll to frament 指定的 element。

我们需要再添加配置 anchorScrolling: 'enabled'

provideRouter(
  routes,
  withInMemoryScrolling({ scrollPositionRestoration: 'top', anchorScrolling: 'enabled' })
)

注:by default,scrollPositionRestoration 和 anchorScrolling 默认值都是 'disabled'

效果

配置了以后就会自动 scroll to frament 指定的 element 了。

好,我们继续看 back / forward 的例子。

当 history back 的时候,scrollTop 没有回到历史位置,而是 scroll to top。

它之所以 scroll to top 是因为我们配置了 scrollPositionRestoration: 'top'。

假如 scrollPositionRestoration 是 'disabled' (by default),那 history back 时,scrollTop 会呆在原位,既不会回到历史位置,也不会 scroll to top。

我们把 scrollPositionRestoration 改成 'enabled'

provideRouter(
  routes,
  withInMemoryScrolling({ scrollPositionRestoration: 'enabled', anchorScrolling: 'enabled' })
)

效果

修改配置后,back / forward 就会回到历史 (离开页面时) 的位置了。

 

301 Redirect (重定向)

游览器和服务端之间还有一个 redirect 的概念。

游览器发请求 URL 地址 /product-a,到服务端后,服务端返回 status 301 加上一个重定向的 URL 地址 /product-b。

游览器接受以后会再发一个请求到那个 URL 地址 /product-b,这就是 redirect (重定向) 的过程。

真实项目例子:

有一个 product detail 页面

这个页面发布好一阵后,突然有天发现 URL 上的产品名字写错了,想改,怎么办?

如果我增加一个新的 URL,显示同一个产品,这将导致 SEO 不友好,因为 2 个 URL 的页面内容一模一样 (俗称 canonical)。

如果我把旧的 URL 删了 (返回 404),这样虽然解决了 canonical 的问题,但与此同时这个页面过往累计的 SEO ranking 也随之消失,亏大了。

所以,正确的做法是 301 redirect,把原本错误的 URL 重定向 (redirect) 到新的 URL 地址上去,这样就可以保留 SEO page ranking 了。

Angular Routing 也有 redirect 概念 (当然它不仅仅用于 SEO,任何重定向目的都可以使用它)。

app.routes.ts

export const routes: Routes = [
  { path: '', component: HomeComponent },

  // 1. 当 URL 是 '/about-us' 时, 重定向到 '/about' 
  { path: 'about-us', redirectTo: 'about' },
  { path: 'about', component: AboutComponent },

  { path: 'contact', component: ContactComponent }
];

效果

从 '/about-us' redirect to '/about'。

我们由浅入深,这里点到为止,下面会教更复杂的 redirect 方式,比如把所有 starts with /product/**/* 的 URL redirect 到 products/**/*。

 

Lazy-loading and preloading route component

Lazy-loading component 这个概念,我们在 Dynamic Component 文章中学过。

Routing 底层原理就是 Dynamic Component,所以它自然也支持 lazy-loading component。

app.routes.ts

import { Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { AboutComponent } from './about/about.component';
import { ContactComponent } from './contact/contact.component';

export const routes: Routes = [
  { path: '', component: HomeComponent },
  { path: 'about', component: AboutComponent },
  { path: 'contact', component: ContactComponent }
];

上面这个是 eager loading,所有组件代码会被 bundle 在一起,访问 home page 时全部都会被下载。

这样不好,用户访问 home page,就应该只加载 Home 组件,没有必要也加载 About 和 Contact 组件的代码。

好,我们要把它改成 lazy-loading。

import { Routes } from '@angular/router';

export const routes: Routes = [
  { path: '', loadComponent: () => import('./home/home.component').then(m => m.HomeComponent) },
  { path: 'about', loadComponent: () => import('./about/about.component').then(m => m.AboutComponent) },
  { path: 'contact', loadComponent: () => import('./contact/contact.component').then(m => m.ContactComponent) }
];

效果

About 和 Contact 的代码被分开打包进不同的 chunk,当访问到 about page 时,about 的 chunk 才被下载。

Preloading

lazy-loading 往往会伴随着 preloading。lazy-loading 的用意是不要太早去 load,但是一直等到需要的时候才去 load,有时候又太迟了。

所以需要一个 preloading,在 lazy-loading 前提下,选择一个 right time 去提前加载 (preload)。

app.config.ts

export const appConfig: ApplicationConfig = {
  providers: [
    provideExperimentalZonelessChangeDetection(), 
    provideRouter(
      routes,
      withPreloading(PreloadAllModules)
    )
  ]
};

withPreloading + PreloadAllModules 是 Angular Routing built-in 的 preloading 机制。

开启后,所有 lazy-loading 的组件,会在第一次导航结束后执行 preload。

比如说,用户访问 home page,一开始 Home, About, Contact 组件都不会加载,Angular 只会渲染出 App 组件。

然后才去加载 Hone 组件,等 Home 组件加载完,createComponent insert to <router-outlet> 之后,导航结束,此时开始 preload About 和 Contact 组件。

Custom PreloadingStrategy

PreloadAllModules 不够灵活,假如我们想自定义逻辑去实现 preloading,该怎么做呢?

preload 组件底层使用的是 RouterConfigLoader,不过这个接口 Angular 没有公开,我们用不了。

我们唯一能做的是提供一个 Custom PreloadingStrategy 替代 PreloadAllModules (它也是一个 PreloadingStrategy)。

PreloadingStrategy 只需要 implement 一个接口 -- preload 方法。

当第一次导航结束后,preload 方法会被调用多次,每一次会得到参数一 route,和参数二 loadComporent function。

route 的作用是识别,loadComponent function 则是去加载组件。

@Injectable({providedIn: 'root'})
export class MyPreloadingStrategy implements PreloadingStrategy {

  preload(route: Route, fn: () => Observable<any>): Observable<any> {

    // 1. 如果是 about page 就 preload
    if(route.path === 'about') {
      return fn().pipe(catchError(() => of(null)));
    }

    // 2. 其它返回 null 表示不要 preload,等用户访问到那个页面才去加载
    return of(null);
  }
} 

export const appConfig: ApplicationConfig = {
  providers: [
    provideExperimentalZonelessChangeDetection(), 
    provideRouter(
      routes,
      withPreloading(MyPreloadingStrategy)
    )
  ]
};

要想更灵活控制的话,我们还可以把 loadComponent 函数先保持起来。

@Injectable({providedIn: 'root'})
export class MyPreloadingStrategy implements PreloadingStrategy {

  readonly routePreloadMap = new Map<Route, () => Observable<any>>();

  preload(route: Route, fn: () => Observable<any>): Observable<any> {
    // 1. 把所有 loadComponent function 保存起来
    this.routePreloadMap.set(route, fn);
    return of(null);
  }
} 

并在未来某个时间点上去加载组件。

export class AppComponent {
  constructor( ){
    const preloadingStrategy = inject(MyPreloadingStrategy);

    window.setTimeout(() => {
      const [_, preload] =  [...preloadingStrategy.routePreloadMap.entries()].find(([route]) => route.path === 'about')!;
      preload();
    }, 5000)
  }
}

 

canActivate の 401 > 302 authorization guard

游览器和服务端之间还有一个 Authorization Guard 的概念。

有些网页是不公开给外人的,只有工作人员可以访问,普通用户无权访问。

比如 admin page -- "/admin"

当用户输入 URL 地址 "/admin" 之后,游览器发请求到服务端,服务端会先查看请求是否包含特定的 authorization cookies,如果没有代表用户没有登入 (非工作人员)。

此时,服务端会返回 status 401 表示没有登入无法访问页面,或者体验更好一点的,返回 status 302 重定向到登入页面 e.g. "/login"。

Angular Routing 也有 Authorization Guard 的概念

 

 

 

 

 

  

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

   

 

 

  

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

  

 

 

 

 

   

 

 

 

  

 

 

Routing 的基本原理

要让客户端负责路由,有几个步骤需要做到:

  1. 路由配对

    不同的路径要对应不同的内容,或者说不同的组件,比如说路径 /about 对应 About 组件,显示 About 的内容,路径 /contact 对应 Contact 组件,显示 Contact 内容。

    使用 Angular Routing,我们只需要提供路径和组件的配对关系就可以了,Angular 会获取游览器的 URL 地址通过配对找出对应的组件,

    然后通过 Dynamic Component 的方式创建组件,接着用 ViewContainerRef.insert 插入到页面里。

  2. 屏蔽游览器的路由机制

    Angular 会监听所有 <a> element 的点击事件,通过 event.preventDefault 方法阻止游览器默认行为。

    游览器默认会做 2 件事件,第一件是更新 URL 地址,第二件是发请求到服务端。

    虽然我们只是希望阻止它发请求,但是 preventDefault 没得选,它同时也会阻止 URL 地址的更新,

    因此,在执行 preventDefault 之后,Angular 还会利用 History API 自行更新 URL 地址。

  3. 监听 URL 地址变更

    无论是 <a> href 还是 history back / forward,Angular 都会监听到。

    它是透过 window popstate 事件监听到 history back / forward 的。

    每一次变更,Angular 就会重新配对然后切换组件。 

大体上就是下图这两套步骤

当然往细看还有很多小概念,比如 lazyload 组件、reuse 组件、multiple outlet、multilayer outlet、authen/auth、scrolling 等等等。

本篇我们主要是深入理解最核心的几个机制/概念/原理,其它较上层/独立/额外的部分,我们留给下一篇。

 

Routing Get Started

我们先来大体感受一下,使用 Angular CLI 创建一个带 routing 的项目。

ng new routing --routing --skip-tests --ssr=false --style=scss

关键是 --routing

多了几样东西

  1. app-routes.ts

    这个是让我们写路径与组件配对逻辑的地方。

  2. app.config.ts

    多了一个 provideRouter,provideRouter 函数的源码在 provide_router.ts

    里面最关键的是 APP_BOOTSTRAP_LISTENER,顾名思义它就是一个启动函数。

    默认情况下,routing 会在 App 组件渲染完成后启动,相关源码在 application_ref.ts 里的 _loadComponent 函数。

    这个 _loadComponent 函数以前我们在逛 NodeInjector 源码就有研究过了,这里不再复述细节。

  3. App Template

    这个 <router-outlet /> 是一个结构型指令,它的职责就是插入对应的组件到这个位置。

    通常我们可以把 App 组件作为 layout,header 和 footer 是每个页面重复的,交给 layout 负责,中间的内容是随着不同路径改变的,这个则交给 <router-outlet /> 负责。

我们创建 3 个组件来表现 Home、About、Contact 页面 (我这里是以企业网站作为例子)。

ng g c home; ng g c about; ng g c contact

接着添加路径与组件配对到 app-routes.ts

import { Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { AboutComponent } from './about/about.component';
import { ContactComponent } from './contact/contact.component';

export const routes: Routes = [
  { path: '', component: HomeComponent },           // 配对 URL: '/'
  { path: 'about', component: AboutComponent },     // 配对 URL: '/about'
  { path: 'contact', component: ContactComponent }, // 配对 URL: '/contact'
];

提醒:path 不需要也不可以有 leading slash。

效果

 

Angular の URL 结构

URL -> 配对 -> 输出组件是本篇要讲解的核心,我们就从 URL 地址结构开始吧。

下图是我们比较熟悉的 URL 结构。

前半段的 scheme host port 我们不管,我们只关注后半段 path query fragment 就好。

Angular Routing URL 比起常见的 URL 结构要复杂得多,它多了 4 个概念:

Segment & Segment Group

长长的 path 在 Angular 会被 split by slash '/' 变成一个一个 Segment。

比如:/products/iphone-14 代表有 2 个 Segment,用 Array 来表示的话长这样 ['products', 'iphone-14']。

[/path] will become [/segment1/segment2/segment3]

而多个 Segment 合在一起则被称为 Segment Group。

简而言之,一个 path 被分解为多个 Segment,多个 Segment 又称作 Segment Group,所以 path 等于 Segment Group。

Segment Parameters (a.k.a Matrix Parameters)

/products;key1=value1/iphone-14;key2=value2

;key1=value1 是 segment1 (products) 的 parameters

;key2=value2 是 segment2 (iphone-14) 的 parameters

?query 是整个 URL 的 parameters (a.k.a query parameters),一个 URL 只能有一个 query,在复杂的项目或许会不够用,所以 Angular 搞了多一个 Segment Parameters 的概念。

每一个 Segment 都可以设置专属于这个 Segment 的 parameters。

Multiple Segment Group (multiple <router-outlet />)

/about(secondary:contact//tertiary:blog)

关键是中间多了  ...(secondary:...//tertiary:...)

它的意义是让我们在 URL 里能表达 multiple path (a.k.a multiple Segment Group),它有什么用呢?

一个 path 配对一个组件,插入一个 <router-outlet />。

multiple path 配对 multiple 组件,插入 multiple <router-outlet />。

看例子

上面设置了 3 个配对,不同的 path,不同的组件,不同的 router-outlet。

这个 URL /about(secondary:contact//tertiary:blog) 会同时配对成功上面 3 个。

配对成功后,3 个组件都会被创建,然后插入到对应的 router-outlet 位置。

效果

Multiple Segment Group with Multilayer (nested <router-outlet />)

我们先了解什么是 Multilayer。

下面是 Single Layer:

访问 /products/iphone-14 效果:

我们把它改成 Multilayer,在 ProductDetail Template 加入一个 <router-outlet />。

这个 outlet 要展现 3 种内容:

  1. product description
  2. product reviews
  3. related products

添加路径与组件配对

效果

<router-outlet /> 输出的组件内又有另一个 <router-outlet />,这就是所谓的 Multilayer。

Multilayer 对项目管理很有帮助,后端 (比如 ASP.NET Core Razor Pages) 方案,一个页面最多只能分成 Layout 和 Body 两层,

而 Angular 这种 Multilayer 不只能实现 Layout 和 Body,Body 还可以继续往下细分,非常灵活。

当 Multiple Segment Group 遇上 Multilayer

好,回到主题,当 Multiple Segment Group 遇到 Multilayer 时,URL 结构会有点不同。

这个是 root layer 有 Multiple Segment Group 的 URL

/about(secondary:contact//tertiary:blog)

这个是 child layer 有 Multiple Segment Group 的 URL

/products/iphone-14/(reviews//secondary:related-products)

可以看到 root layer 和 child layer 的写法是不同的

child layer 一开始就括弧了,primary path 和 secondary path 通通在括弧里面,而 root layer 则一开始是 primary path 接着才是括弧,primary path 没有在括弧里面。

root 和 child 同时有 Multiple Segment Group 的 URL

/about/(about-a1//secondary:about-a2)(secondary:contact//tertiary:blog)

grandchild 有 Multiple Segment Group 的 URL

/about/(about-a1/(about-b1//secondary:about-b2)//secondary:about-a2)

child 和 grandchild 的写法是一样的,primary path 和 secondary path 通通在括弧里面。

Multilayer + Multiple Segment Group 完整例子

/about/(about-a1/(about-b1//secondary:about-b2)//secondary:about-a2)(secondary:contact//tertiary:blog)

URL 有点吓人。它有 3 个 layer,每一个都有 Multiple Segment Group。 

配对

export const routes: Routes = [
  {
    outlet: 'primary',
    path: 'about',
    component: AboutComponent,
    children: [
      {
        outlet: 'primary',
        path: 'about-a1',
        component: AboutA1Component,
        children: [
          {
            outlet: 'primary',
            path: 'about-b1',
            component: AboutB1Component,
          },
          {
            outlet: 'secondary',
            path: 'about-b2',
            component: AboutB2Component,
          },
        ],
      },
      { outlet: 'secondary', path: 'about-a2', component: AboutA2Component },
    ],
  },
  { outlet: 'secondary', path: 'contact', component: ContactComponent },
  { outlet: 'tertiary', path: 'blog', component: BlogComponent },
];

App Template

<header>header</header>

<router-outlet name="primary" />   <!--这里会插入 About 组件-->
<router-outlet name="secondary" /> <!--这里会插入 Contact 组件-->
<router-outlet name="tertiary" />  <!--这里会插入 Blog 组件-->

<footer>footer</footer>

About Template

<p>about works!</p>
<router-outlet name="primary" />   <!--这里会插入 AboutA1 组件-->
<router-outlet name="secondary" /> <!--这里会插入 AboutA2 组件-->

AboutA1 Template

<p>about-a1 works!</p>
<router-outlet name="primary" />   <!--这里会插入 AboutB1 组件-->
<router-outlet name="secondary" /> <!--这里会插入 AboutB2 组件-->

最终一个 URL 配对出 7 个组件

注:上面只有 About 有 Multilayer,其实 Blog 和 Contact 也是可以有 Multilayer 的。我没有加进去只是因为它们的原理和 About 是一样的,所以没有必要作为例子。

总结

Angular URL 结构与众不同的地方主要是在 path 的部分,?query 和 #fragment 和常见的 URL 是一样的。

path 的部分主要有 4 个概念:

  1. Segment 和 Segment Group 概念

  2. Segment Parameters (a.k.a Matrix Parameters) 概念

  3. Multiple Segment Group 的概念

  4. Multilayer + Multiple Segment Group 的概念

由于本篇主要是讲解原理,所以我们不需要过多的在意具体的实现代码,我们关注它的原理就好,下一篇我会给更多具体例子。

 

UrlTree

Angular 团队非常喜欢树,之前的几篇文章中,我们就学习过好几棵树,比如 NodeInjector Tree、Logical View Tree、DOM Tree。

Angular Routing 又多出了好几棵树 😧。

由于 Angular 的 URL 结构异常复杂,如果只使用 string 会很难操作,于是 Angular 搞了一个 UrlTree 的概念,简单说就是把 URL string 变成 URL 树形结构对象。

class UrlTree

UrlTree 长这样,源码在 url_tree.ts

上一 part 我们有提到,一个 URL 包含 /path ?query #fragment 

UrlTree 的 queryParams 属性对应 ?query

fragment 属性对应 #fragment 

/path 则被分割成多个 Segment,而多个 Segment 又称为 Segment Group,它就对应了 root 属性。

class UrlSegmentGroup 源码也是在 url_tree.ts

segments 属性装的是从 path 分割出来的 Segment List。

children 用于表达 Multilayer 和 Multiple Segment Group,它是一个 key value pair 类型,key 指的是 Segment Group 的名字,比如 primary、secondary、tertiary。

class Segment 源码也是在 url_tree.ts

属性 path 就是被分割出来的 string value,parameters 则是属于这个 Segment 的 Parameters (Segment Parameters or Matrix Parameters)。

Example for UrlTree

我们来看一些例子,感受一下:

query parameters & frament

URL 长这样

/about?key1=value%201#target-id

UrlTree 长这样

属性 fragment 对应 #fragment

属性 queryParams 对应 ?query

URL string 是 encoded 的,比如空格是 %20,而 UrlTree 是 decoded 的,%20 会转换回空格,所以我们在操控 UrlTree 时不需要去顾虑 encode/decode 的问题。

Root Segment Group & empty path

URL 长这样

/

UrlTree 长这样

无论如何,UrlTree 一定会有 Root Segment Group,哪怕是 empty path。

segments 是空的,children 也是空的。

Single & Multiple Segment Group

URL 长这样

/about

UrlTree 长这样

这个 UrlTree 有几个反直觉的地方:

  1. 为什么是两个 Segment Group,而不是一个?
  2. 为什么 Root Segment Group 的 segments 是空 array?
  3. 为什么 about 被放入了 children primary Segment Group 里,而不是 Root Segment Group?

感觉 Root Segment Group 在这里根本是个多余的丫,它完全没有负责任何东西,资料都记入在 children primary Segment Group 里。

原因是这样的,试想想如果 URL 是一个 Multiple Segment Group,比如 

/about(secondary:contact//tertiary:blog)

它的 UrlTree 长这样

此时 Root Segment Group segments 是空 Array 就变得合理了。

为了统一结构,Root Segment Group 是不用于配对 path 的,它的 segments 一定是一个空 Array,path 只会交给 children。

绝大部分的情况下,UrlTree 里面最少有 2 层 -- Root and Children。(empty path 是例外,它不会有 children)

另外一点,如果 URL 是 Single Segment Group,而且没有表明 Segment Group Name,那它默认名是 'primary'。

比如上面的 /about

那如果 URL 有表明 Segment Group Name,那就依据 Segment Group Name

比如 /(secondary:about)

Segment with Segment Parameters

URL 长这样

/prodcuts;key1=value%201/iphone-14;key2=value%202

UrlTree 长这样

Segment Parameters 被记入到各自的 Segment 中。

Multilayer + Single Segment Group

URL 其实没有 Multilayer + Single Segment Group 的概念。我们看一个例子

URL 长这样

/products/iphone-14/reviews

配对长这个

export const routes: Routes = [
  {
    path: 'products/iphone-14',
    component: ProductDetailComponent,
    children: [{ path: 'reviews', component: ProductReviewComponent }],
  },
];

效果

有 2 层 router-oulet,所以它是 Multilayer,但是它没有 Multiple Segment Group。

UrlTree 长这样

只有 2 层而已。

不管是 Single Layer 还是 Multilayer,只要不是 Multiple Segment Group,那它们的 URL 结构都是一样的,不同的地方是配对设置和 router-outlet 指令。

Multilayer + Multiple Segment Group

URL 长这样

/about/(about-a1//secondary:about-a2)

UrlTree 长这样

第一层 Root 和第二层 primary Segment Group 

第三层 primary 和 secondary Segment Group

只有 Multilayer + Multiple Segment Group 才会让 UrlTree 突破 2 层。

to UrlTree and from UrlTree

通过 Router.parseUrl 可以把一个 URL string 转换成 UrlTree 对象。

export class AppComponent {
  constructor() {
    const router = inject(Router);
    const urlTree = router.parseUrl('/about(secondary:contact)');
    console.log(urlTree.root.children['secondary'].segments[0].path); // 'contact'
  }
}

Router 还有很多功能,本篇会一一介绍。

只要执行 UrlTree.toString 方法,就可以把 UrlTree 对象转换成 URL string。

export class AppComponent {
  constructor() {
    const router = inject(Router);
    const urlTree = router.parseUrl('/about(secondary:contact)');
    console.log(urlTree.toString()); // '/about(secondary:contact)'
  }
}

build UrlTree by command

用 Router.parseUrl 生成 UrlTree 不是一个好主意,更方便的方式是通过 command。

export class AppComponent {
  constructor() {
    const router = inject(Router);
    const urlTree = router.createUrlTree([
      { outlets: { primary: ['about'], secondary: ['contact'] } },
    ]);
    console.log(urlTree.toString() === '/about(secondary:contact)'); // true
  }
}

使用 Router.createUrlTree 方法可以通过 command 的方式创建 UrlTree。

command 是一个 string Array,它有一些潜规则,我们看看各种例子:

export class AppComponent {
  constructor() {
    const router = inject(Router);

    router.createUrlTree(['about']).toString(); // '/about' 

    // Query Parameters
    router.createUrlTree(['about'], { queryParams: { key1: 'value1' } }).toString(); // '/about?key1=value1' 注:Angular 会提我们 encode
    
    // Fragment
    router.createUrlTree(['about'], { fragment: 'target-id' }).toString(); // '/about#target-id
 
    router.createUrlTree(['products', 'iphone-14']).toString(); // '/products/iphone-14' 
    router.createUrlTree(['products/iphone-14']).toString();    // '/products/iphone-14' 和上一个是一样的,不过推荐统一使用上一个就好

    // Segment Parameters (a.k.a Matrix Parameters)
    router.createUrlTree(['products', { key1: 'value1' }, 'iphone-14']).toString(); // '/products;key1=value1/iphone-14'

    // Multiple Segment Group
    router.createUrlTree([{ outlets: { primary: ['about'], secondary: ['contact'], tertiary: ['blog'] } }]); // '/about(secondary:contact//tertiary:blog)'

    // Multilayer + Multiple Segment Group
    router.createUrlTree(['products', { outlets: { primary: ['iphone-14'], secondary: ['contact'], tertiary: ['blog'] } }]); // '/products/(iphone-14//secondary:contact//tertiary:blog)'
  }
}

好,UrlTree 就介绍到这里。

 

Route

URL 讲完了,下一个是配对。

Route 又名 Route Config (就是我们上面一直提到的配对设置),它的主要作用是配置不同的 URL 要做出什么对应的 action。

比如说,当 URL 是 /about 时,创建 About 组件然后插入到指定 router-outlet。

又比如,当 URL 是 /contact-us 时,redirect 到 URL /contact。

除了配置 URL 与 action,Route 还可以配置其它的小功能,不过这篇我们 focus 在输出组件和 redirect 就好,其它的留给下一篇。

Routes

Routes 就是 Route Array,长这样

import { Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { AboutComponent } from './about/about.component';
import { ContactComponent } from './contact/contact.component';

export const routes: Routes = [
  { path: '', component: HomeComponent },           // 当 URL 是 / 时,router-outlet 输出 Home 组件
  { path: 'about', component: AboutComponent },     // 当 URL 是 /about 时,router-outlet 输出 About 组件
  { path: 'contact', component: ContactComponent }, // 当 URL 是 /contact 时,router-outlet 输出 Contact 组件
  { path: 'about-us', redirectTo: 'about' }         // 当 URL 是 /about-us 时,redirect 到 URL /about
];

URL 的配对过程是 for loop Routes,一个一个 Route 与 URL 进行配对,配对成功就采取 Route 的 action,然后停止配对,配对失败则继续尝试下一个。

Route.matcher

Route.path 是一个上层配置,配对的底层原理其实依靠的是 Route.matcher 方法。只是 Angular 内置了一个 matcher 作为默认,因此我们在日常使用中才可以通过上层的 Route.path 表达配对关系。

既然我们要搞清楚原理,那自然是要从 matcher 方法学习起咯。

一个 Multilayer 的例子

export const routes: Routes = [
  {
    path: 'products/iphone-14', // 配对 /products/iphone-14
    component: ProductDetailComponent, 
    children: [
      { path: '', component: ProductDescriptionComponent },             // 配对 /products/iphone-14
      { path: 'reviews', component: ProductReviewComponent },           // 配对 /products/iphone-14/reviews
      { path: 'related-products', component: RelatedProductComponent }, // 配对 /products/iphone-14/related-products
    ],
  },
];

我们把它改成用 matcher 方法来实现。

matcher 方法长这样

export const routes: Routes = [
  {
    matcher: (segments, group, route) => {
      return null;
    },
    component: ProductDetailComponent,
  },
];

它有三个参数:

  1. segments

    在配对的过程中,我们比对的不是 URL string 而是 UrlTree 对象。

    这个 segments 指的就是 UrlTree 里的 Sement Group 里的 segments。

  2. group

    group 就是 Segment Group。

    我们知道 Segment Group 有可能会有 Multiple 和 Multilayer 概念,这里 Angular 会替我们选好,

    比如说,UrlTree 的第一层 Segment Group 是 root Segment Group,它不用于配对,它的 segments 一定是空 Array,所以 Angular 会直接跳过它,拿第二层的 Segment Group 来配对。

    再比如,当我们设置 Route.outlet: 'secondary',那 Angular 就会拿 UrlTree.root.children['secondary'] 的 Segment Group 来调用 matcher 方法。

    segments 就是从 Segment Group 拿出来的而已。

  3. route

    route 就是当前 Route 的 clone 版本。

return null 表示配对失败,Angular 会尝试下一个 Route。

那如果配对成功的话,需要这样返回

  {
    matcher: (segments, _group, _route) => {
      // URL: /products/iphone-14/reviews
      if (segments.slice(0, 2).join('/') === 'products/iphone-14') {
        return {
          consumed: segments.slice(0, 2),
        };
      }
      return null;
    },
    component: ProductDetailComponent,
  },
];

只要头两个 Segment 分别为 'products' 和 'iphone-14' 就算配对成功。

返回一个带有 consumed 属性的对象。

consumed 的意思是 “已消耗",怎么理解?

例子中的 URL 是 '/products/iphone-14/reviews',split by slash 后会有 3 个 Segment。

而这个 matcher 方法只检查了头两个 Segment 就配对成功了,那第三个 Segment 'reviews' 又谁来处理呢?

答案是交给 children Routes。matcher 方法需要表达自己只消耗了头两个 Segment,然后 Angular 会把第三个 Segment 交给 children Routes 去处理。

我们加上 children Routes:

export const routes: Routes = [
  {
    // URL: /products/iphone-14/reviews
    matcher: (segments, _group, _route) => {
      if (segments.slice(0, 2).join('/') === 'products/iphone-14') {
        return {
          consumed: segments.slice(0, 2),
        };
      }
      return null;
    },
    component: ProductDetailComponent,
    children: [
      {
        matcher: (segments, _group, _route) => {
          console.log(segments.length === 1); // true
          console.log(segments[0].path === 'reviews'); // true

          if (segments[0].path === 'reviews') {
            return {
              consumed: segments,
            };
          }
          return null;
        },
        component: ProductReviewComponent,
      },
    ],
  },
];

children 的 matcher 方法参数 segments 就只剩下一个 Segment,因为上一层已经消耗了 2 个 Segment。

消耗不完 Segment 怎么办?

上面例子中有 3 个 Segment ['products', 'iphone-14', 'reviews'],如果我们只有一个 Route,它 consumed 了头两个 Segment,但没有 children Routes 了,

那剩下的第三个 Segment 会怎样?答案是直接配对失败,Angular 会尝试下一个 Route,又从 3 个 Segment 开始执行 matcher 方法。

过早消耗完 Segment 怎么办?

假设只有 2 个 Segment ['products', 'iphone-14'],Route consumed 了两个 Segment,但它还有 children Routes 会怎么样?

答案是上层的 Route 配对成功可以执行 action(比如输出组件),而 children Routes 配对失败,不执行 child Route 的 action。

消耗不完 vs 过早消耗完

消耗不完会导致上层原本配对成功的 Route 也算失败,而过早消耗完则会保留上层原本配对成功的 Route。

好,Route 我们就先学到这里。

 

ActivatedRouteSnapshot、RouterStateSnapshot、ActivatedRoute、RouterState

ActivatedRouteSnapshot、RouterStateSnapshot、ActivatedRoute、RouterState 息息相关,而且长的很像,基本上掌握一个就等于掌握了全部。

What is ActivatedRouteSnapshot?

什么是 ActivatedRouteSnapshot? 我们先忽略掉结尾的 snapshot,ActivatedRoute 顾名思义就是 "已激活" 的 Route。

这是 Routes

export const routes: Routes = [
  { path: '', component: HomeComponent },           // 当 URL 是 / 时,App Template 的 router-outlet 输出 Home 组件
  { path: 'about', component: AboutComponent },     // 当 URL 是 /about 时,App Template 的 router-outlet 输出 About 组件
  { path: 'contact', component: ContactComponent }, // 当 URL 是 /contact 时,App Template 的 router-outlet 输出 Contact 组件
  {
    path: 'products/iphone-14',                     // 当 URL 是 /products/iphone-14 时,App Template 的 router-outlet 输出 ProductDetail 组件
    component: ProductDetailComponent,
    children: [
      // 当 URL 是 /products/iphone-14 时,ProductDetail Template 的 router-outlet 输出 ProductDescription 组件
      { path: '', component: ProductDescriptionComponent },             

      // 当 URL 是 /products/iphone-14/reviews 时,ProductDetail Template 的 router-outlet 输出 ProductReview 组件
      { path: 'reviews', component: ProductReviewComponent },           

      // 当 URL 是 /products/iphone-14/related-products 时,ProductDetail Template 的 router-outlet 输出 RelatedProduct 组件
      { path: 'related-products', component: RelatedProductComponent }  
    ]
  },
];

每一次 URL 变更的时候,Angular Routing 都会进行一轮 UrlTree vs Routes 配对,配对成功的 Route 会拿来生成 ActivatedRouteSnapshot,表示这个 Route 被 "激活" 了。

比如

ProductDetail Route 和 /products/iphone-14 Segment Group 配对成功,生成了 ProductDetail ActivatedRouteSnapshot。

RelatedProduct Route 和 /related-products Segment Group 配对成功,生成了 RelatedProduct ActivatedRouteSnapshot。

Route 是树形结构,ActivatedRouteSnapshot 自然也是树形结构。yeah🎉我们又学了多一棵树。

ActivatedRouteSnapshot 有啥用

ActivatedRouteSnapshot 保存了 Route 和 UrlTree 的资料,比如 Query Paramteres、Segment Parameters 等等。

至于为什么它是 snapshot 呢?下面讲解 ActivatedRoute 时会揭晓。

 

 

 

 

Root ActivatedRouteSnapshot

UrlTree 有一个 Root Segment Group,它很特别,它不被用于配对 path,它的 segments 总是空 Array,它就是一个根。

ActivatedRouteSnapshot 也有这么一个特别的根。

export const routes: Routes = [
  { path: 'about', component: AboutComponent }
];
export class AboutComponent {
  constructor() {
    const activateRoute = inject(ActivatedRoute);
    console.log(activateRoute.parent!.routeConfig); // null
    console.log(activateRoute.parent!.component === AppComponent); // true
    console.log(activateRoute.parent!.parent); // null
  }
}

About ActivatedRoute 并不是 Root ActivatedRoute。

Root ActivatedRoute 是没有 routeConfig 的,而且它对应的 component 是 Root Component 也就是 App 组件。是不是也很特别?

 

 

 

What is ActivatedRouteSnapshot?

顾名思义 ActivatedRouteSnapshot 就是 ActivatedRoute 的快照版本,那为什么要搞一个快照呢?

Route Reuse

我们需要理解一个新概念 Route Reuse。

服务端的 Routing 行为是:每当更换 URL 地址,整个页面都会销毁重新渲染,JavaScript state 全部清空。

Angular Routing 默认行为是:每当更换 URL 地址,部分组件会被销毁创建新的,部分组件不会被销毁,它们会被 reuse。

举例:

URL 从 

/products/iphone-14/related-products

切换到

/products/iphone-14/reviews

ActivatedRoute 从 ProductDetail -> RelatedProduct

切换到 ProductDetail -> ProductReview

Angular 会 reuse ProductDetail ActivatedRoute 和 ProductDetail 组件,

会销毁 RelatedProduct,并创建新的 ProductReview ActivatedRoute 和组件。

这就是 Route Reuse 概念。

RouteReuseStrategy

哪一些 ActivatedRoute 会被 reuse,哪一些要销毁创建新的,取决于 RouteReuseStrategy Service Provider。

RouteReuseStrategy 源码在 route_reuse_strategy.ts

RouteReuseStratefy 源码也在 route_reuse_strategy.ts

好,Route Reuse 就介绍到这里,下一篇我会给出更多 Route Reuse 的例子。

我们回到 what is ActivatedRouteSnapshot?

ActivatedRouteSnapshot 是 ActivatedRoute 的快照资料,ActivatedRoute 有可能被 reuse,所以它的资料是通过 RxJS 流曝露出去的,

使用者需要监听 RxJS 流才能获取到资料。ActivatedRouteSnapshot 是快照资料,它没有 reuse 概念,每一次都没有被重新创建,所以

 

ActivatedRoute 有可能 reuse,而 ActivatedRouteSnapshot 则绝对不会,每一次 ActivatedRouteSnapshot 都会被创建新的。 

而且 Angular Routing 在处理导航过程中其实是先创建 ActivatedRouteSnapshot 后才创建或 reuse ActivatedRoute 的。

ActivatedRoute 有啥用?

ActivatedRoute 的主要用途是让组件获取到和 Route 相关的资料。

举例:

/products/iphone-14;key1=value1/reviews

它会配对到 2 个 Route。

;key1=value1 这个 Segment Parameters 属于第二个 Segment,而第二个 Segment 被 ProductDetail Route 消耗了,于是这个 Segment Parameters 将被记入到它生成的 ProductDetail ActivatedRoute 中。

我们在 ProductDetail 组件里可以获取到这个 Segment Parameters。

ProductDetail 组件

export class ProductDetailComponent {
  constructor() {
    const activatedRoute = inject(ActivatedRoute);
    console.log('Segment Parameters', activatedRoute.snapshot.params); // { key1: 'value1' }
  }
}

三个重点:

  1. ActivatedRoute 是通过 DI 注入的

  2. 不同组件注入的 ActivatedRoute 是不同的,

    比如 ProductDetail 组件注入的是 ProductDetail ActivatedRoute,

    RelatedProduct 组件注入的是 RelatedProduct ActivatedRoute。

    这个是依据 Route.component 的配置。

    我们再看多两个极端的例子

    例子一:

    这是 Routes

    export const routes: Routes = [
      { path: 'about', component: AboutComponent }, 
    ];

    在 About Template 中有一个 AboutContent 组件

    问:About 组件和 AboutContent 组件 inject ActivatedRoute 是同一个吗?

    答:是同一个。

    其原理大致是这样,About Route 配对成功后会创建 About ActivatedRoute,然后会动态创建 About 组件。

    动态创建组件时可以传入一个 Injector (不熟悉 Dynamic Component 的朋友请看这篇)。

    Angular 会传入一个 OutletInjector 

    OutletInjector 会对 inject(ActivatedRoute) 做特别处理。

    About 组件通过这个 OutletInjector 就会 inject 到 About ActivatedRoute。

    AboutContent 组件 inject ActivatedRoute 时,AboutContent NodeInjetor 会往上找,最终就找到了 About ActivatedRoute。(不熟悉 NodeInjector 的朋友请看这篇)

    例子二:

    添加一个 children Routes

    export const routes: Routes = [
      {
        path: 'about',
        component: AboutComponent,
        children: [{ path: '', component: AboutContentComponent }],
      },
    ];

    在 About Template 中添加一个 router-outlet

    AboutContent 组件会被创建 2 次,一次来自 <app-about-content /> 一次来自 <router-outlet />。

    问:这两次 AboutContent inject 的 ActivatedRoute 是同一个吗?

    答:不是同一个,<app-about-content /> inject 到的是 About ActivatedRoute,而 <router-outlet /> 输出的 AboutContent 组件 inject 到的是 AboutContent ActivatedRoute。

    因为 <router-outlet /> 输出的组件是动态创建的,它有 OutletInjector 这就如同 About 组件 inject 的是 About ActivatedRoute 那样。

  3. snapshot 是资料的快照,所以可以直接读取,如果不使用 snapshot,资料将以 RxJS 流的方式获取。

    export class ProductDetailComponent {
      constructor() {
        const activatedRoute = inject(ActivatedRoute);
        activatedRoute.params.subscribe((params) => console.log(params)); // { key1: 'value1' }
      }
    }

    当 URL 从 /products/iphone-14;key1=value1/reviews 切换到 /products/iphone-14;key1=value2/reviews,

    只有 value1 变成了 value2,此时所有的 ActivatedRoute 都会被 reuse,组件也不会被销毁,我们唯一能感知和获取到最新 params 的方法就是通过 subscribe ActivatedRoute.params 流。

    Route Reuse 概念这篇不会深入,下一篇才会详细讲解,这篇我们只要知道有这个概念就可以了。

此外,ActivatedRoute 也可以获取到 Query Parameters 和 Fragment

const activatedRoute = inject(ActivatedRoute);
console.log(activatedRoute.snapshot.queryParams); // { key1: 'value1' }
console.log(activatedRoute.snapshot.fragment); // 'target-id'

Segment Paramters 有专属于某个 Segment 的概念,Query Parameters 和 Fragment 就没有,所以不管哪一个 ActivatedRoute 获取到的 Query Parameters 和 Fragment 都是一样的。

此外,ActivatedRoute.routeConfig 可以获取到对应的 Route 对象。

export const routes: Routes = [
  { path: 'about', component: AboutComponent }
];
export class AboutComponent {
  constructor() {
    const activateRoute = inject(ActivatedRoute);
    console.log(activateRoute.routeConfig); // { path: 'about', component: AboutComponent }
  }
}

 

What is RouterState?

RouterState 是一棵 ActivatedRoute Tree,它维护着所有 ActivatedRoute 的 parent child 关系。

我们通过 Router.routerState 可以获取到当前 URL 配对后生成的 ActivatedRoute Tree。

export class AppComponent {
  constructor() {
    const router = inject(Router);
    router.events.subscribe((e) => {
      if (e.type === EventType.NavigationEnd) {
        console.log(router.routerState);
      }
    });
  }
}

它的结构是这样的

关键是 _root 属性,它是一个 TreeNode 类型。

TreeNode 有 2 个属性,一个是 value,一个是 children。

value 装的是 ActivatedRoute 对象

children 装的是子层 TreeNode。

日常中我们很少会直接使用 RouterState,我们通常是透过 ActivatedRoute 间接使用到 RouterState。

每一个 ActivatedRoute 都有一个私有属性 _routerState 指向 RouterState 对象,

ActivatedRoute.parent 可以获取到父 ActivatedRoute。

ActivatedRoute.children 可以获取到子 ActivatedRoute Array。

parent 和 children 是 getter 方法,内部就是依靠 _routerState 的树形结构完成查找的。

What is RouterStateSnapshot?

顾名思义,RouterState 是 ActivatedRoute Tree,那 RouterStateSnapshot 自然是 ActivatedRouteSnapshot Tree 咯。

通过 RouterState.snapshot 就可以获取到 RouterStateSnapshot 对象了。

好,ActivatedRoute 就先介绍到这里。

 

UrlTree、Route Tree、ActivatedRoute Tree、OutletContext Tree

这 4 棵树的关系就有点像 NodeInjector Tree、Logical View Tree、DOM Tree 那样,长得像但又不完全一样,却又息息相关。

总之,Angular Team 就喜欢搞这种让人傻傻分不清楚的东西出来😡。

下面我把这 4 棵树的结构特性列出来,帮助大家做区分。

UrlTree

UrlTree 是 URL string 的树形版本,这个树形结构是依据 URL string 解析出来的,它跟 Route、ActivatedRoute、Outlet 都没有关系。

只要给 Angular 一个 URL string 它就可以生成出 UrlTree 对象。

Route Tree

Route Tree 指的是我们配置的 Routes Array。

Route 的树形结构完全是由我们自己掌控的。

export const routes: Routes = [
  // 配对 /products/iphone-14
  {
    path: 'products/iphone-14',
    component: ProductDetailComponent,
  },
  // 同样配对 /products/iphone-14
  {
    path: 'products',
    component: ProductComponent,
    children: [
      {
        path: 'iphone-14',
        component: ProductDetailComponent,
      },
    ],
  },
];

上面 2 个 Route 都能配对 URL ‘products/iphone-14’,但是这 2 个 Route 的结构是不一样的,第一个 Route 没有 children,第二个却有 children。

所以说,虽然 Route Tree 是用于配对 UrlTree 的,但是 Route Tree 的树形结构是不受限于 UrlTree 结构的。

ActivatedRoute Tree

ActivatedRoute Tree 是配对成功的 Route Tree,所以它的树形结构不会脱离 Route Tree。

OutletContext Tree

通过 inject ChildrenOutletContexts,我们可以获取到 OutletContext Tree。

export class AppComponent {
  constructor() {
    const router = inject(Router);
    const childrenOutletContexts = inject(ChildrenOutletContexts);
    router.events.subscribe((e) => {
      if (e.type === EventType.NavigationEnd) {
        console.log('childrenOutletContexts', childrenOutletContexts);
      }
    });
  }
}

我们看个例子

URL 长这样

/products/iphone-14/reviews

Routes 长这样

export const routes: Routes = [
  {
    path: 'products/iphone-14',
    component: ProductDetailComponent,
    children: [{ path: 'reviews', component: ProductReviewComponent }],
  },
];

配对后会产生 2 个 ActivatedRoute。

ChildrenOutletContexts 长这样

Child OutletContext

OutletContext 的职责是把 ActivatedRoute 关联到它对应的 <router-outlet />。

OutletContext.outlet 就是 <router-outlet /> 指令的实例 (上图是 null 只因为我偷懒没有放 <router-outlet /> 指令在 Template😅)。

通常 OutletContext 的数量和 ActivatedRoute 的数量是一样的,树形结构也是一样的。

唯一的不同是 OutletContext Tree 是没有 Root OutletContext 的,它开头就是 ChildrenOutletContexts.contexts 一个 Map<string, OutletContext> 类型。

所以,没有任何 OutletContext 会关联到 Root ActivatedRoute,这也挺合理的,Root ActivatedRoute.component 是 App 组件,它自然不可能关联上任何 <router-outlet />。 

有一种情况会导致 OutletContext 和 ActivatedRoute 的数量不一样,树形结构也不一样。那就是当 Route 没有设置 component。

同一个例子,我们把 ProductDetail Route 的 component 属性注释掉。

export const routes: Routes = [
  {
    path: 'products/iphone-14',
    // component: ProductDetailComponent, // 注释掉 component 属性
    children: [{ path: 'reviews', component: ProductReviewComponent }],
  },
];

配对后依然会产生 2 个 ActivatedRoute,但只会产生一个 OutletContext,这个 OutletContext.route 指向 ProductReview ActivatedRoute。

OutletContext Tree 长这样

最后 ProductReview 组件会被插入到 App Template 的 <router-outlet />。

总结:OutletContext Tree 是依据 ActivatedRoute Tree 一对一生成的,只是它会过滤掉了没有 component 的 ActivatedRoute。

 

与 Routing 相关的 Internal Service Providers

Angular 内部使用了许多和 Routing 相关的 Services。我们要深入理解 Routing 最好把它们过一遍。

PlatformLocation

class PlatformLocation 的源码在 platform_location.ts

我们的 platform 是 Browser,所以看 class BrowserPlatformLocation,它的源码也是在 platform_location.ts

BrowserPlatformLocation 是 Angular 对 window.location 和 window.history 的封装。

它的所有方法内部都是调用了 window.location 或 window.history。

比如:

OnPopState 方法内部其实是监听了 window popstate 事件

href, protocal, hostname, port, pathname, search, hash 也只是调用 window.location 而已。

还有各种 History API 操作也是一样的

所以在 Angular 内部,它们不直接操作 window.location 和 window.history,取而代之的是通过 PlatformLocation 间接操作。

LocationStrategy

LocationStrategy 是对 PlatformLocation 的又一层封装。

PlatformLocation 封装的目的是让 Angular 与环境无关 (Angular 不局限于 Browser 环境),

那为什么又要 wrap 多一层 LocationStrategy 呢?

我们先了解一下什么是 hash location。

Routes

export const routes: Routes = [
  {
    path: 'products/iphone-14',
    component: ProductDetailComponent,
    children: [{ path: 'reviews', component: ProductReviewComponent }],
  },
];

app.config.ts

export const appConfig: ApplicationConfig = {
  providers: [provideRouter(routes, withHashLocation())],
};

添加了一个 withHashLocation()

效果

下面这个是默认的 URL,又称为 path 版本 URL

http://localhost:4200/products/iphone-14

下面这个则是 hash 版本 URL

http://localhost:4200/#/products/iphone-14

关键是在中间多了个 /#/ 

它的用意是在 refresh browser 的时候,服务端是否需要处理多种不同路径还是只需要处理一种路径。

在 refresh browser 的时候,游览器一定会发请求到服务端。

如果使用 path URL,路径可能是

  1. http://localhost:4200/products/iphone-14

  2. http://localhost:4200/about

  3. http://localhost:4200/contact

  4. 只要是前端能匹配的路径都有可能在 refresh browser 时被发送到服务端作为请求

服务端需要处理所有的路径,通通返回同样的 index.html 内容。

如果使用 hash URL 情况就不同了

  1. http://localhost:4200/#/products/iphone-14

  2. http://localhost:4200/#/about

  3. http://localhost:4200/#/contact

  4. 只要是前端能匹配的路径都有可能在 refresh browser 时被发送到服务端作为请求

由于 # 后面的路径是不会被发送到服务端的 (这个是 browser 的行为),所以上面所有请求路径,通通会变成 http://localhost:4200/。

那服务端就只需要处理这一个路径就够了。

Angular 有 2 种 LocationStrategy:

  1. PathLocationStrategy (源码在 location_strategy.ts)

    它负责生成 path 版本 URL,比如 http://localhost:4200/products/iphone-14

  2. HashLocatonStrategy (源码在 hash_location_strategy.ts)

    它负责生成 hash 版本 URL,比如 http://localhost:4200/#/products/iphone-14

默认 LocationStrategy 是 PathLocationStrategy 

通过 withHashLocation 函数可以把它换成 HashLocationStrategy

在 Angular 内部,它们不直接使用 PlatformLocation,取而代之的是通过 LocationStrategy 间接调用 PlatformLocation。

Location

看名字就猜到了,Location 是对 LocationStrategy 的又又一层封装。 Angular Team 真的是好喜欢一层一层啊😔

Location 主要是多了一些事件管理。Location 源码在 location.ts

一个典型的观察者模式。

除了 pushState,replaceState 这些前进的 History API,Location 也可以监听到 popstate 事件。

在初始化时 Location 会通过 LocationStrategy 监听 window popstate 事件。

接着通过 Location.subscribe 就可以监听到 _subject 发布的事件(也就是 window popstate 事件)

通过 onUrlChange 可以同时监听 push, replace, popstate 事件。

题外话:Location, PlatformLocation, LocationStrategy, PathLocationStrategy, HashLocationStrategy 这些 Service Provider 并不是 export 在 @angular/router 哦,

它们 export 在 @angular/common。

import { Location, PlatformLocation, LocationStrategy, PathLocationStrategy, HashLocationStrategy } from '@angular/common';

UrlSerializer

UrlSerializer 负责把 URL string 转换成 UrlTree 和把 UrlTree 转换成 URL string。它的源码在 url_tree.ts

Router.parseUrl 内部就是调用了 UrlSerializer.parse 方法

UrlTree.toString 方法内部就是调用了 UrlSerializer.serialize 方法

StateManager

StateManager 负责维护当前的 UrlTree 和 RouterState (ActivatedRoute Tree),同时也负责更新 browser URL,源码在 state_manager.ts

Router.routerState 内部就是调用了 StateManger.getRouterState 方法。

NavigationTransitions

NavigationTransitions 负责启动导航和处理导航中的各个阶段,比如:UrlTree 与 Route 配对 -> 创建 ActivatedRoute Tree -> 创建组件插入 router-outlet 等等。

NavigationTransitions 源码在 navigation_transition.ts

handleNavigationRequest 就是启动一次导航的方法

request 的类型是 NavigationTransition,它包含了所有导航的信息

这些信息并不是在一开始导航就齐全的,一开始只有当前的信息和 rawUrl,targetRouterState 是在导航处理过程中添加进去的,

只是 Angular 为了方便代码管理,把所有信息都集中到 NavigationTransition 对象中,然后让它贯穿整个导航过程。

transitions 是一个 RxJS 流,它代表了整个导航过程。

NavigationTransitions 在初始化时,会添加各种处理导航的过程到这个流上。

比如:UrlTree 与 Route 配对 -> 创建 ActivatedRoute Tree -> 创建组件插入 router-outlet 等等。下一个 part 逛源码时,我们再仔细看一看。

ViewportScroller

ViewportScroller 的职责是控制游览器的 scrollbar。它之所以与 Routing 有关是因为导航后移动 scrollbar 是游览器的默认行为,比如 next page scroll to top,history back 恢复之前的 scrolled position。

为此 Angular Routing 也提供了类似的功能。为了不直接和运行环境有关联,Angular 一如既往的搞了一个 BrowserViewportScroller 间接调用 window.scrollTo 方法。

ViewportScroller 的源码在 viewport_scroller.ts

BrowserViewportScroller 的源码也是在 viewport_scroller.ts

RouterScroller

RouterScroller 的职责是监听 NavigationTransitions 发出的导航事件,然后调用 ViewportScroller 完成 next page scroll to top,history back 恢复之前的 scrolled position 等等操作。

RouterScroller 源码在 router_scroller.ts

Router

上面介绍的所有 Service Provider 有些是完全不公开的,有些虽然是公开但在日常开发中是很少会用到的,除非我们需要 customization。

唯独只有 Router 是日常会用到的。Router 的源码在 router.ts

上面我们已经有介绍过了几个 Router 的属性和方法:

  1. Router.parseUrl

    把 URL string 转换成 UrlTree 对象

  2. Router.createUrlTree

    通过 command 的方式创建 UrlTree 对象

  3. Router.routerState

    获取当前的 ActivatedRoute Tree

我们再看它的导航功能,以及它如何贯穿上面介绍的各种 Internal Server Provider。

Router.navigate 是一个导航方法

navigateByUrl 方法

scheduleNavigation 方法

handleNavigationRequest 方法源码在 navigation_transition.ts

监听 transitions 的是 setupNavigations 方法

它有一系列的 RxJS operators,里面会做非常多事情,我们看最重要的几个就好

最主要有 4 件事:

  1. UrlTree 与 Route 配对 (a.k.a Recognize)

    配对完成后,ActivatedRouteSnapshot 和 RouterStateSnapshot 就创建好了,注意!这里只是创建 snapshot 而且,ActivatedRoute 和 RouterState 还没有被创建。

  2. 创建 ActivatedRoute 和 RouterState

    这里会涉及 Route Reuse 概念。

    ActivatedRouteSnapshot 和 RouterStateSnapshot 没有 reuse 的概念,每一次都会全部创建新的。

    RouterState 和 ActivatedRoute 则有 reuse 的概念,不一定会创建新的。

    这或许就是它们被分开在两个不同阶段创建的原因之一吧。

  3. 创建 OutletContext Tree,创建组件并把组件插入到对应的 router-outlet。

  4. 发布每个阶段的 event

    NavigationTransitions 有一个 private 的 transitions BehaviorSubject 和一个 public 的 events Subject。


    transitions 是导航的起点,只有 NavigationTranstions.setupNavigations 方法监听了这个流,然后在一系列 RxJS operators 中,它会发布不同阶段的事件到 events Subject。

    外部可以通过监听 events Subject 获取到不同阶段的导航事件。

    比如:

RouterScroller 就是其中一个监听 NavigationTranstions.events 的 Service

createScrollEvents 方法上面我们讲解过了,它负责在导航完成后 scroll to top 或者在 history back 的时候恢复之前的 scrolled position 等等。

Router 也是其中一个监听 NavigationTranstions.events 的 Service

StateManager.handleRouterEvent 上面讲解过,它负责维护当前的 UrlTree 和 RouterState,同时也负责更新 browser URL。

更新 browser URL 需要用到 Location -> Location 内又用到 LocationStrategy -> 然后是 PlatformLocation -> 最后才是 Browser History API。

Router 之后会转发导航阶段 event。

日常开发中,我们若想监听导航阶段事件,我们不使用 NavigationTranstions.events(因为 Angular 完全没有公开 NavigationTranstions Service),取而代之的是 Router.events。

总结

除了 Router,其它 Service Provider 日常都很少会被使用,它们都是 Angular 内部在使用而已。

通过这些 Service Provider 我们也可以看出 Angular 架构的职责分离设计,虽然很繁琐,但是对长期代码维护是非常有利的,非常值得学习。

 

Routing 初始化 の 源码逛一逛

上一 part 逛 Router.navigate 源码,我们基本上对 导航 -> Recognize -> 创建 ActivatedRoute -> 创建组件和插入 router-outlet -> Scrolling -> 更新 browser URL 都有一个大致理解了。

这 part 我们逛一下 Routing 的初始化过程。

Routing 初始化的起点在 app.config.ts

provideRouter 函数源码在 provide_router.ts

APP_BOOTSTRAP_LISTENER 会在 App 组件渲染后被执行,相关源码在 application_ref.ts 里的 ApplicationRef._loadComponent 函数 (这个方法我们在 NodeInjector 文章中研究过)。

getBootstrapListener 函数源码在 provide_router.ts

Router constructor 源码在 router.ts

currentUrlTree 属性

StateManager.getCurrentUrlTree 方法源码在 state_manager.ts

class UrlTree 源码在 url_tree.ts

都是空壳。

回到 Router constructor

setupNavigations 方法源码在 navigation_transition.ts

回到 Router constructor

subscribeToNavigationEvents 方法

Router constructor 执行结束,回到 getBootstrapListener 函数。

initialNavigation 方法源码在 router.ts

setUpLocationChangeListener 方法

registerNonRouterCurrentEntryChangeListener 方法源码在 state_manager.ts

回到 setUpLocationChangeListener 方法

回到 initialNavigation 方法

至此,第一个 navigation transition 就诞生了。那一批 RxJS operators 会负责 Recognize -> 创建 ActivatedRoute -> 创建组件和插入 router-outlet -> Scrolling 等等。

 

Routing 导航关键阶段 の 源码逛一逛

上一 part 在讲解 Router.navigate 方法时,我们提到了导航的几个重要阶段,比如 Recognize -> 创建 ActivatedRoute -> 创建组件和插入 router-outlet -> Scrolling 等等。

不过只能算一个简单的 overview,这一 part 我想更详解的逛一下这个部分的源码,因为我觉得搞清楚这几个重要阶段会对接下来学习上层 API 有帮助。

Recognize / RouterStateSnapshot 阶段

Recognize 阶段主要是做 UrlTree 和 Route 配对,然后生成 RouterStateSnapshot (a.k.a ActivatedRouteSnapshot Tree)。另外,redirect 过程其实也是在这个阶段完成的哦。

我们从 Router.scheduleNavigation 方法看起

里面通过 NavigationTransitions.handleNaviagationRequest 方法启动了导航,rawUrl 是目的地 (它的类型是 UrlTree),我们留意它。

handleNavigationRequest 方法

没什么特别的只是发布了 transitions 事件。transitions 类型是 RxJS BehaviorSubject<NavigationTransition>。

这个 NavigationTransition 对象会贯穿所有导航阶段。

这些属性并不是一开始就全部填完的,它们会在一个一个阶段被慢慢填补上,然后一直传下去给下一个阶段使用。

我们盯着 rawUrl 属性,它是起点。

在 NavigationTransitions.setupNavigations 方法中 (这个方法是在 Routing 初始化时被执行的) 会监听 transitions 流,然后是一系列 RxJS operators。

所有导航阶段都发生在这些 RxJS operators 中。

rawUrl 被赋值到了 extractedUrl,UrlHandlingStrategy.extract 不是重点,默认情况下它只是简单赋值 extractedUrl = rawUrl 而已。

我们把焦点从 rawUrl 移到这个 extractedUrl 身上,关注它。

接着就是这 part 的主角 -- Recognize 阶段,它是一个 Angular 扩展的 Custom RxJS operator。

recognize 函数源码在 recognize.ts

recognizeFn 函数 recognize.ts

recognize 方法

split 函数源码在 config_matching.ts

addEmptyPathsToChildrenIfNeeded 函数

回到 recognize 方法

match 方法

processSegmentGroup 方法

processChildren 方法

processChildren 方法里面只是做了一个 foreach 和 flat 的动作,真正做配对工作的依然是回到 processSegmentGroup 方法。

processSegment 方法

processSegmentAgainstRoute 方法

我先讲解一下 redirect 的情况,expandSegmentAgainstRouteUsingRedirect 方法我们就不逛了,讲它的过程就好了。

redirect 的过程是这样的,当配对成功后,本来是应该要创建 ActivatedRouteSnapshot,但是由于它是要 redirect,

所以此时要做的是调整目的地 UrlTree 然后继续新的配对。

比如说:

export const routes: Routes = [
  { path: 'about-us', redirectTo: 'about' },
  { path: 'about', component: AboutComponent },
];

第一个 Route 配对成功后本来应该要创建一个 ActivatedRouteSnapshot,

但由于 Route 是想要 redirect to 'about',那么就要从原本的 URL '/about-us' 换成 '/about' 在重新做配对。

另外,redirect 是可以在半中央的,什么意思?

export const routes: Routes = [
  {
    path: 'products/iphone-14',
    component: ProductDetailComponent,
    children: [
      { path: 'old-reviews', redirectTo: 'reviews' },
      { path: 'reviews', component: ProductReviewComponent },
      { path: 'related-products', component: RelatedProductComponent },
    ],
  },
];

redirect 发生在 child layer,此时已经配对成功的 Route path: 'products/iphone-14' 已经生成了 ActivatedRouteSnapshot,

这个会被保留,只有后续的配对会受影响而已。

可是,如果 redirectTo 是 '/about',开头是 slash '/',那它是一个绝对路径,表示要重头开始配对。

此时 expandSegmentAgainstRouteUsingRedirect  会 throw 一个 redirect error 到外面,外面会负责重新配对,这个部分上面我们有提到过。

好,我们看回没有 redirect 的情况。

matchSegmentAgainstRoute 方法

matchWithChecks 函数源码在 config_matching.ts

回到 matchSegmentAgainstRoute 方法

好,match 方法逛完了,回到 recognize 方法

match 返回 TreeNode<ActivatedRouteSnapshot>[],这里做一个 Root Node,整个 RouterStateSnapshot 就形成了。

RouterStateSnapshot 和 ActivatedRouteSnapshot 创建过程的细节

上面我们有谈及过 RouterStateSnapshot 和 ActivatedRouteSnapshot 的关系,

ActivatedRouteSnapshot 是一个树形结构,因为它有属性 parent 和 children。

而 RouterStateSnapshot 也是一个树形结构,而且它比 ActivatedRouteSnapshot 更干净。

RouterStateSnapshot 有一个 root 属性,类型是 TreeNode。TreeNode 只有 2 个属性,一个是 value 表示当前 TreeNode 对应的 ActivatedRouteSnapshot,另一个 children 属性表示子层 TreeNode Array。

在 Recognizer.match 方法中,首先创建的是 ActivatedRouteSnapshot,然后是它对应的 TreeNode,每一个 ActivatedRouteSnapshot 都会有自己的 TreeNode (TreeNode.value === ActivatedRouteSnapshot)。

如果有子层的话,那 TreeNode 会被延后创建,先创建子层的 ActivatedRouteSnapshot 和子层的 TreeNode,然后才回到父层创建 TreeNode,并且把子层 TreeNode 放入到父层 TreeNode.children 里。

这一轮走完,ActivatedRouteSnapshot 和 TreeNode 都创建完了。

TreeNode.value 关联着 ActivatedRouteSnapshot,TreeNode.children 关联着子层 TreeNode。

至此 TreeNode 的树形结构就诞生了。

但是,此时 ActivatedRouteSnapshot 的 parent 和 children 其实还是空的。

因为 ActivatedRouteSnapshot 内部其实是透过 _routerState 属性获取到 parent 和 children 的,而此时 RouterStateSnapshot 还没有被创建,所以 _routerState 是 undefined。

match 方法之后,Root ActivatedRouteSnapshot 和 Root TreeNode 和 RouterStateSnapshot 才被创建。

在创建 RouterStateSnapshot 的过程中,所以的 ActivatedRouteSnapshot._routerState 才被赋值 RouterStateSnapshot 对象。

总结:正真维护 ActivatedRouteSnapshot 树形结构的是 RouterStateSnapshot,ActivatedRouteSnapshot parent children 只是一个 shortcut 而已。

RouterState 阶段

在创建 RouterState 之前,其实还有 Guards, Resolve, Load Components 阶段。这里我们带过就好,下一篇才教。

  1. Guards 阶段

    Guards 是守卫的意思,顾名思义就是依据条件判断 URL 是否能被访问。

    它可以用来实现 authen 和 auth 功能。

  2. Resolve 阶段

    这个是冷门功能,我们下一篇才讲。

  3. Load Components 阶段

    组件是可以 lazy load 的,这个阶段就会加载。

RouterState 阶段自然就是创建 ActivatedRoute 和 RouterState 咯。

createRouterState 函数源码在 create_router_state.ts

createRouterState 会依据 RouteStateSnapshot 树形结构创建出 RouteState。

RouteStateSnapshot 里面装的是 ActivatedRouteSnapshot,RouteState 里面装的是 ActivatedRoute。

createNode 里有一个很重要概念 -- Route Reuse,不过这篇不会深入讲,留给下一篇。

这里我们只要知道 ActivatedRoute 是可以 reuse 的,它不一定会 new ActivatedRoute 做一个新的。

TreeNode 也是可以 reuse 的,不过我们一般上不在意 TreeNode 是否 reuse,因为 TreeNode 只是维护 ActivatedRoute 的树形结构而已,关键是 ActivatedRoute 是否 reuse。

Activate Routes 阶段

 

未完待续。。。

 

  

   

  

 

目录

上一篇 Angular 18+ 高级教程 – Component 组件 の Control Flow

下一篇 Angular 18+ 高级教程 – Routing 路由 (功能篇)

想查看目录,请移步 Angular 18+ 高级教程 – 目录

喜欢请点推荐👍,若发现教程内容以新版脱节请评论通知我。happy coding 😊💻

 

posted @ 2024-01-16 23:28  兴杰  阅读(2098)  评论(0编辑  收藏  举报