Angular13 Angular 教程:为英雄之旅添加路由支持-里程碑 6:异步路由

2024-02-25 开发教程 Angular13 匿名 3

里程碑 6:异步路由

完成上面的里程碑后,应用程序很自然地长大了。在某一个时间点,你将达到一个顶点,应用将会需要过多的时间来加载。

为了解决这个问题,请使用异步路由,它会根据请求来惰性加载某些特性模块。惰性加载有很多好处。

  • 你可以只在用户请求时才加载某些特性区。
  • 对于那些只访问应用程序某些区域的用户,这样能加快加载速度。
  • 你可以持续扩充惰性加载特性区的功能,而不用增加初始加载的包体积。

你已经完成了一部分。通过把应用组织成一些模块:​AppModule​、​HeroesModule​、​AdminModule ​和 ​CrisisCenterModule​,你已经有了可用于实现惰性加载的候选者。

有些模块(比如 ​AppModule​)必须在启动时加载,但其它的都可以而且应该惰性加载。比如 ​AdminModule ​就只有少数已认证的用户才需要它,所以你应该只有在正确的人请求它时才加载。

惰性加载路由配置

把 ​admin-routing.module.ts​ 中的 ​admin ​路径从 ​'admin'​ 改为空路径 ​''​。

可以用空路径路由来对路由进行分组,而不用往 URL 中添加额外的路径片段。用户仍旧访问 ​/admin​,并且 ​AdminComponent ​仍然作为用来包含子路由的路由组件。

打开 ​AppRoutingModule​,并把一个新的 ​admin ​路由添加到它的 ​appRoutes ​数组中。

给它一个 ​loadChildren ​属性(替换掉 ​children ​属性)。​loadChildren ​属性接收一个函数,该函数使用浏览器内置的动态导入语法 ​import('...')​ 来惰性加载代码,并返回一个承诺(Promise)。其路径是 ​AdminModule ​的位置(相对于应用的根目录)。当代码请求并加载完毕后,这个 ​Promise ​就会解析成一个包含 ​NgModule ​的对象,也就是 ​AdminModule​。

{
path: 'admin',
loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule),
},

注意:
当使用绝对路径时,​NgModule ​的文件位置必须以 ​src/app​ 开头,以便正确解析。对于自定义的 使用绝对路径的路径映射表,你必须在项目的 ​tsconfig.json​ 中必须配置好 ​baseUrl ​和 ​paths ​属性。

当路由器导航到这个路由时,它会用 ​loadChildren ​字符串来动态加载 ​AdminModule​,然后把 ​AdminModule ​添加到当前的路由配置中,最后,它把所请求的路由加载到目标 ​admin ​组件中。

惰性加载和重新配置工作只会发生一次,也就是在该路由首次被请求时。在后续的请求中,该模块和路由都是立即可用的。

最后一步是把管理特性区从主应用中完全分离开。根模块 ​AppModule ​既不能加载也不能引用 ​AdminModule ​及其文件。

在 ​app.module.ts​ 中,从顶部移除 ​AdminModule ​的导入语句,并且从 NgModule 的 ​imports ​数组中移除 ​AdminModule​。

CanLoad:保护对特性模块的未授权加载

你已经使用 ​CanActivate ​保护 ​AdminModule ​了,它会阻止未授权用户访问管理特性区。如果用户未登录,它就会跳转到登录页。

但是路由器仍然会加载 ​AdminModule ​—— 即使用户无法访问它的任何一个组件。理想的方式是,只有在用户已登录的情况下你才加载 ​AdminModule​。

添加一个 ​CanLoad ​守卫,它只在用户已登录并且尝试访问管理特性区的时候,才加载 ​AdminModule ​一次。

现有的 ​AuthGuard ​的 ​checkLogin()​ 方法中已经有了支持 ​CanLoad ​守卫的基础逻辑。

  1. 打开 ​auth.guard.ts​。
  2. 从 ​@angular/router​ 导入 ​CanLoad ​接口。
  3. 把它添加到 ​AuthGuard ​类的 ​implements ​列表中。
  4. 然后像下面这样实现 ​canLoad()​:
canLoad(route: Route): boolean {
const url = `/${route.path}`;
return this.checkLogin(url);
}

路由器会把 ​canLoad()​ 方法的 ​route ​参数设置为准备访问的目标 URL。如果用户已经登录了,​checkLogin()​ 方法就会重定向到那个 URL。

现在,把 ​AuthGuard ​导入到 ​AppRoutingModule ​中,并把 ​AuthGuard ​添加到 ​admin ​路由的 ​canLoad ​数组中。完整的 ​admin ​路由是这样的:

{
path: 'admin',
loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule),
canLoad: [AuthGuard]
},

预加载:特性区的后台加载

除了按需加载模块外,还可以通过预加载方式异步加载模块。

当应用启动时,​AppModule ​被急性加载,这意味着它会立即加载。而 ​AdminModule​ 只在用户点击链接时加载,这叫做惰性加载。

预加载允许你在后台加载模块,以便当用户激活某个特定的路由时,就可以渲染这些数据了。考虑一下危机中心。它不是用户看到的第一个视图。默认情况下,英雄列表才是第一个视图。为了获得最小的初始有效负载和最快的启动时间,你应该急性加载 ​AppModule ​和 ​HeroesModule​。

你可以惰性加载危机中心。但是,你几乎可以肯定用户会在启动应用之后的几分钟内访问危机中心。理想情况下,应用启动时应该只加载 ​AppModule ​和 ​HeroesModule​,然后几乎立即开始后台加载 ​CrisisCenterModule​。在用户浏览到危机中心之前,该模块应该已经加载完毕,可供访问了。

预加载的工作原理

在每次成功的导航后,路由器会在自己的配置中查找尚未加载并且可以预加载的模块。是否加载某个模块,以及要加载哪些模块,取决于预加载策略。

Router ​提供了两种预加载策略:

策略

详情

不预加载

这是默认值。惰性加载的特性区仍然会按需加载。

预加载

预加载所有惰性加载的特性区。

路由器或者完全不预加载或者预加载每个惰性加载模块。 路由器还支持自定义预加载策略,以便完全控制要预加载哪些模块以及何时加载。

本节将指导你把 ​CrisisCenterModule ​改成惰性加载的,并使用 ​PreloadAllModules ​策略来预加载所有惰性加载模块。

惰性加载危机中心

修改路由配置,来惰性加载 ​CrisisCenterModule​。修改的步骤和配置惰性加载 ​AdminModule ​时一样。

  1. 把 ​CrisisCenterRoutingModule ​中的路径从 ​crisis-center​ 改为空字符串。
  2. 往 ​AppRoutingModule ​中添加一个 ​crisis-center​ 路由。
  3. 设置 ​loadChildren ​字符串来加载 ​CrisisCenterModule​。
  4. 从 ​app.module.ts​ 中移除所有对 ​CrisisCenterModule ​的引用。

下面是打开预加载之前的模块修改版:

  • app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { Router } from '@angular/router';
import { AppComponent } from './app.component';
import { PageNotFoundComponent } from './page-not-found/page-not-found.component';
import { ComposeMessageComponent } from './compose-message/compose-message.component';
import { AppRoutingModule } from './app-routing.module';
import { HeroesModule } from './heroes/heroes.module';
import { AuthModule } from './auth/auth.module';
@NgModule({
imports: [
BrowserModule,
BrowserAnimationsModule,
FormsModule,
HeroesModule,
AuthModule,
AppRoutingModule,
],
declarations: [
AppComponent,
ComposeMessageComponent,
PageNotFoundComponent
],
bootstrap: [ AppComponent ]
})
export class AppModule {
}
app-routing.module.ts
import { NgModule } from '@angular/core';
import {
RouterModule, Routes,
} from '@angular/router';
import { ComposeMessageComponent } from './compose-message/compose-message.component';
import { PageNotFoundComponent } from './page-not-found/page-not-found.component';
import { AuthGuard } from './auth/auth.guard';
const appRoutes: Routes = [
{
path: 'compose',
component: ComposeMessageComponent,
outlet: 'popup'
},
{
path: 'admin',
loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule),
canLoad: [AuthGuard]
},
{
path: 'crisis-center',
loadChildren: () => import('./crisis-center/crisis-center.module').then(m => m.CrisisCenterModule)
},
{ path: '', redirectTo: '/heroes', pathMatch: 'full' },
{ path: '**', component: PageNotFoundComponent }
];
@NgModule({
imports: [
RouterModule.forRoot(
appRoutes,
)
],
exports: [
RouterModule
]
})
export class AppRoutingModule {}
crisis-center-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { CrisisCenterHomeComponent } from './crisis-center-home/crisis-center-home.component';
import { CrisisListComponent } from './crisis-list/crisis-list.component';
import { CrisisCenterComponent } from './crisis-center/crisis-center.component';
import { CrisisDetailComponent } from './crisis-detail/crisis-detail.component';
import { CanDeactivateGuard } from '../can-deactivate.guard';
import { CrisisDetailResolverService } from './crisis-detail-resolver.service';
const crisisCenterRoutes: Routes = [
{
path: '',
component: CrisisCenterComponent,
children: [
{
path: '',
component: CrisisListComponent,
children: [
{
path: ':id',
component: CrisisDetailComponent,
canDeactivate: [CanDeactivateGuard],
resolve: {
crisis: CrisisDetailResolverService
}
},
{
path: '',
component: CrisisCenterHomeComponent
}
]
}
]
}
];
@NgModule({
imports: [
RouterModule.forChild(crisisCenterRoutes)
],
exports: [
RouterModule
]
})
export class CrisisCenterRoutingModule { }

你可以现在尝试它,并确认在点击了“Crisis Center”按钮之后加载了 ​CrisisCenterModule​。

要为所有惰性加载模块启用预加载功能,请从 Angular 的路由模块中导入 ​PreloadAllModules​。

RouterModule.forRoot()​ 方法的第二个参数接受一个附加配置选项对象。​preloadingStrategy ​就是其中之一。把 ​PreloadAllModules ​添加到 ​forRoot()​ 调用中:

RouterModule.forRoot(
appRoutes,
{
enableTracing: true, // <-- debugging purposes only
preloadingStrategy: PreloadAllModules
}
)

这项配置会让 ​Router ​预加载器立即加载所有惰性加载路由(带 ​loadChildren ​属性的路由)。

当访问 ​http://localhost:4200​ 时,​/heroes​ 路由立即随之启动,并且路由器在加载了 ​HeroesModule ​之后立即开始加载 ​CrisisCenterModule​。

目前,​AdminModule ​并没有预加载,因为 ​CanLoad ​阻塞了它。

CanLoad 会阻塞预加载

PreloadAllModules ​策略不会加载被​CanLoad​守卫所保护的特性区。

几步之前,你刚刚给 ​AdminModule ​中的路由添加了 ​CanLoad ​守卫,以阻塞加载那个模块,直到用户认证结束。​CanLoad ​守卫的优先级高于预加载策略。

如果你要加载一个模块并且保护它防止未授权访问,请移除 ​canLoad ​守卫,只单独依赖​CanActivate​守卫。

自定义预加载策略

在很多场景下,预加载的每个惰性加载模块都能正常工作。但是,考虑到低带宽和用户指标等因素,可以为特定的特性模块使用自定义预加载策略。

本节将指导你添加一个自定义策略,它只预加载 ​data.preload​ 标志为 ​true ​路由。回想一下,你可以在路由的 ​data ​属性中添加任何东西。

在 ​AppRoutingModule ​的 ​crisis-center​ 路由中设置 ​data.preload​ 标志。

{
path: 'crisis-center',
loadChildren: () => import('./crisis-center/crisis-center.module').then(m => m.CrisisCenterModule),
data: { preload: true }
},

生成一个新的 ​SelectivePreloadingStrategy ​服务。

ng generate service selective-preloading-strategy

使用下列内容替换 ​selective-preloading-strategy.service.ts​:

import { Injectable } from '@angular/core';
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class SelectivePreloadingStrategyService implements PreloadingStrategy {
preloadedModules: string[] = [];
preload(route: Route, load: () => Observable<any>): Observable<any> {
if (route.data?.['preload'] && route.path != null) {
// add the route path to the preloaded module array
this.preloadedModules.push(route.path);
// log the route path to the console
console.log('Preloaded: ' + route.path);
return load();
} else {
return of(null);
}
}
}

SelectivePreloadingStrategyService ​实现了 ​PreloadingStrategy​,它有一个方法 ​preload()​。

路由器会用两个参数来调用 ​preload()​ 方法:

  1. 要加载的路由。
  2. 一个加载器(loader)函数,它能异步加载带路由的模块。

preload ​的实现要返回一个 ​Observable​。如果该路由应该预加载,它就会返回调用加载器函数所返回的 ​Observable​。如果该路由不应该预加载,它就返回一个 ​null ​值的 ​Observable ​对象。

在这个例子中,如果路由的 ​data.preload​ 标志是真值,则 ​preload()​ 方法会加载该路由。

它的副作用是 ​SelectivePreloadingStrategyService ​会把所选路由的 ​path ​记录在它的公共数组 ​preloadedModules ​中。

很快,你就会扩展 ​AdminDashboardComponent ​来注入该服务,并且显示它的 ​preloadedModules ​数组。

但是首先,要对 ​AppRoutingModule ​做少量修改。

  1. 把 ​SelectivePreloadingStrategyService ​导入到 ​AppRoutingModule ​中。
  2. 把 ​PreloadAllModules ​策略替换成对 ​forRoot()​ 的调用,并且传入这个 ​SelectivePreloadingStrategyService​。

现在,编辑 ​AdminDashboardComponent ​以显示这些预加载路由的日志。

  1. 导入 ​SelectivePreloadingStrategyService​(它是一个服务)。
  2. 把它注入到仪表盘的构造函数中。
  3. 修改模板来显示这个策略服务的 ​preloadedModules ​数组。

现在文件如下:

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { SelectivePreloadingStrategyService } from '../../selective-preloading-strategy.service';
@Component({
selector: 'app-admin-dashboard',
templateUrl: './admin-dashboard.component.html',
styleUrls: ['./admin-dashboard.component.css']
})
export class AdminDashboardComponent implements OnInit {
sessionId!: Observable<string>;
token!: Observable<string>;
modules: string[] = [];
constructor(
private route: ActivatedRoute,
preloadStrategy: SelectivePreloadingStrategyService
) {
this.modules = preloadStrategy.preloadedModules;
}
ngOnInit() {
// Capture the session ID if available
this.sessionId = this.route
.queryParamMap
.pipe(map(params => params.get('session_id') || 'None'));
// Capture the fragment if available
this.token = this.route
.fragment
.pipe(map(fragment => fragment || 'None'));
}
}

一旦应用加载完了初始路由,​CrisisCenterModule ​也被预加载了。通过 ​Admin ​特性区中的记录就可以验证它,“Preloaded Modules”中列出了 ​crisis-center​。它也被记录到了浏览器的控制台。

使用重定向迁移 URL

你已经设置好了路由,并且用命令式和声明式的方式导航到了很多不同的路由。但是,任何应用的需求都会随着时间而改变。你把链接 ​/heroes​ 和 ​hero/:id​ 指向了 ​HeroListComponent ​和 ​HeroDetailComponent ​组件。如果有这样一个需求,要把链接 ​heroes ​变成 ​superheroes​,你可能仍然希望以前的 URL 能正常导航。但你也不想在应用中找到并修改每一个链接,这时候,重定向就可以省去这些琐碎的重构工作。

把 /heroes 改为 /superheroes

本节将指导你将 ​Hero ​路由迁移到新的 URL。在导航之前,​Router ​会检查路由配置中的重定向语句,以便将来按需触发重定向。要支持这种修改,你就要在 ​heroes-routing.module​ 文件中把老的路由重定向到新的路由。

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HeroListComponent } from './hero-list/hero-list.component';
import { HeroDetailComponent } from './hero-detail/hero-detail.component';
const heroesRoutes: Routes = [
{ path: 'heroes', redirectTo: '/superheroes' },
{ path: 'hero/:id', redirectTo: '/superhero/:id' },
{ path: 'superheroes', component: HeroListComponent, data: { animation: 'heroes' } },
{ path: 'superhero/:id', component: HeroDetailComponent, data: { animation: 'hero' } }
];
@NgModule({
imports: [
RouterModule.forChild(heroesRoutes)
],
exports: [
RouterModule
]
})
export class HeroesRoutingModule { }

注意,这里有两种类型的重定向。第一种是不带参数的从 ​/heroes​ 重定向到 ​/superheroes​。这是一种非常直观的重定向。第二种是从 ​/hero/:id​ 重定向到 ​/superhero/:id​,它还要包含一个 ​:id​ 路由参数。路由器重定向时使用强大的模式匹配功能,这样,路由器就会检查 URL,并且把 ​path ​中带的路由参数替换成相应的目标形式。以前,你导航到形如 ​/hero/15​ 的 URL 时,带了一个路由参数 ​id​,它的值是 ​15​。

在重定向的时候,路由器还支持查询参数和片段(fragment)。

  • 当使用绝对地址重定向时,路由器将会使用路由配置的 ​redirectTo ​属性中规定的查询参数和片段。
  • 当使用相对地址重定向时,路由器将会使用源地址(跳转前的地址)中的查询参数和片段。

目前,空路径被重定向到了 ​/heroes​,它又被重定向到了 ​/superheroes​。这样不行,因为 ​Router ​在每一层的路由配置中只会处理一次重定向。这样可以防止出现无限循环的重定向。

所以,你要在 ​app-routing.module.ts​ 中修改空路径路由,让它重定向到 ​/superheroes​。

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ComposeMessageComponent } from './compose-message/compose-message.component';
import { PageNotFoundComponent } from './page-not-found/page-not-found.component';
import { AuthGuard } from './auth/auth.guard';
import { SelectivePreloadingStrategyService } from './selective-preloading-strategy.service';
const appRoutes: Routes = [
{
path: 'compose',
component: ComposeMessageComponent,
outlet: 'popup'
},
{
path: 'admin',
loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule),
canLoad: [AuthGuard]
},
{
path: 'crisis-center',
loadChildren: () => import('./crisis-center/crisis-center.module').then(m => m.CrisisCenterModule),
data: { preload: true }
},
{ path: '', redirectTo: '/superheroes', pathMatch: 'full' },
{ path: '**', component: PageNotFoundComponent }
];
@NgModule({
imports: [
RouterModule.forRoot(
appRoutes,
{
enableTracing: false, // <-- debugging purposes only
preloadingStrategy: SelectivePreloadingStrategyService,
}
)
],
exports: [
RouterModule
]
})
export class AppRoutingModule { }

由于 ​routerLink ​与路由配置无关,所以你要修改相关的路由链接,以便在新的路由激活时,它们也能保持激活状态。还要修改 ​app.component.ts​ 模板中的 ​/heroes​ 这个 ​routerLink​。

<div class="wrapper">
<h1 class="title">Angular Router</h1>
<nav>
<a routerLink="/crisis-center" routerLinkActive="active" ariaCurrentWhenActive="page">Crisis Center</a>
<a routerLink="/superheroes" routerLinkActive="active" ariaCurrentWhenActive="page">Heroes</a>
<a routerLink="/admin" routerLinkActive="active" ariaCurrentWhenActive="page">Admin</a>
<a routerLink="/login" routerLinkActive="active" ariaCurrentWhenActive="page">Login</a>
<a [routerLink]="[{ outlets: { popup: ['compose'] } }]">Contact</a>
</nav>
<div [@routeAnimation]="getRouteAnimationData()">
<router-outlet></router-outlet>
</div>
<router-outlet name="popup"></router-outlet>
</div>

修改 ​hero-detail.component.ts​ 中的 ​goToHeroes()​ 方法,使用可选的路由参数导航回 ​/superheroes​。

gotoHeroes(hero: Hero) {
const heroId = hero ? hero.id : null;
// Pass along the hero id if available
// so that the HeroList component can select that hero.
// Include a junk 'foo' property for fun.
this.router.navigate(['/superheroes', { id: heroId, foo: 'foo' }]);
}

当这些重定向设置好之后,所有以前的路由都指向了它们的新目标,并且每个 URL 也仍然能正常工作。

审查路由器配置

要确定你的路由是否真的按照正确的顺序执行的,你可以审查路由器的配置。

可以通过注入路由器并在控制台中记录其 ​config ​属性来实现。比如,把 ​AppModule ​修改为这样,并在浏览器的控制台窗口中查看最终的路由配置。

export class AppModule {
// Diagnostic only: inspect router configuration
constructor(router: Router) {
// Use a custom replacer to display function names in the route configs
const replacer = (key, value) => (typeof value === 'function') ? value.name : value;
console.log('Routes: ', JSON.stringify(router.config, replacer, 2));
}
}

最终的应用

对这个已完成的路由器应用,参见 现场演练/ 下载范例的最终代码。