阅读(1867) (1)

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

2022-07-04 13:46:03 更新

里程碑 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));
  }
}

最终的应用

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