路由与导航

在用户使用应用程序时,Angular的路由器能让用户从一个视图导航到另一个视图。

本章覆盖了该路由器的主要特性。我们通过一个小型应用的成长演进来讲解它。参见在线例子 / 可下载的例子

要看到这个在线例子中浏览器地址栏的URL变化情况,请点击右上角的图标,在Plunker编辑器中打开它,接下来在弹出的预览窗口中点击右上角的蓝色'X'按钮就可以了。

pop out the window
pop out the window

概览

浏览器具有我们熟悉的导航模式:

Angular的Router(即“路由器”)借鉴了这个模型。它把浏览器中的URL看做一个操作指南, 据此导航到一个由客户端生成的视图,并可以把参数传给支撑视图的相应组件,帮它决定具体该展现哪些内容。 我们可以为页面中的链接绑定一个路由,这样,当用户点击链接时,就会导航到应用中相应的视图。 当用户点击按钮、从下拉框中选取,或响应来自任何地方的事件时,我们也可以在代码控制下进行导航。 路由器还在浏览器的历史日志中记录下这些活动,这样浏览器的前进和后退按钮也能照常工作。

目录

Contents

基础知识

本章是包括一系列里程碑,从一个单模块、两个页面的简单程序逐步走向带有多个子路由的多视图设计。

在接触细节之前,我们先来介绍关于路由的一些核心概念。

<base href>

大多数带路由的应用都要在index.html<head>标签下先添加一个<base>元素,来告诉路由器该如何合成导航用的URL。

如果app文件夹是该应用的根目录(就像我们的范例应用一样),那就把href的值设置为下面这样:

src/index.html (base-href)

<base href="/">

从路由库中导入

Angular的路由器是一个可选的服务,它用来呈现指定的URL所对应的视图。 它并不是Angular核心库的一部分,而是在它自己的@angular/router包中。 像其它Angular包一样,我们可以从它导入所需的一切。

src/app/app.module.ts (import)

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

我们将会在后面详细讲解其它选项。

配置

每个带路由的Angular应用都有一个Router(路由器)服务的单例对象。 当浏览器的URL变化时,路由器会查找对应的Route(路由),并据此决定该显示哪个组件。

路由器需要先配置才会有路由信息。 下面的例子创建了四个路由定义,并用RouterModule.forRoot方法来配置路由器, 并把它的返回值添加到AppModuleimports数组中。

src/app/app.module.ts (excerpt)

const appRoutes: Routes = [
  { path: 'crisis-center', component: CrisisListComponent },
  { path: 'hero/:id',      component: HeroDetailComponent },
  {
    path: 'heroes',
    component: HeroListComponent,
    data: { title: 'Heroes List' }
  },
  { path: '',
    redirectTo: '/heroes',
    pathMatch: 'full'
  },
  { path: '**', component: PageNotFoundComponent }
];

@NgModule({
  imports: [
    RouterModule.forRoot(appRoutes)
    // other imports here
  ],
  ...
})
export class AppModule { }

这里的路由树组appRoutes描述如何进行导航。 把它传给RouterModule.forRoot方法并传给本模块的imports数组就可以配置路由器。

每个Route都会把一个URL的path映射到一个组件。 注意,path不能以斜杠(/开头。 路由器会为解析和构建最终的URL,这样当我们在应用的多个视图之间导航时,可以任意使用相对路径和绝对路径。

第一个路由中的:id是一个路由参数的令牌(Token)。比如/hero/42这个URL中,“42”就是id参数的值。 此URL对应的HeroDetailComponent组件将据此查找和展现id为42的英雄。 在本章中稍后的部分,我们将会学习关于路由参数的更多知识。

第三个路由中的data属性用来存放于每个具体路由有关的任意信息。该数据可以被任何一个激活路由访问,并能用来保存诸如 页标题、面包屑以及其它静态只读数据。本章稍后的部分,我们将使用resolve守卫来获取动态数据。

第四个路由中的空路径('')表示应用的默认路径,当URL为空时就会访问那里,因此它通常会作为起点。 这个默认路由会重定向到URL /heroes,并显示HeroesListComponent

最后一个路由中的**路径是一个通配符。当所请求的URL不匹配前面定义的路由表中的任何路径时,路由器就会选择此路由。 这个特性可用于显示“404 - Not Found”页,或自动重定向到其它路由。

这些路由的定义顺序是刻意如此设计的。路由器使用先匹配者优先的策略来匹配路由,所以,具体路由应该放在通用路由的前面。在上面的配置中,带静态路径的路由被放在了前面,后面是空路径路由,因此它会作为默认路由。而通配符路由被放在最后面,这是因为它能匹配上每一个URL,因此应该只有在前面找不到其它能匹配的路由时才匹配它。

路由出口

有了这份配置,当本应用在浏览器中的URL变为/heroes时,路由器就会匹配到pathheroesRoute,并在宿主视图中的RouterOutlet之后显示HeroListComponent组件。

<router-outlet></router-outlet>
<!-- Routed views go here -->

现在,我们已经有了配置好的一些路由,还找到了渲染它们的地方,但又该如何导航到它呢?固然,从浏览器的地址栏直接输入URL也能做到,但是大多数情况下,导航是某些用户操作的结果,比如点击一个A标签。

考虑下列模板:

template: `
  <h1>Angular Router</h1>
  <nav>
    <a routerLink="/crisis-center" routerLinkActive="active">Crisis Center</a>
    <a routerLink="/heroes" routerLinkActive="active">Heroes</a>
  </nav>
  <router-outlet></router-outlet>
`

a标签上的RouterLink指令让路由器得以控制这个a元素。 这里的导航路径是固定的,因此可以把一个字符串赋给routerLink(“一次性”绑定)。

如果需要更加动态的导航路径,那就把它绑定到一个返回链接参数数组的模板表达式。 路由器会把这个数组解析成完整的URL。

每个a标签上的RouterLinkActive指令可以帮用户在外观上区分出当前选中的“活动”路由。 当与它关联的RouterLink被激活时,路由器会把CSS类active添加到这个元素上。 我们可以把该指令添加到a元素或它的父元素上。

路由器状态

在导航时的每个生命周期成功完成时,路由器会构建出一个ActivatedRoute组成的树,它表示路由器的当前状态。 我们可以在应用中的任何地方用Router服务及其routerState属性来访问当前的RouterState值。

路由器状态为我们提供了从任意激活路由开始向上或向下遍历路由树的一种方式,以获得关于父、子、兄弟路由的信息。

总结一下

该应用有一个配置过的路由器。 外壳组件中有一个RouterOutlet,它能显示路由器所生成的视图。 它还有一些RouterLink,用户可以点击它们,来通过路由器进行导航。

下面是一些路由器中的关键词汇及其含义:

路由器部件

含义

Router(路由器)

为激活的URL显示应用组件。管理从一个组件到另一个组件的导航

RouterModule(路由器模块)

一个独立的Angular模块,用于提供所需的服务提供商,以及用来在应用视图之间进行导航的指令。

Routes(路由数组)

定义了一个路由数组,每一个都会把一个URL路径映射到一个组件。

Route(路由)

定义路由器该如何根据URL模式(pattern)来导航到组件。大多数路由都由路径和组件类构成。

RouterOutlet(路由出口)

该指令(<router-outlet>)用来标记出路由器该在哪里显示视图。

RouterLink(路由链接)

该指令用来把一个可点击的HTML元素绑定到路由。 点击带有绑定到字符串链接参数数组routerLink指令的元素就会触发一次导航。

RouterLinkActive(活动路由链接)

当HTML元素上或元素内的routerLink变为激活或非激活状态时,该指令为这个HTML元素添加或移除CSS类。

ActivatedRoute(激活的路由)

为每个路由组件提供提供的一个服务,它包含特定于路由的信息,比如路由参数、静态数据、解析数据、全局查询参数和全局碎片(fragment)。

RouterState(路由器状态)

路由器的当前状态包含了一棵由程序中激活的路由构成的树。它包含一些用于遍历路由树的快捷方法。

链接参数数组

这个数组会被路由器解释成一个路由操作指南。我们可以把一个RouterLink绑定到该数组,或者把它作为参数传给Router.navigate方法。

路由组件

一个带有RouterOutlet的Angular组件,它根据路由器的导航来显示相应的视图。

范例应用

本章要讲的是如何开发一个带路由的多页面应用。 接下来,我们会重点讲它的设计决策,并描述路由的关键特性,比如:

如果打算一步步构建出本应用,本章就会经过一系列里程碑。 但是,本章并不是一个教程,它隐藏了构造Angular应用的细节,那些细节会在本文档的其它地方展开。

本应用的最终版源码可以在在线例子 / 可下载的例子中查看和下载。

范例程序的动图

假设本程序会用来帮助“英雄管理局”运行他们的业务。 英雄们需要找工作,而“英雄管理局”为它们寻找待解决的危机。

本应用具有三个主要的特性区:

  1. 危机中心用于维护要指派给英雄的危机列表。

  2. 英雄区用于维护管理局雇佣的英雄列表。

  3. 管理区会管理危机和英雄的列表。

点击在线例子 / 可下载的例子试用一下。

等应用热身完毕,我们就会看到一排导航按钮,以及一个英雄列表视图。

Hero List

选择其中之一,该应用就会把我们带到此英雄的编辑页面。

Crisis Center Detail

修改完名字,再点击“后退”按钮,我们又回到了英雄列表页,其中显示的英雄名已经变了。注意,对名字的修改会立即生效。

另外我们也可以点击浏览器本身的后退按钮,这样也同样会回到英雄列表页。 在Angular应用中导航也会和标准的Web导航一样更新浏览器中的历史。

现在,点击危机中心链接,前往危机列表页。

Crisis Center List

选择其中之一,该应用就会把我们带到此危机的编辑页面。 危机详情出现在了当前页的子视图区,也就是在列表的紧下方。

修改危机的名称。 注意,危机列表中的相应名称并没有修改。

Crisis Center Detail

这和英雄详情页略有不同。英雄详情会立即保存我们所做的更改。 而危机详情页中,我们的更改都是临时的 —— 除非按“保存”按钮保存它们,或者按“取消”按钮放弃它们。 这两个按钮都会导航回危机中心,显示危机列表。

先不要点击这些按钮。 而是点击浏览器的后退按钮,或者点击“Heroes”链接。

我们会看到弹出了一个对话框。

Confirm Dialog

我们可以回答“确定”以放弃这些更改,或者回答“取消”来继续编辑。

这种行为的幕后是路由器的CanDeactivate守卫。 该守卫让我们有机会进行清理工作或在离开当前视图之前请求用户的许可。

AdminLogin按钮用于演示路由器的其它能力,本章稍后的部分会讲解它们。我们现在先不管它。

我们这就开始本应用的第一个里程碑。

里程碑1:从路由器开始

开始本应用的一个简版,它在两个空路由之间导航。

App in action

设置<base href>

路由器使用浏览器的history.pushState进行导航。 感谢pushState!有了它,我们就能按所期望的样子来显示应用内部的URL路径,比如:localhost:3000/crisis-center。虽然我们使用的全部是客户端合成的视图,但应用内部的这些URL看起来和来自服务器的没有什么不同。

现代HTML 5浏览器是最早支持pushState的,这也就是很多人喜欢把这种URL称作“HTML 5风格的”URL的原因。

HTML 5风格的导航是路由器的默认值。请到下面的附录浏览器URL风格中学习为什么首选“HTML 5”风格、如何调整它的行为,以及如何在必要时切换回老式的hash(#)风格。

我们必须往本应用的index.html添加一个<base href> 元素,这样pushState才能正常工作。 当引用CSS文件、脚本和图片时,浏览器会用<base href>的值作为相对URL的前缀。

<base>元素添加到<head>元素中。 如果app目录是应用的根目录,对于本应用,可以像这样设置index.html中的href值:

src/index.html (base-href)

<base href="/">
Live example note

像Plunker这样的在线编程环境会动态设置应用的基地址(base href),因此我们没办法指定固定的地址。 这就是为什么我们要用一个脚本动态写入<base>标签,而不是直接写<base href...>

<script>document.write('<base href="' + document.location + '" />');</script>

我们只应该在在线例子这种情况下使用这种小花招,不要把它用到产品的正式代码中。

从路由库中导入

先从路由库导入一些符号。 路由器在它自己的@angular/router包中。 它不是Angular内核的一部分。该路由器是可选的服务,这是因为并不是所有应用都需要路由,并且,如果需要,你还可能需要另外的路由库。

通过一些路由来配置路由器,我们可以教它如何进行导航。

定义路由

路由器必须用“路由定义”的列表进行配置。

我们的第一个配置中定义了由两个路由构成的数组,它们分别通过路径(path)导航到了CrisisListComponentHeroListComponent组件。

每个定义都被翻译成了一个Route对象。该对象有一个path字段,表示该路由中的URL路径部分,和一个component字段,表示与该路由相关联的组件。

当浏览器的URL变化时或在代码中告诉路由器导航到一个路径时,路由器就会翻出它用来保存这些路由定义的注册表。

直白的说,我们可以这样解释第一个路由:

下面是第一个配置。我们将路由数组传递到RouterModule.forRoot方法,该方法返回一个包含已配置的Router服务提供商模块和一些其它路由包需要的服务提供商。应用启动时,Router将在当前浏览器URL的基础上进行初始导航。

src/app/app.module.ts (first-config)

import { NgModule }             from '@angular/core';
import { BrowserModule }        from '@angular/platform-browser';
import { FormsModule }          from '@angular/forms';
import { RouterModule, Routes } from '@angular/router';

import { AppComponent }          from './app.component';
import { CrisisListComponent }   from './crisis-list.component';
import { HeroListComponent }     from './hero-list.component';

const appRoutes: Routes = [
  { path: 'crisis-center', component: CrisisListComponent },
  { path: 'heroes', component: HeroListComponent },
];

@NgModule({
  imports: [
    BrowserModule,
    FormsModule,
    RouterModule.forRoot(appRoutes)
  ],
  declarations: [
    AppComponent,
    HeroListComponent,
    CrisisListComponent,
  ],
  bootstrap: [ AppComponent ]
})
export class AppModule { }

作为简单的路由配置,将添加配置好的RouterModuleAppModule中就足够了。 随着应用的成长,我们将需要将路由配置重构到单独的文件,并创建路由模块 - 一种特别的、专门为特性模块的路由器服务的服务模块

AppModule中提供RouterModule,让该路由器在应用的任何地方都能被使用。

AppComponent外壳组件

根组件AppComponent是本应用的壳。它在顶部有一个标题、一个带两个链接的导航条,在底部有一个路由器出口,路由器会在它所指定的位置上把视图切入或调出页面。就像下图中所标出的:

Shell

该组件所对应的模板是这样的:

template: `
  <h1>Angular Router</h1>
  <nav>
    <a routerLink="/crisis-center" routerLinkActive="active">Crisis Center</a>
    <a routerLink="/heroes" routerLinkActive="active">Heroes</a>
  </nav>
  <router-outlet></router-outlet>
`

RouterOutlet

RouterOutlet是一个来自路由库的组件。 路由器会在<router-outlet>标签中显示视图。

一个模板中只能有一个未命名的<router-outlet>。 但路由器可以支持多个命名的出口(outlet),将来我们会涉及到这部分特性。

在出口上方的A标签中,有一个绑定RouterLink指令的属性绑定,就像这样:routerLink="..."。我们从路由库中导入了RouterLink

例子中的每个链接都有一个字符串型的路径,也就是我们以前配置过的路由路径,但还没有指定路由参数。

我们还可以通过提供查询字符串参数为RouterLink提供更多情境信息,或提供一个URL片段(Fragment或hash)来跳转到本页面中的其它区域。 查询字符串可以由[queryParams]绑定来提供,它需要一个对象型参数(如{ name: 'value' }),而URL片段需要一个绑定到[fragment]的单一值。

还可以到后面的附录中学习如何使用链接参数数组

routerLinkActive绑定

每个A标签还有一个到RouterLinkActive指令的属性绑定,就像routerLinkActive="..."

等号(=)右侧的模板表达式包含用空格分隔的一些CSS类。当路由激活时路由器就会把它们添加到此链接上(反之则移除)。我们还可以把RouterLinkActive指令绑定到一个CSS类组成的数组,如[routerLinkActive]="['...']"

RouterLinkActive指令会基于当前的RouterState对象来为激活的RouterLink切换CSS类。 这会一直沿着路由树往下进行级联处理,所以父路由链接和子路由链接可能会同时激活。 要改变这种行为,可以把[routerLinkActiveOptions]绑定到{exact: true}表达式。 如果使用了{ exact: true },那么只有在其URL与当前URL精确匹配时才会激活指定的RouterLink

路由器指令集

RouterLinkRouterLinkActiveRouterOutlet是由RouterModule包提供的指令。 现在它已经可用于我们自己的模板中。

app.component.ts目前是这样的:

src/app/app.component.ts (excerpt)

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

@Component({
  selector: 'my-app',
  template: `
    <h1>Angular Router</h1>
    <nav>
      <a routerLink="/crisis-center" routerLinkActive="active">Crisis Center</a>
      <a routerLink="/heroes" routerLinkActive="active">Heroes</a>
    </nav>
    <router-outlet></router-outlet>
  `
})
export class AppComponent { }

通配符路由

我们以前在应用中创建过两个路由,一个是/crisis-center,另一个是/heroes。 所有其它URL都会导致路由器抛出错误,并让应用崩溃。

可以添加一个通配符路由来拦截所有无效的URL,并优雅的处理它们。 通配符路由的path是两个星号(**),它会匹配任何 URL。 当路由器匹配不上以前定义的那些路由时,它就会选择这个路由。 通配符路由可以导航到自定义的“404 Not Found”组件,也可以重定向到一个现有路由。

路由器使用先匹配者优先的策略来选择路由。 通配符路由是路由配置中最没有特定性的那个,因此务必确保它是配置中的最后一个路由。

要测试本特性,请往HeroListComponent的模板中添加一个带RouterLink的按钮,并且把它的链接设置为"/sidekicks"

src/app/hero-list.component.ts (excerpt)

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

@Component({
  template: `
    <h2>HEROES</h2>
    <p>Get your heroes here</p>

    <button routerLink="/sidekicks">Go to sidekicks</button>
  `
})
export class HeroListComponent { }

当用户点击该按钮时,应用就会失败,因为我们尚未定义过"/sidekicks"路由。

不要添加"/sidekicks"路由,而是定义一个“通配符”路由,让它直接导航到PageNotFoundComponent组件。

src/app/app.module.ts (wildcard)

{ path: '**', component: PageNotFoundComponent }

创建PageNotFoundComponent,以便在用户访问无效网址时显示它。

src/app/not-found.component.ts (404 component)

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

@Component({
  template: '<h2>Page not found</h2>'
})
export class PageNotFoundComponent {}

像其它组件一样,把PageNotFoundComponent添加到AppModule的声明中。

现在,当用户访问/sidekicks或任何无效的URL时,浏览器就会显示“Page not found”。 浏览器的地址栏仍指向无效的URL。

默认路由设置为英雄列表

应用启动时,浏览器地址栏中的初始URL是这样的:

localhost:3000

它不能匹配任何已配置的路由,这表示当应用启动时,它不会显示任何组件。 用户必须点击一个链接来触发导航或者显示组件。

如果应用有一个默认路由显然会更好,它会立即显示英雄列表,就像用户点击了“Heroes”链接或者把localhost:3000/heroes粘贴进地址栏一样。

重定向路由

首选方案是添加一个redirect路由来把最初的相对路径('')转换成期望的默认路径(/heroes)。 浏览器地址栏会显示.../heroes,就像你直接导航到那里一样。

在通配符路由上方添加一个默认路由。 在下方的代码片段中,它出现在通配符路由的紧上方,展示了这个里程碑的完整appRoutes

src/app/app-routing.module.ts (appRoutes)

const appRoutes: Routes = [
  { path: 'crisis-center', component: CrisisListComponent },
  { path: 'heroes',        component: HeroListComponent },
  { path: '',   redirectTo: '/heroes', pathMatch: 'full' },
  { path: '**', component: PageNotFoundComponent }
];

重定向路由需要一个pathMatch属性,来告诉路由器如何用URL去匹配路由的路径,否则路由器就会报错。 在本应用中,路由器应该只有在完整的URL等于''时才选择HeroListComponent组件,因此我们要把pathMatch设置为'full'

从技术角度说,pathMatch = 'full'导致URL中剩下的、未匹配的部分必须等于''。 在这个例子中,跳转路由在一个顶级路由中,因此剩下的URL和完整的URL是一样的。

pathMatch的另一个可能的值是'prefix',它会告诉路由器:当剩下的URL以这个跳转路由中的prefix值开头时,就会匹配上这个跳转路由。

在这里不能这么做!如果pathMatch的值是'prefix',那么每个URL都会匹配上''

尝试把它设置为'prefix',然后点击Go to sidekicks按钮。别忘了,它是一个无效URL,本应显示“Page not found”页。 但是,我们看到了“英雄列表”页。在地址栏中输入一个无效的URL,我们又被路由到了/heroes每一个URL,无论有效与否,都会匹配上这个路由定义。

默认路由应该只有在整个URL等于''时才重定向到HeroListComponent,别忘了把重定向路由设置为pathMatch = 'full'

要了解更多,参见Victor Savkin的帖子关于重定向

“起步阶段”总结

我们得到了一个非常基本的、带导航的应用,当用户点击链接时,它能在两个视图之间切换。

我们已经学会了如何:

这个初学者应用的其它部分有点平淡无奇,从路由器的角度来看也很平淡。 如果你还是倾向于在这个里程碑里构建它们,参见下面的构建详情。

这个初学者应用的结构是这样的:

router-sample
src
app
app.component.ts
app.module.ts
crisis-list.component.ts
hero-list.component.ts
not-found.component.ts
main.ts
index.html
styles.css
tsconfig.json
node_modules ...
package.json

下面是当前里程碑中讨论过的文件列表:

  1. import { Component } from '@angular/core';
  2. @Component({
  3. selector: 'my-app',
  4. template: `
  5. <h1>Angular Router</h1>
  6. <nav>
  7. <a routerLink="/crisis-center" routerLinkActive="active">Crisis Center</a>
  8. <a routerLink="/heroes" routerLinkActive="active">Heroes</a>
  9. </nav>
  10. <router-outlet></router-outlet>
  11. `
  12. })
  13. export class AppComponent { }

里程碑 #2:路由模块

在原始的路由配置中,我们提供了仅有两个路由的简单配置来设置应用的路由。对于简单的路由,这没有问题。 随着应用的成长,我们使用更多路由器特征,比如守卫、解析器和子路由等,我们很自然想要重构路由。 建议将路由信息移到一个单独的特殊用途的模块,叫做路由模块

路由模块有一系列特性:

将路由配置重构为路由模块

/app目录下创建一个名叫app-routing.module.ts的文件,以包含这个路由模块。

导入CrisisListComponentHeroListComponent组件,就像app.module.ts中一样。然后把Router的导入语句和路由配置以及RouterModule.forRoot移入这个路由模块中。

遵循规约,添加一个AppRoutingModule类并导出它,以便稍后在AppModule中导入它。

最后,可以通过把它添加到该模块的exports数组中来再次导出RouterModule。 通过在AppModule中导入AppRoutingModule并再次导出RouterModule,那些声明在AppModule中的组件就可以访问路由指令了,比如RouterLinkRouterOutlet

做完这些之后,该文件变成了这样:

src/app/app-routing.module.ts

  1. import { NgModule } from '@angular/core';
  2. import { RouterModule, Routes } from '@angular/router';
  3. import { CrisisListComponent } from './crisis-list.component';
  4. import { HeroListComponent } from './hero-list.component';
  5. import { PageNotFoundComponent } from './not-found.component';
  6. const appRoutes: Routes = [
  7. { path: 'crisis-center', component: CrisisListComponent },
  8. { path: 'heroes', component: HeroListComponent },
  9. { path: '', redirectTo: '/heroes', pathMatch: 'full' },
  10. { path: '**', component: PageNotFoundComponent }
  11. ];
  12. @NgModule({
  13. imports: [
  14. RouterModule.forRoot(appRoutes)
  15. ],
  16. exports: [
  17. RouterModule
  18. ]
  19. })
  20. export class AppRoutingModule {}

接下来,修改app.module.ts文件,首先从app-routing.module.ts中导入新创建的AppRoutingModule, 然后把imports数组中的RouterModule.forRoot替换为AppRoutingModule

src/app/app.module.ts

  1. import { NgModule } from '@angular/core';
  2. import { BrowserModule } from '@angular/platform-browser';
  3. import { FormsModule } from '@angular/forms';
  4. import { AppComponent } from './app.component';
  5. import { AppRoutingModule } from './app-routing.module';
  6. import { CrisisListComponent } from './crisis-list.component';
  7. import { HeroListComponent } from './hero-list.component';
  8. import { PageNotFoundComponent } from './not-found.component';
  9. @NgModule({
  10. imports: [
  11. BrowserModule,
  12. FormsModule,
  13. AppRoutingModule
  14. ],
  15. declarations: [
  16. AppComponent,
  17. HeroListComponent,
  18. CrisisListComponent,
  19. PageNotFoundComponent
  20. ],
  21. bootstrap: [ AppComponent ]
  22. })
  23. export class AppModule { }

本章稍后的部分,我们将创建一个多路由模块,并讲解为何我们必须以正确的顺序导入那些路由模块

应用继续正常运行,我们可以把路由模块作为为每个特性模块维护路由配置的中心地方。

你需要路由模块吗?

路由模块在根模块或者特性模块替换了路由配置。在路由模块或者在模块内部配置路由,但不要同时在两处都配置。

路由模块是设计选择,它的价值在配置很复杂,并包含专门守卫和解析器服务时尤其明显。 在配置很简单时,它可能看起来很多余。

在配置很简单时,一些开发者跳过路由模块(例如AppRoutingModule),并将路由配置直接混合在关联模块中(比如AppModule )。

我们建议你选择其中一种模式,并坚持模式的一致性。

大多数开发者应该采用路由模块,以保持一致性。

它在配置复杂时,能确保代码干净。 它让测试特性模块更加容易。 它的存在突出了模块时被路由的事实。 开发者可以很自然的从路由模块中查找和扩展路由配置。

里程碑 #2 英雄特征区

我们刚刚学习了如何用RouterLink指令进行导航。接下来我们将到:

这个例子重写了《英雄指南》的“服务”部分的英雄列表特性,我们可以从在线例子 / 可下载的例子中赋值大部分代码过来。

下面是用户将看到的版本:

App in action

典型的应用具有多个特性区,每个特性区都专注于特定的业务用途。

虽然我们也可以把文件都放在src/app/目录下,但那样是不现实的,而且很难维护。 大部分开发人员更喜欢把每个特性区都放在它自己的目录下。

我们准备把应用拆分成多个不同的特性模块,每个特有模块都有自己的关注点。 然后,我们就会把它们导入到主模块中,并且在它们之间导航。

添加英雄管理功能

按照下列步骤:

src/app/heroes/heroes.module.ts (pre-routing)

  1. import { NgModule } from '@angular/core';
  2. import { CommonModule } from '@angular/common';
  3. import { FormsModule } from '@angular/forms';
  4. import { HeroListComponent } from './hero-list.component';
  5. import { HeroDetailComponent } from './hero-detail.component';
  6. import { HeroService } from './hero.service';
  7. @NgModule({
  8. imports: [
  9. CommonModule,
  10. FormsModule,
  11. ],
  12. declarations: [
  13. HeroListComponent,
  14. HeroDetailComponent
  15. ],
  16. providers: [ HeroService ]
  17. })
  18. export class HeroesModule {}

安排完这些,我们就有了四个英雄管理特性区的文件:

src/app/heroes
hero-detail.component.ts
hero-list.component.ts
hero.service.ts
heroes.module.ts

英雄特性区的路由需求

“英雄”特性有两个相互协作的组件,列表和详情。 列表视图是自给自足的,我们导航到它,它会自行获取英雄列表并显示它们。

详情视图就不同了。它要显示一个特定的英雄,但是它本身却无法知道显示哪一个,此信息必须来自外部。

当用户从列表中选择了一个英雄时,我们就导航到详情页以显示那个英雄。 通过把所选英雄的id编码进路由的URL中,就能告诉详情视图该显示哪个英雄。

英雄特性区的路由配置

heroes目录下创建一个新的heroes-routing.module.ts文件,使用的技术和以前创建AppRoutingModule时的一样。

src/app/heroes/heroes-routing.module.ts

  1. import { NgModule } from '@angular/core';
  2. import { RouterModule, Routes } from '@angular/router';
  3. import { HeroListComponent } from './hero-list.component';
  4. import { HeroDetailComponent } from './hero-detail.component';
  5. const heroesRoutes: Routes = [
  6. { path: 'heroes', component: HeroListComponent },
  7. { path: 'hero/:id', component: HeroDetailComponent }
  8. ];
  9. @NgModule({
  10. imports: [
  11. RouterModule.forChild(heroesRoutes)
  12. ],
  13. exports: [
  14. RouterModule
  15. ]
  16. })
  17. export class HeroRoutingModule { }

把路由模块文件和它对应的模块文件放在同一个目录下。 比如这里的heroes-routing.module.tsheroes.module.ts都位于src/app/heroes目录下。

将路由模块文件放到它相关的模块文件所在目录里。 这里,heroes-routing.module.tsheroes.module.ts都在app/heroes目录中。

从新位置src/app/heroes/目录中导入英雄相关的组件,定义两个“英雄管理”路由,并导出HeroRoutingModule类。

现在,我们有了Heroes模块的路由,还得在RouterModule中把它们注册给路由器,和AppRoutingModule中的做法几乎完全一样。

这里有少量但是关键的不同点。 在AppRoutingModule中,我们使用了静态的RouterModule.forRoot方法来注册我们的路由和全应用级服务提供商。 在特性模块中,我们要改用forChild静态方法。

只在根模块AppRoutingModule中调用RouterModule.forRoot(如果在AppModule中注册应用的顶级路由,那就在AppModule中调用)。 在其它模块中,我们就必须调用RouterModule.forChild方法来注册附属路由。

把路由模块添加到HeroesModule

我们在Heroes模块中从heroes-routing.module.ts中导入HeroRoutingModule,并注册其路由。

打开heroes.module.ts,从heroes-routing.module.ts中导入HeroRoutingModule并把它添加到HeroesModuleimports数组中。 写完后的HeroesModule是这样的:

src/app/heroes/heroes.module.ts

  1. import { NgModule } from '@angular/core';
  2. import { CommonModule } from '@angular/common';
  3. import { FormsModule } from '@angular/forms';
  4. import { HeroListComponent } from './hero-list.component';
  5. import { HeroDetailComponent } from './hero-detail.component';
  6. import { HeroService } from './hero.service';
  7. import { HeroRoutingModule } from './heroes-routing.module';
  8. @NgModule({
  9. imports: [
  10. CommonModule,
  11. FormsModule,
  12. HeroRoutingModule
  13. ],
  14. declarations: [
  15. HeroListComponent,
  16. HeroDetailComponent
  17. ],
  18. providers: [ HeroService ]
  19. })
  20. export class HeroesModule {}

移除重复的“英雄管理”路由

英雄类的路由目前定义在两个地方:HeroesRoutingModule中(并最终给HeroesModule)和AppRoutingModule中。

由特性模块提供的路由会被路由器再组合上它们所导入的模块的路由。 这让我们可以继续定义特性路由模块中的路由,而不用修改主路由配置。

但我们显然不会想把同一个路由定义两次,那就移除HeroListComponent的导入和来自app-routing.module.ts中的/heroes路由。

保留默认路由和通配符路由! 它们是应用程序顶层该自己处理的关注点。

src/app/app-routing.module.ts (v2)

import { NgModule }              from '@angular/core';
import { RouterModule, Routes }  from '@angular/router';

import { CrisisListComponent }   from './crisis-list.component';
// import { HeroListComponent }  from './hero-list.component';  // <-- delete this line
import { PageNotFoundComponent } from './not-found.component';

const appRoutes: Routes = [
  { path: 'crisis-center', component: CrisisListComponent },
  // { path: 'heroes',     component: HeroListComponent }, // <-- delete this line
  { path: '',   redirectTo: '/heroes', pathMatch: 'full' },
  { path: '**', component: PageNotFoundComponent }
];

@NgModule({
  imports: [
    RouterModule.forRoot(appRoutes)
  ],
  exports: [
    RouterModule
  ]
})
export class AppRoutingModule {}

把“英雄管理”模块导入到AppModule

英雄这个特性模块已经就绪,但应用仍然不知道HeroesModule的存在。 打开app.module.ts,并按照下述步骤修改它。

导入HeroesModule并且把它加到根模块AppModule@NgModule元数据中的imports数组中。

AppModuledeclarations中移除HeroListComponent,因为它现在已经改由HeroesModule提供了。 这一步很重要!因为一个组件只能声明在一个属主模块中。 这个例子中,Heroes模块就是Heroes组件的属主模块,而AppModule要通过导入HeroesModule才能使用这些组件。

最终,AppModule不再了解那些特定于“英雄”特性的知识,比如它的组件、路由细节等。 我们可以让“英雄”特性独立演化,添加更多的组件或各种各样的路由。 这是我们为每个特性区创建独立模块后获得的核心优势。

经过这些步骤,AppModule变成了这样:

src/app/app.module.ts

  1. import { NgModule } from '@angular/core';
  2. import { BrowserModule } from '@angular/platform-browser';
  3. import { FormsModule } from '@angular/forms';
  4. import { AppComponent } from './app.component';
  5. import { AppRoutingModule } from './app-routing.module';
  6. import { HeroesModule } from './heroes/heroes.module';
  7. import { CrisisListComponent } from './crisis-list.component';
  8. import { PageNotFoundComponent } from './not-found.component';
  9. @NgModule({
  10. imports: [
  11. BrowserModule,
  12. FormsModule,
  13. HeroesModule,
  14. AppRoutingModule
  15. ],
  16. declarations: [
  17. AppComponent,
  18. CrisisListComponent,
  19. PageNotFoundComponent
  20. ],
  21. bootstrap: [ AppComponent ]
  22. })
  23. export class AppModule { }

导入模块的顺序很重要

看看该模块的imports数组。注意,AppRoutingModule最后一个。最重要的是,它位于HeroesModule之后。

src/app/app.module.ts (module-imports)

imports: [
  BrowserModule,
  FormsModule,
  HeroesModule,
  AppRoutingModule
],

路由配置的顺序很重要。 路由器会接受第一个匹配上导航所要求的路径的那个路由。

当所有路由都在同一个AppRoutingModule时,我们要把默认路由和通配符路由放在最后(这里是在/heroes路由后面), 这样路由器才有机会匹配到/heroes路由,否则它就会先遇到并匹配上该通配符路由,并导航到“页面未找到”路由。

这些路由不再位于单一文件中。他们分布在两个不同的模块中:AppRoutingModuleHeroesRoutingModule

每个路由模块都会根据导入的顺序把自己的路由配置追加进去。 如果我们先列出了AppRoutingModule,那么通配符路由就会被注册在“英雄管理”路由之前。 通配符路由(它匹配任意URL)将会拦截住每一个到“英雄管理”路由的导航,因此事实上屏蔽了所有“英雄管理”路由。

反转路由模块的导入顺序,我们就会看到当点击英雄相关的链接时被导向了“页面未找到”路由。 要学习如何在运行时查看路由器配置,参见稍后的内容

带参数的路由定义

回到HeroesRoutingModule并再次检查这些路由定义。 HeroDetailComponent的路由有点特殊。

src/app/heroes/heroes-routing.module.ts (excerpt)

{ path: 'hero/:id', component: HeroDetailComponent }

注意路径中的:id令牌。它为路由参数在路径中创建一个“空位”。在这里,我们期待路由器把英雄的id插入到那个“空位”中。

如果要告诉路由器导航到详情组件,并让它显示“Magneta”,我们会期望这个英雄的id像这样显示在浏览器的URL中:

localhost:3000/hero/15

如果用户把此URL输入到浏览器的地址栏中,路由器就会识别出这种模式,同样进入“Magneta”的详情视图。

Route parameter: Required or optional?

在这个场景下,把路由参数的令牌:id嵌入到路由定义的path中是一个好主意,因为对于HeroDetailComponent来说id必须的, 而且路径中的值15已经足够把到“Magneta”的路由和到其它英雄的路由明确区分开。

这次我们不打算通过点击链接来导航到详情组件,因此也不用再把带RouterLink的新的A标签加到壳组件中。

而是改为当用户在列表中点击一个英雄时,我们将要求路由器导航到所选英雄的详情视图

HeroListComponent开始。 修改它的构造函数,让它通过依赖注入获得RouterHeroService

src/app/heroes/hero-list.component.ts (constructor)

constructor(
  private router: Router,
  private service: HeroService
) {}

还要对模板进行一点修改:

template: `
  <h2>HEROES</h2>
  <ul class="items">
    <li *ngFor="let hero of heroes | async"
      (click)="onSelect(hero)">
      <span class="badge">{{ hero.id }}</span> {{ hero.name }}
    </li>
  </ul>

  <button routerLink="/sidekicks">Go to sidekicks</button>
`

模板像以前一样定义了一个*ngFor重复器。 还有一个(click)事件绑定,绑定到了组件的onSelect方法,就像这样:

src/app/heroes/hero-list.component.ts (select)

  onSelect(hero: Hero) {
    this.router.navigate(['/hero', hero.id]);
  }

它用一个链接参数数组调用路由器的navigate方法。 如果我们想把它用在HTML中,那么也可以把相同的语法用在RouterLink中。

在列表视图中设置路由参数

我们将导航到HeroDetailComponent组件。在那里,我们期望看到所选英雄的详情,这需要两部分信息:导航目标和该英雄的id

因此,这个链接参数数组中有两个条目:目标路由的path(路径),和一个用来指定所选英雄id路由参数

src/app/heroes/hero-list.component.ts (link-parameters-array)

['/hero', hero.id] // { 15 }

路由器从该数组中组合出了目标URL: localhost:3000/hero/15

目标组件HeroDetailComponent该怎么知道这个id参数呢? 当然不会是自己去分析URL了!那是路由器的工作。

路由器从URL中解析出路由参数(id:15),并通过ActivatedRoute服务来把它提供给HeroDetailComponent组件。

ActivatedRoute:一站式获取路由信息

该路由的路径和参数可以通过注入进来的一个名叫ActivatedRoute的路由服务来获取。 它有一大堆有用的信息,包括:

url: 该路由路径的Observable对象。它的值是一个由路径中各个部件组成的字符串数组。

data: 该路由提供的data对象的一个Observable对象。还包含从resolve守卫中解析出来的值。

params: 包含该路由的必选参数和可选参数Observable对象。

queryParams: 一个包含对所有路由都有效的查询参数Observable对象。

fragment: 一个包含对所有路由都有效的片段值的Observable对象。

outlet: RouterOutlet的名字,用于指示渲染该路由的位置。对于未命名的RouterOutlet,这个名字是primary

routeConfig: 与该路由的原始路径对应的配置信息。

parent: 当使用子路由时,它是一个包含父路由信息的ActivatedRoute对象。

firstChild: 包含子路由列表中的第一个ActivatedRoute对象。

children: 包含当前路由下激活的全部子路由

我们要从路由器(router)包中导入RouterActivatedRouteParams类。

src/app/heroes/hero-detail.component.ts (activated route)

import { Router, ActivatedRoute, Params } from '@angular/router';

这里导入switchMap操作符是因为我们稍后将会处理路由参数的可观察对象Observable

src/app/heroes/hero-detail.component.ts (switchMap operator import)

import 'rxjs/add/operator/switchMap';

通常,我们会直接写一个构造函数,让Angular把组件所需的服务注入进来,自动定义同名的私有变量,并把它们存进去。

src/app/heroes/hero-detail.component.ts (constructor)

constructor(
  private route: ActivatedRoute,
  private router: Router,
  private service: HeroService
) {}

然后,在ngOnInit方法中,我们用ActivatedRoute服务来接收路由的参数,从参数中取得该英雄的id,并接收此英雄用于显示。

把数据访问逻辑放进ngOnInit方法中,而不是构造函数中可以提升组件的可测试性。 Angular会在创建完HeroDetailComponent的实例之后调用ngOnInit方法,因此该英雄会在即将使用时接收到。

要了解关于ngOnInit方法和其它组件生命周期钩子的更多知识,参见生命周期钩子一章。

src/app/heroes/hero-detail.component.ts (ngOnInit)

ngOnInit() {
  this.route.params
    // (+) converts string 'id' to a number
    .switchMap((params: Params) => this.service.getHero(+params['id']))
    .subscribe((hero: Hero) => this.hero = hero);
}

由于参数是作为Observable提供的,所以我们得用switchMap操作符来根据名字取得id参数,并告诉HeroService来获取带有那个id的英雄。

switchMap允许你在Observable的当前值上执行一个动作,并将它映射一个新的Observable。像许多其它rxjs操作符一样, switchMap既可以处理Observable也可以处理Promise发射的值。

如果用户重新导航到该路由,并且它正在获取一个英雄时,switchMap操作符还将取消任何正在执行的请求。

使用subscribe方法来检测id的变化,并据此重新获取英雄。

参数的可观察对象(Observable)与组件复用

在这个例子中,我们订阅了路由参数的Observable对象。 这种写法暗示着这些路由参数在该组件的生存期内可能会变化。

确实如此!默认情况下,如果它没有访问过其它组件就导航到了同一个组件实例,那么路由器倾向于复用组件实例。如果复用,这些参数可以变化。

假设父组件的导航栏有“前进”和“后退”按钮,用来轮流显示英雄列表中中英雄的详情。 每次点击都会强制导航到带前一个或后一个idHeroDetailComponent组件。

我们不希望路由器仅仅从DOM中移除当前的HeroDetailComponent实例,并且用下一个id重新创建它。 那可能导致界面抖动。 更好的方式是复用同一个组件实例,并更新这些参数。

不幸的是,ngOnInit对每个实例只调用一次。 我们需要一种方式来检测在同一个实例中路由参数什么时候发生了变化。 而params属性这个可观察对象(Observable)干净漂亮的处理了这种情况。

当在组件中订阅一个可观察对象时,我们通常总是要在组件销毁时取消这个订阅。

但是也有少数例外情况不需要取消订阅。 ActivateRoute中的各种可观察对象就是属于这种情况。

ActivateRoute及其可观察对象都是由Router本身负责管理的。 Router会在不再需要时销毁这个路由组件,而注入进去的ActivateRoute也随之销毁了。

不过,我们仍然可以随意取消订阅,这不会造成任何损害,而且也不是一项坏的实践。

Snapshot(快照):当不需要Observable时的替代品

本应用不需要复用HeroDetailComponent。 我们总会先返回英雄列表,再选择另一位英雄。 所以,不存在从一个英雄详情导航到另一个而不用经过英雄列表的情况。 这意味着我们每次都会得到一个全新的HeroDetailComponent实例。

假如我们很确定这个HeroDetailComponent组件的实例永远、永远不会被复用,那就可以使用快照来简化这段代码。

route.snapshot提供了路由参数的初始值。 我们可以通过它来直接访问参数,而不用订阅或者添加Observable的操作符。 这样在读写时就会更简单:

src/app/heroes/hero-detail.component.ts (ngOnInit snapshot)

ngOnInit() {
  // (+) converts string 'id' to a number
  let id = +this.route.snapshot.params['id'];

  this.service.getHero(id)
    .then((hero: Hero) => this.hero = hero);
}

记住:,用这种技巧,我们只得到了这些参数的初始值。 如果有可能连续多次导航到此组件,那么就该用params可观察对象的方式。 我们在这里选择使用params可观察对象策略,以防万一。

HeroDetailComponent组件有一个“Back”按钮,关联到它的gotoHeroes方法,该方法会导航回HeroListComponent组件。

路由的navigate方法同样接受一个单条目的链接参数数组,我们也可以把它绑定到[routerLink]指令上。 它保存着HeroListComponent组件的路径

src/app/heroes/hero-detail.component.ts (excerpt)

gotoHeroes() {
  this.router.navigate(['/heroes']);
}

路由参数:必须还是可选?

如果想导航到HeroDetailComponent以对id为15的英雄进行查看并编辑,就要在路由的URL中使用路由参数来指定必要参数值。

localhost:3000/hero/15

我们也能在路由请求中添加可选信息。 比如,当从HeroDetailComponent返回英雄列表时,如果能自动选中刚刚查看过的英雄就好了。

Selected hero

如果我们能在从HeroDetailComponent返回时在URL中带上英雄Magneta的id,不就可以了吗?接下来我们就尝试实现这个场景。

可选信息有很多种形式。搜索条件通常就不是严格结构化的,比如name='wind*';有多个值也很常见,如after='12/31/2015'&before='1/1/2017'; 而且顺序无关,如before='1/1/2017'&after='12/31/2015',还可能有很多种变体格式,如during='currentYear'

这么多种参数要放在URL的路径中可不容易。即使我们能制定出一个合适的URL方案,实现起来也太复杂了,得通过模式匹配才能把URL翻译成命名路由。

可选参数是在导航期间传送任意复杂信息的理想载体。 可选参数不涉及到模式匹配并在表达上提供了巨大的灵活性。

和必要参数一样,路由器也支持通过可选参数导航。 我们在定义完必要参数之后,通过一个独立的对象来定义可选参数

通常,对于强制性的值(比如用于区分两个路由路径的)使用必备参数;当这个值是可选的、复杂的或多值的时,使用可选参数。

英雄列表:选定一个英雄(也可不选)

当导航到HeroDetailComponent时,我们可以在路由参数中指定一个所要编辑的英雄id,只要把它作为链接参数数组中的第二个条目就可以了。

src/app/heroes/hero-list.component.ts (link-parameters-array)

['/hero', hero.id] // { 15 }

路由器在导航URL中内嵌了id的值,这是因为我们把它用一个:id占位符当做路由参数定义在了路由的path中:

src/app/heroes/heroes-routing.module.ts (hero-detail-route)

{ path: 'hero/:id', component: HeroDetailComponent }

当用户点击后退按钮时,HeroDetailComponent构造了另一个链接参数数组,可以用它导航回HeroListComponent

src/app/heroes/hero-detail.component.ts (gotoHeroes)

gotoHeroes() {
  this.router.navigate(['/heroes']);
}

该数组缺少一个路由参数,这是因为我们那时没有理由往HeroListComponent发送信息。

但现在有了。我们要在导航请求中同时发送当前英雄的id,以便HeroListComponent可以在列表中高亮这个英雄。 这是一个有更好,没有也无所谓的特性,就算没有它,列表照样能显示得很完美。

我们传送一个包含可选id参数的对象。 为了演示,我们还在对象中定义了一个没用的额外参数(foo),HeroListComponent应该忽略它。 下面是修改过的导航语句:

src/app/heroes/hero-detail.component.ts (go to heroes)

gotoHeroes() {
  let heroId = this.hero ? this.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(['/heroes', { id: heroId, foo: 'foo' }]);
}

该应用仍然能工作。点击“back”按钮返回英雄列表视图。

注意浏览器的地址栏。

要看到这个在线例子中浏览器地址栏的URL变化情况,请点击右上角的图标,在Plunker编辑器中打开它,接下来在弹出的预览窗口中点击右上角的蓝色'X'按钮就可以了。

pop out the window
pop out the window

它应该是这样的,不过也取决于你在哪里运行它:

localhost:3000/heroes;id=15;foo=foo

id的值像这样出现在URL中(;id=15;foo=foo),但不在URL的路径部分。 “Heroes”路由的路径部分并没有定义:id

可选的路由参数没有使用“?”和“&”符号分隔,因为它们将用在URL查询字符串中。 它们是用“;”分隔的。 这是矩阵URL标记法 —— 我们以前可能从未见过。

Matrix URL写法首次提出是在1996提案中,提出者是Web的奠基人:Tim Berners-Lee。

虽然Matrix写法未曾进入过HTML标准,但它是合法的。而且在浏览器的路由系统中,它作为从父路由和子路由中单独隔离出参数的方式而广受欢迎。Angular的路由器正是这样一个路由系统,并支持跨浏览器的Matrix写法。

这种语法对我们来说可能有点奇怪,不过用户不会在意这一点,因为该URL可以正常的通过邮件发出去或粘贴到浏览器的地址栏中。

ActivatedRoute服务中的路由参数

英雄列表仍没有改变,没有哪个英雄列被加亮显示。

在线例子 / 可下载的例子高亮了选中的行,因为它演示的是应用的最终状态,因此包含了我们即将示范的步骤。 此刻,我们描述的仍是那些步骤之前的状态。

HeroListComponent还完全不需要任何参数,也不知道该怎么处理它们。我们这就改变这一点。

以前,当从HeroListComponent导航到HeroDetailComponent时,我们通过ActivatedRoute服务订阅了路由参数这个Observable,并让它能用在HeroDetailComponent中。我们把该服务注入到了HeroDetailComponent的构造函数中。

这次,我们要进行反向导航,从HeroDetailComponentHeroListComponent

首先,我们扩展该路由的导入语句,以包含进ActivatedRoute服务的类;

src/app/heroes/hero-list.component.ts (import)

import { Router, ActivatedRoute, Params } from '@angular/router';

我们将导入switchMap操作符,在路由参数的Observable对象上执行操作。

src/app/heroes/hero-list.component.ts (rxjs imports)

import 'rxjs/add/operator/switchMap';
import { Observable } from 'rxjs/Observable';

接着,我们注入ActivatedRouteHeroListComponent的构造函数中。

src/app/heroes/hero-list.component.ts (constructor and ngOnInit)

export class HeroListComponent implements OnInit {
  heroes: Observable<Hero[]>;

  private selectedId: number;

  constructor(
    private service: HeroService,
    private route: ActivatedRoute,
    private router: Router
  ) {}

  ngOnInit() {
    this.heroes = this.route.params
      .switchMap((params: Params) => {
        this.selectedId = +params['id'];
        return this.service.getHeroes();
      });
  }
}

ActivatedRoute.params属性是一个路由参数的可观察对象。当用户导航到这个组件时,params会发射一个新的id值。 在ngOnInit中,我们订阅了这些值,设置到selectedId,并获取英雄数据。

所有的路由参数或查询参数都是字符串。 params['id']表达式前面的加号(+)是一个JavaScript的小技巧,用来把字符串转换成整数。

我们添加了一个isSelected方法,当英雄的id和选中的id匹配时,它返回真值。

src/app/heroes/hero-list.component.ts (isSelected)

isSelected(hero: Hero) { return hero.id === this.selectedId; }

最后,我们用CSS类绑定更新模板,把它绑定到isSelected方法上。 如果该方法返回true,此绑定就会添加CSS类selected,否则就移除它。 在<li>标记中找到它,就像这样:

src/app/heroes/hero-list.component.ts (template)

template: `
  <h2>HEROES</h2>
  <ul class="items">
    <li *ngFor="let hero of heroes | async"
      [class.selected]="isSelected(hero)"
      (click)="onSelect(hero)">
      <span class="badge">{{ hero.id }}</span> {{ hero.name }}
    </li>
  </ul>

  <button routerLink="/sidekicks">Go to sidekicks</button>
`

当用户从英雄列表导航到英雄“Magneta”并返回时,“Magneta”看起来是选中的:

Selected List

这儿可选的foo路由参数人畜无害,并继续被忽略。

为路由组件添加动画

这个“英雄”特性模块就要完成了,但这个特性还没有平滑的转场效果。

在这一节,我们将为英雄详情组件添加一些动画

首先导入BrowserAnimationsModule

src/app/app.module.ts (@NgModule imports excerpt) (animations-module)

import { BrowserAnimationsModule } from '@angular/platform-browser/animations';

@NgModule({
  imports: [
    BrowserAnimationsModule

在根目录src/app/下创建一个animations.ts。内容如下:

src/app/animations.ts (excerpt)

import { animate, AnimationEntryMetadata, state, style, transition, trigger } from '@angular/core';

// Component transition animations
export const slideInDownAnimation: AnimationEntryMetadata =
  trigger('routeAnimation', [
    state('*',
      style({
        opacity: 1,
        transform: 'translateX(0)'
      })
    ),
    transition(':enter', [
      style({
        opacity: 0,
        transform: 'translateX(-100%)'
      }),
      animate('0.2s ease-in')
    ]),
    transition(':leave', [
      animate('0.5s ease-out', style({
        opacity: 0,
        transform: 'translateY(100%)'
      }))
    ])
  ]);

该文件做了如下工作:

我们可以为其它路由组件用不同的转场效果创建更多触发器。现在这个触发器已经足够当前的里程碑用了。

返回HeroDetailComponent,从'./animations.ts中导入slideInDownAnimation。 从@angular/core中导入HostBinding装饰器,我们很快就会用到它。

把一个包含slideInDownAnimationanimations数组添加到@Component的元数据中。

然后把三个@HostBinding属性添加到类中以设置这个路由组件元素的动画和样式。

src/app/heroes/hero-detail.component.ts (host bindings)

@HostBinding('@routeAnimation') routeAnimation = true;
@HostBinding('style.display')   display = 'block';
@HostBinding('style.position')  position = 'absolute';

传给了第一个@HostBinding'@routeAnimation'匹配了slideInDownAnimation触发器的名字routeAnimation。 把routeAnimation属性设置为true,因为我们只关心:enter:leave这两个状态。

另外两个@HostBinding属性指定组件的外观和位置。

当进入该路由时,HeroDetailComponent将会从左侧缓动进入屏幕,而离开路由时,将会向下划出。

由特性模块提供的路由将会被路由器和它们导入的模块提供的路由组合在一起。这让我们可以继续定义特性路由,而不用修改主路由配置。

里程碑#3的总结

我们学到了如何:

做完这些修改之后,目录结构是这样的:

router-sample
src
app
heroes
hero-detail.component.ts
hero-list.component.ts
hero.service.ts
heroes.module.ts
heroes-routing.module.ts
app.component.ts
app.module.ts
app-routing.module.ts
crisis-list.component.ts
main.ts
index.html
styles.css
tsconfig.json
node_modules ...
package.json

这里是当前版本的范例程序相关文件。

  1. import { Component } from '@angular/core';
  2. @Component({
  3. selector: 'my-app',
  4. template: `
  5. <h1>Angular Router</h1>
  6. <nav>
  7. <a routerLink="/crisis-center" routerLinkActive="active">Crisis Center</a>
  8. <a routerLink="/heroes" routerLinkActive="active">Heroes</a>
  9. </nav>
  10. <router-outlet></router-outlet>
  11. `
  12. })
  13. export class AppComponent { }

里程碑#4:危机中心

是时候往该应用的危机中心(现在是占位符)中添加一些真实的特性了。

我们先从模仿“英雄管理”中的特性开始:

我们将会把CrisisService转换成模拟的危机列表,而不再是模拟的英雄列表:

src/app/crisis-center/crisis.service.ts (mock-crises)

export class Crisis {
  constructor(public id: number, public name: string) { }
}

const CRISES = [
  new Crisis(1, 'Dragon Burning Cities'),
  new Crisis(2, 'Sky Rains Great White Sharks'),
  new Crisis(3, 'Giant Asteroid Heading For Earth'),
  new Crisis(4, 'Procrastinators Meeting Delayed Again'),
];

最终的危机中心可以作为引入子路由这个新概念的基础。 我们把英雄管理保持在当前状态,以便和危机中心进行对比,以后再根据这些差异是否有价值来决定后续行动。

遵循关注点分离(Separation of Concerns)原则, 对危机中心的修改不会影响AppModule或其它特性模块中的组件。

带有子路由的危机中心

本节会展示如何组织危机中心,来满足Angular应用所推荐的模式:

如果我们有更多特性区,它们的组件树是这样的:

Component Tree

子路由组件

crisis-center目录下添加下列crisis-center.component.ts文件:

src/app/crisis-center/crisis-center.component.ts (minus imports)

@Component({
  template:  `
    <h2>CRISIS CENTER</h2>
    <router-outlet></router-outlet>
  `
})
export class CrisisCenterComponent { }

CrisisCenterComponentAppComponent有下列共同点:

就像大多数的壳一样,CrisisCenterComponent类也非常简单,甚至比AppComponent更简单: 它没有业务逻辑,它的模板中没有链接,只有一个标题和用于放置危机中心的子视图的<router-outlet>

AppComponent和大多数其它组件不同的是,它甚至都没有指定选择器selector。 它不需要选择器,因为我们不会把这个组件嵌入到某个父模板中,而是使用路由器导航到它。

子路由配置

CrisisCenterComponent是一个像AppComponent一样的路由组件。 它有自己的RouterOutlet和自己的子路由。

把下面的crisis-center-home.component.ts文件添加到crisis-center目录中。

src/app/crisis-center/crisis-center-home.component.ts (minus imports)

@Component({
  template: `
    <p>Welcome to the Crisis Center</p>
  `
})
export class CrisisCenterHomeComponent { }

heroes-routing.module.ts文件一样,我们也创建一个crisis-center-routing.module.ts。 但这次,我们要把子路由定义在父路由crisis-center中。

src/app/crisis-center/crisis-center-routing.module.ts (Routes)

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

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

注意,父路由crisis-center有一个children属性,它有一个包含CrisisListComponent的路由。 CrisisListModule路由还有一个带两个路由的children数组。

这两个路由导航到了危机中心的两个子组件:CrisisCenterHomeComponentCrisisDetailComponent

对这些路由的处理中有一些重要的不同

路由器会把这些路由对应的组件放在CrisisCenterComponentRouterOutlet中,而不是AppComponent壳组件中的。

CrisisListComponent包含危机列表和一个RouterOutlet,用以显示Crisis Center HomeCrisis Detail这两个路由组件。

Crisis Detail路由是Crisis List的子路由。由于路由器默认会复用组件,因此当我们选择了另一个危机时,CrisisDetailComponent会被复用。

作为对比,回到Hero Detail路由时,每当我们选择了不同的英雄时,该组件都会被重新创建。

在顶级,以/开头的路径指向的总是应用的根。 但这里是子路由。 它们是在父路由路径的基础上做出的扩展。 在路由树中每深入一步,我们就会在该路由的路径上添加一个斜线/(除非该路由的路径是空的)。

如果把该逻辑应用到危机中心中的导航,那么父路径就是/crisis-center

本例子中包含站点部分的绝对URL,就是:

localhost:3000/crisis-center/2

这里是完整的crisis-center.routing.ts及其导入语句。

src/app/crisis-center/crisis-center-routing.module.ts (excerpt)

import { NgModule }             from '@angular/core';
import { RouterModule, Routes } from '@angular/router';

import { CrisisCenterHomeComponent } from './crisis-center-home.component';
import { CrisisListComponent }       from './crisis-list.component';
import { CrisisCenterComponent }     from './crisis-center.component';
import { CrisisDetailComponent }     from './crisis-detail.component';

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

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

把危机中心模块导入到AppModule的路由中

就像HeroesModule模块中一样,我们必须把CrisisCenterModule添加到AppModuleimports数组中,就在AppRoutingModule前面:

src/app/app.module.ts (import CrisisCenterModule)

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

import { AppComponent }            from './app.component';
import { PageNotFoundComponent }   from './not-found.component';

import { AppRoutingModule }        from './app-routing.module';
import { HeroesModule }            from './heroes/heroes.module';
import { CrisisCenterModule }      from './crisis-center/crisis-center.module';

import { DialogService }           from './dialog.service';

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

我们还从app.routing.ts中移除了危机中心的初始路由。我们的路由现在是由HeroesModuleCrisisCenter特性模块提供的。

我们将保持app.routing.ts文件中只有通用路由,本章稍后会讲解它。

src/app/app-routing.module.ts (v3)

import { NgModule }                from '@angular/core';
import { RouterModule, Routes }    from '@angular/router';

import { ComposeMessageComponent } from './compose-message.component';
import { PageNotFoundComponent }   from './not-found.component';

const appRoutes: Routes = [
  { path: '',   redirectTo: '/heroes', pathMatch: 'full' },
  { path: '**', component: PageNotFoundComponent }
];

@NgModule({
  imports: [
    RouterModule.forRoot(appRoutes)
  ],
  exports: [
    RouterModule
  ]
})
export class AppRoutingModule {}

相对导航

虽然构建出了危机中心特性区,我们却仍在使用以斜杠开头的绝对路径来导航到危机详情的路由。

路由器会从路由配置的顶层来匹配像这样的绝对路径

我们固然可以继续像危机中心特性区一样使用绝对路径,但是那样会把链接钉死在特定的父路由结构上。 如果我们修改了父路径/crisis-center,那就不得不修改每一个链接参数数组。

通过改成定义相对于当前URL的路径,我们可以把链接从这种依赖中解放出来。 当我们修改了该特性区的父路由路径时,该特性区内部的导航仍然完好无损。

例子如下:

链接参数数组中,路由器支持“目录式”语法来指导我们如何查询路由名:

./无前导斜线形式是相对于当前级别的。

../会回到当前路由路径的上一级。

我们可以把相对导航语法和一个祖先路径组合起来用。 如果不得不导航到一个兄弟路由,我们可以用../<sibling>来回到上一级,然后进入兄弟路由路径中。

Router.navigate方法导航到相对路径时,我们必须提供当前的ActivatedRoute,来让路由器知道我们现在位于路由树中的什么位置。

链接参数数组中,添加一个带有relativeTo属性的对象,并把它设置为当前的ActivatedRoute。 这样路由器就会基于当前激活路由的位置来计算出目标URL。

当调用路由器的navigateByUrl时,总是要指定完整的绝对路径

危机列表onSelect方法改成使用相对导航,以便我们不用每次都从路由配置的顶层开始。

我们已经注入过了ActivatedRoute,我们需要它来和相对导航路径组合在一起。

src/app/crisis-center/crisis-list.component.ts (constructor)

constructor(
  private service: CrisisService,
  private route: ActivatedRoute,
  private router: Router
) {}

当访问危机中心时,其祖先路径是/crisis-center,所以我们只需要把危机id添加到现有路径中就可以了。

src/app/crisis-center/crisis-list.component.ts (relative navigation)

onSelect(crisis: Crisis) {
  this.selectedId = crisis.id;

  // Navigate with relative link
  this.router.navigate([crisis.id], { relativeTo: this.route });
}

如果我们用RouterLink来代替Router服务进行导航,就要使用相同的链接参数数组,不过不再需要提供relativeTo属性。 ActivatedRoute已经隐含在了RouterLink指令中。

src/app/crisis-center/crisis-list.component.ts (relative routerLink)

template: `
  <ul class="items">
    <li *ngFor="let crisis of crises | async">
      <a [routerLink]="[crisis.id]"
         [class.selected]="isSelected(crisis)">
        <span class="badge">{{ crisis.id }}</span>
        {{ crisis.name }}
      </a>
    </li>
  </ul>`

修改CrisisDetailComponentgotoCrises方法,来使用相对路径返回危机中心列表。

src/app/crisis-center/crisis-detail.component.ts (relative navigation)

// Relative navigation back to the crises
this.router.navigate(['../', { id: crisisId, foo: 'foo' }], { relativeTo: this.route });

注意这个路径使用了../语法返回上一级。 如果当前危机的id3,那么最终返回到的路径就是/crisis-center/;id=3;foo=foo

用命名出口(outlet)显示多重路由

我们决定给用户提供一种方式来联系危机中心。 当用户点击“Contact”按钮时,我们要在一个弹出框中显示一条消息。

即使在应用中的不同页面之间切换,这个弹出框也应该始终保持打开状态,直到用户发送了消息或者手动取消。 显然,我们不能把这个弹出框跟其它放到页面放到同一个路由出口中。

迄今为止,我们只定义过单路由出口,并且在其中嵌套了子路由以便对路由分组。 在每个模板中,路由器只能支持一个无名主路由出口。

模板还可以有多个命名的路由出口。 每个命名出口都自己有一组带组件的路由。 多重出口可以在同一时间根据不同的路由来显示不同的内容。

AppComponent中添加一个名叫“popup”的出口,就在无名出口的下方。

src/app/app.component.ts (outlets)

<router-outlet></router-outlet>
<router-outlet name="popup"></router-outlet>

一旦我们学会了如何把一个弹出框组件路由到该出口,那里就是将会出现弹出框的地方。

第二路由

命名出口是第二路由的目标。

第二路由很像主路由,配置方式也一样。它们只有一些关键的不同点:

src/app/compose-message.component.ts中创建一个名叫ComposeMessageComponent的新组件。 它显示一个简单的表单,包括一个头、一个消息输入框和两个按钮:“Send”和“Cancel”。

Contact popup

下面是该组件及其模板:

  1. import { Component, HostBinding } from '@angular/core';
  2. import { Router } from '@angular/router';
  3. import { slideInDownAnimation } from './animations';
  4. @Component({
  5. templateUrl: './compose-message.component.html',
  6. styles: [ ':host { position: relative; bottom: 10%; }' ],
  7. animations: [ slideInDownAnimation ]
  8. })
  9. export class ComposeMessageComponent {
  10. @HostBinding('@routeAnimation') routeAnimation = true;
  11. @HostBinding('style.display') display = 'block';
  12. @HostBinding('style.position') position = 'absolute';
  13. details: string;
  14. sending: boolean = false;
  15. constructor(private router: Router) {}
  16. send() {
  17. this.sending = true;
  18. this.details = 'Sending Message...';
  19. setTimeout(() => {
  20. this.sending = false;
  21. this.closePopup();
  22. }, 1000);
  23. }
  24. cancel() {
  25. this.closePopup();
  26. }
  27. closePopup() {
  28. // Providing a `null` value to the named outlet
  29. // clears the contents of the named outlet
  30. this.router.navigate([{ outlets: { popup: null }}]);
  31. }
  32. }

它看起来几乎和我们以前看到的其它组件一样,但有两个值得注意的区别。

主要send()方法在发送消息和关闭弹出框之前通过等待模拟了一秒钟的延迟。

closePopup()方法用把popup出口导航到null的方式关闭了弹出框。 这个奇怪的用法在稍后的部分有讲解。

像其它组件一样,我们还要把ComposeMessageComponent添加到AppModuledeclarations中。

添加第二路由

打开AppRoutingModule,并把一个新的compose路由添加到appRoutes中。

src/app/app-routing.module.ts (compose route)

{
  path: 'compose',
  component: ComposeMessageComponent,
  outlet: 'popup'
},

pathcomponent属性应该很熟悉了吧。 注意这个新的属性outlet被设置成了'popup'。 这个路由现在指向了popup出口,而ComposeMessageComponent也将显示在那里。

用户需要某种途径来打开这个弹出框。 打开AppComponent,并添加一个“Contact”链接。

src/app/app.component.ts (contact-link)

<a [routerLink]="[{ outlets: { popup: ['compose'] } }]">Contact</a>

虽然compose路由被钉死在了popup出口上,但这仍然不足以向RouterLink指令表明要加载该路由。 我们还要在链接参数数组中指定这个命名出口,并通过属性绑定的形式把它绑定到RouterLink上。

链接参数数组包含一个只有一个outlets属性的对象,它的值是另一个对象,这个对象以一个或多个路由的出口名作为属性名。 在这里,它只有一个出口名“popup”,它的值则是另一个链接参数数组,用于指定compose路由。

意思是,当用户点击此链接时,在路由出口popup中显示与compose路由相关联的组件。

当有且只有一个无名出口时,外部对象中的这个outlets对象并不是必须的。

路由器假设这个路由指向了无名的主出口,并为我们创建这些对象。

当路由到一个命名出口时,我们就会发现一个以前被隐藏的真相: 我们可以在同一个RouterLink指令中为多个路由出口指定多个路由。

这里我们实际上没能这样做。要想指向命名出口,我们就得使用一种更强大也更啰嗦的语法。

第二路由导航:在导航期间合并路由

导航到危机中心并点击“Contact”,我们将会在浏览器的地址栏看到如下URL:

http://.../crisis-center(popup:compose)

这个URL中有意思的部分是...后面的这些:

点击Heroes链接,并再次查看URL:

http://.../heroes(popup:compose)

主导航的部分变化了,而第二路由没有变。

路由器在导航树中对两个独立的分支保持追踪,并在URL中对这棵树进行表达。

我们还可以添加更多出口和更多路由(无论是在顶层还是在嵌套的子层)来创建一个带有多个分支的导航树。 路由器将会生成相应的URL。

通过像前面那样填充outlets对象,我们可以告诉路由器立即导航到一棵完整的树。 然后把这个对象通过一个链接参数数组传给router.navigate方法。

有空的时候你可以自行试验这些可能性。

清除第二路由

正如我们刚刚学到的,除非导航到新的组件,否则路由出口中的组件会始终存在。 这里涉及到的第二出口也同样如此。

每个第二出口都有自己独立的导航,跟主出口的导航彼此独立。 修改主出口中的当前路由并不会影响到popup出口中的。 这就是为什么在危机中心和英雄管理之间导航时,弹出框始终都是可见的。

点击“send”或“cancel”按钮,则清除弹出框视图。 为何如此?我们再来看看closePopup()方法:

src/app/compose-message.component.ts (closePopup)

closePopup() {
  // Providing a `null` value to the named outlet
  // clears the contents of the named outlet
  this.router.navigate([{ outlets: { popup: null }}]);
}

它使用Router.navigate()方法进行强制导航,并传入了一个链接参数数组

就像在AppComponent中绑定到的Contact RouterLink一样,它也包含了一个带outlets属性的对象。 outlets属性的值是另一个对象,该对象用一些出口名称作为属性名。 唯一的命名出口是'popup'

但这次,'popup'的值是nullnull不是一个路由,但却是一个合法的值。 把popup这个RouterOutlet设置为null会清除该出口,并且从当前URL中移除第二路由popup

里程碑5:路由守卫

现在,任何用户都能在任何时候导航到任何地方。 但有时候这样是不对的。

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

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

守卫还可以告诉路由器导航到别处,这样也取消当前的导航。

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

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

路由器支持多种守卫:

  1. CanActivate来处理导航某路由的情况。

  2. CanActivateChild处理导航子路由的情况。

  3. CanDeactivate来处理从当前路由离开的情况。

  4. Resolve在路由激活之前获取路由数据。

  5. CanLoad来处理异步导航到某特性模块的情况。

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

我们会在接下来的小节中看到一些例子。

CanActivate: 要求认证

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

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

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

在下一节,我们将会使用一些新的管理特性来扩展危机中心。 那些特性尚未定义,但是我们可以先从添加一个名叫AdminModule的特性模块开始。

创建一个admin目录,它带有一个特性模块文件、一个路由配置文件和一些支持性组件。

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

src/app/admin
admin-dashboard.component.ts
admin.component.ts
admin.module.ts
admin-routing.module.ts
manage-crises.component.ts
manage-heroes.component.ts

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

  1. import { Component } from '@angular/core';
  2. @Component({
  3. template: `
  4. <p>Dashboard</p>
  5. `
  6. })
  7. export class AdminDashboardComponent { }

由于AdminModuleAdminComponent中的RouterLink是一个空路径的路由,所以它会匹配到管理特性区的任何路由。 但我们只有在访问Dashboard路由时才希望该链接被激活。 所以我们往Dashboard这个routerLink上添加了另一个绑定[routerLinkActiveOptions]="{ exact: true }", 这样就只有当我们导航到/admin这个URL时才会激活它,而不会在导航到它的某个子路由时。

我们的初始管理路由配置如下:

src/app/admin/admin-routing.module.ts (admin routing)

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下的子路由,我们有一个带pathchildren的子路由, 但它没有使用component。这并不是配置中的失误,而是在使用无组件路由。

我们的目标是对admin路径下的危机中心管理类路由进行分组,但并不需要另一个仅用来分组路由的组件。 一个无组件的路由就能让我们轻松的守卫子路由

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

src/app/app.module.ts (admin module)

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

import { AppComponent }            from './app.component';
import { PageNotFoundComponent }   from './not-found.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';

import { DialogService }           from './dialog.service';

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

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

src/app/app.component.ts (template)

template: `
  <h1 class="title">Angular Router</h1>
  <nav>
    <a routerLink="/crisis-center" routerLinkActive="active">Crisis Center</a>
    <a routerLink="/heroes" routerLinkActive="active">Heroes</a>
    <a routerLink="/admin" routerLinkActive="active">Admin</a>
    <a [routerLink]="[{ outlets: { popup: ['compose'] } }]">Contact</a>
  </nav>
  <router-outlet></router-outlet>
  <router-outlet name="popup"></router-outlet>
  `

守护“管理特性”区

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

我们可以在用户登录之前隐藏这些链接,但这样会有点复杂并难以维护。

我们换种方式:写一个CanActivate()守卫,当匿名用户尝试访问管理组件时,把它/她重定向到登录页。

这是一种具有通用性的守护目标(通常会有其它特性需要登录用户才能访问),所以我们在应用的根目录下创建一个auth-guard.ts文件。

此刻,我们的兴趣在于看看守卫是如何工作的,所以我们第一个版本没做什么有用的事情。它只是往控制台写日志,并且立即返回true,让导航继续:

src/app/auth-guard.service.ts (excerpt)

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

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate() {
    console.log('AuthGuard#canActivate called');
    return true;
  }
}

接下来,打开crisis-center.routes.ts,导入AuthGuard类,修改管理路由并通过CanActivate()守卫来引用AuthGuard

src/app/admin/admin-routing.module.ts (guarded admin route)

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

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可以调用应用中的一项服务,该服务能让用户登录,并且保存当前用户的信息。下面是一个AuthService的示范:

src/app/auth.service.ts (excerpt)

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

import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/of';
import 'rxjs/add/operator/do';
import 'rxjs/add/operator/delay';

@Injectable()
export class AuthService {
  isLoggedIn: boolean = false;

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

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

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

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

我们这就修改AuthGuard来调用它。

src/app/auth-guard.service.ts (v2)

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

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

  canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
    let url: string = state.url;

    return this.checkLogin(url);
  }

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

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

    // Navigate to the login page with extras
    this.router.navigate(['/login']);
    return false;
  }
}

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

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

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

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

添加LoginComponent

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

我们将在login-routing.module.ts中注册一个/login路由,并把必要的提供商添加providers数组中。 在app.module.ts中,我们导入LoginComponent并把它加入根模块的declarations中。 同时在AppModule中导入并添加LoginRoutingModule

  1. import { NgModule } from '@angular/core';
  2. import { BrowserModule } from '@angular/platform-browser';
  3. import { FormsModule } from '@angular/forms';
  4. import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
  5. import { Router } from '@angular/router';
  6. import { AppComponent } from './app.component';
  7. import { AppRoutingModule } from './app-routing.module';
  8. import { HeroesModule } from './heroes/heroes.module';
  9. import { ComposeMessageComponent } from './compose-message.component';
  10. import { LoginRoutingModule } from './login-routing.module';
  11. import { LoginComponent } from './login.component';
  12. import { PageNotFoundComponent } from './not-found.component';
  13. import { DialogService } from './dialog.service';
  14. @NgModule({
  15. imports: [
  16. BrowserModule,
  17. FormsModule,
  18. HeroesModule,
  19. LoginRoutingModule,
  20. AppRoutingModule,
  21. BrowserAnimationsModule
  22. ],
  23. declarations: [
  24. AppComponent,
  25. ComposeMessageComponent,
  26. LoginComponent,
  27. PageNotFoundComponent
  28. ],
  29. providers: [
  30. DialogService
  31. ],
  32. bootstrap: [ AppComponent ]
  33. })
  34. export class AppModule {
  35. // Diagnostic only: inspect router configuration
  36. constructor(router: Router) {
  37. console.log('Routes: ', JSON.stringify(router.config, undefined, 2));
  38. }
  39. }

它们所需的守卫和服务提供商必须在模块一级提供。这让路由器在导航过程中可以通过Injector来取得这些服务。 同样的规则也适用于异步加载的特性模块。

CanAcitvateChild:保护子路由

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

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

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

接下来,实现CanAcitvateChild方法,它所接收的参数与CanAcitvate方法一样:一个ActivatedRouteSnapshot和一个RouterStateSnapshotCanAcitvateChild方法可以返回Observable<boolean>Promise<boolean>来支持异步检查,或boolean来支持同步检查。 这里返回的是boolean

src/app/auth-guard.service.ts (excerpt)

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

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

  canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
    let url: string = state.url;

    return this.checkLogin(url);
  }

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

/* . . . */
}

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

src/app/admin/admin-routing.module.ts (excerpt)

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守卫。

Cancel and save

我们的范例应用不会与服务器通讯。 幸运的是,我们有另一种方式来演示异步的路由器钩子。

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

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

src/app/crisis-center/crisis-detail.component.ts (cancel and save methods)

cancel() {
  this.gotoCrises();
}

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

如果用户尝试不保存或撤销就导航到外面该怎么办? 用户可以按浏览器的后退按钮,或点击英雄的链接。 这些操作都会触发导航。本应用应该自动保存或取消吗?

都不行。我们应该弹出一个确认对话框来要求用户明确做出选择,该对话框会用异步的方式等用户做出选择

我们也能用同步的方式等用户的答复,阻塞代码。但如果能用异步的方式等待用户的答复,应用就会响应性更好,也能同时做别的事。异步等待用户的答复和等待服务器的答复是类似的。

DialogService(为了在应用级使用,已经注入到了AppModule)就可以做到这些。

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

我们创建了一个Guard,它将检查这个组件中canDeactivate函数的工作现场,在这里,它就是CrisisDetailComponent。我们并不需要知道CrisisDetailComponent确认退出激活状态的详情。这让我们的守卫可以被复用,这是一次轻而易举的胜利。

src/app/can-deactivate-guard.service.ts

  1. import { Injectable } from '@angular/core';
  2. import { CanDeactivate } from '@angular/router';
  3. import { Observable } from 'rxjs/Observable';
  4. export interface CanComponentDeactivate {
  5. canDeactivate: () => Observable<boolean> | Promise<boolean> | boolean;
  6. }
  7. @Injectable()
  8. export class CanDeactivateGuard implements CanDeactivate<CanComponentDeactivate> {
  9. canDeactivate(component: CanComponentDeactivate) {
  10. return component.canDeactivate ? component.canDeactivate() : true;
  11. }
  12. }

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

src/app/can-deactivate-guard.service.ts (component-specific)

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

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

@Injectable()
export class CanDeactivateGuard implements CanDeactivate<CrisisDetailComponent> {

  canDeactivate(
    component: CrisisDetailComponent,
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot
  ): Promise<boolean> | boolean {
    // Get the Crisis Center ID
    console.log(route.params['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
    // promise which resolves to true or false when the user decides
    return component.dialogService.confirm('Discard changes?');
  }
}

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

src/app/crisis-center/crisis-detail.component.ts (excerpt)

canDeactivate(): Promise<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
  // promise which resolves to true or false when the user decides
  return this.dialogService.confirm('Discard changes?');
}

注意,canDeactivate方法可以同步返回,如果没有危机,或者没有未定的修改,它就立即返回true。但是它也可以返回一个承诺(Promise)或可观察对象(Observable),路由器将等待它们被解析为真值(继续导航)或假值(留下)。

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

src/app/crisis-center/crisis-center-routing.module.ts (can deactivate guard)

import { NgModule }             from '@angular/core';
import { RouterModule, Routes } from '@angular/router';

import { CrisisCenterHomeComponent } from './crisis-center-home.component';
import { CrisisListComponent }       from './crisis-list.component';
import { CrisisCenterComponent }     from './crisis-center.component';
import { CrisisDetailComponent }     from './crisis-detail.component';

import { CanDeactivateGuard }    from '../can-deactivate-guard.service';

const crisisCenterRoutes: Routes = [
  {
    path: '',
    redirectTo: '/crisis-center',
    pathMatch: 'full'
  },
  {
    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 { }

我们还要把这个Guard添加到appRoutingModuleproviders中去,以便Router可以在导航过程中注入它。

  1. import { NgModule } from '@angular/core';
  2. import { RouterModule, Routes } from '@angular/router';
  3. import { ComposeMessageComponent } from './compose-message.component';
  4. import { CanDeactivateGuard } from './can-deactivate-guard.service';
  5. import { PageNotFoundComponent } from './not-found.component';
  6. const appRoutes: Routes = [
  7. {
  8. path: 'compose',
  9. component: ComposeMessageComponent,
  10. outlet: 'popup'
  11. },
  12. { path: '', redirectTo: '/heroes', pathMatch: 'full' },
  13. { path: '**', component: PageNotFoundComponent }
  14. ];
  15. @NgModule({
  16. imports: [
  17. RouterModule.forRoot(appRoutes)
  18. ],
  19. exports: [
  20. RouterModule
  21. ],
  22. providers: [
  23. CanDeactivateGuard
  24. ]
  25. })
  26. export class AppRoutingModule {}

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

Resolve: 预先获取组件数据

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

这种方式没有问题,但是它们还有进步的空间。 如果我们在使用真实api,很有可能数据返回有延迟,导致无法即时显示。 在这种情况下,直到数据到达前,显示一个空的组件不是最好的用户体验。

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

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

我们需要Resolve守卫。

导航前预先加载路由信息

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

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

在“危机中心”特性区中创建crisis-detail-resolver.service.ts文件。

src/app/crisis-center/crisis-detail-resolver.service.ts

  1. import { Injectable } from '@angular/core';
  2. import { Router, Resolve, RouterStateSnapshot,
  3. ActivatedRouteSnapshot } from '@angular/router';
  4. import { Crisis, CrisisService } from './crisis.service';
  5. @Injectable()
  6. export class CrisisDetailResolver implements Resolve<Crisis> {
  7. constructor(private cs: CrisisService, private router: Router) {}
  8. resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<Crisis> {
  9. let id = route.params['id'];
  10. return this.cs.getCrisis(id).then(crisis => {
  11. if (crisis) {
  12. return crisis;
  13. } else { // id not found
  14. this.router.navigate(['/crisis-center']);
  15. return null;
  16. }
  17. });
  18. }
  19. }

CrisisDetailComponent.ngOnInit中拿到相关的危机检索逻辑,并且把它们移到CrisisDetailResolver中。 导入Crisis模型、CrisisServiceRouter以便让我们可以在找不到指定的危机时导航到别处。

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

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

CrisisService.getCrisis方法返回了一个Promise。 返回Promise可以阻止路由被加载,直到数据获取完毕。 如果它没有返回一个有效的Crisis,就把用户导航回CrisisListComponent,并取消以前到CrisisDetailComponent尚未完成的导航。

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

别忘了把CrisisDetailResolver服务添加到CrisisCenterRoutingModuleproviders数组中。

src/app/crisis-center/crisis-center-routing.module.ts (resolver)

import { CrisisDetailResolver }   from './crisis-detail-resolver.service';

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

CrisisDetailComponent不应该再去获取这个危机的详情。 把CrisisDetailComponent改成从ActivatedRoute.data.crisis属性中获取危机详情,这正是我们重新配置路由的恰当时机。 当CrisisDetailComponent要求取得危机详情时,它就已经在那里了。

src/app/crisis-center/crisis-detail.component.ts (ngOnInit v2)

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

两个关键点

  1. 路由器的这个Resolve接口是可选的。CrisisDetailResolver没有继承自某个基类。路由器只要找到了这个方法,就会调用它。

  2. 我们依赖路由器调用此守卫。不必关心用户用哪种方式导航离开,这是路由器的工作。我们只要写出这个类,等路由器从那里取出它就可以了。

本里程碑中与危机中心有关的代码如下:

  1. import { Component } from '@angular/core';
  2. @Component({
  3. selector: 'my-app',
  4. template: `
  5. <h1 class="title">Angular Router</h1>
  6. <nav>
  7. <a routerLink="/crisis-center" routerLinkActive="active">Crisis Center</a>
  8. <a routerLink="/heroes" routerLinkActive="active">Heroes</a>
  9. <a routerLink="/admin" routerLinkActive="active">Admin</a>
  10. <a routerLink="/login" routerLinkActive="active">Login</a>
  11. <a [routerLink]="[{ outlets: { popup: ['compose'] } }]">Contact</a>
  12. </nav>
  13. <router-outlet></router-outlet>
  14. <router-outlet name="popup"></router-outlet>
  15. `
  16. })
  17. export class AppComponent {
  18. }
  1. import { Injectable } from '@angular/core';
  2. import {
  3. CanActivate, Router,
  4. ActivatedRouteSnapshot,
  5. RouterStateSnapshot,
  6. CanActivateChild
  7. } from '@angular/router';
  8. import { AuthService } from './auth.service';
  9. @Injectable()
  10. export class AuthGuard implements CanActivate, CanActivateChild {
  11. constructor(private authService: AuthService, private router: Router) {}
  12. canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
  13. let url: string = state.url;
  14. return this.checkLogin(url);
  15. }
  16. canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
  17. return this.canActivate(route, state);
  18. }
  19. checkLogin(url: string): boolean {
  20. if (this.authService.isLoggedIn) { return true; }
  21. // Store the attempted URL for redirecting
  22. this.authService.redirectUrl = url;
  23. // Navigate to the login page
  24. this.router.navigate(['/login']);
  25. return false;
  26. }
  27. }

查询参数及片段

在这个查询参数例子中,我们只为路由指定了参数,但是该如何定义一些所有路由中都可用的可选参数呢? 这就该“查询参数”登场了。

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

接下来,我们将更新AuthGuard来提供session_id查询参数,在导航到其它路由后,它还会存在。

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

我们还将为router.nativate方法传入一个NavigationExtras对象,用来导航到/login路由。

src/app/auth-guard.service.ts (v3)

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

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

  canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
    let url: string = state.url;

    return this.checkLogin(url);
  }

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

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

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

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

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

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

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

src/app/login.component.ts (preserve)

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

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

由于要在登录后导航到危机管理特征区的路由,所以我们还得更新它,来处理这些全局查询参数和片段。

src/app/admin/admin-dashboard.component.ts (v2)

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

@Component({
  template:  `
    <p>Dashboard</p>

    <p>Session ID: {{ sessionId | async }}</p>
    <a id="anchor"></a>
    <p>Token: {{ token | async }}</p>
  `
})
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
      .queryParams
      .map(params => params['session_id'] || 'None');

    // Capture the fragment if available
    this.token = this.route
      .fragment
      .map(fragment => fragment || 'None');
  }
}

查询参数片段可通过Router服务的routerState属性使用。和路由参数类似,全局查询参数和片段也是Observable对象。 在更新过的英雄管理组件中,我们将直接把Observable传给模板,借助AsyncPipe在组件被销毁时自动取消Observable的订阅。

要看到这个在线例子中浏览器地址栏的URL变化情况,请点击右上角的图标,在Plunker编辑器中打开它,接下来在弹出的预览窗口中点击右上角的蓝色'X'按钮就可以了。

pop out the window
pop out the window

按照下列步骤试验下:点击Crisis Admin按钮,它会带着我们提供的“查询参数”和“片段”跳转到登录页。 点击登录按钮,我们就会被带到Crisis Admin页,仍然带着上一步提供的“查询参数”和“片段”。

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

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

里程碑6:异步路由

完成上面的里程碑后,我们的应用程序很自然的长大了。在继续构建特征区的过程中,应用的尺寸将会变得更大。在某一个时间点,我们将达到一个顶点,应用将会需要过多的时间来加载。

如何才能解决这个问题呢?我们引进了异步路由到应用程序中,并获得在请求时才惰性加载特性模块的能力。这样给我们带来了下列好处:

我们已经完成了一部分。通过把应用组织成一些模块:AppModuleHeroesModuleAdminModuleCrisisCenterModule, 我们已经有了可用于实现惰性加载的候选者。

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

惰性加载路由配置

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

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

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

给它一个loadChildren属性(注意不是children属性),把它设置为AdminModule的地址。 该地址是AdminModule的文件路径(相对于app目录的),加上一个#分隔符,再加上导出模块的类名AdminModule

app-routing.module.ts (load children)

  1. {
  2. path: 'admin',
  3. loadChildren: 'app/admin/admin.module#AdminModule',
  4. },

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

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

Angular提供一个内置模块加载器,支持SystemJS来异步加载模块。如果我们使用其它捆绑工具比如Webpack,则使用Webpack的机制来异步加载模块。

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

app.module.ts中,从顶部移除AdminModule的导入语句,并且从Angular模块的imports数组中移除AdminModule

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

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

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

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

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

打开auth-guard.service.ts,从@angular/router中导入CanLoad接口。 把它添加到AuthGuard类的implements列表中。 然后实现canLoad,代码如下:

src/app/auth-guard.service.ts (CanLoad guard)

canLoad(route: Route): boolean {
  let url = `/${route.path}`;

  return this.checkLogin(url);
}

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

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

app-routing.module.ts (lazy admin route)

  1. {
  2. path: 'admin',
  3. loadChildren: 'app/admin/admin.module#AdminModule',
  4. canLoad: [AuthGuard]
  5. },

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

我们已经学会了如何按需加载模块,接下来再看看如何使用预加载技术异步加载模块。

看起来好像应用一直都是这么做的,但其实并非如此。 AppModule在应用启动时就被加载了,它是立即加载的。 而AdminModule只有当用户点击某个链接时才会加载,它是惰性加载的。

预加载是介于两者之间的一种方式。 我们来看看危机中心。 用户第一眼不会看到它。 默认情况下,英雄管理才是第一视图。 为了获得尽可能小的初始加载体积和最快的加载速度,我们应该对AppModuleHeroesModule进行立即加载。

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

这就是预加载

预加载的工作原理

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

Router内置了两种预加载策略:

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

在下一节,我们将会把CrisisCenterModule改为默认惰性加载的,并使用PreloadAllModules策略来尽快加载它(以及所有其它惰性加载模块)。

惰性加载危机中心

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

  1. CrisisCenterRoutingModule中的路径从crisis-center改为空字符串。

  2. AppRoutingModule中添加一个crisis-center路由。

  3. 设置loadChildren字符串来加载CrisisCenterModule

  4. app.module.ts中移除所有对CrisisCenterModule的引用。

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

  1. import { NgModule } from '@angular/core';
  2. import { BrowserModule } from '@angular/platform-browser';
  3. import { FormsModule } from '@angular/forms';
  4. import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
  5. import { Router } from '@angular/router';
  6. import { AppComponent } from './app.component';
  7. import { AppRoutingModule } from './app-routing.module';
  8. import { HeroesModule } from './heroes/heroes.module';
  9. import { ComposeMessageComponent } from './compose-message.component';
  10. import { LoginRoutingModule } from './login-routing.module';
  11. import { LoginComponent } from './login.component';
  12. import { PageNotFoundComponent } from './not-found.component';
  13. import { DialogService } from './dialog.service';
  14. @NgModule({
  15. imports: [
  16. BrowserModule,
  17. FormsModule,
  18. HeroesModule,
  19. LoginRoutingModule,
  20. AppRoutingModule,
  21. BrowserAnimationsModule
  22. ],
  23. declarations: [
  24. AppComponent,
  25. ComposeMessageComponent,
  26. LoginComponent,
  27. PageNotFoundComponent
  28. ],
  29. providers: [
  30. DialogService
  31. ],
  32. bootstrap: [ AppComponent ]
  33. })
  34. export class AppModule {
  35. // Diagnostic only: inspect router configuration
  36. constructor(router: Router) {
  37. console.log('Routes: ', JSON.stringify(router.config, undefined, 2));
  38. }
  39. }

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

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

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

src/app/app-routing.module.ts (preload all)

    RouterModule.forRoot(
      appRoutes
      , { preloadingStrategy: PreloadAllModules }
    )

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

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

意外的是,AdminModule没有预加载,有什么东西阻塞了它。

CanLoad会阻塞预加载

PreloadAllModules策略不会加载被CanLoad守卫所保护的特性区。这是刻意设计的。

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

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

自定义预加载策略

在大多数场景下,预加载每个惰性加载模块就很好了,但是有时候它却并不是正确的选择,特别是在移动设备和低带宽连接下。 我们可能出于用户的测量和其它商业和技术因素而选择只对某些特性模块进行预加载。

使用自定义预加载策略,我们可以控制路由器预加载哪些路由以及如何加载。

在这一节,我们将添加一个自定义策略,它预加载那些data.preload标志为true的路由。 回忆一下,我们可以往路由的data属性中添加任何东西。

AppRoutingModulecrisis-center路由中设置data.preload标志。

src/app/app-routing.module.ts (route data preload)

{
  path: 'crisis-center',
  loadChildren: 'app/crisis-center/crisis-center.module#CrisisCenterModule',
  data: { preload: true }
},

往项目中添加一个新的名叫selective-preloading-strategy.ts的文件,并在其中定义一个服务类SelectivePreloadingStrategy,代码如下:

src/app/selective-preloading-strategy.ts (excerpt)

import 'rxjs/add/observable/of';
import { Injectable } from '@angular/core';
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable } from 'rxjs/Observable';

@Injectable()
export class SelectivePreloadingStrategy implements PreloadingStrategy {
  preloadedModules: string[] = [];

  preload(route: Route, load: () => Observable<any>): Observable<any> {
    if (route.data && route.data['preload']) {
      // 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 Observable.of(null);
    }
  }
}

SelectivePreloadingStrategy实现了PreloadingStrategy,它只有一个方法preload

路由器会用两个参数调用调用preload方法:

  1. 要加载的路由。

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

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

在这个例子中,preload方法只有在路由的data.preload标识为真时才会加载该路由。

它还有一个副作用。 SelectivePreloadingStrategy会把所选路由的path记录在它的公共数组preloadedModules中。

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

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

  1. SelectivePreloadingStrategy导入到AppRoutingModule中。

  2. PreloadAllModules策略替换成对forRoot的调用,并且传入这个SelectivePreloadingStrategy

  3. SelectivePreloadingStrategy策略添加到AppRoutingModuleproviders数组中,以便它可以注入到应用中的任何地方。

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

  1. 导入SelectivePreloadingStrategy(它是一个服务)。

  2. 把它注入到仪表盘的构造函数中。

  3. 修改模板来显示这个策略服务的preloadedModules数组。

当完成时,代码如下:

src/app/admin/admin-dashboard.component.ts (preloaded modules)

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

import { SelectivePreloadingStrategy } from '../selective-preloading-strategy';

import 'rxjs/add/operator/map';

@Component({
  template:  `
    <p>Dashboard</p>

    <p>Session ID: {{ sessionId | async }}</p>
    <a id="anchor"></a>
    <p>Token: {{ token | async }}</p>

    Preloaded Modules
    <ul>
      <li *ngFor="let module of modules">{{ module }}</li>
    </ul>
  `
})
export class AdminDashboardComponent implements OnInit {
  sessionId: Observable<string>;
  token: Observable<string>;
  modules: string[];

  constructor(
    private route: ActivatedRoute,
    private preloadStrategy: SelectivePreloadingStrategy
  ) {
    this.modules = preloadStrategy.preloadedModules;
  }

  ngOnInit() {
    // Capture the session ID if available
    this.sessionId = this.route
      .queryParams
      .map(params => params['session_id'] || 'None');

    // Capture the fragment if available
    this.token = this.route
      .fragment
      .map(fragment => fragment || 'None');
  }
}

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

审查路由器配置

我们把大量的精力投入到在一系列路由模块文件里配置路由器上,并且小心的以合适的顺序列出它们。 这些路由是否真的如同你预想的那样执行了? 路由器的真实配置是怎样的?

通过注入它(Router)并检查它的config属性,我们可以随时审查路由器的当前配置。 例如,把AppModule修改为这样,并在浏览器的控制台窗口中查看最终的路由配置。

src/app/app.module.ts (inspect the router config)

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

export class AppModule {
  // Diagnostic only: inspect router configuration
  constructor(router: Router) {
    console.log('Routes: ', JSON.stringify(router.config, undefined, 2));
  }
}

总结与最终的应用

本章中涉及到了很多背景知识,而且本应用程序也太大了,所以没法在这里显示。请访问在线例子 / 可下载的例子,在那里你可以下载最终的源码。

附录

本章剩下的部分是一组附录,它详尽阐述了我们曾匆匆带过的一些知识点。

该附件中的内容不是必须的,感兴趣的人才需要阅读它。

链接参数数组保存路由导航时所需的成分:

我们可以把RouterLink指令绑定到一个数组,就像这样:

<a [routerLink]="['/heroes']">Heroes</a>

在指定路由参数时,我们写过一个双元素的数组,就像这样:

this.router.navigate(['/hero', hero.id]);

我们可以在对象中提供可选的路由参数,就像这样:

<a [routerLink]="['/crisis-center', { foo: 'foo' }]">Crisis Center</a>

这三个例子覆盖了我们在单级路由的应用中所需的一切。在添加一个像危机中心一样的子路由时,我们创建新链接数组组合。

回忆一下,我们曾为危机中心指定过一个默认的子路由,以便能使用这种简单的RouterLink

<a [routerLink]="['/crisis-center']">Crisis Center</a>

分解一下。

在下一步,我们会用到它。这次,我们要构建一个从根组件往下导航到“巨龙危机”时的链接参数数组:

<a [routerLink]="['/crisis-center', 1]">Dragon Crisis</a>

只要想,我们也可以用危机中心路由单独重定义AppComponent的模板:

template: `
  <h1 class="title">Angular Router</h1>
  <nav>
    <a [routerLink]="['/crisis-center']">Crisis Center</a>
    <a [routerLink]="['/crisis-center/1', { foo: 'foo' }]">Dragon Crisis</a>
    <a [routerLink]="['/crisis-center/2']">Shark Crisis</a>
  </nav>
  <router-outlet></router-outlet>
`

总结:我们可以用一级、两级或多级路由来写应用程序。 链接参数数组提供了用来表示任意深度路由的链接参数数组以及任意合法的路由参数序列、必须的路由器参数以及可选的路由参数对象。

附录:LocationStrategy以及浏览器URL样式

当路由器导航到一个新的组件视图时,它会用该视图的URL来更新浏览器的当前地址以及历史。 严格来说,这个URL其实是本地的,浏览器不会把该URL发给服务器,并且不会重新加载此页面。

现代HTML 5浏览器支持history.pushState API, 这是一项可以改变浏览器的当前地址和历史,却又不会触发服务端页面请求的技术。 路由器可以合成出一个“自然的”URL,它看起来和那些需要进行页面加载的URL没什么区别。

下面是危机中心的URL在“HTML 5 pushState”风格下的样子:

localhost:3002/crisis-center/

老旧的浏览器在当前地址的URL变化时总会往服务器发送页面请求……唯一的例外规则是:当这些变化位于“#”(被称为“hash”)后面时不会发送。通过把应用内的路由URL拼接在#之后,路由器可以获得这条“例外规则”带来的优点。下面是到危机中心路由的“hash URL”:

localhost:3002/src/#/crisis-center/

路由器通过两种LocationStrategy提供商来支持所有这些风格:

  1. PathLocationStrategy - 默认的策略,支持“HTML 5 pushState”风格。

  2. HashLocationStrategy - 支持“hash URL”风格。

RouterModule.forRoot函数把LocationStrategy设置成了PathLocationStrategy,使其成为了默认策略。 我们可以在启动过程中改写(override)它,来切换到HashLocationStrategy风格 —— 如果我们更喜欢这种。

要学习关于“提供商”和启动过程的更多知识,参见依赖注入一章。

哪种策略更好?

我们必须选择一种策略,并且在项目的早期就这么干。一旦该应用进入了生产阶段,要改起来可就不容易了,因为外面已经有了大量对应用URL的引用。

几乎所有的Angular项目都会使用默认的HTML 5风格。它生成的URL更易于被用户理解,它也为将来做服务端渲染预留了空间。

在服务器端渲染指定的页面,是一项可以在该应用首次加载时大幅提升响应速度的技术。那些原本需要十秒甚至更长时间加载的应用,可以预先在服务端渲染好,并在少于一秒的时间内完整呈现在用户的设备上。

只有当应用的URL看起来像是标准的Web URL,中间没有hash(#)时,这个选项才能生效。

除非你有强烈的理由不得不使用hash路由,否则就应该坚决使用默认的HTML 5路由风格。

HTML 5 URL与<base href>

由于路由器默认使用“HTML 5 pushState”风格,所以我们必须用一个base href来配置该策略(Strategy)。

配置该策略的首选方式是往index.html<head>中添加一个<base href> element标签。

<base href="/">

如果没有此标签,当通过“深链接”进入该应用时,浏览器就不能加载资源(图片、CSS、脚本)。如果有人把应用的链接粘贴进浏览器的地址栏或从邮件中点击应用的链接时,这种问题就发生。

有些开发人员可能无法添加<base>元素,这可能是因为它们没有访问<head>index.html的权限。

它们仍然可以使用HTML 5格式的URL,但要采取两个步骤进行补救:

  1. 用适当的APP_BASE_HREF值提供(provide)路由器。

  2. 对所有Web资源使用绝对地址:CSS、图片、脚本、模板HTML。

HashLocationStrategy

我们可以在根模块的RouterModule.forRoot的第二个参数中传入一个带有useHash: true的对象,以回到基于HashLocationStrategy的传统方式。

src/app/app.module.ts (hash URL strategy)

import { NgModule }             from '@angular/core';
import { BrowserModule }        from '@angular/platform-browser';
import { FormsModule }          from '@angular/forms';
import { Routes, RouterModule } from '@angular/router';

import { AppComponent }          from './app.component';
import { PageNotFoundComponent } from './not-found.component';

const routes: Routes = [

];

@NgModule({
  imports: [
    BrowserModule,
    FormsModule,
    RouterModule.forRoot(routes, { useHash: true })  // .../#/crisis-center/
  ],
  declarations: [
    AppComponent,
    PageNotFoundComponent
  ],
  providers: [

  ],
  bootstrap: [ AppComponent ]
})
export class AppModule { }