Angular 教程:为英雄之旅添加路由支持-里程碑 6:异步路由
里程碑 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
守卫的基础逻辑。
- 打开
auth.guard.ts
。 - 从
@angular/router
导入 CanLoad
接口。 - 把它添加到
AuthGuard
类的 implements
列表中。 - 然后像下面这样实现
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
时一样。
- 把
CrisisCenterRoutingModule
中的路径从 crisis-center
改为空字符串。 - 往
AppRoutingModule
中添加一个 crisis-center
路由。 - 设置
loadChildren
字符串来加载 CrisisCenterModule
。 - 从
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 {
}
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 {}
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()
方法:
- 要加载的路由。
- 一个加载器(loader)函数,它能异步加载带路由的模块。
preload
的实现要返回一个 Observable
。如果该路由应该预加载,它就会返回调用加载器函数所返回的 Observable
。如果该路由不应该预加载,它就返回一个 null
值的 Observable
对象。
在这个例子中,如果路由的 data.preload
标志是真值,则 preload()
方法会加载该路由。
它的副作用是 SelectivePreloadingStrategyService
会把所选路由的 path
记录在它的公共数组 preloadedModules
中。
很快,你就会扩展 AdminDashboardComponent
来注入该服务,并且显示它的 preloadedModules
数组。
但是首先,要对 AppRoutingModule
做少量修改。
- 把
SelectivePreloadingStrategyService
导入到 AppRoutingModule
中。 - 把
PreloadAllModules
策略替换成对 forRoot()
的调用,并且传入这个 SelectivePreloadingStrategyService
。
现在,编辑 AdminDashboardComponent
以显示这些预加载路由的日志。
- 导入
SelectivePreloadingStrategyService
(它是一个服务)。 - 把它注入到仪表盘的构造函数中。
- 修改模板来显示这个策略服务的
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));
}
}
最终的应用
对这个已完成的路由器应用,参见 现场演练 / 下载范例的最终代码。