Angular 是一个应用设计框架与开发平台,用于创建高效、复杂、精致的单页面应用。

大多数 Angular 代码都只能用最新的 Javascript 编写。它会用类型实现依赖注入,还会用装饰器来提供元数据。

Angular 版本更迭速度很快,但是每个版本均往前兼容1-2个版本。正常情况下,不会出现较大的跨越。

条件假设

在您进入该教程的学习后,默认假设您已熟悉了HTML5CSS3javascript和一些最新标准的相关知识。本教程中的示例均使用最新标准下的Typescript进行展示。

注:
- 本教程均使用 Angular (V9.1.11)版本。

在正式使用 Angular 框架之前,我们需要搭建本地开发环境和工作空间。

前提条件

  1. 相关知识

要想使用 Angular 框架,您必须先熟悉以下技术:

注:
- 关于 Typescript 只会非常有用,但非必备技能。

  1. 关于Node.js

确保您的开发环境中包含了 Node.js 和一个包管理器。

注:
Angular 需要 Node.js V10.9.0或更高版本。
- 在终端中运行 node -v命令可检查您的 Node.js 版本。
- 若要获取 Node.js ,请转到 nodejs.org

  1. npm 包管理器

Angular、Angular CLI 和 Angular 应用都依赖于 npm 包中提供的特性和功能。要想下载并安装 npm 包,您必须拥有一个 npm 包管理器。

注:
在安装了 Node.js 后会默认安装 npm 客户端命令行界面。
- 在终端中运行 npm -v命令可检查您是否成功安装了 npm 客户端。

搭建步骤

1. 安装 Angular CLI

Angular CLI 可以帮助您创建项目、生成应用和库代码,以及执行各种开发 任务,比如测试,打包和部署。

  • 使用 npm 命令安装 CLI ,请打开终端输入如下命令:

npm install -g @angular/cli

2。 创建工作空间和初始应用

您要在 Angular 工作区上下文中开发应用,需要创建一个新的工作空间和初始入门应用。

  • 运行 CLI 命令 ng new并提供 my-app名称作为参数。

ng new my-app

  • ng new命令会提示您提供要将哪些特性包含在初始应用中。若无特殊要求,可按 EnterReturn接受默认值。

3. 运行应用

Angular CLI 中包含一个服务器,方便您在本地构建和提供应用。

  • 转到 workspace 文件夹(my-app)。
  • 使用 CLI 命令 ng serve--open选项来启动服务器。

cd my-appng serve --open

注:
- ng serve命令会启动开发服务器、监视文件,并在这些文件发生更改时重建应用。

--open可缩写为-o,该选项会自动打开您的浏览器访问 "http://localhost:4200/" ,网页展示如下:

架构概览

Angular 是一个用 HTML 和 TypeScript 构建客户端应用的平台与框架。Angular 本身就是用 TypeScript 写成的。它将核心功能和可选功能作为一组 TypeScript 库进行实现,你可以把它们导入你的应用中。


Angular 的基本构造块是 NgModule,它为组件提供了编译的上下文环境。 NgModule 会把相关的代码收集到一些功能集中。Angular 应用就是由一组 NgModule 定义出的。 应用至少会有一个用于引导应用的根模块,通常还会有很多特性模块。

  • 组件定义视图。视图是一组可见的屏幕元素,Angular 可以根据你的程序逻辑和数据来选择和修改它们。 每个应用都至少有一个根组件。

  • 组件使用服务。服务会提供那些与视图不直接相关的功能。服务提供者可以作为依赖被注入到组件中, 这能让你的代码更加模块化、更加可复用、更加高效。

组件和服务都是简单的类,这些类使用装饰器来标出它们的类型,并提供元数据以告知 Angular 该如何使用它们。

  • 组件类的元数据将组件类和一个用来定义视图的模板关联起来。 模板把普通的 HTML 和 Angular 指令与绑定标记(markup)组合起来,这样 Angular 就可以在渲染 HTML 之前先修改这些 HTML。

  • 服务类的元数据提供了一些信息,Angular 要用这些信息来让组件可以通过依赖注入(DI)使用该服务。

应用的组件通常会定义很多视图,并进行分级组织。Angular 提供了 Router 服务来帮助你定义视图之间的导航路径。 路由器提供了先进的浏览器内导航功能。

注:
- 参考 [Angular9 词汇表]() 以了解对 Angular 重要名词和用法的基本定义。

模块

Angular 定义了 NgModule,它和 JavaScript(ES2015) 的模块不同而且有一定的互补性。 NgModule 为一个组件集声明了编译的上下文环境,它专注于某个应用领域、某个工作流或一组紧密相关的能力。 NgModule 可以将其组件和一组相关代码(如服务)关联起来,形成功能单元。

每个 Angular 应用都有一个根模块,通常命名为 AppModule。根模块提供了用来启动应用的引导机制。 一个应用通常会包含很多特性模块。

像 JavaScript 模块一样,NgModule 也可以从其它 NgModule 中导入功能,并允许导出它们自己的功能供其它 NgModule 使用。 比如,要在你的应用中使用路由器(Router)服务,就要导入 Router 这个 NgModule。

把你的代码组织成一些清晰的特性模块,可以帮助管理复杂应用的开发工作并实现可复用性设计。 另外,这项技术还能让你获得惰性加载(也就是按需加载模块)的优点,以尽可能减小启动时需要加载的代码体积。

注:
- 参考 [Angular9 模块简介]() 以深入了解模块。

组件

每个 Angular 应用都至少有一个组件,也就是根组件,它会把组件树和页面中的 DOM 连接起来。 每个组件都会定义一个类,其中包含应用的数据和逻辑,并与一个 HTML 模板相关联,该模板定义了一个供目标环境下显示的视图。

@Component() 装饰器表明紧随它的那个类是一个组件,并提供模板和该组件专属的元数据。

注:
- 装饰器是一些用于修饰 JavaScript 类的函数。Angular 定义了许多装饰器,这些装饰器会把一些特定种类的元数据附加到类上,以便 Angular 了解这些这些类的含义以及该如何使用它们。

模块、指令及数据绑定

模板会把 HTML 和 Angular 的标记(markup)组合起来,这些标记可以在 HTML 元素显示出来之前修改它们。 模板中的指令会提供程序逻辑,而绑定标记会把你应用中的数据和 DOM 连接在一起。 有两种类型的数据绑定:

  • 事件绑定让你的应用可以通过更新应用的数据来响应目标环境下的用户输入。

  • 属性绑定让你将从应用数据中计算出来的值插入到 HTML 中。

在视图显示出来之前,Angular 会先根据你的应用数据和逻辑来运行模板中的指令并解析绑定表达式,以修改 HTML 元素和 DOM。 Angular 支持双向数据绑定,这意味着 DOM 中发生的变化(比如用户的选择)同样可以反映回你的程序数据中。

你的模板也可以用管道转换要显示的值以增强用户体验。比如,可以使用管道来显示适合用户所在语言环境的日期和货币格式。 Angular 为一些通用的转换提供了预定义管道,你还可以定义自己的管道。

注:
- 参考 [Angular9 组件简介]() 以深入了解组件。

服务与依赖注入

对于与特定视图无关并希望跨组件共享的数据或逻辑,可以创建服务类。 服务类的定义通常紧跟在 “@Injectable()” 装饰器之后。该装饰器提供的元数据可以让你的服务作为依赖被注入到客户组件中。

依赖注入(或 DI)让你可以保持组件类的精简和高效。有了 DI,组件就不用从服务器获取数据、验证用户输入或直接把日志写到控制台,而是会把这些任务委托给服务。

注:
- 参考 [Angular9 服务和 DI 简介]() 以深入了解服务与依赖注入。

路由

Angular 的 Router 模块提供了一个服务,它可以让你定义在应用的各个不同状态和视图层次结构之间导航时要使用的路径。 它的工作模型基于人们熟知的浏览器导航约定:

  • 在地址栏输入 URL,浏览器就会导航到相应的页面。

  • 在页面中点击链接,浏览器就会导航到一个新页面。

  • 点击浏览器的前进和后退按钮,浏览器就会在你的浏览历史中向前或向后导航。

不过路由器会把类似 URL 的路径映射到视图而不是页面。 当用户执行一个动作时(比如点击链接),本应该在浏览器中加载一个新页面,但是路由器拦截了浏览器的这个行为,并显示或隐藏一个视图层次结构。

如果路由器认为当前的应用状态需要某些特定的功能,而定义此功能的模块尚未加载,路由器就会按需惰性加载此模块。

路由器会根据你应用中的导航规则和数据状态来拦截 URL。 当用户点击按钮、选择下拉框或收到其它任何来源的输入时,你可以导航到一个新视图。 路由器会在浏览器的历史日志中记录这个动作,所以前进和后退按钮也能正常工作。

要定义导航规则,你就要把导航路径和你的组件关联起来。 路径(path)使用类似 URL 的语法来和程序数据整合在一起,就像模板语法会把你的视图和程序数据整合起来一样。 然后你就可以用程序逻辑来决定要显示或隐藏哪些视图,以根据你制定的访问规则对用户的输入做出响应。

注:
- 参考 [Angular9 路由与导航]() 以深入了解路由。

NgModule 简介

Angular 应用是模块化的,它拥有自己的模块化系统,称作 NgModule。 一个 NgModule 就是一个容器,用于存放一些内聚的代码块,这些代码块专注于某个应用领域、某个工作流或一组紧密相关的功能。 它可以包含一些组件、服务提供者或其它代码文件,其作用域由包含它们的 NgModule 定义。 它还可以导入一些由其它模块中导出的功能,并导出一些指定的功能供其它 NgModule 使用。

每个 Angular 应用都至少有一个 NgModule 类,也就是根模块,它习惯上命名为 AppModule,并位于一个名叫 app.module.ts 的文件中。引导这个根模块就可以启动你的应用。

虽然小型的应用可能只有一个 NgModule,不过大多数应用都会有很多特性模块。应用的根模块之所以叫根模块,是因为它可以包含任意深度的层次化子模块。

@NgModule 元数据

NgModule 是一个带有 @NgModule() 装饰器的类。@NgModule() 装饰器是一个函数,它接受一个元数据对象,该对象的属性用来描述这个模块。其中最重要的属性如下。

  • declarations(可声明对象表) —— 那些属于本 NgModule 的组件、指令、管道。

  • exports(导出表) —— 那些能在其它模块的组件模板中使用的可声明对象的子集。

  • imports(导入表) —— 那些导出了本模块中的组件模板所需的类的其它模块。

  • providers —— 本模块向全局服务中贡献的那些服务的创建器。 这些服务能被本应用中的任何部分使用。(你也可以在组件级别指定服务提供者,这通常是首选方式。)

  • bootstrap —— 应用的主视图,称为根组件。它是应用中所有其它视图的宿主。只有根模块才应该设置这个 bootstrap 属性。

下面展示一个简单的根 NgModule 定义(Bath: "src/app/app.module.ts" ):

import { NgModule }         form `@angular/core`;import { BrowserModule }    form `@angular/platform-browser`;@NgModule({    imports:        [ BrowserModule ],    providers:      [ Logger ],    declarations:   [ AppComponent ],    exprots:        [ AppComponent ],    bootstrap:      [ AppComponent ]})export class AppModule { }

注:
- 把 AppComponent 放到 exports 中是为了演示导出的语法,这在本例子中实际上是没必要的。
- 根模块没有任何理由导出任何东西,因为其它模块永远不需要导入根模块。

NgModule 和组件

  • NgModule 为其中的组件提供了一个编译上下文环境。根模块总会有一个根组件,并在引导期间创建它。 但是,任何模块都能包含任意数量的其它组件,这些组件可以通过路由器加载,也可以通过模板创建。那些属于这个 NgModule 的组件会共享同一个编译上下文环境。

  • 组件及其模板共同定义视图。组件还可以包含视图层次结构,它能让你定义任意复杂的屏幕区域,可以将其作为一个整体进行创建、修改和销毁。 一个视图层次结构中可以混合使用由不同 NgModule 中的组件定义的视图。 这种情况很常见,特别是对一些 UI 库来说。

  • 当你创建一个组件时,它直接与一个叫做宿主视图的视图关联起来。 宿主视图可以是视图层次结构的根,该视图层次结构可以包含一些内嵌视图,这些内嵌视图又是其它组件的宿主视图。 这些组件可以位于相同的 NgModule 中,也可以从其它 NgModule 中导入。 树中的视图可以嵌套到任意深度。

注:
- 视图的这种层次结构是 Angular 在 DOM 和应用数据中检测与响应变更时的关键因素。

NgModule 和 JavaScript 的模块

NgModule 系统与 JavaScript(ES2015)用来管理 JavaScript 对象的模块系统不同,而且也没有直接关联。 这两种模块系统不同但互补。你可以使用它们来共同编写你的应用。

JavaScript 中,每个文件是一个模块,文件中定义的所有对象都从属于那个模块。 通过 export 关键字,模块可以把它的某些对象声明为公共的。 其它 JavaScript 模块可以使用import 语句来访问这些公共对象。

import { NgModule }     form '@angular/core';import { AppComponent } form './app.component';

export class AppModule { }

Angular 自带库

Angular 会作为一组 JavaScript 模块进行加载,你可以把它们看成库模块。每个 Angular 库的名称都带有 @angular 前缀。 使用 npm 包管理器安装 Angular 的库,并使用 JavaScript 的 import 语句导入其中的各个部分。

  • 如下,从@angular/core库中导入 Angular 的 Component 装饰器:

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

  • 还可以使用 JavaScript 的导入语句从 Angular 库中导入 Angular 模块。 比如,下列代码从 platform-browser 库中导入了 BrowserModule 这个 NgModule。

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

  • 在上面这个简单的根模块范例中,应用的根模块需要来自 BrowserModule 中的素材。要访问这些素材,就要把它加入 @NgModule 元数据的 imports 中,代码如下:

inports:    [ BrowserModule ],

通过这种方式,你可以同时使用 Angular 和 JavaScript 的这两种模块系统。 虽然这两种模块系统容易混淆(它们共享了同样的词汇 importexport),不过只要多用用你就会熟悉它们各自的语境了。

组件控制屏幕上被称为视图的一小片区域。比如,教程中的下列视图都是由一个个组件所定义和控制的:

  • 带有导航链接的应用根组件。

  • 英雄列表。

  • 英雄编辑器。

你在类中定义组件的应用逻辑,为视图提供支持。 组件通过一些由属性和方法组成的 API 与视图交互。

比如,HeroListComponent 中有一个 名为 heroes 的属性,它储存着一个数组的英雄数据。 HeroListComponent 还有一个 selectHero() 方法,当用户从列表中选择一个英雄时,它会设置 selectedHero 属性的值。 该组件会从服务获取英雄列表,它是一个 TypeScript 的构造器参数型属性。本服务通过依赖注入系统提供给该组件。

export class HeroListComponent implements OnInit {  heroes: Hero[];  selectedHero: Hero;  constructor(private service: HeroService) { }  ngOnInit() {    this.heroes = this.service.getHeroes();  }  selectHero(hero: Hero) { this.selectedHero = hero; }}

当用户在应用中穿行时,Angular 就会创建、更新、销毁一些组件。 你的应用可以通过一些可选的生命周期钩子(比如 ngOnInit())来在每个特定的时机采取行动。

组件的元数据

@Component 装饰器会指出紧随其后的那个类是个组件类,并为其指定元数据。 在下面的范例代码中,你可以看到 HeroListComponent 只是一个普通类,完全没有 Angular 特有的标记或语法。 直到给它加上了 @Component 装饰器,它才变成了组件。

组件的元数据告诉 Angular 到哪里获取它需要的主要构造块,以创建和展示这个组件及其视图。 具体来说,它把一个模板(无论是直接内联在代码中还是引用的外部文件)和该组件关联起来。 该组件及其模板,共同描述了一个视图。

除了包含或指向模板之外,@Component 的元数据还会配置要如何在 HTML 中引用该组件,以及该组件需要哪些服务等等。

下面的例子中就是 HeroListComponent 的基础元数据:

@Component({  selector:    'app-hero-list',  templateUrl: './hero-list.component.html',  providers:  [ HeroService ]})export class HeroListComponent implements OnInit {/* . . . */}

这个例子展示了一些最常用的 @Component 配置选项:

  • selector:是一个 CSS 选择器,它会告诉 Angular,一旦在模板 HTML 中找到了这个选择器对应的标签,就创建并插入该组件的一个实例。 比如,如果应用的 HTML 中包含 <app-hero-list></app-hero-list>,Angular 就会在这些标签中插入一个 HeroListComponent 实例的视图。

  • templateUrl:该组件的 HTML 模板文件相对于这个组件文件的地址。 另外,你还可以用 template 属性的值来提供内联的 HTML 模板。 这个模板定义了该组件的宿主视图。

  • providers:当前组件所需的服务提供者的一个数组。在这个例子中,它告诉 Angular 该如何提供一个 HeroService 实例,以获取要显示的英雄列表。

模板与视图

你要通过组件的配套模板来定义其视图。模板就是一种 HTML,它会告诉 Angular 如何渲染该组件。

视图通常会分层次进行组织,让你能以 UI 分区或页面为单位进行修改、显示或隐藏。 与组件直接关联的模板会定义该组件的宿主视图。该组件还可以定义一个带层次结构的视图,它包含一些内嵌的视图作为其它组件的宿主。

带层次结构的视图可以包含同一模块(NgModule)中组件的视图,也可以(而且经常会)包含其它模块中定义的组件的视图。

模板语法

模板很像标准的 HTML,但是它还包含 Angular 的模板语法,这些模板语法可以根据你的应用逻辑、应用状态和 DOM 数据来修改这些 HTML。 你的模板可以使用数据绑定来协调应用和 DOM 中的数据,使用管道在显示出来之前对其进行转换,使用指令来把程序逻辑应用到要显示的内容上。

比如,下面是本教程中 HeroListComponent 的模板:

<h2>Hero List</h2><p><i>Pick a hero from the list</i></p><ul>  <li *ngFor="let hero of heroes" (click)="selectHero(hero)">    {{hero.name}}  </li></ul><app-hero-detail *ngIf="selectedHero" [hero]="selectedHero"></app-hero-detail>

这个模板使用了典型的 HTML 元素,比如 <h2><p>,还包括一些 Angular 的模板语法元素,如 *ngFor{{hero.name}}click[hero]<app-hero-detail>。这些模板语法元素告诉 Angular 该如何根据程序逻辑和数据在屏幕上渲染 HTML。

  • *ngFor 指令告诉 Angular 在一个列表上进行迭代。

  • {{hero.name}}(click)[hero] 把程序数据绑定到及绑定回 DOM,以响应用户的输入。更多内容参见稍后的数据绑定部分。

  • 模板中的 <app-hero-detail> 标签是一个代表新组件 HeroDetailComponent 的元素。 HeroDetailComponent(代码略)定义了 HeroListComponent 的英雄详情子视图。 注意观察像这样的自定义组件是如何与原生 HTML 元素无缝的混合在一起的。

数据绑定

如果没有框架,你就要自己负责把数据值推送到 HTML 控件中,并把来自用户的响应转换成动作和对值的更新。 手动写这种数据推拉逻辑会很枯燥、容易出错,难以阅读 —— 有前端 JavaScript 开发经验的程序员一定深有体会。

Angular 支持双向数据绑定,这是一种对模板中的各个部件与组件中的各个部件进行协调的机制。 往模板 HTML 中添加绑定标记可以告诉 Angular 该如何连接它们。

下图显示了数据绑定标记的四种形式。每种形式都有一个方向 —— 从组件到 DOM、从 DOM 到组件或双向。

这个来自 HeroListComponent 模板中的例子展示了其中的三种形式:

<li>{{hero.name}}</li><app-hero-detail [hero]="selectedHero"></app-hero-detail><li (click)="selectHero(hero)"></li>

  • {{hero.name}} 这个插值在 <li> 标签中显示组件的 hero.name 属性的值。

  • [hero]属性绑定把父组件 HeroListComponent 的 selectedHero 的值传到子组件 HeroDetailComponent 的 hero 属性中。

  • 当用户点击某个英雄的名字时,(click) 事件绑定会调用组件的 selectHero 方法。

双向数据绑定(主要用于模板驱动表单中),它会把属性绑定和事件绑定组合成一种单独的写法。下面这个来自 HeroDetailComponent 模板中的例子通过 ngModel 指令使用了双向数据绑定:

<input [(ngModel)]="hero.name">

在双向绑定中,数据属性值通过属性绑定从组件流到输入框。用户的修改通过事件绑定流回组件,把属性值设置为最新的值。

Angular 在每个 JavaScript 事件循环中处理所有的数据绑定,它会从组件树的根部开始,递归处理全部子组件。

数据绑定在模板及其组件之间的通讯中扮演了非常重要的角色,它对于父组件和子组件之间的通讯也同样重要。

管道

Angular 的管道可以让你在模板中声明显示值的转换逻辑。 带有 @Pipe 装饰器的类中会定义一个转换函数,用来把输入值转换成供视图显示用的输出值。

Angular 自带了很多管道,比如 date 管道和 currency 管道,完整的列表参见 Pipes API 列表。你也可以自己定义一些新管道。

要在 HTML 模板中指定值的转换方式,请使用 管道操作符 (|)。

{{interpolated_value | pipe_name}}

你可以把管道串联起来,把一个管道函数的输出送给另一个管道函数进行转换。 管道还能接收一些参数,来控制它该如何进行转换。比如,你可以把要使用的日期格式传给 date 管道:

<!-- Default format: output 'Jun 15, 2015'--> <p>Today is {{today | date}}</p><!-- fullDate format: output 'Monday, June 15, 2015'--><p>The date is {{today | date:'fullDate'}}</p> <!-- shortTime format: output '9:43 AM'--> <p>The time is {{today | date:'shortTime'}}</p>

指令

Angular 的模板是动态的。当 Angular 渲染它们的时候,会根据指令给出的指示对 DOM 进行转换。 指令就是一个带有 @Directive() 装饰器的类。

组件从技术角度上说就是一个指令,但是由于组件对 Angular 应用来说非常独特、非常重要,因此 Angular 专门定义了 @Component() 装饰器,它使用一些面向模板的特性扩展了 @Directive() 装饰器。

除组件外,还有两种指令:结构型指令和属性型指令。 Angular 本身定义了一系列这两种类型的指令,你也可以使用 @Directive() 装饰器来定义自己的指令。

像组件一样,指令的元数据把它所装饰的指令类和一个 selector 关联起来,selector 用来把该指令插入到 HTML 中。 在模板中,指令通常作为属性出现在元素标签上,可能仅仅作为名字出现,也可能作为赋值目标或绑定目标出现。

1. 结构型指令

结构型指令通过添加、移除或替换 DOM 元素来修改布局。 这个范例模板使用了两个内置的结构型指令来为要渲染的视图添加程序逻辑:

<li *ngFor="let hero of heroes"></li><app-hero-detail *ngIf="selectedHero"></app-hero-detail>

  • *ngFor 是一个迭代器,它要求 Angular 为 heroes 列表中的每个英雄渲染出一个 <li>

  • *ngIf 是个条件语句,只有当选中的英雄存在时,它才会包含 HeroDetail 组件。

2. 属性型指令

属性型指令会修改现有元素的外观或行为。 在模板中,它们看起来就像普通的 HTML 属性一样,因此得名“属性型指令”。

ngModel 指令就是属性型指令的一个例子,它实现了双向数据绑定。 ngModel 修改现有元素(一般是 <input>)的行为:设置其显示属性值,并响应 change 事件。

<input [(ngModel)]="hero.name">

注:
- Angular 还有很多预定义指令,有些修改布局结构(比如 ngSwitch),有些修改 DOM 元素和组件的样子(比如 ngStylengClass)。
- 参考 [Angular9 结构型指令]() 和 [Angular9 属性型指令]() 以了解 Angular 两种指令类型。

服务与依赖注入简介

服务是一个广义的概念,它包括应用所需的任何值、函数或特性。狭义的服务是一个明确定义了用途的类。它应该做,并做好一些具体的事。

Angular 把组件和服务区分开,以提高模块性和复用性。 通过把组件中和视图有关的功能与其它类型的处理分离开,你可以让组件类更加精简、高效。

理想情况下,组件的工作只管用户体验,而不用顾及其它。 它应该提供用于数据绑定的属性和方法,以便作为视图(由模板渲染)和应用逻辑(通常包含一些模型的概念)的中介者。

组件应该把诸如从服务器获取数据、验证用户输入或直接往控制台中写日志等工作委托给各种服务。通过把各种处理任务定义到可注入的服务类中,你可以让它被任何组件使用。 通过在不同的环境中注入同一种服务的不同提供者,你还可以让你的应用更具适应性。

Angular 不会强迫您遵循这些原则。Angular 只会通过依赖注入来帮您更容易地将应用逻辑分解为服务,并让这些服务可用于各个组件中。

应用示例

以下示例,用于将日志记录到浏览器的控制台。

export class Logger {  log(msg: any)   { console.log(msg); }  error(msg: any) { console.error(msg); }  warn(msg: any)  { console.warn(msg); }}

服务也可以依赖其它服务。比如,这里的 HeroService 就依赖于 Logger 服务,它还用 BackendService 来获取英雄数据。BackendService 还可能再转而依赖 HttpClient 服务来从服务器异步获取英雄列表。

export class HeroService {  private heroes: Hero[] = [];  constructor(    private backend: BackendService,    private logger: Logger) { }  getHeroes() {    this.backend.getAll(Hero).then( (heroes: Hero[]) => {      this.logger.log(`Fetched ${heroes.length} heroes.`);      this.heroes.push(...heroes); // fill cache    });    return this.heroes;  }}

依赖注入

DI 被融入 Angular 框架中,用于在任何地方给新建的组件提供服务或所需的其它东西。 组件是服务的消费者,也就是说,你可以把一个服务注入到组件中,让组件类得以访问该服务类。

在 Angular 中,要把一个类定义为服务,就要用 @Injectable() 装饰器来提供元数据,以便让 Angular 可以把它作为依赖注入到组件中。 同样,也要使用 @Injectable() 装饰器来表明一个组件或其它类(比如另一个服务、管道或 NgModule)拥有一个依赖。

  • 注入器是主要的机制。Angular 会在启动过程中为你创建全应用级注入器以及所需的其它注入器。你不用自己创建注入器。

  • 该注入器会创建依赖、维护一个容器来管理这些依赖,并尽可能复用它们。

  • 提供者是一个对象,用来告诉注入器应该如何获取或创建依赖。

你的应用中所需的任何依赖,都必须使用该应用的注入器来注册一个提供者,以便注入器可以使用这个提供者来创建新实例。 对于服务,该提供者通常就是服务类本身。

注:
- 依赖不一定是服务,它也有可能是函数或者值。

当 Angular 创建组件类的新实例时,它会通过查看该组件类的构造函数,来决定该组件依赖哪些服务或其它依赖项。 比如 HeroListComponent 的构造函数中需要 HeroService:

constructor(private service: HeroService) { }

当 Angular 发现某个组件依赖某个服务时,它会首先检查是否该注入器中已经有了那个服务的任何现有实例。如果所请求的服务尚不存在,注入器就会使用以前注册的服务提供者来制作一个,并把它加入注入器中,然后把该服务返回给 Angular。

当所有请求的服务已解析并返回时,Angular 可以用这些服务实例为参数,调用该组件的构造函数。

HeroService 的注入过程如下所示:

提供服务

对于要用到的任何服务,您必须至少注册一个提供者。服务可以在自己的元数据中把自己注册为提供者,这样可以让自己随处可用。或者,您也可以为特定的模块或组件注册提供者。要注册提供者,就要在服务的 @Injectable() 装饰器中提供它的元数据,或者在 @NgModule()@Component() 的元数据中。

  • 默认情况下,Angular CLI 的 ng generate service 命令会在 @Injectable() 装饰器中提供元数据来把它注册到根注入器中。本教程就用这种方法注册了 HeroService 的提供者:

@Injectable({  providedIn: 'root',})

当你在根一级提供服务时,Angular 会为 HeroService 创建一个单一的共享实例,并且把它注入到任何想要它的类中。这种在 @Injectable 元数据中注册提供者的方式还让 Angular 能够通过移除那些从未被用过的服务来优化大小。

  • 当你使用特定的 NgModule 注册提供者时,该服务的同一个实例将会对该 NgModule 中的所有组件可用。要想在这一层注册,请用 @NgModule() 装饰器中的 providers 属性:

@NgModule({  providers: [   BackendService,   Logger ], ...})

  • 当您在组件级注册提供者时,您会为该组件的每一个新实例提供该服务的一个新实例。 要在组件级注册,就要在 @Component() 元数据的 providers 属性中注册服务提供者。

@Component({  selector:    'app-hero-list',  templateUrl: './hero-list.component.html',  providers:  [ HeroService ]})

工具与技巧

在了解了基本的 Angular 构建块之后,您可以进一步了解可以帮助你开发和交付 Angular 应用的特性和工具。

  • 参考“英雄指南”教程,了解如何将这些基本构建块放在一起,来创建设计精良的应用。

  • 查看词汇表,了解 Angular 特有的术语和用法。

  • 根据您的开发阶段和感兴趣的领域,使用该文档更深入地学习某些关键特性。

应用架构

  • 组件与模板一章中介绍了如何把组件中的应用数据与页面显示模板联系起来,以创建一个完整的交互式应用。

  • NgModules 一章中提供了关于 Angular 应用模块化结构的深度信息。

  • 路由与导航一章中提供了一些深度信息,教您如何构造出一个允许用户导航到单页面应用中不同视图 的应用。

  • 依赖注入一章提供了一些深度信息,教您如何让每个组件类都可以获取实现其功能所需的服务和对象。

响应式编程

“组件和模板”一章提供了模板语法的指南和详细信息,用于在视图中随时随地显示组件数据,并从用户那里收集输入,以便做出响应。

其它页面和章节则描述了 Angular 应用的一些基本编程技巧。

  • 生命周期钩子:通过实现生命周期钩子接口,可以窃听组件生命周期中的一些关键时刻 —— 从创建到销毁。

  • 可观察对象(Observable)和事件处理:如何在组件和服务中使用可观察对象来发布和订阅任意类型的消息,比如用户交互事件和异步操作结果。

  • Angular 自定义元素:如何使用 Web Components 把组件打包成自定义元素,Web Components 是一种以框架无关的方式定义新 HTML 元素的 Web 标准。

  • 表单:通过基于 HTML 的输入验证,来支持复杂的数据录入场景。

  • 动画:使用 Angular 的动画库,您可以让组件支持动画行为,而不用深入了解动画技术或 CSS。

“客户端-服务器”交互

Angular 为单页面应用提供了一个框架,其中的大多数逻辑和数据都留在客户端。大多数应用仍然需要使用 HttpClient 来访问服务器,以访问和保存数据。对于某些平台和应用,您可能还希望使用 PWA(渐进式 Web 应用)模型来改善用户体验。

  • HTTP:与服务器通信,通过 HTTP 客户端来获取数据、保存数据,并调用服务端的动作。

  • 服务器端渲染:Angular Universal 通过服务器端渲染(SSR)在服务器上生成静态应用页面。这允许您在服务器上运行 Angular 应用,以提高性能,并在移动设备和低功耗设备上快速显示首屏,同时也方便了网页抓取工具。

  • Service Worker 和 PWA:使用 Service Worker 来减少对网络的依赖,并显著改善用户体验。

  • Web worker:学习如何在后台线程中运行 CPU 密集型的计算。

为开发周期提供支持

“开发工作流”部分描述了用于编译、测试和部署 Angular 应用的工具和过程。

  • CLI 命令参考手册:Angular CLI 是一个命令行工具,可用于创建项目、生成应用和库代码,以及执行各种持续开发任务,如测试、打包和部署。

  • 编译:Angular 为开发环境提供了 JIT(即时)编译方式,为生产环境提供了 AOT(预先)编译方式。

  • 测试平台:对应用的各个部件运行单元测试,让它们好像在和 Angular 框架交互一样。

  • 部署:学习如何把 Angular 应用部署到远端服务器上。

  • 安全指南:学习 Angular 对常见 Web 应用的弱点和工具(比如跨站脚本攻击)提供的内置防护措施。

  • 国际化 :借助 Angular 的国际化(i18n)工具,可以让您的应用支持多语言环境。

  • 无障碍性:让所有用户都能访问您的应用。

文件结构、配置和依赖

  • 工作区与文件结构:理解 Angular 工作区与项目文件夹的结构。

  • 构建与运行:学习为项目定义不同的构建和代理服务器设置的配置方式,比如开发、预生产和生产。

  • npm 包:Angular 框架、Angular CLI 和 Angular 应用中用到的组件都是用 npm 打包的,并通过 npm 注册服务器进行发布。Angular CLI 会创建一个默认的 package.json 文件,它会指定一组初始的包,它们可以一起使用,共同支持很多常见的应用场景。

  • TypeScript 配置:TypeScript 是 Angular 应用开发的主要语言。

  • 浏览器支持:让您的应用能和各种浏览器兼容。

扩展 Angular

  • Angular 库:学习如何使用和创建可复用的库。

  • 学习原理图 :学习如何自定义和扩展 CLI 的生成(generate)能力。

  • CLI 构建器:学习如何自定义和扩展 CLI 的能力,让它使用工具来执行复杂任务,比如构建和测试应用。

在“英雄指南”中,您将从头开始构建自己的应用,体验典型的开发过程。

本指南向您展示了如何使用 Angular CLI 工具搭建本地开发环境并开发应用;对 Angular CLI 工具的基础知识进行了介绍。

您建立的应用可以帮助“神盾局”管理好自己的英雄们。该应用具有许多在任何数据驱动的应用完成后的应用会获取一些英雄列表、编辑所选英雄的详细信息,并在不同的英雄数据之间导航。

您会在“英雄指南”中用到的很多个示例中找到对此应用领域的引用和扩展,但您并不一定非要通过“英雄指南”来理解这些例子。

“英雄指南”工作内容:

  1. 使用 Angular 内置指令来显示或隐藏元素,并显示英雄数据列表。

  1. 创建 Angular 组件以显示英雄的详情,并显示一个英雄数组。

  1. 为只读数据使用单项数据绑定。

  1. 添加可编辑字段,使用双向数据绑定来更新模型。

  1. 把组件中的方法绑定到用户事件上,比如案件和点击。

  1. 让用户可以在主列表中选择一个英雄,然后在详情视图中编辑他。

  1. 使用管道来格式化数据。

  1. 创建共享的服务来管理这些英雄。

  1. 使用路由在不同的视图及其组件之间导航。

应用外壳

使用 Angular CLI 来创建最初的应用程序。在“英雄指南”中,您将对该入门级的应用程序进行修改和拓展,以创建出“英雄指南”应用。

所需工作:

  1. 设置开发环境。

  1. 创建新的工作区,并初始化应用项目。

  1. 启动开发服务器。

  1. 修改此应用。

搭建开发环境

要想搭建开发环境,请遵循[搭建本地环境]()中的步骤进行操作。

创建新的工作区和一个初始应用

Angular 工作区是您开发应用所在的上下文环境。一个工作区包含一个或多个项目所需的文件。每个项目都是一组由应用、库或端到端(e2e)测试组合的文件集合。

创建新的工作区和初始应用项目的步骤:

  1. 确保你现在没有位于 Angular 工作区的文件夹中。例如,如果你之前已经创建过其他工作区,请回到其父目录中。

  1. 运行 CLI 命令 ng new,空间名请使用 angular-tour-of-heroes,如下所示:

    ng new angular-tour-of-heroes

  1. ng new 命令会提示你输入要在初始应用项目中包含哪些特性,请按 Enter 或 Return 键接受其默认值。

Angular CLI 会安装必要的 Angular npm 包和其它依赖项。这可能需要几分钟。

它还会创建下列工作区和初始项目的文件:

  • 新的工作区,其根目录名叫 angular-tour-of-heroes。

  • 一个最初的骨架应用项目,同样叫做 angular-tour-of-heroes(位于 src 子目录下)。

  • 一个端到端测试项目(位于 e2e 子目录下)。

  • 相关的配置文件。

初始应用项目是一个简单的 "欢迎" 应用,随时可以运行它。

启动应用程序

在终端进入工作目录,并启动这个应用。

cd angular-tour-of-heroesng serve --open

注:
ng serve命令会构建本应用,启动开发度武器,监听源文件,并且当那些文件发生变化时重新构建本应用。
--open会打开浏览器访问 "http://localhost:4200/" 。

Angular 组件

您所看到的页面就是所谓的应用外壳。这个外壳是被一个名叫 AppComponent 的 Angular 组件控制的。

组件是 Angular 应用中的基本构造块。 它们在屏幕上显示数据,监听用户输入,并且根据这些输入执行相应的动作。

修改应用标题

用您最喜欢的编辑器或 IDE 打开这个项目,并访问src/app目录,来对这个起始应用做一些修改。

您会在这里看到 AppComponent 壳的三个实现文件:

  1. app.component.ts— 组件的类代码,这是用 TypeScript 写的。

  1. app.component.html— 组件的模板,这是用 HTML 写的。

  1. app.component.css— 组件的模板,这是用 CSS 写的。

更改应用标题

打开组件的类文件( app.component.ts ),并把 title 属性的值修改为 'Tour of Heros'。

Path:"app.component.ts (class title property)"

title = 'Tour of Heroes';

打开组文件的模板文件 app.component.html 并清空 Angular CLI 自动生成的默认模板。改为下列 HTML 内容:

Path:"app.component.html (template)"

<h1>{{title}}</h1>

双花括号语法是 Angular 的插值绑定语法。 这个插值绑定的意思是把组件的 title 属性的值绑定到 HTML 中的 h1 标记中。

浏览器会自动刷新,并且显示出了新的应用标题。

添加应用样式

大多数应用都会努力让整个应用保持一致的外观。 因此,CLI 会生成一个空白的 styles.css 文件。 你可以把全应用级别的样式放进去。

打开 src/styles.css 并把下列代码添加到此文件中。

Path:"src/styles.css (excerpt)"

/* Application-wide Styles */h1 {  color: #369;  font-family: Arial, Helvetica, sans-serif;  font-size: 250%;}h2, h3 {  color: #444;  font-family: Arial, Helvetica, sans-serif;  font-weight: lighter;}body {  margin: 2em;}body, input[type="text"], button {  color: #333;  font-family: Cambria, Georgia;}/* everywhere else */* {  font-family: Arial, Helvetica, sans-serif;}

查看源代码

  1. Path:"src/app/app.component.ts"

    import { Component } from '@angular/core';    @Component({      selector: 'app-root',      templateUrl: './app.component.html',      styleUrls: ['./app.component.css']    })    export class AppComponent {      title = 'Tour of Heroes';    }

  1. Path:"src/app/app.component.html"

    <h1>{{title}}</h1>

  1. Path:"src/styles.css(excerpt)"

    /* Application-wide Styles */    h1 {      color: #369;      font-family: Arial, Helvetica, sans-serif;      font-size: 250%;    }    h2, h3 {      color: #444;      font-family: Arial, Helvetica, sans-serif;      font-weight: lighter;    }    body {      margin: 2em;    }    body, input[type="text"], button {      color: #333;      font-family: Cambria, Georgia;    }    /* everywhere else */    * {      font-family: Arial, Helvetica, sans-serif;    }

总结

  • 您使用 Angular CLI 创建了初始的应用结构。

  • 您学会了使用 Angular 组件来显示数据。

  • 您使用双花括号插值显示了应用标题。

应用程序现在有了基本的标题。 接下来您要创建一个新的组件来显示英雄信息并且把这个组件放到应用程序的外壳里去。

创建英雄列表组件

使用 Angular CLI 创建一个名为 heroes 的新组件

ng generate component heroes

CLI 创建了一个新的文件夹 "src/app/heroes/",并生成了 HeroesComponent 的四个文件。其类文件如下:

Path:"app/heroes/heroes.component.ts (initial version)"

import { Component, OnInit } from '@angular/core';@Component({  selector: 'app-heroes',  templateUrl: './heroes.component.html',  styleUrls: ['./heroes.component.css']})export class HeroesComponent implements OnInit {  constructor() { }  ngOnInit() {  }}

你要从 Angular 核心库中导入 Component 符号,并为组件类加上 @Component 装饰器。

@Component 是个装饰器函数,用于为该组件指定 Angular 所需的元数据。

CLI 自动生成了三个元数据属性:

  • selector— 组件的选择器(CSS 元素选择器)

  • templateUrl— 组件模板文件的位置。

  • styleUrls— 组件私有 CSS 样式表文件的位置。

CSS 元素选择器 app-heroes 用来在父组件的模板中匹配 HTML 元素的名称,以识别出该组件。

ngOnInit() 是一个生命周期钩子,Angular 在创建完组件后很快就会调用 ngOnInit()。这里是放置初始化逻辑的好地方。

始终要 export 这个组件类,以便在其它地方(比如 AppModule)导入它。

添加 hero 属性

往 HeroesComponent 中添加一个 hero 属性,用来表示一个名叫 “W3Cschool” 的英雄。

Path:"heroes.component.ts (hero property)"

hero = 'Windstorm';

显示英雄

打开模板文件 "heroes.component.html"。删除 Angular CLI 自动生成的默认内容,改为到 hero 属性的数据绑定。

Path:"heroes.component.html"

{{hero}}

显示 HeroesComponent 视图

要显示 HeroesComponent 你必须把它加到壳组件 AppComponent 的模板中。

别忘了,app-heroes 就是 HeroesComponent 的 元素选择器。 所以,只要把 <app-heroes> 元素添加到 AppComponent 的模板文件中就可以了,就放在标题下方。

Path:"src/app/app.component.html"

<h1>{{title}}</h1><app-heroes></app-heroes>

如果 CLI 的 ng serve 命令仍在运行,浏览器就会自动刷新,并且同时显示出应用的标题和英雄的名字。

创建 Hero 类

真实的英雄当然不止一个名字。

src/app 文件夹中为 Hero 类创建一个文件,并添加 id 和 name 属性。

Path:"src/app/hero.ts"

export interface Hero {  id: number;  name: string;}

回到 HeroesComponent 类,并且导入这个 Hero 类。

把组件的 hero 属性的类型重构为 Hero。 然后以 1 为 id、以 “W3Cschool” 为名字初始化它。

修改后的 HeroesComponent 类如下:

Path:"src/app/heroes/heroes.component.ts"

import { Component, OnInit } from '@angular/core';import { Hero } from '../hero';@Component({  selector: 'app-heroes',  templateUrl: './heroes.component.html',  styleUrls: ['./heroes.component.css']})export class HeroesComponent implements OnInit {  hero: Hero = {    id: 1,    name: 'Windstorm'  };  constructor() { }  ngOnInit() {  }}

页面显示变得不正常了,因为你刚刚把 hero 从字符串改成了对象。

显示 hero 对象

修改模板中的绑定,以显示英雄的名字,并在详情中显示 id 和 name,就像这样:

Path:"heroes.component.html (HeroesComponent's template)"

<h2>{{hero.name}} Details</h2><div><span>id: </span>{{hero.id}}</div><div><span>name: </span>{{hero.name}}</div>

浏览器自动刷新,并显示这位英雄的信息。

使用 UppercasePipe 进行格式化

把 hero.name 的绑定修改成这样:

Path:"src/app/heroes/heroes.component.html"

<h2>{{hero.name | uppercase}} Details</h2>

浏览器刷新了。现在,英雄的名字显示成了大写字母。

绑定表达式中的 uppercase 位于管道操作符( | )的右边,用来调用内置管道 UppercasePipe。

管道 是格式化字符串、金额、日期和其它显示数据的好办法。 Angular 发布了一些内置管道,而且你还可以创建自己的管道。

编辑英雄名字

用户应该能在一个 <input> 输入框中编辑英雄的名字。

当用户输入时,这个输入框应该能同时显示和修改英雄的 name 属性。 也就是说,数据流从组件类流出到屏幕,并且从屏幕流回到组件类。

要想让这种数据流动自动化,就要在表单元素 <input> 和组件的 hero.name 属性之间建立双向数据绑定。

双向绑定

把模板中的英雄详情区重构成这样:

Path:"src/app/heroes/heroes.component.html (HeroesComponent's template)"

<div>  <label>name:    <input [(ngModel)]="hero.name" placeholder="name"/>  </label></div>

[(ngModel)] 是 Angular 的双向数据绑定语法。

这里把 hero.name 属性绑定到了 HTML 的 textbox 元素上,以便数据流可以双向流动:从 hero.name 属性流动到 textbox,并且从 textbox 流回到 hero.name

缺少 FormsModule

注意,当你加上 [(ngModel)] 之后这个应用无法工作了。

打开浏览器的开发工具,就会在控制台中看到如下信息:

Template parse errors:Can't bind to 'ngModel' since it isn't a known property of 'input'.

虽然 ngModel 是一个有效的 Angular 指令,不过它在默认情况下是不可用的。

它属于一个可选模块 FormsModule,你必须自行添加此模块才能使用该指令。

AppModule

Angular 需要知道如何把应用程序的各个部分组合到一起,以及该应用需要哪些其它文件和库。 这些信息被称为元数据(metadata)。

有些元数据位于 @Component 装饰器中,你会把它加到组件类上。 另一些关键性的元数据位于 @NgModule 装饰器中。

最重要的 @NgModule 装饰器位于顶层类 AppModule 上。

Angular CLI 在创建项目的时候就在 "src/app/app.module.ts" 中生成了一个 AppModule 类。 这里也就是你要添加 FormsModule 的地方。

导入 FormsModule

打开 AppModule (app.module.ts) 并从 @angular/forms 库中导入 FormsModule 符号。

Path:"app.module.ts (FormsModule symbol import)"

import { FormsModule } from '@angular/forms'; // <-- NgModel lives here

然后把 FormsModule 添加到 @NgModule 元数据的 imports 数组中,这里是该应用所需外部模块的列表。

Path:"app.module.ts (@NgModule imports)"

imports: [  BrowserModule,  FormsModule],

刷新浏览器,应用又能正常工作了。你可以编辑英雄的名字,并且会看到这个改动立刻体现在这个输入框上方的 <h2> 中。

声明 HeroesComponent

每个组件都必须声明在(且只能声明在)一个 NgModule 中。

你没有声明过 HeroesComponent,可为什么本应用却正常呢?

这是因为 Angular CLI 在生成 HeroesComponent 组件的时候就自动把它加到了 AppModule 中。

打开 "src/app/app.module.ts" 你就会发现 HeroesComponent 已经在顶部导入过了。

Path:"src/app/app.module.ts"

import { HeroesComponent } from './heroes/heroes.component';

HeroesComponent 也已经声明在了 @NgModule.declarations 数组中。

Path:"src/app/app.module.ts"

declarations: [  AppComponent,  HeroesComponent],

注:
AppModule 声明了应用中的所有组件,AppComponent 和 HeroesComponent。

Final code review

本篇设计的代码如下:

  1. Path:"src/app/heroes/heroes.component.ts"

import { Component, OnInit } from '@angular/core';import { Hero } from '../hero';@Component({  selector: 'app-heroes',  templateUrl: './heroes.component.html',  styleUrls: ['./heroes.component.css']})export class HeroesComponent implements OnInit {  hero: Hero = {    id: 1,    name: 'Windstorm'  };  constructor() { }  ngOnInit() {  }}

  1. Path:"src/app/heroes/heroes.component.html"

<h2>{{hero.name | uppercase}} Details</h2><div><span>id: </span>{{hero.id}}</div><div>  <label>name:    <input [(ngModel)]="hero.name" placeholder="name"/>  </label></div>

  1. Path:"src/app/app.module.ts"

import { BrowserModule } from '@angular/platform-browser';import { NgModule } from '@angular/core';import { FormsModule } from '@angular/forms'; // <-- NgModel lives hereimport { AppComponent } from './app.component';import { HeroesComponent } from './heroes/heroes.component';@NgModule({  declarations: [    AppComponent,    HeroesComponent  ],  imports: [    BrowserModule,    FormsModule  ],  providers: [],  bootstrap: [AppComponent]})export class AppModule { }

  1. Path:"src/app/app.component.ts"

import { Component } from '@angular/core';@Component({  selector: 'app-root',  templateUrl: './app.component.html',  styleUrls: ['./app.component.css']})export class AppComponent {  title = 'Tour of Heroes';}

  1. Path:"src/app/app.component.html"

<h1>{{title}}</h1><app-heroes></app-heroes>

  1. Path:"src/app/hero.ts"

export interface Hero {  id: number;  name: string;}

总结

  • 您使用 CLI 创建了第二个组件 HeroesComponent。

  • 您把 HeroesComponent 添加到了壳组件 AppComponent 中,以便显示它。

  • 您使用 UppercasePipe 来格式化英雄的名字。

  • 您用 ngModel 指令实现了双向数据绑定。

  • 您知道了 AppModule。

  • 您把 FormsModule 导入了 AppModule,以便 Angular 能识别并应用 ngModel 指令。

  • 您知道了把组件声明到 AppModule 是很重要的,并认识到 CLI 会自动帮你声明它。

您将扩展“英雄指南”应用,让它显示一个英雄列表, 并允许用户选择一个英雄,并查看该英雄的详细信息。

创建模拟(mock)的英雄数据

您需要一些英雄数据以供显示。

最终,您会从远端的数据服务器获取它。 不过目前,您要先创建一些模拟的英雄数据,并假装它们是从服务器上取到的。

在 "src/app/" 文件夹中创建一个名叫 "mock-heroes.ts" 的文件。 定义一个包含十个英雄的常量数组 HEROES,并导出它。 该文件是这样的。

Path:"src/app/mock-heroes.ts"

import { Hero } from './hero';export const HEROES: Hero[] = [  { id: 11, name: 'Dr Nice' },  { id: 12, name: 'Narco' },  { id: 13, name: 'Bombasto' },  { id: 14, name: 'Celeritas' },  { id: 15, name: 'Magneta' },  { id: 16, name: 'RubberMan' },  { id: 17, name: 'Dynama' },  { id: 18, name: 'Dr IQ' },  { id: 19, name: 'Magma' },  { id: 20, name: 'Tornado' }];

显示这些英雄

打开 HeroesComponent 类文件,并导入模拟的 HEROES

Path:"src/app/heroes/heroes.component.ts (import HEROES)"

import { HEROES } from '../mock-heroes';

往类中添加一个 heroes 属性,这样可以暴露出这个 HEROES 数组,以供绑定。

Path:"src/app/heroes/heroes.component.ts"

export class HeroesComponent implements OnInit {  heroes = HEROES;}

使用 *ngFor 列出这些英雄

打开 HeroesComponent 的模板文件,并做如下修改:

  • 在顶部添加 <h2>

  • 然后添加表示无序列表的 HTML 元素(<ul>

  • <ul> 中插入一个 <li> 元素,以显示单个 hero 的属性。

  • 点缀上一些 CSS 类(稍后你还会添加更多 CSS 样式)。

完成后如下:

Path:"heroes.component.html (heroes template)"

<h2>My Heroes</h2><ul class="heroes">  <li>    <span class="badge">{{hero.id}}</span> {{hero.name}}  </li></ul>

这只展示了一个英雄。要想把他们都列出来,就要为 <li> 添加一个 *ngFor 以便迭代出列表中的所有英雄:

<li *ngFor="let hero of heroes">

*ngFor 是一个 Angular 的复写器(repeater)指令。 它会为列表中的每项数据复写它的宿主元素。

这个例子中涉及的语法如下:

  • <li> 就是 *ngFor 的宿主元素。

  • heroes 就是来自 HeroesComponent 类的列表。

  • 当依次遍历这个列表时,hero 会为每个迭代保存当前的英雄对象。

注:
- ngFor 前面的星号(*)是该语法中的关键部分。

更改列表外观

英雄列表应该富有吸引力,并且当用户把鼠标移到某个英雄上和从列表中选中某个英雄时,应该给出视觉反馈。

在教程的第一章,你曾在 styles.css 中为整个应用设置了一些基础的样式。 但那个样式表并不包含英雄列表所需的样式。

固然,你可以把更多样式加入到 styles.css,并且放任它随着你添加更多组件而不断膨胀。

但还有更好的方式。你可以定义属于特定组件的私有样式,并且让组件所需的一切(代码、HTML 和 CSS)都放在一起。

这种方式让你在其它地方复用该组件更加容易,并且即使全局样式和这里不一样,组件也仍然具有期望的外观。

你可以用多种方式定义私有样式,或者内联在 @Component.styles 数组中,或者在 @Component.styleUrls 所指出的样式表文件中。

当 CLI 生成 HeroesComponent 时,它也同时为 HeroesComponent 创建了空白的 heroes.component.css 样式表文件,并且让 @Component.styleUrls 指向它,像这样:

Path:"src/app/heroes/heroes.component.ts (@Component)"

@Component({  selector: 'app-heroes',  templateUrl: './heroes.component.html',  styleUrls: ['./heroes.component.css']})

打开 heroes.component.css 文件,并且把 HeroesComponent 的私有 CSS 样式粘贴进去。 你可以在本指南底部的查看最终代码中找到它们。

主从结构

当用户在主列表中点击一个英雄时,该组件应该在页面底部显示所选英雄的详情。

在本节,你将监听英雄条目的点击事件,并更新英雄的详情。

添加 Click 事件绑定

再往 <li> 元素上插入一句点击事件的绑定代码:

Path:"heroes.component.html (template excerpt)"

<li *ngFor="let hero of heroes" (click)="onSelect(hero)">

这是 Angular 事件绑定 语法的例子。

click 外面的圆括号会让 Angular 监听这个 <li> 元素的 click 事件。 当用户点击 <li> 时,Angular 就会执行表达式 onSelect(hero)

下一部分,会在 HeroesComponent 上定义一个 onSelect() 方法,用来显示 *ngFor 表达式所定义的那个英雄(hero)。

添加 click 事件处理器

把该组件的 hero 属性改名为 selectedHero,但不要为它赋值。 因为应用刚刚启动时并没有所选英雄。

添加如下 onSelect() 方法,它会把模板中被点击的英雄赋值给组件的 selectedHero 属性。

Path:"src/app/heroes/heroes.component.ts (onSelect)"

selectedHero: Hero;onSelect(hero: Hero): void {  this.selectedHero = hero;}

添加详情区

现在,组件的模板中有一个列表。要想点击列表中的一个英雄,并显示该英雄的详情,你需要在模板中留一个区域,用来显示这些详情。 在 heroes.component.html 中该列表的紧下方,添加如下代码:

Path:"heroes.component.html (selected hero details)"

<h2>{{selectedHero.name | uppercase}} Details</h2><div><span>id: </span>{{selectedHero.id}}</div><div>  <label>name:    <input [(ngModel)]="selectedHero.name" placeholder="name"/>  </label></div>

刷新浏览器,你会发现应用挂了。

打开浏览器的开发者工具,它的控制台中显示出如下错误信息:

HeroesComponent.html:3 ERROR TypeError: Cannot read property 'name' of undefined

当应用启动时,selectedHeroundefined,没有问题。

但模板中的绑定表达式引用了 selectedHero 的属性(表达式为 {{selectedHero.name}}),这必然会失败,因为你还没选过英雄呢。

修复 —— 使用 *ngIf 隐藏空白的详情

该组件应该只有当 selectedHero 存在时才显示所选英雄的详情。

把显示英雄详情的 HTML 包裹在一个 <div> 中。 并且为这个 div 添加 Angular 的 *ngIf 指令,把它的值设置为 selectedHero

*Path:"src/app/heroes/heroes.component.html (ngIf)"**

<div *ngIf="selectedHero">  <h2>{{selectedHero.name | uppercase}} Details</h2>  <div><span>id: </span>{{selectedHero.id}}</div>  <div>    <label>name:      <input [(ngModel)]="selectedHero.name" placeholder="name"/>    </label>  </div></div>

浏览器刷新之后,英雄名字的列表又出现了。 详情部分仍然是空。 从英雄列表中点击一个英雄,它的详情就出现了。 应用又能工作了。 英雄们出现在列表中,而被点击的英雄出现在了页面底部。

注:
- 当 selectedHeroundefined 时,ngIf 从 DOM 中移除了英雄详情。因此也就不用关心 selectedHero 的绑定了。

  • 当用户选择一个英雄时,selectedHero 也就有了值,并且 ngIf 把英雄的详情放回到 DOM 中。

给所选英雄添加样式

所有的 <li> 元素看起来都是一样的,因此很难从列表中识别出所选英雄。

如果用户点击了“Magneta”,这个英雄应该用一个略有不同的背景色显示出来,就像这样:

所选英雄的颜色来自于你前面添加的样式中的 CSS 类 .selected。 所以你只要在用户点击一个 <li> 时把 .selected 类应用到该元素上就可以了。

Angular 的 CSS 类绑定机制让根据条件添加或移除一个 CSS 类变得很容易。 只要把 [class.some-css-class]="some-condition" 添加到你要施加样式的元素上就可以了。

在 HeroesComponent 模板中的 <li> 元素上添加 [class.selected] 绑定,代码如下:

Path:"heroes.component.html (toggle the 'selected' CSS class)"

[class.selected]="hero === selectedHero"

如果当前行的英雄和 selectedHero 相同,Angular 就会添加 CSS 类 selected,否则就会移除它。

最终的 <li> 是这样的:

Path:"heroes.component.html (list item hero)"

<li *ngFor="let hero of heroes"  [class.selected]="hero === selectedHero"  (click)="onSelect(hero)">  <span class="badge">{{hero.id}}</span> {{hero.name}}</li>

查看最终代码

  1. Path:"src/app/mock-heroes.ts"

    export const HEROES: Hero[] = [      { id: 11, name: 'Dr Nice' },      { id: 12, name: 'Narco' },      { id: 13, name: 'Bombasto' },      { id: 14, name: 'Celeritas' },      { id: 15, name: 'Magneta' },      { id: 16, name: 'RubberMan' },      { id: 17, name: 'Dynama' },      { id: 18, name: 'Dr IQ' },      { id: 19, name: 'Magma' },      { id: 20, name: 'Tornado' }    ];

  1. Path:"src/app/heroes/heroes.component.ts"

    import { Component, OnInit } from '@angular/core';    import { Hero } from '../hero';    import { HEROES } from '../mock-heroes';    @Component({      selector: 'app-heroes',      templateUrl: './heroes.component.html',      styleUrls: ['./heroes.component.css']    })    export class HeroesComponent implements OnInit {      heroes = HEROES;      selectedHero: Hero;      constructor() { }      ngOnInit() {      }      onSelect(hero: Hero): void {        this.selectedHero = hero;      }    }

  1. Path:"src/app/heroes/heroes.component.html"

    <h2>My Heroes</h2>    <ul class="heroes">      <li *ngFor="let hero of heroes"        [class.selected]="hero === selectedHero"        (click)="onSelect(hero)">        <span class="badge">{{hero.id}}</span> {{hero.name}}      </li>    </ul>    <div *ngIf="selectedHero">      <h2>{{selectedHero.name | uppercase}} Details</h2>      <div><span>id: </span>{{selectedHero.id}}</div>      <div>        <label>name:          <input [(ngModel)]="selectedHero.name" placeholder="name"/>        </label>      </div>    </div>

  1. Path:"src/app/heroes/heroes.component.css"

    /* HeroesComponent's private CSS styles */    .heroes {      margin: 0 0 2em 0;      list-style-type: none;      padding: 0;      width: 15em;    }    .heroes li {      cursor: pointer;      position: relative;      left: 0;      background-color: #EEE;      margin: .5em;      padding: .3em 0;      height: 1.6em;      border-radius: 4px;    }    .heroes li:hover {      color: #607D8B;      background-color: #DDD;      left: .1em;    }    .heroes li.selected {      background-color: #CFD8DC;      color: white;    }    .heroes li.selected:hover {      background-color: #BBD8DC;      color: white;    }    .heroes .badge {      display: inline-block;      font-size: small;      color: white;      padding: 0.8em 0.7em 0 0.7em;      background-color:#405061;      line-height: 1em;      position: relative;      left: -1px;      top: -4px;      height: 1.8em;      margin-right: .8em;      border-radius: 4px 0 0 4px;    }

总结

  • 英雄指南应用在一个主从视图中显示了英雄列表。

  • 用户可以选择一个英雄,并查看该英雄的详情。

  • 您使用 *ngFor 显示了一个列表。

  • 您使用 *ngIf 来根据条件包含或排除了一段 HTML。

  • 您可以用 class 绑定来切换 CSS 的样式类。

主从组件

此刻,HeroesComponent 同时显示了英雄列表和所选英雄的详情。

把所有特性都放在同一个组件中,将会使应用“长大”后变得不可维护。 你要把大型组件拆分成小一点的子组件,每个子组件都要集中精力处理某个特定的任务或工作流。

本页面中,你将迈出第一步 —— 把英雄详情移入一个独立的、可复用的 HeroDetailComponent。

HeroesComponent 将仅仅用来表示英雄列表。 HeroDetailComponent 将用来表示所选英雄的详情。

制作 HeroDetailComponent

使用 Angular CLI 生成一个名叫 hero-detail 的新组件。

ng generate component hero-detail

这个命令会做这些事:

  • 创建目录 "src/app/hero-detail"。

在这个目录中会生成四个文件:

  • 作为组件样式的 CSS 文件。

  • 作为组件模板的 HTML 文件。

  • 存放组件类 HeroDetailComponent 的 TypeScript 文件。

  • HeroDetailComponent 类的测试文件。

该命令还会把 HeroDetailComponent 添加到 "src/app/app.module.ts" 文件中 @NgModuledeclarations 列表中。

编写模板

从 HeroesComponent 模板的底部把表示英雄详情的 HTML 代码剪切粘贴到所生成的 HeroDetailComponent 模板中。

所粘贴的 HTML 引用了 selectedHero。 新的 HeroDetailComponent 可以展示任意英雄,而不仅仅所选的。因此还要把模板中的所有 selectedHero 替换为 hero

完工之后,HeroDetailComponent 的模板应该是这样的:

Path:"src/app/hero-detail/hero-detail.component.html"

<div *ngIf="hero">  <h2>{{hero.name | uppercase}} Details</h2>  <div><span>id: </span>{{hero.id}}</div>  <div>    <label>name:      <input [(ngModel)]="hero.name" placeholder="name"/>    </label>  </div></div>

添加 @Input() hero 属性

HeroDetailComponent 模板中绑定了组件中的 hero 属性,它的类型是 Hero

打开 HeroDetailComponent 类文件,并导入 Hero 符号。

Path:"src/app/hero-detail/hero-detail.component.ts (import Hero)"

import { Hero } from '../hero';

hero 属性必须是一个带有 @Input() 装饰器的输入属性,因为外部的 HeroesComponent 组件将会绑定到它。就像这样:

<app-hero-detail [hero]="selectedHero"></app-hero-detail>

修改 @angular/core 的导入语句,导入 Input 符号。

Path:"src/app/hero-detail/hero-detail.component.ts (import Input)"

import { Component, OnInit, Input } from '@angular/core';

添加一个带有 @Input() 装饰器的 hero 属性。

Path:"src/app/hero-detail/hero-detail.component.ts"

@Input() hero: Hero;

这就是你要对 HeroDetailComponent 类做的唯一一项修改。 没有其它属性,也没有展示逻辑。这个组件所做的只是通过 hero 属性接收一个英雄对象,并显示它。

显示 HeroDetailComponent

HeroesComponent 仍然是主从视图。

在你从模板中剪切走代码之前,它自己负责显示英雄的详情。现在它要把这个职责委托给 HeroDetailComponent 了。

这两个组件将会具有父子关系。 当用户从列表中选择了某个英雄时,父组件 HeroesComponent 将通过把要显示的新英雄发送给子组件 HeroDetailComponent,来控制子组件。

你不用修改 HeroesComponent 类,但是要修改它的模板。

修改 HeroesComponent 的模板

HeroDetailComponent 的选择器是 'app-hero-detail'。 把 <app-hero-detail> 添加到 HeroesComponent 模板的底部,以便把英雄详情的视图显示到那里。

把 HeroesComponent.selectedHero 绑定到该元素的 hero 属性,就像这样:

Path:"heroes.component.html (HeroDetail binding)"

<app-hero-detail [hero]="selectedHero"></app-hero-detail>

[hero]="selectedHero" 是 Angular 的属性绑定语法。

这是一种单向数据绑定。从 HeroesComponent 的 selectedHero 属性绑定到目标元素的 hero 属性,并映射到了 HeroDetailComponent 的 hero 属性。

现在,当用户在列表中点击某个英雄时,selectedHero 就改变了。 当 selectedHero 改变时,属性绑定会修改 HeroDetailComponent 的 hero 属性,HeroDetailComponent 就会显示这个新的英雄。

修改后的 HeroesComponent 的模板是这样的:

Path:"heroes.component.html"

<h2>My Heroes</h2><ul class="heroes">  <li *ngFor="let hero of heroes"    [class.selected]="hero === selectedHero"    (click)="onSelect(hero)">    <span class="badge">{{hero.id}}</span> {{hero.name}}  </li></ul><app-hero-detail [hero]="selectedHero"></app-hero-detail>

浏览器刷新,应用又像以前一样开始工作了。

产生的变化:

像以前一样,一旦用户点击了一个英雄的名字,该英雄的详情就显示在了英雄列表下方。 现在,HeroDetailComponent 负责显示那些详情,而不再是 HeroesComponent。

把原来的 HeroesComponent 重构成两个组件带来了一些优点,无论是现在还是未来:

你通过缩减 HeroesComponent 的职责简化了该组件。

你可以把 HeroDetailComponent 改进成一个功能丰富的英雄编辑器,而不用改动父组件 HeroesComponent。

你可以改进 HeroesComponent,而不用改动英雄详情视图。

将来你可以在其它组件的模板中重复使用 HeroDetailComponent。

查看最终代码

  1. Path:"src/app/hero-detail/hero-detail.component.ts"

    import { Component, OnInit, Input } from '@angular/core';    import { Hero } from '../hero';    @Component({      selector: 'app-hero-detail',      templateUrl: './hero-detail.component.html',      styleUrls: ['./hero-detail.component.css']    })    export class HeroDetailComponent implements OnInit {      @Input() hero: Hero;      constructor() { }      ngOnInit() {      }    }

  1. Path:"src/app/hero-detail/hero-detail.component.html"

    <div *ngIf="hero">      <h2>{{hero.name | uppercase}} Details</h2>      <div><span>id: </span>{{hero.id}}</div>      <div>        <label>name:          <input [(ngModel)]="hero.name" placeholder="name"/>        </label>      </div>    </div>

  1. Path:"src/app/heroes/heroes.component.html"

    <h2>My Heroes</h2>    <ul class="heroes">      <li *ngFor="let hero of heroes"        [class.selected]="hero === selectedHero"        (click)="onSelect(hero)">        <span class="badge">{{hero.id}}</span> {{hero.name}}      </li>    </ul>    <app-hero-detail [hero]="selectedHero"></app-hero-detail>

  1. Path:"src/app/app.module.ts"

    import { BrowserModule } from '@angular/platform-browser';    import { NgModule } from '@angular/core';    import { FormsModule } from '@angular/forms';    import { AppComponent } from './app.component';    import { HeroesComponent } from './heroes/heroes.component';    import { HeroDetailComponent } from './hero-detail/hero-detail.component';    @NgModule({      declarations: [        AppComponent,        HeroesComponent,        HeroDetailComponent      ],      imports: [        BrowserModule,        FormsModule      ],      providers: [],      bootstrap: [AppComponent]    })    export class AppModule { }

总结

  • 您创建了一个独立的、可复用的 HeroDetailComponent 组件。

  • 您用属性绑定语法来让父组件 HeroesComponent 可以控制子组件 HeroDetailComponent。

  • 您用 @Input 装饰器来让 hero 属性可以在外部的 HeroesComponent 中绑定。

英雄指南的 HeroesComponent 目前获取和显示的都是模拟数据。

本节课的重构完成之后,HeroesComponent 变得更精简,并且聚焦于为它的视图提供支持。这也让它更容易使用模拟服务进行单元测试。

服务存在的意义

组件不应该直接获取或保存数据,它们不应该了解是否在展示假数据。 它们应该聚焦于展示数据,而把数据访问的职责委托给某个服务。

本节课,你将创建一个 HeroService,应用中的所有类都可以使用它来获取英雄列表。 不要使用 new 关键字来创建此服务,而要依靠 Angular 的依赖注入机制把它注入到 HeroesComponent 的构造函数中。

服务是在多个“互相不知道”的类之间共享信息的好办法。 你将创建一个 MessageService,并且把它注入到两个地方:

  1. 注入到 HeroService 中,它会使用该服务发送消息

  1. 注入到 MessagesComponent 中,它会显示其中的消息。当用户点击某个英雄时,它还会显示该英雄的 ID

创建 HeroService

使用 Angular CLI 创建一个名叫 hero 的服务。

ng generate service hero

该命令会在 "src/app/hero.service.ts" 中生成 HeroService 类的骨架,代码如下:

Path:"src/app/hero.service.ts (new service)"

import { Injectable } from '@angular/core';@Injectable({  providedIn: 'root',})export class HeroService {  constructor() { }}

@Injectable() 服务

注意,这个新的服务导入了 Angular 的 Injectable 符号,并且给这个服务类添加了 @Injectable() 装饰器。 它把这个类标记为依赖注入系统的参与者之一。HeroService 类将会提供一个可注入的服务,并且它还可以拥有自己的待注入的依赖。 目前它还没有依赖,但是很快就会有了。

@Injectable() 装饰器会接受该服务的元数据对象,就像 @Component() 对组件类的作用一样。

获取英雄数据

HeroService 可以从任何地方获取数据:Web 服务、本地存储(LocalStorage)或一个模拟的数据源。

从组件中移除数据访问逻辑,意味着将来任何时候你都可以改变目前的实现方式,而不用改动任何组件。 这些组件不需要了解该服务的内部实现。

这节课中的实现仍然会提供模拟的英雄列表。

导入 Hero 和 HEROES。

Path:"src/app/hero.service.ts"

import { Hero } from './hero';import { HEROES } from './mock-heroes';

添加一个 getHeroes 方法,让它返回模拟的英雄列表。

Path:"src/app/hero.service.ts"

getHeroes(): Hero[] {  return HEROES;}

提供(provide) HeroService

你必须先注册一个服务提供者,来让 HeroService 在依赖注入系统中可用,Angular 才能把它注入到 HeroesComponent 中。所谓服务提供者就是某种可用来创建或交付一个服务的东西;在这里,它通过实例化 HeroService 类,来提供该服务。

为了确保 HeroService 可以提供该服务,就要使用注入器来注册它。注入器是一个对象,负责当应用要求获取它的实例时选择和注入该提供者。

默认情况下,Angular CLI 命令 ng generate service 会通过给 @Injectable() 装饰器添加 providedIn: 'root' 元数据的形式,用根注入器将你的服务注册成为提供者。

@Injectable({  providedIn: 'root',})

注:
- 这是一个过渡性的代码范例,它将会允许你提供并使用 HeroService。此刻的代码和最终代码相差很大。

当你在顶层提供该服务时,Angular 就会为 HeroService 创建一个单一的、共享的实例,并把它注入到任何想要它的类上。 在 @Injectable 元数据中注册该提供者,还能允许 Angular 通过移除那些完全没有用过的服务来进行优化。

现在 HeroService 已经准备好插入到 HeroesComponent 中了。

修改 HeroesComponent

打开 HeroesComponent 类文件。

删除 HEROES 的导入语句,因为你以后不会再用它了。 转而导入 HeroService

Path:"src/app/heroes/heroes.component.ts (import HeroService)"

import { HeroService } from '../hero.service';

把 heroes 属性的定义改为一句简单的声明。

Path:"src/app/heroes/heroes.component.ts"

heroes: Hero[];

注入 HeroService

往构造函数中添加一个私有的 heroService,其类型为 HeroService

Path:"src/app/heroes/heroes.component.ts"

constructor(private heroService: HeroService) {}

这个参数同时做了两件事:

  1. 声明了一个私有 heroService 属性。
  2. 把它标记为一个 HeroService 的注入点。

当 Angular 创建 HeroesComponent 时,依赖注入系统就会把这个 heroService 参数设置为 HeroService 的单例对象。

添加 getHeroes()

创建一个方法,以从服务中获取这些英雄数据。

Path:"src/app/heroes/heroes.component.ts"

getHeroes(): void {  this.heroes = this.heroService.getHeroes();}

在 ngOnInit() 中调用它

你固然可以在构造函数中调用 getHeroes(),但那不是最佳实践。

让构造函数保持简单,只做初始化操作,比如把构造函数的参数赋值给属性。 构造函数不应该做任何事。 它当然不应该调用某个函数来向远端服务(比如真实的数据服务)发起 HTTP 请求。

而是选择在 ngOnInit 生命周期钩子中调用 getHeroes(),之后 Angular 会在构造出 HeroesComponent 的实例之后的某个合适的时机调用 ngOnInit()

Path:"src/app/heroes/heroes.component.ts"

getHeroes(): void { ngOnInit() {  this.getHeroes();}

刷新浏览器,该应用仍运行的一如既往。 显示英雄列表,并且当你点击某个英雄的名字时显示出英雄详情视图。

可观察(Observable)的数据

HeroService.getHeroes() 的函数签名是同步的,它所隐含的假设是 HeroService 总是能同步获取英雄列表数据。 而 HeroesComponent 也同样假设能同步取到 getHeroes() 的结果。

Path:"src/app/heroes/heroes.component.ts"

this.heroes = this.heroService.getHeroes();

这在真实的应用中几乎是不可能的。 现在能这么做,只是因为目前该服务返回的是模拟数据。 不过很快,该应用就要从远端服务器获取英雄数据了,而那天生就是异步操作。

HeroService 必须等服务器给出响应, 而 getHeroes() 不能立即返回英雄数据, 浏览器也不会在该服务等待期间停止响应。

HeroService.getHeroes() 必须具有某种形式的异步函数签名。

这节课,HeroService.getHeroes() 将会返回 Observable,部分原因在于它最终会使用 Angular 的 HttpClient.get() 方法来获取英雄数据,而 HttpClient.get() 会返回 Observable

可观察对象版本的 HeroService

Observable 是 RxJS 库中的一个关键类。

在稍后的 HTTP 教程中,你就会知道 Angular HttpClient 的方法会返回 RxJS 的 Observable。 这节课,你将使用 RxJS 的 of() 函数来模拟从服务器返回数据。

打开 "HeroService" 文件,并从 RxJS 中导入 Observableof 符号。

Path:"src/app/hero.service.ts (Observable imports)"

import { Observable, of } from 'rxjs';

getHeroes() 方法改成这样:

Path:"src/app/hero.service.ts"

getHeroes(): Observable<Hero[]> {  return of(HEROES);}

of(HEROES) 会返回一个 Observable<Hero[]>,它会发出单个值,这个值就是这些模拟英雄的数组。

在 HeroesComponent 中订阅

HeroService.getHeroes 方法之前返回一个 Hero[], 现在它返回的是 Observable<Hero[]>

你必须在 HeroesComponent 中也向本服务中的这种形式看齐。

找到 getHeroes 方法,并且把它替换为如下代码(和前一个版本对比显示):

  1. Path:"heroes.component.ts (Observable)"
    getHeroes(): void {  this.heroService.getHeroes()      .subscribe(heroes => this.heroes = heroes);}

  1. Path:"heroes.component.ts (Original)"
    getHeroes(): void {  this.heroes = this.heroService.getHeroes();}

Observable.subscribe() 是关键的差异点。

上一个版本把英雄的数组赋值给了该组件的 heroes 属性。 这种赋值是同步的,这里包含的假设是服务器能立即返回英雄数组或者浏览器能在等待服务器响应时冻结界面。

HeroService 真的向远端服务器发起请求时,这种方式就行不通了。

新的版本等待 Observable 发出这个英雄数组,这可能立即发生,也可能会在几分钟之后。 然后,subscribe() 方法把这个英雄数组传给这个回调函数,该函数把英雄数组赋值给组件的 heroes 属性。

使用这种异步方式,当 HeroService 从远端服务器获取英雄数据时,就可以工作了。

显示消息

这一节将指导你:

  • 添加一个 MessagesComponent,它在屏幕的底部显示应用中的消息。

  • 创建一个可注入的、全应用级别的 MessageService,用于发送要显示的消息。

  • MessageService 注入到 HeroService 中。

  • HeroService 成功获取了英雄数据时显示一条消息。

创建 MessagesComponent

使用 CLI 创建 MessagesComponent。

ng generate component messages

CLI 在 "src/app/messages" 中创建了组件文件,并且把 MessagesComponent 声明在了 AppModule 中。

修改 AppComponent 的模板来显示所生成的 MessagesComponent

Path:"src/app/message.service.ts"

import { Injectable } from '@angular/core';@Injectable({  providedIn: 'root',})export class MessageService {  messages: string[] = [];  add(message: string) {    this.messages.push(message);  }  clear() {    this.messages = [];  }}

该服务对外暴露了它的 messages 缓存,以及两个方法:add() 方法往缓存中添加一条消息,clear() 方法用于清空缓存。

注入到 HeroService 中

在 HeroService 中导入 MessageService。

Path:"src/app/hero.service.ts (import MessageService)"

import { MessageService } from './message.service';

修改这个构造函数,添加一个私有的 messageService 属性参数。 Angular 将会在创建 HeroService 时把 MessageService 的单例注入到这个属性中。

Path:"src/app/hero.service.ts"

constructor(private messageService: MessageService) { }

注:
- 这是一个典型的“服务中的服务”场景: 你把 MessageService 注入到了 HeroService 中,而 HeroService 又被注入到了 HeroesComponent 中。

从 HeroService 中发送一条消息

修改 getHeroes() 方法,在获取到英雄数组时发送一条消息。

Path:"src/app/hero.service.ts"

getHeroes(): Observable<Hero[]> {  // TODO: send the message _after_ fetching the heroes  this.messageService.add('HeroService: fetched heroes');  return of(HEROES);}

从 HeroService 中显示消息

MessagesComponent 可以显示所有消息, 包括当 HeroService 获取到英雄数据时发送的那条。

打开 MessagesComponent,并且导入 MessageService

Path:"src/app/messages/messages.component.ts (import MessageService)"

import { MessageService } from '../message.service';

修改构造函数,添加一个 public 的 messageService 属性。 Angular 将会在创建 MessagesComponent 的实例时 把 MessageService 的实例注入到这个属性中。

Path:"src/app/messages/messages.component.ts"

constructor(public messageService: MessageService) {}

这个 messageService 属性必须是公共属性,因为你将会在模板中绑定到它。

绑定到 MessageService

把 CLI 生成的 MessagesComponent 的模板改成这样:

Path:"src/app/messages/messages.component.html"

<div *ngIf="messageService.messages.length">  <h2>Messages</h2>  <button class="clear"          (click)="messageService.clear()">clear</button>  <div *ngFor='let message of messageService.messages'> {{message}} </div></div>

这个模板直接绑定到了组件的 messageService 属性上。

  • *ngIf 只有在有消息时才会显示消息区。

  • *ngFor 用来在一系列 <div> 元素中展示消息列表。

  • Angular 的事件绑定把按钮的 click 事件绑定到了 MessageService.clear()

当你把 最终代码 某一页的内容添加到 messages.component.css 中时,这些消息会变得好看一些。

为 hero 服务添加额外的消息

下面的例子展示了当用户点击某个英雄时,如何发送和显示一条消息,以及如何显示该用户的选取历史。当你学到后面的路由一章时,这会很有帮助。

Path:"src/app/heroes/heroes.component.ts"

import { Component, OnInit } from '@angular/core';import { Hero } from '../hero';import { HeroService } from '../hero.service';import { MessageService } from '../message.service';@Component({  selector: 'app-heroes',  templateUrl: './heroes.component.html',  styleUrls: ['./heroes.component.css']})export class HeroesComponent implements OnInit {  selectedHero: Hero;  heroes: Hero[];  constructor(private heroService: HeroService, private messageService: MessageService) { }  ngOnInit() {    this.getHeroes();  }  onSelect(hero: Hero): void {    this.selectedHero = hero;    this.messageService.add(`HeroesComponent: Selected hero id=${hero.id}`);  }  getHeroes(): void {    this.heroService.getHeroes()        .subscribe(heroes => this.heroes = heroes);  }}

刷新浏览器,页面显示出了英雄列表。 滚动到底部,就会在消息区看到来自 HeroService 的消息。 点击“清空”按钮,消息区不见了。

查看最终代码

  1. Path:"src/app/hero.service.ts"

    import { Injectable } from '@angular/core';    import { Observable, of } from 'rxjs';    import { Hero } from './hero';    import { HEROES } from './mock-heroes';    import { MessageService } from './message.service';    @Injectable({      providedIn: 'root',    })    export class HeroService {      constructor(private messageService: MessageService) { }      getHeroes(): Observable<Hero[]> {        // TODO: send the message _after_ fetching the heroes        this.messageService.add('HeroService: fetched heroes');        return of(HEROES);      }    }

  1. Path:"src/app/message.service.ts"

    import { Injectable } from '@angular/core';    @Injectable({      providedIn: 'root',    })    export class MessageService {      messages: string[] = [];      add(message: string) {        this.messages.push(message);      }      clear() {        this.messages = [];      }    }

  1. Path:"src/app/heroes/heroes.component.ts"

    import { Component, OnInit } from '@angular/core';    import { Hero } from '../hero';    import { HeroService } from '../hero.service';    import { MessageService } from '../message.service';    @Component({      selector: 'app-heroes',      templateUrl: './heroes.component.html',      styleUrls: ['./heroes.component.css']    })    export class HeroesComponent implements OnInit {      selectedHero: Hero;      heroes: Hero[];      constructor(private heroService: HeroService, private messageService: MessageService) { }      ngOnInit() {        this.getHeroes();      }      onSelect(hero: Hero): void {        this.selectedHero = hero;        this.messageService.add(`HeroesComponent: Selected hero id=${hero.id}`);      }      getHeroes(): void {        this.heroService.getHeroes()            .subscribe(heroes => this.heroes = heroes);      }    }

  1. Path:"src/app/messages/messages.component.ts"

    import { Component, OnInit } from '@angular/core';    import { MessageService } from '../message.service';    @Component({      selector: 'app-messages',      templateUrl: './messages.component.html',      styleUrls: ['./messages.component.css']    })    export class MessagesComponent implements OnInit {      constructor(public messageService: MessageService) {}      ngOnInit() {      }    }

  1. Path:"src/app/messages/messages.component.html"

    <div *ngIf="messageService.messages.length">      <h2>Messages</h2>      <button class="clear"              (click)="messageService.clear()">clear</button>      <div *ngFor='let message of messageService.messages'> {{message}} </div>    </div>

  1. Path:"src/app/messages/messages.component.css"

    /* MessagesComponent's private CSS styles */    h2 {      color: red;      font-family: Arial, Helvetica, sans-serif;      font-weight: lighter;    }    body {      margin: 2em;    }    body, input[text], button {      color: crimson;      font-family: Cambria, Georgia;    }    button.clear {      font-family: Arial;      background-color: #eee;      border: none;      padding: 5px 10px;      border-radius: 4px;      cursor: pointer;      cursor: hand;    }    button:hover {      background-color: #cfd8dc;    }    button:disabled {      background-color: #eee;      color: #aaa;      cursor: auto;    }    button.clear {      color: #333;      margin-bottom: 12px;    }

  1. Path:"src/app/app.module.ts"

    import { BrowserModule } from '@angular/platform-browser';    import { NgModule } from '@angular/core';    import { FormsModule } from '@angular/forms';    import { AppComponent } from './app.component';    import { HeroesComponent } from './heroes/heroes.component';    import { HeroDetailComponent } from './hero-detail/hero-detail.component';    import { MessagesComponent } from './messages/messages.component';    @NgModule({      declarations: [        AppComponent,        HeroesComponent,        HeroDetailComponent,        MessagesComponent      ],      imports: [        BrowserModule,        FormsModule      ],      providers: [        // no need to place any providers due to the `providedIn` flag...      ],      bootstrap: [ AppComponent ]    })    export class AppModule { }

  1. Path:"src/app/app.component.html"

    <h1>{{title}}</h1>    <app-heroes></app-heroes>    <app-messages></app-messages>

假想

假设有一些“英雄指南”的新需求:

  • 添加一个仪表盘视图。

  • 添加在英雄列表和仪表盘视图之间导航的能力。

  • 无论在哪个视图中点击一个英雄,都会导航到该英雄的详情页。

  • 在邮件中点击一个深链接,会直接打开一个特定英雄的详情视图。

完成效果:

添加 AppRoutingModule

在 Angular 中,最好在一个独立的顶层模块中加载和配置路由器,它专注于路由功能,然后由根模块 AppModule 导入它。

按照惯例,这个模块类的名字叫做 AppRoutingModule,并且位于 "src/app" 下的 "app-routing.module.ts" 文件中。

使用 CLI 生成它。

ng generate module app-routing --flat --module=app

注:
- --flat 把这个文件放进了 src/app 中,而不是单独的目录中。

  • --module=app 告诉 CLI 把它注册到 AppModule 的 imports 数组中。

生成文件是这样的:

Path:"src/app/app-routing.module.ts (generated)"

import { NgModule } from '@angular/core';import { CommonModule } from '@angular/common';@NgModule({  imports: [    CommonModule  ],  declarations: []})export class AppRoutingModule { }

把它替换如下:

Path:"src/app/app-routing.module.ts (updated)"

import { NgModule } from '@angular/core';import { RouterModule, Routes } from '@angular/router';import { HeroesComponent } from './heroes/heroes.component';const routes: Routes = [  { path: 'heroes', component: HeroesComponent }];@NgModule({  imports: [RouterModule.forRoot(routes)],  exports: [RouterModule]})export class AppRoutingModule { }

首先,AppRoutingModule 会导入 RouterModule 和 Routes,以便该应用具有路由功能。配置好路由后,接着导入 HeroesComponent,它将告诉路由器要去什么地方。

注意,对 CommonModule 的引用和 declarations 数组不是必要的,因此它们不再是 AppRoutingModule 的一部分。以下各节将详细介绍 AppRoutingModule 的其余部分。

路由

该文件的下一部分是你的路由配置。 Routes 告诉路由器,当用户单击链接或将 URL 粘贴进浏览器地址栏时要显示哪个视图。

由于 AppRoutingModule 已经导入了 HeroesComponent,因此你可以直接在 routes 数组中使用它:

Path:"src/app/app-routing.module.ts"

const routes: Routes = [  { path: 'heroes', component: HeroesComponent }];

典型的 Angular Route 具有两个属性:

path: 用来匹配浏览器地址栏中 URL 的字符串。

component: 导航到该路由时,路由器应该创建的组件。

这会告诉路由器把该 URL 与 path:'heroes' 匹配。 如果网址类似于 "localhost:4200/heroes" 就显示 HeroesComponent。

RouterModule.forRoot()

@NgModule 元数据会初始化路由器,并开始监听浏览器地址的变化。

下面的代码行将 RouterModule 添加到 AppRoutingModule 的 imports 数组中,同时通过调用 RouterModule.forRoot() 来用这些 routes 配置它:

Path:"src/app/app-routing.module.ts"

imports: [ RouterModule.forRoot(routes) ],

注:
- 这个方法之所以叫 forRoot(),是因为你要在应用的顶层配置这个路由器。 forRoot() 方法会提供路由所需的服务提供者和指令,还会基于浏览器的当前 URL 执行首次导航。

接下来,AppRoutingModule 导出 RouterModule,以便它在整个应用程序中生效。

Path:"src/app/app-routing.module.ts (exports array)"

exports: [ RouterModule ]

添加路由出口 RouterOutlet

打开 AppComponent 的模板,把 <app-heroes> 元素替换为 <router-outlet> 元素。

Path:"src/app/app.component.html (router-outlet)"

<h1>{{title}}</h1><router-outlet></router-outlet><app-messages></app-messages>

AppComponent 的模板不再需要 <app-heroes>,因为只有当用户导航到这里时,才需要显示 HeroesComponent。

<router-outlet> 会告诉路由器要在哪里显示路由的视图。

注:
- 能在 AppComponent 中使用 RouterOutlet,是因为 AppModule 导入了 AppRoutingModule,而 AppRoutingModule 中导出了 RouterModule。 在本教程开始时你运行的那个 ng generate 命令添加了这个导入,是因为 --module=app 标志。如果你手动创建 app-routing.module.ts 或使用了 CLI 之外的工具,你就要把 AppRoutingModule 导入到 app.module.ts 中,并且把它添加到 NgModule 的 imports 数组中。

此时浏览器应该刷新,并显示应用标题,但是没有显示英雄列表。看看浏览器的地址栏。 URL 是以 / 结尾的。 而到 HeroesComponent 的路由路径是 /heroes

在地址栏中把 /heroes 追加到 URL 后面。你应该能看到熟悉的主从结构的英雄显示界面。

添加路由链接 (routerLink)

理想情况下,用户应该能通过点击链接进行导航,而不用被迫把路由的 URL 粘贴到地址栏。

添加一个 <nav> 元素,并在其中放一个链接 <a> 元素,当点击它时,就会触发一个到 HeroesComponent 的导航。 修改过的 AppComponent 模板如下:

Path:"src/app/app.component.html (heroes RouterLink)"

<h1>{{title}}</h1><nav>  <a routerLink="/heroes">Heroes</a></nav><router-outlet></router-outlet><app-messages></app-messages>

routerLink 属性的值为 "/heroes",路由器会用它来匹配出指向 HeroesComponent 的路由。 routerLinkRouterLink 指令的选择器,它会把用户的点击转换为路由器的导航操作。 它是 RouterModule 中的另一个公共指令。

刷新浏览器,显示出了应用的标题和指向英雄列表的链接,但并没有显示英雄列表。

点击这个链接。地址栏变成了 /heroes,并且显示出了英雄列表。

注:
- 从下面的 最终代码中把私有 CSS 样式添加到 app.component.css 中,可以让导航链接变得更好看一点。

添加仪表盘视图

当有多个视图时,路由会更有价值。不过目前还只有一个英雄列表视图。

使用 CLI 添加一个 DashboardComponent:

ng generate component dashboard

CLI 生成了 DashboardComponent 的相关文件,并把它声明到 AppModule 中。

把这三个文件中的内容改成这样:

  1. Path:"src/app/dashboard/dashboard.component.html"

<h3>Top Heroes</h3><div class="grid grid-pad">  <a *ngFor="let hero of heroes" class="col-1-4">    <div class="module hero">      <h4>{{hero.name}}</h4>    </div>  </a></div>

  1. Path:"src/app/dashboard/dashboard.component.ts"

import { Component, OnInit } from '@angular/core';import { Hero } from '../hero';import { HeroService } from '../hero.service';@Component({  selector: 'app-dashboard',  templateUrl: './dashboard.component.html',  styleUrls: [ './dashboard.component.css' ]})export class DashboardComponent implements OnInit {  heroes: Hero[] = [];  constructor(private heroService: HeroService) { }  ngOnInit() {    this.getHeroes();  }  getHeroes(): void {    this.heroService.getHeroes()      .subscribe(heroes => this.heroes = heroes.slice(1, 5));  }}

  1. Path:"src/app/dashboard/dashboard.component.css"

/* DashboardComponent's private CSS styles */[class*='col-'] {  float: left;  padding-right: 20px;  padding-bottom: 20px;}[class*='col-']:last-of-type {  padding-right: 0;}a {  text-decoration: none;}*, *:after, *:before {  -webkit-box-sizing: border-box;  -moz-box-sizing: border-box;  box-sizing: border-box;}h3 {  text-align: center;  margin-bottom: 0;}h4 {  position: relative;}.grid {  margin: 0;}.col-1-4 {  width: 25%;}.module {  padding: 20px;  text-align: center;  color: #eee;  max-height: 120px;  min-width: 120px;  background-color: #3f525c;  border-radius: 2px;}.module:hover {  background-color: #eee;  cursor: pointer;  color: #607d8b;}.grid-pad {  padding: 10px 0;}.grid-pad > [class*='col-']:last-of-type {  padding-right: 20px;}@media (max-width: 600px) {  .module {    font-size: 10px;    max-height: 75px; }}@media (max-width: 1024px) {  .grid {    margin: 0;  }  .module {    min-width: 60px;  }}

这个模板用来表示由英雄名字链接组成的一个阵列。

*ngFor 复写器为组件的 heroes 数组中的每个条目创建了一个链接。

这些链接被 dashboard.component.css 中的样式格式化成了一些色块。

这些链接还没有指向任何地方,但很快就会了。

这个类和 HeroesComponent 类很像。

它定义了一个 heroes 数组属性。

它的构造函数希望 Angular 把 HeroService 注入到私有的 heroService 属性中。

ngOnInit() 生命周期钩子中调用 getHeroes()

这个 getHeroes() 函数会截取第 2 到 第 5 位英雄,也就是说只返回四个顶层英雄(第二,第三,第四和第五)。

Path:"src/app/dashboard/dashboard.component.ts"

getHeroes(): void {  this.heroService.getHeroes()    .subscribe(heroes => this.heroes = heroes.slice(1, 5));}

添加仪表盘路由

要导航到仪表盘,路由器中就需要一个相应的路由。

把 DashboardComponent 导入到 AppRoutingModule 中。

Path:"src/app/app-routing.module.ts (import DashboardComponent)"

import { DashboardComponent }   from './dashboard/dashboard.component';

把一个指向 DashboardComponent 的路由添加到 AppRoutingModule.routes 数组中。

Path:"src/app/app-routing.module.ts"

{ path: 'dashboard', component: DashboardComponent },

添加默认路由

当应用启动时,浏览器的地址栏指向了网站的根路径。 它没有匹配到任何现存路由,因此路由器也不会导航到任何地方。 <router-outlet> 下方是空白的。

要让应用自动导航到这个仪表盘,请把下列路由添加到 AppRoutingModule.Routes 数组中。

Path:"src/app/app-routing.module.ts"

{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },

这个路由会把一个与空路径“完全匹配”的 URL 重定向到路径为 '/dashboard' 的路由。

浏览器刷新之后,路由器加载了 DashboardComponent,并且浏览器的地址栏会显示出 /dashboard 这个 URL。

把仪表盘链接添加到壳组件中

应该允许用户通过点击页面顶部导航区的各个链接在 DashboardComponent 和 HeroesComponent 之间来回导航。

把仪表盘的导航链接添加到壳组件 AppComponent 的模板中,就放在 Heroes 链接的前面。

Path:"src/app/app.component.html"

<h1>{{title}}</h1><nav>  <a routerLink="/dashboard">Dashboard</a>  <a routerLink="/heroes">Heroes</a></nav><router-outlet></router-outlet><app-messages></app-messages>

刷新浏览器,你就能通过点击这些链接在这两个视图之间自由导航了。

导航到英雄详情

HeroDetailComponent 可以显示所选英雄的详情。 此刻,HeroDetailsComponent 只能在 HeroesComponent 的底部看到。

用户应该能通过三种途径看到这些详情。

通过在仪表盘中点击某个英雄。

通过在英雄列表中点击某个英雄。

通过把一个“深链接” URL 粘贴到浏览器的地址栏中来指定要显示的英雄。

在这一节,你将能导航到 HeroDetailComponent,并把它从 HeroesComponent 中解放出来。

从 HeroesComponent 中删除英雄详情

当用户在 HeroesComponent 中点击某个英雄条目时,应用应该能导航到 HeroDetailComponent,从英雄列表视图切换到英雄详情视图。 英雄列表视图将不再显示,而英雄详情视图要显示出来。

打开 HeroesComponent 的模板文件(heroes/heroes.component.html),并从底部删除 <app-hero-detail> 元素。

目前,点击某个英雄条目还没有反应。不过当你启用了到 HeroDetailComponent 的路由之后,很快就能修复它。

添加英雄详情视图

要导航到 id 为 11 的英雄的详情视图,类似于 ~/detail/11 的 URL 将是一个不错的 URL。

打开 AppRoutingModule 并导入 HeroDetailComponent

Path:"src/app/app-routing.module.ts (import HeroDetailComponent)"

import { HeroDetailComponent }  from './hero-detail/hero-detail.component';

然后把一个参数化路由添加到 AppRoutingModule.routes 数组中,它要匹配指向英雄详情视图的路径。

Path:"src/app/app-routing.module.ts"

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

path 中的冒号(:)表示 :id 是一个占位符,它表示某个特定英雄的 id

此刻,应用中的所有路由都就绪了。

Path:"src/app/app-routing.module.ts (all routes)"

const routes: Routes = [  { path: '', redirectTo: '/dashboard', pathMatch: 'full' },  { path: 'dashboard', component: DashboardComponent },  { path: 'detail/:id', component: HeroDetailComponent },  { path: 'heroes', component: HeroesComponent }];

DashboardComponent 中的英雄链接

此刻,DashboardComponent 中的英雄连接还没有反应。

路由器已经有一个指向 HeroDetailComponent 的路由了, 修改仪表盘中的英雄连接,让它们通过参数化的英雄详情路由进行导航。

Path:"src/app/dashboard/dashboard.component.html (hero links))"

<a *ngFor="let hero of heroes" class="col-1-4"    routerLink="/detail/{{hero.id}}">  <div class="module hero">    <h4>{{hero.name}}</h4>  </div></a>

你正在 *ngFor 复写器中使用 Angular 的插值绑定来把当前迭代的 hero.id 插入到每个 routerLink 中。

HeroesComponent 中的英雄链接

HeroesComponent 中的这些英雄条目都是 <li> 元素,它们的点击事件都绑定到了组件的 onSelect() 方法中。

Path:"src/app/heroes/heroes.component.html (list with onSelect)"

<ul class="heroes">  <li *ngFor="let hero of heroes"    [class.selected]="hero === selectedHero"    (click)="onSelect(hero)">    <span class="badge">{{hero.id}}</span> {{hero.name}}  </li></ul>

清理 <li>,只保留它的 *ngFor,把徽章(<badge>)和名字包裹进一个 <a> 元素中, 并且像仪表盘的模板中那样为这个 <a> 元素添加一个 routerLink 属性。

Path:"src/app/heroes/heroes.component.html (list with links)"

<ul class="heroes">  <li *ngFor="let hero of heroes">    <a routerLink="/detail/{{hero.id}}">      <span class="badge">{{hero.id}}</span> {{hero.name}}    </a>  </li></ul>

你还要修改私有样式表(heroes.component.css),让列表恢复到以前的外观。 修改后的样式表参见本指南底部的最终代码。

移除死代码(可选)

虽然 HeroesComponent 类仍然能正常工作,但 onSelect() 方法和 selectedHero 属性已经没用了。

最好清理掉它们,将来你会体会到这么做的好处。 下面是删除了死代码之后的类。

Path:"src/app/heroes/heroes.component.ts (cleaned up)"

export class HeroesComponent implements OnInit {  heroes: Hero[];  constructor(private heroService: HeroService) { }  ngOnInit() {    this.getHeroes();  }  getHeroes(): void {    this.heroService.getHeroes()    .subscribe(heroes => this.heroes = heroes);  }}

支持路由的 HeroDetailComponent

以前,父组件 HeroesComponent 会设置 HeroDetailComponent.hero 属性,然后 HeroDetailComponent 就会显示这个英雄。

HeroesComponent 已经不会再那么做了。 现在,当路由器会在响应形如 ~/detail/11 的 URL 时创建 HeroDetailComponent。

HeroDetailComponent 需要从一种新的途径获取要显示的英雄。 本节会讲解如下操作:

  • 获取创建本组件的路由。

  • 从这个路由中提取出 id

  • 通过 HeroService 从服务器上获取具有这个 id 的英雄数据。

先添加下列导入语句:

Path:"src/app/hero-detail/hero-detail.component.ts"

import { ActivatedRoute } from '@angular/router';import { Location } from '@angular/common';import { HeroService }  from '../hero.service';

然后把 ActivatedRoute、HeroService 和 Location 服务注入到构造函数中,将它们的值保存到私有变量里:

Path:"src/app/hero-detail/hero-detail.component.ts"

constructor(  private route: ActivatedRoute,  private heroService: HeroService,  private location: Location) {}

ActivatedRoute 保存着到这个 HeroDetailComponent 实例的路由信息。 这个组件对从 URL 中提取的路由参数感兴趣。 其中的 id 参数就是要显示的英雄的 id

HeroService 从远端服务器获取英雄数据,本组件将使用它来获取要显示的英雄。

location 是一个 Angular 的服务,用来与浏览器打交道。 稍后,你就会使用它来导航回上一个视图。

从路由参数中提取 id

ngOnInit() 生命周期钩子 中调用 getHero(),代码如下:

Path:"src/app/hero-detail/hero-detail.component.ts"

ngOnInit(): void {  this.getHero();}getHero(): void {  const id = +this.route.snapshot.paramMap.get('id');  this.heroService.getHero(id)    .subscribe(hero => this.hero = hero);}

route.snapshot 是一个路由信息的静态快照,抓取自组件刚刚创建完毕之后。

paramMap 是一个从 URL 中提取的路由参数值的字典。 "id" 对应的值就是要获取的英雄的 id。

路由参数总会是字符串。 JavaScript 的 (+) 操作符会把字符串转换成数字,英雄的 id 就是数字类型。

刷新浏览器,应用挂了。出现一个编译错误,因为 HeroService 没有一个名叫 getHero() 的方法。 这就添加它。

添加 HeroService.getHero()

添加 HeroService,并在 getHeroes() 后面添加如下的 getHero() 方法,它接收 id 参数:

Path:"src/app/hero.service.ts (getHero)"

getHero(id: number): Observable<Hero> {  // TODO: send the message _after_ fetching the hero  this.messageService.add(`HeroService: fetched hero id=${id}`);  return of(HEROES.find(hero => hero.id === id));}

注:
- 反引号 ( ` ) 用于定义 JavaScript 的 模板字符串字面量,以便嵌入 id。

getHeroes() 一样,getHero() 也有一个异步函数签名。 它用 RxJS 的 of() 函数返回一个 Observable 形式的模拟英雄数据。

你将来可以用一个真实的 Http 请求来重新实现 getHero(),而不用修改调用了它的 HeroDetailComponent。

此时刷新浏览器,应用再次恢复如常。你可以在仪表盘或英雄列表中点击一个英雄来导航到该英雄的详情视图。

如果你在浏览器的地址栏中粘贴了 "localhost:4200/detail/11",路由器也会导航到 id: 11 的英雄("Dr. Nice")的详情视图。

回到原路

通过点击浏览器的后退按钮,你可以回到英雄列表或仪表盘视图,这取决于你从哪里进入的详情视图。

如果能在 HeroDetail 视图中也有这么一个按钮就更好了。

把一个后退按钮添加到组件模板的底部,并且把它绑定到组件的 goBack() 方法。

Path:"src/app/hero-detail/hero-detail.component.html (back button)"

<button (click)="goBack()">go back</button>

在组件类中添加一个 goBack() 方法,利用你以前注入的 Location 服务在浏览器的历史栈中后退一步。

Path:"src/app/hero-detail/hero-detail.component.ts (goBack)"

goBack(): void {  this.location.back();}

刷新浏览器,并开始点击。 用户能在应用中导航:从仪表盘到英雄详情再回来,从英雄列表到 mini 版英雄详情到英雄详情,再回到英雄列表。

查看最终代码

AppRoutingModule

Path:"src/app/app-routing.module.ts"

    import { NgModule }             from '@angular/core';    import { RouterModule, Routes } from '@angular/router';    import { DashboardComponent }   from './dashboard/dashboard.component';    import { HeroesComponent }      from './heroes/heroes.component';    import { HeroDetailComponent }  from './hero-detail/hero-detail.component';    const routes: Routes = [      { path: '', redirectTo: '/dashboard', pathMatch: 'full' },      { path: 'dashboard', component: DashboardComponent },      { path: 'detail/:id', component: HeroDetailComponent },      { path: 'heroes', component: HeroesComponent }    ];    @NgModule({      imports: [ RouterModule.forRoot(routes) ],      exports: [ RouterModule ]    })    export class AppRoutingModule {}

AppModule

Path:"src/app/app.module.ts"

    import { NgModule }       from '@angular/core';    import { BrowserModule }  from '@angular/platform-browser';    import { FormsModule }    from '@angular/forms';    import { AppComponent }         from './app.component';    import { DashboardComponent }   from './dashboard/dashboard.component';    import { HeroDetailComponent }  from './hero-detail/hero-detail.component';    import { HeroesComponent }      from './heroes/heroes.component';    import { MessagesComponent }    from './messages/messages.component';    import { AppRoutingModule }     from './app-routing.module';    @NgModule({      imports: [        BrowserModule,        FormsModule,        AppRoutingModule      ],      declarations: [        AppComponent,        DashboardComponent,        HeroesComponent,        HeroDetailComponent,        MessagesComponent      ],      bootstrap: [ AppComponent ]    })    export class AppModule { }

HeroService

Path:"src/app/hero.service.ts"

    import { Injectable } from '@angular/core';    import { Observable, of } from 'rxjs';    import { Hero } from './hero';    import { HEROES } from './mock-heroes';    import { MessageService } from './message.service';    @Injectable({ providedIn: 'root' })    export class HeroService {      constructor(private messageService: MessageService) { }      getHeroes(): Observable<Hero[]> {        // TODO: send the message _after_ fetching the heroes        this.messageService.add('HeroService: fetched heroes');        return of(HEROES);      }      getHero(id: number): Observable<Hero> {        // TODO: send the message _after_ fetching the hero        this.messageService.add(`HeroService: fetched hero id=${id}`);        return of(HEROES.find(hero => hero.id === id));      }    }

AppComponent

  • Path:"src/app/app.component.html"
    <h1>{{title}}</h1><nav><a routerLink="/dashboard">Dashboard</a><a routerLink="/heroes">Heroes</a></nav><router-outlet></router-outlet><app-messages></app-messages>

  • Path:"src/app/app.component.css"
    /* AppComponent's private CSS styles */h1 {  font-size: 1.2em;  margin-bottom: 0;}h2 {  font-size: 2em;  margin-top: 0;  padding-top: 0;}nav a {  padding: 5px 10px;  text-decoration: none;  margin-top: 10px;  display: inline-block;  background-color: #eee;  border-radius: 4px;}nav a:visited, a:link {  color: #334953;}nav a:hover {  color: #039be5;  background-color: #cfd8dc;}nav a.active {  color: #039be5;}

DashboardComponent

  • Path:"src/app/dashboard/dashboard.component.html"
    <h3>Top Heroes</h3><div class="grid grid-pad">  <a *ngFor="let hero of heroes" class="col-1-4"      routerLink="/detail/{{hero.id}}">    <div class="module hero">      <h4>{{hero.name}}</h4>    </div>  </a></div>

  • Path:"src/app/dashboard/dashboard.component.css"

    import { Component, OnInit } from '@angular/core';    import { Hero } from '../hero';    import { HeroService } from '../hero.service';    @Component({      selector: 'app-dashboard',      templateUrl: './dashboard.component.html',      styleUrls: [ './dashboard.component.css' ]    })    export class DashboardComponent implements OnInit {      heroes: Hero[] = [];      constructor(private heroService: HeroService) { }      ngOnInit() {        this.getHeroes();      }      getHeroes(): void {        this.heroService.getHeroes()          .subscribe(heroes => this.heroes = heroes.slice(1, 5));      }    }

  • Path:"src/app/dashboard/dashboard.component.css"

    /* DashboardComponent's private CSS styles */    [class*='col-'] {      float: left;      padding-right: 20px;      padding-bottom: 20px;    }    [class*='col-']:last-of-type {      padding-right: 0;    }    a {      text-decoration: none;    }    *, *:after, *:before {      -webkit-box-sizing: border-box;      -moz-box-sizing: border-box;      box-sizing: border-box;    }    h3 {      text-align: center;      margin-bottom: 0;    }    h4 {      position: relative;    }    .grid {      margin: 0;    }    .col-1-4 {      width: 25%;    }    .module {      padding: 20px;      text-align: center;      color: #eee;      max-height: 120px;      min-width: 120px;      background-color: #3f525c;      border-radius: 2px;    }    .module:hover {      background-color: #eee;      cursor: pointer;      color: #607d8b;    }    .grid-pad {      padding: 10px 0;    }    .grid-pad > [class*='col-']:last-of-type {      padding-right: 20px;    }    @media (max-width: 600px) {      .module {        font-size: 10px;        max-height: 75px; }    }    @media (max-width: 1024px) {      .grid {        margin: 0;      }      .module {        min-width: 60px;      }    }

HeroesComponent

  • Path:"src/app/heroes/heroes.component.html"

    <h2>My Heroes</h2>    <ul class="heroes">      <li *ngFor="let hero of heroes">        <a routerLink="/detail/{{hero.id}}">          <span class="badge">{{hero.id}}</span> {{hero.name}}        </a>      </li>    </ul>

  • Path:"src/app/heroes/heroes.component.ts"

    import { Component, OnInit } from '@angular/core';    import { Hero } from '../hero';    import { HeroService } from '../hero.service';    @Component({      selector: 'app-heroes',      templateUrl: './heroes.component.html',      styleUrls: ['./heroes.component.css']    })    export class HeroesComponent implements OnInit {      heroes: Hero[];      constructor(private heroService: HeroService) { }      ngOnInit() {        this.getHeroes();      }      getHeroes(): void {        this.heroService.getHeroes()        .subscribe(heroes => this.heroes = heroes);      }    }

  • Path:"src/app/heroes/heroes.component.css"

    /* DashboardComponent's private CSS styles */    [class*='col-'] {      float: left;      padding-right: 20px;      padding-bottom: 20px;    }    [class*='col-']:last-of-type {      padding-right: 0;    }    a {      text-decoration: none;    }    *, *:after, *:before {      -webkit-box-sizing: border-box;      -moz-box-sizing: border-box;      box-sizing: border-box;    }    h3 {      text-align: center;      margin-bottom: 0;    }    h4 {      position: relative;    }    .grid {      margin: 0;    }    .col-1-4 {      width: 25%;    }    .module {      padding: 20px;      text-align: center;      color: #eee;      max-height: 120px;      min-width: 120px;      background-color: #3f525c;      border-radius: 2px;    }    .module:hover {      background-color: #eee;      cursor: pointer;      color: #607d8b;    }    .grid-pad {      padding: 10px 0;    }    .grid-pad > [class*='col-']:last-of-type {      padding-right: 20px;    }    @media (max-width: 600px) {      .module {        font-size: 10px;        max-height: 75px; }    }    @media (max-width: 1024px) {      .grid {        margin: 0;      }      .module {        min-width: 60px;      }    }

HeroesComponent

  • Path:"src/app/heroes/heroes.component.html"

<h2>My Heroes</h2><ul class="heroes">  <li *ngFor="let hero of heroes">    <a routerLink="/detail/{{hero.id}}">      <span class="badge">{{hero.id}}</span> {{hero.name}}    </a>  </li></ul>

  • Path:"ssrc/app/heroes/heroes.component.ts"

import { Component, OnInit } from '@angular/core';import { Hero } from '../hero';import { HeroService } from '../hero.service';@Component({  selector: 'app-heroes',  templateUrl: './heroes.component.html',  styleUrls: ['./heroes.component.css']})export class HeroesComponent implements OnInit {  heroes: Hero[];  constructor(private heroService: HeroService) { }  ngOnInit() {    this.getHeroes();  }  getHeroes(): void {    this.heroService.getHeroes()    .subscribe(heroes => this.heroes = heroes);  }}

  • Path:"src/app/heroes/heroes.component.css"

/* HeroesComponent's private CSS styles */.heroes {  margin: 0 0 2em 0;  list-style-type: none;  padding: 0;  width: 15em;}.heroes li {  position: relative;  cursor: pointer;  background-color: #EEE;  margin: .5em;  padding: .3em 0;  height: 1.6em;  border-radius: 4px;}.heroes li:hover {  color: #607D8B;  background-color: #DDD;  left: .1em;}.heroes a {  color: #333;  text-decoration: none;  position: relative;  display: block;  width: 250px;}.heroes a:hover {  color:#607D8B;}.heroes .badge {  display: inline-block;  font-size: small;  color: white;  padding: 0.8em 0.7em 0 0.7em;  background-color:#405061;  line-height: 1em;  position: relative;  left: -1px;  top: -4px;  height: 1.8em;  min-width: 16px;  text-align: right;  margin-right: .8em;  border-radius: 4px 0 0 4px;}

HeroDetailComponent

  • Path:"src/app/hero-detail/hero-detail.component.html"

<div *ngIf="hero">  <h2>{{hero.name | uppercase}} Details</h2>  <div><span>id: </span>{{hero.id}}</div>  <div>    <label>name:      <input [(ngModel)]="hero.name" placeholder="name"/>    </label>  </div>  <button (click)="goBack()">go back</button></div>

  • Path:"src/app/hero-detail/hero-detail.component.ts"

import { Component, OnInit } from '@angular/core';import { ActivatedRoute } from '@angular/router';import { Location } from '@angular/common';import { Hero }         from '../hero';import { HeroService }  from '../hero.service';@Component({  selector: 'app-hero-detail',  templateUrl: './hero-detail.component.html',  styleUrls: [ './hero-detail.component.css' ]})export class HeroDetailComponent implements OnInit {  hero: Hero;  constructor(    private route: ActivatedRoute,    private heroService: HeroService,    private location: Location  ) {}  ngOnInit(): void {    this.getHero();  }  getHero(): void {    const id = +this.route.snapshot.paramMap.get('id');    this.heroService.getHero(id)      .subscribe(hero => this.hero = hero);  }  goBack(): void {    this.location.back();  }}

  • Path:"src/app/hero-detail/hero-detail.component.css"

/* DashboardComponent's private CSS styles */[class*='col-'] {  float: left;  padding-right: 20px;  padding-bottom: 20px;}[class*='col-']:last-of-type {  padding-right: 0;}a {  text-decoration: none;}*, *:after, *:before {  -webkit-box-sizing: border-box;  -moz-box-sizing: border-box;  box-sizing: border-box;}h3 {  text-align: center;  margin-bottom: 0;}h4 {  position: relative;}.grid {  margin: 0;}.col-1-4 {  width: 25%;}.module {  padding: 20px;  text-align: center;  color: #eee;  max-height: 120px;  min-width: 120px;  background-color: #3f525c;  border-radius: 2px;}.module:hover {  background-color: #eee;  cursor: pointer;  color: #607d8b;}.grid-pad {  padding: 10px 0;}.grid-pad > [class*='col-']:last-of-type {  padding-right: 20px;}@media (max-width: 600px) {  .module {    font-size: 10px;    max-height: 75px; }}@media (max-width: 1024px) {  .grid {    margin: 0;  }  .module {    min-width: 60px;  }}HeroesComponentsrc/app/heroes/heroes.component.htmlsrc/app/heroes/heroes.component.tssrc/app/heroes/heroes.component.csscontent_copy/* HeroesComponent's private CSS styles */.heroes {  margin: 0 0 2em 0;  list-style-type: none;  padding: 0;  width: 15em;}.heroes li {  position: relative;  cursor: pointer;  background-color: #EEE;  margin: .5em;  padding: .3em 0;  height: 1.6em;  border-radius: 4px;}.heroes li:hover {  color: #607D8B;  background-color: #DDD;  left: .1em;}.heroes a {  color: #333;  text-decoration: none;  position: relative;  display: block;  width: 250px;}.heroes a:hover {  color:#607D8B;}.heroes .badge {  display: inline-block;  font-size: small;  color: white;  padding: 0.8em 0.7em 0 0.7em;  background-color:#405061;  line-height: 1em;  position: relative;  left: -1px;  top: -4px;  height: 1.8em;  min-width: 16px;  text-align: right;  margin-right: .8em;  border-radius: 4px 0 0 4px;}

总结

  • 添加了 Angular 路由器在各个不同组件之间导航。

  • 您使用一些 <a> 链接和一个 <router-outlet> 把 AppComponent 转换成了一个导航用的壳组件。

  • 您在 AppRoutingModule 中配置了路由器。

  • 您定义了一些简单路由、一个重定向路由和一个参数化路由。

  • 您在 <a> 元素中使用了 routerLink 指令。

  • 您把一个紧耦合的主从视图重构成了带路由的详情视图。

  • 您使用路由链接参数来导航到所选英雄的详情视图。

  • 在多个组件之间共享了 HeroService 服务。

您将借助 Angular 的 HttpClient 来添加一些数据持久化特性。

HeroService 通过 HTTP 请求获取英雄数据。

用户可以添加、编辑和删除英雄,并通过 HTTP 来保存这些更改。

用户可以根据名字搜索英雄。

启用 HTTP 服务

HttpClient 是 Angular 通过 HTTP 与远程服务器通讯的机制。

要让 HttpClient 在应用中随处可用,需要两个步骤。首先,用导入语句把它添加到根模块 AppModule 中:

Path:"src/app/app.module.ts (HttpClientModule import)"

import { HttpClientModule }    from '@angular/common/http';

接下来,仍然在 AppModule 中,把 HttpClientModule 添加到 imports 数组中:

Path:"src/app/app.module.ts (imports array excerpt)"

@NgModule({  imports: [    HttpClientModule,  ],})

模拟数据服务器

这个教学例子会与一个使用 内存 Web API(In-memory Web API) 模拟出的远程数据服务器通讯。

安装完这个模块之后,应用将会通过 HttpClient 来发起请求和接收响应,而不用在乎实际上是这个内存 Web API 在拦截这些请求、操作一个内存数据库,并且给出仿真的响应。

通过使用内存 Web API,你不用架设服务器就可以学习 HttpClient 了。

注:
- 这个内存 Web API 模块与 Angular 中的 HTTP 模块无关。

  • 如果你只是在阅读本教程来学习 HttpClient,那么可以跳过这一步。 如果你正在随着本教程敲代码,那就留下来,并加上这个内存 Web API。

用如下命令从 npm 或 cnpm 中安装这个内存 Web API 包(译注:请使用 0.5+ 的版本,不要使用 0.4-)

npm install angular-in-memory-web-api --save

AppModule 中,导入 HttpClientInMemoryWebApiModuleInMemoryDataService 类,稍后你将创建它们。

Path:"src/app/app.module.ts (In-memory Web API imports)"

import { HttpClientInMemoryWebApiModule } from 'angular-in-memory-web-api';import { InMemoryDataService }  from './in-memory-data.service';

HttpClientModule 之后,将 HttpClientInMemoryWebApiModule 添加到 AppModuleimports 数组中,并以 InMemoryDataService 为参数对其进行配置。

Path:"src/app/app.module.ts (imports array excerpt)"

HttpClientModule,// The HttpClientInMemoryWebApiModule module intercepts HTTP requests// and returns simulated server responses.// Remove it when a real server is ready to receive requests.HttpClientInMemoryWebApiModule.forRoot(  InMemoryDataService, { dataEncapsulation: false })

forRoot() 配置方法接收一个 InMemoryDataService 类来初始化内存数据库。

使用以下命令生成类 "src/app/in-memory-data.service.ts":

ng generate service InMemoryData

将 in-memory-data.service.ts 改为以下内容:

Path:"src/app/in-memory-data.service.ts"

import { Injectable } from '@angular/core';import { InMemoryDbService } from 'angular-in-memory-web-api';import { Hero } from './hero';@Injectable({  providedIn: 'root',})export class InMemoryDataService implements InMemoryDbService {  createDb() {    const heroes = [      { id: 11, name: 'Dr Nice' },      { id: 12, name: 'Narco' },      { id: 13, name: 'Bombasto' },      { id: 14, name: 'Celeritas' },      { id: 15, name: 'Magneta' },      { id: 16, name: 'RubberMan' },      { id: 17, name: 'Dynama' },      { id: 18, name: 'Dr IQ' },      { id: 19, name: 'Magma' },      { id: 20, name: 'Tornado' }    ];    return {heroes};  }  // Overrides the genId method to ensure that a hero always has an id.  // If the heroes array is empty,  // the method below returns the initial number (11).  // if the heroes array is not empty, the method below returns the highest  // hero id + 1.  genId(heroes: Hero[]): number {    return heroes.length > 0 ? Math.max(...heroes.map(hero => hero.id)) + 1 : 11;  }}

"in-memory-data.service.ts" 文件已代替了 "mock-heroes.ts" 文件,现在后者可以安全的删除了。

等服务器就绪后,你就可以抛弃这个内存 Web API,应用的请求将直接传给服务器。

英雄与 HTTP

在 HeroService 中,导入 HttpClientHttpHeaders

Path:"src/app/hero.service.ts (import HTTP symbols)"

import { HttpClient, HttpHeaders } from '@angular/common/http';

仍然在 HeroService 中,把 HttpClient 注入到构造函数中一个名叫 http 的私有属性中。

Path:"src/app/hero.service.ts"

constructor(  private http: HttpClient,  private messageService: MessageService) { }

注意保留对 MessageService 的注入,但是因为您将频繁调用它,因此请把它包裹进一个私有的 log 方法中。

Path:"src/app/hero.service.ts"

/** Log a HeroService message with the MessageService */private log(message: string) {  this.messageService.add(`HeroService: ${message}`);}

把服务器上英雄数据资源的访问地址 heroesURL 定义为 :base/:collectionName 的形式。 这里的 base 是要请求的资源,而 collectionName 是 "in-memory-data-service.ts" 中的英雄数据对象。

Path:"src/app/hero.service.ts"

private heroesUrl = 'api/heroes';  // URL to web api

通过 HttpClient 获取英雄

当前的 HeroService.getHeroes() 使用 RxJS 的 of() 函数来把模拟英雄数据返回为 Observable<Hero[]> 格式。

Path:"src/app/hero.service.ts (getHeroes with RxJs 'of()')"

getHeroes(): Observable<Hero[]> {  return of(HEROES);}

把该方法转换成使用 HttpClient 的,代码如下:

Path:"src/app/hero.service.ts"

/** GET heroes from the server */getHeroes(): Observable<Hero[]> {  return this.http.get<Hero[]>(this.heroesUrl)}

刷新浏览器后,英雄数据就会从模拟服务器被成功读取。

你用 http.get() 替换了 of(),没有做其它修改,但是应用仍然在正常工作,这是因为这两个函数都返回了 Observable<Hero[]>

HttpClient 的方法返回单个值

所有的 HttpClient 方法都会返回某个值的 RxJS Observable。

HTTP 是一个请求/响应式协议。你发起请求,它返回单个的响应。

通常,Observable 可以在一段时间内返回多个值。 但来自 HttpClientObservable 总是发出一个值,然后结束,再也不会发出其它值。

具体到这次 HttpClient.get() 调用,它返回一个 Observable<Hero[]>,也就是“一个英雄数组的可观察对象”。在实践中,它也只会返回一个英雄数组。

HttpClient.get() 返回响应数据

HttpClient.get() 默认情况下把响应体当做无类型的 JSON 对象进行返回。 如果指定了可选的模板类型 <Hero[]>,就会给返回你一个类型化的对象。

服务器的数据 API 决定了 JSON 数据的具体形态。 英雄指南的数据 API 会把英雄数据作为一个数组进行返回。

注:
- 其它 API 可能在返回对象中深埋着你想要的数据。 你可能要借助 RxJS 的 map() 操作符对 Observable 的结果进行处理,以便把这些数据挖掘出来。

  • 虽然不打算在此展开讨论,不过你可以到范例源码中的 getHeroNo404() 方法中找到一个使用 map() 操作符的例子。

错误处理

凡事皆会出错,特别是当你从远端服务器获取数据的时候。 HeroService.getHeroes() 方法应该捕获错误,并做适当的处理。

要捕获错误,你就要使用 RxJS 的 catchError() 操作符来建立对Observable 结果的处理管道(pipe)。

从 rxjs/operators 中导入 catchError 符号,以及你稍后将会用到的其它操作符。

Path:"src/app/hero.service.ts"

import { catchError, map, tap } from 'rxjs/operators';

现在,使用 pipe() 方法来扩展 Observable 的结果,并给它一个 catchError() 操作符。

Path:"src/app/hero.service.ts"

getHeroes(): Observable<Hero[]> {  return this.http.get<Hero[]>(this.heroesUrl)    .pipe(      catchError(this.handleError<Hero[]>('getHeroes', []))    );}

catchError() 操作符会拦截失败的 Observable。 它把错误对象传给错误处理器,错误处理器会处理这个错误。

下面的 handleError() 方法会报告这个错误,并返回一个无害的结果(安全值),以便应用能正常工作。

handleError

下面这个 handleError() 将会在很多 HeroService 的方法之间共享,所以要把它通用化,以支持这些彼此不同的需求。

它不再直接处理这些错误,而是返回给 catchError 返回一个错误处理函数。还要用操作名和出错时要返回的安全值来对这个错误处理函数进行配置。

Path:"src/app/hero.service.ts"

/** * Handle Http operation that failed. * Let the app continue. * @param operation - name of the operation that failed * @param result - optional value to return as the observable result */private handleError<T>(operation = 'operation', result?: T) {  return (error: any): Observable<T> => {    // TODO: send the error to remote logging infrastructure    console.error(error); // log to console instead    // TODO: better job of transforming error for user consumption    this.log(`${operation} failed: ${error.message}`);    // Let the app keep running by returning an empty result.    return of(result as T);  };}

在控制台中汇报了这个错误之后,这个处理器会汇报一个用户友好的消息,并给应用返回一个安全值,让应用继续工作。

因为每个服务方法都会返回不同类型的 Observable 结果,因此 handleError() 也需要一个类型参数,以便它返回一个此类型的安全值,正如应用所期望的那样。

窥探 Observable

HeroService 的方法将会窥探 Observable 的数据流,并通过 log() 方法往页面底部发送一条消息。

它们可以使用 RxJS 的 tap() 操作符来实现,该操作符会查看 Observable 中的值,使用那些值做一些事情,并且把它们传出来。 这种 tap() 回调不会改变这些值本身。

下面是 getHeroes() 的最终版本,它使用 tap() 来记录各种操作。

Path:"src/app/hero.service.ts"

/** GET heroes from the server */getHeroes(): Observable<Hero[]> {  return this.http.get<Hero[]>(this.heroesUrl)    .pipe(      tap(_ => this.log('fetched heroes')),      catchError(this.handleError<Hero[]>('getHeroes', []))    );}

通过 id 获取英雄

大多数的 Web API 都支持以 :baseURL/:id 的形式根据 id 进行获取。

这里的 baseURL 就是在 英雄列表与 HTTP 部分定义过的 heroesURL(api/heroes)。而 id 则是你要获取的英雄的编号,比如,api/heroes/11。 把 HeroService.getHero() 方法改成这样,以发起该请求:

Path:"src/app/hero.service.ts"

/** GET hero by id. Will 404 if id not found */getHero(id: number): Observable<Hero> {  const url = `${this.heroesUrl}/${id}`;  return this.http.get<Hero>(url).pipe(    tap(_ => this.log(`fetched hero id=${id}`)),    catchError(this.handleError<Hero>(`getHero id=${id}`))  );}

这里和 getHeroes() 相比有三个显著的差异:

  • getHero() 使用想获取的英雄的 id 构造了一个请求 URL。

  • 服务器应该使用单个英雄作为回应,而不是一个英雄数组。

  • 所以,getHero() 会返回 Observable<Hero>(“一个可观察的单个英雄对象”),而不是一个可观察的英雄对象数组。

修改英雄

在英雄详情视图中编辑英雄的名字。 随着输入,英雄的名字也跟着在页面顶部的标题区更新了。 但是当你点击“后退”按钮时,这些修改都丢失了。

如果你希望保留这些修改,就要把它们写回到服务器。

在英雄详情模板的底部添加一个保存按钮,它绑定了一个 click 事件,事件绑定会调用组件中一个名叫 save() 的新方法:

Path:"src/app/hero-detail/hero-detail.component.html (save)"

<button (click)="save()">save</button>

在 HeroDetail 组件类中,添加如下的 save() 方法,它使用英雄服务中的 updateHero() 方法来保存对英雄名字的修改,然后导航回前一个视图。

Path:"src/app/hero-detail/hero-detail.component.ts (save)"

save(): void {  this.heroService.updateHero(this.hero)    .subscribe(() => this.goBack());}

添加 HeroService.updateHero()

updateHero() 的总体结构和 getHeroes() 很相似,但它会使用 http.put() 来把修改后的英雄保存到服务器上。 把下列代码添加进HeroService

Path:"src/app/hero.service.ts (update)"

/** PUT: update the hero on the server */updateHero(hero: Hero): Observable<any> {  return this.http.put(this.heroesUrl, hero, this.httpOptions).pipe(    tap(_ => this.log(`updated hero id=${hero.id}`)),    catchError(this.handleError<any>('updateHero'))  );}

HttpClient.put() 方法接受三个参数:

  • URL 地址

  • 要修改的数据(这里就是修改后的英雄)

  • 选项

URL 没变。英雄 Web API 通过英雄对象的 id 就可以知道要修改哪个英雄。

英雄 Web API 期待在保存时的请求中有一个特殊的头。 这个头是在 HeroServicehttpOptions 常量中定义的。

Path:"src/app/hero.service.ts"

httpOptions = {  headers: new HttpHeaders({ 'Content-Type': 'application/json' })};

刷新浏览器,修改英雄名,保存这些修改。在 HeroDetailComponentsave() 方法中导航到前一个视图。 现在,改名后的英雄已经显示在列表中了。

添加新英雄

要添加英雄,本应用中只需要英雄的名字。你可以使用一个和添加按钮成对的 <input> 元素。

把下列代码插入到 HeroesComponent 模板中标题的紧后面:

Path:"src/app/heroes/heroes.component.html (add)"

<div>  <label>Hero name:    <input #heroName />  </label>  <!-- (click) passes input value to add() and then clears the input -->  <button (click)="add(heroName.value); heroName.value=''">    add  </button></div>

当点击事件触发时,调用组件的点击处理器(add()),然后清空这个输入框,以便用来输入另一个名字。把下列代码添加到 HeroesComponent 类:

Path:"src/app/heroes/heroes.component.ts (add)"

add(name: string): void {  name = name.trim();  if (!name) { return; }  this.heroService.addHero({ name } as Hero)    .subscribe(hero => {      this.heroes.push(hero);    });}

当指定的名字非空时,这个处理器会用这个名字创建一个类似于 Hero 的对象(只缺少 id 属性),并把它传给服务的 addHero() 方法。

addHero() 保存成功时,subscribe() 的回调函数会收到这个新英雄,并把它追加到 heroes 列表中以供显示。

HeroService 类中添加 addHero() 方法。

Path:"src/app/hero.service.ts (addHero)"

/** POST: add a new hero to the server */addHero(hero: Hero): Observable<Hero> {  return this.http.post<Hero>(this.heroesUrl, hero, this.httpOptions).pipe(    tap((newHero: Hero) => this.log(`added hero w/ id=${newHero.id}`)),    catchError(this.handleError<Hero>('addHero'))  );}

addHero()updateHero() 有两点不同。

它调用 HttpClient.post() 而不是 put()

它期待服务器为这个新的英雄生成一个 id,然后把它通过 Observable<Hero> 返回给调用者。

刷新浏览器,并添加一些英雄。

删除某个英雄

英雄列表中的每个英雄都有一个删除按钮。

把下列按钮(button)元素添加到 HeroesComponent 的模板中,就在每个 <li>元素中的英雄名字后方。

Path:"src/app/heroes/heroes.component.html"

<button class="delete" title="delete hero"  (click)="delete(hero)">x</button>

英雄列表的 HTML 应该是这样的:

Path:"src/app/heroes/heroes.component.html (list of heroes)"

<ul class="heroes">  <li *ngFor="let hero of heroes">    <a routerLink="/detail/{{hero.id}}">      <span class="badge">{{hero.id}}</span> {{hero.name}}    </a>    <button class="delete" title="delete hero"      (click)="delete(hero)">x</button>  </li></ul>

要把删除按钮定位在每个英雄条目的最右边,就要往 heroes.component.css 中添加一些 CSS。你可以在下方的 最终代码 中找到这些 CSS。

delete() 处理器添加到组件中。

Path:"src/app/heroes/heroes.component.ts (delete)"

delete(hero: Hero): void {  this.heroes = this.heroes.filter(h => h !== hero);  this.heroService.deleteHero(hero).subscribe();}

虽然这个组件把删除英雄的逻辑委托给了 HeroService,但仍保留了更新它自己的英雄列表的职责。 组件的 delete() 方法会在 HeroService 对服务器的操作成功之前,先从列表中移除要删除的英雄。

组件与 heroService.delete() 返回的 Observable 还完全没有关联。必须订阅它。

注:
- 如果你忘了调用 subscribe(),本服务将不会把这个删除请求发送给服务器。 作为一条通用的规则,Observable 在有人订阅之前什么都不会做。

  • 你可以暂时删除 subscribe() 来确认这一点。点击“Dashboard”,然后点击“Heroes”,就又看到完整的英雄列表了。

接下来,把 deleteHero() 方法添加到 HeroService 中,代码如下。

Path:"src/app/hero.service.ts (delete)"

/** DELETE: delete the hero from the server */deleteHero(hero: Hero | number): Observable<Hero> {  const id = typeof hero === 'number' ? hero : hero.id;  const url = `${this.heroesUrl}/${id}`;  return this.http.delete<Hero>(url, this.httpOptions).pipe(    tap(_ => this.log(`deleted hero id=${id}`)),    catchError(this.handleError<Hero>('deleteHero'))  );}

注:
- deleteHero() 调用了 HttpClient.delete()

  • URL 就是英雄的资源 URL 加上要删除的英雄的 id

  • 您不用像 put()post() 中那样发送任何数据。

  • 您仍要发送 httpOptions

根据名字搜索

在最后一次练习中,您要学到把 Observable 的操作符串在一起,让你能将相似 HTTP 请求的数量最小化,并节省网络带宽。

您将往仪表盘中加入英雄搜索特性。 当用户在搜索框中输入名字时,您会不断发送根据名字过滤英雄的 HTTP 请求。 您的目标是仅仅发出尽可能少的必要请求。

HeroService.searchHeroes()

先把 searchHeroes() 方法添加到 HeroService 中。

Path:"src/app/hero.service.ts"

/* GET heroes whose name contains search term */searchHeroes(term: string): Observable<Hero[]> {  if (!term.trim()) {    // if not search term, return empty hero array.    return of([]);  }  return this.http.get<Hero[]>(`${this.heroesUrl}/?name=${term}`).pipe(    tap(x => x.length ?       this.log(`found heroes matching "${term}"`) :       this.log(`no heroes matching "${term}"`)),    catchError(this.handleError<Hero[]>('searchHeroes', []))  );}

如果没有搜索词,该方法立即返回一个空数组。 剩下的部分和 getHeroes() 很像。 唯一的不同点是 URL,它包含了一个由搜索词组成的查询字符串。

为仪表盘添加搜索功能

打开 DashboardComponent 的模板并且把用于搜索英雄的元素 <app-hero-search> 添加到代码的底部。

Path:"src/app/dashboard/dashboard.component.html"

<h3>Top Heroes</h3><div class="grid grid-pad">  <a *ngFor="let hero of heroes" class="col-1-4"      routerLink="/detail/{{hero.id}}">    <div class="module hero">      <h4>{{hero.name}}</h4>    </div>  </a></div>

这个模板看起来很像 HeroesComponent 模板中的 *ngFor 复写器。

为此,下一步就是添加一个组件,它的选择器要能匹配 <app-hero-search>

创建 HeroSearchComponent

使用 CLI 创建一个 HeroSearchComponent。

ng generate component hero-search

CLI 生成了 HeroSearchComponent 的三个文件,并把该组件添加到了 AppModule 的声明中。

把生成的 HeroSearchComponent 的模板改成一个 <input> 和一个匹配到的搜索结果的列表。代码如下:

Path:"src/app/hero-search/hero-search.component.html"

<div id="search-component">  <h4><label for="search-box">Hero Search</label></h4>  <input #searchBox id="search-box" (input)="search(searchBox.value)" />  <ul class="search-result">    <li *ngFor="let hero of heroes$ | async" >      <a routerLink="/detail/{{hero.id}}">        {{hero.name}}      </a>    </li>  </ul></div>

从下面的 最终代码 中把私有 CSS 样式添加到 "hero-search.component.css" 中。

当用户在搜索框中输入时,一个 keyup 事件绑定会调用该组件的 search() 方法,并传入新的搜索框的值。

AsyncPipe

*ngFor 会重复渲染这些英雄对象。注意,*ngFor 在一个名叫 heroes$ 的列表上迭代,而不是 heroes$ 是一个约定,表示 heroes$ 是一个 Observable 而不是数组。

Path:"src/app/hero-search/hero-search.component.html"

<li *ngFor="let hero of heroes$ | async" >

由于 *ngFor 不能直接使用 Observable,所以要使用一个管道字符(|),后面紧跟着一个 async。这表示 Angular 的 AsyncPipe 管道,它会自动订阅 Observable,这样你就不用在组件类中这么做了。

修正 HeroSearchComponent 类

修改所生成的 HeroSearchComponent 类及其元数据,代码如下:

Path:"src/app/hero-search/hero-search.component.ts"

import { Component, OnInit } from '@angular/core';import { Observable, Subject } from 'rxjs';import {   debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';import { Hero } from '../hero';import { HeroService } from '../hero.service';@Component({  selector: 'app-hero-search',  templateUrl: './hero-search.component.html',  styleUrls: [ './hero-search.component.css' ]})export class HeroSearchComponent implements OnInit {  heroes$: Observable<Hero[]>;  private searchTerms = new Subject<string>();  constructor(private heroService: HeroService) {}  // Push a search term into the observable stream.  search(term: string): void {    this.searchTerms.next(term);  }  ngOnInit(): void {    this.heroes$ = this.searchTerms.pipe(      // wait 300ms after each keystroke before considering the term      debounceTime(300),      // ignore new term if same as previous term      distinctUntilChanged(),      // switch to new search observable each time the term changes      switchMap((term: string) => this.heroService.searchHeroes(term)),    );  }}

注意,heroes$ 声明为一个 Observable

Path:"src/app/hero-search/hero-search.component.ts"

heroes$: Observable<Hero[]>;

你将会在 ngOnInit() 中设置它,在此之前,先仔细看看 searchTerms 的定义。

RxJS Subject 类型的 searchTerms

searchTerms 属性是 RxJS 的 Subject 类型。

Path:"src/app/hero-search/hero-search.component.ts"

private searchTerms = new Subject<string>();// Push a search term into the observable stream.search(term: string): void {  this.searchTerms.next(term);}

Subject 既是可观察对象的数据源,本身也是 Observable。 你可以像订阅任何 Observable 一样订阅 Subject

你还可以通过调用它的 next(value) 方法往 Observable 中推送一些值,就像 search() 方法中一样。

文本框的 input 事件的事件绑定会调用 search() 方法。

Path:"src/app/hero-search/hero-search.component.html"

<input #searchBox id="search-box" (input)="search(searchBox.value)" />

每当用户在文本框中输入时,这个事件绑定就会使用文本框的值(搜索词)调用 search() 函数。 searchTerms 变成了一个能发出搜索词的稳定的流。

串联 RxJS 操作符

如果每当用户击键后就直接调用 searchHeroes() 将导致创建海量的 HTTP 请求,浪费服务器资源并干扰数据调度计划。

应该怎么做呢?ngOnInit()searchTerms 这个可观察对象的处理管道中加入了一系列 RxJS 操作符,用以缩减对 searchHeroes() 的调用次数,并最终返回一个可及时给出英雄搜索结果的可观察对象(每次都是 Hero[] )。

代码如下:

Path:"src/app/hero-search/hero-search.component.ts"

this.heroes$ = this.searchTerms.pipe(  // wait 300ms after each keystroke before considering the term  debounceTime(300),  // ignore new term if same as previous term  distinctUntilChanged(),  // switch to new search observable each time the term changes  switchMap((term: string) => this.heroService.searchHeroes(term)),);

各个操作符的工作方式如下:

  • 在传出最终字符串之前,debounceTime(300) 将会等待,直到新增字符串的事件暂停了 300 毫秒。 你实际发起请求的间隔永远不会小于 300ms。

  • distinctUntilChanged() 会确保只在过滤条件变化时才发送请求。

  • switchMap() 会为每个从 debounce()distinctUntilChanged() 中通过的搜索词调用搜索服务。 它会取消并丢弃以前的搜索可观察对象,只保留最近的。

注:
- 借助 switchMap 操作符, 每个有效的击键事件都会触发一次 HttpClient.get() 方法调用。 即使在每个请求之间都有至少 300ms 的间隔,仍然可能会同时存在多个尚未返回的 HTTP 请求。

  • switchMap() 会记住原始的请求顺序,只会返回最近一次 HTTP 方法调用的结果。 以前的那些请求都会被取消和舍弃。

  • 注意,取消前一个 searchHeroes() 可观察对象并不会中止尚未完成的 HTTP 请求。 那些不想要的结果只会在它们抵达应用代码之前被舍弃。

记住,组件类中并没有订阅 heroes$这个可观察对象,而是由模板中的 AsyncPipe 完成的。

再次运行本应用,在这个仪表盘中输入现有的英雄名字,您可以看到:

查看最终代码

HeroService

Path:"src/app/hero.service.ts"

import { Injectable } from '@angular/core';import { HttpClient, HttpHeaders } from '@angular/common/http';import { Observable, of } from 'rxjs';import { catchError, map, tap } from 'rxjs/operators';import { Hero } from './hero';import { MessageService } from './message.service';@Injectable({ providedIn: 'root' })export class HeroService {  private heroesUrl = 'api/heroes';  // URL to web api  httpOptions = {    headers: new HttpHeaders({ 'Content-Type': 'application/json' })  };  constructor(    private http: HttpClient,    private messageService: MessageService) { }  /** GET heroes from the server */  getHeroes(): Observable<Hero[]> {    return this.http.get<Hero[]>(this.heroesUrl)      .pipe(        tap(_ => this.log('fetched heroes')),        catchError(this.handleError<Hero[]>('getHeroes', []))      );  }  /** GET hero by id. Return `undefined` when id not found */  getHeroNo404<Data>(id: number): Observable<Hero> {    const url = `${this.heroesUrl}/?id=${id}`;    return this.http.get<Hero[]>(url)      .pipe(        map(heroes => heroes[0]), // returns a {0|1} element array        tap(h => {          const outcome = h ? `fetched` : `did not find`;          this.log(`${outcome} hero id=${id}`);        }),        catchError(this.handleError<Hero>(`getHero id=${id}`))      );  }  /** GET hero by id. Will 404 if id not found */  getHero(id: number): Observable<Hero> {    const url = `${this.heroesUrl}/${id}`;    return this.http.get<Hero>(url).pipe(      tap(_ => this.log(`fetched hero id=${id}`)),      catchError(this.handleError<Hero>(`getHero id=${id}`))    );  }  /* GET heroes whose name contains search term */  searchHeroes(term: string): Observable<Hero[]> {    if (!term.trim()) {      // if not search term, return empty hero array.      return of([]);    }    return this.http.get<Hero[]>(`${this.heroesUrl}/?name=${term}`).pipe(      tap(x => x.length ?         this.log(`found heroes matching "${term}"`) :         this.log(`no heroes matching "${term}"`)),      catchError(this.handleError<Hero[]>('searchHeroes', []))    );  }  //////// Save methods //////////  /** POST: add a new hero to the server */  addHero(hero: Hero): Observable<Hero> {    return this.http.post<Hero>(this.heroesUrl, hero, this.httpOptions).pipe(      tap((newHero: Hero) => this.log(`added hero w/ id=${newHero.id}`)),      catchError(this.handleError<Hero>('addHero'))    );  }  /** DELETE: delete the hero from the server */  deleteHero(hero: Hero | number): Observable<Hero> {    const id = typeof hero === 'number' ? hero : hero.id;    const url = `${this.heroesUrl}/${id}`;    return this.http.delete<Hero>(url, this.httpOptions).pipe(      tap(_ => this.log(`deleted hero id=${id}`)),      catchError(this.handleError<Hero>('deleteHero'))    );  }  /** PUT: update the hero on the server */  updateHero(hero: Hero): Observable<any> {    return this.http.put(this.heroesUrl, hero, this.httpOptions).pipe(      tap(_ => this.log(`updated hero id=${hero.id}`)),      catchError(this.handleError<any>('updateHero'))    );  }  /**   * Handle Http operation that failed.   * Let the app continue.   * @param operation - name of the operation that failed   * @param result - optional value to return as the observable result   */  private handleError<T>(operation = 'operation', result?: T) {    return (error: any): Observable<T> => {      // TODO: send the error to remote logging infrastructure      console.error(error); // log to console instead      // TODO: better job of transforming error for user consumption      this.log(`${operation} failed: ${error.message}`);      // Let the app keep running by returning an empty result.      return of(result as T);    };  }  /** Log a HeroService message with the MessageService */  private log(message: string) {    this.messageService.add(`HeroService: ${message}`);  }}

InMemoryDataService

Path:"src/app/in-memory-data.service.ts"

import { Injectable } from '@angular/core';import { InMemoryDbService } from 'angular-in-memory-web-api';import { Hero } from './hero';@Injectable({  providedIn: 'root',})export class InMemoryDataService implements InMemoryDbService {  createDb() {    const heroes = [      { id: 11, name: 'Dr Nice' },      { id: 12, name: 'Narco' },      { id: 13, name: 'Bombasto' },      { id: 14, name: 'Celeritas' },      { id: 15, name: 'Magneta' },      { id: 16, name: 'RubberMan' },      { id: 17, name: 'Dynama' },      { id: 18, name: 'Dr IQ' },      { id: 19, name: 'Magma' },      { id: 20, name: 'Tornado' }    ];    return {heroes};  }  // Overrides the genId method to ensure that a hero always has an id.  // If the heroes array is empty,  // the method below returns the initial number (11).  // if the heroes array is not empty, the method below returns the highest  // hero id + 1.  genId(heroes: Hero[]): number {    return heroes.length > 0 ? Math.max(...heroes.map(hero => hero.id)) + 1 : 11;  }}

AppModule

Path:"src/app/app.module.ts"

import { NgModule }       from '@angular/core';import { BrowserModule }  from '@angular/platform-browser';import { FormsModule }    from '@angular/forms';import { HttpClientModule }    from '@angular/common/http';import { HttpClientInMemoryWebApiModule } from 'angular-in-memory-web-api';import { InMemoryDataService }  from './in-memory-data.service';import { AppRoutingModule }     from './app-routing.module';import { AppComponent }         from './app.component';import { DashboardComponent }   from './dashboard/dashboard.component';import { HeroDetailComponent }  from './hero-detail/hero-detail.component';import { HeroesComponent }      from './heroes/heroes.component';import { HeroSearchComponent }  from './hero-search/hero-search.component';import { MessagesComponent }    from './messages/messages.component';@NgModule({  imports: [    BrowserModule,    FormsModule,    AppRoutingModule,    HttpClientModule,    // The HttpClientInMemoryWebApiModule module intercepts HTTP requests    // and returns simulated server responses.    // Remove it when a real server is ready to receive requests.    HttpClientInMemoryWebApiModule.forRoot(      InMemoryDataService, { dataEncapsulation: false }    )  ],  declarations: [    AppComponent,    DashboardComponent,    HeroesComponent,    HeroDetailComponent,    MessagesComponent,    HeroSearchComponent  ],  bootstrap: [ AppComponent ]})export class AppModule { }

HeroesComponent

  • Path:"src/app/heroes/heroes.component.html"

<h2>My Heroes</h2><div>  <label>Hero name:    <input #heroName />  </label>  <!-- (click) passes input value to add() and then clears the input -->  <button (click)="add(heroName.value); heroName.value=''">    add  </button></div><ul class="heroes">  <li *ngFor="let hero of heroes">    <a routerLink="/detail/{{hero.id}}">      <span class="badge">{{hero.id}}</span> {{hero.name}}    </a>    <button class="delete" title="delete hero"      (click)="delete(hero)">x</button>  </li></ul>

  • Path:"src/app/heroes/heroes.component.ts"

import { Component, OnInit } from '@angular/core';import { Hero } from '../hero';import { HeroService } from '../hero.service';@Component({  selector: 'app-heroes',  templateUrl: './heroes.component.html',  styleUrls: ['./heroes.component.css']})export class HeroesComponent implements OnInit {  heroes: Hero[];  constructor(private heroService: HeroService) { }  ngOnInit() {    this.getHeroes();  }  getHeroes(): void {    this.heroService.getHeroes()    .subscribe(heroes => this.heroes = heroes);  }  add(name: string): void {    name = name.trim();    if (!name) { return; }    this.heroService.addHero({ name } as Hero)      .subscribe(hero => {        this.heroes.push(hero);      });  }  delete(hero: Hero): void {    this.heroes = this.heroes.filter(h => h !== hero);    this.heroService.deleteHero(hero).subscribe();  }}

  • Path:"src/app/heroes/heroes.component.css"

/* HeroesComponent's private CSS styles */.heroes {  margin: 0 0 2em 0;  list-style-type: none;  padding: 0;  width: 15em;}.heroes li {  position: relative;  cursor: pointer;  background-color: #EEE;  margin: .5em;  padding: .3em 0;  height: 1.6em;  border-radius: 4px;}.heroes li:hover {  color: #607D8B;  background-color: #DDD;  left: .1em;}.heroes a {  color: #333;  text-decoration: none;  position: relative;  display: block;  width: 250px;}.heroes a:hover {  color: #607D8B;}.heroes .badge {  display: inline-block;  font-size: small;  color: white;  padding: 0.8em 0.7em 0 0.7em;  background-color: #405061;  line-height: 1em;  position: relative;  left: -1px;  top: -4px;  height: 1.8em;  min-width: 16px;  text-align: right;  margin-right: .8em;  border-radius: 4px 0 0 4px;}button {  background-color: #eee;  border: none;  padding: 5px 10px;  border-radius: 4px;  cursor: pointer;  cursor: hand;  font-family: Arial;}button:hover {  background-color: #cfd8dc;}button.delete {  position: relative;  left: 194px;  top: -32px;  background-color: gray !important;  color: white;}

HeroDetailComponent

  • Path:"src/app/hero-detail/hero-detail.component.html"

<div *ngIf="hero">  <h2>{{hero.name | uppercase}} Details</h2>  <div><span>id: </span>{{hero.id}}</div>  <div>    <label>name:      <input [(ngModel)]="hero.name" placeholder="name"/>    </label>  </div>  <button (click)="goBack()">go back</button>  <button (click)="save()">save</button></div>

  • Path:"src/app/hero-detail/hero-detail.component.ts"

import { Component, OnInit, Input } from '@angular/core';import { ActivatedRoute } from '@angular/router';import { Location } from '@angular/common';import { Hero }         from '../hero';import { HeroService }  from '../hero.service';@Component({  selector: 'app-hero-detail',  templateUrl: './hero-detail.component.html',  styleUrls: [ './hero-detail.component.css' ]})export class HeroDetailComponent implements OnInit {  @Input() hero: Hero;  constructor(    private route: ActivatedRoute,    private heroService: HeroService,    private location: Location  ) {}  ngOnInit(): void {    this.getHero();  }  getHero(): void {    const id = +this.route.snapshot.paramMap.get('id');    this.heroService.getHero(id)      .subscribe(hero => this.hero = hero);  }  goBack(): void {    this.location.back();  }  save(): void {    this.heroService.updateHero(this.hero)      .subscribe(() => this.goBack());  }}

DashboardComponent

Path:"src/app/dashboard/dashboard.component.html"

<h3>Top Heroes</h3><div class="grid grid-pad">  <a *ngFor="let hero of heroes" class="col-1-4"      routerLink="/detail/{{hero.id}}">    <div class="module hero">      <h4>{{hero.name}}</h4>    </div>  </a></div><app-hero-search></app-hero-search>

HeroSearchComponent

  • Path:"src/app/hero-search/hero-search.component.html"

<div id="search-component">  <h4><label for="search-box">Hero Search</label></h4>  <input #searchBox id="search-box" (input)="search(searchBox.value)" />  <ul class="search-result">    <li *ngFor="let hero of heroes$ | async" >      <a routerLink="/detail/{{hero.id}}">        {{hero.name}}      </a>    </li>  </ul></div>

  • Path:"src/app/hero-search/hero-search.component.ts"

import { Component, OnInit } from '@angular/core';import { Observable, Subject } from 'rxjs';import {   debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';import { Hero } from '../hero';import { HeroService } from '../hero.service';@Component({  selector: 'app-hero-search',  templateUrl: './hero-search.component.html',  styleUrls: [ './hero-search.component.css' ]})export class HeroSearchComponent implements OnInit {  heroes$: Observable<Hero[]>;  private searchTerms = new Subject<string>();  constructor(private heroService: HeroService) {}  // Push a search term into the observable stream.  search(term: string): void {    this.searchTerms.next(term);  }  ngOnInit(): void {    this.heroes$ = this.searchTerms.pipe(      // wait 300ms after each keystroke before considering the term      debounceTime(300),      // ignore new term if same as previous term      distinctUntilChanged(),      // switch to new search observable each time the term changes      switchMap((term: string) => this.heroService.searchHeroes(term)),    );  }}

  • Path:"src/app/hero-search/hero-search.component.css"

/* HeroSearch private styles */.search-result li {  border-bottom: 1px solid gray;  border-left: 1px solid gray;  border-right: 1px solid gray;  width: 195px;  height: 16px;  padding: 5px;  background-color: white;  cursor: pointer;  list-style-type: none;}.search-result li:hover {  background-color: #607D8B;}.search-result li a {  color: #888;  display: block;  text-decoration: none;}.search-result li a:hover {  color: white;}.search-result li a:active {  color: white;}#search-box {  width: 200px;  height: 20px;}ul.search-result {  margin-top: 0;  padding-left: 0;}

总结

您添加了在应用程序中使用 HTTP 的必备依赖。

您重构了 HeroService,以通过 web API 来加载英雄数据。

您扩展了 HeroService 来支持 post()、put() 和 delete() 方法。

您修改了组件,以允许用户添加、编辑和删除英雄。

您配置了一个内存 Web API。

您学会了如何使用“可观察对象”。

《英雄指南》教程结束了。 如果你准备开始学习 Angular 开发的原理,请开始 架构 一章。

词汇表

Angular 有自己的词汇表。 虽然大多数 Angular 短语都是日常用语或计算机术语,但是在 Angular 体系中,它们有特别的含义。

本词汇表列出了常用术语和少量具有反常或意外含义的不常用术语。

预 (ahead-of-time, AOT) 编译

Angular 的预先(AOT)编译器可以在编译期间把 Angular 的 HTML 代码和 TypeScript 代码转换成高效的 JavaScript 代码,这样浏览器就可以直接下载并运行它们。 对于产品环境,这是最好的编译模式,相对于即时 (JIT) 编译而言,它能减小加载时间,并提高性能。

使用命令行工具 ngc 来编译你的应用之后,就可以直接启动一个模块工厂,这意味着你不必再在 JavaScript 打包文件中包含 Angular 编译器。

Angular 元素(element)

被包装成自定义元素的 Angular 组件。

注:
- 参考 [Angular 元素]() 一文。

注解(Annotation)

一种为类提供元数据的结构。

注:
参见 [装饰器]()。

应用外壳(app-shell)

应用外壳是一种在构建期间通过路由为应用渲染出部分内容的方式。 这样就能为用户快速渲染出一个有意义的首屏页面,因为浏览器可以在初始化脚本之前渲染出静态的 HTML 和 CSS。

欲知详情,参见应用外壳模型。

你可以使用 Angular CLI 来生成一个应用外壳。 它可以在浏览器下载完整版应用之前,先快速启动一个静态渲染页面(所有页面的公共骨架)来增强用户体验,等代码加载完毕后再自动切换到完整版。

注:
- 参考 [Service Worker 与 PWA]() 一文。

建筑师(Architect)

CLI 用来根据所提供的配置执行复杂任务(比如编译和执行测试)的工具。 建筑师是一个外壳,它用来对一个指定的目标配置来执行一个构建器(builder) (定义在一个 npm 包中)。

在工作空间配置文件中,"architect" 区可以为建筑师的各个构建器提供配置项。

比如,内置的 linting 构建器定义在 @angular-devkit/build_angular:tslint 包中,它使用 TSLint 工具来执行 linting 操作,其配置是在 tslint.json 文件中指定的。

使用 CLI 命令 ng run可以通过指定与某个构建器相关联的目标配置来调用此构建器。 整合器(Integrator)可以添加一些构建器来启用某些工具和工作流,以便通过 Angular CLI 来运行它。比如,自定义构建器可以把 CLI 命令(如 ng buildng test)的内置实现替换为第三方工具。

属性型指令(attribute directives)

指令 (directive)的一种。可以监听或修改其它 HTML 元素、特性 (attribute)、属性 (property)、组件的行为。通常用作 HTML 属性,就像它的名字所暗示的那样。

注:
- 参考 [属性型指令]() 一文。

绑定 (binding)

广义上是指把变量或属性设置为某个数据值的一种实践。 在 Angular 中,一般是指数据绑定,它会根据数据对象属性的值来设置 DOM 对象的属性。

有时也会指在“令牌(Token)”和依赖提供者(Provider) 之间的依赖注入 绑定。

启动/引导 (bootstrap)

一种用来初始化和启动应用或系统的途径。

在 Angular 中,应用的根模块(AppModule)有一个 bootstrap 属性,用于指出该应用的的顶层组件。 在引导期间,Angular 会创建这些组件,并插入到宿主页面 index.html 中。 你可以在同一个 index.html 中引导多个应用,每个应用都有一些自己的组件。

注:
参考 [引导]() 一文。

构建器(Builder)

一个函数,它使用 Architect API 来执行复杂的过程,比如构建或测试。 构建器的代码定义在一个 npm 包中。

比如,BrowserBuilder 针对某个浏览器目标运行 webpack 构建,而 KarmaBuilder 则启动 Karma 服务器,并且针对单元测试运行 webpack 构建。

CLI 命令 ng run 使用一个特定的目标配置来调用构建器。 工作空间配置文件 angular.json 中包含这些内置构建器的默认配置。

大小写类型(case types)

Angular 使用大小写约定来区分多种名字,详见风格指南中的 "命名" 一节。下面是这些大小写类型的汇总表:

  • 小驼峰形式(camelCase):符号、属性、方法、管道名、非组件指令的选择器、常量。 小驼峰(也叫标准驼峰)形式的第一个字母要使用小写形式。比如 "selectedHero"。

  • 大驼峰形式(UpperCamelCase)或叫帕斯卡形式(PascalCase):类名(包括用来定义组件、接口、NgModule、指令、管道等的类)。 大驼峰形式的第一个字母要使用大写形式。比如 "HeroListComponent"。

  • 中线形式(dash-case)或叫烤串形式(kebab-case):文件名中的描述部分,组件的选择器。比如 "app-hero-list"。

  • 下划线形式(underscore_case)或叫蛇形形式(snake_case):在 Angular 中没有典型用法。蛇形形式使用下划线连接各个单词。 比如 "convert_link_mode"。

  • 大写下划线形式(UPPER_UNDERSCORE_CASE)或叫大写蛇形形式(UPPER_SNAKE_CASE):传统的常量写法(可以接受,但更推荐用小驼峰形式(camelCase)) 大蛇形形式使用下划线分隔的全大写单词。比如 "FIX_ME"。

变更检测(change detection)

Angular 框架会通过此机制将应用程序 UI 的状态与数据的状态同步。变更检测器在运行时会检查数据模型的当前状态,并在下一轮迭代时将其和先前保存的状态进行比较。

当应用逻辑更改组件数据时,绑定到视图中 DOM 属性上的值也要随之更改。变更检测器负责更新视图以反映当前的数据模型。类似地,用户也可以与 UI 进行交互,从而引发要更改数据模型状态的事件。这些事件可以触发变更检测。

使用默认的(“CheckAlways”)变更检测策略,变更检测器将遍历每个视图模型上的视图层次结构,以检查模板中的每个数据绑定属性。在第一阶段,它将所依赖的数据的当前状态与先前状态进行比较,并收集更改。在第二阶段,它将更新页面上的 DOM 以反映出所有新的数据值。

如果设置了 OnPush(“CheckOnce”)变更检测策略,则变更检测器仅在显式调用它或由 @Input 引用的变化或触发事件处理程序时运行。这通常可以提高性能。

注:
参考 [优化 Angular 的变更检测]() 一文。

类装饰器(class decorator)

装饰器会出现在类定义的紧前方,用来声明该类具有指定的类型,并且提供适合该类型的元数据。

可以用下列装饰器来声明 Angular 的类:

  • @Component()

  • @Directive()

  • @Pipe()

  • @Injectable()

  • @NgModule()

类字段装饰器(class field decorator)

出现在类定义中属性紧前方的装饰器语句用来声明该字段的类型。比如 @Input@Output

集合(collection)

在 Angular 中,是指收录在同一个 npm 包 中的一组原理图(schematics)。

命令行界面(CLI)

Angular CLI 是一个命令行工具,用于管理 Angular 的开发周期。它用于为工作区或项目创建初始的脚手架,并且运行生成器(schematics)来为初始生成的版本添加或修改各类代码。 CLI 支持开发周期中的所有阶段,比如构建、测试、打包和部署。

  • 要开始使用 CLI 来创建新项目,参考 [建立本地开发环境]()。

  • 要了解 CLI 的全部功能,参考 [CLI 命令参考手册]()。

注:
参考 [Schematics CLI]() 一文。

组件 (component)

一个带有 @Component() 装饰器的类,和它的伴生模板关联在一起。组件类及其模板共同定义了一个视图。

组件是指令的一种特例。@Component() 装饰器扩展了 @Directive() 装饰器,增加了一些与模板有关的特性。

Angular 的组件类负责暴露数据,并通过数据绑定机制来处理绝大多数视图的显示和用户交互逻辑。

注:
- 要了解更多关于组件类、模板和视图的知识,参考 [架构概览]() 一章。

配置(configuration)

参考 [工作空间配置]() 。

自定义元素(Custom element)

一种 Web 平台的特性,目前已经被绝大多数浏览器支持,在其它浏览器中也可以通过腻子脚本获得支持(参考 [浏览器支持]())。

这种自定义元素特性通过允许你定义标签(其内容是由 JavaScript 代码来创建和控制的)来扩展 HTML。当自定义元素(也叫 Web Component)被添加到 CustomElementRegistry 之后就会被浏览器识别。

你可以使用 API 来转换 Angular 组件,以便它能够注册进浏览器中,并且可以用在你往 DOM 中添加的任意 HTML 中。 自定义元素标签可以把组件的视图(包括变更检测和数据绑定功能)插入到不受 Angular 控制的内容中。

注:
- 参考 [Angular 元素]()。
- 参考 [加载动态组件]()。

数据绑定 (data binding)

这个过程可以让应用程序将数据展示给用户,并对用户的操作(点击、触屏、按键)做出回应。

在数据绑定机制下,你只要声明一下 HTML 部件和数据源之间的关系,把细节交给框架去处理。 而以前的手动操作过程是:将数据推送到 HTML 页面中、添加事件监听器、从屏幕获取变化后的数据,并更新应用中的值。

更多的绑定形式,见[模板语法]():

  • [插值]()

  • [property 绑定]()

  • [事件绑定]()

  • [attribute 绑定]()

  • [CSS 类绑定]()

  • [样式绑定]()

  • [基于 ngModel 的双向数据绑定]()

可声明对象(declarable)

类的一种类型,你可以把它们添加到 NgModule 的 declarations 列表中。 你可以声明组件、指令和管道。

不要声明:

  • 已经在其它 NgModule 中声明过的类

  • 从其它包中导入的指令数组。比如,不要再次声明来自 @angular/forms 中的 FORMS_DIRECTIVES

  • NgModule 类

  • 服务类

  • 非 Angular 的类和对象,比如:字符串、数字、函数、实体模型、配置、业务逻辑和辅助类

装饰器(decorator | decoration)

一个函数,用来修饰紧随其后的类或属性定义。 装饰器(也叫注解)是 JavaScript 的一种语言特性,是一项位于阶段 2(stage 2)的试验特性。

Angular 定义了一些装饰器,用来为类或属性附加元数据,来让自己知道那些类或属性的含义,以及该如何处理它们。

注:
- 参考 [类装饰器]()、[类属性装饰器]()。

依赖注入(dependency injection)

依赖注入既是设计模式,同时又是一种机制:当应用程序的一些部件(即一些依赖)需要另一些部件时, 利用依赖注入来创建被请求的部件,并将它们注入到需要它们的部件中。

在 Angular 中,依赖通常是服务,但是也可以是值,比如字符串或函数。应用的注入器(它是在启动期间自动创建的)会使用该服务或值的配置好的提供者来按需实例化这些依赖。各个不同的提供者可以为同一个服务提供不同的实现。

注:
参考 [Angular 中的依赖注入]() 一章。

DI 令牌(Token)

一种用来查阅的令牌,它关联到一个依赖提供者,用于依赖注入系统中。

差异化加载一种构建技术,它会为同一个应用创建两个发布包。一个是较小的发布包,是针对现代浏览器的。另一个是较大的发布包,能让该应用正确的运行在像 IE 11 这样的老式浏览器上,这些浏览器不能支持全部现代浏览器的 API。

注:
参考 [Deployment]() 一章。

指令 (directive)

一个可以修改 DOM 结构或修改 DOM 和组件数据模型中某些属性的类。 指令类的定义紧跟在 @Directive() 装饰器之后,以提供元数据。

指令类几乎总与 HTML 元素或属性 (attribute) 相关。 通常会把这些 HTML 元素或者属性 (attribute) 当做指令本身。 当 Angular 在 HTML 模板中发现某个指令时,会创建与之相匹配的指令类的实例,并且把这部分 DOM 的控制权交给它。

指令分为三类:

  • 组件使用 @Component()(继承自 @Directive())为某个类关联一个模板。

  • [属性型指令]() 修改页面元素的行为和外观。

  • [结构型指令]() 修改 DOM 的结构。

Angular 提供了一些以 ng 为前缀的内置指令。你也可以创建新的指令来实现自己的功能。 你可以为自定义指令关联一个选择器(一种形如 <my-directive> 的 HTML 标记),以扩展模板语法,从而让你能在应用中使用它。

领域特定语言(DSL)

一种特殊用途的库或 API,参见领域特定语言词条。

Angular 使用领域特定语言扩展了 TypeScript,用于与 Angular 应用相关的许多领域。这些 DSL 都定义在 NgModule 中,比如 动画、表单和路由与导航。

动态组件加载(dynamic component loading)

一种在运行期间把组件添加到 DOM 中的技术,它需要你从编译期间排除该组件,然后,当你把它添加到 DOM 中时,再把它接入 Angular 的变更检测与事件处理框架。

注:
参考 [自定义元素](),它提供了一种更简单的方式来达到相同的效果。

急性加载(Eager Loading)

在启动时加载的 NgModule 和组件被称为急性加载,与之相对的是那些在运行期间才加载的方式(惰性加载)。 参见惰性加载。

ECMAScript 语言

[官方 JavaScript 语言规范]()

并不是所有浏览器都支持最新的 ECMAScript 标准,不过你可以使用转译器(比如TypeScript)来用最新特性写代码,然后它会被转译成可以在浏览器的其它版本上运行的代码。

注:
参考 [浏览器支持]()。

元素(Element)

Angular 定义了 ElementRef 类来包装与渲染有关的原生 UI 元素。这让你可以在大多数情况下使用 Angular 的模板和数据绑定机制来访问 DOM 元素,而不必再引用原生元素。

本文档中一般会使用元素(Element)来指代 ElementRef 的实例,注意与 DOM 元素(你必要时你可以直接访问它)区分开。

可以对比下 [自定义元素]()。

入口点(Entry Point)

JavaScript 模块的目的是供 npm 包的用户进行导入。入口点模块通常会重新导出来自其它内部模块的一些符号。每个包可以包含多个入口点。比如 @angular/core 就有两个入口点模块,它们可以使用名字 @angular/core 和 @angular/core/testing 进行导入。

表单控件(form control)

一个 FormControl 实例,它是 Angular 表单的基本构造块。它会和 FormGroup 和 FormArray 一起,跟踪表单输入元素的值、有效性和状态。

注:
参考 [Angular 表单简介]()。

表单模型(form model)

是指在指定的时间点,表单输入元素的值和验证状态的"权威数据源"。当使用响应式表单时,表单模型会在组件类中显式创建。当使用模板驱动表单时,表单模型是由一些指令隐式创建的。

注:
参考 [Angular 表单简介]()。

表单验证(form validation)

一种检查,当表单值发生变化时运行,并根据预定义的约束来汇报指定的这些值是否正确并完全。响应式表单使用验证器函数,而模板驱动表单则使用验证器指令。

注:
参考 [表单验证器]()。

不可变性(immutability)

是否能够在创建之后修改值的状态。响应式表单会执行不可变性的更改,每次更改数据模型都会生成一个新的数据模型,而不是修改现有的数据模型。 模板驱动表单则会执行可变的更改,它通过 NgModel 和双向数据绑定来就地修改现有的数据模型。

可注入对象(injectable)

Angular 中的类或其它概念使用依赖注入机制来提供依赖。 可供注入的服务类必须使用 @Injectable() 装饰器标出来。其它条目,比如常量值,也可用于注入。

注入器 (injector)

Angular 依赖注入系统中可以在缓存中根据名字查找依赖,也可以通过配置过的提供者来创建依赖。 启动过程中会自动为每个模块创建一个注入器,并被组件树继承。

注入器会提供依赖的一个单例,并把这个单例对象注入到多个组件中。

模块和组件级别的注入器树可以为它们拥有的组件及其子组件提供同一个依赖的不同实例。

你可以为同一个依赖使用不同的提供者来配置这些注入器,这些提供者可以为同一个依赖提供不同的实现。

注:
参考 [多级依赖注入]()。

输入属性 (input)

当定义指令时,指令属性上的 @Input() 装饰器让该属性可以作为属性绑定的目标使用。 数据值会从等号右侧的模板表达式所指定的数据源流入组件的输入属性。

注:
参考 [输入与输出属性]()。

插值 (interpolation)

属性数据绑定 (property data binding) 的一种形式,位于双大括号中的模板表达式 (template expression)会被渲染成文本。 在被赋值给元素属性或者显示在元素标签中之前,这些文本可能会先与周边的文本合并,参见下面的例子。

<label>My current hero is {{hero.name}}</label>

常春藤引擎(Ivy)

Ivy 是 Angular 的下一代编译和渲染管道的代号。在 Angular 的版本 9 中,默认情况下使用新的编译器和运行时,而不再用旧的编译器和运行时,也就是 View Engine。

注:
参考 [Angular Ivy]()。

JavaScript

参见 [ECMAScript]() 和 [TypeScript]()。

即时 (just-in-time, JIT) 编译

在启动期间,Angular 的即时编译器(JIT)会在运行期间把你的 Angular HTML 和 TypeScript 代码转换成高效的 JavaScript 代码。

当你运行 Angular 的 CLI 命令 ng build 和 ng serve 时,JIT 编译是默认选项,而且是开发期间的最佳实践。但是强烈建议你不要在生产环境下使用 JIT 模式,因为它会导致巨大的应用负担,从而拖累启动时的性能。

注:
参考 [预先 (AOT) 编译]()。

惰性加载(Lazy loading)

惰性加载过程会把应用拆分成多个包并且按需加载它们,从而提高应用加载速度。 比如,一些依赖可以根据需要进行惰性加载,与之相对的是那些 急性加载 的模块,它们是根模块所要用的,因此会在启动期间加载。

路由器只有当父视图激活时才需要加载子视图。同样,你还可以构建一些自定义元素,它们也可以在需要时才加载进 Angular 应用。

库(Library)

一种 Angular 项目。用来让其它 Angular 应用包含它,以提供各种功能。库不是一个完整的 Angular 应用,不能独立运行。(要想为非 Angular 应用添加可复用的 Angular 功能,你可以使用 Angular 的自定义元素。)

库的开发者可以使用 CLI 在现有的 工作区 中 generate 新库的脚手架,还能把库发布为 npm 包。

应用开发者可以使用 CLI 来把一个已发布的库 add 进这个应用所在的工作区。

注:
参考 [原理图(schematic)]()。

生命周期钩子(Lifecycle hook)

一种接口,它允许你监听指令和组件的生命周期,比如创建、更新和销毁等。

每个接口只有一个钩子方法,方法名是接口名加前缀 ng。例如,OnInit 接口的钩子方法名为 ngOnInit。

Angular 会按以下顺序调用钩子方法:

  • ngOnChanges - 在输入属性 (input)/输出属性 (output)的绑定值发生变化时调用。

  • ngOnInit - 在第一次 ngOnChanges 完成后调用。

  • ngDoCheck - 开发者自定义变更检测。

  • ngAfterContentInit - 在组件内容初始化后调用。

  • ngAfterContentChecked - 在组件内容每次检查后调用。

  • ngAfterViewInit - 在组件视图初始化后调用。

  • ngAfterViewChecked - 在组件视图每次检查后调用。

  • ngOnDestroy - 在指令销毁前调用。

注:
参考 [生命周期钩子页]()。

模块 (module)

通常,模块会收集一组专注于单一目的的代码块。Angular 既使用 JavaScript 的标准模块,也定义了 Angular 自己的模块,也就是 NgModule。

在 JavaScript (ECMAScript) 中,每个文件都是一个模块,该文件中定义的所有对象都属于这个模块。这些对象可以导出为公共对象,而这些公共对象可以被其它模块导入后使用。

Angular 就是用一组 JavaScript 模块(也叫库)的形式发布的。每个 Angular 库都带有 @angular 前缀。 使用 NPM 包管理器安装它们,并且使用 JavaScript 的 import 声明语句从中导入各个部件。

注:
参考 [NgModule]()。

ngcc

Angular 兼容性编译器。如果使用 Ivy 构建应用程序,但依赖未用 Ivy 编译的库,则 CLI 将使用 ngcc 自动更新依赖库以使用 Ivy。

NgModule

一种带有 @NgModule() 装饰器的类定义,它会声明并提供一组专注于特定功能的代码块,比如业务领域、工作流或一组紧密相关的能力集等。

像 JavaScript 模块一样,NgModule 能导出那些可供其它 NgModule 使用的功能,也可以从其它 NgModule 中导入其公开的功能。 NgModule 类的元数据中包括一些供应用使用的组件、指令和管道,以及导入、导出列表。参见可声明对象。

NgModule 通常会根据它导出的内容决定其文件名,比如,Angular 的 DatePipe 类就属于 date_pipe.ts 文件中一个名叫 date_pipe 的特性模块。 你可以从 Angular 的范围化包中导入它们,比如 @angular/core。

每个 Angular 应用都有一个根模块。通常,这个类会命名为 AppModule,并且位于一个名叫 app.module.ts 的文件中。

注:
参考 [NgModules]()。

npm 包

npm 包管理器用于分发与加载 Angular 的模块和库。

注:
你还可以了解 Angular 如何使用 [Npm 包]() 的更多知识。

可观察对象(Observable)

一个多值生成器,这些值会被推送给订阅者。 Angular 中到处都会用到异步事件处理。你要通过调用可观察对象的 subscribe() 方法来订阅它,从而让这个可观察对象得以执行,你还要给该方法传入一些回调函数来接收 "有新值"、"错误" 或 "完成" 等通知。

可观察对象可以把任意类型的一个或多个值传给订阅者,无论是同步(就像函数把值返回给它的调用者一样)还是异步。 订阅者会在生成了新值时收到包含这个新值的通知,以及正常结束或错误结束时的通知。

Angular 使用一个名叫响应式扩展 (RxJS)的第三方包来实现这些功能。

注:
参考 [可观察对象]()。

观察者(Observer)

传给可观察对象 的 subscribe() 方法的一个对象,其中定义了订阅者的一组回调函数。

输出属性 (output)

当定义指令时,指令属性上的 @Output() 装饰器会让该属性可用作事件绑定的目标。 事件从该属性流出到等号右侧指定的模板表达式中。

注:
参考 [输入与输出属性]()。

管道(pipe)

一个带有 @Pipe{} 装饰器的类,它定义了一个函数,用来把输入值转换成输出值,以显示在视图中。 Angular 定义了很多管道,并且你还可可以自定义新的管道。

注:
参考 [管道]()。

平台(platform)

在 Angular 术语中,平台是供 Angular 应用程序在其中运行的上下文。Angular 应用程序最常见的平台是 Web 浏览器,但它也可以是移动设备的操作系统或 Web 服务器。

@angular/platform-* 软件包提供了对各种 Angular 运行时平台的支持。这些软件包通过提供用于收集用户输入和渲染指定平台 UI 的实现,以允许使用 @angular/core 和 @angular/common 的应用程序在不同的环境中执行。隔离平台相关的功能使开发人员可以独立于平台使用框架的其余部分。

  • 在 Web 浏览器中运行时,BrowserModule 是从 platform-browser 软件包中导入的,并支持简化安全性和事件处理的服务,并允许应用程序访问浏览器专有的功能,例如解释键盘输入和控制文档要显示的标题。浏览器中运行的所有应用程序都使用同一个平台服务。

  • 使用服务端渲染(SSR)时,platform-server 包将提供 DOM、XMLHttpRequest 和其它不依赖浏览器的其它底层功能的 Web 服务器端实现。

腻子脚本(polyfill)

一个 NPM 包,它负责弥补浏览器 JavaScript 实现与最新标准之间的 "缝隙"。参见浏览器支持页,以了解要在特定平台支持特定功能时所需的腻子脚本。

项目(project)

在 Angular CLI 中,CLI 命令可能会创建或修改独立应用或库。

由 ng new 创建的项目中包含一组源文件、资源和配置文件,当你用 CLI 开发或测试此应用时就会用到它们。此外,还可以用 ng generate application 或 ng generate library 命令创建项目。

angular.json 文件可以配置某个工作空间 中的所有项目。

注:
参考 [项目文件结构]()。

提供者 (provider)

一个实现了 Provider 接口的对象。一个提供者对象定义了如何获取与 DI 令牌(token) 相关联的可注入依赖。 注入器会使用这个提供者来创建它所依赖的那些类的实例。

Angular 会为每个注入器注册一些 Angular 自己的服务。你也可以注册应用自己所需的服务提供者。

参见服务和依赖注入。

注:
参考 [依赖注入]()。

响应式表单 (reactive forms)

通过组件中代码构建 Angular 表单的一个框架。 另一种技术是模板驱动表单

构建响应式表单时:

"权威数据源"(表单模型)定义在组件类中。

表单验证在组件代码而不是验证器指令中定义。

在组件类中,使用 new FormControl() 或者 FormBuilder 显性地创建每个控件。

模板中的 input 元素不使用 ngModel。

相关联的 Angular 指令全部以 Form 开头,例如 FormGroup()、FormControl() 和 FormControlName()。

另一种方式是模板驱动表单。模板驱动表单的简介和这两种方式的比较,参见 [Angular 表单简介]()。

路由器 (router)

一种工具,用来配置和实现 Angular 应用中各个状态和视图之间的导航。

Router 模块是一个 NgModule,它提供在应用视图间导航时需要的服务提供者和指令。路由组件是一种组件,它导入了 Router 模块,并且其模板中包含 RouterOutlet 元素,路由器生成的视图就会被显示在那里。

路由器定义了在单页面中的各个视图之间导航的方式,而不是在页面之间。它会解释类似 URL 的链接,以决定该创建或销毁哪些视图,以及要加载或卸载哪些组件。它让你可以在 Angular 应用中获得惰性加载的好处。

注:
参考 [路由与导航]()。

路由出口(router outlet)

一种指令,它在路由组件的模板中扮演占位符的角色,Angular 会根据当前的路由状态动态填充它。

路由组件 (routing component)

一个模板中带有 RouterOutlet 指令的 Angular 组件,用于根据路由器的导航显示相应的视图。

注:
参考 [路由与导航]()。

规则(rule)

在原理图 中,是指一个在文件树上运行的函数,用于以指定方式创建、删除或修改文件,并返回一个新的 Tree 对象。

原理图(schematic)

脚手架库会定义如何借助创建、修改、重构或移动文件和代码等操作来生成或转换某个项目。每个原理图定义了一些规则,以操作一个被称为文件树的虚拟文件系统。

Angular CLI 使用原理图来生成和修改 Angular 项目及其部件。

Angular 提供了一组用于 CLI 的原理图。参见 Angular CLI 命令参考手册。当 ng add 命令向项目中添加某个库时,就会运行原理图。ng generate 命令则会运行原理图,来创建应用、库和 Angular 代码块。

公共库的开发者可以创建原理图,来让 CLI 生成他们自己的发布的库。欲知详情,参见 devkit 文档。

注:
参考 [原理图]()和[把库与 CLI 集成]()。

Schematics CLI

Schematics 自带了一个命令行工具。 使用 Node 6.9 或更高版本,可以全局安装这个 Schematics CLI:

npm install -g @angular-devkit/schematics-cli

这会安装可执行文件 schematics,你可以用它来创建新工程、往现有工程中添加新的 schematic,或扩展某个现有的 schematic。

范围化包 (scoped package)

一种把相关的 npm 包分组到一起的方式。 Angular 的 NgModule 都是在一些以 @angular 为范围名的范围化包中发布的。比如 @angular/core、@angular/common、@angular/forms 和 @angular/router。

和导入普通包相同的方式导入范围化包。

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

服务端渲染

一项在服务端生成静态应用页面的技术,它可以在对来自浏览器的请求进行响应时生成这些页面或用它们提供服务。 它还可以提前把这些页面生成为 HTML 文件,以便稍后用它们来提供服务。

该技术可以增强手机和低功耗设备的性能,而且会在应用加载通过快速展示一个静态首屏来提升用户体验。这个静态版本还能让你的应用对网络蜘蛛更加友好。

你可以通过 CLI 运行 Angular Universal 工具,借助 @nguniversal/express-engine schematic 原理图来更轻松的让应用支持服务端渲染。

服务 (service)

在 Angular 中,服务就是一个带有 @Injectable 装饰器的类,它封装了可以在应用程序中复用的非 UI 逻辑和代码。 Angular 把组件和服务分开,是为了增进模块化程度和可复用性。

@Injectable 元数据让服务类能用于依赖注入机制中。可注入的类是用提供者进行实例化的。 各个注入器会维护一个提供者的列表,并根据组件或其它服务的需要,用它们来提供服务的实例。

注:
参考 [服务与依赖注入简介]()。

结构型指令(Structural directives)

一种指令类型,它能通过修改 DOM (添加、删除或操纵元素及其子元素)来修整或重塑 HTML 的布局。

注:
参考 [结构型指令页]()。

订阅者(Subscriber)

一个函数,用于定义如何获取或生成要发布的值或消息。 当有消费者调用可观察对象的 subscribe() 方法时,该函数就会执行。

订阅一个可观察对象就会触发该对象的执行、为该对象关联一些回调函数,并创建一个 Subscription(订阅记录)对象来让你能取消订阅。

subscribe() 方法接收一个 JavaScript 对象(叫做观察者(observer)),其中最多可以包含三个回调,分别对应可观察对象可以发出的几种通知类型:

  • next(下一个)通知会发送一个值,比如数字、字符串、对象。

  • error(错误)通知会发送 JavaScript 错误或异常。

  • complete(完成)通知不会发送值,但是当调用结束时会调用这个处理器。异步的值可能会在调用了完成之后继续发送过来。

目标

项目的一个可构建或可运行的子集,它是工作空间配置文件中的一个子对象,它会被建筑师(Architect)的构建器(Builder)执行。

在 angular.json 文件中,每个项目都有一个 architect 分区,其中包含一些用于配置构建器的目标。其中一些目标对应于 CLI 命令,比如 build、serve、test 和 lint。

比如,ng build 命令用来编译项目时所调用的构建器会使用一个特定的构建工具,并且具有一份默认配置,此配置中的值可以通过命令行参数进行覆盖。目标 build 还为 "生产环境" 构建定义了另一个配置,可以通过在 build 命令上添加 --prod 标志来调用它。

建筑师工具提供了一组构建器。ng new 命令为初始应用项目提供了一组目标。ng generate application 和 ng generate library 命令则为每个新项目提供了一组目标。这些目标的选项和配置都可以进行自定义,以便适应你项目的需求。比如,你可能会想为项目的 "build" 目标添加一个 "staging" 或 "testing" 配置。

你还可以定义一个自定义构建器,并且往项目配置中添加一个目标,来使用你的自定义构建器。然后你就可以通过 ng run 命令来运行此目标。

模板 (template)

用来定义要如何在 HTML 中渲染组件视图的代码。

模板会把纯 HTML 和 Angular 的数据绑定语法、指令和模板表达式组合起来。Angular 的元素会插入或计算那些值,以便在页面显示出来之前修改 HTML 元素。

模板通过 @Component() 装饰器与组件类类关联起来。模板代码可以作为 template 属性的值用内联的方式提供,也可以通过 templateUrl 属性链接到一个独立的 HTML 文件。

用 TemplateRef 对象表示的其它模板用来定义一些备用视图或内嵌视图,它们可以来自多个不同的组件。

模板驱动表单(template-driven forms)

一种在视图中使用 HTML 表单和输入类元素构建 Angular 表单的格式。 它的替代方案是响应式表单框架。

当构建模板驱动表单时:

模板是“权威数据源”。使用属性 (attribute) 在单个输入元素上定义验证规则。

使用 ngModel 进行双向绑定,保持组件模型和用户输入之间的同步。

在幕后,Angular 为每个带有 name 属性和双向绑定的输入元素创建了一个新的控件。

相关的 Angular 指令都带有 ng 前缀,例如 ngForm、ngModel 和 ngModelGroup。

另一种方式是响应式表单。响应式表单的简介和两种方式的比较参见 [Angular 表单简介]()。

模板表达式(template expression)

一种类似 TypeScript 的语法,Angular 用它对数据绑定 (data binding)进行求值。

到[模板表达式]()部分了解更多模板表达式的知识。

令牌(Token)

用于高效查表的不透明标识符(译注:不透明是指你不必了解其细节)。在 Angular 中,DI 令牌用于在依赖注入系统中查找服务提供者。

转译(transpile)

一种翻译过程,它会把一个版本的 JavaScript 转换成另一个版本,比如把下一版的 ES2015 转换成老版本的 ES5。

目录树(tree)

在 schematics 中,一个用 Tree 类表示的虚拟文件系统。 Schematic 规则以一个 tree 对象作为输入,对它们进行操作,并且返回一个新的 tree 对象。

TypeScript

TypeScript 是一种基于 JavaScript 的程序设计语言,以其可选类型系统著称。 TypeScript 提供了编译时类型检查和强大的工具支持(比如代码补齐、重构、内联文档和智能搜索等)。 许多代码编辑器和 IDE 都原生支持 TypeScript 或通过插件提供支持。

TypeScript 是 Angular 的首选语言。要了解更多,参见 [typescriptlang.org]()。

Universal

用来帮 Angular 应用实现服务端渲染的工具。 当与应用集成在一起时,Universal 可以在服务端生成静态页面并用它们来响应来自浏览器的请求。 当浏览器正准备运行完整版应用的时候,这个初始的静态页可以用作一个可快速加载的占位符。

欲知详情,参见 [Angular Universal: 服务端渲染]()。

视图 (view)

视图是可显示元素的最小分组单位,它们会被同时创建和销毁。 Angular 在一个或多个指令 (directive) 的控制下渲染视图。

组件 (component) 类及其关联的模板 (template)定义了一个视图。 具体实现上,视图由一个与该组件相关的 ViewRef 实例表示。 直属于某个组件的视图叫做宿主视图。 通常会把视图组织成一些视图树(view hierarchies)。

视图中各个元素的属性可以动态修改以响应用户的操作,而这些元素的结构(数量或顺序)则不能。你可以通过在它们的视图容器中插入、移动或移除内嵌视图来修改这些元素的结构。

当用户在应用中导航时(比如使用路由器),视图树可以动态加载或卸载。

视图引擎(View Engine)

Angular 9 之前的版本使用的编译和渲染管道。可对比 Ivy。

视图树(View hierarchy)

一棵相关视图的树,它们可以作为一个整体行动。其根视图就是组件的宿主视图。宿主视图可以是内嵌视图树的根,它被收集到了宿主组件上的一个视图容器(ViewContainerRef)中。视图树是 Angular 变更检测的关键部件之一。

视图树和组件树并不是一一对应的。那些嵌入到指定视图树上下文中的视图也可能是其它组件的宿主视图。那些组件可能和宿主组件位于同一个 NgModule 中,也可能属于其它 NgModule。

Web 组件

参见 [自定义元素]()。

工作空间(Workspace)

一组基于 Angular CLI 的 Angular 项目(也就是说应用或库),它们通常共同位于一个单一的源码仓库(比如 git)中。

CLI 的 ng new 命令会在文件系统中创建一个目录(也就是工作空间的根目录)。 在工作空间根目录下,还会创建此工作空间的配置文件(angular.json),并且还会默认初始化一个同名的应用项目。

而用来创建或操作应用和库的命令(比如 add 和 generate)必须在工作区目录下才能执行。

欲知详情,参见 [工作空间配置]()。

工作空间配置(Workspace configuration)

一个名叫 angular.json 的文件,它位于 Angular 工作空间 的根目录下,并为 Angular CLI 提供的或集成的各个构建/开发工具提供工作空间级和项目专属的默认配置项。

欲知详情,参见工作空间配置。

还有一些项目专属的配置文件是给某些工具使用的。比如 package.json 是给 npm 包管理器使用的,tsconfig.json 是给 TypeScript 转译器使用的,而 tslint.json 是给 TSLint 使用的。

欲知详情,参见[工作空间]()和[项目文件结构]()。

区域 (zone)

一组异步任务的执行上下文。它对于调试、性能分析和测试那些包含了异步操作(如事件处理、承诺、远程服务器调用等)的应用是非常有用的。

Angular 应用会运行在一个 Zone 区域中,在这里,它可以对异步事件做出反应,可以通过检查数据变更、利用数据绑定 (data bindings) 来更新信息显示。

Zone 的使用方可以在异步操作完成之前或之后采取行动。

在视图中显示数据

各种 Angular 组件构成了应用的数据结构。 组件关联到的 HTML 模板提供了在 Web 页面的上下文中显示数据的各种方法。 组件类和模板,共同构成了应用数据的一个视图。

在页面上把数据的值及其表现形式组合起来的过程,就叫做数据绑定。 通过将 HTML 模板中的各个控件绑定到组件类中的各种数据属性,你就把数据展示给了用户(并从该用户收集数据)。

另外,你可以使用指令来向模板中添加逻辑,指令告诉 Angular 在渲染页面时要如何修改。

Angular 定义了一种模板语言,它扩展了 HTML 标记,其扩展语法可以让你定义各种各样的数据绑定和逻辑指令。 当渲染完此页面之后,Angular 会解释这种模板语法,来根据你的逻辑更新 HTML 和数据的当前状态。 在你读完模板语法这章之前,本页中的练习可以先让你快速了解下这种模板语法的工作方式。

在这个示例中,你将创建一个带有英雄列表的组件。 你会显示出这些英雄的名字清单,某些情况下,还会在清单下方显示一条消息。 最终的用户界面是这样的:

使用插值显示组件属性

要显示组件的属性,最简单的方式就是通过插值 (interpolation) 来绑定属性名。 要使用插值,就把属性名包裹在双花括号里放进视图模板,如 {{myHero}}。

使用 CLI 命令 ng new displaying-data 创建一个工作空间和一个名叫 displaying-data 的应用。

删除 "app.component.html" 文件,这个范例中不再需要它了。

然后,到 "app.component.ts" 文件中修改组件的模板和代码。

修改完之后,它应该是这样的:

Path:"src/app/app.component.ts"

import { Component } from '@angular/core';@Component({  selector: 'app-root',  template: `    <h1>{{title}}</h1>    <h2>My favorite hero is: {{myHero}}</h2>    `})export class AppComponent {  title = 'Tour of Heroes';  myHero = 'Windstorm';}

再把两个属性 titlemyHero 添加到之前空白的组件中。

修改完的模板会使用双花括号形式的插值来显示这两个模板属性:

Path:"src/app/app.component.ts (template)"

template: `  <h1>{{title}}</h1>  <h2>My favorite hero is: {{myHero}}</h2>  `

模板是包在 ECMAScript 2015 反引号 (`) 中的一个多行字符串。 允许把一个字符串写在多行上, 使 HTML 模板更容易阅读。

Angular 自动从组件中提取 titlemyHero 属性的值,并且把这些值插入浏览器中。当这些属性发生变化时,Angular 就会自动刷新显示。

严格来说,“重新显示”是在某些与视图有关的异步事件之后发生的,例如,按键、定时器完成或对 HTTP 请求的响应。

注:
- 你没有调用 new 来创建 AppComponent 类的实例,是 Angular 替你创建了它。那么它是如何创建的呢?
- @Component 装饰器中指定的 CSS 选择器 selector,它指定了一个叫 <app-root& 的元素。 该元素是 "index.html" 文件里的一个占位符。

Path:"src/index.html (body)"

<body>  <app-root></app-root></body>

当你通过 "main.ts" 中的 AppComponent 类启动时,Angular 在 "index.html" 中查找一个 <app-root> 元素, 然后实例化一个 AppComponent,并将其渲染到 <app-root> 标签中。

运行应用。它应该显示出标题和英雄名:

选择模板来源

@Component 元数据告诉 Angular 要到哪里去找该组件的模板。 你有两种方式存放组件的模板。

你可以使用 @Component 装饰器的 template 属性来定义内联模板。内联模板对于小型示例或测试很有用。

此外,你还可以把模板定义在单独的 HTML 文件中,并且让 @Component 装饰器的 templateUrl 属性指向该文件。这种配置方式通常用于所有比小型测试或示例更复杂的场景中,它也是生成新组件时的默认值。

无论用哪种风格,模板数据绑定在访问组件属性方面都是完全一样的。 这里的应用使用了内联 HTML,是因为该模板很小,而且示例也很简单,用不到外部 HTML 文件。

  • 默认情况下,Angular CLI 命令 ng generate component 在生成组件时会带有模板文件,你可以通过参数来覆盖它:

ng generate component hero -t

初始化

下面的例子使用变量赋值来对组件进行初始化。

export class AppComponent {  title: string;  myHero: string;  constructor() {    this.title = 'Tour of Heroes';    this.myHero = 'Windstorm';  }}

你可以用构造函数来代替这些属性的声明和初始化语句。

添加循环遍历数据的逻辑

*ngFor 指令(Angular 预置)可以让你循环遍历数据。下面的例子使用该指令来显示数组型属性中的所有值。

要显示一个英雄列表,先向组件中添加一个英雄名字数组,然后把 myHero 重定义为数组中的第一个名字。

Path:"src/app/app.component.ts (class)"

export class AppComponent {  title = 'Tour of Heroes';  heroes = ['Windstorm', 'Bombasto', 'Magneta', 'Tornado'];  myHero = this.heroes[0];}

接着,在模板中使用 Angular 的 ngFor 指令来显示 heroes 列表中的每一项。

Path:"src/app/app.component.ts (template)"

template: `  <h1>{{title}}</h1>  <h2>My favorite hero is: {{myHero}}</h2>  <p>Heroes:</p>  <ul>    <li *ngFor="let hero of heroes">      {{ hero }}    </li>  </ul>`

这个界面使用了由 <ul><li> 标签组成的无序列表。<li> 元素里的 *ngFor 是 Angular 的“迭代”指令。 它将 <li> 元素及其子级标记为“迭代模板”:

Path:"src/app/app.component.ts (li)"

<li *ngFor="let hero of heroes">  {{ hero }}</li>

注:
- 不要忘记 *ngFor 中的前导星号 (*)。它是语法中不可或缺的一部分。

注意看 ngFor 双引号表达式中的 hero,它是一个模板输入变量。 更多模板输入变量的信息,见模板语法中的 微语法 (microsyntax)。

Angular 为列表中的每个条目复制一个 <li> 元素,在每个迭代中,把 hero 变量设置为当前条目(英雄)。 Angular 把 hero 变量作为双花括号插值的上下文。

本例中,ngFor 用于显示一个“数组”, 但 ngFor 可以为任何可迭代的 (iterable) 对象重复渲染条目。

现在,英雄们出现在了一个无序列表中。

为数据创建一个类

应用代码直接在组件内部直接定义了数据。 作为演示还可以,但它显然不是最佳实践。

现在使用的是到了一个字符串数组的绑定。在真实的应用中,大多是到一个对象数组的绑定。

要将此绑定转换成使用对象,需要把这个英雄名字数组变成 Hero 对象数组。但首先得有一个 Hero 类。

ng generate class hero

此命令创建了如下代码:

Path:"src/app/hero.ts"

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

你定义了一个类,具有一个构造函数和两个属性:idname

它可能看上去不像是有属性的类,但它确实有,利用的是 TypeScript 提供的简写形式 —— 用构造函数的参数直接定义属性。

来看第一个参数:

Path:"src/app/hero.ts (id)"

public id: number,

这个简写语法做了很多:

  • 声明了一个构造函数参数及其类型。

  • 声明了一个同名的公共属性。

  • 当创建该类的一个实例时,把该属性初始化为相应的参数值。

使用 Hero 类

导入了 Hero 类之后,组件的 heroes 属性就可以返回一个类型化的Hero 对象数组了。

Path:"src/app/app.component.ts (heroes)"

heroes = [  new Hero(1, 'Windstorm'),  new Hero(13, 'Bombasto'),  new Hero(15, 'Magneta'),  new Hero(20, 'Tornado')];myHero = this.heroes[0];

接着,修改模板。 现在它显示的是英雄的 idname。 要修复它,只显示英雄的 name 属性就行了。

Path:"src/app/app.component.ts (template)"

template: `  <h1>{{title}}</h1>  <h2>My favorite hero is: {{myHero.name}}</h2>  <p>Heroes:</p>  <ul>    <li *ngFor="let hero of heroes">      {{ hero.name }}    </li>  </ul>`

显示上还和以前一样,不过代码更清晰了。

通过 NgIf 进行条件显示

有时,应用需要只在特定情况下显示视图或视图的一部分。

来改一下这个例子,如果多于三位英雄,显示一条消息。

Angular 的 ngIf 指令会根据一个布尔条件来显示或移除一个元素。 来看看实际效果,把下列语句加到模板的底部:

Path:"src/app/app.component.ts (message)"

<p *ngIf="heroes.length > 3">There are many heroes!</p>

双引号内的模板表达式 *ngIf="heroes.length > 3" 的外观和行为与 TypeScript 非常相似。当组件的英雄列表包含三个以上的条目时,Angular 会将这段话添加到 DOM 中,这条消息就显示出来了。如果只有三个或更少的条目,Angular 就会省略该段落,也就不会显示任何消息。

双引号中的模板表达式 *ngIf="heros.length > 3",外观和行为很象 TypeScript。 当组件中的英雄列表有三个以上的条目时,Angular 就会把这个段落添加到 DOM 中,于是消息显示了出来。 如果有三个或更少的条目,则 Angular 会省略这些段落,所以不显示消息。

注:
- Angular 并不是在显示和隐藏这条消息,它是在从 DOM 中添加和移除这个段落元素。 这会提高性能,特别是在一些大的项目中有条件地包含或排除一大堆带着很多数据绑定的 HTML 时。

试一下。因为这个数组中有四个条目,所以消息应该显示出来。 回到 "app.component.ts",从英雄数组中删除或注释掉一个元素。 浏览器应该自动刷新,消息应该会消失。

源代码

  1. Path:"src/app/app.component.ts"

    import { Component } from '@angular/core';    import { Hero } from './hero';    @Component({      selector: 'app-root',      template: `      <h1>{{title}}</h1>      <h2>My favorite hero is: {{myHero.name}}</h2>      <p>Heroes:</p>      <ul>        <li *ngFor="let hero of heroes">          {{ hero.name }}          </li>      </ul>      <p *ngIf="heroes.length > 3">There are many heroes!</p>    `    })    export class AppComponent {      title = 'Tour of Heroes';      heroes = [        new Hero(1, 'Windstorm'),        new Hero(13, 'Bombasto'),        new Hero(15, 'Magneta'),        new Hero(20, 'Tornado')      ];      myHero = this.heroes[0];    }

  1. Path:"src/app/hero.ts"

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

  1. Path:"src/app/app.module.ts"

    import { NgModule } from '@angular/core';    import { BrowserModule }  from '@angular/platform-browser';    import { AppComponent } from './app.component';    @NgModule({      imports: [        BrowserModule      ],      declarations: [        AppComponent      ],      bootstrap: [ AppComponent ]    })    export class AppModule { }

  1. Path:"main.ts"

    import { enableProdMode } from '@angular/core';    import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';    import { AppModule } from './app/app.module';    import { environment } from './environments/environment';    if (environment.production) {      enableProdMode();    }    platformBrowserDynamic().bootstrapModule(AppModule);

总结

现在您知道了如何使用:

  • 带有双花括号的插值 (interpolation) 来显示一个组件属性。

  • ngFor 显示数组。

  • 用一个 TypeScript 类来为你的组件描述模型数据并显示模型的属性。

  • ngIf 根据一个布尔表达式有条件地显示一段 HTML。

这是一篇关于 Angular 模板语言的技术大全。 在本篇中解释了模板语言的基本原理,并描述了你将在本教程的学习中遇到的大部分语法。

Angular 应用管理着用户之所见和所为,并通过 Component 类的实例(组件)和面向用户的模板交互来实现这一点。

从使用模型-视图-控制器 (MVC) 或模型-视图-视图模型 (MVVM) 的经验中,很多开发人员都熟悉了组件和模板这两个概念。 在 Angular 中,组件扮演着控制器或视图模型的角色,模板则扮演视图的角色。

这是一篇关于 Angular 模板语言的技术大全。 它解释了模板语言的基本原理,并描述了你将在文档中其它地方遇到的大部分语法。

模板中的 HTML

HTML 是 Angular 模板的语言。几乎所有的 HTML 语法都是有效的模板语法。 但值得注意的例外是 <script>元素,它被禁用了,以阻止脚本注入攻击的风险。(实际上,<script> 只是被忽略了。)

有些合法的 HTML 被用在模板中是没有意义的。比如<html><body><base> 这几个元素在这其中并没有扮演有用的角色。剩下的所有元素基本上就都有其所用了。

可以通过组件和指令来扩展模板中的 HTML 词汇。它们看上去就是新元素和属性。接下来将学习如何通过数据绑定来动态获取/设置 DOM(文档对象模型)的值。

首先看看数据绑定的第一种形式 —— 插值,它展示了模板的 HTML 可以有多丰富。

插值与模板表达式

插值能让你把计算后的字符串合并到 HTML 元素标签之间和属性赋值语句内的文本中。模板表达式则是用来供你求出这些字符串的。

插值 {{...}}

所谓 "插值" 是指将表达式嵌入到标记文本中。 默认情况下,插值会用双花括号 {{ }} 作为分隔符。

在下面的代码片段中,{{ currentCustomer }} 就是插值的例子。

Path:"src/app/app.component.html"

<h3>Current customer: {{ currentCustomer }}</h3>

花括号之间的文本currentCustomer通常是组件属性的名字。Angular 会把这个名字替换为响应组件属性的字符串值。

Path:"src/app/app.component.html"

<p>{{title}}</p><div><img src="{{itemImageUrl}}"></div>

在上面的示例中,Angular 计算 titleitemImageUrl 属性并填充空白,首先显示一些标题文本,然后显示图像。

一般来说,括号间的素材是一个模板表达式,Angular 先对它求值,再把它转换成字符串。 下列插值通过把括号中的两个数字相加说明了这一点:

Path:"src/app/app.component.html"

<!-- "The sum of 1 + 1 is 2" --><p>The sum of 1 + 1 is {{1 + 1}}.</p>

这个表达式可以调用宿主组件的方法,就像下面用的 getVal()

Path:"src/app/app.component.html"

<!-- "The sum of 1 + 1 is not 4" --><p>The sum of 1 + 1 is not {{1 + 1 + getVal()}}.</p>

Angular 对所有双花括号中的表达式求值,把求值的结果转换成字符串,并把它们跟相邻的字符串字面量连接起来。最后,把这个组合出来的插值结果赋给元素或指令的属性。

你看上去似乎正在将结果插入元素标签之间,并将其赋值给属性。 但实际上,插值是一种特殊语法,Angular 会将其转换为属性绑定。

注:
- 如果你想用别的分隔符来代替 {{ }},也可以通过 Component 元数据中的 interpolation 选项来配置插值分隔符。

模板表达式

模板表达式会产生一个值,并出现在双花括号 {{ }} 中。 Angular 执行这个表达式,并把它赋值给绑定目标的属性,这个绑定目标可能是 HTML 元素、组件或指令。

{{1 + 1}} 中所包含的模板表达式是 1 + 1。 在属性绑定中会再次看到模板表达式,它出现在 = 右侧的引号中,就像这样:[property]="expression"

在语法上,模板表达式与 JavaScript 很像。很多 JavaScript 表达式都是合法的模板表达式,但也有一些例外。

你不能使用那些具有或可能引发副作用的 JavaScript 表达式,包括:

  • 赋值 (=, +=, -=, ...)。

  • newtypeofinstanceof 等运算符。

  • 使用 ;, 串联起来的表达式。

  • 自增和自减运算符:++--

  • 一些 ES2015+ 版本的运算符。

和 JavaScript 语法的其它显著差异包括:

  • 不支持位运算,比如 |&

  • 新的模板表达式运算符,例如 |?. 和 !

表达式上下文

典型的表达式上下文就是这个组件实例,它是各种绑定值的来源。 在下面的代码片段中,双花括号中的 recommended 和引号中的 itemImageUrl2 所引用的都是 AppComponent 中的属性。

Path:"src/app/app.component.html"

<h4>{{recommended}}</h4><img [src]="itemImageUrl2">

表达式也可以引用模板中的上下文属性,例如模板输入变量,

let customer,或模板引用变量 #customerInput

Path:"src/app/app.component.html (template input variable)"

<ul>  <li *ngFor="let customer of customers">{{customer.name}}</li></ul>

Path:"src/app/app.component.html (template reference variable)"

<label>Type something:  <input #customerInput>{{customerInput.value}}</label>

表达式中的上下文变量是由模板变量、指令的上下文变量(如果有)和组件的成员叠加而成的。 如果你要引用的变量名存在于一个以上的命名空间中,那么,模板变量是最优先的,其次是指令的上下文变量,最后是组件的成员。

上一个例子中就体现了这种命名冲突。组件具有一个名叫 customer 的属性,而 *ngFor 声明了一个也叫 customer 的模板变量。

注:
- 在 {{customer.name}} 表达式中的 customer 实际引用的是模板变量,而不是组件的属性。

  • 模板表达式不能引用全局命名空间中的任何东西,比如 windowdocument。它们也不能调用 console.logMath.max。 它们只能引用表达式上下文中的成员。

表达式使用指南

当使用模板表达式时,请遵循下列要素:

  1. 非常简单

虽然也可以写复杂的模板表达式,不过最好避免那样做。

属性名或方法调用应该是常态,但偶然使用逻辑取反 ! 也是可以的。 其它情况下,应该把应用程序和业务逻辑限制在组件中,这样它才能更容易开发和测试。

  1. 执行迅速

Angular 会在每个变更检测周期后执行模板表达式。 变更检测周期会被多种异步活动触发,比如 Promise 解析、HTTP 结果、定时器时间、按键或鼠标移动。

表达式应该快速结束,否则用户就会感到拖沓,特别是在较慢的设备上。 当计算代价较高时,应该考虑缓存那些从其它值计算得出的值。

  1. 没有可见的副作用

模板表达式除了目标属性的值以外,不应该改变应用的任何状态。

这条规则是 Angular “单向数据流”策略的基础。 永远不用担心读取组件值可能改变另外的显示值。 在一次单独的渲染过程中,视图应该总是稳定的。

幂等的表达式是最理想的,因为它没有副作用,并且可以提高 Angular 的变更检测性能。 用 Angular 术语来说,幂等表达式总会返回完全相同的东西,除非其依赖值之一发生了变化。

在单独的一次事件循环中,被依赖的值不应该改变。 如果幂等的表达式返回一个字符串或数字,连续调用它两次,也应该返回相同的字符串或数字。 如果幂等的表达式返回一个对象(包括 Date 或 Array),连续调用它两次,也应该返回同一个对象的引用。

注:
- 对于 *ngFor,这种行为有一个例外。*ngFor 具有 trackBy 功能,在迭代对象时它可以处理对象的相等性。详情参见 带 trackBy*ngFor

模板语句

模板语句用来响应由绑定目标(如 HTML 元素、组件或指令)触发的事件。 模板语句将在事件绑定一节看到,它出现在 = 号右侧的引号中,就像这样:(event)="statement"

Path:"src/app/app.component.html"

<button (click)="deleteHero()">Delete hero</button>

模板语句有副作用。 这是事件处理的关键。因为你要根据用户的输入更新应用状态。

响应事件是 Angular 中“单向数据流”的另一面。 在一次事件循环中,可以随意改变任何地方的任何东西。

和模板表达式一样,模板语句使用的语言也像 JavaScript。 模板语句解析器和模板表达式解析器有所不同,特别之处在于它支持基本赋值 (=) 和表达式链 (;)。

然而,某些 JavaScript 语法和模板表达式语法仍然是不允许的:

  • new 运算符

自增和自减运算符:++--

操作并赋值,例如 +=-=

位运算符,例如 |&

管道运算符

语句上下文

和表达式中一样,语句只能引用语句上下文中 —— 通常是正在绑定事件的那个组件实例。

典型的语句上下文就是当前组件的实例。 (click)="deleteHero()" 中的 deleteHero 就是这个数据绑定组件上的一个方法。

Path:"src/app/app.component.html"

<button (click)="deleteHero()">Delete hero</button>

语句上下文可以引用模板自身上下文中的属性。 在下面的例子中,就把模板的 $event 对象、模板输入变量 (let hero)和模板引用变量 (#heroForm)传给了组件中的一个事件处理器方法。

Path:"src/app/app.component.html"

<button (click)="onSave($event)">Save</button><button *ngFor="let hero of heroes" (click)="deleteHero(hero)">{{hero.name}}</button><form #heroForm (ngSubmit)="onSubmit(heroForm)"> ... </form>

模板上下文中的变量名的优先级高于组件上下文中的变量名。在上面的 deleteHero(hero) 中,hero 是一个模板输入变量,而不是组件中的 hero 属性。

语句指南

模板语句不能引用全局命名空间的任何东西。比如不能引用 windowdocument,也不能调用 console.logMath.max

和表达式一样,避免写复杂的模板语句。 常规是函数调用或者属性赋值。

绑定语法:概览

数据绑定是一种机制,用来协调用户可见的内容,特别是应用数据的值。 虽然也可以手动从 HTML 中推送或拉取这些值,但是如果将这些任务转交给绑定框架,应用就会更易于编写、阅读和维护。 你只需声明数据源和目标 HTML 元素之间的绑定关系就可以了,框架会完成其余的工作。

Angular 提供了多种数据绑定方式。绑定类型可以分为三类,按数据流的方向分为:

  1. 单向:从数据源到视图

  • 绑定类型:插值、属性、Attribute、CSS类、样式。

  • 语法:

    {{expression}}    [target]="expression"    bind-target="expression"

  1. 单向:从视图到数据源

  • 绑定类型:事件

  • 语法:

    (target)="statement"    on-target="statement"

  • 双向:视图到数据源到视图

  • 绑定类型:双向

  • 语法:

    [(target)]="expression"    bindon-target="expression"

除插值以外的其它绑定类型在等号的左侧都有一个“目标名称”,由绑定符 []() 包起来, 或者带有前缀:bind-on-bindon-

绑定的“目标”是绑定符内部的属性或事件:[]()[()]

在绑定时可以使用来源指令的每个公共成员。 你无需进行任何特殊操作即可在模板表达式或语句内访问指令的成员。

数据绑定与 HTML

在正常的 HTML 开发过程中,你使用 HTML 元素来创建视觉结构, 通过把字符串常量设置到元素的 attribute 来修改那些元素。

<div class="special">Plain old HTML</div><img src="images/item.png"><button disabled>Save</button>

使用数据绑定,你可以控制按钮状态等各个方面:

Path:"src/app/app.component.html"

<!-- Bind button disabled state to `isUnchanged` property --><button [disabled]="isUnchanged">Save</button>

注:
- 这里绑定到的是按钮的 DOM 元素的 disabled 这个 Property,而不是 Attribute

  • 这是数据绑定的通用规则。数据绑定使用 DOM 元素、组件和指令的 Property,而不是 HTML 的 Attribute

HTML attribute 与 DOM property 的对比

理解 HTML 属性和 DOM 属性之间的区别,是了解 Angular 绑定如何工作的关键。Attribute 是由 HTML 定义的。Property 是从 DOM(文档对象模型)节点访问的。

  • 一些 HTML Attribute 可以 1:1 映射到 Property;例如,“ id”。

  • 某些 HTML Attribute 没有相应的 Property。例如,aria-*。

  • 某些 DOM Property 没有相应的 Attribute。例如,textContent。

重要的是要记住,HTML Attribute 和 DOM Property 是不同的,就算它们具有相同的名称也是如此。 在 Angular 中,HTML Attribute 的唯一作用是初始化元素和指令的状态。

模板绑定使用的是 Property 和事件,而不是 Attribute

编写数据绑定时,你只是在和目标对象的 DOM Property 和事件打交道。

注:
- 该通用规则可以帮助你建立 HTML Attribute 和 DOM Property 的思维模型: 属性负责初始化 DOM 属性,然后完工。Property 值可以改变;Attribute 值则不能。

  • 此规则有一个例外。 可以通过 setAttribute() 来更改 Attribute,接着它会重新初始化相应的 DOM 属性。

示例 1:<input>

当浏览器渲染 <input type="text" value="Sarah"> 时,它会创建一个对应的 DOM 节点,其 value Property 已初始化为 “Sarah”。

<input type="text" value="Sarah">

当用户在 <input> 中输入 Sally 时,DOM 元素的 value Property 将变为 Sally。 但是,如果使用 input.getAttribute('value') 查看 HTML 的 Attribute value,则可以看到该 attribute 保持不变 —— 它返回了 Sarah

HTML 的 value 这个 attribute 指定了初始值;DOM 的 value 这个 property 是当前值。

示例 2:禁用按钮

disabled Attribute 是另一个例子。按钮的 disabled Property 默认为 false,因此按钮是启用的。

当你添加 disabled Attribute 时,仅仅它的出现就将按钮的 disabled Property 初始化成了 true,因此该按钮就被禁用了。

<button disabled>Test Button</button>

添加和删除 disabledAttribute 会禁用和启用该按钮。 但是,Attribute 的值无关紧要,这就是为什么你不能通过编写 <button disabled="false">仍被禁用</button> 来启用此按钮的原因。

要控制按钮的状态,请设置 disabled Property

虽然技术上说你可以设置 [attr.disabled] 属性绑定,但是它们的值是不同的,Property 绑定要求一个布尔值,而其相应的 Attribute 绑定则取决于该值是否为 null。例子如下:

&
lt;input [disabled]="condition ? true : false"&
lt;input [attr.disabled]="condition ? 'disabled' : null"&

通常,要使用 Property 绑定而不是 Attribute 绑定,因为它更直观(是一个布尔值),语法更短,并且性能更高。

绑定类型与绑定目标

数据绑定的目标是 DOM 中的对象。 根据绑定类型,该目标可以是 Property 名(元素、组件或指令的)、事件名(元素、组件或指令的),有时是 Attribute 名。下表中总结了不同绑定类型的目标。

  1. 绑定类型:属性。

  • 目标:元素的 property 、组件的 property 、指令的 property 。

  • 示例:

    <img [src]="heroImageUrl">    <app-hero-detail [hero]="currentHero"></app-hero-detail>    <div [ngClass]="{'special': isSpecial}"></div>

  1. 绑定类型:事件。

  • 目标:元素的事件、组件的事件、指令的事件。

  • 示例:

    <button (click)="onSave()">Save</button>    <app-hero-detail (deleteRequest)="deleteHero()"></app-hero-detail>    <div (myClick)="clicked=$event" clickable>click me</div>

  1. 绑定类型:双向。

  • 目标:事件与 property。

  • 示例:

    <input [(ngModel)]="name">

  1. 绑定类型:Attribute 。

  • 目标:attribute(例外情况)。

  • 示例:

    <button [attr.aria-label]="help">help</button>

  1. 绑定类型:CSS 类。

  • 目标:class property 。

  • 示例:

    <div [class.special]="isSpecial">Special</div>

  1. 绑定类型:样式。

  • 目标:style property 。

  • 示例:

    <button [style.color]="isSpecial ? 'red' : 'green'">

Property 绑定 [property]

使用 Property 绑定到目标元素或指令 @Input() 装饰器的 set 型属性。

单向输入

Property 绑定的值在一个方向上流动,从组件的 Property 变为目标元素的 Property

你不能使用属性绑定从目标元素读取或拉取值。同样的,你也不能使用属性绑定在目标元素上调用方法。如果元素要引发事件,则可以使用事件绑定来监听它们。

示例:

最常见的 Property 绑定将元素的 Property 设置为组件的 Property 值。例子之一是将 img 元素的 src Property 绑定到组件的 itemImageUrl Property

Path:"src/app/app.component.html"

<img [src]="itemImageUrl">

这是绑定到 colSpan Property 的示例。请注意,它不是 colspan,后者是 Attribute,用小写的 s 拼写。

Path:"src/app/app.component.html"

<!-- Notice the colSpan property is camel case --><tr><td [colSpan]="2">Span 2 columns</td></tr>

另一个例子是当组件说它 isUnchanged(未改变)时禁用按钮:

Path:"src/app/app.component.html"

<!-- Bind button disabled state to `isUnchanged` property --><button [disabled]="isUnchanged">Disabled Button</button>

另一个例子是设置指令的属性:

Path:"src/app/app.component.html"

<p [ngClass]="classes">[ngClass] binding to the classes property making this blue</p

另一种方法是设置自定义组件的模型属性 —— 这是一种父级和子级组件进行通信的好办法:

Path:"src/app/app.component.html"

<app-item-detail [childItem]="parentItem"></app-item-detail>

绑定目标

包裹在方括号中的元素属性名标记着目标属性。下列代码中的目标属性是 image 元素的 src 属性。

Path:"src/app/app.component.html"

<img [src]="itemImageUrl">

还有一种使用 bind- 前缀的替代方案:

Path:"src/app/app.component.html"

<img bind-src="itemImageUrl">

在大多数情况下,目标名都是 Property 名,虽然它看起来像 Attribute 名。因此,在这个例子中,src<img> 元素属性的名称。

元素属性可能是最常见的绑定目标,但 Angular 会先去看这个名字是否是某个已知指令的属性名,就像下面的例子中一样:

Path:"src/app/app.component.html"

<p [ngClass]="classes">[ngClass] binding to the classes property making this blue</p>

从技术上讲,Angular 将这个名称与指令的 @Input() 进行匹配,它来自指令的 inputs 数组中列出的 Property 名称之一或是用 @Input() 装饰的属性。这些输入都映射到指令自身的属性。

如果名字没有匹配上已知指令或元素的属性,Angular 就会报告“未知指令”的错误。

注:
- 尽管目标名称通常是 Property 的名称,但是在 Angular 中,有几个常见属性会自动将 Attribute 映射为 Property。这些包括 class / classNamennerHtml / innerHTMLtabindex / abIndex

消除副作用

模板表达的计算应该没有明显的副作用。表达式语言本身或你编写模板表达式的方式在一定程度上有所帮助。你不能为属性绑定表达式中的任何内容赋值,也不能使用递增和递减运算符。

例如,假设你有一个表达式,该表达式调用了具有副作用的属性或方法。该表达式可以调用类似 getFoo() 的函数,只有你知道 getFoo() 做了什么。如果 getFoo() 更改了某些内容,而你恰巧绑定到该内容,则 Angular 可能会也可能不会显示更改后的值。Angular 可能会检测到更改并抛出警告错误。最佳实践是坚持使用属性和返回值并避免副作用的方法。

返回正确的类型

模板表达式的计算结果应该是目标属性所需要的值类型。如果 target 属性需要一个字符串,则返回一个字符串;如果需要一个数字,则返回一个数字;如果需要一个对象,则返回一个对象,依此类推。

在下面的例子中,temDetailComponentchildItem 属性需要一个字符串,而这正是你要发送给属性绑定的内容:

Path:"src/app/app.component.html"

<app-item-detail [childItem]="parentItem"></app-item-detail>

你可以查看 ItemDetailComponent 来确认这一点,它的 @Input 类型设为了字符串:

Path:"src/app/item-detail/item-detail.component.ts (setting the @Input() type)"

@Input() childItem: string;

如你所见,AppComponent 中的 parentItem 是一个字符串,而 ItemDetailComponent 需要的就是字符串:

Path:"src/app/app.component.ts"

parentItem = 'lamp';

传入对象

前面的简单示例演示了传入字符串的情况。要传递对象,其语法和思想是相同的。

在这种情况下,ItemListComponent 嵌套在 AppComponent 中,并且 items 属性需要一个对象数组。

Path:"src/app/app.component.html"

<app-item-list [items]="currentItems"></app-item-list>

items 属性是在 ItemListComponent 中用 Item 类型声明的,并带有 @Input() 装饰器:

Path:"src/app/item-list.component.ts"

@Input() items: Item[];

在此示例应用程序中,Item是具有两个属性的对象。一个 id 和一个 name

Path:"src/app/item.ts"

export interface Item {  id: number;  name: string;}

当另一个文件 "mock-items.ts" 中存在一个条目列表时,你可以在 "app.component.ts" 中指定另一个条目,以便渲染新条目:

Path:"src/app.component.ts"

currentItems = [{  id: 21,  name: 'phone'}];

在这个例子中,你只需要确保你所提供的对象数组的类型,也就是这个 Item 的类型是嵌套组件 `ItemListComponent 所需要的类型。

在此示例中,AppComponen 指定了另一个 item 对象( urrentItems )并将其传给嵌套的 ItemListComponentItemListComponent 之所以能够使用 currentItems 是因为它与 "item.ts" 中定义的 Item 对象的类型相匹配。在 "item.ts" 文件中,ItemListComponent 获得了其对 item 的定义。

方括号

方括号 [] 告诉 Angular 计算该模板表达式。如果省略括号,Angular 会将字符串视为常量,并使用该字符串初始化目标属性 :

Path:"src/app.component.html"

<app-item-detail childItem="parentItem"></app-item-detail>

省略方括号将渲染字符串 parentItem,而不是 parentItem 的值。

一次性字符串初始化

当满足下列条件时,应该省略括号:

  • 目标属性接受字符串值。

  • 字符串是一个固定值,你可以直接将其放入模板中。

  • 这个初始值永不改变。

你通常会以这种方式在标准 HTML 中初始化属性,并且它对指令和组件的属性初始化同样有效。 下面的示例将 StringInitComponent 中的 prefix 属性初始化为固定字符串,而不是模板表达式。Angular 设置它,然后就不管它了。

Path:"src/app/app.component.html"

<app-string-init prefix="This is a one-time initialized string."></app-string-init>

另一方面,[item] 绑定仍然是与组件的 currentItems 属性的实时绑定。

属性绑定与插值

你通常得在插值和属性绑定之间做出选择。 下列这几对绑定做的事情完全相同:

Path:"src/app/app.component.html"

<p><img src="{{itemImageUrl}}"> is the <i>interpolated</i> image.</p><p><img [src]="itemImageUrl"> is the <i>property bound</i> image.</p><p><span>"{{interpolationTitle}}" is the <i>interpolated</i> title.</span></p><p>"<span [innerHTML]="propertyTitle"></span>" is the <i>property bound</i> title.</p>

在许多情况下,插值是属性绑定的便捷替代法。当要把数据值渲染为字符串时,虽然可读性方面倾向于插值,但没有技术上的理由偏爱一种形式。但是,将元素属性设置为非字符串的数据值时,必须使用属性绑定。

内容安全

假设如下恶意内容:

Path:"src/app/app.component.ts"

evilTitle = 'Template <script>alert("evil never sleeps")</script> Syntax';

在组件模板中,内容可以与插值一起使用:

Path:"src/app/app.component.html"

<p><span>"{{evilTitle}}" is the <i>interpolated</i> evil title.</span></p>

幸运的是,Angular 数据绑定对于危险的 HTML 高度戒备。在上述情况下,HTML 将按原样显示,而 Javascript 不执行。Angular 不允许带有 script 标签的 HTML 泄漏到浏览器中,无论是插值还是属性绑定。

不过,在下列示例中,Angular 会在显示值之前先对它们进行无害化处理。

Path:"src/app/app.component.html"

<!-- Angular generates a warning for the following line as it sanitizes them WARNING: sanitizing HTML stripped some content (see http://g.co/ng/security#xss).--> <p>"<span [innerHTML]="evilTitle"></span>" is the <i>property bound</i> evil title.</p>

插值处理 <script> 标记与属性绑定的方式不同,但是这两种方法都可以使内容无害。以下是 evilTitle 示例的浏览器输出。

"Template <script>alert("evil never sleeps")</script> Syntax" is the interpolated evil title."Template Syntax" is the property bound evil title.

attribute、class 和 style 绑定

模板语法为那些不太适合使用属性绑定的场景提供了专门的单向数据绑定形式。

要在运行中的应用查看 Attribute 绑定、类绑定和样式绑定,请参见 现场演练 / 下载范例 特别是对于本节。

attribute 绑定

可以直接使用 Attribute 绑定设置 Attribute 的值。一般来说,绑定时设置的是目标的 Property,而 Attribute 绑定是唯一的例外,它创建和设置的是 Attribute

通常,使用 Property 绑定设置元素的 Property 优于使用字符串设置 Attribute。但是,有时没有要绑定的元素的 Property,所以其解决方案就是 Attribute 绑定。

考虑 ARIASVG。它们都纯粹是 Attribute,不对应于元素的 Property,也不能设置元素的 Property。 在这些情况下,就没有要绑定到的目标 Property

Attribute 绑定的语法类似于 Property 绑定,但其括号之间不是元素的 Property,而是由前缀 attr、点( . )和 Attribute 名称组成。然后,你就可以使用能解析为字符串的表达式来设置该 Attribute 的值,或者当表达式解析为 null 时删除该 Attribute

attribute 绑定的主要用例之一是设置 ARIA attribute(译注:ARIA 指无障碍功能,用于给残障人士访问互联网提供便利), 就像这个例子中一样:

Path:"src/app/app.component.html"

<!-- create and set an aria attribute for assistive technology --><button [attr.aria-label]="actionName">{{actionName}} with Aria</button>

colspancolSpan
注意 colspan AttributecolSpan Property 之间的区别。

如果你这样写:

&
lt;tr&<td colspan="{{1 + 1}}"&Three-Four</td&</tr&

你会收到如下错误:

&
Template parse errors:
Can't bind to 'colspan' since it isn't a known native property

如错误消息所示,<td& 元素没有 colspan 这个 Property。这是正确的,因为 colspan 是一个 Attribute,而 colSpan (colSpan 中的 S 是大写)则是相应的 Property。插值和 Property 绑定只能设置 Property,不能设置 Attribute

相反,你可以使用 Property 绑定并将其改写为:

&Path:"src/app/app.component.html"

lt;!-- Notice the colSpan property is camel case --&
lt;tr&<td [colSpan]="1 + 1"&Three-Four</td&</tr&

类绑定

下面是在普通 HTML 中不用绑定来设置 class Attribute 的方法:

<!-- standard class attribute setting --><div class="foo bar">Some text</div>

你还可以使用类绑定来为一个元素添加和移除 CSS 类。

要创建单个类的绑定,请使用 class 前缀,紧跟一个点(.),再跟上 CSS 类名,比如 [class.foo]="hasFoo"。 当绑定表达式为真值的时候,Angular 就会加上这个类,为假值则会移除,但 undefined 是假值中的例外,参见样式委派 部分。

要想创建多个类的绑定,请使用通用的 [class] 形式来绑定类,而不要带点,比如 [class]="classExpr"。 该表达式可以是空格分隔的类名字符串,或者用一个以类名为键、真假值表达式为值的对象。 当使用对象格式时,Angular 只会加上那些相关的值为真的类名。

一定要注意,在对象型表达式中(如 objectArrayMapSet 等),当这个类列表改变时,对象的引用也必须修改。仅仅修改其属性而不修改对象引用是无法生效的。

如果有多处绑定到了同一个类名,出现的冲突将根据样式的优先级规则进行解决。

绑定类型语法输入类型输入值示例
单个类绑定[class.foo]="hasFoo"boolean OR undefined OR nulltrue, false
多个类绑定[class]="classExpr"string OR {[key: string]: boolean / undefined / null} OR Array<string> | "my-class-1 my-class-2 my-class-3" OR {foo: true, bar: false} OR ['foo', 'bar']`

注:
- 多个类绑定的输入类型按顺序对应一个示例,而不是一个类型对应多个示例。

尽管此技术适用于切换单个类名,但在需要同时管理多个类名时请考虑使用 NgClass 指令。

样式绑定

下面演示了如何不通过绑定在普通 HTML 中设置 style 属性:

<!-- standard style attribute setting --><div style="color: blue">Some text</div>

你还可以通过样式绑定来动态设置样式。

要想创建单个样式的绑定,请以 style 前缀开头,紧跟一个点(.),再跟着 CSS 样式的属性名,比如 [style.width]="width"。 该属性将会被设置为绑定表达式的值,该值通常为字符串。 不过你还可以添加一个单位表达式,比如 em%,这时候该值就要是一个 number 类型。

注:
- 样式属性命名方法可以用中线命名法,像上面的一样 也可以用驼峰式命名法,如 fontSize

如果要切换多个样式,你可以直接绑定到 [style] 属性而不用点(比如,[style]="styleExpr")。赋给 [style] 的绑定表达式通常是一系列样式组成的字符串,比如 "width: 100px; height: 100px;"。

你也可以把该表达式格式化成一个以样式名为键、以样式值为值的对象,比如 {width: '100px', height: '100px'}。一定要注意,对于任何对象型的表达式( 如 objectArrayMapSet 等),当这个样式列表改变时,对象的引用也必须修改。仅仅修改其属性而不修改对象引用是无法生效的。。

如果有多处绑定了同一个样式属性,则会使用样式的优先级规则来解决冲突。

绑定类型语法输入类型输入值示例
单一样式绑定[style.width]="width"string OR undefined OR null"100px"
带单位的单一样式绑定[style.width.px]="width"number OR undefined OR null100
多个样式绑定[style]="styleExpr"string OR {[key: string]: string / undefined / null} OR Array<string>`['width', '100px']

NgStyle 指令可以作为 [style] 绑定的替代指令。但是,应该把上面这种 [style] 样式绑定语法作为首选,因为随着 Angular 中样式绑定的改进,NgStyle 将不再提供重要的价值,并最终在未来的某个版本中删除。

样式的优先级规则

一个 HTML 元素可以把它的 CSS 类列表和样式值绑定到多个来源(例如,来自多个指令的宿主 host 绑定)。

当对同一个类名或样式属性存在多个绑定时,Angular 会使用一组优先级规则来解决冲突,并确定最终哪些类或样式会应用到该元素中。

样式的优先级规则(从高到低)

  1. 模板绑定

  1. 属性绑定(例如 <div [class.foo]="hasFoo"& 或 <div [style.color]="color"&)

1. Map 绑定(例如,<div [class]="classExpr"& 或 <div [style]="styleExpr"& )

2. 静态值(例如 <div class="foo"& 或 <div style="color: blue"& )

  1. 指令宿主绑定

  1. 属性绑定(例如,host: {'[class.foo]': 'hasFoo'} 或 host: {'[style.color]': 'color'} )

1. Map 绑定(例如,host: {'[class]': 'classExpr'} 或者 host: {'[style]': 'styleExpr'} )

2. 静态值(例如,host: {'class': 'foo'} 或 host: {'style': 'color: blue'} )

  1. 组件宿主绑定

  1. 属性绑定(例如,host: {'[class.foo]': 'hasFoo'} 或 host: {'[style.color]': 'color'} )

1. Map 绑定(例如,host: {'[class]': 'classExpr'} 或者 host: {'[style]': 'styleExpr'} )

2. 静态值(例如,host: {'class': 'foo'} 或 host: {'style': 'color: blue'} )

某个类或样式绑定越具体,它的优先级就越高。

对具体类(例如 [class.foo] )的绑定优先于一般化的 [class] 绑定,对具体样式(例如 [style.bar] )的绑定优先于一般化的 [style] 绑定。

Path:"src/app/app.component.html"

<h3>Basic specificity</h3><!-- The `class.special` binding will override any value for the `special` class in `classExpr`.  --><div [class.special]="isSpecial" [class]="classExpr">Some text.</div><!-- The `style.color` binding will override any value for the `color` property in `styleExpr`.  --><div [style.color]="color" [style]="styleExpr">Some text.</div>

当处理不同来源的绑定时,也适用这种基于具体度的规则。 某个元素可能在声明它的模板中有一些绑定、在所匹配的指令中有一些宿主绑定、在所匹配的组件中有一些宿主绑定。

模板中的绑定是最具体的,因为它们直接并且唯一地应用于该元素,所以它们具有最高的优先级。

指令的宿主绑定被认为不太具体,因为指令可以在多个位置使用,所以它们的优先级低于模板绑定。

指令经常会增强组件的行为,所以组件的宿主绑定优先级最低。

Path:"src/app/app.component.html"

<h3>Source specificity</h3><!-- The `class.special` template binding will override any host binding to the `special` class set by `dirWithClassBinding` or `comp-with-host-binding`.--><comp-with-host-binding [class.special]="isSpecial" dirWithClassBinding>Some text.</comp-with-host-binding><!-- The `style.color` template binding will override any host binding to the `color` property set by `dirWithStyleBinding` or `comp-with-host-binding`. --><comp-with-host-binding [style.color]="color" dirWithStyleBinding>Some text.</comp-with-host-binding>

另外,绑定总是优先于静态属性。

在下面的例子中,class[class] 具有相似的具体度,但 [class] 绑定优先,因为它是动态的。

Path:"src/app/app.component.html"

<h3>Dynamic vs static</h3><!-- If `classExpr` has a value for the `special` class, this value will override the `class="special"` below --><div class="special" [class]="classExpr">Some text.</div><!-- If `styleExpr` has a value for the `color` property, this value will override the `style="color: blue"` below --><div style="color: blue" [style]="styleExpr">Some text.</div>

委托优先级较低的样式

更高优先级的样式可以使用 undefined 值“委托”给低级的优先级样式。虽然把 style 属性设置为 null 可以确保该样式被移除,但把它设置为 undefined 会导致 Angular 回退到该样式的次高优先级。

例如,考虑以下模板:

Path:"src/app/app.component.html"

<comp-with-host-binding dirWithHostBinding></comp-with-host-binding>

想象一下,dirWithHostBinding 指令和 comp-with-host-binding 组件都有 [style.width] 宿主绑定。在这种情况下,如果 dirWithHostBinding 把它的绑定设置为 undefined,则 width 属性将回退到 comp-with-host-binding 主机绑定的值。但是,如果 dirWithHostBinding 把它的绑定设置为 null,那么 width 属性就会被完全删除。

事件绑定 (event)

事件绑定允许你监听某些事件,比如按键、鼠标移动、点击和触屏。

Angular 的事件绑定语法由等号左侧带圆括号的目标事件和右侧引号中的模板语句组成。 下面事件绑定监听按钮的点击事件。每当点击发生时,都会调用组件的 onSave() 方法。

目标事件

如前所述,其目标就是此按钮的单击事件。

Path:"src/app/app.component.html"

<button (click)="onSave($event)">Save</button>

有些人更喜欢带 on- 前缀的备选形式,称之为规范形式:

Path:"src/app/app.component.html"

<button on-click="onSave($event)">on-click Save</button>

元素事件可能是更常见的目标,但 Angular 会先看这个名字是否能匹配上已知指令的事件属性,就像下面这个例子:

Path:"src/app/app.component.html"

<h4>myClick is an event on the custom ClickDirective:</h4><button (myClick)="clickMessage=$event" clickable>click with myClick</button>{{clickMessage}}

如果这个名字没能匹配到元素事件或已知指令的输出属性,Angular 就会报“未知指令”错误。

$event 和事件处理语句

在事件绑定中,Angular 会为目标事件设置事件处理器。

当事件发生时,这个处理器会执行模板语句。 典型的模板语句通常涉及到响应事件执行动作的接收器,例如从 HTML 控件中取得值,并存入模型。

绑定会通过名叫 $event 的事件对象传递关于此事件的信息(包括数据值)。

事件对象的形态取决于目标事件。如果目标事件是原生 DOM 元素事件, $event 就是 DOM 事件对象,它有像 targettarget.value 这样的属性。

考虑这个示例:

Path:"src/app/app.component.html"

<input [value]="currentItem.name"       (input)="currentItem.name=$event.target.value" >without NgModel

上面的代码在把输入框的 value 属性绑定到 name 属性。 要监听对值的修改,代码绑定到输入框的 input 事件。 当用户造成更改时,input 事件被触发,并在包含了 DOM 事件对象 ($event) 的上下文中执行这条语句。

要更新 name 属性,就要通过路径 $event.target.value 来获取更改后的值。

如果事件属于指令(回想一下,组件是指令的一种),那么 $event 具体是什么由指令决定。

使用 EventEmitter 实现自定义事件

通常,指令使用 Angular EventEmitter 来触发自定义事件。 指令创建一个 EventEmitter 实例,并且把它作为属性暴露出来。 指令调用 EventEmitter.emit(payload) 来触发事件,可以传入任何东西作为消息载荷。 父指令通过绑定到这个属性来监听事件,并通过 $event 对象来访问载荷。

假设 ItemDetailComponent 用于显示英雄的信息,并响应用户的动作。 虽然 ItemDetailComponent 包含删除按钮,但它自己并不知道该如何删除这个英雄。 最好的做法是触发事件来报告“删除用户”的请求。

下面的代码节选自 ItemDetailComponent:

Path:"src/app/item-detail/item-detail.component.html (template)"

<img src="{{itemImageUrl}}" [style.display]="displayNone"><span [style.text-decoration]="lineThrough">{{ item.name }}</span><button (click)="delete()">Delete</button>

Path:"src/app/item-detail/item-detail.component.ts (deleteRequest)"

// This component makes a request but it can't actually delete a hero.@Output() deleteRequest = new EventEmitter<Item>();delete() {  this.deleteRequest.emit(this.item);  this.displayNone = this.displayNone ? '' : 'none';  this.lineThrough = this.lineThrough ? '' : 'line-through';}

组件定义了 deleteRequest 属性,它是 EventEmitter 实例。 当用户点击删除时,组件会调用 delete() 方法,让 EventEmitter 发出一个 Item 对象。

现在,假设有个宿主的父组件,它绑定了 ItemDetailComponentdeleteRequest 事件。

Path:"src/app/app.component.html (event-binding-to-component)"

<app-item-detail (deleteRequest)="deleteItem($event)" [item]="currentItem"></app-item-detail>

deleteRequest 事件触发时,Angular 调用父组件的 deleteItem 方法, 在 $event 变量中传入要删除的英雄(来自 ItemDetail)。

模板语句有副作用

虽然模板表达式不应该有副作用,但是模板语句通常会有。这里的 deleteItem() 方法就有一个副作用:它删除了一个条目。

删除这个英雄会更新模型,还可能触发其它修改,包括向远端服务器的查询和保存。 这些变更通过系统进行扩散,并最终显示到当前以及其它视图中。

双向绑定 [(...)]

双向绑定为你的应用程序提供了一种在组件类及其模板之间共享数据的方式。

双向绑定的基础知识

双向绑定会做两件事:

  1. 设置特定的元素属性。

  1. 监听元素的变更事件。

Angular 为此提供了一种特殊的双向数据绑定语法 [()][()] 语法将属性绑定的括号 [] 与事件绑定的括号 () 组合在一起。

[()] 语法很容易想明白:该元素具有名为 x 的可设置属性和名为 xChange 的相应事件。 SizerComponent 就是用的这种模式。它具有一个名为 size 的值属性和一个与之相伴的 sizeChange 事件:

Path:"src/app/sizer.component.ts"

import { Component, Input, Output, EventEmitter } from '@angular/core';@Component({  selector: 'app-sizer',  templateUrl: './sizer.component.html',  styleUrls: ['./sizer.component.css']})export class SizerComponent {  @Input()  size: number | string;  @Output() sizeChange = new EventEmitter<number>();  dec() { this.resize(-1); }  inc() { this.resize(+1); }  resize(delta: number) {    this.size = Math.min(40, Math.max(8, +this.size + delta));    this.sizeChange.emit(this.size);  }}

Path:"src/app/sizer.component.html"

<div>  <button (click)="dec()" title="smaller">-</button>  <button (click)="inc()" title="bigger">+</button>  <label [style.font-size.px]="size">FontSize: {{size}}px</label></div>

size 的初始值来自属性绑定的输入值。单击按钮可在最小值/最大值范围内增大或减小 size,然后带上调整后的大小发出 sizeChange 事件。

下面的例子中,AppComponent.fontSize 被双向绑定到 SizerComponent

Path:"src/app/app.component.html (two-way-1)"

<app-sizer [(size)]="fontSizePx"></app-sizer><div [style.font-size.px]="fontSizePx">Resizable Text</div>

AppComponent.fontSizePx 建立初始 SizerComponent.size 值。

Path:"src/app/app.component.ts"

fontSizePx = 16;

单击按钮就会通过双向绑定更新 AppComponent.fontSizePx。修改后的 AppComponent.fontSizePx 值将传递到样式绑定,从而使显示的文本更大或更小。

双向绑定语法实际上是属性绑定和事件绑定的语法糖。 Angular 将 izerComponent 的绑定分解成这样:

Path:"src/app/app.component.html (two-way-2)"

<app-sizer [size]="fontSizePx" (sizeChange)="fontSizePx=$event"></app-sizer>

$event 变量包含了 SizerComponent.sizeChange 事件的荷载。 当用户点击按钮时,Angular 将 $event 赋值给 AppComponent.fontSizePx

表单中的双向绑定

与单独的属性绑定和事件绑定相比,双向绑定语法非常方便。将双向绑定与 HTML 表单元素(例如 <input><select>)一起使用会很方便。但是,没有哪个原生 HTML 元素会遵循 x 值和 xChange 事件的命名模式。

内置指令

Angular 提供了两种内置指令:属性型指令和结构型指令。

内置属性型指令

属性型指令会监听并修改其它 HTML 元素和组件的行为、AttributeProperty。 它们通常被应用在元素上,就好像它们是 HTML 属性一样,因此得名属性型指令。

许多 NgModule(例如 RouterModuleFormsModule )都定义了自己的属性型指令。最常见的属性型指令如下:

  1. NgClass —— 添加和删除一组 CSS 类。

用 ngClass 同时添加或删除几个 CSS 类。

Path:"src/app/app.component.html"

    <!-- toggle the "special" class on/off with a property -->    <div [ngClass]="isSpecial ? 'special' : ''">This div is special</div>

注:
- 要添加或删除单个类,请使用类绑定而不是 NgClass

考虑一个 setCurrentClasses() 组件方法,该方法设置一个组件属性 currentClasses,该对象具有一个根据其它三个组件属性的 true / false 状态来添加或删除三个 CSS 类的对象。该对象的每个键(key)都是一个 CSS 类名。如果要添加上该类,则其值为 true,反之则为 false。

Path:"src/app/app.component.html"

    currentClasses: {};    setCurrentClasses() {      // CSS classes: added/removed per current state of component properties      this.currentClasses =  {        'saveable': this.canSave,        'modified': !this.isUnchanged,        'special':  this.isSpecial      };    }

NgClass 属性绑定到 currentClasses,根据它来设置此元素的 CSS 类:

Path:"src/app/app.component.html"

    <div [ngClass]="currentClasses">This div is initially saveable, unchanged, and special.</div>

注:
- 请记住,在这种情况下,你要在初始化时和它依赖的属性发生变化时调用 setCurrentClasses()

  1. NgStyle —— 添加和删除一组 HTML 样式。

使用 NgStyle 根据组件的状态同时动态设置多个内联样式。

不用 NgStyle

有些情况下,要考虑使用样式绑定来设置单个样式值,而不使用 NgStyle

Path:"src/app/app.component.html"

    <div [style.font-size]="isSpecial ? 'x-large' : 'smaller'">      This div is x-large or smaller.    </div>

但是,如果要同时设置多个内联样式,请使用 NgStyle 指令。

下面的例子是一个 setCurrentStyles() 方法,它基于该组件另外三个属性的状态,用一个定义了三个样式的对象设置了 currentStyles 属性。

Path:"src/app/app.component.ts"

    currentStyles: {};    setCurrentStyles() {      // CSS styles: set per current state of component properties      this.currentStyles = {        'font-style':  this.canSave      ? 'italic' : 'normal',        'font-weight': !this.isUnchanged ? 'bold'   : 'normal',        'font-size':   this.isSpecial    ? '24px'   : '12px'      };    }

ngStyle 属性绑定到 currentStyles,来根据它设置此元素的样式:

Path:"src/app/app.component.html"

    <div [ngStyle]="currentStyles">      This div is initially italic, normal weight, and extra large (24px).    </div>

注:
- 请记住,无论是在初始时还是其依赖的属性发生变化时,都要调用 setCurrentStyles()

  1. NgModel —— 将数据双向绑定添加到 HTML 表单元素。

NgModel 指令允许你显示数据属性并在用户进行更改时更新该属性。这是一个例子:

Path:"src/app/app.component.html (NgModel example)"

    <label for="example-ngModel">[(ngModel)]:</label>    <input [(ngModel)]="currentItem.name" id="example-ngModel">

导入 FormsModule 以使用 ngModel

要想在双向数据绑定中使用 ngModel 指令,必须先导入 FormsModule 并将其添加到 NgModuleimports 列表中。要了解关于 FormsModulengModel 的更多信息,参见表单一章。

记住,要导入 FormsModule 才能让 [(ngModel)] 可用,如下所示:

Path:"src/app/app.module.ts (FormsModule import)"

    import { FormsModule } from '@angular/forms'; // <--- JavaScript import from Angular    /* . . . */    @NgModule({    /* . . . */      imports: [        BrowserModule,        FormsModule // <--- import into the NgModule      ],    /* . . . */    })    export class AppModule { }

通过分别绑定到 <input> 元素的 value 属性和 input 事件,可以达到同样的效果:

Path:"src/app/app.component.html"

    <label for="without">without NgModel:</label>    <input [value]="currentItem.name" (input)="currentItem.name=$event.target.value" id="without">

为了简化语法,ngModel 指令把技术细节隐藏在其输入属性 ngModel 和输出属性 ngModelChange 的后面:

Path:"src/app/app.component.html"

    <label for="example-change">(ngModelChange)="...name=$event":</label>    <input [ngModel]="currentItem.name" (ngModelChange)="currentItem.name=$event" id="example-change">

ngModel 输入属性会设置该元素的值,并通过 ngModelChange 的输出属性来监听元素值的变化。

NgModel 和值访问器

这些技术细节是针对每种具体元素的,因此 NgModel 指令仅适用于通过 ControlValueAccessor 适配过这种协议的元素。Angular 已经为所有基本的 HTML 表单元素提供了值访问器,表单一章示范了如何绑定到它们。

在编写适当的值访问器之前,不能将 [(ngModel)] 应用于非表单的原生元素或第三方自定义组件。欲知详情,参见DefaultValueAccessor上的 API 文档。

你不一定非用为所编写的 Angular 组件提供值访问器,因为你还可以把值属性和事件属性命名为符合 Angular 的基本双向绑定语法的形式,并完全跳过 NgModel。双向绑定部分的 sizer 是此技术的一个示例。

单独的 ngModel 绑定是对绑定到元素的原生属性方式的一种改进,但你可以使用 [(ngModel)] 语法来通过单个声明简化绑定:

Path:"src/app/app.component.html"

    <label for="example-ngModel">[(ngModel)]:</label>    <input [(ngModel)]="currentItem.name" id="example-ngModel">

[(ngModel)] 语法只能设置数据绑定属性。如果你要做得更多,可以编写扩展表单。例如,下面的代码将 <input> 值更改为大写:

Path:"src/app/app.component.html"

    <input [ngModel]="currentItem.name" (ngModelChange)="setUppercaseName($event)" id="example-uppercase">

这里是所有这些变体的动画,包括这个大写转换的版本:

内置结构型指令

结构型指令的职责是 HTML 布局。 它们塑造或重塑 DOM 的结构,这通常是通过添加、移除和操纵它们所附加到的宿主元素来实现的。

常见的内置结构型指令:

  1. NgIf —— 从模板中创建或销毁子视图。

你可以通过将 NgIf 指令应用在宿主元素上来从 DOM 中添加或删除元素。在此示例中,将指令绑定到了条件表达式,例如 isActive

Path:"src/app/app.component.html"

    <app-item-detail *ngIf="isActive" [item]="item"></app-item-detail>

注:
- 不要忘了 ngIf 前面的星号(*)。

isActive 表达式返回真值时,NgIf 会把 ItemDetailComponent 添加到 DOM 中。当表达式为假值时,NgIf

用户输入

当用户点击链接、按下按钮或者输入文字时,这些用户动作都会产生 DOM 事件。 本章解释如何使用 Angular 事件绑定语法把这些事件绑定到事件处理器。

绑定到用户输入事件

你可以使用 Angular 事件绑定机制来响应任何 DOM 事件。 许多 DOM 事件是由用户输入触发的。绑定这些事件可以获取用户输入。

要绑定 DOM 事件,只要把 DOM 事件的名字包裹在圆括号中,然后用放在引号中的模板语句对它赋值就可以了。

下例展示了一个事件绑定,它实现了一个点击事件处理器:

Path:"src/app/click-me.component.ts"

<button (click)="onClickMe()">Click me!</button>

等号左边的 (click) 表示把按钮的点击事件作为绑定目标。 等号右边引号中的文本是模板语句,通过调用组件的 onClickMe 方法来响应这个点击事件。

写绑定时,需要知道模板语句的执行上下文。 出现在模板语句中的每个标识符都属于特定的上下文对象。 这个对象通常都是控制此模板的 Angular 组件。 上例中只显示了一行 HTML,那段 HTML 片段属于下面这个组件:

Path:"src/app/click-me.component.ts"

@Component({  selector: 'app-click-me',  template: `    <button (click)="onClickMe()">Click me!</button>    {{clickMessage}}`})export class ClickMeComponent {  clickMessage = '';  onClickMe() {    this.clickMessage = 'You are my hero!';  }}

当用户点击按钮时,Angular 调用 ClickMeComponentonClickMe 方法。

通过 $event 对象取得用户输入

DOM 事件可以携带可能对组件有用的信息。 本节将展示如何绑定输入框的 keyup 事件,在每个敲击键盘时获取用户输入。

下面的代码监听 keyup 事件,并将整个事件载荷 ($event) 传给组件的事件处理器。

Path:"src/app/keyup.components.ts (template v.1)"

template: `  <input (keyup)="onKey($event)">  <p>{{values}}</p>`

当用户按下并释放一个按键时,触发 keyup 事件,Angular 在 $event 变量提供一个相应的 DOM 事件对象,上面的代码将它作为参数传给 onKey() 方法。

Path:"src/app/keyup.components.ts (class v.1)"

export class KeyUpComponent_v1 {  values = '';  onKey(event: any) { // without type info    this.values += event.target.value + ' | ';  }}

$event 对象的属性取决于 DOM 事件的类型。例如,鼠标事件与输入框编辑事件包含了不同的信息。

所有标准 DOM 事件对象都有一个 target 属性, 引用触发该事件的元素。 在本例中,target 是 <input> 元素, event.target.value 返回该元素的当前内容。

在组件的 onKey() 方法中,把输入框的值和分隔符 (|) 追加组件的 values 属性。 使用插值来把存放累加结果的 values 属性回显到屏幕上。

假设用户输入字母“abc”,然后用退格键一个一个删除它们。 用户界面将显示:

a | ab | abc | ab | a | |

或者,你可以用 event.key 替代 event.target.value,积累各个按键本身,这样同样的用户输入可以产生:

&
a | b | c | backspace | backspace | backspace |

$event的类型

上例将 $event 转换为 any 类型。 这样简化了代码,但是有成本。 没有任何类型信息能够揭示事件对象的属性,防止简单的错误。

下面的例子,使用了带类型方法:

Path:"src/app/keyup.components.ts (class v.1 - typed )"

export class KeyUpComponent_v1 {  values = '';  onKey(event: KeyboardEvent) { // with type info    this.values += (event.target as HTMLInputElement).value + ' | ';  }}

$event 的类型现在是 KeyboardEvent。 不是所有的元素都有 value 属性,所以它将 target 转换为输入元素。 OnKey 方法更加清晰地表达了它期望从模板得到什么,以及它是如何解析事件的。

传入 $event 是靠不住的做法

类型化事件对象揭露了重要的一点,即反对把整个 DOM 事件传到方法中,因为这样组件会知道太多模板的信息。 只有当它知道更多它本不应了解的 HTML 实现细节时,它才能提取信息。 这就违反了模板(用户看到的)和组件(应用如何处理用户数据)之间的分离关注原则。

下面将介绍如何用模板引用变量来解决这个问题。

从一个模板引用变量中获得用户输入

还有另一种获取用户数据的方式:使用 Angular 的模板引用变量。 这些变量提供了从模块中直接访问元素的能力。 在标识符前加上井号 (#) 就能声明一个模板引用变量。

下面的例子使用了局部模板变量,在一个超简单的模板中实现按键反馈功能。

Path:"src/app/loop-back.component.ts"

@Component({  selector: 'app-loop-back',  template: `    <input #box (keyup)="0">    <p>{{box.value}}</p>  `})export class LoopbackComponent { }

这个模板引用变量名叫 box,在 <input> 元素声明,它引用 <input> 元素本身。 代码使用 box 获得输入元素的 value 值,并通过插值把它显示在 <p> 标签中。

这个模板完全是完全自包含的。它没有绑定到组件,组件也没做任何事情。

在输入框中输入,就会看到每次按键时,显示也随之更新了。

除非你绑定一个事件,否则这将完全无法工作。

只有在应用做了些异步事件(如击键),Angular 才更新绑定(并最终影响到屏幕)。 本例代码将 keyup 事件绑定到了数字 0,这可能是最短的模板语句了。 虽然这个语句不做什么,但它满足 Angular 的要求,所以 Angular 将更新屏幕。

从模板变量获得输入框比通过 $event 对象更加简单。 下面的代码重写了之前 keyup 示例,它使用变量来获得用户输入。

Path:"src/app/keyup.components.ts (v2)"

@Component({  selector: 'app-key-up2',  template: `    <input #box (keyup)="onKey(box.value)">    <p>{{values}}</p>  `})export class KeyUpComponent_v2 {  values = '';  onKey(value: string) {    this.values += value + ' | ';  }}

这个方法最漂亮的一点是:组件代码从视图中获得了干净的数据值。再也不用了解 $event 变量及其结构了。

按键事件过滤(通过 key.enter)

(keyup) 事件处理器监听每一次按键。 有时只在意回车键,因为它标志着用户结束输入。 解决这个问题的一种方法是检查每个 $event.keyCode,只有键值是回车键时才采取行动。

更简单的方法是:绑定到 Angular 的 keyup.enter 模拟事件。 然后,只有当用户敲回车键时,Angular 才会调用事件处理器。

Path:"src/app/keyup.components.ts (v3)"

@Component({  selector: 'app-key-up3',  template: `    <input #box (keyup.enter)="onEnter(box.value)">    <p>{{value}}</p>  `})export class KeyUpComponent_v3 {  value = '';  onEnter(value: string) { this.value = value; }}

下面展示了它的工作原理。

失去焦点事件 (blur)

前上例中,如果用户没有先按回车键,而是移开了鼠标,点击了页面中其它地方,输入框的当前值就会丢失。 只有当用户按下了回车键候,组件的 values属性才能更新。

下面通过同时监听输入框的回车键和失去焦点事件来修正这个问题。

Path:"src/app/keyup.components.ts (v4)"

@Component({  selector: 'app-key-up4',  template: `    <input #box      (keyup.enter)="update(box.value)"      (blur)="update(box.value)">    <p>{{value}}</p>  `})export class KeyUpComponent_v4 {  value = '';  update(value: string) { this.value = value; }}

结合使用

现在,在一个微型应用中一起使用它们,应用能显示一个英雄列表,并把新的英雄加到列表中。 用户可以通过输入英雄名和点击“添加”按钮来添加英雄。

下面就是“简版英雄指南”组件。

Path:"src/app/little-tour.component.ts"

@Component({  selector: 'app-little-tour',  template: `    <input #newHero      (keyup.enter)="addHero(newHero.value)"      (blur)="addHero(newHero.value); newHero.value='' ">    <button (click)="addHero(newHero.value)">Add</button>    <ul><li *ngFor="let hero of heroes">{{hero}}</li></ul>  `})export class LittleTourComponent {  heroes = ['Windstorm', 'Bombasto', 'Magneta', 'Tornado'];  addHero(newHero: string) {    if (newHero) {      this.heroes.push(newHero);    }  }}

源代码

  1. Path:"src/app/click-me.component.ts"

    import { Component } from '@angular/core';    @Component({      selector: 'app-click-me',      template: `        <button (click)="onClickMe()">Click me!</button>        {{clickMessage}}`    })    export class ClickMeComponent {      clickMessage = '';      onClickMe() {        this.clickMessage = 'You are my hero!';      }    }

  1. Path:"src/app/keyup.components.ts"

    import { Component } from '@angular/core';    @Component({      selector: 'app-key-up1',      template: `        <input (keyup)="onKey($event)">        <p>{{values}}</p>      `    })    export class KeyUpComponent_v1 {      values = '';      /*      onKey(event: any) { // without type info        this.values += event.target.value + ' | ';      }      */      onKey(event: KeyboardEvent) { // with type info        this.values += (event.target as HTMLInputElement).value + ' | ';      }    }    //////////////////////////////////////////    @Component({      selector: 'app-key-up2',      template: `        <input #box (keyup)="onKey(box.value)">        <p>{{values}}</p>      `    })    export class KeyUpComponent_v2 {      values = '';      onKey(value: string) {        this.values += value + ' | ';      }    }    //////////////////////////////////////////    @Component({      selector: 'app-key-up3',      template: `        <input #box (keyup.enter)="onEnter(box.value)">        <p>{{value}}</p>      `    })    export class KeyUpComponent_v3 {      value = '';      onEnter(value: string) { this.value = value; }    }    //////////////////////////////////////////    @Component({      selector: 'app-key-up4',      template: `        <input #box          (keyup.enter)="update(box.value)"          (blur)="update(box.value)">        <p>{{value}}</p>      `    })    export class KeyUpComponent_v4 {      value = '';      update(value: string) { this.value = value; }    }

  1. Path:"src/app/loop-back.component.ts"

    import { Component } from '@angular/core';    @Component({      selector: 'app-loop-back',      template: `        <input #box (keyup)="0">        <p>{{box.value}}</p>      `    })    export class LoopbackComponent { }

  1. Path:"src/app/little-tour.component.ts"

    import { Component } from '@angular/core';    @Component({      selector: 'app-little-tour',      template: `        <input #newHero          (keyup.enter)="addHero(newHero.value)"          (blur)="addHero(newHero.value); newHero.value='' ">        <button (click)="addHero(newHero.value)">Add</button>        <ul><li *ngFor="let hero of heroes">{{hero}}</li></ul>      `    })    export class LittleTourComponent {      heroes = ['Windstorm', 'Bombasto', 'Magneta', 'Tornado'];      addHero(newHero: string) {        if (newHero) {          this.heroes.push(newHero);        }      }    }

Angular 还支持被动事件侦听器。例如,你可以使用以下步骤使滚动事件变为被动监听。

  1. 在 src 目录下创建一个 "zone-flags.ts" 文件。

  1. 往这个文件中添加如下语句。

(window as any)['__zone_symbol__PASSIVE_EVENTS'] = ['scroll'];

  1. 在 "src/polyfills.ts" 文件中,导入 "zone.js" 之前,先导入新创建的 "zone-flags" 文件。

import './zone-flags';import 'zone.js/dist/zone';  // Included with Angular CLI.

经过这些步骤,你添加 scroll 事件的监听器时,它就是被动(passive)的。

小结

  • 使用模板变量来引用元素 — newHero 模板变量引用了 <input> 元素。 你可以在 <input> 的任何兄弟或子级元素中引用 newHero

  • 传递数值,而非元素 — 获取输入框的值并将它传给组件的 addHero,而不要传递 newHero

  • 保持模板语句简单 — (blur) 事件被绑定到两个 JavaScript 语句。 第一句调用 addHero。第二句 newHero.value='' 在添加新英雄到列表中后清除输入框。

属性型指令用于改变一个 DOM 元素的外观或行为。

指令概览

在 Angular 中有三种类型的指令:

  1. 组件 — 拥有模板的指令

  1. 结构型指令 — 通过添加和移除 DOM 元素改变 DOM 布局的指令

  1. 属性型指令 — 改变元素、组件或其它指令的外观和行为的指令。

组件是这三种指令中最常用的。 你在快速上手例子中第一次见到组件。

结构型指令修改视图的结构。例如,NgForNgIf。 要了解更多,参见结构型指令 指南。

属性型指令改变一个元素的外观或行为。例如,内置的 NgStyle 指令可以同时修改元素的多个样式。

创建一个简单的属性型指令

属性型指令至少需要一个带有 @Directive 装饰器的控制器类。该装饰器指定了一个用于标识属性的选择器。 控制器类实现了指令需要的指令行为。

本章展示了如何创建一个简单的属性型指令 appHighlight,当用户把鼠标悬停在一个元素上时,改变它的背景色。你可以这样用它:

Path:"src/app/app.component.html (applied)"

<p appHighlight>Highlight me!</p>

注:
- 指令不支持命名空间。

编写指令代码

在命令行窗口下用 CLI 命令 ng generate directive 创建指令类文件。

ng generate directive highlight

CLI 会创建 "src/app/highlight.directive.ts" 及相应的测试文件("src/app/highlight.directive.spec.ts"),并且在根模块 AppModule 中声明这个指令类。

注:
- 和组件一样,这些指令也必须在Angular 模块中进行声明。

生成的 "src/app/highlight.directive.ts" 文件如下:

Path:"src/app/highlight.directive.ts"

import { Directive } from '@angular/core';@Directive({  selector: '[appHighlight]'})export class HighlightDirective {  constructor() { }}

这里导入的 Directive 符号提供了 Angular 的 @Directive 装饰器。

@Directive 装饰器的配置属性中指定了该指令的 CSS 属性型选择器 [appHighlight]

这里的方括号([])表示它的属性型选择器。 Angular 会在模板中定位每个拥有名叫 appHighlight 属性的元素,并且为这些元素加上本指令的逻辑。

正因如此,这类指令被称为 属性选择器。

紧跟在 @Directive 元数据之后的就是该指令的控制器类,名叫 HighlightDirective,它包含了该指令的逻辑(目前为空逻辑)。然后导出 HighlightDirective,以便它能在别处访问到。

现在,把刚才生成的 "src/app/highlight.directive.ts" 编辑成这样:

Path:"src/app/highlight.directive.ts"

import { Directive, ElementRef } from '@angular/core';@Directive({  selector: '[appHighlight]'})export class HighlightDirective {    constructor(el: ElementRef) {       el.nativeElement.style.backgroundColor = 'yellow';    }}

import 语句还从 Angular 的 core 库中导入了一个 ElementRef 符号。

你可以在指令的构造函数中使用 ElementRef 来注入宿主 DOM 元素的引用,也就是你放置 appHighlight 的那个元素。

ElementRef 通过其 nativeElement 属性给你了直接访问宿主 DOM 元素的能力。

这里的第一个实现把宿主元素的背景色设置为了黄色。

使用属性型指令

要想使用这个新的 HighlightDirective,就往根组件 AppComponent 的模板中添加一个 <p> 元素,并把该指令作为一个属性使用。

Path:"src/app/app.component.html"

<p appHighlight>Highlight me!</p>

运行这个应用以查看 HighlightDirective 的实际效果。

ng serve

总结:Angular 在宿主元素 <p> 上发现了一个 appHighlight 属性。 然后它创建了一个 HighlightDirective 类的实例,并把所在元素的引用注入到了指令的构造函数中。 在构造函数中,该指令把 <p> 元素的背景设置为了黄色。

响应用户引发的事件

当前,appHighlight 只是简单的设置元素的颜色。 这个指令应该在用户鼠标悬浮一个元素时,设置它的颜色。

先把 HostListener 加进导入列表中。

Path:"src/app/highlight.directive.ts (imports)"

import { Directive, ElementRef, HostListener } from '@angular/core';

然后使用 HostListener 装饰器添加两个事件处理器,它们会在鼠标进入或离开时进行响应。

Path:"src/app/highlight.directive.ts (mouse-methods)"

@HostListener('mouseenter') onMouseEnter() {  this.highlight('yellow');}@HostListener('mouseleave') onMouseLeave() {  this.highlight(null);}private highlight(color: string) {  this.el.nativeElement.style.backgroundColor = color;}

@HostListener 装饰器让你订阅某个属性型指令所在的宿主 DOM 元素的事件,在这个例子中就是 <p>

当然,你可以通过标准的 JavaScript 方式手动给宿主 DOM 元素附加一个事件监听器。 但这种方法至少有三个问题:

  • 必须正确的书写事件监听器。

  • 当指令被销毁的时候,必须拆卸事件监听器,否则会导致内存泄露。

  • 必须直接和 DOM API 打交道,应该避免这样做。

这些处理器委托了一个辅助方法来为 DOM 元素(el)设置颜色。

这个辅助方法(highlight)被从构造函数中提取了出来。 修改后的构造函数只负责声明要注入的元素 el: ElementRef

Path:"src/app/highlight.directive.ts (constructor)"

constructor(private el: ElementRef) { }

下面是修改后的指令代码:

Path:"src/app/highlight.directive.ts"

import { Directive, ElementRef, HostListener } from '@angular/core';@Directive({  selector: '[appHighlight]'})export class HighlightDirective {  constructor(private el: ElementRef) { }  @HostListener('mouseenter') onMouseEnter() {    this.highlight('yellow');  }  @HostListener('mouseleave') onMouseLeave() {    this.highlight(null);  }  private highlight(color: string) {    this.el.nativeElement.style.backgroundColor = color;  }}

运行本应用并确认:当把鼠标移到 p 上的时候,背景色就出现了,而移开时就消失了。

使用 @Input 数据绑定向指令传递值

高亮的颜色目前是硬编码在指令中的,这不够灵活。 在这一节中,你应该让指令的使用者可以指定要用哪种颜色进行高亮。

先从 @angular/core 中导入 Input

Path:"src/app/highlight.directive.ts (imports)"

import { Directive, ElementRef, HostListener, Input } from '@angular/core';

然后把 highlightColor 属性添加到指令类中,就像这样:

Path:"src/app/highlight.directive.ts (highlightColor)"

@Input() highlightColor: string;

绑定到 @Input 属性

注意看 @Input 装饰器。它往类上添加了一些元数据,从而让该指令的 highlightColor 能用于绑定。

它之所以称为输入属性,是因为数据流是从绑定表达式流向指令内部的。 如果没有这个元数据,Angular 就会拒绝绑定,参见稍后了解更多。

试试把下列指令绑定变量添加到 AppComponent 的模板中:

Path:"src/app/app.component.html (excerpt)"

<p appHighlight highlightColor="yellow">Highlighted in yellow</p><p appHighlight [highlightColor]="'orange'">Highlighted in orange</p>

color 属性添加到 AppComponent 中:

Path:"src/app/app.component.ts (class)"

export class AppComponent {  color = 'yellow';}

让它通过属性绑定来控制高亮颜色。

Path:"src/app/app.component.html (excerpt)"

<p appHighlight [highlightColor]="color">Highlighted with parent component's color</p>

很不错,但如果可以在应用该指令时在同一个属性中设置颜色就更好了,就像这样:

Path:"src/app/app.component.html (color)"

<p [appHighlight]="color">Highlight me!</p>

[appHighlight] 属性同时做了两件事:把这个高亮指令应用到了 <p> 元素上,并且通过属性绑定设置了该指令的高亮颜色。 你复用了该指令的属性选择器 [appHighlight] 来同时完成它们。 这是清爽、简约的语法。

你还要把该指令的 highlightColor 改名为 appHighlight,因为它是颜色属性目前的绑定名。

Path:"src/app/highlight.directive.ts (renamed to match directive selector)"

@Input() appHighlight: string;

这可不好。因为 appHighlight 是一个糟糕的属性名,而且不能反映该属性的意图。

绑定到 @Input 别名

幸运的是,你可以随意命名该指令的属性,并且给它指定一个用于绑定的别名。

恢复原始属性名,并在 @Input 的参数中把该选择器指定为别名。

Path:"src/app/highlight.directive.ts (color property with alias)"

@Input('appHighlight') highlightColor: string;

在指令内部,该属性叫 highlightColor,在外部,你绑定到它地方,它叫 appHighlight

这是最好的结果:理想的内部属性名,理想的绑定语法:

Path:"src/app/app.component.html (color)"

<p [appHighlight]="color">Highlight me!</p>

现在,你通过别名绑定到了 highlightColor 属性,并修改 onMouseEnter() 方法来使用它。 如果有人忘了绑定到 appHighlight,那就用红色进行高亮。

Path:"src/app/highlight.directive.ts (mouse enter)"

@HostListener('mouseenter') onMouseEnter() {  this.highlight(this.highlightColor || 'red');}

这是最终版本的指令类。

Path:"src/app/highlight.directive.ts (excerpt)"

import { Directive, ElementRef, HostListener, Input } from '@angular/core';@Directive({  selector: '[appHighlight]'})export class HighlightDirective {  constructor(private el: ElementRef) { }  @Input('appHighlight') highlightColor: string;  @HostListener('mouseenter') onMouseEnter() {    this.highlight(this.highlightColor || 'red');  }  @HostListener('mouseleave') onMouseLeave() {    this.highlight(null);  }  private highlight(color: string) {    this.el.nativeElement.style.backgroundColor = color;  }}

测试程序

凭空想象该指令如何工作可不容易。你需要把 AppComponent 改成一个测试程序,它让你可以通过单选按钮来选取高亮颜色,并且把你选取的颜色绑定到指令中。

把 "app.component.html" 修改成这样:

Path:"src/app/app.component.html (v2)"

<h1>My First Attribute Directive</h1><h4>Pick a highlight color</h4><div>  <input type="radio" name="colors" (click)="color='lightgreen'">Green  <input type="radio" name="colors" (click)="color='yellow'">Yellow  <input type="radio" name="colors" (click)="color='cyan'">Cyan</div><p [appHighlight]="color">Highlight me!</p>

修改 AppComponent.color,让它不再有初始值。

Path:"src/app/app.component.ts (class)"

export class AppComponent {  color: string;}

下面是测试程序和指令的动图。

绑定到第二个属性

本例的指令只有一个可定制属性,真实的应用通常需要更多。

目前,默认颜色(它在用户选取了高亮颜色之前一直有效)被硬编码为红色。应该允许模板的开发者设置默认颜色。

把第二个名叫 defaultColor 的输入属性添加到 HighlightDirective 中:

Path:"src/app/highlight.directive.ts (defaultColor)"

@Input() defaultColor: string;

修改该指令的 onMouseEnter,让它首先尝试使用 highlightColor 进行高亮,然后用 defaultColor,如果它们都没有指定,那就用红色作为后备。

Path:"src/app/highlight.directive.ts (mouse-enter)"

@HostListener('mouseenter') onMouseEnter() {  this.highlight(this.highlightColor || this.defaultColor || 'red');}

当已经绑定过 appHighlight 属性时,要如何绑定到第二个属性呢?

像组件一样,你也可以绑定到指令的很多属性,只要把它们依次写在模板中就行了。 开发者可以绑定到 AppComponent.color,并且用紫罗兰色作为默认颜色,代码如下:

Path:"src/app/highlight.directive.ts (defaultColor)"

<p [appHighlight]="color" defaultColor="violet">  Highlight me too!</p>

Angular 之所以知道 defaultColor 绑定属于 HighlightDirective,是因为你已经通过 @Input 装饰器把它设置成了公共属性。

当这些代码完成时,测试程序工作时的动图如下:

源代码

  1. Path:"src/app/app.component.ts"

    import { Component } from '@angular/core';    @Component({      selector: 'app-root',      templateUrl: './app.component.html'    })    export class AppComponent {      color: string;    }

  1. Path:"src/app/app.component.html"

    <h1>My First Attribute Directive</h1>    <h4>Pick a highlight color</h4>    <div>      <input type="radio" name="colors" (click)="color='lightgreen'">Green      <input type="radio" name="colors" (click)="color='yellow'">Yellow      <input type="radio" name="colors" (click)="color='cyan'">Cyan    </div>    <p [appHighlight]="color">Highlight me!</p>    <p [appHighlight]="color" defaultColor="violet">      Highlight me too!    </p>

  1. Path:"src/app/highlight.directive.ts"

    /* tslint:disable:member-ordering */    import { Directive, ElementRef, HostListener, Input } from '@angular/core';    @Directive({      selector: '[appHighlight]'    })    export class HighlightDirective {      constructor(private el: ElementRef) { }      @Input() defaultColor: string;      @Input('appHighlight') highlightColor: string;      @HostListener('mouseenter') onMouseEnter() {        this.highlight(this.highlightColor || this.defaultColor || 'red');      }      @HostListener('mouseleave') onMouseLeave() {        this.highlight(null);      }      private highlight(color: string) {        this.el.nativeElement.style.backgroundColor = color;      }    }

  1. Path:"src/app/app.module.ts"

    import { NgModule } from '@angular/core';    import { BrowserModule } from '@angular/platform-browser';    import { AppComponent } from './app.component';    import { HighlightDirective } from './highlight.directive';    @NgModule({      imports: [ BrowserModule ],      declarations: [        AppComponent,        HighlightDirective      ],      bootstrap: [ AppComponent ]    })    export class AppModule { }

  1. Path:"src/app/main.ts"

    import { enableProdMode } from '@angular/core';    import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';    import { AppModule } from './app/app.module';    import { environment } from './environments/environment';    if (environment.production) {      enableProdMode();    }    platformBrowserDynamic().bootstrapModule(AppModule);

  1. Path:"src/app/index.html"

    <!DOCTYPE html>    <html lang="en">      <head>        <meta charset="UTF-8">        <title>Attribute Directives</title>        <base href="/">        <meta name="viewport" content="width=device-width, initial-scale=1">      </head>      <body>        <app-root></app-root>      </body>    </html>

小结

  • 构建一个属性型指令,它用于修改一个元素的行为。

  • 把一个指令应用到模板中的某个元素上。

  • 响应事件以改变指令的行为。

  • 把值绑定到指令中。

附录

问题:为什么要加@Input

在这个例子中 hightlightColorHighlightDirective 的一个输入型属性。你见过它没有用别名时的代码:

Path:"src/app/highlight.directive.ts (color)"

@Input() highlightColor: string;

也见过用别名时的代码:

Path:"src/app/highlight.directive.ts (color)"

@Input('appHighlight') highlightColor: string;

无论哪种方式,@Input 装饰器都告诉 Angular,该属性是公共的,并且能被父组件绑定。 如果没有 @Input,Angular 就会拒绝绑定到该属性。

但你以前也曾经把模板 HTML 绑定到组件的属性,而且从来没有用过 @Input。 差异何在?

差异在于信任度不同。 Angular 把组件的模板看做从属于该组件的。 组件和它的模板默认会相互信任。 这也就是意味着,组件自己的模板可以绑定到组件的任意属性,无论是否使用了 @Input 装饰器。

但组件或指令不应该盲目的信任其它组件或指令。 因此组件或指令的属性默认是不能被绑定的。 从 Angular 绑定机制的角度来看,它们是私有的,而当添加了 @Input 时,Angular 绑定机制才会把它们当成公共的。 只有这样,它们才能被其它组件或属性绑定。

你可以根据属性名在绑定中出现的位置来判定是否要加 @Input

当它出现在等号右侧的模板表达式中时,它属于模板所在的组件,不需要 @Input 装饰器。

当它出现在等号左边的方括号([ ])中时,该属性属于其它组件或指令,它必须带有 @Input 装饰器。

试用此原理分析下列示例:

Path:"src/app/app.component.html (color)"

<p [appHighlight]="color">Highlight me!</p>

  • color 属性位于右侧的绑定表达式中,它属于模板所在的组件。 该模板和组件相互信任。因此 color 不需要 @Input 装饰器。

  • appHighlight 属性位于左侧,它引用了 HighlightDirective 中一个带别名的属性,它不是模板所属组件的一部分,因此存在信任问题。 所以,该属性必须带 @Input 装饰器。

什么是结构型指令?

结构型指令的职责是 HTML 布局。 它们塑造或重塑 DOM 的结构,比如添加、移除或维护这些元素。

像其它指令一样,你可以把结构型指令应用到一个宿主元素上。 然后它就可以对宿主元素及其子元素做点什么。

结构型指令非常容易识别。 在这个例子中,星号(*)被放在指令的属性名之前。

Path:"src/app/app.component.html (ngif)"

<div *ngIf="hero" class="name">{{hero.name}}</div>

没有方括号,没有圆括号,只是把 *ngIf 设置为一个字符串。

在这个例子中,你将学到星号(*)这个简写方法,而这个字符串是一个微语法,而不是通常的模板表达式。 Angular 会解开这个语法糖,变成一个 <ng-template> 标记,包裹着宿主元素及其子元素。 每个结构型指令都可以用这个模板做点不同的事情。

三个常用的内置结构型指令 —— NgIfNgForNgSwitch...。 你在模板语法一章中学过它,并且在 Angular 文档的例子中到处都在用它。下面是模板中的例子:

Path:"src/app/app.component.html (built-in)"

<div *ngIf="hero" class="name">{{hero.name}}</div><ul>  <li *ngFor="let hero of heroes">{{hero.name}}</li></ul><div [ngSwitch]="hero?.emotion">  <app-happy-hero    *ngSwitchCase="'happy'"    [hero]="hero"></app-happy-hero>  <app-sad-hero      *ngSwitchCase="'sad'"      [hero]="hero"></app-sad-hero>  <app-confused-hero *ngSwitchCase="'confused'" [hero]="hero"></app-confused-hero>  <app-unknown-hero  *ngSwitchDefault           [hero]="hero"></app-unknown-hero></div>

指令的拼写形式

你将看到指令同时具有两种拼写形式大驼峰 UpperCamelCase 和小驼峰 lowerCamelCase,比如你已经看过的 NgIfngIf。 这里的原因在于,NgIf 引用的是指令的类名,而 ngIf 引用的是指令的属性名*。

指令的类名拼写成大驼峰形式(NgIf),而它的属性名则拼写成小驼峰形式(ngIf)。 本章会在谈论指令的属性和工作原理时引用指令的类名,在描述如何在 HTML 模板中把该指令应用到元素时,引用指令的属性名。

还有另外两种 Angular 指令,在本开发指南的其它地方有讲解:(1) 组件 (2) 属性型指令。

组件可以在原生 HTML 元素中管理一小片区域的 HTML。从技术角度说,它就是一个带模板的指令。

属性型指令会改变某个元素、组件或其它指令的外观或行为。 比如,内置的NgStyle指令可以同时修改元素的多个样式。

你可以在一个宿主元素上应用多个属性型指令,但只能应用一个结构型指令。

NgIf 案例分析

NgIf 是一个很好的结构型指令案例:它接受一个布尔值,并据此让一整块 DOM 树出现或消失。

Path:"src/app/app.component.html (ngif-true)"

<p *ngIf="true">  Expression is true and ngIf is true.  This paragraph is in the DOM.</p><p *ngIf="false">  Expression is false and ngIf is false.  This paragraph is not in the DOM.</p>

ngIf 指令并不是使用 CSS 来隐藏元素的。它会把这些元素从 DOM 中物理删除。 使用浏览器的开发者工具就可以确认这一点。

可以看到第一段文字出现在了 DOM 中,而第二段则没有,在第二段的位置上是一个关于“绑定”的注释。

当条件为假时,NgIf 会从 DOM 中移除它的宿主元素,取消它监听过的那些 DOM 事件,从 Angular 变更检测中移除该组件,并销毁它。 这些组件和 DOM 节点可以被当做垃圾收集起来,并且释放它们占用的内存。

为什么是移除而不是隐藏?

指令也可以通过把它的 display 风格设置为 none 而隐藏不需要的段落。

Path:"src/app/app.component.html (display-none)"

<p [style.display]="'block'">  Expression sets display to "block".  This paragraph is visible.</p><p [style.display]="'none'">  Expression sets display to "none".  This paragraph is hidden but still in the DOM.</p>

当不可见时,这个元素仍然留在 DOM 中。

对于简单的段落,隐藏和移除之间的差异影响不大,但对于资源占用较多的组件是不一样的。 当隐藏掉一个元素时,组件的行为还在继续 —— 它仍然附加在它所属的 DOM 元素上, 它也仍在监听事件。Angular 会继续检查哪些能影响数据绑定的变更。 组件原本要做的那些事情仍在继续。

虽然不可见,组件及其各级子组件仍然占用着资源,而这些资源如果分配给别人可能会更有用。 在性能和内存方面的负担相当可观,响应度会降低,而用户却可能无法从中受益。

当然,从积极的一面看,重新显示这个元素会非常快。 组件以前的状态被保留着,并随时可以显示。 组件不用重新初始化 —— 该操作可能会比较昂贵。 这时候隐藏和显示就成了正确的选择。

但是,除非有非常强烈的理由来保留它们,否则你会更倾向于移除用户看不见的那些 DOM 元素,并且使用 NgIf 这样的结构型指令来收回用不到的资源。

同样的考量也适用于每一个结构型指令,无论是内置的还是自定义的。 你应该提醒自己慎重考虑添加元素、移除元素以及创建和销毁组件的后果。

星号(*)前缀

你可能注意到了指令名的星号(*)前缀,并且困惑于为什么需要它以及它是做什么的。

这里的 *ngIf 会在 hero 存在时显示英雄的名字。

Path:"src/app/app.component.html (asterisk)"

<div *ngIf="hero" class="name">{{hero.name}}</div>

星号是一个用来简化更复杂语法的“语法糖”。 从内部实现来说,Angular 把 *ngIf 属性 翻译成一个 <ng-template> 元素 并用它来包裹宿主元素,代码如下:

Path:"src/app/app.component.html (ngif-template)"

<ng-template [ngIf]="hero">  <div class="name">{{hero.name}}</div></ng-template>

  • *ngIf 指令被移到了 <ng-template> 元素上。在那里它变成了一个属性绑定 [ngIf]

  • <div> 上的其余部分,包括它的 class 属性在内,移到了内部的 <ng-template> 元素上。

第一种形态永远不会真的渲染出来。 只有最终产出的结果才会出现在 DOM 中。

Angular 会在真正渲染的时候填充 <ng-template> 的内容,并且把 <ng-template> 替换为一个供诊断用的注释。

NgForNgSwitch...指令也都遵循同样的模式。

*ngFor 内幕

Angular 会把 *ngFor 用同样的方式把星号(*)语法的 template属性转换成 <ng-template>元素。

这里有一个 NgFor 的全特性应用,同时用了这两种写法:

Path:"src/app/app.component.html (inside-ngfor)"

<div *ngFor="let hero of heroes; let i=index; let odd=odd; trackBy: trackById" [class.odd]="odd">  ({{i}}) {{hero.name}}</div><ng-template ngFor let-hero [ngForOf]="heroes" let-i="index" let-odd="odd" [ngForTrackBy]="trackById">  <div [class.odd]="odd">({{i}}) {{hero.name}}</div></ng-template>

它明显比 ngIf 复杂得多,确实如此。 NgFor 指令比本章展示过的 NgIf 具有更多的必选特性和可选特性。 至少 NgFor 会需要一个循环变量(let hero)和一个列表(heroes)。

你可以通过把一个字符串赋值给 ngFor 来启用这些特性,这个字符串使用 Angular 的微语法。

ngFor 字符串之外的每一样东西都会留在宿主元素(<div&)上,也就是说它移到了 <ng-template& 内部。 在这个例子中,[class.odd]="odd" 留在了 <div& 上。

微语法

Angular 微语法能让你通过简短的、友好的字符串来配置一个指令。 微语法解析器把这个字符串翻译成 <ng-template> 上的属性:

  • let 关键字声明一个模板输入变量,你会在模板中引用它。本例子中,这个输入变量就是 heroiodd。 解析器会把 let herolet ilet odd 翻译成命名变量 let-herolet-ilet-odd

  • 微语法解析器接收 oftrackby,把它们首字母大写(of -> Of, trackBy -> TrackBy), 并且给它们加上指令的属性名(ngFor)前缀,最终生成的名字是 ngForOfngForTrackBy。 这两个最终生成的名字是 NgFor 的输入属性,指令据此了解到列表是 heroes,而 track-by 函数是 trackById

  • NgFor 指令在列表上循环,每个循环中都会设置和重置它自己的上下文对象上的属性。 这些属性包括但不限于 indexodd 以及一个特殊的属性名 $implicit(隐式变量)。

  • let-ilet-odd 变量是通过 let i=indexlet odd=odd 来定义的。 Angular 把它们设置为上下文对象中的 indexodd 属性的当前值。

  • 这里并没有指定 let-hero 的上下文属性。它的来源是隐式的。 Angular 将 let-hero 设置为此上下文中 $implicit 属性的值, 它是由 NgFor 用当前迭代中的英雄初始化的。

  • API 参考手册中描述了 NgFor 指令的其它属性和上下文属性。

  • NgForOf 指令实现了 NgFor。请到 NgForOf API 参考手册中了解 NgForOf 指令的更多属性及其上下文属性。

编写你自己的结构型指令

当你编写自己的结构型指令时,也可以利用这些微语法机制。 例如,Angular 中的微语法允许你写成 <div *ngFor="let item of items">{{item}}</div> 而不是 <ng-template ngFor let-item [ngForOf]="items"><div>{{item}}</div></ng-template>。 以下各节提供了有关约束、语法和微语法翻译方式的详细信息。

约束

微语法必须满足以下要求:

  • 它必须可被预先了解,以便 IDE 可以解析它而无需知道指令的底层语义或已存在哪些指令。

  • 它必须转换为 DOM 中的“键-值”属性。

语法

当你编写自己的结构型指令时,请使用以下语法:

*:prefix="( :let | :expression ) (';' | ',')? ( :let | :as | :keyExp )*"

下表描述了微语法的每个组成部分。

组成部分描述
prefixHTML 属性键(attribute key)
keyHTML 属性键(attribute key)
local模板中使用的局部变量名
export指令使用指定名称导出的值
expression标准 Angular 表达式
keyExp = :key ":"? :expression ("as" :local)? ";"?
let = "let" :local "=" :export ";"?
as = :export "as" :local ";"?

翻译

将微语法转换为常规的绑定语法,如下所示:

微语法翻译结果
prefix 和裸表达式[prefix]="expression"
keyExp[prefixKey] "表达式" (let-prefixKey="export"),注意 prefix 已经加成了 key
letlet-local="export"

微语法样例

下表说明了 Angular 会如何解开微语法。

微语法解语法糖后
*ngFor="let item of [1,2,3]"<ng-template ngFor let-item [ngForOf]="[1,2,3]">
*ngFor="let item of [1,2,3] as items; trackBy: myTrack; index as i"<ng-template ngFor let-item [ngForOf]="[1,2,3]" let-items="ngForOf" [ngForTrackBy]="myTrack" let-i="index">
*ngIf="exp"<ng-template [ngIf]="exp">
*ngIf="exp as value"<ng-template [ngIf]="exp" let-value="ngIf">

注:
- 这些微语法机制在你写自己的结构型指令时也同样有效。

模板输入变量

模板输入变量是这样一种变量,你可以在单个实例的模板中引用它的值。 这个例子中有好几个模板输入变量:heroiodd。 它们都是用 let 作为前导关键字。

模板输入变量和模板引用变量是不同的,无论是在语义上还是语法上。

你使用 let 关键字(如 let hero)在模板中声明一个模板输入变量。 这个变量的范围被限制在所重复模板的单一实例上。 事实上,你可以在其它结构型指令中使用同样的变量名。

而声明模板引用变量使用的是给变量名加 # 前缀的方式(#var)。 一个引用变量引用的是它所附着到的元素、组件或指令。它可以在整个模板的任意位置访问。

模板输入变量和引用变量具有各自独立的命名空间。let hero 中的 hero#hero 中的 hero 并不是同一个变量。

每个宿主元素上只能有一个结构型指令

有时你会希望只有当特定的条件为真时才重复渲染一个 HTML 块。 你可能试过把 *ngFor*ngIf 放在同一个宿主元素上,但 Angular 不允许。这是因为你在一个元素上只能放一个结构型指令。

原因很简单。结构型指令可能会对宿主元素及其子元素做很复杂的事。当两个指令放在同一个元素上时,谁先谁后?NgIf 优先还是 NgFor 优先?NgIf 可以取消 NgFor 的效果吗? 如果要这样做,Angular 应该如何把这种能力泛化,以取消其它结构型指令的效果呢?

对这些问题,没有办法简单回答。而禁止多个结构型指令则可以简单地解决这个问题。 这种情况下有一个简单的解决方案:把 *ngIf 放在一个"容器"元素上,再包装进 *ngFor 元素。 这个元素可以使用ng-container,以免引入一个新的 HTML 层级。

NgSwitch 内幕

Angular 的 NgSwitch 实际上是一组相互合作的指令:NgSwitchNgSwitchCaseNgSwitchDefault

例子如下:

Path:"src/app/app.component.html (ngswitch)"

<div [ngSwitch]="hero?.emotion">  <app-happy-hero    *ngSwitchCase="'happy'"    [hero]="hero"></app-happy-hero>  <app-sad-hero      *ngSwitchCase="'sad'"      [hero]="hero"></app-sad-hero>  <app-confused-hero *ngSwitchCase="'confused'" [hero]="hero"></app-confused-hero>  <app-unknown-hero  *ngSwitchDefault           [hero]="hero"></app-unknown-hero></div>

一个值(hero.emotion)被被赋值给了 NgSwitch,以决定要显示哪一个分支。

NgSwitch 本身不是结构型指令,而是一个属性型指令,它控制其它两个 switch 指令的行为。 这也就是为什么你要写成 [ngSwitch] 而不是 *ngSwitch 的原因。

NgSwitchCaseNgSwitchDefault 都是结构型指令。 因此你要使用星号(*)前缀来把它们附着到元素上。 NgSwitchCase 会在它的值匹配上选项值的时候显示它的宿主元素。 NgSwitchDefault 则会当没有兄弟 NgSwitchCase 匹配上时显示它的宿主元素。

指令所在的元素就是它的宿主元素。 <happy-hero&*ngSwitchCase 的宿主元素。 <unknown-hero&*ngSwitchDefault 的宿主元素。

像其它的结构型指令一样,NgSwitchCaseNgSwitchDefault 也可以解开语法糖,变成 <ng-template> 的形式。

Path:"src/app/app.component.html (ngswitch-template)"

<div [ngSwitch]="hero?.emotion">  <ng-template [ngSwitchCase]="'happy'">    <app-happy-hero [hero]="hero"></app-happy-hero>  </ng-template>  <ng-template [ngSwitchCase]="'sad'">    <app-sad-hero [hero]="hero"></app-sad-hero>  </ng-template>  <ng-template [ngSwitchCase]="'confused'">    <app-confused-hero [hero]="hero"></app-confused-hero>  </ng-template >  <ng-template ngSwitchDefault>    <app-unknown-hero [hero]="hero"></app-unknown-hero>  </ng-template></div>

优先使用星号(*)语法

星号(*)语法比不带语法糖的形式更加清晰。 如果找不到单一的元素来应用该指令,可以使用<ng-container>作为该指令的容器。

虽然很少有理由在模板中使用结构型指令的属性形式和元素形式,但这些幕后知识仍然是很重要的,即:Angular 会创建 <ng-template>,还要了解它的工作原理。 当需要写自己的结构型指令时,你就要使用 <ng-template>

<ng-template>元素

<ng-template>是一个 Angular 元素,用来渲染 HTML。 它永远不会直接显示出来。 事实上,在渲染视图之前,Angular 会把 <ng-template> 及其内容替换为一个注释。

如果没有使用结构型指令,而仅仅把一些别的元素包装进 <ng-template> 中,那些元素就是不可见的。 在下面的这个短语"Hip! Hip! Hooray!"中,中间的这个 "Hip!"(欢呼声) 就是如此。

Path:"src/app/app.component.html (template-tag)"

<p>Hip!</p><ng-template>  <p>Hip!</p></ng-template><p>Hooray!</p>

Angular 抹掉了中间的那个 "Hip!",让欢呼声显得不再那么热烈了。

结构型指令会让 <ng-template> 正常工作,在你写自己的结构型指令时就会看到这一点。

使用<ng-container>把一些兄弟元素归为一组

通常都需要一个根元素作为结构型指令的宿主。 列表元素(<li>)就是一个典型的供 NgFor 使用的宿主元素。

Path:"src/app/app.component.html (ngfor-li)"

<li *ngFor="let hero of heroes">{{hero.name}}</li>

当没有这样一个单一的宿主元素时,你就可以把这些内容包裹在一个原生的 HTML 容器元素中,比如 <div>,并且把结构型指令附加到这个"包裹"上。

Path:"src/app/app.component.html (ngif)"

<div *ngIf="hero" class="name">{{hero.name}}</div>

但引入另一个容器元素(通常是 <span><div>)来把一些元素归到一个单一的根元素下,通常也会带来问题。注意,是"通常"而不是"总会"。

这种用于分组的元素可能会破坏模板的外观表现,因为 CSS 的样式既不曾期待也不会接受这种新的元素布局。 比如,假设你有下列分段布局。

Path:"src/app/app.component.html (ngif-span)"

<p>  I turned the corner  <span *ngIf="hero">    and saw {{hero.name}}. I waved  </span>  and continued on my way.</p>

而你的 CSS 样式规则是应用于 <p> 元素下的 <span> 的。

Path:"src/app/app.component.css (p-span)"

p span { color: red; font-size: 70%; }

这样渲染出来的段落就会非常奇怪。

本来为其它地方准备的 p span 样式,被意外的应用到了这里。

另一个问题是:有些 HTML 元素需要所有的直属下级都具有特定的类型。 比如,<select> 元素要求直属下级必须为 <option>,那就没办法把这些选项包装进 <div><span> 中。

如果这样做:

Path:"src/app/app.component.html (select-span)"

<div>  Pick your favorite hero  (<label><input type="checkbox" checked (change)="showSad = !showSad">show sad</label>)</div><select [(ngModel)]="hero">  <span *ngFor="let h of heroes">    <span *ngIf="showSad || h.emotion !== 'sad'">      <option [ngValue]="h">{{h.name}} ({{h.emotion}})</option>    </span>  </span></select>

下拉列表就是空的。浏览器不会显示 <span> 中的 <option>。

<ng-container> 的救赎

Angular 的 <ng-container> 是一个分组元素,但它不会污染样式或元素布局,因为 Angular 压根不会把它放进 DOM 中。

下面是重新实现的条件化段落,这次使用 <ng-container>

Path:"src/app/app.component.html (ngif-ngcontainer)"

<p>  I turned the corner  <ng-container *ngIf="hero">    and saw {{hero.name}}. I waved  </ng-container>  and continued on my way.</p>

这次就渲染对了。

现在用 <ng-container> 来根据条件排除选择框中的某个 <option>

Path:"src/app/app.component.html (select-ngcontainer)"

<div>  Pick your favorite hero  (<label><input type="checkbox" checked (change)="showSad = !showSad">show sad</label>)</div><select [(ngModel)]="hero">  <ng-container *ngFor="let h of heroes">    <ng-container *ngIf="showSad || h.emotion !== 'sad'">      <option [ngValue]="h">{{h.name}} ({{h.emotion}})</option>    </ng-container>  </ng-container></select>

下拉框也工作正常。

注:
- ngModel 指令是在 Angular 的 FormsModule 中定义的,你要在想使用它的模块的 imports: [...] 元数据中导入 FormsModule

<ng-container> 是一个由 Angular 解析器负责识别处理的语法元素。 它不是一个指令、组件、类或接口,更像是 JavaScript 中 if 块中的花括号。

if (someCondition) {  statement1;  statement2;  statement3;}

没有这些花括号,JavaScript 只会执行第一句,而你原本的意图是把其中的所有语句都视为一体来根据条件执行。 而 <ng-container> 满足了 Angular 模板中类似的需求。

写一个结构型指令

你需要写一个名叫 UnlessDirective 的结构型指令,它是 NgIf 的反义词。 NgIf 在条件为 true 的时候显示模板内容,而 UnlessDirective 则会在条件为 false 时显示模板内容。

Path:"src/app/app.component.html (appUnless-1)"

<p *appUnless="condition">Show this sentence unless the condition is true.</p>

创建指令很像创建组件。

  • 导入 Directive 装饰器(而不再是 Component)。

  • 导入符号 InputTemplateRefViewContainerRef,你在任何结构型指令中都会需要它们。

  • 给指令类添加装饰器。

  • 设置 CSS 属性选择器,以便在模板中标识出这个指令该应用于哪个元素。

这里是起点:

Path:"src/app/unless.directive.ts (skeleton)"

import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';@Directive({ selector: '[appUnless]'})export class UnlessDirective {}

指令的选择器通常是把指令的属性名括在方括号中,如 [appUnless]。 这个方括号定义出了一个 CSS 属性选择器。

该指令的属性名应该拼写成小驼峰形式,并且带有一个前缀。 但是,这个前缀不能用 ng,因为它只属于 Angular 本身。 请选择一些简短的,适合你自己或公司的前缀。 在这个例子中,前缀是 app。

指令的类名用 Directive 结尾,参见风格指南。 但 Angular 自己的指令例外。

TemplateRef 和 ViewContainerRef

像这个例子一样的简单结构型指令会从 Angular 生成的 <ng-template> 元素中创建一个内嵌的视图,并把这个视图插入到一个视图容器中,紧挨着本指令原来的宿主元素 <p>(译注:注意不是子节点,而是兄弟节点)。

你可以使用TemplateRef取得 <ng-template> 的内容,并通过ViewContainerRef来访问这个视图容器。

你可以把它们都注入到指令的构造函数中,作为该类的私有属性。

Path:"src/app/unless.directive.ts (ctor)"

constructor(  private templateRef: TemplateRef<any>,  private viewContainer: ViewContainerRef) { }

appUnless 属性

该指令的使用者会把一个 true/false 条件绑定到 [appUnless] 属性上。 也就是说,该指令需要一个带有 @InputappUnless 属性。

Path:"src/app/unless.directive.ts (set)"

@Input() set appUnless(condition: boolean) {  if (!condition && !this.hasView) {    this.viewContainer.createEmbeddedView(this.templateRef);    this.hasView = true;  } else if (condition && this.hasView) {    this.viewContainer.clear();    this.hasView = false;  }}

一旦该值的条件发生了变化,Angular 就会去设置 appUnless 属性。因为不能用 appUnless 属性,所以你要为它定义一个设置器(setter)。

如果条件为假,并且以前尚未创建过该视图,就告诉视图容器(ViewContainer)根据模板创建一个内嵌视图。

如果条件为真,并且视图已经显示出来了,就会清除该容器,并销毁该视图。

没有人会读取 appUnless 属性,因此它不需要定义 getter

完整的指令代码如下:

Path:"src/app/unless.directive.ts (excerpt)"

import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';/** * Add the template content to the DOM unless the condition is true. */@Directive({ selector: '[appUnless]'})export class UnlessDirective {  private hasView = false;  constructor(    private templateRef: TemplateRef<any>,    private viewContainer: ViewContainerRef) { }  @Input() set appUnless(condition: boolean) {    if (!condition && !this.hasView) {      this.viewContainer.createEmbeddedView(this.templateRef);      this.hasView = true;    } else if (condition && this.hasView) {      this.viewContainer.clear();      this.hasView = false;    }  }}

把这个指令添加到 AppModuledeclarations 数组中。

然后创建一些 HTML 来试用一下。

Path:"src/app/app.component.html (appUnless)"

<p *appUnless="condition" class="unless a">  (A) This paragraph is displayed because the condition is false.</p><p *appUnless="!condition" class="unless b">  (B) Although the condition is true,  this paragraph is displayed because appUnless is set to false.</p>

conditionfalse 时,顶部的段落就会显示出来,而底部的段落消失了。 当 conditiontrue 时,顶部的段落被移除了,而底部的段落显示了出来。

改进自定义指令的模板类型检查

你可以通过在指令定义中添加模板守护属性来改进自定义指令的模板类型检查。这些属性可以帮助 Angular 模板类型检查器在编译期间发现模板中的错误,避免这些失误导致运行期错误。

使用类型守护属性可以告诉模板类型检查器你所期望的类型,从而改进该模板的编译期类型检查。

  • 属性 ngTemplateGuard_(someInputProperty) 允许你为模板中的输入表达式指定一个更准确的类型。

  • ngTemplateContextGuard 静态属性声明了模板上下文的类型。

本节提供了这两种类型守护属性的例子。

使用模板守护功能可以让模板内的类型需求更具体

模板中的结构型指令会根据输入表达式来控制是否要在运行时渲染该模板。为了帮助编译器捕获模板类型中的错误,你应该尽可能详细地指定模板内指令的输入表达式所期待的类型。

类型守护函数会把输入表达式所期待的类型窄化为在运行时可能传给指令的子类型。你可以提供这样一个函数来帮助类型检查器在编译期间推断出该表达式的正确类型。

例如,NgIf 的实现使用类型窄化来确保只有当 *ngIf 的输入表达式为真时,模板才会被实例化。为了提供具体的类型要求,NgIf 指令定义了一个静态属性 ngTemplateGuard_ngIf: 'binding'binding 值是一种常见的类型窄化的例子,它会对输入表达式进行求值,以满足类型要求。

要为模板中的指令提供一个更具体的输入表达式类型,就要把 ngTemplateGuard_xx 属性添加到该指令中,其静态属性名的后缀(xx)是 @Input 字段名。该属性的值既可以是针对其返回类型的通用类型窄化函数,也可以是字符串 "binding" 就像 NgIf 一样。

例如,考虑以下结构型指令,它以模板表达式的结果作为输入。

Path:"src/app/IfLoadedDirective"

export type Loaded = { type: 'loaded', data: T };export type Loading = { type: 'loading' };export type LoadingState = Loaded | Loading;export class IfLoadedDirective {    @Input('ifLoaded') set state(state: LoadingState) {}    static ngTemplateGuard_state(dir: IfLoadedDirective, expr: LoadingState): expr is Loaded { return true; };export interface Person {  name: string;}@Component({  template: `{{ state.data }}`,})export class AppComponent {  state: LoadingState;}

在这个例子中,LoadingState<T> 类型允许两种状态之一,Loaded<T>Loading。此表达式用作该指令的 state 输入是一个总括类型 LoadingState,因为此处的加载状态是未知的。

IfLoadedDirective 定义声明了静态字段 ngTemplateGuard_state,表示其窄化行为。在 AppComponent 模板中,*ifLoaded 结构型指令只有当实际的 stateLoaded<Person> 类型时,才会渲染该模板。类型守护允许类型检查器推断出模板中可接受的 state 类型是 Loaded<T>,并进一步推断出 T 必须是 Person 一个实例。

为指令上下文指定类型

如果你的结构型指令要为实例化的模板提供一个上下文,可以通过提供静态的 ngTemplateContextGuard 函数在模板中给它提供合适的类型。下面的代码片段展示了该函数的一个例子。

Path:"src/app/myDirective.ts"

@Directive({…})export class ExampleDirective {    // Make sure the template checker knows the type of the context with which the    // template of this directive will be rendered    static ngTemplateContextGuard(dir: ExampleDirective, ctx: unknown): ctx is ExampleContext { return true; };    // …}

源代码

  1. Path:"src/app/app.component.ts" 。

    import { Component } from '@angular/core';    import { Hero, heroes } from './hero';    @Component({      selector: 'app-root',      templateUrl: './app.component.html',      styleUrls: [ './app.component.css' ]    })    export class AppComponent {      heroes = heroes;      hero = this.heroes[0];      condition = false;      logs: string[] = [];      showSad = true;      status = 'ready';      trackById(index: number, hero: Hero): number { return hero.id; }    }

  1. Path:"src/app/app.component.html" 。

    <h1>Structural Directives</h1>    <p>Conditional display of hero</p>    <blockquote>    <div *ngIf="hero" class="name">{{hero.name}}</div>    </blockquote>    <p>List of heroes</p>    <ul>      <li *ngFor="let hero of heroes">{{hero.name}}</li>    </ul>    <hr>    <h2 id="ngIf">NgIf</h2>    <p *ngIf="true">      Expression is true and ngIf is true.      This paragraph is in the DOM.    </p>    <p *ngIf="false">      Expression is false and ngIf is false.      This paragraph is not in the DOM.    </p>    <p [style.display]="'block'">      Expression sets display to "block".      This paragraph is visible.    </p>    <p [style.display]="'none'">      Expression sets display to "none".      This paragraph is hidden but still in the DOM.    </p>    <h4>NgIf with template</h4>    <p><ng-template> element</p>    <ng-template [ngIf]="hero">      <div class="name">{{hero.name}}</div>    </ng-template>    <hr>    <h2 id="ng-container"><ng-container></h2>    <h4>*ngIf with a <ng-container></h4>    <button (click)="hero = hero ? null : heroes[0]">Toggle hero</button>    <p>      I turned the corner      <ng-container *ngIf="hero">        and saw {{hero.name}}. I waved      </ng-container>      and continued on my way.    </p>    <p>      I turned the corner      <span *ngIf="hero">        and saw {{hero.name}}. I waved      </span>      and continued on my way.    </p>    <p><i><select> with <span></i></p>    <div>      Pick your favorite hero      (<label><input type="checkbox" checked (change)="showSad = !showSad">show sad</label>)    </div>    <select [(ngModel)]="hero">      <span *ngFor="let h of heroes">        <span *ngIf="showSad || h.emotion !== 'sad'">          <option [ngValue]="h">{{h.name}} ({{h.emotion}})</option>        </span>      </span>    </select>    <p><i><select> with <ng-container></i></p>    <div>      Pick your favorite hero      (<label><input type="checkbox" checked (change)="showSad = !showSad">show sad</label>)    </div>    <select [(ngModel)]="hero">      <ng-container *ngFor="let h of heroes">        <ng-container *ngIf="showSad || h.emotion !== 'sad'">          <option [ngValue]="h">{{h.name}} ({{h.emotion}})</option>        </ng-container>      </ng-container>    </select>    <br><br>    <hr>    <h2 id="ngFor">NgFor</h2>    <div class="box">    <p class="code"><div *ngFor="let hero of heroes; let i=index; let odd=odd; trackBy: trackById" [class.odd]="odd"></p>    <div *ngFor="let hero of heroes; let i=index; let odd=odd; trackBy: trackById" [class.odd]="odd">      ({{i}}) {{hero.name}}    </div>    <p class="code"><ng-template ngFor let-hero [ngForOf]="heroes" let-i="index" let-odd="odd" [ngForTrackBy]="trackById"/></p>    <ng-template ngFor let-hero [ngForOf]="heroes" let-i="index" let-odd="odd" [ngForTrackBy]="trackById">      <div [class.odd]="odd">({{i}}) {{hero.name}}</div>    </ng-template>    </div>    <hr>    <h2 id="ngSwitch">NgSwitch</h2>    <div>Pick your favorite hero</div>    <p>      <label *ngFor="let h of heroes">        <input type="radio" name="heroes" [(ngModel)]="hero" [value]="h">{{h.name}}      </label>      <label><input type="radio" name="heroes" (click)="hero = null">None of the above</label>    </p>    <h4>NgSwitch</h4>    <div [ngSwitch]="hero?.emotion">      <app-happy-hero    *ngSwitchCase="'happy'"    [hero]="hero"></app-happy-hero>      <app-sad-hero      *ngSwitchCase="'sad'"      [hero]="hero"></app-sad-hero>      <app-confused-hero *ngSwitchCase="'confused'" [hero]="hero"></app-confused-hero>      <app-unknown-hero  *ngSwitchDefault           [hero]="hero"></app-unknown-hero>    </div>    <h4>NgSwitch with <ng-template></h4>    <div [ngSwitch]="hero?.emotion">      <ng-template [ngSwitchCase]="'happy'">        <app-happy-hero [hero]="hero"></app-happy-hero>      </ng-template>      <ng-template [ngSwitchCase]="'sad'">        <app-sad-hero [hero]="hero"></app-sad-hero>      </ng-template>      <ng-template [ngSwitchCase]="'confused'">        <app-confused-hero [hero]="hero"></app-confused-hero>      </ng-template >      <ng-template ngSwitchDefault>        <app-unknown-hero [hero]="hero"></app-unknown-hero>      </ng-template>    </div>    <hr>    <h2><ng-template></h2>    <p>Hip!</p>    <ng-template>      <p>Hip!</p>    </ng-template>    <p>Hooray!</p>    <hr>    <h2 id="appUnless">UnlessDirective</h2>    <p>      The condition is currently      <span [ngClass]="{ 'a': !condition, 'b': condition, 'unless': true }">{{condition}}</span>.      <button        (click)="condition = !condition"        [ngClass] = "{ 'a': condition, 'b': !condition }" >        Toggle condition to {{condition ? 'false' : 'true'}}      </button>    </p>    <p *appUnless="condition" class="unless a">      (A) This paragraph is displayed because the condition is false.    </p>    <p *appUnless="!condition" class="unless b">      (B) Although the condition is true,      this paragraph is displayed because appUnless is set to false.    </p>    <h4>UnlessDirective with template</h4>    <p *appUnless="condition">Show this sentence unless the condition is true.</p>    <p *appUnless="condition" class="code unless">      (A) <p *appUnless="condition" class="code unless">    </p>    <ng-template [appUnless]="condition">      <p class="code unless">        (A) <ng-template [appUnless]="condition">      </p>    </ng-template>

  1. Path:"src/app/app.component.css" 。

    button {      min-width: 100px;      font-size: 100%;    }    .box {      border: 1px solid gray;      max-width: 600px;      padding: 4px;    }    .choices {      font-style: italic;    }    code, .code {      background-color: #eee;      color: black;      font-family: Courier, sans-serif;      font-size: 85%;    }    div.code {      width: 400px;    }    .heroic {      font-size: 150%;      font-weight: bold;    }    hr {      margin: 40px 0    }    .odd {      background-color:  palegoldenrod;    }    td, th {      text-align: left;      vertical-align: top;    }    p span { color: red; font-size: 70%; }    .unless {      border: 2px solid;      padding: 6px;    }    p.unless {      width: 500px;    }    button.a, span.a, .unless.a {      color: red;      border-color: gold;      background-color: yellow;      font-size: 100%;    }    button.b, span.b, .unless.b {      color: black;      border-color: green;      background-color: lightgreen;      font-size: 100%;    }

  1. Path:"src/app/app.module.ts" 。

    import { NgModule }      from '@angular/core';    import { FormsModule }   from '@angular/forms';    import { BrowserModule } from '@angular/platform-browser';    import { AppComponent }         from './app.component';    import { heroSwitchComponents } from './hero-switch.components';    import { UnlessDirective }    from './unless.directive';    @NgModule({      imports: [ BrowserModule, FormsModule ],      declarations: [        AppComponent,        heroSwitchComponents,        UnlessDirective      ],      bootstrap: [ AppComponent ]    })    export class AppModule { }

  1. Path:"src/app/hero.ts" 。

    export interface Hero {      id: number;      name: string;      emotion?: string;    }    export const heroes: Hero[] = [      { id: 1, name: 'Dr Nice',  emotion: 'happy'},      { id: 2, name: 'Narco',     emotion: 'sad' },      { id: 3, name: 'Windstorm', emotion: 'confused' },      { id: 4, name: 'Magneta'}    ];

  1. Path:"src/app/hero-switch.components.ts" 。

    import { Component, Input } from '@angular/core';    import { Hero } from './hero';    @Component({      selector: 'app-happy-hero',      template: `Wow. You like {{hero.name}}. What a happy hero ... just like you.`    })    export class HappyHeroComponent {      @Input() hero: Hero;    }    @Component({      selector: 'app-sad-hero',      template: `You like {{hero.name}}? Such a sad hero. Are you sad too?`    })    export class SadHeroComponent {      @Input() hero: Hero;    }    @Component({      selector: 'app-confused-hero',      template: `Are you as confused as {{hero.name}}?`    })    export class ConfusedHeroComponent {      @Input() hero: Hero;    }    @Component({      selector: 'app-unknown-hero',      template: `{{message}}`    })    export class UnknownHeroComponent {      @Input() hero: Hero;      get message() {        return this.hero && this.hero.name ?          `${this.hero.name} is strange and mysterious.` :          'Are you feeling indecisive?';      }    }    export const heroSwitchComponents =      [ HappyHeroComponent, SadHeroComponent, ConfusedHeroComponent, UnknownHeroComponent ];

  1. Path:"src/app/unless.directive.ts" 。

    import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';    /**     * Add the template content to the DOM unless the condition is true.     *     * If the expression assigned to `appUnless` evaluates to a truthy value     * then the templated elements are removed removed from the DOM,     * the templated elements are (re)inserted into the DOM.     *     * <div *appUnless="errorCount" class="success">     *   Congrats! Everything is great!     * </div>     *     * ### Syntax     *     * - `<div *appUnless="condition">...</div>`     * - `<ng-template [appUnless]="condition"><div>...</div></ng-template>`     *     */    @Directive({ selector: '[appUnless]'})    export class UnlessDirective {      private hasView = false;      constructor(        private templateRef: TemplateRef<any>,        private viewContainer: ViewContainerRef) { }      @Input() set appUnless(condition: boolean) {        if (!condition && !this.hasView) {          this.viewContainer.createEmbeddedView(this.templateRef);          this.hasView = true;        } else if (condition && this.hasView) {          this.viewContainer.clear();          this.hasView = false;        }      }    }

小结

  • 结构型指令可以操纵 HTML 的元素布局。

  • 当没有合适的宿主元素时,可以使用<ng-container>对元素进行分组。

  • Angular 会把星号(*)语法解开成 <ng-template>

  • 内置指令 NgIfNgForNgSwitch 的工作原理。

  • 微语法如何展开成<ng-template>

  • 写了一个自定义结构型指令 —— UnlessDirective

用管道转换数据

管道用来对字符串、货币金额、日期和其他显示数据进行转换和格式化。管道是一些简单的函数,可以在模板表达式中用来接受输入值并返回一个转换后的值。例如,你可以使用一个管道把日期显示为 1988 年 4 月 15 日,而不是其原始字符串格式。

Angular 为典型的数据转换提供了内置的管道,包括国际化的转换(i18n),它使用本地化信息来格式化数据。数据格式化常用的内置管道如下:

  • DatePipe:根据本地环境中的规则格式化日期值。

  • UpperCasePipe:把文本全部转换成大写。

  • LowerCasePipe :把文本全部转换成小写。

  • CurrencyPipe :把数字转换成货币字符串,根据本地环境中的规则进行格式化。

  • DecimalPipe:把数字转换成带小数点的字符串,根据本地环境中的规则进行格式化。

  • PercentPipe :把数字转换成百分比字符串,根据本地环境中的规则进行格式化。

你还可以创建管道来封装自定义转换,并在模板表达式中使用自定义管道。

先决条件

要想使用管道,你应该对这些内容有基本的了解:

  • Typescript 和 HTML5 编程

  • 带有 CSS 样式的 HTML 模板

  • 组件

在模板中使用管道

要应用管道,请如下所示在模板表达式中使用管道操作符(|),紧接着是该管道的名字,对于内置的 DatePipe 它的名字是 date 。这个例子中的显示如下:

"app.component.html" 在另一个单独的模板中使用 date 来显示生日。

"hero-birthday1.component.ts" 使用相同的管道作为组件内嵌模板的一部分,同时该组件也会设置生日值。

  1. Path:"src/app/app.component.html" 。

    <p>The hero's birthday is {{ birthday | date }}</p>

  1. Path:"src/app/app.component.html" 。

    import { Component } from '@angular/core';    @Component({      selector: 'app-hero-birthday',      template: `<p>The hero's birthday is {{ birthday | date }}</p>`    })    export class HeroBirthdayComponent {      birthday = new Date(1988, 3, 15); // April 15, 1988 -- since month parameter is zero-based    }

使用参数和管道链来格式化数据

可以用可选参数微调管道的输出。例如,你可以使用 CurrencyPipe 和国家代码(如 EUR)作为参数。模板表达式 {{ amount | currency:'EUR' }} 会把 amount 转换成欧元。紧跟在管道名称( currency )后面的是冒号(:)和参数值('EUR')。

如果管道能接受多个参数,就用冒号分隔这些值。例如,{{ amount | currency:'EUR':'Euros '}} 会把第二个参数(字符串 'Euros ')添加到输出字符串中。你可以使用任何有效的模板表达式作为参数,比如字符串字面量或组件的属性。

有些管道需要至少一个参数,并且允许使用更多的可选参数,比如 SlicePipe 。例如, {{ slice:1:5 }} 会创建一个新数组或字符串,它以第 1 个元素开头,并以第 5 个元素结尾。

示例:格式化日期

下面的例子显示了两种不同格式('shortDate''fullDate')之间的切换:

该 "app.component.html" 模板使用 DatePipe (名为 date)的格式参数把日期显示为 04/15/88 。

"hero-birthday2.component.ts" 组件把该管道的 format 参数绑定到 template 中组件的 format 属性,并添加了一个按钮,其 click 事件绑定到了该组件的 toggleFormat() 方法。

"hero-birthday2.component.ts" 组件的 toggleFormat() 方法会在短格式('shortDate')和长格式('fullDate')之间切换该组件的 format 属性。

  1. Path:"src/app/app.component.html" 。

    <p>The hero's birthday is {{ birthday | date:"MM/dd/yy" }} </p>

  1. Path:"src/app/hero-birthday2.component.ts (template)" 。

    template: `      <p>The hero's birthday is {{ birthday | date:format }}</p>      <button (click)="toggleFormat()">Toggle Format</button>    `

  1. Path:"src/app/hero-birthday2.component.ts (class)" 。

    export class HeroBirthday2Component {      birthday = new Date(1988, 3, 15); // April 15, 1988 -- since month parameter is zero-based      toggle = true; // start with true == shortDate      get format()   { return this.toggle ? 'shortDate' : 'fullDate'; }      toggleFormat() { this.toggle = !this.toggle; }    }

点击 Toggle Format 按钮可以在 04/15/1988 和 Friday, April 15, 1988 之间切换日期格式,如下所示:

示例:通过串联管道应用两种格式

你可以对管道进行串联,以便一个管道的输出成为下一个管道的输入。

在下面的示例中,串联管道首先将格式应用于一个日期值,然后将格式化之后的日期转换为大写字符。 "src/app/app.component.html" 模板的第一个标签页把 DatePipeUpperCasePipe 的串联起来,将其显示为 APR 15, 1988。"src/app/app.component.html" 模板的第二个标签页在串联 uppercase 之前,还把 fullDate 参数传递给了 date,将其显示为 FRIDAY, APRIL 15, 1988。

  1. Path:"src/app/app.component.html (1)" 。

    The chained hero's birthday is    {{ birthday | date | uppercase}}

  1. Path:"src/app/app.component.html (2)" 。

    The chained hero's birthday is    {{  birthday | date:'fullDate' | uppercase}}

为自定义数据转换创建管道

创建自定义管道来封装那些内置管道没有提供的转换。然后你就可以在模板表达式中使用你的自定义管道,就像内置管道一样,把输入值转换成显示输出。

把一个类标记为一个管道

要把类标记为管道并提供配置元数据,请把 @Pipe 装饰器应用到这个类上。管道类名是 UpperCamelCase(类名的一般约定),相应的 name 字符串是 camelCase 的。不要在 name 中使用连字符。详细信息和更多示例,请参阅管道名称 。

在模板表达式中使用 name 就像在内置管道中一样。

  • 把你的管道包含在 NgModule 元数据的 declarations 字段中,以便它能用于模板。

  • 把你的管道包含在 NgModule 元数据的 declarations 字段中,以便它能用于模板。

使用 PipeTransform 接口

在自定义管道类中实现 PipeTransform 接口来执行转换。

Angular 调用 transform 方法,该方法使用绑定的值作为第一个参数,把其它任何参数都以列表的形式作为第二个参数,并返回转换后的值。

示例:指数级转换

在游戏中,你可能希望实现一种指数级转换,以指数级增加英雄的力量。例如,如果英雄的得分是 2,那么英雄的能量会指数级增长 10 次,最终得分为 1024。你可以使用自定义管道进行这种转换。

下列代码示例显示了两个组件定义:

"exponential-strength.pipe.ts" 通过一个执行转换的 transform 方法定义了一个名为 exponentialStrength 的自定义管道。它为传递给管道的参数定义了 transform 方法的一个参数(exponent)。

"power-booster.component.ts" 组件演示了如何使用该管道,指定了一个值( 2 )和一个 exponent 参数( 10 )。

  1. Path:"src/app/exponential-strength.pipe.ts" 。

    import { Pipe, PipeTransform } from '@angular/core';    /*     * Raise the value exponentially     * Takes an exponent argument that defaults to 1.     * Usage:     *   value | exponentialStrength:exponent     * Example:     *   {{ 2 | exponentialStrength:10 }}     *   formats to: 1024    */    @Pipe({name: 'exponentialStrength'})    export class ExponentialStrengthPipe implements PipeTransform {      transform(value: number, exponent?: number): number {        return Math.pow(value, isNaN(exponent) ? 1 : exponent);      }    }

  1. Path:"src/app/power-booster.component.ts" 。

    import { Component } from '@angular/core';    @Component({      selector: 'app-power-booster',      template: `        <h2>Power Booster</h2>        <p>Super power boost: {{2 | exponentialStrength: 10}}</p>      `    })    export class PowerBoosterComponent { }

输出结果如下所示:

通过管道中的数据绑定来检测变更

你可以通过带有管道的数据绑定来显示值并响应用户操作。如果是原始类型的输入值,比如 StringNumber ,或者是对象引用型的输入值,比如 DateArray ,那么每当 Angular 检测到输入值或引用有变化时,就会执行该输入管道。

比如,你可以修改前面的自定义管道示例,通过 ngModel 的双向绑定来输入数量和提升因子,如下面的代码示例所示。

Path:"src/app/power-boost-calculator.component.ts" 。

import { Component } from '@angular/core';@Component({  selector: 'app-power-boost-calculator',  template: `    <h2>Power Boost Calculator</h2>    <div>Normal power: <input [(ngModel)]="power"></div>    <div>Boost factor: <input [(ngModel)]="factor"></div>    <p>      Super Hero Power: {{power | exponentialStrength: factor}}    </p>  `})export class PowerBoostCalculatorComponent {  power = 5;  factor = 1;}

每当用户改变 “normal power” 值或 “boost factor” 时,就会执行 exponentialStrength 管道,如下所示。

Angular 会检测每次变更,并立即运行该管道。对于原始输入值,这很好。但是,如果要在复合对象中更改某些内部值(例如日期中的月份、数组中的元素或对象中的属性),就需要了解变更检测的工作原理,以及如何使用 impure(非纯)管道。

变更检测的工作原理

Angular 会在每次 DOM 事件(每次按键、鼠标移动、计时器滴答和服务器响应)之后运行的变更检测过程中查找对数据绑定值的更改。下面这段不使用管道的例子演示了 Angular 如何利用默认的变更检测策略来监控和更新 heroes 数组中每个英雄的显示效果。示例显示如下:

  • 在 "flying-heroes.component.html (v1)" 模板中, *ngFor 会重复显示英雄的名字。

  • 与之相伴的组件类 "flying-heroes.component.ts (v1)" 提供了一些英雄,把这些英雄添加到数组中,并重置了该数组。

  1. Path:"src/app/flying-heroes.component.html (v1)" 。

    New hero:      <input type="text" #box              (keyup.enter)="addHero(box.value); box.value=''"              placeholder="hero name">      <button (click)="reset()">Reset</button>      <div *ngFor="let hero of heroes">        {{hero.name}}      </div>

  1. Path:"src/app/flying-heroes.component.ts (v1)" 。

    export class FlyingHeroesComponent {      heroes: any[] = [];      canFly = true;      constructor() { this.reset(); }      addHero(name: string) {        name = name.trim();        if (!name) { return; }        let hero = {name, canFly: this.canFly};        this.heroes.push(hero);      }      reset() { this.heroes = HEROES.slice(); }    }

每次用户添加一个英雄时,Angular 都会更新显示内容。如果用户点击了 Reset 按钮,Angular 就会用原来这些英雄组成的新数组来替换 heroes ,并更新显示。如果你添加删除或更改了某个英雄的能力,Angular 也会检测这些变化并更新显示。

然而,如果对于每次更改都执行一个管道来更新显示,就会降低你应用的性能。因此,Angular 会使用更快的变更检测算法来执行管道,如下一节所述。

检测原始类型和对象引用的纯变更

通过默认情况下,管道会定义成纯的(pure),这样 Angular 只有在检测到输入值发生了纯变更时才会执行该管道。纯变更是对原始输入值(比如 StringNumberBooleanSymbol )的变更,或是对对象引用的变更(比如 DateArrayFunctionObject)。

纯管道必须使用纯函数,它能处理输入并返回没有副作用的值。换句话说,给定相同的输入,纯函数应该总是返回相同的输出。

使用纯管道,Angular 会忽略复合对象中的变化,例如往现有数组中新增的元素,因为检查原始值或对象引用比对对象中的差异进行深度检查要快得多。Angular 可以快速判断是否可以跳过执行该管道并更新视图。

但是,以数组作为输入的纯管道可能无法正常工作。为了演示这个问题,修改前面的例子来把英雄列表过滤成那些会飞的英雄。在 *ngFor 中使用 FlyingHeroesPipe ,代码如下。这个例子的显示如下:

  1. 带有新管道的模板(Path:"src/app/flying-heroes.component.html (flyers)")。

    <div *ngFor="let hero of (heroes | flyingHeroes)">      {{hero.name}}    </div>

  1. FlyingHeroesPipe 自定义管道实现(Path:"src/app/flying-heroes.pipe.ts")。

    import { Pipe, PipeTransform } from '@angular/core';    import { Flyer } from './heroes';    @Pipe({ name: 'flyingHeroes' })    export class FlyingHeroesPipe implements PipeTransform {      transform(allHeroes: Flyer[]) {        return allHeroes.filter(hero => hero.canFly);      }    }

该应用现在展示了意想不到的行为:当用户添加了会飞的英雄时,它们都不会出现在 “Heroes who fly” 中。发生这种情况是因为添加英雄的代码会把它 pushheroes 数组中:

Path:"src/app/flying-heroes.component.ts" 。

this.heroes.push(hero);

而变更检测器会忽略对数组元素的更改,所以管道不会运行。

Angular 忽略了被改变的数组元素的原因是对数组的引用没有改变。由于 Angular 认为该数组仍是相同的,所以不会更新其显示。

获得所需行为的方法之一是更改对象引用本身。你可以用一个包含新更改过的元素的新数组替换该数组,然后把这个新数组作为输入传给管道。在上面的例子中,你可以创建一个附加了新英雄的数组,并把它赋值给 heroes。 Angular 检测到了这个数组引用的变化,并执行了该管道。

总结一下,如果修改了输入数组,纯管道就不会执行。如果替换了输入数组,就会执行该管道并更新显示,如下图所示。

上面的例子演示了如何更改组件的代码来适应某个管道。

为了让你的组件更简单,独立于那些使用管道的 HTML,你可以用一个不纯的管道来检测复合对象(如数组)中的变化,如下一节所述。

检测复合对象中的非纯变更

要在复合对象内部进行更改后执行自定义管道(例如更改数组元素),就需要把管道定义为 impure 以检测非纯的变更。每当按键或鼠标移动时,Angular 都会检测到一次变更,从而执行一个非纯管道。

注:
- 虽然非纯管道很实用,但要小心使用。长时间运行非纯管道可能会大大降低你的应用速度。

通过把 pure 标志设置为 false 来把管道设置成非纯的:

Path:"src/app/flying-heroes.pipe.ts" 。

@Pipe({  name: 'flyingHeroesImpure',  pure: false})

下面的代码显示了 FlyingHeroesImpurePipe 的完整实现,它扩展了 FlyingHeroesPipe 以继承其特性。这个例子表明你不需要修改其他任何东西 - 唯一的区别就是在管道元数据中把 pure 标志设置为 false

  1. Path:"src/app/flying-heroes.pipe.ts (FlyingHeroesImpurePipe)" 。

    @Pipe({      name: 'flyingHeroesImpure',      pure: false    })    export class FlyingHeroesImpurePipe extends FlyingHeroesPipe {}

  1. Path:"src/app/flying-heroes.pipe.ts (FlyingHeroesPipe)" 。

    import { Pipe, PipeTransform } from '@angular/core';    import { Flyer } from './heroes';    @Pipe({ name: 'flyingHeroes' })    export class FlyingHeroesPipe implements PipeTransform {      transform(allHeroes: Flyer[]) {        return allHeroes.filter(hero => hero.canFly);      }    }

对于非纯管道,FlyingHeroesImpurePipe 是个不错的选择,因为它的 transform 函数非常简单快捷:

Path:"src/app/flying-heroes.pipe.ts (filter)" 。

return allHeroes.filter(hero => hero.canFly);

你可以从 FlyingHeroesComponent 派生一个 FlyingHeroesImpureComponent。如下面的代码所示,只有模板中的管道发生了变化。

Path:"src/app/flying-heroes-impure.component.html (excerpt)" 。

<div *ngFor="let hero of (heroes | flyingHeroesImpure)">  {{hero.name}}</div>

从一个可观察对象中解包数据

可观察对象能让你在应用的各个部分之间传递消息。建议在事件处理、异步编程以及处理多个值时使用这些可观察对象。可观察对象可以提供任意类型的单个或多个值,可以是同步的(作为一个函数为它的调用者提供一个值),也可以是异步的。

使用内置的 AsyncPipe 接受一个可观察对象作为输入,并自动订阅输入。如果没有这个管道,你的组件代码就必须订阅这个可观察对象来使用它的值,提取已解析的值、把它们公开进行绑定,并在销毁这段可观察对象时取消订阅,以防止内存泄漏。 AsyncPipe 是一个非纯管道,可以节省组件中的样板代码,以维护订阅,并在数据到达时持续从该可观察对象中提供值。

下列代码示例使用 async 管道将带有消息字符串( message$ )的可观察对象绑定到视图中。

Path:"src/app/hero-async-message.component.ts" 。

import { Component } from '@angular/core';import { Observable, interval } from 'rxjs';import { map, take } from 'rxjs/operators';@Component({  selector: 'app-hero-message',  template: `    <h2>Async Hero Message and AsyncPipe</h2>    <p>Message: {{ message$ | async }}</p>    <button (click)="resend()">Resend</button>`,})export class HeroAsyncMessageComponent {  message$: Observable<string>;  private messages = [    'You are my hero!',    'You are the best hero!',    'Will you be my hero?'  ];  constructor() { this.resend(); }  resend() {    this.message$ = interval(500).pipe(      map(i => this.messages[i]),      take(this.messages.length)    );  }}

缓存 HTTP 请求

为了使用 HTTP 与后端服务进行通信,HttpClient 服务使用了可观察对象,并提供了 HTTPClient.get() 方法来从服务器获取数据。这个异步方法会发送一个 HTTP 请求,并返回一个可观察对象,它会发出请求到的响应数据。

AsyncPipe 所示,你可以使用非纯管道 AsyncPipe 接受一个可观察对象作为输入,并自动订阅输入。你也可以创建一个非纯管道来建立和缓存 HTTP 请求。

每当组件运行变更检测时就会调用非纯管道,在 CheckAlways 策略下会每隔几毫秒运行一次。为避免出现性能问题,只有当请求的 URL 发生变化时才会调用该服务器(如下例所示),并使用该管道缓存服务器的响应。显示如下:

  1. fetch 管道( Path:"src/app/fetch-json.pipe.ts" )。

    import { HttpClient }          from '@angular/common/http';    import { Pipe, PipeTransform } from '@angular/core';    @Pipe({      name: 'fetch',      pure: false    })    export class FetchJsonPipe implements PipeTransform {      private cachedData: any = null;      private cachedUrl = '';      constructor(private http: HttpClient) { }      transform(url: string): any {        if (url !== this.cachedUrl) {          this.cachedData = null;          this.cachedUrl = url;          this.http.get(url).subscribe(result => this.cachedData = result);        }        return this.cachedData;      }    }

  1. 一个用于演示该请求的挽具组件("src/app/hero-list.component.ts"),它使用一个模板,该模板定义了两个到该管道的绑定,该管道会向 "heroes.json" 文件请求英雄数组。第二个绑定把 fetch 管道与内置的 JsonPipe 串联起来,以 JSON 格式显示同一份英雄数据。

    import { Component } from '@angular/core';    @Component({      selector: 'app-hero-list',      template: `        <h2>Heroes from JSON File</h2>        <div *ngFor="let hero of ('assets/heroes.json' | fetch) ">          {{hero.name}}        </div>        <p>Heroes as JSON:          {{'assets/heroes.json' | fetch | json}}        </p>`    })    export class HeroListComponent { }

在上面的例子中,管道请求数据时的剖面展示了如下几点:

  • 每个绑定都有自己的管道实例。

  • 每个管道实例都会缓存自己的 URL 和数据,并且只调用一次服务器。

fetch 和 fetch-json 管道会显示英雄,如下图所示。

注:
- 内置的 JsonPipe 提供了一种方法来诊断一个离奇失败的数据绑定,或用来检查一个对象是否能用于将来的绑定。

当 Angular 实例化组件类并渲染组件视图及其子视图时,组件实例的生命周期就开始了。生命周期一直伴随着变更检测,Angular 会检查数据绑定属性何时发生变化,并按需更新视图和组件实例。当 Angular 销毁组件实例并从 DOM 中移除它渲染的模板时,生命周期就结束了。当 Angular 在执行过程中创建、更新和销毁实例时,指令就有了类似的生命周期。

你的应用可以使用生命周期钩子方法来触发组件或指令生命周期中的关键事件,以初始化新实例,需要时启动变更检测,在变更检测过程中响应更新,并在删除实例之前进行清理。

先决条件

在使用生命周期钩子之前,你应该对这些内容有一个基本的了解:

  • TypeScript 编程 。

  • Angular 应用设计基础,就像 Angular 的基本概念中所讲的那样。

响应生命周期事件

你可以通过实现一个或多个 Angular core 库中定义的生命周期钩子接口来响应组件或指令生命周期中的事件。这些钩子让你有机会在适当的时候对组件或指令实例进行操作,比如 Angular 创建、更新或销毁这个实例时。

每个接口都有唯一的一个钩子方法,它们的名字是由接口名再加上 ng 前缀构成的。比如,OnInit 接口的钩子方法叫做 ngOnInit()。如果你在组件或指令类中实现了这个方法,Angular 就会在首次检查完组件或指令的输入属性后,紧接着调用它。

Path:"peek-a-boo.component.ts (excerpt)" 。

@Directive()export class PeekABooDirective implements OnInit {  constructor(private logger: LoggerService) { }  // implement OnInit's `ngOnInit` method  ngOnInit() { this.logIt(`OnInit`); }  logIt(msg: string) {    this.logger.log(`#${nextId++} ${msg}`);  }}

注:
- 你不必实现所有生命周期钩子,只要实现你需要的那些就可以了。

生命周期的顺序

当你的应用通过调用构造函数来实例化一个组件或指令时,Angular 就会调用那个在该实例生命周期的适当位置实现了的那些钩子方法。

Angular 会按以下顺序执行钩子方法。你可以用它来执行以下类型的操作。

钩子方法用途调用时机
ngOnChanges()当 Angular 设置或重新设置数据绑定的输入属性时响应。 该方法接受当前和上一属性值的 SimpleChanges 对象。注意,这发生的非常频繁,所以你在这里执行的任何操作都会显著影响性能。在 ngOnInit() 之前以及所绑定的一个或多个输入属性的值发生变化时都会调用。
ngOnInit()在 Angular 第一次显示数据绑定和设置指令/组件的输入属性之后,初始化指令/组件。在第一轮 ngOnChanges() 完成之后调用,只调用一次。
ngDoCheck()检测,并在发生 Angular 无法或不愿意自己检测的变化时作出反应。紧跟在每次执行变更检测时的 ngOnChanges() 和 首次执行变更检测时的 ngOnInit() 后调用。
ngAfterContentInit()当 Angular 把外部内容投影进组件视图或指令所在的视图之后调用。第一次 ngDoCheck() 之后调用,只调用一次。
ngAfterContentChecked()每当 Angular 检查完被投影到组件或指令中的内容之后调用。ngAfterContentInit() 和每次 ngDoCheck() 之后调用
ngAfterViewInit()当 Angular 初始化完组件视图及其子视图或包含该指令的视图之后调用。第一次 ngAfterContentChecked() 之后调用,只调用一次。
ngAfterViewChecked()每当 Angular 做完组件视图和子视图或包含该指令的视图的变更检测之后调用。ngAfterViewInit() 和每次 ngAfterContentChecked() 之后调用。
ngOnDestroy()每当 Angular 每次销毁指令/组件之前调用并清扫。 在这儿反订阅可观察对象和分离事件处理器,以防内存泄漏。在 Angular 销毁指令或组件之前立即调用。

生命周期范例

通过在受控于根组件 AppComponent 的一些组件上进行的一系列练习,演示了生命周期钩子的运作方式。 每一个例子中,父组件都扮演了子组件测试台的角色,以展示出一个或多个生命周期钩子方法。

下表列出了这些练习及其简介。 范例代码也用来阐明后续各节的一些特定任务。

组件说明
Peek-a-boo展示每个生命周期钩子,每个钩子方法都会在屏幕上显示一条日志。
Spy展示了你如何在自定义指令中使用生命周期钩子。 SpyDirective 实现了 ngOnInit() 和 ngOnDestroy() 钩子,并且使用它们来观察和汇报一个元素何时进入或离开当前视图。
OnChanges演示了每当组件的输入属性之一发生变化时,Angular 如何调用 ngOnChanges() 钩子。并且演示了如何解释传给钩子方法的 changes 对象。
DoCheck实现了一个 ngDoCheck() 方法,通过它可以自定义变更检测逻辑。 监视该钩子把哪些变更记录到了日志中,观察 Angular 以什么频度调用这个钩子。
AfterView显示 Angular 中的视图所指的是什么。 演示了 ngAfterViewInit() 和 ngAfterViewChecked() 钩子。
AfterContent展示如何把外部内容投影进组件中,以及如何区分“投影进来的内容”和“组件的子视图”。 演示了 ngAfterContentInit() 和 ngAfterContentChecked() 钩子。
计数器演示了一个组件和一个指令的组合,它们各自有自己的钩子。

初始化组件或指令

使用 ngOnInit() 方法执行以下初始化任务。

  • 在构造函数外部执行复杂的初始化。组件的构造应该既便宜又安全。比如,你不应该在组件构造函数中获取数据。当在测试中创建组件时或者决定显示它之前,你不应该担心新组件会尝试联系远程服务器。

ngOnInit() 是组件获取初始数据的好地方。比如,英雄指南 中的《在 ngOnInit() 中调用它》小节。

  • 在 Angular 设置好输入属性之后设置组件。构造函数应该只把初始局部变量设置为简单的值。

请记住,只有在构造完成之后才会设置指令的数据绑定输入属性。如果要根据这些属性对指令进行初始化,请在运行 ngOnInit() 时设置它们。

&ngOnChanges() 方法是你能访问这些属性的第一次机会。Angular 会在调用 ngOnInit() 之前调用 ngOnChanges(),而且之后还会调用多次。但它只调用一次 ngOnInit()

在实例销毁时进行清理

把清理逻辑放进 ngOnDestroy() 中,这个逻辑就必然会在 Angular 销毁该指令之前运行。

这里是释放资源的地方,这些资源不会自动被垃圾回收。如果你不这样做,就存在内存泄漏的风险。

  • 取消订阅可观察对象和 DOM 事件。

  • 停止 interval 计时器。

  • 反注册该指令在全局或应用服务中注册过的所有回调。

ngOnDestroy() 方法也可以用来通知应用程序的其它部分,该组件即将消失。

一般性例子

下面的例子展示了各个生命周期事件的调用顺序和相对频率,以及如何在组件和指令中单独使用或同时使用这些钩子。

所有生命周期事件的顺序和频率

为了展示 Angular 如何以预期的顺序调用钩子,PeekABooComponent 演示了一个组件中的所有钩子。

实际上,你很少会(几乎永远不会)像这个演示中一样实现所有这些接口。

下列快照反映了用户单击 Create... 按钮,然后单击 Destroy... 按钮后的日志状态。

日志信息的日志和所规定的钩子调用顺序是一致的: OnChangesOnInitDoCheck (3x)AfterContentInitAfterContentChecked (3x)AfterViewInitAfterViewChecked (3x)OnDestroy

注:
- 该日志确认了在创建期间那些输入属性(这里是 name 属性)没有被赋值。 这些输入属性要等到 onInit() 中才可用,以便做进一步的初始化。

如果用户点击 Update Hero 按钮,就会看到另一个 OnChanges 和至少两组 DoCheckAfterContentCheckedAfterViewChecked 钩子。 注意,这三种钩子被触发了很多次,所以让它们的逻辑尽可能保持精简是非常重要的!

使用指令来监视 DOM

这个 Spy 例子演示了如何在指令和组件中使用钩子方法。SpyDirective 实现了两个钩子 ngOnInit()ngOnDestroy(),以便发现被监视的元素什么时候位于当前视图中。

这个模板将 SpyDirective 应用到由父组件 SpyComponent 管理的 ngFor 内的 <div> 中。

该例子不执行任何初始化或清理工作。它只是通过记录指令本身的实例化时间和销毁时间来跟踪元素在视图中的出现和消失。

像这样的间谍指令可以深入了解你无法直接修改的 DOM 对象。你无法触及原生 <div> 的实现,也无法修改第三方组件,但是可以用指令来监视这些元素。

这个指令定义了 ngOnInit()ngOnDestroy() 钩子,它通过一个注入进来的 LoggerService 把消息记录到父组件中去。

Path:"src/app/spy.directive.ts" 。

// Spy on any element to which it is applied.// Usage: <div mySpy>...</div>@Directive({selector: '[mySpy]'})export class SpyDirective implements OnInit, OnDestroy {  constructor(private logger: LoggerService) { }  ngOnInit()    { this.logIt(`onInit`); }  ngOnDestroy() { this.logIt(`onDestroy`); }  private logIt(msg: string) {    this.logger.log(`Spy #${nextId++} ${msg}`);  }}

你可以把这个侦探指令写到任何原生元素或组件元素上,以观察它何时被初始化和销毁。 下面是把它附加到用来重复显示英雄数据的这个 <div> 上。

Path:"src/app/spy.component.html" 。

<div *ngFor="let hero of heroes" mySpy class="heroes">  {{hero}}</div>

每个“侦探”的创建和销毁都可以标出英雄所在的那个 <div> 的出现和消失。钩子记录中的结构是这样的:

添加一个英雄就会产生一个新的英雄 <div>。侦探的 ngOnInit() 记录下了这个事件。

Reset 按钮清除了这个 heroes 列表。 Angular 从 DOM 中移除了所有英雄的 div,并且同时销毁了附加在这些 div 上的侦探指令。 侦探的 ngOnDestroy() 方法汇报了它自己的临终时刻。

同时使用组件和指令的钩子

在这个例子中,CounterComponent 使用了 ngOnChanges() 方法,以便在每次父组件递增其输入属性 counter 时记录一次变更。

这个例子将前例中的 SpyDirective 用于 CounterComponent 的日志,以便监视这些日志条目的创建和销毁。

使用变更检测钩子

一旦检测到该组件或指令的输入属性发生了变化,Angular 就会调用它的 ngOnChanges() 方法。 这个 onChanges 范例通过监控 OnChanges() 钩子演示了这一点。

Path:"on-changes.component.ts (excerpt)" 。

ngOnChanges(changes: SimpleChanges) {  for (let propName in changes) {    let chng = changes[propName];    let cur  = JSON.stringify(chng.currentValue);    let prev = JSON.stringify(chng.previousValue);    this.changeLog.push(`${propName}: currentValue = ${cur}, previousValue = ${prev}`);  }}

ngOnChanges() 方法获取了一个对象,它把每个发生变化的属性名都映射到了一个 SimpleChange 对象, 该对象中有属性的当前值和前一个值。这个钩子会在这些发生了变化的属性上进行迭代,并记录它们。

这个例子中的 OnChangesComponent 组件有两个输入属性:heropower

Path:"src/app/on-changes.component.ts" 。

@Input() hero: Hero;@Input() power: string;

宿主 OnChangesParentComponent 绑定了它们,就像这样:

Path:"src/app/on-changes-parent.component.html" 。

<on-changes [hero]="hero" [power]="power"></on-changes>

下面是此例子中的当用户做出更改时的操作演示:

日志条目把 power 属性的变化显示为字符串。但请注意,ngOnChanges() 方法不会捕获对 hero.name 更改。这是因为只有当输入属性的值发生变化时,Angular 才会调用该钩子。在这种情况下,hero 是输入属性,hero 属性的值是对 hero 对象的引用 。当它自己的 name 属性的值发生变化时,对象引用并没有改变。

响应视图的变更

当 Angular 在变更检测期间遍历视图树时,需要确保子组件中的某个变更不会尝试更改其父组件中的属性。因为单向数据流的工作原理就是这样的,这样的更改将无法正常渲染。

如果你需要做一个与预期数据流反方向的修改,就必须触发一个新的变更检测周期,以允许渲染这种变更。这些例子说明了如何安全地做出这些改变。

AfterView 例子展示了 AfterViewInit()AfterViewChecked() 钩子,Angular 会在每次创建了组件的子视图后调用它们。

下面是一个子视图,它用来把英雄的名字显示在一个 <input> 中:

Path:"src/app/ChildComponent" 。

@Component({  selector: 'app-child-view',  template: '<input [(ngModel)]="hero">'})export class ChildViewComponent {  hero = 'Magneta';}

AfterViewComponent 把这个子视图显示在它的模板中:

Path:"src/app/AfterViewComponent (template)" 。

template: `  <div>-- child view begins --</div>    <app-child-view></app-child-view>  <div>-- child view ends --</div>`

下列钩子基于子视图中的每一次数据变更采取行动,它只能通过带@ViewChild装饰器的属性来访问子视图。

Path:"src/app/AfterViewComponent (class excerpts)" 。

export class AfterViewComponent implements  AfterViewChecked, AfterViewInit {  private prevHero = '';  // Query for a VIEW child of type `ChildViewComponent`  @ViewChild(ChildViewComponent) viewChild: ChildViewComponent;  ngAfterViewInit() {    // viewChild is set after the view has been initialized    this.logIt('AfterViewInit');    this.doSomething();  }  ngAfterViewChecked() {    // viewChild is updated after the view has been checked    if (this.prevHero === this.viewChild.hero) {      this.logIt('AfterViewChecked (no change)');    } else {      this.prevHero = this.viewChild.hero;      this.logIt('AfterViewChecked');      this.doSomething();    }  }  // ...}

在更新视图之前等待

在这个例子中,当英雄名字超过 10 个字符时,doSomething() 方法会更新屏幕,但在更新 comment 之前会等一个节拍(tick)。

Path:"src/app/AfterViewComponent (doSomething)" 。

// This surrogate for real business logic sets the `comment`private doSomething() {  let c = this.viewChild.hero.length > 10 ? `That's a long name` : '';  if (c !== this.comment) {    // Wait a tick because the component's view has already been checked    this.logger.tick_then(() => this.comment = c);  }}

在组件的视图合成完之后,就会触发 AfterViewInit()AfterViewChecked() 钩子。如果你修改了这段代码,让这个钩子立即修改该组件的数据绑定属性 comment,你就会发现 Angular 抛出一个错误。

LoggerService.tick_then() 语句把日志的更新工作推迟了一个浏览器 JavaScript 周期,也就触发了一个新的变更检测周期。

编写精简的钩子方法来避免性能问题

当你运行 AfterView 示例时,请注意当没有发生任何需要注意的变化时,Angular 仍然会频繁的调用 AfterViewChecked()。 要非常小心你放到这些方法中的逻辑或计算量。

响应被投影内容的变更

内容投影是从组件外部导入 HTML 内容,并把它插入在组件模板中指定位置上的一种途径。 你可以在目标中通过查找下列结构来认出内容投影。

  • 元素标签中间的 HTML。

  • 组件模板中的 <ng-content> 标签。

这个 AfterContent 例子探索了 AfterContentInit() 和 AfterContentChecked() 钩子。Angular 会在把外部内容投影进该组件时调用它们。

对比前面的 AfterView 例子考虑这个变化。 这次不再通过模板来把子视图包含进来,而是改为从 AfterContentComponent 的父组件中导入它。下面是父组件的模板:

Path:"src/app/AfterContentParentComponent (template excerpt)" 。

`<after-content>   <app-child></app-child> </after-content>`

注意,<app-child> 标签被包含在 <after-content> 标签中。 永远不要在组件标签的内部放任何内容 —— 除非你想把这些内容投影进这个组件中。

现在来看该组件的模板:

Path:"src/app/AfterContentComponent (template)" 。

template: `  <div>-- projected content begins --</div>    <ng-content></ng-content>  <div>-- projected content ends --</div>`

<ng-content> 标签是外来内容的占位符。 它告诉 Angular 在哪里插入这些外来内容。 在这里,被投影进去的内容就是来自父组件的 <app-child> 标签。

使用 AfterContent 钩子

AfterContent 钩子和 AfterView 相似。关键的不同点是子组件的类型不同。

AfterView 钩子所关心的是 ViewChildren,这些子组件的元素标签会出现在该组件的模板里面。

AfterContent 钩子所关心的是 ContentChildren,这些子组件被 Angular 投影进该组件中。

下列 AfterContent 钩子基于子级内容中值的变化而采取相应的行动,它只能通过带有 @ContentChild 装饰器的属性来查询到“子级内容”。

Path:"src/app/AfterContentComponent (class excerpts)" 。

export class AfterContentComponent implements AfterContentChecked, AfterContentInit {  private prevHero = '';  comment = '';  // Query for a CONTENT child of type `ChildComponent`  @ContentChild(ChildComponent) contentChild: ChildComponent;  ngAfterContentInit() {    // contentChild is set after the content has been initialized    this.logIt('AfterContentInit');    this.doSomething();  }  ngAfterContentChecked() {    // contentChild is updated after the content has been checked    if (this.prevHero === this.contentChild.hero) {      this.logIt('AfterContentChecked (no change)');    } else {      this.prevHero = this.contentChild.hero;      this.logIt('AfterContentChecked');      this.doSomething();    }  }  // ...}

&- 不需要等待内容更新

&- 该组件的 doSomething() 方法会立即更新该组件的数据绑定属性 comment。而无需延迟更新以确保正确渲染 。

&- Angular 在调用 AfterView 钩子之前,就已调用完所有的 AfterContent 钩子。 在完成该组件视图的合成之前, Angular 就已经完成了所投影内容的合成工作。 AfterContent... 和 AfterView... 钩子之间有一个小的时间窗,允许你修改宿主视图。

自定义变更检测逻辑

要监控 ngOnChanges() 无法捕获的变更,你可以实现自己的变更检查逻辑,比如 DoCheck 的例子。这个例子展示了你如何使用 ngDoCheck() 钩子来检测和处理 Angular 自己没有捕捉到的变化。

DoCheck 示例使用下面的 ngDoCheck() 钩子扩展了 OnChanges 示例:

Path:"src/app/DoCheckComponent (ngDoCheck)" 。

ngDoCheck() {  if (this.hero.name !== this.oldHeroName) {    this.changeDetected = true;    this.changeLog.push(`DoCheck: Hero name changed to "${this.hero.name}" from "${this.oldHeroName}"`);    this.oldHeroName = this.hero.name;  }  if (this.power !== this.oldPower) {    this.changeDetected = true;    this.changeLog.push(`DoCheck: Power changed to "${this.power}" from "${this.oldPower}"`);    this.oldPower = this.power;  }  if (this.changeDetected) {      this.noChangeCount = 0;  } else {      // log that hook was called when there was no relevant change.      let count = this.noChangeCount += 1;      let noChangeMsg = `DoCheck called ${count}x when no change to hero or power`;      if (count === 1) {        // add new "no change" message        this.changeLog.push(noChangeMsg);      } else {        // update last "no change" message        this.changeLog[this.changeLog.length - 1] = noChangeMsg;      }  }  this.changeDetected = false;}

这段代码会检查某些感兴趣的值,捕获并把它们当前的状态和之前的进行比较。当 heropower 没有实质性变化时,它就会在日志中写一条特殊的信息,这样你就能看到 DoCheck() 被调用的频率。其结果很有启发性。

虽然 ngDoCheck() 钩子可以检测出英雄的 name 何时发生了变化,但却非常昂贵。无论变化发生在何处,每个变化检测周期都会以很大的频率调用这个钩子。在用户可以执行任何操作之前,本例中已经调用了20多次。

这些初始化检查大部分都是由 Angular 首次在页面的其它地方渲染不相关的数据触发的。只要把光标移动到另一个 <input> 就会触发一次调用。其中的少数调用揭示了相关数据的实际变化情况。如果使用这个钩子,那么你的实现必须非常轻量级,否则会损害用户体验。

通过输入型绑定把数据从父组件传到子组件

HeroChildComponent 有两个输入型属性,它们通常带 @Input 装饰器。

Path:"component-interaction/src/app/hero-child.component.ts" 。

import { Component, Input } from '@angular/core';import { Hero } from './hero';@Component({  selector: 'app-hero-child',  template: `    <h3>{{hero.name}} says:</h3>    <p>I, {{hero.name}}, am at your service, {{masterName}}.</p>  `})export class HeroChildComponent {  @Input() hero: Hero;  @Input('master') masterName: string;}

第二个 @Input 为子组件的属性名 masterName 指定一个别名 master(译者注:不推荐为起别名,请参见风格指南).

父组件 HeroParentComponent 把子组件的 HeroChildComponent 放到 *ngFor 循环器中,把自己的 master 字符串属性绑定到子组件的 master 别名上,并把每个循环的 hero 实例绑定到子组件的 hero 属性。

Path:"component-interaction/src/app/hero-parent.component.ts" 。

import { Component } from '@angular/core';import { HEROES } from './hero';@Component({  selector: 'app-hero-parent',  template: `    <h2>{{master}} controls {{heroes.length}} heroes</h2>    <app-hero-child *ngFor="let hero of heroes"      [hero]="hero"      [master]="master">    </app-hero-child>  `})export class HeroParentComponent {  heroes = HEROES;  master = 'Master';}

运行应用程序会显示三个英雄:

端到端测试,用于确保所有的子组件都如预期般初始化并显示出来:

Path:"component-interaction/e2e/src/app.e2e-spec.ts" 。

// ...let _heroNames = ['Dr IQ', 'Magneta', 'Bombasto'];let _masterName = 'Master';it('should pass properties to children properly', function () {  let parent = element.all(by.tagName('app-hero-parent')).get(0);  let heroes = parent.all(by.tagName('app-hero-child'));  for (let i = 0; i < _heroNames.length; i++) {    let childTitle = heroes.get(i).element(by.tagName('h3')).getText();    let childDetail = heroes.get(i).element(by.tagName('p')).getText();    expect(childTitle).toEqual(_heroNames[i] + ' says:');    expect(childDetail).toContain(_masterName);  }});// ...

通过 setter 截听输入属性值的变化

使用一个输入属性的 setter,以拦截父组件中值的变化,并采取行动。

子组件 NameChildComponent 的输入属性 name 上的这个 setter,会 trim 掉名字里的空格,并把空值替换成默认字符串。

Path:"component-interaction/src/app/name-child.component.ts" 。

import { Component, Input } from '@angular/core';@Component({  selector: 'app-name-child',  template: '<h3>"{{name}}"</h3>'})export class NameChildComponent {  private _name = '';  @Input()  set name(name: string) {    this._name = (name && name.trim()) || '<no name set>';  }  get name(): string { return this._name; }}

下面的 NameParentComponent 展示了各种名字的处理方式,包括一个全是空格的名字。

Path:"component-interaction/src/app/name-parent.component.ts" 。

import { Component } from '@angular/core';@Component({  selector: 'app-name-parent',  template: `  <h2>Master controls {{names.length}} names</h2>  <app-name-child *ngFor="let name of names" [name]="name"></app-name-child>  `})export class NameParentComponent {  // Displays 'Dr IQ', '<no name set>', 'Bombasto'  names = ['Dr IQ', '   ', '  Bombasto  '];}

端到端测试:输入属性的 setter,分别使用空名字和非空名字。

Path:"component-interaction/e2e/src/app.e2e-spec.ts" 。

// ...it('should display trimmed, non-empty names', function () {  let _nonEmptyNameIndex = 0;  let _nonEmptyName = '"Dr IQ"';  let parent = element.all(by.tagName('app-name-parent')).get(0);  let hero = parent.all(by.tagName('app-name-child')).get(_nonEmptyNameIndex);  let displayName = hero.element(by.tagName('h3')).getText();  expect(displayName).toEqual(_nonEmptyName);});it('should replace empty name with default name', function () {  let _emptyNameIndex = 1;  let _defaultName = '"<no name set>"';  let parent = element.all(by.tagName('app-name-parent')).get(0);  let hero = parent.all(by.tagName('app-name-child')).get(_emptyNameIndex);  let displayName = hero.element(by.tagName('h3')).getText();  expect(displayName).toEqual(_defaultName);});// ...

通过ngOnChanges()来截听输入属性值的变化

使用 OnChanges 生命周期钩子接口的 ngOnChanges() 方法来监测输入属性值的变化并做出回应。

&当需要监视多个、交互式输入属性的时候,本方法比用属性的 setter 更合适。

这个 VersionChildComponent 会监测输入属性 majorminor 的变化,并把这些变化编写成日志以报告这些变化。

Path:"component-interaction/src/app/version-child.component.ts" 。

import { Component, Input, OnChanges, SimpleChange } from '@angular/core';@Component({  selector: 'app-version-child',  template: `    <h3>Version {{major}}.{{minor}}</h3>    <h4>Change log:</h4>    <ul>      <li *ngFor="let change of changeLog">{{change}}</li>    </ul>  `})export class VersionChildComponent implements OnChanges {  @Input() major: number;  @Input() minor: number;  changeLog: string[] = [];  ngOnChanges(changes: {[propKey: string]: SimpleChange}) {    let log: string[] = [];    for (let propName in changes) {      let changedProp = changes[propName];      let to = JSON.stringify(changedProp.currentValue);      if (changedProp.isFirstChange()) {        log.push(`Initial value of ${propName} set to ${to}`);      } else {        let from = JSON.stringify(changedProp.previousValue);        log.push(`${propName} changed from ${from} to ${to}`);      }    }    this.changeLog.push(log.join(', '));  }}

VersionParentComponent 提供 minormajor 值,把修改它们值的方法绑定到按钮上。

Path:"component-interaction/src/app/version-parent.component.ts" 。

import { Component } from '@angular/core';@Component({  selector: 'app-version-parent',  template: `    <h2>Source code version</h2>    <button (click)="newMinor()">New minor version</button>    <button (click)="newMajor()">New major version</button>    <app-version-child [major]="major" [minor]="minor"></app-version-child>  `})export class VersionParentComponent {  major = 1;  minor = 23;  newMinor() {    this.minor++;  }  newMajor() {    this.major++;    this.minor = 0;  }}

下面是点击按钮的结果。

测试确保这两个输入属性值都被初始化了,当点击按钮后,ngOnChanges 应该被调用,属性的值也符合预期。

Path:"component-interaction/e2e/src/app.e2e-spec.ts" 。

// ...// Test must all execute in this exact orderit('should set expected initial values', function () {  let actual = getActual();  let initialLabel = 'Version 1.23';  let initialLog = 'Initial value of major set to 1, Initial value of minor set to 23';  expect(actual.label).toBe(initialLabel);  expect(actual.count).toBe(1);  expect(actual.logs.get(0).getText()).toBe(initialLog);});it('should set expected values after clicking 'Minor' twice', function () {  let repoTag = element(by.tagName('app-version-parent'));  let newMinorButton = repoTag.all(by.tagName('button')).get(0);  newMinorButton.click().then(function() {    newMinorButton.click().then(function() {      let actual = getActual();      let labelAfter2Minor = 'Version 1.25';      let logAfter2Minor = 'minor changed from 24 to 25';      expect(actual.label).toBe(labelAfter2Minor);      expect(actual.count).toBe(3);      expect(actual.logs.get(2).getText()).toBe(logAfter2Minor);    });  });});it('should set expected values after clicking 'Major' once', function () {  let repoTag = element(by.tagName('app-version-parent'));  let newMajorButton = repoTag.all(by.tagName('button')).get(1);  newMajorButton.click().then(function() {    let actual = getActual();    let labelAfterMajor = 'Version 2.0';    let logAfterMajor = 'major changed from 1 to 2, minor changed from 25 to 0';    expect(actual.label).toBe(labelAfterMajor);    expect(actual.count).toBe(4);    expect(actual.logs.get(3).getText()).toBe(logAfterMajor);  });});function getActual() {  let versionTag = element(by.tagName('app-version-child'));  let label = versionTag.element(by.tagName('h3')).getText();  let ul = versionTag.element((by.tagName('ul')));  let logs = ul.all(by.tagName('li'));  return {    label: label,    logs: logs,    count: logs.count()  };}// ...

父组件监听子组件的事件

子组件暴露一个 EventEmitter 属性,当事件发生时,子组件利用该属性 emits(向上弹射)事件。父组件绑定到这个事件属性,并在事件发生时作出回应。

子组件的 EventEmitter 属性是一个输出属性,通常带有 @Output 装饰器,就像在 VoterComponent 中看到的。

Path:"component-interaction/src/app/voter.component.ts" 。

import { Component, EventEmitter, Input, Output } from '@angular/core';@Component({  selector: 'app-voter',  template: `    <h4>{{name}}</h4>    <button (click)="vote(true)"  [disabled]="didVote">Agree</button>    <button (click)="vote(false)" [disabled]="didVote">Disagree</button>  `})export class VoterComponent {  @Input()  name: string;  @Output() voted = new EventEmitter<boolean>();  didVote = false;  vote(agreed: boolean) {    this.voted.emit(agreed);    this.didVote = true;  }}

点击按钮会触发 truefalse (布尔型有效载荷)的事件。

父组件 VoteTakerComponent 绑定了一个事件处理器(onVoted()),用来响应子组件的事件($event)并更新一个计数器。

Path:"component-interaction/src/app/votetaker.component.ts" 。

import { Component }      from '@angular/core';@Component({  selector: 'app-vote-taker',  template: `    <h2>Should mankind colonize the Universe?</h2>    <h3>Agree: {{agreed}}, Disagree: {{disagreed}}</h3>    <app-voter *ngFor="let voter of voters"      [name]="voter"      (voted)="onVoted($event)">    </app-voter>  `})export class VoteTakerComponent {  agreed = 0;  disagreed = 0;  voters = ['Narco', 'Celeritas', 'Bombasto'];  onVoted(agreed: boolean) {    agreed ? this.agreed++ : this.disagreed++;  }}

本框架把事件参数(用 $event 表示)传给事件处理方法,该方法会处理它:

测试确保点击 AgreeDisagree 按钮时,计数器被正确更新。

Path:"component-interaction/e2e/src/app.e2e-spec.ts" 。

// ...it('should not emit the event initially', function () {  let voteLabel = element(by.tagName('app-vote-taker'))    .element(by.tagName('h3')).getText();  expect(voteLabel).toBe('Agree: 0, Disagree: 0');});it('should process Agree vote', function () {  let agreeButton1 = element.all(by.tagName('app-voter')).get(0)    .all(by.tagName('button')).get(0);  agreeButton1.click().then(function() {    let voteLabel = element(by.tagName('app-vote-taker'))      .element(by.tagName('h3')).getText();    expect(voteLabel).toBe('Agree: 1, Disagree: 0');  });});it('should process Disagree vote', function () {  let agreeButton1 = element.all(by.tagName('app-voter')).get(1)    .all(by.tagName('button')).get(1);  agreeButton1.click().then(function() {    let voteLabel = element(by.tagName('app-vote-taker'))      .element(by.tagName('h3')).getText();    expect(voteLabel).toBe('Agree: 1, Disagree: 1');  });});// ...

父组件与子组件通过本地变量互动

父组件不能使用数据绑定来读取子组件的属性或调用子组件的方法。但可以在父组件模板里,新建一个本地变量来代表子组件,然后利用这个变量来读取子组件的属性和调用子组件的方法,如下例所示。

子组件 CountdownTimerComponent 进行倒计时,归零时发射一个导弹。startstop 方法负责控制时钟并在模板里显示倒计时的状态信息。

Path:"component-interaction/src/app/countdown-timer.component.ts" 。

import { Component, OnDestroy, OnInit } from '@angular/core';@Component({  selector: 'app-countdown-timer',  template: '<p>{{message}}</p>'})export class CountdownTimerComponent implements OnInit, OnDestroy {  intervalId = 0;  message = '';  seconds = 11;  clearTimer() { clearInterval(this.intervalId); }  ngOnInit()    { this.start(); }  ngOnDestroy() { this.clearTimer(); }  start() { this.countDown(); }  stop()  {    this.clearTimer();    this.message = `Holding at T-${this.seconds} seconds`;  }  private countDown() {    this.clearTimer();    this.intervalId = window.setInterval(() => {      this.seconds -= 1;      if (this.seconds === 0) {        this.message = 'Blast off!';      } else {        if (this.seconds < 0) { this.seconds = 10; } // reset        this.message = `T-${this.seconds} seconds and counting`;      }    }, 1000);  }}

计时器组件的宿主组件 CountdownLocalVarParentComponent 如下:

Path:"component-interaction/src/app/countdown-parent.component.ts" 。

import { Component }                from '@angular/core';import { CountdownTimerComponent }  from './countdown-timer.component';@Component({  selector: 'app-countdown-parent-lv',  template: `  <h3>Countdown to Liftoff (via local variable)</h3>  <button (click)="timer.start()">Start</button>  <button (click)="timer.stop()">Stop</button>  <div class="seconds">{{timer.seconds}}</div>  <app-countdown-timer #timer></app-countdown-timer>  `,  styleUrls: ['../assets/demo.css']})export class CountdownLocalVarParentComponent { }

父组件不能通过数据绑定使用子组件的 start 和 stop 方法,也不能访问子组件的 seconds 属性。

把本地变量(#timer)放到(<countdown-timer>)标签中,用来代表子组件。这样父组件的模板就得到了子组件的引用,于是可以在父组件的模板中访问子组件的所有属性和方法。

这个例子把父组件的按钮绑定到子组件的 start 和 stop 方法,并用插值来显示子组件的 seconds 属性。

下面是父组件和子组件一起工作时的效果。

测试确保在父组件模板中显示的秒数和子组件状态信息里的秒数同步。它还会点击 Stop 按钮来停止倒计时:

Path:"component-interaction/e2e/src/app.e2e-spec.ts" 。

// ...it('timer and parent seconds should match', function () {  let parent = element(by.tagName(parentTag));  let message = parent.element(by.tagName('app-countdown-timer')).getText();  browser.sleep(10); // give `seconds` a chance to catchup with `message`  let seconds = parent.element(by.className('seconds')).getText();  expect(message).toContain(seconds);});it('should stop the countdown', function () {  let parent = element(by.tagName(parentTag));  let stopButton = parent.all(by.tagName('button')).get(1);  stopButton.click().then(function() {    let message = parent.element(by.tagName('app-countdown-timer')).getText();    expect(message).toContain('Holding');  });});// ...

父组件调用@ViewChild()

这个本地变量方法是个简单便利的方法。但是它也有局限性,因为父组件-子组件的连接必须全部在父组件的模板中进行。父组件本身的代码对子组件没有访问权。

如果父组件的类需要读取子组件的属性值或调用子组件的方法,就不能使用本地变量方法。

当父组件类需要这种访问时,可以把子组件作为 ViewChild ,注入到父组件里面。

下面的例子用与倒计时相同的范例来解释这种技术。 它的外观或行为没有变化。子组件 CountdownTimerComponent 也和原来一样。

注:
- 由本地变量切换到 ViewChild 技术的唯一目的就是做示范。

下面是父组件 CountdownViewChildParentComponent:

Path:"component-interaction/src/app/countdown-parent.component.ts" 。

import { AfterViewInit, ViewChild } from '@angular/core';import { Component }                from '@angular/core';import { CountdownTimerComponent }  from './countdown-timer.component';@Component({  selector: 'app-countdown-parent-vc',  template: `  <h3>Countdown to Liftoff (via ViewChild)</h3>  <button (click)="start()">Start</button>  <button (click)="stop()">Stop</button>  <div class="seconds">{{ seconds() }}</div>  <app-countdown-timer></app-countdown-timer>  `,  styleUrls: ['../assets/demo.css']})export class CountdownViewChildParentComponent implements AfterViewInit {  @ViewChild(CountdownTimerComponent)  private timerComponent: CountdownTimerComponent;  seconds() { return 0; }  ngAfterViewInit() {    // Redefine `seconds()` to get from the `CountdownTimerComponent.seconds` ...    // but wait a tick first to avoid one-time devMode    // unidirectional-data-flow-violation error    setTimeout(() => this.seconds = () => this.timerComponent.seconds, 0);  }  start() { this.timerComponent.start(); }  stop() { this.timerComponent.stop(); }}

把子组件的视图插入到父组件类需要做一点额外的工作。

首先,你必须导入对装饰器 ViewChild 以及生命周期钩子 AfterViewInit 的引用。

接着,通过 @ViewChild 属性装饰器,将子组件 CountdownTimerComponent 注入到私有属性 timerComponent 里面。

组件元数据里就不再需要 #timer 本地变量了。而是把按钮绑定到父组件自己的 startstop 方法,使用父组件的 seconds 方法的插值来展示秒数变化。

这些方法可以直接访问被注入的计时器组件。

ngAfterViewInit() 生命周期钩子是非常重要的一步。被注入的计时器组件只有在 Angular 显示了父组件视图之后才能访问,所以它先把秒数显示为 0.

然后 Angular 会调用 ngAfterViewInit 生命周期钩子,但这时候再更新父组件视图的倒计时就已经太晚了。Angular 的单向数据流规则会阻止在同一个周期内更新父组件视图。应用在显示秒数之前会被迫再等一轮。

使用 setTimeout() 来等下一轮,然后改写 seconds() 方法,这样它接下来就会从注入的这个计时器组件里获取秒数的值。

注:
- 可以使用和之前一样的倒计时测试,此处不再重复操作。

父组件和子组件通过服务来通讯

父组件和它的子组件共享同一个服务,利用该服务在组件家族内部实现双向通讯。

该服务实例的作用域被限制在父组件和其子组件内。这个组件子树之外的组件将无法访问该服务或者与它们通讯。

这个 MissionServiceMissionControlComponent 和多个 AstronautComponent 子组件连接起来。

Path:"component-interaction/src/app/mission.service.ts" 。

import { Injectable } from '@angular/core';import { Subject }    from 'rxjs';@Injectable()export class MissionService {  // Observable string sources  private missionAnnouncedSource = new Subject<string>();  private missionConfirmedSource = new Subject<string>();  // Observable string streams  missionAnnounced$ = this.missionAnnouncedSource.asObservable();  missionConfirmed$ = this.missionConfirmedSource.asObservable();  // Service message commands  announceMission(mission: string) {    this.missionAnnouncedSource.next(mission);  }  confirmMission(astronaut: string) {    this.missionConfirmedSource.next(astronaut);  }}

MissionControlComponent 提供服务的实例,并将其共享给它的子组件(通过 providers 元数据数组),子组件可以通过构造函数将该实例注入到自身。

Path:"component-interaction/src/app/missioncontrol.component.ts" 。

import { Component }          from '@angular/core';import { MissionService }     from './mission.service';@Component({  selector: 'app-mission-control',  template: `    <h2>Mission Control</h2>    <button (click)="announce()">Announce mission</button>    <app-astronaut *ngFor="let astronaut of astronauts"      [astronaut]="astronaut">    </app-astronaut>    <h3>History</h3>    <ul>      <li *ngFor="let event of history">{{event}}</li>    </ul>  `,  providers: [MissionService]})export class MissionControlComponent {  astronauts = ['Lovell', 'Swigert', 'Haise'];  history: string[] = [];  missions = ['Fly to the moon!',              'Fly to mars!',              'Fly to Vegas!'];  nextMission = 0;  constructor(private missionService: MissionService) {    missionService.missionConfirmed$.subscribe(      astronaut => {        this.history.push(`${astronaut} confirmed the mission`);      });  }  announce() {    let mission = this.missions[this.nextMission++];    this.missionService.announceMission(mission);    this.history.push(`Mission "${mission}" announced`);    if (this.nextMission >= this.missions.length) { this.nextMission = 0; }  }}

AstronautComponent 也通过自己的构造函数注入该服务。由于每个 AstronautComponent 都是 MissionControlComponent 的子组件,所以它们获取到的也是父组件的这个服务实例。

Path:"component-interaction/src/app/astronaut.component.ts" 。

import { Component, Input, OnDestroy } from '@angular/core';import { MissionService } from './mission.service';import { Subscription }   from 'rxjs';@Component({  selector: 'app-astronaut',  template: `    <p>      {{astronaut}}: <strong>{{mission}}</strong>      <button        (click)="confirm()"        [disabled]="!announced || confirmed">        Confirm      </button>    </p>  `})export class AstronautComponent implements OnDestroy {  @Input() astronaut: string;  mission = '<no mission announced>';  confirmed = false;  announced = false;  subscription: Subscription;  constructor(private missionService: MissionService) {    this.subscription = missionService.missionAnnounced$.subscribe(      mission => {        this.mission = mission;        this.announced = true;        this.confirmed = false;    });  }  confirm() {    this.confirmed = true;    this.missionService.confirmMission(this.astronaut);  }  ngOnDestroy() {    // prevent memory leak when component destroyed    this.subscription.unsubscribe();  }}

注:
- 这个例子保存了 subscription 变量,并在 AstronautComponent 被销毁时调用 unsubscribe() 退订。 这是一个用于防止内存泄漏的保护措施。实际上,在这个应用程序中并没有这个风险,因为 AstronautComponent 的生命期和应用程序的生命期一样长。但在更复杂的应用程序环境中就不一定了。

  • 不需要在 MissionControlComponent 中添加这个保护措施,因为它作为父组件,控制着 MissionService 的生命期。

History 日志证明了:在父组件 MissionControlComponent 和子组件 AstronautComponent 之间,信息通过该服务实现了双向传递。

测试确保点击父组件 MissionControlComponent 和子组件 AstronautComponent 两个的组件的按钮时,History 日志和预期的一样。

Path:"component-interaction/e2e/src/app.e2e-spec.ts" 。

// ...it('should announce a mission', function () {  let missionControl = element(by.tagName('app-mission-control'));  let announceButton = missionControl.all(by.tagName('button')).get(0);  announceButton.click().then(function () {    let history = missionControl.all(by.tagName('li'));    expect(history.count()).toBe(1);    expect(history.get(0).getText()).toMatch(/Mission.* announced/);  });});it('should confirm the mission by Lovell', function () {  testConfirmMission(1, 2, 'Lovell');});it('should confirm the mission by Haise', function () {  testConfirmMission(3, 3, 'Haise');});it('should confirm the mission by Swigert', function () {  testConfirmMission(2, 4, 'Swigert');});function testConfirmMission(buttonIndex: number, expectedLogCount: number, astronaut: string) {  let _confirmedLog = ' confirmed the mission';  let missionControl = element(by.tagName('app-mission-control'));  let confirmButton = missionControl.all(by.tagName('button')).get(buttonIndex);  confirmButton.click().then(function () {    let history = missionControl.all(by.tagName('li'));    expect(history.count()).toBe(expectedLogCount);    expect(history.get(expectedLogCount - 1).getText()).toBe(astronaut + _confirmedLog);  });}// ...

Angular 应用使用标准的 CSS 来设置样式。这意味着你可以把关于 CSS 的那些知识和技能直接用于 Angular 程序中,例如:样式表、选择器、规则以及媒体查询等。

另外,Angular 还能把组件样式捆绑在组件上,以实现比标准样式表更加模块化的设计。

使用组件样式

对你编写的每个 Angular 组件来说,除了定义 HTML 模板之外,还要定义用于模板的 CSS 样式、 指定任意的选择器、规则和媒体查询。

实现方式之一,是在组件的元数据中设置 styles 属性。 styles 属性可以接受一个包含 CSS 代码的字符串数组。 通常你只给它一个字符串就行了,如同下例:

Path:"src/app/hero-app.component.ts" 。

@Component({  selector: 'app-root',  template: `    <h1>Tour of Heroes</h1>    <app-hero-main [hero]="hero"></app-hero-main>  `,  styles: ['h1 { font-weight: normal; }']})export class HeroAppComponent {/* . . . */}

范围化的样式

它们既不会被模板中嵌入的组件继承,也不会被通过内容投影(如 ng-content)嵌进来的组件继承。

在这个例子中,h1 的样式只对 HeroAppComponent 生效,既不会作用于内嵌的 HeroMainComponent,也不会作用于应用中其它任何地方的 <h1> 标签。

这种范围限制就是所谓的样式模块化特性

  • 可以使用对每个组件最有意义的 CSS 类名和选择器。

  • 类名和选择器是局限于该组件的,它不会和应用中其它地方的类名和选择器冲突。

  • 组件的样式不会因为别的地方修改了样式而被意外改变。

  • 你可以让每个组件的 CSS 代码和它的 TypeScript、HTML 代码放在一起,这将促成清爽整洁的项目结构。

  • 将来你可以修改或移除组件的 CSS 代码,而不用遍历整个应用来看它有没有在别处用到。

注:
- 在 @Component 的元数据中指定的样式只会对该组件的模板生效。

特殊的选择器

组件样式中有一些从影子(Shadow) DOM 样式范围领域引入的特殊选择器:

:host

使用 :host 伪类选择器,用来选择组件宿主元素中的元素(相对于组件模板内部的元素)。

Path:"src/app/hero-details.component.css" 。

:host {  display: block;  border: 1px solid black;}

:host 选择是是把宿主元素作为目标的唯一方式。除此之外,你将没办法指定它, 因为宿主不是组件自身模板的一部分,而是父组件模板的一部分。

要把宿主样式作为条件,就要像函数一样把其它选择器放在 :host 后面的括号中。

下一个例子再次把宿主元素作为目标,但是只有当它同时带有 active CSS 类的时候才会生效。

Path:"src/app/hero-details.component.css" 。

:host(.active) {  border-width: 3px;}

:host-context

有时候,基于某些来自组件视图外部的条件应用样式是很有用的。 例如,在文档的 <body> 元素上可能有一个用于表示样式主题 (theme) 的 CSS 类,你应当基于它来决定组件的样式。

这时可以使用 :host-context() 伪类选择器。它也以类似 :host() 形式使用。它在当前组件宿主元素的祖先节点中查找 CSS 类, 直到文档的根节点为止。在与其它选择器组合使用时,它非常有用。

在下面的例子中,只有当某个祖先元素有 CSS 类 theme-light 时,才会把 background-color 样式应用到组件内部的所有 <h2> 元素中。

Path:"src/app/hero-details.component.css" 。

:host-context(.theme-light) h2 {  background-color: #eef;}

已废弃 /deep/、>>> 和 ::ng-deep

组件样式通常只会作用于组件自身的 HTML 上。

把伪类 ::ng-deep 应用到任何一条 CSS 规则上就会完全禁止对那条规则的视图包装。任何带有 ::ng-deep 的样式都会变成全局样式。为了把指定的样式限定在当前组件及其下级组件中,请确保在 ::ng-deep 之前带上 :host 选择器。如果 ::ng-deep 组合器在 :host 伪类之外使用,该样式就会污染其它组件。

这个例子以所有的 <h3> 元素为目标,从宿主元素到当前元素再到 DOM 中的所有子元素:

Path:"src/app/hero-details.component.css" 。

:host /deep/ h3 {  font-style: italic;}

/deep/ 组合器还有两个别名:>>>::ng-deep

注:
- /deep/>>> 选择器只能被用在仿真 (emulated) 模式下。 这种方式是默认值,也是用得最多的方式。

  • CSS 标准中用于 "刺穿 Shadow DOM" 的组合器已经被废弃,并将这个特性从主流浏览器和工具中移除。 因此,Angular 也将会移除对它们的支持(包括 /deep/&&&::ng-deep)。 目前,建议先统一使用 ::ng-deep,以便兼容将来的工具。

把样式加载进组件中

有几种方式把样式加入组件:

  • 设置 stylesstyleUrls 元数据

  • 内联在模板的 HTML 中

  • 通过 CSS 文件导入

上述作用域规则对所有这些加载模式都适用。

元数据中的样式

你可以给 @Component 装饰器添加一个 styles 数组型属性。

这个数组中的每一个字符串(通常也只有一个)定义一份 CSS。

Path:"src/app/hero-app.component.ts (CSS inline)" 。

@Component({  selector: 'app-root',  template: `    <h1>Tour of Heroes</h1>    <app-hero-main [hero]="hero"></app-hero-main>  `,  styles: ['h1 { font-weight: normal; }']})export class HeroAppComponent {/* . . . */}

注:
- 这些样式只对当前组件生效。 它们既不会作用于模板中嵌入的任何组件,也不会作用于投影进来的组件(如 ng-content )。

当使用 --inline-styles 标识创建组件时,Angular CLI 的 ng generate component 命令就会定义一个空的 styles 数组。

ng generate component hero-app --inline-style

组件元数据中的样式文件

你可以通过把外部 CSS 文件添加到 @ComponentstyleUrls 属性中来加载外部样式。

  1. Path:"src/app/hero-app.component.ts (CSS in file)" 。

    @Component({      selector: 'app-root',      template: `        <h1>Tour of Heroes</h1>        <app-hero-main [hero]="hero"></app-hero-main>      `,      styleUrls: ['./hero-app.component.css']    })    export class HeroAppComponent {    /* . . . */    }

  1. Path:"src/app/hero-app.component.css" 。

    h1 {      font-weight: normal;    }

注:
- 这些样式只对当前组件生效。 它们既不会作用于模板中嵌入的任何组件,也不会作用于投影进来的组件(如 ng-content )。

  • 你可以指定多个样式文件,甚至可以组合使用 stylestyleUrls 方式。

当你使用 Angular CLI 的 ng generate component 命令但不带 --inline-style 标志时,CLI 会为你创建一个空白的样式表文件,并且在所生成组件的 styleUrls 中引用该文件。

ng generate component hero-app

模板内联样式

你也可以直接在组件的 HTML 模板中写 <style> 标签来内嵌 CSS 样式。

Path:"src/app/hero-controls.component.ts" 。

@Component({  selector: 'app-hero-controls',  template: `    <style>      button {        background-color: white;        border: 1px solid #777;      }    </style>    <h3>Controls</h3>    <button (click)="activate()">Activate</button>  `})

模板中的 link 标签

你也可以在组件的 HTML 模板中写 <link> 标签。

Path:"src/app/hero-team.component.ts" 。

@Component({  selector: 'app-hero-team',  template: `    <!-- We must use a relative URL so that the AOT compiler can find the stylesheet -->    <link rel="stylesheet" href="../assets/hero-team.component.css">    <h3>Team</h3>    <ul>      <li *ngFor="let member of hero.team">        {{member}}      </li>    </ul>`})

注:
- 当使用 CLI 进行构建时,要确保这个链接到的样式表文件被复制到了服务器上。

  • 只要引用过,CLI 就会计入这个样式表,无论这个 link 标签的 href 指向的 URL 是相对于应用根目录的还是相对于组件文件的。

CSS @imports 语法

你还可以利用标准的 CSS @import 规则来把其它 CSS 文件导入到 CSS 文件中。

在这种情况下,URL 是相对于你正在导入的 CSS 文件的。

Path:"src/app/hero-details.component.css (excerpt)" 。

/* The AOT compiler needs the `./` to show that this is local */@import './hero-details-box.css';

外部以及全局样式文件

当使用 CLI 进行构建时,你必须配置 "angular.json" 文件,使其包含所有外部资源(包括外部的样式表文件)。

在它的 styles 区注册这些全局样式文件,默认情况下,它会有一个预先配置的全局 "styles.css" 文件。

非 CSS 样式文件

如果使用 CLI 进行构建,那么你可以用 sasslessstylus 来编写样式,并使用相应的扩展名(.scss.less.styl)把它们指定到 @Component.styleUrls 元数据中。例子如下:

@Component({  selector: 'app-root',  templateUrl: './app.component.html',  styleUrls: ['./app.component.scss']})...

CLI 的构建过程会运行相关的预处理器。

当使用 ng generate component 命令生成组件文件时,CLI 会默认生成一个空白的 CSS 样式文件(.css)。 你可以配置 CLI,让它默认使用你喜欢的 CSS 预处理器。

注:
- 添加到 @Component.styles 数组中的字符串必须写成 CSS,因为 CLI 没法对这些内联的样式使用任何 CSS 预处理器。

视图封装模式

像上面讨论过的一样,组件的 CSS 样式被封装进了自己的视图中,而不会影响到应用程序的其它部分。

通过在组件的元数据上设置视图封装模式,你可以分别控制每个组件的封装模式。 可选的封装模式一共有如下几种:

  • ShadowDom 模式使用浏览器原生的 Shadow DOM 实现(参见 MDN 上的 Shadow DOM)来为组件的宿主元素附加一个 Shadow DOM。组件的视图被附加到这个 Shadow DOM 中,组件的样式也被包含在这个 Shadow DOM 中。(译注:不进不出,没有样式能进来,组件样式出不去。)

  • Native 视图包装模式使用浏览器原生 Shadow DOM 的一个废弃实现 —— 参见变化详情。

  • Emulated 模式(默认值)通过预处理(并改名)CSS 代码来模拟 Shadow DOM 的行为,以达到把 CSS 样式局限在组件视图中的目的。 更多信息,见附录 1。(译注:只进不出,全局样式能进来,组件样式出不去)

  • None 意味着 Angular 不使用视图封装。 Angular 会把 CSS 添加到全局样式中。而不会应用上前面讨论过的那些作用域规则、隔离和保护等。 从本质上来说,这跟把组件的样式直接放进 HTML 是一样的。(译注:能进能出。)

通过组件元数据中的 encapsulation 属性来设置组件封装模式:

Path:"src/app/quest-summary.component.ts" 。

// warning: few browsers support shadow DOM encapsulation at this timeencapsulation: ViewEncapsulation.Native

ShadowDom 模式只适用于提供了原生 Shadow DOM 支持的浏览器(参见 Can I use 上的 Shadow DOM v1 部分)。 它仍然受到很多限制,这就是为什么仿真 (Emulated) 模式是默认选项,并建议将其用于大多数情况。

查看生成的 CSS

当使用默认的仿真模式时,Angular 会对组件的所有样式进行预处理,让它们模仿出标准的 Shadow CSS 作用域规则。

在启用了仿真模式的 Angular 应用的 DOM 树中,每个 DOM 元素都被加上了一些额外的属性。

<hero-details _nghost-pmm-5>  <h2 _ngcontent-pmm-5>Mister Fantastic</h2>  <hero-team _ngcontent-pmm-5 _nghost-pmm-6>    <h3 _ngcontent-pmm-6>Team</h3>  </hero-team></hero-detail>

生成出的属性分为两种:

一个元素在原生封装方式下可能是 Shadow DOM 的宿主,在这里被自动添加上一个 _nghost 属性。 这是组件宿主元素的典型情况。

组件视图中的每一个元素,都有一个 _ngcontent 属性,它会标记出该元素属于哪个宿主的模拟 Shadow DOM。

这些属性的具体值并不重要。它们是自动生成的,并且你永远不会在程序代码中直接引用到它们。 但它们会作为生成的组件样式的目标,就像 DOM 的 <head> 中一样:

[_nghost-pmm-5] {  display: block;  border: 1px solid black;}h3[_ngcontent-pmm-6] {  background-color: white;  border: 1px solid #777;}

这些就是那些样式被处理后的结果,每个选择器都被增加了 _nghost_ngcontent 属性选择器。 这些额外的选择器实现了本文所描述的这些作用域规则。

&本节讲的是一个用于显示广告的范例,而部分广告拦截器插件,比如 Chrome 的 AdGuard,可能会破坏其工作逻辑,因此,请在本页关闭那些插件。

组件的模板不会永远是固定的。应用可能会需要在运行期间加载一些新的组件。

动态组件加载

下面的例子展示了如何构建动态广告条。

英雄管理局正在计划一个广告活动,要在广告条中显示一系列不同的广告。几个不同的小组可能会频繁加入新的广告组件。 再用只支持静态组件结构的模板显然是不现实的。

你需要一种新的组件加载方式,它不需要在广告条组件的模板中引用固定的组件。

Angular 自带的 API 就能支持动态加载组件。

指令

在添加组件之前,先要定义一个锚点来告诉 Angular 要把组件插入到什么地方。

广告条使用一个名叫 AdDirective 的辅助指令来在模板中标记出有效的插入点。

Path:"src/app/ad.directive.ts" 。

import { Directive, ViewContainerRef } from '@angular/core';@Directive({  selector: '[ad-host]',})export class AdDirective {  constructor(public viewContainerRef: ViewContainerRef) { }}

AdDirective 注入了 ViewContainerRef 来获取对容器视图的访问权,这个容器就是那些动态加入的组件的宿主。

@Directive 装饰器中,要注意选择器的名称:ad-host,它就是你将应用到元素上的指令。

加载组件

广告条的大部分实现代码都在 "ad-banner.component.ts" 中。 为了让这个例子简单点,HTML 被直接放在了 @Component 装饰器的 template 属性中。

<ng-template> 元素就是刚才制作的指令将应用到的地方。 要应用 AdDirective,回忆一下来自 "ad.directive.ts" 的选择器 ad-host。把它应用到 <ng-template>(不用带方括号)。 这下,Angular 就知道该把组件动态加载到哪里了。

Path:"src/app/ad-banner.component.ts (template)" 。

template: `            <div class="ad-banner-example">              <h3>Advertisements</h3>              <ng-template ad-host></ng-template>            </div>          `

<ng-template> 元素是动态加载组件的最佳选择,因为它不会渲染任何额外的输出。

解析组件

深入看看 "ad-banner.component.ts" 中的方法。

AdBannerComponent 接收一个 AdItem 对象的数组作为输入,它最终来自 AdServiceAdItem 对象指定要加载的组件类,以及绑定到该组件上的任意数据。 AdService 可以返回广告活动中的那些广告。

AdBannerComponent 传入一个组件数组可以在模板中放入一个广告的动态列表,而不用写死在模板中。

通过 getAds() 方法,AdBannerComponent 可以循环遍历 AdItems 的数组,并且每三秒调用一次 loadComponent() 来加载新组件。

Path:"src/app/ad-banner.component.ts (excerpt)" 。

export class AdBannerComponent implements OnInit, OnDestroy {  @Input() ads: AdItem[];  currentAdIndex = -1;  @ViewChild(AdDirective, {static: true}) adHost: AdDirective;  interval: any;  constructor(private componentFactoryResolver: ComponentFactoryResolver) { }  ngOnInit() {    this.loadComponent();    this.getAds();  }  ngOnDestroy() {    clearInterval(this.interval);  }  loadComponent() {    this.currentAdIndex = (this.currentAdIndex + 1) % this.ads.length;    const adItem = this.ads[this.currentAdIndex];    const componentFactory = this.componentFactoryResolver.resolveComponentFactory(adItem.component);    const viewContainerRef = this.adHost.viewContainerRef;    viewContainerRef.clear();    const componentRef = viewContainerRef.createComponent(componentFactory);    (<AdComponent>componentRef.instance).data = adItem.data;  }  getAds() {    this.interval = setInterval(() => {      this.loadComponent();    }, 3000);  }}

这里的 loadComponent() 方法很重要。 来一步步看看。首先,它选取了一个广告。

&loadComponent() 如何选择广告

&loadComponent() 方法使用某种算法选择了一个广告。

&(译注:循环选取算法)首先,它把 currentAdIndex 递增一,然后用它除以 AdItem 数组长度的余数作为新的 currentAdIndex 的值, 最后用这个值来从数组中选取一个 adItem

loadComponent() 选取了一个广告之后,它使用 ComponentFactoryResolver 来为每个具体的组件解析出一个 ComponentFactory。 然后 ComponentFactory 会为每一个组件创建一个实例。

接下来,你要把 viewContainerRef 指向这个组件的现有实例。但你怎么才能找到这个实例呢? 很简单,因为它指向了 adHost,而这个 adHost 就是你以前设置过的指令,用来告诉 Angular 该把动态组件插入到什么位置。

回忆一下,AdDirective 曾在它的构造函数中注入了一个 ViewContainerRef。 因此这个指令可以访问到这个你打算用作动态组件宿主的元素。

要把这个组件添加到模板中,你可以调用 ViewContainerRefcreateComponent()

createComponent() 方法返回一个引用,指向这个刚刚加载的组件。 使用这个引用就可以与该组件进行交互,比如设置它的属性或调用它的方法。

对选择器的引用

通常,Angular 编译器会为模板中所引用的每个组件都生成一个 ComponentFactory 类。 但是,对于动态加载的组件,模板中不会出现对它们的选择器的引用。

要想确保编译器照常生成工厂类,就要把这些动态加载的组件添加到 NgModuleentryComponents 数组中:

Path:"src/app/app.module.ts (entry components)" 。

entryComponents: [ HeroJobAdComponent, HeroProfileComponent ],

公共的 AdComponent 接口

在广告条中,所有组件都实现了一个公共接口 AdComponent,它定义了一个标准化的 API,来把数据传给组件。

下面就是两个范例组件及其 AdComponent 接口:

  1. Path:"src/app/hero-job-ad.component.ts" 。

    import { Component, Input } from '@angular/core';    import { AdComponent }      from './ad.component';    @Component({      template: `        <div class="job-ad">          <h4>{{data.headline}}</h4>          {{data.body}}        </div>      `    })    export class HeroJobAdComponent implements AdComponent {      @Input() data: any;    }

  1. Path:"hero-profile.component.ts" 。

    import { Component, Input }  from '@angular/core';    import { AdComponent }       from './ad.component';    @Component({      template: `        <div class="hero-profile">          <h3>Featured Hero Profile</h3>          <h4>{{data.name}}</h4>          <p>{{data.bio}}</p>          <strong>Hire this hero today!</strong>        </div>      `    })    export class HeroProfileComponent implements AdComponent {      @Input() data: any;    }

  1. Path:"ad.component.ts" 。

    export interface AdComponent {      data: any;    }

结果展示

概览

Angular 元素就是打包成自定义元素的 Angular 组件。所谓自定义元素就是一套与具体框架无关的用于定义新 HTML 元素的 Web 标准。

自定义元素这项特性目前受到了 Chrome、Edge(基于 Chromium 的版本)、Opera 和 Safari 的支持,在其它浏览器中也能通过腻子脚本(参见浏览器支持)加以支持。 自定义元素扩展了 HTML,它允许你定义一个由 JavaScript 代码创建和控制的标签。 浏览器会维护一个自定义元素的注册表 CustomElementRegistry,它把一个可实例化的 JavaScript 类映射到 HTML 标签上。

@angular/elements 包导出了一个 createCustomElement() API,它在 Angular 组件接口与变更检测功能和内置 DOM API 之间建立了一个桥梁。

把组件转换成自定义元素可以让所有所需的 Angular 基础设施都在浏览器中可用。 创建自定义元素的方式简单直观,它会自动把你组件定义的视图连同变更检测与数据绑定等 Angular 的功能映射为相应的原生 HTML 等价物。

使用自定义元素

自定义元素会自举 —— 它们在添加到 DOM 中时就会自行启动自己,并在从 DOM 中移除时自行销毁自己。一旦自定义元素添加到了任何页面的 DOM 中,它的外观和行为就和其它的 HTML 元素一样了,不需要对 Angular 的术语或使用约定有任何特殊的了解。

  • Angular 应用中的简易动态内容

把组件转换成自定义元素为你在 Angular 应用中创建动态 HTML 内容提供了一种简单的方式。 在 Angular 应用中,你直接添加到 DOM 中的 HTML 内容是不会经过 Angular 处理的,除非你使用动态组件来借助自己的代码把 HTML 标签与你的应用数据关联起来并参与变更检测。而使用自定义组件,所有这些装配工作都是自动的。

  • 富内容应用

如果你有一个富内容应用(比如正在展示本文档的这个),自定义元素能让你的内容提供者使用复杂的 Angular 功能,而不要求他了解 Angular 的知识。比如,像本文档这样的 Angular 指南是使用 Angular 导航工具直接添加到 DOM 中的,但是其中可以包含特殊的元素,比如 <code-snippet>,它可以执行复杂的操作。 你所要告诉你的内容提供者的一切,就是这个自定义元素的语法。他们不需要了解关于 Angular 的任何知识,也不需要了解你的组件的数据结构或实现。

工作原理

使用 createCustomElement() 函数来把组件转换成一个可注册成浏览器中自定义元素的类。 注册完这个配置好的类之后,你就可以在内容中像内置 HTML 元素一样使用这个新元素了,比如直接把它加到 DOM 中:

<my-popup message="Use Angular!"></my-popup>

当你的自定义元素放进页面中时,浏览器会创建一个已注册类的实例。其内容是由组件模板提供的,它使用 Angular 模板语法,并且使用组件和 DOM 数据进行渲染。组件的输入属性(Property)对应于该元素的输入属性(Attribute)。

把组件转换成自定义元素

Angular 提供了 createCustomElement() 函数,以支持把 Angular 组件及其依赖转换成自定义元素。该函数会收集该组件的 Observable 型属性,提供浏览器创建和销毁实例时所需的 Angular 功能,还会对变更进行检测并做出响应。

这个转换过程实现了 NgElementConstructor 接口,并创建了一个构造器类,用于生成该组件的一个自举型实例。

然后用 JavaScript 的 customElements.define() 函数把这个配置好的构造器和相关的自定义元素标签注册到浏览器的 CustomElementRegistry 中。 当浏览器遇到这个已注册元素的标签时,就会使用该构造器来创建一个自定义元素的实例。

映射

寄宿着 Angular 组件的自定义元素在组件中定义的"数据及逻辑"和标准的 DOM API 之间建立了一座桥梁。组件的属性和逻辑会直接映射到 HTML 属性和浏览器的事件系统中。

  • 用于创建的 API 会解析该组件,以查找输入属性(Property),并在这个自定义元素上定义相应的属性(Attribute)。 它把属性名转换成与自定义元素兼容的形式(自定义元素不区分大小写),生成的属性名会使用中线分隔的小写形式。 比如,对于带有 @Input('myInputProp') inputProp 的组件,其对应的自定义元素会带有一个 my-input-prop 属性。

  • 组件的输出属性会用 HTML 自定义事件的形式进行分发,自定义事件的名字就是这个输出属性的名字。 比如,对于带有 @Output() valueChanged = new EventEmitter() 属性的组件,其相应的自定义元素将会分发名叫 "valueChanged" 的事件,事件中所携带的数据存储在该事件对象的 detail 属性中。 如果你提供了别名,就改用这个别名。比如,@Output('myClick') clicks = new EventEmitter<string>(); 会导致分发名为 "myClick" 事件。

自定义元素的浏览器支持

最近开发的 Web 平台特性:自定义元素目前在一些浏览器中实现了原生支持,而其它浏览器或者尚未决定,或者已经制订了计划。

浏览器自定义元素支持
Chrome原生支持。
Edge (基于 Chromium 的)原生支持。
Firefox原生支持。
Opera原生支持。
Safari原生支持。

对于原生支持了自定义元素的浏览器,该规范要求开发人员使用 ES2016 的类来定义自定义元素 —— 开发人员可以在项目的 TypeScript 配置文件中设置 target: "es2015" 属性来满足这一要求。并不是所有浏览器都支持自定义元素和 ES2015,开发人员也可以选择使用腻子脚本来让它支持老式浏览器和 ES5 的代码。

使用 Angular CLI 可以自动为你的项目添加正确的腻子脚本:ng add @angular/elements --project=*your_project_name*

范例:弹窗服务

以前,如果你要在运行期间把一个组件添加到应用中,就不得不定义动态组件。你还要把动态组件添加到模块的 entryComponents 列表中,以便应用在启动时能找到它,然后还要加载它、把它附加到 DOM 中的元素上,并且装配所有的依赖、变更检测和事件处理,详见动态组件加载器。

用 Angular 自定义组件会让这个过程更简单、更透明。它会自动提供所有基础设施和框架,而你要做的就是定义所需的各种事件处理逻辑。(如果你不准备在应用中直接用它,还要把该组件在编译时排除出去。)

这个弹窗服务的范例应用(见后面)定义了一个组件,你可以动态加载它也可以把它转换成自定义组件。

  • "popup.component.ts" 定义了一个简单的弹窗元素,用于显示一条输入消息,附带一些动画和样式。

  • "popup.service.ts" 创建了一个可注入的服务,它提供了两种方式来执行 PopupComponent:作为动态组件或作为自定义元素。注意动态组件的方式需要更多的代码来做搭建工作。

  • "app.module.ts" 把 PopupComponent 添加到模块的 entryComponents 列表中,而从编译过程中排除它,以消除启动时的警告和错误。

  • "app.component.ts" 定义了该应用的根组件,它借助 PopupService 在运行时把这个弹窗添加到 DOM 中。在应用运行期间,根组件的构造函数会把 PopupComponent 转换成自定义元素。

为了对比,这个范例中同时演示了这两种方式。一个按钮使用动态加载的方式添加弹窗,另一个按钮使用自定义元素的方式。可以看到,两者的结果是一样的,其差别只是准备过程不同。

  1. Path:"src/app/popup.component.ts"。

    import { Component, EventEmitter, Input, Output } from '@angular/core';    import { animate, state, style, transition, trigger } from '@angular/animations';    @Component({      selector: 'my-popup',      template: `        <span>Popup: {{message}}</span>        <button (click)="closed.next()">✖</button>      `,      host: {        '[@state]': 'state',      },      animations: [        trigger('state', [          state('opened', style({transform: 'translateY(0%)'})),          state('void, closed', style({transform: 'translateY(100%)', opacity: 0})),          transition('* => *', animate('100ms ease-in')),        ])      ],      styles: [`        :host {          position: absolute;          bottom: 0;          left: 0;          right: 0;          background: #009cff;          height: 48px;          padding: 16px;          display: flex;          justify-content: space-between;          align-items: center;          border-top: 1px solid black;          font-size: 24px;        }        button {          border-radius: 50%;        }      `]    })    export class PopupComponent {      state: 'opened' | 'closed' = 'closed';      @Input()      set message(message: string) {        this._message = message;        this.state = 'opened';      }      get message(): string { return this._message; }      _message: string;      @Output()      closed = new EventEmitter();    }

  1. Path:"src/app/popup.service.ts"。

    import { ApplicationRef, ComponentFactoryResolver, Injectable, Injector } from '@angular/core';    import { NgElement, WithProperties } from '@angular/elements';    import { PopupComponent } from './popup.component';    @Injectable()    export class PopupService {      constructor(private injector: Injector,                  private applicationRef: ApplicationRef,                  private componentFactoryResolver: ComponentFactoryResolver) {}      // Previous dynamic-loading method required you to set up infrastructure      // before adding the popup to the DOM.      showAsComponent(message: string) {        // Create element        const popup = document.createElement('popup-component');        // Create the component and wire it up with the element        const factory = this.componentFactoryResolver.resolveComponentFactory(PopupComponent);        const popupComponentRef = factory.create(this.injector, [], popup);        // Attach to the view so that the change detector knows to run        this.applicationRef.attachView(popupComponentRef.hostView);        // Listen to the close event        popupComponentRef.instance.closed.subscribe(() => {          document.body.removeChild(popup);          this.applicationRef.detachView(popupComponentRef.hostView);        });        // Set the message        popupComponentRef.instance.message = message;        // Add to the DOM        document.body.appendChild(popup);      }      // This uses the new custom-element method to add the popup to the DOM.      showAsElement(message: string) {        // Create element        const popupEl: NgElement & WithProperties<PopupComponent> = document.createElement('popup-element') as any;        // Listen to the close event        popupEl.addEventListener('closed', () => document.body.removeChild(popupEl));        // Set the message        popupEl.message = message;        // Add to the DOM        document.body.appendChild(popupEl);      }    }

  1. Path:"src/app/app.module.ts"。

    import { NgModule } from '@angular/core';    import { BrowserModule } from '@angular/platform-browser';    import { BrowserAnimationsModule } from '@angular/platform-browser/animations';    import { AppComponent } from './app.component';    import { PopupComponent } from './popup.component';    import { PopupService } from './popup.service';    // Include the `PopupService` provider,    // but exclude `PopupComponent` from compilation,    // because it will be added dynamically.    @NgModule({      imports: [BrowserModule, BrowserAnimationsModule],      providers: [PopupService],      declarations: [AppComponent, PopupComponent],      bootstrap: [AppComponent],      entryComponents: [PopupComponent],    })    export class AppModule {    }

  1. Path:"app.component.ts"。

    import { Component, Injector } from '@angular/core';    import { createCustomElement } from '@angular/elements';    import { PopupService } from './popup.service';    import { PopupComponent } from './popup.component';    @Component({      selector: 'app-root',      template: `        <input #input value="Message">        <button (click)="popup.showAsComponent(input.value)">Show as component</button>        <button (click)="popup.showAsElement(input.value)">Show as element</button>      `,    })    export class AppComponent {      constructor(injector: Injector, public popup: PopupService) {        // Convert `PopupComponent` to a custom element.        const PopupElement = createCustomElement(PopupComponent, {injector});        // Register the custom element with the browser.        customElements.define('popup-element', PopupElement);      }    }

为自定义元素添加类型支持

一般的 DOM API,比如 document.createElement()document.querySelector(),会返回一个与指定的参数相匹配的元素类型。比如,调用 document.createElement('a') 会返回 HTMLAnchorElement,这样 TypeScript 就会知道它有一个 href 属性,而 document.createElement('div') 会返回 HTMLDivElement,这样 TypeScript 就会知道它没有 href 属性。

当调用未知元素(比如自定义的元素名 popup-element)时,该方法会返回泛化类型,比如 HTMLELement,这时候 TypeScript 就无法推断出所返回元素的正确类型。

用 Angular 创建的自定义元素会扩展 NgElement 类型(而它扩展了 HTMLElement)。除此之外,这些自定义元素还拥有相应组件的每个输入属性。比如,popup-element 元素具有一个 string 型的 message 属性。

如果你要让你的自定义元素获得正确的类型,还可使用一些选项。假设你要创建一个基于下列组件的自定义元素 my-dialog

@Component(...)class MyDialog {  @Input() content: string;}

获得精确类型的最简单方式是把相关 DOM 方法的返回值转换成正确的类型。要做到这一点,你可以使用 NgElementWithProperties 类型(都导出自 @angular/elements):

const aDialog = document.createElement('my-dialog') as NgElement & WithProperties<{content: string}>;aDialog.content = 'Hello, world!';aDialog.content = 123;  // <-- ERROR: TypeScript knows this should be a string.aDialog.body = 'News';  // <-- ERROR: TypeScript knows there is no `body` property on `aDialog`.

这是一种让你的自定义元素快速获得 TypeScript 特性(比如类型检查和自动完成支持)的好办法,不过如果你要在多个地方使用它,可能会有点啰嗦,因为不得不在每个地方对返回类型做转换。

另一种方式可以对每个自定义元素的类型只声明一次。你可以扩展 HTMLElementTagNameMap,TypeScript 会在 DOM 方法(如 document.createElement()document.querySelector() 等)中用它来根据标签名推断返回元素的类型。

declare global {  interface HTMLElementTagNameMap {    'my-dialog': NgElement & WithProperties<{content: string}>;    'my-other-element': NgElement & WithProperties<{foo: 'bar'}>;    ...  }}

现在,TypeScript 就可以像内置元素一样推断出它的正确类型了:

document.createElement('div')               //--> HTMLDivElement (built-in element)document.querySelector('foo')               //--> Element        (unknown element)document.createElement('my-dialog')         //--> NgElement & WithProperties<{content: string}> (custom element)document.querySelector('my-other-element')  //--> NgElement & WithProperties<{foo: 'bar'}>      (custom element)

用表单处理用户输入是许多常见应用的基础功能。 应用通过表单来让用户登录、修改个人档案、输入敏感信息以及执行各种数据输入任务。

Angular 提供了两种不同的方法来通过表单处理用户输入:响应式表单和模板驱动表单。 两者都从视图中捕获用户输入事件、验证用户输入、创建表单模型、修改数据模型,并提供跟踪这些更改的途径。

本指南提供的信息可以帮你确定哪种方式最适合你的情况。它介绍了这两种方法所用的公共构造块,还总结了两种方式之间的关键区别,并在建立、数据流和测试等不同的情境下展示了这些差异。

先决条件

要想学习使用表单,你应该对这些内容有基本的了解:

  • TypeScript和 HTML5 编程。

  • Angular 的应用设计基础,就像Angular Concepts 中描述的那样。

  • Angular 模板语法的基础知识。

选择一种方法

响应式表单和模板驱动表单以不同的方式处理和管理表单数据。每种方法都有各自的优点。

  • 响应式表单提供对底层表单对象模型直接、显式的访问。它们与模板驱动表单相比,更加健壮:它们的可扩展性、可复用性和可测试性都更高。如果表单是你的应用程序的关键部分,或者你已经在使用响应式表单来构建应用,那就使用响应式表单。

  • 模板驱动表单依赖模板中的指令来创建和操作底层的对象模型。它们对于向应用添加一个简单的表单非常有用,比如电子邮件列表注册表单。它们很容易添加到应用中,但在扩展性方面不如响应式表单。如果你有可以只在模板中管理的非常基本的表单需求和逻辑,那么模板驱动表单就很合适。

关键差异

下表总结了响应式表单和模板驱动表单之间的一些关键差异。

响应式模板驱动
建立表单模型显式的,在组件类中创建隐式的,由指令创建
数据模型结构化和不可变的非结构化和可变的
可预测性同步异步
表单验证函数指令

可伸缩性

如果表单是应用程序的核心部分,那么可伸缩性就非常重要。能够跨组件复用表单模型是至关重要的。

响应式表单比模板驱动表单更有可伸缩性。它们提供对底层表单 API 的直接访问,以及对表单数据模型的同步访问,从而可以更轻松地创建大型表单。响应式表单需要较少的测试设置,测试时不需要深入理解变更检测,就能正确测试表单更新和验证。

模板驱动表单专注于简单的场景,可复用性没那么高。它们抽象出了底层表单 API,并且只提供对表单数据模型的异步访问。对模板驱动表单的这种抽象也会影响测试。测试程序非常依赖于手动触发变更检测才能正常运行,并且需要进行更多设置工作。

建立表单模型

响应式表单和模板驱动型表单都会跟踪用户与之交互的表单输入元素和组件模型中的表单数据之间的值变更。这两种方法共享同一套底层构建块,只在如何创建和管理常用表单控件实例方面有所不同。

常用表单基础类

响应式表单和模板驱动表单都建立在下列基础类之上。

  • FormControl 实例用于追踪单个表单控件的值和验证状态。

  • FormGroup 用于追踪一个表单控件组的值和状态。

  • FormArray 用于追踪表单控件数组的值和状态。

  • ControlValueAccessor 用于在 Angular 的 FormControl 实例和原生 DOM 元素之间创建一个桥梁。

建立响应式表单

对于响应式表单,你可以直接在组件类中定义表单模型。[formControl] 指令会通过内部值访问器来把显式创建的 FormControl 实例与视图中的特定表单元素联系起来。

下面的组件使用响应式表单为单个控件实现了一个输入字段。在这个例子中,表单模型是 FormControl 实例。

import { Component } from '@angular/core';import { FormControl } from '@angular/forms';@Component({  selector: 'app-reactive-favorite-color',  template: `    Favorite Color: <input type="text" [formControl]="favoriteColorControl">  `})export class FavoriteColorComponent {  favoriteColorControl = new FormControl('');}

下图展示了在响应式表单中直接访问表单模型。它通过输入元素上的 [formControl] 指令,在任何给定的时间点提供表单元素的值和状态。

建立模板驱动表单

在模板驱动表单中,表单模型是隐式的,而不是显式的。指令 NgModel 为指定的表单元素创建并管理一个 FormControl 实例。

下面的组件使用模板驱动表单为单个控件实现了同样的输入字段。

import { Component } from '@angular/core';@Component({  selector: 'app-template-favorite-color',  template: `    Favorite Color: <input type="text" [(ngModel)]="favoriteColor">  `})export class FavoriteColorComponent {  favoriteColor = '';}

在模板驱动表单中,对表单模型的间接访问。你没有对 FormControl 实例的直接编程访问,如下图所示。

表单中的数据流

当应用包含一个表单时,Angular 必须让该视图与组件模型保持同步,并让组件模型与视图保持同步。当用户通过视图更改值并进行选择时,新值必须反映在数据模型中。同样,当程序逻辑改变数据模型中的值时,这些值也必须反映到视图中。

响应式表单和模板驱动表单在处理来自用户或程序化变更时的数据处理方式上有所不同。下面的这些示意图会以上面定义的 favorite-color 输入字段为例,分别说明两种表单各自的数据流。

响应式表单中的数据流

在响应式表单中,视图中的每个表单元素都直接链接到一个表单模型(FormControl 实例)。 从视图到模型的修改以及从模型到视图的修改都是同步的,而且不依赖于 UI 的渲染方式。

这个视图到模型的示意图展示了当输入字段的值发生变化时数据是如何从视图开始,经过下列步骤进行流动的。

  1. 最终用户在输入框元素中键入了一个值,这里是 "Blue"。

  1. 这个输入框元素会发出一个带有最新值的 "input" 事件。

  1. 这个控件值访问器 ControlValueAccessor 会监听表单输入框元素上的事件,并立即把新值传给 FormControl 实例。

  1. FormControl 实例会通过 valueChanges 这个可观察对象发出这个新值。

  1. valueChanges 的任何一个订阅者都会收到这个新值。

这个模型到视图的示意图体现了程序中对模型的修改是如何通过下列步骤传播到视图中的。

  1. favoriteColorControl.setValue() 方法被调用,它会更新这个 FormControl 的值。

  1. FormControl 实例会通过 valueChanges 这个可观察对象发出新值。

  1. valueChanges 的任何订阅者都会收到这个新值。

  1. 该表单输入框元素上的控件值访问器会把控件更新为这个新值。

模板驱动表单中的数据流

在模板驱动表单中,每一个表单元素都是和一个负责管理内部表单模型的指令关联起来的。

这个视图到模型的图表展示了当输入字段的值发生变化时,数据流是如何从视图开始经过下列步骤进行流动的。

  1. 最终用户在输入框元素中敲 "Blue"。

  1. 该输入框元素会发出一个 "input" 事件,带着值 "Blue"。

  1. 附着在该输入框上的控件值访问器会触发 FormControl 实例上的 setValue() 方法。

  1. FormControl 实例通过 valueChanges 这个可观察对象发出新值。

  1. valueChanges 的任何订阅者都会收到新值。

  1. 控件值访问器 ControlValueAccessory 还会调用 NgModel.viewToModelUpdate() 方法,它会发出一个 ngModelChange 事件。

  1. 由于该组件模板双向数据绑定到了 favoriteColor,组件中的 favoriteColor 属性就会修改为 ngModelChange 事件所发出的值("Blue")。

这个模型到视图的示意图展示了当 favoriteColor 从蓝变到红时,数据是如何经过如下步骤从模型流动到视图的。

  1. 组件中修改了 favoriteColor 的值。

  1. 变更检测开始。

  1. 在变更检测期间,由于这些输入框之一的值发生了变化,Angular 就会调用 NgModel 指令上的 ngOnChanges 生命周期钩子。

  1. ngOnChanges() 方法会把一个异步任务排入队列,以设置内部 FormControl 实例的值。

  1. 变更检测完成。

  1. 在下一个检测周期,用来为 FormControl 实例赋值的任务就会执行。

  1. FormControl 实例通过可观察对象 valueChanges 发出最新值。

  1. valueChanges 的任何订阅者都会收到这个新值。

  1. 控件值访问器 ControlValueAccessor 会使用 favoriteColor 的最新值来修改表单的输入框元素。

数据模型的可变性

变更追踪的方法对应用的效率有着重要影响。

  • 响应式表单通过以不可变的数据结构提供数据模型,来保持数据模型的纯粹性。每当在数据模型上触发更改时,FormControl 实例都会返回一个新的数据模型,而不会更新现有的数据模型。这使你能够通过该控件的可观察对象跟踪对数据模型的唯一更改。这让变更检测更有效率,因为它只需在唯一性更改(译注:也就是对象引用发生变化)时进行更新。由于数据更新遵循响应式模式,因此你可以把它和可观察对象的各种运算符集成起来以转换数据。

  • 模板驱动的表单依赖于可变性和双向数据绑定,可以在模板中做出更改时更新组件中的数据模型。由于使用双向数据绑定时没有用来对数据模型进行跟踪的唯一性更改,因此变更检测在需要确定何时更新时效率较低。

前面那些使用 favorite-color 输入元素的例子就演示了这种差异。

  • 对于响应式表单,当控件值更新时,FormControl 的实例总会返回一个新值。

  • 对于模板驱动的表单,favorite-color 属性总会被修改为新值。

表单验证

验证是管理任何表单时必备的一部分。无论你是要检查必填项,还是查询外部 API 来检查用户名是否已存在,Angular 都会提供一组内置的验证器,以及创建自定义验证器所需的能力。

  • 响应式表单把自定义验证器定义成函数,它以要验证的控件作为参数。

  • 模板驱动表单和模板指令紧密相关,并且必须提供包装了验证函数的自定义验证器指令。

测试

测试在复杂的应用程序中也起着重要的作用。当验证你的表单功能是否正确时,更简单的测试策略往往也更有用。测试响应式表单和模板驱动表单的差别之一在于它们是否需要渲染 UI 才能基于表单控件和表单字段变化来执行断言。下面的例子演示了使用响应式表单和模板驱动表单时表单的测试过程。

测试响应式表单

响应式表单提供了相对简单的测试策略,因为它们能提供对表单和数据模型的同步访问,而且不必渲染 UI 就能测试它们。在这些测试中,控件和数据是通过控件进行查询和操纵的,不需要和变更检测周期打交道。

下面的测试利用前面例子中的 "喜欢的颜色" 组件来验证响应式表单中的 "从视图到模型" 和 "从模型到视图" 数据流。

验证“从视图到模型”的数据流

第一个例子执行了下列步骤来验证“从视图到模型”数据流。

  1. 查询表单输入框元素的视图,并为测试创建自定义的 "input" 事件

  1. 把输入的新值设置为 Red,并在表单输入元素上调度 "input" 事件。

  1. 断言该组件的 favoriteColorControl 的值与来自输入框的值是匹配的。

//Favorite color test - view to modelit('should update the value of the input field', () => {  const input = fixture.nativeElement.querySelector('input');  const event = createNewEvent('input');  input.value = 'Red';  input.dispatchEvent(event);  expect(fixture.componentInstance.favoriteColorControl.value).toEqual('Red');});

验证“从模型到视图”数据流:

  1. 使用 favoriteColorControl 这个 FormControl 实例来设置新值。

  1. 查询表单中输入框的视图。

  1. 断言控件上设置的新值与输入中的值是匹配的。

//Favorite color test - model to viewit('should update the value in the control', () => {  component.favoriteColorControl.setValue('Blue');  const input = fixture.nativeElement.querySelector('input');  expect(input.value).toBe('Blue');});

测试模板驱动表单

使用模板驱动表单编写测试就需要详细了解变更检测过程,以及指令在每个变更检测周期中如何运行,以确保在正确的时间查询、测试或更改元素。

下面的测试使用了以前的 "喜欢的颜色" 组件,来验证模板驱动表单的 "从视图到模型" 和 "从模型到视图" 数据流。

验证 "从视图到模型" 数据流:

  1. 查询表单输入元素中的视图,并为测试创建自定义 "input" 事件。

  1. 把输入框的新值设置为 Red,并在表单输入框元素上派发 "input" 事件。

  1. 通过测试夹具(Fixture)来运行变更检测。

  1. 断言该组件 favoriteColor 属性的值与来自输入框的值是匹配的。

//Favorite color test - view to modelit('should update the favorite color in the component', fakeAsync(() => {  const input = fixture.nativeElement.querySelector('input');  const event = createNewEvent('input');  input.value = 'Red';  input.dispatchEvent(event);  fixture.detectChanges();  expect(component.favoriteColor).toEqual('Red');}));

验证“从模型到视图”数据流:

  1. 使用组件实例来设置 favoriteColor 的值。

  1. 通过测试夹具(Fixture)来运行变更检测。

  1. fakeAsync() 任务中使用 tick() 方法来模拟时间的流逝。

  1. 查询表单输入框元素的视图。

  1. 断言输入框的值与该组件实例的 favoriteColor 属性值是匹配的。

//Favorite color test - model to viewit('should update the favorite color on the input field', fakeAsync(() => {  component.favoriteColor = 'Blue';  fixture.detectChanges();  tick();  const input = fixture.nativeElement.querySelector('input');  expect(input.value).toBe('Blue');}));

响应式表单提供了一种模型驱动的方式来处理表单输入,其中的值会随时间而变化。本文会向你展示如何创建和更新基本的表单控件,接下来还会在一个表单组中使用多个控件,验证表单的值,以及创建动态表单,也就是在运行期添加或移除控件。

先决条件

在深入了解被动表单之前,你应该对这些内容有一个基本的了解:

  • TypeScript 编程。

  • Angular 的应用设计基础,就像Angular Concepts 中描述的那样。

  • “表单简介”中提供的表单设计概念。

响应式表单概述

响应式表单使用显式的、不可变的方式,管理表单在特定的时间点上的状态。对表单状态的每一次变更都会返回一个新的状态,这样可以在变化时维护模型的整体性。响应式表单是围绕 Observable 流构建的,表单的输入和值都是通过这些输入值组成的流来提供的,它可以同步访问。

响应式表单还提供了一种更直观的测试路径,因为在请求时你可以确信这些数据是一致的、可预料的。这个流的任何一个消费者都可以安全地操纵这些数据。

响应式表单与模板驱动表单有着显著的不同点。响应式表单通过对数据模型的同步访问提供了更多的可预测性,使用 Observable 的操作符提供了不可变性,并且通过 Observable 流提供了变化追踪功能。

模板驱动的表单允许你直接在模板中修改数据,但不像响应式表单那么明确,因为它们依赖嵌入到模板中的指令,并借助可变数据来异步跟踪变化。参见表单概览以了解这两种范式之间的详细比较。

添加基础表单控件

使用表单控件有三个步骤。

  1. 在你的应用中注册响应式表单模块。该模块声明了一些你要用在响应式表单中的指令。

  1. 生成一个新的 FormControl 实例,并把它保存在组件中。

  1. 在模板中注册这个 FormControl

然后,你可以把组件添加到模板中来显示表单。

下面的例子展示了如何添加一个表单控件。在这个例子中,用户在输入字段中输入自己的名字,捕获其输入值,并显示表单控件的当前值。

注册响应式表单模块

要使用响应式表单控件,就要从 @angular/forms 包中导入 ReactiveFormsModule,并把它添加到你的 NgModuleimports 数组中。

Path:"src/app/app.module.ts (excerpt)" 。

import { ReactiveFormsModule } from '@angular/forms';@NgModule({  imports: [    // other imports ...    ReactiveFormsModule  ],})export class AppModule { }

生成一个新的 FormControl

使用 CLI 命令 ng generate 在项目中生成一个组件作为该表单控件的宿主。

ng generate component NameEditor

要注册一个表单控件,就要导入 FormControl 类并创建一个 FormControl 的新实例,将其保存为类的属性。

Path:"src/app/name-editor/name-editor.component.ts" 。

import { Component } from '@angular/core';import { FormControl } from '@angular/forms';@Component({  selector: 'app-name-editor',  templateUrl: './name-editor.component.html',  styleUrls: ['./name-editor.component.css']})export class NameEditorComponent {  name = new FormControl('');}

可以用 FormControl 的构造函数设置初始值,这个例子中它是空字符串。通过在你的组件类中创建这些控件,你可以直接对表单控件的状态进行监听、修改和校验。

在模板中注册该控件

在组件类中创建了控件之后,你还要把它和模板中的一个表单控件关联起来。修改模板,为表单控件添加 formControl 绑定,formControl 是由 ReactiveFormsModule 中的 FormControlDirective 提供的。

Path:"src/app/name-editor/name-editor.component.html" 。

<label>  Name:  <input type="text" [formControl]="name"></label>

使用这种模板绑定语法,把该表单控件注册给了模板中名为 name 的输入元素。这样,表单控件和 DOM 元素就可以互相通讯了:视图会反映模型的变化,模型也会反映视图中的变化。

显示该组件

把该组件添加到模板时,将显示指派给 name 的表单控件。

Path:"src/app/app.component.html (name editor)" 。

<app-name-editor></app-name-editor>

显示表单控件的值

你可以用下列方式显示它的值:

  • 通过可观察对象 valueChanges,你可以在模板中使用 AsyncPipe 或在组件类中使用 subscribe() 方法来监听表单值的变化。

  • 使用 value 属性。它能让你获得当前值的一份快照。

下面的例子展示了如何在模板中使用插值显示当前值。

Path:"src/app/name-editor/name-editor.component.html (control value)" 。

<p>  Value: {{ name.value }}</p>

一旦你修改了表单控件所关联的元素,这里显示的值也跟着变化了。

响应式表单还能通过每个实例的属性和方法提供关于特定控件的更多信息。AbstractControl 的这些属性和方法用于控制表单状态,并在处理表单校验时决定何时显示信息。

替换表单控件的值

响应式表单还有一些方法可以用编程的方式修改控件的值,它让你可以灵活的修改控件的值而不需要借助用户交互。FormControl 提供了一个 setValue() 方法,它会修改这个表单控件的值,并且验证与控件结构相对应的值的结构。比如,当从后端 API 或服务接收到了表单数据时,可以通过 setValue() 方法来把原来的值替换为新的值。

下列的例子往组件类中添加了一个方法,它使用 setValue() 方法来修改 Nancy 控件的值。

Path:"src/app/name-editor/name-editor.component.ts (update value)" 。

updateName() {  this.name.setValue('Nancy');}

修改模板,添加一个按钮,用于模拟改名操作。在点 Update Name 按钮之前表单控件元素中输入的任何值都会回显为它的当前值。

Path:"src/app/name-editor/name-editor.component.html (update value)" 。

<p>  <button (click)="updateName()">Update Name</button></p>

由于表单模型是该控件的事实之源,因此当你单击该按钮时,组件中该输入框的值也变化了,覆盖掉它的当前值。

注:
- 在这个例子中,你只使用单个控件,但是当调用 FormGroupFormArray 实例的 setValue() 方法时,传入的值就必须匹配控件组或控件数组的结构才行。

把表单控件分组

表单中通常会包含几个相互关联的控件。响应式表单提供了两种把多个相关控件分组到同一个输入表单中的方法。

  • 表单组定义了一个带有一组控件的表单,你可以把它们放在一起管理。表单组的基础知识将在本节中讨论。你也可以通过嵌套表单组来创建更复杂的表单。

  • 表单数组定义了一个动态表单,你可以在运行时添加和删除控件。你也可以通过嵌套表单数组来创建更复杂的表单。欲知详情,参见下面的创建动态表单。

就像 FormControl 的实例能让你控制单个输入框所对应的控件一样,FormGroup 的实例也能跟踪一组 FormControl 实例(比如一个表单)的表单状态。当创建 FormGroup 时,其中的每个控件都会根据其名字进行跟踪。下面的例子展示了如何管理单个控件组中的多个 FormControl 实例。

生成一个 ProfileEditor 组件并从 @angular/forms 包中导入 FormGroupFormControl 类。

ng generate component ProfileEditor

Path:"src/app/profile-editor/profile-editor.component.ts (imports)" 。

import { FormGroup, FormControl } from '@angular/forms';

要将表单组添加到此组件中,请执行以下步骤。

  1. 创建一个 FormGroup 实例。

  1. 把这个 FormGroup 模型关联到视图。

  1. 保存表单数据。

创建一个 FormGroup 实例

在组件类中创建一个名叫 profileForm 的属性,并设置为 FormGroup 的一个新实例。要初始化这个 FormGroup,请为构造函数提供一个由控件组成的对象,对象中的每个名字都要和表单控件的名字一一对应。

对此个人档案表单,要添加两个 FormControl 实例,名字分别为 firstNamelastName

Path:"src/app/profile-editor/profile-editor.component.ts (form group)" 。

import { Component } from '@angular/core';import { FormGroup, FormControl } from '@angular/forms';@Component({  selector: 'app-profile-editor',  templateUrl: './profile-editor.component.html',  styleUrls: ['./profile-editor.component.css']})export class ProfileEditorComponent {  profileForm = new FormGroup({    firstName: new FormControl(''),    lastName: new FormControl(''),  });}

现在,这些独立的表单控件被收集到了一个控件组中。这个 FormGroup 用对象的形式提供了它的模型值,这个值来自组中每个控件的值。 FormGroup 实例拥有和 FormControl 实例相同的属性(比如 valueuntouched)和方法(比如 setValue())。

把这个 FormGroup 模型关联到视图。

这个表单组还能跟踪其中每个控件的状态及其变化,所以如果其中的某个控件的状态或值变化了,父控件也会发出一次新的状态变更或值变更事件。该控件组的模型来自它的所有成员。在定义了这个模型之后,你必须更新模板,来把该模型反映到视图中。

Path:"src/app/profile-editor/profile-editor.component.html (template form group)" 。

<form [formGroup]="profileForm">    <label>    First Name:    <input type="text" formControlName="firstName">  </label>  <label>    Last Name:    <input type="text" formControlName="lastName">  </label></form>

注意,就像 FormGroup 所包含的那控件一样,profileForm 这个 FormGroup 也通过 FormGroup 指令绑定到了 form 元素,在该模型和表单中的输入框之间创建了一个通讯层。 由 FormControlName 指令提供的 formControlName 属性把每个输入框和 FormGroup 中定义的表单控件绑定起来。这些表单控件会和相应的元素通讯,它们还把更改传给 FormGroup,这个 FormGroup 是模型值的事实之源。

保存表单数据

ProfileEditor 组件从用户那里获得输入,但在真实的场景中,你可能想要先捕获表单的值,等将来在组件外部进行处理。 FormGroup 指令会监听 form 元素发出的 submit 事件,并发出一个 ngSubmit 事件,让你可以绑定一个回调函数。

onSubmit() 回调方法添加为 form 标签上的 ngSubmit 事件监听器。

Path:"src/app/profile-editor/profile-editor.component.html (submit event)" 。

<form [formGroup]="profileForm" (ngSubmit)="onSubmit()">

ProfileEditor 组件上的 onSubmit() 方法会捕获 profileForm 的当前值。要保持该表单的封装性,就要使用 EventEmitter 向组件外部提供该表单的值。下面的例子会使用 console.warn 把这个值记录到浏览器的控制台中。

Path:"src/app/profile-editor/profile-editor.component.ts (submit method)" 。

onSubmit() {  // TODO: Use EventEmitter with form value  console.warn(this.profileForm.value);}

form 标签所发出的 submit 事件是原生 DOM 事件,通过点击类型为 submit 的按钮可以触发本事件。这还让用户可以用回车键来提交填完的表单。

往表单的底部添加一个 button,用于触发表单提交。

Path:"src/app/profile-editor/profile-editor.component.html (submit button)" 。

<button type="submit" [disabled]="!profileForm.valid">Submit</button>

&上面这个代码片段中的按钮还附加了一个 disabled 绑定,用于在 profileForm 无效时禁用该按钮。目前你还没有执行任何表单验证逻辑,因此该按钮始终是可用的。稍后的验证表单输入部分会讲解基础的表单验证。

显示组件

要显示包含此表单的 ProfileEditor 组件,请把它添加到组件模板中。

Path:"src/app/app.component.html (profile editor)" 。

<app-profile-editor></app-profile-editor>

ProfileEditor 让你能管理 FormGroup 中的 firstNamelastNameFormControl 实例。

创建嵌套的表单组

表单组可以同时接受单个表单控件实例和其它表单组实例作为其子控件。这可以让复杂的表单模型更容易维护,并在逻辑上把它们分组到一起。

如果要构建复杂的表单,如果能在更小的分区中管理不同类别的信息就会更容易一些。使用嵌套的 FormGroup 可以让你把大型表单组织成一些稍小的、易管理的分组。

要制作更复杂的表单,请遵循如下步骤。

创建一个嵌套的表单组。

在模板中对这个嵌套表单分组。

某些类型的信息天然就属于同一个组。比如名称和地址就是这类嵌套组的典型例子,下面的例子中就用到了它们。

创建一个嵌套组

要在 profileForm 中创建一个嵌套组,就要把一个嵌套的 address 元素添加到此表单组的实例中。

Path:"src/app/profile-editor/profile-editor.component.ts (nested form group)" 。

import { Component } from '@angular/core';import { FormGroup, FormControl } from '@angular/forms';@Component({  selector: 'app-profile-editor',  templateUrl: './profile-editor.component.html',  styleUrls: ['./profile-editor.component.css']})export class ProfileEditorComponent {  profileForm = new FormGroup({    firstName: new FormControl(''),    lastName: new FormControl(''),    address: new FormGroup({      street: new FormControl(''),      city: new FormControl(''),      state: new FormControl(''),      zip: new FormControl('')    })  });}

在这个例子中,address group 把现有的 firstNamelastName 控件和新的 streetcitystatezip 控件组合在一起。虽然 address 这个 FormGroupprofileForm 这个整体 FormGroup 的一个子控件,但是仍然适用同样的值和状态的变更规则。来自内嵌控件组的状态和值的变更将会冒泡到它的父控件组,以维护整体模型的一致性。

在模板中对此嵌套表单分组

在修改了组件类中的模型之后,还要修改模板,来把这个 FormGroup 实例对接到它的输入元素。

把包含 streetcitystatezip 字段的 address 表单组添加到 ProfileEditor 模板中。

Path:"src/app/profile-editor/profile-editor.component.html (template nested form group)" 。

<div formGroupName="address">  <h3>Address</h3>  <label>    Street:    <input type="text" formControlName="street">  </label>  <label>    City:    <input type="text" formControlName="city">  </label>    <label>    State:    <input type="text" formControlName="state">  </label>  <label>    Zip Code:    <input type="text" formControlName="zip">  </label></div>

ProfileEditor 表单显示为一个组,但是将来这个模型会被进一步细分,以表示逻辑分组区域。

注:

  • 这里使用了 value 属性和 JsonPipe 管道在组件模板中显示了这个 FormGroup 的值。

更新部分数据模型

当修改包含多个 FormGroup 实例的值时,你可能只希望更新模型中的一部分,而不是完全替换掉。这一节会讲解该如何更新 AbstractControl 模型中的一部分。

有两种更新模型值的方式:

  • 使用 setValue() 方法来为单个控件设置新值。 setValue() 方法会严格遵循表单组的结构,并整体性替换控件的值。

  • 使用 patchValue() 方法可以用对象中所定义的任何属性为表单模型进行替换。

setValue() 方法的严格检查可以帮助你捕获复杂表单嵌套中的错误,而 patchValue() 在遇到那些错误时可能会默默的失败。

ProfileEditorComponent 中,使用 updateProfile 方法传入下列数据可以更新用户的名字与街道住址。

Path:"src/app/profile-editor/profile-editor.component.ts (patch value)" 。

updateProfile() {  this.profileForm.patchValue({    firstName: 'Nancy',    address: {      street: '123 Drew Street'    }  });}

通过往模板中添加一个按钮来模拟一次更新操作,以修改用户档案。

Path:"src/app/profile-editor/profile-editor.component.html (update value)" 。

<p>  <button (click)="updateProfile()">Update Profile</button></p>

当点击按钮时,profileForm 模型中只有 firstNamestreet 被修改了。注意,street 是在 address 属性的对象中被修改的。这种结构是必须的,因为 patchValue() 方法要针对模型的结构进行更新。patchValue() 只会更新表单模型中所定义的那些属性。

使用 FormBuilder 服务生成控件

当需要与多个表单打交道时,手动创建多个表单控件实例会非常繁琐。FormBuilder 服务提供了一些便捷方法来生成表单控件。FormBuilder 在幕后也使用同样的方式来创建和返回这些实例,只是用起来更简单。

通过下列步骤可以利用这项服务。

导入 FormBuilder 类。

注入这个 FormBuilder 服务。

生成表单内容。

下面的例子展示了如何重构 ProfileEditor 组件,用 FormBuilder 来代替手工创建这些 FormControlFormGroup 实例。

导入 FormBuilder 类

@angular/forms 包中导入 FormBuilder 类。

Path:"src/app/profile-editor/profile-editor.component.ts (import)" 。

import { FormBuilder } from '@angular/forms';

注入 FormBuilder 服务

FormBuilder 是一个可注入的服务提供者,它是由 ReactiveFormModule 提供的。只要把它添加到组件的构造函数中就可以注入这个依赖。

Path:"src/app/profile-editor/profile-editor.component.ts (constructor)" 。

constructor(private fb: FormBuilder) { }

生成表单控件

FormBuilder 服务有三个方法:control()group()array()。这些方法都是工厂方法,用于在组件类中分别生成 FormControlFormGroupFormArray

group 方法来创建 profileForm 控件。

Path:"src/app/profile-editor/profile-editor.component.ts (form builder)" 。

import { Component } from '@angular/core';import { FormBuilder } from '@angular/forms';@Component({  selector: 'app-profile-editor',  templateUrl: './profile-editor.component.html',  styleUrls: ['./profile-editor.component.css']})export class ProfileEditorComponent {  profileForm = this.fb.group({    firstName: [''],    lastName: [''],    address: this.fb.group({      street: [''],      city: [''],      state: [''],      zip: ['']    }),  });  constructor(private fb: FormBuilder) { }}

在上面的例子中,你可以使用 group() 方法,用和前面一样的名字来定义这些属性。这里,每个控件名对应的值都是一个数组,这个数组中的第一项是其初始值。

&你可以只使用初始值来定义控件,但是如果你的控件还需要同步或异步验证器,那就在这个数组中的第二项和第三项提供同步和异步验证器。

比较一下用表单构建器和手动创建实例这两种方式。

  1. Path:"src/app/profile-editor/profile-editor.component.ts (instances)" 。

    profileForm = new FormGroup({      firstName: new FormControl(''),      lastName: new FormControl(''),      address: new FormGroup({        street: new FormControl(''),        city: new FormControl(''),        state: new FormControl(''),        zip: new FormControl('')      })    });

  1. Path:"src/app/profile-editor/profile-editor.component.ts (form builder)" 。

    profileForm = this.fb.group({      firstName: [''],      lastName: [''],      address: this.fb.group({        street: [''],        city: [''],        state: [''],        zip: ['']      }),    });

验证表单输入

表单验证用于确保用户的输入是完整和正确的。本节讲解了如何把单个验证器添加到表单控件中,以及如何显示表单的整体状态。表单验证的更多知识在表单验证一章中有详细的讲解。

使用下列步骤添加表单验证。

  1. 在表单组件中导入一个验证器函数。

  1. 把这个验证器添加到表单中的相应字段。

  1. 添加逻辑来处理验证状态。

最常见的验证是做一个必填字段。下面的例子给出了如何在 firstName 控件中添加必填验证并显示验证结果的方法。

导入验证器函数

响应式表单包含了一组开箱即用的常用验证器函数。这些函数接收一个控件,用以验证并根据验证结果返回一个错误对象或空值。

@angular/forms 包中导入 Validators 类。

Path:"src/app/profile-editor/profile-editor.component.ts (import)" 。

import { Validators } from '@angular/forms';

建一个必填字段

ProfileEditor 组件中,把静态方法 Validators.required 设置为 firstName 控件值数组中的第二项。

Path:"src/app/profile-editor/profile-editor.component.ts (import)" 。

profileForm = this.fb.group({  firstName: ['', Validators.required],  lastName: [''],  address: this.fb.group({    street: [''],    city: [''],    state: [''],    zip: ['']  }),});

HTML5 有一组内置的属性,用来进行原生验证,包括 requiredminlengthmaxlength 等。虽然是可选的,不过你也可以在表单的输入元素上把它们添加为附加属性来使用它们。这里我们把 required 属性添加到 firstName 输入元素上。

Path:"src/app/profile-editor/profile-editor.component.html (required attribute)" 。

<input type="text" formControlName="firstName" required>

注:

  • 这些 HTML5 验证器属性可以和 Angular 响应式表单提供的内置验证器组合使用。组合使用这两种验证器实践,可以防止在模板检查完之后表达式再次被修改导致的错误。

显示表单状态

当你往表单控件上添加了一个必填字段时,它的初始值是无效的(invalid)。这种无效状态会传播到其父 FormGroup 元素中,也让这个 FormGroup 的状态变为无效的。你可以通过该 FormGroup 实例的 status 属性来访问其当前状态。

使用插值显示 profileForm 的当前状态。

Path:"src/app/profile-editor/profile-editor.component.html (display status)" 。

<p>  Form Status: {{ profileForm.status }}</p>

提交按钮被禁用了,因为 firstName 控件的必填项规则导致了 profileForm 也是无效的。在你填写了 firstName 输入框之后,该表单就变成了有效的,并且提交按钮也启用了。

创建动态表单

FormArrayFormGroup 之外的另一个选择,用于管理任意数量的匿名控件。像 FormGroup 实例一样,你也可以往 FormArray 中动态插入和移除控件,并且 FormArray 实例的值和验证状态也是根据它的子控件计算得来的。 不过,你不需要为每个控件定义一个名字作为 key,因此,如果你事先不知道子控件的数量,这就是一个很好的选择。

要定义一个动态表单,请执行以下步骤。

  1. 导入 FormArray 类。

  1. 定义一个 FormArray 控件。

  1. 使用 getter 方法访问 FormArray 控件。

  1. 在模板中显示这个表单数组。

下面的例子展示了如何在 ProfileEditor 中管理别名数组。

导入 FormArray 类

@angular/form 中导入 FormArray,以使用它的类型信息。FormBuilder 服务用于创建 FormArray 实例。

Path:"src/app/profile-editor/profile-editor.component.ts (import)" 。

import { FormArray } from '@angular/forms';

定义 FormArray 控件

你可以通过把一组(从零项到多项)控件定义在一个数组中来初始化一个 FormArray。为 profileForm 添加一个 aliases 属性,把它定义为 FormArray 类型。

使用 FormBuilder.array() 方法来定义该数组,并用 FormBuilder.control() 方法来往该数组中添加一个初始控件。

Path:"src/app/profile-editor/profile-editor.component.ts (aliases form array)" 。

profileForm = this.fb.group({  firstName: ['', Validators.required],  lastName: [''],  address: this.fb.group({    street: [''],    city: [''],    state: [''],    zip: ['']  }),  aliases: this.fb.array([    this.fb.control('')  ])});

FormGroup 中的这个 aliases 控件现在管理着一个控件,将来还可以动态添加多个。

访问 FormArray 控件

相对于重复使用 profileForm.get() 方法获取每个实例的方式,getter 可以让你轻松访问表单数组各个实例中的别名。 表单数组实例用一个数组来代表未定数量的控件。通过 getter 来访问控件很方便,这种方法还能很容易地重复处理更多控件。

使用 getter 语法创建类属性 aliases,以从父表单组中接收表示绰号的表单数组控件。

Path:"src/app/profile-editor/profile-editor.component.ts (aliases getter)" 。

get aliases() {  return this.profileForm.get('aliases') as FormArray;}

注:

  • 因为返回的控件的类型是AbstractControl,所以你要为该方法提供一个显式的类型声明来访问 FormArray 特有的语法。

定义一个方法来把一个绰号控件动态插入到绰号 FormArray 中。用 FormArray.push() 方法把该控件添加为数组中的新条目。

Path:"src/app/profile-editor/profile-editor.component.ts (add alias)" 。

addAlias() {  this.aliases.push(this.fb.control(''));}

在这个模板中,这些控件会被迭代,把每个控件都显示为一个独立的输入框。

在模板中显示表单数组

要想为表单模型添加 aliases,你必须把它加入到模板中供用户输入。和 FormGroupNameDirective 提供的 formGroupName 一样,FormArrayNameDirective 也使用 formArrayName 在这个 FormArray 实例和模板之间建立绑定。

formGroupName <div> 元素的结束标签下方,添加一段模板 HTML。

Path:"src/app/profile-editor/profile-editor.component.html (aliases form array template)" 。

<div formArrayName="aliases">  <h3>Aliases</h3> <button (click)="addAlias()">Add Alias</button>  <div *ngFor="let alias of aliases.controls; let i=index">    <!-- The repeated alias template -->    <label>      Alias:      <input type="text" [formControlName]="i">    </label>  </div></div>

*ngFor 指令对 aliases FormArray 提供的每个 FormControl 进行迭代。因为 FormArray 中的元素是匿名的,所以你要把索引号赋值给 i 变量,并且把它传给每个控件的 formControlName 输入属性。

每当新的 alias 加进来时,FormArray 的实例就会基于这个索引号提供它的控件。这将允许你在每次计算根控件的状态和值时跟踪每个控件。

添加一个别名

最初,表单只包含一个绰号字段,点击 Add Alias 按钮,就出现了另一个字段。你还可以验证由模板底部的“Form Value”显示出来的表单模型所报告的这个绰号数组。

注:

  • 除了为每个绰号使用 FormControl 之外,你还可以改用 FormGroup 来组合上一些额外字段。对其中的每个条目定义控件的过程和前面没有区别。

响应式表单 API 汇总

下列表格给出了用于创建和管理响应式表单控件的基础类和服务。

说明
AbstractControl所有三种表单控件类(FormControlFormGroup 和 FormArray)的抽象基类。它提供了一些公共的行为和属性。
FormControl管理单体表单控件的值和有效性状态。它对应于 HTML 的表单控件,比如 <input> 或 <select>
FormGroup管理一组 AbstractControl 实例的值和有效性状态。该组的属性中包括了它的子控件。组件中的顶层表单就是 FormGroup
FormArray管理一些 AbstractControl 实例数组的值和有效性状态。
FormBuilder一个可注入的服务,提供一些用于提供创建控件实例的工厂方法。

指令

指令说明
FormControlDirective把一个独立的 FormControl 实例绑定到表单控件元素。
FormControlName把一个现有 FormGroup 中的 FormControl 实例根据名字绑定到表单控件元素。
FormGroupDirective把一个现有的 FormGroup 实例绑定到 DOM 元素。
FormGroupName把一个内嵌的 FormGroup 实例绑定到一个 DOM 元素。
FormArrayName把一个内嵌的 FormArray 实例绑定到一个 DOM 元素。

通过验证用户输入的准确性和完整性,可以提高整体的数据质量。该页面显示了如何从 UI 验证用户输入,以及如何在响应式表单和模板驱动表单中显示有用的验证消息。

先决条件

在学习表单验证之前,你应该对这些内容有一个基本的了解。

  • TypeScript和 HTML5 编程。

  • Angular 应用设计的基本概念。

  • Angular 支持的两类表单。

  • 模板驱动表单或响应式表单的基础知识。

在模板驱动表单中验证输入

为了往模板驱动表单中添加验证机制,你要添加一些验证属性,就像原生的 HTML 表单验证器。 Angular 会用指令来匹配这些具有验证功能的指令。

每当表单控件中的值发生变化时,Angular 就会进行验证,并生成一个验证错误的列表(对应着 INVALID 状态)或者 null(对应着 VALID 状态)。

你可以通过把 ngModel 导出成局部模板变量来查看该控件的状态。 比如下面这个例子就把 NgModel导出成了一个名叫name` 的变量:

Path:"template/hero-form-template.component.html (name)" 。

<input id="name" name="name" class="form-control"      required minlength="4" appForbiddenName="bob"      [(ngModel)]="hero.name" #name="ngModel" ><div *ngIf="name.invalid && (name.dirty || name.touched)"    class="alert alert-danger">  <div *ngIf="name.errors.required">    Name is required.  </div>  <div *ngIf="name.errors.minlength">    Name must be at least 4 characters long.  </div>  <div *ngIf="name.errors.forbiddenName">    Name cannot be Bob.  </div></div>

  1. <input> 元素带有一些 HTML 验证属性:requiredminlength。它还带有一个自定义的验证器指令 forbiddenName。要了解更多信息,参见自定义验证器一节。

  1. #name="ngModel"NgModel 导出成了一个名叫 name 的局部变量。NgModel 把自己控制的 FormControl 实例的属性映射出去,让你能在模板中检查控件的状态,比如 validdirty

  • <div> 元素的 *ngIf 展示了一组嵌套的消息 div,但是只在有“name”错误和控制器为 dirty 或者 touched 时才出现。

  • 每个嵌套的 <div> 为其中一个可能出现的验证错误显示一条自定义消息。比如 requiredminlengthforbiddenName

为防止验证程序在用户有机会编辑表单之前就显示错误,你应该检查控件的 dirty 状态或 touched 状态。

&- 当用户在被监视的字段中修改该值时,控件就会被标记为 dirty(脏)。

&- 当用户的表单控件失去焦点时,该控件就会被标记为 touched(已接触)。

在响应式表单中验证输入

在响应式表单中,事实之源是其组件类。不应该通过模板上的属性来添加验证器,而应该在组件类中直接把验证器函数添加到表单控件模型上(FormControl)。然后,一旦控件发生了变化,Angular 就会调用这些函数。

验证器(Validator)函数

验证器函数可以是同步函数,也可以是异步函数。

  • 同步验证器:这些同步函数接受一个控件实例,然后返回一组验证错误或 null。你可以在实例化一个 FormControl 时把它作为构造函数的第二个参数传进去。

  • 异步验证器 :这些异步函数接受一个控件实例并返回一个 PromiseObservable,它稍后会发出一组验证错误或 null。在实例化 FormControl 时,可以把它们作为第三个参数传入。

出于性能方面的考虑,只有在所有同步验证器都通过之后,Angular 才会运行异步验证器。当每一个异步验证器都执行完之后,才会设置这些验证错误。

内置验证器函数

你可以选择编写自己的验证器函数,也可以使用 Angular 的一些内置验证器。

在模板驱动表单中用作属性的那些内置验证器,比如 requiredminlength,也都可以作为 Validators 类中的函数使用。

要想把这个英雄表单改造成一个响应式表单,你还是要用那些内置验证器,但这次改为用它们的函数形态。参见下面的例子。

Path:"reactive/hero-form-reactive.component.ts (validator functions)" 。

ngOnInit(): void {  this.heroForm = new FormGroup({    'name': new FormControl(this.hero.name, [      Validators.required,      Validators.minLength(4),      forbiddenNameValidator(/bob/i) // <-- Here's how you pass in the custom validator.    ]),    'alterEgo': new FormControl(this.hero.alterEgo),    'power': new FormControl(this.hero.power, Validators.required)  });}get name() { return this.heroForm.get('name'); }get power() { return this.heroForm.get('power'); }

在这个例子中,name 控件设置了两个内置验证器 - Validators.requiredValidators.minLength(4) 以及一个自定义验证器 forbiddenNameValidator

所有这些验证器都是同步的,所以它们作为第二个参数传递。注意,你可以通过把这些函数放到一个数组中传入来支持多个验证器。

这个例子还添加了一些 getter 方法。在响应式表单中,你通常会通过它所属的控件组(FormGroup)的 get 方法来访问表单控件,但有时候为模板定义一些 getter 作为简短形式。

如果你到模板中找到 name 输入框,就会发现它和模板驱动的例子很相似。

Path:"reactive/hero-form-reactive.component.html (name with error msg)" 。

<input id="name" class="form-control"      formControlName="name" required ><div *ngIf="name.invalid && (name.dirty || name.touched)"    class="alert alert-danger">  <div *ngIf="name.errors.required">    Name is required.  </div>  <div *ngIf="name.errors.minlength">    Name must be at least 4 characters long.  </div>  <div *ngIf="name.errors.forbiddenName">    Name cannot be Bob.  </div></div>

这个表单与模板驱动的版本不同,它不再导出任何指令。相反,它使用组件类中定义的 name 读取器(getter)。

请注意,

  • required 属性仍然出现在模板中。虽然它对于验证来说不是必须的,但为了无障碍性,还是应该保留它。

定义自定义验证器

内置的验证器并不是总能精确匹配应用中的用例,因此有时你需要创建一个自定义验证器。

考虑前面的响应式式表单中的 forbiddenNameValidator 函数。该函数的定义如下。

Path:"shared/forbidden-name.directive.ts (forbiddenNameValidator)" 。

/** A hero's name can't match the given regular expression */export function forbiddenNameValidator(nameRe: RegExp): ValidatorFn {  return (control: AbstractControl): {[key: string]: any} | null => {    const forbidden = nameRe.test(control.value);    return forbidden ? {'forbiddenName': {value: control.value}} : null;  };}

这个函数实际上是一个工厂,它接受一个用来检测指定名字是否已被禁用的正则表达式,并返回一个验证器函数。

在本例中,禁止的名字是“bob”; 验证器会拒绝任何带有“bob”的英雄名字。 在其它地方,只要配置的正则表达式可以匹配上,它可能拒绝“alice”或者任何其它名字。

forbiddenNameValidator 工厂函数返回配置好的验证器函数。 该函数接受一个 Angular 控制器对象,并在控制器值有效时返回 null,或无效时返回验证错误对象。 验证错误对象通常有一个名为验证秘钥(forbiddenName)的属性。其值为一个任意词典,你可以用来插入错误信息({name})。

自定义异步验证器和同步验证器很像,只是它们必须返回一个稍后会输出 null 或“验证错误对象”的承诺(Promise)或可观察对象,如果是可观察对象,那么它必须在某个时间点被完成(complete),那时候这个表单就会使用它输出的最后一个值作为验证结果。(译注:HTTP 服务是自动完成的,但是某些自定义的可观察对象可能需要手动调用 complete 方法)

把自定义验证器添加到响应式表单中

在响应式表单中,通过直接把该函数传给 FormControl 来添加自定义验证器。

Path:"reactive/hero-form-reactive.component.ts (validator functions)" 。

this.heroForm = new FormGroup({  'name': new FormControl(this.hero.name, [    Validators.required,    Validators.minLength(4),    forbiddenNameValidator(/bob/i) // <-- Here's how you pass in the custom validator.  ]),  'alterEgo': new FormControl(this.hero.alterEgo),  'power': new FormControl(this.hero.power, Validators.required)});

为模板驱动表单中添加自定义验证器

在模板驱动表单中,要为模板添加一个指令,该指令包含了 validator 函数。例如,对应的 ForbiddenValidatorDirective 用作 forbiddenNameValidator 的包装器。

Angular 在验证过程中会识别出该指令的作用,因为该指令把自己注册成了 NG_VALIDATORS 提供者,如下例所示。NG_VALIDATORS 是一个带有可扩展验证器集合的预定义提供者。

Path:"hared/forbidden-name.directive.ts (providers)" 。

providers: [{provide: NG_VALIDATORS, useExisting: ForbiddenValidatorDirective, multi: true}]

然后该指令类实现了 Validator 接口,以便它能简单的与 Angular 表单集成在一起。这个指令的其余部分有助于你理解它们是如何协作的:

Path:"shared/forbidden-name.directive.ts (directive)" 。

@Directive({  selector: '[appForbiddenName]',  providers: [{provide: NG_VALIDATORS, useExisting: ForbiddenValidatorDirective, multi: true}]})export class ForbiddenValidatorDirective implements Validator {  @Input('appForbiddenName') forbiddenName: string;  validate(control: AbstractControl): {[key: string]: any} | null {    return this.forbiddenName ? forbiddenNameValidator(new RegExp(this.forbiddenName, 'i'))(control)                              : null;  }}

一旦 ForbiddenValidatorDirective 写好了,你只要把 forbiddenName 选择器添加到输入框上就可以激活这个验证器了。比如:

Path:"template/hero-form-template.component.html (forbidden-name-input)" 。

<input id="name" name="name" class="form-control"      required minlength="4" appForbiddenName="bob"      [(ngModel)]="hero.name" #name="ngModel" >

注意

&自定义验证指令是用 useExisting 而不是 useClass 来实例化的。注册的验证程序必须是 ForbiddenValidatorDirective 实例本身 - 表单中的实例,也就是表单中 forbiddenName 属性被绑定到了"bob"的那个。

表示控件状态的 CSS 类

Angular 会自动把很多控件属性作为 CSS 类映射到控件所在的元素上。你可以使用这些类来根据表单状态给表单控件元素添加样式。目前支持下列类:

  • .ng-valid
  • .ng-invalid
  • .ng-pending
  • .ng-pristine
  • .ng-dirty
  • .ng-untouched
  • .ng-touched在下面的例子中,这个英雄表单使用 .ng-valid.ng-invalid 来设置每个表单控件的边框颜色。

Path:"forms.css (status classes)" 。

.ng-valid[required], .ng-valid.required  {  border-left: 5px solid #42A948; /* green */}.ng-invalid:not(form)  {  border-left: 5px solid #a94442; /* red */}

跨字段交叉验证

跨字段交叉验证器是一种自定义验证器,可以对表单中不同字段的值进行比较,并针对它们的组合进行接受或拒绝。例如,你可能有一个提供互不兼容选项的表单,以便让用户选择 A 或 B,而不能两者都选。某些字段值也可能依赖于其它值;用户可能只有当选择了 A 之后才能选择 B。

下列交叉验证的例子说明了如何进行如下操作:

  • 根据两个兄弟控件的值验证响应式表单或模板驱动表单的输入,

  • 当用户与表单交互过,且验证失败后,就会显示描述性的错误信息。

这些例子使用了交叉验证,以确保英雄们不会通过填写 Hero 表单来暴露自己的真实身份。验证器会通过检查英雄的名字和第二人格是否匹配来做到这一点。

为响应式表单添加交叉验证

该表单具有以下结构:

const heroForm = new FormGroup({  'name': new FormControl(),  'alterEgo': new FormControl(),  'power': new FormControl()});

注意,namealterEgo 是兄弟控件。要想在单个自定义验证器中计算这两个控件,你就必须在它们共同的祖先控件中执行验证: FormGroup。你可以在 FormGroup 中查询它的子控件,从而让你能比较它们的值。

要想给 FormGroup 添加验证器,就要在创建时把一个新的验证器传给它的第二个参数。

const heroForm = new FormGroup({  'name': new FormControl(),  'alterEgo': new FormControl(),  'power': new FormControl()}, { validators: identityRevealedValidator });

验证器的代码如下。

Path:"shared/identity-revealed.directive.ts" 。

/** A hero's name can't match the hero's alter ego */export const identityRevealedValidator: ValidatorFn = (control: FormGroup): ValidationErrors | null => {  const name = control.get('name');  const alterEgo = control.get('alterEgo');  return name && alterEgo && name.value === alterEgo.value ? { 'identityRevealed': true } : null;};

这个 identity 验证器实现了 ValidatorFn 接口。它接收一个 Angular 表单控件对象作为参数,当表单有效时,它返回一个 null,否则返回 ValidationErrors 对象。

该验证器通过调用 FormGroupget 方法来检索这些子控件,然后比较 namealterEgo 控件的值。

如果值不匹配,则 hero 的身份保持秘密,两者都有效,且 validator 返回 null。如果匹配,就说明英雄的身份已经暴露了,验证器必须通过返回一个错误对象来把这个表单标记为无效的。

为了提供更好的用户体验,当表单无效时,模板还会显示一条恰当的错误信息。

Path:"reactive/hero-form-template.component.html" 。

<div *ngIf="heroForm.errors?.identityRevealed && (heroForm.touched || heroForm.dirty)" class="cross-validation-error-message alert alert-danger">    Name cannot match alter ego.</div>

如果 FormGroup 中有一个由 identityRevealed 验证器返回的交叉验证错误,*ngIf 就会显示错误,但只有当该用户已经与表单进行过交互的时候才显示。

为模板驱动表单添加交叉验证

对于模板驱动表单,你必须创建一个指令来包装验证器函数。你可以使用NG_VALIDATORS 令牌来把该指令提供为验证器,如下例所示。

Path:"shared/identity-revealed.directive.ts" 。

@Directive({  selector: '[appIdentityRevealed]',  providers: [{ provide: NG_VALIDATORS, useExisting: IdentityRevealedValidatorDirective, multi: true }]})export class IdentityRevealedValidatorDirective implements Validator {  validate(control: AbstractControl): ValidationErrors {    return identityRevealedValidator(control)  }}

你必须把这个新指令添加到 HTML 模板中。由于验证器必须注册在表单的最高层,因此下列模板会把该指令放在 form 标签上。

Path:"template/hero-form-template.component.html" 。

<form #heroForm="ngForm" appIdentityRevealed>

为了提供更好的用户体验,当表单无效时,我们要显示一个恰当的错误信息。

Path:"template/hero-form-template.component.html" 。

<div *ngIf="heroForm.errors?.identityRevealed && (heroForm.touched || heroForm.dirty)" class="cross-validation-error-message alert alert-danger">    Name cannot match alter ego.</div>

这在模板驱动表单和响应式表单中都是一样的。

创建异步验证器

异步验证器实现了 AsyncValidatorFnAsyncValidator 接口。它们与其同步版本非常相似,但有以下不同之处。

  • validate() 函数必须返回一个 Promise 或可观察对象,

  • 返回的可观察对象必须是有尽的,这意味着它必须在某个时刻完成(complete)。要把无尽的可观察对象转换成有尽的,可以在管道中加入过滤操作符,比如 firstlasttaketakeUntil

异步验证在同步验证完成后才会发生,并且只有在同步验证成功时才会执行。如果更基本的验证方法已经发现了无效输入,那么这种检查顺序就可以让表单避免使用昂贵的异步验证流程(例如 HTTP 请求)。

异步验证开始之后,表单控件就会进入 pending 状态。你可以检查控件的 pending 属性,并用它来给出对验证中的视觉反馈。

一种常见的 UI 模式是在执行异步验证时显示 Spinner(转轮)。下面的例子展示了如何在模板驱动表单中实现这一点。

<input [(ngModel)]="name" #model="ngModel" appSomeAsyncValidator><app-spinner *ngIf="model.pending"></app-spinner>

实现自定义异步验证器

在下面的例子中,异步验证器可以确保英雄们选择了一个尚未采用的第二人格。新英雄不断涌现,老英雄也会离开,所以无法提前找到可用的人格列表。为了验证潜在的第二人格条目,验证器必须启动一个异步操作来查询包含所有在编英雄的中央数据库。

下面的代码创建了一个验证器类 UniqueAlterEgoValidator,它实现了 AsyncValidator 接口。

@Injectable({ providedIn: 'root' })export class UniqueAlterEgoValidator implements AsyncValidator {  constructor(private heroesService: HeroesService) {}  validate(    ctrl: AbstractControl  ): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> {    return this.heroesService.isAlterEgoTaken(ctrl.value).pipe(      map(isTaken => (isTaken ? { uniqueAlterEgo: true } : null)),      catchError(() => of(null))    );  }}

构造函数中注入了 HeroesService,它定义了如下接口。

interface HeroesService {  isAlterEgoTaken: (alterEgo: string) => Observable<boolean>;}

在真实的应用中,HeroesService 会负责向英雄数据库发起一个 HTTP 请求,以检查该第二人格是否可用。 从该验证器的视角看,此服务的具体实现无关紧要,所以这个例子仅仅针对 HeroesService 接口来写实现代码。

当验证开始的时候,UniqueAlterEgoValidator 把任务委托给 HeroesServiceisAlterEgoTaken() 方法,并传入当前控件的值。这时候,该控件会被标记为 pending 状态,直到 validate() 方法所返回的可观察对象完成(complete)了。

isAlterEgoTaken() 方法会调度一个 HTTP 请求来检查第二人格是否可用,并返回 Observable<boolean> 作为结果。validate() 方法通过 map 操作符来对响应对象进行管道化处理,并把它转换成验证结果。

与任何验证器一样,如果表单有效,该方法返回 null,如果无效,则返回 ValidationErrors。这个验证器使用 catchError 操作符来处理任何潜在的错误。在这个例子中,验证器将 isAlterEgoTaken() 错误视为成功的验证,因为未能发出验证请求并不一定意味着这个第二人格无效。你也可以用不同的方式处理这种错误,比如返回 ValidationError 对象。

一段时间过后,这条可观察对象链完成,异步验证也就完成了。pending 标志位也设置为 false,该表单的有效性也已更新。

优化异步验证器的性能

默认情况下,所有验证程序在每次表单值更改后都会运行。对于同步验证器,这通常不会对应用性能产生明显的影响。但是,异步验证器通常会执行某种 HTTP 请求来验证控件。每次击键后调度一次 HTTP 请求都会给后端 API 带来压力,应该尽可能避免。

你可以把 updateOn 属性从 change(默认值)改成 submitblur 来推迟表单验证的更新时机。

使用模板驱动表单时,可以在模板中设置该属性。

<input [(ngModel)]="name" [ngModelOptions]="{updateOn: 'blur'}">

使用响应式表单时,可以在 FormControl 实例中设置该属性。

new FormControl('', {updateOn: 'blur'});

许多表单(例如问卷)可能在格式和意图上都非常相似。为了更快更轻松地生成这种表单的不同版本,你可以根据描述业务对象模型的元数据来创建动态表单模板。然后就可以根据数据模型中的变化,使用该模板自动生成新的表单。

如果你有这样一种表单,其内容必须经常更改以满足快速变化的业务需求和监管需求,该技术就特别有用。一个典型的例子就是问卷。你可能需要在不同的上下文中获取用户的意见。用户要看到的表单格式和样式应该保持不变,而你要提的实际问题则会因上下文而异。

在本教程中,你将构建一个渲染基本问卷的动态表单。你要为正在找工作的英雄们建立一个在线应用。英雄管理局会不断修补应用流程,但是借助动态表单,你可以动态创建新的表单,而无需修改应用代码。

本教程将指导你完成以下步骤。

  1. 为项目启用响应式表单。

  1. 建立一个数据模型来表示表单控件。

  1. 使用示例数据填充模型。

  1. 开发一个组件来动态创建表单控件。

你创建的表单会使用输入验证和样式来改善用户体验。它有一个 Submit 按钮,这个按钮只会在所有的用户输入都有效时启用,并用色彩和一些错误信息来标记出无效输入。

这个基本版可以不断演进,以支持更多的问题类型、更优雅的渲染体验以及更高大上的用户体验。

先决条件

在学习本节之前,你应该对下列内容有一个基本的了解。

为项目启用响应式表单

动态表单是基于响应式表单的。为了让应用访问响应式表达式指令,根模块会从 @angular/forms 库中导入 ReactiveFormsModule

以下代码展示了此范例在根模块中所做的设置。

  1. Path:"src/app/app.module.ts" 。

    import { BrowserModule }                from '@angular/platform-browser';    import { ReactiveFormsModule }          from '@angular/forms';    import { NgModule }                     from '@angular/core';    import { AppComponent }                 from './app.component';    import { DynamicFormComponent }         from './dynamic-form.component';    import { DynamicFormQuestionComponent } from './dynamic-form-question.component';    @NgModule({      imports: [ BrowserModule, ReactiveFormsModule ],      declarations: [ AppComponent, DynamicFormComponent, DynamicFormQuestionComponent ],      bootstrap: [ AppComponent ]    })    export class AppModule {      constructor() {      }    }

  1. Path:"src/app/main.ts" 。

    import { enableProdMode } from '@angular/core';    import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';    import { AppModule } from './app/app.module';    import { environment } from './environments/environment';    if (environment.production) {      enableProdMode();    }    platformBrowserDynamic().bootstrapModule(AppModule);

创建一个表单对象模型

动态表单需要一个对象模型来描述此表单功能所需的全部场景。英雄应用表单中的例子是一组问题 - 也就是说,表单中的每个控件都必须提问并接受一个答案。

此类表单的数据模型必须能表示一个问题。本例中包含 DynamicFormQuestionComponent,它定义了一个问题作为模型中的基本对象。

这个 QuestionBase 是一组控件的基类,可以在表单中表示问题及其答案。

Path:"src/app/question-base.ts" 。

export class QuestionBase<T> {  value: T;  key: string;  label: string;  required: boolean;  order: number;  controlType: string;  type: string;  options: {key: string, value: string}[];  constructor(options: {      value?: T,      key?: string,      label?: string,      required?: boolean,      order?: number,      controlType?: string,      type?: string    } = {}) {    this.value = options.value;    this.key = options.key || '';    this.label = options.label || '';    this.required = !!options.required;    this.order = options.order === undefined ? 1 : options.order;    this.controlType = options.controlType || '';    this.type = options.type || '';  }}

定义控件类

此范例从这个基类派生出两个新类,TextboxQuestionDropdownQuestion,分别代表不同的控件类型。当你在下一步中创建表单模板时,你会实例化这些具体的问题类,以便动态渲染相应的控件。

  1. TextboxQuestion 控件类型表示一个普通问题,并允许用户输入答案。

Path:"src/app/question-textbox.ts" 。

    import { QuestionBase } from './question-base';    export class TextboxQuestion extends QuestionBase<string> {      controlType = 'textbox';      type: string;      constructor(options: {} = {}) {        super(options);        this.type = options['type'] || '';      }    }

TextboxQuestion 控件类型将使用 <input> 元素表示在表单模板中。该元素的 type 属性将根据 options 参数中指定的 type 字段定义(例如 textemailurl )。

  1. DropdownQuestion 控件表示在选择框中的一个选项列表。

Path:"src/app/question-dropdown.ts" 。

    import { QuestionBase } from './question-base';    export class DropdownQuestion extends QuestionBase<string> {      controlType = 'dropdown';      options: {key: string, value: string}[] = [];      constructor(options: {} = {}) {        super(options);        this.options = options['options'] || [];      }    }

编写表单组

动态表单会使用一个服务来根据表单模型创建输入控件的分组集合。下面的 QuestionControlService 会收集一组 FormGroup 实例,这些实例会消费问题模型中的元数据。你可以指定一些默认值和验证规则。

Path:"src/app/question-control.service.ts" 。

import { Injectable }   from '@angular/core';import { FormControl, FormGroup, Validators } from '@angular/forms';import { QuestionBase } from './question-base';@Injectable()export class QuestionControlService {  constructor() { }  toFormGroup(questions: QuestionBase<string>[] ) {    let group: any = {};    questions.forEach(question => {      group[question.key] = question.required ? new FormControl(question.value || '', Validators.required)                                              : new FormControl(question.value || '');    });    return new FormGroup(group);  }}

编写动态表单内容

动态表单本身就是一个容器组件,稍后你会添加它。每个问题都会在表单组件的模板中用一个 <app-question> 标签表示,该标签会匹配 DynamicFormQuestionComponent 中的一个实例。

DynamicFormQuestionComponent 负责根据数据绑定的问题对象中的各种值来渲染单个问题的详情。该表单依靠 [formGroup] 指令来将模板 HTML 和底层的控件对象联系起来。DynamicFormQuestionComponent 会创建表单组,并用问题模型中定义的控件来填充它们,并指定显示和验证规则。

  1. Path:"src/app/dynamic-form-question.component.html" 。

    <div [formGroup]="form">      <label [attr.for]="question.key">{{question.label}}</label>      <div [ngSwitch]="question.controlType">        <input *ngSwitchCase="'textbox'" [formControlName]="question.key"                [id]="question.key" [type]="question.type">        <select [id]="question.key" *ngSwitchCase="'dropdown'" [formControlName]="question.key">          <option *ngFor="let opt of question.options" [value]="opt.key">{{opt.value}}</option>        </select>      </div>      <div class="errorMessage" *ngIf="!isValid">{{question.label}} is required</div>    </div>

  1. Path:"src/app/dynamic-form-question.component.ts" 。

    import { Component, Input } from '@angular/core';    import { FormGroup }        from '@angular/forms';    import { QuestionBase }     from './question-base';    @Component({      selector: 'app-question',      templateUrl: './dynamic-form-question.component.html'    })    export class DynamicFormQuestionComponent {      @Input() question: QuestionBase<string>;      @Input() form: FormGroup;      get isValid() { return this.form.controls[this.question.key].valid; }    }

DynamicFormQuestionComponent 的目标是展示模型中定义的各类问题。你现在只有两类问题,但可以想象将来还会有更多。模板中的 ngSwitch 语句会决定要显示哪种类型的问题。这里用到了带有 formControlNameformGroup 选择器的指令。这两个指令都是在 ReactiveFormsModule 中定义的。

提供数据

还要另外一项服务来提供一组具体的问题,以便构建出一个单独的表单。在本练习中,你将创建 QuestionService 以从硬编码的范例数据中提供这组问题。在真实世界的应用中,该服务可能会从后端获取数据。重点是,你可以完全通过 QuestionService 返回的对象来控制英雄的求职申请问卷。要想在需求发生变化时维护问卷,你只需要在 questions 数组中添加、更新和删除对象。

QuestionService 以一个绑定到 @Input() 的问题数组的形式提供了一组问题。

Path:"src/app/question.service.ts" 。

import { Injectable }       from '@angular/core';import { DropdownQuestion } from './question-dropdown';import { QuestionBase }     from './question-base';import { TextboxQuestion }  from './question-textbox';import { of } from 'rxjs';@Injectable()export class QuestionService {  // TODO: get from a remote source of question metadata  getQuestions() {    let questions: QuestionBase<string>[] = [      new DropdownQuestion({        key: 'brave',        label: 'Bravery Rating',        options: [          {key: 'solid',  value: 'Solid'},          {key: 'great',  value: 'Great'},          {key: 'good',   value: 'Good'},          {key: 'unproven', value: 'Unproven'}        ],        order: 3      }),      new TextboxQuestion({        key: 'firstName',        label: 'First name',        value: 'Bombasto',        required: true,        order: 1      }),      new TextboxQuestion({        key: 'emailAddress',        label: 'Email',        type: 'email',        order: 2      })    ];    return of(questions.sort((a, b) => a.order - b.order));  }}

创建一个动态表单模板

DynamicFormComponent 组件是表单的入口点和主容器,它在模板中用 <app-dynamic-form> 表示。

DynamicFormComponent 组件通过把每个问题都绑定到一个匹配 DynamicFormQuestionComponent<app-question> 元素来渲染问题列表。

  1. Path:"src/app/dynamic-form.component.html" 。

    <div>      <form (ngSubmit)="onSubmit()" [formGroup]="form">        <div *ngFor="let question of questions" class="form-row">          <app-question [question]="question" [form]="form"></app-question>        </div>        <div class="form-row">          <button type="submit" [disabled]="!form.valid">Save</button>        </div>      </form>      <div *ngIf="payLoad" class="form-row">        <strong>Saved the following values</strong><br>{{payLoad}}      </div>    </div>

  1. Path:"src/app/dynamic-form.component.ts" 。

    import { Component, Input, OnInit }  from '@angular/core';    import { FormGroup }                 from '@angular/forms';    import { QuestionBase }              from './question-base';    import { QuestionControlService }    from './question-control.service';    @Component({      selector: 'app-dynamic-form',      templateUrl: './dynamic-form.component.html',      providers: [ QuestionControlService ]    })    export class DynamicFormComponent implements OnInit {      @Input() questions: QuestionBase<string>[] = [];      form: FormGroup;      payLoad = '';      constructor(private qcs: QuestionControlService) {  }      ngOnInit() {        this.form = this.qcs.toFormGroup(this.questions);      }      onSubmit() {        this.payLoad = JSON.stringify(this.form.getRawValue());      }    }

显示表单

要显示动态表单的一个实例,AppComponent 外壳模板会把一个 QuestionService 返回的 questions 数组传递给表单容器组件 <app-dynamic-form>

Path:"src/app/app.component.ts" 。

import { Component }       from '@angular/core';import { QuestionService } from './question.service';import { QuestionBase }    from './question-base';import { Observable }      from 'rxjs';@Component({  selector: 'app-root',  template: `    <div>      <h2>Job Application for Heroes</h2>      <app-dynamic-form [questions]="questions$ | async"></app-dynamic-form>    </div>  `,  providers:  [QuestionService]})export class AppComponent {  questions$: Observable<QuestionBase<any>[]>;  constructor(service: QuestionService) {    this.questions$ = service.getQuestions();  }}

这个例子为英雄提供了一个工作申请表的模型,但是除了 QuestionService 返回的对象外,没有涉及任何跟英雄有关的问题。这种模型和数据的分离,允许你为任何类型的调查表复用这些组件,只要它与这个问题对象模型兼容即可。

确保数据有效

表单模板使用元数据的动态数据绑定来渲染表单,而不用做任何与具体问题有关的硬编码。它动态添加了控件元数据和验证标准。

要确保输入有效,就要禁用 “Save” 按钮,直到此表单处于有效状态。当表单有效时,你可以单击 “Save” 按钮,该应用就会把表单的当前值渲染为 JSON。

最终的表单如下图所示。

注:

  • 不同类型的表单和控件集合

本教程展示了如何构建一个问卷,它只是一种动态表单。这个例子使用 `FormGroup` 来收集一组控件。有关不同类型动态表单的示例,请参阅在 [响应式表单](https://www.51coolma.cn/angulerten/angulerten-yi9337wt.html) 中的创建动态表单一节。那个例子还展示了如何使用 `FormArray` 而不是 `FormGroup` 来收集一组控件。

  • 验证用户输入

[验证表单输入](https://www.51coolma.cn/angulerten/angulerten-gsm437ww.html) 部分介绍了如何在响应式表单中进行输入验证的基础知识。

使用可观察对象(Observable)来传递值

可观察对象对在应用的各个部分之间传递消息提供了支持。 它们在 Angular 中频繁使用,并且推荐把它们用于事件处理、异步编程以及处理多个值等场景。

观察者(Observer)模式是一个软件设计模式,它有一个对象,称之为主体 Subject,负责维护一个依赖项(称之为观察者 Observer)的列表,并且在状态变化时自动通知它们。 该模式和发布/订阅模式非常相似(但不完全一样)。

可观察对象是声明式的 —— 也就是说,虽然你定义了一个用于发布值的函数,但是在有消费者订阅它之前,这个函数并不会实际执行。 订阅之后,当这个函数执行完或取消订阅时,订阅者就会收到通知。

可观察对象可以发送多个任意类型的值 —— 字面量、消息、事件。无论这些值是同步发送的还是异步发送的,接收这些值的 API 都是一样的。 由于准备(setup)和清场(teardown)的逻辑都是由可观察对象自己处理的,因此你的应用代码只管订阅并消费这些值就可以了,做完之后,取消订阅。无论这个流是击键流、HTTP 响应流还是定时器,对这些值进行监听和停止监听的接口都是一样的。

由于这些优点,可观察对象在 Angular 中得到广泛使用,也同样建议应用开发者好好使用它。

基本用法和词汇

作为发布者,你创建一个 Observable 的实例,其中定义了一个订阅者(subscriber)函数。 当有消费者调用 subscribe() 方法时,这个函数就会执行。 订阅者函数用于定义“如何获取或生成那些要发布的值或消息”。

要执行所创建的可观察对象,并开始从中接收通知,你就要调用它的 subscribe() 方法,并传入一个观察者(observer)。 这是一个 JavaScript 对象,它定义了你收到的这些消息的处理器(handler)。 subscribe() 调用会返回一个 Subscription 对象,该对象具有一个 unsubscribe() 方法。 当调用该方法时,你就会停止接收通知。

下面这个例子中示范了这种基本用法,它展示了如何使用可观察对象来对当前地理位置进行更新。

//Observe geolocation updates// Create an Observable that will start listening to geolocation updates// when a consumer subscribes.const locations = new Observable((observer) => {  let watchId: number;  // Simple geolocation API check provides values to publish  if ('geolocation' in navigator) {    watchId = navigator.geolocation.watchPosition((position: Position) => {      observer.next(position);    }, (error: PositionError) => {      observer.error(error);    });  } else {    observer.error('Geolocation not available');  }  // When the consumer unsubscribes, clean up data ready for next subscription.  return {    unsubscribe() {      navigator.geolocation.clearWatch(watchId);    }  };});// Call subscribe() to start listening for updates.const locationsSubscription = locations.subscribe({  next(position) {    console.log('Current Position: ', position);  },  error(msg) {    console.log('Error Getting Location: ', msg);  }});// Stop listening for location after 10 secondssetTimeout(() => {  locationsSubscription.unsubscribe();}, 10000);

定义观察者

用于接收可观察对象通知的处理器要实现 Observer 接口。这个对象定义了一些回调函数来处理可观察对象可能会发来的三种通知:

通知类型说明
next必要。用来处理每个送达值。在开始执行后可能执行零次或多次。
error可选。用来处理错误通知。错误会中断这个可观察对象实例的执行过程。
complete可选。用来处理执行完毕(complete)通知。当执行完毕后,这些值就会继续传给下一个处理器。

观察者对象可以定义这三种处理器的任意组合。如果你不为某种通知类型提供处理器,这个观察者就会忽略相应类型的通知。

订阅

只有当有人订阅 Observable 的实例时,它才会开始发布值。 订阅时要先调用该实例的 subscribe() 方法,并把一个观察者对象传给它,用来接收通知。

为了展示订阅的原理,我们需要创建新的可观察对象。它有一个构造函数可以用来创建新实例,但是为了更简明,也可以使用 Observable 上定义的一些静态方法来创建一些常用的简单可观察对象:

- `of(...items)` —— 返回一个 `Observable` 实例,它用同步的方式把参数中提供的这些值发送出来。

- `from(iterable)` —— 把它的参数转换成一个 `Observable` 实例。 该方法通常用于把一个数组转换成一个(发送多个值的)可观察对象。

下面的例子会创建并订阅一个简单的可观察对象,它的观察者会把接收到的消息记录到控制台中:

//Subscribe using observer// Create simple observable that emits three valuesconst myObservable = of(1, 2, 3);// Create observer objectconst myObserver = {  next: x => console.log('Observer got a next value: ' + x),  error: err => console.error('Observer got an error: ' + err),  complete: () => console.log('Observer got a complete notification'),};// Execute with the observer objectmyObservable.subscribe(myObserver);// Logs:// Observer got a next value: 1// Observer got a next value: 2// Observer got a next value: 3// Observer got a complete notification

另外,subscribe() 方法还可以接收定义在同一行中的回调函数,无论 nexterror 还是 complete 处理器。比如,下面的 subscribe() 调用和前面指定预定义观察者的例子是等价的。

//Subscribe with positional argumentsmyObservable.subscribe(  x => console.log('Observer got a next value: ' + x),  err => console.error('Observer got an error: ' + err),  () => console.log('Observer got a complete notification'));

无论哪种情况,next 处理器都是必要的,而 errorcomplete 处理器是可选的。

注意,next() 函数可以接受消息字符串、事件对象、数字值或各种结构,具体类型取决于上下文。 为了更通用一点,我们把由可观察对象发布出来的数据统称为流。任何类型的值都可以表示为可观察对象,而这些值会被发布为一个流。

创建可观察对象

使用 Observable 构造函数可以创建任何类型的可观察流。 当执行可观察对象的 subscribe() 方法时,这个构造函数就会把它接收到的参数作为订阅函数来运行。 订阅函数会接收一个 Observer 对象,并把值发布给观察者的 next() 方法。

比如,要创建一个与前面的 of(1, 2, 3) 等价的可观察对象,你可以这样做:

//Create observable with constructor// This function runs when subscribe() is calledfunction sequenceSubscriber(observer) {  // synchronously deliver 1, 2, and 3, then complete  observer.next(1);  observer.next(2);  observer.next(3);  observer.complete();  // unsubscribe function doesn't need to do anything in this  // because values are delivered synchronously  return {unsubscribe() {}};}// Create a new Observable that will deliver the above sequenceconst sequence = new Observable(sequenceSubscriber);// execute the Observable and print the result of each notificationsequence.subscribe({  next(num) { console.log(num); },  complete() { console.log('Finished sequence'); }});// Logs:// 1// 2// 3// Finished sequence

如果要略微加强这个例子,我们可以创建一个用来发布事件的可观察对象。在这个例子中,订阅函数是用内联方式定义的。

//Create with custom fromEvent functionfunction fromEvent(target, eventName) {  return new Observable((observer) => {    const handler = (e) => observer.next(e);    // Add the event handler to the target    target.addEventListener(eventName, handler);    return () => {      // Detach the event handler from the target      target.removeEventListener(eventName, handler);    };  });}

现在,你就可以使用这个函数来创建可发布 keydown 事件的可观察对象了:

//Use custom fromEvent functionconst ESC_KEY = 27;const nameInput = document.getElementById('name') as HTMLInputElement;const subscription = fromEvent(nameInput, 'keydown')  .subscribe((e: KeyboardEvent) => {    if (e.keyCode === ESC_KEY) {      nameInput.value = '';    }  });

多播

典型的可观察对象会为每一个观察者创建一次新的、独立的执行。 当观察者进行订阅时,该可观察对象会连上一个事件处理器,并且向那个观察者发送一些值。当第二个观察者订阅时,这个可观察对象就会连上一个新的事件处理器,并独立执行一次,把这些值发送给第二个可观察对象。

有时候,不应该对每一个订阅者都独立执行一次,你可能会希望每次订阅都得到同一批值 —— 即使是那些你已经发送过的。这在某些情况下有用,比如用来发送 document 上的点击事件的可观察对象。

多播用来让可观察对象在一次执行中同时广播给多个订阅者。借助支持多播的可观察对象,你不必注册多个监听器,而是复用第一个(next)监听器,并且把值发送给各个订阅者。

当创建可观察对象时,你要决定你希望别人怎么用这个对象以及是否对它的值进行多播。

来看一个从 1 到 3 进行计数的例子,它每发出一个数字就会等待 1 秒。

//Create a delayed sequencefunction sequenceSubscriber(observer) {  const seq = [1, 2, 3];  let timeoutId;  // Will run through an array of numbers, emitting one value  // per second until it gets to the end of the array.  function doSequence(arr, idx) {    timeoutId = setTimeout(() => {      observer.next(arr[idx]);      if (idx === arr.length - 1) {        observer.complete();      } else {        doSequence(arr, ++idx);      }    }, 1000);  }  doSequence(seq, 0);  // Unsubscribe should clear the timeout to stop execution  return {unsubscribe() {    clearTimeout(timeoutId);  }};}// Create a new Observable that will deliver the above sequenceconst sequence = new Observable(sequenceSubscriber);sequence.subscribe({  next(num) { console.log(num); },  complete() { console.log('Finished sequence'); }});// Logs:// (at 1 second): 1// (at 2 seconds): 2// (at 3 seconds): 3// (at 3 seconds): Finished sequence

注意,如果你订阅了两次,就会有两个独立的流,每个流都会每秒发出一个数字。代码如下:

//Two subscriptions// Subscribe starts the clock, and will emit after 1 secondsequence.subscribe({  next(num) { console.log('1st subscribe: ' + num); },  complete() { console.log('1st sequence finished.'); }});// After 1/2 second, subscribe again.setTimeout(() => {  sequence.subscribe({    next(num) { console.log('2nd subscribe: ' + num); },    complete() { console.log('2nd sequence finished.'); }  });}, 500);// Logs:// (at 1 second): 1st subscribe: 1// (at 1.5 seconds): 2nd subscribe: 1// (at 2 seconds): 1st subscribe: 2// (at 2.5 seconds): 2nd subscribe: 2// (at 3 seconds): 1st subscribe: 3// (at 3 seconds): 1st sequence finished// (at 3.5 seconds): 2nd subscribe: 3// (at 3.5 seconds): 2nd sequence finished

修改这个可观察对象以支持多播,代码如下:

//Create a multicast subscriberfunction multicastSequenceSubscriber() {  const seq = [1, 2, 3];  // Keep track of each observer (one for every active subscription)  const observers = [];  // Still a single timeoutId because there will only ever be one  // set of values being generated, multicasted to each subscriber  let timeoutId;  // Return the subscriber function (runs when subscribe()  // function is invoked)  return (observer) => {    observers.push(observer);    // When this is the first subscription, start the sequence    if (observers.length === 1) {      timeoutId = doSequence({        next(val) {          // Iterate through observers and notify all subscriptions          observers.forEach(obs => obs.next(val));        },        complete() {          // Notify all complete callbacks          observers.slice(0).forEach(obs => obs.complete());        }      }, seq, 0);    }    return {      unsubscribe() {        // Remove from the observers array so it's no longer notified        observers.splice(observers.indexOf(observer), 1);        // If there's no more listeners, do cleanup        if (observers.length === 0) {          clearTimeout(timeoutId);        }      }    };  };}// Run through an array of numbers, emitting one value// per second until it gets to the end of the array.function doSequence(observer, arr, idx) {  return setTimeout(() => {    observer.next(arr[idx]);    if (idx === arr.length - 1) {      observer.complete();    } else {      doSequence(observer, arr, ++idx);    }  }, 1000);}// Create a new Observable that will deliver the above sequenceconst multicastSequence = new Observable(multicastSequenceSubscriber());// Subscribe starts the clock, and begins to emit after 1 secondmulticastSequence.subscribe({  next(num) { console.log('1st subscribe: ' + num); },  complete() { console.log('1st sequence finished.'); }});// After 1 1/2 seconds, subscribe again (should "miss" the first value).setTimeout(() => {  multicastSequence.subscribe({    next(num) { console.log('2nd subscribe: ' + num); },    complete() { console.log('2nd sequence finished.'); }  });}, 1500);// Logs:// (at 1 second): 1st subscribe: 1// (at 2 seconds): 1st subscribe: 2// (at 2 seconds): 2nd subscribe: 2// (at 3 seconds): 1st subscribe: 3// (at 3 seconds): 1st sequence finished// (at 3 seconds): 2nd subscribe: 3// (at 3 seconds): 2nd sequence finished

虽然支持多播的可观察对象需要做更多的准备工作,但对某些应用来说,这非常有用。稍后我们会介绍一些简化多播的工具,它们让你能接收任何可观察对象,并把它变成支持多播的。

错误处理

由于可观察对象会异步生成值,所以用 try/catch 是无法捕获错误的。你应该在观察者中指定一个 error 回调来处理错误。发生错误时还会导致可观察对象清理现有的订阅,并且停止生成值。可观察对象可以生成值(调用 next 回调),也可以调用 completeerror 回调来主动结束。

myObservable.subscribe({  next(num) { console.log('Next num: ' + num)},  error(err) { console.log('Received an errror: ' + err)}});

响应式编程是一种面向数据流和变更传播的异步编程范式(Wikipedia)。RxJS(响应式扩展的 JavaScript 版)是一个使用可观察对象进行响应式编程的库,它让组合异步代码和基于回调的代码变得更简单。参见 RxJS 官方文档。

RxJS 提供了一种对 Observable 类型的实现,直到 Observable 成为了 JavaScript 语言的一部分并且浏览器支持它之前,它都是必要的。这个库还提供了一些工具函数,用于创建和使用可观察对象。这些工具函数可用于:

  • 把现有的异步代码转换成可观察对象

  • 迭代流中的各个值

  • 把这些值映射成其它类型

  • 对流进行过滤

  • 组合多个流

创建可观察对象的函数

RxJS 提供了一些用来创建可观察对象的函数。这些函数可以简化根据某些东西创建可观察对象的过程,比如事件、定时器、承诺等等。比如:

//Create an observable from a promiseimport { from } from 'rxjs';// Create an Observable out of a promiseconst data = from(fetch('/api/endpoint'));// Subscribe to begin listening for async resultdata.subscribe({  next(response) { console.log(response); },  error(err) { console.error('Error: ' + err); },  complete() { console.log('Completed'); }});

//Create an observable from a counterimport { interval } from 'rxjs';// Create an Observable that will publish a value on an intervalconst secondsCounter = interval(1000);// Subscribe to begin publishing valuessecondsCounter.subscribe(n =>  console.log(`It's been ${n} seconds since subscribing!`));

//Create an observable from an eventimport { fromEvent } from 'rxjs';const el = document.getElementById('my-element');// Create an Observable that will publish mouse movementsconst mouseMoves = fromEvent(el, 'mousemove');// Subscribe to start listening for mouse-move eventsconst subscription = mouseMoves.subscribe((evt: MouseEvent) => {  // Log coords of mouse movements  console.log(`Coords: ${evt.clientX} X ${evt.clientY}`);  // When the mouse is over the upper-left of the screen,  // unsubscribe to stop listening for mouse movements  if (evt.clientX < 40 && evt.clientY < 40) {    subscription.unsubscribe();  }});

//Create an observable that creates an AJAX requestimport { ajax } from 'rxjs/ajax';// Create an Observable that will create an AJAX requestconst apiData = ajax('/api/data');// Subscribe to create the requestapiData.subscribe(res => console.log(res.status, res.response));

操作符

操作符是基于可观察对象构建的一些对集合进行复杂操作的函数。RxJS 定义了一些操作符,比如 map()filter()concat()flatMap()

操作符接受一些配置项,然后返回一个以来源可观察对象为参数的函数。当执行这个返回的函数时,这个操作符会观察来源可观察对象中发出的值,转换它们,并返回由转换后的值组成的新的可观察对象。下面是一个简单的例子:

Path:"Map operator" 。

import { map } from 'rxjs/operators';const nums = of(1, 2, 3);const squareValues = map((val: number) => val * val);const squaredNums = squareValues(nums);squaredNums.subscribe(x => console.log(x));// Logs// 1// 4// 9

你可以使用管道来把这些操作符链接起来。管道让你可以把多个由操作符返回的函数组合成一个。pipe() 函数以你要组合的这些函数作为参数,并且返回一个新的函数,当执行这个新函数时,就会顺序执行那些被组合进去的函数。

应用于某个可观察对象上的一组操作符就像一个处理流程 —— 也就是说,对你感兴趣的这些值进行处理的一组操作步骤。这个处理流程本身不会做任何事。你需要调用 subscribe() 来通过处理流程得出并生成一个结果。

例子如下:

Path:"Standalone pipe function" 。

import { filter, map } from 'rxjs/operators';const nums = of(1, 2, 3, 4, 5);// Create a function that accepts an Observable.const squareOddVals = pipe(  filter((n: number) => n % 2 !== 0),  map(n => n * n));// Create an Observable that will run the filter and map functionsconst squareOdd = squareOddVals(nums);// Subscribe to run the combined functionssquareOdd.subscribe(x => console.log(x));

pipe() 函数也同时是 RxJS 的 Observable 上的一个方法,所以你可以用下列简写形式来达到同样的效果:

Path:"Observable.pipe function" 。

import { filter, map } from 'rxjs/operators';const squareOdd = of(1, 2, 3, 4, 5)  .pipe(    filter(n => n % 2 !== 0),    map(n => n * n)  );// Subscribe to get valuessquareOdd.subscribe(x => console.log(x));

常用操作符

RxJS 提供了很多操作符,不过只有少数是常用的。 下面是一个常用操作符的列表和用法范例,参见 [RxJS API]() 文档。

注:

  • 对于 Angular 应用来说,我们提倡使用管道来组合操作符,而不是使用链式写法。链式写法仍然在很多 RxJS 中使用着。
类别操作符
创建from,fromEvent, of
组合combineLatest, concat, merge, startWith , withLatestFrom, zip
过滤debounceTime, distinctUntilChanged, filter, take, takeUntil
转换bufferTime, concatMap, map, mergeMap, scan, switchMap
工具tap
多播share

错误处理

除了可以在订阅时提供 error() 处理器外,RxJS 还提供了 catchError 操作符,它允许你在管道中处理已知错误。

假设你有一个可观察对象,它发起 API 请求,然后对服务器返回的响应进行映射。如果服务器返回了错误或值不存在,就会生成一个错误。如果你捕获这个错误并提供了一个默认值,流就会继续处理这些值,而不会报错。

下面是使用 catchError 操作符实现这种效果的例子:

Path:"catchError operator" 。

import { ajax } from 'rxjs/ajax';import { map, catchError } from 'rxjs/operators';// Return "response" from the API. If an error happens,// return an empty array.const apiData = ajax('/api/data').pipe(  map(res => {    if (!res.response) {      throw new Error('Value expected!');    }    return res.response;  }),  catchError(err => of([])));apiData.subscribe({  next(x) { console.log('data: ', x); },  error(err) { console.log('errors already caught... will not run'); }});

重试失败的可观察对象

catchError 提供了一种简单的方式进行恢复,而 retry 操作符让你可以尝试失败的请求。

可以在 catchError 之前使用 retry 操作符。它会订阅到原始的来源可观察对象,它可以重新运行导致结果出错的动作序列。如果其中包含 HTTP 请求,它就会重新发起那个 HTTP 请求。

下列代码把前面的例子改成了在捕获错误之前重发请求:

Path:"retry operator" 。

import { ajax } from 'rxjs/ajax';import { map, retry, catchError } from 'rxjs/operators';const apiData = ajax('/api/data').pipe(  retry(3), // Retry up to 3 times before failing  map(res => {    if (!res.response) {      throw new Error('Value expected!');    }    return res.response;  }),  catchError(err => of([])));apiData.subscribe({  next(x) { console.log('data: ', x); },  error(err) { console.log('errors already caught... will not run'); }});

不要重试登录认证请求,这些请求只应该由用户操作触发。我们肯定不会希望自动重复发送登录请求导致用户的账号被锁定。

可观察对象的命名约定

由于 Angular 的应用几乎都是用 TypeScript 写的,你通常会希望知道某个变量是否可观察对象。虽然 Angular 框架并没有针对可观察对象的强制性命名约定,不过你经常会看到可观察对象的名字以“$”符号结尾。

这在快速浏览代码并查找可观察对象值时会非常有用。同样的,如果你希望用某个属性来存储来自可观察对象的最近一个值,它的命名惯例是与可观察对象同名,但不带“$”后缀。

比如:

Path:"Naming observables" 。

import { Component } from '@angular/core';import { Observable } from 'rxjs';@Component({  selector: 'app-stopwatch',  templateUrl: './stopwatch.component.html'})export class StopwatchComponent {  stopwatchValue: number;  stopwatchValue$: Observable<number>;  start() {    this.stopwatchValue$.subscribe(num =>      this.stopwatchValue = num    );  }}

Angular 中的可观察对象

Angular 使用可观察对象作为处理各种常用异步操作的接口。比如:

  • EventEmitter 类派生自 Observable

  • HTTP 模块使用可观察对象来处理 AJAX 请求和响应。

  • 路由器和表单模块使用可观察对象来监听对用户输入事件的响应。

在组件之间传递数据

Angular 提供了一个 EventEmitter 类,它用来通过组件的 @Output() 装饰器 发送一些值。EventEmitter 扩展了 RxJS Subject,并添加了一个 emit() 方法,这样它就可以发送任意值了。当你调用 emit() 时,就会把所发送的值传给订阅上来的观察者的 next() 方法。

这种用法的例子参见 EventEmitter 文档。下面这个范例组件监听了 openclose 事件:

<zippy (open)="onOpen($event)" (close)="onClose($event)"></zippy>

组件的定义如下:

//EventEmitter@Component({  selector: 'zippy',  template: `  <div class="zippy">    <div (click)="toggle()">Toggle</div>    <div [hidden]="!visible">      <ng-content></ng-content>    </div>  </div>`})export class ZippyComponent {  visible = true;  @Output() open = new EventEmitter<any>();  @Output() close = new EventEmitter<any>();  toggle() {    this.visible = !this.visible;    if (this.visible) {      this.open.emit(null);    } else {      this.close.emit(null);    }  }}

HTTP

Angular 的 HttpClient 从 HTTP 方法调用中返回了可观察对象。例如,"http.get(‘/api’)" 就会返回可观察对象。相对于基于承诺(Promise)的 HTTP API,它有一系列优点:

  • 可观察对象不会修改服务器的响应(和在承诺上串联起来的 .then() 调用一样)。反之,你可以使用一系列操作符来按需转换这些值。

  • HTTP 请求是可以通过 unsubscribe() 方法来取消的。

  • 请求可以进行配置,以获取进度事件的变化。

  • 失败的请求很容易重试。

Async 管道

AsyncPipe 会订阅一个可观察对象或承诺,并返回其发出的最后一个值。当发出新值时,该管道就会把这个组件标记为需要进行变更检查的(译注:因此可能导致刷新界面)。

下面的例子把 time 这个可观察对象绑定到了组件的视图中。这个可观察对象会不断使用当前时间更新组件的视图。

//Using async pipe@Component({  selector: 'async-observable-pipe',  template: `<div><code>observable|async</code>:       Time: {{ time | async }}</div>`})export class AsyncObservablePipeComponent {  time = new Observable<string>(observer => {    setInterval(() => observer.next(new Date().toString()), 1000);  });}

路由器 (router)

Router.events 以可观察对象的形式提供了其事件。 你可以使用 RxJS 中的 filter() 操作符来找到感兴趣的事件,并且订阅它们,以便根据浏览过程中产生的事件序列作出决定。 例子如下:

//Router eventsimport { Router, NavigationStart } from '@angular/router';import { filter } from 'rxjs/operators';@Component({  selector: 'app-routable',  templateUrl: './routable.component.html',  styleUrls: ['./routable.component.css']})export class Routable1Component implements OnInit {  navStart: Observable<NavigationStart>;  constructor(private router: Router) {    // Create a new Observable that publishes only the NavigationStart event    this.navStart = router.events.pipe(      filter(evt => evt instanceof NavigationStart)    ) as Observable<NavigationStart>;  }  ngOnInit() {    this.navStart.subscribe(evt => console.log('Navigation Started!'));  }}

ActivatedRoute 是一个可注入的路由器服务,它使用可观察对象来获取关于路由路径和路由参数的信息。比如,ActivatedRoute.url 包含一个用于汇报路由路径的可观察对象。例子如下:

//ActivatedRouteimport { ActivatedRoute } from '@angular/router';@Component({  selector: 'app-routable',  templateUrl: './routable.component.html',  styleUrls: ['./routable.component.css']})export class Routable2Component implements OnInit {  constructor(private activatedRoute: ActivatedRoute) {}  ngOnInit() {    this.activatedRoute.url      .subscribe(url => console.log('The URL changed to: ' + url));  }}

响应式表单 (reactive forms)

响应式表单具有一些属性,它们使用可观察对象来监听表单控件的值。 FormControlvalueChanges 属性和 statusChanges 属性包含了会发出变更事件的可观察对象。订阅可观察的表单控件属性是在组件类中触发应用逻辑的途径之一。比如:

//Reactive formsimport { FormGroup } from '@angular/forms';@Component({  selector: 'my-component',  template: 'MyComponent Template'})export class MyComponent implements OnInit {  nameChangeLog: string[] = [];  heroForm: FormGroup;  ngOnInit() {    this.logNameChange();  }  logNameChange() {    const nameControl = this.heroForm.get('name');    nameControl.valueChanges.forEach(      (value: string) => this.nameChangeLog.push(value)    );  }}

输入提示(type-ahead)建议

可观察对象可以简化输入提示建议的实现方式。典型的输入提示要完成一系列独立的任务:

  • 从输入中监听数据。

  • 移除输入值前后的空白字符,并确认它达到了最小长度。

  • 防抖(这样才能防止连续按键时每次按键都发起 API 请求,而应该等到按键出现停顿时才发起)

  • 如果输入值没有变化,则不要发起请求(比如按某个字符,然后快速按退格)。

  • 如果已发出的 AJAX 请求的结果会因为后续的修改而变得无效,那就取消它。

完全用 JavaScript 的传统写法实现这个功能可能需要大量的工作。使用可观察对象,你可以使用这样一个 RxJS 操作符的简单序列:

//Typeaheadimport { fromEvent } from 'rxjs';import { ajax } from 'rxjs/ajax';import { debounceTime, distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators';const searchBox = document.getElementById('search-box');const typeahead = fromEvent(searchBox, 'input').pipe(  map((e: KeyboardEvent) => (e.target as HTMLInputElement).value),  filter(text => text.length > 2),  debounceTime(10),  distinctUntilChanged(),  switchMap(() => ajax('/api/endpoint')));typeahead.subscribe(data => { // Handle the data from the API});

指数化退避

指数化退避是一种失败后重试 API 的技巧,它会在每次连续的失败之后让重试时间逐渐变长,超过最大重试次数之后就会彻底放弃。 如果使用承诺和其它跟踪 AJAX 调用的方法会非常复杂,而使用可观察对象,这非常简单:

//Exponential backoffimport { pipe, range, timer, zip } from 'rxjs';import { ajax } from 'rxjs/ajax';import { retryWhen, map, mergeMap } from 'rxjs/operators';function backoff(maxTries, ms) { return pipe(   retryWhen(attempts => zip(range(1, maxTries), attempts)     .pipe(       map(([i]) => i * i),       mergeMap(i =>  timer(i * ms))     )   ) );}ajax('/api/endpoint')  .pipe(backoff(3, 250))  .subscribe(data => handleData(data));function handleData(data) {  // ...}

可观察对象与其它技术的比较

你可以经常使用可观察对象(Observable)而不是承诺(Promise)来异步传递值。 类似的,可观察对象也可以取代事件处理器的位置。最后,由于可观察对象传递多个值,所以你可以在任何可能构建和操作数组的地方使用可观察对象。

在这些情况下,可观察对象的行为与其替代技术有一些差异,不过也提供了一些显著的优势。下面是对这些差异的详细比较。

可观察对象 vs. 承诺

可观察对象经常拿来和承诺进行对比。有一些关键的不同点:

  • 可观察对象是声明式的,在被订阅之前,它不会开始执行。承诺是在创建时就立即执行的。这让可观察对象可用于定义那些应该按需执行的菜谱。

  • 可观察对象能提供多个值。承诺只提供一个。这让可观察对象可用于随着时间的推移获取多个值。

  • 可观察对象会区分串联处理和订阅语句。承诺只有 .then() 语句。这让可观察对象可用于创建供系统的其它部分使用而不希望立即执行的复杂菜谱。

  • 可观察对象的 subscribe() 会负责处理错误。承诺会把错误推送给它的子承诺。这让可观察对象可用于进行集中式、可预测的错误处理。

创建与订阅

  • 在有消费者订阅之前,可观察对象不会执行。subscribe() 会执行一次定义好的行为,并且可以再次调用它。每次订阅都是单独计算的。重新订阅会导致重新计算这些值。

Path:"src/observables.ts (observable)" 。

    // declare a publishing operation    const observable = new Observable<number>(observer => {      // Subscriber fn...    });    // initiate execution    observable.subscribe(() => {      // observer handles notifications    });

  • 承诺会立即执行,并且只执行一次。当承诺创建时,会立即计算出结果。没有办法重新做一次。所有的 then 语句(订阅)都会共享同一次计算。

Path:"src/promises.ts (promise)" 。

    // initiate execution    const promise = new Promise<number>((resolve, reject) => {      // Executer fn...    });    promise.then(value => {      // handle result here    });

串联

  • 可观察对象会区分各种转换函数,比如映射和订阅。只有订阅才会激活订阅者函数,以开始计算那些值。

Path:"src/observables.ts (chain)" 。

    observable.pipe(map(v => 2 * v));

  • 承诺并不区分最后的 .then() 语句(等价于订阅)和中间的 .then() 语句(等价于映射)。

Path:"src/promises.ts (chain)" 。

    promise.then(v => 2 * v);

可取消

  • 可观察对象的订阅是可取消的。取消订阅会移除监听器,使其不再接受将来的值,并通知订阅者函数取消正在进行的工作。

Path:"src/observables.ts (unsubcribe)" 。

    const subscription = observable.subscribe(() => {      // observer handles notifications    });    subscription.unsubscribe();

  • 承诺是不可取消的。

错误处理

  • 可观察对象的错误处理工作交给了订阅者的错误处理器,并且该订阅者会自动取消对这个可观察对象的订阅。

Path:"src/observables.ts (error)" 。

    observable.subscribe(() => {      throw Error('my error');    });

  • 承诺会把错误推给其子承诺。

Path:"src/promises.ts (error)" 。

    promise.then(() => {      throw Error('my error');    });

速查表

下列代码片段揭示了同样的操作要如何分别使用可观察对象和承诺进行实现。

操作可观察对象承诺
创建new Observable((observer) => { observer.next(123); });new Promise((resolve, reject) => { resolve(123); }); 
转换obs.pipe(map((value) => value * 2));promise.then((value) => value * 2); 
订阅sub = obs.subscribe((value) => { console.log(value) });promise.then((value) => { console.log(value); }) 
取消订阅sub.unsubscribe();承诺被解析时隐式完成。

可观察对象 vs. 事件 API

可观察对象和事件 API 中的事件处理器很像。这两种技术都会定义通知处理器,并使用它们来处理一段时间内传递的多个值。订阅可观察对象与添加事件处理器是等价的。一个显著的不同是你可以配置可观察对象,使其在把事件传给事件处理器之前先进行转换。

使用可观察对象来处理错误和异步操作在 HTTP 请求这样的场景下更加具有一致性。

下列代码片段揭示了同样的操作要如何分别使用可观察对象和事件 API 进行实现。

  1. “创建与取消”操作。

  • 可观察对象。

    // Setup    let clicks$ = fromEvent(buttonEl, ‘click’);    // Begin listening    let subscription = clicks$      .subscribe(e => console.log(‘Clicked’, e))    // Stop listening    subscription.unsubscribe();

  • 事件 API。

    function handler(e) {      console.log(‘Clicked’, e);    }    // Setup & begin listening    button.addEventListener(‘click’, handler);    // Stop listening    button.removeEventListener(‘click’, handler);

  1. 配置操作。

  • 可观察对象。

监听按键,提供一个流来表示这些输入的值。

    fromEvent(inputEl, 'keydown').pipe(      map(e => e.target.value)    );

  • 事件 API。

不支持配置。

    element.addEventListener(eventName, (event) => {      // Cannot change the passed Event into another      // value before it gets to the handler    });

  1. 订阅操作。

  • 可观察对象。

    observable.subscribe(() => {      // notification handlers here    });

  • 事件 API。

    element.addEventListener(eventName, (event) => {      // notification handler here    });

可观察对象 vs. 数组

可观察对象会随时间生成值。数组是用一组静态的值创建的。某种意义上,可观察对象是异步的,而数组是同步的。 在下面的例子中, 符号表示异步传递值。

  1. 给出值。

  • 可观察对象。

    obs: ➞1➞2➞3➞5➞7    obsB: ➞'a'➞'b'➞'c'

  • 数组。

    arr: [1, 2, 3, 5, 7]    arrB: ['a', 'b', 'c']

  1. concat()

  • 可观察对象。

    concat(obs, obsB)    ➞1➞2➞3➞5➞7➞'a'➞'b'➞'c'

  • 数组。

    arr.concat(arrB)    [1,2,3,5,7,'a','b','c']

  1. filter()

  • 可观察对象。

    obs.pipe(filter((v) => v>3))    ➞5➞7

  • 数组。

    arr.filter((v) => v>3)    [5, 7]

  1. find()

  • 可观察对象。

    obs.pipe(find((v) => v>3))    ➞5

  • 数组。

    arr.find((v) => v>3)    5

  1. findIndex()

  • 可观察对象。

    obs.pipe(findIndex((v) => v>3))    ➞3

  • 数组。

    arr.findIndex((v) => v>3)    3

  1. forEach()

  • 可观察对象。

    obs.pipe(tap((v) => {      console.log(v);    }))    1    2    3    5    7

  • 数组。

    arr.forEach((v) => {      console.log(v);    })    1    2    3    5    7

  1. map()

  • 可观察对象。

    obs.pipe(map((v) => -v))    ➞-1➞-2➞-3➞-5➞-7

  • 数组。

    arr.map((v) => -v)    [-1, -2, -3, -5, -7]

  1. reduce()

  • 可观察对象。

    obs.pipe(reduce((s,v)=> s+v, 0))    ➞18

  • 数组。

    arr.reduce((s,v) => s+v, 0)    18

NgModules 用于配置注入器和编译器,并帮你把那些相关的东西组织在一起。

NgModule 是一个带有 @NgModule 装饰器的类。 @NgModule 的参数是一个元数据对象,用于描述如何编译组件的模板,以及如何在运行时创建注入器。 它会标出该模块自己的组件、指令和管道,通过 exports 属性公开其中的一部分,以便外部组件使用它们。 NgModule 还能把一些服务提供者添加到应用的依赖注入器中。

Angular 模块化

模块是组织应用和使用外部库扩展应用的最佳途径。

Angular 自己的库都是 NgModule,比如 FormsModuleHttpClientModuleRouterModule。 很多第三方库也是 NgModule,比如 Material DesignIonicAngularFire2

NgModule 把组件、指令和管道打包成内聚的功能块,每个模块聚焦于一个特性区域、业务领域、工作流或通用工具。

模块还可以把服务加到应用中。 这些服务可能是内部开发的(比如你自己写的),或者来自外部的(比如 Angular 的路由和 HTTP 客户端)。

模块可以在应用启动时急性加载,也可以由路由器进行异步的惰性加载。

NgModule 的元数据会做这些:

  • 声明某些组件、指令和管道属于这个模块。

  • 公开其中的部分组件、指令和管道,以便其它模块中的组件模板中可以使用它们。

  • 导入其它带有组件、指令和管道的模块,这些模块中的元件都是本模块所需的。

  • 提供一些供应用中的其它组件使用的服务。

每个 Angular 应用都至少有一个模块,也就是根模块。 你可以引导那个模块,以启动该应用。

对于那些只有少量组件的简单应用,根模块就是你所需的一切。 随着应用的成长,你要把这个根模块重构成一些特性模块,它们代表一组密切相关的功能集。 然后你再把这些模块导入到根模块中。

基本的模块

Angular CLI 在创建新应用时会生成如下基本模块 AppModule

Path:"src/app/app.module.ts (default AppModule)" 。

// importsimport { BrowserModule } from '@angular/platform-browser';import { NgModule } from '@angular/core';import { AppComponent } from './app.component';// @NgModule decorator with its metadata@NgModule({  declarations: [AppComponent],  imports: [BrowserModule],  providers: [],  bootstrap: [AppComponent]})export class AppModule {}

文件的顶部是一些导入语句。接下来是你配置 NgModule 的地方,用于规定哪些组件和指令属于它(declarations),以及它使用了哪些其它模块(imports)。

JavaScript 和 Angular 都使用模块来组织代码,虽然它们的组织形式不同,但 Angular 的应用会同时依赖两者。

JavaScript 模块

在 JavaScript 中,模块是内含 JavaScript 代码的独立文件。要让其中的东西可用,你要写一个导出语句,通常会放在相应的代码之后,类似这样:

export class AppComponent { ... }

然后,当你在其它文件中需要这个文件的代码时,要像这样导入它:

import { AppComponent } from './app.component';

JavaScript 模块让你能为代码加上命名空间,防止因为全局变量而引起意外。

NgModules

NgModule 是一些带有 @NgModule 装饰器的类。@NgModule 装饰器的 imports 数组会告诉 Angular 哪些其它的 NgModule 是当前模块所需的。 imports 数组中的这些模块与 JavaScript 模块不同,它们都是 NgModule 而不是常规的 JavaScript 模块。 带有 @NgModule 装饰器的类通常会习惯性地放在单独的文件中,但单独的文件并不像 JavaScript 模块那样作为必要条件,而是因为它带有 @NgModule 装饰器及其元数据。

Angular CLI 生成的 AppModule 实际演示了这两种模块:

/* These are JavaScript import statements. Angular doesn’t know anything about these. */import { BrowserModule } from '@angular/platform-browser';import { NgModule } from '@angular/core';import { AppComponent } from './app.component';/* The @NgModule decorator lets Angular know that this is an NgModule. */@NgModule({  declarations: [    AppComponent  ],  imports: [     /* These are NgModule imports. */    BrowserModule  ],  providers: [],  bootstrap: [AppComponent]})export class AppModule { }

NgModule 类与 JavaScript 模块有下列关键性的不同:

  • NgModule 只绑定了可声明的类,这些可声明的类只是供 Angular 编译器用的。

  • 与 JavaScript 类把它所有的成员类都放在一个巨型文件中不同,你要把该模块的类列在它的 @NgModule.declarations 列表中。

  • NgModule 只能导出可声明的类。这可能是它自己拥有的也可能是从其它模块中导入的。它不会声明或导出任何其它类型的类。

  • 与 JavaScript 模块不同,NgModule 可以通过把服务提供者加到 @NgModule.providers 列表中,来用服务扩展整个应用。

先决条件

对下列知识有基本的了解:

启动过程

NgModule 用于描述应用的各个部分如何组织在一起。 每个应用有至少一个 Angular 模块,根模块就是你用来启动此应用的模块。 按照惯例,它通常命名为 AppModule。

当你使用 Angular CLI 命令 ng new 生成一个应用时,其默认的 AppModule 是这样的:

/* JavaScript imports */import { BrowserModule } from '@angular/platform-browser';import { NgModule } from '@angular/core';import { FormsModule } from '@angular/forms';import { HttpClientModule } from '@angular/common/http';import { AppComponent } from './app.component';/* the AppModule class with the @NgModule decorator */@NgModule({  declarations: [    AppComponent  ],  imports: [    BrowserModule,    FormsModule,    HttpClientModule  ],  providers: [],  bootstrap: [AppComponent]})export class AppModule { }

import 语句之后,是一个带有 @NgModule 装饰器的类。

@NgModule 装饰器表明 AppModule 是一个 NgModule 类。 @NgModule 获取一个元数据对象,它会告诉 Angular 如何编译和启动本应用。

  • declarations —— 该应用所拥有的组件。

  • imports —— 导入 BrowserModule 以获取浏览器特有的服务,比如 DOM 渲染、无害化处理和位置(location)。

  • providers —— 各种服务提供者。

  • bootstrap —— 根组件,Angular 创建它并插入 index.html 宿主页面。

Angular CLI 创建的默认应用只有一个组件 AppComponent,所以它会同时出现在 declarationsbootstrap 数组中。

declarations 数组

该模块的 declarations 数组告诉 Angular 哪些组件属于该模块。 当你创建更多组件时,也要把它们添加到 declarations 中。

每个组件都应该(且只能)声明(declare)在一个 NgModule 类中。如果你使用了未声明过的组件,Angular 就会报错。

declarations 数组只能接受可声明对象。可声明对象包括组件、指令和管道。 一个模块的所有可声明对象都必须放在 declarations 数组中。 可声明对象必须只能属于一个模块,如果同一个类被声明在了多个模块中,编译器就会报错。

这些可声明的类在当前模块中是可见的,但是对其它模块中的组件是不可见的 —— 除非把它们从当前模块导出, 并让对方模块导入本模块。

下面是哪些类可以添加到 declarations 数组中的例子:

declarations: [  YourComponent,  YourPipe,  YourDirective],

每个可声明对象都只能属于一个模块,所以只能把它声明在一个 @NgModule 中。当你需要在其它模块中使用它时,就要在那里导入包含这个可声明对象的模块。

只有 @NgModule 可以出现在 imports 数组中。

通过 @NgModule 使用指令

使用 declarations 数组声明指令。在模块中使用指令、组件或管道的步骤如下:

  1. 从你编写它的文件中导出它。

  1. 把它导入到适当的模块中。

  1. @NgModuledeclarations 数组中声明它。

这三步的结果如下所示。在你创建指令的文件中导出它。 下面的例子中,"item.directive.ts" 中的 ItemDirective 是 CLI 自动生成的默认指令结构。

Path:"src/app/item.directive.ts" 。

import { Directive } from '@angular/core';@Directive({  selector: '[appItem]'})export class ItemDirective {// code goes here  constructor() { }}

重点在于你要先在这里导出它才能在别处导入它。接下来,使用 JavaScript 的 import 语句把它导入到 NgModule 中(这里是 "app.module.ts")。

Path:"src/app/app.module.ts" 。

import { ItemDirective } from './item.directive';

同样在这个文件中,把它添加到 @NgModuledeclarations 数组中:

Path:"src/app/app.module.ts" 。

declarations: [  AppComponent,  ItemDirective],

现在,你就可以在组件中使用 ItemDirective 了。这个例子中使用的是 AppModule,但是在特性模块中你也可以这么做。

注:

  • 组件、指令和管道都只能属于一个模块。你在应用中也只需要声明它们一次,因为你还可以通过导入必要的模块来使用它们。这能节省你的时间,并且帮助你的应用保持精简。

imports 数组

模块的 imports 数组只会出现在 @NgModule 元数据对象中。 它告诉 Angular 该模块想要正常工作,还需要哪些模块。

列表中的模块导出了本模块中的各个组件模板中所引用的各个组件、指令或管道。在这个例子中,当前组件是 AppComponent,它引用了导出自 BrowserModuleFormsModuleHttpClientModule 的组件、指令或管道。 总之,组件的模板中可以引用在当前模块中声明的或从其它模块中导入的组件、指令、管道。

providers 数组

providers 数组中列出了该应用所需的服务。当直接把服务列在这里时,它们是全应用范围的。 当你使用特性模块和惰性加载时,它们是范围化的。

bootstrap 数组

应用是通过引导根模块 AppModule 来启动的,根模块还引用了 entryComponent。 此外,引导过程还会创建 bootstrap 数组中列出的组件,并把它们逐个插入到浏览器的 DOM 中。

每个被引导的组件都是它自己的组件树的根。 插入一个被引导的组件通常触发一系列组件的创建并形成组件树。

虽然也可以在宿主页面中放多个组件,但是大多数应用只有一个组件树,并且只从一个根组件开始引导。

这个根组件通常叫做 AppComponent,并且位于根模块的 bootstrap 数组中。

Angular 应用至少需要一个充当根模块使用的模块。 如果你要把某些特性添加到应用中,可以通过添加模块来实现。 下列是一些常用的 Angular 模块,其中带有一些其内容物的例子:

NgModule导入自为何使用
BrowserModule@angular/platform-browser当你想要在浏览器中运行应用时
CommonModule@angular/common当你想要使用 NgIf 和 NgFor 时
FormsModule@angular/forms当要构建模板驱动表单时(它包含 NgModel )
ReactiveFormsModule@angular/forms当要构建响应式表单时
RouterModule@angular/router要使用路由功能,并且你要用到 RouterLink,.forRoot() 和 .forChild() 时
HttpClientModule@angular/common/http当你要和服务器对话时

导入模块

当你使用这些 Angular 模块时,在 AppModule(或适当的特性模块)中导入它们,并把它们列在当前 @NgModuleimports 数组中。比如,在 Angular CLI 生成的基本应用中,BrowserModule 会在 "app.module.ts" 中 AppModule 的顶部最先导入。

/* import modules so that AppModule can access them */import { BrowserModule } from '@angular/platform-browser';import { NgModule } from '@angular/core';import { AppComponent } from './app.component';@NgModule({  declarations: [    AppComponent  ],  imports: [ /* add modules here so Angular knows to use them */    BrowserModule,  ],  providers: [],  bootstrap: [AppComponent]})export class AppModule { }

文件顶部的这些导入是 JavaScript 的导入语句,而 @NgModule 中的 imports 数组则是 Angular 特有的。

BrowserModule 和 CommonModule

BrowserModule 导入了 CommonModule,它贡献了很多通用的指令,比如 ngIfngFor。 另外,BrowserModule 重新导出了 CommonModule,以便它所有的指令在任何导入了 BrowserModule 的模块中都可以使用。

对于运行在浏览器中的应用来说,都必须在根模块中 AppModule 导入 BrowserModule,因为它提供了启动和运行浏览器应用时某些必须的服务。BrowserModule 的提供者是面向整个应用的,所以它只能在根模块中使用,而不是特性模块。 特性模块只需要 CommonModule 中的常用指令,它们不需要重新安装所有全应用级的服务。

如果你把 BrowserModule 导入了惰性加载的特性模块中,Angular 就会返回一个错误,并告诉你要改用 CommonModule

下面是特性模块的五个常用分类,包括五组:

  • 领域特性模块。

  • 带路由的特性模块。

  • 路由模块。

  • 服务特性模块

  • 可视部件特性模块。

虽然下面的指南中描述了每种类型的使用及其典型特征,但在实际的应用中,你还可能看到它们的混合体。

  1. 特性模块:领域。

指导原则:

  • 领域特性模块用来给用户提供应用程序领域中特有的用户体验,比如编辑客户信息或下订单等。

  • 它们通常会有一个顶层组件来充当该特性的根组件,并且通常是私有的。用来支持它的各级子组件。

  • 领域特性模块大部分由 declarations 组成,只有顶层组件会被导出。

  • 领域特性模块很少会有服务提供者。如果有,那么这些服务的生命周期必须和该模块的生命周期完全相同。

  • 领域特性模块通常会由更高一级的特性模块导入且只导入一次。

  • 对于缺少路由的小型应用,它们可能只会被根模块 AppModule 导入一次。

  1. 特性模块:路由( Routed )。

指导原则:

  • 带路由的特性模块是一种特殊的领域特性模块,但它的顶层组件会作为路由导航时的目标组件。

  • 根据这个定义,所有惰性加载的模块都是路由特性模块。

  • 带路由的特性模块不会导出任何东西,因为它们的组件永远不会出现在外部组件的模板中。

  • 惰性加载的路由特性模块不应该被任何模块导入。如果那样做就会导致它被急性加载,破坏了惰性加载的设计用途。 也就是说你应该永远不会看到它们在 AppModuleimports 中被引用。 急性加载的路由特性模块必须被其它模块导入,以便编译器能了解它所包含的组件。

  • 路由特性模块很少会有服务提供者。如果那样做,那么它所提供的服务的生命周期必须与该模块的生命周期完全相同。不要在路由特性模块或被路由特性模块所导入的模块中提供全应用级的单例服务。

  1. 特性模块:路由( Routing )。

指导原则:

路由模块为其它模块提供路由配置,并且把路由这个关注点从它的配套模块中分离出来。

路由模块通常会做这些:

  • 定义路由。

  • 把路由配置添加到该模块的 imports 中。

  • 把路由守卫和解析器的服务提供者添加到该模块的 providers 中。

  • 路由模块应该与其配套模块同名,但是加上“Routing”后缀。比如,"foo.module.ts" 中的 FooModule 就有一个位于 "foo-routing.module.ts" 文件中的 FooRoutingModule 路由模块。 如果其配套模块是根模块 AppModuleAppRoutingModule 就要使用 RouterModule.forRoot(routes) 来把路由器配置添加到它的 imports 中。 所有其它路由模块都是子模块,要使用 RouterModule.forChild(routes)

  • 按照惯例,路由模块会重新导出这个 RouterModule,以便其配套模块中的组件可以访问路由器指令,比如 RouterLinkRouterOutlet

  • 路由模块没有自己的可声明对象。组件、指令和管道都是特性模块的职责,而不是路由模块的。

路由模块只应该被它的配套模块导入。

  1. 特性模块:服务。

指导原则:

服务模块提供了一些工具服务,比如数据访问和消息。理论上,它们应该是完全由服务提供者组成的,不应该有可声明对象。Angular 的 HttpClientModule 就是一个服务模块的好例子。

根模块 AppModule 是唯一的可以导入服务模块的模块。

  1. 特性模块:窗口部件。

指导原则:

  • 窗口部件模块为外部模块提供组件、指令和管道。很多第三方 UI 组件库都是窗口部件模块。

  • 窗口部件模块应该完全由可声明对象组成,它们中的大部分都应该被导出。

  • 窗口部件模块很少会有服务提供者。

  • 如果任何模块的组件模板中需要用到这些窗口部件,就请导入相应的窗口部件模块。

下表中汇总了各种特性模块类型的关键特征。

特性模块特性模块提供者导出什么被谁导入
领域罕见顶层组件特性模块,AppModule
路由( Routed )罕见
路由( Routing )有(守卫)RouterModule特性(供路由使用)
服务AppModule
窗口部件罕见特性

从分类上说,入口组件是 Angular 命令式加载的任意组件(也就是说你没有在模板中引用过它), 你可以在 NgModule 中引导它,或把它包含在路由定义中来指定入口组件。

对比一下这两种组件类型:有一类组件被包含在模板中,它们是声明式加载的;另一类组件你会命令式加载它,这就是入口组件。

入口组件有两种主要的类型:

  • 引导用的根组件。

  • 在路由定义中指定的组件。

引导用的入口组件

下面这个例子中指定了一个引导用组件 AppComponent,位于基本的 "app.module.ts" 中:

@NgModule({  declarations: [    AppComponent  ],  imports: [    BrowserModule,    FormsModule,    HttpClientModule,    AppRoutingModule  ],  providers: [],  bootstrap: [AppComponent] // bootstrapped entry component})

可引导组件是一个入口组件,Angular 会在引导过程中把它加载到 DOM 中。 其它入口组件是在其它时机动态加载的,比如用路由器。

Angular 会动态加载根组件 AppComponent,是因为它的类型作为参数传给了 @NgModule.bootstrap 函数。

组件也可以在该模块的 ngDoBootstrap() 方法中进行命令式引导。 @NgModule.bootstrap 属性告诉编译器,这里是一个入口组件,它应该生成代码,来使用这个组件引导该应用。

引导用的组件必须是入口组件,因为引导过程是命令式的,所以它需要一个入口组件。

路由到的入口组件

入口组件的第二种类型出现在路由定义中,就像这样:

const routes: Routes = [  {    path: '',    component: CustomerListComponent  }];

路由定义使用组件类型引用了一个组件:component: CustomerListComponent

所有路由组件都必须是入口组件。这需要你把同一个组件添加到两个地方(路由中和 entryComponents 中),但编译器足够聪明,可以识别出这里是一个路由定义,因此它会自动把这些路由组件添加到 entryComponents 中。

entryComponents 数组

虽然 @NgModule 装饰器具有一个 entryComponents 数组,但大多数情况下你不用显式设置入口组件,因为 Angular 会自动把 @NgModule.bootstrap 中的组件以及路由定义中的组件添加到入口组件中。 虽然这两种机制足够自动添加大多数入口组件,但如果你要用其它方式根据类型来命令式的引导或动态加载某个组件,你就必须把它们显式添加到 entryComponents 中了。

entryComponents 和编译器

对于生产环境的应用,你总是希望加载尽可能小的代码。 这些代码应该只包含你实际使用到的类,并且排除那些从未用到的组件。因此,Angular 编译器只会为那些可以从 entryComponents 中直接或间接访问到的组件生成代码。 这意味着,仅仅往 @NgModule.declarations 中添加更多引用,并不能表达出它们在最终的代码包中是必要的。

实际上,很多库声明和导出的组件都是你从未用过的。 比如,Material Design 库会导出其中的所有组件,因为它不知道你会用哪一个。然而,显然你也不打算全都用上。 对于那些你没有引用过的,摇树优化工具就会把这些组件从最终的代码包中摘出去。

如果一个组件既不是入口组件也没有在模板中使用过,摇树优化工具就会把它扔出去。 所以,最好只添加那些真正的入口组件,以便让应用尽可能保持精简。

特性模块是用来对代码进行组织的模块。

随着应用的增长,你可能需要组织与特定应用有关的代码。 这将帮你把特性划出清晰的边界。使用特性模块,你可以把与特定的功能或特性有关的代码从其它代码中分离出来。 为应用勾勒出清晰的边界,有助于开发人员之间、小组之间的协作,有助于分离各个指令,并帮助管理根模块的大小。

特性模块 vs. 根模块

与核心的 Angular API 的概念相反,特性模块是最佳的组织方式。特性模块提供了聚焦于特定应用需求的一组功能,比如用户工作流、路由或表单。 虽然你也可以用根模块做完所有事情,不过特性模块可以帮助你把应用划分成一些聚焦的功能区。特性模块通过它提供的服务以及共享出的组件、指令和管道来与根模块和其它模块合作。

如何制作特性模块

如果你已经有了 Angular CLI 生成的应用,可以在项目的根目录下输入下面的命令来创建特性模块。把这里的 CustomerDashboard 替换成你的模块名。你可以从名字中省略掉“Module”后缀,因为 CLI 会自动追加上它:

ng generate module CustomerDashboard

这会让 CLI 创建一个名叫 "customer-dashboard" 的文件夹,其中有一个名叫 "customer-dashboard.module.ts",内容如下:

import { NgModule } from '@angular/core';import { CommonModule } from '@angular/common';@NgModule({  imports: [    CommonModule  ],  declarations: []})export class CustomerDashboardModule { }

无论根模块还是特性模块,其 NgModule 结构都是一样的。在 CLI 生成的特性模块中,在文件顶部有两个 JavaScript 的导入语句:第一个导入了 NgModule,它像根模块中一样让你能使用 @NgModule 装饰器;第二个导入了 CommonModule,它提供了很多像 ngIfngFor 这样的常用指令。 特性模块导入 CommonModule,而不是 BrowserModule,后者只应该在根模块中导入一次。 CommonModule 只包含常用指令的信息,比如 ngIfngFor,它们在大多数模板中都要用到,而 BrowserModule 为浏览器所做的应用配置只会使用一次。

declarations 数组让你能添加专属于这个模块的可声明对象(组件、指令和管道)。 要添加组件,就在命令行中输入如下命令,这里的 "customer-dashboard" 是一个目录,CLI 会把特性模块生成在这里,而 CustomerDashboard 就是该组件的名字:

ng generate component customer-dashboard/CustomerDashboard

这会在 customer-dashboard 中为新组件生成一个目录,并使用 CustomerDashboardComponent 的信息修改这个特性模块:

Path:"src/app/customer-dashboard/customer-dashboard.module.ts"。

// import the new componentimport { CustomerDashboardComponent } from './customer-dashboard/customer-dashboard.component';@NgModule({  imports: [    CommonModule  ],  declarations: [    CustomerDashboardComponent  ],})

CustomerDashboardComponent 出现在了顶部的 JavaScript 导入列表里,并且被添加到了 declarations 数组中,它会让 Angular 把新组件和这个特性模块联系起来。

导入特性模块

要想把这个特性模块包含进应用中,你还得让根模块 "app.module.ts" 知道它。注意,在 "customer-dashboard.module.ts" 文件底部 CustomerDashboardModule 的导出部分。这样就把它暴露出来,以便其它模块可以拿到它。要想把它导入到 AppModule 中,就把它加入 "app.module.ts" 的导入表中,并将其加入 imports 数组:

Path:"src/app/app.module.ts"。

import { HttpClientModule } from '@angular/common/http';import { NgModule } from '@angular/core';import { FormsModule } from '@angular/forms';import { BrowserModule } from '@angular/platform-browser';import { AppComponent } from './app.component';// import the feature module here so you can add it to the imports array belowimport { CustomerDashboardModule } from './customer-dashboard/customer-dashboard.module';@NgModule({  declarations: [    AppComponent  ],  imports: [    BrowserModule,    FormsModule,    HttpClientModule,    CustomerDashboardModule // add the feature module here  ],  providers: [],  bootstrap: [AppComponent]})export class AppModule { }

现在 AppModule 知道这个特性模块了。如果你往该特性模块中加入过任何服务提供者,AppModule 也同样会知道它,其它模块中也一样。不过,NgModule 并不会暴露出它们的组件。

渲染特性模块的组件模板

当 CLI 为这个特性模块生成 CustomerDashboardComponent 时,还包含一个模板 "customer-dashboard.component.html",它带有如下页面脚本:

Path:"src/app/customer-dashboard/customer-dashboard/customer-dashboard.component.html"。

<p>  customer-dashboard works!</p>

要想在 AppComponent 中查看这些 HTML,你首先要在 CustomerDashboardModule 中导出 CustomerDashboardComponent。 在 "customer-dashboard.module.ts" 中,declarations 数组的紧下方,加入一个包含 CustomerDashboardModuleexports 数组:

Path:"src/app/customer-dashboard/customer-dashboard.module.ts"。

exports: [  CustomerDashboardComponent]

然后,在 AppComponent 的 "app.component.html" 中,加入标签 <app-customer-dashboard>

Path:"src/app/app.component.html"。

<h1>  {{title}}</h1><!-- add the selector from the CustomerDashboardComponent --><app-customer-dashboard></app-customer-dashboard>

现在,除了默认渲染出的标题外,还渲染出了 CustomerDashboardComponent 的模板:

提供者就是一本说明书,用来指导依赖注入系统该如何获取某个依赖的值。 大多数情况下,这些依赖就是你要创建和提供的那些服务。

提供服务

如果你是用 Angular CLI 创建的应用,那么可以使用下列 CLI 的 ng generate 命令在项目根目录下创建一个服务。把其中的 User 替换成你的服务名。

ng generate service User

该命令会创建下列 UserService 骨架:

Path:"src/app/user.service.ts" 。

import { Injectable } from '@angular/core';@Injectable({  providedIn: 'root',})export class UserService {}

现在,你就可以在应用中到处注入 UserService 了。

该服务本身是 CLI 创建的一个类,并且加上了 @Injectable() 装饰器。默认情况下,该装饰器是用 providedIn 属性进行配置的,它会为该服务创建一个提供者。在这个例子中,providedIn: 'root' 指定 Angular 应该在根注入器中提供该服务。

提供者的作用域

当你把服务提供者添加到应用的根注入器中时,它就在整个应用程序中可用了。 另外,这些服务提供者也同样对整个应用中的类是可用的 —— 只要它们有供查找用的服务令牌。

你应该始终在根注入器中提供这些服务 —— 除非你希望该服务只有在消费方要导入特定的 @NgModule 时才生效。

providedIn 与 NgModule

也可以规定某个服务只有在特定的 @NgModule 中提供。比如,如果你希望只有当消费方导入了你创建的 UserModule 时才让 UserService 在应用中生效,那就可以指定该服务要在该模块中提供:

Path:"src/app/user.service.ts" 。

import { Injectable } from '@angular/core';import { UserModule } from './user.module';@Injectable({  providedIn: UserModule,})export class UserService {}

上面的例子展示的就是在模块中提供服务的首选方式。之所以推荐该方式,是因为当没有人注入它时,该服务就可以被摇树优化掉。如果没办法指定哪个模块该提供这个服务,你也可以在那个模块中为该服务声明一个提供者:

Path:"src/app/user.module.ts" 。

import { NgModule } from '@angular/core';import { UserService } from './user.service';@NgModule({  providers: [UserService],})export class UserModule {}

使用惰性加载模块限制提供者的作用域

在 CLI 生成的基本应用中,模块是急性加载的,这意味着它们都是由本应用启动的,Angular 会使用一个依赖注入体系来让一切服务都在模块间有效。对于急性加载式应用,应用中的根注入器会让所有服务提供者都对整个应用有效。

当使用惰性加载时,这种行为需要进行改变。惰性加载就是只有当需要时才加载模块,比如路由中。它们没办法像急性加载模块那样进行加载。这意味着,在它们的 providers 数组中列出的服务都是不可用的,因为根注入器并不知道这些模块。

当 Angular 的路由器惰性加载一个模块时,它会创建一个新的注入器。这个注入器是应用的根注入器的一个子注入器。想象一棵注入器树,它有唯一的根注入器,而每一个惰性加载模块都有一个自己的子注入器。路由器会把根注入器中的所有提供者添加到子注入器中。如果路由器在惰性加载时创建组件,Angular 会更倾向于使用从这些提供者中创建的服务实例,而不是来自应用的根注入器的服务实例。

任何在惰性加载模块的上下文中创建的组件(比如路由导航),都会获取该服务的局部实例,而不是应用的根注入器中的实例。而外部模块中的组件,仍然会收到来自于应用的根注入器创建的实例。

虽然你可以使用惰性加载模块来提供实例,但不是所有的服务都能惰性加载。比如,像路由之类的模块只能在根模块中使用。路由器需要使用浏览器中的全局对象 location 进行工作。

使用组件限定服务提供者的作用域

另一种限定提供者作用域的方式是把要限定的服务添加到组件的 providers 数组中。组件中的提供者和 NgModule 中的提供者是彼此独立的。 当你要急性加载一个自带了全部所需服务的模块时,这种方式是有帮助的。 在组件中提供服务,会限定该服务只能在该组件及其子组件中有效,而同一模块中的其它组件不能访问它。

Path:"src/app/app.component.ts" 。

@Component({/* . . . */  providers: [UserService]})

在模块中提供服务还是在组件中

通常,要在根模块中提供整个应用都需要的服务,在惰性加载模块中提供限定范围的服务。

路由器工作在根级,所以如果你把服务提供者放进组件(即使是 AppComponent)中,那些依赖于路由器的惰性加载模块,将无法看到它们。

当你必须把一个服务实例的作用域限定到组件及其组件树中时,可以使用组件注册一个服务提供者。 比如,用户编辑组件 UserEditorComponent,它需要一个缓存 UserService 实例,那就应该把 UserService 注册进 UserEditorComponent 中。 然后,每个 UserEditorComponent 的实例都会获取它自己的缓存服务实例。

单例服务是指在应用中只存在一个实例的服务。

提供单例服务

在 Angular 中有两种方式来生成单例服务:

  • @Injectable() 中的 providedIn 属性设置为 "root"

  • 把该服务包含在 AppModule 或某个只会被 AppModule 导入的模块中。

使用 providedIn

从 Angular 6.0 开始,创建单例服务的首选方式就是在那个服务类的 @Injectable 装饰器上把 providedIn 设置为 root。这会告诉 Angular 在应用的根上提供此服务。

Path:"src/app/user.service.ts" 。

import { Injectable } from '@angular/core';@Injectable({  providedIn: 'root',})export class UserService {}

NgModule 的 providers 数组

在基于 Angular 6.0 以前的版本构建的应用中,服务是注册在 NgModuleproviders 数组中的,就像这样:

@NgModule({  ...  providers: [UserService],  ...})

如果这个 NgModule 是根模块 AppModule,此 UserService 就会是单例的,并且在整个应用中都可用。虽然你可能会看到这种形式的代码,但是最好使用在服务自身的 @Injectable() 装饰器上设置 providedIn 属性的形式,因为 Angular 6.0 可以对这些服务进行摇树优化。

forRoot() 模式

通常,你只需要用 providedIn 提供服务,用 forRoot()/forChild() 提供路由即可。 不过,理解 forRoot() 为何能够确保服务只有单个实例,可以让你学会更深层次的开发知识。

如果模块同时定义了 providers(服务)和 declarations(组件、指令、管道),那么,当你同时在多个特性模块中加载此模块时,这些服务就会被注册在多个地方。这会导致出现多个服务实例,并且该服务的行为不再像单例一样。

有多种方式来防止这种现象:

  • providedIn 语法代替在模块中注册服务的方式。

  • 把你的服务分离到它们自己的模块中。

  • 在模块中分别定义 forRoot()forChild() 方法。

使用 forRoot() 来把提供者从该模块中分离出去,这样你就能在根模块中导入该模块时带上 providers,并且在子模块中导入它时不带 providers

  1. 在该模块中创建一个静态方法 forRoot()

  1. 把这些提供者放进 forRoot() 方法中。

Path:"src/app/greeting/greeting.module.ts" 。

static forRoot(config: UserServiceConfig): ModuleWithProviders<GreetingModule> {  return {    ngModule: GreetingModule,    providers: [      {provide: UserServiceConfig, useValue: config }    ]  };}

forRoot() 和 Router

RouterModule 中提供了 Router 服务,同时还有一些路由指令,比如 RouterOutletrouterLink 等。应用的根模块导入了 RouterModule,以便应用中有一个 Router 服务,并且让应用的根组件可以访问各个路由器指令。任何一个特性模块也必须导入 RouterModule,这样它们的组件模板中才能使用这些路由器指令。

如果 RouterModule 没有 forRoot(),那么每个特性模块都会实例化一个新的 Router 实例,而这会破坏应用的正常逻辑,因为应用中只能有一个 Router 实例。通过使用 forRoot() 方法,应用的根模块中会导入 RouterModule.forRoot(...),从而获得一个 Router 实例,而所有的特性模块要导入 RouterModule.forChild(...),它就不会实例化另外的 Router

注:

  • 如果你的某个模块也同时有 providersdeclarations,你也可以使用这种技巧来把它们分开。你可能会在某些传统应用中看到这种模式。 不过,从 Angular 6.0 开始,提供服务的最佳实践是使用 @Injectable()providedIn 属性。

forRoot() 的工作原理

forRoot() 会接受一个服务配置对象,并返回一个 ModuleWithProviders 对象,它带有下列属性:

  • ngModule:在这个例子中,就是 GreetingModule 类。

  • providers - 配置好的服务提供者。

根模块 AppModule 导入了 GreetingModule,并把它的 providers 添加到了 AppModule 的服务提供者列表中。特别是,Angular 会把所有从其它模块导入的提供者追加到本模块的 @NgModule.providers 中列出的提供者之前。这种顺序可以确保你在 AppModuleproviders 中显式列出的提供者,其优先级高于导入模块中给出的提供者。

在这个范例应用中,导入 GreetingModule,并只在 AppModule 中调用一次它的 forRoot() 方法。像这样注册它一次就可以防止出现多个实例。

你还可以在 GreetingModule 中添加一个用于配置 UserServiceforRoot() 方法。

在下面的例子中,可选的注入 UserServiceConfig 扩展了 UserService。如果 UserServiceConfig 存在,就从这个配置中设置用户名。

Path:"src/app/greeting/user.service.ts (constructor)" 。

constructor(@Optional() config?: UserServiceConfig) {  if (config) { this._userName = config.userName; }}

下面是一个接受 UserServiceConfig 参数的 forRoot() 方法:

Path:"src/app/greeting/greeting.module.ts (forRoot)" 。

static forRoot(config: UserServiceConfig): ModuleWithProviders<GreetingModule> {  return {    ngModule: GreetingModule,    providers: [      {provide: UserServiceConfig, useValue: config }    ]  };}

最后,在 AppModuleimports 列表中调用它。在下面的代码片段中,省略了文件的另一部分。

Path:"src/app/app.module.ts (imports)" 。

import { GreetingModule } from './greeting/greeting.module';@NgModule({  imports: [    GreetingModule.forRoot({userName: 'Miss Marple'}),  ],})

该应用不再显示默认的 “Sherlock Holmes”,而是用 “Miss Marple” 作为用户名称。

注:

  • 在本文件的顶部要以 JavaScript import 形式导入 GreetingModule,并且不要把它多次加入到本 @NgModuleimports 列表中。

防止重复导入 GreetingModule

只有根模块 AppModule 才能导入 GreetingModule。如果一个惰性加载模块也导入了它, 该应用就会为服务生成多个实例。

要想防止惰性加载模块重复导入 GreetingModule,可以添加如下的 GreetingModule 构造函数。

Path:"src/app/greeting/greeting.module.ts" 。

constructor (@Optional() @SkipSelf() parentModule?: GreetingModule) {  if (parentModule) {    throw new Error(      'GreetingModule is already loaded. Import it in the AppModule only');  }}

该构造函数要求 Angular 把 GreetingModule 注入它自己。 如果 Angular 在当前注入器中查找 GreetingModule,这次注入就会导致死循环,但是 @SkipSelf() 装饰器的意思是 "在注入器树中层次高于我的祖先注入器中查找 GreetingModule。"

如果该构造函数如预期般执行在 AppModule 中,那就不会有任何祖先注入器可以提供 CoreModule 的实例,所以该注入器就会放弃注入。

默认情况下,当注入器找不到想找的提供者时,会抛出一个错误。 但 @Optional() 装饰器表示找不到该服务也无所谓。 于是注入器会返回 nullparentModule 参数也就被赋成了空值,而构造函数没有任何异常。

但如果你把 GreetingModule 导入到像 CustomerModule 这样的惰性加载模块中,事情就不一样了。

Angular 创建惰性加载模块时会给它一个自己的注入器,它是根注入器的子注入器。 @SkipSelf() 让 Angular 在其父注入器中查找 GreetingModule,这次,它的父注入器是根注入器(而上次的父注入器是空)。 当然,这次它找到了由根模块 AppModule 导入的实例。 该构造函数检测到存在 parentModule,于是抛出一个错误。

以下这两个文件仅供参考:

  1. Path:"src/app/app.module.ts" 。

    import { BrowserModule } from '@angular/platform-browser';    import { NgModule } from '@angular/core';    /* App Root */    import { AppComponent } from './app.component';    /* Feature Modules */    import { ContactModule } from './contact/contact.module';    import { GreetingModule } from './greeting/greeting.module';    /* Routing Module */    import { AppRoutingModule } from './app-routing.module';    @NgModule({      imports: [        BrowserModule,        ContactModule,        GreetingModule.forRoot({userName: 'Miss Marple'}),        AppRoutingModule      ],      declarations: [        AppComponent      ],      bootstrap: [AppComponent]    })    export class AppModule { }

  1. Path:"src/app/greeting.module.ts" 。

import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core';import { CommonModule } from '@angular/common';import { GreetingComponent } from './greeting.component';import { UserServiceConfig } from './user.service';@NgModule({  imports:      [ CommonModule ],  declarations: [ GreetingComponent ],  exports:      [ GreetingComponent ]})export class GreetingModule {  constructor (@Optional() @SkipSelf() parentModule?: GreetingModule) {    if (parentModule) {      throw new Error(        'GreetingModule is already loaded. Import it in the AppModule only');    }  }  static forRoot(config: UserServiceConfig): ModuleWithProviders<GreetingModule> {    return {      ngModule: GreetingModule,      providers: [        {provide: UserServiceConfig, useValue: config }      ]    };  }}

默认情况下,NgModule 都是急性加载的,也就是说它会在应用加载时尽快加载,所有模块都是如此,无论是否立即要用。对于带有很多路由的大型应用,考虑使用惰性加载 —— 一种按需加载 NgModule 的模式。惰性加载可以减小初始包的尺寸,从而减少加载时间。

惰性加载入门

本节会介绍配置惰性加载路由的基本过程。 想要一个分步的范例,参见本页的分步设置部分。

要惰性加载 Angular 模块,请在 AppRoutingModule routes 中使用 loadchildren 代替 component 进行配置,代码如下。

//AppRoutingModule (excerpt)const routes: Routes = [  {    path: 'items',    loadChildren: () => import('./items/items.module').then(m => m.ItemsModule)  }];

在惰性加载模块的路由模块中,添加一个指向该组件的路由。

//Routing module for lazy loaded module (excerpt)const routes: Routes = [  {    path: '',    component: ItemsComponent  }];

还要确保从 AppModule 中移除了 ItemsModule

分步设置

建立惰性加载的特性模块有两个主要步骤:

  1. 使用 --route 标志,用 CLI 创建特性模块。

  1. 配置相关路由。

建立应用

如果你还没有应用,可以遵循下面的步骤使用 CLI 创建一个。如果已经有了,可以直接跳到 配置路由部分。 输入下列命令,其中的 customer-app 表示你的应用名称:

ng new customer-app --routing

这会创建一个名叫 "customer-app" 的应用,而 --routing 标识生成了一个名叫 "app-routing.module.ts" 的文件,它是你建立惰性加载的特性模块时所必须的。 输入命令 cd customer-app 进入该项目。

注:

  • --routing 选项需要 Angular/CLI 8.1 或更高版本。

创建一个带路由的特性模块

接下来,你将需要一个包含路由的目标组件的特性模块。 要创建它,在终端中输入如下命令,其中 customers 是特性模块的名称。加载 customers 特性模块的路径也是 customers,因为它是通过 --route 选项指定的:

ng generate module customers --route customers --module app.module

这将创建一个 "customers" 文件夹,在其 "customers.module.ts" 文件中定义了新的可惰性加载模块 CustomersModule。该命令会自动在新特性模块中声明 CustomersComponent

因为这个新模块想要惰性加载,所以该命令不会在应用的根模块 "app.module.ts" 中添加对新特性模块的引用。 相反,它将声明的路由 customers 添加到以 --module 选项指定的模块中声明的 routes 数组中。

Path:"src/app/app-routing.module.ts" 。

const routes: Routes = [  {    path: 'customers',    loadChildren: () => import('./customers/customers.module').then(m => m.CustomersModule)  }];

惰性加载语法使用 loadChildren,其后是一个使用浏览器内置的 import('...')语法进行动态导入的函数。 其导入路径是到当前模块的相对路径。

添加另一个特性模块

使用同样的命令创建第二个带路由的惰性加载特性模块及其桩组件。

ng generate module orders --route orders --module app.module

这将创建一个名为 "orders" 的新文件夹,其中包含 OrdersModuleOrdersRoutingModule 以及新的 OrdersComponent 源文件。 使用 --route 选项指定的 orders 路由,用惰性加载语法添加到了 "app-routing.module.ts" 文件内的 routes 数组中。

Path:"src/app/app-routing.module.ts" 。

const routes: Routes = [  {    path: 'customers',    loadChildren: () => import('./customers/customers.module').then(m => m.CustomersModule)  },  {    path: 'orders',    loadChildren: () => import('./orders/orders.module').then(m => m.OrdersModule)  }];

建立 UI

虽然你也可以在地址栏中输入 URL,不过导航 UI 会更好用,也更常见。 把 "app.component.html" 中的占位脚本替换成一个自定义的导航,以便你在浏览器中能轻松地在模块之间导航。

Path:"src/app/app.component.html" 。

<h1>  {{title}}</h1><button routerLink="/customers">Customers</button><button routerLink="/orders">Orders</button><button routerLink="">Home</button><router-outlet></router-outlet>

要想在浏览器中看到你的应用,就在终端窗口中输入下列命令:

ng serve

然后,跳转到 "localhost:4200",这时你应该看到 "customer-app" 和三个按钮。

这些按钮生效了,因为 CLI 会自动将特性模块的路由添加到 "app.module.ts" 中的 routes 数组中。

导入与路由配置

CLI 会将每个特性模块自动添加到应用级的路由映射表中。 通过添加默认路由来最终完成这些步骤。 在 "app-routing.module.ts" 文件中,使用如下命令更新 routes 数组:

Path:"src/app/app-routing.module.ts" 。

const routes: Routes = [  {    path: 'customers',    loadChildren: () => import('./customers/customers.module').then(m => m.CustomersModule)  },  {    path: 'orders',    loadChildren: () => import('./orders/orders.module').then(m => m.OrdersModule)  },  {    path: '',    redirectTo: '',    pathMatch: 'full'  }];

前两个路径是到 CustomersModuleOrdersModule 的路由。 最后一个条目则定义了默认路由。空路径匹配所有不匹配先前路径的内容。

特性模块内部

接下来,仔细看看 "customers.module.ts" 文件。如果你使用的是 CLI,并按照此页面中的步骤进行操作,则无需在此处执行任何操作。

Path:"src/app/customers/customers.module.ts" 。

import { NgModule } from '@angular/core';import { CommonModule } from '@angular/common';import { CustomersRoutingModule } from './customers-routing.module';import { CustomersComponent } from './customers.component';@NgModule({  imports: [    CommonModule,    CustomersRoutingModule  ],  declarations: [CustomersComponent]})export class CustomersModule { }

"customers.module.ts" 文件导入了 "customers-routing.module.ts" 和 "customers.component.ts" 文件。@NgModuleimports 数组中列出了 CustomersRoutingModule,让 CustomersModule 可以访问它自己的路由模块。CustomersComponent 位于 declarations 数组中,这意味着 CustomersComponent 属于 CustomersModule

然后,"app-routing.module.ts" 会使用 JavaScript 的动态导入功能来导入特性模块 "customers.module.ts"。

专属于特性模块的路由定义文件 "customers-routing.module.ts" 将导入在 "customers.component.ts" 文件中定义的自有特性组件,以及其它 JavaScript 导入语句。然后将空路径映射到 CustomersComponent

Path:"src/app/customers/customers-routing.module.ts" 。

import { NgModule } from '@angular/core';import { Routes, RouterModule } from '@angular/router';import { CustomersComponent } from './customers.component';const routes: Routes = [  {    path: '',    component: CustomersComponent  }];@NgModule({  imports: [RouterModule.forChild(routes)],  exports: [RouterModule]})export class CustomersRoutingModule { }

这里的 path 设置为空字符串,因为 AppRoutingModule 中的路径已经设置为 customers,因此,CustomersRoutingModule 中的此路由已经位于 customers 这个上下文中。此路由模块中的每个路由都是其子路由。

另一个特性模块中路由模块的配置也类似。

Path:"src/app/orders/orders-routing.module.ts (excerpt)" 。

import { OrdersComponent } from './orders.component';const routes: Routes = [  {    path: '',    component: OrdersComponent  }];

确认它工作正常

你可以使用 Chrome 开发者工具来确认一下这些模块真的是惰性加载的。 在 Chrome 中,按 Cmd+Option+i(Mac)或 Ctrl+Shift+j(PC),并选中 Network 页标签。

点击 OrdersCustomers 按钮。如果你看到某个 chunk 文件出现了,就表示一切就绪,特性模块被惰性加载成功了。OrdersCustomers 都应该出现一次 chunk,并且它们各自只应该出现一次。

要想再次查看它或测试本项目后面的行为,只要点击 Network 页左上放的 清除 图标即可。

然后,使用 Cmd+r(Mac) 或 Ctrl+r(PC) 重新加载页面。

forRoot() 与 forChild()

你可能已经注意到了,CLI 会把 RouterModule.forRoot(routes) 添加到 AppRoutingModuleimports 数组中。 这会让 Angular 知道 AppRoutingModule 是一个路由模块,而 forRoot() 表示这是一个根路由模块。 它会配置你传入的所有路由、让你能访问路由器指令并注册 RouterforRoot() 在应用中只应该使用一次,也就是这个 AppRoutingModule 中。

CLI 还会把 RouterModule.forChild(routes) 添加到各个特性模块中。这种方式下 Angular 就会知道这个路由列表只负责提供额外的路由并且其设计意图是作为特性模块使用。你可以在多个模块中使用 forChild()

forRoot() 方法为路由器管理全局性的注入器配置。 forChild() 方法中没有注入器配置,只有像 RouterOutletRouterLink 这样的指令。 欲知详情,参见单例服务章的 forRoot() 模式小节。

预加载

预加载通过在后台加载部分应用来改进用户体验。你可以预加载模块或组件数据。

预加载模块

预加载模块通过在后台加载部分应用来改善用户体验,这样用户在激活路由时就无需等待下载这些元素。

要启用所有惰性加载模块的预加载, 请从 Angular 的 router 导入 PreloadAllModules 令牌。

//AppRoutingModule (excerpt)import { PreloadAllModules } from '@angular/router';

还是在 AppRoutingModule 中,通过 forRoot() 指定你的预加载策略。

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

预加载组件数据

要预加载组件数据,你可以使用 resolver 守卫。解析器通过阻止页面加载来改进用户体验,直到显示页面时的全部必要数据都可用。

创建一个解析器服务。通过 CLI,生成服务的命令如下:

ng generate service

在你的服务中,导入下列路由器成员,实现 Resolve 接口,并注入到 Router 服务中:

//Resolver service (excerpt)import { Resolve } from '@angular/router';...export class CrisisDetailResolverService implements Resolve<> {  resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<> {    // your logic goes here  }}

把这个解析器导入此模块的路由模块。

//Feature module's routing module (excerpt)import { YourResolverService }    from './your-resolver.service';

在组件的 route 配置中添加一个 resolve 对象。

//Feature module's routing module (excerpt){  path: '/your-path',  component: YourComponent,  resolve: {    crisis: YourResolverService  }}

在此组件中,使用一个 Observable 来从 ActivatedRoute 获取数据。

//Component (excerpt)ngOnInit() {  this.route.data    .subscribe((your-parameters) => {      // your data-specific code goes here    });}···

创建共享模块能让你更好地组织和梳理代码。你可以把常用的指令、管道和组件放进一个模块中,然后在应用中其它需要这些的地方导入该模块。

想象某个应用有下列模块:

import { CommonModule } from '@angular/common';import { NgModule } from '@angular/core';import { FormsModule } from '@angular/forms';import { CustomerComponent } from './customer.component';import { NewItemDirective } from './new-item.directive';import { OrdersPipe } from './orders.pipe';@NgModule({ imports:      [ CommonModule ], declarations: [ CustomerComponent, NewItemDirective, OrdersPipe ], exports:      [ CustomerComponent, NewItemDirective, OrdersPipe,                 CommonModule, FormsModule ]})export class SharedModule { }

请注意以下几点:

  • 它导入了 CommonModule,因为该模块需要一些常用指令。

  • 它声明并导出了一些工具性的管道、指令和组件类。

-它重新导出了 CommonModuleFormsModule

通过重新导出 CommonModuleFormsModule,任何导入了这个 SharedModule 的其它模块,就都可以访问来自 CommonModuleNgIfNgFor 等指令了,也可以绑定到来自 FormsModule 中的 [(ngModel)] 的属性了。

即使 SharedModule 中声明的组件没有绑定过 [(ngModel)],而且 SharedModule 也不需要导入 FormsModuleSharedModule 仍然可以导出 FormsModule,而不必把它列在 imports 中。 这种方式下,你可以让其它模块也能访问 FormsModule,而不用直接在自己的 @NgModule 装饰器中导入它。

使用来自其它模块的组件和服务

在使用来自其它模块的组件和来自其它模块的服务时,有一个很重要的区别。 当你要使用指令、管道和组件时,导入那些模块就可以了。而导入带有服务的模块意味着你会拥有那个服务的一个新实例,这通常不会是你想要的结果(你通常会想取到现存的服务)。使用模块导入来控制服务的实例化。

获取共享服务的最常见方式是通过 Angular 的依赖注入系统,而不是模块系统(导入模块将导致创建新的服务实例,那不是典型的用法)。

宏观来讲,NgModule 是组织 Angular 应用的一种方式,它们通过 @NgModule 装饰器中的元数据来实现这一点。 这些元数据可以分成三类:

静态的:编译器配置,用于告诉编译器指令的选择器并通过选择器匹配的方式决定要把该指令应用到模板中的什么位置。它是通过 declarations 数组来配置的。

运行时:通过 providers 数组提供给注入器的配置。

组合/分组:通过 importsexports 数组来把多个 NgModule 放在一起,并让它们可用。

@NgModule({  // Static, that is compiler configuration  declarations: [], // Configure the selectors  entryComponents: [], // Generate the host factory  // Runtime, or injector configuration  providers: [], // Runtime injector configuration  // Composability / Grouping  imports: [], // composing NgModules together  exports: [] // making NgModules available to other parts of the app})

@NgModule 元数据

接下来对 @NgModule 元数据中属性的进行汇总:

  1. 属性:declarations

属于该模块的可声明对象(组件、指令和管道)的列表。

  • 当编译模板时,你需要确定一组选择器,它们将用于触发相应的指令。

  • 该模板在 NgModule 环境中编译 —— 模板的组件是在该 NgModule 内部声明的,它会使用如下规则来确定这组选择器:

  • 列在 declarations 中的所有指令选择器。

  • 从所导入的 NgModule 中导出的那些指令的选择器。

  • 组件、指令和管道只能属于一个模块。 如果尝试把同一个类声明在多个模块中,编译器就会报告一个错误。 小心,不要重复声明从其它模块中直接或间接导入的类。

  1. 属性:providers

依赖注入提供者的列表。

  • Angular 会使用该模块的注入器注册这些提供者。 如果该模块是启动模块,那就会使用根注入器。

  • 当需要注入到任何组件、指令、管道或服务时,这些服务对于本注入器的子注入器都是可用的。

  • 惰性加载模块有自己的注入器,它通常是应用的根注入器的子注入器。

  • 惰性加载的服务是局限于这个惰性加载模块的注入器中的。 如果惰性加载模块也提供了 UserService,那么在这个模块的上下文中创建的任何组件(比如在路由器导航时),都会获得这个服务的本模块内实例,而不是来自应用的根注入器的实例。

  • 其它外部模块中的组件也会使用它们自己的注入器提供的服务实例。

  1. 属性:imports

  • 要折叠(Folded)进本模块中的其它模块。折叠的意思是从被导入的模块中导出的那些软件资产同样会被声明在这里。

  • 特别是,这里列出的模块,其导出的组件、指令或管道,当在组件模板中被引用时,和本模块自己声明的那些是等价的。

  • 组件模板可以引用其它组件、指令或管道,不管它们是在本模块中声明的,还是从导入的模块中导出的。 比如,只有当该模块导入了 Angular 的 CommonModule(也可能从BrowserModule中间接导入)时,组件才能使用NgIfNgFor 指令。

  • 你可以从 CommonModule 中导入很多标准指令,不过也有些常用的指令属于其它模块。 比如,你只有导入了 Angular 的 FormsModule 时才能使用 [(ngModel)]

  1. 属性:exports

可供导入了自己的模块使用的可声明对象(组件、指令、管道类)的列表。

  • 导出的可声明对象就是本模块的公共 API。 只有当其它模块导入了本模块,并且本模块导出了 UserComponent 时,其它模块中的组件才能使用本模块中的 UserComponent

  • 默认情况下这些可声明对象都是私有的。 如果本模块没有导出 UserComponent,那么就只有本模块中的组件才能使用 UserComponent

  • 导入某个模块并不会自动重新导出被导入模块的那些导入。 模块 B 不会因为它导入了模块 A,而模块 A 导入了 CommonModule 而能够使用 ngIf。 模块 B 必须自己导入 CommonModule

  • 一个模块可以把另一个模块加入自己的 exports 列表中,这时,另一个模块的所有公共组件、指令和管道都会被导出。

  • 重新导出可以让模块被显式传递。 如果模块 A 重新导出了 CommonModule,而模块 B 导入了模块 A,那么模块 B 就可以使用 ngIf 了 —— 即使它自己没有导入 CommonModule

  1. 属性:bootstrap

要自动启动的组件列表。

  • 通常,在这个列表中只有一个组件,也就是应用的根组件。

  • Angular 也可以用多个引导组件进行启动,它们每一个在宿主页面中都有自己的位置。

  • 启动组件会自动添加到 entryComponents 中。

  1. 属性:entryComponents

那些可以动态加载进视图的组件列表。

  • 默认情况下,Angular 应用至少有一个入口组件,也就是根组件 AppComponent。 它用作进入该应用的入口点,也就是说你通过引导它来启动本应用。

  • 路由组件也是入口组件,因为你需要动态加载它们。 路由器创建它们,并把它们扔到 DOM 中的 <router-outlet> 附近。

  • 虽然引导组件和路由组件都是入口组件,不过你不用自己把它们加到模块的 entryComponents 列表中,因为它们会被隐式添加进去。

  • Angular 会自动把模块的 bootstrap 中的组件和路由定义中的组件添加到 entryComponents 列表。

  • 而那些使用不易察觉的ViewComponentRef.createComponent()的方式进行命令式引导的组件仍然需要添加。

  • 动态组件加载在除路由器之外的大多数应用中都不太常见。如果你需要动态加载组件,就必须自己把那些组件添加到 entryComponents 列表中。

NgModules 可以帮你把应用组织成一些紧密相关的代码块。

这里回答的是开发者常问起的关于 NgModule设计与实现问题

我应该把哪些类加到 declarations 中?

把可声明的类(组件、指令和管道)添加到 declarations 列表中。

这些类只能在应用程序的一个并且只有一个模块中声明。 只有当它们从属于某个模块时,才能把在此模块中声明它们。

什么是可声明的?

声明的就是组件、指令和管道这些可以被加到模块的 declarations 列表中的类。它们也是所有能被加到 declarations 中的类。

哪些类不应该加到 declarations 中?

只有可声明的类才能加到模块的 declarations 列表中。

不要声明:

  • 已经在其它模块中声明过的类。无论它来自应用自己的模块(@NgModule)还是第三方模块。

  • 从其它模块中导入的指令。例如,不要声明来自 @angular/formsFORMS_DIRECTIVES,因为 FormsModule 已经声明过它们了。

  • 模块类。

  • 服务类

  • 非 Angular 的类和对象,比如:字符串、数字、函数、实体模型、配置、业务逻辑和辅助类。

为什么要把同一个组件声明在不同的 NgModule 属性中?

AppComponent 经常被同时列在 declarationsbootstrap 中。 另外你还可能看到 HeroComponent 被同时列在 declarationsexportsentryComponent 中。

这看起来是多余的,不过这些函数具有不同的功能,从它出现在一个列表中无法推断出它也应该在另一个列表中。

  • AppComponent 可能被声明在此模块中,但可能不是引导组件。

  • AppComponent 可能在此模块中引导,但可能是由另一个特性模块声明的。

  • 某个组件可能是从另一个应用模块中导入的(所以你没法声明它)并且被当前模块重新导出。

  • 某个组件可能被导出,以便用在外部组件的模板中,也可能同时被一个弹出式对话框加载。

"Can't bind to 'x' since it isn't a known property of 'y'"是什么意思?

这个错误通常意味着你或者忘了声明指令“x”,或者你没有导入“x”所属的模块。

如果“x”其实不是属性,或者是组件的私有属性(比如它不带 @Input@Output 装饰器),那么你也同样会遇到这个错误。

我应该导入什么?

导入你需要在当前模块的组件模板中使用的那些公开的(被导出的)可声明类。

这意味着要从 @angular/common 中导入 CommonModule 才能访问 Angular 的内置指令,比如 NgIfNgFor。 你可以直接导入它或者从重新导出过该模块的其它模块中导入它。

如果你的组件有 [(ngModel)] 双向绑定表达式,就要从 @angular/forms 中导入 FormsModule

如果当前模块中的组件包含了共享模块和特性模块中的组件、指令和管道,就导入这些模块。

只能在根模块 AppModule 中导入 BrowserModule

我应该导入 BrowserModule 还是 CommonModule?

几乎所有要在浏览器中使用的应用的根模块(AppModule)都应该从 @angular/platform-browser 中导入 BrowserModule

BrowserModule 提供了启动和运行浏览器应用的那些基本的服务提供者。

BrowserModule 还从 @angular/common 中重新导出了 CommonModule,这意味着 AppModule 中的组件也同样可以访问那些每个应用都需要的 Angular 指令,如 NgIfNgFor

在其它任何模块中都不要导入BrowserModule。 特性模块和惰性加载模块应该改成导入 CommonModule。 它们需要通用的指令。它们不需要重新初始化全应用级的提供者。

如果我两次导入同一个模块会怎么样?

没有任何问题。当三个模块全都导入模块'A'时,Angular 只会首次遇到时加载一次模块'A',之后就不会这么做了。

无论 A 出现在所导入模块的哪个层级,都会如此。 如果模块'B'导入模块'A'、模块'C'导入模块'B',模块'D'导入 [C, B, A],那么'D'会触发模块'C'的加载,'C'会触发'B'的加载,而'B'会加载'A'。 当 Angular 在'D'中想要获取'B''A'时,这两个模块已经被缓存过了,可以立即使用。

Angular 不允许模块之间出现循环依赖,所以不要让模块'A'导入模块'B',而模块'B'又导入模块'A'

特性模块中导入 CommonModule 可以让它能用在任何目标平台上,不仅是浏览器。那些跨平台库的作者应该喜欢这种方式的。

我应该导出什么?

导出那些其它模块希望在自己的模板中引用的可声明类。这些也是你的公共类。 如果你不导出某个类,它就是私有的,只对当前模块中声明的其它组件可见。

你可以导出任何可声明类(组件、指令和管道),而不用管它是声明在当前模块中还是某个导入的模块中。

你可以重新导出整个导入过的模块,这将导致重新导出它们导出的所有类。重新导出的模块甚至不用先导入。

我不应该导出什么?

不要导出:

  • 那些你只想在当前模块中声明的那些组件中使用的私有组件、指令和管道。如果你不希望任何模块看到它,就不要导出。

  • 不可声明的对象,比如服务、函数、配置、实体模型等。

  • 那些只被路由器或引导函数动态加载的组件。 比如入口组件可能从来不会在其它组件的模板中出现。 导出它们没有坏处,但也没有好处。

  • 纯服务模块没有公开(导出)的声明。 例如,没必要重新导出 HttpClientModule,因为它不导出任何东西。 它唯一的用途是一起把 http 的那些服务提供者添加到应用中。

我可以重新导出类和模块吗?

毫无疑问!

模块是从其它模块中选取类并把它们重新导出成统一、便利的新模块的最佳方式。

模块可以重新导出其它模块,这会导致重新导出它们导出的所有类。 Angular 自己的 BrowserModule 就重新导出了一组模块,例如:

exports: [CommonModule, ApplicationModule]

模块还能导出一个组合,它可以包含自己的声明、某些导入的类以及导入的模块。

不要费心去导出纯服务类。 纯服务类的模块不会导出任何可供其它模块使用的可声明类。 例如,不用重新导出 HttpClientModule,因为它没有导出任何东西。 它唯一的用途是把那些 http 服务提供者一起添加到应用中。

forRoot()方法是什么?

静态方法 forRoot() 是一个约定,它可以让开发人员更轻松的配置模块的想要单例使用的服务及其提供者。RouterModule.forRoot() 就是一个很好的例子。

应用把一个 Routes 对象传给 RouterModule.forRoot(),为的就是使用路由配置全应用级的 Router 服务。 RouterModule.forRoot() 返回一个ModuleWithProviders对象。 你把这个结果添加到根模块 AppModuleimports 列表中。

只能在应用的根模块 AppModule 中调用并导入 forRoot() 的结果。 在其它模块,特别是惰性加载模块中,不要导入它。 要了解关于 forRoot() 的更多信息,参见单例服务一章的 the forRoot() 模式部分。

对于服务来说,除了可以使用 forRoot() 外,更好的方式是在该服务的 @Injectable() 装饰器中指定 providedIn: 'root',它让该服务自动在全应用级可用,这样它也就默认是单例的。

RouterModule 也提供了静态方法 forChild(),用于配置惰性加载模块的路由。

forRoot()forChild() 都是约定俗成的方法名,它们分别用于在根模块和特性模块中配置服务。

当你写类似的需要可配置的服务提供者时,请遵循这个约定。

为什么服务提供者在特性模块中的任何地方都是可见的?

列在引导模块的 @NgModule.providers 中的服务提供者具有全应用级作用域。 往 NgModule.providers 中添加服务提供者将导致该服务被发布到整个应用中。

当你导入一个模块时,Angular 就会把该模块的服务提供者(也就是它的 providers 列表中的内容)加入该应用的根注入器中。

这会让该提供者对应用中所有知道该提供者令牌(token)的类都可见。

通过 NgModule 导入来实现可扩展性是 NgModule 体系的主要设计目标。 把 NgModule 的提供者并入应用程序的注入器可以让库模块使用新的服务来强化应用程序变得更容易。 只要添加一次 HttpClientModule,那么应用中的每个组件就都可以发起 Http 请求了。

不过,如果你期望模块的服务只对那个特性模块内部声明的组件可见,那么这可能会带来一些不受欢迎的意外。 如果 HeroModule 提供了一个 HeroService,并且根模块 AppModule 导入了 HeroModule,那么任何知道 HeroService类型的类都可能注入该服务,而不仅是在 HeroModule 中声明的那些类。

要限制对某个服务的访问,可以考虑惰性加载提供该服务的 NgModule

为什么在惰性加载模块中声明的服务提供者只对该模块自身可见?

和启动时就加载的模块中的提供者不同,惰性加载模块中的提供者是局限于模块的。

当 Angular 路由器惰性加载一个模块时,它创建了一个新的运行环境。 那个环境拥有自己的注入器,它是应用注入器的直属子级。

路由器把该惰性加载模块的提供者和它导入的模块的提供者添加到这个子注入器中。

这些提供者不会被拥有相同令牌的应用级别提供者的变化所影响。 当路由器在惰性加载环境中创建组件时,Angular 优先使用惰性加载模块中的服务实例,而不是来自应用的根注入器的。

如果两个模块提供了同一个服务会怎么样?

当同时加载了两个导入的模块,它们都列出了使用同一个令牌的提供者时,后导入的模块会“获胜”,这是因为这两个提供者都被添加到了同一个注入器中。

当 Angular 尝试根据令牌注入服务时,它使用第二个提供者来创建并交付服务实例。

每个注入了该服务的类获得的都是由第二个提供者创建的实例。 即使是声明在第一个模块中的类,它取得的实例也是来自第二个提供者的。

如果模块 A 提供了一个使用令牌'X'的服务,并且导入的模块 B 也用令牌'X'提供了一个服务,那么模块 A 中定义的服务“获胜”了。

由根 AppModule 提供的服务相对于所导入模块中提供的服务有优先权。换句话说:AppModule 总会获胜。

我应该如何把服务的范围限制到模块中?

如果一个模块在应用程序启动时就加载,它的 @NgModule.providers 具有全应用级作用域。 它们也可用于整个应用的注入中。

导入的提供者很容易被由其它导入模块中的提供者替换掉。 这虽然是故意这样设计的,但是也可能引起意料之外的结果。

作为一个通用的规则,应该只导入一次带提供者的模块,最好在应用的根模块中。 那里也是配置、包装和改写这些服务的最佳位置。

假设模块需要一个定制过的 HttpBackend,它为所有的 Http 请求添加一个特别的请求头。 如果应用中其它地方的另一个模块也定制了 HttpBackend 或仅仅导入了 HttpClientModule,它就会改写当前模块的 HttpBackend 提供者,丢掉了这个特别的请求头。 这样服务器就会拒绝来自该模块的请求。

要消除这个问题,就只能在应用的根模块 AppModule 中导入 HttpClientModule

如果你必须防范这种“提供者腐化”现象,那就不要依赖于“启动时加载”模块的 providers

只要可能,就让模块惰性加载。 Angular 给了惰性加载模块自己的子注入器。 该模块中的提供者只对由该注入器创建的组件树可见。

如果你必须在应用程序启动时主动加载该模块,就改成在组件中提供该服务。

继续看这个例子,假设某个模块的组件真的需要一个私有的、自定义的 HttpBackend

那就创建一个“顶层组件”来扮演该模块中所有组件的根。 把这个自定义的 HttpBackend 提供者添加到这个顶层组件的 providers 列表中,而不是该模块的 providers 中。 回忆一下,Angular 会为每个组件实例创建一个子注入器,并使用组件自己的 providers 来配置这个注入器。

当该组件的子组件想要一个 HttpBackend 服务时,Angular 会提供一个局部的 HttpBackend 服务,而不是应用的根注入器创建的那个。 子组件将正确发起 http 请求,而不管其它模块对 HttpBackend 做了什么。

确保把模块中的组件都创建成这个顶层组件的子组件。

你可以把这些子组件都嵌在顶层组件的模板中。或者,给顶层组件一个 <router-outlet>,让它作为路由的宿主。 定义子路由,并让路由器把模块中的组件加载进该路由出口(outlet)中。

虽然通过在惰性加载模块中或组件中提供某个服务来限制它的访问都是可行的方式,但在组件中提供服务可能导致这些服务出现多个实例。因此,应该优先使用惰性加载的方式。

我应该把全应用级提供者添加到根模块 AppModule 中还是根组件 AppComponent 中?

通过在服务的 @Injectable() 装饰器中(例如服务)指定 providedIn: 'root' 来定义全应用级提供者,或者 InjectionToken 的构造器(例如提供令牌的地方),都可以定义全应用级提供者。 通过这种方式创建的服务提供者会自动在整个应用中可用,而不用把它列在任何模块中。

如果某个提供者不能用这种方式配置(可能因为它没有有意义的默认值),那就在根模块 AppModule 中注册这些全应用级服务,而不是在 AppComponent 中。

惰性加载模块及其组件可以注入 AppModule 中的服务,却不能注入 AppComponent 中的。

只有当该服务必须对 AppComponent 组件树之外的组件不可见时,才应该把服务注册进 AppComponentproviders 中。 这是一个非常罕见的异常用法。

更一般地说,优先把提供者注册进模块中,而不是组件中。

讨论

Angular 把所有启动期模块的提供者都注册进了应用的根注入器中。 这些服务是由根注入器中的提供者创建的,并且在整个应用中都可用。 它们具有应用级作用域。

某些服务(比如 Router)只有当注册进应用的根注入器时才能正常工作。

相反,Angular 使用 AppComponent 自己的注入器注册了 AppComponent 的提供者。 AppComponent 服务只在该组件及其子组件树中才能使用。 它们具有组件级作用域。

AppComponent 的注入器是根注入器的子级,注入器层次中的下一级。 这对于没有路由器的应用来说几乎是整个应用了。 但对那些带路由的应用,路由操作位于顶层,那里不存在 AppComponent 服务。这意味着惰性加载模块不能使用它们。

我应该把其它提供者注册到模块中还是组件中?

提供者应该使用 @Injectable 语法进行配置。只要可能,就应该把它们在应用的根注入器中提供(providedIn: 'root')。 如果它们只被惰性加载的上下文中使用,那么这种方式配置的服务就是惰性加载的。

如果要由消费方来决定是否把它作为全应用级提供者,那么就要在模块中(@NgModule.providers)注册提供者,而不是组件中(@Component.providers)。

当你必须把服务实例的范围限制到某个组件及其子组件树时,就把提供者注册到该组件中。 指令的提供者也同样照此处理。

例如,如果英雄编辑组件需要自己私有的缓存英雄服务实例,那就应该把 HeroService 注册进 HeroEditorComponent 中。 这样,每个新的 HeroEditorComponent 的实例都会得到一份自己的缓存服务实例。 编辑器的改动只会作用于它自己的服务,而不会影响到应用中其它地方的英雄实例。

总是在根模块 AppModule 中注册全应用级服务,而不要在根组件 AppComponent 中。

为什么在共享模块中为惰性加载模块提供服务是个馊主意?

急性加载的场景

当急性加载的模块提供了服务时,比如 UserService,该服务是在全应用级可用的。如果根模块提供了 UserService,并导入了另一个也提供了同一个 UserService 的模块,Angular 就会把它们中的一个注册进应用的根注入器中(参见如果两次导入了同一个模块会怎样?)。

然后,当某些组件注入 UserService 时,Angular 就会发现它已经在应用的根注入器中了,并交付这个全应用级的单例服务。这样不会出现问题。

惰性加载场景

现在,考虑一个惰性加载的模块,它也提供了一个名叫 UserService 的服务。

当路由器准备惰性加载 HeroModule 的时候,它会创建一个子注入器,并且把 UserService 的提供者注册到那个子注入器中。子注入器和根注入器是不同的。

当 Angular 创建一个惰性加载的 HeroComponent 时,它必须注入一个 UserService。 这次,它会从惰性加载模块的子注入器中查找 UserService 的提供者,并用它创建一个 UserService 的新实例。 这个 UserService 实例与 Angular 在主动加载的组件中注入的那个全应用级单例对象截然不同。

这个场景导致你的应用每次都创建一个新的服务实例,而不是使用单例的服务。

为什么惰性加载模块会创建一个子注入器?

Angular 会把 @NgModule.providers 中的提供者添加到应用的根注入器中…… 除非该模块是惰性加载的,这种情况下,Angular 会创建一子注入器,并且把该模块的提供者添加到这个子注入器中。

这意味着模块的行为将取决于它是在应用启动期间加载的还是后来惰性加载的。如果疏忽了这一点,可能导致严重后果。

为什么 Angular 不能像主动加载模块那样把惰性加载模块的提供者也添加到应用程序的根注入器中呢?为什么会出现这种不一致?

归根结底,这来自于 Angular 依赖注入系统的一个基本特征: 在注入器还没有被第一次使用之前,可以不断为其添加提供者。 一旦注入器已经创建和开始交付服务,它的提供者列表就被冻结了,不再接受新的提供者。

当应用启动时,Angular 会首先使用所有主动加载模块中的提供者来配置根注入器,这发生在它创建第一个组件以及注入任何服务之前。 一旦应用开始工作,应用的根注入器就不再接受新的提供者了。

之后,应用逻辑开始惰性加载某个模块。 Angular 必须把这个惰性加载模块中的提供者添加到某个注入器中。 但是它无法将它们添加到应用的根注入器中,因为根注入器已经不再接受新的提供者了。 于是,Angular 在惰性加载模块的上下文中创建了一个新的子注入器。

我要如何知道一个模块或服务是否已经加载过了?

某些模块及其服务只能被根模块 AppModule 加载一次。 在惰性加载模块中再次导入这个模块会导致错误的行为,这个错误可能非常难于检测和诊断。

为了防范这种风险,可以写一个构造函数,它会尝试从应用的根注入器中注入该模块或服务。如果这种注入成功了,那就说明这个类是被第二次加载的,你就可以抛出一个错误,或者采取其它挽救措施。

某些 NgModule(例如 BrowserModule)就实现了那样一个守卫。 下面是一个名叫 GreetingModuleNgModule 的 自定义构造函数。

Path:"src/app/greeting/greeting.module.ts (Constructor)" 。

constructor (@Optional() @SkipSelf() parentModule?: GreetingModule) {  if (parentModule) {    throw new Error(      'GreetingModule is already loaded. Import it in the AppModule only');  }}

什么是入口组件?

Angular 根据组件类型命令式加载的组件是入口组件.

而通过组件选择器声明式加载的组件则不是入口组件。

Angular 会声明式的加载组件,它使用组件的选择器在模板中定位元素。 然后,Angular 会创建该组件的 HTML 表示,并把它插入 DOM 中所选元素的内部。它们不是入口组件。

而用于引导的根 AppComponent 则是一个入口组件。 虽然它的选择器匹配了 "index.html" 中的一个元素,但是 "index.html" 并不是组件模板,而且 AppComponent 选择器也不会在任何组件模板中出现。

在路由定义中用到的组件也同样是入口组件。 路由定义根据类型来引用组件。 路由器会忽略路由组件的选择器(即使它有选择器),并且把该组件动态加载到 RouterOutlet 中。

引导组件和入口组件有什么不同?

引导组件是入口组件的一种。 它是被 Angular 的引导(应用启动)过程加载到 DOM 中的入口组件。 其它入口组件则是被其它方式动态加载的,比如被路由器加载。

@NgModule.bootstrap 属性告诉编译器这是一个入口组件,同时它应该生成一些代码来用该组件引导此应用。

不需要把组件同时列在 bootstrapentryComponent 列表中 —— 虽然这样做也没坏处。

什么时候我应该把组件加到 entryComponents 中?

大多数应用开发者都不需要把组件添加到 entryComponents 中。

Angular 会自动把恰当的组件添加到入口组件中。 列在 @NgModule.bootstrap 中的组件会自动加入。 由路由配置引用到的组件会被自动加入。 用这两种机制添加的组件在入口组件中占了绝大多数。

如果你的应用要用其它手段来根据类型引导或动态加载组件,那就得把它显式添加到 entryComponents 中。

虽然把组件加到这个列表中也没什么坏处,不过最好还是只添加真正的入口组件。 不要添加那些被其它组件的模板引用过的组件。

为什么 Angular 需要入口组件?

原因在于摇树优化。对于产品化应用,你会希望加载尽可能小而快的代码。 代码中应该仅仅包括那些实际用到的类。 它应该排除那些从未用过的组件,无论该组件是否被声明过。

事实上,大多数库中声明和导出的组件你都用不到。 如果你从未引用它们,那么摇树优化器就会从最终的代码包中把这些组件砍掉。

如果Angular 编译器为每个声明的组件都生成了代码,那么摇树优化器的作用就没有了。

所以,编译器转而采用一种递归策略,它只为你用到的那些组件生成代码。

编译器从入口组件开始工作,为它在入口组件的模板中找到的那些组件生成代码,然后又为在这些组件中的模板中发现的组件生成代码,以此类推。 当这个过程结束时,它就已经为每个入口组件以及从入口组件可以抵达的每个组件生成了代码。

如果该组件不是入口组件或者没有在任何模板中发现过,编译器就会忽略它。

有哪些类型的模块?我应该如何使用它们?

每个应用都不一样。根据不同程度的经验,开发者会做出不同的选择。下列建议和指导原则广受欢迎。

SharedModule

为那些可能会在应用中到处使用的组件、指令和管道创建 SharedModule。 这种模块应该只包含 declarations,并且应该导出几乎所有 declarations 里面的声明。

SharedModule 可以重新导出其它小部件模块,比如 CommonModuleFormsModule 和提供你广泛使用的 UI 控件的那些模块。

SharedModule不应该带有 providers,原因在前面解释过了。 它的导入或重新导出的模块中也不应该有 providers。 如果你要违背这条指导原则,请务必想清楚你在做什么,并要有充分的理由。

在任何特性模块中(无论是你在应用启动时主动加载的模块还是之后惰性加载的模块),你都可以随意导入这个 SharedModule

特性模块

特性模块是你围绕特定的应用业务领域创建的模块,比如用户工作流、小工具集等。它们包含指定的特性,并为你的应用提供支持,比如路由、服务、窗口部件等。 要对你的应用中可能会有哪些特性模块有个概念,考虑如果你要把与特定功能(比如搜索)有关的文件放进一个目录下,该目录的内容就可能是一个名叫 SearchModule 的特性模块。 它将会包含构成搜索功能的全部组件、路由和模板。

在 NgModule 和 JavaScript 模块之间有什么不同?

在 Angular 应用中,NgModule 会和 JavaScript 的模块一起工作。

在现代 JavaScript 中,每个文件都是模块(参见模块)。 在每个文件中,你要写一个 export 语句将模块的一部分公开。

Angular 模块是一个带有 @NgModule 装饰器的类,而 JavaScript 模块则没有。 Angular 的 NgModule 有自己的 importsexports 来达到类似的目的。

你可以导入其它 NgModules,以便在当前模块的组件模板中使用它们导出的类。 你可以导出当前 NgModules 中的类,以便其它 NgModules 可以导入它们,并用在自己的组件模板中。

Angular 如何查找模板中的组件、指令和管道?什么是 模板引用 ?

Angular 编译器在组件模板内查找其它组件、指令和管道。一旦找到了,那就是一个“模板引用”。

Angular 编译器通过在一个模板的 HTML 中匹配组件或指令的选择器(selector),来查找组件或指令。

编译器通过分析模板 HTML 中的管道语法中是否出现了特定的管道名来查找对应的管道。

Angular 只查询两种组件、指令或管道:1)那些在当前模块中声明过的,以及 2)那些被当前模块导入的模块所导出的。

什么是 Angular 编译器?

Angular 编译器会把你所编写的应用代码转换成高性能的 JavaScript 代码。 在编译过程中,@NgModule 的元数据扮演了很重要的角色。

你写的代码是无法直接执行的。 比如组件。 组件有一个模板,其中包含了自定义元素、属性型指令、Angular 绑定声明和一些显然不属于原生 HTML 的古怪语法。

Angular 编译器读取模板的 HTML,把它和相应的组件类代码组合在一起,并产出组件工厂。

组件工厂为组件创建纯粹的、100% JavaScript 的表示形式,它包含了 @Component 元数据中描述的一切:HTML、绑定指令、附属的样式等……

由于指令和管道都出现在组件模板中,*Angular 编译器**也同样会把它们组合进编译后的组件代码中。

@NgModule 元数据告诉 Angular 编译器要为当前模块编译哪些组件,以及如何把当前模块和其它模块链接起来。

依赖注入(DI)是一种重要的应用设计模式。 Angular 有自己的 DI 框架,在设计应用时常会用到它,以提升它们的开发效率和模块化程度。

依赖,是当类需要执行其功能时,所需要的服务或对象。 DI 是一种编码模式,其中的类会从外部源中请求获取依赖,而不是自己创建它们。

在 Angular 中,DI 框架会在实例化该类时向其提供这个类所声明的依赖项。本指南介绍了 DI 在 Angular 中的工作原理,以及如何借助它来让你的应用更灵活、高效、健壮,以及可测试、可维护。

我们先看一下英雄指南中英雄管理特性的简化版。这个简化版不使用 DI,我们将逐步把它转换成使用 DI 的。

  1. Path:"src/app/heroes/heroes.component.ts" 。

    import { Component } from '@angular/core';    @Component({      selector: 'app-heroes',      template: `        <h2>Heroes</h2>        <app-hero-list></app-hero-list>      `    })    export class HeroesComponent { }

  1. Path:"src/app/heroes/heroes.component.ts" 。

    import { Component }   from '@angular/core';    import { HEROES }      from './mock-heroes';    @Component({      selector: 'app-hero-list',      template: `        <div *ngFor="let hero of heroes">          {{hero.id}} - {{hero.name}}        </div>      `    })    export class HeroListComponent {      heroes = HEROES;    }

  1. Path:"src/app/heroes/hero.ts" 。

    export interface Hero {      id: number;      name: string;      isSecret: boolean;    }

  1. Path:"src/app/heroes/mock-heroes.ts" 。

    import { Hero } from './hero';    export const HEROES: Hero[] = [      { id: 11, isSecret: false, name: 'Dr Nice' },      { id: 12, isSecret: false, name: 'Narco' },      { id: 13, isSecret: false, name: 'Bombasto' },      { id: 14, isSecret: false, name: 'Celeritas' },      { id: 15, isSecret: false, name: 'Magneta' },      { id: 16, isSecret: false, name: 'RubberMan' },      { id: 17, isSecret: false, name: 'Dynama' },      { id: 18, isSecret: true,  name: 'Dr IQ' },      { id: 19, isSecret: true,  name: 'Magma' },      { id: 20, isSecret: true,  name: 'Tornado' }    ];

HeroesComponent 是顶层英雄管理组件。 它唯一的目的是显示 HeroListComponent,该组件会显示一个英雄名字的列表。

HeroListComponent 的这个版本从 HEROES 数组(它在一个独立的 "mock-heroes" 文件中定义了一个内存集合)中获取英雄。

Path:"src/app/heroes/hero-list.component.ts (class)" 。

export class HeroListComponent {  heroes = HEROES;}

这种方法在原型阶段有用,但是不够健壮、不利于维护。 一旦你想要测试该组件或想从远程服务器获得英雄列表,就不得不修改 HeroesListComponent 的实现,并且替换每一处使用了 HEROES 模拟数据的地方。

创建和注册可注入的服务

DI 框架让你能从一个可注入的服务类(独立文件)中为组件提供数据。为了演示,我们还会创建一个用来提供英雄列表的、可注入的服务类,并把它注册为该服务的提供者。

同一个文件中放多个类容易让人困惑。我们通常建议你在单独的文件中定义组件和服务。

如果你把组件和服务都放在同一个文件中,请务必先定义服务,然后再定义组件。如果在服务之前定义组件,则会在运行时收到一个空引用错误。

也可以借助 forwardRef() 方法来先定义组件,就像这个博客中解释的那样。

创建可注入的服务类

Angular CLI 可以用下列命令在 "src/app/heroes" 目录下生成一个新的 HeroService 类。

ng generate service heroes/hero

下列命令会创建 HeroService 的骨架。

Path:"src/app/heroes/hero.service.ts (CLI-generated)" 。

import { Injectable } from '@angular/core';@Injectable({  providedIn: 'root',})export class HeroService {  constructor() { }}

@Injectable() 是每个 Angular 服务定义中的基本要素。该类的其余部分导出了一个 getHeroes 方法,它会返回像以前一样的模拟数据。(真实的应用可能会从远程服务器中异步获取这些数据,不过这里我们先忽略它,专心实现服务的注入机制。)

Path:"src/app/heroes/hero.service.ts" 。

import { Injectable } from '@angular/core';import { HEROES } from './mock-heroes';@Injectable({  // we declare that this service should be created  // by the root application injector.  providedIn: 'root',})export class HeroService {  getHeroes() { return HEROES; }}

用服务提供者配置注入器

我们创建的类提供了一个服务。@Injectable() 装饰器把它标记为可供注入的服务,不过在你使用该服务的 provider 提供者配置好 Angular 的依赖注入器之前,Angular 实际上无法将其注入到任何位置。

该注入器负责创建服务实例,并把它们注入到像 HeroListComponent 这样的类中。 你很少需要自己创建 Angular 的注入器。Angular 会在执行应用时为你创建注入器,第一个注入器是根注入器,创建于启动过程中。

提供者会告诉注入器如何创建该服务。 要想让注入器能够创建服务(或提供其它类型的依赖),你必须使用某个提供者配置好注入器。

提供者可以是服务类本身,因此注入器可以使用 new 来创建实例。 你还可以定义多个类,以不同的方式提供同一个服务,并使用不同的提供者来配置不同的注入器。

注入器是可继承的,这意味着如果指定的注入器无法解析某个依赖,它就会请求父注入器来解析它。 组件可以从它自己的注入器来获取服务、从其祖先组件的注入器中获取、从其父 NgModule 的注入器中获取,或从 root 注入器中获取。

你可以在三种位置之一设置元数据,以便在应用的不同层级使用提供者来配置注入器:

  • 在服务本身的 @Injectable() 装饰器中。

  • NgModule@NgModule() 装饰器中。

  • 在组件的 @Component() 装饰器中。

@Injectable() 装饰器具有一个名叫 providedIn 的元数据选项,在那里你可以指定把被装饰类的提供者放到 root 注入器中,或某个特定 NgModule 的注入器中。

@NgModule()@Component() 装饰器都有用一个 providers 元数据选项,在那里你可以配置 NgModule 级或组件级的注入器。

所有组件都是指令,而 providers 选项是从 @Directive() 中继承来的。 你也可以与组件一样的级别为指令、管道配置提供者。

注入服务

HeroListComponent 要想从 HeroService 中获取英雄列表,就得要求注入 HeroService,而不是自己使用 new 来创建自己的 HeroService 实例。

你可以通过制定带有依赖类型的构造函数参数来要求 Angular 在组件的构造函数中注入依赖项。下面的代码是 HeroListComponent 的构造函数,它要求注入 HeroService

Path:"src/app/heroes/hero-list.component (constructor signature)" 。

constructor(heroService: HeroService)

当然,HeroListComponent 还应该使用注入的这个 HeroService 做一些事情。 这里是修改过的组件,它转而使用注入的服务。与前一版本并列显示,以便比较。

//hero-list.component (with DI)import { Component }   from '@angular/core';import { Hero }        from './hero';import { HeroService } from './hero.service';@Component({  selector: 'app-hero-list',  template: `    <div *ngFor="let hero of heroes">      {{hero.id}} - {{hero.name}}    </div>  `})export class HeroListComponent {  heroes: Hero[];  constructor(heroService: HeroService) {    this.heroes = heroService.getHeroes();  }}

//hero-list.component (without DI)import { Component }   from '@angular/core';import { HEROES }      from './mock-heroes';@Component({  selector: 'app-hero-list',  template: `    <div *ngFor="let hero of heroes">      {{hero.id}} - {{hero.name}}    </div>  `})export class HeroListComponent {  heroes = HEROES;}

必须在某些父注入器中提供 HeroServiceHeroListComponent 并不关心 HeroService 来自哪里。 如果你决定在 AppModule 中提供 HeroService,也不必修改 HeroListComponent

注入器树与服务实例

在某个注入器的范围内,服务是单例的。也就是说,在指定的注入器中最多只有某个服务的最多一个实例。

应用只有一个根注入器。在 rootAppModule 级提供 UserService 意味着它注册到了根注入器上。 在整个应用中只有一个 UserService 实例,每个要求注入 UserService 的类都会得到这一个服务实例,除非你在子注入器中配置了另一个提供者。

Angular DI 具有分层注入体系,这意味着下级注入器也可以创建它们自己的服务实例。 Angular 会有规律的创建下级注入器。每当 Angular 创建一个在 @Component() 中指定了 providers 的组件实例时,它也会为该实例创建一个新的子注入器。 类似的,当在运行期间加载一个新的 NgModule 时,Angular 也可以为它创建一个拥有自己的提供者的注入器。

子模块和组件注入器彼此独立,并且会为所提供的服务分别创建自己的实例。当 Angular 销毁 NgModule 或组件实例时,也会销毁这些注入器以及注入器中的那些服务实例。

借助注入器继承机制,你仍然可以把全应用级的服务注入到这些组件中。 组件的注入器是其父组件注入器的子节点,它会继承所有的祖先注入器,其终点则是应用的根注入器。 Angular 可以注入该继承谱系中任何一个注入器提供的服务。

比如,Angular 既可以把 HeroComponent 中提供的 HeroService 注入到 HeroListComponent,也可以注入 AppModule 中提供的 UserService

测试带有依赖的组件

基于依赖注入设计一个类,能让它更易于测试。 要想高效的测试应用的各个部分,你所要做的一切就是把这些依赖列到构造函数的参数表中而已。

比如,你可以使用一个可在测试期间操纵的模拟服务来创建新的 HeroListComponent

Path:"src/app/test.component.ts" 。

const expectedHeroes = [{name: 'A'}, {name: 'B'}]const mockService = <HeroService> {getHeroes: () => expectedHeroes }it('should have heroes when HeroListComponent created', () => {  // Pass the mock to the constructor as the Angular injector would  const component = new HeroListComponent(mockService);  expect(component.heroes.length).toEqual(expectedHeroes.length);});

那些需要其它服务的服务

服务还可以具有自己的依赖。HeroService 非常简单,没有自己的依赖。不过,如果你希望通过日志服务来报告这些活动,那么就可以使用同样的构造函数注入模式,添加一个构造函数来接收一个 Logger 参数。

这是修改后的 HeroService,它注入了 Logger,我们把它和前一个版本的服务放在一起进行对比。

  1. Path:"src/app/heroes/hero.service (v2)" 。

    import { Injectable } from '@angular/core';    import { HEROES }     from './mock-heroes';    import { Logger }     from '../logger.service';    @Injectable({      providedIn: 'root',    })    export class HeroService {      constructor(private logger: Logger) {  }      getHeroes() {        this.logger.log('Getting heroes ...');        return HEROES;      }    }

  1. Path:"src/app/heroes/hero.service (v1)" 。

    import { Injectable } from '@angular/core';    import { HEROES }     from './mock-heroes';    @Injectable({      providedIn: 'root',    })    export class HeroService {      getHeroes() { return HEROES; }    }

  1. Path:"src/app/logger.service" 。

    import { Injectable } from '@angular/core';    @Injectable({      providedIn: 'root'    })    export class Logger {      logs: string[] = []; // capture logs for testing      log(message: string) {        this.logs.push(message);        console.log(message);      }    }

该构造函数请求注入一个 Logger 的实例,并把它保存在一个名叫 logger 的私有字段中。 当要求获取英雄列表时,getHeroes() 方法就会记录一条消息。

注意,虽然 Logger 服务没有自己的依赖项,但是它同样带有 @Injectable() 装饰器。实际上,@Injectable() 对所有服务都是必须的。

当 Angular 创建一个构造函数中有参数的类时,它会查找有关这些参数的类型,和供注入使用的元数据,以便找到正确的服务。 如果 Angular 无法找到参数信息,它就会抛出一个错误。 只有当类具有某种装饰器时,Angular 才能找到参数信息。 @Injectable() 装饰器是所有服务类的标准装饰器。

装饰器是 TypeScript 强制要求的。当 TypeScript 把代码转译成 JavaScript 时,一般会丢弃参数的类型信息。只有当类具有装饰器,并且 "tsconfig.json" 中的编译器选项 emitDecoratorMetadatatrue 时,TypeScript 才会保留这些信息。CLI 所配置的 "tsconfig.json" 就带有 emitDecoratorMetadata: true

这意味着你有责任给所有服务类加上 @Injectable()

依赖注入令牌

当使用提供者配置注入器时,就会把提供者和一个 DI 令牌关联起来。 注入器维护一个内部令牌-提供者的映射表,当请求一个依赖项时就会引用它。令牌就是这个映射表的键。

在简单的例子中,依赖项的值是一个实例,而类的类型则充当键来查阅它。 通过把 HeroService 类型作为令牌,你可以直接从注入器中获得一个 HeroService 实例。

Path:"src/app/injector.component.ts" 。

heroService: HeroService;

当你编写的构造函数中需要注入基于类的依赖项时,其行为也类似。 当你使用 HeroService 类的类型来定义构造函数参数时,Angular 就会知道要注入与 HeroService 类这个令牌相关的服务。

Path:"src/app/heroes/hero-list.component.ts" 。

constructor(heroService: HeroService)

很多依赖项的值都是通过类来提供的,但不是全部。扩展的 provide 对象让你可以把多种不同种类的提供者和 DI 令牌关联起来。

可选依赖

HeroService 需要一个记录器,但是如果找不到它会怎么样?

当组件或服务声明某个依赖项时,该类的构造函数会以参数的形式接收那个依赖项。 通过给这个参数加上 @Optional() 注解,你可以告诉 Angular,该依赖是可选的。

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

constructor(@Optional() private logger?: Logger) {  if (this.logger) {    this.logger.log(some_message);  }}

当使用 @Optional() 时,你的代码必须能正确处理 null 值。如果你没有在任何地方注册过 logger 提供者,那么注入器就会把 logger 的值设置为 null

@Inject()@Optional() 都是参数装饰器。它们通过在需要依赖项的类的构造函数上对参数进行注解,来改变 DI 框架提供依赖项的方式。

小结

本节中你学到了 Angular 依赖注入的基础知识。 你可以注册多种提供者,并且知道了如何通过为构造函数添加参数来请求所注入的对象(比如服务)。

Angular 中的注入器有一些规则,你可以利用这些规则来在应用程序中获得所需的可注入对象可见性。通过了解这些规则,可以确定应在哪个 NgModule、组件或指令中声明服务提供者。

两个注入器层次结构

Angular 中有两个注入器层次结构:

  • ModuleInjector 层次结构 —— 使用 @NgModule()@Injectable() 注解在此层次结构中配置 ModuleInjector

  • ElementInjector 层次结构 —— 在每个 DOM 元素上隐式创建。除非你在 @Directive()@Component()providers 属性中进行配置,否则默认情况下,ElementInjector 为空。

ModuleInjector

可以通过以下两种方式之一配置 ModuleInjector

  • 使用 @Injectable()providedIn 属性引用 @NgModule()root

  • 使用 @NgModule()providers 数组。

摇树优化与 @Injectable()

使用 @Injectable()providedIn 属性优于 @NgModule()providers 数组,因为使用 @Injectable()providedIn 时,优化工具可以进行摇树优化,从而删除你的应用程序中未使用的服务,以减小捆绑包尺寸。

摇树优化对于库特别有用,因为使用该库的应用程序不需要注入它。在 DI 提供者中了解有关可摇树优化的提供者的更多信息。

ModuleInjector@NgModule.providersNgModule.imports 属性配置。ModuleInjector 是可以通过 NgModule.imports 递归找到的所有 providers 数组的扁平化。

ModuleInjector 是在延迟加载其它 @NgModules 时创建的。

使用 @Injectable()providedIn 属性提供服务的方式如下:

import { Injectable } from '@angular/core';@Injectable({  providedIn: 'root'  // <--provides this service in the root ModuleInjector})export class ItemService {  name = 'telephone';}

@Injectable() 装饰器标识服务类。该 providedIn 属性配置指定的 ModuleInjector,这里的 root 会把让该服务在 root ModuleInjector 上可用。

平台注入器

root 之上还有两个注入器,一个是额外的 ModuleInjector,一个是 NullInjector()

思考下 Angular 要如何通过 "main.ts" 中的如下代码引导应用程序:

platformBrowserDynamic().bootstrapModule(AppModule).then(ref => {...})

bootstrapModule() 方法会创建一个由 AppModule 配置的注入器作为平台注入器的子注入器。也就是 root ModuleInjector

platformBrowserDynamic() 方法创建一个由 PlatformModule 配置的注入器,该注入器包含特定平台的依赖项。这允许多个应用共享同一套平台配置。例如,无论你运行多少个应用程序,浏览器都只有一个 URL 栏。你可以使用 platformBrowser() 函数提供 extraProviders,从而在平台级别配置特定平台的额外提供者。

层次结构中的下一个父注入器是 NullInjector(),它是树的顶部。如果你在树中向上走了很远,以至于要在 NullInjector() 中寻找服务,那么除非使用 @Optional(),否则将收到错误消息,因为最终所有东西都将以 NullInjector() 结束并返回错误,或者对于 @Optional(),返回 null

下图展示了前面各段落描述的 root ModuleInjector 及其父注入器之间的关系。

虽然 root 是一个特殊的别名,但其它 ModuleInjector 都没有别名。每当创建动态加载组件时,你还会创建 ModuleInjector,比如路由器,它还会创建子 ModuleInjector

无论是使用 bootstrapModule() 的方法配置它,还是将所有提供者都用 root 注册到其自己的服务中,所有请求最终都会转发到 root 注入器。

@Injectable() vs. @NgModule()

如果你在 AppModule@NgModule() 中配置应用级提供者,它就会覆盖一个在 @Injectable()root 元数据中配置的提供者。你可以用这种方式,来配置供多个应用共享的服务的非默认提供者。

下面的例子中,通过把 location 策略 的提供者添加到 AppModuleproviders 列表中,为路由器配置了非默认的 location 策略。

Path:"src/app/app.module.ts (providers)" 。

providers: [  { provide: LocationStrategy, useClass: HashLocationStrategy }]

ElementInjector

Angular 会为每个 DOM 元素隐式创建 ElementInjector

可以用 @Component() 装饰器中的 providersviewProviders 属性来配置 ElementInjector 以提供服务。例如,下面的 TestComponent 通过提供此服务来配置 ElementInjector

@Component({  ...  providers: [{ provide: ItemService, useValue: { name: 'lamp' } }]})export class TestComponent

在组件中提供服务时,可以通过 ElementInjector 在该组件实例处使用该服务。根据解析规则部分描述的可见性规则,它也同样在子组件/指令处可见。

当组件实例被销毁时,该服务实例也将被销毁。

@Directive() 和 @Component()

组件是一种特殊类型的指令,这意味着 @Directive() 具有 providers 属性,@Component() 也同样如此。 这意味着指令和组件都可以使用 providers 属性来配置提供者。当使用 providers 属性为组件或指令配置提供者时,该提供程商就属于该组件或指令的 ElementInjector。同一元素上的组件和指令共享同一个注入器。

解析规则

当为组件/指令解析令牌时,Angular 分为两个阶段来解析它:

针对 ElementInjector 层次结构(其父级)

针对 ModuleInjector 层次结构(其父级)

当组件声明依赖项时,Angular 会尝试使用它自己的 ElementInjector 来满足该依赖。 如果组件的注入器缺少提供者,它将把请求传给其父组件的 ElementInjector

这些请求将继续转发,直到 Angular 找到可以处理该请求的注入器或用完祖先 ElementInjector

如果 Angular 在任何 ElementInjector 中都找不到提供者,它将返回到发起请求的元素,并在 ModuleInjector 层次结构中进行查找。如果 Angular 仍然找不到提供者,它将引发错误。

如果你已在不同级别注册了相同 DI 令牌的提供者,则 Angular 会用遇到的第一个来解析该依赖。例如,如果提供者已经在需要此服务的组件中本地注册了,则 Angular 不会再寻找同一服务的其它提供者。

解析修饰符

可以使用 @Optional()@Self()@SkipSelf()@Host() 来修饰 Angular 的解析行为。从 @angular/core 导入它们,并在注入服务时在组件类构造函数中使用它们。

修饰符的类型

解析修饰符分为三类:

如果 Angular 找不到你要的东西该怎么办,用 @Optional()

从哪里开始寻找,用 @SkipSelf()

到哪里停止寻找,用 @Host()@Self()

默认情况下,Angular 始终从当前的 Injector 开始,并一直向上搜索。修饰符使你可以更改开始(默认是自己)或结束位置。

另外,你可以组合除 @Host()@Self() 之外的所有修饰符,当然还有 @SkipSelf()@Self()

@Optional()

@Optional() 允许 Angular 将你注入的服务视为可选服务。这样,如果无法在运行时解析它,Angular 只会将服务解析为 null,而不会抛出错误。在下面的示例中,服务 OptionalService 没有在 @NgModule() 或组件类中提供,所以它没有在应用中的任何地方。

Path:"resolution-modifiers/src/app/optional/optional.component.ts" 。

export class OptionalComponent {  constructor(@Optional() public optional?: OptionalService) {}}

@Self()

使用 @Self() 让 Angular 仅查看当前组件或指令的 ElementInjector

@Self() 的一个好例子是要注入某个服务,但只有当该服务在当前宿主元素上可用时才行。为了避免这种情况下出错,请将 @Self()@Optional() 结合使用。

例如,在下面的 SelfComponent 中。请注意在构造函数中注入的 LeafService

Path:"resolution-modifiers/src/app/self-no-data/self-no-data.component.ts" 。

@Component({  selector: 'app-self-no-data',  templateUrl: './self-no-data.component.html',  styleUrls: ['./self-no-data.component.css']})export class SelfNoDataComponent {  constructor(@Self() @Optional() public leaf?: LeafService) { }}

在这个例子中,有一个父提供者,注入服务将返回该值,但是,使用 @Self()@Optional() 注入的服务将返回 null 因为 @Self() 告诉注入器在当前宿主元素上就要停止搜索。

另一个示例显示了具有 FlowerService 提供者的组件类。在这个例子中,注入器没有超出当前 ElementInjector 就停止了,因为它已经找到了 FlowerService 并返回了黄色花朵????。

Path:"resolution-modifiers/src/app/self/self.component.ts" 。

@Component({  selector: 'app-self',  templateUrl: './self.component.html',  styleUrls: ['./self.component.css'],  providers: [{ provide: FlowerService, useValue: { emoji: '????' } }]})export class SelfComponent {  constructor(@Self() public flower: FlowerService) {}}

@SkipSelf()

@SkipSelf()@Self() 相反。使用 @SkipSelf(),Angular 在父 ElementInjector 中而不是当前 ElementInjector 中开始搜索服务。因此,如果父 ElementInjectoremoji 使用了值 ????(蕨类),但组件的 providers 数组中有 ????(枫叶),则 Angular 将忽略 ????(枫叶),而使用 ????(蕨类)。

要在代码中看到这一点,请先假定 emoji 的以下值就是父组件正在使用的值,如本服务所示:

Path:"resolution-modifiers/src/app/leaf.service.ts" 。

export class LeafService {  emoji = '????';}

想象一下,在子组件中,你有一个不同的值 ????(枫叶),但你想使用父项的值。你就要使用 @SkipSelf()

Path:"resolution-modifiers/src/app/skipself/skipself.component.ts" 。

@Component({  selector: 'app-skipself',  templateUrl: './skipself.component.html',  styleUrls: ['./skipself.component.css'],  // Angular would ignore this LeafService instance  providers: [{ provide: LeafService, useValue: { emoji: '????' } }]})export class SkipselfComponent {  // Use @SkipSelf() in the constructor  constructor(@SkipSelf() public leaf: LeafService) { }}

在这个例子中,你获得的 emoji 值将为 ????(蕨类),而不是 ????(枫叶)。

@SkipSelf() with @Optional()

如果值为 null 请同时使用 @SkipSelf()@Optional() 来防止错误。在下面的示例中,将 Person 服务注入到构造函数中。@SkipSelf() 告诉 Angular 跳过当前的注入器,如果 Person 服务为 null,则 @Optional() 将防止报错。

class Person {  constructor(@Optional() @SkipSelf() parent?: Person) {}}

@Host()

@Host() 使你可以在搜索提供者时将当前组件指定为注入器树的最后一站。即使树的更上级有一个服务实例,Angular 也不会继续寻找。使用 @Host() 的例子如下:

Path:"resolution-modifiers/src/app/host/host.component.ts" 。

@Component({  selector: 'app-host',  templateUrl: './host.component.html',  styleUrls: ['./host.component.css'],  //  provide the service  providers: [{ provide: FlowerService, useValue: { emoji: '????' } }]})export class HostComponent {  // use @Host() in the constructor when injecting the service  constructor(@Host() @Optional() public flower?: FlowerService) { }}

由于 HostComponent 在其构造函数中具有 @Host(),因此,无论 HostComponent 的父级是否可能有 flower.emoji 值,该 HostComponent 都将使用 ????(黄色花朵)。

模板的逻辑结构

在组件类中提供服务时,服务在 ElementInjector 树中的可见性是取决于你在何处以及如何提供这些服务。

了解 Angular 模板的基础逻辑结构将为你配置服务并进而控制其可见性奠定基础。

组件在模板中使用,如以下示例所示:

<app-root>    <app-child></app-child></app-root>

注:

通常,你要在单独的文件中声明组件及其模板。为了理解注入系统的工作原理,从组合逻辑树的视角来看它们是很有帮助的。使用术语“逻辑”将其与渲染树(你的应用程序 DOM 树)区分开。为了标记组件模板的位置,本指南使用 <#VIEW& 伪元素,该元素实际上不存在于渲染树中,仅用于心智模型中。

下面是如何将 <app-root><app-child> 视图树组合为单个逻辑树的示例:

<app-root>  <#VIEW>    <app-child>     <#VIEW>       ...content goes here...     </#VIEW>    </app-child>  <#VIEW></app-root>

当你在组件类中配置服务时,了解这种 <#VIEW> 划界的思想尤其重要。

在 @Component() 中提供服务

你如何通过 @Component() (或 @Directive() )装饰器提供服务决定了它们的可见性。以下各节演示了 providersviewProviders 以及使用 @SkipSelf()@Host() 修改服务可见性的方法。

组件类可以通过两种方式提供服务:

  1. 使用 providers 数组

    @Component({      ...      providers: [        {provide: FlowerService, useValue: {emoji: '????'}}      ]    })

  1. 使用 viewProviders 数组

    @Component({      ...      viewProviders: [        {provide: AnimalService, useValue: {emoji: '????'}}      ]    })

为了解 providersviewProviders 对服务可见性的影响有何差异,以下各节将逐步构建一个示例并在代码和逻辑树中比较 providersviewProviders 的作用。

注:

在逻辑树中,你会看到 @Provide@Inject@NgModule,这些不是真正的 HTML 属性,只是为了在这里证明其幕后的原理。

  • @Inject(Token)=&Value 表示,如果要将 Token 注入逻辑树中的此位置,则它的值为 Value

  • @Provide(Token=Value) 表示,在逻辑树中的此位置存在一个值为 ValueToken 提供者的声明。

  • @NgModule(Token) 表示,应在此位置使用后备的 NgModule 注入器。

应用程序结构示例

示例应用程序的 root 提供了 FlowerService,其 emoji 值为 ????(红色芙蓉)。

Path:"providers-viewproviders/src/app/flower.service.ts" 。

@Injectable({  providedIn: 'root'})export class FlowerService {  emoji = '????';}

考虑一个只有 AppComponentChildComponent 的简单应用程序。最基本的渲染视图看起来就像嵌套的 HTML 元素,例如:

<app-root> <!-- AppComponent selector -->    <app-child> <!-- ChildComponent selector -->    </app-child></app-root>

但是,在幕后,Angular 在解析注入请求时使用如下逻辑视图表示形式:

<app-root> <!-- AppComponent selector -->    <#VIEW>        <app-child> <!-- ChildComponent selector -->            <#VIEW>            </#VIEW>        </app-child>    </#VIEW></app-root>

此处的 <#VIEW> 表示模板的实例。请注意,每个组件都有自己的 <#VIEW>

了解此结构可以告知你如何提供和注入服务,并完全控制服务的可见性。

现在,考虑 <app-root> 只注入了 FlowerService

Path:"providers-viewproviders/src/app/app.component.ts" 。

export class AppComponent  {  constructor(public flower: FlowerService) {}}

将绑定添加到 <app-root> 模板来将结果可视化:

Path:"providers-viewproviders/src/app/app.component.html" 。

<p>Emoji from FlowerService: {{flower.emoji}}</p>

该视图中的输出为:

Emoji from FlowerService: ????

在逻辑树中,这可以表示成如下形式:

<app-root @NgModule(AppModule)        @Inject(FlowerService) flower=>"????">  <#VIEW>    <p>Emoji from FlowerService: {{flower.emoji}} (????)</p>    <app-child>      <#VIEW>      </#VIEW>     </app-child>  </#VIEW></app-root>

<app-root> 请求 FlowerService 时,注入器的工作就是解析 FlowerService 令牌。令牌的解析分为两个阶段:

  1. 注入器确定逻辑树中搜索的开始位置和结束位置。注入程序从起始位置开始,并在逻辑树的每个级别上查找令牌。如果找到令牌,则将其返回。

  1. 如果未找到令牌,则注入程序将寻找最接近的父 @NgModule() 委派该请求。

在这个例子中,约束为:

  1. 从属于 <app-root><#VIEW> 开始,并结束于 <app-root>

  • 通常,搜索的起点就是注入点。但是,在这个例子中,<app-root> @Component 的特殊之处在于它们还包括自己的 viewProviders,这就是为什么搜索从 <app-root><#VIEW> 开始的原因。(对于匹配同一位置的指令,情况却并非如此)。

  • 结束位置恰好与组件本身相同,因为它就是此应用程序中最顶层的组件。

  1. 当在 ElementInjector 中找不到注入令牌时,就用 AppModule 充当后备注入器。

使用 providers 数组

现在,在 ChildComponent 类中,为 FlowerService 添加一个提供者,以便在接下来的小节中演示更复杂的解析规则:

Path:"providers-viewproviders/src/app/child.component.ts" 。

@Component({  selector: 'app-child',  templateUrl: './child.component.html',  styleUrls: ['./child.component.css'],  // use the providers array to provide a service  providers: [{ provide: FlowerService, useValue: { emoji: '????' } }]})export class ChildComponent {  // inject the service  constructor( public flower: FlowerService) { }}

现在,在 @Component() 装饰器中提供了 FlowerService,当 <app-child> 请求该服务时,注入器仅需要查找 <app-child> 自己的 ElementInjector。不必再通过注入器树继续搜索。

下一步是将绑定添加到 ChildComponent 模板。

Path:"providers-viewproviders/src/app/child.component.html" 。

<p>Emoji from FlowerService: {{flower.emoji}}</p>

要渲染新的值,请在 AppComponent 模板的底部添加 <app-child>,以便其视图也显示向日葵:

Child ComponentEmoji from FlowerService: ????

在逻辑树中,可以把它表示成这样:

<app-root @NgModule(AppModule)        @Inject(FlowerService) flower=>"????">  <#VIEW>    <p>Emoji from FlowerService: {{flower.emoji}} (????)</p>    <app-child @Provide(FlowerService="????")               @Inject(FlowerService)=>"????"> <!-- search ends here -->      <#VIEW> <!-- search starts here -->        <h2>Parent Component</h2>        <p>Emoji from FlowerService: {{flower.emoji}} (????)</p>      </#VIEW>     </app-child>  </#VIEW></app-root>

<app-child> 请求 FlowerService 时,注入器从 <app-child><#VIEW> 开始搜索(包括 <#VIEW>,因为它是从 @Component() 注入的),并到 <app-child> 结束。在这个例子中,FlowerService<app-child>providers 数组中解析为向日葵????。注入器不必在注入器树中进一步查找。一旦找到 FlowerService,它便停止运行,再也看不到????(红芙蓉)。

使用 viewProviders 数组

使用 viewProviders 数组是在 @Component() 装饰器中提供服务的另一种方法。使用 viewProviders 使服务在 <#VIEW> 中可见。

除了使用 viewProviders 数组外,其它步骤与使用 providers 数组相同。

该示例应用程序具有第二个服务 AnimalService 来演示 viewProviders

首先,创建一个 AnimalServiceemoji 的????(鲸鱼)属性:

Path:"providers-viewproviders/src/app/animal.service.ts" 。

import { Injectable } from '@angular/core';@Injectable({  providedIn: 'root'})export class AnimalService {  emoji = '????';}

遵循与 FlowerService 相同的模式,将 AnimalService 注入 AppComponent 类:

Path:"providers-viewproviders/src/app/app.component.ts" 。

export class AppComponent  {  constructor(public flower: FlowerService, public animal: AnimalService) {}}

注:

你可以保留所有与 FlowerService 相关的代码,因为它可以与 AnimalService 进行比较。

添加一个 viewProviders 数组,并将 AnimalService 也注入到 <app-child> 类中,但是给 emoji 一个不同的值。在这里,它的值为????(小狗)。

Path:"providers-viewproviders/src/app/child.component.ts" 。

@Component({  selector: 'app-child',  templateUrl: './child.component.html',  styleUrls: ['./child.component.css'],  // provide services  providers: [{ provide: FlowerService, useValue: { emoji: '????' } }],  viewProviders: [{ provide: AnimalService, useValue: { emoji: '????' } }]})export class ChildComponent {  // inject service  constructor( public flower: FlowerService, public animal: AnimalService) { }}

将绑定添加到 ChildComponentAppComponent 模板。在 ChildComponent 模板中,添加以下绑定:

Path:"providers-viewproviders/src/app/child.component.html" 。

<p>Emoji from AnimalService: {{animal.emoji}}</p>

此外,将其添加到 AppComponent 模板:

Path:"providers-viewproviders/src/app/app.component.html" 。

<p>Emoji from AnimalService: {{animal.emoji}}</p>

现在,你应该在浏览器中看到两个值:

AppComponentEmoji from AnimalService: ????Child ComponentEmoji from AnimalService: ????

viewProviders 示例的逻辑树如下:

<app-root @NgModule(AppModule)        @Inject(AnimalService) animal=>"????">  <#VIEW>    <app-child>      <#VIEW       @Provide(AnimalService="????")       @Inject(AnimalService=>"????")>       <!-- ^^using viewProviders means AnimalService is available in <#VIEW>-->       <p>Emoji from AnimalService: {{animal.emoji}} (????)</p>      </#VIEW>     </app-child>  </#VIEW></app-root>

FlowerService 示例一样,<app-child> @Component() 装饰器中提供了 AnimalService。这意味着,由于注入器首先在组件的 ElementInjector 中查找,因此它将找到 AnimalService 的值 ????(小狗)。它不需要继续搜索 ElementInjector 树,也不需要搜索ModuleInjector

providers 与 viewProviders

为了看清 providersviewProviders 的差异,请在示例中添加另一个组件,并将其命名为 InspectorComponentInspectorComponent 将是 ChildComponent 的子 ChildComponent。在 "inspector.component.ts" 中,将 FlowerServiceAnimalService 注入构造函数中:

Path:"providers-viewproviders/src/app/inspector/inspector.component.ts" 。

export class InspectorComponent {  constructor(public flower: FlowerService, public animal: AnimalService) { }}

你不需要 providersviewProviders 数组。接下来,在 "inspector.component.html" 中,从以前的组件中添加相同的 html:

Path:"providers-viewproviders/src/app/inspector/inspector.component.html" 。

<p>Emoji from FlowerService: {{flower.emoji}}</p><p>Emoji from AnimalService: {{animal.emoji}}</p>

别忘了将 InspectorComponent 添加到 AppModule declarations 数组。

Path:"providers-viewproviders/src/app/app.module.ts" 。

@NgModule({  imports:      [ BrowserModule, FormsModule ],  declarations: [ AppComponent, ChildComponent, InspectorComponent ],  bootstrap:    [ AppComponent ],  providers: []})export class AppModule { }

接下来,确保你的 "child.component.html" 包含以下内容:

Path:"providers-viewproviders/src/app/child/child.component.html" 。

<p>Emoji from FlowerService: {{flower.emoji}}</p><p>Emoji from AnimalService: {{animal.emoji}}</p><div class="container">  <h3>Content projection</h3>    <ng-content></ng-content></div><h3>Inside the view</h3><app-inspector></app-inspector>

前两行带有绑定,来自之前的步骤。新的部分是 <ng-content><app-inspector><ng-content> 允许你投影内容,ChildComponent 模板中的 <app-inspector> 使 InspectorComponent 成为 ChildComponent 的子组件。

接下来,将以下内容添加到 "app.component.html" 中以利用内容投影的优势。

Path:"providers-viewproviders/src/app/app.component.html" 。

<app-child><app-inspector></app-inspector></app-child>

现在,浏览器将渲染以下内容,为简洁起见,省略了前面的示例:

//...Omitting previous examples. The following applies to this section.Content projection: This is coming from content. Doesn't get to seepuppy because the puppy is declared inside the view only.Emoji from FlowerService: ????Emoji from AnimalService: ????Emoji from FlowerService: ????Emoji from AnimalService: ????

这四个绑定说明了 providersviewProviders 之间的区别。由于????(小狗)在<#VIEW>中声明,因此投影内容不可见。投影的内容中会看到????(鲸鱼)。

但是下一部分,InspectorComponentChildComponent 的子组件,InspectorComponent<#VIEW> 内部,因此当它请求 AnimalService 时,它会看到????(小狗)。

逻辑树中的 AnimalService 如下所示:

<app-root @NgModule(AppModule)        @Inject(AnimalService) animal=>"????">  <#VIEW>    <app-child>      <#VIEW       @Provide(AnimalService="????")       @Inject(AnimalService=>"????")>       <!-- ^^using viewProviders means AnimalService is available in <#VIEW>-->       <p>Emoji from AnimalService: {{animal.emoji}} (????)</p>       <app-inspector>        <p>Emoji from AnimalService: {{animal.emoji}} (????)</p>       </app-inspector>      </#VIEW>      <app-inspector>        <#VIEW>          <p>Emoji from AnimalService: {{animal.emoji}} (????)</p>        </#VIEW>      </app-inspector>     </app-child>  </#VIEW></app-root>

<app-inspector> 的投影内容中看到了????(鲸鱼),而不是????(小狗),因为????(小狗)在 <app-child><#VIEW> 中。如果 <app-inspector> 也位于 <#VIEW> 则只能看到????(小狗)。

修改服务可见性

如何使用可见性修饰符 @Host()@Self()@SkipSelf() 来限制 ElementInjector 的开始和结束范围。

提供者令牌的可见性

可见性装饰器影响搜索注入令牌时在逻辑树中开始和结束的位置。为此,要将可见性装饰器放置在注入点,即 constructor(),而不是在声明点。

为了修改该注入器从哪里开始寻找 FlowerService,把 @SkipSelf() 加到 <app-child>@Inject 声明 FlowerService 中。该声明在 <app-child> 构造函数中,如 "child.component.ts" 所示:

constructor(@SkipSelf() public flower : FlowerService) { }

使用 @SkipSelf()<app-child> 注入器不会寻找自身来获取 FlowerService。相反,喷射器开始在 <app-root>ElementInjector 中寻找 FlowerService,在那里它什么也没找到。 然后,它返回到 <app-child>ModuleInjector 并找到????(红芙蓉)值,这是可用的,因为 <app-child> ModuleInjector<app-root> ModuleInjector 被展开成了一个 ModuleInjector。因此,UI 将渲染以下内容:

Emoji from FlowerService: ????

在逻辑树中,这种情况可能如下所示:

<app-root @NgModule(AppModule)        @Inject(FlowerService) flower=>"????">  <#VIEW>    <app-child @Provide(FlowerService="????")>      <#VIEW @Inject(FlowerService, SkipSelf)=>"????">      <!-- With SkipSelf, the injector looks to the next injector up the tree -->      </#VIEW>      </app-child>  </#VIEW></app-root>

尽管 <app-child> 提供了????(向日葵),但该应用程序渲染了????(红色芙蓉),因为 @SkipSelf() 导致当前的注入器跳过了自身并寻找其父级。

如果现在将 @Host()(以及 @SkipSelf() )添加到了 FlowerService@Inject,其结果将为 null。这是因为 @Host() 将搜索的上限限制为 <#VIEW>。这是在逻辑树中的情况:

<app-root @NgModule(AppModule)        @Inject(FlowerService) flower=>"????">  <#VIEW> <!-- end search here with null-->    <app-child @Provide(FlowerService="????")> <!-- start search here -->      <#VIEW @Inject(FlowerService, @SkipSelf, @Host, @Optional)=>null>      </#VIEW>      </app-parent>  </#VIEW></app-root>

在这里,服务及其值是相同的,但是 @Host() 阻止了注入器对 FlowerService 进行任何高于 <#VIEW> 的查找,因此找不到它并返回 null

@SkipSelf() 和 viewProviders

<app-child> 目前提供在 viewProviders 数组中提供了值为 ????(小狗)的 AnimalService。由于注入器只需要查看 <app-child>ElementInjector 中的 AnimalService,它就不会看到????(鲸鱼)。

就像在 FlowerService 示例中一样,如果将 @SkipSelf() 添加到 AnimalService 的构造函数中,则注入器将不在 AnimalService 的当前 <app-child>ElementInjector 中查找 AnimalService

export class ChildComponent {// add @SkipSelf()  constructor(@SkipSelf() public animal : AnimalService) { }}

相反,注入器将从 <app-root> ElementInjector 开始找。请记住,<app-child> 类在 viewProviders 数组中 AnimalService 中提供了????(小狗)的值:

@Component({  selector: 'app-child',  ...  viewProviders:  [{ provide: AnimalService, useValue: { emoji: '????' } }]})

<app-child> 中使用 SkipSelf() 的逻辑树是这样的:

<app-root @NgModule(AppModule)        @Inject(AnimalService=>"????")>  <#VIEW><!-- search begins here -->    <app-child>      <#VIEW       @Provide(AnimalService="????")       @Inject(AnimalService, SkipSelf=>"????")>       <!--Add @SkipSelf -->      </#VIEW>      </app-child>  </#VIEW></app-root>

<app-child> 中使用 @SkipSelf(),注入器就会在 <app-root>ElementInjector 中找到 ????(鲸)。

@Host() 和 viewProviders

如果把 @Host() 添加到 AnimalService 的构造函数上,结果就是????(小狗),因为注入器会在 <app-child><#VIEW> 中查找 AnimalService 服务。这里是 <app-child> 类中的 viewProviders 数组和构造函数中的 @Host()

@Component({  selector: 'app-child',  ...  viewProviders:  [{ provide: AnimalService, useValue: { emoji: '????' } }]})export class ChildComponent {  constructor(@Host() public animal : AnimalService) { }}

@Host() 导致注入器开始查找,直到遇到 <#VIEW> 的边缘。

<app-root @NgModule(AppModule)        @Inject(AnimalService=>"????")>  <#VIEW>    <app-child>      <#VIEW       @Provide(AnimalService="????")       @Inject(AnimalService, @Host=>"????")> <!-- @Host stops search here -->      </#VIEW>      </app-child>  </#VIEW></app-root>

将带有第三个动物????(刺猬)的 viewProviders 数组添加到 "app.component.ts" 的 @Component() 元数据中:

@Component({  selector: 'app-root',  templateUrl: './app.component.html',  styleUrls: [ './app.component.css' ],  viewProviders: [{ provide: AnimalService, useValue: { emoji: '????' } }]})

接下来,同时把 @SkipSelf()@Host() 加在 "child.component.ts" 中 AnimalService 的构造函数中。这是 <app-child> 构造函数中的 @Host()@SkipSelf()

export class ChildComponent {  constructor(  @Host() @SkipSelf() public animal : AnimalService) { }}

@Host()SkipSelf() 应用于 providers 数组中的 FlowerService,结果为 null,因为 @SkipSelf() 会在 <app-child> 的注入器中开始搜索,但是 @Host() 要求它在 <#VIEW> 停止搜索 —— 没有 FlowerService。在逻辑树中,你可以看到 FlowerService<app-child> 中可见,而在 <#VIEW> 中不可见。

不过,提供在 AppComponentviewProviders 数组中的 AnimalService,是可见的。

逻辑树表示法说明了为何如此:

<app-root @NgModule(AppModule)        @Inject(AnimalService=>"????")>  <#VIEW @Provide(AnimalService="????")         @Inject(AnimalService, @SkipSelf, @Host, @Optional)=>"????">    <!-- ^^@SkipSelf() starts here,  @Host() stops here^^ -->    <app-child>      <#VIEW @Provide(AnimalService="????")             @Inject(AnimalService, @SkipSelf, @Host, @Optional)=>"????">               <!-- Add @SkipSelf ^^-->      </#VIEW>      </app-child>  </#VIEW></app-root>

@SkipSelf() 导致注入器从 <app-root> 而不是 <app-child> 处开始对 AnimalService 进行搜索,而 @Host() 会在 <app-root><#VIEW> 处停止搜索。 由于 AnimalService 是通过 viewProviders 数组提供的,因此注入程序会在 <#VIEW> 找到????(刺猬)。

ElementInjector 用例示例

在不同级别配置一个或多个提供者的能力开辟了很有用的可能性。

场景:服务隔离

出于架构方面的考虑,可能会让你决定把一个服务限制到只能在它所属的那个应用域中访问。 比如,这个例子中包括一个用于显示反派列表的 VillainsListComponent,它会从 VillainsService 中获得反派列表数据。

如果你在根模块 AppModule 中(也就是你注册 HeroesService 的地方)提供 VillainsService,就会让应用中的任何地方都能访问到 VillainsService,包括针对英雄的工作流。如果你稍后修改了 VillainsService,就可能破坏了英雄组件中的某些地方。在根模块 AppModule 中提供该服务将会引入此风险。

该怎么做呢?你可以在 VillainsListComponentproviders 元数据中提供 VillainsService,就像这样:

Path:"src/app/villains-list.component.ts (metadata)" 。

@Component({  selector: 'app-villains-list',  templateUrl: './villains-list.component.html',  providers: [ VillainsService ]})

VillainsListComponent 的元数据中而不是其它地方提供 VillainsService 服务,该服务就会只在 VillainsListComponent 及其子组件树中可用。

VillainService 对于 VillainsListComponent 来说是单例的,因为它就是在这里声明的。只要 VillainsListComponent 没有销毁,它就始终是 VillainService 的同一个实例。但是对于 VillainsListComponent 的多个实例,每个 VillainsListComponent 的实例都会有自己的 VillainService 实例。

场景:多重编辑会话

很多应用允许用户同时进行多个任务。 比如,在纳税申报应用中,申报人可以打开多个报税单,随时可能从一个切换到另一个。

本章要示范的场景仍然是基于《英雄指南》的。 想象一个外层的 HeroListComponent,它显示一个超级英雄的列表。

要打开一个英雄的报税单,申报者点击英雄名,它就会打开一个组件来编辑那个申报单。 每个选中的申报单都会在自己的组件中打开,并且可以同时打开多个申报单。

每个报税单组件都有下列特征:

  • 属于它自己的报税单会话。

  • 可以修改一个报税单,而不会影响另一个组件中的申报单。

  • 能把所做的修改保存到它的报税单中,或者放弃它们。

假设 HeroTaxReturnComponent 还有一些管理并还原这些更改的逻辑。 这对于简单的报税单来说是很容易的。 不过,在现实世界中,报税单的数据模型非常复杂,对这些修改的管理可能不得不投机取巧。 你可以把这种管理任务委托给一个辅助服务,就像这个例子中所做的。

报税单服务 HeroTaxReturnService 缓存了单条 HeroTaxReturn,用于跟踪那个申报单的变更,并且可以保存或还原它。 它还委托给了全应用级的单例服务 HeroService,它是通过依赖注入机制取得的。

Path:"src/app/hero-tax-return.service.ts" 。

import { Injectable }    from '@angular/core';import { HeroTaxReturn } from './hero';import { HeroesService } from './heroes.service';@Injectable()export class HeroTaxReturnService {  private currentTaxReturn: HeroTaxReturn;  private originalTaxReturn: HeroTaxReturn;  constructor(private heroService: HeroesService) { }  set taxReturn (htr: HeroTaxReturn) {    this.originalTaxReturn = htr;    this.currentTaxReturn  = htr.clone();  }  get taxReturn (): HeroTaxReturn {    return this.currentTaxReturn;  }  restoreTaxReturn() {    this.taxReturn = this.originalTaxReturn;  }  saveTaxReturn() {    this.taxReturn = this.currentTaxReturn;    this.heroService.saveTaxReturn(this.currentTaxReturn).subscribe();  }}

下面是正在使用 HeroTaxReturnServiceHeroTaxReturnComponent 组件。

Path:"src/app/hero-tax-return.component.ts" 。

import { Component, EventEmitter, Input, Output } from '@angular/core';import { HeroTaxReturn }        from './hero';import { HeroTaxReturnService } from './hero-tax-return.service';@Component({  selector: 'app-hero-tax-return',  templateUrl: './hero-tax-return.component.html',  styleUrls: [ './hero-tax-return.component.css' ],  providers: [ HeroTaxReturnService ]})export class HeroTaxReturnComponent {  message = '';  @Output() close = new EventEmitter<void>();  get taxReturn(): HeroTaxReturn {    return this.heroTaxReturnService.taxReturn;  }  @Input()  set taxReturn (htr: HeroTaxReturn) {    this.heroTaxReturnService.taxReturn = htr;  }  constructor(private heroTaxReturnService: HeroTaxReturnService) { }  onCanceled()  {    this.flashMessage('Canceled');    this.heroTaxReturnService.restoreTaxReturn();  };  onClose()  { this.close.emit(); };  onSaved() {    this.flashMessage('Saved');    this.heroTaxReturnService.saveTaxReturn();  }  flashMessage(msg: string) {    this.message = msg;    setTimeout(() => this.message = '', 500);  }}

通过 @Input() 属性可以得到要编辑的报税单,这个属性被实现成了读取器(getter)和设置器(setter)。 设置器根据传进来的报税单初始化了组件自己的 HeroTaxReturnService 实例。 读取器总是返回该服务所存英雄的当前状态。 组件也会请求该服务来保存或还原这个报税单。

但如果该服务是一个全应用范围的单例就不行了。 每个组件就都会共享同一个服务实例,每个组件也都会覆盖属于其它英雄的报税单。

要防止这一点,就要在 HeroTaxReturnComponent 元数据的 providers 属性中配置组件级的注入器,来提供该服务。

Path:"src/app/hero-tax-return.component.ts (providers)" 。

providers: [ HeroTaxReturnService ]

HeroTaxReturnComponent 有它自己的 HeroTaxReturnService 提供者。 回忆一下,每个组件的实例都有它自己的注入器。 在组件级提供服务可以确保组件的每个实例都得到一个自己的、私有的服务实例,而报税单也不会再被意外覆盖了。

场景:专门的提供者

在其它层级重新提供服务的另一个理由,是在组件树的深层中把该服务替换为一个更专门化的实现。

考虑一个依赖于一系列服务的 Car 组件。 假设你在根注入器(代号 A)中配置了通用的提供者:CarService、EngineServiceTiresService

你创建了一个车辆组件(A),它显示一个从另外三个通用服务构造出的车辆。

然后,你创建一个子组件(B),它为 CarServiceEngineService 定义了自己特有的提供者,它们具有适用于组件 B 的特有能力。

组件 B 是另一个组件 C 的父组件,而组件 C 又定义了自己的,更特殊的CarService 提供者。

在幕后,每个组件都有自己的注入器,这个注入器带有为组件本身准备的 0 个、1 个或多个提供者。

当你在最深层的组件 C 解析 Car 的实例时,它使用注入器 C 解析生成了一个 Car 的实例,使用注入器 B 解析了 Engine,而 Tires 则是由根注入器 A 解析的。

依赖提供者会使用 DI 令牌来配置注入器,注入器会用它来提供这个依赖值的具体的、运行时版本。 注入器依靠 "提供者配置" 来创建依赖的实例,并把该实例注入到组件、指令、管道和其它服务中。

你必须使用提供者来配置注入器,否则注入器就无法知道如何创建此依赖。 注入器创建服务实例的最简单方法,就是用这个服务类本身来创建它。 如果你把服务类作为此服务的 DI 令牌,注入器的默认行为就是 new 出这个类实例。

在下面这个典型的例子中,Logger 类自身提供了Logger` 的实例。

providers: [Logger]

不过,你也可以用一个替代提供者来配置注入器,这样就可以指定另一些同样能提供日志功能的对象。 比如:

  • 你可以提供一个替代类。

  • 你可以提供一个类似于 Logger 的对象。

  • 你的提供者可以调用一个工厂函数来创建 logger

Provider 对象字面量

类提供者的语法实际上是一种简写形式,它会扩展成一个由 Provider 接口定义的提供者配置对象。 下面的代码片段展示了 providers 中给出的类会如何扩展成完整的提供者配置对象。

providers: [Logger]

[{ provide: Logger, useClass: Logger }]

扩展的提供者配置是一个具有两个属性的对象字面量。

  • provide 属性存有令牌,它作为一个 key,在定位依赖值和配置注入器时使用。

  • 第二个属性是一个提供者定义对象,它告诉注入器要如何创建依赖值。 提供者定义对象中的 key 可以是 useClass —— 就像这个例子中一样。 也可以是 useExistinguseValueuseFactory。 每一个 key 都用于提供一种不同类型的依赖,我们稍后会讨论。

替代类提供者

不同的类都可用于提供相同的服务。 比如,下面的代码告诉注入器,当组件使用 Logger 令牌请求日志对象时,给它返回一个 BetterLogger 实例。

[{ provide: Logger, useClass: BetterLogger }]

扩展的提供者配置是一个具有两个属性的对象字面量。

  • provide 属性存有令牌,它作为一个 key,在定位依赖值和配置注入器时使用。

  • 第二个属性是一个提供者定义对象,它告诉注入器要如何创建依赖值。 提供者定义对象中的 key 可以是 useClass —— 就像这个例子中一样。 也可以是 useExistinguseValueuseFactory。 每一个 key 都用于提供一种不同类型的依赖,我们稍后会讨论。

替代类提供者

不同的类都可用于提供相同的服务。 比如,下面的代码告诉注入器,当组件使用 Logger 令牌请求日志对象时,给它返回一个 BetterLogger 实例。

[{ provide: Logger, useClass: BetterLogger }]

带依赖的类提供者

另一个类 EvenBetterLogger 可能要在日志信息里显示用户名。 这个 logger 要从注入的 UserService 实例中来获取该用户。

@Injectable()export class EvenBetterLogger extends Logger {  constructor(private userService: UserService) { super(); }  log(message: string) {    let name = this.userService.user.name;    super.log(`Message to ${name}: ${message}`);  }}

注入器需要提供这个新的日志服务以及该服务所依赖的 UserService 对象。 使用 useClass 作为提供者定义对象的 key,来配置一个 logger 的替代品,比如 BetterLogger。 下面的数组同时在父模块和组件的 providers 元数据选项中指定了这些提供者。

[ UserService,  { provide: Logger, useClass: EvenBetterLogger }]

别名类提供者

假设老的组件依赖于 OldLogger 类。OldLoggerNewLogger 的接口相同,但是由于某种原因,我们没法修改老的组件来使用 NewLogger

当老的组件要使用 OldLogger 记录信息时,你可能希望改用 NewLogger 的单例来处理它。 在这种情况下,无论某个组件请求老的 logger 还是新的 logger,依赖注入器都应该注入这个 NewLogger 的单例。 也就是说 OldLogger 应该是 NewLogger 的别名。

如果你试图用 useClassOldLogger 指定一个别名 NewLogger,就会在应用中得到 NewLogger 的两个不同的实例。

[ NewLogger,  // Not aliased! Creates two instances of `NewLogger`  { provide: OldLogger, useClass: NewLogger}]

要确保只有一个 NewLogger 实例,就要用 useExisting 来为 OldLogger 指定别名。

[ NewLogger,  // Alias OldLogger w/ reference to NewLogger  { provide: OldLogger, useExisting: NewLogger}]

值提供者

有时候,提供一个现成的对象会比要求注入器从类去创建更简单一些。 如果要注入一个你已经创建过的对象,请使用 useValue 选项来配置该注入器。

下面的代码定义了一个变量,用来创建这样一个能扮演 logger 角色的对象。

// An object in the shape of the logger servicefunction silentLoggerFn() {}export const SilentLogger = {  logs: ['Silent logger says "Shhhhh!". Provided via "useValue"'],  log: silentLoggerFn};

下面的提供者定义对象使用 useValue 作为 key 来把该变量与 Logger 令牌关联起来。

[{ provide: Logger, useValue: SilentLogger }]

非类依赖

并非所有的依赖都是类。 有时候你会希望注入字符串、函数或对象。

应用通常会用大量的小型参数来定义配置对象,比如应用的标题或 Web API 端点的地址。 这些配置对象不一定总是类的实例。 它们还可能是对象字面量,如下例所示。

Path:"src/app/app.config.ts (excerpt)" 。

export const HERO_DI_CONFIG: AppConfig = {  apiEndpoint: 'api.heroes.com',  title: 'Dependency Injection'};

TypeScript 接口不是有效的令牌

HERO_DI_CONFIG 常量满足 AppConfig 接口的要求。 不幸的是,你不能用 TypeScript 的接口作为令牌。 在 TypeScript 中,接口是一个设计期的概念,无法用作 DI 框架在运行期所需的令牌。

// FAIL! Can't use interface as provider token[{ provide: AppConfig, useValue: HERO_DI_CONFIG })]

// FAIL! Can't inject using the interface as the parameter typeconstructor(private config: AppConfig){ }

如果你曾经在强类型语言中使用过依赖注入功能,这一点可能看起来有点奇怪,那些语言都优先使用接口作为查找依赖的 key。 不过,JavaScript 没有接口,所以,当 TypeScript 转译成 JavaScript 时,接口也就消失了。 在运行期间,没有留下任何可供 Angular 进行查找的接口类型信息。

替代方案之一是以类似于 AppModule 的方式,在 NgModule 中提供并注入这个配置对象。

Path:"src/app/app.module.ts (providers)" 。

providers: [  UserService,  { provide: APP_CONFIG, useValue: HERO_DI_CONFIG }],

另一个为非类依赖选择提供者令牌的解决方案是定义并使用 InjectionToken 对象。 下面的例子展示了如何定义那样一个令牌。

Path:"src/app/app.config.ts" 。

import { InjectionToken } from '@angular/core';export const APP_CONFIG = new InjectionToken<AppConfig>('app.config');

虽然类型参数在这里是可选的,不过还是能把此依赖的类型信息传达给开发人员和开发工具。 这个令牌的描述则是开发人员的另一个助力。

使用 InjectionToken 对象注册依赖提供者:

Path:"src/app/app.component.ts" 。

constructor(@Inject(APP_CONFIG) config: AppConfig) {  this.title = config.title;}

虽然 AppConfig 接口在依赖注入时没有任何作用,但它可以为该组件类中的这个配置对象指定类型信息。

工厂提供者

有时候你需要动态创建依赖值,创建时需要的信息你要等运行期间才能拿到。 比如,你可能需要某个在浏览器会话过程中会被反复修改的信息,而且这个可注入服务还不能独立访问这个信息的源头。

这种情况下,你可以使用工厂提供者。 当需要从第三方库创建依赖项实例时,工厂提供者也很有用,因为第三方库不是为 DI 而设计的。

比如,假设 HeroService 必须对普通用户隐藏秘密英雄,只有得到授权的用户才能看到他们。

EvenBetterLogger 一样,HeroService 需要知道该用户是否有权查看秘密英雄。 而认证信息可能会在应用的单个会话中发生变化,比如你改用另一个用户登录。

假设你不希望直接把 UserService 注入到 HeroService 中,因为你不希望把这个服务与那些高度敏感的信息牵扯到一起。 这样 HeroService 就无法直接访问到用户信息,来决定谁有权访问,谁没有。

要解决这个问题,我们给 HeroService 的构造函数一个逻辑型标志,以控制是否显示秘密英雄。

Path:"src/app/heroes/hero.service.ts (excerpt)" 。

constructor(  private logger: Logger,  private isAuthorized: boolean) { }getHeroes() {  let auth = this.isAuthorized ? 'authorized ' : 'unauthorized';  this.logger.log(`Getting heroes for ${auth} user.`);  return HEROES.filter(hero => this.isAuthorized || !hero.isSecret);}

你可以注入 Logger 但是不能注入 isAuthorized 标志。不过你可以改用工厂提供者来为 HeroService 创建一个新的 logger 实例。

工厂提供者需要一个工厂函数。

Path:"src/app/heroes/hero.service.provider.ts (excerpt)" 。

let heroServiceFactory = (logger: Logger, userService: UserService) => {  return new HeroService(logger, userService.user.isAuthorized);};

虽然 HeroService 不能访问 UserService,但是工厂函数可以。 你把 LoggerUserService 注入到了工厂提供者中,并让注入器把它们传给这个工厂函数。

Path:"src/app/heroes/hero.service.provider.ts (excerpt)" 。

export let heroServiceProvider =  { provide: HeroService,    useFactory: heroServiceFactory,    deps: [Logger, UserService]  };

  • useFactory 字段告诉 Angular 该提供者是一个工厂函数,该函数的实现代码是 heroServiceFactory

  • deps 属性是一个提供者令牌数组。 LoggerUserService 类作为它们自己的类提供者令牌使用。 注入器解析这些令牌,并把与之对应的服务注入到相应的工厂函数参数表中。

注意,你把这个工厂提供者保存到了一个导出的变量 heroServiceProvider 中。 这个额外的步骤让工厂提供者可被复用。 你可以在任何需要它的地方用这个变量来配置 HeroService 的提供者。 在这个例子中,你只在 HeroesComponent 中用到了它。你在该组件元数据的 providers 数组中用 heroServiceProvider 替换了 HeroService

下面并列显示了新旧实现。

  1. Path:"src/app/heroes/heroes.component (v3)" 。

    import { Component }          from '@angular/core';    import { heroServiceProvider } from './hero.service.provider';    @Component({      selector: 'app-heroes',      providers: [ heroServiceProvider ],      template: `        <h2>Heroes</h2>        <app-hero-list></app-hero-list>      `    })    export class HeroesComponent { }

  1. Path:"src/app/heroes/heroes.component (v2)" 。

    import { Component } from '@angular/core';    import { HeroService } from './hero.service';    @Component({      selector: 'app-heroes',      providers: [ HeroService ],      template: `        <h2>Heroes</h2>        <app-hero-list></app-hero-list>      `    })    export class HeroesComponent { }

预定义令牌与多提供者

Angular 提供了一些内置的注入令牌常量,你可以用它们来自定义系统的多种行为。

比如,你可以使用下列内置令牌来切入 Angular 框架的启动和初始化过程。 提供者对象可以把任何一个注入令牌与一个或多个用来执行应用初始化操作的回调函数关联起来。

  • PLATFORM_INITIALIZER:平台初始化之后调用的回调函数。

  • APP_BOOTSTRAP_LISTENER:每个启动组件启动完成之后调用的回调函数。这个处理器函数会收到这个启动组件的 ComponentRef 实例。

  • APP_INITIALIZER:应用初始化之前调用的回调函数。注册的所有初始化器都可以(可选地)返回一个 Promise。所有返回 Promise 的初始化函数都必须在应用启动之前解析完。如果任何一个初始化器失败了,该应用就不会继续启动。

该提供者对象还有第三个选项 multi: true,把它和 APP_INITIALIZER 一起使用可以为特定的事件注册多个处理器。

比如,当启动应用时,你可以使用同一个令牌注册多个初始化器。

export const APP_TOKENS = [ { provide: PLATFORM_INITIALIZER, useFactory: platformInitialized, multi: true    }, { provide: APP_INITIALIZER, useFactory: delayBootstrapping, multi: true }, { provide: APP_BOOTSTRAP_LISTENER, useFactory: appBootstrapped, multi: true },];

在其它地方,多个提供者也同样可以和单个令牌关联起来。 比如,你可以使用内置的 NG_VALIDATORS 令牌注册自定义表单验证器,还可以在提供者定义对象中使用 multi: true 属性来为指定的验证器令牌提供多个验证器实例。 Angular 会把你的自定义验证器添加到现有验证器的集合中。

路由器也同样用多个提供者关联到了一个令牌。 当你在单个模块中用 RouterModule.forRootRouterModule.forChild 提供了多组路由时,ROUTES 令牌会把这些不同的路由组都合并成一个单一值。

可摇树优化的提供者

摇树优化是指一个编译器选项,意思是把应用中未引用过的代码从最终生成的包中移除。 如果提供者是可摇树优化的,Angular 编译器就会从最终的输出内容中移除应用代码中从未用过的服务。 这会显著减小你的打包体积。

理想情况下,如果应用没有注入服务,它就不应该包含在最终输出中。 不过,Angular 要能在构建期间识别出该服务是否需要。 由于还可能用 injector.get(Service) 的形式直接注入服务,所以 Angular 无法准确识别出代码中可能发生此注入的全部位置,因此为保险起见,只能把服务包含在注入器中。 因此,在 NgModule 或 组件级别提供的服务是无法被摇树优化掉的。

下面这个不可摇树优化的 Angular 提供者的例子为 NgModule 注入器配置了一个服务提供者。

Path:"src/app/tree-shaking/service-and-modules.ts" 。

import { Injectable, NgModule } from '@angular/core';@Injectable()export class Service {  doSomething(): void {  }}@NgModule({  providers: [Service],})export class ServiceModule {}

你可以把该模块导入到你的应用模块中,以便该服务可注入到你的应用中,例子如下。

Path:"src/app/tree-shaking/app.modules.ts" 。

@NgModule({  imports: [    BrowserModule,    RouterModule.forRoot([]),    ServiceModule,  ],})export class AppModule {}

当运行 ngc 时,它会把 AppModule 编译到模块工厂中,工厂包含该模块及其导入的所有模块中声明的所有提供者。在运行时,该工厂会变成负责实例化所有这些服务的注入器。

这里摇树优化不起作用,因为 Angular 无法根据是否用到了其它代码块(服务类),来决定是否能排除这块代码(模块工厂中的服务提供者定义)。要让服务可以被摇树优化,关于如何构建该服务实例的信息(即提供者定义),就应该是服务类本身的一部分。

创建可摇树优化的提供者

只要在服务本身的 @Injectable() 装饰器中指定,而不是在依赖该服务的 NgModule 或组件的元数据中指定,你就可以制作一个可摇树优化的提供者。

下面的例子展示了与上面的 ServiceModule 例子等价的可摇树优化的版本。

Path:"src/app/tree-shaking/service.ts" 。

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

该服务还可以通过配置工厂函数来实例化,如下例所示。

Path:"src/app/tree-shaking/service.0.ts" 。

@Injectable({  providedIn: 'root',  useFactory: () => new Service('dependency'),})export class Service {  constructor(private dep: string) {  }}

要想覆盖可摇树优化的提供者,请使用其它提供者来配置指定的 NgModule 或组件的注入器,只要使用 @NgModule()@Component() 装饰器中的 providers: [] 数组就可以了。

嵌套的服务依赖

这些被注入服务的消费者不需要知道如何创建这个服务。新建和缓存这个服务是依赖注入器的工作。消费者只要让依赖注入框架知道它需要哪些依赖项就可以了。

有时候一个服务依赖其它服务...而其它服务可能依赖另外的更多服务。 依赖注入框架会负责正确的顺序解析这些嵌套的依赖项。 在每一步,依赖的使用者只要在它的构造函数里简单声明它需要什么,框架就会完成所有剩下的事情。

下面的例子往 AppComponent 里声明它依赖 LoggerServiceUserContext

Path:"src/app/app.component.ts" 。

constructor(logger: LoggerService, public userContext: UserContextService) {  userContext.loadUser(this.userId);  logger.logInfo('AppComponent initialized');}

UserContext 转而依赖 LoggerServiceUserService(这个服务用来收集特定用户信息)。

Path:"user-context.service.ts (injection)" 。

@Injectable({  providedIn: 'root'})export class UserContextService {  constructor(private userService: UserService, private loggerService: LoggerService) {  }}

当 Angular 新建 AppComponent 时,依赖注入框架会先创建一个 LoggerService 的实例,然后创建 UserContextService 实例。 UserContextService 也需要框架刚刚创建的这个 LoggerService 实例,这样框架才能为它提供同一个实例。UserContextService 还需要框架创建过的 UserServiceUserService 没有其它依赖,所以依赖注入框架可以直接 new 出该类的一个实例,并把它提供给 UserContextService 的构造函数。

父组件 AppComponent 不需要了解这些依赖的依赖。 只要在构造函数中声明自己需要的依赖即可(这里是 LoggerServiceUserContextService),框架会帮你解析这些嵌套的依赖。

当所有的依赖都就位之后,AppComponent 就会显示该用户的信息。

把服务的范围限制到某个组件的子树下

Angular 应用程序有多个依赖注入器,组织成一个与组件树平行的树状结构。 每个注入器都会创建依赖的一个单例。在所有该注入器负责提供服务的地方,所提供的都是同一个实例。 可以在注入器树的任何层级提供和建立特定的服务。这意味着,如果在多个注入器中提供该服务,那么该服务也就会有多个实例。

由根注入器提供的依赖可以注入到应用中任何地方的任何组件中。 但有时候你可能希望把服务的有效性限制到应用程序的一个特定区域。 比如,你可能希望用户明确选择一个服务,而不是让根注入器自动提供它。

通过在组件树的子级根组件中提供服务,可以把一个被注入服务的作用域局限在应用程序结构中的某个分支中。 这个例子中展示了如何通过把服务添加到子组件 @Component() 装饰器的 providers 数组中,来为 HeroesBaseComponent 提供另一个 HeroService 实例:

Path:"src/app/sorted-heroes.component.ts (HeroesBaseComponent excerpt)" 。

@Component({  selector: 'app-unsorted-heroes',  template: `<div *ngFor="let hero of heroes">{{hero.name}}</div>`,  providers: [HeroService]})export class HeroesBaseComponent implements OnInit {  constructor(private heroService: HeroService) { }}

当 Angular 新建 HeroBaseComponent 的时候,它会同时新建一个 HeroService 实例,该实例只在该组件及其子组件(如果有)中可见。

也可以在应用程序别处的另一个组件里提供 HeroService。这样就会导致在另一个注入器中存在该服务的另一个实例。

这个例子中,局部化的 HeroService 单例,遍布整份范例代码,包括 HeroBiosComponentHeroOfTheMonthComponentHeroBaseComponent。 这些组件每个都有自己的 HeroService 实例,用来管理独立的英雄库。

多个服务实例(沙箱式隔离)

在组件树的同一个级别上,有时需要一个服务的多个实例。

一个用来保存其伴生组件的实例状态的服务就是个好例子。 每个组件都需要该服务的单独实例。 每个服务有自己的工作状态,与其它组件的服务和状态隔离。这叫做沙箱化,因为每个服务和组件实例都在自己的沙箱里运行。

在这个例子中,HeroBiosComponent 渲染了 HeroBioComponent 的三个实例。

Path:"ap/hero-bios.component.ts" 。

@Component({  selector: 'app-hero-bios',  template: `    <app-hero-bio [heroId]="1"></app-hero-bio>    <app-hero-bio [heroId]="2"></app-hero-bio>    <app-hero-bio [heroId]="3"></app-hero-bio>`,  providers: [HeroService]})export class HeroBiosComponent {}

每个 HeroBioComponent 都能编辑一个英雄的生平。HeroBioComponent 依赖 HeroCacheService 服务来对该英雄进行读取、缓存和执行其它持久化操作。

Path:"src/app/hero-cache.service.ts" 。

@Injectable()export class HeroCacheService {  hero: Hero;  constructor(private heroService: HeroService) {}  fetchCachedHero(id: number) {    if (!this.hero) {      this.hero = this.heroService.getHeroById(id);    }    return this.hero;  }}

这三个 HeroBioComponent 实例不能共享同一个 HeroCacheService 实例。否则它们会相互冲突,争相把自己的英雄放在缓存里面。

它们应该通过在自己的元数据(metadata)providers 数组里面列出 HeroCacheService, 这样每个 HeroBioComponent 就能拥有自己独立的 HeroCacheService 实例了。

Path:"src/app/hero-bio.component.ts" 。

@Component({  selector: 'app-hero-bio',  template: `    <h4>{{hero.name}}</h4>    <ng-content></ng-content>    <textarea cols="25" [(ngModel)]="hero.description"></textarea>`,  providers: [HeroCacheService]})export class HeroBioComponent implements OnInit  {  @Input() heroId: number;  constructor(private heroCache: HeroCacheService) { }  ngOnInit() { this.heroCache.fetchCachedHero(this.heroId); }  get hero() { return this.heroCache.hero; }}

父组件 HeroBiosComponent 把一个值绑定到 heroIdngOnInit 把该 id 传递到服务,然后服务获取和缓存英雄。hero 属性的 getter 从服务里面获取缓存的英雄,并在模板里显示它绑定到属性值。

确认三个 HeroBioComponent 实例拥有自己独立的英雄数据缓存。

使用参数装饰器来限定依赖查找方式

当类需要某个依赖项时,该依赖项就会作为参数添加到类的构造函数中。 当 Angular 需要实例化该类时,就会调用 DI 框架来提供该依赖。 默认情况下,DI 框架会在注入器树中查找一个提供者,从该组件的局部注入器开始,如果需要,则沿着注入器树向上冒泡,直到根注入器。

  • 第一个配置过该提供者的注入器就会把依赖(服务实例或值)提供给这个构造函数。

  • 如果在根注入器中也没有找到提供者,则 DI 框架将会抛出一个错误。

通过在类的构造函数中对服务参数使用参数装饰器,可以提供一些选项来修改默认的搜索行为。

用 @Optional 来让依赖是可选的,以及使用 @Host 来限定搜索方式

依赖可以注册在组件树的任何层级上。 当组件请求某个依赖时,Angular 会从该组件的注入器找起,沿着注入器树向上,直到找到了第一个满足要求的提供者。如果没找到依赖,Angular 就会抛出一个错误。

某些情况下,你需要限制搜索,或容忍依赖项的缺失。 你可以使用组件构造函数参数上的 @Host@Optional 这两个限定装饰器来修改 Angular 的搜索行为。

  • @Optional 属性装饰器告诉 Angular 当找不到依赖时就返回 null

  • @Host 属性装饰器会禁止在宿主组件以上的搜索。宿主组件通常就是请求该依赖的那个组件。 不过,当该组件投影进某个父组件时,那个父组件就会变成宿主。下面的例子中介绍了第二种情况。

如下例所示,这些装饰器可以独立使用,也可以同时使用。这个 HeroBiosAndContactsComponent 是你以前见过的那个 HeroBiosComponent 的修改版。

Path:"src/app/hero-bios.component.ts (HeroBiosAndContactsComponent)" 。

@Component({  selector: 'app-hero-bios-and-contacts',  template: `    <app-hero-bio [heroId]="1"> <app-hero-contact></app-hero-contact> </app-hero-bio>    <app-hero-bio [heroId]="2"> <app-hero-contact></app-hero-contact> </app-hero-bio>    <app-hero-bio [heroId]="3"> <app-hero-contact></app-hero-contact> </app-hero-bio>`,  providers: [HeroService]})export class HeroBiosAndContactsComponent {  constructor(logger: LoggerService) {    logger.logInfo('Creating HeroBiosAndContactsComponent');  }}

注意看模板:

Path:"dependency-injection-in-action/src/app/hero-bios.component.ts" 。

template: `  <app-hero-bio [heroId]="1"> <app-hero-contact></app-hero-contact> </app-hero-bio>  <app-hero-bio [heroId]="2"> <app-hero-contact></app-hero-contact> </app-hero-bio>  <app-hero-bio [heroId]="3"> <app-hero-contact></app-hero-contact> </app-hero-bio>`,

<hero-bio> 标签中是一个新的 <hero-contact> 元素。Angular 就会把相应的 HeroContactComponent投影(transclude)进 HeroBioComponent 的视图里, 将它放在 HeroBioComponent 模板的 <ng-content> 标签槽里。

Path:"src/app/hero-bio.component.ts (template)" 。

template: `  <h4>{{hero.name}}</h4>  <ng-content></ng-content>  <textarea cols="25" [(ngModel)]="hero.description"></textarea>`,

HeroContactComponent 获得的英雄电话号码,被投影到上面的英雄描述里,结果如下:

这里的 HeroContactComponent 演示了限定型装饰器。

Path:"src/app/hero-contact.component.ts" 。

@Component({  selector: 'app-hero-contact',  template: `  <div>Phone #: {{phoneNumber}}  <span *ngIf="hasLogger">!!!</span></div>`})export class HeroContactComponent {  hasLogger = false;  constructor(      @Host() // limit to the host component's instance of the HeroCacheService      private heroCache: HeroCacheService,      @Host()     // limit search for logger; hides the application-wide logger      @Optional() // ok if the logger doesn't exist      private loggerService?: LoggerService  ) {    if (loggerService) {      this.hasLogger = true;      loggerService.logInfo('HeroContactComponent can log!');    }  }  get phoneNumber() { return this.heroCache.hero.phone; }}

注意构造函数的参数。

Path:"src/app/hero-contact.component.ts" 。

@Host() // limit to the host component's instance of the HeroCacheServiceprivate heroCache: HeroCacheService,@Host()     // limit search for logger; hides the application-wide logger@Optional() // ok if the logger doesn't existprivate loggerService?: LoggerService

@Host() 函数是构造函数属性 heroCache 的装饰器,确保从其父组件 HeroBioComponent 得到一个缓存服务。如果该父组件中没有该服务,Angular 就会抛出错误,即使组件树里的再上级有某个组件拥有这个服务,还是会抛出错误。

另一个 @Host() 函数是构造函数属性 loggerService 的装饰器。 在本应用程序中只有一个在 AppComponent 级提供的 LoggerService 实例。 该宿主 HeroBioComponent 没有自己的 LoggerService 提供者。

如果没有同时使用 @Optional() 装饰器的话,Angular 就会抛出错误。当该属性带有 @Optional() 标记时,Angular 就会把 loggerService 设置为 null,并继续执行组件而不会抛出错误。

下面是 HeroBiosAndContactsComponent 的执行结果:

如果注释掉 @Host() 装饰器,Angular 就会沿着注入器树往上走,直到在 AppComponent 中找到该日志服务。日志服务的逻辑加了进来,所显示的英雄信息增加了 "!!!" 标记,这表明确实找到了日志服务。

如果你恢复了 @Host() 装饰器,并且注释掉 @Optional 装饰器,应用就会抛出一个错误,因为它在宿主组件这一层找不到所需的 Logger。EXCEPTION: No provider for LoggerService! (HeroContactComponent -> LoggerService)

使用 @Inject 指定自定义提供者

自定义提供者让你可以为隐式依赖提供一个具体的实现,比如内置浏览器 API。下面的例子使用 InjectionToken 来提供 localStorage,将其作为 BrowserStorageService 的依赖项。

Path:"src/app/storage.service.ts" 。

import { Inject, Injectable, InjectionToken } from '@angular/core';export const BROWSER_STORAGE = new InjectionToken<Storage>('Browser Storage', {  providedIn: 'root',  factory: () => localStorage});@Injectable({  providedIn: 'root'})export class BrowserStorageService {  constructor(@Inject(BROWSER_STORAGE) public storage: Storage) {}  get(key: string) {    this.storage.getItem(key);  }  set(key: string, value: string) {    this.storage.setItem(key, value);  }  remove(key: string) {    this.storage.removeItem(key);  }  clear() {    this.storage.clear();  }}

factory 函数返回 window 对象上的 localStorage 属性。Inject 装饰器修饰一个构造函数参数,用于为某个依赖提供自定义提供者。现在,就可以在测试期间使用 localStorage 的 Mock API 来覆盖这个提供者了,而不必与真实的浏览器 API 进行交互。

使用 @Self 和 @SkipSelf 来修改提供者的搜索方式

注入器也可以通过构造函数的参数装饰器来指定范围。下面的例子就在 Component 类的 providers 中使用浏览器的 sessionStorage API 覆盖了 BROWSER_STORAGE 令牌。同一个 BrowserStorageService 在构造函数中使用 @Self@SkipSelf 装饰器注入了两次,来分别指定由哪个注入器来提供依赖。

Path:"src/app/storage.component.ts" 。

import { Component, OnInit, Self, SkipSelf } from '@angular/core';import { BROWSER_STORAGE, BrowserStorageService } from './storage.service';@Component({  selector: 'app-storage',  template: `    Open the inspector to see the local/session storage keys:    <h3>Session Storage</h3>    <button (click)="setSession()">Set Session Storage</button>    <h3>Local Storage</h3>    <button (click)="setLocal()">Set Local Storage</button>  `,  providers: [    BrowserStorageService,    { provide: BROWSER_STORAGE, useFactory: () => sessionStorage }  ]})export class StorageComponent implements OnInit {  constructor(    @Self() private sessionStorageService: BrowserStorageService,    @SkipSelf() private localStorageService: BrowserStorageService,  ) { }  ngOnInit() {  }  setSession() {    this.sessionStorageService.set('hero', 'Dr Nice - Session');  }  setLocal() {    this.localStorageService.set('hero', 'Dr Nice - Local');  }}

使用 @Self 装饰器时,注入器只在该组件的注入器中查找提供者。@SkipSelf 装饰器可以让你跳过局部注入器,并在注入器树中向上查找,以发现哪个提供者满足该依赖。 sessionStorageService 实例使用浏览器的 sessionStorage 来跟 BrowserStorageService 打交道,而 localStorageService 跳过了局部注入器,使用根注入器提供的 BrowserStorageService,它使用浏览器的 localStorage API。

注入组件的 DOM 元素

即便开发者极力避免,仍然会有很多视觉效果和第三方工具 (比如 jQuery) 需要访问 DOM。这会让你不得不访问组件所在的 DOM 元素。

为了说明这一点,请看属性型指令中那个 HighlightDirective 的简化版。

Path:"src/app/highlight.directive.ts" 。

import { Directive, ElementRef, HostListener, Input } from '@angular/core';@Directive({  selector: '[appHighlight]'})export class HighlightDirective {  @Input('appHighlight') highlightColor: string;  private el: HTMLElement;  constructor(el: ElementRef) {    this.el = el.nativeElement;  }  @HostListener('mouseenter') onMouseEnter() {    this.highlight(this.highlightColor || 'cyan');  }  @HostListener('mouseleave') onMouseLeave() {    this.highlight(null);  }  private highlight(color: string) {    this.el.style.backgroundColor = color;  }}

当用户把鼠标移到 DOM 元素上时,指令将指令所在的元素的背景设置为一个高亮颜色。

Angular 把构造函数参数 el 设置为注入的 ElementRef,该 ElementRef 代表了宿主的 DOM 元素,它的 nativeElement 属性把该 DOM 元素暴露给了指令。

下面的代码把指令的 myHighlight 属性(Attribute)填加到两个 <div> 标签里,一个没有赋值,一个赋值了颜色。

Path:"src/app/app.component.html (highlight)" 。

<div id="highlight"  class="di-component"  appHighlight>  <h3>Hero Bios and Contacts</h3>  <div appHighlight="yellow">    <app-hero-bios-and-contacts></app-hero-bios-and-contacts>  </div></div>

下图显示了鼠标移到 <hero-bios-and-contacts> 标签上的效果:

使用提供者来定义依赖

为了从依赖注入器中获取服务,你必须传给它一个令牌。 Angular 通常会通过指定构造函数参数以及参数的类型来处理它。 参数的类型可以用作注入器的查阅令牌。 Angular 会把该令牌传给注入器,并把它的结果赋给相应的参数。

下面是一个典型的例子。

Path:"src/app/hero-bios.component.ts (component constructor injection)" 。

constructor(logger: LoggerService) {  logger.logInfo('Creating HeroBiosComponent');}

Angular 会要求注入器提供与 LoggerService 相关的服务,并把返回的值赋给 logger 参数。

如果注入器已经缓存了与该令牌相关的服务实例,那么它就会直接提供此实例。 如果它没有,它就要使用与该令牌相关的提供者来创建一个。

如果注入器无法根据令牌在自己内部找到对应的提供者,它便将请求移交给它的父级注入器,这个过程不断重复,直到没有更多注入器为止。 如果没找到,注入器就抛出一个错误...除非这个请求是可选的。

新的注入器没有提供者。 Angular 会使用一组首选提供者来初始化它本身的注入器。 你必须为自己应用程序特有的依赖项来配置提供者。

定义提供者

用于实例化类的默认方法不一定总适合用来创建依赖。你可以到依赖提供者部分查看其它方法。 HeroOfTheMonthComponent 例子示范了一些替代方案,展示了为什么需要它们。 它看起来很简单:一些属性和一些由 logger 生成的日志。

它背后的代码定制了 DI 框架提供依赖项的方法和位置。 这个例子阐明了通过提供对象字面量来把对象的定义和 DI 令牌关联起来的另一种方式。

Path:"hero-of-the-month.component.ts" 。

import { Component, Inject } from '@angular/core';import { DateLoggerService } from './date-logger.service';import { Hero }              from './hero';import { HeroService }       from './hero.service';import { LoggerService }     from './logger.service';import { MinimalLogger }     from './minimal-logger.service';import { RUNNERS_UP,         runnersUpFactory }  from './runners-up';@Component({  selector: 'app-hero-of-the-month',  templateUrl: './hero-of-the-month.component.html',  providers: [    { provide: Hero,          useValue:    someHero },    { provide: TITLE,         useValue:   'Hero of the Month' },    { provide: HeroService,   useClass:    HeroService },    { provide: LoggerService, useClass:    DateLoggerService },    { provide: MinimalLogger, useExisting: LoggerService },    { provide: RUNNERS_UP,    useFactory:  runnersUpFactory(2), deps: [Hero, HeroService] }  ]})export class HeroOfTheMonthComponent {  logs: string[] = [];  constructor(      logger: MinimalLogger,      public heroOfTheMonth: Hero,      @Inject(RUNNERS_UP) public runnersUp: string,      @Inject(TITLE) public title: string)  {    this.logs = logger.logs;    logger.logInfo('starting up');  }}

providers 数组展示了你可以如何使用其它的键来定义提供者:useValueuseClassuseExistinguseFactory

值提供者:useValue

useValue 键让你可以为 DI 令牌关联一个固定的值。 使用该技巧来进行运行期常量设置,比如网站的基础地址和功能标志等。 你也可以在单元测试中使用值提供者,来用一个 Mock 数据来代替一个生产环境下的数据服务。

HeroOfTheMonthComponent 例子中有两个值-提供者。

Path:"dependency-injection-in-action/src/app/hero-of-the-month.component.ts" 。

{ provide: Hero,          useValue:    someHero },{ provide: TITLE,         useValue:   'Hero of the Month' },

  • 第一处提供了用于 Hero 令牌的 Hero 类的现有实例,而不是要求注入器使用 new 来创建一个新实例或使用它自己的缓存实例。这里令牌就是这个类本身。

  • 第二处为 TITLE 令牌指定了一个字符串字面量资源。 TITLE 提供者的令牌不是一个类,而是一个特别的提供者查询键,名叫InjectionToken,表示一个 InjectionToken 实例。

你可以把 InjectionToken 用作任何类型的提供者的令牌,但是当依赖是简单类型(比如字符串、数字、函数)时,它会特别有用。

一个值-提供者的值必须在指定之前定义。 比如标题字符串就是立即可用的。 该例中的 someHero 变量是以前在如下的文件中定义的。 你不能使用那些要等以后才能定义其值的变量。

Path:"dependency-injection-in-action/src/app/hero-of-the-month.component.ts" 。

const someHero = new Hero(42, 'Magma', 'Had a great month!', '555-555-5555');

其它类型的提供者都会惰性创建它们的值,也就是说只在需要注入它们的时候才创建。

类提供者:useClass

useClass 提供的键让你可以创建并返回指定类的新实例。

你可以使用这类提供者来为公共类或默认类换上一个替代实现。比如,这个替代实现可以实现一种不同的策略来扩展默认类,或在测试环境中模拟真实类的行为。

请看下面 HeroOfTheMonthComponent 里的两个例子:

Path:"dependency-injection-in-action/src/app/hero-of-the-month.component.ts" 。

{ provide: HeroService,   useClass:    HeroService },{ provide: LoggerService, useClass:    DateLoggerService },

第一个提供者是展开了语法糖的,是一个典型情况的展开。一般来说,被新建的类(HeroService)同时也是该提供者的注入令牌。 通常都选用缩写形式,完整形式可以让细节更明确。

第二个提供者使用 DateLoggerService 来满足 LoggerService。该 LoggerServiceAppComponent 级别已经被注册。当这个组件要求 LoggerService 的时候,它得到的却是 DateLoggerService 服务的实例。

这个组件及其子组件会得到 DateLoggerService 实例。这个组件树之外的组件得到的仍是 LoggerService 实例。

DateLoggerServiceLoggerService 继承;它把当前的日期/时间附加到每条信息上。

Path:"src/app/date-logger.service.ts" 。

@Injectable({  providedIn: 'root'})export class DateLoggerService extends LoggerService{  logInfo(msg: any)  { super.logInfo(stamp(msg)); }  logDebug(msg: any) { super.logInfo(stamp(msg)); }  logError(msg: any) { super.logError(stamp(msg)); }}function stamp(msg: any) { return msg + ' at ' + new Date(); }

别名提供者:useExisting

useExisting 提供了一个键,让你可以把一个令牌映射成另一个令牌。实际上,第一个令牌就是第二个令牌所关联的服务的别名,这样就创建了访问同一个服务对象的两种途径。

Path:"dependency-injection-in-action/src/app/hero-of-the-month.component.ts" 。

{ provide: MinimalLogger, useExisting: LoggerService },

你可以使用别名接口来窄化 API。下面的例子中使用别名就是为了这个目的。

想象 LoggerService 有个很大的 API 接口,远超过现有的三个方法和一个属性。你可能希望把 API 接口收窄到只有两个你确实需要的成员。在这个例子中,MinimalLogger类-接口,就这个 API 成功缩小到了只有两个成员:

Path:"src/app/minimal-logger.service.ts" 。

// Class used as a "narrowing" interface that exposes a minimal logger// Other members of the actual implementation are invisibleexport abstract class MinimalLogger {  logs: string[];  logInfo: (msg: string) => void;}

下面的例子在一个简化版的 HeroOfTheMonthComponent 中使用 MinimalLogger

Path:"src/app/hero-of-the-month.component.ts (minimal version)" 。

@Component({  selector: 'app-hero-of-the-month',  templateUrl: './hero-of-the-month.component.html',  // TODO: move this aliasing, `useExisting` provider to the AppModule  providers: [{ provide: MinimalLogger, useExisting: LoggerService }]})export class HeroOfTheMonthComponent {  logs: string[] = [];  constructor(logger: MinimalLogger) {    logger.logInfo('starting up');  }}

HeroOfTheMonthComponent 构造函数的 logger 参数是一个 MinimalLogger 类型,在支持 TypeScript 感知的编辑器里,只能看到它的两个成员 logslogInfo

实际上,Angularlogger 参数设置为注入器里 LoggerService 令牌下注册的完整服务,该令牌恰好是以前提供的那个 DateLoggerService 实例。

在下面的图片中,显示了日志日期,可以确认这一点:

工厂提供者:useFactory

Path:"dependency-injection-in-action/src/app/hero-of-the-month.component.ts" 。

{ provide: RUNNERS_UP,    useFactory:  runnersUpFactory(2), deps: [Hero, HeroService] }

注入器通过调用你用 useFactory 键指定的工厂函数来提供该依赖的值。 注意,提供者的这种形态还有第三个键 deps,它指定了供 useFactory 函数使用的那些依赖。

使用这项技术,可以用包含了一些依赖服务和本地状态输入的工厂函数来建立一个依赖对象。

这个依赖对象(由工厂函数返回的)通常是一个类实例,不过也可以是任何其它东西。 在这个例子中,依赖对象是一个表示 "月度英雄" 参赛者名称的字符串。

在这个例子中,局部状态是数字 2,也就是组件应该显示的参赛者数量。 该状态的值传给了 runnersUpFactory() 作为参数。 runnersUpFactory() 返回了提供者的工厂函数,它可以使用传入的状态值和注入的服务 HeroHeroService

Path:"runners-up.ts (excerpt)" 。

export function runnersUpFactory(take: number) {  return (winner: Hero, heroService: HeroService): string => {    /* ... */  };};

runnersUpFactory() 返回的提供者的工厂函数返回了实际的依赖对象,也就是表示名字的字符串。

  • 这个返回的函数需要一个 Hero 和一个 HeroService 参数。

Angular 根据 deps 数组中指定的两个令牌来提供这些注入参数。

  • 该函数返回名字的字符串,Angular 可以把它们注入到 HeroOfTheMonthComponentrunnersUp 参数中。

该函数从 HeroService 中接受候选的英雄,从中取 2 个参加竞赛,并把他们的名字串接起来返回。

提供替代令牌:类接口与 'InjectionToken'

当使用类作为令牌,同时也把它作为返回依赖对象或服务的类型时,Angular 依赖注入使用起来最容易。

但令牌不一定都是类,就算它是一个类,它也不一定都返回类型相同的对象。这是下一节的主题。

类-接口

前面的月度英雄的例子使用了 MinimalLogger 类作为 LoggerService 提供者的令牌。

Path:"dependency-injection-in-action/src/app/hero-of-the-month.component.ts" 。

{ provide: MinimalLogger, useExisting: LoggerService },

MinimalLogger 是一个抽象类。

Path:"dependency-injection-in-action/src/app/minimal-logger.service.ts" 。

// Class used as a "narrowing" interface that exposes a minimal logger// Other members of the actual implementation are invisibleexport abstract class MinimalLogger {  logs: string[];  logInfo: (msg: string) => void;}

你通常从一个可扩展的抽象类继承。但这个应用中并没有类会继承 MinimalLogger

LoggerServiceDateLoggerService本可以从 MinimalLogger 中继承。 它们也可以实现 MinimalLogger,而不用单独定义接口。 但它们没有。 MinimalLogger 在这里仅仅被用作一个 "依赖注入令牌"。

当你通过这种方式使用类时,它称作类接口。

就像 DI 提供者中提到的那样,接口不是有效的 DI 令牌,因为它是 TypeScript 自己用的,在运行期间不存在。使用这种抽象类接口不但可以获得像接口一样的强类型,而且可以像普通类一样把它用作提供者令牌。

类接口应该只定义允许它的消费者调用的成员。窄的接口有助于解耦该类的具体实现和它的消费者。

用类作为接口可以让你获得真实 JavaScript 对象中的接口的特性。 但是,为了最小化内存开销,该类应该是没有实现的。 对于构造函数,MinimalLogger 会转译成未优化过的、预先最小化过的 JavaScript。

Path:"dependency-injection-in-action/src/app/minimal-logger.service.ts" 。

var MinimalLogger = (function () {  function MinimalLogger() {}  return MinimalLogger;}());exports("MinimalLogger", MinimalLogger);

注:

只要不实现它,不管添加多少成员,它都不会增长大小,因为这些成员虽然是有类型的,但却没有实现。

你可以再看看 TypeScript 的 MinimalLogger 类,确定一下它是没有实现的。

'InjectionToken' 对象

依赖对象可以是一个简单的值,比如日期,数字和字符串,或者一个无形的对象,比如数组和函数。

这样的对象没有应用程序接口,所以不能用一个类来表示。更适合表示它们的是:唯一的和符号性的令牌,一个 JavaScript 对象,拥有一个友好的名字,但不会与其它的同名令牌发生冲突。

InjectionToken 具有这些特征。在Hero of the Month例子中遇见它们两次,一个是 title 的值,一个是 runnersUp 工厂提供者。

Path:"dependency-injection-in-action/src/app/hero-of-the-month.component.ts" 。

{ provide: TITLE,         useValue:   'Hero of the Month' },{ provide: RUNNERS_UP,    useFactory:  runnersUpFactory(2), deps: [Hero, HeroService] }

这样创建 TITLE 令牌:

Path:"dependency-injection-in-action/src/app/hero-of-the-month.component.ts" 。

import { InjectionToken } from '@angular/core';export const TITLE = new InjectionToken<string>('title');

类型参数,虽然是可选的,但可以向开发者和开发工具传达类型信息。 而且这个令牌的描述信息也可以为开发者提供帮助。

注入到派生类

当编写一个继承自另一个组件的组件时,要格外小心。如果基础组件有依赖注入,必须要在派生类中重新提供和重新注入它们,并将它们通过构造函数传给基类。

在这个刻意生成的例子里,SortedHeroesComponent 继承自 HeroesBaseComponent,显示一个被排序的英雄列表。

HeroesBaseComponent 能自己独立运行。它在自己的实例里要求 HeroService,用来得到英雄,并将他们按照数据库返回的顺序显示出来。

Path:"src/app/sorted-heroes.component.ts (HeroesBaseComponent)" 。

@Component({  selector: 'app-unsorted-heroes',  template: `<div *ngFor="let hero of heroes">{{hero.name}}</div>`,  providers: [HeroService]})export class HeroesBaseComponent implements OnInit {  constructor(private heroService: HeroService) { }  heroes: Array<Hero>;  ngOnInit() {    this.heroes = this.heroService.getAllHeroes();    this.afterGetHeroes();  }  // Post-process heroes in derived class override.  protected afterGetHeroes() {}}

让构造函数保持简单

构造函数应该只用来初始化变量。 这条规则让组件在测试环境中可以放心地构造组件,以免在构造它们时,无意中做出一些非常戏剧化的动作(比如与服务器进行会话)。 这就是为什么你要在 ngOnInit 里面调用 HeroService,而不是在构造函数中。

用户希望看到英雄按字母顺序排序。与其修改原始的组件,不如派生它,新建 SortedHeroesComponent,以便展示英雄之前进行排序。 SortedHeroesComponent 让基类来获取英雄。

可惜,Angular 不能直接在基类里直接注入 HeroService。必须在这个组件里再次提供 HeroService,然后通过构造函数传给基类。

Path:"src/app/sorted-heroes.component.ts (SortedHeroesComponent)" 。

@Component({  selector: 'app-sorted-heroes',  template: `<div *ngFor="let hero of heroes">{{hero.name}}</div>`,  providers: [HeroService]})export class SortedHeroesComponent extends HeroesBaseComponent {  constructor(heroService: HeroService) {    super(heroService);  }  protected afterGetHeroes() {    this.heroes = this.heroes.sort((h1, h2) => {      return h1.name < h2.name ? -1 :            (h1.name > h2.name ? 1 : 0);    });  }}

现在,请注意 afterGetHeroes() 方法。 你的第一反应是在 SortedHeroesComponent 组件里面建一个 ngOnInit 方法来做排序。但是 Angular 会先调用派生类的 ngOnInit,后调用基类的 ngOnInit, 所以可能在英雄到达之前就开始排序。这就产生了一个讨厌的错误。

覆盖基类的 afterGetHeroes() 方法可以解决这个问题。

分析上面的这些复杂性是为了强调避免使用组件继承这一点。

使用一个前向引用(forwardRef)来打破循环

在 TypeScript 里面,类声明的顺序是很重要的。如果一个类尚未定义,就不能引用它。

这通常不是一个问题,特别是当你遵循一个文件一个类规则的时候。 但是有时候循环引用可能不能避免。当一个类A 引用类 B,同时'B'引用'A'的时候,你就陷入困境了:它们中间的某一个必须要先定义。

Angular 的 forwardRef() 函数建立一个间接地引用,Angular 可以随后解析。

这个关于父查找器的例子中全都是没办法打破的循环类引用。

当一个类需要引用自身的时候,你面临同样的困境,就像在 AlexComponentprovdiers 数组中遇到的困境一样。 该 providers 数组是一个 @Component() 装饰器函数的一个属性,它必须在类定义之前出现。

使用 forwardRef 来打破这种循环:

Path:"parent-finder.component.ts (AlexComponent providers)" 。

providers: [{ provide: Parent, useExisting: forwardRef(() => AlexComponent) }],

应用的组件之间经常需要共享信息。你通常要用松耦合的技术来共享信息,比如数据绑定和服务共享。但是有时候让一个组件直接引用另一个组件还是很有意义的。 例如,你需要通过另一个组件的直接引用来访问其属性或调用其方法。

在 Angular 中获取组件引用略微有些棘手。 Angular 组件本身并没有一棵可以用编程方式检查或浏览的树。 其父子关系是通过组件的视图对象间接建立的。

每个组件都有一个宿主视图和一些内嵌视图。 组件 A 的内嵌视图可以是组件 B 的宿主视图,而组件 B 还可以有它自己的内嵌视图。 这意味着每个组件都有一棵以该组件的宿主视图为根节点的视图树。

有一些用于在视图树中向下导航的 API。 请到 API 参考手册中查看 Query、QueryList、ViewChildren 和 ContentChildren。

不存在用于获取父引用的公共 API。 不过,由于每个组件的实例都会添加到注入器的容器中,因此你可以通过 Angular 的依赖注入来访问父组件。

本节描述的就是关于这种做法的一些技巧。

查找已知类型的父组件

你可以使用标准的类注入形式来获取类型已知的父组件。

在下面的例子中,父组件 AlexComponent 具有一些子组件,包括 CathyComponent

Path:"parent-finder.component.ts (AlexComponent v.1)" 。

@Component({  selector: 'alex',  template: `    <div class="a">      <h3>{{name}}</h3>      <cathy></cathy>      <craig></craig>      <carol></carol>    </div>`,})export class AlexComponent extends Base{  name = 'Alex';}

在把 AlexComponent 注入到 CathyComponent 的构造函数中之后,Cathy 可以报告她是否能访问 Alex

Path:"parent-finder.component.ts (CathyComponent)" 。

@Component({  selector: 'cathy',  template: `  <div class="c">    <h3>Cathy</h3>    {{alex ? 'Found' : 'Did not find'}} Alex via the component class.<br>  </div>`})export class CathyComponent {  constructor( @Optional() public alex?: AlexComponent ) { }}

注意,虽然为了安全起见我们用了 @Optional 限定符,但是范例中仍然会确认 alex 参数是否有值。

不能根据父组件的基类访问父组件

如果你不知道具体的父组件类怎么办?

可复用组件可能是多个组件的子组件。想象一个用于渲染相关金融工具的突发新闻的组件。 出于商业原因,当市场上的数据流发生变化时,这些新组件会频繁调用其父组件。

该应用可能定义了十几个金融工具组件。理想情况下,它们全都实现了同一个基类,你的 NewsComponent 也能理解其 API。

如果能查找实现了某个接口的组件当然更好。 但那是不可能的。因为 TypeScript 接口在转译后的 JavaScript 中不存在,而 JavaScript 不支持接口。 因此,找无可找。

这个设计并不怎么好。 该例子是为了验证组件是否能通过其父组件的基类来注入父组件。

这个例子中的 CraigComponent 体现了此问题。往回看,你可以看到 Alex 组件扩展(继承)了基类 Base。

Path:"parent-finder.component.ts (Alex class signature)" 。

export class AlexComponent extends Base

CraigComponent 试图把 Base 注入到它的构造函数参数 alex 中,并汇报这次注入是否成功了。

Path:"parent-finder.component.ts (CraigComponent)" 。

@Component({  selector: 'craig',  template: `  <div class="c">    <h3>Craig</h3>    {{alex ? 'Found' : 'Did not find'}} Alex via the base class.  </div>`})export class CraigComponent {  constructor( @Optional() public alex?: Base ) { }}

不幸的是,这不行! 范例确认了 alex 参数为空。 因此,你不能通过父组件的基类注入它。

根据父组件的类接口查找它

你可以通过父组件的类接口来查找它。

该父组件必须合作,以类接口令牌为名,为自己定义一个别名提供者。

回忆一下,Angular 总是会把组件实例添加到它自己的注入器中,因此以前你才能把 Alex 注入到 Cathy 中。

编写一个 别名提供者(一个 provide 对象字面量,其中有一个 useExisting 定义),创造了另一种方式来注入同一个组件实例,并把那个提供者添加到 AlexComponent @Component() 元数据的 providers 数组中。

Path:"parent-finder.component.ts (AlexComponent providers)" 。

providers: [{ provide: Parent, useExisting: forwardRef(() => AlexComponent) }],

Parent 是该提供者的类接口。 forwardRef 用于打破循环引用,因为在你刚才这个定义中 AlexComponent 引用了自身。

Alex 的第三个子组件 Carol,把其父组件注入到了自己的 parent 参数中 —— 和你以前做过的一样。

Path:"parent-finder.component.ts (CarolComponent class)" 。

export class CarolComponent {  name = 'Carol';  constructor( @Optional() public parent?: Parent ) { }}

下面是 Alex 及其家人的运行效果。

使用 @SkipSelf() 在树中查找父组件

想象一下组件树的一个分支:Alice -> Barry -> Carol。 无论 Alice 还是 Barry 都实现了类接口 Parent

Barry 很为难。他需要访问他的母亲 Alice,同时他自己还是 Carol 的父亲。 这意味着他必须同时注入 Parent 类接口来找到 Alice,同时还要提供一个 Parent 来满足 Carol 的要求。

Barry 的代码如下。

Path:"parent-finder.component.ts (BarryComponent)" 。

const templateB = `  <div class="b">    <div>      <h3>{{name}}</h3>      <p>My parent is {{parent?.name}}</p>    </div>    <carol></carol>    <chris></chris>  </div>`;@Component({  selector:   'barry',  template:   templateB,  providers:  [{ provide: Parent, useExisting: forwardRef(() => BarryComponent) }]})export class BarryComponent implements Parent {  name = 'Barry';  constructor( @SkipSelf() @Optional() public parent?: Parent ) { }}

Barryproviders 数组看起来和 Alex 的一样。 如果你准备继续像这样编写别名提供者,就应该创建一个辅助函数。

现在,注意看 Barry 的构造函数。

//Barry's constructorconstructor( @SkipSelf() @Optional() public parent?: Parent ) { }

//Carol's constructorconstructor( @Optional() public parent?: Parent ) { }

除增加了 @SkipSelf 装饰器之外,它和 Carol 的构造函数相同。

使用 @SkipSelf 有两个重要原因:

它告诉注入器开始从组件树中高于自己的位置(也就是父组件)开始搜索 Parent 依赖。

如果你省略了 @SkipSelf 装饰器,Angular 就会抛出循环依赖错误。

Cannot instantiate cyclic dependency! (BethComponent -> Parent -> BethComponent)

下面是 Alice、Barry 及其家人的运行效果。

父类接口

类接口是一个抽象类,它实际上用做接口而不是基类。

下面的例子定义了一个类接口 Parent。

Path:"parent-finder.component.ts (Parent class-interface)" 。

export abstract class Parent { name: string; }

Parent 类接口定义了一个带类型的 name 属性,但没有实现它。 这个 name 属性是父组件中唯一可供子组件调用的成员。 这样的窄化接口帮助把子组件从它的父组件中解耦出来。

一个组件想要作为父组件使用,就应该像 AliceComponent 那样实现这个类接口。

Path:"parent-finder.component.ts (AliceComponent class signature)" 。

export class AliceComponent implements Parent

这样做可以增加代码的清晰度,但在技术上并不是必要的。 虽然 AlexComponentBase 类所要求的一样具有 name 属性,但它的类签名中并没有提及 Parent

Path:"parent-finder.component.ts (AlexComponent class signature)" 。

export class AlexComponent extends Base

provideParent() 辅助函数

你很快就会厌倦为同一个父组件编写别名提供者的变体形式,特别是带有 forwardRef 的那种。

Path:"dependency-injection-in-action/src/app/parent-finder.component.ts" 。

providers: [{ provide: Parent, useExisting: forwardRef(() => AlexComponent) }],

你可以像把这些逻辑抽取到辅助函数中,就像这样。

Path:"dependency-injection-in-action/src/app/parent-finder.component.ts" 。

// Helper method to provide the current component instance in the name of a `parentType`.export function provideParent  (component: any) {    return { provide: Parent, useExisting: forwardRef(() => component) };  }

现在,你可以为组件添加一个更简单、更有意义的父组件提供者。

Path:"dependency-injection-in-action/src/app/parent-finder.component.ts" 。

providers:  [ provideParent(AliceComponent) ]

你还可以做得更好。当前版本的辅助函数只能为类接口 Parent 定义别名。 应用可能具有多种父组件类型,每个父组件都有自己的类接口令牌。

这是一个修订后的版本,它默认为 parent,但是也能接受另一个父类接口作为可选的第二参数。

Path:"dependency-injection-in-action/src/app/parent-finder.component.ts" 。

// Helper method to provide the current component instance in the name of a `parentType`.// The `parentType` defaults to `Parent` when omitting the second parameter.export function provideParent  (component: any, parentType?: any) {    return { provide: parentType || Parent, useExisting: forwardRef(() => component) };  }

下面是针对不同父组件类型的用法。

Path:"dependency-injection-in-action/src/app/parent-finder.component.ts" 。

providers:  [ provideParent(BethComponent, DifferentParent) ]

大多数前端应用都要通过 HTTP 协议与服务器通讯,才能下载或上传数据并访问其它后端服务。Angular 给应用提供了一个简化的 HTTP 客户端 API,也就是 @angular/common/http 中的 HttpClient 服务类。

HTTP 客户端服务提供了以下主要功能。

  • 请求类型化响应对象的能力。

  • 简化的错误处理。

  • 各种特性的可测试性。

  • 请求和响应的拦截机制。

先决条件

在使用 HTTPClientModule 之前,你应该对下列内容有基本的了解:

  • TypeScript 编程

  • HTTP 协议的用法

要想使用 HttpClient,就要先导入 Angular 的 HttpClientModule。大多数应用都会在根模块 AppModule 中导入它。

Path:"app/app.module.ts (excerpt)" 。

import { NgModule }         from '@angular/core';import { BrowserModule }    from '@angular/platform-browser';import { HttpClientModule } from '@angular/common/http';@NgModule({  imports: [    BrowserModule,    // import HttpClientModule after BrowserModule.    HttpClientModule,  ],  declarations: [    AppComponent,  ],  bootstrap: [ AppComponent ]})export class AppModule {}

然后,你可以把 HttpClient 服务注入成一个应用类的依赖项,如下面的 ConfigService 例子所示。

Path:"app/config/config.service.ts (excerpt)" 。

import { Injectable } from '@angular/core';import { HttpClient } from '@angular/common/http';@Injectable()export class ConfigService {  constructor(private http: HttpClient) { }}

HttpClient 服务为所有工作都使用了可观察对象。你必须导入示例代码片段中出现的 RxJS 可观察对象和操作符。比如 ConfigService 中的这些导入就很典型。

Path:"app/config/config.service.ts (RxJS imports)" 。

import { Observable, throwError } from 'rxjs';import { catchError, retry } from 'rxjs/operators';

注:

  • 该实例应用不需要数据服务器。它依赖于 Angular in-memory-web-api,它替代了 HttpClient 模块中的 HttpBackend。这个替代服务会模拟 REST 式的后端的行为。

使用 HTTPClient.get() 方法从服务器获取数据。该异步方法会发送一个 HTTP 请求,并返回一个 Observable,它会在收到响应时发出所请求到的数据。返回的类型取决于你调用时传入的 observeresponseType 参数。

get() 方法有两个参数。要获取的端点 URL,以及一个可以用来配置请求的选项对象。

options: {    headers?: HttpHeaders | {[header: string]: string | string[]},    observe?: 'body' | 'events' | 'response',    params?: HttpParams|{[param: string]: string | string[]},    reportProgress?: boolean,    responseType?: 'arraybuffer'|'blob'|'json'|'text',    withCredentials?: boolean,  }

这些重要的选项包括 observeresponseType 属性。

  • observe 选项用于指定要返回的响应内容。

  • responseType 选项指定返回数据的格式。

你可以使用 options 对象来配置传出请求的各个方面。例如,在 Adding headers 中,该服务使用 headers 选项属性设置默认头。

使用 params 属性可以配置带 HTTP URL 参数的请求, reportProgress 选项可以在传输大量数据时监听进度事件。

应用经常会从服务器请求 JSON 数据。在 ConfigService 例子中,该应用需要服务器 "config.json" 上的一个配置文件来指定资源的 URL

Path:"assets/config.json" 。

{  "heroesUrl": "api/heroes",  "textfile": "assets/textfile.txt"}

要获取这类数据,get() 调用需要以下几个选项: {observe: 'body', responseType: 'json'}。这些是这些选项的默认值,所以下面的例子不会传递 options 对象。后面几节展示了一些额外的选项。

这个例子符合通过定义一个可重用的可注入服务来执行数据处理功能来创建可伸缩解决方案的最佳实践。除了提取数据外,该服务还可以对数据进行后处理,添加错误处理,并添加重试逻辑。

ConfigService 使用 HttpClient.get() 方法获取这个文件。

Path:"app/config/config.service.ts (getConfig v.1)" 。

configUrl = 'assets/config.json';getConfig() {  return this.http.get(this.configUrl);}

ConfigComponent 注入了 ConfigService 并调用了 getConfig 服务方法。

由于该服务方法返回了一个 Observable 配置数据,该组件会订阅该方法的返回值。订阅回调只会对后处理进行最少量的处理。它会把数据字段复制到组件的 config 对象中,该对象在组件模板中是数据绑定的,用于显示。

Path:"app/config/config.component.ts (showConfig v.1)" 。

showConfig() {  this.configService.getConfig()    .subscribe((data: Config) => this.config = {        heroesUrl: data['heroesUrl'],        textfile:  data['textfile']    });}

请求输入一个类型的响应

你可以构造自己的 HttpClient 请求来声明响应对象的类型,以便让输出更容易、更明确。所指定的响应类型会在编译时充当类型断言。

注:

  • 指定响应类型是在向 TypeScript 声明,它应该把你的响应对象当做给定类型来使用。这是一种构建期检查,它并不能保证服务器会实际给出这种类型的响应对象。该服务器需要自己确保返回服务器 API 中指定的类型。

要指定响应对象类型,首先要定义一个具有必需属性的接口。这里要使用接口而不是类,因为响应对象是普通对象,无法自动转换成类的实例。

export interface Config {  heroesUrl: string;  textfile: string;}

接下来,在服务器中把该接口指定为 HttpClient.get() 调用的类型参数。

Path:"app/config/config.service.ts (getConfig v.2)" 。

getConfig() {  // now returns an Observable of Config  return this.http.get<Config>(this.configUrl);}

当把接口作为类型参数传给 HttpClient.get() 方法时,你可以使用RxJS map 操作符来根据 UI 的需求转换响应数据。然后,把转换后的数据传给异步管道。

修改后的组件方法,其回调函数中获取一个带类型的对象,它易于使用,且消费起来更安全:

Path:"app/config/config.component.ts (showConfig v.2)" 。

config: Config;showConfig() {  this.configService.getConfig()    // clone the data object, using its known Config shape    .subscribe((data: Config) => this.config = { ...data });}

要访问接口中定义的属性,必须将从 JSON 获得的普通对象显式转换为所需的响应类型。例如,以下 subscribe 回调会将 data 作为对象接收,然后进行类型转换以访问属性。

.subscribe(data => this.config = {  heroesUrl: (data as any).heroesUrl,  textfile:  (data as any).textfile,});

OBSERVERESPONSE 的类型是字符串的联合类型,而不是普通的字符串。

options: {    ...    observe?: 'body' | 'events' | 'response',    ...    responseType?: 'arraybuffer'|'blob'|'json'|'text',    ...  }

这会引起混乱。例如:

// this worksclient.get('/foo', {responseType: 'text'})// but this does NOT workconst options = {  responseType: 'text',};client.get('/foo', options)

在第二种情况下,TypeScript 会把 options 的类型推断为 {responseType: string}。该类型的 HttpClient.get 太宽泛,无法传递给 HttpClient.get,它希望 responseType 的类型是特定的字符串之一。而 HttpClient 就是以这种方式显式输入的,因此编译器可以根据你提供的选项报告正确的返回类型。

使用 as const,可以让 TypeScript 知道你并不是真的要使用字面字符串类型:

const options = {  responseType: 'text' as const,};client.get('/foo', options);

读取完整的响应体

在前面的例子中,对 HttpClient.get() 的调用没有指定任何选项。默认情况下,它返回了响应体中包含的 JSON 数据。

你可能还需要关于这次对话的更多信息。比如,有时候服务器会返回一个特殊的响应头或状态码,来指出某些在应用的工作流程中很重要的条件。

可以用 get() 方法的 observe 选项来告诉 HttpClient,你想要完整的响应对象:

getConfigResponse(): Observable<HttpResponse<Config>> {  return this.http.get<Config>(    this.configUrl, { observe: 'response' });}

现在,HttpClient.get() 会返回一个 HttpResponse 类型的 Observable,而不只是 JSON 数据。

该组件的 showConfigResponse() 方法会像显示配置数据一样显示响应头:

Path:"app/config/config.component.ts (showConfigResponse)" 。

showConfigResponse() {  this.configService.getConfigResponse()    // resp is of type `HttpResponse<Config>`    .subscribe(resp => {      // display its headers      const keys = resp.headers.keys();      this.headers = keys.map(key =>        `${key}: ${resp.headers.get(key)}`);      // access the body directly, which is typed as `Config`.      this.config = { ... resp.body };    });}

注:

  • 该响应对象具有一个带有正确类型的 body 属性。

发起 JSONP 请求

当服务器不支持 CORS 协议时,应用程序可以使用 HttpClient 跨域发出 JSONP 请求。

Angular 的 JSONP 请求会返回一个 Observable。 遵循订阅可观察对象变量的模式,并在使用async 管道管理结果之前,使用 RxJS map 操作符转换响应。

在 Angular 中,通过在 NgModuleimports 中包含 HttpClientJsonpModule 来使用 JSONP。在以下示例中,searchHeroes() 方法使用 JSONP 请求来查询名称包含搜索词的英雄。

/* GET heroes whose name contains search term */searchHeroes(term: string): Observable {  term = term.trim();  let heroesURL = `${this.heroesURL}?${term}`;  return this.http.jsonp(heroesUrl, 'callback').pipe(      catchError(this.handleError('searchHeroes', [])) // then handle the error    );};

该请求将 heroesURL 作为第一个参数,并将回调函数名称作为第二个参数。响应被包装在回调函数中,该函数接受 JSONP 方法返回的可观察对象,并将它们通过管道传给错误处理程序。

请求非 JSON 数据

不是所有的 API 都会返回 JSON 数据。在下面这个例子中,DownloaderService 中的方法会从服务器读取文本文件, 并把文件的内容记录下来,然后把这些内容使用 Observable<string> 的形式返回给调用者。

Path:"app/downloader/downloader.service.ts (getTextFile)" 。

getTextFile(filename: string) {  // The Observable returned by get() is of type Observable<string>  // because a text response was specified.  // There's no need to pass a <string> type parameter to get().  return this.http.get(filename, {responseType: 'text'})    .pipe(      tap( // Log the result or error        data => this.log(filename, data),        error => this.logError(filename, error)      )    );}

这里的 HttpClient.get() 返回字符串而不是默认的 JSON 对象,因为它的 responseType 选项是 'text'

RxJS 的 tap 操作符(如“窃听”中所述)使代码可以检查通过可观察对象的成功值和错误值,而不会干扰它们。

DownloaderComponent 中的 download() 方法通过订阅这个服务中的方法来发起一次请求。

Path:"app/downloader/downloader.component.ts (download)" 。

download() {  this.downloaderService.getTextFile('assets/textfile.txt')    .subscribe(results => this.contents = results);}

如果请求在服务器上失败了,那么 HttpClient 就会返回一个错误对象而不是一个成功的响应对象。

执行服务器请求的同一个服务中也应该执行错误检查、解释和解析。

发生错误时,你可以获取失败的详细信息,以便通知你的用户。在某些情况下,你也可以自动重试该请求。

获取错误详情

当数据访问失败时,应用会给用户提供有用的反馈。原始的错误对象作为反馈并不是特别有用。除了检测到错误已经发生之外,还需要获取错误详细信息并使用这些细节来撰写用户友好的响应。

可能会出现两种类型的错误。

服务器端可能会拒绝该请求,并返回状态码为 404500 的 HTTP 响应。这些是错误响应。

客户端也可能出现问题,例如网络错误会让请求无法成功完成,或者 RxJS 操作符也会抛出异常。这些错误会产生 JavaScript 的 ErrorEvent 对象。

HttpClient 在其 HttpErrorResponse 中会捕获两种错误。你可以检查一下这个响应是否存在错误。

下面的例子在之前定义的 ConfigService 中定义了一个错误处理程序。

Path:"app/config/config.service.ts (handleError)" 。

private handleError(error: HttpErrorResponse) {  if (error.error instanceof ErrorEvent) {    // A client-side or network error occurred. Handle it accordingly.    console.error('An error occurred:', error.error.message);  } else {    // The backend returned an unsuccessful response code.    // The response body may contain clues as to what went wrong,    console.error(      `Backend returned code ${error.status}, ` +      `body was: ${error.error}`);  }  // return an observable with a user-facing error message  return throwError(    'Something bad happened; please try again later.');};

该处理程序会返回一个带有用户友好的错误信息的 RxJS ErrorObservable。下列代码修改了 getConfig() 方法,它使用一个管道把 HttpClient.get() 调用返回的所有 Observable 发送给错误处理器。

Path:"app/config/config.service.ts (getConfig v.3 with error handler)" 。

getConfig() {  return this.http.get<Config>(this.configUrl)    .pipe(      catchError(this.handleError)    );}

重试失败的请求

有时候,错误只是临时性的,只要重试就可能会自动消失。 比如,在移动端场景中可能会遇到网络中断的情况,只要重试一下就能拿到正确的结果。

RxJS 库提供了几个重试操作符。例如,retry() 操作符会自动重新订阅一个失败的 Observable 几次。重新订阅 HttpClient 方法会导致它重新发出 HTTP 请求。

下面的例子演示了如何在把一个失败的请求传给错误处理程序之前,先通过管道传给 retry() 操作符。

Path:"app/config/config.service.ts (getConfig with retry)" 。

getConfig() {  return this.http.get<Config>(this.configUrl)    .pipe(      retry(3), // retry a failed request up to 3 times      catchError(this.handleError) // then handle the error    );}

除了从服务器获取数据外,HttpClient 还支持其它一些 HTTP 方法,比如 PUTPOSTDELETE,你可以用它们来修改远程数据。

本指南中的这个范例应用包括一个简化版本的《英雄指南》,它会获取英雄数据,并允许用户添加、删除和修改它们。 下面几节在 HeroesService 范例中展示了数据更新方法的一些例子。

发起一个 POST 请求

应用经常在提交表单时通过 POST 请求向服务器发送数据。 下面这个例子中,HeroesService 在向数据库添加英雄时发起了一个 HTTP POST 请求。

Path:"app/heroes/heroes.service.ts (addHero)" 。

/** POST: add a new hero to the database */addHero (hero: Hero): Observable<Hero> {  return this.http.post<Hero>(this.heroesUrl, hero, httpOptions)    .pipe(      catchError(this.handleError('addHero', hero))    );}

HttpClient.post() 方法像 get() 一样也有类型参数,可以用它来指出你期望服务器返回特定类型的数据。该方法需要一个资源 URL 和两个额外的参数:

  • body - 要在请求体中 POST 过去的数据。

  • options - 一个包含方法选项的对象,在这里,它用来指定必要的请求头。

该例子捕获了前面所指的错误。

HeroesComponent 通过订阅该服务方法返回的 Observable 发起了一次实际的 POST 操作。

Path:"app/heroes/heroes.component.ts (addHero)" 。

this.heroesService  .addHero(newHero)  .subscribe(hero => this.heroes.push(hero));

当服务器成功做出响应时,会带有这个新创建的英雄,然后该组件就会把这个英雄添加到正在显示的 heroes 列表中。

发起 DELETE 请求

该应用可以把英雄的 id 传给 HttpClient.delete 方法的请求 URL 来删除一个英雄。

Path:"app/heroes/heroes.service.ts (deleteHero)" 。

/** DELETE: delete the hero from the server */deleteHero (id: number): Observable<{}> {  const url = `${this.heroesUrl}/${id}`; // DELETE api/heroes/42  return this.http.delete(url, httpOptions)    .pipe(      catchError(this.handleError('deleteHero'))    );}

HeroesComponent 订阅了该服务方法返回的 Observable 时,就会发起一次实际的 DELETE 操作。

Path:"app/heroes/heroes.component.ts (deleteHero)" 。

this.heroesService  .deleteHero(hero.id)  .subscribe();

该组件不会等待删除操作的结果,所以它的 subscribe (订阅)中没有回调函数。不过就算你不关心结果,也仍然要订阅它。调用 subscribe() 方法会执行这个可观察对象,这时才会真的发起 DELETE 请求。

注:

  • 你必须调用 subscribe(),否则什么都不会发生。仅仅调用 HeroesService.deleteHero() 是不会发起 DELETE 请求的。

// oops ... subscribe() is missing so nothing happens    this.heroesService.deleteHero(hero.id);

在调用方法返回的可观察对象的 subscribe() 方法之前,HttpClient 方法不会发起 HTTP 请求。这适用于 HttpClient 的所有方法。

AsyncPipe 会自动为你订阅(以及取消订阅)。

HttpClient 的所有方法返回的可观察对象都设计为冷的。 HTTP 请求的执行都是延期执行的,让你可以用 tapcatchError 这样的操作符来在实际执行 HTTP 请求之前,先对这个可观察对象进行扩展。

调用 subscribe(...) 会触发这个可观察对象的执行,并导致HttpClient` 组合并把 HTTP 请求发给服务器。

你可以把这些可观察对象看做实际 HTTP 请求的蓝图。

实际上,每个 subscribe() 都会初始化此可观察对象的一次单独的、独立的执行。 订阅两次就会导致发起两个 HTTP 请求。

<br>const req = http.get<Heroes&('/api/heroes');<br>// 0 requests made - .subscribe() not called.<br>req.subscribe();<br>// 1 request made.<br>req.subscribe();<br>// 2 requests made.<br>```

发起 PUT 请求

应用可以使用 HttpClient 服务发送 PUT 请求。下面的 HeroesService 示例(就像 POST 示例一样)用一个修改过的数据替换了该资源。

Path:"app/heroes/heroes.service.ts (updateHero)" 。

/** PUT: update the hero on the server. Returns the updated hero upon success. */updateHero (hero: Hero): Observable<Hero> {  return this.http.put<Hero>(this.heroesUrl, hero, httpOptions)    .pipe(      catchError(this.handleError('updateHero', hero))    );}

对于所有返回可观察对象的 HTTP 方法,调用者(HeroesComponent.update())必须 subscribe()HttpClient.put() 返回的可观察对象,才会真的发起请求。

添加和更新请求头

很多服务器都需要额外的头来执行保存操作。 例如,服务器可能需要一个授权令牌,或者需要 Content-Type 头来显式声明请求体的 MIME 类型。

  1. 添加请求头。

HeroesService 在一个 httpOptions 对象中定义了这样的头,它们被传递给每个 HttpClient 的保存型方法。

Path:"app/heroes/heroes.service.ts (httpOptions)" 。

    import { HttpHeaders } from '@angular/common/http';    const httpOptions = {      headers: new HttpHeaders({        'Content-Type':  'application/json',        'Authorization': 'my-auth-token'      })    };

  1. 更新请求头。

你不能直接修改前面的选项对象中的 HttpHeaders 请求头,因为 HttpHeaders 类的实例是不可变对象。请改用 set() 方法,以返回当前实例应用了新更改之后的副本。

下面的例子演示了当旧令牌过期时,可以在发起下一个请求之前更新授权头。

    httpOptions.headers =      httpOptions.headers.set('Authorization', 'my-new-auth-token');

使用 HttpParams 类和 params 选项在你的 HttpRequest 中添加 URL 查询字符串。

下面的例子中,searchHeroes() 方法用于查询名字中包含搜索词的英雄。

首先导入 HttpParams 类。

import {HttpParams} from "@angular/common/http";

/* GET heroes whose name contains search term */searchHeroes(term: string): Observable<Hero[]> {  term = term.trim();  // Add safe, URL encoded search parameter if there is a search term  const options = term ?   { params: new HttpParams().set('name', term) } : {};  return this.http.get<Hero[]>(this.heroesUrl, options)    .pipe(      catchError(this.handleError<Hero[]>('searchHeroes', []))    );}

如果有搜索词,代码会用进行过 URL 编码的搜索参数来构造一个 options 对象。例如,如果搜索词是 "cat",那么 GET 请求的 URL 就是 api/heroes?name=cat

HttpParams 是不可变对象。如果需要更新选项,请保留 .set() 方法的返回值。

你也可以使用 fromString 变量从查询字符串中直接创建 HTTP 参数:

const params = new HttpParams({fromString: 'name=foo'});

借助拦截机制,你可以声明一些拦截器,它们可以检查并转换从应用中发给服务器的 HTTP 请求。这些拦截器还可以在返回应用的途中检查和转换来自服务器的响应。多个拦截器构成了请求/响应处理器的双向链表。

拦截器可以用一种常规的、标准的方式对每一次 HTTP 的请求/响应任务执行从认证到记日志等很多种隐式任务。

如果没有拦截机制,那么开发人员将不得不对每次 HttpClient 调用显式实现这些任务。

编写拦截器

要实现拦截器,就要实现一个实现了 HttpInterceptor 接口中的 intercept() 方法的类。

这里是一个什么也不做的空白拦截器,它只会不做任何修改的传递这个请求。

Path:"app/http-interceptors/noop-interceptor.ts" 。

import { Injectable } from '@angular/core';import {  HttpEvent, HttpInterceptor, HttpHandler, HttpRequest} from '@angular/common/http';import { Observable } from 'rxjs';/** Pass untouched request through to the next request handler. */@Injectable()export class NoopInterceptor implements HttpInterceptor {  intercept(req: HttpRequest<any>, next: HttpHandler):    Observable<HttpEvent<any>> {    return next.handle(req);  }}

intercept 方法会把请求转换成一个最终返回 HTTP 响应体的 Observable。 在这个场景中,每个拦截器都完全能自己处理这个请求。

大多数拦截器拦截都会在传入时检查请求,然后把(可能被修改过的)请求转发给 next 对象的 handle() 方法,而 next 对象实现了 HttpHandler 接口。

export abstract class HttpHandler {  abstract handle(req: HttpRequest<any>): Observable<HttpEvent<any>>;}

intercept() 一样,handle() 方法也会把 HTTP 请求转换成 HttpEvents 组成的 Observable,它最终包含的是来自服务器的响应。 intercept() 函数可以检查这个可观察对象,并在把它返回给调用者之前修改它。

这个无操作的拦截器,会直接使用原始的请求调用 next.handle(),并返回它返回的可观察对象,而不做任何后续处理。

next 对象

next 对象表示拦截器链表中的下一个拦截器。 这个链表中的最后一个 next 对象就是 HttpClient 的后端处理器(backend handler),它会把请求发给服务器,并接收服务器的响应。

大多数的拦截器都会调用 next.handle(),以便这个请求流能走到下一个拦截器,并最终传给后端处理器。 拦截器也可以不调用 next.handle(),使这个链路短路,并返回一个带有人工构造出来的服务器响应的 自己的 Observable

这是一种常见的中间件模式,在像 "Express.js" 这样的框架中也会找到它。

提供这个拦截器

这个 NoopInterceptor 就是一个由 Angular 依赖注入 (DI)系统管理的服务。 像其它服务一样,你也必须先提供这个拦截器类,应用才能使用它。

由于拦截器是 HttpClient 服务的(可选)依赖,所以你必须在提供 HttpClient 的同一个(或其各级父注入器)注入器中提供这些拦截器。 那些在 DI 创建完 HttpClient 之后再提供的拦截器将会被忽略。

由于在 AppModule 中导入了 HttpClientModule,导致本应用在其根注入器中提供了 HttpClient。所以你也同样要在 AppModule 中提供这些拦截器。

在从 @angular/common/http 中导入了 HTTP_INTERCEPTORS 注入令牌之后,编写如下的 NoopInterceptor 提供者注册语句:

{ provide: HTTP_INTERCEPTORS, useClass: NoopInterceptor, multi: true },

注意 multi: true 选项。 这个必须的选项会告诉 Angular HTTP_INTERCEPTORS 是一个多重提供者的令牌,表示它会注入一个多值的数组,而不是单一的值。

你也可以直接把这个提供者添加到 AppModule 中的提供者数组中,不过那样会非常啰嗦。况且,你将来还会用这种方式创建更多的拦截器并提供它们。 你还要特别注意提供这些拦截器的顺序。

认真考虑创建一个封装桶(barrel)文件,用于把所有拦截器都收集起来,一起提供给 httpInterceptorProviders 数组,可以先从这个 NoopInterceptor 开始。

Path:"app/http-interceptors/index.ts" 。

/* "Barrel" of Http Interceptors */import { HTTP_INTERCEPTORS } from '@angular/common/http';import { NoopInterceptor } from './noop-interceptor';/** Http interceptor providers in outside-in order */export const httpInterceptorProviders = [  { provide: HTTP_INTERCEPTORS, useClass: NoopInterceptor, multi: true },];

然后导入它,并把它加到 AppModuleproviders 数组中,就像这样:

Path:"app/app.module.ts (interceptor providers)" 。

providers: [  httpInterceptorProviders],

当你再创建新的拦截器时,就同样把它们添加到 httpInterceptorProviders 数组中,而不用再修改 AppModule

拦截器的顺序

Angular 会按照你提供它们的顺序应用这些拦截器。 如果你提供拦截器的顺序是先 A,再 B,再 C,那么请求阶段的执行顺序就是 A->B->C,而响应阶段的执行顺序则是 C->B->A。

以后你就再也不能修改这些顺序或移除某些拦截器了。 如果你需要动态启用或禁用某个拦截器,那就要在那个拦截器中自行实现这个功能。

处理拦截器事件

大多数 HttpClient 方法都会返回 HttpResponse<any> 型的可观察对象。HttpResponse 类本身就是一个事件,它的类型是 HttpEventType.Response。但是,单个 HTTP 请求可以生成其它类型的多个事件,包括报告上传和下载进度的事件。HttpInterceptor.intercept()HttpHandler.handle() 会返回 HttpEvent<any> 型的可观察对象。

很多拦截器只关心发出的请求,而对 next.handle() 返回的事件流不会做任何修改。 但是,有些拦截器需要检查并修改 next.handle() 的响应。上述做法就可以在流中看到所有这些事件。

虽然拦截器有能力改变请求和响应,但 HttpRequestHttpResponse 实例的属性却是只读(readonly)的, 因此让它们基本上是不可变的。

有充足的理由把它们做成不可变对象:应用可能会重试发送很多次请求之后才能成功,这就意味着这个拦截器链表可能会多次重复处理同一个请求。 如果拦截器可以修改原始的请求对象,那么重试阶段的操作就会从修改过的请求开始,而不是原始请求。 而这种不可变性,可以确保这些拦截器在每次重试时看到的都是同样的原始请求。

你的拦截器应该在没有任何修改的情况下返回每一个事件,除非它有令人信服的理由去做。

TypeScript 会阻止你设置 HttpRequest 的只读属性。

// Typescript disallows the following assignment because req.url is readonlyreq.url = req.url.replace('http://', 'https://');

如果你必须修改一个请求,先把它克隆一份,修改这个克隆体后再把它传给 next.handle()。你可以在一步中克隆并修改此请求,例子如下。

Path:"app/http-interceptors/ensure-https-interceptor.ts (excerpt)" 。

// clone request and replace 'http://' with 'https://' at the same timeconst secureReq = req.clone({  url: req.url.replace('http://', 'https://')});// send the cloned, "secure" request to the next handler.return next.handle(secureReq);

这个 clone() 方法的哈希型参数允许你在复制出克隆体的同时改变该请求的某些特定属性。

  1. 修改请求体。

readonly 这种赋值保护,无法防范深修改(修改子对象的属性),也不能防范你修改请求体对象中的属性。

    req.body.name = req.body.name.trim(); // bad idea!

如果必须修改请求体,请执行以下步骤。

  • 复制请求体并在副本中进行修改。

  • 使用 clone() 方法克隆这个请求对象。

  • 用修改过的副本替换被克隆的请求体。

    // copy the body and trim whitespace from the name property    const newBody = { ...body, name: body.name.trim() };    // clone request and set its body    const newReq = req.clone({ body: newBody });    // send the cloned request to the next handler.    return next.handle(newReq);

  1. 克隆时清除请求体。

有时,你需要清除请求体而不是替换它。为此,请将克隆后的请求体设置为 null

注:

  • 如果你把克隆后的请求体设为 undefined,那么 Angular 会认为你想让请求体保持原样。

    newReq = req.clone({ ... }); // body not mentioned => preserve original body    newReq = req.clone({ body: undefined }); // preserve original body    newReq = req.clone({ body: null }); // clear the body

设置默认请求头

应用通常会使用拦截器来设置外发请求的默认请求头。

该范例应用具有一个 AuthService,它会生成一个认证令牌。 在这里,AuthInterceptor 会注入该服务以获取令牌,并对每一个外发的请求添加一个带有该令牌的认证头:

Path:"app/http-interceptors/auth-interceptor.ts" 。

import { AuthService } from '../auth.service';@Injectable()export class AuthInterceptor implements HttpInterceptor {  constructor(private auth: AuthService) {}  intercept(req: HttpRequest<any>, next: HttpHandler) {    // Get the auth token from the service.    const authToken = this.auth.getAuthorizationToken();    // Clone the request and replace the original headers with    // cloned headers, updated with the authorization.    const authReq = req.clone({      headers: req.headers.set('Authorization', authToken)    });    // send cloned request with header to the next handler.    return next.handle(authReq);  }}

这种在克隆请求的同时设置新请求头的操作太常见了,因此它还有一个快捷方式 setHeaders

// Clone the request and set the new header in one step.const authReq = req.clone({ setHeaders: { Authorization: authToken } });

这种可以修改头的拦截器可以用于很多不同的操作,比如:

  • 认证 / 授权

  • 控制缓存行为。比如 If-Modified-Since

  • XSRF 防护

用拦截器记日志

因为拦截器可以同时处理请求和响应,所以它们也可以对整个 HTTP 操作执行计时和记录日志等任务。

考虑下面这个 LoggingInterceptor,它捕获请求的发起时间、响应的接收时间,并使用注入的 MessageService 来发送总共花费的时间。

Path:"app/http-interceptors/logging-interceptor.ts)" 。

import { finalize, tap } from 'rxjs/operators';import { MessageService } from '../message.service';@Injectable()export class LoggingInterceptor implements HttpInterceptor {  constructor(private messenger: MessageService) {}  intercept(req: HttpRequest<any>, next: HttpHandler) {    const started = Date.now();    let ok: string;    // extend server response observable with logging    return next.handle(req)      .pipe(        tap(          // Succeeds when there is a response; ignore other events          event => ok = event instanceof HttpResponse ? 'succeeded' : '',          // Operation failed; error is an HttpErrorResponse          error => ok = 'failed'        ),        // Log when response observable either completes or errors        finalize(() => {          const elapsed = Date.now() - started;          const msg = `${req.method} "${req.urlWithParams}"             ${ok} in ${elapsed} ms.`;          this.messenger.add(msg);        })      );  }}

RxJS 的 tap 操作符会捕获请求成功了还是失败了。 RxJS 的 finalize 操作符无论在响应成功还是失败时都会调用(这是必须的),然后把结果汇报给 MessageService

在这个可观察对象的流中,无论是 tap 还是 finalize 接触过的值,都会照常发送给调用者。

用拦截器实现缓存

拦截器还可以自行处理这些请求,而不用转发给 next.handle()

比如,你可能会想缓存某些请求和响应,以便提升性能。 你可以把这种缓存操作委托给某个拦截器,而不破坏你现有的各个数据服务。

下例中的 CachingInterceptor 演示了这种方法。

Path:"app/http-interceptors/caching-interceptor.ts)" 。

@Injectable()export class CachingInterceptor implements HttpInterceptor {  constructor(private cache: RequestCache) {}  intercept(req: HttpRequest<any>, next: HttpHandler) {    // continue if not cacheable.    if (!isCacheable(req)) { return next.handle(req); }    const cachedResponse = this.cache.get(req);    return cachedResponse ?      of(cachedResponse) : sendRequest(req, next, this.cache);  }}

  • isCacheable() 函数用于决定该请求是否允许缓存。 在这个例子中,只有发到 npm 包搜索 APIGET 请求才是可以缓存的。

  • 如果该请求是不可缓存的,该拦截器只会把该请求转发给链表中的下一个处理器。

  • 如果可缓存的请求在缓存中找到了,该拦截器就会通过 of() 函数返回一个已缓存的响应体的可观察对象,然后绕过 next 处理器(以及所有其它下游拦截器)。

  • 如果可缓存的请求不在缓存中,代码会调用 sendRequest()。这个函数会创建一个没有请求头的请求克隆体,这是因为 npm API 禁止它们。然后,该函数把请求的克隆体转发给 next.handle(),它会最终调用服务器并返回来自服务器的响应对象。

/** * Get server response observable by sending request to `next()`. * Will add the response to the cache on the way out. */function sendRequest(  req: HttpRequest<any>,  next: HttpHandler,  cache: RequestCache): Observable<HttpEvent<any>> {  // No headers allowed in npm search request  const noHeaderReq = req.clone({ headers: new HttpHeaders() });  return next.handle(noHeaderReq).pipe(    tap(event => {      // There may be other events besides the response.      if (event instanceof HttpResponse) {        cache.put(req, event); // Update the cache.      }    })  );}

注意 sendRequest() 是如何在返回应用程序的过程中拦截响应的。该方法通过 tap() 操作符来管理响应对象,该操作符的回调函数会把该响应对象添加到缓存中。

然后,原始的响应会通过这些拦截器链,原封不动的回到服务器的调用者那里。

数据服务,比如 PackageSearchService,并不知道它们收到的某些 HttpClient 请求实际上是从缓存的请求中返回来的。

用拦截器来请求多个值

HttpClient.get() 方法通常会返回一个可观察对象,它会发出一个值(数据或错误)。拦截器可以把它改成一个可以发出多个值的可观察对象。

修改后的 CachingInterceptor 版本可以返回一个立即发出所缓存响应的可观察对象,然后把请求发送到 NPMWeb API,然后把修改过的搜索结果重新发出一次。

// cache-then-refreshif (req.headers.get('x-refresh')) {  const results$ = sendRequest(req, next, this.cache);  return cachedResponse ?    results$.pipe( startWith(cachedResponse) ) :    results$;}// cache-or-fetchreturn cachedResponse ?  of(cachedResponse) : sendRequest(req, next, this.cache);

cache-then-refresh 选项是由一个自定义的 x-refresh 请求头触发的。

PackageSearchComponent 中的一个检查框会切换 withRefresh 标识, 它是 PackageSearchService.search() 的参数之一。 search() 方法创建了自定义的 x-refresh 头,并在调用 HttpClient.get() 前把它添加到请求里。

修改后的 CachingInterceptor 会发起一个服务器请求,而不管有没有缓存的值。 就像 前面 的 sendRequest() 方法一样进行订阅。 在订阅 results$ 可观察对象时,就会发起这个请求。

  • 如果没有缓存值,拦截器直接返回 results$

  • 如果有缓存的值,这些代码就会把缓存的响应加入到 result$ 的管道中,使用重组后的可观察对象进行处理,并发出两次。 先立即发出一次缓存的响应体,然后发出来自服务器的响应。 订阅者将会看到一个包含这两个响应的序列。

应用程序有时会传输大量数据,而这些传输可能要花很长时间。文件上传就是典型的例子。你可以通过提供有关此类传输的进度反馈,为用户提供更好的体验。

要想发出一个带有进度事件的请求,你可以创建一个 HttpRequest 实例,并把 reportProgress 选项设置为 true 来启用对进度事件的跟踪。

Path:"app/uploader/uploader.service.ts (upload request)" 。

const req = new HttpRequest('POST', '/upload/file', file, {  reportProgress: true});

注:

  • 每个进度事件都会触发变更检测,所以只有当需要在 UI 上报告进度时,你才应该开启它们。

  • HttpClient.request()HTTP 方法一起使用时,可以用 observe: 'events' 来查看所有事件,包括传输的进度。

接下来,把这个请求对象传递给 HttpClient.request() 方法,该方法返回一个 HttpEventsObservable(与 拦截器 部分处理过的事件相同)。

Path:"app/uploader/uploader.service.ts (upload body)" 。

// The `HttpClient.request` API produces a raw event stream// which includes start (sent), progress, and response events.return this.http.request(req).pipe(  map(event => this.getEventMessage(event, file)),  tap(message => this.showProgress(message)),  last(), // return last (completed) message to caller  catchError(this.handleError(file)));

getEventMessage 方法解释了事件流中每种类型的 HttpEvent

Path:"app/uploader/uploader.service.ts (getEventMessage)" 。

/** Return distinct message for sent, upload progress, & response events */private getEventMessage(event: HttpEvent<any>, file: File) {  switch (event.type) {    case HttpEventType.Sent:      return `Uploading file "${file.name}" of size ${file.size}.`;    case HttpEventType.UploadProgress:      // Compute and show the % done:      const percentDone = Math.round(100 * event.loaded / event.total);      return `File "${file.name}" is ${percentDone}% uploaded.`;    case HttpEventType.Response:      return `File "${file.name}" was completely uploaded!`;    default:      return `File "${file.name}" surprising upload event: ${event.type}.`;  }}

本指南中的示例应用中没有用来接受上传文件的服务器。"app/http-interceptors/upload-interceptor.ts" 的 UploadInterceptor 通过返回一个模拟这些事件的可观察对象来拦截和短路上传请求。

如果你需要发一个 HTTP 请求来响应用户的输入,那么每次击键就发送一个请求的效率显然不高。最好等用户停止输入后再发送请求。这种技术叫做防抖。可以通过防抖来优化与服务器的交互。

考虑下面这个模板,它让用户输入一个搜索词来按名字查找 npm 包。 当用户在搜索框中输入名字时,PackageSearchComponent 就会把这个根据名字搜索包的请求发给 npm web API

Path:"app/package-search/package-search.component.html (search)" 。

<input (keyup)="search($event.target.value)" id="name" placeholder="Search"/><ul>  <li *ngFor="let package of packages$ | async">    <b>{{package.name}} v.{{package.version}}</b> -    <i>{{package.description}}</i>  </li></ul>

这里,keyup 事件绑定会把每次击键都发送给组件的 search() 方法。下面的代码片段使用 RxJS 的操作符为这个输入实现了防抖。

Path:"app/package-search/package-search.component.ts (excerpt)" 。

withRefresh = false;packages$: Observable<NpmPackageInfo[]>;private searchText$ = new Subject<string>();search(packageName: string) {  this.searchText$.next(packageName);}ngOnInit() {  this.packages$ = this.searchText$.pipe(    debounceTime(500),    distinctUntilChanged(),    switchMap(packageName =>      this.searchService.search(packageName, this.withRefresh))  );}constructor(private searchService: PackageSearchService) { }

searchText$ 是来自用户的搜索框值的序列。它被定义为 RxJS Subject 类型,这意味着它是一个多播 Observable,它还可以通过调用 next(value) 来自行发出值,就像在 search() 方法中一样。

除了把每个 searchText 的值都直接转发给 PackageSearchService 之外,ngOnInit() 中的代码还通过下列三个操作符对这些搜索值进行管道处理,以便只有当它是一个新值并且用户已经停止输入时,要搜索的值才会抵达该服务。

  • debounceTime(500) - 等待用户停止输入(本例中为 1/2 秒)。

  • distinctUntilChanged() - 等待搜索文本发生变化。

  • switchMap() - 将搜索请求发送到服务。

这些代码把 packages$ 设置成了使用搜索结果组合出的 Observable 对象。 模板中使用 AsyncPipe 订阅了 packages$,一旦搜索结果的值发回来了,就显示这些搜索结果。

使用 switchMap() 操作符

switchMap() 操作符接受一个返回 Observable 的函数型参数。在这个例子中,PackageSearchService.search 像其它数据服务方法那样返回一个 Observable。如果先前的搜索请求仍在进行中 (如网络连接不良),它将取消该请求并发送新的请求。

请注意,switchMap() 会按照原始的请求顺序返回这些服务的响应,而不用关心服务器实际上是以乱序返回的它们。

如果你觉得将来会复用这些防抖逻辑, 可以把它移到单独的工具函数中,或者移到 PackageSearchService 中。

跨站请求伪造 (XSRF 或 CSRF)是一个攻击技术,它能让攻击者假冒一个已认证的用户在你的网站上执行未知的操作。HttpClient 支持一种通用的机制来防范 XSRF 攻击。当执行 HTTP 请求时,一个拦截器会从 cookie 中读取 XSRF 令牌(默认名字为 XSRF-TOKEN),并且把它设置为一个 HTTP 头 X-XSRF-TOKEN,由于只有运行在你自己的域名下的代码才能读取这个 cookie,因此后端可以确认这个 HTTP 请求真的来自你的客户端应用,而不是攻击者。

默认情况下,拦截器会在所有的修改型请求中(比如 POST 等)把这个请求头发送给使用相对 URL 的请求。但不会在 GET/HEAD 请求中发送,也不会发送给使用绝对 URL 的请求。

要获得这种优点,你的服务器需要在页面加载或首个 GET 请求中把一个名叫 XSRF-TOKEN 的令牌写入可被 JavaScript 读到的会话 cookie 中。 而在后续的请求中,服务器可以验证这个 cookie 是否与 HTTP 头 X-XSRF-TOKEN 的值一致,以确保只有运行在你自己域名下的代码才能发起这个请求。这个令牌必须对每个用户都是唯一的,并且必须能被服务器验证,因此不能由客户端自己生成令牌。把这个令牌设置为你的站点认证信息并且加了盐(salt)的摘要,以提升安全性。

为了防止多个 Angular 应用共享同一个域名或子域时出现冲突,要给每个应用分配一个唯一的 cookie 名称。

注:

HttpClient 支持的只是 XSRF 防护方案的客户端这一半。 你的后端服务必须配置为给页面设置 cookie,并且要验证请求头,以确保全都是合法的请求。如果不这么做,就会导致 Angular 的默认防护措施失效。

配置自定义 cookie/header 名称

如果你的后端服务中对 XSRF 令牌的 cookie 或 头使用了不一样的名字,就要使用 HttpClientXsrfModule.withConfig() 来覆盖掉默认值。

imports: [  HttpClientModule,  HttpClientXsrfModule.withOptions({    cookieName: 'My-Xsrf-Cookie',    headerName: 'My-Xsrf-Header',  }),],

如同所有的外部依赖一样,你必须把 HTTP 后端也 Mock 掉,以便你的测试可以模拟这种与后端的互动。 @angular/common/http/testing 库能让这种 Mock 工作变得直截了当。

Angular 的 HTTP 测试库是专为其中的测试模式而设计的。在这种模式下,会首先在应用中执行代码并发起请求。 然后,这个测试会期待发起或未发起过某个请求,并针对这些请求进行断言, 最终对每个所预期的请求进行刷新(flush)来对这些请求提供响应。

最终,测试可能会验证这个应用不曾发起过非预期的请求。

本章所讲的这些测试位于 "src/testing/http-client.spec.ts" 中。 在 "src/app/heroes/heroes.service.spec.ts" 中还有一些测试,用于测试那些调用了 "HttpClient" 的数据服务。

搭建测试环境

要开始测试那些通过 HttpClient 发起的请求,就要导入 HttpClientTestingModule 模块,并把它加到你的 TestBed 设置里去,代码如下:

Path:"app/testing/http-client.spec.ts (imports)" 。

// Http testing module and mocking controllerimport { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';// Other importsimport { TestBed } from '@angular/core/testing';import { HttpClient, HttpErrorResponse } from '@angular/common/http';

然后把 HTTPClientTestingModule 添加到 TestBed 中,并继续设置被测服务。

Path:"app/testing/http-client.spec.ts(setup)" 。

describe('HttpClient testing', () => {  let httpClient: HttpClient;  let httpTestingController: HttpTestingController;  beforeEach(() => {    TestBed.configureTestingModule({      imports: [ HttpClientTestingModule ]    });    // Inject the http service and test controller for each test    httpClient = TestBed.inject(HttpClient);    httpTestingController = TestBed.inject(HttpTestingController);  });  /// Tests begin ///});

现在,在测试中发起的这些请求会发给这些测试用的后端(testing backend),而不是标准的后端。

这种设置还会调用 TestBed.inject(),来获取注入的 HttpClient 服务和模拟对象的控制器 HttpTestingController,以便在测试期间引用它们。

期待并回复请求

现在,你就可以编写测试,等待 GET 请求并给出模拟响应。

Path:"app/testing/http-client.spec.ts(httpClient.get)" 。

it('can test HttpClient.get', () => {  const testData: Data = {name: 'Test Data'};  // Make an HTTP GET request  httpClient.get<Data>(testUrl)    .subscribe(data =>      // When observable resolves, result should match test data      expect(data).toEqual(testData)    );  // The following `expectOne()` will match the request's URL.  // If no requests or multiple requests matched that URL  // `expectOne()` would throw.  const req = httpTestingController.expectOne('/data');  // Assert that the request is a GET.  expect(req.request.method).toEqual('GET');  // Respond with mock data, causing Observable to resolve.  // Subscribe callback asserts that correct data was returned.  req.flush(testData);  // Finally, assert that there are no outstanding requests.  httpTestingController.verify();});

最后一步,验证没有发起过预期之外的请求,足够通用,因此你可以把它移到 afterEach() 中:

afterEach(() => {  // After every test, assert that there are no more pending requests.  httpTestingController.verify();});

  1. 自定义对请求的预期

如果仅根据 URL 匹配还不够,你还可以自行实现匹配函数。 比如,你可以验证外发的请求是否带有某个认证头:

    // Expect one request with an authorization header    const req = httpTestingController.expectOne(      req => req.headers.has('Authorization')    );

像前面的 expectOne() 测试一样,如果零或两个以上的请求满足了这个断言,它就会抛出异常。

  1. 处理一个以上的请求

如果你需要在测试中对重复的请求进行响应,可以使用 match() API 来代替 expectOne(),它的参数不变,但会返回一个与这些请求相匹配的数组。一旦返回,这些请求就会从将来要匹配的列表中移除,你要自己验证和刷新(flush)它。

// get all pending requests that match the given URLconst requests = httpTestingController.match(testUrl);expect(requests.length).toEqual(3);// Respond to each request with different resultsrequests[0].flush([]);requests[1].flush([testData[0]]);requests[2].flush(testData);

测试对错误的预期

你还要测试应用对于 HTTP 请求失败时的防护。

调用 request.flush() 并传入一个错误信息,如下所示:

it('can test for 404 error', () => {  const emsg = 'deliberate 404 error';  httpClient.get<Data[]>(testUrl).subscribe(    data => fail('should have failed with the 404 error'),    (error: HttpErrorResponse) => {      expect(error.status).toEqual(404, 'status');      expect(error.error).toEqual(emsg, 'message');    }  );  const req = httpTestingController.expectOne(testUrl);  // Respond with mock error  req.flush(emsg, { status: 404, statusText: 'Not Found' });});

另外,你还可以使用 ErrorEvent 来调用 request.error().

it('can test for network error', () => {  const emsg = 'simulated network error';  httpClient.get<Data[]>(testUrl).subscribe(    data => fail('should have failed with the network error'),    (error: HttpErrorResponse) => {      expect(error.error.message).toEqual(emsg, 'message');    }  );  const req = httpTestingController.expectOne(testUrl);  // Create mock ErrorEvent, raised when something goes wrong at the network level.  // Connection timeout, DNS error, offline, etc  const mockError = new ErrorEvent('Network error', {    message: emsg,  });  // Respond with mock error  req.error(mockError);});

应用内导航:路由到视图

在单页面应用中,你可以通过显示或隐藏特定组件的显示部分来改变用户能看到的内容,而不用去服务器获取新页面。当用户执行应用任务时,他们要在你预定义的不同视图之间移动。要想在应用的单个页面中实现这种导航,你可以使用 Angular 的 Router(路由器)。

为了处理从一个视图到下一个视图之间的导航,你可以使用 Angular 的路由器。路由器会把浏览器 URL 解释成改变视图的操作指南,以完成导航。

要探索一个具备路由器主要功能的示例应用,请参见现场演练 / 下载范例。

先决条件在创建路由之前,你应该熟悉以下内容:

  • 组件的基础知识

  • 模板的基础知识

  • 一个 Angular 应用,你可以使用 Angular CLI 生成一个基本的 Angular 应用。

有关这个现成应用的 Angular 简介,请参见快速上手。有关构建 Angular 应用的更深入体验,请参见英雄指南教程。两者都会指导你使用组件类和模板。

选择路由策略

你必须在开发项目的早期就选择一种路由策略,因为一旦该应用进入了生产阶段,你网站的访问者就会使用并依赖应用的这些 URL 引用。

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

在服务器端渲染指定的页面,是一项可以在该应用首次加载时大幅提升响应速度的技术。那些原本需要十秒甚至更长时间加载的应用,可以预先在服务端渲染好,并在少于一秒的时间内完整渲染在用户的设备上。

只有当应用的 URL 看起来像是标准的 Web URL,中间没有 hash(#)时,这个选项才能生效。

下面的命令会用 Angular CLI 来生成一个带有应用路由模块(AppRoutingModule)的基本 Angular 应用,它是一个 NgModule,可用来配置路由。下面的例子中应用的名字是 routing-app

ng new routing-app --routing

一旦生成新应用,CLI 就会提示你选择 CSSCSS 预处理器。在这个例子中,我们接受 CSS 的默认值。

为路由添加组件

为了使用 Angular 的路由器,应用至少要有两个组件才能从一个导航到另一个。要使用 CLI 创建组件,请在命令行输入以下内容,其中 first 是组件的名称:

ng generate component first

为第二个组件重复这个步骤,但给它一个不同的名字。这里的新名字是 second

ng generate component second

CLI 会自动添加 Component 后缀,所以如果在编写 first-component,那么其组件名就是 FirstComponentComponent

本指南适用于 CLI 生成的 Angular 应用。如果你是手动工作的,请确保你的 "index.html" 文件的 <head& 中有 <base href="/"& 语句。这里假定 "app" 文件夹是应用的根目录,并使用 "/" 作为基础路径。

导入这些新组件

要使用这些新组件,请把它们导入到该文件顶部的 AppRoutingModule 中,具体如下:

//AppRoutingModule (excerpt)import { FirstComponent } from './first/first.component';import { SecondComponent } from './second/second.component';

创建路由有三个基本的构建块。

AppRoutingModule 导入 AppModule 并把它添加到 imports 数组中。

Angular CLI 会为你执行这一步骤。但是,如果要手动创建应用或使用现存的非 CLI 应用,请验证导入和配置是否正确。下面是使用 --routing 标志生成的默认 AppModule

//Default CLI AppModule with routingimport { BrowserModule } from '@angular/platform-browser';import { NgModule } from '@angular/core';import { AppRoutingModule } from './app-routing.module'; // CLI imports AppRoutingModuleimport { AppComponent } from './app.component';@NgModule({  declarations: [    AppComponent  ],  imports: [    BrowserModule,    AppRoutingModule // CLI adds AppRoutingModule to the AppModule's imports array  ],  providers: [],  bootstrap: [AppComponent]})export class AppModule { }

  1. RouterModuleRoutes 导入到你的路由模块中。

Angular CLI 会自动执行这一步骤。CLI 还为你的路由设置了 Routes 数组,并为 @NgModule() 配置了 importsexports 数组。

    //CLI app routing module    import { NgModule } from '@angular/core';    import { Routes, RouterModule } from '@angular/router'; // CLI imports router    const routes: Routes = []; // sets up routes constant where you define your routes    // configures NgModule imports and exports    @NgModule({      imports: [RouterModule.forRoot(routes)],      exports: [RouterModule]    })    export class AppRoutingModule { }

  1. Routes 数组中定义你的路由。

这个数组中的每个路由都是一个包含两个属性的 JavaScript 对象。第一个属性 path 定义了该路由的 URL 路径。第二个属性 component 定义了要让 Angular 用作相应路径的组件。

    //AppRoutingModule (excerpt)    const routes: Routes = [      { path: 'first-component', component: FirstComponent },      { path: 'second-component', component: SecondComponent },    ];

  1. 把这些路由添加到你的应用中。

现在你已经定义了路由,可以把它们添加到应用中了。首先,添加到这两个组件的链接。把要添加路由的链接赋值给 routerLink 属性。将属性的值设置为该组件,以便在用户点击各个链接时显示这个值。接下来,修改组件模板以包含 <router-outlet> 标签。该元素会通知 Angular,你可以用所选路由的组件更新应用的视图。

    //Template with routerLink and router-outlet    <h1>Angular Router App</h1>    <!-- This nav gives you links to click, which tells the router which route to use (defined in the routes constant in  AppRoutingModule) -->    <nav>      <ul>        <li><a routerLink="/first-component" routerLinkActive="active">First Component</a></li>        <li><a routerLink="/second-component" routerLinkActive="active">Second Component</a></li>      </ul>    </nav>    <!-- The routed views render in the <router-outlet>-->    <router-outlet></router-outlet>

路由顺序

路由的顺序很重要,因为 Router 在匹配路由时使用“先到先得”策略,所以应该在不那么具体的路由前面放置更具体的路由。首先列出静态路径的路由,然后是一个与默认路由匹配的空路径路由。通配符路由是最后一个,因为它匹配每一个 URL,只有当其它路由都没有匹配时,Router 才会选择它。

通常,当用户导航你的应用时,你会希望把信息从一个组件传递到另一个组件。例如,考虑一个显示杂货商品购物清单的应用。列表中的每一项都有一个唯一的 id。要想编辑某个项目,用户需要单击“编辑”按钮,打开一个 EditGroceryItem 组件。你希望该组件得到该商品的 id,以便它能向用户显示正确的信息。

你也可以使用一个路由把这种类型的信息传给你的应用组件。要做到这一点,你可以使用 ActivatedRoute 接口。

要从路由中获取信息:

  1. ActivatedRouteParamMap 导入你的组件。

    //In the component class (excerpt)    import { Router, ActivatedRoute, ParamMap } from '@angular/router';

这些 import 语句添加了组件所需的几个重要元素。要详细了解每个 API,请参阅以下 API 页面:

  • Router

  • ActivatedRoute

  • ParamMap

  1. 通过把 ActivatedRoute 的一个实例添加到你的应用的构造函数中来注入它:

//In the component class (excerpt)constructor(  private route: ActivatedRoute,) {}

  1. 更新 ngOnInit() 方法来访问这个 ActivatedRoute 并跟踪 id 参数:

//In the component (excerpt)ngOnInit() {  this.route.queryParams.subscribe(params => {    this.name = params['name'];  });}

注:

  • 前面的例子使用了一个变量 name,并根据 name 参数给它赋值。

当用户试图导航到那些不存在的应用部件时,在正常的应用中应该能得到很好的处理。要在应用中添加此功能,需要设置通配符路由。当所请求的 `URL 与任何路由器路径都不匹配时,Angular 路由器就会选择这个路由。

要设置通配符路由,请在 routes 定义中添加以下代码。

//AppRoutingModule (excerpt){ path: '**', component:  }

这两个星号 ** 告诉 Angular,这个 routes 定义是通配符路由。对于 component 属性,你可以使用应用中的任何组件。常见的选择包括应用专属的 PageNotFoundComponent,你可以定义它来向用户展示 404 页面,或者跳转到应用的主组件。通配符路由是最后一个路由,因为它匹配所有的 URL。有关路由顺序的更多详细信息,请参阅路由顺序。

显示 404 页面

要显示 404 页面,请设置一个通配符路由,并将 component 属性设置为你要用于 404 页面的组件,如下所示:

//AppRoutingModule (excerpt)const routes: Routes = [  { path: 'first-component', component: FirstComponent },  { path: 'second-component', component: SecondComponent },  { path: '',   redirectTo: '/first-component', pathMatch: 'full' }, // redirect to `first-component`  { path: '**', component: FirstComponent },  { path: '**', component: PageNotFoundComponent },  // Wildcard route for a 404 page];

path** 的最后一条路由是通配符路由。如果请求的 URL 与前面列出的路径不匹配,路由器会选择这个路由,并把该用户送到 PageNotFoundComponent

设置重定向

要设置重定向,请使用重定向源的 path、要重定向目标的 component 和一个 pathMatch 值来配置路由,以告诉路由器该如何匹配 URL

//AppRoutingModule (excerpt)const routes: Routes = [  { path: 'first-component', component: FirstComponent },  { path: 'second-component', component: SecondComponent },  { path: '',   redirectTo: '/first-component', pathMatch: 'full' }, // redirect to `first-component`  { path: '**', component: FirstComponent },];

在这个例子中,第三个路由是重定向路由,所以路由器会默认跳到 first-component 路由。注意,这个重定向路由位于通配符路由之前。这里的 path: '' 表示使用初始的相对 URL( '' )。

随着你的应用变得越来越复杂,你可能要创建一些根组件之外的相对路由。这些嵌套路由类型称为子路由。这意味着你要为你的应用添加第二 <router-outlet>,因为它是 AppComponent 之外的另一个 <router-outlet>

在这个例子中,还有两个子组件,child-achild-b。这里的 FirstComponent 有它自己的 <nav>AppComponent 之外的第二 <router-outlet>

//In the template<h2>First Component</h2><nav>  <ul>    <li><a routerLink="child-a">Child A</a></li>    <li><a routerLink="child-b">Child B</a></li>  </ul></nav><router-outlet></router-outlet>

子路由和其它路由一样,同时需要 pathcomponent。唯一的区别是你要把子路由放在父路由的 children 数组中。

//AppRoutingModule (excerpt)const routes: Routes = [  { path: 'first-component',    component: FirstComponent, // this is the component with the <router-outlet> in the template    children: [      {        path: 'child-a', // child route path        component: ChildAComponent // child route component that the router renders      },      {        path: 'child-b',        component: ChildBComponent // another child route component that the router renders      }    ] },

相对路径允许你定义相对于当前 URL 段的路径。下面的例子展示了到另一个组件 second-component 的相对路由。FirstComponentSecondComponent 在树中处于同一级别,但是,指向 SecondComponent 的链接位于 FirstComponent 中,这意味着路由器必须先上升一个级别,然后进入二级目录才能找到 SecondComponent。你可以使用 ../ 符号来上升一个级别,而不用写出到 SecondComponent 的完整路径。

//In the template<h2>First Component</h2><nav>  <ul>    <li><a routerLink="../second-component">Relative Route to second component</a></li>  </ul></nav><router-outlet></router-outlet>

除了 ../,还可以使用 ./ 或者不带前导斜杠来指定当前级别。

指定相对路由

要指定相对路由,请使用 NavigationExtras 中的 relativeTo 属性。在组件类中,从 @angular/router 导入 NavigationExtras

然后在导航方法中使用 relativeTo 参数。在链接参数数组(它包含 items)之后添加一个对象,把该对象的 relativeTo 属性设置为当前的 ActivatedRoute,也就是 this.route

//RelativeTogoToItems() {  this.router.navigate(['items'], { relativeTo: this.route });}

goToItems() 方法会把目标 URI 解释为相对于当前路由的,并导航到 items 路由。

有时,应用中的某个特性需要访问路由的部件,比如查询参数或片段(fragment)。本教程的这个阶段使用了一个“英雄指南”中的列表视图,你可以在其中点击一个英雄来查看详情。路由器使用 id 来显示正确的英雄的详情。

首先,在要导航的组件中导入以下成员。

//Component import statements (excerpt)import { ActivatedRoute } from '@angular/router';import { Observable } from 'rxjs';import { switchMap } from 'rxjs/operators';

接下来,注入当前路由(ActivatedRoute)服务:

//Component (excerpt)constructor(private route: ActivatedRoute) {}

配置这个类,让你有一个可观察对象 heroes$、一个用来保存英雄的 id 号的 selectedId,以及 ngOnInit() 中的英雄们,添加下面的代码来获取所选英雄的 id。这个代码片段假设你有一个英雄列表、一个英雄服务、一个能获取你的英雄的函数,以及用来渲染你的列表和细节的 HTML,就像在《英雄指南》例子中一样。

//Component 1 (excerpt)heroes$: Observable;selectedId: number;heroes = HEROES;ngOnInit() {  this.heroes$ = this.route.paramMap.pipe(    switchMap(params => {      this.selectedId = Number(params.get('id'));      return this.service.getHeroes();    })  );}

接下来,在要导航到的组件中,导入以下成员。

//Component 2 (excerpt)import { Router, ActivatedRoute, ParamMap } from '@angular/router';import { Observable } from 'rxjs';

在组件类的构造函数中注入 ActivatedRouteRouter,这样在这个组件中就可以用它们了:

//Component 2 (excerpt)item$: Observable;  constructor(    private route: ActivatedRoute,    private router: Router  ) {}  ngOnInit() {    let id = this.route.snapshot.paramMap.get('id');    this.hero$ = this.service.getHero(id);  }  gotoItems(item: Item) {    let heroId = item ? hero.id : null;    // Pass along the item id if available    // so that the HeroList component can select that item.    this.router.navigate(['/heroes', { id: itemId }]);  }

惰性加载

你可以配置路由定义来实现惰性加载模块,这意味着 Angular 只会在需要时才加载这些模块,而不是在应用启动时就加载全部。 另外,你可以在后台预加载一些应用部件来改善用户体验。

关于惰性加载和预加载的详情,请参见专门的指南惰性加载 NgModule

防止未经授权的访问

使用路由守卫来防止用户未经授权就导航到应用的某些部分。Angular 中提供了以下路由守卫:

  • CanActivate
  • CanActivateChild
  • CanDeactivate
  • Resolve
  • CanLoad

要想使用路由守卫,可以考虑使用无组件路由,因为这对于保护子路由很方便。

为你的守卫创建一项服务:

ng generate guard your-guard

请在守卫类里实现你要用到的守卫。下面的例子使用 CanActivate 来保护该路由。

//Component (excerpt)export class YourGuard implements CanActivate {  canActivate(    next: ActivatedRouteSnapshot,    state: RouterStateSnapshot): boolean {      // your  logic goes here  }}

在路由模块中,在 routes 配置中使用相应的属性。这里的 canActivate 会告诉路由器它要协调到这个特定路由的导航。

//Routing module (excerpt){  path: '/your-path',  component: YourComponent,  canActivate: [YourGuard],}

虽然“快速上手:“英雄指南”教程介绍了 Angular 中的一般概念,而本篇 “路由器教程”详细介绍了 Angular 的路由能力。本教程将指导你在基本的路由器配置之上,创建子路由、读取路由参数、惰性加载 NgModules、路由守卫,和预加载数据,以改善用户体验。

概览

本章要讲的是如何开发一个带路由的多页面应用。 接下来会重点讲解了路由的关键特性,比如:

  • 把应用的各个特性组织成模块。

  • 导航到组件(Heroes 链接到“英雄列表”组件)。

  • 包含一个路由参数(当路由到“英雄详情”时,把该英雄的 id 传进去)。

  • 子路由(危机中心特性有一组自己的路由)。

  • CanActivate 守卫(检查路由的访问权限)。

  • CanActivateChild 守卫(检查子路由的访问权限)。

  • CanDeactivate 守卫(询问是否丢弃未保存的更改)。

  • Resolve 守卫(预先获取路由数据)。

  • 惰性加载一个模块。

  • CanLoad 守卫(在加载特性模块之前进行检查)。

就像你正逐步构建应用一样,本指南设置了一系列里程碑。不过这里假设你已经熟悉了 Angular 的基本概念。有关 Angular 的一般性介绍,参见 快速上手。有关更深入的概述,请参阅“英雄指南”教程。

范例程序实战

本教程的示例应用会帮助“英雄职业管理局”找到需要英雄来解决的危机。

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

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

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

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

该应用会渲染出一排导航按钮和和一个英雄列表视图。

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

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

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

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

选择其中之一,该应用就会把你带到此危机的编辑页面。 危机详情是当前页的子组件,就在列表的紧下方。

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

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

单击浏览器后退按钮或 “Heroes” 链接,可以激活一个对话框。

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

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

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

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

用 Angular CLI 生成一个范例应用。

ng new angular-router-sample

定义路由

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

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

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

第一个路由执行以下操作:

  • 当浏览器地址栏的 URL 变化时,如果它匹配上了路径部分 "/crisis-center",路由器就会激活一个 CrisisListComponent 的实例,并显示它的视图。

  • 当应用程序请求导航到路径 "/crisis-center" 时,路由器激活一个 CrisisListComponent 的实例,显示它的视图,并将该路径更新到浏览器地址栏和历史。

第一个配置定义了由两个路由构成的数组,它们用最短路径指向了 CrisisListComponentHeroListComponent

生成 CrisisListHeroList 组件,以便路由器能够渲染它们。

ng generate component crisis-list

ng generate component hero-list

  1. Path:"src/app/crisis-list/crisis-list.component.html" 。

    <h2>CRISIS CENTER</h2>    <p>Get your crisis here</p>

  1. Path:"src/app/hero-list/hero-list.component.html" 。

    <h2>HEROES</h2>    <p>Get your heroes here</p>

注册 Router 和 Routes

为了使用 Router,你必须注册来自 @angular/router 包中的 RouterModule。定义一个路由数组 appRoutes,并把它传给 RouterModule.forRoot() 方法。RouterModule.forRoot() 方法会返回一个模块,其中包含配置好的 Router 服务提供者,以及路由库所需的其它提供者。一旦启动了应用,Router 就会根据当前的浏览器 URL 进行首次导航。

注:

  • RouterModule.forRoot() 方法是用于注册全应用级提供者的编码模式。要详细了解全应用级提供者。

Path:"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/crisis-list.component';import { HeroListComponent }     from './hero-list/hero-list.component';const appRoutes: Routes = [  { path: 'crisis-center', component: CrisisListComponent },  { path: 'heroes', component: HeroListComponent },];@NgModule({  imports: [    BrowserModule,    FormsModule,    RouterModule.forRoot(      appRoutes,      { enableTracing: true } // <-- debugging purposes only    )  ],  declarations: [    AppComponent,    HeroListComponent,    CrisisListComponent,  ],  bootstrap: [ AppComponent ]})export class AppModule { }

对于最小化的路由配置,把配置好的 RouterModule 添加到 AppModule 中就足够了。但是,随着应用的成长,你将需要将路由配置重构到单独的文件中,并创建路由模块,路由模块是一种特殊的、专做路由的服务模块。

RouterModule.forRoot() 注册到 AppModuleimports 数组中,能让该 Router 服务在应用的任何地方都能使用。

添加路由出口

根组件 AppComponent 是本应用的壳。它在顶部有一个标题、一个带两个链接的导航条,在底部有一个路由器出口,路由器会在它所指定的位置上渲染各个组件。

路由出口扮演一个占位符的角色,表示路由组件将会渲染到哪里。

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

Path:"src/app/app.component.html" 。

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

定义通配符路由

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

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

路由器会使用先到先得的策略来选择路由。 由于通配符路由是最不具体的那个,因此务必确保它是路由配置中的最后一个路由。

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

Path:"src/app/hero-list/hero-list.component.html (excerpt)" 。

<h2>HEROES</h2><p>Get your heroes here</p><button routerLink="/sidekicks">Go to sidekicks</button>

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

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

Path:"src/app/app.module.ts (wildcard)" 。

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

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

ng generate component page-not-found

Path:"src/app/page-not-found.component.html (404 component)" 。

<h2>Page not found</h2>

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

设置跳转

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

localhost:4200

它不能匹配上任何硬编码进来的路由,于是就会走到通配符路由中去,并且显示 PageNotFoundComponent

这个应用需要一个有效的默认路由,在这里应该用英雄列表作为默认页。当用户点击 "Heroes" 链接或把 "localhost:4200/heroes" 粘贴到地址栏时,它应该导航到列表页。

添加一个 redirect 路由,把最初的相对 URL('')转换成所需的默认路径(/heroes)。

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

Path:"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 }];

浏览器的地址栏会显示 ".../heroes",好像你直接在那里导航一样。

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

聚焦 PATHMATCH

从技术角度看,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'

小结

当用户单击某个链接时,该示例应用可以在两个视图之间切换。

本节涵盖了以下几点的做法:

  • 加载路由库。

  • 往壳组件的模板中添加一个导航条,导航条中有一些 A 标签、routerLink 指令和 routerLinkActive 指令。

  • 往壳组件的模板中添加一个 router-outlet 指令,视图将会被显示在那里。

  • RouterModule.forRoot() 配置路由器模块。

  • 设置路由器,使其合成 HTML5 模式的浏览器 URL

  • 使用通配符路由来处理无效路由。

  • 当应用在空路径下启动时,导航到默认路由。

初学者应用结构图:

本节产生的文件列表:

  1. Path:"app.component.html" 。

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

  1. Path:"app.module.ts" 。

    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/crisis-list.component';    import { HeroListComponent }     from './hero-list/hero-list.component';    import { PageNotFoundComponent } from './page-not-found/page-not-found.component';    const appRoutes: Routes = [      { path: 'crisis-center', component: CrisisListComponent },      { path: 'heroes', component: HeroListComponent },      { path: '',   redirectTo: '/heroes', pathMatch: 'full' },      { path: '**', component: PageNotFoundComponent }    ];    @NgModule({      imports: [        BrowserModule,        FormsModule,        RouterModule.forRoot(          appRoutes,          { enableTracing: true } // <-- debugging purposes only        )      ],      declarations: [        AppComponent,        HeroListComponent,        CrisisListComponent,        PageNotFoundComponent      ],      bootstrap: [ AppComponent ]    })    export class AppModule { }

  1. Path:"hero-list/hero-list.component.html" 。

    <h2>HEROES</h2>    <p>Get your heroes here</p>    <button routerLink="/sidekicks">Go to sidekicks</button>

  1. Path:"crisis-list/crisis-list.component.html" 。

    <h2>CRISIS CENTER</h2>    <p>Get your crisis here</p>

  1. Path:"page-not-found/page-not-found.component.html" 。

    <h2>Page not found</h2>

  1. Path:"index.html" 。

    <html lang="en">      <head>        <!-- Set the base href -->        <base href="/">        <title>Angular Router</title>        <meta charset="UTF-8">        <meta name="viewport" content="width=device-width, initial-scale=1">      </head>      <body>        <app-root></app-root>      </body>    </html>

本节会向你展示如何配置一个名叫路由模块的专用模块,它会保存你应用的路由配置。

路由模块有以下几个特点:

  • 把路由这个关注点从其它应用类关注点中分离出去。

  • 测试特性模块时,可以替换或移除路由模块。

  • 为路由服务提供者(如守卫和解析器等)提供一个众所周知的位置。

  • 不要声明组件。

把路由集成到应用中

路由应用范例中默认不包含路由。 要想在使用 Angular CLI 创建项目时支持路由,请为项目或应用的每个 NgModule 设置 --routing 选项。 当你用 CLI 命令 ng new 创建新项目或用 ng generate app 命令创建新应用,请指定 --routing 选项。这会告诉 CLI 包含上 @angular/router 包,并创建一个名叫 "app-routing.module.ts" 的文件。 然后你就可以在添加到项目或应用中的任何 NgModule 中使用路由功能了。

比如,可以用下列命令生成带路由的 NgModule

ng generate module my-module --routing

这将创建一个名叫 "my-module-routing.module.ts" 的独立文件,来保存这个 NgModule 的路由信息。 该文件包含一个空的 Routes 对象,你可以使用一些指向各个组件和 NgModule 的路由来填充该对象。

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

在 "/app" 目录下创建一个 AppRouting 模块,以包含路由配置。

ng generate module app-routing --module app --flat

导入 CrisisListComponent、HeroListComponent 和 PageNotFoundCompponent 组件,就像 app.module.ts 中那样。然后把 Router 的导入语句和路由配置以及 RouterModule.forRoot() 移入这个路由模块中。

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

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

Path:"src/app/app-routing.module.ts" 。

import { NgModule }              from '@angular/core';import { RouterModule, Routes }  from '@angular/router';import { CrisisListComponent }   from './crisis-list/crisis-list.component';import { HeroListComponent }     from './hero-list/hero-list.component';import { PageNotFoundComponent } from './page-not-found/page-not-found.component';const appRoutes: Routes = [  { path: 'crisis-center', component: CrisisListComponent },  { path: 'heroes',        component: HeroListComponent },  { path: '',   redirectTo: '/heroes', pathMatch: 'full' },  { path: '**', component: PageNotFoundComponent }];@NgModule({  imports: [    RouterModule.forRoot(      appRoutes,      { enableTracing: true } // <-- debugging purposes only    )  ],  exports: [    RouterModule  ]})export class AppRoutingModule {}

接下来,修改 "app.module.ts" 文件,从 imports 数组中移除 RouterModule.forRoot

Path:"src/app/app.module.ts" 。

import { NgModule }       from '@angular/core';import { BrowserModule }  from '@angular/platform-browser';import { FormsModule }    from '@angular/forms';import { AppComponent }     from './app.component';import { AppRoutingModule } from './app-routing.module';import { CrisisListComponent }   from './crisis-list/crisis-list.component';import { HeroListComponent }     from './hero-list/hero-list.component';import { PageNotFoundComponent } from './page-not-found/page-not-found.component';@NgModule({  imports: [    BrowserModule,    FormsModule,    AppRoutingModule  ],  declarations: [    AppComponent,    HeroListComponent,    CrisisListComponent,    PageNotFoundComponent  ],  bootstrap: [ AppComponent ]})export class AppModule { }

应用继续照常运行,你可以把路由模块作为将来每个模块维护路由配置的中心位置。

路由模块的优点

路由模块(通常称为 AppRoutingModule )代替了根模板或特性模块中的路由模块。

这种路由模块在你的应用不断增长,以及配置中包括了专门的守卫和解析器服务时会非常有用。

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

大多数应用都应该采用路由模块,以保持一致性。 它在配置复杂时,能确保代码干净。 它让测试特性模块更加容易。 它的存在让人一眼就能看出这个模块是带路由的。 开发者可以很自然的从路由模块中查找和扩展路由配置。

本节涵盖了以下内容:

  • 用模块把应用和路由组织为一些特性区。

  • 命令式的从一个组件导航到另一个

  • 通过路由传递必要信息和可选信息

这个示例应用在“英雄指南”教程的“服务”部分重新创建了英雄特性区,并复用了 Tour of Heroes: Services example code 中的大部分代码。

典型的应用具有多个特性区,每个特性区都专注于特定的业务用途并拥有自己的文件夹。

该部分将向你展示如何将应用重构为不同的特性模块、将它们导入到主模块中,并在它们之间导航。

添加英雄管理功能

遵循下列步骤:

  • 为了管理这些英雄,在 "heroes" 目录下创建一个带路由的 HeroesModule,并把它注册到根模块 AppModule 中。

    ng generate module heroes/heroes --module app --flat --routing

  • 把 "app" 下占位用的 "hero-list" 目录移到 "heroes" 目录中。

  • <h2> 加文字,改成 <h2>HEROES</h2>

  • 删除模板底部的 <app-hero-detail> 组件。

  • 把现场演练中 "heroes/heroes.component.css" 文件的内容复制到 "hero-list.component.css" 文件中。

  • 把现场演练中 "heroes/heroes.component.ts" 文件的内容复制到 "hero-list.component.ts" 文件中。

  • 把组件类名改为 HeroListComponent

  • selector 改为 app-hero-list

注:

  • 对于路由组件来说,这些选择器不是必须的,因为这些组件是在渲染页面时动态插入的,不过选择器对于在 HTML 元素树中标记和选中它们是很有用的。

  • 把 "hero-detail" 目录中的 "hero.ts"、"hero.service.ts" 和 "mock-heroes.ts" 文件复制到 "heroes" 子目录下。

  • 把 "message.service.ts" 文件复制到 "src/app" 目录下。

  • 在 "hero.service.ts" 文件中修改导入 "message.service" 的相对路径。

接下来,更新 HeroesModule 的元数据。

  • 导入 HeroDetailComponentHeroListComponent,并添加到 HeroesModule 模块的 declarations 数组中。

Path:"src/app/heroes/heroes.module.ts" 。

import { NgModule }       from '@angular/core';import { CommonModule }   from '@angular/common';import { FormsModule }    from '@angular/forms';import { HeroListComponent }    from './hero-list/hero-list.component';import { HeroDetailComponent }  from './hero-detail/hero-detail.component';import { HeroesRoutingModule } from './heroes-routing.module';@NgModule({  imports: [    CommonModule,    FormsModule,    HeroesRoutingModule  ],  declarations: [    HeroListComponent,    HeroDetailComponent  ]})export class HeroesModule {}

英雄管理部分的文件结构如下:

  1. 英雄特性区的路由需求:

英雄特性区中有两个相互协作的组件:英雄列表和英雄详情。当你导航到列表视图时,它会获取英雄列表并显示出来。当你点击一个英雄时,详细视图就会显示那个特定的英雄。

通过把所选英雄的 id 编码进路由的 URL 中,就能告诉详情视图该显示哪个英雄。

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

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

AppRoutingModule 中,你使用了静态的 RouterModule.forRoot() 方法来注册路由和全应用级服务提供者。在特性模块中你要改用 forChild() 静态方法。

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

修改后的 HeroesRoutingModule 是这样的:

Path:"src/app/heroes/heroes-routing.module.ts" 。

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

考虑为每个特性模块提供自己的路由配置文件。虽然特性路由目前还很少,但即使在小型应用中,路由也会变得越来越复杂。

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

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

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

移除 HeroListComponent 的导入和来自 "app-routing.module.ts" 中的 /heroes 路由。

保留默认路由和通配符路由,因为这些路由仍然要在应用的顶层使用。

Path:"src/app/app-routing.module.ts (v2)" 。

    import { NgModule }              from '@angular/core';    import { RouterModule, Routes }  from '@angular/router';    import { CrisisListComponent }   from './crisis-list/crisis-list.component';    // import { HeroListComponent }  from './hero-list/hero-list.component';  // <-- delete this line    import { PageNotFoundComponent } from './page-not-found/page-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,          { enableTracing: true } // <-- debugging purposes only        )      ],      exports: [        RouterModule      ]    })    export class AppRoutingModule {}

  1. 移除英雄列表的声明。

因为 HeroesModule 现在提供了 HeroListComponent,所以把它从 AppModuledeclarations 数组中移除。现在你已经有了一个单独的 HeroesModule,你可以用更多的组件和不同的路由来演进英雄特性区。

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

Path:"src/app/app.module.ts" 。

    import { NgModule }       from '@angular/core';    import { BrowserModule }  from '@angular/platform-browser';    import { FormsModule }    from '@angular/forms';    import { AppComponent }     from './app.component';    import { AppRoutingModule } from './app-routing.module';    import { HeroesModule }     from './heroes/heroes.module';    import { CrisisListComponent }   from './crisis-list/crisis-list.component';    import { PageNotFoundComponent } from './page-not-found/page-not-found.component';    @NgModule({      imports: [        BrowserModule,        FormsModule,        HeroesModule,        AppRoutingModule      ],      declarations: [        AppComponent,        CrisisListComponent,        PageNotFoundComponent      ],      bootstrap: [ AppComponent ]    })    export class AppModule { }

模块导入顺序

请注意该模块的 imports 数组,AppRoutingModule 是最后一个,并且位于 HeroesModule 之后。

Path:"src/app/app.module.ts (module-imports)" 。

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

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

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

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

反转路由模块的导入顺序,就会看到当点击英雄相关的链接时被导向了“页面未找到”路由。

路由参数

  1. 带参数的路由定义。

回到 HeroesRoutingModule 并再次检查这些路由定义。 HeroDetailComponent 路由的路径中带有 :id 令牌。

Path:"src/app/heroes/heroes-routing.module.ts (excerpt)" 。

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

:id 令牌会为路由参数在路径中创建一个“空位”。在这里,这种配置会让路由器把英雄的 id 插入到那个“空位”中。

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

    localhost:4200/hero/15

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

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

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

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

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

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

Path:"src/app/heroes/hero-list/hero-list.component.html (link-parameters-array)" 。

    <a [routerLink]="['/hero', hero.id]">

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

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

ActivatedRoute 实战

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

Path:"src/app/heroes/hero-detail/hero-detail.component.ts (activated route)" 。

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

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

Path:"src/app/heroes/hero-detail/hero-detail.component.ts (switchMap operator import)" 。

import { switchMap } from 'rxjs/operators';

把这些服务作为私有变量添加到构造函数中,以便 Angular 注入它们(让它们对组件可见)。

Path:"src/app/heroes/hero-detail/hero-detail.component.ts (constructor)" 。

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

ngOnInit() 方法中,使用 ActivatedRoute 服务来检索路由的参数,从参数中提取出英雄的 id,并检索要显示的英雄。

Path:"src/app/heroes/hero-detail/hero-detail.component.ts (ngOnInit)" 。

ngOnInit() {  this.hero$ = this.route.paramMap.pipe(    switchMap((params: ParamMap) =>      this.service.getHero(params.get('id')))  );}

当这个 map 发生变化时,paramMap 会从更改后的参数中获取 id 参数。

然后,让 HeroService 去获取具有该 id 的英雄,并返回 HeroService 请求的结果。

switchMap 操作符做了两件事。它把 HeroService 返回的 Observable<Hero> 拍平,并取消以前的未完成请求。当 HeroService 仍在检索旧的 id 时,如果用户使用新的 id 重新导航到这个路由,switchMap 会放弃那个旧请求,并返回新 id 的英雄。

AsyncPipe 处理这个可观察的订阅,而且该组件的 hero 属性也会用检索到的英雄(重新)进行设置。

  1. ParamMap API

ParamMap API 的灵感来自于 URLSearchParams 接口。它提供了处理路由参数( paramMap )和查询参数( queryParamMap )访问的方法。

  • 成员:has(name) 。

说明:如果参数名位于参数列表中,就返回 true

  • 成员:get(name) 。

说明:如果这个 map 中有参数名对应的参数值(字符串),就返回它,否则返回 null。如果参数值实际上是一个数组,就返回它的第一个元素。

  • 成员:getAll(name) 。

说明:如果这个 map 中有参数名对应的值,就返回一个字符串数组,否则返回空数组。当一个参数名可能对应多个值的时候,请使用 getAll

  • 成员:keys 。

说明:返回这个 map 中的所有参数名组成的字符串数组。

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

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

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

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

你肯定不希望路由器先从 DOM 中移除当前的 HeroDetailComponent 实例,只是为了用下一个 id 重新创建它,因为它将重新渲染视图。为了更好的用户体验,路由器会复用同一个组件实例,而只是更新参数。

由于 ngOnInit() 在每个组件实例化时只会被调用一次,所以你可以使用 paramMap 可观察对象来检测路由参数在同一个实例中何时发生了变化。

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

不过,ActivatedRoute 中的可观察对象是一个例外,因为 ActivatedRoute 及其可观察对象与 Router 本身是隔离的。 Router 会在不再需要时销毁这个路由组件,而注入进去的 ActivateRoute 也随之销毁了。

  1. snapshot:当不需要 Observable 时的替代品。

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

假如你很确定这个 HeroDetailComponent 实例永远不会被重用,你可以使用 snapshot

route.snapshot 提供了路由参数的初始值。 你可以通过它来直接访问参数,而不用订阅或者添加 Observable 的操作符,代码如下:

Path:"src/app/heroes/hero-detail/hero-detail.component.ts (ngOnInit snapshot)" 。

    ngOnInit() {      let id = this.route.snapshot.paramMap.get('id');      this.hero$ = this.service.getHero(id);    }

用这种技术,snapshot 只会得到这些参数的初始值。如果路由器可能复用该组件,那么就该用 paramMap 可观察对象的方式。本教程的示例应用中就用了 paramMap 可观察对象。

导航回列表组件

HeroDetailComponent 的 “Back” 按钮使用了 gotoHeroes() 方法,该方法会强制导航回 HeroListComponent

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

Path:"src/app/heroes/hero-detail/hero-detail.component.ts (excerpt)"。

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

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

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

    localhost:4200/hero/15

你也能在路由请求中添加可选信息。 比如,当从 "hero-detail.component.ts" 返回到列表时,如果能自动选中刚刚查看过的英雄就好了。

当从 HeroDetailComponent 返回时,你可以会通过把正在查看的英雄的 id 作为可选参数包含在 URL 中来实现这个特性。

可选信息还可以包含其它形式,例如:

  • 结构松散的搜索条件。比如 name='wind_'

  • 多个值。比如 after='12/31/2015' & before='1/1/2017' - 没有特定的顺序 -before='1/1/2017' & after='12/31/2015' - 具有各种格式 -during='currentYear'

由于这些参数不适合用作 URL 路径,因此可以使用可选参数在导航过程中传递任意复杂的信息。可选参数不参与模式匹配,因此在表达上提供了巨大的灵活性。

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

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

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

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

Path:"src/app/heroes/hero-list/hero-list.component.html (link-parameters-array)"。

    <a [routerLink]="['/hero', hero.id]">

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

Path:"src/app/heroes/heroes-routing.module.ts (hero-detail-route)"。

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

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

Path:"src/app/heroes/hero-detail/hero-detail.component.ts (gotoHeroes)"。

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

该数组缺少一个路由参数,这是因为以前你不需要往 HeroListComponent 发送信息。

现在,使用导航请求发送当前英雄的 id,以便 HeroListComponent 在其列表中突出显示该英雄。

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

Path:"src/app/heroes/hero-detail/hero-detail.component.ts (go to heroes)"。

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

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

注意浏览器的地址栏。

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

    localhost:4200/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 写法。

ActivatedRoute 服务中的路由参数

开发到现在,英雄列表还没有变化。没有突出显示的英雄行。

HeroListComponent 需要添加使用这些参数的代码。

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

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

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

Path:"src/app/heroes/hero-list/hero-list.component.ts (import)"。

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

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

Path:"src/app/heroes/hero-list/hero-list.component.ts (rxjs imports)"。

import { Observable } from 'rxjs';import { switchMap } from 'rxjs/operators';

HeroListComponent 构造函数中注入 ActivatedRoute

Path:"src/app/heroes/hero-list/hero-list.component.ts (constructor and ngOnInit)"。

export class HeroListComponent implements OnInit {  heroes$: Observable<Hero[]>;  selectedId: number;  constructor(    private service: HeroService,    private route: ActivatedRoute  ) {}  ngOnInit() {    this.heroes$ = this.route.paramMap.pipe(      switchMap(params => {        // (+) before `params.get()` turns the string into a number        this.selectedId = +params.get('id');        return this.service.getHeroes();      })    );  }}

ActivatedRoute.paramMap 属性是一个路由参数的 Observable。当用户导航到这个组件时,paramMap 会发射一个新值,其中包含 id。 在 ngOnInit() 中,你订阅了这些值,设置到 selectedId,并获取英雄数据。

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

Path:"src/app/heroes/hero-list/hero-list.component.html"。

<h2>HEROES</h2><ul class="heroes">  <li *ngFor="let hero of heroes$ | async"    [class.selected]="hero.id === selectedId">    <a [routerLink]="['/hero', hero.id]">      <span class="badge">{{ hero.id }}</span>{{ hero.name }}    </a>  </li></ul><button routerLink="/sidekicks">Go to sidekicks</button>

当选中列表条目时,要添加一些样式。

Path:"src/app/heroes/hero-list/hero-list.component.css"。

.heroes li.selected {  background-color: #CFD8DC;  color: white;}.heroes li.selected:hover {  background-color: #BBD8DC;}

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

这个可选的 foo 路由参数人畜无害,路由器会继续忽略它。

添加路由动画

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

首先导入 BrowserAnimationsModule,并添加到 imports 数组中:

Path:"src/app/app.module.ts (animations-module)"。

import { BrowserAnimationsModule } from '@angular/platform-browser/animations';@NgModule({  imports: [    BrowserAnimationsModule,  ],})

接下来,为指向 HeroListComponentHeroDetailComponent 的路由定义添加一个 data 对象。 转场是基于 states 的,你将使用来自路由的 animation 数据为转场提供一个有名字的动画 state

Path:"src/app/heroes/heroes-routing.module.ts (animation data)"。

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

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

Path:"src/app/animations.ts (excerpt)" 。

import {  trigger, animateChild, group,  transition, animate, style, query} from '@angular/animations';// Routable animationsexport const slideInAnimation =  trigger('routeAnimation', [    transition('heroes <=> hero', [      style({ position: 'relative' }),      query(':enter, :leave', [        style({          position: 'absolute',          top: 0,          left: 0,          width: '100%'        })      ]),      query(':enter', [        style({ left: '-100%'})      ]),      query(':leave', animateChild()),      group([        query(':leave', [          animate('300ms ease-out', style({ left: '100%'}))        ]),        query(':enter', [          animate('300ms ease-out', style({ left: '0%'}))        ])      ]),      query(':enter', animateChild()),    ])  ]);

该文件做了如下工作:

  • 导入动画符号以构建动画触发器、控制状态并管理状态之间的过渡。

  • 导出了一个名叫 slideInAnimation 的常量,并把它设置为一个名叫 routeAnimation 的动画触发器。

  • 定义一个转场动画,当在 heroeshero 路由之间来回切换时,如果进入(:enter)应用视图则让组件从屏幕的左侧滑入,如果离开(:leave)应用视图则让组件从右侧划出。

回到 AppComponent,从 @angular/router 包导入 RouterOutlet,并从 "./animations.ts" 导入 slideInAnimation

为包含 slideInAnimation@Component 元数据添加一个 animations 数组。

Path:"src/app/app.component.ts (animations)" 。

import { RouterOutlet } from '@angular/router';import { slideInAnimation } from './animations';@Component({  selector: 'app-root',  templateUrl: 'app.component.html',  styleUrls: ['app.component.css'],  animations: [ slideInAnimation ]})

要想使用路由动画,就要把 RouterOutlet 包装到一个元素中。再把 @routeAnimation 触发器绑定到该元素上。

为了把 @routeAnimation 转场转场到指定的状态,你需要从 ActivatedRoutedata 中提供它。 RouterOutlet 导出成了一个模板变量 outlet,这样你就可以绑定一个到路由出口的引用了。这个例子中使用了一个 routerOutlet 变量。

Path:"src/app/app.component.html (router outlet)" 。

<h1>Angular Router</h1><nav>  <a routerLink="/crisis-center" routerLinkActive="active">Crisis Center</a>  <a routerLink="/heroes" routerLinkActive="active">Heroes</a></nav><div [@routeAnimation]="getAnimationData(routerOutlet)">  <router-outlet #routerOutlet="outlet"></router-outlet></div>

@routeAnimation 属性使用所提供的 routerOutlet 引用来绑定到 getAnimationData(),因此下一步就要在 AppComponent 中定义那个函数。getAnimationData 函数会根据 ActivatedRoute 所提供的 data 对象返回动画的属性。animation 属性会根据你在 "animations.ts" 中定义 slideInAnimation() 时使用的 transition 名称进行匹配。

Path:"src/app/app.component.ts (router outlet)" 。

export class AppComponent {  getAnimationData(outlet: RouterOutlet) {    return outlet && outlet.activatedRouteData && outlet.activatedRouteData['animation'];  }}

如果在两个路由之间切换,导航进来时,HeroDetailComponentHeroListComponent 会从左侧滑入;导航离开时将会从右侧划出。

小结

本节包括以下内容:

  • 把应用组织成特性区。

  • 命令式的从一个组件导航到另一个。

  • 通过路由参数传递信息,并在组件中订阅它们。

  • 把这个特性分区模块导入根模块 AppModule

  • 把动画应用到路由组件上。

做完这些修改之后,目录结构如下:

本节产生的文件列表:

  1. Path:"animations.ts" 。

    import {      trigger, animateChild, group,      transition, animate, style, query    } from '@angular/animations';    // Routable animations    export const slideInAnimation =      trigger('routeAnimation', [        transition('heroes <=> hero', [          style({ position: 'relative' }),          query(':enter, :leave', [            style({              position: 'absolute',              top: 0,              left: 0,              width: '100%'            })          ]),          query(':enter', [            style({ left: '-100%'})          ]),          query(':leave', animateChild()),          group([            query(':leave', [              animate('300ms ease-out', style({ left: '100%'}))            ]),            query(':enter', [              animate('300ms ease-out', style({ left: '0%'}))            ])          ]),          query(':enter', animateChild()),        ])      ]);

  1. Path:"app.component.html" 。

    <h1>Angular Router</h1>    <nav>      <a routerLink="/crisis-center" routerLinkActive="active">Crisis Center</a>      <a routerLink="/heroes" routerLinkActive="active">Heroes</a>    </nav>    <div [@routeAnimation]="getAnimationData(routerOutlet)">      <router-outlet #routerOutlet="outlet"></router-outlet>    </div>

  1. Path:"app.component.ts" 。

    import { Component } from '@angular/core';    import { RouterOutlet } from '@angular/router';    import { slideInAnimation } from './animations';    @Component({      selector: 'app-root',      templateUrl: 'app.component.html',      styleUrls: ['app.component.css'],      animations: [ slideInAnimation ]    })    export class AppComponent {      getAnimationData(outlet: RouterOutlet) {        return outlet && outlet.activatedRouteData && outlet.activatedRouteData['animation'];      }    }

  1. Path:"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 { AppRoutingModule } from './app-routing.module';    import { HeroesModule }     from './heroes/heroes.module';    import { CrisisListComponent }   from './crisis-list/crisis-list.component';    import { PageNotFoundComponent } from './page-not-found/page-not-found.component';    @NgModule({      imports: [        BrowserModule,        BrowserAnimationsModule,        FormsModule,        HeroesModule,        AppRoutingModule      ],      declarations: [        AppComponent,        CrisisListComponent,        PageNotFoundComponent      ],      bootstrap: [ AppComponent ]    })    export class AppModule { }

  1. Path:"app-routing.module.ts" 。

    import { NgModule }              from '@angular/core';    import { RouterModule, Routes }  from '@angular/router';    import { CrisisListComponent }   from './crisis-list/crisis-list.component';    /* . . . */    import { PageNotFoundComponent } from './page-not-found/page-not-found.component';    const appRoutes: Routes = [      { path: 'crisis-center', component: CrisisListComponent },    /* . . . */      { path: '',   redirectTo: '/heroes', pathMatch: 'full' },      { path: '**', component: PageNotFoundComponent }    ];    @NgModule({      imports: [        RouterModule.forRoot(          appRoutes,          { enableTracing: true } // <-- debugging purposes only        )      ],      exports: [        RouterModule      ]    })    export class AppRoutingModule {}

  1. Path:"hero-list.component.css" 。

    /* HeroListComponent's private CSS styles */    .heroes {      margin: 0 0 2em 0;      list-style-type: none;      padding: 0;      width: 15em;    }    .heroes li {      position: relative;      cursor: pointer;      background-color: #EEE;      margin: .5em;      padding: .3em 0;      height: 1.6em;      border-radius: 4px;    }    .heroes li:hover {      color: #607D8B;      background-color: #DDD;      left: .1em;    }    .heroes a {      color: #888;      text-decoration: none;      position: relative;      display: block;    }    .heroes a:hover {      color:#607D8B;    }    .heroes .badge {      display: inline-block;      font-size: small;      color: white;      padding: 0.8em 0.7em 0 0.7em;      background-color: #607D8B;      line-height: 1em;      position: relative;      left: -1px;      top: -4px;      height: 1.8em;      min-width: 16px;      text-align: right;      margin-right: .8em;      border-radius: 4px 0 0 4px;    }    button {      background-color: #eee;      border: none;      padding: 5px 10px;      border-radius: 4px;      cursor: pointer;      cursor: hand;      font-family: Arial;    }    button:hover {      background-color: #cfd8dc;    }    button.delete {      position: relative;      left: 194px;      top: -32px;      background-color: gray !important;      color: white;    }    .heroes li.selected {      background-color: #CFD8DC;      color: white;    }    .heroes li.selected:hover {      background-color: #BBD8DC;    }

  1. Path:"hero-list.component.html" 。

    <h2>HEROES</h2>    <ul class="heroes">      <li *ngFor="let hero of heroes$ | async"        [class.selected]="hero.id === selectedId">        <a [routerLink]="['/hero', hero.id]">          <span class="badge">{{ hero.id }}</span>{{ hero.name }}        </a>      </li>    </ul>    <button routerLink="/sidekicks">Go to sidekicks</button>

  1. Path:"hero-list.component.ts" 。

    // TODO: Feature Componetized like CrisisCenter    import { Observable } from 'rxjs';    import { switchMap } from 'rxjs/operators';    import { Component, OnInit } from '@angular/core';    import { ActivatedRoute } from '@angular/router';    import { HeroService }  from '../hero.service';    import { Hero } from '../hero';    @Component({      selector: 'app-hero-list',      templateUrl: './hero-list.component.html',      styleUrls: ['./hero-list.component.css']    })    export class HeroListComponent implements OnInit {      heroes$: Observable<Hero[]>;      selectedId: number;      constructor(        private service: HeroService,        private route: ActivatedRoute      ) {}      ngOnInit() {        this.heroes$ = this.route.paramMap.pipe(          switchMap(params => {            // (+) before `params.get()` turns the string into a number            this.selectedId = +params.get('id');            return this.service.getHeroes();          })        );      }    }

  1. Path:"hero-detail.component.html" 。

    <h2>HEROES</h2>    <div *ngIf="hero$ | async as hero">      <h3>"{{ hero.name }}"</h3>      <div>        <label>Id: </label>{{ hero.id }}</div>      <div>        <label>Name: </label>        <input [(ngModel)]="hero.name" placeholder="name"/>      </div>      <p>        <button (click)="gotoHeroes(hero)">Back</button>      </p>    </div>

  1. Path:"hero-detail.component.ts" 。

    import { switchMap } from 'rxjs/operators';    import { Component, OnInit } from '@angular/core';    import { Router, ActivatedRoute, ParamMap } from '@angular/router';    import { Observable } from 'rxjs';    import { HeroService }  from '../hero.service';    import { Hero } from '../hero';    @Component({      selector: 'app-hero-detail',      templateUrl: './hero-detail.component.html',      styleUrls: ['./hero-detail.component.css']    })    export class HeroDetailComponent implements OnInit {      hero$: Observable<Hero>;      constructor(        private route: ActivatedRoute,        private router: Router,        private service: HeroService      ) {}      ngOnInit() {        this.hero$ = this.route.paramMap.pipe(          switchMap((params: ParamMap) =>            this.service.getHero(params.get('id')))        );      }      gotoHeroes(hero: Hero) {        let heroId = hero ? hero.id : null;        // Pass along the hero id if available        // so that the HeroList component can select that hero.        // Include a junk 'foo' property for fun.        this.router.navigate(['/heroes', { id: heroId, foo: 'foo' }]);      }    }    /*      this.router.navigate(['/superheroes', { id: heroId, foo: 'foo' }]);    */

  1. Path:"hero.service.ts" 。

    import { Injectable } from '@angular/core';    import { Observable, of } from 'rxjs';    import { map } from 'rxjs/operators';    import { Hero } from './hero';    import { HEROES } from './mock-heroes';    import { MessageService } from '../message.service';    @Injectable({      providedIn: 'root',    })    export class HeroService {      constructor(private messageService: MessageService) { }      getHeroes(): Observable<Hero[]> {        // TODO: send the message _after_ fetching the heroes        this.messageService.add('HeroService: fetched heroes');        return of(HEROES);      }      getHero(id: number | string) {        return this.getHeroes().pipe(          // (+) before `id` turns the string into a number          map((heroes: Hero[]) => heroes.find(hero => hero.id === +id))        );      }    }

  1. Path:"heroes.module.ts" 。

    import { NgModule }       from '@angular/core';    import { CommonModule }   from '@angular/common';    import { FormsModule }    from '@angular/forms';    import { HeroListComponent }    from './hero-list/hero-list.component';    import { HeroDetailComponent }  from './hero-detail/hero-detail.component';    import { HeroesRoutingModule } from './heroes-routing.module';    @NgModule({      imports: [        CommonModule,        FormsModule,        HeroesRoutingModule      ],      declarations: [        HeroListComponent,        HeroDetailComponent      ]    })    export class HeroesModule {}

  1. Path:"heroes-routing.module.ts" 。

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

  1. Path:"message.service.ts" 。

    import { Injectable } from '@angular/core';    @Injectable({      providedIn: 'root',    })    export class MessageService {      messages: string[] = [];      add(message: string) {        this.messages.push(message);      }      clear() {        this.messages = [];      }    }

本节将向你展示如何在应用中添加子路由并使用相对路由。

要为应用当前的危机中心添加更多特性,请执行类似于 heroes 特性的步骤:

  • 在 src/app 目录下创建一个 crisis-center 子目录。

  • 把 app/heroes 中的文件和目录复制到新的 crisis-center 文件夹中。

  • 在这些新建的文件中,把每个 "hero" 都改成 "crisis",每个 "heroes" 都改成 "crises"。

  • 把这些 NgModule 文件改名为 crisis-center.module.ts 和 crisis-center-routing.module.ts。

使用 mockcrises 来代替 mockheroes

Path:"src/app/crisis-center/mock-crises.ts" 。

import { Crisis } from './crisis';export const CRISES: Crisis[] = [  { id: 1, name: 'Dragon Burning Cities' },  { id: 2, name: 'Sky Rains Great White Sharks' },  { id: 3, name: 'Giant Asteroid Heading For Earth' },  { id: 4, name: 'Procrastinators Meeting Delayed Again' },]

最终的危机中心可以作为引入子路由这个新概念的基础。 你可以把英雄管理保持在当前状态,以便和危机中心进行对比。

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

带有子路由的危机中心

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

  • 把每个特性放在自己的目录中。

  • 每个特性都有自己的 Angular 特性模块。

  • 每个特性区都有自己的根组件。

  • 每个特性区的根组件中都有自己的路由出口及其子路由。

  • 特性区的路由很少(或完全不)与其它特性区的路由交叉。

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

子路由组件

crisis-center 目录下生成一个 CrisisCenter 组件:

ng generate component crisis-center/crisis-center

使用如下代码更新组件模板:

Path:"src/app/crisis-center/crisis-center/crisis-center.component.html" 。

<h2>CRISIS CENTER</h2><router-outlet></router-outlet>

CrisisCenterComponentAppComponent 有下列共同点:

它是危机中心特性区的根,正如 AppComponent 是整个应用的根。

它是危机管理特性区的壳,正如 AppComponent 是管理高层工作流的壳。

就像大多数的壳一样,CrisisCenterComponent 类是最小化的,因为它没有业务逻辑,它的模板中没有链接,只有一个标题和用于放置危机中心的子组件的 <router-outlet>

子路由配置

crisis-center 目录下生成一个 CrisisCenterHome 组件,作为 "危机中心" 特性的宿主页面。

ng generate component crisis-center/crisis-center-home

用一条欢迎信息修改 Crisis Center 中的模板。

Path:"src/app/crisis-center/crisis-center-home/crisis-center-home.component.html" 。

<p>Welcome to the Crisis Center</p>

把 "heroes-routing.module.ts" 文件复制过来,改名为 "crisis-center-routing.module.ts",并修改它。 这次你要把子路由定义在父路由 crisis-center 中。

Path:"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          }        ]      }    ]  }];

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

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

对这些子路由的处理中有一些重要的差异。

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

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

Crisis Detail 路由是 Crisis List 的子路由。由于路由器默认会复用组件,因此当你选择了另一个危机时,CrisisDetailComponent 会被复用。 作为对比,回头看看 Hero Detail 路由,每当你从列表中选择了不同的英雄时,都会重新创建该组件。

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

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

要导航到 CrisisCenterHomeComponent,完整的 URL 是 /crisis-center (/crisis-center + '' + '')。

要导航到 CrisisDetailComponent 以展示 id=2 的危机,完整的 URL 是 /crisis-center/2 (/crisis-center + '' + '/2')。

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

localhost:4200/crisis-center/2

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

Path:"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/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';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 前面:

  1. Path:"src/app/crisis-center/crisis-center.module.ts" 。

    import { NgModule }       from '@angular/core';    import { FormsModule }    from '@angular/forms';    import { CommonModule }   from '@angular/common';    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 { CrisisCenterRoutingModule } from './crisis-center-routing.module';    @NgModule({      imports: [        CommonModule,        FormsModule,        CrisisCenterRoutingModule      ],      declarations: [        CrisisCenterComponent,        CrisisListComponent,        CrisisCenterHomeComponent,        CrisisDetailComponent      ]    })    export class CrisisCenterModule {}

  1. Path:"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 './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';    @NgModule({      imports: [        CommonModule,        FormsModule,        HeroesModule,        CrisisCenterModule,        AppRoutingModule      ],      declarations: [        AppComponent,        PageNotFoundComponent      ],      bootstrap: [ AppComponent ]    })    export class AppModule { }

从 "app.routing.ts" 中移除危机中心的初始路由。 因为现在是 HeroesModuleCrisisCenter 模块提供了这些特性路由。

"app-routing.module.ts" 文件中只有应用的顶层路由,比如默认路由和通配符路由。

Path:"src/app/app-routing.module.ts (v3)" 。

import { NgModule }                from '@angular/core';import { RouterModule, Routes }    from '@angular/router';import { PageNotFoundComponent }  from './page-not-found/page-not-found.component';const appRoutes: Routes = [  { path: '',   redirectTo: '/heroes', pathMatch: 'full' },  { path: '**', component: PageNotFoundComponent }];@NgModule({  imports: [    RouterModule.forRoot(      appRoutes,      { enableTracing: true } // <-- debugging purposes only    )  ],  exports: [    RouterModule  ]})export class AppRoutingModule {}

相对导航

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

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

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

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

路由器支持在链接参数数组中使用“目录式”语法来为查询路由名提供帮助:

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

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

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

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

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

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

使用相对 URL 导航到危机列表

你已经注入了组成相对导航路径所需的 ActivatedRoute

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

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

Path:"src/app/crisis-center/crisis-detail/crisis-detail.component.ts (relative navigation)" 。

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

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

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

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

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

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

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

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

Path:"src/app/app.component.html (outlets)" 。

<div [@routeAnimation]="getAnimationData(routerOutlet)">  <router-outlet #routerOutlet="outlet"></router-outlet></div><router-outlet name="popup"></router-outlet>

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

  1. 第二路由。

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

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

  • 它们彼此互不依赖。

  • 它们与其它路由组合使用。

  • 它们显示在命名出口中。

生成一个新的组件来组合这个消息。

    ng generate component compose-message

它显示一个简单的表单,包括一个头、一个消息输入框和两个按钮:“Send”和“Cancel”。

下面是该组件及其模板和样式:

  • Path:"src/app/compose-message/compose-message.component.css" 。

        :host {          position: relative; bottom: 10%;        }

  • Path:"src/app/compose-message/compose-message.component.html" 。

        <h3>Contact Crisis Center</h3>        <div *ngIf="details">          {{ details }}        </div>        <div>          <div>            <label>Message: </label>          </div>          <div>            <textarea [(ngModel)]="message" rows="10" cols="35" [disabled]="sending"></textarea>          </div>        </div>        <p *ngIf="!sending">          <button (click)="send()">Send</button>          <button (click)="cancel()">Cancel</button>        </p>

  • Path:"src/app/compose-message/compose-message.component.ts" 。

        import { Component, HostBinding } from '@angular/core';        import { Router }                 from '@angular/router';        @Component({          selector: 'app-compose-message',          templateUrl: './compose-message.component.html',          styleUrls: ['./compose-message.component.css']        })        export class ComposeMessageComponent {          details: string;          message: string;          sending = false;          constructor(private router: Router) {}          send() {            this.sending = true;            this.details = 'Sending Message...';            setTimeout(() => {              this.sending = false;              this.closePopup();            }, 1000);          }          cancel() {            this.closePopup();          }          closePopup() {            // Providing a `null` value to the named outlet            // clears the contents of the named outlet            this.router.navigate([{ outlets: { popup: null }}]);          }        }

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

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

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

  1. 添加第二路由。

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

Path:"src/app/app-routing.module.ts (compose route)" 。

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

除了 pathcomponent 属性之外还有一个新的属性 outlet,它被设置成了 'popup'。 这个路由现在指向了 popup 出口,而 ComposeMessageComponent 也将显示在那里。

为了给用户某种途径来打开这个弹出框,还要往 AppComponent 模板中添加一个“Contact”链接。

Path:"src/app/app.component.html (contact-link)" 。

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

虽然 compose 路由被配置到了 popup 出口上,但这仍然不足以把该路由和 RouterLink 指令联系起来。 你还要在链接参数数组中指定这个命名出口,并通过属性绑定的形式把它绑定到 `RouterLink 上。

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

换句话说,当用户点击此链接时,路由器会在路由出口 popup 中显示与 compose 路由相关联的组件。

当只需要考虑一个路由和一个无名出口时,外部对象中的这个 outlets 对象是完全不必要的。

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

路由到一个命名出口会揭示一个路由特性: 你可以在同一个 RouterLink 指令中为多个路由出口指定多个路由。

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

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

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

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

  • "crisis-center" 是主导航。

  • 圆括号包裹的部分是第二路由。

  • 第二路由包括一个出口名称(popup)、一个冒号分隔符和第二路由的路径(compose)。

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

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

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

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

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

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

  1. 清除第二路由。

像常规出口一样,二级出口会一直存在,直到你导航到新组件。

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

再看 closePopup() 方法:

Path:"src/app/compose-message/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 }}]);}

单击 “send” 或 “cancel” 按钮可以清除弹出视图。closePopup() 函数会使用 Router.navigate() 方法强制导航,并传入一个链接参数数组。

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

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

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

  • 该用户可能无权导航到目标组件。

  • 可能用户得先登录(认证)。

  • 在显示目标组件前,你可能得先获取某些数据。

  • 在离开组件前,你可能要先保存修改。

  • 你可能要询问用户:你是否要放弃本次更改,而不用保存它们?

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

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

  • 如果它返回 true,导航过程会继续

  • 如果它返回 false,导航过程就会终止,且用户留在原地。

  • 如果它返回 UrlTree,则取消当前的导航,并且开始导航到返回的这个 UrlTree.

注:

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

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

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

注:

  • 提供给 Router 的可观察对象还必须能结束(complete)。否则,导航就不会继续。

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

  • CanActivate来处理导航到某路由的情况。

  • CanActivateChild来处理导航到某子路由的情况。

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

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

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

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

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

CanActivate :需要身份验证

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

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

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

使用一些新的管理功能来扩展危机中心。首先添加一个名为 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

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

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

Path:"src/app/admin/admin/admin.component.html" 。

    <h3>ADMIN</h3>    <nav>      <a routerLink="./" routerLinkActive="active"        [routerLinkActiveOptions]="{ exact: true }">Dashboard</a>      <a routerLink="./crises" routerLinkActive="active">Manage Crises</a>      <a routerLink="./heroes" routerLinkActive="active">Manage Heroes</a>    </nav>    <router-outlet></router-outlet>

Path:"src/app/admin/admin-dashboard/admin-dashboard.component.htmlsrc/app/admin/admin.module.tssrc/app/admin/manage-crises/manage-crises.component.htmlsrc/app/admin/manage-heroes/manage-heroes.component.html" 。

    <p>Dashboard</p>

Path:"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 {}

Path:"src/app/admin/manage-crises/manage-crises.component.html" 。

    <p>Manage your crises here</p>

Path:"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 时才会激活它,而不会在导航到它的某个子路由时。

无组件路由:分组路由,而不需要组件。

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

Path:"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 下的子路由有一个 path 和一个 children 属性,但是它没有使用 component。这就定义了一个无组件路由。

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

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

Path:"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 './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 中添加一个链接,让用户能点击它,以访问该特性。

Path:"src/app/app.component.html (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>    <div [@routeAnimation]="getAnimationData(routerOutlet)">      <router-outlet #routerOutlet="outlet"></router-outlet>    </div>    <router-outlet name="popup"></router-outlet>2. 守护“管理特性”区。    现在危机中心的每个路由都是对所有人开放的。这些新的管理特性应该只能被已登录用户访问。    编写一个 `CanActivate()` 守卫,将正在尝试访问管理组件匿名用户重定向到登录页。    在 "auth" 文件夹中生成一个 `AuthGuard`。
ng generate guard auth/auth```

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

Path:"src/app/auth/auth.guard.ts (excerpt)" 。

    import { Injectable } from '@angular/core';    import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';    @Injectable({      providedIn: 'root',    })    export class AuthGuard implements CanActivate {      canActivate(        next: ActivatedRouteSnapshot,        state: RouterStateSnapshot): boolean {        console.log('AuthGuard#canActivate called');        return true;      }    }

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

Path:"src/app/admin/admin-routing.module.ts (guarded admin route)" 。

    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 {}

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

  1. 通过 AuthGuard 验证。

AuthGuard 模拟身份验证。

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

    ng generate service auth/auth

修改 AuthService 以登入此用户:

Path:"src/app/auth/auth.service.ts (excerpt)" 。

    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;      login(): Observable<boolean> {        return of(true).pipe(          delay(1000),          tap(val => this.isLoggedIn = true)        );      }      logout(): void {        this.isLoggedIn = false;      }    }

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

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

修改 AuthGuard 以调用 AuthService

Path:"src/app/auth/auth.guard.ts (v2)" 。

    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(        next: ActivatedRouteSnapshot,        state: RouterStateSnapshot): true|UrlTree {        let 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');      }    }

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

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

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

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

  1. 添加 LoginComponent。

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

    ng generate component auth/login

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

Path:"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 {    }

Path:"src/app/auth/login/login.component.html" 。

    <h2>LOGIN</h2>    <p>{{message}}</p>    <p>      <button (click)="login()"  *ngIf="!authService.isLoggedIn">Login</button>      <button (click)="logout()" *ngIf="authService.isLoggedIn">Logout</button>    </p>

Path:"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.setMessage();      }      setMessage() {        this.message = 'Logged ' + (this.authService.isLoggedIn ? 'in' : 'out');      }      login() {        this.message = 'Trying to log in ...';        this.authService.login().subscribe(() => {          this.setMessage();          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.setMessage();      }    }

Path:"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 和一个 RouterStateSnapshotCanActivateChild 方法可以返回 Observable<boolean|UrlTree>Promise<boolean|UrlTree> 来支持异步检查,或 booleanUrlTree 来支持同步检查。 这里返回的或者是 true 以便允许用户访问管理特性模块,或者是 UrlTree 以便把用户重定向到登录页:

Path:"src/app/auth/auth.guard.ts (excerpt)" 。

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 {    let url: string = state.url;    return this.checkLogin(url);  }  canActivateChild(    route: ActivatedRouteSnapshot,    state: RouterStateSnapshot): true|UrlTree {    return this.canActivate(route, state);  }/* . . . */}

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

Path:"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 守卫能帮助你决定如何处理未保存的更改,以及如何处理。

取消与保存

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

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

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

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

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

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

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

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

ng generate service dialog

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

Path:"src/app/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);  };}

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

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

ng generate guard can-deactivate

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

Path:"src/app/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;  }}

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

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

Path:"src/app/can-deactivate.guard.ts (component-specific)" 。

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 组件,它已经实现了对未保存的更改进行确认的工作流。

Path:"src/app/crisis-center/crisis-detail/crisis-detail.component.ts (excerpt)" 。

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

Path:"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/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 DetailCrisis Detail 中,它们等待路由读取完对应的英雄和危机。

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

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

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

导航前预先加载路由信息

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

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

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

ng generate service crisis-center/crisis-detail-resolver

Path:"src/app/crisis-center/crisis-detail-resolver.service.ts (generated)" 。

import { Injectable } from '@angular/core';@Injectable({  providedIn: 'root',})export class CrisisDetailResolverService {  constructor() { }}

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

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

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

CrisisService.getCrisis() 方法返回一个可观察对象,以防止在数据获取完之前加载本路由。 Router 守卫要求这个可观察对象必须可结束(complete),也就是说它已经发出了所有值。 你可以为 take 操作符传入一个参数 1,以确保这个可观察对象会在从 getCrisis 方法所返回的可观察对象中取到第一个值之后就会结束。

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

Path:"src/app/crisis-center/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, take }         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> {    let id = route.paramMap.get('id');    return this.cs.getCrisis(id).pipe(      take(1),      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 对象。

Path:"src/app/crisis-center/crisis-center-routing.module.ts (resolver)" 。

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 属性中获取危机详情,这正是你重新配置路由的恰当时机。

Path:"src/app/crisis-center/crisis-detail/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 接口是可选的。CrisisDetailResolverService 没有继承自某个基类。路由器只要找到了这个方法,就会调用它。

  1. 路由器会在用户可以导航的任何情况下调用该解析器,这样你就不用针对每个用例都编写代码了。

  1. 在任何一个解析器中返回空的 Observable 就会取消导航。

查询参数及片段

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

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

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

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

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

Path:"src/app/auth/auth.guard.ts (v3)" 。

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 {    let 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    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'    };    // Redirect to the login page with extras    return this.router.createUrlTree(['/login'], navigationExtras);  }}

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

Path:"src/app/auth/login/login.component.ts (preserve)" 。

// Set our navigation extras object// that passes on our global query params and fragmentlet navigationExtras: NavigationExtras = {  queryParamsHandling: 'preserve',  preserveFragment: true};// Redirect the userthis.router.navigate([redirectUrl], navigationExtras);

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

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

Path:"src/app/admin/admin-dashboard/admin-dashboard.component.ts (v2)" 。

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 按钮,它会带着你提供的 queryParamMapfragment 跳转到登录页。 点击 Login 按钮,你就会被重定向到 Admin Dashboard 页。 注意,它仍然带着上一步提供的 queryParamMapfragment

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

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

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

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

你可以只在用户请求时才加载某些特性区。

对于那些只访问应用程序某些区域的用户,这样能加快加载速度。

你可以持续扩充惰性加载特性区的功能,而不用增加初始加载的包体积。

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

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

惰性加载路由配置

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

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

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

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

Path:"app-routing.module.ts (load children)" 。

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

注:

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

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

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

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

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

在 "app.module.ts" 中,从顶部移除 AdminModule 的导入语句,并且从 NgModuleimports 数组中移除 AdminModule

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

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

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

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

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

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

Path:"src/app/auth/auth.guard.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 路由是这样的:

Path:"app-routing.module.ts (lazy admin route)" 。

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

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

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

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

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

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

  1. 预加载的工作原理

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

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

  • 完全不预加载,这是默认值。惰性加载的特性区仍然会按需加载。

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

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

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

  1. 惰性加载危机中心

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

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

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

  • 设置 loadChildren 字符串来加载 CrisisCenterModule

  • 从 "app.module.ts" 中移除所有对 CrisisCenterModule 的引用。

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

  • Path:"app.module.ts" 。

        import { NgModule }       from '@angular/core';        import { BrowserModule }  from '@angular/platform-browser';        import { FormsModule }    from '@angular/forms';        import { BrowserAnimationsModule } from '@angular/platform-browser/animations';        import { Router } from '@angular/router';        import { AppComponent }            from './app.component';        import { PageNotFoundComponent }   from './page-not-found/page-not-found.component';        import { ComposeMessageComponent } from './compose-message/compose-message.component';        import { AppRoutingModule }        from './app-routing.module';        import { HeroesModule }            from './heroes/heroes.module';        import { AuthModule }              from './auth/auth.module';        @NgModule({          imports: [            BrowserModule,            BrowserAnimationsModule,            FormsModule,            HeroesModule,            AuthModule,            AppRoutingModule,          ],          declarations: [            AppComponent,            ComposeMessageComponent,            PageNotFoundComponent          ],          bootstrap: [ AppComponent ]        })        export class AppModule {        }

  • Path:"app-routing.module.ts" 。

        import { NgModule }     from '@angular/core';        import {          RouterModule, Routes,        } from '@angular/router';        import { ComposeMessageComponent } from './compose-message/compose-message.component';        import { PageNotFoundComponent }   from './page-not-found/page-not-found.component';        import { AuthGuard }               from './auth/auth.guard';        const appRoutes: Routes = [          {            path: 'compose',            component: ComposeMessageComponent,            outlet: 'popup'          },          {            path: 'admin',            loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule),            canLoad: [AuthGuard]          },          {            path: 'crisis-center',            loadChildren: () => import('./crisis-center/crisis-center.module').then(m => m.CrisisCenterModule)          },          { path: '',   redirectTo: '/heroes', pathMatch: 'full' },          { path: '**', component: PageNotFoundComponent }        ];        @NgModule({          imports: [            RouterModule.forRoot(              appRoutes,            )          ],          exports: [            RouterModule          ]        })        export class AppRoutingModule {}

  • Path:"crisis-center-routing.module.ts" 。

        import { NgModule }             from '@angular/core';        import { RouterModule, Routes } from '@angular/router';        import { CrisisCenterHomeComponent } from './crisis-center-home/crisis-center-home.component';        import { CrisisListComponent }       from './crisis-list/crisis-list.component';        import { CrisisCenterComponent }     from './crisis-center/crisis-center.component';        import { CrisisDetailComponent }     from './crisis-detail/crisis-detail.component';        import { CanDeactivateGuard }             from '../can-deactivate.guard';        import { CrisisDetailResolverService }    from './crisis-detail-resolver.service';        const crisisCenterRoutes: Routes = [          {            path: '',            component: CrisisCenterComponent,            children: [              {                path: '',                component: CrisisListComponent,                children: [                  {                    path: ':id',                    component: CrisisDetailComponent,                    canDeactivate: [CanDeactivateGuard],                    resolve: {                      crisis: CrisisDetailResolverService                    }                  },                  {                    path: '',                    component: CrisisCenterHomeComponent                  }                ]              }            ]          }        ];        @NgModule({          imports: [            RouterModule.forChild(crisisCenterRoutes)          ],          exports: [            RouterModule          ]        })        export class CrisisCenterRoutingModule { }

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

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

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

Path:"src/app/app-routing.module.ts (preload all)" 。

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

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

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

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

CanLoad 会阻塞预加载

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

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

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

自定义预加载策略

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

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

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

Path:"src/app/app-routing.module.ts (route data preload)" 。

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

生成一个新的 SelectivePreloadingStrategy 服务。

ng generate service selective-preloading-strategy

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

Path:"src/app/selective-preloading-strategy.service.ts" 。

import { Injectable } from '@angular/core';import { PreloadingStrategy, Route } from '@angular/router';import { Observable, of } from 'rxjs';@Injectable({  providedIn: 'root',})export class SelectivePreloadingStrategyService implements PreloadingStrategy {  preloadedModules: string[] = [];  preload(route: Route, load: () => Observable<any>): Observable<any> {    if (route.data && 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 of(null);    }  }}

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

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

  1. 要加载的路由。

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

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

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

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

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

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

  1. SelectivePreloadingStrategyService 导入到 AppRoutingModule 中。

  1. PreloadAllModules 策略替换成对 forRoot() 的调用,并且传入这个 SelectivePreloadingStrategyService

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

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

导入 SelectivePreloadingStrategyService(它是一个服务)。

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

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

现在文件如下:

Path:"src/app/admin/admin-dashboard/admin-dashboard.component.ts (preloaded modules)" 。

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

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

使用重定向迁移 URL

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

把 /heroes 改为 /superheroes

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

Path:"src/app/heroes/heroes-routing.module.ts (heroes redirects)" 。

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

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

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

- 当使用绝对地址重定向时,路由器将会使用路由配置的 `redirectTo` 属性中规定的查询参数和片段。

- 当使用相对地址重定向时,路由器将会使用源地址(跳转前的地址)中的查询参数和片段。

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

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

Path:"src/app/app-routing.module.ts (superheroes redirect)" 。

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

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

Path:"src/app/app.component.html (superheroes active routerLink))" 。

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

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

Path:"src/app/heroes/hero-detail/hero-detail.component.ts (goToHeroes)" 。

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

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

审查路由器配置

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

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

Path:"src/app/app.module.ts (inspect the router config)" 。

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

最终的应用

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

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

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

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

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

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

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

HashLocationStrategy - 支持 “hash URL” 风格。

RouterModule.forRoot() 函数把 LocationStrategy 设置成了 PathLocationStrategy,使其成为了默认策略。 你还可以在启动过程中改写(override)它,来切换到 HashLocationStrategy 风格。

下面的部分重点介绍了一些路由器的核心概念。

路由器导入

Angular 的 Router 是一个可选服务,它为指定的 URL 提供特定的组件视图。它不是 Angular 核心的一部分,因此它位于自己的包 @angular/router 中。

从任何其它的 Angular 包中导入你需要的东西。

Path:"src/app/app.module.ts (import)" 。

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

有关浏览器 URL 风格的更多信息,请参阅 LocationStrategy 和浏览器的网址样式

配置

带路由的 Angular 应用中有一个 Router 服务的单例实例。当浏览器的 URL 发生变化时,该路由器会查找相应的 Route,以便根据它确定要显示的组件。

在配置之前,路由器没有任何路由。下面的例子创建了五个路由定义,通过 RouterModule.forRoot() 方法配置路由器,并把结果添加到 AppModuleimports 数组中。

Path:"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,      { enableTracing: true } // <-- debugging purposes only    )    // other imports here  ],  ...})export class AppModule { }

appRoutes 路由数组描述了如何导航。把它传给模块的 imports 数组中的 RouterModule.forRoot() 方法来配置路由器。

每个 Route 都会把一个 URL path 映射到一个组件。路径中没有前导斜杠。路由器会为你解析并构建最终的 URL,这样你就可以在应用视图中导航时使用相对路径和绝对路径了。

第二个路由中的 :id 是路由参数的令牌。在像 "/hero/42" 这样的 URL 中,“42”是 id 参数的值。相应的 HeroDetailComponent 用这个值来查找并显示 id 为 42 的英雄。

第三个路由中的 data 属性是存放与该特定路由关联的任意数据的地方。每个激活的路由都可以访问 data 属性。可以用它来存储页面标题,面包屑文本和其它只读静态数据等项目。你可以尝试使用解析器守卫来检索动态数据。

第四个路由中的空路径表示该应用的默认路径 - 当 URL 中的路径为空时通常要去的地方,就像它在刚进来时一样。这个默认路由重定向到了 "/heroes" 这个 URL 的路由,因此会显示 HeroesListComponent

如果你需要查看导航生命周期中发生了什么事件,可以把 enableTracing 选项作为路由器默认配置的一部分。这会把每个导航生命周期中发生的每个路由器事件都输出到浏览器控制台中。enableTracing 只会用于调试目的。你可以把 enableTracing: true 选项作为第二个参数传给 RouterModule.forRoot() 方法。

路由出口

RouterOutlet 是一个来自路由器库的指令,虽然它的用法像组件一样。它充当占位符,用于在模板中标记出路由器应该显示把该组件显示在那个出口的位置。

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

对于上面的配置,当这个应用的浏览器 URL 变为 "/heroes" 时,路由器就会把这个 URL 与路由路径 "/heroes" 匹配,并把 HeroListComponent 作为兄弟元素显示在宿主组件模板中的 RouterOutlet 下方。

路由链接

要想通过某些用户操作(比如单击一下 a 标签)进行导航,请使用 RouterLink

考虑下面的模板:

Path:"src/app/app.component.html" 。

<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 指令让路由器可以控制这些元素。导航路径是固定的,所以你可以给 routerLink 赋值一个字符串(“一次性”绑定)。

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

活动路由链路

RouterLinkActive 指令会根据当前的 RouterState 切换活动 RouterLink 上所绑定的 CSS 类。

在每个 a 标签上,你会看到一个到 RouterLinkActive 指令的属性绑定,就像 routerLinkActive="..."。

等号 = 右侧的模板表达式,包含一个以空格分隔的 CSS 类字符串,当这个链接处于活动状态时,路由器就会加上这些字符串(并在非活动状态时删除)。你可以把 RouterLinkActive 指令设置成一串类的字符串,比如 [routerLinkActive]="'active fluffy'",也可以把它绑定到一个返回这样一个字符串的组件属性上。

活动路由链接会级联到路由树的每个级别,这样父路由和子路由链接就可以同时处于活动状态。要覆盖这种行为,你可以用 { exact: true } 表达式绑定到 [routerLinkActiveOptions] 输入绑定。使用 { exact: true } 之后,给定的 RouterLink 只有在 URL 与当前 URL 完全匹配时才会激活。

路由器状态

每个成功的导航生命周期结束后,路由器都会构建一个 ActivatedRoute 对象树,它构成了路由器的当前状态。你可以从任何地方使用应用的 Router 服务和 routerState 属性来访问当前的 RouterState

RouterState 中的每个 ActivatedRoute 都提供了向上或向下遍历路由树的方法,用于从父路由、子路由和兄弟路由中获取信息。

激活路由

路由的路径和参数可以通过注入名为 ActivatedRoute 的路由服务获得。它提供了大量有用的信息,包括:

属性说明
url一个路由路径的 Observable,是一个由路由路径的各个部分组成的字符串数组。
data包含提供给当前路由的 data 对象的 Observable。 也包含任何由解析守卫解析出的值。
paramMap一个包含该路由的必要参数和可选参数 map 的 Observable。 这个 map 支持从同一个参数中获得单个或多个值。
queryParamMap一个包含适用于所有路由的查询参数 map 的 Observable。 这个 map 支持从同一个查询参数中获得单个或多个值。
fragment一个适用于所有路由的 URL 片段的 Observable
outlet用来渲染该路由的 RouterOutlet 的名字。 对于无名出口,这个出口的名字是 primary
routeConfig包含原始路径的那个路由的配置信息。
parent当该路由是子路由时,表示该路由的父级 ActivatedRoute
firstChild包含该路由的子路由列表中的第一个 ActivatedRoute
children包含当前路由下所有激活的子路由。

还有两个较旧的属性,但更推荐使用它们的替代品,因为它们可能会在以后的 Angular 版本中弃用。

- `params` :一个 `Observable`,它包含专属于该路由的必要参数和可选参数。请改用 `paramMap`。

- `queryParams`:一个包含可用于所有路由的查询参数的 `Observable`。请改用 `queryParamMap`。

路由器事件

Router 在每次导航过程中都会通过 Router.events 属性发出导航事件。这些事件的范围贯穿从导航开始和结束之间的多个时间点。导航事件的完整列表如下表所示。

路由事件说明
NavigationStart导航开始时触发的事件。
RouteConfigLoadStart在 Router 惰性加载路由配置之前触发的事件。
RouteConfigLoadEnd在某个路由已经惰性加载完毕时触发的事件。
RoutesRecognized当路由器解析了 URL,而且路由已经识别完毕时触发的事件。
GuardsCheckStart当路由器开始进入路由守卫阶段时触发的事件。
ChildActivationStart当路由器开始激活某路由的子路由时触发的事件。
ActivationStart当路由器开始激活某个路由时触发的事件。
GuardsCheckEnd当路由器成功结束了路由守卫阶段时触发的事件。
ResolveStart当路由器开始路由解析阶段时触发的事件。
ChildActivationEnd当路由器成功激活某路由的子路由时触发的事件。
ResolveEnd当路由器的路由解析阶段成功完成时触发的事件。
ActivationEnd当路由器成功激活了某个路由时触发的事件。
NavigationEnd当导航成功结束时触发的事件。
NavigationCancel当导航被取消时触发的事件。 这可能在导航期间某个路由守卫返回了 false 或返回了 UrlTree 以进行重定向时发生。
NavigationError当导航由于非预期的错误而失败时触发的事件。
Scroll用来表示滚动的事件。

当启用了 enableTracing 选项时,Angular 会把这些事件都记录到控制台。有关筛选路由器导航事件的示例,请参阅 Angular 中的 Observables 一章的路由器部分。

路由器术语

这里是一些关键的 Router 术语及其含义:

路由器部件含义
Router为活动 URL 显示应用中的组件。 管理从一个组件到另一个的导航。
RouterModule一个单独的 NgModule,它提供了一些必要的服务提供者和一些用于在应用视图间导航的指令。
Routes定义一个路由数组,每一个条目都会把一个 URL 路径映射到组件。
Route定义路由器如何基于一个 URL 模式导航到某个组件。 大部分路由都由一个路径和一个组件类组成。
RouterOutlet该指令 (<router-outlet>) 用于指出路由器应该把视图显示在哪里。
RouterLink用于将可点击的 HTML 元素绑定到某个路由的指令。单击带有 routerLink 指令且绑定到字符串或链接参数数组的元素,将触发导航。
RouterLinkActive该指令会在元素上或元素内包含的相关 routerLink 处于活动/非活动状态时,从 HTML 元素上添加/移除类。
ActivatedRoute一个提供给每个路由组件的服务,其中包含当前路由专属的信息,例如路由参数、静态数据、解析数据、全局查询参数和全局片段。
RouterState路由器的当前状态,包括一棵当前激活路由的树以及遍历这棵路由树的便捷方法。
链接参数数组一个由路由器将其解释为路由指南的数组。你可以将该数组绑定到 RouterLink 或将该数组作为参数传递给 Router.navigate 方法。
路由组件一个带有 RouterOutlet 的 Angular 组件,可基于路由器的导航来显示视图。

Web 应用程序的安全涉及到很多方面。针对常见的漏洞和攻击,比如跨站脚本攻击,Angular 提供了一些内置的保护措施。本章将讨论这些内置保护措施,但不会涉及应用级安全,比如用户认证(这个用户是谁?)和授权(这个用户能做什么?)。

要了解更多攻防信息,参见开放式 Web 应用程序安全项目(OWASP)。

你可以在 Stackblitz 中试用并下载本页的代码。

最佳实践

  • 及时把 Angular 包更新到最新版本。 我们会频繁的更新 Angular 库,这些更新可能会修复之前版本中发现的安全漏洞。查看 Angular 的更新记录,了解与安全有关的更新。

  • 不要修改你的 Angular 副本。 私有的、定制版的 Angular 往往跟不上最新版本,这可能导致你忽略重要的安全修复与增强。反之,应该在社区共享你对 Angular 所做的改进并创建 Pull Request

  • 避免使用本文档中带“安全风险”标记的 Angular API。 要了解更多信息,请参阅本章的 信任安全值 部分。

审计 Angular 应用程序

Angular 应用应该遵循和常规 Web 应用一样的安全原则并按照这些原则进行审计。Angular 中某些应该在安全评审中被审计的 API( 比如bypassSecurityTrust API)都在文档中被明确标记为安全性敏感的。

跨站脚本(XSS)允许攻击者将恶意代码注入到页面中。这些代码可以偷取用户数据 (特别是它们的登录数据),还可以冒充用户执行操作。它是 Web 上最常见的攻击方式之一。

为了防范 XSS 攻击,你必须阻止恶意代码进入 DOM。比如,如果某个攻击者能骗你把 <script> 标签插入到 DOM,就可以在你的网站上运行任何代码。 除了 <script>,攻击者还可以使用很多 DOM 元素和属性来执行代码,比如 <img onerror="..."><a href="javascript:...">。 如果攻击者所控制的数据混进了 DOM,就会导致安全漏洞。

Angular 的“跨站脚本安全模型”

为了系统性的防范 XSS 问题,Angular 默认把所有值都当做不可信任的。 当值从模板中以属性(Property)、DOM 元素属性(Attribte)、CSS 类绑定或插值等途径插入到 DOM 中的时候, Angular 将对这些值进行无害化处理(Sanitize),对不可信的值进行编码。

Angular 的模板同样是可执行的:模板中的 HTML、Attribute 和绑定表达式(还没有绑定到值的时候)会被当做可信任的。 这意味着应用必须防止把可能被攻击者控制的值直接编入模板的源码中。永远不要根据用户的输入和原始模板动态生成模板源码! 使用离线模板编译器是防范这类“模板注入”漏洞的有效途径。

无害化处理与安全环境

无害化处理会审查不可信的值,并将它们转换成可以安全插入到 DOM 的形式。多数情况下,这些值并不会在处理过程中发生任何变化。 无害化处理的方式取决于所在的环境:一个在 CSS 里面无害的值,可能在 URL 里很危险。

Angular 定义了四个安全环境 - HTML,样式,URL,和资源 URL:

  • HTML:值需要被解释为 HTML 时使用,比如当绑定到 innerHTML 时。

  • 样式:值需要作为 CSS 绑定到 style 属性时使用。

  • URL:值需要被用作 URL 属性时使用,比如 <a href>

  • 资源 URL 的值需要作为代码进行加载并执行,比如 <script src> 中的 URL

Angular 会对前三项中种不可信的值进行无害化处理,但不能对第四种资源 URL 进行无害化,因为它们可能包含任何代码。在开发模式下, 如果在进行无害化处理时需要被迫改变一个值,Angular 就会在控制台上输出一个警告。

无害化示例

下面的例子绑定了 htmlSnippet 的值,一次把它放进插值里,另一次把它绑定到元素的 innerHTML 属性上。

Path:"src/app/inner-html-binding.component.html" 。

<h3>Binding innerHTML</h3><p>Bound value:</p><p class="e2e-inner-html-interpolated">{{htmlSnippet}}</p><p>Result of binding to innerHTML:</p><p class="e2e-inner-html-bound" [innerHTML]="htmlSnippet"></p>

插值的内容总会被编码 - 其中的 HTML 不会被解释,所以浏览器会在元素的文本内容中显示尖括号。

如果希望这段 HTML 被正常解释,就必须绑定到一个 HTML 属性上,比如 innerHTML。但是如果把一个可能被攻击者控制的值绑定到 innerHTML 就会导致 XSS 漏洞。 比如,包含在 <script> 标签的代码就会被执行:

Path:"src/app/inner-html-binding.component.ts (class)" 。

export class InnerHtmlBindingComponent {  // For example, a user/attacker-controlled value from a URL.  htmlSnippet = 'Template <script>alert("0wned")</script> <b>Syntax</b>';}

Angular 认为这些值是不安全的,并自动进行无害化处理。它会移除 <script> 标签,但保留安全的内容,比如该片段中的 <b> 元素。

避免直接使用 DOM API

浏览器内置的 DOM API 不会自动保护你免受安全漏洞的侵害。比如 document、通过 ElementRef 拿到的节点和很多第三方 API,都可能包含不安全的方法。如果你使用能操纵 DOM 的其它库,也同样无法借助像 Angular 插值那样的自动清理功能。 所以,要避免直接和 DOM 打交道,而是尽可能使用 Angular 模板。

浏览器内置的 DOM API 不会自动针对安全漏洞进行防护。比如,document(它可以通过 ElementRef 访问)以及其它第三方 API 都可能包含不安全的方法。 要避免直接与 DOM 交互,只要可能,就尽量使用 Angular 模板。

内容安全策略

内容安全策略(CSP) 是用来防范 XSS 的纵深防御技术。 要打开 CSP,请配置你的 Web 服务器,让它返回合适的 HTTP 头 Content_Security_Policy。 要了解关于内容安全策略的更多信息,请参阅 HTML5Rocks 上的内容安全策略简介。

使用离线模板编译器

离线模板编译器阻止了一整套被称为“模板注入”的漏洞,并能显著增强应用程序的性能。尽量在产品发布时使用离线模板编译器, 而不要动态生成模板(比如在代码中拼接字符串生成模板)。由于 Angular 会信任模板本身的代码,所以,动态生成的模板 —— 特别是包含用户数据的模板 —— 会绕过 Angular 自带的保护机制。 要了解如何用安全的方式动态创建表单,请参见 构建动态表单 一章。

服务器端 XSS 保护

服务器端构造的 HTML 很容易受到注入攻击。当需要在服务器端生成 HTML 时(比如 Angular 应用的初始页面), 务必使用一个能够自动进行无害化处理以防范 XSS 漏洞的后端模板语言。不要在服务器端使用模板语言生成 Angular 模板, 这样会带来很高的 “模板注入” 风险。

有时候,应用程序确实需要包含可执行的代码,比如使用 URL 显示 <iframe>,或者构造出有潜在危险的 URL。 为了防止在这种情况下被自动无害化,你可以告诉 Angular:我已经审查了这个值,检查了它是怎么生成的,并确信它总是安全的。 但是千万要小心!如果你信任了一个可能是恶意的值,就会在应用中引入一个安全漏洞。如果你有疑问,请找一个安全专家复查下。

注入 DomSanitizer 服务,然后调用下面的方法之一,你就可以把一个值标记为可信任的。

bypassSecurityTrustHtml

bypassSecurityTrustScript

bypassSecurityTrustStyle

bypassSecurityTrustUrl

bypassSecurityTrustResourceUrl

记住,一个值是否安全取决于它所在的环境,所以你要为这个值按预定的用法选择正确的环境。假设下面的模板需要把 javascript.alert(...) 方法绑定到 URL

Path:"src/app/bypass-security.component.html (URL)" 。

<h4>An untrusted URL:</h4><p><a class="e2e-dangerous-url" [href]="dangerousUrl">Click me</a></p><h4>A trusted URL:</h4><p><a class="e2e-trusted-url" [href]="trustedUrl">Click me</a></p>

通常,Angular 会自动无害化这个 URL 并禁止危险的代码。为了防止这种行为,可以调用 bypassSecurityTrustUrl 把这个 URL 值标记为一个可信任的 URL

Path:"src/app/bypass-security.component.ts (trust-url)" 。

constructor(private sanitizer: DomSanitizer) {  // javascript: URLs are dangerous if attacker controlled.  // Angular sanitizes them in data binding, but you can  // explicitly tell Angular to trust this value:  this.dangerousUrl = 'javascript:alert("Hi there")';  this.trustedUrl = sanitizer.bypassSecurityTrustUrl(this.dangerousUrl);

如果需要把用户输入转换为一个可信任的值,可以在控制器方法中处理。下面的模板允许用户输入一个 YouTube 视频的 ID, 然后把相应的视频加载到 <iframe> 中。<iframe src> 是一个“资源 URL”的安全环境,因为不可信的源码可能作为文件下载到本地,被毫无防备的用户执行。 所以要调用一个控制器方法来构造一个新的、可信任的视频 URL,这样 Angular 就会允许把它绑定到 <iframe src>

Path:"src/app/bypass-security.component.html (iframe)" 。

<h4>Resource URL:</h4><p>Showing: {{dangerousVideoUrl}}</p><p>Trusted:</p><iframe class="e2e-iframe-trusted-src" width="640" height="390" [src]="videoUrl"></iframe><p>Untrusted:</p><iframe class="e2e-iframe-untrusted-src" width="640" height="390" [src]="dangerousVideoUrl"></iframe>

Path:"src/app/bypass-security.component.ts (trust-video-url)" 。

updateVideoUrl(id: string) {  // Appending an ID to a YouTube URL is safe.  // Always make sure to construct SafeValue objects as  // close as possible to the input data so  // that it's easier to check if the value is safe.  this.dangerousVideoUrl = 'https://www.youtube.com/embed/' + id;  this.videoUrl =      this.sanitizer.bypassSecurityTrustResourceUrl(this.dangerousVideoUrl);}

Angular 内置了一些支持来防范两个常见的 HTTP 漏洞:跨站请求伪造(XSRF)和跨站脚本包含(XSSI)。 这两个漏洞主要在服务器端防范,但是 Angular 也自带了一些辅助特性,可以让客户端的集成变得更容易。

跨站请求伪造(XSRF)

在跨站请求伪造(XSRF 或 CSFR)中,攻击者欺骗用户,让他们访问一个假冒页面(例如 "evil.com"), 该页面带有恶意代码,秘密的向你的应用程序服务器发送恶意请求(例如 "example-bank.com")。

假设用户已经在 "example-bank.com" 登录。用户打开一个邮件,点击里面的链接,在新页面中打开 "evil.com"。

该 "evil.com" 页面立刻发送恶意请求到 "example-bank.com"。这个请求可能是从用户账户转账到攻击者的账户。 与该请求一起,浏览器自动发出 "example-bank.com" 的 cookie

如果 "example-bank.com" 服务器缺乏 XSRF 保护,就无法辨识请求是从应用程序发来的合法请求还是从 "evil.com" 来的假请求。

为了防止这种情况,你必须确保每个用户的请求都是从你自己的应用中发出的,而不是从另一个网站发出的。 客户端和服务器必须合作来抵挡这种攻击。

常见的反 XSRF 技术是服务器随机生成一个用户认证令牌到 cookie 中。 客户端代码获取这个 cookie,并用它为接下来所有的请求添加自定义请求页头。 服务器比较收到的 cookie 值与请求页头的值,如果它们不匹配,便拒绝请求。

这个技术之所以有效,是因为所有浏览器都实现了同源策略。只有设置 cookie 的网站的代码可以访问该站的 cookie,并为该站的请求设置自定义页头。 这就是说,只有你的应用程序可以获取这个 cookie 令牌和设置自定义页头。"evil.com" 的恶意代码不能。

Angular 的 HttpClient 对这项技术的客户端部分提供了内置的支持要了解更多信息,参见 HttpClient 部分。

可到 "开放式 Web 应用程序安全项目 (OWASP) " 深入了解 CSRF,参见 Cross-Site Request Forgery (CSRF)Cross-Site Request Forgery (CSRF) Prevention Cheat Sheet

跨站脚本包含(XSSI)

跨站脚本包含,也被称为 Json 漏洞,它可以允许一个攻击者的网站从 JSON API 读取数据。这种攻击发生在老的浏览器上, 它重写原生 JavaScript 对象的构造函数,然后使用 <script> 标签包含一个 API 的 URL。

只有在返回的 JSON 能像 JavaScript 一样可以被执行时,这种攻击才会生效。所以服务端会约定给所有 JSON 响应体加上前缀 )]}, ,来把它们标记为不可执行的, 以防范这种攻击。

Angular 的 HttpClient 库会识别这种约定,并在进一步解析之前,自动把字符串 )]}, 从所有响应中去掉。

Angular 是一个应用设计框架与开发平台,用于创建高效、复杂、精致的单页面应用。

大多数 Angular 代码都只能用最新的 Javascript 编写。它会用类型实现依赖注入,还会用装饰器来提供元数据。

Angular 版本更迭速度很快,但是每个版本均往前兼容1-2个版本。正常情况下,不会出现较大的跨越。

条件假设

在您进入该教程的学习后,默认假设您已熟悉了HTML5CSS3javascript和一些最新标准的相关知识。本教程中的示例均使用最新标准下的Typescript进行展示。

注:
- 本教程均使用 Angular (V9.1.11)版本。

在正式使用 Angular 框架之前,我们需要搭建本地开发环境和工作空间。

前提条件

  1. 相关知识

要想使用 Angular 框架,您必须先熟悉以下技术:

注:
- 关于 Typescript 只会非常有用,但非必备技能。

  1. 关于Node.js

确保您的开发环境中包含了 Node.js 和一个包管理器。

注:
Angular 需要 Node.js V10.9.0或更高版本。
- 在终端中运行 node -v命令可检查您的 Node.js 版本。
- 若要获取 Node.js ,请转到 nodejs.org

  1. npm 包管理器

Angular、Angular CLI 和 Angular 应用都依赖于 npm 包中提供的特性和功能。要想下载并安装 npm 包,您必须拥有一个 npm 包管理器。

注:
在安装了 Node.js 后会默认安装 npm 客户端命令行界面。
- 在终端中运行 npm -v命令可检查您是否成功安装了 npm 客户端。

搭建步骤

1. 安装 Angular CLI

Angular CLI 可以帮助您创建项目、生成应用和库代码,以及执行各种开发 任务,比如测试,打包和部署。

  • 使用 npm 命令安装 CLI ,请打开终端输入如下命令:

npm install -g @angular/cli

2。 创建工作空间和初始应用

您要在 Angular 工作区上下文中开发应用,需要创建一个新的工作空间和初始入门应用。

  • 运行 CLI 命令 ng new并提供 my-app名称作为参数。

ng new my-app

  • ng new命令会提示您提供要将哪些特性包含在初始应用中。若无特殊要求,可按 EnterReturn接受默认值。

3. 运行应用

Angular CLI 中包含一个服务器,方便您在本地构建和提供应用。

  • 转到 workspace 文件夹(my-app)。
  • 使用 CLI 命令 ng serve--open选项来启动服务器。

cd my-appng serve --open

注:
- ng serve命令会启动开发服务器、监视文件,并在这些文件发生更改时重建应用。

--open可缩写为-o,该选项会自动打开您的浏览器访问 "http://localhost:4200/" ,网页展示如下:

架构概览

Angular 是一个用 HTML 和 TypeScript 构建客户端应用的平台与框架。Angular 本身就是用 TypeScript 写成的。它将核心功能和可选功能作为一组 TypeScript 库进行实现,你可以把它们导入你的应用中。


Angular 的基本构造块是 NgModule,它为组件提供了编译的上下文环境。 NgModule 会把相关的代码收集到一些功能集中。Angular 应用就是由一组 NgModule 定义出的。 应用至少会有一个用于引导应用的根模块,通常还会有很多特性模块。

  • 组件定义视图。视图是一组可见的屏幕元素,Angular 可以根据你的程序逻辑和数据来选择和修改它们。 每个应用都至少有一个根组件。

  • 组件使用服务。服务会提供那些与视图不直接相关的功能。服务提供者可以作为依赖被注入到组件中, 这能让你的代码更加模块化、更加可复用、更加高效。

组件和服务都是简单的类,这些类使用装饰器来标出它们的类型,并提供元数据以告知 Angular 该如何使用它们。

  • 组件类的元数据将组件类和一个用来定义视图的模板关联起来。 模板把普通的 HTML 和 Angular 指令与绑定标记(markup)组合起来,这样 Angular 就可以在渲染 HTML 之前先修改这些 HTML。

  • 服务类的元数据提供了一些信息,Angular 要用这些信息来让组件可以通过依赖注入(DI)使用该服务。

应用的组件通常会定义很多视图,并进行分级组织。Angular 提供了 Router 服务来帮助你定义视图之间的导航路径。 路由器提供了先进的浏览器内导航功能。

注:
- 参考 [Angular9 词汇表]() 以了解对 Angular 重要名词和用法的基本定义。

模块

Angular 定义了 NgModule,它和 JavaScript(ES2015) 的模块不同而且有一定的互补性。 NgModule 为一个组件集声明了编译的上下文环境,它专注于某个应用领域、某个工作流或一组紧密相关的能力。 NgModule 可以将其组件和一组相关代码(如服务)关联起来,形成功能单元。

每个 Angular 应用都有一个根模块,通常命名为 AppModule。根模块提供了用来启动应用的引导机制。 一个应用通常会包含很多特性模块。

像 JavaScript 模块一样,NgModule 也可以从其它 NgModule 中导入功能,并允许导出它们自己的功能供其它 NgModule 使用。 比如,要在你的应用中使用路由器(Router)服务,就要导入 Router 这个 NgModule。

把你的代码组织成一些清晰的特性模块,可以帮助管理复杂应用的开发工作并实现可复用性设计。 另外,这项技术还能让你获得惰性加载(也就是按需加载模块)的优点,以尽可能减小启动时需要加载的代码体积。

注:
- 参考 [Angular9 模块简介]() 以深入了解模块。

组件

每个 Angular 应用都至少有一个组件,也就是根组件,它会把组件树和页面中的 DOM 连接起来。 每个组件都会定义一个类,其中包含应用的数据和逻辑,并与一个 HTML 模板相关联,该模板定义了一个供目标环境下显示的视图。

@Component() 装饰器表明紧随它的那个类是一个组件,并提供模板和该组件专属的元数据。

注:
- 装饰器是一些用于修饰 JavaScript 类的函数。Angular 定义了许多装饰器,这些装饰器会把一些特定种类的元数据附加到类上,以便 Angular 了解这些这些类的含义以及该如何使用它们。

模块、指令及数据绑定

模板会把 HTML 和 Angular 的标记(markup)组合起来,这些标记可以在 HTML 元素显示出来之前修改它们。 模板中的指令会提供程序逻辑,而绑定标记会把你应用中的数据和 DOM 连接在一起。 有两种类型的数据绑定:

  • 事件绑定让你的应用可以通过更新应用的数据来响应目标环境下的用户输入。

  • 属性绑定让你将从应用数据中计算出来的值插入到 HTML 中。

在视图显示出来之前,Angular 会先根据你的应用数据和逻辑来运行模板中的指令并解析绑定表达式,以修改 HTML 元素和 DOM。 Angular 支持双向数据绑定,这意味着 DOM 中发生的变化(比如用户的选择)同样可以反映回你的程序数据中。

你的模板也可以用管道转换要显示的值以增强用户体验。比如,可以使用管道来显示适合用户所在语言环境的日期和货币格式。 Angular 为一些通用的转换提供了预定义管道,你还可以定义自己的管道。

注:
- 参考 [Angular9 组件简介]() 以深入了解组件。

服务与依赖注入

对于与特定视图无关并希望跨组件共享的数据或逻辑,可以创建服务类。 服务类的定义通常紧跟在 “@Injectable()” 装饰器之后。该装饰器提供的元数据可以让你的服务作为依赖被注入到客户组件中。

依赖注入(或 DI)让你可以保持组件类的精简和高效。有了 DI,组件就不用从服务器获取数据、验证用户输入或直接把日志写到控制台,而是会把这些任务委托给服务。

注:
- 参考 [Angular9 服务和 DI 简介]() 以深入了解服务与依赖注入。

路由

Angular 的 Router 模块提供了一个服务,它可以让你定义在应用的各个不同状态和视图层次结构之间导航时要使用的路径。 它的工作模型基于人们熟知的浏览器导航约定:

  • 在地址栏输入 URL,浏览器就会导航到相应的页面。

  • 在页面中点击链接,浏览器就会导航到一个新页面。

  • 点击浏览器的前进和后退按钮,浏览器就会在你的浏览历史中向前或向后导航。

不过路由器会把类似 URL 的路径映射到视图而不是页面。 当用户执行一个动作时(比如点击链接),本应该在浏览器中加载一个新页面,但是路由器拦截了浏览器的这个行为,并显示或隐藏一个视图层次结构。

如果路由器认为当前的应用状态需要某些特定的功能,而定义此功能的模块尚未加载,路由器就会按需惰性加载此模块。

路由器会根据你应用中的导航规则和数据状态来拦截 URL。 当用户点击按钮、选择下拉框或收到其它任何来源的输入时,你可以导航到一个新视图。 路由器会在浏览器的历史日志中记录这个动作,所以前进和后退按钮也能正常工作。

要定义导航规则,你就要把导航路径和你的组件关联起来。 路径(path)使用类似 URL 的语法来和程序数据整合在一起,就像模板语法会把你的视图和程序数据整合起来一样。 然后你就可以用程序逻辑来决定要显示或隐藏哪些视图,以根据你制定的访问规则对用户的输入做出响应。

注:
- 参考 [Angular9 路由与导航]() 以深入了解路由。

NgModule 简介

Angular 应用是模块化的,它拥有自己的模块化系统,称作 NgModule。 一个 NgModule 就是一个容器,用于存放一些内聚的代码块,这些代码块专注于某个应用领域、某个工作流或一组紧密相关的功能。 它可以包含一些组件、服务提供者或其它代码文件,其作用域由包含它们的 NgModule 定义。 它还可以导入一些由其它模块中导出的功能,并导出一些指定的功能供其它 NgModule 使用。

每个 Angular 应用都至少有一个 NgModule 类,也就是根模块,它习惯上命名为 AppModule,并位于一个名叫 app.module.ts 的文件中。引导这个根模块就可以启动你的应用。

虽然小型的应用可能只有一个 NgModule,不过大多数应用都会有很多特性模块。应用的根模块之所以叫根模块,是因为它可以包含任意深度的层次化子模块。

@NgModule 元数据

NgModule 是一个带有 @NgModule() 装饰器的类。@NgModule() 装饰器是一个函数,它接受一个元数据对象,该对象的属性用来描述这个模块。其中最重要的属性如下。

  • declarations(可声明对象表) —— 那些属于本 NgModule 的组件、指令、管道。

  • exports(导出表) —— 那些能在其它模块的组件模板中使用的可声明对象的子集。

  • imports(导入表) —— 那些导出了本模块中的组件模板所需的类的其它模块。

  • providers —— 本模块向全局服务中贡献的那些服务的创建器。 这些服务能被本应用中的任何部分使用。(你也可以在组件级别指定服务提供者,这通常是首选方式。)

  • bootstrap —— 应用的主视图,称为根组件。它是应用中所有其它视图的宿主。只有根模块才应该设置这个 bootstrap 属性。

下面展示一个简单的根 NgModule 定义(Bath: "src/app/app.module.ts" ):

import { NgModule }         form `@angular/core`;import { BrowserModule }    form `@angular/platform-browser`;@NgModule({    imports:        [ BrowserModule ],    providers:      [ Logger ],    declarations:   [ AppComponent ],    exprots:        [ AppComponent ],    bootstrap:      [ AppComponent ]})export class AppModule { }

注:
- 把 AppComponent 放到 exports 中是为了演示导出的语法,这在本例子中实际上是没必要的。
- 根模块没有任何理由导出任何东西,因为其它模块永远不需要导入根模块。

NgModule 和组件

  • NgModule 为其中的组件提供了一个编译上下文环境。根模块总会有一个根组件,并在引导期间创建它。 但是,任何模块都能包含任意数量的其它组件,这些组件可以通过路由器加载,也可以通过模板创建。那些属于这个 NgModule 的组件会共享同一个编译上下文环境。

  • 组件及其模板共同定义视图。组件还可以包含视图层次结构,它能让你定义任意复杂的屏幕区域,可以将其作为一个整体进行创建、修改和销毁。 一个视图层次结构中可以混合使用由不同 NgModule 中的组件定义的视图。 这种情况很常见,特别是对一些 UI 库来说。

  • 当你创建一个组件时,它直接与一个叫做宿主视图的视图关联起来。 宿主视图可以是视图层次结构的根,该视图层次结构可以包含一些内嵌视图,这些内嵌视图又是其它组件的宿主视图。 这些组件可以位于相同的 NgModule 中,也可以从其它 NgModule 中导入。 树中的视图可以嵌套到任意深度。

注:
- 视图的这种层次结构是 Angular 在 DOM 和应用数据中检测与响应变更时的关键因素。

NgModule 和 JavaScript 的模块

NgModule 系统与 JavaScript(ES2015)用来管理 JavaScript 对象的模块系统不同,而且也没有直接关联。 这两种模块系统不同但互补。你可以使用它们来共同编写你的应用。

JavaScript 中,每个文件是一个模块,文件中定义的所有对象都从属于那个模块。 通过 export 关键字,模块可以把它的某些对象声明为公共的。 其它 JavaScript 模块可以使用import 语句来访问这些公共对象。

import { NgModule }     form '@angular/core';import { AppComponent } form './app.component';

export class AppModule { }

Angular 自带库

Angular 会作为一组 JavaScript 模块进行加载,你可以把它们看成库模块。每个 Angular 库的名称都带有 @angular 前缀。 使用 npm 包管理器安装 Angular 的库,并使用 JavaScript 的 import 语句导入其中的各个部分。

  • 如下,从@angular/core库中导入 Angular 的 Component 装饰器:

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

  • 还可以使用 JavaScript 的导入语句从 Angular 库中导入 Angular 模块。 比如,下列代码从 platform-browser 库中导入了 BrowserModule 这个 NgModule。

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

  • 在上面这个简单的根模块范例中,应用的根模块需要来自 BrowserModule 中的素材。要访问这些素材,就要把它加入 @NgModule 元数据的 imports 中,代码如下:

inports:    [ BrowserModule ],

通过这种方式,你可以同时使用 Angular 和 JavaScript 的这两种模块系统。 虽然这两种模块系统容易混淆(它们共享了同样的词汇 importexport),不过只要多用用你就会熟悉它们各自的语境了。

组件控制屏幕上被称为视图的一小片区域。比如,教程中的下列视图都是由一个个组件所定义和控制的:

  • 带有导航链接的应用根组件。

  • 英雄列表。

  • 英雄编辑器。

你在类中定义组件的应用逻辑,为视图提供支持。 组件通过一些由属性和方法组成的 API 与视图交互。

比如,HeroListComponent 中有一个 名为 heroes 的属性,它储存着一个数组的英雄数据。 HeroListComponent 还有一个 selectHero() 方法,当用户从列表中选择一个英雄时,它会设置 selectedHero 属性的值。 该组件会从服务获取英雄列表,它是一个 TypeScript 的构造器参数型属性。本服务通过依赖注入系统提供给该组件。

export class HeroListComponent implements OnInit {  heroes: Hero[];  selectedHero: Hero;  constructor(private service: HeroService) { }  ngOnInit() {    this.heroes = this.service.getHeroes();  }  selectHero(hero: Hero) { this.selectedHero = hero; }}

当用户在应用中穿行时,Angular 就会创建、更新、销毁一些组件。 你的应用可以通过一些可选的生命周期钩子(比如 ngOnInit())来在每个特定的时机采取行动。

组件的元数据

@Component 装饰器会指出紧随其后的那个类是个组件类,并为其指定元数据。 在下面的范例代码中,你可以看到 HeroListComponent 只是一个普通类,完全没有 Angular 特有的标记或语法。 直到给它加上了 @Component 装饰器,它才变成了组件。

组件的元数据告诉 Angular 到哪里获取它需要的主要构造块,以创建和展示这个组件及其视图。 具体来说,它把一个模板(无论是直接内联在代码中还是引用的外部文件)和该组件关联起来。 该组件及其模板,共同描述了一个视图。

除了包含或指向模板之外,@Component 的元数据还会配置要如何在 HTML 中引用该组件,以及该组件需要哪些服务等等。

下面的例子中就是 HeroListComponent 的基础元数据:

@Component({  selector:    'app-hero-list',  templateUrl: './hero-list.component.html',  providers:  [ HeroService ]})export class HeroListComponent implements OnInit {/* . . . */}

这个例子展示了一些最常用的 @Component 配置选项:

  • selector:是一个 CSS 选择器,它会告诉 Angular,一旦在模板 HTML 中找到了这个选择器对应的标签,就创建并插入该组件的一个实例。 比如,如果应用的 HTML 中包含 <app-hero-list></app-hero-list>,Angular 就会在这些标签中插入一个 HeroListComponent 实例的视图。

  • templateUrl:该组件的 HTML 模板文件相对于这个组件文件的地址。 另外,你还可以用 template 属性的值来提供内联的 HTML 模板。 这个模板定义了该组件的宿主视图。

  • providers:当前组件所需的服务提供者的一个数组。在这个例子中,它告诉 Angular 该如何提供一个 HeroService 实例,以获取要显示的英雄列表。

模板与视图

你要通过组件的配套模板来定义其视图。模板就是一种 HTML,它会告诉 Angular 如何渲染该组件。

视图通常会分层次进行组织,让你能以 UI 分区或页面为单位进行修改、显示或隐藏。 与组件直接关联的模板会定义该组件的宿主视图。该组件还可以定义一个带层次结构的视图,它包含一些内嵌的视图作为其它组件的宿主。

带层次结构的视图可以包含同一模块(NgModule)中组件的视图,也可以(而且经常会)包含其它模块中定义的组件的视图。

模板语法

模板很像标准的 HTML,但是它还包含 Angular 的模板语法,这些模板语法可以根据你的应用逻辑、应用状态和 DOM 数据来修改这些 HTML。 你的模板可以使用数据绑定来协调应用和 DOM 中的数据,使用管道在显示出来之前对其进行转换,使用指令来把程序逻辑应用到要显示的内容上。

比如,下面是本教程中 HeroListComponent 的模板:

<h2>Hero List</h2><p><i>Pick a hero from the list</i></p><ul>  <li *ngFor="let hero of heroes" (click)="selectHero(hero)">    {{hero.name}}  </li></ul><app-hero-detail *ngIf="selectedHero" [hero]="selectedHero"></app-hero-detail>

这个模板使用了典型的 HTML 元素,比如 <h2><p>,还包括一些 Angular 的模板语法元素,如 *ngFor{{hero.name}}click[hero]<app-hero-detail>。这些模板语法元素告诉 Angular 该如何根据程序逻辑和数据在屏幕上渲染 HTML。

  • *ngFor 指令告诉 Angular 在一个列表上进行迭代。

  • {{hero.name}}(click)[hero] 把程序数据绑定到及绑定回 DOM,以响应用户的输入。更多内容参见稍后的数据绑定部分。

  • 模板中的 <app-hero-detail> 标签是一个代表新组件 HeroDetailComponent 的元素。 HeroDetailComponent(代码略)定义了 HeroListComponent 的英雄详情子视图。 注意观察像这样的自定义组件是如何与原生 HTML 元素无缝的混合在一起的。

数据绑定

如果没有框架,你就要自己负责把数据值推送到 HTML 控件中,并把来自用户的响应转换成动作和对值的更新。 手动写这种数据推拉逻辑会很枯燥、容易出错,难以阅读 —— 有前端 JavaScript 开发经验的程序员一定深有体会。

Angular 支持双向数据绑定,这是一种对模板中的各个部件与组件中的各个部件进行协调的机制。 往模板 HTML 中添加绑定标记可以告诉 Angular 该如何连接它们。

下图显示了数据绑定标记的四种形式。每种形式都有一个方向 —— 从组件到 DOM、从 DOM 到组件或双向。

这个来自 HeroListComponent 模板中的例子展示了其中的三种形式:

<li>{{hero.name}}</li><app-hero-detail [hero]="selectedHero"></app-hero-detail><li (click)="selectHero(hero)"></li>

  • {{hero.name}} 这个插值在 <li> 标签中显示组件的 hero.name 属性的值。

  • [hero]属性绑定把父组件 HeroListComponent 的 selectedHero 的值传到子组件 HeroDetailComponent 的 hero 属性中。

  • 当用户点击某个英雄的名字时,(click) 事件绑定会调用组件的 selectHero 方法。

双向数据绑定(主要用于模板驱动表单中),它会把属性绑定和事件绑定组合成一种单独的写法。下面这个来自 HeroDetailComponent 模板中的例子通过 ngModel 指令使用了双向数据绑定:

<input [(ngModel)]="hero.name">

在双向绑定中,数据属性值通过属性绑定从组件流到输入框。用户的修改通过事件绑定流回组件,把属性值设置为最新的值。

Angular 在每个 JavaScript 事件循环中处理所有的数据绑定,它会从组件树的根部开始,递归处理全部子组件。

数据绑定在模板及其组件之间的通讯中扮演了非常重要的角色,它对于父组件和子组件之间的通讯也同样重要。

管道

Angular 的管道可以让你在模板中声明显示值的转换逻辑。 带有 @Pipe 装饰器的类中会定义一个转换函数,用来把输入值转换成供视图显示用的输出值。

Angular 自带了很多管道,比如 date 管道和 currency 管道,完整的列表参见 Pipes API 列表。你也可以自己定义一些新管道。

要在 HTML 模板中指定值的转换方式,请使用 管道操作符 (|)。

{{interpolated_value | pipe_name}}

你可以把管道串联起来,把一个管道函数的输出送给另一个管道函数进行转换。 管道还能接收一些参数,来控制它该如何进行转换。比如,你可以把要使用的日期格式传给 date 管道:

<!-- Default format: output 'Jun 15, 2015'--> <p>Today is {{today | date}}</p><!-- fullDate format: output 'Monday, June 15, 2015'--><p>The date is {{today | date:'fullDate'}}</p> <!-- shortTime format: output '9:43 AM'--> <p>The time is {{today | date:'shortTime'}}</p>

指令

Angular 的模板是动态的。当 Angular 渲染它们的时候,会根据指令给出的指示对 DOM 进行转换。 指令就是一个带有 @Directive() 装饰器的类。

组件从技术角度上说就是一个指令,但是由于组件对 Angular 应用来说非常独特、非常重要,因此 Angular 专门定义了 @Component() 装饰器,它使用一些面向模板的特性扩展了 @Directive() 装饰器。

除组件外,还有两种指令:结构型指令和属性型指令。 Angular 本身定义了一系列这两种类型的指令,你也可以使用 @Directive() 装饰器来定义自己的指令。

像组件一样,指令的元数据把它所装饰的指令类和一个 selector 关联起来,selector 用来把该指令插入到 HTML 中。 在模板中,指令通常作为属性出现在元素标签上,可能仅仅作为名字出现,也可能作为赋值目标或绑定目标出现。

1. 结构型指令

结构型指令通过添加、移除或替换 DOM 元素来修改布局。 这个范例模板使用了两个内置的结构型指令来为要渲染的视图添加程序逻辑:

<li *ngFor="let hero of heroes"></li><app-hero-detail *ngIf="selectedHero"></app-hero-detail>

  • *ngFor 是一个迭代器,它要求 Angular 为 heroes 列表中的每个英雄渲染出一个 <li>

  • *ngIf 是个条件语句,只有当选中的英雄存在时,它才会包含 HeroDetail 组件。

2. 属性型指令

属性型指令会修改现有元素的外观或行为。 在模板中,它们看起来就像普通的 HTML 属性一样,因此得名“属性型指令”。

ngModel 指令就是属性型指令的一个例子,它实现了双向数据绑定。 ngModel 修改现有元素(一般是 <input>)的行为:设置其显示属性值,并响应 change 事件。

<input [(ngModel)]="hero.name">

注:
- Angular 还有很多预定义指令,有些修改布局结构(比如 ngSwitch),有些修改 DOM 元素和组件的样子(比如 ngStylengClass)。
- 参考 [Angular9 结构型指令]() 和 [Angular9 属性型指令]() 以了解 Angular 两种指令类型。

服务与依赖注入简介

服务是一个广义的概念,它包括应用所需的任何值、函数或特性。狭义的服务是一个明确定义了用途的类。它应该做,并做好一些具体的事。

Angular 把组件和服务区分开,以提高模块性和复用性。 通过把组件中和视图有关的功能与其它类型的处理分离开,你可以让组件类更加精简、高效。

理想情况下,组件的工作只管用户体验,而不用顾及其它。 它应该提供用于数据绑定的属性和方法,以便作为视图(由模板渲染)和应用逻辑(通常包含一些模型的概念)的中介者。

组件应该把诸如从服务器获取数据、验证用户输入或直接往控制台中写日志等工作委托给各种服务。通过把各种处理任务定义到可注入的服务类中,你可以让它被任何组件使用。 通过在不同的环境中注入同一种服务的不同提供者,你还可以让你的应用更具适应性。

Angular 不会强迫您遵循这些原则。Angular 只会通过依赖注入来帮您更容易地将应用逻辑分解为服务,并让这些服务可用于各个组件中。

应用示例

以下示例,用于将日志记录到浏览器的控制台。

export class Logger {  log(msg: any)   { console.log(msg); }  error(msg: any) { console.error(msg); }  warn(msg: any)  { console.warn(msg); }}

服务也可以依赖其它服务。比如,这里的 HeroService 就依赖于 Logger 服务,它还用 BackendService 来获取英雄数据。BackendService 还可能再转而依赖 HttpClient 服务来从服务器异步获取英雄列表。

export class HeroService {  private heroes: Hero[] = [];  constructor(    private backend: BackendService,    private logger: Logger) { }  getHeroes() {    this.backend.getAll(Hero).then( (heroes: Hero[]) => {      this.logger.log(`Fetched ${heroes.length} heroes.`);      this.heroes.push(...heroes); // fill cache    });    return this.heroes;  }}

依赖注入

DI 被融入 Angular 框架中,用于在任何地方给新建的组件提供服务或所需的其它东西。 组件是服务的消费者,也就是说,你可以把一个服务注入到组件中,让组件类得以访问该服务类。

在 Angular 中,要把一个类定义为服务,就要用 @Injectable() 装饰器来提供元数据,以便让 Angular 可以把它作为依赖注入到组件中。 同样,也要使用 @Injectable() 装饰器来表明一个组件或其它类(比如另一个服务、管道或 NgModule)拥有一个依赖。

  • 注入器是主要的机制。Angular 会在启动过程中为你创建全应用级注入器以及所需的其它注入器。你不用自己创建注入器。

  • 该注入器会创建依赖、维护一个容器来管理这些依赖,并尽可能复用它们。

  • 提供者是一个对象,用来告诉注入器应该如何获取或创建依赖。

你的应用中所需的任何依赖,都必须使用该应用的注入器来注册一个提供者,以便注入器可以使用这个提供者来创建新实例。 对于服务,该提供者通常就是服务类本身。

注:
- 依赖不一定是服务,它也有可能是函数或者值。

当 Angular 创建组件类的新实例时,它会通过查看该组件类的构造函数,来决定该组件依赖哪些服务或其它依赖项。 比如 HeroListComponent 的构造函数中需要 HeroService:

constructor(private service: HeroService) { }

当 Angular 发现某个组件依赖某个服务时,它会首先检查是否该注入器中已经有了那个服务的任何现有实例。如果所请求的服务尚不存在,注入器就会使用以前注册的服务提供者来制作一个,并把它加入注入器中,然后把该服务返回给 Angular。

当所有请求的服务已解析并返回时,Angular 可以用这些服务实例为参数,调用该组件的构造函数。

HeroService 的注入过程如下所示:

提供服务

对于要用到的任何服务,您必须至少注册一个提供者。服务可以在自己的元数据中把自己注册为提供者,这样可以让自己随处可用。或者,您也可以为特定的模块或组件注册提供者。要注册提供者,就要在服务的 @Injectable() 装饰器中提供它的元数据,或者在 @NgModule()@Component() 的元数据中。

  • 默认情况下,Angular CLI 的 ng generate service 命令会在 @Injectable() 装饰器中提供元数据来把它注册到根注入器中。本教程就用这种方法注册了 HeroService 的提供者:

@Injectable({  providedIn: 'root',})

当你在根一级提供服务时,Angular 会为 HeroService 创建一个单一的共享实例,并且把它注入到任何想要它的类中。这种在 @Injectable 元数据中注册提供者的方式还让 Angular 能够通过移除那些从未被用过的服务来优化大小。

  • 当你使用特定的 NgModule 注册提供者时,该服务的同一个实例将会对该 NgModule 中的所有组件可用。要想在这一层注册,请用 @NgModule() 装饰器中的 providers 属性:

@NgModule({  providers: [   BackendService,   Logger ], ...})

  • 当您在组件级注册提供者时,您会为该组件的每一个新实例提供该服务的一个新实例。 要在组件级注册,就要在 @Component() 元数据的 providers 属性中注册服务提供者。

@Component({  selector:    'app-hero-list',  templateUrl: './hero-list.component.html',  providers:  [ HeroService ]})

工具与技巧

在了解了基本的 Angular 构建块之后,您可以进一步了解可以帮助你开发和交付 Angular 应用的特性和工具。

  • 参考“英雄指南”教程,了解如何将这些基本构建块放在一起,来创建设计精良的应用。

  • 查看词汇表,了解 Angular 特有的术语和用法。

  • 根据您的开发阶段和感兴趣的领域,使用该文档更深入地学习某些关键特性。

应用架构

  • 组件与模板一章中介绍了如何把组件中的应用数据与页面显示模板联系起来,以创建一个完整的交互式应用。

  • NgModules 一章中提供了关于 Angular 应用模块化结构的深度信息。

  • 路由与导航一章中提供了一些深度信息,教您如何构造出一个允许用户导航到单页面应用中不同视图 的应用。

  • 依赖注入一章提供了一些深度信息,教您如何让每个组件类都可以获取实现其功能所需的服务和对象。

响应式编程

“组件和模板”一章提供了模板语法的指南和详细信息,用于在视图中随时随地显示组件数据,并从用户那里收集输入,以便做出响应。

其它页面和章节则描述了 Angular 应用的一些基本编程技巧。

  • 生命周期钩子:通过实现生命周期钩子接口,可以窃听组件生命周期中的一些关键时刻 —— 从创建到销毁。

  • 可观察对象(Observable)和事件处理:如何在组件和服务中使用可观察对象来发布和订阅任意类型的消息,比如用户交互事件和异步操作结果。

  • Angular 自定义元素:如何使用 Web Components 把组件打包成自定义元素,Web Components 是一种以框架无关的方式定义新 HTML 元素的 Web 标准。

  • 表单:通过基于 HTML 的输入验证,来支持复杂的数据录入场景。

  • 动画:使用 Angular 的动画库,您可以让组件支持动画行为,而不用深入了解动画技术或 CSS。

“客户端-服务器”交互

Angular 为单页面应用提供了一个框架,其中的大多数逻辑和数据都留在客户端。大多数应用仍然需要使用 HttpClient 来访问服务器,以访问和保存数据。对于某些平台和应用,您可能还希望使用 PWA(渐进式 Web 应用)模型来改善用户体验。

  • HTTP:与服务器通信,通过 HTTP 客户端来获取数据、保存数据,并调用服务端的动作。

  • 服务器端渲染:Angular Universal 通过服务器端渲染(SSR)在服务器上生成静态应用页面。这允许您在服务器上运行 Angular 应用,以提高性能,并在移动设备和低功耗设备上快速显示首屏,同时也方便了网页抓取工具。

  • Service Worker 和 PWA:使用 Service Worker 来减少对网络的依赖,并显著改善用户体验。

  • Web worker:学习如何在后台线程中运行 CPU 密集型的计算。

为开发周期提供支持

“开发工作流”部分描述了用于编译、测试和部署 Angular 应用的工具和过程。

  • CLI 命令参考手册:Angular CLI 是一个命令行工具,可用于创建项目、生成应用和库代码,以及执行各种持续开发任务,如测试、打包和部署。

  • 编译:Angular 为开发环境提供了 JIT(即时)编译方式,为生产环境提供了 AOT(预先)编译方式。

  • 测试平台:对应用的各个部件运行单元测试,让它们好像在和 Angular 框架交互一样。

  • 部署:学习如何把 Angular 应用部署到远端服务器上。

  • 安全指南:学习 Angular 对常见 Web 应用的弱点和工具(比如跨站脚本攻击)提供的内置防护措施。

  • 国际化 :借助 Angular 的国际化(i18n)工具,可以让您的应用支持多语言环境。

  • 无障碍性:让所有用户都能访问您的应用。

文件结构、配置和依赖

  • 工作区与文件结构:理解 Angular 工作区与项目文件夹的结构。

  • 构建与运行:学习为项目定义不同的构建和代理服务器设置的配置方式,比如开发、预生产和生产。

  • npm 包:Angular 框架、Angular CLI 和 Angular 应用中用到的组件都是用 npm 打包的,并通过 npm 注册服务器进行发布。Angular CLI 会创建一个默认的 package.json 文件,它会指定一组初始的包,它们可以一起使用,共同支持很多常见的应用场景。

  • TypeScript 配置:TypeScript 是 Angular 应用开发的主要语言。

  • 浏览器支持:让您的应用能和各种浏览器兼容。

扩展 Angular

  • Angular 库:学习如何使用和创建可复用的库。

  • 学习原理图 :学习如何自定义和扩展 CLI 的生成(generate)能力。

  • CLI 构建器:学习如何自定义和扩展 CLI 的能力,让它使用工具来执行复杂任务,比如构建和测试应用。

在“英雄指南”中,您将从头开始构建自己的应用,体验典型的开发过程。

本指南向您展示了如何使用 Angular CLI 工具搭建本地开发环境并开发应用;对 Angular CLI 工具的基础知识进行了介绍。

您建立的应用可以帮助“神盾局”管理好自己的英雄们。该应用具有许多在任何数据驱动的应用完成后的应用会获取一些英雄列表、编辑所选英雄的详细信息,并在不同的英雄数据之间导航。

您会在“英雄指南”中用到的很多个示例中找到对此应用领域的引用和扩展,但您并不一定非要通过“英雄指南”来理解这些例子。

“英雄指南”工作内容:

  1. 使用 Angular 内置指令来显示或隐藏元素,并显示英雄数据列表。

  1. 创建 Angular 组件以显示英雄的详情,并显示一个英雄数组。

  1. 为只读数据使用单项数据绑定。

  1. 添加可编辑字段,使用双向数据绑定来更新模型。

  1. 把组件中的方法绑定到用户事件上,比如案件和点击。

  1. 让用户可以在主列表中选择一个英雄,然后在详情视图中编辑他。

  1. 使用管道来格式化数据。

  1. 创建共享的服务来管理这些英雄。

  1. 使用路由在不同的视图及其组件之间导航。

应用外壳

使用 Angular CLI 来创建最初的应用程序。在“英雄指南”中,您将对该入门级的应用程序进行修改和拓展,以创建出“英雄指南”应用。

所需工作:

  1. 设置开发环境。

  1. 创建新的工作区,并初始化应用项目。

  1. 启动开发服务器。

  1. 修改此应用。

搭建开发环境

要想搭建开发环境,请遵循[搭建本地环境]()中的步骤进行操作。

创建新的工作区和一个初始应用

Angular 工作区是您开发应用所在的上下文环境。一个工作区包含一个或多个项目所需的文件。每个项目都是一组由应用、库或端到端(e2e)测试组合的文件集合。

创建新的工作区和初始应用项目的步骤:

  1. 确保你现在没有位于 Angular 工作区的文件夹中。例如,如果你之前已经创建过其他工作区,请回到其父目录中。

  1. 运行 CLI 命令 ng new,空间名请使用 angular-tour-of-heroes,如下所示:

    ng new angular-tour-of-heroes

  1. ng new 命令会提示你输入要在初始应用项目中包含哪些特性,请按 Enter 或 Return 键接受其默认值。

Angular CLI 会安装必要的 Angular npm 包和其它依赖项。这可能需要几分钟。

它还会创建下列工作区和初始项目的文件:

  • 新的工作区,其根目录名叫 angular-tour-of-heroes。

  • 一个最初的骨架应用项目,同样叫做 angular-tour-of-heroes(位于 src 子目录下)。

  • 一个端到端测试项目(位于 e2e 子目录下)。

  • 相关的配置文件。

初始应用项目是一个简单的 "欢迎" 应用,随时可以运行它。

启动应用程序

在终端进入工作目录,并启动这个应用。

cd angular-tour-of-heroesng serve --open

注:
ng serve命令会构建本应用,启动开发度武器,监听源文件,并且当那些文件发生变化时重新构建本应用。
--open会打开浏览器访问 "http://localhost:4200/" 。

Angular 组件

您所看到的页面就是所谓的应用外壳。这个外壳是被一个名叫 AppComponent 的 Angular 组件控制的。

组件是 Angular 应用中的基本构造块。 它们在屏幕上显示数据,监听用户输入,并且根据这些输入执行相应的动作。

修改应用标题

用您最喜欢的编辑器或 IDE 打开这个项目,并访问src/app目录,来对这个起始应用做一些修改。

您会在这里看到 AppComponent 壳的三个实现文件:

  1. app.component.ts— 组件的类代码,这是用 TypeScript 写的。

  1. app.component.html— 组件的模板,这是用 HTML 写的。

  1. app.component.css— 组件的模板,这是用 CSS 写的。

更改应用标题

打开组件的类文件( app.component.ts ),并把 title 属性的值修改为 'Tour of Heros'。

Path:"app.component.ts (class title property)"

title = 'Tour of Heroes';

打开组文件的模板文件 app.component.html 并清空 Angular CLI 自动生成的默认模板。改为下列 HTML 内容:

Path:"app.component.html (template)"

<h1>{{title}}</h1>

双花括号语法是 Angular 的插值绑定语法。 这个插值绑定的意思是把组件的 title 属性的值绑定到 HTML 中的 h1 标记中。

浏览器会自动刷新,并且显示出了新的应用标题。

添加应用样式

大多数应用都会努力让整个应用保持一致的外观。 因此,CLI 会生成一个空白的 styles.css 文件。 你可以把全应用级别的样式放进去。

打开 src/styles.css 并把下列代码添加到此文件中。

Path:"src/styles.css (excerpt)"

/* Application-wide Styles */h1 {  color: #369;  font-family: Arial, Helvetica, sans-serif;  font-size: 250%;}h2, h3 {  color: #444;  font-family: Arial, Helvetica, sans-serif;  font-weight: lighter;}body {  margin: 2em;}body, input[type="text"], button {  color: #333;  font-family: Cambria, Georgia;}/* everywhere else */* {  font-family: Arial, Helvetica, sans-serif;}

查看源代码

  1. Path:"src/app/app.component.ts"

    import { Component } from '@angular/core';    @Component({      selector: 'app-root',      templateUrl: './app.component.html',      styleUrls: ['./app.component.css']    })    export class AppComponent {      title = 'Tour of Heroes';    }

  1. Path:"src/app/app.component.html"

    <h1>{{title}}</h1>

  1. Path:"src/styles.css(excerpt)"

    /* Application-wide Styles */    h1 {      color: #369;      font-family: Arial, Helvetica, sans-serif;      font-size: 250%;    }    h2, h3 {      color: #444;      font-family: Arial, Helvetica, sans-serif;      font-weight: lighter;    }    body {      margin: 2em;    }    body, input[type="text"], button {      color: #333;      font-family: Cambria, Georgia;    }    /* everywhere else */    * {      font-family: Arial, Helvetica, sans-serif;    }

总结

  • 您使用 Angular CLI 创建了初始的应用结构。

  • 您学会了使用 Angular 组件来显示数据。

  • 您使用双花括号插值显示了应用标题。

应用程序现在有了基本的标题。 接下来您要创建一个新的组件来显示英雄信息并且把这个组件放到应用程序的外壳里去。

创建英雄列表组件

使用 Angular CLI 创建一个名为 heroes 的新组件

ng generate component heroes

CLI 创建了一个新的文件夹 "src/app/heroes/",并生成了 HeroesComponent 的四个文件。其类文件如下:

Path:"app/heroes/heroes.component.ts (initial version)"

import { Component, OnInit } from '@angular/core';@Component({  selector: 'app-heroes',  templateUrl: './heroes.component.html',  styleUrls: ['./heroes.component.css']})export class HeroesComponent implements OnInit {  constructor() { }  ngOnInit() {  }}

你要从 Angular 核心库中导入 Component 符号,并为组件类加上 @Component 装饰器。

@Component 是个装饰器函数,用于为该组件指定 Angular 所需的元数据。

CLI 自动生成了三个元数据属性:

  • selector— 组件的选择器(CSS 元素选择器)

  • templateUrl— 组件模板文件的位置。

  • styleUrls— 组件私有 CSS 样式表文件的位置。

CSS 元素选择器 app-heroes 用来在父组件的模板中匹配 HTML 元素的名称,以识别出该组件。

ngOnInit() 是一个生命周期钩子,Angular 在创建完组件后很快就会调用 ngOnInit()。这里是放置初始化逻辑的好地方。

始终要 export 这个组件类,以便在其它地方(比如 AppModule)导入它。

添加 hero 属性

往 HeroesComponent 中添加一个 hero 属性,用来表示一个名叫 “W3Cschool” 的英雄。

Path:"heroes.component.ts (hero property)"

hero = 'Windstorm';

显示英雄

打开模板文件 "heroes.component.html"。删除 Angular CLI 自动生成的默认内容,改为到 hero 属性的数据绑定。

Path:"heroes.component.html"

{{hero}}

显示 HeroesComponent 视图

要显示 HeroesComponent 你必须把它加到壳组件 AppComponent 的模板中。

别忘了,app-heroes 就是 HeroesComponent 的 元素选择器。 所以,只要把 <app-heroes> 元素添加到 AppComponent 的模板文件中就可以了,就放在标题下方。

Path:"src/app/app.component.html"

<h1>{{title}}</h1><app-heroes></app-heroes>

如果 CLI 的 ng serve 命令仍在运行,浏览器就会自动刷新,并且同时显示出应用的标题和英雄的名字。

创建 Hero 类

真实的英雄当然不止一个名字。

src/app 文件夹中为 Hero 类创建一个文件,并添加 id 和 name 属性。

Path:"src/app/hero.ts"

export interface Hero {  id: number;  name: string;}

回到 HeroesComponent 类,并且导入这个 Hero 类。

把组件的 hero 属性的类型重构为 Hero。 然后以 1 为 id、以 “W3Cschool” 为名字初始化它。

修改后的 HeroesComponent 类如下:

Path:"src/app/heroes/heroes.component.ts"

import { Component, OnInit } from '@angular/core';import { Hero } from '../hero';@Component({  selector: 'app-heroes',  templateUrl: './heroes.component.html',  styleUrls: ['./heroes.component.css']})export class HeroesComponent implements OnInit {  hero: Hero = {    id: 1,    name: 'Windstorm'  };  constructor() { }  ngOnInit() {  }}

页面显示变得不正常了,因为你刚刚把 hero 从字符串改成了对象。

显示 hero 对象

修改模板中的绑定,以显示英雄的名字,并在详情中显示 id 和 name,就像这样:

Path:"heroes.component.html (HeroesComponent's template)"

<h2>{{hero.name}} Details</h2><div><span>id: </span>{{hero.id}}</div><div><span>name: </span>{{hero.name}}</div>

浏览器自动刷新,并显示这位英雄的信息。

使用 UppercasePipe 进行格式化

把 hero.name 的绑定修改成这样:

Path:"src/app/heroes/heroes.component.html"

<h2>{{hero.name | uppercase}} Details</h2>

浏览器刷新了。现在,英雄的名字显示成了大写字母。

绑定表达式中的 uppercase 位于管道操作符( | )的右边,用来调用内置管道 UppercasePipe。

管道 是格式化字符串、金额、日期和其它显示数据的好办法。 Angular 发布了一些内置管道,而且你还可以创建自己的管道。

编辑英雄名字

用户应该能在一个 <input> 输入框中编辑英雄的名字。

当用户输入时,这个输入框应该能同时显示和修改英雄的 name 属性。 也就是说,数据流从组件类流出到屏幕,并且从屏幕流回到组件类。

要想让这种数据流动自动化,就要在表单元素 <input> 和组件的 hero.name 属性之间建立双向数据绑定。

双向绑定

把模板中的英雄详情区重构成这样:

Path:"src/app/heroes/heroes.component.html (HeroesComponent's template)"

<div>  <label>name:    <input [(ngModel)]="hero.name" placeholder="name"/>  </label></div>

[(ngModel)] 是 Angular 的双向数据绑定语法。

这里把 hero.name 属性绑定到了 HTML 的 textbox 元素上,以便数据流可以双向流动:从 hero.name 属性流动到 textbox,并且从 textbox 流回到 hero.name

缺少 FormsModule

注意,当你加上 [(ngModel)] 之后这个应用无法工作了。

打开浏览器的开发工具,就会在控制台中看到如下信息:

Template parse errors:Can't bind to 'ngModel' since it isn't a known property of 'input'.

虽然 ngModel 是一个有效的 Angular 指令,不过它在默认情况下是不可用的。

它属于一个可选模块 FormsModule,你必须自行添加此模块才能使用该指令。

AppModule

Angular 需要知道如何把应用程序的各个部分组合到一起,以及该应用需要哪些其它文件和库。 这些信息被称为元数据(metadata)。

有些元数据位于 @Component 装饰器中,你会把它加到组件类上。 另一些关键性的元数据位于 @NgModule 装饰器中。

最重要的 @NgModule 装饰器位于顶层类 AppModule 上。

Angular CLI 在创建项目的时候就在 "src/app/app.module.ts" 中生成了一个 AppModule 类。 这里也就是你要添加 FormsModule 的地方。

导入 FormsModule

打开 AppModule (app.module.ts) 并从 @angular/forms 库中导入 FormsModule 符号。

Path:"app.module.ts (FormsModule symbol import)"

import { FormsModule } from '@angular/forms'; // <-- NgModel lives here

然后把 FormsModule 添加到 @NgModule 元数据的 imports 数组中,这里是该应用所需外部模块的列表。

Path:"app.module.ts (@NgModule imports)"

imports: [  BrowserModule,  FormsModule],

刷新浏览器,应用又能正常工作了。你可以编辑英雄的名字,并且会看到这个改动立刻体现在这个输入框上方的 <h2> 中。

声明 HeroesComponent

每个组件都必须声明在(且只能声明在)一个 NgModule 中。

你没有声明过 HeroesComponent,可为什么本应用却正常呢?

这是因为 Angular CLI 在生成 HeroesComponent 组件的时候就自动把它加到了 AppModule 中。

打开 "src/app/app.module.ts" 你就会发现 HeroesComponent 已经在顶部导入过了。

Path:"src/app/app.module.ts"

import { HeroesComponent } from './heroes/heroes.component';

HeroesComponent 也已经声明在了 @NgModule.declarations 数组中。

Path:"src/app/app.module.ts"

declarations: [  AppComponent,  HeroesComponent],

注:
AppModule 声明了应用中的所有组件,AppComponent 和 HeroesComponent。

Final code review

本篇设计的代码如下:

  1. Path:"src/app/heroes/heroes.component.ts"

import { Component, OnInit } from '@angular/core';import { Hero } from '../hero';@Component({  selector: 'app-heroes',  templateUrl: './heroes.component.html',  styleUrls: ['./heroes.component.css']})export class HeroesComponent implements OnInit {  hero: Hero = {    id: 1,    name: 'Windstorm'  };  constructor() { }  ngOnInit() {  }}

  1. Path:"src/app/heroes/heroes.component.html"

<h2>{{hero.name | uppercase}} Details</h2><div><span>id: </span>{{hero.id}}</div><div>  <label>name:    <input [(ngModel)]="hero.name" placeholder="name"/>  </label></div>

  1. Path:"src/app/app.module.ts"

import { BrowserModule } from '@angular/platform-browser';import { NgModule } from '@angular/core';import { FormsModule } from '@angular/forms'; // <-- NgModel lives hereimport { AppComponent } from './app.component';import { HeroesComponent } from './heroes/heroes.component';@NgModule({  declarations: [    AppComponent,    HeroesComponent  ],  imports: [    BrowserModule,    FormsModule  ],  providers: [],  bootstrap: [AppComponent]})export class AppModule { }

  1. Path:"src/app/app.component.ts"

import { Component } from '@angular/core';@Component({  selector: 'app-root',  templateUrl: './app.component.html',  styleUrls: ['./app.component.css']})export class AppComponent {  title = 'Tour of Heroes';}

  1. Path:"src/app/app.component.html"

<h1>{{title}}</h1><app-heroes></app-heroes>

  1. Path:"src/app/hero.ts"

export interface Hero {  id: number;  name: string;}

总结

  • 您使用 CLI 创建了第二个组件 HeroesComponent。

  • 您把 HeroesComponent 添加到了壳组件 AppComponent 中,以便显示它。

  • 您使用 UppercasePipe 来格式化英雄的名字。

  • 您用 ngModel 指令实现了双向数据绑定。

  • 您知道了 AppModule。

  • 您把 FormsModule 导入了 AppModule,以便 Angular 能识别并应用 ngModel 指令。

  • 您知道了把组件声明到 AppModule 是很重要的,并认识到 CLI 会自动帮你声明它。

您将扩展“英雄指南”应用,让它显示一个英雄列表, 并允许用户选择一个英雄,并查看该英雄的详细信息。

创建模拟(mock)的英雄数据

您需要一些英雄数据以供显示。

最终,您会从远端的数据服务器获取它。 不过目前,您要先创建一些模拟的英雄数据,并假装它们是从服务器上取到的。

在 "src/app/" 文件夹中创建一个名叫 "mock-heroes.ts" 的文件。 定义一个包含十个英雄的常量数组 HEROES,并导出它。 该文件是这样的。

Path:"src/app/mock-heroes.ts"

import { Hero } from './hero';export const HEROES: Hero[] = [  { id: 11, name: 'Dr Nice' },  { id: 12, name: 'Narco' },  { id: 13, name: 'Bombasto' },  { id: 14, name: 'Celeritas' },  { id: 15, name: 'Magneta' },  { id: 16, name: 'RubberMan' },  { id: 17, name: 'Dynama' },  { id: 18, name: 'Dr IQ' },  { id: 19, name: 'Magma' },  { id: 20, name: 'Tornado' }];

显示这些英雄

打开 HeroesComponent 类文件,并导入模拟的 HEROES

Path:"src/app/heroes/heroes.component.ts (import HEROES)"

import { HEROES } from '../mock-heroes';

往类中添加一个 heroes 属性,这样可以暴露出这个 HEROES 数组,以供绑定。

Path:"src/app/heroes/heroes.component.ts"

export class HeroesComponent implements OnInit {  heroes = HEROES;}

使用 *ngFor 列出这些英雄

打开 HeroesComponent 的模板文件,并做如下修改:

  • 在顶部添加 <h2>

  • 然后添加表示无序列表的 HTML 元素(<ul>

  • <ul> 中插入一个 <li> 元素,以显示单个 hero 的属性。

  • 点缀上一些 CSS 类(稍后你还会添加更多 CSS 样式)。

完成后如下:

Path:"heroes.component.html (heroes template)"

<h2>My Heroes</h2><ul class="heroes">  <li>    <span class="badge">{{hero.id}}</span> {{hero.name}}  </li></ul>

这只展示了一个英雄。要想把他们都列出来,就要为 <li> 添加一个 *ngFor 以便迭代出列表中的所有英雄:

<li *ngFor="let hero of heroes">

*ngFor 是一个 Angular 的复写器(repeater)指令。 它会为列表中的每项数据复写它的宿主元素。

这个例子中涉及的语法如下:

  • <li> 就是 *ngFor 的宿主元素。

  • heroes 就是来自 HeroesComponent 类的列表。

  • 当依次遍历这个列表时,hero 会为每个迭代保存当前的英雄对象。

注:
- ngFor 前面的星号(*)是该语法中的关键部分。

更改列表外观

英雄列表应该富有吸引力,并且当用户把鼠标移到某个英雄上和从列表中选中某个英雄时,应该给出视觉反馈。

在教程的第一章,你曾在 styles.css 中为整个应用设置了一些基础的样式。 但那个样式表并不包含英雄列表所需的样式。

固然,你可以把更多样式加入到 styles.css,并且放任它随着你添加更多组件而不断膨胀。

但还有更好的方式。你可以定义属于特定组件的私有样式,并且让组件所需的一切(代码、HTML 和 CSS)都放在一起。

这种方式让你在其它地方复用该组件更加容易,并且即使全局样式和这里不一样,组件也仍然具有期望的外观。

你可以用多种方式定义私有样式,或者内联在 @Component.styles 数组中,或者在 @Component.styleUrls 所指出的样式表文件中。

当 CLI 生成 HeroesComponent 时,它也同时为 HeroesComponent 创建了空白的 heroes.component.css 样式表文件,并且让 @Component.styleUrls 指向它,像这样:

Path:"src/app/heroes/heroes.component.ts (@Component)"

@Component({  selector: 'app-heroes',  templateUrl: './heroes.component.html',  styleUrls: ['./heroes.component.css']})

打开 heroes.component.css 文件,并且把 HeroesComponent 的私有 CSS 样式粘贴进去。 你可以在本指南底部的查看最终代码中找到它们。

主从结构

当用户在主列表中点击一个英雄时,该组件应该在页面底部显示所选英雄的详情。

在本节,你将监听英雄条目的点击事件,并更新英雄的详情。

添加 Click 事件绑定

再往 <li> 元素上插入一句点击事件的绑定代码:

Path:"heroes.component.html (template excerpt)"

<li *ngFor="let hero of heroes" (click)="onSelect(hero)">

这是 Angular 事件绑定 语法的例子。

click 外面的圆括号会让 Angular 监听这个 <li> 元素的 click 事件。 当用户点击 <li> 时,Angular 就会执行表达式 onSelect(hero)

下一部分,会在 HeroesComponent 上定义一个 onSelect() 方法,用来显示 *ngFor 表达式所定义的那个英雄(hero)。

添加 click 事件处理器

把该组件的 hero 属性改名为 selectedHero,但不要为它赋值。 因为应用刚刚启动时并没有所选英雄。

添加如下 onSelect() 方法,它会把模板中被点击的英雄赋值给组件的 selectedHero 属性。

Path:"src/app/heroes/heroes.component.ts (onSelect)"

selectedHero: Hero;onSelect(hero: Hero): void {  this.selectedHero = hero;}

添加详情区

现在,组件的模板中有一个列表。要想点击列表中的一个英雄,并显示该英雄的详情,你需要在模板中留一个区域,用来显示这些详情。 在 heroes.component.html 中该列表的紧下方,添加如下代码:

Path:"heroes.component.html (selected hero details)"

<h2>{{selectedHero.name | uppercase}} Details</h2><div><span>id: </span>{{selectedHero.id}}</div><div>  <label>name:    <input [(ngModel)]="selectedHero.name" placeholder="name"/>  </label></div>

刷新浏览器,你会发现应用挂了。

打开浏览器的开发者工具,它的控制台中显示出如下错误信息:

HeroesComponent.html:3 ERROR TypeError: Cannot read property 'name' of undefined

当应用启动时,selectedHeroundefined,没有问题。

但模板中的绑定表达式引用了 selectedHero 的属性(表达式为 {{selectedHero.name}}),这必然会失败,因为你还没选过英雄呢。

修复 —— 使用 *ngIf 隐藏空白的详情

该组件应该只有当 selectedHero 存在时才显示所选英雄的详情。

把显示英雄详情的 HTML 包裹在一个 <div> 中。 并且为这个 div 添加 Angular 的 *ngIf 指令,把它的值设置为 selectedHero

*Path:"src/app/heroes/heroes.component.html (ngIf)"**

<div *ngIf="selectedHero">  <h2>{{selectedHero.name | uppercase}} Details</h2>  <div><span>id: </span>{{selectedHero.id}}</div>  <div>    <label>name:      <input [(ngModel)]="selectedHero.name" placeholder="name"/>    </label>  </div></div>

浏览器刷新之后,英雄名字的列表又出现了。 详情部分仍然是空。 从英雄列表中点击一个英雄,它的详情就出现了。 应用又能工作了。 英雄们出现在列表中,而被点击的英雄出现在了页面底部。

注:
- 当 selectedHeroundefined 时,ngIf 从 DOM 中移除了英雄详情。因此也就不用关心 selectedHero 的绑定了。

  • 当用户选择一个英雄时,selectedHero 也就有了值,并且 ngIf 把英雄的详情放回到 DOM 中。

给所选英雄添加样式

所有的 <li> 元素看起来都是一样的,因此很难从列表中识别出所选英雄。

如果用户点击了“Magneta”,这个英雄应该用一个略有不同的背景色显示出来,就像这样:

所选英雄的颜色来自于你前面添加的样式中的 CSS 类 .selected。 所以你只要在用户点击一个 <li> 时把 .selected 类应用到该元素上就可以了。

Angular 的 CSS 类绑定机制让根据条件添加或移除一个 CSS 类变得很容易。 只要把 [class.some-css-class]="some-condition" 添加到你要施加样式的元素上就可以了。

在 HeroesComponent 模板中的 <li> 元素上添加 [class.selected] 绑定,代码如下:

Path:"heroes.component.html (toggle the 'selected' CSS class)"

[class.selected]="hero === selectedHero"

如果当前行的英雄和 selectedHero 相同,Angular 就会添加 CSS 类 selected,否则就会移除它。

最终的 <li> 是这样的:

Path:"heroes.component.html (list item hero)"

<li *ngFor="let hero of heroes"  [class.selected]="hero === selectedHero"  (click)="onSelect(hero)">  <span class="badge">{{hero.id}}</span> {{hero.name}}</li>

查看最终代码

  1. Path:"src/app/mock-heroes.ts"

    export const HEROES: Hero[] = [      { id: 11, name: 'Dr Nice' },      { id: 12, name: 'Narco' },      { id: 13, name: 'Bombasto' },      { id: 14, name: 'Celeritas' },      { id: 15, name: 'Magneta' },      { id: 16, name: 'RubberMan' },      { id: 17, name: 'Dynama' },      { id: 18, name: 'Dr IQ' },      { id: 19, name: 'Magma' },      { id: 20, name: 'Tornado' }    ];

  1. Path:"src/app/heroes/heroes.component.ts"

    import { Component, OnInit } from '@angular/core';    import { Hero } from '../hero';    import { HEROES } from '../mock-heroes';    @Component({      selector: 'app-heroes',      templateUrl: './heroes.component.html',      styleUrls: ['./heroes.component.css']    })    export class HeroesComponent implements OnInit {      heroes = HEROES;      selectedHero: Hero;      constructor() { }      ngOnInit() {      }      onSelect(hero: Hero): void {        this.selectedHero = hero;      }    }

  1. Path:"src/app/heroes/heroes.component.html"

    <h2>My Heroes</h2>    <ul class="heroes">      <li *ngFor="let hero of heroes"        [class.selected]="hero === selectedHero"        (click)="onSelect(hero)">        <span class="badge">{{hero.id}}</span> {{hero.name}}      </li>    </ul>    <div *ngIf="selectedHero">      <h2>{{selectedHero.name | uppercase}} Details</h2>      <div><span>id: </span>{{selectedHero.id}}</div>      <div>        <label>name:          <input [(ngModel)]="selectedHero.name" placeholder="name"/>        </label>      </div>    </div>

  1. Path:"src/app/heroes/heroes.component.css"

    /* HeroesComponent's private CSS styles */    .heroes {      margin: 0 0 2em 0;      list-style-type: none;      padding: 0;      width: 15em;    }    .heroes li {      cursor: pointer;      position: relative;      left: 0;      background-color: #EEE;      margin: .5em;      padding: .3em 0;      height: 1.6em;      border-radius: 4px;    }    .heroes li:hover {      color: #607D8B;      background-color: #DDD;      left: .1em;    }    .heroes li.selected {      background-color: #CFD8DC;      color: white;    }    .heroes li.selected:hover {      background-color: #BBD8DC;      color: white;    }    .heroes .badge {      display: inline-block;      font-size: small;      color: white;      padding: 0.8em 0.7em 0 0.7em;      background-color:#405061;      line-height: 1em;      position: relative;      left: -1px;      top: -4px;      height: 1.8em;      margin-right: .8em;      border-radius: 4px 0 0 4px;    }

总结

  • 英雄指南应用在一个主从视图中显示了英雄列表。

  • 用户可以选择一个英雄,并查看该英雄的详情。

  • 您使用 *ngFor 显示了一个列表。

  • 您使用 *ngIf 来根据条件包含或排除了一段 HTML。

  • 您可以用 class 绑定来切换 CSS 的样式类。

主从组件

此刻,HeroesComponent 同时显示了英雄列表和所选英雄的详情。

把所有特性都放在同一个组件中,将会使应用“长大”后变得不可维护。 你要把大型组件拆分成小一点的子组件,每个子组件都要集中精力处理某个特定的任务或工作流。

本页面中,你将迈出第一步 —— 把英雄详情移入一个独立的、可复用的 HeroDetailComponent。

HeroesComponent 将仅仅用来表示英雄列表。 HeroDetailComponent 将用来表示所选英雄的详情。

制作 HeroDetailComponent

使用 Angular CLI 生成一个名叫 hero-detail 的新组件。

ng generate component hero-detail

这个命令会做这些事:

  • 创建目录 "src/app/hero-detail"。

在这个目录中会生成四个文件:

  • 作为组件样式的 CSS 文件。

  • 作为组件模板的 HTML 文件。

  • 存放组件类 HeroDetailComponent 的 TypeScript 文件。

  • HeroDetailComponent 类的测试文件。

该命令还会把 HeroDetailComponent 添加到 "src/app/app.module.ts" 文件中 @NgModuledeclarations 列表中。

编写模板

从 HeroesComponent 模板的底部把表示英雄详情的 HTML 代码剪切粘贴到所生成的 HeroDetailComponent 模板中。

所粘贴的 HTML 引用了 selectedHero。 新的 HeroDetailComponent 可以展示任意英雄,而不仅仅所选的。因此还要把模板中的所有 selectedHero 替换为 hero

完工之后,HeroDetailComponent 的模板应该是这样的:

Path:"src/app/hero-detail/hero-detail.component.html"

<div *ngIf="hero">  <h2>{{hero.name | uppercase}} Details</h2>  <div><span>id: </span>{{hero.id}}</div>  <div>    <label>name:      <input [(ngModel)]="hero.name" placeholder="name"/>    </label>  </div></div>

添加 @Input() hero 属性

HeroDetailComponent 模板中绑定了组件中的 hero 属性,它的类型是 Hero

打开 HeroDetailComponent 类文件,并导入 Hero 符号。

Path:"src/app/hero-detail/hero-detail.component.ts (import Hero)"

import { Hero } from '../hero';

hero 属性必须是一个带有 @Input() 装饰器的输入属性,因为外部的 HeroesComponent 组件将会绑定到它。就像这样:

<app-hero-detail [hero]="selectedHero"></app-hero-detail>

修改 @angular/core 的导入语句,导入 Input 符号。

Path:"src/app/hero-detail/hero-detail.component.ts (import Input)"

import { Component, OnInit, Input } from '@angular/core';

添加一个带有 @Input() 装饰器的 hero 属性。

Path:"src/app/hero-detail/hero-detail.component.ts"

@Input() hero: Hero;

这就是你要对 HeroDetailComponent 类做的唯一一项修改。 没有其它属性,也没有展示逻辑。这个组件所做的只是通过 hero 属性接收一个英雄对象,并显示它。

显示 HeroDetailComponent

HeroesComponent 仍然是主从视图。

在你从模板中剪切走代码之前,它自己负责显示英雄的详情。现在它要把这个职责委托给 HeroDetailComponent 了。

这两个组件将会具有父子关系。 当用户从列表中选择了某个英雄时,父组件 HeroesComponent 将通过把要显示的新英雄发送给子组件 HeroDetailComponent,来控制子组件。

你不用修改 HeroesComponent 类,但是要修改它的模板。

修改 HeroesComponent 的模板

HeroDetailComponent 的选择器是 'app-hero-detail'。 把 <app-hero-detail> 添加到 HeroesComponent 模板的底部,以便把英雄详情的视图显示到那里。

把 HeroesComponent.selectedHero 绑定到该元素的 hero 属性,就像这样:

Path:"heroes.component.html (HeroDetail binding)"

<app-hero-detail [hero]="selectedHero"></app-hero-detail>

[hero]="selectedHero" 是 Angular 的属性绑定语法。

这是一种单向数据绑定。从 HeroesComponent 的 selectedHero 属性绑定到目标元素的 hero 属性,并映射到了 HeroDetailComponent 的 hero 属性。

现在,当用户在列表中点击某个英雄时,selectedHero 就改变了。 当 selectedHero 改变时,属性绑定会修改 HeroDetailComponent 的 hero 属性,HeroDetailComponent 就会显示这个新的英雄。

修改后的 HeroesComponent 的模板是这样的:

Path:"heroes.component.html"

<h2>My Heroes</h2><ul class="heroes">  <li *ngFor="let hero of heroes"    [class.selected]="hero === selectedHero"    (click)="onSelect(hero)">    <span class="badge">{{hero.id}}</span> {{hero.name}}  </li></ul><app-hero-detail [hero]="selectedHero"></app-hero-detail>

浏览器刷新,应用又像以前一样开始工作了。

产生的变化:

像以前一样,一旦用户点击了一个英雄的名字,该英雄的详情就显示在了英雄列表下方。 现在,HeroDetailComponent 负责显示那些详情,而不再是 HeroesComponent。

把原来的 HeroesComponent 重构成两个组件带来了一些优点,无论是现在还是未来:

你通过缩减 HeroesComponent 的职责简化了该组件。

你可以把 HeroDetailComponent 改进成一个功能丰富的英雄编辑器,而不用改动父组件 HeroesComponent。

你可以改进 HeroesComponent,而不用改动英雄详情视图。

将来你可以在其它组件的模板中重复使用 HeroDetailComponent。

查看最终代码

  1. Path:"src/app/hero-detail/hero-detail.component.ts"

    import { Component, OnInit, Input } from '@angular/core';    import { Hero } from '../hero';    @Component({      selector: 'app-hero-detail',      templateUrl: './hero-detail.component.html',      styleUrls: ['./hero-detail.component.css']    })    export class HeroDetailComponent implements OnInit {      @Input() hero: Hero;      constructor() { }      ngOnInit() {      }    }

  1. Path:"src/app/hero-detail/hero-detail.component.html"

    <div *ngIf="hero">      <h2>{{hero.name | uppercase}} Details</h2>      <div><span>id: </span>{{hero.id}}</div>      <div>        <label>name:          <input [(ngModel)]="hero.name" placeholder="name"/>        </label>      </div>    </div>

  1. Path:"src/app/heroes/heroes.component.html"

    <h2>My Heroes</h2>    <ul class="heroes">      <li *ngFor="let hero of heroes"        [class.selected]="hero === selectedHero"        (click)="onSelect(hero)">        <span class="badge">{{hero.id}}</span> {{hero.name}}      </li>    </ul>    <app-hero-detail [hero]="selectedHero"></app-hero-detail>

  1. Path:"src/app/app.module.ts"

    import { BrowserModule } from '@angular/platform-browser';    import { NgModule } from '@angular/core';    import { FormsModule } from '@angular/forms';    import { AppComponent } from './app.component';    import { HeroesComponent } from './heroes/heroes.component';    import { HeroDetailComponent } from './hero-detail/hero-detail.component';    @NgModule({      declarations: [        AppComponent,        HeroesComponent,        HeroDetailComponent      ],      imports: [        BrowserModule,        FormsModule      ],      providers: [],      bootstrap: [AppComponent]    })    export class AppModule { }

总结

  • 您创建了一个独立的、可复用的 HeroDetailComponent 组件。

  • 您用属性绑定语法来让父组件 HeroesComponent 可以控制子组件 HeroDetailComponent。

  • 您用 @Input 装饰器来让 hero 属性可以在外部的 HeroesComponent 中绑定。

英雄指南的 HeroesComponent 目前获取和显示的都是模拟数据。

本节课的重构完成之后,HeroesComponent 变得更精简,并且聚焦于为它的视图提供支持。这也让它更容易使用模拟服务进行单元测试。

服务存在的意义

组件不应该直接获取或保存数据,它们不应该了解是否在展示假数据。 它们应该聚焦于展示数据,而把数据访问的职责委托给某个服务。

本节课,你将创建一个 HeroService,应用中的所有类都可以使用它来获取英雄列表。 不要使用 new 关键字来创建此服务,而要依靠 Angular 的依赖注入机制把它注入到 HeroesComponent 的构造函数中。

服务是在多个“互相不知道”的类之间共享信息的好办法。 你将创建一个 MessageService,并且把它注入到两个地方:

  1. 注入到 HeroService 中,它会使用该服务发送消息

  1. 注入到 MessagesComponent 中,它会显示其中的消息。当用户点击某个英雄时,它还会显示该英雄的 ID

创建 HeroService

使用 Angular CLI 创建一个名叫 hero 的服务。

ng generate service hero

该命令会在 "src/app/hero.service.ts" 中生成 HeroService 类的骨架,代码如下:

Path:"src/app/hero.service.ts (new service)"

import { Injectable } from '@angular/core';@Injectable({  providedIn: 'root',})export class HeroService {  constructor() { }}

@Injectable() 服务

注意,这个新的服务导入了 Angular 的 Injectable 符号,并且给这个服务类添加了 @Injectable() 装饰器。 它把这个类标记为依赖注入系统的参与者之一。HeroService 类将会提供一个可注入的服务,并且它还可以拥有自己的待注入的依赖。 目前它还没有依赖,但是很快就会有了。

@Injectable() 装饰器会接受该服务的元数据对象,就像 @Component() 对组件类的作用一样。

获取英雄数据

HeroService 可以从任何地方获取数据:Web 服务、本地存储(LocalStorage)或一个模拟的数据源。

从组件中移除数据访问逻辑,意味着将来任何时候你都可以改变目前的实现方式,而不用改动任何组件。 这些组件不需要了解该服务的内部实现。

这节课中的实现仍然会提供模拟的英雄列表。

导入 Hero 和 HEROES。

Path:"src/app/hero.service.ts"

import { Hero } from './hero';import { HEROES } from './mock-heroes';

添加一个 getHeroes 方法,让它返回模拟的英雄列表。

Path:"src/app/hero.service.ts"

getHeroes(): Hero[] {  return HEROES;}

提供(provide) HeroService

你必须先注册一个服务提供者,来让 HeroService 在依赖注入系统中可用,Angular 才能把它注入到 HeroesComponent 中。所谓服务提供者就是某种可用来创建或交付一个服务的东西;在这里,它通过实例化 HeroService 类,来提供该服务。

为了确保 HeroService 可以提供该服务,就要使用注入器来注册它。注入器是一个对象,负责当应用要求获取它的实例时选择和注入该提供者。

默认情况下,Angular CLI 命令 ng generate service 会通过给 @Injectable() 装饰器添加 providedIn: 'root' 元数据的形式,用根注入器将你的服务注册成为提供者。

@Injectable({  providedIn: 'root',})

注:
- 这是一个过渡性的代码范例,它将会允许你提供并使用 HeroService。此刻的代码和最终代码相差很大。

当你在顶层提供该服务时,Angular 就会为 HeroService 创建一个单一的、共享的实例,并把它注入到任何想要它的类上。 在 @Injectable 元数据中注册该提供者,还能允许 Angular 通过移除那些完全没有用过的服务来进行优化。

现在 HeroService 已经准备好插入到 HeroesComponent 中了。

修改 HeroesComponent

打开 HeroesComponent 类文件。

删除 HEROES 的导入语句,因为你以后不会再用它了。 转而导入 HeroService

Path:"src/app/heroes/heroes.component.ts (import HeroService)"

import { HeroService } from '../hero.service';

把 heroes 属性的定义改为一句简单的声明。

Path:"src/app/heroes/heroes.component.ts"

heroes: Hero[];

注入 HeroService

往构造函数中添加一个私有的 heroService,其类型为 HeroService

Path:"src/app/heroes/heroes.component.ts"

constructor(private heroService: HeroService) {}

这个参数同时做了两件事:

  1. 声明了一个私有 heroService 属性。
  2. 把它标记为一个 HeroService 的注入点。

当 Angular 创建 HeroesComponent 时,依赖注入系统就会把这个 heroService 参数设置为 HeroService 的单例对象。

添加 getHeroes()

创建一个方法,以从服务中获取这些英雄数据。

Path:"src/app/heroes/heroes.component.ts"

getHeroes(): void {  this.heroes = this.heroService.getHeroes();}

在 ngOnInit() 中调用它

你固然可以在构造函数中调用 getHeroes(),但那不是最佳实践。

让构造函数保持简单,只做初始化操作,比如把构造函数的参数赋值给属性。 构造函数不应该做任何事。 它当然不应该调用某个函数来向远端服务(比如真实的数据服务)发起 HTTP 请求。

而是选择在 ngOnInit 生命周期钩子中调用 getHeroes(),之后 Angular 会在构造出 HeroesComponent 的实例之后的某个合适的时机调用 ngOnInit()

Path:"src/app/heroes/heroes.component.ts"

getHeroes(): void { ngOnInit() {  this.getHeroes();}

刷新浏览器,该应用仍运行的一如既往。 显示英雄列表,并且当你点击某个英雄的名字时显示出英雄详情视图。

可观察(Observable)的数据

HeroService.getHeroes() 的函数签名是同步的,它所隐含的假设是 HeroService 总是能同步获取英雄列表数据。 而 HeroesComponent 也同样假设能同步取到 getHeroes() 的结果。

Path:"src/app/heroes/heroes.component.ts"

this.heroes = this.heroService.getHeroes();

这在真实的应用中几乎是不可能的。 现在能这么做,只是因为目前该服务返回的是模拟数据。 不过很快,该应用就要从远端服务器获取英雄数据了,而那天生就是异步操作。

HeroService 必须等服务器给出响应, 而 getHeroes() 不能立即返回英雄数据, 浏览器也不会在该服务等待期间停止响应。

HeroService.getHeroes() 必须具有某种形式的异步函数签名。

这节课,HeroService.getHeroes() 将会返回 Observable,部分原因在于它最终会使用 Angular 的 HttpClient.get() 方法来获取英雄数据,而 HttpClient.get() 会返回 Observable

可观察对象版本的 HeroService

Observable 是 RxJS 库中的一个关键类。

在稍后的 HTTP 教程中,你就会知道 Angular HttpClient 的方法会返回 RxJS 的 Observable。 这节课,你将使用 RxJS 的 of() 函数来模拟从服务器返回数据。

打开 "HeroService" 文件,并从 RxJS 中导入 Observableof 符号。

Path:"src/app/hero.service.ts (Observable imports)"

import { Observable, of } from 'rxjs';

getHeroes() 方法改成这样:

Path:"src/app/hero.service.ts"

getHeroes(): Observable<Hero[]> {  return of(HEROES);}

of(HEROES) 会返回一个 Observable<Hero[]>,它会发出单个值,这个值就是这些模拟英雄的数组。

在 HeroesComponent 中订阅

HeroService.getHeroes 方法之前返回一个 Hero[], 现在它返回的是 Observable<Hero[]>

你必须在 HeroesComponent 中也向本服务中的这种形式看齐。

找到 getHeroes 方法,并且把它替换为如下代码(和前一个版本对比显示):

  1. Path:"heroes.component.ts (Observable)"
    getHeroes(): void {  this.heroService.getHeroes()      .subscribe(heroes => this.heroes = heroes);}

  1. Path:"heroes.component.ts (Original)"
    getHeroes(): void {  this.heroes = this.heroService.getHeroes();}

Observable.subscribe() 是关键的差异点。

上一个版本把英雄的数组赋值给了该组件的 heroes 属性。 这种赋值是同步的,这里包含的假设是服务器能立即返回英雄数组或者浏览器能在等待服务器响应时冻结界面。

HeroService 真的向远端服务器发起请求时,这种方式就行不通了。

新的版本等待 Observable 发出这个英雄数组,这可能立即发生,也可能会在几分钟之后。 然后,subscribe() 方法把这个英雄数组传给这个回调函数,该函数把英雄数组赋值给组件的 heroes 属性。

使用这种异步方式,当 HeroService 从远端服务器获取英雄数据时,就可以工作了。

显示消息

这一节将指导你:

  • 添加一个 MessagesComponent,它在屏幕的底部显示应用中的消息。

  • 创建一个可注入的、全应用级别的 MessageService,用于发送要显示的消息。

  • MessageService 注入到 HeroService 中。

  • HeroService 成功获取了英雄数据时显示一条消息。

创建 MessagesComponent

使用 CLI 创建 MessagesComponent。

ng generate component messages

CLI 在 "src/app/messages" 中创建了组件文件,并且把 MessagesComponent 声明在了 AppModule 中。

修改 AppComponent 的模板来显示所生成的 MessagesComponent

Path:"src/app/message.service.ts"

import { Injectable } from '@angular/core';@Injectable({  providedIn: 'root',})export class MessageService {  messages: string[] = [];  add(message: string) {    this.messages.push(message);  }  clear() {    this.messages = [];  }}

该服务对外暴露了它的 messages 缓存,以及两个方法:add() 方法往缓存中添加一条消息,clear() 方法用于清空缓存。

注入到 HeroService 中

在 HeroService 中导入 MessageService。

Path:"src/app/hero.service.ts (import MessageService)"

import { MessageService } from './message.service';

修改这个构造函数,添加一个私有的 messageService 属性参数。 Angular 将会在创建 HeroService 时把 MessageService 的单例注入到这个属性中。

Path:"src/app/hero.service.ts"

constructor(private messageService: MessageService) { }

注:
- 这是一个典型的“服务中的服务”场景: 你把 MessageService 注入到了 HeroService 中,而 HeroService 又被注入到了 HeroesComponent 中。

从 HeroService 中发送一条消息

修改 getHeroes() 方法,在获取到英雄数组时发送一条消息。

Path:"src/app/hero.service.ts"

getHeroes(): Observable<Hero[]> {  // TODO: send the message _after_ fetching the heroes  this.messageService.add('HeroService: fetched heroes');  return of(HEROES);}

从 HeroService 中显示消息

MessagesComponent 可以显示所有消息, 包括当 HeroService 获取到英雄数据时发送的那条。

打开 MessagesComponent,并且导入 MessageService

Path:"src/app/messages/messages.component.ts (import MessageService)"

import { MessageService } from '../message.service';

修改构造函数,添加一个 public 的 messageService 属性。 Angular 将会在创建 MessagesComponent 的实例时 把 MessageService 的实例注入到这个属性中。

Path:"src/app/messages/messages.component.ts"

constructor(public messageService: MessageService) {}

这个 messageService 属性必须是公共属性,因为你将会在模板中绑定到它。

绑定到 MessageService

把 CLI 生成的 MessagesComponent 的模板改成这样:

Path:"src/app/messages/messages.component.html"

<div *ngIf="messageService.messages.length">  <h2>Messages</h2>  <button class="clear"          (click)="messageService.clear()">clear</button>  <div *ngFor='let message of messageService.messages'> {{message}} </div></div>

这个模板直接绑定到了组件的 messageService 属性上。

  • *ngIf 只有在有消息时才会显示消息区。

  • *ngFor 用来在一系列 <div> 元素中展示消息列表。

  • Angular 的事件绑定把按钮的 click 事件绑定到了 MessageService.clear()

当你把 最终代码 某一页的内容添加到 messages.component.css 中时,这些消息会变得好看一些。

为 hero 服务添加额外的消息

下面的例子展示了当用户点击某个英雄时,如何发送和显示一条消息,以及如何显示该用户的选取历史。当你学到后面的路由一章时,这会很有帮助。

Path:"src/app/heroes/heroes.component.ts"

import { Component, OnInit } from '@angular/core';import { Hero } from '../hero';import { HeroService } from '../hero.service';import { MessageService } from '../message.service';@Component({  selector: 'app-heroes',  templateUrl: './heroes.component.html',  styleUrls: ['./heroes.component.css']})export class HeroesComponent implements OnInit {  selectedHero: Hero;  heroes: Hero[];  constructor(private heroService: HeroService, private messageService: MessageService) { }  ngOnInit() {    this.getHeroes();  }  onSelect(hero: Hero): void {    this.selectedHero = hero;    this.messageService.add(`HeroesComponent: Selected hero id=${hero.id}`);  }  getHeroes(): void {    this.heroService.getHeroes()        .subscribe(heroes => this.heroes = heroes);  }}

刷新浏览器,页面显示出了英雄列表。 滚动到底部,就会在消息区看到来自 HeroService 的消息。 点击“清空”按钮,消息区不见了。

查看最终代码

  1. Path:"src/app/hero.service.ts"

    import { Injectable } from '@angular/core';    import { Observable, of } from 'rxjs';    import { Hero } from './hero';    import { HEROES } from './mock-heroes';    import { MessageService } from './message.service';    @Injectable({      providedIn: 'root',    })    export class HeroService {      constructor(private messageService: MessageService) { }      getHeroes(): Observable<Hero[]> {        // TODO: send the message _after_ fetching the heroes        this.messageService.add('HeroService: fetched heroes');        return of(HEROES);      }    }

  1. Path:"src/app/message.service.ts"

    import { Injectable } from '@angular/core';    @Injectable({      providedIn: 'root',    })    export class MessageService {      messages: string[] = [];      add(message: string) {        this.messages.push(message);      }      clear() {        this.messages = [];      }    }

  1. Path:"src/app/heroes/heroes.component.ts"

    import { Component, OnInit } from '@angular/core';    import { Hero } from '../hero';    import { HeroService } from '../hero.service';    import { MessageService } from '../message.service';    @Component({      selector: 'app-heroes',      templateUrl: './heroes.component.html',      styleUrls: ['./heroes.component.css']    })    export class HeroesComponent implements OnInit {      selectedHero: Hero;      heroes: Hero[];      constructor(private heroService: HeroService, private messageService: MessageService) { }      ngOnInit() {        this.getHeroes();      }      onSelect(hero: Hero): void {        this.selectedHero = hero;        this.messageService.add(`HeroesComponent: Selected hero id=${hero.id}`);      }      getHeroes(): void {        this.heroService.getHeroes()            .subscribe(heroes => this.heroes = heroes);      }    }

  1. Path:"src/app/messages/messages.component.ts"

    import { Component, OnInit } from '@angular/core';    import { MessageService } from '../message.service';    @Component({      selector: 'app-messages',      templateUrl: './messages.component.html',      styleUrls: ['./messages.component.css']    })    export class MessagesComponent implements OnInit {      constructor(public messageService: MessageService) {}      ngOnInit() {      }    }

  1. Path:"src/app/messages/messages.component.html"

    <div *ngIf="messageService.messages.length">      <h2>Messages</h2>      <button class="clear"              (click)="messageService.clear()">clear</button>      <div *ngFor='let message of messageService.messages'> {{message}} </div>    </div>

  1. Path:"src/app/messages/messages.component.css"

    /* MessagesComponent's private CSS styles */    h2 {      color: red;      font-family: Arial, Helvetica, sans-serif;      font-weight: lighter;    }    body {      margin: 2em;    }    body, input[text], button {      color: crimson;      font-family: Cambria, Georgia;    }    button.clear {      font-family: Arial;      background-color: #eee;      border: none;      padding: 5px 10px;      border-radius: 4px;      cursor: pointer;      cursor: hand;    }    button:hover {      background-color: #cfd8dc;    }    button:disabled {      background-color: #eee;      color: #aaa;      cursor: auto;    }    button.clear {      color: #333;      margin-bottom: 12px;    }

  1. Path:"src/app/app.module.ts"

    import { BrowserModule } from '@angular/platform-browser';    import { NgModule } from '@angular/core';    import { FormsModule } from '@angular/forms';    import { AppComponent } from './app.component';    import { HeroesComponent } from './heroes/heroes.component';    import { HeroDetailComponent } from './hero-detail/hero-detail.component';    import { MessagesComponent } from './messages/messages.component';    @NgModule({      declarations: [        AppComponent,        HeroesComponent,        HeroDetailComponent,        MessagesComponent      ],      imports: [        BrowserModule,        FormsModule      ],      providers: [        // no need to place any providers due to the `providedIn` flag...      ],      bootstrap: [ AppComponent ]    })    export class AppModule { }

  1. Path:"src/app/app.component.html"

    <h1>{{title}}</h1>    <app-heroes></app-heroes>    <app-messages></app-messages>

假想

假设有一些“英雄指南”的新需求:

  • 添加一个仪表盘视图。

  • 添加在英雄列表和仪表盘视图之间导航的能力。

  • 无论在哪个视图中点击一个英雄,都会导航到该英雄的详情页。

  • 在邮件中点击一个深链接,会直接打开一个特定英雄的详情视图。

完成效果:

添加 AppRoutingModule

在 Angular 中,最好在一个独立的顶层模块中加载和配置路由器,它专注于路由功能,然后由根模块 AppModule 导入它。

按照惯例,这个模块类的名字叫做 AppRoutingModule,并且位于 "src/app" 下的 "app-routing.module.ts" 文件中。

使用 CLI 生成它。

ng generate module app-routing --flat --module=app

注:
- --flat 把这个文件放进了 src/app 中,而不是单独的目录中。

  • --module=app 告诉 CLI 把它注册到 AppModule 的 imports 数组中。

生成文件是这样的:

Path:"src/app/app-routing.module.ts (generated)"

import { NgModule } from '@angular/core';import { CommonModule } from '@angular/common';@NgModule({  imports: [    CommonModule  ],  declarations: []})export class AppRoutingModule { }

把它替换如下:

Path:"src/app/app-routing.module.ts (updated)"

import { NgModule } from '@angular/core';import { RouterModule, Routes } from '@angular/router';import { HeroesComponent } from './heroes/heroes.component';const routes: Routes = [  { path: 'heroes', component: HeroesComponent }];@NgModule({  imports: [RouterModule.forRoot(routes)],  exports: [RouterModule]})export class AppRoutingModule { }

首先,AppRoutingModule 会导入 RouterModule 和 Routes,以便该应用具有路由功能。配置好路由后,接着导入 HeroesComponent,它将告诉路由器要去什么地方。

注意,对 CommonModule 的引用和 declarations 数组不是必要的,因此它们不再是 AppRoutingModule 的一部分。以下各节将详细介绍 AppRoutingModule 的其余部分。

路由

该文件的下一部分是你的路由配置。 Routes 告诉路由器,当用户单击链接或将 URL 粘贴进浏览器地址栏时要显示哪个视图。

由于 AppRoutingModule 已经导入了 HeroesComponent,因此你可以直接在 routes 数组中使用它:

Path:"src/app/app-routing.module.ts"

const routes: Routes = [  { path: 'heroes', component: HeroesComponent }];

典型的 Angular Route 具有两个属性:

path: 用来匹配浏览器地址栏中 URL 的字符串。

component: 导航到该路由时,路由器应该创建的组件。

这会告诉路由器把该 URL 与 path:'heroes' 匹配。 如果网址类似于 "localhost:4200/heroes" 就显示 HeroesComponent。

RouterModule.forRoot()

@NgModule 元数据会初始化路由器,并开始监听浏览器地址的变化。

下面的代码行将 RouterModule 添加到 AppRoutingModule 的 imports 数组中,同时通过调用 RouterModule.forRoot() 来用这些 routes 配置它:

Path:"src/app/app-routing.module.ts"

imports: [ RouterModule.forRoot(routes) ],

注:
- 这个方法之所以叫 forRoot(),是因为你要在应用的顶层配置这个路由器。 forRoot() 方法会提供路由所需的服务提供者和指令,还会基于浏览器的当前 URL 执行首次导航。

接下来,AppRoutingModule 导出 RouterModule,以便它在整个应用程序中生效。

Path:"src/app/app-routing.module.ts (exports array)"

exports: [ RouterModule ]

添加路由出口 RouterOutlet

打开 AppComponent 的模板,把 <app-heroes> 元素替换为 <router-outlet> 元素。

Path:"src/app/app.component.html (router-outlet)"

<h1>{{title}}</h1><router-outlet></router-outlet><app-messages></app-messages>

AppComponent 的模板不再需要 <app-heroes>,因为只有当用户导航到这里时,才需要显示 HeroesComponent。

<router-outlet> 会告诉路由器要在哪里显示路由的视图。

注:
- 能在 AppComponent 中使用 RouterOutlet,是因为 AppModule 导入了 AppRoutingModule,而 AppRoutingModule 中导出了 RouterModule。 在本教程开始时你运行的那个 ng generate 命令添加了这个导入,是因为 --module=app 标志。如果你手动创建 app-routing.module.ts 或使用了 CLI 之外的工具,你就要把 AppRoutingModule 导入到 app.module.ts 中,并且把它添加到 NgModule 的 imports 数组中。

此时浏览器应该刷新,并显示应用标题,但是没有显示英雄列表。看看浏览器的地址栏。 URL 是以 / 结尾的。 而到 HeroesComponent 的路由路径是 /heroes

在地址栏中把 /heroes 追加到 URL 后面。你应该能看到熟悉的主从结构的英雄显示界面。

添加路由链接 (routerLink)

理想情况下,用户应该能通过点击链接进行导航,而不用被迫把路由的 URL 粘贴到地址栏。

添加一个 <nav> 元素,并在其中放一个链接 <a> 元素,当点击它时,就会触发一个到 HeroesComponent 的导航。 修改过的 AppComponent 模板如下:

Path:"src/app/app.component.html (heroes RouterLink)"

<h1>{{title}}</h1><nav>  <a routerLink="/heroes">Heroes</a></nav><router-outlet></router-outlet><app-messages></app-messages>

routerLink 属性的值为 "/heroes",路由器会用它来匹配出指向 HeroesComponent 的路由。 routerLinkRouterLink 指令的选择器,它会把用户的点击转换为路由器的导航操作。 它是 RouterModule 中的另一个公共指令。

刷新浏览器,显示出了应用的标题和指向英雄列表的链接,但并没有显示英雄列表。

点击这个链接。地址栏变成了 /heroes,并且显示出了英雄列表。

注:
- 从下面的 最终代码中把私有 CSS 样式添加到 app.component.css 中,可以让导航链接变得更好看一点。

添加仪表盘视图

当有多个视图时,路由会更有价值。不过目前还只有一个英雄列表视图。

使用 CLI 添加一个 DashboardComponent:

ng generate component dashboard

CLI 生成了 DashboardComponent 的相关文件,并把它声明到 AppModule 中。

把这三个文件中的内容改成这样:

  1. Path:"src/app/dashboard/dashboard.component.html"

<h3>Top Heroes</h3><div class="grid grid-pad">  <a *ngFor="let hero of heroes" class="col-1-4">    <div class="module hero">      <h4>{{hero.name}}</h4>    </div>  </a></div>

  1. Path:"src/app/dashboard/dashboard.component.ts"

import { Component, OnInit } from '@angular/core';import { Hero } from '../hero';import { HeroService } from '../hero.service';@Component({  selector: 'app-dashboard',  templateUrl: './dashboard.component.html',  styleUrls: [ './dashboard.component.css' ]})export class DashboardComponent implements OnInit {  heroes: Hero[] = [];  constructor(private heroService: HeroService) { }  ngOnInit() {    this.getHeroes();  }  getHeroes(): void {    this.heroService.getHeroes()      .subscribe(heroes => this.heroes = heroes.slice(1, 5));  }}

  1. Path:"src/app/dashboard/dashboard.component.css"

/* DashboardComponent's private CSS styles */[class*='col-'] {  float: left;  padding-right: 20px;  padding-bottom: 20px;}[class*='col-']:last-of-type {  padding-right: 0;}a {  text-decoration: none;}*, *:after, *:before {  -webkit-box-sizing: border-box;  -moz-box-sizing: border-box;  box-sizing: border-box;}h3 {  text-align: center;  margin-bottom: 0;}h4 {  position: relative;}.grid {  margin: 0;}.col-1-4 {  width: 25%;}.module {  padding: 20px;  text-align: center;  color: #eee;  max-height: 120px;  min-width: 120px;  background-color: #3f525c;  border-radius: 2px;}.module:hover {  background-color: #eee;  cursor: pointer;  color: #607d8b;}.grid-pad {  padding: 10px 0;}.grid-pad > [class*='col-']:last-of-type {  padding-right: 20px;}@media (max-width: 600px) {  .module {    font-size: 10px;    max-height: 75px; }}@media (max-width: 1024px) {  .grid {    margin: 0;  }  .module {    min-width: 60px;  }}

这个模板用来表示由英雄名字链接组成的一个阵列。

*ngFor 复写器为组件的 heroes 数组中的每个条目创建了一个链接。

这些链接被 dashboard.component.css 中的样式格式化成了一些色块。

这些链接还没有指向任何地方,但很快就会了。

这个类和 HeroesComponent 类很像。

它定义了一个 heroes 数组属性。

它的构造函数希望 Angular 把 HeroService 注入到私有的 heroService 属性中。

ngOnInit() 生命周期钩子中调用 getHeroes()

这个 getHeroes() 函数会截取第 2 到 第 5 位英雄,也就是说只返回四个顶层英雄(第二,第三,第四和第五)。

Path:"src/app/dashboard/dashboard.component.ts"

getHeroes(): void {  this.heroService.getHeroes()    .subscribe(heroes => this.heroes = heroes.slice(1, 5));}

添加仪表盘路由

要导航到仪表盘,路由器中就需要一个相应的路由。

把 DashboardComponent 导入到 AppRoutingModule 中。

Path:"src/app/app-routing.module.ts (import DashboardComponent)"

import { DashboardComponent }   from './dashboard/dashboard.component';

把一个指向 DashboardComponent 的路由添加到 AppRoutingModule.routes 数组中。

Path:"src/app/app-routing.module.ts"

{ path: 'dashboard', component: DashboardComponent },

添加默认路由

当应用启动时,浏览器的地址栏指向了网站的根路径。 它没有匹配到任何现存路由,因此路由器也不会导航到任何地方。 <router-outlet> 下方是空白的。

要让应用自动导航到这个仪表盘,请把下列路由添加到 AppRoutingModule.Routes 数组中。

Path:"src/app/app-routing.module.ts"

{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },

这个路由会把一个与空路径“完全匹配”的 URL 重定向到路径为 '/dashboard' 的路由。

浏览器刷新之后,路由器加载了 DashboardComponent,并且浏览器的地址栏会显示出 /dashboard 这个 URL。

把仪表盘链接添加到壳组件中

应该允许用户通过点击页面顶部导航区的各个链接在 DashboardComponent 和 HeroesComponent 之间来回导航。

把仪表盘的导航链接添加到壳组件 AppComponent 的模板中,就放在 Heroes 链接的前面。

Path:"src/app/app.component.html"

<h1>{{title}}</h1><nav>  <a routerLink="/dashboard">Dashboard</a>  <a routerLink="/heroes">Heroes</a></nav><router-outlet></router-outlet><app-messages></app-messages>

刷新浏览器,你就能通过点击这些链接在这两个视图之间自由导航了。

导航到英雄详情

HeroDetailComponent 可以显示所选英雄的详情。 此刻,HeroDetailsComponent 只能在 HeroesComponent 的底部看到。

用户应该能通过三种途径看到这些详情。

通过在仪表盘中点击某个英雄。

通过在英雄列表中点击某个英雄。

通过把一个“深链接” URL 粘贴到浏览器的地址栏中来指定要显示的英雄。

在这一节,你将能导航到 HeroDetailComponent,并把它从 HeroesComponent 中解放出来。

从 HeroesComponent 中删除英雄详情

当用户在 HeroesComponent 中点击某个英雄条目时,应用应该能导航到 HeroDetailComponent,从英雄列表视图切换到英雄详情视图。 英雄列表视图将不再显示,而英雄详情视图要显示出来。

打开 HeroesComponent 的模板文件(heroes/heroes.component.html),并从底部删除 <app-hero-detail> 元素。

目前,点击某个英雄条目还没有反应。不过当你启用了到 HeroDetailComponent 的路由之后,很快就能修复它。

添加英雄详情视图

要导航到 id 为 11 的英雄的详情视图,类似于 ~/detail/11 的 URL 将是一个不错的 URL。

打开 AppRoutingModule 并导入 HeroDetailComponent

Path:"src/app/app-routing.module.ts (import HeroDetailComponent)"

import { HeroDetailComponent }  from './hero-detail/hero-detail.component';

然后把一个参数化路由添加到 AppRoutingModule.routes 数组中,它要匹配指向英雄详情视图的路径。

Path:"src/app/app-routing.module.ts"

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

path 中的冒号(:)表示 :id 是一个占位符,它表示某个特定英雄的 id

此刻,应用中的所有路由都就绪了。

Path:"src/app/app-routing.module.ts (all routes)"

const routes: Routes = [  { path: '', redirectTo: '/dashboard', pathMatch: 'full' },  { path: 'dashboard', component: DashboardComponent },  { path: 'detail/:id', component: HeroDetailComponent },  { path: 'heroes', component: HeroesComponent }];

DashboardComponent 中的英雄链接

此刻,DashboardComponent 中的英雄连接还没有反应。

路由器已经有一个指向 HeroDetailComponent 的路由了, 修改仪表盘中的英雄连接,让它们通过参数化的英雄详情路由进行导航。

Path:"src/app/dashboard/dashboard.component.html (hero links))"

<a *ngFor="let hero of heroes" class="col-1-4"    routerLink="/detail/{{hero.id}}">  <div class="module hero">    <h4>{{hero.name}}</h4>  </div></a>

你正在 *ngFor 复写器中使用 Angular 的插值绑定来把当前迭代的 hero.id 插入到每个 routerLink 中。

HeroesComponent 中的英雄链接

HeroesComponent 中的这些英雄条目都是 <li> 元素,它们的点击事件都绑定到了组件的 onSelect() 方法中。

Path:"src/app/heroes/heroes.component.html (list with onSelect)"

<ul class="heroes">  <li *ngFor="let hero of heroes"    [class.selected]="hero === selectedHero"    (click)="onSelect(hero)">    <span class="badge">{{hero.id}}</span> {{hero.name}}  </li></ul>

清理 <li>,只保留它的 *ngFor,把徽章(<badge>)和名字包裹进一个 <a> 元素中, 并且像仪表盘的模板中那样为这个 <a> 元素添加一个 routerLink 属性。

Path:"src/app/heroes/heroes.component.html (list with links)"

<ul class="heroes">  <li *ngFor="let hero of heroes">    <a routerLink="/detail/{{hero.id}}">      <span class="badge">{{hero.id}}</span> {{hero.name}}    </a>  </li></ul>

你还要修改私有样式表(heroes.component.css),让列表恢复到以前的外观。 修改后的样式表参见本指南底部的最终代码。

移除死代码(可选)

虽然 HeroesComponent 类仍然能正常工作,但 onSelect() 方法和 selectedHero 属性已经没用了。

最好清理掉它们,将来你会体会到这么做的好处。 下面是删除了死代码之后的类。

Path:"src/app/heroes/heroes.component.ts (cleaned up)"

export class HeroesComponent implements OnInit {  heroes: Hero[];  constructor(private heroService: HeroService) { }  ngOnInit() {    this.getHeroes();  }  getHeroes(): void {    this.heroService.getHeroes()    .subscribe(heroes => this.heroes = heroes);  }}

支持路由的 HeroDetailComponent

以前,父组件 HeroesComponent 会设置 HeroDetailComponent.hero 属性,然后 HeroDetailComponent 就会显示这个英雄。

HeroesComponent 已经不会再那么做了。 现在,当路由器会在响应形如 ~/detail/11 的 URL 时创建 HeroDetailComponent。

HeroDetailComponent 需要从一种新的途径获取要显示的英雄。 本节会讲解如下操作:

  • 获取创建本组件的路由。

  • 从这个路由中提取出 id

  • 通过 HeroService 从服务器上获取具有这个 id 的英雄数据。

先添加下列导入语句:

Path:"src/app/hero-detail/hero-detail.component.ts"

import { ActivatedRoute } from '@angular/router';import { Location } from '@angular/common';import { HeroService }  from '../hero.service';

然后把 ActivatedRoute、HeroService 和 Location 服务注入到构造函数中,将它们的值保存到私有变量里:

Path:"src/app/hero-detail/hero-detail.component.ts"

constructor(  private route: ActivatedRoute,  private heroService: HeroService,  private location: Location) {}

ActivatedRoute 保存着到这个 HeroDetailComponent 实例的路由信息。 这个组件对从 URL 中提取的路由参数感兴趣。 其中的 id 参数就是要显示的英雄的 id

HeroService 从远端服务器获取英雄数据,本组件将使用它来获取要显示的英雄。

location 是一个 Angular 的服务,用来与浏览器打交道。 稍后,你就会使用它来导航回上一个视图。

从路由参数中提取 id

ngOnInit() 生命周期钩子 中调用 getHero(),代码如下:

Path:"src/app/hero-detail/hero-detail.component.ts"

ngOnInit(): void {  this.getHero();}getHero(): void {  const id = +this.route.snapshot.paramMap.get('id');  this.heroService.getHero(id)    .subscribe(hero => this.hero = hero);}

route.snapshot 是一个路由信息的静态快照,抓取自组件刚刚创建完毕之后。

paramMap 是一个从 URL 中提取的路由参数值的字典。 "id" 对应的值就是要获取的英雄的 id。

路由参数总会是字符串。 JavaScript 的 (+) 操作符会把字符串转换成数字,英雄的 id 就是数字类型。

刷新浏览器,应用挂了。出现一个编译错误,因为 HeroService 没有一个名叫 getHero() 的方法。 这就添加它。

添加 HeroService.getHero()

添加 HeroService,并在 getHeroes() 后面添加如下的 getHero() 方法,它接收 id 参数:

Path:"src/app/hero.service.ts (getHero)"

getHero(id: number): Observable<Hero> {  // TODO: send the message _after_ fetching the hero  this.messageService.add(`HeroService: fetched hero id=${id}`);  return of(HEROES.find(hero => hero.id === id));}

注:
- 反引号 ( ` ) 用于定义 JavaScript 的 模板字符串字面量,以便嵌入 id。

getHeroes() 一样,getHero() 也有一个异步函数签名。 它用 RxJS 的 of() 函数返回一个 Observable 形式的模拟英雄数据。

你将来可以用一个真实的 Http 请求来重新实现 getHero(),而不用修改调用了它的 HeroDetailComponent。

此时刷新浏览器,应用再次恢复如常。你可以在仪表盘或英雄列表中点击一个英雄来导航到该英雄的详情视图。

如果你在浏览器的地址栏中粘贴了 "localhost:4200/detail/11",路由器也会导航到 id: 11 的英雄("Dr. Nice")的详情视图。

回到原路

通过点击浏览器的后退按钮,你可以回到英雄列表或仪表盘视图,这取决于你从哪里进入的详情视图。

如果能在 HeroDetail 视图中也有这么一个按钮就更好了。

把一个后退按钮添加到组件模板的底部,并且把它绑定到组件的 goBack() 方法。

Path:"src/app/hero-detail/hero-detail.component.html (back button)"

<button (click)="goBack()">go back</button>

在组件类中添加一个 goBack() 方法,利用你以前注入的 Location 服务在浏览器的历史栈中后退一步。

Path:"src/app/hero-detail/hero-detail.component.ts (goBack)"

goBack(): void {  this.location.back();}

刷新浏览器,并开始点击。 用户能在应用中导航:从仪表盘到英雄详情再回来,从英雄列表到 mini 版英雄详情到英雄详情,再回到英雄列表。

查看最终代码

AppRoutingModule

Path:"src/app/app-routing.module.ts"

    import { NgModule }             from '@angular/core';    import { RouterModule, Routes } from '@angular/router';    import { DashboardComponent }   from './dashboard/dashboard.component';    import { HeroesComponent }      from './heroes/heroes.component';    import { HeroDetailComponent }  from './hero-detail/hero-detail.component';    const routes: Routes = [      { path: '', redirectTo: '/dashboard', pathMatch: 'full' },      { path: 'dashboard', component: DashboardComponent },      { path: 'detail/:id', component: HeroDetailComponent },      { path: 'heroes', component: HeroesComponent }    ];    @NgModule({      imports: [ RouterModule.forRoot(routes) ],      exports: [ RouterModule ]    })    export class AppRoutingModule {}

AppModule

Path:"src/app/app.module.ts"

    import { NgModule }       from '@angular/core';    import { BrowserModule }  from '@angular/platform-browser';    import { FormsModule }    from '@angular/forms';    import { AppComponent }         from './app.component';    import { DashboardComponent }   from './dashboard/dashboard.component';    import { HeroDetailComponent }  from './hero-detail/hero-detail.component';    import { HeroesComponent }      from './heroes/heroes.component';    import { MessagesComponent }    from './messages/messages.component';    import { AppRoutingModule }     from './app-routing.module';    @NgModule({      imports: [        BrowserModule,        FormsModule,        AppRoutingModule      ],      declarations: [        AppComponent,        DashboardComponent,        HeroesComponent,        HeroDetailComponent,        MessagesComponent      ],      bootstrap: [ AppComponent ]    })    export class AppModule { }

HeroService

Path:"src/app/hero.service.ts"

    import { Injectable } from '@angular/core';    import { Observable, of } from 'rxjs';    import { Hero } from './hero';    import { HEROES } from './mock-heroes';    import { MessageService } from './message.service';    @Injectable({ providedIn: 'root' })    export class HeroService {      constructor(private messageService: MessageService) { }      getHeroes(): Observable<Hero[]> {        // TODO: send the message _after_ fetching the heroes        this.messageService.add('HeroService: fetched heroes');        return of(HEROES);      }      getHero(id: number): Observable<Hero> {        // TODO: send the message _after_ fetching the hero        this.messageService.add(`HeroService: fetched hero id=${id}`);        return of(HEROES.find(hero => hero.id === id));      }    }

AppComponent

  • Path:"src/app/app.component.html"
    <h1>{{title}}</h1><nav><a routerLink="/dashboard">Dashboard</a><a routerLink="/heroes">Heroes</a></nav><router-outlet></router-outlet><app-messages></app-messages>

  • Path:"src/app/app.component.css"
    /* AppComponent's private CSS styles */h1 {  font-size: 1.2em;  margin-bottom: 0;}h2 {  font-size: 2em;  margin-top: 0;  padding-top: 0;}nav a {  padding: 5px 10px;  text-decoration: none;  margin-top: 10px;  display: inline-block;  background-color: #eee;  border-radius: 4px;}nav a:visited, a:link {  color: #334953;}nav a:hover {  color: #039be5;  background-color: #cfd8dc;}nav a.active {  color: #039be5;}

DashboardComponent

  • Path:"src/app/dashboard/dashboard.component.html"
    <h3>Top Heroes</h3><div class="grid grid-pad">  <a *ngFor="let hero of heroes" class="col-1-4"      routerLink="/detail/{{hero.id}}">    <div class="module hero">      <h4>{{hero.name}}</h4>    </div>  </a></div>

  • Path:"src/app/dashboard/dashboard.component.css"

    import { Component, OnInit } from '@angular/core';    import { Hero } from '../hero';    import { HeroService } from '../hero.service';    @Component({      selector: 'app-dashboard',      templateUrl: './dashboard.component.html',      styleUrls: [ './dashboard.component.css' ]    })    export class DashboardComponent implements OnInit {      heroes: Hero[] = [];      constructor(private heroService: HeroService) { }      ngOnInit() {        this.getHeroes();      }      getHeroes(): void {        this.heroService.getHeroes()          .subscribe(heroes => this.heroes = heroes.slice(1, 5));      }    }

  • Path:"src/app/dashboard/dashboard.component.css"

    /* DashboardComponent's private CSS styles */    [class*='col-'] {      float: left;      padding-right: 20px;      padding-bottom: 20px;    }    [class*='col-']:last-of-type {      padding-right: 0;    }    a {      text-decoration: none;    }    *, *:after, *:before {      -webkit-box-sizing: border-box;      -moz-box-sizing: border-box;      box-sizing: border-box;    }    h3 {      text-align: center;      margin-bottom: 0;    }    h4 {      position: relative;    }    .grid {      margin: 0;    }    .col-1-4 {      width: 25%;    }    .module {      padding: 20px;      text-align: center;      color: #eee;      max-height: 120px;      min-width: 120px;      background-color: #3f525c;      border-radius: 2px;    }    .module:hover {      background-color: #eee;      cursor: pointer;      color: #607d8b;    }    .grid-pad {      padding: 10px 0;    }    .grid-pad > [class*='col-']:last-of-type {      padding-right: 20px;    }    @media (max-width: 600px) {      .module {        font-size: 10px;        max-height: 75px; }    }    @media (max-width: 1024px) {      .grid {        margin: 0;      }      .module {        min-width: 60px;      }    }

HeroesComponent

  • Path:"src/app/heroes/heroes.component.html"

    <h2>My Heroes</h2>    <ul class="heroes">      <li *ngFor="let hero of heroes">        <a routerLink="/detail/{{hero.id}}">          <span class="badge">{{hero.id}}</span> {{hero.name}}        </a>      </li>    </ul>

  • Path:"src/app/heroes/heroes.component.ts"

    import { Component, OnInit } from '@angular/core';    import { Hero } from '../hero';    import { HeroService } from '../hero.service';    @Component({      selector: 'app-heroes',      templateUrl: './heroes.component.html',      styleUrls: ['./heroes.component.css']    })    export class HeroesComponent implements OnInit {      heroes: Hero[];      constructor(private heroService: HeroService) { }      ngOnInit() {        this.getHeroes();      }      getHeroes(): void {        this.heroService.getHeroes()        .subscribe(heroes => this.heroes = heroes);      }    }

  • Path:"src/app/heroes/heroes.component.css"

    /* DashboardComponent's private CSS styles */    [class*='col-'] {      float: left;      padding-right: 20px;      padding-bottom: 20px;    }    [class*='col-']:last-of-type {      padding-right: 0;    }    a {      text-decoration: none;    }    *, *:after, *:before {      -webkit-box-sizing: border-box;      -moz-box-sizing: border-box;      box-sizing: border-box;    }    h3 {      text-align: center;      margin-bottom: 0;    }    h4 {      position: relative;    }    .grid {      margin: 0;    }    .col-1-4 {      width: 25%;    }    .module {      padding: 20px;      text-align: center;      color: #eee;      max-height: 120px;      min-width: 120px;      background-color: #3f525c;      border-radius: 2px;    }    .module:hover {      background-color: #eee;      cursor: pointer;      color: #607d8b;    }    .grid-pad {      padding: 10px 0;    }    .grid-pad > [class*='col-']:last-of-type {      padding-right: 20px;    }    @media (max-width: 600px) {      .module {        font-size: 10px;        max-height: 75px; }    }    @media (max-width: 1024px) {      .grid {        margin: 0;      }      .module {        min-width: 60px;      }    }

HeroesComponent

  • Path:"src/app/heroes/heroes.component.html"

<h2>My Heroes</h2><ul class="heroes">  <li *ngFor="let hero of heroes">    <a routerLink="/detail/{{hero.id}}">      <span class="badge">{{hero.id}}</span> {{hero.name}}    </a>  </li></ul>

  • Path:"ssrc/app/heroes/heroes.component.ts"

import { Component, OnInit } from '@angular/core';import { Hero } from '../hero';import { HeroService } from '../hero.service';@Component({  selector: 'app-heroes',  templateUrl: './heroes.component.html',  styleUrls: ['./heroes.component.css']})export class HeroesComponent implements OnInit {  heroes: Hero[];  constructor(private heroService: HeroService) { }  ngOnInit() {    this.getHeroes();  }  getHeroes(): void {    this.heroService.getHeroes()    .subscribe(heroes => this.heroes = heroes);  }}

  • Path:"src/app/heroes/heroes.component.css"

/* HeroesComponent's private CSS styles */.heroes {  margin: 0 0 2em 0;  list-style-type: none;  padding: 0;  width: 15em;}.heroes li {  position: relative;  cursor: pointer;  background-color: #EEE;  margin: .5em;  padding: .3em 0;  height: 1.6em;  border-radius: 4px;}.heroes li:hover {  color: #607D8B;  background-color: #DDD;  left: .1em;}.heroes a {  color: #333;  text-decoration: none;  position: relative;  display: block;  width: 250px;}.heroes a:hover {  color:#607D8B;}.heroes .badge {  display: inline-block;  font-size: small;  color: white;  padding: 0.8em 0.7em 0 0.7em;  background-color:#405061;  line-height: 1em;  position: relative;  left: -1px;  top: -4px;  height: 1.8em;  min-width: 16px;  text-align: right;  margin-right: .8em;  border-radius: 4px 0 0 4px;}

HeroDetailComponent

  • Path:"src/app/hero-detail/hero-detail.component.html"

<div *ngIf="hero">  <h2>{{hero.name | uppercase}} Details</h2>  <div><span>id: </span>{{hero.id}}</div>  <div>    <label>name:      <input [(ngModel)]="hero.name" placeholder="name"/>    </label>  </div>  <button (click)="goBack()">go back</button></div>

  • Path:"src/app/hero-detail/hero-detail.component.ts"

import { Component, OnInit } from '@angular/core';import { ActivatedRoute } from '@angular/router';import { Location } from '@angular/common';import { Hero }         from '../hero';import { HeroService }  from '../hero.service';@Component({  selector: 'app-hero-detail',  templateUrl: './hero-detail.component.html',  styleUrls: [ './hero-detail.component.css' ]})export class HeroDetailComponent implements OnInit {  hero: Hero;  constructor(    private route: ActivatedRoute,    private heroService: HeroService,    private location: Location  ) {}  ngOnInit(): void {    this.getHero();  }  getHero(): void {    const id = +this.route.snapshot.paramMap.get('id');    this.heroService.getHero(id)      .subscribe(hero => this.hero = hero);  }  goBack(): void {    this.location.back();  }}

  • Path:"src/app/hero-detail/hero-detail.component.css"

/* DashboardComponent's private CSS styles */[class*='col-'] {  float: left;  padding-right: 20px;  padding-bottom: 20px;}[class*='col-']:last-of-type {  padding-right: 0;}a {  text-decoration: none;}*, *:after, *:before {  -webkit-box-sizing: border-box;  -moz-box-sizing: border-box;  box-sizing: border-box;}h3 {  text-align: center;  margin-bottom: 0;}h4 {  position: relative;}.grid {  margin: 0;}.col-1-4 {  width: 25%;}.module {  padding: 20px;  text-align: center;  color: #eee;  max-height: 120px;  min-width: 120px;  background-color: #3f525c;  border-radius: 2px;}.module:hover {  background-color: #eee;  cursor: pointer;  color: #607d8b;}.grid-pad {  padding: 10px 0;}.grid-pad > [class*='col-']:last-of-type {  padding-right: 20px;}@media (max-width: 600px) {  .module {    font-size: 10px;    max-height: 75px; }}@media (max-width: 1024px) {  .grid {    margin: 0;  }  .module {    min-width: 60px;  }}HeroesComponentsrc/app/heroes/heroes.component.htmlsrc/app/heroes/heroes.component.tssrc/app/heroes/heroes.component.csscontent_copy/* HeroesComponent's private CSS styles */.heroes {  margin: 0 0 2em 0;  list-style-type: none;  padding: 0;  width: 15em;}.heroes li {  position: relative;  cursor: pointer;  background-color: #EEE;  margin: .5em;  padding: .3em 0;  height: 1.6em;  border-radius: 4px;}.heroes li:hover {  color: #607D8B;  background-color: #DDD;  left: .1em;}.heroes a {  color: #333;  text-decoration: none;  position: relative;  display: block;  width: 250px;}.heroes a:hover {  color:#607D8B;}.heroes .badge {  display: inline-block;  font-size: small;  color: white;  padding: 0.8em 0.7em 0 0.7em;  background-color:#405061;  line-height: 1em;  position: relative;  left: -1px;  top: -4px;  height: 1.8em;  min-width: 16px;  text-align: right;  margin-right: .8em;  border-radius: 4px 0 0 4px;}

总结

  • 添加了 Angular 路由器在各个不同组件之间导航。

  • 您使用一些 <a> 链接和一个 <router-outlet> 把 AppComponent 转换成了一个导航用的壳组件。

  • 您在 AppRoutingModule 中配置了路由器。

  • 您定义了一些简单路由、一个重定向路由和一个参数化路由。

  • 您在 <a> 元素中使用了 routerLink 指令。

  • 您把一个紧耦合的主从视图重构成了带路由的详情视图。

  • 您使用路由链接参数来导航到所选英雄的详情视图。

  • 在多个组件之间共享了 HeroService 服务。

您将借助 Angular 的 HttpClient 来添加一些数据持久化特性。

HeroService 通过 HTTP 请求获取英雄数据。

用户可以添加、编辑和删除英雄,并通过 HTTP 来保存这些更改。

用户可以根据名字搜索英雄。

启用 HTTP 服务

HttpClient 是 Angular 通过 HTTP 与远程服务器通讯的机制。

要让 HttpClient 在应用中随处可用,需要两个步骤。首先,用导入语句把它添加到根模块 AppModule 中:

Path:"src/app/app.module.ts (HttpClientModule import)"

import { HttpClientModule }    from '@angular/common/http';

接下来,仍然在 AppModule 中,把 HttpClientModule 添加到 imports 数组中:

Path:"src/app/app.module.ts (imports array excerpt)"

@NgModule({  imports: [    HttpClientModule,  ],})

模拟数据服务器

这个教学例子会与一个使用 内存 Web API(In-memory Web API) 模拟出的远程数据服务器通讯。

安装完这个模块之后,应用将会通过 HttpClient 来发起请求和接收响应,而不用在乎实际上是这个内存 Web API 在拦截这些请求、操作一个内存数据库,并且给出仿真的响应。

通过使用内存 Web API,你不用架设服务器就可以学习 HttpClient 了。

注:
- 这个内存 Web API 模块与 Angular 中的 HTTP 模块无关。

  • 如果你只是在阅读本教程来学习 HttpClient,那么可以跳过这一步。 如果你正在随着本教程敲代码,那就留下来,并加上这个内存 Web API。

用如下命令从 npm 或 cnpm 中安装这个内存 Web API 包(译注:请使用 0.5+ 的版本,不要使用 0.4-)

npm install angular-in-memory-web-api --save

AppModule 中,导入 HttpClientInMemoryWebApiModuleInMemoryDataService 类,稍后你将创建它们。

Path:"src/app/app.module.ts (In-memory Web API imports)"

import { HttpClientInMemoryWebApiModule } from 'angular-in-memory-web-api';import { InMemoryDataService }  from './in-memory-data.service';

HttpClientModule 之后,将 HttpClientInMemoryWebApiModule 添加到 AppModuleimports 数组中,并以 InMemoryDataService 为参数对其进行配置。

Path:"src/app/app.module.ts (imports array excerpt)"

HttpClientModule,// The HttpClientInMemoryWebApiModule module intercepts HTTP requests// and returns simulated server responses.// Remove it when a real server is ready to receive requests.HttpClientInMemoryWebApiModule.forRoot(  InMemoryDataService, { dataEncapsulation: false })

forRoot() 配置方法接收一个 InMemoryDataService 类来初始化内存数据库。

使用以下命令生成类 "src/app/in-memory-data.service.ts":

ng generate service InMemoryData

将 in-memory-data.service.ts 改为以下内容:

Path:"src/app/in-memory-data.service.ts"

import { Injectable } from '@angular/core';import { InMemoryDbService } from 'angular-in-memory-web-api';import { Hero } from './hero';@Injectable({  providedIn: 'root',})export class InMemoryDataService implements InMemoryDbService {  createDb() {    const heroes = [      { id: 11, name: 'Dr Nice' },      { id: 12, name: 'Narco' },      { id: 13, name: 'Bombasto' },      { id: 14, name: 'Celeritas' },      { id: 15, name: 'Magneta' },      { id: 16, name: 'RubberMan' },      { id: 17, name: 'Dynama' },      { id: 18, name: 'Dr IQ' },      { id: 19, name: 'Magma' },      { id: 20, name: 'Tornado' }    ];    return {heroes};  }  // Overrides the genId method to ensure that a hero always has an id.  // If the heroes array is empty,  // the method below returns the initial number (11).  // if the heroes array is not empty, the method below returns the highest  // hero id + 1.  genId(heroes: Hero[]): number {    return heroes.length > 0 ? Math.max(...heroes.map(hero => hero.id)) + 1 : 11;  }}

"in-memory-data.service.ts" 文件已代替了 "mock-heroes.ts" 文件,现在后者可以安全的删除了。

等服务器就绪后,你就可以抛弃这个内存 Web API,应用的请求将直接传给服务器。

英雄与 HTTP

在 HeroService 中,导入 HttpClientHttpHeaders

Path:"src/app/hero.service.ts (import HTTP symbols)"

import { HttpClient, HttpHeaders } from '@angular/common/http';

仍然在 HeroService 中,把 HttpClient 注入到构造函数中一个名叫 http 的私有属性中。

Path:"src/app/hero.service.ts"

constructor(  private http: HttpClient,  private messageService: MessageService) { }

注意保留对 MessageService 的注入,但是因为您将频繁调用它,因此请把它包裹进一个私有的 log 方法中。

Path:"src/app/hero.service.ts"

/** Log a HeroService message with the MessageService */private log(message: string) {  this.messageService.add(`HeroService: ${message}`);}

把服务器上英雄数据资源的访问地址 heroesURL 定义为 :base/:collectionName 的形式。 这里的 base 是要请求的资源,而 collectionName 是 "in-memory-data-service.ts" 中的英雄数据对象。

Path:"src/app/hero.service.ts"

private heroesUrl = 'api/heroes';  // URL to web api

通过 HttpClient 获取英雄

当前的 HeroService.getHeroes() 使用 RxJS 的 of() 函数来把模拟英雄数据返回为 Observable<Hero[]> 格式。

Path:"src/app/hero.service.ts (getHeroes with RxJs 'of()')"

getHeroes(): Observable<Hero[]> {  return of(HEROES);}

把该方法转换成使用 HttpClient 的,代码如下:

Path:"src/app/hero.service.ts"

/** GET heroes from the server */getHeroes(): Observable<Hero[]> {  return this.http.get<Hero[]>(this.heroesUrl)}

刷新浏览器后,英雄数据就会从模拟服务器被成功读取。

你用 http.get() 替换了 of(),没有做其它修改,但是应用仍然在正常工作,这是因为这两个函数都返回了 Observable<Hero[]>

HttpClient 的方法返回单个值

所有的 HttpClient 方法都会返回某个值的 RxJS Observable。

HTTP 是一个请求/响应式协议。你发起请求,它返回单个的响应。

通常,Observable 可以在一段时间内返回多个值。 但来自 HttpClientObservable 总是发出一个值,然后结束,再也不会发出其它值。

具体到这次 HttpClient.get() 调用,它返回一个 Observable<Hero[]>,也就是“一个英雄数组的可观察对象”。在实践中,它也只会返回一个英雄数组。

HttpClient.get() 返回响应数据

HttpClient.get() 默认情况下把响应体当做无类型的 JSON 对象进行返回。 如果指定了可选的模板类型 <Hero[]>,就会给返回你一个类型化的对象。

服务器的数据 API 决定了 JSON 数据的具体形态。 英雄指南的数据 API 会把英雄数据作为一个数组进行返回。

注:
- 其它 API 可能在返回对象中深埋着你想要的数据。 你可能要借助 RxJS 的 map() 操作符对 Observable 的结果进行处理,以便把这些数据挖掘出来。

  • 虽然不打算在此展开讨论,不过你可以到范例源码中的 getHeroNo404() 方法中找到一个使用 map() 操作符的例子。

错误处理

凡事皆会出错,特别是当你从远端服务器获取数据的时候。 HeroService.getHeroes() 方法应该捕获错误,并做适当的处理。

要捕获错误,你就要使用 RxJS 的 catchError() 操作符来建立对Observable 结果的处理管道(pipe)。

从 rxjs/operators 中导入 catchError 符号,以及你稍后将会用到的其它操作符。

Path:"src/app/hero.service.ts"

import { catchError, map, tap } from 'rxjs/operators';

现在,使用 pipe() 方法来扩展 Observable 的结果,并给它一个 catchError() 操作符。

Path:"src/app/hero.service.ts"

getHeroes(): Observable<Hero[]> {  return this.http.get<Hero[]>(this.heroesUrl)    .pipe(      catchError(this.handleError<Hero[]>('getHeroes', []))    );}

catchError() 操作符会拦截失败的 Observable。 它把错误对象传给错误处理器,错误处理器会处理这个错误。

下面的 handleError() 方法会报告这个错误,并返回一个无害的结果(安全值),以便应用能正常工作。

handleError

下面这个 handleError() 将会在很多 HeroService 的方法之间共享,所以要把它通用化,以支持这些彼此不同的需求。

它不再直接处理这些错误,而是返回给 catchError 返回一个错误处理函数。还要用操作名和出错时要返回的安全值来对这个错误处理函数进行配置。

Path:"src/app/hero.service.ts"

/** * Handle Http operation that failed. * Let the app continue. * @param operation - name of the operation that failed * @param result - optional value to return as the observable result */private handleError<T>(operation = 'operation', result?: T) {  return (error: any): Observable<T> => {    // TODO: send the error to remote logging infrastructure    console.error(error); // log to console instead    // TODO: better job of transforming error for user consumption    this.log(`${operation} failed: ${error.message}`);    // Let the app keep running by returning an empty result.    return of(result as T);  };}

在控制台中汇报了这个错误之后,这个处理器会汇报一个用户友好的消息,并给应用返回一个安全值,让应用继续工作。

因为每个服务方法都会返回不同类型的 Observable 结果,因此 handleError() 也需要一个类型参数,以便它返回一个此类型的安全值,正如应用所期望的那样。

窥探 Observable

HeroService 的方法将会窥探 Observable 的数据流,并通过 log() 方法往页面底部发送一条消息。

它们可以使用 RxJS 的 tap() 操作符来实现,该操作符会查看 Observable 中的值,使用那些值做一些事情,并且把它们传出来。 这种 tap() 回调不会改变这些值本身。

下面是 getHeroes() 的最终版本,它使用 tap() 来记录各种操作。

Path:"src/app/hero.service.ts"

/** GET heroes from the server */getHeroes(): Observable<Hero[]> {  return this.http.get<Hero[]>(this.heroesUrl)    .pipe(      tap(_ => this.log('fetched heroes')),      catchError(this.handleError<Hero[]>('getHeroes', []))    );}

通过 id 获取英雄

大多数的 Web API 都支持以 :baseURL/:id 的形式根据 id 进行获取。

这里的 baseURL 就是在 英雄列表与 HTTP 部分定义过的 heroesURL(api/heroes)。而 id 则是你要获取的英雄的编号,比如,api/heroes/11。 把 HeroService.getHero() 方法改成这样,以发起该请求:

Path:"src/app/hero.service.ts"

/** GET hero by id. Will 404 if id not found */getHero(id: number): Observable<Hero> {  const url = `${this.heroesUrl}/${id}`;  return this.http.get<Hero>(url).pipe(    tap(_ => this.log(`fetched hero id=${id}`)),    catchError(this.handleError<Hero>(`getHero id=${id}`))  );}

这里和 getHeroes() 相比有三个显著的差异:

  • getHero() 使用想获取的英雄的 id 构造了一个请求 URL。

  • 服务器应该使用单个英雄作为回应,而不是一个英雄数组。

  • 所以,getHero() 会返回 Observable<Hero>(“一个可观察的单个英雄对象”),而不是一个可观察的英雄对象数组。

修改英雄

在英雄详情视图中编辑英雄的名字。 随着输入,英雄的名字也跟着在页面顶部的标题区更新了。 但是当你点击“后退”按钮时,这些修改都丢失了。

如果你希望保留这些修改,就要把它们写回到服务器。

在英雄详情模板的底部添加一个保存按钮,它绑定了一个 click 事件,事件绑定会调用组件中一个名叫 save() 的新方法:

Path:"src/app/hero-detail/hero-detail.component.html (save)"

<button (click)="save()">save</button>

在 HeroDetail 组件类中,添加如下的 save() 方法,它使用英雄服务中的 updateHero() 方法来保存对英雄名字的修改,然后导航回前一个视图。

Path:"src/app/hero-detail/hero-detail.component.ts (save)"

save(): void {  this.heroService.updateHero(this.hero)    .subscribe(() => this.goBack());}

添加 HeroService.updateHero()

updateHero() 的总体结构和 getHeroes() 很相似,但它会使用 http.put() 来把修改后的英雄保存到服务器上。 把下列代码添加进HeroService

Path:"src/app/hero.service.ts (update)"

/** PUT: update the hero on the server */updateHero(hero: Hero): Observable<any> {  return this.http.put(this.heroesUrl, hero, this.httpOptions).pipe(    tap(_ => this.log(`updated hero id=${hero.id}`)),    catchError(this.handleError<any>('updateHero'))  );}

HttpClient.put() 方法接受三个参数:

  • URL 地址

  • 要修改的数据(这里就是修改后的英雄)

  • 选项

URL 没变。英雄 Web API 通过英雄对象的 id 就可以知道要修改哪个英雄。

英雄 Web API 期待在保存时的请求中有一个特殊的头。 这个头是在 HeroServicehttpOptions 常量中定义的。

Path:"src/app/hero.service.ts"

httpOptions = {  headers: new HttpHeaders({ 'Content-Type': 'application/json' })};

刷新浏览器,修改英雄名,保存这些修改。在 HeroDetailComponentsave() 方法中导航到前一个视图。 现在,改名后的英雄已经显示在列表中了。

添加新英雄

要添加英雄,本应用中只需要英雄的名字。你可以使用一个和添加按钮成对的 <input> 元素。

把下列代码插入到 HeroesComponent 模板中标题的紧后面:

Path:"src/app/heroes/heroes.component.html (add)"

<div>  <label>Hero name:    <input #heroName />  </label>  <!-- (click) passes input value to add() and then clears the input -->  <button (click)="add(heroName.value); heroName.value=''">    add  </button></div>

当点击事件触发时,调用组件的点击处理器(add()),然后清空这个输入框,以便用来输入另一个名字。把下列代码添加到 HeroesComponent 类:

Path:"src/app/heroes/heroes.component.ts (add)"

add(name: string): void {  name = name.trim();  if (!name) { return; }  this.heroService.addHero({ name } as Hero)    .subscribe(hero => {      this.heroes.push(hero);    });}

当指定的名字非空时,这个处理器会用这个名字创建一个类似于 Hero 的对象(只缺少 id 属性),并把它传给服务的 addHero() 方法。

addHero() 保存成功时,subscribe() 的回调函数会收到这个新英雄,并把它追加到 heroes 列表中以供显示。

HeroService 类中添加 addHero() 方法。

Path:"src/app/hero.service.ts (addHero)"

/** POST: add a new hero to the server */addHero(hero: Hero): Observable<Hero> {  return this.http.post<Hero>(this.heroesUrl, hero, this.httpOptions).pipe(    tap((newHero: Hero) => this.log(`added hero w/ id=${newHero.id}`)),    catchError(this.handleError<Hero>('addHero'))  );}

addHero()updateHero() 有两点不同。

它调用 HttpClient.post() 而不是 put()

它期待服务器为这个新的英雄生成一个 id,然后把它通过 Observable<Hero> 返回给调用者。

刷新浏览器,并添加一些英雄。

删除某个英雄

英雄列表中的每个英雄都有一个删除按钮。

把下列按钮(button)元素添加到 HeroesComponent 的模板中,就在每个 <li>元素中的英雄名字后方。

Path:"src/app/heroes/heroes.component.html"

<button class="delete" title="delete hero"  (click)="delete(hero)">x</button>

英雄列表的 HTML 应该是这样的:

Path:"src/app/heroes/heroes.component.html (list of heroes)"

<ul class="heroes">  <li *ngFor="let hero of heroes">    <a routerLink="/detail/{{hero.id}}">      <span class="badge">{{hero.id}}</span> {{hero.name}}    </a>    <button class="delete" title="delete hero"      (click)="delete(hero)">x</button>  </li></ul>

要把删除按钮定位在每个英雄条目的最右边,就要往 heroes.component.css 中添加一些 CSS。你可以在下方的 最终代码 中找到这些 CSS。

delete() 处理器添加到组件中。

Path:"src/app/heroes/heroes.component.ts (delete)"

delete(hero: Hero): void {  this.heroes = this.heroes.filter(h => h !== hero);  this.heroService.deleteHero(hero).subscribe();}

虽然这个组件把删除英雄的逻辑委托给了 HeroService,但仍保留了更新它自己的英雄列表的职责。 组件的 delete() 方法会在 HeroService 对服务器的操作成功之前,先从列表中移除要删除的英雄。

组件与 heroService.delete() 返回的 Observable 还完全没有关联。必须订阅它。

注:
- 如果你忘了调用 subscribe(),本服务将不会把这个删除请求发送给服务器。 作为一条通用的规则,Observable 在有人订阅之前什么都不会做。

  • 你可以暂时删除 subscribe() 来确认这一点。点击“Dashboard”,然后点击“Heroes”,就又看到完整的英雄列表了。

接下来,把 deleteHero() 方法添加到 HeroService 中,代码如下。

Path:"src/app/hero.service.ts (delete)"

/** DELETE: delete the hero from the server */deleteHero(hero: Hero | number): Observable<Hero> {  const id = typeof hero === 'number' ? hero : hero.id;  const url = `${this.heroesUrl}/${id}`;  return this.http.delete<Hero>(url, this.httpOptions).pipe(    tap(_ => this.log(`deleted hero id=${id}`)),    catchError(this.handleError<Hero>('deleteHero'))  );}

注:
- deleteHero() 调用了 HttpClient.delete()

  • URL 就是英雄的资源 URL 加上要删除的英雄的 id

  • 您不用像 put()post() 中那样发送任何数据。

  • 您仍要发送 httpOptions

根据名字搜索

在最后一次练习中,您要学到把 Observable 的操作符串在一起,让你能将相似 HTTP 请求的数量最小化,并节省网络带宽。

您将往仪表盘中加入英雄搜索特性。 当用户在搜索框中输入名字时,您会不断发送根据名字过滤英雄的 HTTP 请求。 您的目标是仅仅发出尽可能少的必要请求。

HeroService.searchHeroes()

先把 searchHeroes() 方法添加到 HeroService 中。

Path:"src/app/hero.service.ts"

/* GET heroes whose name contains search term */searchHeroes(term: string): Observable<Hero[]> {  if (!term.trim()) {    // if not search term, return empty hero array.    return of([]);  }  return this.http.get<Hero[]>(`${this.heroesUrl}/?name=${term}`).pipe(    tap(x => x.length ?       this.log(`found heroes matching "${term}"`) :       this.log(`no heroes matching "${term}"`)),    catchError(this.handleError<Hero[]>('searchHeroes', []))  );}

如果没有搜索词,该方法立即返回一个空数组。 剩下的部分和 getHeroes() 很像。 唯一的不同点是 URL,它包含了一个由搜索词组成的查询字符串。

为仪表盘添加搜索功能

打开 DashboardComponent 的模板并且把用于搜索英雄的元素 <app-hero-search> 添加到代码的底部。

Path:"src/app/dashboard/dashboard.component.html"

<h3>Top Heroes</h3><div class="grid grid-pad">  <a *ngFor="let hero of heroes" class="col-1-4"      routerLink="/detail/{{hero.id}}">    <div class="module hero">      <h4>{{hero.name}}</h4>    </div>  </a></div>

这个模板看起来很像 HeroesComponent 模板中的 *ngFor 复写器。

为此,下一步就是添加一个组件,它的选择器要能匹配 <app-hero-search>

创建 HeroSearchComponent

使用 CLI 创建一个 HeroSearchComponent。

ng generate component hero-search

CLI 生成了 HeroSearchComponent 的三个文件,并把该组件添加到了 AppModule 的声明中。

把生成的 HeroSearchComponent 的模板改成一个 <input> 和一个匹配到的搜索结果的列表。代码如下:

Path:"src/app/hero-search/hero-search.component.html"

<div id="search-component">  <h4><label for="search-box">Hero Search</label></h4>  <input #searchBox id="search-box" (input)="search(searchBox.value)" />  <ul class="search-result">    <li *ngFor="let hero of heroes$ | async" >      <a routerLink="/detail/{{hero.id}}">        {{hero.name}}      </a>    </li>  </ul></div>

从下面的 最终代码 中把私有 CSS 样式添加到 "hero-search.component.css" 中。

当用户在搜索框中输入时,一个 keyup 事件绑定会调用该组件的 search() 方法,并传入新的搜索框的值。

AsyncPipe

*ngFor 会重复渲染这些英雄对象。注意,*ngFor 在一个名叫 heroes$ 的列表上迭代,而不是 heroes$ 是一个约定,表示 heroes$ 是一个 Observable 而不是数组。

Path:"src/app/hero-search/hero-search.component.html"

<li *ngFor="let hero of heroes$ | async" >

由于 *ngFor 不能直接使用 Observable,所以要使用一个管道字符(|),后面紧跟着一个 async。这表示 Angular 的 AsyncPipe 管道,它会自动订阅 Observable,这样你就不用在组件类中这么做了。

修正 HeroSearchComponent 类

修改所生成的 HeroSearchComponent 类及其元数据,代码如下:

Path:"src/app/hero-search/hero-search.component.ts"

import { Component, OnInit } from '@angular/core';import { Observable, Subject } from 'rxjs';import {   debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';import { Hero } from '../hero';import { HeroService } from '../hero.service';@Component({  selector: 'app-hero-search',  templateUrl: './hero-search.component.html',  styleUrls: [ './hero-search.component.css' ]})export class HeroSearchComponent implements OnInit {  heroes$: Observable<Hero[]>;  private searchTerms = new Subject<string>();  constructor(private heroService: HeroService) {}  // Push a search term into the observable stream.  search(term: string): void {    this.searchTerms.next(term);  }  ngOnInit(): void {    this.heroes$ = this.searchTerms.pipe(      // wait 300ms after each keystroke before considering the term      debounceTime(300),      // ignore new term if same as previous term      distinctUntilChanged(),      // switch to new search observable each time the term changes      switchMap((term: string) => this.heroService.searchHeroes(term)),    );  }}

注意,heroes$ 声明为一个 Observable

Path:"src/app/hero-search/hero-search.component.ts"

heroes$: Observable<Hero[]>;

你将会在 ngOnInit() 中设置它,在此之前,先仔细看看 searchTerms 的定义。

RxJS Subject 类型的 searchTerms

searchTerms 属性是 RxJS 的 Subject 类型。

Path:"src/app/hero-search/hero-search.component.ts"

private searchTerms = new Subject<string>();// Push a search term into the observable stream.search(term: string): void {  this.searchTerms.next(term);}

Subject 既是可观察对象的数据源,本身也是 Observable。 你可以像订阅任何 Observable 一样订阅 Subject

你还可以通过调用它的 next(value) 方法往 Observable 中推送一些值,就像 search() 方法中一样。

文本框的 input 事件的事件绑定会调用 search() 方法。

Path:"src/app/hero-search/hero-search.component.html"

<input #searchBox id="search-box" (input)="search(searchBox.value)" />

每当用户在文本框中输入时,这个事件绑定就会使用文本框的值(搜索词)调用 search() 函数。 searchTerms 变成了一个能发出搜索词的稳定的流。

串联 RxJS 操作符

如果每当用户击键后就直接调用 searchHeroes() 将导致创建海量的 HTTP 请求,浪费服务器资源并干扰数据调度计划。

应该怎么做呢?ngOnInit()searchTerms 这个可观察对象的处理管道中加入了一系列 RxJS 操作符,用以缩减对 searchHeroes() 的调用次数,并最终返回一个可及时给出英雄搜索结果的可观察对象(每次都是 Hero[] )。

代码如下:

Path:"src/app/hero-search/hero-search.component.ts"

this.heroes$ = this.searchTerms.pipe(  // wait 300ms after each keystroke before considering the term  debounceTime(300),  // ignore new term if same as previous term  distinctUntilChanged(),  // switch to new search observable each time the term changes  switchMap((term: string) => this.heroService.searchHeroes(term)),);

各个操作符的工作方式如下:

  • 在传出最终字符串之前,debounceTime(300) 将会等待,直到新增字符串的事件暂停了 300 毫秒。 你实际发起请求的间隔永远不会小于 300ms。

  • distinctUntilChanged() 会确保只在过滤条件变化时才发送请求。

  • switchMap() 会为每个从 debounce()distinctUntilChanged() 中通过的搜索词调用搜索服务。 它会取消并丢弃以前的搜索可观察对象,只保留最近的。

注:
- 借助 switchMap 操作符, 每个有效的击键事件都会触发一次 HttpClient.get() 方法调用。 即使在每个请求之间都有至少 300ms 的间隔,仍然可能会同时存在多个尚未返回的 HTTP 请求。

  • switchMap() 会记住原始的请求顺序,只会返回最近一次 HTTP 方法调用的结果。 以前的那些请求都会被取消和舍弃。

  • 注意,取消前一个 searchHeroes() 可观察对象并不会中止尚未完成的 HTTP 请求。 那些不想要的结果只会在它们抵达应用代码之前被舍弃。

记住,组件类中并没有订阅 heroes$这个可观察对象,而是由模板中的 AsyncPipe 完成的。

再次运行本应用,在这个仪表盘中输入现有的英雄名字,您可以看到:

查看最终代码

HeroService

Path:"src/app/hero.service.ts"

import { Injectable } from '@angular/core';import { HttpClient, HttpHeaders } from '@angular/common/http';import { Observable, of } from 'rxjs';import { catchError, map, tap } from 'rxjs/operators';import { Hero } from './hero';import { MessageService } from './message.service';@Injectable({ providedIn: 'root' })export class HeroService {  private heroesUrl = 'api/heroes';  // URL to web api  httpOptions = {    headers: new HttpHeaders({ 'Content-Type': 'application/json' })  };  constructor(    private http: HttpClient,    private messageService: MessageService) { }  /** GET heroes from the server */  getHeroes(): Observable<Hero[]> {    return this.http.get<Hero[]>(this.heroesUrl)      .pipe(        tap(_ => this.log('fetched heroes')),        catchError(this.handleError<Hero[]>('getHeroes', []))      );  }  /** GET hero by id. Return `undefined` when id not found */  getHeroNo404<Data>(id: number): Observable<Hero> {    const url = `${this.heroesUrl}/?id=${id}`;    return this.http.get<Hero[]>(url)      .pipe(        map(heroes => heroes[0]), // returns a {0|1} element array        tap(h => {          const outcome = h ? `fetched` : `did not find`;          this.log(`${outcome} hero id=${id}`);        }),        catchError(this.handleError<Hero>(`getHero id=${id}`))      );  }  /** GET hero by id. Will 404 if id not found */  getHero(id: number): Observable<Hero> {    const url = `${this.heroesUrl}/${id}`;    return this.http.get<Hero>(url).pipe(      tap(_ => this.log(`fetched hero id=${id}`)),      catchError(this.handleError<Hero>(`getHero id=${id}`))    );  }  /* GET heroes whose name contains search term */  searchHeroes(term: string): Observable<Hero[]> {    if (!term.trim()) {      // if not search term, return empty hero array.      return of([]);    }    return this.http.get<Hero[]>(`${this.heroesUrl}/?name=${term}`).pipe(      tap(x => x.length ?         this.log(`found heroes matching "${term}"`) :         this.log(`no heroes matching "${term}"`)),      catchError(this.handleError<Hero[]>('searchHeroes', []))    );  }  //////// Save methods //////////  /** POST: add a new hero to the server */  addHero(hero: Hero): Observable<Hero> {    return this.http.post<Hero>(this.heroesUrl, hero, this.httpOptions).pipe(      tap((newHero: Hero) => this.log(`added hero w/ id=${newHero.id}`)),      catchError(this.handleError<Hero>('addHero'))    );  }  /** DELETE: delete the hero from the server */  deleteHero(hero: Hero | number): Observable<Hero> {    const id = typeof hero === 'number' ? hero : hero.id;    const url = `${this.heroesUrl}/${id}`;    return this.http.delete<Hero>(url, this.httpOptions).pipe(      tap(_ => this.log(`deleted hero id=${id}`)),      catchError(this.handleError<Hero>('deleteHero'))    );  }  /** PUT: update the hero on the server */  updateHero(hero: Hero): Observable<any> {    return this.http.put(this.heroesUrl, hero, this.httpOptions).pipe(      tap(_ => this.log(`updated hero id=${hero.id}`)),      catchError(this.handleError<any>('updateHero'))    );  }  /**   * Handle Http operation that failed.   * Let the app continue.   * @param operation - name of the operation that failed   * @param result - optional value to return as the observable result   */  private handleError<T>(operation = 'operation', result?: T) {    return (error: any): Observable<T> => {      // TODO: send the error to remote logging infrastructure      console.error(error); // log to console instead      // TODO: better job of transforming error for user consumption      this.log(`${operation} failed: ${error.message}`);      // Let the app keep running by returning an empty result.      return of(result as T);    };  }  /** Log a HeroService message with the MessageService */  private log(message: string) {    this.messageService.add(`HeroService: ${message}`);  }}

InMemoryDataService

Path:"src/app/in-memory-data.service.ts"

import { Injectable } from '@angular/core';import { InMemoryDbService } from 'angular-in-memory-web-api';import { Hero } from './hero';@Injectable({  providedIn: 'root',})export class InMemoryDataService implements InMemoryDbService {  createDb() {    const heroes = [      { id: 11, name: 'Dr Nice' },      { id: 12, name: 'Narco' },      { id: 13, name: 'Bombasto' },      { id: 14, name: 'Celeritas' },      { id: 15, name: 'Magneta' },      { id: 16, name: 'RubberMan' },      { id: 17, name: 'Dynama' },      { id: 18, name: 'Dr IQ' },      { id: 19, name: 'Magma' },      { id: 20, name: 'Tornado' }    ];    return {heroes};  }  // Overrides the genId method to ensure that a hero always has an id.  // If the heroes array is empty,  // the method below returns the initial number (11).  // if the heroes array is not empty, the method below returns the highest  // hero id + 1.  genId(heroes: Hero[]): number {    return heroes.length > 0 ? Math.max(...heroes.map(hero => hero.id)) + 1 : 11;  }}

AppModule

Path:"src/app/app.module.ts"

import { NgModule }       from '@angular/core';import { BrowserModule }  from '@angular/platform-browser';import { FormsModule }    from '@angular/forms';import { HttpClientModule }    from '@angular/common/http';import { HttpClientInMemoryWebApiModule } from 'angular-in-memory-web-api';import { InMemoryDataService }  from './in-memory-data.service';import { AppRoutingModule }     from './app-routing.module';import { AppComponent }         from './app.component';import { DashboardComponent }   from './dashboard/dashboard.component';import { HeroDetailComponent }  from './hero-detail/hero-detail.component';import { HeroesComponent }      from './heroes/heroes.component';import { HeroSearchComponent }  from './hero-search/hero-search.component';import { MessagesComponent }    from './messages/messages.component';@NgModule({  imports: [    BrowserModule,    FormsModule,    AppRoutingModule,    HttpClientModule,    // The HttpClientInMemoryWebApiModule module intercepts HTTP requests    // and returns simulated server responses.    // Remove it when a real server is ready to receive requests.    HttpClientInMemoryWebApiModule.forRoot(      InMemoryDataService, { dataEncapsulation: false }    )  ],  declarations: [    AppComponent,    DashboardComponent,    HeroesComponent,    HeroDetailComponent,    MessagesComponent,    HeroSearchComponent  ],  bootstrap: [ AppComponent ]})export class AppModule { }

HeroesComponent

  • Path:"src/app/heroes/heroes.component.html"

<h2>My Heroes</h2><div>  <label>Hero name:    <input #heroName />  </label>  <!-- (click) passes input value to add() and then clears the input -->  <button (click)="add(heroName.value); heroName.value=''">    add  </button></div><ul class="heroes">  <li *ngFor="let hero of heroes">    <a routerLink="/detail/{{hero.id}}">      <span class="badge">{{hero.id}}</span> {{hero.name}}    </a>    <button class="delete" title="delete hero"      (click)="delete(hero)">x</button>  </li></ul>

  • Path:"src/app/heroes/heroes.component.ts"

import { Component, OnInit } from '@angular/core';import { Hero } from '../hero';import { HeroService } from '../hero.service';@Component({  selector: 'app-heroes',  templateUrl: './heroes.component.html',  styleUrls: ['./heroes.component.css']})export class HeroesComponent implements OnInit {  heroes: Hero[];  constructor(private heroService: HeroService) { }  ngOnInit() {    this.getHeroes();  }  getHeroes(): void {    this.heroService.getHeroes()    .subscribe(heroes => this.heroes = heroes);  }  add(name: string): void {    name = name.trim();    if (!name) { return; }    this.heroService.addHero({ name } as Hero)      .subscribe(hero => {        this.heroes.push(hero);      });  }  delete(hero: Hero): void {    this.heroes = this.heroes.filter(h => h !== hero);    this.heroService.deleteHero(hero).subscribe();  }}

  • Path:"src/app/heroes/heroes.component.css"

/* HeroesComponent's private CSS styles */.heroes {  margin: 0 0 2em 0;  list-style-type: none;  padding: 0;  width: 15em;}.heroes li {  position: relative;  cursor: pointer;  background-color: #EEE;  margin: .5em;  padding: .3em 0;  height: 1.6em;  border-radius: 4px;}.heroes li:hover {  color: #607D8B;  background-color: #DDD;  left: .1em;}.heroes a {  color: #333;  text-decoration: none;  position: relative;  display: block;  width: 250px;}.heroes a:hover {  color: #607D8B;}.heroes .badge {  display: inline-block;  font-size: small;  color: white;  padding: 0.8em 0.7em 0 0.7em;  background-color: #405061;  line-height: 1em;  position: relative;  left: -1px;  top: -4px;  height: 1.8em;  min-width: 16px;  text-align: right;  margin-right: .8em;  border-radius: 4px 0 0 4px;}button {  background-color: #eee;  border: none;  padding: 5px 10px;  border-radius: 4px;  cursor: pointer;  cursor: hand;  font-family: Arial;}button:hover {  background-color: #cfd8dc;}button.delete {  position: relative;  left: 194px;  top: -32px;  background-color: gray !important;  color: white;}

HeroDetailComponent

  • Path:"src/app/hero-detail/hero-detail.component.html"

<div *ngIf="hero">  <h2>{{hero.name | uppercase}} Details</h2>  <div><span>id: </span>{{hero.id}}</div>  <div>    <label>name:      <input [(ngModel)]="hero.name" placeholder="name"/>    </label>  </div>  <button (click)="goBack()">go back</button>  <button (click)="save()">save</button></div>

  • Path:"src/app/hero-detail/hero-detail.component.ts"

import { Component, OnInit, Input } from '@angular/core';import { ActivatedRoute } from '@angular/router';import { Location } from '@angular/common';import { Hero }         from '../hero';import { HeroService }  from '../hero.service';@Component({  selector: 'app-hero-detail',  templateUrl: './hero-detail.component.html',  styleUrls: [ './hero-detail.component.css' ]})export class HeroDetailComponent implements OnInit {  @Input() hero: Hero;  constructor(    private route: ActivatedRoute,    private heroService: HeroService,    private location: Location  ) {}  ngOnInit(): void {    this.getHero();  }  getHero(): void {    const id = +this.route.snapshot.paramMap.get('id');    this.heroService.getHero(id)      .subscribe(hero => this.hero = hero);  }  goBack(): void {    this.location.back();  }  save(): void {    this.heroService.updateHero(this.hero)      .subscribe(() => this.goBack());  }}

DashboardComponent

Path:"src/app/dashboard/dashboard.component.html"

<h3>Top Heroes</h3><div class="grid grid-pad">  <a *ngFor="let hero of heroes" class="col-1-4"      routerLink="/detail/{{hero.id}}">    <div class="module hero">      <h4>{{hero.name}}</h4>    </div>  </a></div><app-hero-search></app-hero-search>

HeroSearchComponent

  • Path:"src/app/hero-search/hero-search.component.html"

<div id="search-component">  <h4><label for="search-box">Hero Search</label></h4>  <input #searchBox id="search-box" (input)="search(searchBox.value)" />  <ul class="search-result">    <li *ngFor="let hero of heroes$ | async" >      <a routerLink="/detail/{{hero.id}}">        {{hero.name}}      </a>    </li>  </ul></div>

  • Path:"src/app/hero-search/hero-search.component.ts"

import { Component, OnInit } from '@angular/core';import { Observable, Subject } from 'rxjs';import {   debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';import { Hero } from '../hero';import { HeroService } from '../hero.service';@Component({  selector: 'app-hero-search',  templateUrl: './hero-search.component.html',  styleUrls: [ './hero-search.component.css' ]})export class HeroSearchComponent implements OnInit {  heroes$: Observable<Hero[]>;  private searchTerms = new Subject<string>();  constructor(private heroService: HeroService) {}  // Push a search term into the observable stream.  search(term: string): void {    this.searchTerms.next(term);  }  ngOnInit(): void {    this.heroes$ = this.searchTerms.pipe(      // wait 300ms after each keystroke before considering the term      debounceTime(300),      // ignore new term if same as previous term      distinctUntilChanged(),      // switch to new search observable each time the term changes      switchMap((term: string) => this.heroService.searchHeroes(term)),    );  }}

  • Path:"src/app/hero-search/hero-search.component.css"

/* HeroSearch private styles */.search-result li {  border-bottom: 1px solid gray;  border-left: 1px solid gray;  border-right: 1px solid gray;  width: 195px;  height: 16px;  padding: 5px;  background-color: white;  cursor: pointer;  list-style-type: none;}.search-result li:hover {  background-color: #607D8B;}.search-result li a {  color: #888;  display: block;  text-decoration: none;}.search-result li a:hover {  color: white;}.search-result li a:active {  color: white;}#search-box {  width: 200px;  height: 20px;}ul.search-result {  margin-top: 0;  padding-left: 0;}

总结

您添加了在应用程序中使用 HTTP 的必备依赖。

您重构了 HeroService,以通过 web API 来加载英雄数据。

您扩展了 HeroService 来支持 post()、put() 和 delete() 方法。

您修改了组件,以允许用户添加、编辑和删除英雄。

您配置了一个内存 Web API。

您学会了如何使用“可观察对象”。

《英雄指南》教程结束了。 如果你准备开始学习 Angular 开发的原理,请开始 架构 一章。

词汇表

Angular 有自己的词汇表。 虽然大多数 Angular 短语都是日常用语或计算机术语,但是在 Angular 体系中,它们有特别的含义。

本词汇表列出了常用术语和少量具有反常或意外含义的不常用术语。

预 (ahead-of-time, AOT) 编译

Angular 的预先(AOT)编译器可以在编译期间把 Angular 的 HTML 代码和 TypeScript 代码转换成高效的 JavaScript 代码,这样浏览器就可以直接下载并运行它们。 对于产品环境,这是最好的编译模式,相对于即时 (JIT) 编译而言,它能减小加载时间,并提高性能。

使用命令行工具 ngc 来编译你的应用之后,就可以直接启动一个模块工厂,这意味着你不必再在 JavaScript 打包文件中包含 Angular 编译器。

Angular 元素(element)

被包装成自定义元素的 Angular 组件。

注:
- 参考 [Angular 元素]() 一文。

注解(Annotation)

一种为类提供元数据的结构。

注:
参见 [装饰器]()。

应用外壳(app-shell)

应用外壳是一种在构建期间通过路由为应用渲染出部分内容的方式。 这样就能为用户快速渲染出一个有意义的首屏页面,因为浏览器可以在初始化脚本之前渲染出静态的 HTML 和 CSS。

欲知详情,参见应用外壳模型。

你可以使用 Angular CLI 来生成一个应用外壳。 它可以在浏览器下载完整版应用之前,先快速启动一个静态渲染页面(所有页面的公共骨架)来增强用户体验,等代码加载完毕后再自动切换到完整版。

注:
- 参考 [Service Worker 与 PWA]() 一文。

建筑师(Architect)

CLI 用来根据所提供的配置执行复杂任务(比如编译和执行测试)的工具。 建筑师是一个外壳,它用来对一个指定的目标配置来执行一个构建器(builder) (定义在一个 npm 包中)。

在工作空间配置文件中,"architect" 区可以为建筑师的各个构建器提供配置项。

比如,内置的 linting 构建器定义在 @angular-devkit/build_angular:tslint 包中,它使用 TSLint 工具来执行 linting 操作,其配置是在 tslint.json 文件中指定的。

使用 CLI 命令 ng run可以通过指定与某个构建器相关联的目标配置来调用此构建器。 整合器(Integrator)可以添加一些构建器来启用某些工具和工作流,以便通过 Angular CLI 来运行它。比如,自定义构建器可以把 CLI 命令(如 ng buildng test)的内置实现替换为第三方工具。

属性型指令(attribute directives)

指令 (directive)的一种。可以监听或修改其它 HTML 元素、特性 (attribute)、属性 (property)、组件的行为。通常用作 HTML 属性,就像它的名字所暗示的那样。

注:
- 参考 [属性型指令]() 一文。

绑定 (binding)

广义上是指把变量或属性设置为某个数据值的一种实践。 在 Angular 中,一般是指数据绑定,它会根据数据对象属性的值来设置 DOM 对象的属性。

有时也会指在“令牌(Token)”和依赖提供者(Provider) 之间的依赖注入 绑定。

启动/引导 (bootstrap)

一种用来初始化和启动应用或系统的途径。

在 Angular 中,应用的根模块(AppModule)有一个 bootstrap 属性,用于指出该应用的的顶层组件。 在引导期间,Angular 会创建这些组件,并插入到宿主页面 index.html 中。 你可以在同一个 index.html 中引导多个应用,每个应用都有一些自己的组件。

注:
参考 [引导]() 一文。

构建器(Builder)

一个函数,它使用 Architect API 来执行复杂的过程,比如构建或测试。 构建器的代码定义在一个 npm 包中。

比如,BrowserBuilder 针对某个浏览器目标运行 webpack 构建,而 KarmaBuilder 则启动 Karma 服务器,并且针对单元测试运行 webpack 构建。

CLI 命令 ng run 使用一个特定的目标配置来调用构建器。 工作空间配置文件 angular.json 中包含这些内置构建器的默认配置。

大小写类型(case types)

Angular 使用大小写约定来区分多种名字,详见风格指南中的 "命名" 一节。下面是这些大小写类型的汇总表:

  • 小驼峰形式(camelCase):符号、属性、方法、管道名、非组件指令的选择器、常量。 小驼峰(也叫标准驼峰)形式的第一个字母要使用小写形式。比如 "selectedHero"。

  • 大驼峰形式(UpperCamelCase)或叫帕斯卡形式(PascalCase):类名(包括用来定义组件、接口、NgModule、指令、管道等的类)。 大驼峰形式的第一个字母要使用大写形式。比如 "HeroListComponent"。

  • 中线形式(dash-case)或叫烤串形式(kebab-case):文件名中的描述部分,组件的选择器。比如 "app-hero-list"。

  • 下划线形式(underscore_case)或叫蛇形形式(snake_case):在 Angular 中没有典型用法。蛇形形式使用下划线连接各个单词。 比如 "convert_link_mode"。

  • 大写下划线形式(UPPER_UNDERSCORE_CASE)或叫大写蛇形形式(UPPER_SNAKE_CASE):传统的常量写法(可以接受,但更推荐用小驼峰形式(camelCase)) 大蛇形形式使用下划线分隔的全大写单词。比如 "FIX_ME"。

变更检测(change detection)

Angular 框架会通过此机制将应用程序 UI 的状态与数据的状态同步。变更检测器在运行时会检查数据模型的当前状态,并在下一轮迭代时将其和先前保存的状态进行比较。

当应用逻辑更改组件数据时,绑定到视图中 DOM 属性上的值也要随之更改。变更检测器负责更新视图以反映当前的数据模型。类似地,用户也可以与 UI 进行交互,从而引发要更改数据模型状态的事件。这些事件可以触发变更检测。

使用默认的(“CheckAlways”)变更检测策略,变更检测器将遍历每个视图模型上的视图层次结构,以检查模板中的每个数据绑定属性。在第一阶段,它将所依赖的数据的当前状态与先前状态进行比较,并收集更改。在第二阶段,它将更新页面上的 DOM 以反映出所有新的数据值。

如果设置了 OnPush(“CheckOnce”)变更检测策略,则变更检测器仅在显式调用它或由 @Input 引用的变化或触发事件处理程序时运行。这通常可以提高性能。

注:
参考 [优化 Angular 的变更检测]() 一文。

类装饰器(class decorator)

装饰器会出现在类定义的紧前方,用来声明该类具有指定的类型,并且提供适合该类型的元数据。

可以用下列装饰器来声明 Angular 的类:

  • @Component()

  • @Directive()

  • @Pipe()

  • @Injectable()

  • @NgModule()

类字段装饰器(class field decorator)

出现在类定义中属性紧前方的装饰器语句用来声明该字段的类型。比如 @Input@Output

集合(collection)

在 Angular 中,是指收录在同一个 npm 包 中的一组原理图(schematics)。

命令行界面(CLI)

Angular CLI 是一个命令行工具,用于管理 Angular 的开发周期。它用于为工作区或项目创建初始的脚手架,并且运行生成器(schematics)来为初始生成的版本添加或修改各类代码。 CLI 支持开发周期中的所有阶段,比如构建、测试、打包和部署。

  • 要开始使用 CLI 来创建新项目,参考 [建立本地开发环境]()。

  • 要了解 CLI 的全部功能,参考 [CLI 命令参考手册]()。

注:
参考 [Schematics CLI]() 一文。

组件 (component)

一个带有 @Component() 装饰器的类,和它的伴生模板关联在一起。组件类及其模板共同定义了一个视图。

组件是指令的一种特例。@Component() 装饰器扩展了 @Directive() 装饰器,增加了一些与模板有关的特性。

Angular 的组件类负责暴露数据,并通过数据绑定机制来处理绝大多数视图的显示和用户交互逻辑。

注:
- 要了解更多关于组件类、模板和视图的知识,参考 [架构概览]() 一章。

配置(configuration)

参考 [工作空间配置]() 。

自定义元素(Custom element)

一种 Web 平台的特性,目前已经被绝大多数浏览器支持,在其它浏览器中也可以通过腻子脚本获得支持(参考 [浏览器支持]())。

这种自定义元素特性通过允许你定义标签(其内容是由 JavaScript 代码来创建和控制的)来扩展 HTML。当自定义元素(也叫 Web Component)被添加到 CustomElementRegistry 之后就会被浏览器识别。

你可以使用 API 来转换 Angular 组件,以便它能够注册进浏览器中,并且可以用在你往 DOM 中添加的任意 HTML 中。 自定义元素标签可以把组件的视图(包括变更检测和数据绑定功能)插入到不受 Angular 控制的内容中。

注:
- 参考 [Angular 元素]()。
- 参考 [加载动态组件]()。

数据绑定 (data binding)

这个过程可以让应用程序将数据展示给用户,并对用户的操作(点击、触屏、按键)做出回应。

在数据绑定机制下,你只要声明一下 HTML 部件和数据源之间的关系,把细节交给框架去处理。 而以前的手动操作过程是:将数据推送到 HTML 页面中、添加事件监听器、从屏幕获取变化后的数据,并更新应用中的值。

更多的绑定形式,见[模板语法]():

  • [插值]()

  • [property 绑定]()

  • [事件绑定]()

  • [attribute 绑定]()

  • [CSS 类绑定]()

  • [样式绑定]()

  • [基于 ngModel 的双向数据绑定]()

可声明对象(declarable)

类的一种类型,你可以把它们添加到 NgModule 的 declarations 列表中。 你可以声明组件、指令和管道。

不要声明:

  • 已经在其它 NgModule 中声明过的类

  • 从其它包中导入的指令数组。比如,不要再次声明来自 @angular/forms 中的 FORMS_DIRECTIVES

  • NgModule 类

  • 服务类

  • 非 Angular 的类和对象,比如:字符串、数字、函数、实体模型、配置、业务逻辑和辅助类

装饰器(decorator | decoration)

一个函数,用来修饰紧随其后的类或属性定义。 装饰器(也叫注解)是 JavaScript 的一种语言特性,是一项位于阶段 2(stage 2)的试验特性。

Angular 定义了一些装饰器,用来为类或属性附加元数据,来让自己知道那些类或属性的含义,以及该如何处理它们。

注:
- 参考 [类装饰器]()、[类属性装饰器]()。

依赖注入(dependency injection)

依赖注入既是设计模式,同时又是一种机制:当应用程序的一些部件(即一些依赖)需要另一些部件时, 利用依赖注入来创建被请求的部件,并将它们注入到需要它们的部件中。

在 Angular 中,依赖通常是服务,但是也可以是值,比如字符串或函数。应用的注入器(它是在启动期间自动创建的)会使用该服务或值的配置好的提供者来按需实例化这些依赖。各个不同的提供者可以为同一个服务提供不同的实现。

注:
参考 [Angular 中的依赖注入]() 一章。

DI 令牌(Token)

一种用来查阅的令牌,它关联到一个依赖提供者,用于依赖注入系统中。

差异化加载一种构建技术,它会为同一个应用创建两个发布包。一个是较小的发布包,是针对现代浏览器的。另一个是较大的发布包,能让该应用正确的运行在像 IE 11 这样的老式浏览器上,这些浏览器不能支持全部现代浏览器的 API。

注:
参考 [Deployment]() 一章。

指令 (directive)

一个可以修改 DOM 结构或修改 DOM 和组件数据模型中某些属性的类。 指令类的定义紧跟在 @Directive() 装饰器之后,以提供元数据。

指令类几乎总与 HTML 元素或属性 (attribute) 相关。 通常会把这些 HTML 元素或者属性 (attribute) 当做指令本身。 当 Angular 在 HTML 模板中发现某个指令时,会创建与之相匹配的指令类的实例,并且把这部分 DOM 的控制权交给它。

指令分为三类:

  • 组件使用 @Component()(继承自 @Directive())为某个类关联一个模板。

  • [属性型指令]() 修改页面元素的行为和外观。

  • [结构型指令]() 修改 DOM 的结构。

Angular 提供了一些以 ng 为前缀的内置指令。你也可以创建新的指令来实现自己的功能。 你可以为自定义指令关联一个选择器(一种形如 <my-directive> 的 HTML 标记),以扩展模板语法,从而让你能在应用中使用它。

领域特定语言(DSL)

一种特殊用途的库或 API,参见领域特定语言词条。

Angular 使用领域特定语言扩展了 TypeScript,用于与 Angular 应用相关的许多领域。这些 DSL 都定义在 NgModule 中,比如 动画、表单和路由与导航。

动态组件加载(dynamic component loading)

一种在运行期间把组件添加到 DOM 中的技术,它需要你从编译期间排除该组件,然后,当你把它添加到 DOM 中时,再把它接入 Angular 的变更检测与事件处理框架。

注:
参考 [自定义元素](),它提供了一种更简单的方式来达到相同的效果。

急性加载(Eager Loading)

在启动时加载的 NgModule 和组件被称为急性加载,与之相对的是那些在运行期间才加载的方式(惰性加载)。 参见惰性加载。

ECMAScript 语言

[官方 JavaScript 语言规范]()

并不是所有浏览器都支持最新的 ECMAScript 标准,不过你可以使用转译器(比如TypeScript)来用最新特性写代码,然后它会被转译成可以在浏览器的其它版本上运行的代码。

注:
参考 [浏览器支持]()。

元素(Element)

Angular 定义了 ElementRef 类来包装与渲染有关的原生 UI 元素。这让你可以在大多数情况下使用 Angular 的模板和数据绑定机制来访问 DOM 元素,而不必再引用原生元素。

本文档中一般会使用元素(Element)来指代 ElementRef 的实例,注意与 DOM 元素(你必要时你可以直接访问它)区分开。

可以对比下 [自定义元素]()。

入口点(Entry Point)

JavaScript 模块的目的是供 npm 包的用户进行导入。入口点模块通常会重新导出来自其它内部模块的一些符号。每个包可以包含多个入口点。比如 @angular/core 就有两个入口点模块,它们可以使用名字 @angular/core 和 @angular/core/testing 进行导入。

表单控件(form control)

一个 FormControl 实例,它是 Angular 表单的基本构造块。它会和 FormGroup 和 FormArray 一起,跟踪表单输入元素的值、有效性和状态。

注:
参考 [Angular 表单简介]()。

表单模型(form model)

是指在指定的时间点,表单输入元素的值和验证状态的"权威数据源"。当使用响应式表单时,表单模型会在组件类中显式创建。当使用模板驱动表单时,表单模型是由一些指令隐式创建的。

注:
参考 [Angular 表单简介]()。

表单验证(form validation)

一种检查,当表单值发生变化时运行,并根据预定义的约束来汇报指定的这些值是否正确并完全。响应式表单使用验证器函数,而模板驱动表单则使用验证器指令。

注:
参考 [表单验证器]()。

不可变性(immutability)

是否能够在创建之后修改值的状态。响应式表单会执行不可变性的更改,每次更改数据模型都会生成一个新的数据模型,而不是修改现有的数据模型。 模板驱动表单则会执行可变的更改,它通过 NgModel 和双向数据绑定来就地修改现有的数据模型。

可注入对象(injectable)

Angular 中的类或其它概念使用依赖注入机制来提供依赖。 可供注入的服务类必须使用 @Injectable() 装饰器标出来。其它条目,比如常量值,也可用于注入。

注入器 (injector)

Angular 依赖注入系统中可以在缓存中根据名字查找依赖,也可以通过配置过的提供者来创建依赖。 启动过程中会自动为每个模块创建一个注入器,并被组件树继承。

注入器会提供依赖的一个单例,并把这个单例对象注入到多个组件中。

模块和组件级别的注入器树可以为它们拥有的组件及其子组件提供同一个依赖的不同实例。

你可以为同一个依赖使用不同的提供者来配置这些注入器,这些提供者可以为同一个依赖提供不同的实现。

注:
参考 [多级依赖注入]()。

输入属性 (input)

当定义指令时,指令属性上的 @Input() 装饰器让该属性可以作为属性绑定的目标使用。 数据值会从等号右侧的模板表达式所指定的数据源流入组件的输入属性。

注:
参考 [输入与输出属性]()。

插值 (interpolation)

属性数据绑定 (property data binding) 的一种形式,位于双大括号中的模板表达式 (template expression)会被渲染成文本。 在被赋值给元素属性或者显示在元素标签中之前,这些文本可能会先与周边的文本合并,参见下面的例子。

<label>My current hero is {{hero.name}}</label>

常春藤引擎(Ivy)

Ivy 是 Angular 的下一代编译和渲染管道的代号。在 Angular 的版本 9 中,默认情况下使用新的编译器和运行时,而不再用旧的编译器和运行时,也就是 View Engine。

注:
参考 [Angular Ivy]()。

JavaScript

参见 [ECMAScript]() 和 [TypeScript]()。

即时 (just-in-time, JIT) 编译

在启动期间,Angular 的即时编译器(JIT)会在运行期间把你的 Angular HTML 和 TypeScript 代码转换成高效的 JavaScript 代码。

当你运行 Angular 的 CLI 命令 ng build 和 ng serve 时,JIT 编译是默认选项,而且是开发期间的最佳实践。但是强烈建议你不要在生产环境下使用 JIT 模式,因为它会导致巨大的应用负担,从而拖累启动时的性能。

注:
参考 [预先 (AOT) 编译]()。

惰性加载(Lazy loading)

惰性加载过程会把应用拆分成多个包并且按需加载它们,从而提高应用加载速度。 比如,一些依赖可以根据需要进行惰性加载,与之相对的是那些 急性加载 的模块,它们是根模块所要用的,因此会在启动期间加载。

路由器只有当父视图激活时才需要加载子视图。同样,你还可以构建一些自定义元素,它们也可以在需要时才加载进 Angular 应用。

库(Library)

一种 Angular 项目。用来让其它 Angular 应用包含它,以提供各种功能。库不是一个完整的 Angular 应用,不能独立运行。(要想为非 Angular 应用添加可复用的 Angular 功能,你可以使用 Angular 的自定义元素。)

库的开发者可以使用 CLI 在现有的 工作区 中 generate 新库的脚手架,还能把库发布为 npm 包。

应用开发者可以使用 CLI 来把一个已发布的库 add 进这个应用所在的工作区。

注:
参考 [原理图(schematic)]()。

生命周期钩子(Lifecycle hook)

一种接口,它允许你监听指令和组件的生命周期,比如创建、更新和销毁等。

每个接口只有一个钩子方法,方法名是接口名加前缀 ng。例如,OnInit 接口的钩子方法名为 ngOnInit。

Angular 会按以下顺序调用钩子方法:

  • ngOnChanges - 在输入属性 (input)/输出属性 (output)的绑定值发生变化时调用。

  • ngOnInit - 在第一次 ngOnChanges 完成后调用。

  • ngDoCheck - 开发者自定义变更检测。

  • ngAfterContentInit - 在组件内容初始化后调用。

  • ngAfterContentChecked - 在组件内容每次检查后调用。

  • ngAfterViewInit - 在组件视图初始化后调用。

  • ngAfterViewChecked - 在组件视图每次检查后调用。

  • ngOnDestroy - 在指令销毁前调用。

注:
参考 [生命周期钩子页]()。

模块 (module)

通常,模块会收集一组专注于单一目的的代码块。Angular 既使用 JavaScript 的标准模块,也定义了 Angular 自己的模块,也就是 NgModule。

在 JavaScript (ECMAScript) 中,每个文件都是一个模块,该文件中定义的所有对象都属于这个模块。这些对象可以导出为公共对象,而这些公共对象可以被其它模块导入后使用。

Angular 就是用一组 JavaScript 模块(也叫库)的形式发布的。每个 Angular 库都带有 @angular 前缀。 使用 NPM 包管理器安装它们,并且使用 JavaScript 的 import 声明语句从中导入各个部件。

注:
参考 [NgModule]()。

ngcc

Angular 兼容性编译器。如果使用 Ivy 构建应用程序,但依赖未用 Ivy 编译的库,则 CLI 将使用 ngcc 自动更新依赖库以使用 Ivy。

NgModule

一种带有 @NgModule() 装饰器的类定义,它会声明并提供一组专注于特定功能的代码块,比如业务领域、工作流或一组紧密相关的能力集等。

像 JavaScript 模块一样,NgModule 能导出那些可供其它 NgModule 使用的功能,也可以从其它 NgModule 中导入其公开的功能。 NgModule 类的元数据中包括一些供应用使用的组件、指令和管道,以及导入、导出列表。参见可声明对象。

NgModule 通常会根据它导出的内容决定其文件名,比如,Angular 的 DatePipe 类就属于 date_pipe.ts 文件中一个名叫 date_pipe 的特性模块。 你可以从 Angular 的范围化包中导入它们,比如 @angular/core。

每个 Angular 应用都有一个根模块。通常,这个类会命名为 AppModule,并且位于一个名叫 app.module.ts 的文件中。

注:
参考 [NgModules]()。

npm 包

npm 包管理器用于分发与加载 Angular 的模块和库。

注:
你还可以了解 Angular 如何使用 [Npm 包]() 的更多知识。

可观察对象(Observable)

一个多值生成器,这些值会被推送给订阅者。 Angular 中到处都会用到异步事件处理。你要通过调用可观察对象的 subscribe() 方法来订阅它,从而让这个可观察对象得以执行,你还要给该方法传入一些回调函数来接收 "有新值"、"错误" 或 "完成" 等通知。

可观察对象可以把任意类型的一个或多个值传给订阅者,无论是同步(就像函数把值返回给它的调用者一样)还是异步。 订阅者会在生成了新值时收到包含这个新值的通知,以及正常结束或错误结束时的通知。

Angular 使用一个名叫响应式扩展 (RxJS)的第三方包来实现这些功能。

注:
参考 [可观察对象]()。

观察者(Observer)

传给可观察对象 的 subscribe() 方法的一个对象,其中定义了订阅者的一组回调函数。

输出属性 (output)

当定义指令时,指令属性上的 @Output() 装饰器会让该属性可用作事件绑定的目标。 事件从该属性流出到等号右侧指定的模板表达式中。

注:
参考 [输入与输出属性]()。

管道(pipe)

一个带有 @Pipe{} 装饰器的类,它定义了一个函数,用来把输入值转换成输出值,以显示在视图中。 Angular 定义了很多管道,并且你还可可以自定义新的管道。

注:
参考 [管道]()。

平台(platform)

在 Angular 术语中,平台是供 Angular 应用程序在其中运行的上下文。Angular 应用程序最常见的平台是 Web 浏览器,但它也可以是移动设备的操作系统或 Web 服务器。

@angular/platform-* 软件包提供了对各种 Angular 运行时平台的支持。这些软件包通过提供用于收集用户输入和渲染指定平台 UI 的实现,以允许使用 @angular/core 和 @angular/common 的应用程序在不同的环境中执行。隔离平台相关的功能使开发人员可以独立于平台使用框架的其余部分。

  • 在 Web 浏览器中运行时,BrowserModule 是从 platform-browser 软件包中导入的,并支持简化安全性和事件处理的服务,并允许应用程序访问浏览器专有的功能,例如解释键盘输入和控制文档要显示的标题。浏览器中运行的所有应用程序都使用同一个平台服务。

  • 使用服务端渲染(SSR)时,platform-server 包将提供 DOM、XMLHttpRequest 和其它不依赖浏览器的其它底层功能的 Web 服务器端实现。

腻子脚本(polyfill)

一个 NPM 包,它负责弥补浏览器 JavaScript 实现与最新标准之间的 "缝隙"。参见浏览器支持页,以了解要在特定平台支持特定功能时所需的腻子脚本。

项目(project)

在 Angular CLI 中,CLI 命令可能会创建或修改独立应用或库。

由 ng new 创建的项目中包含一组源文件、资源和配置文件,当你用 CLI 开发或测试此应用时就会用到它们。此外,还可以用 ng generate application 或 ng generate library 命令创建项目。

angular.json 文件可以配置某个工作空间 中的所有项目。

注:
参考 [项目文件结构]()。

提供者 (provider)

一个实现了 Provider 接口的对象。一个提供者对象定义了如何获取与 DI 令牌(token) 相关联的可注入依赖。 注入器会使用这个提供者来创建它所依赖的那些类的实例。

Angular 会为每个注入器注册一些 Angular 自己的服务。你也可以注册应用自己所需的服务提供者。

参见服务和依赖注入。

注:
参考 [依赖注入]()。

响应式表单 (reactive forms)

通过组件中代码构建 Angular 表单的一个框架。 另一种技术是模板驱动表单

构建响应式表单时:

"权威数据源"(表单模型)定义在组件类中。

表单验证在组件代码而不是验证器指令中定义。

在组件类中,使用 new FormControl() 或者 FormBuilder 显性地创建每个控件。

模板中的 input 元素不使用 ngModel。

相关联的 Angular 指令全部以 Form 开头,例如 FormGroup()、FormControl() 和 FormControlName()。

另一种方式是模板驱动表单。模板驱动表单的简介和这两种方式的比较,参见 [Angular 表单简介]()。

路由器 (router)

一种工具,用来配置和实现 Angular 应用中各个状态和视图之间的导航。

Router 模块是一个 NgModule,它提供在应用视图间导航时需要的服务提供者和指令。路由组件是一种组件,它导入了 Router 模块,并且其模板中包含 RouterOutlet 元素,路由器生成的视图就会被显示在那里。

路由器定义了在单页面中的各个视图之间导航的方式,而不是在页面之间。它会解释类似 URL 的链接,以决定该创建或销毁哪些视图,以及要加载或卸载哪些组件。它让你可以在 Angular 应用中获得惰性加载的好处。

注:
参考 [路由与导航]()。

路由出口(router outlet)

一种指令,它在路由组件的模板中扮演占位符的角色,Angular 会根据当前的路由状态动态填充它。

路由组件 (routing component)

一个模板中带有 RouterOutlet 指令的 Angular 组件,用于根据路由器的导航显示相应的视图。

注:
参考 [路由与导航]()。

规则(rule)

在原理图 中,是指一个在文件树上运行的函数,用于以指定方式创建、删除或修改文件,并返回一个新的 Tree 对象。

原理图(schematic)

脚手架库会定义如何借助创建、修改、重构或移动文件和代码等操作来生成或转换某个项目。每个原理图定义了一些规则,以操作一个被称为文件树的虚拟文件系统。

Angular CLI 使用原理图来生成和修改 Angular 项目及其部件。

Angular 提供了一组用于 CLI 的原理图。参见 Angular CLI 命令参考手册。当 ng add 命令向项目中添加某个库时,就会运行原理图。ng generate 命令则会运行原理图,来创建应用、库和 Angular 代码块。

公共库的开发者可以创建原理图,来让 CLI 生成他们自己的发布的库。欲知详情,参见 devkit 文档。

注:
参考 [原理图]()和[把库与 CLI 集成]()。

Schematics CLI

Schematics 自带了一个命令行工具。 使用 Node 6.9 或更高版本,可以全局安装这个 Schematics CLI:

npm install -g @angular-devkit/schematics-cli

这会安装可执行文件 schematics,你可以用它来创建新工程、往现有工程中添加新的 schematic,或扩展某个现有的 schematic。

范围化包 (scoped package)

一种把相关的 npm 包分组到一起的方式。 Angular 的 NgModule 都是在一些以 @angular 为范围名的范围化包中发布的。比如 @angular/core、@angular/common、@angular/forms 和 @angular/router。

和导入普通包相同的方式导入范围化包。

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

服务端渲染

一项在服务端生成静态应用页面的技术,它可以在对来自浏览器的请求进行响应时生成这些页面或用它们提供服务。 它还可以提前把这些页面生成为 HTML 文件,以便稍后用它们来提供服务。

该技术可以增强手机和低功耗设备的性能,而且会在应用加载通过快速展示一个静态首屏来提升用户体验。这个静态版本还能让你的应用对网络蜘蛛更加友好。

你可以通过 CLI 运行 Angular Universal 工具,借助 @nguniversal/express-engine schematic 原理图来更轻松的让应用支持服务端渲染。

服务 (service)

在 Angular 中,服务就是一个带有 @Injectable 装饰器的类,它封装了可以在应用程序中复用的非 UI 逻辑和代码。 Angular 把组件和服务分开,是为了增进模块化程度和可复用性。

@Injectable 元数据让服务类能用于依赖注入机制中。可注入的类是用提供者进行实例化的。 各个注入器会维护一个提供者的列表,并根据组件或其它服务的需要,用它们来提供服务的实例。

注:
参考 [服务与依赖注入简介]()。

结构型指令(Structural directives)

一种指令类型,它能通过修改 DOM (添加、删除或操纵元素及其子元素)来修整或重塑 HTML 的布局。

注:
参考 [结构型指令页]()。

订阅者(Subscriber)

一个函数,用于定义如何获取或生成要发布的值或消息。 当有消费者调用可观察对象的 subscribe() 方法时,该函数就会执行。

订阅一个可观察对象就会触发该对象的执行、为该对象关联一些回调函数,并创建一个 Subscription(订阅记录)对象来让你能取消订阅。

subscribe() 方法接收一个 JavaScript 对象(叫做观察者(observer)),其中最多可以包含三个回调,分别对应可观察对象可以发出的几种通知类型:

  • next(下一个)通知会发送一个值,比如数字、字符串、对象。

  • error(错误)通知会发送 JavaScript 错误或异常。

  • complete(完成)通知不会发送值,但是当调用结束时会调用这个处理器。异步的值可能会在调用了完成之后继续发送过来。

目标

项目的一个可构建或可运行的子集,它是工作空间配置文件中的一个子对象,它会被建筑师(Architect)的构建器(Builder)执行。

在 angular.json 文件中,每个项目都有一个 architect 分区,其中包含一些用于配置构建器的目标。其中一些目标对应于 CLI 命令,比如 build、serve、test 和 lint。

比如,ng build 命令用来编译项目时所调用的构建器会使用一个特定的构建工具,并且具有一份默认配置,此配置中的值可以通过命令行参数进行覆盖。目标 build 还为 "生产环境" 构建定义了另一个配置,可以通过在 build 命令上添加 --prod 标志来调用它。

建筑师工具提供了一组构建器。ng new 命令为初始应用项目提供了一组目标。ng generate application 和 ng generate library 命令则为每个新项目提供了一组目标。这些目标的选项和配置都可以进行自定义,以便适应你项目的需求。比如,你可能会想为项目的 "build" 目标添加一个 "staging" 或 "testing" 配置。

你还可以定义一个自定义构建器,并且往项目配置中添加一个目标,来使用你的自定义构建器。然后你就可以通过 ng run 命令来运行此目标。

模板 (template)

用来定义要如何在 HTML 中渲染组件视图的代码。

模板会把纯 HTML 和 Angular 的数据绑定语法、指令和模板表达式组合起来。Angular 的元素会插入或计算那些值,以便在页面显示出来之前修改 HTML 元素。

模板通过 @Component() 装饰器与组件类类关联起来。模板代码可以作为 template 属性的值用内联的方式提供,也可以通过 templateUrl 属性链接到一个独立的 HTML 文件。

用 TemplateRef 对象表示的其它模板用来定义一些备用视图或内嵌视图,它们可以来自多个不同的组件。

模板驱动表单(template-driven forms)

一种在视图中使用 HTML 表单和输入类元素构建 Angular 表单的格式。 它的替代方案是响应式表单框架。

当构建模板驱动表单时:

模板是“权威数据源”。使用属性 (attribute) 在单个输入元素上定义验证规则。

使用 ngModel 进行双向绑定,保持组件模型和用户输入之间的同步。

在幕后,Angular 为每个带有 name 属性和双向绑定的输入元素创建了一个新的控件。

相关的 Angular 指令都带有 ng 前缀,例如 ngForm、ngModel 和 ngModelGroup。

另一种方式是响应式表单。响应式表单的简介和两种方式的比较参见 [Angular 表单简介]()。

模板表达式(template expression)

一种类似 TypeScript 的语法,Angular 用它对数据绑定 (data binding)进行求值。

到[模板表达式]()部分了解更多模板表达式的知识。

令牌(Token)

用于高效查表的不透明标识符(译注:不透明是指你不必了解其细节)。在 Angular 中,DI 令牌用于在依赖注入系统中查找服务提供者。

转译(transpile)

一种翻译过程,它会把一个版本的 JavaScript 转换成另一个版本,比如把下一版的 ES2015 转换成老版本的 ES5。

目录树(tree)

在 schematics 中,一个用 Tree 类表示的虚拟文件系统。 Schematic 规则以一个 tree 对象作为输入,对它们进行操作,并且返回一个新的 tree 对象。

TypeScript

TypeScript 是一种基于 JavaScript 的程序设计语言,以其可选类型系统著称。 TypeScript 提供了编译时类型检查和强大的工具支持(比如代码补齐、重构、内联文档和智能搜索等)。 许多代码编辑器和 IDE 都原生支持 TypeScript 或通过插件提供支持。

TypeScript 是 Angular 的首选语言。要了解更多,参见 [typescriptlang.org]()。

Universal

用来帮 Angular 应用实现服务端渲染的工具。 当与应用集成在一起时,Universal 可以在服务端生成静态页面并用它们来响应来自浏览器的请求。 当浏览器正准备运行完整版应用的时候,这个初始的静态页可以用作一个可快速加载的占位符。

欲知详情,参见 [Angular Universal: 服务端渲染]()。

视图 (view)

视图是可显示元素的最小分组单位,它们会被同时创建和销毁。 Angular 在一个或多个指令 (directive) 的控制下渲染视图。

组件 (component) 类及其关联的模板 (template)定义了一个视图。 具体实现上,视图由一个与该组件相关的 ViewRef 实例表示。 直属于某个组件的视图叫做宿主视图。 通常会把视图组织成一些视图树(view hierarchies)。

视图中各个元素的属性可以动态修改以响应用户的操作,而这些元素的结构(数量或顺序)则不能。你可以通过在它们的视图容器中插入、移动或移除内嵌视图来修改这些元素的结构。

当用户在应用中导航时(比如使用路由器),视图树可以动态加载或卸载。

视图引擎(View Engine)

Angular 9 之前的版本使用的编译和渲染管道。可对比 Ivy。

视图树(View hierarchy)

一棵相关视图的树,它们可以作为一个整体行动。其根视图就是组件的宿主视图。宿主视图可以是内嵌视图树的根,它被收集到了宿主组件上的一个视图容器(ViewContainerRef)中。视图树是 Angular 变更检测的关键部件之一。

视图树和组件树并不是一一对应的。那些嵌入到指定视图树上下文中的视图也可能是其它组件的宿主视图。那些组件可能和宿主组件位于同一个 NgModule 中,也可能属于其它 NgModule。

Web 组件

参见 [自定义元素]()。

工作空间(Workspace)

一组基于 Angular CLI 的 Angular 项目(也就是说应用或库),它们通常共同位于一个单一的源码仓库(比如 git)中。

CLI 的 ng new 命令会在文件系统中创建一个目录(也就是工作空间的根目录)。 在工作空间根目录下,还会创建此工作空间的配置文件(angular.json),并且还会默认初始化一个同名的应用项目。

而用来创建或操作应用和库的命令(比如 add 和 generate)必须在工作区目录下才能执行。

欲知详情,参见 [工作空间配置]()。

工作空间配置(Workspace configuration)

一个名叫 angular.json 的文件,它位于 Angular 工作空间 的根目录下,并为 Angular CLI 提供的或集成的各个构建/开发工具提供工作空间级和项目专属的默认配置项。

欲知详情,参见工作空间配置。

还有一些项目专属的配置文件是给某些工具使用的。比如 package.json 是给 npm 包管理器使用的,tsconfig.json 是给 TypeScript 转译器使用的,而 tslint.json 是给 TSLint 使用的。

欲知详情,参见[工作空间]()和[项目文件结构]()。

区域 (zone)

一组异步任务的执行上下文。它对于调试、性能分析和测试那些包含了异步操作(如事件处理、承诺、远程服务器调用等)的应用是非常有用的。

Angular 应用会运行在一个 Zone 区域中,在这里,它可以对异步事件做出反应,可以通过检查数据变更、利用数据绑定 (data bindings) 来更新信息显示。

Zone 的使用方可以在异步操作完成之前或之后采取行动。

在视图中显示数据

各种 Angular 组件构成了应用的数据结构。 组件关联到的 HTML 模板提供了在 Web 页面的上下文中显示数据的各种方法。 组件类和模板,共同构成了应用数据的一个视图。

在页面上把数据的值及其表现形式组合起来的过程,就叫做数据绑定。 通过将 HTML 模板中的各个控件绑定到组件类中的各种数据属性,你就把数据展示给了用户(并从该用户收集数据)。

另外,你可以使用指令来向模板中添加逻辑,指令告诉 Angular 在渲染页面时要如何修改。

Angular 定义了一种模板语言,它扩展了 HTML 标记,其扩展语法可以让你定义各种各样的数据绑定和逻辑指令。 当渲染完此页面之后,Angular 会解释这种模板语法,来根据你的逻辑更新 HTML 和数据的当前状态。 在你读完模板语法这章之前,本页中的练习可以先让你快速了解下这种模板语法的工作方式。

在这个示例中,你将创建一个带有英雄列表的组件。 你会显示出这些英雄的名字清单,某些情况下,还会在清单下方显示一条消息。 最终的用户界面是这样的:

使用插值显示组件属性

要显示组件的属性,最简单的方式就是通过插值 (interpolation) 来绑定属性名。 要使用插值,就把属性名包裹在双花括号里放进视图模板,如 {{myHero}}。

使用 CLI 命令 ng new displaying-data 创建一个工作空间和一个名叫 displaying-data 的应用。

删除 "app.component.html" 文件,这个范例中不再需要它了。

然后,到 "app.component.ts" 文件中修改组件的模板和代码。

修改完之后,它应该是这样的:

Path:"src/app/app.component.ts"

import { Component } from '@angular/core';@Component({  selector: 'app-root',  template: `    <h1>{{title}}</h1>    <h2>My favorite hero is: {{myHero}}</h2>    `})export class AppComponent {  title = 'Tour of Heroes';  myHero = 'Windstorm';}

再把两个属性 titlemyHero 添加到之前空白的组件中。

修改完的模板会使用双花括号形式的插值来显示这两个模板属性:

Path:"src/app/app.component.ts (template)"

template: `  <h1>{{title}}</h1>  <h2>My favorite hero is: {{myHero}}</h2>  `

模板是包在 ECMAScript 2015 反引号 (`) 中的一个多行字符串。 允许把一个字符串写在多行上, 使 HTML 模板更容易阅读。

Angular 自动从组件中提取 titlemyHero 属性的值,并且把这些值插入浏览器中。当这些属性发生变化时,Angular 就会自动刷新显示。

严格来说,“重新显示”是在某些与视图有关的异步事件之后发生的,例如,按键、定时器完成或对 HTTP 请求的响应。

注:
- 你没有调用 new 来创建 AppComponent 类的实例,是 Angular 替你创建了它。那么它是如何创建的呢?
- @Component 装饰器中指定的 CSS 选择器 selector,它指定了一个叫 <app-root& 的元素。 该元素是 "index.html" 文件里的一个占位符。

Path:"src/index.html (body)"

<body>  <app-root></app-root></body>

当你通过 "main.ts" 中的 AppComponent 类启动时,Angular 在 "index.html" 中查找一个 <app-root> 元素, 然后实例化一个 AppComponent,并将其渲染到 <app-root> 标签中。

运行应用。它应该显示出标题和英雄名:

选择模板来源

@Component 元数据告诉 Angular 要到哪里去找该组件的模板。 你有两种方式存放组件的模板。

你可以使用 @Component 装饰器的 template 属性来定义内联模板。内联模板对于小型示例或测试很有用。

此外,你还可以把模板定义在单独的 HTML 文件中,并且让 @Component 装饰器的 templateUrl 属性指向该文件。这种配置方式通常用于所有比小型测试或示例更复杂的场景中,它也是生成新组件时的默认值。

无论用哪种风格,模板数据绑定在访问组件属性方面都是完全一样的。 这里的应用使用了内联 HTML,是因为该模板很小,而且示例也很简单,用不到外部 HTML 文件。

  • 默认情况下,Angular CLI 命令 ng generate component 在生成组件时会带有模板文件,你可以通过参数来覆盖它:

ng generate component hero -t

初始化

下面的例子使用变量赋值来对组件进行初始化。

export class AppComponent {  title: string;  myHero: string;  constructor() {    this.title = 'Tour of Heroes';    this.myHero = 'Windstorm';  }}

你可以用构造函数来代替这些属性的声明和初始化语句。

添加循环遍历数据的逻辑

*ngFor 指令(Angular 预置)可以让你循环遍历数据。下面的例子使用该指令来显示数组型属性中的所有值。

要显示一个英雄列表,先向组件中添加一个英雄名字数组,然后把 myHero 重定义为数组中的第一个名字。

Path:"src/app/app.component.ts (class)"

export class AppComponent {  title = 'Tour of Heroes';  heroes = ['Windstorm', 'Bombasto', 'Magneta', 'Tornado'];  myHero = this.heroes[0];}

接着,在模板中使用 Angular 的 ngFor 指令来显示 heroes 列表中的每一项。

Path:"src/app/app.component.ts (template)"

template: `  <h1>{{title}}</h1>  <h2>My favorite hero is: {{myHero}}</h2>  <p>Heroes:</p>  <ul>    <li *ngFor="let hero of heroes">      {{ hero }}    </li>  </ul>`

这个界面使用了由 <ul><li> 标签组成的无序列表。<li> 元素里的 *ngFor 是 Angular 的“迭代”指令。 它将 <li> 元素及其子级标记为“迭代模板”:

Path:"src/app/app.component.ts (li)"

<li *ngFor="let hero of heroes">  {{ hero }}</li>

注:
- 不要忘记 *ngFor 中的前导星号 (*)。它是语法中不可或缺的一部分。

注意看 ngFor 双引号表达式中的 hero,它是一个模板输入变量。 更多模板输入变量的信息,见模板语法中的 微语法 (microsyntax)。

Angular 为列表中的每个条目复制一个 <li> 元素,在每个迭代中,把 hero 变量设置为当前条目(英雄)。 Angular 把 hero 变量作为双花括号插值的上下文。

本例中,ngFor 用于显示一个“数组”, 但 ngFor 可以为任何可迭代的 (iterable) 对象重复渲染条目。

现在,英雄们出现在了一个无序列表中。

为数据创建一个类

应用代码直接在组件内部直接定义了数据。 作为演示还可以,但它显然不是最佳实践。

现在使用的是到了一个字符串数组的绑定。在真实的应用中,大多是到一个对象数组的绑定。

要将此绑定转换成使用对象,需要把这个英雄名字数组变成 Hero 对象数组。但首先得有一个 Hero 类。

ng generate class hero

此命令创建了如下代码:

Path:"src/app/hero.ts"

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

你定义了一个类,具有一个构造函数和两个属性:idname

它可能看上去不像是有属性的类,但它确实有,利用的是 TypeScript 提供的简写形式 —— 用构造函数的参数直接定义属性。

来看第一个参数:

Path:"src/app/hero.ts (id)"

public id: number,

这个简写语法做了很多:

  • 声明了一个构造函数参数及其类型。

  • 声明了一个同名的公共属性。

  • 当创建该类的一个实例时,把该属性初始化为相应的参数值。

使用 Hero 类

导入了 Hero 类之后,组件的 heroes 属性就可以返回一个类型化的Hero 对象数组了。

Path:"src/app/app.component.ts (heroes)"

heroes = [  new Hero(1, 'Windstorm'),  new Hero(13, 'Bombasto'),  new Hero(15, 'Magneta'),  new Hero(20, 'Tornado')];myHero = this.heroes[0];

接着,修改模板。 现在它显示的是英雄的 idname。 要修复它,只显示英雄的 name 属性就行了。

Path:"src/app/app.component.ts (template)"

template: `  <h1>{{title}}</h1>  <h2>My favorite hero is: {{myHero.name}}</h2>  <p>Heroes:</p>  <ul>    <li *ngFor="let hero of heroes">      {{ hero.name }}    </li>  </ul>`

显示上还和以前一样,不过代码更清晰了。

通过 NgIf 进行条件显示

有时,应用需要只在特定情况下显示视图或视图的一部分。

来改一下这个例子,如果多于三位英雄,显示一条消息。

Angular 的 ngIf 指令会根据一个布尔条件来显示或移除一个元素。 来看看实际效果,把下列语句加到模板的底部:

Path:"src/app/app.component.ts (message)"

<p *ngIf="heroes.length > 3">There are many heroes!</p>

双引号内的模板表达式 *ngIf="heroes.length > 3" 的外观和行为与 TypeScript 非常相似。当组件的英雄列表包含三个以上的条目时,Angular 会将这段话添加到 DOM 中,这条消息就显示出来了。如果只有三个或更少的条目,Angular 就会省略该段落,也就不会显示任何消息。

双引号中的模板表达式 *ngIf="heros.length > 3",外观和行为很象 TypeScript。 当组件中的英雄列表有三个以上的条目时,Angular 就会把这个段落添加到 DOM 中,于是消息显示了出来。 如果有三个或更少的条目,则 Angular 会省略这些段落,所以不显示消息。

注:
- Angular 并不是在显示和隐藏这条消息,它是在从 DOM 中添加和移除这个段落元素。 这会提高性能,特别是在一些大的项目中有条件地包含或排除一大堆带着很多数据绑定的 HTML 时。

试一下。因为这个数组中有四个条目,所以消息应该显示出来。 回到 "app.component.ts",从英雄数组中删除或注释掉一个元素。 浏览器应该自动刷新,消息应该会消失。

源代码

  1. Path:"src/app/app.component.ts"

    import { Component } from '@angular/core';    import { Hero } from './hero';    @Component({      selector: 'app-root',      template: `      <h1>{{title}}</h1>      <h2>My favorite hero is: {{myHero.name}}</h2>      <p>Heroes:</p>      <ul>        <li *ngFor="let hero of heroes">          {{ hero.name }}          </li>      </ul>      <p *ngIf="heroes.length > 3">There are many heroes!</p>    `    })    export class AppComponent {      title = 'Tour of Heroes';      heroes = [        new Hero(1, 'Windstorm'),        new Hero(13, 'Bombasto'),        new Hero(15, 'Magneta'),        new Hero(20, 'Tornado')      ];      myHero = this.heroes[0];    }

  1. Path:"src/app/hero.ts"

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

  1. Path:"src/app/app.module.ts"

    import { NgModule } from '@angular/core';    import { BrowserModule }  from '@angular/platform-browser';    import { AppComponent } from './app.component';    @NgModule({      imports: [        BrowserModule      ],      declarations: [        AppComponent      ],      bootstrap: [ AppComponent ]    })    export class AppModule { }

  1. Path:"main.ts"

    import { enableProdMode } from '@angular/core';    import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';    import { AppModule } from './app/app.module';    import { environment } from './environments/environment';    if (environment.production) {      enableProdMode();    }    platformBrowserDynamic().bootstrapModule(AppModule);

总结

现在您知道了如何使用:

  • 带有双花括号的插值 (interpolation) 来显示一个组件属性。

  • ngFor 显示数组。

  • 用一个 TypeScript 类来为你的组件描述模型数据并显示模型的属性。

  • ngIf 根据一个布尔表达式有条件地显示一段 HTML。

这是一篇关于 Angular 模板语言的技术大全。 在本篇中解释了模板语言的基本原理,并描述了你将在本教程的学习中遇到的大部分语法。

Angular 应用管理着用户之所见和所为,并通过 Component 类的实例(组件)和面向用户的模板交互来实现这一点。

从使用模型-视图-控制器 (MVC) 或模型-视图-视图模型 (MVVM) 的经验中,很多开发人员都熟悉了组件和模板这两个概念。 在 Angular 中,组件扮演着控制器或视图模型的角色,模板则扮演视图的角色。

这是一篇关于 Angular 模板语言的技术大全。 它解释了模板语言的基本原理,并描述了你将在文档中其它地方遇到的大部分语法。

模板中的 HTML

HTML 是 Angular 模板的语言。几乎所有的 HTML 语法都是有效的模板语法。 但值得注意的例外是 <script>元素,它被禁用了,以阻止脚本注入攻击的风险。(实际上,<script> 只是被忽略了。)

有些合法的 HTML 被用在模板中是没有意义的。比如<html><body><base> 这几个元素在这其中并没有扮演有用的角色。剩下的所有元素基本上就都有其所用了。

可以通过组件和指令来扩展模板中的 HTML 词汇。它们看上去就是新元素和属性。接下来将学习如何通过数据绑定来动态获取/设置 DOM(文档对象模型)的值。

首先看看数据绑定的第一种形式 —— 插值,它展示了模板的 HTML 可以有多丰富。

插值与模板表达式

插值能让你把计算后的字符串合并到 HTML 元素标签之间和属性赋值语句内的文本中。模板表达式则是用来供你求出这些字符串的。

插值 {{...}}

所谓 "插值" 是指将表达式嵌入到标记文本中。 默认情况下,插值会用双花括号 {{ }} 作为分隔符。

在下面的代码片段中,{{ currentCustomer }} 就是插值的例子。

Path:"src/app/app.component.html"

<h3>Current customer: {{ currentCustomer }}</h3>

花括号之间的文本currentCustomer通常是组件属性的名字。Angular 会把这个名字替换为响应组件属性的字符串值。

Path:"src/app/app.component.html"

<p>{{title}}</p><div><img src="{{itemImageUrl}}"></div>

在上面的示例中,Angular 计算 titleitemImageUrl 属性并填充空白,首先显示一些标题文本,然后显示图像。

一般来说,括号间的素材是一个模板表达式,Angular 先对它求值,再把它转换成字符串。 下列插值通过把括号中的两个数字相加说明了这一点:

Path:"src/app/app.component.html"

<!-- "The sum of 1 + 1 is 2" --><p>The sum of 1 + 1 is {{1 + 1}}.</p>

这个表达式可以调用宿主组件的方法,就像下面用的 getVal()

Path:"src/app/app.component.html"

<!-- "The sum of 1 + 1 is not 4" --><p>The sum of 1 + 1 is not {{1 + 1 + getVal()}}.</p>

Angular 对所有双花括号中的表达式求值,把求值的结果转换成字符串,并把它们跟相邻的字符串字面量连接起来。最后,把这个组合出来的插值结果赋给元素或指令的属性。

你看上去似乎正在将结果插入元素标签之间,并将其赋值给属性。 但实际上,插值是一种特殊语法,Angular 会将其转换为属性绑定。

注:
- 如果你想用别的分隔符来代替 {{ }},也可以通过 Component 元数据中的 interpolation 选项来配置插值分隔符。

模板表达式

模板表达式会产生一个值,并出现在双花括号 {{ }} 中。 Angular 执行这个表达式,并把它赋值给绑定目标的属性,这个绑定目标可能是 HTML 元素、组件或指令。

{{1 + 1}} 中所包含的模板表达式是 1 + 1。 在属性绑定中会再次看到模板表达式,它出现在 = 右侧的引号中,就像这样:[property]="expression"

在语法上,模板表达式与 JavaScript 很像。很多 JavaScript 表达式都是合法的模板表达式,但也有一些例外。

你不能使用那些具有或可能引发副作用的 JavaScript 表达式,包括:

  • 赋值 (=, +=, -=, ...)。

  • newtypeofinstanceof 等运算符。

  • 使用 ;, 串联起来的表达式。

  • 自增和自减运算符:++--

  • 一些 ES2015+ 版本的运算符。

和 JavaScript 语法的其它显著差异包括:

  • 不支持位运算,比如 |&

  • 新的模板表达式运算符,例如 |?. 和 !

表达式上下文

典型的表达式上下文就是这个组件实例,它是各种绑定值的来源。 在下面的代码片段中,双花括号中的 recommended 和引号中的 itemImageUrl2 所引用的都是 AppComponent 中的属性。

Path:"src/app/app.component.html"

<h4>{{recommended}}</h4><img [src]="itemImageUrl2">

表达式也可以引用模板中的上下文属性,例如模板输入变量,

let customer,或模板引用变量 #customerInput

Path:"src/app/app.component.html (template input variable)"

<ul>  <li *ngFor="let customer of customers">{{customer.name}}</li></ul>

Path:"src/app/app.component.html (template reference variable)"

<label>Type something:  <input #customerInput>{{customerInput.value}}</label>

表达式中的上下文变量是由模板变量、指令的上下文变量(如果有)和组件的成员叠加而成的。 如果你要引用的变量名存在于一个以上的命名空间中,那么,模板变量是最优先的,其次是指令的上下文变量,最后是组件的成员。

上一个例子中就体现了这种命名冲突。组件具有一个名叫 customer 的属性,而 *ngFor 声明了一个也叫 customer 的模板变量。

注:
- 在 {{customer.name}} 表达式中的 customer 实际引用的是模板变量,而不是组件的属性。

  • 模板表达式不能引用全局命名空间中的任何东西,比如 windowdocument。它们也不能调用 console.logMath.max。 它们只能引用表达式上下文中的成员。

表达式使用指南

当使用模板表达式时,请遵循下列要素:

  1. 非常简单

虽然也可以写复杂的模板表达式,不过最好避免那样做。

属性名或方法调用应该是常态,但偶然使用逻辑取反 ! 也是可以的。 其它情况下,应该把应用程序和业务逻辑限制在组件中,这样它才能更容易开发和测试。

  1. 执行迅速

Angular 会在每个变更检测周期后执行模板表达式。 变更检测周期会被多种异步活动触发,比如 Promise 解析、HTTP 结果、定时器时间、按键或鼠标移动。

表达式应该快速结束,否则用户就会感到拖沓,特别是在较慢的设备上。 当计算代价较高时,应该考虑缓存那些从其它值计算得出的值。

  1. 没有可见的副作用

模板表达式除了目标属性的值以外,不应该改变应用的任何状态。

这条规则是 Angular “单向数据流”策略的基础。 永远不用担心读取组件值可能改变另外的显示值。 在一次单独的渲染过程中,视图应该总是稳定的。

幂等的表达式是最理想的,因为它没有副作用,并且可以提高 Angular 的变更检测性能。 用 Angular 术语来说,幂等表达式总会返回完全相同的东西,除非其依赖值之一发生了变化。

在单独的一次事件循环中,被依赖的值不应该改变。 如果幂等的表达式返回一个字符串或数字,连续调用它两次,也应该返回相同的字符串或数字。 如果幂等的表达式返回一个对象(包括 Date 或 Array),连续调用它两次,也应该返回同一个对象的引用。

注:
- 对于 *ngFor,这种行为有一个例外。*ngFor 具有 trackBy 功能,在迭代对象时它可以处理对象的相等性。详情参见 带 trackBy*ngFor

模板语句

模板语句用来响应由绑定目标(如 HTML 元素、组件或指令)触发的事件。 模板语句将在事件绑定一节看到,它出现在 = 号右侧的引号中,就像这样:(event)="statement"

Path:"src/app/app.component.html"

<button (click)="deleteHero()">Delete hero</button>

模板语句有副作用。 这是事件处理的关键。因为你要根据用户的输入更新应用状态。

响应事件是 Angular 中“单向数据流”的另一面。 在一次事件循环中,可以随意改变任何地方的任何东西。

和模板表达式一样,模板语句使用的语言也像 JavaScript。 模板语句解析器和模板表达式解析器有所不同,特别之处在于它支持基本赋值 (=) 和表达式链 (;)。

然而,某些 JavaScript 语法和模板表达式语法仍然是不允许的:

  • new 运算符

自增和自减运算符:++--

操作并赋值,例如 +=-=

位运算符,例如 |&

管道运算符

语句上下文

和表达式中一样,语句只能引用语句上下文中 —— 通常是正在绑定事件的那个组件实例。

典型的语句上下文就是当前组件的实例。 (click)="deleteHero()" 中的 deleteHero 就是这个数据绑定组件上的一个方法。

Path:"src/app/app.component.html"

<button (click)="deleteHero()">Delete hero</button>

语句上下文可以引用模板自身上下文中的属性。 在下面的例子中,就把模板的 $event 对象、模板输入变量 (let hero)和模板引用变量 (#heroForm)传给了组件中的一个事件处理器方法。

Path:"src/app/app.component.html"

<button (click)="onSave($event)">Save</button><button *ngFor="let hero of heroes" (click)="deleteHero(hero)">{{hero.name}}</button><form #heroForm (ngSubmit)="onSubmit(heroForm)"> ... </form>

模板上下文中的变量名的优先级高于组件上下文中的变量名。在上面的 deleteHero(hero) 中,hero 是一个模板输入变量,而不是组件中的 hero 属性。

语句指南

模板语句不能引用全局命名空间的任何东西。比如不能引用 windowdocument,也不能调用 console.logMath.max

和表达式一样,避免写复杂的模板语句。 常规是函数调用或者属性赋值。

绑定语法:概览

数据绑定是一种机制,用来协调用户可见的内容,特别是应用数据的值。 虽然也可以手动从 HTML 中推送或拉取这些值,但是如果将这些任务转交给绑定框架,应用就会更易于编写、阅读和维护。 你只需声明数据源和目标 HTML 元素之间的绑定关系就可以了,框架会完成其余的工作。

Angular 提供了多种数据绑定方式。绑定类型可以分为三类,按数据流的方向分为:

  1. 单向:从数据源到视图

  • 绑定类型:插值、属性、Attribute、CSS类、样式。

  • 语法:

    {{expression}}    [target]="expression"    bind-target="expression"

  1. 单向:从视图到数据源

  • 绑定类型:事件

  • 语法:

    (target)="statement"    on-target="statement"

  • 双向:视图到数据源到视图

  • 绑定类型:双向

  • 语法:

    [(target)]="expression"    bindon-target="expression"

除插值以外的其它绑定类型在等号的左侧都有一个“目标名称”,由绑定符 []() 包起来, 或者带有前缀:bind-on-bindon-

绑定的“目标”是绑定符内部的属性或事件:[]()[()]

在绑定时可以使用来源指令的每个公共成员。 你无需进行任何特殊操作即可在模板表达式或语句内访问指令的成员。

数据绑定与 HTML

在正常的 HTML 开发过程中,你使用 HTML 元素来创建视觉结构, 通过把字符串常量设置到元素的 attribute 来修改那些元素。

<div class="special">Plain old HTML</div><img src="images/item.png"><button disabled>Save</button>

使用数据绑定,你可以控制按钮状态等各个方面:

Path:"src/app/app.component.html"

<!-- Bind button disabled state to `isUnchanged` property --><button [disabled]="isUnchanged">Save</button>

注:
- 这里绑定到的是按钮的 DOM 元素的 disabled 这个 Property,而不是 Attribute

  • 这是数据绑定的通用规则。数据绑定使用 DOM 元素、组件和指令的 Property,而不是 HTML 的 Attribute

HTML attribute 与 DOM property 的对比

理解 HTML 属性和 DOM 属性之间的区别,是了解 Angular 绑定如何工作的关键。Attribute 是由 HTML 定义的。Property 是从 DOM(文档对象模型)节点访问的。

  • 一些 HTML Attribute 可以 1:1 映射到 Property;例如,“ id”。

  • 某些 HTML Attribute 没有相应的 Property。例如,aria-*。

  • 某些 DOM Property 没有相应的 Attribute。例如,textContent。

重要的是要记住,HTML Attribute 和 DOM Property 是不同的,就算它们具有相同的名称也是如此。 在 Angular 中,HTML Attribute 的唯一作用是初始化元素和指令的状态。

模板绑定使用的是 Property 和事件,而不是 Attribute

编写数据绑定时,你只是在和目标对象的 DOM Property 和事件打交道。

注:
- 该通用规则可以帮助你建立 HTML Attribute 和 DOM Property 的思维模型: 属性负责初始化 DOM 属性,然后完工。Property 值可以改变;Attribute 值则不能。

  • 此规则有一个例外。 可以通过 setAttribute() 来更改 Attribute,接着它会重新初始化相应的 DOM 属性。

示例 1:<input>

当浏览器渲染 <input type="text" value="Sarah"> 时,它会创建一个对应的 DOM 节点,其 value Property 已初始化为 “Sarah”。

<input type="text" value="Sarah">

当用户在 <input> 中输入 Sally 时,DOM 元素的 value Property 将变为 Sally。 但是,如果使用 input.getAttribute('value') 查看 HTML 的 Attribute value,则可以看到该 attribute 保持不变 —— 它返回了 Sarah

HTML 的 value 这个 attribute 指定了初始值;DOM 的 value 这个 property 是当前值。

示例 2:禁用按钮

disabled Attribute 是另一个例子。按钮的 disabled Property 默认为 false,因此按钮是启用的。

当你添加 disabled Attribute 时,仅仅它的出现就将按钮的 disabled Property 初始化成了 true,因此该按钮就被禁用了。

<button disabled>Test Button</button>

添加和删除 disabledAttribute 会禁用和启用该按钮。 但是,Attribute 的值无关紧要,这就是为什么你不能通过编写 <button disabled="false">仍被禁用</button> 来启用此按钮的原因。

要控制按钮的状态,请设置 disabled Property

虽然技术上说你可以设置 [attr.disabled] 属性绑定,但是它们的值是不同的,Property 绑定要求一个布尔值,而其相应的 Attribute 绑定则取决于该值是否为 null。例子如下:

&
lt;input [disabled]="condition ? true : false"&
lt;input [attr.disabled]="condition ? 'disabled' : null"&

通常,要使用 Property 绑定而不是 Attribute 绑定,因为它更直观(是一个布尔值),语法更短,并且性能更高。

绑定类型与绑定目标

数据绑定的目标是 DOM 中的对象。 根据绑定类型,该目标可以是 Property 名(元素、组件或指令的)、事件名(元素、组件或指令的),有时是 Attribute 名。下表中总结了不同绑定类型的目标。

  1. 绑定类型:属性。

  • 目标:元素的 property 、组件的 property 、指令的 property 。

  • 示例:

    <img [src]="heroImageUrl">    <app-hero-detail [hero]="currentHero"></app-hero-detail>    <div [ngClass]="{'special': isSpecial}"></div>

  1. 绑定类型:事件。

  • 目标:元素的事件、组件的事件、指令的事件。

  • 示例:

    <button (click)="onSave()">Save</button>    <app-hero-detail (deleteRequest)="deleteHero()"></app-hero-detail>    <div (myClick)="clicked=$event" clickable>click me</div>

  1. 绑定类型:双向。

  • 目标:事件与 property。

  • 示例:

    <input [(ngModel)]="name">

  1. 绑定类型:Attribute 。

  • 目标:attribute(例外情况)。

  • 示例:

    <button [attr.aria-label]="help">help</button>

  1. 绑定类型:CSS 类。

  • 目标:class property 。

  • 示例:

    <div [class.special]="isSpecial">Special</div>

  1. 绑定类型:样式。

  • 目标:style property 。

  • 示例:

    <button [style.color]="isSpecial ? 'red' : 'green'">

Property 绑定 [property]

使用 Property 绑定到目标元素或指令 @Input() 装饰器的 set 型属性。

单向输入

Property 绑定的值在一个方向上流动,从组件的 Property 变为目标元素的 Property

你不能使用属性绑定从目标元素读取或拉取值。同样的,你也不能使用属性绑定在目标元素上调用方法。如果元素要引发事件,则可以使用事件绑定来监听它们。

示例:

最常见的 Property 绑定将元素的 Property 设置为组件的 Property 值。例子之一是将 img 元素的 src Property 绑定到组件的 itemImageUrl Property

Path:"src/app/app.component.html"

<img [src]="itemImageUrl">

这是绑定到 colSpan Property 的示例。请注意,它不是 colspan,后者是 Attribute,用小写的 s 拼写。

Path:"src/app/app.component.html"

<!-- Notice the colSpan property is camel case --><tr><td [colSpan]="2">Span 2 columns</td></tr>

另一个例子是当组件说它 isUnchanged(未改变)时禁用按钮:

Path:"src/app/app.component.html"

<!-- Bind button disabled state to `isUnchanged` property --><button [disabled]="isUnchanged">Disabled Button</button>

另一个例子是设置指令的属性:

Path:"src/app/app.component.html"

<p [ngClass]="classes">[ngClass] binding to the classes property making this blue</p

另一种方法是设置自定义组件的模型属性 —— 这是一种父级和子级组件进行通信的好办法:

Path:"src/app/app.component.html"

<app-item-detail [childItem]="parentItem"></app-item-detail>

绑定目标

包裹在方括号中的元素属性名标记着目标属性。下列代码中的目标属性是 image 元素的 src 属性。

Path:"src/app/app.component.html"

<img [src]="itemImageUrl">

还有一种使用 bind- 前缀的替代方案:

Path:"src/app/app.component.html"

<img bind-src="itemImageUrl">

在大多数情况下,目标名都是 Property 名,虽然它看起来像 Attribute 名。因此,在这个例子中,src<img> 元素属性的名称。

元素属性可能是最常见的绑定目标,但 Angular 会先去看这个名字是否是某个已知指令的属性名,就像下面的例子中一样:

Path:"src/app/app.component.html"

<p [ngClass]="classes">[ngClass] binding to the classes property making this blue</p>

从技术上讲,Angular 将这个名称与指令的 @Input() 进行匹配,它来自指令的 inputs 数组中列出的 Property 名称之一或是用 @Input() 装饰的属性。这些输入都映射到指令自身的属性。

如果名字没有匹配上已知指令或元素的属性,Angular 就会报告“未知指令”的错误。

注:
- 尽管目标名称通常是 Property 的名称,但是在 Angular 中,有几个常见属性会自动将 Attribute 映射为 Property。这些包括 class / classNamennerHtml / innerHTMLtabindex / abIndex

消除副作用

模板表达的计算应该没有明显的副作用。表达式语言本身或你编写模板表达式的方式在一定程度上有所帮助。你不能为属性绑定表达式中的任何内容赋值,也不能使用递增和递减运算符。

例如,假设你有一个表达式,该表达式调用了具有副作用的属性或方法。该表达式可以调用类似 getFoo() 的函数,只有你知道 getFoo() 做了什么。如果 getFoo() 更改了某些内容,而你恰巧绑定到该内容,则 Angular 可能会也可能不会显示更改后的值。Angular 可能会检测到更改并抛出警告错误。最佳实践是坚持使用属性和返回值并避免副作用的方法。

返回正确的类型

模板表达式的计算结果应该是目标属性所需要的值类型。如果 target 属性需要一个字符串,则返回一个字符串;如果需要一个数字,则返回一个数字;如果需要一个对象,则返回一个对象,依此类推。

在下面的例子中,temDetailComponentchildItem 属性需要一个字符串,而这正是你要发送给属性绑定的内容:

Path:"src/app/app.component.html"

<app-item-detail [childItem]="parentItem"></app-item-detail>

你可以查看 ItemDetailComponent 来确认这一点,它的 @Input 类型设为了字符串:

Path:"src/app/item-detail/item-detail.component.ts (setting the @Input() type)"

@Input() childItem: string;

如你所见,AppComponent 中的 parentItem 是一个字符串,而 ItemDetailComponent 需要的就是字符串:

Path:"src/app/app.component.ts"

parentItem = 'lamp';

传入对象

前面的简单示例演示了传入字符串的情况。要传递对象,其语法和思想是相同的。

在这种情况下,ItemListComponent 嵌套在 AppComponent 中,并且 items 属性需要一个对象数组。

Path:"src/app/app.component.html"

<app-item-list [items]="currentItems"></app-item-list>

items 属性是在 ItemListComponent 中用 Item 类型声明的,并带有 @Input() 装饰器:

Path:"src/app/item-list.component.ts"

@Input() items: Item[];

在此示例应用程序中,Item是具有两个属性的对象。一个 id 和一个 name

Path:"src/app/item.ts"

export interface Item {  id: number;  name: string;}

当另一个文件 "mock-items.ts" 中存在一个条目列表时,你可以在 "app.component.ts" 中指定另一个条目,以便渲染新条目:

Path:"src/app.component.ts"

currentItems = [{  id: 21,  name: 'phone'}];

在这个例子中,你只需要确保你所提供的对象数组的类型,也就是这个 Item 的类型是嵌套组件 `ItemListComponent 所需要的类型。

在此示例中,AppComponen 指定了另一个 item 对象( urrentItems )并将其传给嵌套的 ItemListComponentItemListComponent 之所以能够使用 currentItems 是因为它与 "item.ts" 中定义的 Item 对象的类型相匹配。在 "item.ts" 文件中,ItemListComponent 获得了其对 item 的定义。

方括号

方括号 [] 告诉 Angular 计算该模板表达式。如果省略括号,Angular 会将字符串视为常量,并使用该字符串初始化目标属性 :

Path:"src/app.component.html"

<app-item-detail childItem="parentItem"></app-item-detail>

省略方括号将渲染字符串 parentItem,而不是 parentItem 的值。

一次性字符串初始化

当满足下列条件时,应该省略括号:

  • 目标属性接受字符串值。

  • 字符串是一个固定值,你可以直接将其放入模板中。

  • 这个初始值永不改变。

你通常会以这种方式在标准 HTML 中初始化属性,并且它对指令和组件的属性初始化同样有效。 下面的示例将 StringInitComponent 中的 prefix 属性初始化为固定字符串,而不是模板表达式。Angular 设置它,然后就不管它了。

Path:"src/app/app.component.html"

<app-string-init prefix="This is a one-time initialized string."></app-string-init>

另一方面,[item] 绑定仍然是与组件的 currentItems 属性的实时绑定。

属性绑定与插值

你通常得在插值和属性绑定之间做出选择。 下列这几对绑定做的事情完全相同:

Path:"src/app/app.component.html"

<p><img src="{{itemImageUrl}}"> is the <i>interpolated</i> image.</p><p><img [src]="itemImageUrl"> is the <i>property bound</i> image.</p><p><span>"{{interpolationTitle}}" is the <i>interpolated</i> title.</span></p><p>"<span [innerHTML]="propertyTitle"></span>" is the <i>property bound</i> title.</p>

在许多情况下,插值是属性绑定的便捷替代法。当要把数据值渲染为字符串时,虽然可读性方面倾向于插值,但没有技术上的理由偏爱一种形式。但是,将元素属性设置为非字符串的数据值时,必须使用属性绑定。

内容安全

假设如下恶意内容:

Path:"src/app/app.component.ts"

evilTitle = 'Template <script>alert("evil never sleeps")</script> Syntax';

在组件模板中,内容可以与插值一起使用:

Path:"src/app/app.component.html"

<p><span>"{{evilTitle}}" is the <i>interpolated</i> evil title.</span></p>

幸运的是,Angular 数据绑定对于危险的 HTML 高度戒备。在上述情况下,HTML 将按原样显示,而 Javascript 不执行。Angular 不允许带有 script 标签的 HTML 泄漏到浏览器中,无论是插值还是属性绑定。

不过,在下列示例中,Angular 会在显示值之前先对它们进行无害化处理。

Path:"src/app/app.component.html"

<!-- Angular generates a warning for the following line as it sanitizes them WARNING: sanitizing HTML stripped some content (see http://g.co/ng/security#xss).--> <p>"<span [innerHTML]="evilTitle"></span>" is the <i>property bound</i> evil title.</p>

插值处理 <script> 标记与属性绑定的方式不同,但是这两种方法都可以使内容无害。以下是 evilTitle 示例的浏览器输出。

"Template <script>alert("evil never sleeps")</script> Syntax" is the interpolated evil title."Template Syntax" is the property bound evil title.

attribute、class 和 style 绑定

模板语法为那些不太适合使用属性绑定的场景提供了专门的单向数据绑定形式。

要在运行中的应用查看 Attribute 绑定、类绑定和样式绑定,请参见 现场演练 / 下载范例 特别是对于本节。

attribute 绑定

可以直接使用 Attribute 绑定设置 Attribute 的值。一般来说,绑定时设置的是目标的 Property,而 Attribute 绑定是唯一的例外,它创建和设置的是 Attribute

通常,使用 Property 绑定设置元素的 Property 优于使用字符串设置 Attribute。但是,有时没有要绑定的元素的 Property,所以其解决方案就是 Attribute 绑定。

考虑 ARIASVG。它们都纯粹是 Attribute,不对应于元素的 Property,也不能设置元素的 Property。 在这些情况下,就没有要绑定到的目标 Property

Attribute 绑定的语法类似于 Property 绑定,但其括号之间不是元素的 Property,而是由前缀 attr、点( . )和 Attribute 名称组成。然后,你就可以使用能解析为字符串的表达式来设置该 Attribute 的值,或者当表达式解析为 null 时删除该 Attribute

attribute 绑定的主要用例之一是设置 ARIA attribute(译注:ARIA 指无障碍功能,用于给残障人士访问互联网提供便利), 就像这个例子中一样:

Path:"src/app/app.component.html"

<!-- create and set an aria attribute for assistive technology --><button [attr.aria-label]="actionName">{{actionName}} with Aria</button>

colspancolSpan
注意 colspan AttributecolSpan Property 之间的区别。

如果你这样写:

&
lt;tr&<td colspan="{{1 + 1}}"&Three-Four</td&</tr&

你会收到如下错误:

&
Template parse errors:
Can't bind to 'colspan' since it isn't a known native property

如错误消息所示,<td& 元素没有 colspan 这个 Property。这是正确的,因为 colspan 是一个 Attribute,而 colSpan (colSpan 中的 S 是大写)则是相应的 Property。插值和 Property 绑定只能设置 Property,不能设置 Attribute

相反,你可以使用 Property 绑定并将其改写为:

&Path:"src/app/app.component.html"

lt;!-- Notice the colSpan property is camel case --&
lt;tr&<td [colSpan]="1 + 1"&Three-Four</td&</tr&

类绑定

下面是在普通 HTML 中不用绑定来设置 class Attribute 的方法:

<!-- standard class attribute setting --><div class="foo bar">Some text</div>

你还可以使用类绑定来为一个元素添加和移除 CSS 类。

要创建单个类的绑定,请使用 class 前缀,紧跟一个点(.),再跟上 CSS 类名,比如 [class.foo]="hasFoo"。 当绑定表达式为真值的时候,Angular 就会加上这个类,为假值则会移除,但 undefined 是假值中的例外,参见样式委派 部分。

要想创建多个类的绑定,请使用通用的 [class] 形式来绑定类,而不要带点,比如 [class]="classExpr"。 该表达式可以是空格分隔的类名字符串,或者用一个以类名为键、真假值表达式为值的对象。 当使用对象格式时,Angular 只会加上那些相关的值为真的类名。

一定要注意,在对象型表达式中(如 objectArrayMapSet 等),当这个类列表改变时,对象的引用也必须修改。仅仅修改其属性而不修改对象引用是无法生效的。

如果有多处绑定到了同一个类名,出现的冲突将根据样式的优先级规则进行解决。

绑定类型语法输入类型输入值示例
单个类绑定[class.foo]="hasFoo"boolean OR undefined OR nulltrue, false
多个类绑定[class]="classExpr"string OR {[key: string]: boolean / undefined / null} OR Array<string> | "my-class-1 my-class-2 my-class-3" OR {foo: true, bar: false} OR ['foo', 'bar']`

注:
- 多个类绑定的输入类型按顺序对应一个示例,而不是一个类型对应多个示例。

尽管此技术适用于切换单个类名,但在需要同时管理多个类名时请考虑使用 NgClass 指令。

样式绑定

下面演示了如何不通过绑定在普通 HTML 中设置 style 属性:

<!-- standard style attribute setting --><div style="color: blue">Some text</div>

你还可以通过样式绑定来动态设置样式。

要想创建单个样式的绑定,请以 style 前缀开头,紧跟一个点(.),再跟着 CSS 样式的属性名,比如 [style.width]="width"。 该属性将会被设置为绑定表达式的值,该值通常为字符串。 不过你还可以添加一个单位表达式,比如 em%,这时候该值就要是一个 number 类型。

注:
- 样式属性命名方法可以用中线命名法,像上面的一样 也可以用驼峰式命名法,如 fontSize

如果要切换多个样式,你可以直接绑定到 [style] 属性而不用点(比如,[style]="styleExpr")。赋给 [style] 的绑定表达式通常是一系列样式组成的字符串,比如 "width: 100px; height: 100px;"。

你也可以把该表达式格式化成一个以样式名为键、以样式值为值的对象,比如 {width: '100px', height: '100px'}。一定要注意,对于任何对象型的表达式( 如 objectArrayMapSet 等),当这个样式列表改变时,对象的引用也必须修改。仅仅修改其属性而不修改对象引用是无法生效的。。

如果有多处绑定了同一个样式属性,则会使用样式的优先级规则来解决冲突。

绑定类型语法输入类型输入值示例
单一样式绑定[style.width]="width"string OR undefined OR null"100px"
带单位的单一样式绑定[style.width.px]="width"number OR undefined OR null100
多个样式绑定[style]="styleExpr"string OR {[key: string]: string / undefined / null} OR Array<string>`['width', '100px']

NgStyle 指令可以作为 [style] 绑定的替代指令。但是,应该把上面这种 [style] 样式绑定语法作为首选,因为随着 Angular 中样式绑定的改进,NgStyle 将不再提供重要的价值,并最终在未来的某个版本中删除。

样式的优先级规则

一个 HTML 元素可以把它的 CSS 类列表和样式值绑定到多个来源(例如,来自多个指令的宿主 host 绑定)。

当对同一个类名或样式属性存在多个绑定时,Angular 会使用一组优先级规则来解决冲突,并确定最终哪些类或样式会应用到该元素中。

样式的优先级规则(从高到低)

  1. 模板绑定

  1. 属性绑定(例如 <div [class.foo]="hasFoo"& 或 <div [style.color]="color"&)

1. Map 绑定(例如,<div [class]="classExpr"& 或 <div [style]="styleExpr"& )

2. 静态值(例如 <div class="foo"& 或 <div style="color: blue"& )

  1. 指令宿主绑定

  1. 属性绑定(例如,host: {'[class.foo]': 'hasFoo'} 或 host: {'[style.color]': 'color'} )

1. Map 绑定(例如,host: {'[class]': 'classExpr'} 或者 host: {'[style]': 'styleExpr'} )

2. 静态值(例如,host: {'class': 'foo'} 或 host: {'style': 'color: blue'} )

  1. 组件宿主绑定

  1. 属性绑定(例如,host: {'[class.foo]': 'hasFoo'} 或 host: {'[style.color]': 'color'} )

1. Map 绑定(例如,host: {'[class]': 'classExpr'} 或者 host: {'[style]': 'styleExpr'} )

2. 静态值(例如,host: {'class': 'foo'} 或 host: {'style': 'color: blue'} )

某个类或样式绑定越具体,它的优先级就越高。

对具体类(例如 [class.foo] )的绑定优先于一般化的 [class] 绑定,对具体样式(例如 [style.bar] )的绑定优先于一般化的 [style] 绑定。

Path:"src/app/app.component.html"

<h3>Basic specificity</h3><!-- The `class.special` binding will override any value for the `special` class in `classExpr`.  --><div [class.special]="isSpecial" [class]="classExpr">Some text.</div><!-- The `style.color` binding will override any value for the `color` property in `styleExpr`.  --><div [style.color]="color" [style]="styleExpr">Some text.</div>

当处理不同来源的绑定时,也适用这种基于具体度的规则。 某个元素可能在声明它的模板中有一些绑定、在所匹配的指令中有一些宿主绑定、在所匹配的组件中有一些宿主绑定。

模板中的绑定是最具体的,因为它们直接并且唯一地应用于该元素,所以它们具有最高的优先级。

指令的宿主绑定被认为不太具体,因为指令可以在多个位置使用,所以它们的优先级低于模板绑定。

指令经常会增强组件的行为,所以组件的宿主绑定优先级最低。

Path:"src/app/app.component.html"

<h3>Source specificity</h3><!-- The `class.special` template binding will override any host binding to the `special` class set by `dirWithClassBinding` or `comp-with-host-binding`.--><comp-with-host-binding [class.special]="isSpecial" dirWithClassBinding>Some text.</comp-with-host-binding><!-- The `style.color` template binding will override any host binding to the `color` property set by `dirWithStyleBinding` or `comp-with-host-binding`. --><comp-with-host-binding [style.color]="color" dirWithStyleBinding>Some text.</comp-with-host-binding>

另外,绑定总是优先于静态属性。

在下面的例子中,class[class] 具有相似的具体度,但 [class] 绑定优先,因为它是动态的。

Path:"src/app/app.component.html"

<h3>Dynamic vs static</h3><!-- If `classExpr` has a value for the `special` class, this value will override the `class="special"` below --><div class="special" [class]="classExpr">Some text.</div><!-- If `styleExpr` has a value for the `color` property, this value will override the `style="color: blue"` below --><div style="color: blue" [style]="styleExpr">Some text.</div>

委托优先级较低的样式

更高优先级的样式可以使用 undefined 值“委托”给低级的优先级样式。虽然把 style 属性设置为 null 可以确保该样式被移除,但把它设置为 undefined 会导致 Angular 回退到该样式的次高优先级。

例如,考虑以下模板:

Path:"src/app/app.component.html"

<comp-with-host-binding dirWithHostBinding></comp-with-host-binding>

想象一下,dirWithHostBinding 指令和 comp-with-host-binding 组件都有 [style.width] 宿主绑定。在这种情况下,如果 dirWithHostBinding 把它的绑定设置为 undefined,则 width 属性将回退到 comp-with-host-binding 主机绑定的值。但是,如果 dirWithHostBinding 把它的绑定设置为 null,那么 width 属性就会被完全删除。

事件绑定 (event)

事件绑定允许你监听某些事件,比如按键、鼠标移动、点击和触屏。

Angular 的事件绑定语法由等号左侧带圆括号的目标事件和右侧引号中的模板语句组成。 下面事件绑定监听按钮的点击事件。每当点击发生时,都会调用组件的 onSave() 方法。

目标事件

如前所述,其目标就是此按钮的单击事件。

Path:"src/app/app.component.html"

<button (click)="onSave($event)">Save</button>

有些人更喜欢带 on- 前缀的备选形式,称之为规范形式:

Path:"src/app/app.component.html"

<button on-click="onSave($event)">on-click Save</button>

元素事件可能是更常见的目标,但 Angular 会先看这个名字是否能匹配上已知指令的事件属性,就像下面这个例子:

Path:"src/app/app.component.html"

<h4>myClick is an event on the custom ClickDirective:</h4><button (myClick)="clickMessage=$event" clickable>click with myClick</button>{{clickMessage}}

如果这个名字没能匹配到元素事件或已知指令的输出属性,Angular 就会报“未知指令”错误。

$event 和事件处理语句

在事件绑定中,Angular 会为目标事件设置事件处理器。

当事件发生时,这个处理器会执行模板语句。 典型的模板语句通常涉及到响应事件执行动作的接收器,例如从 HTML 控件中取得值,并存入模型。

绑定会通过名叫 $event 的事件对象传递关于此事件的信息(包括数据值)。

事件对象的形态取决于目标事件。如果目标事件是原生 DOM 元素事件, $event 就是 DOM 事件对象,它有像 targettarget.value 这样的属性。

考虑这个示例:

Path:"src/app/app.component.html"

<input [value]="currentItem.name"       (input)="currentItem.name=$event.target.value" >without NgModel

上面的代码在把输入框的 value 属性绑定到 name 属性。 要监听对值的修改,代码绑定到输入框的 input 事件。 当用户造成更改时,input 事件被触发,并在包含了 DOM 事件对象 ($event) 的上下文中执行这条语句。

要更新 name 属性,就要通过路径 $event.target.value 来获取更改后的值。

如果事件属于指令(回想一下,组件是指令的一种),那么 $event 具体是什么由指令决定。

使用 EventEmitter 实现自定义事件

通常,指令使用 Angular EventEmitter 来触发自定义事件。 指令创建一个 EventEmitter 实例,并且把它作为属性暴露出来。 指令调用 EventEmitter.emit(payload) 来触发事件,可以传入任何东西作为消息载荷。 父指令通过绑定到这个属性来监听事件,并通过 $event 对象来访问载荷。

假设 ItemDetailComponent 用于显示英雄的信息,并响应用户的动作。 虽然 ItemDetailComponent 包含删除按钮,但它自己并不知道该如何删除这个英雄。 最好的做法是触发事件来报告“删除用户”的请求。

下面的代码节选自 ItemDetailComponent:

Path:"src/app/item-detail/item-detail.component.html (template)"

<img src="{{itemImageUrl}}" [style.display]="displayNone"><span [style.text-decoration]="lineThrough">{{ item.name }}</span><button (click)="delete()">Delete</button>

Path:"src/app/item-detail/item-detail.component.ts (deleteRequest)"

// This component makes a request but it can't actually delete a hero.@Output() deleteRequest = new EventEmitter<Item>();delete() {  this.deleteRequest.emit(this.item);  this.displayNone = this.displayNone ? '' : 'none';  this.lineThrough = this.lineThrough ? '' : 'line-through';}

组件定义了 deleteRequest 属性,它是 EventEmitter 实例。 当用户点击删除时,组件会调用 delete() 方法,让 EventEmitter 发出一个 Item 对象。

现在,假设有个宿主的父组件,它绑定了 ItemDetailComponentdeleteRequest 事件。

Path:"src/app/app.component.html (event-binding-to-component)"

<app-item-detail (deleteRequest)="deleteItem($event)" [item]="currentItem"></app-item-detail>

deleteRequest 事件触发时,Angular 调用父组件的 deleteItem 方法, 在 $event 变量中传入要删除的英雄(来自 ItemDetail)。

模板语句有副作用

虽然模板表达式不应该有副作用,但是模板语句通常会有。这里的 deleteItem() 方法就有一个副作用:它删除了一个条目。

删除这个英雄会更新模型,还可能触发其它修改,包括向远端服务器的查询和保存。 这些变更通过系统进行扩散,并最终显示到当前以及其它视图中。

双向绑定 [(...)]

双向绑定为你的应用程序提供了一种在组件类及其模板之间共享数据的方式。

双向绑定的基础知识

双向绑定会做两件事:

  1. 设置特定的元素属性。

  1. 监听元素的变更事件。

Angular 为此提供了一种特殊的双向数据绑定语法 [()][()] 语法将属性绑定的括号 [] 与事件绑定的括号 () 组合在一起。

[()] 语法很容易想明白:该元素具有名为 x 的可设置属性和名为 xChange 的相应事件。 SizerComponent 就是用的这种模式。它具有一个名为 size 的值属性和一个与之相伴的 sizeChange 事件:

Path:"src/app/sizer.component.ts"

import { Component, Input, Output, EventEmitter } from '@angular/core';@Component({  selector: 'app-sizer',  templateUrl: './sizer.component.html',  styleUrls: ['./sizer.component.css']})export class SizerComponent {  @Input()  size: number | string;  @Output() sizeChange = new EventEmitter<number>();  dec() { this.resize(-1); }  inc() { this.resize(+1); }  resize(delta: number) {    this.size = Math.min(40, Math.max(8, +this.size + delta));    this.sizeChange.emit(this.size);  }}

Path:"src/app/sizer.component.html"

<div>  <button (click)="dec()" title="smaller">-</button>  <button (click)="inc()" title="bigger">+</button>  <label [style.font-size.px]="size">FontSize: {{size}}px</label></div>

size 的初始值来自属性绑定的输入值。单击按钮可在最小值/最大值范围内增大或减小 size,然后带上调整后的大小发出 sizeChange 事件。

下面的例子中,AppComponent.fontSize 被双向绑定到 SizerComponent

Path:"src/app/app.component.html (two-way-1)"

<app-sizer [(size)]="fontSizePx"></app-sizer><div [style.font-size.px]="fontSizePx">Resizable Text</div>

AppComponent.fontSizePx 建立初始 SizerComponent.size 值。

Path:"src/app/app.component.ts"

fontSizePx = 16;

单击按钮就会通过双向绑定更新 AppComponent.fontSizePx。修改后的 AppComponent.fontSizePx 值将传递到样式绑定,从而使显示的文本更大或更小。

双向绑定语法实际上是属性绑定和事件绑定的语法糖。 Angular 将 izerComponent 的绑定分解成这样:

Path:"src/app/app.component.html (two-way-2)"

<app-sizer [size]="fontSizePx" (sizeChange)="fontSizePx=$event"></app-sizer>

$event 变量包含了 SizerComponent.sizeChange 事件的荷载。 当用户点击按钮时,Angular 将 $event 赋值给 AppComponent.fontSizePx

表单中的双向绑定

与单独的属性绑定和事件绑定相比,双向绑定语法非常方便。将双向绑定与 HTML 表单元素(例如 <input><select>)一起使用会很方便。但是,没有哪个原生 HTML 元素会遵循 x 值和 xChange 事件的命名模式。

内置指令

Angular 提供了两种内置指令:属性型指令和结构型指令。

内置属性型指令

属性型指令会监听并修改其它 HTML 元素和组件的行为、AttributeProperty。 它们通常被应用在元素上,就好像它们是 HTML 属性一样,因此得名属性型指令。

许多 NgModule(例如 RouterModuleFormsModule )都定义了自己的属性型指令。最常见的属性型指令如下:

  1. NgClass —— 添加和删除一组 CSS 类。

用 ngClass 同时添加或删除几个 CSS 类。

Path:"src/app/app.component.html"

    <!-- toggle the "special" class on/off with a property -->    <div [ngClass]="isSpecial ? 'special' : ''">This div is special</div>

注:
- 要添加或删除单个类,请使用类绑定而不是 NgClass

考虑一个 setCurrentClasses() 组件方法,该方法设置一个组件属性 currentClasses,该对象具有一个根据其它三个组件属性的 true / false 状态来添加或删除三个 CSS 类的对象。该对象的每个键(key)都是一个 CSS 类名。如果要添加上该类,则其值为 true,反之则为 false。

Path:"src/app/app.component.html"

    currentClasses: {};    setCurrentClasses() {      // CSS classes: added/removed per current state of component properties      this.currentClasses =  {        'saveable': this.canSave,        'modified': !this.isUnchanged,        'special':  this.isSpecial      };    }

NgClass 属性绑定到 currentClasses,根据它来设置此元素的 CSS 类:

Path:"src/app/app.component.html"

    <div [ngClass]="currentClasses">This div is initially saveable, unchanged, and special.</div>

注:
- 请记住,在这种情况下,你要在初始化时和它依赖的属性发生变化时调用 setCurrentClasses()

  1. NgStyle —— 添加和删除一组 HTML 样式。

使用 NgStyle 根据组件的状态同时动态设置多个内联样式。

不用 NgStyle

有些情况下,要考虑使用样式绑定来设置单个样式值,而不使用 NgStyle

Path:"src/app/app.component.html"

    <div [style.font-size]="isSpecial ? 'x-large' : 'smaller'">      This div is x-large or smaller.    </div>

但是,如果要同时设置多个内联样式,请使用 NgStyle 指令。

下面的例子是一个 setCurrentStyles() 方法,它基于该组件另外三个属性的状态,用一个定义了三个样式的对象设置了 currentStyles 属性。

Path:"src/app/app.component.ts"

    currentStyles: {};    setCurrentStyles() {      // CSS styles: set per current state of component properties      this.currentStyles = {        'font-style':  this.canSave      ? 'italic' : 'normal',        'font-weight': !this.isUnchanged ? 'bold'   : 'normal',        'font-size':   this.isSpecial    ? '24px'   : '12px'      };    }

ngStyle 属性绑定到 currentStyles,来根据它设置此元素的样式:

Path:"src/app/app.component.html"

    <div [ngStyle]="currentStyles">      This div is initially italic, normal weight, and extra large (24px).    </div>

注:
- 请记住,无论是在初始时还是其依赖的属性发生变化时,都要调用 setCurrentStyles()

  1. NgModel —— 将数据双向绑定添加到 HTML 表单元素。

NgModel 指令允许你显示数据属性并在用户进行更改时更新该属性。这是一个例子:

Path:"src/app/app.component.html (NgModel example)"

    <label for="example-ngModel">[(ngModel)]:</label>    <input [(ngModel)]="currentItem.name" id="example-ngModel">

导入 FormsModule 以使用 ngModel

要想在双向数据绑定中使用 ngModel 指令,必须先导入 FormsModule 并将其添加到 NgModuleimports 列表中。要了解关于 FormsModulengModel 的更多信息,参见表单一章。

记住,要导入 FormsModule 才能让 [(ngModel)] 可用,如下所示:

Path:"src/app/app.module.ts (FormsModule import)"

    import { FormsModule } from '@angular/forms'; // <--- JavaScript import from Angular    /* . . . */    @NgModule({    /* . . . */      imports: [        BrowserModule,        FormsModule // <--- import into the NgModule      ],    /* . . . */    })    export class AppModule { }

通过分别绑定到 <input> 元素的 value 属性和 input 事件,可以达到同样的效果:

Path:"src/app/app.component.html"

    <label for="without">without NgModel:</label>    <input [value]="currentItem.name" (input)="currentItem.name=$event.target.value" id="without">

为了简化语法,ngModel 指令把技术细节隐藏在其输入属性 ngModel 和输出属性 ngModelChange 的后面:

Path:"src/app/app.component.html"

    <label for="example-change">(ngModelChange)="...name=$event":</label>    <input [ngModel]="currentItem.name" (ngModelChange)="currentItem.name=$event" id="example-change">

ngModel 输入属性会设置该元素的值,并通过 ngModelChange 的输出属性来监听元素值的变化。

NgModel 和值访问器

这些技术细节是针对每种具体元素的,因此 NgModel 指令仅适用于通过 ControlValueAccessor 适配过这种协议的元素。Angular 已经为所有基本的 HTML 表单元素提供了值访问器,表单一章示范了如何绑定到它们。

在编写适当的值访问器之前,不能将 [(ngModel)] 应用于非表单的原生元素或第三方自定义组件。欲知详情,参见DefaultValueAccessor上的 API 文档。

你不一定非用为所编写的 Angular 组件提供值访问器,因为你还可以把值属性和事件属性命名为符合 Angular 的基本双向绑定语法的形式,并完全跳过 NgModel。双向绑定部分的 sizer 是此技术的一个示例。

单独的 ngModel 绑定是对绑定到元素的原生属性方式的一种改进,但你可以使用 [(ngModel)] 语法来通过单个声明简化绑定:

Path:"src/app/app.component.html"

    <label for="example-ngModel">[(ngModel)]:</label>    <input [(ngModel)]="currentItem.name" id="example-ngModel">

[(ngModel)] 语法只能设置数据绑定属性。如果你要做得更多,可以编写扩展表单。例如,下面的代码将 <input> 值更改为大写:

Path:"src/app/app.component.html"

    <input [ngModel]="currentItem.name" (ngModelChange)="setUppercaseName($event)" id="example-uppercase">

这里是所有这些变体的动画,包括这个大写转换的版本:

内置结构型指令

结构型指令的职责是 HTML 布局。 它们塑造或重塑 DOM 的结构,这通常是通过添加、移除和操纵它们所附加到的宿主元素来实现的。

常见的内置结构型指令:

  1. NgIf —— 从模板中创建或销毁子视图。

你可以通过将 NgIf 指令应用在宿主元素上来从 DOM 中添加或删除元素。在此示例中,将指令绑定到了条件表达式,例如 isActive

Path:"src/app/app.component.html"

    <app-item-detail *ngIf="isActive" [item]="item"></app-item-detail>

注:
- 不要忘了 ngIf 前面的星号(*)。

isActive 表达式返回真值时,NgIf 会把 ItemDetailComponent 添加到 DOM 中。当表达式为假值时,NgIf

用户输入

当用户点击链接、按下按钮或者输入文字时,这些用户动作都会产生 DOM 事件。 本章解释如何使用 Angular 事件绑定语法把这些事件绑定到事件处理器。

绑定到用户输入事件

你可以使用 Angular 事件绑定机制来响应任何 DOM 事件。 许多 DOM 事件是由用户输入触发的。绑定这些事件可以获取用户输入。

要绑定 DOM 事件,只要把 DOM 事件的名字包裹在圆括号中,然后用放在引号中的模板语句对它赋值就可以了。

下例展示了一个事件绑定,它实现了一个点击事件处理器:

Path:"src/app/click-me.component.ts"

<button (click)="onClickMe()">Click me!</button>

等号左边的 (click) 表示把按钮的点击事件作为绑定目标。 等号右边引号中的文本是模板语句,通过调用组件的 onClickMe 方法来响应这个点击事件。

写绑定时,需要知道模板语句的执行上下文。 出现在模板语句中的每个标识符都属于特定的上下文对象。 这个对象通常都是控制此模板的 Angular 组件。 上例中只显示了一行 HTML,那段 HTML 片段属于下面这个组件:

Path:"src/app/click-me.component.ts"

@Component({  selector: 'app-click-me',  template: `    <button (click)="onClickMe()">Click me!</button>    {{clickMessage}}`})export class ClickMeComponent {  clickMessage = '';  onClickMe() {    this.clickMessage = 'You are my hero!';  }}

当用户点击按钮时,Angular 调用 ClickMeComponentonClickMe 方法。

通过 $event 对象取得用户输入

DOM 事件可以携带可能对组件有用的信息。 本节将展示如何绑定输入框的 keyup 事件,在每个敲击键盘时获取用户输入。

下面的代码监听 keyup 事件,并将整个事件载荷 ($event) 传给组件的事件处理器。

Path:"src/app/keyup.components.ts (template v.1)"

template: `  <input (keyup)="onKey($event)">  <p>{{values}}</p>`

当用户按下并释放一个按键时,触发 keyup 事件,Angular 在 $event 变量提供一个相应的 DOM 事件对象,上面的代码将它作为参数传给 onKey() 方法。

Path:"src/app/keyup.components.ts (class v.1)"

export class KeyUpComponent_v1 {  values = '';  onKey(event: any) { // without type info    this.values += event.target.value + ' | ';  }}

$event 对象的属性取决于 DOM 事件的类型。例如,鼠标事件与输入框编辑事件包含了不同的信息。

所有标准 DOM 事件对象都有一个 target 属性, 引用触发该事件的元素。 在本例中,target 是 <input> 元素, event.target.value 返回该元素的当前内容。

在组件的 onKey() 方法中,把输入框的值和分隔符 (|) 追加组件的 values 属性。 使用插值来把存放累加结果的 values 属性回显到屏幕上。

假设用户输入字母“abc”,然后用退格键一个一个删除它们。 用户界面将显示:

a | ab | abc | ab | a | |

或者,你可以用 event.key 替代 event.target.value,积累各个按键本身,这样同样的用户输入可以产生:

&
a | b | c | backspace | backspace | backspace |

$event的类型

上例将 $event 转换为 any 类型。 这样简化了代码,但是有成本。 没有任何类型信息能够揭示事件对象的属性,防止简单的错误。

下面的例子,使用了带类型方法:

Path:"src/app/keyup.components.ts (class v.1 - typed )"

export class KeyUpComponent_v1 {  values = '';  onKey(event: KeyboardEvent) { // with type info    this.values += (event.target as HTMLInputElement).value + ' | ';  }}

$event 的类型现在是 KeyboardEvent。 不是所有的元素都有 value 属性,所以它将 target 转换为输入元素。 OnKey 方法更加清晰地表达了它期望从模板得到什么,以及它是如何解析事件的。

传入 $event 是靠不住的做法

类型化事件对象揭露了重要的一点,即反对把整个 DOM 事件传到方法中,因为这样组件会知道太多模板的信息。 只有当它知道更多它本不应了解的 HTML 实现细节时,它才能提取信息。 这就违反了模板(用户看到的)和组件(应用如何处理用户数据)之间的分离关注原则。

下面将介绍如何用模板引用变量来解决这个问题。

从一个模板引用变量中获得用户输入

还有另一种获取用户数据的方式:使用 Angular 的模板引用变量。 这些变量提供了从模块中直接访问元素的能力。 在标识符前加上井号 (#) 就能声明一个模板引用变量。

下面的例子使用了局部模板变量,在一个超简单的模板中实现按键反馈功能。

Path:"src/app/loop-back.component.ts"

@Component({  selector: 'app-loop-back',  template: `    <input #box (keyup)="0">    <p>{{box.value}}</p>  `})export class LoopbackComponent { }

这个模板引用变量名叫 box,在 <input> 元素声明,它引用 <input> 元素本身。 代码使用 box 获得输入元素的 value 值,并通过插值把它显示在 <p> 标签中。

这个模板完全是完全自包含的。它没有绑定到组件,组件也没做任何事情。

在输入框中输入,就会看到每次按键时,显示也随之更新了。

除非你绑定一个事件,否则这将完全无法工作。

只有在应用做了些异步事件(如击键),Angular 才更新绑定(并最终影响到屏幕)。 本例代码将 keyup 事件绑定到了数字 0,这可能是最短的模板语句了。 虽然这个语句不做什么,但它满足 Angular 的要求,所以 Angular 将更新屏幕。

从模板变量获得输入框比通过 $event 对象更加简单。 下面的代码重写了之前 keyup 示例,它使用变量来获得用户输入。

Path:"src/app/keyup.components.ts (v2)"

@Component({  selector: 'app-key-up2',  template: `    <input #box (keyup)="onKey(box.value)">    <p>{{values}}</p>  `})export class KeyUpComponent_v2 {  values = '';  onKey(value: string) {    this.values += value + ' | ';  }}

这个方法最漂亮的一点是:组件代码从视图中获得了干净的数据值。再也不用了解 $event 变量及其结构了。

按键事件过滤(通过 key.enter)

(keyup) 事件处理器监听每一次按键。 有时只在意回车键,因为它标志着用户结束输入。 解决这个问题的一种方法是检查每个 $event.keyCode,只有键值是回车键时才采取行动。

更简单的方法是:绑定到 Angular 的 keyup.enter 模拟事件。 然后,只有当用户敲回车键时,Angular 才会调用事件处理器。

Path:"src/app/keyup.components.ts (v3)"

@Component({  selector: 'app-key-up3',  template: `    <input #box (keyup.enter)="onEnter(box.value)">    <p>{{value}}</p>  `})export class KeyUpComponent_v3 {  value = '';  onEnter(value: string) { this.value = value; }}

下面展示了它的工作原理。

失去焦点事件 (blur)

前上例中,如果用户没有先按回车键,而是移开了鼠标,点击了页面中其它地方,输入框的当前值就会丢失。 只有当用户按下了回车键候,组件的 values属性才能更新。

下面通过同时监听输入框的回车键和失去焦点事件来修正这个问题。

Path:"src/app/keyup.components.ts (v4)"

@Component({  selector: 'app-key-up4',  template: `    <input #box      (keyup.enter)="update(box.value)"      (blur)="update(box.value)">    <p>{{value}}</p>  `})export class KeyUpComponent_v4 {  value = '';  update(value: string) { this.value = value; }}

结合使用

现在,在一个微型应用中一起使用它们,应用能显示一个英雄列表,并把新的英雄加到列表中。 用户可以通过输入英雄名和点击“添加”按钮来添加英雄。

下面就是“简版英雄指南”组件。

Path:"src/app/little-tour.component.ts"

@Component({  selector: 'app-little-tour',  template: `    <input #newHero      (keyup.enter)="addHero(newHero.value)"      (blur)="addHero(newHero.value); newHero.value='' ">    <button (click)="addHero(newHero.value)">Add</button>    <ul><li *ngFor="let hero of heroes">{{hero}}</li></ul>  `})export class LittleTourComponent {  heroes = ['Windstorm', 'Bombasto', 'Magneta', 'Tornado'];  addHero(newHero: string) {    if (newHero) {      this.heroes.push(newHero);    }  }}

源代码

  1. Path:"src/app/click-me.component.ts"

    import { Component } from '@angular/core';    @Component({      selector: 'app-click-me',      template: `        <button (click)="onClickMe()">Click me!</button>        {{clickMessage}}`    })    export class ClickMeComponent {      clickMessage = '';      onClickMe() {        this.clickMessage = 'You are my hero!';      }    }

  1. Path:"src/app/keyup.components.ts"

    import { Component } from '@angular/core';    @Component({      selector: 'app-key-up1',      template: `        <input (keyup)="onKey($event)">        <p>{{values}}</p>      `    })    export class KeyUpComponent_v1 {      values = '';      /*      onKey(event: any) { // without type info        this.values += event.target.value + ' | ';      }      */      onKey(event: KeyboardEvent) { // with type info        this.values += (event.target as HTMLInputElement).value + ' | ';      }    }    //////////////////////////////////////////    @Component({      selector: 'app-key-up2',      template: `        <input #box (keyup)="onKey(box.value)">        <p>{{values}}</p>      `    })    export class KeyUpComponent_v2 {      values = '';      onKey(value: string) {        this.values += value + ' | ';      }    }    //////////////////////////////////////////    @Component({      selector: 'app-key-up3',      template: `        <input #box (keyup.enter)="onEnter(box.value)">        <p>{{value}}</p>      `    })    export class KeyUpComponent_v3 {      value = '';      onEnter(value: string) { this.value = value; }    }    //////////////////////////////////////////    @Component({      selector: 'app-key-up4',      template: `        <input #box          (keyup.enter)="update(box.value)"          (blur)="update(box.value)">        <p>{{value}}</p>      `    })    export class KeyUpComponent_v4 {      value = '';      update(value: string) { this.value = value; }    }

  1. Path:"src/app/loop-back.component.ts"

    import { Component } from '@angular/core';    @Component({      selector: 'app-loop-back',      template: `        <input #box (keyup)="0">        <p>{{box.value}}</p>      `    })    export class LoopbackComponent { }

  1. Path:"src/app/little-tour.component.ts"

    import { Component } from '@angular/core';    @Component({      selector: 'app-little-tour',      template: `        <input #newHero          (keyup.enter)="addHero(newHero.value)"          (blur)="addHero(newHero.value); newHero.value='' ">        <button (click)="addHero(newHero.value)">Add</button>        <ul><li *ngFor="let hero of heroes">{{hero}}</li></ul>      `    })    export class LittleTourComponent {      heroes = ['Windstorm', 'Bombasto', 'Magneta', 'Tornado'];      addHero(newHero: string) {        if (newHero) {          this.heroes.push(newHero);        }      }    }

Angular 还支持被动事件侦听器。例如,你可以使用以下步骤使滚动事件变为被动监听。

  1. 在 src 目录下创建一个 "zone-flags.ts" 文件。

  1. 往这个文件中添加如下语句。

(window as any)['__zone_symbol__PASSIVE_EVENTS'] = ['scroll'];

  1. 在 "src/polyfills.ts" 文件中,导入 "zone.js" 之前,先导入新创建的 "zone-flags" 文件。

import './zone-flags';import 'zone.js/dist/zone';  // Included with Angular CLI.

经过这些步骤,你添加 scroll 事件的监听器时,它就是被动(passive)的。

小结

  • 使用模板变量来引用元素 — newHero 模板变量引用了 <input> 元素。 你可以在 <input> 的任何兄弟或子级元素中引用 newHero

  • 传递数值,而非元素 — 获取输入框的值并将它传给组件的 addHero,而不要传递 newHero

  • 保持模板语句简单 — (blur) 事件被绑定到两个 JavaScript 语句。 第一句调用 addHero。第二句 newHero.value='' 在添加新英雄到列表中后清除输入框。

属性型指令用于改变一个 DOM 元素的外观或行为。

指令概览

在 Angular 中有三种类型的指令:

  1. 组件 — 拥有模板的指令

  1. 结构型指令 — 通过添加和移除 DOM 元素改变 DOM 布局的指令

  1. 属性型指令 — 改变元素、组件或其它指令的外观和行为的指令。

组件是这三种指令中最常用的。 你在快速上手例子中第一次见到组件。

结构型指令修改视图的结构。例如,NgForNgIf。 要了解更多,参见结构型指令 指南。

属性型指令改变一个元素的外观或行为。例如,内置的 NgStyle 指令可以同时修改元素的多个样式。

创建一个简单的属性型指令

属性型指令至少需要一个带有 @Directive 装饰器的控制器类。该装饰器指定了一个用于标识属性的选择器。 控制器类实现了指令需要的指令行为。

本章展示了如何创建一个简单的属性型指令 appHighlight,当用户把鼠标悬停在一个元素上时,改变它的背景色。你可以这样用它:

Path:"src/app/app.component.html (applied)"

<p appHighlight>Highlight me!</p>

注:
- 指令不支持命名空间。

编写指令代码

在命令行窗口下用 CLI 命令 ng generate directive 创建指令类文件。

ng generate directive highlight

CLI 会创建 "src/app/highlight.directive.ts" 及相应的测试文件("src/app/highlight.directive.spec.ts"),并且在根模块 AppModule 中声明这个指令类。

注:
- 和组件一样,这些指令也必须在Angular 模块中进行声明。

生成的 "src/app/highlight.directive.ts" 文件如下:

Path:"src/app/highlight.directive.ts"

import { Directive } from '@angular/core';@Directive({  selector: '[appHighlight]'})export class HighlightDirective {  constructor() { }}

这里导入的 Directive 符号提供了 Angular 的 @Directive 装饰器。

@Directive 装饰器的配置属性中指定了该指令的 CSS 属性型选择器 [appHighlight]

这里的方括号([])表示它的属性型选择器。 Angular 会在模板中定位每个拥有名叫 appHighlight 属性的元素,并且为这些元素加上本指令的逻辑。

正因如此,这类指令被称为 属性选择器。

紧跟在 @Directive 元数据之后的就是该指令的控制器类,名叫 HighlightDirective,它包含了该指令的逻辑(目前为空逻辑)。然后导出 HighlightDirective,以便它能在别处访问到。

现在,把刚才生成的 "src/app/highlight.directive.ts" 编辑成这样:

Path:"src/app/highlight.directive.ts"

import { Directive, ElementRef } from '@angular/core';@Directive({  selector: '[appHighlight]'})export class HighlightDirective {    constructor(el: ElementRef) {       el.nativeElement.style.backgroundColor = 'yellow';    }}

import 语句还从 Angular 的 core 库中导入了一个 ElementRef 符号。

你可以在指令的构造函数中使用 ElementRef 来注入宿主 DOM 元素的引用,也就是你放置 appHighlight 的那个元素。

ElementRef 通过其 nativeElement 属性给你了直接访问宿主 DOM 元素的能力。

这里的第一个实现把宿主元素的背景色设置为了黄色。

使用属性型指令

要想使用这个新的 HighlightDirective,就往根组件 AppComponent 的模板中添加一个 <p> 元素,并把该指令作为一个属性使用。

Path:"src/app/app.component.html"

<p appHighlight>Highlight me!</p>

运行这个应用以查看 HighlightDirective 的实际效果。

ng serve

总结:Angular 在宿主元素 <p> 上发现了一个 appHighlight 属性。 然后它创建了一个 HighlightDirective 类的实例,并把所在元素的引用注入到了指令的构造函数中。 在构造函数中,该指令把 <p> 元素的背景设置为了黄色。

响应用户引发的事件

当前,appHighlight 只是简单的设置元素的颜色。 这个指令应该在用户鼠标悬浮一个元素时,设置它的颜色。

先把 HostListener 加进导入列表中。

Path:"src/app/highlight.directive.ts (imports)"

import { Directive, ElementRef, HostListener } from '@angular/core';

然后使用 HostListener 装饰器添加两个事件处理器,它们会在鼠标进入或离开时进行响应。

Path:"src/app/highlight.directive.ts (mouse-methods)"

@HostListener('mouseenter') onMouseEnter() {  this.highlight('yellow');}@HostListener('mouseleave') onMouseLeave() {  this.highlight(null);}private highlight(color: string) {  this.el.nativeElement.style.backgroundColor = color;}

@HostListener 装饰器让你订阅某个属性型指令所在的宿主 DOM 元素的事件,在这个例子中就是 <p>

当然,你可以通过标准的 JavaScript 方式手动给宿主 DOM 元素附加一个事件监听器。 但这种方法至少有三个问题:

  • 必须正确的书写事件监听器。

  • 当指令被销毁的时候,必须拆卸事件监听器,否则会导致内存泄露。

  • 必须直接和 DOM API 打交道,应该避免这样做。

这些处理器委托了一个辅助方法来为 DOM 元素(el)设置颜色。

这个辅助方法(highlight)被从构造函数中提取了出来。 修改后的构造函数只负责声明要注入的元素 el: ElementRef

Path:"src/app/highlight.directive.ts (constructor)"

constructor(private el: ElementRef) { }

下面是修改后的指令代码:

Path:"src/app/highlight.directive.ts"

import { Directive, ElementRef, HostListener } from '@angular/core';@Directive({  selector: '[appHighlight]'})export class HighlightDirective {  constructor(private el: ElementRef) { }  @HostListener('mouseenter') onMouseEnter() {    this.highlight('yellow');  }  @HostListener('mouseleave') onMouseLeave() {    this.highlight(null);  }  private highlight(color: string) {    this.el.nativeElement.style.backgroundColor = color;  }}

运行本应用并确认:当把鼠标移到 p 上的时候,背景色就出现了,而移开时就消失了。

使用 @Input 数据绑定向指令传递值

高亮的颜色目前是硬编码在指令中的,这不够灵活。 在这一节中,你应该让指令的使用者可以指定要用哪种颜色进行高亮。

先从 @angular/core 中导入 Input

Path:"src/app/highlight.directive.ts (imports)"

import { Directive, ElementRef, HostListener, Input } from '@angular/core';

然后把 highlightColor 属性添加到指令类中,就像这样:

Path:"src/app/highlight.directive.ts (highlightColor)"

@Input() highlightColor: string;

绑定到 @Input 属性

注意看 @Input 装饰器。它往类上添加了一些元数据,从而让该指令的 highlightColor 能用于绑定。

它之所以称为输入属性,是因为数据流是从绑定表达式流向指令内部的。 如果没有这个元数据,Angular 就会拒绝绑定,参见稍后了解更多。

试试把下列指令绑定变量添加到 AppComponent 的模板中:

Path:"src/app/app.component.html (excerpt)"

<p appHighlight highlightColor="yellow">Highlighted in yellow</p><p appHighlight [highlightColor]="'orange'">Highlighted in orange</p>

color 属性添加到 AppComponent 中:

Path:"src/app/app.component.ts (class)"

export class AppComponent {  color = 'yellow';}

让它通过属性绑定来控制高亮颜色。

Path:"src/app/app.component.html (excerpt)"

<p appHighlight [highlightColor]="color">Highlighted with parent component's color</p>

很不错,但如果可以在应用该指令时在同一个属性中设置颜色就更好了,就像这样:

Path:"src/app/app.component.html (color)"

<p [appHighlight]="color">Highlight me!</p>

[appHighlight] 属性同时做了两件事:把这个高亮指令应用到了 <p> 元素上,并且通过属性绑定设置了该指令的高亮颜色。 你复用了该指令的属性选择器 [appHighlight] 来同时完成它们。 这是清爽、简约的语法。

你还要把该指令的 highlightColor 改名为 appHighlight,因为它是颜色属性目前的绑定名。

Path:"src/app/highlight.directive.ts (renamed to match directive selector)"

@Input() appHighlight: string;

这可不好。因为 appHighlight 是一个糟糕的属性名,而且不能反映该属性的意图。

绑定到 @Input 别名

幸运的是,你可以随意命名该指令的属性,并且给它指定一个用于绑定的别名。

恢复原始属性名,并在 @Input 的参数中把该选择器指定为别名。

Path:"src/app/highlight.directive.ts (color property with alias)"

@Input('appHighlight') highlightColor: string;

在指令内部,该属性叫 highlightColor,在外部,你绑定到它地方,它叫 appHighlight

这是最好的结果:理想的内部属性名,理想的绑定语法:

Path:"src/app/app.component.html (color)"

<p [appHighlight]="color">Highlight me!</p>

现在,你通过别名绑定到了 highlightColor 属性,并修改 onMouseEnter() 方法来使用它。 如果有人忘了绑定到 appHighlight,那就用红色进行高亮。

Path:"src/app/highlight.directive.ts (mouse enter)"

@HostListener('mouseenter') onMouseEnter() {  this.highlight(this.highlightColor || 'red');}

这是最终版本的指令类。

Path:"src/app/highlight.directive.ts (excerpt)"

import { Directive, ElementRef, HostListener, Input } from '@angular/core';@Directive({  selector: '[appHighlight]'})export class HighlightDirective {  constructor(private el: ElementRef) { }  @Input('appHighlight') highlightColor: string;  @HostListener('mouseenter') onMouseEnter() {    this.highlight(this.highlightColor || 'red');  }  @HostListener('mouseleave') onMouseLeave() {    this.highlight(null);  }  private highlight(color: string) {    this.el.nativeElement.style.backgroundColor = color;  }}

测试程序

凭空想象该指令如何工作可不容易。你需要把 AppComponent 改成一个测试程序,它让你可以通过单选按钮来选取高亮颜色,并且把你选取的颜色绑定到指令中。

把 "app.component.html" 修改成这样:

Path:"src/app/app.component.html (v2)"

<h1>My First Attribute Directive</h1><h4>Pick a highlight color</h4><div>  <input type="radio" name="colors" (click)="color='lightgreen'">Green  <input type="radio" name="colors" (click)="color='yellow'">Yellow  <input type="radio" name="colors" (click)="color='cyan'">Cyan</div><p [appHighlight]="color">Highlight me!</p>

修改 AppComponent.color,让它不再有初始值。

Path:"src/app/app.component.ts (class)"

export class AppComponent {  color: string;}

下面是测试程序和指令的动图。

绑定到第二个属性

本例的指令只有一个可定制属性,真实的应用通常需要更多。

目前,默认颜色(它在用户选取了高亮颜色之前一直有效)被硬编码为红色。应该允许模板的开发者设置默认颜色。

把第二个名叫 defaultColor 的输入属性添加到 HighlightDirective 中:

Path:"src/app/highlight.directive.ts (defaultColor)"

@Input() defaultColor: string;

修改该指令的 onMouseEnter,让它首先尝试使用 highlightColor 进行高亮,然后用 defaultColor,如果它们都没有指定,那就用红色作为后备。

Path:"src/app/highlight.directive.ts (mouse-enter)"

@HostListener('mouseenter') onMouseEnter() {  this.highlight(this.highlightColor || this.defaultColor || 'red');}

当已经绑定过 appHighlight 属性时,要如何绑定到第二个属性呢?

像组件一样,你也可以绑定到指令的很多属性,只要把它们依次写在模板中就行了。 开发者可以绑定到 AppComponent.color,并且用紫罗兰色作为默认颜色,代码如下:

Path:"src/app/highlight.directive.ts (defaultColor)"

<p [appHighlight]="color" defaultColor="violet">  Highlight me too!</p>

Angular 之所以知道 defaultColor 绑定属于 HighlightDirective,是因为你已经通过 @Input 装饰器把它设置成了公共属性。

当这些代码完成时,测试程序工作时的动图如下:

源代码

  1. Path:"src/app/app.component.ts"

    import { Component } from '@angular/core';    @Component({      selector: 'app-root',      templateUrl: './app.component.html'    })    export class AppComponent {      color: string;    }

  1. Path:"src/app/app.component.html"

    <h1>My First Attribute Directive</h1>    <h4>Pick a highlight color</h4>    <div>      <input type="radio" name="colors" (click)="color='lightgreen'">Green      <input type="radio" name="colors" (click)="color='yellow'">Yellow      <input type="radio" name="colors" (click)="color='cyan'">Cyan    </div>    <p [appHighlight]="color">Highlight me!</p>    <p [appHighlight]="color" defaultColor="violet">      Highlight me too!    </p>

  1. Path:"src/app/highlight.directive.ts"

    /* tslint:disable:member-ordering */    import { Directive, ElementRef, HostListener, Input } from '@angular/core';    @Directive({      selector: '[appHighlight]'    })    export class HighlightDirective {      constructor(private el: ElementRef) { }      @Input() defaultColor: string;      @Input('appHighlight') highlightColor: string;      @HostListener('mouseenter') onMouseEnter() {        this.highlight(this.highlightColor || this.defaultColor || 'red');      }      @HostListener('mouseleave') onMouseLeave() {        this.highlight(null);      }      private highlight(color: string) {        this.el.nativeElement.style.backgroundColor = color;      }    }

  1. Path:"src/app/app.module.ts"

    import { NgModule } from '@angular/core';    import { BrowserModule } from '@angular/platform-browser';    import { AppComponent } from './app.component';    import { HighlightDirective } from './highlight.directive';    @NgModule({      imports: [ BrowserModule ],      declarations: [        AppComponent,        HighlightDirective      ],      bootstrap: [ AppComponent ]    })    export class AppModule { }

  1. Path:"src/app/main.ts"

    import { enableProdMode } from '@angular/core';    import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';    import { AppModule } from './app/app.module';    import { environment } from './environments/environment';    if (environment.production) {      enableProdMode();    }    platformBrowserDynamic().bootstrapModule(AppModule);

  1. Path:"src/app/index.html"

    <!DOCTYPE html>    <html lang="en">      <head>        <meta charset="UTF-8">        <title>Attribute Directives</title>        <base href="/">        <meta name="viewport" content="width=device-width, initial-scale=1">      </head>      <body>        <app-root></app-root>      </body>    </html>

小结

  • 构建一个属性型指令,它用于修改一个元素的行为。

  • 把一个指令应用到模板中的某个元素上。

  • 响应事件以改变指令的行为。

  • 把值绑定到指令中。

附录

问题:为什么要加@Input

在这个例子中 hightlightColorHighlightDirective 的一个输入型属性。你见过它没有用别名时的代码:

Path:"src/app/highlight.directive.ts (color)"

@Input() highlightColor: string;

也见过用别名时的代码:

Path:"src/app/highlight.directive.ts (color)"

@Input('appHighlight') highlightColor: string;

无论哪种方式,@Input 装饰器都告诉 Angular,该属性是公共的,并且能被父组件绑定。 如果没有 @Input,Angular 就会拒绝绑定到该属性。

但你以前也曾经把模板 HTML 绑定到组件的属性,而且从来没有用过 @Input。 差异何在?

差异在于信任度不同。 Angular 把组件的模板看做从属于该组件的。 组件和它的模板默认会相互信任。 这也就是意味着,组件自己的模板可以绑定到组件的任意属性,无论是否使用了 @Input 装饰器。

但组件或指令不应该盲目的信任其它组件或指令。 因此组件或指令的属性默认是不能被绑定的。 从 Angular 绑定机制的角度来看,它们是私有的,而当添加了 @Input 时,Angular 绑定机制才会把它们当成公共的。 只有这样,它们才能被其它组件或属性绑定。

你可以根据属性名在绑定中出现的位置来判定是否要加 @Input

当它出现在等号右侧的模板表达式中时,它属于模板所在的组件,不需要 @Input 装饰器。

当它出现在等号左边的方括号([ ])中时,该属性属于其它组件或指令,它必须带有 @Input 装饰器。

试用此原理分析下列示例:

Path:"src/app/app.component.html (color)"

<p [appHighlight]="color">Highlight me!</p>

  • color 属性位于右侧的绑定表达式中,它属于模板所在的组件。 该模板和组件相互信任。因此 color 不需要 @Input 装饰器。

  • appHighlight 属性位于左侧,它引用了 HighlightDirective 中一个带别名的属性,它不是模板所属组件的一部分,因此存在信任问题。 所以,该属性必须带 @Input 装饰器。

什么是结构型指令?

结构型指令的职责是 HTML 布局。 它们塑造或重塑 DOM 的结构,比如添加、移除或维护这些元素。

像其它指令一样,你可以把结构型指令应用到一个宿主元素上。 然后它就可以对宿主元素及其子元素做点什么。

结构型指令非常容易识别。 在这个例子中,星号(*)被放在指令的属性名之前。

Path:"src/app/app.component.html (ngif)"

<div *ngIf="hero" class="name">{{hero.name}}</div>

没有方括号,没有圆括号,只是把 *ngIf 设置为一个字符串。

在这个例子中,你将学到星号(*)这个简写方法,而这个字符串是一个微语法,而不是通常的模板表达式。 Angular 会解开这个语法糖,变成一个 <ng-template> 标记,包裹着宿主元素及其子元素。 每个结构型指令都可以用这个模板做点不同的事情。

三个常用的内置结构型指令 —— NgIfNgForNgSwitch...。 你在模板语法一章中学过它,并且在 Angular 文档的例子中到处都在用它。下面是模板中的例子:

Path:"src/app/app.component.html (built-in)"

<div *ngIf="hero" class="name">{{hero.name}}</div><ul>  <li *ngFor="let hero of heroes">{{hero.name}}</li></ul><div [ngSwitch]="hero?.emotion">  <app-happy-hero    *ngSwitchCase="'happy'"    [hero]="hero"></app-happy-hero>  <app-sad-hero      *ngSwitchCase="'sad'"      [hero]="hero"></app-sad-hero>  <app-confused-hero *ngSwitchCase="'confused'" [hero]="hero"></app-confused-hero>  <app-unknown-hero  *ngSwitchDefault           [hero]="hero"></app-unknown-hero></div>

指令的拼写形式

你将看到指令同时具有两种拼写形式大驼峰 UpperCamelCase 和小驼峰 lowerCamelCase,比如你已经看过的 NgIfngIf。 这里的原因在于,NgIf 引用的是指令的类名,而 ngIf 引用的是指令的属性名*。

指令的类名拼写成大驼峰形式(NgIf),而它的属性名则拼写成小驼峰形式(ngIf)。 本章会在谈论指令的属性和工作原理时引用指令的类名,在描述如何在 HTML 模板中把该指令应用到元素时,引用指令的属性名。

还有另外两种 Angular 指令,在本开发指南的其它地方有讲解:(1) 组件 (2) 属性型指令。

组件可以在原生 HTML 元素中管理一小片区域的 HTML。从技术角度说,它就是一个带模板的指令。

属性型指令会改变某个元素、组件或其它指令的外观或行为。 比如,内置的NgStyle指令可以同时修改元素的多个样式。

你可以在一个宿主元素上应用多个属性型指令,但只能应用一个结构型指令。

NgIf 案例分析

NgIf 是一个很好的结构型指令案例:它接受一个布尔值,并据此让一整块 DOM 树出现或消失。

Path:"src/app/app.component.html (ngif-true)"

<p *ngIf="true">  Expression is true and ngIf is true.  This paragraph is in the DOM.</p><p *ngIf="false">  Expression is false and ngIf is false.  This paragraph is not in the DOM.</p>

ngIf 指令并不是使用 CSS 来隐藏元素的。它会把这些元素从 DOM 中物理删除。 使用浏览器的开发者工具就可以确认这一点。

可以看到第一段文字出现在了 DOM 中,而第二段则没有,在第二段的位置上是一个关于“绑定”的注释。

当条件为假时,NgIf 会从 DOM 中移除它的宿主元素,取消它监听过的那些 DOM 事件,从 Angular 变更检测中移除该组件,并销毁它。 这些组件和 DOM 节点可以被当做垃圾收集起来,并且释放它们占用的内存。

为什么是移除而不是隐藏?

指令也可以通过把它的 display 风格设置为 none 而隐藏不需要的段落。

Path:"src/app/app.component.html (display-none)"

<p [style.display]="'block'">  Expression sets display to "block".  This paragraph is visible.</p><p [style.display]="'none'">  Expression sets display to "none".  This paragraph is hidden but still in the DOM.</p>

当不可见时,这个元素仍然留在 DOM 中。

对于简单的段落,隐藏和移除之间的差异影响不大,但对于资源占用较多的组件是不一样的。 当隐藏掉一个元素时,组件的行为还在继续 —— 它仍然附加在它所属的 DOM 元素上, 它也仍在监听事件。Angular 会继续检查哪些能影响数据绑定的变更。 组件原本要做的那些事情仍在继续。

虽然不可见,组件及其各级子组件仍然占用着资源,而这些资源如果分配给别人可能会更有用。 在性能和内存方面的负担相当可观,响应度会降低,而用户却可能无法从中受益。

当然,从积极的一面看,重新显示这个元素会非常快。 组件以前的状态被保留着,并随时可以显示。 组件不用重新初始化 —— 该操作可能会比较昂贵。 这时候隐藏和显示就成了正确的选择。

但是,除非有非常强烈的理由来保留它们,否则你会更倾向于移除用户看不见的那些 DOM 元素,并且使用 NgIf 这样的结构型指令来收回用不到的资源。

同样的考量也适用于每一个结构型指令,无论是内置的还是自定义的。 你应该提醒自己慎重考虑添加元素、移除元素以及创建和销毁组件的后果。

星号(*)前缀

你可能注意到了指令名的星号(*)前缀,并且困惑于为什么需要它以及它是做什么的。

这里的 *ngIf 会在 hero 存在时显示英雄的名字。

Path:"src/app/app.component.html (asterisk)"

<div *ngIf="hero" class="name">{{hero.name}}</div>

星号是一个用来简化更复杂语法的“语法糖”。 从内部实现来说,Angular 把 *ngIf 属性 翻译成一个 <ng-template> 元素 并用它来包裹宿主元素,代码如下:

Path:"src/app/app.component.html (ngif-template)"

<ng-template [ngIf]="hero">  <div class="name">{{hero.name}}</div></ng-template>

  • *ngIf 指令被移到了 <ng-template> 元素上。在那里它变成了一个属性绑定 [ngIf]

  • <div> 上的其余部分,包括它的 class 属性在内,移到了内部的 <ng-template> 元素上。

第一种形态永远不会真的渲染出来。 只有最终产出的结果才会出现在 DOM 中。

Angular 会在真正渲染的时候填充 <ng-template> 的内容,并且把 <ng-template> 替换为一个供诊断用的注释。

NgForNgSwitch...指令也都遵循同样的模式。

*ngFor 内幕

Angular 会把 *ngFor 用同样的方式把星号(*)语法的 template属性转换成 <ng-template>元素。

这里有一个 NgFor 的全特性应用,同时用了这两种写法:

Path:"src/app/app.component.html (inside-ngfor)"

<div *ngFor="let hero of heroes; let i=index; let odd=odd; trackBy: trackById" [class.odd]="odd">  ({{i}}) {{hero.name}}</div><ng-template ngFor let-hero [ngForOf]="heroes" let-i="index" let-odd="odd" [ngForTrackBy]="trackById">  <div [class.odd]="odd">({{i}}) {{hero.name}}</div></ng-template>

它明显比 ngIf 复杂得多,确实如此。 NgFor 指令比本章展示过的 NgIf 具有更多的必选特性和可选特性。 至少 NgFor 会需要一个循环变量(let hero)和一个列表(heroes)。

你可以通过把一个字符串赋值给 ngFor 来启用这些特性,这个字符串使用 Angular 的微语法。

ngFor 字符串之外的每一样东西都会留在宿主元素(<div&)上,也就是说它移到了 <ng-template& 内部。 在这个例子中,[class.odd]="odd" 留在了 <div& 上。

微语法

Angular 微语法能让你通过简短的、友好的字符串来配置一个指令。 微语法解析器把这个字符串翻译成 <ng-template> 上的属性:

  • let 关键字声明一个模板输入变量,你会在模板中引用它。本例子中,这个输入变量就是 heroiodd。 解析器会把 let herolet ilet odd 翻译成命名变量 let-herolet-ilet-odd

  • 微语法解析器接收 oftrackby,把它们首字母大写(of -> Of, trackBy -> TrackBy), 并且给它们加上指令的属性名(ngFor)前缀,最终生成的名字是 ngForOfngForTrackBy。 这两个最终生成的名字是 NgFor 的输入属性,指令据此了解到列表是 heroes,而 track-by 函数是 trackById

  • NgFor 指令在列表上循环,每个循环中都会设置和重置它自己的上下文对象上的属性。 这些属性包括但不限于 indexodd 以及一个特殊的属性名 $implicit(隐式变量)。

  • let-ilet-odd 变量是通过 let i=indexlet odd=odd 来定义的。 Angular 把它们设置为上下文对象中的 indexodd 属性的当前值。

  • 这里并没有指定 let-hero 的上下文属性。它的来源是隐式的。 Angular 将 let-hero 设置为此上下文中 $implicit 属性的值, 它是由 NgFor 用当前迭代中的英雄初始化的。

  • API 参考手册中描述了 NgFor 指令的其它属性和上下文属性。

  • NgForOf 指令实现了 NgFor。请到 NgForOf API 参考手册中了解 NgForOf 指令的更多属性及其上下文属性。

编写你自己的结构型指令

当你编写自己的结构型指令时,也可以利用这些微语法机制。 例如,Angular 中的微语法允许你写成 <div *ngFor="let item of items">{{item}}</div> 而不是 <ng-template ngFor let-item [ngForOf]="items"><div>{{item}}</div></ng-template>。 以下各节提供了有关约束、语法和微语法翻译方式的详细信息。

约束

微语法必须满足以下要求:

  • 它必须可被预先了解,以便 IDE 可以解析它而无需知道指令的底层语义或已存在哪些指令。

  • 它必须转换为 DOM 中的“键-值”属性。

语法

当你编写自己的结构型指令时,请使用以下语法:

*:prefix="( :let | :expression ) (';' | ',')? ( :let | :as | :keyExp )*"

下表描述了微语法的每个组成部分。

组成部分描述
prefixHTML 属性键(attribute key)
keyHTML 属性键(attribute key)
local模板中使用的局部变量名
export指令使用指定名称导出的值
expression标准 Angular 表达式
keyExp = :key ":"? :expression ("as" :local)? ";"?
let = "let" :local "=" :export ";"?
as = :export "as" :local ";"?

翻译

将微语法转换为常规的绑定语法,如下所示:

微语法翻译结果
prefix 和裸表达式[prefix]="expression"
keyExp[prefixKey] "表达式" (let-prefixKey="export"),注意 prefix 已经加成了 key
letlet-local="export"

微语法样例

下表说明了 Angular 会如何解开微语法。

微语法解语法糖后
*ngFor="let item of [1,2,3]"<ng-template ngFor let-item [ngForOf]="[1,2,3]">
*ngFor="let item of [1,2,3] as items; trackBy: myTrack; index as i"<ng-template ngFor let-item [ngForOf]="[1,2,3]" let-items="ngForOf" [ngForTrackBy]="myTrack" let-i="index">
*ngIf="exp"<ng-template [ngIf]="exp">
*ngIf="exp as value"<ng-template [ngIf]="exp" let-value="ngIf">

注:
- 这些微语法机制在你写自己的结构型指令时也同样有效。

模板输入变量

模板输入变量是这样一种变量,你可以在单个实例的模板中引用它的值。 这个例子中有好几个模板输入变量:heroiodd。 它们都是用 let 作为前导关键字。

模板输入变量和模板引用变量是不同的,无论是在语义上还是语法上。

你使用 let 关键字(如 let hero)在模板中声明一个模板输入变量。 这个变量的范围被限制在所重复模板的单一实例上。 事实上,你可以在其它结构型指令中使用同样的变量名。

而声明模板引用变量使用的是给变量名加 # 前缀的方式(#var)。 一个引用变量引用的是它所附着到的元素、组件或指令。它可以在整个模板的任意位置访问。

模板输入变量和引用变量具有各自独立的命名空间。let hero 中的 hero#hero 中的 hero 并不是同一个变量。

每个宿主元素上只能有一个结构型指令

有时你会希望只有当特定的条件为真时才重复渲染一个 HTML 块。 你可能试过把 *ngFor*ngIf 放在同一个宿主元素上,但 Angular 不允许。这是因为你在一个元素上只能放一个结构型指令。

原因很简单。结构型指令可能会对宿主元素及其子元素做很复杂的事。当两个指令放在同一个元素上时,谁先谁后?NgIf 优先还是 NgFor 优先?NgIf 可以取消 NgFor 的效果吗? 如果要这样做,Angular 应该如何把这种能力泛化,以取消其它结构型指令的效果呢?

对这些问题,没有办法简单回答。而禁止多个结构型指令则可以简单地解决这个问题。 这种情况下有一个简单的解决方案:把 *ngIf 放在一个"容器"元素上,再包装进 *ngFor 元素。 这个元素可以使用ng-container,以免引入一个新的 HTML 层级。

NgSwitch 内幕

Angular 的 NgSwitch 实际上是一组相互合作的指令:NgSwitchNgSwitchCaseNgSwitchDefault

例子如下:

Path:"src/app/app.component.html (ngswitch)"

<div [ngSwitch]="hero?.emotion">  <app-happy-hero    *ngSwitchCase="'happy'"    [hero]="hero"></app-happy-hero>  <app-sad-hero      *ngSwitchCase="'sad'"      [hero]="hero"></app-sad-hero>  <app-confused-hero *ngSwitchCase="'confused'" [hero]="hero"></app-confused-hero>  <app-unknown-hero  *ngSwitchDefault           [hero]="hero"></app-unknown-hero></div>

一个值(hero.emotion)被被赋值给了 NgSwitch,以决定要显示哪一个分支。

NgSwitch 本身不是结构型指令,而是一个属性型指令,它控制其它两个 switch 指令的行为。 这也就是为什么你要写成 [ngSwitch] 而不是 *ngSwitch 的原因。

NgSwitchCaseNgSwitchDefault 都是结构型指令。 因此你要使用星号(*)前缀来把它们附着到元素上。 NgSwitchCase 会在它的值匹配上选项值的时候显示它的宿主元素。 NgSwitchDefault 则会当没有兄弟 NgSwitchCase 匹配上时显示它的宿主元素。

指令所在的元素就是它的宿主元素。 <happy-hero&*ngSwitchCase 的宿主元素。 <unknown-hero&*ngSwitchDefault 的宿主元素。

像其它的结构型指令一样,NgSwitchCaseNgSwitchDefault 也可以解开语法糖,变成 <ng-template> 的形式。

Path:"src/app/app.component.html (ngswitch-template)"

<div [ngSwitch]="hero?.emotion">  <ng-template [ngSwitchCase]="'happy'">    <app-happy-hero [hero]="hero"></app-happy-hero>  </ng-template>  <ng-template [ngSwitchCase]="'sad'">    <app-sad-hero [hero]="hero"></app-sad-hero>  </ng-template>  <ng-template [ngSwitchCase]="'confused'">    <app-confused-hero [hero]="hero"></app-confused-hero>  </ng-template >  <ng-template ngSwitchDefault>    <app-unknown-hero [hero]="hero"></app-unknown-hero>  </ng-template></div>

优先使用星号(*)语法

星号(*)语法比不带语法糖的形式更加清晰。 如果找不到单一的元素来应用该指令,可以使用<ng-container>作为该指令的容器。

虽然很少有理由在模板中使用结构型指令的属性形式和元素形式,但这些幕后知识仍然是很重要的,即:Angular 会创建 <ng-template>,还要了解它的工作原理。 当需要写自己的结构型指令时,你就要使用 <ng-template>

<ng-template>元素

<ng-template>是一个 Angular 元素,用来渲染 HTML。 它永远不会直接显示出来。 事实上,在渲染视图之前,Angular 会把 <ng-template> 及其内容替换为一个注释。

如果没有使用结构型指令,而仅仅把一些别的元素包装进 <ng-template> 中,那些元素就是不可见的。 在下面的这个短语"Hip! Hip! Hooray!"中,中间的这个 "Hip!"(欢呼声) 就是如此。

Path:"src/app/app.component.html (template-tag)"

<p>Hip!</p><ng-template>  <p>Hip!</p></ng-template><p>Hooray!</p>

Angular 抹掉了中间的那个 "Hip!",让欢呼声显得不再那么热烈了。

结构型指令会让 <ng-template> 正常工作,在你写自己的结构型指令时就会看到这一点。

使用<ng-container>把一些兄弟元素归为一组

通常都需要一个根元素作为结构型指令的宿主。 列表元素(<li>)就是一个典型的供 NgFor 使用的宿主元素。

Path:"src/app/app.component.html (ngfor-li)"

<li *ngFor="let hero of heroes">{{hero.name}}</li>

当没有这样一个单一的宿主元素时,你就可以把这些内容包裹在一个原生的 HTML 容器元素中,比如 <div>,并且把结构型指令附加到这个"包裹"上。

Path:"src/app/app.component.html (ngif)"

<div *ngIf="hero" class="name">{{hero.name}}</div>

但引入另一个容器元素(通常是 <span><div>)来把一些元素归到一个单一的根元素下,通常也会带来问题。注意,是"通常"而不是"总会"。

这种用于分组的元素可能会破坏模板的外观表现,因为 CSS 的样式既不曾期待也不会接受这种新的元素布局。 比如,假设你有下列分段布局。

Path:"src/app/app.component.html (ngif-span)"

<p>  I turned the corner  <span *ngIf="hero">    and saw {{hero.name}}. I waved  </span>  and continued on my way.</p>

而你的 CSS 样式规则是应用于 <p> 元素下的 <span> 的。

Path:"src/app/app.component.css (p-span)"

p span { color: red; font-size: 70%; }

这样渲染出来的段落就会非常奇怪。

本来为其它地方准备的 p span 样式,被意外的应用到了这里。

另一个问题是:有些 HTML 元素需要所有的直属下级都具有特定的类型。 比如,<select> 元素要求直属下级必须为 <option>,那就没办法把这些选项包装进 <div><span> 中。

如果这样做:

Path:"src/app/app.component.html (select-span)"

<div>  Pick your favorite hero  (<label><input type="checkbox" checked (change)="showSad = !showSad">show sad</label>)</div><select [(ngModel)]="hero">  <span *ngFor="let h of heroes">    <span *ngIf="showSad || h.emotion !== 'sad'">      <option [ngValue]="h">{{h.name}} ({{h.emotion}})</option>    </span>  </span></select>

下拉列表就是空的。浏览器不会显示 <span> 中的 <option>。

<ng-container> 的救赎

Angular 的 <ng-container> 是一个分组元素,但它不会污染样式或元素布局,因为 Angular 压根不会把它放进 DOM 中。

下面是重新实现的条件化段落,这次使用 <ng-container>

Path:"src/app/app.component.html (ngif-ngcontainer)"

<p>  I turned the corner  <ng-container *ngIf="hero">    and saw {{hero.name}}. I waved  </ng-container>  and continued on my way.</p>

这次就渲染对了。

现在用 <ng-container> 来根据条件排除选择框中的某个 <option>

Path:"src/app/app.component.html (select-ngcontainer)"

<div>  Pick your favorite hero  (<label><input type="checkbox" checked (change)="showSad = !showSad">show sad</label>)</div><select [(ngModel)]="hero">  <ng-container *ngFor="let h of heroes">    <ng-container *ngIf="showSad || h.emotion !== 'sad'">      <option [ngValue]="h">{{h.name}} ({{h.emotion}})</option>    </ng-container>  </ng-container></select>

下拉框也工作正常。

注:
- ngModel 指令是在 Angular 的 FormsModule 中定义的,你要在想使用它的模块的 imports: [...] 元数据中导入 FormsModule

<ng-container> 是一个由 Angular 解析器负责识别处理的语法元素。 它不是一个指令、组件、类或接口,更像是 JavaScript 中 if 块中的花括号。

if (someCondition) {  statement1;  statement2;  statement3;}

没有这些花括号,JavaScript 只会执行第一句,而你原本的意图是把其中的所有语句都视为一体来根据条件执行。 而 <ng-container> 满足了 Angular 模板中类似的需求。

写一个结构型指令

你需要写一个名叫 UnlessDirective 的结构型指令,它是 NgIf 的反义词。 NgIf 在条件为 true 的时候显示模板内容,而 UnlessDirective 则会在条件为 false 时显示模板内容。

Path:"src/app/app.component.html (appUnless-1)"

<p *appUnless="condition">Show this sentence unless the condition is true.</p>

创建指令很像创建组件。

  • 导入 Directive 装饰器(而不再是 Component)。

  • 导入符号 InputTemplateRefViewContainerRef,你在任何结构型指令中都会需要它们。

  • 给指令类添加装饰器。

  • 设置 CSS 属性选择器,以便在模板中标识出这个指令该应用于哪个元素。

这里是起点:

Path:"src/app/unless.directive.ts (skeleton)"

import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';@Directive({ selector: '[appUnless]'})export class UnlessDirective {}

指令的选择器通常是把指令的属性名括在方括号中,如 [appUnless]。 这个方括号定义出了一个 CSS 属性选择器。

该指令的属性名应该拼写成小驼峰形式,并且带有一个前缀。 但是,这个前缀不能用 ng,因为它只属于 Angular 本身。 请选择一些简短的,适合你自己或公司的前缀。 在这个例子中,前缀是 app。

指令的类名用 Directive 结尾,参见风格指南。 但 Angular 自己的指令例外。

TemplateRef 和 ViewContainerRef

像这个例子一样的简单结构型指令会从 Angular 生成的 <ng-template> 元素中创建一个内嵌的视图,并把这个视图插入到一个视图容器中,紧挨着本指令原来的宿主元素 <p>(译注:注意不是子节点,而是兄弟节点)。

你可以使用TemplateRef取得 <ng-template> 的内容,并通过ViewContainerRef来访问这个视图容器。

你可以把它们都注入到指令的构造函数中,作为该类的私有属性。

Path:"src/app/unless.directive.ts (ctor)"

constructor(  private templateRef: TemplateRef<any>,  private viewContainer: ViewContainerRef) { }

appUnless 属性

该指令的使用者会把一个 true/false 条件绑定到 [appUnless] 属性上。 也就是说,该指令需要一个带有 @InputappUnless 属性。

Path:"src/app/unless.directive.ts (set)"

@Input() set appUnless(condition: boolean) {  if (!condition && !this.hasView) {    this.viewContainer.createEmbeddedView(this.templateRef);    this.hasView = true;  } else if (condition && this.hasView) {    this.viewContainer.clear();    this.hasView = false;  }}

一旦该值的条件发生了变化,Angular 就会去设置 appUnless 属性。因为不能用 appUnless 属性,所以你要为它定义一个设置器(setter)。

如果条件为假,并且以前尚未创建过该视图,就告诉视图容器(ViewContainer)根据模板创建一个内嵌视图。

如果条件为真,并且视图已经显示出来了,就会清除该容器,并销毁该视图。

没有人会读取 appUnless 属性,因此它不需要定义 getter

完整的指令代码如下:

Path:"src/app/unless.directive.ts (excerpt)"

import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';/** * Add the template content to the DOM unless the condition is true. */@Directive({ selector: '[appUnless]'})export class UnlessDirective {  private hasView = false;  constructor(    private templateRef: TemplateRef<any>,    private viewContainer: ViewContainerRef) { }  @Input() set appUnless(condition: boolean) {    if (!condition && !this.hasView) {      this.viewContainer.createEmbeddedView(this.templateRef);      this.hasView = true;    } else if (condition && this.hasView) {      this.viewContainer.clear();      this.hasView = false;    }  }}

把这个指令添加到 AppModuledeclarations 数组中。

然后创建一些 HTML 来试用一下。

Path:"src/app/app.component.html (appUnless)"

<p *appUnless="condition" class="unless a">  (A) This paragraph is displayed because the condition is false.</p><p *appUnless="!condition" class="unless b">  (B) Although the condition is true,  this paragraph is displayed because appUnless is set to false.</p>

conditionfalse 时,顶部的段落就会显示出来,而底部的段落消失了。 当 conditiontrue 时,顶部的段落被移除了,而底部的段落显示了出来。

改进自定义指令的模板类型检查

你可以通过在指令定义中添加模板守护属性来改进自定义指令的模板类型检查。这些属性可以帮助 Angular 模板类型检查器在编译期间发现模板中的错误,避免这些失误导致运行期错误。

使用类型守护属性可以告诉模板类型检查器你所期望的类型,从而改进该模板的编译期类型检查。

  • 属性 ngTemplateGuard_(someInputProperty) 允许你为模板中的输入表达式指定一个更准确的类型。

  • ngTemplateContextGuard 静态属性声明了模板上下文的类型。

本节提供了这两种类型守护属性的例子。

使用模板守护功能可以让模板内的类型需求更具体

模板中的结构型指令会根据输入表达式来控制是否要在运行时渲染该模板。为了帮助编译器捕获模板类型中的错误,你应该尽可能详细地指定模板内指令的输入表达式所期待的类型。

类型守护函数会把输入表达式所期待的类型窄化为在运行时可能传给指令的子类型。你可以提供这样一个函数来帮助类型检查器在编译期间推断出该表达式的正确类型。

例如,NgIf 的实现使用类型窄化来确保只有当 *ngIf 的输入表达式为真时,模板才会被实例化。为了提供具体的类型要求,NgIf 指令定义了一个静态属性 ngTemplateGuard_ngIf: 'binding'binding 值是一种常见的类型窄化的例子,它会对输入表达式进行求值,以满足类型要求。

要为模板中的指令提供一个更具体的输入表达式类型,就要把 ngTemplateGuard_xx 属性添加到该指令中,其静态属性名的后缀(xx)是 @Input 字段名。该属性的值既可以是针对其返回类型的通用类型窄化函数,也可以是字符串 "binding" 就像 NgIf 一样。

例如,考虑以下结构型指令,它以模板表达式的结果作为输入。

Path:"src/app/IfLoadedDirective"

export type Loaded = { type: 'loaded', data: T };export type Loading = { type: 'loading' };export type LoadingState = Loaded | Loading;export class IfLoadedDirective {    @Input('ifLoaded') set state(state: LoadingState) {}    static ngTemplateGuard_state(dir: IfLoadedDirective, expr: LoadingState): expr is Loaded { return true; };export interface Person {  name: string;}@Component({  template: `{{ state.data }}`,})export class AppComponent {  state: LoadingState;}

在这个例子中,LoadingState<T> 类型允许两种状态之一,Loaded<T>Loading。此表达式用作该指令的 state 输入是一个总括类型 LoadingState,因为此处的加载状态是未知的。

IfLoadedDirective 定义声明了静态字段 ngTemplateGuard_state,表示其窄化行为。在 AppComponent 模板中,*ifLoaded 结构型指令只有当实际的 stateLoaded<Person> 类型时,才会渲染该模板。类型守护允许类型检查器推断出模板中可接受的 state 类型是 Loaded<T>,并进一步推断出 T 必须是 Person 一个实例。

为指令上下文指定类型

如果你的结构型指令要为实例化的模板提供一个上下文,可以通过提供静态的 ngTemplateContextGuard 函数在模板中给它提供合适的类型。下面的代码片段展示了该函数的一个例子。

Path:"src/app/myDirective.ts"

@Directive({…})export class ExampleDirective {    // Make sure the template checker knows the type of the context with which the    // template of this directive will be rendered    static ngTemplateContextGuard(dir: ExampleDirective, ctx: unknown): ctx is ExampleContext { return true; };    // …}

源代码

  1. Path:"src/app/app.component.ts" 。

    import { Component } from '@angular/core';    import { Hero, heroes } from './hero';    @Component({      selector: 'app-root',      templateUrl: './app.component.html',      styleUrls: [ './app.component.css' ]    })    export class AppComponent {      heroes = heroes;      hero = this.heroes[0];      condition = false;      logs: string[] = [];      showSad = true;      status = 'ready';      trackById(index: number, hero: Hero): number { return hero.id; }    }

  1. Path:"src/app/app.component.html" 。

    <h1>Structural Directives</h1>    <p>Conditional display of hero</p>    <blockquote>    <div *ngIf="hero" class="name">{{hero.name}}</div>    </blockquote>    <p>List of heroes</p>    <ul>      <li *ngFor="let hero of heroes">{{hero.name}}</li>    </ul>    <hr>    <h2 id="ngIf">NgIf</h2>    <p *ngIf="true">      Expression is true and ngIf is true.      This paragraph is in the DOM.    </p>    <p *ngIf="false">      Expression is false and ngIf is false.      This paragraph is not in the DOM.    </p>    <p [style.display]="'block'">      Expression sets display to "block".      This paragraph is visible.    </p>    <p [style.display]="'none'">      Expression sets display to "none".      This paragraph is hidden but still in the DOM.    </p>    <h4>NgIf with template</h4>    <p><ng-template> element</p>    <ng-template [ngIf]="hero">      <div class="name">{{hero.name}}</div>    </ng-template>    <hr>    <h2 id="ng-container"><ng-container></h2>    <h4>*ngIf with a <ng-container></h4>    <button (click)="hero = hero ? null : heroes[0]">Toggle hero</button>    <p>      I turned the corner      <ng-container *ngIf="hero">        and saw {{hero.name}}. I waved      </ng-container>      and continued on my way.    </p>    <p>      I turned the corner      <span *ngIf="hero">        and saw {{hero.name}}. I waved      </span>      and continued on my way.    </p>    <p><i><select> with <span></i></p>    <div>      Pick your favorite hero      (<label><input type="checkbox" checked (change)="showSad = !showSad">show sad</label>)    </div>    <select [(ngModel)]="hero">      <span *ngFor="let h of heroes">        <span *ngIf="showSad || h.emotion !== 'sad'">          <option [ngValue]="h">{{h.name}} ({{h.emotion}})</option>        </span>      </span>    </select>    <p><i><select> with <ng-container></i></p>    <div>      Pick your favorite hero      (<label><input type="checkbox" checked (change)="showSad = !showSad">show sad</label>)    </div>    <select [(ngModel)]="hero">      <ng-container *ngFor="let h of heroes">        <ng-container *ngIf="showSad || h.emotion !== 'sad'">          <option [ngValue]="h">{{h.name}} ({{h.emotion}})</option>        </ng-container>      </ng-container>    </select>    <br><br>    <hr>    <h2 id="ngFor">NgFor</h2>    <div class="box">    <p class="code"><div *ngFor="let hero of heroes; let i=index; let odd=odd; trackBy: trackById" [class.odd]="odd"></p>    <div *ngFor="let hero of heroes; let i=index; let odd=odd; trackBy: trackById" [class.odd]="odd">      ({{i}}) {{hero.name}}    </div>    <p class="code"><ng-template ngFor let-hero [ngForOf]="heroes" let-i="index" let-odd="odd" [ngForTrackBy]="trackById"/></p>    <ng-template ngFor let-hero [ngForOf]="heroes" let-i="index" let-odd="odd" [ngForTrackBy]="trackById">      <div [class.odd]="odd">({{i}}) {{hero.name}}</div>    </ng-template>    </div>    <hr>    <h2 id="ngSwitch">NgSwitch</h2>    <div>Pick your favorite hero</div>    <p>      <label *ngFor="let h of heroes">        <input type="radio" name="heroes" [(ngModel)]="hero" [value]="h">{{h.name}}      </label>      <label><input type="radio" name="heroes" (click)="hero = null">None of the above</label>    </p>    <h4>NgSwitch</h4>    <div [ngSwitch]="hero?.emotion">      <app-happy-hero    *ngSwitchCase="'happy'"    [hero]="hero"></app-happy-hero>      <app-sad-hero      *ngSwitchCase="'sad'"      [hero]="hero"></app-sad-hero>      <app-confused-hero *ngSwitchCase="'confused'" [hero]="hero"></app-confused-hero>      <app-unknown-hero  *ngSwitchDefault           [hero]="hero"></app-unknown-hero>    </div>    <h4>NgSwitch with <ng-template></h4>    <div [ngSwitch]="hero?.emotion">      <ng-template [ngSwitchCase]="'happy'">        <app-happy-hero [hero]="hero"></app-happy-hero>      </ng-template>      <ng-template [ngSwitchCase]="'sad'">        <app-sad-hero [hero]="hero"></app-sad-hero>      </ng-template>      <ng-template [ngSwitchCase]="'confused'">        <app-confused-hero [hero]="hero"></app-confused-hero>      </ng-template >      <ng-template ngSwitchDefault>        <app-unknown-hero [hero]="hero"></app-unknown-hero>      </ng-template>    </div>    <hr>    <h2><ng-template></h2>    <p>Hip!</p>    <ng-template>      <p>Hip!</p>    </ng-template>    <p>Hooray!</p>    <hr>    <h2 id="appUnless">UnlessDirective</h2>    <p>      The condition is currently      <span [ngClass]="{ 'a': !condition, 'b': condition, 'unless': true }">{{condition}}</span>.      <button        (click)="condition = !condition"        [ngClass] = "{ 'a': condition, 'b': !condition }" >        Toggle condition to {{condition ? 'false' : 'true'}}      </button>    </p>    <p *appUnless="condition" class="unless a">      (A) This paragraph is displayed because the condition is false.    </p>    <p *appUnless="!condition" class="unless b">      (B) Although the condition is true,      this paragraph is displayed because appUnless is set to false.    </p>    <h4>UnlessDirective with template</h4>    <p *appUnless="condition">Show this sentence unless the condition is true.</p>    <p *appUnless="condition" class="code unless">      (A) <p *appUnless="condition" class="code unless">    </p>    <ng-template [appUnless]="condition">      <p class="code unless">        (A) <ng-template [appUnless]="condition">      </p>    </ng-template>

  1. Path:"src/app/app.component.css" 。

    button {      min-width: 100px;      font-size: 100%;    }    .box {      border: 1px solid gray;      max-width: 600px;      padding: 4px;    }    .choices {      font-style: italic;    }    code, .code {      background-color: #eee;      color: black;      font-family: Courier, sans-serif;      font-size: 85%;    }    div.code {      width: 400px;    }    .heroic {      font-size: 150%;      font-weight: bold;    }    hr {      margin: 40px 0    }    .odd {      background-color:  palegoldenrod;    }    td, th {      text-align: left;      vertical-align: top;    }    p span { color: red; font-size: 70%; }    .unless {      border: 2px solid;      padding: 6px;    }    p.unless {      width: 500px;    }    button.a, span.a, .unless.a {      color: red;      border-color: gold;      background-color: yellow;      font-size: 100%;    }    button.b, span.b, .unless.b {      color: black;      border-color: green;      background-color: lightgreen;      font-size: 100%;    }

  1. Path:"src/app/app.module.ts" 。

    import { NgModule }      from '@angular/core';    import { FormsModule }   from '@angular/forms';    import { BrowserModule } from '@angular/platform-browser';    import { AppComponent }         from './app.component';    import { heroSwitchComponents } from './hero-switch.components';    import { UnlessDirective }    from './unless.directive';    @NgModule({      imports: [ BrowserModule, FormsModule ],      declarations: [        AppComponent,        heroSwitchComponents,        UnlessDirective      ],      bootstrap: [ AppComponent ]    })    export class AppModule { }

  1. Path:"src/app/hero.ts" 。

    export interface Hero {      id: number;      name: string;      emotion?: string;    }    export const heroes: Hero[] = [      { id: 1, name: 'Dr Nice',  emotion: 'happy'},      { id: 2, name: 'Narco',     emotion: 'sad' },      { id: 3, name: 'Windstorm', emotion: 'confused' },      { id: 4, name: 'Magneta'}    ];

  1. Path:"src/app/hero-switch.components.ts" 。

    import { Component, Input } from '@angular/core';    import { Hero } from './hero';    @Component({      selector: 'app-happy-hero',      template: `Wow. You like {{hero.name}}. What a happy hero ... just like you.`    })    export class HappyHeroComponent {      @Input() hero: Hero;    }    @Component({      selector: 'app-sad-hero',      template: `You like {{hero.name}}? Such a sad hero. Are you sad too?`    })    export class SadHeroComponent {      @Input() hero: Hero;    }    @Component({      selector: 'app-confused-hero',      template: `Are you as confused as {{hero.name}}?`    })    export class ConfusedHeroComponent {      @Input() hero: Hero;    }    @Component({      selector: 'app-unknown-hero',      template: `{{message}}`    })    export class UnknownHeroComponent {      @Input() hero: Hero;      get message() {        return this.hero && this.hero.name ?          `${this.hero.name} is strange and mysterious.` :          'Are you feeling indecisive?';      }    }    export const heroSwitchComponents =      [ HappyHeroComponent, SadHeroComponent, ConfusedHeroComponent, UnknownHeroComponent ];

  1. Path:"src/app/unless.directive.ts" 。

    import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';    /**     * Add the template content to the DOM unless the condition is true.     *     * If the expression assigned to `appUnless` evaluates to a truthy value     * then the templated elements are removed removed from the DOM,     * the templated elements are (re)inserted into the DOM.     *     * <div *appUnless="errorCount" class="success">     *   Congrats! Everything is great!     * </div>     *     * ### Syntax     *     * - `<div *appUnless="condition">...</div>`     * - `<ng-template [appUnless]="condition"><div>...</div></ng-template>`     *     */    @Directive({ selector: '[appUnless]'})    export class UnlessDirective {      private hasView = false;      constructor(        private templateRef: TemplateRef<any>,        private viewContainer: ViewContainerRef) { }      @Input() set appUnless(condition: boolean) {        if (!condition && !this.hasView) {          this.viewContainer.createEmbeddedView(this.templateRef);          this.hasView = true;        } else if (condition && this.hasView) {          this.viewContainer.clear();          this.hasView = false;        }      }    }

小结

  • 结构型指令可以操纵 HTML 的元素布局。

  • 当没有合适的宿主元素时,可以使用<ng-container>对元素进行分组。

  • Angular 会把星号(*)语法解开成 <ng-template>

  • 内置指令 NgIfNgForNgSwitch 的工作原理。

  • 微语法如何展开成<ng-template>

  • 写了一个自定义结构型指令 —— UnlessDirective

用管道转换数据

管道用来对字符串、货币金额、日期和其他显示数据进行转换和格式化。管道是一些简单的函数,可以在模板表达式中用来接受输入值并返回一个转换后的值。例如,你可以使用一个管道把日期显示为 1988 年 4 月 15 日,而不是其原始字符串格式。

Angular 为典型的数据转换提供了内置的管道,包括国际化的转换(i18n),它使用本地化信息来格式化数据。数据格式化常用的内置管道如下:

  • DatePipe:根据本地环境中的规则格式化日期值。

  • UpperCasePipe:把文本全部转换成大写。

  • LowerCasePipe :把文本全部转换成小写。

  • CurrencyPipe :把数字转换成货币字符串,根据本地环境中的规则进行格式化。

  • DecimalPipe:把数字转换成带小数点的字符串,根据本地环境中的规则进行格式化。

  • PercentPipe :把数字转换成百分比字符串,根据本地环境中的规则进行格式化。

你还可以创建管道来封装自定义转换,并在模板表达式中使用自定义管道。

先决条件

要想使用管道,你应该对这些内容有基本的了解:

  • Typescript 和 HTML5 编程

  • 带有 CSS 样式的 HTML 模板

  • 组件

在模板中使用管道

要应用管道,请如下所示在模板表达式中使用管道操作符(|),紧接着是该管道的名字,对于内置的 DatePipe 它的名字是 date 。这个例子中的显示如下:

"app.component.html" 在另一个单独的模板中使用 date 来显示生日。

"hero-birthday1.component.ts" 使用相同的管道作为组件内嵌模板的一部分,同时该组件也会设置生日值。

  1. Path:"src/app/app.component.html" 。

    <p>The hero's birthday is {{ birthday | date }}</p>

  1. Path:"src/app/app.component.html" 。

    import { Component } from '@angular/core';    @Component({      selector: 'app-hero-birthday',      template: `<p>The hero's birthday is {{ birthday | date }}</p>`    })    export class HeroBirthdayComponent {      birthday = new Date(1988, 3, 15); // April 15, 1988 -- since month parameter is zero-based    }

使用参数和管道链来格式化数据

可以用可选参数微调管道的输出。例如,你可以使用 CurrencyPipe 和国家代码(如 EUR)作为参数。模板表达式 {{ amount | currency:'EUR' }} 会把 amount 转换成欧元。紧跟在管道名称( currency )后面的是冒号(:)和参数值('EUR')。

如果管道能接受多个参数,就用冒号分隔这些值。例如,{{ amount | currency:'EUR':'Euros '}} 会把第二个参数(字符串 'Euros ')添加到输出字符串中。你可以使用任何有效的模板表达式作为参数,比如字符串字面量或组件的属性。

有些管道需要至少一个参数,并且允许使用更多的可选参数,比如 SlicePipe 。例如, {{ slice:1:5 }} 会创建一个新数组或字符串,它以第 1 个元素开头,并以第 5 个元素结尾。

示例:格式化日期

下面的例子显示了两种不同格式('shortDate''fullDate')之间的切换:

该 "app.component.html" 模板使用 DatePipe (名为 date)的格式参数把日期显示为 04/15/88 。

"hero-birthday2.component.ts" 组件把该管道的 format 参数绑定到 template 中组件的 format 属性,并添加了一个按钮,其 click 事件绑定到了该组件的 toggleFormat() 方法。

"hero-birthday2.component.ts" 组件的 toggleFormat() 方法会在短格式('shortDate')和长格式('fullDate')之间切换该组件的 format 属性。

  1. Path:"src/app/app.component.html" 。

    <p>The hero's birthday is {{ birthday | date:"MM/dd/yy" }} </p>

  1. Path:"src/app/hero-birthday2.component.ts (template)" 。

    template: `      <p>The hero's birthday is {{ birthday | date:format }}</p>      <button (click)="toggleFormat()">Toggle Format</button>    `

  1. Path:"src/app/hero-birthday2.component.ts (class)" 。

    export class HeroBirthday2Component {      birthday = new Date(1988, 3, 15); // April 15, 1988 -- since month parameter is zero-based      toggle = true; // start with true == shortDate      get format()   { return this.toggle ? 'shortDate' : 'fullDate'; }      toggleFormat() { this.toggle = !this.toggle; }    }

点击 Toggle Format 按钮可以在 04/15/1988 和 Friday, April 15, 1988 之间切换日期格式,如下所示:

示例:通过串联管道应用两种格式

你可以对管道进行串联,以便一个管道的输出成为下一个管道的输入。

在下面的示例中,串联管道首先将格式应用于一个日期值,然后将格式化之后的日期转换为大写字符。 "src/app/app.component.html" 模板的第一个标签页把 DatePipeUpperCasePipe 的串联起来,将其显示为 APR 15, 1988。"src/app/app.component.html" 模板的第二个标签页在串联 uppercase 之前,还把 fullDate 参数传递给了 date,将其显示为 FRIDAY, APRIL 15, 1988。

  1. Path:"src/app/app.component.html (1)" 。

    The chained hero's birthday is    {{ birthday | date | uppercase}}

  1. Path:"src/app/app.component.html (2)" 。

    The chained hero's birthday is    {{  birthday | date:'fullDate' | uppercase}}

为自定义数据转换创建管道

创建自定义管道来封装那些内置管道没有提供的转换。然后你就可以在模板表达式中使用你的自定义管道,就像内置管道一样,把输入值转换成显示输出。

把一个类标记为一个管道

要把类标记为管道并提供配置元数据,请把 @Pipe 装饰器应用到这个类上。管道类名是 UpperCamelCase(类名的一般约定),相应的 name 字符串是 camelCase 的。不要在 name 中使用连字符。详细信息和更多示例,请参阅管道名称 。

在模板表达式中使用 name 就像在内置管道中一样。

  • 把你的管道包含在 NgModule 元数据的 declarations 字段中,以便它能用于模板。

  • 把你的管道包含在 NgModule 元数据的 declarations 字段中,以便它能用于模板。

使用 PipeTransform 接口

在自定义管道类中实现 PipeTransform 接口来执行转换。

Angular 调用 transform 方法,该方法使用绑定的值作为第一个参数,把其它任何参数都以列表的形式作为第二个参数,并返回转换后的值。

示例:指数级转换

在游戏中,你可能希望实现一种指数级转换,以指数级增加英雄的力量。例如,如果英雄的得分是 2,那么英雄的能量会指数级增长 10 次,最终得分为 1024。你可以使用自定义管道进行这种转换。

下列代码示例显示了两个组件定义:

"exponential-strength.pipe.ts" 通过一个执行转换的 transform 方法定义了一个名为 exponentialStrength 的自定义管道。它为传递给管道的参数定义了 transform 方法的一个参数(exponent)。

"power-booster.component.ts" 组件演示了如何使用该管道,指定了一个值( 2 )和一个 exponent 参数( 10 )。

  1. Path:"src/app/exponential-strength.pipe.ts" 。

    import { Pipe, PipeTransform } from '@angular/core';    /*     * Raise the value exponentially     * Takes an exponent argument that defaults to 1.     * Usage:     *   value | exponentialStrength:exponent     * Example:     *   {{ 2 | exponentialStrength:10 }}     *   formats to: 1024    */    @Pipe({name: 'exponentialStrength'})    export class ExponentialStrengthPipe implements PipeTransform {      transform(value: number, exponent?: number): number {        return Math.pow(value, isNaN(exponent) ? 1 : exponent);      }    }

  1. Path:"src/app/power-booster.component.ts" 。

    import { Component } from '@angular/core';    @Component({      selector: 'app-power-booster',      template: `        <h2>Power Booster</h2>        <p>Super power boost: {{2 | exponentialStrength: 10}}</p>      `    })    export class PowerBoosterComponent { }

输出结果如下所示:

通过管道中的数据绑定来检测变更

你可以通过带有管道的数据绑定来显示值并响应用户操作。如果是原始类型的输入值,比如 StringNumber ,或者是对象引用型的输入值,比如 DateArray ,那么每当 Angular 检测到输入值或引用有变化时,就会执行该输入管道。

比如,你可以修改前面的自定义管道示例,通过 ngModel 的双向绑定来输入数量和提升因子,如下面的代码示例所示。

Path:"src/app/power-boost-calculator.component.ts" 。

import { Component } from '@angular/core';@Component({  selector: 'app-power-boost-calculator',  template: `    <h2>Power Boost Calculator</h2>    <div>Normal power: <input [(ngModel)]="power"></div>    <div>Boost factor: <input [(ngModel)]="factor"></div>    <p>      Super Hero Power: {{power | exponentialStrength: factor}}    </p>  `})export class PowerBoostCalculatorComponent {  power = 5;  factor = 1;}

每当用户改变 “normal power” 值或 “boost factor” 时,就会执行 exponentialStrength 管道,如下所示。

Angular 会检测每次变更,并立即运行该管道。对于原始输入值,这很好。但是,如果要在复合对象中更改某些内部值(例如日期中的月份、数组中的元素或对象中的属性),就需要了解变更检测的工作原理,以及如何使用 impure(非纯)管道。

变更检测的工作原理

Angular 会在每次 DOM 事件(每次按键、鼠标移动、计时器滴答和服务器响应)之后运行的变更检测过程中查找对数据绑定值的更改。下面这段不使用管道的例子演示了 Angular 如何利用默认的变更检测策略来监控和更新 heroes 数组中每个英雄的显示效果。示例显示如下:

  • 在 "flying-heroes.component.html (v1)" 模板中, *ngFor 会重复显示英雄的名字。

  • 与之相伴的组件类 "flying-heroes.component.ts (v1)" 提供了一些英雄,把这些英雄添加到数组中,并重置了该数组。

  1. Path:"src/app/flying-heroes.component.html (v1)" 。

    New hero:      <input type="text" #box              (keyup.enter)="addHero(box.value); box.value=''"              placeholder="hero name">      <button (click)="reset()">Reset</button>      <div *ngFor="let hero of heroes">        {{hero.name}}      </div>

  1. Path:"src/app/flying-heroes.component.ts (v1)" 。

    export class FlyingHeroesComponent {      heroes: any[] = [];      canFly = true;      constructor() { this.reset(); }      addHero(name: string) {        name = name.trim();        if (!name) { return; }        let hero = {name, canFly: this.canFly};        this.heroes.push(hero);      }      reset() { this.heroes = HEROES.slice(); }    }

每次用户添加一个英雄时,Angular 都会更新显示内容。如果用户点击了 Reset 按钮,Angular 就会用原来这些英雄组成的新数组来替换 heroes ,并更新显示。如果你添加删除或更改了某个英雄的能力,Angular 也会检测这些变化并更新显示。

然而,如果对于每次更改都执行一个管道来更新显示,就会降低你应用的性能。因此,Angular 会使用更快的变更检测算法来执行管道,如下一节所述。

检测原始类型和对象引用的纯变更

通过默认情况下,管道会定义成纯的(pure),这样 Angular 只有在检测到输入值发生了纯变更时才会执行该管道。纯变更是对原始输入值(比如 StringNumberBooleanSymbol )的变更,或是对对象引用的变更(比如 DateArrayFunctionObject)。

纯管道必须使用纯函数,它能处理输入并返回没有副作用的值。换句话说,给定相同的输入,纯函数应该总是返回相同的输出。

使用纯管道,Angular 会忽略复合对象中的变化,例如往现有数组中新增的元素,因为检查原始值或对象引用比对对象中的差异进行深度检查要快得多。Angular 可以快速判断是否可以跳过执行该管道并更新视图。

但是,以数组作为输入的纯管道可能无法正常工作。为了演示这个问题,修改前面的例子来把英雄列表过滤成那些会飞的英雄。在 *ngFor 中使用 FlyingHeroesPipe ,代码如下。这个例子的显示如下:

  1. 带有新管道的模板(Path:"src/app/flying-heroes.component.html (flyers)")。

    <div *ngFor="let hero of (heroes | flyingHeroes)">      {{hero.name}}    </div>

  1. FlyingHeroesPipe 自定义管道实现(Path:"src/app/flying-heroes.pipe.ts")。

    import { Pipe, PipeTransform } from '@angular/core';    import { Flyer } from './heroes';    @Pipe({ name: 'flyingHeroes' })    export class FlyingHeroesPipe implements PipeTransform {      transform(allHeroes: Flyer[]) {        return allHeroes.filter(hero => hero.canFly);      }    }

该应用现在展示了意想不到的行为:当用户添加了会飞的英雄时,它们都不会出现在 “Heroes who fly” 中。发生这种情况是因为添加英雄的代码会把它 pushheroes 数组中:

Path:"src/app/flying-heroes.component.ts" 。

this.heroes.push(hero);

而变更检测器会忽略对数组元素的更改,所以管道不会运行。

Angular 忽略了被改变的数组元素的原因是对数组的引用没有改变。由于 Angular 认为该数组仍是相同的,所以不会更新其显示。

获得所需行为的方法之一是更改对象引用本身。你可以用一个包含新更改过的元素的新数组替换该数组,然后把这个新数组作为输入传给管道。在上面的例子中,你可以创建一个附加了新英雄的数组,并把它赋值给 heroes。 Angular 检测到了这个数组引用的变化,并执行了该管道。

总结一下,如果修改了输入数组,纯管道就不会执行。如果替换了输入数组,就会执行该管道并更新显示,如下图所示。

上面的例子演示了如何更改组件的代码来适应某个管道。

为了让你的组件更简单,独立于那些使用管道的 HTML,你可以用一个不纯的管道来检测复合对象(如数组)中的变化,如下一节所述。

检测复合对象中的非纯变更

要在复合对象内部进行更改后执行自定义管道(例如更改数组元素),就需要把管道定义为 impure 以检测非纯的变更。每当按键或鼠标移动时,Angular 都会检测到一次变更,从而执行一个非纯管道。

注:
- 虽然非纯管道很实用,但要小心使用。长时间运行非纯管道可能会大大降低你的应用速度。

通过把 pure 标志设置为 false 来把管道设置成非纯的:

Path:"src/app/flying-heroes.pipe.ts" 。

@Pipe({  name: 'flyingHeroesImpure',  pure: false})

下面的代码显示了 FlyingHeroesImpurePipe 的完整实现,它扩展了 FlyingHeroesPipe 以继承其特性。这个例子表明你不需要修改其他任何东西 - 唯一的区别就是在管道元数据中把 pure 标志设置为 false

  1. Path:"src/app/flying-heroes.pipe.ts (FlyingHeroesImpurePipe)" 。

    @Pipe({      name: 'flyingHeroesImpure',      pure: false    })    export class FlyingHeroesImpurePipe extends FlyingHeroesPipe {}

  1. Path:"src/app/flying-heroes.pipe.ts (FlyingHeroesPipe)" 。

    import { Pipe, PipeTransform } from '@angular/core';    import { Flyer } from './heroes';    @Pipe({ name: 'flyingHeroes' })    export class FlyingHeroesPipe implements PipeTransform {      transform(allHeroes: Flyer[]) {        return allHeroes.filter(hero => hero.canFly);      }    }

对于非纯管道,FlyingHeroesImpurePipe 是个不错的选择,因为它的 transform 函数非常简单快捷:

Path:"src/app/flying-heroes.pipe.ts (filter)" 。

return allHeroes.filter(hero => hero.canFly);

你可以从 FlyingHeroesComponent 派生一个 FlyingHeroesImpureComponent。如下面的代码所示,只有模板中的管道发生了变化。

Path:"src/app/flying-heroes-impure.component.html (excerpt)" 。

<div *ngFor="let hero of (heroes | flyingHeroesImpure)">  {{hero.name}}</div>

从一个可观察对象中解包数据

可观察对象能让你在应用的各个部分之间传递消息。建议在事件处理、异步编程以及处理多个值时使用这些可观察对象。可观察对象可以提供任意类型的单个或多个值,可以是同步的(作为一个函数为它的调用者提供一个值),也可以是异步的。

使用内置的 AsyncPipe 接受一个可观察对象作为输入,并自动订阅输入。如果没有这个管道,你的组件代码就必须订阅这个可观察对象来使用它的值,提取已解析的值、把它们公开进行绑定,并在销毁这段可观察对象时取消订阅,以防止内存泄漏。 AsyncPipe 是一个非纯管道,可以节省组件中的样板代码,以维护订阅,并在数据到达时持续从该可观察对象中提供值。

下列代码示例使用 async 管道将带有消息字符串( message$ )的可观察对象绑定到视图中。

Path:"src/app/hero-async-message.component.ts" 。

import { Component } from '@angular/core';import { Observable, interval } from 'rxjs';import { map, take } from 'rxjs/operators';@Component({  selector: 'app-hero-message',  template: `    <h2>Async Hero Message and AsyncPipe</h2>    <p>Message: {{ message$ | async }}</p>    <button (click)="resend()">Resend</button>`,})export class HeroAsyncMessageComponent {  message$: Observable<string>;  private messages = [    'You are my hero!',    'You are the best hero!',    'Will you be my hero?'  ];  constructor() { this.resend(); }  resend() {    this.message$ = interval(500).pipe(      map(i => this.messages[i]),      take(this.messages.length)    );  }}

缓存 HTTP 请求

为了使用 HTTP 与后端服务进行通信,HttpClient 服务使用了可观察对象,并提供了 HTTPClient.get() 方法来从服务器获取数据。这个异步方法会发送一个 HTTP 请求,并返回一个可观察对象,它会发出请求到的响应数据。

AsyncPipe 所示,你可以使用非纯管道 AsyncPipe 接受一个可观察对象作为输入,并自动订阅输入。你也可以创建一个非纯管道来建立和缓存 HTTP 请求。

每当组件运行变更检测时就会调用非纯管道,在 CheckAlways 策略下会每隔几毫秒运行一次。为避免出现性能问题,只有当请求的 URL 发生变化时才会调用该服务器(如下例所示),并使用该管道缓存服务器的响应。显示如下:

  1. fetch 管道( Path:"src/app/fetch-json.pipe.ts" )。

    import { HttpClient }          from '@angular/common/http';    import { Pipe, PipeTransform } from '@angular/core';    @Pipe({      name: 'fetch',      pure: false    })    export class FetchJsonPipe implements PipeTransform {      private cachedData: any = null;      private cachedUrl = '';      constructor(private http: HttpClient) { }      transform(url: string): any {        if (url !== this.cachedUrl) {          this.cachedData = null;          this.cachedUrl = url;          this.http.get(url).subscribe(result => this.cachedData = result);        }        return this.cachedData;      }    }

  1. 一个用于演示该请求的挽具组件("src/app/hero-list.component.ts"),它使用一个模板,该模板定义了两个到该管道的绑定,该管道会向 "heroes.json" 文件请求英雄数组。第二个绑定把 fetch 管道与内置的 JsonPipe 串联起来,以 JSON 格式显示同一份英雄数据。

    import { Component } from '@angular/core';    @Component({      selector: 'app-hero-list',      template: `        <h2>Heroes from JSON File</h2>        <div *ngFor="let hero of ('assets/heroes.json' | fetch) ">          {{hero.name}}        </div>        <p>Heroes as JSON:          {{'assets/heroes.json' | fetch | json}}        </p>`    })    export class HeroListComponent { }

在上面的例子中,管道请求数据时的剖面展示了如下几点:

  • 每个绑定都有自己的管道实例。

  • 每个管道实例都会缓存自己的 URL 和数据,并且只调用一次服务器。

fetch 和 fetch-json 管道会显示英雄,如下图所示。

注:
- 内置的 JsonPipe 提供了一种方法来诊断一个离奇失败的数据绑定,或用来检查一个对象是否能用于将来的绑定。

当 Angular 实例化组件类并渲染组件视图及其子视图时,组件实例的生命周期就开始了。生命周期一直伴随着变更检测,Angular 会检查数据绑定属性何时发生变化,并按需更新视图和组件实例。当 Angular 销毁组件实例并从 DOM 中移除它渲染的模板时,生命周期就结束了。当 Angular 在执行过程中创建、更新和销毁实例时,指令就有了类似的生命周期。

你的应用可以使用生命周期钩子方法来触发组件或指令生命周期中的关键事件,以初始化新实例,需要时启动变更检测,在变更检测过程中响应更新,并在删除实例之前进行清理。

先决条件

在使用生命周期钩子之前,你应该对这些内容有一个基本的了解:

  • TypeScript 编程 。

  • Angular 应用设计基础,就像 Angular 的基本概念中所讲的那样。

响应生命周期事件

你可以通过实现一个或多个 Angular core 库中定义的生命周期钩子接口来响应组件或指令生命周期中的事件。这些钩子让你有机会在适当的时候对组件或指令实例进行操作,比如 Angular 创建、更新或销毁这个实例时。

每个接口都有唯一的一个钩子方法,它们的名字是由接口名再加上 ng 前缀构成的。比如,OnInit 接口的钩子方法叫做 ngOnInit()。如果你在组件或指令类中实现了这个方法,Angular 就会在首次检查完组件或指令的输入属性后,紧接着调用它。

Path:"peek-a-boo.component.ts (excerpt)" 。

@Directive()export class PeekABooDirective implements OnInit {  constructor(private logger: LoggerService) { }  // implement OnInit's `ngOnInit` method  ngOnInit() { this.logIt(`OnInit`); }  logIt(msg: string) {    this.logger.log(`#${nextId++} ${msg}`);  }}

注:
- 你不必实现所有生命周期钩子,只要实现你需要的那些就可以了。

生命周期的顺序

当你的应用通过调用构造函数来实例化一个组件或指令时,Angular 就会调用那个在该实例生命周期的适当位置实现了的那些钩子方法。

Angular 会按以下顺序执行钩子方法。你可以用它来执行以下类型的操作。

钩子方法用途调用时机
ngOnChanges()当 Angular 设置或重新设置数据绑定的输入属性时响应。 该方法接受当前和上一属性值的 SimpleChanges 对象。注意,这发生的非常频繁,所以你在这里执行的任何操作都会显著影响性能。在 ngOnInit() 之前以及所绑定的一个或多个输入属性的值发生变化时都会调用。
ngOnInit()在 Angular 第一次显示数据绑定和设置指令/组件的输入属性之后,初始化指令/组件。在第一轮 ngOnChanges() 完成之后调用,只调用一次。
ngDoCheck()检测,并在发生 Angular 无法或不愿意自己检测的变化时作出反应。紧跟在每次执行变更检测时的 ngOnChanges() 和 首次执行变更检测时的 ngOnInit() 后调用。
ngAfterContentInit()当 Angular 把外部内容投影进组件视图或指令所在的视图之后调用。第一次 ngDoCheck() 之后调用,只调用一次。
ngAfterContentChecked()每当 Angular 检查完被投影到组件或指令中的内容之后调用。ngAfterContentInit() 和每次 ngDoCheck() 之后调用
ngAfterViewInit()当 Angular 初始化完组件视图及其子视图或包含该指令的视图之后调用。第一次 ngAfterContentChecked() 之后调用,只调用一次。
ngAfterViewChecked()每当 Angular 做完组件视图和子视图或包含该指令的视图的变更检测之后调用。ngAfterViewInit() 和每次 ngAfterContentChecked() 之后调用。
ngOnDestroy()每当 Angular 每次销毁指令/组件之前调用并清扫。 在这儿反订阅可观察对象和分离事件处理器,以防内存泄漏。在 Angular 销毁指令或组件之前立即调用。

生命周期范例

通过在受控于根组件 AppComponent 的一些组件上进行的一系列练习,演示了生命周期钩子的运作方式。 每一个例子中,父组件都扮演了子组件测试台的角色,以展示出一个或多个生命周期钩子方法。

下表列出了这些练习及其简介。 范例代码也用来阐明后续各节的一些特定任务。

组件说明
Peek-a-boo展示每个生命周期钩子,每个钩子方法都会在屏幕上显示一条日志。
Spy展示了你如何在自定义指令中使用生命周期钩子。 SpyDirective 实现了 ngOnInit() 和 ngOnDestroy() 钩子,并且使用它们来观察和汇报一个元素何时进入或离开当前视图。
OnChanges演示了每当组件的输入属性之一发生变化时,Angular 如何调用 ngOnChanges() 钩子。并且演示了如何解释传给钩子方法的 changes 对象。
DoCheck实现了一个 ngDoCheck() 方法,通过它可以自定义变更检测逻辑。 监视该钩子把哪些变更记录到了日志中,观察 Angular 以什么频度调用这个钩子。
AfterView显示 Angular 中的视图所指的是什么。 演示了 ngAfterViewInit() 和 ngAfterViewChecked() 钩子。
AfterContent展示如何把外部内容投影进组件中,以及如何区分“投影进来的内容”和“组件的子视图”。 演示了 ngAfterContentInit() 和 ngAfterContentChecked() 钩子。
计数器演示了一个组件和一个指令的组合,它们各自有自己的钩子。

初始化组件或指令

使用 ngOnInit() 方法执行以下初始化任务。

  • 在构造函数外部执行复杂的初始化。组件的构造应该既便宜又安全。比如,你不应该在组件构造函数中获取数据。当在测试中创建组件时或者决定显示它之前,你不应该担心新组件会尝试联系远程服务器。

ngOnInit() 是组件获取初始数据的好地方。比如,英雄指南 中的《在 ngOnInit() 中调用它》小节。

  • 在 Angular 设置好输入属性之后设置组件。构造函数应该只把初始局部变量设置为简单的值。

请记住,只有在构造完成之后才会设置指令的数据绑定输入属性。如果要根据这些属性对指令进行初始化,请在运行 ngOnInit() 时设置它们。

&ngOnChanges() 方法是你能访问这些属性的第一次机会。Angular 会在调用 ngOnInit() 之前调用 ngOnChanges(),而且之后还会调用多次。但它只调用一次 ngOnInit()

在实例销毁时进行清理

把清理逻辑放进 ngOnDestroy() 中,这个逻辑就必然会在 Angular 销毁该指令之前运行。

这里是释放资源的地方,这些资源不会自动被垃圾回收。如果你不这样做,就存在内存泄漏的风险。

  • 取消订阅可观察对象和 DOM 事件。

  • 停止 interval 计时器。

  • 反注册该指令在全局或应用服务中注册过的所有回调。

ngOnDestroy() 方法也可以用来通知应用程序的其它部分,该组件即将消失。

一般性例子

下面的例子展示了各个生命周期事件的调用顺序和相对频率,以及如何在组件和指令中单独使用或同时使用这些钩子。

所有生命周期事件的顺序和频率

为了展示 Angular 如何以预期的顺序调用钩子,PeekABooComponent 演示了一个组件中的所有钩子。

实际上,你很少会(几乎永远不会)像这个演示中一样实现所有这些接口。

下列快照反映了用户单击 Create... 按钮,然后单击 Destroy... 按钮后的日志状态。

日志信息的日志和所规定的钩子调用顺序是一致的: OnChangesOnInitDoCheck (3x)AfterContentInitAfterContentChecked (3x)AfterViewInitAfterViewChecked (3x)OnDestroy

注:
- 该日志确认了在创建期间那些输入属性(这里是 name 属性)没有被赋值。 这些输入属性要等到 onInit() 中才可用,以便做进一步的初始化。

如果用户点击 Update Hero 按钮,就会看到另一个 OnChanges 和至少两组 DoCheckAfterContentCheckedAfterViewChecked 钩子。 注意,这三种钩子被触发了很多次,所以让它们的逻辑尽可能保持精简是非常重要的!

使用指令来监视 DOM

这个 Spy 例子演示了如何在指令和组件中使用钩子方法。SpyDirective 实现了两个钩子 ngOnInit()ngOnDestroy(),以便发现被监视的元素什么时候位于当前视图中。

这个模板将 SpyDirective 应用到由父组件 SpyComponent 管理的 ngFor 内的 <div> 中。

该例子不执行任何初始化或清理工作。它只是通过记录指令本身的实例化时间和销毁时间来跟踪元素在视图中的出现和消失。

像这样的间谍指令可以深入了解你无法直接修改的 DOM 对象。你无法触及原生 <div> 的实现,也无法修改第三方组件,但是可以用指令来监视这些元素。

这个指令定义了 ngOnInit()ngOnDestroy() 钩子,它通过一个注入进来的 LoggerService 把消息记录到父组件中去。

Path:"src/app/spy.directive.ts" 。

// Spy on any element to which it is applied.// Usage: <div mySpy>...</div>@Directive({selector: '[mySpy]'})export class SpyDirective implements OnInit, OnDestroy {  constructor(private logger: LoggerService) { }  ngOnInit()    { this.logIt(`onInit`); }  ngOnDestroy() { this.logIt(`onDestroy`); }  private logIt(msg: string) {    this.logger.log(`Spy #${nextId++} ${msg}`);  }}

你可以把这个侦探指令写到任何原生元素或组件元素上,以观察它何时被初始化和销毁。 下面是把它附加到用来重复显示英雄数据的这个 <div> 上。

Path:"src/app/spy.component.html" 。

<div *ngFor="let hero of heroes" mySpy class="heroes">  {{hero}}</div>

每个“侦探”的创建和销毁都可以标出英雄所在的那个 <div> 的出现和消失。钩子记录中的结构是这样的:

添加一个英雄就会产生一个新的英雄 <div>。侦探的 ngOnInit() 记录下了这个事件。

Reset 按钮清除了这个 heroes 列表。 Angular 从 DOM 中移除了所有英雄的 div,并且同时销毁了附加在这些 div 上的侦探指令。 侦探的 ngOnDestroy() 方法汇报了它自己的临终时刻。

同时使用组件和指令的钩子

在这个例子中,CounterComponent 使用了 ngOnChanges() 方法,以便在每次父组件递增其输入属性 counter 时记录一次变更。

这个例子将前例中的 SpyDirective 用于 CounterComponent 的日志,以便监视这些日志条目的创建和销毁。

使用变更检测钩子

一旦检测到该组件或指令的输入属性发生了变化,Angular 就会调用它的 ngOnChanges() 方法。 这个 onChanges 范例通过监控 OnChanges() 钩子演示了这一点。

Path:"on-changes.component.ts (excerpt)" 。

ngOnChanges(changes: SimpleChanges) {  for (let propName in changes) {    let chng = changes[propName];    let cur  = JSON.stringify(chng.currentValue);    let prev = JSON.stringify(chng.previousValue);    this.changeLog.push(`${propName}: currentValue = ${cur}, previousValue = ${prev}`);  }}

ngOnChanges() 方法获取了一个对象,它把每个发生变化的属性名都映射到了一个 SimpleChange 对象, 该对象中有属性的当前值和前一个值。这个钩子会在这些发生了变化的属性上进行迭代,并记录它们。

这个例子中的 OnChangesComponent 组件有两个输入属性:heropower

Path:"src/app/on-changes.component.ts" 。

@Input() hero: Hero;@Input() power: string;

宿主 OnChangesParentComponent 绑定了它们,就像这样:

Path:"src/app/on-changes-parent.component.html" 。

<on-changes [hero]="hero" [power]="power"></on-changes>

下面是此例子中的当用户做出更改时的操作演示:

日志条目把 power 属性的变化显示为字符串。但请注意,ngOnChanges() 方法不会捕获对 hero.name 更改。这是因为只有当输入属性的值发生变化时,Angular 才会调用该钩子。在这种情况下,hero 是输入属性,hero 属性的值是对 hero 对象的引用 。当它自己的 name 属性的值发生变化时,对象引用并没有改变。

响应视图的变更

当 Angular 在变更检测期间遍历视图树时,需要确保子组件中的某个变更不会尝试更改其父组件中的属性。因为单向数据流的工作原理就是这样的,这样的更改将无法正常渲染。

如果你需要做一个与预期数据流反方向的修改,就必须触发一个新的变更检测周期,以允许渲染这种变更。这些例子说明了如何安全地做出这些改变。

AfterView 例子展示了 AfterViewInit()AfterViewChecked() 钩子,Angular 会在每次创建了组件的子视图后调用它们。

下面是一个子视图,它用来把英雄的名字显示在一个 <input> 中:

Path:"src/app/ChildComponent" 。

@Component({  selector: 'app-child-view',  template: '<input [(ngModel)]="hero">'})export class ChildViewComponent {  hero = 'Magneta';}

AfterViewComponent 把这个子视图显示在它的模板中:

Path:"src/app/AfterViewComponent (template)" 。

template: `  <div>-- child view begins --</div>    <app-child-view></app-child-view>  <div>-- child view ends --</div>`

下列钩子基于子视图中的每一次数据变更采取行动,它只能通过带@ViewChild装饰器的属性来访问子视图。

Path:"src/app/AfterViewComponent (class excerpts)" 。

export class AfterViewComponent implements  AfterViewChecked, AfterViewInit {  private prevHero = '';  // Query for a VIEW child of type `ChildViewComponent`  @ViewChild(ChildViewComponent) viewChild: ChildViewComponent;  ngAfterViewInit() {    // viewChild is set after the view has been initialized    this.logIt('AfterViewInit');    this.doSomething();  }  ngAfterViewChecked() {    // viewChild is updated after the view has been checked    if (this.prevHero === this.viewChild.hero) {      this.logIt('AfterViewChecked (no change)');    } else {      this.prevHero = this.viewChild.hero;      this.logIt('AfterViewChecked');      this.doSomething();    }  }  // ...}

在更新视图之前等待

在这个例子中,当英雄名字超过 10 个字符时,doSomething() 方法会更新屏幕,但在更新 comment 之前会等一个节拍(tick)。

Path:"src/app/AfterViewComponent (doSomething)" 。

// This surrogate for real business logic sets the `comment`private doSomething() {  let c = this.viewChild.hero.length > 10 ? `That's a long name` : '';  if (c !== this.comment) {    // Wait a tick because the component's view has already been checked    this.logger.tick_then(() => this.comment = c);  }}

在组件的视图合成完之后,就会触发 AfterViewInit()AfterViewChecked() 钩子。如果你修改了这段代码,让这个钩子立即修改该组件的数据绑定属性 comment,你就会发现 Angular 抛出一个错误。

LoggerService.tick_then() 语句把日志的更新工作推迟了一个浏览器 JavaScript 周期,也就触发了一个新的变更检测周期。

编写精简的钩子方法来避免性能问题

当你运行 AfterView 示例时,请注意当没有发生任何需要注意的变化时,Angular 仍然会频繁的调用 AfterViewChecked()。 要非常小心你放到这些方法中的逻辑或计算量。

响应被投影内容的变更

内容投影是从组件外部导入 HTML 内容,并把它插入在组件模板中指定位置上的一种途径。 你可以在目标中通过查找下列结构来认出内容投影。

  • 元素标签中间的 HTML。

  • 组件模板中的 <ng-content> 标签。

这个 AfterContent 例子探索了 AfterContentInit() 和 AfterContentChecked() 钩子。Angular 会在把外部内容投影进该组件时调用它们。

对比前面的 AfterView 例子考虑这个变化。 这次不再通过模板来把子视图包含进来,而是改为从 AfterContentComponent 的父组件中导入它。下面是父组件的模板:

Path:"src/app/AfterContentParentComponent (template excerpt)" 。

`<after-content>   <app-child></app-child> </after-content>`

注意,<app-child> 标签被包含在 <after-content> 标签中。 永远不要在组件标签的内部放任何内容 —— 除非你想把这些内容投影进这个组件中。

现在来看该组件的模板:

Path:"src/app/AfterContentComponent (template)" 。

template: `  <div>-- projected content begins --</div>    <ng-content></ng-content>  <div>-- projected content ends --</div>`

<ng-content> 标签是外来内容的占位符。 它告诉 Angular 在哪里插入这些外来内容。 在这里,被投影进去的内容就是来自父组件的 <app-child> 标签。

使用 AfterContent 钩子

AfterContent 钩子和 AfterView 相似。关键的不同点是子组件的类型不同。

AfterView 钩子所关心的是 ViewChildren,这些子组件的元素标签会出现在该组件的模板里面。

AfterContent 钩子所关心的是 ContentChildren,这些子组件被 Angular 投影进该组件中。

下列 AfterContent 钩子基于子级内容中值的变化而采取相应的行动,它只能通过带有 @ContentChild 装饰器的属性来查询到“子级内容”。

Path:"src/app/AfterContentComponent (class excerpts)" 。

export class AfterContentComponent implements AfterContentChecked, AfterContentInit {  private prevHero = '';  comment = '';  // Query for a CONTENT child of type `ChildComponent`  @ContentChild(ChildComponent) contentChild: ChildComponent;  ngAfterContentInit() {    // contentChild is set after the content has been initialized    this.logIt('AfterContentInit');    this.doSomething();  }  ngAfterContentChecked() {    // contentChild is updated after the content has been checked    if (this.prevHero === this.contentChild.hero) {      this.logIt('AfterContentChecked (no change)');    } else {      this.prevHero = this.contentChild.hero;      this.logIt('AfterContentChecked');      this.doSomething();    }  }  // ...}

&- 不需要等待内容更新

&- 该组件的 doSomething() 方法会立即更新该组件的数据绑定属性 comment。而无需延迟更新以确保正确渲染 。

&- Angular 在调用 AfterView 钩子之前,就已调用完所有的 AfterContent 钩子。 在完成该组件视图的合成之前, Angular 就已经完成了所投影内容的合成工作。 AfterContent... 和 AfterView... 钩子之间有一个小的时间窗,允许你修改宿主视图。

自定义变更检测逻辑

要监控 ngOnChanges() 无法捕获的变更,你可以实现自己的变更检查逻辑,比如 DoCheck 的例子。这个例子展示了你如何使用 ngDoCheck() 钩子来检测和处理 Angular 自己没有捕捉到的变化。

DoCheck 示例使用下面的 ngDoCheck() 钩子扩展了 OnChanges 示例:

Path:"src/app/DoCheckComponent (ngDoCheck)" 。

ngDoCheck() {  if (this.hero.name !== this.oldHeroName) {    this.changeDetected = true;    this.changeLog.push(`DoCheck: Hero name changed to "${this.hero.name}" from "${this.oldHeroName}"`);    this.oldHeroName = this.hero.name;  }  if (this.power !== this.oldPower) {    this.changeDetected = true;    this.changeLog.push(`DoCheck: Power changed to "${this.power}" from "${this.oldPower}"`);    this.oldPower = this.power;  }  if (this.changeDetected) {      this.noChangeCount = 0;  } else {      // log that hook was called when there was no relevant change.      let count = this.noChangeCount += 1;      let noChangeMsg = `DoCheck called ${count}x when no change to hero or power`;      if (count === 1) {        // add new "no change" message        this.changeLog.push(noChangeMsg);      } else {        // update last "no change" message        this.changeLog[this.changeLog.length - 1] = noChangeMsg;      }  }  this.changeDetected = false;}

这段代码会检查某些感兴趣的值,捕获并把它们当前的状态和之前的进行比较。当 heropower 没有实质性变化时,它就会在日志中写一条特殊的信息,这样你就能看到 DoCheck() 被调用的频率。其结果很有启发性。

虽然 ngDoCheck() 钩子可以检测出英雄的 name 何时发生了变化,但却非常昂贵。无论变化发生在何处,每个变化检测周期都会以很大的频率调用这个钩子。在用户可以执行任何操作之前,本例中已经调用了20多次。

这些初始化检查大部分都是由 Angular 首次在页面的其它地方渲染不相关的数据触发的。只要把光标移动到另一个 <input> 就会触发一次调用。其中的少数调用揭示了相关数据的实际变化情况。如果使用这个钩子,那么你的实现必须非常轻量级,否则会损害用户体验。

通过输入型绑定把数据从父组件传到子组件

HeroChildComponent 有两个输入型属性,它们通常带 @Input 装饰器。

Path:"component-interaction/src/app/hero-child.component.ts" 。

import { Component, Input } from '@angular/core';import { Hero } from './hero';@Component({  selector: 'app-hero-child',  template: `    <h3>{{hero.name}} says:</h3>    <p>I, {{hero.name}}, am at your service, {{masterName}}.</p>  `})export class HeroChildComponent {  @Input() hero: Hero;  @Input('master') masterName: string;}

第二个 @Input 为子组件的属性名 masterName 指定一个别名 master(译者注:不推荐为起别名,请参见风格指南).

父组件 HeroParentComponent 把子组件的 HeroChildComponent 放到 *ngFor 循环器中,把自己的 master 字符串属性绑定到子组件的 master 别名上,并把每个循环的 hero 实例绑定到子组件的 hero 属性。

Path:"component-interaction/src/app/hero-parent.component.ts" 。

import { Component } from '@angular/core';import { HEROES } from './hero';@Component({  selector: 'app-hero-parent',  template: `    <h2>{{master}} controls {{heroes.length}} heroes</h2>    <app-hero-child *ngFor="let hero of heroes"      [hero]="hero"      [master]="master">    </app-hero-child>  `})export class HeroParentComponent {  heroes = HEROES;  master = 'Master';}

运行应用程序会显示三个英雄:

端到端测试,用于确保所有的子组件都如预期般初始化并显示出来:

Path:"component-interaction/e2e/src/app.e2e-spec.ts" 。

// ...let _heroNames = ['Dr IQ', 'Magneta', 'Bombasto'];let _masterName = 'Master';it('should pass properties to children properly', function () {  let parent = element.all(by.tagName('app-hero-parent')).get(0);  let heroes = parent.all(by.tagName('app-hero-child'));  for (let i = 0; i < _heroNames.length; i++) {    let childTitle = heroes.get(i).element(by.tagName('h3')).getText();    let childDetail = heroes.get(i).element(by.tagName('p')).getText();    expect(childTitle).toEqual(_heroNames[i] + ' says:');    expect(childDetail).toContain(_masterName);  }});// ...

通过 setter 截听输入属性值的变化

使用一个输入属性的 setter,以拦截父组件中值的变化,并采取行动。

子组件 NameChildComponent 的输入属性 name 上的这个 setter,会 trim 掉名字里的空格,并把空值替换成默认字符串。

Path:"component-interaction/src/app/name-child.component.ts" 。

import { Component, Input } from '@angular/core';@Component({  selector: 'app-name-child',  template: '<h3>"{{name}}"</h3>'})export class NameChildComponent {  private _name = '';  @Input()  set name(name: string) {    this._name = (name && name.trim()) || '<no name set>';  }  get name(): string { return this._name; }}

下面的 NameParentComponent 展示了各种名字的处理方式,包括一个全是空格的名字。

Path:"component-interaction/src/app/name-parent.component.ts" 。

import { Component } from '@angular/core';@Component({  selector: 'app-name-parent',  template: `  <h2>Master controls {{names.length}} names</h2>  <app-name-child *ngFor="let name of names" [name]="name"></app-name-child>  `})export class NameParentComponent {  // Displays 'Dr IQ', '<no name set>', 'Bombasto'  names = ['Dr IQ', '   ', '  Bombasto  '];}

端到端测试:输入属性的 setter,分别使用空名字和非空名字。

Path:"component-interaction/e2e/src/app.e2e-spec.ts" 。

// ...it('should display trimmed, non-empty names', function () {  let _nonEmptyNameIndex = 0;  let _nonEmptyName = '"Dr IQ"';  let parent = element.all(by.tagName('app-name-parent')).get(0);  let hero = parent.all(by.tagName('app-name-child')).get(_nonEmptyNameIndex);  let displayName = hero.element(by.tagName('h3')).getText();  expect(displayName).toEqual(_nonEmptyName);});it('should replace empty name with default name', function () {  let _emptyNameIndex = 1;  let _defaultName = '"<no name set>"';  let parent = element.all(by.tagName('app-name-parent')).get(0);  let hero = parent.all(by.tagName('app-name-child')).get(_emptyNameIndex);  let displayName = hero.element(by.tagName('h3')).getText();  expect(displayName).toEqual(_defaultName);});// ...

通过ngOnChanges()来截听输入属性值的变化

使用 OnChanges 生命周期钩子接口的 ngOnChanges() 方法来监测输入属性值的变化并做出回应。

&当需要监视多个、交互式输入属性的时候,本方法比用属性的 setter 更合适。

这个 VersionChildComponent 会监测输入属性 majorminor 的变化,并把这些变化编写成日志以报告这些变化。

Path:"component-interaction/src/app/version-child.component.ts" 。

import { Component, Input, OnChanges, SimpleChange } from '@angular/core';@Component({  selector: 'app-version-child',  template: `    <h3>Version {{major}}.{{minor}}</h3>    <h4>Change log:</h4>    <ul>      <li *ngFor="let change of changeLog">{{change}}</li>    </ul>  `})export class VersionChildComponent implements OnChanges {  @Input() major: number;  @Input() minor: number;  changeLog: string[] = [];  ngOnChanges(changes: {[propKey: string]: SimpleChange}) {    let log: string[] = [];    for (let propName in changes) {      let changedProp = changes[propName];      let to = JSON.stringify(changedProp.currentValue);      if (changedProp.isFirstChange()) {        log.push(`Initial value of ${propName} set to ${to}`);      } else {        let from = JSON.stringify(changedProp.previousValue);        log.push(`${propName} changed from ${from} to ${to}`);      }    }    this.changeLog.push(log.join(', '));  }}

VersionParentComponent 提供 minormajor 值,把修改它们值的方法绑定到按钮上。

Path:"component-interaction/src/app/version-parent.component.ts" 。

import { Component } from '@angular/core';@Component({  selector: 'app-version-parent',  template: `    <h2>Source code version</h2>    <button (click)="newMinor()">New minor version</button>    <button (click)="newMajor()">New major version</button>    <app-version-child [major]="major" [minor]="minor"></app-version-child>  `})export class VersionParentComponent {  major = 1;  minor = 23;  newMinor() {    this.minor++;  }  newMajor() {    this.major++;    this.minor = 0;  }}

下面是点击按钮的结果。

测试确保这两个输入属性值都被初始化了,当点击按钮后,ngOnChanges 应该被调用,属性的值也符合预期。

Path:"component-interaction/e2e/src/app.e2e-spec.ts" 。

// ...// Test must all execute in this exact orderit('should set expected initial values', function () {  let actual = getActual();  let initialLabel = 'Version 1.23';  let initialLog = 'Initial value of major set to 1, Initial value of minor set to 23';  expect(actual.label).toBe(initialLabel);  expect(actual.count).toBe(1);  expect(actual.logs.get(0).getText()).toBe(initialLog);});it('should set expected values after clicking 'Minor' twice', function () {  let repoTag = element(by.tagName('app-version-parent'));  let newMinorButton = repoTag.all(by.tagName('button')).get(0);  newMinorButton.click().then(function() {    newMinorButton.click().then(function() {      let actual = getActual();      let labelAfter2Minor = 'Version 1.25';      let logAfter2Minor = 'minor changed from 24 to 25';      expect(actual.label).toBe(labelAfter2Minor);      expect(actual.count).toBe(3);      expect(actual.logs.get(2).getText()).toBe(logAfter2Minor);    });  });});it('should set expected values after clicking 'Major' once', function () {  let repoTag = element(by.tagName('app-version-parent'));  let newMajorButton = repoTag.all(by.tagName('button')).get(1);  newMajorButton.click().then(function() {    let actual = getActual();    let labelAfterMajor = 'Version 2.0';    let logAfterMajor = 'major changed from 1 to 2, minor changed from 25 to 0';    expect(actual.label).toBe(labelAfterMajor);    expect(actual.count).toBe(4);    expect(actual.logs.get(3).getText()).toBe(logAfterMajor);  });});function getActual() {  let versionTag = element(by.tagName('app-version-child'));  let label = versionTag.element(by.tagName('h3')).getText();  let ul = versionTag.element((by.tagName('ul')));  let logs = ul.all(by.tagName('li'));  return {    label: label,    logs: logs,    count: logs.count()  };}// ...

父组件监听子组件的事件

子组件暴露一个 EventEmitter 属性,当事件发生时,子组件利用该属性 emits(向上弹射)事件。父组件绑定到这个事件属性,并在事件发生时作出回应。

子组件的 EventEmitter 属性是一个输出属性,通常带有 @Output 装饰器,就像在 VoterComponent 中看到的。

Path:"component-interaction/src/app/voter.component.ts" 。

import { Component, EventEmitter, Input, Output } from '@angular/core';@Component({  selector: 'app-voter',  template: `    <h4>{{name}}</h4>    <button (click)="vote(true)"  [disabled]="didVote">Agree</button>    <button (click)="vote(false)" [disabled]="didVote">Disagree</button>  `})export class VoterComponent {  @Input()  name: string;  @Output() voted = new EventEmitter<boolean>();  didVote = false;  vote(agreed: boolean) {    this.voted.emit(agreed);    this.didVote = true;  }}

点击按钮会触发 truefalse (布尔型有效载荷)的事件。

父组件 VoteTakerComponent 绑定了一个事件处理器(onVoted()),用来响应子组件的事件($event)并更新一个计数器。

Path:"component-interaction/src/app/votetaker.component.ts" 。

import { Component }      from '@angular/core';@Component({  selector: 'app-vote-taker',  template: `    <h2>Should mankind colonize the Universe?</h2>    <h3>Agree: {{agreed}}, Disagree: {{disagreed}}</h3>    <app-voter *ngFor="let voter of voters"      [name]="voter"      (voted)="onVoted($event)">    </app-voter>  `})export class VoteTakerComponent {  agreed = 0;  disagreed = 0;  voters = ['Narco', 'Celeritas', 'Bombasto'];  onVoted(agreed: boolean) {    agreed ? this.agreed++ : this.disagreed++;  }}

本框架把事件参数(用 $event 表示)传给事件处理方法,该方法会处理它:

测试确保点击 AgreeDisagree 按钮时,计数器被正确更新。

Path:"component-interaction/e2e/src/app.e2e-spec.ts" 。

// ...it('should not emit the event initially', function () {  let voteLabel = element(by.tagName('app-vote-taker'))    .element(by.tagName('h3')).getText();  expect(voteLabel).toBe('Agree: 0, Disagree: 0');});it('should process Agree vote', function () {  let agreeButton1 = element.all(by.tagName('app-voter')).get(0)    .all(by.tagName('button')).get(0);  agreeButton1.click().then(function() {    let voteLabel = element(by.tagName('app-vote-taker'))      .element(by.tagName('h3')).getText();    expect(voteLabel).toBe('Agree: 1, Disagree: 0');  });});it('should process Disagree vote', function () {  let agreeButton1 = element.all(by.tagName('app-voter')).get(1)    .all(by.tagName('button')).get(1);  agreeButton1.click().then(function() {    let voteLabel = element(by.tagName('app-vote-taker'))      .element(by.tagName('h3')).getText();    expect(voteLabel).toBe('Agree: 1, Disagree: 1');  });});// ...

父组件与子组件通过本地变量互动

父组件不能使用数据绑定来读取子组件的属性或调用子组件的方法。但可以在父组件模板里,新建一个本地变量来代表子组件,然后利用这个变量来读取子组件的属性和调用子组件的方法,如下例所示。

子组件 CountdownTimerComponent 进行倒计时,归零时发射一个导弹。startstop 方法负责控制时钟并在模板里显示倒计时的状态信息。

Path:"component-interaction/src/app/countdown-timer.component.ts" 。

import { Component, OnDestroy, OnInit } from '@angular/core';@Component({  selector: 'app-countdown-timer',  template: '<p>{{message}}</p>'})export class CountdownTimerComponent implements OnInit, OnDestroy {  intervalId = 0;  message = '';  seconds = 11;  clearTimer() { clearInterval(this.intervalId); }  ngOnInit()    { this.start(); }  ngOnDestroy() { this.clearTimer(); }  start() { this.countDown(); }  stop()  {    this.clearTimer();    this.message = `Holding at T-${this.seconds} seconds`;  }  private countDown() {    this.clearTimer();    this.intervalId = window.setInterval(() => {      this.seconds -= 1;      if (this.seconds === 0) {        this.message = 'Blast off!';      } else {        if (this.seconds < 0) { this.seconds = 10; } // reset        this.message = `T-${this.seconds} seconds and counting`;      }    }, 1000);  }}

计时器组件的宿主组件 CountdownLocalVarParentComponent 如下:

Path:"component-interaction/src/app/countdown-parent.component.ts" 。

import { Component }                from '@angular/core';import { CountdownTimerComponent }  from './countdown-timer.component';@Component({  selector: 'app-countdown-parent-lv',  template: `  <h3>Countdown to Liftoff (via local variable)</h3>  <button (click)="timer.start()">Start</button>  <button (click)="timer.stop()">Stop</button>  <div class="seconds">{{timer.seconds}}</div>  <app-countdown-timer #timer></app-countdown-timer>  `,  styleUrls: ['../assets/demo.css']})export class CountdownLocalVarParentComponent { }

父组件不能通过数据绑定使用子组件的 start 和 stop 方法,也不能访问子组件的 seconds 属性。

把本地变量(#timer)放到(<countdown-timer>)标签中,用来代表子组件。这样父组件的模板就得到了子组件的引用,于是可以在父组件的模板中访问子组件的所有属性和方法。

这个例子把父组件的按钮绑定到子组件的 start 和 stop 方法,并用插值来显示子组件的 seconds 属性。

下面是父组件和子组件一起工作时的效果。

测试确保在父组件模板中显示的秒数和子组件状态信息里的秒数同步。它还会点击 Stop 按钮来停止倒计时:

Path:"component-interaction/e2e/src/app.e2e-spec.ts" 。

// ...it('timer and parent seconds should match', function () {  let parent = element(by.tagName(parentTag));  let message = parent.element(by.tagName('app-countdown-timer')).getText();  browser.sleep(10); // give `seconds` a chance to catchup with `message`  let seconds = parent.element(by.className('seconds')).getText();  expect(message).toContain(seconds);});it('should stop the countdown', function () {  let parent = element(by.tagName(parentTag));  let stopButton = parent.all(by.tagName('button')).get(1);  stopButton.click().then(function() {    let message = parent.element(by.tagName('app-countdown-timer')).getText();    expect(message).toContain('Holding');  });});// ...

父组件调用@ViewChild()

这个本地变量方法是个简单便利的方法。但是它也有局限性,因为父组件-子组件的连接必须全部在父组件的模板中进行。父组件本身的代码对子组件没有访问权。

如果父组件的类需要读取子组件的属性值或调用子组件的方法,就不能使用本地变量方法。

当父组件类需要这种访问时,可以把子组件作为 ViewChild ,注入到父组件里面。

下面的例子用与倒计时相同的范例来解释这种技术。 它的外观或行为没有变化。子组件 CountdownTimerComponent 也和原来一样。

注:
- 由本地变量切换到 ViewChild 技术的唯一目的就是做示范。

下面是父组件 CountdownViewChildParentComponent:

Path:"component-interaction/src/app/countdown-parent.component.ts" 。

import { AfterViewInit, ViewChild } from '@angular/core';import { Component }                from '@angular/core';import { CountdownTimerComponent }  from './countdown-timer.component';@Component({  selector: 'app-countdown-parent-vc',  template: `  <h3>Countdown to Liftoff (via ViewChild)</h3>  <button (click)="start()">Start</button>  <button (click)="stop()">Stop</button>  <div class="seconds">{{ seconds() }}</div>  <app-countdown-timer></app-countdown-timer>  `,  styleUrls: ['../assets/demo.css']})export class CountdownViewChildParentComponent implements AfterViewInit {  @ViewChild(CountdownTimerComponent)  private timerComponent: CountdownTimerComponent;  seconds() { return 0; }  ngAfterViewInit() {    // Redefine `seconds()` to get from the `CountdownTimerComponent.seconds` ...    // but wait a tick first to avoid one-time devMode    // unidirectional-data-flow-violation error    setTimeout(() => this.seconds = () => this.timerComponent.seconds, 0);  }  start() { this.timerComponent.start(); }  stop() { this.timerComponent.stop(); }}

把子组件的视图插入到父组件类需要做一点额外的工作。

首先,你必须导入对装饰器 ViewChild 以及生命周期钩子 AfterViewInit 的引用。

接着,通过 @ViewChild 属性装饰器,将子组件 CountdownTimerComponent 注入到私有属性 timerComponent 里面。

组件元数据里就不再需要 #timer 本地变量了。而是把按钮绑定到父组件自己的 startstop 方法,使用父组件的 seconds 方法的插值来展示秒数变化。

这些方法可以直接访问被注入的计时器组件。

ngAfterViewInit() 生命周期钩子是非常重要的一步。被注入的计时器组件只有在 Angular 显示了父组件视图之后才能访问,所以它先把秒数显示为 0.

然后 Angular 会调用 ngAfterViewInit 生命周期钩子,但这时候再更新父组件视图的倒计时就已经太晚了。Angular 的单向数据流规则会阻止在同一个周期内更新父组件视图。应用在显示秒数之前会被迫再等一轮。

使用 setTimeout() 来等下一轮,然后改写 seconds() 方法,这样它接下来就会从注入的这个计时器组件里获取秒数的值。

注:
- 可以使用和之前一样的倒计时测试,此处不再重复操作。

父组件和子组件通过服务来通讯

父组件和它的子组件共享同一个服务,利用该服务在组件家族内部实现双向通讯。

该服务实例的作用域被限制在父组件和其子组件内。这个组件子树之外的组件将无法访问该服务或者与它们通讯。

这个 MissionServiceMissionControlComponent 和多个 AstronautComponent 子组件连接起来。

Path:"component-interaction/src/app/mission.service.ts" 。

import { Injectable } from '@angular/core';import { Subject }    from 'rxjs';@Injectable()export class MissionService {  // Observable string sources  private missionAnnouncedSource = new Subject<string>();  private missionConfirmedSource = new Subject<string>();  // Observable string streams  missionAnnounced$ = this.missionAnnouncedSource.asObservable();  missionConfirmed$ = this.missionConfirmedSource.asObservable();  // Service message commands  announceMission(mission: string) {    this.missionAnnouncedSource.next(mission);  }  confirmMission(astronaut: string) {    this.missionConfirmedSource.next(astronaut);  }}

MissionControlComponent 提供服务的实例,并将其共享给它的子组件(通过 providers 元数据数组),子组件可以通过构造函数将该实例注入到自身。

Path:"component-interaction/src/app/missioncontrol.component.ts" 。

import { Component }          from '@angular/core';import { MissionService }     from './mission.service';@Component({  selector: 'app-mission-control',  template: `    <h2>Mission Control</h2>    <button (click)="announce()">Announce mission</button>    <app-astronaut *ngFor="let astronaut of astronauts"      [astronaut]="astronaut">    </app-astronaut>    <h3>History</h3>    <ul>      <li *ngFor="let event of history">{{event}}</li>    </ul>  `,  providers: [MissionService]})export class MissionControlComponent {  astronauts = ['Lovell', 'Swigert', 'Haise'];  history: string[] = [];  missions = ['Fly to the moon!',              'Fly to mars!',              'Fly to Vegas!'];  nextMission = 0;  constructor(private missionService: MissionService) {    missionService.missionConfirmed$.subscribe(      astronaut => {        this.history.push(`${astronaut} confirmed the mission`);      });  }  announce() {    let mission = this.missions[this.nextMission++];    this.missionService.announceMission(mission);    this.history.push(`Mission "${mission}" announced`);    if (this.nextMission >= this.missions.length) { this.nextMission = 0; }  }}

AstronautComponent 也通过自己的构造函数注入该服务。由于每个 AstronautComponent 都是 MissionControlComponent 的子组件,所以它们获取到的也是父组件的这个服务实例。

Path:"component-interaction/src/app/astronaut.component.ts" 。

import { Component, Input, OnDestroy } from '@angular/core';import { MissionService } from './mission.service';import { Subscription }   from 'rxjs';@Component({  selector: 'app-astronaut',  template: `    <p>      {{astronaut}}: <strong>{{mission}}</strong>      <button        (click)="confirm()"        [disabled]="!announced || confirmed">        Confirm      </button>    </p>  `})export class AstronautComponent implements OnDestroy {  @Input() astronaut: string;  mission = '<no mission announced>';  confirmed = false;  announced = false;  subscription: Subscription;  constructor(private missionService: MissionService) {    this.subscription = missionService.missionAnnounced$.subscribe(      mission => {        this.mission = mission;        this.announced = true;        this.confirmed = false;    });  }  confirm() {    this.confirmed = true;    this.missionService.confirmMission(this.astronaut);  }  ngOnDestroy() {    // prevent memory leak when component destroyed    this.subscription.unsubscribe();  }}

注:
- 这个例子保存了 subscription 变量,并在 AstronautComponent 被销毁时调用 unsubscribe() 退订。 这是一个用于防止内存泄漏的保护措施。实际上,在这个应用程序中并没有这个风险,因为 AstronautComponent 的生命期和应用程序的生命期一样长。但在更复杂的应用程序环境中就不一定了。

  • 不需要在 MissionControlComponent 中添加这个保护措施,因为它作为父组件,控制着 MissionService 的生命期。

History 日志证明了:在父组件 MissionControlComponent 和子组件 AstronautComponent 之间,信息通过该服务实现了双向传递。

测试确保点击父组件 MissionControlComponent 和子组件 AstronautComponent 两个的组件的按钮时,History 日志和预期的一样。

Path:"component-interaction/e2e/src/app.e2e-spec.ts" 。

// ...it('should announce a mission', function () {  let missionControl = element(by.tagName('app-mission-control'));  let announceButton = missionControl.all(by.tagName('button')).get(0);  announceButton.click().then(function () {    let history = missionControl.all(by.tagName('li'));    expect(history.count()).toBe(1);    expect(history.get(0).getText()).toMatch(/Mission.* announced/);  });});it('should confirm the mission by Lovell', function () {  testConfirmMission(1, 2, 'Lovell');});it('should confirm the mission by Haise', function () {  testConfirmMission(3, 3, 'Haise');});it('should confirm the mission by Swigert', function () {  testConfirmMission(2, 4, 'Swigert');});function testConfirmMission(buttonIndex: number, expectedLogCount: number, astronaut: string) {  let _confirmedLog = ' confirmed the mission';  let missionControl = element(by.tagName('app-mission-control'));  let confirmButton = missionControl.all(by.tagName('button')).get(buttonIndex);  confirmButton.click().then(function () {    let history = missionControl.all(by.tagName('li'));    expect(history.count()).toBe(expectedLogCount);    expect(history.get(expectedLogCount - 1).getText()).toBe(astronaut + _confirmedLog);  });}// ...

Angular 应用使用标准的 CSS 来设置样式。这意味着你可以把关于 CSS 的那些知识和技能直接用于 Angular 程序中,例如:样式表、选择器、规则以及媒体查询等。

另外,Angular 还能把组件样式捆绑在组件上,以实现比标准样式表更加模块化的设计。

使用组件样式

对你编写的每个 Angular 组件来说,除了定义 HTML 模板之外,还要定义用于模板的 CSS 样式、 指定任意的选择器、规则和媒体查询。

实现方式之一,是在组件的元数据中设置 styles 属性。 styles 属性可以接受一个包含 CSS 代码的字符串数组。 通常你只给它一个字符串就行了,如同下例:

Path:"src/app/hero-app.component.ts" 。

@Component({  selector: 'app-root',  template: `    <h1>Tour of Heroes</h1>    <app-hero-main [hero]="hero"></app-hero-main>  `,  styles: ['h1 { font-weight: normal; }']})export class HeroAppComponent {/* . . . */}

范围化的样式

它们既不会被模板中嵌入的组件继承,也不会被通过内容投影(如 ng-content)嵌进来的组件继承。

在这个例子中,h1 的样式只对 HeroAppComponent 生效,既不会作用于内嵌的 HeroMainComponent,也不会作用于应用中其它任何地方的 <h1> 标签。

这种范围限制就是所谓的样式模块化特性

  • 可以使用对每个组件最有意义的 CSS 类名和选择器。

  • 类名和选择器是局限于该组件的,它不会和应用中其它地方的类名和选择器冲突。

  • 组件的样式不会因为别的地方修改了样式而被意外改变。

  • 你可以让每个组件的 CSS 代码和它的 TypeScript、HTML 代码放在一起,这将促成清爽整洁的项目结构。

  • 将来你可以修改或移除组件的 CSS 代码,而不用遍历整个应用来看它有没有在别处用到。

注:
- 在 @Component 的元数据中指定的样式只会对该组件的模板生效。

特殊的选择器

组件样式中有一些从影子(Shadow) DOM 样式范围领域引入的特殊选择器:

:host

使用 :host 伪类选择器,用来选择组件宿主元素中的元素(相对于组件模板内部的元素)。

Path:"src/app/hero-details.component.css" 。

:host {  display: block;  border: 1px solid black;}

:host 选择是是把宿主元素作为目标的唯一方式。除此之外,你将没办法指定它, 因为宿主不是组件自身模板的一部分,而是父组件模板的一部分。

要把宿主样式作为条件,就要像函数一样把其它选择器放在 :host 后面的括号中。

下一个例子再次把宿主元素作为目标,但是只有当它同时带有 active CSS 类的时候才会生效。

Path:"src/app/hero-details.component.css" 。

:host(.active) {  border-width: 3px;}

:host-context

有时候,基于某些来自组件视图外部的条件应用样式是很有用的。 例如,在文档的 <body> 元素上可能有一个用于表示样式主题 (theme) 的 CSS 类,你应当基于它来决定组件的样式。

这时可以使用 :host-context() 伪类选择器。它也以类似 :host() 形式使用。它在当前组件宿主元素的祖先节点中查找 CSS 类, 直到文档的根节点为止。在与其它选择器组合使用时,它非常有用。

在下面的例子中,只有当某个祖先元素有 CSS 类 theme-light 时,才会把 background-color 样式应用到组件内部的所有 <h2> 元素中。

Path:"src/app/hero-details.component.css" 。

:host-context(.theme-light) h2 {  background-color: #eef;}

已废弃 /deep/、>>> 和 ::ng-deep

组件样式通常只会作用于组件自身的 HTML 上。

把伪类 ::ng-deep 应用到任何一条 CSS 规则上就会完全禁止对那条规则的视图包装。任何带有 ::ng-deep 的样式都会变成全局样式。为了把指定的样式限定在当前组件及其下级组件中,请确保在 ::ng-deep 之前带上 :host 选择器。如果 ::ng-deep 组合器在 :host 伪类之外使用,该样式就会污染其它组件。

这个例子以所有的 <h3> 元素为目标,从宿主元素到当前元素再到 DOM 中的所有子元素:

Path:"src/app/hero-details.component.css" 。

:host /deep/ h3 {  font-style: italic;}

/deep/ 组合器还有两个别名:>>>::ng-deep

注:
- /deep/>>> 选择器只能被用在仿真 (emulated) 模式下。 这种方式是默认值,也是用得最多的方式。

  • CSS 标准中用于 "刺穿 Shadow DOM" 的组合器已经被废弃,并将这个特性从主流浏览器和工具中移除。 因此,Angular 也将会移除对它们的支持(包括 /deep/&&&::ng-deep)。 目前,建议先统一使用 ::ng-deep,以便兼容将来的工具。

把样式加载进组件中

有几种方式把样式加入组件:

  • 设置 stylesstyleUrls 元数据

  • 内联在模板的 HTML 中

  • 通过 CSS 文件导入

上述作用域规则对所有这些加载模式都适用。

元数据中的样式

你可以给 @Component 装饰器添加一个 styles 数组型属性。

这个数组中的每一个字符串(通常也只有一个)定义一份 CSS。

Path:"src/app/hero-app.component.ts (CSS inline)" 。

@Component({  selector: 'app-root',  template: `    <h1>Tour of Heroes</h1>    <app-hero-main [hero]="hero"></app-hero-main>  `,  styles: ['h1 { font-weight: normal; }']})export class HeroAppComponent {/* . . . */}

注:
- 这些样式只对当前组件生效。 它们既不会作用于模板中嵌入的任何组件,也不会作用于投影进来的组件(如 ng-content )。

当使用 --inline-styles 标识创建组件时,Angular CLI 的 ng generate component 命令就会定义一个空的 styles 数组。

ng generate component hero-app --inline-style

组件元数据中的样式文件

你可以通过把外部 CSS 文件添加到 @ComponentstyleUrls 属性中来加载外部样式。

  1. Path:"src/app/hero-app.component.ts (CSS in file)" 。

    @Component({      selector: 'app-root',      template: `        <h1>Tour of Heroes</h1>        <app-hero-main [hero]="hero"></app-hero-main>      `,      styleUrls: ['./hero-app.component.css']    })    export class HeroAppComponent {    /* . . . */    }

  1. Path:"src/app/hero-app.component.css" 。

    h1 {      font-weight: normal;    }

注:
- 这些样式只对当前组件生效。 它们既不会作用于模板中嵌入的任何组件,也不会作用于投影进来的组件(如 ng-content )。

  • 你可以指定多个样式文件,甚至可以组合使用 stylestyleUrls 方式。

当你使用 Angular CLI 的 ng generate component 命令但不带 --inline-style 标志时,CLI 会为你创建一个空白的样式表文件,并且在所生成组件的 styleUrls 中引用该文件。

ng generate component hero-app

模板内联样式

你也可以直接在组件的 HTML 模板中写 <style> 标签来内嵌 CSS 样式。

Path:"src/app/hero-controls.component.ts" 。

@Component({  selector: 'app-hero-controls',  template: `    <style>      button {        background-color: white;        border: 1px solid #777;      }    </style>    <h3>Controls</h3>    <button (click)="activate()">Activate</button>  `})

模板中的 link 标签

你也可以在组件的 HTML 模板中写 <link> 标签。

Path:"src/app/hero-team.component.ts" 。

@Component({  selector: 'app-hero-team',  template: `    <!-- We must use a relative URL so that the AOT compiler can find the stylesheet -->    <link rel="stylesheet" href="../assets/hero-team.component.css">    <h3>Team</h3>    <ul>      <li *ngFor="let member of hero.team">        {{member}}      </li>    </ul>`})

注:
- 当使用 CLI 进行构建时,要确保这个链接到的样式表文件被复制到了服务器上。

  • 只要引用过,CLI 就会计入这个样式表,无论这个 link 标签的 href 指向的 URL 是相对于应用根目录的还是相对于组件文件的。

CSS @imports 语法

你还可以利用标准的 CSS @import 规则来把其它 CSS 文件导入到 CSS 文件中。

在这种情况下,URL 是相对于你正在导入的 CSS 文件的。

Path:"src/app/hero-details.component.css (excerpt)" 。

/* The AOT compiler needs the `./` to show that this is local */@import './hero-details-box.css';

外部以及全局样式文件

当使用 CLI 进行构建时,你必须配置 "angular.json" 文件,使其包含所有外部资源(包括外部的样式表文件)。

在它的 styles 区注册这些全局样式文件,默认情况下,它会有一个预先配置的全局 "styles.css" 文件。

非 CSS 样式文件

如果使用 CLI 进行构建,那么你可以用 sasslessstylus 来编写样式,并使用相应的扩展名(.scss.less.styl)把它们指定到 @Component.styleUrls 元数据中。例子如下:

@Component({  selector: 'app-root',  templateUrl: './app.component.html',  styleUrls: ['./app.component.scss']})...

CLI 的构建过程会运行相关的预处理器。

当使用 ng generate component 命令生成组件文件时,CLI 会默认生成一个空白的 CSS 样式文件(.css)。 你可以配置 CLI,让它默认使用你喜欢的 CSS 预处理器。

注:
- 添加到 @Component.styles 数组中的字符串必须写成 CSS,因为 CLI 没法对这些内联的样式使用任何 CSS 预处理器。

视图封装模式

像上面讨论过的一样,组件的 CSS 样式被封装进了自己的视图中,而不会影响到应用程序的其它部分。

通过在组件的元数据上设置视图封装模式,你可以分别控制每个组件的封装模式。 可选的封装模式一共有如下几种:

  • ShadowDom 模式使用浏览器原生的 Shadow DOM 实现(参见 MDN 上的 Shadow DOM)来为组件的宿主元素附加一个 Shadow DOM。组件的视图被附加到这个 Shadow DOM 中,组件的样式也被包含在这个 Shadow DOM 中。(译注:不进不出,没有样式能进来,组件样式出不去。)

  • Native 视图包装模式使用浏览器原生 Shadow DOM 的一个废弃实现 —— 参见变化详情。

  • Emulated 模式(默认值)通过预处理(并改名)CSS 代码来模拟 Shadow DOM 的行为,以达到把 CSS 样式局限在组件视图中的目的。 更多信息,见附录 1。(译注:只进不出,全局样式能进来,组件样式出不去)

  • None 意味着 Angular 不使用视图封装。 Angular 会把 CSS 添加到全局样式中。而不会应用上前面讨论过的那些作用域规则、隔离和保护等。 从本质上来说,这跟把组件的样式直接放进 HTML 是一样的。(译注:能进能出。)

通过组件元数据中的 encapsulation 属性来设置组件封装模式:

Path:"src/app/quest-summary.component.ts" 。

// warning: few browsers support shadow DOM encapsulation at this timeencapsulation: ViewEncapsulation.Native

ShadowDom 模式只适用于提供了原生 Shadow DOM 支持的浏览器(参见 Can I use 上的 Shadow DOM v1 部分)。 它仍然受到很多限制,这就是为什么仿真 (Emulated) 模式是默认选项,并建议将其用于大多数情况。

查看生成的 CSS

当使用默认的仿真模式时,Angular 会对组件的所有样式进行预处理,让它们模仿出标准的 Shadow CSS 作用域规则。

在启用了仿真模式的 Angular 应用的 DOM 树中,每个 DOM 元素都被加上了一些额外的属性。

<hero-details _nghost-pmm-5>  <h2 _ngcontent-pmm-5>Mister Fantastic</h2>  <hero-team _ngcontent-pmm-5 _nghost-pmm-6>    <h3 _ngcontent-pmm-6>Team</h3>  </hero-team></hero-detail>

生成出的属性分为两种:

一个元素在原生封装方式下可能是 Shadow DOM 的宿主,在这里被自动添加上一个 _nghost 属性。 这是组件宿主元素的典型情况。

组件视图中的每一个元素,都有一个 _ngcontent 属性,它会标记出该元素属于哪个宿主的模拟 Shadow DOM。

这些属性的具体值并不重要。它们是自动生成的,并且你永远不会在程序代码中直接引用到它们。 但它们会作为生成的组件样式的目标,就像 DOM 的 <head> 中一样:

[_nghost-pmm-5] {  display: block;  border: 1px solid black;}h3[_ngcontent-pmm-6] {  background-color: white;  border: 1px solid #777;}

这些就是那些样式被处理后的结果,每个选择器都被增加了 _nghost_ngcontent 属性选择器。 这些额外的选择器实现了本文所描述的这些作用域规则。

&本节讲的是一个用于显示广告的范例,而部分广告拦截器插件,比如 Chrome 的 AdGuard,可能会破坏其工作逻辑,因此,请在本页关闭那些插件。

组件的模板不会永远是固定的。应用可能会需要在运行期间加载一些新的组件。

动态组件加载

下面的例子展示了如何构建动态广告条。

英雄管理局正在计划一个广告活动,要在广告条中显示一系列不同的广告。几个不同的小组可能会频繁加入新的广告组件。 再用只支持静态组件结构的模板显然是不现实的。

你需要一种新的组件加载方式,它不需要在广告条组件的模板中引用固定的组件。

Angular 自带的 API 就能支持动态加载组件。

指令

在添加组件之前,先要定义一个锚点来告诉 Angular 要把组件插入到什么地方。

广告条使用一个名叫 AdDirective 的辅助指令来在模板中标记出有效的插入点。

Path:"src/app/ad.directive.ts" 。

import { Directive, ViewContainerRef } from '@angular/core';@Directive({  selector: '[ad-host]',})export class AdDirective {  constructor(public viewContainerRef: ViewContainerRef) { }}

AdDirective 注入了 ViewContainerRef 来获取对容器视图的访问权,这个容器就是那些动态加入的组件的宿主。

@Directive 装饰器中,要注意选择器的名称:ad-host,它就是你将应用到元素上的指令。

加载组件

广告条的大部分实现代码都在 "ad-banner.component.ts" 中。 为了让这个例子简单点,HTML 被直接放在了 @Component 装饰器的 template 属性中。

<ng-template> 元素就是刚才制作的指令将应用到的地方。 要应用 AdDirective,回忆一下来自 "ad.directive.ts" 的选择器 ad-host。把它应用到 <ng-template>(不用带方括号)。 这下,Angular 就知道该把组件动态加载到哪里了。

Path:"src/app/ad-banner.component.ts (template)" 。

template: `            <div class="ad-banner-example">              <h3>Advertisements</h3>              <ng-template ad-host></ng-template>            </div>          `

<ng-template> 元素是动态加载组件的最佳选择,因为它不会渲染任何额外的输出。

解析组件

深入看看 "ad-banner.component.ts" 中的方法。

AdBannerComponent 接收一个 AdItem 对象的数组作为输入,它最终来自 AdServiceAdItem 对象指定要加载的组件类,以及绑定到该组件上的任意数据。 AdService 可以返回广告活动中的那些广告。

AdBannerComponent 传入一个组件数组可以在模板中放入一个广告的动态列表,而不用写死在模板中。

通过 getAds() 方法,AdBannerComponent 可以循环遍历 AdItems 的数组,并且每三秒调用一次 loadComponent() 来加载新组件。

Path:"src/app/ad-banner.component.ts (excerpt)" 。

export class AdBannerComponent implements OnInit, OnDestroy {  @Input() ads: AdItem[];  currentAdIndex = -1;  @ViewChild(AdDirective, {static: true}) adHost: AdDirective;  interval: any;  constructor(private componentFactoryResolver: ComponentFactoryResolver) { }  ngOnInit() {    this.loadComponent();    this.getAds();  }  ngOnDestroy() {    clearInterval(this.interval);  }  loadComponent() {    this.currentAdIndex = (this.currentAdIndex + 1) % this.ads.length;    const adItem = this.ads[this.currentAdIndex];    const componentFactory = this.componentFactoryResolver.resolveComponentFactory(adItem.component);    const viewContainerRef = this.adHost.viewContainerRef;    viewContainerRef.clear();    const componentRef = viewContainerRef.createComponent(componentFactory);    (<AdComponent>componentRef.instance).data = adItem.data;  }  getAds() {    this.interval = setInterval(() => {      this.loadComponent();    }, 3000);  }}

这里的 loadComponent() 方法很重要。 来一步步看看。首先,它选取了一个广告。

&loadComponent() 如何选择广告

&loadComponent() 方法使用某种算法选择了一个广告。

&(译注:循环选取算法)首先,它把 currentAdIndex 递增一,然后用它除以 AdItem 数组长度的余数作为新的 currentAdIndex 的值, 最后用这个值来从数组中选取一个 adItem

loadComponent() 选取了一个广告之后,它使用 ComponentFactoryResolver 来为每个具体的组件解析出一个 ComponentFactory。 然后 ComponentFactory 会为每一个组件创建一个实例。

接下来,你要把 viewContainerRef 指向这个组件的现有实例。但你怎么才能找到这个实例呢? 很简单,因为它指向了 adHost,而这个 adHost 就是你以前设置过的指令,用来告诉 Angular 该把动态组件插入到什么位置。

回忆一下,AdDirective 曾在它的构造函数中注入了一个 ViewContainerRef。 因此这个指令可以访问到这个你打算用作动态组件宿主的元素。

要把这个组件添加到模板中,你可以调用 ViewContainerRefcreateComponent()

createComponent() 方法返回一个引用,指向这个刚刚加载的组件。 使用这个引用就可以与该组件进行交互,比如设置它的属性或调用它的方法。

对选择器的引用

通常,Angular 编译器会为模板中所引用的每个组件都生成一个 ComponentFactory 类。 但是,对于动态加载的组件,模板中不会出现对它们的选择器的引用。

要想确保编译器照常生成工厂类,就要把这些动态加载的组件添加到 NgModuleentryComponents 数组中:

Path:"src/app/app.module.ts (entry components)" 。

entryComponents: [ HeroJobAdComponent, HeroProfileComponent ],

公共的 AdComponent 接口

在广告条中,所有组件都实现了一个公共接口 AdComponent,它定义了一个标准化的 API,来把数据传给组件。

下面就是两个范例组件及其 AdComponent 接口:

  1. Path:"src/app/hero-job-ad.component.ts" 。

    import { Component, Input } from '@angular/core';    import { AdComponent }      from './ad.component';    @Component({      template: `        <div class="job-ad">          <h4>{{data.headline}}</h4>          {{data.body}}        </div>      `    })    export class HeroJobAdComponent implements AdComponent {      @Input() data: any;    }

  1. Path:"hero-profile.component.ts" 。

    import { Component, Input }  from '@angular/core';    import { AdComponent }       from './ad.component';    @Component({      template: `        <div class="hero-profile">          <h3>Featured Hero Profile</h3>          <h4>{{data.name}}</h4>          <p>{{data.bio}}</p>          <strong>Hire this hero today!</strong>        </div>      `    })    export class HeroProfileComponent implements AdComponent {      @Input() data: any;    }

  1. Path:"ad.component.ts" 。

    export interface AdComponent {      data: any;    }

结果展示

概览

Angular 元素就是打包成自定义元素的 Angular 组件。所谓自定义元素就是一套与具体框架无关的用于定义新 HTML 元素的 Web 标准。

自定义元素这项特性目前受到了 Chrome、Edge(基于 Chromium 的版本)、Opera 和 Safari 的支持,在其它浏览器中也能通过腻子脚本(参见浏览器支持)加以支持。 自定义元素扩展了 HTML,它允许你定义一个由 JavaScript 代码创建和控制的标签。 浏览器会维护一个自定义元素的注册表 CustomElementRegistry,它把一个可实例化的 JavaScript 类映射到 HTML 标签上。

@angular/elements 包导出了一个 createCustomElement() API,它在 Angular 组件接口与变更检测功能和内置 DOM API 之间建立了一个桥梁。

把组件转换成自定义元素可以让所有所需的 Angular 基础设施都在浏览器中可用。 创建自定义元素的方式简单直观,它会自动把你组件定义的视图连同变更检测与数据绑定等 Angular 的功能映射为相应的原生 HTML 等价物。

使用自定义元素

自定义元素会自举 —— 它们在添加到 DOM 中时就会自行启动自己,并在从 DOM 中移除时自行销毁自己。一旦自定义元素添加到了任何页面的 DOM 中,它的外观和行为就和其它的 HTML 元素一样了,不需要对 Angular 的术语或使用约定有任何特殊的了解。

  • Angular 应用中的简易动态内容

把组件转换成自定义元素为你在 Angular 应用中创建动态 HTML 内容提供了一种简单的方式。 在 Angular 应用中,你直接添加到 DOM 中的 HTML 内容是不会经过 Angular 处理的,除非你使用动态组件来借助自己的代码把 HTML 标签与你的应用数据关联起来并参与变更检测。而使用自定义组件,所有这些装配工作都是自动的。

  • 富内容应用

如果你有一个富内容应用(比如正在展示本文档的这个),自定义元素能让你的内容提供者使用复杂的 Angular 功能,而不要求他了解 Angular 的知识。比如,像本文档这样的 Angular 指南是使用 Angular 导航工具直接添加到 DOM 中的,但是其中可以包含特殊的元素,比如 <code-snippet>,它可以执行复杂的操作。 你所要告诉你的内容提供者的一切,就是这个自定义元素的语法。他们不需要了解关于 Angular 的任何知识,也不需要了解你的组件的数据结构或实现。

工作原理

使用 createCustomElement() 函数来把组件转换成一个可注册成浏览器中自定义元素的类。 注册完这个配置好的类之后,你就可以在内容中像内置 HTML 元素一样使用这个新元素了,比如直接把它加到 DOM 中:

<my-popup message="Use Angular!"></my-popup>

当你的自定义元素放进页面中时,浏览器会创建一个已注册类的实例。其内容是由组件模板提供的,它使用 Angular 模板语法,并且使用组件和 DOM 数据进行渲染。组件的输入属性(Property)对应于该元素的输入属性(Attribute)。

把组件转换成自定义元素

Angular 提供了 createCustomElement() 函数,以支持把 Angular 组件及其依赖转换成自定义元素。该函数会收集该组件的 Observable 型属性,提供浏览器创建和销毁实例时所需的 Angular 功能,还会对变更进行检测并做出响应。

这个转换过程实现了 NgElementConstructor 接口,并创建了一个构造器类,用于生成该组件的一个自举型实例。

然后用 JavaScript 的 customElements.define() 函数把这个配置好的构造器和相关的自定义元素标签注册到浏览器的 CustomElementRegistry 中。 当浏览器遇到这个已注册元素的标签时,就会使用该构造器来创建一个自定义元素的实例。

映射

寄宿着 Angular 组件的自定义元素在组件中定义的"数据及逻辑"和标准的 DOM API 之间建立了一座桥梁。组件的属性和逻辑会直接映射到 HTML 属性和浏览器的事件系统中。

  • 用于创建的 API 会解析该组件,以查找输入属性(Property),并在这个自定义元素上定义相应的属性(Attribute)。 它把属性名转换成与自定义元素兼容的形式(自定义元素不区分大小写),生成的属性名会使用中线分隔的小写形式。 比如,对于带有 @Input('myInputProp') inputProp 的组件,其对应的自定义元素会带有一个 my-input-prop 属性。

  • 组件的输出属性会用 HTML 自定义事件的形式进行分发,自定义事件的名字就是这个输出属性的名字。 比如,对于带有 @Output() valueChanged = new EventEmitter() 属性的组件,其相应的自定义元素将会分发名叫 "valueChanged" 的事件,事件中所携带的数据存储在该事件对象的 detail 属性中。 如果你提供了别名,就改用这个别名。比如,@Output('myClick') clicks = new EventEmitter<string>(); 会导致分发名为 "myClick" 事件。

自定义元素的浏览器支持

最近开发的 Web 平台特性:自定义元素目前在一些浏览器中实现了原生支持,而其它浏览器或者尚未决定,或者已经制订了计划。

浏览器自定义元素支持
Chrome原生支持。
Edge (基于 Chromium 的)原生支持。
Firefox原生支持。
Opera原生支持。
Safari原生支持。

对于原生支持了自定义元素的浏览器,该规范要求开发人员使用 ES2016 的类来定义自定义元素 —— 开发人员可以在项目的 TypeScript 配置文件中设置 target: "es2015" 属性来满足这一要求。并不是所有浏览器都支持自定义元素和 ES2015,开发人员也可以选择使用腻子脚本来让它支持老式浏览器和 ES5 的代码。

使用 Angular CLI 可以自动为你的项目添加正确的腻子脚本:ng add @angular/elements --project=*your_project_name*

范例:弹窗服务

以前,如果你要在运行期间把一个组件添加到应用中,就不得不定义动态组件。你还要把动态组件添加到模块的 entryComponents 列表中,以便应用在启动时能找到它,然后还要加载它、把它附加到 DOM 中的元素上,并且装配所有的依赖、变更检测和事件处理,详见动态组件加载器。

用 Angular 自定义组件会让这个过程更简单、更透明。它会自动提供所有基础设施和框架,而你要做的就是定义所需的各种事件处理逻辑。(如果你不准备在应用中直接用它,还要把该组件在编译时排除出去。)

这个弹窗服务的范例应用(见后面)定义了一个组件,你可以动态加载它也可以把它转换成自定义组件。

  • "popup.component.ts" 定义了一个简单的弹窗元素,用于显示一条输入消息,附带一些动画和样式。

  • "popup.service.ts" 创建了一个可注入的服务,它提供了两种方式来执行 PopupComponent:作为动态组件或作为自定义元素。注意动态组件的方式需要更多的代码来做搭建工作。

  • "app.module.ts" 把 PopupComponent 添加到模块的 entryComponents 列表中,而从编译过程中排除它,以消除启动时的警告和错误。

  • "app.component.ts" 定义了该应用的根组件,它借助 PopupService 在运行时把这个弹窗添加到 DOM 中。在应用运行期间,根组件的构造函数会把 PopupComponent 转换成自定义元素。

为了对比,这个范例中同时演示了这两种方式。一个按钮使用动态加载的方式添加弹窗,另一个按钮使用自定义元素的方式。可以看到,两者的结果是一样的,其差别只是准备过程不同。

  1. Path:"src/app/popup.component.ts"。

    import { Component, EventEmitter, Input, Output } from '@angular/core';    import { animate, state, style, transition, trigger } from '@angular/animations';    @Component({      selector: 'my-popup',      template: `        <span>Popup: {{message}}</span>        <button (click)="closed.next()">✖</button>      `,      host: {        '[@state]': 'state',      },      animations: [        trigger('state', [          state('opened', style({transform: 'translateY(0%)'})),          state('void, closed', style({transform: 'translateY(100%)', opacity: 0})),          transition('* => *', animate('100ms ease-in')),        ])      ],      styles: [`        :host {          position: absolute;          bottom: 0;          left: 0;          right: 0;          background: #009cff;          height: 48px;          padding: 16px;          display: flex;          justify-content: space-between;          align-items: center;          border-top: 1px solid black;          font-size: 24px;        }        button {          border-radius: 50%;        }      `]    })    export class PopupComponent {      state: 'opened' | 'closed' = 'closed';      @Input()      set message(message: string) {        this._message = message;        this.state = 'opened';      }      get message(): string { return this._message; }      _message: string;      @Output()      closed = new EventEmitter();    }

  1. Path:"src/app/popup.service.ts"。

    import { ApplicationRef, ComponentFactoryResolver, Injectable, Injector } from '@angular/core';    import { NgElement, WithProperties } from '@angular/elements';    import { PopupComponent } from './popup.component';    @Injectable()    export class PopupService {      constructor(private injector: Injector,                  private applicationRef: ApplicationRef,                  private componentFactoryResolver: ComponentFactoryResolver) {}      // Previous dynamic-loading method required you to set up infrastructure      // before adding the popup to the DOM.      showAsComponent(message: string) {        // Create element        const popup = document.createElement('popup-component');        // Create the component and wire it up with the element        const factory = this.componentFactoryResolver.resolveComponentFactory(PopupComponent);        const popupComponentRef = factory.create(this.injector, [], popup);        // Attach to the view so that the change detector knows to run        this.applicationRef.attachView(popupComponentRef.hostView);        // Listen to the close event        popupComponentRef.instance.closed.subscribe(() => {          document.body.removeChild(popup);          this.applicationRef.detachView(popupComponentRef.hostView);        });        // Set the message        popupComponentRef.instance.message = message;        // Add to the DOM        document.body.appendChild(popup);      }      // This uses the new custom-element method to add the popup to the DOM.      showAsElement(message: string) {        // Create element        const popupEl: NgElement & WithProperties<PopupComponent> = document.createElement('popup-element') as any;        // Listen to the close event        popupEl.addEventListener('closed', () => document.body.removeChild(popupEl));        // Set the message        popupEl.message = message;        // Add to the DOM        document.body.appendChild(popupEl);      }    }

  1. Path:"src/app/app.module.ts"。

    import { NgModule } from '@angular/core';    import { BrowserModule } from '@angular/platform-browser';    import { BrowserAnimationsModule } from '@angular/platform-browser/animations';    import { AppComponent } from './app.component';    import { PopupComponent } from './popup.component';    import { PopupService } from './popup.service';    // Include the `PopupService` provider,    // but exclude `PopupComponent` from compilation,    // because it will be added dynamically.    @NgModule({      imports: [BrowserModule, BrowserAnimationsModule],      providers: [PopupService],      declarations: [AppComponent, PopupComponent],      bootstrap: [AppComponent],      entryComponents: [PopupComponent],    })    export class AppModule {    }

  1. Path:"app.component.ts"。

    import { Component, Injector } from '@angular/core';    import { createCustomElement } from '@angular/elements';    import { PopupService } from './popup.service';    import { PopupComponent } from './popup.component';    @Component({      selector: 'app-root',      template: `        <input #input value="Message">        <button (click)="popup.showAsComponent(input.value)">Show as component</button>        <button (click)="popup.showAsElement(input.value)">Show as element</button>      `,    })    export class AppComponent {      constructor(injector: Injector, public popup: PopupService) {        // Convert `PopupComponent` to a custom element.        const PopupElement = createCustomElement(PopupComponent, {injector});        // Register the custom element with the browser.        customElements.define('popup-element', PopupElement);      }    }

为自定义元素添加类型支持

一般的 DOM API,比如 document.createElement()document.querySelector(),会返回一个与指定的参数相匹配的元素类型。比如,调用 document.createElement('a') 会返回 HTMLAnchorElement,这样 TypeScript 就会知道它有一个 href 属性,而 document.createElement('div') 会返回 HTMLDivElement,这样 TypeScript 就会知道它没有 href 属性。

当调用未知元素(比如自定义的元素名 popup-element)时,该方法会返回泛化类型,比如 HTMLELement,这时候 TypeScript 就无法推断出所返回元素的正确类型。

用 Angular 创建的自定义元素会扩展 NgElement 类型(而它扩展了 HTMLElement)。除此之外,这些自定义元素还拥有相应组件的每个输入属性。比如,popup-element 元素具有一个 string 型的 message 属性。

如果你要让你的自定义元素获得正确的类型,还可使用一些选项。假设你要创建一个基于下列组件的自定义元素 my-dialog

@Component(...)class MyDialog {  @Input() content: string;}

获得精确类型的最简单方式是把相关 DOM 方法的返回值转换成正确的类型。要做到这一点,你可以使用 NgElementWithProperties 类型(都导出自 @angular/elements):

const aDialog = document.createElement('my-dialog') as NgElement & WithProperties<{content: string}>;aDialog.content = 'Hello, world!';aDialog.content = 123;  // <-- ERROR: TypeScript knows this should be a string.aDialog.body = 'News';  // <-- ERROR: TypeScript knows there is no `body` property on `aDialog`.

这是一种让你的自定义元素快速获得 TypeScript 特性(比如类型检查和自动完成支持)的好办法,不过如果你要在多个地方使用它,可能会有点啰嗦,因为不得不在每个地方对返回类型做转换。

另一种方式可以对每个自定义元素的类型只声明一次。你可以扩展 HTMLElementTagNameMap,TypeScript 会在 DOM 方法(如 document.createElement()document.querySelector() 等)中用它来根据标签名推断返回元素的类型。

declare global {  interface HTMLElementTagNameMap {    'my-dialog': NgElement & WithProperties<{content: string}>;    'my-other-element': NgElement & WithProperties<{foo: 'bar'}>;    ...  }}

现在,TypeScript 就可以像内置元素一样推断出它的正确类型了:

document.createElement('div')               //--> HTMLDivElement (built-in element)document.querySelector('foo')               //--> Element        (unknown element)document.createElement('my-dialog')         //--> NgElement & WithProperties<{content: string}> (custom element)document.querySelector('my-other-element')  //--> NgElement & WithProperties<{foo: 'bar'}>      (custom element)

用表单处理用户输入是许多常见应用的基础功能。 应用通过表单来让用户登录、修改个人档案、输入敏感信息以及执行各种数据输入任务。

Angular 提供了两种不同的方法来通过表单处理用户输入:响应式表单和模板驱动表单。 两者都从视图中捕获用户输入事件、验证用户输入、创建表单模型、修改数据模型,并提供跟踪这些更改的途径。

本指南提供的信息可以帮你确定哪种方式最适合你的情况。它介绍了这两种方法所用的公共构造块,还总结了两种方式之间的关键区别,并在建立、数据流和测试等不同的情境下展示了这些差异。

先决条件

要想学习使用表单,你应该对这些内容有基本的了解:

  • TypeScript和 HTML5 编程。

  • Angular 的应用设计基础,就像Angular Concepts 中描述的那样。

  • Angular 模板语法的基础知识。

选择一种方法

响应式表单和模板驱动表单以不同的方式处理和管理表单数据。每种方法都有各自的优点。

  • 响应式表单提供对底层表单对象模型直接、显式的访问。它们与模板驱动表单相比,更加健壮:它们的可扩展性、可复用性和可测试性都更高。如果表单是你的应用程序的关键部分,或者你已经在使用响应式表单来构建应用,那就使用响应式表单。

  • 模板驱动表单依赖模板中的指令来创建和操作底层的对象模型。它们对于向应用添加一个简单的表单非常有用,比如电子邮件列表注册表单。它们很容易添加到应用中,但在扩展性方面不如响应式表单。如果你有可以只在模板中管理的非常基本的表单需求和逻辑,那么模板驱动表单就很合适。

关键差异

下表总结了响应式表单和模板驱动表单之间的一些关键差异。

响应式模板驱动
建立表单模型显式的,在组件类中创建隐式的,由指令创建
数据模型结构化和不可变的非结构化和可变的
可预测性同步异步
表单验证函数指令

可伸缩性

如果表单是应用程序的核心部分,那么可伸缩性就非常重要。能够跨组件复用表单模型是至关重要的。

响应式表单比模板驱动表单更有可伸缩性。它们提供对底层表单 API 的直接访问,以及对表单数据模型的同步访问,从而可以更轻松地创建大型表单。响应式表单需要较少的测试设置,测试时不需要深入理解变更检测,就能正确测试表单更新和验证。

模板驱动表单专注于简单的场景,可复用性没那么高。它们抽象出了底层表单 API,并且只提供对表单数据模型的异步访问。对模板驱动表单的这种抽象也会影响测试。测试程序非常依赖于手动触发变更检测才能正常运行,并且需要进行更多设置工作。

建立表单模型

响应式表单和模板驱动型表单都会跟踪用户与之交互的表单输入元素和组件模型中的表单数据之间的值变更。这两种方法共享同一套底层构建块,只在如何创建和管理常用表单控件实例方面有所不同。

常用表单基础类

响应式表单和模板驱动表单都建立在下列基础类之上。

  • FormControl 实例用于追踪单个表单控件的值和验证状态。

  • FormGroup 用于追踪一个表单控件组的值和状态。

  • FormArray 用于追踪表单控件数组的值和状态。

  • ControlValueAccessor 用于在 Angular 的 FormControl 实例和原生 DOM 元素之间创建一个桥梁。

建立响应式表单

对于响应式表单,你可以直接在组件类中定义表单模型。[formControl] 指令会通过内部值访问器来把显式创建的 FormControl 实例与视图中的特定表单元素联系起来。

下面的组件使用响应式表单为单个控件实现了一个输入字段。在这个例子中,表单模型是 FormControl 实例。

import { Component } from '@angular/core';import { FormControl } from '@angular/forms';@Component({  selector: 'app-reactive-favorite-color',  template: `    Favorite Color: <input type="text" [formControl]="favoriteColorControl">  `})export class FavoriteColorComponent {  favoriteColorControl = new FormControl('');}

下图展示了在响应式表单中直接访问表单模型。它通过输入元素上的 [formControl] 指令,在任何给定的时间点提供表单元素的值和状态。

建立模板驱动表单

在模板驱动表单中,表单模型是隐式的,而不是显式的。指令 NgModel 为指定的表单元素创建并管理一个 FormControl 实例。

下面的组件使用模板驱动表单为单个控件实现了同样的输入字段。

import { Component } from '@angular/core';@Component({  selector: 'app-template-favorite-color',  template: `    Favorite Color: <input type="text" [(ngModel)]="favoriteColor">  `})export class FavoriteColorComponent {  favoriteColor = '';}

在模板驱动表单中,对表单模型的间接访问。你没有对 FormControl 实例的直接编程访问,如下图所示。

表单中的数据流

当应用包含一个表单时,Angular 必须让该视图与组件模型保持同步,并让组件模型与视图保持同步。当用户通过视图更改值并进行选择时,新值必须反映在数据模型中。同样,当程序逻辑改变数据模型中的值时,这些值也必须反映到视图中。

响应式表单和模板驱动表单在处理来自用户或程序化变更时的数据处理方式上有所不同。下面的这些示意图会以上面定义的 favorite-color 输入字段为例,分别说明两种表单各自的数据流。

响应式表单中的数据流

在响应式表单中,视图中的每个表单元素都直接链接到一个表单模型(FormControl 实例)。 从视图到模型的修改以及从模型到视图的修改都是同步的,而且不依赖于 UI 的渲染方式。

这个视图到模型的示意图展示了当输入字段的值发生变化时数据是如何从视图开始,经过下列步骤进行流动的。

  1. 最终用户在输入框元素中键入了一个值,这里是 "Blue"。

  1. 这个输入框元素会发出一个带有最新值的 "input" 事件。

  1. 这个控件值访问器 ControlValueAccessor 会监听表单输入框元素上的事件,并立即把新值传给 FormControl 实例。

  1. FormControl 实例会通过 valueChanges 这个可观察对象发出这个新值。

  1. valueChanges 的任何一个订阅者都会收到这个新值。

这个模型到视图的示意图体现了程序中对模型的修改是如何通过下列步骤传播到视图中的。

  1. favoriteColorControl.setValue() 方法被调用,它会更新这个 FormControl 的值。

  1. FormControl 实例会通过 valueChanges 这个可观察对象发出新值。

  1. valueChanges 的任何订阅者都会收到这个新值。

  1. 该表单输入框元素上的控件值访问器会把控件更新为这个新值。

模板驱动表单中的数据流

在模板驱动表单中,每一个表单元素都是和一个负责管理内部表单模型的指令关联起来的。

这个视图到模型的图表展示了当输入字段的值发生变化时,数据流是如何从视图开始经过下列步骤进行流动的。

  1. 最终用户在输入框元素中敲 "Blue"。

  1. 该输入框元素会发出一个 "input" 事件,带着值 "Blue"。

  1. 附着在该输入框上的控件值访问器会触发 FormControl 实例上的 setValue() 方法。

  1. FormControl 实例通过 valueChanges 这个可观察对象发出新值。

  1. valueChanges 的任何订阅者都会收到新值。

  1. 控件值访问器 ControlValueAccessory 还会调用 NgModel.viewToModelUpdate() 方法,它会发出一个 ngModelChange 事件。

  1. 由于该组件模板双向数据绑定到了 favoriteColor,组件中的 favoriteColor 属性就会修改为 ngModelChange 事件所发出的值("Blue")。

这个模型到视图的示意图展示了当 favoriteColor 从蓝变到红时,数据是如何经过如下步骤从模型流动到视图的。

  1. 组件中修改了 favoriteColor 的值。

  1. 变更检测开始。

  1. 在变更检测期间,由于这些输入框之一的值发生了变化,Angular 就会调用 NgModel 指令上的 ngOnChanges 生命周期钩子。

  1. ngOnChanges() 方法会把一个异步任务排入队列,以设置内部 FormControl 实例的值。

  1. 变更检测完成。

  1. 在下一个检测周期,用来为 FormControl 实例赋值的任务就会执行。

  1. FormControl 实例通过可观察对象 valueChanges 发出最新值。

  1. valueChanges 的任何订阅者都会收到这个新值。

  1. 控件值访问器 ControlValueAccessor 会使用 favoriteColor 的最新值来修改表单的输入框元素。

数据模型的可变性

变更追踪的方法对应用的效率有着重要影响。

  • 响应式表单通过以不可变的数据结构提供数据模型,来保持数据模型的纯粹性。每当在数据模型上触发更改时,FormControl 实例都会返回一个新的数据模型,而不会更新现有的数据模型。这使你能够通过该控件的可观察对象跟踪对数据模型的唯一更改。这让变更检测更有效率,因为它只需在唯一性更改(译注:也就是对象引用发生变化)时进行更新。由于数据更新遵循响应式模式,因此你可以把它和可观察对象的各种运算符集成起来以转换数据。

  • 模板驱动的表单依赖于可变性和双向数据绑定,可以在模板中做出更改时更新组件中的数据模型。由于使用双向数据绑定时没有用来对数据模型进行跟踪的唯一性更改,因此变更检测在需要确定何时更新时效率较低。

前面那些使用 favorite-color 输入元素的例子就演示了这种差异。

  • 对于响应式表单,当控件值更新时,FormControl 的实例总会返回一个新值。

  • 对于模板驱动的表单,favorite-color 属性总会被修改为新值。

表单验证

验证是管理任何表单时必备的一部分。无论你是要检查必填项,还是查询外部 API 来检查用户名是否已存在,Angular 都会提供一组内置的验证器,以及创建自定义验证器所需的能力。

  • 响应式表单把自定义验证器定义成函数,它以要验证的控件作为参数。

  • 模板驱动表单和模板指令紧密相关,并且必须提供包装了验证函数的自定义验证器指令。

测试

测试在复杂的应用程序中也起着重要的作用。当验证你的表单功能是否正确时,更简单的测试策略往往也更有用。测试响应式表单和模板驱动表单的差别之一在于它们是否需要渲染 UI 才能基于表单控件和表单字段变化来执行断言。下面的例子演示了使用响应式表单和模板驱动表单时表单的测试过程。

测试响应式表单

响应式表单提供了相对简单的测试策略,因为它们能提供对表单和数据模型的同步访问,而且不必渲染 UI 就能测试它们。在这些测试中,控件和数据是通过控件进行查询和操纵的,不需要和变更检测周期打交道。

下面的测试利用前面例子中的 "喜欢的颜色" 组件来验证响应式表单中的 "从视图到模型" 和 "从模型到视图" 数据流。

验证“从视图到模型”的数据流

第一个例子执行了下列步骤来验证“从视图到模型”数据流。

  1. 查询表单输入框元素的视图,并为测试创建自定义的 "input" 事件

  1. 把输入的新值设置为 Red,并在表单输入元素上调度 "input" 事件。

  1. 断言该组件的 favoriteColorControl 的值与来自输入框的值是匹配的。

//Favorite color test - view to modelit('should update the value of the input field', () => {  const input = fixture.nativeElement.querySelector('input');  const event = createNewEvent('input');  input.value = 'Red';  input.dispatchEvent(event);  expect(fixture.componentInstance.favoriteColorControl.value).toEqual('Red');});

验证“从模型到视图”数据流:

  1. 使用 favoriteColorControl 这个 FormControl 实例来设置新值。

  1. 查询表单中输入框的视图。

  1. 断言控件上设置的新值与输入中的值是匹配的。

//Favorite color test - model to viewit('should update the value in the control', () => {  component.favoriteColorControl.setValue('Blue');  const input = fixture.nativeElement.querySelector('input');  expect(input.value).toBe('Blue');});

测试模板驱动表单

使用模板驱动表单编写测试就需要详细了解变更检测过程,以及指令在每个变更检测周期中如何运行,以确保在正确的时间查询、测试或更改元素。

下面的测试使用了以前的 "喜欢的颜色" 组件,来验证模板驱动表单的 "从视图到模型" 和 "从模型到视图" 数据流。

验证 "从视图到模型" 数据流:

  1. 查询表单输入元素中的视图,并为测试创建自定义 "input" 事件。

  1. 把输入框的新值设置为 Red,并在表单输入框元素上派发 "input" 事件。

  1. 通过测试夹具(Fixture)来运行变更检测。

  1. 断言该组件 favoriteColor 属性的值与来自输入框的值是匹配的。

//Favorite color test - view to modelit('should update the favorite color in the component', fakeAsync(() => {  const input = fixture.nativeElement.querySelector('input');  const event = createNewEvent('input');  input.value = 'Red';  input.dispatchEvent(event);  fixture.detectChanges();  expect(component.favoriteColor).toEqual('Red');}));

验证“从模型到视图”数据流:

  1. 使用组件实例来设置 favoriteColor 的值。

  1. 通过测试夹具(Fixture)来运行变更检测。

  1. fakeAsync() 任务中使用 tick() 方法来模拟时间的流逝。

  1. 查询表单输入框元素的视图。

  1. 断言输入框的值与该组件实例的 favoriteColor 属性值是匹配的。

//Favorite color test - model to viewit('should update the favorite color on the input field', fakeAsync(() => {  component.favoriteColor = 'Blue';  fixture.detectChanges();  tick();  const input = fixture.nativeElement.querySelector('input');  expect(input.value).toBe('Blue');}));

响应式表单提供了一种模型驱动的方式来处理表单输入,其中的值会随时间而变化。本文会向你展示如何创建和更新基本的表单控件,接下来还会在一个表单组中使用多个控件,验证表单的值,以及创建动态表单,也就是在运行期添加或移除控件。

先决条件

在深入了解被动表单之前,你应该对这些内容有一个基本的了解:

  • TypeScript 编程。

  • Angular 的应用设计基础,就像Angular Concepts 中描述的那样。

  • “表单简介”中提供的表单设计概念。

响应式表单概述

响应式表单使用显式的、不可变的方式,管理表单在特定的时间点上的状态。对表单状态的每一次变更都会返回一个新的状态,这样可以在变化时维护模型的整体性。响应式表单是围绕 Observable 流构建的,表单的输入和值都是通过这些输入值组成的流来提供的,它可以同步访问。

响应式表单还提供了一种更直观的测试路径,因为在请求时你可以确信这些数据是一致的、可预料的。这个流的任何一个消费者都可以安全地操纵这些数据。

响应式表单与模板驱动表单有着显著的不同点。响应式表单通过对数据模型的同步访问提供了更多的可预测性,使用 Observable 的操作符提供了不可变性,并且通过 Observable 流提供了变化追踪功能。

模板驱动的表单允许你直接在模板中修改数据,但不像响应式表单那么明确,因为它们依赖嵌入到模板中的指令,并借助可变数据来异步跟踪变化。参见表单概览以了解这两种范式之间的详细比较。

添加基础表单控件

使用表单控件有三个步骤。

  1. 在你的应用中注册响应式表单模块。该模块声明了一些你要用在响应式表单中的指令。

  1. 生成一个新的 FormControl 实例,并把它保存在组件中。

  1. 在模板中注册这个 FormControl

然后,你可以把组件添加到模板中来显示表单。

下面的例子展示了如何添加一个表单控件。在这个例子中,用户在输入字段中输入自己的名字,捕获其输入值,并显示表单控件的当前值。

注册响应式表单模块

要使用响应式表单控件,就要从 @angular/forms 包中导入 ReactiveFormsModule,并把它添加到你的 NgModuleimports 数组中。

Path:"src/app/app.module.ts (excerpt)" 。

import { ReactiveFormsModule } from '@angular/forms';@NgModule({  imports: [    // other imports ...    ReactiveFormsModule  ],})export class AppModule { }

生成一个新的 FormControl

使用 CLI 命令 ng generate 在项目中生成一个组件作为该表单控件的宿主。

ng generate component NameEditor

要注册一个表单控件,就要导入 FormControl 类并创建一个 FormControl 的新实例,将其保存为类的属性。

Path:"src/app/name-editor/name-editor.component.ts" 。

import { Component } from '@angular/core';import { FormControl } from '@angular/forms';@Component({  selector: 'app-name-editor',  templateUrl: './name-editor.component.html',  styleUrls: ['./name-editor.component.css']})export class NameEditorComponent {  name = new FormControl('');}

可以用 FormControl 的构造函数设置初始值,这个例子中它是空字符串。通过在你的组件类中创建这些控件,你可以直接对表单控件的状态进行监听、修改和校验。

在模板中注册该控件

在组件类中创建了控件之后,你还要把它和模板中的一个表单控件关联起来。修改模板,为表单控件添加 formControl 绑定,formControl 是由 ReactiveFormsModule 中的 FormControlDirective 提供的。

Path:"src/app/name-editor/name-editor.component.html" 。

<label>  Name:  <input type="text" [formControl]="name"></label>

使用这种模板绑定语法,把该表单控件注册给了模板中名为 name 的输入元素。这样,表单控件和 DOM 元素就可以互相通讯了:视图会反映模型的变化,模型也会反映视图中的变化。

显示该组件

把该组件添加到模板时,将显示指派给 name 的表单控件。

Path:"src/app/app.component.html (name editor)" 。

<app-name-editor></app-name-editor>

显示表单控件的值

你可以用下列方式显示它的值:

  • 通过可观察对象 valueChanges,你可以在模板中使用 AsyncPipe 或在组件类中使用 subscribe() 方法来监听表单值的变化。

  • 使用 value 属性。它能让你获得当前值的一份快照。

下面的例子展示了如何在模板中使用插值显示当前值。

Path:"src/app/name-editor/name-editor.component.html (control value)" 。

<p>  Value: {{ name.value }}</p>

一旦你修改了表单控件所关联的元素,这里显示的值也跟着变化了。

响应式表单还能通过每个实例的属性和方法提供关于特定控件的更多信息。AbstractControl 的这些属性和方法用于控制表单状态,并在处理表单校验时决定何时显示信息。

替换表单控件的值

响应式表单还有一些方法可以用编程的方式修改控件的值,它让你可以灵活的修改控件的值而不需要借助用户交互。FormControl 提供了一个 setValue() 方法,它会修改这个表单控件的值,并且验证与控件结构相对应的值的结构。比如,当从后端 API 或服务接收到了表单数据时,可以通过 setValue() 方法来把原来的值替换为新的值。

下列的例子往组件类中添加了一个方法,它使用 setValue() 方法来修改 Nancy 控件的值。

Path:"src/app/name-editor/name-editor.component.ts (update value)" 。

updateName() {  this.name.setValue('Nancy');}

修改模板,添加一个按钮,用于模拟改名操作。在点 Update Name 按钮之前表单控件元素中输入的任何值都会回显为它的当前值。

Path:"src/app/name-editor/name-editor.component.html (update value)" 。

<p>  <button (click)="updateName()">Update Name</button></p>

由于表单模型是该控件的事实之源,因此当你单击该按钮时,组件中该输入框的值也变化了,覆盖掉它的当前值。

注:
- 在这个例子中,你只使用单个控件,但是当调用 FormGroupFormArray 实例的 setValue() 方法时,传入的值就必须匹配控件组或控件数组的结构才行。

把表单控件分组

表单中通常会包含几个相互关联的控件。响应式表单提供了两种把多个相关控件分组到同一个输入表单中的方法。

  • 表单组定义了一个带有一组控件的表单,你可以把它们放在一起管理。表单组的基础知识将在本节中讨论。你也可以通过嵌套表单组来创建更复杂的表单。

  • 表单数组定义了一个动态表单,你可以在运行时添加和删除控件。你也可以通过嵌套表单数组来创建更复杂的表单。欲知详情,参见下面的创建动态表单。

就像 FormControl 的实例能让你控制单个输入框所对应的控件一样,FormGroup 的实例也能跟踪一组 FormControl 实例(比如一个表单)的表单状态。当创建 FormGroup 时,其中的每个控件都会根据其名字进行跟踪。下面的例子展示了如何管理单个控件组中的多个 FormControl 实例。

生成一个 ProfileEditor 组件并从 @angular/forms 包中导入 FormGroupFormControl 类。

ng generate component ProfileEditor

Path:"src/app/profile-editor/profile-editor.component.ts (imports)" 。

import { FormGroup, FormControl } from '@angular/forms';

要将表单组添加到此组件中,请执行以下步骤。

  1. 创建一个 FormGroup 实例。

  1. 把这个 FormGroup 模型关联到视图。

  1. 保存表单数据。

创建一个 FormGroup 实例

在组件类中创建一个名叫 profileForm 的属性,并设置为 FormGroup 的一个新实例。要初始化这个 FormGroup,请为构造函数提供一个由控件组成的对象,对象中的每个名字都要和表单控件的名字一一对应。

对此个人档案表单,要添加两个 FormControl 实例,名字分别为 firstNamelastName

Path:"src/app/profile-editor/profile-editor.component.ts (form group)" 。

import { Component } from '@angular/core';import { FormGroup, FormControl } from '@angular/forms';@Component({  selector: 'app-profile-editor',  templateUrl: './profile-editor.component.html',  styleUrls: ['./profile-editor.component.css']})export class ProfileEditorComponent {  profileForm = new FormGroup({    firstName: new FormControl(''),    lastName: new FormControl(''),  });}

现在,这些独立的表单控件被收集到了一个控件组中。这个 FormGroup 用对象的形式提供了它的模型值,这个值来自组中每个控件的值。 FormGroup 实例拥有和 FormControl 实例相同的属性(比如 valueuntouched)和方法(比如 setValue())。

把这个 FormGroup 模型关联到视图。

这个表单组还能跟踪其中每个控件的状态及其变化,所以如果其中的某个控件的状态或值变化了,父控件也会发出一次新的状态变更或值变更事件。该控件组的模型来自它的所有成员。在定义了这个模型之后,你必须更新模板,来把该模型反映到视图中。

Path:"src/app/profile-editor/profile-editor.component.html (template form group)" 。

<form [formGroup]="profileForm">    <label>    First Name:    <input type="text" formControlName="firstName">  </label>  <label>    Last Name:    <input type="text" formControlName="lastName">  </label></form>

注意,就像 FormGroup 所包含的那控件一样,profileForm 这个 FormGroup 也通过 FormGroup 指令绑定到了 form 元素,在该模型和表单中的输入框之间创建了一个通讯层。 由 FormControlName 指令提供的 formControlName 属性把每个输入框和 FormGroup 中定义的表单控件绑定起来。这些表单控件会和相应的元素通讯,它们还把更改传给 FormGroup,这个 FormGroup 是模型值的事实之源。

保存表单数据

ProfileEditor 组件从用户那里获得输入,但在真实的场景中,你可能想要先捕获表单的值,等将来在组件外部进行处理。 FormGroup 指令会监听 form 元素发出的 submit 事件,并发出一个 ngSubmit 事件,让你可以绑定一个回调函数。

onSubmit() 回调方法添加为 form 标签上的 ngSubmit 事件监听器。

Path:"src/app/profile-editor/profile-editor.component.html (submit event)" 。

<form [formGroup]="profileForm" (ngSubmit)="onSubmit()">

ProfileEditor 组件上的 onSubmit() 方法会捕获 profileForm 的当前值。要保持该表单的封装性,就要使用 EventEmitter 向组件外部提供该表单的值。下面的例子会使用 console.warn 把这个值记录到浏览器的控制台中。

Path:"src/app/profile-editor/profile-editor.component.ts (submit method)" 。

onSubmit() {  // TODO: Use EventEmitter with form value  console.warn(this.profileForm.value);}

form 标签所发出的 submit 事件是原生 DOM 事件,通过点击类型为 submit 的按钮可以触发本事件。这还让用户可以用回车键来提交填完的表单。

往表单的底部添加一个 button,用于触发表单提交。

Path:"src/app/profile-editor/profile-editor.component.html (submit button)" 。

<button type="submit" [disabled]="!profileForm.valid">Submit</button>

&上面这个代码片段中的按钮还附加了一个 disabled 绑定,用于在 profileForm 无效时禁用该按钮。目前你还没有执行任何表单验证逻辑,因此该按钮始终是可用的。稍后的验证表单输入部分会讲解基础的表单验证。

显示组件

要显示包含此表单的 ProfileEditor 组件,请把它添加到组件模板中。

Path:"src/app/app.component.html (profile editor)" 。

<app-profile-editor></app-profile-editor>

ProfileEditor 让你能管理 FormGroup 中的 firstNamelastNameFormControl 实例。

创建嵌套的表单组

表单组可以同时接受单个表单控件实例和其它表单组实例作为其子控件。这可以让复杂的表单模型更容易维护,并在逻辑上把它们分组到一起。

如果要构建复杂的表单,如果能在更小的分区中管理不同类别的信息就会更容易一些。使用嵌套的 FormGroup 可以让你把大型表单组织成一些稍小的、易管理的分组。

要制作更复杂的表单,请遵循如下步骤。

创建一个嵌套的表单组。

在模板中对这个嵌套表单分组。

某些类型的信息天然就属于同一个组。比如名称和地址就是这类嵌套组的典型例子,下面的例子中就用到了它们。

创建一个嵌套组

要在 profileForm 中创建一个嵌套组,就要把一个嵌套的 address 元素添加到此表单组的实例中。

Path:"src/app/profile-editor/profile-editor.component.ts (nested form group)" 。

import { Component } from '@angular/core';import { FormGroup, FormControl } from '@angular/forms';@Component({  selector: 'app-profile-editor',  templateUrl: './profile-editor.component.html',  styleUrls: ['./profile-editor.component.css']})export class ProfileEditorComponent {  profileForm = new FormGroup({    firstName: new FormControl(''),    lastName: new FormControl(''),    address: new FormGroup({      street: new FormControl(''),      city: new FormControl(''),      state: new FormControl(''),      zip: new FormControl('')    })  });}

在这个例子中,address group 把现有的 firstNamelastName 控件和新的 streetcitystatezip 控件组合在一起。虽然 address 这个 FormGroupprofileForm 这个整体 FormGroup 的一个子控件,但是仍然适用同样的值和状态的变更规则。来自内嵌控件组的状态和值的变更将会冒泡到它的父控件组,以维护整体模型的一致性。

在模板中对此嵌套表单分组

在修改了组件类中的模型之后,还要修改模板,来把这个 FormGroup 实例对接到它的输入元素。

把包含 streetcitystatezip 字段的 address 表单组添加到 ProfileEditor 模板中。

Path:"src/app/profile-editor/profile-editor.component.html (template nested form group)" 。

<div formGroupName="address">  <h3>Address</h3>  <label>    Street:    <input type="text" formControlName="street">  </label>  <label>    City:    <input type="text" formControlName="city">  </label>    <label>    State:    <input type="text" formControlName="state">  </label>  <label>    Zip Code:    <input type="text" formControlName="zip">  </label></div>

ProfileEditor 表单显示为一个组,但是将来这个模型会被进一步细分,以表示逻辑分组区域。

注:

  • 这里使用了 value 属性和 JsonPipe 管道在组件模板中显示了这个 FormGroup 的值。

更新部分数据模型

当修改包含多个 FormGroup 实例的值时,你可能只希望更新模型中的一部分,而不是完全替换掉。这一节会讲解该如何更新 AbstractControl 模型中的一部分。

有两种更新模型值的方式:

  • 使用 setValue() 方法来为单个控件设置新值。 setValue() 方法会严格遵循表单组的结构,并整体性替换控件的值。

  • 使用 patchValue() 方法可以用对象中所定义的任何属性为表单模型进行替换。

setValue() 方法的严格检查可以帮助你捕获复杂表单嵌套中的错误,而 patchValue() 在遇到那些错误时可能会默默的失败。

ProfileEditorComponent 中,使用 updateProfile 方法传入下列数据可以更新用户的名字与街道住址。

Path:"src/app/profile-editor/profile-editor.component.ts (patch value)" 。

updateProfile() {  this.profileForm.patchValue({    firstName: 'Nancy',    address: {      street: '123 Drew Street'    }  });}

通过往模板中添加一个按钮来模拟一次更新操作,以修改用户档案。

Path:"src/app/profile-editor/profile-editor.component.html (update value)" 。

<p>  <button (click)="updateProfile()">Update Profile</button></p>

当点击按钮时,profileForm 模型中只有 firstNamestreet 被修改了。注意,street 是在 address 属性的对象中被修改的。这种结构是必须的,因为 patchValue() 方法要针对模型的结构进行更新。patchValue() 只会更新表单模型中所定义的那些属性。

使用 FormBuilder 服务生成控件

当需要与多个表单打交道时,手动创建多个表单控件实例会非常繁琐。FormBuilder 服务提供了一些便捷方法来生成表单控件。FormBuilder 在幕后也使用同样的方式来创建和返回这些实例,只是用起来更简单。

通过下列步骤可以利用这项服务。

导入 FormBuilder 类。

注入这个 FormBuilder 服务。

生成表单内容。

下面的例子展示了如何重构 ProfileEditor 组件,用 FormBuilder 来代替手工创建这些 FormControlFormGroup 实例。

导入 FormBuilder 类

@angular/forms 包中导入 FormBuilder 类。

Path:"src/app/profile-editor/profile-editor.component.ts (import)" 。

import { FormBuilder } from '@angular/forms';

注入 FormBuilder 服务

FormBuilder 是一个可注入的服务提供者,它是由 ReactiveFormModule 提供的。只要把它添加到组件的构造函数中就可以注入这个依赖。

Path:"src/app/profile-editor/profile-editor.component.ts (constructor)" 。

constructor(private fb: FormBuilder) { }

生成表单控件

FormBuilder 服务有三个方法:control()group()array()。这些方法都是工厂方法,用于在组件类中分别生成 FormControlFormGroupFormArray

group 方法来创建 profileForm 控件。

Path:"src/app/profile-editor/profile-editor.component.ts (form builder)" 。

import { Component } from '@angular/core';import { FormBuilder } from '@angular/forms';@Component({  selector: 'app-profile-editor',  templateUrl: './profile-editor.component.html',  styleUrls: ['./profile-editor.component.css']})export class ProfileEditorComponent {  profileForm = this.fb.group({    firstName: [''],    lastName: [''],    address: this.fb.group({      street: [''],      city: [''],      state: [''],      zip: ['']    }),  });  constructor(private fb: FormBuilder) { }}

在上面的例子中,你可以使用 group() 方法,用和前面一样的名字来定义这些属性。这里,每个控件名对应的值都是一个数组,这个数组中的第一项是其初始值。

&你可以只使用初始值来定义控件,但是如果你的控件还需要同步或异步验证器,那就在这个数组中的第二项和第三项提供同步和异步验证器。

比较一下用表单构建器和手动创建实例这两种方式。

  1. Path:"src/app/profile-editor/profile-editor.component.ts (instances)" 。

    profileForm = new FormGroup({      firstName: new FormControl(''),      lastName: new FormControl(''),      address: new FormGroup({        street: new FormControl(''),        city: new FormControl(''),        state: new FormControl(''),        zip: new FormControl('')      })    });

  1. Path:"src/app/profile-editor/profile-editor.component.ts (form builder)" 。

    profileForm = this.fb.group({      firstName: [''],      lastName: [''],      address: this.fb.group({        street: [''],        city: [''],        state: [''],        zip: ['']      }),    });

验证表单输入

表单验证用于确保用户的输入是完整和正确的。本节讲解了如何把单个验证器添加到表单控件中,以及如何显示表单的整体状态。表单验证的更多知识在表单验证一章中有详细的讲解。

使用下列步骤添加表单验证。

  1. 在表单组件中导入一个验证器函数。

  1. 把这个验证器添加到表单中的相应字段。

  1. 添加逻辑来处理验证状态。

最常见的验证是做一个必填字段。下面的例子给出了如何在 firstName 控件中添加必填验证并显示验证结果的方法。

导入验证器函数

响应式表单包含了一组开箱即用的常用验证器函数。这些函数接收一个控件,用以验证并根据验证结果返回一个错误对象或空值。

@angular/forms 包中导入 Validators 类。

Path:"src/app/profile-editor/profile-editor.component.ts (import)" 。

import { Validators } from '@angular/forms';

建一个必填字段

ProfileEditor 组件中,把静态方法 Validators.required 设置为 firstName 控件值数组中的第二项。

Path:"src/app/profile-editor/profile-editor.component.ts (import)" 。

profileForm = this.fb.group({  firstName: ['', Validators.required],  lastName: [''],  address: this.fb.group({    street: [''],    city: [''],    state: [''],    zip: ['']  }),});

HTML5 有一组内置的属性,用来进行原生验证,包括 requiredminlengthmaxlength 等。虽然是可选的,不过你也可以在表单的输入元素上把它们添加为附加属性来使用它们。这里我们把 required 属性添加到 firstName 输入元素上。

Path:"src/app/profile-editor/profile-editor.component.html (required attribute)" 。

<input type="text" formControlName="firstName" required>

注:

  • 这些 HTML5 验证器属性可以和 Angular 响应式表单提供的内置验证器组合使用。组合使用这两种验证器实践,可以防止在模板检查完之后表达式再次被修改导致的错误。

显示表单状态

当你往表单控件上添加了一个必填字段时,它的初始值是无效的(invalid)。这种无效状态会传播到其父 FormGroup 元素中,也让这个 FormGroup 的状态变为无效的。你可以通过该 FormGroup 实例的 status 属性来访问其当前状态。

使用插值显示 profileForm 的当前状态。

Path:"src/app/profile-editor/profile-editor.component.html (display status)" 。

<p>  Form Status: {{ profileForm.status }}</p>

提交按钮被禁用了,因为 firstName 控件的必填项规则导致了 profileForm 也是无效的。在你填写了 firstName 输入框之后,该表单就变成了有效的,并且提交按钮也启用了。

创建动态表单

FormArrayFormGroup 之外的另一个选择,用于管理任意数量的匿名控件。像 FormGroup 实例一样,你也可以往 FormArray 中动态插入和移除控件,并且 FormArray 实例的值和验证状态也是根据它的子控件计算得来的。 不过,你不需要为每个控件定义一个名字作为 key,因此,如果你事先不知道子控件的数量,这就是一个很好的选择。

要定义一个动态表单,请执行以下步骤。

  1. 导入 FormArray 类。

  1. 定义一个 FormArray 控件。

  1. 使用 getter 方法访问 FormArray 控件。

  1. 在模板中显示这个表单数组。

下面的例子展示了如何在 ProfileEditor 中管理别名数组。

导入 FormArray 类

@angular/form 中导入 FormArray,以使用它的类型信息。FormBuilder 服务用于创建 FormArray 实例。

Path:"src/app/profile-editor/profile-editor.component.ts (import)" 。

import { FormArray } from '@angular/forms';

定义 FormArray 控件

你可以通过把一组(从零项到多项)控件定义在一个数组中来初始化一个 FormArray。为 profileForm 添加一个 aliases 属性,把它定义为 FormArray 类型。

使用 FormBuilder.array() 方法来定义该数组,并用 FormBuilder.control() 方法来往该数组中添加一个初始控件。

Path:"src/app/profile-editor/profile-editor.component.ts (aliases form array)" 。

profileForm = this.fb.group({  firstName: ['', Validators.required],  lastName: [''],  address: this.fb.group({    street: [''],    city: [''],    state: [''],    zip: ['']  }),  aliases: this.fb.array([    this.fb.control('')  ])});

FormGroup 中的这个 aliases 控件现在管理着一个控件,将来还可以动态添加多个。

访问 FormArray 控件

相对于重复使用 profileForm.get() 方法获取每个实例的方式,getter 可以让你轻松访问表单数组各个实例中的别名。 表单数组实例用一个数组来代表未定数量的控件。通过 getter 来访问控件很方便,这种方法还能很容易地重复处理更多控件。

使用 getter 语法创建类属性 aliases,以从父表单组中接收表示绰号的表单数组控件。

Path:"src/app/profile-editor/profile-editor.component.ts (aliases getter)" 。

get aliases() {  return this.profileForm.get('aliases') as FormArray;}

注:

  • 因为返回的控件的类型是AbstractControl,所以你要为该方法提供一个显式的类型声明来访问 FormArray 特有的语法。

定义一个方法来把一个绰号控件动态插入到绰号 FormArray 中。用 FormArray.push() 方法把该控件添加为数组中的新条目。

Path:"src/app/profile-editor/profile-editor.component.ts (add alias)" 。

addAlias() {  this.aliases.push(this.fb.control(''));}

在这个模板中,这些控件会被迭代,把每个控件都显示为一个独立的输入框。

在模板中显示表单数组

要想为表单模型添加 aliases,你必须把它加入到模板中供用户输入。和 FormGroupNameDirective 提供的 formGroupName 一样,FormArrayNameDirective 也使用 formArrayName 在这个 FormArray 实例和模板之间建立绑定。

formGroupName <div> 元素的结束标签下方,添加一段模板 HTML。

Path:"src/app/profile-editor/profile-editor.component.html (aliases form array template)" 。

<div formArrayName="aliases">  <h3>Aliases</h3> <button (click)="addAlias()">Add Alias</button>  <div *ngFor="let alias of aliases.controls; let i=index">    <!-- The repeated alias template -->    <label>      Alias:      <input type="text" [formControlName]="i">    </label>  </div></div>

*ngFor 指令对 aliases FormArray 提供的每个 FormControl 进行迭代。因为 FormArray 中的元素是匿名的,所以你要把索引号赋值给 i 变量,并且把它传给每个控件的 formControlName 输入属性。

每当新的 alias 加进来时,FormArray 的实例就会基于这个索引号提供它的控件。这将允许你在每次计算根控件的状态和值时跟踪每个控件。

添加一个别名

最初,表单只包含一个绰号字段,点击 Add Alias 按钮,就出现了另一个字段。你还可以验证由模板底部的“Form Value”显示出来的表单模型所报告的这个绰号数组。

注:

  • 除了为每个绰号使用 FormControl 之外,你还可以改用 FormGroup 来组合上一些额外字段。对其中的每个条目定义控件的过程和前面没有区别。

响应式表单 API 汇总

下列表格给出了用于创建和管理响应式表单控件的基础类和服务。

说明
AbstractControl所有三种表单控件类(FormControlFormGroup 和 FormArray)的抽象基类。它提供了一些公共的行为和属性。
FormControl管理单体表单控件的值和有效性状态。它对应于 HTML 的表单控件,比如 <input> 或 <select>
FormGroup管理一组 AbstractControl 实例的值和有效性状态。该组的属性中包括了它的子控件。组件中的顶层表单就是 FormGroup
FormArray管理一些 AbstractControl 实例数组的值和有效性状态。
FormBuilder一个可注入的服务,提供一些用于提供创建控件实例的工厂方法。

指令

指令说明
FormControlDirective把一个独立的 FormControl 实例绑定到表单控件元素。
FormControlName把一个现有 FormGroup 中的 FormControl 实例根据名字绑定到表单控件元素。
FormGroupDirective把一个现有的 FormGroup 实例绑定到 DOM 元素。
FormGroupName把一个内嵌的 FormGroup 实例绑定到一个 DOM 元素。
FormArrayName把一个内嵌的 FormArray 实例绑定到一个 DOM 元素。

通过验证用户输入的准确性和完整性,可以提高整体的数据质量。该页面显示了如何从 UI 验证用户输入,以及如何在响应式表单和模板驱动表单中显示有用的验证消息。

先决条件

在学习表单验证之前,你应该对这些内容有一个基本的了解。

  • TypeScript和 HTML5 编程。

  • Angular 应用设计的基本概念。

  • Angular 支持的两类表单。

  • 模板驱动表单或响应式表单的基础知识。

在模板驱动表单中验证输入

为了往模板驱动表单中添加验证机制,你要添加一些验证属性,就像原生的 HTML 表单验证器。 Angular 会用指令来匹配这些具有验证功能的指令。

每当表单控件中的值发生变化时,Angular 就会进行验证,并生成一个验证错误的列表(对应着 INVALID 状态)或者 null(对应着 VALID 状态)。

你可以通过把 ngModel 导出成局部模板变量来查看该控件的状态。 比如下面这个例子就把 NgModel导出成了一个名叫name` 的变量:

Path:"template/hero-form-template.component.html (name)" 。

<input id="name" name="name" class="form-control"      required minlength="4" appForbiddenName="bob"      [(ngModel)]="hero.name" #name="ngModel" ><div *ngIf="name.invalid && (name.dirty || name.touched)"    class="alert alert-danger">  <div *ngIf="name.errors.required">    Name is required.  </div>  <div *ngIf="name.errors.minlength">    Name must be at least 4 characters long.  </div>  <div *ngIf="name.errors.forbiddenName">    Name cannot be Bob.  </div></div>

  1. <input> 元素带有一些 HTML 验证属性:requiredminlength。它还带有一个自定义的验证器指令 forbiddenName。要了解更多信息,参见自定义验证器一节。

  1. #name="ngModel"NgModel 导出成了一个名叫 name 的局部变量。NgModel 把自己控制的 FormControl 实例的属性映射出去,让你能在模板中检查控件的状态,比如 validdirty

  • <div> 元素的 *ngIf 展示了一组嵌套的消息 div,但是只在有“name”错误和控制器为 dirty 或者 touched 时才出现。

  • 每个嵌套的 <div> 为其中一个可能出现的验证错误显示一条自定义消息。比如 requiredminlengthforbiddenName

为防止验证程序在用户有机会编辑表单之前就显示错误,你应该检查控件的 dirty 状态或 touched 状态。

&- 当用户在被监视的字段中修改该值时,控件就会被标记为 dirty(脏)。

&- 当用户的表单控件失去焦点时,该控件就会被标记为 touched(已接触)。

在响应式表单中验证输入

在响应式表单中,事实之源是其组件类。不应该通过模板上的属性来添加验证器,而应该在组件类中直接把验证器函数添加到表单控件模型上(FormControl)。然后,一旦控件发生了变化,Angular 就会调用这些函数。

验证器(Validator)函数

验证器函数可以是同步函数,也可以是异步函数。

  • 同步验证器:这些同步函数接受一个控件实例,然后返回一组验证错误或 null。你可以在实例化一个 FormControl 时把它作为构造函数的第二个参数传进去。

  • 异步验证器 :这些异步函数接受一个控件实例并返回一个 PromiseObservable,它稍后会发出一组验证错误或 null。在实例化 FormControl 时,可以把它们作为第三个参数传入。

出于性能方面的考虑,只有在所有同步验证器都通过之后,Angular 才会运行异步验证器。当每一个异步验证器都执行完之后,才会设置这些验证错误。

内置验证器函数

你可以选择编写自己的验证器函数,也可以使用 Angular 的一些内置验证器。

在模板驱动表单中用作属性的那些内置验证器,比如 requiredminlength,也都可以作为 Validators 类中的函数使用。

要想把这个英雄表单改造成一个响应式表单,你还是要用那些内置验证器,但这次改为用它们的函数形态。参见下面的例子。

Path:"reactive/hero-form-reactive.component.ts (validator functions)" 。

ngOnInit(): void {  this.heroForm = new FormGroup({    'name': new FormControl(this.hero.name, [      Validators.required,      Validators.minLength(4),      forbiddenNameValidator(/bob/i) // <-- Here's how you pass in the custom validator.    ]),    'alterEgo': new FormControl(this.hero.alterEgo),    'power': new FormControl(this.hero.power, Validators.required)  });}get name() { return this.heroForm.get('name'); }get power() { return this.heroForm.get('power'); }

在这个例子中,name 控件设置了两个内置验证器 - Validators.requiredValidators.minLength(4) 以及一个自定义验证器 forbiddenNameValidator

所有这些验证器都是同步的,所以它们作为第二个参数传递。注意,你可以通过把这些函数放到一个数组中传入来支持多个验证器。

这个例子还添加了一些 getter 方法。在响应式表单中,你通常会通过它所属的控件组(FormGroup)的 get 方法来访问表单控件,但有时候为模板定义一些 getter 作为简短形式。

如果你到模板中找到 name 输入框,就会发现它和模板驱动的例子很相似。

Path:"reactive/hero-form-reactive.component.html (name with error msg)" 。

<input id="name" class="form-control"      formControlName="name" required ><div *ngIf="name.invalid && (name.dirty || name.touched)"    class="alert alert-danger">  <div *ngIf="name.errors.required">    Name is required.  </div>  <div *ngIf="name.errors.minlength">    Name must be at least 4 characters long.  </div>  <div *ngIf="name.errors.forbiddenName">    Name cannot be Bob.  </div></div>

这个表单与模板驱动的版本不同,它不再导出任何指令。相反,它使用组件类中定义的 name 读取器(getter)。

请注意,

  • required 属性仍然出现在模板中。虽然它对于验证来说不是必须的,但为了无障碍性,还是应该保留它。

定义自定义验证器

内置的验证器并不是总能精确匹配应用中的用例,因此有时你需要创建一个自定义验证器。

考虑前面的响应式式表单中的 forbiddenNameValidator 函数。该函数的定义如下。

Path:"shared/forbidden-name.directive.ts (forbiddenNameValidator)" 。

/** A hero's name can't match the given regular expression */export function forbiddenNameValidator(nameRe: RegExp): ValidatorFn {  return (control: AbstractControl): {[key: string]: any} | null => {    const forbidden = nameRe.test(control.value);    return forbidden ? {'forbiddenName': {value: control.value}} : null;  };}

这个函数实际上是一个工厂,它接受一个用来检测指定名字是否已被禁用的正则表达式,并返回一个验证器函数。

在本例中,禁止的名字是“bob”; 验证器会拒绝任何带有“bob”的英雄名字。 在其它地方,只要配置的正则表达式可以匹配上,它可能拒绝“alice”或者任何其它名字。

forbiddenNameValidator 工厂函数返回配置好的验证器函数。 该函数接受一个 Angular 控制器对象,并在控制器值有效时返回 null,或无效时返回验证错误对象。 验证错误对象通常有一个名为验证秘钥(forbiddenName)的属性。其值为一个任意词典,你可以用来插入错误信息({name})。

自定义异步验证器和同步验证器很像,只是它们必须返回一个稍后会输出 null 或“验证错误对象”的承诺(Promise)或可观察对象,如果是可观察对象,那么它必须在某个时间点被完成(complete),那时候这个表单就会使用它输出的最后一个值作为验证结果。(译注:HTTP 服务是自动完成的,但是某些自定义的可观察对象可能需要手动调用 complete 方法)

把自定义验证器添加到响应式表单中

在响应式表单中,通过直接把该函数传给 FormControl 来添加自定义验证器。

Path:"reactive/hero-form-reactive.component.ts (validator functions)" 。

this.heroForm = new FormGroup({  'name': new FormControl(this.hero.name, [    Validators.required,    Validators.minLength(4),    forbiddenNameValidator(/bob/i) // <-- Here's how you pass in the custom validator.  ]),  'alterEgo': new FormControl(this.hero.alterEgo),  'power': new FormControl(this.hero.power, Validators.required)});

为模板驱动表单中添加自定义验证器

在模板驱动表单中,要为模板添加一个指令,该指令包含了 validator 函数。例如,对应的 ForbiddenValidatorDirective 用作 forbiddenNameValidator 的包装器。

Angular 在验证过程中会识别出该指令的作用,因为该指令把自己注册成了 NG_VALIDATORS 提供者,如下例所示。NG_VALIDATORS 是一个带有可扩展验证器集合的预定义提供者。

Path:"hared/forbidden-name.directive.ts (providers)" 。

providers: [{provide: NG_VALIDATORS, useExisting: ForbiddenValidatorDirective, multi: true}]

然后该指令类实现了 Validator 接口,以便它能简单的与 Angular 表单集成在一起。这个指令的其余部分有助于你理解它们是如何协作的:

Path:"shared/forbidden-name.directive.ts (directive)" 。

@Directive({  selector: '[appForbiddenName]',  providers: [{provide: NG_VALIDATORS, useExisting: ForbiddenValidatorDirective, multi: true}]})export class ForbiddenValidatorDirective implements Validator {  @Input('appForbiddenName') forbiddenName: string;  validate(control: AbstractControl): {[key: string]: any} | null {    return this.forbiddenName ? forbiddenNameValidator(new RegExp(this.forbiddenName, 'i'))(control)                              : null;  }}

一旦 ForbiddenValidatorDirective 写好了,你只要把 forbiddenName 选择器添加到输入框上就可以激活这个验证器了。比如:

Path:"template/hero-form-template.component.html (forbidden-name-input)" 。

<input id="name" name="name" class="form-control"      required minlength="4" appForbiddenName="bob"      [(ngModel)]="hero.name" #name="ngModel" >

注意

&自定义验证指令是用 useExisting 而不是 useClass 来实例化的。注册的验证程序必须是 ForbiddenValidatorDirective 实例本身 - 表单中的实例,也就是表单中 forbiddenName 属性被绑定到了"bob"的那个。

表示控件状态的 CSS 类

Angular 会自动把很多控件属性作为 CSS 类映射到控件所在的元素上。你可以使用这些类来根据表单状态给表单控件元素添加样式。目前支持下列类:

  • .ng-valid
  • .ng-invalid
  • .ng-pending
  • .ng-pristine
  • .ng-dirty
  • .ng-untouched
  • .ng-touched在下面的例子中,这个英雄表单使用 .ng-valid.ng-invalid 来设置每个表单控件的边框颜色。

Path:"forms.css (status classes)" 。

.ng-valid[required], .ng-valid.required  {  border-left: 5px solid #42A948; /* green */}.ng-invalid:not(form)  {  border-left: 5px solid #a94442; /* red */}

跨字段交叉验证

跨字段交叉验证器是一种自定义验证器,可以对表单中不同字段的值进行比较,并针对它们的组合进行接受或拒绝。例如,你可能有一个提供互不兼容选项的表单,以便让用户选择 A 或 B,而不能两者都选。某些字段值也可能依赖于其它值;用户可能只有当选择了 A 之后才能选择 B。

下列交叉验证的例子说明了如何进行如下操作:

  • 根据两个兄弟控件的值验证响应式表单或模板驱动表单的输入,

  • 当用户与表单交互过,且验证失败后,就会显示描述性的错误信息。

这些例子使用了交叉验证,以确保英雄们不会通过填写 Hero 表单来暴露自己的真实身份。验证器会通过检查英雄的名字和第二人格是否匹配来做到这一点。

为响应式表单添加交叉验证

该表单具有以下结构:

const heroForm = new FormGroup({  'name': new FormControl(),  'alterEgo': new FormControl(),  'power': new FormControl()});

注意,namealterEgo 是兄弟控件。要想在单个自定义验证器中计算这两个控件,你就必须在它们共同的祖先控件中执行验证: FormGroup。你可以在 FormGroup 中查询它的子控件,从而让你能比较它们的值。

要想给 FormGroup 添加验证器,就要在创建时把一个新的验证器传给它的第二个参数。

const heroForm = new FormGroup({  'name': new FormControl(),  'alterEgo': new FormControl(),  'power': new FormControl()}, { validators: identityRevealedValidator });

验证器的代码如下。

Path:"shared/identity-revealed.directive.ts" 。

/** A hero's name can't match the hero's alter ego */export const identityRevealedValidator: ValidatorFn = (control: FormGroup): ValidationErrors | null => {  const name = control.get('name');  const alterEgo = control.get('alterEgo');  return name && alterEgo && name.value === alterEgo.value ? { 'identityRevealed': true } : null;};

这个 identity 验证器实现了 ValidatorFn 接口。它接收一个 Angular 表单控件对象作为参数,当表单有效时,它返回一个 null,否则返回 ValidationErrors 对象。

该验证器通过调用 FormGroupget 方法来检索这些子控件,然后比较 namealterEgo 控件的值。

如果值不匹配,则 hero 的身份保持秘密,两者都有效,且 validator 返回 null。如果匹配,就说明英雄的身份已经暴露了,验证器必须通过返回一个错误对象来把这个表单标记为无效的。

为了提供更好的用户体验,当表单无效时,模板还会显示一条恰当的错误信息。

Path:"reactive/hero-form-template.component.html" 。

<div *ngIf="heroForm.errors?.identityRevealed && (heroForm.touched || heroForm.dirty)" class="cross-validation-error-message alert alert-danger">    Name cannot match alter ego.</div>

如果 FormGroup 中有一个由 identityRevealed 验证器返回的交叉验证错误,*ngIf 就会显示错误,但只有当该用户已经与表单进行过交互的时候才显示。

为模板驱动表单添加交叉验证

对于模板驱动表单,你必须创建一个指令来包装验证器函数。你可以使用NG_VALIDATORS 令牌来把该指令提供为验证器,如下例所示。

Path:"shared/identity-revealed.directive.ts" 。

@Directive({  selector: '[appIdentityRevealed]',  providers: [{ provide: NG_VALIDATORS, useExisting: IdentityRevealedValidatorDirective, multi: true }]})export class IdentityRevealedValidatorDirective implements Validator {  validate(control: AbstractControl): ValidationErrors {    return identityRevealedValidator(control)  }}

你必须把这个新指令添加到 HTML 模板中。由于验证器必须注册在表单的最高层,因此下列模板会把该指令放在 form 标签上。

Path:"template/hero-form-template.component.html" 。

<form #heroForm="ngForm" appIdentityRevealed>

为了提供更好的用户体验,当表单无效时,我们要显示一个恰当的错误信息。

Path:"template/hero-form-template.component.html" 。

<div *ngIf="heroForm.errors?.identityRevealed && (heroForm.touched || heroForm.dirty)" class="cross-validation-error-message alert alert-danger">    Name cannot match alter ego.</div>

这在模板驱动表单和响应式表单中都是一样的。

创建异步验证器

异步验证器实现了 AsyncValidatorFnAsyncValidator 接口。它们与其同步版本非常相似,但有以下不同之处。

  • validate() 函数必须返回一个 Promise 或可观察对象,

  • 返回的可观察对象必须是有尽的,这意味着它必须在某个时刻完成(complete)。要把无尽的可观察对象转换成有尽的,可以在管道中加入过滤操作符,比如 firstlasttaketakeUntil

异步验证在同步验证完成后才会发生,并且只有在同步验证成功时才会执行。如果更基本的验证方法已经发现了无效输入,那么这种检查顺序就可以让表单避免使用昂贵的异步验证流程(例如 HTTP 请求)。

异步验证开始之后,表单控件就会进入 pending 状态。你可以检查控件的 pending 属性,并用它来给出对验证中的视觉反馈。

一种常见的 UI 模式是在执行异步验证时显示 Spinner(转轮)。下面的例子展示了如何在模板驱动表单中实现这一点。

<input [(ngModel)]="name" #model="ngModel" appSomeAsyncValidator><app-spinner *ngIf="model.pending"></app-spinner>

实现自定义异步验证器

在下面的例子中,异步验证器可以确保英雄们选择了一个尚未采用的第二人格。新英雄不断涌现,老英雄也会离开,所以无法提前找到可用的人格列表。为了验证潜在的第二人格条目,验证器必须启动一个异步操作来查询包含所有在编英雄的中央数据库。

下面的代码创建了一个验证器类 UniqueAlterEgoValidator,它实现了 AsyncValidator 接口。

@Injectable({ providedIn: 'root' })export class UniqueAlterEgoValidator implements AsyncValidator {  constructor(private heroesService: HeroesService) {}  validate(    ctrl: AbstractControl  ): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> {    return this.heroesService.isAlterEgoTaken(ctrl.value).pipe(      map(isTaken => (isTaken ? { uniqueAlterEgo: true } : null)),      catchError(() => of(null))    );  }}

构造函数中注入了 HeroesService,它定义了如下接口。

interface HeroesService {  isAlterEgoTaken: (alterEgo: string) => Observable<boolean>;}

在真实的应用中,HeroesService 会负责向英雄数据库发起一个 HTTP 请求,以检查该第二人格是否可用。 从该验证器的视角看,此服务的具体实现无关紧要,所以这个例子仅仅针对 HeroesService 接口来写实现代码。

当验证开始的时候,UniqueAlterEgoValidator 把任务委托给 HeroesServiceisAlterEgoTaken() 方法,并传入当前控件的值。这时候,该控件会被标记为 pending 状态,直到 validate() 方法所返回的可观察对象完成(complete)了。

isAlterEgoTaken() 方法会调度一个 HTTP 请求来检查第二人格是否可用,并返回 Observable<boolean> 作为结果。validate() 方法通过 map 操作符来对响应对象进行管道化处理,并把它转换成验证结果。

与任何验证器一样,如果表单有效,该方法返回 null,如果无效,则返回 ValidationErrors。这个验证器使用 catchError 操作符来处理任何潜在的错误。在这个例子中,验证器将 isAlterEgoTaken() 错误视为成功的验证,因为未能发出验证请求并不一定意味着这个第二人格无效。你也可以用不同的方式处理这种错误,比如返回 ValidationError 对象。

一段时间过后,这条可观察对象链完成,异步验证也就完成了。pending 标志位也设置为 false,该表单的有效性也已更新。

优化异步验证器的性能

默认情况下,所有验证程序在每次表单值更改后都会运行。对于同步验证器,这通常不会对应用性能产生明显的影响。但是,异步验证器通常会执行某种 HTTP 请求来验证控件。每次击键后调度一次 HTTP 请求都会给后端 API 带来压力,应该尽可能避免。

你可以把 updateOn 属性从 change(默认值)改成 submitblur 来推迟表单验证的更新时机。

使用模板驱动表单时,可以在模板中设置该属性。

<input [(ngModel)]="name" [ngModelOptions]="{updateOn: 'blur'}">

使用响应式表单时,可以在 FormControl 实例中设置该属性。

new FormControl('', {updateOn: 'blur'});

许多表单(例如问卷)可能在格式和意图上都非常相似。为了更快更轻松地生成这种表单的不同版本,你可以根据描述业务对象模型的元数据来创建动态表单模板。然后就可以根据数据模型中的变化,使用该模板自动生成新的表单。

如果你有这样一种表单,其内容必须经常更改以满足快速变化的业务需求和监管需求,该技术就特别有用。一个典型的例子就是问卷。你可能需要在不同的上下文中获取用户的意见。用户要看到的表单格式和样式应该保持不变,而你要提的实际问题则会因上下文而异。

在本教程中,你将构建一个渲染基本问卷的动态表单。你要为正在找工作的英雄们建立一个在线应用。英雄管理局会不断修补应用流程,但是借助动态表单,你可以动态创建新的表单,而无需修改应用代码。

本教程将指导你完成以下步骤。

  1. 为项目启用响应式表单。

  1. 建立一个数据模型来表示表单控件。

  1. 使用示例数据填充模型。

  1. 开发一个组件来动态创建表单控件。

你创建的表单会使用输入验证和样式来改善用户体验。它有一个 Submit 按钮,这个按钮只会在所有的用户输入都有效时启用,并用色彩和一些错误信息来标记出无效输入。

这个基本版可以不断演进,以支持更多的问题类型、更优雅的渲染体验以及更高大上的用户体验。

先决条件

在学习本节之前,你应该对下列内容有一个基本的了解。

为项目启用响应式表单

动态表单是基于响应式表单的。为了让应用访问响应式表达式指令,根模块会从 @angular/forms 库中导入 ReactiveFormsModule

以下代码展示了此范例在根模块中所做的设置。

  1. Path:"src/app/app.module.ts" 。

    import { BrowserModule }                from '@angular/platform-browser';    import { ReactiveFormsModule }          from '@angular/forms';    import { NgModule }                     from '@angular/core';    import { AppComponent }                 from './app.component';    import { DynamicFormComponent }         from './dynamic-form.component';    import { DynamicFormQuestionComponent } from './dynamic-form-question.component';    @NgModule({      imports: [ BrowserModule, ReactiveFormsModule ],      declarations: [ AppComponent, DynamicFormComponent, DynamicFormQuestionComponent ],      bootstrap: [ AppComponent ]    })    export class AppModule {      constructor() {      }    }

  1. Path:"src/app/main.ts" 。

    import { enableProdMode } from '@angular/core';    import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';    import { AppModule } from './app/app.module';    import { environment } from './environments/environment';    if (environment.production) {      enableProdMode();    }    platformBrowserDynamic().bootstrapModule(AppModule);

创建一个表单对象模型

动态表单需要一个对象模型来描述此表单功能所需的全部场景。英雄应用表单中的例子是一组问题 - 也就是说,表单中的每个控件都必须提问并接受一个答案。

此类表单的数据模型必须能表示一个问题。本例中包含 DynamicFormQuestionComponent,它定义了一个问题作为模型中的基本对象。

这个 QuestionBase 是一组控件的基类,可以在表单中表示问题及其答案。

Path:"src/app/question-base.ts" 。

export class QuestionBase<T> {  value: T;  key: string;  label: string;  required: boolean;  order: number;  controlType: string;  type: string;  options: {key: string, value: string}[];  constructor(options: {      value?: T,      key?: string,      label?: string,      required?: boolean,      order?: number,      controlType?: string,      type?: string    } = {}) {    this.value = options.value;    this.key = options.key || '';    this.label = options.label || '';    this.required = !!options.required;    this.order = options.order === undefined ? 1 : options.order;    this.controlType = options.controlType || '';    this.type = options.type || '';  }}

定义控件类

此范例从这个基类派生出两个新类,TextboxQuestionDropdownQuestion,分别代表不同的控件类型。当你在下一步中创建表单模板时,你会实例化这些具体的问题类,以便动态渲染相应的控件。

  1. TextboxQuestion 控件类型表示一个普通问题,并允许用户输入答案。

Path:"src/app/question-textbox.ts" 。

    import { QuestionBase } from './question-base';    export class TextboxQuestion extends QuestionBase<string> {      controlType = 'textbox';      type: string;      constructor(options: {} = {}) {        super(options);        this.type = options['type'] || '';      }    }

TextboxQuestion 控件类型将使用 <input> 元素表示在表单模板中。该元素的 type 属性将根据 options 参数中指定的 type 字段定义(例如 textemailurl )。

  1. DropdownQuestion 控件表示在选择框中的一个选项列表。

Path:"src/app/question-dropdown.ts" 。

    import { QuestionBase } from './question-base';    export class DropdownQuestion extends QuestionBase<string> {      controlType = 'dropdown';      options: {key: string, value: string}[] = [];      constructor(options: {} = {}) {        super(options);        this.options = options['options'] || [];      }    }

编写表单组

动态表单会使用一个服务来根据表单模型创建输入控件的分组集合。下面的 QuestionControlService 会收集一组 FormGroup 实例,这些实例会消费问题模型中的元数据。你可以指定一些默认值和验证规则。

Path:"src/app/question-control.service.ts" 。

import { Injectable }   from '@angular/core';import { FormControl, FormGroup, Validators } from '@angular/forms';import { QuestionBase } from './question-base';@Injectable()export class QuestionControlService {  constructor() { }  toFormGroup(questions: QuestionBase<string>[] ) {    let group: any = {};    questions.forEach(question => {      group[question.key] = question.required ? new FormControl(question.value || '', Validators.required)                                              : new FormControl(question.value || '');    });    return new FormGroup(group);  }}

编写动态表单内容

动态表单本身就是一个容器组件,稍后你会添加它。每个问题都会在表单组件的模板中用一个 <app-question> 标签表示,该标签会匹配 DynamicFormQuestionComponent 中的一个实例。

DynamicFormQuestionComponent 负责根据数据绑定的问题对象中的各种值来渲染单个问题的详情。该表单依靠 [formGroup] 指令来将模板 HTML 和底层的控件对象联系起来。DynamicFormQuestionComponent 会创建表单组,并用问题模型中定义的控件来填充它们,并指定显示和验证规则。

  1. Path:"src/app/dynamic-form-question.component.html" 。

    <div [formGroup]="form">      <label [attr.for]="question.key">{{question.label}}</label>      <div [ngSwitch]="question.controlType">        <input *ngSwitchCase="'textbox'" [formControlName]="question.key"                [id]="question.key" [type]="question.type">        <select [id]="question.key" *ngSwitchCase="'dropdown'" [formControlName]="question.key">          <option *ngFor="let opt of question.options" [value]="opt.key">{{opt.value}}</option>        </select>      </div>      <div class="errorMessage" *ngIf="!isValid">{{question.label}} is required</div>    </div>

  1. Path:"src/app/dynamic-form-question.component.ts" 。

    import { Component, Input } from '@angular/core';    import { FormGroup }        from '@angular/forms';    import { QuestionBase }     from './question-base';    @Component({      selector: 'app-question',      templateUrl: './dynamic-form-question.component.html'    })    export class DynamicFormQuestionComponent {      @Input() question: QuestionBase<string>;      @Input() form: FormGroup;      get isValid() { return this.form.controls[this.question.key].valid; }    }

DynamicFormQuestionComponent 的目标是展示模型中定义的各类问题。你现在只有两类问题,但可以想象将来还会有更多。模板中的 ngSwitch 语句会决定要显示哪种类型的问题。这里用到了带有 formControlNameformGroup 选择器的指令。这两个指令都是在 ReactiveFormsModule 中定义的。

提供数据

还要另外一项服务来提供一组具体的问题,以便构建出一个单独的表单。在本练习中,你将创建 QuestionService 以从硬编码的范例数据中提供这组问题。在真实世界的应用中,该服务可能会从后端获取数据。重点是,你可以完全通过 QuestionService 返回的对象来控制英雄的求职申请问卷。要想在需求发生变化时维护问卷,你只需要在 questions 数组中添加、更新和删除对象。

QuestionService 以一个绑定到 @Input() 的问题数组的形式提供了一组问题。

Path:"src/app/question.service.ts" 。

import { Injectable }       from '@angular/core';import { DropdownQuestion } from './question-dropdown';import { QuestionBase }     from './question-base';import { TextboxQuestion }  from './question-textbox';import { of } from 'rxjs';@Injectable()export class QuestionService {  // TODO: get from a remote source of question metadata  getQuestions() {    let questions: QuestionBase<string>[] = [      new DropdownQuestion({        key: 'brave',        label: 'Bravery Rating',        options: [          {key: 'solid',  value: 'Solid'},          {key: 'great',  value: 'Great'},          {key: 'good',   value: 'Good'},          {key: 'unproven', value: 'Unproven'}        ],        order: 3      }),      new TextboxQuestion({        key: 'firstName',        label: 'First name',        value: 'Bombasto',        required: true,        order: 1      }),      new TextboxQuestion({        key: 'emailAddress',        label: 'Email',        type: 'email',        order: 2      })    ];    return of(questions.sort((a, b) => a.order - b.order));  }}

创建一个动态表单模板

DynamicFormComponent 组件是表单的入口点和主容器,它在模板中用 <app-dynamic-form> 表示。

DynamicFormComponent 组件通过把每个问题都绑定到一个匹配 DynamicFormQuestionComponent<app-question> 元素来渲染问题列表。

  1. Path:"src/app/dynamic-form.component.html" 。

    <div>      <form (ngSubmit)="onSubmit()" [formGroup]="form">        <div *ngFor="let question of questions" class="form-row">          <app-question [question]="question" [form]="form"></app-question>        </div>        <div class="form-row">          <button type="submit" [disabled]="!form.valid">Save</button>        </div>      </form>      <div *ngIf="payLoad" class="form-row">        <strong>Saved the following values</strong><br>{{payLoad}}      </div>    </div>

  1. Path:"src/app/dynamic-form.component.ts" 。

    import { Component, Input, OnInit }  from '@angular/core';    import { FormGroup }                 from '@angular/forms';    import { QuestionBase }              from './question-base';    import { QuestionControlService }    from './question-control.service';    @Component({      selector: 'app-dynamic-form',      templateUrl: './dynamic-form.component.html',      providers: [ QuestionControlService ]    })    export class DynamicFormComponent implements OnInit {      @Input() questions: QuestionBase<string>[] = [];      form: FormGroup;      payLoad = '';      constructor(private qcs: QuestionControlService) {  }      ngOnInit() {        this.form = this.qcs.toFormGroup(this.questions);      }      onSubmit() {        this.payLoad = JSON.stringify(this.form.getRawValue());      }    }

显示表单

要显示动态表单的一个实例,AppComponent 外壳模板会把一个 QuestionService 返回的 questions 数组传递给表单容器组件 <app-dynamic-form>

Path:"src/app/app.component.ts" 。

import { Component }       from '@angular/core';import { QuestionService } from './question.service';import { QuestionBase }    from './question-base';import { Observable }      from 'rxjs';@Component({  selector: 'app-root',  template: `    <div>      <h2>Job Application for Heroes</h2>      <app-dynamic-form [questions]="questions$ | async"></app-dynamic-form>    </div>  `,  providers:  [QuestionService]})export class AppComponent {  questions$: Observable<QuestionBase<any>[]>;  constructor(service: QuestionService) {    this.questions$ = service.getQuestions();  }}

这个例子为英雄提供了一个工作申请表的模型,但是除了 QuestionService 返回的对象外,没有涉及任何跟英雄有关的问题。这种模型和数据的分离,允许你为任何类型的调查表复用这些组件,只要它与这个问题对象模型兼容即可。

确保数据有效

表单模板使用元数据的动态数据绑定来渲染表单,而不用做任何与具体问题有关的硬编码。它动态添加了控件元数据和验证标准。

要确保输入有效,就要禁用 “Save” 按钮,直到此表单处于有效状态。当表单有效时,你可以单击 “Save” 按钮,该应用就会把表单的当前值渲染为 JSON。

最终的表单如下图所示。

注:

  • 不同类型的表单和控件集合

本教程展示了如何构建一个问卷,它只是一种动态表单。这个例子使用 `FormGroup` 来收集一组控件。有关不同类型动态表单的示例,请参阅在 [响应式表单](https://www.51coolma.cn/angulerten/angulerten-yi9337wt.html) 中的创建动态表单一节。那个例子还展示了如何使用 `FormArray` 而不是 `FormGroup` 来收集一组控件。

  • 验证用户输入

[验证表单输入](https://www.51coolma.cn/angulerten/angulerten-gsm437ww.html) 部分介绍了如何在响应式表单中进行输入验证的基础知识。

使用可观察对象(Observable)来传递值

可观察对象对在应用的各个部分之间传递消息提供了支持。 它们在 Angular 中频繁使用,并且推荐把它们用于事件处理、异步编程以及处理多个值等场景。

观察者(Observer)模式是一个软件设计模式,它有一个对象,称之为主体 Subject,负责维护一个依赖项(称之为观察者 Observer)的列表,并且在状态变化时自动通知它们。 该模式和发布/订阅模式非常相似(但不完全一样)。

可观察对象是声明式的 —— 也就是说,虽然你定义了一个用于发布值的函数,但是在有消费者订阅它之前,这个函数并不会实际执行。 订阅之后,当这个函数执行完或取消订阅时,订阅者就会收到通知。

可观察对象可以发送多个任意类型的值 —— 字面量、消息、事件。无论这些值是同步发送的还是异步发送的,接收这些值的 API 都是一样的。 由于准备(setup)和清场(teardown)的逻辑都是由可观察对象自己处理的,因此你的应用代码只管订阅并消费这些值就可以了,做完之后,取消订阅。无论这个流是击键流、HTTP 响应流还是定时器,对这些值进行监听和停止监听的接口都是一样的。

由于这些优点,可观察对象在 Angular 中得到广泛使用,也同样建议应用开发者好好使用它。

基本用法和词汇

作为发布者,你创建一个 Observable 的实例,其中定义了一个订阅者(subscriber)函数。 当有消费者调用 subscribe() 方法时,这个函数就会执行。 订阅者函数用于定义“如何获取或生成那些要发布的值或消息”。

要执行所创建的可观察对象,并开始从中接收通知,你就要调用它的 subscribe() 方法,并传入一个观察者(observer)。 这是一个 JavaScript 对象,它定义了你收到的这些消息的处理器(handler)。 subscribe() 调用会返回一个 Subscription 对象,该对象具有一个 unsubscribe() 方法。 当调用该方法时,你就会停止接收通知。

下面这个例子中示范了这种基本用法,它展示了如何使用可观察对象来对当前地理位置进行更新。

//Observe geolocation updates// Create an Observable that will start listening to geolocation updates// when a consumer subscribes.const locations = new Observable((observer) => {  let watchId: number;  // Simple geolocation API check provides values to publish  if ('geolocation' in navigator) {    watchId = navigator.geolocation.watchPosition((position: Position) => {      observer.next(position);    }, (error: PositionError) => {      observer.error(error);    });  } else {    observer.error('Geolocation not available');  }  // When the consumer unsubscribes, clean up data ready for next subscription.  return {    unsubscribe() {      navigator.geolocation.clearWatch(watchId);    }  };});// Call subscribe() to start listening for updates.const locationsSubscription = locations.subscribe({  next(position) {    console.log('Current Position: ', position);  },  error(msg) {    console.log('Error Getting Location: ', msg);  }});// Stop listening for location after 10 secondssetTimeout(() => {  locationsSubscription.unsubscribe();}, 10000);

定义观察者

用于接收可观察对象通知的处理器要实现 Observer 接口。这个对象定义了一些回调函数来处理可观察对象可能会发来的三种通知:

通知类型说明
next必要。用来处理每个送达值。在开始执行后可能执行零次或多次。
error可选。用来处理错误通知。错误会中断这个可观察对象实例的执行过程。
complete可选。用来处理执行完毕(complete)通知。当执行完毕后,这些值就会继续传给下一个处理器。

观察者对象可以定义这三种处理器的任意组合。如果你不为某种通知类型提供处理器,这个观察者就会忽略相应类型的通知。

订阅

只有当有人订阅 Observable 的实例时,它才会开始发布值。 订阅时要先调用该实例的 subscribe() 方法,并把一个观察者对象传给它,用来接收通知。

为了展示订阅的原理,我们需要创建新的可观察对象。它有一个构造函数可以用来创建新实例,但是为了更简明,也可以使用 Observable 上定义的一些静态方法来创建一些常用的简单可观察对象:

- `of(...items)` —— 返回一个 `Observable` 实例,它用同步的方式把参数中提供的这些值发送出来。

- `from(iterable)` —— 把它的参数转换成一个 `Observable` 实例。 该方法通常用于把一个数组转换成一个(发送多个值的)可观察对象。

下面的例子会创建并订阅一个简单的可观察对象,它的观察者会把接收到的消息记录到控制台中:

//Subscribe using observer// Create simple observable that emits three valuesconst myObservable = of(1, 2, 3);// Create observer objectconst myObserver = {  next: x => console.log('Observer got a next value: ' + x),  error: err => console.error('Observer got an error: ' + err),  complete: () => console.log('Observer got a complete notification'),};// Execute with the observer objectmyObservable.subscribe(myObserver);// Logs:// Observer got a next value: 1// Observer got a next value: 2// Observer got a next value: 3// Observer got a complete notification

另外,subscribe() 方法还可以接收定义在同一行中的回调函数,无论 nexterror 还是 complete 处理器。比如,下面的 subscribe() 调用和前面指定预定义观察者的例子是等价的。

//Subscribe with positional argumentsmyObservable.subscribe(  x => console.log('Observer got a next value: ' + x),  err => console.error('Observer got an error: ' + err),  () => console.log('Observer got a complete notification'));

无论哪种情况,next 处理器都是必要的,而 errorcomplete 处理器是可选的。

注意,next() 函数可以接受消息字符串、事件对象、数字值或各种结构,具体类型取决于上下文。 为了更通用一点,我们把由可观察对象发布出来的数据统称为流。任何类型的值都可以表示为可观察对象,而这些值会被发布为一个流。

创建可观察对象

使用 Observable 构造函数可以创建任何类型的可观察流。 当执行可观察对象的 subscribe() 方法时,这个构造函数就会把它接收到的参数作为订阅函数来运行。 订阅函数会接收一个 Observer 对象,并把值发布给观察者的 next() 方法。

比如,要创建一个与前面的 of(1, 2, 3) 等价的可观察对象,你可以这样做:

//Create observable with constructor// This function runs when subscribe() is calledfunction sequenceSubscriber(observer) {  // synchronously deliver 1, 2, and 3, then complete  observer.next(1);  observer.next(2);  observer.next(3);  observer.complete();  // unsubscribe function doesn't need to do anything in this  // because values are delivered synchronously  return {unsubscribe() {}};}// Create a new Observable that will deliver the above sequenceconst sequence = new Observable(sequenceSubscriber);// execute the Observable and print the result of each notificationsequence.subscribe({  next(num) { console.log(num); },  complete() { console.log('Finished sequence'); }});// Logs:// 1// 2// 3// Finished sequence

如果要略微加强这个例子,我们可以创建一个用来发布事件的可观察对象。在这个例子中,订阅函数是用内联方式定义的。

//Create with custom fromEvent functionfunction fromEvent(target, eventName) {  return new Observable((observer) => {    const handler = (e) => observer.next(e);    // Add the event handler to the target    target.addEventListener(eventName, handler);    return () => {      // Detach the event handler from the target      target.removeEventListener(eventName, handler);    };  });}

现在,你就可以使用这个函数来创建可发布 keydown 事件的可观察对象了:

//Use custom fromEvent functionconst ESC_KEY = 27;const nameInput = document.getElementById('name') as HTMLInputElement;const subscription = fromEvent(nameInput, 'keydown')  .subscribe((e: KeyboardEvent) => {    if (e.keyCode === ESC_KEY) {      nameInput.value = '';    }  });

多播

典型的可观察对象会为每一个观察者创建一次新的、独立的执行。 当观察者进行订阅时,该可观察对象会连上一个事件处理器,并且向那个观察者发送一些值。当第二个观察者订阅时,这个可观察对象就会连上一个新的事件处理器,并独立执行一次,把这些值发送给第二个可观察对象。

有时候,不应该对每一个订阅者都独立执行一次,你可能会希望每次订阅都得到同一批值 —— 即使是那些你已经发送过的。这在某些情况下有用,比如用来发送 document 上的点击事件的可观察对象。

多播用来让可观察对象在一次执行中同时广播给多个订阅者。借助支持多播的可观察对象,你不必注册多个监听器,而是复用第一个(next)监听器,并且把值发送给各个订阅者。

当创建可观察对象时,你要决定你希望别人怎么用这个对象以及是否对它的值进行多播。

来看一个从 1 到 3 进行计数的例子,它每发出一个数字就会等待 1 秒。

//Create a delayed sequencefunction sequenceSubscriber(observer) {  const seq = [1, 2, 3];  let timeoutId;  // Will run through an array of numbers, emitting one value  // per second until it gets to the end of the array.  function doSequence(arr, idx) {    timeoutId = setTimeout(() => {      observer.next(arr[idx]);      if (idx === arr.length - 1) {        observer.complete();      } else {        doSequence(arr, ++idx);      }    }, 1000);  }  doSequence(seq, 0);  // Unsubscribe should clear the timeout to stop execution  return {unsubscribe() {    clearTimeout(timeoutId);  }};}// Create a new Observable that will deliver the above sequenceconst sequence = new Observable(sequenceSubscriber);sequence.subscribe({  next(num) { console.log(num); },  complete() { console.log('Finished sequence'); }});// Logs:// (at 1 second): 1// (at 2 seconds): 2// (at 3 seconds): 3// (at 3 seconds): Finished sequence

注意,如果你订阅了两次,就会有两个独立的流,每个流都会每秒发出一个数字。代码如下:

//Two subscriptions// Subscribe starts the clock, and will emit after 1 secondsequence.subscribe({  next(num) { console.log('1st subscribe: ' + num); },  complete() { console.log('1st sequence finished.'); }});// After 1/2 second, subscribe again.setTimeout(() => {  sequence.subscribe({    next(num) { console.log('2nd subscribe: ' + num); },    complete() { console.log('2nd sequence finished.'); }  });}, 500);// Logs:// (at 1 second): 1st subscribe: 1// (at 1.5 seconds): 2nd subscribe: 1// (at 2 seconds): 1st subscribe: 2// (at 2.5 seconds): 2nd subscribe: 2// (at 3 seconds): 1st subscribe: 3// (at 3 seconds): 1st sequence finished// (at 3.5 seconds): 2nd subscribe: 3// (at 3.5 seconds): 2nd sequence finished

修改这个可观察对象以支持多播,代码如下:

//Create a multicast subscriberfunction multicastSequenceSubscriber() {  const seq = [1, 2, 3];  // Keep track of each observer (one for every active subscription)  const observers = [];  // Still a single timeoutId because there will only ever be one  // set of values being generated, multicasted to each subscriber  let timeoutId;  // Return the subscriber function (runs when subscribe()  // function is invoked)  return (observer) => {    observers.push(observer);    // When this is the first subscription, start the sequence    if (observers.length === 1) {      timeoutId = doSequence({        next(val) {          // Iterate through observers and notify all subscriptions          observers.forEach(obs => obs.next(val));        },        complete() {          // Notify all complete callbacks          observers.slice(0).forEach(obs => obs.complete());        }      }, seq, 0);    }    return {      unsubscribe() {        // Remove from the observers array so it's no longer notified        observers.splice(observers.indexOf(observer), 1);        // If there's no more listeners, do cleanup        if (observers.length === 0) {          clearTimeout(timeoutId);        }      }    };  };}// Run through an array of numbers, emitting one value// per second until it gets to the end of the array.function doSequence(observer, arr, idx) {  return setTimeout(() => {    observer.next(arr[idx]);    if (idx === arr.length - 1) {      observer.complete();    } else {      doSequence(observer, arr, ++idx);    }  }, 1000);}// Create a new Observable that will deliver the above sequenceconst multicastSequence = new Observable(multicastSequenceSubscriber());// Subscribe starts the clock, and begins to emit after 1 secondmulticastSequence.subscribe({  next(num) { console.log('1st subscribe: ' + num); },  complete() { console.log('1st sequence finished.'); }});// After 1 1/2 seconds, subscribe again (should "miss" the first value).setTimeout(() => {  multicastSequence.subscribe({    next(num) { console.log('2nd subscribe: ' + num); },    complete() { console.log('2nd sequence finished.'); }  });}, 1500);// Logs:// (at 1 second): 1st subscribe: 1// (at 2 seconds): 1st subscribe: 2// (at 2 seconds): 2nd subscribe: 2// (at 3 seconds): 1st subscribe: 3// (at 3 seconds): 1st sequence finished// (at 3 seconds): 2nd subscribe: 3// (at 3 seconds): 2nd sequence finished

虽然支持多播的可观察对象需要做更多的准备工作,但对某些应用来说,这非常有用。稍后我们会介绍一些简化多播的工具,它们让你能接收任何可观察对象,并把它变成支持多播的。

错误处理

由于可观察对象会异步生成值,所以用 try/catch 是无法捕获错误的。你应该在观察者中指定一个 error 回调来处理错误。发生错误时还会导致可观察对象清理现有的订阅,并且停止生成值。可观察对象可以生成值(调用 next 回调),也可以调用 completeerror 回调来主动结束。

myObservable.subscribe({  next(num) { console.log('Next num: ' + num)},  error(err) { console.log('Received an errror: ' + err)}});

响应式编程是一种面向数据流和变更传播的异步编程范式(Wikipedia)。RxJS(响应式扩展的 JavaScript 版)是一个使用可观察对象进行响应式编程的库,它让组合异步代码和基于回调的代码变得更简单。参见 RxJS 官方文档。

RxJS 提供了一种对 Observable 类型的实现,直到 Observable 成为了 JavaScript 语言的一部分并且浏览器支持它之前,它都是必要的。这个库还提供了一些工具函数,用于创建和使用可观察对象。这些工具函数可用于:

  • 把现有的异步代码转换成可观察对象

  • 迭代流中的各个值

  • 把这些值映射成其它类型

  • 对流进行过滤

  • 组合多个流

创建可观察对象的函数

RxJS 提供了一些用来创建可观察对象的函数。这些函数可以简化根据某些东西创建可观察对象的过程,比如事件、定时器、承诺等等。比如:

//Create an observable from a promiseimport { from } from 'rxjs';// Create an Observable out of a promiseconst data = from(fetch('/api/endpoint'));// Subscribe to begin listening for async resultdata.subscribe({  next(response) { console.log(response); },  error(err) { console.error('Error: ' + err); },  complete() { console.log('Completed'); }});

//Create an observable from a counterimport { interval } from 'rxjs';// Create an Observable that will publish a value on an intervalconst secondsCounter = interval(1000);// Subscribe to begin publishing valuessecondsCounter.subscribe(n =>  console.log(`It's been ${n} seconds since subscribing!`));

//Create an observable from an eventimport { fromEvent } from 'rxjs';const el = document.getElementById('my-element');// Create an Observable that will publish mouse movementsconst mouseMoves = fromEvent(el, 'mousemove');// Subscribe to start listening for mouse-move eventsconst subscription = mouseMoves.subscribe((evt: MouseEvent) => {  // Log coords of mouse movements  console.log(`Coords: ${evt.clientX} X ${evt.clientY}`);  // When the mouse is over the upper-left of the screen,  // unsubscribe to stop listening for mouse movements  if (evt.clientX < 40 && evt.clientY < 40) {    subscription.unsubscribe();  }});

//Create an observable that creates an AJAX requestimport { ajax } from 'rxjs/ajax';// Create an Observable that will create an AJAX requestconst apiData = ajax('/api/data');// Subscribe to create the requestapiData.subscribe(res => console.log(res.status, res.response));

操作符

操作符是基于可观察对象构建的一些对集合进行复杂操作的函数。RxJS 定义了一些操作符,比如 map()filter()concat()flatMap()

操作符接受一些配置项,然后返回一个以来源可观察对象为参数的函数。当执行这个返回的函数时,这个操作符会观察来源可观察对象中发出的值,转换它们,并返回由转换后的值组成的新的可观察对象。下面是一个简单的例子:

Path:"Map operator" 。

import { map } from 'rxjs/operators';const nums = of(1, 2, 3);const squareValues = map((val: number) => val * val);const squaredNums = squareValues(nums);squaredNums.subscribe(x => console.log(x));// Logs// 1// 4// 9

你可以使用管道来把这些操作符链接起来。管道让你可以把多个由操作符返回的函数组合成一个。pipe() 函数以你要组合的这些函数作为参数,并且返回一个新的函数,当执行这个新函数时,就会顺序执行那些被组合进去的函数。

应用于某个可观察对象上的一组操作符就像一个处理流程 —— 也就是说,对你感兴趣的这些值进行处理的一组操作步骤。这个处理流程本身不会做任何事。你需要调用 subscribe() 来通过处理流程得出并生成一个结果。

例子如下:

Path:"Standalone pipe function" 。

import { filter, map } from 'rxjs/operators';const nums = of(1, 2, 3, 4, 5);// Create a function that accepts an Observable.const squareOddVals = pipe(  filter((n: number) => n % 2 !== 0),  map(n => n * n));// Create an Observable that will run the filter and map functionsconst squareOdd = squareOddVals(nums);// Subscribe to run the combined functionssquareOdd.subscribe(x => console.log(x));

pipe() 函数也同时是 RxJS 的 Observable 上的一个方法,所以你可以用下列简写形式来达到同样的效果:

Path:"Observable.pipe function" 。

import { filter, map } from 'rxjs/operators';const squareOdd = of(1, 2, 3, 4, 5)  .pipe(    filter(n => n % 2 !== 0),    map(n => n * n)  );// Subscribe to get valuessquareOdd.subscribe(x => console.log(x));

常用操作符

RxJS 提供了很多操作符,不过只有少数是常用的。 下面是一个常用操作符的列表和用法范例,参见 [RxJS API]() 文档。

注:

  • 对于 Angular 应用来说,我们提倡使用管道来组合操作符,而不是使用链式写法。链式写法仍然在很多 RxJS 中使用着。
类别操作符
创建from,fromEvent, of
组合combineLatest, concat, merge, startWith , withLatestFrom, zip
过滤debounceTime, distinctUntilChanged, filter, take, takeUntil
转换bufferTime, concatMap, map, mergeMap, scan, switchMap
工具tap
多播share

错误处理

除了可以在订阅时提供 error() 处理器外,RxJS 还提供了 catchError 操作符,它允许你在管道中处理已知错误。

假设你有一个可观察对象,它发起 API 请求,然后对服务器返回的响应进行映射。如果服务器返回了错误或值不存在,就会生成一个错误。如果你捕获这个错误并提供了一个默认值,流就会继续处理这些值,而不会报错。

下面是使用 catchError 操作符实现这种效果的例子:

Path:"catchError operator" 。

import { ajax } from 'rxjs/ajax';import { map, catchError } from 'rxjs/operators';// Return "response" from the API. If an error happens,// return an empty array.const apiData = ajax('/api/data').pipe(  map(res => {    if (!res.response) {      throw new Error('Value expected!');    }    return res.response;  }),  catchError(err => of([])));apiData.subscribe({  next(x) { console.log('data: ', x); },  error(err) { console.log('errors already caught... will not run'); }});

重试失败的可观察对象

catchError 提供了一种简单的方式进行恢复,而 retry 操作符让你可以尝试失败的请求。

可以在 catchError 之前使用 retry 操作符。它会订阅到原始的来源可观察对象,它可以重新运行导致结果出错的动作序列。如果其中包含 HTTP 请求,它就会重新发起那个 HTTP 请求。

下列代码把前面的例子改成了在捕获错误之前重发请求:

Path:"retry operator" 。

import { ajax } from 'rxjs/ajax';import { map, retry, catchError } from 'rxjs/operators';const apiData = ajax('/api/data').pipe(  retry(3), // Retry up to 3 times before failing  map(res => {    if (!res.response) {      throw new Error('Value expected!');    }    return res.response;  }),  catchError(err => of([])));apiData.subscribe({  next(x) { console.log('data: ', x); },  error(err) { console.log('errors already caught... will not run'); }});

不要重试登录认证请求,这些请求只应该由用户操作触发。我们肯定不会希望自动重复发送登录请求导致用户的账号被锁定。

可观察对象的命名约定

由于 Angular 的应用几乎都是用 TypeScript 写的,你通常会希望知道某个变量是否可观察对象。虽然 Angular 框架并没有针对可观察对象的强制性命名约定,不过你经常会看到可观察对象的名字以“$”符号结尾。

这在快速浏览代码并查找可观察对象值时会非常有用。同样的,如果你希望用某个属性来存储来自可观察对象的最近一个值,它的命名惯例是与可观察对象同名,但不带“$”后缀。

比如:

Path:"Naming observables" 。

import { Component } from '@angular/core';import { Observable } from 'rxjs';@Component({  selector: 'app-stopwatch',  templateUrl: './stopwatch.component.html'})export class StopwatchComponent {  stopwatchValue: number;  stopwatchValue$: Observable<number>;  start() {    this.stopwatchValue$.subscribe(num =>      this.stopwatchValue = num    );  }}

Angular 中的可观察对象

Angular 使用可观察对象作为处理各种常用异步操作的接口。比如:

  • EventEmitter 类派生自 Observable

  • HTTP 模块使用可观察对象来处理 AJAX 请求和响应。

  • 路由器和表单模块使用可观察对象来监听对用户输入事件的响应。

在组件之间传递数据

Angular 提供了一个 EventEmitter 类,它用来通过组件的 @Output() 装饰器 发送一些值。EventEmitter 扩展了 RxJS Subject,并添加了一个 emit() 方法,这样它就可以发送任意值了。当你调用 emit() 时,就会把所发送的值传给订阅上来的观察者的 next() 方法。

这种用法的例子参见 EventEmitter 文档。下面这个范例组件监听了 openclose 事件:

<zippy (open)="onOpen($event)" (close)="onClose($event)"></zippy>

组件的定义如下:

//EventEmitter@Component({  selector: 'zippy',  template: `  <div class="zippy">    <div (click)="toggle()">Toggle</div>    <div [hidden]="!visible">      <ng-content></ng-content>    </div>  </div>`})export class ZippyComponent {  visible = true;  @Output() open = new EventEmitter<any>();  @Output() close = new EventEmitter<any>();  toggle() {    this.visible = !this.visible;    if (this.visible) {      this.open.emit(null);    } else {      this.close.emit(null);    }  }}

HTTP

Angular 的 HttpClient 从 HTTP 方法调用中返回了可观察对象。例如,"http.get(‘/api’)" 就会返回可观察对象。相对于基于承诺(Promise)的 HTTP API,它有一系列优点:

  • 可观察对象不会修改服务器的响应(和在承诺上串联起来的 .then() 调用一样)。反之,你可以使用一系列操作符来按需转换这些值。

  • HTTP 请求是可以通过 unsubscribe() 方法来取消的。

  • 请求可以进行配置,以获取进度事件的变化。

  • 失败的请求很容易重试。

Async 管道

AsyncPipe 会订阅一个可观察对象或承诺,并返回其发出的最后一个值。当发出新值时,该管道就会把这个组件标记为需要进行变更检查的(译注:因此可能导致刷新界面)。

下面的例子把 time 这个可观察对象绑定到了组件的视图中。这个可观察对象会不断使用当前时间更新组件的视图。

//Using async pipe@Component({  selector: 'async-observable-pipe',  template: `<div><code>observable|async</code>:       Time: {{ time | async }}</div>`})export class AsyncObservablePipeComponent {  time = new Observable<string>(observer => {    setInterval(() => observer.next(new Date().toString()), 1000);  });}

路由器 (router)

Router.events 以可观察对象的形式提供了其事件。 你可以使用 RxJS 中的 filter() 操作符来找到感兴趣的事件,并且订阅它们,以便根据浏览过程中产生的事件序列作出决定。 例子如下:

//Router eventsimport { Router, NavigationStart } from '@angular/router';import { filter } from 'rxjs/operators';@Component({  selector: 'app-routable',  templateUrl: './routable.component.html',  styleUrls: ['./routable.component.css']})export class Routable1Component implements OnInit {  navStart: Observable<NavigationStart>;  constructor(private router: Router) {    // Create a new Observable that publishes only the NavigationStart event    this.navStart = router.events.pipe(      filter(evt => evt instanceof NavigationStart)    ) as Observable<NavigationStart>;  }  ngOnInit() {    this.navStart.subscribe(evt => console.log('Navigation Started!'));  }}

ActivatedRoute 是一个可注入的路由器服务,它使用可观察对象来获取关于路由路径和路由参数的信息。比如,ActivatedRoute.url 包含一个用于汇报路由路径的可观察对象。例子如下:

//ActivatedRouteimport { ActivatedRoute } from '@angular/router';@Component({  selector: 'app-routable',  templateUrl: './routable.component.html',  styleUrls: ['./routable.component.css']})export class Routable2Component implements OnInit {  constructor(private activatedRoute: ActivatedRoute) {}  ngOnInit() {    this.activatedRoute.url      .subscribe(url => console.log('The URL changed to: ' + url));  }}

响应式表单 (reactive forms)

响应式表单具有一些属性,它们使用可观察对象来监听表单控件的值。 FormControlvalueChanges 属性和 statusChanges 属性包含了会发出变更事件的可观察对象。订阅可观察的表单控件属性是在组件类中触发应用逻辑的途径之一。比如:

//Reactive formsimport { FormGroup } from '@angular/forms';@Component({  selector: 'my-component',  template: 'MyComponent Template'})export class MyComponent implements OnInit {  nameChangeLog: string[] = [];  heroForm: FormGroup;  ngOnInit() {    this.logNameChange();  }  logNameChange() {    const nameControl = this.heroForm.get('name');    nameControl.valueChanges.forEach(      (value: string) => this.nameChangeLog.push(value)    );  }}

输入提示(type-ahead)建议

可观察对象可以简化输入提示建议的实现方式。典型的输入提示要完成一系列独立的任务:

  • 从输入中监听数据。

  • 移除输入值前后的空白字符,并确认它达到了最小长度。

  • 防抖(这样才能防止连续按键时每次按键都发起 API 请求,而应该等到按键出现停顿时才发起)

  • 如果输入值没有变化,则不要发起请求(比如按某个字符,然后快速按退格)。

  • 如果已发出的 AJAX 请求的结果会因为后续的修改而变得无效,那就取消它。

完全用 JavaScript 的传统写法实现这个功能可能需要大量的工作。使用可观察对象,你可以使用这样一个 RxJS 操作符的简单序列:

//Typeaheadimport { fromEvent } from 'rxjs';import { ajax } from 'rxjs/ajax';import { debounceTime, distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators';const searchBox = document.getElementById('search-box');const typeahead = fromEvent(searchBox, 'input').pipe(  map((e: KeyboardEvent) => (e.target as HTMLInputElement).value),  filter(text => text.length > 2),  debounceTime(10),  distinctUntilChanged(),  switchMap(() => ajax('/api/endpoint')));typeahead.subscribe(data => { // Handle the data from the API});

指数化退避

指数化退避是一种失败后重试 API 的技巧,它会在每次连续的失败之后让重试时间逐渐变长,超过最大重试次数之后就会彻底放弃。 如果使用承诺和其它跟踪 AJAX 调用的方法会非常复杂,而使用可观察对象,这非常简单:

//Exponential backoffimport { pipe, range, timer, zip } from 'rxjs';import { ajax } from 'rxjs/ajax';import { retryWhen, map, mergeMap } from 'rxjs/operators';function backoff(maxTries, ms) { return pipe(   retryWhen(attempts => zip(range(1, maxTries), attempts)     .pipe(       map(([i]) => i * i),       mergeMap(i =>  timer(i * ms))     )   ) );}ajax('/api/endpoint')  .pipe(backoff(3, 250))  .subscribe(data => handleData(data));function handleData(data) {  // ...}

可观察对象与其它技术的比较

你可以经常使用可观察对象(Observable)而不是承诺(Promise)来异步传递值。 类似的,可观察对象也可以取代事件处理器的位置。最后,由于可观察对象传递多个值,所以你可以在任何可能构建和操作数组的地方使用可观察对象。

在这些情况下,可观察对象的行为与其替代技术有一些差异,不过也提供了一些显著的优势。下面是对这些差异的详细比较。

可观察对象 vs. 承诺

可观察对象经常拿来和承诺进行对比。有一些关键的不同点:

  • 可观察对象是声明式的,在被订阅之前,它不会开始执行。承诺是在创建时就立即执行的。这让可观察对象可用于定义那些应该按需执行的菜谱。

  • 可观察对象能提供多个值。承诺只提供一个。这让可观察对象可用于随着时间的推移获取多个值。

  • 可观察对象会区分串联处理和订阅语句。承诺只有 .then() 语句。这让可观察对象可用于创建供系统的其它部分使用而不希望立即执行的复杂菜谱。

  • 可观察对象的 subscribe() 会负责处理错误。承诺会把错误推送给它的子承诺。这让可观察对象可用于进行集中式、可预测的错误处理。

创建与订阅

  • 在有消费者订阅之前,可观察对象不会执行。subscribe() 会执行一次定义好的行为,并且可以再次调用它。每次订阅都是单独计算的。重新订阅会导致重新计算这些值。

Path:"src/observables.ts (observable)" 。

    // declare a publishing operation    const observable = new Observable<number>(observer => {      // Subscriber fn...    });    // initiate execution    observable.subscribe(() => {      // observer handles notifications    });

  • 承诺会立即执行,并且只执行一次。当承诺创建时,会立即计算出结果。没有办法重新做一次。所有的 then 语句(订阅)都会共享同一次计算。

Path:"src/promises.ts (promise)" 。

    // initiate execution    const promise = new Promise<number>((resolve, reject) => {      // Executer fn...    });    promise.then(value => {      // handle result here    });

串联

  • 可观察对象会区分各种转换函数,比如映射和订阅。只有订阅才会激活订阅者函数,以开始计算那些值。

Path:"src/observables.ts (chain)" 。

    observable.pipe(map(v => 2 * v));

  • 承诺并不区分最后的 .then() 语句(等价于订阅)和中间的 .then() 语句(等价于映射)。

Path:"src/promises.ts (chain)" 。

    promise.then(v => 2 * v);

可取消

  • 可观察对象的订阅是可取消的。取消订阅会移除监听器,使其不再接受将来的值,并通知订阅者函数取消正在进行的工作。

Path:"src/observables.ts (unsubcribe)" 。

    const subscription = observable.subscribe(() => {      // observer handles notifications    });    subscription.unsubscribe();

  • 承诺是不可取消的。

错误处理

  • 可观察对象的错误处理工作交给了订阅者的错误处理器,并且该订阅者会自动取消对这个可观察对象的订阅。

Path:"src/observables.ts (error)" 。

    observable.subscribe(() => {      throw Error('my error');    });

  • 承诺会把错误推给其子承诺。

Path:"src/promises.ts (error)" 。

    promise.then(() => {      throw Error('my error');    });

速查表

下列代码片段揭示了同样的操作要如何分别使用可观察对象和承诺进行实现。

操作可观察对象承诺
创建new Observable((observer) => { observer.next(123); });new Promise((resolve, reject) => { resolve(123); }); 
转换obs.pipe(map((value) => value * 2));promise.then((value) => value * 2); 
订阅sub = obs.subscribe((value) => { console.log(value) });promise.then((value) => { console.log(value); }) 
取消订阅sub.unsubscribe();承诺被解析时隐式完成。

可观察对象 vs. 事件 API

可观察对象和事件 API 中的事件处理器很像。这两种技术都会定义通知处理器,并使用它们来处理一段时间内传递的多个值。订阅可观察对象与添加事件处理器是等价的。一个显著的不同是你可以配置可观察对象,使其在把事件传给事件处理器之前先进行转换。

使用可观察对象来处理错误和异步操作在 HTTP 请求这样的场景下更加具有一致性。

下列代码片段揭示了同样的操作要如何分别使用可观察对象和事件 API 进行实现。

  1. “创建与取消”操作。

  • 可观察对象。

    // Setup    let clicks$ = fromEvent(buttonEl, ‘click’);    // Begin listening    let subscription = clicks$      .subscribe(e => console.log(‘Clicked’, e))    // Stop listening    subscription.unsubscribe();

  • 事件 API。

    function handler(e) {      console.log(‘Clicked’, e);    }    // Setup & begin listening    button.addEventListener(‘click’, handler);    // Stop listening    button.removeEventListener(‘click’, handler);

  1. 配置操作。

  • 可观察对象。

监听按键,提供一个流来表示这些输入的值。

    fromEvent(inputEl, 'keydown').pipe(      map(e => e.target.value)    );

  • 事件 API。

不支持配置。

    element.addEventListener(eventName, (event) => {      // Cannot change the passed Event into another      // value before it gets to the handler    });

  1. 订阅操作。

  • 可观察对象。

    observable.subscribe(() => {      // notification handlers here    });

  • 事件 API。

    element.addEventListener(eventName, (event) => {      // notification handler here    });

可观察对象 vs. 数组

可观察对象会随时间生成值。数组是用一组静态的值创建的。某种意义上,可观察对象是异步的,而数组是同步的。 在下面的例子中, 符号表示异步传递值。

  1. 给出值。

  • 可观察对象。

    obs: ➞1➞2➞3➞5➞7    obsB: ➞'a'➞'b'➞'c'

  • 数组。

    arr: [1, 2, 3, 5, 7]    arrB: ['a', 'b', 'c']

  1. concat()

  • 可观察对象。

    concat(obs, obsB)    ➞1➞2➞3➞5➞7➞'a'➞'b'➞'c'

  • 数组。

    arr.concat(arrB)    [1,2,3,5,7,'a','b','c']

  1. filter()

  • 可观察对象。

    obs.pipe(filter((v) => v>3))    ➞5➞7

  • 数组。

    arr.filter((v) => v>3)    [5, 7]

  1. find()

  • 可观察对象。

    obs.pipe(find((v) => v>3))    ➞5

  • 数组。

    arr.find((v) => v>3)    5

  1. findIndex()

  • 可观察对象。

    obs.pipe(findIndex((v) => v>3))    ➞3

  • 数组。

    arr.findIndex((v) => v>3)    3

  1. forEach()

  • 可观察对象。

    obs.pipe(tap((v) => {      console.log(v);    }))    1    2    3    5    7

  • 数组。

    arr.forEach((v) => {      console.log(v);    })    1    2    3    5    7

  1. map()

  • 可观察对象。

    obs.pipe(map((v) => -v))    ➞-1➞-2➞-3➞-5➞-7

  • 数组。

    arr.map((v) => -v)    [-1, -2, -3, -5, -7]

  1. reduce()

  • 可观察对象。

    obs.pipe(reduce((s,v)=> s+v, 0))    ➞18

  • 数组。

    arr.reduce((s,v) => s+v, 0)    18

NgModules 用于配置注入器和编译器,并帮你把那些相关的东西组织在一起。

NgModule 是一个带有 @NgModule 装饰器的类。 @NgModule 的参数是一个元数据对象,用于描述如何编译组件的模板,以及如何在运行时创建注入器。 它会标出该模块自己的组件、指令和管道,通过 exports 属性公开其中的一部分,以便外部组件使用它们。 NgModule 还能把一些服务提供者添加到应用的依赖注入器中。

Angular 模块化

模块是组织应用和使用外部库扩展应用的最佳途径。

Angular 自己的库都是 NgModule,比如 FormsModuleHttpClientModuleRouterModule。 很多第三方库也是 NgModule,比如 Material DesignIonicAngularFire2

NgModule 把组件、指令和管道打包成内聚的功能块,每个模块聚焦于一个特性区域、业务领域、工作流或通用工具。

模块还可以把服务加到应用中。 这些服务可能是内部开发的(比如你自己写的),或者来自外部的(比如 Angular 的路由和 HTTP 客户端)。

模块可以在应用启动时急性加载,也可以由路由器进行异步的惰性加载。

NgModule 的元数据会做这些:

  • 声明某些组件、指令和管道属于这个模块。

  • 公开其中的部分组件、指令和管道,以便其它模块中的组件模板中可以使用它们。

  • 导入其它带有组件、指令和管道的模块,这些模块中的元件都是本模块所需的。

  • 提供一些供应用中的其它组件使用的服务。

每个 Angular 应用都至少有一个模块,也就是根模块。 你可以引导那个模块,以启动该应用。

对于那些只有少量组件的简单应用,根模块就是你所需的一切。 随着应用的成长,你要把这个根模块重构成一些特性模块,它们代表一组密切相关的功能集。 然后你再把这些模块导入到根模块中。

基本的模块

Angular CLI 在创建新应用时会生成如下基本模块 AppModule

Path:"src/app/app.module.ts (default AppModule)" 。

// importsimport { BrowserModule } from '@angular/platform-browser';import { NgModule } from '@angular/core';import { AppComponent } from './app.component';// @NgModule decorator with its metadata@NgModule({  declarations: [AppComponent],  imports: [BrowserModule],  providers: [],  bootstrap: [AppComponent]})export class AppModule {}

文件的顶部是一些导入语句。接下来是你配置 NgModule 的地方,用于规定哪些组件和指令属于它(declarations),以及它使用了哪些其它模块(imports)。

JavaScript 和 Angular 都使用模块来组织代码,虽然它们的组织形式不同,但 Angular 的应用会同时依赖两者。

JavaScript 模块

在 JavaScript 中,模块是内含 JavaScript 代码的独立文件。要让其中的东西可用,你要写一个导出语句,通常会放在相应的代码之后,类似这样:

export class AppComponent { ... }

然后,当你在其它文件中需要这个文件的代码时,要像这样导入它:

import { AppComponent } from './app.component';

JavaScript 模块让你能为代码加上命名空间,防止因为全局变量而引起意外。

NgModules

NgModule 是一些带有 @NgModule 装饰器的类。@NgModule 装饰器的 imports 数组会告诉 Angular 哪些其它的 NgModule 是当前模块所需的。 imports 数组中的这些模块与 JavaScript 模块不同,它们都是 NgModule 而不是常规的 JavaScript 模块。 带有 @NgModule 装饰器的类通常会习惯性地放在单独的文件中,但单独的文件并不像 JavaScript 模块那样作为必要条件,而是因为它带有 @NgModule 装饰器及其元数据。

Angular CLI 生成的 AppModule 实际演示了这两种模块:

/* These are JavaScript import statements. Angular doesn’t know anything about these. */import { BrowserModule } from '@angular/platform-browser';import { NgModule } from '@angular/core';import { AppComponent } from './app.component';/* The @NgModule decorator lets Angular know that this is an NgModule. */@NgModule({  declarations: [    AppComponent  ],  imports: [     /* These are NgModule imports. */    BrowserModule  ],  providers: [],  bootstrap: [AppComponent]})export class AppModule { }

NgModule 类与 JavaScript 模块有下列关键性的不同:

  • NgModule 只绑定了可声明的类,这些可声明的类只是供 Angular 编译器用的。

  • 与 JavaScript 类把它所有的成员类都放在一个巨型文件中不同,你要把该模块的类列在它的 @NgModule.declarations 列表中。

  • NgModule 只能导出可声明的类。这可能是它自己拥有的也可能是从其它模块中导入的。它不会声明或导出任何其它类型的类。

  • 与 JavaScript 模块不同,NgModule 可以通过把服务提供者加到 @NgModule.providers 列表中,来用服务扩展整个应用。

先决条件

对下列知识有基本的了解:

启动过程

NgModule 用于描述应用的各个部分如何组织在一起。 每个应用有至少一个 Angular 模块,根模块就是你用来启动此应用的模块。 按照惯例,它通常命名为 AppModule。

当你使用 Angular CLI 命令 ng new 生成一个应用时,其默认的 AppModule 是这样的:

/* JavaScript imports */import { BrowserModule } from '@angular/platform-browser';import { NgModule } from '@angular/core';import { FormsModule } from '@angular/forms';import { HttpClientModule } from '@angular/common/http';import { AppComponent } from './app.component';/* the AppModule class with the @NgModule decorator */@NgModule({  declarations: [    AppComponent  ],  imports: [    BrowserModule,    FormsModule,    HttpClientModule  ],  providers: [],  bootstrap: [AppComponent]})export class AppModule { }

import 语句之后,是一个带有 @NgModule 装饰器的类。

@NgModule 装饰器表明 AppModule 是一个 NgModule 类。 @NgModule 获取一个元数据对象,它会告诉 Angular 如何编译和启动本应用。

  • declarations —— 该应用所拥有的组件。

  • imports —— 导入 BrowserModule 以获取浏览器特有的服务,比如 DOM 渲染、无害化处理和位置(location)。

  • providers —— 各种服务提供者。

  • bootstrap —— 根组件,Angular 创建它并插入 index.html 宿主页面。

Angular CLI 创建的默认应用只有一个组件 AppComponent,所以它会同时出现在 declarationsbootstrap 数组中。

declarations 数组

该模块的 declarations 数组告诉 Angular 哪些组件属于该模块。 当你创建更多组件时,也要把它们添加到 declarations 中。

每个组件都应该(且只能)声明(declare)在一个 NgModule 类中。如果你使用了未声明过的组件,Angular 就会报错。

declarations 数组只能接受可声明对象。可声明对象包括组件、指令和管道。 一个模块的所有可声明对象都必须放在 declarations 数组中。 可声明对象必须只能属于一个模块,如果同一个类被声明在了多个模块中,编译器就会报错。

这些可声明的类在当前模块中是可见的,但是对其它模块中的组件是不可见的 —— 除非把它们从当前模块导出, 并让对方模块导入本模块。

下面是哪些类可以添加到 declarations 数组中的例子:

declarations: [  YourComponent,  YourPipe,  YourDirective],

每个可声明对象都只能属于一个模块,所以只能把它声明在一个 @NgModule 中。当你需要在其它模块中使用它时,就要在那里导入包含这个可声明对象的模块。

只有 @NgModule 可以出现在 imports 数组中。

通过 @NgModule 使用指令

使用 declarations 数组声明指令。在模块中使用指令、组件或管道的步骤如下:

  1. 从你编写它的文件中导出它。

  1. 把它导入到适当的模块中。

  1. @NgModuledeclarations 数组中声明它。

这三步的结果如下所示。在你创建指令的文件中导出它。 下面的例子中,"item.directive.ts" 中的 ItemDirective 是 CLI 自动生成的默认指令结构。

Path:"src/app/item.directive.ts" 。

import { Directive } from '@angular/core';@Directive({  selector: '[appItem]'})export class ItemDirective {// code goes here  constructor() { }}

重点在于你要先在这里导出它才能在别处导入它。接下来,使用 JavaScript 的 import 语句把它导入到 NgModule 中(这里是 "app.module.ts")。

Path:"src/app/app.module.ts" 。

import { ItemDirective } from './item.directive';

同样在这个文件中,把它添加到 @NgModuledeclarations 数组中:

Path:"src/app/app.module.ts" 。

declarations: [  AppComponent,  ItemDirective],

现在,你就可以在组件中使用 ItemDirective 了。这个例子中使用的是 AppModule,但是在特性模块中你也可以这么做。

注:

  • 组件、指令和管道都只能属于一个模块。你在应用中也只需要声明它们一次,因为你还可以通过导入必要的模块来使用它们。这能节省你的时间,并且帮助你的应用保持精简。

imports 数组

模块的 imports 数组只会出现在 @NgModule 元数据对象中。 它告诉 Angular 该模块想要正常工作,还需要哪些模块。

列表中的模块导出了本模块中的各个组件模板中所引用的各个组件、指令或管道。在这个例子中,当前组件是 AppComponent,它引用了导出自 BrowserModuleFormsModuleHttpClientModule 的组件、指令或管道。 总之,组件的模板中可以引用在当前模块中声明的或从其它模块中导入的组件、指令、管道。

providers 数组

providers 数组中列出了该应用所需的服务。当直接把服务列在这里时,它们是全应用范围的。 当你使用特性模块和惰性加载时,它们是范围化的。

bootstrap 数组

应用是通过引导根模块 AppModule 来启动的,根模块还引用了 entryComponent。 此外,引导过程还会创建 bootstrap 数组中列出的组件,并把它们逐个插入到浏览器的 DOM 中。

每个被引导的组件都是它自己的组件树的根。 插入一个被引导的组件通常触发一系列组件的创建并形成组件树。

虽然也可以在宿主页面中放多个组件,但是大多数应用只有一个组件树,并且只从一个根组件开始引导。

这个根组件通常叫做 AppComponent,并且位于根模块的 bootstrap 数组中。

Angular 应用至少需要一个充当根模块使用的模块。 如果你要把某些特性添加到应用中,可以通过添加模块来实现。 下列是一些常用的 Angular 模块,其中带有一些其内容物的例子:

NgModule导入自为何使用
BrowserModule@angular/platform-browser当你想要在浏览器中运行应用时
CommonModule@angular/common当你想要使用 NgIf 和 NgFor 时
FormsModule@angular/forms当要构建模板驱动表单时(它包含 NgModel )
ReactiveFormsModule@angular/forms当要构建响应式表单时
RouterModule@angular/router要使用路由功能,并且你要用到 RouterLink,.forRoot() 和 .forChild() 时
HttpClientModule@angular/common/http当你要和服务器对话时

导入模块

当你使用这些 Angular 模块时,在 AppModule(或适当的特性模块)中导入它们,并把它们列在当前 @NgModuleimports 数组中。比如,在 Angular CLI 生成的基本应用中,BrowserModule 会在 "app.module.ts" 中 AppModule 的顶部最先导入。

/* import modules so that AppModule can access them */import { BrowserModule } from '@angular/platform-browser';import { NgModule } from '@angular/core';import { AppComponent } from './app.component';@NgModule({  declarations: [    AppComponent  ],  imports: [ /* add modules here so Angular knows to use them */    BrowserModule,  ],  providers: [],  bootstrap: [AppComponent]})export class AppModule { }

文件顶部的这些导入是 JavaScript 的导入语句,而 @NgModule 中的 imports 数组则是 Angular 特有的。

BrowserModule 和 CommonModule

BrowserModule 导入了 CommonModule,它贡献了很多通用的指令,比如 ngIfngFor。 另外,BrowserModule 重新导出了 CommonModule,以便它所有的指令在任何导入了 BrowserModule 的模块中都可以使用。

对于运行在浏览器中的应用来说,都必须在根模块中 AppModule 导入 BrowserModule,因为它提供了启动和运行浏览器应用时某些必须的服务。BrowserModule 的提供者是面向整个应用的,所以它只能在根模块中使用,而不是特性模块。 特性模块只需要 CommonModule 中的常用指令,它们不需要重新安装所有全应用级的服务。

如果你把 BrowserModule 导入了惰性加载的特性模块中,Angular 就会返回一个错误,并告诉你要改用 CommonModule

下面是特性模块的五个常用分类,包括五组:

  • 领域特性模块。

  • 带路由的特性模块。

  • 路由模块。

  • 服务特性模块

  • 可视部件特性模块。

虽然下面的指南中描述了每种类型的使用及其典型特征,但在实际的应用中,你还可能看到它们的混合体。

  1. 特性模块:领域。

指导原则:

  • 领域特性模块用来给用户提供应用程序领域中特有的用户体验,比如编辑客户信息或下订单等。

  • 它们通常会有一个顶层组件来充当该特性的根组件,并且通常是私有的。用来支持它的各级子组件。

  • 领域特性模块大部分由 declarations 组成,只有顶层组件会被导出。

  • 领域特性模块很少会有服务提供者。如果有,那么这些服务的生命周期必须和该模块的生命周期完全相同。

  • 领域特性模块通常会由更高一级的特性模块导入且只导入一次。

  • 对于缺少路由的小型应用,它们可能只会被根模块 AppModule 导入一次。

  1. 特性模块:路由( Routed )。

指导原则:

  • 带路由的特性模块是一种特殊的领域特性模块,但它的顶层组件会作为路由导航时的目标组件。

  • 根据这个定义,所有惰性加载的模块都是路由特性模块。

  • 带路由的特性模块不会导出任何东西,因为它们的组件永远不会出现在外部组件的模板中。

  • 惰性加载的路由特性模块不应该被任何模块导入。如果那样做就会导致它被急性加载,破坏了惰性加载的设计用途。 也就是说你应该永远不会看到它们在 AppModuleimports 中被引用。 急性加载的路由特性模块必须被其它模块导入,以便编译器能了解它所包含的组件。

  • 路由特性模块很少会有服务提供者。如果那样做,那么它所提供的服务的生命周期必须与该模块的生命周期完全相同。不要在路由特性模块或被路由特性模块所导入的模块中提供全应用级的单例服务。

  1. 特性模块:路由( Routing )。

指导原则:

路由模块为其它模块提供路由配置,并且把路由这个关注点从它的配套模块中分离出来。

路由模块通常会做这些:

  • 定义路由。

  • 把路由配置添加到该模块的 imports 中。

  • 把路由守卫和解析器的服务提供者添加到该模块的 providers 中。

  • 路由模块应该与其配套模块同名,但是加上“Routing”后缀。比如,"foo.module.ts" 中的 FooModule 就有一个位于 "foo-routing.module.ts" 文件中的 FooRoutingModule 路由模块。 如果其配套模块是根模块 AppModuleAppRoutingModule 就要使用 RouterModule.forRoot(routes) 来把路由器配置添加到它的 imports 中。 所有其它路由模块都是子模块,要使用 RouterModule.forChild(routes)

  • 按照惯例,路由模块会重新导出这个 RouterModule,以便其配套模块中的组件可以访问路由器指令,比如 RouterLinkRouterOutlet

  • 路由模块没有自己的可声明对象。组件、指令和管道都是特性模块的职责,而不是路由模块的。

路由模块只应该被它的配套模块导入。

  1. 特性模块:服务。

指导原则:

服务模块提供了一些工具服务,比如数据访问和消息。理论上,它们应该是完全由服务提供者组成的,不应该有可声明对象。Angular 的 HttpClientModule 就是一个服务模块的好例子。

根模块 AppModule 是唯一的可以导入服务模块的模块。

  1. 特性模块:窗口部件。

指导原则:

  • 窗口部件模块为外部模块提供组件、指令和管道。很多第三方 UI 组件库都是窗口部件模块。

  • 窗口部件模块应该完全由可声明对象组成,它们中的大部分都应该被导出。

  • 窗口部件模块很少会有服务提供者。

  • 如果任何模块的组件模板中需要用到这些窗口部件,就请导入相应的窗口部件模块。

下表中汇总了各种特性模块类型的关键特征。

特性模块特性模块提供者导出什么被谁导入
领域罕见顶层组件特性模块,AppModule
路由( Routed )罕见
路由( Routing )有(守卫)RouterModule特性(供路由使用)
服务AppModule
窗口部件罕见特性

从分类上说,入口组件是 Angular 命令式加载的任意组件(也就是说你没有在模板中引用过它), 你可以在 NgModule 中引导它,或把它包含在路由定义中来指定入口组件。

对比一下这两种组件类型:有一类组件被包含在模板中,它们是声明式加载的;另一类组件你会命令式加载它,这就是入口组件。

入口组件有两种主要的类型:

  • 引导用的根组件。

  • 在路由定义中指定的组件。

引导用的入口组件

下面这个例子中指定了一个引导用组件 AppComponent,位于基本的 "app.module.ts" 中:

@NgModule({  declarations: [    AppComponent  ],  imports: [    BrowserModule,    FormsModule,    HttpClientModule,    AppRoutingModule  ],  providers: [],  bootstrap: [AppComponent] // bootstrapped entry component})

可引导组件是一个入口组件,Angular 会在引导过程中把它加载到 DOM 中。 其它入口组件是在其它时机动态加载的,比如用路由器。

Angular 会动态加载根组件 AppComponent,是因为它的类型作为参数传给了 @NgModule.bootstrap 函数。

组件也可以在该模块的 ngDoBootstrap() 方法中进行命令式引导。 @NgModule.bootstrap 属性告诉编译器,这里是一个入口组件,它应该生成代码,来使用这个组件引导该应用。

引导用的组件必须是入口组件,因为引导过程是命令式的,所以它需要一个入口组件。

路由到的入口组件

入口组件的第二种类型出现在路由定义中,就像这样:

const routes: Routes = [  {    path: '',    component: CustomerListComponent  }];

路由定义使用组件类型引用了一个组件:component: CustomerListComponent

所有路由组件都必须是入口组件。这需要你把同一个组件添加到两个地方(路由中和 entryComponents 中),但编译器足够聪明,可以识别出这里是一个路由定义,因此它会自动把这些路由组件添加到 entryComponents 中。

entryComponents 数组

虽然 @NgModule 装饰器具有一个 entryComponents 数组,但大多数情况下你不用显式设置入口组件,因为 Angular 会自动把 @NgModule.bootstrap 中的组件以及路由定义中的组件添加到入口组件中。 虽然这两种机制足够自动添加大多数入口组件,但如果你要用其它方式根据类型来命令式的引导或动态加载某个组件,你就必须把它们显式添加到 entryComponents 中了。

entryComponents 和编译器

对于生产环境的应用,你总是希望加载尽可能小的代码。 这些代码应该只包含你实际使用到的类,并且排除那些从未用到的组件。因此,Angular 编译器只会为那些可以从 entryComponents 中直接或间接访问到的组件生成代码。 这意味着,仅仅往 @NgModule.declarations 中添加更多引用,并不能表达出它们在最终的代码包中是必要的。

实际上,很多库声明和导出的组件都是你从未用过的。 比如,Material Design 库会导出其中的所有组件,因为它不知道你会用哪一个。然而,显然你也不打算全都用上。 对于那些你没有引用过的,摇树优化工具就会把这些组件从最终的代码包中摘出去。

如果一个组件既不是入口组件也没有在模板中使用过,摇树优化工具就会把它扔出去。 所以,最好只添加那些真正的入口组件,以便让应用尽可能保持精简。

特性模块是用来对代码进行组织的模块。

随着应用的增长,你可能需要组织与特定应用有关的代码。 这将帮你把特性划出清晰的边界。使用特性模块,你可以把与特定的功能或特性有关的代码从其它代码中分离出来。 为应用勾勒出清晰的边界,有助于开发人员之间、小组之间的协作,有助于分离各个指令,并帮助管理根模块的大小。

特性模块 vs. 根模块

与核心的 Angular API 的概念相反,特性模块是最佳的组织方式。特性模块提供了聚焦于特定应用需求的一组功能,比如用户工作流、路由或表单。 虽然你也可以用根模块做完所有事情,不过特性模块可以帮助你把应用划分成一些聚焦的功能区。特性模块通过它提供的服务以及共享出的组件、指令和管道来与根模块和其它模块合作。

如何制作特性模块

如果你已经有了 Angular CLI 生成的应用,可以在项目的根目录下输入下面的命令来创建特性模块。把这里的 CustomerDashboard 替换成你的模块名。你可以从名字中省略掉“Module”后缀,因为 CLI 会自动追加上它:

ng generate module CustomerDashboard

这会让 CLI 创建一个名叫 "customer-dashboard" 的文件夹,其中有一个名叫 "customer-dashboard.module.ts",内容如下:

import { NgModule } from '@angular/core';import { CommonModule } from '@angular/common';@NgModule({  imports: [    CommonModule  ],  declarations: []})export class CustomerDashboardModule { }

无论根模块还是特性模块,其 NgModule 结构都是一样的。在 CLI 生成的特性模块中,在文件顶部有两个 JavaScript 的导入语句:第一个导入了 NgModule,它像根模块中一样让你能使用 @NgModule 装饰器;第二个导入了 CommonModule,它提供了很多像 ngIfngFor 这样的常用指令。 特性模块导入 CommonModule,而不是 BrowserModule,后者只应该在根模块中导入一次。 CommonModule 只包含常用指令的信息,比如 ngIfngFor,它们在大多数模板中都要用到,而 BrowserModule 为浏览器所做的应用配置只会使用一次。

declarations 数组让你能添加专属于这个模块的可声明对象(组件、指令和管道)。 要添加组件,就在命令行中输入如下命令,这里的 "customer-dashboard" 是一个目录,CLI 会把特性模块生成在这里,而 CustomerDashboard 就是该组件的名字:

ng generate component customer-dashboard/CustomerDashboard

这会在 customer-dashboard 中为新组件生成一个目录,并使用 CustomerDashboardComponent 的信息修改这个特性模块:

Path:"src/app/customer-dashboard/customer-dashboard.module.ts"。

// import the new componentimport { CustomerDashboardComponent } from './customer-dashboard/customer-dashboard.component';@NgModule({  imports: [    CommonModule  ],  declarations: [    CustomerDashboardComponent  ],})

CustomerDashboardComponent 出现在了顶部的 JavaScript 导入列表里,并且被添加到了 declarations 数组中,它会让 Angular 把新组件和这个特性模块联系起来。

导入特性模块

要想把这个特性模块包含进应用中,你还得让根模块 "app.module.ts" 知道它。注意,在 "customer-dashboard.module.ts" 文件底部 CustomerDashboardModule 的导出部分。这样就把它暴露出来,以便其它模块可以拿到它。要想把它导入到 AppModule 中,就把它加入 "app.module.ts" 的导入表中,并将其加入 imports 数组:

Path:"src/app/app.module.ts"。

import { HttpClientModule } from '@angular/common/http';import { NgModule } from '@angular/core';import { FormsModule } from '@angular/forms';import { BrowserModule } from '@angular/platform-browser';import { AppComponent } from './app.component';// import the feature module here so you can add it to the imports array belowimport { CustomerDashboardModule } from './customer-dashboard/customer-dashboard.module';@NgModule({  declarations: [    AppComponent  ],  imports: [    BrowserModule,    FormsModule,    HttpClientModule,    CustomerDashboardModule // add the feature module here  ],  providers: [],  bootstrap: [AppComponent]})export class AppModule { }

现在 AppModule 知道这个特性模块了。如果你往该特性模块中加入过任何服务提供者,AppModule 也同样会知道它,其它模块中也一样。不过,NgModule 并不会暴露出它们的组件。

渲染特性模块的组件模板

当 CLI 为这个特性模块生成 CustomerDashboardComponent 时,还包含一个模板 "customer-dashboard.component.html",它带有如下页面脚本:

Path:"src/app/customer-dashboard/customer-dashboard/customer-dashboard.component.html"。

<p>  customer-dashboard works!</p>

要想在 AppComponent 中查看这些 HTML,你首先要在 CustomerDashboardModule 中导出 CustomerDashboardComponent。 在 "customer-dashboard.module.ts" 中,declarations 数组的紧下方,加入一个包含 CustomerDashboardModuleexports 数组:

Path:"src/app/customer-dashboard/customer-dashboard.module.ts"。

exports: [  CustomerDashboardComponent]

然后,在 AppComponent 的 "app.component.html" 中,加入标签 <app-customer-dashboard>

Path:"src/app/app.component.html"。

<h1>  {{title}}</h1><!-- add the selector from the CustomerDashboardComponent --><app-customer-dashboard></app-customer-dashboard>

现在,除了默认渲染出的标题外,还渲染出了 CustomerDashboardComponent 的模板:

提供者就是一本说明书,用来指导依赖注入系统该如何获取某个依赖的值。 大多数情况下,这些依赖就是你要创建和提供的那些服务。

提供服务

如果你是用 Angular CLI 创建的应用,那么可以使用下列 CLI 的 ng generate 命令在项目根目录下创建一个服务。把其中的 User 替换成你的服务名。

ng generate service User

该命令会创建下列 UserService 骨架:

Path:"src/app/user.service.ts" 。

import { Injectable } from '@angular/core';@Injectable({  providedIn: 'root',})export class UserService {}

现在,你就可以在应用中到处注入 UserService 了。

该服务本身是 CLI 创建的一个类,并且加上了 @Injectable() 装饰器。默认情况下,该装饰器是用 providedIn 属性进行配置的,它会为该服务创建一个提供者。在这个例子中,providedIn: 'root' 指定 Angular 应该在根注入器中提供该服务。

提供者的作用域

当你把服务提供者添加到应用的根注入器中时,它就在整个应用程序中可用了。 另外,这些服务提供者也同样对整个应用中的类是可用的 —— 只要它们有供查找用的服务令牌。

你应该始终在根注入器中提供这些服务 —— 除非你希望该服务只有在消费方要导入特定的 @NgModule 时才生效。

providedIn 与 NgModule

也可以规定某个服务只有在特定的 @NgModule 中提供。比如,如果你希望只有当消费方导入了你创建的 UserModule 时才让 UserService 在应用中生效,那就可以指定该服务要在该模块中提供:

Path:"src/app/user.service.ts" 。

import { Injectable } from '@angular/core';import { UserModule } from './user.module';@Injectable({  providedIn: UserModule,})export class UserService {}

上面的例子展示的就是在模块中提供服务的首选方式。之所以推荐该方式,是因为当没有人注入它时,该服务就可以被摇树优化掉。如果没办法指定哪个模块该提供这个服务,你也可以在那个模块中为该服务声明一个提供者:

Path:"src/app/user.module.ts" 。

import { NgModule } from '@angular/core';import { UserService } from './user.service';@NgModule({  providers: [UserService],})export class UserModule {}

使用惰性加载模块限制提供者的作用域

在 CLI 生成的基本应用中,模块是急性加载的,这意味着它们都是由本应用启动的,Angular 会使用一个依赖注入体系来让一切服务都在模块间有效。对于急性加载式应用,应用中的根注入器会让所有服务提供者都对整个应用有效。

当使用惰性加载时,这种行为需要进行改变。惰性加载就是只有当需要时才加载模块,比如路由中。它们没办法像急性加载模块那样进行加载。这意味着,在它们的 providers 数组中列出的服务都是不可用的,因为根注入器并不知道这些模块。

当 Angular 的路由器惰性加载一个模块时,它会创建一个新的注入器。这个注入器是应用的根注入器的一个子注入器。想象一棵注入器树,它有唯一的根注入器,而每一个惰性加载模块都有一个自己的子注入器。路由器会把根注入器中的所有提供者添加到子注入器中。如果路由器在惰性加载时创建组件,Angular 会更倾向于使用从这些提供者中创建的服务实例,而不是来自应用的根注入器的服务实例。

任何在惰性加载模块的上下文中创建的组件(比如路由导航),都会获取该服务的局部实例,而不是应用的根注入器中的实例。而外部模块中的组件,仍然会收到来自于应用的根注入器创建的实例。

虽然你可以使用惰性加载模块来提供实例,但不是所有的服务都能惰性加载。比如,像路由之类的模块只能在根模块中使用。路由器需要使用浏览器中的全局对象 location 进行工作。

使用组件限定服务提供者的作用域

另一种限定提供者作用域的方式是把要限定的服务添加到组件的 providers 数组中。组件中的提供者和 NgModule 中的提供者是彼此独立的。 当你要急性加载一个自带了全部所需服务的模块时,这种方式是有帮助的。 在组件中提供服务,会限定该服务只能在该组件及其子组件中有效,而同一模块中的其它组件不能访问它。

Path:"src/app/app.component.ts" 。

@Component({/* . . . */  providers: [UserService]})

在模块中提供服务还是在组件中

通常,要在根模块中提供整个应用都需要的服务,在惰性加载模块中提供限定范围的服务。

路由器工作在根级,所以如果你把服务提供者放进组件(即使是 AppComponent)中,那些依赖于路由器的惰性加载模块,将无法看到它们。

当你必须把一个服务实例的作用域限定到组件及其组件树中时,可以使用组件注册一个服务提供者。 比如,用户编辑组件 UserEditorComponent,它需要一个缓存 UserService 实例,那就应该把 UserService 注册进 UserEditorComponent 中。 然后,每个 UserEditorComponent 的实例都会获取它自己的缓存服务实例。

单例服务是指在应用中只存在一个实例的服务。

提供单例服务

在 Angular 中有两种方式来生成单例服务:

  • @Injectable() 中的 providedIn 属性设置为 "root"

  • 把该服务包含在 AppModule 或某个只会被 AppModule 导入的模块中。

使用 providedIn

从 Angular 6.0 开始,创建单例服务的首选方式就是在那个服务类的 @Injectable 装饰器上把 providedIn 设置为 root。这会告诉 Angular 在应用的根上提供此服务。

Path:"src/app/user.service.ts" 。

import { Injectable } from '@angular/core';@Injectable({  providedIn: 'root',})export class UserService {}

NgModule 的 providers 数组

在基于 Angular 6.0 以前的版本构建的应用中,服务是注册在 NgModuleproviders 数组中的,就像这样:

@NgModule({  ...  providers: [UserService],  ...})

如果这个 NgModule 是根模块 AppModule,此 UserService 就会是单例的,并且在整个应用中都可用。虽然你可能会看到这种形式的代码,但是最好使用在服务自身的 @Injectable() 装饰器上设置 providedIn 属性的形式,因为 Angular 6.0 可以对这些服务进行摇树优化。

forRoot() 模式

通常,你只需要用 providedIn 提供服务,用 forRoot()/forChild() 提供路由即可。 不过,理解 forRoot() 为何能够确保服务只有单个实例,可以让你学会更深层次的开发知识。

如果模块同时定义了 providers(服务)和 declarations(组件、指令、管道),那么,当你同时在多个特性模块中加载此模块时,这些服务就会被注册在多个地方。这会导致出现多个服务实例,并且该服务的行为不再像单例一样。

有多种方式来防止这种现象:

  • providedIn 语法代替在模块中注册服务的方式。

  • 把你的服务分离到它们自己的模块中。

  • 在模块中分别定义 forRoot()forChild() 方法。

使用 forRoot() 来把提供者从该模块中分离出去,这样你就能在根模块中导入该模块时带上 providers,并且在子模块中导入它时不带 providers

  1. 在该模块中创建一个静态方法 forRoot()

  1. 把这些提供者放进 forRoot() 方法中。

Path:"src/app/greeting/greeting.module.ts" 。

static forRoot(config: UserServiceConfig): ModuleWithProviders<GreetingModule> {  return {    ngModule: GreetingModule,    providers: [      {provide: UserServiceConfig, useValue: config }    ]  };}

forRoot() 和 Router

RouterModule 中提供了 Router 服务,同时还有一些路由指令,比如 RouterOutletrouterLink 等。应用的根模块导入了 RouterModule,以便应用中有一个 Router 服务,并且让应用的根组件可以访问各个路由器指令。任何一个特性模块也必须导入 RouterModule,这样它们的组件模板中才能使用这些路由器指令。

如果 RouterModule 没有 forRoot(),那么每个特性模块都会实例化一个新的 Router 实例,而这会破坏应用的正常逻辑,因为应用中只能有一个 Router 实例。通过使用 forRoot() 方法,应用的根模块中会导入 RouterModule.forRoot(...),从而获得一个 Router 实例,而所有的特性模块要导入 RouterModule.forChild(...),它就不会实例化另外的 Router

注:

  • 如果你的某个模块也同时有 providersdeclarations,你也可以使用这种技巧来把它们分开。你可能会在某些传统应用中看到这种模式。 不过,从 Angular 6.0 开始,提供服务的最佳实践是使用 @Injectable()providedIn 属性。

forRoot() 的工作原理

forRoot() 会接受一个服务配置对象,并返回一个 ModuleWithProviders 对象,它带有下列属性:

  • ngModule:在这个例子中,就是 GreetingModule 类。

  • providers - 配置好的服务提供者。

根模块 AppModule 导入了 GreetingModule,并把它的 providers 添加到了 AppModule 的服务提供者列表中。特别是,Angular 会把所有从其它模块导入的提供者追加到本模块的 @NgModule.providers 中列出的提供者之前。这种顺序可以确保你在 AppModuleproviders 中显式列出的提供者,其优先级高于导入模块中给出的提供者。

在这个范例应用中,导入 GreetingModule,并只在 AppModule 中调用一次它的 forRoot() 方法。像这样注册它一次就可以防止出现多个实例。

你还可以在 GreetingModule 中添加一个用于配置 UserServiceforRoot() 方法。

在下面的例子中,可选的注入 UserServiceConfig 扩展了 UserService。如果 UserServiceConfig 存在,就从这个配置中设置用户名。

Path:"src/app/greeting/user.service.ts (constructor)" 。

constructor(@Optional() config?: UserServiceConfig) {  if (config) { this._userName = config.userName; }}

下面是一个接受 UserServiceConfig 参数的 forRoot() 方法:

Path:"src/app/greeting/greeting.module.ts (forRoot)" 。

static forRoot(config: UserServiceConfig): ModuleWithProviders<GreetingModule> {  return {    ngModule: GreetingModule,    providers: [      {provide: UserServiceConfig, useValue: config }    ]  };}

最后,在 AppModuleimports 列表中调用它。在下面的代码片段中,省略了文件的另一部分。

Path:"src/app/app.module.ts (imports)" 。

import { GreetingModule } from './greeting/greeting.module';@NgModule({  imports: [    GreetingModule.forRoot({userName: 'Miss Marple'}),  ],})

该应用不再显示默认的 “Sherlock Holmes”,而是用 “Miss Marple” 作为用户名称。

注:

  • 在本文件的顶部要以 JavaScript import 形式导入 GreetingModule,并且不要把它多次加入到本 @NgModuleimports 列表中。

防止重复导入 GreetingModule

只有根模块 AppModule 才能导入 GreetingModule。如果一个惰性加载模块也导入了它, 该应用就会为服务生成多个实例。

要想防止惰性加载模块重复导入 GreetingModule,可以添加如下的 GreetingModule 构造函数。

Path:"src/app/greeting/greeting.module.ts" 。

constructor (@Optional() @SkipSelf() parentModule?: GreetingModule) {  if (parentModule) {    throw new Error(      'GreetingModule is already loaded. Import it in the AppModule only');  }}

该构造函数要求 Angular 把 GreetingModule 注入它自己。 如果 Angular 在当前注入器中查找 GreetingModule,这次注入就会导致死循环,但是 @SkipSelf() 装饰器的意思是 "在注入器树中层次高于我的祖先注入器中查找 GreetingModule。"

如果该构造函数如预期般执行在 AppModule 中,那就不会有任何祖先注入器可以提供 CoreModule 的实例,所以该注入器就会放弃注入。

默认情况下,当注入器找不到想找的提供者时,会抛出一个错误。 但 @Optional() 装饰器表示找不到该服务也无所谓。 于是注入器会返回 nullparentModule 参数也就被赋成了空值,而构造函数没有任何异常。

但如果你把 GreetingModule 导入到像 CustomerModule 这样的惰性加载模块中,事情就不一样了。

Angular 创建惰性加载模块时会给它一个自己的注入器,它是根注入器的子注入器。 @SkipSelf() 让 Angular 在其父注入器中查找 GreetingModule,这次,它的父注入器是根注入器(而上次的父注入器是空)。 当然,这次它找到了由根模块 AppModule 导入的实例。 该构造函数检测到存在 parentModule,于是抛出一个错误。

以下这两个文件仅供参考:

  1. Path:"src/app/app.module.ts" 。

    import { BrowserModule } from '@angular/platform-browser';    import { NgModule } from '@angular/core';    /* App Root */    import { AppComponent } from './app.component';    /* Feature Modules */    import { ContactModule } from './contact/contact.module';    import { GreetingModule } from './greeting/greeting.module';    /* Routing Module */    import { AppRoutingModule } from './app-routing.module';    @NgModule({      imports: [        BrowserModule,        ContactModule,        GreetingModule.forRoot({userName: 'Miss Marple'}),        AppRoutingModule      ],      declarations: [        AppComponent      ],      bootstrap: [AppComponent]    })    export class AppModule { }

  1. Path:"src/app/greeting.module.ts" 。

import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core';import { CommonModule } from '@angular/common';import { GreetingComponent } from './greeting.component';import { UserServiceConfig } from './user.service';@NgModule({  imports:      [ CommonModule ],  declarations: [ GreetingComponent ],  exports:      [ GreetingComponent ]})export class GreetingModule {  constructor (@Optional() @SkipSelf() parentModule?: GreetingModule) {    if (parentModule) {      throw new Error(        'GreetingModule is already loaded. Import it in the AppModule only');    }  }  static forRoot(config: UserServiceConfig): ModuleWithProviders<GreetingModule> {    return {      ngModule: GreetingModule,      providers: [        {provide: UserServiceConfig, useValue: config }      ]    };  }}

默认情况下,NgModule 都是急性加载的,也就是说它会在应用加载时尽快加载,所有模块都是如此,无论是否立即要用。对于带有很多路由的大型应用,考虑使用惰性加载 —— 一种按需加载 NgModule 的模式。惰性加载可以减小初始包的尺寸,从而减少加载时间。

惰性加载入门

本节会介绍配置惰性加载路由的基本过程。 想要一个分步的范例,参见本页的分步设置部分。

要惰性加载 Angular 模块,请在 AppRoutingModule routes 中使用 loadchildren 代替 component 进行配置,代码如下。

//AppRoutingModule (excerpt)const routes: Routes = [  {    path: 'items',    loadChildren: () => import('./items/items.module').then(m => m.ItemsModule)  }];

在惰性加载模块的路由模块中,添加一个指向该组件的路由。

//Routing module for lazy loaded module (excerpt)const routes: Routes = [  {    path: '',    component: ItemsComponent  }];

还要确保从 AppModule 中移除了 ItemsModule

分步设置

建立惰性加载的特性模块有两个主要步骤:

  1. 使用 --route 标志,用 CLI 创建特性模块。

  1. 配置相关路由。

建立应用

如果你还没有应用,可以遵循下面的步骤使用 CLI 创建一个。如果已经有了,可以直接跳到 配置路由部分。 输入下列命令,其中的 customer-app 表示你的应用名称:

ng new customer-app --routing

这会创建一个名叫 "customer-app" 的应用,而 --routing 标识生成了一个名叫 "app-routing.module.ts" 的文件,它是你建立惰性加载的特性模块时所必须的。 输入命令 cd customer-app 进入该项目。

注:

  • --routing 选项需要 Angular/CLI 8.1 或更高版本。

创建一个带路由的特性模块

接下来,你将需要一个包含路由的目标组件的特性模块。 要创建它,在终端中输入如下命令,其中 customers 是特性模块的名称。加载 customers 特性模块的路径也是 customers,因为它是通过 --route 选项指定的:

ng generate module customers --route customers --module app.module

这将创建一个 "customers" 文件夹,在其 "customers.module.ts" 文件中定义了新的可惰性加载模块 CustomersModule。该命令会自动在新特性模块中声明 CustomersComponent

因为这个新模块想要惰性加载,所以该命令不会在应用的根模块 "app.module.ts" 中添加对新特性模块的引用。 相反,它将声明的路由 customers 添加到以 --module 选项指定的模块中声明的 routes 数组中。

Path:"src/app/app-routing.module.ts" 。

const routes: Routes = [  {    path: 'customers',    loadChildren: () => import('./customers/customers.module').then(m => m.CustomersModule)  }];

惰性加载语法使用 loadChildren,其后是一个使用浏览器内置的 import('...')语法进行动态导入的函数。 其导入路径是到当前模块的相对路径。

添加另一个特性模块

使用同样的命令创建第二个带路由的惰性加载特性模块及其桩组件。

ng generate module orders --route orders --module app.module

这将创建一个名为 "orders" 的新文件夹,其中包含 OrdersModuleOrdersRoutingModule 以及新的 OrdersComponent 源文件。 使用 --route 选项指定的 orders 路由,用惰性加载语法添加到了 "app-routing.module.ts" 文件内的 routes 数组中。

Path:"src/app/app-routing.module.ts" 。

const routes: Routes = [  {    path: 'customers',    loadChildren: () => import('./customers/customers.module').then(m => m.CustomersModule)  },  {    path: 'orders',    loadChildren: () => import('./orders/orders.module').then(m => m.OrdersModule)  }];

建立 UI

虽然你也可以在地址栏中输入 URL,不过导航 UI 会更好用,也更常见。 把 "app.component.html" 中的占位脚本替换成一个自定义的导航,以便你在浏览器中能轻松地在模块之间导航。

Path:"src/app/app.component.html" 。

<h1>  {{title}}</h1><button routerLink="/customers">Customers</button><button routerLink="/orders">Orders</button><button routerLink="">Home</button><router-outlet></router-outlet>

要想在浏览器中看到你的应用,就在终端窗口中输入下列命令:

ng serve

然后,跳转到 "localhost:4200",这时你应该看到 "customer-app" 和三个按钮。

这些按钮生效了,因为 CLI 会自动将特性模块的路由添加到 "app.module.ts" 中的 routes 数组中。

导入与路由配置

CLI 会将每个特性模块自动添加到应用级的路由映射表中。 通过添加默认路由来最终完成这些步骤。 在 "app-routing.module.ts" 文件中,使用如下命令更新 routes 数组:

Path:"src/app/app-routing.module.ts" 。

const routes: Routes = [  {    path: 'customers',    loadChildren: () => import('./customers/customers.module').then(m => m.CustomersModule)  },  {    path: 'orders',    loadChildren: () => import('./orders/orders.module').then(m => m.OrdersModule)  },  {    path: '',    redirectTo: '',    pathMatch: 'full'  }];

前两个路径是到 CustomersModuleOrdersModule 的路由。 最后一个条目则定义了默认路由。空路径匹配所有不匹配先前路径的内容。

特性模块内部

接下来,仔细看看 "customers.module.ts" 文件。如果你使用的是 CLI,并按照此页面中的步骤进行操作,则无需在此处执行任何操作。

Path:"src/app/customers/customers.module.ts" 。

import { NgModule } from '@angular/core';import { CommonModule } from '@angular/common';import { CustomersRoutingModule } from './customers-routing.module';import { CustomersComponent } from './customers.component';@NgModule({  imports: [    CommonModule,    CustomersRoutingModule  ],  declarations: [CustomersComponent]})export class CustomersModule { }

"customers.module.ts" 文件导入了 "customers-routing.module.ts" 和 "customers.component.ts" 文件。@NgModuleimports 数组中列出了 CustomersRoutingModule,让 CustomersModule 可以访问它自己的路由模块。CustomersComponent 位于 declarations 数组中,这意味着 CustomersComponent 属于 CustomersModule

然后,"app-routing.module.ts" 会使用 JavaScript 的动态导入功能来导入特性模块 "customers.module.ts"。

专属于特性模块的路由定义文件 "customers-routing.module.ts" 将导入在 "customers.component.ts" 文件中定义的自有特性组件,以及其它 JavaScript 导入语句。然后将空路径映射到 CustomersComponent

Path:"src/app/customers/customers-routing.module.ts" 。

import { NgModule } from '@angular/core';import { Routes, RouterModule } from '@angular/router';import { CustomersComponent } from './customers.component';const routes: Routes = [  {    path: '',    component: CustomersComponent  }];@NgModule({  imports: [RouterModule.forChild(routes)],  exports: [RouterModule]})export class CustomersRoutingModule { }

这里的 path 设置为空字符串,因为 AppRoutingModule 中的路径已经设置为 customers,因此,CustomersRoutingModule 中的此路由已经位于 customers 这个上下文中。此路由模块中的每个路由都是其子路由。

另一个特性模块中路由模块的配置也类似。

Path:"src/app/orders/orders-routing.module.ts (excerpt)" 。

import { OrdersComponent } from './orders.component';const routes: Routes = [  {    path: '',    component: OrdersComponent  }];

确认它工作正常

你可以使用 Chrome 开发者工具来确认一下这些模块真的是惰性加载的。 在 Chrome 中,按 Cmd+Option+i(Mac)或 Ctrl+Shift+j(PC),并选中 Network 页标签。

点击 OrdersCustomers 按钮。如果你看到某个 chunk 文件出现了,就表示一切就绪,特性模块被惰性加载成功了。OrdersCustomers 都应该出现一次 chunk,并且它们各自只应该出现一次。

要想再次查看它或测试本项目后面的行为,只要点击 Network 页左上放的 清除 图标即可。

然后,使用 Cmd+r(Mac) 或 Ctrl+r(PC) 重新加载页面。

forRoot() 与 forChild()

你可能已经注意到了,CLI 会把 RouterModule.forRoot(routes) 添加到 AppRoutingModuleimports 数组中。 这会让 Angular 知道 AppRoutingModule 是一个路由模块,而 forRoot() 表示这是一个根路由模块。 它会配置你传入的所有路由、让你能访问路由器指令并注册 RouterforRoot() 在应用中只应该使用一次,也就是这个 AppRoutingModule 中。

CLI 还会把 RouterModule.forChild(routes) 添加到各个特性模块中。这种方式下 Angular 就会知道这个路由列表只负责提供额外的路由并且其设计意图是作为特性模块使用。你可以在多个模块中使用 forChild()

forRoot() 方法为路由器管理全局性的注入器配置。 forChild() 方法中没有注入器配置,只有像 RouterOutletRouterLink 这样的指令。 欲知详情,参见单例服务章的 forRoot() 模式小节。

预加载

预加载通过在后台加载部分应用来改进用户体验。你可以预加载模块或组件数据。

预加载模块

预加载模块通过在后台加载部分应用来改善用户体验,这样用户在激活路由时就无需等待下载这些元素。

要启用所有惰性加载模块的预加载, 请从 Angular 的 router 导入 PreloadAllModules 令牌。

//AppRoutingModule (excerpt)import { PreloadAllModules } from '@angular/router';

还是在 AppRoutingModule 中,通过 forRoot() 指定你的预加载策略。

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

预加载组件数据

要预加载组件数据,你可以使用 resolver 守卫。解析器通过阻止页面加载来改进用户体验,直到显示页面时的全部必要数据都可用。

创建一个解析器服务。通过 CLI,生成服务的命令如下:

ng generate service

在你的服务中,导入下列路由器成员,实现 Resolve 接口,并注入到 Router 服务中:

//Resolver service (excerpt)import { Resolve } from '@angular/router';...export class CrisisDetailResolverService implements Resolve<> {  resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<> {    // your logic goes here  }}

把这个解析器导入此模块的路由模块。

//Feature module's routing module (excerpt)import { YourResolverService }    from './your-resolver.service';

在组件的 route 配置中添加一个 resolve 对象。

//Feature module's routing module (excerpt){  path: '/your-path',  component: YourComponent,  resolve: {    crisis: YourResolverService  }}

在此组件中,使用一个 Observable 来从 ActivatedRoute 获取数据。

//Component (excerpt)ngOnInit() {  this.route.data    .subscribe((your-parameters) => {      // your data-specific code goes here    });}···

创建共享模块能让你更好地组织和梳理代码。你可以把常用的指令、管道和组件放进一个模块中,然后在应用中其它需要这些的地方导入该模块。

想象某个应用有下列模块:

import { CommonModule } from '@angular/common';import { NgModule } from '@angular/core';import { FormsModule } from '@angular/forms';import { CustomerComponent } from './customer.component';import { NewItemDirective } from './new-item.directive';import { OrdersPipe } from './orders.pipe';@NgModule({ imports:      [ CommonModule ], declarations: [ CustomerComponent, NewItemDirective, OrdersPipe ], exports:      [ CustomerComponent, NewItemDirective, OrdersPipe,                 CommonModule, FormsModule ]})export class SharedModule { }

请注意以下几点:

  • 它导入了 CommonModule,因为该模块需要一些常用指令。

  • 它声明并导出了一些工具性的管道、指令和组件类。

-它重新导出了 CommonModuleFormsModule

通过重新导出 CommonModuleFormsModule,任何导入了这个 SharedModule 的其它模块,就都可以访问来自 CommonModuleNgIfNgFor 等指令了,也可以绑定到来自 FormsModule 中的 [(ngModel)] 的属性了。

即使 SharedModule 中声明的组件没有绑定过 [(ngModel)],而且 SharedModule 也不需要导入 FormsModuleSharedModule 仍然可以导出 FormsModule,而不必把它列在 imports 中。 这种方式下,你可以让其它模块也能访问 FormsModule,而不用直接在自己的 @NgModule 装饰器中导入它。

使用来自其它模块的组件和服务

在使用来自其它模块的组件和来自其它模块的服务时,有一个很重要的区别。 当你要使用指令、管道和组件时,导入那些模块就可以了。而导入带有服务的模块意味着你会拥有那个服务的一个新实例,这通常不会是你想要的结果(你通常会想取到现存的服务)。使用模块导入来控制服务的实例化。

获取共享服务的最常见方式是通过 Angular 的依赖注入系统,而不是模块系统(导入模块将导致创建新的服务实例,那不是典型的用法)。

宏观来讲,NgModule 是组织 Angular 应用的一种方式,它们通过 @NgModule 装饰器中的元数据来实现这一点。 这些元数据可以分成三类:

静态的:编译器配置,用于告诉编译器指令的选择器并通过选择器匹配的方式决定要把该指令应用到模板中的什么位置。它是通过 declarations 数组来配置的。

运行时:通过 providers 数组提供给注入器的配置。

组合/分组:通过 importsexports 数组来把多个 NgModule 放在一起,并让它们可用。

@NgModule({  // Static, that is compiler configuration  declarations: [], // Configure the selectors  entryComponents: [], // Generate the host factory  // Runtime, or injector configuration  providers: [], // Runtime injector configuration  // Composability / Grouping  imports: [], // composing NgModules together  exports: [] // making NgModules available to other parts of the app})

@NgModule 元数据

接下来对 @NgModule 元数据中属性的进行汇总:

  1. 属性:declarations

属于该模块的可声明对象(组件、指令和管道)的列表。

  • 当编译模板时,你需要确定一组选择器,它们将用于触发相应的指令。

  • 该模板在 NgModule 环境中编译 —— 模板的组件是在该 NgModule 内部声明的,它会使用如下规则来确定这组选择器:

  • 列在 declarations 中的所有指令选择器。

  • 从所导入的 NgModule 中导出的那些指令的选择器。

  • 组件、指令和管道只能属于一个模块。 如果尝试把同一个类声明在多个模块中,编译器就会报告一个错误。 小心,不要重复声明从其它模块中直接或间接导入的类。

  1. 属性:providers

依赖注入提供者的列表。

  • Angular 会使用该模块的注入器注册这些提供者。 如果该模块是启动模块,那就会使用根注入器。

  • 当需要注入到任何组件、指令、管道或服务时,这些服务对于本注入器的子注入器都是可用的。

  • 惰性加载模块有自己的注入器,它通常是应用的根注入器的子注入器。

  • 惰性加载的服务是局限于这个惰性加载模块的注入器中的。 如果惰性加载模块也提供了 UserService,那么在这个模块的上下文中创建的任何组件(比如在路由器导航时),都会获得这个服务的本模块内实例,而不是来自应用的根注入器的实例。

  • 其它外部模块中的组件也会使用它们自己的注入器提供的服务实例。

  1. 属性:imports

  • 要折叠(Folded)进本模块中的其它模块。折叠的意思是从被导入的模块中导出的那些软件资产同样会被声明在这里。

  • 特别是,这里列出的模块,其导出的组件、指令或管道,当在组件模板中被引用时,和本模块自己声明的那些是等价的。

  • 组件模板可以引用其它组件、指令或管道,不管它们是在本模块中声明的,还是从导入的模块中导出的。 比如,只有当该模块导入了 Angular 的 CommonModule(也可能从BrowserModule中间接导入)时,组件才能使用NgIfNgFor 指令。

  • 你可以从 CommonModule 中导入很多标准指令,不过也有些常用的指令属于其它模块。 比如,你只有导入了 Angular 的 FormsModule 时才能使用 [(ngModel)]

  1. 属性:exports

可供导入了自己的模块使用的可声明对象(组件、指令、管道类)的列表。

  • 导出的可声明对象就是本模块的公共 API。 只有当其它模块导入了本模块,并且本模块导出了 UserComponent 时,其它模块中的组件才能使用本模块中的 UserComponent

  • 默认情况下这些可声明对象都是私有的。 如果本模块没有导出 UserComponent,那么就只有本模块中的组件才能使用 UserComponent

  • 导入某个模块并不会自动重新导出被导入模块的那些导入。 模块 B 不会因为它导入了模块 A,而模块 A 导入了 CommonModule 而能够使用 ngIf。 模块 B 必须自己导入 CommonModule

  • 一个模块可以把另一个模块加入自己的 exports 列表中,这时,另一个模块的所有公共组件、指令和管道都会被导出。

  • 重新导出可以让模块被显式传递。 如果模块 A 重新导出了 CommonModule,而模块 B 导入了模块 A,那么模块 B 就可以使用 ngIf 了 —— 即使它自己没有导入 CommonModule

  1. 属性:bootstrap

要自动启动的组件列表。

  • 通常,在这个列表中只有一个组件,也就是应用的根组件。

  • Angular 也可以用多个引导组件进行启动,它们每一个在宿主页面中都有自己的位置。

  • 启动组件会自动添加到 entryComponents 中。

  1. 属性:entryComponents

那些可以动态加载进视图的组件列表。

  • 默认情况下,Angular 应用至少有一个入口组件,也就是根组件 AppComponent。 它用作进入该应用的入口点,也就是说你通过引导它来启动本应用。

  • 路由组件也是入口组件,因为你需要动态加载它们。 路由器创建它们,并把它们扔到 DOM 中的 <router-outlet> 附近。

  • 虽然引导组件和路由组件都是入口组件,不过你不用自己把它们加到模块的 entryComponents 列表中,因为它们会被隐式添加进去。

  • Angular 会自动把模块的 bootstrap 中的组件和路由定义中的组件添加到 entryComponents 列表。

  • 而那些使用不易察觉的ViewComponentRef.createComponent()的方式进行命令式引导的组件仍然需要添加。

  • 动态组件加载在除路由器之外的大多数应用中都不太常见。如果你需要动态加载组件,就必须自己把那些组件添加到 entryComponents 列表中。

NgModules 可以帮你把应用组织成一些紧密相关的代码块。

这里回答的是开发者常问起的关于 NgModule设计与实现问题

我应该把哪些类加到 declarations 中?

把可声明的类(组件、指令和管道)添加到 declarations 列表中。

这些类只能在应用程序的一个并且只有一个模块中声明。 只有当它们从属于某个模块时,才能把在此模块中声明它们。

什么是可声明的?

声明的就是组件、指令和管道这些可以被加到模块的 declarations 列表中的类。它们也是所有能被加到 declarations 中的类。

哪些类不应该加到 declarations 中?

只有可声明的类才能加到模块的 declarations 列表中。

不要声明:

  • 已经在其它模块中声明过的类。无论它来自应用自己的模块(@NgModule)还是第三方模块。

  • 从其它模块中导入的指令。例如,不要声明来自 @angular/formsFORMS_DIRECTIVES,因为 FormsModule 已经声明过它们了。

  • 模块类。

  • 服务类

  • 非 Angular 的类和对象,比如:字符串、数字、函数、实体模型、配置、业务逻辑和辅助类。

为什么要把同一个组件声明在不同的 NgModule 属性中?

AppComponent 经常被同时列在 declarationsbootstrap 中。 另外你还可能看到 HeroComponent 被同时列在 declarationsexportsentryComponent 中。

这看起来是多余的,不过这些函数具有不同的功能,从它出现在一个列表中无法推断出它也应该在另一个列表中。

  • AppComponent 可能被声明在此模块中,但可能不是引导组件。

  • AppComponent 可能在此模块中引导,但可能是由另一个特性模块声明的。

  • 某个组件可能是从另一个应用模块中导入的(所以你没法声明它)并且被当前模块重新导出。

  • 某个组件可能被导出,以便用在外部组件的模板中,也可能同时被一个弹出式对话框加载。

"Can't bind to 'x' since it isn't a known property of 'y'"是什么意思?

这个错误通常意味着你或者忘了声明指令“x”,或者你没有导入“x”所属的模块。

如果“x”其实不是属性,或者是组件的私有属性(比如它不带 @Input@Output 装饰器),那么你也同样会遇到这个错误。

我应该导入什么?

导入你需要在当前模块的组件模板中使用的那些公开的(被导出的)可声明类。

这意味着要从 @angular/common 中导入 CommonModule 才能访问 Angular 的内置指令,比如 NgIfNgFor。 你可以直接导入它或者从重新导出过该模块的其它模块中导入它。

如果你的组件有 [(ngModel)] 双向绑定表达式,就要从 @angular/forms 中导入 FormsModule

如果当前模块中的组件包含了共享模块和特性模块中的组件、指令和管道,就导入这些模块。

只能在根模块 AppModule 中导入 BrowserModule

我应该导入 BrowserModule 还是 CommonModule?

几乎所有要在浏览器中使用的应用的根模块(AppModule)都应该从 @angular/platform-browser 中导入 BrowserModule

BrowserModule 提供了启动和运行浏览器应用的那些基本的服务提供者。

BrowserModule 还从 @angular/common 中重新导出了 CommonModule,这意味着 AppModule 中的组件也同样可以访问那些每个应用都需要的 Angular 指令,如 NgIfNgFor

在其它任何模块中都不要导入BrowserModule。 特性模块和惰性加载模块应该改成导入 CommonModule。 它们需要通用的指令。它们不需要重新初始化全应用级的提供者。

如果我两次导入同一个模块会怎么样?

没有任何问题。当三个模块全都导入模块'A'时,Angular 只会首次遇到时加载一次模块'A',之后就不会这么做了。

无论 A 出现在所导入模块的哪个层级,都会如此。 如果模块'B'导入模块'A'、模块'C'导入模块'B',模块'D'导入 [C, B, A],那么'D'会触发模块'C'的加载,'C'会触发'B'的加载,而'B'会加载'A'。 当 Angular 在'D'中想要获取'B''A'时,这两个模块已经被缓存过了,可以立即使用。

Angular 不允许模块之间出现循环依赖,所以不要让模块'A'导入模块'B',而模块'B'又导入模块'A'

特性模块中导入 CommonModule 可以让它能用在任何目标平台上,不仅是浏览器。那些跨平台库的作者应该喜欢这种方式的。

我应该导出什么?

导出那些其它模块希望在自己的模板中引用的可声明类。这些也是你的公共类。 如果你不导出某个类,它就是私有的,只对当前模块中声明的其它组件可见。

你可以导出任何可声明类(组件、指令和管道),而不用管它是声明在当前模块中还是某个导入的模块中。

你可以重新导出整个导入过的模块,这将导致重新导出它们导出的所有类。重新导出的模块甚至不用先导入。

我不应该导出什么?

不要导出:

  • 那些你只想在当前模块中声明的那些组件中使用的私有组件、指令和管道。如果你不希望任何模块看到它,就不要导出。

  • 不可声明的对象,比如服务、函数、配置、实体模型等。

  • 那些只被路由器或引导函数动态加载的组件。 比如入口组件可能从来不会在其它组件的模板中出现。 导出它们没有坏处,但也没有好处。

  • 纯服务模块没有公开(导出)的声明。 例如,没必要重新导出 HttpClientModule,因为它不导出任何东西。 它唯一的用途是一起把 http 的那些服务提供者添加到应用中。

我可以重新导出类和模块吗?

毫无疑问!

模块是从其它模块中选取类并把它们重新导出成统一、便利的新模块的最佳方式。

模块可以重新导出其它模块,这会导致重新导出它们导出的所有类。 Angular 自己的 BrowserModule 就重新导出了一组模块,例如:

exports: [CommonModule, ApplicationModule]

模块还能导出一个组合,它可以包含自己的声明、某些导入的类以及导入的模块。

不要费心去导出纯服务类。 纯服务类的模块不会导出任何可供其它模块使用的可声明类。 例如,不用重新导出 HttpClientModule,因为它没有导出任何东西。 它唯一的用途是把那些 http 服务提供者一起添加到应用中。

forRoot()方法是什么?

静态方法 forRoot() 是一个约定,它可以让开发人员更轻松的配置模块的想要单例使用的服务及其提供者。RouterModule.forRoot() 就是一个很好的例子。

应用把一个 Routes 对象传给 RouterModule.forRoot(),为的就是使用路由配置全应用级的 Router 服务。 RouterModule.forRoot() 返回一个ModuleWithProviders对象。 你把这个结果添加到根模块 AppModuleimports 列表中。

只能在应用的根模块 AppModule 中调用并导入 forRoot() 的结果。 在其它模块,特别是惰性加载模块中,不要导入它。 要了解关于 forRoot() 的更多信息,参见单例服务一章的 the forRoot() 模式部分。

对于服务来说,除了可以使用 forRoot() 外,更好的方式是在该服务的 @Injectable() 装饰器中指定 providedIn: 'root',它让该服务自动在全应用级可用,这样它也就默认是单例的。

RouterModule 也提供了静态方法 forChild(),用于配置惰性加载模块的路由。

forRoot()forChild() 都是约定俗成的方法名,它们分别用于在根模块和特性模块中配置服务。

当你写类似的需要可配置的服务提供者时,请遵循这个约定。

为什么服务提供者在特性模块中的任何地方都是可见的?

列在引导模块的 @NgModule.providers 中的服务提供者具有全应用级作用域。 往 NgModule.providers 中添加服务提供者将导致该服务被发布到整个应用中。

当你导入一个模块时,Angular 就会把该模块的服务提供者(也就是它的 providers 列表中的内容)加入该应用的根注入器中。

这会让该提供者对应用中所有知道该提供者令牌(token)的类都可见。

通过 NgModule 导入来实现可扩展性是 NgModule 体系的主要设计目标。 把 NgModule 的提供者并入应用程序的注入器可以让库模块使用新的服务来强化应用程序变得更容易。 只要添加一次 HttpClientModule,那么应用中的每个组件就都可以发起 Http 请求了。

不过,如果你期望模块的服务只对那个特性模块内部声明的组件可见,那么这可能会带来一些不受欢迎的意外。 如果 HeroModule 提供了一个 HeroService,并且根模块 AppModule 导入了 HeroModule,那么任何知道 HeroService类型的类都可能注入该服务,而不仅是在 HeroModule 中声明的那些类。

要限制对某个服务的访问,可以考虑惰性加载提供该服务的 NgModule

为什么在惰性加载模块中声明的服务提供者只对该模块自身可见?

和启动时就加载的模块中的提供者不同,惰性加载模块中的提供者是局限于模块的。

当 Angular 路由器惰性加载一个模块时,它创建了一个新的运行环境。 那个环境拥有自己的注入器,它是应用注入器的直属子级。

路由器把该惰性加载模块的提供者和它导入的模块的提供者添加到这个子注入器中。

这些提供者不会被拥有相同令牌的应用级别提供者的变化所影响。 当路由器在惰性加载环境中创建组件时,Angular 优先使用惰性加载模块中的服务实例,而不是来自应用的根注入器的。

如果两个模块提供了同一个服务会怎么样?

当同时加载了两个导入的模块,它们都列出了使用同一个令牌的提供者时,后导入的模块会“获胜”,这是因为这两个提供者都被添加到了同一个注入器中。

当 Angular 尝试根据令牌注入服务时,它使用第二个提供者来创建并交付服务实例。

每个注入了该服务的类获得的都是由第二个提供者创建的实例。 即使是声明在第一个模块中的类,它取得的实例也是来自第二个提供者的。

如果模块 A 提供了一个使用令牌'X'的服务,并且导入的模块 B 也用令牌'X'提供了一个服务,那么模块 A 中定义的服务“获胜”了。

由根 AppModule 提供的服务相对于所导入模块中提供的服务有优先权。换句话说:AppModule 总会获胜。

我应该如何把服务的范围限制到模块中?

如果一个模块在应用程序启动时就加载,它的 @NgModule.providers 具有全应用级作用域。 它们也可用于整个应用的注入中。

导入的提供者很容易被由其它导入模块中的提供者替换掉。 这虽然是故意这样设计的,但是也可能引起意料之外的结果。

作为一个通用的规则,应该只导入一次带提供者的模块,最好在应用的根模块中。 那里也是配置、包装和改写这些服务的最佳位置。

假设模块需要一个定制过的 HttpBackend,它为所有的 Http 请求添加一个特别的请求头。 如果应用中其它地方的另一个模块也定制了 HttpBackend 或仅仅导入了 HttpClientModule,它就会改写当前模块的 HttpBackend 提供者,丢掉了这个特别的请求头。 这样服务器就会拒绝来自该模块的请求。

要消除这个问题,就只能在应用的根模块 AppModule 中导入 HttpClientModule

如果你必须防范这种“提供者腐化”现象,那就不要依赖于“启动时加载”模块的 providers

只要可能,就让模块惰性加载。 Angular 给了惰性加载模块自己的子注入器。 该模块中的提供者只对由该注入器创建的组件树可见。

如果你必须在应用程序启动时主动加载该模块,就改成在组件中提供该服务。

继续看这个例子,假设某个模块的组件真的需要一个私有的、自定义的 HttpBackend

那就创建一个“顶层组件”来扮演该模块中所有组件的根。 把这个自定义的 HttpBackend 提供者添加到这个顶层组件的 providers 列表中,而不是该模块的 providers 中。 回忆一下,Angular 会为每个组件实例创建一个子注入器,并使用组件自己的 providers 来配置这个注入器。

当该组件的子组件想要一个 HttpBackend 服务时,Angular 会提供一个局部的 HttpBackend 服务,而不是应用的根注入器创建的那个。 子组件将正确发起 http 请求,而不管其它模块对 HttpBackend 做了什么。

确保把模块中的组件都创建成这个顶层组件的子组件。

你可以把这些子组件都嵌在顶层组件的模板中。或者,给顶层组件一个 <router-outlet>,让它作为路由的宿主。 定义子路由,并让路由器把模块中的组件加载进该路由出口(outlet)中。

虽然通过在惰性加载模块中或组件中提供某个服务来限制它的访问都是可行的方式,但在组件中提供服务可能导致这些服务出现多个实例。因此,应该优先使用惰性加载的方式。

我应该把全应用级提供者添加到根模块 AppModule 中还是根组件 AppComponent 中?

通过在服务的 @Injectable() 装饰器中(例如服务)指定 providedIn: 'root' 来定义全应用级提供者,或者 InjectionToken 的构造器(例如提供令牌的地方),都可以定义全应用级提供者。 通过这种方式创建的服务提供者会自动在整个应用中可用,而不用把它列在任何模块中。

如果某个提供者不能用这种方式配置(可能因为它没有有意义的默认值),那就在根模块 AppModule 中注册这些全应用级服务,而不是在 AppComponent 中。

惰性加载模块及其组件可以注入 AppModule 中的服务,却不能注入 AppComponent 中的。

只有当该服务必须对 AppComponent 组件树之外的组件不可见时,才应该把服务注册进 AppComponentproviders 中。 这是一个非常罕见的异常用法。

更一般地说,优先把提供者注册进模块中,而不是组件中。

讨论

Angular 把所有启动期模块的提供者都注册进了应用的根注入器中。 这些服务是由根注入器中的提供者创建的,并且在整个应用中都可用。 它们具有应用级作用域。

某些服务(比如 Router)只有当注册进应用的根注入器时才能正常工作。

相反,Angular 使用 AppComponent 自己的注入器注册了 AppComponent 的提供者。 AppComponent 服务只在该组件及其子组件树中才能使用。 它们具有组件级作用域。

AppComponent 的注入器是根注入器的子级,注入器层次中的下一级。 这对于没有路由器的应用来说几乎是整个应用了。 但对那些带路由的应用,路由操作位于顶层,那里不存在 AppComponent 服务。这意味着惰性加载模块不能使用它们。

我应该把其它提供者注册到模块中还是组件中?

提供者应该使用 @Injectable 语法进行配置。只要可能,就应该把它们在应用的根注入器中提供(providedIn: 'root')。 如果它们只被惰性加载的上下文中使用,那么这种方式配置的服务就是惰性加载的。

如果要由消费方来决定是否把它作为全应用级提供者,那么就要在模块中(@NgModule.providers)注册提供者,而不是组件中(@Component.providers)。

当你必须把服务实例的范围限制到某个组件及其子组件树时,就把提供者注册到该组件中。 指令的提供者也同样照此处理。

例如,如果英雄编辑组件需要自己私有的缓存英雄服务实例,那就应该把 HeroService 注册进 HeroEditorComponent 中。 这样,每个新的 HeroEditorComponent 的实例都会得到一份自己的缓存服务实例。 编辑器的改动只会作用于它自己的服务,而不会影响到应用中其它地方的英雄实例。

总是在根模块 AppModule 中注册全应用级服务,而不要在根组件 AppComponent 中。

为什么在共享模块中为惰性加载模块提供服务是个馊主意?

急性加载的场景

当急性加载的模块提供了服务时,比如 UserService,该服务是在全应用级可用的。如果根模块提供了 UserService,并导入了另一个也提供了同一个 UserService 的模块,Angular 就会把它们中的一个注册进应用的根注入器中(参见如果两次导入了同一个模块会怎样?)。

然后,当某些组件注入 UserService 时,Angular 就会发现它已经在应用的根注入器中了,并交付这个全应用级的单例服务。这样不会出现问题。

惰性加载场景

现在,考虑一个惰性加载的模块,它也提供了一个名叫 UserService 的服务。

当路由器准备惰性加载 HeroModule 的时候,它会创建一个子注入器,并且把 UserService 的提供者注册到那个子注入器中。子注入器和根注入器是不同的。

当 Angular 创建一个惰性加载的 HeroComponent 时,它必须注入一个 UserService。 这次,它会从惰性加载模块的子注入器中查找 UserService 的提供者,并用它创建一个 UserService 的新实例。 这个 UserService 实例与 Angular 在主动加载的组件中注入的那个全应用级单例对象截然不同。

这个场景导致你的应用每次都创建一个新的服务实例,而不是使用单例的服务。

为什么惰性加载模块会创建一个子注入器?

Angular 会把 @NgModule.providers 中的提供者添加到应用的根注入器中…… 除非该模块是惰性加载的,这种情况下,Angular 会创建一子注入器,并且把该模块的提供者添加到这个子注入器中。

这意味着模块的行为将取决于它是在应用启动期间加载的还是后来惰性加载的。如果疏忽了这一点,可能导致严重后果。

为什么 Angular 不能像主动加载模块那样把惰性加载模块的提供者也添加到应用程序的根注入器中呢?为什么会出现这种不一致?

归根结底,这来自于 Angular 依赖注入系统的一个基本特征: 在注入器还没有被第一次使用之前,可以不断为其添加提供者。 一旦注入器已经创建和开始交付服务,它的提供者列表就被冻结了,不再接受新的提供者。

当应用启动时,Angular 会首先使用所有主动加载模块中的提供者来配置根注入器,这发生在它创建第一个组件以及注入任何服务之前。 一旦应用开始工作,应用的根注入器就不再接受新的提供者了。

之后,应用逻辑开始惰性加载某个模块。 Angular 必须把这个惰性加载模块中的提供者添加到某个注入器中。 但是它无法将它们添加到应用的根注入器中,因为根注入器已经不再接受新的提供者了。 于是,Angular 在惰性加载模块的上下文中创建了一个新的子注入器。

我要如何知道一个模块或服务是否已经加载过了?

某些模块及其服务只能被根模块 AppModule 加载一次。 在惰性加载模块中再次导入这个模块会导致错误的行为,这个错误可能非常难于检测和诊断。

为了防范这种风险,可以写一个构造函数,它会尝试从应用的根注入器中注入该模块或服务。如果这种注入成功了,那就说明这个类是被第二次加载的,你就可以抛出一个错误,或者采取其它挽救措施。

某些 NgModule(例如 BrowserModule)就实现了那样一个守卫。 下面是一个名叫 GreetingModuleNgModule 的 自定义构造函数。

Path:"src/app/greeting/greeting.module.ts (Constructor)" 。

constructor (@Optional() @SkipSelf() parentModule?: GreetingModule) {  if (parentModule) {    throw new Error(      'GreetingModule is already loaded. Import it in the AppModule only');  }}

什么是入口组件?

Angular 根据组件类型命令式加载的组件是入口组件.

而通过组件选择器声明式加载的组件则不是入口组件。

Angular 会声明式的加载组件,它使用组件的选择器在模板中定位元素。 然后,Angular 会创建该组件的 HTML 表示,并把它插入 DOM 中所选元素的内部。它们不是入口组件。

而用于引导的根 AppComponent 则是一个入口组件。 虽然它的选择器匹配了 "index.html" 中的一个元素,但是 "index.html" 并不是组件模板,而且 AppComponent 选择器也不会在任何组件模板中出现。

在路由定义中用到的组件也同样是入口组件。 路由定义根据类型来引用组件。 路由器会忽略路由组件的选择器(即使它有选择器),并且把该组件动态加载到 RouterOutlet 中。

引导组件和入口组件有什么不同?

引导组件是入口组件的一种。 它是被 Angular 的引导(应用启动)过程加载到 DOM 中的入口组件。 其它入口组件则是被其它方式动态加载的,比如被路由器加载。

@NgModule.bootstrap 属性告诉编译器这是一个入口组件,同时它应该生成一些代码来用该组件引导此应用。

不需要把组件同时列在 bootstrapentryComponent 列表中 —— 虽然这样做也没坏处。

什么时候我应该把组件加到 entryComponents 中?

大多数应用开发者都不需要把组件添加到 entryComponents 中。

Angular 会自动把恰当的组件添加到入口组件中。 列在 @NgModule.bootstrap 中的组件会自动加入。 由路由配置引用到的组件会被自动加入。 用这两种机制添加的组件在入口组件中占了绝大多数。

如果你的应用要用其它手段来根据类型引导或动态加载组件,那就得把它显式添加到 entryComponents 中。

虽然把组件加到这个列表中也没什么坏处,不过最好还是只添加真正的入口组件。 不要添加那些被其它组件的模板引用过的组件。

为什么 Angular 需要入口组件?

原因在于摇树优化。对于产品化应用,你会希望加载尽可能小而快的代码。 代码中应该仅仅包括那些实际用到的类。 它应该排除那些从未用过的组件,无论该组件是否被声明过。

事实上,大多数库中声明和导出的组件你都用不到。 如果你从未引用它们,那么摇树优化器就会从最终的代码包中把这些组件砍掉。

如果Angular 编译器为每个声明的组件都生成了代码,那么摇树优化器的作用就没有了。

所以,编译器转而采用一种递归策略,它只为你用到的那些组件生成代码。

编译器从入口组件开始工作,为它在入口组件的模板中找到的那些组件生成代码,然后又为在这些组件中的模板中发现的组件生成代码,以此类推。 当这个过程结束时,它就已经为每个入口组件以及从入口组件可以抵达的每个组件生成了代码。

如果该组件不是入口组件或者没有在任何模板中发现过,编译器就会忽略它。

有哪些类型的模块?我应该如何使用它们?

每个应用都不一样。根据不同程度的经验,开发者会做出不同的选择。下列建议和指导原则广受欢迎。

SharedModule

为那些可能会在应用中到处使用的组件、指令和管道创建 SharedModule。 这种模块应该只包含 declarations,并且应该导出几乎所有 declarations 里面的声明。

SharedModule 可以重新导出其它小部件模块,比如 CommonModuleFormsModule 和提供你广泛使用的 UI 控件的那些模块。

SharedModule不应该带有 providers,原因在前面解释过了。 它的导入或重新导出的模块中也不应该有 providers。 如果你要违背这条指导原则,请务必想清楚你在做什么,并要有充分的理由。

在任何特性模块中(无论是你在应用启动时主动加载的模块还是之后惰性加载的模块),你都可以随意导入这个 SharedModule

特性模块

特性模块是你围绕特定的应用业务领域创建的模块,比如用户工作流、小工具集等。它们包含指定的特性,并为你的应用提供支持,比如路由、服务、窗口部件等。 要对你的应用中可能会有哪些特性模块有个概念,考虑如果你要把与特定功能(比如搜索)有关的文件放进一个目录下,该目录的内容就可能是一个名叫 SearchModule 的特性模块。 它将会包含构成搜索功能的全部组件、路由和模板。

在 NgModule 和 JavaScript 模块之间有什么不同?

在 Angular 应用中,NgModule 会和 JavaScript 的模块一起工作。

在现代 JavaScript 中,每个文件都是模块(参见模块)。 在每个文件中,你要写一个 export 语句将模块的一部分公开。

Angular 模块是一个带有 @NgModule 装饰器的类,而 JavaScript 模块则没有。 Angular 的 NgModule 有自己的 importsexports 来达到类似的目的。

你可以导入其它 NgModules,以便在当前模块的组件模板中使用它们导出的类。 你可以导出当前 NgModules 中的类,以便其它 NgModules 可以导入它们,并用在自己的组件模板中。

Angular 如何查找模板中的组件、指令和管道?什么是 模板引用 ?

Angular 编译器在组件模板内查找其它组件、指令和管道。一旦找到了,那就是一个“模板引用”。

Angular 编译器通过在一个模板的 HTML 中匹配组件或指令的选择器(selector),来查找组件或指令。

编译器通过分析模板 HTML 中的管道语法中是否出现了特定的管道名来查找对应的管道。

Angular 只查询两种组件、指令或管道:1)那些在当前模块中声明过的,以及 2)那些被当前模块导入的模块所导出的。

什么是 Angular 编译器?

Angular 编译器会把你所编写的应用代码转换成高性能的 JavaScript 代码。 在编译过程中,@NgModule 的元数据扮演了很重要的角色。

你写的代码是无法直接执行的。 比如组件。 组件有一个模板,其中包含了自定义元素、属性型指令、Angular 绑定声明和一些显然不属于原生 HTML 的古怪语法。

Angular 编译器读取模板的 HTML,把它和相应的组件类代码组合在一起,并产出组件工厂。

组件工厂为组件创建纯粹的、100% JavaScript 的表示形式,它包含了 @Component 元数据中描述的一切:HTML、绑定指令、附属的样式等……

由于指令和管道都出现在组件模板中,*Angular 编译器**也同样会把它们组合进编译后的组件代码中。

@NgModule 元数据告诉 Angular 编译器要为当前模块编译哪些组件,以及如何把当前模块和其它模块链接起来。

依赖注入(DI)是一种重要的应用设计模式。 Angular 有自己的 DI 框架,在设计应用时常会用到它,以提升它们的开发效率和模块化程度。

依赖,是当类需要执行其功能时,所需要的服务或对象。 DI 是一种编码模式,其中的类会从外部源中请求获取依赖,而不是自己创建它们。

在 Angular 中,DI 框架会在实例化该类时向其提供这个类所声明的依赖项。本指南介绍了 DI 在 Angular 中的工作原理,以及如何借助它来让你的应用更灵活、高效、健壮,以及可测试、可维护。

我们先看一下英雄指南中英雄管理特性的简化版。这个简化版不使用 DI,我们将逐步把它转换成使用 DI 的。

  1. Path:"src/app/heroes/heroes.component.ts" 。

    import { Component } from '@angular/core';    @Component({      selector: 'app-heroes',      template: `        <h2>Heroes</h2>        <app-hero-list></app-hero-list>      `    })    export class HeroesComponent { }

  1. Path:"src/app/heroes/heroes.component.ts" 。

    import { Component }   from '@angular/core';    import { HEROES }      from './mock-heroes';    @Component({      selector: 'app-hero-list',      template: `        <div *ngFor="let hero of heroes">          {{hero.id}} - {{hero.name}}        </div>      `    })    export class HeroListComponent {      heroes = HEROES;    }

  1. Path:"src/app/heroes/hero.ts" 。

    export interface Hero {      id: number;      name: string;      isSecret: boolean;    }

  1. Path:"src/app/heroes/mock-heroes.ts" 。

    import { Hero } from './hero';    export const HEROES: Hero[] = [      { id: 11, isSecret: false, name: 'Dr Nice' },      { id: 12, isSecret: false, name: 'Narco' },      { id: 13, isSecret: false, name: 'Bombasto' },      { id: 14, isSecret: false, name: 'Celeritas' },      { id: 15, isSecret: false, name: 'Magneta' },      { id: 16, isSecret: false, name: 'RubberMan' },      { id: 17, isSecret: false, name: 'Dynama' },      { id: 18, isSecret: true,  name: 'Dr IQ' },      { id: 19, isSecret: true,  name: 'Magma' },      { id: 20, isSecret: true,  name: 'Tornado' }    ];

HeroesComponent 是顶层英雄管理组件。 它唯一的目的是显示 HeroListComponent,该组件会显示一个英雄名字的列表。

HeroListComponent 的这个版本从 HEROES 数组(它在一个独立的 "mock-heroes" 文件中定义了一个内存集合)中获取英雄。

Path:"src/app/heroes/hero-list.component.ts (class)" 。

export class HeroListComponent {  heroes = HEROES;}

这种方法在原型阶段有用,但是不够健壮、不利于维护。 一旦你想要测试该组件或想从远程服务器获得英雄列表,就不得不修改 HeroesListComponent 的实现,并且替换每一处使用了 HEROES 模拟数据的地方。

创建和注册可注入的服务

DI 框架让你能从一个可注入的服务类(独立文件)中为组件提供数据。为了演示,我们还会创建一个用来提供英雄列表的、可注入的服务类,并把它注册为该服务的提供者。

同一个文件中放多个类容易让人困惑。我们通常建议你在单独的文件中定义组件和服务。

如果你把组件和服务都放在同一个文件中,请务必先定义服务,然后再定义组件。如果在服务之前定义组件,则会在运行时收到一个空引用错误。

也可以借助 forwardRef() 方法来先定义组件,就像这个博客中解释的那样。

创建可注入的服务类

Angular CLI 可以用下列命令在 "src/app/heroes" 目录下生成一个新的 HeroService 类。

ng generate service heroes/hero

下列命令会创建 HeroService 的骨架。

Path:"src/app/heroes/hero.service.ts (CLI-generated)" 。

import { Injectable } from '@angular/core';@Injectable({  providedIn: 'root',})export class HeroService {  constructor() { }}

@Injectable() 是每个 Angular 服务定义中的基本要素。该类的其余部分导出了一个 getHeroes 方法,它会返回像以前一样的模拟数据。(真实的应用可能会从远程服务器中异步获取这些数据,不过这里我们先忽略它,专心实现服务的注入机制。)

Path:"src/app/heroes/hero.service.ts" 。

import { Injectable } from '@angular/core';import { HEROES } from './mock-heroes';@Injectable({  // we declare that this service should be created  // by the root application injector.  providedIn: 'root',})export class HeroService {  getHeroes() { return HEROES; }}

用服务提供者配置注入器

我们创建的类提供了一个服务。@Injectable() 装饰器把它标记为可供注入的服务,不过在你使用该服务的 provider 提供者配置好 Angular 的依赖注入器之前,Angular 实际上无法将其注入到任何位置。

该注入器负责创建服务实例,并把它们注入到像 HeroListComponent 这样的类中。 你很少需要自己创建 Angular 的注入器。Angular 会在执行应用时为你创建注入器,第一个注入器是根注入器,创建于启动过程中。

提供者会告诉注入器如何创建该服务。 要想让注入器能够创建服务(或提供其它类型的依赖),你必须使用某个提供者配置好注入器。

提供者可以是服务类本身,因此注入器可以使用 new 来创建实例。 你还可以定义多个类,以不同的方式提供同一个服务,并使用不同的提供者来配置不同的注入器。

注入器是可继承的,这意味着如果指定的注入器无法解析某个依赖,它就会请求父注入器来解析它。 组件可以从它自己的注入器来获取服务、从其祖先组件的注入器中获取、从其父 NgModule 的注入器中获取,或从 root 注入器中获取。

你可以在三种位置之一设置元数据,以便在应用的不同层级使用提供者来配置注入器:

  • 在服务本身的 @Injectable() 装饰器中。

  • NgModule@NgModule() 装饰器中。

  • 在组件的 @Component() 装饰器中。

@Injectable() 装饰器具有一个名叫 providedIn 的元数据选项,在那里你可以指定把被装饰类的提供者放到 root 注入器中,或某个特定 NgModule 的注入器中。

@NgModule()@Component() 装饰器都有用一个 providers 元数据选项,在那里你可以配置 NgModule 级或组件级的注入器。

所有组件都是指令,而 providers 选项是从 @Directive() 中继承来的。 你也可以与组件一样的级别为指令、管道配置提供者。

注入服务

HeroListComponent 要想从 HeroService 中获取英雄列表,就得要求注入 HeroService,而不是自己使用 new 来创建自己的 HeroService 实例。

你可以通过制定带有依赖类型的构造函数参数来要求 Angular 在组件的构造函数中注入依赖项。下面的代码是 HeroListComponent 的构造函数,它要求注入 HeroService

Path:"src/app/heroes/hero-list.component (constructor signature)" 。

constructor(heroService: HeroService)

当然,HeroListComponent 还应该使用注入的这个 HeroService 做一些事情。 这里是修改过的组件,它转而使用注入的服务。与前一版本并列显示,以便比较。

//hero-list.component (with DI)import { Component }   from '@angular/core';import { Hero }        from './hero';import { HeroService } from './hero.service';@Component({  selector: 'app-hero-list',  template: `    <div *ngFor="let hero of heroes">      {{hero.id}} - {{hero.name}}    </div>  `})export class HeroListComponent {  heroes: Hero[];  constructor(heroService: HeroService) {    this.heroes = heroService.getHeroes();  }}

//hero-list.component (without DI)import { Component }   from '@angular/core';import { HEROES }      from './mock-heroes';@Component({  selector: 'app-hero-list',  template: `    <div *ngFor="let hero of heroes">      {{hero.id}} - {{hero.name}}    </div>  `})export class HeroListComponent {  heroes = HEROES;}

必须在某些父注入器中提供 HeroServiceHeroListComponent 并不关心 HeroService 来自哪里。 如果你决定在 AppModule 中提供 HeroService,也不必修改 HeroListComponent

注入器树与服务实例

在某个注入器的范围内,服务是单例的。也就是说,在指定的注入器中最多只有某个服务的最多一个实例。

应用只有一个根注入器。在 rootAppModule 级提供 UserService 意味着它注册到了根注入器上。 在整个应用中只有一个 UserService 实例,每个要求注入 UserService 的类都会得到这一个服务实例,除非你在子注入器中配置了另一个提供者。

Angular DI 具有分层注入体系,这意味着下级注入器也可以创建它们自己的服务实例。 Angular 会有规律的创建下级注入器。每当 Angular 创建一个在 @Component() 中指定了 providers 的组件实例时,它也会为该实例创建一个新的子注入器。 类似的,当在运行期间加载一个新的 NgModule 时,Angular 也可以为它创建一个拥有自己的提供者的注入器。

子模块和组件注入器彼此独立,并且会为所提供的服务分别创建自己的实例。当 Angular 销毁 NgModule 或组件实例时,也会销毁这些注入器以及注入器中的那些服务实例。

借助注入器继承机制,你仍然可以把全应用级的服务注入到这些组件中。 组件的注入器是其父组件注入器的子节点,它会继承所有的祖先注入器,其终点则是应用的根注入器。 Angular 可以注入该继承谱系中任何一个注入器提供的服务。

比如,Angular 既可以把 HeroComponent 中提供的 HeroService 注入到 HeroListComponent,也可以注入 AppModule 中提供的 UserService

测试带有依赖的组件

基于依赖注入设计一个类,能让它更易于测试。 要想高效的测试应用的各个部分,你所要做的一切就是把这些依赖列到构造函数的参数表中而已。

比如,你可以使用一个可在测试期间操纵的模拟服务来创建新的 HeroListComponent

Path:"src/app/test.component.ts" 。

const expectedHeroes = [{name: 'A'}, {name: 'B'}]const mockService = <HeroService> {getHeroes: () => expectedHeroes }it('should have heroes when HeroListComponent created', () => {  // Pass the mock to the constructor as the Angular injector would  const component = new HeroListComponent(mockService);  expect(component.heroes.length).toEqual(expectedHeroes.length);});

那些需要其它服务的服务

服务还可以具有自己的依赖。HeroService 非常简单,没有自己的依赖。不过,如果你希望通过日志服务来报告这些活动,那么就可以使用同样的构造函数注入模式,添加一个构造函数来接收一个 Logger 参数。

这是修改后的 HeroService,它注入了 Logger,我们把它和前一个版本的服务放在一起进行对比。

  1. Path:"src/app/heroes/hero.service (v2)" 。

    import { Injectable } from '@angular/core';    import { HEROES }     from './mock-heroes';    import { Logger }     from '../logger.service';    @Injectable({      providedIn: 'root',    })    export class HeroService {      constructor(private logger: Logger) {  }      getHeroes() {        this.logger.log('Getting heroes ...');        return HEROES;      }    }

  1. Path:"src/app/heroes/hero.service (v1)" 。

    import { Injectable } from '@angular/core';    import { HEROES }     from './mock-heroes';    @Injectable({      providedIn: 'root',    })    export class HeroService {      getHeroes() { return HEROES; }    }

  1. Path:"src/app/logger.service" 。

    import { Injectable } from '@angular/core';    @Injectable({      providedIn: 'root'    })    export class Logger {      logs: string[] = []; // capture logs for testing      log(message: string) {        this.logs.push(message);        console.log(message);      }    }

该构造函数请求注入一个 Logger 的实例,并把它保存在一个名叫 logger 的私有字段中。 当要求获取英雄列表时,getHeroes() 方法就会记录一条消息。

注意,虽然 Logger 服务没有自己的依赖项,但是它同样带有 @Injectable() 装饰器。实际上,@Injectable() 对所有服务都是必须的。

当 Angular 创建一个构造函数中有参数的类时,它会查找有关这些参数的类型,和供注入使用的元数据,以便找到正确的服务。 如果 Angular 无法找到参数信息,它就会抛出一个错误。 只有当类具有某种装饰器时,Angular 才能找到参数信息。 @Injectable() 装饰器是所有服务类的标准装饰器。

装饰器是 TypeScript 强制要求的。当 TypeScript 把代码转译成 JavaScript 时,一般会丢弃参数的类型信息。只有当类具有装饰器,并且 "tsconfig.json" 中的编译器选项 emitDecoratorMetadatatrue 时,TypeScript 才会保留这些信息。CLI 所配置的 "tsconfig.json" 就带有 emitDecoratorMetadata: true

这意味着你有责任给所有服务类加上 @Injectable()

依赖注入令牌

当使用提供者配置注入器时,就会把提供者和一个 DI 令牌关联起来。 注入器维护一个内部令牌-提供者的映射表,当请求一个依赖项时就会引用它。令牌就是这个映射表的键。

在简单的例子中,依赖项的值是一个实例,而类的类型则充当键来查阅它。 通过把 HeroService 类型作为令牌,你可以直接从注入器中获得一个 HeroService 实例。

Path:"src/app/injector.component.ts" 。

heroService: HeroService;

当你编写的构造函数中需要注入基于类的依赖项时,其行为也类似。 当你使用 HeroService 类的类型来定义构造函数参数时,Angular 就会知道要注入与 HeroService 类这个令牌相关的服务。

Path:"src/app/heroes/hero-list.component.ts" 。

constructor(heroService: HeroService)

很多依赖项的值都是通过类来提供的,但不是全部。扩展的 provide 对象让你可以把多种不同种类的提供者和 DI 令牌关联起来。

可选依赖

HeroService 需要一个记录器,但是如果找不到它会怎么样?

当组件或服务声明某个依赖项时,该类的构造函数会以参数的形式接收那个依赖项。 通过给这个参数加上 @Optional() 注解,你可以告诉 Angular,该依赖是可选的。

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

constructor(@Optional() private logger?: Logger) {  if (this.logger) {    this.logger.log(some_message);  }}

当使用 @Optional() 时,你的代码必须能正确处理 null 值。如果你没有在任何地方注册过 logger 提供者,那么注入器就会把 logger 的值设置为 null

@Inject()@Optional() 都是参数装饰器。它们通过在需要依赖项的类的构造函数上对参数进行注解,来改变 DI 框架提供依赖项的方式。

小结

本节中你学到了 Angular 依赖注入的基础知识。 你可以注册多种提供者,并且知道了如何通过为构造函数添加参数来请求所注入的对象(比如服务)。

Angular 中的注入器有一些规则,你可以利用这些规则来在应用程序中获得所需的可注入对象可见性。通过了解这些规则,可以确定应在哪个 NgModule、组件或指令中声明服务提供者。

两个注入器层次结构

Angular 中有两个注入器层次结构:

  • ModuleInjector 层次结构 —— 使用 @NgModule()@Injectable() 注解在此层次结构中配置 ModuleInjector

  • ElementInjector 层次结构 —— 在每个 DOM 元素上隐式创建。除非你在 @Directive()@Component()providers 属性中进行配置,否则默认情况下,ElementInjector 为空。

ModuleInjector

可以通过以下两种方式之一配置 ModuleInjector

  • 使用 @Injectable()providedIn 属性引用 @NgModule()root

  • 使用 @NgModule()providers 数组。

摇树优化与 @Injectable()

使用 @Injectable()providedIn 属性优于 @NgModule()providers 数组,因为使用 @Injectable()providedIn 时,优化工具可以进行摇树优化,从而删除你的应用程序中未使用的服务,以减小捆绑包尺寸。

摇树优化对于库特别有用,因为使用该库的应用程序不需要注入它。在 DI 提供者中了解有关可摇树优化的提供者的更多信息。

ModuleInjector@NgModule.providersNgModule.imports 属性配置。ModuleInjector 是可以通过 NgModule.imports 递归找到的所有 providers 数组的扁平化。

ModuleInjector 是在延迟加载其它 @NgModules 时创建的。

使用 @Injectable()providedIn 属性提供服务的方式如下:

import { Injectable } from '@angular/core';@Injectable({  providedIn: 'root'  // <--provides this service in the root ModuleInjector})export class ItemService {  name = 'telephone';}

@Injectable() 装饰器标识服务类。该 providedIn 属性配置指定的 ModuleInjector,这里的 root 会把让该服务在 root ModuleInjector 上可用。

平台注入器

root 之上还有两个注入器,一个是额外的 ModuleInjector,一个是 NullInjector()

思考下 Angular 要如何通过 "main.ts" 中的如下代码引导应用程序:

platformBrowserDynamic().bootstrapModule(AppModule).then(ref => {...})

bootstrapModule() 方法会创建一个由 AppModule 配置的注入器作为平台注入器的子注入器。也就是 root ModuleInjector

platformBrowserDynamic() 方法创建一个由 PlatformModule 配置的注入器,该注入器包含特定平台的依赖项。这允许多个应用共享同一套平台配置。例如,无论你运行多少个应用程序,浏览器都只有一个 URL 栏。你可以使用 platformBrowser() 函数提供 extraProviders,从而在平台级别配置特定平台的额外提供者。

层次结构中的下一个父注入器是 NullInjector(),它是树的顶部。如果你在树中向上走了很远,以至于要在 NullInjector() 中寻找服务,那么除非使用 @Optional(),否则将收到错误消息,因为最终所有东西都将以 NullInjector() 结束并返回错误,或者对于 @Optional(),返回 null

下图展示了前面各段落描述的 root ModuleInjector 及其父注入器之间的关系。

虽然 root 是一个特殊的别名,但其它 ModuleInjector 都没有别名。每当创建动态加载组件时,你还会创建 ModuleInjector,比如路由器,它还会创建子 ModuleInjector

无论是使用 bootstrapModule() 的方法配置它,还是将所有提供者都用 root 注册到其自己的服务中,所有请求最终都会转发到 root 注入器。

@Injectable() vs. @NgModule()

如果你在 AppModule@NgModule() 中配置应用级提供者,它就会覆盖一个在 @Injectable()root 元数据中配置的提供者。你可以用这种方式,来配置供多个应用共享的服务的非默认提供者。

下面的例子中,通过把 location 策略 的提供者添加到 AppModuleproviders 列表中,为路由器配置了非默认的 location 策略。

Path:"src/app/app.module.ts (providers)" 。

providers: [  { provide: LocationStrategy, useClass: HashLocationStrategy }]

ElementInjector

Angular 会为每个 DOM 元素隐式创建 ElementInjector

可以用 @Component() 装饰器中的 providersviewProviders 属性来配置 ElementInjector 以提供服务。例如,下面的 TestComponent 通过提供此服务来配置 ElementInjector

@Component({  ...  providers: [{ provide: ItemService, useValue: { name: 'lamp' } }]})export class TestComponent

在组件中提供服务时,可以通过 ElementInjector 在该组件实例处使用该服务。根据解析规则部分描述的可见性规则,它也同样在子组件/指令处可见。

当组件实例被销毁时,该服务实例也将被销毁。

@Directive() 和 @Component()

组件是一种特殊类型的指令,这意味着 @Directive() 具有 providers 属性,@Component() 也同样如此。 这意味着指令和组件都可以使用 providers 属性来配置提供者。当使用 providers 属性为组件或指令配置提供者时,该提供程商就属于该组件或指令的 ElementInjector。同一元素上的组件和指令共享同一个注入器。

解析规则

当为组件/指令解析令牌时,Angular 分为两个阶段来解析它:

针对 ElementInjector 层次结构(其父级)

针对 ModuleInjector 层次结构(其父级)

当组件声明依赖项时,Angular 会尝试使用它自己的 ElementInjector 来满足该依赖。 如果组件的注入器缺少提供者,它将把请求传给其父组件的 ElementInjector

这些请求将继续转发,直到 Angular 找到可以处理该请求的注入器或用完祖先 ElementInjector

如果 Angular 在任何 ElementInjector 中都找不到提供者,它将返回到发起请求的元素,并在 ModuleInjector 层次结构中进行查找。如果 Angular 仍然找不到提供者,它将引发错误。

如果你已在不同级别注册了相同 DI 令牌的提供者,则 Angular 会用遇到的第一个来解析该依赖。例如,如果提供者已经在需要此服务的组件中本地注册了,则 Angular 不会再寻找同一服务的其它提供者。

解析修饰符

可以使用 @Optional()@Self()@SkipSelf()@Host() 来修饰 Angular 的解析行为。从 @angular/core 导入它们,并在注入服务时在组件类构造函数中使用它们。

修饰符的类型

解析修饰符分为三类:

如果 Angular 找不到你要的东西该怎么办,用 @Optional()

从哪里开始寻找,用 @SkipSelf()

到哪里停止寻找,用 @Host()@Self()

默认情况下,Angular 始终从当前的 Injector 开始,并一直向上搜索。修饰符使你可以更改开始(默认是自己)或结束位置。

另外,你可以组合除 @Host()@Self() 之外的所有修饰符,当然还有 @SkipSelf()@Self()

@Optional()

@Optional() 允许 Angular 将你注入的服务视为可选服务。这样,如果无法在运行时解析它,Angular 只会将服务解析为 null,而不会抛出错误。在下面的示例中,服务 OptionalService 没有在 @NgModule() 或组件类中提供,所以它没有在应用中的任何地方。

Path:"resolution-modifiers/src/app/optional/optional.component.ts" 。

export class OptionalComponent {  constructor(@Optional() public optional?: OptionalService) {}}

@Self()

使用 @Self() 让 Angular 仅查看当前组件或指令的 ElementInjector

@Self() 的一个好例子是要注入某个服务,但只有当该服务在当前宿主元素上可用时才行。为了避免这种情况下出错,请将 @Self()@Optional() 结合使用。

例如,在下面的 SelfComponent 中。请注意在构造函数中注入的 LeafService

Path:"resolution-modifiers/src/app/self-no-data/self-no-data.component.ts" 。

@Component({  selector: 'app-self-no-data',  templateUrl: './self-no-data.component.html',  styleUrls: ['./self-no-data.component.css']})export class SelfNoDataComponent {  constructor(@Self() @Optional() public leaf?: LeafService) { }}

在这个例子中,有一个父提供者,注入服务将返回该值,但是,使用 @Self()@Optional() 注入的服务将返回 null 因为 @Self() 告诉注入器在当前宿主元素上就要停止搜索。

另一个示例显示了具有 FlowerService 提供者的组件类。在这个例子中,注入器没有超出当前 ElementInjector 就停止了,因为它已经找到了 FlowerService 并返回了黄色花朵????。

Path:"resolution-modifiers/src/app/self/self.component.ts" 。

@Component({  selector: 'app-self',  templateUrl: './self.component.html',  styleUrls: ['./self.component.css'],  providers: [{ provide: FlowerService, useValue: { emoji: '????' } }]})export class SelfComponent {  constructor(@Self() public flower: FlowerService) {}}

@SkipSelf()

@SkipSelf()@Self() 相反。使用 @SkipSelf(),Angular 在父 ElementInjector 中而不是当前 ElementInjector 中开始搜索服务。因此,如果父 ElementInjectoremoji 使用了值 ????(蕨类),但组件的 providers 数组中有 ????(枫叶),则 Angular 将忽略 ????(枫叶),而使用 ????(蕨类)。

要在代码中看到这一点,请先假定 emoji 的以下值就是父组件正在使用的值,如本服务所示:

Path:"resolution-modifiers/src/app/leaf.service.ts" 。

export class LeafService {  emoji = '????';}

想象一下,在子组件中,你有一个不同的值 ????(枫叶),但你想使用父项的值。你就要使用 @SkipSelf()

Path:"resolution-modifiers/src/app/skipself/skipself.component.ts" 。

@Component({  selector: 'app-skipself',  templateUrl: './skipself.component.html',  styleUrls: ['./skipself.component.css'],  // Angular would ignore this LeafService instance  providers: [{ provide: LeafService, useValue: { emoji: '????' } }]})export class SkipselfComponent {  // Use @SkipSelf() in the constructor  constructor(@SkipSelf() public leaf: LeafService) { }}

在这个例子中,你获得的 emoji 值将为 ????(蕨类),而不是 ????(枫叶)。

@SkipSelf() with @Optional()

如果值为 null 请同时使用 @SkipSelf()@Optional() 来防止错误。在下面的示例中,将 Person 服务注入到构造函数中。@SkipSelf() 告诉 Angular 跳过当前的注入器,如果 Person 服务为 null,则 @Optional() 将防止报错。

class Person {  constructor(@Optional() @SkipSelf() parent?: Person) {}}

@Host()

@Host() 使你可以在搜索提供者时将当前组件指定为注入器树的最后一站。即使树的更上级有一个服务实例,Angular 也不会继续寻找。使用 @Host() 的例子如下:

Path:"resolution-modifiers/src/app/host/host.component.ts" 。

@Component({  selector: 'app-host',  templateUrl: './host.component.html',  styleUrls: ['./host.component.css'],  //  provide the service  providers: [{ provide: FlowerService, useValue: { emoji: '????' } }]})export class HostComponent {  // use @Host() in the constructor when injecting the service  constructor(@Host() @Optional() public flower?: FlowerService) { }}

由于 HostComponent 在其构造函数中具有 @Host(),因此,无论 HostComponent 的父级是否可能有 flower.emoji 值,该 HostComponent 都将使用 ????(黄色花朵)。

模板的逻辑结构

在组件类中提供服务时,服务在 ElementInjector 树中的可见性是取决于你在何处以及如何提供这些服务。

了解 Angular 模板的基础逻辑结构将为你配置服务并进而控制其可见性奠定基础。

组件在模板中使用,如以下示例所示:

<app-root>    <app-child></app-child></app-root>

注:

通常,你要在单独的文件中声明组件及其模板。为了理解注入系统的工作原理,从组合逻辑树的视角来看它们是很有帮助的。使用术语“逻辑”将其与渲染树(你的应用程序 DOM 树)区分开。为了标记组件模板的位置,本指南使用 <#VIEW& 伪元素,该元素实际上不存在于渲染树中,仅用于心智模型中。

下面是如何将 <app-root><app-child> 视图树组合为单个逻辑树的示例:

<app-root>  <#VIEW>    <app-child>     <#VIEW>       ...content goes here...     </#VIEW>    </app-child>  <#VIEW></app-root>

当你在组件类中配置服务时,了解这种 <#VIEW> 划界的思想尤其重要。

在 @Component() 中提供服务

你如何通过 @Component() (或 @Directive() )装饰器提供服务决定了它们的可见性。以下各节演示了 providersviewProviders 以及使用 @SkipSelf()@Host() 修改服务可见性的方法。

组件类可以通过两种方式提供服务:

  1. 使用 providers 数组

    @Component({      ...      providers: [        {provide: FlowerService, useValue: {emoji: '????'}}      ]    })

  1. 使用 viewProviders 数组

    @Component({      ...      viewProviders: [        {provide: AnimalService, useValue: {emoji: '????'}}      ]    })

为了解 providersviewProviders 对服务可见性的影响有何差异,以下各节将逐步构建一个示例并在代码和逻辑树中比较 providersviewProviders 的作用。

注:

在逻辑树中,你会看到 @Provide@Inject@NgModule,这些不是真正的 HTML 属性,只是为了在这里证明其幕后的原理。

  • @Inject(Token)=&Value 表示,如果要将 Token 注入逻辑树中的此位置,则它的值为 Value

  • @Provide(Token=Value) 表示,在逻辑树中的此位置存在一个值为 ValueToken 提供者的声明。

  • @NgModule(Token) 表示,应在此位置使用后备的 NgModule 注入器。

应用程序结构示例

示例应用程序的 root 提供了 FlowerService,其 emoji 值为 ????(红色芙蓉)。

Path:"providers-viewproviders/src/app/flower.service.ts" 。

@Injectable({  providedIn: 'root'})export class FlowerService {  emoji = '????';}

考虑一个只有 AppComponentChildComponent 的简单应用程序。最基本的渲染视图看起来就像嵌套的 HTML 元素,例如:

<app-root> <!-- AppComponent selector -->    <app-child> <!-- ChildComponent selector -->    </app-child></app-root>

但是,在幕后,Angular 在解析注入请求时使用如下逻辑视图表示形式:

<app-root> <!-- AppComponent selector -->    <#VIEW>        <app-child> <!-- ChildComponent selector -->            <#VIEW>            </#VIEW>        </app-child>    </#VIEW></app-root>

此处的 <#VIEW> 表示模板的实例。请注意,每个组件都有自己的 <#VIEW>

了解此结构可以告知你如何提供和注入服务,并完全控制服务的可见性。

现在,考虑 <app-root> 只注入了 FlowerService

Path:"providers-viewproviders/src/app/app.component.ts" 。

export class AppComponent  {  constructor(public flower: FlowerService) {}}

将绑定添加到 <app-root> 模板来将结果可视化:

Path:"providers-viewproviders/src/app/app.component.html" 。

<p>Emoji from FlowerService: {{flower.emoji}}</p>

该视图中的输出为:

Emoji from FlowerService: ????

在逻辑树中,这可以表示成如下形式:

<app-root @NgModule(AppModule)        @Inject(FlowerService) flower=>"????">  <#VIEW>    <p>Emoji from FlowerService: {{flower.emoji}} (????)</p>    <app-child>      <#VIEW>      </#VIEW>     </app-child>  </#VIEW></app-root>

<app-root> 请求 FlowerService 时,注入器的工作就是解析 FlowerService 令牌。令牌的解析分为两个阶段:

  1. 注入器确定逻辑树中搜索的开始位置和结束位置。注入程序从起始位置开始,并在逻辑树的每个级别上查找令牌。如果找到令牌,则将其返回。

  1. 如果未找到令牌,则注入程序将寻找最接近的父 @NgModule() 委派该请求。

在这个例子中,约束为:

  1. 从属于 <app-root><#VIEW> 开始,并结束于 <app-root>

  • 通常,搜索的起点就是注入点。但是,在这个例子中,<app-root> @Component 的特殊之处在于它们还包括自己的 viewProviders,这就是为什么搜索从 <app-root><#VIEW> 开始的原因。(对于匹配同一位置的指令,情况却并非如此)。

  • 结束位置恰好与组件本身相同,因为它就是此应用程序中最顶层的组件。

  1. 当在 ElementInjector 中找不到注入令牌时,就用 AppModule 充当后备注入器。

使用 providers 数组

现在,在 ChildComponent 类中,为 FlowerService 添加一个提供者,以便在接下来的小节中演示更复杂的解析规则:

Path:"providers-viewproviders/src/app/child.component.ts" 。

@Component({  selector: 'app-child',  templateUrl: './child.component.html',  styleUrls: ['./child.component.css'],  // use the providers array to provide a service  providers: [{ provide: FlowerService, useValue: { emoji: '????' } }]})export class ChildComponent {  // inject the service  constructor( public flower: FlowerService) { }}

现在,在 @Component() 装饰器中提供了 FlowerService,当 <app-child> 请求该服务时,注入器仅需要查找 <app-child> 自己的 ElementInjector。不必再通过注入器树继续搜索。

下一步是将绑定添加到 ChildComponent 模板。

Path:"providers-viewproviders/src/app/child.component.html" 。

<p>Emoji from FlowerService: {{flower.emoji}}</p>

要渲染新的值,请在 AppComponent 模板的底部添加 <app-child>,以便其视图也显示向日葵:

Child ComponentEmoji from FlowerService: ????

在逻辑树中,可以把它表示成这样:

<app-root @NgModule(AppModule)        @Inject(FlowerService) flower=>"????">  <#VIEW>    <p>Emoji from FlowerService: {{flower.emoji}} (????)</p>    <app-child @Provide(FlowerService="????")               @Inject(FlowerService)=>"????"> <!-- search ends here -->      <#VIEW> <!-- search starts here -->        <h2>Parent Component</h2>        <p>Emoji from FlowerService: {{flower.emoji}} (????)</p>      </#VIEW>     </app-child>  </#VIEW></app-root>

<app-child> 请求 FlowerService 时,注入器从 <app-child><#VIEW> 开始搜索(包括 <#VIEW>,因为它是从 @Component() 注入的),并到 <app-child> 结束。在这个例子中,FlowerService<app-child>providers 数组中解析为向日葵????。注入器不必在注入器树中进一步查找。一旦找到 FlowerService,它便停止运行,再也看不到????(红芙蓉)。

使用 viewProviders 数组

使用 viewProviders 数组是在 @Component() 装饰器中提供服务的另一种方法。使用 viewProviders 使服务在 <#VIEW> 中可见。

除了使用 viewProviders 数组外,其它步骤与使用 providers 数组相同。

该示例应用程序具有第二个服务 AnimalService 来演示 viewProviders

首先,创建一个 AnimalServiceemoji 的????(鲸鱼)属性:

Path:"providers-viewproviders/src/app/animal.service.ts" 。

import { Injectable } from '@angular/core';@Injectable({  providedIn: 'root'})export class AnimalService {  emoji = '????';}

遵循与 FlowerService 相同的模式,将 AnimalService 注入 AppComponent 类:

Path:"providers-viewproviders/src/app/app.component.ts" 。

export class AppComponent  {  constructor(public flower: FlowerService, public animal: AnimalService) {}}

注:

你可以保留所有与 FlowerService 相关的代码,因为它可以与 AnimalService 进行比较。

添加一个 viewProviders 数组,并将 AnimalService 也注入到 <app-child> 类中,但是给 emoji 一个不同的值。在这里,它的值为????(小狗)。

Path:"providers-viewproviders/src/app/child.component.ts" 。

@Component({  selector: 'app-child',  templateUrl: './child.component.html',  styleUrls: ['./child.component.css'],  // provide services  providers: [{ provide: FlowerService, useValue: { emoji: '????' } }],  viewProviders: [{ provide: AnimalService, useValue: { emoji: '????' } }]})export class ChildComponent {  // inject service  constructor( public flower: FlowerService, public animal: AnimalService) { }}

将绑定添加到 ChildComponentAppComponent 模板。在 ChildComponent 模板中,添加以下绑定:

Path:"providers-viewproviders/src/app/child.component.html" 。

<p>Emoji from AnimalService: {{animal.emoji}}</p>

此外,将其添加到 AppComponent 模板:

Path:"providers-viewproviders/src/app/app.component.html" 。

<p>Emoji from AnimalService: {{animal.emoji}}</p>

现在,你应该在浏览器中看到两个值:

AppComponentEmoji from AnimalService: ????Child ComponentEmoji from AnimalService: ????

viewProviders 示例的逻辑树如下:

<app-root @NgModule(AppModule)        @Inject(AnimalService) animal=>"????">  <#VIEW>    <app-child>      <#VIEW       @Provide(AnimalService="????")       @Inject(AnimalService=>"????")>       <!-- ^^using viewProviders means AnimalService is available in <#VIEW>-->       <p>Emoji from AnimalService: {{animal.emoji}} (????)</p>      </#VIEW>     </app-child>  </#VIEW></app-root>

FlowerService 示例一样,<app-child> @Component() 装饰器中提供了 AnimalService。这意味着,由于注入器首先在组件的 ElementInjector 中查找,因此它将找到 AnimalService 的值 ????(小狗)。它不需要继续搜索 ElementInjector 树,也不需要搜索ModuleInjector

providers 与 viewProviders

为了看清 providersviewProviders 的差异,请在示例中添加另一个组件,并将其命名为 InspectorComponentInspectorComponent 将是 ChildComponent 的子 ChildComponent。在 "inspector.component.ts" 中,将 FlowerServiceAnimalService 注入构造函数中:

Path:"providers-viewproviders/src/app/inspector/inspector.component.ts" 。

export class InspectorComponent {  constructor(public flower: FlowerService, public animal: AnimalService) { }}

你不需要 providersviewProviders 数组。接下来,在 "inspector.component.html" 中,从以前的组件中添加相同的 html:

Path:"providers-viewproviders/src/app/inspector/inspector.component.html" 。

<p>Emoji from FlowerService: {{flower.emoji}}</p><p>Emoji from AnimalService: {{animal.emoji}}</p>

别忘了将 InspectorComponent 添加到 AppModule declarations 数组。

Path:"providers-viewproviders/src/app/app.module.ts" 。

@NgModule({  imports:      [ BrowserModule, FormsModule ],  declarations: [ AppComponent, ChildComponent, InspectorComponent ],  bootstrap:    [ AppComponent ],  providers: []})export class AppModule { }

接下来,确保你的 "child.component.html" 包含以下内容:

Path:"providers-viewproviders/src/app/child/child.component.html" 。

<p>Emoji from FlowerService: {{flower.emoji}}</p><p>Emoji from AnimalService: {{animal.emoji}}</p><div class="container">  <h3>Content projection</h3>    <ng-content></ng-content></div><h3>Inside the view</h3><app-inspector></app-inspector>

前两行带有绑定,来自之前的步骤。新的部分是 <ng-content><app-inspector><ng-content> 允许你投影内容,ChildComponent 模板中的 <app-inspector> 使 InspectorComponent 成为 ChildComponent 的子组件。

接下来,将以下内容添加到 "app.component.html" 中以利用内容投影的优势。

Path:"providers-viewproviders/src/app/app.component.html" 。

<app-child><app-inspector></app-inspector></app-child>

现在,浏览器将渲染以下内容,为简洁起见,省略了前面的示例:

//...Omitting previous examples. The following applies to this section.Content projection: This is coming from content. Doesn't get to seepuppy because the puppy is declared inside the view only.Emoji from FlowerService: ????Emoji from AnimalService: ????Emoji from FlowerService: ????Emoji from AnimalService: ????

这四个绑定说明了 providersviewProviders 之间的区别。由于????(小狗)在<#VIEW>中声明,因此投影内容不可见。投影的内容中会看到????(鲸鱼)。

但是下一部分,InspectorComponentChildComponent 的子组件,InspectorComponent<#VIEW> 内部,因此当它请求 AnimalService 时,它会看到????(小狗)。

逻辑树中的 AnimalService 如下所示:

<app-root @NgModule(AppModule)        @Inject(AnimalService) animal=>"????">  <#VIEW>    <app-child>      <#VIEW       @Provide(AnimalService="????")       @Inject(AnimalService=>"????")>       <!-- ^^using viewProviders means AnimalService is available in <#VIEW>-->       <p>Emoji from AnimalService: {{animal.emoji}} (????)</p>       <app-inspector>        <p>Emoji from AnimalService: {{animal.emoji}} (????)</p>       </app-inspector>      </#VIEW>      <app-inspector>        <#VIEW>          <p>Emoji from AnimalService: {{animal.emoji}} (????)</p>        </#VIEW>      </app-inspector>     </app-child>  </#VIEW></app-root>

<app-inspector> 的投影内容中看到了????(鲸鱼),而不是????(小狗),因为????(小狗)在 <app-child><#VIEW> 中。如果 <app-inspector> 也位于 <#VIEW> 则只能看到????(小狗)。

修改服务可见性

如何使用可见性修饰符 @Host()@Self()@SkipSelf() 来限制 ElementInjector 的开始和结束范围。

提供者令牌的可见性

可见性装饰器影响搜索注入令牌时在逻辑树中开始和结束的位置。为此,要将可见性装饰器放置在注入点,即 constructor(),而不是在声明点。

为了修改该注入器从哪里开始寻找 FlowerService,把 @SkipSelf() 加到 <app-child>@Inject 声明 FlowerService 中。该声明在 <app-child> 构造函数中,如 "child.component.ts" 所示:

constructor(@SkipSelf() public flower : FlowerService) { }

使用 @SkipSelf()<app-child> 注入器不会寻找自身来获取 FlowerService。相反,喷射器开始在 <app-root>ElementInjector 中寻找 FlowerService,在那里它什么也没找到。 然后,它返回到 <app-child>ModuleInjector 并找到????(红芙蓉)值,这是可用的,因为 <app-child> ModuleInjector<app-root> ModuleInjector 被展开成了一个 ModuleInjector。因此,UI 将渲染以下内容:

Emoji from FlowerService: ????

在逻辑树中,这种情况可能如下所示:

<app-root @NgModule(AppModule)        @Inject(FlowerService) flower=>"????">  <#VIEW>    <app-child @Provide(FlowerService="????")>      <#VIEW @Inject(FlowerService, SkipSelf)=>"????">      <!-- With SkipSelf, the injector looks to the next injector up the tree -->      </#VIEW>      </app-child>  </#VIEW></app-root>

尽管 <app-child> 提供了????(向日葵),但该应用程序渲染了????(红色芙蓉),因为 @SkipSelf() 导致当前的注入器跳过了自身并寻找其父级。

如果现在将 @Host()(以及 @SkipSelf() )添加到了 FlowerService@Inject,其结果将为 null。这是因为 @Host() 将搜索的上限限制为 <#VIEW>。这是在逻辑树中的情况:

<app-root @NgModule(AppModule)        @Inject(FlowerService) flower=>"????">  <#VIEW> <!-- end search here with null-->    <app-child @Provide(FlowerService="????")> <!-- start search here -->      <#VIEW @Inject(FlowerService, @SkipSelf, @Host, @Optional)=>null>      </#VIEW>      </app-parent>  </#VIEW></app-root>

在这里,服务及其值是相同的,但是 @Host() 阻止了注入器对 FlowerService 进行任何高于 <#VIEW> 的查找,因此找不到它并返回 null

@SkipSelf() 和 viewProviders

<app-child> 目前提供在 viewProviders 数组中提供了值为 ????(小狗)的 AnimalService。由于注入器只需要查看 <app-child>ElementInjector 中的 AnimalService,它就不会看到????(鲸鱼)。

就像在 FlowerService 示例中一样,如果将 @SkipSelf() 添加到 AnimalService 的构造函数中,则注入器将不在 AnimalService 的当前 <app-child>ElementInjector 中查找 AnimalService

export class ChildComponent {// add @SkipSelf()  constructor(@SkipSelf() public animal : AnimalService) { }}

相反,注入器将从 <app-root> ElementInjector 开始找。请记住,<app-child> 类在 viewProviders 数组中 AnimalService 中提供了????(小狗)的值:

@Component({  selector: 'app-child',  ...  viewProviders:  [{ provide: AnimalService, useValue: { emoji: '????' } }]})

<app-child> 中使用 SkipSelf() 的逻辑树是这样的:

<app-root @NgModule(AppModule)        @Inject(AnimalService=>"????")>  <#VIEW><!-- search begins here -->    <app-child>      <#VIEW       @Provide(AnimalService="????")       @Inject(AnimalService, SkipSelf=>"????")>       <!--Add @SkipSelf -->      </#VIEW>      </app-child>  </#VIEW></app-root>

<app-child> 中使用 @SkipSelf(),注入器就会在 <app-root>ElementInjector 中找到 ????(鲸)。

@Host() 和 viewProviders

如果把 @Host() 添加到 AnimalService 的构造函数上,结果就是????(小狗),因为注入器会在 <app-child><#VIEW> 中查找 AnimalService 服务。这里是 <app-child> 类中的 viewProviders 数组和构造函数中的 @Host()

@Component({  selector: 'app-child',  ...  viewProviders:  [{ provide: AnimalService, useValue: { emoji: '????' } }]})export class ChildComponent {  constructor(@Host() public animal : AnimalService) { }}

@Host() 导致注入器开始查找,直到遇到 <#VIEW> 的边缘。

<app-root @NgModule(AppModule)        @Inject(AnimalService=>"????")>  <#VIEW>    <app-child>      <#VIEW       @Provide(AnimalService="????")       @Inject(AnimalService, @Host=>"????")> <!-- @Host stops search here -->      </#VIEW>      </app-child>  </#VIEW></app-root>

将带有第三个动物????(刺猬)的 viewProviders 数组添加到 "app.component.ts" 的 @Component() 元数据中:

@Component({  selector: 'app-root',  templateUrl: './app.component.html',  styleUrls: [ './app.component.css' ],  viewProviders: [{ provide: AnimalService, useValue: { emoji: '????' } }]})

接下来,同时把 @SkipSelf()@Host() 加在 "child.component.ts" 中 AnimalService 的构造函数中。这是 <app-child> 构造函数中的 @Host()@SkipSelf()

export class ChildComponent {  constructor(  @Host() @SkipSelf() public animal : AnimalService) { }}

@Host()SkipSelf() 应用于 providers 数组中的 FlowerService,结果为 null,因为 @SkipSelf() 会在 <app-child> 的注入器中开始搜索,但是 @Host() 要求它在 <#VIEW> 停止搜索 —— 没有 FlowerService。在逻辑树中,你可以看到 FlowerService<app-child> 中可见,而在 <#VIEW> 中不可见。

不过,提供在 AppComponentviewProviders 数组中的 AnimalService,是可见的。

逻辑树表示法说明了为何如此:

<app-root @NgModule(AppModule)        @Inject(AnimalService=>"????")>  <#VIEW @Provide(AnimalService="????")         @Inject(AnimalService, @SkipSelf, @Host, @Optional)=>"????">    <!-- ^^@SkipSelf() starts here,  @Host() stops here^^ -->    <app-child>      <#VIEW @Provide(AnimalService="????")             @Inject(AnimalService, @SkipSelf, @Host, @Optional)=>"????">               <!-- Add @SkipSelf ^^-->      </#VIEW>      </app-child>  </#VIEW></app-root>

@SkipSelf() 导致注入器从 <app-root> 而不是 <app-child> 处开始对 AnimalService 进行搜索,而 @Host() 会在 <app-root><#VIEW> 处停止搜索。 由于 AnimalService 是通过 viewProviders 数组提供的,因此注入程序会在 <#VIEW> 找到????(刺猬)。

ElementInjector 用例示例

在不同级别配置一个或多个提供者的能力开辟了很有用的可能性。

场景:服务隔离

出于架构方面的考虑,可能会让你决定把一个服务限制到只能在它所属的那个应用域中访问。 比如,这个例子中包括一个用于显示反派列表的 VillainsListComponent,它会从 VillainsService 中获得反派列表数据。

如果你在根模块 AppModule 中(也就是你注册 HeroesService 的地方)提供 VillainsService,就会让应用中的任何地方都能访问到 VillainsService,包括针对英雄的工作流。如果你稍后修改了 VillainsService,就可能破坏了英雄组件中的某些地方。在根模块 AppModule 中提供该服务将会引入此风险。

该怎么做呢?你可以在 VillainsListComponentproviders 元数据中提供 VillainsService,就像这样:

Path:"src/app/villains-list.component.ts (metadata)" 。

@Component({  selector: 'app-villains-list',  templateUrl: './villains-list.component.html',  providers: [ VillainsService ]})

VillainsListComponent 的元数据中而不是其它地方提供 VillainsService 服务,该服务就会只在 VillainsListComponent 及其子组件树中可用。

VillainService 对于 VillainsListComponent 来说是单例的,因为它就是在这里声明的。只要 VillainsListComponent 没有销毁,它就始终是 VillainService 的同一个实例。但是对于 VillainsListComponent 的多个实例,每个 VillainsListComponent 的实例都会有自己的 VillainService 实例。

场景:多重编辑会话

很多应用允许用户同时进行多个任务。 比如,在纳税申报应用中,申报人可以打开多个报税单,随时可能从一个切换到另一个。

本章要示范的场景仍然是基于《英雄指南》的。 想象一个外层的 HeroListComponent,它显示一个超级英雄的列表。

要打开一个英雄的报税单,申报者点击英雄名,它就会打开一个组件来编辑那个申报单。 每个选中的申报单都会在自己的组件中打开,并且可以同时打开多个申报单。

每个报税单组件都有下列特征:

  • 属于它自己的报税单会话。

  • 可以修改一个报税单,而不会影响另一个组件中的申报单。

  • 能把所做的修改保存到它的报税单中,或者放弃它们。

假设 HeroTaxReturnComponent 还有一些管理并还原这些更改的逻辑。 这对于简单的报税单来说是很容易的。 不过,在现实世界中,报税单的数据模型非常复杂,对这些修改的管理可能不得不投机取巧。 你可以把这种管理任务委托给一个辅助服务,就像这个例子中所做的。

报税单服务 HeroTaxReturnService 缓存了单条 HeroTaxReturn,用于跟踪那个申报单的变更,并且可以保存或还原它。 它还委托给了全应用级的单例服务 HeroService,它是通过依赖注入机制取得的。

Path:"src/app/hero-tax-return.service.ts" 。

import { Injectable }    from '@angular/core';import { HeroTaxReturn } from './hero';import { HeroesService } from './heroes.service';@Injectable()export class HeroTaxReturnService {  private currentTaxReturn: HeroTaxReturn;  private originalTaxReturn: HeroTaxReturn;  constructor(private heroService: HeroesService) { }  set taxReturn (htr: HeroTaxReturn) {    this.originalTaxReturn = htr;    this.currentTaxReturn  = htr.clone();  }  get taxReturn (): HeroTaxReturn {    return this.currentTaxReturn;  }  restoreTaxReturn() {    this.taxReturn = this.originalTaxReturn;  }  saveTaxReturn() {    this.taxReturn = this.currentTaxReturn;    this.heroService.saveTaxReturn(this.currentTaxReturn).subscribe();  }}

下面是正在使用 HeroTaxReturnServiceHeroTaxReturnComponent 组件。

Path:"src/app/hero-tax-return.component.ts" 。

import { Component, EventEmitter, Input, Output } from '@angular/core';import { HeroTaxReturn }        from './hero';import { HeroTaxReturnService } from './hero-tax-return.service';@Component({  selector: 'app-hero-tax-return',  templateUrl: './hero-tax-return.component.html',  styleUrls: [ './hero-tax-return.component.css' ],  providers: [ HeroTaxReturnService ]})export class HeroTaxReturnComponent {  message = '';  @Output() close = new EventEmitter<void>();  get taxReturn(): HeroTaxReturn {    return this.heroTaxReturnService.taxReturn;  }  @Input()  set taxReturn (htr: HeroTaxReturn) {    this.heroTaxReturnService.taxReturn = htr;  }  constructor(private heroTaxReturnService: HeroTaxReturnService) { }  onCanceled()  {    this.flashMessage('Canceled');    this.heroTaxReturnService.restoreTaxReturn();  };  onClose()  { this.close.emit(); };  onSaved() {    this.flashMessage('Saved');    this.heroTaxReturnService.saveTaxReturn();  }  flashMessage(msg: string) {    this.message = msg;    setTimeout(() => this.message = '', 500);  }}

通过 @Input() 属性可以得到要编辑的报税单,这个属性被实现成了读取器(getter)和设置器(setter)。 设置器根据传进来的报税单初始化了组件自己的 HeroTaxReturnService 实例。 读取器总是返回该服务所存英雄的当前状态。 组件也会请求该服务来保存或还原这个报税单。

但如果该服务是一个全应用范围的单例就不行了。 每个组件就都会共享同一个服务实例,每个组件也都会覆盖属于其它英雄的报税单。

要防止这一点,就要在 HeroTaxReturnComponent 元数据的 providers 属性中配置组件级的注入器,来提供该服务。

Path:"src/app/hero-tax-return.component.ts (providers)" 。

providers: [ HeroTaxReturnService ]

HeroTaxReturnComponent 有它自己的 HeroTaxReturnService 提供者。 回忆一下,每个组件的实例都有它自己的注入器。 在组件级提供服务可以确保组件的每个实例都得到一个自己的、私有的服务实例,而报税单也不会再被意外覆盖了。

场景:专门的提供者

在其它层级重新提供服务的另一个理由,是在组件树的深层中把该服务替换为一个更专门化的实现。

考虑一个依赖于一系列服务的 Car 组件。 假设你在根注入器(代号 A)中配置了通用的提供者:CarService、EngineServiceTiresService

你创建了一个车辆组件(A),它显示一个从另外三个通用服务构造出的车辆。

然后,你创建一个子组件(B),它为 CarServiceEngineService 定义了自己特有的提供者,它们具有适用于组件 B 的特有能力。

组件 B 是另一个组件 C 的父组件,而组件 C 又定义了自己的,更特殊的CarService 提供者。

在幕后,每个组件都有自己的注入器,这个注入器带有为组件本身准备的 0 个、1 个或多个提供者。

当你在最深层的组件 C 解析 Car 的实例时,它使用注入器 C 解析生成了一个 Car 的实例,使用注入器 B 解析了 Engine,而 Tires 则是由根注入器 A 解析的。

依赖提供者会使用 DI 令牌来配置注入器,注入器会用它来提供这个依赖值的具体的、运行时版本。 注入器依靠 "提供者配置" 来创建依赖的实例,并把该实例注入到组件、指令、管道和其它服务中。

你必须使用提供者来配置注入器,否则注入器就无法知道如何创建此依赖。 注入器创建服务实例的最简单方法,就是用这个服务类本身来创建它。 如果你把服务类作为此服务的 DI 令牌,注入器的默认行为就是 new 出这个类实例。

在下面这个典型的例子中,Logger 类自身提供了Logger` 的实例。

providers: [Logger]

不过,你也可以用一个替代提供者来配置注入器,这样就可以指定另一些同样能提供日志功能的对象。 比如:

  • 你可以提供一个替代类。

  • 你可以提供一个类似于 Logger 的对象。

  • 你的提供者可以调用一个工厂函数来创建 logger

Provider 对象字面量

类提供者的语法实际上是一种简写形式,它会扩展成一个由 Provider 接口定义的提供者配置对象。 下面的代码片段展示了 providers 中给出的类会如何扩展成完整的提供者配置对象。

providers: [Logger]

[{ provide: Logger, useClass: Logger }]

扩展的提供者配置是一个具有两个属性的对象字面量。

  • provide 属性存有令牌,它作为一个 key,在定位依赖值和配置注入器时使用。

  • 第二个属性是一个提供者定义对象,它告诉注入器要如何创建依赖值。 提供者定义对象中的 key 可以是 useClass —— 就像这个例子中一样。 也可以是 useExistinguseValueuseFactory。 每一个 key 都用于提供一种不同类型的依赖,我们稍后会讨论。

替代类提供者

不同的类都可用于提供相同的服务。 比如,下面的代码告诉注入器,当组件使用 Logger 令牌请求日志对象时,给它返回一个 BetterLogger 实例。

[{ provide: Logger, useClass: BetterLogger }]

扩展的提供者配置是一个具有两个属性的对象字面量。

  • provide 属性存有令牌,它作为一个 key,在定位依赖值和配置注入器时使用。

  • 第二个属性是一个提供者定义对象,它告诉注入器要如何创建依赖值。 提供者定义对象中的 key 可以是 useClass —— 就像这个例子中一样。 也可以是 useExistinguseValueuseFactory。 每一个 key 都用于提供一种不同类型的依赖,我们稍后会讨论。

替代类提供者

不同的类都可用于提供相同的服务。 比如,下面的代码告诉注入器,当组件使用 Logger 令牌请求日志对象时,给它返回一个 BetterLogger 实例。

[{ provide: Logger, useClass: BetterLogger }]

带依赖的类提供者

另一个类 EvenBetterLogger 可能要在日志信息里显示用户名。 这个 logger 要从注入的 UserService 实例中来获取该用户。

@Injectable()export class EvenBetterLogger extends Logger {  constructor(private userService: UserService) { super(); }  log(message: string) {    let name = this.userService.user.name;    super.log(`Message to ${name}: ${message}`);  }}

注入器需要提供这个新的日志服务以及该服务所依赖的 UserService 对象。 使用 useClass 作为提供者定义对象的 key,来配置一个 logger 的替代品,比如 BetterLogger。 下面的数组同时在父模块和组件的 providers 元数据选项中指定了这些提供者。

[ UserService,  { provide: Logger, useClass: EvenBetterLogger }]

别名类提供者

假设老的组件依赖于 OldLogger 类。OldLoggerNewLogger 的接口相同,但是由于某种原因,我们没法修改老的组件来使用 NewLogger

当老的组件要使用 OldLogger 记录信息时,你可能希望改用 NewLogger 的单例来处理它。 在这种情况下,无论某个组件请求老的 logger 还是新的 logger,依赖注入器都应该注入这个 NewLogger 的单例。 也就是说 OldLogger 应该是 NewLogger 的别名。

如果你试图用 useClassOldLogger 指定一个别名 NewLogger,就会在应用中得到 NewLogger 的两个不同的实例。

[ NewLogger,  // Not aliased! Creates two instances of `NewLogger`  { provide: OldLogger, useClass: NewLogger}]

要确保只有一个 NewLogger 实例,就要用 useExisting 来为 OldLogger 指定别名。

[ NewLogger,  // Alias OldLogger w/ reference to NewLogger  { provide: OldLogger, useExisting: NewLogger}]

值提供者

有时候,提供一个现成的对象会比要求注入器从类去创建更简单一些。 如果要注入一个你已经创建过的对象,请使用 useValue 选项来配置该注入器。

下面的代码定义了一个变量,用来创建这样一个能扮演 logger 角色的对象。

// An object in the shape of the logger servicefunction silentLoggerFn() {}export const SilentLogger = {  logs: ['Silent logger says "Shhhhh!". Provided via "useValue"'],  log: silentLoggerFn};

下面的提供者定义对象使用 useValue 作为 key 来把该变量与 Logger 令牌关联起来。

[{ provide: Logger, useValue: SilentLogger }]

非类依赖

并非所有的依赖都是类。 有时候你会希望注入字符串、函数或对象。

应用通常会用大量的小型参数来定义配置对象,比如应用的标题或 Web API 端点的地址。 这些配置对象不一定总是类的实例。 它们还可能是对象字面量,如下例所示。

Path:"src/app/app.config.ts (excerpt)" 。

export const HERO_DI_CONFIG: AppConfig = {  apiEndpoint: 'api.heroes.com',  title: 'Dependency Injection'};

TypeScript 接口不是有效的令牌

HERO_DI_CONFIG 常量满足 AppConfig 接口的要求。 不幸的是,你不能用 TypeScript 的接口作为令牌。 在 TypeScript 中,接口是一个设计期的概念,无法用作 DI 框架在运行期所需的令牌。

// FAIL! Can't use interface as provider token[{ provide: AppConfig, useValue: HERO_DI_CONFIG })]

// FAIL! Can't inject using the interface as the parameter typeconstructor(private config: AppConfig){ }

如果你曾经在强类型语言中使用过依赖注入功能,这一点可能看起来有点奇怪,那些语言都优先使用接口作为查找依赖的 key。 不过,JavaScript 没有接口,所以,当 TypeScript 转译成 JavaScript 时,接口也就消失了。 在运行期间,没有留下任何可供 Angular 进行查找的接口类型信息。

替代方案之一是以类似于 AppModule 的方式,在 NgModule 中提供并注入这个配置对象。

Path:"src/app/app.module.ts (providers)" 。

providers: [  UserService,  { provide: APP_CONFIG, useValue: HERO_DI_CONFIG }],

另一个为非类依赖选择提供者令牌的解决方案是定义并使用 InjectionToken 对象。 下面的例子展示了如何定义那样一个令牌。

Path:"src/app/app.config.ts" 。

import { InjectionToken } from '@angular/core';export const APP_CONFIG = new InjectionToken<AppConfig>('app.config');

虽然类型参数在这里是可选的,不过还是能把此依赖的类型信息传达给开发人员和开发工具。 这个令牌的描述则是开发人员的另一个助力。

使用 InjectionToken 对象注册依赖提供者:

Path:"src/app/app.component.ts" 。

constructor(@Inject(APP_CONFIG) config: AppConfig) {  this.title = config.title;}

虽然 AppConfig 接口在依赖注入时没有任何作用,但它可以为该组件类中的这个配置对象指定类型信息。

工厂提供者

有时候你需要动态创建依赖值,创建时需要的信息你要等运行期间才能拿到。 比如,你可能需要某个在浏览器会话过程中会被反复修改的信息,而且这个可注入服务还不能独立访问这个信息的源头。

这种情况下,你可以使用工厂提供者。 当需要从第三方库创建依赖项实例时,工厂提供者也很有用,因为第三方库不是为 DI 而设计的。

比如,假设 HeroService 必须对普通用户隐藏秘密英雄,只有得到授权的用户才能看到他们。

EvenBetterLogger 一样,HeroService 需要知道该用户是否有权查看秘密英雄。 而认证信息可能会在应用的单个会话中发生变化,比如你改用另一个用户登录。

假设你不希望直接把 UserService 注入到 HeroService 中,因为你不希望把这个服务与那些高度敏感的信息牵扯到一起。 这样 HeroService 就无法直接访问到用户信息,来决定谁有权访问,谁没有。

要解决这个问题,我们给 HeroService 的构造函数一个逻辑型标志,以控制是否显示秘密英雄。

Path:"src/app/heroes/hero.service.ts (excerpt)" 。

constructor(  private logger: Logger,  private isAuthorized: boolean) { }getHeroes() {  let auth = this.isAuthorized ? 'authorized ' : 'unauthorized';  this.logger.log(`Getting heroes for ${auth} user.`);  return HEROES.filter(hero => this.isAuthorized || !hero.isSecret);}

你可以注入 Logger 但是不能注入 isAuthorized 标志。不过你可以改用工厂提供者来为 HeroService 创建一个新的 logger 实例。

工厂提供者需要一个工厂函数。

Path:"src/app/heroes/hero.service.provider.ts (excerpt)" 。

let heroServiceFactory = (logger: Logger, userService: UserService) => {  return new HeroService(logger, userService.user.isAuthorized);};

虽然 HeroService 不能访问 UserService,但是工厂函数可以。 你把 LoggerUserService 注入到了工厂提供者中,并让注入器把它们传给这个工厂函数。

Path:"src/app/heroes/hero.service.provider.ts (excerpt)" 。

export let heroServiceProvider =  { provide: HeroService,    useFactory: heroServiceFactory,    deps: [Logger, UserService]  };

  • useFactory 字段告诉 Angular 该提供者是一个工厂函数,该函数的实现代码是 heroServiceFactory

  • deps 属性是一个提供者令牌数组。 LoggerUserService 类作为它们自己的类提供者令牌使用。 注入器解析这些令牌,并把与之对应的服务注入到相应的工厂函数参数表中。

注意,你把这个工厂提供者保存到了一个导出的变量 heroServiceProvider 中。 这个额外的步骤让工厂提供者可被复用。 你可以在任何需要它的地方用这个变量来配置 HeroService 的提供者。 在这个例子中,你只在 HeroesComponent 中用到了它。你在该组件元数据的 providers 数组中用 heroServiceProvider 替换了 HeroService

下面并列显示了新旧实现。

  1. Path:"src/app/heroes/heroes.component (v3)" 。

    import { Component }          from '@angular/core';    import { heroServiceProvider } from './hero.service.provider';    @Component({      selector: 'app-heroes',      providers: [ heroServiceProvider ],      template: `        <h2>Heroes</h2>        <app-hero-list></app-hero-list>      `    })    export class HeroesComponent { }

  1. Path:"src/app/heroes/heroes.component (v2)" 。

    import { Component } from '@angular/core';    import { HeroService } from './hero.service';    @Component({      selector: 'app-heroes',      providers: [ HeroService ],      template: `        <h2>Heroes</h2>        <app-hero-list></app-hero-list>      `    })    export class HeroesComponent { }

预定义令牌与多提供者

Angular 提供了一些内置的注入令牌常量,你可以用它们来自定义系统的多种行为。

比如,你可以使用下列内置令牌来切入 Angular 框架的启动和初始化过程。 提供者对象可以把任何一个注入令牌与一个或多个用来执行应用初始化操作的回调函数关联起来。

  • PLATFORM_INITIALIZER:平台初始化之后调用的回调函数。

  • APP_BOOTSTRAP_LISTENER:每个启动组件启动完成之后调用的回调函数。这个处理器函数会收到这个启动组件的 ComponentRef 实例。

  • APP_INITIALIZER:应用初始化之前调用的回调函数。注册的所有初始化器都可以(可选地)返回一个 Promise。所有返回 Promise 的初始化函数都必须在应用启动之前解析完。如果任何一个初始化器失败了,该应用就不会继续启动。

该提供者对象还有第三个选项 multi: true,把它和 APP_INITIALIZER 一起使用可以为特定的事件注册多个处理器。

比如,当启动应用时,你可以使用同一个令牌注册多个初始化器。

export const APP_TOKENS = [ { provide: PLATFORM_INITIALIZER, useFactory: platformInitialized, multi: true    }, { provide: APP_INITIALIZER, useFactory: delayBootstrapping, multi: true }, { provide: APP_BOOTSTRAP_LISTENER, useFactory: appBootstrapped, multi: true },];

在其它地方,多个提供者也同样可以和单个令牌关联起来。 比如,你可以使用内置的 NG_VALIDATORS 令牌注册自定义表单验证器,还可以在提供者定义对象中使用 multi: true 属性来为指定的验证器令牌提供多个验证器实例。 Angular 会把你的自定义验证器添加到现有验证器的集合中。

路由器也同样用多个提供者关联到了一个令牌。 当你在单个模块中用 RouterModule.forRootRouterModule.forChild 提供了多组路由时,ROUTES 令牌会把这些不同的路由组都合并成一个单一值。

可摇树优化的提供者

摇树优化是指一个编译器选项,意思是把应用中未引用过的代码从最终生成的包中移除。 如果提供者是可摇树优化的,Angular 编译器就会从最终的输出内容中移除应用代码中从未用过的服务。 这会显著减小你的打包体积。

理想情况下,如果应用没有注入服务,它就不应该包含在最终输出中。 不过,Angular 要能在构建期间识别出该服务是否需要。 由于还可能用 injector.get(Service) 的形式直接注入服务,所以 Angular 无法准确识别出代码中可能发生此注入的全部位置,因此为保险起见,只能把服务包含在注入器中。 因此,在 NgModule 或 组件级别提供的服务是无法被摇树优化掉的。

下面这个不可摇树优化的 Angular 提供者的例子为 NgModule 注入器配置了一个服务提供者。

Path:"src/app/tree-shaking/service-and-modules.ts" 。

import { Injectable, NgModule } from '@angular/core';@Injectable()export class Service {  doSomething(): void {  }}@NgModule({  providers: [Service],})export class ServiceModule {}

你可以把该模块导入到你的应用模块中,以便该服务可注入到你的应用中,例子如下。

Path:"src/app/tree-shaking/app.modules.ts" 。

@NgModule({  imports: [    BrowserModule,    RouterModule.forRoot([]),    ServiceModule,  ],})export class AppModule {}

当运行 ngc 时,它会把 AppModule 编译到模块工厂中,工厂包含该模块及其导入的所有模块中声明的所有提供者。在运行时,该工厂会变成负责实例化所有这些服务的注入器。

这里摇树优化不起作用,因为 Angular 无法根据是否用到了其它代码块(服务类),来决定是否能排除这块代码(模块工厂中的服务提供者定义)。要让服务可以被摇树优化,关于如何构建该服务实例的信息(即提供者定义),就应该是服务类本身的一部分。

创建可摇树优化的提供者

只要在服务本身的 @Injectable() 装饰器中指定,而不是在依赖该服务的 NgModule 或组件的元数据中指定,你就可以制作一个可摇树优化的提供者。

下面的例子展示了与上面的 ServiceModule 例子等价的可摇树优化的版本。

Path:"src/app/tree-shaking/service.ts" 。

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

该服务还可以通过配置工厂函数来实例化,如下例所示。

Path:"src/app/tree-shaking/service.0.ts" 。

@Injectable({  providedIn: 'root',  useFactory: () => new Service('dependency'),})export class Service {  constructor(private dep: string) {  }}

要想覆盖可摇树优化的提供者,请使用其它提供者来配置指定的 NgModule 或组件的注入器,只要使用 @NgModule()@Component() 装饰器中的 providers: [] 数组就可以了。

嵌套的服务依赖

这些被注入服务的消费者不需要知道如何创建这个服务。新建和缓存这个服务是依赖注入器的工作。消费者只要让依赖注入框架知道它需要哪些依赖项就可以了。

有时候一个服务依赖其它服务...而其它服务可能依赖另外的更多服务。 依赖注入框架会负责正确的顺序解析这些嵌套的依赖项。 在每一步,依赖的使用者只要在它的构造函数里简单声明它需要什么,框架就会完成所有剩下的事情。

下面的例子往 AppComponent 里声明它依赖 LoggerServiceUserContext

Path:"src/app/app.component.ts" 。

constructor(logger: LoggerService, public userContext: UserContextService) {  userContext.loadUser(this.userId);  logger.logInfo('AppComponent initialized');}

UserContext 转而依赖 LoggerServiceUserService(这个服务用来收集特定用户信息)。

Path:"user-context.service.ts (injection)" 。

@Injectable({  providedIn: 'root'})export class UserContextService {  constructor(private userService: UserService, private loggerService: LoggerService) {  }}

当 Angular 新建 AppComponent 时,依赖注入框架会先创建一个 LoggerService 的实例,然后创建 UserContextService 实例。 UserContextService 也需要框架刚刚创建的这个 LoggerService 实例,这样框架才能为它提供同一个实例。UserContextService 还需要框架创建过的 UserServiceUserService 没有其它依赖,所以依赖注入框架可以直接 new 出该类的一个实例,并把它提供给 UserContextService 的构造函数。

父组件 AppComponent 不需要了解这些依赖的依赖。 只要在构造函数中声明自己需要的依赖即可(这里是 LoggerServiceUserContextService),框架会帮你解析这些嵌套的依赖。

当所有的依赖都就位之后,AppComponent 就会显示该用户的信息。

把服务的范围限制到某个组件的子树下

Angular 应用程序有多个依赖注入器,组织成一个与组件树平行的树状结构。 每个注入器都会创建依赖的一个单例。在所有该注入器负责提供服务的地方,所提供的都是同一个实例。 可以在注入器树的任何层级提供和建立特定的服务。这意味着,如果在多个注入器中提供该服务,那么该服务也就会有多个实例。

由根注入器提供的依赖可以注入到应用中任何地方的任何组件中。 但有时候你可能希望把服务的有效性限制到应用程序的一个特定区域。 比如,你可能希望用户明确选择一个服务,而不是让根注入器自动提供它。

通过在组件树的子级根组件中提供服务,可以把一个被注入服务的作用域局限在应用程序结构中的某个分支中。 这个例子中展示了如何通过把服务添加到子组件 @Component() 装饰器的 providers 数组中,来为 HeroesBaseComponent 提供另一个 HeroService 实例:

Path:"src/app/sorted-heroes.component.ts (HeroesBaseComponent excerpt)" 。

@Component({  selector: 'app-unsorted-heroes',  template: `<div *ngFor="let hero of heroes">{{hero.name}}</div>`,  providers: [HeroService]})export class HeroesBaseComponent implements OnInit {  constructor(private heroService: HeroService) { }}

当 Angular 新建 HeroBaseComponent 的时候,它会同时新建一个 HeroService 实例,该实例只在该组件及其子组件(如果有)中可见。

也可以在应用程序别处的另一个组件里提供 HeroService。这样就会导致在另一个注入器中存在该服务的另一个实例。

这个例子中,局部化的 HeroService 单例,遍布整份范例代码,包括 HeroBiosComponentHeroOfTheMonthComponentHeroBaseComponent。 这些组件每个都有自己的 HeroService 实例,用来管理独立的英雄库。

多个服务实例(沙箱式隔离)

在组件树的同一个级别上,有时需要一个服务的多个实例。

一个用来保存其伴生组件的实例状态的服务就是个好例子。 每个组件都需要该服务的单独实例。 每个服务有自己的工作状态,与其它组件的服务和状态隔离。这叫做沙箱化,因为每个服务和组件实例都在自己的沙箱里运行。

在这个例子中,HeroBiosComponent 渲染了 HeroBioComponent 的三个实例。

Path:"ap/hero-bios.component.ts" 。

@Component({  selector: 'app-hero-bios',  template: `    <app-hero-bio [heroId]="1"></app-hero-bio>    <app-hero-bio [heroId]="2"></app-hero-bio>    <app-hero-bio [heroId]="3"></app-hero-bio>`,  providers: [HeroService]})export class HeroBiosComponent {}

每个 HeroBioComponent 都能编辑一个英雄的生平。HeroBioComponent 依赖 HeroCacheService 服务来对该英雄进行读取、缓存和执行其它持久化操作。

Path:"src/app/hero-cache.service.ts" 。

@Injectable()export class HeroCacheService {  hero: Hero;  constructor(private heroService: HeroService) {}  fetchCachedHero(id: number) {    if (!this.hero) {      this.hero = this.heroService.getHeroById(id);    }    return this.hero;  }}

这三个 HeroBioComponent 实例不能共享同一个 HeroCacheService 实例。否则它们会相互冲突,争相把自己的英雄放在缓存里面。

它们应该通过在自己的元数据(metadata)providers 数组里面列出 HeroCacheService, 这样每个 HeroBioComponent 就能拥有自己独立的 HeroCacheService 实例了。

Path:"src/app/hero-bio.component.ts" 。

@Component({  selector: 'app-hero-bio',  template: `    <h4>{{hero.name}}</h4>    <ng-content></ng-content>    <textarea cols="25" [(ngModel)]="hero.description"></textarea>`,  providers: [HeroCacheService]})export class HeroBioComponent implements OnInit  {  @Input() heroId: number;  constructor(private heroCache: HeroCacheService) { }  ngOnInit() { this.heroCache.fetchCachedHero(this.heroId); }  get hero() { return this.heroCache.hero; }}

父组件 HeroBiosComponent 把一个值绑定到 heroIdngOnInit 把该 id 传递到服务,然后服务获取和缓存英雄。hero 属性的 getter 从服务里面获取缓存的英雄,并在模板里显示它绑定到属性值。

确认三个 HeroBioComponent 实例拥有自己独立的英雄数据缓存。

使用参数装饰器来限定依赖查找方式

当类需要某个依赖项时,该依赖项就会作为参数添加到类的构造函数中。 当 Angular 需要实例化该类时,就会调用 DI 框架来提供该依赖。 默认情况下,DI 框架会在注入器树中查找一个提供者,从该组件的局部注入器开始,如果需要,则沿着注入器树向上冒泡,直到根注入器。

  • 第一个配置过该提供者的注入器就会把依赖(服务实例或值)提供给这个构造函数。

  • 如果在根注入器中也没有找到提供者,则 DI 框架将会抛出一个错误。

通过在类的构造函数中对服务参数使用参数装饰器,可以提供一些选项来修改默认的搜索行为。

用 @Optional 来让依赖是可选的,以及使用 @Host 来限定搜索方式

依赖可以注册在组件树的任何层级上。 当组件请求某个依赖时,Angular 会从该组件的注入器找起,沿着注入器树向上,直到找到了第一个满足要求的提供者。如果没找到依赖,Angular 就会抛出一个错误。

某些情况下,你需要限制搜索,或容忍依赖项的缺失。 你可以使用组件构造函数参数上的 @Host@Optional 这两个限定装饰器来修改 Angular 的搜索行为。

  • @Optional 属性装饰器告诉 Angular 当找不到依赖时就返回 null

  • @Host 属性装饰器会禁止在宿主组件以上的搜索。宿主组件通常就是请求该依赖的那个组件。 不过,当该组件投影进某个父组件时,那个父组件就会变成宿主。下面的例子中介绍了第二种情况。

如下例所示,这些装饰器可以独立使用,也可以同时使用。这个 HeroBiosAndContactsComponent 是你以前见过的那个 HeroBiosComponent 的修改版。

Path:"src/app/hero-bios.component.ts (HeroBiosAndContactsComponent)" 。

@Component({  selector: 'app-hero-bios-and-contacts',  template: `    <app-hero-bio [heroId]="1"> <app-hero-contact></app-hero-contact> </app-hero-bio>    <app-hero-bio [heroId]="2"> <app-hero-contact></app-hero-contact> </app-hero-bio>    <app-hero-bio [heroId]="3"> <app-hero-contact></app-hero-contact> </app-hero-bio>`,  providers: [HeroService]})export class HeroBiosAndContactsComponent {  constructor(logger: LoggerService) {    logger.logInfo('Creating HeroBiosAndContactsComponent');  }}

注意看模板:

Path:"dependency-injection-in-action/src/app/hero-bios.component.ts" 。

template: `  <app-hero-bio [heroId]="1"> <app-hero-contact></app-hero-contact> </app-hero-bio>  <app-hero-bio [heroId]="2"> <app-hero-contact></app-hero-contact> </app-hero-bio>  <app-hero-bio [heroId]="3"> <app-hero-contact></app-hero-contact> </app-hero-bio>`,

<hero-bio> 标签中是一个新的 <hero-contact> 元素。Angular 就会把相应的 HeroContactComponent投影(transclude)进 HeroBioComponent 的视图里, 将它放在 HeroBioComponent 模板的 <ng-content> 标签槽里。

Path:"src/app/hero-bio.component.ts (template)" 。

template: `  <h4>{{hero.name}}</h4>  <ng-content></ng-content>  <textarea cols="25" [(ngModel)]="hero.description"></textarea>`,

HeroContactComponent 获得的英雄电话号码,被投影到上面的英雄描述里,结果如下:

这里的 HeroContactComponent 演示了限定型装饰器。

Path:"src/app/hero-contact.component.ts" 。

@Component({  selector: 'app-hero-contact',  template: `  <div>Phone #: {{phoneNumber}}  <span *ngIf="hasLogger">!!!</span></div>`})export class HeroContactComponent {  hasLogger = false;  constructor(      @Host() // limit to the host component's instance of the HeroCacheService      private heroCache: HeroCacheService,      @Host()     // limit search for logger; hides the application-wide logger      @Optional() // ok if the logger doesn't exist      private loggerService?: LoggerService  ) {    if (loggerService) {      this.hasLogger = true;      loggerService.logInfo('HeroContactComponent can log!');    }  }  get phoneNumber() { return this.heroCache.hero.phone; }}

注意构造函数的参数。

Path:"src/app/hero-contact.component.ts" 。

@Host() // limit to the host component's instance of the HeroCacheServiceprivate heroCache: HeroCacheService,@Host()     // limit search for logger; hides the application-wide logger@Optional() // ok if the logger doesn't existprivate loggerService?: LoggerService

@Host() 函数是构造函数属性 heroCache 的装饰器,确保从其父组件 HeroBioComponent 得到一个缓存服务。如果该父组件中没有该服务,Angular 就会抛出错误,即使组件树里的再上级有某个组件拥有这个服务,还是会抛出错误。

另一个 @Host() 函数是构造函数属性 loggerService 的装饰器。 在本应用程序中只有一个在 AppComponent 级提供的 LoggerService 实例。 该宿主 HeroBioComponent 没有自己的 LoggerService 提供者。

如果没有同时使用 @Optional() 装饰器的话,Angular 就会抛出错误。当该属性带有 @Optional() 标记时,Angular 就会把 loggerService 设置为 null,并继续执行组件而不会抛出错误。

下面是 HeroBiosAndContactsComponent 的执行结果:

如果注释掉 @Host() 装饰器,Angular 就会沿着注入器树往上走,直到在 AppComponent 中找到该日志服务。日志服务的逻辑加了进来,所显示的英雄信息增加了 "!!!" 标记,这表明确实找到了日志服务。

如果你恢复了 @Host() 装饰器,并且注释掉 @Optional 装饰器,应用就会抛出一个错误,因为它在宿主组件这一层找不到所需的 Logger。EXCEPTION: No provider for LoggerService! (HeroContactComponent -> LoggerService)

使用 @Inject 指定自定义提供者

自定义提供者让你可以为隐式依赖提供一个具体的实现,比如内置浏览器 API。下面的例子使用 InjectionToken 来提供 localStorage,将其作为 BrowserStorageService 的依赖项。

Path:"src/app/storage.service.ts" 。

import { Inject, Injectable, InjectionToken } from '@angular/core';export const BROWSER_STORAGE = new InjectionToken<Storage>('Browser Storage', {  providedIn: 'root',  factory: () => localStorage});@Injectable({  providedIn: 'root'})export class BrowserStorageService {  constructor(@Inject(BROWSER_STORAGE) public storage: Storage) {}  get(key: string) {    this.storage.getItem(key);  }  set(key: string, value: string) {    this.storage.setItem(key, value);  }  remove(key: string) {    this.storage.removeItem(key);  }  clear() {    this.storage.clear();  }}

factory 函数返回 window 对象上的 localStorage 属性。Inject 装饰器修饰一个构造函数参数,用于为某个依赖提供自定义提供者。现在,就可以在测试期间使用 localStorage 的 Mock API 来覆盖这个提供者了,而不必与真实的浏览器 API 进行交互。

使用 @Self 和 @SkipSelf 来修改提供者的搜索方式

注入器也可以通过构造函数的参数装饰器来指定范围。下面的例子就在 Component 类的 providers 中使用浏览器的 sessionStorage API 覆盖了 BROWSER_STORAGE 令牌。同一个 BrowserStorageService 在构造函数中使用 @Self@SkipSelf 装饰器注入了两次,来分别指定由哪个注入器来提供依赖。

Path:"src/app/storage.component.ts" 。

import { Component, OnInit, Self, SkipSelf } from '@angular/core';import { BROWSER_STORAGE, BrowserStorageService } from './storage.service';@Component({  selector: 'app-storage',  template: `    Open the inspector to see the local/session storage keys:    <h3>Session Storage</h3>    <button (click)="setSession()">Set Session Storage</button>    <h3>Local Storage</h3>    <button (click)="setLocal()">Set Local Storage</button>  `,  providers: [    BrowserStorageService,    { provide: BROWSER_STORAGE, useFactory: () => sessionStorage }  ]})export class StorageComponent implements OnInit {  constructor(    @Self() private sessionStorageService: BrowserStorageService,    @SkipSelf() private localStorageService: BrowserStorageService,  ) { }  ngOnInit() {  }  setSession() {    this.sessionStorageService.set('hero', 'Dr Nice - Session');  }  setLocal() {    this.localStorageService.set('hero', 'Dr Nice - Local');  }}

使用 @Self 装饰器时,注入器只在该组件的注入器中查找提供者。@SkipSelf 装饰器可以让你跳过局部注入器,并在注入器树中向上查找,以发现哪个提供者满足该依赖。 sessionStorageService 实例使用浏览器的 sessionStorage 来跟 BrowserStorageService 打交道,而 localStorageService 跳过了局部注入器,使用根注入器提供的 BrowserStorageService,它使用浏览器的 localStorage API。

注入组件的 DOM 元素

即便开发者极力避免,仍然会有很多视觉效果和第三方工具 (比如 jQuery) 需要访问 DOM。这会让你不得不访问组件所在的 DOM 元素。

为了说明这一点,请看属性型指令中那个 HighlightDirective 的简化版。

Path:"src/app/highlight.directive.ts" 。

import { Directive, ElementRef, HostListener, Input } from '@angular/core';@Directive({  selector: '[appHighlight]'})export class HighlightDirective {  @Input('appHighlight') highlightColor: string;  private el: HTMLElement;  constructor(el: ElementRef) {    this.el = el.nativeElement;  }  @HostListener('mouseenter') onMouseEnter() {    this.highlight(this.highlightColor || 'cyan');  }  @HostListener('mouseleave') onMouseLeave() {    this.highlight(null);  }  private highlight(color: string) {    this.el.style.backgroundColor = color;  }}

当用户把鼠标移到 DOM 元素上时,指令将指令所在的元素的背景设置为一个高亮颜色。

Angular 把构造函数参数 el 设置为注入的 ElementRef,该 ElementRef 代表了宿主的 DOM 元素,它的 nativeElement 属性把该 DOM 元素暴露给了指令。

下面的代码把指令的 myHighlight 属性(Attribute)填加到两个 <div> 标签里,一个没有赋值,一个赋值了颜色。

Path:"src/app/app.component.html (highlight)" 。

<div id="highlight"  class="di-component"  appHighlight>  <h3>Hero Bios and Contacts</h3>  <div appHighlight="yellow">    <app-hero-bios-and-contacts></app-hero-bios-and-contacts>  </div></div>

下图显示了鼠标移到 <hero-bios-and-contacts> 标签上的效果:

使用提供者来定义依赖

为了从依赖注入器中获取服务,你必须传给它一个令牌。 Angular 通常会通过指定构造函数参数以及参数的类型来处理它。 参数的类型可以用作注入器的查阅令牌。 Angular 会把该令牌传给注入器,并把它的结果赋给相应的参数。

下面是一个典型的例子。

Path:"src/app/hero-bios.component.ts (component constructor injection)" 。

constructor(logger: LoggerService) {  logger.logInfo('Creating HeroBiosComponent');}

Angular 会要求注入器提供与 LoggerService 相关的服务,并把返回的值赋给 logger 参数。

如果注入器已经缓存了与该令牌相关的服务实例,那么它就会直接提供此实例。 如果它没有,它就要使用与该令牌相关的提供者来创建一个。

如果注入器无法根据令牌在自己内部找到对应的提供者,它便将请求移交给它的父级注入器,这个过程不断重复,直到没有更多注入器为止。 如果没找到,注入器就抛出一个错误...除非这个请求是可选的。

新的注入器没有提供者。 Angular 会使用一组首选提供者来初始化它本身的注入器。 你必须为自己应用程序特有的依赖项来配置提供者。

定义提供者

用于实例化类的默认方法不一定总适合用来创建依赖。你可以到依赖提供者部分查看其它方法。 HeroOfTheMonthComponent 例子示范了一些替代方案,展示了为什么需要它们。 它看起来很简单:一些属性和一些由 logger 生成的日志。

它背后的代码定制了 DI 框架提供依赖项的方法和位置。 这个例子阐明了通过提供对象字面量来把对象的定义和 DI 令牌关联起来的另一种方式。

Path:"hero-of-the-month.component.ts" 。

import { Component, Inject } from '@angular/core';import { DateLoggerService } from './date-logger.service';import { Hero }              from './hero';import { HeroService }       from './hero.service';import { LoggerService }     from './logger.service';import { MinimalLogger }     from './minimal-logger.service';import { RUNNERS_UP,         runnersUpFactory }  from './runners-up';@Component({  selector: 'app-hero-of-the-month',  templateUrl: './hero-of-the-month.component.html',  providers: [    { provide: Hero,          useValue:    someHero },    { provide: TITLE,         useValue:   'Hero of the Month' },    { provide: HeroService,   useClass:    HeroService },    { provide: LoggerService, useClass:    DateLoggerService },    { provide: MinimalLogger, useExisting: LoggerService },    { provide: RUNNERS_UP,    useFactory:  runnersUpFactory(2), deps: [Hero, HeroService] }  ]})export class HeroOfTheMonthComponent {  logs: string[] = [];  constructor(      logger: MinimalLogger,      public heroOfTheMonth: Hero,      @Inject(RUNNERS_UP) public runnersUp: string,      @Inject(TITLE) public title: string)  {    this.logs = logger.logs;    logger.logInfo('starting up');  }}

providers 数组展示了你可以如何使用其它的键来定义提供者:useValueuseClassuseExistinguseFactory

值提供者:useValue

useValue 键让你可以为 DI 令牌关联一个固定的值。 使用该技巧来进行运行期常量设置,比如网站的基础地址和功能标志等。 你也可以在单元测试中使用值提供者,来用一个 Mock 数据来代替一个生产环境下的数据服务。

HeroOfTheMonthComponent 例子中有两个值-提供者。

Path:"dependency-injection-in-action/src/app/hero-of-the-month.component.ts" 。

{ provide: Hero,          useValue:    someHero },{ provide: TITLE,         useValue:   'Hero of the Month' },

  • 第一处提供了用于 Hero 令牌的 Hero 类的现有实例,而不是要求注入器使用 new 来创建一个新实例或使用它自己的缓存实例。这里令牌就是这个类本身。

  • 第二处为 TITLE 令牌指定了一个字符串字面量资源。 TITLE 提供者的令牌不是一个类,而是一个特别的提供者查询键,名叫InjectionToken,表示一个 InjectionToken 实例。

你可以把 InjectionToken 用作任何类型的提供者的令牌,但是当依赖是简单类型(比如字符串、数字、函数)时,它会特别有用。

一个值-提供者的值必须在指定之前定义。 比如标题字符串就是立即可用的。 该例中的 someHero 变量是以前在如下的文件中定义的。 你不能使用那些要等以后才能定义其值的变量。

Path:"dependency-injection-in-action/src/app/hero-of-the-month.component.ts" 。

const someHero = new Hero(42, 'Magma', 'Had a great month!', '555-555-5555');

其它类型的提供者都会惰性创建它们的值,也就是说只在需要注入它们的时候才创建。

类提供者:useClass

useClass 提供的键让你可以创建并返回指定类的新实例。

你可以使用这类提供者来为公共类或默认类换上一个替代实现。比如,这个替代实现可以实现一种不同的策略来扩展默认类,或在测试环境中模拟真实类的行为。

请看下面 HeroOfTheMonthComponent 里的两个例子:

Path:"dependency-injection-in-action/src/app/hero-of-the-month.component.ts" 。

{ provide: HeroService,   useClass:    HeroService },{ provide: LoggerService, useClass:    DateLoggerService },

第一个提供者是展开了语法糖的,是一个典型情况的展开。一般来说,被新建的类(HeroService)同时也是该提供者的注入令牌。 通常都选用缩写形式,完整形式可以让细节更明确。

第二个提供者使用 DateLoggerService 来满足 LoggerService。该 LoggerServiceAppComponent 级别已经被注册。当这个组件要求 LoggerService 的时候,它得到的却是 DateLoggerService 服务的实例。

这个组件及其子组件会得到 DateLoggerService 实例。这个组件树之外的组件得到的仍是 LoggerService 实例。

DateLoggerServiceLoggerService 继承;它把当前的日期/时间附加到每条信息上。

Path:"src/app/date-logger.service.ts" 。

@Injectable({  providedIn: 'root'})export class DateLoggerService extends LoggerService{  logInfo(msg: any)  { super.logInfo(stamp(msg)); }  logDebug(msg: any) { super.logInfo(stamp(msg)); }  logError(msg: any) { super.logError(stamp(msg)); }}function stamp(msg: any) { return msg + ' at ' + new Date(); }

别名提供者:useExisting

useExisting 提供了一个键,让你可以把一个令牌映射成另一个令牌。实际上,第一个令牌就是第二个令牌所关联的服务的别名,这样就创建了访问同一个服务对象的两种途径。

Path:"dependency-injection-in-action/src/app/hero-of-the-month.component.ts" 。

{ provide: MinimalLogger, useExisting: LoggerService },

你可以使用别名接口来窄化 API。下面的例子中使用别名就是为了这个目的。

想象 LoggerService 有个很大的 API 接口,远超过现有的三个方法和一个属性。你可能希望把 API 接口收窄到只有两个你确实需要的成员。在这个例子中,MinimalLogger类-接口,就这个 API 成功缩小到了只有两个成员:

Path:"src/app/minimal-logger.service.ts" 。

// Class used as a "narrowing" interface that exposes a minimal logger// Other members of the actual implementation are invisibleexport abstract class MinimalLogger {  logs: string[];  logInfo: (msg: string) => void;}

下面的例子在一个简化版的 HeroOfTheMonthComponent 中使用 MinimalLogger

Path:"src/app/hero-of-the-month.component.ts (minimal version)" 。

@Component({  selector: 'app-hero-of-the-month',  templateUrl: './hero-of-the-month.component.html',  // TODO: move this aliasing, `useExisting` provider to the AppModule  providers: [{ provide: MinimalLogger, useExisting: LoggerService }]})export class HeroOfTheMonthComponent {  logs: string[] = [];  constructor(logger: MinimalLogger) {    logger.logInfo('starting up');  }}

HeroOfTheMonthComponent 构造函数的 logger 参数是一个 MinimalLogger 类型,在支持 TypeScript 感知的编辑器里,只能看到它的两个成员 logslogInfo

实际上,Angularlogger 参数设置为注入器里 LoggerService 令牌下注册的完整服务,该令牌恰好是以前提供的那个 DateLoggerService 实例。

在下面的图片中,显示了日志日期,可以确认这一点:

工厂提供者:useFactory

Path:"dependency-injection-in-action/src/app/hero-of-the-month.component.ts" 。

{ provide: RUNNERS_UP,    useFactory:  runnersUpFactory(2), deps: [Hero, HeroService] }

注入器通过调用你用 useFactory 键指定的工厂函数来提供该依赖的值。 注意,提供者的这种形态还有第三个键 deps,它指定了供 useFactory 函数使用的那些依赖。

使用这项技术,可以用包含了一些依赖服务和本地状态输入的工厂函数来建立一个依赖对象。

这个依赖对象(由工厂函数返回的)通常是一个类实例,不过也可以是任何其它东西。 在这个例子中,依赖对象是一个表示 "月度英雄" 参赛者名称的字符串。

在这个例子中,局部状态是数字 2,也就是组件应该显示的参赛者数量。 该状态的值传给了 runnersUpFactory() 作为参数。 runnersUpFactory() 返回了提供者的工厂函数,它可以使用传入的状态值和注入的服务 HeroHeroService

Path:"runners-up.ts (excerpt)" 。

export function runnersUpFactory(take: number) {  return (winner: Hero, heroService: HeroService): string => {    /* ... */  };};

runnersUpFactory() 返回的提供者的工厂函数返回了实际的依赖对象,也就是表示名字的字符串。

  • 这个返回的函数需要一个 Hero 和一个 HeroService 参数。

Angular 根据 deps 数组中指定的两个令牌来提供这些注入参数。

  • 该函数返回名字的字符串,Angular 可以把它们注入到 HeroOfTheMonthComponentrunnersUp 参数中。

该函数从 HeroService 中接受候选的英雄,从中取 2 个参加竞赛,并把他们的名字串接起来返回。

提供替代令牌:类接口与 'InjectionToken'

当使用类作为令牌,同时也把它作为返回依赖对象或服务的类型时,Angular 依赖注入使用起来最容易。

但令牌不一定都是类,就算它是一个类,它也不一定都返回类型相同的对象。这是下一节的主题。

类-接口

前面的月度英雄的例子使用了 MinimalLogger 类作为 LoggerService 提供者的令牌。

Path:"dependency-injection-in-action/src/app/hero-of-the-month.component.ts" 。

{ provide: MinimalLogger, useExisting: LoggerService },

MinimalLogger 是一个抽象类。

Path:"dependency-injection-in-action/src/app/minimal-logger.service.ts" 。

// Class used as a "narrowing" interface that exposes a minimal logger// Other members of the actual implementation are invisibleexport abstract class MinimalLogger {  logs: string[];  logInfo: (msg: string) => void;}

你通常从一个可扩展的抽象类继承。但这个应用中并没有类会继承 MinimalLogger

LoggerServiceDateLoggerService本可以从 MinimalLogger 中继承。 它们也可以实现 MinimalLogger,而不用单独定义接口。 但它们没有。 MinimalLogger 在这里仅仅被用作一个 "依赖注入令牌"。

当你通过这种方式使用类时,它称作类接口。

就像 DI 提供者中提到的那样,接口不是有效的 DI 令牌,因为它是 TypeScript 自己用的,在运行期间不存在。使用这种抽象类接口不但可以获得像接口一样的强类型,而且可以像普通类一样把它用作提供者令牌。

类接口应该只定义允许它的消费者调用的成员。窄的接口有助于解耦该类的具体实现和它的消费者。

用类作为接口可以让你获得真实 JavaScript 对象中的接口的特性。 但是,为了最小化内存开销,该类应该是没有实现的。 对于构造函数,MinimalLogger 会转译成未优化过的、预先最小化过的 JavaScript。

Path:"dependency-injection-in-action/src/app/minimal-logger.service.ts" 。

var MinimalLogger = (function () {  function MinimalLogger() {}  return MinimalLogger;}());exports("MinimalLogger", MinimalLogger);

注:

只要不实现它,不管添加多少成员,它都不会增长大小,因为这些成员虽然是有类型的,但却没有实现。

你可以再看看 TypeScript 的 MinimalLogger 类,确定一下它是没有实现的。

'InjectionToken' 对象

依赖对象可以是一个简单的值,比如日期,数字和字符串,或者一个无形的对象,比如数组和函数。

这样的对象没有应用程序接口,所以不能用一个类来表示。更适合表示它们的是:唯一的和符号性的令牌,一个 JavaScript 对象,拥有一个友好的名字,但不会与其它的同名令牌发生冲突。

InjectionToken 具有这些特征。在Hero of the Month例子中遇见它们两次,一个是 title 的值,一个是 runnersUp 工厂提供者。

Path:"dependency-injection-in-action/src/app/hero-of-the-month.component.ts" 。

{ provide: TITLE,         useValue:   'Hero of the Month' },{ provide: RUNNERS_UP,    useFactory:  runnersUpFactory(2), deps: [Hero, HeroService] }

这样创建 TITLE 令牌:

Path:"dependency-injection-in-action/src/app/hero-of-the-month.component.ts" 。

import { InjectionToken } from '@angular/core';export const TITLE = new InjectionToken<string>('title');

类型参数,虽然是可选的,但可以向开发者和开发工具传达类型信息。 而且这个令牌的描述信息也可以为开发者提供帮助。

注入到派生类

当编写一个继承自另一个组件的组件时,要格外小心。如果基础组件有依赖注入,必须要在派生类中重新提供和重新注入它们,并将它们通过构造函数传给基类。

在这个刻意生成的例子里,SortedHeroesComponent 继承自 HeroesBaseComponent,显示一个被排序的英雄列表。

HeroesBaseComponent 能自己独立运行。它在自己的实例里要求 HeroService,用来得到英雄,并将他们按照数据库返回的顺序显示出来。

Path:"src/app/sorted-heroes.component.ts (HeroesBaseComponent)" 。

@Component({  selector: 'app-unsorted-heroes',  template: `<div *ngFor="let hero of heroes">{{hero.name}}</div>`,  providers: [HeroService]})export class HeroesBaseComponent implements OnInit {  constructor(private heroService: HeroService) { }  heroes: Array<Hero>;  ngOnInit() {    this.heroes = this.heroService.getAllHeroes();    this.afterGetHeroes();  }  // Post-process heroes in derived class override.  protected afterGetHeroes() {}}

让构造函数保持简单

构造函数应该只用来初始化变量。 这条规则让组件在测试环境中可以放心地构造组件,以免在构造它们时,无意中做出一些非常戏剧化的动作(比如与服务器进行会话)。 这就是为什么你要在 ngOnInit 里面调用 HeroService,而不是在构造函数中。

用户希望看到英雄按字母顺序排序。与其修改原始的组件,不如派生它,新建 SortedHeroesComponent,以便展示英雄之前进行排序。 SortedHeroesComponent 让基类来获取英雄。

可惜,Angular 不能直接在基类里直接注入 HeroService。必须在这个组件里再次提供 HeroService,然后通过构造函数传给基类。

Path:"src/app/sorted-heroes.component.ts (SortedHeroesComponent)" 。

@Component({  selector: 'app-sorted-heroes',  template: `<div *ngFor="let hero of heroes">{{hero.name}}</div>`,  providers: [HeroService]})export class SortedHeroesComponent extends HeroesBaseComponent {  constructor(heroService: HeroService) {    super(heroService);  }  protected afterGetHeroes() {    this.heroes = this.heroes.sort((h1, h2) => {      return h1.name < h2.name ? -1 :            (h1.name > h2.name ? 1 : 0);    });  }}

现在,请注意 afterGetHeroes() 方法。 你的第一反应是在 SortedHeroesComponent 组件里面建一个 ngOnInit 方法来做排序。但是 Angular 会先调用派生类的 ngOnInit,后调用基类的 ngOnInit, 所以可能在英雄到达之前就开始排序。这就产生了一个讨厌的错误。

覆盖基类的 afterGetHeroes() 方法可以解决这个问题。

分析上面的这些复杂性是为了强调避免使用组件继承这一点。

使用一个前向引用(forwardRef)来打破循环

在 TypeScript 里面,类声明的顺序是很重要的。如果一个类尚未定义,就不能引用它。

这通常不是一个问题,特别是当你遵循一个文件一个类规则的时候。 但是有时候循环引用可能不能避免。当一个类A 引用类 B,同时'B'引用'A'的时候,你就陷入困境了:它们中间的某一个必须要先定义。

Angular 的 forwardRef() 函数建立一个间接地引用,Angular 可以随后解析。

这个关于父查找器的例子中全都是没办法打破的循环类引用。

当一个类需要引用自身的时候,你面临同样的困境,就像在 AlexComponentprovdiers 数组中遇到的困境一样。 该 providers 数组是一个 @Component() 装饰器函数的一个属性,它必须在类定义之前出现。

使用 forwardRef 来打破这种循环:

Path:"parent-finder.component.ts (AlexComponent providers)" 。

providers: [{ provide: Parent, useExisting: forwardRef(() => AlexComponent) }],

应用的组件之间经常需要共享信息。你通常要用松耦合的技术来共享信息,比如数据绑定和服务共享。但是有时候让一个组件直接引用另一个组件还是很有意义的。 例如,你需要通过另一个组件的直接引用来访问其属性或调用其方法。

在 Angular 中获取组件引用略微有些棘手。 Angular 组件本身并没有一棵可以用编程方式检查或浏览的树。 其父子关系是通过组件的视图对象间接建立的。

每个组件都有一个宿主视图和一些内嵌视图。 组件 A 的内嵌视图可以是组件 B 的宿主视图,而组件 B 还可以有它自己的内嵌视图。 这意味着每个组件都有一棵以该组件的宿主视图为根节点的视图树。

有一些用于在视图树中向下导航的 API。 请到 API 参考手册中查看 Query、QueryList、ViewChildren 和 ContentChildren。

不存在用于获取父引用的公共 API。 不过,由于每个组件的实例都会添加到注入器的容器中,因此你可以通过 Angular 的依赖注入来访问父组件。

本节描述的就是关于这种做法的一些技巧。

查找已知类型的父组件

你可以使用标准的类注入形式来获取类型已知的父组件。

在下面的例子中,父组件 AlexComponent 具有一些子组件,包括 CathyComponent

Path:"parent-finder.component.ts (AlexComponent v.1)" 。

@Component({  selector: 'alex',  template: `    <div class="a">      <h3>{{name}}</h3>      <cathy></cathy>      <craig></craig>      <carol></carol>    </div>`,})export class AlexComponent extends Base{  name = 'Alex';}

在把 AlexComponent 注入到 CathyComponent 的构造函数中之后,Cathy 可以报告她是否能访问 Alex

Path:"parent-finder.component.ts (CathyComponent)" 。

@Component({  selector: 'cathy',  template: `  <div class="c">    <h3>Cathy</h3>    {{alex ? 'Found' : 'Did not find'}} Alex via the component class.<br>  </div>`})export class CathyComponent {  constructor( @Optional() public alex?: AlexComponent ) { }}

注意,虽然为了安全起见我们用了 @Optional 限定符,但是范例中仍然会确认 alex 参数是否有值。

不能根据父组件的基类访问父组件

如果你不知道具体的父组件类怎么办?

可复用组件可能是多个组件的子组件。想象一个用于渲染相关金融工具的突发新闻的组件。 出于商业原因,当市场上的数据流发生变化时,这些新组件会频繁调用其父组件。

该应用可能定义了十几个金融工具组件。理想情况下,它们全都实现了同一个基类,你的 NewsComponent 也能理解其 API。

如果能查找实现了某个接口的组件当然更好。 但那是不可能的。因为 TypeScript 接口在转译后的 JavaScript 中不存在,而 JavaScript 不支持接口。 因此,找无可找。

这个设计并不怎么好。 该例子是为了验证组件是否能通过其父组件的基类来注入父组件。

这个例子中的 CraigComponent 体现了此问题。往回看,你可以看到 Alex 组件扩展(继承)了基类 Base。

Path:"parent-finder.component.ts (Alex class signature)" 。

export class AlexComponent extends Base

CraigComponent 试图把 Base 注入到它的构造函数参数 alex 中,并汇报这次注入是否成功了。

Path:"parent-finder.component.ts (CraigComponent)" 。

@Component({  selector: 'craig',  template: `  <div class="c">    <h3>Craig</h3>    {{alex ? 'Found' : 'Did not find'}} Alex via the base class.  </div>`})export class CraigComponent {  constructor( @Optional() public alex?: Base ) { }}

不幸的是,这不行! 范例确认了 alex 参数为空。 因此,你不能通过父组件的基类注入它。

根据父组件的类接口查找它

你可以通过父组件的类接口来查找它。

该父组件必须合作,以类接口令牌为名,为自己定义一个别名提供者。

回忆一下,Angular 总是会把组件实例添加到它自己的注入器中,因此以前你才能把 Alex 注入到 Cathy 中。

编写一个 别名提供者(一个 provide 对象字面量,其中有一个 useExisting 定义),创造了另一种方式来注入同一个组件实例,并把那个提供者添加到 AlexComponent @Component() 元数据的 providers 数组中。

Path:"parent-finder.component.ts (AlexComponent providers)" 。

providers: [{ provide: Parent, useExisting: forwardRef(() => AlexComponent) }],

Parent 是该提供者的类接口。 forwardRef 用于打破循环引用,因为在你刚才这个定义中 AlexComponent 引用了自身。

Alex 的第三个子组件 Carol,把其父组件注入到了自己的 parent 参数中 —— 和你以前做过的一样。

Path:"parent-finder.component.ts (CarolComponent class)" 。

export class CarolComponent {  name = 'Carol';  constructor( @Optional() public parent?: Parent ) { }}

下面是 Alex 及其家人的运行效果。

使用 @SkipSelf() 在树中查找父组件

想象一下组件树的一个分支:Alice -> Barry -> Carol。 无论 Alice 还是 Barry 都实现了类接口 Parent

Barry 很为难。他需要访问他的母亲 Alice,同时他自己还是 Carol 的父亲。 这意味着他必须同时注入 Parent 类接口来找到 Alice,同时还要提供一个 Parent 来满足 Carol 的要求。

Barry 的代码如下。

Path:"parent-finder.component.ts (BarryComponent)" 。

const templateB = `  <div class="b">    <div>      <h3>{{name}}</h3>      <p>My parent is {{parent?.name}}</p>    </div>    <carol></carol>    <chris></chris>  </div>`;@Component({  selector:   'barry',  template:   templateB,  providers:  [{ provide: Parent, useExisting: forwardRef(() => BarryComponent) }]})export class BarryComponent implements Parent {  name = 'Barry';  constructor( @SkipSelf() @Optional() public parent?: Parent ) { }}

Barryproviders 数组看起来和 Alex 的一样。 如果你准备继续像这样编写别名提供者,就应该创建一个辅助函数。

现在,注意看 Barry 的构造函数。

//Barry's constructorconstructor( @SkipSelf() @Optional() public parent?: Parent ) { }

//Carol's constructorconstructor( @Optional() public parent?: Parent ) { }

除增加了 @SkipSelf 装饰器之外,它和 Carol 的构造函数相同。

使用 @SkipSelf 有两个重要原因:

它告诉注入器开始从组件树中高于自己的位置(也就是父组件)开始搜索 Parent 依赖。

如果你省略了 @SkipSelf 装饰器,Angular 就会抛出循环依赖错误。

Cannot instantiate cyclic dependency! (BethComponent -> Parent -> BethComponent)

下面是 Alice、Barry 及其家人的运行效果。

父类接口

类接口是一个抽象类,它实际上用做接口而不是基类。

下面的例子定义了一个类接口 Parent。

Path:"parent-finder.component.ts (Parent class-interface)" 。

export abstract class Parent { name: string; }

Parent 类接口定义了一个带类型的 name 属性,但没有实现它。 这个 name 属性是父组件中唯一可供子组件调用的成员。 这样的窄化接口帮助把子组件从它的父组件中解耦出来。

一个组件想要作为父组件使用,就应该像 AliceComponent 那样实现这个类接口。

Path:"parent-finder.component.ts (AliceComponent class signature)" 。

export class AliceComponent implements Parent

这样做可以增加代码的清晰度,但在技术上并不是必要的。 虽然 AlexComponentBase 类所要求的一样具有 name 属性,但它的类签名中并没有提及 Parent

Path:"parent-finder.component.ts (AlexComponent class signature)" 。

export class AlexComponent extends Base

provideParent() 辅助函数

你很快就会厌倦为同一个父组件编写别名提供者的变体形式,特别是带有 forwardRef 的那种。

Path:"dependency-injection-in-action/src/app/parent-finder.component.ts" 。

providers: [{ provide: Parent, useExisting: forwardRef(() => AlexComponent) }],

你可以像把这些逻辑抽取到辅助函数中,就像这样。

Path:"dependency-injection-in-action/src/app/parent-finder.component.ts" 。

// Helper method to provide the current component instance in the name of a `parentType`.export function provideParent  (component: any) {    return { provide: Parent, useExisting: forwardRef(() => component) };  }

现在,你可以为组件添加一个更简单、更有意义的父组件提供者。

Path:"dependency-injection-in-action/src/app/parent-finder.component.ts" 。

providers:  [ provideParent(AliceComponent) ]

你还可以做得更好。当前版本的辅助函数只能为类接口 Parent 定义别名。 应用可能具有多种父组件类型,每个父组件都有自己的类接口令牌。

这是一个修订后的版本,它默认为 parent,但是也能接受另一个父类接口作为可选的第二参数。

Path:"dependency-injection-in-action/src/app/parent-finder.component.ts" 。

// Helper method to provide the current component instance in the name of a `parentType`.// The `parentType` defaults to `Parent` when omitting the second parameter.export function provideParent  (component: any, parentType?: any) {    return { provide: parentType || Parent, useExisting: forwardRef(() => component) };  }

下面是针对不同父组件类型的用法。

Path:"dependency-injection-in-action/src/app/parent-finder.component.ts" 。

providers:  [ provideParent(BethComponent, DifferentParent) ]

大多数前端应用都要通过 HTTP 协议与服务器通讯,才能下载或上传数据并访问其它后端服务。Angular 给应用提供了一个简化的 HTTP 客户端 API,也就是 @angular/common/http 中的 HttpClient 服务类。

HTTP 客户端服务提供了以下主要功能。

  • 请求类型化响应对象的能力。

  • 简化的错误处理。

  • 各种特性的可测试性。

  • 请求和响应的拦截机制。

先决条件

在使用 HTTPClientModule 之前,你应该对下列内容有基本的了解:

  • TypeScript 编程

  • HTTP 协议的用法

要想使用 HttpClient,就要先导入 Angular 的 HttpClientModule。大多数应用都会在根模块 AppModule 中导入它。

Path:"app/app.module.ts (excerpt)" 。

import { NgModule }         from '@angular/core';import { BrowserModule }    from '@angular/platform-browser';import { HttpClientModule } from '@angular/common/http';@NgModule({  imports: [    BrowserModule,    // import HttpClientModule after BrowserModule.    HttpClientModule,  ],  declarations: [    AppComponent,  ],  bootstrap: [ AppComponent ]})export class AppModule {}

然后,你可以把 HttpClient 服务注入成一个应用类的依赖项,如下面的 ConfigService 例子所示。

Path:"app/config/config.service.ts (excerpt)" 。

import { Injectable } from '@angular/core';import { HttpClient } from '@angular/common/http';@Injectable()export class ConfigService {  constructor(private http: HttpClient) { }}

HttpClient 服务为所有工作都使用了可观察对象。你必须导入示例代码片段中出现的 RxJS 可观察对象和操作符。比如 ConfigService 中的这些导入就很典型。

Path:"app/config/config.service.ts (RxJS imports)" 。

import { Observable, throwError } from 'rxjs';import { catchError, retry } from 'rxjs/operators';

注:

  • 该实例应用不需要数据服务器。它依赖于 Angular in-memory-web-api,它替代了 HttpClient 模块中的 HttpBackend。这个替代服务会模拟 REST 式的后端的行为。

使用 HTTPClient.get() 方法从服务器获取数据。该异步方法会发送一个 HTTP 请求,并返回一个 Observable,它会在收到响应时发出所请求到的数据。返回的类型取决于你调用时传入的 observeresponseType 参数。

get() 方法有两个参数。要获取的端点 URL,以及一个可以用来配置请求的选项对象。

options: {    headers?: HttpHeaders | {[header: string]: string | string[]},    observe?: 'body' | 'events' | 'response',    params?: HttpParams|{[param: string]: string | string[]},    reportProgress?: boolean,    responseType?: 'arraybuffer'|'blob'|'json'|'text',    withCredentials?: boolean,  }

这些重要的选项包括 observeresponseType 属性。

  • observe 选项用于指定要返回的响应内容。

  • responseType 选项指定返回数据的格式。

你可以使用 options 对象来配置传出请求的各个方面。例如,在 Adding headers 中,该服务使用 headers 选项属性设置默认头。

使用 params 属性可以配置带 HTTP URL 参数的请求, reportProgress 选项可以在传输大量数据时监听进度事件。

应用经常会从服务器请求 JSON 数据。在 ConfigService 例子中,该应用需要服务器 "config.json" 上的一个配置文件来指定资源的 URL

Path:"assets/config.json" 。

{  "heroesUrl": "api/heroes",  "textfile": "assets/textfile.txt"}

要获取这类数据,get() 调用需要以下几个选项: {observe: 'body', responseType: 'json'}。这些是这些选项的默认值,所以下面的例子不会传递 options 对象。后面几节展示了一些额外的选项。

这个例子符合通过定义一个可重用的可注入服务来执行数据处理功能来创建可伸缩解决方案的最佳实践。除了提取数据外,该服务还可以对数据进行后处理,添加错误处理,并添加重试逻辑。

ConfigService 使用 HttpClient.get() 方法获取这个文件。

Path:"app/config/config.service.ts (getConfig v.1)" 。

configUrl = 'assets/config.json';getConfig() {  return this.http.get(this.configUrl);}

ConfigComponent 注入了 ConfigService 并调用了 getConfig 服务方法。

由于该服务方法返回了一个 Observable 配置数据,该组件会订阅该方法的返回值。订阅回调只会对后处理进行最少量的处理。它会把数据字段复制到组件的 config 对象中,该对象在组件模板中是数据绑定的,用于显示。

Path:"app/config/config.component.ts (showConfig v.1)" 。

showConfig() {  this.configService.getConfig()    .subscribe((data: Config) => this.config = {        heroesUrl: data['heroesUrl'],        textfile:  data['textfile']    });}

请求输入一个类型的响应

你可以构造自己的 HttpClient 请求来声明响应对象的类型,以便让输出更容易、更明确。所指定的响应类型会在编译时充当类型断言。

注:

  • 指定响应类型是在向 TypeScript 声明,它应该把你的响应对象当做给定类型来使用。这是一种构建期检查,它并不能保证服务器会实际给出这种类型的响应对象。该服务器需要自己确保返回服务器 API 中指定的类型。

要指定响应对象类型,首先要定义一个具有必需属性的接口。这里要使用接口而不是类,因为响应对象是普通对象,无法自动转换成类的实例。

export interface Config {  heroesUrl: string;  textfile: string;}

接下来,在服务器中把该接口指定为 HttpClient.get() 调用的类型参数。

Path:"app/config/config.service.ts (getConfig v.2)" 。

getConfig() {  // now returns an Observable of Config  return this.http.get<Config>(this.configUrl);}

当把接口作为类型参数传给 HttpClient.get() 方法时,你可以使用RxJS map 操作符来根据 UI 的需求转换响应数据。然后,把转换后的数据传给异步管道。

修改后的组件方法,其回调函数中获取一个带类型的对象,它易于使用,且消费起来更安全:

Path:"app/config/config.component.ts (showConfig v.2)" 。

config: Config;showConfig() {  this.configService.getConfig()    // clone the data object, using its known Config shape    .subscribe((data: Config) => this.config = { ...data });}

要访问接口中定义的属性,必须将从 JSON 获得的普通对象显式转换为所需的响应类型。例如,以下 subscribe 回调会将 data 作为对象接收,然后进行类型转换以访问属性。

.subscribe(data => this.config = {  heroesUrl: (data as any).heroesUrl,  textfile:  (data as any).textfile,});

OBSERVERESPONSE 的类型是字符串的联合类型,而不是普通的字符串。

options: {    ...    observe?: 'body' | 'events' | 'response',    ...    responseType?: 'arraybuffer'|'blob'|'json'|'text',    ...  }

这会引起混乱。例如:

// this worksclient.get('/foo', {responseType: 'text'})// but this does NOT workconst options = {  responseType: 'text',};client.get('/foo', options)

在第二种情况下,TypeScript 会把 options 的类型推断为 {responseType: string}。该类型的 HttpClient.get 太宽泛,无法传递给 HttpClient.get,它希望 responseType 的类型是特定的字符串之一。而 HttpClient 就是以这种方式显式输入的,因此编译器可以根据你提供的选项报告正确的返回类型。

使用 as const,可以让 TypeScript 知道你并不是真的要使用字面字符串类型:

const options = {  responseType: 'text' as const,};client.get('/foo', options);

读取完整的响应体

在前面的例子中,对 HttpClient.get() 的调用没有指定任何选项。默认情况下,它返回了响应体中包含的 JSON 数据。

你可能还需要关于这次对话的更多信息。比如,有时候服务器会返回一个特殊的响应头或状态码,来指出某些在应用的工作流程中很重要的条件。

可以用 get() 方法的 observe 选项来告诉 HttpClient,你想要完整的响应对象:

getConfigResponse(): Observable<HttpResponse<Config>> {  return this.http.get<Config>(    this.configUrl, { observe: 'response' });}

现在,HttpClient.get() 会返回一个 HttpResponse 类型的 Observable,而不只是 JSON 数据。

该组件的 showConfigResponse() 方法会像显示配置数据一样显示响应头:

Path:"app/config/config.component.ts (showConfigResponse)" 。

showConfigResponse() {  this.configService.getConfigResponse()    // resp is of type `HttpResponse<Config>`    .subscribe(resp => {      // display its headers      const keys = resp.headers.keys();      this.headers = keys.map(key =>        `${key}: ${resp.headers.get(key)}`);      // access the body directly, which is typed as `Config`.      this.config = { ... resp.body };    });}

注:

  • 该响应对象具有一个带有正确类型的 body 属性。

发起 JSONP 请求

当服务器不支持 CORS 协议时,应用程序可以使用 HttpClient 跨域发出 JSONP 请求。

Angular 的 JSONP 请求会返回一个 Observable。 遵循订阅可观察对象变量的模式,并在使用async 管道管理结果之前,使用 RxJS map 操作符转换响应。

在 Angular 中,通过在 NgModuleimports 中包含 HttpClientJsonpModule 来使用 JSONP。在以下示例中,searchHeroes() 方法使用 JSONP 请求来查询名称包含搜索词的英雄。

/* GET heroes whose name contains search term */searchHeroes(term: string): Observable {  term = term.trim();  let heroesURL = `${this.heroesURL}?${term}`;  return this.http.jsonp(heroesUrl, 'callback').pipe(      catchError(this.handleError('searchHeroes', [])) // then handle the error    );};

该请求将 heroesURL 作为第一个参数,并将回调函数名称作为第二个参数。响应被包装在回调函数中,该函数接受 JSONP 方法返回的可观察对象,并将它们通过管道传给错误处理程序。

请求非 JSON 数据

不是所有的 API 都会返回 JSON 数据。在下面这个例子中,DownloaderService 中的方法会从服务器读取文本文件, 并把文件的内容记录下来,然后把这些内容使用 Observable<string> 的形式返回给调用者。

Path:"app/downloader/downloader.service.ts (getTextFile)" 。

getTextFile(filename: string) {  // The Observable returned by get() is of type Observable<string>  // because a text response was specified.  // There's no need to pass a <string> type parameter to get().  return this.http.get(filename, {responseType: 'text'})    .pipe(      tap( // Log the result or error        data => this.log(filename, data),        error => this.logError(filename, error)      )    );}

这里的 HttpClient.get() 返回字符串而不是默认的 JSON 对象,因为它的 responseType 选项是 'text'

RxJS 的 tap 操作符(如“窃听”中所述)使代码可以检查通过可观察对象的成功值和错误值,而不会干扰它们。

DownloaderComponent 中的 download() 方法通过订阅这个服务中的方法来发起一次请求。

Path:"app/downloader/downloader.component.ts (download)" 。

download() {  this.downloaderService.getTextFile('assets/textfile.txt')    .subscribe(results => this.contents = results);}

如果请求在服务器上失败了,那么 HttpClient 就会返回一个错误对象而不是一个成功的响应对象。

执行服务器请求的同一个服务中也应该执行错误检查、解释和解析。

发生错误时,你可以获取失败的详细信息,以便通知你的用户。在某些情况下,你也可以自动重试该请求。

获取错误详情

当数据访问失败时,应用会给用户提供有用的反馈。原始的错误对象作为反馈并不是特别有用。除了检测到错误已经发生之外,还需要获取错误详细信息并使用这些细节来撰写用户友好的响应。

可能会出现两种类型的错误。

服务器端可能会拒绝该请求,并返回状态码为 404500 的 HTTP 响应。这些是错误响应。

客户端也可能出现问题,例如网络错误会让请求无法成功完成,或者 RxJS 操作符也会抛出异常。这些错误会产生 JavaScript 的 ErrorEvent 对象。

HttpClient 在其 HttpErrorResponse 中会捕获两种错误。你可以检查一下这个响应是否存在错误。

下面的例子在之前定义的 ConfigService 中定义了一个错误处理程序。

Path:"app/config/config.service.ts (handleError)" 。

private handleError(error: HttpErrorResponse) {  if (error.error instanceof ErrorEvent) {    // A client-side or network error occurred. Handle it accordingly.    console.error('An error occurred:', error.error.message);  } else {    // The backend returned an unsuccessful response code.    // The response body may contain clues as to what went wrong,    console.error(      `Backend returned code ${error.status}, ` +      `body was: ${error.error}`);  }  // return an observable with a user-facing error message  return throwError(    'Something bad happened; please try again later.');};

该处理程序会返回一个带有用户友好的错误信息的 RxJS ErrorObservable。下列代码修改了 getConfig() 方法,它使用一个管道把 HttpClient.get() 调用返回的所有 Observable 发送给错误处理器。

Path:"app/config/config.service.ts (getConfig v.3 with error handler)" 。

getConfig() {  return this.http.get<Config>(this.configUrl)    .pipe(      catchError(this.handleError)    );}

重试失败的请求

有时候,错误只是临时性的,只要重试就可能会自动消失。 比如,在移动端场景中可能会遇到网络中断的情况,只要重试一下就能拿到正确的结果。

RxJS 库提供了几个重试操作符。例如,retry() 操作符会自动重新订阅一个失败的 Observable 几次。重新订阅 HttpClient 方法会导致它重新发出 HTTP 请求。

下面的例子演示了如何在把一个失败的请求传给错误处理程序之前,先通过管道传给 retry() 操作符。

Path:"app/config/config.service.ts (getConfig with retry)" 。

getConfig() {  return this.http.get<Config>(this.configUrl)    .pipe(      retry(3), // retry a failed request up to 3 times      catchError(this.handleError) // then handle the error    );}

除了从服务器获取数据外,HttpClient 还支持其它一些 HTTP 方法,比如 PUTPOSTDELETE,你可以用它们来修改远程数据。

本指南中的这个范例应用包括一个简化版本的《英雄指南》,它会获取英雄数据,并允许用户添加、删除和修改它们。 下面几节在 HeroesService 范例中展示了数据更新方法的一些例子。

发起一个 POST 请求

应用经常在提交表单时通过 POST 请求向服务器发送数据。 下面这个例子中,HeroesService 在向数据库添加英雄时发起了一个 HTTP POST 请求。

Path:"app/heroes/heroes.service.ts (addHero)" 。

/** POST: add a new hero to the database */addHero (hero: Hero): Observable<Hero> {  return this.http.post<Hero>(this.heroesUrl, hero, httpOptions)    .pipe(      catchError(this.handleError('addHero', hero))    );}

HttpClient.post() 方法像 get() 一样也有类型参数,可以用它来指出你期望服务器返回特定类型的数据。该方法需要一个资源 URL 和两个额外的参数:

  • body - 要在请求体中 POST 过去的数据。

  • options - 一个包含方法选项的对象,在这里,它用来指定必要的请求头。

该例子捕获了前面所指的错误。

HeroesComponent 通过订阅该服务方法返回的 Observable 发起了一次实际的 POST 操作。

Path:"app/heroes/heroes.component.ts (addHero)" 。

this.heroesService  .addHero(newHero)  .subscribe(hero => this.heroes.push(hero));

当服务器成功做出响应时,会带有这个新创建的英雄,然后该组件就会把这个英雄添加到正在显示的 heroes 列表中。

发起 DELETE 请求

该应用可以把英雄的 id 传给 HttpClient.delete 方法的请求 URL 来删除一个英雄。

Path:"app/heroes/heroes.service.ts (deleteHero)" 。

/** DELETE: delete the hero from the server */deleteHero (id: number): Observable<{}> {  const url = `${this.heroesUrl}/${id}`; // DELETE api/heroes/42  return this.http.delete(url, httpOptions)    .pipe(      catchError(this.handleError('deleteHero'))    );}

HeroesComponent 订阅了该服务方法返回的 Observable 时,就会发起一次实际的 DELETE 操作。

Path:"app/heroes/heroes.component.ts (deleteHero)" 。

this.heroesService  .deleteHero(hero.id)  .subscribe();

该组件不会等待删除操作的结果,所以它的 subscribe (订阅)中没有回调函数。不过就算你不关心结果,也仍然要订阅它。调用 subscribe() 方法会执行这个可观察对象,这时才会真的发起 DELETE 请求。

注:

  • 你必须调用 subscribe(),否则什么都不会发生。仅仅调用 HeroesService.deleteHero() 是不会发起 DELETE 请求的。

// oops ... subscribe() is missing so nothing happens    this.heroesService.deleteHero(hero.id);

在调用方法返回的可观察对象的 subscribe() 方法之前,HttpClient 方法不会发起 HTTP 请求。这适用于 HttpClient 的所有方法。

AsyncPipe 会自动为你订阅(以及取消订阅)。

HttpClient 的所有方法返回的可观察对象都设计为冷的。 HTTP 请求的执行都是延期执行的,让你可以用 tapcatchError 这样的操作符来在实际执行 HTTP 请求之前,先对这个可观察对象进行扩展。

调用 subscribe(...) 会触发这个可观察对象的执行,并导致HttpClient` 组合并把 HTTP 请求发给服务器。

你可以把这些可观察对象看做实际 HTTP 请求的蓝图。

实际上,每个 subscribe() 都会初始化此可观察对象的一次单独的、独立的执行。 订阅两次就会导致发起两个 HTTP 请求。

<br>const req = http.get<Heroes&('/api/heroes');<br>// 0 requests made - .subscribe() not called.<br>req.subscribe();<br>// 1 request made.<br>req.subscribe();<br>// 2 requests made.<br>```

发起 PUT 请求

应用可以使用 HttpClient 服务发送 PUT 请求。下面的 HeroesService 示例(就像 POST 示例一样)用一个修改过的数据替换了该资源。

Path:"app/heroes/heroes.service.ts (updateHero)" 。

/** PUT: update the hero on the server. Returns the updated hero upon success. */updateHero (hero: Hero): Observable<Hero> {  return this.http.put<Hero>(this.heroesUrl, hero, httpOptions)    .pipe(      catchError(this.handleError('updateHero', hero))    );}

对于所有返回可观察对象的 HTTP 方法,调用者(HeroesComponent.update())必须 subscribe()HttpClient.put() 返回的可观察对象,才会真的发起请求。

添加和更新请求头

很多服务器都需要额外的头来执行保存操作。 例如,服务器可能需要一个授权令牌,或者需要 Content-Type 头来显式声明请求体的 MIME 类型。

  1. 添加请求头。

HeroesService 在一个 httpOptions 对象中定义了这样的头,它们被传递给每个 HttpClient 的保存型方法。

Path:"app/heroes/heroes.service.ts (httpOptions)" 。

    import { HttpHeaders } from '@angular/common/http';    const httpOptions = {      headers: new HttpHeaders({        'Content-Type':  'application/json',        'Authorization': 'my-auth-token'      })    };

  1. 更新请求头。

你不能直接修改前面的选项对象中的 HttpHeaders 请求头,因为 HttpHeaders 类的实例是不可变对象。请改用 set() 方法,以返回当前实例应用了新更改之后的副本。

下面的例子演示了当旧令牌过期时,可以在发起下一个请求之前更新授权头。

    httpOptions.headers =      httpOptions.headers.set('Authorization', 'my-new-auth-token');

使用 HttpParams 类和 params 选项在你的 HttpRequest 中添加 URL 查询字符串。

下面的例子中,searchHeroes() 方法用于查询名字中包含搜索词的英雄。

首先导入 HttpParams 类。

import {HttpParams} from "@angular/common/http";

/* GET heroes whose name contains search term */searchHeroes(term: string): Observable<Hero[]> {  term = term.trim();  // Add safe, URL encoded search parameter if there is a search term  const options = term ?   { params: new HttpParams().set('name', term) } : {};  return this.http.get<Hero[]>(this.heroesUrl, options)    .pipe(      catchError(this.handleError<Hero[]>('searchHeroes', []))    );}

如果有搜索词,代码会用进行过 URL 编码的搜索参数来构造一个 options 对象。例如,如果搜索词是 "cat",那么 GET 请求的 URL 就是 api/heroes?name=cat

HttpParams 是不可变对象。如果需要更新选项,请保留 .set() 方法的返回值。

你也可以使用 fromString 变量从查询字符串中直接创建 HTTP 参数:

const params = new HttpParams({fromString: 'name=foo'});

借助拦截机制,你可以声明一些拦截器,它们可以检查并转换从应用中发给服务器的 HTTP 请求。这些拦截器还可以在返回应用的途中检查和转换来自服务器的响应。多个拦截器构成了请求/响应处理器的双向链表。

拦截器可以用一种常规的、标准的方式对每一次 HTTP 的请求/响应任务执行从认证到记日志等很多种隐式任务。

如果没有拦截机制,那么开发人员将不得不对每次 HttpClient 调用显式实现这些任务。

编写拦截器

要实现拦截器,就要实现一个实现了 HttpInterceptor 接口中的 intercept() 方法的类。

这里是一个什么也不做的空白拦截器,它只会不做任何修改的传递这个请求。

Path:"app/http-interceptors/noop-interceptor.ts" 。

import { Injectable } from '@angular/core';import {  HttpEvent, HttpInterceptor, HttpHandler, HttpRequest} from '@angular/common/http';import { Observable } from 'rxjs';/** Pass untouched request through to the next request handler. */@Injectable()export class NoopInterceptor implements HttpInterceptor {  intercept(req: HttpRequest<any>, next: HttpHandler):    Observable<HttpEvent<any>> {    return next.handle(req);  }}

intercept 方法会把请求转换成一个最终返回 HTTP 响应体的 Observable。 在这个场景中,每个拦截器都完全能自己处理这个请求。

大多数拦截器拦截都会在传入时检查请求,然后把(可能被修改过的)请求转发给 next 对象的 handle() 方法,而 next 对象实现了 HttpHandler 接口。

export abstract class HttpHandler {  abstract handle(req: HttpRequest<any>): Observable<HttpEvent<any>>;}

intercept() 一样,handle() 方法也会把 HTTP 请求转换成 HttpEvents 组成的 Observable,它最终包含的是来自服务器的响应。 intercept() 函数可以检查这个可观察对象,并在把它返回给调用者之前修改它。

这个无操作的拦截器,会直接使用原始的请求调用 next.handle(),并返回它返回的可观察对象,而不做任何后续处理。

next 对象

next 对象表示拦截器链表中的下一个拦截器。 这个链表中的最后一个 next 对象就是 HttpClient 的后端处理器(backend handler),它会把请求发给服务器,并接收服务器的响应。

大多数的拦截器都会调用 next.handle(),以便这个请求流能走到下一个拦截器,并最终传给后端处理器。 拦截器也可以不调用 next.handle(),使这个链路短路,并返回一个带有人工构造出来的服务器响应的 自己的 Observable

这是一种常见的中间件模式,在像 "Express.js" 这样的框架中也会找到它。

提供这个拦截器

这个 NoopInterceptor 就是一个由 Angular 依赖注入 (DI)系统管理的服务。 像其它服务一样,你也必须先提供这个拦截器类,应用才能使用它。

由于拦截器是 HttpClient 服务的(可选)依赖,所以你必须在提供 HttpClient 的同一个(或其各级父注入器)注入器中提供这些拦截器。 那些在 DI 创建完 HttpClient 之后再提供的拦截器将会被忽略。

由于在 AppModule 中导入了 HttpClientModule,导致本应用在其根注入器中提供了 HttpClient。所以你也同样要在 AppModule 中提供这些拦截器。

在从 @angular/common/http 中导入了 HTTP_INTERCEPTORS 注入令牌之后,编写如下的 NoopInterceptor 提供者注册语句:

{ provide: HTTP_INTERCEPTORS, useClass: NoopInterceptor, multi: true },

注意 multi: true 选项。 这个必须的选项会告诉 Angular HTTP_INTERCEPTORS 是一个多重提供者的令牌,表示它会注入一个多值的数组,而不是单一的值。

你也可以直接把这个提供者添加到 AppModule 中的提供者数组中,不过那样会非常啰嗦。况且,你将来还会用这种方式创建更多的拦截器并提供它们。 你还要特别注意提供这些拦截器的顺序。

认真考虑创建一个封装桶(barrel)文件,用于把所有拦截器都收集起来,一起提供给 httpInterceptorProviders 数组,可以先从这个 NoopInterceptor 开始。

Path:"app/http-interceptors/index.ts" 。

/* "Barrel" of Http Interceptors */import { HTTP_INTERCEPTORS } from '@angular/common/http';import { NoopInterceptor } from './noop-interceptor';/** Http interceptor providers in outside-in order */export const httpInterceptorProviders = [  { provide: HTTP_INTERCEPTORS, useClass: NoopInterceptor, multi: true },];

然后导入它,并把它加到 AppModuleproviders 数组中,就像这样:

Path:"app/app.module.ts (interceptor providers)" 。

providers: [  httpInterceptorProviders],

当你再创建新的拦截器时,就同样把它们添加到 httpInterceptorProviders 数组中,而不用再修改 AppModule

拦截器的顺序

Angular 会按照你提供它们的顺序应用这些拦截器。 如果你提供拦截器的顺序是先 A,再 B,再 C,那么请求阶段的执行顺序就是 A->B->C,而响应阶段的执行顺序则是 C->B->A。

以后你就再也不能修改这些顺序或移除某些拦截器了。 如果你需要动态启用或禁用某个拦截器,那就要在那个拦截器中自行实现这个功能。

处理拦截器事件

大多数 HttpClient 方法都会返回 HttpResponse<any> 型的可观察对象。HttpResponse 类本身就是一个事件,它的类型是 HttpEventType.Response。但是,单个 HTTP 请求可以生成其它类型的多个事件,包括报告上传和下载进度的事件。HttpInterceptor.intercept()HttpHandler.handle() 会返回 HttpEvent<any> 型的可观察对象。

很多拦截器只关心发出的请求,而对 next.handle() 返回的事件流不会做任何修改。 但是,有些拦截器需要检查并修改 next.handle() 的响应。上述做法就可以在流中看到所有这些事件。

虽然拦截器有能力改变请求和响应,但 HttpRequestHttpResponse 实例的属性却是只读(readonly)的, 因此让它们基本上是不可变的。

有充足的理由把它们做成不可变对象:应用可能会重试发送很多次请求之后才能成功,这就意味着这个拦截器链表可能会多次重复处理同一个请求。 如果拦截器可以修改原始的请求对象,那么重试阶段的操作就会从修改过的请求开始,而不是原始请求。 而这种不可变性,可以确保这些拦截器在每次重试时看到的都是同样的原始请求。

你的拦截器应该在没有任何修改的情况下返回每一个事件,除非它有令人信服的理由去做。

TypeScript 会阻止你设置 HttpRequest 的只读属性。

// Typescript disallows the following assignment because req.url is readonlyreq.url = req.url.replace('http://', 'https://');

如果你必须修改一个请求,先把它克隆一份,修改这个克隆体后再把它传给 next.handle()。你可以在一步中克隆并修改此请求,例子如下。

Path:"app/http-interceptors/ensure-https-interceptor.ts (excerpt)" 。

// clone request and replace 'http://' with 'https://' at the same timeconst secureReq = req.clone({  url: req.url.replace('http://', 'https://')});// send the cloned, "secure" request to the next handler.return next.handle(secureReq);

这个 clone() 方法的哈希型参数允许你在复制出克隆体的同时改变该请求的某些特定属性。

  1. 修改请求体。

readonly 这种赋值保护,无法防范深修改(修改子对象的属性),也不能防范你修改请求体对象中的属性。

    req.body.name = req.body.name.trim(); // bad idea!

如果必须修改请求体,请执行以下步骤。

  • 复制请求体并在副本中进行修改。

  • 使用 clone() 方法克隆这个请求对象。

  • 用修改过的副本替换被克隆的请求体。

    // copy the body and trim whitespace from the name property    const newBody = { ...body, name: body.name.trim() };    // clone request and set its body    const newReq = req.clone({ body: newBody });    // send the cloned request to the next handler.    return next.handle(newReq);

  1. 克隆时清除请求体。

有时,你需要清除请求体而不是替换它。为此,请将克隆后的请求体设置为 null

注:

  • 如果你把克隆后的请求体设为 undefined,那么 Angular 会认为你想让请求体保持原样。

    newReq = req.clone({ ... }); // body not mentioned => preserve original body    newReq = req.clone({ body: undefined }); // preserve original body    newReq = req.clone({ body: null }); // clear the body

设置默认请求头

应用通常会使用拦截器来设置外发请求的默认请求头。

该范例应用具有一个 AuthService,它会生成一个认证令牌。 在这里,AuthInterceptor 会注入该服务以获取令牌,并对每一个外发的请求添加一个带有该令牌的认证头:

Path:"app/http-interceptors/auth-interceptor.ts" 。

import { AuthService } from '../auth.service';@Injectable()export class AuthInterceptor implements HttpInterceptor {  constructor(private auth: AuthService) {}  intercept(req: HttpRequest<any>, next: HttpHandler) {    // Get the auth token from the service.    const authToken = this.auth.getAuthorizationToken();    // Clone the request and replace the original headers with    // cloned headers, updated with the authorization.    const authReq = req.clone({      headers: req.headers.set('Authorization', authToken)    });    // send cloned request with header to the next handler.    return next.handle(authReq);  }}

这种在克隆请求的同时设置新请求头的操作太常见了,因此它还有一个快捷方式 setHeaders

// Clone the request and set the new header in one step.const authReq = req.clone({ setHeaders: { Authorization: authToken } });

这种可以修改头的拦截器可以用于很多不同的操作,比如:

  • 认证 / 授权

  • 控制缓存行为。比如 If-Modified-Since

  • XSRF 防护

用拦截器记日志

因为拦截器可以同时处理请求和响应,所以它们也可以对整个 HTTP 操作执行计时和记录日志等任务。

考虑下面这个 LoggingInterceptor,它捕获请求的发起时间、响应的接收时间,并使用注入的 MessageService 来发送总共花费的时间。

Path:"app/http-interceptors/logging-interceptor.ts)" 。

import { finalize, tap } from 'rxjs/operators';import { MessageService } from '../message.service';@Injectable()export class LoggingInterceptor implements HttpInterceptor {  constructor(private messenger: MessageService) {}  intercept(req: HttpRequest<any>, next: HttpHandler) {    const started = Date.now();    let ok: string;    // extend server response observable with logging    return next.handle(req)      .pipe(        tap(          // Succeeds when there is a response; ignore other events          event => ok = event instanceof HttpResponse ? 'succeeded' : '',          // Operation failed; error is an HttpErrorResponse          error => ok = 'failed'        ),        // Log when response observable either completes or errors        finalize(() => {          const elapsed = Date.now() - started;          const msg = `${req.method} "${req.urlWithParams}"             ${ok} in ${elapsed} ms.`;          this.messenger.add(msg);        })      );  }}

RxJS 的 tap 操作符会捕获请求成功了还是失败了。 RxJS 的 finalize 操作符无论在响应成功还是失败时都会调用(这是必须的),然后把结果汇报给 MessageService

在这个可观察对象的流中,无论是 tap 还是 finalize 接触过的值,都会照常发送给调用者。

用拦截器实现缓存

拦截器还可以自行处理这些请求,而不用转发给 next.handle()

比如,你可能会想缓存某些请求和响应,以便提升性能。 你可以把这种缓存操作委托给某个拦截器,而不破坏你现有的各个数据服务。

下例中的 CachingInterceptor 演示了这种方法。

Path:"app/http-interceptors/caching-interceptor.ts)" 。

@Injectable()export class CachingInterceptor implements HttpInterceptor {  constructor(private cache: RequestCache) {}  intercept(req: HttpRequest<any>, next: HttpHandler) {    // continue if not cacheable.    if (!isCacheable(req)) { return next.handle(req); }    const cachedResponse = this.cache.get(req);    return cachedResponse ?      of(cachedResponse) : sendRequest(req, next, this.cache);  }}

  • isCacheable() 函数用于决定该请求是否允许缓存。 在这个例子中,只有发到 npm 包搜索 APIGET 请求才是可以缓存的。

  • 如果该请求是不可缓存的,该拦截器只会把该请求转发给链表中的下一个处理器。

  • 如果可缓存的请求在缓存中找到了,该拦截器就会通过 of() 函数返回一个已缓存的响应体的可观察对象,然后绕过 next 处理器(以及所有其它下游拦截器)。

  • 如果可缓存的请求不在缓存中,代码会调用 sendRequest()。这个函数会创建一个没有请求头的请求克隆体,这是因为 npm API 禁止它们。然后,该函数把请求的克隆体转发给 next.handle(),它会最终调用服务器并返回来自服务器的响应对象。

/** * Get server response observable by sending request to `next()`. * Will add the response to the cache on the way out. */function sendRequest(  req: HttpRequest<any>,  next: HttpHandler,  cache: RequestCache): Observable<HttpEvent<any>> {  // No headers allowed in npm search request  const noHeaderReq = req.clone({ headers: new HttpHeaders() });  return next.handle(noHeaderReq).pipe(    tap(event => {      // There may be other events besides the response.      if (event instanceof HttpResponse) {        cache.put(req, event); // Update the cache.      }    })  );}

注意 sendRequest() 是如何在返回应用程序的过程中拦截响应的。该方法通过 tap() 操作符来管理响应对象,该操作符的回调函数会把该响应对象添加到缓存中。

然后,原始的响应会通过这些拦截器链,原封不动的回到服务器的调用者那里。

数据服务,比如 PackageSearchService,并不知道它们收到的某些 HttpClient 请求实际上是从缓存的请求中返回来的。

用拦截器来请求多个值

HttpClient.get() 方法通常会返回一个可观察对象,它会发出一个值(数据或错误)。拦截器可以把它改成一个可以发出多个值的可观察对象。

修改后的 CachingInterceptor 版本可以返回一个立即发出所缓存响应的可观察对象,然后把请求发送到 NPMWeb API,然后把修改过的搜索结果重新发出一次。

// cache-then-refreshif (req.headers.get('x-refresh')) {  const results$ = sendRequest(req, next, this.cache);  return cachedResponse ?    results$.pipe( startWith(cachedResponse) ) :    results$;}// cache-or-fetchreturn cachedResponse ?  of(cachedResponse) : sendRequest(req, next, this.cache);

cache-then-refresh 选项是由一个自定义的 x-refresh 请求头触发的。

PackageSearchComponent 中的一个检查框会切换 withRefresh 标识, 它是 PackageSearchService.search() 的参数之一。 search() 方法创建了自定义的 x-refresh 头,并在调用 HttpClient.get() 前把它添加到请求里。

修改后的 CachingInterceptor 会发起一个服务器请求,而不管有没有缓存的值。 就像 前面 的 sendRequest() 方法一样进行订阅。 在订阅 results$ 可观察对象时,就会发起这个请求。

  • 如果没有缓存值,拦截器直接返回 results$

  • 如果有缓存的值,这些代码就会把缓存的响应加入到 result$ 的管道中,使用重组后的可观察对象进行处理,并发出两次。 先立即发出一次缓存的响应体,然后发出来自服务器的响应。 订阅者将会看到一个包含这两个响应的序列。

应用程序有时会传输大量数据,而这些传输可能要花很长时间。文件上传就是典型的例子。你可以通过提供有关此类传输的进度反馈,为用户提供更好的体验。

要想发出一个带有进度事件的请求,你可以创建一个 HttpRequest 实例,并把 reportProgress 选项设置为 true 来启用对进度事件的跟踪。

Path:"app/uploader/uploader.service.ts (upload request)" 。

const req = new HttpRequest('POST', '/upload/file', file, {  reportProgress: true});

注:

  • 每个进度事件都会触发变更检测,所以只有当需要在 UI 上报告进度时,你才应该开启它们。

  • HttpClient.request()HTTP 方法一起使用时,可以用 observe: 'events' 来查看所有事件,包括传输的进度。

接下来,把这个请求对象传递给 HttpClient.request() 方法,该方法返回一个 HttpEventsObservable(与 拦截器 部分处理过的事件相同)。

Path:"app/uploader/uploader.service.ts (upload body)" 。

// The `HttpClient.request` API produces a raw event stream// which includes start (sent), progress, and response events.return this.http.request(req).pipe(  map(event => this.getEventMessage(event, file)),  tap(message => this.showProgress(message)),  last(), // return last (completed) message to caller  catchError(this.handleError(file)));

getEventMessage 方法解释了事件流中每种类型的 HttpEvent

Path:"app/uploader/uploader.service.ts (getEventMessage)" 。

/** Return distinct message for sent, upload progress, & response events */private getEventMessage(event: HttpEvent<any>, file: File) {  switch (event.type) {    case HttpEventType.Sent:      return `Uploading file "${file.name}" of size ${file.size}.`;    case HttpEventType.UploadProgress:      // Compute and show the % done:      const percentDone = Math.round(100 * event.loaded / event.total);      return `File "${file.name}" is ${percentDone}% uploaded.`;    case HttpEventType.Response:      return `File "${file.name}" was completely uploaded!`;    default:      return `File "${file.name}" surprising upload event: ${event.type}.`;  }}

本指南中的示例应用中没有用来接受上传文件的服务器。"app/http-interceptors/upload-interceptor.ts" 的 UploadInterceptor 通过返回一个模拟这些事件的可观察对象来拦截和短路上传请求。

如果你需要发一个 HTTP 请求来响应用户的输入,那么每次击键就发送一个请求的效率显然不高。最好等用户停止输入后再发送请求。这种技术叫做防抖。可以通过防抖来优化与服务器的交互。

考虑下面这个模板,它让用户输入一个搜索词来按名字查找 npm 包。 当用户在搜索框中输入名字时,PackageSearchComponent 就会把这个根据名字搜索包的请求发给 npm web API

Path:"app/package-search/package-search.component.html (search)" 。

<input (keyup)="search($event.target.value)" id="name" placeholder="Search"/><ul>  <li *ngFor="let package of packages$ | async">    <b>{{package.name}} v.{{package.version}}</b> -    <i>{{package.description}}</i>  </li></ul>

这里,keyup 事件绑定会把每次击键都发送给组件的 search() 方法。下面的代码片段使用 RxJS 的操作符为这个输入实现了防抖。

Path:"app/package-search/package-search.component.ts (excerpt)" 。

withRefresh = false;packages$: Observable<NpmPackageInfo[]>;private searchText$ = new Subject<string>();search(packageName: string) {  this.searchText$.next(packageName);}ngOnInit() {  this.packages$ = this.searchText$.pipe(    debounceTime(500),    distinctUntilChanged(),    switchMap(packageName =>      this.searchService.search(packageName, this.withRefresh))  );}constructor(private searchService: PackageSearchService) { }

searchText$ 是来自用户的搜索框值的序列。它被定义为 RxJS Subject 类型,这意味着它是一个多播 Observable,它还可以通过调用 next(value) 来自行发出值,就像在 search() 方法中一样。

除了把每个 searchText 的值都直接转发给 PackageSearchService 之外,ngOnInit() 中的代码还通过下列三个操作符对这些搜索值进行管道处理,以便只有当它是一个新值并且用户已经停止输入时,要搜索的值才会抵达该服务。

  • debounceTime(500) - 等待用户停止输入(本例中为 1/2 秒)。

  • distinctUntilChanged() - 等待搜索文本发生变化。

  • switchMap() - 将搜索请求发送到服务。

这些代码把 packages$ 设置成了使用搜索结果组合出的 Observable 对象。 模板中使用 AsyncPipe 订阅了 packages$,一旦搜索结果的值发回来了,就显示这些搜索结果。

使用 switchMap() 操作符

switchMap() 操作符接受一个返回 Observable 的函数型参数。在这个例子中,PackageSearchService.search 像其它数据服务方法那样返回一个 Observable。如果先前的搜索请求仍在进行中 (如网络连接不良),它将取消该请求并发送新的请求。

请注意,switchMap() 会按照原始的请求顺序返回这些服务的响应,而不用关心服务器实际上是以乱序返回的它们。

如果你觉得将来会复用这些防抖逻辑, 可以把它移到单独的工具函数中,或者移到 PackageSearchService 中。

跨站请求伪造 (XSRF 或 CSRF)是一个攻击技术,它能让攻击者假冒一个已认证的用户在你的网站上执行未知的操作。HttpClient 支持一种通用的机制来防范 XSRF 攻击。当执行 HTTP 请求时,一个拦截器会从 cookie 中读取 XSRF 令牌(默认名字为 XSRF-TOKEN),并且把它设置为一个 HTTP 头 X-XSRF-TOKEN,由于只有运行在你自己的域名下的代码才能读取这个 cookie,因此后端可以确认这个 HTTP 请求真的来自你的客户端应用,而不是攻击者。

默认情况下,拦截器会在所有的修改型请求中(比如 POST 等)把这个请求头发送给使用相对 URL 的请求。但不会在 GET/HEAD 请求中发送,也不会发送给使用绝对 URL 的请求。

要获得这种优点,你的服务器需要在页面加载或首个 GET 请求中把一个名叫 XSRF-TOKEN 的令牌写入可被 JavaScript 读到的会话 cookie 中。 而在后续的请求中,服务器可以验证这个 cookie 是否与 HTTP 头 X-XSRF-TOKEN 的值一致,以确保只有运行在你自己域名下的代码才能发起这个请求。这个令牌必须对每个用户都是唯一的,并且必须能被服务器验证,因此不能由客户端自己生成令牌。把这个令牌设置为你的站点认证信息并且加了盐(salt)的摘要,以提升安全性。

为了防止多个 Angular 应用共享同一个域名或子域时出现冲突,要给每个应用分配一个唯一的 cookie 名称。

注:

HttpClient 支持的只是 XSRF 防护方案的客户端这一半。 你的后端服务必须配置为给页面设置 cookie,并且要验证请求头,以确保全都是合法的请求。如果不这么做,就会导致 Angular 的默认防护措施失效。

配置自定义 cookie/header 名称

如果你的后端服务中对 XSRF 令牌的 cookie 或 头使用了不一样的名字,就要使用 HttpClientXsrfModule.withConfig() 来覆盖掉默认值。

imports: [  HttpClientModule,  HttpClientXsrfModule.withOptions({    cookieName: 'My-Xsrf-Cookie',    headerName: 'My-Xsrf-Header',  }),],

如同所有的外部依赖一样,你必须把 HTTP 后端也 Mock 掉,以便你的测试可以模拟这种与后端的互动。 @angular/common/http/testing 库能让这种 Mock 工作变得直截了当。

Angular 的 HTTP 测试库是专为其中的测试模式而设计的。在这种模式下,会首先在应用中执行代码并发起请求。 然后,这个测试会期待发起或未发起过某个请求,并针对这些请求进行断言, 最终对每个所预期的请求进行刷新(flush)来对这些请求提供响应。

最终,测试可能会验证这个应用不曾发起过非预期的请求。

本章所讲的这些测试位于 "src/testing/http-client.spec.ts" 中。 在 "src/app/heroes/heroes.service.spec.ts" 中还有一些测试,用于测试那些调用了 "HttpClient" 的数据服务。

搭建测试环境

要开始测试那些通过 HttpClient 发起的请求,就要导入 HttpClientTestingModule 模块,并把它加到你的 TestBed 设置里去,代码如下:

Path:"app/testing/http-client.spec.ts (imports)" 。

// Http testing module and mocking controllerimport { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';// Other importsimport { TestBed } from '@angular/core/testing';import { HttpClient, HttpErrorResponse } from '@angular/common/http';

然后把 HTTPClientTestingModule 添加到 TestBed 中,并继续设置被测服务。

Path:"app/testing/http-client.spec.ts(setup)" 。

describe('HttpClient testing', () => {  let httpClient: HttpClient;  let httpTestingController: HttpTestingController;  beforeEach(() => {    TestBed.configureTestingModule({      imports: [ HttpClientTestingModule ]    });    // Inject the http service and test controller for each test    httpClient = TestBed.inject(HttpClient);    httpTestingController = TestBed.inject(HttpTestingController);  });  /// Tests begin ///});

现在,在测试中发起的这些请求会发给这些测试用的后端(testing backend),而不是标准的后端。

这种设置还会调用 TestBed.inject(),来获取注入的 HttpClient 服务和模拟对象的控制器 HttpTestingController,以便在测试期间引用它们。

期待并回复请求

现在,你就可以编写测试,等待 GET 请求并给出模拟响应。

Path:"app/testing/http-client.spec.ts(httpClient.get)" 。

it('can test HttpClient.get', () => {  const testData: Data = {name: 'Test Data'};  // Make an HTTP GET request  httpClient.get<Data>(testUrl)    .subscribe(data =>      // When observable resolves, result should match test data      expect(data).toEqual(testData)    );  // The following `expectOne()` will match the request's URL.  // If no requests or multiple requests matched that URL  // `expectOne()` would throw.  const req = httpTestingController.expectOne('/data');  // Assert that the request is a GET.  expect(req.request.method).toEqual('GET');  // Respond with mock data, causing Observable to resolve.  // Subscribe callback asserts that correct data was returned.  req.flush(testData);  // Finally, assert that there are no outstanding requests.  httpTestingController.verify();});

最后一步,验证没有发起过预期之外的请求,足够通用,因此你可以把它移到 afterEach() 中:

afterEach(() => {  // After every test, assert that there are no more pending requests.  httpTestingController.verify();});

  1. 自定义对请求的预期

如果仅根据 URL 匹配还不够,你还可以自行实现匹配函数。 比如,你可以验证外发的请求是否带有某个认证头:

    // Expect one request with an authorization header    const req = httpTestingController.expectOne(      req => req.headers.has('Authorization')    );

像前面的 expectOne() 测试一样,如果零或两个以上的请求满足了这个断言,它就会抛出异常。

  1. 处理一个以上的请求

如果你需要在测试中对重复的请求进行响应,可以使用 match() API 来代替 expectOne(),它的参数不变,但会返回一个与这些请求相匹配的数组。一旦返回,这些请求就会从将来要匹配的列表中移除,你要自己验证和刷新(flush)它。

// get all pending requests that match the given URLconst requests = httpTestingController.match(testUrl);expect(requests.length).toEqual(3);// Respond to each request with different resultsrequests[0].flush([]);requests[1].flush([testData[0]]);requests[2].flush(testData);

测试对错误的预期

你还要测试应用对于 HTTP 请求失败时的防护。

调用 request.flush() 并传入一个错误信息,如下所示:

it('can test for 404 error', () => {  const emsg = 'deliberate 404 error';  httpClient.get<Data[]>(testUrl).subscribe(    data => fail('should have failed with the 404 error'),    (error: HttpErrorResponse) => {      expect(error.status).toEqual(404, 'status');      expect(error.error).toEqual(emsg, 'message');    }  );  const req = httpTestingController.expectOne(testUrl);  // Respond with mock error  req.flush(emsg, { status: 404, statusText: 'Not Found' });});

另外,你还可以使用 ErrorEvent 来调用 request.error().

it('can test for network error', () => {  const emsg = 'simulated network error';  httpClient.get<Data[]>(testUrl).subscribe(    data => fail('should have failed with the network error'),    (error: HttpErrorResponse) => {      expect(error.error.message).toEqual(emsg, 'message');    }  );  const req = httpTestingController.expectOne(testUrl);  // Create mock ErrorEvent, raised when something goes wrong at the network level.  // Connection timeout, DNS error, offline, etc  const mockError = new ErrorEvent('Network error', {    message: emsg,  });  // Respond with mock error  req.error(mockError);});

应用内导航:路由到视图

在单页面应用中,你可以通过显示或隐藏特定组件的显示部分来改变用户能看到的内容,而不用去服务器获取新页面。当用户执行应用任务时,他们要在你预定义的不同视图之间移动。要想在应用的单个页面中实现这种导航,你可以使用 Angular 的 Router(路由器)。

为了处理从一个视图到下一个视图之间的导航,你可以使用 Angular 的路由器。路由器会把浏览器 URL 解释成改变视图的操作指南,以完成导航。

要探索一个具备路由器主要功能的示例应用,请参见现场演练 / 下载范例。

先决条件在创建路由之前,你应该熟悉以下内容:

  • 组件的基础知识

  • 模板的基础知识

  • 一个 Angular 应用,你可以使用 Angular CLI 生成一个基本的 Angular 应用。

有关这个现成应用的 Angular 简介,请参见快速上手。有关构建 Angular 应用的更深入体验,请参见英雄指南教程。两者都会指导你使用组件类和模板。

选择路由策略

你必须在开发项目的早期就选择一种路由策略,因为一旦该应用进入了生产阶段,你网站的访问者就会使用并依赖应用的这些 URL 引用。

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

在服务器端渲染指定的页面,是一项可以在该应用首次加载时大幅提升响应速度的技术。那些原本需要十秒甚至更长时间加载的应用,可以预先在服务端渲染好,并在少于一秒的时间内完整渲染在用户的设备上。

只有当应用的 URL 看起来像是标准的 Web URL,中间没有 hash(#)时,这个选项才能生效。

下面的命令会用 Angular CLI 来生成一个带有应用路由模块(AppRoutingModule)的基本 Angular 应用,它是一个 NgModule,可用来配置路由。下面的例子中应用的名字是 routing-app

ng new routing-app --routing

一旦生成新应用,CLI 就会提示你选择 CSSCSS 预处理器。在这个例子中,我们接受 CSS 的默认值。

为路由添加组件

为了使用 Angular 的路由器,应用至少要有两个组件才能从一个导航到另一个。要使用 CLI 创建组件,请在命令行输入以下内容,其中 first 是组件的名称:

ng generate component first

为第二个组件重复这个步骤,但给它一个不同的名字。这里的新名字是 second

ng generate component second

CLI 会自动添加 Component 后缀,所以如果在编写 first-component,那么其组件名就是 FirstComponentComponent

本指南适用于 CLI 生成的 Angular 应用。如果你是手动工作的,请确保你的 "index.html" 文件的 <head& 中有 <base href="/"& 语句。这里假定 "app" 文件夹是应用的根目录,并使用 "/" 作为基础路径。

导入这些新组件

要使用这些新组件,请把它们导入到该文件顶部的 AppRoutingModule 中,具体如下:

//AppRoutingModule (excerpt)import { FirstComponent } from './first/first.component';import { SecondComponent } from './second/second.component';

创建路由有三个基本的构建块。

AppRoutingModule 导入 AppModule 并把它添加到 imports 数组中。

Angular CLI 会为你执行这一步骤。但是,如果要手动创建应用或使用现存的非 CLI 应用,请验证导入和配置是否正确。下面是使用 --routing 标志生成的默认 AppModule

//Default CLI AppModule with routingimport { BrowserModule } from '@angular/platform-browser';import { NgModule } from '@angular/core';import { AppRoutingModule } from './app-routing.module'; // CLI imports AppRoutingModuleimport { AppComponent } from './app.component';@NgModule({  declarations: [    AppComponent  ],  imports: [    BrowserModule,    AppRoutingModule // CLI adds AppRoutingModule to the AppModule's imports array  ],  providers: [],  bootstrap: [AppComponent]})export class AppModule { }

  1. RouterModuleRoutes 导入到你的路由模块中。

Angular CLI 会自动执行这一步骤。CLI 还为你的路由设置了 Routes 数组,并为 @NgModule() 配置了 importsexports 数组。

    //CLI app routing module    import { NgModule } from '@angular/core';    import { Routes, RouterModule } from '@angular/router'; // CLI imports router    const routes: Routes = []; // sets up routes constant where you define your routes    // configures NgModule imports and exports    @NgModule({      imports: [RouterModule.forRoot(routes)],      exports: [RouterModule]    })    export class AppRoutingModule { }

  1. Routes 数组中定义你的路由。

这个数组中的每个路由都是一个包含两个属性的 JavaScript 对象。第一个属性 path 定义了该路由的 URL 路径。第二个属性 component 定义了要让 Angular 用作相应路径的组件。

    //AppRoutingModule (excerpt)    const routes: Routes = [      { path: 'first-component', component: FirstComponent },      { path: 'second-component', component: SecondComponent },    ];

  1. 把这些路由添加到你的应用中。

现在你已经定义了路由,可以把它们添加到应用中了。首先,添加到这两个组件的链接。把要添加路由的链接赋值给 routerLink 属性。将属性的值设置为该组件,以便在用户点击各个链接时显示这个值。接下来,修改组件模板以包含 <router-outlet> 标签。该元素会通知 Angular,你可以用所选路由的组件更新应用的视图。

    //Template with routerLink and router-outlet    <h1>Angular Router App</h1>    <!-- This nav gives you links to click, which tells the router which route to use (defined in the routes constant in  AppRoutingModule) -->    <nav>      <ul>        <li><a routerLink="/first-component" routerLinkActive="active">First Component</a></li>        <li><a routerLink="/second-component" routerLinkActive="active">Second Component</a></li>      </ul>    </nav>    <!-- The routed views render in the <router-outlet>-->    <router-outlet></router-outlet>

路由顺序

路由的顺序很重要,因为 Router 在匹配路由时使用“先到先得”策略,所以应该在不那么具体的路由前面放置更具体的路由。首先列出静态路径的路由,然后是一个与默认路由匹配的空路径路由。通配符路由是最后一个,因为它匹配每一个 URL,只有当其它路由都没有匹配时,Router 才会选择它。

通常,当用户导航你的应用时,你会希望把信息从一个组件传递到另一个组件。例如,考虑一个显示杂货商品购物清单的应用。列表中的每一项都有一个唯一的 id。要想编辑某个项目,用户需要单击“编辑”按钮,打开一个 EditGroceryItem 组件。你希望该组件得到该商品的 id,以便它能向用户显示正确的信息。

你也可以使用一个路由把这种类型的信息传给你的应用组件。要做到这一点,你可以使用 ActivatedRoute 接口。

要从路由中获取信息:

  1. ActivatedRouteParamMap 导入你的组件。

    //In the component class (excerpt)    import { Router, ActivatedRoute, ParamMap } from '@angular/router';

这些 import 语句添加了组件所需的几个重要元素。要详细了解每个 API,请参阅以下 API 页面:

  • Router

  • ActivatedRoute

  • ParamMap

  1. 通过把 ActivatedRoute 的一个实例添加到你的应用的构造函数中来注入它:

//In the component class (excerpt)constructor(  private route: ActivatedRoute,) {}

  1. 更新 ngOnInit() 方法来访问这个 ActivatedRoute 并跟踪 id 参数:

//In the component (excerpt)ngOnInit() {  this.route.queryParams.subscribe(params => {    this.name = params['name'];  });}

注:

  • 前面的例子使用了一个变量 name,并根据 name 参数给它赋值。

当用户试图导航到那些不存在的应用部件时,在正常的应用中应该能得到很好的处理。要在应用中添加此功能,需要设置通配符路由。当所请求的 `URL 与任何路由器路径都不匹配时,Angular 路由器就会选择这个路由。

要设置通配符路由,请在 routes 定义中添加以下代码。

//AppRoutingModule (excerpt){ path: '**', component:  }

这两个星号 ** 告诉 Angular,这个 routes 定义是通配符路由。对于 component 属性,你可以使用应用中的任何组件。常见的选择包括应用专属的 PageNotFoundComponent,你可以定义它来向用户展示 404 页面,或者跳转到应用的主组件。通配符路由是最后一个路由,因为它匹配所有的 URL。有关路由顺序的更多详细信息,请参阅路由顺序。

显示 404 页面

要显示 404 页面,请设置一个通配符路由,并将 component 属性设置为你要用于 404 页面的组件,如下所示:

//AppRoutingModule (excerpt)const routes: Routes = [  { path: 'first-component', component: FirstComponent },  { path: 'second-component', component: SecondComponent },  { path: '',   redirectTo: '/first-component', pathMatch: 'full' }, // redirect to `first-component`  { path: '**', component: FirstComponent },  { path: '**', component: PageNotFoundComponent },  // Wildcard route for a 404 page];

path** 的最后一条路由是通配符路由。如果请求的 URL 与前面列出的路径不匹配,路由器会选择这个路由,并把该用户送到 PageNotFoundComponent

设置重定向

要设置重定向,请使用重定向源的 path、要重定向目标的 component 和一个 pathMatch 值来配置路由,以告诉路由器该如何匹配 URL

//AppRoutingModule (excerpt)const routes: Routes = [  { path: 'first-component', component: FirstComponent },  { path: 'second-component', component: SecondComponent },  { path: '',   redirectTo: '/first-component', pathMatch: 'full' }, // redirect to `first-component`  { path: '**', component: FirstComponent },];

在这个例子中,第三个路由是重定向路由,所以路由器会默认跳到 first-component 路由。注意,这个重定向路由位于通配符路由之前。这里的 path: '' 表示使用初始的相对 URL( '' )。

随着你的应用变得越来越复杂,你可能要创建一些根组件之外的相对路由。这些嵌套路由类型称为子路由。这意味着你要为你的应用添加第二 <router-outlet>,因为它是 AppComponent 之外的另一个 <router-outlet>

在这个例子中,还有两个子组件,child-achild-b。这里的 FirstComponent 有它自己的 <nav>AppComponent 之外的第二 <router-outlet>

//In the template<h2>First Component</h2><nav>  <ul>    <li><a routerLink="child-a">Child A</a></li>    <li><a routerLink="child-b">Child B</a></li>  </ul></nav><router-outlet></router-outlet>

子路由和其它路由一样,同时需要 pathcomponent。唯一的区别是你要把子路由放在父路由的 children 数组中。

//AppRoutingModule (excerpt)const routes: Routes = [  { path: 'first-component',    component: FirstComponent, // this is the component with the <router-outlet> in the template    children: [      {        path: 'child-a', // child route path        component: ChildAComponent // child route component that the router renders      },      {        path: 'child-b',        component: ChildBComponent // another child route component that the router renders      }    ] },

相对路径允许你定义相对于当前 URL 段的路径。下面的例子展示了到另一个组件 second-component 的相对路由。FirstComponentSecondComponent 在树中处于同一级别,但是,指向 SecondComponent 的链接位于 FirstComponent 中,这意味着路由器必须先上升一个级别,然后进入二级目录才能找到 SecondComponent。你可以使用 ../ 符号来上升一个级别,而不用写出到 SecondComponent 的完整路径。

//In the template<h2>First Component</h2><nav>  <ul>    <li><a routerLink="../second-component">Relative Route to second component</a></li>  </ul></nav><router-outlet></router-outlet>

除了 ../,还可以使用 ./ 或者不带前导斜杠来指定当前级别。

指定相对路由

要指定相对路由,请使用 NavigationExtras 中的 relativeTo 属性。在组件类中,从 @angular/router 导入 NavigationExtras

然后在导航方法中使用 relativeTo 参数。在链接参数数组(它包含 items)之后添加一个对象,把该对象的 relativeTo 属性设置为当前的 ActivatedRoute,也就是 this.route

//RelativeTogoToItems() {  this.router.navigate(['items'], { relativeTo: this.route });}

goToItems() 方法会把目标 URI 解释为相对于当前路由的,并导航到 items 路由。

有时,应用中的某个特性需要访问路由的部件,比如查询参数或片段(fragment)。本教程的这个阶段使用了一个“英雄指南”中的列表视图,你可以在其中点击一个英雄来查看详情。路由器使用 id 来显示正确的英雄的详情。

首先,在要导航的组件中导入以下成员。

//Component import statements (excerpt)import { ActivatedRoute } from '@angular/router';import { Observable } from 'rxjs';import { switchMap } from 'rxjs/operators';

接下来,注入当前路由(ActivatedRoute)服务:

//Component (excerpt)constructor(private route: ActivatedRoute) {}

配置这个类,让你有一个可观察对象 heroes$、一个用来保存英雄的 id 号的 selectedId,以及 ngOnInit() 中的英雄们,添加下面的代码来获取所选英雄的 id。这个代码片段假设你有一个英雄列表、一个英雄服务、一个能获取你的英雄的函数,以及用来渲染你的列表和细节的 HTML,就像在《英雄指南》例子中一样。

//Component 1 (excerpt)heroes$: Observable;selectedId: number;heroes = HEROES;ngOnInit() {  this.heroes$ = this.route.paramMap.pipe(    switchMap(params => {      this.selectedId = Number(params.get('id'));      return this.service.getHeroes();    })  );}

接下来,在要导航到的组件中,导入以下成员。

//Component 2 (excerpt)import { Router, ActivatedRoute, ParamMap } from '@angular/router';import { Observable } from 'rxjs';

在组件类的构造函数中注入 ActivatedRouteRouter,这样在这个组件中就可以用它们了:

//Component 2 (excerpt)item$: Observable;  constructor(    private route: ActivatedRoute,    private router: Router  ) {}  ngOnInit() {    let id = this.route.snapshot.paramMap.get('id');    this.hero$ = this.service.getHero(id);  }  gotoItems(item: Item) {    let heroId = item ? hero.id : null;    // Pass along the item id if available    // so that the HeroList component can select that item.    this.router.navigate(['/heroes', { id: itemId }]);  }

惰性加载

你可以配置路由定义来实现惰性加载模块,这意味着 Angular 只会在需要时才加载这些模块,而不是在应用启动时就加载全部。 另外,你可以在后台预加载一些应用部件来改善用户体验。

关于惰性加载和预加载的详情,请参见专门的指南惰性加载 NgModule

防止未经授权的访问

使用路由守卫来防止用户未经授权就导航到应用的某些部分。Angular 中提供了以下路由守卫:

  • CanActivate
  • CanActivateChild
  • CanDeactivate
  • Resolve
  • CanLoad

要想使用路由守卫,可以考虑使用无组件路由,因为这对于保护子路由很方便。

为你的守卫创建一项服务:

ng generate guard your-guard

请在守卫类里实现你要用到的守卫。下面的例子使用 CanActivate 来保护该路由。

//Component (excerpt)export class YourGuard implements CanActivate {  canActivate(    next: ActivatedRouteSnapshot,    state: RouterStateSnapshot): boolean {      // your  logic goes here  }}

在路由模块中,在 routes 配置中使用相应的属性。这里的 canActivate 会告诉路由器它要协调到这个特定路由的导航。

//Routing module (excerpt){  path: '/your-path',  component: YourComponent,  canActivate: [YourGuard],}

虽然“快速上手:“英雄指南”教程介绍了 Angular 中的一般概念,而本篇 “路由器教程”详细介绍了 Angular 的路由能力。本教程将指导你在基本的路由器配置之上,创建子路由、读取路由参数、惰性加载 NgModules、路由守卫,和预加载数据,以改善用户体验。

概览

本章要讲的是如何开发一个带路由的多页面应用。 接下来会重点讲解了路由的关键特性,比如:

  • 把应用的各个特性组织成模块。

  • 导航到组件(Heroes 链接到“英雄列表”组件)。

  • 包含一个路由参数(当路由到“英雄详情”时,把该英雄的 id 传进去)。

  • 子路由(危机中心特性有一组自己的路由)。

  • CanActivate 守卫(检查路由的访问权限)。

  • CanActivateChild 守卫(检查子路由的访问权限)。

  • CanDeactivate 守卫(询问是否丢弃未保存的更改)。

  • Resolve 守卫(预先获取路由数据)。

  • 惰性加载一个模块。

  • CanLoad 守卫(在加载特性模块之前进行检查)。

就像你正逐步构建应用一样,本指南设置了一系列里程碑。不过这里假设你已经熟悉了 Angular 的基本概念。有关 Angular 的一般性介绍,参见 快速上手。有关更深入的概述,请参阅“英雄指南”教程。

范例程序实战

本教程的示例应用会帮助“英雄职业管理局”找到需要英雄来解决的危机。

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

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

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

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

该应用会渲染出一排导航按钮和和一个英雄列表视图。

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

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

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

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

选择其中之一,该应用就会把你带到此危机的编辑页面。 危机详情是当前页的子组件,就在列表的紧下方。

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

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

单击浏览器后退按钮或 “Heroes” 链接,可以激活一个对话框。

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

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

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

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

用 Angular CLI 生成一个范例应用。

ng new angular-router-sample

定义路由

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

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

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

第一个路由执行以下操作:

  • 当浏览器地址栏的 URL 变化时,如果它匹配上了路径部分 "/crisis-center",路由器就会激活一个 CrisisListComponent 的实例,并显示它的视图。

  • 当应用程序请求导航到路径 "/crisis-center" 时,路由器激活一个 CrisisListComponent 的实例,显示它的视图,并将该路径更新到浏览器地址栏和历史。

第一个配置定义了由两个路由构成的数组,它们用最短路径指向了 CrisisListComponentHeroListComponent

生成 CrisisListHeroList 组件,以便路由器能够渲染它们。

ng generate component crisis-list

ng generate component hero-list

  1. Path:"src/app/crisis-list/crisis-list.component.html" 。

    <h2>CRISIS CENTER</h2>    <p>Get your crisis here</p>

  1. Path:"src/app/hero-list/hero-list.component.html" 。

    <h2>HEROES</h2>    <p>Get your heroes here</p>

注册 Router 和 Routes

为了使用 Router,你必须注册来自 @angular/router 包中的 RouterModule。定义一个路由数组 appRoutes,并把它传给 RouterModule.forRoot() 方法。RouterModule.forRoot() 方法会返回一个模块,其中包含配置好的 Router 服务提供者,以及路由库所需的其它提供者。一旦启动了应用,Router 就会根据当前的浏览器 URL 进行首次导航。

注:

  • RouterModule.forRoot() 方法是用于注册全应用级提供者的编码模式。要详细了解全应用级提供者。

Path:"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/crisis-list.component';import { HeroListComponent }     from './hero-list/hero-list.component';const appRoutes: Routes = [  { path: 'crisis-center', component: CrisisListComponent },  { path: 'heroes', component: HeroListComponent },];@NgModule({  imports: [    BrowserModule,    FormsModule,    RouterModule.forRoot(      appRoutes,      { enableTracing: true } // <-- debugging purposes only    )  ],  declarations: [    AppComponent,    HeroListComponent,    CrisisListComponent,  ],  bootstrap: [ AppComponent ]})export class AppModule { }

对于最小化的路由配置,把配置好的 RouterModule 添加到 AppModule 中就足够了。但是,随着应用的成长,你将需要将路由配置重构到单独的文件中,并创建路由模块,路由模块是一种特殊的、专做路由的服务模块。

RouterModule.forRoot() 注册到 AppModuleimports 数组中,能让该 Router 服务在应用的任何地方都能使用。

添加路由出口

根组件 AppComponent 是本应用的壳。它在顶部有一个标题、一个带两个链接的导航条,在底部有一个路由器出口,路由器会在它所指定的位置上渲染各个组件。

路由出口扮演一个占位符的角色,表示路由组件将会渲染到哪里。

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

Path:"src/app/app.component.html" 。

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

定义通配符路由

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

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

路由器会使用先到先得的策略来选择路由。 由于通配符路由是最不具体的那个,因此务必确保它是路由配置中的最后一个路由。

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

Path:"src/app/hero-list/hero-list.component.html (excerpt)" 。

<h2>HEROES</h2><p>Get your heroes here</p><button routerLink="/sidekicks">Go to sidekicks</button>

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

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

Path:"src/app/app.module.ts (wildcard)" 。

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

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

ng generate component page-not-found

Path:"src/app/page-not-found.component.html (404 component)" 。

<h2>Page not found</h2>

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

设置跳转

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

localhost:4200

它不能匹配上任何硬编码进来的路由,于是就会走到通配符路由中去,并且显示 PageNotFoundComponent

这个应用需要一个有效的默认路由,在这里应该用英雄列表作为默认页。当用户点击 "Heroes" 链接或把 "localhost:4200/heroes" 粘贴到地址栏时,它应该导航到列表页。

添加一个 redirect 路由,把最初的相对 URL('')转换成所需的默认路径(/heroes)。

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

Path:"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 }];

浏览器的地址栏会显示 ".../heroes",好像你直接在那里导航一样。

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

聚焦 PATHMATCH

从技术角度看,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'

小结

当用户单击某个链接时,该示例应用可以在两个视图之间切换。

本节涵盖了以下几点的做法:

  • 加载路由库。

  • 往壳组件的模板中添加一个导航条,导航条中有一些 A 标签、routerLink 指令和 routerLinkActive 指令。

  • 往壳组件的模板中添加一个 router-outlet 指令,视图将会被显示在那里。

  • RouterModule.forRoot() 配置路由器模块。

  • 设置路由器,使其合成 HTML5 模式的浏览器 URL

  • 使用通配符路由来处理无效路由。

  • 当应用在空路径下启动时,导航到默认路由。

初学者应用结构图:

本节产生的文件列表:

  1. Path:"app.component.html" 。

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

  1. Path:"app.module.ts" 。

    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/crisis-list.component';    import { HeroListComponent }     from './hero-list/hero-list.component';    import { PageNotFoundComponent } from './page-not-found/page-not-found.component';    const appRoutes: Routes = [      { path: 'crisis-center', component: CrisisListComponent },      { path: 'heroes', component: HeroListComponent },      { path: '',   redirectTo: '/heroes', pathMatch: 'full' },      { path: '**', component: PageNotFoundComponent }    ];    @NgModule({      imports: [        BrowserModule,        FormsModule,        RouterModule.forRoot(          appRoutes,          { enableTracing: true } // <-- debugging purposes only        )      ],      declarations: [        AppComponent,        HeroListComponent,        CrisisListComponent,        PageNotFoundComponent      ],      bootstrap: [ AppComponent ]    })    export class AppModule { }

  1. Path:"hero-list/hero-list.component.html" 。

    <h2>HEROES</h2>    <p>Get your heroes here</p>    <button routerLink="/sidekicks">Go to sidekicks</button>

  1. Path:"crisis-list/crisis-list.component.html" 。

    <h2>CRISIS CENTER</h2>    <p>Get your crisis here</p>

  1. Path:"page-not-found/page-not-found.component.html" 。

    <h2>Page not found</h2>

  1. Path:"index.html" 。

    <html lang="en">      <head>        <!-- Set the base href -->        <base href="/">        <title>Angular Router</title>        <meta charset="UTF-8">        <meta name="viewport" content="width=device-width, initial-scale=1">      </head>      <body>        <app-root></app-root>      </body>    </html>

本节会向你展示如何配置一个名叫路由模块的专用模块,它会保存你应用的路由配置。

路由模块有以下几个特点:

  • 把路由这个关注点从其它应用类关注点中分离出去。

  • 测试特性模块时,可以替换或移除路由模块。

  • 为路由服务提供者(如守卫和解析器等)提供一个众所周知的位置。

  • 不要声明组件。

把路由集成到应用中

路由应用范例中默认不包含路由。 要想在使用 Angular CLI 创建项目时支持路由,请为项目或应用的每个 NgModule 设置 --routing 选项。 当你用 CLI 命令 ng new 创建新项目或用 ng generate app 命令创建新应用,请指定 --routing 选项。这会告诉 CLI 包含上 @angular/router 包,并创建一个名叫 "app-routing.module.ts" 的文件。 然后你就可以在添加到项目或应用中的任何 NgModule 中使用路由功能了。

比如,可以用下列命令生成带路由的 NgModule

ng generate module my-module --routing

这将创建一个名叫 "my-module-routing.module.ts" 的独立文件,来保存这个 NgModule 的路由信息。 该文件包含一个空的 Routes 对象,你可以使用一些指向各个组件和 NgModule 的路由来填充该对象。

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

在 "/app" 目录下创建一个 AppRouting 模块,以包含路由配置。

ng generate module app-routing --module app --flat

导入 CrisisListComponent、HeroListComponent 和 PageNotFoundCompponent 组件,就像 app.module.ts 中那样。然后把 Router 的导入语句和路由配置以及 RouterModule.forRoot() 移入这个路由模块中。

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

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

Path:"src/app/app-routing.module.ts" 。

import { NgModule }              from '@angular/core';import { RouterModule, Routes }  from '@angular/router';import { CrisisListComponent }   from './crisis-list/crisis-list.component';import { HeroListComponent }     from './hero-list/hero-list.component';import { PageNotFoundComponent } from './page-not-found/page-not-found.component';const appRoutes: Routes = [  { path: 'crisis-center', component: CrisisListComponent },  { path: 'heroes',        component: HeroListComponent },  { path: '',   redirectTo: '/heroes', pathMatch: 'full' },  { path: '**', component: PageNotFoundComponent }];@NgModule({  imports: [    RouterModule.forRoot(      appRoutes,      { enableTracing: true } // <-- debugging purposes only    )  ],  exports: [    RouterModule  ]})export class AppRoutingModule {}

接下来,修改 "app.module.ts" 文件,从 imports 数组中移除 RouterModule.forRoot

Path:"src/app/app.module.ts" 。

import { NgModule }       from '@angular/core';import { BrowserModule }  from '@angular/platform-browser';import { FormsModule }    from '@angular/forms';import { AppComponent }     from './app.component';import { AppRoutingModule } from './app-routing.module';import { CrisisListComponent }   from './crisis-list/crisis-list.component';import { HeroListComponent }     from './hero-list/hero-list.component';import { PageNotFoundComponent } from './page-not-found/page-not-found.component';@NgModule({  imports: [    BrowserModule,    FormsModule,    AppRoutingModule  ],  declarations: [    AppComponent,    HeroListComponent,    CrisisListComponent,    PageNotFoundComponent  ],  bootstrap: [ AppComponent ]})export class AppModule { }

应用继续照常运行,你可以把路由模块作为将来每个模块维护路由配置的中心位置。

路由模块的优点

路由模块(通常称为 AppRoutingModule )代替了根模板或特性模块中的路由模块。

这种路由模块在你的应用不断增长,以及配置中包括了专门的守卫和解析器服务时会非常有用。

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

大多数应用都应该采用路由模块,以保持一致性。 它在配置复杂时,能确保代码干净。 它让测试特性模块更加容易。 它的存在让人一眼就能看出这个模块是带路由的。 开发者可以很自然的从路由模块中查找和扩展路由配置。

本节涵盖了以下内容:

  • 用模块把应用和路由组织为一些特性区。

  • 命令式的从一个组件导航到另一个

  • 通过路由传递必要信息和可选信息

这个示例应用在“英雄指南”教程的“服务”部分重新创建了英雄特性区,并复用了 Tour of Heroes: Services example code 中的大部分代码。

典型的应用具有多个特性区,每个特性区都专注于特定的业务用途并拥有自己的文件夹。

该部分将向你展示如何将应用重构为不同的特性模块、将它们导入到主模块中,并在它们之间导航。

添加英雄管理功能

遵循下列步骤:

  • 为了管理这些英雄,在 "heroes" 目录下创建一个带路由的 HeroesModule,并把它注册到根模块 AppModule 中。

    ng generate module heroes/heroes --module app --flat --routing

  • 把 "app" 下占位用的 "hero-list" 目录移到 "heroes" 目录中。

  • <h2> 加文字,改成 <h2>HEROES</h2>

  • 删除模板底部的 <app-hero-detail> 组件。

  • 把现场演练中 "heroes/heroes.component.css" 文件的内容复制到 "hero-list.component.css" 文件中。

  • 把现场演练中 "heroes/heroes.component.ts" 文件的内容复制到 "hero-list.component.ts" 文件中。

  • 把组件类名改为 HeroListComponent

  • selector 改为 app-hero-list

注:

  • 对于路由组件来说,这些选择器不是必须的,因为这些组件是在渲染页面时动态插入的,不过选择器对于在 HTML 元素树中标记和选中它们是很有用的。

  • 把 "hero-detail" 目录中的 "hero.ts"、"hero.service.ts" 和 "mock-heroes.ts" 文件复制到 "heroes" 子目录下。

  • 把 "message.service.ts" 文件复制到 "src/app" 目录下。

  • 在 "hero.service.ts" 文件中修改导入 "message.service" 的相对路径。

接下来,更新 HeroesModule 的元数据。

  • 导入 HeroDetailComponentHeroListComponent,并添加到 HeroesModule 模块的 declarations 数组中。

Path:"src/app/heroes/heroes.module.ts" 。

import { NgModule }       from '@angular/core';import { CommonModule }   from '@angular/common';import { FormsModule }    from '@angular/forms';import { HeroListComponent }    from './hero-list/hero-list.component';import { HeroDetailComponent }  from './hero-detail/hero-detail.component';import { HeroesRoutingModule } from './heroes-routing.module';@NgModule({  imports: [    CommonModule,    FormsModule,    HeroesRoutingModule  ],  declarations: [    HeroListComponent,    HeroDetailComponent  ]})export class HeroesModule {}

英雄管理部分的文件结构如下:

  1. 英雄特性区的路由需求:

英雄特性区中有两个相互协作的组件:英雄列表和英雄详情。当你导航到列表视图时,它会获取英雄列表并显示出来。当你点击一个英雄时,详细视图就会显示那个特定的英雄。

通过把所选英雄的 id 编码进路由的 URL 中,就能告诉详情视图该显示哪个英雄。

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

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

AppRoutingModule 中,你使用了静态的 RouterModule.forRoot() 方法来注册路由和全应用级服务提供者。在特性模块中你要改用 forChild() 静态方法。

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

修改后的 HeroesRoutingModule 是这样的:

Path:"src/app/heroes/heroes-routing.module.ts" 。

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

考虑为每个特性模块提供自己的路由配置文件。虽然特性路由目前还很少,但即使在小型应用中,路由也会变得越来越复杂。

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

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

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

移除 HeroListComponent 的导入和来自 "app-routing.module.ts" 中的 /heroes 路由。

保留默认路由和通配符路由,因为这些路由仍然要在应用的顶层使用。

Path:"src/app/app-routing.module.ts (v2)" 。

    import { NgModule }              from '@angular/core';    import { RouterModule, Routes }  from '@angular/router';    import { CrisisListComponent }   from './crisis-list/crisis-list.component';    // import { HeroListComponent }  from './hero-list/hero-list.component';  // <-- delete this line    import { PageNotFoundComponent } from './page-not-found/page-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,          { enableTracing: true } // <-- debugging purposes only        )      ],      exports: [        RouterModule      ]    })    export class AppRoutingModule {}

  1. 移除英雄列表的声明。

因为 HeroesModule 现在提供了 HeroListComponent,所以把它从 AppModuledeclarations 数组中移除。现在你已经有了一个单独的 HeroesModule,你可以用更多的组件和不同的路由来演进英雄特性区。

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

Path:"src/app/app.module.ts" 。

    import { NgModule }       from '@angular/core';    import { BrowserModule }  from '@angular/platform-browser';    import { FormsModule }    from '@angular/forms';    import { AppComponent }     from './app.component';    import { AppRoutingModule } from './app-routing.module';    import { HeroesModule }     from './heroes/heroes.module';    import { CrisisListComponent }   from './crisis-list/crisis-list.component';    import { PageNotFoundComponent } from './page-not-found/page-not-found.component';    @NgModule({      imports: [        BrowserModule,        FormsModule,        HeroesModule,        AppRoutingModule      ],      declarations: [        AppComponent,        CrisisListComponent,        PageNotFoundComponent      ],      bootstrap: [ AppComponent ]    })    export class AppModule { }

模块导入顺序

请注意该模块的 imports 数组,AppRoutingModule 是最后一个,并且位于 HeroesModule 之后。

Path:"src/app/app.module.ts (module-imports)" 。

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

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

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

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

反转路由模块的导入顺序,就会看到当点击英雄相关的链接时被导向了“页面未找到”路由。

路由参数

  1. 带参数的路由定义。

回到 HeroesRoutingModule 并再次检查这些路由定义。 HeroDetailComponent 路由的路径中带有 :id 令牌。

Path:"src/app/heroes/heroes-routing.module.ts (excerpt)" 。

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

:id 令牌会为路由参数在路径中创建一个“空位”。在这里,这种配置会让路由器把英雄的 id 插入到那个“空位”中。

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

    localhost:4200/hero/15

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

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

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

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

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

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

Path:"src/app/heroes/hero-list/hero-list.component.html (link-parameters-array)" 。

    <a [routerLink]="['/hero', hero.id]">

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

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

ActivatedRoute 实战

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

Path:"src/app/heroes/hero-detail/hero-detail.component.ts (activated route)" 。

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

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

Path:"src/app/heroes/hero-detail/hero-detail.component.ts (switchMap operator import)" 。

import { switchMap } from 'rxjs/operators';

把这些服务作为私有变量添加到构造函数中,以便 Angular 注入它们(让它们对组件可见)。

Path:"src/app/heroes/hero-detail/hero-detail.component.ts (constructor)" 。

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

ngOnInit() 方法中,使用 ActivatedRoute 服务来检索路由的参数,从参数中提取出英雄的 id,并检索要显示的英雄。

Path:"src/app/heroes/hero-detail/hero-detail.component.ts (ngOnInit)" 。

ngOnInit() {  this.hero$ = this.route.paramMap.pipe(    switchMap((params: ParamMap) =>      this.service.getHero(params.get('id')))  );}

当这个 map 发生变化时,paramMap 会从更改后的参数中获取 id 参数。

然后,让 HeroService 去获取具有该 id 的英雄,并返回 HeroService 请求的结果。

switchMap 操作符做了两件事。它把 HeroService 返回的 Observable<Hero> 拍平,并取消以前的未完成请求。当 HeroService 仍在检索旧的 id 时,如果用户使用新的 id 重新导航到这个路由,switchMap 会放弃那个旧请求,并返回新 id 的英雄。

AsyncPipe 处理这个可观察的订阅,而且该组件的 hero 属性也会用检索到的英雄(重新)进行设置。

  1. ParamMap API

ParamMap API 的灵感来自于 URLSearchParams 接口。它提供了处理路由参数( paramMap )和查询参数( queryParamMap )访问的方法。

  • 成员:has(name) 。

说明:如果参数名位于参数列表中,就返回 true

  • 成员:get(name) 。

说明:如果这个 map 中有参数名对应的参数值(字符串),就返回它,否则返回 null。如果参数值实际上是一个数组,就返回它的第一个元素。

  • 成员:getAll(name) 。

说明:如果这个 map 中有参数名对应的值,就返回一个字符串数组,否则返回空数组。当一个参数名可能对应多个值的时候,请使用 getAll

  • 成员:keys 。

说明:返回这个 map 中的所有参数名组成的字符串数组。

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

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

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

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

你肯定不希望路由器先从 DOM 中移除当前的 HeroDetailComponent 实例,只是为了用下一个 id 重新创建它,因为它将重新渲染视图。为了更好的用户体验,路由器会复用同一个组件实例,而只是更新参数。

由于 ngOnInit() 在每个组件实例化时只会被调用一次,所以你可以使用 paramMap 可观察对象来检测路由参数在同一个实例中何时发生了变化。

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

不过,ActivatedRoute 中的可观察对象是一个例外,因为 ActivatedRoute 及其可观察对象与 Router 本身是隔离的。 Router 会在不再需要时销毁这个路由组件,而注入进去的 ActivateRoute 也随之销毁了。

  1. snapshot:当不需要 Observable 时的替代品。

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

假如你很确定这个 HeroDetailComponent 实例永远不会被重用,你可以使用 snapshot

route.snapshot 提供了路由参数的初始值。 你可以通过它来直接访问参数,而不用订阅或者添加 Observable 的操作符,代码如下:

Path:"src/app/heroes/hero-detail/hero-detail.component.ts (ngOnInit snapshot)" 。

    ngOnInit() {      let id = this.route.snapshot.paramMap.get('id');      this.hero$ = this.service.getHero(id);    }

用这种技术,snapshot 只会得到这些参数的初始值。如果路由器可能复用该组件,那么就该用 paramMap 可观察对象的方式。本教程的示例应用中就用了 paramMap 可观察对象。

导航回列表组件

HeroDetailComponent 的 “Back” 按钮使用了 gotoHeroes() 方法,该方法会强制导航回 HeroListComponent

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

Path:"src/app/heroes/hero-detail/hero-detail.component.ts (excerpt)"。

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

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

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

    localhost:4200/hero/15

你也能在路由请求中添加可选信息。 比如,当从 "hero-detail.component.ts" 返回到列表时,如果能自动选中刚刚查看过的英雄就好了。

当从 HeroDetailComponent 返回时,你可以会通过把正在查看的英雄的 id 作为可选参数包含在 URL 中来实现这个特性。

可选信息还可以包含其它形式,例如:

  • 结构松散的搜索条件。比如 name='wind_'

  • 多个值。比如 after='12/31/2015' & before='1/1/2017' - 没有特定的顺序 -before='1/1/2017' & after='12/31/2015' - 具有各种格式 -during='currentYear'

由于这些参数不适合用作 URL 路径,因此可以使用可选参数在导航过程中传递任意复杂的信息。可选参数不参与模式匹配,因此在表达上提供了巨大的灵活性。

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

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

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

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

Path:"src/app/heroes/hero-list/hero-list.component.html (link-parameters-array)"。

    <a [routerLink]="['/hero', hero.id]">

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

Path:"src/app/heroes/heroes-routing.module.ts (hero-detail-route)"。

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

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

Path:"src/app/heroes/hero-detail/hero-detail.component.ts (gotoHeroes)"。

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

该数组缺少一个路由参数,这是因为以前你不需要往 HeroListComponent 发送信息。

现在,使用导航请求发送当前英雄的 id,以便 HeroListComponent 在其列表中突出显示该英雄。

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

Path:"src/app/heroes/hero-detail/hero-detail.component.ts (go to heroes)"。

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

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

注意浏览器的地址栏。

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

    localhost:4200/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 写法。

ActivatedRoute 服务中的路由参数

开发到现在,英雄列表还没有变化。没有突出显示的英雄行。

HeroListComponent 需要添加使用这些参数的代码。

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

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

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

Path:"src/app/heroes/hero-list/hero-list.component.ts (import)"。

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

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

Path:"src/app/heroes/hero-list/hero-list.component.ts (rxjs imports)"。

import { Observable } from 'rxjs';import { switchMap } from 'rxjs/operators';

HeroListComponent 构造函数中注入 ActivatedRoute

Path:"src/app/heroes/hero-list/hero-list.component.ts (constructor and ngOnInit)"。

export class HeroListComponent implements OnInit {  heroes$: Observable<Hero[]>;  selectedId: number;  constructor(    private service: HeroService,    private route: ActivatedRoute  ) {}  ngOnInit() {    this.heroes$ = this.route.paramMap.pipe(      switchMap(params => {        // (+) before `params.get()` turns the string into a number        this.selectedId = +params.get('id');        return this.service.getHeroes();      })    );  }}

ActivatedRoute.paramMap 属性是一个路由参数的 Observable。当用户导航到这个组件时,paramMap 会发射一个新值,其中包含 id。 在 ngOnInit() 中,你订阅了这些值,设置到 selectedId,并获取英雄数据。

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

Path:"src/app/heroes/hero-list/hero-list.component.html"。

<h2>HEROES</h2><ul class="heroes">  <li *ngFor="let hero of heroes$ | async"    [class.selected]="hero.id === selectedId">    <a [routerLink]="['/hero', hero.id]">      <span class="badge">{{ hero.id }}</span>{{ hero.name }}    </a>  </li></ul><button routerLink="/sidekicks">Go to sidekicks</button>

当选中列表条目时,要添加一些样式。

Path:"src/app/heroes/hero-list/hero-list.component.css"。

.heroes li.selected {  background-color: #CFD8DC;  color: white;}.heroes li.selected:hover {  background-color: #BBD8DC;}

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

这个可选的 foo 路由参数人畜无害,路由器会继续忽略它。

添加路由动画

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

首先导入 BrowserAnimationsModule,并添加到 imports 数组中:

Path:"src/app/app.module.ts (animations-module)"。

import { BrowserAnimationsModule } from '@angular/platform-browser/animations';@NgModule({  imports: [    BrowserAnimationsModule,  ],})

接下来,为指向 HeroListComponentHeroDetailComponent 的路由定义添加一个 data 对象。 转场是基于 states 的,你将使用来自路由的 animation 数据为转场提供一个有名字的动画 state

Path:"src/app/heroes/heroes-routing.module.ts (animation data)"。

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

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

Path:"src/app/animations.ts (excerpt)" 。

import {  trigger, animateChild, group,  transition, animate, style, query} from '@angular/animations';// Routable animationsexport const slideInAnimation =  trigger('routeAnimation', [    transition('heroes <=> hero', [      style({ position: 'relative' }),      query(':enter, :leave', [        style({          position: 'absolute',          top: 0,          left: 0,          width: '100%'        })      ]),      query(':enter', [        style({ left: '-100%'})      ]),      query(':leave', animateChild()),      group([        query(':leave', [          animate('300ms ease-out', style({ left: '100%'}))        ]),        query(':enter', [          animate('300ms ease-out', style({ left: '0%'}))        ])      ]),      query(':enter', animateChild()),    ])  ]);

该文件做了如下工作:

  • 导入动画符号以构建动画触发器、控制状态并管理状态之间的过渡。

  • 导出了一个名叫 slideInAnimation 的常量,并把它设置为一个名叫 routeAnimation 的动画触发器。

  • 定义一个转场动画,当在 heroeshero 路由之间来回切换时,如果进入(:enter)应用视图则让组件从屏幕的左侧滑入,如果离开(:leave)应用视图则让组件从右侧划出。

回到 AppComponent,从 @angular/router 包导入 RouterOutlet,并从 "./animations.ts" 导入 slideInAnimation

为包含 slideInAnimation@Component 元数据添加一个 animations 数组。

Path:"src/app/app.component.ts (animations)" 。

import { RouterOutlet } from '@angular/router';import { slideInAnimation } from './animations';@Component({  selector: 'app-root',  templateUrl: 'app.component.html',  styleUrls: ['app.component.css'],  animations: [ slideInAnimation ]})

要想使用路由动画,就要把 RouterOutlet 包装到一个元素中。再把 @routeAnimation 触发器绑定到该元素上。

为了把 @routeAnimation 转场转场到指定的状态,你需要从 ActivatedRoutedata 中提供它。 RouterOutlet 导出成了一个模板变量 outlet,这样你就可以绑定一个到路由出口的引用了。这个例子中使用了一个 routerOutlet 变量。

Path:"src/app/app.component.html (router outlet)" 。

<h1>Angular Router</h1><nav>  <a routerLink="/crisis-center" routerLinkActive="active">Crisis Center</a>  <a routerLink="/heroes" routerLinkActive="active">Heroes</a></nav><div [@routeAnimation]="getAnimationData(routerOutlet)">  <router-outlet #routerOutlet="outlet"></router-outlet></div>

@routeAnimation 属性使用所提供的 routerOutlet 引用来绑定到 getAnimationData(),因此下一步就要在 AppComponent 中定义那个函数。getAnimationData 函数会根据 ActivatedRoute 所提供的 data 对象返回动画的属性。animation 属性会根据你在 "animations.ts" 中定义 slideInAnimation() 时使用的 transition 名称进行匹配。

Path:"src/app/app.component.ts (router outlet)" 。

export class AppComponent {  getAnimationData(outlet: RouterOutlet) {    return outlet && outlet.activatedRouteData && outlet.activatedRouteData['animation'];  }}

如果在两个路由之间切换,导航进来时,HeroDetailComponentHeroListComponent 会从左侧滑入;导航离开时将会从右侧划出。

小结

本节包括以下内容:

  • 把应用组织成特性区。

  • 命令式的从一个组件导航到另一个。

  • 通过路由参数传递信息,并在组件中订阅它们。

  • 把这个特性分区模块导入根模块 AppModule

  • 把动画应用到路由组件上。

做完这些修改之后,目录结构如下:

本节产生的文件列表:

  1. Path:"animations.ts" 。

    import {      trigger, animateChild, group,      transition, animate, style, query    } from '@angular/animations';    // Routable animations    export const slideInAnimation =      trigger('routeAnimation', [        transition('heroes <=> hero', [          style({ position: 'relative' }),          query(':enter, :leave', [            style({              position: 'absolute',              top: 0,              left: 0,              width: '100%'            })          ]),          query(':enter', [            style({ left: '-100%'})          ]),          query(':leave', animateChild()),          group([            query(':leave', [              animate('300ms ease-out', style({ left: '100%'}))            ]),            query(':enter', [              animate('300ms ease-out', style({ left: '0%'}))            ])          ]),          query(':enter', animateChild()),        ])      ]);

  1. Path:"app.component.html" 。

    <h1>Angular Router</h1>    <nav>      <a routerLink="/crisis-center" routerLinkActive="active">Crisis Center</a>      <a routerLink="/heroes" routerLinkActive="active">Heroes</a>    </nav>    <div [@routeAnimation]="getAnimationData(routerOutlet)">      <router-outlet #routerOutlet="outlet"></router-outlet>    </div>

  1. Path:"app.component.ts" 。

    import { Component } from '@angular/core';    import { RouterOutlet } from '@angular/router';    import { slideInAnimation } from './animations';    @Component({      selector: 'app-root',      templateUrl: 'app.component.html',      styleUrls: ['app.component.css'],      animations: [ slideInAnimation ]    })    export class AppComponent {      getAnimationData(outlet: RouterOutlet) {        return outlet && outlet.activatedRouteData && outlet.activatedRouteData['animation'];      }    }

  1. Path:"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 { AppRoutingModule } from './app-routing.module';    import { HeroesModule }     from './heroes/heroes.module';    import { CrisisListComponent }   from './crisis-list/crisis-list.component';    import { PageNotFoundComponent } from './page-not-found/page-not-found.component';    @NgModule({      imports: [        BrowserModule,        BrowserAnimationsModule,        FormsModule,        HeroesModule,        AppRoutingModule      ],      declarations: [        AppComponent,        CrisisListComponent,        PageNotFoundComponent      ],      bootstrap: [ AppComponent ]    })    export class AppModule { }

  1. Path:"app-routing.module.ts" 。

    import { NgModule }              from '@angular/core';    import { RouterModule, Routes }  from '@angular/router';    import { CrisisListComponent }   from './crisis-list/crisis-list.component';    /* . . . */    import { PageNotFoundComponent } from './page-not-found/page-not-found.component';    const appRoutes: Routes = [      { path: 'crisis-center', component: CrisisListComponent },    /* . . . */      { path: '',   redirectTo: '/heroes', pathMatch: 'full' },      { path: '**', component: PageNotFoundComponent }    ];    @NgModule({      imports: [        RouterModule.forRoot(          appRoutes,          { enableTracing: true } // <-- debugging purposes only        )      ],      exports: [        RouterModule      ]    })    export class AppRoutingModule {}

  1. Path:"hero-list.component.css" 。

    /* HeroListComponent's private CSS styles */    .heroes {      margin: 0 0 2em 0;      list-style-type: none;      padding: 0;      width: 15em;    }    .heroes li {      position: relative;      cursor: pointer;      background-color: #EEE;      margin: .5em;      padding: .3em 0;      height: 1.6em;      border-radius: 4px;    }    .heroes li:hover {      color: #607D8B;      background-color: #DDD;      left: .1em;    }    .heroes a {      color: #888;      text-decoration: none;      position: relative;      display: block;    }    .heroes a:hover {      color:#607D8B;    }    .heroes .badge {      display: inline-block;      font-size: small;      color: white;      padding: 0.8em 0.7em 0 0.7em;      background-color: #607D8B;      line-height: 1em;      position: relative;      left: -1px;      top: -4px;      height: 1.8em;      min-width: 16px;      text-align: right;      margin-right: .8em;      border-radius: 4px 0 0 4px;    }    button {      background-color: #eee;      border: none;      padding: 5px 10px;      border-radius: 4px;      cursor: pointer;      cursor: hand;      font-family: Arial;    }    button:hover {      background-color: #cfd8dc;    }    button.delete {      position: relative;      left: 194px;      top: -32px;      background-color: gray !important;      color: white;    }    .heroes li.selected {      background-color: #CFD8DC;      color: white;    }    .heroes li.selected:hover {      background-color: #BBD8DC;    }

  1. Path:"hero-list.component.html" 。

    <h2>HEROES</h2>    <ul class="heroes">      <li *ngFor="let hero of heroes$ | async"        [class.selected]="hero.id === selectedId">        <a [routerLink]="['/hero', hero.id]">          <span class="badge">{{ hero.id }}</span>{{ hero.name }}        </a>      </li>    </ul>    <button routerLink="/sidekicks">Go to sidekicks</button>

  1. Path:"hero-list.component.ts" 。

    // TODO: Feature Componetized like CrisisCenter    import { Observable } from 'rxjs';    import { switchMap } from 'rxjs/operators';    import { Component, OnInit } from '@angular/core';    import { ActivatedRoute } from '@angular/router';    import { HeroService }  from '../hero.service';    import { Hero } from '../hero';    @Component({      selector: 'app-hero-list',      templateUrl: './hero-list.component.html',      styleUrls: ['./hero-list.component.css']    })    export class HeroListComponent implements OnInit {      heroes$: Observable<Hero[]>;      selectedId: number;      constructor(        private service: HeroService,        private route: ActivatedRoute      ) {}      ngOnInit() {        this.heroes$ = this.route.paramMap.pipe(          switchMap(params => {            // (+) before `params.get()` turns the string into a number            this.selectedId = +params.get('id');            return this.service.getHeroes();          })        );      }    }

  1. Path:"hero-detail.component.html" 。

    <h2>HEROES</h2>    <div *ngIf="hero$ | async as hero">      <h3>"{{ hero.name }}"</h3>      <div>        <label>Id: </label>{{ hero.id }}</div>      <div>        <label>Name: </label>        <input [(ngModel)]="hero.name" placeholder="name"/>      </div>      <p>        <button (click)="gotoHeroes(hero)">Back</button>      </p>    </div>

  1. Path:"hero-detail.component.ts" 。

    import { switchMap } from 'rxjs/operators';    import { Component, OnInit } from '@angular/core';    import { Router, ActivatedRoute, ParamMap } from '@angular/router';    import { Observable } from 'rxjs';    import { HeroService }  from '../hero.service';    import { Hero } from '../hero';    @Component({      selector: 'app-hero-detail',      templateUrl: './hero-detail.component.html',      styleUrls: ['./hero-detail.component.css']    })    export class HeroDetailComponent implements OnInit {      hero$: Observable<Hero>;      constructor(        private route: ActivatedRoute,        private router: Router,        private service: HeroService      ) {}      ngOnInit() {        this.hero$ = this.route.paramMap.pipe(          switchMap((params: ParamMap) =>            this.service.getHero(params.get('id')))        );      }      gotoHeroes(hero: Hero) {        let heroId = hero ? hero.id : null;        // Pass along the hero id if available        // so that the HeroList component can select that hero.        // Include a junk 'foo' property for fun.        this.router.navigate(['/heroes', { id: heroId, foo: 'foo' }]);      }    }    /*      this.router.navigate(['/superheroes', { id: heroId, foo: 'foo' }]);    */

  1. Path:"hero.service.ts" 。

    import { Injectable } from '@angular/core';    import { Observable, of } from 'rxjs';    import { map } from 'rxjs/operators';    import { Hero } from './hero';    import { HEROES } from './mock-heroes';    import { MessageService } from '../message.service';    @Injectable({      providedIn: 'root',    })    export class HeroService {      constructor(private messageService: MessageService) { }      getHeroes(): Observable<Hero[]> {        // TODO: send the message _after_ fetching the heroes        this.messageService.add('HeroService: fetched heroes');        return of(HEROES);      }      getHero(id: number | string) {        return this.getHeroes().pipe(          // (+) before `id` turns the string into a number          map((heroes: Hero[]) => heroes.find(hero => hero.id === +id))        );      }    }

  1. Path:"heroes.module.ts" 。

    import { NgModule }       from '@angular/core';    import { CommonModule }   from '@angular/common';    import { FormsModule }    from '@angular/forms';    import { HeroListComponent }    from './hero-list/hero-list.component';    import { HeroDetailComponent }  from './hero-detail/hero-detail.component';    import { HeroesRoutingModule } from './heroes-routing.module';    @NgModule({      imports: [        CommonModule,        FormsModule,        HeroesRoutingModule      ],      declarations: [        HeroListComponent,        HeroDetailComponent      ]    })    export class HeroesModule {}

  1. Path:"heroes-routing.module.ts" 。

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

  1. Path:"message.service.ts" 。

    import { Injectable } from '@angular/core';    @Injectable({      providedIn: 'root',    })    export class MessageService {      messages: string[] = [];      add(message: string) {        this.messages.push(message);      }      clear() {        this.messages = [];      }    }

本节将向你展示如何在应用中添加子路由并使用相对路由。

要为应用当前的危机中心添加更多特性,请执行类似于 heroes 特性的步骤:

  • 在 src/app 目录下创建一个 crisis-center 子目录。

  • 把 app/heroes 中的文件和目录复制到新的 crisis-center 文件夹中。

  • 在这些新建的文件中,把每个 "hero" 都改成 "crisis",每个 "heroes" 都改成 "crises"。

  • 把这些 NgModule 文件改名为 crisis-center.module.ts 和 crisis-center-routing.module.ts。

使用 mockcrises 来代替 mockheroes

Path:"src/app/crisis-center/mock-crises.ts" 。

import { Crisis } from './crisis';export const CRISES: Crisis[] = [  { id: 1, name: 'Dragon Burning Cities' },  { id: 2, name: 'Sky Rains Great White Sharks' },  { id: 3, name: 'Giant Asteroid Heading For Earth' },  { id: 4, name: 'Procrastinators Meeting Delayed Again' },]

最终的危机中心可以作为引入子路由这个新概念的基础。 你可以把英雄管理保持在当前状态,以便和危机中心进行对比。

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

带有子路由的危机中心

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

  • 把每个特性放在自己的目录中。

  • 每个特性都有自己的 Angular 特性模块。

  • 每个特性区都有自己的根组件。

  • 每个特性区的根组件中都有自己的路由出口及其子路由。

  • 特性区的路由很少(或完全不)与其它特性区的路由交叉。

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

子路由组件

crisis-center 目录下生成一个 CrisisCenter 组件:

ng generate component crisis-center/crisis-center

使用如下代码更新组件模板:

Path:"src/app/crisis-center/crisis-center/crisis-center.component.html" 。

<h2>CRISIS CENTER</h2><router-outlet></router-outlet>

CrisisCenterComponentAppComponent 有下列共同点:

它是危机中心特性区的根,正如 AppComponent 是整个应用的根。

它是危机管理特性区的壳,正如 AppComponent 是管理高层工作流的壳。

就像大多数的壳一样,CrisisCenterComponent 类是最小化的,因为它没有业务逻辑,它的模板中没有链接,只有一个标题和用于放置危机中心的子组件的 <router-outlet>

子路由配置

crisis-center 目录下生成一个 CrisisCenterHome 组件,作为 "危机中心" 特性的宿主页面。

ng generate component crisis-center/crisis-center-home

用一条欢迎信息修改 Crisis Center 中的模板。

Path:"src/app/crisis-center/crisis-center-home/crisis-center-home.component.html" 。

<p>Welcome to the Crisis Center</p>

把 "heroes-routing.module.ts" 文件复制过来,改名为 "crisis-center-routing.module.ts",并修改它。 这次你要把子路由定义在父路由 crisis-center 中。

Path:"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          }        ]      }    ]  }];

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

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

对这些子路由的处理中有一些重要的差异。

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

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

Crisis Detail 路由是 Crisis List 的子路由。由于路由器默认会复用组件,因此当你选择了另一个危机时,CrisisDetailComponent 会被复用。 作为对比,回头看看 Hero Detail 路由,每当你从列表中选择了不同的英雄时,都会重新创建该组件。

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

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

要导航到 CrisisCenterHomeComponent,完整的 URL 是 /crisis-center (/crisis-center + '' + '')。

要导航到 CrisisDetailComponent 以展示 id=2 的危机,完整的 URL 是 /crisis-center/2 (/crisis-center + '' + '/2')。

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

localhost:4200/crisis-center/2

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

Path:"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/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';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 前面:

  1. Path:"src/app/crisis-center/crisis-center.module.ts" 。

    import { NgModule }       from '@angular/core';    import { FormsModule }    from '@angular/forms';    import { CommonModule }   from '@angular/common';    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 { CrisisCenterRoutingModule } from './crisis-center-routing.module';    @NgModule({      imports: [        CommonModule,        FormsModule,        CrisisCenterRoutingModule      ],      declarations: [        CrisisCenterComponent,        CrisisListComponent,        CrisisCenterHomeComponent,        CrisisDetailComponent      ]    })    export class CrisisCenterModule {}

  1. Path:"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 './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';    @NgModule({      imports: [        CommonModule,        FormsModule,        HeroesModule,        CrisisCenterModule,        AppRoutingModule      ],      declarations: [        AppComponent,        PageNotFoundComponent      ],      bootstrap: [ AppComponent ]    })    export class AppModule { }

从 "app.routing.ts" 中移除危机中心的初始路由。 因为现在是 HeroesModuleCrisisCenter 模块提供了这些特性路由。

"app-routing.module.ts" 文件中只有应用的顶层路由,比如默认路由和通配符路由。

Path:"src/app/app-routing.module.ts (v3)" 。

import { NgModule }                from '@angular/core';import { RouterModule, Routes }    from '@angular/router';import { PageNotFoundComponent }  from './page-not-found/page-not-found.component';const appRoutes: Routes = [  { path: '',   redirectTo: '/heroes', pathMatch: 'full' },  { path: '**', component: PageNotFoundComponent }];@NgModule({  imports: [    RouterModule.forRoot(      appRoutes,      { enableTracing: true } // <-- debugging purposes only    )  ],  exports: [    RouterModule  ]})export class AppRoutingModule {}

相对导航

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

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

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

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

路由器支持在链接参数数组中使用“目录式”语法来为查询路由名提供帮助:

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

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

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

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

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

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

使用相对 URL 导航到危机列表

你已经注入了组成相对导航路径所需的 ActivatedRoute

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

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

Path:"src/app/crisis-center/crisis-detail/crisis-detail.component.ts (relative navigation)" 。

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

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

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

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

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

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

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

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

Path:"src/app/app.component.html (outlets)" 。

<div [@routeAnimation]="getAnimationData(routerOutlet)">  <router-outlet #routerOutlet="outlet"></router-outlet></div><router-outlet name="popup"></router-outlet>

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

  1. 第二路由。

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

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

  • 它们彼此互不依赖。

  • 它们与其它路由组合使用。

  • 它们显示在命名出口中。

生成一个新的组件来组合这个消息。

    ng generate component compose-message

它显示一个简单的表单,包括一个头、一个消息输入框和两个按钮:“Send”和“Cancel”。

下面是该组件及其模板和样式:

  • Path:"src/app/compose-message/compose-message.component.css" 。

        :host {          position: relative; bottom: 10%;        }

  • Path:"src/app/compose-message/compose-message.component.html" 。

        <h3>Contact Crisis Center</h3>        <div *ngIf="details">          {{ details }}        </div>        <div>          <div>            <label>Message: </label>          </div>          <div>            <textarea [(ngModel)]="message" rows="10" cols="35" [disabled]="sending"></textarea>          </div>        </div>        <p *ngIf="!sending">          <button (click)="send()">Send</button>          <button (click)="cancel()">Cancel</button>        </p>

  • Path:"src/app/compose-message/compose-message.component.ts" 。

        import { Component, HostBinding } from '@angular/core';        import { Router }                 from '@angular/router';        @Component({          selector: 'app-compose-message',          templateUrl: './compose-message.component.html',          styleUrls: ['./compose-message.component.css']        })        export class ComposeMessageComponent {          details: string;          message: string;          sending = false;          constructor(private router: Router) {}          send() {            this.sending = true;            this.details = 'Sending Message...';            setTimeout(() => {              this.sending = false;              this.closePopup();            }, 1000);          }          cancel() {            this.closePopup();          }          closePopup() {            // Providing a `null` value to the named outlet            // clears the contents of the named outlet            this.router.navigate([{ outlets: { popup: null }}]);          }        }

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

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

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

  1. 添加第二路由。

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

Path:"src/app/app-routing.module.ts (compose route)" 。

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

除了 pathcomponent 属性之外还有一个新的属性 outlet,它被设置成了 'popup'。 这个路由现在指向了 popup 出口,而 ComposeMessageComponent 也将显示在那里。

为了给用户某种途径来打开这个弹出框,还要往 AppComponent 模板中添加一个“Contact”链接。

Path:"src/app/app.component.html (contact-link)" 。

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

虽然 compose 路由被配置到了 popup 出口上,但这仍然不足以把该路由和 RouterLink 指令联系起来。 你还要在链接参数数组中指定这个命名出口,并通过属性绑定的形式把它绑定到 `RouterLink 上。

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

换句话说,当用户点击此链接时,路由器会在路由出口 popup 中显示与 compose 路由相关联的组件。

当只需要考虑一个路由和一个无名出口时,外部对象中的这个 outlets 对象是完全不必要的。

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

路由到一个命名出口会揭示一个路由特性: 你可以在同一个 RouterLink 指令中为多个路由出口指定多个路由。

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

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

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

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

  • "crisis-center" 是主导航。

  • 圆括号包裹的部分是第二路由。

  • 第二路由包括一个出口名称(popup)、一个冒号分隔符和第二路由的路径(compose)。

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

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

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

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

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

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

  1. 清除第二路由。

像常规出口一样,二级出口会一直存在,直到你导航到新组件。

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

再看 closePopup() 方法:

Path:"src/app/compose-message/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 }}]);}

单击 “send” 或 “cancel” 按钮可以清除弹出视图。closePopup() 函数会使用 Router.navigate() 方法强制导航,并传入一个链接参数数组。

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

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

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

  • 该用户可能无权导航到目标组件。

  • 可能用户得先登录(认证)。

  • 在显示目标组件前,你可能得先获取某些数据。

  • 在离开组件前,你可能要先保存修改。

  • 你可能要询问用户:你是否要放弃本次更改,而不用保存它们?

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

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

  • 如果它返回 true,导航过程会继续

  • 如果它返回 false,导航过程就会终止,且用户留在原地。

  • 如果它返回 UrlTree,则取消当前的导航,并且开始导航到返回的这个 UrlTree.

注:

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

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

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

注:

  • 提供给 Router 的可观察对象还必须能结束(complete)。否则,导航就不会继续。

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

  • CanActivate来处理导航到某路由的情况。

  • CanActivateChild来处理导航到某子路由的情况。

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

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

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

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

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

CanActivate :需要身份验证

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

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

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

使用一些新的管理功能来扩展危机中心。首先添加一个名为 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

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

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

Path:"src/app/admin/admin/admin.component.html" 。

    <h3>ADMIN</h3>    <nav>      <a routerLink="./" routerLinkActive="active"        [routerLinkActiveOptions]="{ exact: true }">Dashboard</a>      <a routerLink="./crises" routerLinkActive="active">Manage Crises</a>      <a routerLink="./heroes" routerLinkActive="active">Manage Heroes</a>    </nav>    <router-outlet></router-outlet>

Path:"src/app/admin/admin-dashboard/admin-dashboard.component.htmlsrc/app/admin/admin.module.tssrc/app/admin/manage-crises/manage-crises.component.htmlsrc/app/admin/manage-heroes/manage-heroes.component.html" 。

    <p>Dashboard</p>

Path:"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 {}

Path:"src/app/admin/manage-crises/manage-crises.component.html" 。

    <p>Manage your crises here</p>

Path:"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 时才会激活它,而不会在导航到它的某个子路由时。

无组件路由:分组路由,而不需要组件。

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

Path:"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 下的子路由有一个 path 和一个 children 属性,但是它没有使用 component。这就定义了一个无组件路由。

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

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

Path:"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 './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 中添加一个链接,让用户能点击它,以访问该特性。

Path:"src/app/app.component.html (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>    <div [@routeAnimation]="getAnimationData(routerOutlet)">      <router-outlet #routerOutlet="outlet"></router-outlet>    </div>    <router-outlet name="popup"></router-outlet>2. 守护“管理特性”区。    现在危机中心的每个路由都是对所有人开放的。这些新的管理特性应该只能被已登录用户访问。    编写一个 `CanActivate()` 守卫,将正在尝试访问管理组件匿名用户重定向到登录页。    在 "auth" 文件夹中生成一个 `AuthGuard`。
ng generate guard auth/auth```

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

Path:"src/app/auth/auth.guard.ts (excerpt)" 。

    import { Injectable } from '@angular/core';    import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';    @Injectable({      providedIn: 'root',    })    export class AuthGuard implements CanActivate {      canActivate(        next: ActivatedRouteSnapshot,        state: RouterStateSnapshot): boolean {        console.log('AuthGuard#canActivate called');        return true;      }    }

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

Path:"src/app/admin/admin-routing.module.ts (guarded admin route)" 。

    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 {}

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

  1. 通过 AuthGuard 验证。

AuthGuard 模拟身份验证。

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

    ng generate service auth/auth

修改 AuthService 以登入此用户:

Path:"src/app/auth/auth.service.ts (excerpt)" 。

    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;      login(): Observable<boolean> {        return of(true).pipe(          delay(1000),          tap(val => this.isLoggedIn = true)        );      }      logout(): void {        this.isLoggedIn = false;      }    }

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

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

修改 AuthGuard 以调用 AuthService

Path:"src/app/auth/auth.guard.ts (v2)" 。

    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(        next: ActivatedRouteSnapshot,        state: RouterStateSnapshot): true|UrlTree {        let 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');      }    }

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

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

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

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

  1. 添加 LoginComponent。

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

    ng generate component auth/login

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

Path:"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 {    }

Path:"src/app/auth/login/login.component.html" 。

    <h2>LOGIN</h2>    <p>{{message}}</p>    <p>      <button (click)="login()"  *ngIf="!authService.isLoggedIn">Login</button>      <button (click)="logout()" *ngIf="authService.isLoggedIn">Logout</button>    </p>

Path:"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.setMessage();      }      setMessage() {        this.message = 'Logged ' + (this.authService.isLoggedIn ? 'in' : 'out');      }      login() {        this.message = 'Trying to log in ...';        this.authService.login().subscribe(() => {          this.setMessage();          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.setMessage();      }    }

Path:"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 和一个 RouterStateSnapshotCanActivateChild 方法可以返回 Observable<boolean|UrlTree>Promise<boolean|UrlTree> 来支持异步检查,或 booleanUrlTree 来支持同步检查。 这里返回的或者是 true 以便允许用户访问管理特性模块,或者是 UrlTree 以便把用户重定向到登录页:

Path:"src/app/auth/auth.guard.ts (excerpt)" 。

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 {    let url: string = state.url;    return this.checkLogin(url);  }  canActivateChild(    route: ActivatedRouteSnapshot,    state: RouterStateSnapshot): true|UrlTree {    return this.canActivate(route, state);  }/* . . . */}

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

Path:"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 守卫能帮助你决定如何处理未保存的更改,以及如何处理。

取消与保存

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

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

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

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

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

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

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

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

ng generate service dialog

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

Path:"src/app/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);  };}

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

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

ng generate guard can-deactivate

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

Path:"src/app/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;  }}

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

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

Path:"src/app/can-deactivate.guard.ts (component-specific)" 。

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 组件,它已经实现了对未保存的更改进行确认的工作流。

Path:"src/app/crisis-center/crisis-detail/crisis-detail.component.ts (excerpt)" 。

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

Path:"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/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 DetailCrisis Detail 中,它们等待路由读取完对应的英雄和危机。

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

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

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

导航前预先加载路由信息

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

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

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

ng generate service crisis-center/crisis-detail-resolver

Path:"src/app/crisis-center/crisis-detail-resolver.service.ts (generated)" 。

import { Injectable } from '@angular/core';@Injectable({  providedIn: 'root',})export class CrisisDetailResolverService {  constructor() { }}

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

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

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

CrisisService.getCrisis() 方法返回一个可观察对象,以防止在数据获取完之前加载本路由。 Router 守卫要求这个可观察对象必须可结束(complete),也就是说它已经发出了所有值。 你可以为 take 操作符传入一个参数 1,以确保这个可观察对象会在从 getCrisis 方法所返回的可观察对象中取到第一个值之后就会结束。

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

Path:"src/app/crisis-center/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, take }         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> {    let id = route.paramMap.get('id');    return this.cs.getCrisis(id).pipe(      take(1),      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 对象。

Path:"src/app/crisis-center/crisis-center-routing.module.ts (resolver)" 。

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 属性中获取危机详情,这正是你重新配置路由的恰当时机。

Path:"src/app/crisis-center/crisis-detail/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 接口是可选的。CrisisDetailResolverService 没有继承自某个基类。路由器只要找到了这个方法,就会调用它。

  1. 路由器会在用户可以导航的任何情况下调用该解析器,这样你就不用针对每个用例都编写代码了。

  1. 在任何一个解析器中返回空的 Observable 就会取消导航。

查询参数及片段

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

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

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

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

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

Path:"src/app/auth/auth.guard.ts (v3)" 。

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 {    let 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    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'    };    // Redirect to the login page with extras    return this.router.createUrlTree(['/login'], navigationExtras);  }}

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

Path:"src/app/auth/login/login.component.ts (preserve)" 。

// Set our navigation extras object// that passes on our global query params and fragmentlet navigationExtras: NavigationExtras = {  queryParamsHandling: 'preserve',  preserveFragment: true};// Redirect the userthis.router.navigate([redirectUrl], navigationExtras);

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

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

Path:"src/app/admin/admin-dashboard/admin-dashboard.component.ts (v2)" 。

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 按钮,它会带着你提供的 queryParamMapfragment 跳转到登录页。 点击 Login 按钮,你就会被重定向到 Admin Dashboard 页。 注意,它仍然带着上一步提供的 queryParamMapfragment

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

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

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

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

你可以只在用户请求时才加载某些特性区。

对于那些只访问应用程序某些区域的用户,这样能加快加载速度。

你可以持续扩充惰性加载特性区的功能,而不用增加初始加载的包体积。

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

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

惰性加载路由配置

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

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

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

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

Path:"app-routing.module.ts (load children)" 。

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

注:

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

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

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

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

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

在 "app.module.ts" 中,从顶部移除 AdminModule 的导入语句,并且从 NgModuleimports 数组中移除 AdminModule

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

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

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

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

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

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

Path:"src/app/auth/auth.guard.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 路由是这样的:

Path:"app-routing.module.ts (lazy admin route)" 。

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

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

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

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

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

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

  1. 预加载的工作原理

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

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

  • 完全不预加载,这是默认值。惰性加载的特性区仍然会按需加载。

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

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

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

  1. 惰性加载危机中心

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

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

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

  • 设置 loadChildren 字符串来加载 CrisisCenterModule

  • 从 "app.module.ts" 中移除所有对 CrisisCenterModule 的引用。

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

  • Path:"app.module.ts" 。

        import { NgModule }       from '@angular/core';        import { BrowserModule }  from '@angular/platform-browser';        import { FormsModule }    from '@angular/forms';        import { BrowserAnimationsModule } from '@angular/platform-browser/animations';        import { Router } from '@angular/router';        import { AppComponent }            from './app.component';        import { PageNotFoundComponent }   from './page-not-found/page-not-found.component';        import { ComposeMessageComponent } from './compose-message/compose-message.component';        import { AppRoutingModule }        from './app-routing.module';        import { HeroesModule }            from './heroes/heroes.module';        import { AuthModule }              from './auth/auth.module';        @NgModule({          imports: [            BrowserModule,            BrowserAnimationsModule,            FormsModule,            HeroesModule,            AuthModule,            AppRoutingModule,          ],          declarations: [            AppComponent,            ComposeMessageComponent,            PageNotFoundComponent          ],          bootstrap: [ AppComponent ]        })        export class AppModule {        }

  • Path:"app-routing.module.ts" 。

        import { NgModule }     from '@angular/core';        import {          RouterModule, Routes,        } from '@angular/router';        import { ComposeMessageComponent } from './compose-message/compose-message.component';        import { PageNotFoundComponent }   from './page-not-found/page-not-found.component';        import { AuthGuard }               from './auth/auth.guard';        const appRoutes: Routes = [          {            path: 'compose',            component: ComposeMessageComponent,            outlet: 'popup'          },          {            path: 'admin',            loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule),            canLoad: [AuthGuard]          },          {            path: 'crisis-center',            loadChildren: () => import('./crisis-center/crisis-center.module').then(m => m.CrisisCenterModule)          },          { path: '',   redirectTo: '/heroes', pathMatch: 'full' },          { path: '**', component: PageNotFoundComponent }        ];        @NgModule({          imports: [            RouterModule.forRoot(              appRoutes,            )          ],          exports: [            RouterModule          ]        })        export class AppRoutingModule {}

  • Path:"crisis-center-routing.module.ts" 。

        import { NgModule }             from '@angular/core';        import { RouterModule, Routes } from '@angular/router';        import { CrisisCenterHomeComponent } from './crisis-center-home/crisis-center-home.component';        import { CrisisListComponent }       from './crisis-list/crisis-list.component';        import { CrisisCenterComponent }     from './crisis-center/crisis-center.component';        import { CrisisDetailComponent }     from './crisis-detail/crisis-detail.component';        import { CanDeactivateGuard }             from '../can-deactivate.guard';        import { CrisisDetailResolverService }    from './crisis-detail-resolver.service';        const crisisCenterRoutes: Routes = [          {            path: '',            component: CrisisCenterComponent,            children: [              {                path: '',                component: CrisisListComponent,                children: [                  {                    path: ':id',                    component: CrisisDetailComponent,                    canDeactivate: [CanDeactivateGuard],                    resolve: {                      crisis: CrisisDetailResolverService                    }                  },                  {                    path: '',                    component: CrisisCenterHomeComponent                  }                ]              }            ]          }        ];        @NgModule({          imports: [            RouterModule.forChild(crisisCenterRoutes)          ],          exports: [            RouterModule          ]        })        export class CrisisCenterRoutingModule { }

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

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

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

Path:"src/app/app-routing.module.ts (preload all)" 。

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

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

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

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

CanLoad 会阻塞预加载

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

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

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

自定义预加载策略

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

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

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

Path:"src/app/app-routing.module.ts (route data preload)" 。

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

生成一个新的 SelectivePreloadingStrategy 服务。

ng generate service selective-preloading-strategy

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

Path:"src/app/selective-preloading-strategy.service.ts" 。

import { Injectable } from '@angular/core';import { PreloadingStrategy, Route } from '@angular/router';import { Observable, of } from 'rxjs';@Injectable({  providedIn: 'root',})export class SelectivePreloadingStrategyService implements PreloadingStrategy {  preloadedModules: string[] = [];  preload(route: Route, load: () => Observable<any>): Observable<any> {    if (route.data && 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 of(null);    }  }}

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

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

  1. 要加载的路由。

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

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

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

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

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

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

  1. SelectivePreloadingStrategyService 导入到 AppRoutingModule 中。

  1. PreloadAllModules 策略替换成对 forRoot() 的调用,并且传入这个 SelectivePreloadingStrategyService

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

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

导入 SelectivePreloadingStrategyService(它是一个服务)。

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

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

现在文件如下:

Path:"src/app/admin/admin-dashboard/admin-dashboard.component.ts (preloaded modules)" 。

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

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

使用重定向迁移 URL

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

把 /heroes 改为 /superheroes

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

Path:"src/app/heroes/heroes-routing.module.ts (heroes redirects)" 。

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

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

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

- 当使用绝对地址重定向时,路由器将会使用路由配置的 `redirectTo` 属性中规定的查询参数和片段。

- 当使用相对地址重定向时,路由器将会使用源地址(跳转前的地址)中的查询参数和片段。

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

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

Path:"src/app/app-routing.module.ts (superheroes redirect)" 。

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

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

Path:"src/app/app.component.html (superheroes active routerLink))" 。

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

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

Path:"src/app/heroes/hero-detail/hero-detail.component.ts (goToHeroes)" 。

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

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

审查路由器配置

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

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

Path:"src/app/app.module.ts (inspect the router config)" 。

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

最终的应用

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

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

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

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

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

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

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

HashLocationStrategy - 支持 “hash URL” 风格。

RouterModule.forRoot() 函数把 LocationStrategy 设置成了 PathLocationStrategy,使其成为了默认策略。 你还可以在启动过程中改写(override)它,来切换到 HashLocationStrategy 风格。

下面的部分重点介绍了一些路由器的核心概念。

路由器导入

Angular 的 Router 是一个可选服务,它为指定的 URL 提供特定的组件视图。它不是 Angular 核心的一部分,因此它位于自己的包 @angular/router 中。

从任何其它的 Angular 包中导入你需要的东西。

Path:"src/app/app.module.ts (import)" 。

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

有关浏览器 URL 风格的更多信息,请参阅 LocationStrategy 和浏览器的网址样式

配置

带路由的 Angular 应用中有一个 Router 服务的单例实例。当浏览器的 URL 发生变化时,该路由器会查找相应的 Route,以便根据它确定要显示的组件。

在配置之前,路由器没有任何路由。下面的例子创建了五个路由定义,通过 RouterModule.forRoot() 方法配置路由器,并把结果添加到 AppModuleimports 数组中。

Path:"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,      { enableTracing: true } // <-- debugging purposes only    )    // other imports here  ],  ...})export class AppModule { }

appRoutes 路由数组描述了如何导航。把它传给模块的 imports 数组中的 RouterModule.forRoot() 方法来配置路由器。

每个 Route 都会把一个 URL path 映射到一个组件。路径中没有前导斜杠。路由器会为你解析并构建最终的 URL,这样你就可以在应用视图中导航时使用相对路径和绝对路径了。

第二个路由中的 :id 是路由参数的令牌。在像 "/hero/42" 这样的 URL 中,“42”是 id 参数的值。相应的 HeroDetailComponent 用这个值来查找并显示 id 为 42 的英雄。

第三个路由中的 data 属性是存放与该特定路由关联的任意数据的地方。每个激活的路由都可以访问 data 属性。可以用它来存储页面标题,面包屑文本和其它只读静态数据等项目。你可以尝试使用解析器守卫来检索动态数据。

第四个路由中的空路径表示该应用的默认路径 - 当 URL 中的路径为空时通常要去的地方,就像它在刚进来时一样。这个默认路由重定向到了 "/heroes" 这个 URL 的路由,因此会显示 HeroesListComponent

如果你需要查看导航生命周期中发生了什么事件,可以把 enableTracing 选项作为路由器默认配置的一部分。这会把每个导航生命周期中发生的每个路由器事件都输出到浏览器控制台中。enableTracing 只会用于调试目的。你可以把 enableTracing: true 选项作为第二个参数传给 RouterModule.forRoot() 方法。

路由出口

RouterOutlet 是一个来自路由器库的指令,虽然它的用法像组件一样。它充当占位符,用于在模板中标记出路由器应该显示把该组件显示在那个出口的位置。

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

对于上面的配置,当这个应用的浏览器 URL 变为 "/heroes" 时,路由器就会把这个 URL 与路由路径 "/heroes" 匹配,并把 HeroListComponent 作为兄弟元素显示在宿主组件模板中的 RouterOutlet 下方。

路由链接

要想通过某些用户操作(比如单击一下 a 标签)进行导航,请使用 RouterLink

考虑下面的模板:

Path:"src/app/app.component.html" 。

<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 指令让路由器可以控制这些元素。导航路径是固定的,所以你可以给 routerLink 赋值一个字符串(“一次性”绑定)。

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

活动路由链路

RouterLinkActive 指令会根据当前的 RouterState 切换活动 RouterLink 上所绑定的 CSS 类。

在每个 a 标签上,你会看到一个到 RouterLinkActive 指令的属性绑定,就像 routerLinkActive="..."。

等号 = 右侧的模板表达式,包含一个以空格分隔的 CSS 类字符串,当这个链接处于活动状态时,路由器就会加上这些字符串(并在非活动状态时删除)。你可以把 RouterLinkActive 指令设置成一串类的字符串,比如 [routerLinkActive]="'active fluffy'",也可以把它绑定到一个返回这样一个字符串的组件属性上。

活动路由链接会级联到路由树的每个级别,这样父路由和子路由链接就可以同时处于活动状态。要覆盖这种行为,你可以用 { exact: true } 表达式绑定到 [routerLinkActiveOptions] 输入绑定。使用 { exact: true } 之后,给定的 RouterLink 只有在 URL 与当前 URL 完全匹配时才会激活。

路由器状态

每个成功的导航生命周期结束后,路由器都会构建一个 ActivatedRoute 对象树,它构成了路由器的当前状态。你可以从任何地方使用应用的 Router 服务和 routerState 属性来访问当前的 RouterState

RouterState 中的每个 ActivatedRoute 都提供了向上或向下遍历路由树的方法,用于从父路由、子路由和兄弟路由中获取信息。

激活路由

路由的路径和参数可以通过注入名为 ActivatedRoute 的路由服务获得。它提供了大量有用的信息,包括:

属性说明
url一个路由路径的 Observable,是一个由路由路径的各个部分组成的字符串数组。
data包含提供给当前路由的 data 对象的 Observable。 也包含任何由解析守卫解析出的值。
paramMap一个包含该路由的必要参数和可选参数 map 的 Observable。 这个 map 支持从同一个参数中获得单个或多个值。
queryParamMap一个包含适用于所有路由的查询参数 map 的 Observable。 这个 map 支持从同一个查询参数中获得单个或多个值。
fragment一个适用于所有路由的 URL 片段的 Observable
outlet用来渲染该路由的 RouterOutlet 的名字。 对于无名出口,这个出口的名字是 primary
routeConfig包含原始路径的那个路由的配置信息。
parent当该路由是子路由时,表示该路由的父级 ActivatedRoute
firstChild包含该路由的子路由列表中的第一个 ActivatedRoute
children包含当前路由下所有激活的子路由。

还有两个较旧的属性,但更推荐使用它们的替代品,因为它们可能会在以后的 Angular 版本中弃用。

- `params` :一个 `Observable`,它包含专属于该路由的必要参数和可选参数。请改用 `paramMap`。

- `queryParams`:一个包含可用于所有路由的查询参数的 `Observable`。请改用 `queryParamMap`。

路由器事件

Router 在每次导航过程中都会通过 Router.events 属性发出导航事件。这些事件的范围贯穿从导航开始和结束之间的多个时间点。导航事件的完整列表如下表所示。

路由事件说明
NavigationStart导航开始时触发的事件。
RouteConfigLoadStart在 Router 惰性加载路由配置之前触发的事件。
RouteConfigLoadEnd在某个路由已经惰性加载完毕时触发的事件。
RoutesRecognized当路由器解析了 URL,而且路由已经识别完毕时触发的事件。
GuardsCheckStart当路由器开始进入路由守卫阶段时触发的事件。
ChildActivationStart当路由器开始激活某路由的子路由时触发的事件。
ActivationStart当路由器开始激活某个路由时触发的事件。
GuardsCheckEnd当路由器成功结束了路由守卫阶段时触发的事件。
ResolveStart当路由器开始路由解析阶段时触发的事件。
ChildActivationEnd当路由器成功激活某路由的子路由时触发的事件。
ResolveEnd当路由器的路由解析阶段成功完成时触发的事件。
ActivationEnd当路由器成功激活了某个路由时触发的事件。
NavigationEnd当导航成功结束时触发的事件。
NavigationCancel当导航被取消时触发的事件。 这可能在导航期间某个路由守卫返回了 false 或返回了 UrlTree 以进行重定向时发生。
NavigationError当导航由于非预期的错误而失败时触发的事件。
Scroll用来表示滚动的事件。

当启用了 enableTracing 选项时,Angular 会把这些事件都记录到控制台。有关筛选路由器导航事件的示例,请参阅 Angular 中的 Observables 一章的路由器部分。

路由器术语

这里是一些关键的 Router 术语及其含义:

路由器部件含义
Router为活动 URL 显示应用中的组件。 管理从一个组件到另一个的导航。
RouterModule一个单独的 NgModule,它提供了一些必要的服务提供者和一些用于在应用视图间导航的指令。
Routes定义一个路由数组,每一个条目都会把一个 URL 路径映射到组件。
Route定义路由器如何基于一个 URL 模式导航到某个组件。 大部分路由都由一个路径和一个组件类组成。
RouterOutlet该指令 (<router-outlet>) 用于指出路由器应该把视图显示在哪里。
RouterLink用于将可点击的 HTML 元素绑定到某个路由的指令。单击带有 routerLink 指令且绑定到字符串或链接参数数组的元素,将触发导航。
RouterLinkActive该指令会在元素上或元素内包含的相关 routerLink 处于活动/非活动状态时,从 HTML 元素上添加/移除类。
ActivatedRoute一个提供给每个路由组件的服务,其中包含当前路由专属的信息,例如路由参数、静态数据、解析数据、全局查询参数和全局片段。
RouterState路由器的当前状态,包括一棵当前激活路由的树以及遍历这棵路由树的便捷方法。
链接参数数组一个由路由器将其解释为路由指南的数组。你可以将该数组绑定到 RouterLink 或将该数组作为参数传递给 Router.navigate 方法。
路由组件一个带有 RouterOutlet 的 Angular 组件,可基于路由器的导航来显示视图。

Web 应用程序的安全涉及到很多方面。针对常见的漏洞和攻击,比如跨站脚本攻击,Angular 提供了一些内置的保护措施。本章将讨论这些内置保护措施,但不会涉及应用级安全,比如用户认证(这个用户是谁?)和授权(这个用户能做什么?)。

要了解更多攻防信息,参见开放式 Web 应用程序安全项目(OWASP)。

你可以在 Stackblitz 中试用并下载本页的代码。

最佳实践

  • 及时把 Angular 包更新到最新版本。 我们会频繁的更新 Angular 库,这些更新可能会修复之前版本中发现的安全漏洞。查看 Angular 的更新记录,了解与安全有关的更新。

  • 不要修改你的 Angular 副本。 私有的、定制版的 Angular 往往跟不上最新版本,这可能导致你忽略重要的安全修复与增强。反之,应该在社区共享你对 Angular 所做的改进并创建 Pull Request

  • 避免使用本文档中带“安全风险”标记的 Angular API。 要了解更多信息,请参阅本章的 信任安全值 部分。

审计 Angular 应用程序

Angular 应用应该遵循和常规 Web 应用一样的安全原则并按照这些原则进行审计。Angular 中某些应该在安全评审中被审计的 API( 比如bypassSecurityTrust API)都在文档中被明确标记为安全性敏感的。

跨站脚本(XSS)允许攻击者将恶意代码注入到页面中。这些代码可以偷取用户数据 (特别是它们的登录数据),还可以冒充用户执行操作。它是 Web 上最常见的攻击方式之一。

为了防范 XSS 攻击,你必须阻止恶意代码进入 DOM。比如,如果某个攻击者能骗你把 <script> 标签插入到 DOM,就可以在你的网站上运行任何代码。 除了 <script>,攻击者还可以使用很多 DOM 元素和属性来执行代码,比如 <img onerror="..."><a href="javascript:...">。 如果攻击者所控制的数据混进了 DOM,就会导致安全漏洞。

Angular 的“跨站脚本安全模型”

为了系统性的防范 XSS 问题,Angular 默认把所有值都当做不可信任的。 当值从模板中以属性(Property)、DOM 元素属性(Attribte)、CSS 类绑定或插值等途径插入到 DOM 中的时候, Angular 将对这些值进行无害化处理(Sanitize),对不可信的值进行编码。

Angular 的模板同样是可执行的:模板中的 HTML、Attribute 和绑定表达式(还没有绑定到值的时候)会被当做可信任的。 这意味着应用必须防止把可能被攻击者控制的值直接编入模板的源码中。永远不要根据用户的输入和原始模板动态生成模板源码! 使用离线模板编译器是防范这类“模板注入”漏洞的有效途径。

无害化处理与安全环境

无害化处理会审查不可信的值,并将它们转换成可以安全插入到 DOM 的形式。多数情况下,这些值并不会在处理过程中发生任何变化。 无害化处理的方式取决于所在的环境:一个在 CSS 里面无害的值,可能在 URL 里很危险。

Angular 定义了四个安全环境 - HTML,样式,URL,和资源 URL:

  • HTML:值需要被解释为 HTML 时使用,比如当绑定到 innerHTML 时。

  • 样式:值需要作为 CSS 绑定到 style 属性时使用。

  • URL:值需要被用作 URL 属性时使用,比如 <a href>

  • 资源 URL 的值需要作为代码进行加载并执行,比如 <script src> 中的 URL

Angular 会对前三项中种不可信的值进行无害化处理,但不能对第四种资源 URL 进行无害化,因为它们可能包含任何代码。在开发模式下, 如果在进行无害化处理时需要被迫改变一个值,Angular 就会在控制台上输出一个警告。

无害化示例

下面的例子绑定了 htmlSnippet 的值,一次把它放进插值里,另一次把它绑定到元素的 innerHTML 属性上。

Path:"src/app/inner-html-binding.component.html" 。

<h3>Binding innerHTML</h3><p>Bound value:</p><p class="e2e-inner-html-interpolated">{{htmlSnippet}}</p><p>Result of binding to innerHTML:</p><p class="e2e-inner-html-bound" [innerHTML]="htmlSnippet"></p>

插值的内容总会被编码 - 其中的 HTML 不会被解释,所以浏览器会在元素的文本内容中显示尖括号。

如果希望这段 HTML 被正常解释,就必须绑定到一个 HTML 属性上,比如 innerHTML。但是如果把一个可能被攻击者控制的值绑定到 innerHTML 就会导致 XSS 漏洞。 比如,包含在 <script> 标签的代码就会被执行:

Path:"src/app/inner-html-binding.component.ts (class)" 。

export class InnerHtmlBindingComponent {  // For example, a user/attacker-controlled value from a URL.  htmlSnippet = 'Template <script>alert("0wned")</script> <b>Syntax</b>';}

Angular 认为这些值是不安全的,并自动进行无害化处理。它会移除 <script> 标签,但保留安全的内容,比如该片段中的 <b> 元素。

避免直接使用 DOM API

浏览器内置的 DOM API 不会自动保护你免受安全漏洞的侵害。比如 document、通过 ElementRef 拿到的节点和很多第三方 API,都可能包含不安全的方法。如果你使用能操纵 DOM 的其它库,也同样无法借助像 Angular 插值那样的自动清理功能。 所以,要避免直接和 DOM 打交道,而是尽可能使用 Angular 模板。

浏览器内置的 DOM API 不会自动针对安全漏洞进行防护。比如,document(它可以通过 ElementRef 访问)以及其它第三方 API 都可能包含不安全的方法。 要避免直接与 DOM 交互,只要可能,就尽量使用 Angular 模板。

内容安全策略

内容安全策略(CSP) 是用来防范 XSS 的纵深防御技术。 要打开 CSP,请配置你的 Web 服务器,让它返回合适的 HTTP 头 Content_Security_Policy。 要了解关于内容安全策略的更多信息,请参阅 HTML5Rocks 上的内容安全策略简介。

使用离线模板编译器

离线模板编译器阻止了一整套被称为“模板注入”的漏洞,并能显著增强应用程序的性能。尽量在产品发布时使用离线模板编译器, 而不要动态生成模板(比如在代码中拼接字符串生成模板)。由于 Angular 会信任模板本身的代码,所以,动态生成的模板 —— 特别是包含用户数据的模板 —— 会绕过 Angular 自带的保护机制。 要了解如何用安全的方式动态创建表单,请参见 构建动态表单 一章。

服务器端 XSS 保护

服务器端构造的 HTML 很容易受到注入攻击。当需要在服务器端生成 HTML 时(比如 Angular 应用的初始页面), 务必使用一个能够自动进行无害化处理以防范 XSS 漏洞的后端模板语言。不要在服务器端使用模板语言生成 Angular 模板, 这样会带来很高的 “模板注入” 风险。

有时候,应用程序确实需要包含可执行的代码,比如使用 URL 显示 <iframe>,或者构造出有潜在危险的 URL。 为了防止在这种情况下被自动无害化,你可以告诉 Angular:我已经审查了这个值,检查了它是怎么生成的,并确信它总是安全的。 但是千万要小心!如果你信任了一个可能是恶意的值,就会在应用中引入一个安全漏洞。如果你有疑问,请找一个安全专家复查下。

注入 DomSanitizer 服务,然后调用下面的方法之一,你就可以把一个值标记为可信任的。

bypassSecurityTrustHtml

bypassSecurityTrustScript

bypassSecurityTrustStyle

bypassSecurityTrustUrl

bypassSecurityTrustResourceUrl

记住,一个值是否安全取决于它所在的环境,所以你要为这个值按预定的用法选择正确的环境。假设下面的模板需要把 javascript.alert(...) 方法绑定到 URL

Path:"src/app/bypass-security.component.html (URL)" 。

<h4>An untrusted URL:</h4><p><a class="e2e-dangerous-url" [href]="dangerousUrl">Click me</a></p><h4>A trusted URL:</h4><p><a class="e2e-trusted-url" [href]="trustedUrl">Click me</a></p>

通常,Angular 会自动无害化这个 URL 并禁止危险的代码。为了防止这种行为,可以调用 bypassSecurityTrustUrl 把这个 URL 值标记为一个可信任的 URL

Path:"src/app/bypass-security.component.ts (trust-url)" 。

constructor(private sanitizer: DomSanitizer) {  // javascript: URLs are dangerous if attacker controlled.  // Angular sanitizes them in data binding, but you can  // explicitly tell Angular to trust this value:  this.dangerousUrl = 'javascript:alert("Hi there")';  this.trustedUrl = sanitizer.bypassSecurityTrustUrl(this.dangerousUrl);

如果需要把用户输入转换为一个可信任的值,可以在控制器方法中处理。下面的模板允许用户输入一个 YouTube 视频的 ID, 然后把相应的视频加载到 <iframe> 中。<iframe src> 是一个“资源 URL”的安全环境,因为不可信的源码可能作为文件下载到本地,被毫无防备的用户执行。 所以要调用一个控制器方法来构造一个新的、可信任的视频 URL,这样 Angular 就会允许把它绑定到 <iframe src>

Path:"src/app/bypass-security.component.html (iframe)" 。

<h4>Resource URL:</h4><p>Showing: {{dangerousVideoUrl}}</p><p>Trusted:</p><iframe class="e2e-iframe-trusted-src" width="640" height="390" [src]="videoUrl"></iframe><p>Untrusted:</p><iframe class="e2e-iframe-untrusted-src" width="640" height="390" [src]="dangerousVideoUrl"></iframe>

Path:"src/app/bypass-security.component.ts (trust-video-url)" 。

updateVideoUrl(id: string) {  // Appending an ID to a YouTube URL is safe.  // Always make sure to construct SafeValue objects as  // close as possible to the input data so  // that it's easier to check if the value is safe.  this.dangerousVideoUrl = 'https://www.youtube.com/embed/' + id;  this.videoUrl =      this.sanitizer.bypassSecurityTrustResourceUrl(this.dangerousVideoUrl);}

Angular 内置了一些支持来防范两个常见的 HTTP 漏洞:跨站请求伪造(XSRF)和跨站脚本包含(XSSI)。 这两个漏洞主要在服务器端防范,但是 Angular 也自带了一些辅助特性,可以让客户端的集成变得更容易。

跨站请求伪造(XSRF)

在跨站请求伪造(XSRF 或 CSFR)中,攻击者欺骗用户,让他们访问一个假冒页面(例如 "evil.com"), 该页面带有恶意代码,秘密的向你的应用程序服务器发送恶意请求(例如 "example-bank.com")。

假设用户已经在 "example-bank.com" 登录。用户打开一个邮件,点击里面的链接,在新页面中打开 "evil.com"。

该 "evil.com" 页面立刻发送恶意请求到 "example-bank.com"。这个请求可能是从用户账户转账到攻击者的账户。 与该请求一起,浏览器自动发出 "example-bank.com" 的 cookie

如果 "example-bank.com" 服务器缺乏 XSRF 保护,就无法辨识请求是从应用程序发来的合法请求还是从 "evil.com" 来的假请求。

为了防止这种情况,你必须确保每个用户的请求都是从你自己的应用中发出的,而不是从另一个网站发出的。 客户端和服务器必须合作来抵挡这种攻击。

常见的反 XSRF 技术是服务器随机生成一个用户认证令牌到 cookie 中。 客户端代码获取这个 cookie,并用它为接下来所有的请求添加自定义请求页头。 服务器比较收到的 cookie 值与请求页头的值,如果它们不匹配,便拒绝请求。

这个技术之所以有效,是因为所有浏览器都实现了同源策略。只有设置 cookie 的网站的代码可以访问该站的 cookie,并为该站的请求设置自定义页头。 这就是说,只有你的应用程序可以获取这个 cookie 令牌和设置自定义页头。"evil.com" 的恶意代码不能。

Angular 的 HttpClient 对这项技术的客户端部分提供了内置的支持要了解更多信息,参见 HttpClient 部分。

可到 "开放式 Web 应用程序安全项目 (OWASP) " 深入了解 CSRF,参见 Cross-Site Request Forgery (CSRF)Cross-Site Request Forgery (CSRF) Prevention Cheat Sheet

跨站脚本包含(XSSI)

跨站脚本包含,也被称为 Json 漏洞,它可以允许一个攻击者的网站从 JSON API 读取数据。这种攻击发生在老的浏览器上, 它重写原生 JavaScript 对象的构造函数,然后使用 <script> 标签包含一个 API 的 URL。

只有在返回的 JSON 能像 JavaScript 一样可以被执行时,这种攻击才会生效。所以服务端会约定给所有 JSON 响应体加上前缀 )]}, ,来把它们标记为不可执行的, 以防范这种攻击。

Angular 的 HttpClient 库会识别这种约定,并在进一步解析之前,自动把字符串 )]}, 从所有响应中去掉。