Angular-和-TypeScript-开发指南-全-
Angular 和 TypeScript 开发指南(全)
原文:Angular Development with Typescript
译者:飞龙
第一章. 介绍 Angular
本章涵盖
-
Angular 框架的高级概述
-
使用 Angular CLI 生成新项目
-
开始使用 Angular 模块和组件
-
介绍示例应用程序 ngAuction
Angular 是由 Google 维护的开源 JavaScript 框架。它是其流行的前身 AngularJS 的完全重写。Angular 的第一个版本于 2016 年 9 月以 Angular 2 的名称发布。不久之后,名称中的数字 2 被移除,现在它只是 Angular。每年两次,Angular 团队会发布这个框架的主要版本。未来的版本将包括新功能,性能更优,并生成更小的代码包,但框架的架构很可能保持不变。
Angular 应用程序可以使用 JavaScript(使用 ECMAScript 5 或更高版本的语法)或 TypeScript 进行开发。在本书中,我们使用 TypeScript;我们在附录 B 中解释了我们选择它的原因。
注意
在本书中,我们假设您了解 JavaScript 和 HTML 的语法,并理解网络应用程序由什么组成。我们还假设您了解 CSS。如果您不熟悉 TypeScript 的语法和 ECMAScript 的最新版本,我们建议您首先阅读附录 A 和附录 B,然后继续阅读本章。如果您是使用 Node.js 工具进行开发的初学者,请阅读附录 C。
| |
注意
本书中的所有代码示例都经过 Angular 6 测试,并且应该无需任何更改即可与 Angular 7 兼容。您可以从github.com/Farata/angulartypescript下载代码示例。我们将在第二章中提供如何运行每个代码示例的说明。
本章首先简要概述了 Angular 框架。然后我们将开始编码——我们将使用 Angular CLI 工具生成我们的第一个项目。最后,我们将介绍在阅读本书时将构建的示例应用程序 ngAuction。
1.1. 为什么选择 Angular 进行 Web 开发?
Web 开发者使用不同的 JavaScript 框架和库,其中最流行的是 Angular、React 和 Vue.js。您可以在很多文章和博客文章中找到比较它们的文章,但这样的比较是没有根据的,因为 React 和 Vue.js 是不提供完整解决方案的库,用于开发和部署完整的 Web 应用程序,而 Angular 则提供了这样的完整解决方案。
如果你为你的项目选择了 React 或 Vue.js,你还需要选择其他支持路由、依赖注入、表单、应用程序捆绑和部署的产品。最终,你的应用程序将包含由资深开发者或架构师挑选的多个库和工具。如果这位开发者决定离开项目,找到替代者将不会容易,因为新员工可能不熟悉项目中使用的所有库和工具。
Angular 框架是一个平台,它包含了开发部署 Web 应用程序所需的所有内容,包括电池。只要新人员了解 Angular,替换一个 Angular 开发者与另一个开发者一样容易。
从技术角度来看,我们喜欢 Angular,因为它是一个功能齐全的框架,你可以直接使用它来完成以下任务:
-
使用 Angular CLI 在几秒钟内生成一个新的单页 Web 应用程序
-
创建一个由一组可以松散耦合通信的组件组成的 Web 应用程序
-
使用强大的路由器安排客户端导航
-
注入并轻松替换 服务,这些服务是你在其中实现数据通信或其他业务逻辑的类
-
通过可注入的单例服务安排状态管理
-
清晰地分离 UI 和业务逻辑
-
将你的应用程序模块化,以便在应用程序启动时只加载核心功能,其他模块按需加载
-
使用 Angular Material 库创建现代外观的 UI
-
实现响应式编程,你的应用程序组件不会拉取可能尚未准备好的数据,而是订阅数据源并在数据可用时接收通知
话虽如此,我们需要承认 React 和 Vue.js 相对于 Angular 有一个优势。尽管 Angular 适合创建单页应用程序,其中整个应用程序都在这个框架中开发,但 React 和 Vue.js 编写的代码可以包含到任何 Web 应用程序中,无论使用了什么其他框架来开发任何单页或多页 Web 应用程序。
当 Angular 团队发布一个目前称为 @angular/elements 的新模块时,这个优势将消失(见 github.com/angular/angular/tree/master/packages/elements)。然后你将能够将你的 Angular 组件打包成自定义元素(见 developer.mozilla.org/en-US/docs/Web/Web_Components/Custom_Elements),这些元素可以嵌入到任何用 JavaScript 编写的现有 Web 应用程序中,无论是否使用了其他库。
1.2. 为什么要在 TypeScript 中开发而不是在 JavaScript 中?
你可能想知道,为什么不在 JavaScript 中开发?既然 JavaScript 已经是一种语言,为什么还需要使用另一种编程语言呢?你不会找到关于为 Java 或 C# 应用程序开发额外语言的文章,对吧?
原因在于在 JavaScript 中开发并不特别高效。比如说,一个函数期望一个string类型的参数,但开发者错误地通过传递一个数值来调用它。在 JavaScript 中,这种错误只能在运行时捕获。Java 或 C#编译器甚至不会编译类型不匹配的代码,但 JavaScript 是一种动态类型语言,变量的类型可以在运行时改变。
虽然 JavaScript 引擎通过变量的值来猜测类型做得相当不错,但在不知道类型的情况下,开发工具的帮助能力有限。在中型和大型应用程序中,这种 JavaScript 的不足降低了软件开发者的生产力。
在大型项目中,良好的 IDE 上下文相关帮助和重构支持非常重要。在静态类型语言中,IDE 可以在瞬间重命名变量或函数名出现的所有实例,但在不支持类型的 JavaScript 中则不行。如果你在函数或变量名上犯了一个错误,它会以红色显示。如果你向函数传递了错误数量的参数(或错误的类型),错误也会以红色显示。IDE 还提供了出色的上下文相关帮助。TypeScript 代码可以通过 IDE 进行重构。
TypeScript 遵循最新的 ECMAScript 规范,并在此基础上增加了类型、接口、装饰器、类成员变量(字段)、泛型、枚举、public、protected和private等关键字,以及更多。您可以在 GitHub 上的 TypeScript 路线图github.com/Microsoft/TypeScript/wiki/Roadmap中查看 TypeScript 未来版本将带来哪些新功能。
TypeScript 接口允许您声明自定义类型。接口有助于防止在应用程序中使用错误类型的对象导致的编译时错误。
生成的 JavaScript 代码易于阅读,看起来像是手写的代码。Angular 框架本身是用 TypeScript 编写的,Angular 文档(见angular.io)、文章和博客中的大多数代码示例也是使用 TypeScript 编写的。2018 年,Stack Overflow 开发者调查(insights.stackoverflow.com/survey/2018)显示 TypeScript 是第四受欢迎的语言。如果您想看到更多科学证据,证明 TypeScript 比 JavaScript 更高效,请阅读“是否需要类型化:JavaScript 中可检测错误的量化”(Zheng Gao 等人,ICSE 2017)这项研究,可在earlbarr.com/publications/typestudy.pdf找到。
作者的实际经验
我们在 Farata Systems 公司工作,多年来我们使用 Adobe Flex(目前是 Apache Flex)框架开发了相当复杂的软件。Flex 是一个建立在强类型、编译型 ActionScript 语言之上的高效框架,应用程序部署在 Flash Player 浏览器插件(一个虚拟机)中。
当网络社区开始远离使用浏览器插件时,我们花了两年时间试图找到 Flex 框架的替代品。我们尝试了不同的基于 JavaScript 的框架,但我们的开发者的生产力严重下降。最终,我们看到了隧道尽头的曙光,这是 TypeScript 语言、Angular 框架和 Angular Material UI 库的结合。
1.3. Angular 概述
Angular 是一个基于组件的框架,任何 Angular 应用都是一个组件树(想象一下视图)。每个视图都由组件类的实例表示。一个 Angular 应用有一个根组件,它可能有子组件。每个子组件可能有它自己的子组件,依此类推。
想象一下你需要用 Angular 重写 Twitter 应用。你可以从你的网页设计师那里获取一个原型,并从将其拆分为组件开始,如图 1.1 所示。带有粗边的顶层组件包含多个子组件。中间,你可以看到一个 New Tweet 组件位于两个 Tweet 组件实例之上,而 Tweet 组件本身又有回复、转发、点赞和直接消息的子组件。
图 1.1. 将原型拆分为组件

父组件可以通过将值绑定到子组件的属性来向其子组件传递数据。子组件不知道数据来自哪里。子组件可以通过发出事件将数据传递给父组件(而不需要知道父组件是谁)。这种架构使组件自包含且可重用。
在 TypeScript 中编写时,一个 组件 是一个被装饰器 @Component() 注解的类,其中你指定了组件的 UI(我们在 附录 B 的 B.10 节,“装饰器”中解释了装饰器,附录 B)。
@Component({
...
}
export class AppComponent {
...
})
你应用的大部分业务逻辑都是在服务中实现的,服务是没有 UI 的类。Angular 将创建你的服务类实例,并将它们注入到你的组件中。你的组件可能依赖于服务,而你的服务可能依赖于其他服务。一个 服务 是一个实现某些业务逻辑的类。Angular 使用我们在 第五章 中讨论的依赖注入(DI)机制将服务注入到你的组件或其他服务中。
组件被分组到 Angular 模块中。一个模块是一个带有@NgModule()装饰器的类。一个典型的 Angular 模块是一个小的类,除非你想编写手动引导应用程序的代码——例如,如果一个应用程序包含一个遗留的 AngularJS 应用程序。@NgModule()装饰器列出了应包含在此模块中的所有组件和其他工件(服务、指令等)。以下列表显示了一个示例。
列表 1.1. 包含一个组件的模块
@NgModule({
declarations: [
AppComponent *1*
],
imports: [
BrowserModule
],
bootstrap: [AppComponent] *2*
})
export class AppModule { }
-
1 声明 AppComponent 属于此模块
-
2 声明 AppComponent 是一个根组件
要编写一个简约的 Angular 应用程序,你可以创建一个AppComponent并将其列在@NgModule()的declarations和bootstrap属性中。一个典型的模块列出了几个组件,并且根组件在模块的bootstrap属性中指定。列表 1.1 还列出了BrowserModule,这对于在浏览器中运行的应用程序是必需的。
组件是 Angular 架构的核心。
显示了由四个组件和两个服务组成的示例 Angular 应用程序的高级图,所有这些都被打包在一个模块中。Angular 将它的HttpClient服务注入到你的应用Service1中,后者反过来又注入到GrandChild1组件中。
图 1.2. Angular 应用程序的示例架构

每个组件的 HTML 模板要么内联在组件内部(@Component()的template属性),要么使用templateUrl属性从组件引用的文件中。后者提供了代码和 UI 之间的清晰分离。同样的规则也适用于样式组件。你可以使用styles属性内联样式,或者提供 CSS 文件的位置在styleURLs中。以下列表显示了某些搜索组件的结构。
列表 1.2. 示例组件的结构
@Component({
selector: 'app-search', *1*
templateUrl: './search.component.html', *2*
styleUrls: ['./search.component.css'] *3*
})
export class SearchComponent {
// Component's properties and methods go here
}
-
1 其他组件的模板可以使用标签
。 -
2 模板代码在此文件中。
-
3 组件的样式在此文件中(可能还有其他文件)。
selector属性中的值定义了可以在其他组件模板中使用的标签名称。例如,根应用组件可以包含一个子搜索组件,如下所示。
列表 1.3. 在应用组件中使用搜索组件
@Component({
selector: 'app-root',
template: `<div>
<app-search></app-search> *1*
</div>`,
styleUrls: ['./app.component.css'],
})
export class AppComponent {
...
}
- 1 AppComponent 的 UI 包括 SearchComponent 的 UI。
列表 1.3 使用内联模板。注意,对于多行模板,使用反引号符号而不是引号(参见附录 A 中的 A.3 节)。
Angular 框架非常适合开发单页应用程序(SPAs),在这种应用程序中,整个浏览器页面不会被刷新,只有页面(视图)的某个部分可能会在用户浏览应用程序时替换另一个部分。这种客户端导航是通过 Angular 路由来安排的。如果您想在组件的 UI 中为渲染其子组件分配一个区域,您可以使用一个特殊的标签,<router-outlet>。例如,在应用程序启动时,您可能在这个出口显示主组件,如果用户点击产品链接,出口内容将被产品组件替换。
To arrange navigation within a child component, you can allocate the <router-outlet> area in the child as well. Chapters 3 and 4 explain how the router works.
Angular 应用程序的 UI 组件
Angular 团队发布了一个名为 Angular Material 的 UI 组件库(见 material.angular.io)。在撰写本文时,它基于 Material Design 指南(见 material.io/guidelines)提供了超过 30 个精心设计的 UI 组件。我们建议在项目中使用 Angular Material 组件,如果您需要除 Angular Material 之外更多组件,可以使用 PrimeNG、Kendo UI、DevExtreme 或其他第三方库。您还可以使用流行的 Bootstrap 库与 Angular 应用程序一起使用,我们将在第二章的 ngAuction 示例中展示如何做到这一点。从第七章开始,您将重写 ngAuction,用 Angular Material 组件替换 Bootstrap 组件。
| |
移动设备上的 Angular
Angular 的渲染引擎是一个独立的模块,允许第三方供应商创建自己的渲染引擎,该引擎针对非浏览器平台。组件的 TypeScript 部分保持不变,但 @Component 装饰器的 template 属性的内容可能包含 XML 或其他用于渲染原生组件的语言。
例如,您可以使用 Native-Script 框架的 XML 标签编写组件的模板,该框架作为 JavaScript 和原生 iOS 及 Android UI 组件之间的桥梁。另一个自定义 UI 渲染器允许您使用 Angular 与 React Native,这是为 iOS 和 Android 创建原生(非混合)UI 的另一种方式。
我们之前提到,新的 Angular 应用程序可以在几秒钟内生成。让我们看看 Angular CLI 工具是如何做到这一点的。
1.4. 介绍 Angular CLI
Angular CLI 是一个用于管理应用程序整个生命周期的 Angular 项目的工具。它作为代码生成器,极大地简化了新项目创建以及现有应用程序中生成新组件、服务和路由的过程。您还可以使用 Angular CLI 为开发和生产部署构建代码包。Angular CLI 不仅为您生成样板项目,还会安装 Angular 框架及其所有依赖项。
Angular CLI 已成为启动新 Angular 项目的既定方式。您将使用包管理器 npm 安装 Angular CLI。如果您不熟悉包管理器,请阅读附录 C。要在计算机上全局安装 Angular CLI 以供多个项目使用,请在终端窗口中运行以下命令:
npm install @angular/cli -g
安装完成后,Angular CLI 即可生成新的 Angular 项目。
1.4.1. 生成新的 Angular 项目
CLI 代表 命令行界面,安装 Angular CLI 后,您可以从终端窗口运行 ng 命令。Angular CLI 理解许多命令行选项,您可以通过运行 ng help 命令查看所有选项。您将首先使用 ng new 命令生成一个新的 Angular 项目。创建一个名为 hello-cli 的新项目:
ng new hello-cli
此命令将创建一个目录,名为 hello-cli,并生成一个包含一个模块、一个组件以及所有必需配置文件的项目,包括包含所有项目依赖项的 package.json 文件(有关详细信息,请参阅附录 C)。生成这些文件后,Angular CLI 将启动 npm 以安装 package.json 中指定的所有依赖项。当此命令完成后,您将看到一个名为 hello-cli 的新目录,如图 1.3 所示。
提示
假设您有一个 Angular 5 项目,并希望切换到 Angular 的最新版本。您不需要手动修改 package.json 文件中的依赖项。运行 ng update 命令,如果已安装最新版本的 Angular CLI,则 package.json 中的所有依赖项都将更新。从一种 Angular 版本更新到另一种 Angular 版本的过程在update.angular.io中描述。
图 1.3. 新生成的 Angular 项目

我们将在第二章中回顾 hello-cli 目录的内容,但让我们构建并运行此项目。在终端窗口中,切换到 hello-cli 目录并运行以下命令:
ng serve
Angular CLI 将花费大约 10–15 秒将 TypeScript 编译成 JavaScript 并构建应用程序包。然后 Angular CLI 将启动其开发服务器,准备在端口 4200 上提供服务。您的终端输出可能看起来像图 1.4。
图 1.4. 使用 ng serve 构建包

现在,将你的网络浏览器指向 http://localhost:4200,你将看到应用的登录页面,如图 1.5 所示。
图 1.5. 在浏览器中运行应用

恭喜!你创建、配置、构建并运行了你的第一个 Angular 应用,而且没有写一行代码!
ng serve 命令在内存中构建包而不生成文件。在项目开发过程中,你只需运行一次 ng serve,然后继续编写代码。每次你修改并保存文件时,Angular CLI 都会在内存中重建包(这需要几秒钟),你将立即看到代码修改的结果。以下 JavaScript 包被生成:
-
inline.bundle.js 是一个由 Webpack 加载器使用的文件,用于加载其他文件。
-
main.bundle.js 包含你的代码(组件、服务等)。
-
polyfills.bundle.js 包含 Angular 运行在旧浏览器中所需的填充代码。
-
styles.bundle.js 包含你的应用中的 CSS 样式。
-
vendor.bundle.js 包含 Angular 框架及其依赖项的代码。
对于每个包,Angular CLI 都会生成一个源映射文件,以便调试原始 TypeScript,尽管浏览器将运行生成的 JavaScript。不要被 vendor.bundle.js 的大尺寸吓到——这是一个开发构建,当构建生产包时,其大小将显著减少。
Webpack 和 Angular CLI
目前,Angular CLI 使用 Webpack(见 webpack.js.org)来构建包,并使用 webpack-dev-server 来提供应用服务。当你运行 ng serve 时,Angular CLI 会运行 webpack-dev-server。从 Angular 7 开始,Angular CLI 提供了一个选项,可以使用 Bazel 进行打包。在初始项目构建之后,如果开发者继续在项目上工作,Bazel 可以比 Webpack 快得多地重建包。
ng new 的一些有用选项
当你使用 ng new 命令生成一个新项目时,你可以指定一个选项来改变生成的内容。如果你不希望为应用程序组件样式生成一个单独的 CSS 文件,请指定 inline-style 选项:
ng new hello-cli --inline-style
如果你不想为应用程序组件模板生成一个单独的 HTML 文件,请使用 inline-template 选项:
ng new hello-cli --inline-template
如果你不想为单元测试生成文件,请使用 skip-tests 选项:
ng new hello-cli --skip-tests
如果你计划在你的应用中实现导航,请使用路由选项生成一个额外的模块,你将在其中配置路由:
ng new hello-cli --routing
要获取完整选项列表,请运行 ng help new 命令或阅读 Angular CLI Wiki 页面 github.com/angular/angular-cli/wiki。
1.4.2. 开发和生产构建
ng serve命令在内存中打包了应用,但没有生成文件,也没有优化你的 Hello CLI 应用。你将使用ng build命令来生成文件,但现在让我们开始讨论包大小优化和两种编译模式。
在你浏览器的开发者工具中打开网络标签页,你会看到浏览器不得不加载几兆字节的代码来渲染这个简单的应用。在开发模式下,应用的大小不是一个问题,因为你在本地运行服务器,浏览器加载这个应用需要超过一秒钟,如图 1.6 所示 figure 1.6。
图 1.6. 运行未优化的应用

现在想象一个用户使用移动设备通过普通的 3G 连接浏览互联网。加载相同的 Hello CLI 应用需要 20 秒钟。很多人无法忍受等待 20 秒来加载除了 Facebook(地球上 30%的人口生活在 Facebook 上)以外的任何应用。在上线之前,你需要减小包的大小。
在构建包时应用--prod选项将通过优化你的代码产生更小的包(如图 1.6 所示 figure 1.6)。它将重命名你的变量为单个字母,删除注释和空行,并删除大部分未使用的代码。可以从应用包中移除的另一段代码是 Angular 编译器。是的,ng serve命令将编译器包含在了供应商的.bundle.js 中。但是,当你为生产构建应用时,你将如何从部署的应用中移除 Angular 编译器?
1.5. JIT 与 AOT 编译
让我们回顾一下app.component.html的代码。大部分由标准的 HTML 标签组成,但有一行是浏览器无法理解的:
Welcome to {{title}}!
这些双大括号表示在 Angular 中将值绑定到字符串中,但这一行必须由 Angular 编译器(称为ngc)编译,以将绑定替换为浏览器可以理解的内容。组件模板可以包含其他 Angular 特定的语法(例如,结构指令*ngIf和*ngFor),在请求浏览器渲染模板之前需要编译。
当你运行ng serve命令时,模板编译是在浏览器内进行的。在浏览器加载你的应用包之后,Angular 编译器(打包在 vendor.bundle.js 中)从 main.bundle.js 中编译模板。这被称为即时(JIT)编译。这个术语意味着编译发生在包到达浏览器时。
JIT 编译的缺点包括以下内容:
-
在加载包和渲染 UI 之间存在一个时间间隔。这段时间用于 JIT 编译。对于像 Hello CLI 这样的小型应用,这段时间是微不足道的,但在现实世界的应用中,JIT 编译可能需要几秒钟,因此用户需要等待更长的时间才能看到你的应用。
-
Angular 编译器必须包含在 vendor.bundle.js 中,这会增加应用的大小。
在生产中使用 JIT 编译是不推荐的,你希望在创建包之前将模板预编译成 JavaScript。这就是“提前时间”(AOT)编译的原理。
AOT 编译的优势如下:
-
浏览器可以在应用加载后立即渲染 UI。不需要等待代码编译。
-
ngc编译器不包括在 vendor.bundle.js 中,你应用的结果大小可能会更小。
为什么使用“可能”而不是“将会”?从包中移除 ngc 编译器应该总是导致应用大小减小,对吧?并不总是这样。编译后的模板比使用简洁的 Angular 语法的模板要大。Hello CLI 的大小肯定会更小,因为只有一个编译行。但在具有大量视图的大应用中,编译后的模板可能会增加应用的大小,甚至比包含在包中的 ngc 的 JIT 编译应用还要大。无论如何,你应该使用 AOT 模式,因为用户会更快地看到你应用的初始着陆页。
注意
你可能会惊讶地看到,一个用 tsc 编译良好的应用出现了 ngc 编译器错误。原因是 AOT 要求你的代码是静态可分析的。例如,你不能在模板中使用的属性上使用 private 关键字,也不允许有默认导出。修复 ngc 编译器报告的错误,并享受 AOT 编译的好处。
无论你选择 JIT 还是 AOT 编译,你最终都会决定进行优化的生产构建。你该如何做?
1.5.1. 使用--prod 选项创建包
当你使用 --prod 选项构建包时,Angular CLI 会执行代码优化和 AOT 编译。通过在 Hello CLI 项目中运行以下命令来查看其效果:
ng serve --prod
在你的浏览器中打开应用,并检查网络标签页,如图 1.7 所示。现在相同应用的压缩后大小仅为 108 KB。
图 1.7. 使用 AOT 运行优化后的应用

展开包含包大小的列——开发服务器甚至为你做了 gzip 压缩。包的文件名包括每个包的哈希码。Angular CLI 在每次生产构建时都会计算一个新的哈希码,以防止浏览器在部署新应用版本到生产时使用缓存的版本。
你难道不应该总是使用 AOT 吗?理想情况下,你应该这样做,除非你使用了某些在 AOT 编译期间产生错误的第三方 JavaScript 库。如果你遇到这个问题,可以通过以下命令关闭 AOT 编译,以构建包:
ng serve --prod --aot false
图 1.8 显示,与图 1.7 中的 AOT 编译应用相比,大小和加载时间都有所增加。
图 1.8. 不使用 AOT 运行优化后的应用

1.5.2. 在磁盘上生成包
您之前使用的是 ng serve 命令,该命令在内存中构建包。当您准备好生成生产文件时,请改用 ng build 命令。ng build 命令在 dist 目录中生成文件(默认情况下),但包的大小不会进行优化。
使用 ng build --prod,生成的文件将进行优化但不会压缩,因此您需要在之后对包应用 gzip 压缩。我们将在第十二章的第 12.5.3 节中介绍构建生产包和在 Node.js 服务器上部署应用程序的过程。
在 dist 目录中构建文件后,您可以将它们复制到您使用的任何 Web 服务器上。阅读您 Web 服务器的产品文档,如果您知道如何在您的服务器上部署 index.html 文件,那么 Angular 应用程序包也应该放在这里。
本节的目标是让您开始使用 Angular CLI,我们将在第二章中继续介绍其内容。第一个生成的应用程序相对简单,并不能展示 Angular 的所有功能;下一节将为您提供一些关于如何在 Angular 中实现这些功能的想法。
1.6. 介绍示例 ngAuction 应用程序
为了使这本书更具实用性,我们每章开始时都会向您展示一些小应用程序,这些应用程序展示了 Angular 语法或技术,而在大多数章节的末尾,您将使用新概念在一个实际应用程序中。您将看到组件和服务是如何组合成一个实际应用程序的。
想象一个在线拍卖(让我们称它为 ngAuction),人们可以浏览和搜索产品。当结果显示时,用户可以选择产品并进行出价。最新出价的信息将由服务器推送给所有订阅此类通知的用户。
浏览、搜索和出价的功能将通过向使用 Node.js 开发的服务器中的 RESTful 端点发送请求来实现。服务器将使用 WebSockets 推送有关用户出价和其他用户出价的通知。图 1.9 展示了 ngAuction 的示例工作流程。
图 1.9. ngAuction 工作流程

图 1.10 展示了 ngAuction 首页的第一个版本在桌面计算机上的渲染方式。最初,您将使用灰色占位符而不是产品图片。
图 1.10. 突出显示组件的 ngAuction 首页

您将使用 Bootstrap 库提供的响应式 UI 组件(见 getbootstrap.com),因此在家页在移动设备上的渲染可能如图 1.11 所示。
图 1.11. 智能手机上的在线拍卖首页

从 第七章 开始,您将重新设计 ngAuction,完全移除 Bootstrap 框架,用 Angular Material 和 Flex Layout 库来替换它。重构后的 ngAuction 的主页将看起来像 图 1.12。
图 1.12. 重新设计的 ngAuction

Angular 应用程序的开发归结为创建和组合组件。在 第二章 中,您将使用 Angular CLI 生成此项目和其组件及服务,而在 第七章 中,您将重构其代码。图 1.13 展示了 ngAuction 应用程序的工程结构。
图 1.13. 在线拍卖应用的工程结构

在 第二章 中,您将开始创建 ngAuction 的初始版本着陆页,并在随后的章节中,您将不断添加功能,展示各种 Angular 功能和技术。
注意
我们建议您使用像 WebStorm(价格低廉)或 Visual Studio Code(免费)这样的 IDE 开发 Angular 应用程序。它们提供自动完成功能,提供方便的搜索,并集成了终端窗口,这样您就可以在 IDE 内完成所有工作。
摘要
-
Angular 应用程序可以使用 TypeScript 或 JavaScript 开发。
-
Angular 是一个基于组件的框架。
-
TypeScript 源代码必须在部署之前转换为 JavaScript。
-
Angular CLI 是一个伟大的工具,它可以帮助您快速启动项目。它支持在开发中捆绑和提供您的应用程序,并为生产准备构建。
第二章. Angular 应用的主要工件
本章涵盖
-
理解组件、指令、服务、模块和管道
-
使用 Angular CLI 生成组件、指令、服务和路由
-
查看 Angular 数据绑定
-
构建 ngAuction 应用的第一个版本
在本章中,我们将首先解释 Angular 应用主要工件的作用。我们将向你介绍每一个,并展示 Angular CLI 如何生成这些工件。然后我们将概述 Angular CLI 配置文件,你可以在这里修改你的项目设置。
之后,我们将讨论如何在 Angular 中实现数据绑定。在本章结束时,你将开发 ngAuction 应用的初始版本,你将在整本书中继续对其进行工作。
2.1. 组件
组件,任何 Angular 应用的主要工件,是一个带有视图(UI)的类。要将一个类转换为组件,你需要用 @Component() 装饰器对其进行装饰。一个组件可以由一个或多个文件组成——例如,一个扩展名为 .ts 的文件,其中包含组件类,一个 .css 文件包含样式,一个 .html 文件包含模板,以及一个 .spec.ts 文件包含组件的测试代码。
你不必将每个组件的代码分割到这些文件中。例如,你可以将一个组件放在一个文件中,包含内联样式和模板,并且没有测试的文件。无论有多少文件代表你的组件,它们都应该位于同一个目录中。
任何组件都属于应用的一个确切模块,你必须将组件类的名称包含在模块文件中 @NgModule() 装饰器的 declarations 属性中。在 第一章 中,我们已经将 AppComponent 列在 AppModule 中。
Angular CLI 的 ng generate 命令
即使在生成新项目之后,你仍然可以使用 Angular CLI 通过使用 ng generate 命令或其别名 ng g 来生成工件。以下是一些你可以与 ng g 命令一起使用的选项:
-
ng g c—— 生成一个新的组件。 -
ng g s—— 生成一个新的服务。 -
ng g d—— 生成一个新的指令。 -
ng g m—— 生成一个新的模块。 -
ng g application—— 在同一个项目中生成一个新的应用。这个命令是在 Angular 6 中引入的。 -
ng g library—— 从 Angular 6 开始,你可以生成一个库项目。
这不是一个应用,但它可以包括服务和组件。
每个这些命令都需要一个参数,例如项目的名称,来生成。要获取完整的选项和参数列表,请运行 ng help generate 命令或参考 Angular CLI 文档。
这里是使用 ng g 命令的一些示例:
-
ng g c product将在 src/app/product 目录中为新的产品组件生成四个文件,并将ProductComponent类添加到@NgModule的declarations属性中。 -
ng g c product -is --it --spec false将在 src/app/product 目录中生成一个包含内联样式和模板的单个文件 product.component.ts,且不包含测试,并将ProductComponent添加到@NgModule的declarations属性中。 -
ng g s product将生成包含用@Injectable装饰的类的文件 product.service.ts,以及位于 src/app 目录中的 product.service.spec.ts 文件。
ng g s product -m app.module 将生成与前面命令相同的文件,并将 ProductService 添加到 @NgModule 的 providers 属性中。
让我们在第一章中创建的 Hello CLI 项目中添加一个产品组件,通过在该项目的根目录中运行以下命令:
ng g c product --is --it --spec false
此命令将在 src/app/product 目录中创建 product.component.ts 文件。
列表 2.1. product.component.ts
@Component({
selector: 'app-product',
template: `
<p>
product Works! *1*
</p>
`,
styles: []
})
export class ProductComponent implements OnInit { *2*
constructor() { }
ngOnInit() {} *3*
}
-
1 默认要渲染的文本
-
2 实现 OnInit 需要在类中实现 ngOnInit() 方法。
-
3 生命周期方法
生成的 @Component() 装饰器具有 app-product 选择器,带有内联 HTML 的 template 属性,以及用于内联 CSS 的 styles 属性。其他组件可以通过使用 <app-product> 标签将你的产品组件包含在其模板中。
列表 2.1 中的类有一个空的构造函数和一个方法,ngOnInit(),这是组件生命周期方法之一。如果实现,ngOnInit() 将在构造函数中的代码之后调用。OnInit 是需要实现 ngOnInit() 的生命周期接口之一。我们将在第九章的 9.2 节中介绍组件生命周期。
注意
@Component() 装饰器还有一些其他属性,我们将在使用它们的时候讨论。@Component() 装饰器的所有属性都描述在 angular.io/api/core/Component。
| |
使用选择器前缀
列表 2.1 中的组件选择器以 app- 为前缀,这是应用程序的默认前缀。对于库项目,默认前缀是 lib-。一个好的做法是想出一个更具体的、能更好地识别你的应用程序的前缀。你的项目名为 Hello CLI,因此你可能希望给你的所有组件都加上 hello- 前缀。为此,在生成组件时使用 -prefix 选项:
ng g c product -prefix hello
该命令将生成一个具有 hello-product 选择器的组件。确保所有生成的组件都有一个特定前缀的一个更简单的方法是在文件 .angular-cli.json(或从 Angular 6 开始的 angular.json)中指定前缀,这将在本章后面讨论。
如果你打开 app.module.ts 文件,你会看到 ProductComponent 已经被你的 ng g c 命令导入并添加到 declarations 部分:
@NgModule({
declarations: [
AppComponent,
ProductComponent
],
imports: [
BrowserModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
新生成的ProductComponent类被添加到@NgModule()的declarations属性中。在这本书的每一章中,我们都会继续使用组件,这样你就有机会学习 Angular 组件的各种功能。
什么是元数据?
TypeScript 装饰器允许你通过提供关于装饰实体的元数据来修改类、属性或方法(或其参数)的行为,而无需更改它们的代码。一般来说,元数据是关于数据的额外信息。例如,在 MP3 文件中,音频是数据,但艺术家名称、歌曲标题和专辑封面是元数据。MP3 播放器包括元数据处理器,可以读取元数据并在播放歌曲时显示其中的一些信息。
在类的情况下,元数据指的是关于类的额外信息。例如,@Component()装饰器告诉 Angular(元数据处理器)这不是一个普通类,而是一个组件。Angular 根据@Component()装饰器属性中提供的信息生成额外的 JavaScript 代码,将类转换为 UI 组件。@Component()装饰器不会改变装饰类的内部结构,而是添加一些描述类的数据,以便 Angular 编译器可以正确生成组件的最终代码。
在类属性的情况下,@Input()装饰器告诉 Angular 这个类属性应该支持绑定,并能够从父组件接收数据。你将在第八章的 8.2.1 节中学习关于@Input()装饰器的更多内容。
在底层,装饰器是一个将一些数据附加到装饰元素上的函数。有关更多详细信息,请参阅附录 B 中的 B.10 节。
组件是一个具有 UI 的类,而服务是一个实现应用程序业务逻辑的类。让我们熟悉一下服务。
2.2. 服务
为了更清晰地分离代码,我们通常不会为获取或操作数据的代码使用组件。注入式服务是处理数据的正确位置。一个组件可能依赖于一个或多个服务。第五章介绍了 Angular 中依赖注入的工作原理。在这里,我们将向您展示服务和组件是如何协同工作的。
让我们从在共享文件夹中生成一个服务开始,假设这个服务将被多个组件使用。为了确保@NgModule()的providers属性会更新为新生成的服务,请使用以下选项:
ng g s shared/product -m app.module
新文件 product.service.ts 将在 src/app/shared 目录下生成:
@Injectable()
export class ProductService {
constructor() { }
}
因此,app.module.ts 文件将被更新以包含此服务的提供者:
@NgModule({
...
providers: [ProductService]
})
export class AppModule { }
接下来,在 ProductService 中实现一些具有所需业务逻辑的方法。请注意,生成的 ProductService 类被 @Injectable() 装饰器注解。为了使 Angular 实例化并将此服务注入到任何组件中,请将以下参数添加到组件的构造函数中:
constructor(productService: ProductService){
// start using the service, e.g. productService.getMyData();
}
服务并不是唯一没有 UI 的工件。指令也没有自己的 UI,但它们可以附加到组件的 UI 上。
Angular 6 的新特性
从 Angular 6 开始,ng g s 命令生成一个带有 Injectable() 装饰器的类:
@Injectable({
provideIn: 'root'
})
provideIn: 'root' 允许您跳过在 NgModule() 装饰器的 providers 属性中指定服务的步骤。
2.3. 指令
将 Angular 指令视为 HTML 增强器。指令允许您教一个旧的 HTML 元素新的技巧。指令 是一个带有 @Directive() 装饰器的类。您将在第十一章的第 11.7 节(section 11.7)中看到 @Directive() 装饰器的使用。
指令不能有自己的 UI,但可以附加到组件或常规 HTML 元素上以改变它们的视觉表示。Angular 中有两种类型的指令:
-
结构型— 指令改变组件模板的结构。
-
属性型— 指令改变单个组件或 HTML 元素的行性行为或视觉表示。
例如,使用结构型 *ngFor 指令,您可以遍历数组(或其他集合),并为数组中的每个项目渲染一个 HTML 元素。以下列表使用 *ngFor 指令遍历 products 数组,并为每个产品渲染一个 <li> 元素(假设有一个接口或 Product 类,其中有一个 title 属性)。
列表 2.2. 使用 *ngFor 迭代产品
@Component({
selector: 'app-root',
template: `<h1>All Products</h1>
<ul>
<li *ngFor="let product of products"> *1*
{{product.title}} *2*
</li>
</ul>
`})
export class AppComponent {
products: Product[] = []; *3*
// the code to populate products is removed for brevity
}
-
1 为每个产品渲染
-
2 每个
- 元素显示产品标题。
-
*3 ngFor 遍历此数组。
以下元素使用结构型 *ngIf 指令根据 hasError() 方法返回的(true 或 false)来显示或隐藏 <mat-error> 元素,该方法检查表单字段 title 中的值是否具有无效的最小长度:
<mat-error *ngIf="formModel.hasError('minlength', 'title')" >Enter at least 3
characters</mat-error>
在本章后面讨论双向绑定时,我们将使用属性 ngModel 指令将 <input> 元素中的值绑定到类变量 shippingAddress:
<input type='text' placeholder="Enter shipping address"
[(ngModel)]="shippingAddress">
您还可以创建自定义属性指令,这在产品文档 angular.io/guide/attribute-directives 中有描述。
另一个没有自己的 UI 但可以在组件模板中转换值的工件是管道。
2.4. 管道
管道 是一个模板元素,允许您将一个值转换成期望的输出。通过在要转换的值后添加垂直线(|)和管道名称来指定管道:
template: `<p>Your birthday is {{ birthday | date }}</p>`
在前面的示例中,birthday变量的值将被转换为默认格式的日期。Angular 附带了许多内置管道,每个管道都有一个实现其功能的类(例如,DatePipe),以及你可以在模板中使用的名称(例如date):
-
UpperCasePipe允许你通过在模板中使用| uppercase将输入字符串转换为大写。 -
LowerCasePipe允许你通过在模板中使用| lowercase将输入字符串转换为小写。 -
DatePipe允许你通过使用| date来以不同的格式显示日期。 -
CurrencyPipe通过使用| currency将数字转换为所需的货币。 -
AsyncPipe通过使用| async来展开提供的Observable流中的数据。你将在第 6.5 节中找到使用async的代码示例,该节位于第六章。
一些管道不需要输入参数(例如uppercase),而一些则需要(例如date:'medium')。你可以链式调用任意数量的管道。以下代码片段展示了如何以中等日期格式和大写形式(例如,JUN 15, 2001, 9:43:11 PM)显示birthday变量的值:
template=
`<p>{{ birthday | date: 'medium' | uppercase}}</p>`
如你所见,实际上无需编写任何代码,你就可以将日期转换为所需的格式,并显示为大写(请参阅 Angular DatePipe文档中的日期格式,mng.bz/78lD)。
除了预定义的管道外,你还可以创建自定义管道,这些管道可以包含特定于你应用程序的代码。创建自定义管道的过程在angular.io/guide/pipes中描述。本章的代码示例包括一个演示如何将华氏度转换为摄氏度并返回的自定义管道的应用程序。
现在你已经知道你的应用程序可以包含组件、服务、指令和管道。所有这些元素都必须在你的应用程序模块中声明。
2.5. 模块
Angular 模块是一个用于存放一组相关组件、服务、指令和管道的容器。你可以将模块视为一个实现应用程序业务领域特定功能的包,例如运输或计费模块。小型应用程序的所有元素都可以位于一个模块中(根模块),而大型应用程序可能包含多个模块(功能模块)。所有应用程序都必须至少有一个在应用程序启动时启动的根模块。
从语法角度来看,Angular 模块是一个带有@NgModule()装饰器的类。要在应用程序启动时加载根模块,请在你的应用程序的 main.ts 文件中调用bootstrapModule()方法。
platformBrowserDynamic().bootstrapModule(AppModule);
Angular 框架本身被拆分为模块。包括一些 Angular 模块是必须的(例如,@angular/core),而一些模块是可选的。例如,如果你计划使用 Angular 表单 API 和进行 HTTP 请求,你应该在 package.json 文件中添加@angular/forms和@angular/common/http,并在你的应用根模块中包含FormsModule和HttpClientModule,如下面的列表所示。
列表 2.3. 示例根模块
@NgModule({
declarations: [
AppComponent *1*
],
imports: [ *2*
BrowserModule,
FormsModule,
HttpClientModule
],
bootstrap: [AppComponent] *3*
})
export class AppModule { }
-
1 此模块中包含的唯一组件
-
2 其他本应用所需的模块
-
3 应用启动时加载的顶级组件
如果你决定将应用拆分为几个模块,除了根模块外,你还需要创建功能模块,下文将介绍。
2.5.1. 功能模块
一个 Angular 应用可能由根模块和功能模块组成。你可以在功能模块中实现应用的一个特定功能(例如,运输)。而 Web 应用的根模块的@NgModule()装饰器必须包含BrowserModule,功能模块则包含CommonModule。功能模块可以被其他模块导入。功能模块的@NgModule()装饰器不包含bootstrap属性,因为启动整个应用是根模块的责任。
下面的列表生成一个名为 Hello Modules 的小型应用,并向其中添加一个名为ShippingModule的功能模块。
列表 2.4. 生成项目和功能模块
ng new hello-modules *1*
cd hello-modules
ng g m shipping *2*
-
1 生成名为 hello-modules 的新项目
-
2 生成名为 shipping 的新功能模块
此应用将有一个功能模块,其内容如下所示,位于文件 src/app/shipping/shipping.module.ts 中。
列表 2.5. 生成的功能模块
@NgModule({
imports: [
CommonModule *1*
],
declarations: []
})
export class ShippingModule { }
- 1 功能模块导入 CommonModule 而不是 BrowserModule
现在让我们生成一个新的运输组件,并指示 Angular CLI 将其包含到ShippingModule中:
ng g c shipping -it -is -m shipping
此命令生成文件 shipping/shipping.component.ts,其中包含装饰过的类ShippingComponent,一个内联模板和一个空的styles属性。该命令还将它添加到ShippingModule的declarations部分。运输组件的代码如下所示。
列表 2.6. 生成的运输组件
@Component({
selector: 'app-shipping',
template: `
<p>
shipping Works! *1*
</p>
`,
styles: []
})
export class ShippingComponent implements OnInit { *2*
constructor() { }
ngOnInit() {} *3*
}
-
1 默认模板
-
2 实现 OnInit 生命周期接口
-
3 此生命周期钩子在构造函数之后被调用。
注意到运输组件的选择器:app-shipping。你将在AppComponent的模板中使用这个名称。
你的运输模块代码将包括运输组件,其外观如下所示。
列表 2.7. 生成的运输模块
@NgModule({
imports: [
CommonModule
],
declarations: [ShippingComponent]
})
export class ShippingModule { }
特殊模块可以声明自己的组件和服务,但要使所有或部分组件和服务对其他模块可见,你需要导出它们。在这种情况下,你需要向发货模块添加一个exports部分,使其看起来如下所示。
列表 2.8. 导出发货组件
@NgModule({
imports: [
CommonModule
],
declarations: [ShippingComponent],
exports: [ShippingComponent] *1*
})
export class ShippingModule { }
- 1 从模块中导出组件
外部模块将只能看到在exports中明确提到的发货模块的成员。发货模块可能包含其他成员,如类、指令和管道。如果你没有在exports部分中列出它们,这些成员将保持私有,并从应用的其他部分隐藏。现在你应该将发货模块包含在根模块中。
列表 2.9. 将发货模块添加到根模块
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
ShippingModule *1*
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
- 1 将发货模块添加到根模块
要让浏览器在根组件中渲染发货组件,你可以在AppComponent的模板中添加<app-shipping>标签。
列表 2.10. 添加发货组件
@Component({
selector: 'app-root',
template: `
<h1>Welcome to {{title}}!!</h1>
<app-shipping></app-shipping> *1*
`,
styles: []
})
export class AppComponent {
title = 'app';
}
- 1 将 ShippingComponent 添加到根组件的模板
使用ng serve运行此应用,并在浏览器中打开 http://localhost:4200。你会看到从根模块渲染的AppComponent和从发货模块渲染的ShippingComponent,如图 2.1 所示。
图 2.1. 运行双模块应用

“欢迎使用 app!!”是位于根模块中的AppComponent发出的问候,而“shipping Works!”则来自位于你的功能模块中的ShippingComponent。这是一个相当简单的例子,但它说明了你可以如何模块化一个应用,使其特定功能位于一个单独的可重用模块中,并由单独的开发者(们)开发。
你的应用模块可以在应用启动时预先加载,就像 Hello Modules 项目中所做的那样,或者按需加载,例如当用户点击特定的链接时。在第 4.3 节中,你将看到具有懒加载模块的示例应用。
到现在为止,你知道组件由 TypeScript 代码和 UI(模板)组成。接下来要学习的概念是如何在数据通过编程方式或用户与应用交互的结果发生变化时同步代码和 UI。
2.6. 理解数据绑定
Angular 有一个名为数据绑定的机制,允许你保持组件属性与视图同步。在本节中,我们将解释数据绑定是如何与属性和事件一起工作的。
Angular 支持两种类型的数据绑定:单向(默认)和双向。在单向数据绑定中,数据在一个方向上同步:要么是从类成员变量(属性)到视图,要么是从视图事件到类变量或处理事件的函数。Angular 在其变更检测周期中更新绑定,这在第九章的第 9.1 节 section 9.1 中解释。
2.6.1. 绑定属性和事件
要在模板的字符串中显示类变量的值,请使用双大括号。如果一个类有一个名为name的变量,你可以这样显示它的值:
<h1>Hello {{name}}!</h1>
这也被称为插值,你可以在这些双大括号内使用任何有效的表达式。
使用方括号将类变量的值绑定到 HTML 元素或 Angular 组件的属性。以下示例将类变量isValid的值绑定到 HTML <span>元素的hidden属性:
<span [hidden]="isValid">This field is required</span>
注意方括号位于等号左侧。如果isValid变量的值为false,则span元素的文本不会隐藏,用户将看到消息“此字段为必填项。”一旦isValid变量的值变为true,文本“此字段为必填项”将变为隐藏。
上述示例说明了从类变量到视图的单向绑定。接下来的列表将展示从视图到类成员(如方法)的单向绑定。
要将事件处理函数分配给事件,请在组件的模板中将事件名称放在括号内。以下列表展示了如何将onClickEvent()函数绑定到click事件,以及将onInputEvent()函数绑定到input事件。
列表 2.11. 带有处理器的两个事件
<button (click)="onClickEvent()">Get Products</button> *1*
<input placeholder="Product name" (input)="onInputEvent()"> *2*
-
1 如果按钮被点击,调用 onClickEvent()方法。
-
2 一旦输入字段的值发生变化,就调用 onInputEvent()方法。
当括号中指定的事件被触发时,双引号中的表达式将被重新评估。在列表 2.11 中,表达式是函数,因此每次触发相应的事件时都会调用它们。
如果你对分析事件对象的属性感兴趣,请将$event参数添加到方法处理器中。特别是,事件对象的target属性表示事件发生的 DOM 节点。事件对象的实例仅在绑定作用域内(即在事件处理器方法中)可用。图 2.2 展示了如何读取事件绑定语法。
图 2.2. 事件绑定语法

括号中的事件被称为绑定目标。您可以将函数绑定到今天存在的任何标准 DOM 事件(参见 Mozilla 开发者网络文档中的“事件参考”,mzl.la/1JcBR22)或未来将要引入的事件。
括号用于将标准 DOM 事件以及组件的自定义事件进行绑定。例如,假设您有一个可以发出自定义事件 lastPrice 的价格报价组件。以下代码片段显示了如何将 lastPrice 事件绑定到类方法 priceQuoteHandler():
<price-quoter (lastPrice)="priceQuoteHandler($event)">?</price-quoter>
您将在第八章(kindle_split_017.xhtml#ch08)和第 8.2.2 节(kindle_split_017.xhtml#ch08lev2sec2)中学习如何创建发出自定义事件的组件。
2.6.2. 单向和双向数据绑定实例
让我们运行并回顾本章附带的项目绑定中的两个简单应用程序。如果您使用 Angular 5 代码示例,这两个应用程序 oneway 和 twoway 通过在文件 .angular-cli.json 中的 apps 数组中创建两个元素来配置。如果您使用 Angular 6 代码示例,这两个应用程序在文件 angular.json 中配置。
配置 Angular CLI 项目
在 Angular 6 之前,生成的项目包括配置文件 .angular-cli.json,它允许您指定源代码的位置,哪个目录将包含编译后的代码,在哪里可以找到您项目的资源,以及第三方库(如果有)所需的代码和样式在哪里,等等。Angular CLI 在生成您的应用程序工件、构建和运行测试时使用此文件的属性。
您可以在文档“Angular CLI 配置模式”中找到每个配置属性的完整和当前描述,该文档可在 github.com/angular/angular-cli/wiki/angular-cli 找到。您将在本节中使用 apps 配置属性,并在第 2.7 节(#ch02lev1sec7)中使用 styles 和 scripts 属性。
从 Angular 6 开始,项目在 angular.json 文件中配置,其模式描述在 github.com/angular/angular-cli/wiki/angular-workspace。现在项目被视为一个工作区,它可以包含一个或多个具有自己配置的应用程序和库,但它们都共享位于单个目录中的依赖项:node_modules。
这两个应用程序将类似地配置——只有应用程序名称和引导这些应用程序的文件名称将不同,如下面的列表所示。
列表 2.12. Angular 5: 在 .angular-cli.json 中配置两个应用程序
"apps": [
{
"name": "oneway", *1*
...
"main": "main-one-way-binding.ts", *2*
...
},
{
"name": "twoway", *3*
...
"main": "main-two-way-binding.ts", *4*
...
}
]
-
1 第一个应用程序的名称
-
2 第一个应用程序的引导文件
-
3 第二个应用程序的名称
-
4 第二个应用程序的引导文件
因为这两个应用程序位于同一个项目中,所以您只需要运行一次 npm install。在 Angular 5 及更早版本中,您可以通过指定 ng serve 或 ng build 命令中的 --app 选项来捆绑和运行这些应用程序中的任何一个。main-one-way-binding.ts 文件包含从名为 one-way 的目录中引导应用程序模块的代码,而 main-two-way-binding.ts 文件则从本项目的 two-way 目录中引导应用程序模块。
在 Angular 5 中,如果您想将捆绑包构建到内存中,并使用名为 oneway 的应用程序启动开发服务器,以下命令将实现这一点:
ng serve --app oneway
注意
如果您使用 Angular 6 版本的代码示例,则不需要 --app 选项:ng serve oneway。
如果您还希望 Angular CLI 打开浏览器到 http://localhost:4200,请将 -o 添加到前面的命令中:
ng serve --app oneway -o
在您的 IDE 中打开绑定项目,并在其终端窗口中运行 npm i 命令。在依赖项安装完成后,运行前面的命令以查看单向示例应用程序的实际效果。它将渲染如图 2.3 所示的页面。
图 2.3. 运行单向绑定应用程序

在 Angular 6 版本的代码示例中,oneway 和 twoway 应用程序在 projects 部分的 angular.json 文件中进行了配置。以下命令将运行 oneway 应用程序,渲染如图 2.3 所示的 UI。
ng serve oneway
以下列表显示了渲染此页面的 AppComponent 的代码。
列表 2.13. 单向绑定示例的 AppComponent
@Component({
selector: 'app-root',
template:`
<h1>{{name}}</h1> *1*
<button (click)="changeName()"> *2*
Change name
</button>
`
})
export class AppComponent {
name: string = "Mary Smith";
changeName() {
this.name = "Bill Smart";
}
}
-
1 初始时使用单向属性绑定来渲染类变量
name的值 -
2 点击按钮通过单向事件绑定到
changeName()方法来更新变量name的值为 "Bill Smart"。
一旦用户点击按钮,changeName() 方法就会修改 name 的值,单向属性绑定随即生效,name 变量的新值将在页面上显示。
现在停止开发服务器(Ctrl-C),并运行名为 twoway 的应用程序:
ng serve --app twoway -o
此页面的模板包含以下 HTML 标签:<input>、<button> 和 <p>。在输入字段中输入 26 Broadway,您将看到如图 2.4 所示的页面。
图 2.4. 运行双向绑定示例应用程序

当输入字段中的值改变时,<p> 标签内的文本值会立即改变。如果您点击按钮,输入字段和段落的值将更改为 "The shipping address is 123 Main Street"。在这个应用程序中,您使用了双向绑定。应用程序组件的代码如下所示。
列表 2.14. 双向绑定示例
@Component({
selector: 'app-root',
template: `
<input type='text'
placeholder="Enter shipping address"
[(ngModel)]="shippingAddress"> *1*
<button (click)="shippingAddress='123 Main Street'">
Set Default Address
</button> *2*
<p>The shipping address is {{shippingAddress}}</p>
`
})
export class AppComponent {
shippingAddress: string;
}
-
1 使用 ngModel 表示双向绑定
-
2 点击时更新
shippingAddress的值
您可以通过使用 Angular 的 ngModel 指令将输入字段的值绑定到 shippingAddress 变量:
[(ngModel)]="shippingAddress"
记住,方括号表示属性绑定,圆括号表示事件绑定。为了表示双向绑定,将模板元素的 ngModel 用方括号和圆括号同时包围。在上面的代码中,您指示 Angular 在输入字段中的值改变时立即更新 shippingAddress 变量,并在 shippingAddress 的值改变时立即更新输入字段的值。这就是双向绑定的含义。
当您输入 26 Broadway 时,shippingAddress 变量的值也在改变。按钮的点击会程序性地将地址更改为 123 Main Street,并且这个值会传播回输入字段。
在审查位于双向目录中的此应用程序代码时,请注意应用程序模块导入了 FormsModule,这是由于您使用了 ngModel 指令,它是 Forms API 的一部分,在 第七章 中介绍。
2.7. 实战:开始使用 ngAuction
从现在开始,大多数章节将以一个实战部分结束,包含代码审查或开发 ngAuction 应用程序某个方面的说明,人们可以查看精选产品的列表,查看特定产品的详细信息,执行产品搜索,并监控其他用户的出价。我们将逐步向这个应用程序添加代码,以便您可以看到不同的 Angular 部分是如何结合在一起的。本书附带源代码包括各章节 ngAuction 文件夹中的此类实战部分的完成版本,但我们鼓励您亲自尝试这些练习(源代码可在 github.com/Farata/angulartypescript 和 www.manning.com/books/angular-development-with-typescript-second-edition 找到)。
本实战练习包含开发第一章kindle.split/010.xhtml#ch01中介绍的示例拍卖初始版本的说明。您将首先生成一个新的 Angular CLI 项目,然后创建主页,将其拆分为 Angular 组件,并创建一个用于获取产品的服务。
注意
本练习的完成版本位于 chapter2/ngAuction 目录中。要运行这个版本的 ngAuction,切换到 ngAuction 目录,运行 npm install,然后通过运行 ng serve 命令启动应用程序。我们假设您已经在您的计算机上安装了 Angular CLI 和 npm 软件包管理器。
2.7.1. ngAuction 的初始项目设置
让我们从零开始开发 ngAuction。实战练习中的每个部分都将包含一组您需要遵循的说明,以便您自己开发 ngAuction。
创建一个新的目录,并使用 Angular CLI 运行以下命令来生成一个新的项目—ngAuction:
ng new ngAuction --prefix nga --routing
-
ng new—生成一个新的项目。 -
--prefix nga— 生成的 .angular-cli.json 文件将具有prefix属性值nga(对于 ngAuction)。生成的应用程序组件将具有nga-root选择器,而我们将为 ngAuction 生成的所有其他组件也将具有带有前缀nga-的选择器。 -
--routing— 您将在 第三章 中为 ngAuction 添加导航。--routing选项将生成一个用于路由支持的样板模块。
在您的 IDE 中打开新创建的 ngAuction 目录,转到集成终端视图,并运行以下命令:
ng serve -o
此命令将构建包,启动开发服务器,并打开浏览器,浏览器将渲染页面,就像在 第一章 中 图 1.5 所示的 Hello CLI 应用程序一样。
在 第二章、第三章 和 第五章 中,您将使用 Bootstrap 4 框架(见 getbootstrap.com)进行样式设计和在 ngAuction 中实现响应式网页设计。术语 响应式网页设计(RWD)意味着视图布局可以适应用户设备的屏幕大小。从 第七章 开始,您将使用 Angular Material 组件(见 material.angular.io)重新设计 ngAuction 的 UI,并从该项目中移除 Bootstrap 框架。
因为 Bootstrap 库有 jQuery 和 Popper.js 作为依赖项,您需要在以下列表中运行命令来在 ngAuction 项目中安装它们。
列表 2.15. 安装 Bootstrap、jQuery 和 Popper.js
npm i bootstrap jquery popper.js --save-prod
提示
如果您使用的是低于 5.0 的 npm,请使用 --save 选项而不是 --save-prod。在 npm 5 中,有快捷方式:-P 对应于 --save-prod(默认)和 -D 对应于 --save-dev。
当您需要使用来自外部 JavaScript 库的全局样式或脚本时,您可以将其添加到 .angular-cli.json 配置文件中,或者从 Angular 6 开始,添加到 angular .json 中。在您的案例中,Bootstrap 入门指南(见 getbootstrap.com/docs/4.1/getting-started)指导您将 bootstrap.min.css 添加到应用的 index .html 文件中。但由于您使用 Angular CLI,您将把它添加到 .angular-cli.json 中的 styles 部分,所以看起来是这样的:
"styles": [
"styles.css",
"../node_modules/bootstrap/dist/css/bootstrap.min.css"
]
Bootstrap 文档还指导您添加 jQuery js/bootstrap.min.js 文件,并将它添加到 .angular-cli.json 中的 scripts 部分,如下所示:
"scripts": [
"..node_modules/jquery/dist/jquery.min.js",
"..node_modules/bootstrap/dist/js/bootstrap.min.js"
]
提示
当您运行 ng serve 或 ng build 命令时,前面的脚本将被放置在 scripts.bundle.js 文件中。
2.7.2. 为 ngAuction 生成组件
你的 ngAuction 应用将包含几个 Angular 组件。在上一个部分,你使用根组件 app 生成了项目。现在,你将使用命令 ng generate component(或 ng g c)生成更多组件。在终端窗口中运行以下清单中的命令以生成 第一章 中的 图 1.10 所示的组件。
清单 2.16. 为 ngAuction 生成组件
ng g c home
ng g c carousel
ng g c footer
ng g c navbar
ng g c product-item
ng g c product-detail
ng g c search
ng g c stars
清单 2.12 中的每个组件都将生成在单独的文件夹中。打开 app.module.ts,你会看到 Angular CLI 也已经添加了导入语句并声明了所有这些组件。
现在,你将生成将在下一章为 ngAuction 提供数据的产物服务。运行以下命令以生成产物服务:
ng g s shared/product
将 ProductService 添加到 @NgModule() 的 providers 属性中:
@NgModule({
...
providers: [ProductService],
...
})
export class AppModule { }
提示
在编写或生成代码时,你可能注意到一些代码片段被标记为红色的波浪线。将鼠标悬停在这些行上以获取更多信息。这可能不是 TypeScript 编译器错误,而是 TSLint 对你的编码风格的投诉。运行命令 ng lint --fix,这些样式错误可以自动修复。
2.7.3. 应用组件
应用组件是 ngAuction 的根组件,并将托管所有其他组件。你的 app.component.html 将包括以下元素:顶部是导航栏,左侧是搜索,右侧是路由出口,底部是页脚。在第三章 中,你将使用 <router-outlet> 标签来渲染 HomeComponent 或 ProductDetailComponent,但在 ngAuction 的初始版本中,你将只在那里渲染 HomeComponent。用以下清单替换 app.component.html 的内容。
清单 2.17. AppComponent 模板
<nga-navbar></nga-navbar> *1*
<div class="container">
<div class="row">
<div class="col-md-3"> *2*
<nga-search></nga-search> *3*
</div>
<div class="col-md-9"> *4*
<router-outlet></router-outlet> *5*
</div>
</div>
</div>
<nga-footer></nga-footer> *6*
-
1 导航栏组件位于顶部。
-
2 Bootstrap 的 flex 网格的三列分配给了搜索组件。
-
3 渲染搜索组件
-
4 九列分配给了路由出口区域。
-
5 路由出口区域
-
6 页脚组件在底部渲染。
你可能在 HTML 元素中看到一些不熟悉的 CSS 类——它们都来自 Bootstrap 框架。例如,样式 col-md-3 和 col-md-9 来自 Bootstrap 可伸缩网格布局系统,其中视口的宽度被分成 12 个不可见的列。你可以在 getbootstrap.com/docs/4.0/layout/grid 上阅读有关 Bootstrap 网格系统的信息。
默认情况下,Bootstrap 网格支持五个级别,用于不同用户设备宽度:xs、sm、md、lg和xl。例如,md代表中等设备(992 像素或更多),lg是 1200 像素或更多,依此类推。在你的应用组件中,你希望在中等或更大的视口中为搜索组件(<nga-search>)分配三列,并为<router-outlet>分配九列。
由于你没有指定当设备小于md时为<nga-search>分配多少列,浏览器将整个视口的宽度分配给你的搜索组件,<router-outlet>将在搜索下方渲染。你的应用组件的 UI 元素布局将根据用户屏幕的宽度不同而有所不同。
使用ng serve -o启动应用,浏览器将显示一个与图 2.5 所示页面相似的页面。
图 2.5. 运行 ngAuction 的第一个版本

这还不像是一个在线拍卖应用的着陆页,但至少在添加 Bootstrap 框架、生成组件和服务后,应用没有崩溃。保持此应用运行,并按照即将到来的说明继续添加代码,你将看到它如何逐渐变成一个更可用的网页。
2.7.4. 导航栏组件
典型的导航栏位于页面顶部,提供应用菜单。Bootstrap 框架为导航栏组件提供了多种样式,具体描述见getbootstrap.com/docs/4.0/components/navbar。将 navbar.component.html 的内容替换为以下列表。
列表 2.18. 导航栏组件模板
<nav class="navbar navbar-expand-lg navbar-light bg-light"> *1*
<a class="navbar-brand" [routerLink]="['/']">ngAuction</a> *2*
<button class="navbar-toggler" type="button"
data-toggle="collapse"
data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent"
aria-expanded="false" aria-label="Toggle navigation>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse"
id="navbarSupportedContent"> *3*
<ul class="navbar-nav mr-auto">
<li class="nav-item active">
<a class="nav-link" href="#">Home <span class="sr-
only">(current)</span></a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">About</a>
</li>
<li class="nav-item dropdown"> *4*
<a class="nav-link dropdown-toggle" href="#"
id="navbarDropdown" role="button"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Services
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="#">Find products</a> *5*
<a class="dropdown-item" href="#">Place order</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#">Pay</a>
</div>
</li>
</ul>
</div>
</nav>
-
1 Bootstrap 的导航组件和 CSS 选择器
-
2 Bootstrap 的 navbar-brand 表示你的拍卖品牌,点击时将路由到默认页面。
-
3 Bootstrap 的折叠组件在小屏幕上以三个水平条的形式呈现。
-
4 服务下拉菜单
-
5 下拉菜单项
保存此文件后,Angular CLI 将自动重新构建包,你的页面将类似于图 2.6 所示。
图 2.6. 渲染导航栏

如果你没有看到图 2.6 中显示的页面,而是浏览器只渲染了一个空页面,那会怎样?每次当你看到不期望看到的内容时,打开 Chrome 开发者工具,查看控制台标签页中是否有任何错误信息。
将浏览器窗口宽度缩小,菜单将折叠,在右侧显示三个水平条,如图 2.7 所示。
图 2.7. 折叠的导航栏菜单

2.7.5. 搜索组件
最终,你将实现SearchComponent以根据产品标题、价格或类别执行产品搜索。但在 ngAuction 的初始版本中,你只想渲染SearchComponent的视图。将 search.component.html 的内容替换为以下列表。
列表 2.19. 搜索组件模板
<form name="searchForm">
<div class="form-group"> *1*
<label for="productTitle">Product title:</label>
<input type="text" id="productTitle"
placeholder="Title" class="form-control"> *1*
</div>
<div class="form-group">
<label for="productPrice">Product price:</label>
<input id="productPrice"
name="productPrice" type="number" min="0"
placeholder="Price" class="form-control"> *1*
</div>
<div class="form-group">
<label for="productCategory">Product category:</label>
<select id="productCategory" class="form-control"></select> *1*
</div>
<div class="form-group">
<button type="submit"
class="btn btn-primary btn-block">Search</button> *1*
</div>
</form>
- 1 类选择器中的所有值都来自 Bootstrap 框架。
渲染的应用程序如图 2.8 所示图 2.8。
图 2.8. 渲染搜索组件

现在,让我们处理页脚组件。
2.7.6. 页脚组件
你的页脚将仅显示版权信息。以下列表修改了 footer.component.html 的内容。
列表 2.20. 页脚组件模板
<div class="container">
<hr>
<footer>
<div class="row"> *1*
<div class="col-lg-12"> *1*
<p>Copyright © ngAuction 2018</p>
</div>
</div>
</footer>
</div>
- 1 类选择器中的所有值都来自 Bootstrap 框架。
你的 ngAuction 应用程序渲染的首页如图 2.9 所示图 2.9。
图 2.9. 渲染页脚

2.7.7. 轮播组件
在 ngAuction 着陆页的顶部,你想要实现特色产品的幻灯片展示。为此,你将使用 Bootstrap 附带的自定义轮播组件(见getbootstrap.com/docs/4.0/components/carousel)。为了手动旋转幻灯片,轮播组件包括上一个/下一个控制(侧边的箭头)和当前幻灯片的指示器(底部的短横线)。
为了简化,你将使用灰色矩形而不是实际图像。方便的placeholder.com服务返回指定大小的灰色矩形,在轮播图中,你将使用三个 800 x 300 px 的灰色矩形。
修改 carousel.component.html 的代码,使其看起来像以下列表。
列表 2.21. 轮播组件模板
<div id="myCarousel" class="carousel slide" data-ride="carousel">
<ol class="carousel-indicators">
<li data-target="#myCarousel" data-slide-to="0" class="active"></li>
<li data-target="#myCarousel" data-slide-to="1"></li>
<li data-target="#myCarousel" data-slide-to="2"></li>
</ol>
<div class="carousel-inner">
<div class="carousel-item active">
<img class="d-block w-100" src="http://placehold.it/800x300"> *1*
</div>
<div class="carousel-item">
<img class="d-block w-100" src="http://placehold.it/800x300"> *2*
</div>
<div class="carousel-item">
<img class="d-block w-100" src="http://placehold.it/800x300"> *3*
</div>
</div>
<a class="carousel-control-prev" href="#myCarousel"
role="button" data-slide="prev"> *4*
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
<span class="sr-only">Previous</span>
</a>
<a class="carousel-control-next" href="#myCarousel"
role="button" data-slide="next"> *5*
<span class="carousel-control-next-icon" aria-hidden="true"></span>
<span class="sr-only">Next</span>
</a>
</div>
-
1 第一张幻灯片是一个 800 x 300 px 的灰色矩形。
-
2 第二张幻灯片
-
3 第三张幻灯片
-
4 点击左箭头显示上一张图片。
-
5 点击右箭头显示下一张图片。
现在,你需要为轮播图添加一些样式。因为它是一个自定义组件,你将在其 CSS 文件中添加display: block。你还想在轮播图的底部添加一些空间,这样其他组件就不会重叠。为了将这些样式应用于组件本身而不是其内部,你将使用代表轮播图的伪类选择器:host。为了确保幻灯片图像占据轮播图宿主<div>的整个宽度,将以下列表的样式添加到 carousel.component.css 文件中。
列表 2.22. carousel.component.css
:host { *1*
display: block; *2*
margin-bottom: 10px; *3*
}
img {
width: 100%; *4*
}
-
1 将样式应用于轮播组件,而不是其内部
-
2 将组件显示为占用整个宽度的块元素
-
3 在轮播图下方添加一些空间
-
4 图片应占据轮播图的整个宽度。
覆盖 Bootstrap 样式
大多数 Bootstrap 框架样式都位于 node_modules/bootstrap/dist/css/bootstrap.css 文件中。如果您想覆盖一些默认样式,请查看 Bootstrap 如何定义它们,并决定您想更改什么。然后,在您的组件中定义与 Bootstrap 文件选择器匹配的 CSS 样式。
例如,轮播指示器以破折号的形式渲染,Bootstrap CSS 选择器 .carousel-indicators li 看起来如下:
.carousel-indicators li {
position: relative;
-webkit-box-flex: 0;
-ms-flex: 0 1 auto;
flex: 0 1 auto;
width: 30px;
height: 3px;
margin-right: 3px;
margin-left: 3px;
text-indent: -999px;
background-color: rgba(255, 255, 255, 0.5);
}
如果您想将指示器从破折号改为圆形,请向 carousel.component.css 添加以下样式:
.carousel-indicators li {
width: 10px;
height: 10px;
border-radius: 100%;
}
如果在轮播组件中添加代码后,ngAuction 的渲染没有变化,仍然看起来像 图 2.9,请不要感到惊讶。这是因为您还没有将 <nga-carousel> 标签添加到任何父组件中。您将向主页组件添加 <nga-carousel>,这是您接下来要创建的。
2.7.8. 主页组件
您的应用组件模板包括 <router-outlet> 区域,在 md-size 视口上,该区域将位于 <nga-search> 的右侧。在 第三章 中,您将修改 ngAuction 以在 <router-outlet> 中渲染主页或产品详情组件,但到目前为止,您将渲染主页组件。您的主页组件将托管并渲染顶部的轮播组件,以及其下的几个产品。
修改生成的 home.component.html 的内容,使其看起来如下列表。
列表 2.23. 主页组件模板
<div class="row carousel-holder">
<div class="col-md-12">
<nga-carousel></nga-carousel> *1*
</div>
</div>
<div class="row">
We'll render several ProductItem components here *2*
</div>
-
1 轮播组件位于主页组件的顶部。
-
2 在 第三章 中,您将用产品项组件替换此文本。
第一个 <div> 托管轮播组件,第二个 <div> 显示文本,表明您计划在那里渲染多个产品项。尽管如此,您正在运行的 ngAuction 的 UI 仍未改变,您可能已经猜到这是由于您没有在应用组件中包含 <nga-home> 标签。您不会这样做。您将使用 Angular Router 在 <router-outlet> 区域内渲染 HomeComponent。
第三章 和 第四章 详细介绍了路由器——现在,您只需在生成的 app/app-routing.module.ts 文件中进行一个小改动,该文件包含以下列表中的行,用于路由配置。
列表 2.24. 配置路由
const routes: Routes = [];
将前面列表中的代码替换为以下列表中的代码。
列表 2.25. 将空路径映射到主页组件
const routes: Routes = [
{
path: '', component: HomeComponent
}
];
这意味着如果基础 URL 后的路径为空(ngAuction 的 URL 在端口号后没有内容),则渲染 HomeComponent。您还需要在 app-routing.module.ts 文件中添加对 HomeComponent 的导入语句。
现在 ngAuction 的渲染如图 图 2.10 所示,轮播组件将运行灰色矩形的幻灯片展示。
图 2.10. 使用主页组件渲染 ngAuction

ngAuction 的初始版本已经准备好了。你可以开始缩小浏览器窗口的宽度,看看这个 UI 在小设备上是如何渲染的。一旦窗口宽度小于 992 像素(Bootstrap md尺寸的值),浏览器将更改页面布局,将整个窗口宽度分配给搜索组件,而主页组件将在搜索组件下方渲染。这就是响应式网页设计的实际应用。
摘要
-
一个 Angular 应用程序由组件的层次结构表示,这些组件被打包成模块。
-
每个 Angular 组件都包含一个用于 UI 渲染的模板和一个实现组件功能的类。
-
模板和样式可以是内联的,也可以存储在单独的文件中。
-
即使项目已经生成,Angular CLI 也是一个有用的工具。
-
数据绑定是一种机制,用于保持组件的 UI 和底层类中的值同步。
-
你可以在 Angular 项目中使用第三方 JavaScript 库。
第三章. 路由基础
本章涵盖
-
配置父路由和子路由
-
在导航从一个路由到另一个路由时传递数据
-
配置和使用子路由
在单页应用(SPA)中,网页不会重新加载,但其部分可能会改变。您可能希望向此应用程序添加导航,以便根据用户操作更改页面内容区域(称为路由出口)。Angular 路由器允许您配置和实现此类导航,而无需执行完整的页面重新加载。
通常,您可以将路由器视为一个负责应用程序视图状态的对象。每个应用程序都有一个路由器对象,您需要配置应用程序的路由。
在本章中,我们将讨论 Angular 路由的主要功能,包括在父组件和子组件中配置路由、向路由传递数据以及向 HTML 锚点元素添加路由支持。
ngAuction 应用现在有一个主页视图;您将添加第二个视图,以便如果用户点击主页上的产品标题,页面内容将更改以显示所选产品的详细信息。在任何给定时间,用户将在<router-outlet>区域看到HomeComponent或ProductDetailComponent之一。
3.1. 路由基础
你可以将单页应用(SPA)视为一组状态,例如主页、产品详情和运输。每个状态代表同一 SPA 的不同视图。
图 3.1 展示了 ngAuction 应用的着陆页,其中顶部有一个导航栏(一个组件),左侧有一个搜索表单(另一个组件),底部有一个页脚(另一个组件),并且您希望这些组件始终可见。
图 3.1. ngAuction 应用主页上的组件

除了始终可见的部分之外,还有一个内容区域(见图 3.2),最初将显示<nga-home>组件及其子组件,但也可以根据用户操作显示其他视图。要显示其他视图,您需要配置路由,使其能够在出口处显示不同的视图,用一种视图替换另一种视图。您将为要在该区域显示的每个视图分配一个组件。这个内容区域由<router-outlet>标签表示。
小贴士
页面上可以有多个出口。我们将在第四章的 4.2 节中介绍这一点。
图 3.2. 分配更改视图的区域

路由器负责管理客户端导航,在本章的后面部分我们将提供一个路由器的高级概述。在非 SPA 世界中,网站导航是通过向服务器发出一系列请求来实现的,通过向浏览器发送适当的 HTML 文档来刷新整个页面。在 SPA 中,渲染组件的代码已经位于客户端(除了在第四章第 4.3 节中涵盖的懒加载场景之外),您需要用另一个视图替换一个视图。
当用户在应用程序中导航时,应用程序仍然可以向服务器发出请求以检索或发送数据。有时一个视图(UI 代码和数据组合)已经将所需的所有内容下载到浏览器中。其他时候,视图将通过发出 AJAX 请求或通过 WebSockets 与服务器进行通信。每个视图都会有一个在浏览器地址栏中显示的唯一 URL。我们将在下一节讨论这一点。
3.2. 位置策略
在任何给定时间,浏览器的地址栏显示当前视图的 URL。一个 URL 可以包含不同的部分,或称为段。它以协议开始,后跟域名,并且可能包括端口号。需要传递给服务器的参数可能跟在问号之后(这对于 HTTP GET请求是正确的),如下所示:mysite.com:8080/auction?someParam=1234。
在非 SPA 中,更改前面 URL 中的任何字符会导致向服务器发出新的请求。在 SPA 中,您需要能够修改 URL 而无需强制浏览器进行服务器端请求,以便应用程序可以在客户端定位适当的视图。Angular 提供了两种位置策略来实现客户端导航:
-
HashLocationStrategy— 在 URL 中添加一个哈希符号(#),哈希符号之后的 URL 段唯一标识了用作网页片段的视图。这种策略与所有浏览器兼容,包括旧浏览器。 -
PathLocationStrategy— 这种基于HistoryAPI 的策略仅在支持 HTML5 的浏览器中工作。这是 Angular 中的默认位置策略。
3.2.1. 基于哈希的导航
使用基于哈希的导航的示例 URL 显示在图 3.3 中。将哈希符号右侧的任何字符进行更改不会直接导致服务器端请求,而是在哈希之后导航到由路径(带或不带参数)表示的视图。哈希符号充当基础 URL 和所需内容的客户端位置之间的分隔符。
图 3.3. 解构 URL

尝试导航一个像 Gmail 这样的 SPA 并观察 URL。对于收件箱,它看起来像这样:mail.google.com/mail/u/0/#inbox。现在转到已发送文件夹,URL 的哈希部分将从 inbox 更改为 sent。客户端 JavaScript 代码调用必要的函数以显示已发送视图。但为什么当切换到已发送框时,Gmail 应用仍然显示“正在加载...”的消息?已发送视图的 JavaScript 代码仍然可以向服务器发出 AJAX 请求以获取新数据,但它不需要从服务器加载任何额外的代码、标记或 CSS。
要使用基于哈希的导航,@NgModule() 必须包含 providers 值(我们将在第五章的 5.2 节中讨论提供者),如下所示。
列表 3.1. 使用哈希位置策略
import {HashLocationStrategy, LocationStrategy} from "@angular/common";
...
@NgModule({
...
providers:[{provide: LocationStrategy, useClass: HashLocationStrategy}] *1*
})
- 1 提供者需要,以便 Angular 注入支持哈希位置策略的服务。
3.2.2. 基于 History API 的导航
浏览器的 History API 允许你通过用户导航历史记录来回移动,以及通过编程方式操作历史堆栈(请参阅 Mozilla 开发者网络中的“操作浏览器历史记录”,mng.bz/i64G)。特别是,pushState() 方法用于在用户导航你的单页应用(SPA)时附加一个段到基本 URL。
考虑以下 URL:mysite.com:8080/products/page/3(注意没有哈希符号)。URL 段 products/page/3 可以通过程序将(附加)到基本 URL,而不使用哈希标签。如果用户从第 3 页导航到第 4 页,应用程序的代码将推送 URL 段 products/page/4,并将之前访问过的 products/page/3 保存到浏览器历史记录中。
Angular 免去了你显式调用 pushState() 的麻烦——你只需配置 URL 段并映射到相应的组件。基于 History API 的位置策略,你需要告诉 Angular 在你的应用程序中用作基本 URL 的内容,以便它能够正确地附加客户端 URL 段。如果你想在非根路径上提供 Angular 应用,你必须执行以下操作:
-
将
<base>标签添加到 index.html 的头部,例如<base href="/mypath">,或在运行ng build时使用--base-href选项。Angular CLI 生成的项目在 index.html 中包含<base href="/">。 -
在根模块中为
APP_BASE_HREF常量分配一个值,并将其用作providers值。以下列表使用/作为基本 URL,但它可以是表示基本 URL 结尾的任何 URL 段。
列表 3.2. 为基于 History 的 API 添加支持
import { APP_BASE_HREF } from '@angular/common';
...
@NgModule({
...
providers:[{provide: APP_BASE_HREF, useValue: '/mypath'}] *1*
})
class AppModule { }
- 1 提供者需要,以便路由正确解析 URL。
APP_BASE_HREF影响路由器如何解析应用程序内routerLink属性和router.navigate()调用,而<base href=". . .">标签影响浏览器在加载静态资源(如<link>、<script>和<img>标签)时解析 URL 的方式。
3.3. 客户端导航的构建块
让我们熟悉使用 Angular 路由实现客户端导航的主要概念。路由是通过RouterModule配置的。如果您的应用程序需要路由,请确保您的package.json文件包含依赖项@angular/router。Angular 包含许多支持导航的类——例如,Router、Route、Routes、ActivatedRoute等。您将路由配置在类型为Route的对象数组中,如下一列表所示。此数组中的每个元素都是类型为Route的对象。
列表 3.3. 路由配置示例
const routes: Routes = [
{path: '', component: HomeComponent}, *1*
{path: 'product', component: ProductDetailComponent} *2*
];
-
1 默认渲染 HomeComponent
-
2 如果 URL 包含产品片段,则渲染 ProductDetailComponent
由于路由配置是在模块级别完成的,因此您需要让应用程序模块了解@NgModule()装饰器中的路由。如果您为根模块声明路由,请使用forRoot()方法,例如,如下所示。
列表 3.4. 让根模块了解路由
import { BrowserModule } from '@angular/platform-browser';
import { RouterModule } from '@angular/router';
...
@NgModule({
imports: [BrowserModule,
RouterModule.forRoot(routes)], *1*
...
})
- 1 为应用程序根模块创建一个路由模块和一个服务
如果您使用带有--routing选项的 Angular CLI 命令ng new生成了应用程序(正如您在第二章的手动部分所做的那样),您将获得一个单独的文件app-routing.module.ts,您可以在其中配置路由,如下一列表所示。
列表 3.5. 具有路由支持的独立模块
const routes: Routes = [ *1*
{ path: '', component: HomeComponent},
{ path: 'product', component: ProductDetailComponent}
];
@NgModule({
imports: [RouterModule.forRoot(routes)], *2*
exports: [RouterModule] *3*
})
export class AppRoutingModule {}
-
1 配置路由
-
2 为应用程序根模块创建一个路由模块和一个服务
-
3 使此模块可从其他模块访问
如果您正在为功能模块配置路由(而不是根模块),请使用forChild()方法,该方法也会创建一个路由模块,但不会创建路由服务(forRoot()应该已经创建了服务),如下一列表所示。
列表 3.6. 为功能模块创建路由模块
@NgModule({
imports: [CommonModule,
RouterModule.forChild(routes)] *1*
...
})
export class MyFeatureModule {}
- 1 创建路由模块但不创建路由服务
让我们从简单的应用程序开始,该应用程序说明了路由。假设您想创建一个根组件,该组件在页面顶部有两个链接,分别是“首页”和“产品详情”。应用程序应根据用户点击的链接渲染HomeComponent或ProductDetailComponent。HomeComponent将渲染文本“首页组件”,而ProductDetailComponent将渲染“产品详情组件”。最初,网页应显示HomeComponent,如图 3.4 所示。
图 3.4. 带有红色背景的首页组件

用户点击产品详情链接后,路由器应显示ProductDetailComponent,如图 3.5 所示。
图 3.5. 以青色背景渲染的产品详情组件

你可以在图 3.4 和 3.5 中看到这些路由的 URL 看起来如何。这个基本应用的主要目标是熟悉路由器,因此组件将非常简单,如下面的列表所示。
列表 3.7. HomeComponent
@Component({
selector: 'home',
template: '<h1 class="home">Home Component</h1>',
styles: ['.home {background: red}']}) *1*
export class HomeComponent {}
- 1 以红色背景渲染此组件
ProductDetailComponent的代码看起来很相似,如以下列表所示,但它使用的是青色背景而不是红色。
列表 3.8. ProductDetailComponent
@Component({
selector: 'product',
template: '<h1 class="product">Product Detail Component</h1>',
styles: ['.product {background: cyan}']}) *1*
export class ProductDetailComponent {}
- 1 以青色背景渲染此组件
Routes类型只是Route接口中定义的类型对象的集合,如下一个列表所示。
列表 3.9. Angular 的Route接口
export interface Route {
path?: string;
pathMatch?: string;
matcher?: UrlMatcher;
component?: Type<any>;
redirectTo?: string;
outlet?: string;
canActivate?: any[];
canActivateChild?: any[];
canDeactivate?: any[];
canLoad?: any[];
data?: Data;
resolve?: ResolveData;
children?: Routes;
loadChildren?: LoadChildren;
runGuardsAndResolvers?: RunGuardsAndResolvers;
}
你可以向forRoot()或forChild()函数传递一个配置对象,该对象只填充了几个属性。在基本应用中,你使用在Route接口中定义的两个属性:path和component。我们将在名为 app.routing.ts 的文件中这样做,如下面的列表所示。
列表 3.10. app.routing.ts
const routes: Routes = [
{path: '', component: HomeComponent}, *1*
{path: 'product', component: ProductDetailComponent} *2*
];
export const routing = RouterModule.forRoot(routes); *3*
-
1 HomeComponent 映射到一个包含空字符串的路径,这隐式地使其成为默认路由。
-
2 如果 URL 在基本 URL 后有产品段,则在路由出口中渲染 ProductDetail-Component
-
3 调用 invokes forRoot()并导出路由配置,以便根模块可以导入
下一步是创建一个根组件,它将包含在主页和产品详情视图之间导航的链接。以下列表显示了位于 app.component.ts 文件中的根AppComponent。
列表 3.11. app.component.ts
@Component({
selector: 'app-root',
template: `
<a [routerLink]="['/']">Home</a> *1*
<a [routerLink]="['/product']">Product Details</a> *2*
<router-outlet></router-outlet> *3*
`
})
export class AppComponent {}
-
1 创建一个绑定 routerLink 到空路径的链接
-
2 创建一个绑定 routerLink 到路径/product 的链接
-
3
<router-outlet>指定了页面中路由将渲染组件的区域(一次一个)。
routerLink周围的方括号表示属性绑定,而右侧的括号表示一个包含一个元素的数组(例如,['/'])。第二个锚标签将routerLink属性绑定到为/product 路径配置的组件。匹配的组件将在标记为<router-outlet>的区域渲染,在这个应用中位于锚标签下方。
没有组件知道路由配置,因为它属于模块的业务,如下面的列表所示。
列表 3.12. app.module.ts
...
import {routing} from './app.routing'; *1*
@NgModule({
imports: [ BrowserModule,
routing ], *2*
declarations: [ AppComponent,
HomeComponent,
ProductDetailComponent],
providers:[{provide: LocationStrategy, useClass: HashLocationStrategy}],*3*
bootstrap: [ AppComponent ]
})
class AppModule {}
-
1 导入路由配置
-
2 将路由配置添加到@NgModule()
-
3 让依赖注入机制知道你想要 HashLocationStrategy
模块的providers属性是一个注册提供者的数组(本例中只有一个),用于依赖注入,这将在第五章中介绍。在此阶段,您只需知道,尽管默认的位置策略是PathLocationStrategy,但您希望 Angular 使用HashLocationStrategy类进行路由(注意图 3.5 中 URL 中的哈希符号)。
在本章附带的项目 router-samples 中,我们在.angular-cli.json 文件中配置了多个应用程序。本节中描述的应用程序名称为basic,您可以通过在终端窗口中输入以下命令来运行它:
ng serve --app basic -o
注意
在 Angular 6 中,.angular-cli.json 文件已重命名为 angular.json。另外,如果您决定运行此应用的 Angular 6 版本(它包含书中的代码示例),则不需要--app选项:ng serve basic -o。
提示
不要忘记在项目 router-samples 中运行npm install。
在基本的路由代码示例中,我们使用 HTML 锚标签中的routerLink来安排导航。但如果您需要在不要求用户点击链接的情况下以编程方式安排导航呢?
3.4. 使用navigate()导航到路由
让我们修改基本的代码示例,使用navigate()方法进行导航。您将添加一个按钮,该按钮也将导航到ProductDetailComponent,但这次不使用 HTML 锚点。
以下列表重用了上一节中的路由配置,但在AppComponent的构造函数中注入的Router实例上调用了navigate()方法。
列表 3.13. 使用navigate()
@Component({
selector: 'app',
template: `
<a [routerLink]="['/']">Home</a>
<a [routerLink]="['/product']">Product Details</a>
<button (click)="navigateToProductDetail()>Product Details *1*
</button>
<router-outlet></router-outlet>
`
})
class AppComponent {
constructor(private _router: Router){} *2*
navigateToProductDetail(){
this._router.navigate(["/product"]); *3*
}
}
-
1 点击此按钮将调用 navigateToProductDetail()方法。
-
2 Angular 会将 Router 实例注入到 router 变量中。
-
3 以编程方式导航到配置的产品路由
在列表 3.13 中,用户需要点击按钮才能转到产品路由。但导航可以不要求用户操作实现——只需在需要时从您的应用程序代码中调用navigate()方法。例如,您可以强制应用程序导航到登录路由,如果用户未登录。
默认情况下,当用户使用路由器导航时,浏览器的地址栏会发生变化。如果您不想显示当前路由的 URL,请使用skipLocationChange指令:
<a [routerLink]="['/product']" skipLocationChange>Product Details</a>
在这种情况下,即使用户导航到product路由,URL 仍然保持为 http://localhost:4200/#/。为了以编程方式达到相同的效果,请使用以下语法:
this._router.navigate(["/product"], {skipLocationChange: true});
处理 404 错误
如果用户在您的应用程序中输入一个不存在的 URL,路由器将无法找到匹配的路由,并在浏览器控制台打印错误消息,让用户困惑为什么没有发生导航。考虑创建一个应用程序组件,该组件将在应用程序无法找到匹配组件时显示。
例如,您可以创建一个名为 _404Component 的组件,并使用通配符路径**进行配置:
[
{path: '', component: HomeComponent},
{path: 'product', component: ProductDetailComponent},
{path: '**', component: _404Component}
])
通配符路由配置必须是路由数组中的最后一个元素。路由器始终将通配符路由视为匹配,因此通配符路由之后的任何路由都不会被考虑。
3.5. 传递数据到路由
基本路由应用程序展示了如何在路由出口区域显示不同的组件,但您通常还需要向组件传递一些数据。例如,如果应用程序组件显示产品列表,并且您想导航到产品详情路由,您需要将产品 ID 传递到代表目标路由的组件。在这种情况下,您需要在路由配置中的path属性中添加一个参数。在以下列表中,您更改了product路由的配置,以指示当 URL 段包含'product'之后的值时(冒号表示路径的变量部分 - :id),必须渲染ProductDetailComponent。
列表 3.14. Routes配置
const routes: Routes = [
{path: '', component: HomeComponent},
{path: 'product/:id', component: ProductDetailComponent} *1*
];
- 1 如果 URL 包含 product 片段后跟一个值,则渲染 ProductDetailComponent 并将其值传递给它
因此,您的应用程序组件需要将产品 ID 的值包含在routerLink中,以确保如果用户选择此路由,产品 ID 的值将被传递到ProductDetailComponent。应用程序的新版本可能看起来如下所示。
列表 3.15. app.component.ts
@Component({
selector: 'app-root',
template: `
<a [routerLink]="['/']">Home</a>
<a [routerLink]="['/product', productId]">Product Detail</a> *1*
<router-outlet></router-outlet>
`
})
class AppComponent {
productId = 1234; *2*
}
-
1 如果用户点击此链接,导航到产品路由,传递 productId 的值
-
2 设置 productId 属性的值
第二个routerLink绑定到包含路径的静态部分/product 和代表产品 ID 的值/:id的两个元素数组。数组元素构建了传递给RouterModule.forRoot()方法的路由配置中指定的路径。对于产品详情路由,Angular 将构造 URL 段/product/1234。
要查看此应用程序的实际运行情况,请在您的终端窗口中运行以下命令:
ng serve --app params -o
3.5.1. 从 ActivatedRoute 中提取参数
如果父组件可以将参数传递给路由,则表示目标路由的组件应该能够接收它。指示 Angular 将 ActivatedRoute 实例注入到表示目标路由的组件的构造函数中。ActivatedRoute 实例将包括传递的参数以及路由的 URL 段和其他属性。新版本的组件,它能够渲染产品详情并接收参数,将被称为 ProductDetailComponent,它将获得一个 ActivatedRoute 对象的注入,如下所示。
列表 3.16. ProductDetailComponentParam
@Component({
selector: 'product',
template: `<h1 class="product">Product Detail for Product: {{productID}}</h1>`,*1*
styles: ['.product {background: cyan}']
})
export class ProductDetailComponent {
productID: string;
constructor(route: ActivatedRoute) { *2*
this.productID = route.snapshot.paramMap.get('id'); *3*
}
}
-
1 显示接收到的产品 ID
-
2 将此组件的构造函数中注入的 ActivatedRoute 对象。
-
3 获取名为 id 的参数的值并将其分配给 productID 类变量,该变量通过绑定在模板中使用
图 3.6 展示了产品详情视图在浏览器中的渲染方式。注意 URL:路由将产品/:id 路径替换为 /product/1234。
图 3.6. 产品详情路由接收了产品 ID 1234。

将参数传递给路由
在此应用中,您使用 ActivatedRouteSnapshot 类型的 snapshot 属性来检索参数的值。snapshot 表示“一次性操作”,此属性用于参数传递给路由时永远不会改变的场景。在您的应用中,它之所以有效,是因为父路由中的产品 ID 永远不会改变,始终是 1234。但是,如果您尝试手动更改 图 3.6 中显示的 URL(例如,将其更改为 /product/12345),则 ProductDetailComponent 不会反映参数的变化。
在某些情况下,参数值在导航到路由后仍然会不断变化。例如,AppComponent 渲染产品列表,用户可以选择不同的产品。AppComponent 和 ProductDetailComponent 都在同一窗口中渲染。在这种情况下,您需要订阅 ActivatedRoute.paramMap 属性,而不是使用 snapshot 属性,因为每次用户点击不同的产品时,它都会发出新的值,例如:
route.paramMap.subscribe(
params => this.productID = params.get('id')
);
您将在 第六章 的 6.6 节 中看到此示例。
让我们回顾一下 Angular 在渲染应用程序主页面时在幕后执行的步骤:
1. 检查每个
routerLink的内容。2. 连接数组中指定的值。如果数组项是一个表达式,则评估此表达式(如
productId)。最后,将APP_BASE_HREF的值追加到结果字符串的开头。3.
RouterLink指令如果附加到<a>元素上,则会添加href属性;否则,它只监听click事件。
图 3.7 显示了应用程序主页的快照,其中 Chrome 开发者工具面板已打开。因为配置的主路由的path属性为空字符串,所以 Angular 没有向页面基本 URL 添加任何内容。但是,产品详情链接下的锚点已经转换为常规 HTML 标签。当用户点击产品详情链接时,路由器将附加一个哈希符号并将/product/1234 添加到基本 URL,从而使产品详情视图的绝对 URL 成为 http://localhost:4200/#/product/1234。
图 3.7. 浏览器中的 Angular 编译代码

注意
在本节中,您学习了如何将动态数据传递给路由——这些数据可能在运行时发生变化。有时,您需要将静态数据传递给路由(不改变的数据)。您可以通过在路由配置中使用data属性将任何任意数据传递给路由。您将在第四章的 4.3.1 节中看到一个这样的示例。
有时,您需要传递给路由可选查询参数,这些参数不是路由配置的一部分。让我们看看如何传递查询参数。
3.5.2. 将查询参数传递给路由
您可以使用查询参数(问号后的 URL 段),如下面的 URL 所示:http://localhost:4200/products?category=sports。查询参数不限于特定的路由,如果您想在导航时通过routerLink传递它们,您可以这样做:
<a [routerLink]="['/products']" [queryParams]="{category:'sports'}">?
Sports products </a>
因为查询参数不限于特定的路由,并且可以被任何活动路由访问,所以路由配置不需要包括它们:
{path: 'products', component: ProductDetailComponent}
要使用程序化导航传递查询参数,您需要访问Router对象。代码可能看起来像以下列表。
列表 3.17. 注入和访问Router对象
constructor (private router: Router) {} *1*
showSportingProducts() {
this.router.navigate(['/products'], *2*
{queryParams: {category: 'sports'}});
}
-
1 注入 Router 对象
-
2 在
Router对象上调用 navigate()
在此示例中,您传递了一个包含一个查询参数的对象;但您也可以指定多个参数。
要在目标组件中接收查询参数,您将再次使用ActivatedRoute对象。
列表 3.18. 接收查询参数
@Component({
selector: 'product',
template: `<h1 class="product">Showing products in {{productCategory}}</h1>
`,
styles: ['.product {background: cyan}']
})
export class ProductDetailComponent {
productCategory: string;
constructor(route: ActivatedRoute) {
this.productCategory = route.snapshot.queryParamMap.get('category'); *1*
}
}
- 1 提取名为 category 的查询参数
要查看此代码示例的实际效果,请运行以下命令:
ng serve --app queryparams -o
3.6. 子路由
一个 Angular 应用程序是由具有父子关系的组件组成的树。子组件可以有自己的路由,但所有路由都在任何组件之外配置。想象一下,您想启用ProductDetailComponent(AppComponent的子组件)显示产品描述或卖家信息。此外,同一产品可能有多个卖家,因此您需要传递卖家 ID 以显示卖家详情。以下列表通过使用Route的children属性为子路由ProductDetailComponent配置路由。
列表 3.19. 配置子路由
const routes: Routes = [
{path: '', component: HomeComponent},
{path: 'product/:id', component: ProductDetailComponent,
children: [ *1*
{path: '', component: ProductDescriptionComponent}, *2*
{path: 'seller/:id', component: SellerInfoComponent} *3*
]}
];
-
1 此属性配置 ProductDetailComponent 的路由。
-
2 ProductDescriptionComponent 默认存在。
-
3 从 ProductDetailComponent,用户可以导航到 SellerInfoComponent。
在这里,children 属性是路径为 product/:id 的路由配置的一部分。在导航到 product 路由时,您传递产品 ID,然后,如果用户决定导航到 seller,您将卖家 ID 传递给 SellerInfoComponent。
图 3.8 展示了用户在根组件上点击产品详情链接后应用程序的外观,这将渲染 ProductDetailComponent(子组件),默认显示 ProductDescriptionComponent,因为后者组件被配置为空 path 属性。
图 3.8. 产品描述路由

图 3.9 展示了用户点击产品详情链接然后点击卖家信息后的应用程序。
注意
如果您正在阅读这本书的电子版,您会看到卖家信息显示在黄色背景上。我们故意这样做是为了稍后在本章中讨论组件的样式。
图 3.9. 子路由渲染 SellerInfo

为了实现 图 3.8 和 图 3.9 中显示的视图,您需要修改 ProductDetailComponent,使其也具有两个子组件 SellerInfoComponent 和 ProductDescriptionComponent,以及自己的 <router-outlet>。 图 3.10 展示了您将要实现的组件层次结构。
图 3.10. 路由层次结构

以下三个列表显示了 ProductDetailComponent、ProductDescriptionComponent 和 SellerInfoComponent 的代码。ProductDetailComponent 的新版本有自己的出口,可以在其中显示 ProductDescriptionComponent(默认)或 SellerInfoComponent。
列表 3.20. product.detail.component.ts
@Component({
selector: 'product',
styles: ['.product {background: cyan}'],
template: `
<div class="product">
<h1>Product Detail for Product: {{productId}}</h1>
<router-outlet></router-outlet> *1*
<p><a [routerLink]="['./seller', sellerId]">Seller Info</a></p> *2*
</div>
`
})
export class ProductDetailComponent {
productId: string;
sellerId = 5678;
constructor(route: ActivatedRoute) {
this.productId = route.snapshot.paramMap.get('id');
}
}
-
1 ProductDetailComponent 为其子组件逐个渲染分配了自己的路由出口区域。
-
2 当用户点击此链接时,Angular 将 /seller/5678 段添加到现有 URL 并渲染 SellerInfoComponent。
当用户点击带有子组件的产品链接时,URL 中会添加 product/1234 段。路由器在配置对象中找到与此路径匹配的内容,并在出口处渲染 ProductDetailComponent。
用户导航到 ProductDetailComponent,默认情况下根据路由配置渲染 ProductDescriptionComponent。然后,用户点击卖家信息链接,URL 将在哈希符号后包含 product/1234/seller/5678 段,如 图 3.11 所示。
图 3.11. 卖家组件的 URL

路由将在配置对象中找到一个匹配项,并在子组件的 <router-outlet> 中渲染 SellerInfoComponent。ProductDescriptionComponent 的代码很简单,如下所示。
列表 3.21. product.description.component.ts
@Component({
selector: 'product-description',
template: '<p>This is a great product!</p>'
})
export class ProductDescriptionComponent {}
因为 SellerInfoComponent 期望接收卖家 ID,所以它的构造函数需要一个类型为 ActivatedRoute 的参数来获取卖家 ID,如下所示,就像你在 ProductDetailComponent 中做的那样。
列表 3.22. seller.info.component.ts
@Component({
selector: 'seller',
template: 'The seller is Mary Lou, id {{sellerID}} ',
styles: [':host {background: yellow}'] *1*
})
export class SellerInfoComponent {
sellerID: string;
constructor(route: ActivatedRoute){ *2*
this.sellerID = route.snapshot.paramMap.get('id'); *3*
}
}
-
1 使用伪类 :host 在黄色背景上显示此组件的内容。
-
2 注入 Activated-Route 对象
-
3 获取传递的 id 的值并将其分配给 sellerID 以进行渲染
可以使用 :host 伪类选择器与使用阴影 DOM(在第八章第 8.5.1 节中讨论)创建的元素一起使用,这为组件提供了更好的封装。尽管并非所有网络浏览器都支持阴影 DOM,但 Angular 默认模拟阴影 DOM。在这里,你使用 :host 将黄色背景颜色应用到 SellerInfoComponent。在模拟模式下,:host 选择器被转换为一个基于属性的随机生成的选择器,如下所示:
[ng-host-f23ed] {
background: yellow;
}
属性(在此处,ng-host-f23ed)附加到表示组件的元素。组件的阴影 DOM 样式不会与全局 DOM 的样式合并,组件的 HTML 标签的 ID 也不会与 DOM 的 ID 冲突。
要运行此代码示例,请在路由-samples 项目的终端窗口中输入以下命令:
ng serve --app child -o
深度链接
深度链接 是创建指向网页内特定内容而不是整个页面的链接的能力。在基本的路由应用程序中,你已经看到了深度链接的例子:
-
URL http://localhost:4200/#/product/1234 不仅链接到产品详情页面,还链接到一个表示具有 ID
1234的特定产品视图。 -
URL http://localhost:4200/#/product/1234/seller/5678 深入链接。它显示了具有 ID
5678的卖家信息,该卖家销售具有 ID1234的产品。
你可以通过从在 Chrome 中运行的应用程序复制链接 http://localhost:4200/#/product/1234/seller/5678 并将其粘贴到 Firefox 或 Safari 中来轻松看到深度链接的实际应用。但是有一个注意事项。使用 PathLocationStrategy 时,当你直接在浏览器地址栏中输入路由的直接 URL,它仍然会向服务器发出请求,服务器找不到名为你的路由的资源(这是正确的)。这将导致 404 错误。当请求的资源未找到时,配置你的 web 服务器将重定向到你的应用程序的 index.html。这将使你的 Angular 应用程序重新获得控制权,并且路由将被正确解析。Angular CLI 开发服务器已经配置了重定向。
| |
路由事件
当用户导航您的应用程序时,Angular 会分发事件,例如NavigationStart、NavigationEnd等。大约有一打路由事件,如果需要,您可以拦截任何一个。在第六章的 6.6 节中,您将看到一个使用路由事件来决定何时显示和隐藏进度条的示例,如果导航速度慢的话。为了调试目的,您可以使用enableTracing选项在浏览器控制台中记录路由事件(它仅在根模块中工作):
RouterModule.forRoot(
routes,
{enableTracing: true}
)
现在您已经学习了路由的基本功能,让我们看看如何在 ngAuction 应用程序中应用它们。
3.7. 动手:为在线拍卖添加导航
备注
本章的源代码可以在github.com/Farata/angulartypescript和www.manning.com/books/angular-development-with-typescript-second-edition找到。
这个动手练习从我们在第二章中停止的地方开始。到目前为止,您已经部分实现了 ngAuction 的着陆页;您的目标是让几个产品项在轮播组件下渲染,使着陆页看起来如图 3.12 所示。
图 3.12. 带有产品的 ngAuction 着陆页

此视图的数据将由ProductService提供。这个动手练习包含了将ProductService注入到HomeComponent中的说明。您还将实现导航,以便当用户点击产品标题时,Router将在<router-outlet>区域渲染ProductDetail组件。
您的ProductService将包含关于产品的硬编码数据。将ProductService作为HomeComponent构造函数的参数将指示 Angular 实例化和注入产品对象到该组件。
作为起点,您将使用位于 chapter3/ngAuction 文件夹中的项目,该项目大部分与 chapter2/ngAuction 相同,只是增加了一个:shared/product.service.ts 文件包含提供产品数据的代码。
要开始这个练习,请在您的 IDE 中打开 chapter3/ngAuction 文件夹,并通过运行npm install命令安装项目依赖。
3.7.1. ProductService
ProductService包含关于产品和获取它们的 API 的硬编码数据。让我们回顾以下列表中所示的 product.service.ts 中的代码(为了简洁,我们删除了大部分硬编码数据)。
列表 3.23. product.service.ts
export class Product { *1*
constructor(
public id: number,
public title: string,
public price: number,
public rating: number,
public description: string,
public categories: string[]) {
}
}
export class ProductService { *2*
getProducts(): Product[] { *3*
return products.map(p => new Product(p.id, p.title,
p.price, p.rating, p.description, p.categories));
}
getProductById(productId: number): Product { *4*
return products.find(p => p.id === productId);
}
}
const products = [ *5*
{
'id': 0,
'title': 'First Product',
'price': 24.99,
'rating': 4.3,
'description': 'This is a short description.
Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
'categories': ['electronics', 'hardware']
},
{
'id': 1,
'title': 'Second Product',
'price': 64.99,
'rating': 3.5,
'description': 'This is a short description.
Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
'categories': ['books']
}];
}
-
1 产品实例将通过 ProductService 的方法返回。
-
2 ProductService 类提供了一个获取产品的 API。
-
3 此方法返回所有硬编码的产品。
-
4 此方法根据 productId 返回一个产品。
-
5 包含硬编码产品的数组
简而言之,你将添加代码,让 Angular 创建 ProductService 类的实例,并将其注入到 ProductItemComponent 和 ProductDetailComponent 中,以便它们可以在服务上调用 getProducts() 和 getProductById() 方法。
3.7.2. ProductItemComponent
图 3.13 展示了六个产品,每个都是一个 ProductItemComponent 的实例。
图 3.13. 六个 ProductItemComponent 实例

ProductItemComponent 知道如何根据其父组件(HomeComponent)提供的产品渲染一个产品。将 product-item.component.ts 修改如下所示。
列表 3.24. product-item.component.ts
import {Component, Input} from '@angular/core';
import {Product} from '../shared/product.service';
@Component({
selector: 'nga-product-item',
templateUrl: './product-item.component.html',
styleUrls: ['./product-item.component.css']
})
export class ProductItemComponent {
@Input() product: Product; *1*
}
- 1 此输入属性将从父组件接收产品。
要渲染的产品将通过 ProductItemComponent 的属性 product(带有 @Input() 装饰器)传递给它。@Input() 属性在 第 8.2.1 节中描述,位于 第八章。
使用以下列表中的内容修改 product-item.component.html。
列表 3.25. product-item.component.html
<div class="thumbnail">
<img src="http://placehold.it/320x150">
<div class="caption">
<h4 class="float-right">{{product.price | currency}}</h4> *1*
<h4><a [routerLink]="['/products', product.id]">{{product.title}}</a> *2*
</h4>
<p>{{product.description}}</p>
</div>
<!-- <div class="ratings"> *3*
<nga-stars [rating]="product.rating"></nga-stars>
</div> -->
</div>
-
1 应用货币管道进行格式化
-
2 产品标题变成了导航到产品详情的链接。
-
3 评分组件已被注释并将在以后添加。
注意,你将产品的 price、title 和 id 属性绑定到组件的模板中。你还使用了一个 Angular 内置管道,currency,来格式化价格。目前,你将 <nga-stars> 组件注释掉,因为 StarsComponent 的代码尚未准备好。注意,product.title 是一个 routerLink,当用户点击它时将导航到 ProductDetailComponent。ProductItemComponent 的实例将由 HomeComponent 托管,你将在下一部分更新它。
3.7.3. HomeComponent
home 组件将
-
使用注入的
ProductService检索所有特色产品并将它们存储在products数组中。 -
为
products数组中每个找到的产品渲染ProductItemComponent。
在 第 2.7.8 节中,位于 第二章,你实现了 HomeComponent 的第一个版本,并将其添加到模板中。现在,你需要修改构造函数以注入 ProductService 并在 ngOnInit() 方法中检索产品。将 home.component.ts 中的代码修改如下所示。
列表 3.26. home.component.ts
import {Component, OnInit} from '@angular/core';
import {Product, ProductService} from '../shared/product.service';
@Component({
selector: 'nga-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.css']
})
export class HomeComponent implements OnInit {
products: Product[]=[];
constructor(private productService: ProductService) { } *1*
ngOnInit() { *2*
this.products = this.productService.getProducts(); *3*
}
}
-
1 注入 ProductService
-
2 实现在构造函数之后调用的生命周期方法 ngOnInit()
-
3 使用 ProductService 检索产品
当 Angular 实例化 HomeComponent 时,它会注入 ProductService 的实例。因为使用了 private 标识符,生成的 JavaScript 将会有一个实例变量,productService。
Angular 在构造函数之后调用组件生命周期方法 ngOnInit(),你将在那里调用 getProducts() 方法。在第九章的 9.2 节中,我们将讨论组件生命周期方法,你将看到为什么 ngOnInit() 是获取数据的正确位置。
修改 home.component.html 文件,使用结构指令 *ngFor 遍历数组 products 并渲染每个产品。
列表 3.27. home.component.html
<div class="row">
<div class="col-md-12">
<nga-carousel></nga-carousel> *1*
</div>
</div>
<div class="row">
<div *ngFor="let product of products" *2*
class="col-sm-4 col-lg-4 col-md-4"> *3*
<nga-product-item [product]="product"></nga-product-item> *4*
</div>
</div>
-
1 轮播组件位于顶部。
-
2 遍历 products 数组
-
3 为每个组件分配 Bootstrap 弹性网格的四列
-
4 为每个产品渲染
组件
每个产品在网页上由相同的 HTML 片段表示。因为有多件产品,所以需要多次渲染相同的 HTML。组件模板内部使用 NgFor 指令来遍历数据集合中的项目列表,为每个项目渲染 HTML 标记。在组件模板中,*ngFor 代表 NgFor 指令。
因为 *ngFor 指令位于 <div> 内部,每次循环迭代都会渲染一个包含相应 <nga-product-item> 内容的 <div>。要将产品实例传递给 ProductItemComponent,你使用方括号进行属性绑定:
<nga-product-item [product]="product">
左侧的 [product] 指的是 <nga-product-item> 组件内部的名为 product 的属性,而右侧的 product 是在 *ngFor 指令中即时声明的本地模板变量,作为 let product。
Bootstrap 的网格样式 class="col-sm-4 col-lg-4 col-md-4" 指示浏览器将 <div> 的宽度平均分成 4 列(12 列中的 4 列),分配给小、大和中等设备上的每个产品,如图 3.14 所示。尝试移除此类,看看它如何影响 UI。
图 3.14. 使用 Bootstrap 网格分割 <div> 宽度

运行 ng serve -o 命令,你将看到在轮播图中渲染了六个产品,如图 3.14 所示,但产品评分中不会有任何星星。我们将在下一部分处理星星。
3.7.4. 星级组件
StarsComponent 将渲染星星以显示产品评分,如图 3.15 所示。
图 3.15. 星级组件

在 ngAuction 的着陆页上,StarsComponent 将是 ProductItemComponent 的子组件。最终,我们还会在 ProductDetailComponent 中重用它。
修改生成的 stars.component.ts 文件的代码,使其看起来如下。
列表 3.28. stars.component.ts
import {Component, Input, OnInit} from '@angular/core';
@Component({
templateUrl: 'stars.component.html',
styleUrls: ['./stars.component.css'],
selector: 'nga-stars'
})
export class StarsComponent implements OnInit {
@Input() count = 5; *1*
@Input() rating = 0; *2*
stars: boolean[] = []; *3*
ngOnInit() { *4*
for (let i = 1; i <= this.count; i++) {
this.stars.push(i > this.rating); // push true or false
}
}
}
-
1 使用 @Input 装饰属性 count(在第八章的 8.1.2 节中介绍)以便父组件可以使用属性绑定来分配其值
-
2 使用 @Input 装饰属性 rating 的原因相同
-
3 此数组的每个元素对应一个单独的星形。
-
4 根据父组件提供的评分初始化星形数组为布尔值
count属性指定要渲染的星形总数。如果父组件没有提供此输入属性的值,则默认渲染五个星形。如果需要,StarsComponent可以渲染更多或更少的星形。以下列表显示了如何渲染七个星形。
列表 3.29. 渲染七个星形
<nga-stars [rating]="product.rating" *1*
[count]="7"></nga-stars> *2*
-
1 将 7 绑定到 count 输入属性
-
2 将产品评分绑定到
组件的 rating 输入属性
stars数组中具有false值的元素表示没有颜色的星形,而具有true值的元素表示填充颜色的星形。rating属性存储平均产品评分,该评分确定应该填充颜色的星形数量以及应该保持空白的星形数量。
Bootstrap 4 框架不包含渲染星形的图像。有多个流行的图标字体库(Material Design Icons、Font Awesome、Octicons 等);我们将使用 Material Design Icons。为了在项目中本地化它们,按照以下方式安装这些图标:
npm i material-design-icons
然后将这些字体添加到.angular-cli.json文件的styles部分,如下所示列表。
列表 3.30. .angular-cli.json 的样式部分
"styles": [
"styles.css",
"../node_modules/bootstrap/dist/css/bootstrap.min.css",
"../node_modules/material-icons/css/material-design-icons.min.css" *1*
]
- 1 添加 Material Design 图标
修改 stars.component.html 的内容,使其看起来如下所示列表。
列表 3.31. stars.component.html
<p>
<span *ngFor="let isEmpty of stars" *1*
class="material-icons"> *2*
{{ isEmpty ? 'star_border' : *3*
'star_rate' }} *4*
</span>
<span>{{ rating }} stars</span>
</p>
-
1 遍历布尔数组 stars 并应用填充或空白的星形
-
2 使用 material-icons 样式
-
3 填充颜色的 Material Design 星形称为 star_rate。
-
4 一个空的星形样式称为 star_border。
注意你如何绑定一个或另一个 CSS 类(使用双大括号)。为了样式化星形图标,将以下样式添加到 stars.component.css 文件中。
列表 3.32. stars.component.css
:host {
display: block;
}
.material-icons { *1*
display: inline-block;
font-size: inherit;
height: 16px;
width: 16px;
}
- 1 样式化星形图标
ProductItemComponent将是StarsComponent的父组件。为了使其成为ProductItemComponent的子组件,取消注释之前创建的 product-item.component.html 文件中的<div>。
列表 3.33. 添加带有组件的
<div class="ratings"> *1*
<nga-stars [rating]="product.rating"></nga-stars> *2*
</div>
-
1 将星形设置为深红色(请参阅下一列表中的 CSS)
-
2 将产品评分绑定到
组件的输入属性
CSS 选择器ratings将在 product-item.component.css 文件中定义。您将使用以下列表中的样式在 product-item.component.css 文件中将星形设置为深红色并添加一些填充。
列表 3.34. product-item.component.css
.ratings {
color: darkred; *1*
}
img {
width: 100%; *2*
}
-
1 设置颜色为深红色
-
2 确保图像不会相互重叠
您将此样式添加到 StarsComponent 的父组件中,以便在子组件中需要时选择不同的星级颜色。如果另一个组件需要渲染星级,您可以在那里选择另一种颜色。现在您的 ProductItemComponent 为每个子产品渲染评分,如图 3.14 所示。
是时候使用 Router 实现导航了。
3.7.5. ProductDetailComponent
在 第二章 中,您生成了路由模块,但它只有一个配置的路由,如您在以下列表中看到的,它渲染 HomeComponent。
列表 3.35. app-routing.module.ts
import {NgModule} from '@angular/core';
import {Routes, RouterModule} from '@angular/router';
import {HomeComponent} from "./home/home.component";
const routes: Routes = [ *1*
{
path: '', component: HomeComponent
}
];
@NgModule({
imports: [RouterModule.forRoot(routes)], *2*
exports: [RouterModule] *3*
})
export class AppRoutingModule { }
-
1 配置默认路由
-
2 为应用程序根模块创建一个路由模块和服务
-
3 重新导出 RouterModule,以便其他模块可以访问它
您想添加另一个路由,以便当用户在 ProductItemComponent 中点击产品标题时,Router 将 HomeComponent 替换为 ProductDetailComponent。在此导航过程中,您想将所选产品的 ID 传递给 ProductDetailComponent。修改路由配置,使其看起来如下所示,并且不要忘记导入 ProductDetailComponent。
列表 3.36. 配置第二个路由
const routes: Routes = [
{ path: '', component: HomeComponent},
{ path: 'products/:productId', component: ProductDetailComponent} *1*
];
- 1 配置 URL 片段的路由,如 products/123
ProductDetailComponent 将通过注入的 ActivatedRoute 从父组件接收所选产品 ID,然后向 ProductService 发送请求以检索产品详情。由于 ProductDetailComponent 将重用 Angular 在应用程序启动时为您创建的 ProductService 实例,因此将此服务添加到构造函数的参数中。修改 product-detail.component.ts 中的代码,使其看起来如下所示。
列表 3.37. product-detail.component.ts
import { Component, OnInit } from '@angular/core';
import {Product, ProductService} from '../shared/product.service';
import {ActivatedRoute} from '@angular/router';
@Component({
selector: 'auction-product-detail',
templateUrl: './product-detail.component.html',
styleUrls: ['./product-detail.component.css']
})
export class ProductDetailComponent implements OnInit {
product: Product;
constructor(private route: ActivatedRoute,
private productService: ProductService) { } *1*
ngOnInit() {
const prodId: number = parseInt(
this.route.snapshot.params['productId']); *2*
this.product = this.productService.getProductById(prodId); *3*
}
}
-
1 同时将 ActivatedRoute 和 ProductService 注入到构造函数中。
-
2 从 ActivatedRoute 中提取 productId 参数
-
3 在服务上调用 getProductById(),并提供 prodId 作为参数
实例变量 product 的属性值将被绑定到组件模板,并由浏览器渲染。ProductDetailComponent 的模板将包含产品图片(一个灰色矩形)、产品价格、标题和描述。
修改 product-detail.component.html 的内容,使其看起来如下所示。
列表 3.38. product-detail.component.html
<div class="thumbnail">
<img src="http://placehold.it/820x320">
<div>
<h4 class="float-right">{{ product.price }}</h4> *1*
<h4>{{ product.title }}</h4> *2*
<p>{{ product.description }}</p> *3*
</div>
<div class="ratings"> *4*
<p><nga-stars [rating]="product.rating"></nga-stars></p> *5*
</div>
</div>
-
1 在右侧渲染产品价格
-
2 渲染产品标题
-
3 渲染产品描述
-
4 使用类 ratings 以深红色渲染星级
-
5 使用绑定到组件属性 rating 的产品评分渲染 StarsComponent
ProductDetailComponent 也使用了 <nga-stars>。为了改变一下,让我们在 product-detail.component.css 中添加以下列表的样式。
列表 3.39. product-detail.component.css
.ratings {
color: darkgreen; *1*
padding-left: 10px;
padding-right: 10px;
}
- 1 将星级设置为深绿色
使用 ng serve -o 运行应用——导航到产品详情视图将正常工作。点击第一个产品的标题,Router 将创建一个 ProductDetailComponent 的实例。浏览器将显示产品详情,如图 3.16 所示。
图 3.16. 渲染产品详情

使用 ng serve -o 运行此应用以查看 ngAuction 的着陆页。请注意,在产品详情视图中,星星显示为深绿色,而在着陆页上,它们是深红色。
摘要
-
你可以在父组件和子组件中配置路由。
-
你可以在导航过程中向路由传递数据。
-
在导航过程中,路由器在由
<router-outlet>标签定义的区域渲染组件。
第四章. 路由高级
本章涵盖
-
守卫路由
-
创建具有多个路由出口的组件
-
懒加载模块
本章涵盖了一些高级路由功能。您将学习如何使用路由守卫,这些守卫允许您限制对某些路由的访问,警告用户关于未保存的更改,并在允许用户导航到路由之前确保检索重要数据。
然后,我们将向您展示如何创建具有多个路由出口的组件。最后,您将了解如何懒加载模块——意味着只有当用户决定导航到某些路由时才会加载。
本章不包括 ngAuction 的动手实践部分。如果您急于从处理路由切换到学习其他 Angular 功能,您可以跳过本章,稍后再回来。
4.1. 守卫路由
Angular 提供了几个 守卫接口,这些接口为您提供了一种中介导航到和从路由的方法。假设您有一个只有经过身份验证的用户才能访问的路由。换句话说,您想要守卫(保护)该路由。图 4.1 展示了一个工作流程,说明了登录守卫如何保护只有经过身份验证的用户才能访问的路由。如果用户未登录,应用程序将渲染登录视图。
图 4.1. 带有守卫的示例登录工作流程

这里有一些其他场景,在这些场景中守卫可以提供帮助:
-
只有当用户经过身份验证并有权这样做时才打开路由。
-
显示由多个组件组成的多部分表单,并且只有当当前部分输入的数据有效时,用户才被允许导航到表单的下一部分。
-
如果用户尝试从路由导航离开,提醒用户关于未保存的更改。
-
只有在填充了某些数据结构之后才允许导航到路由。
这些是守卫接口:
-
CanActivate允许或禁止导航到路由。 -
CanActivateChild中介导航到子路由。 -
CanDeactivate允许或禁止从当前路由导航离开。 -
Resolve确保在导航到路由之前检索所需的数据。 -
CanLoad允许或禁止懒加载模块。
第 3.3 节 和 第三章 提到,Routes 类型是一个符合 Route 接口的项的数组。到目前为止,您已经使用了如 path 和 component 这样的属性来配置路由。现在,您将了解如何中介导航到或从路由,并确保在导航到路由之前检索某些数据。让我们从添加一个当用户想要导航到路由时将工作的守卫开始。
4.1.1. 实现 CanActivate 守卫
想象一个带有链接的组件,只有已登录的用户才能导航到。为了保护此路由,您需要创建一个新的类(例如,LoginGuard),该类实现CanActivate接口,该接口声明一个方法,canActivate()。在此方法中,您实现验证逻辑,该逻辑将返回true或false。如果守卫的canActivate()返回true,则用户可以导航到该路由。您需要将此守卫分配给canActivate属性,如下所示。
列表 4.1. 使用canActivate守卫配置路由
const routes: Routes = [
...
{path: 'product',
component: ProductDetailComponent,
canActivate: [LoginGuard]} *1*
];
- 1 LoginGuard 将调解对 ProductDetailComponent 的导航。
因为Route的canActivate属性接受一个数组作为值,所以如果您需要检查多个条件以允许或禁止导航,您可以分配多个守卫。
让我们创建一个简单的应用程序来展示您如何保护product路由,防止未登录的用户访问。为了使示例简单,您不会使用身份验证服务,而是会随机生成登录状态。以下类实现了CanActivate接口。canActivate()函数将包含返回true或false的代码。如果函数返回false(用户未登录),则应用程序不会导航到该路由,将显示警告,并将用户导航到登录视图。
列表 4.2. login.guard.ts
@Injectable()
export class LoginGuard implements CanActivate {
constructor(private router: Router) {} *1*
canActivate() {
// A call to the actual login service would go here
// For now we'll just randomly return true or false
let loggedIn = Math.random() < 0.5; *2*
if (!loggedIn) { *3*
alert("You're not logged in and will be redirected to Login page");
this.router.navigate(["/login"]); *4*
}
return loggedIn;
}
}
-
1 注入 Router 对象
-
2 随机生成登录状态
-
3 条件显示“未登录”消息
-
4 重定向到登录页面
此canActivate()函数的实现将随机返回true或false,模拟用户的登录状态。
下一步是将此守卫用于路由配置。以下列表显示了如何为具有主页和产品详情路由的应用程序配置路由。后者受LoginGuard保护。
列表 4.3. 使用守卫配置一个路由
export const routes: Routes = [
{path: '', component: HomeComponent},
{path: 'login', component: LoginComponent},
{path: 'product', component: ProductDetailComponent,
canActivate: [LoginGuard]} *1*
];
- 1 将守卫添加到产品路由
您的LoginComponent将非常简单——它将显示文本“请在此处登录”,如下所示。
列表 4.4. login.component.ts
@Component({
selector: 'home',
template: '<h1 class="home">Please login here</h1>',
styles: ['.home {background: greenyellow}']
})
export class LoginComponent {}
Angular 将使用其 DI 机制实例化LoginGuard类,但您必须在此类需要注入的提供者列表中提及此类。将LoginGuard名称添加到@NgModule()中的提供者列表。
列表 4.5. 将守卫添加到模块的提供者
@NgModule({
imports: [BrowserModule, RouterModule.forRoot(routes)],
declarations: [AppComponent, HomeComponent,
ProductDetailComponent, LoginComponent],
providers: [LoginGuard] *1*
bootstrap: [AppComponent]
})
- 1 将守卫类添加到提供者列表,以便 Angular 可以实例化和注入它
您的根组件模板将如下所示。
列表 4.6. AppComponent的模板
template: `
<a [routerLink]="['/']">Home</a>
<a [routerLink]="['/product']">Product Detail</a>
<a [routerLink]="['/login']">Login</a> *1*
<router-outlet></router-outlet>
`
- 1 登录页面
要查看此应用程序的实际运行情况,请运行以下命令:
ng serve --app guards -o
图 4.2 显示了用户点击产品详情链接后发生的情况,但LoginGuard决定用户未登录。
图 4.2. 点击产品详情链接是受保护的

点击“确定”将关闭带有警告的弹出窗口,并导航到 /login 路由。在 图 4.2 中,你实现了 canActivate() 方法,但没有向其提供任何参数。但此方法可以与可选参数一起使用:
canActivate(destination: ActivatedRouteSnapshot, state: RouterStateSnapshot)
ActivatedRouteSnapshot 和 RouterStateSnapshot 的值将由 Angular 自动注入,如果您想分析当前路由的状态,这可能非常有用。例如,如果您想了解用户尝试导航到的路由名称,您可以这样做:
canActivate(destination: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
console.log(destination.component.name);
...
}
CanActivate 守卫控制谁可以进入,但你如何控制用户是否应该被允许从路由中导航?你为什么需要这个?
4.1.2. 实现 CanDeactivate 守卫
CanDeactivate 接口调解从路由导航的过程。当您想警告用户视图中有一些未保存的更改时,此守卫非常有用。为了说明这一点,您将更新上一节的应用程序并添加一个输入字段到 ProductDetailComponent。如果用户在此字段中输入某些内容,然后尝试从该路由导航,您的 CanDeactivate 守卫将显示“您想要保存更改”的警告,如下所示。
列表 4.7. 带有输入字段的 ProductDetailComponent
@Component({
selector: 'product',
template: `<h1 class="product">Product Detail Component</h1>
<input placeholder="Enter your name" type="text"
[formControl]="name">`, *1*
styles: ['.product {background: cyan}']
})
export class ProductDetailComponent {
name: FormControl = new FormControl(); *2*
}
-
1 将变量 name 绑定到来自表单 API 的指令*
-
2 从表单 API 创建 FormControl 实例*
列表 4.7 使用表单 API,这在第十章(kindle_split_019.xhtml#ch10)和第十一章(kindle_split_020.xhtml#ch11)中有介绍。到目前为止,您只需要知道您创建 FormControl 类的实例并将其绑定到 <input> 元素。在您的守卫中,您将使用 FormControl.dirty 属性来了解用户是否在输入字段中输入了任何内容。以下列表创建了一个 UnsavedChangesGuard 类,该类实现了 CanDeactivate 接口。
列表 4.8. UnsavedChangesGuard 实现 CanDeactivate
@Injectable()
export class UnsavedChangesGuard
implements CanDeactivate<ProductDetailComponent> { *1*
canDeactivate(component: ProductDetailComponent) { *2*
if (component.name.dirty) { *3*
return window.confirm("You have unsaved changes. Still want to leave?"
);
} else {
return true;
}
}
}
-
1 为 ProductDetailComponent 实现 CanDeactivate 守卫*
-
2 实现 CanDeactivate 守卫所需的 canDeactivate() 方法*
-
3 检查输入控件的内容是否已更改*
CanDeactivate 接口使用参数化类型,你使用 TypeScript 泛型语法指定:<ProductDetailComponent>。canDeactivate() 方法可以与多个参数一起使用(见 angular.io/api/router/CanDeactivate),但你将只使用一个:要保护的组件。
如果用户在输入字段中输入了任何值—if (component.name.dirty)—你将显示一个带有警告的弹出窗口。你需要从上一节的应用程序中添加一些额外的功能。首先,将 CanDeactivate 守卫添加到路由配置中。
列表 4.9. 向路由添加 CanDeactivate 和 CanDeactivate 守卫
const routes: Routes = [
{path: '', component: HomeComponent},
{path: 'login', component: LoginComponent},
{path: 'product', component: ProductDetailComponent,
canActivate: [LoginGuard], *1*
canDeactivate: [UnsavedChangesGuard]} *2*
];
-
1 将 LoginGuard 添加到产品路由*
-
2 将 UnsavedChangesGuard 添加到产品路由
下一个列表中包括了模块中提供者列表中的新保护器。
列表 4.10. 指定保护器的提供者
@NgModule({
...
providers: [LoginGuard, *1*
UnsavedChangesGuard] *2*
})
-
1 添加 LoginGuard 提供者
-
2 添加 UnsavedChangesGuard 提供者
运行此应用程序 (ng serve --app guards -o),访问 /product 路由,并在输入字段中输入一些内容。然后,尝试点击应用程序中的另一个链接或浏览器的后退按钮。你将看到 图 4.3 中显示的消息。
图 4.3. 未保存更改保护器正在工作

现在你已经知道如何控制路由的导航。接下来要确保的是,当路由所需的数据尚未准备好时,用户不会过早地导航到该路由。
4.1.3. 实现解析保护器
假设你导航到一个产品详情组件,该组件会发起一个 HTTP 请求以检索数据。连接速度慢,需要两秒钟来检索数据。这意味着用户将看到空组件两秒钟,然后数据才会显示。这不是一个好的用户体验。如果服务器请求返回错误呢?用户将在看到错误消息后查看空组件。这就是为什么在所需数据到达之前甚至不渲染组件可能是一个好主意。
如果你想要确保在用户导航到某个路由之前,某些数据结构已经被填充,创建一个允许在路由激活之前获取数据的 Resolve 保护器。一个 resolver 是一个实现了 Resolve 接口的类。其 resolve() 方法中的代码加载所需的数据,并且只有在数据到达后,路由器才会导航到该路由。
让我们回顾一个将有两个链接的应用程序:主页和数据。当用户点击数据链接时,它必须渲染 DataComponent,这需要在用户看到这个视图之前加载大量数据。为了预加载数据(一个 48 MB 的 JSON 文件),你将创建一个实现 Resolve 接口的 DataResolver 类。路由配置如下所示。
列表 4.11. 带有解析器的路由
const routes: Routes = [
{path: '', component: HomeComponent},
{path: 'mydata', component: DataComponent,
resolve:{ *1*
loadedJsonData: DataResolver *2*
}
}
];
-
1 配置 mydata 路由的解析保护器
-
2 指定预加载数据的类
注意,HomeComponent 没有保护器。你只为渲染 DataComponent 的路由配置了 DataResolver。Angular 将在用户每次导航到 mydata 路由时调用其 resolve() 方法。因为你将解析对象属性的名称命名为 loadedJsonData,你将能够在 DataComponent 中使用 ActivatedRoute 对象访问预加载数据,如下所示:
activatedRoute.snapshot.data['loadedJsonData'];
下面的代码显示了您的解析器代码。在此代码中,您使用了尚未介绍的一些语法元素,例如 @Injectable()(在第五章中解释),HttpClient(在第十二章中介绍),以及 Observable(在第四章附录 D 和第六章中介绍),但我们仍然想回顾这个代码示例,因为它与路由器有关。
列表 4.12. data.resolver.ts
@Injectable() *1*
export class DataResolver implements Resolve<string[]>{
constructor ( private httpClient: HttpClient){} *2*
resolve(): Observable<string[]>{ *3*
return this.httpClient
.get<string[]>("./assets/48MB_DATA.json"); *4*
}
}
-
1 将此服务标记为可注入
-
2 注入 HttpClient 服务以读取数据
-
3 实现了 resolve() 方法
-
4 从文件中读取数据
您的解析器类是一个可注入的服务,它实现了 Resolve 接口,该接口要求实现一个 resolve() 方法,该方法可以返回一个 Observable、一个 Promise 或任何任意对象。
小贴士
因为解析器是一个服务,所以您需要在 @NgModule() 装饰器中声明其 provider(在第五章的 5.2 节 中介绍)。
在这里,您使用 HttpClient 服务读取包含 360,000 条随机数据记录的文件。HttpClient.get() 方法返回一个 Observable,您的 resolve() 方法也是如此。Angular 为解析器生成代码,该代码自动订阅到可观察对象并将发出的数据存储在 ActivatedRoute 对象中。
在 DataComponent 的构造函数中,您提取解析器加载的数据并将其存储在变量中。在这种情况下,您不显示或处理数据,因为您的目标是展示解析器在 DataComponent 渲染之前加载了数据。图 4.4 显示了构造函数中的断点处的调试器。请注意,数据已在 DataComponent 的构造函数中加载并可用。UI 将在您的构造函数中的代码完成后渲染。
图 4.4. 数据已加载。

此应用的源代码位于 resolver 目录中,您可以通过运行 ng serve --app resolver -o 来查看其运行情况。
每次您导航到 mydata 路由时,文件将被重新加载,并且用户将看到来自 Angular Material UI 组件库的进度条 (mat-progress-bar)。您将在第五章的 5.6 节 中介绍这个库。第五章。
进度条用于 AppComponent 的模板中,但 AppComponent 如何知道何时开始显示进度条以及何时从 UI 中移除它?路由器在导航期间触发事件,例如 NavigationStart、NavigationEnd 以及其他一些事件。您的 AppComponent 订阅到这些事件,当 NavigationStart 被触发时,进度条显示,在 NavigationEnd 时移除,如下面的列表所示。
列表 4.13. app.component.ts
@Component({
selector: 'app-root',
template: `
<a [routerLink]="['/']">Home</a>
<a [routerLink]="['mydata']">Data</a>
<router-outlet></router-outlet>
<div *ngIf="isNavigating"> *1*
Loading...
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
</div>
`
})
export class AppComponent {
isNavigating = false; *2*
constructor (private router: Router){ *3*
this.router.events.subscribe( *4*
(event) => {
if (event instanceof NavigationStart){
this.isNavigating=true; *5*
}
if (event instanceof NavigationEnd) {
this.isNavigating=false; *6*
}
}
);
}
}
-
1 根据 isNavigating 标志条件性地显示/隐藏进度条
-
2 初始时 isNavigating 标志为 false。
-
3 注入路由对象
-
4 订阅路由事件
-
5 如果触发 NavigationStart,则将标志设置为 true
-
6 如果触发 NavigationEnd,则将标志设置为 false
小贴士
为了避免反复读取如此大的文件,你可以在第一次读取后将其数据缓存到内存中。如果你对如何做到这一点感兴趣,请查看位于 data.resolver2.ts 文件中的另一个版本的解析器的代码。该解析器使用来自 data.service.ts 的可注入服务,因此在下一次点击时,不是读取文件,而是从内存缓存中检索数据。由于数据服务是单例的,它将存活于DataComponent的创建和销毁之间,缓存的数据仍然可用。
| |
重新加载活动路由
你可以使用runGuardsAndResolvers和onSameUrlNavigation选项配置并重新运行已激活的路由的守卫和解析器。
假设用户访问了mydata路由,并在一段时间后想要通过再次点击数据链接来重新加载同一路由中的数据。以下列表中的routes配置通过重新应用守卫和解析器来实现这一点:
const routes: Routes = [
{path: '', component: HomeComponent},
{path: 'mydata', component: DataComponent,
resolve: {
mydata: DataResolver
},
runGuardsAndResolvers: 'always' *1*
}
];
export const routing = RouterModule.forRoot(routes,
{onSameUrlNavigation: "reload"} *2*
-
1 总是运行守卫和解析器
-
2 当用户导航到同一路由时重新加载组件
你可以在产品文档中了解有关其他守卫的信息,请参阅angular.io/guide/router#milestone-5-route-guards。
现在,我们将继续介绍另一个主题:如何创建一个包含多个 <router-outlet> 的视图。
4.2. 开发具有多个路由出口的单页应用(SPA)
ngAuction 目录包含实现第三章(kindle_split_012.xhtml#ch03)动手实践部分功能的 ngAuction 代码。
到目前为止,在所有路由代码示例中,你使用的组件都有一个单一的标签<router-outlet>,Angular 根据配置的路由渲染视图。现在,你将看到如何配置和渲染位于同一组件中的同级路由中的视图。让我们考虑一些多出口视图的用例:
-
想象一个类似仪表板的 SPA,它有几个专用区域(出口),每个区域可以渲染多个组件(一次一个)。出口 A 可以显示你的股票组合,无论是以表格还是图表的形式,而出口 B 则显示最新的新闻或广告。
-
假设你想要向 SPA 添加一个聊天区域,以便用户在保持当前路由激活的同时与客户服务代表进行交流。你想要添加一个独立的聊天路由,允许用户同时使用这两个路由,并能够从一个路由切换到另一个路由。
在 Angular 中,你可以通过不仅有一个 主 出口,还有一个命名的 次级 出口来实现这两种场景,这些出口与主出口同时显示。
为了区分主出口和次级出口的组件渲染,你需要添加另一个 <router-outlet> 标签,但这个出口必须有一个名称。例如,以下代码片段定义了主出口和聊天出口:
<router-outlet></router-outlet> *1*
<router-outlet name="chat"></router-outlet> *2*
-
1 主出口
-
2 次级(命名)出口
图 4.5 展示了用户点击“主页”链接然后点击“打开聊天”链接后同时打开两个路由的应用程序。左侧显示了主出口中 HomeComponent 的渲染,右侧显示了在命名出口中渲染的 ChatComponent。点击“关闭聊天”链接将移除命名出口的内容(你向 HomeComponent 添加了一个 HTML <input> 字段,向 ChatComponent 添加了一个 <textarea>,这样在用户在主页和聊天路由之间切换时更容易看到哪个组件具有焦点)。
图 4.5. 使用次级路由渲染聊天视图

注意辅助路由 URL 中的括号,http://localhost:4200/#home(aux:chat)。与使用正斜杠从父路由中分离子路由不同,辅助路由表示为括号中的 URL 段。此 URL 告诉你 home 和 chat 是兄弟路由。
聊天路由的配置指定了 ChatComponent 必须渲染的出口名称,如下面的列表所示。
列表 4.14. 为两个出口配置路由
export const routes: Routes = [
{path: '', redirectTo: 'home', pathMatch: 'full'}, *1*
{path: 'home', component: HomeComponent}, *2*
{path: 'chat', component: ChatComponent, outlet: "aux"} *3*
];
-
1 将空路径重定向到主页路由
-
2 如果 URL 包含主页,则在主出口中渲染 HomeComponent
-
3 如果 URL 包含聊天,则在名为 aux 的出口中渲染 ChatComponent
在此配置中,我们想向您介绍 redirectTo 属性。HomeComponent 将在两种情况下渲染:要么默认在基本 URL 上,要么如果 URL 只包含 /home 段,如 http://localhost:4200/home。pathMatch: 'full' 表示客户端部分的 URL 必须正好是 /,所以如果你输入了 URL http://localhost:4200/product/home,它不会重定向到 home。
应用程序组件的模板可能看起来如下所示。
列表 4.15. 具有两个出口的组件模板
template: `
<a [routerLink]="['']">Home</a> *1*
<a [routerLink]="['', {outlets: { aux: 'chat'}}]">Open Chat</a> *2*
<a [routerLink]="[{outlets: { aux: null }}]">Close Chat</a> *3*
<br/>
<router-outlet></router-outlet> *4*
<router-outlet name="aux"></router-outlet> *5*
`
-
1 一个链接用于导航到主出口的默认路由
-
2 一个链接用于导航到名为 aux 的出口中的聊天路由
-
3 一个链接用于从 UI 中移除名为 aux 的出口
-
4 此区域分配给主出口。
-
5 此区域分配给名为 aux 的次级出口。
注意这里有两个出口:一个主出口(未命名)和一个次级出口(命名)。当用户点击“打开聊天”链接时,你指示 Angular 在名为 aux 的出口中渲染配置为 chat 的组件。要关闭次级出口,请将 null 而不是路由名称分配给它。
如果你想以编程方式导航到(或关闭)命名出口,请使用 Router.navigate() 方法:
navigate([{outlets: {aux: 'chat'}}]);
要查看此应用在两个路由出口处的实际效果,请在路由-samples 项目中运行以下命令:
ng serve --app outlets -o
路由器还可以帮助你解决另一个问题。为了使应用更具响应性,你想要最小化浏览器加载以显示应用着陆页的代码量。你真的需要在应用启动时加载每个路由的所有代码吗?
4.2.1. 懒加载模块
以前,我们的一位作者正在为一个欧洲汽车制造商的网站工作。有一个名为“欧洲交付”的菜单项,供美国公民使用,他们可以飞往欧洲的汽车工厂,在那里取走他们的新车,然后花两周时间驾驶自己的车,享受欧洲的一切。之后,汽车将被运往美国。这样的旅行可能需要花费数千美元,正如你可以想象的那样,不会有很多网站访客对探索这个选项感兴趣。那么,为什么要把支持“欧洲交付”菜单的代码包含在这个网站的着陆页中,从而增加初始页面大小?
一个更好的解决方案是创建一个单独的欧洲交付模块,只有当用户点击菜单项时才会下载,对吧?一般来说,Web 应用的着陆页应该只包含用户访问网站时必须存在的最小核心功能。
任何中型或大型应用都应该分成几个模块,其中每个模块实现某些功能(计费、运输等),并且按需懒加载。在第二章中,第 2.5.1 节,你看到了一个分成两个模块的应用,但两个模块都在应用启动时加载。在本节中,我们将向你展示如何懒加载一个模块。
让我们创建一个包含三个链接的应用:主页、产品详情和豪华商品。想象一下,豪华商品需要与普通产品不同地处理,你希望将此功能分离到一个名为 LuxuryModule 的功能模块中,该模块将有一个名为 LuxuryComponent 的组件。大多数应用用户收入有限,很少会点击豪华商品链接,因此没有必要在应用启动时加载豪华模块的代码。你将懒加载它——只有当用户点击豪华商品链接时才会加载。这种方式对于在较差的连接区域使用的移动应用尤为重要——根模块的代码必须只包含核心功能。LuxuryModule 的代码如下所示。
列表 4.16. luxury.module.ts
@NgModule({
imports: [CommonModule, *1*
RouterModule.forChild([ *2*
{path: '', component: LuxuryComponent} *3*
])],
declarations: [LuxuryComponent]
})
export class LuxuryModule {}
-
1 导入 CommonModule 作为功能模块所需的模块
-
2 使用 forChild() 方法配置此功能模块的默认路由
-
3 默认情况下,渲染其唯一组件,LuxuryComponent
在下一个列表中,LuxuryComponent 的代码仅显示在黄色(暗示金色)背景上的文本“高级组件”。
列表 4.17. luxury.component.ts
@Component({
selector: 'luxury',
template: `<h1 class="gold">Luxury Component</h1>`, *1*
styles: ['.gold {background: yellow}'] *2*
})
export class LuxuryComponent {}
-
1 应用 CSS 选择器 gold
-
2 声明 CSS 选择器 gold
以下列出的是根模块的代码。
列表 4.18. app.module.ts
@NgModule({
imports: [BrowserModule,
RouterModule.forRoot([ *1*
{path: '', component: HomeComponent},
{path: 'product', component: ProductDetailComponent},
{path: 'luxury', loadChildren: './luxury.module#LuxuryModule'} *2*
])
],
declarations: [AppComponent, HomeComponent, ProductDetailComponent],
providers:[{provide: LocationStrategy, useClass: HashLocationStrategy}],
bootstrap: [AppComponent]
})
export class AppModule {}
-
1 为根模块配置路由
-
2 使用 loadChildren 组件进行懒加载,而不是使用 component 属性
注意,imports 部分只包括 BrowserModule 和 RouterModule。功能模块 LuxuryModule 没有列在这里。此外,根模块在其 declarations 部分没有提到 LuxuryComponent,因为该组件不是根模块的一部分。当路由器从根和功能模块解析路由配置时,它将正确地将 luxury 路径映射到在 LuxuryModule 中声明的 LuxuryComponent。
你不是将 path 映射到组件,而是使用 loadChildren 属性,提供要加载的模块的路径和名称。请注意,loadChildren 的值不是一个类型化的模块名称,而是一个字符串。根模块不了解 LuxuryModule 类型;但是当用户点击“高级项目”链接时,加载模块将解析这个字符串,并从前面显示的 luxury .module.ts 文件中加载 LuxuryModule。
为了确保支持 LuxuryModule 的代码在应用启动时不会被加载,Angular CLI 将其代码放置在单独的包中。在你的项目 router-samples 中,这个应用在 .angular-cli.json 中配置为名为 lazy。你可以通过运行以下命令来构建包:
ng serve --app lazy -o
终端窗口将打印有关包的信息,如下所示。
列表 4.19. 由 ng serve 构建的包
chunk {inline} inline.bundle.js (inline)
chunk {luxury.module} luxury.module.chunk.js () *1*
chunk {main} main.bundle.js (main) 33.3 kB
chunk {polyfills} polyfills.bundle.js (polyfills)
chunk {styles} styles.bundle.js (styles)
chunk {vendor} vendor.bundle.js (vendor)
- 1 为懒加载的包构建了单独的包。
第二行显示,你的高级模块被放置在一个名为 luxury.module.chunk.js 的单独包中。
注意
当你运行 ng build --prod 时,懒加载模块的包名称是数字,而不是名称。在代码示例中,高级模块的默认包名称将是零后跟一个生成的哈希码,例如 0.0797fe80dbf6edcb363f.chunk.js。如果你的应用有两个懒加载模块,它们将被放置在以 0 和 1 分别开始的包中。
如果你打开浏览器到 localhost:4200 并检查开发者工具中的网络标签,你将不会在那里看到这个模块。参见 图 4.6。
图 4.6. 高级模块未加载

点击“高级项目”链接,你会看到浏览器进行了额外的请求并下载了 LuxuryModule 的代码,如 图 4.7 底部所示。高级模块已被加载,并且 LuxuryComponent 已在路由出口中渲染。
图 4.7. 点击按钮后加载了高级模块

这个简单的示例并没有显著减少初始下载的大小。但使用懒加载技术构建大型应用程序可以减少可下载代码的初始大小数百千字节或更多,从而提高应用程序的感知性能。感知性能 是用户对应用程序性能的看法,提高它很重要,尤其是在从移动设备上通过慢速网络加载应用程序时。
在我们过去的一个项目中,经理表示新开发的网页应用的首页必须加载得非常快。我们问:“有多快?”他给我们发了一个链接到某个应用:“和这个一样快。”我们点击链接,发现一个风格很好的网页,菜单以四个大方块的形式呈现。这个页面确实加载得非常快。点击任何一个方块后,选定的模块需要超过 10 秒才能进行懒加载。这就是感知性能的实际应用。
小贴士
尽可能使您的应用程序的根模块尽可能小。将应用程序的其余部分拆分为懒加载模块,用户将称赞您应用程序的性能。
4.2.2. 预加载器
假设您在实现懒加载后,在初始应用启动时节省了一秒钟。但当用户点击奢侈品链接时,他们仍然需要等待这一秒钟,以便浏览器加载您的奢侈品模块。如果用户不需要等待这一秒钟,那就太好了。使用 Angular 预加载器,您可以一石二鸟:减少初始下载时间 并且 在处理懒加载路由时获得即时响应。
使用 Angular 预加载器,您可以执行以下操作:
-
在用户与您的应用交互时,在后台预加载所有懒加载模块
-
在路由配置中指定预加载策略
-
通过创建一个实现
PreloadingStrategy接口的类来实现自定义预加载策略
Angular 提供了一个名为 PreloadAllModules 的预加载策略,这意味着在您的应用程序加载后,Angular 会立即在后台加载所有包含懒加载模块的包。这不会阻塞应用程序,用户可以继续使用应用程序而不会出现任何延迟。只需将此预加载策略作为 forRoot() 的第二个参数添加即可,如下所示。
列表 4.20. 添加预加载策略
RouterModule.forRoot([
{path: '', component: HomeComponent},
{path: 'product', component: ProductDetailComponent},
{path: 'luxury', loadChildren: './luxury.module#LuxuryModule' }
],
{
preloadingStrategy: PreloadAllModules *1*
})
- 1 添加 PreloadAllModules 预加载策略
在此代码更改之后,网络标签将显示 luxury.module.chunk.js 也已被加载。大型应用程序可能包含数十个懒加载模块,您可能需要制定一些自定义策略,以确定哪些懒加载模块应该预加载,哪些不应该。
假设您有两个懒加载模块,LuxuryModule 和 SuperLuxuryModule,并且您只想预加载第一个。您可以在 luxury 路径的配置中添加一些布尔变量(例如,preloadme: true):
{path: 'luxury', loadChildren: './luxury.module#LuxuryModule', data:
{preloadme: true} }
{path: 'luxury', loadChildren: './superluxury.module#SuperLuxuryModule' }
您的自定义预加载器可能看起来像以下列表。
列表 4.21. 一个示例自定义预加载类
@Injectable()
export class CustomPreloadingStrategy implements PreloadingStrategy { *1*
preload(route: Route,
load: () => Observable<any>): Observable<any> { *2*
return (route.data && route.data['preloadme']) ? *3*
load(): empty(); *4*
}
}
-
1 创建一个实现 PreloadingStrategy 接口的类
-
2 将返回一个 Observable 的回调函数传递给 preload()方法
-
3 检查每个路由配置的数据对象上的 preloadme 属性值。如果存在且其值为 preloadme: true,则调用 load()回调函数。
-
4 无需预加载—返回一个空的 Observable
因为CustomPreloadingStrategy是一个可注入的服务,您需要将其添加到根模块的@NgModule装饰器中的providers属性。不要忘记在forRoot()方法中将您自定义预加载器的名称作为参数指定。
摘要
-
使用守卫来介导客户端导航。
-
如果需要,可以在同一组件中创建多个
<router-outlet>标签。 -
通过实现懒加载技术来最小化您应用的初始大小。
第五章. Angular 中的依赖注入
本章涵盖
-
介绍依赖注入作为设计模式
-
理解 Angular 如何实现 DI
-
注册对象提供者和使用注入器
-
将 Angular Material UI 组件添加到 ngAuction
第四章讨论了路由器,现在 ngAuction 应用程序知道如何从主页导航到产品详情页。在本章中,我们将专注于 Angular 如何自动化创建对象和从其构建块组装应用程序的过程。
一个 Angular 应用程序是一组组件、指令和服务,它们可能相互依赖。尽管每个组件都可以显式实例化其依赖项,但 Angular 可以使用其依赖注入(DI)机制来完成这项工作。
我们将从这个章节开始,确定 DI 解决的问题,并回顾 DI 作为软件工程设计模式的优点。然后,我们将通过一个依赖于ProductService的示例ProductComponent来具体说明 Angular 如何实现 DI 模式。您将了解如何编写可注入的服务以及如何将其注入到另一个组件中。
之后,您将看到一个示例应用程序,演示了 Angular DI 如何通过仅更改一行代码来轻松地用一个组件依赖项替换另一个组件依赖项。在本章末尾,我们将通过一个实际操作练习来构建 ngAuction 的下一个版本,该版本使用 Angular Material UI 组件。
设计模式是解决某些常见任务的推荐。给定设计模式可以根据您使用的软件以不同的方式实现。在第一部分,我们将简要介绍两种设计模式:依赖注入和控制反转(IoC)。
5.1. 依赖注入模式
如果您曾经编写过一个接受对象作为参数的函数,那么您已经编写了一个实例化此对象并将其注入到函数中的程序。想象一个负责运输产品的配送中心。一个跟踪已运输产品的应用程序可以创建一个Product对象并调用一个创建并保存运输记录的函数:
var product = new Product();
createShipment(product);
createShipment()函数依赖于Product对象实例的存在,这意味着createShipment()函数有一个依赖项:Product。但该函数本身并不知道如何创建Product。调用脚本应通过某种方式创建并(想象一下注入)将此对象作为参数传递给函数。
从技术上讲,您正在将Product对象的创建与其使用解耦——但上述两行代码都位于同一脚本中,因此这不是真正的解耦。如果您需要将Product替换为MockProduct,在这个简单示例中只需进行少量代码更改。
假设createShipment()函数有三个依赖项(例如产品、运输公司和履约中心),并且每个依赖项都有自己的依赖项呢?在这种情况下,为createShipment()函数创建不同的对象集将需要更多的手动代码更改。是否有可能要求某人为你创建依赖项(及其依赖项)的实例?
这就是依赖注入模式的内容:如果对象 A 依赖于一个由标记(一个唯一的 ID)标识的对象 B,对象 A 不会显式地使用new操作符来实例化 B 指向的对象。相反,它将从操作环境中注入 B。
对象 A 只需要声明,“我需要一个名为 B 的对象;请有人给我提供它?”对象 A 不请求特定的对象类型(例如,Product),而是将注入的责任委托给标记 B。看起来对象 A 不想控制创建实例,并准备好让框架控制这个过程,不是吗?
控制反转模式
控制反转(Inversion of Control,IoC)比依赖注入(DI)更为通用。不是让你的应用程序使用框架(或软件容器)中的某个 API,而是框架创建并供应应用程序需要的对象。IoC 模式可以以不同的方式实现,DI 是提供所需对象的一种方式。Angular 扮演着 IoC 容器的角色,可以根据你的组件声明提供所需的对象。
5.2. Angular 应用程序中 DI 的优点
在我们探索 Angular DI 实现的语法之前,让我们看看使用注入对象而不是使用new操作符实例化对象的优点。Angular 提供了一种机制,有助于注册和实例化组件依赖项。简而言之,DI 帮助你以松耦合的方式编写代码,并使你的代码更具可测试性和可重用性。
Angular 中注入的内容
在 Angular 中,你注入服务或常量。服务是 TypeScript 类的实例,没有 UI,仅实现应用程序的业务逻辑。常量可以是任何值。通常,你会注入 Angular 服务(如Router或ActivatedRoute)或与服务器通信的自己的类。你将在第 5.6 节中看到一个注入常量的例子。服务可以在组件或另一个服务中注入。
5.2.1. 松耦合和可重用性
假设你有一个使用 ProductService 类获取产品详情的 ProductComponent。在没有 DI 的情况下,你的 ProductComponent 需要知道如何实例化 ProductService 类。这可以通过多种方式完成,例如使用 new,在单例对象上调用 getInstance(),或者调用某个工厂函数 createProductService()。在任何情况下,ProductComponent 都会与 ProductService 变得 紧密耦合,因为用这个服务的另一个实现替换 ProductService 需要在 ProductComponent 中进行代码更改。
如果你需要在另一个使用不同服务获取产品详情的应用中重用 ProductComponent,你必须修改代码,例如 productService = new AnotherProductService()。依赖注入(DI)允许你通过避免组件和服务知道如何创建它们的依赖项来解耦应用组件和服务。
Angular 文档使用了 token 的概念,它是一个表示要注入的对象的任意键。通过指定提供者,你可以将 tokens 映射到值以进行 DI。provider 是对 Angular 的一个指令,说明如何创建一个对象实例以供将来注入到目标组件、服务或指令中。考虑以下示例,一个 ProductComponent 的例子,它注入了 ProductService。
列表 5.1. ProductService 注入到 ProductComponent
@Component({
providers: [ProductService] *1*
})
class ProductComponent {
product: Product;
constructor(productService: ProductService) { *2*
this.product = productService.getProduct(); *3*
}
}
-
1 将 ProductService token 指定为一个注入提供者
-
2 注入由 ProductService token 表示的对象
-
3 使用注入对象的 API
通常,token 的名称与要注入的对象的类型相匹配,所以 列表 5.1 是一个简写,指示 Angular 使用同名的类提供 ProductService token。长版本看起来像这样:providers:[{provide: ProductService, useClass: ProductService}]。你对 Angular 说,“如果你看到一个构造函数使用 ProductService token 的类,注入 ProductService 类的实例。”
使用 @Component() 或 @NgModule 的 provide 属性,你可以将相同的 token 映射到不同的值或对象(例如,在其他人开发真实服务类的同时模拟 ProductService 的功能)。
注意
你已经在第三章的第 3.1.2 节中使用了 providers 属性,但它是在 @NgModule() 模块级别上定义的,而不是在组件级别上。
现在你已经将 providers 属性添加到 ProductComponent 的 @Component() 装饰器中,Angular 的 DI 模块将知道它需要实例化一个 ProductService 类型的对象。
下一个问题是什么时候创建服务的实例?这取决于你为该服务指定的装饰器。在列表 5.1 中,你是在@Component()装饰器内部指定提供者的。这告诉 Angular 在创建ProductComponent时创建ProductService的实例。如果你在@NgModule()装饰器内部的providers属性中指定ProductService,那么服务实例将在应用级别上创建为一个单例,这样所有组件都可以重用它。
ProductComponent不需要知道要使用ProductService类型的具体实现——它将使用指定为提供者的任何对象。对ProductService对象的引用将通过构造函数参数注入,并且不需要在ProductComponent中显式实例化ProductService。只需像列表 5.1 中那样使用它,该列表调用由 Angular 魔法般创建的ProductService实例上的服务方法getProduct()。
如果你需要使用ProductService类型的不同实现来重用相同的ProductComponent,请更改提供者行,如providers: [{provide: ProductService, useClass: AnotherProductService}]。你将在第 5.5 节中看到一个更改可注入服务的例子。现在 Angular 将实例化AnotherProductService,但使用ProductService的ProductComponent代码不需要修改。在这个例子中,使用依赖注入增加了ProductComponent的可重用性,并消除了它与ProductService的紧密耦合。
5.2.2. 可测试性
依赖注入增加了你组件的独立可测试性。如果你想对你的代码进行单元测试,你可以轻松地注入模拟对象。比如说,你需要给你的应用程序添加一个登录功能。你可以创建一个LoginComponent(用于渲染 ID 和密码字段),它使用一个LoginService,该服务应连接到某个授权服务器并检查用户的权限。在单元测试LoginComponent时,你不想因为授权服务器宕机而导致测试失败。
在单元测试中,我们经常使用模拟对象来模仿真实对象的行为。使用依赖注入框架,你可以创建一个模拟对象MockLoginService,它不连接到授权服务器,而是为具有特定 ID/密码组合的用户分配硬编码的访问权限。使用依赖注入,你可以写一行代码将MockLoginService注入到你的应用程序的登录视图中,而无需等待授权服务器就绪。你的测试将获得注入到你的应用程序登录视图中的MockLoginService实例(如图 5.1 所示),并且你的测试不会因为无法控制的问题而失败。
注意
在第十四章的实践部分[kindle_split_023.xhtml#ch14],你会看到如何对可注入服务进行单元测试。
图 5.1. 测试中的依赖注入

5.3. 注入器和提供者
现在你已经对依赖注入作为一种通用的、软件工程设计模式有了简要的介绍,让我们来探讨在 Angular 中实现 DI 的具体细节。特别是,我们将讨论注入器和提供者等概念。
每个组件都可以有一个Injector实例,该实例可以将对象或原始值注入到组件中。任何 Angular 应用程序都有一个根注入器,对所有模块都可用。为了让注入器知道要注入的内容,你指定提供者。注入器会将提供者中指定的对象或值注入到组件的构造函数中。提供者允许你将自定义类型(或令牌)映射到该类型的具体实现(或值)。
注释
尽管急切加载的模块没有自己的注入器,但延迟加载的模块有一个自己的子根注入器,它是父模块注入器的直接子节点。你将在第 5.7 节中看到一个延迟加载模块的注入示例。
小贴士
在 Angular 中,你只能通过类的构造函数参数将服务注入到类中。如果你看到一个无参数构造函数的类,这保证这个类中没有注入任何内容。
我们将在本章的几个代码示例中使用ProductComponent和ProductService。如果你的应用程序有一个实现特定类型(如ProductService)的类,你可以在@NgModule()装饰器中在应用程序级别为该类指定提供者对象,如下所示:
@NgModule({
...
providers: [{provide: ProductService, useClass: ProductService}]
})
当令牌名称与类名相同时,你可以使用简短表示法在模块中指定提供者:
@NgModule({
...
providers: [ProductService]
})
providers行指示注入器如下:“当你需要构造一个具有ProductService类型参数的对象时,创建一个注册类的实例以注入到该对象中。”当 Angular 实例化一个将ProductService令牌作为组件构造函数参数的组件时,它将实例化和注入ProductService,或者只是重用现有实例并注入它。在这种情况下,我们将为整个应用程序拥有一个服务单例实例。
如果需要为特定令牌注入不同的实现,请使用较长的表示法:
@NgModule({
...
providers: [{provide: ProductService, useClass: MockProductService}]
})
providers属性可以在@Component()注解中指定。在@Component()中ProductService提供者的简短表示法如下:
@Component({
...
providers: [ProductService]
})
export class ProductComponent{
constructor(productService: ProductService) {}
...
}
你可以使用与模块相同的长期表示法为提供者指定。如果提供者在组件级别指定,Angular 将在组件实例化期间创建并注入ProductService实例。
多亏了提供者,注入器知道要注入什么;现在你需要指定在哪里注入服务。对于类,这归结为声明一个构造函数参数,指定令牌为其类型。前面的代码片段展示了如何注入由ProductService令牌表示的对象。构造函数将保持不变,无论指定为提供者的ProductService的具体实现是什么。
providers属性是一个数组。如果需要,你可以为不同的服务指定多个提供者。以下是一个指定ProductService令牌提供者对象的单元素数组的示例:
[{provide: ProductService, useClass: MockProductService}]
provide属性将令牌映射到实例化可注入对象的函数。此示例指示 Angular 在ProductService令牌作为构造函数参数使用的地方创建MockProductService类的实例。Angular 的注入器可以使用类或工厂函数进行实例化和注入。你可以使用以下属性声明提供者:
-
useClass— 将令牌映射到类,如前例所示 -
useFactory— 将令牌映射到工厂函数,该函数根据某些标准实例化对象 -
useValue— 将string或特殊的InjectionToken映射到任意值(非基于类的注入)
你如何在代码中决定使用这些属性中的哪一个?在下一节中,你将熟悉useClass属性。第 5.6 节说明了useFactory和useValue。
5.4. 一个简单的 Angular DI 应用程序
现在你已经看到了许多与 Angular DI 相关的代码片段,让我们构建一个小型应用程序,将所有这些组件整合在一起。这将为你使用 ngAuction 应用程序中的 DI 做准备。
5.4.1. 注入产品服务
你将创建一个简单的应用程序,使用ProductComponent来渲染产品详情,并使用ProductService来提供产品数据。如果你使用书中提供的可下载代码,此应用程序位于 di-samples/basic 目录中。在本节中,你将构建一个生成图 5.2 中所示页面的应用程序。
图 5.2. 一个示例 DI 应用程序

ProductComponent可以通过声明构造函数参数的类型来请求注入ProductService对象:
constructor(productService: ProductService)
图 5.3 展示了一个使用这些组件的示例应用程序。
图 5.3. 将ProductService注入到ProductComponent

AppModule有一个根组件AppComponent,它包含ProductComponent,该组件依赖于ProductService。注意import和export语句。ProductService类的定义以export语句开始,以便其他组件可以访问其内容。
在组件级别上定义的 providers 属性(参见图 5.3)(#ch05fig03)指示 Angular 在创建 ProductComponent 时提供 ProductService 类的一个实例。ProductService 可能会与某些服务器通信,请求网页上选择的产品详情,但我们现在将跳过这部分内容,专注于如何将此服务注入到 ProductComponent 中。以下列表从根组件开始实现了 图 5.3 中的组件。
列表 5.2. app.component.ts
import {Component} from '@angular/core';
@Component({
selector: 'app-root',
template: `<h1>Basic Dependency Injection Sample</h1>
<di-product-page></di-product-page>` *1*
})
export class AppComponent {}
- 1 将
组件添加到模板中
根据 <di-product-page> 标签,你可以猜测有一个具有此值的选择器的组件。此选择器在 ProductComponent 中声明,其依赖项 ProductService 通过构造函数注入,如下一列表所示。
列表 5.3. product.component.ts
import {Component} from '@angular/core';
import {ProductService, Product} from "./product.service";
@Component({
selector: 'di-product-page', *1*
template: `<div>
<h1>Product Details</h1>
<h2>Title: {{product.title}}</h2>
<h2>Description: {{product.description}}</h2>
<h2>Price: \${{product.price}}</h2>
</div>`,
providers: [ProductService] *2*
})
export class ProductComponent {
product: Product;
constructor(productService: ProductService) { *3*
this.product = productService.getProduct();
}
}
-
1 指定此组件的选择器
-
2 providers 属性的简写告诉注入器实例化 ProductService 类。
-
3 Angular 实例化了 ProductService 并将其注入此处。
在 列表 5.3 中,你使用 ProductService 类作为具有相同名称的类型的一个标记,因此你可以使用简写而不需要显式映射 provide 和 useClass 属性。当指定提供者时,你将可注入对象的标记与其实现分开。尽管在这种情况下,标记的名称与类型的名称相同——ProductService——但映射到该标记的代码可以位于名为 ProductService、OtherProductService 或其他名称的类中。用另一个实现替换一个实现归结为更改 providers 行。
ProductComponent 的构造函数在服务上调用 getProduct() 并将返回的 Product 对象的引用放置在 product 类变量中,该变量用于 HTML 模板。通过使用双大括号,将 Product 类的 title、description 和 price 属性绑定。
product-service.ts 文件包含了两个类的声明:Product 和 ProductService,如以下列表所示。
列表 5.4. product-service.ts
export class Product { *1*
constructor(
public id: number,
public title: string,
public price: number,
public description: string) {
}
}
export class ProductService {
getProduct(): Product { *2*
return new Product(0, "iPhone 7", 249.99,
"The latest iPhone, 7-inch screen");
}
}
-
1 Product 类代表一个产品(一个值对象)。它在此脚本之外使用,因此将其导出。
-
2 为了简单起见,getProduct() 方法总是返回具有硬编码值的相同产品。
在实际应用中,getProduct() 方法必须从外部数据源获取产品信息,例如通过向远程服务器发出 HTTP 请求。
要运行此示例,请执行 npm install 并运行以下命令:
ng serve --app basic -o
浏览器将打开窗口,如图 5.2(#ch05fig02)中所示。ProductService 的实例被注入到 ProductComponent 中,该组件渲染服务提供的产品详情。
在下一节中,您将看到装饰了 @Injectable() 的 ProductService,这仅在服务本身有依赖项时才需要。它指示 Angular 为此服务生成额外的元数据。在示例中不需要 @Injectable() 装饰器,因为 ProductService 没有其他依赖项被注入,并且 Angular 不需要额外的元数据来将 ProductService 注入到组件中。
使用 @Inject() 的替代 DI 语法
在我们的示例中,提供者将令牌映射到类,注入的语法很简单:使用构造函数参数的类型作为令牌,Angular 将为提供的类型生成所需的元数据:
constructor(productService: ProductService)
有一种替代的、更冗长的语法,可以使用装饰器 @Inject() 来指定令牌:
constructor(@Inject(ProductService) productService)
在这种情况下,您不需要指定构造函数参数的类型,而是使用 @Inject() 装饰器来指示 Angular 为 ProductService 生成元数据。在基于类的注入中,您不需要使用这种冗长的语法,但有些情况下您必须使用 @Inject(),我们将在 5.6.1 节 中讨论这一点。
5.4.2. 注入 HttpClient 服务
通常,一个服务需要通过 HTTP 请求获取必要的数据。ProductComponent 依赖于 ProductService,它使用 Angular DI 机制进行注入。如果 ProductService 需要执行 HTTP 请求,它将有一个 HttpClient 对象作为其依赖项。ProductService 需要导入 HttpClient 对象以进行注入;@NgModule() 必须导入 HttpClientModule,它定义了 HttpClient 提供者。ProductService 类应该有一个构造函数来注入 HttpClient 对象。图 5.4 显示 ProductComponent 依赖于具有其自身依赖项的 ProductService:HttpClient。
图 5.4. 一个依赖项可以有自己的依赖项。

以下列表说明了将 HttpClient 对象注入到 ProductService 以及从 products.json 文件中检索产品的过程。
列表 5.5. 将 HttpClient 注入到 ProductService
import {HttpClient} from '@angular/common/http';
import {Injectable} from "@angular/core";
@Injectable()
export class ProductService {
constructor(private http: HttpClient) { *1*
let products = http.get<string>('products.json') *2*
.subscribe(...); *3*
}
-
1 注入 HttpClient
-
2 使用 HTTP GET
-
3 订阅 HTTP 请求的结果
因为 ProductService 有自己的可注入依赖项,所以您需要用 @Injectable() 装饰它。在这里,您将一个服务注入到另一个服务中。类构造函数是注入点,但您在哪里声明注入 HttpClient 类型对象的提供者?所有必需的提供者都声明在 HttpClientModule 中。您只需将其添加到您的 AppModule 中,如下所示。
列表 5.6. 添加 HttpClientModule
import { HttpClientModule} from '@angular/common/http'; *1*
...
@NgModule({
imports: [
BrowserModule,
HttpClientModule *2*
],
declarations: [AppComponent],
bootstrap: [AppComponent]
})
-
1 在根模块中导入 HttpClientModule
-
2 将 HttpClientModule 添加到导入部分
注意
第十二章 解释了 HttpClient 的工作原理。
从 Angular 6 开始,@Injectable() 装饰器允许你指定 provideIn 属性,这可能让你免于显式声明服务的提供者。以下列表显示了如何指示 Angular 自动创建模块级别的 ProductService 提供者。
列表 5.7. 使用 provideIn
@Injectable(
provideIn: 'root'
)
export class ProductService {
...
}
现在你已经看到了如何将对象注入到组件中,让我们看看使用 Angular DI 替换一个服务实现为另一个实现需要什么。
5.5. 简化注入服务切换
在本章早期,我们提到 DI 模式允许你将组件与其依赖项解耦。在前一节中,你将 ProductComponent 从 ProductService 中解耦。现在让我们模拟另一个场景。
假设你已经开始使用一个 ProductService 进行开发,该服务应从远程服务器获取数据,但服务器的数据源尚未准备好。与其修改 ProductService 中的代码以引入硬编码的数据进行测试,你将创建另一个类:MockProductService。
为了说明从一种服务切换到另一种服务有多容易,你将创建一个小型应用程序,该应用程序使用两个 ProductComponent 实例。最初,第一个将使用 MockProductService,第二个使用 ProductService。然后,通过一行更改,你将使它们都使用同一个服务。图 5.5 显示了应用程序如何渲染使用 ProductService 不同实现的两个产品组件。
图 5.5. 两个组件和两个产品

iPhone 7 产品由 Product1Component 渲染,而 Samsung 7 由 Product2Component 渲染。这个应用程序专注于使用 Angular DI 切换产品服务,因此我们保持了组件和服务简单。本章附带的应用程序中组件和服务位于单独的文件中,但我们把所有相关代码放在以下列表中。
列表 5.8. 两个产品和两个服务
// a value object
class Product {
constructor(public title: string) {}
}
// services
class ProductService { *1*
getProduct(): Product {
return new Product('iPhone 7');
}
}
class MockProductService { *1*
getProduct(): Product {
return new Product('Samsung 7');
}
}
// product components
@Component({
selector: 'product1',
template: 'Product 1: {{product.title}}'})
class Product1Component {
product: Product;
constructor(private productService: ProductService) { *2*
this.product = productService.getProduct();
}
}
@Component({
selector: 'product2',
template: 'Product 2: {{product.title}}',
providers: [{provide: ProductService, useClass: MockProductService}] *3*
})
class Product2Component {
product: Product;
constructor(private productService: ProductService) { *4*
this.product = productService.getProduct();
}
}
@Component({
selector: 'app-root',
template: `
<product1></product1>
<p>
<product2></product2>
`
})
class AppComponent {} *5*
@NgModule({
imports: [BrowserModule],
providers: [ProductService], *6*
declarations: [AppComponent, Product1Component, Product2Component],
bootstrap: [AppComponent]
})
class AppModule { }
-
1 不良设计
-
2 由于在此组件级别没有声明提供者,它将使用应用级别的提供者。
-
3 仅在组件级别为 ProductComponent2 声明提供者
-
4 ProductComponent2 获取 MockProductService,因为其提供者在组件级别已指定。
-
5 浏览器渲染 AppComponent 的两个子组件
-
6 声明应用级别的提供者
小贴士
列表 5.8 中有两行被标记为不良设计。阅读“面向抽象编程”的侧边栏以获取解释。
如果一个组件不需要特定的 ProductService 实现,那么在父组件级别或 @NgModule() 中指定了提供者的情况下,就没有必要显式声明其提供者。在 列表 5.8 中,Product1Component 没有声明自己的 ProductService 提供者,Angular 将在应用级别找到它。
但每个组件都可以自由覆盖在应用或父组件级别做出的providers声明,如Product2Component所示。每个组件都有自己的注入器,在Product2Component的实例化过程中,这个注入器将看到组件级别的提供者并将注入MockProductService。这个注入器甚至不会检查应用级别上是否有相同标记的提供者。
如果你决定Product2Component应该获取ProductService的注入实例,请从其@Component()装饰器中移除providers行。
从现在开始,无论何时需要注入ProductService类型且在组件级别未指定providers行,Angular 都会实例化并注入ProductService。在做出上述更改后运行应用程序,组件将呈现如图 5.6 所示。
图 5.6. 两个组件和一个服务

要查看此应用程序的实际运行情况,请运行以下命令:
ng serve --app switching -o
假设你的应用程序有数十个使用ProductService的组件。如果每个组件都使用new运算符实例化此服务,你需要进行数十次代码更改。使用 Angular DI,你能够通过更改providers声明中的一行来切换服务。
面向抽象编程
在面向对象编程中,建议按照接口或抽象编程。因为 Angular DI 模块允许你替换可注入对象,所以如果你能声明一个ProductService接口并将其指定为提供者,那将很棒。然后你会编写几个实现此接口的具体类,并根据需要将它们切换到providers声明中。
你可以在 Java、C#、PHP 和其他面向对象的语言中这样做。问题是,在将 TypeScript 代码转换为 JavaScript 后,接口被移除了,因为 JavaScript 不支持它们。换句话说,如果ProductService被声明为接口,下面的构造函数将是错误的,因为 JavaScript 代码对ProductService一无所知:
constructor(productService: ProductService)
但 TypeScript 支持抽象类,其中可以有一些方法被实现,而有些则是抽象的——声明但未实现。然后,你需要实现一些具体的类,这些类扩展了抽象类并实现了所有抽象方法。例如,你可以有如下所示的类:
export abstract class ProductService{ *1*
abstract getProduct(): Product; *2*
}
export class MockProductService extends ProductService{ *3*
getProduct(): Product {
return new Product('Samsung 7');
}
}
export class RealProductService extends ProductService{ *4*
getProduct(): Product {
return new Product('iPhone 7');
}
}
-
1 声明一个抽象类
-
2 声明一个抽象方法
-
3 创建抽象类的第一个具体实现
-
4 创建抽象类的第二个具体实现
注意,如果你的抽象类没有实现任何方法(如本例所示),你可以使用关键字implement而不是extend。
好消息是,你可以在构造函数中使用抽象类的名称,在 JavaScript 代码生成期间,Angular 将根据提供者声明使用特定的具体类。正如这个侧边栏中声明的 ProductService、MockProductService 和 RealProductService 类一样,这将允许你编写如下内容:
@NgModule({
providers: [{provide: ProductService, useClass: RealProductService}],
...
})
export class AppModule { }
@Component({...})
export class Product1Component {
constructor(productService: ProductService) {...};
}
}
在这里,你同时在令牌和构造函数的参数中使用了抽象。在 列表 5.8 中,ProductService 是某些功能的具体实现,这种情况并不适用。如果你决定从服务的一个具体实现切换到另一个,替换提供者的方式与之前描述的方式相同。
在 列表 5.8 中,你将 ProductService 和 MockProductService 类声明为具有相同名称的方法 getProducts()。如果你使用了抽象类方法,TypeScript 编译器会给出错误,如果你尝试实现一个具体类但遗漏了一个抽象方法的实现。这就是为什么 列表 5.8 中的两行被标记为不良设计。
如果你的组件或模块无法将令牌映射到类,但需要应用一些业务逻辑来决定实例化哪个类呢?此外,如果你只想注入一个原始值而不是一个对象呢?
5.6. 使用 useFactory 和 useValue 声明提供者
通常情况下,当需要在实例化对象之前应用一些应用逻辑时,会使用工厂函数。例如,你可能需要决定实例化哪个对象,或者你的对象可能有一个需要在你创建实例之前初始化的带有参数的构造函数。让我们修改上一节中的应用程序,以说明工厂和值提供者的使用。
下面的列表显示了 Product2Component 的修改版本,你可以在 di-samples 应用程序的工厂目录中找到它。它展示了如何编写一个工厂函数并将其用作注入器的提供者。这个工厂函数根据 boolean 标志 isProd 创建 ProductService 或 MockProductService,该标志指示是否在生产或开发环境中运行,如下面的列表所示。
列表 5.9. product.factory.ts
export function productServiceFactory (isProd: boolean) { *1*
if (isProd) {? *2*
return new ProductService();?
} else {?
return new MockProductService();?
}?
}
-
1 将 isProd 的值注入到工厂函数中
-
2 根据 isProd 的值实例化服务
你将使用 useFactory 属性来指定 ProductService 令牌的提供者。因为这个工厂函数需要一个参数(一个依赖项),你需要告诉 Angular 从哪里获取这个参数的值,你可以使用一个特殊的属性 deps 来做到这一点,如下面的列表所示。
列表 5.10. 将工厂函数指定为提供者
{provide: ProductService,? useFactory: productServiceFactory, *1*
deps: ['IS_PROD_ENVIRONMENT']} *2*
-
1 此函数用于实例化服务
-
2 此工厂函数的依赖项
在这里,你指示 Angular 向你的工厂函数注入由 IS_PROD_ENVIRONMENT 令牌指定的值。如果工厂函数有多个参数,你可以在 deps 数组中列出相应的令牌。
如何为表示为字符串的令牌提供一个静态值?你可以通过使用 useValue 属性来实现。以下是如何将值 true 与 IS_PROD_ENVIRONMENT 令牌关联起来的示例:
{provide: 'IS_PROD_ENVIRONMENT', useValue: true}
注意,你将一个字符串令牌映射到一个硬编码的原始值,这在现实世界的应用程序中不是你通常会做的事情。让我们使用 Angular CLI 在 src/environments 目录中生成的环境文件中的环境变量来找出你的应用程序是在开发模式还是生产模式下运行。此目录有两个文件:environment.prod.ts 和 environment.ts。以下是 environment.prod.ts 的内容:
export const environment = {
production: true
};
environment.ts 文件有类似的内容,但将 production 环境变量赋值为 false。如果你没有使用 ng serve 或 ng build 中的 --prod 选项,则 environment.ts 中定义的环境变量可以在你的应用程序代码中使用。当你使用 --prod 构建包时,可以在 environment.prod.ts 中使用定义的变量:
{provide: 'IS_PROD_ENVIRONMENT', useValue: environment.production}
在环境文件中,你可以定义你需要的任何变量,并使用点符号在应用程序中访问它们,例如 environment.myOtherVar。
使用 useFactory 和 useValue 的提供者的整个应用程序模块代码如下所示。
列表 5.11. 使用 useFactory 和 useValue 的提供者
...
import {ProductService} from './product.service';
import {productServiceFactory} from './product.factory';
import {environment} from '../../environments/environment';
@NgModule({
imports: [BrowserModule],
providers: [{provide: ProductService,?
useFactory: productServiceFactory, *1*
deps: ['IS_PROD_ENVIRONMENT']}, *2*
{provide: 'IS_PROD_ENVIRONMENT',
useValue: environment.production}], *3*
declarations: [AppComponent, Product1Component, Product2Component],
bootstrap: [AppComponent]
})
export class AppModule {}
-
1 将 productServiceFactory 工厂函数映射到 ProductService 令牌
-
2 指定要注入到工厂函数中的参数
-
3 将环境文件中的值映射到 IS_PROD_ENVIRONMENT 令牌
你可以在 di-samples 项目的 factory 目录中找到实现 useFactory、useValue 以及环境变量 production 的应用程序的完整代码。首先,按照以下方式运行此应用程序:
ng serve --app factory -o
在开发环境中,工厂函数提供 MockProductService,浏览器渲染显示三星的两个组件。现在,以生产模式运行相同的应用程序:
ng serve --app factory --prod -o
这次,environment.production 的值是 true,工厂提供了 ProductService,浏览器渲染了两个 iPhone。
总结一下,提供者可以将令牌映射到类、工厂函数或任意值,以便注入器知道要注入哪些对象或值。类或工厂可能有自己的依赖项,因此提供者应指定所有这些依赖项。图 5.7 展示了提供者与示例应用程序的注入器之间的关系。
图 5.7. 使用依赖注入依赖项

你可以将值注入到一个字符串令牌(如IS_PROD_ENVIRONMENT)中,这很棒,但这可能潜在地造成问题。如果你的应用使用了别人的模块,而这个模块恰好也有一个名为IS_PROD_ENVIRONMENT的令牌,但在这里注入了不同含义的值,会发生什么?这里存在一个命名冲突。使用 JavaScript 字符串,在任何给定时间,内存中只有一个位置分配给IS_PROD_ENVIRONMENT,你无法确定会注入什么值。
5.6.1. 使用 InjectionToken
为了避免使用硬编码的字符串作为令牌引起的冲突,Angular 提供了一个InjectionToken类,它比使用字符串更可取。想象一下,你想创建一个可以从不同的服务器(如开发、生产和质量保证)获取数据的组件,并且你想将服务器的 URL 字符串注入到名为BackendUrl的令牌中。而不是注入 URL 字符串令牌,你应该创建一个InjectionToken的实例,如下一列表所示。
列表 5.12. 使用InjectionToken而不是字符串令牌
import {Component, Inject, InjectionToken} from '@angular/core';
export const BACKEND_URL = new InjectionToken('BackendUrl'); *1*
@Component({
selector: 'app-root',
template: '<h2>The value of BACKEND_URL is {{url}}</h2>',
providers: [{provide:BACKEND_URL, useValue: 'http://myQAserver.com'}] *2*
})
export class AppComponent {
constructor(@Inject(BACKEND_URL) public url) {} *3*
}
-
1 实例化 InjectionToken
-
2 声明一个提供者以将值注入到令牌中
-
3 将
myQAserver.com注入到 BACKEND_URL 令牌中
在这里,你将字符串BackendUrl包装成一个InjectionToken的实例。然后,在这个组件的构造函数中,而不是注入一个模糊的字符串类型,你注入一个指向InjectionToken具体实例的BACKEND_URL。即使另一个模块的代码也有new InjectionToken('BackendUrl'),它也将是不同的对象。
BACKEND_URL不是一个类型,所以你不能将你的InjectionToken实例指定为构造函数参数的类型。你会得到一个编译错误:
constructor(public url: BACKEND_URL) // error
这就是为什么你没有指定AppComponent构造函数的参数类型,而是使用@Inject(BACKEND_URL)装饰器来让 Angular 知道要注入哪个对象。
提示
你不能注入 TypeScript 接口,因为它们在转译的 JavaScript 代码中没有表示。
你知道提供者可以在组件和模块级别定义,并且模块级别的提供者可以在整个应用中使用。当你的应用有多个模块时,事情会变得复杂。在功能模块的@NgModule中声明的提供者是否也会在根模块中可用,或者它们会被隐藏在功能模块内部?
5.6.2. 模块化应用中的依赖注入
每个根应用模块都有自己的注入器。如果你将你的应用拆分为几个急切加载的功能模块,它们将重用根模块的注入器,所以如果你在根模块中声明了ProductService的提供者,任何其他模块都可以在 DI 中使用它。
如果在功能模块中声明了一个提供者——它是否对应用注入器可用?这个问题的答案取决于你如何加载功能模块。
如果一个模块是急速加载的,其提供者可以在整个应用程序中使用,但每个懒加载的模块都有自己的注入器,不暴露提供者。在懒加载模块的 @NgModule() 装饰器中声明的提供者仅在该模块内可用,而不是整个应用程序。让我们考虑两种不同的场景:一个带有懒加载模块,另一个带有急速加载模块。
5.7. 懒加载模块中的提供者
在本节中,你将实验懒加载模块内声明的提供者。你将从第 4.3 节的修改后的应用程序开始,该应用程序位于 第四章。这次,你将添加一个可注入的 LuxuryService 并在 LuxuryModule 中声明其提供者。LuxuryService 将类似于以下列表。
列表 5.13. luxury.service.ts
import {Injectable} from '@angular/core';
@Injectable()
export class LuxuryService {
getLuxuryItem() { *1*
return "I'm the Luxury service from lazy module";
}
}
- 1 此服务有一个方法。
LuxuryModule 声明了此服务的提供者,如下面的列表所示。
列表 5.14. luxury.module.ts
@NgModule({
...
declarations: [LuxuryComponent], *1*
providers: [LuxuryService] *2*
})
export class LuxuryModule {} *3*
-
1 此模块有一个组件。
-
2 声明 LuxuryService 的提供者
-
3 导出模块以使其在其他模块中可见
LuxuryComponent 将使用此服务,如下面的列表所示。
列表 5.15. luxury.component.tss
@Component({
selector: 'luxury',
template: `<h1 class="gold">Luxury Component</h1>
The luxury service returned {{luxuryItem}} `,
styles: ['.gold {background: yellow}']
})
export class LuxuryComponent {
luxuryItem: string
constructor(private luxuryService: LuxuryService) {} *1*
ngOnInit() {
this.luxuryItem = this.luxuryService.getLuxuryItem(); *2*
}
}
-
1 注入 LuxuryService
-
2 在 LuxuryService 上调用一个方法
记住,AppModule 懒加载 LuxuryModule,正如你可以在下一个列表中看到的那样。
列表 5.16. 根模块
@NgModule({
imports: [ BrowserModule,
RouterModule.forRoot([
...
{path: 'luxury',
loadChildren: './lazymodule/luxury.module#LuxuryModule'} ] *1*
)
],
bootstrap: [AppComponent]
})
export class AppModule {}
- 1 使用引号指定模块以进行懒加载
运行此应用程序将懒加载 LuxuryModule,LuxuryComponent 将注入 LuxuryService 并调用其 API。
以下列表尝试从根模块(这两个模块属于同一项目)将 LuxuryService 注入到 HomeComponent 中。
列表 5.17. home.component.ts
import {Component} from '@angular/core';
import {LuxuryService} from "./lazymodule/luxury.service";
@Component({
selector: 'home',
template: '<h1 class="home">Home Component</h1>',
styles: ['.home {background: red}']
})
export class HomeComponent {
constructor (luxuryService: LuxuryService) {}) *1*
}
- 1 将 LuxuryService 注入到另一个模块的组件中
你不会得到任何编译器错误,但如果运行修改后的应用程序,你会得到运行时错误“没有 LuxuryService 提供者!”根模块无法访问懒加载模块中声明的提供者,该模块有自己的注入器。
5.8. 在急速加载的模块中的提供者
让我们在上一节中描述的项目中添加一个 ShippingModule,但这个模块将被急速加载。类似于 LuxuryModule,ShippingModule 将有一个组件和一个名为 ShippingService 的可注入服务。你想看看根模块是否也可以使用在急速加载的 ShippingModule 中声明的 ShippingService 提供者,如下面的列表所示。
列表 5.18. shipping.module.ts
// imports are omitted for brevity
@NgModule({
imports: [
CommonModule,
RouterModule.forChild([ *1*
{path: 'shipping', component: ShippingComponent}
])],
declarations: [ShippingComponent],
providers: [ShippingService]
})
export class ShippingModule { }
- 1 为功能模块添加路由
提示
在 第 2.5.1 节 中 第二章,ShippingModule 还在 @NgModule() 装饰器中包含了 exports: [ShippingComponent]。你必须在那里导出 ShippingComponent,因为它被用于位于 AppModule 中的 AppComponent 模板。在这个例子中,你只在 ShippingModule 内部使用 ShippingComponent,因此不需要导出。
ShippingComponent 接收 ShippingService 注入,并将调用其 getShippingItem() 方法,该方法返回硬编码的文本,“我是来自运输模块的运输服务。”
列表 5.19. shipping.component.ts
import {Component, OnInit} from '@angular/core';
import {ShippingService} from './shipping.service';
@Component({
selector: 'app-shipping',
template: `<h1>Shipping Component</h1>
The shipping service returned {{shippingItem}}`,
styles: []
})
export class ShippingComponent implements OnInit {
shippingItem: string;
constructor(private shippingService: ShippingService) {} *1*
ngOnInit() {
this.shippingItem = this.shippingService.getShippingItem(); *2*
}
}
-
1 注入 ShippingService
-
2 使用 ShippingService
图 5.8 展示了项目的结构和根 AppModule 的内容。在第 17 行,你预加载了 ShippingModule,在第 18 行,你懒加载了 LuxuryModule。
注意
到你阅读这段内容的时候,第 9-11 行的函数可能不再需要,第 17 行的 ShippingModule 预加载可能看起来像这样:{path: 'shipping', loadChildren: () => ShippingModule}。但在写作的时候,第 17 行使用函数会导致 AOT 编译时出错。
图 5.8. 使用功能模块的应用模块

要查看此应用程序的实际运行情况,请运行以下命令:
ng serve --app lazyinjection -o
点击运输详情链接会显示 ShippingService 返回的数据,如图 5.9 所示。即使你没有在根应用模块中声明 ShippingService 的提供者,ShippingService 仍然被注入到 ShippingComponent 中。
图 5.9. 导航到运输模块

这证明了这样一个事实:预加载模块的提供者与根模块的提供者合并。换句话说,Angular 为所有预加载模块有一个单一的注入器。
5.9. 实践:在 ngAuction 中使用 Angular Material 组件
注意
本章的源代码可以在 github.com/Farata/angulartypescript 和 www.manning.com/books/angular-development-with-typescript-second-edition 找到。
在 第三章 的实践部分,你在 ngAuction 中使用了依赖注入。你将 ProductService 提供者添加到 @NgModule() 中,并且这个服务被注入到 HomeComponent 和 ProductDetailComponent 中。在 ngAuction 的最终版本中,你也将 ProductService 注入到 SearchComponent 中。
在本节中,我们不会专注于 DI,而是向您介绍现代外观的 UI 组件 Angular Material 库。目标是替换 ngAuction 登录页面上的 HTML 元素,使用 Angular Material (AM) UI 组件。你仍然会在 ngAuction 的这个版本中保留 Bootstrap 库,但从第七章(kindle_split_016.xhtml#ch07)开始,你将完全重写 ngAuction,使其仅使用 AM 组件。
你将从第三章(kindle_split_012.xhtml#ch03)中的 ngAuction 作为起点,逐步用 AM 对应元素替换 HTML 元素,这样登录页面看起来就像图 5.10(#ch05fig10)中所示。
图 5.10. 带有 Angular Material 组件的 ngAuction

5.9.1. Angular Material 库简要概述
Angular Material 是由 Google 开发的一个 UI 组件库,基于定义良好设计原则和一致用户体验的 Material Design 指南(参见material.io/guidelines)。这些指南提供了关于如何设计 Web 或移动应用 UI 的建议。
AM 提供了超过 30 个 UI 组件和四个预构建主题。主题是一组调色板,每个调色板定义了颜色的一系列阴影,当一起使用时看起来很好(参见material.io/guidelines/style/color.html),如图 5.11(#ch05fig11)所示。
图 5.11. 样例材料设计调色板

数字 500 的颜色是调板的主色。我们将在第七章(kindle_split_016.xhtml#ch07)的动手实践部分向你展示如何自定义调色板。在撰写本文时,AM 提供了四个预构建主题:deeppurple-amber、indigo-pink、pink-bluegrey和purple-green。向你的应用添加主题的一种方法是在 index.html 中使用<link>标签:
<link href="../node_modules/@angular/material/prebuilt-themes/indigo-
pink.css" rel="stylesheet">?
或者,你可以按照以下方式将主题添加到全局 CSS 文件(styles.css)中:
@import '~@angular/material/prebuilt-themes/indigo-pink.css';
任何使用 AM 构建的应用都可以为 UI 组件指定以下颜色:
-
primary— 主要颜色 -
accent— 次要颜色 -
warn— 用于错误和警告 -
foreground— 用于文本和图标 -
background— 用于组件背景
在为你的应用 UI 进行样式设计时,大部分情况下你不会指定颜色名称或代码,就像在常规 CSS 中做的那样。你将使用前面提到的关键字之一。
小贴士
在第七章(kindle_split_016.xhtml#ch07)的动手实践部分,你将开始使用 CSS 扩展 SaaS 进行样式设计。
以下行显示了如何添加带有指定主题主色的 AM 工具栏组件:
<mat-toolbar color="primary"></mat-toolbar>
如果你决定切换到不同的主题,不需要更改前面的代码——<mat-toolbar>将使用新选择主题的主要颜色。
AM 组件包括输入字段、单选按钮、复选框、按钮、日期选择器、工具栏、网格列表、数据表等。有关组件的当前列表,请参阅产品文档material.angular.io。一些组件作为标签添加到组件模板中,一些作为指令。无论如何,AM 组件的名称都以前缀 mat- 开头。
下面的列表展示了如何创建一个包含链接和带图标的按钮的工具栏。
列表 5.20. 使用 Angular Material 创建工具栏
<mat-toolbar color="primary">? *1*
<a [routerLink]="['/']">Home</a>?
<button mat-icon-button> *2*
<mat-icon>more_vert</mat-icon> *3*
? </button>?
</mat-toolbar>
-
1 AM 主题颜色为主色调的工具栏
-
2 带有图标的按钮
-
3 将 Google Material 图标 more_vert 放置在按钮上
在这里,你使用了两个 AM 标签 <mat-toolbar> 和 <mat-icon>,以及一个指令 mat-icon-button。每个 AM 组件都打包在一个功能模块中,你需要在 AppModule 的 @NgModule() 装饰器中导入所需的 AM 组件的模块。你将在对 ngAuction 进行改观的同时看到如何这样做。
小贴士
如果你想在你的电脑上构建 ngAuction 的新版本,请将 chapter3/ngAuction 目录复制到另一个位置,在那里运行 npm install,并遵循下一节中的说明。
5.9.2. 将 AM 库添加到项目中
首先,你需要通过在项目根目录中运行以下命令来安装 AM 库所需的三个模块:
npm i @angular/material @angular/cdk @angular/animations
在这个版本的 ngAuction 中,你将使用预构建的 indigo-pink 主题,所以用以下行替换 styles.css 文件的内容:
@import '~@angular/material/prebuilt-themes/indigo-pink.css';
小贴士
从 Angular CLI 6 开始,你可以使用一个命令将 AM 库添加到你的项目中:ng add @angular/material。此命令将安装所需的包并修改应用程序中几个文件的代码,因此你需要做的输入更少。我们没有使用此命令,因为我们想将在这个应用程序中使用的所有 AM 组件保留在一个单独的功能模块中。
5.9.3. 添加带有 AM 组件的功能模块
AM 组件被打包为功能模块,你应该只添加你的应用程序需要的模块,而不是添加整个 AM 库的内容。你可以将所需的模块添加到根应用程序模块中,或者创建一个单独的模块并在其中列出所有所需的组件。
在这个版本的 ngAuction 中,你将保留 ngAuction 的 UI 组件在一个单独的模块中。通过运行以下命令生成一个新的 AuctionMaterialModule:
ng g m AuctionMaterial
此命令将在 app/auction-material/auction-material.module.ts 文件中生成功能模块的样板代码。修改此文件的代码,使其看起来像以下列表。
列表 5.21. AM UI 组件的功能模块
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import {MatToolbarModule} from '@angular/material/toolbar'; *1*
import {MatIconModule} from '@angular/material/icon';
import {MatMenuModule} from '@angular/material/menu';
import {MatButtonModule} from '@angular/material/button';
import {MatInputModule} from '@angular/material/input';
import {MatSelectModule, } from '@angular/material/select';
import {MatCardModule} from '@angular/material/card';
import {MatFormFieldModule} from '@angular/material/form-field';
import {BrowserAnimationsModule}
from '@angular/platform-browser/animations'; *2*
@NgModule({
imports: [
CommonModule *3*
],
exports: [ *4*
MatToolbarModule, MatIconModule, MatMenuModule, MatButtonModule,
MatInputModule, MatSelectModule, MatCardModule,
MatFormFieldModule, BrowserAnimationsModule
]
})
export class AuctionMaterialModule { }
-
1 只导入 ngAuction 需要的 AM 模块
-
2 此模块声明了动画服务的提供者。
-
3 这是一个功能模块,因此需要导入 CommonModule。
-
4 重新导出 AM 模块,以便它们可以在 ngAuction 的其他模块中使用
现在,打开 app.module.ts(ngAuction 的根模块)并将您的 AuctionMaterialModule 功能模块添加到 @NgModule() 的 imports 属性中,如以下列表所示。
列表 5.22. 添加 AM 功能模块
import {AuctionMaterialModule} from "./auction-material/auction-material.module";
...
@NgModule({
...
imports: [
...
AuctionMaterialModule *1*
]
...
})
- 1 将 AM 功能模块添加到根模块
现在是构建和运行 ngAuction 的好时机:
ng serve -o
您现在还看不到 ngAuction UI 中的任何变化,但请保持开发服务器运行,以便在下一节添加更多代码时,登录页面的外观将逐渐改变。
5.9.4. 修改 NavbarComponent 的外观
导航栏组件是一个带有菜单的黑色栏。您将首先替换 navbar.component.html 中的现有内容,以使用 <mat-toolbar>,它最终将包含拍卖的菜单。删除此文件中的当前内容,并在那里添加一个空工具栏:
<mat-toolbar color="primary"></mat-toolbar>
在进行更改时,请注意您正在运行的 ngAuction 的用户界面——现在它有一个空的蓝色工具栏。您希望工具栏包含指向主页的链接和一个通过按钮点击激活的弹出菜单。按钮应包含一个有三个垂直点的图标(见图 5.14),并且指令 mat-icon-button 将普通按钮转换为可以包含 <mat-icon> 的按钮。对于图像,您将使用 more_vert,这是在 material.io/icons 免费提供的 Google 物料图标之一。
通过修改 navbar.component.html 的内容来添加链接和按钮,使其与以下列表匹配。
列表 5.23. 向工具栏添加链接和图标按钮
<mat-toolbar color="primary">
<a [routerLink]="['/']">ngAuction</a> *1*
<button mat-icon-button > *2*
<mat-icon>more_vert</mat-icon>
</button>
</mat-toolbar>
-
1 添加一个链接以导航到默认路由
-
2 添加一个看起来像三个垂直点的按钮
现在,工具栏将看起来像图 5.12。
图 5.12. 一个带有损坏图标的工具栏

您指定了图标名称 more_vert,但未将 Google 物料图标添加到 index.html 中。将以下内容添加到 index.html 的 <head> 部分:
<link href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet">
现在,more_vert 图标已正确显示在按钮上,如图图 5.13 所示。
图 5.13. 一个带有固定图标的工具栏

下一步是将此按钮推到工具栏的右侧,无论屏幕宽度如何。您将在链接和按钮之间添加一个 <div> 来填充空间。将以下样式添加到 navbar.component.css:
.fill {
flex: 1;
}
默认情况下,工具栏具有 CSS 弹性盒布局(见css-tricks.com/snippets/css/a-guide-to-flexbox)。样式 flex:1 表示“将整个宽度分配给 HTML 元素。”
在 navbar.component.html 中,在 <a> 和 <button> 标签之间放置 <div>:
<div class="fill"></div>
现在按钮已经推到最右边,如图图 5.14 所示。
图 5.14. 将按钮推到右边

到目前为止,点击按钮不打开菜单有两个原因:
-
您还没有创建菜单。
-
你还没有将菜单链接到按钮。
来自第三章 chapter 3 的 ngAuction 应用程序有三个链接:关于、服务和联系。让我们将它们转换成一个弹出菜单。每个菜单项都将有一个图标(<mat-icon>)和文本。在 Angular Material 中,菜单由 <mat-menu> 表示,它可以包含一个或多个项目,例如 <button mat-menu-item> 组件。
将以下列表中的代码添加到 navbar.component.html 中的 </mat-toolbar> 标签之后。
列表 5.24. 声明弹出菜单的项目
<mat-menu #menu="matMenu"> *1*
<button mat-menu-item> *2*
<mat-icon>info</mat-icon>
<span>About</span>
</button>
<button mat-menu-item> *3*
<mat-icon>settings</mat-icon>
<span>Services</span>
</button>
<button mat-menu-item> *4*
<mat-icon>contacts</mat-icon>
<span>Contact</span>
</button>
</mat-menu>
-
1 使用 AM 菜单控件
-
2 第一菜单项
-
3 第二菜单项
-
4 第三菜单项
每个 <mat-icon> 使用 Google Material 图标中的一个(info、settings 和 contacts)。请注意,你声明了一个局部模板变量 #menu, 来引用此菜单,并将其分配给 AM matMenu 指令。本身,<mat-menu> 不会渲染任何内容,直到它通过 matMenuTriggerFor 指令附加到一个组件上。要将此菜单附加到你的工具栏按钮,将 menu 模板变量绑定到 matMenuTriggerFor 指令。更新按钮如下所示:
<button mat-icon-button [matMenuTriggerFor]="menu">
<mat-icon>more_vert</mat-icon>
</button>
注意
你可以用 <a [routerLink]> 链接替换 <button> 标签。
如果你现在点击工具栏按钮,它将显示菜单,如图 5.15 figure 5.15 所示。
图 5.15. 工具栏菜单

5.9.5. 修改 SearchComponent UI
SearchComponent 模板将包含一个包含三个控件(一个文本输入、一个数字输入和一个选择下拉菜单)的表单,这些控件将使用 matInput 指令(它们应放置在 <mat-form-field> 内部)和 <mat-select> 实现。为了使搜索按钮突出,你还将添加一个 mat-raised-button 指令和搜索图标到这个按钮。
修改 search.component.html 文件中的代码,使其看起来如下所示。
列表 5.25. search.component.html
<form #f="ngForm"> *1*
<mat-form-field> *2*
<input matInput
type="text"
placeholder="Product title"
name="title" ngModel>
</mat-form-field>
<mat-form-field> *3*
<input matInput
type="number"
placeholder="Product price"
name="price" ngModel>
</mat-form-field>
<mat-form-field> *4*
<mat-select placeholder="Category" name="category" ngModel>
<mat-option *ngFor="let c of categories"
[value]="c">{{ c }}</mat-option>
</mat-select>
</mat-form-field>
<button mat-raised-button color="accent" type="submit"> *5*
<mat-icon>search</mat-icon>SEARCH
</button>
</form>
-
1 使用模板驱动的表单 API
-
2 第一表单字段
-
3 第二表单字段
-
4 第三表单字段
-
5 表单的提交按钮
ngForm 和 ngModel 指令是定义在 FormModule(在第十章 chapter 10 的 10.2.1 节 section 10.2.1 中描述)中的模板驱动表单的一部分,并且你需要将其添加到 AppModule 中的 @NgModule() 装饰器中,如下面的列表所示。
列表 5.26. 添加对 Forms API 的支持
import {FormsModule} from '@angular/forms';
@NgModule({
...
imports: [
...
FormsModule *1*
]
})
- 1 添加对模板驱动表单 API 的支持
让我们确保 UI 正确渲染。目前,在小屏幕上,它看起来像 figure 5.16。
图 5.16. 搜索表单中的按钮未对齐

mat-form-field 组件和 mat-select 下拉菜单应占据搜索组件的整个宽度。你还需要在表单控件之间添加更多空间。
将以下列表中的样式添加到 search.component.css 文件中。
列表 5.27. search.component.css 的片段
mat-form-field, mat-select, [mat-raised-button] {
display: block;
margin-top: 16px;
width: 100%;
}
display: block; 告诉浏览器将搜索组件渲染为标准的 <div>。现在搜索表单已经很好地对齐,如图 5.17 所示。
图 5.17. 搜索表单

在这个版本的 ngAuction 中,搜索按钮不会执行搜索,搜索表单也不会进行输入验证。你将在第十一章的第 11.8 节中修复这个问题,在我们讨论 Angular 表单 API 之后。
5.9.6. 用图片替换轮播组件
在撰写本文时,Angular Material 还没有轮播组件。在实际项目中,你会在第三方库中找到轮播组件,例如 PrimeNG 库(www.primefaces.org/primeng/#/carousel),但在 ngAuction 的这个版本中,你将用静态图片替换轮播组件。
用以下代码替换 carousel.component.html 的内容:
<img src="http://placehold.it/800x300" alt="Banner">
现在,浏览器在轮播组件的位置显示一个灰色矩形。你本可以保留 Bootstrap 轮播组件,但仅仅为了轮播组件而加载整个 Bootstrap 库并不值得。目标是逐步切换到 AM 组件。
5.9.7. 使用间距进行更多修复
通过向 app.component.css 添加以下样式,让我们在工具栏和其他组件之间添加一些空间:
.container {
margin-top: 16px;
}
现在,在轮播组件和产品项之间添加一些空间。修改 home.component.css 文件,使其看起来像以下代码(display:block 用于将此自定义组件渲染为 <div>):
:host {
display: block;
}
auction-carousel {
margin-bottom: 16px;
}
5.9.8. 在 ProductItemComponent 中使用 mat-card
下一步是将你的产品以瓷砖的形式显示,每个 <nga-product-item> 将使用 <mat-card> 组件。为了在卡片内渲染每个产品,修改 product-item.component.html 文件的内容,使其看起来像以下列表。
列表 5.28. product-item.component.html
<mat-card> *1*
<mat-card-title>{{ product.title }}</mat-card-title> *2*
<img mat-card-image src="http://placehold.it/320x150"> *3*
<mat-card-content>
{{product.description}} *4*
</mat-card-content>
<mat-card-actions>
<a mat-button color="accent"
[routerLink]="['/products', product.id]">VIEW</a> *5*
</mat-card-actions>
</mat-card>
-
1 定义了 AM
组件的内容 -
2 产品标题位于顶部。
-
3 产品图片
-
4 产品描述
-
5 一个链接用于导航到产品详情
5.9.9. 为 HomeComponent 添加样式
你的 HomeComponent 包含几个 ProductItemComponent 的实例。现在让我们向 home.component.css 添加更多样式,以便以 CSS 风格 flex 显示并对齐产品。将以下列表的样式添加到 home.component.css 中。
列表 5.29. home.component.css
.product-grid {
display: flex; *1*
flex-wrap: wrap;
margin: 0 -8px;
}
nga-product-item {
margin: 0 8px 16px;
flex-basis: calc(100% / 3 - 16px); *2*
}
-
1 使用 CSS flexbox
-
2 每个组件分配屏幕宽度的三分之一加上边距
现在 ngAuction 的着陆页看起来更现代,如之前在图 5.10 中所示。它不仅比第三章中的 ngAuction 版本看起来更好,而且它的控件(搜索表单、菜单)对用户的操作提供了快速和动画响应。尝试将焦点放在搜索字段之一,你会看到字段提示如何移动到顶部。搜索按钮也显示了涟漪效果。
你没有改变第三章中展示的图 3.16 的产品详情页的外观。看看你是否能自己做到这一点。当所有标准 UI 元素都被 AM 组件替换后,你可以从 package.json 和.angular-cli.json(或者如果你使用 Angular 6,从 angular.json)中移除对 Bootstrap 库的依赖。
摘要
-
提供者注册对象以供将来注入。
-
你可以声明一个提供者,它不仅可以使用一个类,还可以使用一个函数或原始值。
-
注射器形成了一个层次结构,如果 Angular 在组件级别找不到请求类型的提供者,它将尝试通过遍历父级注射器来找到它。
-
懒加载的模块有自己的注射器,并且声明在懒加载模块内部的提供者对根模块不可用。
-
Angular Material 提供了一套看起来现代的 UI 组件。
第六章. Angular 中的响应式编程
本章涵盖
-
将事件作为观察者处理
-
在 Angular
Router和表单中使用观察者 -
在 HTTP 请求中使用观察者
-
通过丢弃不需要的 HTTP 响应来最小化网络负载
前五章的目标是使用 Angular 快速启动应用程序开发。在这些章节中,我们讨论了如何从头开始生成新项目,包括模块、路由和依赖注入。在本章中,我们将向您展示 Angular 如何支持一种响应式编程风格,其中您的应用程序对用户发起的更改或异步事件(如来自路由器、表单或服务器的数据到达)做出的反应。您将了解哪些 Angular API 支持数据推送并允许您订阅基于 RxJS 的观察者数据流。
注意
如果您不熟悉 RxJS 库的概念,如观察者、观察者、操作符和订阅,请在继续本章之前阅读附录 D。
Angular 提供了可用于实现各种场景的现成观察者:处理事件、订阅路由的参数、检查表单的状态、处理 HTTP 请求等。您将看到一些使用 Angular 观察者的示例,但以下每一章都包含响应式代码。
您可能会说,任何 JavaScript 应用程序都可以使用事件监听器并提供回调来处理事件,但我们将向您展示如何将事件视为随时间向观察者推送值的数据流。您将编写代码来订阅观察者事件流并在观察者对象中处理它们。您将能够对事件应用一个或多个操作符,在它移动到观察者时进行处理,这是使用常规事件监听器所不可能的。
注意
本章的源代码可以在github.com/Farata/angulartypescriptand www.manning.com/books/angular-development-with-typescript-second-edition找到。您可以在名为 observables 的目录中找到本节使用的代码示例。在您的 IDE 中打开此目录,并运行npm install以安装 Angular 及其依赖项。当需要时,我们提供有关如何运行代码示例的说明。
让我们先讨论如何使用和不使用观察者来处理事件。
6.1. 不使用观察者处理事件
每个 DOM 事件都由包含描述事件属性的对象表示。Angular 应用程序可以处理标准 DOM 事件,也可以发出自定义事件。一个 UI 事件的处理器函数可以用可选的$event参数声明。对于标准 DOM 事件,你可以使用浏览器Event对象的任何函数或属性(参见 Mozilla 开发者网络文档中的“事件”,mzl.la/1EAG6iw))。
在某些情况下,你可能对读取事件对象的属性不感兴趣,例如,当页面上只有一个按钮被点击,而这正是你所关心的。在其他情况下,你可能想了解特定的信息,比如当keyup事件被派发时,在<input>字段中输入了什么字符。下面的代码示例展示了如何处理 DOM 的keyup事件并打印出触发此事件的输入字段的值。
列表 6.1. 处理 keyup 事件
template:`<input id="stock" (keyup)="onKey($event)">` *1*
...
onKey(event:any) {
console.log("You have entered " + event.target.value); *2*
}
-
1 绑定到 keyup 事件
-
2 事件处理方法
在这个代码片段中,你只关心Event对象的一个属性:target。通过应用对象解构(参见附录 A 中的第 A.9.1 节),onKey()处理程序可以通过使用花括号和函数参数来即时获取target属性的引用:
onKey({target}) {
console.log("You have entered " + target.value);
}
如果你的代码派发了一个自定义事件,它可以携带应用程序特定的数据,并且事件对象可以是强类型的(不是any类型)。你将在第八章列表 8.4 中看到如何指定自定义事件的类型。
一个传统的 JavaScript 应用程序将派发的事件视为一次性的事件;例如,一次点击导致一次函数调用。Angular 提供了一种不同的方法,你可以将任何事件视为随时间发生的数据的可观察流。例如,如果用户在<input>字段中输入了几个字符,每个字符都可以被视为可观察流的一次发射。
你可以订阅可观察的事件,并指定在每次新值发出时调用的代码,以及可选的错误处理和流完成的代码。通常,你会指定多个链式 RxJS 操作符,然后调用subscribe()方法。
为什么我们需要将 RxJS 操作符应用于来自 UI 的事件?让我们考虑一个使用事件绑定来处理用户在输入股票符号以获取其价格时派发的多个keyup事件的例子:
<input type='text' (keyup) = "getStockPrice($event)">
这种技术对于处理用户输入时派发的多个事件是否足够好?想象一下,前面的代码被用来获取 AAPL 股票的报价。当用户输入第一个A时,getStockPrice()函数将向服务器发送请求,如果存在这样的股票,服务器将返回A的价格。然后用户输入第二个A,这将导致另一个服务器请求AA的报价。这个过程会重复进行,直到AAP和AAPL。
这不是你想要的。为了延迟getStockPrice()的调用,你可以将其放在setTimeout()函数中,例如,延迟 500 毫秒,以给用户足够的时间输入几个字母:
const stock = document.getElementById("stock");
stock.addEventListener("keyup", function(event) {
setTimeout(function() {
getStockPrice(event);
}, 500);
}, false);
如果用户在输入字段中继续输入,不要忘记调用clearTimeout()并启动另一个计时器。
关于在调用 getStockPrice() 之前预处理事件的几个函数的组合,有没有优雅的解决方案?如果用户输入缓慢,在 500 毫秒的间隔内只能输入 AAP,第一个请求 AAP 发送到服务器,500 毫秒后发送第二个请求 AAPL。如果客户端返回一个 Promise 对象,程序无法丢弃第一个 HTTP 请求的结果,并且可能会因不想要的 HTTP 响应而过载网络。
使用 RxJS 处理事件为你提供了一个名为 debounceTime 的方便操作符,该操作符使得可观察对象仅在经过指定时间(例如 500 毫秒)且数据生产者(在我们的例子中是 <input> 字段)在此期间没有产生新值时才发出下一个值。无需清除和重新创建计时器。此外,switchMap 操作符允许轻松取消等待挂起请求(例如,getStockPrice())的可观察对象,如果可观察对象发出新值(例如,用户持续输入)。Angular 可以提供什么来处理具有订阅者的输入字段事件?
6.2. 将 DOM 事件转换为可观察对象
在 Angular 应用程序中,你可以使用一个特殊的类 ElementRef 直接访问任何 DOM 元素,我们将使用这个特性来说明如何订阅任意 HTML 元素的事件。你将创建一个应用程序,它将订阅用户输入股票符号以获取其价格的 <input> 元素,如前所述。
要将一个 DOM 事件转换为可观察对象流,你需要执行以下操作:
1. 获取 DOM 对象的引用。
2. 使用
Observable.fromEvent()创建一个可观察对象,提供 DOM 对象和要订阅的事件的引用。3. 订阅此可观察对象并处理事件。
在一个常规的 JavaScript 应用中,要获取 DOM 元素的引用,你使用 DOM 选择器 API,document.querySelector()。在 Angular 中,你可以使用 @ViewChild() 装饰器从组件模板中获取元素的引用。
为了唯一标识模板元素,你将使用以哈希符号开始的局部模板变量。以下代码片段使用局部模板变量 #stockSymbol 作为 <input> 元素的 ID:
<input type="text" #stockSymbol placeholder="Enter stock">
如果你需要在 TypeScript 类内部获取前一个元素的引用,你可以使用 @ViewChild('stockSymbol') 装饰器,以下列表中的应用程序展示了如何做到这一点。注意,你只导入那些你实际使用的 RxJS 成员。
列表 6.2. fromevent/app.component.ts
import {AfterViewInit, Component, ElementRef, ViewChild} from '@angular/core';
import {Observable} from "rxjs";
import {debounceTime, map} from 'rxjs/operators';
@Component({
selector: "app-root",
template: `
<h2>Observable events</h2>
<input type="text" #stockSymbol placeholder="Enter stock" >
`
})
export class AppComponent implements AfterViewInit {
@ViewChild('stockSymbol') myInputField: ElementRef; *1*
ngAfterViewInit() { *2*
let keyup$: =
Observable.fromEvent(this.myInputField.nativeElement, 'keyup'); *3*
let keyupValue$ = keyup$
.pipe(
debounceTime(500), *4*
map(event => event['target'].value)) *5*
.subscribe(stock => this.getStockQuoteFromServer(stock)); *6*
}
getStockQuoteFromServer(stock: string) {
console.log(`The price of ${stock} is
${(100 * Math.random()).toFixed(4)}`); *7*
}
}
-
1 声明一个名为 myInputField 的属性,该属性包含对 字段的引用
-
2 将代码放置在 ngAfterViewInit() 组件生命周期方法中
-
3 从 keyup 事件创建可观察对象
-
4 等待可观察对象发射暂停 500 毫秒
-
5 将 DOM 事件转换为 target.value 属性,该属性包含用户输入的股票代码
-
6 对可观察对象发出的每个值调用 getStockQuoteFromServer() 方法
-
7 打印生成的随机股票价格
小贴士
从 Angular 6 开始,不再使用 Observable.fromEvent(),只需写 fromEvent()。
| |
注意
在 列表 6.2 中,订阅事件的代码放置在 ngAfterViewInit() 组件生命周期方法中,Angular 在组件的 UI 初始化时调用此方法。你将在第九章 ch09 的 第 9.2 节 中了解组件生命周期方法。
你可以通过运行以下命令来查看此代码示例的实际效果:
ng serve --app fromevent -o
打开浏览器的控制台并开始输入股票代码。根据你打字的速度,你将在控制台中看到一条或几条报告股票价格的消息。
你可以将任何 DOM 事件转换为可观察对象,但直接使用 ElementRef 访问 DOM 是不被推荐的,因为这可能会带来一些安全漏洞(有关详细信息,请参阅 angular.io/api/core/ElementRef)。那么,更好的方法是订阅 DOM 对象中的值变化吗?
6.3. 使用表单 API 处理可观察事件
Angular 表单 API(在第十章 chapters 10 和第十一章 11 中介绍)提供了现成的可观察对象,用于推送有关整个表单或表单控件发生的所有重要事件的通告。以下有两个示例:
-
valueChanges— 这个属性是一个可观察对象,当表单控件的值发生变化时,它会发出数据。 -
statusChanges— 这个属性是一个可观察对象,它发出表单控件或整个表单的有效性状态。状态从有效变为无效或反之亦然。
在本节中,我们将向您展示如何使用 valueChanges 属性与 HTML <input> 元素一起使用。
FormControl 类是表单处理的基本块之一,它表示一个表单控件。默认情况下,每当表单控件的值发生变化时,底层的 FormControl 对象会通过其 valueChanges 属性(类型为 Observable)发出一个事件,你可以订阅它。
让我们通过使用表单 API 订阅 <input> 字段的 input 事件并生成股票报价来重写上一节的 app。表单元素可以通过 formControl 指令绑定到组件属性,你将使用它而不是直接访问 DOM 对象。
以下列表在调用 subscribe() 之前应用了 RxJS debounceTime 操作符,指示 this.searchInput.valueChanges 可观察对象在用户 500 毫秒内没有输入任何内容时发出数据。
列表 6.3. formcontrol/app.component.ts
import {Component} from '@angular/core';
import {FormControl} from '@angular/forms';
import {debounceTime} from 'rxjs/operators;
@Component({
selector: 'app-root',
template: `
<h2>Observable events from formcontrol</h2>
<input type="text" placeholder="Enter stock"
[formControl]="searchInput"> *1*
`
})
export class AppComponent {
searchInput = new FormControl('');
constructor() {
this.searchInput.valueChanges *2*
pipe(debounceTime(500)) *3*
.subscribe(stock => this.getStockQuoteFromServer(stock)); *4*
}
getStockQuoteFromServer(stock: string) {
console.log(`The price of ${stock} is ${(100 * Math.random()).toFixed(4)}
`);
}
}
-
1 将此 元素链接到组件属性 searchInput
-
2 valueChanges 属性是一个可观察对象。
-
3 在发出
<input>元素的内容之前等待 500 毫秒的静默时间 -
4 订阅可观察对象
您的 subscribe() 方法为 Observer 提供了一个方法(没有错误或流完成处理程序)。searchInput 控制器生成的流中的每个值都传递给 getStockQuoteFromServer() 方法。在现实世界的场景中,此方法会向服务器发出请求(您将在 第 6.4 节 中看到这样的应用程序),但您的方法只是生成并打印一个随机的股票价格。
如果您没有使用 debounceTime 操作符,valueChanges 将会在用户每次输入字符后发出值。图 6.1 展示了您启动此应用程序并在输入字段中输入 AAPL 后会发生什么。
图 6.1. 获取 AAPL 的价格

要查看此应用程序的实际运行情况,请在终端中运行以下命令:
ng serve --app formcontrol -o
注意
您可能会争辩说,您可以通过简单地绑定到 change 事件来实现此示例,该事件会在用户完成输入股票符号并将焦点从输入字段移出时触发。这是真的,但在许多场景中,您可能希望从服务器获得即时响应,例如在用户输入时检索和过滤数据集合。
在 代码示例 6.3 中,您没有向服务器发送任何网络请求以获取价格报价——您在用户的计算机上生成随机数。即使用户输入了错误的股票符号,此代码示例也会调用 Math.random(),这对应用程序的性能影响微乎其微。在现实世界的应用程序中,用户的输入错误可能会生成网络请求,在返回错误输入的股票符号的报价时引入延迟。您将如何处理丢弃不想要的请求的结果?
6.4. 使用 switchMap 抛弃不想要的 HTTP 请求的结果
可观察对象相较于承诺的优势之一是可观察对象可以被取消。在前一节中,我们提供了一个场景,即一个打字错误可能导致服务器请求返回不希望的结果。实现主从视图是请求取消的另一个用例。比如说,当用户点击产品列表中的一行以查看必须从服务器检索的产品详情时。然后他们改变主意并点击另一行,这会发出另一个服务器请求;在这种情况下,理想情况下应该丢弃挂起的请求的结果。
在 Angular 中,HTTP 请求返回可观察对象。让我们看看如何通过创建一个在用户在输入字段中输入时发出 HTTP 请求的应用程序来丢弃挂起的 HTTP 请求的结果。我们将使用两个可观察流:
-
由搜索
<input>字段产生的可观察流 -
用户在搜索字段中输入时产生的 HTTP 请求的可观察流
对于这个示例,您将使用位于 openweathermap.org 的免费天气服务,该服务为全球各地的城市提供天气请求的 API。要使用此服务,请访问 OpenWeatherMap 并接收一个应用程序 ID (appid)。此服务以 JSON 格式返回天气信息。例如,要获取伦敦当前的华氏温度 (units=imperial),URL 可能如下所示:api.openweathermap.org/data/2.5/find?q=London&units=imperial&appid=12345。
您将通过将基本 URL 与输入的城市名称和应用程序 ID 连接来构建请求 URL。当用户输入城市名称的字母时,代码会订阅事件流并发出 HTTP 请求。如果在上一个响应返回之前发出新的请求,switchMap 操作符(在 附录 D 中的 第 D.8 节 解释)将取消并丢弃先前的内部可观察对象(因此先前的 HTTP 请求的结果永远不会到达浏览器),并将新的请求发送到这个天气服务。以下列表中的示例还使用了 FormControl 指令从用户输入城市名称的输入字段生成可观察的流。
列表 6.4. weather/app.component
@Component({
selector: "app-root",
template: `
<h2>Observable weather</h2>
<input type="text" placeholder="Enter city" [formControl]="searchInput">
<h3>{{weather}}</h3>
`
})
export class AppComponent implements OnInit{
private baseWeatherURL = 'http://api.openweathermap.org/data/2.5/weather?q=';
private urlSuffix: = "&units=imperial&appid=12345";
searchInput = new FormControl();
weather: string;
constructor(private http: HttpClient) {}
ngOnInit() { *1*
this.searchInput.valueChanges
.pipe(switchMap(city => this.getWeather(city))) *2*
.subscribe(
res => {
this.weather = *3*
`Current temperature is ${res['main'].temp}F, ` +
`humidity: ${res['main'].humidity}%`;
},
err => console.log(`Can't get weather. Error code: %s, URL: %s`,
err.message, err.url)
);
}
getWeather(city: string): Observable<any> { *4*
return this.http.get(this.baseWeatherURL + city + this.urlSuffix)
.pipe(catchError( err => { *5*
if (err.status === 404){
console.log(`City ${city} not found`);
return Observable.empty()} *6*
})
);
}
}
-
1 在
ngOnInit()中创建订阅,该函数在组件属性初始化后调用 -
2
switchMap操作符从输入字段中获取输入值(第一个可观察对象)并将其传递给getWeather()方法,该方法向天气服务发出 HTTP 请求。 -
3 使用温度和湿度信息初始化天气变量
-
4
getWeather()方法构建 URL 并定义 HTTP GET 请求。 -
5 如果用户输入的城市不存在,则拦截错误
-
6 为了使应用程序继续运行,在发生 404 错误时返回一个空的观察对象
小贴士
从 TypeScript 2.7 开始,您需要在声明或构造函数中初始化类变量:例如,weather = ''。如果您不想这样做,请将 TypeScript 编译器的 strictPropertyInitialization 选项设置为 false。
注意 列表 6.4 中的两个可观察对象:
-
FormControl指令从输入字段事件(this.searchInput.valueChanges)创建一个可观察对象。 -
getWeather()也返回一个可观察对象。
当外部可观察对象(在这种情况下为 FormControl)生成数据传递给内部可观察对象(getWeather() 函数)时,我们经常使用 switchMap 操作符:可观察对象 1 -> switchMap(function) -> 可观察对象 2 -> subscribe()。
如果 Observable1 推送了新值,但内部 Observable2 还没有完成,Observable2 会被取消。我们正在从当前的内部观察者切换到新的观察者,switchMap操作符会取消订阅挂起的 Observable2 并重新订阅以处理 Observable1 产生的新值。
在列表 6.4 中,如果 UI 的观察者流在getWeather()返回的观察者发出值之前推送了下一个值,switchMap会杀死getWeather()的观察者,从 UI 获取城市的新值,并再次调用getWeather()。取消getWeather()会导致HttpClient丢弃缓慢且未及时完成的挂起 HTTP 请求的结果。
subscribe()方法只有一个用于处理来自服务器的数据的回调,其中您可以从返回的 JSON 中提取温度和湿度。如果用户请求一个不存在的城市,这个天气服务提供的 API 会返回 404。您在catchError操作符中拦截并处理这个错误。想象一下,一个打字慢的用户在尝试查找伦敦的天气时输入了Lo。对Lo的 HTTP 请求发出,返回了 404,然后您创建了一个空的观察者,以便subscribe()方法得到一个空的结果,这并不是一个错误。
要运行此应用,您需要首先在api.openweathermap.org获取您自己的密钥(需要一分钟),并将列表 6.4 中的代码中的 12345 替换为您自己的密钥。然后您可以使用以下命令运行此应用:
ng serve --app weather -o
浏览器将在 http://localhost:4200 打开应用,渲染一个包含单个输入字段的窗口,您可以在其中输入城市名称。图 6.2 显示了在具有快速 200 Mbps 互联网连接的计算机上键入单词London时的网络流量。
图 6.2. 不进行节流获取天气

在这种情况下,进行了六次 HTTP 请求并返回了 HTTP 响应。阅读前两行的查询。对于城市L和Lo的请求返回了 404。但对于Lon、Lond、Londo和London的请求则成功完成,每个请求都返回了数百字节,不必要地拥塞了网络。将这些字节加起来——总共是 3,134 字节,但在快速网络上的用户甚至都不会注意到这一点。
现在让我们模拟一个慢速网络并验证丢弃不需要的结果是否有效。在慢速互联网连接上,每个 HTTP 请求需要超过 200 毫秒才能完成,但用户一直在打字,挂起的 HTTP 请求的响应应该被丢弃。
Chrome 开发者工具的网络标签页有一个下拉菜单,默认选项为 Online,这意味着使用完整的连接速度。现在,让我们通过选择 Slow 3G 选项来模拟慢速连接。重新输入单词 London 会产生多个 HTTP 请求,但现在连接变慢了,挂起的请求的结果被丢弃,并且永远不会到达浏览器,如图 6.3 所示。请注意,这次你收到了 789 字节,这比 3,134 字节要好得多。
图 6.3. 使用节流获取天气

通过非常少的编程,你可以通过消除浏览器处理你感兴趣的城市的四个 HTTP 响应的需求来节省带宽,这些城市可能甚至不存在。只需添加一行 switchMap,你就能完成很多事情。确实,使用好的框架或库,你写的代码更少。Angular 管道还允许你用更少的手动编码实现更多功能,在下一节中,你将了解 AsyncPipe,它将消除调用 subscribe() 的需求。
6.5. 使用 AsyncPipe
第 2.5 节 在 第二章 中介绍了管道,它们用于组件模板中,可以在模板内直接转换数据。例如,DatePipe 可以将日期转换为指定的格式并显示。管道放置在模板中的竖线之后,例如:
<p> Birthday: {{birthday | date: 'medium'}}</p>
在此代码片段中,birthday 是一个类型为 Date 的组件属性。Angular 提供了一个 AsyncPipe,它可以接受类型为 Observable 的组件属性,自动订阅它,并在模板中渲染结果。
下一个列表声明了一个类型为 Observable<number> 的 numbers 变量,并用一个每秒发出连续数字的观察者初始化它。take(10) 操作符将限制发射到前 10 个数字。
列表 6.5. asyncpipe/app.component.ts
import {Component} from '@angular/core';
import 'rxjs/add/observable/interval';
import {take} from 'rxjs/operators';
import {Observable} from "rxjs";
@Component({
selector: "app-root",
template: `{{numbers$ | async}}` *1*
})
export class AppComponent {
numbers$: Observable<number> =
Observable.interval(1000) *2*
.pipe(take(10)); *3*
}
-
1 自动订阅可观察的数字
-
2 每秒发出连续数字
-
3 只取 0 到 9 之间的 10 个数字
提示
从 Angular 6 开始,不再使用 Observable.interval(),只需写 interval()。
如 附录 D 中所述,要从可观察对象获取数据,我们需要调用 subscribe() 方法。在 列表 6.5 中,没有显式调用 subscribe(),但请注意模板中的 async 管道。async 管道会自动订阅 numbers 可观察对象,并显示可观察对象推送的从 0 到 9 的数字。要查看此示例的实际效果,请运行以下命令:
ng serve --app asyncpipe -o
这是一个相当简单的例子,它永远不会抛出任何错误。在现实世界的应用中,事情会发生,你应该使用 catch 操作符向可观察对象添加错误处理,就像你在上一节中的天气示例中所做的那样。
现在,让我们考虑另一个使用async管道的应用程序。这次,你将调用一个返回产品观察者数组的函数,并使用async管道来渲染其值。此应用程序将使用可注入的ProductService,其getProducts()方法返回Product对象的观察者数组,如以下列表所示。
列表 6.6. asyncpipe-products/product.service.ts
import {Injectable} from '@angular/core';
import {Observable} from "rxjs";
import 'rxjs/add/observable/of';
export interface Product { *1*
id: number;
title: string;
price: number
}
@Injectable()
export class ProductService {
products: Product[] = [ *2*
{id: 0, title: "First Product", price: 24.99},
{id: 1, title:"Second Product", price: 64.99},
{id: 2, title:"Third Product", price: 74.99}
];
getProducts(): Observable<Product[]> {
return Observable.of(this.products); *3*
}
}
-
1 定义产品类型
-
2 填充产品数组
-
3 将产品数组转换为观察者
提示
从 Angular 6 开始,只需写of()而不是Observable.of()。
下一个列表显示了获取ProductService注入并调用getProducts()的应用组件,它返回一个观察者。请注意,那里没有显式调用subscribe()——你在模板中使用async管道。在这个组件中,你使用 Angular 的结构指令*ngFor遍历产品,并为每个产品渲染带有产品标题和价格的<li>元素,如以下列表所示。
列表 6.7. asyncpipe-products/app.component.ts
import {Component} from '@angular/core';
import {Product, ProductService} from "./product.service";
import {Observable} from "rxjs";
@Component({
selector: "app-root",
template: `
<ul>
<li *ngFor="let product of products$ | async"> *1*
{{product.title}} {{product.price}} *2*
</li>
</ul>
`
})
export class AppComponent {
products$: Observable<Product[]>; *3*
constructor(private productService: ProductService) {}
ngOnInit() {
this.products$ = this.productService.getProducts(); *4*
}
}
-
1 遍历产品并将它们通过异步管道进行订阅
-
2 渲染产品标题和价格
-
3 使用泛型语法声明观察者
products$以进行类型检查 -
**4 将值分配给
products$**
重要的是要理解getProducts()函数返回一个空的观察者(observable),它还没有发出任何内容,并且你将其分配给products$变量。在订阅products$之前,不会向此组件推送任何数据,并且async管道在模板中执行此操作。
要查看此应用程序的实际运行情况,请运行以下命令:
ng serve --app asyncpipe-products -o
图 6.4 显示了浏览器将如何渲染产品。
图 6.4. 渲染观察者产品

由于我们正在讨论管道,让我们应用 Angular 内置的currency管道来显示美元价格。只需在product.price之后添加currency管道即可:
{{product.title}} {{product.price | currency : "USD"}}
你可以在angular.io/api/common/CurrencyPipe上阅读更多关于currency管道及其参数的信息。图 6.5 显示了浏览器在应用货币管道后如何渲染美元产品。
图 6.5. 渲染观察者产品

使用 async as
使用异步管道(async pipes),你可以使用特殊的语法async as来避免在模板中创建多个订阅。考虑以下代码,它在模板中创建了两个订阅,假设存在一个名为product$的观察者:
<div>
<h4>{{ (product$ | async).price}}</h4> *1*
<p>{{ (product$ | async).description }}</p> *2*
</div>
-
1 第一次订阅
-
2 第二次订阅
以下代码通过创建一个局部模板变量product,该变量将存储单个订阅的引用,并在同一模板的多个位置重用它来重写之前的代码:
<div *ngIf="product$ | async as product"> *1*
<h4>{{ product.price}}</h4> *2*
<p>{{ prod *2*
-
1 创建订阅并将其存储在产品变量中
-
2 使用名为 product 的订阅
现在,让我们看看在路由器导航过程中如何使用可观察属性。
6.6. 可观察属性和路由器
Angular 路由器在多个类中提供了可观察属性。有没有简单的方法可以找到它们?最快的方法是打开你感兴趣的类的类型定义文件(见附录 B(kindle_split_026.xhtml#app02))。通常,IDEs 在上下文(右键单击)菜单中提供选项,以便转到所选类的声明。让我们以 ActivatedRoute 类为例,看看它的声明。它位于 router_state.d.ts 文件中(我们为了简洁起见删除了一些内容),如下所示。
列表 6.8. ActivatedRoute 的一个片段
export declare class ActivatedRoute {
url: Observable<UrlSegment[]>;
queryParams: Observable<Params>;
fragment: Observable<string>;
data: Observable<Data>;
snapshot: ActivatedRouteSnapshot;
...
readonly paramMap: Observable<ParamMap>;
readonly queryParamMap: Observable<ParamMap>;
}
在第三章的第 3.4 节(kindle_split_012.xhtml#ch03lev1sec4)中,你将 ActivatedRoute 注入到 ProductDetailComponent 中,以便在导航期间接收路由参数。当时,你使用了 ActivatedRoute 的 snapshot 属性来获取父路由的值。如果你需要获取永远不会改变的参数,这种技术效果很好。但是,如果父路由中的参数随时间变化,你需要订阅一个可观察的属性,如 paramMap。
为什么父参数的值会改变?想象一个显示产品列表的组件,当用户选择一个产品时,应用会导航到显示产品详情的路由。通常,这些用例被称为 主-详细信息通信。
当用户第一次点击产品时,路由器执行以下步骤:
1. 实例化
ProductDetailComponent2. 将
ProductDetailComponent组件附加到 DOM 对象3. 在路由出口处渲染
ProductDetailComponent组件4. 将参数(例如,产品 ID)传递给
ProductDetailComponent
如果用户在父组件中选择另一个产品,前三个步骤将不会执行,因为 ProductDetailComponent 已经实例化,附加到 DOM 上,并且由浏览器渲染。路由器将只传递一个新的产品 ID 到 ProductDetailComponent,这就是为什么订阅 paramMap 是最佳做法。以下列表实现了这个场景,从 AppComponent 开始。
列表 6.9. master-detail/app.component.ts
interface Product { *1*
id: number;
description: string;
}
@Component({
selector: 'app-root',
template: `
<ul style="width: 100px;">
<li *ngFor="let product of products"
[class.selected]="product === selectedProduct"
(click) = onSelect(product)> *2*
<span>{{product.id}} {{product.description}} </span>
</li>
</ul>
<router-outlet></router-outlet>
`,
styles:[`.selected {background-color: cornflowerblue}`]
})
export class AppComponent {
selectedProduct: Product;
products: Product[] = [
{id: 1, description: "iPhone 7"},
{id: 2, description: "Samsung 7"},
{id: 3, description: "MS Lumina"}
];
constructor(private _router: Router) {} *3*
onSelect(prod: Product): void {
this.selectedProduct = prod;
this._router.navigate(["/productDetail", prod.id]); *4*
}
}
-
1 定义产品类型
-
2 当用户选择一个项目时,调用 onSelect() 处理程序
-
3 注入路由器以便可以使用其 navigate() 方法
-
4 导航到产品详情路由
此应用的路由配置如下:
[
{path: 'productDetail/:id', component: ProductDetailComponent}
]
以下列表展示了 ProductDetailComponent 订阅 paramMap 的代码。
列表 6.10. master-detail/product.detail.component.ts
@Component({
selector: 'product',
template: `<h3 class="product">Details for product id {{productId}}</h3>`,*1*
styles: ['.product {background: cyan; width: 200px;} ']
})
export class ProductDetailComponent {
productId: string;
constructor(private route: ActivatedRoute) {
this.route.paramMap
.subscribe( *2*
params => this.productId = params.get('id')); *3*
}
}
-
1 将 productId 的值嵌入到标题中
-
2 订阅 paramMap 可观察属性
-
3 从当前产品 ID 中提取并将其分配给 productId 属性以在 UI 中显示
现在 ProductDetailComponent 将根据用户选择渲染识别当前产品的文本。图 6.6 展示了用户在列表中选择第二个产品后 UI 的外观。要查看此应用程序的实际运行情况,请运行以下命令:
ng serve --app master-detail -o
图 6.6. 实现主从场景

在 第七章 中,您将重写 ngAuction,您将看到 Flex Layout 库中的 ObservableMedia 类如何通知您屏幕尺寸的变化(例如,用户减小窗口宽度)。此可观察对象在根据智能手机和平板电脑等较小设备的视口宽度更改 UI 布局时也非常方便。
摘要
-
使用可观察的数据流简化了异步编程。您可以根据需要订阅和取消订阅流。
-
使用
async管道是订阅可观察对象的首选方式。 -
async管道会自动取消订阅可观察对象。 -
使用
switchMap操作符结合HttpClient可以让您轻松丢弃挂起 HTTP 请求的不想要的输出。
第七章. 使用 Flex Layout 布局页面
本章涵盖
-
使用 Flex Layout 库实现响应式网页设计
-
使用
ObservableMedia服务 -
根据视口大小更改布局
当涉及到开发网络应用程序时,你需要决定你是否将为桌面和移动版本创建单独的应用程序,或者是否在所有设备上重用相同的代码。前者方法允许你使用移动设备上的原生控件,从而使 UI 看起来更自然,但你需要为每个应用程序维护代码的单独版本。后者方法是使用单个代码库并实现 响应式网页设计(RWD),以便 UI 布局将适应设备屏幕尺寸。
注意
术语 RWD 是由 Ethan Marcotte 在文章“Responsive Web Design”(可在 alistapart.com/article/responsive-web-design 查找)中提出的。
有第三种方法:除了你的在桌面上工作的网络应用程序之外,开发一个 混合 应用程序,这是一个在移动浏览器中运行但也可以调用移动设备原生 API 的网络应用程序。
在本章中,你将了解如何使用响应式设计(RWD)方法使你的应用程序在大屏幕和小屏幕上看起来美观且功能齐全。第六章 介绍了当应用程序中发生某些重要事件时可以推送通知的可观察对象。让我们看看你是否可以使用可观察对象来通知你用户屏幕尺寸的变化,并根据用户设备视口的宽度更改 UI 布局。使用智能手机的用户和使用大屏幕的用户应该看到相同应用程序的不同布局。
我们将向你展示如何使用 Flex Layout 库来实现 RWD,以及如何使用其 ObservableMedia 服务来节省你编写大量 CSS。
最后,你将开始重写 ngAuction 应用程序,展示你所学到的许多技术,主要目标是移除应用程序中的 Bootstrap,仅使用 Angular Material 和 Flex Layout 库。
7.1. Flex Layout 和 ObservableMedia
想象一下,你已经布局了应用程序的 UI,它在用户具有 1200 像素或更宽的宽度分辨率的显示器上看起来很棒。如果用户在具有 640 像素视口宽度的智能手机上打开此应用程序会发生什么?根据设备的不同,它可能只会渲染应用程序 UI 的一部分,在底部添加一个水平栏,或者缩小 UI 以适应小视口,使应用程序难以使用。或者考虑另一种场景:具有大屏幕的用户缩小浏览器窗口,因为他们需要在显示器上放置另一个应用程序。
要实现 RWD,您可以使用 CSS 媒体查询,由 @media 规则表示。在您的应用 CSS 中,您可以包含一组媒体查询,为不同的屏幕宽度提供不同的布局。浏览器会持续检查当前窗口宽度,一旦宽度超过 @media 规则中设置的 断点(例如,宽度小于 640 像素),就会应用新的页面布局。
实现灵活布局的另一种方法是使用 CSS Flexbox 和媒体查询(参见 mng.bz/6B42)。您的应用 UI 被设计成一系列灵活的盒子,如果浏览器无法水平(或垂直)地容纳 Flexbox 内容,内容将被渲染在下一行(或列)中。
您还可以借助 CSS Grid 实现 RWD(参见 mng.bz/k29F)。Flexbox 和 CSS Grid 都需要您对 @media 规则有良好的理解。
Angular Flex Layout 库(参见 github.com/angular/flex-layout)是一个 UI 布局引擎,用于实现响应式 Web 设计,而无需在您的 CSS 文件中编写媒体查询。该库提供了一套简单的 Angular 指令,内部应用 flexbox 布局规则,并提供 ObservableMedia 服务,该服务通知您的应用用户设备视口的当前宽度。
Angular Flex Layout 相比于标准 CSS API 具有以下优势:
-
它生成跨浏览器的 CSS。
-
它提供了一个对 Angular 友好的 API,用于使用指令和可观察者处理媒体查询。
注意
在本节中,我们提供了 Flex 布局库的简要描述,以便您快速入门。有关更多详细信息和大纲,请参阅 Flex 布局文档,网址为 github.com/angular/flex-layout/wiki。
Flex 布局库提供了两个 API:静态和响应式。静态 API 允许您使用指令来指定容器及其子元素的布局属性。响应式 API 增强了静态 API 指令,使您能够实现响应式 Web 设计(RWD),以便应用布局根据不同的屏幕尺寸进行变化。
7.1.1. 使用 Flex 布局指令
Flex 布局库中有两种类型的指令:一种用于容器,另一种用于其子元素。容器的指令用于对其子元素进行对齐。子指令应用于由 Flex 布局管理的容器的子元素。使用子指令,您可以指定每个子元素的顺序、它所占的空间量以及一些其他属性,如 表 7.1 所示。
表 7.1. 常用 Flex 布局指令
| 指令 | 描述 |
|---|---|
| 容器指令 |
|
- fxLayout
| 指令指示元素使用 CSS Flexbox 来布局子元素。 |
|---|
|
- fxLayoutAlign
| 以特定方式(向左、向底、均匀分布等)对齐子元素。允许的值取决于附加到同一容器元素的 fxLayout 值——请参阅 Angular Flex Layout 文档。 |
|---|
|
- fxLayoutGap
| 控制子元素之间的空间。 |
|---|
| 子指令 |
|
- fxFlex
| 控制子元素在父容器中占据的空间量。 |
|---|
|
- fxFlexAlign
| 允许根据 fxLayoutAlign 指令在父容器中选择性更改子元素的定位。 |
|---|
|
- fxFlexOrder
| 允许更改子元素在父容器中的顺序。例如,当从桌面切换到移动屏幕时,可以使用它将重要组件移动到可见区域。 |
|---|
注意
子指令期望位于一个带有容器指令的 HTML 元素内部。
让我们看看如何使用 Flex Layout 库将两个<div>元素在一行中并排对齐。首先,你需要将 Flex Layout 库及其依赖项@angular/cdk添加到你的项目中:
npm i @angular/flex-layout @angular/cdk
下一步是将FlexLayoutModule添加到根@NgModule()装饰器中,如下所示。
列表 7.1. 添加FlexLayoutModule
import {FlexLayoutModule} from '@angular/flex-layout';
@NgModule({
imports: [
FlexLayoutModule
//...
]
})
export class AppModule {}
以下列表创建了一个组件,该组件从左到右显示相邻的<div>元素。
列表 7.2. flex-layout/app.component.ts
@Component({
selector: 'app-root',
styles: [`
.parent {height: 100px;}
.left {background-color: cyan;}
.right {background-color: yellow;}
`],
template: `
<div class="parent" fxLayout="row" > *1*
<div fxFlex class="left">Left</div> *2*
<div fxFlex class="right">Right</div> *2*
</div>
`
})
export class AppComponent {}
-
1 fxLayout 指令将
转换为 flex 布局容器,其中子元素水平分配(在一行中)。 -
2 fxFlex 指令指示每个子元素在父容器中占据相等的空间。
要查看此应用程序的实际效果,请运行以下命令:
ng serve --app flex-layout -o
图 7.1 展示了浏览器如何渲染子元素。每个子元素占据容器可用宽度的 50%。
图 7.1. 两元素在一行中对齐

要使右 div 比左 div 占据更多空间,你可以将所需的空间值分配给子fxFlex指令。以下模板使用子级指令fxFlex将 30%的可用宽度分配给左子元素,70%分配给右子元素:
<div fxLayout="row" class="parent">
<div fxFlex="30%" class="left">Left</div>
<div fxFlex="70%" class="right">Right</div>
</div>
现在,UI 渲染如图图 7.2 所示。
图 7.2. 右元素比左元素占据更多空间。

要将容器子元素的布局从行更改为列,将容器的布局方向从行更改为列,如fxLayout="column":
<div fxLayout="column" class="parent">
<div fxFlex="30%" class="left">Left</div>
<div fxFlex="70%" class="right">Right</div>
</div>
图 7.3 展示了子元素如何垂直渲染。
图 7.3. 容器元素的列布局

说到在宽屏上,你有足够的空间水平地渲染左右组件并排,但如果用户在较小的屏幕上打开相同的应用程序,你希望自动更改布局为垂直,以便右组件显示在左组件下方。
Flex 布局库中的每个指令都可以选择性地有一个 后缀(媒体查询规则的别名),用于指定它应该应用于哪个屏幕尺寸。例如,flexLayout.sm 指令的后缀是 .sm,这意味着它只应在屏幕宽度较小时应用。这些别名对应于在《Material Design 指南》中定义的宽度断点(见 mng.bz/RmLN):
-
xs— 超小(小于 599 像素) -
sm— 小(560–959 像素) -
md— 中等(960–1279 像素) -
lg— 大(1280–1919 像素) -
xl— 超大(1920–5000 像素)
下一个列表更改了您的应用程序,使其父容器在中等和大型屏幕上水平排列其子元素,而在小设备上垂直排列。
列表 7.3. 添加 .sm 后缀
<div class="parent"
fxLayout="row" *1*
fxLayout.sm="column" > *2*
<div fxFlex="30%" class="left">Left</div>
<div fxFlex="70%" class="right">Right</div>
</div>
-
1 默认情况下,子元素按行对齐。
-
2 在小屏幕尺寸下,子元素垂直对齐。
为了说明这将如何改变布局,您将使用 Chrome 开发者工具,它在其工具栏左侧有一个图标,允许您切换设备。对于桌面,小尺寸意味着窗口宽度在 600 到 959 像素之间。图 7.4 显示了宽度为 960(仍然是中等尺寸)时的 UI 渲染。
图 7.4. 在宽度为 960 像素的中等设备上的渲染

让我们越过断点,将宽度更改为 959 以模拟小设备。图 7.5 显示布局已从水平变为垂直。
图 7.5. 在宽度为 959 像素的小设备上的渲染

将宽度更改为小于 600 的任何值将导致切换回水平布局,因为您没有指定对于超小设备(.xs 后缀),布局应保持垂直。您可以添加超小(xs)设备的垂直布局:
<div fxLayout="row" class="parent"
fxLayout.sm="column"
fxLayout.xs="column">
您还可以将小于(lt-)和大于(>-)后缀应用于媒体查询别名。例如,如果您使用 lt-md 别名,相应的布局将应用于小屏幕和超小屏幕。在您的应用程序中,您可以指定在宽度小于中等的任何屏幕上,应应用列布局:
<div fxLayout="row" class="parent"
fxLayout.lt-md="column">
使用断点,您可以在组件模板中静态定义您的 UI 应如何布局。如果您不仅想更改容器内的布局,还想根据屏幕尺寸有条件地显示或隐藏某些子元素,怎么办?为了根据屏幕尺寸动态决定浏览器应如何渲染,您将使用 Flex Layout 库提供的 ObservableMedia 服务。
7.1.2. ObservableMedia 服务
ObservableMedia 服务允许订阅屏幕大小变化并程序化地更改应用程序的外观和感觉。例如,在大型屏幕上,你可能会决定显示额外信息。为了避免在小屏幕上渲染不必要的组件,你可以订阅 ObservableMedia 发射的事件,如果屏幕大小变得更大,你可以渲染更多组件。
要实现此功能,导入 ObservableMedia 服务并订阅其 Observable 对象。以下列表显示了如何使用 async 管道订阅关于屏幕大小变化的通知,并在控制台打印当前大小。
列表 7.4. observable-media/app.component.ts
import {Component} from '@angular/core';
import {ObservableMedia} from '@angular/flex-layout';
import {Observable} from 'rxjs';
import {map} from 'rxjs/operators';
@Component({
selector: 'app-root',
template: `<h3>Watch the breakpoint activation messages in the console.
</h3>
<span *ngIf="showExtras$ | async"> *1*
Showing extra info on medium screens</span>
`
})
export class AppComponent {
showExtras$: Observable<boolean>;
constructor(private media: ObservableMedia) { *2*
this.showExtras$ = this.media.asObservable() *3*
.pipe(map(mediaChange => {
console.log(mediaChange.mqAlias);
return mediaChange.mqAlias === 'md'? true: false; *4*
})
);
}
}
-
1 根据 showExtras$ 的值显示/隐藏文本;async 管道订阅 showExtras$*
-
2 注入 ObservableMedia 服务*
-
3 订阅屏幕大小变化时发射的 Observable*
-
4 showExtras$ 在屏幕为中等时发射 true。*
注意
注意 *ngIf 结构指令的使用。如果 showExtras$ 可观察对象发射 true,则将 span 添加到 DOM 中。如果它发射 false,则从 DOM 中移除 span。
media.asObservable() 发射的值具有 MediaChange 类型,该类型包含 mqAlias 属性,它包含表示当前宽度的值——lg 表示大或 md 表示中等。
要查看 列表 7.4 的实际效果,运行以下命令,并打开浏览器的控制台:
ng serve --app observable-media -o
当屏幕大小为 md(中等)时,你会看到文本“在中等屏幕上显示额外信息”。将浏览器窗口的宽度缩小到 sm 大小,此文本将被隐藏。要查看当前的 CSS 媒体查询和 mediaChange 类的其他属性,请将日志语句更改为 console.log(mediaChange);。
在 列表 7.4 中,你明确声明了一个 showExtras$ 可观察对象并订阅它,因为你想要监控 MediaChange。但可以通过使用以下列表中所示的 ObservableMedia.isActive() API 来简化此代码。
列表 7.5. 使用 ObservableMedia.isActive() API
import {Component} from '@angular/core';
import {ObservableMedia} from '@angular/flex-layout';
@Component({
selector: 'app-root',
template: `<h3>Using the ObservableMedia.isActive() API</h3>
<span *ngIf="this.media.isActive('md')"> *1*
Showing extra info on medium screens</span>
`
})
export class AppComponent {
constructor(public media: ObservableMedia) {}
}
- 1 仅在当前视口宽度为 md 时显示文本*
在本章后面的实践部分,你将创建 ngAuction 的新版本,该版本将使用 Flex 布局库和 ObservableMedia 实现响应式设计。
实现响应式设计的其他选项
Flex 布局库可能对初学者有吸引力,因为它易于使用。但它在 Angular 应用中创建响应式布局的解决方案并非唯一。这里有一些其他选项:
-
Angular CDK(组件开发套件)包包括布局模块。安装了
@angular/cdk包之后,你可以使用LayoutModule和BreakpointObserver或MediaMatcher类来监控视口大小的变化。此外,由于 Angular CDK 是 Flex 布局的同级依赖,通过直接使用 Angular CDK,你将使用一个库而不是两个。 -
在撰写本文时,Flex Layout 库仍处于测试版,其创建者经常在新版测试版中引入破坏性更改。如果您不使用 Angular 的最新版本,Flex Layout 可能不支持您使用的 Angular 版本。例如,Flex Layout 库不支持 Angular 4。
为了最小化应用程序中使用的库的数量,考虑使用 CSS Flexbox 和 CSS Grid 实现响应式设计。此外,使用浏览器原生支持的 CSS 总是比使用任何 JavaScript 库更高效。我们推荐 Wes Bos 提供的免费 CSS Grid 视频课程,可在 cssgrid.io 获取。
7.2. 实践:重写 ngAuction
从本章开始,您将从零开始重写 ngAuction。新的 ngAuction 将从一开始就使用 Angular Material,并包括图像,而不仅仅是灰色矩形。搜索组件将由工具栏上的小图标表示,您将添加购物车功能。用户将能够对产品进行竞标,并在他们出价获胜时购买产品。
7.2.1. 为什么要从头开始重写 ngAuction 应用程序?
您可能会想,“我们已经在 第二章、第三章 和 第五章 中对 ngAuction 进行了工作。为什么不一如既往地继续构建相同的应用程序?” 在前几章中,目标是温和地介绍 Angular 框架的主要组件,而不让您被应用程序架构、实现响应式设计和定制主题的信息所压倒。
在前几章中开发的 ngAuction 应用程序很好地实现了该目标。这次重写将展示真实世界 Angular 应用程序的最佳开发实践。您希望实现以下目标:
-
创建一个模块化应用程序,其中每个视图都是一个懒加载的模块。
-
使用 Angular Material 进行 UI 设计,用 SaaS 说明了主题定制的示例。
-
使用 Flex Layout 库。
-
移除对 Bootstrap 和 JQuery 库的依赖。
-
从首页移除搜索框,以更好地利用屏幕空间。
-
将共享组件和服务保存在单独的文件夹中。
-
使用可注入服务说明状态管理,然后使用 NgRx 库重新实现。
-
创建单元测试和端到端测试的脚本。
您在本章中不会实现所有这些功能,但您将开始着手。
在这个应用程序中,您将使用 Flex Layout 库及其之前介绍的 ObservableMedia 服务来实现响应式设计。在大型屏幕上,ngAuction 的首页将每行显示四个产品,如图 7.6 所示 图 7.6。
图 7.6. 在大屏幕上渲染 ngAuction

注意
我们从 Google 应用中借用了数据和图像,展示了 Polymer 库(见 mng.bz/Y5d9)。
应用将使用 async 管道订阅 ObservableMedia 服务,并在窗口宽度变为中等大小时自动将布局更改为每行三个产品,如图 7.7 所示。
图 7.7. 在中等屏幕上渲染 ngAuction

在小屏幕上,应用将切换到两列布局,如图 7.8 所示。
图 7.8. 在小屏幕上渲染 ngAuction

应用在渲染在超小屏幕(单列布局)和超大屏幕(五列布局)时也会更改其布局。
7.2.2. 生成新的 ngAuction 应用
注意
本章的源代码可以在 github.com/Farata/angulartypescript 和 www.manning.com/books/angular-development-with-typescript-second-edition 找到。
这次,你将使用 Angular CLI 的 new 命令并带有选项来生成项目。新的 ngAuction 将使用 Sass 预处理器进行样式,并使用 SCSS 语法。你还想指定 nga- 前缀,因此每个新生成的组件在其选择器中都将具有此前缀:
ng new ng-auction --prefix nga --style scss
注意
我们将在下一节“使用 Sass 创建自定义 Angular Material 主题”中讨论使用 SCSS 的优点。
切换到 ng-auction 目录并运行以下命令以将 Angular Material 和 Flex Layout 库添加到项目中:
npm install @angular/material @angular/cdk *1*
npm i @angular/flex-layout *2*
-
1 安装 Angular Material 库和组件开发工具包。Angular Material 库还需要动画包,该包在项目生成时已被 Angular CLI 安装。
-
2 安装 Flex Layout 库
Angular Material 库附带四个预构建主题,你已经在第五章的 5.6.1 节(kindle_split_014.xhtml#ch05lev2sec5)中尝试了其中一个。但如果你需要的 UI 主题不符合预构建主题怎么办?
7.2.3. 使用 Sass 创建自定义 Angular Material 主题
如果你想要为你的应用创建一个自定义 Angular Material 主题,请阅读位于 material.angular.io/guide/theming 的主题指南。在本节中,我们只为你提供我们为自定义 ngAuction 主题创建的 .scss 文件的代码审查。
当你生成 ngAuction 应用时,你使用了 --style scss 选项。这样做是为了通知 Angular CLI 你将不会使用 CSS 文件,而是将使用语法优美的样式表(Syntactically Awesome Style Sheets),也称为 Sass(见 sass-lang.com)。Sass 是 CSS 的扩展,具有自己的预处理器。Sass 的一些优点包括以下内容:
-
变量— 将样式分配给变量并在多个样式表中重用
-
嵌套— 用于嵌套 CSS 选择器的易于编写和阅读的语法
-
混入(Mixins)— 包含变量的样式块
Sass 提供了两种语法,Sass 和 SCSS,你将在本书中使用后者。如果你单独安装 SaaS,你需要运行你的 .scss 文件通过预处理器来编译它们成常规的 .css 文件,然后再部署。但是 Angular CLI 默认支持 Sass,因此预处理器在捆绑过程中完成其工作。
SCSS 语法
这里是 SCSS 语法的快速介绍:
-
变量(Variables)— 变量名以美元符号开头。以下代码片段声明并使用了变量
$font-stack:$font-stack: Helvetica, sans-serif; body { font: 100% $font-stack; }这个变量可以在多个地方使用,如果你决定将 Helvetica 字体更改为另一个字体,你只需在一个地方进行更改,而不是在每个使用它的 .css 文件中进行更改。
-
嵌套(Nesting)— 它是一种易于阅读的语法,用于编写嵌套 CSS 选择器。以下示例展示了如何将
ul和a样式选择器嵌套在div选择器内部:div { ul { margin: 0; } a { display: block; } } -
混入(Mixins)— 混入是一块 Sass 风格的代码块。混入可以通过
@include添加到你的样式表中。混入也可以使用变量,并且可以作为带参数的函数调用,例如mat-palette($mat-red);。 -
部分(Partials)— 部分是包含代码片段的文件,旨在由其他 Sass 文件导入。部分必须以下划线开头,例如 _theme.scss。当你导入一个部分时,下划线是不需要的,例如
@import './theme';部分不会编译成单独的 CSS 文件——它们的内容只作为导入它们的 .scss 文件的一部分进行编译。 -
导入(Imports)—
@import语句允许你导入其他文件中的样式。虽然 CSS 也有一个@import关键字,但它会对每个文件进行额外的 HTTP 请求。使用 Sass,所有导入在预处理期间都会合并成一个单一的 CSS 文件,因此只需要一个 HTTP 请求来加载 CSS。
在你的 ngAuction 应用中,你将创建一个样式目录,将生成的 styles.scss 文件移到那里,并添加一个额外的部分,_theme.scss。_theme.scss 的内容如下所示。你使用在导入的文件 _theming.scss 中定义的 $mat-cyan 调色板。
列表 7.6. _theme.scss
@import '~@angular/material/theming';
$nga-primary: mat-palette($mat-cyan); *1*
$nga-accent: mat-palette($mat-cyan, A200, A100, A400); *2*
$nga-warn: mat-palette($mat-red); *3*
$nga-theme: mat-light-theme($nga-primary, $nga-accent, $nga-warn); *4*
$nga-background: map-get($nga-theme, background); *5*
$nga-foreground: map-get($nga-theme, foreground); *6*
$nga-typography: mat-typography-config(); *7*
-
1 声明一个用于主要调色板的变量,并用 $mat-cyan 调色板初始化
-
2 声明并初始化一个用于强调调色板的变量,指定 $mat-cyan 的默认、较浅和较深的色调
-
3 声明并初始化一个用于警告调色板的变量
-
4 创建主题(包含所有调色板的 Sass 对象)
-
5 声明并初始化背景调色板变量
-
6 声明并初始化前景调色板变量
-
7 声明并初始化用于排版的变量
在 _theme.scss 文件中,你使用了青色为主要的和强调调色板。你可以在 node_modules/@angular/material/_theming.scss 中找到它们的定义。
在以下列表中,你将在 styles.scss 中添加样式,从导入前面的 _theme.scss 开始。
列表 7.7. styles.scss
@import './theme';
@import url('https://fonts.googleapis.com/icon?family=Material+Icons'); *1*
@import url('https://fonts.googleapis.com/css?family=Titillium+Web:600'); *2*
@import url('https://fonts.googleapis.com/css?family=Abril+Fatface'); *3*
// Be sure that you only ever include this mixin once!
@include mat-core(); *4*
@include angular-material-theme($nga-theme); *5*
// Global styles.
html {
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
height: 100%;
}
body {
color: #212121;
background-color: #f3f3f3;
font-family: mat-font-family($nga-typography);
line-height: mat-line-height($nga-typography, body-1);
font-size: mat-font-size($nga-typography, body-1);
height: 100%;
margin: 0;
}
-
1 导入 Google Material 图标
-
2 导入 Titillium Web 字体(你将用于工具栏标题和稍后用于出价值)
-
3 导入 Abril Fatface 字体(你将用于产品标题)
-
4 导入 Angular Material 核心样式,这些样式不依赖于主题
-
5 加载在 _theme.scss 中配置的自定义主题
styles.scss 和 _theme.scss 文件定义了整个应用的全球样式,你将在 .angular-cli.json 文件的 styles 属性中指定它们。在 ngAuction 中,你还将为单个组件添加样式,_theme.scss 将在每个组件中重复使用。我们故意将样式定义分成两个文件,这样你就可以在组件中重复使用 _theme.scss(仅变量定义),而不必在 styles.scss 中重复核心样式、图像和字体。
现在自定义主题已配置,你可以开始为 ngAuction 的着陆页的 UI 工作了。
7.2.4. 在顶级组件中添加工具栏
图 7.6 展示了 ngAuction 的着陆页,其中包含 Material 工具栏和 HomeComponent。更准确地说,它包括工具栏和一个 <router-outlet> 标签,你在其中渲染 HomeComponent。让我们从创建工具栏的第一个版本开始。这个工具栏将包括左边的菜单图标,中间的 ngAuction 标志,以及右边的购物车图标。它不包括搜索按钮(你将在第十一章的 11.8 节 中添加该按钮)并且看起来像 图 7.9。
图 7.9. 工具栏

在左侧,你使用 Google Material 图标 menu,在右侧,shopping_cart。对于标志,你将 Google Material 的 gavel 图标放在类似 Angular 标志的形状上方,并将其保存为 logo.svg 文件,该文件包含在本书的源代码中。
如你在第五章的动手实践部分所学,要使用 Angular Material 组件,你应该在应用的根模块的 imports 部分中包含相应的模块。在你的工具栏中,你需要 MatToolbarModule、MatButtonModule 和 MatIconModule。由于你将使用 Flex Layout 库,你还需要将 FlexLayoutModule 添加到根模块。稍后在本节中,你将使用 HttpClient 读取产品数据,因此需要将 HttpClientModule 添加到根模块。
将 CLI 生成的 app.module.ts 更新为包含以下列表中的模块。
列表 7.8. app.module.ts
import {BrowserModule} from '@angular/platform-browser';
import {NgModule} from '@angular/core';
import {MatButtonModule} from '@angular/material/button';
import {MatIconModule} from '@angular/material/icon';
import {MatToolbarModule} from '@angular/material/toolbar';
import {FlexLayoutModule} from '@angular/flex-layout';
import {HttpClientModule} from '@angular/common/http';
import {AppComponent} from './app.component';
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
MatButtonModule, *1*
MatIconModule, *1*
MatToolbarModule, *1*
FlexLayoutModule, *2*
HttpClientModule *3*
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule {}
-
1 添加 Angular Material 库中所需的模块
-
2 添加 Flex Layout 模块
-
3 添加 HttpClientModule—you’ll use HttpClient for getting the product data
将生成的 app.component.html 替换为以下列表。
列表 7.9. app.component.html
<mat-toolbar class="toolbar">
<button class="toolbar__icon-button" mat-icon-button> *1*
<mat-icon>menu</mat-icon>
</button>
<div class="toolbar__logo-title-group"
fxLayout
fxLayoutAlign="center center"> *2*
<a routerLink="/" *3*
fxLayout>
<img class="toolbar__logo"
src="/assets/logo.svg"
alt="NgAuction Logo">
</a>
<a class="toolbar__title" *4*
routerLink="/">NgAuction</a>
</div>
<div fxFlex></div> *5*
<button mat-icon-button class="toolbar__icon-button *6*
toolbar__shopping-cart-button">
<mat-icon>shopping_cart</mat-icon>
</button>
</mat-toolbar>
<!--<router-outlet></router-outlet>--> *7*
-
1 带有图标的菜单按钮
-
2 在工具栏中心显示标志
-
3 将标志转换为可点击的链接,配置路由后显示 HomeComponent
-
4 将 ngAuction 文本转换为可点击的链接
-
5 填充物,将购物车图标推到右边
-
6 带有图标的购物车按钮
-
7 你将保持路由出口被注释,直到你配置路由。
为了使工具栏看起来像图 7.9,你需要将以下列表的样式添加到 app.component.scss 文件中。
列表 7.10. app.component.scss
@import '../styles/theme'; *1*
:host { *2*
display: block;
height: 100%;
}
.toolbar { *3*
background-color: mat-color($nga-background, card);
position: relative;
box-shadow: 0 1px mat-color($nga-foreground, divider);
}
.toolbar__logo-title-group { *4*
position: absolute;
right: 50%;
left: 50%;
}
.toolbar__logo { *5*
height: 32px;
margin-right: 16px;
}
.toolbar__title { *6*
color: mat-color($nga-foreground, text);
font-family: 'Titillium Web', sans-serif;
font-weight: 600;
text-decoration: none;
}
.toolbar__icon-button { *7*
color: mat-color($nga-foreground, icon);
}
.toolbar__shopping-cart-button { *8*
margin-right: 8px;
}
-
1 导入你的自定义主题
-
2 使用 Angular 伪选择器:host 来样式化托管 AppComponent 的组件
-
3 应用与该主题中 Material 卡片组件相同的背景(在你的主题中是白色)
-
4 样式化标志名称
-
5 样式化标志图像
-
6 样式化工具栏标题
-
7 样式化图标前景
-
8 样式化购物车按钮
运行ng serve命令将渲染看起来像图 7.9 的 ngAuction 应用。
你已经渲染了一个工具栏 UI,现在你需要显示工具栏下的产品。首先,你需要创建一个ProductService来提供产品数据,然后你将创建一个HomeComponent来渲染这些数据。让我们从ProductService开始。
7.2.5. 创建产品服务
产品服务需要数据。在现实世界的应用中,数据将由服务器提供,你将在第十二章中这样做。现在,你将只使用包含产品信息的 JSON 文件。产品图片也将位于客户端。本书附带的一些代码示例包括 src/data/products.json 文件,其一个片段如下所示。
列表 7.11. src/data/products.json 的一个片段
[
{
"id": 1,
"description" : "Isn't it cool when things look old, but they're not...",
"imageUrl" : "data/img/radio.png",
"price" : 300,
"title" : "Vintage Bluetooth Radio"
},
{
"id": 2,
"description" : "Be an optimist. Carry Sunglasses with you at all times..
.",
"featured" : true,
"imageUrl" : "data/img/sunnies.png",
"price" : 70,
"title" : "Sunglasses"
}
...
]
此文件包含位于 data/img 文件夹中的产品图片的 URL。如果你正在跟随并尝试自己构建 ngAuction,请将 src/data 目录从本书附带代码复制到你的项目中,并将行"data"添加到.app 属性.angular-cli.json文件中的assets。
你将在多个组件中使用ProductService类;你将在 src/app/shared/services 文件夹中生成它。你将在稍后在这个文件夹中添加其他可重用服务(例如SearchService)。你将使用以下 Angular CLI 命令生成ProductService:
ng generate service shared/services/product
然后你将添加此服务的提供者到 app.module:
...
import {ProductService} from './shared/services/product.service';
@NgModule({
...
providers: [ProductService]
})
export class AppModule {}
最佳实践
ProductService的导入语句相当长,并且指向实现此服务文件的路径。随着你的应用程序的增长,模块中的服务数量以及导入语句的数量也会增加,这会污染模块代码。
在 services 文件夹中创建名为 index.ts 的文件,如下所示:
import {Product, ProductService} from './product.service';
export {Product, ProductService} from './product.service';
您导入 Product 和 ProductService 类,并立即重新导出它们。现在,app.module 中的导入语句可以简化为如下所示:
import {Product, ProductService} from './shared/services';
如果您只有一个重新导出的类,这可能看起来有些过度。但如果您在服务文件夹中有多个类,您可以只写一个导入语句来导入所有类、函数或变量——例如:
import { ProductService, Product, SearchService } from './shared/services';
请记住,这仅在具有此类重新导出的文件名为 index.ts 时才有效。
product.service.ts 文件包含 Product 接口和 ProductService 类。Product 接口定义了 ProductService 类的方法返回的对象类型:getAll() 和 getById()。您的 ProductService 代码如下所示。
列表 7.12. product.service.ts
import {Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {Observable} from 'rxjs';
import {map} from 'rxjs/operators';
export interface Product { *1*
id: number;
title: string;
price: number;
imageUrl: string;
description: string;
}
@Injectable()
export class ProductService {
constructor(private http: HttpClient) {} *2*
getAll(): Observable<Product[]> { *3*
return this.http.get<Product[]>('/data/products.json');
}
getById(productId: number): Observable<Product> { *4*
return this.http.get<Product[]>('/data/products.json')
.pipe(
map(products => <Product>products.find(p => p.id === productId)); *5*
)
}
}
-
1 定义产品类型
-
2 注入 HttpClient 对象
-
3 此函数声明了一个可以返回所有产品对象的 Observable。
-
4 此函数声明了一个可以按 ID 返回产品的 Observable。
-
5 map() 查找与函数参数匹配的产品 ID。
因为您没有真实的数据服务器,这两个方法都读取整个 products.json 文件,并且 getById() 方法还将 find() 应用于产品数组以找到匹配的 ID。
最佳实践
您将 Product 类型定义为接口而不是类。因为 JavaScript 不支持接口,编译后的代码将不包括 Product。如果您将 Product 定义为类,TypeScript 编译器会将 Product 类转换为 JavaScript 函数或类,并将其包含在可执行代码中。将类型定义为 TypeScript 接口而不是类可以减小可执行代码的大小。
在下一节中,您将创建包含 HomeComponent 的功能模块——ProductService 的第一个消费者。
7.2.6. 创建主页模块
您希望将每个视图创建为功能模块。这将允许您按需加载它们,并且每个视图的代码将作为单独的包构建。按照以下方式生成功能主页模块:
ng generate module home
此命令将在 src/app/home 目录下创建一个包含 home.module.ts 文件的目录,其内容如下所示。
列表 7.13. home.module.ts
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
@NgModule({
imports: [
CommonModule
],
declarations: []
})
export class HomeModule {}
您可以使用以下命令生成主页组件:
ng generate component home
运行此命令后,Angular CLI 将打印出已生成四个文件(主页组件)和一个文件被更新(主页模块)——HomeComponent 被添加到模块的 @NgModule 装饰器的 declarations 部分中:
create src/app/home/home.component.scss (0 bytes)
create src/app/home/home.component.html (23 bytes)
create src/app/home/home.component.spec.ts (614 bytes)
create src/app/home/home.component.ts (262 bytes)
update src/app/home/home.module.ts (251 bytes)
在此模块中,您将使用 Flex 布局库,因此您想要配置默认路由以便它渲染 HomeComponent。此外,您将使用 Angular Material 库中的 <mat-grid-list> 组件来显示产品。将所需的代码添加到 home.module.ts 中,使其看起来如下所示。
列表 7.14. modified home.module.ts
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {RouterModule} from '@angular/router';
import {FlexLayoutModule} from '@angular/flex-layout';
import {MatGridListModule} from '@angular/material/grid-list';
import {HomeComponent} from './home.component';
@NgModule({
imports: [
CommonModule,
RouterModule.forChild([ *1*
{path: '', component: HomeComponent}
]),
FlexLayoutModule, *2*
MatGridListModule *3*
],
declarations: [HomeComponent]
})
export class HomeModule {}
-
1 为您的功能模块添加路由配置
-
2 添加 Flex Layout 库
-
3 添加
所需的 Angular Material 模块
下一步是更新生成的 home.component.ts 文件中的 HomeComponent。您将向此组件注入两个服务:ProductService 和 ObservableMedia。您将调用 ProductService 上的 getAll() 方法以获取产品数据。ObservableMedia 将监视视口宽度以相应地更改 UI 布局。更具体地说,产品数据将以网格形式显示,而 ObservableMedia 服务将根据当前视口宽度将网格中的列数从一列变为五列。HomeComponent 的代码如下所示。
列表 7.15. home.component.ts
import {Observable} from 'rxjs';
import {map} from 'rxjs/operators';
import {Component} from '@angular/core';
import {ObservableMedia} from '@angular/flex-layout';
import {Product, ProductService} from '../shared/services';
@Component({
selector: 'nga-home',
styleUrls: [ './home.component.scss' ],
templateUrl: './home.component.html'
})
export class HomeComponent {
readonly columns$: Observable<number>; *1*
readonly products$: Observable<Product[]>; *2*
readonly breakpointsToColumnsNumber = new Map([ *3*
[ 'xs', 1 ],
[ 'sm', 2 ],
[ 'md', 3 ],
[ 'lg', 4 ],
[ 'xl', 5 ],
]);
constructor(private media: ObservableMedia,
private productService: ProductService) { *4*
this.products$ = this.productService.getAll(); *5*
this.columns$ = this.media.asObservable() *6*
.pipe(
map(mc => <number>this.breakpointsToColumnsNumber.get(mc.mqAlias)) *7*
);
}
}
-
1 提供网格列数的 observable
-
2 产品的可观察量
-
3 将媒体查询别名映射到网格中的列数
-
4 注入 ObservableMedia 和 ProductService
-
5 获取所有产品的数据
-
6 将 ObservableMedia 对象转换为 Observable
-
7 根据发出的媒体查询别名获取网格列数;
表示从对象转换为数字
ProductService 上的 getAll() 方法初始化了类型为 Observable 的 product$ 变量。您在这里看不到 subscribe() 方法的调用,因为您将在 home 组件的模板中使用 async 管道。
ObservableMedia 的作用是向组件发送媒体查询别名,指示用户设备视口的当前宽度。如果视口是浏览器中的窗口并且用户调整大小,则此宽度可能会变化。如果用户在智能手机上运行此应用程序,视口的宽度不会改变,但 HomeComponent 仍然需要知道它以渲染产品网格。
现在您需要将生成的 home.component.html 模板替换为显示产品网格的行和列的标记。对于网格,您将使用来自 Angular Material 库的 <mat-grid-list> 组件。每个网格单元格的内容将在 <mat-grid-tile> 组件中渲染。
在此模板中,您将使用两次 async 管道。第一个 async 管道将订阅发出网格中列数的 observable,第二个管道将订阅发出产品数据的 observable。home.component.html 文件的代码如下所示。
列表 7.16. home.component.html
<div class="grid-list-container">
<mat-grid-list [cols]="columns$ | async" *1*
gutterSize="16">
<mat-grid-tile class="tile" *ngFor="let product of products$ | async"> *2*
<a class="tile__content" *3*
fxLayout="column"
fxLayoutAlign="center center"
[routerLink]="['/products', product.id]"> *4*
<span class="tile__price-tag"
ngClass.xs="tile__price-tag--xs"> *5*
{{ product.price | currency:'USD':'symbol':'.0' }}
</span>
<div class="tile__thumbnail"
[ngStyle]="{'background-image': 'url(' + product.imageUrl + ')'}"></div>
<div class="tile__title"
ngClass.xs="tile__title--xs"
ngClass.sm="tile__title--sm">{{ product.title }}</div>
</a>
</mat-grid-tile>
</mat-grid-list>
</div>
-
1 订阅列数并将其绑定到
的 cols 属性 -
2 使用 products$ observable 中的数据为每个产品渲染一个
-
3 将每个瓷砖的内容包裹在 标签中,将瓷砖转换为可点击的链接
-
4 点击瓷砖将导航到路径 /products,并传递所选产品的 id 作为参数。
-
5 对于额外小视口,添加 tile__price-tag--xs 中定义的样式
注意
在这个版本的 ngAuction 中没有实现导航到商品详情屏幕。点击商品瓷砖将在浏览器控制台产生错误。
我们想对 列表 7.16 中的最后一个注释进行更多解释。这个 <span> 元素被设置为 tile__price-tag 中定义的样式,但如果视口的尺寸变为额外小(xs),Flex Layout 的 ngClass.xs 指令将添加 tile__price-tag--xs 中定义的样式。如果你比较 tile__price-tag 和 tile__price-tag--xs 样式的定义,你会在 列表 7.17 中看到,合并这两个样式意味着将字体大小从 16 px 更改为 14 px。
小贴士
我们在命名一些样式时使用 __ 和 -- 符号,这是按照块、元素、修饰符(BEM)方法(见 getbem.com)推荐的。
要完成 HomeComponent,你需要在 home.component .scss 中添加一些样式。
列表 7.17. home.component.scss
@import '../../styles/theme'; *1*
:host {
display: block;
}
.grid-list-container {
margin: 16px;
}
.tile {
background-color: mat-color($nga-background, card); *2*
&:hover {
@include mat-elevation(4); *3*
transition: .3s;
}
}
.tile__content {
display: block;
height: 100%;
width: 100%;
padding: 16px;
position: relative;
text-align: center;
text-decoration: none;
}
.tile__price-tag { *4*
color: mat-color($nga-foreground, text);
font-size: 16px;
font-weight: 700;
position: absolute;
right: 20px;
top: 20px;
}
.tile__price-tag--xs { *5*
font-size: 14px;
}
.tile__thumbnail {
background: no-repeat 50% 50%;
background-size: contain;
height: 50%;
width: 50%;
}
.tile__title { *6*
color: mat-color($nga-foreground, text);
font-family: 'Abril Fatface', cursive;
font-size: mat-font-size($nga-typography, display-1); *7*
line-height: mat-line-height($nga-typography, display-1);
}
.tile__title--sm { *8*
font-size: mat-font-size($nga-typography, headline);
line-height: mat-line-height($nga-typography, headline);
}
.tile__title--xs { *9*
font-size: mat-font-size($nga-typography, title);
line-height: mat-line-height($nga-typography, title);
}
-
1 导入你的自定义主题
-
2 使瓷砖的背景颜色与 Angular Material 卡片(白色)相同
-
3 如果用户将鼠标悬停在瓷砖上,通过添加阴影效果(由 mat-elevation mixin 返回)将瓷砖提升到 4 级
-
4 商品价格标签的默认样式
-
5 商品价格标签的额外小视口样式
-
6 商品标题的默认样式
-
7 根据 Material Design 规范,使用 Display 1 字体样式而不是指定硬编码的大小
-
8 针对小型视口的商品标题样式
-
9 针对额外小视口的商品标题样式
HomeComponent 已准备好。你需要在工具栏下渲染它,需要做什么?
7.2.7. 配置路由
在这个动手实践的最初,我们提到 ngAuction 的每个视图都将是一个独立的模块,并且你已经创建了 HomeComponent 作为模块。现在你需要为这个模块配置路由。创建一个 src/app/app.routing.ts 文件,内容如下:
import {Route} from '@angular/router';
export const routes: Route[] = [
{
path: '',
loadChildren: './home/home.module#HomeModule'
}
];
正如你所见,你使用了第四章中 4.3 节解释的懒加载模块的语法。你需要在 app.module.ts 中通过调用 Router.forRoot() 来加载这个配置:
...
import {RouterModule} from '@angular/router';
import {routes} from './app.routing';
@NgModule({
...
imports: [
...
RouterModule.forRoot(routes)
]
...
})
export class AppModule { }
最后一步是取消注释 app.component.html 中最后一行带有 <router-outlet> 标签的行,这样应用程序组件模板的布局如下:
<mat-toolbar>...</mat-toolbar>
<router-outlet></router-outlet>
落地页的编码部分已完成。
7.2.8. 运行 ngAuction
新的 ngAuction 的第一个版本已准备好,所以让我们构建开发包并看看它在浏览器中的样子。运行 ng serve 产生如图 7.10 所示的输出。
图 7.10. 使用 ng serve 打包 ngAuction

注意第一行:Angular CLI 将主模块放置在单独的包中。它之所以这样做,是因为在配置路由时,你使用了懒加载模块的语法,但当你打开浏览器到 http://localhost:4200 时,你会看到主模块已经被加载,如图 7.11 所示。
图 7.11. 运行 ngAuction

主模块被急切地加载,因为它被配置为默认路由(映射到空路径)。ngAuction 的着陆页已经准备好了,除了工具栏上没有搜索按钮。你将在第十一章的 11.8 节 中添加它。
小贴士
如果你点击任何产品瓷砖,浏览器控制台会显示错误,例如“无法匹配任何路由。URL 段:'products/2'。”这个错误将在你开发产品详情页后,第九章的 ngAuction 版本中消失。
摘要
-
你可以保持单个代码库的 web 应用,该应用将根据用户设备的可用宽度采用其 UI。
-
Flex 布局库允许你订阅关于视口宽度变化的通知,并应用相应的 UI 布局。
-
Flex 布局库包括
ObservableMedia类,它可以通知你关于视口当前宽度的信息,让你免于为此编写 CSS。
第八章. 实现组件通信
本章涵盖
-
创建松耦合组件
-
父组件如何将数据传递给子组件,反之亦然
-
实现组件通信的中介者设计模式
一个 Angular 应用是由组件表示的视图树。在设计组件时,你需要确保它们是自包含的,同时有一些方式可以相互通信。在本章中,我们将重点关注组件如何以松耦合的方式相互传递数据。
首先,我们将向你展示一个父组件如何通过绑定到它们的输入属性将数据传递给其子组件。然后,你将看到子组件如何通过其输出属性通过发射事件将数据发送给父组件。
我们将继续使用一个示例,该示例应用中介者设计模式来安排没有父子关系的组件之间的数据交换。中介者可能是任何基于组件的框架中最重要的设计模式。
8.1. 组件间通信
图 8.1 展示了一个由编号和不同形状的组件组成的视图,这有助于更容易地参考。其中一些组件包含其他组件(我们称外部的为容器),而其他的是同级的。为了避免与任何特定的 UI 框架相关联,我们避免了使用像输入字段、下拉菜单和按钮这样的 HTML 元素,但你可以将这些扩展到你的实际应用视图。
图 8.1. 一个视图由组件组成。

当你设计一个由多个组件组成的视图时,它们彼此知道的越少,越好。比如说,一个用户在组件 4 中点击按钮,这个按钮需要在组件 5 中启动一些操作。是否有可能在不让组件 4 知道组件 5 存在的情况下实现这个场景?是的,可以。
你已经通过使用依赖注入看到了松耦合组件的例子。现在我们将通过使用绑定和事件展示另一种实现相同目标的技术。
8.2. 输入和输出属性
将 Angular 组件想象成一个带有出口的黑盒。其中一些被标记为@Input(),而其他被标记为@Output()。你可以创建一个具有任意数量输入和输出的组件。
如果一个 Angular 组件需要从外部世界接收值,你可以将这些值的提供者绑定到组件的相应输入上。它们是从哪里接收的?组件不需要知道。组件只需要知道当这些值提供时该如何处理它们。
如果一个组件需要将值传递到外部世界,它可以通过其输出属性发射事件。它们被发射到谁那里?组件不需要知道。任何感兴趣的人都可以订阅组件发射的事件。
让我们实现这些松耦合原则。首先,你将创建一个OrderProcessorComponent,它可以接收来自父组件的订单请求。
8.2.1. 输入属性
使用@Input()装饰器注解的组件输入属性用于从父组件获取数据。想象一下,你想创建一个用于下单购买股票的 UI 组件。它将知道如何连接到证券交易所,但在本讨论输入属性的上下文中这不相关。你想要确保OrderProcessorComponent通过其带有@Input()装饰器的属性从其他组件接收数据。你的OrderProcessorComponent将如下所示。
列表 8.1. order.component.ts
@Component({
selector: 'order-processor',
template: `
<span *ngIf="!!stockSymbol"> *1*
Buying {{quantity}} shares of {{stockSymbol}}
</span>
`,
styles:[`:host {background: cyan;}`]
})
export class OrderProcessorComponent {
@Input() stockSymbol: string; *2*
@Input() quantity: number; *3*
}
-
1 只有当 stockSymbol 为真值时才显示文本
-
2 声明输入属性以接收股票代码
-
3 声明输入属性以接收数量
OrderProcessorComponent不知道谁将为这些属性提供值,这使得该组件完全可重用。
接下来,我们将查看AppComponent,它是你的应用程序中OrderComponent的父组件。AppComponent允许用户在输入字段中输入股票代码,输入的值通过属性绑定传递给OrderProcessorComponent。以下列表显示了AppComponent的代码。
列表 8.2. input/app.component.ts
@Component({
selector: 'app-root',
template: `
<input type="text" placeholder="Enter stock (e.g. AAPL)"
(change)="onChangeEvent($event)"> *1*
<order-processor [stockSymbol]="stock" *2*
[quantity]="numberOfShares"> *3*
</order-processor>
`
})
export class AppComponent {
stock: string;
readonly numberOfShares = 100; *4*
onChangeEvent({target}): void { *5*
this.stock = target.value; *6*
}
}
-
1 当用户将焦点从输入字段移除(改变事件)时,调用事件处理器并传递事件对象给它
-
2 将子组件的输入属性 stockSymbol 绑定到属性 stock 的值
-
3 将子组件的属性 quantity 的值绑定到属性 numberOfShares 的值
-
4 你不能在类属性中使用关键字 const;使用 readonly。
-
5 从作为参数给出的事件对象中提取属性目标值
-
6 将输入字段中输入的值分配给属性 stock
<order-processor>组件的两个属性都用方括号包围,表示属性绑定。如果你在OrderProcessorComponent内部更改stockSymbol或quantity的值,这不会影响父组件的属性值。属性绑定是单向的:从父组件到子组件。
要查看此应用程序的实际效果,请在第八章的inter-component文件夹中运行npm install,然后运行以下命令:
ng serve --app input -o
最佳实践
尽管我们赞扬 TypeScript 允许指定变量类型,但我们没有声明numberOfShares属性的类型。因为我们用数值初始化了它,TypeScript 编译器将在初始化时使用类型推断来猜测NumberOfShares的类型。在公共 API 中明确声明类型,例如,公共类属性、函数参数和返回类型等。
图 8.2 展示了用户在输入字段中输入 IBM 后的浏览器窗口。OrderProcessorComponent 接收了输入值 100 和 IBM。
图 8.2. OrderProcessorComponent 接收值。

如何让组件拦截输入属性 stockSymbol 值变化时的时刻以执行一些额外的处理?一个简单的方法是将 stockSymbol 转换为一个设置器。如果你想在组件的模板中使用 stockSymbol,也要创建一个获取器,如下面的列表所示。
列表 8.3. 添加设置器和获取器
...
private _stockSymbol: string; *1*
@Input() set stockSymbol(value: string) *2*
if (value !== undefined) {
this._stockSymbol = value;
console.log(`Buying ${this.quantity} shares of ${value}`);
}
}
get stockSymbol(): string { *3*
return this._stockSymbol;
}
-
1 此私有变量无法从模板中访问。
-
2 定义一个输入属性为设置器
-
3 定义一个获取器以便从模板中访问 stockSymbol
当此应用程序启动时,变更检测机制将初始化视为绑定变量 stockSymbol 的变更。设置器被调用,为了避免为未定义的 stockSymbol 发送订单,你在设置器中检查其值。
注意
在 第 9.2.1 节 中,我们将展示如何在 第九章 中拦截输入属性的变更而不使用设置器。
8.2.2. 输出属性和自定义事件
Angular 组件可以使用 EventEmitter 对象派发自定义事件。这些事件将由组件的父组件消费。EventEmitter 是 Subject 的一个子类(在附录 D 中解释),它可以作为观察者和观察对象,但通常你只使用 EventEmitter 来派发自定义事件,这些事件在父组件的模板中处理。
最佳实践
如果你需要一个既是观察者又是观察对象的对象,请使用 RxJS 的 BehaviorSubject。你将在 第 8.3.2 节 中看到如何做到这一点。在未来的版本中,EventEmitter 的内部实现可能会改变,因此最好只用于派发自定义事件。
假设你需要编写一个连接到证券交易所并显示股票价格的 UI 组件。除了显示价格外,该组件还应发送包含最新价格的事件,以便其父组件可以处理它并应用业务逻辑到变化的价格。让我们创建一个 PriceQuoterComponent 来实现这样的功能。在这个组件中,你不会连接到任何金融服务器,而是使用随机数生成器来模拟价格的变化。
在 PriceQuoterComponent 中显示变化的价格相当直接——你将 stockSymbol 和 lastPrice 属性绑定到组件的模板中。
您将通过组件的@Output属性发出自定义事件来通知父组件最新的价格。不仅您会在价格变化时立即触发事件,而且此事件还将携带一个负载:一个包含股票符号及其最新价格的对象。负载的类型将被定义为PriceQuote接口,如下所示。
列表 8.4. iprice.quote.ts
export interface PriceQuote {
stockSymbol: string;
lastPrice: number;
}
PriceQuoterComponent将生成随机报价,并且每两秒发出一次。
列表 8.5. price.quoter.component.ts
@Component({
selector: 'price-quoter',
template: `<strong>Inside PriceQuoterComponent:
{{priceQuote?.stockSymbol}} *1*
{{priceQuote?.lastPrice | currency: 'USD'}}</strong>`,
styles: [`:host {background: pink;}`]
})
export class PriceQuoterComponent {
@Output() lastPrice = new EventEmitter<PriceQuote>(); *2*
priceQuote : PriceQuote;
constructor() {
Observable.interval(2000) *3*
.subscribe(data =>{
this.priceQuote = {
stockSymbol: "IBM",
lastPrice: 100 * Math.random()
};
this.lastPrice.emit(this.priceQuote);} *4*
)
}
}
-
1 问号代表安全导航操作符。
-
2 输出属性 lastPrice 由 EventEmitter 对象表示,该对象向父组件发出 lastPrice 事件。
-
3 通过每两秒调用生成随机数的函数来模拟价格变化,并填充 priceQuote 对象
-
4 通过输出属性发出新价格;lastPrice 事件携带 PriceQuote 对象作为负载
priceQuote?中的安全导航操作符确保如果priceQuote对象尚未可用,模板中的代码不会尝试访问未初始化的priceQuote对象的属性。
提示
我们使用了Observable.interval()而不是setInterval(),因为后者是仅适用于浏览器的 API。从 Angular 6 开始,请使用interval()而不是Observable.interval()。
下一个列表展示了父组件将如何接收和处理来自<price-quoter>组件的lastPrice。
列表 8.6. app.component.ts
@Component({
selector: 'app-root',
template: `
AppComponent received: {{priceQuote?.stockSymbol}}
{{priceQuote?.lastPrice | currency:'USD'}}
<price-quoter (lastPrice)="priceQuoteHandler($event)"> *1*
</price-quoter>
`
})
export class AppComponent {
priceQuote : IPriceQuote;
priceQuoteHandler(event: IPriceQuote) { *2*
this.priceQuote = event;
}
}
-
1 AppComponent 接收 lastPrice 事件并调用 priceQuoteHandler,将接收到的对象作为参数传递。
-
2 接收 IPriceQuote 对象并使用其属性填充 AppComponent 的相关属性
运行此示例,您将看到价格在每两秒更新一次,在PriceQuoterComponent(粉色背景)以及AppComponent(白色背景)中,如图图 8.3 所示。
图 8.3. 运行输出属性示例

要查看此应用的运行情况,请运行以下命令:
ng serve --app output -o
事件冒泡
Angular 不提供支持事件冒泡的 API。如果您尝试在<price-quoter>元素上而不是其父元素上监听lastPrice事件,事件将不会在那里冒泡。在以下代码片段中,lastPrice事件不会到达<div>,因为它是<price-quoter>的父元素:
<div (lastPrice)="priceQuoteHandler($event)">
<price-quoter></price-quoter>
</div>
如果事件冒泡对您的应用很重要,不要使用EventEmitter;请使用原生的 DOM 事件。以下代码片段展示了PriceQuoterComponent如何使用支持冒泡的CustomEvent(来自 Web API):
@Component(...)
class PriceQuoterComponent {
stockSymbol = "IBM";
price;
constructor(element: ElementRef) {
setInterval(() => {
let priceQuote: IPriceQuote = {
stockSymbol: this.stockSymbol,
lastPrice: 100 * Math.random()
};
this.price = priceQuote.lastPrice;
element.nativeElement
.dispatchEvent(new CustomEvent('lastPrice', {
detail: priceQuote,
bubbles: true
}));
}, 1000);
}
}
Angular 注入一个ElementRef对象,该对象有一个指向代表<price-quoter>的 DOM 元素的引用,然后通过调用element.nativeElement.dispatchEvent()来触发一个CustomEvent。在这里,事件冒泡将起作用,但使用ElementRef仅在基于浏览器的应用程序中有效,与非 HTML 渲染器不兼容。
下面的AppComponent处理<div>中的lastPrice事件,它是<price-quoter>组件的父组件。请注意,priceQuoteHandler()参数的类型是CustomEvent,您可以通过detail属性访问其有效载荷:
@Component({
selector: 'app',
template: `
<div (lastPrice)="priceQuoteHandler($event)">
<price-quoter></price-quoter>
</div>
<br>
AppComponent received: {{stockSymbol}}
{{price | currency: 'USD'}}
`
})
class AppComponent {
stockSymbol: string;
price: number;
priceQuoteHandler(event: CustomEvent) {
this.stockSymbol = event.detail.stockSymbol;
this.price = event.detail.lastPrice;
}}
我们已经确定每个 UI 组件应该是自包含的,并且不应该依赖于其他 UI 组件的存在,使用@Input()和@Output()装饰器可以创建可重用组件。但是,如果两个组件彼此不了解,如何安排它们之间的通信呢?
8.3. 实现中介者设计模式
使用中介者设计模式可以实现松散耦合组件之间的通信,根据维基百科的定义,该模式“定义了一组对象如何交互”(en.wikipedia.org/wiki/Mediator_pattern)。我们将通过类比连接玩具积木来解释这究竟意味着什么。
想象一个孩子正在玩搭建积木(想象成组件)的游戏,这些积木“不知道”彼此的存在。今天这个孩子(中介者)可以用一些积木搭建一座房子,明天他们将从相同的积木中构建一艘船。
注意
中介者的作用是确保组件根据当前任务正确地组合在一起,同时保持松散耦合。
回到 Web UI 领域,我们将考虑两种情况:
-
当组件有共同父组件时的通信安排
-
当组件没有共同父组件时的通信安排
8.3.1. 使用共同父组件作为中介者
让我们回顾本章的第一幅图,再次展示在图 8.4。除了组件 1 之外,每个组件都有一个父组件(容器),它可以充当中介者的角色。顶级中介者是容器 1,它负责确保其直接子组件 2、3 和 6 在需要时能够进行通信。另一方面,组件 2 是组件 4 和 5 的中介者。组件 3 是组件 7 和 8 的中介者。
图 8.4. 视图由组件组成。

中介者需要从组件接收数据并将其传递给另一个组件。让我们回到监控股票价格的例子。
想象一个交易员正在监控几只股票的价格。在某个时刻,交易员点击了股票符号旁边的购买按钮,向证券交易所下单。你可以轻松地将购买按钮添加到上一节中的 PriceQuoterComponent,但这个组件不知道如何下单购买股票。PriceQuoterComponent 将通知调解器(AppComponent),交易员此时想要购买特定的股票。
调解器应该知道哪个组件可以放置购买订单,以及如何将股票符号和数量传递给它。图 8.5 展示了 AppComponent 如何在 PriceQuoterComponent 和 OrderComponent 之间进行通信调解。
注意
发射事件的工作方式类似于广播。PriceQuoterComponent 通过 @Output() 属性发射事件,而不需要知道谁将接收它们。OrderComponent 等待其 @Input() 属性的值发生变化,作为下单的信号。
图 8.5. 调解通信

为了演示调解器模式的应用,让我们编写一个小型应用程序,该应用程序由 图 8.5 中显示的两个组件组成。您可以在调解器父目录中找到此应用程序,该目录包含以下文件:
-
istock.ts— 定义表示股票的值对象的
Stock接口 -
price.quoter.component.ts—
PriceQuoterComponent -
order.component.ts—
OrderComponent -
app.component.ts— 包含
<price-quoter>和<order-processor>的模板中的父组件(调解器) -
app.module.ts—
AppModule类
你将在两种情况下使用 Stock 接口:
-
为了表示由
PriceQuoterComponent发射的事件的有效负载 -
为了表示通过绑定传递给
OrderComponent的数据
以下列表显示了 istock.ts 文件的内容。
列表 8.7. istock.ts
export interface Stock {
stockSymbol: string;
bidPrice: number;
}
下一个列表中展示的 PriceQuoterComponent 有一个购买按钮和 buy 输出属性。它仅在用户点击购买按钮时发射 buy 事件。
列表 8.8. price.quoter.component.ts
@Component({
selector: 'price-quoter',
template: `<strong>
<button (click)="buyStocks()">Buy</button>
{{stockSymbol}} {{lastPrice | currency: "USD"}}
</strong>
`,
styles:[`:host {background: pink; padding: 5px 15px 15px 15px;}`]
})
export class PriceQuoterComponent {
@Output() buy: EventEmitter<Stock> = new EventEmitter(); *1*
stockSymbol = "IBM";
lastPrice: number;
constructor() {
Observable.interval(2000)
.subscribe(data =>
this.lastPrice = 100 * Math.random());
}
buyStocks(): void {
let stockToBuy: Stock = {
stockSymbol: this.stockSymbol,
bidPrice: this.lastPrice
};
this.buy.emit(stockToBuy); *2*
}
}
-
1 购买输出属性将用作自定义购买事件。
-
2 发射自定义购买事件
当调解器(AppComponent)从 <price-quoter> 接收到 buy 事件时,它从该事件中提取有效负载并将其分配给 stock 变量,该变量绑定到 <order-processor> 的输入参数,如下列所示。
列表 8.9. app.component.ts
@Component({
selector: 'app-root',
template: `
<price-quoter (buy) = "priceQuoteHandler($event)"> *1*
</price-quoter>
<order-processor
[stock] = "receivedStock"> *2*
</order-processor>
`
})
export class AppComponent {
receivedStock: Stock;
priceQuoteHandler(event: Stock) {
this.receivedStock = event;
}
}
-
1 当调解器接收到购买事件时,它将调用事件处理器。
-
2 将从
<price-quoter>接收到的股票传递给<order-processor>。
当 OrderComponent 上的 buy 输入属性的值发生变化时,其设置器将显示消息“已放置订单...”,显示 stockSymbol 和 bidPrice。
列表 8.10. order.component.ts
@Component({
selector: 'order-processor',
template: `{{message}}`,
styles:[`:host {background: cyan;}`]
})
export class OrderComponent {
message = "Waiting for orders...";
@Input() set stock(value: Stock) { *1*
if (value && value.bidPrice != undefined) {
this.message = `Placed order to buy 100 shares *2*
of ${value.stockSymbol} at
\$${value.bidPrice.toFixed(2)}`;
}
}
}
-
1 通过此设置器接收股票对象
-
2 准备在模板中显示的消息
展示了当 IBM 股票价格为 36.53 美元时,用户点击购买按钮后发生的情况。
PriceQuoterComponent 在左侧渲染,而 OrderComponent 在右侧。它们是自包含的、松散耦合的,并且仍然可以通过 AppComponent 中介相互通信。
图 8.6. 运行中介示例

要查看此应用程序的实际运行情况,请运行以下命令:
ng serve --app mediator-parent -o
中介设计模式也适合 ngAuction。想象一下对热门产品的拍卖战最后几分钟。用户监控频繁更新的出价,并点击按钮提高他们的出价。
8.3.2. 使用可注入服务作为中介
在上一节中,您看到了兄弟组件如何使用它们的父组件作为中介。如果组件没有相同的父组件或不是同时显示的(路由器可能此时不会显示所需的组件),您可以使用可注入服务作为中介。每当组件被创建时,中介服务就会被注入,组件可以订阅服务发出的事件(与 OrderComponent 所使用的 @Input() 参数相反)。
多组件 UI 的真实世界示例
在现实世界的 Web 应用中,您可以找到许多由多个组件组成的 UI。我们将向您展示一个从公开网站 www.forex.com 获取的 UI,该网站提供了一个用于货币交易的 Web 平台。交易员可以实时监控多种货币对的报价(例如,美元和欧元),并在价格合适时下单购买货币。
这是您可以在 mng.bz/M9Af 找到的交易员屏幕的快照。

来自 forex.com 的示例交易员屏幕
我们不知道创建此 UI 时使用了哪个 JavaScript 框架(如果有的话),但我们可以清楚地看到它由多个组件组成。如果我们需要在 Angular 中开发这样的应用程序,我们会创建一个 CurrencyPairComponent,并在顶部放置其四个实例。下面,我们会使用其他组件,例如 PopularMarketComponent、WatchListComponent 等。
在 CurrencyPairComponent 中,我们会创建两个子组件:SellComponent 和 BuyComponent。它们的买卖按钮会发出一个自定义事件,该事件会被父 CurrencyPairComponent 接收,然后父组件需要与 OrderComponent 通信以放置买卖订单。但如果 CurrencyPairComponent 和 OrderComponent 没有共同的父组件,谁将作为它们通信的中介?
展示了一个场景图,表示组件 5 需要向组件 6 和 8 发送数据。如您所见,它们没有共同的父组件,因此您使用可注入服务作为中介。

同一个服务实例将被注入到组件 5、6 和 8 中。组件 5 可以使用服务的 API 提供一些数据,而组件 6 和 8 将在实例化后立即订阅数据。通过在组件 6 和 8 的构造函数中创建订阅,您确保无论这些组件何时创建,它们都会立即从服务中获取数据。
让我们考虑一个实际例子来说明这是如何工作的。想象您有一个 UI,您可以通过在组件的输入框中输入产品名称来搜索产品。您希望提供在 eBay 或 Amazon 上搜索产品的功能。最初,您将渲染 eBay 组件,但如果用户对 eBay 提供的交易不满意,他们会在 Amazon 上寻找相同的产品。图 8.8 展示了用户在搜索字段中输入 aaa 作为产品名称后的该应用 UI。最初,渲染了 eBay 组件,并接收 aaa 作为要搜索的产品。
图 8.8. 在 eBay 上搜索产品 aaa

假设 eBay 提供了 aaa 的详细信息和定价,但用户不满意并点击链接在 Amazon 上寻找相同的产品。您的 UI 有两个链接,一个用于 eBay,另一个用于 Amazon。当用户点击 Amazon 链接时,路由器销毁 eBay 组件并创建 Amazon 组件。您希望保持应用程序状态,以便用户不需要重新输入产品名称,并且 Amazon 组件必须渲染显示 aaa——保存的搜索条件,如图 8.9 所示。
图 8.9. 在 Amazon 上搜索产品 aaa

如果用户改变主意,决定在 Amazon 上搜索不同的产品,然后返回 eBay,新的搜索条件必须在 eBay 组件中显示。
因此,您需要实现两个功能:
-
搜索、eBay 和 Amazon 组件之间的通信。
-
状态管理,以便在用户在 eBay 和 Amazon 之间导航时保留最新的搜索条件。
该应用的代码位于 mediator-service-subject 文件夹中,并包含以下文件:
-
app.component.ts— 最高级组件
AppComponent -
app.module.ts— 包含路由配置的
AppModule -
state.service.ts— 存储应用状态的注入式服务
-
search.component.ts— 带有
<input>字段的SearchComponent -
amazon.component.ts—
AmazonComponent -
ebay.component.ts—
EbayComponent
AppComponent 作为 SearchComponent 的父组件,提供 eBay 和 Amazon 组件的两个链接,并包含 <router-outlet>,如下所示。
列表 8.11. app.component.ts
@Component({
selector: 'app-root',
template: ` <div class="main">
<h2>App component</h2>
<search></search> <b><-- Search component</b> *1*
<p>
<a [routerLink]="['/']">eBay</a> *2*
<a [routerLink]="['/amazon']">Amazon</a> *2*
<router-outlet></router-outlet> *3*
</div>`,
styles: ['.main {background: yellow}']
})
export class AppComponent {}
-
1 用户在此处输入产品名称。
-
2 用于导航的链接
-
3 eBay 或 Amazon 组件在链接下方渲染。
AppModule 按如下方式加载路由配置:
RouterModule.forRoot([
{path: '', component: EbayComponent},
{path: 'amazon', component: AmazonComponent}])
]
您想要创建一个可注入的 StateService,它将接受来自 SearchComponent 的搜索条件并将其发送给其订阅者(eBay 或 Amazon 组件)。在 附录 D 中,我们解释了 RxJS 的 Subject 如何工作。它包含可观察对象和观察者,将满足您的需求,但不会记住已发出的值(搜索条件)。您可以为 SearchComponent 提供的值创建一个单独的变量来存储,但有一个更好的解决方案。
RxJS 库包括 BehaviorSubject,它支持 Subject 的功能——并且它会重新发出最新的已发出值。让我们看看它如何在您的应用中工作:
1. 用户输入
aaa,SearchComponent调用StateService的 API 以将aaa发送到订阅者,最初是一个 eBay 组件。BehaviorSubject发出aaa并记住它(存储应用状态)。2. 用户导航到 Amazon 组件,该组件立即订阅了相同的
BehaviorSubject,重新发出aaa。
下一个列表显示了 StateService 的代码。
列表 8.12. state.service.ts
@Injectable()
export class StateService {
private stateSubject: BehaviorSubject<string> = new BehaviorSubject(''); *1*
set searchCriteria(value: string) { *2*
this.stateSubject.next(value); *3*
}
getState(): Observable<string> { *4*
return this.stateSubject.asObservable();
}
}
-
1 创建一个
BehaviorSubject实例以重新向新订阅者发出最后一个发出的值 -
2 SearchComponent 将调用此方法。
-
3 向订阅者发出搜索条件
-
4 返回主题的可观察对象的引用
getState() 方法返回 BehaviorSubject 的可观察部分,这样 eBay 或 Amazon 组件就可以订阅它。技术上,这些组件可以直接订阅主题,但如果它们有对您的 BehaviorSubject 的引用,它们可以使用 next() API 在主题的观察者上发出数据。您希望允许 eBay 或 Amazon 组件仅使用 subscribe() API——这就是为什么您将只提供来自 BehaviorSubject 的可观察属性引用的原因。
注意
我们使用了 Injectable() 装饰器,但在这里它是可选的,因为我们没有将其他服务注入到 StateService 中。如果我们向这个服务注入 HttpClient 或任何其他服务,使用 Injectable() 就是必需的。
下一个列表显示了 SearchComponent 的代码。您使用表单 API 订阅 valueChanges 可观察对象,如 第六章 中所述。请注意,您将 StateService 注入到该组件中,并且当用户在输入字段中键入时,您将值分配给 StateService 上的 searchCriteria 属性。searchCriteria 属性实现为一个设置器,将用户输入的值发送到 stateSubject 的订阅者,如下面的列表所示。
列表 8.13. search.component.ts
@Component({
selector: "search",
template: `
<input type="text" placeholder="Enter product"
[formControl]="searchInput">
`
})
export class SearchComponent {
searchInput: FormControl;
constructor(private state: StateService) {
this.searchInput = new FormControl('');
this.searchInput.valueChanges *1*
.pipe(debounceTime(300))
.subscribe(searchValue =>
this.state.searchCriteria = searchValue); *2*
}
}
-
1 一个发出输入字段内容的可观察对象
-
2 将输入值传递给 StateService
下面的列表显示了 EbayComponent 的代码,它注入了 StateService 并订阅了 stateSubject 的可观察对象。
列表 8.14. ebay.component.ts
@Component({
selector: 'product',
template: `<div class="ebay">
<h2>eBay component</h2>
Search criteria: {{searchFor$ | async}} *1*
</div>`,
styles: ['.ebay {background: cyan}']
})
export class EbayComponent {
searchFor$: Observable<string>;
constructor(private state: StateService){ *2*
this.searchFor$ = state.getState(); *3*
}
}
-
1 异步管道自动订阅 observable searchFor$。
-
2 注入 StateService
-
3 将可观察对象存储在类变量中
注意
AmazonComponent 中的代码应该是相同的,但在本章附带源代码中,我们保留了一个更冗长的版本,它使用 subscribe() 和 unsubscribe,这样您就可以比较并欣赏 async 管道的好处。
当创建 eBay(或 Amazon)组件时,它会获取 stateSubject 的现有状态并显示它。图 8.10 展示了示例应用中组件之间的通信方式。
注意
这个示例应用不仅说明了您如何使用可注入服务作为中介来安排组件间的通信,还展示了您如何在小型和中型应用中跟踪应用状态。如果您的应用很大,请考虑使用 NgRx 库实现应用状态,如第十五章所述。第十五章。
图 8.10. 应用的工作流程

要查看此应用的实际运行情况,请运行以下命令:
ng serve --app mediator-service -o
您还可以观看一个简短的视频,解释这个应用是如何工作的:mng.bz/oE0s。
提示
在确定您的中介、可重用组件以及它们之间的通信方式之前,不要开始实现您应用的 UI 组件。
现在您知道父组件可以在不知道其内容或存在的情况下将数据传递给其他组件。但如果父组件知道它有一个实现了特定 API 的子组件;父组件能否直接在子组件上调用此 API?
8.4. 暴露子组件的 API
您已经学习了父组件如何通过绑定到输入属性来将其数据传递给子组件。但还有其他情况,当父组件只需要使用子组件公开的 API 时。我们将向您展示一个示例,说明父组件如何从模板和父组件的 TypeScript 代码中调用子组件的 API。
让我们创建一个简单的应用,其中子组件有一个 greet() 方法,该方法将由父组件调用。特别是,父组件在其模板中包含以下行:
<child name= "John" #child1></child>
<button (click) = "child1.greet()">Invoke greet() on child 1</button>
本地模板变量旨在在模板中使用。在上面的代码中,父组件的模板在子组件 #child1 上调用 greet() 方法。
您也可以从 TypeScript 中调用子组件的 API。让我们创建两个相同的子组件实例来演示如何做到这一点:
<child name= "John" #child1></child>
<child name= "Mary" #child2></child>
这些实例的 DOM 引用将分别存储在模板变量 #child1 和 #child2 中。现在您可以在 TypeScript 类中声明一个属性,并用 @ViewChild() 装饰它,这样您就可以从 TypeScript 代码中使用这些对象。当您需要子组件的引用时,@ViewChild() 装饰器非常有用。
你可以这样从模板变量 #child1 将子组件的引用传递到 TypeScript 变量 firstChild:
@ViewChild('child1')
firstChild: ChildComponent;
...
this.firstChild.greet();
@ViewChildren() 装饰器将为你提供相同类型的多个子组件的引用。让我们编写一个小应用程序来展示这些装饰器的使用。子组件的代码位于 childapi/child.component.ts 文件中,如下所示。
列表 8.15. child.component.ts
@Component({
selector: 'child',
template: `<h3>Child {{name}}</h3>`
})
export class ChildComponent {
@Input() name: string;
greet() {
console.log(`Hello from ${this.name}`);
}
}
父组件将包含两个子组件实例,并使用 @ViewChild() 和 @ViewChildren() 装饰器。使用这两个装饰器的父组件的完整代码如下所示。
列表 8.16. app.component.ts
@Component({
selector: 'app-root',
template: `
<h1>Parent</h1>
<child name = "John" #child1></child>
<child name = "Mary" #child2></child>
<button (click) = "child2.greet()">
Invoke greet() on child 2
</button>
<button (click) = "greetAllChildren()">
Invoke greet() on both children
</button>
`
})
export class AppComponent implements AfterViewInit {
@ViewChild('child1')
firstChild: ChildComponent; *1*
@ViewChildren(ChildComponent)
allChildren: QueryList<ChildComponent>; *2*
ngAfterViewInit() { *3*
this.firstChild.greet(); *4*
}
greetAllChildren() {
this.allChildren.forEach(child => child.greet()); *5*
}
}
-
1 获取第一个子组件实例的引用
-
2 获取两个子组件的引用(返回子组件列表)
-
3 使用生命周期钩子 ngAfterViewInit()
-
4 在第一个子组件上调用 greet() 方法
-
5 在两个子组件上调用 greet() 方法
注意
在这个类中,你使用组件生命周期钩子 ngAfterViewInit() 来确保在子组件渲染后使用子组件的 API。有关更多详细信息,请参阅 第九章第 9.2 节。
如果你运行此应用程序,浏览器将渲染 图 8.11 中所示的窗口。
图 8.11. 访问子组件 API

你还将在浏览器控制台中看到以下行:
Hello from John
在应用程序启动时,John 被问候,但为了公平起见,两个子组件都应该被问候。点击按钮将使用整个子组件列表的引用并产生以下输出:
Hello from John
Hello from Mary
要查看此应用程序的实际运行效果,请运行以下命令:
ng serve --app childapi -o
你已经使用了不同的技术进行组件通信来发送数据或调用 API,但你能否从一个组件发送一个 HTML 片段到另一个组件中使用?
8.5. 使用 ngContent 在运行时投影模板
在某些情况下,父组件需要在运行时在子组件中渲染任意标记,你可以在 Angular 中使用 投影 来实现这一点。你可以通过使用 ngContent 指令将父组件模板的一部分投影到其子组件模板上。这是一个两步的过程:
1. 在子组件的模板中,包含
<ng-content></ng-content>标签(插入点)。2. 在父组件中,包含你想要投影到子组件插入点之间的 HTML 片段,这些标签代表子组件(例如,
<my-child>):
template: ` ... <my-child> <div>将此 div 传递给子组件</div> </my-child> ...
在此示例中,父组件不会渲染放置在 <my-child> 和 </my-child> 之间的内容。列表 8.17 和 8.18 展示了这种技术。请注意,这两个组件都声明了一个具有相同名称的 CSS 样式选择器 .wrapper,但每个都定义了不同的背景颜色。这展示了 Angular 在样式封装方面能提供什么,下一节将进行描述。
考虑一个有两个组件的例子——父组件和子组件。父组件将传递一个 HTML 片段给子组件进行渲染。子组件的代码如下所示。
列表 8.17. child.component.ts
import {Component, ViewEncapsulation} from "@angular/core";
@Component({
selector: 'child',
styles: ['.wrapper {background: lightgreen;}'], *1*
template: `
<div class="wrapper">
<h2>Child</h2>
<div>This content is defined in child</div>
<p>
<ng-content></ng-content> *2*
</div>
`,
encapsulation: ViewEncapsulation.Native *3*
})
export class ChildComponent {}
-
1 用于在浅绿色背景上渲染 UI 的类选择器
-
2 来自父组件的内容在这里显示。
-
3 对于样式,使用 ViewEncapsulation.Native 模式(我们将在下一节中解释视图封装模式)。
父组件的代码如下所示。
列表 8.18. app.component.ts
@Component({
selector: 'app-root',
styles: ['.wrapper {background: deeppink;}'], *1*
template: `
<div class="wrapper">
<h2>Parent</h2>
<div>This div is defined in the Parent's template</div>
<child>
<div ><i>Child got this line from parent </i></div> *2*
</child>
</div>
`,
encapsulation:ViewEncapsulation.Native
})
export class AppComponent {}
-
1 用于在浅绿色背景上渲染 UI 的类选择器
-
2 内容将被投影到子组件的模板上。
在 Chrome 浏览器中使用以下命令运行此应用:
ng serve --app projection1 -o
Chrome 浏览器将渲染 图 8.12 中所示的 UI。
图 8.12. 使用 ViewEncapsulation.Native 运行 projection1 应用

文本“子组件从父组件获取了这一行”是从 AppComponent 投影到 Child-Component 的。你可能想知道为什么你想在 Chrome 浏览器中运行这个应用:因为你指定了 ViewEncapsulation.Native,假设浏览器支持 Shadow DOM,而 Chrome 支持这项功能。下一节将提供更多细节。
注意
ViewEncapsulation 模式与投影无关,可以在任何组件中使用,但我们想使用具有不同样式的父组件和子组件的应用来介绍这个功能。
8.5.1. 视图封装模式
JavaScript 模块允许你在脚本中引入范围,这样它们就不会污染浏览器或任何其他执行环境中的全局空间。那么 CSS 呢?想象一下,父组件和子组件意外地声明了具有相同 CSS 类选择器名称的样式,但定义了不同的背景颜色。浏览器会使用不同的背景渲染组件,还是两个组件都会有相同的背景?
简而言之,Shadow DOM 在浏览器中引入了 CSS 样式的范围和 DOM 节点的封装。Shadow DOM 允许你隐藏所选组件的内部结构,使其不显示在全局 DOM 树中。Shadow DOM 在 Eric Bidelman 的文章“Shadow DOM v1: Self-Contained Web Components”中有很好的解释,该文章可在 mng.bz/6VV6 找到。
我们将使用上一节中的应用来展示 Shadow DOM 和 Angular 的 ViewEncapsulation 模式是如何工作的。@Component() 装饰器的 encapsulation 属性可以有三个值之一:
-
ViewEncapsulation.Native— 这可以与支持 Shadow DOM 的浏览器一起使用。 -
ViewEncapsulation.Emulated— 默认情况下,Angular 模拟 Shadow DOM 支持。 -
ViewEncapsulation.None— 如果样式有相同的选择器,最后一个获胜。
提示
有关 CSS 特异性的更多信息,请参阅css-tricks.com/specifics-on-css-specificity。
如前所述,父组件和子组件都使用 .wrapper 样式。在一个普通的 HTML 页面中,这意味着子组件的 .wrapper 的 CSS 规则将覆盖父组件的。让我们看看你是否能封装子组件中的样式,这样它们就不会与父组件的样式冲突,即使它们的名称相同。
图 8.13 展示了在 ViewEncapsulation.Native 模式下打开开发者工具面板时运行的运行中的应用程序。浏览器为父元素和子元素创建了 #shadow-root 节点(见右侧的两个 #shadow-root 节点)。如果你是在彩色版(电子书)中阅读这本书,你会看到 .wrapper 样式将 <app-root> 的背景涂成深粉色。子组件也有使用浅绿色颜色的 .wrapper 样式,但这不会影响父组件。样式是封装的。子组件的 #shadow-root 作用就像一堵墙,阻止子组件的样式覆盖父组件的样式。只有当你确信你的应用程序的用户将使用支持 Shadow DOM 的浏览器时,你才能使用 ViewEncapsulation.Native。
图 8.13. 浏览器创建两个 #shadow-root 节点

图 8.13 展示了将 encapsulation 属性的值更改为 ViewEncapsulation.Emulated 后发生的情况。Angular 默认使用此模式,因此效果与未向 @Component() 装饰器添加 encapsulation 属性相同。DOM 在 <app-root> 元素内部没有任何 #shadow-root 节点,但 Angular 为父元素和子元素生成额外的属性以区分父元素和子元素的样式。Angular 修改组件样式中的所有 CSS 选择器,以包含生成的属性:
<div _ngcontent-c0="" class="wrapper"> *1*
...
<div _ngcontent-c1="" class="wrapper"> *2*
-
1
<app-root>组件中的样式 -
2
<child>组件中的样式
UI 以相同的方式渲染,使用不同的背景颜色渲染这些组件,如图 8.14 所示,但与图 8.13 相比,底层代码并不相同。
图 8.14. 使用 ViewEncapsulation.Emulated 运行 projection1 应用程序

图 8.15 展示了将封装设置为 ViewEncapsulation.None 时运行的相同示例。在这种情况下,子组件的 wrapper 获胜,整个窗口都显示为子组件的浅绿色背景。
图 8.15. 使用 ViewEncapsulation.None 运行 projection1 应用程序

现在你已经了解了封装模式和基本投影,你可能想知道是否可以将内容投影到组件模板的多个区域。
8.5.2. 投影到多个区域
一个组件的模板中可以包含多个 <ng-content> 标签。让我们考虑一个例子,其中子组件的模板被分为三个区域:标题、内容和页脚,如图 8.16 所示。标题和页脚的 HTML 标记可以由父组件投影,而内容区域可以在子组件中定义。为了实现这一点,子组件需要包含两个由父组件填充的单独的 <ng-content></ng-content> 对,分别是标题和页脚。
图 8.16. 运行 projection2 应用

为了确保页眉和页脚内容将在正确的 <ng-content> 区域中渲染,你将使用 select 属性,它可以是一个有效的 CSS 选择器(CSS 类、标签名等)。子组件的模板可能看起来像这样:
<ng-content select=".header"></ng-content>
<div>This content is defined in child</div>
<ng-content select=".footer"></ng-content>
从父组件到达的内容将通过选择器进行匹配,并在相应的区域中渲染。我们创建了一个单独的应用程序在文件夹 projection2 中,以说明投影到多个区域。以下列表显示了子组件。
列表 8.19. child.component.ts
@Component({
selector: 'child',
styles: ['.wrapper {background: lightgreen;}'],
template: `
<div class="wrapper">
<h2>Child</h2>
<ng-content select=".header"></ng-content><p>
<div>This content is defined in child</div><p>
<ng-content select=".footer"></ng-content>
</div>
`
})
export class ChildComponent {}
注意,你现在有两个 <ng-content> 插槽——一个带有选择器 .header,另一个带有 .footer。父组件将不同内容投影到每个插槽。为了使这个例子更具动态性,你使用绑定在标题中显示今天的日期,如下面的列表所示。
列表 8.20. app.component.ts
@Component({
selector: 'app-root',
styles: ['.wrapper {background: deeppink;}'],
template: `
<div class="wrapper">
<h2>Parent</h2>
<div>This div is defined in the Parent's template</div>
<child>
<div class="header"> *1*
<i>Child got this header from parent {{todaysDate}}</i> *2*
</div>
<div class="footer"> *3*
<i>Child got this footer from parent</i>
</div>
</child>
</div>
`
})
export class AppComponent {
todaysDate = new Date().toLocaleDateString();
}
-
1 将此 div 投影到具有标题选择器的子元素上
-
2 将当前日期绑定到投影的内容
-
3 将此 div 投影到具有页脚选择器的子元素上
注意
投影的 HTML 只能绑定在父作用域中可见的属性,因此你无法在父组件的绑定表达式中使用子组件的属性。
要查看此示例的实际效果,请运行以下命令:
ng serve --app projection2 -o
运行此应用将渲染图 8.16 中显示的页面。
使用 <ng-content> 和 select 属性可以创建一个具有多个区域视图的通用组件,这些区域从父组件获取其标记。
投影与直接绑定到 innerHTML 的比较
或者,你可以通过将组件绑定到 innerHTML 来编程更改组件的 HTML 内容:
<p [innerHTML]="myComponentProperty"></p>
但是,出于以下原因,使用 <ng-content> 而不是绑定到 innerHTML 更可取:
-
innerHTML是一个浏览器特定的 API,而<ng-content>是平台无关的。 -
使用
<ng-content>,你可以定义多个插槽,其中 HTML 片段将被插入。 -
<ng-content>允许你将父组件的属性绑定到投影的 HTML 中。
摘要
-
父组件和子组件应避免直接访问彼此的内部结构,而应通过输入和输出属性进行通信。
-
组件可以通过其输出属性发出自定义事件,并且这些事件可以携带特定于应用程序的有效负载。
-
通信无关组件之间应使用中介者设计模式进行安排。一个公共父组件或可注入的服务可以作为中介。
第九章. 变化检测和组件生命周期
本章涵盖
-
Angular 如何知道需要 UI 更新
-
审查组件生命周期中的里程碑
-
在组件生命周期钩子中编写代码
你迄今为止开发的全部应用程序在用户或程序更新组件属性时都正确地更新了 UI。Angular 是如何知道何时更新 UI 的?在本章中,我们将讨论变化检测(CD)机制,该机制监控你的应用程序的异步事件并决定是否更新 UI。
我们还将讨论 Angular 组件的生命周期以及你可以使用的回调方法钩子,以提供特定于应用程序的代码,在组件创建、生命周期和销毁期间拦截重要事件。
最后,我们将继续在 ngAuction 上工作。这次,你将添加显示产品详情的视图。
9.1. 变化检测的高级概述
当用户使用你的应用程序时,事情会发生变化,组件属性的值(模型)会得到修改。大多数变化都是异步发生的——例如,用户点击按钮,从服务器接收数据,可观察者开始发出值,脚本调用setTimeout()函数,等等。Angular 需要知道异步操作的结果何时可用,以便相应地更新 UI。
对于自动 CD,Angular 使用 zone.js(Zone)库。Angular 订阅 Zone 事件以触发 CD,从而保持组件的模型和 UI 同步。CD 周期由浏览器中发生的任何异步事件启动。变化检测器跟踪组件、服务等中做出的所有异步调用;当它们完成时,它会从上到下遍历组件树,以查看任何组件的 UI 是否需要更新。
注意
CD 机制将组件属性的变化应用到其 UI 上。CD 永远不会更改组件属性的值。
zone.js 库是 Angular 项目中依赖之一。它让你免于手动编写代码来更新 UI,但从 Angular 5 开始,使用 Zone 是可选的。为了说明 zone.js 的作用,让我们做一个实验:你将首先创建一个由 Zone 管理的简单项目,然后你将关闭 Zone。该项目包括以下列表中所示的AppComponent。
列表 9.1. Zone 处于开启状态
@Component({
selector: 'app-root',
template: `<h1>Welcome to {{title}}!</h1>`
})
export class AppComponent {
title = 'app';
constructor() {
setTimeout(() => {this.title = 'Angular 5'}, 5000); *1*
}
}
- 1 异步调用代码,以便 Zone 在五秒后更新 UI
运行此应用程序将渲染“欢迎使用 app!”五秒后,消息更改为“欢迎使用 Angular 5!”让我们将 main.ts 文件中的应用程序引导代码更改为使用 Angular 5 中引入的空 Zone 对象noop:
platformBrowserDynamic().bootstrapModule(AppModule, {ngZone: 'noop'});
现在运行相同的应用程序将渲染“欢迎使用 app!”并且这条消息将永远不会改变。你刚刚关闭了 Zone,应用程序没有更新 UI。
注意
您仍然可以通过在应用程序构造函数中注入ApplicationRef服务并在更新title属性值后调用其tick()方法来启动 CD。
一个 Angular 应用程序的结构是一个视图(组件)的树,根组件位于树的顶部。当 Angular 编译组件模板时,每个组件都会获得自己的变更检测器。当 Zone 启动 CD 时,它进行单次遍历,从根到叶组件,检查每个组件的 UI 是否需要更新(有关 CD 在开发与生产模式下的差异,请参阅 9.2 节末尾的侧边栏“生命周期钩子、变更检测和生产模式”)。在每次异步属性更改时,有没有一种方法可以指示变更检测器不要访问每个组件?
9.1.1. 变更检测策略
对于 UI 更新,Angular 提供了两种 CD 策略:Default和OnPush。如果所有组件都使用Default策略,则无论更改发生在何处,Zone 都会检查整个组件树。
如果特定组件声明了OnPush策略,则只有在组件的输入属性绑定发生变化,或者组件使用AsyncPipe且相应的可观察对象开始发出值时,Zone 才会检查该组件及其子组件。
如果一个采用OnPush策略的组件更改了其模板中绑定的一个属性值,则不会启动变更检测周期。要声明OnPush策略,请将以下行添加到@Component()装饰器中:
changeDetection: ChangeDetectionStrategy.OnPush
展示了使用三个组件(父组件、子组件和孙组件)的
OnPush策略的效果。假设父组件的一个属性被修改了。CD 将从检查该组件及其所有后代开始。
图 9.1. 变更检测策略

图 9.1 的左侧展示了默认 CD 策略:检查所有三个组件是否有更改。右侧展示了当子组件具有OnPush CD 策略时会发生什么。CD 从顶部开始,但看到子组件已声明OnPush策略。如果没有输入属性的绑定发生变化,并且没有使用AsyncPipe的可观察对象发出值(例如,通过ActivatedRoute参数),则 CD 不会检查子组件或孙组件。
图 9.1 展示了一个只有三个组件的小型应用程序,但现实世界的应用程序可以有数百个组件。使用OnPush策略,您可以选择不针对树中的特定分支进行 CD。
图 9.2 展示了由 GrandChild1 组件中的事件引起的 CD 周期。尽管这个事件发生在左下角的叶子组件中,但 CD 周期是从顶部开始的;它对每个分支都进行了执行,除了那些从具有OnPush CD 策略的组件起源且对此组件输入属性没有变化的分支。从 CD 周期中排除的组件显示在白色背景上。
图 9.2. 从 CD 周期中排除一个分支

这只是一个关于 CD 机制的简要概述。如果你需要调整 UI 密集型应用程序的性能,例如包含数百个不断变化值的网格数据,你应该深入了解 CD。关于更改检测的深入覆盖,请参阅 Maxim Koretskyi 在mng.bz/0YqE上发表的文章“关于 Angular 中更改检测你需要知道的一切”。
通常,将OnPush作为每个组件的默认 CD 策略是个好主意。如果你发现某个组件的 UI 没有按预期更新,请检查代码,要么切换回Default更改检测策略,要么通过注入ChangeDetectorRef对象并使用其 API 手动启动 CD 遍历(见angular.io/api/core/ChangeDetectorRef)。
如果你有一个运行缓慢且有很多变化模板元素的组件,多次更改检测的遍历是否会导致这种缓慢?
9.1.2. 配置更改检测
列表 9.2 展示了如何通过启用 Angular 调试工具来配置更改检测。将 main.ts 中的应用程序启动代码更改为以下内容。
列表 9.2. 启用 Angular 调试工具
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
import {AppModule} from './app/app.module';
import {ApplicationRef} from '@angular/core';
import {enableDebugTools} from '@angular/platform-browser';
platformBrowserDynamic().bootstrapModule(AppModule).then((module) => {
const applicationRef = module.injector.get(ApplicationRef); *1*
const appComponent = applicationRef.components[0]; *2*
enableDebugTools(appComponent); *3*
});
-
1 获取启动应用程序的引用
-
2 获取应用程序顶层组件的引用
-
3 启用 Angular 调试工具
启动你的应用程序,然后在浏览器控制台中输入以下命令:
ng.profiler.timeChangeDetection({record: true})
现在,你的应用程序将开始报告每个 CD 周期所花费的时间,如图 9.3 所示。
图 9.3. 配置更改检测

我们已经涵盖了更改检测,现在让我们熟悉组件的私有生活。
9.2. 组件生命周期
在 Angular 组件的生命周期中会发生各种事件:它被创建、响应不同的事件,并被销毁。正如上一节所述,当组件被创建时,CD 机制开始监控它。组件被初始化、添加到 DOM 中并由浏览器渲染。之后,组件的状态(其属性值)可能会改变,导致 UI 的重渲染,最终组件被销毁。
图 9.4 显示了你可以添加自定义代码以拦截生命周期事件的方法(方法)。如果 Angular 在你的应用程序中看到任何这些方法被实现,它将调用它们。
图 9.4. 组件的生命周期钩子

在浅灰色背景上显示的回调函数只会被调用一次,而在深色背景上的回调函数可以在组件的生命周期内被多次调用。用户在初始化阶段完成后看到组件。然后变更检测机制确保组件的属性与其 UI 保持同步。如果组件由于路由导航或结构指令(如 *ngIf)从 DOM 树中移除,Angular 将启动销毁阶段。
当组件实例正在创建时,构造函数首先被调用,但在构造函数中组件的属性尚未初始化。构造函数的代码完成后,Angular 将调用以下回调 如果你实现了它们:
-
ngOnChanges()— 当父组件修改(或初始化)绑定到子组件输入属性的值时被调用。如果组件没有输入属性,则不会调用ngOnChanges()。 -
ngOnInit()— 在第一次调用ngOnChanges()之后被调用,如果有的话。尽管你可能在构造函数中初始化一些组件变量,但组件的属性尚未准备好。当ngOnInit()被调用时,组件属性已经被初始化,这就是为什么这个方法主要用于初始数据获取。 -
ngDoCheck()— 在变更检测器的每次遍历中调用。如果你想实现一个自定义的变更检测算法或添加一些调试代码,请在ngDoCheck()中编写它。但请注意,在ngDoCheck()方法中放置任何代码都可能影响你应用程序的性能,因为此方法在变更检测周期的每次遍历中都会被调用。 -
ngAfterContentInit()— 当子组件的状态初始化并且投影完成时被调用。只有在你组件的模板中使用了<ng-content>时,这个方法才会被调用。 -
ngAfterContentChecked()— 在变更检测周期中,如果用于投影内容的绑定发生变化,并且组件从父组件获取了更新后的内容,则在此方法上调用该组件。 -
ngAfterViewInit()— 在组件的视图完全初始化后调用。我们在第八章的 8.4 节 中使用了它。 -
ngAfterViewChecked()— 当变更检测机制检查组件模板的绑定是否有任何更改时被调用。由于在此或其他组件中的修改,此回调可能被多次调用。 -
ngOnDestroy()— 当组件正在销毁时被调用。使用此回调来清理不需要的资源,例如,取消显式创建的订阅或移除计时器。
每个生命周期回调都在接口中以不带前缀 ng 的回调名称声明。例如,如果你计划在 ngOnChanges() 回调中实现功能,请将 implements OnChanges 添加到你的类声明中。
让我们考虑一些代码示例,以说明生命周期钩子的使用。以下代码列表说明了 ngOnInit() 的使用。
列表 9.3. 在 ngOnInit() 中获取数据
@Input() productId: number; *1*
constructor(private productService: ProductService) { } *2*
ngOnInit() {
this.product = this.productService.getProductById(this.productId); *3*
}
-
1 声明输入属性
-
2 注入服务,但在构造函数中不使用它
-
3 在 ngOnInit() 中使用服务以确保 productId 已初始化
此代码使用输入属性 productId 的值作为 getProductById() 方法的参数。如果你在构造函数中调用了 getProductById(),则 productId 属性尚未初始化。到 ngOnInit() 被调用时,productId 已初始化,你可以安全地调用 getProductById()。
当组件被销毁时,会调用 ngOnDestroy() 钩子。例如,当你使用路由器从组件 A 导航到组件 B 时,组件 A 会被销毁,而组件 B 被创建。如果你在组件 A 中创建了一个显式的订阅,别忘了在 ngOnDestroy() 中取消订阅。此钩子也由 Angular 服务支持。
9.2.1. 在 ngOnChanges 钩子中捕获更改
现在我们将编写一个小型应用程序,它使用 ngOnChanges() 并说明了绑定对原始值与对象值的不同影响。此应用程序将包括父组件和子组件,后者将有两个输入属性:greeting 和 user。第一个属性是 string 类型,第二个属性是一个具有一个属性 name 的 Object。要理解 ngOnChanges() 回调可能或可能不被调用,你需要熟悉可变与不可变值的概念。
可变与不可变值
JavaScript 字符串是原始数据类型,它们是 不可变的—当字符串值在内存的某个位置创建时,你无法在那里更改它。考虑以下代码片段:
let greeting = "Hello";
greeting = "Hello Mary";
第一行在内存中创建值 Hello。第二行不会改变该地址的值,而是在不同的内存位置创建新的字符串 Hello Mary。现在你有两个字符串在内存中,每个都是不可变的。
如果 greeting 变量绑定到组件的输入属性,那么其绑定已更改,因为此变量的值最初在内存的一个位置,然后地址发生了变化。
JavaScript 对象(以及函数和数组)是可变的,并且存储在堆内存中,而对象引用仅存储在栈上。在某个内存位置创建对象实例后,当堆内存中对象属性的值发生变化时,栈上对此对象的引用不会改变。考虑以下代码:
var user = {name: "John"};
user.name = "Mary";
在第一行之后,对象被创建,user 对象实例的引用存储在栈内存中,并指向某个内存位置。字符串 "John" 在另一个内存位置创建,user.name 变量知道它在内存中的位置。
在上述代码片段的第二行执行后,新的字符串 "Mary" 在另一个位置创建。但引用变量 user 仍然存储在栈上的相同位置。换句话说,您修改了对象的内容,但没有改变指向此对象的引用变量的值。要使对象不可变,每当任何属性发生变化时,都需要创建对象的新实例。
| |
提示
您可以在 mng.bz/bzL4 上了解更多关于 JavaScript 数据类型和数据结构的信息。
让我们在子组件中添加 ngOnChanges() 钩子来演示它是如何拦截输入属性修改的。此应用程序具有父组件和子组件。子组件有两个输入属性(greeting 和 user)。父组件有两个输入字段,用户可以修改它们的值,这些值绑定到子组件的输入属性。让我们看看 ngOnChanges() 是否会被调用以及它将获取哪些值。父组件的代码如下所示。
列表 9.4. app.component.ts
@Component({
selector: 'app-root',
styles: ['.parent {background: deeppink}'],
template: `
<div class="parent">
<h2>Parent</h2>
<div>Greeting: <input type="text" [(ngModel)]="myGreeting"> *1*
</div>
<div>User name: <input type="text" [(ngModel)]="myUser.name"> *2*
</div>
<child [greeting]="myGreeting" *3*
[user]="myUser"> *4*
</child>
</div>
`
})
export class AppComponent {
myGreeting = 'Hello';
myUser: {name: string} = {name: 'John'};
}
-
1 使用双向绑定同步输入问候语和 myGreeting
-
2 使用双向绑定同步输入用户名和 myUser.name
-
3 将 myGreeting 绑定到子组件的输入属性 greeting
-
4 将 myUser 绑定到子组件的输入属性 user
子组件通过其输入变量从父组件接收值。该组件实现了 OnChanges 接口。在 ngOnChanges() 方法中,每当任何输入变量的绑定发生变化时,立即打印接收到的数据,如下所示。
列表 9.5. child.component.ts
@Component({
selector: 'child',
styles: ['.child {background: lightgreen}'],
template: `
<div class="child">
<h2>Child</h2>
<div>Greeting: {{greeting}}</div>
<div>User name: {{user.name}}</div>
</div>
`
})
export class ChildComponent implements OnChanges { *1*
@Input() greeting: string;
@Input() user: {name: string};
ngOnChanges(changes: {[key: string]: SimpleChange}) { *2*
console.log(JSON.stringify(changes, null, 2));
}
}
-
1 实现了 OnChanges 接口
-
2 当输入属性的绑定发生变化时,Angular 会调用 ngOnChanges()
当 Angular 调用 ngOnChanges() 时,它提供一个包含修改后的输入属性旧值和新值以及表示这是否是第一次绑定更改的标志的 SimpleChange 对象。您使用 JSON.stringify() 来美化打印接收到的值。
让我们看看在 UI 中更改 greeting 和 user.name 是否会导致子组件上调用 ngOnChanges()。我们运行了此应用程序,删除了单词 Hello 的最后一个字母,并将用户名从 John 更改为 John Smith,如图 图 9.5 所示。
图 9.5. ngOnChanges() 在问候语更改后调用

初始时,ngOnChanges() 对两个属性都进行了调用。注意 "firstChange": true——这是绑定中的第一次更改。在我们删除问候语 Hello 中的字母 o 之后,ngOnChanges() 再次被调用,并且 firstChange 标志变为 false。但是,将用户名从 John 更改为 John Smith 并没有调用 ngOnChanges(),因为可变对象 myUser 的绑定没有改变。
要查看此应用程序的实际运行情况,请在项目生命周期中运行 npm install,然后运行以下命令:
ng serve --app lifecycle -o
当仅对象属性更改时,Angular 不会更新输入属性的绑定,这就是为什么子组件上的 ngOnChanges() 没有被调用。但是,更改检测机制仍然捕获了更改。这就是为什么属性 user.name 的新值 "John Smith" 已经在子组件中渲染的原因。
提示
将 changeDetection: ChangeDetectionStrategy.OnPush 添加到 ChildComponent 的模板中,并且其 UI 不会反映父组件用户名的更改。绑定到子组件的 user 属性没有改变;因此,更改检测器甚至不会访问子组件以进行 UI 更新。
您可能赞赏更改检测器正确更新 UI,但您仍然需要以编程方式捕获用户名更改的瞬间并实现一些处理此更改的代码?
9.2.2. 在 ngDoCheck 钩子中捕获更改
假设您想捕获 JavaScript 对象发生变异的瞬间。让我们将前述部分的子组件重写为使用 ngDoCheck() 回调而不是 ngOnChanges()。目标如下:
-
捕获绑定到
Input()属性的对象发生变异的瞬间。 -
找出绑定对象的哪个属性发生了变化。
-
获取更改属性的旧值。
-
获取此属性的新的值。
为了实现这些目标,您将实现 DoCheck 接口并使用 Angular 的 KeyValueDiffers、KeyValueChangeRecord 和 KeyValueDiffer。您想监控 user 对象及其属性。
首先,您将注入 KeyValueDiffers 服务,该服务实现了各种 Angular 艺术品的差异策略。其次,您需要创建一个 KeyValueDiffer 类型的对象,该对象将专门监控 user 对象的更改。当发生更改时,您将获得一个包含 key、previousValue 和 currentValue 属性的 KeyValueChangeRecord 类型的对象。新的子组件代码如下所示。
列表 9.6. child.component-docheck.ts
import {
DoCheck, Input, SimpleChange, Component, KeyValueDiffers,
KeyValueChangeRecord, KeyValueDiffer} from "@angular/core";
@Component({
selector: 'child',
styles: ['.child {background: lightgreen}'],
template: `
<div class="child">
<h2>Child</h2>
<div>Greeting: {{greeting}}</div>
<div>User name: {{user.name}}</div>
</div>
`
})
export class ChildComponent implements DoCheck {
@Input() greeting: string;
@Input() user: {name: string};
differ: KeyValueDiffer<string, string>; *1*
constructor(private _differs: KeyValueDiffers) { } *2*
ngOnInit() {
this.differ = this._differs.find(this.user).create(); *3*
}
ngDoCheck() { *4*
if (this.user && this.differ) {
const changes = this.differ.diff(this.user); *5*
if (changes) {
changes.forEachChangedItem( *6*
(record: KeyValueChangeRecord<string, string>) =>
console.log(`Got changes in property ${record.key} *7*
before: ${record.previousValue} after: ${record.currentValue}
`));
}
}
}
}
-
1 声明一个用于存储差异的变量
-
2 注入用于监控更改的服务
-
3 初始化用于存储用户对象差异的差异变量
-
4 实现回调 ngDoCheck()
-
5 检查用户对象属性是否已更改
-
6 获取每个用户属性的更改记录
-
7 在控制台打印更改
diff()方法返回一个包含更改记录的KeyValueChanges对象,并提供诸如forEachAddedItem()、forEachChangedItem()、forEachRemovedItem()等方法。在你的组件中,你只对捕获更改感兴趣,因此你使用forEachChangedItem(),它返回每个更改属性的KeyValueChangeRecord。
KeyValueChangeRecord接口定义了key、currentValue和previousValue属性,你可以在控制台打印这些属性。图 9.6 显示了在用户名输入字段中删除原始的John字母n之后发生的情况。
图 9.6. ngDoCheck()在变更检测器的每次遍历之后被调用。

捕获用户名更改似乎不是一个实用的用例,但某些应用程序确实需要在属性值更改时调用特定的业务逻辑。例如,金融应用程序可能需要记录交易员每一步的操作。如果交易员在$101 的价格下下了一个买单,然后立即将价格更改为$100,那么这必须在日志文件中跟踪。这可能是一个捕获此类更改并在DoCheck()回调中添加日志的好用例。
要查看此应用程序的实际运行情况,在生命周期/app.module.ts 文件中,将子组件的导入语句修改为import {ChildComponent} from "./child.component-docheck";并运行以下命令:
ng serve --app lifecycle -o
警告
我们再次提醒您:只有在找不到其他拦截数据更改的方法时才使用ngDoCheck(),因为它可能会影响您应用程序的性能。
| |
生命周期钩子、变更检测和生产模式
在本章开头,我们提到变更检测器从组件树顶部到底部进行一次遍历,以查看组件的 UI 是否需要更新。如果你的应用程序以生产模式运行,这是正确的,但在开发模式(默认)下,变更检测器进行两次遍历。
如果你运行本书中的大多数应用程序时打开浏览器的控制台,你会看到一个消息,表明 Angular 正在开发模式下运行,该模式在框架内执行断言和其他检查。其中一种断言验证变更检测遍历不会导致任何绑定(例如,你的代码在组件生命周期回调期间不会在 CD 周期中修改 UI)。如果你的代码试图在生命周期回调中从 UI 更改 UI,Angular 将抛出异常。
当你准备好进行生产构建时,请开启生产模式,这样变更检测器只会进行一次遍历,并且不会执行额外的绑定检查。要启用生产模式,请在调用bootstrap()方法之前在你的应用中调用enableProdMode()。启用生产模式还将导致应用性能更好。
现在我们已经涵盖了组件生命周期的所有重要部分,让我们继续在 ngAuction 上工作。
9.3. 实践:将产品视图添加到 ngAuction
在第七章中,你创建了 ngAuction 的首页。在本节中,你将创建产品视图,它将在用户点击首页中产品瓷砖之一时渲染。图 9.7 显示了如果用户选择复古蓝牙收音机,产品视图将如何显示。
图 9.7. 产品视图

除了右侧收音机的信息外,左侧还有其他推荐的产品,你希望用户考虑。当展示产品描述时,亚马逊使用相同的营销技术。你可能已经在亚马逊的产品页面上看到过“更多考虑的商品”或“经常一起购买”的部分。根据视口大小,推荐产品可以在产品视图的左侧或底部渲染。
图 9.7 中显示的视图将实现为ProductComponent,它将包括两个子组件:ProductDetailComponent和ProductSuggestionComponent。在产品视图中,你将使用 Flex 布局库,以便 UI 布局能够调整到用户设备视口的可用宽度。
还有一件事:你的产品视图将作为一个懒加载的功能模块来实现,这在第四章的 4.3 节中有详细解释。第四章。让我们开始吧。
注意
如果你通过第七章中实践部分的解释和说明创建了 ngAuction 的这个版本,你可以继续在这个应用上工作。你将在 chapter9/ng-auction 文件夹中找到实现了产品视图的 ngAuction 的完整版本。
9.3.1. 创建产品组件和模块
你将首先通过运行以下命令生成一个ProductModule功能模块:
ng g m product
该命令将创建包含 product.module.ts 文件的 product 文件夹。因为你将在产品视图中使用 Flex 布局库,所以将FlexLayoutModule添加到@NgModule()装饰器的imports属性中,如下所示。
列表 9.7. product.module.ts
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {FlexLayoutModule} from '@angular/flex-layout';
@NgModule({
imports: [
CommonModule,
FlexLayoutModule
],
declarations: []
})
export class ProductModule { }
这个功能模块将包含三个组件:ProductComponent、ProductDetailComponent和ProductSuggestionComponent。后两个将是子组件,你希望它们位于产品文件夹下的单独子文件夹中。你将使用以下命令生成这些组件:
ng g c product
ng g c product/product-detail
ng g c product/product-suggestion
这些命令将生成三个组件,并将它们的名称添加到产品模块的 declarations 属性中。当用户在主页组件中点击特定的产品瓷砖时,您将懒加载 ProductModule。为此,您将为 products/:productId 路径配置一个额外的路由,因此 app.component.ts 文件将如下所示。
列表 9.8. app.routing.ts
import {Route} from '@angular/router';
export const routes: Route[] = [
{
path: '',
loadChildren: './home/home.module#HomeModule'
},
{
path: 'products/:productId',
loadChildren: './product/product.module#ProductModule'
}
];
现在您可以继续实现支持产品视图的组件。
9.3.2. 实现产品组件
您的 ProductComponent 将作为两个子组件 ProductDetailComponent 和 ProductSuggestionComponent 的包装器。产品组件实现了以下功能:
-
它应该是产品模块的默认路由。
-
它应该接收从主页组件传递的产品 ID。
-
它应该获取
ProductService对象的引用以接收产品详情。 -
它应根据视口宽度管理其子元素的布局。
要在用户导航到产品视图时渲染 ProductComponent,您需要将以下内容添加到 ProductModule 中。
列表 9.9. product.module.ts
...
import {RouterModule} from '@angular/router';
@NgModule({
imports: [
...
RouterModule.forChild([
{path: '', component: ProductComponent}
])
],
...
})
export class ProductModule {}
在上一节中,您在根模块中配置了路径 'products/:productId' 的路由,这意味着 ProductComponent 必须接收请求的产品 ID。您还需要在 ProductComponent 的构造函数中注入 ProductService,如下所示。
列表 9.10. product.component.ts
import { filter, map, switchMap} from 'rxjs/operators';
import { Component } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs';
import { Product, ProductService } from '../shared/services';
@Component({
selector: 'nga-product',
styleUrls: [ './product.component.scss' ],
templateUrl: './product.component.html'
})
export class ProductComponent {
product$: Observable<Product>;
suggestedProducts$: Observable<Product[]>;
constructor(
private route: ActivatedRoute,
private productService: ProductService
) {
this.product$ = this.route.paramMap
.pipe(
map(params => parseInt(params.get('productId') || '', 10)), *1*
filter(productId => !!productId), *2*
switchMap(productId => this.productService.getById(productId)) *3*
);
this.suggestedProducts$ = this.productService.getAll(); *4*
}
}
-
1 获取产品 ID
-
2 确保产品 ID 是一个有效的数字
-
3 切换到检索指定产品详细信息的观察者
-
4 初始化用于填充建议产品的观察者
此组件从 ActivatedRoute 对象接收产品 ID。在 第六章 的 第 6.6 节 中,您看到了直接订阅 paramMap 的代码。在这种情况下,您没有显式调用 subscribe() 方法,但将在模板中使用 async 管道。这就是为什么您在 map 操作符中使用 parseInt() 将给定的参数从字符串转换为数字的原因。
如果用户在 URL 中输入字母字符而不是产品 ID,例如 http://localhost:4200/products/abc?在这种情况下,parseInt() 返回 NaN,您将在 filter 操作符中使用双感叹号语法 !!productId 来捕获它。非字母字符不会通过 filter 操作符。
数字产品 ID 将被提供给 switchMap 操作符,该操作符切换到由 getById() 方法返回的观察者。要获取建议的产品,您将调用 getAll() 方法。
注意
之前我们提到ngOnInit()是获取数据的正确位置,但在这个代码示例中,你在构造函数中这样做。这会造成问题吗?在这个情况下不会,因为getById()和getAll()都没有使用在构造函数中初始化的组件属性。
| |
作业
ProductComponent的代码可以进行一些改进,我们希望您自己实现它们。
您的产品组件调用productService.getAll()来检索推荐产品。这并不完全正确。假设您选择了太阳镜。产品详情组件将显示太阳镜的描述,并且太阳镜也将作为推荐产品显示。看看您能否修改产品组件的实现,使其不会推荐用户已经选择的产品。
如果您在产品页面上浏览器中输入无效的产品 ID(例如 http://localhost:4200/products/abc),您将看不到任何错误,因为filter()运算符将忽略此请求,但页面将只渲染推荐产品。为了以用户友好的方式处理这种情况,创建一个解析守卫,如果服务找不到提供 ID 的产品,则取消导航并通知用户。例如,您可以使用 Angular Material snack-bar 组件进行通知(见mng.bz/1hx1)。
产品组件的模板将在 product.component.html 文件中实现。它将托管<nga-product-detail>和<nga-product-suggestion>组件,并将产品数据传递给它们以进行渲染,如下所示列表所示。
列表 9.11. product.component.html
<div class="wrapper"
fxLayout="column"
fxLayout.>-md="row-reverse"> *1*
<nga-product-detail
fxFlex="auto" *2*
fxFlex.>-md="65%"
*ngIf="product$ | async as product" *3*
[product]="product"> *4*
</nga-product-detail>
<nga-product-suggestion
fxFlex="auto"
fxFlex.>-md="35%"
*ngIf="suggestedProducts$ | async as products" *5*
[products]="products"> *6*
</nga-product-suggestion>
</div>
-
1 在大于中等视口的屏幕上,在右侧显示产品详情,在左侧显示推荐产品
-
2 足够的空间来渲染此组件,但不要更多
-
3 如果产品生成了一个值,将其放入局部模板变量 product 中
-
4 将产品对象传递给
进行渲染 -
5 如果 suggestedProducts$生成了一个值,将其放入局部模板变量 products 中
-
6 将产品传递给
进行渲染。
最佳实践
列表 9.11 使用了async as语法进行订阅。async as product意味着“定义局部模板变量product,并将发射的值存储在那里。”这种语法在您需要多次引用发射对象时很有用。如果没有async as语法,它可以写成这样:
*ngIf = "product$ | async"
[product] = "product$ | async"
这样就会创建两个订阅而不是一个,您想要避免这种情况,尤其是如果订阅会触发副作用,如 HTTP 请求或一些额外的处理,如过滤或排序大量数据。
| |
注意
从现在起,为了节省本书的空间,我们不会包括包含 ngAuction 组件样式的 .scss 文件的内容。请参考本书附带代码示例,可在 github.com/Farata/angulartypescript 和 www.manning.com/books/angular-development-with-typescript-second-edition 找到。
9.3.3. 实现产品详情组件
ProductDetailComponent 是一个展示组件,其中不包含业务逻辑,并通过其输入属性渲染提供的产品。它是 ProductComponent 的子组件,如下面的列表所示。
列表 9.12. product-detail.component.ts
@Component({
selector: 'nga-product-detail',
styleUrls: ['./product-detail.component.scss'],
templateUrl: './product-detail.component.html'
})
export class ProductDetailComponent {
@Input() product: Product;
}
该组件的模板如下所示。它使用 Flex Layout 库的指令支持响应式网页设计(RWD)。
列表 9.13. product-detail.component.html
<div class="wrapper"
ngClass.lt-md="wrapper--lt-md"
ngClass.>-md="wrapper-->-md"
fxLayout="row"
fxLayoutAlign="center"
fxLayout.xs="column"
fxLayoutAlign.xs="center center"> *1*
<div fxFlex="50%"> *2*
<img class="thumbnail"
[attr.alt]="product.title"
[attr.src]="product.imageUrl">
</div>
<div fxFlex="50%"> *3*
<div class="info">
<h1 class="info__title">{{product?.title}}</h1>
<div class="info__description">{{product?.description}}</div>
<div class="info__bid">
<span>
<span class="info__bid-value" *4*
ngClass.lt-md="info__bid-value--lt-md">
{{product?.price | currency: 'USD': 'symbol': '.0'}}</span>
<span class="info__bid-value-decimal"
ngClass.lt-md="info__bid-value-decimal--lt-md">.00</span>
</span>
<span class="info__bid-label">LAST BID</span>
</div>
<button class="info__bid-button"
mat-raised-button *5*
color="accent">
PLACE BID {{(product?.price + 5) | currency: 'USD': 'symbol': '.0'}} *6*
</button>
</div>
</div>
</div>
-
1 在水平和垂直方向上居中子组件的内容
-
2 视口的一半用于产品图片。
-
3 视口的一半用于标题、描述和竞标控制。
-
4 最后的竞标金额
-
5 使用 Angular Material 的按钮
-
6 用户可以以 $5 的增量进行竞标。
在这个版本的拍卖中,你不需要实现竞标功能。你将在第十三章(kindle_split_022.xhtml#ch13)的手动操作部分完成这项工作。
由于你使用了 Angular Material 库中的 mat-raised-button,请将 MatButtonModule 添加到产品模块
列表 9.14. product.module.ts
....
import {MatButtonModule} from '@angular/material/button';
@NgModule({
imports: [
...
MatButtonModule
]
...
})
export class ProductModule {}
9.3.4. 实现产品推荐组件
在现实世界的在线商店或拍卖中,产品推荐组件会显示用户可能考虑购买的同类别相似产品。在这个版本的 ngAuction 中,你将在“更多考虑项目”的标题下显示所有你的产品(你只有十几个),就像你在图 9.7 左侧看到的那样图 9.7。
ProductSuggestionComponent 是 ProductComponent 的第二个子组件,product-suggestion.component.ts 文件的内容如下所示。
列表 9.15. product-suggestion.component.ts
import { map, startWith } from 'rxjs/operators';
import { Component, Input } from '@angular/core';
import { ObservableMedia } from '@angular/flex-layout';
import { Observable } from 'rxjs';
import { Product } from '../../shared/services';
@Component({
selector: 'nga-product-suggestion',
styleUrls: [ './product-suggestion.component.scss' ],
templateUrl: './product-suggestion.component.html'
})
export class ProductSuggestionComponent {
@Input() products: Product[];
readonly columns$: Observable<number>;
readonly breakpointsToColumnsNumber = new Map([ *1*
[ 'xs', 2 ],
[ 'sm', 3 ],
[ 'md', 5 ],
[ 'lg', 2 ],
[ 'xl', 3 ],
]);
constructor(private media: ObservableMedia) { *2*
this.columns$ = this.media.asObservable()
.pipe(
map(mc => <number>this.breakpointsToColumnsNumber.get(mc.mqAlias)),
startWith(3) // bug workaround *3*
);
}
}
-
1 为不同视口大小设置网格列数
-
2 从 Flex Layout 库注入 ObservableMedia 服务
-
3 根据媒体查询别名获取网格列数
ProductSuggestionComponent 的代码与在第七章(kindle_split_016.xhtml#ch07)的手动操作部分为 ngAuction 开发的 HomeComponent 的代码类似。在这种情况下,你根据视口大小使用不同的网格列数,考虑到屏幕的大部分将被 ProductDetailComponent 占用。产品推荐组件的模板如下所示。
列表 9.16. product-suggestion.component.html
<div class="info__title" fxLayout="row">
More items to consider:
</div>
<mat-grid-list [cols]="columns$ | async" gutterSize="16px"> *1*
<mat-grid-tile class="tile" *ngFor="let product of products"> *2*
<a class="tile__content"
fxLayout
fxLayoutAlign="center center"
[routerLink]="['/products', product.id]"> *3*
<div class="tile__thumbnail"
[ngStyle]="{'background-image':
'url(' + product.imageUrl + ')'}"></div>
</a>
</mat-grid-tile>
</mat-grid-list>
-
1 使用异步管道订阅列
-
2 对于每个产品,渲染一个包含产品信息的锚标签中的瓷砖
-
3 如果用户点击瓷砖,则显示另一个产品信息
由于你使用了 Angular Material 库中的<mat-grid-list>,请将MatGridListModule添加到产品模块中。
列表 9.17. product.module.ts
....
import { MatGridListModule } from '@angular/material/grid-list';
@NgModule({
imports: [
...
MatGridListModule
]
...
})
export class ProductModule {}
要运行实现路由和产品视图的 ngAuction 版本,请使用以下命令:
ng serve -o
在 Chrome Dev Tools 中打开网络标签页,点击其中一个产品。你会看到产品模块的代码和资源是懒加载的。
在第十一章(kindle_split_020.xhtml#ch11)的动手实践部分,你将添加搜索功能和类别标签,以便轻松按类别过滤产品。
摘要
-
变更检测机制自动监控组件属性的变化,并相应地更新 UI。
-
你可以标记应用程序组件树中选定的分支,使其排除在变更检测过程之外。
-
在组件生命周期钩子中编写应用程序代码,确保此代码与 UI 更新同步执行。
第十章. 介绍表单 API
本章涵盖
-
理解 Angular 表单 API
-
使用模板驱动表单
-
使用响应式表单
HTML 提供了显示表单、验证输入值以及将数据提交到服务器的基本功能。但 HTML 表单可能不足以满足现实世界的应用需求,这些应用需要一种程序化处理输入数据、应用自定义验证规则、显示用户友好的错误信息、转换输入数据格式以及选择将数据提交到服务器的方式的方法。对于商业应用来说,在选择 Web 框架时,最重要的考虑因素之一是它处理表单的能力如何。
Angular 为处理表单提供了丰富的支持。它不仅超越了常规的数据绑定,还将表单字段视为一等公民,并提供了对表单数据的精细控制。在本章中,我们将向您介绍两个表单 API:模板驱动和响应式。
10.1. 两个表单 API
每个由 Angular 驱动的表单都有一个底层模型对象,用于存储表单数据。在 Angular 中处理表单有两种方法:模板驱动和响应式。这两种方法分别作为两个不同的 API(一组指令和 TypeScript 类)公开。
使用 模板驱动 API,表单完全通过组件的模板使用指令编程,模型对象由 Angular 隐式创建。模板定义了表单的结构、字段格式和验证规则。由于在定义表单时你受限于 HTML 语法,因此模板驱动方法仅适用于简单表单。
使用响应式 API,你需要在 TypeScript 代码中显式创建模型对象,然后使用特殊指令将 HTML 模板元素链接到该模型的属性。你使用 FormControl、FormGroup 和 FormArray 类显式构建表单模型对象。在模板驱动方法中,你不会直接访问这些类,而在响应式方法中,你显式创建这些类的实例。对于非平凡表单,响应式方法是一个更好的选择。
在开始使用之前,模板驱动和响应式 API 都需要显式启用。要启用响应式表单,请将 ReactiveFormsModule 从 @angular/forms 添加到 NgModule 的 imports 列表中。对于模板驱动表单,如以下示例所示,导入 FormsModule。
列表 10.1. 准备使用模板驱动表单 API
...
import { FormsModule} from '@angular/forms';
@NgModule({
imports: [ BrowserModule,
FormsModule] *1*
...
})
class AppModule {}
- 1 添加了对模板驱动表单 API 的支持
是时候更详细地讨论这两个 API 了。
10.2. 模板驱动表单
使用模板驱动 API,你只能在组件的模板中使用指令。这些指令包含在 FormsModule 中:NgModel、NgModelGroup 和 NgForm。我们将简要介绍这些指令,然后应用模板驱动方法到示例注册表单中。
10.2.1. 表单指令
本节简要介绍了 FormsModule 的三个主要指令:NgModel、NgModelGroup 和 NgForm。我们将向您展示它们如何在模板中使用,并突出它们最重要的功能。
NgForm
NgForm 是表示整个表单的指令。它自动附加到每个 <form> 元素上。NgForm 隐式创建一个 FormGroup 类的实例,该实例表示模型并存储表单数据(关于 FormGroup 的更多内容将在本章后面介绍)。NgForm 自动发现所有带有 NgModel 指令的子 HTML 元素,并将它们的值添加到表单模型对象中。
您可以将隐式创建的 NgForm 对象绑定到局部模板变量,以便在模板内部访问 NgForm 对象的值,如下面的列表所示。
列表 10.2. 将 NgForm 绑定到模板变量
<form #f="ngForm"></form> *1*
<pre>{{ f.value | json }}</pre> *2*
-
1 声明一个局部模板变量 f 并将其绑定到 ngForm
-
2 显示表单模型中的值
局部模板变量 f 指向 <form> 元素上附加的 NgForm 实例。然后您可以使用 f 变量来访问 NgForm 对象的实例成员。其中之一是 value,它表示一个包含所有表单字段当前值的 JavaScript 对象。您可以通过标准的 json 管道传递它,以在页面上显示表单的值。
NgForm 截获标准 HTML 表单的 submit 事件并阻止自动表单提交。相反,它发出自定义的 ngSubmit 事件:
<form #f="ngForm" (ngSubmit)="onSubmit(f.value)"></form>
这通过事件绑定语法订阅 ngSubmit 事件。onSubmit 处理器是在组件中定义的一个具有任意名称的方法,当 ngSubmit 事件发出时会被调用。为了将所有表单的值作为参数传递给此方法,请使用局部模板变量(例如,f)来访问 NgForm 的 value 属性。
NgModel
第二章第 2.6.2 节 讨论了如何使用 NgModel 指令进行双向数据绑定。但在表单 API 中,NgModel 扮演着不同的角色:它标记了应该成为表单模型一部分的 HTML 元素。
在表单 API 的上下文中,NgModel 表示表单上的单个字段。如果一个 HTML 元素包含 ngModel,Angular 会隐式创建一个 FormControl 类的实例,该实例表示模型并存储字段数据(关于 FormControl 的更多内容将在本章后面介绍)。请注意,表单 API 不需要为 ngModel 分配值,也不需要围绕此属性使用任何类型的括号,如下面的列表所示。
列表 10.3. 将 NgModel 指令添加到 HTML 元素
<form #f="ngForm">
<input type="text"
name="username" *1*
ngModel> *2*
</form>
-
1 名称属性是必需的,这样您可以在代码中访问其值。
-
2 确保此 字段包含在表单模型对象中
NgForm.value 属性指向包含所有表单字段值的 JavaScript 对象。字段名称属性的值成为 NgForm.value 中对应属性的属性名。
注意
虽然实现表单指令的类名首字母大写,但在模板中它们的名称应该以小写字母开头(例如,NgForm 与 ngForm)。
NgModelGroup
NgModelGroup 表示表单的一部分,允许您将表单字段分组在一起。像 NgForm 一样,它隐式创建了一个 FormGroup 类的实例。NgModelGroup 在存储在 NgForm.value 中的对象内部创建一个嵌套对象。NgModelGroup 的所有子字段都成为嵌套对象的属性,如下所示。
列表 10.4. 嵌套表单
<form #f="ngForm">
<div ngModelGroup="passwords"> *1*
<input type="text" name="password" ngModel>
<input type="text" name="pconfirm" ngModel>
</div>
</form>
<!-- Access the values from the nested object-->
<pre>Password: {{ f.value.passwords.password }}</pre> *2*
<pre>Password confirmation: {{ f.value.passwords.pconfirm }}</pre> *2*
-
1 ngModelGroup 属性需要一个字符串值,该值成为表示嵌套表单的属性名。
-
2 使用嵌套对象 passwords 作为参考,访问密码和 pconfirm 表单控件的值
表 10.1 包含了在模板驱动表单中使用的指令摘要。
表 10.1. 模板驱动表单指令
| 指令 | 描述 |
|---|---|
| NgForm | 隐式创建的指令,表示整个表单 |
| ngForm | 在模板中使用,用于将模板元素(例如, |
| NgModel | 隐式创建的指令,用于标记要包含在表单模型中的 HTML 元素 |
| ngForm | 在模板中的表单元素(例如,)中使用,以包含在表单模型中 |
| name | 在模板中的表单元素中使用,用于指定其在表单模型中的名称 |
| NgModelGroup | 隐式创建的指令,表示表单的一部分,例如密码和确认密码字段 |
| ngModelGroup | 在模板中使用,用于为表单的一部分命名,以便将来引用 |
| ngSubmit | 拦截 HTML 表单的提交事件 |
10.2.2. 将模板驱动 API 应用到 HTML 表单
让我们创建一个简单的用户注册表单,应用模板驱动表单 API。您还将添加验证逻辑并启用对 ngSubmit 事件的程序性处理。您将从创建模板开始,然后处理 TypeScript 部分。首先,修改标准的 HTML <form> 元素以匹配以下列表。
列表 10.5. Angular 感知表单
<form #f="ngForm" *1*
(ngSubmit)="onSubmit(f.value)"> *2*
<!-- Form controls will be added here -->
</form>
-
1 将 NgForm 绑定到本地模板变量
-
2 提交表单,将表单模型传递给事件处理器
本地模板变量 f 指向 DOM 中 <form> 元素附加的 NgForm 对象。您需要此变量来访问表单的属性(例如 value 和 valid),并检查表单在特定字段中是否有错误。
ngSubmit 事件由 NgForm 触发。您不需要监听标准的提交事件,因为 NgForm 会拦截提交事件并阻止其传播。这防止了表单自动提交到服务器,从而导致页面刷新。相反,NgForm 会触发它自己的 ngSubmit 事件。
onSubmit() 方法将处理 ngSubmit 事件,并将此方法添加到组件的类中。它接受一个参数——表单的值,这是一个普通的 JavaScript 对象,它保存表单上所有字段的值。接下来,添加 username 和 ssn 字段(SSN 是每个美国居民都有的唯一 ID)。
列表 10.6. 用户名和 ssn 字段
<div>Username: <input type="text" name="username" ngModel></div> *1*
<div>SSN: <input type="text" name="ssn" ngModel></div> *2*
-
1 ngModel 属性使这个 元素成为 NgForm 的一部分。您还添加了具有值 username 的 name 属性。
-
2 对 ssn 字段进行类似的更改
现在您将添加输入和确认密码的字段。因为这些字段相关且表示相同的值,所以将它们组合成一个组是自然的。将两个密码包装在一个单独的对象中对于实现一个验证器很有用,该验证器检查两个密码是否相同,如下所示(您将在第 11.3.1 节中看到如何实现,见第十一章)。
列表 10.7. 密码字段
<div ngModelGroup="passwordsGroup"> *1*
<div>Password: <input type="password"
name="password" ngModel></div> *2*
<div>Confirm password: <input type="password" *2*
name="pconfirm" ngModel></div> *2*
</div>
-
1 ngModelGroup 指令指示 NgForm 在表单的值对象内创建一个嵌套对象,以保存子字段。
-
2 密码和 pconfirm 字段的更改与 ngModelGroup 类似,但 name 属性的值不同。
提交按钮与表单的纯 HTML 版本相同:
<button type="submit">Submit</button>
现在您已经完成了模板,您将在以下列表中将其用于组件。
列表 10.8. 使用模板驱动的表单 API 的组件
@Component({
selector: 'app-root',
template: `
<form #f="ngForm" (ngSubmit)="onSubmit(f.value)"> *1*
<div>Username: <input type="text" name="username" ngModel>
</div>
<div>SSN: <input type="text" name="ssn" ngModel>
</div>
<div ngModelGroup="passwordsGroup"> 2((CO8-2)) *2*
<div>Password: <input type="password" name="password" ngModel>
</div>
<div>Confirm password: <input type="password" name="pconfirm" ngModel>
</div>
</div>
<button type="submit">Submit</button>
</form>
`
})
export class AppComponent {
onSubmit(formData) { *3*
console.log(formData);
}
}
-
1 将 NgForm 绑定到本地模板变量并提交表单
-
2 为密码创建一个嵌套组
-
3 onSubmit 事件的处理器方法
onSubmit() 事件处理器接受一个参数:表单模型值,一个包含字段值的对象。如您所见,处理器不使用 Angular 特定的 API。根据模型上的有效性标志,您可以决定是否将 formData 发送到服务器。在这个例子中,您将其打印到控制台。
要查看此应用程序的实际运行情况,请在 form-samples 目录中运行 npm install,然后运行以下命令:
ng serve --app template -o
填写表单并点击提交按钮。模型对象的值将在浏览器控制台中打印出来,如图 10.1 所示。
图 10.1. 运行模板驱动的注册表单

图 10.2 显示了一个应用了表单指令的示例注册表单。每个表单指令都被圈出,以便您可以查看构成表单的内容。展示如何使用表单指令的完整运行应用程序位于模板驱动目录中。
图 10.2. 注册表单上的表单指令

注意
本章的源代码可以在 github.com/Farata/angulartypescript 和 www.manning.com/books/angular-development-with-typescript-second-edition 找到。
10.3. 响应式表单
创建响应式表单比创建模板驱动表单需要更多步骤。简而言之,您需要执行以下步骤:
1. 在声明组件的
NgModule()中导入ReactiveFormsModule。2. 在您的 TypeScript 代码中,创建一个
FormGroup模型对象的实例以存储表单的值。3. 创建一个 HTML 表单模板,添加响应式指令。
4. 使用
FormGroup的实例来访问表单的值。
将 ReactiveFormsModule 添加到 @NgModule() 装饰器中是一个简单的操作。
列表 10.9. 添加响应式表单支持
import { ReactiveFormsModule } from '@angular/forms';
@NgModule({
...
imports: [
...
ReactiveFormsModule *1*
],
...
})
- 1 导入支持响应式表单的模块
现在我们来谈谈如何创建表单模型。
10.3.1. 表单模型
表单模型是一种数据结构,用于存储表单的数据。它可以由 FormControl、FormGroup 和 FormArray 类构建。例如,以下代码示例声明了一个类型为 FormGroup 的类属性,并用一个新对象初始化它,该对象将包含您表单的表单控件实例。
列表 10.10. 创建表单模型对象
myFormModel: FormGroup;
constructor() {
this.myFormModel = new FormGroup({ *1*
username: new FormControl(''), *2*
ssn: new FormControl('') *2*
});
}
-
1 创建表单模型实例
-
2 向表单模型添加表单控件
FormControl
FormControl 是一个原子表单单元。它通常对应于一个单独的 <input> 元素,但它也可以表示一个更复杂的 UI 组件,如日历或滑块。一个 FormControl 实例存储了它对应的 HTML 元素的当前值、元素的验证状态以及它是否已被修改。以下是如何创建一个控制器的示例,将初始值作为构造函数的第一个参数传递:
let city = new FormControl('New York');
您还可以通过附加一个或多个内置或自定义验证器来创建 FormControl。第十一章介绍了表单验证,但以下代码示例展示了如何将两个内置的 Angular 验证器附加到表单控件上。
列表 10.11. 向表单控件添加验证器
let city = new FormControl('New York', *1*
[Validators.required, *2*
Validators.minLength(2)]); *3*
-
1 创建具有初始值“纽约”的表单控件
-
2 向表单控件添加必填验证器
-
3 向表单控件添加最小长度验证器
注意
您可以在模板中添加formControl指令,而无需将其包裹在NgForm指令内——例如,它可以与独立的<input>元素一起使用。您可以在第六章的第 6.3 节中找到一个这样的例子。
FormGroup
FormGroup是一组FormControl对象,代表整个表单或其部分。FormGroup聚合组中每个FormControl的值和有效性。如果组中的一个控制项无效,则整个组变为无效。以下列表显示了使用FormGroup来表示表单或其部分的使用方法。
列表 10.12. 通过实例化FormGroup创建表单模型
myFormModel: FormGroup;
constructor() {
this.myFormModel = new FormGroup({ *1*
username: new FormControl(''),
ssn: new FormControl(''),
passwordsGroup: new FormGroup({ *2*
password: new FormControl(''), *3*
pconfirm: new FormControl('') *4*
})
});
}
-
1 这个
FormGroup实例代表整个表单。 -
2 这个
FormGroup实例代表表单的一部分,将两个密码控制项组合在一起。 -
3 声明并初始化密码表单控制
-
4 声明并初始化密码确认的
pconfirm控制
在第 10.3.6 节中,您将看到创建嵌套表单模型的简化语法。
FormArray
当您需要以编程方式向表单(或从表单中)添加(或删除)控制项时,请使用FormArray。它与FormGroup类似,但有一个length变量。FormGroup代表整个表单或表单的固定子集,而FormArray通常代表可以增长或缩小的表单控制项集合。例如,您可以使用FormArray允许用户输入任意数量的电子邮件。以下列表显示了支持此类表单的模型。
列表 10.13. 将FormArray添加到FormGroup
let myFormModel = new FormGroup({ *1*
emails: new FormArray([ *2*
new FormControl() *3*
])
});
-
1
FormGroup实例代表整个表单。 -
2 这个
FormArray最初包含一个电子邮件控制。 -
3 将
FormControl实例添加到电子邮件数组中
在第 10.3.4 节中,我们将向您展示一个应用程序,允许用户在运行时添加更多电子邮件控制,以便用户可以输入多个电子邮件。
10.3.2. 响应式指令
响应式方法还要求您在组件模板中使用指令,但这些指令与模板驱动 API 中的指令不同。响应式指令随ReactiveFormsModule一起提供,并以form为前缀——例如,formGroup(注意小写的f)。
您不能在模板中创建一个绑定到响应式指令的局部模板变量,并且这不是必需的。在模板驱动表单中,模型是隐式创建的,局部模板变量会为您提供访问模型或其属性的方法。在响应式表单中,您在 TypeScript 中显式创建一个模型,并且不需要在组件模板中访问模型。
反应式指令 formGroup 和 formControl 使用方括号中的属性绑定语法将 DOM 元素绑定到模型对象。通过名称将 DOM 元素链接到 TypeScript 模型属性的指令是 formGroupName、formControlName 和 formArrayName。它们只能在带有 formGroup 指令标记的 HTML 元素内部使用。让我们看看表单指令。
formGroup
formGroup 指令将表示整个表单模型的 FormGroup 类的实例绑定到顶级表单的 DOM 元素,通常是 <form>。在组件模板中,使用小写的 f 来表示 formGroup,在 TypeScript 中,使用大写的 F 创建 FormGroup 类的实例。所有附加到子 DOM 元素上的指令都将位于 formGroup 的作用域内,并且可以通过名称链接模型实例。要在模板中使用 formGroup 指令,您需要首先在组件的 TypeScript 代码中创建 FormGroup 的实例,如下面的列表所示。
列表 10.14. 将 FormGroup 绑定到 HTML 表单
@Component({
selector: 'app-root',
template: `
<form [formGroup]="myFormModel"> *1*
</form>
`
})
class AppComponent {
myFormModel = new FormGroup({ *2*
// form controls are created here });
}
-
1 将表单模型实例绑定到 formGroup 指令
-
2 创建表单模型实例
formGroupName
formGroupName 指令可用于在模板中链接表单内的嵌套组。在父 formGroup 指令的作用域中使用 formGroupName 来链接其子 FormGroup 实例。下一个列表显示了如何定义使用 formGroupName 的表单模型。
列表 10.15. 使用 formGroupName
@Component({
... *1*
template: `<form [formGroup]="myFormModel"> *2*
<div formGroupName="dateRange">...</div>
</form>`
})
class FormComponent {
myFormModel = new FormGroup({ *3*
dateRange: new FormGroup({ *4*
from: new FormControl(),
to : new FormControl()
})
})
}
-
1 绑定表示整个表单的 FormGroup
-
2 将此
链接到在 myFormModel 中定义的名为 dateRange 的 FormGroup -
3 此 FormGroup 使用模板中的 formGroup 指令绑定到 DOM 元素。
-
4 使用模板中的 formGroupName 指令将名为 dateRange 的子 FormGroup 绑定到 DOM 元素。
formControlName
formControlName 必须在 formGroup 指令的作用域中使用。它将单个 FormControl 实例链接到 DOM 元素。让我们继续添加来自上一节中 dateRange 模型的示例代码。组件和表单模型保持不变。您只需要添加带有 formControlName 指令的 HTML 元素来完成模板。
列表 10.16. 完成的表单模板
<form [formGroup]="myFormModel">
<div formGroupName="dateRange">
<input type="date" formControlName="from"> *1*
<input type="date" formControlName="to"> *2*
</div>
</form>
-
1 from 是模型嵌套组 dateRange 中的一个属性名。
-
2 to 是模型嵌套组 dateRange 中的一个属性名。
与 formGroupName 指令类似,您指定要链接到 DOM 元素的 FormControl 的名称。同样,这些是在定义表单模型时选择的名称。
formControl
formControl 指令用于单个表单控件或单控件表单,当你不想使用 FormGroup 创建表单模型,但仍想使用表单 API 的功能,如验证和由 FormControl.valueChanges 属性提供的响应式行为时。你可以在第六章的 6.4 节 的天气应用中看到它。以下列表展示了从表单 API 视角来看该示例的精髓。
列表 10.17. FormControl
@Component({
...
template: `<input type="text" [formControl]="weatherControl">` *1*
})
class FormComponent {
weatherControl: FormControl = new FormControl(); *2*
constructor() {
this.weatherControl.valueChanges *3*
.pipe(
debounceTime(500),
switchMap(city => this.getWeather(city))
)
.subscribe(weather => console.log(weather));
}
}
-
1 对于不是
FormGroup部分的独立FormControl,你不能使用 formControlName 指令。使用带有属性绑定的 formControl。 -
2 不使用 FormGroup 定义表单模型,而是创建一个独立的
FormControl实例。 -
3 使用 valueChanges 可观察对象从表单中获取值。
你可以使用 ngModel(如第二章的 2.6.2 节)来同步用户输入的值与组件的属性;但由于你正在使用表单 API,你可以使用其响应式功能。在 列表 10.18 中,你将两个 RxJS 操作符应用到 valueChanges 属性返回的可观察对象上,以改善用户体验。
10.3.3. 将响应式 API 应用到 HTML 表单
让我们将用户注册表单从 10.2.2 节 重构为使用响应式表单 API。以下列表使用响应式表单 API,首先在 TypeScript 中创建一个模型对象。
列表 10.18. 使用响应式 API 创建表单模型
@Component(...)
class AppComponent {
myFormModel: FormGroup; *1*
constructor() {
this.myFormModel = new FormGroup({ *2*
username: new FormControl(),
ssn: new FormControl(),
passwordsGroup: new FormGroup({ *3*
password: new FormControl(),
pconfirm: new FormControl()
})
});
}
onSubmit() {
console.log(this.myFormModel.value); *4*
}
}
-
1 声明一个组件属性 myFormModel 以保存对表单模型的引用
-
2 创建表单模型的一个实例
-
3 为密码字段创建一个嵌套组
-
4 打印表单模型的值
myFormModel 属性保存了对 FormGroup 实例的引用。你将在组件模板中将此属性绑定到 formGroup 指令。myFormModel 属性通过实例化一个模型类进行初始化。你在父 FormGroup 中为表单控件指定的名称将在组件的模板中使用,以通过 formControlName 和 formGroupName 指令将模型链接到 DOM 元素。
passwordsGroup 属性表示一个嵌套的 FormGroup,它封装了密码和确认密码字段。将它们的值作为一个单独的对象进行验证将会很方便。
注意
在响应式 API 中,onSubmit() 方法不需要参数,因为你可以通过组件的 myFormModel 属性访问表单值。
现在模型已经定义,你可以编写绑定到你的模型对象的 HTML 标记。
列表 10.19. 将 HTML 绑定到模型
<form [formGroup]="myFormModel" *1*
(ngSubmit)="onSubmit()">
<div>Username: <input type="text" formControlName="username"></div> *2*
<div>SSN: <input type="text" formControlName="ssn"></div> *2*
<div formGroupName="passwordsGroup"> *3*
<div>Password: <input type="password"
formControlName="password"></div> *4*
<div>Confirm password: <input type="password"
formControlName="pconfirm"></div> *4*
</div>
<button type="submit">Submit</button>
</form>
-
1 使用 formGroup 指令将 元素绑定到 myFormModel
-
2 formControlName 链接输入字段到模型中定义的相应
FormControl实例。 -
3 使用 formGroupName 将模型的嵌套 FormGroup 链接到 DOM 元素
-
4 使用 formControlName 指令将密码输入字段和 pconfirm 链接起来
此响应式版本的注册表单的行为与模板驱动的版本相同,但内部实现不同。要查看此应用程序的实际运行情况,请在您的 IDE 中打开 form-samples 目录,并运行以下命令:
ng serve --app reactive -o
填写表单,并点击提交。包含输入值的对象将在浏览器控制台中打印出来,如图 10.3 所示 链接。
图 10.3. 运行响应式注册表单

这是一个相当简单的表单,具有预定义的控件,但如果你想在运行时动态添加表单控件怎么办?
10.3.4. 动态向表单添加控件
当您事先知道特定表单中的所有控件时,您可以使用 formControlName 指令将每个模板表单元素与 FormGroup 实例的相应属性关联起来。但如果你想要能够动态添加/删除控件,你需要一种不同的方式来链接控件名称与模型属性。通过使用 FormArray 而不是 FormGroup,您可以指定一个数组索引作为相应模板元素的名称。
让我们看看一个示例,允许用户拥有具有任意数量电子邮件控件的表单。首先,您将定义一个模型,该模型将包含一个名为 emails 的 FormArray,它最初将只有一个用于输入电子邮件的表单控件。
列表 10.20. 在表单模型中使用 FormArray
@Component(...)
class AppComponent {
formModel: FormGroup = new FormGroup({ *1*
emails: new FormArray([ *2*
new FormControl() *3*
])
});
...
-
1 创建一个将表示表单的 FormGroup
-
2 为电子邮件表单控件创建一个 FormArray
-
3 向 emails 数组添加单个表单控件
在模板中,您将创建一个 <ul> HTML 元素,并使用 formArrayName 指令将其链接到模型的 emails 数组。然后,您将使用 *ngFor 迭代此数组,为该数组中的每个表单控件渲染一个 <li> 元素。您的模板还将有一个添加电子邮件按钮,如果用户点击它,您将向 emails 数组添加一个新的 FormControl,如下一列表所示。
列表 10.21. 在模板中迭代 FormArray
<ul formArrayName="emails"> *1*
<li *ngFor="let e of formModel.get('emails').controls; let i = index"> *2*
<input [formControlName]="i"> *3*
</li>
</ul>
<button type="button" (click)="addEmail()">Add Email</button> *4*
-
1 将 emails 数组链接到
- 元素
-
2 迭代 emails 数组并为每个数组元素创建一个
- 和 字段
-
3 使用 emails 数组元素索引作为相应 元素的名称
-
4 定义点击事件处理程序
在 Angular 模板中,
*ngFor指令允许您访问一个特殊的index变量,该变量在迭代集合时存储当前索引。*ngFor循环中的let i语法允许您自动将index值绑定到循环内可用的局部模板变量i。formControlName指令将FormArray中的FormControl与当前渲染的 DOM 元素链接;但不是指定一个名称,而是使用变量i的当前值。当用户点击“添加电子邮件”按钮时,你的组件会将一个新的FormControl实例添加到FormArray中:this.formModel.get('emails').push(new FormControl());在 dynamic-form-controls 目录中,你可以找到在每次点击“添加电子邮件”按钮时动态添加电子邮件表单控件的完整代码。要查看此示例的实际效果,请运行以下命令:
ng serve --app dynamic -o图 10.4 展示了用户点击“添加电子邮件”后此表单的外观。第二个电子邮件字段是通过向名为
emails的FormArray添加一个新的FormControl实例动态添加的,并且页面上的每个控件都是从该数组渲染的。图 10.4. 可增长的电子邮件控件表单
![图片]()
10.4. 表单 API 指令摘要
你已经在模板驱动和响应式表单中使用了许多不同的 Forms API 指令。表 10.2 列出了它们的作用。
表 10.2. 表单 API 指令
指令 描述 响应式表单的指令 FormGroup formGroup formGroupName FormControl formControl formControlName FormArray formArrayName 模板驱动表单的指令 NgForm ngForm NgModel ngModel name NgModelGroup ngModelGroup 模板驱动和响应式表单 ngSubmit 注意,在组件模板中使用的任何指令的名称都以小写字母开头。实现指令的底层类的名称以大写字母开头。在模板驱动表单中,你不需要显式创建这些类的实例,但在响应式表单中,你需要根据需要在使用 TypeScript 代码时实例化它们。
本章中的所有代码示例都说明了用户在表单中输入数据的使用案例,但通常你需要用现有数据填充表单。
10.5. 更新表单数据
在某些场景中,表单需要在用户交互之前填充。例如,你可能需要创建一个用于编辑从服务器或其他来源检索到的产品数据的表单。另一个例子是实现主从关系——例如,在列表中选择一个产品应在表单中显示其详细信息。
Angular 表单 API 提供了多个用于更新表单模型的函数,包括
reset()、setValue()和patchValue()。reset()函数重新初始化表单模型并重置模型上的标志,如touched、dirty等。setValue()函数用于更新表单模型中的所有值。patchValue()函数用于需要更新表单模型选定属性的情况。让我们创建一个简单的应用程序,它将包含以下列表中所示的模式。列表 10.22. 创建表单模型
this.myFormModel = new FormGroup({ id: new FormControl(''), description: new FormControl(''), seller: new FormControl('') });你的应用程序还将有三个按钮:填充、更新描述和重置。相应地,填充按钮使用
setValue()来填充具有表单模型中每个控件值的对象。列表 10.23. 用于填充表单的数据
{ id: 123, description: 'A great product', seller: 'XYZ Corp' }更新描述按钮使用
patchValue()从下一列表中的对象进行部分表单更新(仅描述)。列表 10.24. 用于更新描述的数据
{ description: 'The best product' }重置按钮会从表单中删除所有数据并重置表单模型上的所有标志。你的应用的代码如下所示。
列表 10.25. AppComponent
@Component({ selector: 'app-root', template: ` <form [formGroup]="myFormModel"> *1* <div>Product ID: <input type="text" formControlName="id"></div> <div>Description: <input type="text" formControlName="description"></div> <div>Seller: <input type="text" formControlName="seller"></div> </form> <button (click)="updateEntireForm()">Populate</button> *2* <button (click)="updatePartOfTheForm()">Update Description</button> *3* <button (click)="myFormModel.reset()">Reset</button> *4* ` }) export class AppComponent { myFormModel: FormGroup; constructor() { this.myFormModel = new FormGroup({ *5* id: new FormControl(''), description: new FormControl(''), seller: new FormControl('') }); } updateEntireForm() { this.myFormModel.setValue({ *6* id: 123, description: 'A great product', seller: 'XYZ Corp' }); } updatePartOfTheForm() { this.myFormModel.patchValue({ *7* description: 'The best product' }); } }-
1 将表单模型绑定到 formGroup
-
2 一个用于调用 setValue() 的按钮
-
3 一个用于调用 patchValue() 的按钮
-
4 一个用于调用 reset() 的按钮
-
5 创建一个表单模型对象
-
6 在表单模型上调用 setValue()
-
7 在表单模型上调用 patchValue()
此应用的代码位于 populate 目录中。要查看其运行情况,请运行以下命令:
ng serve --app populate -o注意
你不能在使用
FormArray的表单中使用setValue()。对于此类表单,你需要使用patchValue(),然后在对表单模型调用setControl()方法来重置FormArray。如果一个表单有多个控件,你的代码可能包含很多
new操作符来创建表单元素的实例。有没有一种方法可以避免在代码中污染new语句?10.6. 使用 FormBuilder
注入式服务
FormBuilder简化了表单模型的创建。它与直接使用FormControl、FormGroup和FormArray类没有提供任何独特功能,但它的 API 更简洁,可以节省你重复实例化对象的时间。让我们重构用户注册表单中的代码,第 10.3.3 节。模板将保持完全相同,但以下列表使用
FormBuilder来构建表单模型。列表 10.26. 使用
FormBuilder创建formModelconstructor(fb: FormBuilder) { *1* this.myFormModel = fb.group({ *2* username: [''], *3* ssn: [''], passwordsGroup: fb.group({ *4* password: [''], pconfirm': [''] }) }); }-
1 注入 FormBuilder 服务。
-
2 FormBuilder.group() 使用传递给它的配置对象创建一个 FormGroup。
-
3 每个 FormControl 都使用可能包含初始控件值及其验证器的数组进行实例化。
-
4 与 FormGroup 类似,FormBuilder 允许你创建嵌套组。
FormBuilder.group()方法接受一个对象作为最后一个参数,该对象包含额外的配置参数。如果需要,你可以用它来指定组级别的验证器。正如你所见,使用
FormBuilder配置表单模型更加简洁,它基于配置对象而不是需要显式实例化控件类。要查看此应用的运行情况,请运行命令
ng serve --app formbuilder -o。现在你了解了如何处理表单模型和模板,你可能想知道如何确保在表单中输入的值是有效的。这是下一章的主题。摘要
-
Angular 为表单操作提供了两个 API:模板驱动和响应式。
-
模板驱动方法更容易、更快速地配置,但功能有限。
-
响应式方法让你对表单有更多的控制,表单可以在运行时创建或修改。
第十一章. 验证表单
本章涵盖
-
使用内置表单验证器
-
创建自定义验证器
-
处理同步和异步验证
用户填写表单并点击提交,期望应用程序以某种方式处理数据。在 Web 应用程序中,数据通常被发送到服务器。通常,用户会收到一些数据回传(例如,搜索结果),但有时,数据只是保存在服务器的存储中(例如,创建新订单)。在任何情况下,数据都应该是有效的,以便服务器的软件能够正确地执行其工作。
例如,应用程序不能在没有在登录表单中提供用户 ID 和密码的情况下登录用户。这两个字段都是必需的——否则,表单无效。您甚至不应允许用户在填写所有必需字段之前提交此表单。如果密码不包含至少 8 个字符,包括一个数字、一个大写字母和一个特殊字符,则用户注册表单可能被认为是无效的。
在本章中,我们将向您展示如何使用内置验证器在 Angular 中验证表单以及如何创建自定义表单。在本章末尾,您将开发 ngAuction 的新版本,该版本将包含三个字段。输入的值将首先进行验证,然后才会提交以查找符合输入标准的商品。
我们将首先通过使用响应式表单来探索内置验证器,然后转向模板驱动型验证器。
11.1. 使用内置验证器
Angular 表单 API 包含
Validators类,其中包含静态函数,如required()、minLength()、maxLength()、pattern()、email()等。这些内置验证器可以通过指定指令required、minLength、maxLength、pattern和email分别在模板中使用。pattern验证器允许您指定一个正则表达式。验证器是符合以下列表中接口的函数。
列表 11.1.
ValidatorFn接口interface ValidatorFn { (c: AbstractControl): ValidationErrors | null; }如果验证器函数返回 null,则表示没有错误。否则,它将返回一个类型为
{[key: string]: any}的ValidationErrors对象,其中属性名(错误名称)是字符串,而值(错误描述)可以是任何类型。验证器函数应声明一个类型为
AbstractControl(或其子类)的单个参数,并返回一个对象字面量或 null。在那里,你实现业务逻辑以验证用户输入。AbstractControl是FormControl、FormGroup和FormArray的超类;因此,可以为所有模型类创建验证器。使用响应式表单 API,您可以在创建表单或表单控件时提供验证器,也可以在运行时动态附加验证器。下面的列表显示了将
required验证器附加到由变量username表示的表单控件的示例。列表 11.2. 附加
required验证器import { FormControl, Validators } from '@angular/forms'; ... let username = new FormControl('', Validators.required); *1*- *1. 将所需的验证器附加到 FormControl
在这里,构造函数的第一个参数是控件的初始值,第二个是验证器函数。您还可以将多个验证器附加到表单控件上。
列表 11.3. 附加两个验证器
let username = new FormControl('', [Validators.required, Validators.minLength(5)]); *1*- 1 将必填和最小长度验证器附加到 FormControl
要查询表单或表单控件的验证状态,请使用
valid属性,它可以是两个值之一,true或false:let isValid: boolean = username.valid;上一行检查表单控件中输入的值是否通过附加到该控件的全部验证规则。如果任何规则失败,您将获得由验证器函数生成的错误对象,如下一个列表所示。
列表 11.4. 获取验证器的错误
let errors: {[key: string]: any} = username.errors; *1*- 1 获取验证器报告的所有错误
使用
hasError()方法,您可以检查表单或控件是否有特定错误,并条件性地显示或隐藏相应的错误信息。现在我们来看看如何在模板驱动的表单中应用内置验证器。您将创建一个应用,说明如何为
required、minLength和pattern验证器显示或隐藏错误信息。如果用户输入无效的电话号码,此应用的 UI 可能看起来像 图 11.1。图 11.1. 展示验证错误
![]()
应用组件的模板将包括位于
<input>下的<div>元素,其中包含错误信息。如果电话号码有效或其值未输入(pristine),则<div>将被隐藏,如下面的列表所示。提交按钮将保持禁用状态,直到用户输入一个通过所有验证器的值。列表 11.5. 条件性地显示和隐藏错误
@Component({ selector: 'app-root', template: ` <form #f="ngForm" (ngSubmit)="onSubmit(f.value)" > <div> Phone Number: <input type="text" name="telephone" ngModel required *1* pattern="[0-9]*" *2* minlength="10" *3* #phone="ngModel"> *4* <div [hidden]="phone.valid || phone.pristine"> *5* <div class="error" [hidden]="!phone.hasError('required')"> *6* Phone is required</div> <div class="error" [hidden]="!phone.hasError('minlength')"> *7* Phone has to have at least 10 digits</div> <div class="error" [hidden]="!phone.hasError('pattern')"> *8* Only digits are allowed</div> </div> </div> <button type="submit" [disabled]="f.invalid">Submit</button> *9* </form> `, styles: ['.error {color: red}'] }) export class AppComponent { onSubmit(formData) { console.log(formData); } }-
1 添加必填验证器
-
2 添加模式验证器以允许仅数字
-
3 添加最小长度验证器
-
4 局部变量 #phone 提供了对该控件模型值的访问。
-
5 如果表单控件有效或 pristine,则隐藏错误部分
-
6 如果输入了值然后删除,则显示错误信息
-
7 如果值违反最小长度要求,则显示错误信息
-
8 如果值不匹配正则表达式,则显示错误信息
-
9 在表单有效之前禁用提交按钮
要查看此应用的运行情况,请在名为 form-validation 的项目文件夹中运行
npm install,然后运行以下命令:ng serve --app threevalidators -o验证错误对象
验证器返回的错误由一个具有描述错误名称属性的 JavaScript 对象表示。属性值可以是任何类型,并且可能提供额外的错误详细信息。例如,标准的
Validators.minLength()验证器返回的错误对象如下所示:{ minlength: { requiredLength: 7, actualLength: 5 } }此对象有一个名为
minlength的属性,表示最小长度无效。此属性的值也是一个具有两个字段的对象:requiredLength和actualLength。这些错误详情可用于显示友好的错误消息。并非所有验证器都提供错误详情。有时,该属性仅指示已发生错误。在这种情况下,该属性被初始化为true。以下代码片段展示了内置
Validators.required()错误对象的示例:{ required: true }在第 11.6 节中,您将找到一个如何从
ValidationErrors对象中提取错误描述的示例。表 11.1 提供了由
Validators类提供的 Angular 内置验证器的简要描述。表 11.1. 内置验证器
验证器 描述 min 值不能小于指定的数字;它只能与响应式表单一起使用。 max 值不能大于指定的数字;它只能与响应式表单一起使用。 required 表单控件必须有一个非空值。 requiredTrue 表单控件必须具有 true 值。 email 表单控件值必须是一个有效的电子邮件地址。 minLength 表单控件必须具有最小长度的值。 maxLength 表单控件不能超过指定的字符数。 pattern 表单控件值必须匹配指定的正则表达式。 在验证电话号码的代码示例中,验证器会在用户输入每个字符后检查值。是否可以控制验证何时开始?
11.2. 控制验证开始的时间
在 Angular 5 之前,验证器会在表单控件中的每个值更改时执行其工作。现在,您可以使用
updateOn属性,这使您能够更好地控制验证过程。当您附加验证器时,您可以指定验证应该何时开始。updateOn属性可以取以下值之一:-
change— 这是默认模式,验证器会在值更改时立即检查值。您在上一节验证电话号码时看到了这种行为。 -
blur— 当控件失去焦点时检查值的有效性。 -
submit— 当用户提交表单时检查有效性。
要尝试使用模板驱动的表单使用这些选项,请将
[ngModelOptions]= "{updateOn:'blur'}"添加到列表 11.5 中的电话输入字段,这样当用户将焦点从该控件移开时,才会进行验证。要当提交按钮被点击或按下 Enter 键时开始验证,请使用选项[ngModelOptions]="{updateOn:'submit'}"。注意
如果您使用列表 11.5 中的示例,并使用选项
updateOn: 'submit',请移除条件禁用提交按钮的代码或使用 Enter 键来测试验证。在反应式 API 的情况下,你可以按如下方式设置表单的更新模式。
列表 11.6. 使用反应式 API 在失焦时应用验证器
let telephone = new FormControl('', [{validators: Validators.minLength(10), *1* updateOn:'blur'}); *2*-
1 将 minLength 验证器附加到 FormControl
-
2 当焦点从 FormControl 中移出时验证值
你还可以使用属性
ngFormOptions在表单级别指定更新模式,如下面的列表所示。列表 11.7. 使用模板驱动 API 在失焦时应用验证器
<form #f="ngForm" (ngSubmit)="onSubmit(f.value)" [ngFormOptions]="{updateOn: 'blur'}"> *1* ... </form>- 1 当焦点从一个表单控件移出时,每个表单控件都会被验证。
内置验证器适用于基本验证,但如果你需要应用特定于应用程序的逻辑来决定输入的值是否有效呢?
11.3. 反应式表单中的自定义验证器
你可以在 Angular 中创建自定义验证器。与内置验证器类似,自定义验证器应遵守以下列表中的接口。
列表 11.8. 自定义验证器必须遵守的接口
interface ValidatorFn { (c: AbstractControl): ValidationErrors | null; *1* }- 1 在出错的情况下,返回 ValidationErrors 对象;否则,返回 null
你需要声明一个函数,该函数接受控制类型(
FormControl、FormGroup或FormArray)的实例,并返回ValidationErrors对象或 null。下面的列表显示了检查控件值是否为有效的社会安全号码(SSN)的自定义验证器示例。列表 11.9. 一个示例自定义验证器
function ssnValidator(control: FormControl): ValidationErrors | null { *1* const value = control.value || ''; *2* const valid = value.match(/^\d{9}$/); *3* return valid ? null : { ssn: true }; *4* }-
1 验证 FormControl 并返回一个错误对象或 null
-
2 如果可用,则获取控件值,否则使用空字符串
-
3 将值与表示 SSN 九位格式的正则表达式进行匹配
-
4 如果值是无效的 SSN,则返回错误对象;错误名称是 ssn
你可以像附加内置验证器一样附加自定义验证器到表单控件,如下面的列表所示。
列表 11.10. 将自定义验证器附加到表单控件
@Component({ selector: 'app-root', template: ` <form [formGroup]="myForm"> SSN: <input type="text" formControlName="socialSecurity"> <span [hidden]="!myForm.hasError('ssn', 'socialSecurity')"> *1* SSN is invalid </span> </form> ` }) export class AppComponent { myForm: FormGroup; constructor() { this.myForm = new FormGroup({ socialSecurity: new FormControl('', ssnValidator) *2* }); } }-
1 如果 socialSecurity 表单控件有名为 ssn 的错误,则显示错误信息
-
2 附加你的自定义 ssnValidator
你将看到一个输入字段,需要你输入九位数字以消除错误信息。
你的
ssnValidator返回一个错误对象,指示 SSN 值存在问题:{ ssn: true }。你将错误文本“SSN 无效”添加到 HTML 模板中。ValidationErrors对象可以包含更具体的错误描述,例如{ssn: {description: 'SSN is invalid'}},你可以使用getError()方法获取错误描述。下面的列表显示了修改后的ssnValidator和模板。列表 11.11. 在自定义验证器中添加错误描述
function ssnValidator(control: FormControl): {[key: string]: any} { const value: string = control.value || ''; const valid = value.match(/^\d{9}$/); return valid ? null : {ssn: {description: 'SSN is invalid'}}; *1* } @Component({ selector: 'app', template: ` <form [formGroup]="myForm"> SSN: <input type="text" formControlName="socialSecurity"> <span [hidden]="!myForm.hasError('ssn', 'socialSecurity')"> *2* {{myForm.getError('ssn', 'socialSecurity')?.description}} *3* </span> </form> ` }) class AppComponent { myForm: FormGroup; constructor() { this.form = new FormGroup({ 'socialSecurity': new FormControl('', ssnValidator) }); } }-
1 创建一个包含错误描述的特定对象
-
2 从错误的描述属性中获取错误信息
-
3 如果 socialSecurity 表单控件获得了名为 ssn 的错误,则显示错误信息
注意
在 列表 11.10 中,你使用了 Angular 的 安全导航操作符,它由一个问号表示,可以在组件模板中使用。
getError()调用后的问号表示“如果getError()返回的对象是未定义或 null,则不要尝试访问description属性”,这意味着当输入的值有效时。如果你没有使用安全导航操作符,此代码将为有效的 SSN 值产生运行时错误“无法读取 null 的属性description”。如果你运行此应用,浏览器将显示一个空白的输入字段和消息“SSN 无效”,但用户没有机会输入任何值。在显示验证错误消息之前,始终检查表单控件是否
dirty(已被修改)。<span>元素应如下所示。列表 11.12. 使用
dirty标志<span [hidden]="!(myForm.get('socialSecurity').dirty *1* && myForm.hasError('ssn', 'socialSecurity'))"> {{myForm.getError('ssn', 'socialSecurity')?.description}} </span>- 1 检查表单控件是否已被修改
现在我们给这个表单添加一些样式。Angular 的表单 API 提供了多个 CSS 类,它们与表单上的相应标志协同工作:
.ng-valid、.ng-invalid、.ng-pending、.ng-pristine、.ng-dirty、.ng-untouched和.ng-touched。在代码示例中,如果值无效且已修改,你想要将输入字段的背景色改为浅粉色,如下面的列表所示。列表 11.13. 为输入字段添加样式
@Component({ selector: 'app-root', template: ` <form [formGroup]="myForm"> SSN: <input type="text" formControlName="socialSecurity" class="social"> *1* <span [hidden]="!(myForm.get('socialSecurity').dirty && myForm.hasError('ssn', 'socialSecurity'))"> {{myForm.getError('ssn', 'socialSecurity')?.description}} </span> </form> `, styles:[`.social.ng-dirty.ng-invalid { *2* background-color: lightpink; *3* }`] })-
1 添加 CSS 选择器 social
-
2 字段是否已修改且无效?
-
3 将背景色改为浅粉色
展示
ssnValidator在响应式表单中使用的应用程序位于 reactive-validator 目录中,你可以按照以下方式运行它:ng serve --app reactive-validator -o图 11.2 展示了如果值无效,浏览器将如何渲染此应用。
图 11.2. 显示验证错误和更改背景颜色
![]()
现在你已经知道如何为单个表单控件创建自定义验证器,让我们考虑另一个场景:验证一组表单控件。
11.4. 验证一组控件
你可以通过将验证器函数附加到
FormGroup而不是单个FormControl来验证一组表单控件。以下列表创建了一个equalValidator,确保在示例用户注册表单上的密码和密码确认字段具有相同的值。列表 11.14.
FormGroup的一个示例验证器function equalValidator({value}: FormGroup): {[key: string]: any} { const [first, ...rest] = Object.keys(value || {}); *1* const valid = rest.every(v => value[v] === value[first]); *2* return valid ? null : {equal: true}; *3* }-
1 使用剩余参数,获取 FormGroup.value 的所有属性名称
-
2 遍历属性值以检查它们是否相等
-
3 如果相等,则返回 null;否则,返回一个具有 equal 错误名称的错误对象
前述函数的签名符合
ValidatorFn接口:第一个参数是FormGroup类型,它是AbstractControl的子类,返回类型是一个对象字面量。注意,你在这个函数参数中使用对象解构来从FormGroup对象的实例中提取 value 属性。你还在函数的第一行使用了数组解构和剩余参数,这样你可以遍历
FormGroup.value的属性。你从值对象中获取所有属性的名称,并将它们保存在两个变量first和rest中。first是一个属性,将用作参考值——所有其他属性的值必须等于它才能使验证通过。rest包含所有其他属性的名称。最后,验证器函数返回
null,如果组中的值相同,或者返回一个错误对象,否则。让我们在示例用户注册表单中应用ssnValidator和equalValidator。以下列表显示了修改后的AppComponent类的代码。列表 11.15. 修改后的用户注册表单的表单模型
export class AppComponent { formModel: FormGroup; constructor(fb: FormBuilder) { *1* this.formModel = fb.group({ *2* username: ['', Validators.required], *3* socialSecurity: ['', ssnValidator], *4* passwordsGroup: fb.group({ *5* password: ['', Validators.minLength(5)], *6* pconfirm: [''] *7* }, {validator: equalValidator}) *8* }); } onSubmit() { console.log(this.formModel.value); } }-
1 注入 FormBuilder 服务
-
2 创建表单模型对象
-
3 创建 username 控件并附加 required 验证器
-
4 创建 socialSecurity 控件并附加 ssnValidator
-
5 为密码和密码确认控件创建子组 passwordsGroup
-
6 创建密码控件,应用 minLength 验证器
-
7 创建用于确认密码的 pconfirm 控件
-
8 将 equalValidator 附加到 passwordsGroup 以确保输入的两个密码相同
当用户输入无效值时,为了显示验证错误,你将在模板中每个表单控件旁边添加一个
<span>元素。根据hasError()方法的返回值,错误文本将被显示或隐藏,如下所示。列表 11.16. 用户注册组件的模板和样式
template: ` <form [formGroup]="formModel" (ngSubmit)="onSubmit()"> <div> Username: <input type="text" formControlName="username"> <span class="error" [hidden]="!formModel.hasError('required', 'username')"> *1* Username is required</span> </div> <div> SSN: <input type="text" formControlName="socialSecurity"> <span class="error" [hidden]="!formModel.hasError('ssn', 'socialSecurity')"> *2* SSN is invalid</span> </div> <div formGroupName="passwordsGroup"> <div> Password: <input type="password" formControlName="password"> <span class="error" [hidden]="!formModel.hasError('minlength', ['passwordsGroup', 'password'])"> *3* Password is too short</span> </div> <div> Confirm password: <input type="password" formControlName="pconfirm"> <span class="error" [hidden]="!formModel.hasError('equal', 'passwordsGroup')"> *4* Passwords must be the same</span> </div> </div> <button type="submit" [disabled]="formModel.invalid">Submit</button> </form> `, styles: ['.error {color: red;} ']-
1 如果 username 控件的值无效,显示错误信息
-
2 如果 socialSecurity 控件的值无效,显示错误信息
-
3 如果密码控件的值无效,显示错误信息
-
4 如果密码不相同,显示错误信息
注意你如何访问表单模型的
hasError()方法。它接受两个参数:你想要检查的验证错误名称和表单模型中的控件名称。在username的例子中,它是最顶层FormGroup的直接子控件,代表表单模型,因此你指定控件的名称。但password字段是嵌套FormGroup的子控件,所以控件的路径被指定为一个字符串数组:['passwordsGroup', 'password']。第一个元素是嵌套组的名称,第二个是password字段的名称本身。你可以在 group-validators 目录中找到此应用的代码。要查看此应用的实际效果,请运行以下命令:
ng serve --app groupvalidators -o图 11.3 展示了由单个字段(用户名、SSN 和密码)以及表单组中的无效值产生的错误消息,以及密码不匹配时的无效值。
图 11.3. 显示多个验证错误
![]()
11.5. 检查表单控件的状态和有效性
你已经使用了
valid、invalid和errors等控制属性来检查字段状态。在本节中,我们将探讨许多其他有助于改进用户体验的属性。11.5.1. 已触摸和未触摸表单控件
除了检查控件的有效性之外,你还可以使用
touched和untouched属性来检查表单控件是否被用户访问。如果用户使用键盘或鼠标将焦点放入表单控件,然后移出焦点,则此控件变为touched;而焦点保持在控件中,它仍然是untouched。这可以在显示错误消息时很有用——如果表单控件中的值无效,但用户从未访问过它,你可以选择不使用红色突出显示它,因为用户甚至没有尝试输入值。以下列表显示了一个示例。列表 11.17. 使用 touched 属性
<style>.hasError {border: 1px solid red;}</style> *1* <input type="text" required *2* name="username" ngModel #c="ngModel" *3* [class.hasError]="c.invalid && c.touched"> *4*-
1 定义了一个 CSS 选择器,用于用红色突出显示无效表单控件的边框
-
2 为用户名字段添加了必需的验证器
-
3 启用字段对 Forms API 的支持,并将 NgModel 指令实例的引用保存在局部模板变量 c 中
-
4 条件性地将 hasError CSS 选择器应用到 元素
注意
在 11.5.1 节 中讨论的所有属性都适用于模型类
FormControl、FormGroup和FormArray,以及模板驱动的指令NgModel、NgModelGroup和NgForm。注意最后一行的 CSS 类绑定示例。如果右侧的表达式为
true,则条件性地将hasErrorCSS 类应用到元素上。如果你只使用了c.invalid,则边框会在页面渲染时立即高亮显示;但这可能会让用户感到困惑,尤其是如果页面有很多字段。相反,你可以添加一个额外的条件:字段必须被触摸。现在,字段只有在用户访问并离开该字段后才会高亮显示。11.5.2. 原始和脏字段
另一对有用的属性是
pristine和dirty。pristine表示用户从未与表单控件交互。dirty表示表单控件的初始值已被修改,无论焦点在哪里。这些属性可以用来显示或隐藏验证错误。注意
第 11.5.2 节中的所有属性都有相应的 CSS 类(
ng-touched和ng-untouched、ng-dirty和ng-pristine、ng-valid和ng-invalid),当相应的属性为true时,这些类会自动添加到 HTML 元素上。这些类可以用来为特定状态下的元素设置样式。11.5.3. 等待字段
如果你为一个控件配置了异步验证器,
pending属性可能会很有用。它表示当前的有效性状态是否未知。这发生在异步验证器仍在进行中,你需要等待结果时。这个属性可以用来显示进度指示器。对于响应式表单,
statusChanges属性的类型是Observable,它发出三个值之一:VALID、INVALID和PENDING。11.6. 在响应式表单中动态更改验证器
使用响应式表单 API,你可以在运行时更改表单或其控件附加的验证器。你可能需要实现一个场景,根据一个控件的用户输入,另一个控件的验证规则应该被更改。你可以使用
setValidators()和updateValueAndValidity()函数来实现这一点。想象一个包含两个控件:国家和电话的表单。如果用户在“国家”字段中输入
USA,你希望允许输入不带国家代码的电话号码,并且电话号码至少要有 10 个字符。对于其他国家,需要国家代码,并且电话号码至少要有 11 个字符。换句话说,你需要根据“国家”字段的输入动态设置电话的验证器。以下列表展示了如何实现:你订阅了“国家”字段的valueChanges属性,并根据所选国家将验证器分配给“电话”字段。列表 11.18. 动态更改验证器
@Component({ selector: 'app-root', template: ` <form [formGroup]="myFormModel"> Country: <input type="text" formControlName="country"> <br> Phone: <input type="text" formControlName="phone"> <span class="error" *ngIf="myFormModel.controls['phone'].invalid && myFormModel.controls['phone'].dirty"> *1* Min length: {{this.myFormModel.controls['phone'] .getError('minlength')?.requiredLength}} </span> </form> `, styles: ['.error {color: red;}'] }) export class AppComponent implements OnInit{ myFormModel: FormGroup; countryCtrl: FormControl; phoneCtrl: FormControl; constructor(fb: FormBuilder) { this.myFormModel = fb.group({ *2* country: [''], phone: [''] }); } ngOnInit(){ this.countryCtrl = this.myFormModel.get('country') as FormControl; *3* this.phoneCtrl = this.myFormModel.get('phone') as FormControl; *4* this.countryCtrl.valueChanges.subscribe( country => { *5* if ('USA' === country){ this.phoneCtrl.setValidators([Validators.minLength(10)]); *6* }else{ this.phoneCtrl.setValidators([Validators.minLength(11)]); *7* } this.phoneCtrl.updateValueAndValidity(); *8* } ); } }-
1 仅当电话被修改且无效时显示错误信息
-
2 使用 FormBuilder 创建表单模型
-
3 获取国家控件实例的引用
-
4 获取电话控件实例的引用
-
5 订阅国家控件的更改
-
6 为美国设置电话验证器
-
7 为其他国家设置电话验证器
-
8 向 valueChanges 的订阅者发出更新后的验证器
要查看此应用程序的实际效果,请运行以下命令:
ng serve --app dynamicvalidator -o到目前为止,你一直在客户端执行验证,但如果你想要对表单值进行服务器端验证怎么办?
11.7. 异步验证器
异步验证器可以通过向远程服务器发出请求来检查表单值。与同步验证器一样,异步验证器是函数。主要区别在于异步验证器应返回一个
Observable或Promise对象。图 11.4 比较了同步和异步验证器应实现的接口。它显示了formControl的验证器,但同样适用于AbstractControl的任何子类。图 11.4. 比较同步和异步验证器
![]()
小贴士
如果表单控件既有同步验证器又有异步验证器,则只有在值通过所有同步验证器后,才会调用异步验证器。
本章附带代码中包含一个名为 async-validator 的目录,它使用同步和异步验证器来验证 SSN。对于同步验证,您将重用您在 第 11.3 节 中创建的
ssnValidator()函数。该验证器检查用户是否在表单控件中输入了九位数字。现在,您还希望调用一个服务,该服务将检查输入的 SSN 是否授权用户在美国工作。根据您的规则,如果一个人的 SSN 中包含序列 123,他们可以在美国工作。以下列表创建了一个包含此类异步验证器的 Angular 服务。
列表 11.19. 带有异步验证器的服务
@Injectable() export class SsnValidatorService { checkWorkAuthorization(field: AbstractControl): Observable<ValidationErrors | null> { *1* // In the real-world app you'd make an HTTP call to server // to check if the value is valid return Observable.of(field.value.indexOf('123') >=0 ? null *2* : {work: " You're not authorized to work"}); *3* } }-
1 此函数正确实现了异步验证器接口。
-
2 返回一个空的
Observable——验证通过 -
3 返回一个 ValidationErrors 对象的可观察对象——验证失败
以下列表创建了一个组件,除了同步的 ssn
Validator(),还将异步验证器checkWorkAuthorization()附加到表单控件。列表 11.20. async-validator/app.component.ts
function ssnValidator(control: FormControl): {[key: string]: any} { *1* const value: string = control.value || ''; const valid = value.match(/^\d{9}$/); return valid ? null : {ssn: true}; } @Component({ selector: 'app-root', template: ` <form [formGroup]="myForm"> <h2>Sync and async validation demo </h2> Enter your SSN: <input type="text" formControlName="ssnControl"> <span *ngIf ="myForm.hasError('ssn', 'ssnControl'); else validSSN"> SSN is invalid.</span> *2* <ng-template #validSSN> SSN is valid</ng-template> *3* <span *ngIf ="myForm.hasError('work', 'ssnControl')"> {{myForm.get('ssnControl').errors.work}}</span> *4* </form> ` }) export class AppComponent{ myForm: FormGroup; constructor(private ssnValidatorService: SsnValidatorService) { this.myForm = new FormGroup({ ssnControl: new FormControl('', ssnValidator, *5* ssnValidatorService.checkWorkAuthorization.bind (ssnValidatorService)) *6* }); } }-
1 同步验证器
-
2 在出错的情况下,显示 中的文本;否则,从模板 validSSN 显示
-
3 定义了模板 validSSN
-
4 提取名为 work 的错误描述
-
5 将同步验证器附加到 ssnControl
-
6 将异步验证器附加到 ssnControl
异步验证器作为模型类构造函数的第三个参数传递。如果您需要多个同步或异步验证器,请将数组作为第二个和/或第三个参数指定。
通常,HTML
<template>元素用于指定在页面加载时不会被浏览器渲染但可以在稍后由 JavaScript 渲染的内容。Angular<ng-template>指令具有相同的目的。在您的组件中,<ng-template>的内容是“SSN 是有效的”,它在页面加载时不会被渲染。Angular 指令*ngIf将在输入的 SSN 有效时渲染它,使用模板变量validSSN作为参考。当分配异步验证器
checkWorkAuthorization()时,您想确保此方法在服务ssnValidatorService的上下文中运行。这就是为什么您使用了 JavaScript 函数bind()。要查看此应用的运行情况,请运行以下命令:ng serve --app async-validator -o尝试输入带有和不带有 123 序列的 SSN,以查看不同的验证消息。
注意
此示例的源代码还包括一个额外的异步验证器
checkWorkAuthorizationV2(),因为它不符合 图 11.4 中显示的接口,所以无法附加到表单控件。我们添加此验证器只是为了展示您可以为验证表单值调用任何函数。11.8. 模板驱动的表单中的自定义验证器
在模板驱动的表单中,您只能使用指令来指定验证器,因此需要将验证器函数包装到指令中。以下列表创建了一个指令,它包装了来自 第 11.3 节 的同步 SSN 验证器。
列表 11.21.
SsnValidatorDirective@Directive({ *1* selector: '[ssn]', *2* providers: [{ provide: NG_VALIDATORS, *3* useValue: ssnValidator, multi: true }] }) class SsnValidatorDirective {}-
1 使用 @Directive 装饰器声明一个指令
-
2 定义指令的选择器以用作 HTML 属性
-
3 将 ssnValidator 注册为 NG_VALIDATORS 提供者
ssn选择器周围的方括号表示该指令可以用作属性。这很方便,因为您可以将此属性添加到任何<input>元素或表示为自定义 HTML 元素的 Angular 组件中。在 列表 11.20 中,您使用预定义的
NG_VALIDATORSAngular 标记注册验证器函数。这个标记反过来由NgModel指令注入,NgModel获取附加到 HTML 元素上的所有验证器的列表。然后,NgModel将验证器传递给它隐式创建的FormControl实例。相同的机制负责运行验证器;指令只是配置它们的不同方式。multi属性允许您将多个值与同一个标记关联。当标记注入到NgModel指令中时,NgModel获取一个值列表而不是单个值。这使得您能够传递多个验证器。这里是如何使用
SsnValidatorDirective的示例:<input type="text" name="my-ssn" ngModel ssn>您可以在模板验证器目录中找到说明指令验证器的完整运行应用程序。要查看此应用的运行情况,请运行以下命令:
ng serve --app template-validator -o第十章 介绍了 Forms API 的基础知识。在本章中,我们解释了如何验证表单数据。现在是时候修改 ngAuction 并添加一个搜索表单,以便用户可以搜索产品。
注意
本章的源代码可在
github.com/Farata/angulartypescript和 www.manning.com/books/angular-development-with-typescript-second-edition 找到。11.9. 向 ngAuction 添加搜索表单
您对 ngAuction 的新版本进行了相当多的修改。主要新增的是新的搜索组件,您在其中使用 Angular 表单 API。您还将添加带有产品类别的标签,因此 ngAuction 的顶部部分将类似于图 11.5 所示。
图 11.5. 新的搜索图标和产品类别标签
![]()
要从任何其他应用视图返回主页,用户应点击 ngAuction 标志。当用户点击搜索图标时,搜索表单组件将从左侧滑动,用户可以在此处输入搜索条件,如图 11.6 所示。
图 11.6. 搜索表单组件
![]()
用户点击搜索按钮后,应用程序将调用
ProductService .search();搜索表单组件将滑出屏幕;用户将看到由搜索结果组件渲染的符合搜索条件的商品。注意,在图 11.7 所示的搜索结果视图中没有显示带有类别的标签。这是因为来自不同类别的产品可以满足搜索条件——例如,价格在 70 美元到 100 美元之间。图 11.7. 搜索结果视图
![]()
在本节中,我们不会提供实现所有代码更改的详细说明,因为这需要很多页面来描述。我们将对新的搜索表单组件和搜索结果组件进行代码审查。然后,我们将突出显示在整个 ngAuction 代码中做出的其他重要更改。
11.9.1. 搜索表单组件
您在项目的共享目录中创建了搜索表单组件,以防在应用程序的其他部分需要搜索功能。搜索表单组件的模板包含一个包含三个输入字段(
标题、最低价格和最高价格)的表单。这些字段以及相应的验证错误信息(
<mat-error>)都被包裹在 Angular Material<mat-form-field>中,占位符属性中的值(或如果有,字段标签)变成一个浮动标签,如下面的列表所示。列表 11.22. search-form.component.html
<h1 class="title">Search products</h1> <form class="form" [formGroup]="searchForm" (ngSubmit)="onSearch()"> <mat-form-field class="form__field"> <input matInput type="text" placeholder="Title" *1* formControlName="title"> <mat-error>Title is too short</mat-error> *2* </mat-form-field> <mat-form-field class="form__field"> <input matInput type="number" placeholder="Min price" *3* formControlName="minPrice"> <mat-error>Cannot be less than 0</mat-error> *4* </mat-form-field> <mat-form-field class="form__field"> <input matInput type="number" placeholder="Max price" *5* formControlName="maxPrice" [errorStateMatcher]="matcher"> *6* <mat-error *ngIf="searchForm.controls['maxPrice'].hasError('min')"> Cannot be less than 0</mat-error> *7* <mat-error *ngIf="searchForm.controls['maxPrice'].hasError('max')"> *8* Cannot be more than 10000</mat-error> <mat-error *ngIf="searchForm.hasError('minLessThanMax')"> *9* Should be larger than min price</mat-error> </mat-form-field> <button class="form__submit" color="primary" mat-raised-button>SEARCH</button> </form>-
1 标题表单控件
-
2 验证错误信息用于标题
-
3 最低价格控件
-
4 验证错误信息用于负值
-
5 最高价格控件
-
6 匹配器控制何时显示验证错误。
-
7 如果值是负数,将显示错误信息
-
8 如果值大于最高价格,将显示错误信息
-
9 如果输入的最高价格小于最低价格,将显示错误信息
在 TypeScript 代码中,您将为
标题字段附加一个至少需要两个字符的验证器。如果输入的值无法通过验证器,<mat-error>将显示错误信息。最低价格字段有一个不允许负数的验证器。但
最大价格字段有三个验证器和三个相应的错误消息:第一个在值是负数时显示,第二个在输入的价格大于 10,000 时显示,第三个在输入的最大值小于最小值时显示。您将在搜索表单组件的 TypeScript 代码中创建一个名为
minLessThanMaxValidator的自定义验证器。因为此验证器需要两个字段的值,所以您将把它附加到整个表单而不是单个表单控件。因此,对于<mat-error>中的此验证器,您在表单控件而不是表单中调用hasError()。默认情况下,当值无效且用户与控件交互时显示验证错误。
最大价格字段是特殊的,因为其中一个验证器应该在最小价格字段中的值也被输入时启动。为了指定此验证器何时检查值,您将在搜索表单组件的 TypeScript 代码中实现ErrorStateMatcher接口。如果输入的值未通过一个或多个验证器,将显示相应的错误消息,如图 11.8 所示。图 11.8. 显示两个验证错误
![]()
search-form.component.ts文件包含装饰类SearchFormComponent和自定义验证器minLessThanMaxValidator。表单模型使用FormBuilder创建。当用户点击搜索按钮时,路由器导航到搜索结果组件,并将搜索条件作为查询参数传递(参见第 3.5.2 节中的第三章)。搜索结果组件实现了搜索功能。您还发出一个自定义的
search事件,如以下列表所示,通知AppComponent可以关闭搜索面板。列表 11.23. search-form.component.ts
@Component({ selector: 'nga-search-form', styleUrls: [ './search-form.component.scss' ], templateUrl: './search-form.component.html', changeDetection: ChangeDetectionStrategy.OnPush }) export class SearchFormComponent { @Output() search = new EventEmitter(); readonly matcher = new ShowOnFormInvalidStateMatcher(); *1* readonly searchForm: FormGroup; constructor(fb: FormBuilder, private router: Router) { this.searchForm = fb.group({ *2* title : [, Validators.minLength(2)], minPrice: [, Validators.min(0)], maxPrice: [, [Validators.min(0), Validators.max(10000)]] }, { validator: [ minLessThanMaxValidator ] }); } onSearch(): void { *3* if (this.searchForm.valid) { this.search.emit(); *4* this.router.navigate([ '/search-results' ], *5* queryParams: withoutEmptyValues(this.searchForm.value) *6* }); } } } export class ShowOnFormInvalidStateMatcher implements ErrorStateMatcher { *7* isErrorState(control: FormControl | null, form: FormGroupDirective | null): boolean { return !!((control && control.invalid) || (form && form.hasError('minLessThanMax'))); } } function withoutEmptyValues(object: any): any { *8* return Object.keys(object).reduce((queryParams: any, key) => { if (object[key]) { queryParams[key] = object[key]; } return queryParams; }, {}); } function minLessThanMaxValidator(group: FormGroup): *9* ValidationErrors | null { const minPrice = group.controls['minPrice'].value; const maxPrice = group.controls['maxPrice'].value; if (minPrice && maxPrice) { return minPrice <= maxPrice ? null : { minLessThanMax: true }; } else { return null; } }-
1 控制何时显示最大价格验证错误的对象
-
2 创建带有验证器的表单模型
-
3 用户点击了搜索按钮。
-
4 向应用程序组件发送事件以关闭搜索表单组件
-
5 在查询参数中不发送空值
-
6 导航到搜索结果,传递搜索条件
-
7 当表单或控件无效时报告错误
-
8 创建一个只包含具有值的属性的查询参数对象
-
9 用于比较最小和最大价格的自定义验证器
matInput指令具有errorStateMatcher属性,该属性接受ErrorStateMatcher对象的实例。此对象必须实现isErrorState()方法,该方法接受表单控件和表单,并具有应用程序逻辑以决定是否显示错误消息。在这种情况下,如果控件值无效或minLessThanMax验证器返回错误,则此函数返回true(显示错误)。在搜索产品时,注意 URL,它将包含搜索参数。例如,如果你在标题字段中输入
red并点击搜索,你将调用Router.navigate(),URL 将变为 localhost:4200/search-results?title=red。函数withoutEmptyValues()确保如果某些搜索参数没有被使用(例如,最小和最大价格),它们将不会出现在查询参数中。Note
在第十章的 ngAuction 中,主组件包含了
<mat-grid-list>来渲染产品列表。在这个版本的 ngAuction 中,我们将网格列表提取到一个单独的产品网格组件中,现在它被两个组件复用:分类和搜索结果(两者都属于主模块)。11.9.2. 搜索结果组件
搜索结果组件通过
ActivatedRoute对象的queryParams可观察属性接收查询参数。使用switchMap操作符,你将queryParams可观察对象发出的值传递给另一个可观察对象,即ProductService上的search()方法,如下所示。列表 11.24. search-results.component.ts
@Component({ selector: 'nga-search', styleUrls: [ './search-results.component.scss' ], templateUrl: './search-results.component.html', changeDetection: ChangeDetectionStrategy.OnPush }) export class SearchResultsComponent { readonly products$: Observable<Product[]>; *1* constructor( private productService: ProductService, private route: ActivatedRoute ) { this.products$ = this.route.queryParams.pipe( *2* switchMap(queryParams => this.productService.search(queryParams)) *3* ); } }-
1 声明一个用于产品的可观察对象
-
2 将 RxJS 可管道操作符包装到 pipe()函数中
-
3 将接收到的参数传递给 search()方法
如果你需要关于
switchMap操作符的复习,请参阅附录 D 中的第 D.8 节。你可以在第 D.4.1 节中了解可管道操作符。搜索结果组件的模板包含了产品网格组件,并使用异步管道来展开
products$可观察对象,如下所示。列表 11.25. search-results.component.html
<div class="grid-list-container"> <nga-product-grid [products]="products$ | async"></nga-product-grid> </div>产品网格组件通过其输入参数接收产品,如下所示:
@Input() products: Product[];然后它按照第九章中 9.3.4 节的描述渲染了网格。
11.9.3. 其他代码重构
我们不会提供其他经过重构的 ngAuction 组件的完整代码列表,而是突出显示更改。我们鼓励你阅读本章附带 ngAuction 的代码。如果你对代码有具体问题,可以在
forums.manning.com/forums/angular-development-with-typescript-second-edition的书籍论坛上发帖。显示和隐藏搜索表单组件
在 ngAuction 的第 2、3 和 4 章,搜索组件始终存在于 UI 中,占据了屏幕宽度的 25%。为什么即使用户没有搜索产品,它也占用这么多空间?在这个应用的版本中,搜索表单组件由一个小搜索图标表示,它是应用工具栏的一部分,如 图 11.9 所示。
图 11.9. 工具栏中的搜索图标
![]()
Angular Material 提供了用于侧导航的组件,你可以使用这些组件向全屏应用添加可折叠的侧边内容。你使用
<mat-sidenav-container>组件,它作为侧导航面板(搜索表单组件)和 ngAuction 工具栏的结构容器。<mat-sidenav>代表添加的侧边内容——在你的情况下是搜索表单组件,如下面的列表所示。列表 11.26. app.component.html 的片段
<mat-sidenav-container> *1* <mat-sidenav #sidenav> *2* <nga-search-form (search)="sidenav.close()"></nga-search-form> *3* </mat-sidenav> <mat-toolbar class="toolbar"> <button mat-icon-button *4* class="toolbar__icon-button" (click)="sidenav.toggle()"> *5* <mat-icon>search</mat-icon> *6* </button> <!-- The markup for the logo and shopping cart is omitted --> </mat-toolbar> <router-outlet></router-outlet> </mat-sidenav-container>-
1 将侧边栏和工具栏包裹在
中 -
2 将搜索表单组件包裹在
中 -
3 在搜索事件中,关闭带有搜索表单组件的侧边栏
-
4 声明一个带有图标的按钮
-
5 点击图标按钮切换侧边栏(在这种情况下是打开它)。
-
6 使用由 Google Material icons 提供的名为 search 的图标
重构主页模块
在 第九章,ngAuction 有一个主页模块和一个主页组件。主页模块仍然存在,但不再有主页组件。你将其功能拆分为三个组件:类别、搜索结果和产品网格组件。类别组件在导航栏下方渲染。在类别组件下方,浏览器渲染封装了搜索结果组件的产品网格组件。
路由也发生了变化,现在的路由配置如下所示。
列表 11.27. 路由的修改配置
const routes: Route[] = [ { path: '', pathMatch: 'full', redirectTo: 'categories' }, *1* { path: 'search-results', component: SearchResultsComponent }, *2* { path: 'categories', children: [ { path: '', pathMatch: 'full', redirectTo: 'all' }, *3* { path: ':category', component: CategoriesComponent }, *4* ] } ];-
1 默认情况下,重定向到无组件的类别路由
-
2 为搜索结果组件添加路由
-
3 默认情况下,重定向到类别/all 路由
-
4 为带有参数的类别组件添加路由
在此代码中,你使用所谓的无组件路由
categories,它没有将特定组件映射到路径。它消耗 URL 片段,并将其提供给其子组件。默认情况下,片段 categories 和 all 将合并为 categories/all。传递给无组件路由的参数会进一步传递到子路由。在你的情况下,如果 URL 片段 categories 后面有参数,它将通过
:category路径传递给CategoriesComponent。类别组件
类别组件是主页模块的一部分。它使用标准的 HTML
<nav>元素,该元素旨在包含一组用于导航的链接。为了使这些链接更美观,您将 Angular Material 的mat-tab-nav-bar指令添加到<nav>元素中。在第九章中,主页组件渲染了产品网格,但现在用户将看到带有产品类别名称的标签页。用户可以点击标签页来选择所有产品或属于特定类别的产品,产品网格组件将渲染它们。类别组件的模板如下所示。列表 11.28. categories.component.html
<nav class="tabs" mat-tab-nav-bar> *1* <a mat-tab-link *2* *ngFor="let category of categoriesNames$ | async" *3* #rla="routerLinkActive" routerLinkActive *4* [active]="rla.isActive" [routerLink]="['/categories', category]"> *5* {{ category | uppercase }} *6* </a> </nav> <div class="grid-list-container"> <nga-product-grid [products]="products$ | async"></nga-product-grid> </div> *7*-
1 将 mat-tab-nav-bar 添加到标准的 HTML
-
2 每个标签页标题都是一个链接。
-
3 遍历类别名称以为每个名称创建一个 标签
-
4 路由链接活动状态显示当前哪个链接是激活的。
-
5 导航到 categories 路由,传递类别名称作为参数
-
6 链接文本(标签页标题)是大写的。
-
7 产品网格组件获取要渲染的产品数组。
接下来是类别组件的 TypeScript 代码。它使用
ProductService获取用作标签页标题的类别名称的唯一名称。它还使用相同的服务来检索所有类别或所选类别的产品。列表 11.29. categories.component.ts
@Component({ selector: 'nga-categories', styleUrls: [ './categories.component.scss' ], templateUrl: './categories.component.html', changeDetection: ChangeDetectionStrategy.OnPush }) export class CategoriesComponent { readonly categoriesNames$: Observable<string[]>; *1* readonly products$: Observable<Product[]>; constructor( private productService: ProductService, private route: ActivatedRoute ) { this.categoriesNames$ = this.productService.getDistinctCategories().pipe( map(categories => ['all', ...categories])); *2* this.products$ = this.route.params.pipe( switchMap(({ category }) => this.getCategory(category))); *3* } private getCategory(category: string): Observable<Product[]> { return category.toLowerCase() === 'all' ? this.productService.getAll() *4* : this.productService.getByCategory(category.toLowerCase()); *5* } }-
1 用作标签页标题的类别名称
-
2 创建一个包含类别名称的数组,第一个是“全部”
-
3 获取属于所选类别的产品
-
4 获取所有产品,因为用户点击了“全部”
-
5 获取对应的产品,因为用户点击了具有特定类别名称的标签页
这完成了 ngAuction 的代码审查。要查看其效果,请在项目目录中运行
npm install,然后运行ng serve -o。摘要
-
Angular 随带提供了一些内置验证器,并且您可以创建尽可能多的自定义验证器。
-
您可以使用同步和异步验证器验证用户输入。
-
您可以控制验证何时发生。
第十二章. 使用 HTTP 与服务器交互
本章涵盖
-
使用 HttpClient 服务工作
-
使用 Node 和 Express 框架创建简单的网络服务器
-
开发与 Node 服务器通信的 Angular 客户端
-
拦截 HTTP 请求和响应
Angular 应用程序可以与任何支持 HTTP 的网络服务器通信,无论使用什么服务器端平台。在本章中,我们将向你展示如何使用 Angular 提供的
HttpClient服务。你将了解如何使用HttpClient进行 HTTP GET 和 POST 方法。你还将学习如何拦截 HTTP 请求以实现跨切面关注点,例如全局错误处理。本章首先简要概述 Angular 的
HttpClient服务,然后你将使用 Node.js 和 Express.js 框架创建一个网络服务器。该服务器将提供本章大多数代码示例所需的数据。最后,你将了解如何实现 HTTP 拦截器并在传输大型资产时报告进度。
12.1. HttpClient 服务概述
基于浏览器的网络应用异步运行 HTTP 请求,因此用户界面保持响应,用户可以在服务器处理 HTTP 请求的同时继续使用应用程序。异步 HTTP 请求可以使用回调、承诺或观察者来实现。尽管承诺可以帮助你摆脱回调地狱(参见附录 A 的 A.12.2 节),但它有以下缺点:
-
使用承诺发出的挂起请求无法取消。
-
当承诺解决或拒绝时,客户端将收到数据或错误消息,但在两种情况下都将是单一的数据块。JavaScript 承诺不提供处理随时间分块连续交付的数据流的方法。
观察者没有这些缺点。在第六章的 6.4 节中,我们演示了如何取消使用观察者发出的 HTTP 请求,在第十三章中,你将了解服务器如何使用 WebSockets 向客户端推送数据流。
Angular 通过
HttpClient服务支持 HTTP 通信,该服务来自@angular/common/http包。如果你的应用程序需要 HTTP 通信,你需要将HttpClientModule添加到@NgModule()装饰器的imports部分。如果你查看类型定义文件@angular/common/http/src/client.d.ts,你会看到
get()、post()、put()、delete()以及许多其他方法返回一个Observable,应用程序需要订阅以获取数据。要使用HttpClient服务,你需要将其注入到服务或组件中。注意
如 第五章 所述,每个可注入的服务都需要一个提供者声明。
HttpClient的提供者声明在HttpClientModule中,因此你不需要在你的应用程序中声明它们。以下列表展示了调用
HttpClient服务的get()方式的一种方法,传递一个 URL 作为string。在这里,你检索的是类型为Product的产品。列表 12.1. 发送 HTTP GET 请求
interface Product { *1* id: number, title: string } ... constructor(private httpClient: HttpClient) { } *2* ngOnInit() { this.httpClient.get<Product>('/product/123') *3* .subscribe( data => console.log(`id: ${data.id} title: ${data.title}`), *4* (err: HttpErrorResponse) => console.log(`Got error: ${err}`) *5* ); }-
1 定义 Product 类型
-
2 注入 HttpClient 服务
-
3 声明 get() 请求
-
4 订阅 get() 的结果
-
5 如果有错误,则记录错误
在
get()方法中,你没有指定完整的 URL(例如 http://localhost:8000/product),假设 Angular 应用请求的是它部署在同一服务器上的服务器,因此可以省略 URL 的基本部分。请注意,在get<Product>()中,你使用了 TypeScript 泛型(参见 附录 B 中的 B.9 节),以指定 HTTP 响应体中预期的数据类型。类型注解不会强制或验证服务器返回的数据形状;它只是让其他代码知道预期的服务器响应。默认情况下,响应类型是any,TypeScript 编译器将无法对返回对象上访问的属性进行类型检查。你的
subscribe()方法接收并打印浏览器控制台中的数据。默认情况下,HttpClient预期数据为 JSON 格式,数据会自动转换为 JavaScript 对象。如果你期望非 JSON 数据,请使用responseType选项。例如,你可以像以下列表所示从文件中读取任意文本。列表 12.2. 指定返回数据类型为字符串
let someData: string; this.httpClient .get<string>('/my_data_file.txt', {responseType: 'text'}) *1* .subscribe( data => someData = data, *2* (err: HttpErrorResponse) => console.log(`Got error: ${err}`) *3* );-
1 指定响应体类型为字符串
-
2 将接收到的数据分配给一个变量
-
3 如果有错误,则记录错误
提示
post(),put(), 和delete()方法的使用方式类似于 列表 12.2,通过调用这些方法之一并订阅结果来实现。现在,让我们创建一个应用程序,从 JSON 文件中读取一些数据。
12.2. 使用 HttpClient 读取 JSON 文件
为了说明
HttpClient.get(),你的应用程序将读取包含 JSON 格式产品数据的文件。创建一个包含以下列表中所示 products.json 文件的新文件夹。列表 12.3. 文件数据/products.json
[ { "id": 0, "title": "First Product", "price": 24.99 }, { "id": 1, "title": "Second Product", "price": 64.99 }, { "id": 2, "title": "Third Product", "price": 74.99} ]文件夹 data 和 products.json 文件成为你的应用程序的资产,需要包含在项目包中,因此你需要在 .angular-cli.json 文件(或从 Angular 6 开始的 angular.json)中的应用程序的
assets属性中添加此文件夹,如下一个列表所示。列表 12.4. .angular-cli.json 的片段
"assets": [ "assets", *1* "data" *2* ],-
1 由 Angular CLI 生成的默认资产文件夹的名称
-
2 添加到项目中资产文件夹的名称
让我们创建一个应用,它将显示如图图 12.1 所示的产品数据。
图 12.1. 渲染 products.json 的内容
![]()
您的应用组件将使用
HttpClient.get()来发出 HTTP GET 请求,并且您将声明一个Product接口来定义预期产品数据的结构。get()返回的可观察对象将通过async管道在模板中展开。app.component.ts文件位于 readfile 文件夹中,其内容如下所示。列表 12.5. app.component.ts
interface Product { *1* id: string; title: string; price: number; } @Component({ selector: 'app-root', template: `<h1>Products</h1> <ul> <li *ngFor="let product of products$ | async"> *2* {{product.title }}: {{product.price | currency}} *3* </li> </ul> `}) export class AppComponent{ products$: Observable<Product[]>; *4* constructor(private httpClient: HttpClient) { *5* this.products$ = this.httpClient .get<Product[]>('/data/products.json'); *6* } }-
1 声明产品类型
-
2 使用 async 管道遍历可观察的产品并自动订阅它们
-
3 渲染产品标题和以货币格式表示的价格
-
4 声明一个针对产品的类型化的可观察对象
-
5 注入 HttpClient 服务
-
6 指定预期数据的类型进行 HTTP GET 请求
注意
在此应用中,您不使用生命周期钩子
ngOnInit()来获取数据。这不是犯罪,因为此代码不使用任何可能在组件构造期间未初始化的组件属性。此数据获取将在构造函数之后异步执行,当async管道订阅products$可观察对象时。要查看此应用的运行情况,请在客户端目录中运行
npm install,然后运行以下命令:ng serve --app readfile -o这并不太难,对吧?打开 Chrome 开发者工具,您将看到 HTTP 请求和响应及其头部,如图图 12.2 所示。
图 12.2. 监控 HTTP 请求和响应
![]()
此应用演示了如何发出没有参数且使用默认 HTTP 请求头的 HTTP GET 请求。如果您想添加额外的头和查询参数,请使用提供额外参数的重载版
get()方法,您可以在其中指定额外的选项。以下列表显示了如何使用HttpHeaders和HttpParams类请求来自同一 products.json 文件的数据,并传递额外的头和查询参数。列表 12.6. 向 GET 请求添加 HTTP 头部和查询参数
constructor(private httpClient: HttpClient) { let httpHeaders = new HttpHeaders() *1* .set('Content-Type', 'application/json') .set('Authorization', 'Basic QWxhZGRpb'); let httpParams = new HttpParams() *2* .set('title', "First"); this.products$ = this.httpClient.get<Product[]>('/data/products.json', { *3* headers: httpHeaders, params: httpParams });-
1 创建带有两个额外头部的 HttpHeaders 对象
-
2 创建一个包含一个查询参数的对象(它可以是任何对象字面量)
-
3 将头部和查询参数作为 get()的第二个参数传递
由于您只是读取文件,传递查询参数没有太多意义,但如果您需要向知道如何通过标题搜索产品的服务器端点发出类似请求,代码将相同。使用链式
set()方法,您可以添加所需数量的头部或查询参数。运行列表 12.7 将渲染来自 products.json 的相同数据,但请求的 URL 和 HTTP 头部将有所不同。图 12.3 使用箭头突出显示与图 12.2 相比的差异。
图 12.3. 监控修改后的 HTTP 请求
![]()
您可能想知道如何向服务器发送数据(例如,使用 HTTP POST)。要编写这样的应用程序,您需要一个可以接受您数据的服务器。在第 12.4.2 节中,您将创建一个使用
HttpClient.post()的应用程序,但首先让我们使用 Node.js 和 Express.js 框架创建一个 Web 服务器。12.3. 使用 Node、Express 和 TypeScript 创建 Web 服务器
Angular 可以与任何平台上的 Web 服务器进行通信,但我们在本书中决定创建并使用 Node.js 服务器,以下是一些原因:
-
没有必要学习一门新的编程语言来理解代码。
-
Node 允许您创建独立的应用程序(例如服务器)。
-
使用 Node 可以让您继续用 TypeScript 编写代码,因此我们不必解释如何在 Java、.NET 或其他任何语言中创建 Web 服务器。
您将开始使用 Node 和 Express 框架编写一个基本的 Web 服务器。然后,您将编写另一个可以使用 HTTP 协议提供 JSON 数据的 Web 服务器。在这个服务器准备就绪后,您将创建一个可以消费其数据的 Angular 客户端。
注意
本章的源代码可以在
github.com/Farata/angulartypescript和www.manning.com/books/angular-development-with-typescript-second-edition找到。12.3.1. 创建一个简单的 Web 服务器
在本节中,您将使用 Node 和 Express(
expressjs.com)框架和 TypeScript 创建一个 Web 服务器。本章附带代码中有一个名为 server 的目录,其中包含一个具有自己的 package.json 文件的项目,该文件不包含任何 Angular 依赖项。此文件的dependencies和devDependencies部分如下所示。列表 12.7. 服务器在 package.json 中的依赖项
"dependencies": { "express": "⁴.16.2", *1* "body-parser": "¹.18.2" *2* }, "devDependencies": { "@types/express": "⁴.0.39", *3* "@types/node": "⁶.0.57", *4* "typescript": "².6.2" *5* }-
1 Express.js 框架
-
2 Express.js 的请求体解析器
-
3 Express.js 的类型定义文件
-
4 Node.js 的类型定义文件
-
5 TypeScript 编译器的本地版本
您可以在附录 B 中的 B.12 部分了解类型定义文件。您将在第 12.4 节中使用 body-parser 包从请求对象中提取数据。
注意
您安装 TypeScript 编译器的本地版本,以防您需要保留全局安装的不同版本的
tsc编译器。此外,您不应期望持续集成服务器有一个全局的tsc可执行文件。要使用本地的tsc版本,您可以在 package.json 的scripts部分添加一个自定义 npm 脚本命令("tsc": "tsc")并通过运行npm run tsc命令来启动编译器。因为你会用 TypeScript 编写服务器代码,所以它需要被转译,所以以下列表添加了 tsconfig.json 文件,其中包含了
tsc编译器的选项。列表 12.8. 服务器 tsconfig.json
{ "compilerOptions": { "module": "commonjs", *1* "outDir": "build", *2* "target": "es6" *3* }, "exclude": [ "node_modules" *4* ] }-
1 根据 CommonJS 规范转译模块
-
2 将 .js 文件保存到构建目录中
-
3 使用 ES6 语法转译为 .js 文件
-
4 不转译位于 node_modules 目录中的代码
通过指定模块的 CommonJS 语法,你确保
tsc将import * as express from "express";这样的语句转译为 Node 所需的const express = require ("express");。以下列表显示了文件 my-express-server.ts 中的简单 Web 服务器代码。此服务器通过将三个路径—/、/products 和 /reviews—映射到相应的回调函数,实现了对 HTTP
GET请求的服务器端路由。列表 12.9. my-express-server.ts
import * as express from "express"; const app = express(); *1* app.get('/', (req, res) => res.send('Hello from Express')); *2* app.get('/products', (req, res) => res.send('Got a request for products')); *3* app.get('/reviews', (req, res) => res.send('Got a request for reviews')); *4* const server = app.listen(8000, "localhost", () => { *5* console.log(`Listening on localhost:8000`); });-
1 实例化了 Express.js
-
2 匹配根路由的 GET 请求
-
3 匹配 /products 路由的 GET 请求
-
4 匹配 /reviews 路由的 GET 请求
-
5 在 localhost:8000 上开始监听并执行肥箭头函数中的代码
运行
npm install;通过运行tsc转译所有代码示例,包括 my-express-server.ts;然后通过运行以下命令启动此服务器:node build/my-express-server注意
如果你没有全局安装 TypeScript 编译器,你可以运行其本地版本,./node_modules/typescript/bin/tsc,或者将
"tsc: "tsc"行添加到 package.json 的scripts部分,并像这样运行它:npm run tsc。你将在控制台看到“Listening on localhost:8000”的消息,现在你可以根据在浏览器中输入的 URL 请求产品或评论,如图 12.4 所示。
图 12.4. 使用 Express 的服务器端路由
![]()
注意
要调试 Node 应用程序,请参考你 IDE 的文档。如果你想调试 TypeScript 代码,别忘了在 Node 项目的 tsconfig.json 文件中设置
"sourceMap": true选项。这个服务器响应简单的文本消息,但你是如何创建一个可以响应 JSON 格式数据的服务器的呢?
12.3.2. 提供 JSON 服务
要以 JSON 格式将 JavaScript 对象(如产品)发送到浏览器,你将在响应对象上使用 Express 函数
json()。你的 REST 服务器位于 rest-server.ts 文件中,它可以提供所有产品或特定产品(通过 ID)。在这个服务器中,你将创建三个端点:/ 作为根路径,/api/products 作为所有产品的路径,以及 /api/products/:id 作为包含产品 ID 的路径。products数组将包含三个硬编码的Product类型的对象,这些对象将通过调用 Express 框架提供的res.json()转换为 JSON 格式。列表 12.10. rest-server.ts
import * as express from "express"; const app = express(); interface Product { *1* id: number, title: string, price: number } const products: Product[] = [ *2* {id: 0, title: "First Product", price: 24.99}, {id: 1, title: "Second Product", price: 64.99}, {id: 2, title: "Third Product", price: 74.99} ]; function getProducts(): Product[] { *3* return products; } app.get('/', (req, res) => { *4* res.send('The URL for products is http://localhost:8000/api/products'); }); app.get('/api/products', (req, res) => { *5* res.json(getProducts()); *6* }); function getProductById(productId: number): Product { *7* return products.find(p => p.id === productId); } app.get('/api/products/:id', (req, res) => { *8* res.json(getProductById(parseInt(req.params.id))); *9* }); const server = app.listen(8000, "localhost", () => { console.log(`Listening on localhost:8000`); });-
1 定义了 Product 类型
-
2 创建包含产品数据的三个 JavaScript 对象的数组
-
3 返回所有产品
-
4 将文本提示作为对基础 URL GET 请求的响应
-
5 当 GET 请求包含 URL 中的/api/products 时,调用 getProducts()
-
6 将产品转换为 JSON 并返回给浏览器
-
7 通过 ID 返回产品。这里,你使用数组的 find()方法。
-
8 GET 请求附带了一个参数。其值存储在请求对象的 params 属性中。
-
9 将产品 ID 从字符串转换为整数,调用 getProductById(),并发送 JSON 回
如果之前运行的 my-express-server 还在运行,请使用 Ctrl-C 停止它,然后使用以下命令启动 rest-server:
node build/rest-server在浏览器中输入
http://localhost:8000/api/products,你应该会看到如图 12.5 所示的 JSON 格式的数据。图 12.5. 服务器对 http://localhost:8000/api/products 的响应
![]()
图 12.6 显示了输入 URL http://localhost:8000/api/products/1 后的浏览器窗口。这次,服务器只返回具有值为
1的id的产品数据。图 12.6. 服务器对 http://localhost:8000/api/products/1 的响应
![]()
你的 REST 服务器已准备就绪。现在让我们看看如何在 Angular 应用程序中发起 HTTP GET 请求并处理响应。
12.4. 将 Angular 和 Node 结合在一起
在上一节中,你创建了 rest-server.ts 文件,该文件无论客户端是使用框架编写还是用户在浏览器中直接输入 URL,都会响应 HTTP GET 请求并返回产品详情。在本节中,你将编写一个 Angular 客户端,该客户端将发出 HTTP GET 请求并将产品数据视为由你的服务器返回的
Observable数据流。注意
只是一个提醒:Angular 应用程序和 Node 服务器是两个独立的项目。服务器代码位于名为 server 的目录中,Angular 应用程序位于 client 目录中的另一个项目中。
12.4.1. 服务器上的静态资源
部署在服务器上的典型 Web 应用程序包括静态资源(例如,HTML、图像、CSS 和 JavaScript 代码),当用户输入应用程序的 URL 时,浏览器需要加载这些资源。从服务器的角度来看,Web 应用程序的 Angular 部分被视为静态资源。Express 框架允许你指定静态资源所在的目录。
让我们创建一个新的服务器:rest-server-angular.ts。在上一个节段的 rest-server.ts 文件中,你没有指定静态资源的目录,因为没有在服务器上部署客户端应用程序。在新服务器中,你添加以下列表中的行。
列表 12.11. 指定静态资源所在的目录
import * as path from "path"; *1* app.use('/', express.static(path.join(__dirname, 'public'))); *2*-
1 添加 Node 路径模块以处理目录和路径
-
2 将公共子目录指定为静态资源的位置
与 rest-server.ts 不同,您只需将基本 URL (/) 映射到公共目录,Node 将默认从那里发送 index.html。浏览器加载 index.html,然后加载在
<script>标签中定义的其余捆绑包。注意
由 Angular CLI 生成的原始 index.html 文件不包含
<script>标签,但当你运行ng build或ng serve命令时,它们将创建一个包含所有捆绑包和其他资产的新的 index.html 版本。当浏览器请求静态资源时,Node 将在当前目录的 public 子目录中查找它们——这是您启动此服务器时的构建目录。在这里,您使用 Node 的
path.join()API 确保以跨平台的方式创建绝对文件路径。在下一节中,我们将介绍 Angular 客户端并将其捆绑包部署在公共目录中。rest-server-angular.ts 中的 REST 端点与 rest-server.ts 中的相同:-
/ 服务器提供 index.html,其中包含加载 Angular 应用的代码。
-
/api/products 提供所有产品。
-
/api/products/:id 通过其 ID 提供一个产品。
rest-server-angular.ts 文件的完整代码将在下一列表中展示。
列表 12.12. rest-server-angular.ts
import * as express from "express"; import * as path from "path"; *1* const app = express(); app.use('/', express.static(path.join(__dirname, 'public'))); *2* interface Product { id: number, title: string, price: number } const products: Product[] = [ {id: 0, title: "First Product", price: 24.99}, {id: 1, title: "Second Product", price: 64.99}, {id: 2, title: "Third Product", price: 74.99} ]; function getProducts(): Product[] { return products; } app.get('/api/products', (req, res) => { *3* res.json(getProducts()); }); function getProductById(productId: number): Product { return products.find(p => p.id === productId); } app.get('/api/products/:id', (req, res) => { *4* res.json(getProductById(parseInt(req.params.id))); }); const server = app.listen(8000, "localhost", () => { *5* console.log(`Listening on localhost:8000`); });-
1 添加了提供用于处理文件和目录路径实用工具的路径模块
-
2 对于根路径,指定提供静态资源的目录
-
3 配置 HTTP GET 请求的端点
-
4 为 HTTP GET 请求配置另一个端点
-
5 启动服务器
新服务器已准备好为 Angular 客户端提供 JSON 数据,让我们启动它:
node build/rest-server-angular尝试使用基本 URL http://localhost:8000 向此服务器发出请求将返回 404 错误,因为包含静态资产的目录中没有 index.html 文件:您尚未将 Angular 应用部署到那里。您的下一个任务是创建并部署将消费 JSON 格式数据的 Angular 应用。
12.4.2. 在 Angular 应用中消费 JSON
您的 Angular 应用将位于名为 client 的目录中。在之前的章节中,您使用
ng serve在内存中构建捆绑包来启动所有 Angular 应用,但这次您还将使用ng build命令生成文件中的捆绑包。然后您将使用 npm 脚本来自动化在 12.4.1 节 中创建的 Node 服务器中的这些捆绑包的部署。在开发模式下,您将继续使用运行在端口 4200 的 Angular CLI 开发服务器来提供 Angular 应用。但数据将来自另一个由 Node 和 Express 驱动的网络服务器,该服务器将在端口 8000 上运行。图 12.7 展示了这种双服务器配置。
图 12.7. 开发模式下的两个服务器
![]()
注意
剧透警告:当客户端应用从一台服务器提供并尝试直接访问另一台服务器时,我们会遇到一个问题。当我们到达那里时,我们会解决这个难题。
当 Angular 的
HttpClient对象向一个 URL 发起请求时,响应会以Observable的形式返回,客户端的代码可以通过使用subscribe()方法或使用在 第 6.5 节 中引入的async管道来处理它。使用async管道是首选的,但我们将展示两种方法,以便你可以欣赏async的优势。让我们从这样一个应用开始,它从 rest-server-angular 服务器检索所有产品,并使用 HTML 无序列表 (
<ul>) 来渲染它们。你可以在这个应用中找到 app.component.ts 文件,它位于 client/src/app/restclient 目录中。列表 12.13. restclient/app.component.ts
// import statements omitted for brevity interface Product { *1* id: number, title: string, price: number } @Component({ selector: 'app-root', template: `<h1>All Products</h1> <ul> <li *ngFor="let product of products"> {{product.title}}: {{product.price | currency}} *2* </li> </ul> {{error}} `}) export class AppComponent implements OnInit { products: Product[] = []; theDataSource$: Observable<Product[]>; *3* productSubscription: Subscription; *4* error: string; *5* constructor(private httpClient: HttpClient) { *6* this.theDataSource$ = this.httpClient .get<Product[]>('http://localhost:8000/api/products'); *7* } ngOnInit() { this.productSubscription = this.theDataSource$ .subscribe( *8* data => this.products = data, *9* (err: HttpErrorResponse) => this.error = `Can't get products. Got ${err.message}` *10* ); } }-
1 声明产品的类型
-
2 使用货币管道来渲染价格
-
3 声明由 HttpClient 返回的数据的观察者
-
4 声明订阅属性——你需要从观察者中取消订阅
-
5 在这里显示 HTTP 请求的错误(如果有)。
-
6 注入 HttpClient
-
7 声明发起 HTTP GET 请求产品的意图
-
8 对产品发起 HTTP GET 请求
-
9 将接收到的产品添加到数组中
-
10 将错误消息的值设置为一个变量,以便在 UI 上渲染
注意
你没有使用
ngOnDestroy()显式地取消订阅观察者,因为一旦HttpClient获取到响应(或错误),底层的Observable就会完成,因此观察者会自动取消订阅。你已经在上一节中启动了服务器。现在,通过运行以下命令来启动客户端:
ng serve --app restclient -o浏览器没有渲染任何产品,控制台显示了一个 404 错误,但如果你在
AppComponent中使用了完整的 URL(例如,http://localhost:8000/api/products),浏览器控制台将显示以下错误:Failed to load http://localhost:8000/api/products: No 'Access-Control-Allow- Origin' header is present on the requested resource. Origin 'http://localhost:4200' is therefore not allowed access.这是因为你违反了同源策略(见
mng.bz/2tSb)。这种限制是为在浏览器中运行的客户端设置的作为一种安全机制。假设你访问并登录了 bank.com,然后在另一个标签页中打开了 badguys.com。同源策略确保来自 badguys.com 的脚本无法访问你在 bank.com 的账户。你的 Angular 应用是从 http://localhost:4200 加载的,但试图访问 URL http://localhost:8000。除非运行在端口 8000 的服务器被配置为允许来自 http://localhost:4200 的客户端访问,否则浏览器不允许这样做。当你的客户端应用在 Node 服务器上部署时,你不会遇到这个错误,因为客户端应用将加载在端口 8000 上运行的服务器,并且这个客户端将向同一服务器发送数据请求。
在第十三章的动手实践部分 chapter 13,您将使用 Node.js CORS 包(见
github.com/expressjs/cors)来允许来自其他源客户端的请求,但如果您需要向第三方服务器发送请求,这可能不是一个选项。在开发模式下,有一个更简单的解决方案来解决同源限制。您将使用运行在端口 4200 的服务器作为客户端请求运行在端口 8000 的服务器的代理。同源策略不适用于服务器到服务器的通信。在下一节中,您将了解如何在客户端配置此类代理。12.4.3. 配置客户端代理
在开发模式下,您希望继续使用 Angular CLI 附带的具有热重载功能和快速重建内存中的应用程序包的服务器。另一方面,您希望能够向其他服务器发送请求。
在底层,Angular CLI 开发服务器使用 Webpack 开发服务器,它可以作为代理,在浏览器与其他服务器之间进行通信。您只需在 Angular 项目的根目录中创建一个 proxy-conf.json 文件,在那里您将配置开发服务器应重定向到另一个服务器的 URL 片段。在您的例子中,您希望将任何包含 URL 片段 /api 的请求重定向到运行在端口 8000 的服务器,如下面的列表所示。
列表 12.14. proxy-conf.json
{ "/api": { *1* "target": "http://localhost:8000", *2* "secure": false *3* } }-
1 修改包含 /api 的 URL 的所有请求
-
2 将这些请求重定向到该 URL
-
3 目标连接不需要 SSL 证书。
注意
使用代理文件允许您轻松地在本地和远程服务器之间切换。只需更改
target属性的值,让您的本地应用程序从远程服务器检索数据。您可以在mng.bz/fLgf上了解更多关于 Angular CLI 代理支持的信息。您需要在上一节的应用程序中进行一些小的修改。您应该将后端服务器的完整 URL(http://localhost:8000/api/products)替换为端点的路径(/api/products)。请求产品的代码将看起来像您尝试访问从 Angular CLI 开发服务器下载的应用程序上的 /api/products 端点:
this.theDataSource = this.httpClient .get<Product[]>('/api/products');但开发服务器将识别 URL 中的 /api 片段,并将此请求重定向到运行在端口 8000 的另一个服务器,如图 12.8 所示(与图 12.7 进行比较)。
图 12.8. 使用代理的两个服务器
![]()
要查看修改后的应用程序的实际效果,您需要使用
--proxy-config选项,提供配置代理参数的文件名:ng serve --app restclient --proxy-config proxy-conf.json -o注意
如果您在配置代理参数时忘记提供代理文件的名称,您将收到 404 错误,因为 /api/products 请求不会被重定向,并且端口 4200 运行的服务器中没有这样的端点。
打开您的浏览器到 http://localhost:4200,您将看到 图 12.9 中显示的 Angular 应用程序。
图 12.9. 通过代理从 Node 服务器检索所有产品
![]()
注意数据来自运行在端口 4200 的服务器,该服务器从运行在端口 8000 的服务器获取数据。图 12.8 说明了此数据流。
在开发模式下,使用 Angular CLI 代理可以一石二鸟:在代码更改时热重载您的应用程序,并且无需部署到另一个服务器即可访问数据。
现在让我们看看如何用
async管道替换产品的显式订阅。12.4.4. 使用异步管道订阅可观察对象
我们在 第六章 的 6.5 节 中介绍了
AsyncPipe(或在模板中使用时称为async)。async可以接收一个Observable作为输入,自动订阅它,并在组件被销毁时取消订阅。要查看此功能的工作原理,请在 列表 12.13 中进行以下更改:-
将
products变量的类型从Array更改为Observable。 -
删除变量
theDataSource$的声明。 -
在代码中删除对
subscribe()的调用。您将get()方法返回的Observable分配给products。 -
将
async管道添加到模板中的*ngFor循环。
以下列表实现了这些更改(请参阅文件 restclient/app.component.asyncpipe.ts)。
列表 12.15. app.component.asyncpipe.ts
import { HttpClient} from '@angular/common/http'; import {Observable, EMPTY} from 'rxjs'; import {catchError} from 'rxjs/operators'; import {Component} from "@angular/core"; interface Product { id: number, title: string, price: number } @Component({ selector: 'app-root', template: `<h1>All Products</h1> <ul> <li *ngFor="let product of products$ | async"> *1* {{product.title }} {{product.price | currency}} </li> </ul> {{error}} `}) export class AppComponentAsync{ products$: Observable<Product[]>; error: string; constructor(private httpClient: HttpClient) { this.products$ = this.httpClient.get<Product[]>('/api/products') *2* .pipe( catchError( err => { *3* this.error = `Can't get products. Got ${err.status} from ${err.url}`;*4* return EMPTY; }); *5* } }-
1 异步管道订阅并展开 observable products$ 中的产品
-
2 使用 HttpClient.get() 初始化观察者
-
3 在错误到达异步管道之前拦截错误
-
4 处理可能出现的错误
-
5 返回一个空的观察者,这样订阅者就不会被销毁
运行此应用程序将产生与 图 12.9 中显示相同的输出。
到目前为止,您已经直接将
HttpClient实例注入到组件中,但更常见的是将HttpClient注入到服务中。让我们看看如何做到这一点。12.4.5. 将 HttpClient 注入到服务中
Angular 提供了一种简单的方法来分离业务逻辑实现和渲染 UI。业务逻辑应在服务中实现,UI 在组件中实现,并且您通常在一个或多个注入到组件中的服务中实现所有 HTTP 通信。例如,与 第十一章 一起提供的 ngAuction 应用程序具有注入
HttpClient服务的ProductService类。您将一个服务注入到另一个服务中。ProductService使用HttpClient读取 products.json 文件,但它也可以像上一节中那样从远程服务器获取产品数据。ProductService注入到 ngAuction 的组件中。检查 ngAuction 中 第十一章 伴随的ProductService和CategoriesComponent的源代码,你将识别出 图 12.10 中显示的模式。图 12.10. 将依赖注入到服务和组件中
![]()
来自 ngAuction 的
ProductService的下一个列表是封装业务逻辑和 HTTP 通信到服务中的一个示例。列表 12.16. ngAuction 的
ProductService的一个片段@Injectable() export class ProductService { constructor(private http: HttpClient) {} *1* getAll(): Observable<Product[]> { return this.http.get<Product[]>('/data/products.json'); *2* } getById(productId: number): Observable<Product> { return this.http.get<Product[]>('/data/products.json') *2* .pipe( map(products => products.find(p => p.id === productId)) ); } getByCategory(category: string): Observable<Product[]> { return this.http.get<Product[]>('/data/products.json').pipe( *2* map(products => products.filter(p => p.categories.includes(category))) ); } getDistinctCategories(): Observable<string[]> { return this.http.get<Product[]>('/data/products.json') *2* .pipe( map(this.reduceCategories), map(categories => Array.from(new Set(categories))), ); } // Other code is omitted for brevity }-
1 将 HttpClient 注入到 ProductService
-
2 调用 HttpClient.get()
注意
在前面的 列表 12.19 中,你使用 RxJS 可管道操作符在
pipe()方法中(参见 附录 D 中的 部分 D.4.1)。来自
CategoriesComponent的下一个列表是使用该组件中先前服务的一个示例。列表 12.17. ngAuction 的
CategoriesComponent的一个片段@Component({ selector: 'nga-categories', styleUrls: [ './categories.component.scss' ], templateUrl: './categories.component.html' }) export class CategoriesComponent { readonly categoriesNames$: Observable<string[]>; readonly products$: Observable<Product[]>; constructor( private productService: ProductService, *1* private route: ActivatedRoute ) { this.categoriesNames$ = this.productService.getDistinctCategories() *2* .pipe(map(categories => ['all', ...categories])); this.products$ = this.route.params.pipe( switchMap(({ category }) => this.getCategory(category))); } private getCategory(category: string): Observable<Product[]> { return category.toLowerCase() === 'all' ? this.productService.getAll() *2* : this.productService.getByCategory(category.toLowerCase()); *2* } }-
1 将 ProductService 注入
-
2 使用 ProductService
ProductService的提供者在 ngAuction 的根模块的@NgModule()装饰器中在应用级别声明。在第十三章的实践部分 chapter 13,你将 ngAuction 分割成两个项目,客户端和服务器,并且将使用 Node 和 Express 框架编写网络服务器。一个 Angular 应用(捆绑和资产)如何部署在 web 服务器上?12.4.6. 使用 npm 脚本在服务器上部署 Angular 应用
在服务器上部署客户端代码的过程应该是自动化的。至少,部署一个 Angular 应用包括运行几个命令来构建捆绑包,并用新代码替换先前部署的代码(index.html、JavaScript 捆绑包和其他资产)。部署可能还包括运行测试脚本和其他步骤。
JavaScript 开发者使用各种工具来自动化运行部署任务,如 Grunt、gulp、npm 脚本等。在本节中,我们将向您展示如何使用 npm 脚本进行部署。我们喜欢使用 npm 脚本,因为它们易于使用,并且提供了一种简单的方法来自动化以预定义顺序运行命令序列。此外,您已经安装了 npm,因此无需安装其他软件来自动化您的部署工作流程。
为了说明部署过程,你将使用来自第 12.3.1 节的 rest-server-angular 服务器,你将在其中部署来自第 12.3.4 节的 Angular 应用。部署后,你不再需要配置代理,因为服务器和客户端代码都将部署在同一台运行 http://localhost:8000 的服务器上。在浏览器中输入此 URL 后,用户将看到之前在图 12.9 中展示的产品数据。
npm 允许你在 package.json 中添加
scripts属性,其中你可以为终端命令定义别名。例如,你不必输入长命令ng serve --app restclient --proxy-config proxy-conf.json,你可以在 package.json 的脚本部分定义一个 start 命令,如下所示:"scripts": { "start": "ng serve --app restclient --proxy-config proxy-conf.json" }现在,你只需在控制台中输入
npm start即可。npm 支持十几个开箱即用的脚本命令(有关详细信息,请参阅 npm-scripts 文档,docs.npmjs.com/misc/scripts)。你还可以添加针对你的开发和部署工作流程的新自定义命令。其中一些脚本需要手动运行(例如
npm start),而一些脚本如果具有post和pre前缀则会自动调用(例如,post-install)。如果scripts部分中的任何命令以post前缀开头,它将在该前缀之后指定的相应命令运行后自动执行。例如,如果你定义了命令
"postinstall": "myCustomInstall.js",每次你运行npm install时,脚本 myCustomInstall.js 将自动运行。同样,如果一个命令具有pre前缀,那么这样的命令将在该前缀之后命名的命令之前运行。如果你定义了 npm 脚本所不知道的自定义命令,你需要使用一个额外的选项:
run。比如说,你定义了一个自定义命令startDev,如下所示:"scripts": { "startDev": "ng serve --app restclient --proxy-config proxy-conf.json" }要运行该命令,你需要在终端窗口中输入以下内容:
npm run startDev。为了自动化运行一些自定义命令,使用相同的post和pre前缀。让我们看看如何创建一个可运行的命令序列,用于在 Node 服务器上部署 Angular 应用。打开 client 目录下的 package.json 文件,你将找到四个自定义命令:
build、postbuild、predeploy和deploy。以下列表显示了运行单个命令npm run build时会发生什么:列表 12.18。client/package.json 的一个片段
"scripts": { "build": "ng build --prod --app restclient", *1* "postbuild": "npm run deploy", *2* "predeploy": "rimraf ../server/build/public && mkdirp ../server/build/public",*3* "deploy": "copyfiles -f dist/** ../server/build/public" *4* }-
1 ng build 命令将在默认目录 dist 中创建 restclient 应用的生成版本。
-
2 由于存在 postbuild 命令,它将自动启动并尝试运行部署命令。
-
3 由于还有一个 predeploy 命令,它将在 postbuild 之后和 deploy 之前运行。
-
4 最后,执行部署命令。
我们将在一分钟内解释
predeploy和deploy命令的作用,但我们的主要信息是,启动一个单独的命令导致了在指定顺序中运行四个命令。创建一系列部署命令很容易。小贴士
如果你使用 AOT 编译构建包,并且只使用标准的 Angular 装饰器(没有自定义的),你可以在 polyfills.ts 文件中注释掉
import 'core-js/es7/reflect';这一行来进一步优化你应用中 JavaScript 的大小。这将减小生成的 polyfill 包的大小。通常,部署过程会删除之前部署的文件所在的目录,创建一个新的空目录,并将新文件复制到这个目录中。在你的部署脚本中,你使用三个 npm 包来执行这些操作,无论你使用的是 Windows、Unix 还是 macOS 平台:
-
rimraf—— 删除指定的目录及其子目录 -
mkdirp—— 创建一个新的目录 -
copyfiles—— 从源复制文件到目标
在 package.json 的 devDependencies 部分中检查,你会看到
rimraf、mkdirp和copyfiles。小贴士
目前,Angular CLI 使用 Webpack 来构建包。Angular CLI 7 将包含新的构建工具。特别是,它将包括 Closure Compiler,它产生更小的包。
本章附带代码位于两个兄弟目录中:client 和 server。你的
predeploy命令会删除服务器/build/public 目录的内容(这是你将部署 Angular 应用的地方),然后创建一个新的空公共目录。&&符号允许你定义运行多个脚本的命令。deploy命令将客户端/dist 目录的内容(应用的包和资源)复制到服务器/build/public。在现实生活中,你可能需要在远程服务器上部署一个 Angular 应用,所以使用 package copyfiles 将不会工作。考虑使用 SCP 实用程序(见
en.wikipedia.org/wiki/Secure_copy),它可以从本地计算机安全地传输文件到远程计算机。如果你可以从终端窗口手动运行一个实用程序,你也可以使用 npm 脚本来运行它。在第十四章中,你将学习如何编写测试脚本。将测试运行器包含到构建过程中可能只需将
&& ng test添加到你的predeploy命令中。如果你发现了一些有用的 gulp 插件,为它创建一个 npm 脚本,例如,"myGulpCommand" : "gulp SomeUsefulTask"。为了验证你的部署脚本是否工作,请执行以下步骤:
1. 在服务器目录中运行以下命令以启动服务器:
node build/rest-server-angular2. 在客户端目录中,运行构建和部署脚本:
npm run build检查服务器/构建/公共目录——客户端的包应该在那里。
3. 打开浏览器访问 http://localhost:8000,你的 Angular 应用将从 Node 服务器加载,显示三个产品,如图 12.9 所示 figure 12.9。
我们已经描述了创建和运行网络服务器、创建和运行 Angular 应用程序(以开发模式运行)以及部署到服务器的整个过程。你的 Angular 应用程序使用
HttpClient服务发出 HTTP GET 请求从服务器检索数据。现在让我们看看如何发出 HTTP POST 请求将数据发送到服务器。12.5. 向服务器发送数据
HTTP POST 请求用于向服务器发送新数据。使用
HttpClient,发送 POST 请求与发送 GET 请求类似。调用HttpClient.post()方法表明你打算向指定的 URL 发送数据,但请求是在调用subscribe()时发出的。注意
对于更新服务器上的现有数据,使用
HttpClient.put();对于删除数据,使用HttpClient.delete()。12.5.1. 创建处理 POST 请求的服务器
你需要一个能够处理客户端发出的 POST 请求的网络服务器和端点。本章附带的服务器代码包括一个位于 rest-server-angular-post.ts 文件中的 /api/product 端点,用于添加新产品。因为你的目标不是拥有一个能够添加和保存产品的完整功能服务器,所以 /api/product 端点将简单地记录控制台上的已发布数据并向客户端发送确认消息。
发送到请求体的数据将到达,你需要能够解析它以提取数据。npm 包 body-parser 知道如何在 Express 服务器中这样做。如果你在服务器目录中打开 package.json,你将在依赖项部分找到 body-parser。你的服务器代码的整个代码如下所示。
列表 12.19. rest-server-angular-post.ts
import * as express from "express"; import * as path from "path"; import * as bodyParser from "body-parser"; *1* const app = express(); app.use('/', express.static(path.join(__dirname, 'public'))); app.use(bodyParser.json()); *2* app.post("/api/product", (req, res) => { *3* console.log(`Received new product ${req.body.title} ${req.body.price}`); *4* res.json({'message':`Server responded: added ${req.body.title}`}); *5* }); const server = app.listen(8000, "localhost", () => { const {address, port} = server.address(); console.log(`Listening on ${address}: ${port}`); });-
1 添加 body-parser 包
-
2 创建解析器将 req.body 的负载转换为 JSON
-
3 创建处理 POST 请求的端点
-
4 记录 POST 请求的负载
-
5 向客户端发送确认消息
你的服务器期望以 JSON 格式接收负载,并且它将以包含一个属性
message的 JSON 对象的形式发送响应。通过在服务器目录中运行以下命令来启动此服务器(不要忘记运行tsc来编译它):node build/rest-server-angular-post测试 RESTful API
当你使用 REST 端点创建网络服务器时,你应该测试它们以确保在开始编写任何客户端代码之前端点能够正常工作。你的 IDE 可能提供这样的工具。例如,WebStorm IDE 在“工具”菜单下有一个“测试 RESTful Web 服务”的菜单项。在输入所有测试服务器的数据后,这个工具看起来如下所示。
![]()
使用 WebStorm 的 Test RESTful 客户端
![]()
服务器响应
按下绿色播放按钮,你将在响应选项卡下看到你的/api/product 端点的响应,如图所示的下图。
![]()
服务器响应
如果你的 IDE 没有提供这样的测试工具,请使用名为 Advanced REST Client 的 Chrome 扩展程序(
install.advancedrestclient.com/#/install)或名为 Postman 的工具(www.getpostman.com)。现在你已经创建、启动并测试了 Web 服务器,让我们编写将新产品发布到该服务器的 Angular 客户端。
12.5.2. 创建用于发送 POST 请求的客户端
你的 Angular 应用将渲染一个简单的表单,用户可以在其中输入产品标题和价格,如图 12.11 所示。
图 12.11. 添加新产品的 UI
![]()
在填写完表单并点击添加产品按钮后,服务器将响应按钮下显示的确认消息。
在这个应用中,你将使用模板驱动的表单 API,你的表单将要求用户输入新产品标题和价格。在按钮点击时,你将调用
HttpClient.post()方法,然后是subscribe()。这个 Angular 应用的代码位于 restclientpost 子目录下的 client 目录中。如下所示。
列表 12.20. app.component.ts
import {Component} from "@angular/core"; import {HttpClient, HttpErrorResponse} from "@angular/common/http"; @Component({ selector: 'app-root', template: `<h1>Add new product</h1> <form #f="ngForm" (ngSubmit) = "addProduct(f.value)" > Title: <input id="productTitle" name="title" ngModel> <br> Price: <input id="productPrice" name="price" ngModel> <br> <button type="submit">Add product</button> </form> {{response}} `}) export class AppComponent { response: string; constructor(private httpClient: HttpClient) {} addProduct(formValue) { this.response=''; this.httpClient.post<string>("/api/product", *1* formValue) *2* .subscribe( *3* data => this.response = data['message'], *4* (err: HttpErrorResponse) => *5* this.response = `Can't add product. Error code: ${err.message} ${err.error.message}` ); } }-
1 声明发起 POST 请求的意图
-
2 提供 POST 有效负载
-
3 发起 HTTP POST 请求
-
4 获取服务器的响应
-
5 处理错误
当用户点击添加产品时,应用会发起 POST 请求并订阅服务器响应。
formValue包含一个 JavaScript 对象,其中包含在表单中输入的数据,HttpClient会自动将其转换为 JSON 对象。如果数据已成功发布,服务器将返回一个包含message属性的 JSON 对象,该属性通过数据绑定进行渲染。如果服务器响应错误,你将在 UI 中显示它。注意,你使用
err.message和err.error.message来提取错误描述。第二个属性可能包含额外的错误详情。修改服务器的代码,使其返回字符串而不是 JSON,UI 将显示详细的错误信息。要查看此应用的实际运行情况,请在项目 client 中运行以下命令:
ng serve --app restclientpost --proxy-config proxy-conf.json -o你现在知道如何发送 HTTP 请求和处理响应,你的应用中可能会有很多这样的请求。有没有一种方法可以拦截所有这些请求以提供一些额外的处理,比如显示/隐藏进度条,或者记录请求?
12.6. HTTP 拦截器
Angular 允许你为应用程序中所有 HTTP 请求和响应的预处理和后处理创建 HTTP 拦截器。它们对于实现诸如日志记录、全局错误处理、身份验证等跨切面关注点非常有用。我们想强调的是,拦截器在请求发出之前或在 UI 上渲染响应之前工作。这给你提供了一个机会来实现某些错误的重试场景或防止未授权访问的尝试。
要创建一个拦截器,你需要编写一个实现
HttpInterceptor接口的服务,这要求你实现一个方法:intercept()。Angular 将为这个回调提供两个参数:HttpRequest和HttpHandler。第一个参数包含被拦截的请求对象,你可以克隆并修改它。第二个参数用于通过调用handle()方法将修改后的请求转发到后端或链中的另一个拦截器(如果有的话)。注意
HttpRequest和HttpResponse对象是不可变的,而“修改”一词意味着创建并传递这些对象的新实例。下面的列表中显示的拦截器服务不会修改输出的
HttpRequest,而只是简单地将其内容打印到控制台并原样传递。列表 12.21. 一个简单的拦截器
@Injectable() export class MyFirstInterceptor implements HttpInterceptor { intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { // Clone and modify your HTTPRequest using req.clone() // or perform other actions here console.log("I've intercepted your HTTP request! ${JSON.stringify(req)}`); return next.handle(req); } }在列表 12.21 中,你转发
HttpRequest,但你可以修改其头部或参数,并返回修改后的请求。next.handle()方法在请求完成时返回一个可观察对象,如果你想同时修改 HTTP 响应,可以在next.handle()返回的流上应用额外的 RxJS 操作符。intercept()方法接收HttpRequest对象并返回,不是HttpResponse对象,而是HttpEvent的可观察对象,因为 Angular 将HttpResponse实现为一个HttpEvent值的流。HttpRequest和HttpResponse都是不可变的,如果你想修改它们的属性,你需要先克隆它们,如下面的列表所示。列表 12.22. 修改
HTTPRequestintercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any >> { const modifiedRequest = req.clone({ setHeaders: { ('Authorization', 'Basic QWxhZGRpb') } }); return next.handle(modifiedRequest); }因为拦截器是一个可注入的服务,不要忘记在
@NgModule()装饰器中声明其提供者以HTTP_INTERCEPTORS令牌:providers: [{provide: HTTP_INTERCEPTORS, useClass: MyFirstInterceptor, multi: true}]multi: true选项告诉你HTTP_INTERCEPTORS是一个多提供者令牌——一组服务可以代表相同的令牌。你可以注册多个拦截器,Angular 将注入所有这些拦截器:providers: [{provide: HTTP_INTERCEPTORS, useClass: MyFirstInterceptor, multi: true}, {provide: HTTP_INTERCEPTORS, useClass: MySecondInterceptor, multi: true}]注意
如果你定义了多个拦截器,它们将按照定义的顺序被调用。
为了说明拦截器的工作原理,让我们创建一个带有
HttpInterceptor的应用程序,该拦截器将拦截并记录服务器返回的所有错误。对于客户端,你将重用第 12.5.2 节中显示的应用程序,该应用程序如图 12.11 所示,添加日志服务和拦截器以在控制台记录错误。你将稍微修改前一个章节中的服务器以随机生成错误。你可以在 rest-server-angular-post-errors.ts 文件中找到服务器的完整代码。现在,它将不再只是响应成功消息,而是随机返回一个错误,如下所示。
列表 12.23. 模拟服务器错误
if (Math.random() < 0.5) { *1* res.status(500); res.send({'message': `Server responded: error adding product ${req.body.title}`}); } else { *2* res.send({'message': `Server responded: added ${req.body.title}`); }-
1 返回状态为 500 的 HTTP 响应
-
2 返回成功的 HTTP 响应
按以下方式启动此服务器:
node build/rest-server-angular-post-errors你的 Angular 应用位于拦截器目录中,并包含一个作为两个类实现的日志服务:
LoggingService和ConsoleLoggingService。LoggingService是一个抽象类,它声明了一个方法,log()。列表 12.24. logging.service.ts
@Injectable() export abstract class LoggingService { abstract log(message: string): void; }因为这个类是抽象的,所以不能被实例化,你将创建如下所示的
ConsoleLoggingService类。列表 12.25. console.logging.service.ts
@Injectable() export class ConsoleLoggingService implements LoggingService{ log(message:string): void { console.log(message); } }你可能想知道为什么你会为这样一个简单的日志服务创建一个抽象类。这是因为在实际应用中,你可能不仅想在浏览器的控制台中引入日志,还可能在服务器上引入。拥有一个抽象类将允许你将其用作声明提供者的令牌:
providers: [{provide: LoggingService, useClass: ConsoleLoggingService}]之后,你可以创建一个名为
ServerLoggingService的类,该类实现了LoggingService,并且要从控制台日志切换到服务器日志,你需要更改提供者,而无需修改使用它的组件:providers: [{provide: LoggingService, useClass: ServerLoggingService}]如果你的拦截器收到错误,你会做以下操作:
1. 在控制台上记录它。
2. 将
HttpErrorResponse替换为包含错误消息的新实例HttpResponse。3. 返回新的
HttpResponse,以便客户端可以向用户显示它。拦截器类将在
HttpHandler.next()返回的可观察对象上使用catchError操作符,你将在其中实现这些步骤。你的拦截器在 logging.interceptor.service.ts 文件中实现。列表 12.26. logging.interceptor.service.ts
import {Injectable} from "@angular/core"; import {HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest, HttpResponse} from "@angular/common/http"; import {Observable, of} from "rxjs"; import {catchError} from 'rxjs/operators'; import {LoggingService} from "./logging.service"; @Injectable() export class LoggingInterceptor implements HttpInterceptor { constructor(private loggingService: LoggingService) {} *1* intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { return next.handle(req) *2* .pipe( catchError((err: HttpErrorResponse) => *3* this.loggingService.log(`Logging Interceptor: ${err.error.message}`);*4* return of(new HttpResponse( *5* {body:{message: err.error.message}})); *6* }) ); } }-
1 注入控制台日志服务
-
2 将请求转发到服务器,将响应转发到客户端
-
3 捕获服务器返回的响应错误
-
4 在控制台上记录错误消息
-
5 用
HttpResponse替换HttpErrorResponse -
6 新的
HttpResponse将包含错误消息。
应用程序组件的代码没有拦截器类的引用,如下所示。它将始终接收包含服务器成功添加新产品或错误消息的消息的
HttpResponse对象。列表 12.27. app.component.ts
import {Component} from "@angular/core"; import {HttpClient} from "@angular/common/http"; import {Observable} from "rxjs"; import {map} from "rxjs/operators"; @Component({ selector: 'app-root', template: `<h1>Add new product</h1> <form #f="ngForm" (ngSubmit) = "addProduct(f.value)" > Title: <input id="productTitle" name="title" ngModel> <br> Price: <input id="productPrice" name="price" ngModel> <br> <button type="submit">Add product</button> </form> {{response$ | async}} *1* `}) export class AppComponent { response$: Observable<string>; *2* constructor(private httpClient: HttpClient) {} addProduct(formValue){ this.response$=this.httpClient.post<{message: string}>("/api/product", *3* formValue) .pipe( map (data=> data.message) *4* ); } }-
1 渲染从服务器接收到的任何消息(包括错误)
-
2 这个可观察对象是拦截器的响应。
-
3 期望服务器对 HTTP POST 的响应为 {message: string}
-
4 提取消息属性的文本
当你将此应用与上一节中的应用进行比较时,请注意,你不需要在组件中处理错误,该组件将消息渲染到 UI 上。现在
LoggingInterceptor将处理所有 HTTP 错误。要查看此应用的实际运行情况,请运行以下命令并监控浏览器控制台中的日志消息。
ng serve --app interceptor --proxy-config proxy-conf.json -o此应用应能给你一个如何实现跨切面关注点,如全局错误日志服务,用于所有 HTTP 响应的思路,而无需修改任何使用
HttpClient服务的应用程序组件或服务。HTTP 请求是异步的,可以生成多个你可能想要拦截和处理的事件。让我们看看如何做到这一点。
12.7. 进度事件
有时上传或下载某些资产(如大型数据文件或图像)需要时间,你应该让用户了解进度。
HttpClient提供了包含资产总大小、当前已上传或下载的字节数等信息的事件。要启用进度事件跟踪,请使用带有选项
{reportProgress: true}的HttpRequest对象发出请求。例如,你可以发出一个 HTTP GET 请求来读取 my_large_file.json 文件。列表 12.28. 使用事件跟踪进行 GET 请求
const req = new HttpRequest('GET', *1* './my_large_file.json', *2* { reportProgress: true }); *3* httpClient.request(req).subscribe( *4* // Handle progress events here);-
1 声明发起 GET 请求的意图
-
2 指定要读取的文件
-
3 启用进度事件
-
4 发起请求
在
subscribe()方法中,检查发射的值是否是你感兴趣的事件类型,例如HttpEventType.DownloadProgress或HttpEventType.UploadProgress。这些事件具有loaded属性,表示当前已传输的字节数,以及total属性,表示传输的总大小。下一个应用展示了如何处理进度事件以计算和显示文件下载的百分比。此应用包含一个大的 48 MB JSON 文件。在此情况下,文件内容无关紧要。图 12.12 显示了文件下载完成后应用的状态。左侧的百分比在文件通过
HttpClient加载时正在变化。此应用还会在浏览器的控制台中报告进度。图 12.12. 读取文件时的进度报告
![]()
此应用位于 progressevents 目录中,app.component.ts 文件的内容如下所示。
列表 12.29. app.component.ts
import {HttpClient, HttpEventType, HttpRequest} from '@angular/common/http'; import {Component} from "@angular/core"; @Component({ selector: 'app-root', template: `<h1>Reading a file: {{percentDone}}% done</h1> *1* `}) export class AppComponent{ mydata: any; percentDone: number; constructor(private httpClient: HttpClient) { const req = new HttpRequest('GET', *2* './data/48MB_DATA.json', *3* {reportProgress: true}); *4* httpClient.request(req) .subscribe(data => { if (data.type === HttpEventType.DownloadProgress) { *5* this.percentDone = Math.round(100 * data.loaded / data.total);)) *6* console.log(`Read ${this.percentDone}% of ${data.total} bytes`); } else { this.mydata = data *7* } }); } }-
1 渲染当前百分比
-
2 声明发起 GET 请求的意图
-
3 指定要读取的文件
-
4 启用进度事件跟踪
-
5 检查进度事件的类型
-
6 计算当前百分比
-
7 发射的值不是进度事件
要查看此应用的实际运行情况,请运行以下命令:
ng serve --app progressevents -o这个应用总结了使用 HTTP 与 Web 服务器通信的内容。在下一章中,您将看到 Angular 客户端如何使用 WebSockets 与 Web 服务器通信。
摘要
-
Angular 自带
HttpClient服务,该服务支持与 Web 服务器的 HTTP 通信。 -
HttpClient的公共方法返回一个Observable对象,并且只有当客户端订阅它时,才会向服务器发出请求。 -
Angular 客户端可以与使用不同技术实现的 Web 服务器通信。
-
您可以拦截并替换 HTTP 请求和响应,以实现跨切面关注点。
第十三章 使用 WebSocket 协议与服务器交互
本章涵盖
-
实现服务器数据推送到 Angular 客户端
-
从服务器向多个客户端广播数据
-
将 ngAuction 拆分为两个项目
-
在 ngAuction 中实现竞标
WebSocket 是一种由所有现代网络浏览器支持的低开销二进制协议(参见
en.wikipedia.org/wiki/WebSocket)。它允许浏览器和 Web 服务器之间双向的消息流式传输文本和二进制数据。与 HTTP 不同,WebSocket 不是一个基于请求/响应的协议,服务器应用程序和客户端应用程序可以在数据可用时立即向对方发起数据推送,实现实时通信。这使得 WebSocket 协议非常适合以下类型的应用程序:-
实时交易/拍卖/体育通知
-
通过网络控制医疗设备
-
聊天应用程序
-
多玩家在线游戏
-
社交流中的实时更新
-
实时图表
所有这些应用程序都有一个共同点:存在一个服务器(或设备),可能需要立即向用户发送通知,因为某个重要事件发生在其他地方。这与用户决定向服务器发送请求以获取新数据的使用案例不同。例如,如果股票交易在证券交易所发生,通知必须立即发送给所有用户。
另一个例子是在线拍卖。如果用户 Joe 正在考虑对某个产品进行竞标,而用户 Mary(位于 1,000 英里之外)决定提高对该产品的出价,您最好立即将通知推送给 Joe,而不是等到 Joe 刷新窗口。
我们将从这个章节开始,简要比较 HTTP 和 WebSocket 协议,然后向您展示如何使用 Node 服务器将数据推送到普通网页和 Angular 应用程序。
在实践部分,您将继续在 ngAuction 上工作。您将从将 ngAuction 拆分为两个项目:客户端和服务器开始。服务器应用程序将启动两个服务器:HTTP 服务器将提供数据,WebSocket 服务器可以接收用户出价并推送实时出价通知,模拟多个用户可以对拍卖产品进行竞价的场景。Angular 客户端与这两个服务器进行交互。
13.1. 比较 HTTP 和 WebSocket
基于请求的 HTTP 协议,客户端通过连接发送请求并等待响应返回,如图 13.1 所示。请求和响应都使用相同的浏览器-服务器连接。首先,请求发送出去,然后响应通过相同的“线路”返回。想象一下,河上的一座狭窄桥梁,两边的汽车必须轮流过桥。在 Web 领域,这种类型的通信被称为半双工。
图 13.1. 半双工通信
![]()
WebSocket 协议允许数据在同一连接上同时双向传输(全双工),如图 13.2 所示,任何一方都可以启动数据交换。这就像是一条双车道道路。另一个类比是电话通话,两个通话者可以同时说话并被听到。WebSocket 连接保持活跃,这有额外的益处:服务器和客户端之间交互的低延迟。
图 13.2. 全双工通信
![图片 13.2]()
一个典型的 HTTP 请求/响应会在应用数据上添加几百字节(HTTP 头部)。假设你想编写一个每秒报告最新股票价格的 Web 应用程序。使用 HTTP,这样的应用程序需要发送一个 HTTP 请求(大约 300 字节)并接收一个股票价格,该价格会附带额外的 300 字节的 HTTP 响应对象。
使用 WebSocket,开销低至几个字节。此外,无需每秒都发送请求以获取新的价格报价——这种股票可能一段时间内不会交易。只有当股票价格发生变化时,服务器才会将新的值推送到客户端。注意以下观察结果(见 goo.gl/zjj7Es):
将千字节的数据减少到 2 字节不仅仅是“稍微节省一些字节”,将延迟从 150 毫秒(TCP 往返设置连接加上消息的包)减少到 50 毫秒(仅仅是消息的包)则远远超出了边际。实际上,这两个因素单独就足以使 WebSocket 对 Google 来说变得非常有趣。
伊恩·希克斯
备注
尽管大多数浏览器支持二进制协议 HTTP/2(见
http2.github.io)——它比 HTTP 更高效,并且还允许服务器推送数据——但它并不是 WebSocket 协议的替代品。WebSocket 协议提供了一个 API,允许将 数据 推送到浏览器中运行的客户端应用程序,而 HTTP/2 推送 静态资源 到浏览器,主要用于更快的应用程序交付。每个浏览器都支持一个
WebSocket对象,用于创建和管理到服务器的套接字连接(见mng.bz/1j4g)。最初,浏览器会与服务器建立一个常规的 HTTP 连接,但随后你的应用程序请求升级连接,指定支持 WebSocket 连接的服务器 URL。之后,通信成功,无需 HTTP。WebSocket 端点的 URL 以 ws 开头而不是 http——例如,ws://localhost:8085。WebSocket 协议基于事件和回调。例如,当您的浏览器应用与服务器建立连接时,它会接收到
connection事件,并且您的应用调用回调来处理此事件。为了处理服务器可能通过此连接发送的数据,请期待提供相应回调的message事件。如果连接关闭,close事件会被触发,以便您的应用可以相应地做出反应。在发生错误的情况下,WebSocket对象会接收到error事件。在服务器端,您将不得不处理类似的事件。它们的名称可能取决于您在服务器上使用的 WebSocket 软件。让我们编写一些代码,其中 Node 服务器将通过 WebSocket 向 Angular 应用发送数据。
13.2. 从 Node 服务器向普通客户端推送数据
大多数服务器端平台(Java、.NET、Python 等)都支持 WebSocket。在第十二章中,您开始使用 Node 服务器进行工作,您将继续使用 Node 来实现您的 WebSocket 服务器。在本节中,您将实现一个特定的用例:客户端连接到 socket 后,服务器立即向浏览器客户端推送数据。由于任一方都可以开始通过 WebSocket 连接发送数据,您会看到 WebSocket 并不涉及请求/响应通信。您的简单客户端不需要发送数据请求——服务器将发起通信。
几个 Node 包实现了 WebSocket 协议,您将使用名为
ws的 npm 包(www.npmjs.com/package/ws)。您可以在项目目录中输入以下命令来安装此包及其类型定义:npm install ws npm install @types/ws --save-dev需要类型定义,这样 TypeScript 编译器就不会在您使用
ws包的 API 时发出警告。此外,此文件便于查看可用的 API 和类型。注意
本章附带代码中有一个名为
server的目录,其中包含名为package.json的文件,列出了ws和@types/ws作为依赖项。您只需运行npm install命令。源代码可以在github.com/Farata/angulartypescript和www.manning.com/books/angular-development-with-typescript-second-edition找到。您的第一个 WebSocket 服务器将会非常简单:一旦建立了 socket 连接,它就会将文本“此消息由 WebSocket 服务器推送”推送到 HTML/JavaScript 客户端(不使用 Angular)。我们故意不希望客户端向服务器发送任何请求,这样我们可以说明 socket 是双向的,服务器可以在没有任何请求仪式的情况下推送数据。
你的应用程序创建了两个服务器。HTTP 服务器在端口 8000 上运行,负责向浏览器发送 HTML 页面。当此页面加载时,它会立即连接到运行在端口 8085 上的 WebSocket 服务器。此服务器将在连接建立后立即推送带有问候的消息。此应用程序的代码位于 server/simple-websocket-server.ts 文件中,如下所示。
列表 13.1. simple-websocket-server.ts
import * as express from "express"; import * as path from "path"; import {Server} from "ws"; *1* const app = express(); // HTTP Server app.get('/', (req, res) => res.sendFile(path.join(__dirname, '../simple-websocket-client.html'))); *2* const httpServer = app.listen(8000, "localhost", () => { *3* console.log(`HTTP server is listening on localhost:8000`); }); // WebSocket Server const wsServer = new Server({port: 8085}); *4* console.log('WebSocket server is listening on localhost:8085'); wsServer.on('connection', *5* wsClient => { wsClient.send('This message was pushed by the WebSocket server');*6* wsClient.onerror = (error) => *7* console.log(`The server received: ${error['code']}`); } );-
1 你将使用 ws 模块中的 Server 来实例化 WebSocket 服务器。
-
2 当 HTTP 客户端通过根路径连接时,HTTP 服务器将发送回此 HTML 文件。
-
3 在端口 8000 上启动 HTTP 服务器
-
4 在端口 8085 上启动 WebSocket 服务器
-
5 监听来自客户端的连接事件
-
6 将消息推送到新连接的客户端
-
7 处理连接错误
一旦任何客户端通过端口 8085 连接到你的 WebSocket 服务器,服务器上就会触发连接事件,并且它还会收到代表此特定客户端连接的对象引用。使用
send()方法,服务器向此客户端发送问候。如果另一个客户端连接到同一端口的套接字,它也会收到相同的问候。注意
一旦新客户端连接到服务器,此连接的引用就会被添加到
wsServer.clients数组中,这样你就可以在需要时向所有已连接的客户端广播消息:wsServer.clients.forEach (client => client.send('...'));。在你的应用程序中,HTTP 和 WebSocket 服务器运行在不同的端口上,但你可以通过将新创建的
httpServer实例提供给 WebSocket 服务器的构造函数来重用相同的端口,如下所示。列表 13.2. 两个服务器重用相同的端口
const httpServer = app.listen(8000, "localhost", () => {...}); *1* const wsServer = new Server({server: httpServer}); *2*-
1 在端口 8000 上创建 HTTP 服务器实例
-
2 基于现有的 HTTP 服务器创建 WebSocket 服务器实例
注意
在动手实践部分,你将重用端口 8000 用于 HTTP 和 WebSocket 通信(请参阅 ng-auction/server/ws-auction.ts 文件)。
服务器/简单-websocket-client.html 文件的内容显示在下一条列表中。这是一个不使用任何框架的纯 HTML/JavaScript 客户端。
列表 13.3. simple-websocket-client.html
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> </head> <body> <span id="messageGoesHere"></span> <script type="text/javascript"> var ws = new WebSocket("ws://localhost:8085"); *1* ws.onmessage = function(event) { *2* var mySpan = document.getElementById("messageGoesHere"); mySpan.innerHTML = event.data; }; ws.onerror = function(event) { *3* console.log(`Error ${event}`); } </script> </body> </html>-
1 建立套接字连接
-
2 当消息从套接字到达时,在 元素中显示其内容
-
3 如果发生错误,浏览器将在控制台记录错误消息。
当浏览器下载此文件时,其脚本会连接到你的 WebSocket 服务器,地址为 ws://localhost:8085。在此点,服务器将协议从 HTTP 升级到 WebSocket。请注意,协议是
ws而不是http。对于安全的套接字连接,请使用wss协议。要查看此示例的实际效果,请在服务器目录中运行
npm install,通过运行tsc命令编译代码,然后按照以下方式启动服务器:node build/simple-websocket-server你将在控制台看到以下消息:
WebSocket server is listening on port 8085 HTTP server is listening on 8000打开 Chrome 浏览器并打开其开发者工具,访问 http://localhost:8000。你将看到消息,如图 13.3 所示的左侧。在右侧的“网络”标签页中,你可以看到对本地服务器的两个请求。第一个请求加载了 simple-websocket-client.html 文件,第二个请求是对服务器上 8085 端口上打开的 WebSocket 的请求。
图 13.3. 从套接字获取消息
![图片 13.3]()
在这个例子中,仅使用 HTTP 协议最初加载 HTML 文件。然后客户端请求将协议升级到 WebSocket(状态码 101),从那时起,这个网页将不再使用 HTTP。你可以使用 Chrome 开发者工具中的帧标签页监控通过套接字传输的数据。在这个演示中,你使用浏览器原生的
WebSocket对象编写了一个 WebSocket 客户端,但一个 Angular 应用如何通过 WebSocket 消费或发送消息到服务器呢?13.3. 在 Angular 客户端中使用 WebSocket
在 Angular 中,你通常将所有与服务器的通信包装到可注入的服务中。在 第十二章 中的一些应用程序中,你使用
HttpClient做过这件事,你也将使用WebSocket对象。但这两个对象在HttpClient已经是一个 Angular 可注入服务,你会在应用程序的服务类中 注入 它,而WebSocket是一个原生浏览器对象,你将在服务类内部 创建 它。HttpClient和WebSocket之间还有一个主要区别。如果使用HttpClient发送 HTTP 请求会返回一个包含单个值的可观察对象,那么WebSocket对象提供了一个易于转换为多个值(如股票价格或产品竞标)的可观察流 API。将 WebSocket 视为一个可以发出值的 数据生产者,而
Observable对象可以将这些值传递给订阅者(例如 Angular 组件)。在 Angular 中,你可以手动创建一个从 WebSocket 连接生成可观察流的服务,或者使用 RxJS 提供的WebSocketSubject。在本章中,你将看到在 Angular 客户端处理 WebSocket 消息的两种方式。但首先,让我们看看如何将任何发出值的
Observable包装到 Angular 服务中。13.3.1. 将可观察流包装到服务中
在本节中,你将创建一个可观察服务,该服务会发出硬编码的值,而不连接到任何服务器。在 附录 D 的 13.5 节中,我们解释了如何使用
Observable.create()方法,并提供一个观察者作为参数。如果你还没有阅读 附录 D,请现在阅读。以下列表创建了一个服务,该服务有一个方法,该方法接受一个观察者作为参数,并每秒发出当前时间。
列表 13.4. observable.service.ts
import {Observable} from 'rxjs'; export class ObservableService { createObservableService(): Observable<Date> { *1* return new Observable( *2* observer => { *3* setInterval(() => observer.next(new Date()) *4* , 1000); } ); } }-
1 返回日期的可观察流
-
2 创建可观察对象
-
3 提供观察者
-
4 每秒发射新的日期
在此服务中,你创建一个
Observable对象的实例,假设订阅者将提供一个知道如何处理发射数据的Observer。每当可观察对象在观察者上调用next(new Date())方法时,订阅者将接收到当前的日期和时间。你的数据流永远不会抛出错误,也永远不会完成。你将
ObservableService注入到AppComponent中,它调用createObservableService()方法并订阅其值流,创建一个知道如何处理数据的观察者。观察者只是将接收到的时间分配给渲染 UI 上时间的currentTime变量,如下列所示。列表 13.5. observableservice/app.component.ts
import {Component} from "@angular/core"; import {ObservableService} from "./observable.service"; @Component({ selector: 'app-root', providers: [ObservableService], template: `<h1>Custom observable service</h1> Current time: {{currentTime | date: 'mediumTime'}} *1* `}) export class AppComponent { currentTime: Date; constructor(private observableService: ObservableService) { *2* this.observableService.createObservableService() *3* .subscribe(data => this.currentTime = data); *4* } }-
1 使用日期管道显示时间
-
2 注入封装可观察对象的服务
-
3 创建可观察对象并开始发射日期
-
4 订阅日期流
此应用程序不使用任何服务器,你可以在以下位置看到它的运行情况。在客户端目录中运行以下命令来运行它(在
npm install之后):ng serve --app observableservice -o在浏览器窗口中,当前时间将每秒更新一次。你在这里使用
DatePipe,格式为'mediumTime',它只显示小时、分钟和秒(所有日期格式都在 AngularDatePipe文档中描述,见mng.bz/78lD)。此简单示例演示了创建一个可注入服务的基本技术,该服务封装了一个可观察流,以便组件或用户服务可以订阅它。在这种情况下,你使用
setInterval(),但你可以用任何特定于应用程序的代码替换它,该代码生成一个或多个值并将它们作为流发射出来。不要忘记错误处理以及在必要时完成流。以下列表显示了一个向观察者发送一个元素的可观察对象,可能会抛出错误,并通知观察者流已完成的示例。
列表 13.6. 发送错误和完成事件
return new Observable( observer => { try { observer.next('Hello from observable'); *1* // throw("Got an error"); *2* // some other code can be here } catch(err) { observer.error(err); *3* } finally { observer.complete(); *4* } } );-
1 将文本值发送给观察者
-
2 模拟错误情况
-
3 将错误发送给观察者
-
4 总是让观察者知道数据流已结束。
如果你取消注释带有
throw的行,前面的程序将跳过“某些其他代码”并继续在catch部分,在那里你调用observer.error()。这将导致如果有的话,在订阅者上调用错误处理程序。你的可观察流的数据生产者是时间生成器,但它可以是生成一些有用值的 WebSocket 服务器。让我们创建一个与 WebSocket 服务器通信的 Angular 服务。
13.3.2. 角度与 WebSocket 服务器通信
在实际操作部分,您将实现一个 Angular 客户端通过 WebSockets 与服务器通信的实际情况。这就是 ngAuction 的用户如何进行竞标并接收其他用户竞标通知的方式。
在本节中,我们将向您展示一种非常基本的方法,将
WebSocket包装到 Angular 客户端中。这将是一个相当简单的WebSocket对象包装器,但在实际操作部分,您将使用一个更健壮的WebSocketSubject,它随 RxJS 一起提供。您的下一个 Angular 应用程序将包括一个与 Node WebSocket 服务器交互的服务。服务器端层可以使用支持 WebSockets 的任何技术实现。图 13.4 展示了此类应用程序的架构(想象一下,竞标消息在客户端和服务器之间通过套接字连接进行交换)。
图 13.4. Angular 通过套接字与服务器交互
![]()
列表 13.7 中的代码将浏览器的
WebSocket对象包装成一个可观察的流。此服务根据提供的 URL 创建一个连接到 WebSocket 服务器的WebSocket对象实例,客户端实例处理从服务器接收到的消息。您的
WebSocketService还有一个sendMessage()方法,因此客户端也可以向服务器发送消息。在发送消息之前,该服务会检查连接是否仍然打开(WebSocket.readyState === 1),如下面的列表所示。列表 13.7. wsservice/websocket.service.ts
import {Observable } from 'rxjs'; export class WebSocketService { ws: WebSocket; socketIsOpen = 1; *1* createObservableSocket(url: string): Observable<any> { *2* this.ws = new WebSocket(url); *3* return new Observable( *4* observer => { this.ws.onmessage = (event) => observer.next(event.data); *5* this.ws.onerror = (event) => observer.error(event); *6* this.ws.onclose = (event) => observer.complete(); *7* return () => this.ws.close(1000, "The user disconnected"); *8* } ); } sendMessage(message: string): string { if (this.ws.readyState === this.socketIsOpen) { *9* this.ws.send(message); *10* return `Sent to server ${message}`; } else { return 'Message was not sent - the socket is closed'; *11* } } }-
1 WebSocket 已打开。
-
2 此方法会发出从指定 URL 收到的消息。
-
3 连接到 WebSocket 服务器
-
4 创建一个 Observable 对象
-
5 将从服务器接收到的消息发送给订阅者
-
6 将从服务器接收到的错误发送给订阅者
-
7 如果服务器关闭套接字,则通知订阅者
-
8 返回一个回调,以便调用者可以取消订阅
-
9 检查连接是否打开
-
10 将消息发送到服务器
-
11 通知调用者连接已关闭
注意,您的观察者返回一个回调,因此如果调用者调用
unsubscribe()方法,则此回调将被调用。它将关闭连接,发送状态码 1000 和解释关闭原因的消息。您可以在mng.bz/5V07查看所有允许的关闭连接的状态码。现在,让我们编写
AppComponent,它订阅WebSocketService,该服务注入到 图 13.4 中所示的AppComponent中。此组件,如下面的列表所示,当用户点击发送消息到服务器按钮时,也可以向服务器发送消息。列表 13.8. wsservice/app.component.ts
import {Component, OnDestroy} from "@angular/core"; import {WebSocketService} from "./websocket.service"; import {Subscription} from "rxjs"; @Component({ selector: 'app-root', providers: [ WebSocketService ], template: `<h1>Angular client for a WebSocket server</h1> {{messageFromServer}}<br> <button (click)="sendMessageToServer()">Send Message to Server</button> <button (click)="closeSocket()">Disconnect</button> <div>{{status}}</div> `}) export class AppComponent implements OnDestroy { messageFromServer: string; wsSubscription: Subscription; *1* status; constructor(private wsService: WebSocketService) { *2* this.wsSubscription = this.wsService.createObservableSocket("ws://localhost:8085") *3* .subscribe( data => this.messageFromServer = data, *4* err => console.log( 'err'), () => console.log( 'The observable stream is complete') ); } sendMessageToServer(){ this.status = this.wsService.sendMessage("Hello from client"); *5* } closeSocket(){ this.wsSubscription.unsubscribe(); *6* this.status = 'The socket is closed'; } ngOnDestroy() { this.closeSocket(); } }-
1 此属性将保留订阅的引用。
-
2 注入服务
-
3 连接到服务器
-
4 处理从服务器接收到的数据
-
5 向服务器发送消息
-
6 关闭 WebSocket 连接
注意,您将订阅的引用存储在
wsSubscription属性中,当用户点击“断开连接”按钮时,此组件从可观察对象中取消订阅。这会调用观察者中定义的回调,关闭 WebSocket 连接。客户端已准备好。现在我们将向您展示与该客户端通信的服务器代码。在连接事件上调用的回调函数向客户端发送问候,并将两个更多的事件处理函数添加到表示此特定客户端的对象中。
一个函数处理来自客户端的消息,另一个处理错误(您将记录错误代码)。此服务器在 two-way-websocket-server.ts 文件中实现。
列表 13.9. server/two-way-websocket-server.ts
import {Server} from "ws"; let wsServer = new Server({port:8085}); *1* console.log('WebSocket server is listening on port 8085'); wsServer.on('connection', *2* websocket => { websocket.send('Hello from the two-way WebSocket server'); *3* websocket.onmessage = (message) => *4* console.log(`The server received: ${message['data']}`); websocket.onerror = (error) => *5* console.log(`The server received: ${error['code']}`); websocket.onclose = (why) => *6* console.log(`The server received: ${why.code} ${why.reason}`); });-
1 启动 WebSocket 服务器
-
2 新客户端已连接
-
3 向新连接的客户端问候
-
4 监听来自此客户端的消息
-
5 记录此连接的错误,如果有
-
6 客户端已断开连接,因此您记录了原因。
要查看此应用程序的实际运行情况,请从服务器目录运行以下命令以启动服务器:
node build/two-way-websocket-server然后按照以下步骤从客户端目录构建并启动 Angular 应用程序:
ng serve --app wsservice要模拟多个客户端连接到同一 WebSocket 服务器的场景,请同时在 http://localhost:4200 打开两个浏览器。每个应用程序都将收到服务器的问候,您可以通过点击“发送消息到服务器”按钮向服务器发送消息。
在按钮点击一次后(在 Chrome 开发者工具中,网络下打开了 WS 和 Frames 选项卡),我们拍摄了 图 13.5 中的截图。在右侧,您可以看到从服务器到达的问候消息和客户端发送到服务器的消息。
图 13.5. 从 Node 获取消息的 Angular
![图片 13.5]()
图 13.6 展示了客户端点击“发送消息到服务器”按钮后,断开连接,然后再次发送消息到服务器的截图。
图 13.6. 发送、断开连接,然后再次发送
![图片 13.6]()
注意
浏览器不对 WebSocket 连接执行同源策略。这就是为什么您能够交换来自端口 4200 的客户端和运行在端口 8085 上的服务器之间的数据。请参阅您使用的任何服务器端技术文档,以了解 WebSocket 可用的保护措施。
| |
将 WebSocket 与服务器端消息系统集成
假设你的服务器使用消息系统;让我们以 ActiveMQ 为例。假设你希望启用你的 JavaScript 客户端通过 WebSockets 与 ActiveMQ 交换数据。如果你决定从头开始编写这样的数据交换程序,你需要想出一个方法来通知服务器端点,客户端发送的数据应该被重定向到具有特定名称的 ActiveMQ 队列。然后服务器端代码需要格式化客户端的消息,使其符合 ActiveMQ 的内部协议。此外,服务器端应用程序需要跟踪所有已连接的客户端,并可能实现一些心跳来监控套接字连接的健康状况。这需要大量的编码工作。
好消息是 WebSockets 可以使用子协议与服务器端消息系统集成。例如,服务器端代码可以将 WebSocket 端点映射到 ActiveMQ 中的现有队列。这样,当服务器软件将消息放入队列时,它会自动推送到客户端。同样,当客户端向 WebSocket 端点发送消息时,它会被放置在服务器上的队列中。实现心跳功能只需提供一个配置选项。
STOMP 是用于通过 WebSockets 发送文本消息的流行子协议之一(见
mng.bz/PPsy)。它描述了一个客户端消息代理,它与服务器端对等体进行通信。对于客户端 STOMP 支持,我们使用 ng2-stompjs,可在mng.bz/KdIM获取。服务器端管理员应为其消息服务器(ActiveMQ 具有本机 STOMP 支持)安装 STOMP 连接器。在这种配置中,客户端-服务器通信更加健壮,并且需要在应用程序级别上编写更少的代码。
在第十二章中,你学习了如何通过 HTTP 与 Web 服务器进行通信。在本章中,我们介绍了 WebSocket 协议。ngAuction 的下一个版本将使用这两种通信协议,但首先让我们看看本章涵盖的材料如何应用于你即将实施的 ngAuction 的新功能。
WebSocket 协议不是基于请求/响应模型,WebSocket 服务器可以在没有任何额外仪式的情况下主动与客户端通信。这对于 ngAuction 来说是一个非常有价值的特性,因为服务器首先知道任何用户在多用户应用程序中对每个拍卖产品的出价。由于服务器不需要等待客户端的数据请求,它可以实时将新出的价推送到所有连接到该 WebSocket 服务器的用户。这意味着服务器可以实时将最新的出价推送给所有用户。
13.4. 实践:支持 WebSockets 的 Node 服务器
在本节中,我们将回顾本章附带的重构版本的 ngAuction。在真实拍卖中,多个用户可以对产品进行竞标。当服务器收到用户的竞标时,竞标服务器应向所有观看选定产品的用户广播最新的竞标。本版本的 ngAuction 完成了以下主要任务:
-
将 ngAuction 分为两个独立的项目,客户端和服务器,并将产品数据和图像存储在服务器上。
-
修改客户端,使其使用
HttpClient服务向服务器发送请求以获取产品数据。 -
在服务器端,实现 HTTP 和 WebSocket 服务器。HTTP 服务器将提供产品数据。
-
WebSocket 服务器将接受用户对选定产品的竞标,所有其他用户都可以看到服务器推送的最新竞标。
图 13.7 展示了渲染后的
ProductDetailComponent,其中包含一个按钮,允许用户以 $5 的增量进行竞标。如果用户点击此按钮一次,价格将在他们的 UI 上变为 $75,以及所有其他打开相同产品详细视图的用户。服务器将通过 WebSocket 连接向所有查看此产品的用户广播最新的竞标金额。图 13.7. 带有竞标按钮的
ProductDetailComponent![]()
要实现此功能,您需要在服务器上添加 WebSocket 支持,并在客户端创建一个新的
BidService。图 13.8 展示了在本版本 ngAuction 中涉及客户端-服务器通信的主要参与者。图 13.8. ngAuction 中的客户端-服务器通信
![]()
图 13.8 中的 DI 代表 依赖注入。Angular 将
HttpClient服务注入到ProductService中,后者反过来被注入到三个组件中:CategoriesComponent、SearchComponent和ProductComponent。ProductService负责与服务器所有基于 HTTP 的通信。BidService包装了与服务器所有基于 WebSocket 的通信。它被注入到ProductDetailComponent中。当用户打开产品详细视图时,如果有新的竞标,将显示新的竞标。当用户放置新的竞标时,BidService将消息推送到服务器。当 WebSocket 服务器推送新的竞标时,BidService接收竞标,并ProductDetailComponent渲染它。图 13.8 展示了两个额外的可注入值:
API_BASE_URL和WS_URL。前者将包含 HTTP 服务器的 URL,后者,WebSocket 服务器的 URL。要注入这些值,您将使用InjectionToken。这两个 URL 都是可配置的,并且它们的值存储在 Angular 项目的 environments/environment.ts 和 environments/environment.prod.ts 文件中。environment.ts 文件用于开发模式,如下所示。
列表 13.10. environment.ts
export const environment = { production: false, apiBaseUrl: 'http://localhost:9090/api', wsUrl: 'ws://localhost:9090' };environment.prod.ts 文件用于生产模式,由于 Angular 应用预计将部署在提供数据的服务器上,因此不需要指定 HTTP 通信的完整 URL,如下面的列表所示。
列表 13.11. environment.prod.ts
export const environment = { production: true, apiBaseUrl: '/api', wsUrl: 'ws://localhost:9090' };13.4.1. 以 dev 模式运行 ngAuction
ngAuction 现在由两个项目组成,因此你需要在每个项目中运行
npm install,然后分别启动服务器和客户端。要启动服务器,切换到服务器目录,通过运行tsc将所有 TypeScript 代码编译成 JavaScript,然后按照以下方式启动服务器:node build/main要启动 Angular 应用,转到客户端目录并运行以下命令:
ng serve你将看到与你在第十一章中创建的 ngAuction 相同的 UI,但现在产品数据和图片通过 HTTP 连接从服务器获取。打开你的 Chrome 浏览器,访问 http://localhost:4200,选择一个产品,并点击竞标按钮。你会看到价格以 $5 的幅度增加。现在打开另一个浏览器(例如 Firefox),访问 http://localhost:4200,选择相同的产品,你会看到最新的价格。在第二个浏览器中提交新的竞标,新的竞标将在两个浏览器中显示。服务器将新的竞标推送到所有已连接的客户端。
在阅读了第十二章中关于同源限制和代理客户端请求的内容后,你可能想知道从端口 4200 加载的应用如何在不配置客户端代理的情况下访问运行在端口 9090 上的 HTTP 服务器。这是因为这次,你在 Node 服务器上使用了特殊的 CORS 包,以实现来自任何客户端的无限制访问。你将在下一节中看到如何做到这一点。
13.4.2. 回顾 ngAuction 服务器代码
到目前为止,你已经知道如何使用 Node.js、Express 和
ws包创建和启动 HTTP 和 WebSocket 服务器,这部分内容将不再重复。在本节中,我们将回顾与 ngAuction 新功能相关的服务器代码片段。你将把服务器代码拆分为四个 TypeScript 文件。图 13.9 展示了本章提供的 ngAuction 服务器目录结构。图 13.9. ngAuction 服务器结构
![]()
在第十一章中的 ngAuction,数据文件夹位于 Angular 项目中;现在,你将数据移动到服务器。在第十一章,读取 products.json 的代码和获取所有产品或按 ID 获取产品的函数位于 product .service.ts 文件中,现在位于 db-auction.ts 文件中。main.ts 文件包含启动 HTTP 和 WebSocket 服务器的代码。ws-auction.ts 文件包含支持与 ngAuction 客户端进行 WebSocket 通信的代码。
启动 HTTP 和 WebSocket 服务器
让我们从用于启动服务器的 main.ts 文件开始代码审查。这个文件中的代码与第 13.2 节中的 simple-websocket-server.ts 类似,但这次你不需要在不同的端口上启动 HTTP 和 WebSocket 服务器——它们都使用端口 9090。另一个区别是,你使用 Node 的一个接口
createServer()创建 HTTP 服务器的实例,正如以下列表所示。列表 13.12. main.ts
import * as express from 'express'; import {createServer} from 'http'; import {createBidServer} from './ws-auction'; import {router} from './rest-auction'; const app = express(); app.use('/api', router); *1* const server = createServer(app); *2* createBidServer(server); *3* server.listen(9090, "localhost", () => { *4* const {address, port} = server.address(); console.log(`Listening on ${address} ${port}`); });-
1 将包含/api 的请求转发到 Express 路由器
-
2 创建 http.Server 对象实例
-
3 使用其 HTTP 对等实例创建 BidServer
-
4 启动两个服务器
这段代码使用 Node 的
createServer()创建 HTTP 服务器的实例,并将 Express 作为回调函数传递以处理所有 HTTP 请求。要启动你的 WebSocket 服务器,你从 ws-auction.ts 中调用createBidServer()函数。但首先,让我们审查你的 RESTful HTTP 服务器。HTTP 服务器
在第十二章中,12.2.2 节,你创建了一个简单的 Node/Express 服务器,用于处理产品请求。在本节中,你将看到这样一个服务器的更高级版本。在这里,你将使用 Express 的
Router来路由 HTTP 请求。你还将使用 CORS 模块来允许所有浏览器忽略同源限制。这就是为什么你可以使用ng serve启动客户端而不需要配置代理。最后,产品数据不会硬编码——你将数据处理部分移动到了 db-auction.ts 脚本中。你的 HTTP REST 服务器在 rest-auction.ts 文件中实现,如下列表所示。
列表 13.13. rest-auction.ts
import * as cors from 'cors'; *1* import * as express from 'express'; import { getAllCategories, getProducts, getProductById, getProductsByCategory } from './db-auction'; *2* export const router = express.Router(); *3* router.use(cors()); *4* router.get('/products', async (req: express.Request, res: express.Response) => { *5* res.json(await getProducts(req.query)); *6* }); router.get('/products/:productId', async (req: express.Request, res: express.Response) => { const productId = parseInt(req.params.productId, 10) || -1; res.json(await getProductById(productId)); }); router.get('/categories', async (_, res: express.Response) => { res.json(await getAllCategories()); }); router.get('/categories/:category', async (req: express.Request, res: express.Response) => { res.json(await getProductsByCategory(req.params.category)); });-
1 导入 CORS 模块
-
2 导入数据处理函数
-
3 创建并导出 Express Router 实例
-
4 使用 CORS 允许所有客户端请求
-
5 使用 async 关键字标记函数为异步
-
6 使用 await 关键字避免在 then()回调中嵌套代码
在第 13.4.1 节中,我们提到客户端的开发服务器将在端口 4200 上运行,而 REST 服务器将在端口 9090 上运行。为了克服同源限制,你使用 Express 包 CORS 来启用所有来源的访问(见
mng.bz/aNxM)。如果你在服务器目录中打开 package.json,你会在其中找到依赖项"cors": "².8.4"。在这个服务器中,你创建了一个 Express
Router对象的实例,并使用它根据提供的路径路由 HTTP GET 请求。注意
async和await关键字的使用。在第十二章的第 12.2.2 节中,您没有使用它们来检索产品,因为产品数据存储在数组中,并且那里的getProducts()等函数是同步的。现在您使用 db-auction.ts 中的数据处理函数,它们从文件中读取数据,这是一个异步操作。使用
async和await关键字可以使异步代码看起来像是同步的(有关更多详细信息,请参阅附录 A 的 A.12.4 节)。数据处理脚本
您的 HTTP 服务器使用 db-auction.ts 脚本进行所有数据处理操作。此脚本包含从 products.json 文件中读取产品以及根据提供的搜索条件搜索产品的函数。我们不会审查 db-auction.ts 脚本的全部代码,但我们将讨论与 ngAuction 第十一章中包含的版本的产品.service.ts 相比所做的代码更改,如下所示。
列表 13.14. db-auction.ts(部分列表)
import * as fs from 'fs'; import * as util from 'util'; type DB = Product[]; *1* const readFile = util.promisify(fs.readFile); *2* const db$: Promise<DB> = *3* readFile('./data/products.json', 'utf8') *4* .then(JSON.parse, console.error); export async function getAllCategories(): Promise<string[]> { const allCategories = (await db$) *5* .map(p => p.categories) .reduce((all, current) => all.concat(current), []); return [...new Set(allCategories)]; *6* } ... export async function updateProductBidAmount(productId: number, *7* price: number): Promise<any> { const products = await db$; const product = products.find(p => p.id === productId); if (product) { product.price = price; } }-
1 定义一个新的类型来存储产品数组
-
2 使 fs.readFile 返回一个 promise
-
3 声明读取 products.json 的 promise
-
4 读取 products.json
-
5 此函数获取每个产品的分类名称。
-
6 移除重复的分类
-
7 此函数根据最新的出价更新产品价格。
在第十一章中,products.json 文件位于客户端,
ProductService使用HttpClient服务读取此文件,如下所示:http.get<Product[]>('/data/products.json');现在这个文件位于服务器上,您使用 Node 的
fs模块来读取它,该模块包含用于处理文件系统的函数(有关更多详细信息,请参阅nodejs.org/api/fs.html)。您还使用另一个 Node 模块util,它包含许多有用的实用工具,并使用util.promisify()将文件读取操作返回的数据作为 promise(请参阅mng.bz/Z009),而不是向fs.readFile提供回调。在 db-auction.ts 的几个地方,您会看到
await db$,这意味着“执行db$promise 并等待其解析或拒绝。”db$promise 知道如何读取 products.json 文件。既然我们已经讨论了您的 RESTful 服务器的工作方式,那么让我们熟悉 WebSocket 服务器的代码。
WebSocket 服务器
ws-auction.ts 脚本实现了您的 WebSocket 服务器,该服务器可以接收用户的出价并通知用户有关新出价的信息。出价由包含产品 ID 和出价金额(价格)的
BidMessage类型表示,如下所示。列表 13.15. ws-auction.ts 中的
BidMessageinterface BidMessage { productId: number; price: number; }createBidServer()函数使用提供的http.Server实例创建BidServer类的实例,如下所示。列表 13.16. ws-auction.ts 中的
createBidServer()export function createBidServer(httpServer: http.Server): BidServer { return new BidServer(httpServer); }BidServer类包含标准的 WebSocket 回调onConnection()、onMessage()、onClose()和onError()。这个类的构造函数创建了一个ws.Server实例(在那里你使用ws包),并将onConnection()回调方法定义为 WebSocket 的connection事件。BidServer类的轮廓如下面的列表所示。列表 13.17.
BidServer的结构export class BidServer { private readonly wsServer: ws.Server; constructor(server: http.Server) {} private onConnection(ws: ws): void {...} *1* private onMessage(message: string): void {...} *2* private onClose(): void {...} *3* private onError(error: Error): void {...} *4* }-
1 WebSocket 连接事件处理器
-
2 消息事件处理器
-
3 关闭事件处理器
-
4 错误事件处理器
现在我们来回顾每个方法的实现,从
constructor()开始。列表 13.18. 构造函数
constructor(server: http.Server) { this.wsServer = new ws.Server({ server }); *1* this.wsServer.on('connection', *2* (userSocket: ws) => this.onConnection(userSocket)); }-
1 使用 HTTP 服务器实例实例化 WebSocket 服务器
-
2 定义连接事件处理器
当你的 ngAuction 客户端连接到
BidServer时,会调用onConnection()回调。这个回调的参数是一个WebSocket对象,代表单个客户端的连接。当客户端发起从 HTTP 协议切换到WebSocket协议的初始请求时,它会调用onConnection()回调,如下面的列表所示。列表 13.19. 处理连接事件
private onConnection(ws: ws): void { ws.on('message', (message: string) => this.onMessage(message)); *1* ws.on('error', (error: Error) => this.onError(error)); *2* ws.on('close', () => this.onClose()); *3* console.log(`Connections count: ${this.wsServer.clients.size}`); *4* }-
1 监听消息事件
-
2 监听错误事件
-
3 监听关闭事件
-
4 报告连接客户端的数量
onConnection()方法为 WebSocket 事件message、close和error分配回调方法。当ws模块创建 WebSocket 服务器实例时,它将连接客户端的引用存储在wsServer.clients属性中。每次连接时,你会在控制台上打印连接客户端的数量。下一个列表将逐个回顾回调方法,从onMessage()开始。列表 13.20. 处理客户端消息
import { updateProductBidAmount} from './db-auction'; ... private onMessage(message: string): void { const bid: BidMessage = JSON.parse(message); *1* updateProductBidAmount(bid.productId, bid.price); *2* // Broadcast the new bid this.wsServer.clients.forEach(ws => ws.send(JSON.stringify(bid))); *3* console.log(`Bid ${bid.price} is placed on product ${bid.productId}`); }-
1 解析客户端的 BidMessage
-
2 更新内存数据库中的竞标金额
-
3 向所有订阅者发送新产品竞标信息
onMessage()回调获取用户对产品的竞标并更新 db-auction.ts 脚本中实现的简单内存数据库中的金额。如果用户打开产品详情视图,他们将成为所有用户竞标通知的订阅者,因此你将新的竞标通过套接字推送到每个订阅者。接下来,我们将回顾
close事件的回调。当用户关闭产品详情视图时,WebSocket 连接也会关闭。在这种情况下,关闭的连接将从wsServer.clients中移除,因此不会向不存在的连接发送竞标通知,如下面的列表所示。列表 13.21. 处理关闭连接
private onClose(): void { console.log(`Connections count: ${this.wsServer.clients.size}`); }在
onError()回调中,你从提供的Error对象中提取错误消息并在控制台上记录错误。列表 13.22. 处理 WebSocket 错误
private onError(error: Error): void { console.error(`WebSocket error: "${error.message}"`); } }图 13.10 显示了在 Chrome 和 Firefox 浏览器中打开的相同产品详细信息视图。用户在任一浏览器中点击投标按钮后,最新投标立即在两个视图中同步。
图 13.10. 两个浏览器中的同步投标
![]()
作业
您的 ngAuction 不是一个生产级别的拍卖,您可能会发现一些边缘情况没有得到妥善处理。我们将描述其中之一,以防您想改进这个应用程序。
假设当前产品的投标金额是$70,Joe 点击投标按钮进行$75 的投标。同时,Mary 也看到了$70 作为最新的产品投标,她也点击了投标按钮。可能存在这样的情况:Joe 的请求将投标金额在服务器上更改为$75,几毫秒后,Mary 的$75 投标到达服务器。目前,
BidServer将只是用 Mary 的$75 替换 Joe 的$75,他们每个人都会假设自己投了$75 的标。为了防止这种情况发生,请修改
BidServer中的代码,以拒绝投标,除非投标金额大于现有金额。在这种情况下,向输掉的用户发送一条包含新最低投标金额的消息。我们已经涵盖了 ngAuction 的服务器端代码;让我们看看与第十一章版本相比客户端发生了什么变化。
13.4.3. ngAuction 客户端代码中的变化
如前所述,ngAuction 的 Angular 代码的主要变化是将包含产品数据和图像的文件以及读取这些文件的代码移到了服务器上。相应地,您在
ProductService中添加了代码,以使用第十二章中介绍的HttpClient服务与 HTTP 服务器进行交互。记住,
ProductComponent负责渲染包含ProductDetailComponent和ProductSuggestionComponent(带有建议产品网格)的产品视图,并且您修改了这两个组件的代码。此外,您添加了
bid.service.ts文件以与 WebSocket 服务通信,并修改了product-detail.component.ts中的代码,以便用户可以对产品进行投标并查看其他用户的投标。让我们回顾与产品视图相关的更改。ProductComponent 中的两个观察者
首先,如果用户查看一个产品的详细信息然后从建议产品网格中选择另一个产品,产品视图会发生变化。路由不会改变(用户仍在查看产品视图),
ProductComponent订阅了来自ActivatedRoute的观察者,该观察者发出新选中的productId并检索相应的产品详细信息,如下所示。列表 13.23. 处理来自
ActivatedRoute的更改参数this.product$ = this.route.paramMap .pipe( map(params => parseInt(params.get('productId') || '', 10)), *1* filter(productId => Boolean(productId)), *2* switchMap(productId => this.productService.getById(productId)) *3* );-
1 获取新的 productId
-
2 处理可能无效的 productId。
-
3 获取所选产品的详细信息
在 列表 13.23 中,您从
ActivatedRoute中检索productId并使用switchMap操作符将其传递给ProductService。filter操作符仅作为预防措施,以排除虚假的产品 ID。例如,用户可以手动输入一个错误的 URL,如 http://localhost:4200/products/A23,您不希望请求不存在的产品详情。产品组件的模板包括
<nga-product-detail>组件,它通过其输入属性product获取选定的产品,如下所示。列表 13.24. 将选定的产品传递给
ProductDetailComponent<nga-product-detail fxFlex="auto" fxFlex.>-md="65%" *ngIf="product$ | async as product" *1* [product]="product"> *2* </nga-product-detail>-
1 使用异步管道提取产品对象
-
2 将选定的 Product 传递给 ProductDetailComponent
您将提取产品数据的代码放在
*ngIf指令内部,因为产品数据是异步检索的,并且您想确保product$可观察对象发出的数据与您绑定到ProductDetailComponent输入属性的属性数据相匹配。让我们看看ProductDetailComponent如何处理接收到的产品。在 ProductDetailComponent 中放置和监控出价
ProductDetailComponent的 UI 如 图 13.10 所示。该组件通过其输入属性product获取要显示的产品。如果用户点击出价按钮,则使用BidService通过 WebSocket 连接发送新的出价(比当前出价高 5 美元),BidService实现了与BidServer的所有通信。如果另一个连接到BidServer的用户对同一产品出价,产品详情视图中的出价金额将立即更新。ProductDetailComponent类具有私有的 RxJS 主题productChange$和可观察的latestBids$,它合并了两个可观察对象的数据:-
productChange$处理用户打开产品详情视图并从“更多考虑的项目”列表中选择另一个产品的情况。当输入参数product的绑定发生变化时,生命周期钩子ngOnChanges()会拦截变化,并productChange$发出产品数据。 -
当
productChange$或BidService推送从服务器接收的新出价时,latestBids$发出新值。
您有两个数据源可以发出值,并且在任何发出时都需要更新视图。这就是为什么您使用 RxJS 操作符
combineLatest将两个可观察对象组合在一起。product-detail.component.ts 文件的代码如下所示。列表 13.25. product-detail.component.ts
// imports are omitted for brevity @Component({ selector: 'nga-product-detail', styleUrls: [ './product-detail.component.scss' ], templateUrl: './product-detail.component.html', changeDetection: ChangeDetectionStrategy.OnPush }) export class ProductDetailComponent implements OnInit, OnChanges { private readonly productChange$ = new Subject<Product>(); latestBids$: Observable<number>; @Input() product: Product; constructor(private bidService: BidService) {} *1* ngOnInit() { this.latestBids$ = combineLatest( *2* this.productChange$.pipe(startWith(this.product)), *3* this.bidService.priceUpdates$.pipe(startWith<BidMessage | null>(null)),*4* (product, bid) => bid && bid.productId === product.id ? *5* bid.price: product.price *6* ); } ngOnChanges({ product }: { product: SimpleChange }) { this.productChange$.next(product.currentValue); *7* } placeBid(price: number) { this.bidService.placeBid(this.product.id, price); *8* } }-
1 注入 BidService
-
2 合并两个可观察对象的值
-
3 第一个可观察对象以当前显示的产品开始发出
-
4 第二个可观察对象发出出价。
-
5 检查到达的出价是否针对当前产品
-
6 如果放置了新的出价,则使用其值;否则,使用产品价格
-
7 发射新选择的产品
-
8 在此产品上放置出价
RxJS 操作符
combineLatest(参见mng.bz/Y28Y)订阅了两个可观察对象发出的值,并在任一可观察对象发出值时调用合并函数。在这种情况下,要么是productChange$发出的值,要么是bidService.priceUpdates$(BidService代码包含在下一节中)。以下是你的合并函数:(product, bid) => bid && bid.productId === product.id ? bid.price: product.price这两个可观察对象发出的值表示为参数
(product, bid),这个函数返回product.price或bid.price,具体取决于哪个可观察对象发出了值。这个值将用于在产品详情视图中进行渲染。因为
combineLatest操作符需要两个可观察对象都发出值以最初调用合并函数,所以你应用了startWith操作符(参见mng.bz/OL9z),以确保在可观察对象开始进行常规发射之前,提供值有一个初始发射。对于初始值,你使用一个可观察对象的product,另一个使用BidMessage或 null。当ProductDetailComponent首次渲染时,可观察对象bidService .priceUpdates$发出 null。你的组合可观察对象在
ngOnInit()生命周期钩子中声明,其值使用async管道在模板中渲染。你是在*ngIf指令内部完成的,这样就不会渲染假值:*ngIf="latestBids$ | async as price"当用户点击投标按钮时,你调用
bidService.placeBid(),该函数内部检查是否需要打开与BidServer的连接或连接已经打开。下一节中的product-detail.component.html展示了如何在模板中实现投标按钮。列表 13.26. 模板中的投标按钮
<button class="info__bid-button" mat-raised-button color="accent" (click)="placeBid(price + 5)"> *1* PLACE BID {{ (price + 5) | currency:'USD':'symbol':'.0' }} *2* </button>-
1 将投标金额定为比最新投标/价格高 $5**
-
2 在按钮上显示下一个投标金额**
现在我们来看看
BidService类如何使用 WebSocket 协议与服务器通信。使用 RxJS 与 WebSocket 服务器通信
在 第 13.3.2 节,我们展示了编写与 WebSocket 服务器通信的客户端代码的一种非常基本的方法。在 ngAuction 中,你将使用 RxJS 包含的更健壮的 WebSocket 服务,这意味着你可以在任何 Angular 项目中使用它。
RxJS 提供了一个基于
Subject的 WebSocket 服务实现,该Subject在 附录 D 中的 D.6 节 中进行了说明。RxJS 的Subject既是观察者又是可观察对象。换句话说,它可以接收和发出数据,这使得它非常适合处理 WebSocket 数据流。RxJS 的WebSocketSubject是标准浏览器WebSocket对象的包装器,位于rxjs/websocket文件中。小贴士
在 RxJS 6 之前,
WebSocketSubject类位于rxjs/observable/dom/WebSocketSubject文件中。在其最简单形式中,
WebSocketSubject可以接受一个包含 WebSocket 端点 URL 的字符串或WebSocketSubjectConfig对象的实例,其中你可以提供额外的配置。当你的代码订阅WebSocketSubject时,它要么使用现有的连接,要么创建一个新的连接。从WebSocketSubject取消订阅如果没有任何其他订阅者监听相同的 WebSocket 端点,则会关闭连接。当服务器将数据推送到 socket 时,
WebSocketSubject会将数据作为可观察值发出。在发生错误的情况下,WebSocketSubject会像任何其他可观察对象一样发出错误。如果服务器推送数据但没有订阅者,值将被缓冲,并在新客户端订阅时立即发出。注意
虽然处理消息的方式在常规 RxJS
Subject和WebSocketSubject之间有所不同。如果你在Subject上调用next(),它会向所有订阅者发出数据,但如果你在WebSocketSubject上调用next(),则不会。记住,在可观察对象和订阅者之间有一个服务器,何时发出值由服务器决定。本章附带 ngAuction 客户端包括 shared/services/bid.service.ts 文件,该文件使用
WebSocketSubject。BidService是一个单例,仅由ProductDetailComponent使用,它使用async管道订阅它。当用户关闭产品详情视图时,组件被销毁,async管道取消订阅,关闭 WebSocket 连接。让我们回顾一下 bid.service.ts 脚本的代码。列表 13.27. bid.service.ts
import { WebSocketSubject } from 'rxjs/websocket'; ... export interface BidMessage { productId: number; price: number; } @Injectable() export class BidService { private _wsSubject: WebSocketSubject<any>; private get wsSubject(): WebSocketSubject<any> { *1* const closed = !this._wsSubject || this._wsSubject.closed; *2* if (closed) { this._wsSubject = new WebSocketSubject(this.wsUrl); *3* } return this._wsSubject; } get priceUpdates$(): Observable<BidMessage> { return this.wsSubject.asObservable(); *4* } constructor(@Inject(WS_URL) private readonly wsUrl: string) {} *5* placeBid(productId: number, price: number): void { this.wsSubject.next(JSON.stringify({ productId, price })); *6* } }-
1 私有属性 _wsSubject 的 getter
-
2 WebSocket-Subject 永远没有被创建或已经断开连接。
-
3 连接到 BidServer
-
4 获取主题的可观察对象的引用
-
5 注入 WebSocket 服务器 URL
-
6 将新出价推送到 BidServer
BidService单例包含priceUpdates$getter,它返回可观察对象。ProductDetailComponent在ngOnInit()中使用此 getter。这意味着priceUpdates$一旦ProductDetailComponent渲染,就会打开 WebSocket 连接(通过this.wsSubjectgetter),并且async管道是该组件模板中的订阅者。BidService还有一个私有属性_wsSubject和内部使用的wsSubjectgetter。当从priceUpdates$第一次访问 getter 时,_wsSubject变量不存在,并且会创建一个新的WebSocketSubject实例,与 WebSocket 服务器建立连接。如果用户离开产品详情视图,连接将被关闭。因为
BidService是一个单例,如果用户关闭并重新打开产品详情视图,BidService的实例不会重新创建,但由于连接状态已关闭 (_wsSubject.closed),它将被重新建立。WebSocket 服务器的 URL(
WS_URL)在开发环境中存储在environment.ts中,在生产环境中存储在environment.prod.ts中。此值通过@Inject指令注入到wsUrl变量中。这完成了 ngAuction 更新代码审查,这些更新实现了 Angular 客户端与两个服务器之间的通信。按照第 13.4.1 节所述运行 ngAuction,ngAuction 将变为可用状态。
摘要
-
WebSocket 协议提供了 HTTP 所不具备的独特功能,这使得它在某些用例中成为更好的选择。客户端和服务器都可以发起通信。
-
WebSocket 协议不使用请求/响应模型。
-
你可以创建一个 Angular 服务,将 WebSocket 事件转换为可观察的流。
-
RxJS 库在
WebSocketSubject类中包含了基于Subject的 WebSocket 支持实现,你可以在任何 Angular 应用中使用它。
第十四章. 测试 Angular 应用程序
本章涵盖
-
使用 Jasmine 框架进行单元测试
-
识别 Angular 测试库中的主要工件
-
测试服务、组件和路由器
-
使用 Karma 测试运行器对 Web 浏览器运行单元测试
-
使用 Protractor 框架进行端到端测试
为了确保您的软件没有错误,您需要对其进行测试。即使您的应用程序今天没有错误,在您修改现有代码或引入新代码后,它可能明天会有错误。即使您没有更改特定模块的代码,它也可能因为另一个模块或运行时环境的变化而无法正常工作。您的应用程序代码需要定期重新测试,并且该过程应该自动化。您应该在开发周期的早期就准备测试脚本并开始运行它们。
本章介绍了针对 Web 应用程序前端的两种主要测试类型:
-
单元测试— 断言一个小块代码接受预期的输入数据并返回预期的结果。单元测试是关于测试独立的代码片段,特别是公共接口。
-
端到端测试— 断言整个应用程序按最终用户期望的方式工作,并且所有应用程序部分都正确地相互交互。
单元测试用于测试小型、独立代码单元的业务逻辑。它们运行得相当快,您将比端到端测试更频繁地运行单元测试。端到端(e2e)测试模拟用户操作(如按钮点击)并检查应用程序是否按预期运行。在端到端测试期间,您不应运行单元测试脚本。
注意
还有一些集成测试,用于检查多个应用程序成员是否可以通信。与单元测试模拟依赖项(例如,HTTP 响应)不同,集成测试使用真实的依赖项。要将单元测试转换为集成测试,不要使用模拟。
我们将从介绍 Jasmine 的单元测试基础开始,然后展示如何使用 Angular 测试库与 Jasmine 结合。之后,您将了解如何使用 Protractor,这个用于端到端测试的库。在章节的末尾,我们将向您展示如何编写和运行端到端脚本以测试 ngAuction 的产品搜索工作流程。
14.1. 单元测试
本书作者在为各种客户的大型项目中担任顾问。相当常见的是,这些项目在没有单元测试的情况下编写。我们将描述一个典型的场景,我们在多次场合都遇到过这种情况。
一个大型应用程序可能经过几年的演变。一些最初编写应用程序的开发者已经离开了。一个新的开发者加入了项目,并需要快速学习代码并跟上进度。
一个新的业务需求出现,新团队成员开始着手处理它。他们在现有的
doSomething()函数中实现了这个需求,但 QA 团队又提出了另一个问题,报告说应用在看似无关的区域出现了问题。经过进一步的研究,很明显,应用出现问题是因为在doSomething()中所做的代码更改。新开发者不了解某个业务条件,无法对此进行解释。如果单元测试(或端到端测试)是用
doSomething()的原始版本编写的,并且作为每个构建的一部分运行,这种情况就不会发生。此外,原始单元测试将作为doSomething()的文档。尽管编写单元测试看起来像是一项额外的、耗时的工作,但从长远来看,它可能会为你节省更多的时间。我们喜欢 Google 工程师 Elliotte Rusty Harold 在他的某次演讲中给出的定义——一个单元测试应该验证一个已知、固定的输入产生一个已知、固定的输出。如果你为一个内部使用其他依赖项的函数提供固定的输入,那么这些依赖项应该被模拟,这样单个单元测试脚本就可以测试一个独立的代码单元。
已经创建了几个专门用于编写单元测试的框架,Angular 文档推荐使用 Jasmine 来实现这一目的(请参阅 Angular 文档,
mng.bz/0nv3)。我们将从 Jasmine 的简要概述开始。14.1.1. 了解 Jasmine
Jasmine (
jasmine.github.io/) 允许你实现一种 行为驱动开发(BDD)过程,该过程建议任何软件单元的测试都应从该单元期望的行为来指定。使用 BDD,你使用自然语言结构来描述你认为代码应该做什么。你以简短句子的形式编写单元测试规范(specs),例如:“StarsComponent 发射评分更改事件。”由于测试的含义很容易理解,因此它们可以作为你的程序文档。如果其他开发者需要熟悉你的代码,他们可以从阅读单元测试代码开始,以了解你的意图。使用自然语言描述测试的另一个优点是:它很容易对测试结果进行推理,如图 14.1 所示。
图 14.1. 使用 Jasmine 的测试运行器运行测试
![图片]()
提示
尽管 Jasmine 自带基于浏览器的测试运行器,但你将使用一个基于命令行的测试运行器 Karma,它可以轻松集成到你的应用的自动化构建过程中。
在行为驱动开发(BDD)框架中,一个测试被称为 规范,一个或多个规范的组合称为 套件。套件是通过
describe()函数定义的——这就是你描述你要测试的内容的地方。套件中的每个规范都编程为一个it()函数,它定义了待测试代码的预期行为以及如何测试它。以下列表显示了一个示例。列表 14.1. 简单的 Jasmine 测试套件
describe('MyCalculator', () => { *1* it('should know how to multiply', () => { *2* // The code that tests multiplication goes here }); it('should not divide by zero', () => { *3* // The code that tests division by zero goes here }); });-
1 套件描述和实现套件的函数
-
2 测试乘法的规范
-
3 测试除法的规范
测试框架有一个 断言 的概念,它是一种询问测试表达式是否为真或假的方式。如果断言返回
false,则框架抛出错误。在 Jasmine 中,断言是通过expect()函数指定的,后跟 匹配器:toBe()、toEqual()等。这就像你正在写一个句子,“我期望 2 加 2 等于 4”:expect(2 + 2).toEqual(4);匹配器实现了实际值和预期值之间的布尔比较。如果匹配器返回
true,则规范通过。如果你期望测试结果不包含某个特定值,只需在匹配器前添加关键字not:expect(2 + 2).not.toEqual(5);注意
你可以在类型定义文件 @types/jasmine/index.d.ts 中找到完整的匹配器列表,该文件位于 node_modules 目录中。Angular 测试库添加了更多匹配器,列在
mng.bz/hx5u。在 Angular 中,测试套件的名称与测试文件下的文件名相同,名称后添加 .spec 后缀。例如,文件 application.spec.ts 包含 application.ts 的测试脚本。图 14.2 显示了一个可以位于 app.component.spec.ts 文件中的最小化测试套件;它断言变量
app是AppComponent的实例。断言是期望加上匹配器。图 14.2. 最小化测试套件
![]()
图 14.2 显示了一个包含单个规范的测试套件。如果你从
describe()和it()中提取文本并将它们放在一起,你会得到一个清楚地表明你在这里测试什么的句子:“ApplicationComponent 成功实例化。”如果其他开发者需要知道你的规范测试了什么,他们可以阅读describe()和it()中的文本。每个测试都应该具有自描述性,以便它可以作为程序文档。提示
虽然图 14.2 中显示的测试是由 Angular CLI 生成的,但它相当无用,因为
AppComponent无法成功实例化的可能性几乎为零。图 14.2 中的代码实例化了
AppComponent并期望表达式app instanceof AppComponent评估为true。从import语句中,你可以猜测这个测试脚本位于与AppComponent相同的目录中。注意
在 Angular 应用程序中,你将每个测试脚本保存在与被测试的组件(或服务)相同的目录中,因此如果你需要在另一个应用程序中重用组件,所有相关文件都位于一起。如果你使用 Angular CLI 生成组件或服务,测试的样板代码(.spec.ts 文件)将在同一目录中生成。
如果你想在每次测试之前执行一些代码(例如准备测试依赖项),你可以在
setup函数beforeAll()和beforeEach()中指定它,它们将分别在套件或每个测试用例之前运行。如果你想在套件或每个测试用例完成后立即执行一些代码,请使用teardown函数afterAll()和afterEach()。让我们看看如何在单元测试 TypeScript 类时应用 Jasmine API。
14.1.2. 为类编写测试脚本
假设你有一个具有一个
counter属性和两个方法的Counter类,这两个方法允许增加或减少该属性的值。列表 14.2. counter.ts
export class Counter { counter = 0; *1* increment() { *2* this.counter++; } decrement() { *3* this.counter--; } }-
1 一个类属性
-
2 一个用于增加值的函数
-
3 一个用于减少值的函数
你想在这里进行单元测试吗?你想要确保
increment()方法将counter的值增加一,而decrement()方法将此值减少一。使用 Jasmine 术语,你想要编写一个包含两个测试用例的测试套件。记住,一个测试用例应该测试一个独立的功能部分,因此每个测试用例都应该创建
Counter类的一个实例,并只调用其一个方法。counter.spec.ts 文件的第一个版本如下所示。列表 14.3. counter.spec.ts
import {Counter} from './counter'; describe("Counter", ()=> { *1* it("should increment the counter by 1", () => { *2* let cnt = new Counter(); *3* cnt.increment(); *4* expect(cnt.counter).toBe(1); *5* }); it("should decrement the counter by 1", () => { let cnt = new Counter(); cnt.decrement(); expect(cnt.counter).toBe(-1); }); });-
1 测试套件声明表示你将测试 Counter。
-
2 首个测试用例检查增加功能是否正常。
-
3 设置阶段创建一个 Counter 的新实例。
-
4 调用被测试的函数
-
5 声明期望、断言和匹配器
你的每个测试用例都有类似的功能。设置阶段创建
Counter类的一个新实例,然后调用要测试的方法,最后使用expect()方法声明期望。在一个测试用例中,你期望counter为1,在另一个中为-1。这个测试套件将工作,但你在这里有一些代码重复:每个测试用例都重复了
Counter的实例化。在你测试脚本的重构版本中,你将删除从测试用例中移除的Counter实例化,并在测试之前执行它。看看以下列表中的新测试版本。这是正确的吗?列表 14.4. 重构后的 counter.spec.ts
import {Counter} from './counter'; describe("Counter", () => { let cnt = new Counter(); *1* it("should increment the counter by 1", () => { cnt.increment(); expect(cnt.counter).toBe(1); } ); it("should decrement the counter by 1", () => { cnt.decrement(); expect(cnt.counter).toBe(-1); } ); });- 1 在测试用例之前实例化 Counter
这个测试是不正确的。你的测试套件将创建一个
Counter的实例,第一个测试用例将counter的值增加到1,正如预期的那样。但当第二个测试用例减少counter时,其值变为0,尽管匹配器期望它为-1。您测试脚本的最终版本,如下一列表所示,通过在 Jasmine 的
beforeEach()函数内部创建Counter实例来修复了这个错误。列表 14.5. counter.spec.ts 的最终版本
import {Counter} from './counter'; describe("Counter", () => { let cnt: Counter;?? beforeEach(() => cnt = new Counter())?; *1* it("should increment the counter by 1", () => { cnt.increment(); expect(cnt.counter).toBe(1); } ); it("should decrement the counter by 1", () => { cnt.decrement(); expect(cnt.counter).toBe(-1); } ); });- 1 在 beforeEach() 中实例化 Counter
现在这个脚本正确地指导 Jasmine 在运行您的套件中的每个 spec 之前创建一个新的
Counter实例。让我们看看如何运行它。14.2. 使用 Karma 运行 Jasmine 脚本
对于不使用 Angular CLI 的项目,您需要执行大量的手动配置才能运行 Jasmine 测试。在没有 Angular CLI 的情况下,您需要按照以下方式安装 Jasmine 及其类型定义文件:
npm install jasmine-core @types/jasmine --save-dev然后您需要创建一个包含脚本标签以加载 Jasmine 和您的规格(TypeScript 代码需要预先编译成 JavaScript)的 test.html 文件。最后,您需要手动在每个您关心的浏览器中加载 test.html 并观察您的测试是否通过或失败。
但从命令行运行单元测试是一个更好的选择,因为这样您可以集成测试到项目构建过程中。这是使用名为 Karma 的命令行测试运行器(见
karma-runner.github.io)的主要原因之一。除了这个好处之外,Karma 还具有多个有用的插件,并且可以与许多 JavaScript 测试库一起使用,以针对所有主要浏览器进行测试。Karma 用于测试使用或未使用框架编写的 JavaScript 代码。Karma 可以运行测试以检查您的应用程序是否在多个浏览器(Chrome、Firefox、Internet Explorer 等)中正常工作。在非 Angular CLI 项目中,您可以按照以下列表安装 Karma 和 Jasmine、Chrome 以及 Firefox 的插件。
列表 14.6. 安装 Karma
npm install karma karma-jasmine --save-dev *1* npm install karma-chrome-launcher --save-dev *2* npm install karma-firefox-launcher --save-dev *3*-
1 安装 Karma 及其 Jasmine 插件
-
2 在 Chrome 中安装插件
-
3 在 Firefox 中安装插件以进行测试
然后您需要为项目准备一个配置文件,即 karma.conf.js,但您被 Angular CLI 宠坏了,因为它为您安装并配置了测试 Angular 应用所需的所有内容,包括 Jasmine 和 Karma。我们使用 Angular CLI 创建了一个新项目,并将上一节中描述的代码添加到测试
Counter类中。您将在 hello-jasmine 目录中找到这个项目。图 14.3 展示了该项目的结构,标记了所有与测试相关的文件和目录。图 14.3. hello-jasmine 项目
![]()
在顶部,您可以看到 e2e 目录,在底部,是用于端到端测试的 protractor.conf.js 文件,这些都是在第 14.4 节中描述的。
counter.spec.ts文件是上一节中描述的手动编写的测试脚本。app.component.spec.ts文件是由 Angular CLI 为测试AppComponent生成的,您将在第 14.3.1 节中看到其内容。生成的 test.ts 文件是主要的测试脚本,它加载所有测试脚本。当你运行
ng test命令时,karma.conf.js 文件被 Karma 运行器使用,该命令编译并运行单元测试。测试编译完成后,ng test使用编译后的脚本 test.js 加载 Angular 测试库和所有 .spec.ts 文件,并启动 Karma 运行器。图 14.4 显示了在 hello-jasmine 项目中运行ng test命令的输出。图 14.4. 在 hello-jasmine 项目中运行
ng test![图片]()
要运行测试,Karma 启动 Chrome 浏览器(Angular CLI 配置的唯一一个),并运行五个成功结束的测试。为什么是五个?你在
counter.spec.ts文件中只写了两个测试,对吧?Angular CLI 还生成了 app.component.spec.ts 文件,其中包含包含三个it()函数定义的测试套件。Karma 执行所有扩展名为 .spec.ts 的文件。注意
Angular CLI 项目包含 karma-jasmine-html-reporter 包,如果你想通过浏览器查看测试结果,请打开 URL http://localhost:9876。
你现在不想从 app.component.spec.ts 运行测试,所以让我们将其关闭。如果你想让测试运行器跳过一些测试,将它们的 spec 函数从
it()重命名为xit()。在这里,x 代表 exclude。如果你想跳过整个测试套件,将describe()重命名为xdescribe()。如果你从 app.component.spec.ts 中排除测试套件,测试将自动重新运行,报告显示两个测试成功运行(你为
Counter编写的那些),三个 spec 被跳过(由 Angular CLI 生成的那些):Chrome 63.0.3239 (Mac OS X 10.11.6): Executed 2 of 5 (skipped 3) SUCCESS (0.03 secs / 0.002 secs)随着 spec 数量的增加,你可能只想执行其中一些以更快地看到结果。将 spec 函数从
it()重命名为fit()(f 代表 force)将只执行这些测试,跳过其余的。你知道如何测试,但为什么还不清楚
假设你知道如何测试
Counter类的方法,但你可能仍然有一个价值百万美元的问题:为什么测试像increment()和decrement()这样简单的函数?它们不是总是能正常工作吗?在现实世界中,事情会发生变化,曾经简单的事情变得不再那么简单。假设
decrement()函数的业务逻辑发生了变化,新的要求是不允许counter小于2。开发者将decrement()代码更改为以下样子。decrement(){ this.counter >2 ? this.counter--: this.counter; }突然,你有两条可能的执行路径:
-
当前计数器的值大于
2。 -
当前计数器的值等于
2。
如果你为
decrement()编写了单元测试,那么下次你运行ng test时,它将失败,如下所示:Chrome 63.0.3239 (Mac OS X 10.11.6) Counter should decrement the counter by 1 FAILED *1* Expected 0 to be -1\. *2* at Object.<anonymous> chapter14/hello-jasmine/ src/app/counter/counter.spec.ts:18:27) ...-
1 文本描述了失败的 spec。
-
2 断言失败,因为被测试的代码没有减少等于零的计数器。
您的单元测试失败是一个好事,因为它告诉您应用程序逻辑中发生了变化——在
decrement()中。现在开发者应该查看发生了什么,并向测试套件中添加另一个 spec,以便您有两个it()块来测试decrement()的两个执行路径,以确保它始终正常工作。在现实世界中,业务需求经常变化,如果开发者在没有为新功能提供单元测试的情况下实现它们,您的应用程序可能会变得不可靠,并且会让您(或生产支持工程师)在夜间无法入睡。
| |
提示
失败测试的输出可能不易阅读,因为它可能包含多行错误堆栈跟踪。考虑使用名为 Wallaby 的持续测试工具(见
wallabyjs.com/docs),它会在您的 IDE 中显示一个简短的错误消息,紧邻失败的 spec 代码。| |
注意
在 第十二章,12.3.6 节中,我们解释了如何通过运行一系列 npm 脚本来自动化构建过程。如果您将
ng test添加到构建命令中,如果任何单元测试失败,构建将被终止。例如,构建脚本可以看起来像这样:"build": "ng test && ng build"。很好,Angular CLI 生成了一个可用的 Karma 配置文件,但有时您可能希望根据项目需求对其进行修改。
14.2.1. Karma 配置文件
当 Angular CLI 生成一个新项目时,它包括预先配置好的 karma.conf.js 文件,用于在 Chrome 浏览器中运行 Jasmine 单元测试。您可以在
mng.bz/82cQ上阅读所有可用的配置选项,但我们将仅突出一些您可能希望在项目中修改的选项。以下列出的是生成的 karma.conf.js 文件。列表 14.7. Angular CLI 生成的 karma.conf.js 文件
module.exports = function (config) { config.set({ basePath: '', frameworks: ['jasmine', '@angular/cli'], plugins: [ require('karma-jasmine'), require('karma-chrome-launcher') *1* require('karma-jasmine-html-reporter'), require('karma-coverage-istanbul-reporter'), *2* require('@angular/cli/plugins/karma') *3* ], client:{ clearContext: false // leave Jasmine Spec Runner // output visible in browser }, coverageIstanbulReporter: { reports: [ 'html', 'lcovonly' ], fixWebpackSourcePaths: true }, angularCli: { environment: 'dev' }, reporters: ['progress', *4* 'kjhtml'], *5* port: 9876, *6* colors: true, logLevel: config.LOG_INFO, autoWatch: true, browsers: ['Chrome'], *7* singleRun: false *8* }); };-
1 包含 Chrome 测试插件
-
2 包含代码覆盖率报告器
-
3 包含 Angular CLI 的 Karma 插件
-
4 在控制台上报告测试进度
-
5 使用 karma-jasmine-html-reporter
-
6 在此端口上运行 HTML 报告器
-
7 列出测试中使用的浏览器
-
8 以监视模式运行
注意
如果您想让 Karma 在控制台打印每个完成的 spec 的消息,请在 package.json 中添加 karma-mocha-reporter 作为
devDependency,将require('karma-mocha-reporter')行添加到 karma.conf.js 中,并用mocha替换progress报告器。如果您在持续集成(CI)服务器上运行测试,请使用可以将测试结果写入 JUnit XML 格式文件的 karma-junit-reporter。此配置文件仅使用 Chrome 插件,但在现实世界的应用程序中,您希望在多个浏览器中运行测试。下一节将向您展示如何将 Firefox 添加到测试中使用的浏览器列表。
Karma 可以使用 Istanbul 报告器来报告你的代码测试覆盖率,你可以运行以下命令来生成覆盖率报告:
ng test --code-coverage这将创建一个名为 coverage 的目录,其中包含一个加载覆盖率报告的 index.html 文件。例如,你的 hello-jasmine 项目包含一个
AppComponent和Counter类,它们完全被单元测试覆盖。生成的报告如图 14.5 所示。图 14.5. hello-jasmine 项目的测试覆盖率报告
![图片 14fig05_alt]()
注意
一些组织对代码覆盖率有严格的规则,例如至少 90% 的代码必须被单元测试覆盖,否则构建将失败。为了强制执行这种覆盖率,安装 npm 包 karma-istanbul-threshold 并将
istanbulThresholdReporter部分添加到 karma.conf.js 中。更多详情请参阅mng.bz/544u。14.2.2. 在多个浏览器中进行测试
通常,开发者不会在多个浏览器中手动测试每个代码更改。Chrome 是开发模式的首选浏览器,当测试人员报告你的应用程序在 Chrome 中运行良好,但在 Safari、Firefox 或 Internet Explorer 中产生错误时,你可能会感到不愉快。为了消除这些惊喜,你应该在所有对用户重要的浏览器中运行单元测试。
幸运的是,使用 Karma 设置起来相当简单。假设你想要 Karma 不仅在 Chrome 中运行测试,还要在 Firefox 中运行(你必须在电脑上安装 Firefox)。首先,安装 karma-firefox-launcher 插件:
npm i karma-firefox-launcher --save-dev然后,在 karma.conf.js 的
plugins部分中添加以下行:require('karma-firefox-launcher'),最后,将 Firefox 添加到 karma.conf.js 中的
browsers列表中,使其看起来如下:browsers: ['Chrome', 'Firefox'],小贴士
如果你需要在 Linux 服务器上设置 CI 环境,你可以安装 Xvfb(一个虚拟显示服务器)或使用 无头浏览器(一个没有 UI 的浏览器)。例如,你可以指定
ChromeHeadless来使用无头 Chrome 浏览器。现在,如果你运行
ng test命令,它将在 Chrome 和 Firefox 中运行测试。为每个你关心的浏览器安装 Karma 插件,这将消除“但在 Chrome 中运行良好!”这样的惊喜。我们已经介绍了编写和运行单元测试的基础。让我们看看如何对 Angular 组件、服务和路由进行单元测试。
14.3. 使用 Angular 测试库
Angular 内置了一个测试库,它包括一些 Jasmine 函数的包装器,并添加了
inject()、async()、fakeAsync()等函数。要测试 Angular 组件,你需要使用
TestBed工具的configureTestingModule()方法创建和配置测试用例的 Angular 模块,这允许你声明模块、组件、提供者等。例如,配置测试模块的语法类似于配置@NgModule(),如下所示。列表 14.8. 为您的应用配置测试模块
beforeEach(async(() => { *1* TestBed.configureTestingModule({ *2* declarations: [ AppComponent *3* ], }).compileComponents(); *4* }));-
1 在每个规范之前异步运行此代码
-
2 配置测试模块
-
3 列出测试组件
-
4 编译组件
beforeEach()函数在测试套件的设置阶段使用。通过它,你可以指定每个测试可能需要的所需模块、组件和提供者。async()函数在 Zone 中运行,可以与异步代码一起使用。async()函数不会完成,直到其所有异步操作都已完成或指定的超时时间已通过。在 Angular 应用中,组件是“神奇地”创建的,服务被注入,但在测试脚本中,你需要显式实例化组件并调用
inject()函数或TestBed.get()函数来注入服务。如果被测试的函数调用了异步函数,你应该将这些函数包裹在async()或fakeAsync()中。async()将在 Zone 中运行被测试的函数(s)。如果你的测试代码使用了超时、观察者或承诺,请将其包裹在async()中,以确保在所有异步函数完成后调用expect()函数。如果不这样做,expect()可能会在异步函数的结果到来之前执行,从而导致测试失败。async()函数等待异步代码完成,这是一个好事。另一方面,这样的等待可能会减慢测试速度,而fakeAsync()函数允许你消除等待。fakeAsync()识别代码中测试的计时器,并用立即执行的函数替换setTimeout()、setInterval()或debounceTime()中的代码,就像它们是同步的,并按顺序执行它们。它还通过tick()和flush()函数提供了更精确的时间控制,这些函数允许你快进时间。你可以选择提供以毫秒为单位的时间值进行快进,因此无需等待,即使异步函数使用了
setTimeout()或Observable.interval()。例如,如果你有一个使用 RxJS 操作符myInputField.valueChanges.debounceTime(500).subscribe()的输入字段,你可以编写tick (499)来快进 499 毫秒,然后断言订阅者没有接收到输入字段中输入的数据。你只能在
fakeAsync()中使用tick()函数。不带参数调用tick()表示你希望随后的代码在所有挂起的异步活动完成后执行。要查看本节中的测试示例,请打开本章附带的项目 unit-testing-samples,运行
npm install,然后运行ng test。让我们看看 Angular 测试库的一些 API,从审查由 Angular CLI 生成的 app.component.spec.ts 文件的代码开始。
14.3.1. 测试组件
组件是带有模板的类。如果一个组件的类包含实现某些应用程序逻辑的方法,您可以像测试任何其他函数一样测试它们。但更常见的是,您会测试 UI,以确保绑定正常工作,并且组件模板显示预期的数据。
在底层,Angular 组件由两部分组成:类的实例和 DOM 元素。技术上,当您为一个组件编写单元测试时,它更像是一个集成测试,因为它必须检查组件类的实例和 DOM 对象是否同步工作。
Angular 测试库提供了
TestBed.createComponent()方法,该方法返回一个ComponentFixture对象,它使您能够访问渲染模板的组件和原生 DOM 对象。要访问组件实例,您可以使用
ComponentFixture.componentInstance属性,要访问 DOM 元素,使用ComponentFixture.nativeElement。如果您想访问固定装置的 API(例如,访问组件的注入器、运行 CSS 查询选择器、查找样式或子节点或触发事件处理器),请使用其DebugElement,如ComponentFixture.debugElement.componentInstance和ComponentFixture.debugElement.nativeElement,分别。图 14.6 说明了ComponentFixture对象的一些属性,这些属性也存在于debugElement中。图 14.6.
ComponentFixture的属性![]()
要更新绑定,您可以通过在固定装置上调用
detectChanges()方法来触发组件上的更改检测周期。更改检测更新 UI 后,您可以运行expect()函数来检查渲染的值。在配置测试模块后,您通常执行以下步骤来测试组件:
1. 调用
TestBed.createComponent()来创建组件。2. 使用对
componentInstance的引用来调用组件的方法。3. 调用
ComponentFixture.detectChanges()来触发更改检测。4. 使用对
nativeElement的引用来访问 DOM 对象并检查它是否具有预期的值。注意
如果您想自动触发更改检测,您可以通过配置测试模块中的
ComponentFixtureAutoDetect服务的提供者来实现。尽管这似乎比手动调用detectChanges()更好,但此服务仅注意异步活动,不会对组件属性的同步更新做出反应。让我们检查生成的
app.component.spec.ts文件的代码,看看它是如何执行这些步骤的。这个由 Angular CLI 生成的脚本声明了一个包含三个规格的测试套件:1. 检查组件实例是否已创建。
2. 检查该组件有一个值为
app的title属性。3. 检查 UI 中是否有文本为“欢迎使用 app!”的
<h1>元素代码如下所示。
列表 14.9. app.component.spec.ts
import { TestBed, async } from '@angular/core/testing'; *1* import { AppComponent } from './app.component'; describe('AppComponent', () => { beforeEach(async(() => { *2* TestBed.configureTestingModule({ *3* declarations: [ AppComponent ], }).compileComponents(); *4* })); it('should create the app', async(() => { const fixture = TestBed.createComponent(AppComponent); *5* const app = fixture.debugElement.componentInstance; *6* expect(app).toBeTruthy(); *7* })); it(`should have as title 'app'`, async(() => { const fixture = TestBed.createComponent(AppComponent); const app = fixture.debugElement.componentInstance; expect(app.title).toEqual('app'); })); it('should render title in a h1 tag', async(() => { const fixture = TestBed.createComponent(AppComponent); fixture.detectChanges(); *8* const compiled = fixture.debugElement.nativeElement; *9* expect(compiled.querySelector('h1').textContent) .toContain('Welcome to app!'); *10* })); });-
1 从 Angular 测试库导入所需的模块
-
2 将组件编译包装成 async()
-
3 在设置阶段,异步地在 Zone 中配置测试模块
-
4 将组件编译成内联样式和模板
-
5 实例化 AppComponent
-
6 获取组件实例的引用
-
7 检查将 app 转换为布尔值的结果为 true
-
8 触发更改检测以更新组件的 DOM 对象
-
9 获取 DOM 对象的引用
-
10 检查 DOM 对象是否包含包含此文本的
元素
注意,实例化组件的函数被包装在
async()中。这是因为组件可以有自己的模板和样式文件,而读取文件是一个异步操作。调用
detectChanges()触发更改检测,更新 DOM 元素上的绑定。在此之后,你可以查询 DOM 元素的内容,以确保 UI 显示预期的值。注意
目前,Angular CLI 通过重复调用
createComponent()生成测试。更好的解决方案是编写另一个beforeEach()函数并在那里创建固定装置。在新创建的项目中运行
ng test将报告所有测试通过。浏览器将在 http://localhost:9876 打开,你将看到图 14.7 中显示的测试报告。图 14.7.
ng test的成功运行![]()
让我们看看如果你将 AppComponent 中的
title属性值从app更改为my app会发生什么。因为ng test在监视模式下运行,测试将自动重新运行,你将在控制台看到关于两个失败的规格的消息,规格列表将看起来像图 14.8(如果你有电子书,失败的规格将以红色显示)。图 14.8. 包含失败的规格列表
![]()
第一个失败的规格消息是“AppComponent 应该有标题‘app’”,第二个消息是“AppComponent 应该在 h1 标签中渲染标题。”这些是在
it()函数中提供的消息。点击任何失败的规格将打开另一个页面,提供更多细节和堆栈跟踪。提示
请记住,如果你的组件使用生命周期钩子,它们不会自动调用。你需要显式调用它们,就像
app.ngOnInit()一样。让我们在下一个列表中添加另一个规格,以确保如果 AppComponent 类中的
title属性发生变化,它将在 UI 中正确渲染。列表 14.10. 更新
title的规格it('should render updated title', async(() => { const fixture = TestBed.createComponent(AppComponent); const app = fixture.debugElement.componentInstance; app.title = 'updated app!'; *1* fixture.detectChanges(); *2* const compiled = fixture.debugElement.nativeElement; expect(compiled.querySelector('h1').textContent) .toContain('Welcome to updated app!'); *3* }));-
1 更新标题属性
-
2 强制更改检测
-
3 检查 UI 是否反映了更新的标题
现在
ng test将运行这个额外的规格,并报告它已成功完成。在本节中,你使用了为AppComponent生成的测试,但你在实践部分将看到另一个测试组件的脚本。一个典型的组件使用服务进行数据处理,你创建模拟服务,返回硬编码(且相同)的值,以便专注于测试组件的功能。组件的规范应该只测试组件;服务应该单独测试。
14.3.2. 测试服务
服务是一个包含一个或多个方法的类,你只对公共方法进行单元测试,这些方法反过来可能调用私有方法。在 Angular 应用中,你在
@Component或@NgModule中指定服务的提供者,以便 Angular 可以正确实例化和注入它们。在测试脚本中,你也在设置阶段在TestBed.configureTestingModule()内声明测试服务的提供者。此外,如果你在 Angular 应用中可以在类构造函数中使用提供者的令牌来注入服务,在测试中,注入方式不同。例如,你可以显式调用
inject()函数。另一种实例化和注入服务的方法是使用TestBed.get()方法,它使用根注入器,如 图 14.9 所示。如果服务提供者在根测试模块中指定,这将有效。图 14.9. 将服务注入测试脚本
![]()
组件级注入器可以使用如下方式:
fixture.debugElement.injector.get(ProductService);让我们通过运行以下 Angular CLI 命令来生成一个产品服务:
ng g s product此命令将生成 product.service.ts 和 product.service.spec.ts 文件。后者将包含以下列表中显示的样板代码。
列表 14.11. product.service.spec.ts
import { TestBed, inject } from '@angular/core/testing'; import { ProductService } from './product.service'; describe('ProductService', () => { beforeEach(() => { TestBed.configureTestingModule({ providers: [ProductService] *1* }); }); it('should be created', inject([ProductService], *2* (service: ProductService) => { expect(service).toBeTruthy();} *3* ) ); });-
1 配置提供者
-
2 注入服务
-
3 实现测试逻辑
如果你需要注入多个服务,
inject()函数将列出 DI 令牌数组,后跟与令牌名称对应的参数列表的函数:inject([ProductService, OtherService], (prodService: ProductService, otherService: OtherService) => {...})当你向
ProductService类添加方法时,你可以像之前测试Counter类中的方法一样测试它们,但你需要考虑一个特殊情况,即当服务依赖于另一个服务,例如HttpClient时。在单元测试期间向服务器发送 HTTP 请求会减慢测试速度。此外,你不想因为服务器故障而导致单元测试失败。记住,单元测试是为了测试独立的代码片段。本章附带代码包括单元测试示例项目和一个名为 readfile 的应用。它包括
ProductService,该服务使用HttpClient读取 data/products.json 文件,如下所示列表。列表 14.12. 在服务中读取 data/products.json
export class ProductService { constructor(private httpClient: HttpClient ) {} *1* getProducts(): Observable<Product[]> { *2* return this.httpClient.get<Product[]>('/data/products.json'); } }-
1 注入 HttpClient
-
2 读取文件
让我们为
getProducts()方法编写一个单元测试。你不希望测试失败,如果有人删除了 data/products.json 文件,因为这并不意味着getProducts()中存在问题。你将使用HttpClientTestingModule中的HttpTestingController来模拟HttpClient。HttpTestingController不会发出 HTTP 请求,但允许你使用硬编码的数据来模拟它。要将硬编码的数据添加到响应体中,你将使用
HttpTestingController.flush()方法,要模拟错误,你将使用HttpTestingController.error(),如下面的列表所示。列表 14.13. product.service.spec.ts
import {TestBed, async} from '@angular/core/testing'; import {HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import {ProductService} from './product.service'; import {Product} from './product'; describe('ProductService', () => { let productService: ProductService; let httpMock: HttpTestingController; beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], *1* providers: [ProductService] }); productService = TestBed.get(ProductService); *2* httpMock = TestBed.get(HttpTestingController); *3* }); it('should successfully get products', async(() => { const productData: Product[] = [{ "id": "0", "title": "First Product", "price": 24.99 }]; *4* productService.getProducts() .subscribe(res => expect(res).toEqual(productData)); *5* let productsRequest = httpMock.expectOne('/data/products.json'); productsRequest.flush(productData); *6* })); it('should return error if request for products failed', async( () => { const errorType = 'CANNOT_LOAD_PRODUCTS' ; *7* productService.getProducts() .subscribe(() => {}, *8* errorResponse => expect(errorResponse.error.type).toEqual(errorType)); *9* let productsRequest = httpMock.expectOne('/data/products.json'); productsRequest.error(new ErrorEvent (errorType)); *10* })); afterEach(() => httpMock.verify()); *11* });-
1 将 HttpClientTestingModule 添加到测试模块
-
2 注入 ProductService
-
3 注入 HttpTestingController
-
4 准备硬编码的产品数据
-
5 订阅响应并断言结果
-
6 将产品数据发送到客户端
-
7 准备错误消息
-
8 不处理产品数据
-
9 断言接收到了预期的错误
-
10 将错误发送到客户端
-
11 断言没有挂起的请求
在第一个规范中,你为单个产品硬编码数据,然后调用
getProducts()并订阅响应。注意
Jasmine 提供了一个
spyOn()函数,可以拦截指定的函数(例如,getProducts()),你可以在其中返回一个包含预期数据的存根对象。但使用这样的存根不会发出 HTTP 请求。因为使用了HttpTestingController,HTTP 请求会被发出并被HttpTestingController拦截,它不会向读取 products.json 发出真实的 HTTP 请求,而是将硬编码的产品数据通过 HTTP 机制发送。你期望
getProducts()方法对 /data/products .json 发出单个请求并返回其模拟,这就是expectOne()的作用。如果没有发出此类请求,或者发出了多个此类请求,规范将失败。使用真实的
HttpClient服务,调用subscribe()方法会导致接收数据或错误,但使用HttpTestingController,订阅者将不会收到任何数据,直到你调用flush()或error()。在这里,你将在响应体中提供硬编码的数据。当 Karma 打开浏览器并显示测试结果时,你可以在“源”标签页中打开 Chrome Dev Tools,找到你的 spec 文件的源代码,并添加断点来调试你的测试代码,就像调试任何 TypeScript 代码一样。特别是,如果你在 列表 14.13 中调用
flush()的行上放置断点,你会看到它在subscribe()中的代码之前被调用。verify()方法测试了所有 HTTP 请求,并且没有挂起的请求。你会在每个规范运行后的清理阶段断言这一点。注意,每个规格中的代码都被包装在
async()函数中。这确保了您的expect()调用将在规格中的所有异步调用完成后进行。提示
您可以在 Angular 测试文档中阅读有关用模拟、存根和间谍替换真实服务的其他技术的信息,请参阅
angular.io/guide/testing。现在我们来看看如何测试路由。
14.3.3. 测试使用路由的组件
如果组件包含路由,您可能想要测试不同的导航功能。例如,您可能想要测试路由是否正确地导航到它应该去的地方,参数是否正确传递到目标组件,以及守卫是否不允许未经授权的用户访问某些路由。
为了测试路由相关的功能,Angular 提供了
RouterTestingModule,它可以拦截导航但不加载目标组件。对于测试,你需要路由配置;你可以使用应用程序中使用的相同配置,或者创建一个仅用于测试的单独配置。如果您的路由配置包含许多组件,后者可能是一个更好的选择。用户可以通过与应用程序交互或直接在浏览器地址栏中输入 URL 来导航应用程序。
Router对象负责在您的应用程序代码中实现的导航,而Location对象表示地址栏中的 URL。这两个对象协同工作。要测试路由是否正确导航您的应用程序,请在您的规格说明中调用
navigate()和navigateByUrl(),如果需要,传递参数。navigate()方法接受一个包含路由和参数的数组作为参数,而navigateByUrl()接受一个表示您想要导航到的 URL 段的字符串。如果您使用
navigate(),您指定配置的路径和路由参数(如果有的话)。如果路由配置正确,它应该更新浏览器地址栏中的 URL。为了说明这一点,您将重用 第三章 中一个应用程序的代码,但您将添加规格文件。在该应用程序中,AppComponent的路由配置包括路径 /product/:id,如下面的列表所示。列表 14.14. app.routing.ts 的一部分
export const routes: Routes = [ {path: '', component: HomeComponent}, *1* {path: 'product/:id', component: ProductDetailComponent} *2* ];-
1 默认路由
-
2 带有参数的路由
当用户点击产品详情链接时,应用程序将导航到
ProductDetailComponent,如下面的列表所示。列表 14.15. app.component.ts
@Component({ selector: 'app-root', template: ` <a [routerLink]="['/']">Home</a> <a id="product" [routerLink]="['/product', productId]"> *1* Product Detail</a> <router-outlet></router-outlet> ` }) export class AppComponent { productId = 1234; *2* }-
1 导航到产品详情视图的链接
-
2 要传递给产品详情视图的值
在
app.component.spec.ts文件中,您将测试当用户点击产品详情链接时,URL 包含段 /product/1234。您将通过使用TestBed.get()API 注入Router和Location对象。为了模拟点击产品详情链接,您需要获取相应的 DOM 对象,这可以通过使用By.css()API 实现。实用类By有css()方法,它使用提供的 CSS 选择器匹配元素。因为您的应用程序组件有两个链接,所以您将id=product分配给产品详情链接,这样您就可以通过调用By.css('#product')来获取它。为了模拟点击链接,你使用
triggerEventHandler()方法并传入两个参数。第一个参数的值为click,代表点击事件。第二个参数的值为{button: 0},代表事件对象。RouterLink指令期望的值应包含表示鼠标按钮的button属性,其中零表示左鼠标按钮,如下所示。列表 14.16. app.component.spec.ts
// imports omitted for brevity describe('AppComponent', () => { let fixture; let router: Router; let location: Location; beforeEach(async(() => { TestBed.configureTestingModule({ imports: [RouterTestingModule.withRoutes(routes)], *1* declarations: [ AppComponent, ProductDetailComponent, HomeComponent ]}).compileComponents(); })); beforeEach(fakeAsync(() => { router = TestBed.get(Router); *2* location = TestBed.get(Location); *3* fixture = TestBed.createComponent(AppComponent); router.navigateByUrl('/'); tick(); fixture.detectChanges(); *4* })); it('can navigate and pass params to the product detail view', fakeAsync(() => { const productLink = fixture.debugElement.query(By.css('#product')); *5* productLink.triggerEventHandler('click', {button: 0}); *6* tick(); fixture.detectChanges(); expect(location.path()).toEqual('/product/1234'); *7* })); });-
1 加载路由配置
-
2 注入 Router 对象
-
3 注入 Location 对象
-
4 触发变更检测
-
5 获取产品详情链接的访问权限
-
6 点击链接
-
7 检查断言
fakeAsync()函数包装导航代码(异步操作),而tick()函数确保在您运行断言之前异步导航完成。图 14.10 展示了前面脚本执行的动作序列。
图 14.10. 测试导航步骤
![]()
ng test命令将在单元测试样本项目中运行所有单元测试,该项目包含三个应用程序。所有八个规范都应该成功完成。第八个规范将报告“AppComponent 可以导航并将参数传递到产品详情视图”。注意
要将单元测试集成到您的自动化构建过程中,请通过在 12.3.6 节中描述的 npm 构建脚本中添加
&& ng test来将ng test命令集成到构建过程中。第十二章。单元测试在路由守卫中实现的功能是另一个实际用例。在第四章(kindle_split_013.xhtml#ch04)中,我们介绍了
CanActivate、CanDeactivate和Resolve等守卫。因为守卫是服务,您可以像前节中解释的那样单独测试它们。这就结束了我们对单元测试基础知识的介绍。单元测试断言您的 Angular 应用程序中的每个工件在独立情况下按预期工作。但您如何确保几个组件、服务和其它工件在没有手动测试每个工作流的情况下能够良好协作呢?
14.4. 使用 Protractor 进行端到端测试
端到端(E2E)测试是通过模拟用户与应用的交互来测试整个应用工作流程。例如,下订单的过程可能涉及多个组件和服务。您可以创建一个端到端测试来确保此工作流程按预期行为。此外,如果在单元测试中您正在模拟依赖项,端到端测试将使用真实的依赖项。
要手动测试特定的工作流程,例如登录功能,质量保证工程师准备一个有效的 ID/密码,打开登录页面,输入 ID/密码,然后点击登录按钮。之后,质量保证工程师希望断言您的应用着陆页已成功渲染。测试人员还可以运行另一个测试以确保如果输入错误的 ID/密码,着陆页不会渲染。这是登录工作流程端到端测试的手动方式。
Protractor 是一个测试库,允许您在不手动执行的情况下模拟用户操作来测试应用工作流程。您仍然需要准备测试数据和编写测试逻辑,但测试将在没有人工交互的情况下运行。
默认情况下,Protractor 使用 Jasmine 语法进行测试,除非您手动配置另一个支持的框架(见
mng.bz/d64d)。因此,您的端到端测试脚本将使用已经熟悉的describe()和it()块以及额外的 API。14.4.1. Protractor 基础
在手动测试应用工作流程时,用户通过输入数据、选择选项和点击按钮来“驾驶”网络浏览器。Protractor 基于 Selenium WebDriver(见
www.seleniumhq.org/docs/03_webdriver.jsp),它可以根据提供的脚本自动驱动浏览器。Protractor 还包括用于定位 UI 元素的 Angular 特定 API。在您的设置中,Protractor 将在同一台机器上运行网络浏览器和测试,因此您需要为要在其中运行测试的浏览器安装 Selenium WebDriver。另一种选择是为测试设置一台单独的机器并在那里运行 Selenium Server。Selenium 为不同的编程语言提供了 WebDriver 的实现,Protractor 使用的是 WebDriverJS。
当你使用 Angular CLI 生成一个新项目时,它包括 Protractor 及其配置文件以及包含示例测试脚本的 e2e 目录。在 Angular 6 之前,e2e 目录包含三个文件,如图 14.11 所示。figure 14.11。从 Angular 6 开始,生成的 e2e 目录包括配置文件 protractor .conf.js。
图 14.11. Angular CLI 生成的 E2E 代码
![]()
提示
从 Angular CLI 6 开始,当你生成一个新项目时,它包括两个应用:一个是您的应用项目,另一个应用包含基本的端到端测试。
你通过输入
ng e2e命令来运行 E2E 测试,该命令根据 protractor.conf.js 文件中的配置加载测试脚本。该文件默认假设所有 E2E 测试脚本都位于 e2e 目录中,并且应用程序必须在 Chrome 中启动。列表 14.17. protractor.conf.js 的一个片段
specs: [ './e2e/**/*.e2e-spec.ts' *1* ], capabilities: { 'browserName': 'chrome' *2* }, directConnect: true *3*-
1 测试脚本的位置
-
2 在哪个浏览器中运行你的应用程序
-
3 直接连接到浏览器而不通过服务器
ng e2e命令构建应用程序包,启动 Node 实例,并加载测试脚本、Protractor 和 Selenium WebDriver。Protractor 在浏览器(s)中启动你的应用程序,并且你的测试脚本通过 Protractor 和 WebDriverJS 的 API 与浏览器进行通信。图 14.12 展示了本章示例中使用的 E2E 测试玩家。图 14.12. 由 Angular CLI 生成的 E2E 代码
![]()
在运行测试脚本之前,Protractor 将浏览器特定的驱动程序(例如,ChromeDriver)解压缩到 node_modules/webdriver-manager/selenium 文件夹中,以便 Selenium WebDriver 可以正确地与浏览器通信。在测试期间,Protractor 将启动浏览器,测试完成后,Protractor 将关闭它。
Protractor 可以使用在不同单元测试框架中创建的脚本(Jasmine 是默认的一个),并且每个框架可能都有不同的 API 用于定位和表示页面元素。为了避免你在决定切换到另一个单元测试框架时更改 E2E 脚本,Protractor 提供了一个 API(见 www.protractortest.org/#/api),它可以与所有支持的框架一起工作:
-
browser提供了一个 API 来控制浏览器,例如getCurrentUrl()、wait()等。 -
by是一个定位器,用于通过 ID、CSS、按钮或链接文本等方式在 Angular 应用程序中查找元素。 -
element提供了一个 API,用于在网页上查找和操作单个元素。 -
element.all用于查找和操作元素集合,例如,遍历 HTML 列表或表格的元素。
Tip
$("selector")是element(by.css("selector"))的别名,而$$("selector")是element.all(by.css("selector"))的别名。虽然在 Angular 应用程序中你可以使用结构化指令
*ngFor来渲染一组 UI 元素,但在测试中你应该使用element.all来引用和查找集合中的元素。Tip
虽然 Protractor 定义了自己的 API,但它也暴露了 WebDriver API,例如
browser.takeScreenshot()。E2E 测试在浏览器中加载真实的应用程序,定位页面上的元素,并且可以程序化地点击按钮和链接,用数据填写表单,将它们提交到服务器,然后再定位结果页面上的元素以确保它们具有预期的内容。你可以使用以下方法之一编写 E2E 测试:
-
在同一脚本中,使用它们的 ID 或 CSS 类定位 DOM 元素,并断言应用程序逻辑是否正确。ID 或 CSS 类可能会随时间变化,因此如果您有多个脚本测试同一页面,您需要相应地更新每个脚本。
-
通过在一个文件中编写期望和断言,在另一个文件中编写与 UI 元素交互并调用应用程序 API 的代码,实现 页面对象 设计模式(见
martinfowler.com/bliki/PageObject.html)。页面对象可以实现与整个页面或其部分(例如,工具栏)的 UI 交互,并且可以被多个测试重用。如果 HTML 元素的 CSS 发生变化,您需要修改单个页面对象脚本。
使用第一种方法编写的测试难以阅读,因为它们没有提供理解页面实现了哪些工作流程的简单方法。您将使用第二种方法,其中所有 UI 交互都在页面对象(.po.ts 文件)中实现,而断言的规范在脚本(.e2e-spec.ts 文件)中。这种方法减少了代码重复,因为如果多个规范需要访问相同的 HTML 元素,您不需要复制粘贴元素定位器。页面对象可以作为模拟重要工作流程(如
login()或getProducts())的单一位置,而不是将这些活动分散在测试中。让我们看看 Angular CLI 为新项目生成的端到端测试。
14.4.2. Angular CLI 生成的测试
当您使用 Angular CLI 生成新项目时,它创建一个名为 e2e 的目录,该目录包含三个文件:
-
app.po.ts—
AppComponent的页面对象 -
app.e2e-spec.ts— 生成的
AppComponent的端到端测试 -
tsconfig.e2e.json— TypeScript 编译器选项
app.po.ts 文件包含一个简单的
AppPage类,其中只有两个方法,如 列表 14.18 所示。第一个方法包含导航到组件根页面的代码,第二个方法包含通过 CSS 定位 HTML 元素并获取其文本的代码。这个页面对象是唯一包含通过 CSS 定位元素的代码的地方。列表 14.18. 生成的 app.po.ts 文件
import {browser, by, element} from 'protractor'; export class AppPage { navigateTo() { return browser.get('/'); *1* } getParagraphText() { return element(by.css('app-root h1')).getText(); *2* } }-
1 导航到默认路由
-
2 从
元素获取文本
app.e2e-spec.ts 文件的代码如 列表 14.19 所示。这个测试看起来与上一节中显示的单元测试非常相似。请注意,此文件不包括直接与 HTML 页面交互的代码;它使用页面对象的 API。
列表 14.19. 生成的 app.e2e-spec.ts 文件
import {AppPage} from './app.po'; describe('e2e-testing-samples App', () => { let page: AppPage; beforeEach(() => { page = new AppPage(); *1* }); it('should display welcome message', () => { page.navigateTo(); *2* expect(page.getParagraphText()).toEqual('Welcome to app!'); *3* }); });-
1 创建页面实例
-
2 导航到默认路由
-
3 断言 getParagraphText() 返回的文本是正确的
因为 app.e2e-spec.ts 不包含任何元素定位器,所以测试逻辑很容易理解:您导航到着陆页面并检索段落的内容。您可以使用命令
ng e2e运行前面的 E2E 测试。注意
E2E 测试的运行速度比单元测试慢,您不希望在每次保存文件时都运行它们,就像上一节中使用的
ng test一样。此外,您可能不想为每个工作流程创建 E2E 测试,而是识别最重要的测试并只为它们运行测试。现在您已经看到了生成的测试是如何工作的,您可以编写自己的 E2E 测试。
14.4.3. 测试登录页面
上一节中的 E2E 测试没有包含需要数据输入和导航的工作流程。在本节中,您将为使用表单和路由的应用程序编写测试。本章附带的项目中包含一个名为 e2e-testing-samples 的项目,其中有一个简单的应用程序,具有登录页面和主页。此应用程序的路由配置如下所示。
列表 14.20. 路由配置
[{path: '', redirectTo: 'login', pathMatch: 'full'}, *1* {path: 'login', component: LoginComponent}, *2* {path: 'home', component: HomeComponent}] *3*-
1 将基本 URL 重定向到登录页面
-
2 渲染登录组件
-
3 渲染主页组件
HomeComponent的模板只有一行:<h1>Home Component</h1>以下列表中的登录组件包含一个登录按钮和一个包含两个输入 ID 和密码字段的表单。如果用户将
Joe作为 ID 和password作为密码输入,则您的应用程序将导航到主页;否则,它将停留在登录页面并显示消息“无效的 ID 或密码”。列表 14.21. login.component.ts
@Component({ selector: 'app-home', template: `<h1 class="home">Login Component</h1> <form #f="ngForm" (ngSubmit)="login(f.value)"> *1* ID: <input name="id" ngModel/><br> PWD: <input type="password" name="pwd" ngModel=""/><br> <button type="submit">Login</button> <span id="errMessage" *ngIf="wrongCredentials">Invalid ID or password</span> *2* </form> ` }) export class LoginComponent { wrongCredentials = false; constructor(private router: Router) {} *3* login(formValue) { if ('Joe' === formValue.id && 'password' === formValue.pwd) { this.router.navigate(['/home']); *4* this.wrongCredentials = false; } else { this.router.navigate(['/login']); *5* this.wrongCredentials = true; } } }-
1 登录表单
-
2 无效登录消息
-
3 路由注入
-
4 导航到主页
-
5 导航到登录页面
您的测试位于 e2e 目录中,包括两个页面对象,login.po.ts 和 home.po.ts,以及一个规范,login.e2e-spec.ts。主页面的页面对象包含一个返回头部文本的方法。以下列表显示了 home.po.ts。
列表 14.22. home.po.ts
import {by, element} from 'protractor'; export class HomePage { getHeaderText() { return element(by.css('h1')).getText(); } }登录页面对象使用定位器来获取表单字段和按钮的引用。
login()方法模拟用户操作:输入 ID 和密码并点击登录按钮。navigateToLogin()方法指示浏览器访问配置到登录组件的 URL——例如,http://localhost:4200/login。getErrorMessage()方法返回页面可能存在或不存在登录错误消息。以下列表显示了 login.po.ts。列表 14.23. login.po.ts
import {browser, by, element, $} from 'protractor'; export class LoginPage { id = $('input[name="id"]'); *1* pwd = $('input[name="pwd"]'); *1* submit = element(by.buttonText('Login')); *1* errMessage = element(by.id('errMessage')); *1* login(id: string, password: string): void { this.id.sendKeys(id); *2* this.pwd.sendKeys(password); *2* this.submit.click(); *3* } navigateToLogin() { return browser.get('/login'); *4* } getErrorMessage() { return this.errMessage; *5* } }-
1 使用 $ 作为 element(by.css()) 的别名定位页面元素
-
2 输入提供的 ID 和密码
-
3 点击登录按钮
-
4 导航到登录页面
-
5 返回登录错误消息
此页面对象使登录过程易于理解。
sendKey()方法用于模拟数据输入,而click()模拟按钮点击。现在我们来回顾登录工作流程的测试套件。它实例化了登录页面对象,并包含两个规范:一个用于测试成功的登录,另一个用于失败的登录。
第一个规范指示 Protractor 导航到登录页面并使用硬编码的数据
Joe和password登录用户。如果登录成功,应用将导航到主页,你通过检查浏览器中的 URL 是否包含 /home 来断言这一点。你还断言渲染的页面包含标题“主页组件。”失败登录的规范断言应用停留在登录页面,并显示错误消息。注意以下列表中,此脚本没有直接与 UI 交互的代码。
列表 14.24. login.e2e-spec.ts
import {LoginPage} from './login.po'; import {HomePage} from './home.po'; import {browser} from 'protractor'; describe('Login page', () => { let loginPage: LoginPage; let homePage: HomePage; beforeEach(() => { loginPage = new LoginPage(); *1* }); it('should navigate to login page and log in', () => { *2* loginPage.navigateToLogin(); *3* loginPage.login('Joe', 'password'); *4* const url = browser.getCurrentUrl(); *5* expect(url).toContain('/home'); *6* homePage = new HomePage(); *7* expect(homePage.getHeaderText()).toEqual('Home Component'); *8* }); it('should stay on login page if wrong credentials entered', () => { *9* loginPage.navigateToLogin(); loginPage.login('Joe', 'wrongpassword'); *10* const url = browser.getCurrentUrl(); expect(url).toContain('/login'); *11* expect(loginPage.getErrorMessage().isPresent()).toBe(true); *12* }); });-
1 实例化登录页面对象
-
2 成功登录的规范
-
3 导航到登录页面
-
4 使用正确的凭据登录
-
5 获取浏览器的 URL
-
6 断言 URL 包含 /home
-
7 实例化主页对象
-
8 断言页面标题正确
-
9 失败登录的规范
-
10 执行失败的登录
-
11 断言应用仍然显示登录页面
-
12 断言错误消息显示
LoginComponent使用*ngIf结构性指令有条件地显示或隐藏登录错误消息,你的失败的登录规范断言错误消息出现在页面上。有时在执行断言之前需要等待某些操作完成。例如,你的页面对象中的
login()方法以按钮点击结束,成功的登录规范包含断言 URL 包含 /home。这个断言始终为真,因为你的登录过程几乎瞬间完成,因为它没有连接到认证服务器来检查用户凭据。在现实世界中,认证可能需要几秒钟,而针对 /home 的断言可能会在 URL 变为 /home 之前运行,导致测试失败。
在这些情况下,你可以调用
browser.wait()命令,其中你可以指定要等待的条件。在实战部分,你将编写一个测试,点击搜索按钮,该按钮会为产品发起一个 HTTP 请求,这需要一些时间来完成。在那里,你将使用一个辅助函数,在执行断言之前等待 URL 发生变化。使用
ng e2e命令运行此测试,你会看到 Protractor 如何短暂地打开 Chrome 浏览器,填写表单,并点击登录按钮。终端窗口显示输出,你可以在图 14.13 中看到。图 14.13. 运行登录应用的端到端测试
![]()
你的端到端测试中的两个规范都通过了。如果你想看到测试失败,请从
HomeComponent的模板中删除<h1>标签,或者在LoginComponent中将有效的凭据修改为除Joe和password之外的其他任何内容。更改LoginComponent模板中表单字段的名称也会导致测试失败,因为 WebDriver 定位器无法在登录页面上找到这些元素。本章提供了足够的材料,帮助你开始对 Angular 应用进行单元和端到端测试。Jasmine 和(尤其是)Protractor 都提供了更多可以在测试中使用的 API。要获取更详细的覆盖范围,请查看书籍 Testing Angular Applications(Jesse Palmer 等著,Manning,2018),详细信息请见 www.manning.com/books/testing-angular-applications。
在端到端测试中使用 async 和 await
Protractor 使用 WebDriverJS。它的 API 完全是异步的,并且它的函数返回承诺。所有异步操作(例如,
sendKey()和click())都通过 WebDriver 承诺管理器放入待处理的承诺队列中,称为 控制流队列,以确保断言(如expect()函数)在异步操作之后运行。由于 WebDriver 承诺管理器不会立即执行异步函数,而是将其放入队列中,因此很难调试此代码。这就是为什么 WebDriver 的承诺管理器正在被弃用,你可以使用
async和await关键字来确保流程正确同步(有关详细信息,请参阅mng.bz/f72u)。例如,以下代码声明了一个
login()方法。async login(id: string, password: string) { *1* await this.id.sendKeys(id); *2* await this.pwd.sendKeys(password); *2* await this.submit.click(); *2* }-
1 声明该函数返回一个承诺
-
2 等待承诺解决或拒绝
你不能与 WebDriver 的承诺管理器一起使用
async/await关键字,因此你需要通过在 protractor.conf.js 中添加以下选项来关闭它:SELENIUM_PROMISE_MANAGER: false如果你熟悉了 Protractor 和 Selenium 生态系统的组合,并希望为你的应用找到更简单的端到端测试解决方案,请查看 Cypress 框架,可在
www.cypress.io找到。它是一个新但非常有前途的框架。同时,让我们向 ngAuction 添加一些 Protractor 端到端测试。14.5. 实践:向 ngAuction 添加端到端测试
本练习的目标是为 ngAuction 应用添加一个端到端测试,你可以在本章提供的源代码中的 ng-auction 文件夹中找到它。我们从第十三章(kindle_split_022.xhtml#ch13)中提取了 ngAuction 项目,并添加了产品搜索工作流程的端到端测试。此测试将使用 $10 到 $100 的价格范围来断言从服务器检索到的匹配产品在浏览器中渲染。
注意
本章的源代码可以在
github.com/Farata/angulartypescript和www.manning.com/books/angular-development-with-typescript-second-edition找到。在运行此端到端测试之前,您需要在服务器目录中运行
npm install,使用tsc命令编译代码,并通过运行以下命令启动服务器:node build/main现在,您已准备好审查和运行位于 ngAuction 客户端目录中的测试。
14.5.1. 产品搜索工作流程的端到端测试
要执行产品搜索,实际用户需要完成以下步骤:
1. 打开 ngAuction 的着陆页。
2. 点击左上角的搜索按钮,以便搜索面板显示出来。
3. 输入产品的搜索条件。
4. 点击搜索按钮以查看搜索结果。
5. 浏览符合搜索条件的商品。
您的端到端测试将包括位于 e2e 目录中的两个文件:
search.po.ts文件中的页面对象和search.e2e-spec.ts文件中的测试套件。所有断言都将编程在search.e2e-spec.ts文件中,但页面对象将实现以下逻辑步骤:1. 找到搜索按钮并点击它。
2. 使用数据填写搜索表单。
3. 点击搜索按钮。
4. 等待服务器返回并在浏览器中渲染产品。
5. 检查浏览器是否渲染了产品。
为了确保您的搜索会返回一些产品,您的测试将使用从$10 到$100 的广泛价格范围作为搜索条件。
在几种情况下,您将检查浏览器 URL 是否如您预期的那样,因此我们将提醒您在 ngAuction 的
home.module.ts中如何配置路由,如下列所示。列表 14.25. 来自 home 模块的路由配置
[ {path: '', pathMatch: 'full', redirectTo: 'categories'}, {path: 'search', component: SearchComponent}, {path: 'categories', children: [ { path: '', pathMatch: 'full', redirectTo: 'all'}, { path: ':category', component: CategoriesComponent}, ] } ]让我们先识别将参与我们测试的 HTML 元素。
app.component.html文件包含以下列表中的搜索按钮标记。列表 14.26. 工具栏上的搜索按钮
<button mat-icon-button id="search" *1* class="toolbar__icon-button" (click)="sidenav.toggle()"> <mat-icon>search</mat-icon> </button>- 1 添加的 ID 简化了定位此按钮的代码。
您的页面对象将包含以下列表中的行以定位按钮并点击它。
列表 14.27.
SearchPage类的开始export class SearchPage { performSearch(minimalPrice: number, maximumPrice: number) { *1* const searchOnToolbar = element(by.id('search')); *2* searchOnToolbar.click(); *3* ... } }-
1 一种按价格范围搜索产品的搜索方法
-
2 定位搜索按钮
-
3 点击按钮以显示搜索表单
点击按钮后,搜索表单会显示,您会找到最小和最大价格字段并填写提供的价格,如下列所示。
列表 14.28. 输入搜索条件
const minPrice = $('input[formControlName="minPrice"]'); *1* const maxPrice = $('input[formControlName="maxPrice"]'); *1* minPrice.sendKeys(minimalPrice); *2* maxPrice.sendKeys(maximumPrice); *2*-
1 定位价格表单字段
-
2 填写一些表单字段
如果用户手动这样做,搜索表单将看起来像图 14.14。
图 14.14. 带有搜索条件的表单
![图片]()
现在搜索条件已经输入,你需要定位并点击表单的搜索按钮以执行产品搜索。如果你运行 ngAuction,输入最小和最大价格为$10 和$100,然后点击搜索按钮,结果视图将显示产品,浏览器 URL 将看起来像这样:http://localhost:4200/search?minPrice=10&maxPrice=100。
但在 HTTP 请求完成并 URL 改变之前需要一点时间。真实用户会耐心等待直到搜索结果出现。但在你的测试脚本中,如果你尝试在按钮点击后立即断言 URL 包含搜索段,这个断言可能对也可能不对,这取决于你的服务器响应速度有多快。
你不需要担心从第 14.4.3 节的
login.po.ts登录延迟,因为那里没有发起服务器请求,URL 也瞬间改变。这次,你希望在从performSearch()方法返回之前等待 URL 改变。你将使用
ExpectedConditions类,其中你可以定义要等待的条件。然后,通过调用browser.wait(),你可以等待预期条件变为真——否则,测试将因超时而失败。以下代码示例定位并点击搜索按钮,然后等待 URL 改变以包含/search 段。列表 14.29. 点击表单的搜索按钮
const searchOnForm = element(by.buttonText('SEARCH')); *1* searchOnForm.click(); *2* const EC = protractor.ExpectedConditions; *3* const urlChanged = EC.urlContains('/search'); *4* browser.wait(urlChanged, 5000, *5* 'The URL should contain /search'); *6*-
1 定位搜索按钮
-
2 点击搜索按钮
-
3 声明预期条件的常量
-
4 使用 urlContains() API 检查预期条件
-
5 等待预期条件最多 5 秒或失败
-
6 超时时的显示消息
此代码等待最多 5 秒钟,如果 URL 没有改变,则失败,并打印出图 14.15 中显示的消息。你可能需要根据你电脑上产品搜索的速度来增加超时值。
图 14.15. 测试在超时失败。
![图片]()
如果用户手动搜索了价格在$10 到$100 之间的产品,结果视图可能看起来像图 14.16。
图 14.16. 搜索结果视图
![图片]()
如果由测试脚本启动的搜索操作返回产品,你将提取第一个产品的价格,以便稍后(在规范中)你可以断言产品价格符合搜索条件。因为搜索可能返回一系列产品,所以你会使用
element.allAPI 的别名$$来访问它们。每个产品都有如图 14.17 所示的
tile__price-tag样式,该图是从 Chrome Dev Tools 面板的 Element 选项卡中获取的,当时产品网格正在显示。你将使用tile__price-tag样式来定位产品。图 14.17. 价格的 CSS 选择器
![图片]()
当提取产品价格时,您需要将其转换为数字。在 ngAuction 中,产品价格以包含美元符号的字符串形式呈现,例如图 14.17 中的“$70”。但您需要其数值表示,以便规范可以断言价格在指定的范围内。
getFirstProductPrice()方法包含从字符串中删除美元符号并将其转换为整数值的代码,如下一列表所示。列表 14.30. 获取第一个产品的价格
getFirstProductPrice() { return $$('span[class="tile__price-tag"]') *1* .first().getText() *2* .then((value) => { *3* return parseInt(value.replace('$', ''), 10); *4* }); }-
1 使用 element.all 查找产品
-
2 获取第一个产品的文本
-
3 Protractor 的 API 返回 promises,因此应用 then()
-
4 将产品价格转换为数字并返回
您的页面对象的完整代码如下所示。
列表 14.31. search.po.ts
import {protractor, browser, by, element, $, $$} from 'protractor'; export class SearchPage { performSearch(minimalPrice: number, maximumPrice: number) { const searchOnToolbar = element(by.id('search')); searchOnToolbar.click(); *1* const minPrice = $('input[formControlName="minPrice"]'); const maxPrice = $('input[formControlName="maxPrice"]'); minPrice.sendKeys(minimalPrice); *2* maxPrice.sendKeys(maximumPrice); *2* const searchOnForm = element(by.buttonText('SEARCH')); searchOnForm.click(); *3* const EC = protractor.ExpectedConditions; const urlChanged = EC.urlContains('/search'); *4* browser.wait(urlChanged, 5000, 'The URL should contain "/search"'); *5* } navigateToLandingPage() { *6* return browser.get('/'); } getFirstProductPrice() { return $$('span[class="tile__price-tag"]') *7* .first().getText() *8* .then((value) => {return parseInt(value.replace('$', ''), 10);}); *9* } }-
1 点击工具栏中的搜索图标
-
2 在搜索表单中填写最小和最大价格
-
3 点击表单上的搜索按钮
-
4 声明一个预期条件
-
5 等待最多 5 秒的预期条件
-
6 声明一个导航到着陆页的方法
-
7 定位所有价格元素
-
8 提取第一个产品的价格
-
9 将价格转换为数字
现在我们来回顾位于 search.e2e-spec.ts 文件中的测试套件的代码。
搜索工作流程的测试套件包含一个规范,该规范使用页面对象并在工作流程的每个步骤中添加断言。规范首先导航到 ngAuction 的着陆页,然后断言页面的 URL 包含段 /categories/all。
然后,规范通过在页面对象上调用
performSearch()方法并传递10和100作为搜索的价格范围来执行测试。在此方法完成后,它执行三个断言以检查结果页面的 URL 是否包含段 /search?minPrice=10&maxPrice=100,以及第一个产品的价格是否大于$10 且小于$100。此测试套件的代码如下所示。列表 14.32. search.e2e-spec.ts
import {SearchPage} from './search.po'; import {browser} from 'protractor'; describe('ngAuction search', () => { let searchPage: SearchPage; beforeEach(() => { searchPage = new SearchPage(); *1* }); it('should perform the search for products that cost from $10 to $100', () => { searchPage.navigateToLandingPage(); let url = browser.getCurrentUrl(); expect(url).toContain('/categories/all'); *2* searchPage.performSearch(10, 100); *3* url = browser.getCurrentUrl(); expect(url).toContain('/search?minPrice=10&maxPrice=100'); *4* const firstProductPrice = searchPage.getFirstProductPrice(); *5* expect(firstProductPrice).toBeGreaterThan(10); *6* expect(firstProductPrice).toBeLessThan(100); *7* }); });-
1 实例化页面对象
-
2 断言着陆页的 URL
-
3 搜索产品
-
4 断言搜索结果的页面 URL
-
5 获取第一个产品的价格
-
6 声明价格大于 10
-
7 声明价格小于 100
在终端窗口中,切换到客户端目录,运行
npm install,然后使用ng e2e命令运行测试。测试将成功完成,您将看到图 14.18 中显示的消息。图 14.18. 产品搜索测试成功。
![]()
要使测试失败,修改规范以测试没有产品返回的情况,使用介于$1 和$5,000,000 之间的价格范围。您的 ngAuction 并非为苏富比而创建,您也不携带贵重物品。
摘要
-
单元测试运行得很快,但大多数应用业务逻辑应该使用端到端测试进行测试。
-
当你在编写测试时,让它们失败以确认其失败报告易于理解。
-
运行单元测试应该是你自动化构建过程的一部分,但端到端测试则不应如此。
第十五章. 使用 ngrx 维护应用状态
本章涵盖
-
Redux 数据流的简要介绍
-
使用 ngrx 库维护你的应用状态
-
探索中介设计模式的另一种实现
-
在 ngAuction 中使用 ngrx 实现状态管理
你已经到达了最后一章,你几乎准备好加入一个 Angular 项目了。上一章内容容易阅读,但这一章将需要你全神贯注;我们即将呈现的材料有很多动态部分,你需要很好地理解它们是如何协同工作的。
ngrx 是一个可用于在 Angular 应用中管理状态的库(见
github.com/ngrx)。它是基于 Redux 的原则构建的(另一个流行的状态管理库),但通知层是使用 RxJS 实现的。尽管 Angular 有其他管理应用状态的方法,但 ngrx 在中型和大型应用中越来越受欢迎。在你的应用中使用 ngrx 库来管理状态值得吗?它确实有好处,但它们不是免费的。你应用的复杂性可能会增加,代码也会变得更加难以理解,对于任何新加入项目的人来说。在这一章中,我们介绍了 ngrx 库,这样你就可以决定它是否是你管理应用状态的正确选择。在实践部分,我们对使用 ngrx 进行状态管理的 ngAuction 的另一个版本进行了详细的代码概述。
15.1. 从便利商店到 Redux 架构
想象一下,你是一家便利商店的骄傲所有者,该商店销售各种产品。你还记得你是如何开始的吗?你租了一个空地(商店处于初始状态)。然后你购买了货架并订购了产品。之后,多个供应商开始运送这些产品。你雇佣了员工,他们按照一定的顺序将这些产品摆放在货架上,改变了商店的状态。然后你挂出了盛大开幕的标志,并用许多五彩缤纷的气球装饰了地方。顾客开始光顾你的商店购买产品。
当商店开业时,一些产品放在货架上,一些在顾客的购物车中。一些顾客在收银台排队,那里有商店员工。可以说,在任意时刻,你的商店都有当前状态。
如果顾客采取行动,例如购买五瓶水,收银员扫描条形码,这会减少库存中瓶子的数量——它会更新状态。如果供应商运送新产品,你的店员会相应地更新库存(状态)。
你的 Web 应用程序也可以维护一个存储应用程序状态的商店。就像一个真正的商店,在任何给定时间,你的应用程序的store都有一个当前的state。一些数据集合可能包含从服务器检索的特定数据,并且可能被用户修改。一些单选按钮被选中,用户选择了一些产品并导航到由特定 URL 表示的一些路由。
如果用户与 UI 交互,或者服务器发送新数据,这些actions应该要求商店对象更新状态。为了跟踪状态变化,当前状态对象永远不会更新,而是创建状态对象的新实例。
15.1.1. 什么是 Redux?
Redux 是一个开源的 JavaScript 库,为 JavaScript 应用程序提供了一个状态容器(见
mng.bz/005X)。它是在 Facebook 上创建的,作为 Flux 架构的实现(见mng.bz/jrXy)。最初,与 React 框架一起工作的开发者使 Redux 变得流行,但由于它是一个 JavaScript 库,因此可以在任何 JavaScript 应用程序中使用。Redux 基于以下三个原则:
-
存在单一真相来源。 存储在单个商店中,你的应用程序包含可以表示为对象树的状态。
-
状态是只读的。 当发出一个动作时,reducer 函数不会更新,而是克隆当前状态并根据动作更新克隆的对象。
-
状态变化是通过纯函数进行的。 你编写 reducer 函数(s),它接受一个动作和当前状态对象,并返回一个新的状态。
在 Redux 中,数据流是单向的:
1. 应用组件在商店上派发动作。
2. reducer(一个纯函数)接收当前状态对象,然后克隆、更新并返回它。
3. 应用组件订阅商店,接收新的状态对象,并相应地更新 UI。
图 15.1 展示了单向 Redux 数据流。
图 15.1. Redux 数据流
![]()
一个action是一个 JavaScript 对象,它有一个
type属性,描述了在应用程序中发生的事情,例如用户想要购买 IBM 股票。除了type属性外,动作对象还可以有一个可选的属性,包含应该以某种方式更改应用程序状态的数据有效载荷。以下列表显示了示例。列表 15.1. 购买 IBM 股票的动作
{ type: 'BUY_STOCK', *1* stock: {symbol: 'IBM', quantity: 100} *2* }-
1 动作类型
-
2 动作有效载荷
此对象仅描述动作并提供有效载荷。它不知道状态应该如何改变。谁知道?reducer。
一个 reducer 是一个 纯函数,它指定了状态应该如何改变。reducer 从不改变当前状态,而是创建一个新的(并且更新过的)状态版本。状态对象是不可变的。reducer 创建状态对象的副本并返回一个新的引用。从 Angular 的角度来看,这是一个绑定变更事件,所有相关方将立即知道状态已更改,而无需在整个状态树中进行昂贵的值检查。
注意
您的状态对象可以包含数十个属性和嵌套对象。克隆状态对象创建了一个浅拷贝,而不在内存中复制每个未修改的状态属性,因此内存消耗最小,并且不需要花费太多时间。您可以在
mng.bz/3271上了解创建浅状态拷贝的理由。一个 reducer 函数具有以下列表中所示的签名。
列表 15.2. 一个 reducer 签名
function (previousState, action): State {...} *1*- 1 一个 reducer 函数返回一个新的状态。
是否应该让 reducer 函数实现像放置订单这样的应用功能,这需要与外部服务交互?不,因为 reducer 是用来更新和返回应用状态的——例如,要购买的股票是
"IBM"。实现应用逻辑需要与 reducer 外部环境交互;它会导致 副作用,而纯函数不能有副作用。Reducer 可以实现与状态变化相关的最小应用逻辑。例如,假设用户决定取消一个订单,这需要在状态对象上重置某些字段。主要应用逻辑仍然保留在您的应用代码中(例如,在服务中),除非 Redux 灵感库的具体实现提供了一个专门用于具有副作用代码的位置。在本章中,我们使用 ngrx 库,它建议使用 Angular 服务与所谓的 effects 结合使用,这些 effects 位于存储之外,可以作为存储和服务之间的桥梁。
15.1.2. 为什么将应用状态存储在单一位置很重要
最近,本书的一位作者为一家大型汽车制造商的一个网络项目工作。这是一个允许潜在买家通过从超过一千个套餐和选项(如型号、内饰和外观颜色、底盘长度等)中进行选择来配置汽车的网络应用。该应用开发了许多年。软件模块使用 JavaScript、jQuery、Angular、React 和 Handlebars 编写,在服务器上使用 HTML 模板引擎 Thymeleaf。
从用户的角度来看,这是一个由多个步骤组成的流程,最终根据所选选项配置和定价汽车。但内部,过程是从一个模块切换到另一个模块,每个模块都需要知道前一步的选择,以显示可用的选项。
换句话说,每个模块都需要知道应用程序的当前状态。根据任何特定模块中使用的软件,当前用户选择被存储在以下之一中:
-
URL 参数
-
HTML
data*属性 -
浏览器的本地和会话存储
-
Angular 服务
-
React 存储
新的要求出现了,创建了新的 JIRA 工作项并分配了任务,然后开始实施。一次又一次,看似简单的新的需求实施变成了耗时且昂贵的任务。祝你好运向经理解释为什么在页面 B 显示价格需要半天时间,尽管这个价格已经在页面 A 中已知,或者页面 B 中使用的状态对象没有期望有价格属性,如果在页面 A 中价格是 URL 的一部分,页面 B 期望从本地存储中获取当前状态。从头开始重写不是选项。如果应用程序状态以统一的方式实现并存储在单个位置,那就容易多了!
提示
如果你开始开发一个新项目,请特别注意应用程序状态的实现方式,这将在长期内对你有很大帮助。
15.2. 介绍 ngrx
ngrx 是一个受 Redux 启发的库。你可以将其视为在 Angular 应用程序中管理应用程序状态的 Redux 模式的实现。与 Redux 类似,它实现了单向数据流,并具有 store、actions 和 reducers。它还使用了 RxJS 的发送通知和订阅通知的能力。
大型企业应用程序通常在服务器端实现消息架构,其中一块软件通过某个消息服务器或消息总线向另一块软件发送消息。你可以将 ngrx 视为一个客户端消息系统。用户点击一个按钮,应用程序就会发送一个消息(例如,分发一个动作)。由于这个按钮点击,应用程序的状态发生了变化,而 ngrx 的
Store向订阅者发送消息,将下一个值发射到一个可观察的流中。在第 15.1 节中,我们描述了三个 Redux 原则:
-
唯一真相源。
-
状态是只读的。
-
状态变化是通过纯函数实现的。
在 ngrx 中,应用程序状态通过
Store服务访问,这是一个状态的可观察对象和动作的观察者。在 store.d.ts 文件中声明的Store类如下所示:class Store<T> extends Observable<T> implements Observer<Action>除了声明一个新的原则,ngrx 架构还包括 effects,这些 effects 用于与应用程序的其他部分通信的代码,例如执行 HTTP 请求。使用 ngrx selectors,你可以订阅状态对象特定分支的变化。还有对路由和实体集合的支持,这在 CRUD 操作中可能很有用。
我们将开始 ngrx 介绍,从其主要参与者:store、actions 和 reducers。
15.2.1. 熟悉 store、actions 和 reducers
让我们看看如何在具有两个按钮的简单应用程序中使用 ngrx,这两个按钮可以增加或减少计数器的值。此应用程序的第一个版本不管理状态,如下所示。
列表 15.3. 没有 ngrx 的计数器应用程序
import {Component} from '@angular/core'; @Component({ selector: 'app-root', template: ` <button (click)="increment()">Increment</button> <button (click)="decrement()">Decrement</button> <p>The counter: {{counter}}</p> *1* ` }) export class AppComponent { counter = 0; increment() { this.counter++; *2* } decrement() { this.counter--; *3* } }-
1 显示计数器的值
-
2 增加计数器
-
3 减少计数器
你想要更改此应用程序,使其 ngrx 存储管理
counter变量的状态,但首先你需要在项目中安装 ngrx 存储:npm i @ngrx/storeStore充当状态的容器,分发操作是更新状态的唯一方式。计划是实例化Store对象,并将应用程序逻辑(增加和减少计数器)从组件中移除。你的decrement()和increment()方法将向Store分发操作。操作由 ngrx reducer 处理,它将更新计数器的状态。你的
counter变量的类型将从number变为Observable,为了在 UI 中获取和渲染其发出的值,你需要订阅Store。Action对象中唯一的必需属性是type,对于你的应用程序,你将声明操作类型如下:const INCREMENT = 'INCREMENT'; const DECREMENT = 'DECREMENT';下一步是为你想要在存储中保留的每份数据创建一个 reducer 函数。在你的情况下,只是计数器的值,所以你会创建一个 reducer,使用
switch语句根据接收到的操作类型更新状态,如下所示。记住,reducer 函数接受两个参数:状态和操作。列表 15.4. reducer.ts
import { Action } from '@ngrx/store'; export const INCREMENT = 'INCREMENT'; export const DECREMENT = 'DECREMENT'; export function counterReducer(state = 0, action: Action) { *1* switch (action.type) { *2* case INCREMENT: return state + 1; *3* case DECREMENT: return state - 1; *4* default: return state; *5* } }-
1 计数器(状态)的初始值是零。
-
2 检查操作类型
-
3 通过增加计数器来更新状态
-
4 通过减少计数器来更新状态
-
5 如果提供了未知操作,则返回现有状态
重要的是要注意,reducer 函数不会修改提供的状态,而是返回一个新的值。状态保持不可变。
现在,你需要通知根模块你将使用
counterReducer()函数作为存储的 reducer,如下所示。列表 15.5. app.module.ts
import {BrowserModule} from '@angular/platform-browser'; import {NgModule} from '@angular/core'; import {AppComponent} from './app.component'; import {counterReducer} from "./reducer"; import {StoreModule} from "@ngrx/store"; @NgModule({ declarations: [ AppComponent ], imports: [ BrowserModule, StoreModule.forRoot({counterState: counterReducer}) *1* ], providers: [], bootstrap: [AppComponent] }) export class AppModule {}- 1 让存储知道应用程序的 reducer
在此代码中,你配置应用程序级别的存储以提供指定
counterReducer作为 reducer 函数名称的对象,以及counterState作为此 reducer 应保持状态的性质。最后,你需要更改组件的代码以分发类型为
INCREMENT或DECREMENT的操作,具体取决于用户点击哪个按钮。你还将把Store注入到你的组件中,并订阅其可观察对象,每次计数器变化时都会发出值,如下所示。列表 15.6. app.component.ts
import {Component} from '@angular/core'; import {Observable} from "rxjs"; import {select, Store} from "@ngrx/store"; import {INCREMENT, DECREMENT} from "./reducer"; @Component({ selector: 'app-root', template: ` <button (click)="increment()">Increment</button> <button (click)="decrement()">Decrement</button> <p>The counter: {{counter$ | async}}</p> *1* ` }) export class AppComponent { counter$: Observable<number>; *2* constructor(private store: Store<any>) { this.counter$ = store.select('counterState'); *3* } increment() { this.store.dispatch({type: INCREMENT}); *4* } decrement() { this.store.dispatch({type: DECREMENT}); *5* } }-
1 使用异步管道订阅可观察对象
-
2 声明存储可观察对象的引用变量
-
3 select() 发射 counterState 的变化。
-
4 分发 INCREMENT 动作
-
5 分发 DECREMENT 动作
注意,动作是一个对象(例如,
{type: INCREMENT}),在这个应用中,动作对象没有负载。您也可以将动作视为消息或命令。在下一节中,您将定义每个动作为一个具有两个属性(类型和负载)的类。在此组件中,您使用
select操作符(在 ngrxStore中定义),它允许您观察状态对象。其参数的名称必须与在StoreModule.forRoot()函数中使用的状态对象属性名称匹配。注意
通过在模块中调用
StoreModule.forRoot({counterState: counterReducer})方法将counterReducer分配给存储。AppComponent通过在存储上分发动作或使用存储上的select操作符与counterReducer进行通信。带有 ngrx 存储的应用将具有与原始应用相同的行为,并根据用户的操作增加和减少计数器。
现在,让我们检查您的存储是否真的是单一事实来源。您将在下一个列表中添加一个子组件,该组件将显示从存储接收到的当前计数器值,并在应用启动后 10 秒,子组件将分发
INCREMENT动作。列表 15.7. child.component.ts
import {Component} from '@angular/core'; import {select, Store} from "@ngrx/store"; import {Observable} from "rxjs"; import {INCREMENT} from "../reducer"; @Component({ selector: 'app-child', template: ` <h3> Child component </h3> <p> The counter in child is {{childCounter$ | async}} </p> `, styles: [] }) export class ChildComponent { childCounter$: Observable<number>; constructor(private store: Store<any>) { *1* this.childCounter$ = store.pipe(select('counterState')); *2* setTimeout(() => this.store.dispatch({type: INCREMENT}), *3* 10000); } }-
1 注入存储
-
2 订阅存储
-
3 在 10 秒内,分发 INCREMENT 动作
剩下的只是将
<app-child>标签添加到AppComponent的模板中。图 15.2 展示了用户点击增加按钮三次后的应用。父组件和子组件都显示了从存储中获取的相同计数器值(单一事实来源)。应用启动后 10 秒,ChildComponent分发INCREMENT动作,两个组件都将显示增加后的计数器。图 15.2. 运行计数器应用
![]()
要查看此应用的实际运行情况,请打开项目 counter,运行
npm install,然后运行ng serve -o。注意
ngrx 库包括示例应用(见
mng.bz/7F9x),它允许您使用 Google Books API 维护书籍收藏。本章附带的本章 ngAuction 应用也可以作为 ngrx 的演示,尽管这两个应用都没有使用 ngrx 提供的每个 API。计数器应用是一个相当基本的示例,只有一个还原函数。在实际应用中,存储可能有多个还原器,每个还原器将负责状态对象的一部分。在实践部分,新的 ngAuction 版本将包含多个还原器。
消除事件冒泡的需求
这是您在 第八章 的 8.3.1 节 中看到的图表。
![]()
视图由组件组成
假设组件 7 可以发射一些在组件 6 中需要的数据。如果您使用常见的父组件进行组件间通信,您需要通过组件 7 的
@Output属性发射一个事件;父组件 3 会订阅这个事件并通过其@Output属性重新发射;然后组件 1 会订阅这个事件,并通过绑定将有效载荷传递给组件 6。使用 ngrx 存储消除了编写这一系列不幸事件的需要。组件 7 发射存储的动作,组件 6 使用选择器来接收它。无论在特定视图中组件嵌套的层级有多少,这种简单、相同的组件间通信模型都是适用的。组件 7 和 6 唯一需要的是存储对象的引用。
此图没有提供关于这八个组件做什么的任何细节,但您可以假设 1、2 和 3 是容器组件,它们包含其他组件并实现与应用逻辑交互的子组件、父组件和服务。其余的是表现组件,可以接收数据、发送数据并在 UI 上呈现数据。一些作者建议只有容器组件应该管理状态并与存储进行通信。我们不同意这种观点,因为状态不仅仅是关于存储和共享数据——它还涉及到存储 UI 的状态,这是任何类型组件的一部分。
在计数器示例中,存储管理的是由数字表示的应用状态,但您注意到存储还扮演了另一个角色吗?在第八章的第 8.3.2 节中,我们向您展示了可注入服务如何扮演中介的角色。在计数器应用中,ngrx 存储的主要目标是管理应用状态,但它还扮演了另一个角色:作为父组件和子组件之间的中介。
在第八章中,中介是一个带有 RxJS
BehaviorSubject的服务,您使用了组件来发送和接收数据。使用 ngrx 时,您不需要手动创建BehaviorSubject,因为Store对象既可以用来发射值,也可以用来订阅它们。要通知
BehaviorSubject关于新值的信息,您使用next(),而要通知存储关于新状态的信息,您使用dispatch()。在两种情况下,都要订阅可观察对象以获取新状态。图 15.3 比较了第八章中的列表 8.13 中的EbayComponent的代码(在左侧)与使用 ngrx 的ChildComponent(在右侧)。它们看起来很相似,不是吗?图 15.3.
EbayComponent与ChildComponent的比较![图片]()
我们可以说,
StateService(左侧)和Store(右侧)各自作为单一的真实来源。但是,大型非 ngrx 应用中有多个可注入服务,它们存储不同的状态片段,会有多个真实来源。在 ngrx 应用中,Store服务始终是单一的真实来源,它可能有多个状态片段。现在再看看列表 15.4 中的 reducer,它是一个不需要使用任何外部资源来更新状态的纯函数。如果计数器的值是由服务器提供的呢?reducer 可以使用外部资源,因为它会使 reducer 变得不纯,不是吗?这就是 ngrx effects 发挥作用的地方,我们将在下一节讨论它们。
15.2.2. 熟悉 effect 和 selectors
Reducers 是纯函数,执行简单的操作:接收状态和动作并创建一个新的状态。但是你需要在某个地方实现业务逻辑,例如调用服务、向服务器发送请求等。你需要实现有副作用的函数,这通常在 effect 类中完成。
Effects是存在于 store 之外的可注入类,用于实现具有副作用的函数,而不会破坏单向数据流。ngrx effects 是一个单独的包,你需要运行以下命令将它们添加到你的项目中:
npm i @ngrx/effects如果一个组件派发了一个需要与外部资源通信的动作,这个动作可以被
Effects对象捕获,它将处理这个动作并在 reducer 上派发另一个动作。例如,一个 effect 可以从商店接收一个LOAD_PRODUCTS动作,调用loadProducts(),当数据加载完成后,派发LOAD_PRODUCTS_SUCCESS或LOAD_PRODUCTS_FAILURE动作之一。reducer 将捕获它并相应地更新状态。图 15.4 展示了使用 effect 的 ngrx 数据流。图 15.4. 带有 effect 的 ngrx 数据流
![图片]()
要理解这个图,想象一下用户点击了“购买 100”按钮。组件会在商店上派发一个动作,这个动作可以被 reducer、effect 或两者处理。一个 effect 可以访问外部服务并派发另一个动作。在任何情况下,reducer 最终都负责创建一个新的状态,组件可以使用 selector 获取它并相应地更新 UI(例如,渲染“购买 100 股”的消息)。
注意
我们想强调的是,尽管动作可以在 reducer 和 effect 中处理,但只有 reducer 可以改变应用的状态。
如果你比较图 15.4 和图 15.1 中分别展示的 Redux 和 ngrx 数据流,你会注意到效果存在于存储之外。它们可以与其他 Angular 服务通信,反过来,这些服务也可以与外部服务器通信,如果需要的话。图 15.1 中的另一个不同之处在于,视图会使用
subscribe()来接收最新状态;15.4 展示了可以使用选择器函数检索整个状态对象或其部分的select()方法。在 Redux 和 ngrx 中,组件在存储上派发动作。Redux 动作仅在 reducer 中处理,但在 ngrx 中,一些动作在 reducer 中处理,一些在效果中处理,还有一些两者都处理。例如,如果组件派发
LOAD_PRODUCTS,reducer 可以捕获它来设置状态属性loading为 true,这将导致显示进度指示器。效果可以接收相同的LOAD_PRODUCTS动作并对产品进行 HTTP 请求。你知道要派发一个应该由 reducer 处理的动作,组件会调用
Store.dispatch(),但效果如何派发动作?效果返回一个包含一些有效载荷的观察者。在你的效果类中,你将声明一个或多个用@Effect装饰器注解的类变量。每个效果都将应用ofType运算符,以确保它只对指定的动作类型做出反应,如下面的列表所示。列表 15.8. 带有效果的类片段
@Injectable() export class MyEffects { ... @Effect() loadProducts$ = this.actions$ .pipe(ofType(LOAD_PRODUCTS), .switchMap(this.productService.getProducts())) ... }在这个例子中,
@Effect装饰器将可观察属性loadProducts$标记为LOAD_PRODUCTS类型动作的处理程序,并调用getProducts(),它返回一个Observable。然后,根据发出的值,效果将派发另一个动作(例如,成功或失败)。你将在下一节中看到如何做到这一点。一般来说,你可以将效果视为原始动作和 reducer 之间的中间件,如图 15.5 所示。图 15.5. 数据流中的效果
![]()
在你的应用模块类中,你需要为根模块添加到
@NgModule装饰器的EffectsModule.forRoot(),或者为功能模块添加EffectsModule.forFeature()。我们不想让你被 ngrx 背后的理论压倒,所以让我们继续开发一个使用 ngrx 存储、带有有效载荷的动作、reducer、效果和选择器的应用。
15.2.3. 使用 ngrx 重构中介器应用
在本节中,你将重构第八章第 8.3.2 节(kindle_split_017.xhtml#ch08lev2sec4)中创建的应用。那个应用有一个搜索字段和两个链接,eBay 和 Amazon。你将通过用 ngrx 存储替换维护应用状态的
SearchService注入式来重构它。为了说明效果和服务之间的通信,你将添加ProductService,它将生成搜索结果:包含输入搜索标准在其名称中的产品。新版本的应用位于介质文件夹中。它将使用以下 ngrx 构建块:
-
用于存储和检索应用状态、搜索查询和结果的数据存储
-
用于处理类型为
SEARCH和SEARCH_SUCCESS的动作的 reducer -
处理类型为
SEARCH和SEARCH_SUCCESS的动作的效果 -
用于检索整个状态对象、搜索查询或搜索结果的选择器
图 15.6 显示了用户在
SearchComponent的搜索字段中输入aaa后的介质应用。图 15.6. 在 eBay 上进行的
aaa搜索结果![]()
店存状态
此应用的状态对象将包含两个属性:搜索查询(例如,
aaa)和搜索结果(例如,五个产品)。你将在以下列表中声明类型以表示你应用的状态。列表 15.9. 介质应用的状态
export interface State { searchQuery: string; *1* searchResults: string[]; *2* }-
1 SearchComponent 分发的 SEARCH 动作的有效负载
-
2 在调用 ProductService.getProducts()后,效果分发的 SEARCH_SUCCESS 的有效负载
动作
在计数器应用中,动作不包含有效负载;它们增加或减少计数器。这次不同。
SEARCH动作可以有有效负载(例如aaa),SEARCH_SUCCESS也可以有有效负载(例如五个产品)。这就是为什么仅声明表示动作类型的常量是不够的,你将把每个动作包裹在一个具有有效负载作为参数的构造函数的类中。动作将在以下列表中声明的 actions.ts 文件中声明。列表 15.10. actions.ts
import {Action} from '@ngrx/store'; export const SEARCH = '[Product] search'; *1* export const SEARCH_SUCCESS = '[Product] search success'; *1* export class SearchAction implements Action { *2* readonly type = SEARCH; constructor(public payload: {searchQuery: string}) {} } export class SearchSuccessAction implements Action { *3* readonly type = SEARCH_SUCCESS; constructor(public payload: {searchResults: string[]}) {} } export type SearchActions = SearchAction | SearchSuccessAction; *4*-
1 声明动作类型
-
2 代表带有有效负载的搜索动作的类
-
3 代表带有有效负载的搜索成功动作的类
-
4 声明联合搜索动作类型
注意动作定义中的文本
[Product]。在实际应用中,你可能会有多个SEARCH动作——一个用于产品,一个用于订单等。通过在动作描述前加上[Product],你创建了一个命名空间,使代码更易于阅读。具有命名空间的动作有助于在任何给定时刻理解哪些动作被分发。actions.ts 的最后一行使用了在附录 B 中 B.11 节描述的 TypeScript 联合操作符。在这里,你定义了将在 reducer 签名中使用的
SearchActions类型,这样 TypeScript 编译器就知道在 reducer 的switch语句中允许哪些动作。作为动作创建者的 SearchComponent
动作已声明,但有人必须创建和分发它们。在你的应用中,以下列表中显示的
SearchComponent将在用户输入搜索条件后创建和分发类型为SEARCH的动作。列表 15.11. search.component.ts
@Component({ selector: 'app-search', template: ` <h2>Search component</h2> <input type="text" placeholder="Enter product" [formControl]="searchInput">`, styles: ['.main {background: yellow}'] }) export class SearchComponent { searchInput: FormControl; constructor(private store: Store<any>) { this.searchInput = new FormControl(''); this.searchInput.valueChanges *1* .pipe(debounceTime(300), tap(value => console.log(`The user entered ${value}`))) .subscribe(searchValue => { this.store.dispatch(new SearchAction({ searchQuery: searchValue }));*2* }); } }-
1 订阅表单控制的可观察对象
-
2 实例化和分发带有有效负载的 SEARCH 类型动作
分派的动作将被还原器捕获,它将更新状态对象上的
searchQuery属性。注意
我们将在本节后面讨论另一个动作创建器,即
SearchEffects类。Reducer
在 列表 15.12 中显示的还原器中,你声明了一个描述你的应用程序状态结构的接口,并创建了一个表示初始状态的对象。
reducer()函数将接受初始或当前不可变状态,并使用switch语句根据动作类型创建并返回一个新的状态。列表 15.12. reducers.ts
import {SearchActions, SEARCH, SEARCH_SUCCESS} from './actions'; export interface State { *1* searchQuery: string; searchResults: string[]; } const initialState: State = { *2* searchQuery: '', searchResults: [] }; export function reducer(state = initialState, action: SearchActions): State { switch (action.type) { case SEARCH: { *3* return { *4* ...state, searchQuery: action.payload.searchQuery, *5* searchResults: [] *5* } } case SEARCH_SUCCESS: { *6* return { ...state, *7* searchResults: action.payload.searchResults *8* } } default: { return state; *9* } } }-
1 声明状态对象的结构
-
2 创建一个表示初始状态的对象
-
3 这个动作由组件分派。
-
4 将现有状态值复制到新状态对象中
-
5 使用新值更新两个状态属性
-
6 这个动作将由效果分派。
-
7 将现有状态值复制到新状态对象中
-
8 使用新值更新一个状态属性
-
9 如果分派了意外的动作,则返回当前状态
提示
如果在一个
case子句中使用了未在联合类型SearchActions中声明的动作类型(例如,SEARCH22),TypeScript 编译器将返回一个错误。TypeScript 联合类型的更精确名称是 区分联合。如果一个联合中的所有类型都有一个共同的类型属性,TypeScript 编译器就可以通过这个属性来区分类型。它知道在
case语句中引用的是联合中的哪个特定类型,并为负载属性建议正确的类型。为了克隆状态对象并更新其一些属性,你使用在 附录 A 的 A.7 节中描述的扩展操作符。注意,状态属性将使用动作负载的值进行更新。
Effects
在这个应用中,你将有一个效果,它将使用
ProductService注入式来获取产品。为了简化说明,你不会从外部服务器或文件加载产品。你的ProductService,如下所示,将生成并返回五个产品的可观察对象。它使用 RxJS 的delay操作符来模拟一秒钟的延迟,就像产品来自远程计算机一样。列表 15.13. product.service.ts
@Injectable() export class ProductService { static counter = 0; *1* getProducts(searchQuery: string): Observable<string[]> { const productGenerator = () => `Product ${searchQuery}${ProductService.counter++}`; *2* const products = Array.from({length: 5}, productGenerator); *3* return Observable.of(products).pipe(delay(1000)); *4* } }-
1 连接到搜索查询的计数器是一个产品名称。
-
2 一个生成产品名称的函数
-
3 使用 productGenerator() 创建一个五个元素的数组
-
4 返回一秒延迟后的产品可观察对象
你的
SearchEffects类将声明一个效果,loadProducts$,它将分派具有产品数组作为负载的SEARCH_RESULTS效果。你想要确保这个效果只有在存储分派了SEARCH效果时才会获取产品,所以你使用了 ngrx 操作符ofType(SEARCH)。此效果从
actions$可观察对象发出的类型为SEARCH的动作(搜索查询)中提取有效负载,并使用switchMap将其传递给内部可观察对象(getProducts()方法)。最后,效果将派发类型为SEARCH_RESULTS的动作,并带有有效负载,所有这些都可以在下面的列表中看到。列表 15.14. effects.ts
@Injectable() export class SearchEffects { @Effect() loadProducts$ = this.actions$ *1* .ofType(SEARCH) *2* .pipe( map((action: SearchAction) => action.payload), *3* switchMap(({searchQuery}) => this.productService.getProducts(searchQuery)), *4* map(searchResults => new SearchSuccessAction({searchResults})) *5* ); constructor(private actions$: Actions, *6* private productService: ProductService) {} *7* }-
1 使用流/可观察对象初始化 loadProducts$ 效应
-
2 仅当存储派发了 SEARCH 动作时执行搜索
-
3 从类型为 SEARCH 的动作中提取有效负载
-
4 根据指定的搜索查询获取产品
-
5 使用其有效负载派发类型为 SEARCH_SUCCESS 的动作
-
6 注入 ngrx Actions 可观察对象
-
7 注入 ProductService
在此示例中,你假设
getProducts()总是会发出产品,但你可以在观察者中添加catchError()函数,在那里你会发出报告错误的动作。你将在 列表 15.31 中看到catchError()的使用。提示
虽然在读取数据时使用
switchMap放弃不需要的结果是可以的,但如果编写执行添加、更新或删除操作的效应,请使用concatMap。这将防止在更新记录的过程中一个请求正在进行更新,另一个请求到来时可能出现的竞争条件。使用concatMap,所有请求将依次到达服务。在某些情况下,你可能想创建一个处理动作但不需要派发另一个动作的效应。例如,你可能想创建一个仅记录动作的效应。在这种情况下,你需要将一个
{dispatch: false}对象传递给@Effect装饰器:@Effect({ dispatch: false }) logAction$ = this.actions$ .pipe( tap( action => console.log(action)) );选择器
在现实世界的应用程序中,状态对象可以表示为嵌套属性的树,你可能只想获取存储状态的特定部分,而不是获取整个状态对象并手动遍历其内容。让我们看看应用程序组件如何通过使用选择器来获取特定状态属性的值。
首先,使用
createFeatureSelector()方法获取顶级功能状态的选择器。然后,使用此选择器作为其他更具体选择器的起点,使用createSelector()方法,该方法返回用于选择状态切片的回调函数。你的应用程序的选择器在 selectors.ts 文件中声明。列表 15.15. selectors.ts
import {createFeatureSelector, createSelector} from '@ngrx/store'; import {State} from './reducers'; export const getState = createFeatureSelector<State>('myReducer'); *1* export const getSearchQuery = createSelector(getState, state => state.searchQuery); *2* export const getSearchResults = createSelector(getState, state => state.searchResults); *3*-
1 创建顶级状态的选择器
-
2 创建用于搜索查询状态属性的选择器
-
3 创建用于搜索结果状态属性的选择器
createFeatureSelector()方法的参数是在模块中指定的还原器名称。在@NgModule装饰器中,你将看到以下行:StoreModule.forRoot({myReducer: reducer})因此,为了获取此还原器的引用,你将编写
createFeatureSelector ('myReducer');。让我们回顾一下你到目前为止所取得的成就:
1. 你声明了表示
SEARCH和SEARCH_RESULTS类型动作的类。2.
SearchComponent可以发送类型为SEARCH的动作。3. 还原器可以处理两种动作类型。
4. 你声明了可以获取产品和发送类型为
SEARCH_RESULTS的动作的效果。5. 你声明了选择器以获取应用状态的片段。
为了完成循环,你将使用 eBay 和 Amazon 组件中的选择器来渲染搜索标准和检索到的产品。下面的列表只显示了
EbayComponent的代码(AmazonComponent的代码看起来相同)。列表 15.16. ebay.component.ts
@Component({ selector: 'app-ebay', template: ` <div class="ebay"> <h2>eBay component</h2> Search criteria: {{searchFor$ | async}} *1* <ul> <li *ngFor="let p of searchResults$ | async ">{{ p }}</li> *2* </ul> </div>`, styles: ['.ebay {background: cyan}'] }) export class EbayComponent { searchFor$ = this.store.select(getSearchQuery); *3* searchResults$ = this.store.select(getSearchResults); *4* constructor(private store: Store<State>) {} *5* }-
1 订阅发出搜索标准的可观察对象并渲染它
-
2 订阅发出产品并渲染它们
-
3 在存储上调用 getSearchQuery() 选择器
-
4 在存储上调用 getSearchResults() 选择器
-
5 注入存储
EBayComponent的代码简洁,不包含任何应用逻辑。使用 ngrx,你需要编写更多的代码,但你的 Angular 组件中的每个方法都变成了发送动作的命令或检索数据的选择器,每个命令都会改变你应用的状态。完成应用-ngrx 通信还有一步。你需要在应用模块中注册存储和效果。下一个列表中显示的模块还包括路由配置,因此用户可以在 eBay 和 Amazon 组件之间导航。
列表 15.17. app.module.ts
@NgModule({ imports: [BrowserModule, CommonModule, ReactiveFormsModule, RouterModule.forRoot([ {path: '', component: EbayComponent}, *1* {path: 'amazon', component: AmazonComponent}]), *1* StoreModule.forRoot({myReducer: reducer}), *2* EffectsModule.forRoot([SearchEffects]), *3* StoreDevtoolsModule.instrument({ logOnly: environment.production}), *4* ], declarations: [AppComponent, EbayComponent, AmazonComponent, SearchComponent], providers: [ ProductService, {provide: LocationStrategy, useClass: HashLocationStrategy} ], bootstrap:[AppComponent] }) export class AppModule {}-
1 配置路由
-
2 注册存储并将其链接到还原器
-
3 注册效果
-
4 启用 Redux DevTools 的使用
在下一节中,我们将向你展示如何使用 Chrome 扩展 Redux DevTools 监控状态,以及
instrument()方法的作用。下面的列表中的应用程序组件与第八章中介器示例中的相同 chapter 8。列表 8.10 包含注释,所以我们在这里不会描述它。
列表 15.18. app.component.ts
@Component({ selector: 'app-root', template: ` <div class="main"> <app-search></app-search> <p> <a [routerLink]="['/']">eBay</a> <a [routerLink]="['/amazon']">Amazon</a> <router-outlet></router-outlet> </div>`, styles: ['.main {background: yellow}'] }) export class AppComponent {}要查看此应用的运行情况,请在项目中介器中运行
npm install,然后运行ng serve -o。ngrx 还能提供什么
你的中介器应用使用了
@ngrx/store和@ngrx/effects包,这些包可以解决你大部分的状态管理需求。在实战部分,你还将使用@ngrx/router-store,它提供了连接和监控 AngularRouter的绑定。还有其他一些包:-
@ngrx/entity是一个用于管理记录集合的实体状态适配器。 -
@ngrx/schematics是一个脚手架库,它提供了生成 ngrx 相关代码的蓝图。
考虑自行探索这些包。所有 ngrx 包的 API 都在
mng.bz/362y中描述。现在我们来看看如何使用 Redux DevTools 监控应用状态。
15.2.4. 使用 ngrx store DevTools 监控状态
由于你将状态管理操作委托给 ngrx,你需要一个工具来监控运行时的状态变化。Redux DevTools 浏览器扩展程序以及
@ngrx/store-devtools包用于应用状态仪表化。首先,安装@ngrx/store-devtools:npm install @ngrx/store-devtools其次,添加 Chrome 扩展程序 Redux DevTools(Firefox 也有这样的插件)。
第三,将仪表化代码添加到应用模块中。例如,对于默认配置的仪表化,你可以在
@NgModule装饰器的导入部分添加以下行:StoreDevtoolsModule.instrument()StoreDevtoolsModule必须在StoreModule之后添加。如果你想在生产环境中最小化其开销以添加仪表化,可以使用以下方式使用environment变量:StoreDevtoolsModule.instrument({ logOnly: environment.production })在生产环境中,将
logOnly标志设置为true,这不会包括诸如派发和重新排序动作、在页面重新加载之间持久化状态和动作历史等工具,这些工具会引入明显的性能开销。你可以在mng.bz/cOwC找到logOnly: true关闭的完整功能列表。instrument()方法可以接受在 node_modules/@ngrx/store-devtools/src/config.d.ts 文件中定义的StoreDevtoolsConfig类型的参数。下面的代码示例展示了如何添加仪表化,这将允许监控最多 25 个最近动作,并在生产环境中以仅日志模式工作。列表 15.19. 使用两个配置选项添加仪表化
StoreDevtoolsModule.instrument({ maxAge: 25, *1* logOnly: environment.production *2* })-
1 在浏览器扩展程序中保留最后 25 个状态
-
2 限制浏览器扩展程序在生产环境中仅以日志模式运行
你也可以通过向
instrument()方法提供features参数来限制 Chrome Redux 扩展程序的一些功能。有关配置 ngrx 仪表化和支持的 API 的更多详细信息,请参阅mng.bz/3AXe,但在这里我们将向你展示一些 Chrome Redux 扩展程序的截图,以说明 ngrx 存储 DevTools 的一些功能。小贴士
如果你运行了你的应用,但 Chrome Redux 面板显示一个带有消息“未找到存储”的黑色窗口,请在浏览器中刷新页面。
启动应用会在存储中创建初始状态。图 15.7 展示了你启动中介应用并在输入字段中输入
aaa后的屏幕。动作序列从@ngrx/store和@ngrx/effects包内部派发的两个init动作开始,你选择左侧的@ngrx/store/init动作和右上角的“状态”按钮。状态属性searchQuery和searchResults都是空的。要查看派发搜索动作后的应用状态,请点击此动作。图 15.7. 已选择存储
init动作。![]()
将
init操作视为应用程序可以订阅并在应用程序启动时实现一些逻辑的钩子——例如,你可以检查用户是否已登录。如果你的应用程序使用具有自己的 reducer 的懒加载模块,你也可能看到每个新加载模块的@ngrx/store/update-reducer操作,并且其 reducer 将被添加到存储 reducer 集合中。展示了点击右上角的操作按钮后的屏幕,并显示了最新操作的类型和负载:
1. 最新操作是
"[Product] search success"。2. 已选择操作标签页。
3. 操作负载存储在状态属性
searchResults中。4. 操作类型是
"[Product] search success"。图 15.8. 操作标签页视图
![Images/15fig08_alt.jpg]()
提示
如果你的状态对象有多个分支,通过点击
(pin),你可以在浏览操作时将某个状态片段固定在顶部。如图 15.9 所示,点击状态按钮后,你可以看到你的状态变量
searchQuery和searchResults的当前值:1. 最新操作是
"[Product] search success"。2. 已选择状态标签页。
3. 搜索条件存储在状态属性
searchQuery中。4. 搜索结果存储在状态属性
searchResults中。图 15.9. 状态标签页视图
![Images/15fig09_alt.jpg]()
如果状态标签页显示整个状态对象,点击“差异”按钮将显示特定操作导致的变化。如图 15.10 所示,如果没有选择操作,差异标签页将显示最新操作所做的状态更改:
1. 最新操作是
"[Product] search success"。2. 已选择差异标签页。
3. 状态属性
searchResults的内容不同。图 15.10. 已选择差异标签页。
![Images/15fig10_alt.jpg]()
在调试应用程序时,开发者经常需要重新创建应用程序的某个状态,一种方法是刷新页面并通过点击按钮、选择列表项等方式重复用户操作。使用 Redux DevTools,你可以回到过去并重新创建某个状态,而无需刷新页面——你可以跳转到某个操作发生后的状态,或者跳过某个操作。
当你选择一个操作,如图 15.11 所示,你会看到跳转和跳过按钮,然后点击跳过将划掉所选操作,你的运行中的应用程序将反映这一变化。顶部将显示清除按钮,点击它将从列表中删除此操作。跳转按钮将跳转到所选操作的特定应用程序状态。Redux DevTools 将显示此刻的状态属性,并且应用程序的 UI 将相应地重新渲染:
1. 已点击此操作的跳过按钮。
2. 已选择状态标签页。
3. 搜索查询是
aaabbb。4. 状态属性
searchResults对于aaabbb产品没有显示任何结果。5. 没有点击“清理”按钮。
图 15.11. 跳过了 [产品] 搜索成功的动作
![]()
我们已经向你展示了 ngrx 存储 DevTools 的主要功能,但要更好地理解这个工具,我们鼓励你花些时间自己尝试操作。
15.2.5. 监控路由状态
当用户导航应用时,路由器渲染组件,更新 URL,并在需要时传递参数或查询字符串。在幕后,路由器对象代表路由的当前状态,而
@ngrx/router-store包允许你在 ngrx 存储中跟踪路由状态。此包不会改变 Angular
Router的行为,你可以在组件中继续使用RouterAPI,但由于存储应该是单一的真实来源,你可能还想考虑在那里表示路由状态。在任何给定的时间,ngrx 存储可以让你访问诸如url、params、queryParams等许多其他路由属性。与任何其他状态属性一样,你需要在 ngrx 存储中添加一个还原器,好消息是,你不需要在应用中实现它,因为
routerReducer在@ngrx/router-store中定义。要添加路由状态支持,首先安装此包:npm i @ngrx/router-store之后,将
StoreRouterConnectingModule添加到NgModule装饰器中,并将routerReducer添加到还原器列表中。StoreRouterConnectingModule保存当前的路由状态。在导航期间,在调用路由守卫之前,路由存储会分派类型为ROUTER_NAVIGATION的动作,该动作携带RouterStateSnapshot对象作为其负载。要获取应用中的
routerReducer,你需要执行两个步骤:1. 通过将值分配给属性
StoreRouterConnectingModule.stateKey来给它一个名称。2. 使用上一步的值作为
routerReducer的名称。以下列表展示了如何将
StoreRouterConnectingModule添加到应用模块中。在这里,你使用myRouterReducer作为routerReducer的名称。列表 15.20. 应用模块片段
import {StoreRouterConnectingModule, routerReducer} from '@ngrx/router-store'; ... @NgModule({ imports: [ ... StoreModule.forRoot({myReducer: reducer, ? myRouterReducer: routerReducer}), *1* StoreRouterConnectingModule.forRoot({ stateKey: 'myRouterReducer' *2* }) ] ... }) export class AppModule { }-
1 将路由还原器添加到 StoreModule
-
2 在状态键属性中存储还原器的名称
现在,状态属性
myRouterReducer可以用来访问路由状态。这个属性的值会在每次路由导航时更新。15.2.3 节中的应用没有包含路由状态监控,但本章附带源代码中还有一个名为 mediator-router 的另一个应用,它确实监控路由状态。运行此应用并打开 Redux DevTools 面板。然后导航到 Amazon 路由,你将看到应用状态对象中的
ROUTER_NAVIGATION动作和myRouterReducer属性,如图 15.12 所示。提示
通过点击底部工具栏的下箭头,QA 工程师可以保存当前应用程序的状态并将其发送给开发者,开发者可以将它加载到 Redux 扩展(上箭头)中,以重现报告为错误的场景。
图 15.12. Redux DevTools 中的
RouterStateSnapshot对象![]()
展开 RouterStateSnapshot 的节点;它有大量的属性。这个对象如此之大,甚至可能使 Redux DevTools 崩溃。通常,你只需要监控少量路由状态属性,这就是路由状态序列化器派上用场的地方。
要实现序列化器,定义一个类型,它将只包含
RouterStateSnapshot中你想要监控的属性。然后编写一个实现RouterStateSerializer接口的类,@ngrx/router-store将开始使用它。此接口要求你实现serialize()回调,在那里你应该解构提供的RouterStateSnapshot以提取你关心的那些属性。一些属性,如
url,在顶层可用,而其他属性,例如queryParams,位于RouterStateSnapshot.root属性之下。中介路由项目在serializer.ts文件中实现了路由状态序列化器,如下所示。列表 15.21. serializer.ts
import { RouterStateSerializer } from '@ngrx/router-store'; import {Params, RouterStateSnapshot} from '@angular/router'; interface MyRouterState { *1* url: string; queryParams: Params; } export class MyRouterSerializer implements RouterStateSerializer<MyRouterState> { *2* serialize(routerState: RouterStateSnapshot): MyRouterState { const {url, root: {queryParams}} = routerState; *3* return {url, queryParams}; *4* } }-
1 定义要监控的路由状态属性
-
2 创建一个实现 RouterStateSerializer 的类
-
3 使用解构来获取你需要的属性
-
4 返回包含 url 和 queryParams 属性的对象
现在,Redux DevTools 将只显示
url和queryParams的值。要获取路由状态对象的值,使用select()运算符。下一个列表显示了在中介路由项目中如何实现它。列表 15.22. app.component.ts
export class AppComponent { constructor(store: Store<any>) { store .select(state => state.myRouterReducer) *1* .subscribe(routerState => console.log('The router state: ', routerState)); } }- 1 提取路由状态切片
如果你想在你的效果类中处理路由状态动作,创建一个处理类型为
ROUTER_NAVIGATION的动作的效果。以下是从中介路由项目的effects.ts文件中的代码片段,展示了如何在效果中实现它。列表 15.23. effects.ts 的一个片段
@Injectable() export class SearchEffects { ... @Effect({ dispatch: false }) *1* logNavigation$ = this.actions$.pipe( ofType('ROUTER_NAVIGATION'), *2* tap((action: any) => { console.log('The router action in effect:', action.payload); }) ); constructor(private actions$: Actions, private productService: ProductService) {} }-
1 此效果不派发自己的动作。
-
2 监听 ROUTER_NAVIGATION 动作
在某些情况下,你可能在效果类内部安排导航。为此,如下所示,你可以继续使用路由 API,而不需要 ngrx 的帮助。
列表 15.24. 在 effects 中导航
@Effect({ dispatch: false }) navigateToAmazon$ = this.actions$.pipe( ofType('GOTO_AMAZON') *1* tap((action: any) => { this.router.navigate('/amazon'); *2* }) );-
1 监听 GOTO_AMAZON 动作
-
2 导航到/amazon 路由
这就结束了我们对 ngrx 的介绍,但在本章的动手实践部分,你将看到如何在 ngAuction 应用程序中使用它。
15.3. 使用 ngrx 还是不使用 ngrx
最近,我们的一位客户解释了他们存储状态的需求。在他们应用中,状态由一个包含嵌套数组的大型对象表示,每个数组存储作为图表渲染的数据。应用检索其中一个数组,执行一些计算,并渲染图表。未来,他们计划添加新的图表和数组。
客户询问使用具有
BehaviorSubject的单例 Angular 服务是否比 ngrx 在此用例中提供更不可扩展的解决方案。他补充说,在 ngrx 中,他们可以使用具有其 reducer 的单独数组(状态片段),这可以使得添加新的数组和图表更容易,因为 ngrx 会自动从单个 reducer 创建一个全局状态对象。让我们看看 ngrx 是否能有所帮助。首先,如果他们需要大量数据来渲染不直接使用数据的图表,将计算移动到服务器以避免在内存中保留大型对象并在浏览器中计算数字是有意义的。但如果是他们仍然想在客户端实现所有数据处理的呢?
使用 Angular 服务方法时,具有嵌套数组的对象会增长并变得难以维护。在单独的 reducer/数组的情况下,将它们添加到状态中并推理状态会更容易。
但是,使用 ngrx 方法时,状态对象也会增长,他们需要添加更多的 reducer 和 selector 来处理增长。使用 Angular 服务方法时,他们可以添加更多用于获取状态片段的方法,或者将单例服务拆分为多个服务——主要服务存储数据,而单独的服务(每个图表一个)从主服务获取和处理数据。
ngrx 和服务方法都可以完成工作并保持可维护性。如果应用尚未使用 ngrx,仅仅因为图表而使用 ngrx 是没有意义的。
15.3.1. 比较 ngrx 与 Angular 服务
好的,有没有什么用例表明 ngrx 在状态管理方面比 Angular 服务方法有优势?让我们比较一下状态管理的三个主要特性:
-
单一事实来源可能意味着两件事:
-
每组数据只有一个副本。使用 Angular 服务和
BehaviorSubject可以轻松实现这一点。 -
有一个对象保存了所有应用数据。这是 Redux/ngrx 方法的一个独特特性,它使得 Redux DevTools 成为可能。这对于具有跨模块交互和大量共享数据的大型应用来说是一个非常有价值的特性。如果没有单一的状态对象,几乎是不可能的。DevTools 允许导出/导入应用整个状态,如果你需要重现用户或 QA 工程师发现的错误。但在现实世界中,状态变化会触发副作用,并且不会将应用恢复到完全相同的状态。
-
-
状态只能由 reducer 修改,因此你可以轻松地定位和调试与状态相关的问题。但如果你在 Angular 服务中使用
BehaviorSubject来保存数据,你也能做到这一点。没有BehaviorSubject,很难识别所有可以修改状态的任务,但有了BehaviorSubject,你可以在一个地方设置断点。此外,通过将map操作符应用于BehaviorSubject,你可以像在 reducer 中一样处理所有数据修改。 -
使用 ngrx 和特定的选择器,你可以生成一个派生状态,它结合了存储对象不同部分的数据,并且它可以被缓存。你同样可以在 Angular 服务中轻松做到这一点。定义一个服务,它注入其他服务,使用
combineLatest或withLatestFrom操作符聚合它们的值,然后发出“派生”状态。
如果你想要所有这些功能,从 ngrx 开始可能更容易,因为 ngrx 强制执行这些功能。如果没有强制执行的纪律,你的单例服务可能很快就会从 30 行代码变成一个难以维护的怪物,拥有数百行代码。如果你不确定最佳实践是否可以在你的团队中强制执行,请选择 ngrx,它为你的应用程序提供了一个定义良好的结构。
小贴士
你可以不用编写创建 store、effects、actions 等代码,而是使用 Angular CLI 生成它们,但首先需要安装 ngrx 蓝图(也称为 schematics)。你可以在
mng.bz/7W30了解如何使用@ngrx/schematics。如果你的项目是由 Angular CLI 6 或更高版本生成的,你可以使用以下命令将 NGRX 元素添加到项目中:ng add @ngrx/store ng add @ngrx/effects15.3.2. 状态突变问题
这些问题确实存在,但 Angular 提供了你所需的一切来解决它们。在你的项目中,所有 Angular 组件都使用
OnPush变更检测策略。当你需要修改组件的数据时,创建一个新的对象实例并将其绑定到组件的@Input。有时候使用默认的变更检测策略更有意义。例如,你可能需要创建一个动态表单,其内容根据其他表单控件中输入的值而变化。控件值作为可观察对象(作为
valueChanges)公开,如果你的组件使用OnPush,并且所有其他组件属性都是 RxJSSubjects,那么使用 RxJS 操作符表达逻辑会使代码过于复杂。代码确实可能很复杂,但通常它没有禁用
OnPush策略并直接突变组件状态的好处。那么就别用OnPush。在罕见的情况下,你甚至可以手动使用ChangeDetectorRef触发变更检测。这些技术不能替代不可变的 ngrx 状态,它们也不提供与 ngrx 相同的数据控制级别。但它们有助于避免由状态突变引起的问题。
15.3.3. ngrx 代码更难阅读
动作和还原器引入了间接性,如果不小心使用,会迅速使你的代码变得复杂。新员工需要花费更多的时间才能使你的应用变得高效,因为每个组件都不能孤立地理解。你可能会说,任何不使用 ngrx 的 Angular 应用也是如此,但我们对此持不同意见,原因有两个。
首先,组件和服务在每一个 Angular 应用中看起来几乎一样,但每个 ngrx 项目都有自己的方法来实现和组织动作、还原器、存储选择器和效果。动作可以被定义为变量、类、接口和枚举。它们可以直接作为 ES 模块成员暴露,或者分组到类中。同样适用于还原器。
第二,支持动作和还原器需要编写额外的代码,这些代码在你的应用中原本是不存在的——这不仅仅是将现有应用代码从组件移动到 ngrx 实体。如果你的组件已经很复杂,使用 ngrx 可能会使代码难以阅读。
15.3.4. 学习曲线
ngrx 显著增加了学习曲线。你需要学习
@ngrx/store和@ngrx/effects包是如何工作的。你可能还想要学习@ngrx/entity包,它有助于规范化关系数据。如果你有与关系数据库一起工作的经验,你知道如何轻松地将位于相关表中的数据连接起来。使用@ngrx/entity可以消除创建嵌套 JavaScript 对象(例如,具有嵌套订单的客户对象)和编写复杂还原器的需求。你还需要熟悉 RxJS 库。这并不是什么火箭科学,但如果你已经在学习 Angular、TypeScript 和所有工具,那么推迟添加你可以不用的库会更明智。
15.3.5. 结论
好的库是那些让你写更少代码的库。目前,ngrx 需要你编写大量的额外代码,但我们希望 ngrx 的未来版本将更容易实现和理解。同时,关注一个有前途的状态管理库 NGXS(见
ngxs.gitbooks.io/ngxs),它不需要你像 ngrx 那样编写那么多代码,并且基于 TypeScript 装饰器。另一个名为 ngrx-data (mng.bz/h6Nc) 的新项目承诺以更少的编码支持 ngrx/Redux 的工作流程。从使用
BehaviorSubject的单例可注入服务来管理状态开始。这种方法可能满足你的所有需求。观看 Yakov Fain 的视频“当 ngrx 是过度时”(www.youtube.com/watch?v=xLTIDs0CDCM),其中他比较了两个使用和不使用 ngrx 管理状态的小型应用。给你的应用添加 ngrx 从来不晚,所以不要试图过早地解决你尚未面临的问题。现在让我们看看如何在你的 ngAuction 应用中使用 ngrx。
15.4. 实践:在 ngAuction 中使用 ngrx
本章附带的自带 ngAuction 应用使用 ngrx 进行状态管理。它是模块化的,具有根模块
AppModule和两个功能模块,HomeModule和ProductModule。由于您的功能模块是懒加载的,我们已为每个模块添加了目录存储,该存储又包含其自己的子目录:actions、effects 和 reducers,如图 15.13 所示。尽管此项目有三个名为 store 的目录,但运行中的应用将有一个包含来自每个模块合并状态的单一存储。图 15.13。应用和 home 模块中的状态分支
![]()
在每个文件夹中——actions、effects 和 reducers——我们都有与特定状态切片相关的单独文件。例如,您可以在这些文件夹中找到一个单独的 search.ts 文件,它实现了每个文件夹中的相应搜索功能。
应用状态不仅可以表示数据(例如最新的搜索查询或结果),还可以表示 UI 的状态(例如,加载指示器是否显示或隐藏)。您可能还想知道路由的当前状态和浏览器显示的 URL。
图 15.14 展示了运行中的 ngAuction 的组合状态。以粗体斜体显示的还原器名称,箭头指向每个还原器处理的状态属性。特别是,
products还原器的loading属性可能代表进度指示器的状态。我们还将使用router还原器添加路由支持。图 15.14。ngAuction 的组合状态对象
![]()
路由还原器是特殊的,因为您不需要在您的应用中实现它,因为它在
@ngrx/router-store中定义,将在下一节中介绍。您的 ngAuction 在 package.json 中将@ngrx/router-store包作为依赖项。图 15.15 展示了 ngAuction 启动后用户导航到特定产品页面时的 Redux DevTools 截图。注意那里的路由属性。图 15.15 中的应用状态与图 15.14 中所示的状态结构相匹配:
-
已选择状态选项卡。
-
您可以看到状态的一个
search切片,一个router切片,一个homePage切片和一个productPage切片。
图 15.15。Redux DevTools 中的 ngAuction 状态
![]()
要运行本章附带的自带 ngAuction,您需要打开两个终端窗口,一个用于客户端,一个用于服务器。进入服务器目录,并在那里运行
npm install。然后,使用tsc命令编译代码,并使用node build/main命令启动服务器。之后,在客户端目录中打开另一个单独的终端窗口,并运行npm install命令,然后运行ng serve。我们建议您保持 Redux DevTools 打开以监控应用状态变化。注意
为了使本节的长度相对较短,我们将仅回顾实现首页模块状态管理的代码,并给你一个关于路由状态的简要概述。产品模块的状态管理以类似的方式实现。
ngAuction 使用四个 ngrx 模块:
StoreModule、EffectsModule、StoreRouterConnectingModule和StoreDevtoolsModule,并且每个模块的包都包含在 package.json 的依赖项部分。让我们回顾应用模块中与路由相关的代码。15.4.1. 将路由状态支持添加到应用模块
当你选择一个产品时,路由会导航到相应的产品视图,URL 也会相应地改变——例如,http://localhost:4200/products/1。选择另一个产品将改变路由状态,你还可以将这些类型的更改绑定到应用状态。下一个列表显示了来自 app.module.ts 的代码片段,重点关注与路由状态支持相关的代码。
列表 15.25. app.module.ts
import {EffectsModule} from '@ngrx/effects'; import {StoreRouterConnectingModule, routerReducer} from '@ngrx/router-store'; *1* import {StoreModule} from '@ngrx/store'; import {StoreDevtoolsModule} from '@ngrx/store-devtools'; import {environment} from '../environments/environment'; import {reducers, RouterEffects, *2* SearchEffects} from './store'; ... @NgModule({ imports: [ ... StoreModule.forRoot({...reducers, router: routerReducer}), *3* StoreRouterConnectingModule.forRoot({ *4* stateKey: 'router' *5* }), StoreDevtoolsModule.instrument({ name: 'ngAuction DevTools', logOnly: environment.production }), EffectsModule.forRoot([RouterEffects, SearchEffects]), *6* ... ], ... }) export class AppModule {-
1 导入 store 模块和路由状态的 reducer
-
2 导入路由效果
-
3 将 routerReducer 添加到应用 reducer 集合
-
4 添加路由状态支持
-
5 将路由状态属性命名为 router
-
6 RouterEffects 监听路由事件并派发由 routerReducer 处理的 ngrx 动作。
提示
下一个部分提供了更多关于加载 reducer 的行的详细信息,同时回顾了首页模块的 index.ts 文件的代码。
在 store 中,路由状态的名字由映射到路由 reducer 的属性名(例如,
router)定义。在你的应用中,你将使用默认的routerReducer并将其添加到应用 reducer 集合中:StoreModule.forRoot({...reducers, router: routerReducer}),stateKey属性值的用途是在 store 中找到路由状态并将其连接到 Redux DevTools,以便在调试期间进行时间旅行。分配给stateKey(在你的情况下是router)的值必须与提供给forRoot()方法的 reducer 映射中使用的属性名匹配。要访问路由状态的特定属性,你可以使用 ngrxselect运算符在由router变量表示的对象上。访问整个路由状态可能会导致 Redux DevTools 崩溃,这就是我们创建自定义路由状态序列化器的原因,以保持 store 中只有你需要的状态属性。在 shared/services/router-state-serializer.service.ts 文件中,我们实现了一个序列化器,它返回一个只包含
url、params和queryParams的对象。如果我们没有实现这个序列化器,图 15.14 中显示的路由状态将包含大量的嵌套属性。15.4.2. 在首页模块中管理状态
当 home 模块被懒加载时,其 reducer 被注册到 store 中,并且其状态对象与根状态合并。为了实现这一点,请在以下列表中添加以下行到 home.module.ts 文件中声明 store、reducer 和 effects。
列表 15.26. home.module.ts 的一个片段
import {CategoriesEffects, ProductsEffects, reducers} from './store'; ... @NgModule({ imports: [ ... StoreModule.forFeature('homePage', reducers), *1* EffectsModule.forFeature([ CategoriesEffects, ProductsEffects ]) *2* ]-
1 为功能 home 模块注册 reducer
-
2 为功能 home 模块注册 effects
Tip
forFeature()和forRoot()方法之间的区别在于后者还设置了StoreModule中服务的所需提供者。home 模块在 store/reducers/products.ts 和 store/reducers/categories.ts 文件中有 reducer。请注意,您不是从特定文件导入 reducer,而是从 store 目录导入,并且您可以猜测该目录有一个名为 index.ts 的文件,该文件将多个文件中的 reducer 合并并重新导出。您将在本节稍后看到 index.ts 的内容。
产品动作
在 ngAuction 中,
CategoriesComponent作为 home 视图的容器,在 home 视图中渲染类别标签和产品网格。图 15.16 显示"[Products] Load All"是应用发出的第一个动作。然后它发出"[Categories] Load"。当数据加载完成后,效果会发出两个额外的动作:"[Products] Load All Success"和"[Categories] Load Success"。图 15.16. 从所有类别加载产品
![]()
类别动作在 home/store/actions/categories.ts 文件中声明,产品动作在 home/store/actions/products.ts 文件中声明。我们只回顾 home/store/actions/products.ts 的内容;类别动作以类似的方式声明。
在 ngAuction 中,每个包含动作的文件通常由三个逻辑部分组成:
-
包含定义动作类型的字符串常量的
enum。您可以在mng.bz/sTmp了解 TypeScript 枚举。 -
实现了
Action接口的动作类(每个动作一个类)。 -
结合所有动作类的联合类型。您将在 reducer 和 effects 中使用此类型,以便 TypeScript 编译器可以检查动作类型是否正确,例如
ProductsActionTypes.Load。
下面的列表展示了在 home/store/actions/products.ts 文件中如何声明动作。
列表 15.27. home/store/actions/products.ts
import {Action} from '@ngrx/store'; import {Product} from '../../../shared/services'; export enum ProductsActionTypes { *1* Load = '[Products] Load All', Search = '[Products] Search', LoadFailure = '[Products] Load All Failure', LoadSuccess = '[Products] Load All Success', LoadProductsByCategory = '[Products] Load Products By Category' } export class LoadProducts implements Action { *2* readonly type = ProductsActionTypes.Load; *3* } export class LoadProductsByCategory implements Action { *2* readonly type = ProductsActionTypes.LoadProductsByCategory; *3* constructor(public readonly payload: {category: string}) {} *4* } export class LoadProductsFailure implements Action { *2* readonly type = ProductsActionTypes.LoadFailure; *3* constructor(public readonly payload: {error: string}) {} *4* } export class LoadProductsSuccess implements Action { *2* readonly type = ProductsActionTypes.LoadSuccess; *3* constructor(public readonly payload: {products: Product[]}) {} *4* } export class SearchProducts implements Action { *2* readonly type = ProductsActionTypes.Search; *3* constructor(public readonly payload: {params: {[key: string]: any}}) {} *4* } export type ProductsActions *5* = LoadProducts | LoadProductsByCategory | LoadProductsFailure | LoadProductsSuccess | SearchProducts;-
1 将允许的动作类型声明为字符串常量的枚举
-
2 声明动作的类
-
3 声明动作类型
-
4 使用构造函数参数声明动作有效载荷
-
5 声明允许的动作的联合类型
如您所见,一些动作类只包含动作类型,而另一些则包含有效载荷。
CategoriesComponent
与第十四章 版本 相比,
CategoriesComponent的代码已更改。与状态管理相关的 categories.component.ts 文件片段显示在 列表 15.28 中。在CategoriesComponent的构造函数中,你订阅了路由参数。当此组件接收到分类值时,它要么分发动作以加载所有分类的产品,要么只加载所选的一个。列表 15.28. categories.component.ts 的片段
import { getCategoriesData, getProductsData, *1* LoadCategories, LoadProducts, LoadProductsByCategory, *2* State } from '../store'; @Component({...}) export class CategoriesComponent implements OnDestroy { readonly categories$: Observable<string[]>; readonly products$: Observable<Product[]>; constructor(private route: ActivatedRoute, private store: Store<State>) { *3* this.products$ = this.store.pipe(select(getProductsData)); this.categories$ = this.store.pipe( *4* select(getCategoriesData), map(categories => ['all', ...categories]) *5* ); this.productsSubscription = this.route.params.subscribe( ({ category }) => this.getCategory(category) *6* ); this.store.dispatch(new LoadCategories()); *7* } private getCategory(category: string): void { return category.toLowerCase() === 'all' ? this.store.dispatch(new LoadProducts()) *8* : this.store.dispatch(new LoadProductsByCategory( *9* {category: category.toLowerCase()})); } }-
1 导入 ngrx selectors
-
2 导入 ngrx actions
-
3 注入 Store 对象
-
4 订阅要渲染为标签的分类
-
5 将所有元素添加到分类名称数组中
-
6 加载所选或所有分类
-
7 分发动作以加载分类
-
8 分发动作以加载所有产品
-
9 分发动作以按分类加载产品
产品还原器
主模块有两个还原器:一个用于产品,一个用于分类。以下列表显示了产品和分类的还原器和选择器。
列表 15.29. home/store/reducers/products.ts
import {Product} from '../../../shared/services'; import {ProductsActions, ProductsActionTypes} from '../actions'; export interface State { *1* data: Product[]; loading: boolean; loadingError?: string; } export const initialState: State = { *2* data: [], loading: false }; export function reducer(state = initialState, action: ProductsActions): State { switch (action.type) { case ProductsActionTypes.Load: { *3* return { ...state, loading: true, *4* loadingError: null }; } case ProductsActionTypes.LoadSuccess: { *5* return { ...state, data: action.payload.products, *6* loading: false, loadingError: null }; } case ProductsActionTypes.LoadFailure: { *7* return { ...state, data: [], *8* loading: false, loadingError: action.payload.error *9* }; } default: { return state; } } } export const getData = (state: State) => state.data; *10* export const getDataLoading = (state: State) => state.loading; *10* export const getDataLoadingError = (state: State) => state.loadingError;) *10*-
1 声明产品状态的结构
-
2 初始状态没有产品,加载标志为假。
-
3 处理 Load 动作
-
4 更新加载标志,因为加载开始
-
5 处理 LoadSuccess 动作
-
6 加载产品 - 使用数据更新状态。
-
7 处理 LoadFailure 动作
-
8 如果有的话,删除产品数据
-
9 更新错误信息
-
10 返回状态属性的访问器
产品状态对象有三个属性:包含产品的数组、控制加载指示器的标志,以及如果有任何错误信息的文本。当还原器接收到类型为
Load的动作时,它创建一个新的状态对象,并更新loading属性,该属性可以被组件用于显示进度指示器。如果已经分发了
LoadSuccess动作,则表示产品已成功检索。还原器从动作的payload属性中提取它们,并更新状态对象的data和loading属性。LoadFailure动作表示产品无法检索,还原器从状态对象中删除数据(如果有),更新错误信息,并关闭loading标志。在产品还原器脚本的末尾,你可以看到三条包含如何访问产品状态对象中数据的函数。你在这里定义这些函数,以保持它们与
State接口声明在一起。这些访问器用于创建在 index.ts 中定义的选择器。注意
产品还原器没有代码用于请求数据。记住,与外部存储方通信的代码放在了效果中。
index.ts 在主页还原器中的作用
通常,名为 index.ts 的文件用于重新导出在单独文件中声明的多个成员。这样,如果另一个脚本需要这样的成员,你可以从这个目录中导入这个成员,而不需要知道特定文件的完整路径。在重新导出成员时,你可以给他们新的名称并将它们组合成新的类型。
home/store/reducers/index.ts 文件中有
import * as fromProducts from './products';这一行,要访问从 products.ts 文件导出的成员,你可以使用别名fromProducts作为引用——例如,fromProducts.State或fromProducts.getData()。考虑到这一点,让我们回顾以下列表中 home/store/reducers/index.ts 文件的代码。列表 15.30. home/store/reducers/index.ts
import {createFeatureSelector, createSelector} from '@ngrx/store'; import * as fromRoot from '../../../store'; *1* import * as fromCategories from './categories'; *1* import * as fromProducts from './products'; *1* export interface HomeState { *2* categories: fromCategories.State; products: fromProducts.State; } export interface State extends fromRoot.State { *3* homePage: HomeState; *4* } export const reducers = { *5* categories: fromCategories.reducer, products: fromProducts.reducer }; // The selectors for the home module export const getHomeState = createFeatureSelector<HomeState>('homePage'); export const getProductsState = createSelector(getHomeState, state => state.products); export const getProductsData = createSelector(getProductsState, fromProducts.getData); export const getProductsDataLoading = createSelector(getProductsState, fromProducts.getDataLoading); export const getProductsDataLoadingError = createSelector(getProductsState, fromProducts.getDataLoadingError); export const getCategoriesState = createSelector(getHomeState, state => state.categories); export const getCategoriesData = createSelector(getCategoriesState, fromCategories.getData);-
1 导入各种导出成员并给它们起别名
-
2 结合类别和产品的状态
-
3 通过从根 State 扩展来声明 State 类型
-
4 声明一个名为 homePage 的功能,用于与 StoreModule 或 createFeatureSelector() 一起使用
-
5 将类别和产品的 reducers 结合起来
此脚本从创建描述性别名名称(例如,
fromRoot)开始,这样在知道特定成员来自哪里的情况下阅读代码就更容易了。然后你声明一个HomeState接口,它结合了在产品类别和类别的 reducer 中声明的所有State接口的属性。应用程序商店包含一个状态对象,它可能是一个包含多个分支的复杂对象。每个分支都是由模块 reducer 创建的。当在商店上触发动作时,它会通过每个已注册的 reducer,找到必须处理这个动作的 reducer。reducer 创建一个新的状态并更新全局应用程序状态的相应分支。
在这里,你通过声明扩展
State根并添加一个新的homePage属性来创建家模块分支的表示。你在createFeatureSelector()和 列表 15.26 中使用了这个属性来注册模块家的状态对象。当组合应用程序商店时,ngrx 将homePage对象添加到其中。导出的
reducers成员结合了产品和类别的 reducer。现在再次查看 列表 15.25 中的应用程序模块,它有以下一行:StoreModule.forRoot({...reducers, router: routerReducer})初始时,商店找到并调用每个模块 reducer,它返回相应的状态对象。这就是组合应用程序状态是如何创建的。以下来自 index.ts 的代码片段将
categories和products的名称分配给状态的不同切片:export const reducers = { categories: fromCategories.reducer, products: fromProducts.reducer };在脚本末尾,你声明并导出所有可以用于检索家模块状态切片的选择器。请注意,你使用在各自的 reducer 文件中声明的状态访问器函数。
产品效果
在主模块中,效果位于文件 home/store/effects/categories.ts 和 home/store/effects/products.ts 中,在下面的列表中,我们回顾了后者的代码。
ProductsEffects类声明了三个效果:loadProducts$、loadByCategory$和searchProducts$。列表 15.31. home/store/effects/products.ts
import {Injectable} from '@angular/core'; import {Actions, Effect, ofType} from '@ngrx/effects'; import {Action} from '@ngrx/store'; import {Observable, of} from 'rxjs'; import {catchError, map, switchMap} from 'rxjs/operators'; import {Product, ProductService} from '../../../shared/services'; import {LoadProductsByCategory, LoadProductsFailure, LoadProductsSuccess, ProductsActionTypes, SearchProducts} from '../actions'; @Injectable() export class ProductsEffects { constructor( private readonly actions$: Actions, private readonly productService: ProductService) {} @Effect() loadProducts$: Observable<Action> = this.actions$ .pipe( ofType(ProductsActionTypes.Load), *1* switchMap(() => this.productService.getAll()), *2* handleLoadedProducts() *3* ); @Effect() loadByCategory$: Observable<Action> = this.actions$ .pipe( ofType<LoadProductsByCategory>( *4* ProductsActionTypes.LoadProductsByCategory), *4* map(action => action.payload.category), *5* switchMap(category => this.productService.getByCategory(category)), *6* handleLoadedProducts() *7* ); @Effect() searchProducts: Observable<Action> = this.actions$ .pipe( ofType(ProductsActionTypes.Search), *8* map((action: SearchProducts) => action.payload.params), switchMap(params => this.productService.search(params)), handleLoadedProducts() ); } const handleLoadedProducts = () => *9* (source: Observable<Product[]>) => source.pipe( map(products => new LoadProductsSuccess({products})), catchError(error => of(new LoadProductsFailure({ error }))) );-
1 仅处理加载操作
-
2 尝试加载所有产品
-
3 分发 LoadProductsSuccess 或 LoadProductsFailure
-
4 仅处理按类别加载产品操作
-
5 从有效载荷中提取类别
-
6 尝试按提供的类别加载产品
-
7 分发 LoadProductsSuccess 或 LoadProductsFailure
-
8 仅处理搜索操作
-
9 分发 LoadProductsSuccess 或 LoadProductsFailure 的函数
注意
<LoadProductsByCategory>类型注解在ofType操作符中的使用。这是声明动作有效载荷类型的一种方法。显式声明类型(如map((action: SearchProducts)) 是另一种实现方式。图 15.17 展示了分发类型为
LoadSuccess的动作后的状态:1. 搜索状态为空。
2. 路由状态显示 URL 和参数。
3. 在分发加载成功后,类别状态将被填充。
4. 产品状态包含从服务器检索到的数据。
5.
loading标志为false。6. 没有错误。
图 15.17. 分发
LoadSuccess动作后的状态![图片]()
如同往常,效果分发的动作将由 reducer 处理,reducer 将使用数据或错误信息更新状态。
15.4.3. 单元测试 ngrx reducer
单元测试与状态相关的功能相当简单,因为只有 reducer 可以更改应用状态。记住,reducer 是一个纯函数,如果提供的参数相同,它总是返回相同的输出。
因为每个动作都由一个类表示,所以你只需要实例化动作对象并调用相应的 reducer,向 reducer 提供状态和动作对象。之后,你断言测试下的状态属性具有预期的值。例如,主模块有一个用于产品的 reducer,如下所示定义状态对象。
列表 15.32. 主模块状态中的产品切片
export interface State { data: Product[]; *1* loading: boolean; *2* loadingError?: string; *3* }-
1 当前产品存储于此。
-
2 如果此标志为真,UI 应显示进度指示器。
-
3 如果加载失败,错误信息将存储于此。
让我们回顾 home/store/reducers/products.spec.ts 文件的代码,如下所示列表,它使用此状态对象并断言
loading标志被LoadProducts和LoadProductsSuccess动作正确处理。列表 15.33. home/store/reducers/products.spec.ts
import {LoadProducts, LoadProductsSuccess} from '../actions'; import {initialState, reducer} from './products'; describe('Home page', () => { describe('product reducer', () => { it('sets the flag for a progress indicator while loading products', () => { const loadAction = new LoadProducts(); *1* const loadSuccessAction = new LoadProductsSuccess({products: []}); *2* const beforeLoadingState = reducer(initialState, {} as any); *3* expect(beforeLoadingState.loading).toBe(false); *4* const whileLoadingState = reducer(beforeLoadingState, loadAction); *5* expect(whileLoadingState.loading).toBe(true); *6* const afterLoadingState = reducer(whileLoadingState, loadSuccessAction); *7* expect(afterLoadingState.loading).toBe(false); *8* }); }); });-
1 实例化 LoadProducts 动作
-
2 实例化 LoadProducts 操作
-
3 使用初始状态调用还原器
-
4 断言加载的初始值为假
-
5 提供当前状态和 Load 动作调用还原器
-
6 断言加载标志为真
-
7 提供当前状态和 LoadSuccess 动作调用还原器
-
8 断言加载标志为假
当你使用初始状态调用还原器时,你提供一个空对象并将其转换为
any类型,因此无论提供的动作如何,还原器都必须返回一个有效的状态。检查还原器的代码,并注意switch语句中的default情况。运行ng test命令,Karma 将报告它已成功执行。列表 15.33 规范测试是否正确处理了状态对象中的
loading属性,而不必担心动作负载。但如果你为具有负载的动作编写测试,创建一个包含硬编码数据的存根对象来模拟负载,并调用相应的还原器。这标志着我们对添加到 ngAuction 主模块的 ngrx 代码的审查结束。我们鼓励您自己完成产品模块的代码审查;其与 ngrx 相关的代码类似。
摘要
-
应用状态应该是不可变的。
-
应用逻辑可以从组件中移除,并放置在效果和服务中。
-
组件的方法应该只发送命令(动作)并订阅数据以进行进一步渲染。
-
虽然 ngrx 的学习曲线很陡峭,但使用 ngrx 可能会导致更好的代码组织,这在大型应用中尤为重要。
Angular 6, 7 及更高版本
作者在 Angular 处于早期 alpha 版本时开始编写这本书的第一版。每个新的 Alpha、Beta 和 Release Candidate 都充满了破坏性变更。编写第二版更容易,因为 Angular 成为一个成熟且功能完整的框架。新的大版本每半年发布一次,从一个版本切换到另一个版本并不困难。每个新版本都会与 Google 内部使用的约 600 个 Angular 应用进行测试,以确保向后兼容性。
我们想强调 Angular 6 或未来版本计划引入的一些新功能:
-
Angular Elements— Angular 是开发单页应用程序的一个很好的选择,但创建一个可以添加到现有网页的控件并不是一个简单的任务。Angular Elements 包允许您创建一个自启动的 Angular 组件,该组件由自定义网络元素托管(见 www.w3.org/TR/custom-elements/),可以在任何 HTML 页面上使用。简单来说,您可以定义新的 DOM 元素并将它们注册到浏览器中。在撰写本文时,除 Internet Explorer 外的所有主流浏览器都原生支持自定义元素;对于 IE,您应使用 polyfills。假设有一个使用 JavaScript 和 jQuery 构建的现有网络应用程序。该应用程序的开发者将能够在该应用程序的页面上使用 Angular 组件(打包为自定义元素)。例如,如果您构建了一个报价组件,Angular Elements 将生成一个可以添加到 HTML 页面的脚本,并且您的组件可以在 HTML 页面上使用。以下是一个示例:
<price-quoter stockSymbol="IBM"></price-quoter>如您所猜,
stockSymbol是 Angular 报价组件的@Input参数。如果该组件通过其@Output属性发出自定义事件,则您的网页可以使用标准浏览器 APIaddEventListener()来监听它们。据我们看来,这个杀手级特性将为 Angular 框架打开许多企业之门。Angular Elements 将在 Angular 7 中正式发布。 -
Ivy 渲染器— 这是一款新渲染器的代号,它可以使应用程序的体积更小,编译速度更快。Hello World 应用程序的大小仅为 7 KB(压缩后)和 3 KB(gzip 压缩后)。在构建包的过程中,Ivy 渲染器将消除未使用的代码,这与目前优化包的方式不同。Ivy 渲染器将在 Angular 8 中引入。
-
Bazel 和 Closure Compiler— Bazel 是一个快速构建系统,用于构建谷歌几乎所有软件,包括他们用 Angular 编写的 300 多个应用程序。Bazel 使发布可以作为 npm 包分发的 Angular 代码变得更加容易。Closure Compiler 是用于创建几乎所有谷歌网络应用程序的 JavaScript 艺术品的打包优化器。与 Webpack 和 Rollup 打包器相比,Closure Compiler 生成更小的包,并且在死代码消除方面做得更好。默认情况下,Angular CLI 项目使用 Webpack 4,与旧版本相比,它产生的包更小。
-
组件开发工具包 (CDK)— 此包已被 Angular Material 库使用,该库提供了 30+ 个 UI 组件。Angular 6 引入了树形组件,适用于显示层次数据。新的灵活覆盖组件会根据视口大小自动调整大小和位置。徽章组件可以显示通知标记。如果你不想使用 Angular Material,但想构建自己的 UI 组件库并控制页面布局,你可以使用 CDK。CDK 包含多个子包,包括覆盖、布局、滚动、表格和树。例如,CDK 表格处理行和列,但没有自己的样式。尽管 Angular Material 为 CDK 表格添加了样式,但你可以根据公司指南创建自己的样式。CDK 支持响应式网页设计布局,消除了使用 Flex Layout 或学习 CSS Grid 的需要。Angular 7 通过仅渲染适合屏幕的项来为大量元素列表添加虚拟滚动。Angular 7 还增加了拖放支持。
-
Angular CLI— .angular-cli.json 文件已重命名为 angular.json,其结构也发生了变化。
ng update @angular/cli命令自动将现有的 .angular-cli.json 转换为 angular.json。ng update @angular/core命令更新项目中 package.json 的依赖项到 Angular 的最新版本。如果你需要将现有项目升级到 Angular 6,请阅读 Yakov Fain 的博客,“我如何将十几个项目迁移到 Angular 6 然后到 7”,在mng.bz/qZwC。从 Angular 6 升级到 7 不应需要代码更改。ng new library <name>命令生成一个用于创建库而不是应用程序的项目。此命令将生成一个带有构建系统和测试基础设施的库项目。ng add命令可以将包添加到你的项目中,除了npm install所做的之外,它还可以修改项目中的一些文件,这样你就不需要手动操作。例如,以下命令将安装 Angular Material,将预构建主题添加到 angular.json,将 Material 设计图标添加到 index.html,并将BrowserAnimationsModule添加到@NgModule()装饰器中:ng add @angular/material -
原理图和
ng update——Angular CLI 使用名为* Schematics* 的技术生成工件,该技术使用代码模板为您的项目生成各种工件。如果您决定创建自己的模板并让 Angular CLI 使用它们,Schematics 将帮助您完成这项工作。ng update命令会自动更新您的项目依赖项并执行自动版本修复。使用 Schematics,您将能够创建自己的代码转换,类似于ng update。但您会发现 Angular 6 附带的一些新预构建模板。例如,要为已经包含示例 Angular Material 导航栏代码的root-nav组件生成所有文件,您可以运行以下命令:ng generate @angular/material:materialNav --name=root-nav
我们期待所有将使 Angular 变得更好的新功能!
因此,我们想感谢您阅读我们的书籍。我们希望您喜欢它,并留下积极的反馈,这样 Manning 就会在将来邀请我们编写新版本。
附录 A. ECMAScript 概述
ECMAScript 是一种脚本语言的规范。ECMAScript 语法被应用于多种语言中,其中最流行的实现是 JavaScript。ECMAScript 规范的第一版于 1997 年发布,第六版于 2015 年最终确定。这一版被称为 ES6 或 ES2015。与它的前身 ES5 相比,ES6 引入了大量新特性,附录中涵盖的大部分语法都是关于 ES6 语法的。ES7 于 2016 年最终确定,ES8 于 2017 年确定。ES7 和 ES8 并没有引入很多新的语法元素,但附录的末尾我们将介绍 ES8 的 async/await 语法。
在撰写本文时,大多数网络浏览器完全支持引入了最显著语法添加的 ES6 规范。您可以通过访问 ECMAScript 兼容性网站
mng.bz/ao59来查看 ES6 支持的当前状态。即使您的应用程序的用户使用的是较旧的浏览器,您今天也可以在 ES6/7/8 中进行开发,并使用 Traceur、Babel 或 TypeScript 等转换器将使用最新语法的代码转换为 ES5 版本。我们假设您熟悉 ES5 语法和 API,我们将仅涵盖 ECMAScript 新版本中引入的一些新特性。在本附录中,我们经常将 ES5 中的代码片段与它们的 ES6 等价物进行比较,但 ES6 并没有废弃任何旧语法,因此您可以在未来的网络浏览器或独立的 JavaScript 引擎中安全地运行遗留的 ES5 代码。
A.1. 如何运行代码示例
本附录的代码示例以 .js 扩展名的 JavaScript 文件形式提供。通常,这些代码示例会在控制台产生一些输出,因此您需要打开浏览器控制台来查看输出。您可以创建一个简单的 HTML 文件,并使用
<script>标签在其中包含特定的 .js 文件。另一个选择是使用 CodePen(请参阅
codepen.io)。该网站允许您快速编写、测试和分享使用 HTML、CSS 和 JavaScript 的应用程序。为了节省您的输入,我们将提供大多数代码示例的 CodePen 链接,您可以只需点击链接,查看所选代码示例的实际效果,并根据需要对其进行修改。如果代码示例在控制台产生输出,只需点击 CodePen 窗口底部的“控制台”即可查看。让我们回顾一下 ECMAScript 在 JavaScript 中实现的一些相对较新的特性。
A.2. 变量和 this 的作用域
ES5 中的作用域机制相当复杂。无论我们使用
var关键字在哪里声明变量,声明都会被移动到执行上下文(例如,一个函数)的顶部。这被称为 提升(有关提升的更多信息,请参阅mng.bz/3x9w)。this关键字的用法也不像 Java 或 C# 等语言那样直接。ES6 通过引入
let关键字消除了这种提升混淆(将在下一节讨论),并通过使用箭头函数解决了this混淆问题。让我们更仔细地看看提升和this问题。A.2.1. 变量声明的提升
在 JavaScript 中,所有使用
var关键字的变量声明都会被移动到执行上下文的顶部,即使变量是在代码块内部声明的。看看以下简单示例,它将变量i声明在for循环内部,但也在外部使用:function foo() { for (var i=0; i<10; i++) { } console.log("i=" + i); } foo();运行此代码将打印
i=10。变量i即使看起来似乎只应在循环内部使用,但在循环外部仍然可用。JavaScript 会自动将变量声明提升到顶部。在前面的示例中,提升没有造成任何损害,因为只有一个名为
i的变量。然而,如果在函数内部和外部声明了具有相同名称的两个变量,这可能会导致令人困惑的行为。考虑列表 A.1,它在全局作用域中声明了变量customer。稍后,我们将在局部作用域中引入另一个customer变量,但现在让我们将其注释掉。列表 A.1. 变量声明的提升
var customer = "Joe"; (function () { console.log("The name of the customer inside the function is " + customer); /* if (true) { var customer = "Mary"; } */ })(); console.log("The name of the customer outside the function is " + customer);全局变量
customer在函数内部和外部都是可见的,运行此代码将打印以下内容:The name of the customer inside the function is Joe The name of the customer outside the function is Joe取消注释在花括号内声明和初始化
customer变量的if语句。现在我们有两个具有相同名称的变量——一个在全局作用域中,另一个在函数作用域中。控制台输出现在不同了:The name of the customer inside the function is undefined The name of the customer outside the function is Joe原因是,在 ES5 中,变量声明被提升到作用域的顶部(最顶层括号内的表达式),但带有值的变量初始化则不会。当创建变量时,其初始值是
undefined。第二个未定义的customer变量的声明被提升到表达式的顶部,console.log()打印了函数内部声明的变量值,这覆盖了全局变量customer的值。^([1]) 函数声明也会提升,因此我们可以在声明之前调用函数:¹
在 CodePen 上查看:
mng.bz/cK9y。doSomething(); function doSomething() { console.log("I'm doing something"); }另一方面,函数表达式被视为变量初始化,因此不会提升。以下代码片段将为
doSomething变量产生undefined:doSomething(); var doSomething = function() { console.log("I'm doing something"); }让我们看看 ES6 如何帮助我们处理作用域问题。
A.2.2. 使用 let 和 const 的块级作用域
使用 ES6 的
let关键字而不是var声明变量,允许变量具有块级作用域。下一个列表显示了一个示例。列表 A.2. 块级作用域的变量
let customer = "Joe"; (function () { console.log("The name of the customer inside the function is " + customer); if (true) { let customer = "Mary"; console.log("The name of the customer inside the block is " + customer); } })(); console.log("The name of the customer in the global scope is " + customer);现在,两个
customer变量具有不同的作用域和值,并且此程序将打印以下内容:The name of the customer inside the function is Joe The name of the customer inside the block is Mary The name of the customer in the global scope is Joe简单来说,如果我们正在开发一个新应用程序,我们不使用
var。我们使用let。let关键字允许我们多次将值分配给变量。唯一例外的是for循环。使用let声明循环变量可能会导致性能问题。如果我们想要声明一个初始化后其值不会改变的变量,我们使用
const关键字来声明常量。常量也支持块作用域。let和const之间的唯一区别是后者不允许更改分配的值。最佳实践是使用const开始声明变量;如果我们看到这个值需要改变,我们就将const替换为let。列表 A.2 应该使用const而不是let,因为我们从未重新分配过两个customer变量的值.^([2])²
在 CodePen 上查看:
mng.bz/fkJdA.3. 模板字面量
ES6 引入了一种新的字符串字面量语法,它可以包含嵌入的表达式。这个特性被称为 字符串插值。
在 ES5 中,我们会使用连接来创建一个包含字符串字面量和变量值的字符串:
const customerName = "John Smith"; console.log("Hello" + customerName);在 ES6 中,模板字面量由反引号包围,我们可以在字面量中嵌入表达式,只需将它们放在大括号之间,并以前面加上美元符号即可。在下一个代码片段中,变量
customerName的值被嵌入到字符串字面量中:const customerName = "John Smith"; console.log(`Hello ${customerName}`); function getCustomer() { return "Allan Lou"; } console.log(`Hello ${getCustomer()}`);这段代码的输出如下所示:^([3])
³
在 CodePen 上查看:
mng.bz/Ey30Hello John Smith Hello Allan Lou在前面的例子中,我们将变量
customerName的值嵌入到模板字面量中,然后嵌入getCustomer()函数返回的值。我们可以在大括号之间放置任何有效的 JavaScript 表达式,以嵌入任何有效的 JavaScript 表达式。A.3.1. 多行字符串
在我们的代码中,字符串可以跨越多行。使用反引号,我们可以编写多行字符串,而无需将它们连接或使用反斜杠字符:
const message = `Please enter a password that has at least 8 characters and includes a capital letter`; console.log(message);生成的字符串将把所有空格视为字符串的一部分,因此输出将看起来像这样:^([4])
⁴
在 CodePen 上查看:
mng.bz/1SSP。Please enter a password that has at least 8 characters and includes a capital letterA.4. 可选参数和默认值
在 ES6 中,我们可以为函数参数(参数)指定默认值,如果函数调用时没有提供值,则将使用这些默认值。比如说,我们正在编写一个计算税款的函数,它接受两个参数:年收入和居住州。如果未提供州值,我们希望使用佛罗里达州作为默认值。
在 ES5 中,我们需要在函数体开始时检查是否提供了状态值;如果没有提供,则使用佛罗里达州:
function calcTaxES5(income, state) { state = state || "Florida"; console.log("ES5\. Calculating tax for the resident of " + state + " with the income " + income); } calcTaxES5(50000);这段代码的输出如下:
"ES5\. Calculating tax for the resident of Florida with the income 50000"在 ES6 中,我们可以直接在函数签名中指定默认值:
function calcTaxES6(income, state = "Florida") { console.log("ES6\. Calculating tax for the resident of " + state + " with the income " + income); } calcTaxES6(50000);输出看起来类似:^([5])
⁵
在 CodePen 上查看:
mng.bz/U51z。"ES6\. Calculating tax for the resident of Florida with the income 50000"而不是为可选参数提供一个硬编码的值,我们可以调用一个返回一个值的函数:
function calcTaxES6(income, state = getDefaultState()) { console.log("ES6\. Calculating tax for the resident of " + state + " with the income " + income); }; function getDefaultState() { return "Florida"; } calcTaxES6(50000);只需记住,每次我们调用
calcTaxES6()时,都会调用getDefaultState()函数,这可能会产生性能影响。这种可选参数的新语法意味着我们编写的代码更少,代码也更容易理解。A.5. 箭头函数表达式、this 和 that
ES6 引入了箭头函数表达式,它为匿名函数提供了一种更短的表示法,并为
this变量添加了词法作用域。在某些其他编程语言(如 C#和 Java)中,类似的语法被称为lambda 表达式。箭头函数表达式的语法由参数、粗箭头符号(
=>)和函数体组成。如果函数体只有一个表达式,我们甚至不需要大括号。如果单表达式函数返回一个值,则不需要编写return语句——结果会隐式返回:let sum = (arg1, arg2) => arg1 + arg2;多行箭头函数表达式的主体应该用大括号括起来,并使用显式的
return语句:(arg1, arg2) => { // do something return someResult; }如果箭头函数没有任何参数,则使用空括号:
() => { // do something return someResult; }如果函数只有一个参数,则括号不是必需的:
arg1 => { // do something }在以下代码片段中,我们将箭头函数表达式作为参数传递给数组的
reduce()方法来计算总和,以及filter()来打印偶数:const myArray = [1, 2, 3, 4, 5]; console.log( "The sum of myArray elements is " + myArray.reduce((a,b) => a+b)); // prints 15 console.log( "The even numbers in myArray are " + myArray.filter( value => value % 2 === 0)); // prints 2 4现在你已经熟悉了箭头函数的语法,让我们看看它们如何简化与
this对象引用的工作。在 ES5 中,确定
this关键字所引用的对象并不总是简单的事情。在网上搜索“JavaScript this and that”,你会找到多个帖子,人们抱怨this指向“错误”的对象。this引用的值可能因函数的调用方式和是否使用了严格模式而不同(请参阅 Mozilla 开发者网络上的严格模式文档mng.bz/VNVL)。我们将首先说明这个问题,然后展示 ES6 提供的解决方案。考虑以下代码列表,它每秒调用一次匿名函数。该函数打印提供给
StockQuoteGenerator()构造函数的股票符号的随机生成价格。列表 A.3.
this和thatfunction StockQuoteGenerator(symbol) { // this.symbol = symbol; const that = this; that.symbol = symbol; setInterval( function () { console.log("The price of " + that.symbol + " is " + Math.random()); }, 1000); } const stockQuoteGenerator = new StockQuoteGenerator("IBM");在列表 A.3 中注释掉的行展示了在匿名函数中需要值时,使用
this的错误方式。如果我们没有在that中保存this的值,并在匿名函数中使用this.symbol,它将打印undefined而不是IBM。你会在函数在setInterval ()内部调用时看到这种行为,同样,如果函数在任何回调中调用,也会出现这种行为。在回调内部,this将指向全局对象,这与StockQuoteGenerator()构造函数定义的this不同.^([6]) 确保函数在特定this对象中运行的另一种解决方案是使用 JavaScript 的call()、apply()或bind()函数。⁶
在 CodePen 上查看:
mng.bz/cK70。注意
如果你不太熟悉 JavaScript 中的
this问题,请查看 Richard Bovell 的文章,“清晰理解 JavaScript 的this,并掌握它”在mng.bz/ZQfz。以下列表展示了消除在
that中存储this的需要的箭头函数解决方案。列表 A.4. 粗箭头函数
function StockQuoteGenerator(symbol) { this.symbol = symbol; setInterval( () => { console.log("The price of " + this.symbol + " is " + Math.random()); }, 1000); } const stockQuoteGenerator = new StockQuoteGenerator("IBM");列表 A.4 将正确解决
this引用。作为setInterval ()参数传递的箭头函数使用封装上下文的this值,因此它将识别IBM为this.symbol的值。A.6. 剩余操作符
在 ES5 中,编写具有可变数量参数的函数需要使用特殊的
arguments对象。此对象类似于数组,并包含与传递给函数的参数相对应的值。隐式的arguments变量可以在任何函数中作为局部变量处理。剩余操作符表示函数中的可变数量参数,并且它必须是参数列表中的最后一个。如果函数参数的名称以三个点开头,函数将获取剩余的参数作为一个数组。ES6 的剩余操作符用三个点(...)表示。
例如,我们可以使用单个变量名和剩余操作符将多个客户传递给一个函数:
function processCustomers(...customers) { // implementation of the function goes here }在这个函数内部,我们可以像处理任何数组一样处理
customers数据。想象一下,我们需要编写一个计算税款的函数,该函数必须使用第一个参数income调用,然后是任何数量的表示客户名称的参数。列表 A.5 展示了如何使用 ES5 和 ES6 语法处理可变数量的参数。calcTaxES5()函数使用名为arguments的对象,而calcTaxES6()函数使用 ES6 剩余操作符。列表 A.5. 剩余操作符
// ES5 and arguments object function calcTaxES5() { console.log("ES5\. Calculating tax for customers with the income ", arguments[0]); // income is the first element // extract an array starting from 2nd element var customers = [].slice.call(arguments, 1); customers.forEach(function (customer) { console.log("Processing ", customer); }); } calcTaxES5(50000, "Smith", "Johnson", "McDonald"); calcTaxES5(750000, "Olson", "Clinton"); // ES6 and rest operator function calcTaxES6(income, ...customers) { console.log(`ES6\. Calculating tax for customers with the income ${income}`); customers.forEach( (customer) => console.log(`Processing ${customer}`)); } calcTaxES6(50000, "Smith", "Johnson", "McDonald"); calcTaxES6(750000, "Olson", "Clinton");两个函数,
calcTaxES5()和calcTaxES6(),产生相同的结果:^([7])⁷
在 CodePen 上查看:
mng.bz/I2zq。ES5\. Calculating tax for customers with the income 50000 Processing Smith Processing Johnson Processing McDonald ES5\. Calculating tax for customers with the income 750000 Processing Olson Processing Clinton ES6\. Calculating tax for customers with the income 50000 Processing Smith Processing Johnson Processing McDonald ES6\. Calculating tax for customers with the income 750000 Processing Olson Processing Clinton然而,在处理客户方面存在差异。因为
arguments对象不是一个真正的数组,所以在 ES5 版本中,我们必须使用slice()和call()方法创建一个数组,以提取从arguments的第二个元素开始的客户名称。ES6 版本不需要我们使用这些技巧,因为剩余运算符给我们一个客户的常规数组。使用剩余参数使代码更简单、更易读。A.7. 扩展运算符
ES6 扩展运算符也用三个点(...)表示,就像剩余运算符一样,但与剩余运算符可以将可变数量的参数转换为数组不同,扩展运算符可以执行相反的操作:将数组转换为值或函数参数的列表。
提示
如果你看到等号右侧有三个点,那就是扩展运算符。等号左侧的三个点表示剩余运算符。
假设我们有两个数组,我们需要将第二个数组的元素添加到第一个数组的末尾。使用扩展运算符,这只需要一行代码:
let array1= [...array2];在这里,扩展运算符提取
myArray的每个元素并将其添加到新数组中(这里,方括号表示“创建一个新数组”)。我们也可以按照以下方式创建数组的副本:array1.push(...array2);使用扩展运算符在数组中找到最大值也很简单:
const maxValue = Math.max(...myArray);在某些情况下,我们想要克隆一个对象。例如,假设我们有一个存储我们应用状态的对象,并且当状态属性之一发生变化时,我们想要创建一个新的对象。我们不想修改原始对象,但想通过修改一个或多个属性来克隆它。实现不可变对象的一种方法是通过使用
Object.assign()函数。以下列表首先创建对象的克隆,然后创建另一个克隆,同时更改lastName。列表 A.6. 使用
assign()进行克隆// Clone with Object.assign() const myObject = {name: "Mary" , lastName: "Smith"}; const clone = Object.assign({}, myObject); console.log(clone); // Clone with modifying the `lastName` property const cloneModified = Object.assign({}, myObject, {lastName: "Lee"}); console.log(cloneModified);如以下列表所示,扩展运算符提供了更简洁的语法来实现相同的目标。
列表 A.7. 使用
spread进行克隆// Clone with spread const myObject = { name: "Mary" , lastName: "Smith"}; const cloneSpread = {...myObject}; console.log(cloneSpread); // Clone with modifying the `lastName` const cloneSpreadModified = {...myObject, lastName: "Lee"}; console.log(cloneSpreadModified);我们的
myObject有两个属性:name和lastName。即使我们或其他人向myObject添加更多属性,克隆myObject并修改lastName的那行代码仍然会正常工作。^([8])⁸
在 CodePen 中查看:
mng.bz/X2pL。在 第十五章 中,我们向您展示了如何处理不可变状态对象。在那里,我们使用扩展运算符来克隆状态对象。
A.8. 生成器函数
当浏览器执行 JavaScript 函数时,它会连续运行,不会打断自己的流程。但生成器函数的执行可以被暂停和恢复多次。生成器函数可以将控制权交回调用脚本,该脚本在同一个线程上运行。
一旦生成器函数中的代码达到
yield关键字,它就会被挂起,调用脚本可以通过在生成器上调用next()来恢复函数的执行。要将普通函数转换为生成器,我们需要在function关键字和函数名之间放置一个星号。以下是一个示例:function* doSomething() { console.log("Started processing"); yield; console.log("Resumed processing"); }当我们调用这个函数时,它不会立即执行函数代码,而是返回一个特殊的
Generator对象,该对象作为迭代器。以下行不会打印任何内容:var iterator = doSomething();要开始执行函数的主体,我们需要在生成器上调用
next()方法:iterator.next();在前面的行之后,
doSomething()函数将打印“Started processing”并由于yield运算符而暂停。再次调用next()将打印“Resumed processing。”当我们需要编写一个函数来生成一系列数据,但又想控制何时处理下一个流值时,生成器函数非常有用。想象一下,我们需要一个函数来检索并生成指定股票符号(如 IBM)的股票价格。如果股票价格低于指定的值(限制价格),我们想购买这只股票。
下一个列表中的生成器函数
getStockPrice()通过使用Math.random()生成随机价格来模拟这种场景。列表 A.8.
getStockPrice()function* getStockPrice(symbol) { while (true) { yield Math.random()*100; console.log(`resuming for ${symbol}`); } }如果
yield之后有值,它会被返回给调用者,但函数还没有完成。即使getStockPrice()函数有一个无限循环,它也只有在调用getStockPrice()的脚本在生成器上调用next()时才会产生(返回)价格,如下面的列表所示。列表 A.9. 调用
getStockPrice ()function* getStockPrice(symbol) { while (true) { yield Math.random()*100; console.log(`resuming for ${symbol}`); } } const priceGenerator = getStockPrice("IBM"); *1* const limitPrice = 15; *2* let price = 100; while (price > limitPrice) { *3* price = priceGenerator.next().value; *4* console.log (`The generator returned ${price}`); } console.log(`buying at ${price} !!!`); *5*-
1 创建了生成器对象,但没有执行 getStockPrice()函数的主体
-
2 将限制价格设置为 15 美元,初始价格设置为 100 美元
-
3 持续请求股票价格,直到它们低于 15 美元
-
4 请求下一个价格并将其打印到控制台
-
5 如果价格低于 15 美元,循环结束,程序将打印一条关于购买股票及其价格的消息。
运行列表 A.9 将打印出类似以下内容:
The generator returned 61.63144460879266 resuming for IBM The generator returned 96.60782956052572 resuming for IBM The generator returned 31.163037824444473 resuming for IBM The generator returned 18.416578718461096 resuming for IBM The generator returned 55.80756475683302 resuming for IBM The generator returned 14.203652134165168 buying at 14.203652134165168 !!!注意消息的顺序。当我们对
priceGenerator调用next()方法时,暂停的getStockPrice()方法的执行会从yield下面的行恢复,打印出"resuming for IBM"。尽管控制流已经离开了函数,然后又回来,getStockPrice()仍然记得symbol的值是IBM。当yield运算符将控制权返回给外部脚本时,它会创建一个堆栈快照,以便记住所有局部变量的值。当生成器函数恢复执行时,这些值还没有丢失.^([9])⁹
在 CodePen 上查看:
mng.bz/4d40.使用生成器,你可以将某些操作的实现(如获取价格报价)与这些操作产生的数据的消费分离。数据的消费者会懒加载评估结果,并决定是否需要请求更多数据。
A.9. 解构
创建对象的实例意味着在内存中构建它们。术语 解构 指的是将对象拆分。在 ES5 中,我们可以通过编写一个函数来解构任何对象或集合。ES6 引入了解构赋值语法,允许我们通过指定一个 匹配模式,以简单的表达式从对象的属性或数组中提取数据。通过示例更容易解释,我们将在下面进行演示。
A.9.1. 对象解构
假设一个
getStock()函数返回一个具有symbol和price属性的Stock对象。在 ES5 中,如果我们想要将这些属性的值分配给单独的变量,我们需要首先创建一个变量来存储Stock对象,然后编写两个语句将对象属性分配给相应的变量:var stock = getStock(); var symbol = stock.symbol; var price = stock.price;从 ES6 开始,我们只需要在左侧写一个匹配模式,并将
Stock对象分配给它:let {symbol, price} = getStock();在等号左侧看到花括号有点不寻常,但这匹配表达式的语法的一部分。当我们看到左侧的花括号时,我们会认为它们是一个代码块,而不是对象字面量。以下列表演示了从
getStock()函数获取Stock对象并将其解构为两个变量的过程。列表 A.10. 对象解构
function getStock() { return { symbol: "IBM", price: 100.00 }; } let {symbol, price} = getStock(); console.log(`The price of ${symbol} is ${price}`);运行该脚本将打印以下内容:
The price of IBM is 100换句话说,我们在一个赋值表达式中将一组数据(在这种情况下是对象属性)绑定到一组变量(
symbol和price)。即使Stock对象有超过两个属性,前面的解构表达式仍然有效,因为symbol和price会匹配模式。匹配表达式只列出我们感兴趣的属性变量。^([10])¹⁰
在 CodePen 中查看:
mng.bz/CI47。列表 A.10 之所以有效,是因为变量的名称与
Stock属性的名称相同。让我们将symbol改为sym:let {sym, price} = getStock();现在,我们会得到一个错误“symbol 未定义”,因为 JavaScript 不知道对象的
symbol属性应该分配给变量sym。这是一个错误匹配模式的例子。如果我们真的想将名为sym的变量映射到symbol属性,我们可以为symbol引入一个别名:let {sym: symbol, price} = getStock();如果我们在左侧提供比对象属性数量更多的变量,额外的变量将获得
undefined的值。如果我们添加一个stockExchange变量在左侧,它将被初始化为undefined,因为getStock()返回的对象中没有这样的属性:let {symbol, price, stockExchange} = getStock(); console.log(`The price of ${symbol} is ${price} ${stockExchange}`);如果我们将前面的解构赋值应用到同一个
Stock对象上,控制台输出将如下所示:The price of IBM is 100 undefined如果我们想让
stockExchange变量有一个默认值,例如"NASDAQ",我们可以将解构表达式重写如下:let {symbol, price, stockExchange = "NASDAQ"} = getStock();我们还可以解构嵌套对象。列表 A.11 创建了一个表示微软股票的嵌套对象,并将其传递给
printStockInfo()函数,该函数从这个对象中提取股票符号和证券交易所的名称。列表 A.11. 解构嵌套对象
let msft = { symbol: "MSFT", lastPrice: 50.00, exchange: { *1* name: "NASDAQ", tradingHours: "9:30am-4pm" } }; function printStockInfo(stock) { let {symbol, exchange: {name}} = stock; *2* console.log(`The ${symbol} stock is traded at ${name}`); } printStockInfo(msft);-
1 嵌套对象
-
2 解构嵌套对象以获取证券交易所的名称
运行前面的脚本将打印以下内容:^([11])
¹¹
在 CodePen 上查看:
mng.bz/Xauq。The MSFT stock is traded at NASDAQ假设我们正在编写一个处理浏览器 DOM 事件的函数。在 HTML 部分,我们调用此函数,并将事件对象作为参数传递。事件对象有多个属性,但我们的处理函数只需要
target属性来识别触发此事件的组件。解构语法使这变得很容易:<button id="myButton">Click me</button> ... document .getElementById("myButton") .addEventListener("click", ({target}) => console.log(target));注意函数参数中的解构语法
{target}.^([12])¹²
在 CodePen 上查看:
mng.bz/Dj24。A.9.2. 解构数组
数组解构与对象解构的工作方式非常相似,但我们需要使用方括号而不是花括号。在解构对象时,我们需要指定与对象属性匹配的变量,而在数组中,我们指定与数组索引匹配的变量。以下代码将两个数组元素的值提取到两个变量中:
let [name1, name2] = ["Smith", "Clinton"]; console.log(`name1 = ${name1}, name2 = ${name2}`);输出将如下所示:
name1 = Smith, name2 = Clinton如果我们想提取这个数组的第二个元素,匹配的模式将如下所示:
let [, name2] = ["Smith", "Clinton"];如果一个函数返回一个数组,解构语法将其转换为具有多个返回值的函数,如
getCustomers()函数所示:function getCustomers() { return ["Smith", , , "Gonzales"]; } let [firstCustomer, , , lastCustomer] = getCustomers(); console.log(`The first customer is ${firstCustomer} and the last one is ${las tCustomer}`);现在让我们将数组解构与剩余参数结合起来。假设我们有一个包含多个客户的数组,但我们只想处理前两个。下面的代码片段展示了如何做到这一点:
let customers = ["Smith", "Clinton", "Lou", "Gonzales"]; let [firstCust, secondCust, ...otherCust] = customers; console.log(`The first customer is ${firstCust} and the second one is ${secon dCust}`); console.log(`Other customers are ${otherCust}`);这是该代码产生的控制台输出:
The first customer is Smith and the second one is Clinton Other customers are Lou, Gonzales在类似的情况下,我们可以将匹配模式与剩余参数一起传递给函数:
var customers = ["Smith", "Clinton", "Lou", "Gonzales"]; function processFirstTwoCustomers([firstCust, secondCust, ...otherCust]) { console.log(`The first customer is ${firstCust} and the second one is ${sec ondCust}`); console.log(`Other customers are ${otherCust}`); } processFirstTwoCustomers(customers);输出将相同:
The first customer is Smith and the second one is Clinton Other customers are Lou,Gonzales总结来说,解构的优势在于,当我们需要用位于对象属性或数组中的数据初始化一些变量时,我们可以编写更少的代码。
A.10. 使用 forEach()、for-in 和 for-of 迭代
我们可以使用不同的 JavaScript 关键字和 API 来遍历对象集合。在本节中,我们将向您展示如何使用
for-of循环。我们将将其与for-in循环和forEach()函数进行比较。A.10.1. 使用 forEach() 方法
考虑以下代码,它遍历一个包含四个数字的数组。这个数组还有一个额外的
description属性,它被forEach()忽略:var numbersArray = [1, 2, 3, 4]; numbersArray.description = "four numbers"; numbersArray.forEach((n) => console.log(n));脚本的输出如下所示:
1 2 3 4forEach()方法接受一个函数作为参数,并正确地打印出数组中的四个数字,忽略description属性。forEach()的另一个限制是它不允许我们提前退出循环。我们需要使用every()方法代替forEach(),或者想出其他方法来实现这一点。让我们看看for-in循环如何帮助。A.10.2. 使用 for-in 循环
for-in循环遍历对象和数据集合的属性名。在 JavaScript 中,任何对象都是键值对的集合,其中键是属性名,值是属性值。数组有五个属性:四个用于数字,一个用于description。让我们遍历这个数组的属性:var numbersArray = [1, 2, 3, 4]; numbersArray.description = "four numbers"; for (let n in numbersArray) { console.log(n); }上述代码的输出如下所示:
0 1 2 3 description通过调试器运行此代码显示,这些属性都是
string类型。要查看属性的值,请使用numbersArray[n]表示法打印数组元素:var numbersArray = [1, 2, 3, 4]; numbersArray.description = "four numbers"; for (let n in numbersArray) { console.log(numbersArray[n]); }现在的输出看起来像这样:
1 2 3 4 four numbers如您所见,
for-in循环遍历了所有属性,而不仅仅是数据,这可能不是您需要的。让我们尝试新的for-of语法。A.10.3. 使用 for-of
ES6 引入了
for-of循环,允许我们遍历数据,而不管数据集合具有哪些其他属性。如果需要,我们可以使用break关键字跳出这个循环:var numbersArray = [1, 2, 3, 4]; numbersArray.description = "four numbers"; console.log("Running for-of for the entire array"); for (let n of numbersArray) { console.log(n); } console.log("Running for-of with a break"); for (let n of numbersArray) { if (n > 2) break; console.log(n); }此脚本产生以下输出:^([13])
¹³
在 CodePen 中查看:
mng.bz/53DO。Running for-of for the entire array 1 2 3 4 Running for-of with a break 1 2for-of循环可以与任何可迭代对象一起工作,包括Array、Map、Set以及其他。字符串也是可迭代的。以下代码逐个字符打印字符串"John"的内容:for (let char of "John") { console.log(char); }A.11. 类和继承
虽然 ES5 支持面向对象编程和继承,但使用 ES6 类,代码更易于阅读和编写。
在 ES5 中,对象可以从头创建,也可以从其他对象继承。默认情况下,所有 JavaScript 对象都是从
Object继承的。这种对象继承是通过一个称为prototype的特殊属性实现的,它指向这个对象的祖先。这被称为原型继承。例如,要创建一个从对象Tax继承的NJTax对象,我们可以编写如下内容:function Tax() { // The code of the tax object goes here } function NJTax() { // The code of New Jersey tax object goes here } NJTax.prototype = new Tax(); *1* var njTax = new NJTax();- 1 从 Tax 继承 NJTax
ES6 引入了
class和extends关键字,使语法与其他面向对象的语言(如 Java 和 C#)保持一致。以下代码展示了上述代码的 ES6 等效版本:class Tax { // The code of the tax class goes here } class NJTax extends Tax { // The code of New Jersey tax object goes here } let njTax = new NJTax();Tax类是一个祖先或超类,而NJTax是一个后代或子类。我们也可以说NJTax类与Tax类有“是”的关系。换句话说,NJTax是Tax。您可以在NJTax中实现额外的功能,但NJTax仍然是“是”或“是一种”Tax。同样,如果我们创建一个从Person继承的Employee类,我们可以说Employee是Person。我们可以创建一个或多个对象实例,如下所示:
var tax1 = new Tax(); *1* var tax2 = new Tax(); *2*-
1 Tax 对象的第一个实例
-
2 Tax 对象的第二个实例
注意
类声明不会提升。您需要先声明类,然后再使用它。
这些对象中的每一个都将具有存在于
Tax类中的属性和方法,但它们将具有不同的状态;例如,第一个实例可能为年收入为 50,000 美元的客户创建,第二个为年收入为 75,000 美元的客户创建。每个实例都会共享在Tax类中声明的相同方法副本,因此没有代码重复。在 ES5 中,我们也可以通过在对象原型上声明方法而不是在对象内部声明方法来避免代码重复:
function Tax() { // The code of the tax object goes here } Tax.prototype = { calcTax: function() { // code to calculate tax goes here } }JavaScript 仍然是一种具有原型继承的语言,但 ES6 允许我们编写更优雅的代码:
class Tax() { calcTax() { // code to calculate tax goes here } }类成员变量不受支持
ES6 语法不允许您声明类成员变量,就像在 Java、C#或 TypeScript 中那样。以下语法不支持:
class Tax { var income; }A.11.1. 构造函数
在实例化过程中,类会执行放置在特殊方法中的代码,这些方法称为构造函数。在 Java 和 C#等语言中,构造函数的名称必须与类的名称相同;但在 ES6 中,我们通过使用
constructor关键字来指定类的构造函数:class Tax { constructor(income) { this.income = income; } } var myTax = new Tax(50000);构造函数是一个特殊的方法,它只执行一次:当对象被创建时。
Tax类没有声明单独的类级income变量,而是在this对象上动态创建它,用构造函数参数的值初始化this.income。this变量指向当前对象的实例。下一个示例展示了我们如何创建
NJTax子类的一个实例,向其构造函数提供50,000的收入:class Tax { constructor(income) { this.income = income; } } class NJTax extends Tax { // The code specific to New Jersey tax goes here } let njTax = new NJTax(50000); console.log(`The income in njTax instance is ${njTax.income}`);此代码片段的输出如下:
The income in njTax instance is 50000因为
NJTax子类没有定义自己的构造函数,所以在NJTax实例化时,会自动调用Tax超类的构造函数。如果子类定义了自己的构造函数,则不会发生这种情况。您将在下一节中看到这样的示例。A.11.2.
super关键字和super函数super()函数允许子类(后代)从超类(祖先)调用构造函数。super关键字用于调用在超类中定义的方法。列表 A.12 说明了super()和super的用法。Tax类有一个calculateFederalTax()方法,而它的NJTax子类添加了calculateStateTax()方法。这两个类都有自己的calcMinTax()方法版本。列表 A.12.
super()和superclass Tax { constructor(income) { this.income = income; } calculateFederalTax() { console.log(`Calculating federal tax for income ${this.income}`); } calcMinTax() { console.log("In Tax. Calculating min tax"); return 123; } } class NJTax extends Tax { constructor(income, stateTaxPercent) { super(income); this.stateTaxPercent=stateTaxPercent; } calculateStateTax() { console.log(`Calculating state tax for income ${this.income}`); } calcMinTax() { let minTax = super.calcMinTax(); console.log(`In NJTax. Will adjust min tax of ${minTax}`); } } const theTax = new NJTax(50000, 6); theTax.calculateFederalTax(); theTax.calculateStateTax(); theTax.calcMinTax();运行此代码会产生以下输出^(14))
^(14)
在 CodePen 中查看:
mng.bz/6e9S。Calculating federal tax for income 50000 Calculating state tax for income 50000 In Tax. Calculating min tax In NJTax. Will adjust min tax of 123NJTax类有一个显式定义的构造函数,带有两个参数,income和stateTaxPercent,我们在实例化NJTax时提供这些参数。为了确保Tax的构造函数被调用(它设置了对象的income属性),我们从子类的构造函数中显式调用它:super(income)。如果没有这一行,运行 列表 A.12 将会报告错误;我们必须通过调用函数super()从派生构造函数中调用超类的构造函数。调用超类中代码的另一种方式是使用
super关键字。Tax和NJTax都有calcMinTax()方法。Tax超类中的方法根据联邦税法计算基本最低金额,而子类版本的此方法使用基本值并对其进行调整。这两个方法具有相同的签名,因此我们有一个 方法重写 的例子。通过调用
super.calcMinTax(),我们确保在计算州税时考虑了基本联邦税。如果我们没有调用super.calcMinTax(),子类的calcMinTax()方法版本将适用。方法重写通常用于在不更改其代码的情况下替换超类中方法的函数。关于类和继承的警告
ES6 类仅是提高代码可读性的语法糖。在底层,JavaScript 仍然使用原型继承,这允许你在运行时动态地替换祖先,而一个类只能有一个祖先。尽量避免创建深层继承层次,因为它们会降低代码的灵活性,并在需要时使重构变得复杂。
虽然使用
super关键字可以让你调用祖先中的代码,但你应该尽量避免使用它,以避免后代和祖先对象之间的紧密耦合。后代对祖先了解得越少,越好。A.11.3. 静态变量
如果我们需要一个由多个类实例共享的类属性,我们需要在类声明之外创建它。在 列表 A.13 中,静态变量
counter通过调用printCounter()方法可以从对象A的两个实例中访问。但如果我们尝试在实例级别访问变量counter,它将是未定义的。列表 A.13. 共享类属性
class A { printCounter(){ console.log("static counter=" + A.counter); }; } A.counter = 25; let a1 = new A(); a1.printCounter(); console.log("In the a1 instance counter=" + a1.counter); let a2 = new A(); a2.printCounter(); console.log("In the a2 instance counter=" + a2.counter);那段代码产生以下输出:[1])
在 CodePen 中查看:
mng.bz/lCXD。static counter=25 In the a1 instance counter=undefined static counter=25" In the a2 instance counter=undefinedA.11.4. 访问器、设置器和方法定义
对象的获取器和设置器方法的语法在 ES6 中并不新,但在介绍定义新语法的语法之前,让我们先回顾一下。设置器和获取器将函数绑定到对象属性。考虑对象字面量
Tax的声明和使用:const Tax = { taxableIncome: 0, get income() {return this.taxableIncome;}, set income(value) { this.taxableIncome = value} }; Tax.income=50000; console.log("Income: " + Tax.income); // prints Income: 50000注意
注意,您使用点符号分配和检索
income的值,就像它是Tax对象的一个已声明的属性一样。在 ES5 中,我们需要使用
function关键字,例如calculateTax = function(){...}。在 ES6 中,我们可以在任何方法定义中省略function关键字:const Tax = { taxableIncome: 0, get income() {return this.taxableIncome;}, set income(value) {this.taxableIncome = value}, calculateTax() {return this.taxableIncome*0.13} }; Tax.income = 50000; console.log(`For the income ${Tax.income} your tax is ${Tax.calculateTax()}`);那段代码的输出如下:[3])
在 CodePen 中查看:
mng.bz/5729。For the income 50000 your tax is 6500获取器和设置器为处理属性提供了一个方便的语法。例如,如果我们决定在
income获取器中添加一些验证代码,使用Tax.income语句的脚本就不需要更改。坏处是 ES6 不支持类中的私有变量,因此没有任何东西阻止程序员直接访问在获取器或设置器中使用的变量(例如taxableIncome)。我们将在第 A.13 节中讨论隐藏(封装)变量。A.12. 异步处理
在 ECMAScript 的先前实现中安排异步处理时,我们必须使用 回调,即作为另一个函数参数传递的函数。回调可以同步或异步调用。
之前,我们向
forEach()方法传递了一个回调函数以进行同步调用。在向服务器发送 AJAX 请求时,我们传递一个回调函数,当从服务器收到结果时异步调用该函数。A.12.1. 回调地狱
让我们考虑一个从服务器获取有关一些已订购产品的数据的例子。它从一个异步调用开始,以获取客户信息,然后对于每个客户,我们需要再次调用以获取订单。对于每个订单,我们需要获取产品,最后的调用将获取产品详情。
在异步处理中,我们不知道这些操作中的每一个何时完成,因此我们需要编写回调函数,在先前的操作完成后调用。让我们使用
setTimeout()函数来模拟延迟,就像每个操作需要一秒钟来完成。图 A.1 展示了这段代码可能的样子。图 A.1. 回调地狱或死亡金字塔
![]()
注意
使用回调被认为是一种反模式,也称为死亡金字塔,如左边的图 A.1 所示。在我们的代码示例中,我们有四个回调,这种嵌套级别使得代码难以阅读。在实际应用中,金字塔可能会迅速增长,使得代码非常难以阅读和调试。
在图 A.1 中运行代码将每隔一秒打印以下消息:^(17)。
^(17)
在 CodePen 中查看:
mng.bz/DAX5。Getting customers Getting orders Getting products Getting product detailsA.12.2. ES6 承诺
当你按下咖啡机上的按钮时,你不会立即得到一杯咖啡。你得到一个承诺,你将在稍后得到一杯咖啡。如果你没有忘记提供水和磨碎的咖啡,这个承诺将被解决,你可以在大约一分钟后享用咖啡。如果你的咖啡机没有水或咖啡,这个承诺将被拒绝。整个过程是异步的,你可以在咖啡被煮制的同时做其他事情。
JavaScript 承诺允许我们避免嵌套调用,并使异步代码更易于阅读。
Promise对象代表异步操作的最终完成或失败。在创建Promise对象之后,它将等待并监听异步操作的结果,并通知我们操作是成功还是失败,以便我们可以相应地继续下一步。Promise对象代表操作的将来结果,它可以处于以下状态之一:-
Fulfilled— 操作成功完成。
-
Rejected— 操作失败并返回错误。
-
Pending— 操作正在进行中,既未完成也未拒绝。
我们可以通过向其构造函数提供两个函数来实例化一个
Promise对象:一个是在操作成功时调用的函数,另一个是在操作失败时调用的函数。考虑一个包含getCustomers()函数的脚本,如下所示。列表 A.14. 使用承诺
function getCustomers() { return new Promise( function (resolve, reject) { console.log("Getting customers"); // Emulate an async server call here setTimeout(function() { var success = true; if (success) { resolve("John Smith"); *1* } else { reject("Can't get customers"); *2* } }, 1000); } ); } getCustomers() .then((cust) => console.log(cust)) *3* .catch((err) => console.log(err)); *4* console.log("Invoked getCustomers. Waiting for results");-
1 获取客户
-
2 发生错误时调用
-
3 当承诺得到满足时调用
-
4 当承诺被拒绝时调用
getCustomers()函数返回一个Promise对象,该对象使用具有resolve和reject作为构造函数参数的函数进行实例化。在代码中,如果我们收到客户信息,我们将调用resolve()。为了简单起见,setTimeout()模拟了一个持续一秒的异步调用。我们还硬编码了success标志为true。在现实世界的场景中,我们可以使用XMLHttpRequest对象发出请求,如果结果成功检索,则调用resolve(),如果发生错误,则调用reject()。在列表 A.14 的底部,我们将
then()和catch()方法附加到Promise()实例上。这两个方法中只有一个会被调用。当我们从函数内部调用resolve("John Smith")时,它会导致接收"John Smith"作为其参数的then()被调用。如果我们将success的值改为false,catch()方法将被调用,其参数包含"Can't get customers":Getting customers Invoked getCustomers. Waiting for results John Smith注意,在打印
"John Smith"之前,会打印消息"Invoked getCustomers. Waiting for results"。这证明了getCustomers()函数是异步工作的.^([18])¹⁸
在 CodePen 中查看:
mng.bz/5rf3.每个 promise 代表一个异步操作,我们可以将它们连接起来以确保特定的执行顺序。让我们在下面的列表中添加一个
getOrders()函数,它可以找到提供的客户的订单,并将getOrders()与getCustomers()连接起来。列表 A.15. 连接 promise
function getCustomers() { return new Promise( function (resolve, reject) { console.log("Getting customers"); // Emulate an async server call here setTimeout(function() { const success = true; if (success){ resolve("John Smith"); *1* }else{ reject("Can't get customers"); } }, 1000); } ); return promise; } function getOrders(customer) { return new Promise( function (resolve, reject) { // Emulate an async server call here setTimeout(function() { const success = true; if (success) { resolve(`Found the order 123 for ${customer}`); *2* } else { reject("Can't get orders"); } }, 1000); } ); } getCustomers() .then((cust) => { console.log(cust); return cust; }) .then((cust) => getOrders(cust)) *3* .then((order) => console.log(order)) .catch((err) => console.error(err)); *4* console.log("Chained getCustomers and getOrders. Waiting for results");-
1 当成功获取客户时调用
-
2 当客户的订单成功时调用
-
3 与 getOrders()连接
-
4 处理错误
这段代码不仅声明并连接了两个函数,还展示了我们如何在控制台打印中间结果。列表 A.15 的输出如下(注意,从
getCustomers()返回的客户被正确地传递给了getOrders()):^([19])¹⁹
在 CodePen 中查看:
mng.bz/6z5k.Getting customers Chained getCustomers and getOrders. Waiting for results John Smith Found the order 123 for John Smith我们可以使用
then()链式调用多个函数调用,并为所有链式调用只有一个错误处理脚本。如果发生错误,它将通过整个then链传播,直到找到一个错误处理器。错误发生后,不会调用任何then。将列表 A.15 中的
success变量的值更改为false将导致打印消息"Can't get customers",并且getOrders()方法不会被调用。如果我们移除这些控制台打印,检索客户和订单的代码看起来干净且易于理解:getCustomers() .then((cust) => getOrders(cust)) .catch((err) => console.error(err));添加更多的
then不会使这段代码的可读性降低(将其与图 A.1 中显示的“灾难金字塔”进行比较)。A.12.3. 同时解析多个 promise
另一个需要考虑的案例是相互之间不依赖的异步函数。比如说,我们需要以任意顺序调用两个函数,但只有在它们都完成之后,我们才需要执行某些操作。
Promise对象有一个all()方法,它接受一个 promise 的可迭代集合,并执行(解析)所有这些 promise。因为all()方法返回一个Promise对象,所以我们可以将then()或catch()(或两者)添加到结果中。想象一个需要执行多个异步调用来获取天气、股市新闻和交通信息的网络门户。如果我们想在所有这些调用完成之后才显示门户页面,
Promise.all()就是我们所需要的:Promise.all([getWeather(), getStockMarketNews(), getTraffic()]) .then( (results) => { /* render the portal GUI here */ }) .catch(err => console.error(err)) ;请记住,
Promise.all()只有在所有 promise 都解析后才会解析。如果其中一个拒绝,控制权将转到catch()处理器。与回调函数相比,promises 使我们的代码更加线性且易于阅读,并且它们代表了应用程序的多个状态。在负面方面,promises 不能被取消。想象一个没有耐心的用户多次点击按钮以从服务器获取一些数据。每次点击都会创建一个 promise 并发起一个 HTTP 请求。没有方法可以只保留最后一个请求并取消未完成的请求。
Promise对象进化的下一步是Observable对象,这将在未来的 ECMAScript 规范中介绍;在 第五章 中,我们解释了如何今天使用它。使用 promises 的 JavaScript 代码更容易阅读,但如果仔细查看
then()函数,你会看到你仍然需要提供一个稍后将被调用的回调函数。关键字async和await是 JavaScript 异步编程语法的下一进化步骤。A.12.4. async 和 await
关键字
async和await是在 ES8(也称为 ES2017)中引入的。它们允许我们将返回 promises 的函数当作同步函数来处理。下一行代码只有在上一行代码完成后才会执行。重要的是要注意,等待异步代码完成是在后台发生的,并且不会阻塞程序其他部分的执行:-
async是一个关键字,用于标记返回 promise 的函数。 -
await是一个关键字,我们将其放置在async函数调用之前。这指示 JavaScript 引擎在异步函数返回结果或抛出错误之前,不要继续执行下一行。JavaScript 引擎将await关键字右侧的表达式内部包装成一个 promise,并将方法的其他部分包装成一个then()回调函数。
为了说明
async和await关键字的使用,以下列表重用了getCustomers()和getOrders()函数,这些函数内部使用 promises 来模拟异步处理。列表 A.16. 声明使用 promises 的两个函数
function getCustomers() { return new Promise( function (resolve, reject) { console.log("Getting customers"); // Emulate an async call that takes 1 second to complete setTimeout(function() { const success = true; if (success){ resolve("John Smith"); } else { reject("Can't get customers"); } }, 1000); } ); } function getOrders(customer) { return new Promise( function (resolve, reject) { // Emulate an async call that takes 1 second setTimeout(function() { const success = true; // change it to false if (success){ resolve( `Found the order 123 for ${customer}`); } else { reject(`getOrders() has thrown an error for ${customer}`); } }, 1000); } ); }我们想要链式调用这些函数调用,但这次我们不会像处理 promises 那样使用
then()调用。我们将创建一个新的函数getCustomersOrders(),它内部调用getCustomers(),并在完成时调用getOrders()。我们将在调用
getCustomers()和getOrders()的行中使用await关键字,这样代码将在继续执行之前等待每个函数完成。我们将使用async关键字标记getCustomersOrders()函数,因为它将在内部使用await。以下列表声明并调用了函数getCustomersOrders()。列表 A.17. 声明和调用
async函数(async function getCustomersOrders() { *1* try { const customer = await getCustomers(); *2* console.log(`Got customer ${customer}`); const orders = await getOrders(customer); *3* console.log(orders); } catch(err) { *4* console.log(err); } })(); console.log("This is the last line in the app. Chained getCustomers() and getOrders() are still running without blocking the rest of the app."); *5*-
1 使用
async关键字声明函数 -
2 使用
await调用异步函数getCustomers(),这样下面的代码在函数完成之前不会执行 -
3 使用 await 调用异步函数 getOrders(),因此下面的代码将在函数完成之前不会执行
-
4 处理错误
-
5 此代码在异步函数外部运行。
如你所见,此代码看起来像是同步的。它没有回调,并且逐行执行。错误处理使用标准的
try/catch块进行。运行此代码将产生以下输出:
Getting customers This is the last line in the app. Chained getCustomers() and getOrders() are still running without blocking the rest of the app. Got customer John Smith Found the order 123 for John Smith注意到关于代码最后一行的消息是在客户名称和订单号之前打印的。尽管这些值稍后异步检索,但这个小应用程序的执行没有被阻塞,脚本在异步函数
getCustomers()和getOrders()完成执行之前已经到达了最后一行.^([20])²⁰
在 CodePen 中查看:
mng.bz/pSV8。A.13. ES6 模块
在任何编程语言中,将代码拆分为模块有助于将应用程序组织成逻辑上可能可重用的单元。模块化应用程序允许软件开发者更有效地将编程任务分配给多个开发者。开发者可以决定哪些 API 应该由模块对外暴露,哪些应该内部使用。
ES5 没有用于创建模块的语言结构,因此我们必须求助于以下选项之一:
-
手动实现一个模块设计模式作为立即初始化的函数。
-
使用 AMD (
mng.bz/JKVc) 或 CommonJS (mng.bz/7Lld) 标准的第三方实现。
CommonJS 是为了模块化在浏览器外运行的 JavaScript 应用程序(如 Node.js 中编写的那些,在 Google 的 V8 引擎下部署)而创建的。AMD 主要用于在浏览器中运行的应用程序。
你应该将你的应用程序拆分为模块,以使你的代码更易于维护。除此之外,你还应该最小化在应用程序启动时加载到客户端的 JavaScript 代码量。想象一下典型的在线商店。当用户打开应用程序的主页时,你需要加载处理支付的代码吗?如果他们从未点击“下订单”按钮呢?将应用程序模块化以便按需加载代码会很好。RequireJS 可能是实现 AMD 标准的最受欢迎的第三方库;它允许你定义模块之间的依赖关系,并在浏览器中按需加载它们。
从 ES6 开始,模块已经成为语言的一部分,这意味着开发者将停止使用第三方库来实现各种标准。如果一个脚本使用了
import和/或export关键字,它就成为一个模块。注意
ES6 模块允许你避免污染全局作用域,并限制脚本及其成员(类、函数、变量和常量)的可见性,仅限于那些导入它们的模块。
| |
ES6 模块和全局作用域
假设我们有一个多文件项目,其中一个文件包含以下内容:
class Person {}由于我们没有从这个文件导出任何内容,它不是一个 ES6 模块,
Person类的实例将在全局作用域中创建。如果你在同一项目中已经有另一个脚本也声明了Person类,TypeScript 编译器将在前面的代码中给出错误,指出你正在尝试声明一个已存在的重复项。在前面的代码中添加
export语句会改变情况,并且这个脚本变成了一个模块:export class Person {}现在,
Person类型的对象不会在全局作用域中创建,它们的作用域将仅限于那些导入Person的脚本(其他 ES6 模块)。在第 1 和 2 章中,我们介绍了 Angular 模块,这些模块(与 ES6 模块相反)作为属于一起的 Angular 艺术品的注册表。A.13.1. 导入和导出
一个 模块 只是一个实现特定功能并导出(或导入)公共 API 的 JavaScript 文件,以便其他 JavaScript 程序可以使用它。没有特殊的关键字来声明特定文件中的代码是一个模块。只需使用
import和export关键字,你就可以将一个脚本转换为 ES6 模块。import关键字使一个脚本能够声明它需要使用另一个脚本导出的成员。同样,export关键字让你声明模块应该暴露给其他脚本的变量、函数和类。使用export关键字,你可以使选定的 API 对其他模块可用。未明确导出的模块的函数、变量和类仍然是模块的私有成员。ES6 提供了两种
export使用类型:命名和默认。使用命名导出,你可以在模块的多个成员(如类、函数和变量)之前使用export关键字。以下文件(tax.js)中的代码导出了taxCode变量和calcTaxes()、fileTaxes()函数,但doSomethingElse()函数对外部脚本来说是隐藏的:export let taxCode = 1; export function calcTaxes() { } function doSomethingElse() { } export function fileTaxes() { }当一个脚本导入命名的导出模块成员时,它们的名称必须放在大括号中。
main.js文件说明了这一点:import {taxCode, calcTaxes} from 'tax'; if (taxCode === 1) { // do something } calcTaxes();在这里,
tax指的是模块的文件名,减去文件扩展名。大括号表示解构。从 tax.js 导出的模块导出了三个成员,但我们只对导入taxCode和calcTaxes感兴趣。可以将导出的模块成员之一标记为
default,这意味着这是一个匿名导出,另一个模块可以在其import语句中给它任何名称。导出函数的 my_module.js 文件可能看起来像这样:export default function() { // do something } *1* export let taxCode;- 1 无分号
main.js文件在分配默认名称coolFunction给默认导出时同时导入了命名和默认导出:import coolFunction, {taxCode} from 'my_module'; coolFunction();注意,你不需要在
coolFunction(默认导出)周围使用花括号,但需要在taxCode(命名导出)周围使用。一个导入使用default关键字导出的类、变量或函数的脚本可以给它们新的名称,而无需使用任何特殊的关键字:import aVeryCoolFunction, {taxCode} from 'my_module'; aVeryCoolFunction();但要给命名导出起别名,我们需要写点像这样:
import coolFunction, {taxCode as taxCode2016} from 'my_module';import模块语句不会导致导出代码的复制。导入作为引用。一个导入模块或成员的脚本不能修改它们,如果导入模块中的值发生变化,新的值会立即反映在它们被导入的所有地方。我们在本书的每一章中都使用了导入语句,这样你就有机会很好地了解如何使用 ES6 模块。小贴士
在 Angular 应用中不要使用默认导出,因为在编译时(AoT)你将会遇到错误。
附录 B. TypeScript 基础知识
TypeScript 由微软于 2012 年发布,其核心开发者是 Anders Hejlsberg。他也是 Turbo Pascal 和 Delphi 的作者之一,并且是 C#的首席架构师。在本附录中,我们将介绍 TypeScript 语法的核心元素。
我们还将向您展示如何将 TypeScript 代码转换为 JavaScript(ES5),以便它可以在任何 Web 浏览器或独立的 JavaScript 引擎中执行。本附录并不提供 TypeScript 的完整覆盖。有关完整信息,请参阅www.typescriptlang.org/docs/home.html上的 TypeScript 文档。此外,TypeScript 支持附录 A 中描述的所有语法结构,因此我们在此不再重复。
B.1. 转译器的作用
Web 浏览器不理解任何语言,除了 JavaScript。如果源代码是用 TypeScript 编写的,那么在您可以在 JavaScript 引擎(无论是浏览器还是独立引擎)中运行它之前,必须将其转译为 JavaScript。
转译意味着将一种语言的程序源代码转换为另一种语言的源代码。许多开发者更喜欢使用“编译”这个词,所以像“TypeScript 编译器”和“将 TypeScript 编译成 JavaScript”这样的短语也是有效的。
图 B.1 展示了左侧的 TypeScript 代码及其由 TypeScript 转译器生成的右侧 ES5 版本的 JavaScript 代码。在 TypeScript 中,我们声明了一个类型为
string的变量foo,但转译后的版本没有类型信息。在 TypeScript 中,我们声明了一个名为Bar的类,它在 ES5 语法中以类似类的模式转译。图 B.1. 将 TypeScript 转换为 ES5
![]()
您可以通过访问 TypeScript playground 在www.typescriptlang.org/play来亲自尝试。如果我们指定 ES6 作为转换的目标,生成的 JavaScript 代码将会有所不同;您将在右侧看到
let和class关键字。将 Angular 与静态类型的 TypeScript 结合使用可以简化 Web 应用程序的开发。良好的工具和静态类型分析器可以显著减少运行时错误数量并缩短上市时间。当您的 Angular 应用程序完成时,将包含大量的 JavaScript 代码;尽管在 TypeScript 中开发可能需要您编写更多的代码,但通过节省测试和重构的时间以及最小化运行时错误数量,您将获得收益。
B.2. 开始使用 TypeScript
微软已经开源了 TypeScript,并在 GitHub 上托管了 TypeScript 仓库,网址为
github.com/Microsoft/TypeScript/wiki/Roadmap。你可以使用 npm 安装 TypeScript 编译器。TypeScript 网站www.typescriptlang.org提供了语言文档,并有一个托管在网页上的 TypeScript 编译器(在 Playground 菜单下),你可以在这里交互式地输入 TypeScript 代码并将其编译为 JavaScript,如图 B.1 所示。在左侧输入 TypeScript 代码,其 JavaScript 版本(ES5)将在右侧显示。点击运行按钮执行转换后的代码(如果代码有输出,可以在浏览器控制台查看)。这样的交互式工具足以学习语言语法,但为了实际开发,你需要更好的工具以提高生产力。你可能决定使用 IDE 或文本编辑器,但本地安装 TypeScript 编译器对于开发是必须的。我们将在附录中向你展示如何安装 TypeScript 编译器并运行代码示例,使用的是 Node JavaScript 引擎。
我们假设你已经在计算机上安装了 Node.js 和 npm。如果你还没有安装,请参阅附录 C。
B.2.1. 安装和使用 TypeScript 编译器
我们将使用 Node.js 的 npm 包管理器来安装 TypeScript 编译器。要在全局范围内安装,请在终端窗口中运行以下 npm 命令:
npm install -g typescript-g选项将在你的计算机上全局安装 TypeScript 编译器,因此它可以从终端窗口中的所有项目中访问。要检查 TypeScript 编译器的版本,请运行以下命令:tsc --version如前所述,用 TypeScript 编写的代码必须转换为 JavaScript,以便网页浏览器能够执行它。TypeScript 代码保存在以.ts 扩展名命名的文件中。假设你编写了一个脚本并将其保存到 main.ts 文件中。以下命令将 main.ts 转换为 main.js:
tsc main.ts你还可以生成源映射文件,将 TypeScript 程序中的行映射到生成的 JavaScript 中的对应行。有了源映射,你可以在浏览器中运行 TypeScript 代码时设置断点,即使它执行的是 JavaScript。要将 main.ts 转换为 main.js 并生成源映射文件 main.js.map,请运行以下命令:
tsc --sourcemap main.ts如果浏览器打开了开发者工具面板,它将加载源映射文件以及 JavaScript 文件,你可以在那里调试 TypeScript 代码,就像浏览器运行 TypeScript 一样。
在编译过程中,TypeScript 编译器会从生成的代码中移除所有不被 JavaScript 支持的 TypeScript 类型、接口和关键字。通过提供编译器选项,你可以生成符合 ES3、ES5、ES6 或更新语法的 JavaScript。
这是将代码转换为 ES5 兼容语法的步骤(
--t选项指定目标语法):tsc --t ES5 main.ts您可以通过提供
-w选项来以监视模式启动 TypeScript 编译器。在此模式下,每次您修改并保存代码时,它都会自动转换为相应的 JavaScript 文件。要编译并监视当前目录中的所有.ts 文件,请运行以下命令:tsc -w *.ts编译器将编译所有 TypeScript 文件,打印错误消息(如果有)到控制台,并继续监视文件的变化。一旦文件发生变化,
tsc将立即重新编译它。注意
通常,我们在 IDE 中关闭 TypeScript 自动编译。对于 Angular 应用程序,我们使用 Angular CLI 来编译和打包整个项目。IDE 使用 TypeScript 代码分析器来突出显示错误,即使没有编译。
tsc提供了数十种编译选项,描述在mng.bz/rf14。您可以预先配置编译过程(指定源和目标目录、生成源映射等)。项目目录中存在 tsconfig.json 文件意味着您可以在命令行中输入tsc,编译器将读取所有选项从 tsconfig.json。以下是一个 Angular 项目的 tsconfig.json 文件示例。列表 B.1. tsconfig.json
{ "compilerOptions": { "baseUrl": "src", *1* "outDir": "./dist", *2* "sourceMap": true, *3* "moduleResolution": "node", *4* "noEmitOnError": true, *5* "target": "es5", *6* "experimentalDecorators": true *7* } }-
1 将位于 src 目录中的.ts 文件转换为
-
2 将生成的.js 文件保存到 dist 目录
-
3 生成源映射
-
4 根据基于 Node 的项目结构查找模块
-
5 如果任何文件有编译错误,则不会生成 JavaScript 文件
-
6 将.ts 文件转换为 ES5 语法
-
7 必须支持装饰器
每个 Angular/TypeScript 应用程序都使用与类或类成员(如
@Component()和@Input())一起的装饰器。我们将在本附录的后面讨论装饰器。如果您想排除项目中的一些文件不进行编译,请向 tsconfig.json 添加
exclude属性。这样您可以排除 node_modules 目录的全部内容:"exclude": [ "node_modules" ]B.2.2. TypeScript 作为 JavaScript 的超集
TypeScript 支持 ES5、ES6 和更新的 ECMAScript 语法。只需将具有 JavaScript 代码的文件名扩展名从.js 更改为.ts,它就会成为有效的 TypeScript 代码。作为 JavaScript 的超集,TypeScript 为 JavaScript 添加了多个有用的功能。我们将在下面回顾它们。
B.3. 如何运行代码示例
要在您的计算机上本地运行此附录中的代码示例,请执行以下步骤:
1. 从
nodejs.org/en/download/安装 Node.js(使用当前版本)。2. 将
github.com/Farata/angulartypescript存储库克隆或下载到任何目录。3. 在命令窗口中,切换到该目录,然后转到 code-samples/appendixB 子目录。
4. 通过运行
npm install在本地安装项目依赖项(TypeScript 编译器)。5. 使用本地安装的 TypeScript 编译器将所有代码示例编译到 dist 目录,通过运行
npm run tsc实现,这将把 src 目录中的所有代码示例转换到 dist 目录。6. 要运行特定的代码示例(例如 fatArrow.js),请使用以下命令:
node dist/fatArrow。B.4. 可选类型
你可以声明变量并为所有或其中一些变量提供类型。以下两行是有效的 TypeScript 语法:
let name1 = 'John Smith'; let name2: string = 'John Smith';Tip
在第二行,指定类型
string是不必要的。因为变量是用字符串初始化的,TypeScript 会推断出name2的类型是string。如果你使用类型,TypeScript 的转换器可以在开发过程中检测到类型不匹配,IDE 将提供代码补全和重构支持。这将提高你在任何中等规模项目上的生产力。即使你不在声明中使用类型,TypeScript 也会根据分配的值猜测类型,并在之后进行类型检查。这被称为类型 推断。
以下 TypeScript 代码片段显示,你不能将数值赋给原本打算为
string的name1变量,即使它最初没有声明类型(JavaScript 语法)。在用字符串值初始化这个变量之后,推断类型不会让你将数值赋给name1:let name1 = 'John Smith'; name1 = 123; *1*- 1 将不同类型的值赋给变量在 JavaScript 中是有效的,但在 TypeScript 中由于推断类型的原因是无效的。
在 TypeScript 中,你可以声明具有类型的变量、函数参数和返回值。有四个关键字用于声明基本类型:
number、boolean、string和void。最后一个表示函数声明中没有返回值。变量可以具有null或undefined类型的值,类似于 JavaScript。下面是一些使用显式类型声明的变量的例子:
let salary: number; let isValid: boolean; let customerName: string = null;注意
从 TypeScript 2.7 版本开始,你需要在声明时初始化变量,或者在构造函数中初始化(成员变量)。
所有这些类型都是
any类型的子类型。你也可以显式声明一个变量,指定any作为其类型。在这种情况下,不会应用推断类型。这两个声明都是有效的:let name2: any = 'John Smith'; name2 = 123;如果变量使用显式类型声明,编译器将检查它们的值以确保它们与声明匹配。TypeScript 包含其他在浏览器交互中使用的类型,例如
HTMLElement和Document。如果你定义了一个类或接口,它可以用作变量声明中的自定义类型。我们将在后面介绍类和接口,但首先让我们熟悉 TypeScript 函数,这是 JavaScript 中最常用的结构。B.5. 函数
TypeScript 函数和函数表达式与 JavaScript 函数类似,但你可以显式声明参数类型和返回值。让我们编写一个 JavaScript 函数来计算税费。它将有三个参数,并将根据州、收入和受抚养人数来计算税费。对于每个受抚养人,根据个人居住的州,个人有权获得 500 美元或 300 美元的税费减免。该函数如下所示。
列表 B.2. 在 JavaScript 中计算税费
function calcTax(state, income, dependents) { if (state === 'NY') { return income * 0.06 - dependents * 500; } else if (state === 'NJ') { return income * 0.05 - dependents * 300; } }假设一个收入为 50,000 美元的人居住在新泽西州,并且有两个受抚养人。让我们调用
calcTax():let tax = calcTax('NJ', 50000, 2);tax变量得到1,900的值,这是正确的。即使calcTax()没有为函数参数声明任何类型,你也可以根据参数名称猜测它们。现在让我们以错误的方式调用它,为受抚养人数传递一个string类型的值:var tax = calcTax('NJ', 50000, 'two');你只有在调用此函数时才会发现问题。
tax变量将具有NaN值(不是一个数字)。仅仅因为你没有机会显式指定参数的类型,就悄悄地引入了一个错误。下一个列表将此函数重写为 TypeScript,为参数和返回值声明类型。列表 B.3. 在 TypeScript 中计算税费
function calcTax(state: string, income: number, dependents: number): number { if (state === 'NY'){ return income * 0.06 - dependents * 500; } else if (state ==='NJ'){ return income * 0.05 - dependents * 300; } }现在没有任何方法可以犯同样的错误,并为受抚养人数传递一个
string类型的值:let tax: number = calcTax('NJ', 50000, 'two');TypeScript 编译器将显示一个错误信息,说“类型
string的参数不能分配给类型number的参数。”此外,函数的返回值声明为number,这阻止你将税费计算的结果分配给非数字变量:let tax: string = calcTax('NJ', 50000, 'two');编译器会捕获这个错误,产生错误信息“类型‘number’不能分配给类型‘string’:var tax: string。”这种在编译期间进行的类型检查可以在任何项目中为你节省大量时间。
B.5.1. 默认参数
在声明函数时,你可以指定默认参数值。例如:
function calcTax(income: number, dependents: number, state: string = 'NY'): n umber{ // the code goes here }你甚至不需要更改
calcTax()函数体中的任何一行代码。现在你可以自由地用两个或三个参数调用它:let tax: number = calcTax(50000, 2); // or let tax: number = calcTax(50000, 2, 'NY');两次调用的结果将相同。
B.5.2. 可选参数
在 TypeScript 中,你可以通过在参数名称后附加一个问号来轻松地将函数参数标记为可选。唯一的限制是可选参数必须在函数声明中最后出现。当你为具有可选参数的函数编写代码时,你需要提供应用程序逻辑来处理可选参数未提供的情况。
让我们修改以下列表中的税费计算函数:如果没有指定受抚养人,则不会对计算出的税费应用任何减免。
列表 B.4. 在 TypeScript 中计算税费,已修改
function calcTax(income: number, state: string = 'NY', dependents?: number): number{ let deduction: number; if (dependents) { deduction = dependents * 500; }else { deduction = 0; } if (state === 'NY') { return income * 0.06 - deduction; } else if (state === 'NJ') { return income * 0.05 - deduction; } } let tax: number = calcTax(50000, 'NJ', 3); console.log(`Your tax is ${tax}`); tax = calcTax(50000); console.log(`Your tax is ${tax}`);注意
dependents?: number中的问号。现在函数会检查dependents的值是否提供。如果没有提供,则将 0 赋值给deduction变量;否则,每个受抚养人扣除 500。运行前面的脚本将产生以下输出:
Your tax is 1000 Your tax is 3000注意
TypeScript 支持附录 A 中第 A.5 节中描述的胖箭头表达式语法。
| |
函数重载
JavaScript 不支持函数重载,因此不可能有多个具有相同名称但参数列表不同的函数。TypeScript 支持函数重载,但由于代码必须转换为单个 JavaScript 函数,因此重载的语法并不优雅。
你可以声明一个只有一个主体且只有一个签名的函数,其中需要检查参数的数量和类型,并执行相应的代码部分:
function attr(name: string): string; function attr(name: string, value: string): void; function attr(map: any): void; function attr(nameOrMap: any, value?: string): any { if (nameOrMap && typeof nameOrMap === "string") { // handle string case } else { // handle map case } // handle value here }B.6. 类
如果你熟悉 Java 或 C#,你将熟悉它们经典形式的类和继承概念。在这些语言中,类的定义作为单独的实体(如蓝图)加载到内存中,并由该类的所有实例共享。如果一个类从另一个类继承,则使用两个类的组合蓝图来实例化对象。
TypeScript 是 JavaScript 的超集,它只支持原型继承,你可以通过将一个对象附加到另一个对象的原型属性来创建继承层次结构。在这种情况下,创建了一个动态的对象继承(或更确切地说,是链接)。
在 TypeScript 中,
class关键字是简化编码的语法糖。最终,你的类将被转换为具有原型继承的 JavaScript 对象。在 JavaScript 中,你可以声明一个构造函数并使用new关键字来实例化它。在 TypeScript 中,你也可以声明一个类并使用new运算符来实例化它。一个类可以包含构造函数、字段(属性)和方法。声明的属性和方法通常被称为类成员。我们将通过一系列代码示例来展示 TypeScript 类的语法,并将它们与等效的 ES5 语法进行比较。
让我们创建一个简单的
Person类,该类包含四个属性以存储姓名、年龄和社会安全号码(美国公民和居民的唯一标识符)。在图 B.2 的左侧,你可以看到声明和实例化Person类的 TypeScript 代码;在右侧是tsc编译器生成的 JavaScript 闭包。通过为Person函数创建闭包,TypeScript 编译器启用了暴露和隐藏Person对象元素的功能。图 B.2. 将 TypeScript 类转换为 JavaScript 闭包
![]()
TypeScript 还支持类构造函数,允许你在创建对象时初始化对象变量。类构造函数在对象创建期间只调用一次。图 B.2 的左侧显示了
Person类,它使用constructor关键字用构造函数提供的值初始化类的字段。B.6.1. 访问修饰符
JavaScript 没有声明变量或方法为 私有(对外部代码隐藏)的方式。要隐藏对象中的属性(或方法),你需要创建一个闭包,该闭包既不将此属性附加到
this变量,也不在闭包的返回语句中returns它。TypeScript 提供了
public、protected和private关键字,以帮助你在开发阶段控制对对象成员的访问。默认情况下,所有类成员都具有公共访问权限,并且可以从类外部可见。如果一个成员用protected修饰符声明,它在类及其子类中可见。声明为private的类成员仅在类内部可见。让我们使用
private关键字隐藏_ssn属性的值,使其不能从Person对象外部直接访问。我们将向您展示两种声明使用访问修饰符的类的版本。类的较长版本如下所示。列表 B.5. 使用私有属性
class Person { public firstName: string; public lastName: string; public age: number; private _ssn: string; constructor(firstName: string, lastName: string, age: number, ssn: string ) { this.firstName = firstName; this.lastName = lastName; this.age = age; this._ssn = ssn; } } const p = new Person("John", "Smith", 29, "123-90-4567"); console.log("Last name: " + p.lastName + " SSN: " + p._ssn);注意,私有变量的名称以下划线开头:
_ssn。这是私有属性的命名约定。列表 B.5 的最后一行尝试从外部访问
_ssn私有属性,因此 TypeScript 代码分析器会给你一个编译错误:“属性_ssn是私有的,并且只能在类 ‘Person’ 中访问。”但除非你使用--noEmitOnError编译器选项,否则有错误的代码仍然会被转换为 JavaScript:const Person = (function () { function Person(firstName, lastName, age, _ssn) { this.firstName = firstName; this.lastName = lastName; this.age = age; this._ssn = _ssn; } return Person; })(); const p = new Person("John", "Smith", 29, "123-90-4567"); console.log("Last name: " + p.lastName + " SSN: " + p._ssn);private关键字只在 TypeScript 代码中使其私有,但生成的 JavaScript 代码仍然会将类的所有属性和方法视为公共的。TypeScript 还允许你在构造函数参数中提供访问修饰符,如下列
Person类的简短版本所示。列表 B.6. 使用访问修饰符
class Person { constructor(public firstName: string, public lastName: string, public age: number, private _ssn: string) { } } const p = new Person("John", "Smith", 29, "123-90-4567");当你使用具有访问修饰符的构造函数时,TypeScript 编译器将其视为创建并保留与构造函数参数匹配的类属性的指令。你不需要显式声明和初始化它们。
Person类的简短和长版本生成相同的 JavaScript,但我们建议使用如 图 B.3 所示的较短的语法。图 B.3. 使用
constructor转译 TypeScript 类![]()
B.6.2. 方法
当在类中声明一个函数时,它被称为方法。在 JavaScript 中,你需要在对象的原型上声明方法,但使用类时,你通过指定一个名称后跟括号和花括号来声明方法,就像在其他面向对象的语言中一样。
下一个代码列表显示了如何声明和使用一个具有一个参数且没有返回值的
doSomething()方法的MyClass类。列表 B.7. 创建一个方法
class MyClass { doSomething(howManyTimes: number): void { // do something here } } const mc = new MyClass(); mc.doSomething(5);静态和实例成员
列表 B 7 中的代码以及图 B.2 中显示的类首先创建了一个类的实例,然后使用指向此实例的引用变量来访问其成员:
mc.doSomething(5);如果使用
static关键字声明了一个类属性或方法,其值将在类的所有实例之间共享,并且你不需要创建一个实例来访问静态成员。而不是使用引用变量(例如mc),你将使用类的名称:class MyClass{ static doSomething(howManyTimes: number): void { // do something here } } MyClass.doSomething(5);如果你实例化了一个类,并且需要在同一类中声明的另一个方法中调用类方法,不要使用
this关键字(例如,this.doSomething(5)),但仍然使用类名,例如MyClass.doSomething(10);。B.6.3. 继承
JavaScript 支持基于原型的对象继承,其中一个对象可以将另一个对象作为其原型,这发生在运行时。TypeScript 有
extends关键字用于类的继承,就像 ES6 和其他面向对象的语言一样。但在转换为 JavaScript 时,生成的代码使用原型继承的语法。图 B.4 显示了如何创建一个扩展
Person类的Employee类(第 9 行)。在右侧,你可以看到转换后的 JavaScript 版本,它使用原型继承。TypeScript 代码版本更简洁,更容易阅读。图 B.4. TypeScript 中的类继承
![]()
让我们在下一个列表中向
Employee类添加一个构造函数和一个department属性。列表 B.8. 使用继承
class Employee extends Person { department: string; *1* constructor(firstName: string, lastName: string, *2* age: number, _ssn: string, department: string) { super(firstName, lastName, age, _ssn); *3* this.department = department; } }-
1 声明一个属性部门
-
2 创建一个具有额外部门参数的构造函数
-
3 声明构造函数的子类必须使用 super()调用超类的构造函数。
如果你在一个子类类型的对象上调用在超类中声明的函数,你可以使用这个方法的名字,就像它在子类中声明的一样。但有时你想要特别调用超类的方法,这时你应该使用
super关键字。super关键字有两种用法。在派生类的构造函数中,你可以将其作为方法调用。你还可以使用super关键字来特别调用超类的方法。它通常与方法重写一起使用。例如,如果超类及其子类都有doSomething()方法,子类可以重用超类中编写的功能,并添加其他功能:doSomething() { super.doSomething(); // Add more functionality here }B.7. 接口
JavaScript 不支持接口,在其他面向对象的语言中,接口用于引入一个 API 必须遵守的 代码契约。契约的一个例子可以是类 X 声明它实现了接口 Y。如果类 X 没有实现接口 Y 中声明的方法,则视为违反契约且无法编译。
TypeScript 包含了
interface和implements关键字来支持接口,但接口不会转换成 JavaScript 代码。它们只是帮助你在开发过程中避免使用错误的数据类型。在 TypeScript 中,我们使用接口有两个原因:
-
声明一个定义包含多个属性的定制类型的接口。然后声明一个具有这种类型参数的方法。编译器将检查作为参数给出的对象是否包含接口中声明的所有属性。
-
声明一个包含抽象(未实现)方法的接口。当一个类声明实现了这个接口时,该类必须为所有抽象方法提供实现。
让我们通过示例应用这两种模式。
B.7.1. 使用接口声明自定义类型
当你使用 JavaScript 框架时,你可能会遇到需要某种配置对象作为函数参数的 API。为了找出这个对象中必须提供哪些属性,要么打开 API 的文档,要么阅读框架的源代码。在 TypeScript 中,你可以声明一个包含所有必须存在于配置对象中属性及其类型的接口。
让我们看看如何在
Person类中实现这一点,该类包含一个带有四个参数的构造函数:firstName、lastName、age和ssn。这次,在下面的列表中,你将声明一个包含这四个成员的IPerson接口,并且你将修改Person类的构造函数以使用这种自定义类型的对象作为参数。列表 B.9. 声明一个接口
interface IPerson { firstName: string; lastName: string; age: number; ssn?: string; *1* } class Person { constructor(public config: IPerson) {} *2* } let aPerson: IPerson = { *3* firstName: "John", lastName: "Smith", age: 29 } let p = new Person(aPerson); *4* console.log("Last name: " + p.config.lastName );-
1 声明一个带有 ssn 作为可选成员(注意问号)的 IPerson 接口
-
2 Person 类有一个类型为 IPerson 的参数的构造函数。
-
3 创建一个与 IPerson 兼容的 aPerson 对象字面量
-
4 实例化 Person 对象,提供一个类型为 IPerson 的对象作为参数
TypeScript 具有结构化类型系统,这意味着如果两个不同的类型包含相同的成员,则认为这些类型是兼容的。在 列表 B.9 中,即使你没有指定
aPerson变量的类型,它仍然会被认为与IPerson兼容,并可以在实例化Person对象时用作构造函数参数。如果你更改IPerson的任何一个成员的名称或类型,TypeScript 编译器将报告错误。IPerson接口没有定义任何方法,但 TypeScript 接口可以包含方法签名而不包含实现。B.7.2. 使用
implements关键字可以在类声明中使用
implements关键字来宣布该类将实现特定的接口。假设你有一个如下声明的IPayable接口:interface IPayable { increase_cap: number; increasePay(percent: number): boolean }现在
Employee类可以声明它实现了IPayable接口:class Employee implements IPayable { // The implementation goes here }在深入细节之前,让我们回答这个问题:为什么不在类中直接编写所有必要的代码,而不是将部分代码分离到一个接口中?假设你需要编写一个应用程序,允许为你的组织中的员工增加薪酬。
你可以创建一个
Employee类(它扩展了Person类)并在其中包含increaseSalary()方法。然后业务分析师可能会要求你添加为为你的公司工作的承包商增加薪酬的能力。但承包商由他们的公司名称和 ID 表示;他们没有薪酬的概念,并且按小时支付工资。你可以创建另一个类,
Contractor(不是从Person继承),它包含一些属性和一个increaseHourlyRate()方法。现在你有两个不同的 API:一个用于增加员工的薪酬,另一个用于增加承包商的薪酬。更好的解决方案是创建一个通用的IPayable接口,并让Employee和Contractor类为这些类提供不同的IPayable实现,如下面的列表所示。列表 B.10. 使用多个接口实现
interface IPayable { *1* increasePay(percent: number): boolean } class Person { *2* // properties are omitted for brevity } class Employee extends Person implements IPayable { *3* increasePay(percent: number): boolean { *4* console.log(`Increasing salary by ${percent}`); return true; } } class Contractor implements IPayable { increaseCap:number = 20; *5* increasePay(percent: number): boolean { *6* if (percent < this.increaseCap) { console.log(`Increasing hourly rate by ${percent}`); return true; } else { console.log(`Sorry, the increase cap for contractors is ${this.increaseCap}`); return false; } } } const workers: IPayable[] = []; *7* workers[0] = new Employee(); workers[1] = new Contractor(); workers.forEach(worker => worker.increasePay(30)); *8*-
1
IPayable接口包含了increasePay()方法的签名,该签名将由Employee和Contractor类实现。 -
2
Person类作为Employee类的基类。 -
3
Employee类从Person继承并实现了IPayable接口。一个类可以实现多个接口。 -
4
Employee类实现了increasePay()方法。员工的薪酬可以增加任何金额,因此该方法将在控制台上打印消息并返回 true(允许增加)。 -
5
Contractor类包含一个属性,将薪酬增加的上限设置为 20%。 -
6
Contractor类中increasePay()方法的实现不同,使用超过 20 的参数调用increasePay()将导致显示“Sorry”消息并返回 false。 -
7 声明 IPayable 类型的数组允许你放置任何实现了 IPayable 类型的对象。
-
8 现在你可以对 workers 数组中的任何对象调用 increasePay()方法。注意,使用具有单个 worker 参数的胖箭头表达式时,不需要使用括号。
运行前面的脚本会在浏览器控制台产生以下输出:
Increasing salary by 30 Sorry, the increase cap for contractors is 20为什么使用 implements 关键字声明类?
如果你从
Employee或Contractor的声明中移除implements Payable,代码仍然可以工作,编译器也不会对向workers数组添加这些对象的行提出抱怨。编译器足够智能,能够看到即使类没有显式声明implements IPayable,它也正确地实现了increasePay()。但如果你移除
implements IPayable并尝试更改任何类中increasePay()方法的签名,你将无法将此类对象放入workers数组中,因为该对象将不再是IPayable类型。此外,没有implements关键字,IDE 支持(如重构)将会失效。B.8. 泛型
TypeScript 支持参数化类型,也称为泛型,可以在各种场景中使用。例如,你可以创建一个可以接受任何类型值的函数;但在调用时,在特定的上下文中,你可以显式指定一个具体类型。
再举一个例子:一个数组可以存储任何类型的对象,但你可以在数组中指定允许的特定对象类型(例如,
Person类的实例)。如果你尝试添加不同类型的对象,TypeScript 编译器将生成一个错误。以下代码示例声明了一个
Person类及其子类Employee和一个Animal类。然后它实例化每个类并尝试将它们存储在声明为泛型类型的workers数组中。泛型类型通过将它们放在尖括号中(如<Person>)来表示。列表 B.11. 使用泛型类型
class Person { name: string; } class Employee extends Person { department: number; } class Animal { breed: string; } let workers: Array<Person> = []; workers[0] = new Person(); workers[1] = new Employee(); workers[2] = new Animal(); // compile-time error通过将
workers数组声明为泛型类型<Person>,你宣布了你的计划只存储Person类或其子类的实例。尝试将Animal类的实例存储在同一个数组中会导致编译时错误。名义和结构类型系统
如果你熟悉 Java 或 C#中的泛型,你可能觉得你已经理解了这个语法。但是有一个注意事项。虽然 Java 和 C#使用的是名义类型系统,TypeScript 使用的是结构类型系统。在名义类型系统中,类型是根据它们的名称进行检查的,但在结构类型系统中,是根据它们的结构进行检查的。
在名义类型系统中,以下行将导致错误:
let person: Person = new Animal();在结构化类型系统中,只要类型的结构相似,你就可以将一个类型的对象赋值给另一个类型的变量。让我们通过向
Animal类添加name属性来举例说明。![]()
结构化类型系统在行动
现在 TypeScript 编译器不会对将
Animal对象赋值给类型为Person的变量而报错。类型为Person的变量期望一个具有name属性的对象,而Animal对象恰好有这个属性。这并不是说Person和Animal代表了相同的类型,但这两个类型是兼容的。另一方面,尝试将Person对象赋值给类型为Animal的变量将导致编译错误“类型 Person 中缺少属性 breed”:let worker: Animal = new Person(); // compilation error你可以使用泛型类型与任何对象或函数吗?不。对象或函数的创建者必须允许这个特性。如果你在 GitHub 上打开 TypeScript 的类型定义文件 (lib.d.ts) 并搜索“interface Array”,你将看到
Array的声明,如图 B.5 所示。类型定义文件将在本附录的后面进行解释。图 B.5. 描述
ArrayAPI 的 lib.d.ts 片段![]()
第 1008 行的
<T>作为一个占位符用于实际类型。这意味着 TypeScript 允许你使用Array声明一个类型参数,编译器将检查你在程序中提供的特定类型。在本节前面,我们指定了<Person>作为let workers: Array<Person>中的泛型<T>参数。但由于 JavaScript 不支持泛型,你不会在转译器生成的代码中看到它们。这只是一种在编译时为开发者提供额外安全网的机制。你可以在 图 B.5 的第 1022 行看到另一个
T。当泛型类型与函数参数一起指定时,不需要使用尖括号。但在 TypeScript 中没有T类型。这里的T意味着push方法允许你将特定类型的对象推入数组,如下面的示例所示:workers.push(new Person());你也可以创建自己的支持泛型的类和函数。接下来的列表定义了一个
Comparator<T>接口,它声明了一个compareTo()方法,在方法调用期间期望提供具体的类型。列表 B.12. 创建一个使用泛型的接口
interface Comparator<T> { *1* compareTo(value: T): number; } class Rectangle implements Comparator<Rectangle> { *2* constructor(private width: number, private height: number) {}; compareTo(value: Rectangle): number { *3* if (this.width * this.height >= value.width * value.height) { return 1; } else { return -1; } } } let rect1: Rectangle = new Rectangle(2,5); let rect2: Rectangle = new Rectangle(2,3); rect1.compareTo(rect2) === 1? console.log("rect1 is bigger"): console.log("rect1 is smaller"); *4* class Programmer implements Comparator<Programmer> { *5* constructor(public name: string, private salary: number) {}; compareTo(value: Programmer): number { *6* if (this.salary >= value.salary) { return 1; } else { return -1; } } } let prog1: Programmer = new Programmer("John",20000); let prog2: Programmer = new Programmer("Alex",30000); prog1.compareTo(prog2)===1? console.log(`${prog1.name} is richer`): console.log(`${prog1.name} is poorer`) ; *7*-
1 声明一个具有泛型类型的 Comparator 接口
-
2 创建一个实现 Comparator 的类,指定具体的类型为 Rectangle
-
3 实现比较矩形的函数
-
4 比较矩形(类型 T 被擦除并替换为 Rectangle)
-
5 创建一个实现 Comparator 的类,指定具体的类型为 Programmer
-
6 实现比较程序员的函数
-
7 比较程序员(类型 T 被擦除并替换为 Programmer)
B.9. 只读修饰符
ES6 引入了可以应用于变量的
const关键字,但不能应用于类或接口的属性。你不能这样写:class Person { const name: = "Mary"; // compiler error } const p = new Person(); // no errorsTypeScript 添加了一个可以应用于类属性的
readonly关键字:class Person { readonly name = "Mary"; // no errors }你只能在声明时或类构造函数中初始化
readonly属性。现在,如果你尝试编写修改name属性值的代码,TypeScript 编译器(或静态分析器)将报告一个错误:class Person { readonly name = "Mary"; changeName() { this.name = "John"; // compiler error } }但是,创建一个不可变对象是应用
readonly修饰符的一个更有趣的使用场景。在某些情况下,尤其是在 Angular 应用中,你可能想确保一个对象是不可变的,并且不小心修改对象。让我们尝试将readonly应用到一个对象属性上:class Person { readonly bestFriend: { name: string } = {name: "Mary"}; changeFriend() { this.bestFriend = { name: "John" }; // compiler error } changeFriendName() { this.bestFriend.name = "John"; // no errors } }尝试将另一个对象分配给
bestFriend变量会导致编译错误,因为bestFriend被标记为readonly。但是,更改由bestFriend表示的对象的内部属性仍然是允许的。为了禁止这样做,使用readonly修饰符对对象的每个属性进行修饰:class Person { readonly bestFriend: { readonly name: string } = {name: "Mary"}; changeFriend() { this.bestFriend = { name: "John" }; // compiler error } changeFriendName(newName: string) { this.bestFriend.name = "John"; // compiler error } }在 Angular 应用中,你可能希望将应用程序状态存储在一个绑定到组件输入属性的不可变对象中。为了强制在属性更改时创建一个新的对象实例,编写一个函数来创建一个带有属性修改的对象副本(参见附录 A 中的 A.7 节中的代码示例)。
如果一个对象有多个属性,为它们中的每一个添加
readonly修饰符是一项繁琐的工作,你可以使用只读映射类型来代替。以下示例使用type关键字定义一个新的类型,并使用泛型为Readonly类提供一个具体的对象:type Friend = Readonly<{ name: string, lastName: string }>; class Person { bestFriend: Friend = {name: "Mary", lastName: "Smith"}; changeFriend() { this.bestFriend = { name: "John" }; // compiler error } changeFriendName() { this.bestFriend.name = "John"; // compiler error this.bestFriend.lastName = "Lou"; // compiler error } }B.10. 装饰器
“元数据”这个术语有不同的定义。流行的定义是元数据是关于数据的数据。我们将元数据视为描述和增强代码的数据。在内部,TypeScript 装饰器是特殊的函数,用于添加增强类、属性、方法或参数功能的元数据。TypeScript 装饰器以一个
@符号开始。装饰器存在于 TypeScript 中,并且是在 ECMAScript 中提出的。为了正确地转译它们,在 tsconfig.json 文件中添加以下行以在 TypeScript 转译器中启用实验性功能:
"experimentalDecorators": true在本节中,我们将向您展示如何创建一个简单的装饰器,该装饰器将打印它附加到的类的信息。
假设你想创建一个装饰器
UIComponent(),它可以接受一个 HTML 片段作为参数。装饰器应该能够打印接收到的 HTML 并理解附加实体的属性——例如,一个类。以下列表实现了这一点。列表 B.13. 一个自定义的
UIComponent装饰器function UIComponent (html: string) { console.log(`The decorator received ${html} \n`); return function(target) { console.log(`Creating a UI component from \n ${target} ` ); } } @UIComponent('<h1>Hello Shopper!</h1>') class Shopper { constructor(private name: string) {} }UIComponent()函数有一个string参数,并返回另一个函数,该函数打印隐含变量target的内容,该变量知道装饰器附加到的工件。如果您将此代码编译成 ES5 语法并运行它,控制台上的输出将如下所示:The decorator received <h1>Hello Shopper!</h1> Creating a UI component from function Shopper(name) { this.name = name; }如果将相同的代码编译成 ES6,输出将不同,因为 ES6 支持类:
The decorator received <h1>Hello Shopper!</h1> Creating a UI component from class Shopper { constructor(name) { this.name = name; } }在底层,TypeScript 使用
reflect-metadata库来查询装饰器附加到的工件的结构。这个简单的装饰器知道您想要渲染的 HTML 以及您的类有一个名为name的成员变量。如果您是框架的开发者,需要渲染 UI,这个装饰器的代码可能会很有用。创建自定义装饰器的过程在 TypeScript 文档中描述,请参阅mng.bz/gz6R。要将 TypeScript 类转换为 Angular 组件,您需要使用
@Component()装饰器对其进行装饰。Angular 将内部解析您的注解并生成代码,将所需的行为添加到 TypeScript 类中。要将类变量转换为可以接收值的组件属性,您使用@Input()装饰器:@Component({ selector: 'order-processor', template: ` Buying {{quantity}} shares} ` }) export class OrderComponent { @Input() quantity: number; }在此示例中,
@Component()装饰器定义了OrderComponent类的选择器和模板(UI)。@Input()装饰器使quantity属性能够通过绑定从父组件接收值。当您使用装饰器时,应该有一个装饰器处理器可以解析装饰器内容并将其转换为运行时(浏览器 JavaScript 引擎)可以理解的代码。Angular 包含ngc编译器,它执行装饰器处理器的职责。要使用 Angular 装饰器,请在您的应用程序代码中导入它们的实现。例如,如下导入
@Component()装饰器:import { Component } from '@angular/core';Angular 内置了一套装饰器,但 TypeScript 允许您创建自己的装饰器,无论您是否使用 Angular。
B.11. 联合类型
在 TypeScript 中,您可以根据两个或多个现有类型声明一个新的类型。例如,您可以声明一个变量,它可以接受字符串值或数字:
let padding: string | number;虽然 TypeScript 支持
any类型,但前面的声明与let padding: any的声明相比提供了一些好处。在下面的列表中,我们将回顾 TypeScript 文档中mng.bz/5742中的一个代码示例。此函数可以向提供的字符串添加左填充。填充可以是字符串,该字符串必须预接在提供的参数之前,或者是指定预接字符串的空格数。列表 B.14.
union.ts与any类型function padLeft(value: string, padding: any ) { *1* if (typeof padding === "number") { *2* return Array(padding + 1).join(" ") + value; } if (typeof padding === "string") { *3* return padding + value; } throw new Error(`Expected string or number, got '${padding}'.`); *4* }-
1 提供字符串和类型为 any 的填充
-
2 对于数字参数,生成空格
-
3 对于字符串,使用连接
-
4 如果第二个参数既不是字符串也不是数字,则抛出错误
以下是一些调用
padLeft()的示例:console.log( padLeft("Hello world", 4)); // returns " Hello world" console.log( padLeft("Hello world", "John says "));// returns "John says Hell o world" console.log( padLeft("Hello world", true)); // runtime error但如果你将
padding的类型改为字符串或数字的联合类型,当你尝试使用除了字符串或数字之外的任何内容调用padLeft()时,编译器将报告错误。这也会消除抛出异常的需要。新的padLeft()函数版本更加健壮,如下面的列表所示。列表 B.15. union.ts 使用联合类型
function padLeft(value: string, padding: string | number ) { *1* if (typeof padding === "number") { return Array(padding + 1).join(" ") + value; } if (typeof padding === "string") { return padding + value; } }- 1 只允许将字符串或数字作为第二个参数
现在用错误的类型(例如,
true)调用padLeft()作为第二个参数将返回编译错误:console.log( padLeft("Hello world", true)); // compilation error使用联合类型的另一个好处是 IDE 具有自动完成功能,它会提示你允许的参数类型,因此你甚至没有机会犯这样的错误。在 第十五章 的 15.2.3 节 中,还有一个使用联合类型的实际示例。
B.12. 使用类型定义文件
类型定义文件的目的在于描述一个 JavaScript 库(或脚本)的 API,并提供该 API 所提供的类型。比如说,你想要在你的 TypeScript 代码中使用流行的 JavaScript 库 Lodash。如果你的项目中有一个 Lodash 类型定义文件,TypeScript 静态分析器就会知道 Lodash 函数期望的类型,如果你提供了错误类型,你将得到编译时错误。此外,IDE 也会为 Lodash API 提供自动完成功能。
最初,一个 TypeScript 社区在
definitelytyped.org创建了一个名为 DefinitelyTyped 的 TypeScript 定义文件仓库。2016 年,微软在 npmjs.org 上创建了一个名为@types的组织,我们现在使用的就是这个组织。这个组织拥有超过 5,000 个针对各种 JavaScript 库的类型定义文件。任何类型定义文件名的后缀都是 d.ts,你使用 npm 安装类型定义文件。例如,要安装 Lodash 的类型定义文件,请运行以下命令:
npm i @types/lodash --save-dev这将在你的项目 node_modules/@types 目录中下载 Lodash 定义,并更新 package.json 文件,因此你不需要再次运行此命令。
当你安装 Angular 时,你会在运行
npm install命令后,在 node_modules/@angular 文件夹的子文件夹中找到 Angular 模块中的定义文件,如 第一章 中所述。所有必需的 d.ts 文件都包含在 Angular npm 包中,因此不需要单独安装。在你的项目中存在定义文件将允许 TypeScript 编译器确保在调用 Angular API 时你的代码使用正确的类型。例如,Angular 应用程序是通过调用
bootstrapModule()方法启动的,将应用程序的根模块作为参数传递给它。application_ref.d.ts 文件包含以下对该函数的定义:abstract bootstrapModule<M>(moduleType: Type<M>, compilerOptions?: CompilerOptions | CompilerOptions[]): Promise<NgModuleRef<M>>;通过阅读此定义,您(以及
tsc编译器)知道此函数可以通过一个必需的模块参数Type<M>和一个可选的编译器选项数组来调用。如果 application_ref.d.ts 不是您项目的一部分,TypeScript 编译器会允许您使用错误的参数类型调用bootstrapModule函数,或者完全不传递任何参数,这会导致运行时错误。但是,由于存在 application_ref.d.ts,TypeScript 会在编译时生成错误,显示“提供的参数与调用目标签名不匹配。”类型定义文件还允许 IDE 在您编写调用 Angular 函数或分配对象属性值的代码时显示上下文相关的帮助。显式指定类型定义文件
要显式指定位于 node_modules/@types 目录中的类型定义文件,请将所需的文件添加到 tsconfig.json 的
types部分。以下是一个示例:"compilerOptions": { ... "types": ["es6-shim", "jasmine"], }在过去,我们使用特殊的类型定义管理器
tsd和Typings来安装类型定义文件,但这些管理器不再需要。如果您的应用程序使用其他第三方 JavaScript 库,请使用 npm 安装它们的类型定义文件,以获取编译器帮助和 IDE 中的自动完成功能。B.13. 使用 TSLint 控制代码风格
代码检查器有助于确保代码符合接受的编码风格。使用 TSLint,您可以强制执行指定的规则和编码风格。例如,您可以将 TSLint 配置为检查您的项目中的 TypeScript 代码是否正确对齐和缩进,所有接口的名称是否以大写 I 开头,类名是否使用驼峰式命名法,等等。
您可以使用以下命令全局安装 TSLint:
npm install tslint -g要在项目目录中安装 TSLint 节点模块,请运行以下命令:
npm install tslint您想要应用到代码中的规则在 tslint.json 配置文件中指定,该文件是通过运行
tslint init生成的:{ "defaultSeverity": "error", "extends": [ "tslint: recommended" ], "jsRules": {}, "rules": {}, "rulesDirectory": [] }TSLint 附带了一个包含推荐规则的文件,但您可以使用您偏好的自定义规则。您可以在 node_modules/tslint/lib/configs/recommended.js 文件中查看推荐规则。核心 TSLint 规则在
mng.bz/xx6B上有文档说明。您的 IDE 可能默认支持使用 TSLint 进行代码检查。如果您使用 Angular CLI 生成了项目,它已经包含了 TSLint。附录 C. 使用 npm 包管理器
本附录概述了我们使用 npm 包管理器安装 Angular 及其依赖项所使用的工具。
在本书的大部分内容中,我们使用 Node.js 来安装软件。Node.js(或简称 Node)不仅仅是一个框架或库:它也是一个 JavaScript 运行时环境。我们使用 Node 运行时来运行各种工具,如 npm 或在没有浏览器的情况下启动 JavaScript 代码。我们还使用 npm 脚本来自动化构建、测试和部署 Angular 应用。
要开始,请从
nodejs.org下载并安装当前版本的 Node.js。安装完成后,打开你的终端或命令窗口,并输入以下命令:node --version此命令应打印出已安装的 Node 版本,例如,10.3.0。Node 包含包管理器 npm,我们使用它从位于 www.npmjs.com 的 npm 注册处安装 Angular 和其他包。此存储库托管 Angular 以及超过 400,000 个其他 JavaScript 包。
Node.js 框架
Node.js 也是一个可以用来开发在浏览器外运行的 JavaScript 程序的框架。你可以在 JavaScript 或 Typescript 中开发 Web 应用的服务器端。我们使用 Node.js 和 Express 框架在 第十二章 中编写了一个 Web 服务器。Google 为 Chrome 浏览器开发了一个高性能的 V8 JavaScript 引擎,它也可以用来运行使用 Node.js API 编写的代码。Node.js 框架包括用于与文件系统交互、访问数据库、监听 HTTP 请求等的 API。
要安装一个 JavaScript 库,运行命令
npm install,或简写为npm i。假设你想本地安装 TypeScript 编译器。在任何目录中打开终端,并运行以下命令:npm i typescript在此命令完成后,你将看到一个名为 node_modules 的新子目录,其中安装了 TypeScript 编译器。npm 总是在 node_modules 目录中安装包。如果不存在这样的目录,npm 将创建它。
如果你想全局安装一个包,请添加
-g选项:npm i typescript -g这次 TypeScript 编译器将不会安装到当前目录,而是安装到 Node.js 安装目录的 lib/node_modules 子目录中。
如果你想要安装特定版本的包,请将版本号添加到
@符号之后的包名中。例如,要全局安装 Typescript 2.9.0,请使用以下命令:npm i typescript@2.9.0 -g所有
npm install命令的可用选项都描述在docs.npmjs.com/cli/install。在某些情况下,您可能希望在本地和全局范围内安装相同的包。例如,您可能已经在本地安装了 TypeScript 编译器 2.7,并在全局范围内安装了 TypeScript 2.9。要运行此编译器的全局版本,您可以在终端或命令窗口中输入
tsc命令,要运行本地安装的编译器,您可以从项目目录使用以下命令:node_modules/.bin/tsc一个基于 Node 的典型项目可能有多重依赖项,我们不希望每次都运行单独的
npm i命令来安装每个包。创建一个 package.json 文件是指定所有项目依赖项的更好方式。C.1. 在 package.json 中指定项目依赖项
要启动一个新的基于 Node 的项目,创建一个新的目录(例如,my-node-project),打开您的终端或命令窗口,并将当前工作目录更改为新创建的目录。然后运行
npm init -y命令,这将创建 package.json 配置文件的初始版本。通常,npm init在创建文件时会询问几个问题,但-y标志使其接受所有选项的默认值。以下示例显示了在空的 my-node-project 目录中运行此命令:$ npm init -y Wrote to /Users/username/my-node-project/package.json: { "name": "my-node-project", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC" }大多数生成的配置要么是为了将项目发布到 npm 注册表,要么是在将包作为另一个项目的依赖项安装时使用。我们只会使用 npm 来管理项目依赖项并自动化开发和构建过程。
因为我们不会将其发布到 npm 注册表,所以请移除除
name、description和scripts之外的所有属性。还要添加一个"private": true属性,因为它不是默认创建的。这将防止包意外发布到 npm 注册表。package.json 文件应如下所示:{ "name": "my-node-project", "description": "", "private": true, "scripts": { "test": "echo \"Error: no test specified\" && exit 1" } }scripts配置允许您指定可以在命令窗口中运行的命令别名。默认情况下,npm init创建了test别名,可以像这样运行:npm test。生成的脚本命令包括双 ampersands,&&,它们用作命令之间的分隔符。当您运行npm test时,它将运行两个命令:echo "Error: no test specified"和exit -1。npm 脚本支持大约一打命令名称,如test、start等。这些命令的列表可以在docs.npmjs.com/misc/scripts找到。您可以使用任何名称创建自己的命令别名:
"scripts": { "deploy": "copyfiles -f dist/** ../server/build/public", }因为
deploy是一个自定义别名名称,您需要通过添加关键字run来运行此命令:npm run deploy在第十二章的 12.3.6 节中,我们讨论了如何编写 npm 脚本来在 Web 服务器上构建和部署应用程序。
如果你使用
npm init命令生成 package.json,它将缺少两个重要的部分:dependencies和devDependencies。让我们看看 Angular 项目的依赖项是如何指定的。图 C.1 显示了典型 Angular 项目 package.json 文件的一个片段。图 C.1. package.json 中的 Angular 依赖项
![cfig01_alt.jpg]()
它看起来可能令人畏惧,但好消息是,你不需要记住所有这些软件包及其版本,因为你会使用 Angular CLI 生成项目,它将创建包含正确内容的 package.json 文件。
dependencies部分列出了运行你的应用程序所需的所有软件包。正如你所见,Angular 框架以以@angular开头的几个软件包的形式出现。你可能不需要安装所有这些软件包。例如,如果你的应用程序不使用表单,那么就没有必要在 package.json 中包含@angular/forms。devDependencies部分列出了需要在开发者的计算机上安装的所有软件包。它包括几个软件包,但在生产服务器上不需要任何这些软件包。在生产机器上不需要测试框架或 TypeScript 编译器,对吧?要使用 npm 安装单个软件包,列出该软件包的名称。例如,要将 Lodash 库添加到你的 node_modules 目录,运行以下命令:
npm i lodash要将软件包添加到 node_modules 并添加相应的依赖项到你的 package.json 文件的
dependencies部分,你可以明确指定--save-prod选项:npm i lodash --save-prod你也可以使用缩写
-P代替--save-prod。如果没有指定任何选项,npm i lodash命令将更新 package.json 文件的dependencies部分。要将软件包添加到 node_modules 并添加相应的依赖项到你的 package.json 文件的
devDependencies部分,使用--save-dev选项:npm i protractor --save-dev你也可以使用缩写
-D代替--save-dev。有时,GitHub 上的一个软件包可能包含一个重要的错误修复,而这个修复尚未在 npmjs.org 上发布。如果你想要从 GitHub 安装这样的软件包,你需要将 package.json 依赖项中的版本号替换为其在 GitHub 上的位置。将依赖项改为包含 GitHub 组织名称和存储库名称应该允许你安装此库的 GitHub 版本的最新构建。例如:
"@angular/flex-layout": "angular/flex-layout-builds"前面的配置将有效,假设 Flex Layout 库的 master 分支没有代码问题阻止 npm 安装它。
C.2. 语义版本控制
Angular 版本编号使用一组称为语义版本控制的规则。包的版本由三个数字组成——例如,6.1.2。第一个数字表示一个主要版本,它包括新功能和 API 中的潜在破坏性更改。第二个数字代表一个次要版本,它引入了向后兼容的 API 但没有破坏性更改。第三个数字表示向后兼容的补丁,包含错误修复。
再看看上一节中的图 C.1。每个包都有一个三位数的版本号,其中许多还有额外的符号:
^或~。如果指定的版本只有三位数字,这意味着你指示 npm 安装确切的那个版本。例如,package.json 中的以下行告诉 npm 安装 Angular CLI 版本 6.0.5,即使有更新的版本也不安装:"@angular/cli": "6.0.5"在那个 package.json 文件中的许多包版本前都有一个帽子符号
^。例如:"@angular/core": "⁶.0.0"这意味着如果你允许 npm 安装 6 版本的最新次要版本,如果可用。如果 Angular Core 包的最新版本是 6.2.1,它将被安装。
波浪号
~表示你想要安装给定主版本和次要版本的最新补丁:"jasmine-core": "~2.99.1"你可以在版本号中使用许多其他符号与 npm 一起使用——有关详细信息,请参阅
mng.bz/YnyW。C.3. Yarn 作为 npm 的替代品
Yarn(请参阅
yarnpkg.com)是另一个可以作为 npm 替代使用的包管理器。在版本 5 之前,npm 速度较慢,这是我们开始使用更快的 Yarn 的原因之一。现在 npm 也很快了,但 Yarn 还有一个额外的优势:它创建了一个 yarn.lock 文件,该文件跟踪项目中安装的包的确切版本。假设你的 package.json 文件有一个
"@angular/core": "⁶.0.0"依赖项,并且你的项目中没有 yarn.lock 文件。如果 6.1.0 版本可用,它将被安装,并且会创建一个包含关于 6.1.0 版本记录的 yarn.lock 文件。如果你一个月后运行yarn install,并且如果项目中存在 yarn.lock 文件,Yarn 将使用它并安装 6.1.0 版本,即使 6.2.0 版本可用。以下是从 yarn.lock 中摘录的片段,显示尽管 package.json 中对
@angular/core包的依赖项设置为⁶.0.0,但安装了6.0.2版本:"@angular/core@⁶.0.0": version "6.0.2" resolved "https://registry.yarnpkg.com/@angular/core/-/core- 6.0.2.tgz#d183..." dependencies: tslib "¹.9.0"在团队设置中,你应该将 yarn.lock 文件提交到版本控制仓库,以确保团队中的每个成员都有相同版本的包。
npm 也会创建 package-lock.json 文件,但如果运行
npm install,npm 并不是设计为安装此文件中列出的确切包版本(参见github.com/npm/npm/issues/17979)。好消息是,从版本 5.7 开始,npm 支持npm ci命令,该命令忽略 package.json 中列出的版本,但安装 package-lock.json 文件中列出的版本。如果在某个时候你决定升级包,并覆盖 yarn.lock 中存储的版本,请运行
yarn upgrade-interactive命令,如图 C.2 所示。图 C.2。图 C.2. 使用 Yarn 升级包版本
![图片 C.2 的替代文本]()
Yarn 与你的项目 package.json 文件协同工作,因此无需任何额外的配置。你可以在
yarnpkg.com/en/docs了解更多关于使用 Yarn 的信息。小贴士
你可以要求 Angular CLI 在安装新生成项目的依赖项时使用 Yarn 而不是 npm。从 Angular CLI 6 开始,你可以通过以下命令来完成:
ng config --global cli.packageManager yarn如果你使用较旧的 Angular CLI 版本,请使用以下命令:
ng set --global packageManager=yarn附录 D. RxJS 基础知识
同步编程相对简单,因为你的代码的每一行都是在执行前一行之后。如果你在第 25 行调用一个返回值的函数,你可以将返回值用作第 26 行调用的函数的参数。
异步编程极大地增加了代码的复杂性。在第 37 行,你可以调用一个异步函数,该函数将在稍后返回值。你能否在第 38 行调用一个使用前一个函数返回值的函数?简短的答案是,“这取决于。”
本附录是 RxJS 6 库的介绍,该库可以与任何基于 JavaScript 的应用程序一起使用。它在编写和组合异步代码方面表现出色。因为 Angular 内部使用 RxJS 库,所以我们决定在这本书中添加一个入门指南。
第一个反应式扩展库(Rx)是由 Erik Meijer 在 2009 年创建的。Rx.NET 旨在用于使用 Microsoft .Net 技术的应用程序。然后 Rx 扩展被移植到多种语言中,在 JavaScript 领域,RxJS 6 是这个库的当前版本。
注意
虽然 Angular 依赖于 RxJS 并且没有它无法工作,但 RxJS 本身是一个独立的库,可以在任何 JavaScript 应用程序中使用。
让我们通过考虑一个简单的例子来看看编程中的反应式意味着什么:
let a1 = 2;? let b1 = 4;?? let c1 = a1 + b1; // c1 = 6??这段代码将变量
a1和b1的值相加,c1等于6。现在让我们给这段代码添加几行,修改a1和b1的值:let a1 = 2;? let b1 = 4;?? let c1 = a1 + b1; // c1 = 6?? a1 = 55; // c1 = 6 but should be 59 ? b1 = 20; // c1 = 6 but should be 75当
a1和b1的值发生变化时,c1不会对这些变化做出反应,其值仍然是6。你可以编写一个函数来添加a1和b1的值,并调用它以获取c1的最新值,但这将是一种命令式的编码风格,其中你指定何时调用函数来计算总和。如果
c2能在a1或b1发生变化时自动重新计算,那岂不是很好?想象一下像 Microsoft Excel 这样的电子表格程序,你可以在 C1 单元格中输入一个公式=sum(a1, b1),当 A1 和 B1 发生变化时,C1 会立即做出反应。换句话说,你不需要点击任何按钮来刷新 C1 的值——数据是推送到这个单元格的。在反应式编程风格中(与命令式相对),数据的变化驱动着代码的调用。反应式编程是关于创建响应式、事件驱动的应用程序,其中可观察的事件流被推送到订阅者,他们观察和处理这些事件。
在软件工程中,观察者/可观察对象是一个众所周知的模式,并且非常适合任何异步处理场景。但响应式编程远不止是观察者/可观察模式的实现。可观察流可以被取消,它们可以通知流的结束,并且从数据生产者到订阅者的数据推送过程中可以通过应用一个或多个可组合的算子进行转换。
D.1. 熟悉 RxJS 术语
我们想要观察数据,这意味着存在一个数据生产者——一个使用 HTTP 或 WebSockets 发送数据的服务器,一个用户输入数据的 UI 输入字段,一个智能手机中的加速度计等等。一个可观察对象是一个函数(或对象),它获取生产者数据并将其推送到订阅者。一个观察者是一个对象(或函数),它知道如何处理可观察对象推送的数据,如图 D.1 所示。
图 D.1. 从可观察对象到观察者的数据流
![]()
RxJS 的主要参与者如下:
-
可观察对象— 随时间推送数据的流
-
观察者— 可观察流消费者
-
订阅者— 将观察者与可观察对象连接
-
算子— 数据转换函数
我们将通过展示这些参与者使用的多个示例来介绍每个参与者。要全面了解,请参阅可在
reactivex.io/rxjs找到的 RxJS 文档。热和冷可观察对象
有两种可观察对象:热和冷。主要区别在于,一个冷可观察对象为每个订阅者创建一个数据生产者,而一个热可观察对象首先创建一个数据生产者,每个订阅者从订阅的那一刻起从该生产者获取数据。
让我们比较在 Netflix 上观看电影和去电影院。把自己想象成一个观察者。任何决定在 Netflix 上观看碟中谍的人都会得到整部电影,无论他们何时按下播放按钮。Netflix 为你创建一个新的生产者来流式传输电影。这是一个冷可观察对象。
如果你去电影院,放映时间是下午 4 点,那么生产者是在下午 4 点创建的,流式传输开始。如果有些人(订阅者)迟到,他们会错过电影的开始,只能从到达的那一刻开始观看。这是一个热可观察对象。
一个冷可观察对象在代码调用其上的
subscribe()函数时开始产生数据。例如,你的应用程序可能声明一个提供服务器上 URL 的可观察对象以获取某些产品。请求只有在订阅时才会进行。如果另一个脚本向服务器发出相同的请求,它将获得相同的数据集。热可观察者即使在没有订阅者对数据感兴趣的情况下也会产生数据。例如,你的智能手机中的加速度计会产生关于设备位置的数据,即使没有应用程序订阅这些数据。服务器可以产生最新的股票价格,即使没有用户对这种股票感兴趣。
本附录中的大多数示例都是关于冷可观察者。
D.2. 可观察者、观察者和订阅者
如前所述,可观察者一次从数据源(一个套接字、一个数组、UI 事件)获取一个数据元素。更准确地说,可观察者知道如何做三件事:
-
向观察者发出下一个元素
-
在观察者上抛出错误
-
通知观察者流已结束
因此,观察者对象提供了最多三个回调函数:
-
处理可观察者发出的下一个元素的函数
-
处理可观察者抛出错误的函数
-
处理流结束的函数
订阅者通过调用
subscribe()方法将可观察者与观察者连接起来,通过调用unsubscribe()方法断开连接。订阅可观察者的脚本必须提供知道如何处理产生的元素的观察者对象。假设你创建了一个由变量someObservable表示的可观察者和一个由变量myObserver表示的观察者。你可以按如下方式订阅这样的可观察者:let mySubscription: Subscription = someObservable.subscribe(myObserver);要取消订阅,请调用
unsubscribe()方法:mySubscription.unsubscribe();可观察者如何与提供的观察者进行通信?通过在观察者对象上调用以下函数:
-
next(),用于将下一个数据元素推送到观察者 -
error(),用于将错误信息推送到观察者 -
complete(),向观察者发送关于流结束的信号
你将在 D.5 节 中看到使用这些函数的示例。
D.3. 创建可观察者
RxJS 提供了多种创建可观察者的方式,具体取决于数据生产者的类型——例如,DOM 事件的 数据生产者、数据集合、自定义函数、WebSocket 等。
这里有一些创建可观察者的 API 示例:
-
of(1,2,3)— 将数字序列转换为Observable -
Observable.create(myObserver)— 返回一个Observable,该Observable可以调用您创建并作为参数提供的myObserver上的方法 -
from(myArray)— 将由myArray变量表示的数组转换为Observable。你还可以使用任何可迭代数据集合或生成函数作为from()的参数。 -
fromEvent(myInput, 'keyup')— 将由myInput表示的 HTML 元素的keyup事件转换为Observable。第六章 中有一个使用fromEvent()API 的示例。 -
interval(1000)— 每秒发出一个连续整数(0,1,2,3...)
提示
有一个提议要将
Observable引入 ECMAScript 的未来版本。见github.com/tc39/proposal-observable。让我们创建一个将发出 1、2 和 3 的可观察对象,并订阅此可观察对象。
列表 D.1. 发出
1,2,3of(1,2,3) .subscribe( value => console.log(value), *1* err => console.error(err), *2* () => console.log("Streaming is over") *3* );-
1 处理可观察对象发出的值
-
2 处理错误
-
3 处理流完成消息
注意你向
subscribe()传递了三个胖箭头函数。这三个函数的组合是你的观察者的实现。第一个函数将为可观察对象发出的每个元素调用。第二个函数在发生错误时调用,提供表示错误的对象。第三个函数不接受任何参数,将在可观察对象流结束时调用。运行此代码示例将在控制台产生以下输出:^([1])¹
在 CodePen 中查看:
mng.bz/MwTz。打开底部的控制台视图以查看输出。1 2 3 Streaming is over注意
在附录 A 中,我们讨论了使用
Promise对象,该对象只能调用在then()函数中指定的事件处理器一次。将subscribe()方法视为在Promise对象上调用then()的替代品,但subscribe()的回调不仅调用一次,而是对每个发出的值都调用一次。D.4. 熟悉 RxJS 算子
当数据元素从可观察对象流向观察者时,你可以应用一个或多个 算子,这些是可以在将元素提供给观察者之前处理每个元素的函数。每个算子接受一个可观察对象作为输入,执行其操作,并返回一个新的可观察对象作为输出,如图 D.2 所示。
图 D.2. 算子:输入可观察对象,输出可观察对象
![]()
因为每个算子接受一个可观察对象并创建一个可观察对象作为其输出,所以算子可以被链式调用,以便每个可观察对象元素在传递给观察者之前可以经过几个转换。
RxJS 提供了大约 100 种不同的算子,它们的文档可能并不总是容易理解。从积极的一面来看,文档经常用宝石图来展示算子。你可以在
mng.bz/2534上熟悉宝石图的语法。图 D.3 显示了 RxJS 手册如何使用宝石图来展示map算子(见mng.bz/65G7)。图 D.3. map 算子
![]()
在顶部,一个弹珠图显示了一条水平线,其中形状代表了一串传入的可观察元素流。接下来,展示了特定操作符的作用。在底部,你看到另一条水平线,表示应用操作符后的输出可观察流。垂直线代表流的结束。当你看这个图时,把时间想象成从左到右移动。首先,发出了值 1,然后时间过去,发出了值 2,然后时间过去,发出了值 3,然后流结束。
map操作符接受一个转换函数作为参数,并将其应用于每个传入的元素。图 D.3 展示了将每个传入元素的值乘以 10 的map操作符。现在让我们熟悉一下
filter操作符的弹珠图,如图 D.4 所示。filter操作符接受一个函数谓词作为参数,如果发出的值满足条件则返回true,否则返回false。只有满足条件的值才会传递给订阅者。这个特定的图使用了粗箭头函数来检查当前元素是否为奇数。偶数不会进一步传递到链中的观察者。图 D.4. filter 操作符
![图片]()
操作符是可组合的,你可以将它们链起来,以便可观察发出的项在到达观察者之前可以由一系列操作符处理。
已弃用的操作符链式调用
在 RxJS 6 之前,你可以使用操作符之间的点来链式调用操作符。
列表 D.2. 可链式操作符
const beers = [ {name: "Stella", country: "Belgium", price: 9.50}, {name: "Sam Adams", country: "USA", price: 8.50}, {name: "Bud Light", country: "USA", price: 6.50} ]; from(beers) .filter(beer => beer.price < 8) *1* .map(beer => `${beer.name}: $${beer.price}`) *2* .subscribe( beer => console.log(beer), err => console.error(err) ); console.log("This is the last line of the script");-
1 应用 filter 操作符
-
2 点链式调用 map 操作符
从 RxJS 6 开始,链式调用操作符的唯一方法是通过使用
pipe()方法,将逗号分隔的操作符作为参数传递。下一节将介绍可连接操作符。D.4.1. 可连接操作符
可连接操作符是那些可以使用
pipe()函数链式调用的操作符。我们将首先讨论点链式操作符,以解释为什么在 RxJS 中引入了可连接操作符。如果你安装了版本 6 之前的 RxJS,你可以从
rxjs/add/operator目录导入点链式操作符。例如:import 'rxjs/add/operator/map'; import 'rxjs/add/operator/filter';这些操作符修补了
Observable.prototype的代码,并成为该对象的一部分。如果你后来决定从处理可观察流代码中移除,比如说filter操作符,但你忘记了移除相应的导入语句,那么实现filter的代码仍然会是Observable.prototype的一部分。当打包器尝试消除未使用的代码(摇树优化)时,它们可能会决定保留Observable中的filter操作符代码,即使它没有被应用在应用中。RxJS 5.5 引入了可连接的操作符,这是不修改
Observable的纯函数。您可以使用 ES6 导入语法(例如,import {map} from 'rxjs/operators')导入操作符,然后将它们包装到一个pipe()函数中,该函数接受可变数量的参数,或链式操作符。列表 D.2 中的订阅者将接收到与侧边栏“已弃用的操作符链”中的相同数据,但这是一个更好的版本,从树摇的角度来看,因为它使用了可连接的操作符。此列表包括导入语句,假设 RxJS 已本地安装。
列表 D.3. 使用可连接的操作符
import {map, filter} from 'rxjs/operators'; *1* import {from} from 'rxjs'; *2* ... from(beers) .pipe( *3* filter(beer => beer.price < 8), map(beer => `${beer.name}: $${beer.price}`) ) .subscribe( beer => console.log(beer), err => console.error(err) );-
1 导入 from() 函数
-
2 从 rxjs/operators 而不是 rxjs/add/operator 导入可连接的操作符
-
3 将可连接的操作符包装到 pipe() 函数中
现在如果您从列表 D.2 中删除
filter行,打包器(如 Webpack 4)的树摇功能可以识别导入的函数没有被使用,并且filter操作符的代码将不会包含在包中.^([2])²
在 CodePen 中查看:
mng.bz/RqO5。默认情况下,
from()函数返回一个同步的可观察对象,但如果您想要一个异步的,请使用第二个参数指定一个异步调度程序:from(beers, Scheduler.async)在前面的代码示例中,进行此更改将首先打印“这是脚本的最后一行”,然后才会发出啤酒信息。您可以在
mng.bz/744Y了解更多关于调度程序的信息。现在我们想介绍
reduce操作符,它允许您聚合由可观察对象发出的值。reduce操作符的宝石图显示在图 D.5 中。此图显示了一个发出 1、3 和 5 的可观察对象,reduce操作符将它们相加,产生累积值为 9。图 D.5. reduce 操作符
![]()
reduce操作符有两个参数:一个累加函数,其中我们指定如何聚合值,以及累加函数使用的初始(种子)值。图 D.5 显示使用了 0 作为初始值,但如果我们将其更改为 10,累积的结果将是 19。如您在图 D.5 中看到的那样,累加函数也有两个参数:
-
acc存储当前累积的值,该值对每个发出的元素都可用。 -
curr存储当前发出的值。
以下列表创建了一个从
beers数组中生成的可观察对象,并对每个发出的元素应用两个操作符:map和reduce。map操作符从一个beer对象中提取其价格,而reduce操作符则将这些价格相加。列表 D.4. 使用
map和reduce操作符const beers = [ {name: "Stella", country: "Belgium", price: 9.50}, {name: "Sam Adams", country: "USA", price: 8.50}, {name: "Bud Light", country: "USA", price: 6.50}, {name: "Brooklyn Lager", country: "USA", price: 8.00}, {name: "Sapporo", country: "Japan", price: 7.50} ]; from(beers) .pipe( map(beer => beer.price), *1* reduce( (total, price) => total + price, 0) *2* ) .subscribe( totalPrice => console.log(`Total price: ${totalPrice}`) *3* );-
1 将啤酒对象转换为其价格
-
2 计算所有啤酒的总价
-
3 打印所有啤酒的总价
运行此脚本将生成以下输出:
Total price: 40在这个脚本中,我们正在添加所有价格,但我们可以对聚合值应用任何其他计算,例如计算平均值或最大价格。
当可观察对象完成时,
reduce操作符会发出聚合结果。在这个例子中,这是自然发生的,因为我们从一个有限元素数量的数组创建了一个可观察对象。在其他场景中,我们需要显式调用观察者的complete()方法;你将在下一节中看到如何做到这一点.^([3])³
在 CodePen 中查看:
mng.bz/68fR。本节中的代码示例已经将数组转换成可观察对象,并神奇地将数组元素推送到观察者。在下一节中,我们将向你展示如何通过在观察者上调用
next()函数来推送元素。调试可观察对象
tap操作符可以对源可观察的每个值执行副作用(例如,记录一些数据),但返回的观察者与源相同。特别是,这些操作符可以用于调试目的。假设你有一系列操作符,并想在应用某个操作符前后查看可观察的值。
tap操作符将允许你记录这些值:import { map, tap } from 'rxjs/operators'; myObservable$ .pipe( tap(beer => console.log(`Before: ${beer}`)), map(beer => `${beer.name}, ${beer.country}`), tap(beer => console.log(`After: ${beer}`)) ) .subscribe(...);在这个例子中,你在
map操作符应用前后打印了发出的值。tap操作符不会改变可观察数据——它将其传递给下一个操作符或subscribe()方法。D.5. 使用观察者 API
一个观察者是一个实现一个或多个这些函数的对象:
next()、error()和complete()。让我们使用一个对象字面量来展示一个观察者,但稍后在本节中,我们将使用箭头函数的简化语法:const beerObserver = { next: function(beer) { console.log(`Subscriber got ${beer.name}`)}, error: function(err) { console.err(error)}, complete: function() {console.log("The stream is over")} }我们可以使用
create方法创建一个可观察对象,传递一个表示观察者的参数。当可观察对象被创建时,它还不知道将提供哪个具体对象。这将在订阅时才知道:const beerObservable$ = Observable.create( observer => observer.next(beer));这个特定的观察者认为“当有人订阅我的啤酒时,他们会提供一个具体的啤酒消费者,而我将向这个人推送一个啤酒对象。”在订阅时,我们将一个具体的观察者提供给我们的可观察对象:
beerObservable$.subscribe(beerObserver);观察者将获得啤酒,并在控制台打印类似以下内容:
Subscriber got Stella下一个列表有一个完整的脚本,展示了创建观察者、可观察对象和订阅的过程。
getObservableBeer()函数创建并返回一个可观察对象,该对象将遍历啤酒数组,并通过调用next()将每个啤酒对象推送到观察者。之后,我们的可观察对象将在观察者上调用complete(),表示不会有更多的啤酒。列表 D.5. 使用
Observable.create()function getObservableBeer(){ return Observable.create( observer => { *1* const beers = [ {name: "Stella", country: "Belgium", price: 9.50}, {name: "Sam Adams", country: "USA", price: 8.50}, {name: "Bud Light", country: "USA", price: 6.50}, {name: "Brooklyn Lager", country: "USA", price: 8.00}, {name: "Sapporo", country: "Japan", price: 7.50} ]; beers.forEach( beer => observer.next(beer)); *2* observer.complete(); *3* } ); } getObservableBeer() .subscribe( *4* beer => console.log(`Subscriber got ${beer.name}`), error => console.err(error), () => console.log("The stream is over") );-
1 创建并返回可观察对象
-
2 将每个啤酒推送到观察者
-
3 将流结束消息推送到观察者
-
4 订阅到可观察对象,以三个胖箭头函数的形式提供观察者对象
下一个脚本输出的结果如下:^([4])
⁴
在 CodePen 中查看:
mng.bz/Q7sb.Subscriber got Stella Subscriber got Sam Adams Subscriber got Bud Light Subscriber got Brooklyn Lager Subscriber got Sapporo The stream is over在我们的代码示例中,我们在观察者上调用
next()和complete()。但请记住,可观察对象只是一个数据推送者,总有一个数据生产者(在我们的例子中是啤酒数组)可能会生成错误。在这种情况下,我们会调用observer.error(),并且流会完成。有一种方法可以在订阅者端拦截错误以保持流的状态,这在 D.9 节 中讨论。需要注意的是,我们的数据生产者(啤酒数组)是在
getObservableBeer()可观察对象内部创建的,这使得它成为一个冷可观察对象。WebSocket 可以是另一个生产者的例子。想象一下,我们在服务器上有一个啤酒数据库,并且可以通过 WebSocket 连接请求它们(我们在这里可以使用 HTTP 或任何其他协议):Observable.create((observer) => { const socket = new WebSocket('ws://beers'); socket.addEventListener('message', (beer) => observer.next(beer)); return () => socket.close(); // is invoked on unsubscribe() });对于冷可观察对象,如果查询条件(在我们的例子中,显示所有啤酒)相同,每个订阅者将获得相同的啤酒,无论订阅的时间如何。
D.6. 使用 RxJS Subject
RxJS
Subject是一个包含可观察对象和观察者(s)的对象。这意味着你可以使用next()向其观察者(s)推送数据,也可以订阅它。Subject可以有多个观察者,这使得它在需要实现 多播 时很有用——向多个订阅者发送值,如图 D.6 所示。图 D.6. RxJS
Subject![]()
假设你有一个
Subject的实例和两个订阅者。如果你向主题推送一个值,每个订阅者都会收到它:const mySubject$ = new Subject(); const subscription1 = mySubject$.subscribe(...);? const subscription2 = mySubject$.subscribe(...);? ... ?mySubject$.next(123); // each subscriber gets 123以下示例有一个
Subject和两个订阅者。第一个值被发送到两个订阅者,然后其中一个取消订阅。第二个值被发送到一个活跃的订阅者。列表 D.6. 一个主题和两个订阅者
const mySubject$ = new Subject(); const subscriber1 = mySubject$ .subscribe( x => console.log(`Subscriber 1 got ${x}`) ); *1* const subscriber2 = mySubject$ .subscribe( x => console.log(`Subscriber 2 got ${x}`) ); *2* mySubject$.next(123); *3* subscriber2.unsubscribe(); *4* mySubject$.next(567); *5*-
1 创建第一个订阅者
-
2 创建第二个订阅者
-
3 向订阅者推送值 123(我们有两个订阅者)
-
4 取消第二个订阅者的订阅
-
5 向订阅者推送值 567(现在我们只有一个订阅者)
运行此脚本在控制台产生以下输出:^([5])
⁵
在 CodePen 中查看:
mng.bz/jx16.Subscriber 1 got 123 Subscriber 2 got 123 Subscriber 1 got 567小贴士
有一个命名约定,即以美元符号结束
Observable或Subject类型的变量名。现在让我们考虑一个更实际的例子。一家金融公司有交易员可以下单买卖股票。每当交易员下单时,它必须交给两个脚本(订阅者):
-
知道如何向证券交易所下订单的脚本
-
知道如何向跟踪所有交易活动的交易委员会报告每个订单的脚本
以下清单,用 TypeScript 编写,展示了如何确保当交易者放置订单时,两个订阅者都能立即收到订单。我们创建了一个名为
orders的Subject实例,并且每次我们调用它的next()方法时,两个订阅者都会收到订单。列表 D.7. 广播交易订单
enum Action{ *1* Buy = 'BUY', Sell = 'SELL' } class Order{ *2* constructor(public orderId: number, public traderId: number, public stock: string, public shares: number, public action:Action){} } const orders$ = new Subject<Order>(); *3* class Trader { *4* constructor(private traderId:number, private traderName:string){} placeOrder(order: Order){ orders$.next(order); *5* } } const stockExchange = orders$.subscribe( *6* ord => console.log(`Sending to stock exchange the order to ${ord.action} ${ord.shares} shares of ${ord.stock}`)); const tradeCommission = orders$.subscribe( *7* ord => console.log(`Reporting to trade commission the order to ${ord.action} ${ord.shares} shares of ${ord.stock}`)); const trader = new Trader(1, 'Joe'); const order1 = new Order(1, 1,'IBM',100,Action.Buy); const order2 = new Order(2, 1,'AAPL',100,Action.Sell); trader.placeOrder( order1); *8* trader.placeOrder( order2); *9*-
1 使用枚举声明允许订单执行的操作
-
2 代表订单的类
-
3 仅与订单对象一起工作的主题实例
-
4 代表交易者的类
-
5 当订单被放置时,将其推送到订阅者
-
6 股票交易所订阅者
-
7 交易佣金订阅者
-
8 放置第一订单
-
9 放置第二订单
运行 清单 D.6 产生以下输出:^([6])
⁶
在 CodePen 中查看:
mng.bz/4PIH。Sending to stock exchange the order to BUY 100 shares of IBM Reporting to trade commission the order to BUY 100 shares of IBM Sending to stock exchange the order to SELL 100 shares of AAPL Reporting to trade commission the order to SELL 100 shares of AAPL注意
在 清单 D.6 中,我们使用了 TypeScript 枚举,这允许我们定义有限数量的常量。将买卖操作放在
enum中提供了额外的类型检查,以确保我们的脚本只使用允许的操作。如果我们使用像"SELL"或"BUY"这样的字符串常量,开发者在创建订单时可能会拼写错误("BYE")。通过声明enum Action,我们限制可能的行为为Action.Buy或Action.Sell。尝试使用Action.Bye将导致编译错误。| |
小贴士
我们用 TypeScript 编写了 清单 D.6,但如果您想看到它的 JavaScript 版本,请在包含此附录的项目中运行
npm install和tsc命令。原始代码位于 subject-trader.ts 文件中,编译版本位于 subject-trader.js 中。第六章 包含了一个使用
BehaviorSubject的示例——这是Subject的一个特殊版本,它总是将其最后或初始值发送给新订阅者。D.7.
flatMap操作符在某些情况下,您需要将可观察对象发出的每个项视为另一个可观察对象。外部可观察对象发出内部可观察对象。这意味着您需要编写嵌套的
subscribe()调用(一个用于外部可观察对象,另一个用于内部可观察对象)吗?不,您不需要。flatMap操作符会自动订阅外部可观察对象的每个项。一些操作符在 RxJS 文档中解释得不够好,我们建议您参考通用 ReactiveX (reactive extensions) 文档以获得澄清。
flatMap操作符在mng.bz/7RQB上有更好的解释,该文档指出flatMap用于“将可观察对象发出的项转换为可观察对象,然后将这些可观察对象的输出扁平化为单个可观察对象。” 该文档包括图 D.7 中所示的宝石图。图 D.7.
flatMap操作符![]()
如您所见,
flatMap操作符从外部可观察对象(圆圈)中取出一个发出的项目,并将其内容(菱形内部的可观察对象)展开到扁平化的输出可观察对象流中。flatMap操作符合并内部可观察对象的发出,因此它们的项可能交错。列表 D.8 有一个发出饮料的可观察对象,但这次它发出的是盘而不是单个饮料。第一个盘装的是啤酒,第二个是软饮料。每个盘都是一个可观察对象。我们希望将这些两个盘转换为包含单个饮料的输出流。
列表 D.8. 使用
flatMap展开嵌套可观察对象function getDrinks() { const beers$ = from([ *1* {name: "Stella", country: "Belgium", price: 9.50}, {name: "Sam Adams", country: "USA", price: 8.50}, {name: "Bud Light", country: "USA", price: 6.50} ], Scheduler.async); const softDrinks$ = from([ *2* {name: "Coca Cola", country: "USA", price: 1.50}, {name: "Fanta", country: "USA", price: 1.50}, {name: "Lemonade", country: "France", price: 2.50} ], Scheduler.async); return Observable.create( observer => { observer.next(beers$); *3* observer.next(softDrinks$); *4* observer.complete(); } ); } // We want to "unload" each palette and print each drink info getDrinks() .pipe(flatMap(drinks => drinks)) *5* .subscribe( *6* drink => console.log(`Subscriber got ${drink.name}: ${drink.price}`), error => console.err(error), () => console.log("The stream of drinks is over") );-
1 从啤酒创建一个异步可观察对象
-
2 从软饮料创建一个异步可观察对象
-
3 使用 next() 发出啤酒可观察对象
-
4 使用 next() 发出软饮料可观察对象
-
5 将饮料盘中的饮料卸载到合并的可观察对象中
-
6 订阅到合并的可观察对象
这个脚本将产生如下所示的输出:^([7])
⁷
在 CodePen 中查看:
mng.bz/F38l。Subscriber got Stella: 9.5 Subscriber got Coca Cola: 1.5 Subscriber got Sam Adams: 8.5 Subscriber got Fanta: 1.5 Subscriber got Bud Light: 6.5 Subscriber got Lemonade: 2.5 The stream of observables is over除了卸载饮料盘之外,
flatMap操作符还有其他用途吗?另一个您可能想要使用flatMap的场景是当您需要执行多个 HTTP 请求时,第一个请求的结果应该传递给第二个请求,如下面的列表所示。在 Angular 中,HTTP 请求返回可观察对象,如果没有flatMap(),这可以通过嵌套的subscribe调用来完成(这是一种不好的风格)。列表 D.9. 在 Angular 中订阅 HTTP 请求
this.httpClient.get('/customers/123') .subscribe(customer => { this.httpClient.get(customer.orderUrl) .subscribe(response => this.order = response) })HttpClient.get()方法返回一个Observable,更好的编写前面代码的方式是使用flatMap操作符,它会自动订阅,展开第一个可观察对象的内容,并执行另一个 HTTP 请求:import {flatMap} from 'rxjs/operators'; ... httpClient.get('/customers/123') .pipe( flatMap(customer => this.httpClient.get(customer.orderUrl)) ) .subscribe(response => this.order = response);因为
flatMap是map的一个特例,您可以在将可观察对象扁平化到公共流时指定一个转换函数。在前面的例子中,我们将值customer转换为一个函数调用HttpClient.get()。让我们考虑使用
flatMap的另一个例子。这是一个之前使用的主题-交易员示例的修改版本。这个例子是用 TypeScript 编写的,并使用了两个Subject实例:-
traders$— 这个Subject跟踪交易员。 -
orders$— 这个Subject在Trader类内部声明,并跟踪特定交易员放置的每个订单。
您是想要监控所有交易员下单的经理。没有
flatMap,您需要订阅traders$(外部可观察对象)并为每个主题创建一个嵌套的orders$(内部可观察对象)订阅。使用flatMap允许您只写一个subscribe()调用,该调用将接收每个交易员的一个流中的内部可观察对象,如下面的列表所示。列表 D.10. 两个主题和
flatMapenum Action{ *1* Buy = 'BUY', Sell = 'SELL' } class Order{ constructor(public orderId: number, public traderId: number, public stock: string, public shares: number, public action: Action){} } let traders$ = new Subject<Trader>(); *2* class Trader { orders$ = new Subject<Order>(); *3* constructor(private traderId: number, public traderName: string) {} } let tradersSubscriber = traders$.subscribe (trader => console.log(`Trader ${trader.traderName} arrived`)); let ordersSubscriber = traders$ *4* .pipe(flatMap(trader => trader.orders$)) *5* .subscribe(ord => *6* console.log(`Got order from trader ${ord.traderId} to ${ord.action} ${ord.shares} shares of ${ord.stock}`)); let firstTrader = new Trader(1, 'Joe'); let secondTrader = new Trader(2, 'Mary'); traders$.next(firstTrader); traders$.next(secondTrader); let order1 = new Order(1,1,'IBM',100,Action.Buy); let order2 = new Order(2,1,'AAPL',200,Action.Sell); let order3 = new Order(3,2,'MSFT',500,Action.Buy); // Traders place orders firstTrader.orders$.next(order1); firstTrader.orders$.next(order2); secondTrader.orders$.next(order3);-
1 使用 TypeScript 枚举来定义动作类型
-
2 声明交易者的
Subject -
3 每个交易者都有自己的订单
Subject。 -
4 从外部可观察对象
traders$开始 -
5 从每个交易者实例中提取内部可观察对象
-
6 函数
subscribe()接收一个订单流。
注意
包含字符串常量的枚举定义了动作类型。您可以在
mng.bz/sTmp了解 TypeScript 枚举。在这个程序版本中,
Trader类没有placeOrder()方法。我们只有通过orders$可观察对象使用next()方法将订单推送到其观察者。记住,Subject既有可观察对象也有观察者。下一个展示的是这个程序的输出:
Trader Joe arrived Trader Mary arrived Got order from trader 1 to BUY 100 shares of IBM Got order from trader 1 to SELL 200 shares of AAPL Got order from trader 2 to BUY 500 shares of MSFT在我们的例子中,订阅者将在控制台打印订单,但在现实世界的应用程序中,它可能调用另一个函数,该函数将订单提交给证券交易所执行。8]
⁸
在 CodePen 中查看:
mng.bz/4qC3。D.8.
switchMap操作符而
flatMap会展开并合并外部可观察对象值中的 所有数据,而switchMap操作符处理外部可观察对象中的数据,如果外部可观察对象发出新值,则会取消正在处理的内部订阅。switchMap操作符更容易通过其水滴图来解释,如图 D.8 所示。图 D.8.
switchMap操作符![]()
对于阅读本书印刷版的人来说,我们需要说明外部可观察对象中的圆圈是红色、绿色和蓝色(从左到右)。外部可观察对象发出红色圆圈,
switchMap将内部可观察对象的项目(红色菱形和正方形)发射到输出流中。红色圆圈在内部可观察对象完成处理之后没有中断地被处理,因为绿色圆圈是在内部可观察对象处理完成后发出的。情况与绿色圆圈不同。
switchMap成功展开了绿色菱形并发出它,但蓝色圆圈在绿色正方形被处理之前到达。绿色内部可观察对象的订阅被取消,绿色正方形从未被发射到输出流中。switchMap操作符 切换 到处理蓝色内部可观察对象。清单 D.11 包含两个可观察对象。外部可观察对象使用
interval()函数,每秒发出一个连续数字。借助take操作符,我们限制其输出为两个值:0和1。这些值分别传递给switchMap操作符,内部可观察对象以 400 毫秒的间隔发出三个数字。清单 D.11. 两个可观察对象和
switchMaplet outer$ = interval(1000) *1* .pipe(take(2)); *2* let combined$ = outer$ .pipe(switchMap((x) => { return interval(400) *3* .pipe( take(3), map(y => `outer ${x}: inner ${y}`) ) }) );-
1 外部可观察对象
-
2 此
take操作符将仅从流中获取前两个项目。 -
3 内部可观察对象
combined$.subscribe(result => console.log(`${result}`));下一个展示的是 清单 D.10 的输出:
outer 0: inner 0 outer 0: inner 1 outer 1: inner 0 outer 1: inner 1 outer 1: inner 2注意,第一个内部可观察对象没有发出其第三个值
2。以下是时间线:1. 外部可观察对象发出
0,内部在 400 毫秒后发出0。2. 800 毫秒后,内部可观察对象发出
1。3. 1000 毫秒后,外部可观察对象发出
1,内部可观察对象被取消订阅。4. 第二个外部值的三个内部发射没有中断,因为它没有发出任何新的值。
如果你将
flatMap替换为switchMap,内部可观察对象将为每个外部值发出三个值,如下所示:^([9])⁹
在 CodePen 中查看:
mng.bz/Y9IA。outer 0: inner 0 outer 0: inner 1 outer 0: inner 2 outer 1: inner 0 outer 1: inner 1 outer 1: inner 2你很少会编写发出整数的内外部可观察对象。第六章 解释了
switchMap操作符的一个非常实用的用途。想象一下,一个用户在一个输入字段中输入(外部可观察对象),并且每次
keyup事件都会发起 HTTP 请求(内部可观察对象)。图 D.8 中的圆圈是用户输入的三个字符。内部可观察对象是为每个字符发出的 HTTP 请求。如果用户在第二个 HTTP 请求尚未完成时输入了第三个字符,内部可观察对象将被取消并丢弃。小贴士
如果你想要根据指定的时间间隔定期调用另一个函数,
interval()函数很有用。例如,interval(1000).subscribe(n => doSomething())将导致每秒调用一次doSomething()函数。D.9. 使用
catchError处理错误《响应式宣言》(见 www.reactivemanifesto.org)声明,一个响应式应用应该是弹性的,这意味着应用应该实施一个程序来确保在出现故障时保持其存活。一个可观察对象可以通过在观察者上调用
error()函数来发出错误,但当调用error()方法时,流会完成。RxJS 提供了几个操作符来拦截和处理错误,在它到达观察者的
error()方法上的代码之前:-
catchError(error)— 拦截错误,你可以实现一些业务逻辑来处理它 -
retry(n)— 重试错误操作最多 n 次 -
retryWhen(fn)— 根据提供的函数重试错误操作
接下来,我们将向你展示如何使用可连接的
catchError操作符的例子。在catchError操作符内部,你可以检查错误状态并相应地做出反应。列表 D.12 展示了如何拦截错误,如果错误状态是 500,则切换到不同的数据生产者以获取缓存的数据。如果接收到的错误状态不是 500,此代码将返回一个空的观察者,数据流将完成。在任何情况下,观察者的error()方法都不会被调用。列表 D.12. 使用
catchError拦截错误.pipe( catchError(err => { console.error("Got " + err.status + ": " + err.description); if (err.status === 500){ console.error(">>> Retrieving cached data"); return getCachedData(); // failover } else{ return EMPTY; // don't handle the error } }))列表 D.13 展示了完整的示例,其中我们订阅了来自主数据源——
getData()——的啤酒流,该流随机生成状态为 500 的错误。catchError操作符拦截此错误并切换到备用源:getCachedData()。列表 D.13. 使用
catchError实现故障转移function getData(){ const beers = [ {name: "Sam Adams", country: "USA", price: 8.50}, {name: "Bud Light", country: "USA", price: 6.50}, {name: "Brooklyn Lager", country: "USA", price: 8.00}, {name: "Sapporo", country: "Japan", price: 7.50} ]; return Observable.create( observer => { let counter = 0; beers.forEach( beer => { observer.next(beer); *1* counter++; if (counter > Math.random() * 5) { *2* observer.error({ status: 500, description: "Beer stream error" }); } } ); observer.complete();} ); } // Subscribing to data from the primary source getData() .pipe( catchError(err => { *3* console.error(`Got ${err.status}: ${err.description}`); if (err.status === 500){ console.error(">>> Retrieving cached data"); return getCachedData(); *4* } else{ return EMPTY; *5* } }), map(beer => `${beer.name}, ${beer.country}`) ) .subscribe( beer => console.log(`Subscriber got ${beer}`), err => console.error(err), () => console.log("The stream is over") ); function getCachedData(){ *6* const beers = [ {name: "Leffe Blonde", country: "Belgium", price: 9.50}, {name: "Miller Lite", country: "USA", price: 8.50}, {name: "Corona", country: "Mexico", price: 8.00}, {name: "Asahi", country: "Japan", price: 7.50} ]; return Observable.create( observer => { beers.forEach( beer => { observer.next(beer); } ); observer.complete();} ); }-
1 从主数据源发出下一个啤酒
-
2 随机生成状态为 500 的错误
-
3 在错误到达观察者之前拦截错误
-
4 转换到备用数据源
-
5 不处理非 500 状态的错误;返回一个空的 observable 以完成流
-
6 备用数据源以实现故障转移
该程序的输出可能如下所示:^([10])
¹⁰
在 CodePen 中查看:
mng.bz/QBye.Subscriber got Sam Adams, USA Subscriber got Bud Light, USA Got 500: Beer stream error >>> Retrieving cached data Subscriber got Leffe Blonde, Belgium Subscriber got Miller Lite, USA Subscriber got Corona, Mexico Subscriber got Asahi, Japan The stream is over
-


























































































浙公网安备 33010602011771号