阅读(2068) (0)

Angular 教程:为英雄之旅添加路由支持-里程碑 5:路由守卫

2022-07-04 13:45:47 更新

里程碑 5:路由守卫

现在,任何用户都能在任何时候导航到任何地方。但有时候出于种种原因需要控制对该应用的不同部分的访问。可能包括如下场景:

  • 该用户可能无权导航到目标组件
  • 可能用户得先登录(认证)
  • 在显示目标组件前,你可能得先获取某些数据
  • 在离开组件前,你可能要先保存修改
  • 你可能要询问用户:你是否要放弃本次更改,而不用保存它们?

你可以往路由配置中添加守卫,来处理这些场景。

守卫返回一个值,以控制路由器的行为:

守卫返回的值

详情

true

导航过程会继续

false

导航过程就会终止,且用户留在原地。

UrlTree

取消当前导航,并开始导航到所返回的 UrlTree

注意:
守卫还可以告诉路由器导航到别处,这样也会取消当前的导航。要想在守卫中这么做,就要返回 ​false​。

守卫可以用同步的方式返回一个布尔值。但在很多情况下,守卫无法用同步的方式给出答案。守卫可能会向用户问一个问题、把更改保存到服务器,或者获取新数据,而这些都是异步操作。

因此,路由的守卫可以返回一个 ​Observable<boolean>​ 或 ​Promise<boolean>​,并且路由器会等待这个可观察对象被解析为 ​true ​或 ​false​。

注意:
提供给 ​Router ​的可观察对象会在接收到第一个值之后自动完成(complete)。

路由器可以支持多种守卫接口:

守卫接口

详情

CanActivate

导航某路由时介入

CanActivateChild

导航某个子路由时介入

CanDeactivate

从当前路由离开时介入

Resolve

在某路由激活之前获取路由数据

CanLoad

导航到某个异步加载的特性模块时介入

在分层路由的每个级别上,你都可以设置多个守卫。路由器会先按照从最深的子路由由下往上检查的顺序来检查 ​CanDeactivate()​ 守卫。然后它会按照从上到下的顺序检查 ​CanActivate()​ 守卫。如果特性模块是异步加载的,在加载它之前还会检查 ​CanLoad()​ 守卫。如果任何一个守卫返回 ​false​,其它尚未完成的守卫会被取消,这样整个导航就被取消了。

接下来的小节中有一些例子。

CanActivate :需要身份验证

应用程序通常会根据访问者来决定是否授予某个特性区的访问权。你可以只对已认证过的用户或具有特定角色的用户授予访问权,还可以阻止或限制用户访问权,直到用户账户激活为止。

CanActivate ​守卫是一个管理这些导航类业务规则的工具。

添加一个“管理”特性模块


本节将指导你使用一些新的管理功能来扩展危机中心。首先添加一个名为 ​AdminModule ​的新特性模块。

生成一个带有特性模块文件和路由配置文件的 ​admin ​目录。

ng generate module admin --routing

接下来,生成一些支持性组件。

ng generate component admin/admin-dashboard
ng generate component admin/admin
ng generate component admin/manage-crises
ng generate component admin/manage-heroes

管理特性区的文件是这样的:

屏幕截图 2022-07-05 095722

管理特性模块包含 ​AdminComponent​,它用于在特性模块内的仪表盘路由以及两个尚未完成的用于管理危机和英雄的组件之间进行路由。

  • src/app/admin/admin/admin.component.html
  • <h2>Admin</h2>
    <nav>
      <a routerLink="./" routerLinkActive="active"
        [routerLinkActiveOptions]="{ exact: true }" ariaCurrentWhenActive="page">Dashboard</a>
      <a routerLink="./crises" routerLinkActive="active" ariaCurrentWhenActive="page">Manage Crises</a>
      <a routerLink="./heroes" routerLinkActive="active" ariaCurrentWhenActive="page">Manage Heroes</a>
    </nav>
    <router-outlet></router-outlet>
  • src/app/admin/admin-dashboard/admin-dashboard.component.html
  • <h3>Dashboard</h3>
  • src/app/admin/admin.module.ts
  • import { NgModule } from '@angular/core';
    import { CommonModule } from '@angular/common';
    
    import { AdminComponent } from './admin/admin.component';
    import { AdminDashboardComponent } from './admin-dashboard/admin-dashboard.component';
    import { ManageCrisesComponent } from './manage-crises/manage-crises.component';
    import { ManageHeroesComponent } from './manage-heroes/manage-heroes.component';
    
    import { AdminRoutingModule } from './admin-routing.module';
    
    @NgModule({
      imports: [
        CommonModule,
        AdminRoutingModule
      ],
      declarations: [
        AdminComponent,
        AdminDashboardComponent,
        ManageCrisesComponent,
        ManageHeroesComponent
      ]
    })
    export class AdminModule {}
  • src/app/admin/manage-crises/manage-crises.component.html
  • <p>Manage your crises here</p>
  • src/app/admin/manage-heroes/manage-heroes.component.html
  • <p>Manage your heroes here</p>
虽然管理仪表盘中的 ​RouterLink ​只包含一个没有其它 URL 段的斜杠 ​/​,但它能匹配管理特性区下的任何路由。但你只希望在访问 ​Dashboard ​路由时才激活该链接。往 ​Dashboard​ 这个 routerLink 上添加另一个绑定 ​[routerLinkActiveOptions]="{ exact: true }"​,这样就只有当用户导航到 ​/admin​ 这个 URL 时才会激活它,而不会在导航到它的某个子路由时。
无组件路由:分组路由,而不需要组件

最初的管理路由配置如下:

const adminRoutes: Routes = [
  {
    path: 'admin',
    component: AdminComponent,
    children: [
      {
        path: '',
        children: [
          { path: 'crises', component: ManageCrisesComponent },
          { path: 'heroes', component: ManageHeroesComponent },
          { path: '', component: AdminDashboardComponent }
        ]
      }
    ]
  }
];

@NgModule({
  imports: [
    RouterModule.forChild(adminRoutes)
  ],
  exports: [
    RouterModule
  ]
})
export class AdminRoutingModule {}

AdminComponent ​下的子路由有一个 ​path ​和一个 ​children ​属性,但是它没有使用 ​component​。这就定义了一个无组件路由。

要把 ​Crisis Center​ 管理下的路由分组到 ​admin ​路径下,组件是不必要的。此外,无组件路由可以更容易地保护子路由

接下来,把 ​AdminModule ​导入到 ​app.module.ts​ 中,并把它加入 ​imports ​数组中来注册这些管理类路由。

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';

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 { CrisisCenterModule } from './crisis-center/crisis-center.module';

import { AdminModule } from './admin/admin.module';

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    HeroesModule,
    CrisisCenterModule,
    AdminModule,
    AppRoutingModule
  ],
  declarations: [
    AppComponent,
    ComposeMessageComponent,
    PageNotFoundComponent
  ],
  bootstrap: [ AppComponent ]
})
export class AppModule { }

然后往壳组件 ​AppComponent ​中添加一个链接,让用户能点击它,以访问该特性。

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

守护“管理特性”区

现在危机中心的每个路由都是对所有人开放的。这些新的管理特性应该只能被已登录用户访问。

编写一个 ​CanActivate()​ 守卫,将正在尝试访问管理组件匿名用户重定向到登录页。

在 ​auth ​文件夹中生成一个 ​AuthGuard​。

ng generate guard auth/auth

为了演示这些基础知识,这个例子只把日志写到控制台中,立即 ​return ​true,并允许继续导航:

import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';

@Injectable({
  providedIn: 'root',
})
export class AuthGuard implements CanActivate {
  canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): boolean {
    console.log('AuthGuard#canActivate called');
    return true;
  }
}

接下来,打开 ​admin-routing.module.ts​,导入 ​AuthGuard ​类,修改管理路由并通过 ​CanActivate()​ 守卫来引用 ​AuthGuard​:

import { AuthGuard } from '../auth/auth.guard';

const adminRoutes: Routes = [
  {
    path: 'admin',
    component: AdminComponent,
    canActivate: [AuthGuard],
    children: [
      {
        path: '',
        children: [
          { path: 'crises', component: ManageCrisesComponent },
          { path: 'heroes', component: ManageHeroesComponent },
          { path: '', component: AdminDashboardComponent }
        ],
      }
    ]
  }
];

@NgModule({
  imports: [
    RouterModule.forChild(adminRoutes)
  ],
  exports: [
    RouterModule
  ]
})
export class AdminRoutingModule {}

管理特性区现在受此守卫保护了,不过该守卫还需要做进一步定制。

通过 AuthGuard 验证

让 ​AuthGuard ​模拟身份验证。

AuthGuard ​可以调用应用中的一项服务,该服务能让用户登录,并且保存当前用户的信息。在 ​admin ​目录下生成一个新的 ​AuthService​:

ng generate service auth/auth

修改 ​AuthService ​以登入此用户:

import { Injectable } from '@angular/core';

import { Observable, of } from 'rxjs';
import { tap, delay } from 'rxjs/operators';

@Injectable({
  providedIn: 'root',
})
export class AuthService {
  isLoggedIn = false;

  // store the URL so we can redirect after logging in
  redirectUrl: string | null = null;

  login(): Observable<boolean> {
    return of(true).pipe(
      delay(1000),
      tap(() => this.isLoggedIn = true)
    );
  }

  logout(): void {
    this.isLoggedIn = false;
  }
}

虽然不会真的进行登录,但它有一个 ​isLoggedIn ​标志,用来标识是否用户已经登录过了。它的 ​login()​ 方法会仿真一个对外部服务的 API 调用,返回一个可观察对象(observable)。在短暂的停顿之后,这个可观察对象就会解析成功。​redirectUrl ​属性将会保存在用户要访问的 URL 中,以便认证完之后导航到它。

为了保持最小化,这个例子会将未经身份验证的用户重定向到 ​/admin​。

修改 ​AuthGuard ​以调用 ​AuthService​。

import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, Router, UrlTree } from '@angular/router';

import { AuthService } from './auth.service';

@Injectable({
  providedIn: 'root',
})
export class AuthGuard implements CanActivate {
  constructor(private authService: AuthService, private router: Router) {}

  canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): true|UrlTree {
    const url: string = state.url;

    return this.checkLogin(url);
  }

  checkLogin(url: string): true|UrlTree {
    if (this.authService.isLoggedIn) { return true; }

    // Store the attempted URL for redirecting
    this.authService.redirectUrl = url;

    // Redirect to the login page
    return this.router.parseUrl('/login');
  }
}

注意,你把 ​AuthService ​和 ​Router ​服务注入到了构造函数中。你还没有提供 ​AuthService​,这里要说明的是:可以往路由守卫中注入有用的服务。

该守卫返回一个同步的布尔值。如果用户已经登录,它就返回 ​true​,导航会继续。

这个 ​ActivatedRouteSnapshot ​包含了即将被激活的路由,而 ​RouterStateSnapshot ​包含了该应用即将到达的状态。你应该通过守卫进行检查。

如果用户还没有登录,你就会用 ​RouterStateSnapshot.url​ 保存用户来自的 URL 并让路由器跳转到登录页(你尚未创建该页)。这间接导致路由器自动中止了这次导航,​checkLogin()​ 返回 ​false ​并不是必须的,但这样可以更清楚的表达意图。

添加 LoginComponent

你需要一个 ​LoginComponent ​来让用户登录进这个应用。在登录之后,你就会跳转到前面保存的 URL,如果没有,就跳转到默认 URL。该组件没有什么新内容,你在路由配置中使用它的方式也没什么新意。

ng generate component auth/login

在 ​auth/auth-routing.module.ts​ 文件中注册一个 ​/login​ 路由。在 ​app.module.ts​ 中,导入 ​AuthModule ​并且添加到 ​AppModule ​的 ​imports ​中。

  • src/app/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 { 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 {
    }
  • src/app/auth/login/login.component.html
  • <h2>Login</h2>
    <p>{{message}}</p>
    <p>
      <button type="button" (click)="login()"  *ngIf="!authService.isLoggedIn">Login</button>
      <button type="button" (click)="logout()" *ngIf="authService.isLoggedIn">Logout</button>
    </p>
  • src/app/auth/login/login.component.ts
  • import { Component } from '@angular/core';
    import { Router } from '@angular/router';
    import { AuthService } from '../auth.service';
    
    @Component({
      selector: 'app-login',
      templateUrl: './login.component.html',
      styleUrls: ['./login.component.css']
    })
    export class LoginComponent {
      message: string;
    
      constructor(public authService: AuthService, public router: Router) {
        this.message = this.getMessage();
      }
    
      getMessage() {
        return 'Logged ' + (this.authService.isLoggedIn ? 'in' : 'out');
      }
    
      login() {
        this.message = 'Trying to log in ...';
    
        this.authService.login().subscribe(() => {
          this.message = this.getMessage();
          if (this.authService.isLoggedIn) {
            // Usually you would use the redirect URL from the auth service.
            // However to keep the example simple, we will always redirect to `/admin`.
            const redirectUrl = '/admin';
    
            // Redirect the user
            this.router.navigate([redirectUrl]);
          }
        });
      }
    
      logout() {
        this.authService.logout();
        this.message = this.getMessage();
      }
    }
  • src/app/auth/auth.module.ts
  • import { NgModule } from '@angular/core';
    import { CommonModule } from '@angular/common';
    import { FormsModule } from '@angular/forms';
    
    import { LoginComponent } from './login/login.component';
    import { AuthRoutingModule } from './auth-routing.module';
    
    @NgModule({
      imports: [
        CommonModule,
        FormsModule,
        AuthRoutingModule
      ],
      declarations: [
        LoginComponent
      ]
    })
    export class AuthModule {}

CanActivateChild:保护子路由

你还可以使用 ​CanActivateChild ​守卫来保护子路由。​CanActivateChild ​守卫和 ​CanActivate ​守卫很像。它们的区别在于,​CanActivateChild ​会在任何子路由被激活之前运行。

你要保护管理特性模块,防止它被非授权访问,还要保护这个特性模块内部的那些子路由。

扩展 ​AuthGuard ​以便在 ​admin ​路由之间导航时提供保护。打开 ​auth.guard.ts​ 并从路由库中导入 ​CanActivateChild ​接口。

接下来,实现 ​CanActivateChild ​方法,它所接收的参数与 ​CanActivate ​方法一样:一个 ​ActivatedRouteSnapshot ​和一个 ​RouterStateSnapshot​。​CanActivateChild ​方法可以返回 ​Observable<boolean|UrlTree>​ 或 ​Promise<boolean|UrlTree>​ 来支持异步检查,或 ​boolean ​或 ​UrlTree ​来支持同步检查。这里返回的或者是 ​true ​以便允许用户访问管理特性模块,或者是 ​UrlTree ​以便把用户重定向到登录页:

import { Injectable } from '@angular/core';
import {
  CanActivate, Router,
  ActivatedRouteSnapshot,
  RouterStateSnapshot,
  CanActivateChild,
  UrlTree
} from '@angular/router';
import { AuthService } from './auth.service';

@Injectable({
  providedIn: 'root',
})
export class AuthGuard implements CanActivate, CanActivateChild {
  constructor(private authService: AuthService, private router: Router) {}

  canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): true|UrlTree {
    const url: string = state.url;

    return this.checkLogin(url);
  }

  canActivateChild(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): true|UrlTree {
    return this.canActivate(route, state);
  }

/* . . . */
}

同样把这个 ​AuthGuard ​添加到“无组件的”管理路由,来同时保护它的所有子路由,而不是为每个路由单独添加这个 ​AuthGuard​。

const adminRoutes: Routes = [
  {
    path: 'admin',
    component: AdminComponent,
    canActivate: [AuthGuard],
    children: [
      {
        path: '',
        canActivateChild: [AuthGuard],
        children: [
          { path: 'crises', component: ManageCrisesComponent },
          { path: 'heroes', component: ManageHeroesComponent },
          { path: '', component: AdminDashboardComponent }
        ]
      }
    ]
  }
];

@NgModule({
  imports: [
    RouterModule.forChild(adminRoutes)
  ],
  exports: [
    RouterModule
  ]
})
export class AdminRoutingModule {}

CanDeactivate:处理未保存的更改

回到 “Heroes” 工作流,该应用会立即接受对英雄的每次更改,而不进行验证。

在现实世界,你可能不得不积累来自用户的更改,跨字段验证,在服务器上验证,或者把变更保持在待定状态,直到用户确认这一组字段或取消并还原所有变更为止。

当用户要导航离开时,你可以让用户自己决定该怎么处理这些未保存的更改。如果用户选择了取消,你就留下来,并允许更多改动。如果用户选择了确认,那就进行保存。

在保存成功之前,你还可以继续推迟导航。如果你让用户立即移到下一个界面,而保存却失败了(可能因为数据不符合有效性规则),你就会丢失该错误的上下文环境。

你需要用异步的方式等待,在服务器返回答复之前先停止导航。

CanDeactivate ​守卫能帮助你决定如何处理未保存的更改,以及如何处理。

取消与保存

用户在 ​CrisisDetailComponent ​中更新危机信息。与 ​HeroDetailComponent ​不同,用户的改动不会立即更新危机的实体对象。当用户按下了 Save 按钮时,应用就更新这个实体对象;如果按了 Cancel 按钮,那就放弃这些更改。

这两个按钮都会在保存或取消之后导航回危机列表。

cancel() {
  this.gotoCrises();
}

save() {
  this.crisis.name = this.editName;
  this.gotoCrises();
}

在这种情况下,用户可以点击 heroes 链接,取消,按下浏览器后退按钮,或者不保存就离开。

这个示例应用会弹出一个确认对话框,它会异步等待用户的响应,等用户给出一个明确的答复。

你也可以用同步的方式等用户的答复,阻塞代码。但如果能用异步的方式等待用户的答复,应用就会响应性更好,还能同时做别的事。

生成一个 ​Dialog ​服务,以处理用户的确认操作。

ng generate service dialog

为 ​DialogService ​添加一个 ​confirm()​ 方法,以提醒用户确认。​window.confirm​ 是一个阻塞型操作,它会显示一个模态对话框,并等待用户的交互。

import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';

/**
 * Async modal dialog service
 * DialogService makes this app easier to test by faking this service.
 * TODO: better modal implementation that doesn't use window.confirm
 */
@Injectable({
  providedIn: 'root',
})
export class DialogService {
  /**
   * Ask user to confirm an action. `message` explains the action and choices.
   * Returns observable resolving to `true`=confirm or `false`=cancel
   */
  confirm(message?: string): Observable<boolean> {
    const confirmation = window.confirm(message || 'Is it OK?');

    return of(confirmation);
  }
}

它返回observable,当用户最终决定了如何去做时,它就会被解析 —— 或者决定放弃更改直接导航离开(​true​),或者保留未完成的修改,留在危机编辑器中(​false​)。

生成一个守卫(guard),以检查组件(任意组件均可)中是否存在 ​canDeactivate()​ 方法。

ng generate guard can-deactivate

把下面的代码粘贴到守卫中。

import { Injectable } from '@angular/core';
import { CanDeactivate } from '@angular/router';
import { Observable } from 'rxjs';

export interface CanComponentDeactivate {
 canDeactivate: () => Observable<boolean> | Promise<boolean> | boolean;
}

@Injectable({
  providedIn: 'root',
})
export class CanDeactivateGuard implements CanDeactivate<CanComponentDeactivate> {
  canDeactivate(component: CanComponentDeactivate) {
    return component.canDeactivate ? component.canDeactivate() : true;
  }
}

守卫不需要知道哪个组件有 ​deactivate ​方法,它可以检测 ​CrisisDetailComponent ​组件有没有 ​canDeactivate()​ 方法并调用它。守卫在不知道任何组件 ​deactivate ​方法细节的情况下,就能让这个守卫重复使用。

另外,你也可以为 ​CrisisDetailComponent ​创建一个特定的 ​CanDeactivate ​守卫。在需要访问外部信息时,​canDeactivate()​ 方法为你提供了组件、​ActivatedRoute ​和 ​RouterStateSnapshot ​的当前实例。如果只想为这个组件使用该守卫,并且需要获取该组件属性或确认路由器是否允许从该组件导航出去时,这会非常有用。

import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { CanDeactivate,
         ActivatedRouteSnapshot,
         RouterStateSnapshot } from '@angular/router';

import { CrisisDetailComponent } from './crisis-center/crisis-detail/crisis-detail.component';

@Injectable({
  providedIn: 'root',
})
export class CanDeactivateGuard implements CanDeactivate<CrisisDetailComponent> {

  canDeactivate(
    component: CrisisDetailComponent,
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot
  ): Observable<boolean> | boolean {
    // Get the Crisis Center ID
    console.log(route.paramMap.get('id'));

    // Get the current URL
    console.log(state.url);

    // Allow synchronous navigation (`true`) if no crisis or the crisis is unchanged
    if (!component.crisis || component.crisis.name === component.editName) {
      return true;
    }
    // Otherwise ask the user with the dialog service and return its
    // observable which resolves to true or false when the user decides
    return component.dialogService.confirm('Discard changes?');
  }
}

看看 ​CrisisDetailComponent ​组件,它已经实现了对未保存的更改进行确认的工作流。

canDeactivate(): Observable<boolean> | boolean {
  // Allow synchronous navigation (`true`) if no crisis or the crisis is unchanged
  if (!this.crisis || this.crisis.name === this.editName) {
    return true;
  }
  // Otherwise ask the user with the dialog service and return its
  // observable which resolves to true or false when the user decides
  return this.dialogService.confirm('Discard changes?');
}

注意,​canDeactivate()​ 方法可以同步返回;如果没有危机,或者没有待处理的更改,它会立即返回 ​true​。但它也能返回一个 ​Promise ​或一个 ​Observable​,路由器也会等待它解析为真值(导航)或伪造(停留在当前路由上)。

往 ​crisis-center.routing.module.ts​ 的危机详情路由中用 ​canDeactivate ​数组添加一个 ​Guard​(守卫)。

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';

const crisisCenterRoutes: Routes = [
  {
    path: 'crisis-center',
    component: CrisisCenterComponent,
    children: [
      {
        path: '',
        component: CrisisListComponent,
        children: [
          {
            path: ':id',
            component: CrisisDetailComponent,
            canDeactivate: [CanDeactivateGuard]
          },
          {
            path: '',
            component: CrisisCenterHomeComponent
          }
        ]
      }
    ]
  }
];

@NgModule({
  imports: [
    RouterModule.forChild(crisisCenterRoutes)
  ],
  exports: [
    RouterModule
  ]
})
export class CrisisCenterRoutingModule { }

现在,你已经给了用户一个能保护未保存更改的安全守卫。

Resolve: 预先获取组件数据

在 ​Hero Detail​ 和 ​Crisis Detail​ 中,它们等待路由读取完对应的英雄和危机。

如果你在使用真实 api,很有可能数据返回有延迟,导致无法即时显示。在这种情况下,直到数据到达前,显示一个空的组件不是最好的用户体验。

最好使用解析器预先从服务器上获取完数据,这样在路由激活的那一刻数据就准备好了。还要在路由到此组件之前处理好错误。但当某个 ​id ​无法对应到一个危机详情时,就没办法处理它。这时最好把用户带回到“危机列表”中,那里显示了所有有效的“危机”。

总之,你希望的是只有当所有必要数据都已经拿到之后,才渲染这个路由组件。

导航前预先加载路由信息

目前,​CrisisDetailComponent ​会接收选中的危机。如果该危机没有找到,路由器就会导航回危机列表视图。

如果能在该路由将要激活时提前处理了这个问题,那么用户体验会更好。​CrisisDetailResolver ​服务可以接收一个 ​Crisis​,而如果这个 ​Crisis ​不存在,就会在激活该路由并创建 ​CrisisDetailComponent ​之前先行离开。

在 ​Crisis Center​ 特性区生成一个 ​CrisisDetailResolver ​服务文件。

ng generate service crisis-center/crisis-detail-resolver
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class CrisisDetailResolverService {

  constructor() { }

}

把 ​CrisisDetailComponent.ngOnInit()​ 中与危机检索有关的逻辑移到 ​CrisisDetailResolverService ​中。导入 ​Crisis ​模型、​CrisisService ​和 ​Router ​以便让你可以在找不到指定的危机时导航到别处。

为了更明确一点,可以实现一个带有 ​Crisis ​类型的 ​Resolve ​接口。

注入 ​CrisisService ​和 ​Router​,并实现 ​resolve()​ 方法。该方法可以返回一个 ​Promise​、一个 ​Observable ​来支持异步方式,或者直接返回一个值来支持同步方式。

CrisisService.getCrisis()​ 方法返回一个可观察对象,以防止在数据获取完之前加载本路由。

如果它没有返回有效的 ​Crisis​,就会返回一个 ​Observable​,以取消以前到 ​CrisisDetailComponent ​的在途导航,并把用户导航回 ​CrisisListComponent​。修改后的 ​resolver ​服务是这样的:

import { Injectable } from '@angular/core';
import {
  Router, Resolve,
  RouterStateSnapshot,
  ActivatedRouteSnapshot
} from '@angular/router';
import { Observable, of, EMPTY } from 'rxjs';
import { mergeMap } from 'rxjs/operators';

import { CrisisService } from './crisis.service';
import { Crisis } from './crisis';

@Injectable({
  providedIn: 'root',
})
export class CrisisDetailResolverService implements Resolve<Crisis> {
  constructor(private cs: CrisisService, private router: Router) {}

  resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Crisis> | Observable<never> {
    const id = route.paramMap.get('id')!;

    return this.cs.getCrisis(id).pipe(
      mergeMap(crisis => {
        if (crisis) {
          return of(crisis);
        } else { // id not found
          this.router.navigate(['/crisis-center']);
          return EMPTY;
        }
      })
    );
  }
}

把这个解析器(resolver)导入到 ​crisis-center-routing.module.ts​ 中,并往 ​CrisisDetailComponent ​的路由配置中添加一个 ​resolve ​对象。

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: 'crisis-center',
    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 { }

CrisisDetailComponent ​不应该再去获取这个危机的详情。你只要重新配置路由,就可以修改从哪里获取危机的详情。把 ​CrisisDetailComponent ​改成从 ​ActivatedRoute.data.crisis​ 属性中获取危机详情,这正是你重新配置路由的恰当时机。

ngOnInit() {
  this.route.data
    .subscribe(data => {
      const crisis: Crisis = data['crisis'];
      this.editName = crisis.name;
      this.crisis = crisis;
    });
}

回顾以下三个重要点:

  1. 路由器的这个 ​Resolve ​接口是可选的。​CrisisDetailResolverService ​没有继承自某个基类。路由器只要找到了这个方法,就会调用它。
  2. 路由器会在用户可以导航的任何情况下调用该解析器,这样你就不用针对每个用例都编写代码了。
  3. 在任何一个解析器中返回空的 ​Observable ​就会取消导航。

与里程碑相关的危机中心代码如下。

  • app.component.html
  • <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>
  • crisis-center-home.component.html
  • <h3>Welcome to the Crisis Center</h3>
  • crisis-center.component.html
  • <h2>Crisis Center</h2>
    <router-outlet></router-outlet>
  • 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: 'crisis-center',
        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-list.component.html
  • <ul class="crises">
      <li *ngFor="let crisis of crises$ | async" [class.selected]="crisis.id === selectedId">
        <a [routerLink]="[crisis.id]">
          <span class="badge">{{ crisis.id }}</span>{{ crisis.name }}
        </a>
      </li>
    </ul>
    
    <router-outlet></router-outlet>
  • crisis-list.component.ts
  • import { Component, OnInit } from '@angular/core';
    import { ActivatedRoute } from '@angular/router';
    
    import { CrisisService } from '../crisis.service';
    import { Crisis } from '../crisis';
    import { Observable } from 'rxjs';
    import { switchMap } from 'rxjs/operators';
    
    @Component({
      selector: 'app-crisis-list',
      templateUrl: './crisis-list.component.html',
      styleUrls: ['./crisis-list.component.css']
    })
    export class CrisisListComponent implements OnInit {
      crises$!: Observable<Crisis[]>;
      selectedId = 0;
    
      constructor(
        private service: CrisisService,
        private route: ActivatedRoute
      ) {}
    
      ngOnInit() {
        this.crises$ = this.route.paramMap.pipe(
          switchMap(params => {
            this.selectedId = parseInt(params.get('id')!, 10);
            return this.service.getCrises();
          })
        );
      }
    }
  • crisis-detail.component.html
  • <div *ngIf="crisis">
      <h3>{{ editName }}</h3>
      <p>Id: {{ crisis.id }}</p>
      <label for="crisis-name">Crisis name: </label>
      <input type="text" id="crisis-name" [(ngModel)]="editName" placeholder="name"/>
      <div>
        <button type="button" (click)="save()">Save</button>
        <button type="button" (click)="cancel()">Cancel</button>
      </div>
    </div>
  • crisis-detail.component.ts
  • import { Component, OnInit } from '@angular/core';
    import { ActivatedRoute, Router } from '@angular/router';
    import { Observable } from 'rxjs';
    
    import { Crisis } from '../crisis';
    import { DialogService } from '../../dialog.service';
    
    @Component({
      selector: 'app-crisis-detail',
      templateUrl: './crisis-detail.component.html',
      styleUrls: ['./crisis-detail.component.css']
    })
    export class CrisisDetailComponent implements OnInit {
      crisis!: Crisis;
      editName = '';
    
      constructor(
        private route: ActivatedRoute,
        private router: Router,
        public dialogService: DialogService
      ) {}
    
      ngOnInit() {
        this.route.data
          .subscribe(data => {
            const crisis: Crisis = data['crisis'];
            this.editName = crisis.name;
            this.crisis = crisis;
          });
      }
    
      cancel() {
        this.gotoCrises();
      }
    
      save() {
        this.crisis.name = this.editName;
        this.gotoCrises();
      }
    
      canDeactivate(): Observable<boolean> | boolean {
        // Allow synchronous navigation (`true`) if no crisis or the crisis is unchanged
        if (!this.crisis || this.crisis.name === this.editName) {
          return true;
        }
        // Otherwise ask the user with the dialog service and return its
        // observable which resolves to true or false when the user decides
        return this.dialogService.confirm('Discard changes?');
      }
    
      gotoCrises() {
        const crisisId = this.crisis ? this.crisis.id : null;
        // Pass along the crisis id if available
        // so that the CrisisListComponent can select that crisis.
        // Add a totally useless `foo` parameter for kicks.
        // Relative navigation back to the crises
        this.router.navigate(['../', { id: crisisId, foo: 'foo' }], { relativeTo: this.route });
      }
    }
  • crisis-detail-resolver.service.ts
  • import { Injectable } from '@angular/core';
    import {
      Router, Resolve,
      RouterStateSnapshot,
      ActivatedRouteSnapshot
    } from '@angular/router';
    import { Observable, of, EMPTY } from 'rxjs';
    import { mergeMap } from 'rxjs/operators';
    
    import { CrisisService } from './crisis.service';
    import { Crisis } from './crisis';
    
    @Injectable({
      providedIn: 'root',
    })
    export class CrisisDetailResolverService implements Resolve<Crisis> {
      constructor(private cs: CrisisService, private router: Router) {}
    
      resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Crisis> | Observable<never> {
        const id = route.paramMap.get('id')!;
    
        return this.cs.getCrisis(id).pipe(
          mergeMap(crisis => {
            if (crisis) {
              return of(crisis);
            } else { // id not found
              this.router.navigate(['/crisis-center']);
              return EMPTY;
            }
          })
        );
      }
    }
  • crisis.service.ts
  • import { BehaviorSubject } from 'rxjs';
    import { map } from 'rxjs/operators';
    
    import { Injectable } from '@angular/core';
    import { MessageService } from '../message.service';
    import { Crisis } from './crisis';
    import { CRISES } from './mock-crises';
    
    @Injectable({
      providedIn: 'root',
    })
    export class CrisisService {
      static nextCrisisId = 100;
      private crises$: BehaviorSubject<Crisis[]> = new BehaviorSubject<Crisis[]>(CRISES);
    
      constructor(private messageService: MessageService) { }
    
      getCrises() { return this.crises$; }
    
      getCrisis(id: number | string) {
        return this.getCrises().pipe(
          map(crises => crises.find(crisis => crisis.id === +id)!)
        );
      }
    
    }
  • dialog.service.ts
  • import { Injectable } from '@angular/core';
    import { Observable, of } from 'rxjs';
    
    /**
     * Async modal dialog service
     * DialogService makes this app easier to test by faking this service.
     * TODO: better modal implementation that doesn't use window.confirm
     */
    @Injectable({
      providedIn: 'root',
    })
    export class DialogService {
      /**
       * Ask user to confirm an action. `message` explains the action and choices.
       * Returns observable resolving to `true`=confirm or `false`=cancel
       */
      confirm(message?: string): Observable<boolean> {
        const confirmation = window.confirm(message || 'Is it OK?');
    
        return of(confirmation);
      }
    }

路由守卫

  • auth.guard.ts
  • import { Injectable } from '@angular/core';
    import {
      CanActivate, Router,
      ActivatedRouteSnapshot,
      RouterStateSnapshot,
      CanActivateChild,
      UrlTree
    } from '@angular/router';
    import { AuthService } from './auth.service';
    
    @Injectable({
      providedIn: 'root',
    })
    export class AuthGuard implements CanActivate, CanActivateChild {
      constructor(private authService: AuthService, private router: Router) {}
    
      canActivate(
        route: ActivatedRouteSnapshot,
        state: RouterStateSnapshot): true|UrlTree {
        const url: string = state.url;
    
        return this.checkLogin(url);
      }
    
      canActivateChild(
        route: ActivatedRouteSnapshot,
        state: RouterStateSnapshot): true|UrlTree {
        return this.canActivate(route, state);
      }
    
      checkLogin(url: string): true|UrlTree {
        if (this.authService.isLoggedIn) { return true; }
    
        // Store the attempted URL for redirecting
        this.authService.redirectUrl = url;
    
        // Redirect to the login page
        return this.router.parseUrl('/login');
      }
    }
  • can-deactivate.guard.ts
  • import { Injectable } from '@angular/core';
    import { CanDeactivate } from '@angular/router';
    import { Observable } from 'rxjs';
    
    export interface CanComponentDeactivate {
     canDeactivate: () => Observable<boolean> | Promise<boolean> | boolean;
    }
    
    @Injectable({
      providedIn: 'root',
    })
    export class CanDeactivateGuard implements CanDeactivate<CanComponentDeactivate> {
      canDeactivate(component: CanComponentDeactivate) {
        return component.canDeactivate ? component.canDeactivate() : true;
      }
    }

查询参数及片段

在路由参数部分,你只需要处理该路由的专属参数。但是,你也可以用查询参数来获取对所有路由都可用的可选参数。

片段可以引用页面中带有特定 ​id ​属性的元素。

修改 ​AuthGuard ​以提供 ​session_id ​查询参数,在导航到其它路由后,它还会存在。

再添加一个锚点(​A​)元素,来让你能跳转到页面中的正确位置。

为 ​router.navigate()​ 方法添加一个 ​NavigationExtras ​对象,用来导航到 ​/login​ 路由。

import { Injectable } from '@angular/core';
import {
  CanActivate, Router,
  ActivatedRouteSnapshot,
  RouterStateSnapshot,
  CanActivateChild,
  NavigationExtras,
  UrlTree
} from '@angular/router';
import { AuthService } from './auth.service';

@Injectable({
  providedIn: 'root',
})
export class AuthGuard implements CanActivate, CanActivateChild {
  constructor(private authService: AuthService, private router: Router) {}

  canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): true|UrlTree {
    const url: string = state.url;

    return this.checkLogin(url);
  }

  canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): true|UrlTree {
    return this.canActivate(route, state);
  }

  checkLogin(url: string): true|UrlTree {
    if (this.authService.isLoggedIn) { return true; }

    // Store the attempted URL for redirecting
    this.authService.redirectUrl = url;

    // Create a dummy session id
    const sessionId = 123456789;

    // Set our navigation extras object
    // that contains our global query params and fragment
    const navigationExtras: NavigationExtras = {
      queryParams: { session_id: sessionId },
      fragment: 'anchor'
    };

    // Redirect to the login page with extras
    return this.router.createUrlTree(['/login'], navigationExtras);
  }
}

还可以在导航之间保留查询参数和片段,而无需再次在导航中提供。在 ​LoginComponent ​中的 ​router.navigate()​ 方法中,添加一个对象作为第二个参数,该对象提供了 ​queryParamsHandling ​和 ​preserveFragment​,用于传递当前的查询参数和片段到下一个路由。

// Set our navigation extras object
// that passes on our global query params and fragment
const navigationExtras: NavigationExtras = {
  queryParamsHandling: 'preserve',
  preserveFragment: true
};

// Redirect the user
this.router.navigate([redirectUrl], navigationExtras);

queryParamsHandling ​特性还提供了 ​merge ​选项,它将会在导航时保留当前的查询参数,并与其它查询参数合并。

要在登录后导航到 Admin Dashboard 路由,请更新 ​admin-dashboard.component.ts​ 以处理这些查询参数和片段。

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@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>;

  constructor(private route: ActivatedRoute) {}

  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'));
  }
}

查询参数和片段可通过 ​Router ​服务的 ​routerState ​属性使用。和路由参数类似,全局查询参数和片段也是 ​Observable ​对象。在修改过的英雄管理组件中,你将借助 ​AsyncPipe ​直接把 ​Observable ​传给模板。

按照下列步骤试验下:点击 Admin 按钮,它会带着你提供的 ​queryParamMap ​和 ​fragment ​跳转到登录页。点击 Login 按钮,你就会被重定向到 ​Admin Dashboard​ 页。注意,它仍然带着上一步提供的 ​queryParamMap ​和 ​fragment​。

你可以用这些持久化信息来携带需要为每个页面都提供的信息,如认证令牌或会话的 ID 等。

“查询参数”和“片段”也可以分别用 ​RouterLink ​中的 queryParamsHandling 和 preserveFragment 保存。