构建大规模-Angular-Web-应用-全-

构建大规模 Angular Web 应用(全)

原文:zh.annas-archive.org/md5/DA167AD27703E0822348016B6A3A0D43

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

如果您之前曾受到不可靠的 JavaScript 框架的影响,那么 Angular 平台的成熟度将让您惊叹不已。Angular 可以帮助您构建快速、高效、真实世界的 Web 应用程序。通过本学习路径,您将学习 Angular,并从设计到部署交付高质量、生产就绪的 Angular 应用程序。

您将通过使用 Angular 的构建块创建一个简单的健身应用程序,并将其最终转变为一个全面的个人健身构建器和运行器的最终应用程序“Personal Trainer”,利用 Angular 最基本和强大的特性 - 指令构建。

通过本书,您将学习使用 RxJS 架构 Angular 应用程序的不同方式,以及其中涉及的一些模式。之后,您将介绍路由器优先架构,这是一种设计和开发中大型企业应用程序的七步方法,以及流行的应用程序示例。通过本书的深入学习,您将熟悉使用 Angular、Swagger 和 Docker 进行 Web 开发的范围,学习成功作为 Web 上的个人开发者或企业团队的模式和实践。

本学习路径包括以下 Packt 产品的内容:

  • 《Angular 6 指南》,作者:Chandermani Arora, Kevin Hennessy

  • 《使用 Redux、RxJS 和 NgRx 架构 Angular 应用程序》,作者:Christoffer Noring

  • 《Angular 6 企业级 Web 应用》,作者:Doguhan Uluca

本书面向读者

如果您是一名 JavaScript 或前端开发人员,希望全面了解如何使用 Angular 构建端到端企业就绪的应用程序,那么这本学习路径适合您。

本书包括哪些内容

《第一章》,构建我们的第一个应用程序 - 7 分钟锻炼,教会我们如何构建我们的第一个真正的 Angular 应用程序。在这个过程中,我们将更多了解 Angular 的主要构建块之一,即组件。我们还将介绍 Angular 的模板构造、数据绑定能力和服务。

《第二章》,个人健身教练,介绍一个新的练习,我们将 7 分钟锻炼改造成一个通用的个人健身教练应用程序。该应用程序具有创建除原始的 7 分钟锻炼以外的新锻炼计划的能力。本章介绍了 Angular 的表单功能以及如何使用它们来构建自定义锻炼。

第三章,《支持服务器数据持久性》,涉及从服务器保存和检索锻炼数据。随着我们探索 Angular 的 HTTP 客户端库以及它如何使用 RxJS 可观察对象,我们为 Personal Trainer 增添了持久性功能。

《第四章》,深入理解 Angular 指令,深入探讨了 Angular 指令和组件的内部工作原理。我们构建了许多指令来支持 Personal Trainer。

第五章,1.21 Gigawatt – Flux 模式解释,教授了什么是 Flux 模式以及它包含的概念。展示了如何使用 stores、dispatcher 和几个视图实现 Flux 模式。

第六章,函数式反应式编程,深入探讨了函数式编程的某些特性,如高阶函数、不可变性和递归。此外,我们还将看到如何使代码具有响应性以及响应性的意义。

第七章,操作流和它们的值,着重向读者传授操作符的知识,这就是为什么 RxJS 如此强大的原因。读者应该在本章中对数据操作以及 Observables 有更多的了解。

第八章,RxJS 高级,深入讨论了更高级的 RxJS 概念,例如热和冷 Observables,subjects,错误处理以及如何使用 Marble 测试来测试 RxJS 代码。

第九章,创建本地天气 Web 应用程序,介绍了用于沟通想法的易于使用的设计工具的看板方法软件开发。还涵盖了 Angular 基础知识、单元测试以及如何利用 CLI 工具最大程度地发挥作用。

第十章,为生产发布准备 Angular 应用,介绍如何使用 Docker 中的容器化来实现云部署。

第十一章,使用 Angular Material 增强 Angular 应用,介绍了 Angular Material 并解释了如何使用它构建外观漂亮的应用程序。

第十二章,创建基于路由的企业级应用程序,专注于路由优先架构,一个设计和开发中等到大型应用程序的七步方法。

第十三章,持续集成和 API 设计,介绍了使用 CircleCI 进行持续集成的方法以及利用 Swagger 与后端 API 进行早期集成。

第十四章,设计身份验证和授权,深入探讨了 Angular 和 RESTful 应用程序中与身份验证和授权相关的模式。

第十五章,Angular 应用程序设计和技巧,包含了常见的企业应用程序所需的技巧。

第十六章,AWS 上的高可用云基础设施,不仅涉及应用程序特性,还介绍了在 AWS 上提供高可用云基础设施的规划。

要充分利用本书

我们将使用 TypeScript 语言来构建我们的应用程序;因此,最好使用能够轻松开发 TypeScript 的 IDE。如 Atom、Sublime、WebStorm 和 Visual Studio (或 VS Code)都是此目的的绝佳工具。

下载示例代码文件

您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support注册,直接通过电子邮件接收文件。

您可以通过以下步骤下载代码文件:

  1. www.packtpub.com上登录或注册。

  2. 选择“支持”标签。

  3. 单击代码下载和勘误

  4. 在搜索框中输入书名,然后按照屏幕上的说明进行操作。

下载文件后,请确保使用最新版本的以下工具解压缩或提取文件夹:

  • Windows 平台上使用 WinRAR/7-Zip

  • Mac 平台上使用 Zipeg/iZip/UnRarX

  • Linux 平台上使用 7-Zip/PeaZip

本书的代码束也托管在 GitHub 上,网址为github.com/chandermani/angular6byexamplegithub.com/PacktPublishing/Architecting-Angular-Applications-with-Redux-RxJs-and-NgRx。第三模块的代码束托管在作者的 GitHub 代码库中,网址为github.com/duluca/local-weather-appgithub.com/duluca/lemon-mart。如果代码有更新,将在现有的 GitHub 代码库中更新。

我们还在我们丰富的图书和视频目录中有其他代码束可供选择,网址为**github.com/PacktPublishing/**。去看看吧!

采用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号。以下是一个例子:“我们可以看到路由器如何将app.routes.ts中的路由与workout-builder.routes.ts中的默认路由合并”。

代码块设置如下所示:

"styles": [
   "node_modules/bootstrap/dist/css/bootstrap.min.css",
   "src/styles.css"
],

当我们希望引起您对代码块的特定部分的注意时,相关行或项会以粗体显示:

const routes: Routes = [
    ...
    { path: 'builder', loadChildren: './workout-builder/workout-builder.module#WorkoutBuilderModule'},
    { path: '**', redirectTo: '/start' }
];

在命令行中输入或输出如下所示:

ng new guessthenumber --inlineTemplate

粗体:表示一个新术语、一个重要词或屏幕上看到的字词。例如,菜单或对话框中的字词会以这种方式出现在文本中。以下是一个例子:“在开发者工具中打开Sources标签”

警告或重要说明如下显示。

类似于此出现的小技巧和窍门。

第一章:构建我们的第一个应用程序 - 7 分钟锻炼

我们将在 Angular 中构建一个新的应用程序,并在此过程中更加熟悉该框架。此应用程序还将帮助我们探索 Angular 的一些新功能。

本章我们将涉及的主题包括以下内容:

  • 7 分钟锻炼问题描述: 我们详细描述了本章中我们构建的应用程序的功能。

  • 代码组织: 对于我们的第一个真正的应用程序,我们将尝试解释如何组织代码,特别是 Angular 代码。

  • 设计模型: 我们应用程序的基本构建块之一是其模型。我们根据应用程序的需求设计应用程序模型。

  • 理解数据绑定基础设施: 在构建7 分钟锻炼视图时,我们将了解框架的数据绑定能力,其中包括属性属性样式事件绑定。

  • 探索 Angular 平台指令: 我们将涉及的一些指令有 ngForngIfngClassngStylengSwitch

  • 使用输入属性进行跨组件通信: 当我们构建嵌套组件时,我们学习了如何使用输入属性将数据从父组件传递给其子组件。

  • 使用事件进行跨组件通信: Angular 组件可以订阅和触发事件。我们将介绍 Angular 中的事件绑定支持。

  • Angular 管道: Angular 管道提供了一种格式化视图内容的机制。我们将探索一些标准的 Angular 管道,并构建我们自己的管道以支持从秒数转换为 hh:mm:ss 的转换。

让我们开始吧!我们要做的第一件事是定义我们的7 分钟锻炼应用程序。

什么是 7 分钟锻炼?

我们希望每个阅读本书的人都能保持身体健康。因此,本书应该具有双重目的;它不仅应该刺激您的大脑,还应该敦促您关注您的身体健康。有什么比构建一个针对身体健康的应用程序更好的方法呢!

7 分钟锻炼 是一款在七分钟内快速连续进行一组 12 个练习的锻炼应用程序。7 分钟锻炼 因其短小而产生了巨大的好处而变得相当受欢迎。我们无法证实或否认这些说法,但进行任何形式的费力的体力活动比不做任何事情要好。如果您对这项锻炼感兴趣,那么请访问 well.blogs.nytimes.com/2013/05/09/the-scientific-7-minute-workout/ 了解更多信息。

应用的技术细节包括完成一组 12 个练习,每个练习耗时 30 秒。在开始下一个练习之前,会有一个简短的休息时间。对于我们要构建的应用程序,我们将每次休息 10 秒。因此,总时长略长于七分钟。

在本章结束时,我们将准备好7 分钟锻炼应用程序,它看起来类似于以下内容:

7 分钟锻炼应用

下载代码库

该应用程序的代码可以从专门为本书创建的 GitHub 站点(github.com/chandermani/angular6byexample)上下载。由于我们正在逐步构建该应用程序,我们已经创建了多个检查点,与GitHub 分支checkpoint2.1checkpoint2.2等相对应。在叙述过程中,我们会强调用于参考的分支。这些分支将包含直到该时间点为止的应用程序完成的工作。

7 分钟锻炼的代码可以在名为trainer的存储库文件夹中找到。

那么,让我们开始吧!

搭建构建环境

请记住,我们正在构建一个现代平台,浏览器仍然缺乏支持。因此,在 HTML 中直接引用脚本文件是行不通的(虽然常见,但这是一种应该避免的陈旧方法)。浏览器不理解TypeScript;这意味着必须有一个过程将使用 TypeScript 编写的代码转换为标准的JavaScript(ES5)。因此,为任何 Angular 应用程序设置构建变得至关重要。由于 Angular 的日益流行,我们永远不缺选项。

如果你是一个在 web 技术栈上工作的前端开发人员,那么你无法避免使用Node.js。这是最广泛使用的用于 web/JavaScript 开发的平台。因此,可以想像到大多数 Angular 构建解决方案都是由 Node 支持的。像GruntGulpJSPMwebpack这样的包是任何构建系统的常见构建模块。

由于我们正在建立在 Node.js 平台上,所以请在开始之前安装 Node.js。

对于本书和这个示例应用程序,我们推荐使用Angular CLIbit.ly/ng6be-angular-cli)。作为一个命令行工具,它有一个构建系统和一个脚手架工具,极大简化了 Angular 的开发工作流程。它受欢迎、易于设置、易于管理,并支持几乎所有现代构建系统应有的功能。关于它的更多内容稍后再说。

与任何成熟的框架一样,Angular CLI 并不是 Web 上唯一的选择。社区创建的一些值得注意的起始站点加构建设置如下:

启动站点 位置
angular2-webpack-starter bit.ly/ng2webpack
angular-seed github.com/mgechev/angular-seed

让我们从安装 Angular CLI 开始。在命令行上输入以下内容:

npm i -g @angular/cli

安装后,Angular CLI 会向我们的执行环境添加一个名为ng的新命令。要从命令行创建一个新的 Angular 项目,请运行以下命令:

ng new PROJECT-NAME

这将生成一个文件夹结构,一堆文件,一个模板 Angular 应用程序和一个预配置的构建系统。要从命令行运行应用程序,请执行以下命令:

ng serve --open

然后你就能看到一个基本的 Angular 应用程序在运行!

对于我们的7 分钟锻炼应用程序,我们不打算从零开始,而是打算从基于ng new生成的项目结构的版本进行一些微小的修改。按照以下步骤开始:

想知道默认项目包含什么?试试运行ng new PROJECT-NAME命令。查看生成的内容结构和 Angular CLI 文档,了解默认设置包含了哪些内容。

  1. bit.ly/ngbe-base下载这个应用程序的基础版本并解压到计算机上的某个位置。如果你熟悉 Git 的工作原理,你可以直接克隆存储库并检出base分支:
git checkout base

这段代码作为我们应用程序的起点。

  1. 从命令行导航到trainer文件夹,并执行npm install命令来安装我们应用程序的包依赖

在 Node.js 的世界中,是第三方库(比如我们应用程序中的 Angular)或者支持应用程序构建过程的库。npm 是一个命令行工具,用于从远程存储库中拉取这些包。

  1. 一旦 npm 从 npm 库中拉取了应用程序的依赖,我们就可以准备构建和运行应用程序了。从命令行输入以下命令:
    ng serve --open

这将编译并运行应用程序。如果构建过程正常进行,将会在默认的浏览器窗口/标签中打开一个基本的应用程序页面(http://localhost:4200/)。我们已经准备好开始在 Angular 中开发我们的应用程序了!

但在那之前,了解一下 Angular CLI 及我们对 Angular CLI 生成的默认项目模板进行的自定义会很有趣。

Angular CLI

Angular CLI 的创建旨在规范化和简化 Angular 应用程序的开发和部署工作流程。正如文档所建议的:

"Angular CLI 可以轻松创建一个应用程序,它已经可以直接使用。它已经遵循了我们的最佳实践!"

它包括:

  • 基于webpack的构建系统

  • 一个用于生成所有标准 Angular 组件的脚手架工具,包括模块、指令、组件和管道

  • 遵循Angular 样式指南bit.ly/ngbe-styleguide),确保我们在各种形状和大小的项目中使用社区驱动的标准

也许你从来没有听说过"样式指南"这个术语,或者不明白它的意义。在任何技术中,样式指南都是一组指导方针,帮助我们组织和编写易于开发、维护和扩展的代码。要理解和欣赏 Angular 自己的样式指南,需要对框架本身有一定的了解,而我们已经开始了这个过程。

  • 一个有针对性的代码检查器; Angular CLI 集成了codelyzerbit.ly/ngbe-codelyzer),这是一个静态代码分析工具,用于验证我们的 Angular 代码是否符合规定的一组规则,以确保我们编写的代码符合 Angular 风格指南中制定的标准。

  • 预配置的单元测试端到端e2e)测试框架

还有更多!

想象一下,如果我们必须手动完成所有这些工作!陡峭的学习曲线会迅速让我们感到不知所措。幸运的是,我们不必处理它,Angular CLI 会为我们完成。

Angular CLI 构建设置基于 webpack,但它不会公开底层 webpack 配置;这是有意的。Angular 团队希望将开发人员与 webpack 的复杂性和内部工作隔离开来。Angular CLI 的最终目标是消除任何入门障碍,使设置和运行 Angular 代码变得简单。

这并不意味着 Angular CLI 不可配置。有一个配置文件angular.json),我们可以用它来更改构建设置。我们将不在此处进行处理。查看 7 分钟锻炼的配置文件,并在这里阅读文档:bit.ly/ng6be-angular-cli-config

我们对默认生成的项目模板所做的调整包括:

  • style.css文件中引用了 Bootstrap CSS。

  • 升级了一些 npm 库版本。

  • 将生成代码的前缀配置更改为使用abe(Angular By Example 的简称)而不是app。通过这种更改,我们所有的组件和指令选择器将以abe作为前缀,而不是app。查看app.component.tsselectorabe-root,而不是app-root

谈到 Angular CLI 和构建的话题,有一件事情我们在继续之前应该了解。

我们编写的 TypeScript 代码会怎样呢?

代码转译

众所周知,浏览器只能处理 JavaScript,它们不理解 TypeScript。因此,我们需要一种机制将我们的 TypeScript 代码转换为纯 JavaScript(ES5是我们最安全的选择)。这个工作由TypeScript 编译器 完成。编译器接收 TypeScript 代码并将其转换为 JavaScript。这个过程通常被称为转译,而由于 TypeScript 编译器完成了这项工作,因此称为转译器

多年来,JavaScript 作为一种语言不断发展,每个新版本都为语言增加新的特性/功能。最新的版本,ES2015,继承自 ES5,并对语言进行了重大更新。虽然发布于 2015 年 6 月,但一些较旧的浏览器仍然缺乏对 JavaScript ES2015 版本的支持,这使得其采用成为一项挑战。

当将 TypeScript 代码转译为 JavaScript 时,我们可以指定要使用的 JavaScript 版本。如前所述,ES5 是最安全的选择,但如果我们计划只使用最新和最好的浏览器,那就选择 ES2015。对于 7 分钟锻炼应用程序,我们要转译的代码是 ES5 格式。我们在tsconfig.json中设置了这个 TypeScript 编译器的配置(参见target属性)。

有趣的是,转译可以在构建/编译时和运行时都发生:

  • 构建时转译:作为构建过程的一部分,将脚本文件(在我们的例子中,TypeScript 的.ts文件)编译成普通的 JavaScript。Angular CLI 就是实现构建时转译的。

  • 运行时转译:这发生在浏览器运行时。我们直接引用 TypeScript 文件(在我们的例子中是.ts文件),而浏览器中先加载的 TypeScript 编译器会即时编译这些脚本文件。这只适用于小范例/代码片段,因为加载转译器和即时转译代码会增加额外的性能开销。

转译的过程不仅局限于 TypeScript。针对 Web 的每一种编程语言,比如CoffeeScriptES2015(是的,JavaScript 本身!)或者其他任何浏览器不本能理解的语言,都需要转译。大多数语言都有转译器,而最著名的(除了 TypeScript)是tracuerbabel

Angular CLI 的构建系统负责设置 TypeScript 编译器,并设置文件监视器,以便在我们每次对 TypeScript 文件进行更改时重新编译代码。

如果你对 TypeScript 还不熟悉,要记住 TypeScript 并不依赖 Angular;实际上,Angular 是建立在 TypeScript 之上的。我强烈推荐你查看官方的 TypeScript 文档(www.typescriptlang.org/),并在 Angular 的范畴之外学习这门语言。

让我们回到我们正在构建的应用程序,开始探索代码设置。

代码组织

Angular CLI 的优势在于它规定了适用于各种应用程序规模的代码组织结构。下面是当前的代码组织结构:

  • trainer是应用程序的根文件夹。

  • trainer文件夹内的文件是配置文件和一些标准文件,这些文件都是标准的节点应用程序的一部分。

  • e2e文件夹将包含应用程序的端到端测试。

  • src是所有开发活动发生的主要文件夹。所有应用程序产物都放在src里。

  • src文件夹内的assets文件夹托管静态内容(比如图片、CSS、音频文件等)。

  • app文件夹里包含了应用的源代码。

  • environments文件夹对于设置不同部署环境(比如dev, qa, production)的配置非常有用。

要将 Angular 代码组织在 app 文件夹内,我们参考了 Angular 团队发布的 Angular 风格指南 (bit.ly/ng6be-style-guide)。

功能文件夹

风格指南建议使用 功能文件夹 来组织代码。使用功能文件夹,与单个功能相关的文件被放在一起。如果一个功能增长,我们将其进一步拆分为子功能,将代码存放在子文件夹中。考虑将 app 文件夹作为我们的第一个功能文件夹!随着应用程序的增长,app 将添加子功能以更好地组织代码。

让我们直接开始构建应用程序。我们的第一个关注点是应用程序的模型!

7 分钟训练模型

为这个应用程序设计模型需要我们首先详细描述 7 分钟训练 应用程序的功能方面,然后推导出一个满足这些要求的模型。根据早些定义的问题陈述,一些明显的要求如下:

  • 能够开始训练。

  • 提供关于当前练习及其进度的视觉线索。这包括以下内容:

    • 提供当前练习的视觉描述

    • 提供如何执行特定练习的逐步说明

    • 当前练习剩余时间

  • 当训练结束时通知用户。

我们将添加到这个应用程序的一些其他有价值的功能如下:

  • 暂停当前训练的能力。

  • 提供下一个要执行的练习的信息。

  • 提供音频线索,使用户可以在不断看屏幕的情况下执行训练。这包括:

    • 定时器点击声音

    • 下一个练习的详细信息

    • 表示即将开始的练习

  • 显示正在进行的练习的相关视频,并具备播放它们的能力。

如我们所见,这个应用程序的中心主题是 训练练习。在这里,一个训练是按特定顺序在特定时间内进行的一组练习。因此,让我们继续为我们的训练和练习定义模型。

根据刚才提到的要求,我们将需要关于一个练习的以下详细信息:

  • 名称。这应该是唯一的。

  • 标题。这将显示给用户。

  • 练习的描述。

  • 如何执行练习的说明。

  • 练习的图片。

  • 练习的音频片段名称。

  • 相关视频。

使用 TypeScript,我们可以为我们的模型定义类。

Exercise 类如下所示:

export class Exercise { 
  constructor( 
    public name: string,
    public title: string,
    public description: string, 
    public image: string,
    public nameSound?: string,
    public procedure?: string,
    public videos?: Array<string>) { }
} 

TypeScript 技巧

使用 publicprivate 声明构造函数参数是一种一次性创建和初始化类成员的简写方式。在 nameSoundprocedurevideos 后面的 ? 后缀表示这些是可选参数。

对于训练,我们需要跟踪以下属性:

  • 名称。这应该是唯一的。

  • 标题。这将显示给用户。

  • 训练中包含的练习。

  • 每个练习的持续时间。

  • 两个练习之间的休息时间。

用于跟踪 workout 进度的模型类(WorkoutPlan)如下所示:

export class WorkoutPlan { 
  constructor( 
    public name: string, 
    public title: string, 
    public restBetweenExercise: number, 
 public exercises: ExercisePlan[], 
    public description?: string) { } 

  totalWorkoutDuration(): number { ... } 
} 

totalWorkoutDuration函数返回 workout 的总持续时间(以秒为单位)。

WorkoutPlan在前面的定义中有另一个类ExercisePlan的引用。它跟踪 workout 中的练习及其持续时间,这一点很明显,一旦我们查看ExercisePlan的定义:

export class ExercisePlan { 
  constructor( 
    public exercise: Exercise, 
    public duration: number) { } 
} 

让我为您节省一些输入时间,告诉您从哪里获取模型类,但在此之前,我们需要决定在哪里添加它们。我们已经准备好我们的第一个特性了。

第一个特性模块

7 Minute Workout的主要功能是执行预定义的一组练习。因此,我们现在要创建一个特性模块,并稍后将特性实现添加到这个模块中。我们把这个模块称为workout-runner。让我们使用 Angular CLI 的脚手架功能初始化这个特性。

从命令行导航到trainer/src/app文件夹,并运行以下命令:

ng generate module workout-runner --module app.module.ts

关注控制台日志,了解生成了哪些文件。该命令基本上是:

  • 在一个新的workout-runner文件夹内创建一个新的 Angular WorkoutRunnerModule模块

  • 将新创建的模块导入到主应用程序模块app(app.module.ts)中。

我们现在拥有一个新的特性模块

给每个特性一个自己的模块。

特别注意 Angular CLI 在搭建 Angular 构件时遵循的约定。从前面的示例中,命令行提供的模块名称为workout-runner。虽然生成的文件夹和文件名使用相同的名称,但生成的模块的类名为WorkoutRunnerModule(帕斯卡大小写,带有Module后缀)。

打开新生成的模块定义(workout-runner.module.ts)并查看生成的内容。WorkoutRunnerModule导入CommonModule,这是一个包含常用 Angular 指令(如ngIfngFor)的模块,允许我们在WorkoutRunnerModule中定义的任何组件/指令中使用这些常用指令。

模块是 Angular 组织代码的方式。我们将很快讨论 Angular 模块。

model.ts文件从bit.ly/ng6be-2-1-model-ts复制到workout-runner文件夹。稍后,我们将看到如何利用这些模型类。

由于我们已经启动了一个预配置的 Angular 应用程序,我们只需要了解应用程序如何启动。

应用程序引导

7 Minute Workout的应用程序引导过程可以从src文件夹中进行。有一个main.ts文件通过调用以下命令来引导应用程序:

platformBrowserDynamic().bootstrapModule(AppModule)
    .catch(err => console.log(err));

繁重的工作由 Angular CLI 完成,它编译应用程序,将脚本和 CSS 引用包含到index.html中,并运行应用程序。我们不需要配置任何东西。这些配置是 Angular CLI 默认配置(.angular-cli.json)的一部分。

我们创建了一个新模块,并将一些模型类添加到module文件夹中。在进一步实现该功能并开始之前,让我们稍微谈一下Angular 模块

探索 Angular 模块

随着 7 分钟锻炼 应用程序的增长,我们向其中添加新的组件/指令/管道/其他构件,需要组织这些项目。其中的每一项都需要成为 Angular 模块的一部分。

一个天真的方法是在我们应用的根模块(AppModule)中声明所有内容,就像我们对WorkoutRunnerComponent所做的那样,但这背离了 Angular 模块的整体目的。

要了解为什么单一模块方法永远不是一个好主意,请探索 Angular 模块。

理解 Angular 模块

在 Angular 中,模块是一种将代码组织成属于一起并作为一个统一单元工作的方式。模块是 Angular 分组和组织代码的方式。

Angular 模块主要定义:

  • 它拥有的组件/指令/管道

  • 它向其他模块公开的组件/指令/管道

  • 其依赖的其他模块

  • 模块希望应用程序范围内提供的服务

任何规模较大的 Angular 应用都将模块相互连接在一起:一些模块从其他模块消费构件,一些模块将构件提供给其他模块,一些模块都进行了两者。

作为标准做法,模块的分离是基于特性的。将应用程序划分成特性或子特性(对于大型特性),并为每个特性创建模块。即使框架也遵循此准则,因为所有框架构件都被分为各个模块:

  • CommonModule,汇集了在每个基于浏览器的 Angular 应用中使用的标准框架构件

  • 如果想要使用 Angular 路由框架,则有RouterModule

  • 如果我们的应用需要通过 HTTP 与服务器通信,有HtppModule

Angular 模块是通过将 @NgModule 装饰器应用于 TypeScript 类来创建的。装饰器定义公开了足够的元数据,允许 Angular 加载模块引用的一切。

装饰器具有多个属性,允许我们定义:

  • 外部依赖项(使用imports)。

  • 模块构件(使用declarations)。

  • 模块输出(使用exports)。

  • 在模块内定义的需要全局注册的服务(使用providers)。

  • 主应用视图,称为根组件,承载所有其他应用视图。只有根模块才应该使用bootstrap属性进行设置。

此图表突出显示了模块内部及其相互链接的内容:

在 Angular 上下文中定义的模块(使用 @NgModule 装饰器)和我们在 TypeScript 文件中使用 import 语句导入的模块是不同的。通过 import 语句导入的模块是JavaScript 模块,可以采用CommonJSAMDES2015规范的不同格式,而 Angular 模块是 Angular 用来分隔和组织其构件的构造物。除非讨论的上下文明确是 JavaScript 模块,否则对模块的任何引用都意味着是一个 Angular 模块。我们可以在这里了解更多信息:bit.ly/ng2be6-module-vs-ngmodule

我们希望从所有这些讨论中有一件事是清楚的:除非您正在构建一些基本的东西,否则创建单一的应用程序范围模块并不是 Angular 模块的正确用法。

现在是时候投入行动了;让我们构建我们的第一个组件。

我们的第一个组件 - WorkoutRunnerComponent

WorkoutRunnerComponent是我们的7 分钟锻炼应用的核心部分,它将包含执行锻炼的逻辑。

我们在WorkoutRunnerComponent实现中将要做的事情如下:

  1. 开始锻炼。

  2. 显示正在进行的锻炼和显示进度指示器。

  3. 练习时间结束后,显示下一个练习。

  4. 重复这个过程,直到所有的练习都结束。

我们准备创建(或脚手架)我们的组件。

从命令行,进入src/app文件夹并执行以下ng命令:

ng generate component workout-runner -is

生成器在workout-runner文件夹中生成一堆文件(三个),并更新WorkoutRunnerModule中的模块声明以包括新创建的WorkoutRunnerComponent

-is标志用于停止为组件生成单独的 CSS 文件。由于我们使用全局样式,我们不需要组件特定的样式。

请记住从src/app文件夹而不是src/app/workout-runner文件夹运行此命令。如果我们从src/app/workout-runner运行前面的命令,Angular CLI 将创建一个新的子文件夹,并包含workout-runner组件定义。

用于组件的前述ng generate命令生成这三个文件:

  • <component-name>.component.html:这是组件的视图 HTML。

  • <component-name>.component.spec.ts:单元测试中使用的测试规范文件。

  • <component-name>.component.ts:包含组件实现的主要组件文件。

再次,我们鼓励你查看生成的代码,以了解生成了什么。Angular CLI 组件生成器节省了我们一些按键操作,一旦生成,样板代码可以根据需要进行修改。

虽然我们只看到了四个装饰器元数据属性(比如 templateUrl),但是组件装饰器也支持一些其他有用的属性。查看 Angular 文档以了解更多关于这些属性及其应用的信息。

一个敏锐的读者可能会注意到生成的selector属性值具有前缀abe;这是有意为之。因为我们正在扩展 HTML 的领域特定语言DSL)以包含一个新元素,前缀abe帮助我们划分了我们开发的 HTML 扩展。因此,我们在 HTML 中使用<abe-workout-runner></abe-workout-runner>而不是<workout-runner></workout-runner>。前缀值已在angular.json中配置,参见prefix属性。

请始终为您的组件选择器添加前缀。

现在我们已经有了WorkoutRunnerComponent的样板;让我们开始添加实现,首先是添加模型引用。

workout-runner.component.ts中,导入所有训练模型:

import {WorkoutPlan, ExercisePlan, Exercise} from './model';

接下来,我们需要设置训练数据。让我们通过在生成的ngOnInit函数和与WorkoutRunnerComponent类相关的类属性中添加一些代码来完成:

workoutPlan: WorkoutPlan; 
restExercise: ExercisePlan; 
ngOnInit() { 
   this.workoutPlan = this.buildWorkout(); 
   this.restExercise = new ExercisePlan( 
     new Exercise('rest', 'Relax!', 'Relax a bit', 'rest.png'),  
     this.workoutPlan.restBetweenExercise);   
} 

ngOnInit是 Angular 在组件初始化时调用的特殊函数。我们即将讨论ngOnInit

buildWorkoutWorkoutRunnerComponent上设置完成的训练计划,正如我们即将定义的那样。我们还初始化了一个restExercise变量,以跟踪甚至作为练习的休息时间(注意,restExerciseExercisePlan类型的对象)。

buildWorkout函数很长,最好从可在 Git 分支 checkpoint2.1 中获取的 workout runner 的实现中复制实现(bit.ly/ng6be-2-1-workout-runner-component-ts)。buildWorkout代码如下:

buildWorkout(): WorkoutPlan { 
let workout = new WorkoutPlan('7MinWorkout',  
"7 Minute Workout", 10, []); 
   workout.exercises.push( 
      new ExercisePlan( 
        new Exercise( 
          'jumpingJacks', 
          'Jumping Jacks', 
          'A jumping jack or star jump, also called side-straddle hop
           is a physical jumping exercise.', 
          'JumpingJacks.png', 
          'jumpingjacks.wav', 
          `Assume an erect position, with feet together and 
           arms at your side. ...`, 
          ['dmYwZH_BNd0', 'BABOdJ-2Z6o', 'c4DAnQ6DtF8']), 
        30)); 
   // (TRUNCATED) Other 11 workout exercise data. 
   return workout; 
} 

此代码构建了WorkoutPlan对象,并将练习数据推入exercises数组(一组ExercisePlan对象),返回新构建的训练计划。

初始化工作完成;现在是时候实际实现开始训练了。将以下start函数添加到WorkoutRunnerComponent的实现中:

start() { 
   this.workoutTimeRemaining =  
   this.workoutPlan.totalWorkoutDuration(); 
   this.currentExerciseIndex = 0;  
   this.startExercise(this.workoutPlan.exercises[this.currentExerciseIndex]); 
} 

然后在顶部声明在函数中使用的新变量,以及其他变量声明:

workoutTimeRemaining: number; 
currentExerciseIndex: number; 

workoutTimeRemaining变量跟踪训练的剩余时间,currentExerciseIndex跟踪当前执行的练习索引。调用startExercise实际上开始了一项练习。以下是startExercise的代码:

startExercise(exercisePlan: ExercisePlan) { 
    this.currentExercise = exercisePlan; 
    this.exerciseRunningDuration = 0; 
    const intervalId = setInterval(() => { 
      if (this.exerciseRunningDuration >=  this.currentExercise.duration) { 
          clearInterval(intervalId);  
      } 
      else { this.exerciseRunningDuration++; } 
    }, 1000); 
} 

我们首先初始化currentExerciseexerciseRunningDurationcurrentExercise变量跟踪正在进行的练习,而exerciseRunningDuration跟踪其持续时间。这两个变量也需要在顶部声明:

currentExercise: ExercisePlan; 
exerciseRunningDuration: number; 

我们使用setInterval JavaScript 函数,延迟一秒(1,000 毫秒),以取得进展。在setInterval回调内,exerciseRunningDuration会随着每过一秒而增加。嵌套的clearInterval调用将在运动持续时间结束时停止计时器。

TypeScript 箭头函数

传递给setInterval的回调参数(()=>{...})是一个 lambda 函数(或者是 ES2015 中的箭头函数)。Lambda 函数是匿名函数的简写表示,带有额外的好处。您可以在 bit.ly/ng2be-ts-arrow-functions 了解更多关于它们的知识。

组件的第一次切割几乎完成了,但是它目前有一个静态视图(UI),因此我们无法验证实现。我们可以通过添加一个初步的视图定义迅速纠正这种情况。打开workout-runner.component.ts,注释掉templateUrl属性,添加一个内联模板属性(template)并将其设置为以下内容:

template: `<pre>Current Exercise: {{currentExercise | json}}</pre>
<pre>Time Left: {{currentExercise.duration - exerciseRunningDuration}}</pre>`,

用反引号( )括起来的字符串是 ES2015 的一个新加入的特性。也被称为模板字面量,这样的字符串字面量可以是多行的,并且允许在其中嵌入表达式(不要与 Angular 表达式混淆)。在 MDN 的文章 bit.ly/template-literals 中查看更多细节。

内联与外部视图模板上面的template属性是内联组件模板的一个例子。这允许组件开发人员内联指定组件模板,而不是使用单独的 HTML 文件。内联模板方法通常适用于视图较为简单的组件。内联模板有一个缺点:HTML 格式化变得困难,而且 IDE 支持非常有限,因为内容被视为字符串字面量。当我们将 HTML 外部化时,我们可以开发一个模板作为一个普通的 HTML 文档。我们建议您为复杂的视图使用外部模板文件(通过templateUrl指定)。Angular CLI 默认生成外部模板引用,但我们可以通过向ng组件生成命令传递--inline-template标志来影响此行为,例如 --inline-template true

前面的模板 HTML 将呈现原始的ExercisePlan对象以及剩余的练习时间。在第一个插值内有一个有趣的表达式:currentExercise | jsoncurrentExercise属性在WorkoutRunnerComponent中定义,但是|符号及其后的内容(json)是什么?在 Angular 世界中,这被称为一个管道。管道的唯一目的是转换/格式化模板数据。

此处的json管道是对 JSON 数据进行格式化。您将在本章后面学到更多关于管道的知识,但为了大致了解json管道的作用,我们可以移除json管道和 | 符号,然后呈现模板;我们将在下一步中执行此操作。

要呈现新的WorkoutRunnerComponent实现,它必须被添加到根组件的视图中。修改src/components/app/app.component.html,使用以下代码替换h3标签:

<div class="container body-content app-container">
      <abe-workout-runner></abe-workout-runner>
</div>

虽然实现看起来可能已经完成,但是还缺少一个关键的部分。代码中实际上并没有启动练习。练习应该在页面加载时立即开始。

组件的生命周期钩子将拯救我们!

组件的生命周期钩子

Angular 组件的生命周期是充满事件的。组件被创建,在其生命周期内状态发生变化,最终被销毁。Angular 提供了一些生命周期钩子/函数,当发生这样的事件时(在组件上)框架会调用它们。考虑以下示例:

  • 当组件被初始化时,Angular 会调用ngOnInit

  • 当组件的数据绑定属性发生变化时,Angular 会调用ngOnChanges

  • 当组件被销毁时,Angular 会调用ngOnDestroy

作为开发人员,我们可以利用这些关键时刻,并在相应的组件内执行一些自定义逻辑。

我们将在这里使用的钩子是ngOnInitngOnInit函数在组件的数据绑定属性初始化完成后(但在视图初始化开始之前)首次触发。

虽然ngOnInit和类构造函数看起来类似,但它们有不同的用途。构造函数是一种语言特性,用于初始化类成员。另一方面,ngOnInit用于在组件准备就绪后执行一些初始化操作。避免使用构造函数来进行除成员初始化之外的任何操作。

更新WorkoutRunnerComponent类中的ngOnInit函数,以调用开始锻炼的代码:

ngOnInit() { 
    ...
    this.start(); 
} 

作为组件脚手架的一部分,Angular CLI 已经为ngOnInit生成了签名。ngOnInit函数声明在核心 Angular 框架的OnInit接口上。我们可以通过查看WorkoutRunnerComponent的导入部分来确认这一点:

import {Component,OnInit} from '@angular/core'; 
... 
export class WorkoutRunnerComponent implements OnInit {

还有许多其他生命周期钩子,包括ngOnDestroyngOnChangesngAfterViewInit,组件都支持,但我们在这里不打算深究其中任何一个。查看开发人员指南(angular.io/guide/lifecycle-hooks)上的生命周期钩子,了解更多关于其他钩子的信息。

实现接口(前面的示例中的OnInit)是可选的。只要函数名匹配,这些生命周期钩子就可以起作用。我们仍然建议您使用接口来清晰地传达意图。

是时候运行我们的应用程序了!打开命令行,导航到trainer文件夹,并输入以下命令:

ng serve --open

代码编译通过,但没有 UI 呈现。是什么让我们失败了?让我们查看浏览器控制台是否有错误。

打开浏览器的开发工具(常见的键盘快捷键F12),并查看控制台标签页以查看错误。有一个模板解析错误。Angular 无法定位abe-workout-runner组件。让我们进行一些健全性检查以验证我们的设置:

  • WorkoutRunnerComponent 实现完成 - 检查

  • WorkoutRunnerModule中声明了组件 - 检查

  • WorkoutRunnerModule 导入到AppModule中 - 检查

但是,AppComponent模板无法定位WorkoutRunnerComponent。这是因为WorkoutRunnerComponentAppComponent在不同的模块中吗?的确,这就是问题所在!虽然WorkoutRunnerModule已经导入了AppModule,但WorkoutRunnerModule仍然没有导出新的WorkoutRunnerComponent,这将允许AppComponent使用它。

请记住,将组件/指令/管道添加到模块的declaration部分会使它们在模块内可用。只有在我们导出组件/指令/管道之后,它才可以在模块之间使用。

通过更新WorkoutRunnerModule声明的导出数组,来导出WorkoutRunnerComponent

declarations: [WorkoutRunnerComponent],
exports:[WorkoutRunnerComponent]

这次,我们应该看到以下输出:

如果希望在其他模块中使用 Angular 模块内定义的组件,始终导出这些组件。

模型数据随着每秒的过去而更新!现在你会明白为什么插值表达式({{ }})是一个很好的调试工具。

这也是测试以不使用json管道渲染currentExercise的好时机,看看会渲染出什么。

我们还没有完成!在页面上等待足够长的时间,我们会发现计时器在 30 秒后停止。应该是时候修复它了!

更新setInterval函数内的代码:

if (this.exerciseRunningDuration >=  this.currentExercise.duration) { 
   clearInterval(intervalId); 
 const next: ExercisePlan = this.getNextExercise(); if (next) { if (next !== this.restExercise) { this.currentExerciseIndex++; } this.startExercise(next);}
 else { console.log('Workout complete!'); } 
} 

if条件if (this.exerciseRunningDuration >= this.currentExercise.duration)用于在当前练习的时间持续时间结束后切换到下一个练习。我们使用getNextExercise获取下一个练习,并再次调用startExercise来重复这个过程。如果getNextExercise调用没有返回任何练习,那么训练被认为已经完成。

在练习过渡期间,只有下一个练习不是休息练习时,我们才会增加currentExerciseIndex。请记住,原始的训练计划没有休息练习。为了保持一致性,我们创建了一个休息练习,现在在休息和训练计划中的标准练习之间进行交换。因此,当下一个练习是休息时,currentExerciseIndex不会改变。

让我们快速添加getNextExercise函数。将函数添加到WorkoutRunnerComponent类中:

getNextExercise(): ExercisePlan { 
    let nextExercise: ExercisePlan = null; 
    if (this.currentExercise === this.restExercise) { 
      nextExercise = this.workoutPlan.exercises[this.currentExerciseIndex + 1]; 
    } 
    else if (this.currentExerciseIndex < this.workoutPlan.exercises.length - 1) { 
      nextExercise = this.restExercise; 
    } 
    return nextExercise; 
} 

getNextExercise函数返回需要执行的下一个练习。

请注意,getNextExercise返回的对象是一个ExercisePlan对象,其中包含了练习细节和练习运行的持续时间。

实现方法非常易懂。如果当前练习是休息,就从workoutPlan.exercises数组中取下一个练习(基于currentExerciseIndex);否则,下一个练习是休息,前提是我们不在最后一个练习(else if条件检查)。

有了这个,我们就可以测试我们的实现了。练习应该在每隔 10 或 30 秒后翻转。太棒了!

当前的构建设置在保存文件时自动编译对脚本文件所做的任何更改;它还在这些更改后刷新浏览器。但是,如果 UI 没有更新或事情不像预期的那样工作,请刷新浏览器窗口。如果您在运行代码时遇到问题,请查看 Git 分支checkpoint2.1,了解我们目前所做的工作的可用版本。或者,如果您不使用 Git,请从bit.ly/ng6be-checkpoint2-1下载 Checkpoint 2.1 的快照(ZIP 文件)。首次设置快照时,请参阅trainer文件夹中的README.md文件。

我们已经对组件做了足够的工作,现在让我们构建视图。

构建 7 分钟锻炼视图

在定义模型和实现组件时,大部分工作已经完成。现在,我们只需要利用 Angular 的超赞数据绑定功能对 HTML 进行美化。这将简单、甜美且优雅!

对于7 分钟锻炼视图,我们需要显示锻炼名称、锻炼图片、进度指示器和剩余时间。请用来自 Git 分支checkpoint2.2的文件内容替换workout-runner.component.html文件的本地内容(或者从bit.ly/ng6be-2-2-workout-runner-component-html下载)。视图 HTML 如下所示:

<div class="row">
  <div id="exercise-pane" class="col-sm">
    <h1 class="text-center">{{currentExercise.exercise.title}}</h1>
    <div class="image-container row">
      <img class="img-fluid col-sm" [src]="'/assets/images/' +  
                                      currentExercise.exercise.image" />
    </div>
    <div class="progress time-progress row">
      <div class="progress-bar col-sm" 
            role="progressbar" 
            [attr.aria-valuenow]="exerciseRunningDuration" 
            aria-valuemin="0" 
            [attr.aria-valuemax]="currentExercise.duration"
            [ngStyle]="{'width':(exerciseRunningDuration/currentExercise.duration) * 
                                                                100 + '%'}">
      </div>
    </div>
    <h1>Time Remaining: {{currentExercise.duration-exerciseRunningDuration}}</h1>
  </div>
</div>

WorkoutRunnerComponent当前使用内联模板;相反,我们需要恢复使用外部模板。更新workout-runner.component.ts文件,去掉template属性,然后取消注释我们先前注释掉的templateUrl

在我们理解视图中的 Angular 部分之前,让我们再次运行应用程序。保存workout-runner.component.html中的更改,如果一切顺利,我们将看到完整版本的锻炼应用程序:

基本应用程序现在已经启动并正在运行。锻炼图片和标题显示出来了,进度指示器显示了进度,并且当锻炼时间结束时发生了锻炼切换。这肯定感觉很棒!

如果您在运行代码时遇到问题,请查看 Git 分支checkpoint2.2,了解我们目前所做的工作的可用版本。您还可以从此 GitHub 位置下载checkpoint2.2的快照(ZIP 文件):bit.ly/ng6be-checkpoint-2-2。首次设置快照时,请参阅trainer文件夹中的README.md文件。

查看视图的 HTML 时,除了一些 Bootstrap 样式外,还有一些有趣的 Angular 部分需要我们的关注。在我们详细研究这些视图构造之前,让我们先分解这些元素并提供一个快速摘要:

  • <h1 ...>{{currentExercise.exercise.title}}</h1>:使用插值

  • <img ... [src]="'/assets/images/' + currentExercise.exercise.image" .../>:使用属性绑定将图像的src属性绑定到组件模型属性currentExercise.exercise.image

  • <div ... [attr.aria-valuenow]="exerciseRunningDuration" ... >:使用属性绑定div上的 aria 属性绑定到exerciseRunningDuration

  • < div ... [ngStyle]="{'width':(exerciseRunningDuration/currentExercise.duration) * 100 + '%'}">:使用指令ngStyle将进度条div上的style属性绑定到一个表达式,该表达式评估了运动进度

哇!牵扯到了很多绑定。让我们深入了解绑定基础设施。

Angular 绑定基础设施

大多数现代 JavaScript 框架如今都具有强大的模型-视图绑定支持,Angular 也不例外。任何绑定基础设施的主要目标都是减少开发人员需要编写的样板代码,以保持模型和视图同步。强大的绑定基础设施总是声明性和简洁的。

Angular 绑定基础设施允许我们将模板(原始)HTML 转换为与模型数据绑定的活动视图。根据使用的绑定构造,数据可以在模型到视图和视图到模型两个方向上流动并保持同步。

组件的模型与其视图之间的链接是通过@Component装饰器的templatetemplateUrl属性建立的。除了script标签以外,几乎任何 HTML 片段都可以作为 Angular 绑定基础设施的模板。

要使这种绑定魔法生效,Angular 需要获取视图模板,编译它,将其与模型数据关联并使其与模型更新同步,而无需编写任何自定义样板同步代码。

基于数据流方向,这些绑定可以分为三种类型:

  • 一种从模型到视图的单向绑定:在模型到视图绑定中,模型的变化与视图保持同步。插值、属性、属性、类和样式绑定属于这个类别。

  • 一种从视图到模型的单向绑定:在这个类别中,视图变化流向模型。事件绑定属于这一类。

  • 双向绑定:双向绑定,顾名思义,保持视图和模型同步。用于双向绑定的特殊绑定构造是ngModel,一些标准的 HTML 数据输入元素如inputselect支持双向绑定。

让我们了解如何利用 Angular 的绑定功能来支持视图模板化。Angular 提供了这些绑定构造:

  • 插值

  • 属性绑定

  • 属性绑定

  • 类绑定

  • 样式绑定

  • 事件绑定

现在是学习所有这些绑定构造的好时机。插值是第一个。

插值

内插非常简单。内插符号({{ }})中的表达式(通常称为模板表达式)在模型(或组件类成员)的上下文中进行评估,评估的结果(字符串)被嵌入到 HTML 中。这是一个方便的框架构造,用于显示组件的数据/属性。我们使用内插渲染了练习标题和练习剩余时间:

<h1>{{currentExercise.exercise.title}}</h1>
... 
<h1>Time Remaining: {{currentExercise.duration?-exerciseRunningDuration}}</h1> 

记住,内插将模型变化与视图同步。内插是一种从模型到视图的单向绑定。

在 Angular 中,视图绑定始终在组件作用域中进行评估。

事实上,内插是属性绑定的一个特例,它允许我们将任何 HTML 元素/组件属性绑定到模型上。我们很快会讨论如何使用属性绑定语法来编写内插。认为内插是属性绑定的语法糖。

属性绑定

属性绑定允许我们将本机 HTML/组件属性绑定到组件的模型并保持同步(从模型->视图)。让我们从不同的角度来看一下属性绑定。

看一下 7 分钟锻炼的组件视图(workout-runner.component.html)中的视图摘录:

<img class="img-responsive" [src]="'/static/images/' + currentExercise.exercise.image" /> 

看起来我们在将 imgsrc 属性绑定到一个在运行时求值的表达式。但我们真的在绑定一个属性吗?还是这是一个属性?属性和属性之间的区别是什么?

在 Angular 领域中,虽然前面的语法看起来像是设置 HTML 元素的属性,实际上它是在进行属性绑定。此外,由于许多人不了解 HTML 元素的属性和其属性之间的区别,这个表达式令人非常困惑。因此,在我们了解属性绑定的工作原理之前,让我们试着理解一下元素的属性和属性之间的区别。

属性与属性之间的区别

拿起任何 DOM 元素的 API,你会发现属性、属性、函数和事件。虽然事件和函数是不言自明的,但很难理解属性和属性之间的区别。在日常使用中,我们互换使用这些词,这也不会有太大帮助。以这行代码为例:

<input type="text" value="Awesome Angular"> 

当浏览器为这个输入文本框创建一个 DOM 元素(确切地说是 HTMLInputElement)时,它使用 input 上的 value 属性来设置 inputvalue 属性的初始状态为 Awesome Angular

在初始化之后,对 inputvalue 属性的任何更改都不会反映在 value 属性上;属性始终是Awesome Angular(除非明确地再次设置)。可以通过查询 input 的状态来确认。

假设我们将 input 的数据更改为 Angular rocks! 并查询 input 元素的状态:

input.value // value property 

value 属性始终返回当前输入的内容,即 Angular rocks!。而这个 DOM API 函数:

input.getAttribute('value')  // value attribute 

返回value属性,并始终是最初设置的了不起的 Angular

元素属性的主要作用是在创建相应的 DOM 对象时初始化元素的状态。

还有许多其他细微差别会增加这种混乱。其中包括以下几点:

  • 属性和属性同步在不同属性上不一致。正如我们在上面的例子中看到的,对inputvalue属性的更改不会影响value属性,但这并不适用于所有属性-值对。图像元素的src属性就是一个典型的例子;对属性或属性值的更改始终保持同步。

  • 令人惊讶的是,属性和属性之间的映射也不是一一对应的。有许多属性没有任何后备属性(如innerHTML),也有一些属性在 DOM 上没有定义相应的属性(如colspan)。

  • 属性和属性映射也增加了这种混乱,因为它们没有遵循一致的模式。一个很好的例子是在 Angular 开发者指南中,我们将逐字重现的。

disabled属性是另一个奇葩的例子。按钮的disabled属性默认为false,因此按钮是启用的。当我们添加 disabled 属性时,其存在单独将按钮的disabled属性初始化为true,因此按钮被禁用。添加和删除 disabled 属性会禁用和启用按钮。属性的值是无关紧要的,这就是为什么我们无法通过编写<button disabled="false">仍然禁用</button>来启用按钮。

此讨论的目的是确保我们了解 DOM 元素的属性和属性之间的区别。这种新的思维模式将帮助我们继续探索框架的属性和属性绑定能力。让我们回到我们对属性绑定的讨论。

属性绑定继续……

现在我们了解了属性和属性之间的区别,让我们再次看一看绑定的例子:

<img class="img-responsive" [src]="'/static/images/' + currentExercise.exercise.image" /> 

[propertName]方括号语法用于将img.src属性绑定到 Angular 表达式。

属性绑定的一般语法如下所示:

[target]="sourceExpression"; 

在属性绑定的情况下,目标是 DOM 元素或组件上的属性。通过属性绑定,我们可以绑定到 DOM 元素上的任何属性。img元素上的src属性就是我们所使用的;这种绑定适用于任何 HTML 元素及其上的每个属性。

表达式目标也可以是事件,我们很快会看到当我们探索事件绑定时。

绑定的源和目标了解 Angular 绑定中源和目标的区别很重要。出现在[]内的属性是目标,有时也被称为绑定目标。目标是数据的消费者,并且总是指向组件/元素上的属性。表达式构成了提供数据给目标的数据源。

在运行时,表达式在组件/元素属性的上下文中进行评估(在前面的案例中为WorkoutRunnerComponent.currentExercise.exercise.image属性)。

记得始终在目标周围加上方括号[]。如果不这样做,Angular 将会把表达式视为字符串常量,并且目标将简单地被分配字符串值。

属性绑定、事件绑定和属性绑定不使用插值符号。以下是无效的:[src]="{{'/static/images/' + currentExercise.exercise.image}}"

如果你曾经使用过 AngularJS,属性绑定和事件绑定一起使 Angular 能够摆脱多个指令,比如ng-disableng-srcng-key*ng-mouse*等一些指令。

从数据绑定的角度来看,Angular 对待组件和原生元素的方式是一样的。因此,属性绑定也适用于组件属性!组件可以定义输入输出属性,这些属性可以绑定到视图,比如这样:

<workout-runner [exerciseRestDuration]="restDuration"></workout-runner> 

这个假设的代码片段将WorkoutRunnerComponent类上的exerciseRestDuration属性绑定到容器组件(父组件)上定义的restDuration属性,使我们可以将休息时长作为参数传递给WorkoutRunnerComponent。随着我们改进应用程序并开发新组件,你将学会如何在组件上定义自定义属性和事件。

我们可以使用bind-语法启用属性绑定,这是一种属性绑定的标准形式。这意味着[src]="'/assets/images/' + currentExercise.exercise.image"等同于以下内容:bind-src="img/' + currentExercise.exercise.image".

属性绑定,就像插值一样,是单向的,从组件/元素源到视图。对模型数据的更改与视图保持同步。

我们刚刚创建的模板视图只有一个属性绑定(在[src]上)。其他带方括号的绑定不属于属性绑定,我们马上会介绍它们。

插值语法糖在属性绑定之上

我们通过描述插值为属性绑定提供了一种语法糖来总结插值部分。我们的意图是强调两者如何可以互换使用。插值语法比属性绑定更为简洁,因此非常有用。这就是 Angular 如何解释插值的方式:

<h3>Main heading - {{heading}}</h3> 
<h3 [text-content]="' Main heading - '+ heading"></h3>

Angular 将第一个语句中的插值转换为textContent属性绑定(第二个语句)。

内插法可以用在比你想象的更多地方。以下示例对比了使用内插和属性绑定的相同绑定:

<img [src]="'/assets/images/' + currentExercise.exercise.image" />
<img src="img/{{currentExercise.exercise.image}}" />      // interpolation on attribute

<span [text-content]="helpText"></span>
<span>{{helpText}}</span>

尽管属性绑定(和内插)让我们可以轻松将任何表达式绑定到目标属性,但我们应该谨慎选择所使用的表达式。只要我们的组件存活,Angular 的变更检测系统将在应用程序的生命周期内多次评估您的表达式绑定。因此,在将表达式绑定到属性目标时,请牢记这两点指导原则。

快速表达式评估

属性绑定表达式应该快速评估。慢速表达式评估可能会影响应用的性能。当执行 CPU 密集型工作的函数作为表达式的一部分时,就会发生这种情况。考虑这个绑定:

<div>{{doLotsOfWork()}}</div> 

Angular 在执行变更检测时,将评估之前的doLotsOfWork()表达式。这些变更检测运行的频率比我们想象的要频繁,并且基于一些内部启发式算法,因此我们使用的表达式需要快速评估才是至关重要的。

无副作用的绑定表达式

如果在绑定表达式中使用了函数,它应该是无副作用的。考虑另一个绑定:

<div [innerHTML]="getContent()"></div> 

而底层函数getContent

getContent() { 
  var content=buildContent(); 
  this.timesContentRequested +=1; 
  return content; 
} 

getContent调用通过每次调用更新timesContentRequested属性来改变组件的状态。如果这个属性在视图中使用,比如:

<div>{{timesContentRequested}}</div> 

Angular 会抛出诸如:

Expression '{{getContent()}}' in AppComponent@0:4' has changed after it was checked. Previous value: '1'. Current value: '2'

Angular 框架以开发和生产两种模式工作。如果我们在应用中启用生产模式,则前述错误不会出现。阅读bit.ly/enableProdMode获取更多框架文档详情。

底线是,在属性绑定中使用的表达式应该是无副作用的。

现在让我们看看一些有趣的东西,[ngStyle],看起来像是属性绑定,但实际上不是。方括号[]中指定的目标不是组件/元素的属性(div没有ngStyle属性),而是一个指令。

需要引入两个新概念,目标选择指令

Angular 指令

作为一个框架,Angular 试图增强 HTML 的DSL(代表领域特定语言):

  • 在 HTML 中,组件通过自定义标签引用,例如<abe-workout-runner></abe-workout-runner>(不是标准的 HTML 构造)。这突出了第一个扩展点。

  • 用于属性和事件绑定的[]()定义了第二个扩展点。

  • 然后有指令,第三个扩展点,进一步分类为属性结构型指令,以及组件(组件也是指令!)。

尽管组件自带视图,但属性指令用于增强现有元素/组件的外观和/或行为。

结构指令也没有自己的视图;它们改变了应用在其上的元素的 DOM 布局。我们将在本章后面专门讨论理解这些结构指令的完整部分。

workout-runner视图中使用的ngStyle指令实际上是一个属性指令。

<div class="progress-bar" role="progressbar"  
 [ngStyle] = "{'width':(exerciseRunningDuration/currentExercise.duration) * 100 + '%'}"></div>  

ngStyle指令并没有自己的视图;相反,它允许我们使用绑定表达式在 HTML 元素上设置多个样式(在这种情况下是width)。我们将在本书的后面涵盖许多框架属性指令。

指令名称

指令是一个用于组件指令(也称为组件)、属性指令和结构指令的总称。在本书中,当我们使用术语指令时,根据上下文我们将指的是属性指令或结构指令中的一个。组件指令总是被称为组件。

有了对 Angular 具有的指令类型的基本理解,我们就能理解绑定的目标选择过程。

绑定的目标选择

[]中指定的目标不限于组件/元素属性。虽然属性名是一个常见的目标,但 Angular 模板引擎实际上进行启发式处理来决定目标类型。Angular 首先搜索注册的已知指令(属性或结构),这些指令具有匹配的选择器,然后再寻找匹配目标表达式的属性。考虑这个视图片段:

<div [ngStyle]='expression'></div> 

寻找目标的过程始于框架查找所有具有匹配选择器(ngStyle)的内置和自定义指令。由于 Angular 已经有一个NgStyle指令,它成为了目标(指令类名为NgStyle,而选择器是ngStyle)。如果 Angular 没有内置的NgStyle指令,绑定引擎将会在底层组件上寻找名为ngStyle的属性。

如果没有匹配目标表达式,就会抛出未知指令错误。

这完成了我们对目标选择的讨论。下一部分是关于属性绑定的。

属性绑定

属性绑定存在的唯一原因是 Angular 中存在一些没有相关 DOM 属性的 HTML 属性。colspanaria属性就是一些没有相关属性的良好示例。我们的视图中的进度条 div 使用了属性绑定。

如果属性指令仍然让你困惑,我可以理解,它可能变得有点混乱。基本上,它们是不同的。属性指令(例如[ngStyle])改变了 DOM 元素的外观或行为,正如其名称所示,它们是指令。在任何 HTML 元素上没有名为ngStyle的属性或属性。另一方面,属性绑定是关于绑定到没有对应 DOM 属性的 HTML 属性。

7 分钟锻炼在两个地方使用了属性绑定,分别是[attr.aria-valuenow][attr.aria-valuemax]。我们可能会问一个问题:我们能否使用标准的插值语法来设置属性呢?不能,那是行不通的!让我们试试:打开workout-runner.component.html,将两个[]中的 aria 属性attr.aria-valuenowattr.aria-valuemax替换为下划线代码:

<div class="progress-bar" role="progressbar"  
    aria-valuenow = "{{exerciseRunningDuration}}"  
    aria-valuemin="0"  
    aria-valuemax= "{{currentExercise.duration}}"  ...> </div> 

保存视图,如果应用程序未运行,请运行它。这个错误将在浏览器控制台中弹出:

Can't bind to 'ariaValuenow' since it isn't a known native property in WorkoutRunnerComponent ... 

Angular 试图搜索在不存在的div中的ariaValuenow属性!记住,插值实际上是属性绑定。

我们希望这能传达要点:要绑定到 HTML 属性,使用属性绑定。

Angular 默认绑定到属性而不是属性。

为了支持属性绑定,Angular 使用了前缀表示法,在[]内使用attr。属性绑定如下所示:

[attr.attribute-name]="expression" 

恢复原来的 aria 设置使属性绑定生效:

<div ... [attr.aria-valuenow]="exerciseRunningDuration" 
    [attr.aria-valuemax]="currentExercise.duration" ...> 

请注意,如果不附加显式的attr.前缀,属性绑定是不起作用的。

虽然我们在训练视图中没有使用样式和类绑定,但这些是一些方便的绑定功能,可能会派上用场。因此,值得探索一下。

样式和类绑定

我们使用类绑定根据组件状态设置或移除特定类,如下所示:

[class.class-name]="expression" 

expressiontrue时,添加class-name,当expressionfalse时,移除它。一个简单的例子可能如下所示:

<div [class.highlight]="isPreferred">Jim</div> // Toggles the highlight class 

使用样式绑定根据组件状态设置内联样式:

[style.style-name]="expression";

虽然在训练视图中我们使用了ngStyle指令,但我们也可以使用样式绑定,因为我们只涉及到单一样式。使用样式绑定后,同样的ngStyle表达式会变成下面这样:

[style.width.%]="(exerciseRunningDuration/currentExercise.duration) * 100" 

width是一种样式,由于它还带有单位,我们扩展我们的目标表达式以包含%符号。

请记住,style.class.是方便的绑定方式,用于设置单一类或样式。为了更灵活,还有相应的属性指令:ngClassngStyle

在本章早些时候,我们正式介绍了指令及其分类。其中一个指令类型是属性指令(再次强调,不要将它们与我们在前一节介绍的属性绑定混淆)。在下一节中,我们将着重讲述属性指令。

属性指令

属性指令是改变组件/元素外观、感觉或行为的 HTML 扩展。正如在 Angular 指令部分中描述的那样,这些指令不定义自己的视图。

除了ngStylengClass指令外,核心框架还提供了一些其他属性指令。ngValuengModelngSelectOptionsngControlngFormControl都是 Angular 提供的一些属性指令。

由于7 分钟锻炼使用了ngStyle指令,更加深入地探究这个指令及其密切相关的ngClass是明智的选择。

虽然下一节专门介绍了如何使用ngClassngStyle属性指令,但直到第四章,深入学习 Angular 指令,我们才学习如何创建我们自己的属性指令。

使用ngClassngStyle为 HTML 设置样式

Angular 有两个出色的指令,可以让我们在任何元素上动态设置样式并切换 CSS 类。对于 Bootstrap 进度条,我们使用ngStyle指令动态设置元素的width样式,随着练习的进行而变化:

<div class="progress-bar" role="progressbar" ... 
    [ngStyle]="{'width':(exerciseRunningDuration/currentExercise.duration) * 100 + '%'}"> </div> 

ngStyle允许我们一次绑定一个或多个样式到组件的属性。它以对象作为参数。对象上的每个属性名称都是样式名称,值是绑定到该属性的 Angular 表达式,例如以下示例:

<div [ngStyle]= "{ 
'width':componentWidth,  
'height':componentHeight,  
'font-size': 'larger',  
'font-weight': ifRequired ? 'bold': 'normal' }"></div> 

样式不仅可以绑定到组件属性(componentWidthcomponentHeight),还可以设置为常量值('larger')。表达式解析器还允许使用三元运算符(?:);查看isRequired

如果在 HTML 中样式变得过于复杂,我们也可以选择在我们的组件中编写一个返回对象哈希的函数,并将其设置为表达式:

<div [ngStyle]= "getStyles()"></div> 

此外,组件中的getStyles看起来如下所示:

getStyles () { 
    return { 
      'width':componentWidth, 
      ... 
    } 
} 

ngClass也是按照相同的原则工作,只是它用于切换一个或多个类。例如,看看以下代码:

<div [ngClass]= "{'required':inputRequired, 'email':whenEmail}"></div> 

inputRequiredtrue时将应用required类,反之则将其移除。

指令(自定义或平台)像任何其他 Angular 构件一样,始终属于一个模块。要跨模块使用它们,需要导入该模块。想知道ngStyle是在哪里定义的吗?ngStyle是核心框架模块CommonModule的一部分,并已在训练模块定义中(workout-runner.module.ts)导入。CommonModule定义了许多方便的指令,它们在整个 Angular 中被使用。

好了!这就涵盖了我们需要了解的有关我们新开发的视图的一切。

正如之前所述,如果在运行代码时遇到问题,请查看 Git 分支checkpoint2.2。如果不使用 Git,请从bit.ly/ng2be-checkpoint2-2下载checkpoint2.2的快照(ZIP 文件)。在第一次设置快照时,请参考trainer文件夹中的README.md文件。

是时候添加一些增强功能并更多地了解这个框架了!

更多了解练习

对于第一次进行这项锻炼的人来说,详细描述每个练习中涉及的步骤将是有益的。我们还可以为每个练习添加一些 YouTube 视频的引用,以帮助用户更好地理解练习。

我们将在左侧面板中添加练习描述和说明,并将其称为描述面板。我们还将在右侧面板中添加对 YouTube 视频的引用,即视频播放器面板。为了使事情更加模块化并学习一些新概念,我们将为每个描述面板和视频面板创建独立的组件。

此模型数据已经可用。Exercise类的descriptionprocedure属性(见model.ts)提供了有关练习的必要详细信息。videos数组包含一些相关的 YouTube 视频 ID,将用于获取这些视频。

添加描述和视频面板

Angular 应用程序实际上就是一组组件的层次结构,类似于树结构。截止目前,7 Minute Workout 有两个组件,根组件AppComponent和其子组件WorkoutRunnerComponent,与 HTML 组件布局一致,现在看起来如下:

<abe-root>
    ...
    <abe-workout-runner>...</abe-workout-runner>
</abe-root>

运行应用程序并查看源代码以验证此层次结构。随着我们在应用程序中添加更多组件以实现新功能,该组件树将不断增长并分支出。

我们将向WorkoutRunnerComponent添加两个子组件,分别用于支持练习描述和练习视频。虽然我们可以直接向WorkoutRunnerComponent视图添加一些 HTML,但我们希望在这里学习一些有关组件间通信的知识。让我们从在左侧添加描述面板开始,并了解组件如何接受输入。

带有输入的组件

转到workour-runner文件夹并生成一个样板练习描述组件:

ng generate component exercise-description -is

向生成的exercise-description.component.ts文件添加突出显示的代码:

import { Component, OnInit, Input } from '@angular/core';
...
export class ExerciseDescriptionComponent { 
 @Input() description: string; 
  @Input() steps: string; } 

@Input装饰器表示该组件属性可用于数据绑定。在我们深入研究@Input装饰器之前,让我们完成视图并将其与WorkoutRunnerComponent集成。

workout-runner/exercise-description文件夹中的 Git 分支checkpoint2.3复制练习描述的视图定义,exercise-description.component.html。查看练习描述的突出显示的 HTML:

<div class="card-body">
    <div class="card-text">{{description}}</div>
</div> 
...  
<div class="card-text">
    {{steps}}
</div> 

前面的插值引用了ExerciseDescriptionComponent的输入属性:descriptionsteps

组件定义完成。现在,我们只需在WorkoutRunnerComponent中引用ExerciseDescriptionComponent并为ExerciseDescriptionComponentdescriptionsteps提供值,以便视图正确呈现。

打开workout-runner.component.html并根据以下代码中突出显示部分更新 HTML 片段。在exercise-pane div 之前添加一个名为description-panel的新 div,并调整exercise-pane div 的一些样式,如下所示:

<div class="row">
    <div id="description-panel" class="col-sm-3">
 <abe-exercise-description 
            [description]="currentExercise.exercise.description"
 [steps]="currentExercise.exercise.procedure"></abe-exercise-description>
 </div>
   <div id="exercise-pane" class="col-sm-6">  
   ... 

如果应用正在运行,则描述面板应显示在左侧,并显示相关的练习详情。

WorkoutRunnerComponent能够使用ExerciseDescriptionComponent是因为它已经在WorkoutRunnerModule上声明了(参见workout-runner.module.ts中的声明属性)。Angular CLI 组件生成器为我们执行这项工作。

回顾前面视图中的abe-exercise-description声明。我们以与本章前面对 HTML 元素属性的引用方式相同的方式引用descriptionsteps属性。简单、直观且非常优雅!

Angular 数据绑定基础设施确保当WorkoutRunnerComponent上的currentExercise.exercise.descriptioncurrentExercise.exercise.procedure属性发生变化时,ExerciseDescriptionComponent上绑定的属性descriptionsteps也会被更新。

@Input装饰符可以接受一个属性别名作为参数,这意味着以下内容:考虑一个属性声明,如:@Input("myAwesomeProperty") myProperty:string。它可以在视图中如下引用:<my-component [myAwesomeProperty]="expression"....

Angular 绑定基础设施的强大之处允许我们将任何组件属性作为可绑定属性,方法是将@Input装饰器(和@Output也是)附加到它上。我们不限于基本数据类型,如stringnumberboolean;也可以是复杂对象,接下来我们将在添加视频播放器时看到:

@Input装饰器也可以应用于复杂对象。

workout-runner目录下为视频播放器生成一个新组件:

ng generate component video-player -is

通过从位于 GitHub 位置bit.ly/ng6be-2-3-video-playertrainer/src/components/workout-runner/video-player目录下的video-player.component.tsvideo-player.component.html复制实现,更新生成的样板代码(位于 Git 分支checkpoint2.3中)。

让我们来看看视频播放器的实现。打开video-player.component.ts并查看VideoPlayerComponent类:

export class VideoPlayerComponent implements OnInit, OnChanges { 
  private youtubeUrlPrefix = '//www.youtube.com/embed/'; 

  @Input() videos: Array<string>; 
  safeVideoUrls: Array<SafeResourceUrl>; 

  constructor(private sanitizer: DomSanitizationService) { } 

  ngOnChanges() { 
    this.safeVideoUrls = this.videos ? 
        this.videos 
            .map(v => this.sanitizer.bypassSecurityTrustResourceUrl(this.youtubeUrlPrefix + v)) 
    : this.videos; 
  } 
} 

这里的videos输入属性接受一个字符串数组(YouTube 视频代码)。虽然我们将videos数组作为输入,但我们并不直接在视频播放器视图中使用该数组;相反,我们将输入数组转换成一个新的safeVideoUrls数组并进行绑定。这可以通过查看视图实现来确认:

<div *ngFor="let video of safeVideoUrls"> 
   <iframe width="198" height="132" [src]="video" frameborder="0" allowfullscreen></iframe> 
</div> 

视图还使用了一个名为ngFor的新的 Angular 指令来绑定safeVideoUrls数组。ngFor指令属于一类名为结构指令的指令。该指令的作用是根据绑定集合中的元素数量重新生成 HTML 片段。

如果你对ngFor指令如何与safeVideoUrls一起使用以及为什么我们需要生成safeVideoUrls而不是使用videos输入数组感到困惑,稍等片刻,因为我们很快就要解决这些问题。但是,让我们先完成VideoPlayerComponentWorkoutRunnerComponent的集成,以查看最终结果。

exercise-pane div 后添加组件声明更新WorkoutRunnerComponent视图:

<div id="video-panel" class="col-sm-3">
    <abe-video-player [videos]="currentExercise.exercise.videos"></abe-video-player>
</div> 

VideoPlayerComponentvideos属性绑定到运动的视频集合。

启动/刷新应用程序,视频缩略图应显示在右侧。

如果您在运行代码时遇到问题,请查看 Git 分支checkpoint2.3,以获取迄今为止我们所做内容的可工作版本。您还可以从bit.ly/ng6be-checkpoint-2-3下载checkpoint2.3的快照(ZIP 文件)。在首次设置快照时,请参考trainer文件夹中的README.md文件。

现在,是时候回顾并了解VideoPlayerComponent实现的部分。我们特别需要理解:

  • ngFor指令的工作原理

  • 为什么需要将输入的videos数组转换为safeVideoUrls

  • Angular 组件生命周期事件OnChanges的重要性(在视频播放器中使用)

首先,是时候正式介绍ngFor和它所属的指令类别:结构指令。

结构指令

指令的第三种分类,结构指令,用于操作其布局的组件/元素。

Angular 文档简洁描述了结构指令:

“与定义和控制视图(如组件指令)或修改元素外观和行为(如属性指令)不同,结构指令通过添加和移除整个元素子树来操纵布局。”

由于我们已经涉及到组件指令(例如workout-runnerexercise-description)和属性指令(例如ngClassngStyle),我们可以很好地将它们的行为与结构指令进行对比。

ngFor指令属于这个类。我们可以通过*前缀轻松识别这类指令。除了ngFor,Angular 还提供了一些其他结构指令,如ngIfngSwitch

非常有用的NgForOf

每种模板语言都有构造,允许模板引擎通过重复生成 HTML。Angular 有NgForOfNgForOf指令是一个非常有用的指令,用于将 HTML 片段的一部分复制 n 次。让我们再次看看我们如何在视频播放器中使用NgForOf

<div *ngFor="let video of safeVideoUrls"> 
   <iframe width="198" height="132" [src]="video" frameborder="0" allowfullscreen></iframe> 
</div>

NgForOf的指令选择器为{selector: '[ngFor][ngForOf]'},因此我们可以在视图模板中使用ngForngForOf。我们有时也将此指令称为ngFor

前面的代码为每个训练视频(使用safeVideoUrls数组)重复div片段。 字符串表达式let video of safeVideoUrls的解释如下:在safeVideoUrls数组中取每个视频,并将其分配给模板输入变量video

现在可以在ngFor模板 HTML 中引用此输入变量,就像我们设置src属性绑定时那样。

有趣的是,分配给ngFor指令的字符串并不是典型的 Angular 表达式。 相反,它是一种微语法—一种 Angular 引擎可以解析的微语言。

您可以在 Angular 的开发者指南中了解有关微语法的更多信息:bit.ly/ng6be-micro-syntax

这个微语法公开了一些迭代上下文属性,我们可以将它们分配给模板输入变量,并在ngFor HTML 区块内使用它们。

index就是一个这样的例子。index在每次迭代时从 0 增加到数组的长度,类似于任何编程语言中的for循环。 以下示例显示了如何捕获它:

<div *ngFor="let video of videos; let i=index"> 
     <div>This is video - {{i}}</div> 
</div> 

index之外,还有一些迭代上下文变量;这些包括firstlastevenodd。 这些上下文数据允许我们做一些巧妙的事情。 考虑这个例子:

<div *ngFor="let video of videos; let i=index; let f=first"> 
     <div [class.special]="f">This is video - {{i}}</div> 
</div> 

它对第一个视频div应用了special类。

NgForOf指令可以应用于 HTML 元素以及我们的自定义组件。 这是NgForOf的有效用法:

<user-profile *ngFor="let userDetail of users" [user]= "userDetail"></user-profile>

始终记住在ngFor(和其他结构指令)之前加上星号(*)。 *有其重要性。

结构指令中的星号(*)

*前缀是一种更简洁的格式,用于表示结构指令。 例如,视频播放器使用ngFor的用法。 ngFor模板:

<div *ngFor="let video of safeVideoUrls"> 
   <iframe width="198" height="132" [src]="video" frameborder="0" allowfullscreen></iframe> 
</div>

实际上扩展为以下内容:

<ng-template ngFor let-video [ngForOf]="safeVideoUrls">  
    <div>
        <iframe width="198" height="132"  [src]="video" ...></iframe>  
    </div> 
</ng-template>  

ng-template标签是一个 Angular 元素,有一个对ngFor的声明,一个模板输入变量(video)和指向safeVideoUrls数组的属性(ngForOf)。 前述两个声明都是ngFor的有效用法。

不知道你怎么看,但我更喜欢ngFor的更简洁的格式!

NgForOf性能

由于NgForOf根据集合元素生成 HTML,它因导致性能问题而声名狼藉。 但我们不能责怪指令。 它只是在应该做的事情:迭代和生成元素! 如果底层集合很庞大,UI 渲染可能会受到性能影响,特别是如果集合经常发生变化。 持续销毁和创建元素以响应不断变化的集合的成本可以很快变得禁止。

对于NgForOf的性能调整之一允许我们在底层集合元素添加或删除时改变ngForOf的行为,从而创建和销毁 DOM 元素。

想象一种情况,我们经常从服务器获得一个对象数组,并使用NgForOf将其绑定到视图。NgForOf的默认行为是在每次刷新列表时重新生成 DOM(因为 Angular 进行标准对象相等性检查)。然而,作为开发人员,我们可能很清楚并没有太多的变化。可能有一些新对象已经添加,一些删除,也许一些修改。但是 Angular 只是重新生成整个 DOM。

为了缓解这种情况,Angular 允许我们指定一个自定义的跟踪函数,让 Angular 知道何时两个比较的对象是相等的。看看以下函数:

trackByUserId(index: number, hero: User) { return user.id; } 

诸如这样的函数可以在NgForOf模板中用来告诉 Angular 基于它的id属性而不是进行引用相等性检查来比较 user 对象。

这就是我们如何在NgForOf模板中使用先前的函数:

<div *ngFor="let user of users; trackBy: trackByUserId">{{user.name}}</div> 

NgForOf 现在将避免为已呈现的用户 ID 重新创建 DOM。

请记住,如果用户的绑定属性发生了变化,Angular 仍然可能会更新现有的 DOM 元素。

ngFor指令就讲到这里,让我们继续吧。

我们仍然需要了解VideoPlayerComponent实现中的safeVideoUrlsOnChange生命周期事件的作用。让我们首先解决前者,并了解对safeVideoUrls的需求。

Angular 安全性

了解为什么我们需要绑定到safeVideoUrls而不是videos输入属性的最简单方法是尝试一下videos数组。用以下内容替换现有的ngFor片段 HTML:

<div *ngFor="let video of videos"> 
    <iframe width="198" height="132"  
        [src]="'//www.youtube.com/embed/' + video"  frameborder="0" allowfullscreen></iframe> 
</div>

看一下浏览器的控制台日志(可能需要刷新页面)。框架会抛出一堆错误,比如:

Error: unsafe value used in a resource URL context (see http://g.co/ng/security#xss)

猜猜发生了什么!Angular 正试图保护我们的应用免受跨站脚本XSS)攻击。

这种攻击使攻击者能够将恶意代码注入到我们的网页中。一旦注入,恶意代码可以从当前站点上下文读取数据。这允许它窃取机密信息,还可以冒充已登录用户,从而获得对特权资源的访问权限。

Angular 已经设计成通过清理注入到 Angular 视图中的任何外部代码/脚本来阻止这些攻击。请记住,内容可以通过许多机制被注入到视图中,包括属性/属性/样式绑定或内插。

考虑通过组件模型绑定 HTML 标记到 HTML 元素的innerHTML属性(属性绑定)的例子:

this.htmlContent = '<span>HTML content.</span>'    // Component

<div [innerHTML]="htmlContent"> <!-- View -->

在发出 HTML 内容时,会剥离任何不安全的内容(比如 脚本)。

那么 iframe 呢?在我们之前的例子中,Angular 也会阻止将属性绑定到 iframe 的 src 属性。这是针对使用 iframe 在我们自己的站点中嵌入第三方内容的警告。Angular 也会阻止这样做。

总的来说,该框架围绕内容消毒定义了四个安全上下文。这包括:

  1. HTML 内容消毒,当使用innerHTML属性绑定 HTML 内容时

  2. 样式消毒,当将 CSS 绑定到style属性中时

  3. URL 消毒,当使用anchorimg等标签时使用 URL

  4. 资源消毒,当使用Iframesscript标签时;在这种情况下,内容无法进行消毒,因此默认情况下会被阻止

Angular 正在尽其所能地让我们远离危险。但有时,我们知道内容是安全的,因此希望规避默认的消毒行为。

信任安全内容

为了让 Angular 知晓绑定的内容是安全的,我们使用DomSanitizer并根据刚刚描述的安全上下文调用适当的方法。可用的函数如下:

  • bypassSecurityTrustHtml

  • bypassSecurityTrustScript

  • bypassSecurityTrustStyle

  • bypassSecurityTrustUrl

  • bypassSecurityTrustResourceUrl

在我们的视频播放器实现中,我们使用了bypassSecurityTrustResourceUrl;它将视频 URL 转换为一个受信任的SafeResourceUrl对象:

this.videos.map(v => this.sanitizer.bypassSecurityTrustResourceUrl(this.youtubeUrlPrefix + v)) 

map方法将 videos 数组转换为一组SafeResourceUrl对象并将其分配给safeVideoUrls

先前列出的每个方法都需要一个字符串参数。这是我们希望 Angular 知道是安全的内容。然后返回的对象,可能是SafeStyleSafeHtmlSafeScriptSafeUrlSafeResourceUrl,然后可以绑定到视图中。

有关该主题的全面介绍可以在bit.ly/ng6be-security的框架安全指南中找到。强烈推荐阅读!

最后要回答的一个问题是为什么要在OnChanges Angular 生命周期事件中进行这样的操作?

OnChange 生命周期事件

每当组件的输入发生变化时,都会触发OnChanges生命周期事件。对于VideoPlayerComponent来说,每当加载新的练习时,videos数组输入属性发生变化。我们利用这个生命周期事件来重新创建safeVideoUrls数组并重新绑定到视图。简单!

视频面板实现现在已经完成。让我们添加一些小的增强措施,以及在 Angular 中进行更多的探索。

使用 innerHTML 绑定格式化练习步骤

目前应用程序中的一个痛点是练习步骤的格式。这些步骤有点难以阅读。

步骤应该要么有一个换行符(<br>),要么以 HTML list的格式进行格式化以便易于阅读。这似乎是一项直接的任务,我们可以直接改变绑定到步骤插值的数据,或者编写一个管道,使用行分隔约定()来添加一些 HTML 格式化。为了快速验证,让我们在workout-runner.component.ts中更新第一个练习的步骤,每一行后面都添加一个换行符(<br>):

`Assume an erect position, with feet together and arms at your side. <br> 
 Slightly bend your knees, and propel yourself a few inches into the air. <br> 
 While in air, bring your legs out to the side about shoulder width or slightly wider. <br> 
 ... 

当锻炼重新开始时,看看第一个练习步骤。输出不符合我们的预期,如下所示:

换行标记在浏览器中被直接呈现。Angular 没有将插值呈现为 HTML;相反,它转义了 HTML 字符,并且我们知道原因是安全问题!

如何修复它?很简单!用属性绑定替换插值,将步骤数据绑定到元素的innerHTML属性(在exercise-description.html中),就完成了!

<div class="card-text" [innerHTML]="steps"> 

刷新锻炼页面以确认。

防止跨站脚本攻击(XSS)问题

通过使用innerHTML,我们指示 Angular 不要转义 HTML,但 Angular 仍然会像早先的安全部分所描述的那样对输入的 HTML 进行清理。它会删除诸如<script>标签和其他 JavaScript 之类的内容,以防止 XSS 攻击。如果你想要动态地向 HTML 注入样式/脚本,请使用DomSanitizer来绕过这个清理检查。

是时候再次进行增强了!现在是学习 Angular 管道的时候了。

使用管道显示剩余的锻炼持续时间

如果我们可以告诉用户完成锻炼所剩下的时间,并不仅仅是进行中的练习的持续时间,那将会很好。我们可以在练习窗格的某个位置添加一个倒计时计时器,以显示整体剩余的时间。

我们要采取的方法是定义一个名为workoutTimeRemaining的组件属性。该属性将在锻炼开始时初始化为总时间,并随着每秒的流逝而减少,直到达到零。由于workoutTimeRemaining是一个数值,但我们想以hh:mm:ss的格式显示计时器,所以我们需要在秒数数据和时间格式之间进行转换。Angular 管道是实现这种功能的一个很好的选择。

Angular 管道

管道的主要目的是格式化视图中显示的数据。管道允许我们将内容转换逻辑(格式化)打包为可重用的元素。框架本身带有多个预定义的管道,例如datecurrencylowercaseuppercaseslice等。

这是我们如何在视图中使用管道:

{{expression | pipeName:inputParam1}} 

表达式后面跟着管道符号(|),后面跟着管道名称,然后是一个可选参数(inputParam1),用冒号(:)分隔。如果管道有多个输入,它们可以一个接一个地以冒号分隔显示,如内置的slice管道,它可以对数组或字符串进行切片:

{{fullName | slice:0:20}} //renders first 20 characters  

传递给管道的参数可以是一个常数或组件属性,这意味着我们可以使用带有管道参数的模板表达式。请看下面的例子:

{{fullName | slice:0:truncateAt}} //renders based on value truncateAt 

以下是一些使用date管道的例子,如 Angular date文档中描述的。假设dateObj初始化为2015 年 6 月 15 日 21:43:11,区域设置为en-US

{{ dateObj | date }}               // output is 'Jun 15, 2015        ' 
{{ dateObj | date:'medium' }}      // output is 'Jun 15, 2015, 9:43:11 PM' 
{{ dateObj | date:'shortTime' }}   // output is '9:43 PM            ' 
{{ dateObj | date:'mmss' }}        // output is '43:11'     

一些最常用的管道如下:

  • date: 正如我们刚刚看到的,日期过滤器用于以特定方式格式化日期。该过滤器支持许多格式,而且还具有地域设置。要了解日期管道支持的其他格式,请查看bit.ly/ng2-date上的框架文档。

  • uppercaselowercase: 正如名称所示,这两个管道改变字符串输入的大小写。

  • decimalpercent: decimalpercent管道用于根据当前浏览器地域设置格式化十进制和百分比数值。

  • currency: 这用于根据当前浏览器地域设置将数值格式化为货币:

 {{14.22|currency:"USD" }} <!-Renders USD 14.22 --> 
    {{14.22|currency:"USD":'symbol'}}  <!-Renders $14.22 -->
  • json: 这是一个方便的调试管道,可以使用JSON.stringify将任何输入转换为字符串。我们在本章的开始部分很好地利用了它来呈现WorkoutPlan对象(请参阅 Checkpoint 2.1 代码)。

  • slice: 这个管道允许我们将列表或字符串值拆分,创建一个更小的修剪列表/字符串。我们在前面的代码中看到了一个例子。

我们不会详细介绍前面的管道。从开发的角度来看,只要我们知道有哪些管道以及它们有什么用处,我们总是可以参考平台文档来获得准确的使用说明。

管道链接

管道的一个非常强大的特性是它们可以被链接在一起,其中一个管道的输出可以作为另一个管道的输入。考虑这个例子:

{{fullName | slice:0:20 | uppercase}} 

第一个管道切割fullName的前 20 个字符,第二个管道将它们转换为大写。

现在我们已经了解了管道是什么以及如何使用它们,为什么不为7 Minute Workout应用程序实现一个秒转时间管道呢?

实现自定义管道 - SecondsToTimePipe

SecondsToTimePipe,顾名思义,应该将数值转换为hh:mm:ss格式。

workout-runner 文件夹中创建一个名为shared的文件夹,并从 shared 文件夹中调用此 CLI 命令来生成管道样板:

ng generate pipe seconds-to-time

shared文件夹已被创建,用于添加可以在workout-runner模块中使用的常用组件/指令/管道。这是我们在不同层次组织共享代码时遵循的约定。将来,我们可以在应用程序模块级别创建一个共享文件夹,其中包含全局共享的工件。实际上,如果需要在其他应用程序模块中使用 second to time 管道,它也可以移动到应用程序模块中。

将以下transform函数实现复制到seconds-to-time.pipe.ts(也可以从 Git 分支checkpoint.2.4上的 GitHub 网站bit.ly/nng6be-2-4-seconds-to-time-pipe-ts下载该定义):

export class SecondsToTimePipe implements PipeTransform { 
  transform(value: number): any { 
    if (!isNaN(value)) { 
      const hours = Math.floor(value / 3600);
      const minutes = Math.floor((value - (hours * 3600)) / 60);
      const seconds = value - (hours * 3600) - (minutes * 60);

      return ('0' + hours).substr(-2) + ':'
        + ('0' + minutes).substr(-2) + ':'
        + ('0' + seconds).substr(-2);
    } 
    return; 
  } 
} 

在 Angular 管道中,实现逻辑放在 transform 函数中。作为 PipeTransform 接口的一部分,前面的 transform 函数将输入的秒值转换为 hh:mm:ss 字符串。transform 函数的第一个参数是管道输入。如果提供了后续参数,那么这些参数会作为管道的参数,使用冒号分隔符 (pipe:argument1:arugment2..) 从视图中传递过来。

对于 SecondsToTimePipe,虽然 Angular CLI 生成了一个样板参数 (args?:any),但我们没有使用任何管道参数,因为实现并不需要它。

管道实现非常直接,因为我们将秒转换为小时、分钟和秒。然后,我们将结果连接成一个字符串值并返回该值。对于 hoursminutesseconds 变量,左边添加 0 是为了在小时、分钟或秒的计算值小于 10 的情况下,格式化该值为具有前导 0 的值。

我们刚刚创建的管道只是一个标准的 TypeScript 类。正是 Pipe 装饰器 (@Pipe) 指示 Angular 将这个类视为管道:

@Pipe({ 
  name: 'secondsToTime' 
}) 

管道定义完成,但是在 WorkoutRunnerComponent 中使用管道,该管道必须在 WorkoutRunnerModule 中声明。Angular CLI 在生成时已经为我们做好了这一点(请参阅 workout-runner.module.ts 中的 declaration 部分)。

现在我们只需要在视图中添加管道。通过添加下面突出显示的片段来更新 workout-runner.component.html

<div class="exercise-pane" class="col-sm-6"> 
    <h4 class="text-center">Workout Remaining - {{workoutTimeRemaining | secondsToTime}}</h4>
    <h1 class="text-center">{{currentExercise.exercise.title}}</h1> 

令人惊讶的是,实现还没有完成!还有一步。我们已经定义了一个管道,并在视图中引用了它,但是 workoutTimeRemaining 需要在每秒钟更新一次才能让 SecondsToTimePipe 生效。

我们已经在 start 函数中用总的锻炼时间初始化了 WorkoutRunnerComponentworkoutTimeRemaining 属性:

start() { 
    this.workoutTimeRemaining = this.workoutPlan.totalWorkoutDuration(); 
    ... 
} 

现在的问题是:如何在每秒钟更新 workoutTimeRemaining 变量?记住我们已经设置了一个 setInterval 来更新 exerciseRunningDuration。虽然我们可以为 workoutTimeRemaining 编写另一个 setInterval 实现,但如果一个单独的 setInterval 设置可以满足两个要求会更好。

WorkoutRunnerComponent 中添加一个名为 startExerciseTimeTracking 的函数;它看起来是这样的:

startExerciseTimeTracking() {
    this.exerciseTrackingInterval = window.setInterval(() => {
      if (this.exerciseRunningDuration >= this.currentExercise.duration) {
        clearInterval(this.exerciseTrackingInterval);
        const next: ExercisePlan = this.getNextExercise();
        if (next) {
          if (next !== this.restExercise) {
            this.currentExerciseIndex++;
          }
          this.startExercise(next);
        }
        else {
          console.log('Workout complete!');
        }
        return;
      }
      ++this.exerciseRunningDuration;
      --this.workoutTimeRemaining;
    }, 1000);
  }  

正如你所看到的,这个函数的主要目的是跟踪锻炼的进度,并在完成后切换锻炼。然而,它还跟踪 workoutTimeRemaining(它会递减这个计数器)。第一个 if 条件设置只是确保当所有锻炼完成时清除计时器。内部的 if 条件用于保持 currentExerciseIndex 与正在进行的锻炼保持同步。

该函数使用一个名为 exerciseTrackingInterval 的数值实例变量。将其添加到类声明部分。我们稍后将使用此变量来实现练习暂停行为。

startExercise 中删除完整的 setInterval 设置,并用调用 this.startExerciseTimeTracking(); 替换它。我们已经准备好测试我们的实现了。如果需要的话,刷新浏览器并验证实现:

下一节介绍了另一个内置的 Angular 指令 ngIf,以及另一个小的增强。

使用 ngIf 添加下一个练习指示器

用户在短暂的休息时间内被告知下一个练习是什么会很好。这将帮助他们为下一个练习做好准备。所以让我们添加上去。

要实现这一功能,我们可以简单地从 workoutPlan.exercises 数组中输出下一个练习的标题。我们将标题显示在 Time Remaining 倒计时部分旁边。

更改练习 div(class="exercise-pane")以包含突出显示的内容,并移除现有的 Time Remaining h1

<div class="exercise-pane"> 
<!-- Exiting html --> 
   <div class="progress time-progress"> 
       <!-- Exiting html --> 
   </div> 
 <div class="row">
 <h4 class="col-sm-6 text-left">Time Remaining:
 <strong>{{currentExercise.duration-exerciseRunningDuration}}</strong>
 </h4>
 <h4 class="col-sm-6 text-right" *ngIf="currentExercise.exercise.name=='rest'">Next up:
 <strong>{{workoutPlan.exercises[currentExerciseIndex + 1].exercise.title}}</strong>
 </h4>
 </div>
</div> 

我们包裹现有的 Time Remaining h1,并在一个新的 div 中添加另一个 h3 标签,在其中显示下一个练习,并进行一些样式更新。此外,第二个 h3 中有一个新的指令 ngIf* 前缀意味着它属于 ngFor 所属的同一组指令:结构指令。让我们稍微谈谈 ngIf

ngIf 指令用于根据提供给它的表达式是否返回 truefalse 来添加或移除 DOM 的特定部分。当表达式评估为 true 时,DOM 元素被添加,否则被销毁。将 ngIf 声明与之前的视图隔离开来:

ngIf="currentExercise.details.name=='rest'" 

该指令表达式检查我们当前是否处于休息阶段,并相应地显示或隐藏链接的 h3

同样在相同的 h3 中,我们有一个插值,显示来自 workoutPlan.exercises 数组的练习名称。

在这里需要注意:ngIf 添加和销毁 DOM 元素,因此它不同于我们用来显示和隐藏元素的可见性构造。虽然 styledisplay:none 的最终结果与 ngIf 相同,但机制完全不同:

<div [style.display]="isAdmin" ? 'block' : 'none'">Welcome Admin</div> 

与这行相比:

<div *ngIf="isAdmin" ? 'block' : 'none'">Welcome Admin</div> 

使用ngIf,每当表达式从false变为true时,内容会完全重新初始化。从父级到子级递归创建新元素/组件,并设置数据绑定。当表达式从true变为false时,所有这些都会被销毁。因此,如果ngIf包裹了大量内容并且附加到它的表达式经常更改,使用ngIf有时可能会变得很昂贵。但是,将视图包裹在ngIf中比使用基于 CSS/样式的显示或隐藏更高效,因为当ngIf表达式求值为false时,既不会创建 DOM,也不会设置数据绑定表达式。

Angular 的新版本也支持分支结构。这样我们就可以在视图 HTML 中实现if then else流程。以下示例直接摘自ngIf的平台文档:

<div *ngIf="show; else elseBlock">Text to show</div>
<ng-template #elseBlock>Alternate text while primary text is hidden</ng-template>

else绑定到一个具有模板变量#elseBlockng-template

还有另一个指令属于这类:ngSwitch。当定义在父 HTML 中时,它可以根据ngSwitch表达式交换子 HTML 元素。考虑这个例子:

<div id="parent" [ngSwitch] ="userType"> 
<div *ngSwitchCase="'admin'">I am the Admin!</div> 
<div *ngSwitchCase="'powerUser'">I am the Power User!</div> 
<div *ngSwitchDefault>I am a normal user!</div> 
</div> 

我们将userType表达式绑定到ngSwitch。根据userType的值(adminpowerUser,或任何其他userType),将呈现一个内部 div 元素。ngSwitchDefault指令是通配符匹配/默认匹配,当userType既不是admin也不是powerUser时,它就会被呈现。

如果你还没有意识到,注意这里有三个指令协同工作,以实现类似 switch-case 的行为:

  • ngSwitch

  • ngSwitchCase

  • ngSwitchDefault

回到我们的下一个锻炼实现,我们已经准备好验证实现,启动应用程序,并等待休息时间。在休息期间应该提到下一个锻炼,如下所示:

应用程序正在不断完善。如果你用过这个应用程序并进行了一些体育锻炼,你会非常想念暂停功能。锻炼直到结束才停止。我们需要修复这个行为。

暂停一项锻炼

要暂停一项锻炼,我们需要停止计时器。我们还需要在视图中的某个位置添加一个按钮,让我们可以暂停和恢复锻炼。我们计划通过在页面中心的锻炼区域上绘制一个按钮覆盖层来实现这一点。点击它将在暂停和运行之间切换锻炼状态。我们还将通过按键绑定pP来添加键盘支持以暂停和恢复锻炼。让我们更新组件。

更新WorkoutRunnerComponent类,添加这三个函数,并声明workoutPaused变量:

workoutPaused: boolean; 
...
pause() { 
    clearInterval(this.exerciseTrackingInterval); 
    this.workoutPaused = true; 
} 

resume() { 
    this.startExerciseTimeTracking(); 
    this.workoutPaused = false; 
} 

pauseResumeToggle() { 
    if (this.workoutPaused) { this.resume();    } 
    else {      this.pause();    } 
} 

暂停的实现很简单。我们首先要做的是通过调用clearInterval(this.exerciseTrackingInterval);来取消现有的setInterval设置。恢复时,我们再次调用startExerciseTimeTracking,再次从我们离开的地方开始跟踪时间。

现在我们只需要为视图调用pauseResumeToggle函数。在workout-runner.html中添加以下内容:

<div id="exercise-pane" class="col-sm-6"> 
 <div id="pause-overlay" (click)="pauseResumeToggle()"><span class="pause absolute-center" 
            [ngClass]="{'ion-md-pause' : !workoutPaused, 'ion-md-play' : workoutPaused}">
        </span> </div> 
    <div class="row workout-content"> 

div上的click事件处理程序切换了训练运行状态,而ngClass指令用于在ion-md-pauseion-md-play之间切换类-标准的 Angular 东西。现在缺少的是能够在按下P键时暂停和恢复。

一种方法是在div上应用keyup事件处理程序:

 <div id="pause-overlay" (keyup)= "onKeyPressed($event)"> 

但这种方法存在一些缺点:

  • div元素没有焦点的概念,因此我们还需要在div上添加tabIndex属性才能使其工作

  • 即使这样,它只有在我们至少点击一次div时才会起作用

有一种更好的实现方式;将事件处理程序附加到全局window事件keyup上。这就是应该在div上应用事件绑定的方式:

<div id="pause-overlay" (window:keyup)= "onKeyPressed($event)">

注意在keyup事件之前的特殊window:前缀。我们可以使用这种语法来将事件附加到任何全局对象,比如document。这是 Angular 绑定基础设施的一个方便而非常强大的功能!onKeyPressed事件处理程序需要添加到WorkoutRunnerComponent。将这个函数添加到类中:

onKeyPressed(event: KeyboardEvent) {
    if (event.which === 80 || event.which === 112) {
      this.pauseResumeToggle();
    }
  }

$event对象是 Angular 可供操作的标准DOM 事件对象。由于这是一个键盘事件,特殊的类是KeyboardEventwhich属性与pP的 ASCII 值相匹配。刷新页面,当鼠标悬停在训练图像上时,您应该看到播放/暂停图标,如下所示:

当我们谈论事件绑定时,这将是探索 Angular 事件绑定基础设施的好机会

Angular 事件绑定基础设施

Angular 事件绑定允许组件通过事件与其父组件进行通信。

如果回顾应用实现,到目前为止我们遇到的是属性/属性绑定。这种绑定允许组件/元素从外部世界获取输入。数据流入组件。

事件绑定是属性绑定的反向。它允许组件/元素通知外部世界有关任何状态变化。

正如我们在暂停/恢复实现中看到的,事件绑定使用圆括号(())来指定目标事件:

<div id="pause-overlay" (click)="pauseResumeToggle()"> 

这将pauseResumeToggle()表达式绑定到div上,当点击div时调用该表达式。

像属性一样,事件也有一个规范形式。可以使用on-前缀,而不是使用圆括号:on-click="pauseResumeToggle()"

Angular 支持所有类型的事件。与键盘输入、鼠标移动、按钮点击和触摸有关的事件。该框架甚至允许我们为自己创建的组件定义自定义事件,比如:

<workout-runner (paused)= "stopAudio()"></workout-runner> 

期望事件会产生副作用;换句话说,事件处理程序可能会改变组件的状态,进而可能触发一个连锁反应,导致多个组件对状态变化做出反应并改变它们自己的状态。这与属性绑定表达式不同,后者应该是无副作用的。即使在我们的实现中,点击div元素也会切换练习运行状态。

事件冒泡

当 Angular 将事件处理程序附加到标准 HTML 元素事件时,事件传播工作方式与标准 DOM 事件传播方式相同。这也称为事件冒泡。子元素上的事件向上传播,因此也可以在父元素上进行事件绑定,方式如下:

<div id="parent " (click)="doWork($event)"> Try 
  <div id="child ">me!</div> 
</div> 

点击任一div都会调用父div上的doWork函数。此外,$event.target包含了派发事件的div的引用。

在 Angular 组件上创建的自定义事件不支持事件冒泡。

如果目标分配的表达式计算结果为falsey值(如voidfalse),那么事件冒泡会停止。因此,为了继续传播,表达式应该计算结果为true

<div id="parent" (click)="doWork($event) || true"> 

在这里,$event对象也值得特别关注。

绑定$event 对象

Angular 在目标事件触发时提供了一个$event对象。这个$event包含了发生的事件的细节。

这里需要注意的重要事情是,$event对象的形状是根据事件类型确定的。对于 HTML 元素,它是一个 DOM 事件对象(developer.mozilla.org/en-US/docs/Web/Events),根据实际事件可能有所不同。

但如果是自定义组件事件,传递给$event对象的内容由组件实现决定。

我们现在已经介绍了 Angular 的大多数数据绑定功能,只剩下双向绑定。在我们结束本章之前,有必要快速介绍一下双向绑定构造。

使用 ngModel 进行双向绑定

双向绑定帮助我们保持模型和视图同步。对模型的更改会更新视图,对视图的更改也会更新模型。双向绑定适用的明显领域是表单输入。让我们看一个简单的例子:

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

这里的ngModel指令在inputvalue属性和底层组件的workout.name属性之间建立了双向绑定。用户在input中输入的任何内容都会与workout.name同步,workout.name的任何更改也会反映到前面的input上。

有趣的是,我们甚至可以在不使用ngModel指令的情况下,通过结合属性和事件绑定语法来实现相同的效果。考虑下一个例子;它的工作方式与之前的input一样:

<input [value]="workout.name"  
    (input)="workout.name=$event.target.value" > 

value属性上设置了一个属性绑定,并在input事件上设置了一个事件绑定,使双向同步工作。

我们将在第二章,个人教练,中更详细地介绍双向绑定,我们将在那里构建自定义的锻炼。

我们已经创建了一个总结到目前为止所有绑定的数据流模式的图表。这是一个方便的图表,可以帮助您记忆每个绑定构造以及数据流动的方式:

图片

现在我们有了一个功能齐全的7 分钟锻炼,而且还有一些附加功能,希望您在创建应用程序时玩得开心。是时候来总结本章的教训了。

如果您在运行代码时遇到问题,请查看checkpoint2.4Git 分支,以获取到目前为止我们所做的工作的可用版本。您也可以在这个 GitHub 位置下载checkpoint2.4的快照(一个 ZIP 文件):bit.ly/ng6be-checkpoint-2-4。首次设置快照时,请参阅trainer文件夹中的README.md文件。

使用 Angular 事件进行跨组件通信

现在是时候更深入地了解事件了。让我们为7 分钟锻炼添加音频支持。

使用音频跟踪锻炼进度

对于7 分钟锻炼应用程序来说,添加声音支持是至关重要的。一个人不可能一直盯着屏幕运动。音频提示帮助用户有效地完成锻炼,他们可以只需跟随音频指示即可。

这是我们如何利用音频提示来支持锻炼追踪:

  • 在练习期间,会有一个滴答声的时钟声音

  • 一半的指示器声音会响起,表示练习已经进行了一半

  • 当练习即将结束时,会播放一个练习完成的音频剪辑

  • 在休息阶段,会播放一个音频剪辑,并通知用户接下来的练习是什么

每种情况下都会有一个音频剪辑。

现代浏览器对音频有很好的支持。HTML5 的<audio>标签提供了将音频剪辑嵌入 HTML 内容中的机制。我们也将使用<audio>标签来播放我们的剪辑。

由于计划使用 HTML 的<audio>元素,我们需要创建一个包装指令,允许我们从 Angular 中控制音频元素。请记住,指令是没有视图的 HTML 扩展。

checkpoint3.4 Git 和trainer/static/audio文件夹包含了所有用于播放的音频文件;首先复制它们。如果您不使用 Git,可以在bit.ly/ng6be-checkpoint-3-4下载并解压章节代码的快照。下载并复制音频文件。

构建 Angular 指令封装 HTML 音频

如果你在使用 JavaScript 和 jQuery 时工作得很多,你可能会意识到,我们故意避免直接访问 DOM 来实现任何组件。其实我们并没有必要这样做。Angular 中的数据绑定基础设施,包括属性、属性和事件绑定,帮助我们在不触碰 DOM 的情况下操作 HTML。

对于音频元素,访问模式也应该符合 Angular 规范。在 Angular 中,唯一可以接受和实践直接 DOM 操作的地方就是指令内部。让我们创建一个指令来封装对音频元素的访问。

转到 trainer/src/app/shared 并运行以下命令生成一个模板指令:

ng generate directive my-audio

由于这是我们第一次创建指令,我们鼓励你查看生成的代码。

由于指令被添加到了共享模块中,它也需要被导出。在 exports 数组中添加 MyAudioDirective 的引用(shared.module.ts)。然后更新指令定义与以下代码:

    import {Directive, ElementRef} from '@angular/core'; 

    @Directive({ 
      selector: 'audio', 
      exportAs: 'MyAudio' 
    }) 
    export class MyAudioDirective { 
      private audioPlayer: HTMLAudioElement; 
      constructor(element: ElementRef) { 
        this.audioPlayer = element.nativeElement; 
      } 
    } 

类 MyAudioDirective 被装饰为 @Directive。 @Directive 装饰器类似于 @Component 装饰器,除了我们不能附加视图。因此,不允许使用 template 或 templateUrl

前面的 selector 属性使框架能够识别要应用指令的位置。我们用 audio 替换了生成的 [abeMyAudioDirective] 属性选择器,使得我们的指令在 HTML 中的每个 <audio> 标签上加载。新的选择器作为元素选择器。

在标准场景中,指令选择器是基于属性的(例如生成的代码为 [abeMyAudioDirective]),这有助于我们识别指令被应用在哪里。我们偏离了这个规范,使用了元素选择器来创建 MyAudioDirective 指令。我们希望这个指令能够加载到每个音频元素上,并且不用为每个音频声明单独添加指令特定的属性。因此选择了元素选择器。

在视图模板中使用这个指令时, exportAs 的使用变得清晰了。

构造函数中注入的 ElementRef 对象是 Angular 元素(在这种情况下是 audio)的实例,加载了该指令。当 Angular 编译和执行 HTML 模板时,为每个组件和指令创建 ElementRef 实例。在构造函数中请求时,DI 框架定位到对应的 ElementRef 并注入。我们使用 ElementRef 来在代码中获取底层的音频元素(HTMLAudioElement 的实例)。 audioPlayer 属性保存了这个引用。

现在指令需要暴露一个 API 以便操作音频播放器。添加以下函数到 MyAudioDirective 指令中:

    stop() { 
      this.audioPlayer.pause(); 
    }

    start() { 
      this.audioPlayer.play();
    }
    get currentTime(): number { 
      return this.audioPlayer.currentTime; 
    }

    get duration(): number { 
      return this.audioPlayer.duration; 
    }

    get playbackComplete() { 
      return this.duration == this.currentTime; 
    }

MyAudioDirectiveAPI 有两个函数(startstop)和三个 getter(currentTimeduration,以及一个名为playbackComplete的布尔属性)。这些函数和属性的实现只是对音频元素函数进行了封装。

从 MDN 文档中了解这些音频功能:bit.ly/html-media-element

要理解我们如何使用音频指令,让我们创建一个新的组件来管理音频播放。

为音频支持创建 WorkoutAudioComponent

如果我们回过头来看所需的音频提示,我们需要四个不同的音频提示,因此我们将创建一个包含五个嵌入式<audio>标签的组件(两个音频标签一起工作来产生下一个音频提示)。

从命令行转到trainer/src/app/workout-runner文件夹,并使用 Angular CLI 添加一个新的WorkoutAudioComponent组件。

打开workout-audio.component.html并用这段 HTML 代码替换现有视图模板:

<audio #ticks="MyAudio" loop src="img/tick10s.mp3"></audio>
<audio #nextUp="MyAudio" src="img/nextup.mp3"></audio>
<audio #nextUpExercise="MyAudio" [src]="'/assets/audio/' + nextupSound"></audio>
<audio #halfway="MyAudio" src="img/15seconds.wav"></audio>
<audio #aboutToComplete="MyAudio" src="img/321.wav"></audio> 

有五个<audio>标签,每个用于以下内容:

  • Ticking audio: 第一个音频标签产生滴答声,并在练习开始时启动。

  • Next up audio and exercise audio: 接下来的两个音频标签一起工作。第一个标签发出"下一个"声音。实际的练习音频由第三个标签处理(在前面的代码片段中)。

  • Halfway audio: 第四个音频标签在练习进行到一半时播放。

  • About to complete audio: 最后一个音频标签播放一段音乐来表示练习的完成。

你注意到每个audio标签中都使用了#符号了吗?在 Angular 世界中,这些带有#前缀的变量被称为模板引用变量或者有时候叫做模板变量

正如平台指南定义的那样:

模板引用变量通常是模板中的一个 DOM 元素或指令的引用。

不要将它们与我们之前使用ngFor指令时使用的模板输入变量混淆,例如*ngFor="let video of videos"模板输入变量(在这种情况下是video)的作用域仅限于它被声明的 HTML 片段内,而模板引用变量可以在整个模板中访问。

看看最后一节,MyAudioDirective被定义了。exportAs元数据被设置为MyAudio。在为每个音频标签分配模板引用变量时我们重复了相同的MyAudio字符串:

#ticks="MyAudio"

exportAs的作用是定义可以在视图中用来分配这个指令的变量的名称。记住,单个元素/组件可以应用多个指令。exportAs允许我们基于等号右侧的内容选择应该分配给模板引用变量的哪个指令。

通常,一旦声明了模板变量,就可以访问它们所附加的视图元素/组件,并将其用于视图的其他部分,这是我们将很快讨论的。但在我们的例子中,我们将使用模板变量来引用来自父组件代码的多个MyAudioDirective。让我们了解如何使用它们。

使用以下大纲更新生成的workout-audio.compnent.ts:

import { Component, OnInit, ViewChild } from '@angular/core';
import { MyAudioDirective } from '../../shared/my-audio.directive';

@Component({
 ...
})
export class WorkoutAudioComponent implements OnInit {
 @ViewChild('ticks') private ticks: MyAudioDirective;
 @ViewChild('nextUp') private nextUp: MyAudioDirective;
 @ViewChild('nextUpExercise') private nextUpExercise: MyAudioDirective;
 @ViewChild('halfway') private halfway: MyAudioDirective;
 @ViewChild('aboutToComplete') private aboutToComplete: MyAudioDirective;
 private nextupSound: string;

  constructor() { } 
  ...
}

这个大纲中有趣的是五个属性上带有@ViewChild装饰器。@ViewChild装饰器允许我们将子组件/指令/元素引用注入到父组件中。传递给装饰器的参数是模板变量名,这有助于 DI 匹配要注入的元素/指令。当 Angular 实例化主WorkoutAudioComponent时,它会根据@ViewChild装饰器和传递的模板引用变量名注入相应的音频指令。在我们详细了解@ViewChild之前,让我们先完成基本类的实现。

如果没有在MyAudioDirective指令上设置exportAs,则@ViewChild注入会注入相关的ElementRef实例,而不是MyAudioDirective实例。我们可以通过从myAudioDirective中删除exportAs属性,然后查看WorkoutAudioComponent中注入的依赖项来确认这一点。

剩下的任务就是在正确的时间播放正确的音频组件。将这些函数添加到WorkoutAudioComponent:

stop() {
    this.ticks.stop();
    this.nextUp.stop();
    this.halfway.stop();
    this.aboutToComplete.stop();
    this.nextUpExercise.stop();
  }
  resume() {
    this.ticks.start();
    if (this.nextUp.currentTime > 0 && !this.nextUp.playbackComplete) 
        { this.nextUp.start(); }
    else if (this.nextUpExercise.currentTime > 0 && !this.nextUpExercise.playbackComplete)
         { this.nextUpExercise.start(); }
    else if (this.halfway.currentTime > 0 && !this.halfway.playbackComplete) 
        { this.halfway.start(); }
    else if (this.aboutToComplete.currentTime > 0 && !this.aboutToComplete.playbackComplete) 
        { this.aboutToComplete.start(); }
  }

  onExerciseProgress(progress: ExerciseProgressEvent) {
    if (progress.runningFor === Math.floor(progress.exercise.duration / 2)
      && progress.exercise.exercise.name != 'rest') {
      this.halfway.start();
    }
    else if (progress.timeRemaining === 3) {
      this.aboutToComplete.start();
    }
  }

  onExerciseChanged(state: ExerciseChangedEvent) {
    if (state.current.exercise.name === 'rest') {
      this.nextupSound = state.next.exercise.nameSound;
      setTimeout(() => this.nextUp.start(), 2000);
      setTimeout(() => this.nextUpExercise.start(), 3000);
    }
  } 

写这些函数有困难吗?它们可以在checkpoint3.3Git 分支中找到。

前面代码中使用了两个新的模型类。将它们的声明添加到model.ts中,如下所示(也可在checkpoint3.3中找到):

export class ExerciseProgressEvent {
    constructor(
        public exercise: ExercisePlan,
        public runningFor: number,
        public timeRemaining: number,
        public workoutTimeRemaining: number) { }
}

export class ExerciseChangedEvent {
    constructor(
        public current: ExercisePlan,
        public next: ExercisePlan) { }
} 

这些是用于跟踪进度事件的模型类。WorkoutAudioComponent实现使用这些数据。请记得在workout-audio.component.ts中导入ExerciseProgressEventExerciseProgressEvent的引用。

重申一下,音频组件通过定义两个事件处理程序onExerciseProgressonExerciseChanged来使用事件。事件是如何生成的将随着我们的深入而变得更加清晰。

startresume函数在 workout 开始、暂停或完成时停止和恢复音频播放。resume函数中的额外复杂性是为了处理 workout 在下一个、即将完成或中途暂停的情况。我们只想从之前停止的地方继续。

应该调用onExerciseProgress函数来报告 workout 的进度。它用于根据 workout 的状态播放 halfway 音频和 about-to-complete 音频。传递给它的参数是一个包含 exercise 进度数据的对象。

当 exercise 改变时应该调用onExerciseChanged函数。输入参数包含当前和下一个 exercise,帮助WorkoutAudioComponent决定何时播放下一个 exercise 的音频。

在本节中我们提到了两个新概念:模板引用变量和将子元素/指令注入到父元素中。在继续实现之前,值得更详细地探讨这两个概念。我们将首先学习更多关于模板引用变量的知识。

理解模板引用变量

模板引用变量在视图模板上创建,并且大多数情况下从视图中使用。正如你已经学到的,这些变量可以通过#前缀声明来识别。

模板变量最大的好处之一是它们便于在视图模板层面进行跨组件通信。一旦声明了这样的变量,它们就可以被同级元素/组件及其子元素引用。请查看以下代码片段:

    <input #emailId type="email">Email to {{emailId.value}} 
    <button (click)= "MailUser(emaild.value)">Send</button> 

此代码片段声明了一个模板变量emailId,然后在插值和按钮click表达式中引用它。

Angular 模板引擎将inputHTMLInputElement的一个实例)的 DOM 对象分配给emailId变量。由于该变量在同级元素中可用,我们在按钮的click表达式中使用它。

模板变量也适用于组件。我们可以轻松地做到:

    <trainer-app> 
     <workout-runner #runner></workout-runner> 
     <button (click)= "runner.start()">Start Workout</button> 
    </trainer-app> 

在这种情况下,runner引用了WorkoutRunnerComponent对象,并且按钮用于启动锻炼。

ref-前缀是#的规范替代品。#runner变量也可以声明为ref-runner

模板变量赋值

你可能没有注意到,但在最近几节介绍的模板变量赋值中有一些有趣的事情。简而言之,我们使用了三个示例:

<audio #ticks="MyAudio" loop src="img/tick10s.mp3"></audio> 

<input #emailId type="email">Email to {{emailId.value}}

<workout-runner #runner></workout-runner> 

变量分配的内容取决于变量声明的位置。这受 Angular 规则的约束:

  • 如果指令存在于元素上,例如前面显示的第一个示例中的MyAudioDirective,则指令设置该值。MyAudioDirective指令将ticks变量设置为MyAudioDirective的一个实例。

  • 如果没有指令存在,变量分别分配给了底层 HTML DOM 元素或组件对象(如inputworkout-runner示例中所示)。

我们将采用这种技术来实现训练音频组件与训练运行组件的集成。这个介绍给了我们需要的头部起步。

我们答应要介绍的另一个新概念是使用ViewChildViewChildren装饰器进行子元素/指令注入。

使用@ViewChild 装饰器

@ViewChild装饰器指示 Angular DI 框架在组件树中搜索特定的子组件/指令/元素,并将其注入到父组件中。这允许父组件使用对子组件的引用来与子组件/元素进行交互,这是一种新的通信模式!

在上面的代码中,音频元素指令(MyAudioDirective类)被注入到WorkoutAudioComponent中。

为了建立上下文,让我们重新检查WorkoutAudioComponent的视图片段:

    <audio #ticks="MyAudio" loop src="img/tick10s.mp3"></audio> 

Angular 会将指令(MyAudioDirective)注入到WorkoutAudioComponent的属性ticks中:搜索是基于传递给@ViewChild装饰器的选择器的。让我们再次看一下音频示例:

 @ViewChild('ticks') private ticks: MyAudioDirective;

ViewChild上的选择器参数可以是一个字符串值,此时 Angular 会搜索匹配的模板变量,就像之前一样。

或者可以是类型。这是有效的,并应该注入一个MyAudioDirective的实例:

@ViewChild(MyAudioDirective) private ticks: MyAudioDirective; 

但是,在我们的情况下,它不起作用。为什么?因为在WorkoutAudioComponent视图中声明了多个MyAudioDirective指令,每一个都对应一个<audio>标签。在这种情况下,只有第一个匹配项被注入。并不是很有用。如果视图中只有一个<audio>标签,那么传递类型选择器就可以工作了!

使用@ViewChild修饰的属性一定会在组件的ngAfterViewInit事件钩子被调用之前设置。这意味着如果在构造函数内部访问这些属性,它们会是null

Angular 还有一个装饰器用于定位和注入多个子组件/指令:@ViewChildren

@ViewChildren装饰器

@ViewChildren的工作原理类似于@ViewChild,只不过它可以把多个子类型注入到父级中。再次以上面的音频组件为例,在WorkoutAudioComponent中使用@ViewChildren,我们可以得到所有MyAudioDirective指令的实例,如下所示:

@ViewChildren(MyAudioDirective) allAudios: QueryList<MyAudioDirective>; 

仔细看,allAudios不是标准的 JavaScript 数组,而是一个自定义类QueryList<Type>QueryList类是一个不可变集合,其中包含了 Angular 基于传递给@ViewChildren装饰器的过滤条件所能定位的组件/指令的引用。这个列表的最好之处在于 Angular 会将这个列表与视图的状态保持同步。当动态地向视图中添加/移除指令/组件时,这个列表也会被更新。利用ng-for生成的组件/指令就是这种动态行为的一个典型例子。考虑上面的@ViewChildren使用和这个视图模板:

<audio *ngFor="let clip of clips" src="img/ "+{{clip}}></audio> 

Angular 创建的MyAudioDirective指令的数量取决于clips的数量。使用@ViewChildren时,Angular 会将正确数量的MyAudioDirective实例注入到allAudio属性中,并在从clips数组中添加或移除项时保持同步。

虽然使用@ViewChildren允许我们获取所有MyAudioDirective指令,但它不能用于控制播放。你看,我们需要获取单独的MyAudioDirective实例,因为音频播放的时间是变化的。因此,就有了独特的@ViewChild实现。

一旦我们获得了附加到每个音频元素的MyAudioDirective指令,只需按时播放音频轨道。

整合 WorkoutAudioComponent

虽然我们已经将音频播放功能组件化为WorkoutAudioComponent,但它始终紧密耦合到WorkoutRunnerComponent的实现中。WorkoutAudioComponentWorkoutRunnerComponent获取其操作智能。因此,这两个组件需要交互。WorkoutRunnerComponent需要提供WorkoutAudioComponent的状态改变数据,包括锻炼开始时,锻炼进度,锻炼停止,暂停和恢复时等等。

实现这种集成的一种方式是使用当前公开的WorkoutAudioComponent API(停止、恢复和其他功能)来自WorkoutRunnerComponent

可以通过将WorkoutAudioComponent注入WorkoutRunnerComponent来完成某些操作,就像我们之前将MyAudioDirective注入WorkoutAudioComponent一样。

声明将WorkoutAudioComponent放在WorkoutRunnerComponent的视图中,例如:

<div class="row pt-4">...</div>
<abe-workout-audio></abe-workout-audio>

这样做可以在WorkoutRunnerComponent的实现中给我们一个对WorkoutAudioComponent的引用:

@ViewChild(WorkoutAudioComponent) workoutAudioPlayer: WorkoutAudioComponent; 

然后可以在代码的不同位置从WorkoutRunnerComponent调用WorkoutAudioComponent的函数。例如,暂停会改变如下:

    pause() { 
      clearInterval(this.exerciseTrackingInterval); 
      this.workoutPaused = true; 
 this.workoutAudioPlayer.stop(); 
    }

而播放接下来的语音需要改变startExerciseTimeTracking函数的一部分:

this.startExercise(next); 
this.workoutAudioPlayer.onExerciseChanged(new ExerciseChangedEvent(next, this.getNextExercise()));

这是一个非常可行的选择,其中WorkoutAudioComponent成为WorkoutRunnerComponent控制的一个哑组件。这个解决方案的唯一问题是它给WorkoutRunnerComponent的实现增加了一些噪音。WorkoutRunnerComponent现在也需要管理音频播放。

然而,也有另一种选择。

WorkoutRunnerComponent可以暴露在锻炼执行不同时间触发的事件,例如锻炼开始,锻炼暂停等。WorkoutRunnerComponent暴露事件的优势在于,它允许我们使用相同的事件将其他组件/指令与WorkoutRunnerComponent集成。不管是WorkoutAudioComponent还是我们未来创建的组件。

暴露 WorkoutRunnerComponent 事件

到目前为止,我们只探讨了如何消费事件。Angular 也允许我们触发事件。Angular 组件和指令可以使用EventEmitter类和@Output修饰符来暴露自定义事件。

将这些事件声明添加到WorkoutRunnerComponent的变量声明部分的末尾:

workoutPaused: boolean; 
@Output() exercisePaused: EventEmitter<number> = 
    new EventEmitter<number>(); @Output() exerciseResumed: EventEmitter<number> = 
    new EventEmitter<number>() @Output() exerciseProgress:EventEmitter<ExerciseProgressEvent> = 
    new EventEmitter<ExerciseProgressEvent>(); @Output() exerciseChanged: EventEmitter<ExerciseChangedEvent> = 
    new EventEmitter<ExerciseChangedEvent>(); @Output() workoutStarted: EventEmitter<WorkoutPlan> = 
    new EventEmitter<WorkoutPlan>(); @Output() workoutComplete: EventEmitter<WorkoutPlan> = 
    new EventEmitter<WorkoutPlan>();

事件的名称不言自明,在我们的WorkoutRunnerComponent实现中,我们需要在适当的时候触发它们。

记得把ExerciseProgressEventExerciseChangeEvent导入到顶部已声明的model中。并且将OutputEventEmitter导入到@angular/core中。

让我们尝试理解@Output装饰器和EventEmitter类的作用。

@Output 装饰器

在本章中,我们介绍了很多 Angular 事件处理的能力。我们具体学习了如何使用[]语法在组件、指令或 DOM 元素上消耗任何事件。那我们如何触发自己的事件呢?

在 Angular 中,我们可以创建和触发自己的事件,这些事件表示在我们的组件/指令中发生了值得注意的事情。使用@Output装饰器和EventEmitter类,我们可以定义和触发自定义事件。

现在是一个恰当的时机来回顾我们学到的有关事件的知识。

记住:正是通过事件,组件才能与外部世界通信。当我们声明:

@Output() exercisePaused: EventEmitter<number> = new EventEmitter<number>(); 

这表示WorkoutRunnerComponent公开了一个名为exercisePaused的事件(在锻炼暂停时触发)。

要订阅此事件,我们可以执行以下操作:

<abe-workout-runner (exercisePaused)="onExercisePaused($event)"></abe-workout-runner>

这看起来与我们在锻炼运行器模板中进行 DOM 事件订阅非常相似。看看从锻炼运行者视图中摘取的这个样本:

<div id="pause-overlay" (click)="pauseResumeToggle()" (window:keyup)="onKeyPressed($event)"> 

@Output 装饰器指示 Angular 使该事件可用于模板绑定。没有使用@Output 装饰器创建的事件无法在 HTML 中引用。

@Output 装饰器也可以带有一个参数,表示事件的名称。如果没有提供,则装饰器使用属性名称:@Output("workoutPaused") exercisePaused: EventEmitter<number> ...。这声明了一个名为workoutPaused的事件,而不是exercisePaused

像任何装饰器一样,@Output 装饰器只是为了提供 Angular 框架所需的元数据。真正的工作由 EventEmitter 类完成。

使用 EventEmitter 进行事件处理

Angular 支持基于事件的响应式编程(也被称为Rx风格的编程)来支持异步操作。如果您第一次听到这个词,或者对响应式编程不是很了解,那么您并不孤单。

响应式编程是关于针对异步数据流进行编程。这样的流实际上是根据它们发生的时间顺序排列的一系列正在进行的事件。我们可以想象流就像是生成数据的管道(以某种方式),并将其推送给一个或多个订阅者。由于这些事件被订阅者异步捕获,它们被称为异步数据流。

数据可以是任何内容,从浏览器/DOM 元素事件到用户输入再到使用 AJAX 加载远程数据。使用 Rx 风格,我们统一消耗这些数据。

在 Rx 世界中,有观察者和可观察对象,这是从非常流行的观察者设计模式派生出来的概念。可观察对象是发出数据的流。另一方面,观察者订阅这些事件。

在 Angular 中,EventEmitter 类主要负责提供事件支持。它既充当观察者又充当可观察对象。我们可以在其上触发事件,它也可以监听事件。

EventEmitter上有两个我们感兴趣的函数可用:

  • emit:顾名思义,使用这个函数来引发事件。它接受一个事件数据作为参数。emit是可观察端

  • subscribe:使用这个函数来订阅EventEmitter引发的事件。subscribe是观察者端。

让我们进行一些事件发布和订阅,以了解前面的函数是如何工作的。

从 WorkoutRunnerComponent 中引发事件

看一下EventEmitter的声明。这些已经用type参数声明了。EventEmitter上的type参数表示所发出数据的类型。

让我们从文件顶部开始,逐步向下,在workout-runner.component.ts中添加事件实现。

start函数的末尾添加这个语句:

this.workoutStarted.emit(this.workoutPlan);

我们使用EventEmitteremit函数来引发一个带有当前锻炼计划参数的workoutStarted事件。

要暂停,将这行代码添加到引发exercisePaused事件的函数中:

this.exercisePaused.emit(this.currentExerciseIndex); 

要恢复,添加以下行:

this.exerciseResumed.emit(this.currentExerciseIndex); 

每次触发exercisePausedexerciseResumed事件时,我们将当前练习索引作为参数传递给emit

startExerciseTimeTracking函数中,在调用startExercise之后添加上面的代码:

this.startExercise(next); 
this.exerciseChanged.emit(new ExerciseChangedEvent(next, this.getNextExercise()));

传递的参数包含即将开始的练习(next)和下一个练习(this.getNextExercise())。

在同一个函数中,添加上面突出显示的代码:

this.tracker.endTracking(true); 
this.workoutComplete.emit(this.workoutPlan); 
this.router.navigate(['finish']); 

当锻炼完成时,触发该事件。

在同一个函数中,我们会触发一个事件来传达锻炼的进度。添加这个语句:

--this.workoutTimeRemaining; 
this.exerciseProgress.emit(new ExerciseProgressEvent( this.currentExercise, this.exerciseRunningDuration, this.currentExercise.duration -this.exerciseRunningDuration, this.workoutTimeRemaining));

这完成了我们的事件实现。

你可能已经猜到了,WorkoutAudioComponent现在需要消耗这些事件。这里的挑战是如何组织这些组件,以便它们能以最小的依赖关系互相通信。

组件通信模式

目前的实现中,我们有:

  • 一个基本的WorkoutAudioComponent实现

  • 通过公开锻炼生命周期事件扩展了WorkoutRunnerComponent

这两个组件现在只需要互相交流。

如果父组件需要与其子组件通信,可以通过以下方式完成:

  • 属性绑定:父组件可以在子组件上建立属性绑定,向子组件推送数据。例如,当锻炼暂停时,这个属性绑定可以停止音频播放器:
        <workout-audio [stopped]="workoutPaused"></workout-audio>

属性绑定,在这种情况下,完全正常。当锻炼暂停时,音频也会停止。但并非所有情况都可以使用属性绑定来处理。播放下一个练习音频或中途音频需要更多控制。

  • 调用子组件的函数:如果父组件可以获取到子组件,那么父组件也可以调用子组件的函数。我们已经看到了如何在WorkoutAudioComponent的实现中使用@ViewChild@ViewChildren装饰器来实现这一点。这种方法及其缺点也已经在集成 WorkoutAudioComponent部分中简要讨论过。

还有一种不太好的选项。而不是父引用子组件,子引用父组件。这允许子组件调用父组件的公共函数或订阅父组件的事件。

我们将尝试这种方法,然后放弃实现一个更好的方案!从我们计划实施的不太优化的解决方案中可以得到很多学习。

将父组件注入到子组件中

在最后一个闭合的div之前将WorkoutAudioComponent添加到WorkoutRunnerComponent的视图中:

 <abe-workout-audio></abe-workout-audio> 

接下来,将WorkoutRunnerComponent注入到WorkoutAudioComponent中。打开workout-audio.component.ts并添加以下声明并更新构造函数:

private subscriptions: Array<any>; 

constructor( @Inject(forwardRef(() => WorkoutRunnerComponent)) 
    private runner: WorkoutRunnerComponent) { 
    this.subscriptions = [ 
      this.runner.exercisePaused.subscribe((exercise: ExercisePlan) => 
          this.stop()), 
      this.runner.workoutComplete.subscribe((exercise: ExercisePlan) => 
          this.stop()), 
      this.runner.exerciseResumed.subscribe((exercise: ExercisePlan) => 
          this.resume()), 
      this.runner.exerciseProgress.subscribe((progress: ExerciseProgressEvent) => 
          this.onExerciseProgress(progress)),

      this.runner.exerciseChanged.subscribe((state: ExerciseChangedEvent) =>  
          this.onExerciseChanged(state))]; 
    } 

记得添加这些导入:

    import {Component, ViewChild, Inject, forwardRef} from '@angular/core'; 
    import {WorkoutRunnerComponent} from '../workout-runner.component'  

在运行应用程序之前,让我们试着理解我们之前做过的事情。在构造注入中涉及一些诡计。如果我们直接尝试将WorkoutRunnerComponent注入到WorkoutAudioComponent中,Angular 会抱怨找不到所有的依赖关系。仔细阅读代码,并且认真思考;潜藏着一个微妙的循环依赖问题。WorkoutRunnerComponent已经依赖于WorkoutAudioComponent,因为我们在WorkoutRunnerComponent视图中引用了WorkoutAudioComponent。现在通过将WorkoutRunnerComponent注入到WorkoutAudioComponent中,我们创建了一个依赖循环。

循环依赖对于任何 DI 框架来说都是具有挑战性的。当创建具有循环依赖的组件时,框架必须以某种方式解决循环。在前面的例子中,我们使用@Inject装饰器并传递使用forwardRef()全局框架函数创建的令牌来解决循环依赖问题。

一旦正确进行了注入,在构造函数内部,我们使用EventEmittersubscribe函数为WorkoutRunnerComponent事件绑定处理程序。传递给subscribe的箭头函数在特定的事件参数发生时被调用。我们将所有的订阅收集到一个subscription数组中。当我们需要取消订阅时,这个数组会很有用,以避免内存泄漏。

关于EventEmitter的一点说明:EventEmmiter的订阅(subscribe函数)接受三个参数:

    subscribe(generatorOrNext?: any, error?: any, complete?: any) : any 
  • 第一个参数是一个回调函数,每当事件被触发时被调用

  • 第二个参数是一个错误回调函数,在可观察对象(生成事件的部分)发生错误时被调用

  • 最后一个参数接受一个回调函数,该函数在可观察对象完成发布事件时被调用。

我们已经做了足够的工作,使音频集成可以工作。运行应用程序并开始锻炼。除了滴答声音以外,所有的音频剪辑都在正确的时间播放。您可能需要等一段时间才能听到其他音频剪辑。问题在哪里?

然后,事实证明我们从未在锻炼开始时播放滴答声音。我们可以通过在ticks音频元素上设置autoplay属性或使用组件生命周期事件来触发滴答声音来修复它。我们采取第二种方法。

使用组件生命周期事件

注入到WorkoutAudioComponent中的MyAudioDirective在视图初始化之前是不可用的。

<audio #ticks="MyAudio" loop src="img/tick10s.mp3"></audio>
<audio #nextUp="MyAudio" src="img/nextup.mp3"></audio>
...

我们可以通过访问构造函数中的ticks变量来进行验证;它将为空。Angular 仍然没有进行魔法,我们需要等待WorkoutAudioComponent的子组件初始化。

组件的生命周期钩子现在可以帮助我们了。AfterViewInit事件钩子在组件视图初始化后调用,因此是从中访问组件的子指令/元素的安全位置。让我们快点做吧。

通过添加接口实现和必要的导入,更新WorkoutAudioComponent,如下所示:

import {..., AfterViewInit} from '@angular/core'; 
... 
export class WorkoutAudioComponent implements OnInit, AfterViewInit { 
    ngAfterViewInit() { 
          this.ticks.start(); 
    }

继续测试该应用程序。该应用程序已经具备了完整的音频反馈功能。很好!

尽管一切看起来都很美好,但是现在应用程序中存在一些内存泄漏。如果在锻炼过程中,我们从锻炼页面导航到开始或完成页面,然后再返回到锻炼页面,多个音频剪辑就会在随机时间播放。

似乎WorkoutRunnerComponent在路由导航时没有被销毁,因此包括WorkoutAudioComponent在内的所有子组件都没有被销毁。结果呢?每次我们导航到锻炼页面时都会创建一个新的WorkoutRunnerComponent,但在导航离开时却不会从内存中删除。

此内存泄漏的主要原因是我们在WorkoutAudioComponent中添加的事件处理程序。当音频组件卸载时,我们需要取消订阅这些事件,否则WorkoutRunnerComponent的引用将永远不会被解除引用。

另一个组件生命周期事件在这里为我们解决问题:OnDestroy。将此实现添加到WorkoutAudioComponent类中:

    ngOnDestroy() { 
      this.subscriptions.forEach((s) => s.unsubscribe()); 
    }

另外,记得像我们为AfterViewInit那样为OnDestroy事件接口添加引用。

希望我们在事件订阅期间创建的subscription数组现在有意义了。一次性取消订阅!

此音频集成现在已经完成。虽然这种方法不是集成这两个组件的办法,但我们可以做得更好。子组件引用父组件似乎是不可取的。

在继续之前,删除我们从将父组件注入子组件部分开始添加到workout-audio.component.ts的代码。

使用事件和模板变量进行兄弟组件交互

如果WorkoutRunnerComponentWorkoutAudioComponent组织为兄弟组件会怎样?

如果WorkoutAudioComponentWorkoutRunnerComponent变成兄弟组件,我们可以充分利用 Angular 的事件模板引用变量。 糊涂了吗? 好吧,首先,组件应该这样布局:

    <workout-runner></workout-runner> 
    <workout-audio></workout-audio> 

这有什么提示吗? 从这个模板开始,你可以猜到最终的 HTML 模板会长什么样吗? 在继续之前好好考虑一下。

仍然在挣扎? 一旦我们将它们设置为兄弟组件,Angular 模板引擎的强大之处就展现出来了。 以下模板代码足以集成WorkoutRunnerComponentWorkoutAudioComponent

<abe-workout-runner (exercisePaused)="wa.stop()" 
    (exerciseResumed)="wa.resume()" 
    (exerciseProgress)= "wa.onExerciseProgress($event)" 
    (exerciseChanged)= "wa.onExerciseChanged($event)" 
    (workoutComplete)="wa.stop()" 
    (workoutStarted)="wa.resume()"> 
</abe-workout-runner> 
<abe-workout-audio #wa></abe-workout-audio> 

WorkoutRunnerComponent模板变量wa通过在WorkoutRunnerComponent上的事件处理器表达式中引用该变量进行操纵。 相当优雅! 我们仍然需要解决这种方法中最大的难题:前面的代码放在哪里? 记住,WorkoutRunnerComponent作为路由加载的一部分加载。 在代码中,我们在没有这样的语句的地方:

    <workout-runner></workout-runner> 

我们需要重新组织组件树,并引入一个可以承载WorkoutRunnerComponentWorkoutAudioComponent的容器组件。 然后路由器加载此容器组件而不是WorkoutRunnerComponent。 让我们做吧。

通过导航到trainer/src/app/workout-runner并执行以下命令,从命令行生成新的组件代码:

ng generate component workout-container -is

复制描述事件的 HTML 代码到模板文件中。 运动容器组件已经准备好了。

我们只需要重构路由设置。 打开app-routing.module.ts。 更改运动员的路由并添加必要的导入:

import {WorkoutContainerComponent} 
 from './workout-runner/workout-container/workout-container.component'; 
..
{ path: '/workout', component: WorkoutContainerComponent },

我们已经有一个可以工作的音频集成,清晰、简洁,色彩搭配令人愉悦!

现在是时候结束本章了,但在解决前几节介绍的视频播放器对话框故障之前还不要结束。 当视频播放器对话框打开时,运动不会停止/暂停。

我们不会在这里详细说明修复的内容,并且敦促读者在不查阅checkpoint3.4代码的情况下尝试一下。

这里有一个明显的提示。 使用事件基础设施!

还有一个:从VideoPlayerComponent触发事件,每个事件对应一个开始和结束的播放。

最后一个提示:对话框服务(Modal)上的open函数返回一个 promise,在对话框关闭时解析。

如果您在运行代码时遇到问题,请查看到目前为止我们所做的工作的工作版本在 checkpoint3.4 Git 分支中。或者,如果您没有使用 Git,请从 bit.ly/ng6be-checkpoint-3-4 下载 checkpoint3.4 的快照(ZIP 文件)。第一次设置快照时,请参考 trainer 文件夹中的 README.md 文件。

总结

我们开始本章的目的是创建一个复杂的 Angular 应用程序。 7 分钟健身 应用程序符合要求,在构建这个应用程序的过程中,你学到了很多关于 Angular 框架的知识。

为了构建应用程序,我们首先定义了应用程序的模型。一旦模型就位,我们开始通过构建一个 Angular 组件 来执行实际的实现。Angular 组件只不过是用特定于框架的装饰器 @Component 装饰的类。

我们还学习了关于 Angular 模块 以及 Angular 如何使用它们来组织代码构件。

一旦我们有了完全功能的组件,我们为应用程序创建了一个支持视图。我们还探究了框架的数据绑定能力,包括 属性属性样式事件绑定。此外,我们强调了 插值 是属性绑定的一个特例。

组件是具有附加视图的指令的特殊类别。我们简要介绍了指令是什么以及指令的特殊类别,包括 属性结构性指令

我们学习了如何使用 输入属性 进行跨组件通信。我们组合的两个子组件(ExerciseDescriptionComponentVideoPlayerComponent)使用输入属性从父组件 WorkoutRunnerComponent 获取它们的输入。

然后,我们介绍了 Angular 中的另一个核心构件,管道。我们看到了如何使用管道,比如日期管道,以及如何创建我们自己的管道。

在整个章节中,我们接触了许多 Angular 指令,包括以下内容:

  • ngClass/ngStyle: 用于使用 Angular 绑定能力应用多个样式和类

  • ngFor: 用于使用循环结构生成动态 HTML 内容

  • ngIf: 用于有条件地创建/销毁 DOM 元素

  • ngSwitch: 用于使用 switch-case 结构创建/销毁 DOM 元素

我们现在有一个基本的 7 分钟健身 应用程序。为了提供更好的用户体验,我们还添加了一些小的增强功能,但我们仍然缺少一些使我们的应用程序更易用的功能。 从框架的角度来看,我们有意忽略了一些核心/高级概念,如 变更检测依赖注入组件路由 和数据流模式。

最后,我们接触了一个重要的话题:跨组件通信,主要使用 Angular 事件。我们详细介绍了如何使用 @Output 装饰器和 EventEmitter 创建自定义事件。

在本章中,我们提到的 @ViewChild@ViewChildren 装饰器帮助我们理解了父组件如何获取子组件并进行使用。Angular DI 还允许将父组件注入到子组件中。

我们通过构建一个 WorkoutAudioComponent 来结束了本章,并突出了如何使用 Angular 事件和模板变量进行兄弟组件通信。

接下来呢?我们将构建一个新的应用,个人健身教练。这个应用将允许我们构建自定义的训练。一旦我们能够创建自己的训练,我们将把 7 分钟训练 应用程序改造成通用的 训练运行器 应用程序,可以运行我们使用 个人健身教练 构建的训练。

对于下一章,我们将展示 Angular 的表单功能,同时构建一个允许我们创建、更新和查看自定义训练/练习的 UI。

第二章:个人教练

7 分钟健身 应用程序为我们学习 Angular 提供了绝佳的机会。通过使用该应用程序,我们已经涵盖了许多 Angular 构造。尽管如此,仍然有一些领域,比如 Angular 表单支持和客户端-服务器通信,尚未被探索。这在一定程度上是因为从功能角度来看,7 分钟健身对最终用户的接触点有限。交互仅限于启动、停止和暂停健身。此外,该应用程序既不消费也不生成任何数据(除了健身历史记录)。

在本章中,我们计划更深入地研究前面提到的两个领域之一,即 Angular 表单支持。为了跟上健康和健身主题(无恶意),我们计划构建一个 个人教练 应用程序。新的应用程序将是 7 分钟健身 的延伸,使我们能够构建属于自己的定制健身计划,而不仅仅局限于我们已经拥有的 7 分钟健身 计划。

本章致力于理解 Angular 表单以及如何在构建 个人教练 应用程序时将其应用。

本章我们将涵盖的主题包括以下内容:

  • 定义个人教练需求:因为我们在本章构建一个新应用程序,所以我们从定义应用程序需求开始。

  • 定义个人教练模型:任何应用程序设计都始于定义其模型。我们定义了 个人教练 的模型,它与之前构建的 7 分钟健身 应用程序类似。

  • 定义个人教练布局和导航:我们为新应用程序定义布局、导航模式和视图。我们还设置了与 Angular 路由和主视图集成的导航系统。

  • 添加支持页面:在我们专注于表单功能并构建健身组件之前,我们要构建一些用于健身和锻炼列表的支持组件。

  • 定义健身构建器组件结构:我们布置了健身构建器组件,我们将用它来管理健身。

  • 构建表单:我们广泛使用 HTML 表单和输入元素来创建自定义健身计划。在这个过程中,我们将了解更多关于 Angular 表单的概念。我们将覆盖的概念包括:

    • 表单类型:在 Angular 中可以构建的两种表单类型分别是模板驱动型和响应式型。本章我们将使用这两种类型的表单。

    • ngModel:这为模板驱动表单提供了双向数据绑定,允许我们跟踪更改并验证表单输入。

    • 响应式表单控件:这些包括表单构建器、表单控件、表单组和表单数组。这些用于以编程方式构建表单。

    • 数据格式化:这些是允许我们对用户的反馈进行样式化的 CSS 类。

    • 输入验证:我们将了解 Angular 表单的验证能力。

个人教练需求

基于管理训练和练习的概念,这是我们的个人教练应用程序应该满足的一些要求:

  • 列出所有可用的训练的能力。

  • 创建和编辑训练的能力。在创建和编辑训练时,它应包括:

    • 为训练添加名称、标题、描述和休息时间等训练属性的能力

    • 为训练添加/移除多个练习的能力

    • 对训练中的练习进行排序的能力

    • 保存训练数据的能力

  • 列出所有可用的练习的能力。

  • 创建和编辑练习的能力。在创建和编辑练习时,它应包括:

    • 能够添加诸如名称、标题、描述和程序之类的练习属性

    • 为练习添加图片的能力

    • 为练习添加相关视频的能力

    • 为练习添加音频提示的能力

所有要求似乎都很简单明了,所以让我们先从应用程序的设计开始。依照惯例,我们首先需要考虑能够支持这些要求的模型。

开始使用个人教练的代码

首先,从 GitHub 仓库中的checkpoint4.1下载新个人教练应用程序的基本版本。

该代码可在 GitHub github.com/chandermani/angular6byexample 上下载。检查点在 GitHub 中作为分支实现。要下载的分支如下:GitHub Branch: checkpoint4.1。如果您未使用 Git,请从以下 GitHub 位置下载 Checkpoint 4.1 的快照(ZIP 文件):github.com/chandermani/angular6byexample/archive/checkpoint4.1.zip。首次设置快照时,请参考trainer文件夹中的README.md文件。

这个代码包含完整的7 分钟训练(训练运行器)应用程序。我们添加了一些内容来支持新的个人教练应用程序。一些相关的更新包括:

  • 添加新的WorkoutBuilder功能。此功能包含与个人教练相关的实现。

  • 更新应用程序的布局和样式。

  • trainer/src/appworkout-builder文件夹下为个人教练添加一些组件和带有占位内容的 HTML 模板。

  • 定义到WorkoutBuilder功能的新路由。我们将在接下来的部分介绍如何在应用程序中设置这个路由。

  • 正如我们刚才提到的,将现有的model.ts文件移动到core文件夹中。

让我们讨论一下我们将如何使用这个模型。

在 Workout Builder 服务中使用个人教练模型

服务对于在控制器和其他 Angular 结构之间共享数据非常有用。打开位于 appcore 文件夹中的 model.ts 文件。在这个类中,实际上我们并没有任何数据,而是描述了数据的形状。我们计划使用服务来公开这个模型结构。 在 Workout Runner 中,我们已经做到了这一点。现在,我们将在 Workout Builder 中做同样的事情。

model.ts 文件已经移动到 core 文件夹中,因为它在健身计划生成器健身计划执行器应用程序之间共享。注意:在 checkpoint4.1 中,我们已经更新了 workout-runner.component.tsworkout-audio.component.tsworkout-history-tracker-service.ts 中的导入语句,以反映这一变化。

在第一章 建立我们的第一个应用程序 - 7 分钟锻炼 中,我们回顾了模型文件中的类定义:Exercise, ExercisePlan, 和 WorkoutPlan 正如我们之前提到的,这三个类构成了我们的基础模型。我们现在将开始在我们的新应用程序中使用这个基础模型。

这就是模型设计的全部内容。我们接下来要做的是定义新应用程序的结构。

个人健身教练布局

个人健身教练 的骨架结构���下所示:

这里有以下组件:

  • 顶部导航:这包含应用程序品牌标题和历史链接。

  • 子导航:这里有导航元素,根据活动组件的变化而变化。

  • 左侧导航:这包含依赖于活动组件的元素。

  • 内容区域:这是我们组件的主视图显示的地方。这里发生了大部分的操作。我们将在这里创建/编辑练习和健身计划,并在这里显示练习和健身计划列表。

看看源代码文件;在 trainer/src/app 下有一个新的 workout-builder 文件夹。它具有我们先前描述的每个组件的文件,还有一些占位符内容。在本章中我们将按照这一过程逐步构建这些组件。

但是,我们首先需要在应用程序中连接这些组件。这要求我们定义健身计划生成器应用程序的导航模式,并相应地定义应用程序路由。

个人健身教练导航及路线

我们打算在应用程序中使用的导航模式是列表-详细信息模式。我们将为应用程序中可用的练习和健身计划创建列表页面。单击任何列表项将带我们到该项目的详细视图,我们可以在这里执行所有 CRUD 操作(创建/读取/更新/删除)。以下路线遵循这一模式:

路线 描述
/builder 这只是重定向到 builder/workouts
/builder/workouts 这列出了所有可用的健身计划。这是健身计划生成器的登陆页面
/builder/workout/new 这将创建一个新的健身计划
/builder/workout/:id 这将编辑具有特定 ID 的现有健身计划
/builder/exercises 这列出所有可用的练习
/builder/exercise/new 这创建一个新的练习
/builder/exercise/:id 这将使用特定 ID 编辑现有练习

开始使用个人健身教练导航

此时,如果您查看app-routing.module.ts中的路由配置,您将在src/app文件夹中找到一个新的路由定义,builder

const routes: Routes = [
    ...
    { path: 'builder', component: WorkoutBuilderComponent },
    ...
];

如果您运行应用程序,您会发现起始屏幕显示另一个链接,创建一个锻炼:

图片

幕后,我们已将另一个路由链接添加到start.component.html中的此链接中:

<a routerLink="/builder" class="btn btn-primary btn-lg btn-block" role="button" aria-pressed="true">
   <span>Create a Workout</span>
   <span class="ion-md-add"></span>
</a>

如果你点击这个链接,你将进入以下视图:

图片

同样,在幕后,我们已将workout-builder.component.ts添加到trainer/src/app/workout-builder文件夹,并包含以下内联模板:

  template: `
    <div class="row">
      <div class="col-sm-3"></div>
      <div class="col-sm-6">
          <h1 class="text-center">Workout Builder</h1>
      </div>
      <div class="col-sm-3"></div>
    </div>
  `

并且这个视图在屏幕上显示在我们的app.component.html模板中使用的路由器插座下的标题下:

<div class="container body-content app-container"> 
    <router-outlet></router-outlet> 
</div>` 

我们已将此组件(以及我们为此功能生成的其他文件)包装在名为workout-builder.module.ts的新模块中:

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

import { WorkoutBuilderComponent } from './workout-builder.component';
import { ExerciseComponent } from './exercise/exercise.component';
import { ExercisesComponent } from './exercises/exercises.component';
import { WorkoutComponent } from './workout/workout.component';
import { WorkoutsComponent } from './workouts/workouts.component';

@NgModule({
  imports: [
    CommonModule
  ],
  declarations: [WorkoutBuilderComponent, ExerciseComponent, ExercisesComponent, WorkoutComponent, WorkoutsComponent]
})
export class WorkoutBuilderModule { }

这里可能与我们创建的其他模块有所不同的是,我们导入的是CommonModule而不是BrowserModule。这样避免了第二次导入整个BrowserModule,这会在实现此模块的延迟加载时产生错误。

最后,我们已向app.module.ts添加了此模块的导入:

  ... 
@NgModule({ 
  imports: [ 
  ... 
 WorkoutBuilderModule], 
  ... 

所以,没有什么新奇的地方。根据这些模式,我们现在应该开始考虑为我们的新功能添加先前概述的附加导航。然而,在我们着手进行之前,有一些事情我们需要考虑。

首先,如果我们开始将我们的路由添加到app.routing-module.ts文件中,那么存储在那里的路由数量将增加。这些用于Workout Builder的新路由也将与Workout Runner的路由混合在一起。虽然我们现在添加的路由数量可能看起来微不足道,但随着时间的推移,这可能成为一个维护问题。

其次,我们需要考虑到我们的应用程序现在由两个功能组成——Workout RunnerWorkout Builder。我们应该考虑如何在应用程序中分离这些功能,以便它们可以独立开发。

换句话说,我们希望我们构建的功能之间具有松耦合。使用这种模式可以让我们在应用程序中替换功能而不影响其他功能。例如,远期,我们可能希望将Workout Runner转换为移动应用程序,但保留Workout Builder作为基于 Web 的应用程序。

这种分离我们组件的能力是 Angular 实现的组件设计模式的重要优势之一。幸运的是,Angular 的路由器为我们提供了将我们的路由分离成逻辑上组织良好的路由配置的能力,这些路由配置与我们应用程序的特性紧密匹配。

为了实现这种分离,Angular 允许我们使用子路由,在这里我们可以隔离每个功能的路由。在本章中,我们将使用子路由来分离Workout Builder的路由。

介绍为 Workout Builder 添加子路由

Angular 支持我们隔离新Workout Builder的路由的目标,它为我们提供了在我们应用程序中创建路由器组件层次结构的能力。目前,我们只有一个路由器组件,它位于我们应用程序的根组件中。但是 Angular 允许我们在根组件下添加所谓的子路由器组件。这意味着一个功能可以对另一个功能使用的路由一无所知,每个功能都可以自由地根据该功能内部的更改来调整其路由。

返回我们的应用程序,我们可以在 Angular 中使用子路由来匹配我们应用程序的两个功能的路由与将要使用它们的代码。因此,在我们的应用程序中,我们可以将路由结构化为以下路由层次结构,用于我们的Workout Builder(此时,我们将Workout Runner保持不变,以展示之前和之后的比较):

通过这种方法,我们可以通过特性进行逻辑分离,并使其更易于管理和维护。

那么,让我们开始为我们的应用程序添加子路由。

从本节开始,我们将添加到前面在本章中下载的代码中。如果您想查看下一节的完整代码,可以在 GitHub 仓库的 checkpoint 4.2 中下载。如果您想和我们一起为该部分编写代码,请确保在这个检查点中添加 trainer/src 文件夹中的 styles.css 中的更改,因为我们在这里不会讨论它们。还要确保从仓库的 trainer/src/app/workout-builder 文件夹中添加 exercise(s)、workout(s) 和 navigation 的文件。在这个阶段,这些只是存根文件,我们将在本章后面实现它们。但是,您需要这些存根文件来实现 Workout Builder 模块的导航。该代码可以在 GitHub 上下载:github.com/chandermani/angular6byexample。检查点在 GitHub 中作为分支实现。要下载的分支如下:GitHub 分支:checkpoint4.2。如果您不使用 Git,请从以下 GitHub 位置下载 Checkpoint 4.2 的快照(ZIP 文件):github.com/chandermani/angular6byexample/archive/checkpoint4.2.zip。在第一次设置快照时,请参考 trainer 文件夹中的 README.md 文件。

添加子路由组件

workout-builder 目录中,添加一个名为 workout-builder.routing.module.ts 的新的 TypeScript 文件,并加入以下引用:

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { WorkoutBuilderComponent } from './workout-builder.component';
import { WorkoutsComponent } from './workouts/workouts.component';
import { WorkoutComponent } from './workout/workout.component';
import { ExercisesComponent } from './exercises/exercises.component';
import { ExerciseComponent } from './exercise/exercise.component';

正如您所见,我们正在引入刚提到的组件;它们将成为我们的 Workout Builder(exercise, exercises, workout 和 workouts)的一部分。除了这些引用外,我们还从 Angular 核心模块中引入 NgModule,以及从 Angular 路由器模块中引入 RoutesRouterModule。这些引用将使我们能够添加和导出子路由。

我们没有在这里使用 Angular CLI,因为它没有一个单独的蓝图用于创建路由模块。然而,您可以在创建模块时使用 --routing 选项让 CLI 创建一个路由模块。在这种情况下,我们已经有一个现有的模块创建,所以我们无法使用该标志。查看如下链接以了解更多关于如何实现这一点的细节:github.com/angular/angular-cli/blob/master/docs/documentation/stories/routing.md

然后,在文件中添加以下路由配置:

const routes: Routes = [
  {
    path: 'builder',
    component: WorkoutBuilderComponent,
    children: [
         {path: '', pathMatch: 'full', redirectTo: 'workouts'},
         {path: 'workouts', component: WorkoutsComponent },
         {path: 'workout/new', component: WorkoutComponent },
         {path: 'workout/:id', component: WorkoutComponent },
         {path: 'exercises', component: ExercisesComponent},
         {path: 'exercise/new', component: ExerciseComponent },
         {path: 'exercise/:id', component: ExerciseComponent }
    ]
  },
];

第一个配置path: 'builder'设置了子路由的基本 URL,以便每个子路由都会将其作为前缀。下一个配置将WorkoutBuilder组件标识为此文件中子组件的特色区域根组件。这意味着它将是每个子组件使用router-outlet显示的组件。最后的配置是一个或多个子级,定义了子组件的路由。

这里需要注意的一点是,我们已经设置Workouts作为默认的子路由,配置如下:

{path:'', pathMatch: 'full', redirectTo: 'workouts'}, 

这个配置表示如果有人导航到builder,它们将被重定向到builder/workouts路由。pathMatch: 'full'设置意味着只有当 workout/builder 之后的路径为空字符串时才会进行匹配。这可以防止如果路由是其他内容,比如workout/builder/exercises或者在这个文件中配置的其他路由,那么重定向就不会发生。

最后,添加以下类声明,前面有一个@NgModule装饰器,定义了我们模块的导入和导出:

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

这个导入与app.routing-module.ts中的导入非常相似,只有一个区别 - 我们使用的是RouterModule.forChild,而不是RouterModule.forRoot。这种差异的原因似乎是不言自明的:我们创建子路由,而不是应用程序根目录中的路由,这就是我们表示的方式。然而,在底层,有一个显著的区别。这是因为我们的应用程序中不能有多个路由器服务处于激活状态。forRoot创建路由器服务,但forChild不会。

更新 WorkoutBuilder 组件

我们接下来需要更新WorkoutBuilder组件以支持我们的新子路由。为此,将 Workout Builder 的@Component装饰器改为:

  1. 移除selector

  2. 在模板中添加一个<abe-sub-nav-main>自定义元素

  3. 在模板中添加一个<router-outlet>标签

装饰器现在应该看起来像下面这样:

@Component({
  template: `<div class="container-fluid fixed-top mt-5">
                <div class="row mt-5">
                  <abe-sub-nav-main></abe-sub-nav-main>
                </div>
                <div class="row mt-2">
                  <div class="col-sm-12">
                    <router-outlet></router-outlet>
                  </div>
                </div>
            <div>`
})

我们移除选择器,因为WorkoutBuilderComponent不会被嵌入到应用程序根目录app.component.ts中。相反,它将通过路由从app.routing-module.ts中到达。虽然它将处理来自app.routes.ts的传入路由请求,但它将反过来将它们路由到 Workout Builder 特色中包含的其他组件。

这些组件将使用我们刚刚添加到WorkoutBuilder模板中的<router-outlet>标签来显示它们的视图。考虑到Workout BuilderComponent的模板将是简单的,我们使用内联template而不是templateUrl

通常情况下,我们建议为组件的视图使用templateUrl指向一个单独的 HTML 模板文件。特别是当你预期该视图会涉及超过几行 HTML 时。在这种情况下,更容易处理一个视图在其自己的 HTML 文件中。

我们还添加了一个<abe-sub-nav-main>元素,它将用于创建用于在Workout Builder功能内进行导航的二级顶级菜单。我们稍后在本章中会讨论这一点。

更新 Workout Builder 模块

现在,让我们更新WorkoutBuilderModule。首先,在文件中添加以下导入:

import { WorkoutBuilderRoutingModule } from './workout-builder-routing.module';

它导入了我们刚刚设置的子路由。接下来,更新@NgModule装饰器以添加workoutBuilderRoutingModule:

...
@NgModule({
  imports: [
    CommonModule,
    WorkoutBuilderRoutingModule
  ],
...
}) 

最后,添加新的导航组件的导入和声明,可以在checkpoint4.2中找到:

import { LeftNavExercisesComponent } from './navigation/left-nav-exercises.component';
import { LeftNavMainComponent } from './navigation/left-nav-main.component';
import { SubNavMainComponent } from './navigation/sub-nav-main.component';
...
  declarations: [
    ...
    LeftNavExercisesComponent,
    LeftNavMainComponent,
    SubNavMainComponent]

更新 App 路由模块

最后一步:返回app.routing-module.ts,删除WorkoutBuilderComponent的导入和指向构建器的路由定义:{ path: 'builder', component: WorkoutBuilderComponent },

确保不要改变在app.module.ts中的WorkoutBuilderModule的导入。当我们讨论懒加载时,我们会在下一节中讨论删除它。

将所有内容组合在一起

现在我们有包含子路由的区域或功能路由,并且与Workout Builder相关的所有路由都分别包含在它们自己的路由配置中。这意味着我们可以在WorkoutBuilderRoutes组件中管理Workout Builder的所有路由,而不影响应用程序的其他部分。

如果我们现在从开始页面导航到 Workout Builder,我们可以看到路由如何将app.routes.ts中的路由与workout-builder.routes.ts中的默认路由组合在一起:

如果我们在浏览器中查看 URL,它是/builder/workouts。你会记得开始页面上的路由链接是['/builder']。那么路由是如何带我们到这个位置的呢?

它是这样做的:当链接被点击时,Angular 路由器首先查找app-routing.module.ts中的builder路径,因为该文件包含了我们应用程序中根路由的配置。路由器没有找到该路径,因为我们已经从该文件的路由中删除了它。

但是,WorkoutBuilderModule已经被导入到AppModule中,而该模块又导入workoutBuilderRoutingModule。后者文件包含了我们刚刚配置的子路由。路由器发现builder是该文件中的父路由,因此它使用该路由。它还找到了默认设置,该设置将在builder路径以空字符串结尾时重定向到子路径workouts

如果您查看屏幕,您会看到它显示的是Workouts的视图(而不是以前的Workout Builder)。这意味着路由器已成功将请求路由到WorkoutsComponent,这是我们在workoutBuilderRoutingModule中设置的子路由配置中的默认路由的组件。

路由分辨过程如下所示:

对于子路由的最后一个想法。当您查看我们的子路由组件workout-builder.component.ts时,您会发现它没有引用其父组件app.component.ts<selector>标签已被删除,因此WorkoutBuilderComponent没有嵌入在根组件中)。这意味着我们已经成功地封装了WorkoutBuilderComponent(以及在WorkoutBuilderModule中导入的所有相关组件),这将使我们能够将其全部移到应用程序的其他位置,甚至是到一个新的应用程序中。

现在,是时候将我们的 Workout Builder 的路由转换为使用延迟加载,并构建其导航菜单了。如果您想查看此下一部分的已完成代码,您可以从checkpoint 4.3中的相关代码库中进行下载。同样,如果您正在跟着我们构建应用程序,请确保更新styles.css文件,这里我们不讨论。

代码也可以在 GitHub 上找到:github.com/chandermani/angular6byexample。检查点在 GitHub 中实现为分支。要下载的分支如下:GitHub 分支:checkpoint4.3(文件夹 - trainer)。如果您没有使用 Git,请从以下 GitHub 位置下载Checkpoint 4.3的快照(ZIP 文件):github.com/chandermani/angular6byexample/archive/checkpoint4.3.zip。在首次设置快照时,请参考trainer文件夹中的README.md文件。

路由的延迟加载

当我们推出我们的应用程序时,我们期望我们的用户每天都会访问 Workout Runner(我们知道这对你来说是必须的!)。但是,我们预计他们只会偶尔使用 Workout Builder 来构建他们的练习和锻炼计划。因此,如果我们能够避免在用户只是在 Workout Runner 中做练习时加载 Workout Builder 的开销,那将是很好的。相反,我们更希望在用户想要添加或更新他们的练习和锻炼计划时,仅在需要时加载 Workout Builder。这种方法称为延迟加载。延迟加载允许我们在加载模块时采用异步方法。这意味着我们可以仅加载启动应用程序所需的内容,然后在需要时加载其他模块。

在幕后,当我们使用 Angular CLI 构建和提供我们的应用程序时,它使用了 WebPack 的捆绑和分块功能来实现惰性加载。在我们实现应用程序中的惰性加载时,我们将讨论这些功能。

因此,在我们的 个人健身教练 中,我们希望更改应用程序,以便只在需要时才加载Workout Builder。而 Angular 路由器允许我们仅仅用惰性加载来实现这一点。

但在开始实现惰性加载之前,让我们来看看我们当前的应用程序以及它如何加载我们的模块。在开发者工具中的"来源"选项卡打开后,启动应用程序;当你的浏览器中出现起始页面时,如果你在源树中查看 web pack 节点下,你将看到应用程序中加载的所有文件,包括 Workout RunnerWorkout Builder 文件:

因此,即使我们可能只想使用 Workout Runner,我们也必须加载 Workout Builder。在某种程度上,如果你将我们的应用程序视为单页面应用程序SPA),这是有道理的。为了避免与服务器的往返,SPA 通常会在用户首次启动应用程序时加载所有需要使用应用程序的资源。但就我们来说,重要的一点是,当应用程序首次加载时,我们不需要 Workout Builder。相反,我们希望只有在用户决定添加或更改训练或练习时才加载这些资源。

所以,让我们开始做到这一点。

首先,修改 app.routing-module.ts 以添加以下路由配置用于 WorkoutBuilderModule

const routes: Routes = [
    ...
    { path: 'builder', loadChildren: './workout-builder/workout-builder.module#WorkoutBuilderModule'},
    { path: '**', redirectTo: '/start' }
];

注意 loadChildren 属性是:

module file path + # + module name 

该配置提供了加载和实例化 WorkoutBuilderModule 所需的信息。

接下来回到 workout-builder-routing.module.ts 并将 path 属性更改为空字符串:

export const Routes: Routes = [ 
    { 
 path: '', 
. . . 
    } 
]; 

我们进行此更改是因为现在我们将 pathbuilder)设置为 app.routing-module.ts 中新增的对应的 WorkoutBuilderRoutes 的新配置中。

最后,回到 app-module.ts ,并在该文件中的 @NgModule 配置中移除 WorkoutBuilderModule 的导入。这意味着,与其在应用程序首次启动时加载Workout Builder功能,我们只在用户访问Workout Builder路由时加载它。

让我们用 ng serve 再次构建和运行应用程序。在终端窗口中,你应该看到类似如下的输出:

这里有趣的地方在于最后一行显示了一个名为workout.builder.module的单独文件,名为workout-builder.module.chunk.js.WebPack使用了所谓的代码拆分来将我们的锻炼构建模块划分为一个单独的块。该块在需要时才会在我们的应用程序中加载(即路由器导航到WorkoutBuilderModule时)。

现在,在 Chrome 开发者工具中保持 Sources 标签页打开,再次在浏览器中打开应用程序。当起始页面加载时,只有与Workout Runner相关的文件出现,而与Workout Builder相关的文件却没有出现,如下图所示:

然后,如果我们清除网络标签页并点击“创建锻炼”链接,我们将看到加载workout-builder.module块:

这意味着我们已经实现了我们新功能的封装,并且通过异步路由,我们能够使用惰性加载在需要时加载所有其组件。

子级和异步路由使得实现允许我们应用程序在客户端具有强大导航的同时,还可以将功能封装在单独的子路由组件中,并且只在需要时加载它们变得简单。

Angular 路由器的这种强大和灵活性赋予我们能力,通过密切映射应用程序的行为和响应性来满足用户的期望。在这种情况下,我们利用了这些功能来实现我们的目标:立即加载 Workout Runner,这样我们的用户就可以立即开始锻炼,但避免加载Workout Builder的开销,而是在用户想要构建锻炼时才加载它。

现在我们已经在Workout Builder中设置好了路由配置,我们将把注意力转向构建子级和左侧导航;这将使我们能够使用此路由。接下来的部分将介绍实现此导航。

集成子级和侧边级导航

将子级和侧面级导航集成到应用程序的基本理念是为基于活动视图变化而变化的上下文感知子视图提供支持。例如,当我们在列表页面而不是编辑项目时,我们可能希望在导航中显示不同的元素。一个很好的例子是电子商务网站。想象亚马逊的搜索结果页面和产品详情页面。随着上下文从产品列表变为特定产品,加载的导航元素也会发生变化。

子级导航

我们将首先在Workout Builder中添加子级导航。我们已经将SubNavMainComponent导入到Workout Builder中。但是,目前它只显示占位内容:

现在,我们将用三个路由链接替换该内容:首页、新锻炼和新练习。

打开sub-nav-main.component.html文件,并将其 HTML 更改为以下内容:

<nav class="navbar fixed-top navbar-dark bg-primary mt-5">
    <div>
        <a [routerLink]="['/builder/workouts']" class="btn btn-primary">
        <span class="ion-md-home"></span> Home
        </a>
        <a [routerLink]="['/builder/workout/new']" class="btn btn-primary">
        <span class="ion-md-add"></span> New Workout
        </a>
        <a [routerLink]="['/builder/exercise/new']" class="btn btn-primary">
        <span class="ion-md-add"></span> New Exercise
        </a>
    </div>
</nav>

现在,重新运行应用程序,你将会看到三个导航链接。如果我们点击“新练习”链接按钮,我们将被路由到ExerciseComponent,并且其视图将会出现在Workout Builder视图的路由器出口中。

新锻炼链接按钮的工作方式类似;点击时,它将把用户带到WorkoutComponent并在路由器出口中显示其视图。点击首页链接按钮将返回用户到WorkoutsComponent和视图。

侧边导航

Workout Builder内的侧边导航将根据我们导航到的子组件而异。例如,当我们首次导航到Workout Builder时,我们被带到了锻炼屏幕,因为WorkoutsComponent路由是Workout Builder的默认路由。该组件将需要侧边导航;它将允许我们选择查看锻炼列表或练习列表。

Angular 的基于组件的特性为我们实现这些上下文敏感菜单提供了一种简单的方法。我们可以为每个菜单定义新的组件,然后将其导入到需要它们的组件中。在这种情况下,我们有三个需要侧边菜单的组件:锻炼练习锻炼。前两个组件实际上可以使用相同的菜单,所以我们实际上只需要两个侧边菜单组件:LeftNavMainComponent,它将类似于前面的菜单,并将被ExercisesWorkouts组件使用,以及LeftNavExercisesComponent,它将包含现有练习列表,并将被Workouts组件使用。

我们已经有了两个菜单组件的文件,包括模板文件,并已将它们导入到WorkoutBuilderModule。我们现在将把它们整合到需要它们的组件中。

首先,修改workouts.component.html模板以添加菜单的选择器:

<div class="row">
    <div>
        <abe-left-nav-main></abe-left-nav-main>
    </div>
    <div class="col-sm-10 builder-content">
        <h1 class="text-center">Workouts</h1>
    </div>
  </div>

然后,在left-nav-main.component.html中用导航链接替换占位符文本到WorkoutsComponentExercisesComponent

<div class="left-nav-bar">
    <div class="list-group">
        <a [routerLink]="['/builder/workouts']" class="list-group-item list-group-item-action">Workouts</a>
        <a [routerLink]="['/builder/exercises']" class="list-group-item list-group-item-action">Exercises</a>
    </div>
</div>

运行应用程序,你应该看到以下内容:

遵循完全相同的步骤来完成Exercises组件的侧边菜单。

我们不会在这里展示菜单的代码,但你可以在 GitHub 存储库的checkpoint 4.3trainer/src/app下的workout-builder/exercises文件夹中找到它。

对于锻炼屏幕的菜单,步骤相同,只是你应该将left-nav-exercises.component.html更改为以下内容:

<div class="left-nav-bar">
  <h3>Exercises</h3>
</div> 

我们将使用这个模板作为构建出现在屏幕左侧的锻炼列表的起点,并且可以选择加入到锻炼中的起点。

实施运动和练习列表

即使在我们开始实现运动和练习列表页面之前,我们需要一个用于练习和运动数据的数据存储。当前计划是使用内存数据存储并使用 Angular 服务公开它。在第三章中,支持服务器数据持久化中,我们会将这些数据移到服务器存储以进行长期持久化。目前,内存存储就足够了。让我们添加存储实现。

WorkoutService 作为运动和练习库

在这里的计划是创建一个WorkoutService实例,负责在两个应用程序之间公开运动和锻炼数据。服务的主要职责包括:

  • 与练习相关的 CRUD 操作:获取所有练习,根据名称获取特定练习,创建练习,更新练习并删除它

  • 与锻炼相关的 CRUD 操作:这些操作与与运动相关的操作类似,但针对的是运动实体

代码可在 GitHub 上下载,链接为github.com/chandermani/angular6byexample。要下载的分支如下:GitHub 分支:checkpoint4.4(文件夹trainer)。如果您不使用 Git,请从以下 GitHub 位置下载Checkpoint 4.4的快照(ZIP 文件):github.com/chandermani/angular6byexample/archive/checkpoint4.4.zip。在首次设置快照时,请参考trainer文件夹中的README.md文件。再次提醒,如果您跟随我们构建应用程序,请确保更新styles.css文件,这里我们不讨论。由于本节中的一些文件相当长,而不是在这里显示代码,我们有时还会建议您简单地将文件复制到您的解决方案中。

trainer/src/core文件夹中找到workout-service.ts。该文件中的代码应该如下所示,除了我们由于长度而留出的两个方法setupInitialExercisessetupInitialWorkouts的实现:

import {Injectable} from '@angular/core'; 
import {ExercisePlan} from './model'; 
import {WorkoutPlan} from './model'; 
import {Exercise} from "./model";
import { CoreModule } from './core.module'; 

@Injectable({
  providedIn: CoreModule
})
export class WorkoutService { 
    workouts: Array<WorkoutPlan> = []; 
    exercises: Array<Exercise> = []; 

    constructor() { 
        this.setupInitialExercises(); 
        this.setupInitialWorkouts(); 
    } 

    getExercises(){ 
        return this.exercises; 
    } 

    getWorkouts(){ 
        return this.workouts; 
    } 
    setupInitialExercises(){ 
     // implementation of in-memory store. 
    } 

    setupInitialWorkouts(){ 
     // implementation of in-memory store. 
    } 
}} 

正如我们之前提到的,Angular 服务的实现是直截了当的。在这里,我们声明了一个名为WorkoutService的类,并用@Injectable进行装饰。在@Injectable装饰器中,我们设置了provided-in属性为CoreModule。这样就把WorkoutService注册为 Angular 的依赖注入框架的提供者,并使其在整个应用程序中可用。

在类定义中,我们首先创建两个数组:一个用于Workouts,一个用于Exercises。这些数组分别属于WorkoutPlanExercise类型,因此我们需要从model.ts中导入WorkoutPlanExericse以获取它们的类型定义。

  • 构造函数调用两个方法来设置 Workouts 和 Services 列表。目前,我们只是使用一个内存存储来填充这些列表数据。

  • 两个方法,getExercisesgetWorkouts,顾名思义,分别返回练习和锻炼的列表。由于我们计划使用内存存储来存储锻炼和锻炼数据,WorkoutsExercises数组存储着这些数据。随着我们的进展,我们将向服务添加更多的功能。

  • 是时候为锻炼和练习列表构建组件了!

- 训练和锻炼列表组件

  • 首先,打开trainer/src/app/workout-builder/workouts文件夹中的workouts.component.ts文件,并按以下方式更新导入:
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';

import { WorkoutPlan } from '../../core/model';
import { WorkoutService } from '../../core/workout.service';; 
  • 这段新代码导入了 Angular 的Router,以及WorkoutServiceWorkoutPlan类型。

  • 接下来,用以下代码替换类定义:

export class WorkoutsComponent implements OnInit { 
    workoutList:Array<WorkoutPlan> = []; 

    constructor( 
        public router:Router, 
        public workoutService:WorkoutService) {} 

    ngOnInit() { 
        this.workoutList = this.workoutService.getWorkouts(); 
    } 

    onSelect(workout: WorkoutPlan) { 
        this.router.navigate( ['./builder/workout', workout.name] ); 
    } 
} 
  • 这段代码在构造函数中添加了RouterWorkoutService。然后,在ngOnInit方法中调用WorkoutServicegetWorkouts方法,并用从该方法调用返回的WorkoutPlans列表填充了一个workoutList数组。我们将使用workoutList数组来填充将在Workouts组件视图中显示的锻炼计划列表。

  • 你会注意到,我们将调用WorkoutService的代码放在了ngOnInit方法中。我们不想把这段代码放在构造函数中。最终,我们将把这个服务使用的内存存储替换为对外部数据存储的调用,我们不希望我们组件的实例化受到这个调用的影响。将这些方法调用添加到构造函数中也会使组件的测试变得更加复杂。

  • 为避免这种意外副作用,我们将代码放在ngOnInit方法中。这个方法实现了 Angular 的生命周期钩子之一,OnInit,Angular 在创建服务实例后调用这个方法。这样,我们依赖于 Angular 以一种可预测的方式调用这个方法,不会影响组件的实例化。

  • 接下来,我们将对Exercises组件进行几乎相同的更改。与Workouts组件一样,这段代码将锻炼服务注入到我们的组件中。这次,我们然后使用锻炼服务来检索练习。

  • 由于这与Workouts组件类似,我们这里不会显示代码。只需从检查点 4.4workout-builder/exercises文件夹中添加它。

- 锻炼和练习列表视图

  • 现在,我们需要实现到目前为止一直为空的列表视图!

  • 在本节中,我们将用检查点 4.4更新检查点 4.3的代码。所以,如果你正在跟着我们编码,只需按照本节中列出的步骤进行操作。如果你想看到完成的代码,那就把检查点 4.4的文件复制到你的解决方案中。

- 工作列表视图

  • 要使视图正常工作,打开workouts.component.html并添加以下标记:
<div class="row">
    <div>
        <abe-left-nav-main></abe-left-nav-main>
    </div>
    <div class="col-sm-10 builder-content">
        <h1 class="text-center">Workouts</h1>
        <div *ngFor="let workout of workoutList|orderBy:'title'" class="workout tile" (click)="onSelect(workout)">
          <div class="title">{{workout.title}}</div>
          <div class="stats">
              <span class="duration" title="Duration"><span class="ion-md-time"></span> - {{(workout.totalWorkoutDuration? workout.totalWorkoutDuration(): 0)|secondsToTime}}</span>
              <span class="float-right" title="Exercise Count"><span class="ion-md-list"></span> - {{workout.exercises.length}}</span>
          </div>
      </div>
    </div>
  </div>

我们正在使用 Angular 核心指令之一,ngFor,来循环遍历锻炼计划列表并在页面上以列表形式显示它们。我们在ngFor前面添加*号来标识它为 Angular 指令。使用let语句,我们将workout分配为一个本地变量,我们用它来遍历锻炼计划列表并识别每个锻炼计划要显示的值(例如workout.title)。然后,我们使用我们的自定义管道之一,orderBy,以按标题字母顺序显示锻炼计划列表。我们还使用另一个自定义管道,secondsToTime,来格式化显示总锻炼计划持续时间的时间。

如果您正在与我们一起编码,您将需要将secondsToTime管道移动到共享文件夹中,并将其包含在SharedModule中。然后,将SharedModule添加到WorkoutBuilderModule作为额外的导入。这个更改已经在 GitHub 存储库的checkpoint 4.4中进行了。

最后,我们将点击事件绑定到以下onSelect方法,我们将其添加到我们的组件中:

 onSelect(workout: WorkoutPlan) { 
     this.router.navigate( ['/builder/workout', workout.name] ); 
 }  

这将设置导航到锻炼计划详情页面。当我们点击锻炼计划列表中的项目时,导航将发生。所选锻炼计划名称作为路由/URL的一部分传递到锻炼计划详情页面。

请继续刷新构建器页面(/builder/workouts);显示一个锻炼计划,即 7 分钟锻炼。点击该锻炼计划的瓷砖。您将被带到锻炼屏幕,锻炼计划名称7MinWorkout将出现在 URL 末尾:

锻炼计划屏幕

锻炼计划列表视图

对于锻炼列表视图,我们将按照与锻炼计划列表视图相同的方式进行操作,只是在这种情况下,我们实际上将实现两个视图:一个用于锻炼组件(当用户导航到该组件时将显示在主内容区域中),另一个是用于LeftNavExercisesComponent锻炼上下文菜单(当用户导航到锻炼计划组件以创建或编辑锻炼计划时将显示)。

对于锻炼组件,我们将遵循几乎与在锻炼计划组件中显示锻炼计划列表相同的方法。所以,我们不会在这里显示那些代码。只需添加来自checkpoint 4.4exercises.component.tsexercises.component.html文件。

复制文件完成后,请点击左侧导航中的锻炼链接,以加载您在WorkoutService中已经配置的 12 个锻炼计划。

针对锻炼计划列表,这将设置导航到锻炼计划详情页面。在锻炼计划列表中点击项目会带我们到锻炼计划详情页面。所选锻炼计划名称作为路由/URL的一部分传递到锻炼计划详情页面。

在最终列表视图中,我们将添加一个显示在锻炼计划生成器屏幕左侧上下文菜单中的锻炼列表。当我们创建或编辑锻炼计划时,该视图会加载在左侧导航中。使用 Angular 的基于组件的方法,我们将更新leftNavExercisesComponent及其相关视图以提供此功能。只需从trainer/src/app/navigation文件夹中checkpoint 4.4中添加left-nav-exercises.component.tsleft-nav-exercises.component.html文件。

复制完那些文件后,点击锻炼计划生成器的子导航菜单中的新建锻炼计划按钮,您将会在左侧导航菜单中看到一份现有配置在WorkoutService中的锻炼的列表。

是时候添加加载、保存和更新锻炼/锻炼计划数据的功能了!

构建锻炼计划

个人健身教练提供的核心功能主要集中在锻炼和锻炼计划的构建上。所有功能都旨在支持这两个功能。在这一部分,我们将重点放在使用 Angular 构建和编辑锻炼计划。

WorkoutPlan模型已经定义,因此我们知道构成锻炼计划的各个元素。锻炼计划生成器页面促进用户输入,并允许我们构建/保存锻炼计划数据。

完成后,锻炼计划生成器页面将如下所示:

页面左侧导航列出了可以添加到锻炼计划中的所有锻炼。点击右侧的箭头图标即可将锻炼添加到锻炃计划的末尾。

中间区域专门用于构建锻炼计划。它由按顺序排列的锻炼磁贴和一个表单组成,允许用户提供关于锻炼计划的其他详细信息,如名称、标题、描述和休息时间。

此页面操作有两种模式:

  • 创建/新建:此模式用于创建新的锻炼计划。网址为#/ builder/workout/new

  • 编辑:此模式用于编辑现有的锻炼计划。网址为#/ builder/workout/:id,其中的:id映射到锻炼计划的名称。

了解了页面元素和布局的情况后,现在是时候构建每个元素了。我们将首先从左侧导航开始。

完成左侧导航

在上一节的最后,我们更新了Workout组件的左导航视图,以显示锻炼列表。我们的意图是让用户点击锻炼旁边的箭头将其添加到锻炼计划中。当时,我们推迟了对绑定到该点击事件的LeftNavExercisesComponent中的addExercise方法的实现。现在,我们将继续实现这一步。

我们在这里有一些选择。LeftNavExercisesComponentWorkoutComponent的子组件,因此我们可以实现子/父组件间的通信来完成这一功能。我们在上一章中处理7 分钟锻炼时已经涵盖了这种技术。

然而,将锻炼添加到锻炼计划中是构建锻炼计划的更大过程的一部分,使用子/父级组件间通信会使AddExercise方法的实现与我们将要添加的其他功能有所不同。

为此,更合理的做法是采用另一种数据共享方法,这样我们可以在整个制定锻炼计划的过程中始终保持一致。这种方法涉及使用服务。当我们开始添加其他功能来创建实际的锻炼计划,比如保存/更新逻辑和实现其他相关组件时,选择使用服务的好处将变得越来越明显。

因此,我们在这种情况下引入了一个新的服务:WorkoutBuilderServiceWorkoutBuilderService服务的最终目标是在构建锻炼计划时协调WorkoutService(用于检索和持久化锻炼计划)和组件(如LeftNavExercisesComponent和其他我们稍后将添加的组件)之间的关系,从而将WorkoutComponent中的代码量减少到最低程度。

添加 WorkoutBuilderService

WorkoutBuilderService监视应用程序用户正在构建的锻炼计划的状态。它:

  • 跟踪当前的锻炼计划

  • 创建一个新的锻炼计划

  • 加载现有的锻炼计划

  • 保存锻炼计划

checkpoint 4.5中的trainer/src/appworkout-builder/builder-services文件夹下复制workout-builder-service.ts文件。

该代码也可供所有人在 GitHub 上下载,网址为github.com/chandermani/angular6byexample。在 GitHub 上,检查点作为分支进行了实现。要下载的分支如下:GitHub 分支:checkpoint4.5(文件夹—trainer)。如果你不使用 Git,可以从以下 GitHub 位置下载Checkpoint 4.5的快照(ZIP 文件):github.com/chandermani/angular6byexample/archive/checkpoint4.5.zip。在首次设置快照时,请参阅trainer文件夹中的README.md文件。再次强调,如果你跟着我们一起构建应用程序,请确保更新styles.css文件,这里我们不讨论。

虽然我们通常会使服务在整个应用程序中可用,但WorkoutBuilderService只会在Workout Builder功能中使用。因此,我们并没有在AppModule中的提供者中注册它,而是在WorkoutBuilderModule的提供者数组中注册如下(在文件顶部添加了导入后):

@NgModule({
....
  providers: [WorkoutBuilderService]
})

在这里将其作为提供者添加意味着只有在访问Workout Builder功能时才会加载它,外部无法访问。这意味着它可以独立于应用程序中的其他模块进行演化,并且可以在不影响应用程序其他部分的情况下进行修改。

让我们看一下服务的一些相关部分。

WorkoutBuilderService 需要 WorkoutPlanExercisePlanWorkoutService 的类型定义,因此我们将这些导入到组件中。

import { WorkoutPlan, ExercisePlan } from '../../core/model';
import { WorkoutService } from '../../core/workout.service';

WorkoutBuilderService 依赖于 WorkoutService 来提供持久性和查询功能。我们通过将 WorkoutService 注入到 WorkoutBuilderService 的构造函数中来解决这个依赖**:**

 constructor(public workoutService: WorkoutService) {}

WorkoutBuilderService 还需要跟踪正在构建的训练。我们使用 buildingWorkout 属性来进行跟踪。当我们在服务上调用 startBuilding 方法时,跟踪开始:

startBuilding(name: string){ 
    if(name){ 
        this.buildingWorkout = this.workoutService.getWorkout(name) 
        this.newWorkout = false; 
    }else{ 
        this.buildingWorkout = new WorkoutPlan("", "", 30, []); 
        this.newWorkout = true; 
    } 
    return this.buildingWorkout; 
} 

这个跟踪函数的基本思想是设置一个 WorkoutPlan 对象 (buildingWorkout),以便让组件可以操作训练细节。startBuilding 方法以训练名作为参数。如果没有提供名称,那么意味着我们正在创建一个新的训练,因此会创建并分配一个新的 WorkoutPlan 对象;如果有提供名称,则通过调用 WorkoutService.getWorkout(name) 加载训练细节。无论如何,buildingWorkout 对象都有正在进行的训练。

newWorkout 对象表示训练是新的还是已经存在。在调用该服务的 save 方法时,它用于区分保存和更新情况。

其余的方法 removeExerciseaddExercisemoveExerciseTo 是不言自明的,并影响训练的练习列表 (buildingWorkout)。

WorkoutBuilderService 调用了 WorkoutService 上的一个新方法 getWorkout,我们还没有添加这个方法。继续复制checkpooint 4.5trainer/src文件夹中services文件夹中的workout-service.ts文件中的getWorkout实现。我们不会深究新的服务代码,因为实现相当简单。

让我们回到左侧导航并实现剩余的功能。

使用 ExerciseNav 添加练习

要向我们正在构建的训练中添加练习,我们只需要导入 WorkoutBuilderServiceExercisePlan,将 WorkoutBuilderService 注入 LeftNavExercisesComponent 中,并调用其 addExercise 方法,传递所选练习作为参数:

constructor( 
    public workoutService:WorkoutService, 
 public workoutBuilderService:WorkoutBuilderService) {} 
. . . 
addExercise(exercise:Exercise) { 
 this.workoutBuilderService.addExercise(new ExercisePlan(exercise, 30)); 
} 

内部,WorkoutBuilderService.addExercise 使用新的练习更新 buildingWorkout 模型数据。

前面的实现是共享数据在独立组件之间的经典案例。共享服务以受控的方式向任何请求数据的组件提供数据。在共享数据时,最好的做法始终是使用方法来暴露状态/数据,而不是直接暴露数据对象。我们也可以在组件和服务的实现中看到这一点。LeftNavExercisesComponent 不直接更新训练数据;事实上,它没有直接访问正在构建的训练。相反,它依赖于服务方法 addExercise 来改变当前训练的练习列表。

由于服务是共享的,需要注意一些潜在的问题。由于服务可以通过系统进行注入,我们无法阻止任何组件依赖于任何服务并以不一致的方式调用其函数,从而导致不希望的结果或错误。例如,WorkoutBuilderService需要在调用addExercise之前通过调用startBuilding来初始化。如果组件在初始化之前调用addExercise会发生什么?

实现锻炼组件

WorkoutComponent负责管理锻炼。这包括创建、编辑和查看锻炼。由于引入了WorkoutBuilderService,这个组件的整体复杂性将会减少。除了与其模板视图集成、公开和交互的主要责任外,我们将大部分其他工作委托给WorkoutBuilderService

WorkoutComponent与两个路线/视图相关联,即/builder/workout/new/builder/workout/:id。这些路线处理创建和编辑锻炼的情况。组件的第一个任务是加载或创建它需要操作的锻炼。

路线参数

但在开始构建WorkoutComponent及其关联视图之前,我们需要简要介绍将用户带到该组件的屏幕的导航。这个组件处理创建和编辑锻炼的情况。组件的第一个任务是加载或创建它需要操作的锻炼。我们计划使用 Angular 的路由框架向组件传递必要的数据,以便它知道是否正在编辑现有的锻炼还是创建新的锻炼,并在现有的锻炼的情况下,应该编辑哪个组件。

这是如何实现的呢?WorkoutComponent与两个路线相关,即/builder/workout/new/builder/workout/:id。这两个路线的不同之处在于这些路线的结尾; 在一种情况下,它是/new,在另一种情况下,它是/:id。这些被称为路线参数。第二个路线中的:id是一个路线参数的令牌。路由器将令牌转换为锻炼组件的 ID。正如我们之前看到的,这意味着在7 分钟锻炼的情况下,将传递给组件的 URL 将是/builder/workout/7MinuteWorkout

我们怎么知道这个锻炼名称是 ID 的正确参数呢?当你回忆起我们设置了处理在锻炼屏幕上的锻炼图块点击事件的事件时,那会带我们到锻炼屏幕上,我们将锻炼名称指定为 ID 的参数,就像这样:

 onSelect(workout: WorkoutPlan) { 
     this.router.navigate( ['./builder/workout', workout.name] ); 
 }  

在这里,我们使用路由器的程序化接口来构建路由。router.navigate方法接受一个数组。这被称为链接参数数组。数组中的第一项是路由的路径,第二项是一个路由参数,指定 workout 的 ID。在这种情况下,我们将id参数设置为 workout 名称。我们还可以通过路由链接构建相同类型的 URL,或者直接在浏览器中输入它,以便转到 Workouts 屏幕并编辑特定的 workout。

两条路由中的另一条以/new结尾。由于这条路由没有token参数,路由器将简单地将 URL 无修改地传递给WorkoutComponent。然后,WorkoutComponent需要解析传入的 URL,以确定它应该创建一个新组件。

路由守卫

但是,在链接将用户带到WorkoutComponent之前,我们还需要考虑另一个步骤。始终存在 ID 在 URL 中传递用于编辑 workout 可能是不正确或丢失的情况。在这种情况下,我们不希望加载组件,而是希望将用户重定向到另一个页面或返回到他们来自的页面。

Angular 提供了一种使用路由守卫来实现这一结果的方法。顾名思义,路由守卫提供了一种阻止导航到路由的方法。路由守卫可以用于注入自定义逻辑,可以执行诸如检查授权、加载数据和进行其他验证以确定是否需要取消对组件的导航等操作。所有这些都是在组件加载之前完成的,因此如果取消了路由,则永远不会看到组件。

Angular 提供了几种路由守卫,包括CanActivateCanActivateChildCanDeActivateResolveCanLoad目前,我们对Resolve路由守卫感兴趣Resolve守卫不仅允许我们检查 workout 的存在,还允许在加载WorkoutComponent之前加载与 workout 相关的数据。这样做的优势是我们避免了在WorkoutComponent中检查数据是否加载的必要性,并且在其组件模板中去添加条件逻辑以确保在渲染时数据已经存在。 这在下一章中使用observables时将会特别有用,因为我们必须等待 observable 完成,然后才能保证它提供的数据已经存在。 Resolve守卫将处理等待 observable 完成的问题,这意味着WorkoutComponent在加载之前将保证拥有它所需的数据。

实现 resolve 路由守卫

Resolve 保护允许我们预取训练的数据。在我们的情况下,我们想使用 Resolve 来检查已有训练的任何 ID 的有效性。具体来说,我们将通过调用 WorkoutBuilderService 来检查该 ID,以检索训练计划并查看是否存在。如果存在,我们将加载与训练计划相关的数据,以便它可用于 WorkoutComponent;如果不存在,我们将重定向回训练页面。

trainer/src/app/workoutworkout-builder/workout 文件夹中的 checkpoint 4.5 复制 workout.resolver.ts,你会看到以下代码:

import 'rxjs/add/operator/map';
import 'rxjs/add/operator/take';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { Router, Resolve, RouterStateSnapshot,
         ActivatedRouteSnapshot } from '@angular/router';
import { WorkoutPlan } from '../../core/model';
import { WorkoutBuilderService } from '../builder-services/workout-builder.service';

@Injectable()
export class WorkoutResolver implements Resolve<WorkoutPlan> {
  public workout: WorkoutPlan;

  constructor(
    public workoutBuilderService: WorkoutBuilderService,
    public router: Router) {}

  resolve(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): WorkoutPlan {
    let workoutName = route.paramMap.get('id');

    if (!workoutName) {
        workoutName = '';
    }

    this.workout = this.workoutBuilderService.startBuilding(workoutName);

    if (this.workout) {
        return this.workout;
    } else { // workoutName not found
        this.router.navigate(['/builder/workouts']);
        return null;
    }
  }
}

正如你所见,WorkoutResolver 是一个可注入的类,实现了 Resolve 接口。 代码将 WorkoutBuilderServiceRouter 注入到类中,并使用 resolve 方法实现了接口。resolve 方法接受两个参数;ActivatedRouteSnapshotRouterStateSnapshot。在这种情况下,我们只对这两个参数中的第一个感兴趣,即 ActivatedRouteSnapshot。它包含一个 paramMap,我们从中提取路由的 ID 参数。

resolve 方法然后使用路由中提供的参数调用 WorkoutBuildingServicestartBuilding 方法。如果训练存在,resolve 就会返回数据并继续导航;如果不存在,它会重新定向用户到训练页面并返回 false。如果将 new 作为一个 ID 传递,WorkoutBuilderService 将加载一个新的训练计划,Resolve 保护将允许导航到 WorkoutComponent

resolve 方法可以返回一个 Promise、一个 Observable 或一个同步值。如果我们返回一个 Observable,我们需要确保在继续导航之前 Observable 完成。然而,在这种情况下,我们只是对本地内存数据存储进行同步调用,所以我们只返回一个值。

要完成 WorkoutResolver 的实现,首先确保导入并将其添加到 WorkoutBuilderModule 中作为提供者:

....
import { WorkoutResolver } from './workout/workout.resolver';

@NgModule({
....
  providers: [WorkoutBuilderService, WorkoutResolver]
})
....

然后,通过更新 workout-builder-routing.module.ts,将其添加到 WorkoutComponent 的路由配置中:

....
import { WorkoutResolver } from './workout/workout.resolver';
....
const routes: Routes = [
  {
    path: '',
    component: WorkoutBuilderComponent,
    children: [
         {path: '', pathMatch: 'full', redirectTo: 'workouts'},
         {path: 'workouts', component: WorkoutsComponent },
 {path: 'workout/new', component: WorkoutComponent, resolve: { workout: WorkoutResolver} },
 {path: 'workout/:id', component: WorkoutComponent, resolve: { workout: WorkoutResolver} },
         {path: 'exercises', component: ExercisesComponent},
         {path: 'exercise/new', component: ExerciseComponent },
         {path: 'exercise/:id', component: ExerciseComponent }
    ]
  },
];

正如你所见,我们将 WorkoutResolver 添加到路由模块的导入中。然后,我们将 resolve { workout: WorkoutResolver } 添加到 workout/newworkout/:id 路由配置的末尾。这指示路由器使用 WorkoutResolver 的解析方法,并将其返回值分配给路由数据中的 workout。这个配置意味着 WorkoutResolver 将在路由器导航到 WorkoutComponent 之前被调用,并且在加载时将可用于 WorkoutComponent。接下来我们将看到如何在 WorkoutComponent 中提取这些数据。

实现训练组件继续...

现在我们已经建立了将我们带到“训练”组件的路由,让我们转向完成它的实现。因此,从checkpoint 4.5下的“trainer/src/app/workout-builder/workout”文件夹中复制workout.component.ts文件。(同时,从workout-builder文件夹中复制workout-builder.module.ts文件。当我们到达 Angular 表单时,我们将稍后讨论对该文件的更改。)

打开workout.component.ts,你会看到我们已经添加了一个注入ActivatedRouteWorkoutBuilderService的构造函数:

    constructor( 
    public route: ActivatedRoute, 
    public workoutBuilderService:WorkoutBuilderService){ } 

此外,我们添加了以下ngOnInit方法:

  ngOnInit() {
      this.sub = this.route.data
          .subscribe(
            (data: { workout: WorkoutPlan }) => {
              this.workout = data.workout;
            }
          );
  }

该方法订阅了route并从route.data中提取了workout。没有必要检查训练是否存在,因为我们在“训练解析器”中已经做过了。

我们订阅route.data,因为作为ActivatedRouteroute将其data公开为一个Observable,在组件的生命周期内可以随时改变。这给了我们能力在同一组件实例中使用不同的参数,即使该组件的OnInit生命周期事件只被调用一次。我们将在下一章节详细介绍Observables

除了这段代码外,我们还为“训练组件”添加了一系列方法,用于添加、删除和移动训练。这些方法都调用了“训练生成器服务”上的相应方法,我们将不在这里详细审查它们。我们还添加了一个“持续时间”数组用于填充持续时间下拉列表。

目前,这就足够了组件类的实现。让我们更新相关的“训练”模板。

实现训练模板

现在,从checkpoint 4.5下的“trainer/src/app/workout-builder/workout”文件夹中复制workout.component.html文件。运行应用程序,导航至/builder/workouts,双击“7 分钟训练”磁贴。这应该会加载类似于“构建训练”章节开头所示的视图的“7 分钟训练”细节。

在出现任何问题的情况下,您可以参考“GitHub 仓库:分支:checkpoint4.5”中的checkpoint4.5代码(文件夹-trainer)。

我们将会在这个视图上花费很多时间,所以让我们在这里了解一些具体内容。

练习列表 div(id="exercise-list")按顺序列出组成训练的练习。我们在内容区域的左边以从上到下的形式显示它们。在功能上,这个模板有:

  • 删除按钮用于删除练习

  • 重新排列按钮可以将练习移到列表中的上下位置,以及移到顶部和底部。

我们使用ngFor来遍历练习列表并显示它们:

<div *ngFor="let exercisePlan of workout.exercises; let i=index" class="exercise-item"> 

你会注意到我们在ngFor前面使用了 * 星号,这是<template>标签的简写。我们还使用let来设置两个局部变量:exerisePlan用于标识练习列表中的项目,i用于设置一个索引值,我们将用它来在屏幕上显示练习的编号。我们还将使用索引值来管理重新排序和从列表中删除练习。

用于训练数据的第二个 div 元素(id="workout-data")包含了 HTML 输入元素,用于输入名称、标题和休息时长,并有一个保存训练变动的按钮。

完整列表被包裹在 HTML 表单元素内,以便我们可以利用 Angular 提供的与表单相关的功能。那么这些功能是什么呢?

Angular 表单

表单是 HTML 开发的一个不可或缺的部分,任何旨在客户端开发的框架都不能忽视它们。Angular 提供了一组小而明确定义的构造,使标准的基于表单的操作更加容易。

如果我们仔细考虑的话,任何形式的交互都可以归结为:

  • 允许用户输入

  • 根据业务规则对输入进行验证

  • 将数据提交到后端服务器

Angular 对所有以上用例都有所提供。

对于用户输入,它允许我们在表单输入元素和底层模型之间创建双向绑定,从而避免我们为模型输入同步而必须编写的样板代码。

它还提供了构造来在提交之前验证输入。

最后,Angular 提供了用于客户端服务器交互和将数据持久化到服务器的 HTTP 服务。我们将在第三章中涵盖这些服务,支持服务器数据持久化

由于前两个用例是本章的主要关注点,让我们更多地了解一下 Angular 用户输入和数据验证支持。

模板驱动和响应式表单

Angular 提供两种类型的表单:模板驱动响应式。在本章中我们将讨论这两种类型的表单。因为 Angular 团队表示我们中的许多人主要将使用模板驱动表单,这就是我们将在本章开始使用的内容。

模板驱动表单

正如其名,模板驱动表单侧重于在 HTML 模板内开发表单,并处理大部分表单输入、数据验证、保存和更新逻辑的工作。结果就是,在与表单模板关联的组件类中几乎不需要写太多与表单相关的代码。

模板驱动表单大量使用ngModel表单指令。我们将在接下来的章节中讨论它。它为表单控件提供双向数据绑定,这确实是一个很好的特性。它让我们编写更少的样板代码来实现一个表单。它还帮助我们管理表单的状态(例如,表单控件是否已更改,这些更改是否已保存)。而且,它还能轻松构造消息,显示出表单控件的验证要求未满足的情况(例如,未提供必填字段,电子邮件格式不正确等)。

入门

为了在Workout组件中使用 Angular 表单,我们必须首先添加一些额外的配置。在checkpoint 4.5trainer/src/app文件夹下的workout-builder文件夹中打开workout-buider.module.ts。您将看到它导入了FormsModule

....
import { FormsModule } from '@angular/forms';
....
@NgModule({ 
    imports: [ 
        CommonModule, 
 FormsModule, 
        SharedModule, 
        workoutBuilderRouting 
    ], 

这提供了我们实现表单所需的一切,包括:

  • NgForm

  • ngModel

让我们开始使用这些来构建我们的表单。

使用 NgForm

在我们的模板(workout.component.html)中,我们添加了以下form标签:

<form #f="ngForm" class="row" name="formWorkout" (ngSubmit)="save(f.form)">. . . 
</form> 

让我们看看我们在这里有什么。一个有趣的是,我们仍然使用标准的<form>标签,而不是特殊的 Angular 标签。我们还使用#来定义一个本地变量f,并将ngForm分配给它。创建这个本地变量使我们能够方便地在表单中的其他地方使用它进行与表单相关的活动。例如,您可以看到我们在将f.form传递给绑定到(ngSubmit)onSubmit事件的打开form标签的结束处使用它作为参数。

最后那个绑定到(ngSubmit)的内容告诉我们这里有些不同。尽管我们没有明确添加NgForm指令,但我们的<form>现在具有额外的事件,如ngSubmit,我们可以对其绑定操作。这是怎么发生的?嗯,这不是因为我们为本地变量分配了ngForm而触发的。相反,这是自动发生的,因为我们将表单模块导入了workout-builder.module.ts

有了这个导入,Angular 扫描我们的模板以查找<form>标签,并将该<form>标签包装在NgForm指令内。Angular 文档指出,组件中的<form>元素将升级为使用 Angular 表单系统。这很重要,因为这意味着NgForm的各种功能现在可以与表单一起使用。这包括ngSubmit事件,它在用户触发表单提交时发出信号,并提供在提交表单之前验证整个表单的能力。

ngModel

ngModel是模板驱动表单的基本构建块之一,并且你会发现它在整个表单中都被使用。ngModel的主要作用之一是支持用户输入和底层模型之间的双向绑定。有了这样的设置,模型中的更改会反映在视图中,视图的更新也会反映回模型中。到目前为止,我们讨论过的大多数其他指令都只支持从模型到视图的单向绑定。ngModel是双向的。但是,请注意,它仅在NgForm中可用,用于允许用户输入的元素。

正如你所知,我们已经有一个用于锻炼页面的模型,WorkoutPlan。下面是来自model.tsWorkoutPlan模型:

export class WorkoutPlan { 
  constructor( 
    public name: string, 
    public title: string, 
    public restBetweenExercise: number, 
    public exercises: ExercisePlan[], 
    public description?: string) { 
  } 
totalWorkoutDuration(): number{ 
 . . . [code calculating the total duration of the workout]. . . 
} 

注意在description后面使用?。这意味着它是我们模型中的一个可选属性,不需要创建WorkoutPlan。在我们的表单中,这意味着我们不需要输入描述,一切都可以正常运行。

WorkoutPlan模型中,我们还引用了由另一种类型的模型实例组成的数组:ExercisePlanExercisePlan又由一个数字(duration)和另一个模型(Exercise)组成,看起来像这样:

export class Exercise {
    constructor(
        public name: string,
        public title: string,
        public description: string,
        public image: string,
        public nameSound?: string,
        public procedure?: string,
        public videos?: Array<string>) { }
}

使用这些嵌套类显示了我们可以创建复杂的模型层次结构,这些模型都可以在我们的表单中使用NgModel进行数据绑定。因此,在整个表单中,每当我们需要更新WorkoutPlanExercisePlan中的一个值时,我们都可以使用NgModel来做到这一点(在以下示例中,WorkoutPlan模型将由名为workout的本地变量表示)。

使用ngModel处理 input 和 textarea

打开workout-component.html,查找ngModel。它已被应用于允许用户输入数据的表单元素,包括 input、textarea 和 select。选练名输入设置如下所示:

<input type="text" name="workoutName" class="form-control" id="workout-name" placeholder="Enter workout name. Must be unique." [(ngModel)]="workout.name">

前面的[(ngModel)]指令在输入控件和workout.name模型属性之间建立了双向绑定。方括号和括号应该各自很熟悉。以前,我们将它们分开使用:[]方括号用于属性绑定,()括号用于事件绑定。在后一种情况下,我们通常将事件绑定到与模板相关联的组件中的方法的调用。你可以在用户点击以删除练习的按钮的表单中看到这样的例子:

<span class="btn float-right trashcan" (click)="removeExercise(exercisePlan)"><span class="ion-ios-trash-outline"></span></span>

这里,点击事件明确绑定到我们Workout组件类中名为removeExercise的方法。但对于workout.name输入,我们没有将方法显式绑定到组件上。那么这里发生了什么,更新如何进行而我们没有在组件上调用方法?对这个问题的答案是,组合[( )]是绑定模型属性到输入元素和连接更新模型的事件的速记方式。

换句话说,如果我们在表单中引用一个模型元素,ngModel足够聪明以知道我们要做的是更新该元素(这里是workout.name)当用户输入或更改与其绑定的输入字段中的数据时。在幕后,Angular 创建了一个类似于我们否则必须自己编写的更新方法。太棒了!这种方法让我们不必编写重复的代码来更新我们的模型。

Angular 支持大多数 HTML5 输入类型,包括文本、数字、选择、单选和复选框。这意味着模型和任何这些输入类型之间的绑定都可以直接使用。

textarea元素与输入框的用法相同:

<textarea name="description" . . . [(ngModel)]="workout.description"></textarea> 

在这里,我们将textarea绑定到workout.description。在幕后,ngModel会根据我们在文本区域中键入的内容每次改变都更新我们模型中的 workout 描述。

要测试这是如何工作的,为什么不验证一下这个绑定?在任何一个链接的输入框的末尾添加一个模型插值表达式,比如这样一个:

<input type="text". . . [(ngModel)]="workout.name">{{workout.name}} 

打开 Workout 页面,在输入框中输入一些内容,看看插值是如何立即更新的。双向绑定的神奇!

使用 ngModel 与 select

让我们来看一下select是如何设置的:

<select . . . name="duration" [(ngModel)]="exercisePlan.duration"> 
    <option *ngFor="let duration of durations" [value]="duration.value">{{duration.title}}</option> 
</select> 

我们在这里使用ngFor绑定到一个数组,durations,它在Workout组件类中。数组的结构如下:

 [{ title: "15 seconds", value: 15 }, 
  { title: "30 seconds", value: 30 }, ...] 

ngFor组件将循环数组,并使用插值将下拉框的值与数组中相应的值填充,每个项目的标题使用插值显示,{{duration.title}}。 然后[(ngModel)]将下拉选择绑定到模型中的exercisePlan.duration

注意这里,我们将绑定到嵌套模型:ExercisePlan。 可能会有多个练习要应用这个绑定。在这种情况下,我们必须使用另一个 Angular 表单指令— ngModelGroup—来处理这些绑定。ngModelGroup将允许我们在我们的模型中创建一个包含在训练中包含的练习列表的嵌套组,并然后循环每个练习来将其持续时间绑定到模型。

首先,我们将在表单中创建的 div 标签上添加ngModelGroup,以保存我们的练习列表:

<div id="exercises-list" class="col-sm-2 exercise-list" ngModelGroup="exercises">

这样就创建了嵌套的练习列表。现在,我们必须处理列表中的单个练习,我们可以在包含每个练习的单独 div 中添加另一个ngModelGroup来做到这一点:

<div class="exercise tile" [ngModelGroup]="i">

在这里,我们使用循环的索引动态创建每个练习的单独模型组。这些模型组将嵌套在我们创建的第一个模型组中。临时地,在表单的底部添加标签<pre>{{ f.value | json }}</pre>,就可以看到这个嵌套模型的结构:

{
  "exercises": {
    "0": {
      "duration": 15
    },
    "1": {
      "duration": 60
    },
    "2": {
      "duration": 45
    },
    "exerciseCount": 3
  },
  "workoutName": "1minworkout",
  "title": "1 Minute Workout",
  "description": "desc",
  "restBetweenExercise": 30
}

这是一个强大的功能,让我们能够创建带有嵌套模型的复杂表单,所有这些都可以使用ngModel进行数据绑定**。**

您可能已经注意到我们刚刚介绍的两个ngModelGroup指令标记之间微妙的区别。其中第二个标记被包裹在尖括号[]中,而第一个没有。这是因为在第一个标记中,我们只是为我们的模型组命名,而在第二个标记中,我们是动态地将其绑定到每个练习的 div 标记,使用我们的 for 循环的索引。

和输入一样,选择也支持双向绑定。我们已经看到了改变选择项会更新模型,但是模型到模板的绑定可能不太明显。要验证模型到模板的绑定是否有效,打开 7 分钟锻炼 应用程序并验证持续时间下拉菜单。每个下拉菜单的值都与模型值(30 秒)一致。

Angular 通过使用ngModel很好地保持了模型和视图的同步。更改模型并查看视图更新;更改视图并观察模型立即更新。

现在,让我们给表单添加验证。

下一节的代码也可以在 GitHub 上下载:github.com/chandermani/angular6byexample。检查点在 GitHub 里作为分支实现。要下载的分支如下:GitHub 分支:checkpoint4.6(文件夹—trainer)。或者如果没有使用 Git,可以从以下 GitHub 位置下载 Checkpoint 4.6 的快照(ZIP 文件):github.com/chandermani/angular6byexample/archive/checkpoint4.6.zip。第一次设置完快照,参考trainer文件夹里的README.md文件。同样,如果和我们一起构建应用程序,请务必更新styles.css文件,在这里我们不会讨论。

Angular 验证

俗话说,“不要相信用户输入”。Angular 支持验证,包括标准的 required、min、max 和 pattern,以及自定义验证器。

ngModel

ngModel是我们用来实现验证的基本模块。它为我们做了两件事情:维护模型状态并提供一种机制来识别验证错误并显示验证消息。

要开始,我们需要在所有要验证的表单控件中,为ngModel分配一个本地变量。在每种情况下,我们需要为这个本地变量使用一个唯一的名称。例如,对于锻炼名称,我们在该控件的input标签内添加#name="ngModel",以及 HTML 5 的required属性。现在锻炼名称的input标签应该是这样的:

<input type="text" name="workoutName" #name="ngModel" class="form-control" id="workout-name" placeholder="Enter workout name. Must be unique." [(ngModel)]="workout.name" required> 

通过表单,为每个输入分配ngModel到本地变量。同时,为所有必填字段添加required属性。

Angular 模型状态

每当我们使用NgForm时,我们表单中的每个元素(包括输入、文本区域和选择)都会有与相关模型定义的某些状态。ngModel为我们跟踪这些状态。跟踪的状态包括:

  • pristine:只要用户不与输入进行交互,其值就为true。对input字段的任何更新都将使ng-pristine更改为false

  • dirty:这与ng-pristine相反。当输入数据已更新时为true

  • touched:如果控件曾经获得焦点,则为true

  • untouched:如果控件从未失去焦点,则为true。这只是ng-touched的相反。

  • valid:如果input元素上定义了验证,并且没有任何验证失败,则为true

  • invalid:如果元素上定义的任何验证失败,则为true

pristine``dirtytouched``untouched是有用的属性,可帮助我们决定何时显示错误标签。

Angular CSS 类

基于模型状态,Angular 会向输入元素添加一些 CSS 类。这些包括以下内容:

  • ng-valid:如果模型有效,则使用该标识。

  • ng-invalid:如果模型无效,则使用该标识。

  • ng-pristine:如果模型是原始的,则使用该标识

  • ng-dirty:如果模型已更改,则使用该标识。

  • ng-untouched:如果输入从未被访问过,则使用该标识

  • ng-touched:如果输入已获得焦点,则使用该标识。

要进行验证,请返回到workoutName输入标签,并在input标签内添加名为spy的模板引用变量。

<input type="text" name="workoutName" #name="ngModel" class="form-control" id="workout-name" placeholder="Enter workout name. Must be unique." [(ngModel)]="workout.name" required #spy> 

然后,在标签下面添加以下标签:

<label>{{spy.className}}</label> 

重新加载应用程序,并点击训练构建器中的新训练链接。在屏幕上什么都不动之前,会显示以下内容:

将一些内容添加到名称输入框中,并切换到其他位置。标签会更改为:

这里我们看到的是,随着用户与其交互,Angular 会更改应用于该控件的 CSS 类。您还可以通过在开发者控制台中检查input元素来查看这些更改。

如果我们希望根据其状态对元素应用可视提示,这些 CSS 类转换将非常有用。例如,看看这个片段:

input.ng-invalid {  border:2px solid red; } 

这将在任何输入控件周围绘制红色边框,表示数据无效。

当您向训练页面添加更多验证时,您可以观察(在开发者控制台中)这些类在用户与input元素交互时是如何添加和删除的。

现在我们已经了解了模型状态及其用法,让我们回到验证的讨论(在继续之前,请删除刚刚添加的变量名和标签)。

训练验证

需要验证训练数据是否符合一系列条件。

在为input字段添加了ngModelrequired属性的本地变量引用之后,我们已经能够看到ngModel如何跟踪这些控件的状态变化以及如何切换 CSS 样式。

显示适当的验证消息

现在,输入必须有值,否则验证将失败。但是,我们如何知道验证是否失败呢?ngModel在这里来拯救我们。它可以提供特定输入的验证状态。这就给了我们显示适当验证消息所需的信息。

让我们回到训练计划名称的输入控件。为了显示验证消息,我们必须先修改输入标签如下:

<input type="text" name="workoutName" #name="ngModel" class="form-control" id="workout-name" placeholder="Enter workout name. Must be unique." [(ngModel)]="workout.name" required> 

我们添加了一个名为#name的本地变量,并将ngModel分配给它。这被称为模板引用变量,我们可以使用以下标签来显示输入的验证消息:

<label *ngIf="name.control.hasError('required') && (name.touched)" class="alert alert-danger validation-message">Name is required</label>  

当未提供名称并且控件已被触摸时,我们会显示验证消息。为了检查第一个条件,我们检索控件的hasError属性并查看错误类型是否为required。我们检查名称输入是否已被touched,因为我们不希望在表单首次加载新训练计划时显示消息。

你会注意到,我们使用了一种相对冗长的方式来识别验证错误,而不是简单地使用!name.valid。这种方法允许我们以更具体的方式识别验证错误,这在我们开始为表单控件添加多个验证器时将非常重要。我们将在本章稍后讨论使用多个验证器。为了保持一致性,我们将坚持使用这种更冗长的方法。

现在加载新的训练计划页面(/builder/workouts/new)。在名称输入框中输入一个值,然后删除它。错误标签会像下面的截图所示的那样显示出来。

添加更多验证

Angular 提供了几个现成的验证器,包括:

  • required

  • minLength

  • maxLength

  • email

  • pattern

有关现成验证器的完整列表,请参阅angular.io/api/forms/ValidatorsValidators类的文档。

我们已经看到了required验证器的工作原理。现在,让我们看看另外两个现成的验证器:minLengthmaxLength。除了将其设为必填项外,我们还希望训练计划的标题长度在 5 到 20 个字符之间(我们稍后将介绍pattern验证器)。

除了之前添加到标题输入框的required属性外,我们还将添加minLength属性并将其设置为5,并添加maxLength属性并将其设置为20

<input type="text" . . . minlength="5" maxlength="20" required> 

然后,我们添加另一个标签,其中包含在不满足此验证条件时将显示的消息:

<label *ngIf="(title.control.hasError('minlength') || title.control.hasError('maxlength')) && workout.title.length > 0" class="alert alert-danger validation-message">Title should be between 5 and 20 characters long.</label>  

管理多个验证消息

你会发现,现在显示消息的条件是测试长度不为零。这可以防止在控件被触摸但留空的情况下显示消息。在这种情况下,标题需要的消息应该显示。这条消息只有在字段中没有输入任何内容时才显示,我们通过明确检查控件的hasError类型是否为required来实现这一点:

<label *ngIf="title.control.hasError('required')" class="alert alert-danger validation-message">Title is required.</label>

由于我们将两个验证器附加到此输入字段,我们可以通过在检查是否满足该条件的 div 标签中包含两个验证器来简化对输入被触摸的检查:

<div *ngIf="title.touched"> 
  . . . [the two validators] . . . 
</div> 

我们刚刚所做的展示了如何将多个验证附加到单个输入控件,并在不满足其中一个验证条件时显示适当的消息。然而,很明显,这种方法在处理更复杂的场景时并不可扩展。一些输入包含很多验证,并且控制验证消息何时显示可能变得复杂。随着处理各种显示的表达式变得更加复杂,我们可能希望重构并将它们移入一个自定义指令中。如何创建自定义指令将在第四章,深入理解 Angular 指令中详细介绍。

对于一个锻炼的自定义验证消息

运动而没有任何锻炼是无用的。锻炼中至少应该有一种锻炼,我们应该验证这一限制。

锻炼计数验证的问题在于用户没有直接输入它,而框架却要验证它。尽管如此,我们仍然希望一种机制来验证锻炼计数,类似于此表单上的其他验证。

我们要做的是向表单中添加一个隐藏的输入框,其中包含了锻炼的计数。然后我们将其绑定到ngModel并添加一个模式验证器,以确保有多于一个锻炼。我们将输入框的值设置为锻炼的计数:

<input type="hidden" name="exerciseCount" #exerciseCount="ngModel" ngControl="exerciseCount" class="form-control" id="exercise-count" [(ngModel)]="workout.exercises.length" pattern="[1-9][0-9]*"> 

然后,我们将类似于我们刚刚对其他验证器所做的操作附加到它的验证消息:

<label *ngIf="exerciseCount.control.hasError('pattern')" class="alert alert-danger extended-validation-message">The workout should have at least one exercise!</label>  

我们在这里并没有真正使用ngModel。这里没有涉及双向绑定。我们只对使用它进行自定义验证感兴趣。

打开新的锻炼页面,添加一个锻炼,然后将其删除; 我们应该看到这个错误:

我们在这里做的事情本来可以很容易地完成,而不涉及任何模型验证基础设施。但是,通过将我们的验证钩入该基础设施中,我们会获得一些好处。我们现在可以以一种一致而熟悉的方式确定特定模型的错误和整个表单的错误。最重要的是,如果我们的验证在这里失败,整个表单将无效。

实现刚刚进行的自定义验证通常不是您经常想要做的事情。相反,通常更合理的做法是在自定义指令内部实现这种复杂逻辑。我们将在第四章的深入理解 Angular 指令中详细介绍创建自定义指令。

我们新实现的Exercise Count验证的一个讨厌之处是,在新的Workout屏幕首次出现时就会显示。使用这个消息,我们无法使用ng-touched来隐藏显示。因为练习是以程序方式添加的,并且我们使用来跟踪其数量的隐藏输入在练习被添加或删除时从未改变为已接触的状态。

要解决这个问题,我们需要一个额外的值来检查练习列表的状态何时减少到零,除非表单是第一次加载。这种情况发生的唯一方式是用户添加然后从锻炼中删除练习,直到没有更多的练习为止。因此,我们将向组件添加另一个属性,用于跟踪删除方法是否已被调用。我们称这个值为removeTouched并设置其初始值为false

removeTouched: boolean = false; 

然后,在删除方法中,我们将该值设置为true

removeExercise(exercisePlan: ExercisePlan) { 
    this.removeTouched = true; 
    this.workoutBuilderService.removeExercise(exercisePlan); 
} 

接下来,我们将将removeTouched添加到我们的验证消息条件中,如下所示:

<label *ngIf="exerciseCount.control.hasError('pattern') && (removeTouched)" 

现在,当我们打开一个新的锻炼屏幕时,验证消息将不会显示。但是,如果用户添加然后删除所有练习,那么它将显示。

要理解模型验证如何转化为表单验证,我们需要了解表单级别验证提供了什么。但是,即使在那之前,我们也需要实现保存锻炼并从锻炼表单中调用它。

保存锻炼

我们正在构建的锻炼需要被持久化(仅内存中)。我们需要做的第一件事就是扩展WorkoutServiceWorkoutBuilderService

WorkoutService需要两个新方法,addWorkoutupdateWorkout

addWorkout(workout: WorkoutPlan){ 
    if (workout.name){ 
        this.workouts.push(workout); 
        return workout; 
    } 
} 

updateWorkout(workout: WorkoutPlan){ 
    for (var i = 0; i < this.workouts.length; i++) { 
        if (this.workouts[i].name === workout.name) { 
            this.workouts[i] = workout; 
            break; 
        } 
    } 
} 

addWorkout方法对锻炼名称进行基本检查,然后将锻炼推入锻炼数组中。由于没有涉及后备存储,如果刷新页面,数据就会丢失。我们将在下一章中修复这个问题,将数据持久保存到服务器。

updateWorkout方法在现有的锻炼数组中查找具有相同名称的锻炼,并在找到时进行更新和替换。

我们只需要向WorkoutBuilderService添加一个保存方法,因为我们已经在追踪进行中的锻炼构建的上下文:

save(){ 
    let workout = this.newWorkout ? 
        this._workoutService.addWorkout(this.buildingWorkout) : 
        this._workoutService.updateWorkout(this.buildingWorkout); 
    this.newWorkout = false; 
    return workout; 
} 

save方法根据是否正在创建新的锻炼或正在编辑现有的锻炼,在Workout服务中调用addWorkoutupdateWorkout

从服务的角度来看,这就足够了。是时候将保存锻炼的能力集成到Workout组件中,并了解更多关于表单指令的知识了!

在更详细地查看NgForm之前,让我们向Workout添加保存方法,以便在单击保存按钮时保存训练计划。将以下代码添加到Workout组件中:

save(formWorkout:any){ 
    if (!formWorkout.valid) return; 
    this.workoutBuilderService.save(); 
    this.router.navigate(['/builder/workouts']); 
}  

我们使用其 invalid 属性检查表单的验证状态,如果表单状态有效,则调用WorkoutBuilderService.save方法。

NgForm 更多内容

在 Angular 中,与传统的向服务器发送数据的表单相比,表单扮演了不同的角色。如果我们回头再看一下 form 标签,会发现它缺少标准的 action 属性。使用 Angular 等 SPA 框架时,通过完整页面的回发方式向服务器发送数据不合理。在 Angular 中,所有服务器请求都是通过起源于指令或服务的异步调用进行的。

在幕后,Angular 还关闭了浏览器的内置验证。正如您在本章中看到的,我们仍在使用required等验证属性,看起来与原生 HTML 验证属性相同。然而,正如 Angular 文档所解释的那样,在 Angular 表单内部,“Angular 使用指令将这些属性与框架中的验证器函数匹配。” 参见angular.io/guide/form-validation#template-driven-validation

这里的表单扮演着不同的角色。当表单封装一组输入元素(如输入、文本框和选择)时,它提供了一个 API 来:

  • 根据表单上的输入控件确定表单的状态,例如表单是脏的还是原始的

  • 在表单或控制级别检查验证错误

如果您仍希望使用标准表单行为,可以向form元素添加ngNoForm属性,但这肯定会导致完整页面刷新。您还可以通过添加ngNativeValidate属性来开启浏览器的内置验证。在本章后面的部分,当我们看到如何保存表单和实现验证时,我们将更详细地探讨NgForm API 的具体内容。

表单内 FormControl 对象的状态由NgForm监视。如果其中任何一个无效,那么NgForm会将整个表单设置为无效。在这种情况下,我们已经能够使用NgForm确定一个或多个FormControl对象是无效的,因此整个表单的状态也是无效的。

在完成本章节之前,让我们再看一起问题。

修复表单的保存和验证消息

打开一个新的 Workout 页面,直接点击保存按钮。由于表单无效,什么也没有被保存,但是单个表单输入的验证根本不会显示出来。现在很难知道是哪些元素导致了验证失败。这种行为背后的原因非常明显。如果我们查看名称输入元素的错误消息绑定,会看到以下内容:

*ngIf="name.control?.hasError('required') && name.touched"

请记住,在本章的早些时候,我们明确禁用了显示验证消息,直到用户触摸输入控件为止。同样的问题又回来找我们了,我们现在需要修复它。

我们没有办法显式地将控件的触摸状态更改为未触摸状态。相反,我们将求助于一点欺诈来完成工作。我们将引入一个名为submitted的新属性。将其添加在Workout类定义的顶部,并将其初始值设置为false,如下所示:

submitted: boolean = false;

当保存按钮被点击时,变量将被设置为true。通过添加以下高亮显示的代码来更新保存的实现:

save(formWorkout){ 
 this.submitted = true; 
    if (!formWorkout.valid) return; 
    this._workoutBuilderService.save(); 
    this.router.navigate(['/builder/workouts']); 
} 

然而,这有什么帮助呢?好吧,这个修复的另一个部分需要我们更改我们正在验证的每个控件的错误消息。现在表达式变成了:

*ngIf="name.control.hasError('required') && (name.touched || submitted)"   

通过这个修复,当控件被触摸时或表单提交按钮被按下时(submittedtrue),错误消息将被显示。此表达式修复现在必须适用于每个出现检查的验证消息。

如果现在打开新的 Workout 页面并点击保存按钮,则应该在输入控件上看到所有验证消息:

响应式表单

Angular 支持的另一种表单类型称为响应式表单。响应式表单始于在组件类中构建的模型。通过这种方式,我们使用表单构建器 API在代码中创建一个表单,并将其与一个模型关联起来。

给定我们必须编写的最小代码来使模板驱动表单工作,那么为什么以及何时应考虑使用响应式表单呢?有几种情况下,我们可能想要使用它们。这些情况包括我们想要以编程方式控制创建表单的情况。尤其是在我们试图基于从服务器检索到的数据动态创建表单控件时,这是特别有益的,正如我们将看到的那样。

如果我们的验证变得复杂,通常更容易在代码中处理。使用响应式表单,我们可以将这些复杂的逻辑从 HTML 模板中分离出来,使模板语法更简单。

响应式表单的另一个重要优势是,它使得对表单进行单元测试成为可能,这对模板驱动表单来说并非如此。我们可以在我们的测试中简单实例化我们的表单控件,然后在页面上的标记之外对它们进行测试。

响应式表单使用了三个我们之前没有讨论过的新表单指令:FormGroupFormControlFormArray。这些指令允许在代码中构建的表单对象与模板中的 HTML 标记直接绑定。在组件类中创建的表单控件随后也直接可用于表单本身。从技术上讲,这意味着我们不需要在响应式表单中使用 ngModel(这是模板驱动表单的核心部分),尽管它也可以使用。总体上,这种方法使得模板更清晰、更简洁,更专注于驱动表单的代码。让我们开始构建一个响应式表单。

使用响应式表单入门

我们将使用响应式表单来构建添加和编辑练习的表单。除其他内容外,该表单将允许用户在 YouTube 上添加练习视频的链接。由于他们可以添加任意数量的视频链接,我们需要能够动态添加这些视频链接的控件。这个挑战将对响应式表单在开发更复杂的表单时的有效性提出良好的测试。

表单将如下所示:

要开始,请打开 workout-builder.module.ts 并添加以下import

import { FormsModule, ReactiveFormsModule }   from '@angular/forms'; 
 ... 
@NgModule({ 
    imports: [ 
        CommonModule, 
        FormsModule, 
 ReactiveFormsModule, 
        SharedModule, 
        workoutBuilderRouting 
    ],

ReactiveFormsModule 包含了构建响应式表单所需的内容。

接下来,从 checkpoint 4.6 下的 trainer/src/app 中的 workout-builder/builder-services 文件夹中复制 exercise-builder-service.ts 并将其导入到 workout-builder.module.ts 中:

import { ExerciseBuilderService } from "./builder-services/exercise-builder-service"; 

然后,在同一文件中的提供者数组中将其添加为附加提供者:

@NgModule({ 
   . . . 
  providers: [
    WorkoutBuilderService,
    WorkoutResolver,
    ExerciseBuilderService,
    ExerciseResolver
   ]
}) 

请注意,我们还已将 ExerciseResolver 添加为提供者。我们在这里不会讨论这个,但你应该从 exercise 文件夹下也复制它,并且还要复制更新后的 workout-builder-routing.module.ts,将它作为导航到 ExerciseComponent 的路由守卫。

现在,打开 exercise.component.ts 并添加以下导入语句:

import { Validators, FormArray, FormGroup, FormControl, FormBuilder } from '@angular/forms';

这将引入我们将用来构建表单的以下内容:

  • FormBuilder

  • FormGroup

  • FormControl

  • FormArray

最后,我们将 FormBuilder(以及 RouterActivatedRouteExerciseBuilderService)注入到我们类的构造函数中:

  constructor(
      public route: ActivatedRoute,
      public router: Router,
      public exerciseBuilderService: ExerciseBuilderService,
      public formBuilder: FormBuilder
  ) {}

通过完成这些初步步骤,我们现在可以开始构建我们的表单了。

使用 FormBuilder API

FormBuilder API 是响应式表单的基础。你可以把它想象成是在我们的代码中构建表单的工厂。现在,在你的类中添加 ngOnInit 生命周期钩子,如下所示:

  ngOnInit() {
    this.sub = this.route.data
        .subscribe(
          (data: { exercise: Exercise }) => {
            this.exercise = data.exercise;
          }
        );

      this.buildExerciseForm();
  } 

ngOnInit 被触发时,它将从由 ExerciseResolver 检索和返回的路由数据中提取现有或新的 exercise 的数据。这与初始化 Workout 组件时遵循的模式相同。

现在,让我们通过添加以下代码来实现 buildExerciseForm 方法:

buildExerciseForm(){ 
    this.exerciseForm = this.formBuilder.group({ 
        'name': [this.exercise.name, [Validators.required, AlphaNumericValidator.invalidAlphaNumeric]], 
        'title': [this.exercise.title, Validators.required], 
        'description': [this.exercise.description, Validators.required], 
        'image': [this.exercise.image, Validators.required], 
        'nameSound': [this.exercise.nameSound], 
        'procedure': [this.exercise.procedure], 
        'videos': this.addVideoArray() 
    }) 
}  

让我们来看一下这段代码。首先,我们使用注入的FormBuilder实例来构建表单并将其分配给一个本地变量exerciseForm。使用formBuilder.group,我们向表单添加了多个表单控件。我们通过简单的键/值映射添加了每个表单控件:

'name': [this.exercise.name, Validators.required], 

映射的左侧是FormControl的名称,右侧是一个数组,它的第一个元素是控件的值(在我们的例子中是我们练习模型上对应的元素),第二个是验证器(在这种情况下是开箱即用的必需验证器)。非常整洁!通过在模板之外设置表单控件来设置它们,肯定更容易看到和理解我们的表单控件。

我们不仅可以用这种方式在我们的表单中构建FormControls,还可以添加FormControlGroupsFormControlArray,它们包含其中的FormControls。这意味着我们可以创建包含嵌套输入控件的复杂表单。在我们的情况下,正如我们已经提到的,我们需要考虑用户可能向练习中添加多个视频的情况。我们可以通过添加以下代码来实现:

'videos': this.addVideoArray() 

我们在这里做的是将一个FormArray分配给视频,这意味着我们可以在这个映射中分配多个控件。为了构造这个新的FormArray,我们向我们的类添加了以下addVideoArray方法:

addVideoArray(){ 
    if(this.exercise.videos){ 
        this.exercise.videos.forEach((video : any) => { 
            this.videoArray.push(new FormControl(video, Validators.required)); 
        }); 
    } 
    return this.videoArray; 
} 

这个方法为每个视频构造了一个FormControl;然后将每个视频添加到分配给我们的表单中的videos控件的FormArray中。

将表单模型添加到我们的 HTML 视图

到目前为止,我们一直在我们的类中幕后工作来构建我们的表单。下一步是将我们的表单连接到视图。为此,我们使用了同样的控件,用来在我们的代码中构建表单:formGroupformControlformArray

打开exercise.component.html并添加如下的form标签:

<form class="row" [formGroup]="exerciseForm" (ngSubmit)="onSubmit(exerciseForm)">  

在标签内,我们首先将我们刚刚在代码中构建的exerciseForm分配给formGroup。这样建立了我们编码模型与视图中表单的连接。我们还将ngSubmit事件与代码中的onSubmit方法连接起来(我们稍后会讨论这个方法)。

将表单控件添加到我们的表单输入中

接下来,我们开始构建我们表单的输入。我们将以我们练习名称的输入开始:

<input name="name" formControlName="name" class="form-control" id="name" placeholder="Enter exercise name. Must be unique.">  

我们将我们编码的表单控件的名称分配给formControlName。这样就建立了我们代码中的控件与标记中的input字段之间的链接。这里另一个值得注意的是,我们没有使用required属性。

添加验证

我们接下来要做的是向控件添加验证消息,以便在验证出错时显示:

<label *ngIf="exerciseForm.controls['name'].hasError('required') && (exerciseForm.controls['name'].touched || submitted)" class="alert alert-danger validation-message">Name is required</label>

请注意,这种标记非常类似于我们在模板驱动表单中用于验证的内容,只是用于识别控件的语法有点更冗长。它再次检查控件的hasError属性状态,以确保它是有效的。

但等一下!我们如何验证此输入?我们难道没有从标签中删除required属性吗?这就是我们在代码中添加的控件映射发挥作用的地方。如果回顾一下表单模型的代码,您可以看到name控件的以下映射:

'name': [this.exercise.name, Validators.required], 

映射数组中的第二个元素将必填验证器分配给名称表单控件。这意味着我们不必在模板中添加任何内容;相反,表单控件本身附加了一个必填验证器到模板上。在代码中添加验证器的能力使我们能够方便地在模板之外添加验证器。当涉及编写具有复杂逻辑的自定义验证器时,这是特别有用的。

添加动态表单控件

我们正在构建的练习表单要求允许用户向练习中添加一个或多个视频。由于我们不知道用户可能希望添加多少视频,我们将不得不在用户点击添加视频按钮时动态构建这些视频的input字段。它将如下所示:

我们已经在我们的组件类中看到了用于执行此操作的代码。现在,让我们看看如何在我们的模板中实现它。

首先,我们使用ngFor循环遍历视频列表。然后,我们将视频在列表中的索引赋值给本地变量i。到目前为止,没有什么意外。

<div *ngFor="let video of videoArray.controls; let i=index" class="form-row align-items-center">

在循环内,我们做了三件事。首先,我们为当前练习中的每个视频动态添加一个视频input字段。

<div class="col-sm-10">
    <input type="text" class="form-control" [formControlName]="i" placeholder="Add a related youtube video identified."/>
</div>

接下来,我们添加一个按钮,允许用户删除视频:

<span class="btn alert-danger" title="Delete this video." (click)="deleteVideo(i)">
    <span class="ion-ios-trash-outline"></span>
</span> 

我们将组件类中的deleteVideo方法绑定到按钮的click事件上,并传递给它被删除的视频的索引。

然后,我们为每个视频input字段添加验证消息:

<label *ngIf="exerciseForm.controls['videos'].controls[i].hasError('required') && (exerciseForm.controls['videos'].controls[i].touched || submitted)" class="alert alert-danger validation-message">Video identifier is required</label>

验证消息遵循了在本章其他地方使用的显示消息的相同模式。我们进入exerciseFormControls组以找到特定索引的控件。再次,语法冗长但足够容易理解。

保存表单

构建响应式表单的最后一步是处理表单的保存。在先前构造表单标签时,我们将ngSubmit事件绑定到代码中的以下onSubmit方法上。

  onSubmit(formExercise: FormGroup) {
      this.submitted = true;
      if (!formExercise.valid) { return; }
      this.mapFormValues(formExercise);
      this.exerciseBuilderService.save();
      this.router.navigate(['/builder/exercises']);
  }

此方法将submitted设置为true,这将触发可能之前因表单未被触摸而隐藏的任何验证消息的显示。如果没有任何验证错误,则返回而不保存。如果没有错误,则调用以下mapFormValues方法,将表单中的值分配给将要保存的exercise

  mapFormValues(form: FormGroup) {
      this.exercise.name = form.controls['name'].value;
      this.exercise.title = form.controls['title'].value;
      this.exercise.description = form.controls['description'].value;
      this.exercise.image = form.controls['image'].value;
      this.exercise.nameSound = form.controls['nameSound'].value;
      this.exercise.procedure = form.controls['procedure'].value;
      this.exercise.videos = form.controls['videos'].value;
  }

然后它调用了ExerciseBuilderService中的保存方法,并将用户路由回练习列表屏幕(请记住,任何新练习不会显示在该列表中,因为我们尚未在应用程序中实现数据持久性)。

我们希望这让事情变得清晰起来;当我们尝试构建更复杂的表单时,响应式表单提供了许多优势。它们允许将编程逻辑从模板中移除。它们允许以编程方式向表单添加验证器。而且,它们支持在运行时动态构建表单。

自定义验证器

现在,在我们结束本章之前,我们再看一件事。任何在构建 Web 表单上工作过的人(无论是在 Angular 还是任何其他 Web 技术上)都知道,我们经常需要创建适用于我们正在构建的应用程序的独特验证。Angular 为我们提供了通过构建自定义验证器来增强我们的响应式表单验证的灵活性。

在构建我们的运动形式时,我们需要确保输入的内容,因为名称只包含字母数字字符且没有空格。这是因为当我们开始将练习存储在远程数据存储中时,我们将使用练习的名称作为其键。因此,除了标准的必填字段验证器之外,让我们构建另一个验证器,以确保输入的名称只以字母数字形式存在。

创建自定义控件非常简单。在其最简单的形式中,Angular 自定义验证器是一个以控件作为输入参数的函数,运行验证检查,并返回 true 或 false。因此,让我们首先添加一个名为alphanumeric-validator.ts的 TypeScript 文件。在该文件中,首先从@angular/forms中导入FormControl,然后在该文件中添加以下类:

export class AlphaNumericValidator {
    static invalidAlphaNumeric(control: FormControl): { [key: string]: boolean } {
        if ( control.value.length && !control.value.match(/^[a-z0-9]+$/i) ) {
            return {invalidAlphaNumeric: true };
        }
        return null;
    }
}

该代码遵循我们刚提到的创建验证器的模式。唯一可能有点意外的是当验证失败时它返回 true!只要你明白这个怪癖,你就应该没有问题编写自己的自定义验证器。

将自定义验证器整合到我们的表单中

那么我们如何将自定义验证器插入我们的表单中?如果我们使用响应式表单,答案非常简单。当我们在代码中构建表单时,我们就像添加内置验证器那样添加它。让我们这样做。打开exercise.component.ts并首先为我们的自定义验证器添加一个导入:

import { AlphaNumericValidator } from '../alphanumeric-validator'; 

然后,修改表单构建器代码,将验证器添加到name控件中:

buildExerciseForm(){ 
    this.exerciseForm = this._formBuilder.group({ 
'name': [this.exercise.name, [Validators.required, AlphaNumericValidator.invalidAlphaNumeric]], 
  . . . [other form controls] . . . 
    }); 
} 

由于名称控件已经具有必填验证器,我们使用一个包含两个验证器的数组将AlphaNumericValidator作为第二个验证器添加到控件中。该数组可以用于向控件添加任意数量的验证器。

最后一步是在我们的模板中将控件的适当验证消息合并到我们的模板中。打开workout.component.html并在显示所需验证器消息的标签下方添加以下标签:

<label *ngIf="exerciseForm.controls['name'].hasError('invalidAlphaNumeric') && (exerciseForm.controls['name'].touched || submitted)" class="alert alert-danger validation-message">Name must be alphanumeric</label> 

如果在名称输入框中输入了非字母数字值,练习屏幕现在将显示验证消息:

正如我们所希望的那样,响应式表单使我们能够以简单的方式向我们的表单添加自定义验证器,这样我们就可以在代码中维护验证逻辑,并将其轻松地集成到我们的模板中。

你可能已经注意到,在本章中,我们没有涵盖如何在模板驱动的表单中使用自定义验证器。因为实现它们需要额外的步骤,即构建一个自定义指令。我们将在第四章中进行介绍,《深入理解 Angular 指令》。

运行验证的配置选项

在我们从验证中离开之前,还有一个话题要覆盖,那就是运行验证的配置选项。到目前为止,我们一直使用默认选项,即在每次输入事件上运行验证检查。然而,你可以选择将它们配置为在“blur”(即用户离开输入控件时)或在表单提交时运行。你可以在表单级别或逐个控件的基础上进行配置。

例如,我们可能决定为避免在锻炼表单中处理缺少的锻炼的复杂性,我们将该表单设置为仅在提交时进行验证。我们可以通过向表单标签添加以下高亮显示的NgFormOptions来设置:

<form #f="ngForm" name="formWorkout" (ngSubmit)="save(f.form)" [ngFormOptions]="{updateOn: 'submit'}" class="row">

这将指示 Angular 仅在submit时进行验证。尝试一下,你会发现当你在表单中输入时,不会出现任何验证。留空表单并按保存按钮,你会看到验证信息出现。当然,采用这种方法意味着用户在按保存按钮之前不会收到关于验证的任何视觉提示。

在我们的表单中使用这种方法的时候也会有一些意想不到的副作用。首先是,当我们在标题输入框中输入时,标题不会再在屏幕顶部更新。只有在按保存按钮时,该值才会更新。其次,如果你添加一个或多个锻炼然后移除所有的锻炼,你还会看到一个验证信息出现。这是因为我们为这个控件设置了特殊的条件,导致它在常规验证流程之外触发。

所以,也许我们应该采取一种不同的方法。Angular 提供了一种更细粒度地控制验证流程的选项,即允许我们在控件级别上做这样的配置,使用ngModelOptions。例如,让我们从表单标签中移除ngFormOptions的赋值,并修改标题输入控件以添加ngModelOptions,如下所示:

<input type="text" name="title" class="form-control" #title="ngModel" id="workout-title" placeholder="What would be the workout title?" [(ngModel)]="workout.title" [ngModelOptions]="{updateOn: 'blur'}" minlength="5" maxlength="20" required>

当你在输入框里输入标题时,你会注意到直到你移开它(触发updateOn事件),标题才更新到屏幕上:

正如你记得的,将标题更新为每次按键都会造成默认选项。这是一个刻意的例子,但它说明了这些配置的差异如何工作。

你可能看不出在这里使用模糊设置的必要性。但是,在可能需要通过调用外部数据存储进行验证的情况下,这种方法可以帮助限制调用的次数。在我们实现自定义指令时,会进行这样的远程调用,这就是我们将在第四章中所做的事情,深入理解 Angular 指令。该指令将检查我们远程数据存储中已经存在的重复名称。因此,让我们将此配置从标题输入控件中移除,并放置在名称输入控件中,就像这样:

<input type="text" name="workoutName" #name="ngModel" class="form-control" id="workout-name" placeholder="Enter workout name. Must be unique." [(ngModel)]="workout.name" [ngModelOptions]="{updateOn: 'blur'}" required>

我们还可以在响应式表单中设置验证的时间选项。根据我们已经学到的关于响应式表单的知识,你可能不会感到惊讶,我们将在代码中应用这些设置而不是模板中。例如,要为表单组设置它们,使用以下语法:

new FormGroup(value, {updateOn: 'blur'}));

我们还可以将它们应用到单个表单控件上,在我们的锻炼表单的情况下就是这样。与锻炼表单一样,我们希望能够通过远程调用验证名称的唯一性。因此,我们希望以类似的方式限制验证检查。我们将通过在创建名称表单控件的代码中添加以下内容来实现该目的:

  buildExerciseForm() {
      this.exerciseForm = this.formBuilder.group({
          'name': [
            this.exercise.name,
 {
 updateOn: 'blur',
 validators: [Validators.required, AlphaNumericValidator.invalidAlphaNumeric]
 }
          ],
        ....
      });
  }

请注意,我们将设置和validators数组放在大括号对内的选项对象中。

总结

我们现在有了一个个人教练应用程序。将特定的7 分钟锻炼应用程序转换为通用的个人教练应用程序的过程帮助我们学习了许多新概念。本章开始时,我们定义了新应用程序的需求。然后,我们将模型设计为共享服务。

我们为个人教练应用程序定义了一些新视图和对应的路由。我们还使用了子路由和异步路由来将锻炼构建器从应用程序的其余部分分离出来。

然后,我们将注意力转到了锻炼建设上。本章的主要技术焦点之一是 Angular 表单。锻炼构建器使用了许多表单输入元素,并且我们使用了模板驱动和响应式表单实现了许多常见的表单场景。我们还深入探讨了 Angular 验证,并实现了自定义验证器。我们还介绍了配置运行验证的时间选项。

下一章将讨论客户端-服务器交互。我们创建的锻炼和练习需要被持久化。在下一章中,我们将构建一个持久化层,这将允许我们在服务器上保存锻炼和练习数据。

在我们结束这一章之前,这里有一个友好的提醒。如果你还没有完成个人教练的练习建设例程,请继续。您可以随时将您的实施与伴侣代码库中提供的内容进行比较。您还可以在原始实施中添加一些内容,例如练习图像的文件上传,一旦您对客户端-服务器交互更加熟悉,还可以远程检查确定 YouTube 视频是否真的存在。

第三章:支持服务器数据持久性

现在是时候与服务器进行交流了!创建锻炼、添加练习并保存下来,然后发现所有努力都白费,因为数据没有持久化存储。我们需要解决这个问题。

应用程序很少是自包含的。无论大小如何,任何消费者应用程序都有与其边界之外的元素交互的部分。对于基于 Web 的应用程序,交互主要是与服务器进行的。应用程序与服务器交互用于认证、授权、存储/检索数据、验证数据以及执行其他操作。

本章探讨了 Angular 提供的用于客户端-服务器交互的构造。在这个过程中,我们为个人教练 添加了一个持久化层,用于向后端服务器加载和保存数据。

本章我们讨论的主题包括以下内容:

  • 配置后端以持久化训练数据:我们设置一个 MongoLab 账户,并使用其数据 API 来访问和存储锻炼数据。

  • 了解 Angular HttpClientHttpClient允许我们通过 HTTP 与服务器进行交互。您将学习如何使用HttpClient 发起各种类型的GETPOSTPUTDELETE 请求。

  • 实现锻炼数据的加载和保存:我们使用HTTPClient加载并存储 MongoLab 数据库中的锻炼数据。

  • 我们可以使用 HttpClient 的 XMLHttpRequest 的两种方式:使用 Observables 或承诺。

  • 使用 RxJS 和 Observables:用于订阅和查询数据流。

  • 使用承诺:在本章中,我们将学习如何在 HTTP 调用和响应中使用承诺。

  • 跨域访问工作:由于我们要与不同域的 MongoLab 服务器进行交互,您将了解浏览器对跨域访问的限制。您还将学习 JSONP 和 CORS 如何帮助我们轻松进行跨域访问,以及 Angular 对 JSONP 的支持。

让我们开始吧。

Angular 和服务器交互

任何客户端-服务器交互通常归结为向服务器发送 HTTP 请求并从服务器接收响应。对于重型 JavaScript 应用程序,我们依赖 AJAX 请求/响应机制与服务器通信。为了支持基于 AJAX 的通信,Angular 提供了 Angular HttpClient 模块。在深入研究HttpClient 模块之前,我们需要设置存储数据并允许我们管理数据的服务器平台。

设置持久化存储

对于数据持久性,我们使用名为 MongoDB(www.mongodb.com/)的文档数据库,托管在 MongoLab(www.mlab.com/)上作为我们的数据存储。我们选择 MongoLab 的原因是它提供了与数据库直接交互的接口。这样可以节省我们设置服务器中间件来支持 MongoDB 交互的工作。

直接将数据存储/数据库暴露给客户端并不是一个好主意。但在这种情况下,由于我们的主要目的是学习 Angular 和客户端-服务器交互,我们冒险直接访问了 MongoLab 托管的 MongoDB 实例。还有一种新型应用程序是建立在无后端解决方案上的。在这样的设置中,前端开发人员构建应用程序而无需了解涉及的确切后端知识。服务器交互仅限于向后端发出 API 调用。如果你对这些无后端解决方案感兴趣,可以查看 nobackend.org/

我们的第一个任务是在 MongoLab 上配置一个账号并创建一个数据库:

  1. 前往 mlab.com 并按照网站上的说明注册一个 mLab 账号

  2. 账号配置好后,登录并点击主页上的“Create New”按钮创建一个新的 Mongo 数据库

  3. 在数据库创建页面上,你需要进行一些选择来配置数据库。参见下面的截图选择免费的数据库层级和其他选项:

  1. 创建数据库,记下你创建的数据库名称

  2. 一旦数据库被配置好,打开数据库并从“Collection”标签页向其中添加两个集合:

    • exercises: 这里存储了所有个人教练的锻炼

    • workouts: 这里存储了所有的个人教练锻炼

MongoDB 中的集合相当于数据库表。

MongoDB 属于一类称为文档数据库的数据库。这里的核心概念是文档、属性和它们的关联。与传统数据库不同,这里的模式不是固定的。我们在这本书中不会涵盖文档数据库的概念以及如何为基于文档的存储执行数据建模。个人教练的存储需求有限,我们使用上述的两个文档集合进行管理。甚至我们可能根本不会以真正意义上的文档数据库来使用它。

添加集合后,从“Users”标签页将自己添加为数据库用户。

下一步是确定 MongoLab 账号的 API 密钥。配置好的 API 密钥必须附加到每个发送给 MongoLab 的请求中。要获得 API 密钥,请执行以下步骤:

  1. 点击右上角的用户名(而不是账户名)以打开用户配置文件。

  2. 在名为“API Key”的部分,会显示当前的 API 密钥;复制它。同时,点击 API 密钥下面的按钮以启用数据 API 访问。默认情况下是禁用的。

数据存储模式已经完成。现在我们需要填充这些集合。

填充数据库

个人教练应用程序已经有一个预定义的锻炼和一个包含 12 个锻炼的列表。我们需要使用这些数据填充集合。

trainer/db文件夹的检查点 5.1 中打开seed.js。 它包含种子 JSON 脚本和有关如何向 MongoLab 数据库实例种子数据的详细说明。

种子后,数据库将在 workouts 收集中有一个锻炼和在 exercises 收集中有 12 个练习。 在 MongoLab 网站上验证这一点;收集应该显示如下:

现在一切都设置好了,让我们开始讨论HttpClient模块并为个人健身教练应用程序实现锻炼/运动持久性。

HTTPClient 模块的基础

HTTPClient模块的核心是HttpClient。 它使用XMLHttpRequest作为默认后端执行 HTTP 请求(JSONP 也可用,我们将在本章后面看到)。 它支持GETPOSTPUTDELETE等请求。 在本章中,我们将使用HttpClient进行所有这些类型的请求。 正如我们将看到的,HttpClient使得以最少的设置和复杂性很容易进行这些调用。 这些术语对于之前曾经使用过 Angular 或构建过与后端数据存储通信的 JavaScript 应用程序的人来说都不会感到意外。

但是,Angular 处理 HTTP 请求的方式发生了重大变化。 现在调用请求会返回 HTTP 响应的 Observable。 它这样做是使用 RxJS 库,这是一种广为人知的异步 Observable 模式的开源实现。

您可以在 GitHub 上找到 RxJS 项目github.com/Reactive-Extensions/RxJS。 该网站表明该项目正在与开源开发人员社区一起由微软积极开发。 我们将不会在这里详细介绍 RxJS 如何实现异步 Observable 模式,我们鼓励您访问该网站,以了解有关该模式以及 RxJS 如何实现它的更多信息。 Angular 使用的 RxJS 版本是 beta 5。

简而言之,使用 Observable 允许开发人员将应用程序中流动的数据视为信息流,应用程序可以随时候接并使用的信息流。 这些信息流随时间变化,这允许应用程序对这些变化做出反应。 Observable 的这种特性为函数式响应式编程FRP)提供了基础,从命令式转变为响应式从根本上改变了构建 Web 应用程序的范例。

RxJS库提供了运算符,允许您订阅和查询这些数据流。 此外,您可以轻松混合和组合它们,正如我们将在本章看到的。 Observable 的另一个优点是轻松取消或退订它们,从而可以无缝处理错误。

尽管仍然可以使用 promises,但 Angular 的默认方法使用 Observables。本章还将介绍 promises。

个人教练和服务器集成

正如前一节所述,客户端-服务器交互完全是异步的。当我们修改个人教练应用程序以从服务器加载数据时,这种模式变得不言自明。

在上一章中,练习和锻炼的初始集合是硬编码在WorkoutService实现中的。让我们先看看如何从服务器加载这些数据。

加载练习和锻炼数据

在本章的前面部分,我们使用一个数据形式在数据库中播种了数据,即seed.js文件。现在我们需要在视图中呈现这些数据。MongoLab 数据 API 将在这里帮助我们。

MongoLab 数据 API 使用 API 密钥来验证访问请求。对 MongoLab 端点发出的每个请求都需要一个查询字符串参数,apikey=<key>,其中key是我们在本章中之前提供的 API 密钥。请记住,密钥始终提供给用户并与其帐户关联。不要与他人共享您的 API 密钥。

该 API 遵循可预测的模式来查询和更新数据。对于任何 MongoDB 集合,典型的端点访问模式是以下之一(此处提供了基本 URL:api.mongolab.com/api/1/databases):

  • /<dbname>/collections/<name>?apiKey=<key>: 这包括以下请求:

    • GET: 此操作获取给定集合名称中的所有对象。

    • POST: 此操作将新对象添加到集合名称中。MongoLab 有一个_id属性,它可以唯一标识文档(对象)。如果在发布的数据中未提供,它将被自动生成。

  • /<dbname>/collections/<name>/<id>?apiKey=<key>: 这包括以下请求:

    • GET: 这会获取具有特定 ID 的特定文档/集合项(在集合名称中的 _id属性上进行匹配)。

    • PUT: 这会更新集合名称中的特定项目(id)。

    • DELETE: 这将从集合名称中删除具有特定 ID 的项目。

关于 Data API 接口的更多细节,请访问 MongoLab Data API 文档:docs.mlab.com/data-api

现在我们有能力开始实现练习/锻炼列表页面。

我们在本章中开始时使用的代码是checkpoint 4.6(文件夹:trainer)在本书的 GitHub 存储库中。它可以在 GitHub 上找到(github.com/chandermani/angular6byexample)。检查点在 GitHub 中作为分支进行实现。如果您没有使用 Git,请从以下 GitHub 位置下载检查点 4.6 的快照(ZIP 文件):github.com/chandermani/angular6byexample/tree/checkpoint4.6。第一次设置快照时,请参考trainer文件夹中的README.md文件。

从服务器加载练习和锻炼列表

为了从 MongoLab 数据库中提取练习和锻炼列表,我们必须重新编写我们的WorkoutService服务方法:getExercisesgetWorkouts。但在此之前,我们必须设置我们的服务以便与 Angular 的 HTTPClient 模块一起使用。

将 HTTPClient 模块和 RxJS 添加到我们的项目中

Angular 的 HTTPClient 模块已经包含在您已经安装的 Angular 包中。要使用它,我们需要将其导入到app.module.ts中,就像这样(确保导入跟在BrowserModule之后):

import { HttpClientModule } from '@angular/common/http';
. . . 
@NgModule({ 
  imports: [ 
    BrowserModule,
    HttpClientModule, 
. . . 
})

我们还需要一个外部第三方库:JavaScript 响应式扩展RxJS)。RxJS 实现了 Observable 模式,并且它与 HTTPClient 模块一起被 Angular 使用。它已经包含在我们项目中已经存在的 Angular 包中。

更新 workout-service 以使用 HTTPClient 模块和 RxJS

trainer/src/app/core中打开workout.service.ts。为了在WorkoutService中使用 HTTPClient 和 RxJS,我们需要将以下导入添加到该文件中:

import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
import { catchError } from 'rxjs/operators';

我们正在从 RxJS 导入HTTPClient模块以及Observable,还有一个额外的 RxJS 运算符:catchError。随着我们在这一节中的工作,我们将看到这个运算符是如何使用的。

在类定义中,添加以下属性,其中包括一个锻炼属性和用于设置我们 Mongo 数据库中集合的 URL 以及该数据库的密钥的属性,以及另一个属性:params,它将 API 密钥设置为 API 访问的查询字符串:

workout: WorkoutPlan; 
collectionsUrl = "https://api.mongolab.com/api/1/ databases/<dbname>/collections"; 
apiKey = <key> 
params = '?apiKey=' + this._apiKey; 

用数据库名称和我们在本章中提供的数据库的 API 密钥替换<dbname><key>标记。

接下来,使用以下代码将 HTTPClient 模块注入WorkoutServiceconstructor中:

constructor(public http: HttpClient) {
}

然后将getExercises()方法更改为以下内容:

getExercises() {
    return this.http.get<ExercisePlan>(this.collectionsUrl + '/exercises' + this.params)
        .pipe(catchError(WorkoutService.handleError));
}

如果您习惯于使用承诺进行异步数据操作,那么您在这里看到的将会有所不同。在这里发生的是http.get方法返回了来自 RxJS 库的 Observable。请注意,我们还设置了响应为<ExercisePlan>类型,以明确告诉我们的上游调用者我们的 HTTP GET 调用返回的 Observable 类型。

当使用HTTPClient模块的get方法时,默认响应是返回一个 Observable。然而,Observable 可以转换为一个 promise。正如我们将在本章后面看到的那样,也存在返回 JSONP 的选项。

在我们继续之前,在这段代码中还有一件事要注意。请注意,我们使用了一个管道方法来添加一个catchError操作符。这个操作符接受一个方法,handleError,用于处理失败的响应。handleError方法将失败的响应作为参数。我们将错误记录到控制台并使用Observable.throw将错误返回给消费者:

static handleError (error: Response) { 
    console.error(error); 
    return Observable.throw(error || 'Server error');
}

明确一点,这不是生产代码,而是让我们有机会展示如何编写上游代码来处理作为数据访问一部分生成的错误。

需要明确的是,在这个阶段,Observable 中没有任何数据流动,直到有订阅它的操作。如果您不小心为您的 Observables 添加订阅,这可能会在诸如添加和更新之类的操作中带来一个令人惊讶的时刻。

修改getWorkouts()以使用 HTTPClient 模块

检索运动的代码变化与检索锻炼的代码几乎完全相同:

getWorkouts() {
    return this.http.get<WorkoutPlan[]>(this.collectionsUrl + '/workouts' + this.params)
        .pipe(catchError(WorkoutService.handleError));
}

再次指定 Observable 的类型——在这种情况下是<WorkoutPlan[]>——我们使用pipe来添加一个catchError操作符。

现在getExercisesgetWorkouts方法已经更新,我们需要确保它们与上游调用者配合工作。

更新锻炼/运动列表页面

锻炼和运动列表页面(以及LeftNavExercises)调用model.ts中的getExercisesgetWorkouts方法。为了使这些与正在使用HTTPClient模块进行的远程调用能够正常工作,我们需要修改这些调用以订阅被HTTPClient模块返回的 Observable。因此,请更新exercises.component.ts中的ngOnInit方法中的代码如下:

  ngOnInit() {
    this.workoutService.getExercises()
    .subscribe(
        exercises => this.exerciseList = exercises,
        (err: any) => console.error
    );

我们的方法现在订阅由getExercises方法返回的 Observable;当响应到达时,它将结果赋给exerciseList。如果发生错误,它将分配给一个console.error调用,该调用在控制台中显示错误。所有这些现在都是使用 RxJS 和HTTPClient模块以异步方式处理的。

继续对workouts.component.tsleft-nav-exercises.component.ts中的ngOnInit方法进行类似的更改。

刷新锻炼/运动列表页面,锻炼和运动数据将从数据库服务器加载。

如果您在检索/显示数据时遇到困难,请查看 GitHub 仓库中检查点 5.1 中的完整实现。 请注意,在此检查点中,我们已经禁用了导航链接到锻炼和练习屏幕,因为我们仍然需要为它们添加 Observable 实现。 我们将在下一节中执行此操作。 还记得在运行Checkpoint 5.1的代码之前,请替换数据库名称和 API 密钥。 如果您没有使用 Git,请从以下 GitHub 位置下载Checkpoint 5.1的快照(ZIP 文件):github.com/chandermani/angular6byexample/tree/checkpoint5.1。 在首次设置快照时,请参考trainer文件夹中的README.md文件。

看起来很不错,列表加载正常。 嗯,几乎没有问题! 在锻炼列表页面有一个小故障。 如果我们仔细观察任何列表项(实际上只有一项),我们可以很容易地发现它:

锻炼时长的计算不再有效! 这可能是原因是什么? 我们需要回顾一下这些计算是如何实现的。 WorkoutPlan服务(在model.ts中)定义了一个totalWorkoutDuration方法,用于执行这个计算。

工作数组的差异在于绑定到视图上。在前一章中,我们使用WorkoutPlan服务创建的模型对象数组创建了该数组。 但是现在,因为我们从服务器检索数据,我们将一个简单的 JavaScript 对象数组绑定到视图,这显然没有计算逻辑。

我们可以通过将服务器响应映射到我们的模型类对象并将它们返回给任何上游调用者来解决这个问题。

将服务器数据映射到应用程序模型

如果模型和服务器存储定义匹配,那么将服务器数据映射到我们的模型,反之亦然可能是不必要的。 如果我们看一下Exercise模型类和我们在 MongoLab 中为练习添加的种子数据,我们会发现它们是匹配的,因此映射变得不必要。

如果:

  • 我们的模型定义了任何方法

  • 存储的模型与其在代码中的表示不同

  • 相同的模型类用于表示来自不同来源的数据(这可能发生在混搭数据的情况下,我们从不同的来源拉取数据)

WorkoutPlan服务是模型表示和其存储之间阻抗不匹配的一个典型例子。 查看下面的屏幕截图以了解这些差异:

模型和服务器数据之间的两个主要差异如下:

  • 该模型定义了totalWorkoutDuration方法。

  • exercises数组的表示方式也不同。 模型的exercises数组包含完整的Exercise对象,而服务器数据只存储了练习标识符或名称。

这明显意味着加载和保存锻炼需要模型映射。

我们将通过添加另一个操作符来转换 Observable 响应对象的方式来实现这一点。到目前为止,我们只返回了一个普通的 JavaScript 对象作为响应。很好的一点是,我们用于添加错误处理的pipe方法也允许我们添加额外的操作符,我们可以用它们将 JavaScript 对象转换为我们模型中的WorkoutPlan类型。

让我们将workout-service.ts文件中的getWorkouts方法重写为以下内容:

    getWorkouts(): Observable<WorkoutPlan[]> {
        return this.http.get<WorkoutPlan[]>(this.collectionsUrl + '/workouts' + this.params)
            .pipe(
                map((workouts: Array<any>) => {
                  const result: Array<WorkoutPlan> = [];
                  if (workouts) {
                      workouts.forEach((workout) => {
                          result.push(
                              new WorkoutPlan(
                                  workout.name,
                                  workout.title,
                                  workout.restBetweenExercise,
                                  workout.exercises,
                                  workout.description
                              ));
                      });
                  }
                  return result;
                }),
                catchError(this.handleError<WorkoutPlan[]>('getWorkouts', []))
            );
    }

我们添加了一个map操作符,将这个 Observable 转换为由WorkoutPlan对象组成的 Observable。每个WorkoutPlan对象(目前我们只有一个)将拥有我们所需的totalWorkoutDuration方法。

查看代码,你会发现我们是对 JSON 结果的 HTTPClient 响应进行操作,这就是我们使用<any>类型的原因。然后,我们创建了一个WorkoutPlans的类型数组,并使用箭头函数forEach依次对第一个数组进行迭代,将每个 JavaScript 对象赋给一个WorkoutPlan对象。

我们将这些映射的结果返回给订阅它们的调用者,例如workouts.component.ts。我们还使用新的handleError方法更新了catchError操作符,你可以在检查点 5.2 中找到。调用者不需要对他们用来订阅我们的 workouts Observable 的代码进行任何更改。相反,在应用程序的一个地方可以进行模型映射,然后在整个应用程序中使用它。

如果重新运行应用程序,你会发现总的秒数现在可以正确显示了:

GitHub 仓库的检查点 5.2 包含了我们到目前为止所涵盖的工作实现。GitHub 分支是checkpoint5.2(文件夹:trainer)。

从服务器加载练习和锻炼数据

正如我们之前修复了WorkoutService中的getWorkouts实现一样,我们可以为与练习和锻炼相关的其他获取操作实现。从trainer/src/app/core文件夹中的workout.service.ts文件中拷贝WorkoutServicegetExercisegetWorkout方法的服务实现到检查点 5.2。

getWorkoutgetExercise方法使用锻炼/练习的名称来检索结果。每个 MongoLab 集合项都有一个唯一标识这个项/实体的_id属性。对于我们的ExerciseWorkoutPlan对象,我们使用练习的名称来进行唯一标识。因此,每个对象的name_id属性总是匹配的。

在这一点上,我们需要在workout.service.ts中添加另一个导入:

import { forkJoin } from 'rxjs/observable/forkJoin';

这个导入引入了forkJoin操作符,我们将在稍后讨论它。

特别注意getWorkout方法的实现,因为由于模型和数据存储格式不匹配,这个方法涉及大量的数据转换。现在getWorkout方法的样子如下:

    getWorkout(workoutName: string): Observable<WorkoutPlan> {
      return forkJoin (
          this.http.get(this.collectionsUrl + '/exercises' + this.params),
          this.http.get(this.collectionsUrl + '/workouts/' + workoutName + this.params))
          .pipe(
               map(
                  (data: any) => {
                      const allExercises = data[0];
                      const workout = new WorkoutPlan(
                          data[1].name,
                          data[1].title,
                          data[1].restBetweenExercise,
                          data[1].exercises,
                          data[1].description
                      );
                      workout.exercises.forEach(
                          (exercisePlan: any) => exercisePlan.exercise = allExercises.find(
                              (x: any) => x.name === exercisePlan.name
                          )
                      );
                      return workout;
                  }
              ),
              catchError(this.handleError<WorkoutPlan>(`getWorkout id=${workoutName}`))
        );
      }

getWorkout中发生了很多需要我们理解的事情。

getWorkout方法使用 Observable 及其forkJoin操作符来返回两个 Observable 对象:一个是检索到的Workout,另一个是检索到的所有Exercises的列表。forkJoin操作符的有趣之处在于它不仅允许我们返回多个 Observable 流,而且还会等待两个 Observable 流都检索到其数据后才进一步处理结果。换句话说,它使我们能够从多个并发的 HTTP 请求中获取响应,然后对组合的结果进行操作。

一旦我们有了Workout详情和完整的练习列表,我们将结果pipemap操作符(正如我们之前在Workouts列表的代码中看到的),我们用它来将锻炼的exercises数组改为正确的Exercise类对象。我们通过在从服务器返回的workout.exercises数组中搜索allExercises Observable 的练习名称,然后将匹配的练习分配给锻炼服务的数组来完成这个操作。最终结果是我们有了一个正确设置了exercises数组的完整的WorkoutPlan对象。

这些WorkoutService的更改需要上游调用方也进行修复。我们已经修复了LeftNavExercisesExercises组件中的练习列表,以及Workouts组件中的锻炼。现在让我们按照类似的方式来修复WorkoutExercise组件。Workout服务中的getWorkoutgetExercise方法不是直接由这些组件调用的,而是由构建器服务调用的。因此,我们将不得不连同WorkoutExercise组件以及我们为这些组件添加的两个解析器—WorkoutResolverExerciseResolver—一起修复构建器服务。

修复构建器服务

现在我们已经设置好WorkoutService以从远程数据存储中检索锻炼,我们必须修改WorkoutBuilderService以便能够将该锻炼作为 Observable 检索。提取Workout详情的方法是startBuilding。为了做到这一点,我们将当前的startBuilding方法分成两个方法,一个是为新的锻炼,一个是对我们从服务器检索到的旧的锻炼。以下是新锻炼的代码:

    startBuildingNew() {
      const exerciseArray: ExercisePlan[] = [];
      this.buildingWorkout = new WorkoutPlan('', '', 30, exerciseArray);
      this.newWorkout = true;
      return this.buildingWorkout;
    }

对于旧的锻炼,我们添加以下代码:

    startBuildingExisting(name: string) {
      this.newWorkout = false;
      return this.workoutService.getWorkout(name);
    }

我们让您在ExerciseBuilderService中做同样的修复。

更新解析器

随着我们开始使用 Observable 类型来访问数据,我们需要对通往锻炼和练习屏幕的路由创建的解析器进行一些调整。我们首先处理 workout 文件夹中的 workout-resolver.ts 中的 WorkoutResolver

首先,从 RxJs 中添加以下导入:

import { Observable } from 'rxjs/Observable';
import { of } from 'rxjs/observable/of';
import { map, catchError } from 'rxjs/operators';

接下来,按以下方式更新 resolve 方法:

  resolve(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<WorkoutPlan> {
    const workoutName = route.paramMap.get('id');

    if (!workoutName) {
        return this.workoutBuilderService.startBuildingNew();
    } else {
        return this.workoutBuilderService.startBuildingExisting(workoutName)
        .pipe(
          map(workout => {
            if (workout) {
              this.workoutBuilderService.buildingWorkout = workout;
              return workout;
            } else {
              this.router.navigate(['/builder/workouts']);
              return null;
            }
          }),
          catchError(error => {
            console.log('An error occurred!');
            this.router.navigate(['/builder/workouts']);
            return of(null);
          })
        );
    }

如您所见,我们将新锻炼的行为(在 URL 参数中未传递锻炼名称的情况)和现有锻炼的行为分开了。在前一种情况下,我们调用 workoutBuilderService.startBuildingExisting,它将返回一个新的 WorkoutPlan。在后一种情况下,我们调用 workoutBuilderService.startBuildingExisting 并对结果进行处理,然后将其映射为返回 workout,除非找不到,此时我们会将用户重新路由到 Workouts 屏幕。

修复锻炼和练习组件

一旦我们修复了 WorkoutBuilderServiceWorkoutResolver,实际上在 WorkoutComponent 中不需要进一步的修复。处理 Observable 的所有工作都已经在更下游完成,而在此阶段我们只需要订阅路由数据,获取锻炼,就像我们之前一直在做的那样:

  ngOnInit() {
      this.sub = this.route.data
          .subscribe(
            (data: { workout: WorkoutPlan }) => {
              this.workout = data.workout;
            }
          );
  }

为了测试实现,在 workouts.component.tsonSelect 方法中取消注释以下突出显示的代码:

  onSelect(workout: WorkoutPlan) {
      this.router.navigate( ['./builder/workout', workout.name] );
  }

然后在 builder/workouts/ 显示的锻炼列表中点击任何现有锻炼,比如 7 分钟锻炼。锻炼数据应成功加载。

ExerciseBuilderServiceExerciseResolver 也需要修复。检查点 5.2 包含这些修复。您可以复制这些文件,或自己进行修复并比较实现。不要忘记在 exercises.component.ts 中的 onSelect 方法中取消注释代码。

GitHub 代码库中的 检查点 5.2 包含迄今为止我们所涵盖内容的工作实现。如果您不使用 Git,请从以下 GitHub 位置下载 检查点 5.2 的快照(ZIP 文件):github.com/chandermani/angular6byexample/tree/checkpoint5.2。在首次设置快照时,请参阅 trainer 文件夹中的 README.md 文件。

现在是时候修复、创建和更新练习和锻炼的情景了。

对练习/锻炼进行 CRUD

在创建,读取,更新和删除(CRUD)操作时,所有保存、更新和删除方法都需要转换为 Observable 模式。

在本章前面,我们详细描述了 MongoLab 集合中进行 CRUD 操作的端点访问模式。返回到 加载练习和锻炼数据 部分,重新查看访问模式。我们现在需要这个,因为我们计划创建/更新锻炼。

在实施之前,了解 MongoLab 如何识别集合项以及我们的 ID 生成策略是很重要的。MongoDB 中的每个集合项都是使用_id属性在集合中唯一标识的。在创建新项时,我们可以提供 ID,也可以让服务器自动生成 ID。一旦设置了_id,就无法更改。对于我们的模型,我们将锻炼/运动的名称属性作为唯一的 ID,并将该名称复制到_id字段中(因此,_id没有自动生成)。另外,记住我们的模型类不包含这个_id字段;必须在第一次保存记录之前创建它。

让我们先解决锻炼创建的场景。

创建新的锻炼

采用自下而上的方法,需要修复的第一件事是WorkoutService。根据以下代码更新addWorkout方法:

    addWorkout(workout: WorkoutPlan) {
      const workoutExercises: any = [];
      workout.exercises.forEach(
          (exercisePlan: any) => {
              workoutExercises.push({name: exercisePlan.exercise.name, duration: exercisePlan.duration});
          }
      );

      const body = {
          '_id': workout.name,
          'exercises': workoutExercises,
          'name': workout.name,
          'title': workout.title,
          'description': workout.description,
          'restBetweenExercise': workout.restBetweenExercise
      };

      return this.http.post(this.collectionsUrl + '/workouts' + this.params, body)
        .pipe(
          catchError(this.handleError<WorkoutPlan>())
        );
    }

getWorkout中,我们必须将数据从服务器模型映射到我们的客户端模型;在这里需要做相反的操作。首先,我们为锻炼创建一个新的数组,workoutExercises,然后将更紧凑的版本的锻炼添加到该数组中,以便更好地存储在服务器上。我们只想在服务器上的 exercises 数组中存储锻炼名称和持续时间(该数组是any类型,因为在其紧凑格式中,它不符合ExercisePlan类型)。

接下来,我们通过将这些更改映射到 JSON 对象来设置我们的 post 的主体。请注意,作为构造此对象的一部分,我们将_id属性设置为锻炼的名称,以在锻炼集合的数据库中唯一标识它。

在 MongoDB 中,将锻炼/运动的名称作为记录标识符(或id)的简单方法将无法在任何体量较大的应用程序中使用。请记住,我们正在创建一个可以同时被许多用户访问的基于 Web 的应用程序。由于存在两个用户可能使用相同的锻炼/运动名称的可能性,所以我们需要一个强大的机制来确保名称不重复。MongoLab REST API 的另一个问题是,如果有一个具有相同id字段的重复POST请求,其中一个将创建一个新文档,而另一个将更新它,而不是第二个失败。这意味着在客户端对id字段进行任何重复检查仍然无法防止数据丢失。在这种情况下,分配id值的自动生成是可取的。在标准情况下,在创建实体时,唯一的 ID 生成是在服务器上完成的(主要是由数据库完成)。当实体创建时,响应然后包含生成的 ID。在这种情况下,在我们将数据返回给调用代码之前,我们需要更新模型对象。

最后,我们调用HTTPClient模块的post方法,传递要连接的 URL,额外的查询字符串参数(apiKey)和我们要发送的数据。

最后一条返回语句应该很熟悉,因为我们使用 Observables 返回锻炼对象作为 Observable 解析的一部分。您需要确保在 Observable 链中添加.subscribe以使其起作用。我们将很快通过向WorkoutComponentsave方法添加订阅来实现这一点。

更新锻炼

为什么不尝试实现更新操作呢?updateWorkout方法可以以同样的方式修复,唯一的区别是需要HTTPClient模块的put方法。

    updateWorkout(workout: WorkoutPlan) {
      const workoutExercises: any = [];
      workout.exercises.forEach(
          (exercisePlan: any) => {
              workoutExercises.push({name: exercisePlan.exercise.name, duration: exercisePlan.duration});
          }
      );

      const body = {
          '_id': workout.name,
          'exercises': workoutExercises,
          'name': workout.name,
          'title': workout.title,
          'description': workout.description,
          'restBetweenExercise': workout.restBetweenExercise
      };

      return this.http.put(this.collectionsUrl + '/workouts/' + workout.name + this.params, body)
        .pipe(
          catchError(this.handleError<WorkoutPlan>())
        );
    }

前面的请求 URL 现在包含了一个额外的片段(workout.name),表示需要更新的集合项的标识符。

MongoLab 的PUTAPI 请求会在集合中找不到传递的文档时创建请求体中的文档。在进行PUT请求时,请确保原始记录存在。我们可以通过首先对同一文档进行GET请求并确认我们获取到一个文档来实现这一点。将这留给您来实现。

删除锻炼

需要修复的最后一个操作是删除锻炼。这里是一个简单的实现,我们调用HTTPClient模块的delete方法来删除特定 URL 引用的锻炼:

    deleteWorkout(workoutName: string) {
        return this.http.delete(this.collectionsUrl + '/workouts/' + workoutName + this.params)
          .pipe(
            catchError(this.handleError<WorkoutPlan>())
          );
    }

修复上游代码

现在是时候修复WorkoutBuilderServiceWorkout组件了。WorkoutBuilderServicesave方法现在如下所示:

    save() {
      const workout = this.newWorkout ?
          this.workoutService.addWorkout(this.buildingWorkout) :
          this.workoutService.updateWorkout(this.buildingWorkout);
      this.newWorkout = false;
      return workout;
   }

大部分都和之前一样,因为它确实是一样的!我们不必更新这段代码,因为我们有效地将与外部服务器的交互隔离在我们的WorkoutService组件中。

最后,Workout组件的保存代码如下所示:

  save(formWorkout: any) {
    this.submitted = true;
    if (!formWorkout.valid) { return; }
    this.workoutBuilderService.save().subscribe(
      success => this.router.navigate(['/builder/workouts']),
      err => console.error(err)
    );
  }

在这里,我们进行了更改,以便现在订阅保存。正如您可能还记得我们之前的讨论,subscribe使 Observable 活跃,以便我们可以完成保存。

就是这样!现在我们可以创建新的锻炼和更新现有的锻炼(删除锻炼的完成留给您)。这并不是太困难!

让我们试一下。打开新的Workout Builder页面,创建一个锻炼,并保存它。还尝试编辑一个现有的锻炼。这两种情况应该可以无缝运行。

如果您在运行本地副本时遇到问题,请查看检查点 5.3以获取最新的实现。如果您没有使用 Git,可以从以下 GitHub 位置下载检查点 5.3的快照(ZIP 文件):github.com/chandermani/angular6byexample/tree/checkpoint5.3。初次设置快照时,请参考trainer文件夹中的README.md文件。

在我们进行POSTPUT请求保存数据时,网络端会发生一些有趣的事情。打开浏览器的网络日志控制台(F12),看看发出的请求。日志看起来类似于以下内容:

网络日志

在进行实际的POSTPUT之前,会对相同的端点进行一个OPTIONS请求。在这里我们见到的行为被称为预检请求。这是因为我们正在对api.mongolab.com进行跨域请求。

使用 promises 进行 HTTP 请求

本章的大部分内容都集中在 Angular HTTPClient如何将 Observables 作为XMLHttpRequests的默认方式。这代表着与过去的工作方式相比存在着重大的变化。许多开发人员习惯于使用 promises 进行异步 HTTP 请求。在这种情况下,Angular 继续支持 promises,但不再是默认选择。开发人员必须选择 promises 才能在XMLHttpRequest中使用它们。

例如,如果我们想要在WorkoutServicegetExercises方法中使用 promises,我们将不得不重新构建命令如下:

    getExercises(): Promise<Exercise[]> {
        return this.http.get<Exercise[]>(this.collectionsUrl + '/exercises' + this.params)
        .toPromise()
        .then(res => res)
        .catch(err => {
            return Promise.reject(this.handleError('getExercises', []));
        });
    }

要将此方法转换为使用 promises,我们只需在方法链中添加.toPromise(),一个成功的参数then,以及一个指向现有handleError方法的catchPromise.reject

对于上游组件,我们只需将返回值的处理方式切换为 promises 而不是 Observables。因此,要在这种情况下使用 promises,我们需要更改Exercises.component.tsLeftNavExercises.component.ts中的代码,首先添加一个用于错误消息的新属性(关于如何在屏幕上显示错误消息,我们将留给你来完成):

errorMessage: any;

然后将调用WorkoutServicengOnInit方法更改为以下内容:

  ngOnInit() {
    this.workoutService.getExercises()
 .then(exerciseList => this.exerciseList = exerciseList,
 error => this.errorMessage = <any>error
    );
  }  

当然,我们可以在这个简单的例子中很容易地用 promises 代替 Observables,这并不意味着它们本质上是相同的。一个then promise 会返回另一个 promise,这意味着你可以创建连续链接的 promises。而在 Observable 的情况下,订阅本质上是终点,不能在此之后再进行映射或订阅。

如果你熟悉 promises,在这个阶段可能会倾向于坚持使用它们,而不去尝试使用 Observables。毕竟,在本章中我们使用 Observables 所做的大部分工作也可以用 promises 实现。例如,我们使用 Observable 的forkJoin操作符对getWorkouts的两个 Observable 流进行映射,与之相对应,在 promise 中也可以使用q,all函数来执行相同的操作。

然而,如果你采用这种方式,你实际上是在低估自己。Observables 打开了一种令人兴奋的新的网页开发方式,这种方式称为函数式响应式编程。它们涉及到了一种根本性的思维转变,将应用程序的数据视为一种不断变化的信息流,应用程序对其做出反应和响应。这种转变使得应用程序可以以不同的架构构建,使其更快速和更具弹性。在 Angular 中,Observables 是这些方面的核心,如事件发射器和新版本的NgModel

尽管 promises 是你工具包中的一个有用工具,但我们鼓励你在使用 Angular 开发时调查 Observables。它们是 Angular 前瞻性哲学的一部分,并将对未来的应用程序和技能集具有用处。

查看checkpoint 5.3文件,其中包含我们之前涵盖的与 promises 相关的代码的最新实现。如果你不使用 Git,请从以下 GitHub 位置下载 Checkpoint 5.3 的快照(ZIP 文件):github.com/chandermani/angular6byexample/tree/checkpoint5.3。首次设置快照时,请参考trainer文件夹中的README.md文件。请注意,在下一节中,我们将重新使用 Observables 来编写这段代码。这段代码可以在checkpoint 5.4文件中找到。

异步管道

正如我们在本章涵盖的许多数据操作中所看到的,有一个相当常见的模式一再重复。当一个 Observable 从 HTTP 请求中返回时,我们将响应转换为 JSON 并订阅它。订阅然后将 Observable 输出绑定到 UI 元素。如果我们能消除这种重复的编码并用更简单的方法来完成我们想要做的事情,那不是很好吗?

毫不奇怪,Angular 为我们提供了恰当的方法。它被称为async 管道,可以像其他管道一样用于绑定到屏幕上的元素。但是,异步管道是比其他管道更强大的机制。它以 Observable 或 promise 作为输入,并自动订阅它。它还处理 Observable 的订阅解除,而无需任何进一步的代码。

让我们看一个在我们应用程序中的例子。让我们回到我们在之前关于 promises 讨论的部分中刚才看到的LeftNavExercises组件。请注意,我们已将该组件和Exercises组件从 promises 转换回使用 Observables。

查看checkpoint 5.4文件,其中包含将该代码再次转换为使用 Observables 的最新实现。如果你不使用 Git,请从以下 GitHub 位置下载 Checkpoint 5.4 的快照(ZIP 文件):github.com/chandermani/angular6byexample/tree/checkpoint5.4。首次设置快照时,请参考trainer文件夹中的README.md文件。

然后在LeftNavExercises中做以下更改。首先,从 RxJs 导入 Observable:

import { Observable } from 'rxjs/Observable';

然后将exerciseList从一组练习更改为相同类型的 Observable:

public exerciseList:Observable<Exercise[]>;

然后修改获取练习的WorkoutService调用以消除订阅:

this.exerciseList = this.workoutService.getExercises();

最后,打开 left-nav-exercises.component.html 并在 *ngFor 循环中添加 async 管道,如下所示:

<div *ngFor="let exercise of exerciseList|async|orderBy:'title'">

刷新页面,您仍将看到显示练习列表。但这次,我们使用了 async 管道来消除设置订阅到 Observable 的需求。非常酷!这是 Angular 添加的一个很好的便利,因为我们在这一章节中花费时间理解 Observables 如何使用订阅,现在我们清楚地了解了 async 管道在幕后为我们处理的内容。

我们将让您在 Exercises 组件中实现相同的更改。

了解 HTTP 请求的跨域行为以及 Angular 提供的构造函数以进行跨域请求非常重要。

跨域访问和 Angular

跨域请求是针对不同域中的资源的请求。此类请求,当由 JavaScript 发起时,浏览器会施加一些限制;这些被称为 同源策略 限制。这种限制会阻止浏览器向不同于脚本原始来源的域发出 AJAX 请求。源匹配严格基于协议、主机和端口的组合。

对于我们自己的应用程序,对 https://api.mongolab.com 的调用是跨域调用,因为我们的源代码托管在不同域(很可能是类似 http://localhost/.... 的域)。

有一些变通方法和一些标准可以帮助放松/控制跨域访问。我们将探索其中两种最常用的技术。它们是:

  • 填充式 JSONJSONP

  • 跨域资源共享CORS

绕过同源策略的一种常见方法是使用 JSONP 技术。

使用 JSONP 进行跨域请求

远程调用的 JSONP 机制依赖于浏览器可以执行来自任何域的 JavaScript 文件,无论源的来源是什么,只要脚本是通过 <script> 标签包含的。

在 JSONP 中,不是直接向服务器发出请求,而是生成一个动态的 <script> 标签,并将 src 属性设置为需要调用的服务器端点。当将这个 <script> 标签追加到浏览器的 DOM 中时,将会向目标服务器发出请求。

然后服务器需要以特定格式发送响应,将响应内容包裹在函数调用代码中(在响应数据周围添加额外填充给这种技术命名为 JSONP)。

Angular JSONP 服务隐藏了这种复杂性,并提供了一个易于使用的 API 来进行 JSONP 请求。StackBlitz 链接,stackblitz.com/edit/angular-nxeuxo,突出显示了如何进行 JSONP 请求。它使用了 IEX Free Stock API (iextrading.com/developer/) 来获取任何股票符号的报价。

Angular JSONP 服务仅支持 HTTP 的GET请求。使用任何其他 HTTP 请求,比如POSTPUT,将产生错误。

如果你看看 StackBlitz 项目,你会看到我们在整本书中遵循的组件创建的熟悉模式。我们不会再次介绍这个模式,但会强调一些与使用 Angular JSONP 服务相关的细节。

首先,除了FormsModuleHttpClientModule的导入之外,你需要在app.module.ts中导入HttpClientJsonpModule如下所示:

. . . 
import { HttpClientModule, HttpClientJsonpModule } from '@angular/common/http';
import { FormsModule } from '@angular/forms';
. . . 
@NgModule({
. . . 
  imports: [
    BrowserModule,
    FormsModule,
    HttpClientModule,
 HttpClientJsonpModule
  ],
. . . 
}) 

接下来,我们需要在get-quote.component.ts中添加以下导入:

import { Component }from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
import { map } from 'rxjs/operators';

我们导入了HttpClient,其中包含我们将要使用的JSONP方法,以及 RxJS 的Observablemap运算符。这些导入对你来说应该是很熟悉的,因为我们在这一章中已经基于它们构建了一些内容。

在处理 Angular JSONP 时,重要的是要理解,默认情况下,它返回使用 RxJS 的 Observables。这意味着我们将不得不遵循订阅这些 Observables 的模式,并使用 RxJS 运算符来操纵结果。我们也可以使用异步管道来简化这些操作。

然后我们将HttpClient注入到构造函数中:

constructor(public http: HttpClient) {}

接下来,我们添加几个我们将在 JSONP 调用中使用的变量:

   symbol: string;
   quote: Observable<string>;
   url: string = 'https://api.iextrading.com/1.0/stock/';

symbol变量将保存用户提供的搜索字符串。quote变量将用于我们的模板来显示 JSONP 调用返回的值。而url变量是我们将要向服务进行的调用的基本 URL。

现在我们已经准备好我们的getQuote方法了。让我们来看一下:

   getQuote (){ 
      let searchUrl = `${this.url}${this.symbol}/quote`;
      this.quote = this.http.jsonp(searchUrl, 'callback')
          .pipe(
          map( (res: string) => res)
        ); 
    }; 

首先,我们通过将urlsymbol连接起来,并添加/quote来构建我们的searchUrl。最后部分的quote是我们需要传递给报价服务以返回股票报价的内容。

然后,我们使用HTTPClientjsonp方法来执行对报价服务的远程调用。我们将searchUrl作为该方法的第一个参数传递,将字符串'callback'作为我们的第二个参数。后者被 Angular 用于通过向searchUrl增加额外的查询字符串参数callback。在内部,Angular JSONP 服务会生成一个动态的script标签和一个回调函数并进行远程请求。

打开 StackBlitz 并输入诸如GOOGMSFTFB等股票代码以查看股票报价服务的操作。浏览器的网络日志请求如下所示:

https://api.iextrading.com/1.0/stock/MSFT/quote?callback=ng_jsonp_callback_0

这里的ng_jsonp_callback_0是动态生成的函数。响应如下所示:

typeof ng_jsonp_callback_0 === 'function' && ng_jsonp_callback_0({"quote"::{"symbol":"MSFT"..});

响应被包裹在回调函数中。Angular 解析并评估此响应,导致调用__ng_jsonp__.__req1回调函数。然后,此函数内部将数据路由到我们的函数回调。

我们希望这解释了 JSONP 的工作原理和 JSONP 请求的基本机制。然而,JSONP 有其局限性:

  • 首先,我们只能进行GET请求(因为这些请求是由脚本标签生成的)

  • 其次,服务器也需要实现涉及将响应包装在函数回调中的解决方案的部分

  • 第三,由于 JSONP 依赖于动态脚本生成和注入,总是存在安全风险。

  • 第四,错误处理也不可靠,因为不容易确定为什么脚本加载失败

最终,我们必须认识到 JSONP 更像是一种变通方法而不是解决方案。随着我们迈向 Web 2.0,混搭变得普遍,越来越多的服务提供商决定通过网络公开他们的 API,一个更好的解决方案/标准出现了:CORS。

跨域资源共享

跨域资源共享CORS)提供了一个机制,用于支持跨站点访问控制,允许浏览器从脚本中进行跨域请求。通过这个标准,消费端应用程序(如私人教练)被允许进行某些类型的请求,称为简单请求,而不需要任何特殊设置要求。这些简单请求限定为GETPOST(特定 MIME 类型)和HEAD。所有其他类型的请求被称为复杂请求

对于复杂请求,CORS 规定请求应该由一个 HTTPOPTIONS请求(也称为预检请求)开头,询问服务器允许用于跨域请求的 HTTP 方法。只有在成功探测后才进行实际请求。

您可以在 MDN 文档中了解更多关于 CORS 的信息,链接在developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS

CORS 最好的一点是,客户端无需像 JSONP 一样进行任何调整。整个握手机制对调用代码透明,我们的 AngularHTTPClient调用无障碍运行。

CORS 需要在服务器上进行配置,MongoLab 服务器已经配置为允许跨域请求。因此之前对 MongoLab 进行的POSTPUT请求用于添加和更新练习锻炼文档都引起了预检请求OPTIONS

处理未找到的锻炼

您可能还记得在第二章中,个人教练,我们创建了WorkoutResolver,不仅在导航到WorkoutComponent之前检索锻炼,还防止在路由参数中存在不存在的锻炼时导航到该组件。现在,我们想通过在锻炼屏幕上显示一个错误消息来增强此功能,指示找不到锻炼。

为了做到这一点,我们将修改WorkoutResolver,以便在找不到锻炼时重新路由到锻炼页面。首先,在WorkoutBuilderRoutingModule中添加以下子路由(确保在现有的锻炼路由之前):

children: [ 
  {path: '', pathMatch: 'full', redirectTo: 'workouts'}, 
 {path: 'workouts/workout-not-found', component: WorkoutsComponent'}, 
  {path: 'workouts', component: 'WorkoutsComponent'}, 
   *** other child routes *** 
  }, 
]

接下来,修改WorkoutResolver中的resolve方法,在找不到锻炼时重定向到此路由:

resolve(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<WorkoutPlan> {
    const workoutName = route.paramMap.get('id');

    if (!workoutName) {
        return this.workoutBuilderService.startBuildingNew();
    } else {
        this.isExistingWorkout = true;
        return this.workoutBuilderService.startBuildingExisting(workoutName)
        .pipe(
          map(workout => {
            if (workout) {
              this.workoutBuilderService.buildingWorkout = workout;
              return workout;
            } else {
              this.router.navigate(['/builder/workouts/workout-not-found']);
              return null;
            }
          }),
          catchError(error => {
            console.log('An error occurred!');
            this.router.navigate(['/builder/workouts']);
            return of(null);
          })
        );
    }

然后在Workouts组件的变量中添加一个名为notFound的布尔值,其值设为false

  workoutList: Array<WorkoutPlan> = [];
  public notFound = false;

并且,在该组件的ngOnInit方法中,添加以下代码来检查workout-not-found路径并将notFound的值设为true

ngOnInit() {
  if(this.route.snapshot.url[1] && this.route.snapshot.url[1].path === 
  'workout-not-found') this.notFound = true; 
  this.subscription = this.workoutService.getWorkouts() 
  .subscribe( 
    workoutList => this.workoutList = workoutList, 
    (err:any) => console.error(err) 
  ); 
}

最后,在Workouts.component.html模板中,在锻炼列表上面添加以下div标签,如果notFound被设置为true则显示:

<div *ngIf="notFound" class="not-found-msgbox">Could not load the specific workout!</div>

如果在将用户返回到Workouts页面时,在路径中找到workout-not-found,那么屏幕上将显示以下消息:

我们已经为锻炼构建器页面修复了路由故障,但是还未修复锻炼构建器页面。再次,我们将留给你自己去修复它。

另一个重要的(尚未实现的)工作是修复7 分钟锻炼,因为它目前只适用于一个锻炼常规。

修复 7 分钟锻炼应用程序

目前7 分钟锻炼(或锻炼运行器)程序只能播放一个特定的锻炼。它需要修复以支持使用个人健身教练构建的任何锻炼计划的执行。显然需要将这两种解决方案集成起来。我们已经做好了开始整合的准备工作。我们已经有了共享模型服务和WorkoutService来加载数据,这已经足够让我们开始了。

修复7 分钟锻炼并将其转换为通用的锻炼运行器大致涉及以下步骤:

  • 删除7 分钟锻炼中使用的硬编码锻炼和练习。

  • 修复起始页面以显示所有可用的锻炼,并允许用户选择要进行的锻炼。

  • 修复锻炼的路由配置,将所选的锻炼名称作为路由参数传递到锻炼页面。

  • 使用WorkoutService加载所选的锻炼数据并开始锻炼。

  • 当然,我们需要重新命名应用程序的7 分钟锻炼部分;这个名字现在是一个误称。我认为整个应用程序可以称为个人健身教练。我们也可以从视图中删除对7 分钟锻炼的所有引用。

一个非常好的练习!这就是为什么我们不会为你讲解解决方案。相反,前进并实施解决方案。将你的实现与检查点 5.4提供的实现进行比较。

是时候结束本章并总结你所学到的知识了。

总结

我们现在有一个可以完成很多任务的应用程序。它可以运行锻炼,加载锻炼,保存和更新它们,并跟踪历史记录。回头看,我们以最少的代码实现了这一切。我们敢打赌,如果我们试图在标准的 jQuery 或其他框架中实现这个功能,相比 Angular,会需要大大更多的工作。

我们通过在MongoLab服务器上提供MongoDB数据库开始了本章。由于 MongoLab 提供了访问数据库的 RESTful API,我们节省了一些时间,因为不需要设置自己的服务器基础架构。

我们首先接触到的 Angular 构造是HTTPClient,这是连接到任何 HTTP 后端的主要服务。

您还学会了HTTPClient模块如何使用 Observables。在本章中,我们首次创建了自己的 Observable,并解释了如何订阅这些 Observables。

我们修复了个人健身教练应用程序,使其使用HTTPClient模块来加载和保存锻炼数据(请注意,关于锻炼数据的持久化留给您来完成)。在此过程中,您还了解了与跨域资源访问相关的问题。您学到了 JSONP,这是一个绕过浏览器同源限制的解决方法,以及如何使用 Angular 发出 JSONP 请求。我们还涉及了 CORS,这已经成为跨域通信的标准。

我们现在已经涵盖了大部分 Angular 的构建模块,除了一个重要模块:Angular 指令。我们在各处使用了指令,但还没有创建一个。下一章将专门讲解 Angular 指令。我们将创建许多小指令,例如远程验证器、AJAX 按钮,以及健身建造者应用程序的验证提示指令。

第四章:深入了解 Angular 指令

指令 始终随处可见。它们是 Angular 的基本构建块。每个应用程序的扩展都导致我们创建新的 组件指令。这些组件指令进一步使用了 属性指令(如NgClassNgStyle)和 结构指令(如NgIfNgFor)来扩展其行为。

虽然我们已经构建了许多组件指令和一个单独的属性指令,但仍有一些值得探索的指令构建概念。特别是对于属性和结构指令,我们尚未详细介绍。

本章将涵盖以下主题:

  • 构建指令:我们构建多个指令,并学习指令的有用之处,它们与组件的区别,以及指令如何相互通信和/或与它们的宿主组件通信。我们探讨包括组件指令属性指令结构指令在内的所有指令类型。

  • 异步验证:Angular 可以轻松验证需要服务器交互和因此是异步的规则。在本章中,我们将构建我们的第一个异步验证器。

  • 使用渲染器进行视图操作:渲染器允许以与平台无关的方式进行视图操作。我们将利用渲染器来实现繁忙指示器指令,并学习其 API。

  • 宿主绑定:宿主绑定允许指令与其宿主元素进行通信。本章将涵盖如何利用这样的绑定来进行指令。

  • 指令注入:Angular DI 框架允许基于指令在 HTML 层次结构中的声明位置进行指令注入。我们将介绍与此类注入相关的多种情景。

  • 使用视图子和内容子:组件具有将外部视图模板包含到其自身视图中的能力。我们将介绍如何处理注入的内容。

  • 理解 NgIf 平台指令:我们将深入了解NgIf平台指令的内部工作,并尝试理解结构指令(如NgIf)的工作方式。

  • Angular 组件的视图封装:我们将学习 Angular 如何使用来自Web 组件的概念来支持视图和样式的封装。

构建远程验证器指令

我们以支持服务器数据持久性的第三章 结束了 Workout Runner 能够在 MongoDB 存储中管理锻炼。由于每个锻炼都应具有唯一名称,我们需要执行唯一性约束。因此,在创建/编辑锻炼时,每当用户更改锻炼名称时,我们可以查询 MongoDB 来验证该名称是否已存在。

与任何远程调用一样,这个检查是异步进行的,因此需要一个 远程验证器。我们将使用 Angular 的 异步验证器支持 来构建这个远程验证器。

异步验证器与标准自定义验证器类似,只是返回的不是键-值对象映射或 null,而是一个promise。这个 promise 最终将根据验证状态进行解析(如果有错误,则设置为相应状态),如果验证成功,则返回 null。

我们将创建一个验证指令,用于进行工作名称检查。针对这样的指令,有两种可能的实现方法:

  • 我们可以专门为唯一名称验证创建一个指令

  • 我们可以创建一个通用指令,可以执行任何远程验证。

验证指令

尽管我们正在构建一个验证指令,但我们也可以构建一个标准的自定义验证器类。创建指令的优势在于它可以让我们将指令嵌入到模板驱动的表单中,在视图 HTML 中使用指令。或者,如果表单是使用模型(响应式方法)生成的,我们可以在创建Control对象时直接使用验证器类。

起初,针对数据源(mLab数据库)检查重复名称的要求似乎是一个过于具体的要求,无法通过通用验证器来处理。但通过一些明智的假设和设计选择,我们仍然可以实现一个能够处理所有类型远程验证的验证器,包括工作名称验证。

计划是创建一个外部化实际验证逻辑的验证器。该指令将以验证函数作为输入。这意味着实际验证逻辑不是验证器的一部分,而是实际需要验证输入数据的组件的一部分。指令的工作仅是调用函数并根据函数的返回值返回相应的错误键。

让我们把这个理论付诸实践,构建我们的远程验证指令,恰如其名的RemoteValidatorDirective

以下部分的伴随代码基于 Git 分支checkpoint6.1。您可以与我们一起工作,或者查看上述文件夹中提供的实现。或者,如果您不使用 Git,可以从 GitHub 位置bit.ly/ng2be-checkpoint6-1下载checkpoint6.1的快照(ZIP 文件)。在首次设置快照时,请参考trainer文件夹中的README.md文件。

使用异步验证器验证工作名称

与自定义验证器类似,异步验证器也继承自相同的Validator类;但这次,异步验证器返回一个Promise而不是对象映射。

让我们来看看验证器的定义。从 GitHub(bit.ly/ng6be-6-1-remote-validator-directive-ts)文件夹中复制验证器的定义,并将其添加到shared模块文件夹。验证器的定义如下:

import { Directive, Input } from '@angular/core';
import { NG_ASYNC_VALIDATORS, FormControl } from '@angular/forms';

@Directive({
  selector: '[abeRemoteValidator][ngModel]',
  providers: [{ provide: NG_ASYNC_VALIDATORS, useExisting: RemoteValidatorDirective, multi: true }]
})
export class RemoteValidatorDirective {

  @Input() abeRemoteValidator: string;
  @Input() validateFunction: (value: string) => Promise<boolean>;

  validate(control: FormControl): { [key: string]: any } {
    const value: string = control.value;
    return this.validateFunction(value).then((result: boolean) => {
      if (result) {
        return null;
      }
      else {
        const error: any = {};
        error[this.abeRemoteValidator] = true;
        return error;
      }
    });
  }
} 

切记要从共享模块导出这个指令,以便我们可以在锻炼构建器模块中使用它。

由于我们将验证器注册为指令,而不是使用 FormControl 实例进行注册(通常用于以响应式方式构建表单时),我们需要额外的提供者配置设置(在前述@Directive元数据中添加),通过以下语法:

 providers:[{ provide: NG_ASYNC_VALIDATORS, useExisting: RemoteValidatorDirective,  multi: true }] 

此语句将验证器注册到现有的异步验证器中。

在接下来的部分中,我们将构建一个繁忙指示器指令,解释在前述代码中使用的奇怪的指令选择器selector: abeRemoteValidator`。

在我们深入研究验证器实现之前,让我们将其添加到锻炼名称输入中。这将帮助我们将验证器的行为与其使用联系起来。

用验证器声明更新锻炼名称输入(workout.component.html):

<input type="text" name="workoutName" ... 
 abeRemoteValidator="workoutname"[validateFunction]="validateWorkoutName"> 

为指令选择器添加前缀

始终使用一个标识符(正如你刚刚看到的abe)作为你的指令前缀,以将其与框架指令和其他第三方指令区分开来。

注意:如果 ngModelOptionsupdateOn 设置为 submit,,则更改为 blur

指令实现通过指令属性abeRemoveValidator接受两个输入:validation key,用于设置error key,和validation functionvalidateFunction),用于验证控件的值。这两个输入都用@Input装饰器进行了注解。

输入参数@Input("validateFunction") validateFunction: (value: string) => Promise<boolean>;,绑定到一个函数,而不是标准的组件属性。由于底层语言 TypeScript 的性质(以及 JavaScript),我们可以把这个函数当作属性对待。

当异步验证触发(输入更改时),Angular 调用该函数,并传入基础的control。作为第一步,我们提取当前的输入值,然后使用该输入调用 validateFunction 函数。validateFunction 返回一个 promise,最终应该解析为 truefalse

  • 如果 promise 解析为true,则验证成功,promise 回调函数返回null

  • 如果是 false,则验证失败,返回一个错误键值映射。这里的key是我们在使用验证器时设置的字符串字面量(a2beRemoteValidator="workoutname")。

这个key在输入上有多个验证器声明时非常有用,可以帮助我们识别失败的验证。

接下来在锻炼组件中为这个失败添加一个验证消息。在现有的workout name的验证label后添加此标签声明:

<label *ngIf="name.control.hasError('workoutname')" class="alert alert-danger validation-message">A workout with this name already exists.</label> 

然后将这两个标签包裹在 div 中,就像我们为workout title错误标签做的那样。

hasError 函数检查'workoutname'验证键是否存在。

这个实现缺少的最后一部分是我们在应用指令时分配的实际验证函数([validateFunction]="**validateWorkoutName**""),但尚未实现。

validateWorkoutName 函数添加到 workout.component.ts 中。

validateWorkoutName = (name: string): Promise<boolean> => {
    if (this.workoutName === name) { return Promise.resolve(true); }
    return this.workoutService.getWorkout(name).toPromise()
      .then((workout: WorkoutPlan) => {
        return !workout;
      }, error => {
        return true;
      });
  }  

在探索前面的函数功能之前,我们需要对 WorkoutComponent 类进行一些其他修复。validateWorkoutName 函数依赖于 WorkoutService 来获取具有特定名称的训练计划。让我们在构造函数中注入该服务,并在导入部分添加必要的导入。

import { WorkoutService }  from "../../core/workout.service"; 
... 
constructor(... , private workoutService: WorkoutService) { 

然后声明 workoutNamequeryParamsSub 变量。

private workoutName: string;
queryParamsSub: Subscription

并在 ngOnInit 中添加这个语句。

this.queryParamsSub = this.route.params.subscribe(params => this.workoutName = params['id']); 

前述语句通过观察(订阅)route.params 服务的 observable 来设置当前的训练计划名称。workoutName 用于在使用原始训练计划名称时跳过训练计划名称验证。

之前创建的订阅需要清除以避免内存泄漏,因此将此行添加到 ngDestroy 函数中。

this.queryParamsSub.unsubscribe();

validateWorkoutName 函数定义为实例函数(使用箭头运算符)而不是标准函数(在原型上声明函数)的原因是 'this' 作用域问题。

查看 RemoteValidatorDirectivevalidateFunction 指令的验证器函数调用(使用 @Input("validateFunction") validateFunction; 进行声明)。

return this.validationFunction(value).then((result: boolean) => { ... }); 

当函数(名为 validateFunction)被调用时,this 引用被绑定到 RemoteValidatorDirective,而不是 WorkoutComponent。由于 execute 在前述设置中引用了 validateWorkoutName 函数,所以内部对 this 的任何访问都会有问题。

这会导致 validateWorkoutName 内部的 if (this.workoutName === name) 语句失败,因为 RemoteValiatorDirective 没有 workoutName 实例成员。通过将 validateWorkoutName 定义为实例函数,TypeScript 编译器在函数定义时创建了一个闭包,捕获了 this 的值。

通过新的声明,validateWorkoutName 内部的 this 始终指向 WorkoutComponent,不管函数是如何被调用的。

我们还可以查看 WorkoutComponent 的编译后的 JavaScript 代码,了解闭包是如何与 validateWorkoutName 一起工作的。我们关注的生成代码部分如下:

function WorkoutComponent(...) { 
 var _this = this; 
  ... 
  this.validateWorkoutName = function (name) { 
 if (_this.workoutName === name) 
      return Promise.resolve(true); 

如果我们查看验证函数的实现,我们会发现它涉及查询 mLab 以获取特定的训练计划名称。validateWorkoutName 函数在未找到同名训练计划时返回 true,在找到同名训练计划时返回 false(实际上返回的是一个promise)。

WorkoutService 上的 getWorkout 函数返回一个observable,但我们通过调用 observable 上的 toPromise 函数将其转换为一个promise

现在可以测试验证指令了。创建一个新的训练,并输入一个已有的训练名称,如7minworkout。看看验证错误消息是如何最终显示出来的:

很棒!看起来很不错,但仍然缺少一些东西。用户并不知道我们正在验证训练名称。我们可以改善这个体验。

创建一个忙碌指示器指令

当远程验证训练名称时,我们希望用户意识到后台的活动。在远程验证发生时围绕输入框提供视觉线索应该能达到目的。

仔细思考一下;有一个带有异步验证器(执行远程验证)的输入框,我们想要在验证期间用一个视觉线索装饰输入框。看起来像一个常见的解决模式?的确如此,所以让我们创建另一个指令!

但在我们开始实施之前,必须明确理解我们并不孤军奋战。忙碌指示器指令需要另一个指令NgModel的帮助。我们已经在第二章个人教练中的input元素上使用了NgModel指令。NgModel帮助我们跟踪输入元素的状态。以下示例取自第二章《个人教练》,突出了NgModel如何帮助我们验证输入:

<input type="text" name="workoutName" #name="ngModel"  class="form-control" id="workout-name" ... [(ngModel)]="workout.name" required> 
... 
<label *ngIf="name.control.hasError('required') && (name.touched || submitted)" class="alert alert-danger">Name is required</label>  

即使在上一部分中完成了唯一的训练名称验证,也是使用了相同的NgModel技术来检查验证状态。

让我们从定义指令的概要开始。在src/app/shared文件夹中使用 CLI 生成器创建一个busy-indicator.directive.ts文件:

ng generate directive busy-indicator

同样,通过将指令添加到共享模块文件shared.module.ts中的exports数组中导出它。

接下来,使用NgModel注入来更新指令的构造函数,并从@angular/forms中导入NgModel引用:

constructor(private model: NgModel) { }

这告诉 Angular 要对声明指令的元素注入NgModel实例。记住,NgModel指令已经存在于inputworkoutname)上了:

<input... name="workoutName" #name="ngModel" [(ngModel)]="workout.name" ...>

这已经足够将我们的新指令集成到训练视图中了,让我们快速做吧。

打开workout-builder中的workout.component.html,并在训练名称input中添加忙碌指示器指令:

<input type="text" name="workoutName" ... abeBusyIndicator> 

创建一个新的训练或打开一个现有的训练,查看BusyIndicatorDirective是否已加载并且NgModel注入是否正常工作。这可以通过在BusyIndicatorDirective构造函数内设置断点来轻松验证。

Angular 将相同的NgModel实例注入到BusyIndicatorDirective中,就像在输入 HTML 中遇到ngModel时创建的那样。

也许你会想知道,如果我们将这个指令应用于没有ngModel属性的输入元素,或者实际上任何 HTML 元素/组件,会发生什么,例如这样:

<div abeBusyIndicator></div> 
<input type="text" abeBusyIndicator> 

注射会起作用吗?

当然不是!我们可以在创建锻炼视图中试一下。打开workout.component.html,并在锻炼名称input上面添加以下input。刷新应用:

<input type="text" name="workoutName1" a2beBusyIndicator> 

Angular 抛出一个异常,如下所示:

 EXCEPTION: No provider for NgModel! (BusyIndicatorDirective -> NgModel)

如何避免这个问题?嗯,Angular 的 DI 可以在这里拯救我们,因为它允许我们声明一个可选的依赖关系。

在继续之前删除刚刚添加的input控件。

@Optional装饰器注入可选依赖项

Angular 有一个@Optional装饰器,当应用于构造函数参数时,指示 Angular 注入器在找不到依赖项时注入null

因此,繁忙指示符构造函数可以写成如下所示:

constructor(@Optional() private model: NgModel) { } 

问题解决了吗?并没有;正如先前所述,我们需要NgModel指令使BusyIndicatorDirective起作用。因此,虽然我们学到了一些新知识,但在当前情况下并不是很有用。

在继续之前,请记得将workoutname``input还原为初始状态,应用abeBusyIndicator

只有在元素上已经存在NgModel指令时,才能应用BusyIndicatorDirective

这次,selector指令将会拯救我们。将BusyIndicatorDirective选择器更新为如下所示:

selector: `[abeBusyIndicator][ngModel]` 

如果元素上同时存在a2beBusyIndicatorngModel属性的组合,那么这个选择器将创建BusyIndicatorDirective。问题解决了!

是时候添加实际的实现了。

实现一 - 使用渲染器

要使BusyIndicatorDirective起作用,它需要知道input上的异步验证何时触发以及何时结束。这些信息只能由NgModel指令提供。NgModel有一个control属性,它是Control类的一个实例。正是这个Control类跟踪输入的当前状态,包括以下内容:

  • 当前分配的验证器(同步和异步)

  • 当前值

  • 输入元素的状态,比如pristinedirtytouched

  • 输入验证状态可能是validinvalid或者在异步执行验证时是pending之一

  • 跟踪数值变化或验证状态变化的事件

Control看起来是一个很有用的类,它的pending状态引起了我们的兴趣!

让我们为BusyIndicatorDirective类添加第一个实现。用以下代码更新类:

private subscriptions: Array<any> = []; 
ngAfterViewInit(): void {
    this.subscriptions.push(
      this.model.control.statusChanges.subscribe((status: any) => {
        if (this.model.control.pending) {
          this.renderer.setElementStyle(this.element.nativeElement, 'border-width', '3px');
          this.renderer.setElementStyle(this.element.nativeElement, 'border-color', 'gray');
        }
        else {
          this.renderer.setElementStyle(this.element.nativeElement, 'border-width', null);
          this.renderer.setElementStyle(this.element.nativeElement, 'border-color', null);
        }
      }));
  }  

需要向构造函数添加两个新的依赖项,因为我们在ngAfterViewInit函数中使用它们。将BusyIndicatorDirective构造函数更新如下:

constructor(private model: NgModel,  
 private element: ElementRef, private renderer: Renderer) { }

还需要在'@angular/core'中导入ElementRefRenderer

ElementRef是对底层 HTML 元素(在本例中是input)的包装对象。MyAudioDirective指令使用ElementRef来获取底层的Audio元素。

Renderer 注入值值得一提。调用 setElementStyle 很明显是 Renderer 负责管理 DOM 的标志。 但在更深入地了解 Renderer 的角色之前,让我们尝试理解前面的代码在做什么。

在前面的代码中,模型(NgModel 实例)上的 control 属性定义了一个事件(一个 Observable),statusChanges,我们可以订阅以了解控件验证状态何时更改。 可用的验证状态是validinvalidpending

订阅检查控件状态是否为pending,并相应地使用 Renderer API 函数 setElementStyle 装饰底层元素。 我们设置 inputborder-widthborder-color

前述的实现添加到 ngAfterViewInit 指令生命周期钩子中,该生命周期钩子在视图初始化后调用。

让我们试一试。打开创建锻炼页面或现有的 7 分钟锻炼。 一旦我们离开锻炼名称输入,input 样式会更改,并在锻炼名称的远程验证完成后恢复。 好!

在继续之前,还要将取消订阅代码添加到 BusyIndicatorDirective 中以避免内存泄漏。 将此函数(生命周期钩子)添加到 BusyIndicatorDirective 中:

ngOnDestroy() { 
    this.subscriptions.forEach((s) => s.unsubscribe()); 
} 

始终取消订阅 observables

要始终记住取消对代码中已完成的任何 Observable/EventEmitter 订阅,以避免内存泄漏。

实现看起来不错。Renderer正在发挥作用。但还有一些未解答的问题。

为什么不直接获取底层 DOM 对象并使用标准 DOM API 来操作输入样式? 为什么我们需要 renderer

Angular 渲染器,翻译层

Angular 2 的主要设计目标之一是使其在各种环境、框架和设备上运行。 Angular 通过将核心框架实现分为应用层呈现层来实现了这一目标。 应用层具有我们交互的 API,而呈现层提供了一个抽象,应用层可以使用它而不必担心视图的实际渲染位置。

通过分离渲染层,Angular 理论上可以在各种设置中运行。其中包括(但不限于):

  • 浏览器

  • 浏览器主线程和网络工作线程,出于明显的性能原因

  • 服务器端渲染

  • 原生应用程序框架;正在努力将 Angular 与 NativeScriptReactNative 集成。

  • 测试,允许我们在网络浏览器之外测试应用程序 UI

Angular 在浏览器中使用的 Renderer 实现是 DOMRenderer。 它负责将我们的 API 调用转换为浏览器 DOM 更新。 实际上,我们可以通过在 BusyIndicatorDirective 的构造函数中添加断点并查看 renderer 的值来验证渲染器类型。

准确因此,我们避免在BusyIndicatorDirective内部直接操纵 DOM 元素。您永远不知道代码最终将在哪里运行。我们本来很容易就可以这样做:

this.element.nativeElement.style.borderWidth="3px"; 

相反,我们使用了Renderer以平台无关的方式来做同样的事情。

查看RendererAPI 函数,setElementStyle

this.renderer.setElementStyle( 
             this.element.nativeElement, "border-width", "3px"); 

它接受要设置样式的元素,要更新的样式属性和要设置的值。element引用了注入到BusyIndicatorDirective中的input元素。

重置样式

通过调用setElementStyle设置的样式可以通过在第三个参数中传递null值来重置。请查看前面代码中的else条件。

RendererAPI 还有许多其他方法可用于设置属性、设置属性、监听事件,甚至创建新视图。每当您构建新指令时,请记得评估RendererAPI 以进行 DOM 操作。

有关Renderer及其应用的更详细解释,请参阅 Angular 的设计文档的这里:bit.ly/ng2-render

我们还没有完成!借助 Angular 的强大功能,我们可以改进此实现。Angular 允许我们在指令实现中进行主机绑定,帮助我们避免大量样板代码。

指令中的主机绑定

在 Angular 领域,指令附加到的组件/元素被称为宿主元素:一个承载我们的指令/组件的容器。对于BusyIndicatorDirectiveinput元素就是宿主

虽然我们可以使用Renderer来操纵宿主(我们也是这样做的),但是 Angular 数据绑定基础设施可以进一步减少代码。它提供了一种声明性的方式来管理指令-宿主交互。使用主机绑定概念,我们可以操纵元素的属性属性,并订阅其事件

让我们了解每种主机绑定的能力,最后,我们将修复我们的BusyIndicatorDirective实现。

使用@HostBinding 进行属性绑定

使用主机属性绑定指令属性绑定到宿主元素属性。在变更检测阶段,对指令属性的任何更改都将与链接的主机属性同步。

我们只需要在想要同步的指令属性上使用@HostBinding装饰器。例如,考虑这样的绑定:

@HostBinding("readOnly") get busy() {return this.isbusy}; 

当应用于input时,当isbusy指令属性为true时,它将将input``readOnly属性设置为true

注意,readonly也是input上的属性。这里指的是我们所说的输入属性readOnly

属性绑定

属性绑定将指令属性绑定到宿主组件属性。例如,考虑具有以下绑定的指令:

@HostBinding("attr.disabled") get canEdit(): string  
  { return !this.isAdmin ? "disabled" : null }; 

如果应用于输入,当isAdmin标志为false时,它将在input上添加disabled属性,并在isAdmin为真时清除它。我们在这里也遵循 HTML 模板中使用的相同属性绑定符号。属性名称前缀为字符串字面量attr

我们也可以使用classstyle 绑定来做类似的事情。考虑以下行:

@HostBinding('class.valid')  
   get valid { return this.control.valid; } 

这一行设置了一个类绑定,下一行创建了一个样式绑定:

@HostBinding("style.borderWidth")  
   get focus(): string { return this.focus?"3px": "1px"}; 

事件绑定

最后,事件绑定用于订阅宿主组件/元素引发的事件。考虑这个例子:

@Directive({ selector: 'button, div, span, input' }) 
class ClickTracker { 
  @HostListener('click', ['$event.target']) 
  onClick(element: any) { 
    console.log("button", element, "was clicked"); 
  } 
} 

这在宿主事件click上建立了一个监听器。Angular 将为视图上的每个buttondivspaninput实例化前述指令,并为onClick函数设置宿主绑定。$event变量包含引发的事件数据,target指的是所点击的元素/组件。

事件绑定也适用于组件。考虑以下例子:

@Directive({ selector: 'workout-runner' }) 
class WorkoutTracker { 
  @HostListener('workoutStarted', ['$event']) 
  onWorkoutStarted(workout: any) { 
    console.log("Workout has started!"); 
  } 
} 

通过这个指令,我们跟踪了在WorkoutRunner组件上定义的workoutStarted事件。当锻炼开始时,将调用onWorkoutStarted函数,并带上已开始的锻炼的详情。

现在我们了解了这些绑定是如何工作的,我们可以改进我们的BusyIndicatorDirective实现。

实施二 - 具有宿主绑定的 BusyIndicatorDirective

你可能已经猜到了!我们将使用宿主属性绑定而不是Renderer来设置样式。想要试试吗?试一试吧!清除现有的实现,尝试在不查看以下实现的情况下为borderWidthborderColor样式属性设置宿主绑定。

在宿主绑定实现之后,指令将如下所示:

import {Directive, HostBinding} from '@angular/core'; 
import {NgModel} from '@angular/forms'; 

@Directive({ selector: `[abeBusyIndicator][ngModel]`}) 
export class BusyIndicatorDirective {
  private get validating(): boolean {
    return this.model.control != null && this.model.control.pending;
  }
  @HostBinding('style.borderWidth') get controlBorderWidth():
        string { return this.validating ? '3px' : null; }
  @HostBinding('style.borderColor') get controlBorderColor():
        string { return this.validating ? 'gray' : null; }

  constructor(private model: NgModel) { }
}

我们已经将pending状态检查移到了名为validating的指令属性中,然后使用了controlBorderWidthcontrolBorderColor属性进行样式绑定。这绝对比我们之前的方法更简洁!去测试一下吧。

如果我们告诉你,这可以不需要自定义指令来完成,你不要感到惊讶!这就是我们做的,只需在锻炼名称input上使用样式绑定即可:

<input type="text" name="workoutName" ... 
[style.borderColor]="name.control.pending ? 'gray' : null" [style.borderWidth]="name.control.pending ? '3px' : null">

我们得到了相同的效果!

不,我们的努力并不是白费的。我们学到了rendererhost binding。这些概念在构建提供复杂行为扩展而不仅仅是设置元素样式的指令时会派上用场。

如果你在运行代码时遇到问题,请查看 Git 分支checkpoint6.1,查看我们迄今为止所做的工作的可运行版本。或者,如果你没有使用 Git,请从bit.ly/ng6be-checkpoint-6-1下载checkpoint6.1的快照(ZIP 文件)。在第一次设置快照时,请查看trainer文件夹中的README.md文件。

我们接下来要讨论的主题是指令注入

指令注入

回到几页前,看一下使用rendererBusyIndicatorDirective实现,特别是构造函数:

constructor(private model: NgModel ...) { } 

Angular 会自动定位为指令元素创建的NgModel指令,并将其注入到BusyIndicatorDirective中。这是可能的,因为这两个指令都声明在同一个宿主元素上。

好消息是我们可以影响这种行为。在父 HTML 树或子树上创建的指令也可以被注入。接下来的几节将讨论如何在组件树中跨指令进行注入,这是一个非常实用的功能,允许具有共同血统(在视图中)的指令进行跨指令通信。

我们将使用 StackBlitz(stackblitz.com/edit/angular-pzljm3)来演示这些概念。StackBlitz 是一个在线 IDE,用于运行 Angular 应用程序!

首先,查看文件app.component.ts。它有三个指令:RelationAcquaintanceConsumer,并定义了这个视图层次结构:

<div relation="grand-parent" acquaintance="jack"> 
    <div relation="parent"> 
 <div relation="me" consumer> 
        <div relation="child-1"> 
          <div relation="grandchild-1"></div> 
        </div> 
        <div relation="child-2"></div> 
      </div> 
    </div> 
</div> 

在接下来的几节中,我们将描述不同方式将不同的RelationAcquaintance指令注入到consumer指令中。在ngAfterViewInit生命周期钩子期间,查看浏览器控制台中我们记录的注入依赖项。

注入在同一元素上定义的指令

默认情况下,构造函数注入支持在同一元素上定义的指令。构造函数只需要声明我们要注入的指令类型变量即可:

variable:DirectiveType 

我们在BusyIndicatorDirective中进行的NgModel注入就属于这一类。如果在当前元素上找不到指令,那么 Angular DI 将抛出错误,除非我们将依赖标记为@Optional

可选依赖

@Optional 装饰器不仅限于指令注入。它用于标记任何类型的可选依赖。

从 plunk 示例中,第一个注入(在Consumer指令实现中)将带有me属性(relation="me")的Relation指令注入到消费者指令中:

constructor(private me:Relation ... 

从父级注入指令依赖

使用@Host装饰符对构造函数参数进行前缀,指示 Angular 在当前元素其父级或其父级中搜索依赖项,直到它达到组件边界(在其视图层次结构中的某个地方有指令的组件)。查看第二个consumer注入:

constructor(..., @Host() private myAcquaintance:Acquaintance  

此语句将注入两层上层声明的Acquaintance指令实例。

像前面描述的@Optional装饰器一样,@Host()的使用也不仅限于指令。Angular 服务注入也遵循相同的模式。如果服务标记为@Host,那么搜索将停在宿主组件处。它不会继续向上查找组件树。

@Skipself 装饰器可用于跳过当前元素以进行指令搜索。

从 StackBlitz 示例中,这个注入将带有relation="parent"relation属性值为parent)的Relation指令注入consumer中:

@SkipSelf() private myParent:Relation 

注入子指令(或多个指令)

如果需要将嵌套 HTML 中定义的指令注入到父指令/组件中,有四个装饰器可以帮助我们:

  • @ViewChild/@ViewChildren

  • @ContentChild/@ContentChildren

正如这些命名约定所暗示的,有用于注入单个子指令或多个子指令的装饰器:

要理解@ViewChild/@ViewChildren@ContentChild/@ContentChildren的重要性,我们需要看一下什么是视图和内容子项,这是我们很快要讨论的一个主题。但现在,了解视图子项是组件自己视图的一部分,而内容子项是注入到组件视图中的外部 HTML 就足够了。

看看在 StackBlitz 的示例中,ContentChildren装饰器是如何用于将子Relation指令注入到Consumer中的:

@ContentChildren(Relation) private children:QueryList<Relation>; 

可笑的是,变量children的数据类型不是数组,而是一个自定义类-QueryListQueryList类并不是典型的数组,而是一个由 Angular 在添加或移除依赖项时保持更新的集合。这可能发生在使用NgIfNgFor等结构指令创建/销毁 DOM 树时。我们在接下来的章节中也会更多地讨论QueryList

您可能已经注意到,前面的注入不是构造函数注入,就像前面的两个例子一样。这是有原因的。注入的指令将在底层的组件/元素内容初始化之前不可用。出于这个特定的原因,我们在ngAfterViewInit生命周期钩子内有console.log语句。我们应该只在此生命周期钩子执行后访问内容子项。

前面的示例代码将所有三个子relation对象注入到consumer指令中。

注入后代指令

标准的@ContentChildren装饰器(或事实上也是@ViewChildren)只会注入指令/组件的直接子项,而不是其后代。要包含所有后代,我们需要向Query提供参数:

@ContentChildren(Relation, {descendants: true}) private 
allDescendents:QueryList<Relation>; 

传递descendants: true参数将指示 Angular 搜索所有后代。

如果您查看控制台日志,前面的语句会注入所有四个后代。

尽管 Angular DI 看起来很容易使用,但它拥有很多功能。它管理我们的服务、组件和指令,并在正确的时间将正确的东西提供给我们的正确位置。在组件和其他指令中注入指令提供了一种指令相互通信的机制。这样的注入允许一个指令访问另一个指令的公共 API(公共函数/属性)。

现在是探索新事物的时候了。我们将构建一个 Ajax 按钮组件,允许我们将外部视图注入组件中,这个过程也被称为内容转译

构建一个 Ajax 按钮组件

当我们保存/更新练习或锻炼时,总是存在重复提交的可能性(或重复的POST请求)。当前的实现不提供任何关于保存/更新操作何时开始以及何时完成的反馈。由于缺乏视觉线索,应用程序的用户可能会有意或无意地多次点击保存按钮。

让我们尝试通过创建一个专用按钮来解决这个问题——一个Ajax 按钮,当点击时提供一些视觉线索,并阻止重复的 Ajax 提交。

按钮组件将按照这些行工作。它接受一个函数作为输入。此输入函数(输入参数)应返回与远程请求相关的 promise。单击按钮时,按钮内部调用远程调用(使用输入函数),跟踪底层 promise,并在此过程中显示一些忙碌的线索。此外,为了避免重复提交,按钮在远程调用完成之前保持禁用状态。

以下部分的伴随代码基于 Git 分支checkpoint6.2。您可以与我们一起工作,或者查看分支中提供的实现。或者如果您不使用 Git,请从 GitHub 位置 bit.ly/ng6be-checkpoint-6-2 下载checkpoint6.2的快照(ZIP 文件)。在第一次设置快照时,请参考trainer文件夹中的README.md文件。

让我们创建组件大纲以使事情更清晰。使用以下命令在应用程序的共享模块 (src/app/shared) 下创建一个ajax-button组件,然后从SharedModule导出该组件

ng generate component ajax-button -is

也需要更新组件定义并从@angular/core导入它们:

export class AjaxButtonComponent implements OnInit { 
  busy: boolean = null; 
  @Input() execute: any; 
  @Input() parameter: any; 
} 

需要将以下 HTML 模板添加到ajax-button.component.html中:

<button [attr.disabled]="busy" class="btn btn-primary"> 
    <span [hidden]="!busy">
        <div class="ion-md-cloud-upload spin"></div>
    </span>
    <span>Save</span> 
</button> 

该组件(AjaxButtonComponent)具有两个属性绑定,executeparameterexecute属性指向在单击 Ajax 按钮时调用的函数。parameter是可以传递给此函数的数据。

查看视图中busy标志的使用方式。当busy标志被设置时,我们禁用按钮并显示旋转图标。让我们添加使一切正常工作的实现。将此代码添加到AjaxButtonComponent类中:

@HostListener('click', ['$event'])
onClick(event: any) {
    const result: any = this.execute(this.parameter);
    if (result instanceof Promise) {
      this.busy = true;
      result.then(
        () => { this.busy = null; },
        (error: any) => { this.busy = null; });
    }
}

我们设置了一个主机事件绑定,将点击事件绑定到AjaxButtonComponent组件。每当单击AjaxButtonComponent组件时,都会调用onClick函数。

需要将HostListener导入添加到'@angular/core'模块中。

onClick 实现使用parameter作为唯一参数调用输入函数。调用的结果存储在result变量中。

if 条件检查 result 是否为 Promise 对象。如果是,busy指示器就会被设置为 true。然后按钮等待 promise 被解决,使用 then 函数。无论 promise 是否解决为成功还是错误,忙标志都被设置为null

忙标志被设置为null而不是false的原因是由于这个属性绑定[attr.disabled]="busy"。除非busynull,否则disabled属性不会被移除。请记住,在 HTML 中,disabled="false"不会使按钮处于可点击状态。在按钮再次可点击之前,需要删除属性。

如果我们对这一行感到困惑:

    const result: any = this.execute(this.parameter); 

然后你需要看一下组件的使用方式。打开workout.component.html,将保存按钮的 HTML 替换为以下内容:

<abe-ajax-button [execute]="save" [parameter]="f"></abe-ajax-button> 

Workout.save 函数绑定到 execute,而 parameter 获取 FormControl 对象 f

我们需要更改 Workout 类中的 save 函数以返回 AjaxButtonComponent 的 promise 才能工作。将 save 函数的实现更改为以下内容:

save = (formWorkout: any): Promise<Object | WorkoutPlan> => {
    this.submitted = true;
    if (!formWorkout.valid) { return; }
    const savePromise = this.workoutBuilderService.save().toPromise();

    savePromise.then(
      result => this.router.navigate(['/builder/workouts']),
      err => console.error(err)
    );
    return savePromise;
  } 

save 函数现在返回一个promise,我们通过调用从workoutBuilderService.save()调用返回的observable上的 toPromise 函数来构建它。

注意我们如何将 save 函数定义为实例函数(使用箭头操作符)以使其在this上创建闭包。这是我们之前在构建远程验证器指令时做的事情。

是时候测试我们的实现了!刷新应用程序,打开创建/编辑锻炼视图。点击保存按钮,看到 Ajax 按钮的效果:

前面的动画可能只是短暂的,因为我们在保存后返回到锻炼列表页面。我们可以临时禁用导航以查看新更改。

我们从这一部分开始,旨在突显外部元素/组件如何被传递到组件中。让我们来做吧!

将外部组件/元素传递到一个组件中

从一开始,我们就需要了解传递意味着什么。了解这个概念的最佳方式是看一个例子。

到目前为止,我们建立的任何组件都没有从外部借用内容。不确定这意味着什么?

考虑在workout.component.html中的前面的 AjaxButtonComponent 示例:

<ajax-button [execute]="save" [parameter]="f"></ajax-button> 

如果我们将ajax-button的使用更改为以下内容会怎样?

<ajax-button [execute]="save" [parameter]="f">Save Me!</ajax-button> 

“保存我!”的文本会显示在按钮上吗?不会,试一下!

AjaxButtonComponent 组件已经有一个模板,并拒绝了我们在前面的声明中提供的内容。如果我们能够以某种方式将内容(在前面的例子中的“保存我!”)注入AjaxButtonComponent内部呢?这种将外部视图片段注入组件视图的行为就是我们所说的传递,框架提供了必要的构造来启用传递。

现在是时候介绍两个新概念,内容子级视图子级

内容子级和视图子级

简洁地定义,组件内部定义的 HTML 结构(使用template 或 templateUrl)是组件的视图子级。然而,作为组件使用的一部分提供的 HTML 视图添加到宿主元素(例如<ajax-button>**Save Me!**</ajax-button>)中,定义了组件的内容子级

默认情况下,Angular 不允许将内容子级嵌入,就像我们之前看到的那样。Save Me!文本从未被发送。我们需要明确告诉 Angular 在组件视图模板内的哪里发出内容子级。为了理解这个概念,让我们来修复AjaxButtonComponent的视图。打开ajax-button.component.ts并更新视图模板定义如下:

<button [attr.disabled]="busy" class="btn btn-primary"> 
    <span [hidden]="!busy"> 
        <ng-content select="[data-animator]"></ng-content> 
   </span> 
 <ng-content select="[data-content]"></ng-content> 
</button>

前述视图中的两个ng-content元素定义了内容注入位置,内容子级可以被注入/跨越。selector属性定义了注入到主机中时应使用的CSS 选择器

一旦我们在workout.component.html中修复了AjaxButtonComponent的使用情况,它就会变得更有意义。将其更改为如下:

<ajax-button [execute]="save" [parameter]="f">
    <div class="ion-md-cloud-upload spin" data-animator></div>
 <span data-content>Save</span>
</ajax-button> 

带有data-animatorspan被注入到具有select=[data-animator]属性的ng-content中,而另一个带有data-content属性的span被注入到第二个ng-content声明中。

再次刷新应用程序,尝试保存锻炼。虽然最终结果是相同的,但最终视图是多个视图片段的组合:一个用于组件定义的部分(视图子级),另一个用于组件使用的部分(内容子级)。

下图突出显示了渲染的AjaxButtonComponent的不同之处:

ng-content可以在不带有selector属性的情况下声明。在这种情况下,将注入组件标记内定义的全部内容。

内容注入进入现有组件视图是一个非常强大的概念。它允许组件开发人员提供扩展点,组件消费者可以轻松消费并自定义组件的行为,而且是在受控的方式。

我们为AjaxButtonComponent定义的内容注入允许消费者更改忙碌指示动画和按钮内容,同时保持按钮的行为不变。

Angular 的优势并不止于此。它具有将内容子级视图子级注入到组件代码/实现中的能力。这使得组件可以与其内容/视图子级交互并控制它们的行为。

使用@ViewChild 和@ViewChildren 注入视图子级

让我们看一下WorkoutAudioComponent实现的相关部分。视图定义如下:

<audio #ticks="MyAudio" loop src="img/tick10s.mp3"></audio>
<audio #nextUp="MyAudio" src="img/nextup.mp3"></audio>
<audio #nextUpExercise="MyAudio" [src]="'/assets/audio/' + nextupSound"></audio>
// Some other audio elements 

注入的样式如下:

@ViewChild('ticks') private _ticks: MyAudioDirective; 
@ViewChild('nextUp') private _nextUp: MyAudioDirective; 
@ViewChild('nextUpExercise') private _nextUpExercise: MyAudioDirective; 

audio标签相关联的指令(MyAudioDirective)被注入到WorkoutAudio的实现中,使用@ViewChild装饰器。传递给@ViewChild的参数是用于在视图定义中定位元素的模板变量名称(例如tick)。然后WorkoutAudio组件使用这些音频指令来控制7 分钟锻炼的音频播放。

尽管前面的实现注入了MyAudioDirective,但甚至子组件也可以被注入。例如,我们构建了一个MyAudioComponent,与MyAudioDirective相似,如下所示:

@Component({ 
  selector: 'my-audio', 
  template: '<audio ...></audio>', 
}) 
export class MyAudioComponent { 
  ... 
} 

然后我们可以使用audio标签的方式代替它:

<my-audio #ticks loop  
  src="img/tick10s.mp3"></my-audio> 

注入仍然可以工作。

如果组件视图中定义了多个相同类型的指令/组件会发生什么?使用@ViewChildren装饰器。它允许您查询一个类型的注入。使用@ViewChildren的语法如下:

@ViewChildren(directiveType) children: QueryList<directiveType>; 

这会注入所有类型为directiveType的视图子元素。对于前面所述的WorkoutAudio组件示例,我们可以使用以下语句来获取所有的MyAudioDirective:

@ViewChildren(MyAudioDirectives) private all: QueryList<MyAudioDirectives>; 

ViewChildren装饰器也可以接受用逗号分隔的选择器列表(模板变量名)而不是类型。例如,要在WorkoutAudio组件中选择多个MyAudioDirective实例,我们可以使用以下内容:

 @ViewChildren('ticks, nextUp, nextUpExercise, halfway, aboutToComplete') private all: QueryList<MyAudioDirective>; 

QueryList类是 Angular 提供的特殊类。我们在本章前面的注入后代指令部分介绍了QueryList。让我们进一步探讨QueryList

使用QueryList跟踪注入的依赖

对于需要注入多个组件/指令的组件(使用@ViewChildren@ContentChildren),注入的依赖是一个QueryList对象。

QueryList类是一个只读的**集合*,包含注入的组件/指令。Angular 根据用户界面当前的状态来保持此集合同步。

举个例子,WorkoutAudio指令视图有五个MyAudioDirective实例。因此,对于以下集合,我们将有五个元素:

@ViewChildren(MyAudioDirective) private all: QueryList<MyAudioDirective>; 

虽然前面的例子没有突出显示同步部分,Angular 可以跟踪从视图中添加或删除的组件/指令。这是在使用ngFor等内容生成指令时发生的。

以这个假设的模板为例:

<div *ngFor="let audioData of allAudios"> 
  <audio [src]="audioData.url"></audio> 
</div> 

这里注入的MyAudioDirective指令的数量等于allAudios数组的大小。程序执行过程中,如果向allAudios数组添加或删除元素,则框架也会同步更新指令集合。

虽然QueryList类不是数组,但它可以通过 for (var item in queryListObject) 语法进行迭代(因为它实现了ES6 iterable 接口)。它还有一些其他有用的属性,如 lengthfirstlast,可以派上用场。查看框架文档(bit.ly/ng2-querylist-class)以获取更多详细信息。

从上面的讨论中,我们可以得出结论,QueryList 可以为组件开发人员节省大量样板代码,在需要手动追踪时会很麻烦。

视图子代访问时机

当组件/指令初始化时,视图子代注入是不可用的。Angular 确保视图子代注入在ngAfterViewInit生命周期事件之前可用于组件。确保只在(或之后)ngAfterViewInit事件触发后访问被注入的组件/指令。

现在让我们看看内容子代注入,这几乎相同,只不过有一些细微差别。

使用 @ContentChild 和 @ContentChildren 注入内容子代

Angular 也允许我们注入内容子代,使用一组并行属性:@ContentChild用于注入特定内容子代,@ContentChildren用于注入特定类型的内容子代。

如果我们回顾一下 AjaxButtonComponent 的用法,其内容子代 span 可以通过以下方式注入到 AjaxButtonComponent 实现中:

@ContentChild('spinner') spinner:ElementRef; 
@ContentChild('text') text:ElementRef; 

并在workout.component.html中对应的 span 上添加模板变量:

<div class="ion-md-cloud-upload spin" data-animator #spinner></div>
<span data-content #text>Save</span>

在前面的注入中,它是ElementRef,但也可以是一个组件。如果我们为旋转器定义了一个组件,比如:

<ajax-button> 
    <busy-spinner></busy-spinner> 
    ... 
</ajax-button> 

我们也可以使用以下方式进行注入:

@ContentChild(BusySpinner) spinner: BusySpinner; 

对于指令也是一样的。在AjaxButtonComponent上声明的任何指令都可以注入到AjaxButtonComponent实现中。对于上述情况,由于被传递的元素是标准 HTML 元素,我们注入了ElementRef,这是 Angular 为任何 HTML 元素创建的包装器。

视图子代类似,Angular 确保在ngAfterContentInit生命周期事件之前,内容子代引用绑定到被注入的变量。

当我们谈论注入依赖项时,让我们谈谈一些关于*将服务注入到组件中**的变体。

使用viewProvider进行依赖注入

我们已经熟悉 Angular 中的 DI 注册机制,在那里我们通过将其添加到任何模块声明中将依赖项注册到全局级别。

或者我们可以在组件级别使用@Component装饰器上的providers属性进行:

providers:[WorkoutHistoryTracker, LocalStorage] 

为了避免混淆,我们现在讨论的是注入除指令/组件对象之外的依赖项。在能够使用装饰器提示(如 @Query@ViewChild@ViewChildren 等)注入之前,指令/组件需要在模块的declarations数组中进行注册。

在组件级别注册的依赖项可供其视图子组件内容子组件及其后代使用。

在我们继续之前,我们希望视图内容子组件之间的区别对每个人都非常清晰。如有疑问,请再次参考内容子组件和视图子组件部分。

让我们以第二章中的一个例子,个人教练为例。WorkoutBuilderService 服务在锻炼构建模块(WorkoutBuilderModule)中以应用程序级别注册:

providers: [ExerciseBuilderService, ...  
 WorkoutBuilderService]);

这样可以让我们在整个应用程序中注入 WorkoutBuilderService 以构建锻炼,同时运行锻炼。相反,我们也可以在 WorkoutBuilderComponent 级别注册服务,因为它是所有锻炼/练习创建组件的父组件,类似以下示例:

@Component({ 
    template: `...` 
 providers:[ WorkoutBuilderService ] 
}) 
export class WorkoutBuilderComponent { 

这个改变将禁止在 WorkoutRunner 或与锻炼相关的任何组件中注入 WorkoutBuilderService

如果 WorkoutBuilderService 服务在应用程序级别和组件级别(如上例所示)都注册了,会发生什么?注入会如何进行?根据我们的经验,我们知道 Angular 会将 WorkoutBuilderService 服务的不同实例注入到 WorkoutBuilderComponent(及其后代)中,而应用程序的其他部分(Workout runner)会获得全局依赖。记住层次注入器

Angular 还不止于此。它使用 viewProviders 属性提供了一些进一步的依赖项作用域限定。@Component 装饰器上的 viewProviders 属性允许注册只能在视图子组件中注入的依赖项。

让我们再次考虑 AjaxButtonComponent 的例子,并且考虑一个简单的指令实现,名为 MyDirective,以阐明我们的讨论:

@Directive({ 
  selector: '[myDirective]', 
}) 
export class MyDirective { 
  constructor(service:MyService) { } 
  ... 
} 

MyDirective 类依赖于一个名为 MyService 的服务。

要将此指令应用于 AjaxButtonComponent 模板中的按钮元素,我们也需要注册 MyService 的依赖(假设 MyService 尚未全局注册):

@Component({ 
  selector: 'ajax-button', 
  template:` <button [attr.disabled]="busy" ... 
 myDirective> 
                ... 
             <button>` 
 providers:[MyService], 
... 

由于 MyService 已与 AjaxButtonComponent 注册,因此 MyDirective 也可以添加到其内容子组件中。因此,在 spinner HTML 上应用 myDirective 也将起作用(workout.component.html 中的代码):

<div class="ion-md-cloud-upload spin" data-animator myDirective></div>

但将 providers 属性更改为 viewProviders

viewProviders:[MyService]

会导致 AjaxButtonComponent 的内容子组件(上述代码中的 div)中的 MyService 注入失败,控制台会显示 DI 错误。

使用 viewProviders 注册的依赖对其内容子组件不可见。

视图和内容子级别的依赖作用域乍看起来可能并不有用,但它确实有其好处。 想象一下,我们正在构建一个可重用的组件,我们希望将其打包并交付给开发人员使用。 如果组件有一个预打包的服务依赖项,我们需要特别小心。 如果这样的组件允许内容注入(内容子级别),则在组件上使用基于提供者的注册时,依赖服务将被广泛暴露。 任何内容子级别都可以获取服务依赖并使用它,这会导致不良后果。 通过使用viewProvider注册依赖项,只有组件实现和其子视图才能访问到依赖项,提供了必要的封装层。

我们再次对 DI 框架提供的灵活性和定制级别感到惊讶。 尽管对于初学者来说可能有些令人生畏,但一旦我们开始使用 Angular 构建越来越多的组件/指令,我们总会发现这些概念使我们的实现变得更简单的地方。

让我们把焦点转向指令的第三个分类:结构指令

理解结构指令

虽然我们经常使用结构指令,比如NgIfNgFor,但很少需要创建一个结构指令。仔细考虑。如果我们需要一个新的视图,我们会创建一个组件。如果我们需要扩展现有的元素/组件,我们使用指令。 而结构指令最常见的用途是克隆视图的一部分(也称为模板视图),然后根据一些条件:

  • 要么注入/销毁这些模板(NgIfNgSwitch

  • 或者复制这些模板(NgFor

使用结构指令实现的任何行为都会无意中落入这两个类别之一。

有了这个事实,与其构建我们自己的结构指令,不如看看NgIf实现的源代码。

以下是引起我们兴趣的NgIf指令的摘录。 我们特意忽略了摘录中的ngIfElse部分:

@Directive({selector: '[ngIf]'})
export class NgIf {
 constructor(private _viewContainer: ViewContainerRef, templateRef: TemplateRef<NgIfContext>) {
    this._thenTemplateRef = templateRef;
 }

 @Input()
  set ngIf(condition: any) {
    this._context.$implicit = this._context.ngIf = condition;
    this._updateView();
 }
 private _updateView() {
    if (this._context.$implicit) {
      if (!this._thenViewRef) {
        this._viewContainer.clear();
        this._elseViewRef = null;
        if (this._thenTemplateRef) {
          this._thenViewRef =
              this._viewContainer.createEmbeddedView(this._thenTemplateRef, this._context);
        }
      }
    }
    ...
}

这里没有什么神奇的,只是一个简单的结构指令,检查一个布尔条件(this._context.$implicit)来创建/销毁视图!

上面的第一个 if 条件检查条件this._context.$implicit是否为true。 接下来的条件确保视图已经不是通过检查变量_thenViewRef渲染的。 如果this._context.$implicitfalse转换为true,我们只希望翻转视图。 如果两个 if 条件都为 true,则清除现有视图(this._viewContainer.clear())并清除 else 视图的引用。 最内层的 if 条件确保 if 的模板引用可用。 最后,代码调用_viewContainer.createEmbeddedView来渲染(或重新渲染)视图。

理解指令的工作原理并不困难。需要详细说明的是两个新的注入,ViewContainerRef(_viewContainer)TemplateRef(_templateRef)

TemplateRef

TemplateRef 类(_templateRef)存储了结构指令所引用的模板的引用。还记得来自第一章,构建我们的第一个应用程序 - 7 分钟锻炼的结构指令的讨论吗?所有结构指令都会使用模板 HTML。当我们使用NgIf这样的指令时:

<h3 *ngIf="currentExercise.exercise.name=='rest'"> 
  ... 
</h3> 

Angular 在内部将该声明转换为以下内容:

<ng-template [ngIf]="currentExercise.exercise.name=='rest'"> 
  <h3> ... </h3> 
</ng-template> 

这是结构指令使用的模板,_templateRef指向这个模板。

另一个注入是ViewContainerRef

ViewContainerRef

ViewContainerRef 类指向模板渲染的容器。这个类有许多便利的方法来管理视图。NgIf 实现使用的两个函数createEmbeddedViewclear,用于添加和移除模板 HTML。

createEmbeddedView 函数接受模板引用(再次注入到指令中)并渲染视图。

clear函数销毁已经注入的元素/组件并清除视图容器。因为模板(TemplateRef)中引用的每个组件及其子元素都被销毁,所有相关的绑定也随之消失。

结构指令有一个非常具体的应用领域。不过,我们可以使用TemplateRefViewContainerRef类做很多巧妙的技巧。

我们可以实现一个结构指令,根据用户角色显示/隐藏视图模板。

考虑一个假设的结构指令forRoles的例子:

<button *forRoles="admin">Admin Save</button> 

如果用户不属于admin角色,forRoles指令将不会渲染按钮。核心逻辑可能看起来像下面这样:

if(this.loggedInUser.roles.indexOf(this.forRole) >=0){ 
      this.viewContainer.createEmbeddedView(this.templateRef); 
} 
else { 
      this.viewContainer.clear(); 
}  

指令的实现将需要某种返回已登录用户详情的服务。我们将把这样的指令的实现留给读者。

forRoles指令所做的事情也可以使用NgIf来实现:

<button *ngIf="loggedInUser.roles.indexOf('admin')>=0">Admin Save</button> 

但是forRoles指令只是为模板的可读性增加了明确的意图。

结构指令的一个有趣的应用可能涉及创建一个只是复制传递给它的模板的指令。构建一个将会非常简单;我们只需调用createEmbeddedView两次:

ngOnInit() {       
 this.viewContainer.createEmbeddedView(this._templateRef);        
 this.viewContainer.createEmbeddedView(this._templateRef); 
}  

另一个有趣的练习!

ViewContainerRef 类还有一些其他函数,允许我们注入组件,获取嵌入视图的数量,重新排序视图等等。查看ViewContainerRef的框架文档(bit.ly/view-container-ref)获取更多详细信息。

我们对结构指令的讨论就到这里,是时候开始一些新的东西了!

我们迄今为止构建的组件从通用bootstrap 样式表app.css中定义的一些自定义样式中获取它们的样式(CSS)。Angular 在这方面提供了更多。一个真正可重复使用的组件应该是完全自包含的,无论是行为还是用户界面。

组件样式和视图封装

在 Web 应用程序开发中长期存在的一个问题是,当涉及 DOM 元素的行为和样式时缺乏封装性。我们无法通过任何机制将应用程序的一个部分与另一个部分隔离开来。

事实上,我们拥有太多的强大功能。通过诸如 jQuery 和强大的CSS 选择器等库,我们可以获得任何 DOM 元素并更改其行为。在访问方面,我们的代码与任何外部库的代码之间没有区别。每一段代码都可以操作任何渲染的 DOM 部分。因此,封装层被破坏了。一个编写不良的库可能会引发一些难以调试的严重问题。

CSS 样式也同样适用。如果库实现希望这样做,任何 UI 库实现都可以覆盖全局样式。

这些都是任何库开发者在构建可重复使用库时所面临的真正挑战。一些新兴的 Web 标准试图通过提出web 组件等概念来解决这个问题。

Web 组件简单来说,是可重复使用的用户界面部件,它们封装了状态样式用户界面行为。功能通过明确定义的 API 暴露,用户界面部分也被封装。

web 组件的概念的实现依赖于四个标准:

  • HTML 模板

  • Shadow DOM

  • 自定义元素

  • HTML 导入

在这个讨论中,我们感兴趣的技术标准是Shadow DOM

Shadow DOM 概述

Shadow DOM就像一个并行的 DOM 树,嵌套在一个组件内部(*一个 HTML 元素,与 Angular 组件不要混淆)中,隐藏在主 DOM 树之外。除了组件本身,应用程序的任何部分都无法访问这个 Shadow DOM。

实现 Shadow DOM 标准允许视图,样式和行为封装。了解 Shadow DOM 的最佳方式是查看 HTML5 的videoaudio标签。

你曾经想过这个audio声明是如何实现的吗:

<audio src="img/nextup.mp3" controls></audio> 

产生以下结果?

是浏览器生成潜在的 Shadow DOM 来渲染音频播放器。令人惊讶的是,我们甚至可以查看生成的 DOM!以下是我们如何实现它的步骤:

  • 拿前面的 HTML,创建一个虚拟的 HTML 页面,并在 Chrome 中打开它。

  • 然后打开开发者工具窗口(F12)。单击左上角的设置图标。

  • 在常规设置中,单击如下屏幕截图中突出显示的复选框,以启用查看 Shadow DOM 的检查功能:

刷新页面,现在如果检查生成的audio HTML,影子 DOM 就会显示出来:

shadow-root下,有一个其他部分的页面和脚本无法访问的全新世界。

在影子 DOM 领域中,shadow-root(在上述代码中的#shadow-root)是生成的 DOM 的根节点,托管在shadow host(在这种情况下是audio标签)内。当浏览器渲染这个元素/组件时,渲染的是shadow root的内容,而不是shadow host的内容。

从这次讨论中,我们可以得出结论,影子 DOM 是浏览器创建的一个并行 DOM,它封装了 HTML 元素的标记样式行为(DOM 操纵)。

这是对影子 DOM 的一个初步介绍。要了解更多关于影子 DOM 如何工作的信息,我们建议参阅 Rob Dodson 的系列文章:bit.ly/shadow-dom-intro

但这一切与 Angular 有什么关系呢?事实证明,Angular 组件也支持某种视图封装!这也允许我们为 Angular 组件隔离样式。

影子 DOM 和 Angular 组件

要理解 Angular 如何应用影子 DOM 的概念,我们首先必须学习如何为 Angular 组件设置样式。

当涉及到对本书构建的应用进行样式设置时,我们采取了一种保守的方法。无论是工作构建器还是工作程序7 分钟锻炼)应用,我们构建的所有组件都派生其样式于bootstrap CSS和在app.css中定义的自定义样式。没有一个组件定义了自己的样式。

虽然这符合 Web 应用开发的标准实践,但有时我们确实需要偏离。特别是当我们构建自包含、打包和可重用的组件时。

Angular 通过在@Component装饰器上使用style(用于内联样式)和styleUrl(外部样式表)属性,允许我们为组件定义特定样式。让我们尝试一下style属性,看看 Angular 会做什么。

在下一个练习中,我们将使用AjaxButtonComponent实现来作为我们的实验场所。但在这之前,让我们先看看AjaxButtonComponent的 HTML。AjaxButtonComponent的 HTML 树如下:

让我们使用styles属性覆盖一些样式:

@Component({ 
  ... 
  styles:[` 
    button { 
      background: green; 
    }`] 
}) 

前面的CSS 选择器background属性设置为green,应用于所有 HTML 按钮。保存前面的样式并刷新工作构建器页面。按钮样式已更新。没有惊喜吗?不对,实际上有!看一下生成的 HTML:

一些新属性被添加到许多 HTML 元素上。而最近定义的样式又落在了哪里呢?正如图片显示的,位于head标签的顶部:

head部分定义的样式具有额外的作用域,带有_ngcontent-c1属性(在您的情况下可能属性名不同)。这种作用域允许我们独立地为AjaxButtonComponent设置样式,它不能覆盖任何全局样式。

即使我们使用了styleUrls属性,Angular 也会这样做。假设我们已经将相同的 CSS 嵌入到外部 CSS 文件中并使用了这个:styleUrls:['static/css/ajax-button.css'],Angular 仍然会将样式嵌入到head部分,通过获取 CSS,解析它,然后注入它。

根据定义,本应影响应用程序中所有按钮外观的样式没有产生任何效果。Angular 对这些样式进行了作用域限定。

这种作用域确保组件样式不会干扰已定义的样式,但反之则不成立。全局样式仍会影响组件,除非在组件本身中进行覆盖。

这种作用域样式是 Angular 试图模拟影子 DOM 范式的结果。组件上定义的样式永远不会泄漏到全局样式中。所有这些都是无需任何努力的美妙之处!

如果您正在构建定义自己样式并希望具有一定隔离性的组件,请使用组件的style/styleUrl属性,而不是使用有一个公共 CSS 文件的老式方法。

我们可以通过使用名为encapsulation@Component装饰器属性进一步控制这种行为。该属性的 API 文档提到:

encapsulation: ViewEncapsulation指定模板和样式应如何封装。如果视图具有样式,则默认为ViewEncapsulation.Emulated,否则为ViewEncapsulation.None

正如我们所看到的,一旦我们在组件上设置了样式,封装效果就是Emulated。否则,它是None

如果我们将encapsulation明确设置为ViewEncapsulation.None,则作用域属性将被移除,样式将嵌入到头部部分,就像普通样式一样。

还有第三种选项,ViewEncapsulation.Native,在其中 Angular 实际上为组件视图创建了影子 DOM。将AjaxButtonComponent实现中的encapsulation属性设置为ViewEncapsulation.Native,现在查看渲染的 DOM:

AjaxButtonComponent现在有了影子 DOM!这也意味着按钮的完整样式丢失(来自 bootstrap CSS 的样式),现在按钮需要定义自己的样式。

Angular 竭尽全力确保我们开发的组件可以独立工作并且是可重用的。每个组件已经有了自己的模板和行为。除此之外,我们还可以封装组件样式,使我们能够创建健壮的独立组件。

这让我们来到了本章的结尾,是时候总结一下我们所学到的内容了。

总结

随着我们结束本章,我们现在对指令的工作原理和如何有效使用它们有了更好的理解。

我们从构建RemoteValidatorDirective开始本章,了解了很多关于 Angular 对异步验证的支持。

接下来是BusyIndicatorDirective,再次是一个很好的学习机会。我们探索了renderer服务,它允许以跨平台的方式操纵组件视图。我们还了解了host bindings,它让我们绑定到主机元素的事件属性属性

Angular 允许将指令声明在视图血统中,以便将其注入到血统中。我们专门花了一些时间来理解这种行为。

我们创建的第三个指令(组件)是AjaxButtonComponent。它帮助我们理解了内容子级视图子级对于组件的关键区别。

我们还涉及了结构指令,其中我们探索了NgIf平台指令。

最后,我们从 Angular 在视图封装方面的能力来看。我们探索了 Shadow DOM 的基础知识,并了解了框架如何采用 Shadow DOM 范式来提供视图加样式封装。

从教育的角度来看,所有这些都很有趣。它没有描述房间里的大象,当事情变得复杂时,我们如何管理我们的数据?我们需要处理的问题有:

  • 双向数据流

  • 预测性不足(一个变化可能导致级联变化)

  • 分散状态(没有真正的事实来源,我们的组件可以处于部分更新的状态)

让我们牢记这些问题,当我们开始进入第五章,“1.21 Gigawatt - Flux Pattern Explained”时。

第五章:1.21 吉瓦特 - Flux 模式解释

你的应用程序已经发展壮大,在这个过程中,你慢慢地感到你正在失去应用程序在某个时间点的知识,我们称之为应用程序的状态。可能还会出现其他问题,比如你的应用程序的某些部分与它们所知道的不一致。在一个部分发生的更新可能没有应用到其他部分,你想着这真的应该这么难吗,有没有更好的答案?

你可能只是因为听说 NgRx 是构建应用程序结构的方式而拿起这本书,你很好奇想要了解更多。

让我们先解释一下我们的标题。我们说的 1.21 吉瓦特是什么意思?我要引用电影《回到未来》中的 Doc Brown 角色(www.imdb.com/name/nm0000502/?ref_=tt_trv_qu):

"Marty, 对不起,但是产生 1.21 吉瓦特电力的唯一能源就是一道闪电。"

为什么我们要谈论电影《回到未来》?这就是 Flux 这个名字的来源。现在是时候再引用同一部电影的台词了:

"是的!当然!1955 年 11 月 5 日!那天我发明了时间旅行。我还记得清楚。我站在马桶的边缘挂钟,瓷器是湿的,我滑倒了,撞到了水池,当我醒来时,我有了一个启示!一个幻觉!我脑海中有了一个画面!这个画面使时间旅行成为可能:flux电容器!"

所以你可以看到,对于名为 Flux 的名字有一个解释。很明显,它允许我们时间旅行。至少对于 Redux 来说,我们稍后会在这本书中写到,通过一种称为时间旅行调试的东西,时间旅行是可能的。是否需要一道闪电,那就由你这位亲爱的读者来掐腕验证。

Flux 是 Facebook 创建的一种架构模式。它的产生是因为人们认为 MVC 模式根本无法扩展。随着越来越多的功能被添加,大型的代码库变得脆弱、复杂,最重要的是,不可预测。现在让我们停顿一下,想一想这个词,不可预测。

当模型和视图的数量真正增长时,大型系统被认为会变得不可预测,因为它们之间存在双向数据流,如下图所示:

在这里,我们可以看到模型和视图的数量开始增长。只要一个模型与一个视图进行交流并且反之亦然,一切都还算控制在一定范围内。然而,这种情况很少发生。在上述图表中,我们看到突然之间一个视图可以与多个模型交流,反之亦然,这意味着系统产生了级联效应,我们突然失去了控制。当然,只有一个偏离的箭头看起来并不那么糟糕,但想象一下,如果这个箭头突然变成了十个箭头,我们就真的遇到了严重的问题。

正是因为我们允许双向数据流发生,事情才变得复杂,我们失去了可预测性。对此的解药或治疗被认为是一种更简单类型的数据流,即单向流。现在,有一些关键角色参与了启用单向数据流,这就是这一章节要教我们的内容。

在本章中,我们将学到:

  • 动作和动作创建者是什么

  • 分发者在你的应用程序中扮演了一个中心角色,作为消息的中心

  • 使用存储库进行状态管理

  • 如何通过编码一个 Flux 应用程序流将我们对 Flux 的知识付诸实践

核心概念概述

Flux 模式的核心是单向数据流。它使用一些核心概念来实现这种流。主要思想是当 UI 上创建了一个事件,通过用户的交互,会产生一个动作。这个动作包括一个意图和一个载荷。意图是你想要实现的目标。把意图想象成一个动词。添加一个项目,删除一个项目,等等。载荷是需要发生的数据变化,以实现我们的意图。如果我们试图添加一个项目,那么载荷就是新添加的项目。然后,动作通过分发者在流中传播。动作及其数据最终会进入存储库。

组成 Flux 模式的概念包括:

  • 动作和动作创建者,其中我们设定了一个意图和数据的载荷

  • 分发者,我们的网页蜘蛛,能够左右发送消息

  • 存储库,我们的状态和状态管理的中心位置

所有这些构成了 Flux 模式,并促进了单向数据流。考虑下面的图表:

这里描绘的是一个单向数据流。数据从视图动作,从动作分发者,从分发者存储库。触发流的有两种可能的方式:

  • 应用程序第一次加载时,会从存储库中提取数据,以填充视图。

  • 用户在视图中发生交互,导致了改变的意图。意图被封装在一个动作中,并随后通过分发者发送到存储库。在存储库中,它可以被持久化到数据库中,通过API或保存为应用程序状态,或两者兼而有之。

让我们在接下来的章节中深入探讨每个概念,并强调一些代码示例。

一个统一的数据流

让我们从最顶部开始介绍参与我们统一数据流中的所有方,概念概念地一步一步向下展开。我们将构建一个应用程序,由两个视图组成。在第一个视图中,用户将从列表中选择一个项目。这应该导致创建一个动作。然后,该动作将由调度器分派。该动作及其载荷最终将进入存储。与此同时,另一个视图将从存储中监听变化。当选定项目时,第二个视图将知道并因此可以在其 UI 中指示特定项目已被选定。在高层次上,我们的应用程序及其流程将如下所示:

动作 - 捕捉意图

一个动作就是一个简单的意图,伴随着数据,也就是一条消息。但是一个动作是如何产生的呢?一个动作是由用户与 UI 交互时产生的。用户可能会在列表中选择特定的项目,或者按下按钮意图提交表单。提交表单应该导致产品被创建。

让我们看看两种不同的动作:

  • 在列表中选择项目,这里我们感兴趣的是保存所选项目的索引

  • 将待办事项保存到待办事项列表中

一个动作由一个对象表示。该对象具有两个感兴趣的属性:

  • 类型:这是一个唯一的字符串,告诉我们动作的意图,例如,选择项目

  • 数据:这是我们打算持久保存的数据,例如所选项目的数值索引

考虑到我们的第一个示例动作,该动作的代码表示看起来像下面这样:

{
  type: 'SELECT_ITEM',
  data: 3 // selected index
}

好的,我们已经准备好我们的动作,我们也可以将其视为一条消息。我们希望发送消息以便在 UI 中突出显示所选项。由于这是一个单向流动,我们需要遵循一条既定的航线,并将消息传递给下一个方,也就是调度器。

调度器 - 网络中的蛛网

将调度器视为处理传递给它的消息的网络中的蜘蛛。你也可以将调度器视为一名邮差,承诺您的消息将到达目的地。调度器存在的一个作用就是将消息分派给任何愿意倾听的人。在 Flux 架构中通常只有一个调度器,典型的用法看起来像这样:

dispatcher.dispatch(message);

听取调度器的消息

我们已经确定调度器会将消息分派给任何愿意倾听的人。现在是时候成为那个倾听者了。调度器需要一个注册订阅方法,以便你这个倾听者有能力倾听传入的消息。通常的设置看起来像这样:

dispatcher.register(function(message){});

现在,当你这样设置监听器时,它将有能力监听到发送的任何消息类型。你需要缩小范围;通常,监听器被指定为只处理围绕某一主题的几种消息类型。您的监听器大多看起来像这样:

dispatcher.register((message) => {
  switch(message.type) {
    case 'SELECT_ITEM':
      // do something
  }
});

好的,我们可以筛选出我们关心的消息类型,但在填写实际代码之前,我们需要考虑一下这个监听器是谁。答案很简单:就是 store。

store - 管理状态,数据检索和回调方法

容易认为 store 是数据存储的地方。然而,这并不是它的全部功能。下面的列表可以表达 store 的责任是什么:

  • 状态的持有者

  • 管理状态,可以根据需要进行更新

  • 能够处理通过 HTTP 获取/持久化数据等副作用

  • 处理回调方法

如你所见,这不只是存储状态。现在让我们重新连接到设置与dispatcher监听器相关的工作。让我们将该代码移动到我们的 store 文件store.js中,并将我们的消息内容保存在 store 中:

// store.js

let store = {};

function selectIndex(index) {
  store["selectedIndex"] = index;
}

dispatcher.register(message => {
  switch (message.type) {
    case "SELECT_INDEX":
      selectIndex(message.data);
      break;
  }
});

好的,现在 store 已经知道了新索引的情况,但重要的一点被遗漏了,我们该如何告诉 UI?我们需要一种方法告诉 UI 发生了变化。变化意味着 UI 应该重新读取它的数据。

视图

要告诉视图发生了什么并对其进行操作,需要发生三件事:

  • 视图需要注册为 store 的监听器

  • store 需要发送一个传达变化已发生的事件

  • 视图需要重新加载其数据

从 store 开始,我们需要构建它,以便您可以注册为其事件的监听器。因此,我们添加addListener()方法:

// store-with-pubsub.js

function selectIndex(index) {
  store["selectedIndex"] = index;
}

// registering with the dispatcher
dispatcher.register(message => {
  switch (message.type) {
    case "SELECT_INDEX":
      selectIndex(message.data);

      // signals to the listener that a change has happened
      store.emitChange();
 break;
 }
});

class Store {
  constructor() {
    this.listeners = [];
  }

  addListener(listener) {
 if (!this.listeners["change"]) {
      this.listeners["change"] = [];
    }
 this.listeners["change"].push(listener);
  }

  emitChange() {
    if (this.listeners["change"]) {
      this.listeners["change"].forEach(cb => cb());
    }
  }

  getSelectedItem() {
    return store["selectedIndex"];
  }
}

const store = new Store();
export default store;

在前述代码中,我们还添加了使用emitChange()方法发出事件的能力。您可以很容易地切换该实现以使用EventEmitter或类似的东西。现在是将我们的视图与 store 连接的时候了。我们通过以下方式调用addListener()方法来实现:

// view.js

import store from "./store-with-pubsub";

class View {
  constructor(store) {
    this.index = 0;
    store.addListener(this.notifyChanged);
  }

  // invoked from the store
  notifyChanged() {
    // rereads data from the store
 this.index = store.getSelectedItem();

    // reloading the data
    render();
  }
  render() {
    const elem = document.getElementById('view');
    elem.innerHTML = `Your selected index is: ${this.index}`;
  }
}

let view = new View();

// view.html
<html>
  <body>
    <div id="view"></div>
  </body>
</html>

在前述代码中,我们实现了notifyChanged()方法,当调用时会从 store 中调用getSelectedItem()方法,从而接收到新的值。

在这一点上,我们已经描述了整个链条:一个视图如何接收用户交互,将其转换为操作,然后发送到 store,然后更新 store 的状态。然后 store 发出一个其他视图正在监听的事件。当事件被接收时,在视图中从 store 中重新读取状态,然后视图可以自由地渲染这个刚刚读取的状态,以它认为合适的方式。

我们在这里描述了两件事情:

  • 如何设置流程

  • Flux 中的信息流

设置流程可以通过以下图示来描述:

至于第二种情况,信息流如何通过系统流动,可以用下面的方式来描述:

演示统一数据流

好的,我们已经描述了我们的应用程序包括的部分:

  • 用户可以选择索引的视图

  • 一个允许我们发送消息的分发器

  • 包含我们选择的索引的存储器

  • 从存储器中读取所选索引的第二个视图

让我们从所有这些中构建一个真正的应用程序。以下代码可以在Chapter2/demo目录下的代码库中找到。

创建选择视图

首先我们需要我们的视图,在其中我们将执行选择:

// demo/selectionView.js

import dispatcher from "./dispatcher";

console.log('selection view loaded');

class SelectionView {
  selectIndex(index) {
 console.log('selected index ', index);
    dispatcher.dispatch({
 type: "SELECT_INDEX",
      data: index
 });
 }
}

const view = new SelectionView();
export default view;

我们已经用粗体标出了上面我们打算使用的selectIndex()方法。

添加分发器

接下来,我们需要一个分发器,能够接受我们的消息,如下所示:

// demo/dispatcher.js

class Dispatcher {
  constructor() {
    this.listeners = [];
  }

  dispatch(message) {
    this.listeners.forEach(listener => listener(message));
  }

  register(listener) {
    this.listeners.push(listener);
  }
}

const dispatcher = new Dispatcher();
export default dispatcher;

添加存储器

存储器将作为我们状态的数据源,但也能够在存储器发生更改时告诉任何监听器:

// demo/store.js

import dispatcher from './dispatcher';

function selectIndex(index) {
  store["selectedIndex"] = index;
}

// 1) store registers with dispatcher
dispatcher.register(message => {
  switch (message.type) {
    // 3) message is sent by dispatcher ( that originated from the first view)
    case "SELECT_INDEX":
      selectIndex(message.data);
      // 4) listener, a view, is being notified of the change
      store.emitChange();
      break;
    }
});

class Store {
  constructor() {
    this.listeners = [];
  }

  // 2) listener is added by a view
  addListener(listener) {
    if (!this.listeners["change"]) {
      this.listeners["change"] = [];
    }

    this.listeners["change"].push(listener);
  }

  emitChange() {
    if (this.listeners["change"]) {
      this.listeners["change"].forEach(cb => cb());
    }
  }

  getSelectedItem() {
    return store["selectedIndex"];
  }
}

const store = new Store();
export default store;

添加一个选择视图

此视图将向存储器注册自身,并要求更新其内容。如果有任何更新,它将收到通知,并且将从存储器中读取数据,此视图将传达存储器值的现在是什么:

// demo/selectedView.js

import store from "./store";

console.log('selected view loaded');

class SelectedView {
  constructor() {
    this.index = 0;
    store.addListener(this.notifyChanged.bind(this));
  }

  notifyChanged() {
    this.index = store.getSelectedItem();
    console.log('new index is ', this.index);
  }
}

const view = new SelectedView();
export default SelectedView;

运行演示

在我们运行演示之前,我们需要一个应用文件app.jsapp.js文件应该要求我们的视图,并且执行选择:

// demo/app.js

import selectionView from './selectionView';
import selectedView from './selectedView';

// carry out the selection
selectionView.selectIndex(1);

要运行我们的演示,我们需要编译它。上面我们使用了 ES2015 模块。为了编译这些模块,我们将使用webpack。我们需要在我们的终端中键入以下内容来安装webpack

npm install webpack webpack-cli --save-dev

一旦我们这样做了,我们需要创建webpack.config.js文件,告诉 Webpack 如何编译我们的文件以及将生成的捆绑包放在哪里。该文件如下所示:

// webpack.config.js

module.exports = {
  entry: "./app.js",
  output: { 
    filename: "bundle.js"
  },
  watch: false
};

这告诉 Webpackapp.js是我们应用程序的入口点,并且在创建输出文件bundle.js时应该爬取所有的依赖项。Webpack 默认会将bundle.js放在dist目录中。

还有一件事,我们需要一个名为index.html的 HTML 文件。我们将放在dist文件夹下。它应该是这样的:

// demo/dist/index.html

<html>
  <body>
    <script src="img/bundle.js"></script>
  </body>
</html>

最后,为了运行我们的应用程序,我们需要使用 Webpack 编译它,并启动一个 HTTP 服务器并打开浏览器。我们将在demo目录中使用以下命令完成所有操作:

webpack && cd dist && http-server -p 5000

现在,打开浏览器并导航到http://localhost:5000。您应该会看到以下内容:

所有这些演示了如何使用分发器和存储器进行视图通信。

在我们的流程中添加更多操作

让我们来做一个现实检查。我们还没有将 Flux 流组织得像我们可以使它美观。总体概览是正确的,但如果我们能稍微整理一下,为更多操作留出空间,那将��很好,这样我们就可以更好地了解应用程序应该如何从这里发展。

清理视图

首要任务是查看我们的第一个视图以及它如何对用户交互的反应。目前它看起来像这样:

// first.view.js

import dispatcher from "./dispatcher";

class FirstView {
  selectIndex(index) {
    dispatcher.dispatch({
      type: "SELECT_INDEX",
      data: index
    });
  }
}

let view = new FirstView();

在我们的流程中添加更多操作意味着我们将向视图中添加一些方法,如下所示:

// first.viewII.js

import dispatcher from "./dispatcher";

class View {
  selectIndex(data) {
    dispatcher.dispatch({
      type: "SELECT_INDEX",
      data
    });
  }

  createProduct(data) {
    dispatcher.dispatch({
      type: "CREATE_PRODUCT",
      data
    });
  }

  removeProduct(data) {
    dispatcher.dispatch({
      type: "REMOVE_PRODUCT",
      data
    });
  }
}

let view = new View(); 

好的,现在我们知道如何添加动作了。但是看起来有点丑陋,因为有这么多调用dispatcher和魔术字符串,所以我们通过创建一个包含常量的文件product.constants.js稍微清理一下,其中包含以下代码:

// product.constants.js

export const SELECT_INDEX = "SELECT_INDEX",
export const CREATE_PRODUCT = "CREATE_PRODUCT",
export const REMOVE_PRODUCT = "REMOVE_PRODUCT"

让我们再做一件事。让我们把dispatcher移到product.actions.js中;这通常被称为动作创建者。它将包含dispatcher并引用我们的product.constants.js文件。所以让我们创建该文件:

// product.actions.js

import {
  SELECT_INDEX,
  CREATE_PRODUCT,
  REMOVE_PRODUCT
} from "./product-constants";
import dispatcher from "./dispatcher";
import ProductConstants from "./product.constants";

export const selectIndex = data =>
  dispatcher.dispatch({
    type: SELECT_INDEX,
    data
  });

export const createProduct = data =>
  dispatcher.dispatch({
    type: CREATE_PRODUCT,
    data
  });

export const removeProduct = data =>
  dispatcher.dispatch({
    type: REMOVE_PRODUCT,
    data
  });

使用这些结构,我们可以大大简化视图,看起来像这样:

// first.viewIII.js

import { 
  selectIndex, 
  createProduct, 
  removeProduct 
} from 'product.actions';

function View() {
  this.selectIndex = index => {
    selectIndex(index);
  };

  this.createProduct = product => {
    createProduct(product);
  };

  this.removeProduct = product => {
    removeProduct(product)
  };
}

var view = new View();

清理商店

我们可以对商店做出改进。没有必要编写我们目前所做的所有代码。实际上,市面上有一些库能更好地处理某些功能。

在我们计划应用所有这些变化之前,让我们回顾一下我们的商店到底能做些什么,以及清理工作完成后仍需要具备哪些功能。

让我们想一想,到目前为止我们的商店能做什么:

  • 处理状态变化:它处理状态变化;无论是创建、更新、列出还是删除状态,商店都能改变状态。

  • 可订阅的:它可以让您订阅它;商店具有订阅功能对于视图来说很重要,例如,当状态发生变化时,视图可以监听商店的状态。视图可能会根据新数据重新渲染。

  • 可以传达状态变化:它可以发送状态已更改的事件;这与能够订阅商店相搭配使用,但这实际上是通知监听器状态已更改的行为。

添加 EventEmitter

最后两点实际上可以归结为一个主题,即事件处理,或者能够注册并触发事件的功能。

那么清理商店是什么样子的,为什么我们需要清理呢?清理的原因是使代码更简单。通常在构建商店时会使用一个标准库,称为EventEmitter。该库处理了我们之前提到的内容,即能够注册和触发事件。这是发布-订阅模式的简单实现。基本上,EventEmitter允许您订阅特定事件,并且也允许您触发事件。有关模式本身的更多信息,请参阅以下链接:en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern

你肯定能为此编写自己的代码,但能够使用专用库让您可以专注于其他重要事项,比如解决业务问题,这真的很好。

我们决定使用EventEmitter库,并且我们这样做:

// store-event-emitter.js

export const Store = (() => {
  const eventEmitter = new EventEmitter();
  return {
    addListener: listener => {
      eventEmitter.on("changed", listener);
    },
    emitChange: () => {
      eventEmitter.emit("changed");
    },
    getSelectedItem: () => store["selectedItem"]
  };
})();

这使我们的代码变得更清晰,因为我们不再需要保存内部订阅者列表。虽然我们可以做更多的改变,但让我们在下一节中讨论一下。

添加和清理注册方法

存储的工作之一是处理事件,特别是当存储想要向视图传达其状态发生了变化时。在store.js文件中,还发生着其他一些事情,比如注册我们自己与dispatcher并能够接收分发的操作。我们使用这些操作来改变存储的状态。让我们提醒自己那是什么样子:

// store.js

let store = {};

function selectIndex(index) {
  store["selectedIndex"] = index;
}

dispatcher.register(message => {
  switch (message.type) {
    case "SELECT_INDEX":
      selectIndex(message.data);
      break;
  }
});

这里,我们只支持一个操作,即SELECT_INDEX。我们在这里需要做两件事:

  • 添加另外两个操作,CREATE_PRODUCTREMOVE_PRODUCT,以及相应的函数createProduct()removeProduct()

  • 停止使用神秘字符串,开始使用我们的常量文件

  • 使用我们在store-event-emitter.js文件中创建的存储

让我们实现前面列表中建议的更改:

// store-actions.js

import dispatcher from "./dispatcher";
import {
  SELECT_INDEX,
  CREATE_PRODUCT,
  REMOVE_PRODUCT
} from "./product.constants";

let store = {};

function selectIndex(index) {
  store["selectedIndex"] = index;
}

export const Store = (() => {
  var eventEmitter = new EventEmitter();
  return {
    addListener: listener => {
      eventEmitter.on("changed", listener);
    },
    emitChange: () => {
      eventEmitter.emit("changed");
    },
    getSelectedItem: () => store["selectedItem"]
  };
})();

dispatcher.register(message => {
  switch (message.type) {
    case "SELECT_INDEX":
      selectIndex(message.data);
      break;
  }
});

const createProduct = product => {
  if (!store["products"]) {
    store["products"] = [];
  }
 store["products"].push(product);
};

const removeProduct = product => {
  var index = store["products"].indexOf(product);
  if (index !== -1) {
    store["products"].splice(index, 1);
  }
};

dispatcher.register(({ type, data }) => {
  switch (type) {
    case SELECT_INDEX:
      selectIndex(data);
      break;
    case CREATE_PRODUCT:
      createProduct(data);
 break;
    case REMOVE_PRODUCT:
      removeProduct(data);
 }
});

更多的改进

我们的代码肯定还有更多的改进空间。我们使用了 ES2015 的导入来导入其他文件,但我们大部分的代码都是用 ES5 编写的,所以为什么不充分利用 ES2015 给我们的大多数功能呢?我们可以做的另一个改进是引入不可变性,并确保我们的存储不是被突变的,而是从一个状态过渡到另一个状态。

让我们看一下存储文件,因为那是我们可以添加最多 ES2015 语法的地方。我们目前的模块模式看起来是这样的:

// store-event-emitter.js

var Store = (function(){
  const eventEmitter = new EventEmitter();

  return {
    addListener: listener => {
      eventEmitter.on("changed", listener);
    },
    emitChange: () => {
      eventEmitter.emit("changed");
    },
    getSelectedItem: () => store["selectedItem"]
  };
})();

它可以用一个简单的类来替换,而不是实例化一个EventEmitter,我们可以继承它。公平地说,我们可以使用 ES2015 继承或合并库来避免创建一个单独的EventEmitter实例,但这展示了 ES2015 可以使事情多么简洁:

// store-es2015.js

import { EventEmitter } from "events";
import {
SELECT_INDEX,
CREATE_PRODUCT,
REMOVE_PRODUCT
} from "./product.constants";

let store = {};

class Store extends EventEmitter {
  constructor() {}
    addListener(listener) {
 this.on("changed", listener);
  }

 emitChange() {
 this.emit("changed");
  }

 getSelectedItem() {
 return store["selectedItem"];
  }
}

const storeInstance = new Store();

function createProduct(product) {
  if (!store["products"]) {
    store["products"] = [];
  }
  store["products"].push(product);
}

function removeProduct(product) {
  var index = store["products"].indexOf(product);
  if (index !== -1) {
    store["products"].splice(index, 1);
  }
}

dispatcher.register(({ type, data }) => {
  switch (type) {
    case SELECT_INDEX:
      selectIndex(data);
      storeInstance.emitChange();
      break;
    case CREATE_PRODUCT:
      createProduct(data);
      storeInstance.emitChange();
      break;
    case REMOVE_PRODUCT:
      removeProduct(data);
      storeInstance.emitChange();
  }
});

增加不可变性

我们可以做的另一件事是增加不可变性。首先使用不可变性的理由是使您的代码更可预测,一些框架可以使用这一点进行更简单的变化检测,并且可以依靠引用检查而不是脏检查。当 AngularJS 的整个变化检测机制在编写 Angular 时改变时,情况就是如此。从实际的角度来看,这意味着有一些函数我们可以在我们的存储中进行操作,并应用不可变性原则。第一个原则是不要改变,而是创建一个全新的状态,而不是新状态是旧状态+状态变化。一个简单的例子是:

var oldState = 3;
var newState = oldState + 2

在这里,我们创建了一个新变量newState,而不是突变我们的oldState变量。有一些函数可以帮助我们做到这一点,叫做Object.assign和函数 filter。我们可以用它们来更新情况,以及从列表中添加或删除东西。让我们使用它们并重新编写我们的存储代码的一部分。让我们突出显示我们打算更改的代码:

// excerpt from store-actions.js

const createProduct = product => {
  if (!store["products"]){ 
    store["products"] = [];
  }
  store["products"].push(product);
};

const removeProduct = product => {
  var index = store["products"].indexOf(product);
  if (index !== -1) {
    store["products"].splice(index, 1);
  }
};

让我们应用Object.assignfilter(),并记得不要改变东西。 最终结果应该是这样的:

// excerpt from our new store-actions-immutable.js

const createProduct = product => {
  if (!store["products"]) {
    store["products"] = [];
  }
  store.products = [...store.products, Object.assign(product)];
};

const removeProduct = product => {
  if (!store["products"]) return;

  store["products"] = products.filter(p => p.id !== product.id);
};

我们可以看到createProduct()方法使用了一个 ES2015 构造,即 spread 参数,...,它接受一个列表并将其成员转换为逗号分隔的项目列表。Object.assign()用于复制对象的所有值,因此我们存储的是对象的值而不是它的引用。 使用 filter 方法时,removeProduct()方法变得非常简单。 我们只需创建一个投影,不包括应该删除的产品;删除从未如此简单或优雅。 我们没有改变任何东西。

总结

我们的清理从视图开始;我们想要删除对 dispatcher 的直接连接,也不再需要使用魔术字符串,因为这非常容易出错,并且很容易拼错。 相反,我们可以依赖于常量。 为了解决这个问题,我们创建了一个与 dispatcher 通信的 action creator 类。

我们还创建了一个常量模块来删除魔术字符串。

此外,我们通过开始使用EventEmitter来改进存储。 最后,我们通过给它添加更多动作并开始引用常量来进一步改进存储。

在这一点上,我们的解决方案已经准备好接受更多的动作,并且我们应该非常清楚需要添加到哪些文件中,因为我们支持越来越多的用户交互。

最后,我们围绕 ES2015 和不可变性进行了改进,使得我们的代码看起来更加整洁。 有了这个基础,我们现在可以从静态数据转为涉及副作用和 Ajax 的工作。

让我们在图表中总结我们所有的改进,显示添加到我们流程中的构造:

很明显,使用 action creator 并不是必须的,但它确实清理了代码,并且对存储使用 EventEmitter也是如此;很好但不是必需的。

添加 AJAX 调用

到目前为止,我们在 Flux 流中只处理静态数据。 现在是时候向流程添加真实数据连接,因此添加真实数据。 是时候开始通过 AJAX 和 HTTP 与 API 进行通信了。 获取数据现在相当容易,多亏了 fetch API 和 RxJS 等库。 在将其纳入流程时,你需要考虑以下事项:

  • 在哪里进行 HTTP 调用

  • 如何确保存储得到更新并通知感兴趣的视图

我们注册存储到dispatcher的代码如下:

// excerpt from store-actions-immutable.js

const createProduct = (product) => {
  if (!store["products"]) {
    store["products"] = [];
  }

  store.products = [...store.products, Object.assign(product)];
}

dispatcher.register(({ type, data }) => {
  switch (type) {
    case CREATE_PRODUCT:
      createProduct(data);
      store.emitChange();
      break;
      /* other cases below */
  }
})

如果我们真的这么做,即调用 API 来保存这个产品,createProduct()将是我们调用 API 的地方,如下所示:

// example use of fetch()

fetch(
  '/products' ,
  { method : 'POST', body: product })
  .then(response => {
   // send a message to the dispatcher that the list of products should be reread
}, err => {  
  // report error
});

调用 fetch() 返回一个 Promise。 然而,让我们使用 async/await,因为它使调用变得更加可读。 代码上的差异可见以下示例:

// contrasting example of 'fetch() with promise' vs 'fetch with async/await'

fetch('url')
 .then(data => console.log(data))
 .catch(error => console.error(error));

 // using async/await
 try {
   const data = await fetch('url');
   console.log(data);
 } catch (error) {
   console.error(error);
 }

用这种方法替换createProduct()中发生的事情会添加大量噪音的代码,因此将您的 HTTP 交互封装在 API 结构中是一个好主意,如下所示:

// api.js 

export class Api {
  createProduct(product) {
    return fetch("/products", { method: "POST", body: product });
  }
}

现在让我们用调用我们的 API 结构来替换createProduct()方法的内容,如下所示:

// excerpt from store-actions-api.js

import { Api } from "./api";

const api = new Api();

createProduct() {
  api.createProduct();
}

不过,这还不够。因为我们通过 API 调用创建了一个产品,所以我们应该发出一个强制产品列表重新读取的动作。我们没有这样的动作或支持方法在存储中处理它,所以让我们添加一个:

// product.constants.js

export const SELECT_INDEX = "SELECT_INDEX";
export const CREATE_PRODUCT = "CREATE_PRODUCT";
export const REMOVE_PRODUCT = "REMOVE_PRODUCT";
export const GET_PRODUCTS = "GET_PRODUCTS";

现在让我们在存储中添加所需的方法,并处理它的情况:

// excerpt from store-actions-api.js

import { Api } from "./api";
import {
  // other actions per usual
  GET_PRODUCTS,
} from "./product.constants";

const setProducts = (products) => {
 store["products"] = products;
}

const setError = (error) => {
  store["error"] = error;
}

dispatcher.register( async ({ type, data }) => {
  switch (type) {
    case CREATE_PRODUCT:
      try {
        await api.createProduct(data);
        dispatcher.dispatch(getProducts());
      } catch (error) {
        setError(error);
        storeInstance.emitError();
      }
      break;
    case GET_PRODUCTS:
 try {
 const products = await api.getProducts();
 setProducts(products);
 storeInstance.emitChange();
 }
 catch (error) {
 setError(error);
 storeInstance.emitError();
 }
 break;
  }
});

我们可以看到CREATE_PRODUCT情况将调用相应的 API 方法createProduct(),在完成时将分发GET_PRODUCTS动作。这样做的原因是,当我们成功创建产品时,我们需要从端点读取以获取产品列表的更新版本。我们不能详细看到这一点,但它是通过我们调用getProducts()来调用的。同样,封装每个被分发的东西是很好的,这个封装就是一个动作创建者。

整个文件看起来像这样:

// store-actions-api.js

import dispatcher from "./dispatcher";
import { Action } from "./api";
import { Api } from "./api";
import {
  CREATE_PRODUCT,
  GET_PRODUCTS,
  REMOVE_PRODUCT,
  SELECT_INDEX
} from "./product.constants";

let store = {};

class Store extends EventEmitter {
  constructor() {}
  addListener(listener) {
    this.on("changed", listener);
  }

  emitChange() {
    this.emit("changed");
  }

  emitError() {
    this.emit("error");
  }

  getSelectedItem() {
    return store["selectedItem"];
  }
}

const api = new Api();
const storeInstance = new Store();

const selectIndex = index => {
  store["selectedIndex"] = index;
};

const createProduct = product => {
  if (!store["products"]) {
    store["products"] = [];
  }
  store.products = [...store.products, Object.assign(product)];
};

const removeProduct = product => {
  if (!store["products"]) return;
  store["products"] = products.filter(p => p.id !== product.id);
};

const setProducts = products => {
  store["products"] = products;
};

const setError = error => {
  store["error"] = error;
};

dispatcher.register(async ({ type, data }) => {
  switch (type) {
    case "SELECT_INDEX":
      selectIndex(message.data);
      storeInstance.emitChange();
      break;
    case CREATE_PRODUCT:
      try {
        await api.createProduct(data);
        storeInstance.emitChange();
      } catch (error) {
        setError(error);
        storeInstance.emitError();
      }
      break;
    case GET_PRODUCTS:
      try {
        const products = await api.getProducts();
        setProducts(products);
        storeInstance.emitChange();
      } catch (error) {
        setError(error);
        storeInstance.emitError();
      }
      break;
   }
});

更大的解决方案

到目前为止,我们一直在描述一个只包含产品主题的解决方案,通信只发生在一个视图到另一个视图。在一个更现实的应用程序中,我们将有许多主题,如用户管理、订单等;它们的确切名称取决于您应用程序的领域。至于视图,很可能你会有大量的视图监听另一个视图,就像这个例子中一样:

这描述了一个包含四个不同视图组件的应用程序,围绕它们自己的主题。客户视图包含客户列表,并且允许我们更改我们当前想要关注的客户。另外三个支持视图显示订单消息朋友,它们的内容取决于当前突出显示的客户。从 Flux 的角度来看,订单消息朋友视图可以轻松地向存储注册,以知道何时更新了,因此它们可以获取/重新获取它们需要的数据。然而,想象一下,支持视图自身想要支持 CRUD 操作;然后它们将需要自己的一组常量、动作创建者、API 和存储。因此,现在您的应用程序需要看起来像这样:

/customers 
  constants.js
  customer-actions.js
  customer-store.js
  customer-api.js
/orders
  constants.js
  orders-actions.js
  orders-store.js
  orders-api.js
/messages
  constants.js
  messages-actions.js
  messages-store.js
  messages-api.js
/friends
  constants.js
  friends-actions.js
  friends-store.js
  friends-api.js
/common
  dispatcher.js

这里存在两种有趣的情况:

  • 您有一个独立的视图;所有 CRUD 操作都在它内部发生

  • 您有一个需要监听其他视图的视图

对于第一种情况,一个很好的经验法则是创建自己的一组常量、动作创建者、API 和存储。

对于第二种情况,请确保您的视图向该主题的存储注册自己。例如,如果朋友视图需要监听客户视图,那么它需要向客户存储注册自己。

摘要

我们开始只是想解释 Flux 架构模式。很容易就开始提及它如何与 React 配合,以及有哪些支持 Flux 和 React 的好用库和工具。然而,这样做会使我们的焦点偏离了从更加框架无关的角度解释这一模式的初衷。因此,本章的其他部分致力于解释核心概念,如动作、动作创建者、分发器、仓库和统一数据流。我们逐渐改进了代码,开始使用常量、动作创建者和一个很好的支持库,比如EventEmitter。我们解释了 HTTP 如何嵌入其中,最后,我们讨论了如何构建我们的应用程序。关于 Flux 还有很多可以说的,但我们选择限制范围,以便了解基本原理,这样我们就可以在后续章节中深入研究 Redux 和 NgRx 的方式进行比较。

下一章将在此基础上介绍函数响应式编程FRP)的概念。它更多地处理的是如何理解数据似乎随时到来的事实。尽管听起来很混乱,但甚至这也可以被建模为创建一种结构和秩序的感觉,只要我们把我们的数据看作是一种流。关于这一点,下一章会详细介绍。

第六章:函数式响应式编程

根据维基百科,函数式响应式编程 (FRP) 是用于响应式编程的一种编程范式,它使用函数式编程的构建模块。好的,这听起来挺高大尚的,但是它是什么意思呢?要理解整个句子,我们需要把它拆开来。让我们试着定义以下内容:

  • 编程范式 是围绕程序应该如何组织和结构化的总体理论或工作方式。面向对象编程和函数式编程就是编程范式的例子。

  • 响应式编程 简单来说是利用异步数据流进行编程。异步数据流是值可以在任何时间到达的数据流。

  • 函数式编程 是一种采用更数学化方法的编程范式,它将函数调用视为数学计算,从而避免更改状态或处理可变数据。

总的来说,我们的维基百科定义意味着我们对可能在任何时间到达的值采取了一种函数式编程方法。这并不意味着太多,但希望在本章结束时事情会有所明朗。

在本章中,我们将学习以下内容:

  • 异步数据流

  • 如何操作这些流

递归

“要理解递归这个词,请参见递归这个词。”

这在大多数工程学校都是一个笑话,并且以一种非常简短的方式解释了这是什么。递归是一个数学概念。让我们稍微解释一下。官方定义如下:

当过程的一个步骤涉及到调用过程本身时,递归是过程通过的过程。进行递归的过程被称为“递归的”。

好的,那用人话怎么说?这意味着在运行我们的函数的某个时刻,我们会调用自己。这意味着我们有一个看起来像这样的函数:

function something() {
  statement;
  statement;
  if(condition) {
    something();
  }
  return someValue;
}

我们可以看到函数something() 在其体内的某个时刻调用了自身。递归函数应该遵守以下规则:

  • 应该调用自身

  • 最终应该满足退出条件

如果递归函数没有退出条件,我们将耗尽内存,因为函数将永远调用自身。有某些类型的问题比其他更适合应用递归编程。这些类型的问题的例子有:

  • 遍历树结构

  • 编译代码

  • 为压缩编写算法

  • 对列表进行排序

还有许多其他例子,但重要的是要记住,尽管递归是一个很好的工具,但不应该随处使用。让我们看一个递归真正闪耀的例子。我们的例子是一个链接列表。链接列表由了解他们连接到的节点的节点组成。Node结构的代码如下:

class Node {
  constructor(
    public left, 
    public value
  ) {}
}

使用Node这样的结构,我们可以构建一个由几个链接节点组成的链表。我们可以以以下方式连接一组节点实例:

const head = new Node(null, 1);
const firstNode = new Node(head, 2);
const secondNode = new Node(firstNode, 3);

上述代码的图形表示将如下图所示。在这里,我们可以清楚地看到我们的节点由什么组成以及它们如何连接:

这里,我们有一个链表,其中有三个相连的节点实例。头节点与左侧节点不相连。然而第二个节点连接到第一个节点,而第一个节点连接到头节点。对列表进行以下类型的操作可能会很有趣:

  • 给定列表中的任意节点,找到头节点

  • 在列表中的特定位置插入一个节点

  • 从列表中的给定位置移除一个节点

让我们看看如何解决第一个要点。首先,我们将使用命令式方法,然后我们将使用递归方法来看看它们如何不同。更重要的是,让我们讨论为什么递归方法可能更受欢迎:

// demo of how to find the head node, imperative style

const head = new Node(null, 1);
const firstNode = new Node(head, 2);
const secondNode = new Node(firstNode, 3); 

function findHeadImperative (startNode)  {
  while (startNode.left !== null) {
    startNode = startNode.left;
  }
  return startNode;
}

const foundImp = findHeadImperative(secondNode);
console.log('found', foundImp);
console.log(foundImp === head);

正如我们在这里所见,我们使用while循环来遍历列表,直到找到其left属性为 null 的节点实例。现在,让我们展示递归方法:

// demo of how to find head node, declarative style using recursion

const head = new Node(null, 1);
const firstNode = new Node(head, 2);
const secondNode = new Node(firstNode, 3); 

function findHeadRecursive(startNode) {
  if(startNode.left !== null) {
    return findHeadRecursive(startNode.left);
  } else {
    return startNode;
  }
}

const found = findHeadRecursive(secondNode);
console.log('found', found);
console.log(found === head);

在上面的代码中,我们检查startNode.left是否为 null。如果是这种情况,我们已经到达了我们的退出条件。如果我们尚未达到退出条件,我们继续调用自己。

好的,我们有一个命令式方法和一个递归方法。为什么后者更好?嗯,使用递归方法,我们从一个长列表开始,每次调用自己的时候都使列表变短:有点分而治之的方法。递归方法显著突出的一点是,我们通过说不,我们的退出条件还没有满足,继续处理。继续处理意味着我们像在我们的if子句中那样调用自己。递归编程的要点是我们能减少代码行数吗?嗯,这可能是结果,但更重要的是:它改变了我们解决问题的思维方式。在命令式编程中,我们有一种从上到下解决问题的思维方式,而在递归编程中,我们的思维方式更多地是定义我们何时完成并将问题分解为更容易处理的部分。在上述情况下,我们舍弃了不再感兴趣的部分链表。

不再使用循环

当开始以更功能化的方式编码时,其中一个更显著的变化是我们摆脱了for循环。现在我们已经了解了递归,我们可以使用它代替。让我们看一个简单的命令式代码片段,用于打印一个数组:

// demo of printing an array, imperative style

let array = [1, 2, 3, 4, 5];

function print(arr) {
  for(var i = 0, i < arr.length; i++) {
    console.log(arr[i]); 
  }
}

print(arr);

使用递归的相应代码如下:

// print.js, printing an array using recursion

let array = [1, 2, 3, 4, 5];

function print(arr, pos, len) {
  if (pos < len) {
    console.log(arr[pos]);
    print(arr, pos + 1, len);
  }
  return;
}

print(array, 0, array.length);

如我们所见,我们的命令式代码仍在那里。我们依然从0开始。此外,我们一直持续到我们到达数组的最后位置。一旦我们达到我们的中断条件,我们就退出方法。

重复模式

到目前为止,我们还没有完全说明递归的概念。我们可能有点理解,但可能还不确定为什么不能使用老式的whilefor循环来代替。递归在解决看起来像重复模式的问题时才会显现。一个例子是树。树有一些类似的概念,例如由节点组成。一个没有子节点连接的节点称为叶子。具有子节点但与上游节点没有连接的节点称为根节点。让我们用图示说明这一点:

有一些我们想要在树上进行的有趣操作:

  • 总结节点值

  • 计算节点数

  • 计算宽度

  • 计算深度

为了尝试解决这个问题,我们需要考虑如何将树以数据结构的形式存储。最常见的建模方式是创建一个表示节点具有值、left属性和right属性的表示方法,然后这两个属性分别指向节点。因此,上述 Node 类的代码可能如下:

class NodeClass {
  constructor(left, right, value) {
    this.left = left;
    this.right = right;
    this.value = value;
  }
}

下一步是考虑如何创建树本身。此代码展示了我们如何创建一个具有根节点和两个子节点的树,以及如何将它们绑定在一起:

// tree.js

class NodeClass {
  constructor(left, right, value) {
    this.left = left;
    this.right = right;
    this.value = value;
  }
}

const leftLeftLeftChild = new NodeClass(null, null, 7);
const leftLeftChild = new NodeClass(leftLeftLeftChild, null, 1);
const leftRightChild = new NodeClass(null, null, 2);
const rightLeftChild = new NodeClass(null, null, 4);
const rightRightChild = new NodeClass(null, null, 2);
const left = new NodeClass(leftLeftChild, leftRightChild, 3);
const right = new NodeClass(rightLeftChild, rightRightChild, 5);
const root = new NodeClass(left, right, 2);

module.exports = root;

值得强调的是实例leftright没有子节点。这是因为我们在创建时将它们的值设置为null。另一方面,我们的根节点有leftright对象实例作为子节点。

总结

之后,我们需要考虑如何总结节点。看着它,似乎我们应该总结顶部节点及其两个子节点。因此,代码实现将开始如下:

// tree-sum.js

const root = require('./tree');

function summarise(node) {
  return node.value + node.left.value + node.right.value;
}

console.log(summarise(root)) // 10

如果我们的树增长并突然变成这样时会发生什么:

让我们添加到前面的代码,使其看起来像这样:

// example of a non recursive code

function summarise(node) {
  return node.value + 
    node.left.value + 
    node.right.value +
    node.right.left.value +
    node.right.right.value + 
    node.left.left.value + 
    node.left.right.value;
}

console.log(summarise(root)) // 19

这在技术上是可工作的代码,但还可以改善。在这一点上,从树的角度看,我们应该看到树中的重复模式。我们有以下三角形:

一个三角形由235组成,另一个由312组成,最后一个由542组成。每个三角形通过取节点本身及其左子节点和右子节点来计算其总和。递归就是这样的:发现重复模式并对其进行编码。现在我们可以使用递归来实现我们的summarise()函数,如下所示:

function summarise(node) {
  if(node === null) {
    return 0;
  }
  return node.value + summarise(node.left) + summarise(left.right);
}

我们在这里做的是将我们的重复模式表示为节点 + 左节点 + 右节点。当我们调用summarise(node.left)时,我们简单地再次运行summarise()以获得该节点。前面的实现简短而优雅,并能遍历整个树。一旦你发现问题可以看作是一个重复模式时,递归真是优雅。完整的代码看起来像这样:

// tree.js

class NodeClass {
  constructor(left, right, value) {
    this.left = left;
    this.right = right;
    this.value = value;
  }
}

const leftLeftLeftChild = new NodeClass(null, null, 7);
const leftLeftChild = new NodeClass(leftLeftLeftChild, null, 1);
const leftRightChild = new NodeClass(null, null, 2);
const rightLeftChild = new NodeClass(null, null, 4);
const rightRightChild = new NodeClass(null, null, 2);
const left = new NodeClass(leftLeftChild, leftRightChild, 3);
const right = new NodeClass(rightLeftChild, rightRightChild, 5);
const root = new NodeClass(left, right, 2);

module.exports = root;

// tree-sum.js

const root = require("./tree");

function sum(node) {
  if (node === null) {
    return 0;
  }
  return node.value + sum(node.left) + sum(node.right);
}

console.log("sum", sum(root));

计数

现在,实现一个用于计算树中所有节点的函数变得非常简单,因为我们开始理解递归的本质。我们可以重新使用以前的总结函数,并将每个非空节点简单地计为1,空节点计为0。因此,我们可以简单地修改现有的总结函数如下:

//tree-count.js

const root = require("./tree");

function count(node) {
  if (node === null) {
    return 0;
  } else {
    return 1 + count(node.left) + count(node.right);
  }
}

console.log("count", count(root));

上述代码确保我们成功遍历每个节点。我们的退出条件是当我们达到 null。也就是说,我们正在从一个节点尝试去到其不存在的子节点之一。

宽度

要创建一个宽度函数,我们首先需要定义宽度是什么意思。让我们再次看看我们的树:

这棵树的宽度是4。为什么呢?对于树中每走一步,我们的节点向左和向右各扩展一步。这意味着为了正确计算宽度,我们需要遍历树的边缘。每当我们需要遍历一个节点向左或向右时,我们就增加宽度。从计算的角度来看,我们感兴趣的是这样遍历这棵树:

因此,代码应反映这一事实。我们可以这样实现:

// tree-width.js

const root = require("./tree");

function calc(node, direction) {
  if (node === null) {
    return 0;
  } else {
    return (
      1 + (direction === "left" ? 
      calc(node.left, direction) : 
      calc(node.right, direction))
    );
  }
}

function calcWidth(node) {
  return calc(node.left, "left") + calc(node.right, "right");
}

console.log("width", calcWidth(root));

特别注意,在calcWidth()函数中,我们分别用node.leftnode.right作为参数调用calc()。我们还添加了leftright参数,这在calc()方法中意味着我们将沿着那个方向继续前进。我们的退出条件是最终碰到 null。

异步数据流

异步数据流是一种数据流,在延迟后一个接着一个地发出值。异步一词意味着发出的数据可能在任何时候出现,可能在一秒后或甚至在两分钟后出现。对于模拟异步流的一种方法是在时间轴上放置发出的值,就像这样:

有很多事情可能被视为异步。其中一个是通过 AJAX 获取数据。数据到达的时间取决于许多因素,比如:

  • 您的连接速度

  • 后端 API 的响应速度

  • 数据的大小,以及更多的因素。

这一点是数据并非在这一刻就到达。

其他可能被视为异步的事物包括用户发起的事件,比如滚动或鼠标点击。这些是可以在任何时间发生的事件,取决于用户的交互。因此,我们可以将这些用户界面事件视为时间轴上的连续数据流。以下图表描述了代表用户多次点击的数据流。每次点击会触发一个点击事件c,其在时间轴上的位置如下所示:

乍一看,我们的图表显示了四次点击事件。仔细观察,我们可以看到这些点击事件似乎被分组了。上面的图表包含了两条信息:

  • 已发生多次点击事件

  • 点击事件之间存在一定的延迟

在这里,我们可以看到前两次点击事件发生的时间非常接近;当两个事件发生的时间非常接近时,这将被解释为双击。因此,上面的图告诉我们发生的事件;它还告诉我们发生的时间和频率。通过查看前面的图表,很容易区分单击和双击。

我们可以为每种点击行为分配不同的动作。双击可能意味着我们想要放大,而单击可能意味着我们想要选择某些内容;确切的行为取决于您正在编写的应用程序。

第三个例子是输入。如果我们遇到一种情况,用户正在输入并在一段时间后停止了输入呢?在一定时间过去后,用户期望 UI 有所反应。这就是搜索字段的情况。在这种情况下,用户可能会在搜索字段中输入内容,并且在完成后按下搜索按钮。在 UI 中模拟这种情况的另一种方法是仅提供一个搜索字段,并等待用户停止输入,作为何时开始搜索用户想要的内容的信号。最后的例子被称为自动完成行为。可以以以下方式对其进行模拟:

输入的前三个字符似乎属于同一个搜索查询,而输入的第四个字符则出现得晚得多,可能属于另一个查询。

本节的重点在于突出不同事物适合被建模为流,并且时间轴以及发出值的放置在时间轴上的意义。

将列表与异步流进行比较 - 为使用 RxJS 做准备

到目前为止,我们已经讨论了如何将异步事件建模为时间轴上的连续数据流,或者说是流建模。事件可以是 AJAX 数据,鼠标点击,或其他类型的事件。通过这种方式对事物进行建模,会产生一种有趣的视角,但是,例如,仅仅看双击的情况,并不能让我们深入了解这个数据。还有另一种情况,我们需要过滤掉一些数据。我们在这里讨论的是如何操作数据流。如果没有这个能力,流建模本身就没有实际价值。

有不同的方法来操作数据:有时我们想要将发出的数据更改为其他数据,有时我们可能想更改将数据发送给监听器的频率。有时,我们希望我们的数据流变成完全不同的流。我们将尝试模拟以下情况:

  • 投影:改变正在发出的值的数据

  • 过滤:改变发出的内容

将函数式编程范式与流相结合

本章涵盖了函数式编程和异步数据流。使用 RxJS 并不需要对函数式编程有深入的理解,但是你需要理解声明式的意思,以期聚焦在正确的事情上。你的重点应该是要做什么,而不是如何做。作为一个库,RxJS 会负责如何实现需要的功能。

这两个可能看起来像是两个不同的主题。但是,将它们结合起来,我们就能够操纵流了。流可以被看作是一系列数据的列表,其中数据在某个时间点可用。如果我们开始将我们的流视为列表,特别是不可变的列表,那么就会有一些随列表一起的操作来通过对其应用操作符来操纵列表。操纵的结果是一个新的列表,而不是一个变异的列表。因此,让我们开始将我们的列表哲学及其操作符应用到以下情况中。

投影

在这里,我们可以看到我们的流正在发出值 1234,然后进行了一次变换,将每个值增加了一。这是一个相当简单的情况。如果我们将其视为一个列表,我们可以看到这里所做的只是一个简单的投影,我们会将其编码为:

let newList = list.map(value => value + 1)

过滤

列表中可能存在一些项,以及流中可能存在一些你不想要的项。为了解决这个问题,你需要创建一个过滤器来过滤掉不想要的数据。模拟我们初始的数组,经过处理和得到的数组,我们得到以下结果:

在 JavaScript 中,我们可以通过编写以下代码来实现这一点:

let array = [1,2,3];
let filtered = array.filter(data => data % 2 === 0);

结合心态

那么,我们在这一节想要表达什么呢。显然,我们已经展示了如何操纵列表的例子。好吧,我们所做的是展示我们如何在轴上显示项。从这个意义上说,我们可以看到,以图形方式将异步事件和值列表想成一样的方式,这样思考起来是很容易的。问题是,为什么我们要这样做呢?原因是这是 RxJS 库希望你在开始操纵和构建流时拥有的心态。

摘要

本章已经建立了我们可以将异步事件建模为时间轴上的值。我们介绍了将这些流与列表进行比较的想法,并因此对它们应用不会改变列表本身而只会创建一个新列表的函数方法。应用函数范式的好处是,我们可以专注于想要实现的内容,而不是如何实现它,从而采用了一种声明式方法。我们意识到要将异步和列表组合,从中创建可读的代码并不容易。幸运的是,这正是 RxJS 库为我们做的事情。

这一认识让我们为第八章做准备,RxJS 高级,将涵盖更多的操作符和一些更高级的概念。

第七章:操纵流及其值

操作符是我们可以在流上调用的函数,以多种不同的方式执行操作。操作符是不可变的,这使得流易于推理,并且也很容易测试。正如你将在本章中看到的,我们很少处理一个流,而是处理许多流,理解如何塑造和控制这些流,让你能够从认为这是黑魔法转变为在需要时真正应用 RxJS。

在本章中,我们将涵盖:

  • 如何使用基本操作符

  • 使用操作符以及现有工具调试流

  • 深入了解不同的操作符类别

  • 以 Rx 的方式培养解决问题的思维方式

初始阶段

你几乎总是从创建一组静态值的 RxJS 开始编码。为什么要使用静态值?嗯,没有必要使它过于复杂,你真正需要开始推理的只是一个Observable

然后你开始考虑你想达到什么目标。这让你考虑到你可能需要哪些操作符,以及你需要以哪种顺序应用它们。你可能还会思考如何划分你的问题;这通常意味着创建多个流,每个流解决一个与你尝试解决的更大问题相关的特定问题。

让我们从流创建开始,看看我们如何开始使用流的第一步。

以下代码创建一组静态值的流:

const staticValuesStream$ = Rx.Observable.of(1, 2, 3, 4);

staticValuesStream$.subscribe(data => console.log(data)); 
// emits 1, 2, 3, 4

这是一个非常基本的示例,展示了我们如何创建一个流。我们使用了 of() 创建操作符,它接受任意数量的参数。只要有订阅者,所有参数都会一个接一个地被发射出来。在上述代码中,我们还通过调用subscribe()方法并传递一个以发射的值作为参数的函数来订阅staticValuesStream$

让我们介绍一个操作符,map(),它像一个投影,允许你改变正在发射的值。在发射之前,map()操作符针对流中的每个值都会被调用。

你可以通过提供一个函数并进行投影来使用map()操作符:

const staticValuesStream$ = 
Rx.Observable
  .of(1, 2, 3, 4)
  .map(data => data + 1); 

staticValuesStream$.subscribe(data => console.log(data))
// emits 2, 3, 4, 5

在上述代码中,我们已将map()操作符追加到staticValuesStream$上,并在发射每个值之前应用它,并将其递增一个。因此,生成的数据已经发生改变。这就是如何将操作符追加到流中的:简单地创建流,或者获取现有的流,并逐个追加操作符。

让我们再添加另一个运算符 filter(),以确保我们真正理解如何使用运算符。filter() 做什么?嗯,就像 map() 运算符一样,它被应用于每个值,但不是创建一个投影,而是决定哪些值将被发出。 filter() 接受一个布尔值。任何评估为 true 的表达式意味着该值将被发出;如果为 false,该表达式将不会被发出。

您可以如下使用 filter() 运算符:

const staticValuesStream$ = 
Rx.Observable
  .of(1, 2, 3, 4)
  .map(data => data + 1)
  .filter(data => data % 2 === 0 ); 

staticValuesStream$.subscribe(data => console.log(data));
// emits 2, 4

我们将 filter() 运算符添加到现有的 map() 运算符中。我们给 filter() 运算符的条件是只返回能被 2 整除的 true 值,这就是模运算符的功能。我们知道,仅有 map() 运算符本身可以确保值 2345 被发出。这些值现在正在被 filter() 运算符评估。在这四个值中,只有 24 符合 filter() 运算符设定的条件。

当在流上工作并应用运算符时,事情可能并不总是像前面的代码那样简单。也许无法准确预测哪些内容被发出。针对这些场合,我们有一些可以使用的技巧。其中之一是使用 do() 运算符,它将允许我们检查每个值而不更改它。这为我们提供了充分的机会将其用于调试目的。根据我们在流中所处的位置,do() 运算符将输出不同的值。让我们看看应用 do() 运算符的地方很重要的不同情况:

const staticValuesStream$ = 
Rx.Observable.of(1, 2, 3, 4)
  .do(data => console.log(data)) // 1, 2, 3, 4 
  .map(data => data + 1)
  .do(data => console.log(data)) // 2, 3, 4, 5
  .filter(data => data % 2 === 0 )
  .do(data => console.log(data)); // 2, 4 

// emits 2, 4
staticValuesStream$.subscribe(data => console.log(data))

通过使用 do() 运算符,您可以看到,当我们的流变得越来越复杂时,我们有一种很好的方式来调试我们的流。

理解运算符

到目前为止,我们展示了如何创建一个流并在其上使用一些非常基本的运算符来更改发出的值。我们还介绍了如何使用 do() 运算符来检查您的流而不更改它。并不是所有运算符都像 map()filter()do() 运算符那样容易理解。有不同的策略可以尝试理解每个运算符的功能,以便知道何时使用它们。使用 do() 运算符是一种方法,但您还可以采取图形方法。这种方法被称为大理石图。它由表示时间从左向右流逝的箭头组成。在这个箭头上有圆圈或大理石,代表已发出的值。大理石上有一个值,但大理石之间的距离也可以描述随时间发生的情况。大理石图通常由至少两个带有大理石的箭头组成,以及一个运算符。其目的是表示在应用运算符时流发生了什么。第二个箭头通常代表产生的流。

这是一个示例的大理石图:

RxJS 中的大多数操作符都在 RxMarbles 网站上通过弹图表进行描述:rxmarbles.com/。这是一个快速理解操作符作用的绝妙资源。然而,要真正理解 RxJS,你需要编写代码;这个绕不过去。当然可以用不同的方法。你可以轻松地搭建自己的项目,并从 NPM 安装 RxJS,通过 CDN 链接引用它,或者使用类似 JS Bin(www.jsbin.com)这样的页面,可以方便地将 RxJS 作为库添加,并立即开始编写代码。效果看起来有点像这样:

图片

JS Bin 让启动变得容易,但如果我们可以将拱形图表和 JS Bin 结合起来,当你编写代码时得到代码的图形表示这岂不是很棒?通过 RxFiddle,你可以做到这一点:rxfiddle.net/。你可以输入代码,点击运行,就会显示你刚刚编写的拱形图表,看起来是这样的:

图片

流中的流

我们一直在研究改变被发出的值的不同操作符。流的另一个不同方面是:如果你需要从现有流中创建新流怎么办?这种情况通常会发生在什么时候?有很多情况,比如:

  • 基于一个键盘按键弹起事件的流,进行 AJAX 调用。

  • 统计点击次数,并确定用户是否单击、双击或三击。

你明白了吧;我们开始于一种类型的流,需要转换成另一种类型的流。

让我们先来看看创建一个流,并观察使用操作符创建流的结果时会发生什么:

let stream$ = Rx.Observable.of(1,2,3)
  .map(data => Rx.Observable.of(data));

// Observable, Observable, Observable
stream$.subscribe(data => console.log(data));

此时,通过map()操作符传递的每个值都会产生一个新的Observable。当你订阅stream$时,每个发出的值都将是一个流。你的第一反应可能是对每个值附加一个subscribe(),像这样:

let stream$ = Rx.Observable
  .of(1,2,3)
  .map(data => Rx.Observable.of(data))

stream$.subscribe(data => {
  data.subscribe(val => console.log(val))
});

// 1, 2, 3

抵制这种冲动。这样只会创建难以维护的代码。你想要的是将所有这些流合并成一个,这样你只需要一个subscribe()。这里有一个专门用于此目的的操作符,叫做flatMap()flatMap()的作用是将你的一系列流转换成一个流,一个元流。

它的使用方式如下:

let stream$ = Rx.Observable.of(1,2,3)
  .flatMap(data => Rx.Observable.of(data))

stream$.subscribe(data => {
  console.log(val);
});

// 1, 2, 3

好吧,我们明白了,我们不想要一系列的 Observables,而是要一系列的值。这个操作符看起来确实很棒。但我们仍不太确定何时使用。让我们使这更具体一点。想象一下,你有一个界面由一个输入字段组成。用户在那个输入字段中输入字符。假设你想要对输入一个或多个字符做出反应,并且,例如,根据输入的字符执行一个 AJAX 请求的结果。我们在这里关注两件事:如何收集输入的字符,以及如何执行 AJAX 请求。

让我们从第一件事开始,捕捉输入字段中输入的字符。为此,我们需要一个 HTML 页面和一个 JavaScript 页面。让我们从 HTML 页面开始:

<html>
  <body>
    <input id="input" type="text">
    <script src="img/Rx.min.js"></script>
    <script src="img/app.js"></script>
  </body>
</html>

这描述了我们的输入元素和对 RxJS 的脚本引用,以及对app.js文件的引用。然后我们有app.js文件,在这里我们获取输入元素的引用,并开始监听一旦它们输入的按键:

let elem = document.getElementById('input');
let keyStream$ = Rx.Observable
  .fromEvent(elem, 'keyup')
  .map( ev => ev.key);

keyStream$.subscribe( key => console.log(key));

// emits entered key chars

值得强调的是,我们开始监听通过调用fromEvent()创建操作符发出的keyup事件。然后,我们应用map()操作符来提取存储在ev.key上的字符值。最后,我们订阅这个流。预期地,运行这段代码将导致字符在 HTML 页面输入值后立即在控制台中键入。

让我们通过所输入的内容来做一个基于 AJAX 请求更具体些。为此,我们将使用fetch()API 和名为 swapi(swapi.com)的在线 API,其中包含了有关星球大战电影信息的一系列 API。首先定义我们的 AJAX 调用,然后看看它如何适应我们现有的按键流。

我们说我们将使用fetch()。它让我们可以简单地构建一个 GET 请求如下所示:

fetch('https://swapi.co/api/people/1')
  .then(data => data.json())
  .then(data => console.log('data', data));

当然,我们希望将这个请求转换成一个Observable,这样它就可以很好地与我们的keyStream$配合使用。幸运的是,通过使用from()操作符,我们很容易就可以做到这一点。然而,首先让我们将我们的fetch()调用重写成一个更容易使用的方法。重写的结果如下:

function getStarwarsCharacterStream(id) {
  return fetch('https://swapi.co/api/people/' + id)
    .then(data => data.json());
}

这段代码允许我们提供一个用于构建 URL 的参数,然后我们可以使用它来进行 AJAX 请求获取一些数据。在这一点上,我们准备将我们的函数连接到我们现有的流。我们通过输入以下内容来做到这一点:

let keyStream$ = Rx.Observable.fromEvent(elem, 'keyup')
  .map(ev => ev.key)
  .filter(key => key !== 'Backspace')
 .flatMap( key =>
    Rx.Observable
      .from(getStarwarsCharacterStream(key))
  );

我们用粗体突出了flatmap()操作符的使用,使用了我们的from()转换操作符。最后提到的操作符将我们的getStarwarsCharacterStream()函数作为参数。from()操作符将该函数转换为一个流。

在这里,我们学会了如何连接两个不同的流,同时也学会了如何将Promise转换成一个流。尽管这种方法在纸上看起来很不错,但使用flatMap()是有局限性的,重要的是要理解它们是什么。因此,让我们讨论下一个switchMap()操作符。当我们执行长时间运行的任务时,使用switchMap()操作符的好处将变得更加明显。为了论证起见,让我们定义这样一个任务,如下所示:

function longRunningTask(input) {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve('response based on ' + input);
    }, 5000);
  });
}

在这段代码中,我们有一个需要 5 秒才能执行完的函数;足够长的时间来展示我们想要说明的问题。接下来,让我们看看在以下代码中继续使用flatMap()操作符会有什么影响:

let longRunningStream$ = keyStream$
  .map(ev => ev.key)
  .filter(key => elem.value.length >3)
  .filter( key => key !== 'Backspace')
  .flatMap( key =>
    Rx.Observable
      .from(longRunningTask(elem.value))
  );

longRunningStream$.subscribe(data => console.log(data));

前面的代码工作方式是:每次敲击键盘都会生成一个事件。然而,我们放置了一个.filter()操作符来确保只有在输入至少四个键后才会生成一个事件,filter(key => elem.value.length >3)。现在让我们来谈谈用户此时的期望。如果用户在输入控件中输入字符,他们很可能希望在输入完成时进行请求。用户将“完成”定义为输入一些字符,并且应该能够在输入错误时删除字符。因此,我们可以假设以下输入序列:

// enters abcde
abcde
// removes 'e'

此时,他们已经输入了字符,并且,在一个合理的时间内,编辑了他们的答案。用户期望基于abcd接收到一个答案。然而使用flatMap()操作符意味着用户将会收到两个答案,因为实际上他们输入了abcdeabcd。想象一下根据这两个输入得到一个结果列表;很可能会是两个看起来有些不同的列表。根据我们的代码得到的响应如下:

我们的代码很可能能够处理描述的情况,即在新响应到达时立即重新渲染结果列表。但是这样做有两个问题:首先,我们对abcde进行了不必要的网络请求;其次,如果后端响应速度足够快,我们将在 UI 中看到闪烁,因为结果列表首先被渲染一次,然后不久之后基于第二个响应再次被渲染。这并不好,我们希望出现这样的情况:一直输入时第一个请求将被放弃。这就是switchMap()操作符的用处,它确实可以做到这一点。因此,让我们修改前面的代码如下:

let longRunningStream$ = keyStream$
  .map(ev => ev.key)
  .filter(key => elem.value.length >3)
  .filter( key => key !== 'Backspace')
  .switchMap( key =>
    Rx.Observable
    .from(longRunningTask(elem.value))
  );

在这段代码中,我们简单地将我们的flatMap()切换到了switchMap()。当我们以完全相同的方式执行代码,也就是,用户首先输入12345,然后很快将其改为1234时,最终结果是:

正如我们所看到的,我们只收到了一个请求。原因是当新事件发生时,前一个事件被中止了——switchMap()发挥了它的魔力。用户很高兴,我们也很满意。

AJAX

我们已经提及了如何进行 AJAX 请求的话题。有许多方式可以进行 AJAX 请求;最常见的两种方法是:

  • 使用 fetch API;fetch API 是 Web 标准,因此内置在大多数浏览器中

  • 使用ajax()方法,现在内置到 RxJS 库中;它曾经存在于一个名为 Rx.Dom 的库中

fetch()

fetch()API 是一种 Web 标准。你可以在以下链接找到官方文档:developer.mozilla.org/en-US/docs/Web/API/Fetch_APIfetch()API 是基于Promise的,这意味着我们需要在使用之前将其转换为Observable。该 API 公开了一个fetch()方法,该方法将 URL 作为第一个参数传入,第二个参数是一个可选对象,允许您控制要发送什么主体,如果有的话,要使用哪个 HTTP 动词等等。

我们已经提到了如何在 RxJS 的上下文中最好地处理它。但值得再次重申一下。然而,把我们的 fetch 放入from()操作符并不像简单。让我们写一些代码看看为什么:

let convertedStream$ = 
Rx.Observable.from(fetch('some url'));

convertedStream$.subscribe(data => 'my data?', data);

我们得到了我们的数据对吧?抱歉,不对,我们得到了一个Response对象。但这很简单,只需在map()操作符中调用json()方法,那么我们就有了我们的数据?再次抱歉,不对,当你键入以下内容时,json()方法会返回一个Promise

let convertedStream$ = Rx.Observable.from(fetch('some url'))
  .map( r=> r.json());

// returns PromiseObservable
convertedStream$.subscribe(data => 'my data?', data);

在前一节中,我们已经展示了一种可能的解决方案,即以下结构:

getData() {
  return fetch('some url')
    .then(r => r.json());
}

let convertedStream$ = Rx.Observable.from(getData());
convertedStream$.subscribe(data => console.log('data', data));

在这段代码中,我们只是简单地处理了将数据从from()操作符传递出来之前挖掘出来的工作。用 Promise 玩耍并不太像 RxJS。你可以采取更多基于流的方法;我们几乎就快到达目的地了,我们只需要做一个小调整:

let convertedStream$ = Rx.Observable.from(fetch('some url'))
  .flatMap( r => Rx.Observable.from(r.json()));

// returns data
convertedStream$.subscribe(data => console.log('data'), data);

就是这样:我们的fetch()调用现在提供了像流一样的数据。那我们做了什么呢?我们将我们的map()调用更改为flatMap()调用。原因是当我们调用r.json()时,我们得到了一个Promise。我们通过将其包装在from()调用中Rx.Observable.from(r.json())解决了这个问题。这将使流发出一个PromiseObservable,除非我们从map()改为flatMap()。正如我们在前一节中学到的,如果我们冒着在流内部创建一个流的风险,我们需要flatMap()来拯救我们,而它也确实做到了。

ajax()操作符

与基于Promisefetch()API 不同,ajax()方法实际上是基于Observable的,这让我们的工作变得有点更容易。使用它非常简单,就像这样:

Rx.Observable
  .ajax('https://swapi.co/api/people/1')
  .map(r => r.response)
  .subscribe(data => console.log('from ajax()', data));

如我们所见,前面的代码调用ajax()操作符,并将 URL 作为参数。值得一提的是调用map()操作符,它从response属性中挖出我们的数据。因为它是一个Observable,我们只需像往常一样调用subscribe()方法并提供监听函数作为参数来订阅它。

这涵盖的是在你想要使用 HTTP 动词GET获取数据的简单情况。幸运的是,我们可以很容易地通过使用ajax()的重载版本来创建、更新或删除数据,这个版本接受一个AjaxRequest对象实例,其中包括以下字段:

url?: string;
body?: any;
user?: string;
async?: boolean;
method?: string;
headers?: Object;
timeout?: number;
password?: string;
hasContent?: boolean;
crossDomain?: boolean;
withCredentials?: boolean;
createXHR?: () => XMLHttpRequest;
progressSubscriber?: Subscriber<any>;
responseType?: string;

这个对象规范中所列的所有字段都是可选的,并且我们可以通过请求配置相当多的内容,比如headerstimeoutusercrossDomain,等等;基本上,这就是我们对一个很好的 AJAX 包装功能所期望的。 除了重载的ajax()操作符外,还存在一些简化选项:

  • get(): 使用GET动词获取数据

  • put(): 使用PUT动词更新数据

  • post(): 使用POST动词创建数据

  • patch(): 使用PATCH动词的想法是更新一个部分资源

  • delete(): 使用DELETE动词删除数据

  • getJSON(): 使用GET动词获取数据,并将响应类型设置为application/json

级联调用

到目前为止,我们已经覆盖了你将使用 AJAX 发送或接收数据的两种主要方法。当涉及到接收数据时,通常是不能简单地获取数据并渲染它的。事实上,你很可能需要在何时获取哪些数据上有依赖。 典型的例子是需要在获取剩余数据之前执行登录调用。在某些情况下,可能需要首先登录,然后获取已登录用户的数据,一旦你获得了这些数据,你就可以获取消息、订单或任何特定于某个用户的数据。这种以这种方式获取数据的整个现象被称为级联调用。

让我们看看我们如何使用 promise 进行级联调用,并逐渐学习如何在 RxJS 中做同样的事情。我们会做这个小的跳跃,因为我们假设大部分正在读这本书的人都对 promise 很熟悉。

让我们首先看一下我们之前提到的依赖情况,我们需要按照这个顺序执行以下步骤:

  1. 用户首先登录到系统

  2. 然后我们获取用户的信息

  3. 然后我们获取用户订单的信息

使用 promise,代码看起来应该像这样:

// cascading/cascading-promises.js

login()
  .then(getUser)
  .then(getOrders);

// we collect username and password from a form
const login = (username, password) => {
  return fetch("/login", {
    method: "POST",
    body: { username, password }
  })
  .then(r => r.json())
  .then(token => {
    localStorage.setItem("auth", token);
  });
};

const getUser = () => {
  return fetch("/users", {
    headers: {
      Authorization: "Bearer " + localStorage.getToken("auth")
    }
  }).then(r => r.json());
};

const getOrders = user => {
  return fetch(`/orders/user/${user.id}`, {
    headers: {
      Authorization: "Bearer " + localStorage.getToken("auth")
    }
  }).then(r => r.json());
};

这段代码描述了我们如何首先调用login()方法登录系统,并获得一个 token。我们在未来的任何调用中都使用这个 token 来确保我们进行了经过身份验证的调用。然后我们看到我们如何执行getUser()调用并获得一个用户实例。我们使用相同的用户实例来执行我们的最后一个调用,getOrders(),其中用户 ID 被用作路由参数:/orders/user/${user.id}

我们已经展示了如何使用 promises 执行级联调用;我们这样做是为了建立我们正在尝试解决的问题的一个共同基础。RxJS 的方法非常相似:我们已经展示了ajax()操作符的存在,并且在处理 AJAX 调用时让我们的生活更轻松。要使用 RxJS 实现级联调用效果,我们只需要使用switchMap()操作符。这将使我们的代码看起来像这样:

// cascading/cascading-rxjs.js

let user = "user";
let password = "password";

login(user, password)
  .switchMap(getUser)
  .switchMap(getOrders);

// we collect username and password from a form
const login = (username, password) => {
  return Rx.Observable.ajax("/login", {
    method: "POST",
    body: { username, password }
  })
  .map(r => r.response)
  .do(token => {
    localStorage.setItem("auth", token);
  });
};

const getUser = () => {
  return Rx.Observable.ajax("/users", {
    headers: {
      Authorization: "Bearer " + localStorage.getToken("auth")
    }
  }).map(r => r.response);
};

const getOrders = user => {
  return Rx.Observable.json(`/orders/user/${user.id}`, {
    headers: {
      Authorization: "Bearer " + localStorage.getToken("auth")
    }
  }).map(r => r.response);
};

我们在上述代码中需要更改的部分已用高亮标出。简而言之,更改如下:

  • fetch()ajax()操作符替换

  • 我们调用.map(r => r.response)而不是.then(r => r.json())

  • 对于每个级联调用,我们使用.switchMap()而不是.then(getOrders)

还有一个有趣的方面需要我们来讨论,即并行调用。当我们获取用户和订单时,我们在启动下一个调用之前等待前一个调用完全完成。在许多情况下,这可能并不是严格必需的。想象一下,我们有一个与前一个类似的情况,但是围绕用户有很多有趣的信息我们想要获取。除了仅仅获取订单之外,用户可能有一系列朋友或消息。获取这些数据的前提条件只是我们获取了用户,因此我们知道应该查询哪些朋友和我们需要哪些消息。在 Promise 世界中,我们会使用Promise.all()构造来实现并行化。有了这个想法,我们更新我们的Promise代码如下:

// parallell/parallell-promise.js

// we collect username and password from a form
login(username, password) {
  return new Promise(resolve => {
    resolve('logged in');
  });
}

getUsersData(user) {
  return Promise.all([
    getOrders(user),
    getMessages(user),
    getFriends(user) 
    // not implemented but you get the idea, another call in parallell
  ])
}

getUser() {
  // same as before
}

getOrders(user) {
  // same as before
}

login()
  .then(getUser)
  .then(getUsersData);

如我们从上面代码中看到的,我们引入了新的getUsersData()方法,它并行获取订单、消息和朋友集合,这样可以使我们的应用程序更早地响应,因为数据将会比我们依次获取它们时更早到达。

通过引入forkJoin()操作符,我们可以很容易地在 RxJS 中实现相同的效果。它接受一个流的列表,并并行获取所有内容。因此,我们更新我们的 RxJS 代码如下:

// parallell/parallell-rxjs.js

import Rx from 'rxjs/Rx';
// imagine we collected these from a form
let user = 'user';
let password = 'password';

login(user, password)
  .switchMap(getUser)
  .switchMap(getUsersData)

// we collect username and password from a form
login(username, password) {
  // same as before
}

getUsersData(user) {
  return Rx.Observable.forkJoin([
    getOrders(),
    getMessages(),
    getFriends()
  ])
}

getUser() {
  // same as before
}

getOrders(user) {
  // same as before
}

login()
  .then(getUser)
  .then(getUsersData);

深入了解

到目前为止,我们已经看过了一些操作符,让你可以创建流或者用map()filter()操作符改变流,我们已经学会了如何管理不同的 AJAX 场景等等。基础知识都在这里,但我们还没有以一种结构化的方式来接触操作符这个主题。我们的意思是什么?嗯,操作符可以被认为属于不同的类别。我们可以使用的操作符数量令人震惊地超过 60 个。如果我们有幸可以学会所有这些操作符,这将需要时间。不过这里有个问题:我们只需要知道存在哪些不同类型的操作符,以便我们可以在适当的地方应用它们。这样可以减少我们的认知负担和我们的记忆负担。一旦我们知道我们有哪些类别,我们只需要深入研究,很可能我们最终只会知道总共 10-15 个操作符,其余的我们需要它们时再查阅即可。

目前,我们有以下几种类别:

  • 创建操作符:这些操作符帮助我们首先创建流。几乎任何东西都可以通过这些操作符转换为一个流。

  • 组合操作符:这些操作符帮助我们结合值和流。

  • 数学操作符:这些操作符对发出的值进行数学计算。

  • 基于时间的操作符:这些操作符改变值发出的速度。

  • 分组操作符:这些操作符的概念是对一组值进行操作,而不是单个值。

创建操作符

我们使用创建操作符来创建流本身,因为让我们面对现实:我们需要转换为流的东西并不总是流,但通过将其转换为流,它将不得不与其他流友好相处,并且最重要的是,将能够充分利用使用操作符的全部功能来发挥其全部潜力。

那么,这些其他非流由什么组成呢?嗯,它们可以是任何异步或同步的东西。重要的是它是需要在某个时刻发出的数据。因此,存在一系列的创建操作符。在接下来的子章节中,我们将介绍其中的一部分,足够让您意识到将任何东西转换为流的强大功能。

of() 操作符

我们已经有几次使用了这个操作符。它接受未知数量的逗号分隔参数,可以是整数、字符串或对象。如果您只想发出一组有限的值,那么这是一个您想要使用的操作符。要使用它,只需键入:

// creation-operators/of.js

const numberStream$ = Rx.Observable.of(1,2, 3);
const objectStream$ = Rx.Observable.of({ age: 37 }, { name: "chris" });

// emits 1 2 3
numberStream$.subscribe(data => console.log(data));

// emits { age: 37 }, { name: 'chris' }
objectStream$.subscribe(data => console.log(data));

从代码中可以看出,我们在of()操作符中放置了什么并不重要,它总是能够发出它。

from() 操作符

该操作符可以接受数组或Promise作为输入,并将它们转换为流。要使用它,只需像这样调用它:

// creation-operators/from.js

const promiseStream$ = Rx.Observable.from(
  new Promise(resolve => setTimeout(() => resolve("data"),3000))
);

const arrayStream$ = Rx.Observable.from([1, 2, 3, 4]);

promiseStream$.subscribe(data => console.log("data", data));
// emits data after 3 seconds

arrayStream$.subscribe(data => console.log(data));
// emits 1, 2, 3, 4

这样一来,我们就不必处理不同类型的异步调用,从而省去了很多麻烦。

range() 操作符

该操作符允许您指定一个范围,一个起始数和一个结束数。这是一个快捷方式,可以快速让您创建一个具有一定范围的数值流。要使用它,只需键入:

// creation-operators/range.js

const stream$ = Rx.Observable.range(1,99);

stream$.subscribe(data => console.log(data));
// emits 1... 99 

fromEvent() 操作符

现在变得非常有趣了。fromEvent()操作符允许我们混合 UI 事件,比如clickscroll事件,并将其转换为一个流。到目前为止,我们认为异步调用只与 AJAX 调用有关。这个想法完全不正确。我们可以将 UI 事件与任何类型的异步调用混合,这创造了一个非常有趣的情况,使我们能够编写非常强大、表现力强的代码。我们将在接下来的章节中进一步讨论这个话题,以流思考

要使用此操作符,您需要为它提供两个参数:一个 DOM 元素和事件的名称,如下所示:

// creation-operators/fromEvent.js

// we imagine we have an element in our DOM looking like this <input id="id" />
const elem = document.getElementById("input");
const eventStream$ = Rx.Observable
  .fromEvent(elem, "click")
  .map(ev => ev.key);

// outputs the typed key
eventStream$.subscribe(data => console.log(data));

组合

组合操作符是用于组合来自不同流的值。我们有几个可供使用的操作符可以帮助我们。当我们因某种原因没有所有数据在一个地方,但需要从多个地方获取时,这种类型的操作符是有意义的。如果不是因为即将描述的强大操作符,从不同来源组合数据结构可能是费时且容易出错的工作。

merge()操作符

merge()操作符从不同的流中获取数据并将其组合。然而,这些流可以是任何类型的,只要它们是Observable类型。这意味着我们可以从定时操作、Promise、of()操作符中获取的静态数据等结合数据。合并的作用是交替发出数据。这意味着它将在以下示例中同时从两个流中发出。该操作符有两种用法,作为静态方法,也可以作为实例方法:

// combination/merge.js

let promiseStream = Rx.Observable
.from(new Promise(resolve => resolve("data")))

let stream = Rx.Observable.interval(500).take(3);
let stream2 = Rx.Observable.interval(500).take(5);

// instance method version of merge(), emits 0,0, 1,1 2,2 3, 4
stream.merge(stream2)
  .subscribe(data => console.log("merged", data));

// static version of merge(), emits 0,0, 1,1, 2, 2, 3, 4 and 'data'
Rx.Observable.merge(
  stream,
  stream2,
  promiseStream
)
.subscribe(data => console.log("merged static", data));

这里的要点是,如果你只需要将一个流与另一个流结合,那么使用此操作符的实例方法版本,但如果你有多个流,则使用静态版本。此外,指定流的顺序是重要的。

combineLatest()

想象一下你面临的情况是,你已经与几个端点建立了连接,并且这些端点为你提供了数据。你关心的是每个端点最新发出的数据。也许有一个或多个端点在一段时间后停止发送数据,而你想知道最后发生的事情是什么。在这种情况下,我们希望能够结合所有相关端点的最新值的能力。这就是combineLatest()操作符发挥作用的地方。你可以在以下方式使用它:

// combination/combineLatest.js

let firstStream$ = Rx.Observable
  .interval(500)
  .take(3);

let secondStream$ = Rx.Observable
  .interval(500)
  .take(5);

let combinedStream$ = Rx.Observable.combineLatest(
  firstStream$,
  secondStream$
)

// emits [0, 0] [1,1] [2,2] [2,3] [2,4] [2,5]
combinedStream$.subscribe(data => console.log(data));

我们在这里看到的是firstStream$在一段时间后因为take()操作符的限制发出的值停止了。然而,combineLatest()操作符确保我们仍然获得了firstStream$发出的最后一个值。

zip()

这个操作符的作用是尽可能多地将值拼接在一起。我们可能会处理连续的流,但也可能会处理具有发射值限制的流。你可以在以下方式使用该操作符:

// combination/zip.js

let stream$ = Rx.Observable.of(1, 2, 3, 4);
let secondStream$ = Rx.Observable.of(5, 6, 7, 8);
let thirdStream$ = Rx.Observable.of(9, 10); 

let zippedStream$ = Rx.Observable.zip(
  stream$,
  secondStream$,
  thirdStream$
)

// [1, 5, 9] [2, 6, 10]
zippedStream$.subscribe(data => console.log(data))

如我们所看到的,这里我们在垂直方向上将值拼接在一起,并且取最少发射值的thirdStream$是最短的,计算发出的值的数量。这意味着我们将从左到右取值并将它们合并在一起。由于thirdStream$只有两个值,我们最终只得到了两个发射。

concat()

乍一看,concat()操作符看起来像是另一个merge()操作符,但这并不完全正确。区别在于concat()会等待其他流完成后才从顺序中的下一个流中发出流。你如何安排你的流在调用concat()中很重要。该操作符的使用方式如下:

// combination/concat.js

let firstStream$ = Rx.Observable.of(1,2,3,4);
let secondStream$ = Rx.Observable.of(5,6,7,8);

let concatStream$ = Rx.Observable.concat(
  firstStream$,
  secondStream$
);

concatStream$.subscribe(data => console.log(data));

数学

数学操作符只是在值上执行数学操作的操作符,比如找到最大或最小值,汇总所有值等。

最大值

max() 操作符用于找到最大值。它有两种用法:一种是直接调用max() 操作符,不带参数;另一种是给它传递一个compare函数。compare函数决定某个值是大于、小于还是等于被发出的值。让我们看看这两种不同的版本:

// mathematical/max.js

let streamWithNumbers$ = Rx.Observable
  .of(1,2,3,4)
  .max();

// 4
streamWithNumbers$.subscribe(data => console.log(data)); 

function comparePeople(firstPerson, secondPerson) {
  if (firstPerson.age > secondPerson.age) {
    return 1; 
  } else if (firstPerson.age < secondPerson.age) {
    return -1;
  } 
  return 0;
}

let streamOfObjects$ = Rx.Observable
  .of({
    name : "Yoda",
    age: 999
  }, {
    name : "Chris",
    age: 38 
  })
  .max(comparePeople);

// { name: 'Yoda', age : 999 }
streamOfObjects$.subscribe(data => console.log(data));

如我们在上面的代码中所见,我们得到了一个结果,它是最大的一个。

最小值

min() 操作符与 max() 操作符基本相反;也有两种用法:带参数和不带参数。它的任务是找到最小值。使用方法如下:

// mathematical/min.js

let streamOfValues$ = Rx.Observable
  .of(1, 2, 3, 4)
  .min();

// emits 1
streamOfValues$.subscribe(data => console.log(data));

总和

以前有一个叫做 sum() 的操作符,但已经在多个版本中删除了。现在用的是 .reduce() 。使用 reduce() 操作符,我们可以很容易地实现相同的功能。下面是使用 reduce() 编写 sum() 操作符的方式:

// mathematical/sum.js

let stream = Rx.Observable.of(1, 2, 3, 4)
  .reduce((acc, curr) => acc + curr);

// emits 10
stream.subscribe(data => console.log(data));

这个操作是遍历所有的发出值并将结果相加。所以,本质上,它将所有值相加。当然,这种操作符不仅可以应用于数字,还可以应用于对象。不同之处在于如何执行 reduce() 操作。下面的例子涵盖了这样的场景:

let stream = Rx.Observable.of({ name : "chris" }, { age: 38 })
  .reduce((acc, curr) => Object.assign({},acc, curr));

// { name: 'chris', age: 38 }
stream.subscribe(data => console.log(data)); 

如你从前面的代码中所见,reduce() 操作符确保所有对象的属性都被合并到一个对象中。

时间

时间在讨论流时是一个非常重要的概念。想象一下,你有多个有不同带宽的流,或者一个流比另一个流快,或者你有想在特定时间间隔内重试一个 AJAX 调用的场景。在所有这些情况下,我们需要控制数据发出的速度,时间在所有这些情况下都起着重要的作用。我们有一大堆的操作符,像魔术师一样,让我们能够随心所欲地制定和控制我们的值。

时间间隔(interval())操作符

在 JavaScript 中,有一个 setInterval() 函数,它可以让你以固定的时间间隔执行代码,直到你选择停止它。RxJS 有一个行为类似的操作符,就是 interval() 操作符。它需要一个参数:通常是发出值之间的毫秒数。使用方法如下:

// time/interval.js

let stream$ = Rx.Observable.interval(1000);

// emits 0, 1, 2, 3 ... n with 1 second in between emits, till the end of time
stream$.subscribe(data => console.log(data));

需要注意的是,该操作符将一直发出值,直到你停止它。最好的方法是将其与 take() 操作符组合使用。 take() 操作符需要一个参数,指定在停止之前它要发出多少个值。更新后的代码如下:

// time/interval-take.js

let stream$ = Rx.Observable.interval(1000)
  .take(2);

// emits 0, 1, stops emitting thanks to take() operator
stream$.subscribe(data => console.log(data));

计时器(timer())操作符

timer() 操作符的工作是在一定时间后发出值。它有两种形式:一种是在一定毫秒数后发出一个值,另一种是在它们之间有一定延迟的情况下继续发出值。让我们看看有哪两种不同的形式可用:

// time/timer.js

let stream$ = Rx.Observable.timer(1000);

// delay with 500 milliseconds
let streamWithDelay$ = Rx.Observable.timer(1000, 500) 

// emits 0 after 1000 milliseconds, then no more
stream$.subscribe(data => console.log(data));

streamWithDelay$.subscribe(data => console.log(data));

delay() 操作符

delay() 操作符延迟所有被发出的值,并且使用以下方式:

// time/delay.js

let stream$ = Rx.Observable
.interval(100)
.take(3)
.delay(500);

// 0 after 600 ms, 1 after 1200 ms, 2 after 1800 ms
stream.subscribe(data => console.log(data));

sampleTime() 操作符

sampleTime() 操作符用于在样本期过去后只发出值。这样做的一个很好的用例是当你想要有冷却功能时。想象一下,你有用户太频繁地按下保存按钮。保存可能需要几秒钟的时间才能完成。一种方法是在保存时禁用保存按钮。另一种有效的方法是简单地忽略按钮的任何按下,直到操作有机会完成。以下代码就是这样做的:

// time/sampleTime.js

let elem = document.getElementById("btn");
let stream$ = Rx.Observable
  .fromEvent(elem, "click")
  .sampleTime(8000);

// emits values every 8th second
stream$.subscribe(data => console.log("mouse clicks",data));

debounceTime() 操作符

sampleTime() 操作符能够在一定时间内忽略用户,但 debounceTime() 操作符采取了不同的方式。数据防抖是一个概念,意味着我们在发出值之前等待事情平静下来。想象一下,用户输入的输入元素。用户最终会停止输入。我们想要确保用户实际上已经停止了,所以我们在实际执行操作前等待一段时间。这就是 debounceTime() 操作符为我们所做的。以下示例显示了我们如何监听用户在输入元素中输入,等待用户停止输入,最后执行 AJAX 调用:

// time/debounceTime.js
const elem = document.getElementById("input");

let stream$ = Rx.Observable.fromEvent(elem, "keyup")
  .map( ev => ev.key)
  .filter(key => key !== "Backspace")
  .debounceTime(2000)
  .switchMap( x => {
    return new Rx.Observable.ajax(`https://swapi.co/api/people/${elem.value}`);
  })
  .map(r => r.response);

stream$.subscribe(data => console.log(data));

用户输入数字后,在文本框中输入不活动 2 秒后,将进行一个 AJAX 呼叫,使用我们的文本框输入。

分组

分组操作符允许我们对收集到的一组事件进行操作,而不是一次发出一个事件。

buffer() 操作符

buffer() 操作符的想法是我们可以收集一堆事件,而不会立即发出。操作符本身接受一个参数,一个定义何时停止收集事件的 Observable。在那个时刻,我们可以选择如何处理这些事件。以下是你可以使用这个操作符的方法:

// grouping/buffer.js

const elem = document.getElementById("input");

let keyStream$ = Rx.Observable.fromEvent(elem,"keyup");
let breakStream$ = keyStream$.debounceTime(2000);
let chatStream$ = keyStream$
  .map(ev => ev.key)
  .filter(key => key !== "Backspace")
  .buffer(breakStream$)
  .switchMap(newContent => Rx.Observable.of("send text as I type", newContent));

chatStream$.subscribe(data=> console.log(data));

这样做的作用是收集事件,直到出现了 2 秒的非活动时间。在那时,我们释放了所有缓冲的按键事件。当我们释放所有这些事件时,我们可以,例如,通过 AJAX 发送它们到某个地方。这在聊天应用程序中是一个典型的场景。使用上述代码,我们可以始终发送最新输入的字符。

bufferTime() 操作符

buffer() 非常相似的一个操作符是 bufferTime()。这个操作符让我们指定要缓冲事件的时间长度。它比 buffer() 稍微不那么灵活,但仍然非常有用。

思考流

到目前为止,我们已经经历了一堆场景,向我们展示了我们可以支配哪些操作符,以及它们如何可以被连接。我们也看到了像 flatMap() 和 switchMap() 这样的操作符,在从一个类型的 Observable 到另一个类型时是如何改变事情的。那么,当使用 Observables 时,应该采取哪种方法?显然,我们需要使用操作符来表达算法,但我们应该从哪里开始呢?我们需要做的第一件事就是思考起点和终点。我们想要捕获哪些类型的事件,最终结果应该是什么样的?这已经给了我们一个提示,要进行多少次转换才能达到那个目标。如果我们只想要转换数据,那么我们可能只需要一个 map() 操作符和一个 filter() 操作符。如果我们想要从一个 Observable 转换到另一个 Observable,那么我们就需要一个 flatMap() 或 switchMap()。我们是否有特定的行为,比如等待用户停止输入?如果有的话,那么我们需要查看 debounceTime() 或类似的操作符。这和所有问题其实是一样的:把它分解,看看你有哪些部分,然后征服。不过,让我们尝试将这件事分解成一系列步骤:

  • 输入是什么?UI 事件还是其他东西?

  • 输出是什么?最终结果是什么?

  • 鉴于第二条,我需要哪些转换才能达到目标?

  • 我是否需要处理多个流?

  • 我需要处理错误吗,如果需要,如何处理?

希望这让您了解如何思考流。记住,从小处开始,朝着目标努力。

总结

我们开始学习更多关于基本操作符的知识。在这样做的过程中,我们遇到了 map() 和 filter() 操作符,它们让我们能够控制发出的内容。了解 do() 操作符让我们有办法调试我们的流。此外,我们还了解了像 JS Bin 和 RxFiddle 这样的沙盒环境的存在,以及它们如何帮助我们快速开始使用 RxJS。AJAX 是我们之后深入了解的一个主题,并且我们建立了对可能发生的不同场景的理解。深入了解 RxJS,我们看了不同的操作符类别。虽然我们对其中的内容只是浅尚的涉猎,但这给了我们一个方法去学习库中有哪些类型的操作符。最后,我们通过思考流的方式来改变和发展我们的思维方式,来结束这一章。

这些所获得的知识使我们现在已经准备好进入下一章中更高级的 Rx 主题。我们知道了基础知识,现在是时候将它们掌握了。

第八章:RxJS 高级

我们完成了上一章,更多地教会了我们存在哪些操作符以及如何有效利用它们。拥有了这些知识,我们现在将更深入地涉足这个主题。我们将从了解存在哪些各个部分,到真正理解 RxJS 的本质。了解 RxJS 的本质就意味着更多地了解其运作机制。为了揭示这一点,我们需要涵盖诸如热、温和和冷 Observables 之间的区别是什么;了解 Subjects 以及它们适用的场景;以及有时被忽视的调度器等主题。

我们还有其他与处理 Observables 相关的方面要讨论,即,如何处理错误以及如何测试你的 Observables。

在这一章中,你将学到:

  • 热、冷和温和的 Observables

  • Subject:它们与 Observables 的区别以及何时使用它们

  • 可管道的操作符,RxJS 库的最新添加,以及它们对组合 Observables 的影响

  • 弹珠测试,有助于测试你的 Observables 的测试机制

热、冷和温和的 Observables

有热、冷和温和的 Observables。我们到底是什么意思呢?首先,让我们说你将处理的大多数事情都是冷 Observables。还是不明白?如果我们说冷 Observables 是懒惰的,这样有帮助吗?不?好吧,让我们先来谈谈 Promise。Promise 是热的。当我们执行它们的代码时,它们立刻就会执行。让我们来看一个例子:

// hot-cold-warm/promise.js

function getData() {
  return new Promise(resolve => {
    console.log("this will be printed straight away");
    setTimeout(() => resolve("some data"), 3000); 
  });
}

// emits 'some data' after 3 seconds
getData().then(data => console.log("3 seconds later", data));

如果你来自非 RxJS 背景,你可能在这一点上会想:好吧,是的,这是我预期的。尽管如此,我们要说明的是:调用 getData() 会使你的代码立即运行。这与 RxJS 不同,因为类似的 RxJS 代码实际上不会运行,直到有一个关心结果的监听器/订阅者。RxJS 回答了一个古老的哲学问题:如果有人不在那里听,树在森林中倒下时会不会发出声音?在 Promise 的情况下,会。在 Observable 的情况下,不会。让我们用一个类似的 RxJS 和 Observables 的代码例子来澄清我们刚才说的话:

// hot-cold-warm/observer.js

const Rx = require("rxjs/Rx");

function getData() {
  return Rx.Observable(observer => {
    console.log("this won't be printed until a subscriber exists");
    setTimeout(() => {
      observer.next("some data");
      observer.complete();
    }, 3000);
  });
}

// nothing happens
getData();

在 RxJS 中,像这样的代码被认为是冷,或者懒的。我们需要一个订阅者才能真正发生一些事情。我们可以像这样添加一个订阅者:

// hot-cold-warm/observer-with-subscriber

const Rx = require("rxjs/Rx");

function getData() {
  return Rx.Observable.create(observer => {
    console.log("this won't be printed until a subscriber exists");

    setTimeout(() => {
      observer.next("some data");
      observer.complete();
    }, 3000);
  });
}

const stream$ = getData();
stream$.subscribe(data => console.log("data from observer", data));

这是 Observable 与 Promises 的行为差异的一个重大区别,这一点非常重要。这是一个冷 Observable;那么,什么是热 Observable 呢?此时很容易认为,热 Observable 是立即执行的东西;然而,实际情况并非如此。关于热 Observable 的一个官方解释是,任何订阅它的东西都将与其他订阅者分享生产者。生产者就是在 Observable 内部内部喷出值的东西。这意味着数据被共享。让我们来看看冷 Observable 订阅方案,并将其与热 Observable 订阅方案进行对比。我们将从冷情况开始:

// hot-cold-warm/cold-observable.js
const Rx = require("rxjs/Rx");

const stream$ = Rx.Observable.interval(1000).take(3);

// subscriber 1 emits 0, 1, 2
stream$.subscribe(data => console.log(data));

// subscriber 2, emits 0, 1, 2
stream$.subscribe(data => console.log(data));

// subscriber 3, emits 0, 1, 2, after 2 seconds
setTimeout(() => {
  stream$.subscribe(data => console.log(data)); 
}, 3000);

在上述代码中,我们有三个不同的订阅者,它们各自接收到发出的值的副本。每次添加新的订阅者时,值都从头开始。当我们看前两个订阅者时可能会预料到这一点。至于第三个订阅者,它是在两秒后添加的。是的,即使该订阅者也会收到自己的一组值。解释是每个订阅者在订阅时都会收到自己的生产者。

对于热 Observable,只有一个生产者,这意味着上述情况会有所不同。让我们写一个热 Observable 场景的代码:

// hot observable scenario

// subscriber 1 emits 0, 1, 2
hotStream$.subscribe(data => console.log(data));

// subscriber 2, emits 0, 1, 2
hotStream$.subscribe(data => console.log(data));

// subscriber 3, emits 2, after 2 seconds
setTimeout(() => {
  hotStream$.subscribe(data => console.log(data)); 
}, 3000);

第三个订阅者仅输出值2的原因是其他值已经被发出。第三个订阅者并没有看到这一情况发生。在第三个值发出时,它出现了,这就是它收到值2的原因。

使一个流变热

这个hotStream$,它是如何创建的呢?你曾经说过大多数流都是冷的?实际上,我们有一个操作符来做到这一点,或者说实际上有两个操作符。我们可以使用操作符publish()connect()使流从冷变热。让我们从冷 Observable 开始,然后添加上述操作符,就像这样:

// hot-cold-warm/hot-observable.js

const Rx = require("rxjs/Rx");

let start = new Date();
let stream = Rx.Observable
  .interval(1000)
  .take(5)
  .publish();

setTimeout(() => {
  stream.subscribe(data => {
    console.log(`subscriber 1 ${new Date() - start}`, data);
  });
}, 2000);

setTimeout(() => {
  stream.subscribe(data => {
    console.log(`subscriber 2 ${new Date() - start}`, data)
  });
}, 3000);

stream.connect();
stream.subscribe(
  data => console.log(
    `subscriber 0 - I was here first ${new Date() - start}`, 
    data
  )
);

从上述代码中我们可以看到,我们创建了 Observable,并指示其发出值,每秒一个值。此外,应该在发出五个值后停止。然后我们调用操作符publish()。这将使我们处于就绪模式。然后我们设置了几个订阅分别在两秒后和三秒后发生。接着我们在流上调用connect()。这将使流从热到冷。因此,我们的流开始发出值,每当它开始订阅时,任何订阅者将与任何未来的订阅者共享一个生产者。最后,我们在调用connect()后立即添加了一个订阅者。让我们看看以下屏幕截图的输出:

我们的第一个订阅者在一秒后开始发出数值。第二个订阅者又在另一秒后开始发出数值。这时它的值是1;它错过了第一个值。又过了一秒,第三个订阅者被添加了进来。这个订阅者发出的第一个值是2;它错过了前两个值。我们清楚地看到了publish()connect()操作符是如何帮助我们创建热 Observable 的,以及订阅热 Observable 的时间是多么重要。

到底为什么我想要一个热 Observable?应用领域是什么?嗯,想象一下你有一个直播流,一个足球比赛,你把它流到很多订阅者/观众那里。他们不想看到比赛的第一分钟发生了什么,因为他们来晚了,而是想要看到比赛现在的情况,也就是订阅时的情况(当他们坐在电视机前)。所以,肯定存在热 Observable 适用的情况。

温和的流

迄今为止,我们一直在描述和讨论冷 Observable 和热 Observable,但还有第三种:温和的 Observable。温 Observable 可以被认为是作为冷 Observable 创建的,但在某些条件下变成了热 Observable。让我们通过介绍refCount()操作符来看一个这样的案例:

// hot-cold-warm/warm-observer.js

const Rx = require("rxjs/Rx");

let warmStream = Rx.Observable.interval(1000).take(3).publish().refCount();
let start = new Date();

setTimeout(() => {
  warmStream.subscribe(data => {
    console.log(`subscriber 1 - ${new Date() - start}`,data);
  });
}, 2000);

好,所以我们开始使用操作符publish(),看起来我们即将使用connect()操作符并且我们有了热 Observable,对吗?是的,但是我们没有调用connect(),而是调用了refCount()。这个操作符会让我们的 Observable 变得温和,这样当第一个订阅者到来时,它将表现得像一个冷 Observable。明白吗?那听起来就像一个冷 Observable,对吗?让我们先看一下输出:

回答前面的问题,是的,它确实就像一个冷 Observable;我们不会错过任何已发出的数值。有趣的是当我们加入第二个订阅者时会发生什么。我们来添加第二个订阅者,并看看效果如何:

// hot-cold-warm/warm-observable-subscribers.js

const Rx = require("rxjs/Rx");

let warmStream = Rx.Observable.interval(1000).take(3).publish().refCount();
let start = new Date();

setTimeout(() => {
  warmStream.subscribe(data => {
    console.log(`subscriber 1 - ${new Date() - start}`,data);
  });
}, 1000);

setTimeout(() => {
  warmStream.subscribe(data => {
    console.log(`subscriber 2 - ${new Date() - start}`,data);
  });
}, 3000);

我们添加了第二个订阅者;现在,我们来看一下结果:

从上面的结果中,我们可以看到第一个订阅者独自接收了数值0。当第二个订阅者到来时,它的第一个值是1,证明了这个流已经从表现得像冷 Observable 变成了热 Observable。

还有另一种方式可以创建温和的 Observable,那就是使用share()操作符。share()操作符可以被看作是一个更加智能的操作符,根据情况允许我们的 Observable 从冷到热转变。在某些情况下,这可能是一个非常好的主意。所以,观察到以下关于 Observable 的情况:

  • 作为热 Observable 创建;流没有完成,且没有订阅者超过一个

  • 回退为冷 Observable;在新的订阅到来之前,任何先前的订阅都已经结束

  • 作为一个冷 Observable 创建;Observable 本身在订阅发生之前已经完成

让我们尝试用代码展示第一个要点是如何发生的:

// hot-cold-warm/warm-observable-share.js

const Rx = require("rxjs/Rx");

let stream$ = Rx.Observable.create((observer) => {
  let i = 0;
  let id = setInterval(() => {
    observer.next(i++);
  }, 400);

  return () => {
    clearInterval(id);
  };
}).share();

let sub0, sub;

// first subscription happens immediately
sub0 = stream$.subscribe(
  (data) => console.log("subscriber 0", data),
  err => console.error(err),
  () => console.log("completed"));

// second subscription happens after 1 second
setTimeout(() => {
  sub = stream$.subscribe(
  (data) => console.log("subscriber 1", data),
  err => console.error(err),
  () => console.log("completed"));
}, 1000);

// everything is unscubscribed after 2 seconds
setTimeout(() => {
  sub0.unsubscribe();
  sub.unsubscribe();
}, 2000);

上述代码描述了这样一种情况:我们定义了一个立即发生订阅的流。第二个订阅将在一秒之后发生。现在,根据 share() 操作符的定义,这意味着该流将被创建为冷 Observable,但在第二个订阅者出现时,将成为热 Observable,因为存在先前的订阅者,且流尚未完成。让我们检查我们的输出,验证是否是这种情况:

第一个订阅者似乎显然独自获取值。当第二个订阅者到来时,它似乎与生产者共享,因为它不是从零开始,而是从第一个订阅者开始监听。

主题

我们习惯以某种方式使用 Observable。我们从某处构造它们并开始监听它们发出的值。通常我们几乎无法在创建之后影响正在发出的数据。当然,我们可以更改和过滤它,但除非与另一个流合并,否则在 Observable 中几乎不可能添加更多内容。让我们看看当我们真正控制正在发出的内容时,使用 create() 操作符何时适用于 Observable:

let stream$ = Rx.Observable.create(observer => {
  observer.next(1);
  observer.next(2);
});

stream$.subscribe(data => console.log(data));

我们看到 Observable 充当着一个包装器,围绕着真正发出值的对象 Observer。在我们的 Observer 实例中,Observer 调用 next(),带着一个参数来发出值,这些值我们在 subscribe() 方法中监听到。

本节是关于 Subject 的。Subject 与 Observable 的不同之处在于它可以在创建后影响流的内容。让我们用下面这段代码具体看一下:

// subjects/subject.js

const Rx = require("rxjs/Rx");

let subject = new Rx.Subject();

// emits 1
subject.subscribe(data => console.log(data));

subject.next(1);

我们注意到的第一件事是,我们只需调用构造函数,而不是像在 Observable 中那样使用工厂方法如 create()from() 或类似的方法。我们注意到的第二件事是我们在第二行订阅它,并且只有在最后一行调用 next() 才会发出值。为什么代码要按照这个顺序编写呢?嗯,如果我们不按照这种方式编写代码,并且在第二个调用 next() 的时候发生,我们的订阅变量将不存在,值会立即被发出。尽管我们确定了两件事:我们调用了 next(),我们调用了 subscribe(),这使 Subject 具有双重性质。我们确实提到了 Subject 能够完成另一件事情:在创建后改变流。我们的调用 next() 就是在做这件事。让我们再增加一些调用,以确保我们真正理解这个概念:

// subjects/subjectII.js

const Rx = require("rxjs/Rx");

let subject = new Rx.Subject();

// emits 10 and 100 2 seconds after
subject.subscribe(data => console.log(data));
subject.next(10);

setTimeout(() => {
  subject.next(100);
}, 2000);

正如我们之前所述,我们对next()方法的所有调用都使我们能够影响流;在我们的subscribe()方法中,我们看到对next()的每次调用都会导致subscribe()被调用,或者说,技术上来说,我们传递给它的第一个函数被调用。

使用主题(Subject)来进行级联列表操作

那么,问题是什么?为什么我们应该使用主题而不是可观察对象?这实际上是一个相当深奥的问题。对于大多数与流相关的问题,有许多解决方法;那些诱人使用主题的问题通常可以通过其他方式解决。不过,让我们看看你可以使用它来做什么。让我们谈谈级联下拉列表。我们所说的是,我们想知道一个城市中存在哪些餐馆。因此,想象一下,我们有一个下拉列表,允许我们选择我们感兴趣的国家。一旦我们选择了一个国家,我们应该从城市下拉列表中选择我们感兴趣的城市。此后,我们可以从餐馆列表中选择,并最终选择我们感兴趣的餐馆。在标记中,它很可能看起来像这样:

// subjects/cascading.html

<html>
<body>
  <select id="countries"></select>
  <select id="cities"></select>
  <select id="restaurants"></select>

  <script src="img/Rx.min.js"></script>
  <script src="img/cascadingIV.js"></script>
</body>
</html>

应用程序开始时,我们还没有选择任何内容,唯一被选择的下拉列表是第一个,其中填充了国家。假设我们因此在 JavaScript 中设置了以下代码:

// subjects/cascadingI.js

let countriesElem = document.getElementById("countries");
let citiesElem = document.getElementBtyId("cities");
let restaurantsElem = document.getElementById("restaurants");

// talk to /cities/country/:country, get us cities by selected country
let countriesStream = Rx.Observable.fromEvent(countriesElem, "select");

// talk to /restaurants/city/:city, get us restaurants by selected restaurant
let citiesStream = Rx.Observable.fromEvent(citiesElem, "select");

// talk to /book/restaurant/:restaurant, book selected restaurant
let restaurantsElem = Rx.Observable.fromEvent(restaurantsElem, "select");

到此为止,我们已经确定我们想要监听每个下拉列表的选定事件,并且在国家或城市下拉列表的情况下,我们想要筛选即将出现的下拉列表。假设我们选择了一个特定的国家,那么我们想要重新填充/筛选城市下拉列表,以便它只显示选定国家的城市。对于餐厅下拉列表,我们想要根据我们选择的餐厅进行预订。听起来相当简单,对吧?我们需要一些订阅者。城市下拉列表需要监听国家下拉列表的变化。因此,我们将其添加到我们的代码中:

// subjects/cascadingII.js

let countriesElem = document.getElementById("countries");
let citiesElem = document.getElementBtyId("cities");
let restaurantsElem = document.getElementById("restaurants");

fetchCountries();

function buildList(list, items) {
  list.innerHTML ="";
  items.forEach(item => {
    let elem = document.createElement("option");
    elem.innerHTML = item;
    list.appendChild(elem);
  });
}

function fetchCountries() {
  return Rx.Observable.ajax("countries.json")
    .map(r => r.response)
    .subscribe(countries => buildList(countriesElem, countries.data));
}

function populateCountries() {
  fetchCountries()
    .map(r => r.response)
    .subscribe(countries => buildDropList(countriesElem, countries));
}

let cities$ = new Subject();
cities$.subscribe(cities => buildList(citiesElem, cities));

Rx.Observable.fromEvent(countriesElem, "change")
  .map(ev => ev.target.value)
  .do(val => clearSelections())
  .switchMap(selectedCountry => fetchBy(selectedCountry))
  .subscribe( cities => cities$.next(cities.data));

Rx.Observable.from(citiesElem, "select");

Rx.Observable.from(restaurantsElem, "select");

因此,在这里,我们有一个在选择国家时执行 AJAX 请求的行为;我们获得一个经过筛选的城市列表,并引入新的主题实例cities$。我们对其调用next()方法,并将我们筛选后的城市作为参数传递。最后,通过在流上调用subscribe()方法来监听对cities$流的更改。正如你所见,当数据到达时,我们在那里重建我们的城市下拉列表。

我们意识到我们的下一步是要对我们在城市下拉列表中进行选择的变化做出反应。所以,让我们设置好:

// subjects/cascadingIII.js

let countriesElem = document.getElementById("countries");
let citiesElem = document.getElementBtyId("cities");
let restaurantsElem = document.getElementById("restaurants");

fetchCountries();

function buildList(list, items) {
  list.innerHTML = "";
  items.forEach(item => {
    let elem = document.createElement("option");
    elem.innerHTML = item;
    list.appendChild(elem);
  });
}

function fetchCountries() {
  return Rx.Observable.ajax("countries.json")
    .map(r => r.response)
    .subscribe(countries => buildList(countriesElem, countries.data));
}

function populateCountries() {
  fetchCountries()
    .map(r => r.response)
    .subscribe(countries => buildDropList(countriesElem, countries));
}

let cities$ = new Subject();
cities$.subscribe(cities => buildList(citiesElem, cities));

let restaurants$ = new Rx.Subject();
restaurants$.subscribe(restaurants => buildList(restaurantsElem, restaurants));

Rx.Observable.fromEvent(countriesElem, "change")
  .map(ev => ev.target.value)
  .do( val => clearSelections())
  .switchMap(selectedCountry => fetchBy(selectedCountry))
  .subscribe( cities => cities$.next(cities.data));

Rx.Observable.from(citiesElem, "select")
 .map(ev => ev.target.value)
  .switchMap(selectedCity => fetchBy(selectedCity))
  .subscribe( restaurants => restaurants$.next(restaurants.data)); // talk to /book/restaurant/:restaurant, book selected restaurant
Rx.Observable.from(restaurantsElem, "select");

在上述代码中,我们添加了一些代码来反应我们在城市下拉列表中做出选择。我们还添加了一些代码来监听restaurants$流的变化,最终导致我们的餐馆下拉列表重新填充。最后一步是监听我们在餐馆下拉列表中选择餐馆时的变化。在这里应该发生的事情由你来决定,亲爱的读者。建议是我们为所选餐厅的营业时间或菜单查询一些 API。发挥你的创造力。不过,我们将留给你一些最终的订阅代码:

// subjects/cascadingIV.js

let cities$ = new Rx.Subject();
cities$.subscribe(cities => buildList(citiesElem, cities));

let restaurants$ = new Rx.Subject();
restaurants$.subscribe(restaurants => buildList(restaurantsElem, restaurants));

function buildList(list, items) {
  list.innerHTML = "";
  items.forEach(item => {
    let elem = document.createElement("option");
    elem.innerHTML = item;
    list.appendChild(elem);
  });
}

function fetchCountries() {
  return Rx.Observable.ajax("countries.json")
    .map(r => r.response)
    .subscribe(countries => buildList(countriesElem, countries.data));
}

function fetchBy(by) {
  return Rx.Observable.ajax(`${by}.json`)
  .map(r=> r.response);
}

function clearSelections() {
  citiesElem.innerHTML = "";
  restaurantsElem.innerHTML = "";
}

let countriesElem = document.getElementById("countries");
let citiesElem = document.getElementById("cities");
let restaurantsElem = document.getElementById("restaurants");

fetchCountries();

Rx.Observable.fromEvent(countriesElem, "change")
  .map(ev => ev.target.value)
  .do(val => clearSelections())
  .switchMap(selectedCountry => fetchBy(selectedCountry))
  .subscribe(cities => cities$.next(cities.data));

Rx.Observable.fromEvent(citiesElem, "change")
  .map(ev => ev.target.value)
  .switchMap(selectedCity => fetchBy(selectedCity))
  .subscribe(restaurants => restaurants$.next(restaurants.data));

Rx.Observable.fromEvent(restaurantsElem, "change")
  .map(ev => ev.target.value)
  .subscribe(selectedRestaurant => console.log("selected restaurant", selectedRestaurant));

这变成了一个相当长的代码示例,应该说这不是解决这个问题的最佳方式,但它确实演示了 Subject 的工作原理:它可以在需要时向流中添加值,并且可以被订阅。

BehaviorSubject

到目前为止,我们一直在研究默认类型的 Subject,并揭示了一点它的秘密。然而,还有许多种类型的 Subject。其中一种有趣的类型是BehaviorSubject。所以,我们为什么需要BehaviorSubject,以及用来做什么呢?嗯,当处理默认的 Subject 时,我们能够向流中添加值,并且订阅该流。BehaviorSubject在形式上给了我们一些额外的能力,例如:

  • 一个初始值,如果我们能够在等待 AJAX 调用完成时向 UI 展示一些内容,那就太棒了

  • 我们可以查询最新的数值;在某些情况下,了解上次发出的值是很有意思的。

要解决第一点,让我们写一些代码来展示这种能力:

// subjects/behavior-subject.js

let behaviorSubject = new Rx.BehaviorSubject("default value");

// will emit 'default value'
behaviorSubject.subscribe(data => console.log(data));

// long running AJAX scenario
setTimeout(() => {
  return Rx.Observable.ajax("data.json")
    .map(r => r.response)
    .subscribe(data => behaviorSubject.next(data));
}, 12000);

ReplaySubject

对于普通的 Subject,我们订阅开始的时机很重要。如果我们在设置订阅之前开始发出值,那些值就会被简单地丢失。如果我们有BehaviorSubject,情况会稍微好一些。即使我们在订阅之后才开始发出值,最后发出的值仍然可以获取。然后,接下来的问题是:如果在订阅之前发出了两个或更多个值,并且我们关心这些值 - 那么怎么办呢?

让我们来说明这种情况,并分别看看 Subject 和BehaviorSubject会发生什么:

// example of emitting values before subscription

const Rx = require("rxjs/Rx");

let subject = new Rx.Subject();
subject.next("subject first value");

// emits 'subject second value'
subject.subscribe(data => console.log("subscribe - subject", data));
subject.next("subject second value");

let behaviourSubject = new Rx.BehaviorSubject("behaviorsubject initial value");
behaviourSubject.next("behaviorsubject first value");
behaviourSubject.next("behaviorsubject second value");

// emits 'behaviorsubject second value', 'behaviorsubject third value' 
behaviourSubject.subscribe(data =>
  console.log("subscribe - behaviorsubject", data)
);

behaviourSubject.next("behaviorsubject third value");

从上述代码中可以看到,如果我们关心订阅之前的值,Subject 并不是一个好的选择。BehaviorSubject构造函数在这种情况下略微好一些,但如果我们真的关心之前的值,并且有很多值,那么我们应该看看ReplaySubjectReplaySubject有能力指定两件事:缓冲区大小和窗口大小。缓冲区大小简单地表示它应该记住过去的值的数量,窗口大小指定它应该记住它们多久。让我们在代码中演示一下:

// subjects/replay-subject.js

const Rx = require("rxjs/Rx");

let replaySubject = new Rx.ReplaySubject(2);

replaySubject.next(1);
replaySubject.next(2);
replaySubject.next(3);

// emitting 2 and 3
replaySubject.subscribe(data => console.log(data));

在前面的代码中,我们可以看到我们发出了23,也就是最近发出的两个值。这是因为我们在ReplaySubject构造函数中指定了缓冲区大小为 2。我们唯一丢失的值是1。反之,如果我们在构造函数中指定了一个 3,所有三个值都将到达订阅者。这就是缓冲区大小及其工作方式;那么窗口大小属性又是如何工作的呢?让我们用以下代码来说明它的工作方式:

// subjects/replay-subject-window-size.js

const Rx = require("rxjs/Rx");

let replaySubjectWithWindow = new Rx.ReplaySubject(2, 2000);
replaySubjectWithWindow.next(1);
replaySubjectWithWindow.next(2);
replaySubjectWithWindow.next(3);

setTimeout(() => {
  replaySubjectWithWindow.subscribe(data =>
    console.log("replay with buffer and window size", data));
  }, 
2010);

在这里,我们将窗口大小指定为 2,000 毫秒;这就是值应该保留在缓冲区中的时间。我们可以看到在 2,010 毫秒后我们延迟了订阅的创建。这样做的最终结果是在订阅发生之前不会发出任何值,因为缓冲区在订阅发生之前就已经被清空了。增加窗口大小的值会解决这个问题。

AsyncSubject

AsyncSubject 的容量为 1,这意味着我们可以发出大量的值,但只有最新的值是被存储的。它并不是真的丢失了,但除非您完成流,否则您看不到它。让我们看一个说明这种情况的代码片段:

// subjects/async-subject.js

let asyncSubject = new Rx.AsyncSubject();
asyncSubject.next(1);
asyncSubject.next(2);
asyncSubject.next(3);
asyncSubject.next(4);

asyncSubject.subscribe(data => console.log(data), err => console.error(err));

早些时候,我们发出了四个值,但似乎没有到达订阅者。在这一点上,我们不知道这是因为它只是像一个主题一样丢弃在订阅之前发出的所有值,还是因为其他原因。因此,让我们调用complete()方法并看看它的表现是如何的:

// subjects/async-subject-complete.js

let asyncSubject = new Rx.AsyncSubject();
asyncSubject.next(1);
asyncSubject.next(2);
asyncSubject.next(3);
asyncSubject.next(4);

// emits 4
asyncSubject.subscribe(data => console.log(data), err => console.error(err));
asyncSubject.complete();

这将会发出一个4,因为AsyncSubject只会记住最后一个值,并且我们调用了complete()方法,从而表示流的结束。

错误处理

错误处理是一个非常重要的话题。这是一个容易被忽视的领域。通常在编码时,我们可能会认为我们只需要做一些事情,比如确保我们没有语法错误或运行时错误。对于流,我们大多数时候会考虑运行时错误。问题是,当出现错误时我们应该如何处理呢?我们是应该假装像下雨一样把错误抛开吗?还是我们应该希望在未来的某个时候尝试相同的代码会得到不同的结果,或者当某种类型的错误存在时我们应该放弃?让我们试着集中我们的思想,并看看在 RxJS 中存在的不同错误处理方法。

捕获并继续

迟早会有一个流会抛出一个错误。让我们看看可能是什么样子:

// example of a stream with an error

let stream$ = Rx.Observable.create(observer => {
  observer.next(1);
  observer.error('an error is thrown');  
  observer.next(2);
});

stream$.subscribe(
  data => console.log(data), // 1 
  error => console.error(error) // 'error is thrown'
);

在前面的代码中,我们设置了一个场景,我们首先发出一个值,然后发出一个错误。第一个值被我们的订阅方法的第一个回调捕获了。第二个发出的东西,也就是错误,被我们的错误回调捕获了。第三个发出的值没有传递给我们的订阅者,因为我们的流已经被错误中断。在这里我们可以做一些事情,那就是使用catch()运算符。让我们将它应用到我们的流上并看看会发生什么:

// error-handling/error-catch.js
const Rx = require("rxjs/Rx");

let stream$ = Rx.Observable.create(observer => {
  observer.next(1);
  observer.error("an error is thrown");
  observer.next(2);
}).catch(err => Rx.Observable.of(err));

stream$.subscribe(
  data => console.log(data), // emits 1 and 'error is thrown'
  error => console.error(error)
);

在这里,我们用 catch() 运算符捕获了我们的错误。在 catch() 运算符中,我们获取我们的错误并使用 of() 运算符将其作为普通 Observable 发出。然而我们发出的 2 发生了什么?对于这个,还是没有运气。catch() 运算符能够获取我们的错误并将其转换为正常发出的值;而不是一个错误,我们从流中并未获取到所有的值。

让我们看一个处理多个流的场景:

// example of merging several streams

let merged$ = Rx.Observable.merge(
  Rx.Observable.of(1),
  Rx.Observable.throw("err"),
  Rx.Observable.of(2)
);

merged$.subscribe(data => console.log("merged", data));

在上面的场景中,我们合并了三个流。第一个流发出数字1,没有其他内容被发出。这是因为我们的第二个流将所有内容破坏,因为它发出了一个错误。让我们尝试应用我们新发现的 catch() 运算符并看看会发生什么:

// error-handling/error-merge-catch.js

const Rx = require("rxjs/Rx");

let merged$ = Rx.Observable.merge(
  Rx.Observable.of(1),
  Rx.Observable.throw("err").catch(err => Rx.Observable.of(err)),
  Rx.Observable.of(2)
);

merged$.subscribe(data => console.log("merged", data));

我们运行上面的代码,注意到 1 被发出,错误被作为正常值发出,最后,甚至 2 也被发出了。我们的结论是在将我们的流与其他流合并之前,应用 catch() 运算符是一个好主意。

与之前一样,我们也可以得出结论,catch() 运算符能够阻止流仅仅出错,但是在错误之后会发出的其他值实际上是丢失的。

忽略错误

正如我们在前面的部分看到的,catch() 运算符很好地确保了出错的流在与另一个流合并时不会造成任何问题。catch() 运算符使我们能够获取错误,调查它,并创建一个新的 Observable ,它将发出一个值,就好像什么都没发生一样。然而,有时候,您甚至不想使用出错的流。对于这种情况,有一个名为 onErrorResumeNext() 的不同运算符:

// error-handling/error-ignore.js
const Rx = require("rxjs/Rx");

let mergedIgnore$ = Rx.Observable.onErrorResumeNext(
  Rx.Observable.of(1),
  Rx.Observable.throw("err"),
  Rx.Observable.of(2)
);

mergedIgnore$.subscribe(data => console.log("merge ignore", data));

使用onErrorResumeNext() 运算符的含义是第二个流,即发出错误的流,完全被忽略,发出值12。如果您的场景仅涉及不出错的流,这是一个非常好的运算符。

重试

有不同的原因,你会想要重试一个流。如果您的流处理 AJAX 调用,你就更容易想象为什么要这样做。有时候,局域网上的网络连接可能不可靠,或者您尝试访问的服务可能因某些原因暂时不可用。无论原因如何,您都会遇到这样一种情况,即 hitting 那个端点有时候会回答一个答案,有时候会返回一个 401 错误。我们在这里描述的是向您的流添加重试逻辑的业务场景。让我们看一个设计为失败的流:

// error-handling/error-retry.js
const Rx = require("rxjs/Rx");

let stream$ = Rx.Observable.create(observer => {
  observer.next(1);
  observer.error("err");
})
.retry(3);

// emits 1 1 1 1 err
stream$
  .subscribe(data => console.log(data));

以上代码的输出是值1被发出了四次,然后是我们的错误。发生的情况是我们的流值在订阅中错误回调被命中之前重试了三次。使用retry()操作符延迟了什么时候错误实际被视为错误。然而,上面的例子不合理的地方在于重试是没有意义的,因为错误总是会发生。因此,让我们举个更好的例子 – 一个网络连接可能出现忽然消失的 AJAX 调用:

// example of using a retry with AJAX

let ajaxStream$ = Rx.Observable.ajax("UK1.json")
  .map(r => r.response)
  .retry(3);

ajaxStream$.subscribe(
  data => console.log("ajax result", data),
  err => console.error("ajax error", err)
);

在这里,我们正在尝试向一个似乎不存在的文件发送一个 AJAX 请求。看看控制台,我们面临以下结果:

在上述日志中我们看到了四次失败的 AJAX 请求,导致了一个错误。我们基本上仅仅是将我们的简单流切换为了一个更可信的 AJAX 请求流,具有相同的行为。如果文件突然开始存在,可能会出现两次失败尝试和一次成功尝试的情况。然而,我们的方法有一个缺陷:我们进行 AJAX 尝试的次数太多了。如果我们实际上正在处理间歇性的网络连接,我们需要在尝试之间设置一定的延迟。合理的做法是在尝试之间设置至少 30 秒或更长的延迟。我们可以通过使用一种稍微不同的重试操作符来实现这一点,它以毫秒而不是尝试次数作为参数。它看起来像下面这样:

// retry with a delay

let ajaxStream$ = Rx.Observable.ajax("UK1.json")
  .do(r => console.log("emitted"))
  .map(r => r.response)
  .retryWhen(err => {
    return err.delay(3000);
  });

这里我们使用了操作符retryWhen()retryWhen()操作符的使命是返回一个流。在这一点上,你可以通过添加一个.delay()操作符来延迟它返回的流,以便能够操纵它。这样做的结果是,它将永远重试 AJAX 调用,这可能不是你想要的。

高级重试

我们最有可能想要的是将重试尝试之间的延迟与能够指定我们想要重试流的次数结合起来。让我们看看如何实现这一点:

// error-handling/error-retry-advanced.js

const Rx = require("rxjs/Rx");

let ajaxStream$ = Rx.Observable.ajax("UK1.json")
  .do(r => console.log("emitted"))
  .map(r => r.response)
  .retryWhen(err => {
    return err
    .delay(3000)
    .take(3);
});

这里有趣的部分是我们使用了操作符.take()。我们指定了我们想要从这个内部 Observable 中发出的值的数量。我们现在实现了一种不错的方法,可以控制重试次数和重试之间的延迟。还有一个方面我们还没有尝试到,即当最终放弃时我们想要重试全部重试的方式。在之前的代码中,当流在尝试了x次后没有成功结果时,流就会直接完成。然而,我们可能希望流出现错误。我们只需在代码中添加一个操作符,就可以实现这一点,像这样:

// error-handling/error-retry-advanced-fail.js

let ajaxStream$ = Rx.Observable.ajax("UK1.json")
  .do(r => console.log("emitted"))
  .map(r => r.response)
  .retryWhen(err => {
    return err
    .delay(3000)
    .take(3)
    .concat(Rx.Observable.throw("giving up"));
});

在这里,我们添加了一个concat()操作符,它将一个仅仅会失败的流添加进来。因此,在三次失败尝试之后一定会发生一个错误。这通常比在x次失败尝试之后默默地完成流更好。

不过这并不是一个完美的方法;想象一下你想调查你得到了什么类型的错误。对于进行的 AJAX 请求的情况来说,获得一个以 400 开头的错误和以 500 开头的错误作为 HTTP 状态码是有关系的。它们有不同的含义。500 错误意味着后端出了非常严重的问题,我们可能要立即放弃。然而,404 错误意味着资源不存在,但在与断断续续的网络连接的情况下,这意味着由于我们的连接离线而无法到达资源。因此,重新尝试 404 错误可能是值得的。为了在代码中解决这个问题,我们需要检查发出的值以确定要做什么。我们可以使用do()操作符来检查值。

在下面的代码中,我们调查响应的 HTTP 状态类型并确定如何处理它:

// error-handling/error-retry-errorcodes.js

const Rx = require("rxjs/Rx");

function isOkError(errorCode) {
  return errorCode >= 400 && errorCode < 500;
}

let ajaxStream$ = Rx.Observable.ajax("UK1.json")
  .do(r => console.log("emitted"))
  .map(r => r.response)
  .retryWhen(err => {
    return err
      .do(val => {
        if (!isOkError(val.status) || timesToRetry === 0) {
          throw "give up";
        }
      })
      .delay(3000);
  });

大理石测试

测试异步代码可能是具有挑战性的。首先,我们有时间因素。我们指定用于我们精心设计的算法的操作符的方式导致算法执行的时间从 2 秒到 30 分钟不等。因此,一开始会感觉没有必要进行测试,因为在合理的时间内无法完成。不过,我们有一种测试 RxJS 的方法;它被称为大理石测试,它允许我们控制时间的流逝速度,这样我们就可以在毫秒内执行测试。

大理石的概念为我们所知。我们可以表示一个或多个流以及操作符对两个或多个流产生的影响。我们通过在线上画出流并将值表示为线上的圆圈来做到这一点。操作符显示为输入流下面的动词。操作符后面是第三个流,这是取得输入流并应用操作符得到的结果,即所谓的大理石图。线表示一个连续的时间线。我们将这个概念带到测试中。这意味着我们可以将我们的传入值表示为一个图形表达,并对其应用我们的算法,然后对结果进行断言。

设置

让我们正确设置环境,以便我们可以编写大理石测试。我们需要以下内容:

  • NPM 库 jasmine-marbles

  • 一个已经脚手架化的 Angular 应用

有了这些,我们脚手架化我们的 Angular 项目,就像这样:

ng new MarbleTesting

项目脚手架完成后,现在是时候添加我们的 NPM 库了,就像这样:

cd MarbleTesting
npm install jasmine-marbles --save

现在我们已经完成了设置,所以是时候编写测试了。

编写你的第一个大理石测试

让我们创建一个新的文件marble-testing.spec.ts。它应该看起来像这样:

// marble-testing\MarbleTesting\src\app\marble-testing.spec.ts

import { cold } from "jasmine-marbles";
import "rxjs/add/operator/map";

describe("marble tests", () => {
  it("map - should increase by 1", () => {
    const one$ = cold("x-x|", { x: 1 });
    expect(one$.map(x => x + 1)).toBeObservable(cold("x-x|", { x: 2 }));
  });
});

这里发生了很多有趣的事情。我们从 NPM 库 marble-testing 中导入cold()函数。然后我们通过调用describe()来设置一个测试套件,接着通过调用it()来设置一个测试规范。然后我们调用我们的cold()函数并提供一个字符串。让我们仔细看看那个函数调用:

const stream$ = cold("x-x|", { x: 1 });

上面的代码设置了一个流,期望在流结束前发出两个值。我们怎么知道呢?现在该解释x-x|的含义了。x只是任意值,短横线-表示时间过去了。竖线|表示我们的流已结束。冷函数中的第二个参数是一个映射对象,告诉我们 x 代表什么。在这种情况下,它意味着值是 1。

接下来,让我们看一下下一行:

expect(stream$.map(x => x + 1)).toBeObservable(cold("x-x|", { x: 2 }));

上述代码应用了.map()运算符,并且对流中发出的每个值加了一。然后,我们调用了.toBeObservable()辅助方法并根据预期条件进行验证,

cold("x-x|", { x: 2 })

前面的条件说明我们期望流应该发出两个值,但这些值应该有数字 2。这是有道理的,因为我们的map()函数就是做这个。

补充更多测试

让我们再写一个测试。这次我们将测试filter()运算符。这个很有意思,因为它过滤掉不满足特定条件的值。我们的测试文件现在应该看起来像这样:

import { cold } from "jasmine-marbles";
import "rxjs/add/operator/map";
import "rxjs/add/operator/filter";

describe("marble testing", () => {
  it("map - should increase by 1", () => {
    const one$ = cold("x-x|", { x: 1 });
    expect(one$.map(x => x + 1)).toBeObservable(cold("x-x|", { x: 2 }));
  });

  it("filter - should remove values", () => {
    const stream$ = cold("x-y|", { x: 1, y: 2 });
    expect(stream$.filter(x => x > 1)).toBeObservable(cold("--y|", { y: 2 }));
  });
});

这个测试设置方式几乎和我们的第一个测试一样。这次我们使用filter()运算符,但值得注意的是我们的预期流:

cold("--y|", { y: 2 })

--y,表示我们的第一个值被移除了。根据过滤条件的定义,我们不感到意外。然而,双短横线-的原因是时间仍在流逝,但是一个短横线取代了一个发出的值。

要了解更多关于 Marble 测试的信息,请查看官方文档中的以下链接,github.com/ReactiveX/rxjs/blob/master/doc/writing-marble-tests.md

可管道的运算符

到目前为止,我们没有提及太多,但是当在应用中使用 RxJS 库时,它会占据相当大的空间。在如今的移动优先世界中,每个库在你的应用中包含的千字节都很重要。这很重要,因为用户可能在 3G 连接上,如果加载时间过长,用户可能离开,或者可能不喜欢你的应用,因为它感觉加载很慢,这可能导致你得到不好的评论或失去用户。到目前为止,我们已经使用了两种不同的导入 RxJS 的方式:

  • 导入整个库;这在体积上是相当昂贵的

  • 只导入我们需要的运算符;这可以显著减少捆绑包的大小

不同的选项看起来像这样,导入整个库和所有它的运算符:

import Rx from "rxjs/Rx";

或者这样,只导入我们需要的内容:

import { Observable } from 'rxjs/Observable';
import "rxjs/add/operator/map";
import "rxjs/add/operator/take";

let stream = Observable.interval(1000)
  .map(x => x +1)
  .take(2)

这看起来不错,是吗?是的,但这是一个有缺陷的方法。让我们解释一下当你输入时会发生什么:

import "rxjs/add/operator/map";

通过输入上述内容,我们会添加到Observable的原型中。查看 RxJS 的源代码,它是这样的:

var Observable_1 = require('../../Observable');
var map_1 = require('../../operator/map');

Observable_1.Observable.prototype.map = map_1.map;

从上面的代码中可以看出,我们导入了Observable以及相关的操作符,并且通过将它们分配到原型的map属性上,将操作符添加到了原型上。你可能会想这有什么毛病?问题在于摇树优化,这是我们用来摆脱未使用代码的过程。摇树优化在确定你使用和不使用的代码时会出现问题。事实上,你可能导入了一个map()操作符并将其添加到 Observable 上。随着代码随着时间的推移而改变,你可能最终不再使用它。你可能会争辩说此刻应该移除导入,但你可能的代码量很大,很容易忽略。最好的方式应该是只有使用的操作符包含在最终的包中。正如我们之前提到的,摇树优化的过程很难知道当前方法中使用了什么,没有使用什么。因此,在 RxJS 中进行了一次大规模的重写,添加了一种称为可管道化操作符的东西,它帮助我们解决了上述问题。对原型进行补丁还有另一个不足之处,那就是它创建了一个依赖。如果库发生改变并且我们在进行补丁时不再添加操作符(调用导入),那么我们就有了一个问题。我们只有在运行时才会发现这个问题。我们宁愿得到一个消息,告诉我们操作符已经过我们导入和明确使用,就像这样:

import { operator } from 'some/path';

operator();

使用 let() 创建可重用的操作符

let()操作符允许你拥有整个操作符并对其进行操作,而不仅仅像map()操作符那样操作值。使用let()操作符可能像这样:

import Rx from "rxjs/Rx";

let stream = Rx.Observable.of(0,1,2);
let addAndFilter = obs => obs.map( x => x * 10).filter(x => x % 10 === 0);
let sub3 = obs => obs.map(x => x - 3);

stream
  .let(addAndFilter)
  .let(sub3)
  .subscribe(x => console.log('let', x));

在上面的例子中,我们能够定义一组操作符,比如addAndFiltersub3,并且使用let()操作符在流上使用它们。这使我们能够创建可组合和可重用的操作符。正是基于这种知识,我们现在转向可管道化操作符的概念。

转向可管道化操作符

正如我们之前提到的,可管道化操作符已经出现了,通过从rxjs/operators目录中导入相应的操作符,你就能找到它们,就像这样:

import { map } from "rxjs/operators/map";
import { filter } from "rxjs/operators/filter";

要使用它,我们现在依赖于pipe()操作符,它就像父操作符一样。因此,使用上述操作符将如下所示:

import { map } from "rxjs/operators/map";
import { filter } from "rxjs/operators";
import { of } from "rxjs/observable/of";
import { Observable } from "rxjs/Observable";

let stream = of(1,2);
stream.pipe(
  map(x => x + 1),
  filter(x => x > 1)
)
.subscribe(x => console.log("piped", x)); // emits 2 and 3

总结

本章内容深入介绍了 RxJS,涉及了诸如热、冷、温暖的 Observables 等主题,并且解释了在何时订阅流以及在特定条件下它们如何共享生产者的含义。接下来,我们介绍了 Subject,并且 Observable 并不是你唯一可以订阅的东西。Subject 也允许我们随时向流中添加值,并且我们也了解到根据具体情况存在不同类型的 Subject。

我们深入探讨了一个重要的主题,测试,并试图解释测试异步代码的困难。我们谈到了测试情况的当前状态,以及在这里和现在用什么库进行测试场景。最后,我们介绍了管道操作符,以及我们新的首选导入和组合操作符的方式,以确保我们最终得到尽可能小的捆绑包大小。

在下一章中,您将利用 Waffle 使用看板,按照全栈架构构建一个简单的 Web 应用,并了解使用 RxJS 进行响应式编程。

第九章:创建本地天气 web 应用程序

我们将设计并构建一个简单的使用 Angular 和第三方 web API 的本地天气应用程序,使用迭代式开发方法。您将专注于首先提供价值,同时学习如何使用 Angular、TypeScript、Visual Studio Code、响应式编程和 RxJS 的微妙之处和最佳方式。

在本章中,您将学习以下内容:

  • 使用 Waffle 作为连接到 GitHub 的看板进行路线规划

  • 制作新的 UI 元素来显示当前天气信息,使用组件和接口。

  • 使用 Angular 服务和 HttpClient 从 OpenWeatherMap API 检索数据

  • 利用可观察流使用 RxJS 转换数据

本书提供的代码示例需要 Angular 5 和 6. Angular 5 代码与 Angular 6 兼容。 Angular 6 将在 2019 年 10 月之前得到长期支持。最新版本的代码存储库可以在以下找到:

使用 Waffle 规划功能路线图

在开始编码之前,制定一个粗略的行动计划非常重要,这样您和您的同事或客户就知道您计划执行的路线图。无论您是为自己还是为别人构建应用程序,功能的实时备用库将始终作为在休息之后重返项目时的良好提醒,或作为信息辐射器,防止不断请求状态更新。

在敏捷开发中,您可能已经使用过各种票务系统或工具,例如看板或看板。我的最爱工具是 Waffle.io,waffle.io/,因为它直接集成了您的 GitHub 存储库的问题,并通过标签跟踪问题的状态。这样,您可以继续使用您选择的工具与您的存储库进行交互,并且轻松地发布信息。在接下来的部分中,您将设置一个 Waffle 项目以实现这个目标。

设置一个 Waffle 项目

现在我们将设置我们的 Waffle 项目:

  1. 转到 Waffle.io waffle.io/

  2. 点击登录或免费开始。

  3. 选择公共和私有存储库,以允许访问您的所有存储库。

  4. 点击创建项目。

  5. 搜索本地天气应用程序存储库并选择它。

  6. 点击继续。

你将获得两个初始布局模板,如下图所示:

Waffle.io 默认的看板布局

对于这个简单的项目,您将选择基本。但是,高级布局演示了如何修改 Waffle 的默认设置,通过添加额外的列,如审查,以便测试人员或产品所有者参与过程。您可以进一步定制任何看板以适应您现有的流程。

  1. 选择基本布局并点击创建项目。

  2. 您将看到为您创建的新看板。

空的 Waffle 看板

默认情况下,Waffle 将作为看板服务。它允许你将一个任务从一个状态移动到另一个状态。然而,默认视图将显示存储库中存在的所有问题。要将 Waffle 用作 Scrum 板,您需要为 GitHub 里程碑分配问题,这些里程碑将代表迭代。然后,您可以使用过滤功能仅显示来自该里程碑的问题,或者说来自当前迭代。

在 Waffle 上,您可以通过点击  比例图标给问题附上故事点。列将自动显示总数和卡片顺序,表示优先级,并且将从一个会话保留到另一个会话。此外,您可以切换到度量视图以获取里程碑燃尽图和吞吐量图表和统计信息。

为您的 Local Weather 应用程序创建问题

现在,我们将创建问题的积压,您将使用这些问题来跟踪在实现应用程序设计时的进度。在创建问题时,您应该专注于提供一些价值给用户的功能迭代。您必须克服的技术障碍对您的用户或客户来说没有任何意义。

以下是我们计划在我们的第一个发布版本中构建的功能:

  • 显示当前位置的当天天气信息

  • 显示当前位置的天气预报信息

  • 添加城市搜索功能,使用户可以查看其他城市的天气信息

  • 添加一个首选项窗格,用于存储用户的默认城市

  • 使用 Angular Material 改善应用程序的用户体验

随意在 Waffle 或 GitHub 上创建问题;无论你喜欢哪种方式都可以。在创建第一个迭代的范围时,我对功能有一些其他想法,所以我只是添加了这些问题,但没有指定给某个人或一个里程碑。我还继续为我打算处理的问题添加了故事点。以下是看起来像的看板,因为我将开始处理第一个故事:

板的初始状态快照位于 waffle.io/duluca/local-weather-app

最终,Waffle 提供了一个易于使用的 GUI,以便非技术人员可以轻松地与 GitHub 问题进行交互。通过允许非技术人员参与 GitHub 上的开发过程,你可以让 GitHub 成为整个项目的单一信息来源的好处得以发挥。关于功能和问题的问题、答案和讨论都将作为 GitHub 问题的一部分进行跟踪,而不会在电子邮件中丢失。你还可以在 GitHub 上存储维基类型的文档,因此通过在 GitHub 上集中所有与项目相关的信息、数据、对话和工件,你正在极大地简化可能涉及多个需要持续维护、成本高昂的系统的交互。对于私有知识库和本地企业安装,GitHub 的费用非常合理。如果你坚持使用开源,就像我们在本章中所做的那样,所有这些工具都是免费的。

作为一个额外的福利,我在我的知识库 github.com/duluca/local-weather-app/wiki 上创建了一个初级的维基页面。请注意,你不能在 README.md 或维基页面上上传图片。为了解决这个限制,你可以创建一个新的问题,上传图片作为评论,然后复制并粘贴它的 URL 来在 README.md 或维基页面上嵌入图片。在示例维基中,我使用了这种技术将线框设计嵌入到页面中。

有了一个明确的路线图,你现在准备开始实施你的应用程序。

使用组件和接口来构建 UI 元素

你将利用 Angular 组件、接口和服务以一种解耦、内聚和封装的方式来构建当前天气功能。

Angular 应用的默认起始页位于app.component.html。因此,首先要编辑AppComponent的模板,使用基本的 HTML 布局应用程序的初始起始体验。

我们现在开始开发 Feature 1:显示当前位置的当天天气信息,所以你可以将 Waffle 中的卡片移动到“进行中”列。

我们将添加一个标题作为h1标签,接着是我们应用的标语作为div,以及为显示当前天气的地方设置的占位符,如下面的代码块演示的那样:

src/app/app.component.html
<div style="text-align:center">
  <h1>
  LocalCast Weather
  </h1>
  <div>Your city, your forecast, right now!</div>
  <h2>Current Weather</h2>
  <div>current weather</div>
</div>

在这一点上,你应该运行npm start,然后在浏览器中导航到http://localhost:5000,这样你就可以实时观察到你所做的更改。

添加一个 Angular 组件

我们需要显示当前的天气信息,它位于<div>current weather</div>的位置。为了实现这一点,你需要构建一个负责显示天气数据的组件。

创建单独组件的原因是一个在模型-视图-ViewModelMVVM)设计模式中被规范化的架构最佳实践。你可能之前听说了模型-视图-控制器MVC)模式。大部分于 2005 年至 2015 年之间编写的基于 web 的代码都是按照 MVC 模式编写的。MVVM 与 MVC 模式在重要方面有所不同。正如我在 2013 年的 DevPro 文章中所解释的:

MVVM 的高效实现 自然强制实现了良好的关注点分离。业务逻辑与呈现逻辑清晰地分开。因此,当一个视图被开发时,它就会保持开发完成,因为修复一个视图功能中的错误不会影响其他视图。另一方面,如果您有效地使用可视化继承并创建可重用的用户控件,修复一个地方的错误可以解决整个应用程序中的问题。

Angular 提供了 MVVM 的有效实现。

ViewModels 精巧地封装了任何呈现逻辑,并充当模型的专门版本,通过分隔逻辑,使视图代码更简单。视图和 ViewModel 之间的关系很直接,允许将 UI 行为以更自然的方式封装在可重用的用户控件中。

您可以在bit.ly/MVVMvsMVC上阅读更多关于架构细微差别的内容,包含插图。

接下来,您将使用 Angular CLI 的 ng generate 命令创建您的第一个 Angular 组件,其中将包括视图和 ViewModel:

  1. 在终端中,执行 npx ng generate component current-weather

确保您在local-weather-app文件夹下执行 ng 命令,而不是在项目文件夹下。另外,注意 npx ng generate component current-weather 可以重写为 ng g c current-weather。今后,本书将使用简写格式,并期望您必要时在前面加上 npx

  1. 观察您的 app 文件夹中创建的新文件:
src/app
├── app.component.css
├── app.component.html
├── app.component.spec.ts
├── app.component.ts
├── app.module.ts
├── current-weather
  ├── current-weather.component.css
  ├── current-weather.component.html
  ├── current-weather.component.spec.ts
  └── current-weather.component.ts

一个生成的组件由四个部分组成:

  • current-weather.component.css 包含任何特定于组件的 CSS,是一个可选的文件

  • current-weather.component.html 包含了定义组件外观和绑定渲染的 HTML 模板,可以被视为与任何使用的 CSS 样式结合起来的视图

  • current-weather.component.spec.ts 包含了基于 Jasmine 的单元测试,您可以扩展以测试组件的功能

  • current-weather.component.ts 中包含了 @Component 装饰器,位于类定义的顶部,它是将 CSS、HTML 和 JavaScript 代码绑定在一起的粘合剂。这个类本身可以被视为 ViewModel,从服务中获取数据并执行必要的转换,以公开视图的合理绑定,如下所示:

src/app/current-weather/current-weather.component.ts
import { Component, OnInit } from '@angular/core'
@Component({
  selector: 'app-current-weather',
  templateUrl: './current-weather.component.html',
  styleUrls: ['./current-weather.component.css'],
})
export class CurrentWeatherComponent implements OnInit {
  constructor() {}

  ngOnInit() {}
}

如果你计划编写的组件很简单,可以使用内联样式和内联模板重写它,以简化代码的结构。

  1. 用内联模板和样式更新CurrentWeatherComponent
src/app/current-weather/current-weather.component.ts import { Component, OnInit } from '@angular/core'

@Component({
  selector: 'app-current-weather',
  template: `
  <p>
    current-weather works!
  </p>
  `,
  styles: ['']
})
export class CurrentWeatherComponent implements OnInit {
constructor() {}

ngOnInit() {}
}

当你执行生成命令时,除了创建组件外,命令还将新创建的模块添加到app.module.ts,避免了繁琐的组件连接任务:

src/app/app.module.ts ...
import { CurrentWeatherComponent } from './current-weather/current-weather.component'
...
@NgModule({
declarations: [AppComponent, CurrentWeatherComponent],
...

Angular 的引导过程,不可否认,有点复杂。这也是 Angular CLI 存在的主要原因。index.html包含一个名为<app-root>的元素。当 Angular 开始执行时,首先加载main.ts,它配置了用于浏览器的框架并加载应用程序模块。然后应用程序模块加载所有依赖项并在上述的<app-root>元素内呈现。在第十二章,创建一个路由优先的业务应用程序,当我们构建一个业务应用程序时,我们将创建自己的特性模块以利用 Angular 的可扩展性功能。

现在,我们需要在初始AppComponent模板上显示我们的新组件,以便最终用户看到:

  1. CurrentWeatherComponent添加到AppComponent中,用<app-current-weather></app-current-weather>替换<div>current weather</div>
src/app/app.component.html
<div style="text-align:center">
<h1>
 LocalCast Weather
 </h1>
 <div>Your city, your forecast, right now!</div>
 <h2>Current Weather</h2>
 <app-current-weather></app-current-weather>
</div>
  1. 如果一切正常工作,你应该看到这个:

本地天气应用程序的初始渲染

注意浏览器窗口标签中的图标和名称。作为 Web 开发的惯例,在index.html文件中,使用应用程序的名称和图标更新<title>标签和favicon.ico文件,以自定义浏览器标签信息。如果您的网站图标没有更新,请向href属性附加一个唯一版本号,例如href="favicon.ico?v=2"。因此,您的应用程序将开始看起来像一个真正的 Web 应用程序,而不是一个由 CLI 生成的初学者项目。

使用接口定义您的模型

现在,您的ViewViewModel就位了,您需要定义您的Model。如果回顾设计,您将看到组件需要显示:

  • 城市

  • 国家

  • 当前日期

  • 当前图片

  • 当前温度

  • 当前天气描述

首先创建一个表示这个数据结构的接口:

  1. 在终端执行npx ng generate interface ICurrentWeather

  2. 观察一个新生成的名为icurrent-weather.ts的文件,其中包含一个空接口定义,看起来像这样:

src/app/icurrent-weather.ts
export interface ICurrentWeather {
}

这不是一个理想的设置,因为我们可能会向我们的应用程序添加许多接口,跟踪各种接口可能会变得繁琐。随着时间的推移,当你将这些接口的具体实现作为类添加时,将有意义地将类和它们的接口放在自己的文件中。

为什么不直接将接口命名为CurrentWeather?因为稍后我们可能会创建一个类来实现CurrentWeather的一些有趣的行为。接口建立了一个契约,确定了任何实现或扩展接口的类或接口上可用属性的列表。始终意识到您正在使用类还是接口是非常重要的。如果您遵循始终以大写字母 I 开头命名接口的最佳实践,您将始终意识到您正在传递的对象的类型。因此,接口被命名为ICurrentWeather

  1. icurrent-weather.ts重命名为interfaces.ts

  2. 将接口名称的大写进行更正为ICurrentWeather

  3. 同样,按照以下方式实现接口:

src/app/interfaces.ts
export interface ICurrentWeather {
  city: string
  country: string
  date: Date
  image: string
  temperature: number
  description: string
}

这个接口及其最终的具体表示形式作为一个类是 MVVM 中的模型。到目前为止,我已经强调了 Angular 的各个部分如何符合 MVVM 模式;在接下来,我将用它们的实际名称来指代这些部分。

现在,我们可以将接口导入到组件中,并开始在CurrentWeatherComponent模板中连接绑定。

  1. 导入ICurrentWeather

  2. 切换回templateUrlstyleUrls

  3. 定义一个名为 current 的本地变量,类型为 ICurrentWeather

src/app/current-weather/current-weather.component.ts import { Component, OnInit } from '@angular/core'
import { ICurrentWeather } from '../interfaces'

@Component({
  selector: 'app-current-weather',
  templateUrl: './current-weather.component.html',
  styleUrls: ['./current-weather.component.css'],
})
export class CurrentWeatherComponent implements OnInit {
  current: ICurrentWeather

  constructor() {}

  ngOnInit() {}
}

如果您只键入current: ICurrentWeather,您可以使用自动修复程序自动插入导入语句。

在构造函数中,您将用虚拟数据临时填充当前属性以测试绑定。

  1. 以 JSON 对象的形式实现虚拟数据,并使用as运算符声明它遵循ICurrentWeather
src/app/current-weather/current-weather.component.ts
...
constructor() {
  this.current = {
    city: 'Bethesda',
    country: 'US',
    date: new Date(),
    image: 'assets/img/sunny.svg',
    temperature: 72,
    description: 'sunny',
  } as ICurrentWeather
}
...

src/assets文件夹中,创建一个名为img的子文件夹,并放置您选择的图像以在虚拟数据中引用。

您可能会忘记您创建的接口中的确切属性。通过Ctrl + 鼠标悬停在接口名称上,您可以快速查看它们,如下所示:

Ctrl + 鼠标悬停在接口

现在,您可以更新模板,将您的绑定与基本的基于 HTML 的布局进行连接。

  1. 实现模板:
src/app/current-weather/current-weather.component.html <div>
  <div>
    <span>{{current.city}}, {{current.country}}</span>
    <span>{{current.date | date:'fullDate'}}</span>
  </div>
  <div>
    <img [src]='current.image'>
    <span>{{current.temperature | number:'1.0-0'}}℉</span>
  </div>
  <div>
    {{current.description}}
  </div>
</div>

要更改 current.date 的显示格式,我们使用了上面的 DatePipe ,将'fullDate'作为格式选项传入。在 Angular 中,可以使用各种内置和自定义|操作符来更改数据的外观,而不改变实际的数据。这是一个非常强大、方便和灵活的系统,可以在不编写重复代码的情况下共享用户界面逻辑。在上面的示例中,如果我们想要以更紧凑的形式表示当前日期,我们可以传入'shortDate'。有关各种DatePipe选项的更多信息,请参阅angular.io/api/common/DatePipe的文档。要格式化current.temperature,以便不显示小数值,您可以使用DecimalPipe。文档在angular.io/api/common/DecimalPipe中。

请注意,您可以使用其各自的 HTML 代码来呈现℃和℉:  代表℃,  代表 ℉。

  1. 如果一切正常,您的应用应该看起来类似于该截图:

绑定到虚拟数据的 App

恭喜,您已成功连接了第一个组件。

使用 Angular 服务和 HttpClient 获取数据

现在您需要将您的CurrentWeather组件连接到OpenWeatherMap APIs。在接下来的章节中,我们将重点介绍以下步骤以实现这个目标:

  1. 创建一个新的 Angular 服务

  2. 导入 HttpClientModule 并将其注入服务中

  3. 发现OpenWeatherMap API

  4. 创建符合 API 结构的新接口

  5. 编写一个get请求

  6. 将新服务注入到CurrentWeather组件中

  7. CurrentWeather 组件的init函数中调用该服务

  8. 最后,使用 RxJS 函数将 API 数据映射到本地的ICurrentWeather类型,以便组件可以使用

创建一个新的 Angular 服务

任何超出组件边界的代码应存在于服务中;这包括组件间通信,除非存在父子关系,并且任何类型的 API 调用,以及缓存或从 cookie 或浏览器的 localStorage 中检索数据的任何代码。这是一个在长期内保持您的应用可维护性的重要架构模式。我在我的 DevPro MVVM 文章中详细介绍了这个想法,链接在bit.ly/MVVMvsMVC

要创建 Angular 服务,请执行以下操作:

  1. 在终端中,执行npx ng g s weather --flat false

  2. 观察新创建的weather文件夹:

src/app
...
└── weather
   ├── weather.service.spec.ts
   └── weather.service.ts

生成的服务有两个部分:

  • weather.service.spec.ts包含基于 Jasmine 的单元测试,您可以扩展以测试服务功能。

  • weather.service.ts中包含了类定义之前的@Injectable装饰器,这使得可以将该服务注入到其他组件中,利用 Angular 的提供者系统。这将确保我们的服务将是单例的,意味着无论它在其他地方被实例化多少次,它都只会被实例化一次。

服务已生成,但并未自动提供。要执行此操作,请按照以下步骤进行:

  1. 打开app.module.ts

  2. 在 providers 数组中输入WeatherService

  3. 使用自动修复程序为您导入类:

src/app/app.module.ts
...
import { WeatherService } from './weather/weather.service'
...
@NgModule({
  ...
  providers: [WeatherService],
  ...

如果您已安装了推荐的扩展 TypeScript Hero,则导入语句将自动为您添加。您无需使用自动修复程序来执行此操作。接下来,我将不再强调需要导入模块的需要。

注入依赖项

为了进行 API 调用,您将使用 Angular 中的HttpClient模块。官方文件(angular.io/guide/http)简洁地解释了这个模块的好处:

“通过 HttpClient,@angular/common/http 为 Angular 应用程序提供了一个简化的用于 HTTP 功能的 API,构建在浏览器暴露的 XMLHttpRequest 接口之上。HttpClient 的额外好处包括支持可测试性,强类型化的请求和响应对象,请求和响应拦截器支持以及基于可观察对象的更好的错误处理。”

让我们开始导入HttpClientModule到我们的应用程序中,以便我们可以在WeatherService中注入模块中的HttpClient

  1. app.module.ts中添加HttpClientModule,如下所示:
src/app/app.module.ts
...
import { HttpClientModule } from '@angular/common/http'
...
@NgModule({
  ...
  imports: [
    ...
    HttpClientModule,
    ...
  1. 注入由HttpClientModule提供的HttpClientWeatherService,如下所示:
src/app/weather/weather.service.ts
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'

@Injectable()
export class WeatherService {
  constructor(private httpClient: HttpClient) {}
}

现在,httpClient已经准备好在您的服务中使用。

探索 OpenWeatherMap API

由于httpClient是强类型的,因此我们需要创建一个符合我们将要调用的 API 形状的新接口。为了能够做到这一点,您需要熟悉当前天气数据 API。

  1. 通过导航到openweathermap.org/current阅读文档:

OpenWeatherMap 当前天气数据 API 文档

您将使用名为“按城市名称”的 API,它允许您通过提供城市名称作为参数来获取当前的天气数据。因此,您的网络请求将如下所示:

api.openweathermap.org/data/2.5/weather?q={city name},{country code}
  1. 在文档页面上,点击“API 调用示例”的链接,您将看到以下示例响应:
http://samples.openweathermap.org/data/2.5/weather?q=London,uk&appid=b1b15e88fa797225412429c1c50c122a1
{
  "coord": {
    "lon": -0.13,
    "lat": 51.51
  },
  "weather": [
    {
      "id": 300,
      "main": "Drizzle",
      "description": "light intensity drizzle",
      "icon": "09d"
    }
  ],
  "base": "stations",
  "main": {
    "temp": 280.32,
    "pressure": 1012,
    "humidity": 81,
    "temp_min": 279.15,
    "temp_max": 281.15
  },
  "visibility": 10000,
  "wind": {
    "speed": 4.1,
    "deg": 80
  },
  "clouds": {
    "all": 90
  },
  "dt": 1485789600,
  "sys": {
    "type": 1,
    "id": 5091,
    "message": 0.0103,
    "country": "GB",
    "sunrise": 1485762037,
    "sunset": 1485794875
  },
  "id": 2643743,
  "name": "London",
  "cod": 200
}

鉴于您已经创建的现有ICurrentWeather接口,此响应包含的信息比您所需的要多。因此,您将编写一个新的接口,符合此响应的形状,但只指定您将要使用的数据片段。这个接口将只存在于WeatherService中,我们不会导出它,因为应用程序的其他部分不需要知道这种类型。

  1. weather.service.ts中的import语句和@Injectable语句之间创建一个名为ICurrentWeatherData的新接口

  2. 新接口应该像这样:

src/app/weather/weather.service.ts
interface ICurrentWeatherData {
  weather: [{
    description: string,
    icon: string
  }],
  main: {
    temp: number
  },
  sys: {
    country: string
  },
  dt: number,
  name: string
}

通过ICurrentWeatherData接口,我们通过向接口添加具有不同结构的子对象来定义新的匿名类型。这些对象中的每一个都可以被单独提取出来并定义为它们自己的命名接口。特别要注意的是,weather将是一个具有descriptionicon属性的匿名类型数组。

存储环境变量

很容易被忽视的是,之前章节示例的 URL 包含一个必需的appid参数。你必须在你的 Angular 应用中存储这个键。你可以将它存储在天气服务中,但实际上,应用程序需要能够在从开发到测试、分段和生产环境的移动过程中针对不同的资源集。Angular 提供了两个环境:一个为prod,另一个为默认。

在继续之前,你需要注册一个免费的OpenWeatherMap账户并获取自己的appid。你可以阅读openweathermap.org/appid appid的文档以获取更详细的信息。

  1. 复制你的appid,它将有一长串字符和数字

  2. 将你的appid存储在environment.ts

  3. 为后续使用配置baseUrl

src/environments/environment.ts
export const environment = {
  production: false,
  appId: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
  baseUrl: 'http://',
}

在代码中,我们使用驼峰写法appId以保持我们的编码风格一致。由于 URL 参数是大小写不敏感的,appIdappid都可以使用。

实现一个 HTTP GET 操作

现在,我们可以在天气服务中实现 GET 调用:

  1. WeatherService类中添加一个名为getCurrentWeather的新函数

  2. 导入environment对象

  3. 实现httpClient.get函数

  4. 返回 HTTP 调用的结果:

src/app/weather/weather.service.ts
import { environment } from '../../environments/environment'
...
export class WeatherService {
  constructor(private httpClient: HttpClient) { }

  getCurrentWeather(city: string, country: string) {
    return this.httpClient.get<ICurrentWeatherData>(
        `${environment.baseUrl}api.openweathermap.org/data/2.5/weather?` +
          `q=${city},${country}&appid=${environment.appId}`
    )
  }
}

请注意使用 ES2015 的字符串插值功能。不必像environment.baseUrl + 'api.openweathermap.org/data/2.5/weather?q=' + city + ',' + country + '&appid=' + environment.appId那样将变量追加到一起来构建字符串,你可以使用反引号语法包裹你的字符串。在反引号内,你可以有换行,还可以直接使用${dollarbracket}语法将变量嵌入到字符串的流中。但是,在代码中引入换行时,它将被解释为字面换行—\n。为了在代码中分割字符串,你可以添加一个反斜杠\,但接下来的代码行不能有缩进。如前面的代码示例所示,将多个模板连接起来会更容易些。

CurrentWeather组件加载时,ngOnInit将在第一次触发时,这将调用getCurrentWeather函数,该函数返回一个包含ICurrentWeatherData类型对象的 Observable。Observable 是一种 RxJS 中最基本的事件监听器构建块,代表事件发射器,它将随着时间的推移接收任何数据类型为ICurrentWeatherData的数据。Observable 本身是无害的,除非它被监听。您可以在reactivex.io/rxjs/class/es6/Observable.js~Observable.html中阅读更多关于 Observables 的信息。

CurrentWeather组件加载时,ngOnInit将在第一次触发时,这将调用getCurrentWeather函数,该函数返回一个包含ICurrentWeatherData类型对象的 Observable。Observable 是一种 RxJS 中最基本的事件监听器构建块,代表事件发射器,它将随着时间的推移接收任何数据类型为ICurrentWeatherData的数据。Observable 本身是无害的,除非它被监听。您可以在reactivex.io/rxjs/class/es6/Observable.js~Observable.html中阅读更多关于 Observables 的信息。

CurrentWeather组件加载时,ngOnInit将在第一次触发时,这将调用getCurrentWeather函数,该函数返回一个包含ICurrentWeatherData类型对象的 Observable。Observable 是一种 RxJS 中最基本的事件监听器构建块,代表事件发射器,它将随着时间的推移接收任何数据类型为ICurrentWeatherData的数据。Observable 本身是无害的,除非它被监听。您可以在reactivex.io/rxjs/class/es6/Observable.js~Observable.html中阅读更多关于 Observables 的信息。

  1. CurrentWeather组件加载时,ngOnInit将在第一次触发时,这将调用getCurrentWeather函数,该函数返回一个包含ICurrentWeatherData类型对象的 Observable。Observable 是一种 RxJS 中最基本的事件监听器构建块,代表事件发射器,它将随着时间的推移接收任何数据类型为ICurrentWeatherData的数据。Observable 本身是无害的,除非它被监听。您可以在reactivex.io/rxjs/class/es6/Observable.js~Observable.html中阅读更多关于 Observables 的信息。

  2. CurrentWeather组件加载时,ngOnInit将在第一次触发时,这将调用getCurrentWeather函数,该函数返回一个包含ICurrentWeatherData类型对象的 Observable。Observable 是一种 RxJS 中最基本的事件监听器构建块,代表事件发射器,它将随着时间的推移接收任何数据类型为ICurrentWeatherData的数据。Observable 本身是无害的,除非它被监听。您可以在reactivex.io/rxjs/class/es6/Observable.js~Observable.html中阅读更多关于 Observables 的信息。

src/app/current-weather/current-weather.component.ts
constructor(private weatherService: WeatherService) { }
  1. CurrentWeather组件加载时,ngOnInit将在第一次触发时,这将调用getCurrentWeather函数,该函数返回一个包含ICurrentWeatherData类型对象的 Observable。Observable 是一种 RxJS 中最基本的事件监听器构建块,代表事件发射器,它将随着时间的推移接收任何数据类型为ICurrentWeatherData的数据。Observable 本身是无害的,除非它被监听。您可以在reactivex.io/rxjs/class/es6/Observable.js~Observable.html中阅读更多关于 Observables 的信息。
src/app/current-weather/current-weather.component.ts
ngOnInit() {
  this.weatherService.getCurrentWeather('Bethesda', 'US')
    .subscribe((data) => this.current = data)
}

CurrentWeather组件加载时,ngOnInit将在第一次触发时,这将调用getCurrentWeather函数,该函数返回一个包含ICurrentWeatherData类型对象的 Observable。Observable 是一种 RxJS 中最基本的事件监听器构建块,代表事件发射器,它将随着时间的推移接收任何数据类型为ICurrentWeatherData的数据。Observable 本身是无害的,除非它被监听。您可以在reactivex.io/rxjs/class/es6/Observable.js~Observable.html中阅读更多关于 Observables 的信息。

CurrentWeather组件加载时,ngOnInit将在第一次触发时,这将调用getCurrentWeather函数,该函数返回一个包含ICurrentWeatherData类型对象的 Observable。Observable 是一种 RxJS 中最基本的事件监听器构建块,代表事件发射器,它将随着时间的推移接收任何数据类型为ICurrentWeatherData的数据。Observable 本身是无害的,除非它被监听。您可以在reactivex.io/rxjs/class/es6/Observable.js~Observable.html中阅读更多关于 Observables 的信息。

CurrentWeather组件加载时,ngOnInit将在第一次触发时,这将调用getCurrentWeather函数,该函数返回一个包含ICurrentWeatherData类型对象的 Observable。Observable 是一种 RxJS 中最基本的事件监听器构建块,代表事件发射器,它将随着时间的推移接收任何数据类型为ICurrentWeatherData的数据。Observable 本身是无害的,除非它被监听。您可以在reactivex.io/rxjs/class/es6/Observable.js~Observable.html中阅读更多关于 Observables 的信息。

CurrentWeather组件加载时,ngOnInit将在第一次触发时,这将调用getCurrentWeather函数,该函数返回一个包含ICurrentWeatherData类型对象的 Observable。Observable 是一种 RxJS 中最基本的事件监听器构建块,代表事件发射器,它将随着时间的推移接收任何数据类型为ICurrentWeatherData的数据。Observable 本身是无害的,除非它被监听。您可以在reactivex.io/rxjs/class/es6/Observable.js~Observable.html中阅读更多关于 Observables 的信息。

CurrentWeather组件加载时,ngOnInit将在第一次触发时,这将调用getCurrentWeather函数,该函数返回一个包含ICurrentWeatherData类型对象的 Observable。Observable 是一种 RxJS 中最基本的事件监听器构建块,代表事件发射器,它将随着时间的推移接收任何数据类型为ICurrentWeatherData的数据。Observable 本身是无害的,除非它被监听。您可以在reactivex.io/rxjs/class/es6/Observable.js~Observable.html中阅读更多关于 Observables 的信息。

CurrentWeather组件加载时,ngOnInit将在第一次触发时,这将调用getCurrentWeather函数,该函数返回一个包含ICurrentWeatherData类型对象的 Observable。Observable 是一种 RxJS 中最基本的事件监听器构建块,代表事件发射器,它将随着时间的推移接收任何数据类型为ICurrentWeatherData的数据。Observable 本身是无害的,除非它被监听。您可以在reactivex.io/rxjs/class/es6/Observable.js~Observable.html中阅读更多关于 Observables 的信息。

CurrentWeather组件加载时,ngOnInit将在第一次触发时,这将调用getCurrentWeather函数,该函数返回一个包含ICurrentWeatherData类型对象的 Observable。Observable 是一种 RxJS 中最基本的事件监听器构建块,代表事件发射器,它将随着时间的推移接收任何数据类型为ICurrentWeatherData的数据。Observable 本身是无害的,除非它被监听。您可以在reactivex.io/rxjs/class/es6/Observable.js~Observable.html中阅读更多关于 Observables 的信息。

CurrentWeather组件加载时,ngOnInit将在第一次触发时,这将调用getCurrentWeather函数,该函数返回一个包含ICurrentWeatherData类型对象的 Observable。Observable 是一种 RxJS 中最基本的事件监听器构建块,代表事件发射器,它将随着时间的推移接收任何数据类型为ICurrentWeatherData的数据。Observable 本身是无害的,除非它被监听。您可以在reactivex.io/rxjs/class/es6/Observable.js~Observable.html中阅读更多关于 Observables 的信息。

CurrentWeather组件加载时,ngOnInit将在第一次触发时,这将调用getCurrentWeather函数,该函数返回一个包含ICurrentWeatherData类型对象的 Observable。Observable 是一种 RxJS 中最基本的事件监听器构建块,代表事件发射器,它将随着时间的推移接收任何数据类型为ICurrentWeatherData的数据。Observable 本身是无害的,除非它被监听。您可以在reactivex.io/rxjs/class/es6/Observable.js~Observable.html中阅读更多关于 Observables 的信息。

CurrentWeather组件加载时,ngOnInit将在第一次触发时,这将调用getCurrentWeather函数,该函数返回一个包含ICurrentWeatherData类型对象的 Observable。Observable 是一种 RxJS 中最基本的事件监听器构建块,代表事件发射器,它将随着时间的推移接收任何数据类型为ICurrentWeatherData的数据。Observable 本身是无害的,除非它被监听。您可以在reactivex.io/rxjs/class/es6/Observable.js~Observable.html中阅读更多关于 Observables 的信息。

CurrentWeather组件加载时,ngOnInit将在第一次触发时,这将调用getCurrentWeather函数,该函数返回一个包含ICurrentWeatherData类型对象的 Observable。Observable 是一种 RxJS 中最基本的事件监听器构建块,代表事件发射器,它将随着时间的推移接收任何数据类型为ICurrentWeatherData的数据。Observable 本身是无害的,除非它被监听。您可以在reactivex.io/rxjs/class/es6/Observable.js~Observable.html中阅读更多关于 Observables 的信息。

CurrentWeather组件加载时,ngOnInit将在第一次触发时,这将调用getCurrentWeather函数,该函数返回一个包含ICurrentWeatherData类型对象的 Observable。Observable 是一种 RxJS 中最基本的事件监听器构建块,代表事件发射器,它将随着时间的推移接收任何数据类型为ICurrentWeatherData的数据。Observable 本身是无害的,除非它被监听。您可以在reactivex.io/rxjs/class/es6/Observable.js~Observable.html中阅读更多关于 Observables 的信息。

通过在 Observable 上调用 .subscribe,从本质上说,你将侦听器附加到发射器上。在 subscribe 方法中实现了一个匿名函数,每当接收到新的数据并发出事件时,该函数都将被执行。匿名函数以数据对象作为参数,并且在本例中的具体实现中,将数据块分配给了名为 current 的本地变量。每当 current 被更新时,你之前实现的模板绑定将拉取新数据并在视图上渲染。即使 ngOnInit 只执行一次,对 Observable 的订阅仍然持续。因此,每当有新数据时,当前变量将被更新,并且视图将重新渲染以显示最新数据。

目前错误的根本原因是正在传送的数据属于 ICurrentWeatherData 类型,但是,我们的组件只能理解由 ICurrentWeather 接口描述的形式的数据。在下一部分,你需要更深入地了解 RxJS,以便最好地完成这项任务。

注意,VS Code 和 CLI 有时会停止工作。如前所述,在编写代码时,npm start 命令正在 VS Code 的集成终端中运行。Angular CLI 与 Angular 语言服务插件一起,不断地监视代码更改,将你的 TypeScript 代码转译成 JavaScript,这样你就能在浏览器中实时查看你的更改。最棒的是,当你出现编码错误时,除了在 VS Code 中的红色下划线外,在终端或者浏览器中也会看到一些红色文字,因为转译失败了。在大多数情况下,在纠正错误后,红色下划线会消失,Angular CLI 会自动重新转译你的代码,一切都会正常工作。然而,在某些情况下,你会发现 VS Code 未能在 IDE 中捕捉到输入更改,所以你将得不到自动补全帮助或者 CLI 工具会卡在消息“webpack:编译失败”上。

你有两种主要策略来从这种情况中恢复:

  1. 点击终端,然后按下 Ctrl + C 停止运行 CLI 任务,并通过执行 npm start 重新启动

  2. 如果 #1 不起作用,用 Alt + F4(Windows)或 ⌘ + Q(macOS)退出 VS Code,然后重新启动它

鉴于 Angular 和 VS Code 每月的发布周期,我相信工具只会不断改进。

使用 RxJS 转换数据

RxJS 代表着响应式扩展,这是一个模块化的库,能够实现响应式编程,它本身是一种异步编程范式,并允许通过转换、过滤和控制函数来操纵数据流。你可以将响应式编程看作是事件驱动编程的一种进化。

理解响应式编程

在事件驱动编程中,您将定义一个事件处理程序并将其附加到事件源。更具体地说,如果您有一个保存按钮,该按钮公开onClick事件,您将实现一个confirmSave函数,当触发时,会显示一个弹出窗口询问用户“您确定吗?”。查看以下图示可可视化此过程。

事件驱动实现

简而言之,您将有一个事件在每次用户操作时触发。如果用户多次点击保存按钮,此模式将乐意呈现与点击次数相同的弹出窗口,这并没有太多意义。

发布-订阅(pub/sub)模式是一种不同类型的事件驱动编程。在这种情况下,我们可以编写多个处理程序来同时对给定事件的结果进行操作。假设您的应用刚刚收到了一些更新的数据。发布者将遍历其订阅者列表,并将更新的数据传递给每个订阅者。参考以下图表,更新的数据事件如何触发updateCache函数,该函数可以使用新数据更新您的本地缓存,fetchDetails函数可以从服务器检索有关数据的更多详细信息,并且showToastMessage函数可以通知用户应用程序刚刚收到了新数据。所有这些事件都可以异步发生;但是,fetchDetailsshowToastMessage函数将收到比他们实际需要的更多数据,尝试以不同方式组合这些事件以修改应用程序行为可能会变得非常复杂。

发布-订阅模式实现

在响应式编程中,一切都被视为流。一个流将包含随时间发生的事件,这些事件可以包含一些数据或没有数据。下图可视化了一个场景,您的应用正在监听用户的鼠标点击。不受控的用户点击流是毫无意义的。通过将throttle函数应用于它,您可以对此流施加一些控制,以便每 250 毫秒ms)仅获得更新。如果订阅此新事件,则每 250 毫秒,您将收到一系列点击事件。您可以尝试从每次点击事件中提取一些数据,但在这种情况下,您只对发生的点击事件数量感兴趣。我们可以使用map函数将原始事件数据转化为点击次数。

在下游,我们可能只对带有两个或多个点击的事件感兴趣,所以我们可以使用 filter 函数只对本质上是双击事件的事件采取行动。每当我们的过滤器事件触发时,这意味着用户打算双击,你可以根据这个信息弹出一个警告。流的真正力量在于,你可以选择在它通过各种控制、转换和过滤函数时的任何时候采取行动。你可以选择使用 *ngFor 和 Angular 的 async 管道在 HTML 列表上显示点击数据,这样用户就可以监视每 250 毫秒捕获的点击数据类型。

一个响应式数据流实现

实现响应式转换

为了避免将来从服务中返回意外类型的数据的错误,你需要更新 getCurrentWeather 函数,将返回类型定义为 Observable<ICurrentWeather>,并导入 Observable 类型,如下所示:

src/app/weather/weather.service.ts
import { Observable } from 'rxjs'
import { ICurrentWeather } from '../interfaces'
...

export class WeatherService {
  ...
  getCurrentWeather(city: string, country: string): Observable<ICurrentWeather> {
  }
  ...
}

现在,VS Code 会告诉你,类型 Observable<ICurrentWeatherData> 不可分配给类型 Observable<ICurrentWeather>:

  1. 编写一个名为 transformToICurrentWeather 的转换函数,可以将 ICurrentWeatherData 转换为 ICurrentWeather

  2. 此外,编写一个名为 convertKelvinToFahrenheit 的助手函数,将 API 提供的开尔文温度转换为华氏度:

src/app/weather/weather.service.ts export class WeatherService {...
  private transformToICurrentWeather(data: ICurrentWeatherData): ICurrentWeather {
    return {
      city: data.name,
      country: data.sys.country,
      date: data.dt * 1000,
      image: `http://openweathermap.org/img/w/${data.weather[0].icon}.png`,
      temperature: this.convertKelvinToFahrenheit(data.main.temp),
      description: data.weather[0].description
    }
  }

  private convertKelvinToFahrenheit(kelvin: number): number {
    return kelvin * 9 / 5 - 459.67
  }
}

请注意,你需要在此阶段将图标属性转换为图像 URL。在服务中执行此操作有助于保持封装,在视图模板中绑定图标值到 URL 会违反关注点分离 (SoC) 原则。如果你希望创建真正模块化、可重用和可维护的组件,你必须保持警惕并严格执行 SoC。有关天气图标的文档以及如何形成 URL 的详细信息,包括所有可用的图标,可以在 openweathermap.org/weather-conditions 找到。

另一方面,可以论证说,开尔文到华氏温度的转换实际上是一个视图关注点,但我们在服务中实现了它。这个论点是有道理的,特别是考虑到我们计划有一个功能可以在摄氏度和华氏度之间切换。反对的论点是,目前我们只需要以华氏度显示,这是天气服务的一部分,能够转换单位。这个论点也很有道理。最终的实现将是编写一个自定义的 Angular Pipe,并在模板中应用它。一个管道也可以很容易地与计划的切换按钮绑定。但是,现在我们只需要以华氏度显示,我会倾向于过度设计一个解决方案。

  1. ICurrentWeather.date 更新为 number 类型

在编写转换函数时,你会注意到 API 返回的日期是一个数字。这个数字代表自 UNIX 纪元(时间戳)以来的秒数,即 1970 年 1 月 1 日 00:00:00 UTC。然而,ICurrentWeather期望一个Date对象。通过将时间戳传递给Date对象的构造函数new Date(data.dt)来转换时间戳非常简单。这没有问题,但也是没必要的,因为 Angular 的DatePipe可以直接使用时间戳。在追求简单和最大程度利用我们使用的框架功能的名义上,我们将更新ICurrentWeather以使用number。如果你正在转换大量数据,这种方法还有性能和内存方面的好处,但这个问题在这里并不适用。这里有一个注意事项—JavaScript 的时间戳是以毫秒为单位的,但服务器的值是以秒为单位的,因此在转换过程中仍然需要简单的乘法运算。

  1. 在其他导入语句的下面导入 RxJS 的map操作符:
src/app/weather/weather.service.ts
import { map } from 'rxjs/operators'

手动导入 map 操作符可能看起来很奇怪。RxJS 是一个功能强大的框架,具有广泛的 API 表面。单独的 Observable 就有超过 200 个附加方法。默认包含所有这些方法会在开发时创建太多的功能选择问题,同时也会对最终交付的大小、应用程序性能和内存使用产生负面影响。因此,你必须单独添加要使用的每个操作符。

  1. httpClient.get方法返回的数据流上应用map函数通过一个pipe

  2. data对象传递给transformToICurrentWeather函数:

src/app/weather/weather.service.ts
...
return this.httpClient
  .get<ICurrentWeatherData>(
    `http://api.openweathermap.org/data/2.5/weather?q=${city},${country}&appid=${environment.appId}`
  ).pipe(
    map(data => 
      this.transformToICurrentWeather(data)
    )
  )
...

现在,当数据进入时,可以在数据流中对其进行转换,确保OpenWeatherMap的当前天气 API 数据具有正确的格式,这样可以被CurrentWeather组件消费。

  1. 确保你的应用程序成功编译

  2. 在浏览器中检查结果:

显示来自 OpenWeatherMap 的实时数据

最后,你应该看到你的应用程序能够从OpenWeatherMap中获取实时数据,并正确地将服务器数据转换为你期望的格式。

你已经完成了 Feature 1 的开发:显示当前位置的当天天气信息。提交你的代码并将卡片移到 Waffle 的“已完成”列。

  1. 最后,我们可以将这个任务移到完成列:

Waffle.io 看板状态

总结

恭喜,在这一章中,你创建了你的第一个具有灵活架构的 Angular 应用程序,同时避免了过度设计。这是可能的,因为我们首先建立了一个路线图,并将其编码在一个可见于你的同行和同事的看板中。我们专注于实施我们放在进行中的第一个功能,没有偏离计划。

您现在可以使用 Angular CLI 和优化的 VS Code 开发环境来帮助您减少编码量。您可以利用 TypeScript 匿名类型和可观察流来准确地将复杂的 API 数据重塑为简单的格式,而无需创建一次性接口。

通过主动声明函数的输入和返回类型,并使用通用函数来避免编码错误。您使用了日期和十进制管道来确保数据按预期格式化,同时将与格式相关的问题大部分留在模板中,因为这种逻辑属于模板的范围。

最后,您使用接口在组件和服务之间进行通信,而不会将外部数据结构泄露给内部组件。通过结合应用 Angular、RxJS 和 TypeScript 允许我们执行的所有这些技术,您已确保了关注点的正确分离和封装。因此,CurrentWeather组件现在是一个真正可重用和可组合的组件;这不是一件容易的事情。

如果你不发布它,它就永远不会发生。在下一章中,我们将通过解决应用程序错误和使用 Docker 对 Angular 应用程序进行容器化,为其生产发布做准备,以便可以在 web 上发布。

第十章:准备 Angular 应用程序进行生产发布

如果你没有上线它,那就好像它从来没有发生过。在前一章中,你创建了一个可以检索当前天气数据的本地天气应用程序。你已经创造了一定的价值;然而,如果你不将你的应用程序上线,最终你将得不到任何价值。交付某物很困难,将其投入生产甚至更加困难。你希望遵循一个能够产生可靠、高质量和灵活发布的策略。

我们在第九章中创建的应用程序,创建本地天气 Web 应用程序,比较脆弱。我们需要能够单独交付前端应用程序,而不必与后端应用程序一起处理,这是保持灵活性的重要解耦,以便能够推送独立的应用程序和服务器更新。此外,解耦将确保当应用程序堆栈中的各种工具和技术不可避免地不受支持或不受欢迎时,您将能够替换前端或后端,而无需全面重写系统。

在这一章中,你将学习以下内容:

  • 防范空数据

  • 使用 Docker 容器化应用程序

  • 使用 Zeit Now 将应用程序上线到 Web 上

所需软件列举如下:

  • Docker 社区版版本 17.12

  • Zeit Now 账户

在 Angular 中进行空值保护

在 JavaScript 中,undefinednull 值是必须主动处理的持久问题。在 Angular 中,有多种方法可以防范 null 值:

  1. 属性初始化

  2. 安全导航操作符 ?.

  3. 使用 *ngIf 进行空值保护

属性初始化

在诸如 Java 等静态类型语言中,正确的变量初始化/实例化是无误操作的关键。因此,让我们在 CurrentWeatherComponent 中尝试通过使用默认值来初始化当前值:

src/app/current-weather/current-weather.component.ts
constructor(private weatherService: WeatherService) {
  this.current = {
    city: '',
    country: '',
    date: 0,
    image: '',
    temperature: 0,
    description: '',
  }
}

这些更改的结果将将控制台错误从 12 个减少到 3 个,此时您将只看到 API 调用相关的错误。但是,应用程序本身不会处于可展示状态,如下所示:

属性初始化的结果

要使此视图对用户呈现,我们必须对模板上的每个属性编写默认值的代码。因此,通过初始化解决了空保护问题,我们创建了一个默认值处理问题。对于开发人员来说,初始化和默认值处理都是 O(n) 规模的任务。在最好的情况下,这种策略将令人厌烦,而在最坏的情况下,效果极差且容易出错,最低要求每个属性的工作量达到 O(2n)

安全导航操作符

Angular 实现了安全导航操作 ?. 以防止意外遍历未定义的对象。因此,我们不需要撰写初始化代码并处理模板数值,而是只需更新模板:

src/app/current-weather/current-weather.component.html
<div>
  <div>
    <span>{{current?.city}}, {{current?.country}}</span>
    <span>{{current?.date | date:'fullDate'}}</span>
  </div>
  <div>
    <img [src]='current?.image'>
    <span>{{current?.temperature}}℉</span>
  </div>
  <div>
    {{current?.description}}
  </div>
</div>

这一次,我们不必设置默认值,让 Angular 处理显示未定义的绑定。你会注意到,就像初始化修复一样,错误从 12 个减少到 3 个。应用本身的状态也稍微好了一些。不再显示混乱的数据;但现在还不是一个可以展示的状态,如下所示:

安全导航操作符的结果

你可能能想象出在更复杂的情况下安全导航操作符可以派上用场。然而,当规模化部署时,这种类型的编码仍然需要至少*O(n)*级别的工作量来实现。

使用*ngIf 进行 null 值保护。

理想策略是使用*ngIf,这是一个结构性指令,意味着 Angular 会在假语句之后停止遍历 DOM 树元素。

CurrentWeather组件中,我们可以在尝试渲染模板之前轻松地检查current变量是否为 null 或 undefined:

  1. 更新顶层的div元素,用*ngIf来检查current是否为对象,如下所示:
src/app/current-weather/current-weather.component.html <div *ngIf="current">
  ...
</div>

现在观察控制台日志,没有错误报告。你必须确保你的 Angular 应用程序不会报告任何控制台错误。如果您仍然在控制台日志中看到错误,请确保已经正确恢复了OpenWeather网址到其正确的状态,或者关闭并重新启动npm start进程。我强烈建议你在继续之前解决任何控制台错误。一旦您解决了所有错误,请确保再次提交您的代码。

  1. 提交你的代码。

使用 Docker 容器化应用程序

Docker docker.io 是一个用于开发、部署和运行应用程序的开放平台。Docker 结合了轻量级的容器虚拟化平台和用于管理和部署应用程序的工作流程和工具。虚拟机VMs)和 Docker 容器之间最明显的区别在于,VMs 通常占用数十 GB 的空间并且需要 GB 级别的内存,而容器仅需要 MB 级别的磁盘和内存空间。此外,Docker 平台将主机操作系统OS)级别的配置设置抽象掉,所以成功运行应用程序所需的每个配置设置都被编码在易读的 Dockerfile 格式中,如下所示:

Dockerfile
FROM duluca/minimal-node-web-server:8.11.1
WORKDIR /usr/src/app
COPY dist public

前面的文件描述了一个继承自名为duluca/minimal-node-web-server的容器的新容器,将工作目录更改为/usr/src/app,然后将开发环境中dist文件夹的内容复制到容器的public文件夹中。在这种情况下,父镜像配置了一个 Express.js 服务器,充当 Web 服务器以提供public文件夹中的内容。

参考下图,以可视化表示正在发生的事情:

Docker 镜像的上下文

在基础层是我们的宿主操作系统,比如 Windows 或 macOS,运行 Docker 运行时,这将在下一节中安装。Docker 运行时能够运行自包含的 Docker 镜像,这是由上述的Dockerfile定义的。duluca/minimal-node-web-server基于轻量级的 Linux 操作系统 Alpine。Alpine 是 Linux 的一个完全简化版本,没有任何图形界面、驱动程序,甚至大部分你可能期望从 Linux 系统中获得的 CLI 工具。因此,这个操作系统的大小只有约 5MB。基础包然后安装了 Node.js,这本身的大小约为 10MB,以及我的自定义基于 Node.js 的 Express.js Web 服务器,最终会产生一个小巧的约 15MB 的镜像。Express 服务器被配置为提供/usr/src/app文件夹的内容。在前面的Dockerfile中,我们只需将我们开发环境中/dist文件夹的内容复制并放入/usr/src/app文件夹中。我们将稍后构建和执行这个镜像,这将运行我们的 Express Web 服务器,其中包含了我们dist文件夹的输出。

Docker 的美妙之处在于,你可以访问hub.docker.com,搜索duluca/minimal-node-web-server,阅读它的Dockerfile,并追溯其起源一直到构成 web 服务器基础的原始基础镜像。我鼓励你以这种方式审核你使用的每个 Docker 镜像,以了解它究竟为你的需求带来了什么。你可能会发现它要么过度复杂,要么具有你之前不知道的功能,可以让你的生活变得更加容易。请注意,父镜像需要特定版本的duluca/minimal-node-web-server,在8.11.1处。这是完全有意的,作为读者,你应该选择你发现的 Docker 镜像的最新可用版本。然而,如果你不指定版本号,你将总是得到镜像的最新版本。随着镜像的发布更多版本,你可能拉取将来会破坏你的应用程序的某个版本。因此,始终为你依赖的镜像指定版本号。

其中一个例子就是内置了 HTTPS 重定向支持的duluca/minimal-node-web-server。你可能会花费无数小时尝试设置一个 Nginx 代理来完成同样的事情,而你只需要在你的 Dockerfile 中添加以下行即可:

ENV ENFORCE_HTTPS=xProto

就像 npm 包一样,Docker 可以带来极大的便利和价值,但你必须小心,了解你正在使用的工具。

在第十六章中,AWS 上的高可用云基础设施,我们提到了基于 Nginx 的低占用资源的 docker 镜像的使用。如果你熟悉配置nginx,你可以以duluca/minimal-nginx-web-server作为你的基础镜像。

安装 Docker

为了能够构建和运行容器,你必须首先在你的计算机上安装 Docker 执行环境。

Docker 在 Windows 上的支持可能会有挑战。您必须拥有支持虚拟化扩展的 CPU 的 PC,这在笔记本电脑上并非一定能保证。您还必须拥有启用了 Hyper-V 的 Pro 版 Windows。另一方面,Windows Server 2016 对 Docker 有原生支持,这是微软对业界采用 Docker 和容器化所展现的空前支持。

  1. 通过执行以下命令安装 Docker:

对于 Windows:

PS> choco install docker docker-for-windows -y

对于 macOS:

$ brew install docker
  1. 执行 docker -v 来验证安装。

设置 Docker 脚本

现在,让我们配置一些 Docker 脚本,您可以用来自动构建、测试和发布您的容器。我开发了一套名为npm Scripts for Docker 的脚本,适用于 Windows 10 和 macOS。您可以在 bit.ly/npmScriptsForDocker 获取这些脚本的最新版本:

  1. 在 hub.docker.com/ 上注册一个 Docker Hub 帐户

  2. 为您的应用程序创建一个公共(免费)仓库

不幸的是,在出版时,Zeit 不支持私有 Docker Hub 仓库,因此您的唯一选择是公开发布您的容器。如果您的图像必须保持私有,我建议您按照《第十六章》《AWS 上的高可用云基础设施》中描述的方式设置 AWS ECS 环境。您可以通过访问 Zeit Now 的文档 zeit.co/docs/deployment-types/docker 了解问题的情况。

  1. 更新 package.json 以添加一个新的配置属性,具有以下配置属性:
package.json
  ...
  "config": {
    "imageRepo": "[namespace]/[repository]",
    "imageName": "custom_app_name",
    "imagePort": "0000"
  },
 ...

命名空间将是您的 DockerHub 用户名。您在创建过程中将定义您的仓库名称。例如,一个示例图像仓库变量应如下所示 duluca/localcast-weather。图像名称用于轻松识别您的容器,同时使用类似于 docker ps 的 Docker 命令。我将自己的命名为 localcast-weather。端口将定义从容器内部公开您的应用程序应使用的端口。因为我们在开发中使用 5000,请选择一个不同的端口,比如 8080

  1. 通过复制粘贴从 bit.ly/npmScriptsForDocker 中获取的脚本,向 package.json 中添加 Docker 脚本。以下是脚本的注释版本,解释了每个函数:

注意,使用 npm 脚本时,pre 和 post 关键词用于在给定脚本的执行之前或之后,分别执行辅助脚本。脚本故意被分解为更小的部分,以使其更易于阅读和维护:

package.json
...
  "scripts": {
    ...
    "predocker:build": "npm run build",
    "docker:build": "cross-conf-env docker image build . -t $npm_package_config_imageRepo:$npm_package_version",
    "postdocker:build": "npm run docker:tag",
    ...

运行 npm run docker:build 将在 pre 中构建您的 Angular 应用程序,然后使用 docker image build 命令构建 Docker 镜像,并在 post 中为镜像打上版本号:

package.json
    ...
    "docker:tag": " cross-conf-env docker image tag $npm_package_config_imageRepo:$npm_package_version $npm_package_config_imageRepo:latest",
    ...

npm run docker:tag将使用package.json中的version属性的版本号和latest标签对已构建的 Docker 镜像进行标记:

package.json
    ...
    "docker:run": "run-s -c docker:clean docker:runHelper",
    "docker:runHelper": "cross-conf-env docker run -e NODE_ENV=local --name $npm_package_config_imageName -d -p $npm_package_config_imagePort:3000 $npm_package_config_imageRepo",
    ...

npm run docker:run将删除先前版本的镜像,并使用docker run命令运行已构建的镜像。请注意,imagePort属性将作为 Docker 镜像的外部端口,映射到 Node.js 服务器监听的镜像的内部端口3000

package.json
    ...
    "predocker:publish": "echo Attention! Ensure `docker login` is correct.",
    "docker:publish": "cross-conf-env docker image push $npm_package_config_imageRepo:$npm_package_version",
    "postdocker:publish": "cross-conf-env docker image push $npm_package_config_imageRepo:latest",
    ...

npm run docker:publish将发布构建的镜像到配置的存储库,这种情况下是 Docker Hub,使用docker image push命令。首先发布带版本号的镜像,然后发布标记为latest的镜像。

package.json
    ...
    "docker:clean": "cross-conf-env docker rm -f $npm_package_config_imageName",
    ...

npm run docker:clean将从您的系统中删除先前构建的镜像版本,使用docker rm -f命令:

package.json
    ...
    "docker:taillogs": "cross-conf-env docker logs -f $npm_package_config_imageName",
    ...

npm run docker:taillogs会使用docker log -f命令显示运行中 Docker 实例的内部控制台日志,这是调试 Docker 实例时非常有用的工具:

package.json
    ...
    "docker:open:win": "echo Trying to launch on Windows && timeout 2 && start http://localhost:%npm_package_config_imagePort%",
    "docker:open:mac": "echo Trying to launch on MacOS && sleep 2 && URL=http://localhost:$npm_package_config_imagePort && open $URL",
    ...

npm run docker:open:winnpm run docker:open:mac将等待 2 秒,然后使用imagePort属性以正确的 URL 启动浏览器访问您的应用程序:

package.json
    ...
    "predocker:debug": "run-s docker:build docker:run",
    "docker:debug": "run-s -cs docker:open:win docker:open:mac docker:taillogs"
  },
...

npm run docker:debug将构建您的镜像,并在pre中运行它的一个实例,打开浏览器,然后开始显示容器的内部日志。

  1. 安装两个开发依赖项,以确保脚本的跨平台功能:
$ npm i -D cross-conf-env npm-run-all
  1. 在构建镜像之前,自定义预构建脚本以执行单元测试和 e2e 测试:
package.json
"predocker:build": "npm run build -- --prod --output-path dist && npm test -- --watch=false && npm run e2e",

请注意,npm run build --prod提供了--prod参数,实现了两件事情:

  1. 开发时间的 2.5 MB 负载被优化到~73kb 或更少

  2. 在运行时使用src/environments/environment.prod.ts中定义的配置项

  3. 更新src/environments/environment.prod.ts以使用来自OpenWeather的自己的appId

export const environment = {
  production: true,
  appId: '01ffxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
  baseUrl: 'https://',
}

我们正在修改npm test的执行方式,以便测试只运行一次,工具停止执行。提供--watch=false选项以实现这种行为,与默认的开发友好的持续执行行为相反。此外,提供了npm run build --output-path dist,以确保index.html发布在文件夹的根目录。

  1. 创建一个名为Dockerfile的新文件,没有任何文件扩展名

  2. 实现如下Dockerfile

Dockerfile
FROM duluca/minimal-node-web-server:8.11.1
WORKDIR /usr/src/app
COPY dist public

确保检查dist文件夹的内容。确保index.html位于dist的根目录。否则,请确保您的Dockerfile将包含具有index.html的文件夹复制到其根目录。

  1. 执行npm run predocker:build以确保您的应用程序更改已成功

  2. 执行npm run docker:build以确保您的镜像成功构建

虽然您可以单独运行提供的任何脚本,但实际上只需要记住其中两个:

  • npm run docker:debug会测试,构建,标记,运行,在新的浏览器窗口中为测试启动你的容器化应用程序

  • npm run docker:publish将会把你刚才构建并测试的镜像发布到在线 Docker 仓库

  1. 在你的终端中执行docker:debug:
$ npm run docker:debug

你会注意到脚本在终端窗口显示错误。这些不一定是失败的指标。脚本还不够完善,因此它们同时尝试 Windows 和 macOS 兼容的脚本,并且在第一次构建时,清理命令会失败,因为没有需要清理的东西。等你看到这段话的时候,我可能已经发布了更好的脚本;如果没有,你可以随时提交合并请求。

成功的docker:debug运行会在焦点浏览器窗口中显示你的应用程序,并在终端中显示服务器日志,如下所示:

Current Environment: local.
Server listening on port 3000 inside the container
Attenion: To access server, use http://localhost:EXTERNAL_PORT
EXTERNAL_PORT is specified with 'docker run -p EXTERNAL_PORT:3000'. See 'package.json->imagePort' for th
e default port.
GET / 304 12.402 ms - -
GET /styles.d41d8cd98f00b204e980.bundle.css 304 1.280 ms - -
GET /inline.202587da3544bd761c81.bundle.js 304 11.117 ms - -
GET /polyfills.67d068662b88f84493d2.bundle.js 304 9.269 ms - -
GET /vendor.c0dc0caeb147ad273979.bundle.js 304 2.588 ms - -
GET /main.9e7f6c5fdb72bb69bb94.bundle.js 304 3.712 ms - -

你应该经常运行docker ps来检查你的镜像是否在运行,上次更新时间,或者它是否与现有镜像发生端口冲突。

  1. 在你的终端中执行 docker:publish :
$ npm run docker:publish

你应该在终端窗口中看到类似这样的成功运行信息:

The push refers to a repository [docker.io/duluca/localcast-weather]
60f66aaaaa50: Pushed
...
latest: digest: sha256:b680970d76769cf12cc48f37391d8a542fe226b66d9a6f8a7ac81ad77be4f58b size: 2827

随着时间的推移,你本地的 Docker 缓存可能会增长到相当大的规模,在我笔记本上大约是两年时间内增长了大约 40 GB。你可以使用 docker image prune 和 docker container prune 命令来减小缓存的大小。更详细的信息,请参考docs.docker.com/config/pruning的文档。

接下来让我们看一种更简单的与 Docker 进行交互的方式。

VS Code 中的 Docker 扩展

与 Docker 镜像和容器进行交互的另一种方式是通过 VS Code。如果你按照第九章*,创建本地天气 Web 应用程序*中建议的安装了PeterJausovec.vscode-docker Docker 扩展,你会在 VS Code 的资源管理器窗格中看到一个名为 DOCKER 的可展开标题,如下截图所示所指出的部分:

VS Code 中的 Docker 扩展

让我们来看看该扩展提供的一些功能:

  1. Images包含系统上存在的所有容器快照的列表

  2. 在 Docker 镜像上右键单击会弹出上下文菜单,以运行各种操作,比如 run,push 和 tag

  3. Containers列出了系统上所有存在的可执行 Docker 容器,你可以启动、停止或连接到它们

  4. Registries显示你配置的连接到的注册表,比如 DockerHub 或 AWS Elastic Container Registry

虽然该扩展使与 Docker 进行交互更容易,但用于 Docker 的 npm 脚本可以自动化许多与构建、标记和测试镜像相关的琐事。它们是跨平台的,而且在持续集成环境中同样有效。

通过 CLI 与 npm 脚本进行交互可能会让你感到困惑。接下来让我们看一下 VS Code 的 npm 脚本支持。

VS Code 中的 NPM 脚本

VS Code 提供了对 npm 脚本的支持。 为了启用 npm 脚本资源管理器,打开 VS Code 设置,并确保存在 "npm.enableScriptExplorer": true 属性。 一旦你这样做,你将在资源管理器窗格中看到一个可扩展的名称为 NPM SCRIPTS 的标题,如下箭头所示:

VS Code 中的 NPM 脚本

您可以单击任何脚本来启动包含脚本的 package.json 文件中的行,或者右键单击并选择运行以执行脚本。

部署容器化应用

如果从编码角度交付产品很困难,那么从基础设施角度来看,做到正确更是极其困难。 在后面的章节中,我将讨论如何为您的应用程序提供世界一流的 AWS 弹性容器服务ECS)基础设施,但如果您需要快速展示一个想法,这是没有帮助的。 这就是 Zeit Now 的作用。

Zeit Now

Zeit Now,zeit.co/now,是一个多云服务,可以直接从 CLI 实现应用程序的实时全球部署。 Now 适用于正确实现 package.jsonDockerfile 的应用程序。 即使我们两者都做了,我们仍然更喜欢部署我们的 Docker image,因为在幕后会应用更多的魔法来使 package.json 的部署工作,而您的 Docker image 可以部署到任何地方,包括 AWS ECS。

配置 Now CLI 工具

现在,让我们配置 Zeit Now 在您的存储库上运行:

  1. 通过执行npm i -g now安装 Zeit Now

  2. 通过执行 now -v 确保正确安装

  3. local-weather-app 下创建一个名为 now 的新文件夹

  4. 在新的 now 文件夹下创建一个新的 Dockerfile

  5. 实现从您刚刚发布的镜像中提取文件:

now/Dockerfile
FROM duluca/localcast-weather:6.0.1
  1. 最后,在您的终端中执行 now 命令并按照说明完成配置:
$ now
> No existing credentials found. Please log in:
> We sent an email to xxxxxxxx@gmail.com. Please follow the steps provided
 inside it and make sure the security code matches XXX XXXXX.
√ Email confirmed
√ Fetched your personal details
> Ready! Authentication token and personal details saved in "~\.now"

部署

在 Zeit Now 上部署非常容易:

  1. 将工作目录更改为 now 并执行命令:
$ now --docker --public
  1. 在终端窗口中,该工具将报告其进度和您可以访问您现在发布的应用程序的 URL:
> Deploying C:\dev\local-weather-app\web-app\now under duluca
> Ready! https://xxxxxxxxxxxxx.now.sh [3s]
> Initializing...
> Building
> ▲ docker build
Sending build context to Docker daemon 2.048 kBkB
> Step 1 : FROM duluca/localcast-weather
> latest: Pulling from duluca/localcast-weather
...
> Deployment complete!
  1. 导航到第二行列出的 URL 并验证您的应用程序的发布。

请注意,如果您在配置过程中出现错误,您的浏览器可能会显示一个错误,指示此页面正在尝试加载不安全的脚本,请允许并重新加载以查看您的应用程序。

您可以探索 Zeit Now 的付费功能,这些功能允许为您的应用程序提供高级功能,例如自动缩放。

恭喜,您的应用程序已经在互联网上启动了!

总结

在本章中,你学会了如何通过防范空数据来最好地避免 Angular 控制台错误。你已经配置好系统以便与 Docker 协同工作,并成功地将你的 Web 应用程序与专用的 Web 服务器容器化。你还为 Docker 配置了项目并利用了 npm 脚本,这些脚本可以被任何团队成员利用。最后,你成功地将 Web 应用程序交付到了云端。

现在你知道如何构建一个可靠、弹性、并且容器化的生产就绪 Angular 应用程序,以允许灵活的部署策略。在下一章中,我们将改善应用程序的功能集,并使用 Angular Material 使其看起来更加出色。

第十一章:使用 Angular Material 增强 Angular 应用

在第十章*,为生产发布准备 Angular 应用*中,我们提到需要提供高质量的应用程序。目前,这个应用程序看起来和感觉都很糟糕,仿佛只适用于上个世纪 90 年代末创建的网站。用户或客户对你的产品或工作的第一印象非常重要,所以我们必须创建一个外观出色、并且在移动和桌面浏览器中提供出色用户体验的应用程序。

作为全栈开发人员,很难专注于你的应用程序的完善。当应用程序的功能集迅速增长时,情况会变得更糟。在匆忙中使用 CSS hack 和内联样式,从而改善你的应用程序,这样做将会使你不再写出优质模块化的代码支持视图,而是沦为一名伟大的代码写手。

Angular Material 是与 Angular 紧密协作开发的惊人库。如果你学会如何有效地利用 Angular Material,你创建的功能将会从一开始就看起来和操作起来非常棒,无论你是在开发小型还是大型应用程序。Angular Material 会使你成为一名更加高效的网页开发人员,因为它附带了各种用户控件,你可以利用它们,而且你不必担心浏览器兼容性。作为额外的奖励,编写自定义 CSS 将变得十分罕见。

在本章中,你将学到以下内容:

  • 如何配置 Angular Material

  • 使用 Angular Material 升级 UX

将 Material 组件添加到你的应用中

现在我们已经安装了各种依赖项,我们可以开始修改我们的 Angular 应用,以添加 Material 组件。我们将添加一个工具栏、Material 设计卡片元素,并涵盖基本布局技术,以及辅助功能和排版方面的问题。

Angular Material 的生成器原理图

随着 Angular 6 和引入原理图的推出,像 Material 这样的库可以提供自己的代码生成器。目前,Angular Material 随附三个基本生成器,用于创建带有侧边导航、仪表板布局或数据表的 Angular 组件。你可以在material.angular.io/guide/schematics了解更多关于生成器原理图的信息。

比如,你可以通过执行以下操作创建一个侧边导航布局:

$ ng generate @angular/material:material-nav --name=side-nav 

CREATE src/app/side-nav/side-nav.component.css (110 bytes)
CREATE src/app/side-nav/side-nav.component.html (945 bytes)
CREATE src/app/side-nav/side-nav.component.spec.ts (619 bytes)
CREATE src/app/side-nav/side-nav.component.ts (489 bytes)
UPDATE src/app/app.module.ts (882 bytes)

此命令会更新app.module.ts,直接在该文件中导入 Material 模块,打破了我之前提出的material.module.ts的模式。此外,一个新的SideNavComponent被添加到应用程序作为一个单独的组件,但如同在第十四章中的侧边导航部分中所提到的,设计验证和授权,这样的导航体验需要在你的应用程序的非常根本部分实现。

简而言之,Angular Material Schematics 承诺使向 Angular 应用程序添加各种 Material 模块和组件变得更加轻松;然而,就提供的功能而言,这些模式并不适合创建本书追求的灵活、可扩展和良好架构的代码库。

目前,我建议将这些模式用于快速原型设计或实验目的。

现在,让我们开始手动向 LocalCast Weather 添加一些组件。

使用 Material 工具栏修改着陆页

在我们开始对app.component.ts进行进一步更改之前,让我们将组件切换为使用内联模板和内联样式,这样我们就不必在相对简单的组件上来回切换文件了。

  1. 更新 app.component.ts 以使用内联模板

  2. 删除 app.component.htmlapp.component.css

src/app/app.component.ts import { Component } from '@angular/core'

@Component({
  selector: 'app-root',
  template: `
    <div style="text-align:center">
      <h1>
      LocalCast Weather
      </h1>
      <div>Your city, your forecast, right now!</div>
      <h2>Current Weather</h2>
      <app-current-weather></app-current-weather>
    </div>
  `
})
export class AppComponent {}

让我们通过实现全局工具栏来改进我们的应用程序:

  1. 观察app.component.ts中的h1标签:
src/app/app.component.ts
<h1>
  LocalCast Weather
</h1>
  1. 使用 mat-toolbar 更新h1标签:
src/app/app.component.ts    
<mat-toolbar>
  <span>LocalCast Weather</span>
</mat-toolbar>
  1. 观察结果;您应该会看到一个工具栏,如图所示:

LocalCast 天气工具栏

  1. 用更引人注目的颜色更新mat-toolbar
src/app/app.component.ts    
<mat-toolbar color="primary">

为了更加原生的感觉,工具栏紧贴浏览器边缘非常重要。无论是在大屏幕还是小屏幕格式上都能很好地发挥作用。此外,当您将可点击的元素(例如汉堡菜单或帮助按钮)放在工具栏的最左侧或最右侧时,您将避免用户点击空白处的潜在可能性。这就是为什么 Material 按钮的点击区域实际上比视觉上表示的要大。这在打造无需挫折的用户体验方面有很大的不同:

src/styles.css
body {
  margin: 0;
}

这对这个应用程序不适用,然而,如果您正在构建一个密集的应用程序,您会注意到您的内容将延伸到应用程序的边缘,这不是一个理想的结果。考虑将内容区域包装在一个 div 中,并使用 css 应用适当的边距,如图所示:

src/styles.css
.content-margin {
  margin-left: 8px;
  margin-right: 8px;
}

在下一个截图中,您可以看到应用了主要颜色的边到边工具栏:

用改进后的工具栏的 LocalCast 天气

用 Material Card 表示天气

Material card 是一个很好的容器,用于表示当前的天气信息。卡片元素周围有一个投影,将内容与周围的环境区分开来:

  1. material.module 中导入MatCardModule
src/app/material.module.ts
import { ..., MatCardModule} from '@angular/material'
...
@NgModule({
  imports: [..., MatCardModule],
  exports: [..., MatCardModule],
})
  1. app.component 中,用 <mat-card> 包围<app-current-weather>:
src/app/app.component.ts
  <div style="text-align:center">
    <mat-toolbar color="primary">
      <span>LocalCast Weather</span>
    </mat-toolbar>
    <div>Your city, your forecast, right now!</div>
    <mat-card>
      <h2>Current Weather</h2>
      <app-current-weather></app-current-weather>
    </mat-card>
  </div>
  1. 观察几乎无法区分的卡片元素,如图所示:

LocalCast 天气的难以区分的卡片

为了更好地布局屏幕,我们需要切换到 Flex 布局引擎。从组件模板中删除这些 "训练轮":

  1. 从周围的 <div> 中删除style="text-align:center"

要在页面中心放置一个元素,我们需要创建一行,对中心元素分配宽度,并在两侧创建两个额外的列,可以弹性伸展以占用空白部分,如下所示:

src/app/app.component.ts
<div fxLayout="row">
  <div fxFlex></div>
  <div fxFlex="300px">  
    ...
  </div>
  <div fxFlex></div>
</div>
  1. 用前面的 HTML 包围<mat-card>

  2. 请注意,卡片元素已正确居中,如下所示:

LocalCast Weather 与居中的卡片

通过阅读卡片文档并查看 Material 文档站点上的示例,您将注意到mat-card提供了容纳标题和内容的元素。我们将在接下来的部分实现这个。

material.angular.io上,您可以通过点击括号图标查看任何示例的源代码,或者点击箭头图标在 Plunker 上启动一个工作示例。

辅助功能

利用这样的 Material 功能可能会感到多余;然而,设计应用程序时,您必须考虑响应性、样式、间距和可访问性问题。Material 团队已经付出了很多努力,以便您的代码在大多数情况下可以正确工作,并为尽可能多的用户群提供高质量的用户体验。这可能包括视障人士或以键盘为主的用户,他们必须依赖专门的软件或键盘功能(如标签)来浏览您的应用。利用 Material 元素为这些用户提供了关键的元数据,以便他们能够浏览您的应用。

Material 声明支持以下屏幕阅读器软件:

  • 在 IE / FF / Chrome(Windows)上使用 NVDA 和 JAWS

  • 使用 iOS 上的 Safari 和 OSX 上的 Safari / Chrome 的 VoiceOver

  • 使用 Chrome 上的 TalkBack

卡片标题和内容

现在,让我们实现mat-card的标题和内容元素,如下所示:

src/app/app.component.ts    
<mat-toolbar color="primary">
  <span>LocalCast Weather</span>
</mat-toolbar>
<div>Your city, your forecast, right now!</div>
<div fxLayout="row">
  <div fxFlex></div>
  <mat-card fxFlex="300px">
    <mat-card-header>
      <mat-card-title>Current Weather</mat-card-title>
    </mat-card-header>
    <mat-card-content>
      <app-current-weather></app-current-weather>
    </mat-card-content>
  </mat-card>
  <div fxFlex></div>
</div>

使用 Material,少就是更多。您将注意到我们能够移除中心的div,并直接在居中卡片上应用fxFlex。所有材料元素都原生支持 Flex 布局引擎,这在复杂的 UI 中具有巨大的正面可维护性影响。

当我们应用mat-card-header后,您可以看到以下结果:

带标题和内容的 LocalCast Weather 卡片

请注意,卡片内的字体现在与 Material 的 Roboto 字体匹配。然而,"Current Weather"现在不再那么引人注目。如果您在mat-card-title内重新添加h2标签,"Current Weather"在视觉上看起来会更大;但是,字体不会与您应用程序的其他部分匹配。要解决此问题,您必须了解 Material 的排版功能。

Material 排版

Material 的文档恰如其分地表述如下:

排版是一种排列类型以在显示时使文本易读、可读和吸引人的方法。

Material 提供了一种不同水平的排版,具有不同的字体大小、行高和字重特性,您可以将其应用到任何 HTML 元素,而不仅仅是默认提供的组件。

在下表中是您可以使用的 CSS 类,用于应用 Material 的排版,比如<div class="mat-display-4">Hello, Material world!</div>

类名 用法
display-4display-3display-2display-1 大而独特的标题,通常位于页面顶部(例如,主标题)
headline  对应<h1>标签的部分标题
title  对应<h2>标签的部分标题
subheading-2 对应<h3>标签的部分标题
subheading-1 对应<h4>标签的部分标题
body-1 基本正文文本
body-2 更加粗体的正文文本
caption  较小的正文和提示文本
button 按钮和链接

您可以在material.angular.io/guide/typography了解更多关于 Material 排版的信息。

应用排版

有多种应用排版的方式。一种方式是利用mat-typography类,并使用相应的 HTML 标签如<h2>

src/app/app.component.ts 
<mat-card-header class="mat-typography">
  <mat-card-title><h2>Current Weather</h2></mat-card-title>
</mat-card-header>

另一种方式是直接在元素上应用特定的排版,比如class="mat-title"

src/app/app.component.ts 
<mat-card-title><div class="mat-title">Current Weather</div></mat-card-title>

注意,class="mat-title"可以应用于divspan或带有相同结果的h2

通常来说,实现更具体和本地化的选项通常是更好的选择,也就是第二种实现方式。

将标语更新为居中对齐的标题排版

我们可以使用fxLayoutAlign将应用程序的标语居中,并赋予其一个柔和的mat-caption排版,如下所示:

  1. 实现布局更改和标题排版:
src/app/app.component.ts 
<div fxLayoutAlign="center">
  <div class="mat-caption">Your city, your forecast, right now!</div>
</div>
  1. 观察结果,如下所示:

本地天气中心标语

更新当前天气卡片布局

还有更多工作要做,使 UI 看起来像设计一样,特别是当前天气卡片的内容,如下所示:

为了设计布局,我们将利用 Angular Flex。

您将编辑current-weather.component.html,它使用<div><span>标签来建立各个元素,这些元素可以分别存在于不同行或同一行。随着切换到 Angular Flex,我们需要将所有元素转换为<div>,并使用fxLayout指定行和列。

实现布局脚手架

我们需要首先实现粗略的脚手架。

考虑模板的当前状态:

 src/app/current-weather/current-weather.component.html
 1 <div *ngIf="current">
 2  <div>
 3    <span>{{current.city}}, {{current.country}}</span>
 4    <span>{{current.date | date:'fullDate'}}</span>
 5  </div>
 6  <div>
 7    <img [src]='current.image'>
 8    <span>{{current.temperature | number:'1.0-0'}}℉</span>
 9  </div>
10  <div>
11    {{current.description}}
12  </div>
13 </div>

让我们一步步浏览文件并更新:

  1. 在第 3、4 和 8 行更新<span>元素为<div>

  2. <div>包裹<img>元素

  3. 在第 2 和 6 行的有多个子元素的<div>元素中添加fxLayout="row"属性

  4. 城市和国家列大约占据了屏幕的 2/3,所以在第 3 行的<div>元素中添加fxFlex="66%"

  5. 在第 4 行的下一个<div>元素上添加fxFlex以确保它占据其余的水平空间

  6. 在新的<div>元素(包围<img>元素)中添加fxFlex="66%"

  7. 在第 4 行的下一个<div>元素中添加fxFlex

模板的最终状态应该如下所示:

 src/app/current-weather/current-weather.component.html
 1 <div *ngIf="current">
 2   <div fxLayout="row">
 3     <div fxFlex="66%">{{current.city}}, {{current.country}}</div>
 4     <div fxFlex>{{current.date | date:'fullDate'}}</div>
 5   </div>
 6   <div fxLayout="row">
 7     <div fxFlex="66%">
 8       <img [src]='current.image'>
 9     </div>
10     <div fxFlex>{{current.temperature | number:'1.0-0'}}℉</div>
11   </div>
12   <div>
13    {{current.description}}
14  </div>
15 </div>

在添加 Angular Flex 属性时,你可以更详细一些;但是,你写的代码越多,未来的改动就会变得更加困难。例如,在第 12 行的<div>元素不需要fxLayout="row",因为<div>会隐式换行。同样,在第 4 行和第 7 行,右侧的列不需要显式的fxFlex属性,因为它将自动被左侧元素压缩。

从网格布局的角度来看,你的元素现在都在正确的单元格中,如图所示:

使用布局脚手架的本地天气

对齐元素

现在,我们需要对齐和设计每个单独的单元格以匹配设计。日期和温度需要右对齐,描述需要居中:

  1. 要右对齐日期和温度,在current-weather.component.css中创建一个名为.right的新 CSS 类:
src/app/current-weather/current-weather.component.css
.right {
  text-align: right
}
  1. 在第 4 行和第 10 行的<div>元素中添加class="right"

  2. 以与本章前面居中应用标语相同的方式居中描述的<div>元素

  3. 观察元素的正确对齐方式如下:

本地天气与正确的对齐方式

设计元素

最终设计元素的调整通常是前端开发中最费时的部分。我建议首先进行多次尝试,以便用最少的工作量获得足够接近设计的版本,然后让你的客户或团队决定是否值得投入额外的资源来花费更多时间来完善设计:

  1. 添加新的 CSS 属性:
src/app/current-weather/current-weather.component.css
.no-margin {
  margin-bottom: 0
}
  1. 对于城市名称,在第 3 行,添加class="mat-title no-margin"

  2. 对于日期,在第 4 行,将"mat-subheading-2 no-margin"添加到class="right"

  3. 将日期的格式从'fullDate'改为'EEEE MMM d'以匹配设计

  4. 修改<img>,在第 8 行添加style="zoom: 175%"

  5. 对于温度,在第 10 行,附加"mat-display-3 no-margin"

  6. 对于描述,在第 12 行,添加class="mat-caption"

这是模板的最终状态:

src/app/current-weather/current-weather.component.html
<div *ngIf="current">
  <div fxLayout="row">
    <div fxFlex="66%" class="mat-title no-margin">{{current.city}}, {{current.country}}</div>
    <div fxFlex class="right mat-subheading-2 no-margin">{{current.date | date:'EEEE MMM d'}}</div>
  </div>
  <div fxLayout="row">
    <div fxFlex="66%">
      <img style="zoom: 175%" [src]='current.image'>
    </div>
    <div fxFlex class="right mat-display-3 no-margin">{{current.temperature | number:'1.0-0'}}℉</div>
  </div>
  <div fxLayoutAlign="center" class="mat-caption">
    {{current.description}}
  </div>
</div>
  1. 观察你的代码输出的样式变化,如图所示:

带有样式的本地天气

微调样式

标语可以受益于一些上下边距。这是我们可能会在整个应用程序中使用的常见 CSS,因此让我们将它放在styles.css中:

  1. 实现vertical-margin
src/styles.css
.vertical-margin {
  margin-top: 16px;
  margin-bottom: 16px;
}
  1. 应用vertical-margin
src/app/app.component.ts
<div class="mat-caption vertical-margin">Your city, your forecast, right now!</div>

当前天气具有与城市名称相同的样式;我们需要区分这两者。

  1. app.component.ts中,使用mat-headline排版更新当前天气:
src/app/app.component.ts
<mat-card-title><div class="mat-headline">Current Weather</div></mat-card-title>
  1. 图像和温度没有居中,因此在围绕第 6 行上下文中包含这些元素的行中添加fxLayoutAlign="center center"
src/app/current-weather/current-weather.component.html
<div fxLayout="row" fxLayoutAlign="center center">
  1. 观察您的应用程序的最终设计,应该如下所示:

LocalCast 天气的最终设计

调整以匹配设计

这是一个您可能会花费大量时间的领域。如果我们遵循 80-20 法则,像素完美的微调通常成为最后的 20%,却需要花费 80%的时间来完成。让我们来研究我们的实现和设计之间的差异,以及弥合这一差距需要什么:

日期需要进一步定制。数字序数th丢失了;为了实现这一点,我们需要引入第三方库,比如 moment,或者实现我们自己的解决方案并将其绑定到模板上的日期旁边:

  1. 更新current.date以附加序数:
src/app/current-weather/current-weather.component.html
{{current.date | date:'EEEE MMM d'}}{{getOrdinal(current.date)}}
  1. 实现一个getOrdinal函数:
src/app/current-weather/current-weather.component.ts export class CurrentWeatherComponent implements OnInit {
...
  getOrdinal(date: number) {
    const n = new Date(date).getDate()
    return n > 0
      ? ['th', 'st', 'nd', 'rd'][(n > 3 &amp;&amp; n < 21) || n % 10 > 3 ? 0 : n % 10]
      : ''
  }
  ...
}

请注意,getOrdinal的实现归结为一个复杂的单行代码,不太可读,而且很难维护。这样的函数,如果对您的业务逻辑至关重要,应该进行严格的单元测试。

截至目前为止,Angular 6 不支持在日期模板中插入新的换行;理想情况下,我们应该能够将日期格式指定为'EEEE\nMMM d',以确保换行始终一致。

温度的实现需要使用<span>元素将数字与单位分隔开,并用<span class="unit">℉</span>将其包围起来,其中 unit 是一个 CSS 类,可以使其看起来像上标元素。

  1. 实现一个unitCSS 类:
src/app/current-weather/current-weather.component.css
.unit {
  vertical-align: super;
}
  1. 应用unit
src/app/current-weather/current-weather.component.html
...   
 7 <div fxFlex="55%">
...
10 <div fxFlex class="right no-margin">
11   <p class="mat-display-3">{{current.temperature | number:'1.0-0'}}
12     <span class="mat-display-1 unit">℉</span>
13   </p>

我们需要尝试调整第 7 行上的fxFlex值来确定预报图像应该占用多少空间。否则,温度将溢出到下一行,并且您的设置还会受到浏览器窗口大小的影响。例如,60%在小浏览器窗口下效果很好,但最大化时会造成溢出。然而,55%似乎满足了这两个条件:

修正后的 LocalCast 天气

一如既往,进一步调整边距和填充以进一步定制设计是可能的。然而,每一次与库的偏离都会对维护性造成影响。除非您确实在建立一个以显示天气数据为中心的业务,否则您应该将进一步的优化推迟到项目的最后,如果时间允许的话。如果经验能够作为指导,您将不会进行这样的优化。

通过两个负的 margin-bottom hack,您可以获得一个与原始设计相当接近的设计,但是我不会在这里包含这些 hack,并留给读者在 GitHub 仓库中发现。这些 hack 有时是必要的恶,但一般来说,它们指向设计和实现现实之间的脱节。在调整部分之前的解决方案是甜蜜点,Angular Material 在那里繁荣:

图片

经过调整和 hack 的 LocalCast Weather

更新单元测试

为了保持您的单元测试运行,您需要将MaterialModule导入到任何使用 Angular Material 的组件的spec文件中:

*.component.spec.ts
...
  beforeEach(
    async(() => {
      TestBed.configureTestingModule({
        ...
        imports: [..., MaterialModule, NoopAnimationsModule],
      }).compileComponents()
    })
  )

您还需要更新任何测试,包括 e2e 测试,以查找特定的 HTML 元素。

例如,由于应用程序的标题 LocalCast Weather 不再在一个h1标签中,您必须更新spec文件以在span元素中查找它:

src/app/app.component.spec.ts
expect(compiled.querySelector('span').textContent).toContain('LocalCast Weather')

类似地,在 e2e 测试中,您需要更新您的页面对象函数以从正确的位置检索文本:

e2e/app.po.ts
getParagraphText() {
  return element(by.css('app-root mat-toolbar span')).getText()
}

更新 Angular Material

您可以使用ng update来快速无痛升级体验,应该如下所示:

$ npx ng update @angular/material
 Updating package.json with dependency @angular/cdk @ "6.0.0" (was "5.2.2")...
 Updating package.json with dependency @angular/material @ "6.0.0" (was "5.2.2")...
UPDATE package.json (5563 bytes)

此外,我发现了由 Angular 团队在github.com/angular/material-update-tool发布的material-update-tool。在当前形式下,该工具被宣传为一个特定的 Angular Material 5.x 到 6.0 的更新工具,因此它可能会成为未来ng update的一部分,就像rxjs-tslint工具一样。您可以按照下面的命令来运行该工具:

$ npx angular-material-updater -p .\src\tsconfig.app.json

√ Successfully migrated the project source files. Please check above output for issues that couldn't be automatically fixed.

如果幸运的话,一切顺利,可以随意跳过本节剩下的内容。本节的其余部分我将介绍我在开发本示例过程中遇到的一个涉及发布候选版本和 beta 版本的特定情景,这突显了手动更新的需求。首先,我们将意识到当前版本,然后发现最新可用版本,并最后升级和测试升级,就像我们手动更新 Angular 时那样。

更新 Angular Material

现在我们知道要升级到哪个版本,让我们继续进行升级:

  1. 执行以下命令,将 Material 及其相关组件更新到目标版本:
$ npm install @angular/material@⁵.0.0 @angular/cdk@⁵.0.0 @angular/animations@⁵.0.0 @angular/flex-layout@².0.0-rc.1
  1. 验证您的package.json,确保版本与预期版本匹配

  2. 处理任何 NPM 警告

在这种特定情况下,我们从@angular/flex-layout包收到了无法满足的对等依赖警告。在 GitHub 上的进一步调查(github.com/angular/flex-layout/issues/508)显示,这是一个已知问题,通常可以预期从 Beta 或 RC 包中出现。这意味着可以忽略这些警告是安全的。

总结

在本章中,你学会了将特定的 Angular Material 组件应用到你的应用程序中。你意识到了过度优化 UI 设计的陷阱。我们还讨论了如何保持 Angular Material 的最新状态。

在下一章中,我们将更新天气应用程序,以响应用户输入并使用响应式表单来保持我们的组件解耦,同时还可以使用BehaviorSubject在它们之间进行数据交换。在下一章之后,我们将完成天气应用程序,并把重点转向构建更大型的业务线应用。

第十二章:创建一个以路由为首选的 LOB 应用

Line-of-Business(LOB)应用程序是软件开发世界的支柱。根据维基百科的定义,LOB 是一个通用术语,指的是服务于特定客户交易或业务需求的产品或一组相关产品。LOB 应用程序提供了展示各种功能和功能的良好机会,而无需涉及大型企业应用程序通常需要的扭曲或专业化场景。在某种意义上,它们是 80-20 的学习经验。但是,我必须指出关于 LOB 应用程序的一个奇怪之处——如果您最终创建了一个半有用的 LOB 应用程序,其需求将不受控制地增长,您将很快成为自己成功的受害者。这就是为什么您应该把每个新项目的开始视为一个机会,一个编码的开拓,以更好地创建更灵活的架构。

在本章和其余章节中,我们将使用可扩展的架构和工程最佳实践建立一个功能丰富的新应用程序,以满足具有可扩展架构的 LOB 应用程序的需求。我们将遵循以路由为首选的设计模式,依靠可重用组件创建一个名为 LemonMart 的杂货店 LOB。

在本章中,您将学会以下内容:

  • 有效地使用 CLI 来创建重要的 Angular 组件和 CLI 脚手架

  • 学习如何构建以路由为首选的应用程序

  • 品牌、定制和素材图标

  • 使用 Augury 调试复杂应用程序

  • 启用延迟加载

  • 创建一个步行骨架

本书提供的代码示例需要 Angular 版本 5 和 6。Angular 5 代码与 Angular 6 运行时兼容。Angular 6 将在 2019 年 10 月之前得到长期支持。代码存储库的最新版本可在以下网址找到:

Angular 速查表

在我们开始创建 LOB 应用程序之前,我为您提供了一个速查表,让您熟悉常见的 Angular 语法和 CLI 命令,因为在今后,这些语法和命令将被使用,而不需要明确解释它们的目的。花些时间审查和熟悉新的 Angular 语法、主要组件、CLI 脚手架和常见管道。如果您的背景是 AngularJS,您可能特别需要这个列表,因为您需要放弃一些旧的语法。

绑定

绑定,或数据绑定,指的是代码中的变量和 HTML 模板或其他组件中显示或输入的值之间的自动单向或双向连接:

类型 **语法 ** 数据方向

| 插值属性

属性

样式 | {{expression}}``[target]="expression"``bind-target="expression" | 从数据源单向传输

用于查看目标 |

| 事件 | (target)="statement" on-target="statement" | 从视图目标到单向

用于数据源 |

双向绑定 [(target)]="expression" bindon-target="expression" 双向绑定

来源:angular.io/guide/template-syntax#binding-syntax-an-overview

内置指令

指令封装编码行为,可应用为 HTML 元素或其他组件的属性:

名称 语法 目的
结构指令 *ngIf``*ngFor``*ngSwitch 控制 HTML 的结构布局,以及根据需要在 DOM 中添加或移除元素
属性指令 [class]``[style]``[(model)] 监听并修改其他 HTML 元素、属性、属性和组件的行为,如 CSS 类、HTML 样式和 HTML 表单元素

结构指令来源:angular.io/guide/structural-directives

属性指令来源:angular.io/guide/template-syntax#built-in-attribute-directives

常见的管道

管道修改了在 HTML 模板中显示数据绑定值的方式。

名称 目的 用法
日期 根据语言环境规则,格式化日期 {{date_value &#124; date[:format]}}
文本转换 将文本转换为大写、小写或标题格式 {{value &#124; uppercase}}``{{value &#124; lowercase}}``{{value &#124; titlecase }}
十进制 根据语言环境规则,格式化数字 {{number &#124; number[:digitInfo]}}
百分比 根据语言环境规则,将数字格式化为百分比形式 {{number &#124; percent[:digitInfo]}}
货币 根据语言环境规则,格式化数字为带有货币代码和符号的货币形式 {{number &#124; currency[:currencyCode [:symbolDisplay[:digitInfo]]]}}

管道来源:angular.io/guide/pipes

起始命令、主要组件和 CLI 脚手架

起始命令帮助生成新项目或添加依赖项。Angular CLI 命令可通过自动生成易用的样板脚手架代码来快速创建主要组件。有关完整命令列表,请访问github.com/angular/angular-cli/wiki

名称 目的 CLI 命令
新建 创建一个新的 Angular 应用,并已配置好初始化的 git 仓库、package.json,并已配置好路由。从父级文件夹运行。 npx @angular/cli new project-name --routing
更新 更新 Angular、RxJS 和 Angular Material 依赖项。根据需要重写代码以保持兼容性。 npx ng update
添加材料 安装和配置 Angular Material 依赖项。 npx ng add @angular/material
模块 创建一个新的@NgModule类。使用--routing为子模块添加路由。可选择使用--module将新模块导入到父模块中。 ng g module new-module
组件 创建一个新的@Component类。使用--module指定父模块。可选择使用--flat跳过目录创建,-t用于内联模板,-s用于内联样式。 ng g component new-component
指令 创建一个新的@Directive类。可选择使用--module为给定子模块定义指令的作用域。 ng g directive new-directive
管道 创建一个新的@Pipe类。可选择使用--module为给定子模块定义管道的作用域。 ng g pipe new-pipe
服务 创建一个新的@Injectable类。使用--module为给定子模块提供服务。服务不会自动导入到模块中。可选择使用--flat false 将服务创建在一个目录下。 ng g service new-service
守卫 创建一个新的@Injectable类,实现了路由生命周期钩子CanActivate。使用--module为给定的子模块提供守卫。守卫不会自动导入到一个模块中。 ng g guard new-guard
创建一个基础的类。 ng g class new-class
接口 创建一个基本的接口。 ng g interface new-interface
枚举 创建一个基础的枚举。 ng g enum new-enum

为了正确地在自定义模块下生成之前列出的一些组件,比如my-module,你可以在你想要生成的名字之前加上模块名,比如ng g c my-module/my-new-component。Angular CLI 将正确配置并将新组件放置在my-module文件夹下。

配置 Angular CLI 自动补全

在使用 Angular CLI 时,可以获得自动补全的体验。在你的*nix环境中执行相应的命令:

  • 对于 bash shell:
$ ng completion --bash >> ~/.bashrc
$ source ~/.bashrc
  • 对于 zsh shell:
$ ng completion --zsh >> ~/.zshrc
$ source ~/.zshrc
  • 对于使用 git bash shell 的 Windows 用户:
$ ng completion --bash >> ~/.bash_profile
$ source ~/.bash_profile

以路由为中心的架构

Angular 路由器,包含在@angular/router包中,是构建单页面应用程序SPAs)的核心且关键的部分,它的行为表现就像是可以使用浏览器控件或缩放控件轻松导航的普通网站。

Angular Router 具有高级功能,例如延迟加载,路由出口,辅助路由,智能活动链接跟踪,以及可以被表示为一个href的能力,这使得可以利用无状态数据驱动组件使用 RxJS SubjectBehavior来实现高度灵活的以路由为中心的应用架构。

大型团队可以针对单一代码库进行工作,每个团队负责一个模块的开发,而不会互相影响,同时可以实现简单的持续集成。Google 有着数十亿行代码,选择针对单一代码库工作是有着非常好的理由的。事后的集成是非常昂贵的。

小团队可以动态重新排列其 UI 布局,以快速对变化做出响应,而无需重新构建其代码。很容易低估由于布局或导航的后期变更而浪费的时间量。对于大团队来说,这些变化更容易吸收,但对于小团队来说是一次代价高昂的努力。

通过延迟加载,所有开发人员都可以受益于亚秒级的第一意义性绘制,因为在构建时,向浏览器传递的核心用户体验的文件大小被保持在最低限度。模块的大小影响下载和加载速度,因为浏览器需要执行的操作越多,用户看到应用程序的第一个屏幕就需要的时间就越长。通过定义延迟加载的模块,每个模块可以作为单独的文件打包,可以单独下载和加载,并根据需要使用。智能活动链接跟踪会产生卓越的开发人员和用户体验,使得实现突出显示功能来指示用户当前活动的选项卡或应用程序部分非常容易。辅助路由最大化了组件的重用,并帮助轻松实现复杂的状态转换。通过辅助路由,您可以仅使用单个外部模板呈现多个主视图和详细视图。您还可以控制路由如何在浏览器的 URL 栏中显示,并使用routerLink在模板中和Router.navigate在代码中组成路由,从而驱动复杂的场景。

为了实现路由器优先的实现,您需要这样做:

  1. 早期定义用户角色

  2. 设计时考虑延迟加载

  3. 实施一个步行骨架导航体验

  4. 围绕主要数据组件进行设计

  5. 强制执行解耦的组件架构

  6. 区分用户控件和组件

  7. 最大化代码复用

用户角色通常表示用户的工作职能,比如经理或数据输入专员。在技术术语中,它们可以被视为特定用户类别被允许执行的一组操作。定义用户角色有助于识别可以配置为延迟加载的子模块。毕竟,数据输入专员永远不会看到大多数经理可以看到的屏幕,那么为什么要向这些用户提供这些资源并减慢他们的体验呢?延迟加载对于创建可扩展的应用程序架构至关重要,不仅从应用程序的角度来看,还从高质量和高效的开发角度来看。配置延迟加载可能会有些棘手,这就是为什么早期确定一个步行骨架导航体验非常重要的原因。

确定用户将使用的主要数据组件,例如发票或人员对象,将帮助您避免过度设计您的应用程序。围绕主要数据组件进行设计将及早通知 API 设计,并帮助定义您将使用的BehaviorSubject数据锚定来实现无状态、数据驱动的设计,以确保解耦的组件架构。

最后,识别自包含的用户控件,它们封装了您希望为您的应用程序创建的独特行为。用户控件可能会作为具有数据绑定属性和紧密耦合的控制器逻辑和模板的指令或组件进行创建。另一方面,组件将利用路由生命周期事件来解析参数并对数据执行 CRUD 操作。早期识别这些组件的重用将导致创建更灵活的组件,可以在路由器协调下在多个上下文中重用,最大程度地提高代码重用率。

创建 LemonMart

LemonMart 将是一个具有超过 90 个代码文件的中型业务应用程序。我们将通过从一开始就创建一个配置了路由和 Angular Material 的新 Angular 应用程序来开始我们的旅程。

创建一个以路由为主的应用

采用路由优先的方法时,我们希望在应用程序早期就启用路由:

  1. 你可以通过执行此命令创建已经配置了路由的新应用:

确保没有全局安装 @angular/cli,否则可能会遇到错误:

$ npx @angular/cli new lemon-mart --routing
  1. 为我们创建了一个新的 AppRoutingModule 文件:
src/app/app-routing.modules.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

const routes: Routes = [];

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

我们将在路由数组中定义路由。请注意,路由数组被传递以配置为应用程序的根路由,默认的根路由是 /

在配置你的 RouterModule 时,可以传入额外的选项来自定义路由的默认行为,例如当你尝试加载已经显示的路由时,而不是不采取任何行动,你可以强制重新加载组件。要启用这种行为,请这样创建你的路由 RouterModule.forRoot(routes, { onSameUrlNavigation: 'reload' })

  1. 最后,注册 AppRoutingModuleAppModule,如下所示:
src/app/app.module.ts ...
import { AppRoutingModule } from './app-routing.module';

@NgModule({
  ...
  imports: [
    AppRoutingModule 
    ...
  ],
  ...

配置 Angular.json 和 Package.json

在继续之前,你应该完成以下步骤:

  1. 修改 angular.json 和 tslint.json 以强制执行你的设置和编码规范

  2. 安装 npm i -D prettier

  3. package.json 中添加 prettier 设置

  4. 将开发服务器端口配置为非4200,例如5000

  5. 添加 standardize 脚本并更新 startbuild 脚本

  6. package.json 中为 Docker 添加 npm 脚本

  7. 建立开发规范并在项目中记录,使用 npm i -D dev-norms 然后执行 npx dev-norms create

  8. 如果你使用 VS Code,需要设置 extensions.jsonsettings.json 文件

你可以配置 TypeScript Hero 扩展来自动整理和修剪导入语句,只需在 settings.json 中添加 "typescriptHero.imports.organizeOnSave": true。如果与设置 "files.autoSave": "onFocusChange" 结合使用,你可能会发现该工具在你努力输入时会积极地清理未使用的导入项。确保该设置适合你并且不会与任何其他工具或 VS Code 自己的导入组织功能发生冲突。

  1. 执行 npm run standardize

参考第十章准备 Angular 应用进行生产发布,获取更多配置详细信息。

你可以在bit.ly/npmScriptsForDocker获取适用于 Docker 的 npm 脚本,以及在bit.ly/npmScriptsForAWS获取适用于 AWS 的 npm 脚本。

配置 Material 和样式

我们还需要设置 Angular Material 并配置要使用的主题,如第十一章使用 Angular Material 增强 Angular 应用

  1. 安装 Angular Material:
$ npx ng add @angular/material
$ npm i @angular/flex-layout hammerjs 
$ npx ng g m material --flat -m app
  1. 导入和导出MatButtonModuleMatToolbarModule,和MatIconModule

  2. 配置默认主题并注册其他 Angular 依赖项

  3. 将通用 css 添加到styles.css中,如下所示,

src/styles.css

body {
  margin: 0;
}

.margin-top {
  margin-top: 16px;
}

.horizontal-padding {
  margin-left: 16px;
  margin-right: 16px;
}

.flex-spacer {
  flex: 1 1 auto;
}

参考第十一章使用 Angular Material 增强 Angular 应用,获取更多配置详细信息。

设计 LemonMart

构建一个从数据库到前端的基本路线图非常重要,同时要避免过度工程化。这个初始设计阶段对项目的长期健康和成功至关重要,在这个阶段任何现有的团队隔离必须被打破,并且整个团队必须对整体技术愿景有很好的理解。这比说起来要容易得多,关于这个话题已经有大量的书籍写成。

在工程中,没有一个问题有唯一正确答案,所以重要的是要记住没有人能拥有所有答案,也没有一个清晰的愿景。技术和非技术领导者们创造一个安全的空间,鼓励开放讨论和实验,作为文化的一部分是非常重要的。对于整个团队来说,对这种不确定性的谦卑和同理心和任何单独团队成员的技术能力一样重要。每个团队成员都必须习惯于把自己的自负留在门外,因为我们共同的目标将是在开发周期期间根据不断变化的要求发展和演变应用。如果你成功了,你会发现你创建的软件中的每个部分都可以轻松被任何人替代。

识别用户角色

我们设计的第一步是考虑你为什么要使用这个应用。

我们构想了 LemonMart 的四种用户状态或角色:

  • 经过身份验证的用户,任何经过身份验证的用户都可以访问他们的个人资料

  • 出纳,他们的唯一角色是为客户结账

  • 职员,他们的唯一角色是执行与库存相关的功能

  • 经理,可以执行出纳和职员所能执行的所有操作,但也可以访问管理功能

有了这个想法,我们可以开始设计我们应用的高层结构。

用站点地图识别高级模块

开发你的应用的高级站点地图,如下所示:

用户的登陆页面

我使用 MockFlow.com 的 SiteMap 工具创建了站点地图

显示在sitemap.mockflow.com上。

第一次检查时,三个高级模块显现出延迟加载的候选项:

  1. 销售点(POS)

  2. 库存

  3. 管理员

收银员只能访问 POS 模块和组件。店员只能访问库存模块,该模块将包括额外的屏幕,用于库存录入,产品和类别管理组件。

库存页面

最后,管理员将能够使用管理员模块访问所有三个模块,包括用户管理和收据查找组件。

管理员页面

启用所有三个模块的延迟加载有很大的好处,因为收银员和店员永远不会使用属于其他用户角色的组件,所以没有理由将这些字节发送到他们的设备上。这意味着当管理员模块获得更先进的报告功能或应用程序添加新角色时,POS 模块将不受应用程序带宽和内存增长的影响。这意味着减少了支持电话,并保持了长时间使用相同硬件的一致性性能。

生成经过路由启用的模块

现在我们已经定义了我们的高级组件——管理员,库存和 POS,我们可以将它们定义为模块。这些模块将与您迄今创建的模块不同,因为它们涉及路由和 Angular Material。我们可以将用户配置文件创建为应用程序模块上的一个组件;不过请注意,用户配置文件只能供已经经过身份验证的用户使用,因此定义一个仅供一般经过身份验证的用户使用的第四个模块是有意义的。这样,您将确保您的应用程序的第一个有效载荷尽可能保持最小。此外,我们将创建一个主页组件,以包含应用程序的着陆体验,这样我们就可以将实现细节保持在app.component之外:

  1. 生成managerinventoryposuser 模块,指定它们的目标模块和路由功能:
$ npx ng g m manager -m app --routing
$ npx ng g m inventory -m app --routing
$ npx ng g m pos -m app --routing
$ npx ng g m user -m app --routing

如果您已经配置npx来自动识别ng作为命令,您可以节省更多按键,这样您将不必在每次命令后附加npx。不要全局安装@angular/cli。请注意缩写命令结构,其中ng generate module manager变成了ng g m manager,同样,--module变成了-m

  1. 验证您的 CLI 是否没有错误。

请注意,在 Windows 上使用npx可能会遇到错误,如路径必须是字符串。接收到未定义的错误。这个错误似乎对命令的成功操作没有任何影响,这就是为什么始终检查 CLI 工具生成的内容是至关重要的。

  1. 验证已创建的文件夹和文件:
/src/app
│   app-routing.module.ts
│   app.component.css
│   app.component.html
│   app.component.spec.ts
│   app.component.ts
│   app.module.ts
│   material.module.ts
├───inventory
│        inventory-routing.module.ts
│        inventory.module.ts
├───manager
│        manager-routing.module.ts
│        manager.module.ts
├───pos
│        pos-routing.module.ts
│        pos.module.ts
└───user
        user-routing.module.ts
        user.module.ts
  1. 检查ManagerModule如何连接。

子模块实现了类似于 app.module@NgModule。最大的区别在于,子模块不实现 bootstrap 属性,而这个属性对于根模块是必需的,以初始化你的 Angular 应用程序:

src/app/manager/manager.module.ts
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'

import { ManagerRoutingModule } from './manager-routing.module'

@NgModule({
  imports: [CommonModule, ManagerRoutingModule],
  declarations: [],
})
export class ManagerModule {}

由于我们指定了 -m 选项,该模块已经被导入到 app.module 中:

src/app/app.module.ts
...
import { ManagerModule } from './manager/manager.module'
...
@NgModule({
  ...
  imports: [
    ...
    ManagerModule 
  ],
...

另外,因为我们还指定了 --routing 选项,一个路由模块已经被创建并导入到 ManagerModule 中:

src/app/manager/manager-routing.module.ts
import { NgModule } from '@angular/core'
import { Routes, RouterModule } from '@angular/router'

const routes: Routes = []

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

请注意,RouterModule 正在使用 forChild 进行配置,而不是像 AppRouting 模块的情况下使用 forRoot。这样,路由器就能理解不同模块上下文中定义的路由之间的正确关系,并且能够在这个示例中正确地在所有子路由之前添加 /manager

CLI 不遵循你的 tslint.json 设置。如果你已经正确配置了你的 VS Code 环境,并使用 prettier,那么当你在每个文件上工作时,或者在全局运行 prettier 命令时,你的代码样式偏好将被应用。

设计主页路由

请将以下模拟作为 LemonMart 的着陆体验考虑:

LemonMart 着陆体验

LocalCastWeather 应用程序不同,我们不希望所有这些标记都出现在 App 组件中。App 组件是整个应用程序的根元素;因此,它应该只包含在整个应用程序中始终出现的元素。在以下带注释的实例中,标记为 1 的工具栏将在整个应用程序中持续存在。

标记为 2 的区域将容纳主页组件,它本身将包含一个登陆用户控件,标记为 3:

LemonMart 布局结构

将默认或着陆组件作为 Angular 中的单独元素是最佳实践。这有助于减少必须在每个页面加载和执行的代码量,同时在利用路由器时也会产生更灵活的体系结构:

使用内联模板和样式生成 home 组件:

$ npx ng g c home -m app --inline-template --inline-style

现在,你已经准备好配置路由器。

设置默认路由

让我们开始为 LemonMart 设置一个简单的路由:

  1. 配置你的 home 路由:
src/app/app-routing.module.ts 
...
const routes: Routes = [
  { path: '', redirectTo: '/home', pathMatch: 'full' },
  { path: 'home', component: HomeComponent },
]
...

我们首先为 'home' 定义一个路径,并通过设置组件属性告知路由器渲染 HomeComponent。然后,我们将应用程序的默认路径 '' 重定向到 '/home'。通过设置 pathMatch 属性,我们始终确保这个非常特定的主页路由实例将作为着陆体验呈现。

  1. 创建一个带有内联模板的 pageNotFound 组件

  2. 配置 PageNotFoundComponent 的通配符路由:

src/app/app-routing.module.ts 
...
const routes: Routes = [
  ...
  { path: '**', component: PageNotFoundComponent }
]
...

这样,任何没有匹配的路由都将被重定向到 PageNotFoundComponent

RouterLink

当用户登陆到 PageNotFoundComponent 时,我们希望他们通过 RouterLink 方向重定向到 HomeComponent

  1. 实施内联模板以使用routerLink链接回主页:
src/app/page-not-found/page-not-found.component.ts
...
template: `
    <p>
      This page doesn't exist. Go back to <a routerLink="/home">home</a>.
    </p>
  `,
...

也可以通过<a href>标签实现此导航;但是,在更动态和复杂的导航场景中,您将失去诸如自动活动链接跟踪或动态链接生成等功能。

Angular 引导流程将确保AppComponent在您的index.html中的<app-root>元素内。但是,我们必须手动定义我们希望HomeComponent渲染的位置,以完成路由器配置。

路由器出口

AppComponent被视为app-routing.module中定义的根路由的根元素,这使我们能够在这个根元素中定义出口,以使用<router-outlet>元素动态加载我们希望的任何内容:

  1. 配置AppComponent以使用内联模板和样式

  2. 为您的应用程序添加工具栏

  3. 将您的应用程序名称添加为按钮链接,以便在点击时将用户带到主页

  4. 添加 <router-outlet> 以渲染内容:

src/app/app.component.ts
...
template: `
    <mat-toolbar color="primary">
      <a mat-button routerLink="/home"><h1>LemonMart</h1></a>
    </mat-toolbar>
    <router-outlet></router-outlet>
  `,

现在,主页的内容将在<router-outlet>内渲染。

品牌、自定义和 Material 图标

为构建一个吸引人且直观的工具栏,我们必须向应用程序引入一些图标和品牌,以便用户可以轻松地通过熟悉的图标在应用程序中进行导航。

品牌

在品牌方面,您应确保您的 Web 应用程序应具有自定义调色板,并与桌面和移动浏览器功能集成,以展示您的应用程序名称和图标。

调色板

使用 Material Color 工具选择一个调色板,如第十一章,使用 Angular Material 增强 Angular 应用程序 中所述。这是我为 LemonMart 选择的调色板:

https://material.io/color/#!/?view.left=0&view.right=0&primary.color=2E7D32&secondary.color=C6FF00

实现浏览器清单和图标

您需要确保浏览器在浏览器标签中显示正确的标题文本和图标。此外,应创建一个清单文件,为各种移动操作系统实现特定图标,这样,如果用户将您的网站置为书签,就会显示一个理想的图标,类似于手机上的其他应用图标。这将确保用户在手机设备的主屏幕上收藏或将您的 Web 应用程序置为书签时可以获得一个原生外观的应用程序图标:

  1. 创建或从设计师或网站(如www.flaticon.com)获取您网站的标志的 SVG 版本

  2. 在这种情况下,我将使用特定的柠檬图片:

LemonMart 的标志性标识

在使用互联网上找到的图像时,请注意适用的版权。在这种情况下,我已经购买了许可证以发布这个柠檬标志,但是您可以在以下 URL 获得您自己的副本,前提是您向图像的作者提供所需的归属声明:www.flaticon.com/free-icon/lemon_605070

  1. 使用realfavicongenerator.net等工具生成favicon.ico和清单文件

  2. 根据你的喜好调整 iOS、Android、Windows Phone、macOS 和 Safari 的设置

  3. 确保你设置版本号,网站图标在缓存方面可能让人头疼;一个随机的版本号将确保用户总是得到最新的版本

  4. 下载并解压生成的favicons.zip文件到你的src文件夹中

  5. 编辑angular.json文件以在你的应用程序中包括新资源:

angular.json   
"apps": [
  {
    ...
      "assets": [
        "src/assets",
        "src/favicon.ico",
        "src/android-chrome-192x192.png",
        "src/favicon-16x16.png",
        "src/mstile-310x150.png",
        "src/android-chrome-512x512.png",
        "src/favicon-32x32.png",
        "src/mstile-310x310.png",
        "src/apple-touch-icon.png",
        "src/manifest.json",
        "src/mstile-70x70.png",
        "src/browserconfig.xml",
        "src/mstile-144x144.png",
        "src/safari-pinned-tab.svg",
        "src/mstile-150x150.png"
      ]
  1. 将生成的代码插入到你的index.html<head>部分中:
src/index.html
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png?v=rMlKOnvxlK">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png?v=rMlKOnvxlK">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png?v=rMlKOnvxlK">
<link rel="manifest" href="/manifest.json?v=rMlKOnvxlK">
<link rel="mask-icon" href="/safari-pinned-tab.svg?v=rMlKOnvxlK" color="#b3ad2d">
<link rel="shortcut icon" href="/favicon.ico?v=rMlKOnvxlK">
<meta name="theme-color" content="#ffffff">
  1. 确保你的新网站图标正确显示

为了进一步发展你的品牌,考虑配置一个自定义的 Material 主题并利用material.io/color

自定义图标

现在,让我们在你的 Angular 应用程序中添加自定义的品牌。你需要用来创建网站图标的 svg 图标:

  1. 将图片放在src/app/assets/img/icons下,命名为lemon.svg

  2. HttpClientModule导入AppComponent,以便通过 HTTP 请求.svg文件

  3. 更新AppComponent以注册新的 svg 文件为图标:

src/app/app.component.ts import { DomSanitizer } from '@angular/platform-browser'
...
export class AppComponent {
  constructor(iconRegistry: MatIconRegistry, sanitizer: DomSanitizer) {
    iconRegistry.addSvgIcon(
      'lemon',
      sanitizer.bypassSecurityTrustResourceUrl('assets/img/icons/lemon.svg')
    )
  }
}
  1. 将图标添加到工具栏:
src/app/app.component.ts  
template: `
    <mat-toolbar color="primary">
      <mat-icon svgIcon="lemon"></mat-icon>
      <a mat-button routerLink="/home"><h1>LemonMart</h1></a>
    </mat-toolbar>
    <router-outlet></router-outlet>
  `,

现在让我们添加菜单、用户资料和退出的其余图标。

Material 图标

Angular Material 可以与 Material Design 图标直接使用,可以在你的index.html中作为 Web 字体引入你的应用程序。你可以自行托管这个字体;不过,如果选择这条路线,你也无法享受用户在访问其他网站时已经缓存了字体的好处,这就会导致浏览器在下载 42-56 KB 文件时节省速度和延迟。完整的图标列表可以在material.io/icons/找到。

现在让我们在工具栏上添加一些图标,并为主页设置一个最小的假登录按钮的模板:

  1. 确保 Material 图标的<link>标签已经添加到index.html中:
src/index.html
<head>
  ...
  <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>

如何自行托管的说明可以在google.github.io/material-design-icons/#getting-icons的自行托管部分找到。

配置完成后,使用 Material 图标很容易。

  1. 更新工具栏,使菜单按钮位于标题左侧。

  2. 添加一个fxFlex,使其余图标右对齐。

  3. 添加用户资料和退出图标:

src/app/app.component.ts    
template: `
    <mat-toolbar color="primary">
      <button mat-icon-button><mat-icon>menu</mat-icon></button>
      <mat-icon svgIcon="lemon"></mat-icon>
      <a mat-button routerLink="/home"><h1>LemonMart</h1></a>
      <span class="flex-spacer"></span>
      <button mat-icon-button><mat-icon>account_circle</mat-icon></button>
      <button mat-icon-button><mat-icon>lock_open</mat-icon></button>
    </mat-toolbar>
    <router-outlet></router-outlet>
  `,
  1. 为登录添加一个最小的模板:
src/app/home/home.component.ts 
  styles: [`
    div[fxLayout] {margin-top: 32px;}
  `],
  template: `
    <div fxLayout="column" fxLayoutAlign="center center">
      <span class="mat-display-2">Hello, Lemonite!</span>
      <button mat-raised-button color="primary">Login</button>
    </div>
  `

你的应用程序应该看起来与这个截图类似:

最小登录的 LemonMart

还有一些工作要做,就是在用户的认证状态下实现和显示/隐藏菜单、资料和退出图标。我们将在 Chapter 14 设计认证和授权 中介绍这个功能。现在你已经为你的应用程序设置了基本路由,需要学会如何调试你的 Angular 应用程序,然后再进行设置懒加载模块和子组件。

Angular Augury

Augury 是用于调试和分析 Angular 应用的 Chrome Dev Tools 扩展。这是一个专门为帮助开发人员直观地浏览组件树、检查路由器状态,并通过对开发人员编写的 TypeScript 代码和生成的 JavaScript 代码进行源映射来启用断点调试的工具。您可以从augury.angular.io下载 Augury。安装完成后,当您打开 Chrome Dev Tools 查看您的 Angular 应用时,您会注意到一个新的 Augury 选项卡,如下图所示:

图 3

Chrome Dev Tools Augury

Augury 在理解您的 Angular 应用在运行时的行为方面提供了有用且关键的信息:

  1. 当前的 Angular 版本列在此处,例如,版本为 5.1.2

  2. 组件树

  3. 路由器树显示了应用程序中已配置的所有路由

  4. NgModules 显示了应用程序的AppModule和子模块

组件树

“组件树”选项卡显示了所有应用程序组件的关系以及它们如何相互交互:

  1. 选择特定的组件,例如HomeComponent,如下所示:

图 2

Augury 组件树

右侧的“属性”标签页将显示一个名为“查看源代码”的链接,您可以使用它来调试您的组件。在下面更深的位置,您将能够观察到组件的属性状态,例如显示的登录布尔值,以及您注入到组件中的服务及其状态。

您可以通过双击值来更改任何属性的值。例如,如果您想将displayLogin的值更改为false,只需双击包含真值的蓝色框,并键入 false 即可。您将能够观察到您的更改对您的 Angular 应用的影响。

为了观察HomeComponent的运行时组件层次结构,您可以观察注入器图。

  1. 点击“注入器图”选项卡,如下所示:

图 1

Augury 注入器图

此视图展示了您选择的组件是如何渲染出来的。在这种情况下,我们可以观察到HomeComponent是在AppComponent内部渲染的。这种可视化对于追踪陌生代码库中特定组件的实现或存在深层组件树的情况非常有帮助。

断点调试

让我再次重申一下,console.log语句绝对不应该提交到你的代码库。一般来说,它们只会浪费你的时间,因为这需要编辑代码,之后还得清理你的代码。此外,Augury 已经提供了组件的状态,所以在简单的情况下,你应该能够利用它来观察或转换状态。

有些特定用例,console.log语句可能会很有用。这些大多是操作在并行运行且依赖及时用户交互的异步工作流。在这些情况下,控制台日志可以帮助您更好地理解事件流和各个组件之间的交互。

Augury 还不够复杂,无法解析异步数据或通过函数返回的数据。还有其他常见情况,您可能想观察属性状态在设置时的变化,甚至能够在运行时改变它们的值,以强制代码执行if-elseswitch语句的分支逻辑。对于这些情况,您应该使用断点调试。

假设HomeComponent上存在一些基本逻辑,根据从AuthService获取的isAuthenticated值设置displayLogin布尔值,如下所示:

src/app/home/home.component.ts
...
import { AuthService } from '../auth.service'
...
export class HomeComponent implements OnInit {
  displayLogin = true
  constructor(private authService: AuthService) {}

  ngOnInit() {
    this.displayLogin = !this.authService.isAuthenticated()
  }
}

现在观察displayLogin的值和isAuthenticated函数在设置时的状态,然后观察displayLogin值的变化:

  1. 点击HomeComponent上的查看源链接

  2. ngOnInit函数内的第一行上放一个断点

  3. 刷新页面

  4. Chrome Dev 工具将切换到源选项卡,您将看到断点命中并在此处以蓝色突出显示:

Chrome Dev 工具断点调试

  1. 悬停在this.displayLogin上,观察其值已设置为true

  2. 如果悬停在this.authService.isAuthenticated()上,您将无法观察其值

在断点命中时,您可以在控制台中访问当前范围的状态,这意味着您可以执行函数并观察其值。

  1. 在控制台中执行isAuthenticated()
> !this.authService.isAuthenticated()
true

您会观察到它返回true,这就是this.displayLogin的值。您仍然可以在控制台中强制displayLogin的值。

  1. displayLogin设置为false
> this.displayLogin = false
false

如果观察displayLogin的值,无论是悬停在上面还是从控制台检索,您会看到值被设置为false

利用断点调试基础知识,您可以在一点也不改变源代码的情况下调试复杂的场景。

路由树

路由树选项卡将显示路由器的当前状态。这可以是一个非常有用的工具,可以直观地展示路由和组件之间的关系,如下所示:

Augury 路由树

上述路由树展示了一个深套的路由结构,带有主细节视图。您可以通过点击圆形节点来看到呈现给定组件所需的绝对路径和参数。

正如您所看到的,对于PersonDetailsComponent,确定渲染这个主细节视图的一系列参数可能会变得复杂。

NgModules

NgModules 选项卡显示AppModule和当前加载到内存中的任何其他子模块:

  1. 启动应用的/home路由

  2. 观察 NgModules 标签,如下所示:

Augury NgModules

您会注意到仅加载了AppModule。然而,由于我们的应用程序采用了延迟加载的架构,我们的其他模块尚未被加载。

  1. 导航到ManagerModule中的一个页面

  2. 然后,导航到UserModule中的一个页面

  3. 最后,导航回/home路由

  4. 观察 NgModules 标签,如下所示:

带有三个模块的 Augury NgModules

  1. 现在,您会观察到已加载进内存的三个模块。

NgModules 是一个重要的工具,可以可视化设计和架构的影响。

具有延迟加载的子模块

懒加载允许由 webpack 提供支持的 Angular 构建流程将我们的 Web 应用程序分隔成不同的 JavaScript 文件,称为块。通过将应用程序的各部分分开为单独的子模块,我们允许这些模块及其依赖项捆绑到单独的块中,从而将初始 JavaScript 捆绑大小保持在最小限度。随着应用程序的增长,首次有意义的呈现时间保持不变,而不是随时间持续增加。懒加载对实现可扩展的应用程序架构至关重要。

现在我们将介绍如何设置带有组件和路由的子模块。我们还将使用 Augury 来观察我们不同路由配置的效果。

配置子模块的组件和路由

管理员模块需要一个着陆页面,如此示意图所示:

管理员仪表板

让我们先创建ManagerModule的主屏幕:

  1. 创建ManagerHome组件:
$ npx ng g c manager/managerHome -m manager -s -t

为了在manager文件夹下创建新组件,我们必须在组件名称前加上manager/前缀。另外,我们指定该组件应该被ManagerModule导入和声明。由于这是另一个着陆页,可能不够复杂需要额外的 HTML 和 CSS 文件。您可以使用--inline-style(别名-s)和/或--inline-template(别名-t)来避免创建额外的文件。

  1. 确认您的文件夹结构如下所示:
 /src
 ├───app
 │ │
 │ ├───manager
 │ │ │ manager-routing.module.ts
 │ │ │ manager.module.ts
 │ │ │
 │ │ └───manager-home
 │ │ manager-home.component.spec.ts
 │ │ manager-home.component.ts
  1. 使用manager-routing.module配置ManagerHome组件的路由,类似于我们如何使用app-route.module配置Home组件:
src/app/manager/manager-routing.module.ts
import { ManagerHomeComponent } from './manager-home/manager-home.component'
import { ManagerComponent } from './manager.component'

const routes: Routes = [
  {
    path: '',
    component: ManagerComponent,
    children: [
      { path: '', redirectTo: '/manager/home', pathMatch: 'full' },
      { path: 'home', component: ManagerHomeComponent },
    ],
  },
]

您会注意到http://localhost:5000/manager实际上并不解析为一个组件,因为我们的 Angular 应用程序不知道ManagerModule的存在。让我们首先尝试蛮力、饥饿加载的方法,导入manager.module并在我们的应用程序中注册管理器路由。

预加载

此部分纯粹是为了演示我们迄今学到的导入和注册路由的概念,并不会产生可扩展的解决方案,无论是急切加载还是懒加载组件:

  1. manager.module导入到app.module中:
 src/app/app.module.ts
 import { ManagerModule } from './manager/manager.module'
   ...
   imports: [
   ...
     ManagerModule,
   ]

你会发现http://localhost:5000/manager仍然不能渲染其主页组件。

  1. 使用 Augury 调试路由器状态,如图所示:

带有预加载的路由器树

  1. 看起来/manager路径在正确地注册并指向正确的组件ManagerHomeComponent。这里的问题是,在app-routing.module中配置的rootRouter没有意识到/manager路径,所以**路径占据优先地位,导致呈现PageNotFoundComponent

  2. 作为最后的练习,在app-routing.module中实现'manager'路径,并像平常一样将ManagerHomeComponent指定给它:

src/app/app-routing.module.ts
import { ManagerHomeComponent } from './manager/manager-home/manager-home.component'  
...
const routes: Routes = [
  ...
  { path: 'manager', component: ManagerHomeComponent },
  { path: '**', component: PageNotFoundComponent },
]

现在你会注意到http://localhost:5000/manager正确地渲染,显示manager-home works!;然而,如果通过 Augury 调试路由器状态,你会注意到/manager被注册了两次。

这个解决方案不太可扩展,因为它要求所有开发者维护一个单一的主文件来导入和配置每个模块。这会导致合并冲突和沮丧,希望团队成员不会多次注册相同的路由。

可以设计一种解决方案将模块分成多个文件。你可以在manager.module中实现 Route 数组并将其导出,而不是使用标准的*-routing.module。考虑以下示例:

example/manager/manager.module
export const managerModuleRoutes: Routes = [
  { path: '', component: ManagerHomeComponent }
]

然后这些文件需要逐个被导入到app-routing.module中,并且使用children属性进行配置:

example/app-routing.module
import { managerModuleRoutes } from './manager/manager.module'
...
{ path: 'manager', children: managerModuleRoutes },

这个解决方案能够运行,是一个正确的解决方案,就像 Augury Router 树所展示的那样:

带有子路由的路由器树

没有重复注册,因为我们删除了manager-routing.module。此外,我们不必在manager.module之外导入ManagerHomeComponent,从而得到一个更好的可扩展解决方案。然而,随着应用的增长,我们仍然必须在app.module中注册模块,并且子模块仍然与父app.module以可能不可预测的方式耦合。此外,这段代码无法被分块,因为使用 import 导入的任何代码都被视为硬依赖。

懒加载

现在你理解了模块的预加载是如何工作的,你将能更好地理解我们即将编写的代码,否则这些代码可能会看起来像黑魔法一样,并且神秘(也就是被误解的)代码总是导致混乱的架构。

我们现在将前面的预加载解决方案演变为懒加载的方式。为了从不同的模块加载路由,我们知道不能简单地导入它们,否则它们将被急切加载。答案就在于在app-routing.module.ts中配置路由时使用loadChildren属性,并提供字符串告知路由器如何加载子模块:

  1. 确保你打算懒加载的任何模块都被导入到app.module

  2. 移除ManagerModule中添加的任何路由

  3. 确保将ManagerRoutingModule导入到ManagerModule

  4. 实现或更新带有loadChildren属性的管理路径:

src/app/app-routing.module.ts
import {
  ...
  const routes: Routes = [
    ...
    { path: 'manager', loadChildren: './manager/manager.module#ManagerModule' },
    { path: '**', component: PageNotFoundComponent },
  ]
  ...

通过一个巧妙的技巧实现了惰性加载,避免使用import语句。定义了一个由两部分组成的字符串文字,其中第一部分定义了模块文件的位置,如app/manager/manager.module,第二部分定义了模块的类名。这样的字符串可以在构建过程和运行时进行解释,以动态创建块,加载正确的模块并实例化正确的类。ManagerModule然后就像它自己的 Angular 应用程序一样,管理着它的所有子依赖项和路由。

  1. 更新manager-routing.module路由,考虑到 manager 现在是它们的根路由:
src/app/manager/manager-routing.module.ts
const routes: Routes = [
  { path: '', redirectTo: '/manager/home', pathMatch: 'full' },
  { path: 'home', component: ManagerHomeComponent },
]

现在我们可以将ManagerHomeComponent的路由更新为更有意义的'home'路径。这个路径不会与app-routing.module中的路径冲突,因为在这个上下文中,'home'解析为'manager/home',同样地,当路径为空时,URL 将看起来像http://localhost:5000/manager

  1. 通过观察 Augury 确认惰性加载是否正常运行,如下所示:

通过惰性加载的路由树

ManagerHomeComponent的根节点现在被命名为manager [Lazy]

完成步行骨架

使用我们在本章早些时候为 LemonMart 创建的站点地图,我们需要完成应用的步行骨架导航体验。为了创建这种体验,我们需要创建一些按钮来链接所有模块和组件。我们将逐个模块进行:

  • 在开始之前,更新home.component上的登录按钮,将其链接到Manager模块:
src/app/home/home.component.ts
 ...
 <button mat-raised-button color="primary" routerLink="/manager">Login as Manager</button>
 ...

管理员模块

由于我们已经为ManagerModule启用了惰性加载,让我们继续完成它的其余导航元素。

在当前设置中,ManagerHomeComponentapp.component中定义的<router-outlet>中呈现,因此当用户从HomeComponent导航到ManagerHomeComponent时,app.component中实现的工具栏保持不变。如果我们实现一个类似的工具栏,使其在ManagerModule中保持不变,我们可以为跨模块导航子页面创建一个一致的用户体验。

为实现这一点,我们需要在app.componenthome/home.component之间复制父子关系,其中父级实现了工具栏和一个<router-outlet>,以便子元素可以在那里呈现。

  1. 首先创建基本的manager组件:
$ npx ng g c manager/manager -m manager --flat -s -t

--flat选项跳过目录创建,直接将组件放在manager文件夹下,就像app.component直接放在app文件夹下一样。

  1. 创建一个带有activeLink跟踪的导航工具栏:
src/app/manager/manager.component.ts
styles: [`
   div[fxLayout] {margin-top: 32px;}
   `, `
  .active-link {
    font-weight: bold;
    border-bottom: 2px solid #005005;
  }`
],
template: `
  <mat-toolbar color="accent">
    <a mat-button routerLink="/manager/home" routerLinkActive="active-link">Manager's Dashboard</a>
    <a mat-button routerLink="/manager/users" routerLinkActive="active-link">User Management</a>
    <a mat-button routerLink="/manager/receipts" routerLinkActive="active-link">Receipt Lookup</a>
  </mat-toolbar>
  <router-outlet></router-outlet>
`

必须注意,子模块不会自动访问父模块创建的服务或组件。这是为了保持解耦的架构的重要默认行为。然而,也有一些情况下希望分享一些代码。在这种情况下,mat-toolbar 需要重新导入。由于 MatToolbarModule 已经在 src/app/material.module.ts 中加载,我们只需要在 manager.module.ts 中导入这个模块,这样做不会带来性能或内存的损耗。

  1. ManagerComponent 应该被引入到 ManagerModule 中:
src/app/manager/manager.module.ts
import { MaterialModule } from '../material.module'
import { ManagerComponent } from './manager.component'
...
imports: [... MaterialModule, ManagerComponent],
  1. 为子页面创建组件:
$ npx ng g c manager/userManagement -m manager
$ npx ng g c manager/receiptLookup -m manager
  1. 创建父/子路由。我们知道我们需要以下路由才能导航到我们的子页面,如下:
example
{ path: '', redirectTo: '/manager/home', pathMatch: 'full' },
{ path: 'home', component: ManagerHomeComponent },
{ path: 'users', component: UserManagementComponent },
{ path: 'receipts', component: ReceiptLookupComponent },

为了定位到在 manager.component 中定义的 <router-outlet>,我们需要先创建父路由,然后为子页面指定路由:

src/app/manager/manager-routing.module.ts
...
const routes: Routes = [
  {
    path: '', component: ManagerComponent, children: [
      { path: '', redirectTo: '/manager/home', pathMatch: 'full' },
      { path: 'home', component: ManagerHomeComponent },
      { path: 'users', component: UserManagementComponent },
      { path: 'receipts', component: ReceiptLookupComponent },
    ]
  },
]

现在你应该能够浏览整个应用了。当你点击登录为管理者的按钮时,你将被带到这里显示的页面。可点击的目标被高亮显示,如下所示:

带有可点击目标高亮显示的 Manager's Dashboard

如果你点击 LemonMart,你将被带到主页。如果你点击 Manager's Dashboard,User Management 或 Receipt Lookup,你将被导航到相应的子页面,同时工具栏上的活动链接将是粗体和下划线。

用户模块

登录后,用户将能够通过侧边导航菜单访问他们的个人资料,并查看他们可以在 LemonMart 应用程序中访问的操作列表。在第十四章,设计认证和授权,当我们实现认证和授权时,我们将从服务器接收到用户的角色。根据用户的角色,我们将能够自动导航或限制用户可以看到的选项。我们将在这个模块中实现这些组件,以便它们只有在用户登录后才被加载。为了完成骨架层,我们将忽略与认证相关的问题:

  1. 创建必要的组件:
$ npx ng g c user/profile -m user
$ npx ng g c user/logout -m user -t -s
$ npx ng g c user/navigationMenu -m user -t -s
  1. 实现路由:

从在 app-routing 中实现延迟加载开始:

src/app/app-routing.module.ts
... 
 { path: 'user', loadChildren: 'app/user/user.module#UserModule' },

确保 PageNotFoundComponent 路由总是在 app-routing.module 中的最后一个路由。

现在在 user-routing 中实现子路由:

src/app/user/user-routing.module.ts
...
const routes: Routes = [
  { path: 'profile', component: ProfileComponent },
  { path: 'logout', component: LogoutComponent },
]

我们正在为 NavigationMenuComponent 实现路由,因为它将直接被用作 HTML 元素。另外,由于 userModule 没有一个登陆页面,没有默认路径定义。

  1. 连接用户和注销图标:
src/app/app.component.ts ...
<mat-toolbar>
  ...
  <button mat-mini-fab routerLink="/user/profile" matTooltip="Profile" aria-label="User Profile"><mat-icon>account_circle</mat-icon></button>
  <button mat-mini-fab routerLink="/user/logout" matTooltip="Logout" aria-label="Logout"><mat-icon>lock_open</mat-icon></button>
</mat-toolbar>

图标按钮可能难以理解,因此添加工具提示对它们是个好主意。为了使工具提示正常工作,切换到mat-mini-fab指令并确保在material.module中导入MatTooltipModule,此外,确保为只有图标的按钮添加aria-label,这样依赖于屏幕阅读器的残障用户仍然能够浏览您的 Web 应用。

  1. 确保应用程序正常工作。

您会注意到两个按钮离得太近,如下所示:

带图标的工具栏

  1. 您可以通过在<mat-toolbar>中添加fxLayoutGap="8px"来解决图标布局问题;但是现在柠檬标识离应用名称太远了,如下所示:

带填充图标的工具栏

  1. 通过合并图标和按钮来解决标识布局问题:
src/app/app.component.ts ...<mat-toolbar>  ...
  <a mat-icon-button routerLink="/home"><mat-icon svgIcon="lemon"></mat-icon><span class="mat-h2">LemonMart</span></a>
  ...
</mat-toolbar>

如下截图所示,分组修复了布局问题:

带有分组和填充元素的工具栏

从用户体验的角度来看这更加理想;现在用户可以通过点击柠檬回到主页。

POS 和库存模块

我们的基本架构假定经理的角色。为了能够访问我们即将创建的所有组件,我们需要使经理能够访问 POS 和库存模块。

使用两个新按钮更新ManagerComponent

src/app/manager/manager.component.ts
<mat-toolbar color="accent" fxLayoutGap="8px">
  ...
  <span class="flex-spacer"></span>
  <button mat-mini-fab routerLink="/inventory" matTooltip="Inventory" aria-label="Inventory"><mat-icon>list</mat-icon></button>
  <button mat-mini-fab routerLink="/pos" matTooltip="POS" aria-label="POS"><mat-icon>shopping_cart</mat-icon></button>
</mat-toolbar>

请注意,这些路由链接将导航我们离开ManagerModule,因此工具栏消失是正常的。

现在,由您来实现最后的两个模块。

POS 模块

POS 模块与用户模块非常相似,除了PosComponent将成为默认路由。这将是一个复杂的组件,带有一些子组件,因此确保它是通过目录创建的:

  1. 创建PosComponent

  2. 注册PosComponent作为默认路由

  3. 配置PosModule的懒加载

  4. 确保应用程序正常工作

库存模块

库存模块与ManagerModule非常相似,如下所示:

库存仪表板模拟

  1. 创建基本的Inventory组件

  2. 注册MaterialModule

  3. 创建库存仪表板、库存录入、产品和类别组件

  4. inventory-routing.module中配置父子路由

  5. 配置InventoryModule的懒加载

  6. 确保应用程序正常工作,如下所示:

LemonMart 库存仪表板

现在应用程序的基本架构已经完成,检查路由树以确保懒加载已正确配置,并且模块不会被意外急加载是非常重要的。

检查路由树

转到应用程序的基本路由,并使用 Augury 检查路由树,如图所示:

路由树与急加载错误

一切,除了最初需要的组件,应该带有[Lazy]属性。 如果由于某种原因,路由未带有[Lazy]标记,那么它们可能被错误地导入到app.module或其他某个组件中。

在上述截图中,您可能会注意到ProfileComponentLogoutComponent是急加载的,而user模块正确地标记为[Lazy]。 即使通过工具和代码基础进行多次视觉检查,也可能让您一直寻找问题所在。 但是,如果全局搜索UserModule,您将很快发现它正在被导入到app.module中。

为了保险起见,请确保删除app.module中的任何模块导入语句,您的文件应该像下面这样:

src/app/app.module.ts
import { FlexLayoutModule } from '@angular/flex-layout'
import { BrowserModule } from '@angular/platform-browser'
import { NgModule } from '@angular/core'

import { AppRoutingModule } from './app-routing.module'
import { AppComponent } from './app.component'
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
import { MaterialModule } from './material.module'
import { HomeComponent } from './home/home.component'
import { PageNotFoundComponent } from './page-not-found/page-not-found.component'
import { HttpClientModule } from '@angular/common/http'

@NgModule({
  declarations: [AppComponent, HomeComponent, PageNotFoundComponent],
  imports: [
    BrowserModule,
    AppRoutingModule,
    BrowserAnimationsModule,
    MaterialModule,
    HttpClientModule,
    FlexLayoutModule,
  ],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}

下一张截图展示了更正后的路由树:

带有延迟加载的路由树

在继续前进之前,请确保npm testnpm run e2e执行时没有错误。

通用测试模块

现在我们有大量的模块要处理,为每个规范文件单独配置导入和提供者变得乏味。 为此,我建议创建一个通用的测试模块,其中包含可以在整个项目中重用的通用配置。

首先创建一个新的.ts文件。

  1. 创建common/common.testing.ts

  2. 使用常见的测试提供程序、伪造品和模块填充,如下所示:

我提供了ObservableMediaMatIconRegistryDomSanitizer的伪造实现,以及commonTestingProviderscommonTestingModules的数组。

src/app/common/common.testing.ts
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { MediaChange } from '@angular/flex-layout'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { SafeResourceUrl, SafeValue } from '@angular/platform-browser'
import { NoopAnimationsModule } from '@angular/platform-browser/animations'
// tslint:disable-next-line:max-line-length
import { SecurityContext } from '@angular/platform-browser/src/security/dom_sanitization_service'
import { RouterTestingModule } from '@angular/router/testing'
import { Observable, Subscription, of } from 'rxjs'
import { MaterialModule } from '../material.module'

const FAKE_SVGS = {
  lemon: '<svg><path id="lemon" name="lemon"></path></svg>',
}

export class ObservableMediaFake {
  isActive(query: string): boolean {
    return false
  }

  asObservable(): Observable<MediaChange> {
    return of({} as MediaChange)
  }

  subscribe(
    next?: (value: MediaChange) => void,
    error?: (error: any) => void,
    complete?: () => void
  ): Subscription {
    return new Subscription()
  }
}

export class MatIconRegistryFake {
  _document = document
  addSvgIcon(iconName: string, url: SafeResourceUrl): this {
    // this.addSvgIcon('lemon', 'lemon.svg')
    return this
  }

  getNamedSvgIcon(name: string, namespace: string = ''): Observable<SVGElement> {
    return of(this._svgElementFromString(FAKE_SVGS.lemon))
  }

  private _svgElementFromString(str: string): SVGElement {
    if (this._document || typeof document !== 'undefined') {
      const div = (this._document || document).createElement('DIV')
      div.innerHTML = str
      const svg = div.querySelector('svg') as SVGElement
      if (!svg) {
        throw Error('<svg> tag not found')
      }
      return svg
    }
  }
}

export class DomSanitizerFake {
  bypassSecurityTrustResourceUrl(url: string): SafeResourceUrl {
    return {} as SafeResourceUrl
  }
  sanitize(context: SecurityContext, value: SafeValue | string | null): string | null {
    return value ? value.toString() : null
  }
}

export const commonTestingProviders: any[] = [
  // intentionally left blank
]

export const commonTestingModules: any[] = [
  FormsModule,
  ReactiveFormsModule,
  MaterialModule,
  NoopAnimationsModule,
  HttpClientTestingModule,
  RouterTestingModule,
]

现在让我们看看如何使用共享配置文件的示例:

src/app/app.component.spec.ts import { commonTestingModules,
 commonTestingProviders,
 MatIconRegistryFake,
 DomSanitizerFake,
 ObservableMediaFake,
} from './common/common.testing'
import { ObservableMedia } from '@angular/flex-layout'
import { MatIconRegistry } from '@angular/material'
import { DomSanitizer } from '@angular/platform-browser'

...
TestBed.configureTestingModule({
      imports: commonTestingModules,
      providers: commonTestingProviders.concat([
        { provide: ObservableMedia, useClass: ObservableMediaFake },
        { provide: MatIconRegistry, useClass: MatIconRegistryFake },
        { provide: DomSanitizer, useClass: DomSanitizerFake },
      ]),
      declarations: [AppComponent],
...

大多数其他模块只需导入commonTestingModules即可。

在所有测试通过之前,请不要继续前进!

总结

在本章中,您学会了如何有效地使用 Angular CLI 创建主要的 Angular 组件和脚手架。 您创建了应用程序的品牌,利用自定义和内置 Material 图标。 您学会了如何使用 Augury 调试复杂的 Angular 应用程序。 最后,您开始构建基于路由的应用程序,及早定义用户角色,设计时考虑懒加载,并及早确定行为骨架导航体验。

总结一下,为了完成基于路由的实现,您需要执行以下操作:

  1. 早期定义用户角色

  2. 设计时考虑懒加载

  3. 实现行为骨架导航体验

  4. 围绕主要数据组件进行设计

  5. 强制执行解耦的组件架构

  6. 区分用户控件和组件

  7. 最大化代码重用

在本章中,您执行了步骤 1-3;在接下来的三章中,您将执行步骤 4-7。在第十三章中,持续集成和 API 设计,我们将讨论围绕主要数据组件进行设计,并实现持续集成以确保高质量的交付。在第十四章中,设计身份验证和授权,我们将深入探讨安全考虑因素,并设计有条件的导航体验。在第十五章中,Angular 应用设计和配方,我们将通过坚持解耦组件架构,巧妙选择创建用户控件与组件,并利用各种 TypeScript、RxJS 和 Angular 编码技术来最大程度地重用代码,将所有内容紧密结合在一起。

第十三章:持续集成和 API 设计

在我们开始为我们的 LOB 应用 LemonMart 构建更复杂的功能之前,我们需要确保我们每次代码推送都有通过的测试,并遵守编码标准,并且是团队成员可以运行测试的可执行构件,因为我们将继续进一步开发我们的应用。同时,我们还需要开始思考我们的应用将如何与后端服务器通信。无论是你、你的团队还是另一个团队将要创建新的 API,都很重要的是,API 设计要满足前端和后端架构的需求。为了确保一个顺畅的开发过程,需要一个强大的机制来创建一个可访问的、实时的 API 文档。持续集成CI)可以解决第一个问题,而 Swagger 完美地解决了 API 设计、文档和测试的需求。

持续集成(Continuous Integration)对确保每次代码推送都能交付高质量的成果至关重要,它通过在每次代码推送时构建和执行测试来实现。建立一个 CI 环境可能会耗费很多时间,并需要对所使用的工具有专门的了解。CircleCI 是一个成熟的云端持续集成服务,提供免费的服务套餐和相关文章,帮助你以尽可能少的配置开始使用。我们将介绍一种基于 Docker 的方法,它可以在大多数 CI 服务上运行,让你的特定配置知识保持有效,并将 CI 服务知识降到最低限度。

全栈开发的另一个方面是你可能会同时开发应用的前端和后端。无论你是自己工作,还是和一个团队或多个团队一起工作,建立一个数据契约很关键,以确保你不会在最后关头遇到集成问题。我们将使用 Swagger 来定义 REST API 的数据契约,然后创建一个模拟服务器,你的 Angular 应用可以向它发起 HTTP 请求。对于后端开发来说,Swagger 可以作为生成样板代码的绝佳起点,并且可以继续作为 API 的实时文档和测试 UI。

在本章中,你将学习以下内容:

  • 与 CircleCI 一起使用持续集成

  • 使用 Swagger 进行 API 设计

本章需要以下内容:

  • 免费的 CircleCI 账户

  • Docker

持续集成

持续集成的目的是为了能够在每次代码推送时创建一个一致且可重复的环境,以构建、测试并生成可部署的应用构件。在推送代码之前,开发者应该合理地期望他们的构建会通过;因此,创建一个可靠的 CI 环境,自动化开发者也可以在本地机器上运行的命令,是至关重要的。

容器化构建环境

为了确保在各种 OS 平台、开发者机器和持续集成环境中保持一致的构建环境,你可以对你的构建环境进行容器化。请注意,目前至少有六种常用的 CI 工具。学习每个工具的细节几乎是不可能完成的任务。你的构建环境的容器化是一个超出当前 CI 工具预期的先进概念。然而,容器化是标准化你的 90% 以上的构建基础设施的绝佳方式,几乎可以在任何 CI 环境中执行。通过这种方法,你学到的技能和你创建的构建配置变得更有价值,因为你的知识和你创建的工具都变得可传递和可重用。

有许多策略可以使你的构建环境容器化,具有不同的粒度和性能预期。为了本书的目的,我们将专注于可重用性和易用性。与其创建一组复杂的相互依赖的 Docker 镜像,可能会允许更有效的失败优先和恢复路径,我们将专注于单一和简单的工作流程。较新版本的 Docker 具有一个很棒的功能,称为多阶段构建,它允许你以易于阅读的方式定义一个多镜像过程,并维护一个单一的Dockerfile

在整个过程结束时,你可以提取一个优化的容器镜像作为我们的交付产品,摆脱了先前过程中使用的镜像的复杂性。

作为提醒,你的单一Dockerfile看起来像下面的样例:

Dockerfile
FROM duluca/minimal-node-web-server:8.11.1
WORKDIR /usr/src/app
COPY dist public

多阶段构建通过在单个Dockerfile中使用多个FROM语句来实现,每个阶段可以执行任务并使其实例内的任何资源可用于其他阶段。在构建环境中,我们可以将各种与构建相关的任务实现为它们自己的阶段,然后将最终结果,如 Angular 构建的dist文件夹,复制到包含 Web 服务器的最终镜像中。在这种情况下,我们将实现三个镜像阶段:

  • 构建器:用于构建你的 Angular 应用的生产版本。

  • 测试程序:用于对一个无界面 Chrome 实例执行单元测试和 e2e 测试

  • Web 服务器:最终结果只包含了优化过的生产要素

多阶段构建需要 Docker 版本 17.05 或更高版本。要了解更多关于多阶段构建的信息,请阅读文档:docs.docker.com/develop/develop-images/multistage-build/

首先,创建一个新的文件来实现多阶段配置,命名为Dockerfile.integration,放在项目的根目录。

构建器

第一个阶段是builder。我们需要一个轻量级的构建环境,可以确保统一的构建结果。为此,我创建了一个示例的 Alpine-based Node 构建环境,完整包含了 npm、bash 和 git 工具。关于为何我们使用 Alpine 和 Node 的更多信息,请参考第十章,准备 Angular 应用程序进行生产发布使用 Docker 容器化应用程序部分:

  1. 实现一个新的 npm 脚本来构建你的 Angular 应用程序:
"scripts": {
  "build:prod": "ng build --prod",
}
  1. 继承自基于 Node.js 的构建环境,比如node:10.1duluca/minimal-node-build-env:8.11.2

  2. 实现你特定环境的构建脚本,如下所示:

请注意,在出版时,低级 npm 工具中的一个 bug 阻止了基于node镜像成功安装 Angular 依赖项。这意味着下面的示例Dockerfile基于较旧版本的 Node 和 npm,使用了duluca/minimal-node-build-env:8.9.4。在将来,当 bug 得到解决后,更新的构建环境将能够利用npm ci来安装依赖项,相比npm install命令将带来显著的速度提升。

Dockerfile.integration
FROM duluca/minimal-node-build-env:8.9.4 as builder

# project variables
ENV SRC_DIR /usr/src
ENV GIT_REPO https://github.com/duluca/lemon-mart.git
ENV SRC_CODE_LOCATION .
ENV BUILD_SCRIPT build:prod

# get source code
RUN mkdir -p $SRC_DIR
WORKDIR $SRC_DIR
# if necessary, do SSH setup here or copy source code from local or CI environment
RUN git clone $GIT_REPO .
# COPY $SRC_CODE_LOCATION .

RUN npm install
RUN npm run $BUILD_SCRIPT

在上面的示例中,容器正在从 GitHub 拉取源代码。我选择这样做是为了保持示例的简单性,因为它在本地和远程持续集成环境中都起作用。然而,你的 CI 服务器已经有了源代码的副本,你需要从你的 CI 环境中复制然后在容器中使用。

COPY $SRC_CODE_LOCATION .命令替换RUN git clone $GIT_REPO .命令,从你的 CI 服务器或本地机器复制源代码。如果这么做,你需要实现一个.dockerignore文件,与你的.gitignore文件相似,以确保不泄露秘密,不复制node_modules,并且配置在其他环境中是可重复的。在 CI 环境中,你需要重写环境变量 $SRC_CODE_LOCATION,使得COPY命令的源目录是正确的。随意创建适合各种需求的多个版本的Dockerfile

另外,我建立了一个基于node-alpine的最小 Node 构建环境duluca/minimal-node-build-env,你可以在 Docker Hub 上查看 hub.docker.com/r/duluca/minimal-node-build-env。这个镜像比node小约十倍。Docker 镜像的大小对构建时间有实质影响,因为 CI 服务器或团队成员需要额外时间拉取较大的镜像。选择最适合你需求的环境。

调试构建环境

根据你的特定需求,构建Dockerfile的初始设置可能会让人沮丧。为了测试新命令或调试错误,你可能需要直接与构建环境交互。

要在构建环境中进行交互实验和/或调试,执行以下操作:

$ docker run -it duluca/minimal-node-build-env:8.9.4 /bin/bash

您可以在这个临时环境中测试或调试命令,然后将它们加入到您的Dockerfile中。

测试员

第二阶段是tester。默认情况下,Angular CLI 生成的测试要求是针对开发环境的。这在持续集成环境中不起作用;我们必须配置 Angular 以针对一个无需 GPU 辅助执行的无头浏览器以及进一步的容器化环境来执行测试。

为 Angular 配置一个无头浏览器

protractor 测试工具正式支持在无头模式下运行 Chrome。为了在持续集成环境中执行 Angular 测试,您需要配置您的测试运行器 Karma 使用一个无头 Chrome 实例来运行:

  1. 更新karma.conf.js以包含新的无头浏览器选项:
src/karma.conf.js
...
browsers: ['Chrome', 'ChromiumHeadless', 'ChromiumNoSandbox'],
customLaunchers: {
  ChromiumHeadless: {
        base: 'Chrome',
        flags: [
          '--headless',
          '--disable-gpu',
          // Without a remote debugging port, Google Chrome exits immediately.
          '--remote-debugging-port=9222',
        ],
        debug: true,
      },
      ChromiumNoSandbox: {
        base: 'ChromiumHeadless',
        flags: ['--no-sandbox', '--disable-translate', '--disable-extensions']
      }
    },

ChromiumNoSandbox自定义启动器封装了所有需要的配置元素,以获得一个良好的默认设置。

  1. 更新protractor配置以在无头模式下运行:
e2e/protractor.conf.js
...
  capabilities: {
    browserName: 'chrome',
    chromeOptions: {
      args: [
        '--headless',
        '--disable-gpu',
        '--no-sandbox',
        '--disable-translate',
        '--disable-extensions',
        '--window-size=800,600',
      ],
    },
  },
...

为了测试应用程序的响应场景,您可以使用--window-size选项,如前所示,来更改浏览器设置。

  1. 更新package.json脚本以在生产构建场景中选择新的浏览器选项:
package.json
"scripts": {
  ...
  "test:prod": "npm test -- --watch=false"
  ...
}

请注意,test:prod不包括npm run e2e。e2e 测试是需要更长时间来执行的集成测试,所以在包含它们作为关键构建流水线的一部分时三思而后行。e2e 测试不会在下一节提到的轻量级测试环境中运行,因此它们需要更多的资源和时间来执行。

配置测试环境

对于轻量级的测试环境,我们将利用基于 Alpine 的 Chromium 浏览器安装:

  1. 继承自slapers/alpine-node-chromium

  2. Docker.integration后追加以下配置:

Docker.integration
...
FROM slapers/alpine-node-chromium as tester
ENV BUILDER_SRC_DIR /usr/src
ENV SRC_DIR /usr/src
ENV TEST_SCRIPT test:prod

RUN mkdir -p $SRC_DIR
WORKDIR $SRC_DIR

COPY --from=builder $BUILDER_SRC_DIR $SRC_DIR

CMD 'npm run $TEST_SCRIPT'

上述脚本将从builder阶段复制生产构建,并以可预测的方式执行您的测试脚本。

Web 服务器

第三和最后阶段生成将成为您 web 服务器的容器。一旦此阶段完成,之前的阶段将被丢弃,最终结果将是一个优化的小于 10MB 的容器:

  1. 使用 Docker 将您的应用程序容器化

  2. 在文件末尾附加FROM语句

  3. builder中复制生产就绪的代码,如下所示:

Docker.integration
...
FROM duluca/minimal-nginx-web-server:1.13.8-alpine
ENV BUILDER_SRC_DIR /usr/src
COPY --from=builder $BUILDER_SRC_DIR/dist /var/www
CMD 'nginx'
  1. 构建并测试您的多阶段Dockerfile
$ docker build -f Dockerfile.integration .

如果您从 GitHub 拉取代码,请确保在构建容器之前提交并推送代码,因为容器将直接从存储库中拉取源代码。使用--no-cache选项确保拉取新的源代码。如果您从本地或 CI 环境复制代码,则不要使用--no-cache,因为您不能从能够重复使用先前构建的容器层中获得速度提升。

  1. 将您的脚本另存为名为build:ci的新 npm 脚本,如下所示:
package.json
"scripts": {
  ...
  "build:ci": "docker build -f Dockerfile.integration . -t $npm_package_config_imageRepo:latest",
  ...
}

CircleCI

CircleCI 易于上手,free tier 很棒,对初学者和专业人士都有很好的文档。如果您有独特的企业需求,CircleCI 可以就地部署在企业防火墙后或云端的私有部署中。

CircleCI 为免费设置提供了预制的构建环境,但它也可以使用 Docker 容器运行构建,使其成为一个可以根据用户技能和需求进行扩展的解决方案,如"容器化构建环境"部分所述:

  1. circleci.com/ 创建一个 CircleCI 账户

  2. 使用 GitHub 注册:

CircleCI 注册页面

  1. 添加一个新项目:

CircleCI 项目页面

在下一个屏幕上,你有选择 Linux 或 macOS 构建环境的选项。macOS 构建环境非常适合构建 iOS 或 macOS 应用程序。但是,这些环境没有免费层;只有 1x 并行的 Linux 实例是免费的。

  1. 搜索 lemon-mart 并单击"设置项目"。

  2. 选择 Linux

  3. 选择平台 2.0

  4. 由于我们将使用自定义容器化构建环境,因此将语言选为"其他"

  5. 在您的源代码中,创建一个名为.circleci的文件夹,并添加一个名为config.yml的文件:

.circleci/config.yml
version: 2
jobs:
  build:
    docker:
      - image: docker:17.12.0-ce-git
    working_directory: /usr/src
    steps:
      - checkout
      - setup_remote_docker:
          docker_layer_caching: false
      - run:
          name: Build Docker Image
          command: |
            npm run build:ci

在前面的文件中,定义了一个基于 CircleCI 预构建的docker:17.12.0-ce-git镜像的build作业,该镜像包含 Docker 和 git CLI 工具。然后我们定义构建步骤,它使用checkout从 GitHub 检出源代码,使用setup_remote_docker命令通知 CircleCI 设置一个 Docker-within-Docker 环境,然后执行docker build -f Dockerfile.integration .命令启动我们的自定义构建过程。

为了优化构建,您应该尝试使用层缓存并从 CircleCI 中已经检出的源代码复制源代码。

  1. 将更改同步到 GitHub

  2. 在 CircleCI 上,单击创建您的项目

如果一切顺利,您将会有一个通过绿色构建。如下图所示,构建#4 成功了:

CircleCI 上的绿色构建

目前,CI 服务器正在运行,在第 1 阶段构建应用程序,然后在第 2 阶段运行测试,最后在第 3 阶段构建 Web 服务器。请注意,我们并没有对这个 Web 服务器容器镜像做任何事情,比如将其部署到服务器。

为了部署您的镜像,您需要实现一个部署步骤。在这一步中,您可以将其部署到多个目标,如 Docker Hub、Zeit Now、Heroku 或 AWS ECS。与这些目标的集成将涉及多个步骤。从整体上看,这些步骤如下:

  1. 使用单独的运行步骤安装面向目标的 CLI 工具

  2. 使用针对目标环境的登录凭据配置 Docker,并将这些凭据存储为 CircleCI 环境变量

  3. 使用docker push将生成的 Web 服务器镜像提交到目标的 Docker 注册表

  4. 执行平台特定的deploy命令,指示目标运行刚刚推送的 Docker 镜像。

如何从本地开发环境在 AWS ECS 上配置此类部署的示例在第十六章中有涵盖,AWS 上高可用的云基础设施

代码覆盖率报告

了解你的 Angular 项目的单元测试覆盖量和趋势的一个好方法是通过代码覆盖率报告。为了为你的应用程序生成报告,从项目文件夹执行以下命令:

$ npx ng test --browsers ChromiumNoSandbox --watch=false --code-coverage

结果报告将以 HTML 形式创建在名为覆盖率的文件夹下;执行以下命令在浏览器中查看:

$ npx http-server -c-1 -o -p 9875 ./coverage

这是istanbul.js为 LemonMart 生成的文件夹级样本覆盖报告:

LemonMart 的伊斯坦布尔代码覆盖率报告

你可以进一步深入了解特定文件夹,比如src/app/auth,并获得一个文件级报告,就像这样:

LemonMart 的 src/app/auth 的伊斯坦布尔代码覆盖率报告

你还可以进一步深入了解给定文件,比如cache.service.ts的行级覆盖率,就像这样:

Istanbul 缓存服务代码覆盖率报告

在上面的图像中,您可以看到第 5,12,17-18 和 21-22 行没有被任何测试覆盖。图标表示 if 路径未被采用。我们可以通过实现练习中包含在CacheService中的函数的单元测试来增加我们的代码覆盖率。作为练习,读者应尝试至少用一个新的单元测试覆盖这些功能之一,并观察代码覆盖率报告的变化。

理想情况下,您的 CI 服务器配置应该在每次测试运行时以一种容易访问的方式生成和托管代码覆盖率报告。在package.json中将这些命令作为脚本实现,并在 CI 流程中执行。这个配置留作读者的练习。

http-server作为项目的开发依赖项安装到您的项目中。

API 设计

在全栈开发中,早期确定 API 设计是很重要的。API 设计本身与数据契约的外观密切相关。您可以创建 RESTful 端点或使用下一代 GraphQL 技术。在设计 API 时,前端和后端开发人员应密切合作,以实现共享的设计目标。一些高层目标如下:

  • 最小化客户端与服务器之间传输的数据

  • 坚持成熟的设计模式(即分页)

  • 设计以减少客户端中存在的业务逻辑

  • 展平数据结构

  • 不要暴露数据库键或关系

  • 从一开始就提供版本端点

  • 围绕主要数据组件进行设计

很重要的是不要重复造轮子,并且要严格、甚至严格地设计你的 API。API 设计的错误后果在应用程序上线后可能会产生深远的影响,并且不可能再进行修正。

我将详细介绍如何围绕主要数据组件进行设计,并实现一个示例的 Swagger 端点。

围绕主要数据组件进行设计

它有助于围绕主要数据组件组织你的 API。这将粗略地匹配你在 Angular 应用程序中不同组件中消耗数据的方式。我们将首先通过创建一个粗略的数据实体图来定义我们的主要数据组件,然后使用 Swagger 实现用户数据实体的示例 API。

定义实体

让我们首先试着确定你想要储存的实体是什么,并思考这些实体如何相互关联。

这是一个使用draw.io创建的 LemonMart 的示例设计:

LemonMart 的数据实体图

此时,你的实体是存储在 SQL 还是 NoSQL 数据库中无关紧要。我的建议是坚持你所知道的,但如果你从头开始,像 MongoDB 这样的 NoSQL 数据库将在你的实现和需求演变时提供最大灵活性。

大致来说,你需要为每个实体进行 CRUD API。你可以使用 Swagger 来设计你的 API。

Swagger

Swagger 将允许你设计你的 web API。对于团队来说,它可以充当前端和后端团队之间的接口。此外,通过 API mocking,你可以在实现 API 之前开发和完成 API 功能。

随着我们的进行,我们将实现一个示例用户 API,以演示 Swagger 的工作方式。

该示例项目带有用于 VS Code 的推荐扩展程序。Swagger Viewer 允许我们在不运行任何额外工具的情况下预览 YAML 文件。

定义一个 Swagger YAML 文件

Swagger 规范的最广泛使用和支持版本是swagger: '2.0'。以下示例使用了新的、基于标准的openapi: 3.0.0。示例代码仓库包含了这两个示例。然而,在发布时,Swagger 生态系统中的大多数工具依赖于版本 2.0。

示例代码仓库可以在github.com/duluca/lemon-mart-swagger-server找到。

对于你的模拟 API 服务器,你应该创建一个单独的 git 仓库,这样前端和后端之间的这个约定可以被分开维护。

  1. 创建一个名为lemon-mart-swagger-server的新 GitHub 仓库。

  2. 开始定义一个带有通用信息和目标服务器的 YAML 文件:

swagger.oas3.yaml
openapi: 3.0.0
info:
  title: LemonMart
  description: LemonMart API
  version: "1.0.0"

servers:
  - url: http://localhost:3000
    description: Local environment
  - url: https://mystagingserver.com/v1
    description: Staging environment
  - url: https://myprodserver.com/v1
    description: Production environment
  1. components下,定义共享的数据schemas
swagger.oas3.yaml
...
components:
  schemas: 
    Role:
      type: string
      enum: [clerk, cashier, manager]
    Name:
      type: object
      properties:
        first:
          type: string
        middle:
          type: string
        last:
          type: string
    User:
      type: object
      properties:
        id:
          type: string
        email:
          type: string
        name:
          $ref: '#/components/schemas/Name'
        picture:
          type: string
        role:
          $ref: '#/components/schemas/Role'
        userStatus:
          type: boolean
        lastModified:
          type: string
          format: date
        lastModifiedBy:
          type: string
    Users:
      type: object
      properties:
        total:
          type: number
          format: int32
      items:
        $ref: '#/components/schemas/ArrayOfUser'
    ArrayOfUser:
      type: array
      items:
            $ref: '#/components/schemas/User'
  1. components下,添加共享的parameters,使得重用常见模式像分页端点变得容易:
swagger.oas3.yaml
...
  parameters:
    offsetParam: # <-- Arbitrary name for the definition that will be used to refer to it.
                  # Not necessarily the same as the parameter name.
      in: query
      name: offset
      required: false
      schema:
        type: integer
        minimum: 0
      description: The number of items to skip before starting to collect the result set.
    limitParam:
      in: query
      name: limit
      required: false
      schema:
        type: integer
        minimum: 1
        maximum: 50
        default: 20
      description: The numbers of items to return.
  1. paths下,为/users路径定义一个get端点:
...
paths:
  /users:
    get:
      description: |
        Searches and returns `User` objects.
        Optional query params determines values of returned array
      parameters:
        - in: query
          name: search
          required: false
          schema:
            type: string
          description: Search text
        - $ref: '#/components/parameters/offsetParam'
        - $ref: '#/components/parameters/limitParam'
      responses:
        '200': # Response
          description: OK
          content: # Response body
            application/json: # Media type
              schema:
                $ref: '#/components/schemas/Users'
  1. paths下,添加通过 IDget用户和通过 IDupdate用户的端点:
swagger.oas3.yaml
...
  /user/{id}:
    get:
      description: Gets a `User` object by id
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
          description: User's unique id
      responses:
         '200': # Response
            description: OK
            content: # Response body
              application/json: # Media type
                schema:
                  $ref: '#/components/schemas/User'
    put:
      description: Updates a `User` object given id
      parameters:
        - in: query
          name: id
          required: true
          schema:
            type: string
          description: User's unique id
        - in: body
          name: userData
          schema:
            $ref: '#/components/schemas/User'
          style: form
          explode: false
          description: Updated user object
      responses:
        '200':
          description: OK
          content: # Response body
              application/json: # Media type
                schema:
                  $ref: '#/components/schemas/User'

要验证您的 Swagger 文件,您可以使用editor.swagger.io上的在线编辑器。

注意使用了 style: formexplode: false,这是配置预期接收基本表单数据的端点的最简单方式。要获取更多参数序列化选项或模拟认证端点和一系列其他可能的配置,请参考swagger.io/docs/specification/上的文档。

创建 Swagger 服务器

使用您的 YAML 文件,您可以使用 Swagger Code Gen 工具生成一个模拟的 Node.js 服务器。

使用非官方工具的 OpenAPI 3.0

如前一部分所述,此部分将使用 YAML 文件的版本 2,该版本可以使用官方工具生成服务器。然而,还有其他工具可以生成一些代码,但不够完整以便易于使用:

  1. 如果在项目文件夹上使用 OpenAPI 3.0,请执行以下命令:
$ npx swagger-node-codegen swagger.oas3.yaml -o ./server
...
Done! 
Check out your shiny new API at C:\dev\lemon-mart-swagger-server\server.

在一个名为 server 的新文件夹下,现在应该有一个生成的 Node Express 服务器。

  1. 为服务器安装依赖项:
$ cd server
$ npm install

然后必须手动实现缺失的存根以完成服务器的实现。

使用官方工具的 Swagger 2.0

使用官方工具和版本 2.0,您可以自动创建 API 并生成响应。一旦官方工具完全支持它们,OpenAPI 3.0,相同的说明应该适用:

  1. 在一个可以被您的机器访问到的 URI 上发布您的 YAML 文件:
https://raw.githubusercontent.com/duluca/lemon-mart-swagger-server/master/swagger.2.yaml
  1. 在项目文件夹中,执行以下命令,将 <uri> 替换为指向您的 YAML 文件的 uri:
$ docker run --rm -v ${PWD}:/local swaggerapi/swagger-codegen-cli 
$ generate -i <uri> -l nodejs-server -o /local/server

与前一节类似,这将在 server 目录下创建一个 Node Express 服务器。要执行此服务器,请按照以下步骤操作。

  1. 使用 npm install 安装服务器的依赖项

  2. 运行 npm start。您的模拟服务器现在应该运行起来了。

  3. 转到 http://localhost:3000/docs

  4. 尝试 get /users 的 API;您将注意到 items 属性为空:

Swagger UI - 用户端点

但是,您应该收到虚拟数据。我们将修正这种行为。

  1. 尝试 get /user/{id};您将看到收到一些虚拟数据:

Swagger UI - 按 ID 查找用户端点

行为上的差异是因为,默认情况下,Node Express 服务器使用在 server/controllers/Default.js 下生成的控制器从 server/service/DefaultService.js 读取在服务器创建期间生成的随机数据。然而,您可以禁用默认控制器并强制 Swagger 切换到更好的默认存根模式。

  1. 更新 index.js 以强制使用存根并注释掉控制器:
index.js
var options = {
  swaggerUi: path.join(__dirname, '/swagger.json'),
  // controllers: path.join(__dirname, './controllers'),
  useStubs: true,
}
  1. 再次尝试 /users 端点

正如您在这里看到的,响应默认情况下质量更高:

Swagger UI - 附带虚拟数据的用户端点

在前面的内容中,total是一个整数,role已正确定义,items是一个有效的数组结构。

为了实现更好和更定制的数据模拟,你可以编辑DefaultService.js。在这种情况下,你需要更新usersGET函数,以返回一组定制的用户。

启用跨源资源共享(CORS)

在你能够从应用程序中使用你的服务器之前,你需要对其进行配置,以允许跨源资源共享CORS),以便你在http://localhost:5000上托管的 Angular 应用程序可以与在http://localhost:3000上托管的模拟服务器进行通信:

  1. 安装cors包:
$ npm i cors
  1. 更新index.js来使用cors
server/index.js
...
var cors = require('cors')
...
app.use(cors())

// Initialize the Swagger middleware
swaggerTools.initializeMiddleware(swaggerDoc, function(middleware) {
...

确保在initializeMiddleware之前调用app.use(cors());否则,其他 Express 中间件可能会干扰cors()的功能。

验证和发布 Swagger 服务器

你可以通过 SwaggerUI 验证你的 Swagger 服务器设置,它位于http://localhost:3000/docs,或者你可以在 VS Code 中通过 Preview Swagger 扩展实现更加集成的环境。

我将演示如何使用该扩展从 VS Code 内部测试你的 API:

  1. 在资源管理器中选择 YAML 文件

  2. 按下Shift + Alt + P并执行 Preview Swagger 命令

  3. 你会看到一个交互式窗口来测试你的配置,如下图所示:

在 Visual Studio Code 中预览 Swagger 扩展

  1. 点击/users 下的 Get 按钮

  2. 点击 Try it out 查看结果

在 OpenAPI 3.0.0 中,你会看到一系列服务器,包括本地和远程资源,而不是方案。这是一个非常方便的工具,可以在编写前端应用程序时探索各种数据源。

现在你已经验证了你的 Swagger 服务器,你可以发布你的服务器,以使团队成员或者需要可预测数据集以成功执行的自动验收测试AAT)环境可以访问。

执行以下步骤:*

  1. 将 Docker 的 npm 脚本添加到根级package.json文件中

  2. 添加Dockerfile

Dockerfile
FROM duluca/minimal-node-build-env:8.11.2

RUN mkdir -p /usr/src
WORKDIR /usr/src

COPY server .

RUN npm ci

CMD ["node", "index"]

一旦你构建了容器,你就可以部署它了。

我在 Docker Hub 上发布了一个样例服务器,网址是hub.docker.com/r/duluca/lemon-mart-swagger-server

总结

在本章中,你学会了如何创建基于容器的持续集成环境。我们利用了 CircleCI 作为基于云的 CI 服务,并强调了你可以将构建结果部署到所有主要的云托管提供商。如果你启用这样的自动化部署,你将实现持续部署CD)。通过 CI/CD 管道,你可以与客户和团队成员分享应用程序的每一次迭代,并快速向最终用户交付错误修复或新功能。

我们还讨论了良好 API 设计的重要性,并确定 Swagger 是一个有益于前端和后端开发人员的工具,用于定义和根据实时数据合同进行开发。如果你创建一个 Swagger 模拟服务器,你可以让团队成员拉取模拟服务器镜像,并在后端实现完成之前用它来开发他们的前端应用。

CircleCI 和 Swagger 在各自的方式上都是非常复杂的工具。本章提到的技术故意简单,但旨在实现复杂的工作流程,让你感受到这些工具真正的力量。你可以大大提高这种技术的效率和能力,但这些技术将取决于你的具体需求。

凭借我们可以发送真实 HTTP 请求的 CI 和模拟 API,我们已经准备好迅速迭代,同时确保高质量的交付成果。在下一章中,我们将深入探讨使用基于令牌的身份验证和条件导航技术为你的业务线应用设计授权和认证体验,以实现平滑的用户体验,延续首先路由的方法。

第十四章:设计身份验证和授权

设计一个高质量的身份验证和授权系统,而不会让最终用户感到沮丧,这是一个难题。 身份验证是验证用户身份的行为,授权指定用户访问资源的特权。 这两个过程,简称为 auth,必须无缝地配合工作,以满足具有不同角色、需求和职能的用户的需求。 在今天的网络中,用户对通过浏览器遇到的任何身份验证系统都有很高的期望,因此这是应用程序中绝对要第一次完全正确的一个非常重要的部分。

用户应始终知道他们在应用程序中可以做什么和不能做什么。 如果存在错误、失败或错误,用户应清楚地了解为什么发生此类错误。 随着应用程序的增长,很容易忽略错误条件可能被触发的所有方式。 您的实现应易于扩展或维护,否则您的应用程序的这个基本骨架将需要大量维护。 在本章中,我们将解决创建出色的身份验证用户体验的各种挑战,并实现一个坚实的基线体验。

我们将继续采用路由优先的方式来设计单页应用程序,通过实现 LemonMart 的身份验证和授权体验。 在第十二章中,创建一个基于路由的企业应用程序,我们定义了用户角色,完成了所有主要路由的构建,并完成了对 LemonMart 的初步 walk-through 导航体验,因此我们已经准备好实现基于角色的路由和拉取该实现的细微差别。

在第十三章中,持续集成和 API 设计,我们讨论了围绕主要数据组件设计的想法,因此,您已经熟悉用户实体的外观,这将在实现基于令牌的登录体验中非常有用,包括在实体内缓存角色信息。

在深入研究身份验证之前,我们将讨论在开始实现各种有条件导航元素之前,完成应用程序的高级模拟 - ups 的重要性,这可能在设计阶段发生重大变化。

在本章中,您将学习以下主题:

  • 高级用户体验设计的重要性

  • 基于令牌的身份验证

  • 有条件的导航

  • 侧边导航栏

  • 用于警报的可重用 UI 服务

  • 缓存数据

  • JSON Web 令牌

  • Angular HTTP 拦截器

  • 路由守卫

完成模拟 - ups

假象对确定应用程序中将需要的组件和用户控件的类型至关重要。 将在根级别定义用于跨组件使用的任何用户控件或组件,并通过各自的模块进行作用域定义。

我们已经确定了子模块并为它们设计了首次亮相页面,完成行走骨架。 现在我们已经定义了主要的数据组件,可以为应用程序的其余部分完成模型。 在高级别设计屏幕时,要牢记几点:

  • 用户是否可以通过尽可能少的导航完成其角色所需的常见任务?

  • 用户是否可以通过屏幕上可见的元素轻松访问应用程序的所有信息和功能?

  • 用户是否可以轻松搜索他们需要的数据?

  • 一旦用户找到感兴趣的记录,他们能够轻松地深入了解详细记录或查看相关记录吗?

  • 弹出警告真的有必要吗?您知道用户不会阅读它,对吧?

请记住,设计任何用户体验都没有唯一正确的方式,这就是为什么在设计屏幕时,始终要考虑模块化和可重用性。

当生成各种设计文档,如模型或设计决策时,请注意将其发布在所有团队成员可访问的维基上:

  1. 在 GitHub 上,切换到 Wiki 选项卡

  2. 您可以在Github.com/duluca/lemon-mart/wiki查看我的示例维基页面,如下所示:

GitHub.com LemonMart 维基

  1. 在创建维基页面时,请确保在任何其他可用文档之间进行交叉链接,如 Readme

  2. 请注意,GitHub 在页面下显示维基子页面。

  3. 然而,额外的摘要很有用,比如设计文档部分,因为有些人可能会错过右侧的导航元素。

  4. 在完成模型后,将其发布在维基上。

您可以在此处查看维基的摘要视图:

Lemon Mart 模型摘要视图

  1. 可选地,将模型放在行走骨架应用程序中,以便测试人员可以更好地设想尚未开发的功能。

随着模型完成,我们现在可以继续 LemonMart 的实施,包括认证和授权工作流程。

设计认证和授权工作流程。

良好设计的身份验证工作流程是无状态的,因此没有会话过期的概念。用户可以自由地从任意设备和选项卡上同时或随时与您的无状态 REST API 进行交互。JSON Web Token (JWT) 实现了分布式基于声明的认证,可以通过数字签名或集成保护和/或使用 消息认证码 (MAC) 进行加密。这意味着一旦用户的身份通过密码挑战等方式进行了认证,他们就会收到一个编码的声明票据或令牌,然后可以使用该令牌对系统进行未来请求,而无需重新验证用户的身份。服务器可以独立验证这一声明的有效性,并处理请求,无需事先知道其是否与该用户进行过交互。因此,我们不必存储关于用户的会话信息,这使得我们的解决方案是无状态且易于扩展的。每个令牌将在预定义的时间后过期,由于其分布式的特性,无法远程或单独吊销;但是,我们可以通过插入自定义账户和用户角色状态检查来增强实时安全性,以确保经过认证的用户有权访问服务器端资源。

JSON Web Tokens 实现了 IETF 行业标准 RFC7519,在 tools.ietf.org/html/rfc7519 找到。

良好的授权工作流程能够基于用户的角色实现条件导航,以便用户自动转到最佳的着陆页面;他们不会显示适合其角色的路由或元素,如果他们错误地尝试访问未经授权的路径,系统将会阻止他们这样做。您必须记住,任何客户端角色导航仅仅只是一种便利,而不是为了安全。这意味着每次向服务器发出的调用都应该包含必要的头部信息,并且带有安全令牌,以便服务器可以对用户进行重新认证,独立验证他们的角色,然后才允许其检索受保护的数据。客户端认证是不可信的,这就是为什么密码重置屏幕必须使用服务器端呈现技术来构建,以便用户和服务器都能验证预期的用户正在与系统交互。

在以下部分中,我们将围绕用户数据实体设计一个完整功能的身份验证工作流程,如下:

用户实体

添加身份验证服务

我们将首先创建一个带有真实和虚假登录提供程序的身份验证服务:

  1. 添加身份验证和授权服务:
$ npx ng g s auth -m app --flat false
  1. 确保服务在 app.module 中提供:
src/app/app.module.ts
import { AuthService } from './auth/auth.service'
...  
providers: [AuthService],

为服务创建一个单独的文件夹将组织各种与身份验证和授权相关的组件,比如enum用于角色定义。此外,我们还可以在同一个文件夹中添加一个authService的假装,这对于编写单元测试至关重要。

  1. 将用户角色定义为一个enum
src/app/auth/role.enum.ts
export enum Role {
  None = 'none',
  Clerk = 'clerk',
  Cashier = 'cashier',
  Manager = 'manager',
}

实现一个基本的认证服务

现在,让我们构建一个本地认证服务,它将使我们能够展示一个强大的登录表单,缓存和基于认证状态和用户角色的条件导航概念:

  1. 首先安装一个 JWT 解码库,以及用于伪造认证的 JWT 编码库:
$ npm install jwt-decode fake-jwt-sign
$ npm install -D @types/jwt-decode
  1. auth.service.ts定义您的导入项:
src/app/auth/auth.service.ts
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'

import { sign } from 'fake-jwt-sign' // For fakeAuthProvider only
import * as decode from 'jwt-decode'

import { BehaviorSubject, Observable, of, throwError as observableThrowError } from 'rxjs'
import { catchError, map } from 'rxjs/operators'

import { environment } from '../../environments/environment'
import { Role } from './role.enum'
...
  1. 实现一个IAuthStatus接口来存储解码后的用户信息,一个辅助接口以及默认的安全defaultAuthStatus
src/app/auth/auth.service.ts
...
export interface IAuthStatus {
  isAuthenticated: boolean
  userRole: Role
  userId: string
}

interface IServerAuthResponse {
  accessToken: string
}

const defaultAuthStatus = { isAuthenticated: false, userRole: Role.None, userId: null }
...

IAuthUser是一个接口,代表了你可能从认证服务接收到的典型 JWT 的形状。它包含有关用户及其角色的最少信息,因此它可以附加到服务调用的header中,并且可以可选地缓存在localStorage中以记住用户的登录状态。在前面的实现中,我们假设了一个Manager的默认角色。

  1. 定义AuthService类,其中有一个BehaviorSubject来锚定用户当前的authStatus,并在构造函数中配置一个可以处理emailpassword并返回IServerAuthResponseauthProvider
src/app/auth/auth.service.ts ...
@Injectable({
  providedIn: 'root'
})
export class AuthService {
   private readonly authProvider: (
    email: string,
    password: string
  ) => Observable<IServerAuthResponse>

  authStatus = new BehaviorSubject<IAuthStatus>(defaultAuthStatus)

  constructor(private httpClient: HttpClient) {
     // Fake login function to simulate roles
    this.authProvider = this.fakeAuthProvider
    // Example of a real login call to server-side
    // this.authProvider = this.exampleAuthProvider
  }
  ...

请注意,fakeAuthProvider被配置为该服务的authProvider。一个真实的 auth provider 可能会像以下代码一样,其中用户的电子邮件和密码发送到一个 POST 端点,该端点验证他们的信息,创建并返回一个 JWT 供我们的应用程序消费:

example
private exampleAuthProvider(
  email: string,
  password: string
): Observable<IServerAuthResponse> {
  return this.httpClient.post<IServerAuthResponse>(`${environment.baseUrl}/v1/login`, {
    email: email,
    password: password,
  })
}

这相当简单,因为大部分工作是在服务器端完成的。这个调用也可以被发送给第三方。

请注意 URL 路径中的 API 版本v1是在服务中定义的,而不是作为baseUrl的一部分。这是因为每个 API 可以独立地更改版本。登录可能长时间保持v1,而其他 API 可能会升级为v2v3等。

  1. 实现一个fakeAuthProvider来模拟认证过程,包括动态创建假的 JWT。
src/app/auth/auth.service.ts
  ...
  private fakeAuthProvider(
    email: string,
    password: string
  ): Observable<IServerAuthResponse> {
    if (!email.toLowerCase().endsWith('@test.com')) {
      return observableThrowError('Failed to login! Email needs to end with @test.com.')
    }

    const authStatus = {
      isAuthenticated: true,
      userId: 'e4d1bc2ab25c',
      userRole: email.toLowerCase().includes('cashier')
        ? Role.Cashier
        : email.toLowerCase().includes('clerk')
          ? Role.Clerk
          : email.toLowerCase().includes('manager') ? Role.Manager : Role.None,
    } as IAuthStatus

    const authResponse = {
      accessToken: sign(authStatus, 'secret', {
        expiresIn: '1h',
        algorithm: 'none',
      }),
    } as IServerAuthResponse

    return of(authResponse)
  }
  ...

fakeAuthProvider在服务中实现了本来应该是服务器端方法的内容,因此您可以方便地在微调您的认证流程时进行代码实验。它使用临时的fake-jwt-sign库创建和签署一个 JWT,这样我们也可以演示如何处理一个符合规范的 JWT。

不要在您的 Angular 应用程序中使用fake-jwt-sign依赖项,因为它的目的是用于服务器端代码。

  1. 在继续之前,实现一个transformError函数,用于处理在common/common.ts中的可观察流中混合的HttpErrorResponse和字符串错误:
src/app/common/common.ts
import { HttpErrorResponse } from '@angular/common/http'
import { throwError } from 'rxjs'

export function transformError(error: HttpErrorResponse | string) {
  let errorMessage = 'An unknown error has occurred'
  if (typeof error === 'string') {
    errorMessage = error
  } else if (error.error instanceof ErrorEvent) {
    errorMessage = `Error! ${error.error.message}`
  } else if (error.status) {
    errorMessage = `Request failed with ${error.status} ${error.statusText}`
  }
  return throwError(errorMessage)
}
  1. 实现login函数,它将从下一节中显示的LoginComponent调用。

  2. 添加 import { transformError } from '../common/common'

  3. 还要实现一个对应的注销功能,可以由顶部工具栏中的注销按钮调用,登录尝试失败,或者如果路由授权守卫在用户浏览应用程序时检测到未经授权的访问尝试,这是本章后续讨论的一个主题:

src/app/auth/auth.service.ts
  ...
  login(email: string, password: string): Observable<IAuthStatus> {
    this.logout()

    const loginResponse = this.authProvider(email, password).pipe(
      map(value => {
        return decode(value.accessToken) as IAuthStatus
      }),
      catchError(transformError)
    )

    loginResponse.subscribe(
      res => {
        this.authStatus.next(res)
      },
      err => {
        this.logout()
        return observableThrowError(err)
      }
    )

    return loginResponse
  }

  logout() {
    this.authStatus.next(defaultAuthStatus)
  }
}

login方法通过调用logout方法,authProvider以及在必要时抛出错误来封装了正确的操作顺序。

login方法遵循了 SOLID 设计中的开闭原则,它对外部提供不同的身份验证提供者开放以实现扩展,但对修改是封闭的,因为功能的差异被身份验证提供者封装起来。

在下一节中,我们将实现LoginComponent,以便用户可以输入他们的用户名和密码信息,并尝试登录。

实现登录组件

login组件利用我们刚刚创建的authService,并使用响应式表单实现验证错误。登录组件应该被设计成可以独立渲染,因为在路由事件中,如果我们发现用户没有得到适当的身份验证或授权,我们将把他们导航到这个组件。我们可以将此来源 URL 捕获为redirectUrl,以便用户成功登录后,我们可以将他们导航回去。

  1. 让我们从实现login组件的路由开始:
src/app/app-routing.modules.ts
...
  { path: 'login', component: LoginComponent },
  { path: 'login/:redirectUrl', component: LoginComponent },
...
  1. 现在实现组件本身:
src/app/login/login.component.ts
import { Component, OnInit } from '@angular/core'
import { FormBuilder, FormGroup, Validators, NgForm } from '@angular/forms'
import { AuthService } from '../auth/auth.service'
import { Role } from '../auth/role.enum'

@Component({
  selector: 'app-login',
  templateUrl: 'login.component.html',
  styles: [
    `
    .error {
        color: red
    }
    `,
    `
    div[fxLayout] {margin-top: 32px;}
    `,
  ],
})
export class LoginComponent implements OnInit {
  loginForm: FormGroup
  loginError = ''
  redirectUrl
  constructor(
    private formBuilder: FormBuilder,
    private authService: AuthService,
    private router: Router,
    private route: ActivatedRoute
  ) {
    route.paramMap.subscribe(params => (this.redirectUrl = params.get('redirectUrl')))
  }

  ngOnInit() {
    this.buildLoginForm()
  }

  buildLoginForm() {
    this.loginForm = this.formBuilder.group({
      email: ['', [Validators.required, Validators.email]],
      password: ['', [
        Validators.required,
        Validators.minLength(8),
        Validators.maxLength(50),
      ]],
    })
  }

  async login(submittedForm: FormGroup) {
    this.authService
      .login(submittedForm.value.email, submittedForm.value.password)
      .subscribe(authStatus => {
        if (authStatus.isAuthenticated) {
          this.router.navigate([this.redirectUrl || '/manager'])
        }
      }, error => (this.loginError = error))
  }
}

作为成功登录尝试的结果,我们利用路由器将经过身份验证的用户导航到他们的个人资料。在通过服务从服务器发送错误的情况下,我们将将该错误分配给loginError

  1. 这是一个登录表单的实现,用于捕获和验证用户的电子邮件密码,如果有任何服务器错误,就显示它们:
src/app/login/login.component.html
<div fxLayout="row" fxLayoutAlign="center">
  <mat-card fxFlex="400px">
    <mat-card-header>
      <mat-card-title>
        <div class="mat-headline">Hello, Lemonite!</div>
      </mat-card-title>
    </mat-card-header>
    <mat-card-content>
      <form [formGroup]="loginForm" (ngSubmit)="login(loginForm)" fxLayout="column">
        <div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="10px">
          <mat-icon>email</mat-icon>
          <mat-form-field fxFlex>
            <input matInput placeholder="E-mail" aria-label="E-mail" formControlName="email">
            <mat-error *ngIf="loginForm.get('email').hasError('required')">
              E-mail is required
            </mat-error>
            <mat-error *ngIf="loginForm.get('email').hasError('email')">
              E-mail is not valid
            </mat-error>
          </mat-form-field>
        </div>
        <div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="10px">
          <mat-icon matPrefix>vpn_key</mat-icon>
          <mat-form-field fxFlex>
            <input matInput placeholder="Password" aria-label="Password" type="password" formControlName="password">
            <mat-hint>Minimum 8 characters</mat-hint>
            <mat-error *ngIf="loginForm.get('password').hasError('required')">
              Password is required
            </mat-error>
            <mat-error *ngIf="loginForm.get('password').hasError('minlength')">
              Password is at least 8 characters long
            </mat-error>
            <mat-error *ngIf="loginForm.get('password').hasError('maxlength')">
              Password cannot be longer than 50 characters
            </mat-error>
          </mat-form-field>
        </div>
        <div fxLayout="row" class="margin-top">
          <div *ngIf="loginError" class="mat-caption error">{{loginError}}</div>
          <div class="flex-spacer"></div>
          <button mat-raised-button type="submit" color="primary" [disabled]="loginForm.invalid">Login</button>
        </div>
      </form>
    </mat-card-content>
  </mat-card>
</div>

在电子邮件和密码符合客户端网站验证规则之前,登录按钮将被禁用。另外,<mat-form-field>一次只会显示一个mat-error,除非你为更多的错误创建了更多的空间,所以请确保将您的错误条件放在正确的顺序中。

完成login组件的实现后,现在可以更新主屏幕以有条件地显示或隐藏我们创建的新组件。

  1. 更新home.component以在用户打开应用程序时显示登录:
src/app/home/home.component.ts

  template: `
    <div *ngIf="displayLogin">
      <app-login></app-login>
    </div>
    <div *ngIf="!displayLogin">
      <span class="mat-display-3">You get a lemon, you get a lemon, you get a lemon...</span>
    </div>
  `,

export class HomeComponent implements OnInit {
  displayLogin = true
  ...

不要忘记将上面的代码所需的依赖模块导入到您的 Angular 应用程序中。故意留给读者作为练习,以找到并导入丢失的模块。

你的应用程序应该看起来类似于这个屏幕截图:

LemonMart 带有登录

就实现和显示/隐藏侧边栏菜单、个人资料和注销图标而言,还有一些工作要做,考虑到用户的身份验证状态。

有条件的导航

有条件的导航对于创建一个没有挫败感的用户体验是必要的。通过选择性地显示用户可以访问的元素并隐藏他们无法访问的元素,我们可以让用户自信地在应用程序中导航。

让我们在用户登录应用程序后隐藏登录组件:

  1. home组件中,在home.component中引入authService

  2. authStatus设置为名为displayLogin的本地变量:

src/app/home/home.component
...
import { AuthService } from '../auth/auth.service'
...
export class HomeComponent implements OnInit {
  private _displayLogin = true
  constructor(private authService: AuthService) {}

  ngOnInit() {
    this.authService.authStatus.subscribe(
      authStatus => (this._displayLogin = !authStatus.isAuthenticated)
    )
  }

  get displayLogin() {
    return this._displayLogin
  }
}

属性获取器displayLogin在这里是必要的,否则您可能会收到一个 Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked 消息。这个错误是 Angular 组件生命周期和变更检测工作方式的副作用。这种行为在未来的 Angular 版本中可能会改变。

  1. app组件中,订阅认证状态并将当前值存储在名为displayAccountIcons的本地变量中:
src/app/app.component.ts

import { Component, OnInit } from '@angular/core'
import { AuthService } from './auth/auth.service'
...
export class AppComponent implements OnInit {
  displayAccountIcons = false
  constructor(..., private authService: AuthService) { 
  ...
  ngOnInit() {
    this.authService.authStatus.subscribe(
      authStatus => (this.displayAccountIcons = authStatus.isAuthenticated)
    )
  }
  ...
}
  1. 使用*ngIf隐藏所有针对已登录用户的按钮:
src/app/app.component.ts 
<button *ngIf="displayAccountIcons" ... >

现在,当用户注销时,您的工具栏应该看起来整洁,没有按钮,如下所示:

LemonMart 工具栏登录后

常见验证

在我们继续之前,我们需要为loginForm实现验证。随着我们在第十五章中实现更多的表单,Angular 应用设计和示例,你会意识到反复在模板或响应式表单中输入表单验证会变得繁琐快速。响应式表单的吸引之一是它由代码驱动,因此我们可以轻松地将验证提取到一个共享类中,进行单元测试,并重复使用它们:

  1. common文件夹下创建一个validations.ts文件

  2. 实现电子邮件和密码验证:

src/app/common/validations.ts
import { Validators } from '@angular/forms'

export const EmailValidation = [Validators.required, Validators.email]
export const PasswordValidation = [
  Validators.required,
  Validators.minLength(8),
  Validators.maxLength(50),
]

根据您的密码验证需求,您可以使用Validations.pattern()函数与RegEx模式来强制执行密码复杂性规则,或者利用 OWASP npm 包owasp-password-strength-test来启用密码短语,并设置更灵活的密码要求。

  1. 使用新的验证更新login组件:
src/app/login/login.component.ts
import { EmailValidation, PasswordValidation } from '../common/validations'
  ...
     this.loginForm = this.formBuilder.group({
      email: ['', EmailValidation],
      password: ['', PasswordValidation],
    })

UI 服务

当我们开始处理复杂的工作流程,例如认证工作流程时,有必要能够通过编程方式为用户显示一个弹出通知。在其他情况下,我们可能需要在执行破坏性操作之前询问确认,这时会需要一个更具侵入性的弹出式通知。

无论你使用什么组件库,都会很烦琐地重新编写相同的样板代码,只为了显示一个快速通知。一个 UI 服务可以整洁地封装一个默认实现,也可以根据需要进行定制:

  1. common下创建一个新的uiService

  2. 实现一个showToast函数:

src/app/common/ui.service.ts
import { Injectable, Component, Inject } from '@angular/core'
import {
  MatSnackBar,
  MatSnackBarConfig,
  MatDialog,
  MatDialogConfig,
} from '@angular/material'
import { Observable } from 'rxjs'

@Injectable()
export class UiService {
  constructor(private snackBar: MatSnackBar, private dialog: MatDialog) {}

  showToast(message: string, action = 'Close', config?: MatSnackBarConfig) {
    this.snackBar.open(
      message,
      action,
      config || {
        duration: 7000,
      }
    )
  }
...
}

对于showDialog函数,我们必须实现一个基本的对话框组件:

  1. 在提供的app.module下的common文件夹中添加一个新的simpleDialog,包括内联模板和样式
app/common/simple-dialog/simple-dialog.component.ts
@Component({
  template: `
    <h2 mat-dialog-title>data.title</h2>
    <mat-dialog-content>
      <p>data.content</p>
    </mat-dialog-content>
    <mat-dialog-actions>
      <span class="flex-spacer"></span>
      <button mat-button mat-dialog-close *ngIf="data.cancelText">data.cancelText</button>
      <button mat-button mat-button-raised color="primary" [mat-dialog-close]="true"
        cdkFocusInitial>
        data.okText
      </button>
    </mat-dialog-actions>
  `,
})
export class SimpleDialogComponent {
  constructor(
    public dialogRef: MatDialogRef<SimpleDialogComponent, Boolean>,
    @Inject(MAT_DIALOG_DATA) public data: any
  ) {}
}

请注意,SimpleDialogComponent不应该像selector: 'app-simple-dialog'一样带有应用程序选择器,因为我们计划只在UiService中使用它。从您的组件中删除此属性。

  1. 然后,实现一个showDialog函数来显示SimpleDialogComponent
app/common/ui.service.ts
...
showDialog(
    title: string,
    content: string,
    okText = 'OK',
    cancelText?: string,
    customConfig?: MatDialogConfig
  ): Observable<Boolean> {
    const dialogRef = this.dialog.open(
      SimpleDialogComponent,
      customConfig || {
        width: '300px',
        data: { title: title, content: content, okText: okText, cancelText: cancelText },
      }
    )

    return dialogRef.afterClosed()
  }
}

ShowDialog返回一个Observable<boolean>,因此您可以根据用户的选择实现后续动作。点击确定将返回true,点击取消将返回false

SimpleDialogComponent中,使用@Inject,我们能够使用showDialog发送的所有变量来定制对话框的内容。

不要忘记更新app.module.tsmaterial.module.ts,以适应引入的各种依赖项。

  1. 更新login组件,在登录后显示一个提示消息:
src/app/login/login.component.ts
import { UiService } from '../common/ui.service'
...
constructor(... ,
    private uiService: UiService)
...
  .subscribe(authStatus => {
        if (authStatus.isAuthenticated) {
          this.uiService.showToast(`Welcome! Role: ${authStatus.userRole}`)
          ...

用户登录后,将显示一个提示消息,如图所示:

材料吐司条

snackBar将占据整个屏幕的宽度,或者根据浏览器的大小占据一部分。

使用 cookie 和 localStorage 进行缓存

我们必须能够缓存已登录用户的认证状态。否则,每次刷新页面,用户都将不得不通过登录流程。我们需要更新AuthService以使其保持认证状态。

有三种主要的数据存储方式:

  • cookie

  • localStorage

  • sessionStorage

不应将 cookie 用于存储安全数据,因为它们可能会被坏人嗅探或窃取。此外,cookie 可以存储 4 KB 的数据并可以设置为过期。

localStoragesessionStorage相似。它们是受保护且隔离的浏览器端存储,允许为应用程序存储更大量的数据。你无法为这些存储设置过期日期时间。当浏览器窗口关闭时,sessionStorage的值会被删除。这些值会在页面重新加载和恢复时保留。

JSON Web Tokens 是加密的,并包含用于到期的时间戳,从本质上讲,对抗了cookielocalStorage的弱点。任一选项都应该可以安全地与 JWT 一起使用。

让我们首先实现一个缓存服务,它可以将我们的身份验证信息缓存的方式抽象化,以供AuthService使用:

  1. 首先创建一个封装缓存方法的抽象cacheService
src/app/auth/cache.service.ts
export abstract class CacheService {
  protected getItem<T>(key: string): T {
    const data = localStorage.getItem(key)
    if (data && data !== 'undefined') {
      return JSON.parse(data)
    }
    return null
  }

  protected setItem(key: string, data: object | string) {
    if (typeof data === 'string') {
      localStorage.setItem(key, data)
    }
    localStorage.setItem(key, JSON.stringify(data))
  }

  protected removeItem(key: string) {
    localStorage.removeItem(key)
  }

  protected clear() {
    localStorage.clear()
  }
}

此缓存服务基类可用于为任何服务提供缓存功能。这与创建一个注入到另一个服务中的集中式缓存服务不同。通过避免集中式值存储,我们避免了各种服务之间的相互依赖。

  1. 更新AuthService以扩展CacheService并实现对authStatus的缓存:
auth/auth.service
...
export class AuthService extends CacheService {
  authStatus = new BehaviorSubject<IAuthStatus>(
    this.getItem('authStatus') || defaultAuthStatus
  )

  constructor(private httpClient: HttpClient) {
    super()
    this.authStatus.subscribe(authStatus => this.setItem('authStatus', authStatus))
    ...
  }
  ...
}

此处展示的技术可用于持久化任何类型的数据,并有意利用 RxJS 事件来更新缓存。正如你可能注意到的,我们不需要更新登录函数来调用setItem,因为它已经调用了this.authStatus.next,我们只是连接到数据流。这有助于保持无状态和避免副作用,通过将函数之间解耦。

在初始化BehaviorSubject时,务必处理undefined/null情况,当从缓存中加载数据并仍提供默认实现时。

您可以在setItemgetItem函数中实现自定义缓存过期方案,或者利用第三方创建的服务。

如果您追求高安全性的应用程序,您可能选择仅缓存 JWT 以确保额外的安全层。在任何情况下,JWT 应该被单独缓存,因为令牌必须在每个请求的头部与服务器一起发送。重要的是要了解令牌身份验证的工作原理,以避免泄露妥协的秘密。在下一节中,我们将详细讨论 JWT 生命周期以提高您的理解。

JSON Web Token 生命周期

JSON Web Tokens 搭配着无状态 REST API 架构,使用加密令牌机制,方便、分布式和高性能地对客户端发送的请求进行身份验证和授权。令牌身份验证方案有三个主要组成部分:

  • 客户端捕获登录信息并隐藏不允许的操作以获得良好的用户体验

  • 服务器端验证每个请求都经过了身份验证和拥有适当授权

  • 身份验证服务,生成和验证加密令牌,独立验证来自数据存储的用户请求的身份验证和授权状态

安全系统假设主要组件之间发送/接收的数据是在传输过程中加密的。这意味着您的 REST API 必须使用正确配置的 SSL 证书托管,并通过 HTTPS 提供所有 API 调用,以便用户凭据永远不会在客户端和服务器之间暴露。同样,任何数据库或第三方服务调用都应该通过 HTTPS 进行。此外,任何存储密码的数据存储应该使用安全的单向哈希算法和良好的盐化实践。任何其他敏感用户信息应该使用安全的双向加密算法在静止状态下进行加密。遵循这种分层安全性方法至关重要,因为攻击者需要同时破坏所有实施的安全层来对您的业务造成实质性伤害。

下一个序列图突显了基于 JWT 的身份验证生命周期:

基于 JWT 的身份验证生命周期

最初,用户通过提供用户名和密码登录。一旦验证通过,用户的认证状态和角色将被加密为具有到期日期和时间的 JWT,并发送回浏览器。

你的 Angular(或任何其他 SPA)应用可以安全地将该令牌缓存到本地或会话存储中,这样用户就不需要在每个请求中强制登录,或者更糟糕的是,我们不会在浏览器中存储用户凭证。让我们更新认证服务,以便它能够缓存该令牌。

  1. 更新服务以能够设置、获取、解码和清除令牌,如下所示:
src/app/auth/auth.service.ts
...
  private setToken(jwt: string) {
    this.setItem('jwt', jwt)
  }

  private getDecodedToken(): IAuthStatus {
    return decode(this.getItem('jwt'))
  }

  getToken(): string {
    return this.getItem('jwt') || ''
  }

  private clearToken() {
    this.removeItem('jwt')
  }
  1. 在登录期间调用setToken,在登出期间调用clearToken,如下所示:
src/app/auth/auth.service.ts
...
  login(email: string, password: string): Observable<IAuthStatus> {
    this.logout()

    const loginResponse = this.authProvider(email, password).pipe(
      map(value => {
        this.setToken(value.accessToken)
        return decode(value.accessToken) as IAuthStatus
      }),
      catchError(transformError)
    )
  ...
  logout() {
    this.clearToken()
    this.authStatus.next(defaultAuthStatus)
  }

每个后续的请求都将在请求头中包含 JWT。您应该对每个 API 进行安全保护,以检查并验证接收到的令牌。例如,如果用户想要访问他们的个人资料,AuthService将验证令牌,以检查用户是否经过认证,但还需要进一步的数据库调用来检查用户是否有权查看数据。这样可以确保对用户的系统访问进行独立确认,并防止未过期令牌的滥用。

如果经过认证的用户调用 API,但他们没有适当的授权,例如,如果一个职员要获取所有用户的列表,那么AuthService将返回一个虚假状态,并且客户端将收到 403 Forbidden 的响应,这将显示为用户的错误消息。

用户可以使用过期的令牌发出请求;当此情况发生时,将向客户端发送 401 未授权的响应。作为良好的用户体验实践,我们应该自动提示用户重新登录,并允许他们在没有任何数据丢失的情况下恢复他们的工作流程。

总之,真正的安全性是通过健壮的服务器端实现实现的,任何客户端实现主要是为了实现良好的用户体验和良好的安全实践。

HTTP 拦截器

实现一个 HTTP 拦截器,将 JWT 注入到发送给用户的每个请求的头部,并且通过要求用户登录来优雅地处理认证失败:

  1. auth下创建authHttpInterceptor
src/app/auth/auth-http-interceptor.ts
import {
  HttpEvent,
  HttpHandler,
  HttpInterceptor,
  HttpRequest,
} from '@angular/common/http'
import { Injectable } from '@angular/core'
import { Router } from '@angular/router'
import { Observable, throwError as observableThrowError } from 'rxjs'
import { catchError } from 'rxjs/operators'
import { AuthService } from './auth.service'

@Injectable()
export class AuthHttpInterceptor implements HttpInterceptor {
  constructor(private authService: AuthService, private router: Router) {}
  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    const jwt = this.authService.getToken()
    const authRequest = req.clone({ setHeaders: { authorization: `Bearer ${jwt}` } })
    return next.handle(authRequest).pipe(
      catchError((err, caught) => {
        if (err.status === 401) {
          this.router.navigate(['/user/login'], {
            queryParams: { redirectUrl: this.router.routerState.snapshot.url },
          })
        }

        return observableThrowError(err)
      })
    )
  }
}

请注意,AuthService被利用来检索令牌,而且在 401 错误之后,为登录组件设置redirectUrl

  1. 更新app模块以提供拦截器:
src/app/app.module.ts
 providers: [
    ...
    {
      provide: HTTP_INTERCEPTORS,
      useClass: AuthHttpInterceptor,
      multi: true,
    },
  ],

您可以在 Chrome Dev Tools | Network 选项卡中观察拦截器的实际操作,当应用程序获取lemon.svg文件时。

请求 lemon.svg 的请求头

侧边导航

启用以移动设备为优先的工作流程,并提供轻松的导航机制,以便快速跳转到所需的功能。使用认证服务,根据用户当前的角色,只显示他们可以访问的功能链接。我们将实现侧边导航的模拟如下:

侧边导航的模拟

让我们将侧向导航菜单的代码实现为一个单独的组件,以便更容易维护:

  1. app.module中创建和声明一个NavigationMenuComponent
src/app/app.module.ts
@NgModule({
  declarations: [
    ...
    NavigationMenuComponent,
  ],

直到用户登录后才需要侧向导航。但是,为了能够从工具栏启动侧向导航菜单,我们需要能够从app.component触发它。由于此组件很简单,我们将会急切加载它。如果要惰性加载它,Angular 确实有一个动态组件加载器模式,但这具有高的实现开销,只有在可以节省数百千字节时才有意义。

SideNav将从工具栏触发,并且它带有一个<mat-sidenav-container>父容器,用于承载SideNav本身和应用程序的内容。因此,我们需要通过将<router-outlet>放置在<mat-sidenav-content>中来渲染所有应用程序内容。

  1. 在 material.module 中导入MatSidenavModuleMatListModule
src/app/material.module.ts
@NgModule({
  imports: [
    ...
    MatSidenavModule,
    MatListModule,
  ],
  exports: [
    ...
    MatSidenavModule,
    MatListModule,
  ]
  1. 定义一些样式,确保 Web 应用程序在桌面和移动场景上可以填充整个页面,并保持正确的滚动:
src/app/app.component.ts
styles: [
    `.app-container {
      display: flex;
      flex-direction: column;
      position: absolute;
      top: 0;
      bottom: 0;
      left: 0;
      right: 0;
    }
    .app-is-mobile .app-toolbar {
      position: fixed;
      z-index: 2;
    }
    .app-sidenav-container {
      flex: 1;
    }
    .app-is-mobile .app-sidenav-container {
      flex: 1 0 auto;
    },
    mat-sidenav {
      width: 200px;
    }
    `
  ],
  1. AppComponent中导入ObservableMedia服务:
src/app/app.component.ts
constructor(
    ...
    public media: ObservableMedia
  ) {
  ...
}
  1. 使用响应式SideNav更新模板,将在移动设备上覆盖内容或在桌面场景中将内容推开:
src/app/app.component.ts
...
template: `
  <div class="app-container">
    <mat-toolbar color="primary" fxLayoutGap="8px" class="app-toolbar"
      [class.app-is-mobile]="media.isActive('xs')">
      <button *ngIf="displayAccountIcons" mat-icon-button (click)="sidenav.toggle()">
        <mat-icon>menu</mat-icon>
      </button>
      <a mat-icon-button routerLink="/home">
        <mat-icon svgIcon="lemon"></mat-icon><span class="mat-h2">LemonMart</span>
      </a>
      <span class="flex-spacer"></span>
      <button *ngIf="displayAccountIcons" mat-mini-fab routerLink="/user/profile"
        matTooltip="Profile" aria-label="User Profile"><mat-icon>account_circle</mat-icon>
      </button>
      <button *ngIf="displayAccountIcons" mat-mini-fab routerLink="/user/logout"
        matTooltip="Logout" aria-label="Logout"><mat-icon>lock_open</mat-icon>
      </button>
    </mat-toolbar>
    <mat-sidenav-container class="app-sidenav-container"
                          [style.marginTop.px]="media.isActive('xs') ? 56 : 0">
      <mat-sidenav #sidenav [mode]="media.isActive('xs') ? 'over' : 'side'"
                  [fixedInViewport]="media.isActive('xs')" fixedTopGap="56">
        <app-navigation-menu></app-navigation-menu>
      </mat-sidenav>
      <mat-sidenav-content>
        <router-outlet class="app-container"></router-outlet>
      </mat-sidenav-content>
    </mat-sidenav-container>
  </div>
`,

前面的模板利用了之前注入的 Angular Flex 布局媒体可观察对象来实现响应式。

由于SiveNav内显示的链接长度可变并且受各种基于角色的业务规则约束,最好将其实现为一个单独的组件。

  1. displayAccountIcons实现一个属性获取器,并使用setTimeout来避免诸如ExpressionChangedAfterItHasBeenCheckedError之类的错误。
src/app/app.component.ts export class AppComponent implements OnInit {
  _displayAccountIcons = false
  ...
  ngOnInit() {
    this.authService.authStatus.subscribe(authStatus => {
      setTimeout(() => {
        this._displayAccountIcons = authStatus.isAuthenticated
      }, 0)
    })
  }
  get displayAccountIcons() {
    return this._displayAccountIcons
  }
}
  1. NavigationMenuComponent中实现导航链接:
src/app/navigation-menu/navigation-menu.component.ts
...
  styles: [
    `
    .active-link {
      font-weight: bold;
      border-left: 3px solid green;
    }
  `,
  ],
  template: `
    <mat-nav-list>
      <h3 matSubheader>Manager</h3>
      <a mat-list-item routerLinkActive="active-link" routerLink="/manager/users">Users</a>
      <a mat-list-item routerLinkActive="active-link" routerLink="/manager/receipts">Receipts</a>
      <h3 matSubheader>Inventory</h3>
      <a mat-list-item routerLinkActive="active-link" routerLink="/inventory/stockEntry">Stock Entry</a>
      <a mat-list-item routerLinkActive="active-link" routerLink="/inventory/products">Products</a>
      <a mat-list-item routerLinkActive="active-link" routerLink="/inventory/categories">Categories</a>
      <h3 matSubheader>Clerk</h3>
      <a mat-list-item routerLinkActive="active-link" routerLink="/pos">POS</a>
    </mat-nav-list>
  `,
...

<mat-nav-list>在布局目的上与<mat-list>功能上等效,所以您可以使用该组件的文档进行布局目的。在这里观察到的子标题为 Manager, Inventory 和 Clerk:

在桌面上显示收据查询的管理仪表板

routerLinkActive="active-link"突出显示所选的 Receipts 路由,如前面的截图所示。

此外,您可以在移动设备上看到外观和行为的差异如下:

在移动端显示收据查询的管理仪表板

登出

现在我们缓存了登录状态,需要实现一个退出体验:

  1. AuthService中实现logout函数:
src/app/auth/auth.service.ts
...
  logout() {
    this.clearToken()
    this.authStatus.next(defaultAuthStatus)
  }
  1. 实现logout组件:
src/app/user/logout/logout.component.ts
import { Component, OnInit } from '@angular/core'
import { Router } from '@angular/router'
import { AuthService } from '../../auth/auth.service'

@Component({
  selector: 'app-logout',
  template: `
    <p>
      Logging out...
    </p>
  `,
  styles: [],
})
export class LogoutComponent implements OnInit {
  constructor(private router: Router, private authService: AuthService) {}

  ngOnInit() {
    this.authService.logout()
    this.router.navigate(['/'])
  }
}

正如您所注意到的,登出后,用户会被导航回主页。

登录后基于角色的路由

这是您的应用程序最基本和最重要的部分。通过懒加载,我们已经确保只会加载最少量的资产以使用户能够登录。

用户登录后,应根据其用户角色路由到适当的登录屏幕,以便他们不需要猜测如何使用应用。例如,收银员只需访问 POS 以为顾客结账,因此他们可以自动路由到该屏幕。

你可以找到 POS 屏幕的模拟图示如下:

销售点屏幕模拟图

通过更新 LoginComponent,确保用户在登录后被路由到适当的页面:

  1. 更新 login 逻辑以根据角色路由:
app/src/login/login.component.ts  

async login(submittedForm: FormGroup) {
    ...
    this.router.navigate([
      this.redirectUrl || this.homeRoutePerRole(authStatus.userRole)
    ])
    ...
  }

  homeRoutePerRole(role: Role) {
    switch (role) {
      case Role.Cashier:
        return '/pos' 
      case Role.Clerk:
        return '/inventory' 
      case Role.Manager:
        return '/manager' 
      default:
        return '/user/profile'
    }
  }

同样,店员和经理被路由到他们需要完成任务所需的功能的登录屏幕,正如前面所示。由于我们实现了默认的经理角色,相应的登录体验将自动启动。另一方面,用户意图和非意图尝试访问他们不应访问的路由。在下一节中,我们将了解可以帮助检查身份验证并在表单呈现之前加载必需数据的路由守卫。

路由守卫

路由守卫进一步实现逻辑的解耦和重用,并控制组件生命周期。

以下是您最有可能使用的四个主要守卫:

  1. CanActivateCanActivateChild,用于检查路由的鉴权访问

  2. CanDeactivate,用于在离开路由之前询问权限

  3. Resolve,允许从路由参数预取数据

  4. CanLoad,允许在加载特性模块资产之前执行自定义逻辑

请参考以下部分,了解如何利用 CanActivateCanLoadResolve 守卫将在 第十五章 中进行介绍,Angular 应用设计与实例

鉴权守卫

鉴权守卫通过允许或阻止在加载模块或组件之前意外导航来实现良好的用户体验,并且在向服务器发出任何数据请求之前。

例如,当经理登录时,他们会自动路由到 /manager/home 路径。浏览器将缓存此 URL,并且店员意外地导航到相同的 URL 是完全可能的。Angular 不知道特定路由对用户是否可访问,如果没有 AuthGuard,它将高兴地渲染经理的主页并触发最终会失败的服务器请求。

无论前端实现的健壮性如何,您实施的每个 REST API 都应该在服务器端得到适当的安全保护。

让我们更新路由,以便在没有经过身份验证的用户的情况下无法激活 ProfileComponent,并且除非经理使用 AuthGuard 登录,否则不会加载 ManagerModule

  1. 实现一个 AuthGuard 服务:
src/app/auth/auth-guard.service.ts
import { Injectable } from '@angular/core'
import {
  CanActivate,
  Router,
  ActivatedRouteSnapshot,
  RouterStateSnapshot,
  CanLoad,
  CanActivateChild,
} from '@angular/router'
import { AuthService, IAuthStatus } from './auth.service'
import { Observable } from 'rxjs'
import { Route } from '@angular/compiler/src/core'
import { Role } from './role.enum'
import { UiService } from '../common/ui.service'

@Injectable({
  providedIn: 'root'
})
export class AuthGuard implements CanActivate, CanActivateChild, CanLoad {
  protected currentAuthStatus: IAuthStatus
  constructor(
    protected authService: AuthService,
    protected router: Router,
    private uiService: UiService
  ) {
    this.authService.authStatus.subscribe(
      authStatus => (this.currentAuthStatus = authStatus)
    )
  }

  canLoad(route: Route): boolean | Observable<boolean> | Promise<boolean> {
    return this.checkLogin()
  }

  canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot
  ): boolean | Observable<boolean> | Promise<boolean> {
    return this.checkLogin(route)
  }

  canActivateChild(
    childRoute: ActivatedRouteSnapshot,
    state: RouterStateSnapshot
  ): boolean | Observable<boolean> | Promise<boolean> {
    return this.checkLogin(childRoute)
  }

  protected checkLogin(route?: ActivatedRouteSnapshot) {
    let roleMatch = true
    let params: any
    if (route) {
      const expectedRole = route.data.expectedRole

      if (expectedRole) {
        roleMatch = this.currentAuthStatus.userRole === expectedRole
      }

      if (roleMatch) {
        params = { redirectUrl: route.pathFromRoot.map(r => r.url).join('/') }
      }
    }

    if (!this.currentAuthStatus.isAuthenticated || !roleMatch) {
      this.showAlert(this.currentAuthStatus.isAuthenticated, roleMatch)

      this.router.navigate(['login', params  || {}])
      return false
    }

    return true
  }

  private showAlert(isAuth: boolean, roleMatch: boolean) {
    if (!isAuth) {
      this.uiService.showToast('You must login to continue')
    }

    if (!roleMatch) {
      this.uiService.showToast('You do not have the permissions to view this resource')
    }
  }
}
  1. 使用 CanLoad 守卫防止懒加载模块的加载,例如经理的模块:
src/app/app-routing.module.ts
...
  {
    path: 'manager',
    loadChildren: './manager/manager.module#ManagerModule',
    canLoad: [AuthGuard],
  },
...

在这种情况下,当加载 ManagerModule 时, AuthGuard 将在 canLoad 事件期间被激活,而 checkLogin 函数将验证用户的认证状态。如果守卫返回false,该模块将不会被加载。此时,我们没有元数据来检查用户的角色。

  1. 使用 CanActivate 守卫来阻止个别组件的激活,如用户的profile
user/user-routing.module.ts
...
{ path: 'profile', component: ProfileComponent, canActivate: [AuthGuard] },
...

在 user-routing.module 的情况下, AuthGuard 在 canActivate 事件期间被激活,而 checkLogin 函数控制着这个路由可以导航到哪里。由于用户正在查看自己的个人资料,这里不需要检查用户的角色。

  1. 使用带有 expectedRole 属性的 CanActivate 或 CanActivateChild 来防止其他用户激活组件,如 ManagerHomeComponent
mananger/manager-routing.module.ts
...
  {
    path: 'home',
    component: ManagerHomeComponent,
    canActivate: [AuthGuard],
    data: {
      expectedRole: Role.Manager,
    },
  },
 {
    path: 'users',
    component: UserManagementComponent,
    canActivate: [AuthGuard],
    data: {
      expectedRole: Role.Manager,
    },
  },
  {
    path: 'receipts',
    component: ReceiptLookupComponent,
    canActivate: [AuthGuard],
    data: {
      expectedRole: Role.Manager,
    },
  },
...

ManagerModule 内部,我们可以验证用户是否有权访问特定路由。我们可以通过在路由定义中定义一些元数据,如 expectedRole 来做到这一点,该元数据将在 canActivate 事件中传递给 checkLogin 函数。如果用户已经认证但其角色不匹配Role.ManagerAuthGuard 将返回 false,导航将被阻止。

  1. 确保AuthServiceAuthGuardapp.module 和 manager.module 中都提供,因为它们在两个上下文中都被使用。

在继续之前,请确保执行npm test 和 npm run e2e 确保所有测试都通过。

认证服务伪装和共同测试提供程序

我们需要实现一个AuthServiceFake 以便我们的单元测试通过,并使用类似于 第十二章 中的 commonTestingModules 提到的模式,方便地在我们的规范文件中提供这个假对象。

为了确保我们的假对象将具有与实际AuthService 相同的公共函数和属性,让我们首先创建一个接口:

  1. 在 auth.service.ts 中添加IAuthService
src/app/auth/auth.service.ts export interface IAuthService {
  authStatus: BehaviorSubject<IAuthStatus>
  login(email: string, password: string): Observable<IAuthStatus>
  logout()
  getToken(): string
}
  1. 确保 AuthService 实现了接口

  2. 导出 defaultAuthStatus 以供重复使用

src/app/auth/auth.service.ts

export const defaultAuthStatus = {
  isAuthenticated: false,
  userRole: Role.None,
  userId: null,
}export class AuthService extends CacheService implements IAuthService 

现在我们可以创建一个假对象,它实现了相同的接口,但提供的功能不依赖于任何外部验证系统。

  1. auth 下创建一个名为 auth.service.fake.ts 的新文件:
src/app/auth/auth.service.fake.ts
import { Injectable } from '@angular/core'
import { BehaviorSubject, Observable, of } from 'rxjs'
import { IAuthService, IAuthStatus, defaultAuthStatus } from './auth.service'

@Injectable()
export class AuthServiceFake implements IAuthService {
  authStatus = new BehaviorSubject<IAuthStatus>(defaultAuthStatus)
  constructor() {}

  login(email: string, password: string): Observable<IAuthStatus> {
    return of(defaultAuthStatus)
  }

  logout() {}

  getToken(): string {
    return ''
  }
}
  1. 使用 commonTestingProviders 更新 common.testing.ts
src/app/common/common.testing.ts

export const commonTestingProviders: any[] = [
  { provide: AuthService, useClass: AuthServiceFake },
  UiService,
]
  1. 观察 app.component.spec.ts 中对假对象的使用:
src/app/app.component.spec.ts ...
  TestBed.configureTestingModule({
    imports: commonTestingModules,
    providers: commonTestingProviders.concat([
      { provide: ObservableMedia, useClass: ObservableMediaFake },
      ...

我们之前创建的空的 commonTestingProviders 数组已经与特定于 app.component 的假对象进行了连接,所以我们的新的 AuthServiceFake 应该可以自动应用。

  1. 使用如下所示更新 AuthGuard 的规范文件:
src/app/auth/auth-guard.service.spec.ts ...
   TestBed.configureTestingModule({
      imports: commonTestingModules,
      providers: commonTestingProviders.concat(AuthGuard)
    })
  1. 继续将这种技术应用到所有依赖于AuthServiceUiService的规范文件中

  2. 一个显著的例外是在 auth.service.spec.ts 中,你想使用假对象,因为AuthService是被测试的类,确保它被配置如下:

src/app/auth/auth.service.spec.ts
...
  TestBed.configureTestingModule({
    imports: [HttpClientTestingModule],
    providers: [AuthService, UiService],
  })
  1. 另外,SimpleDialogComponent 的测试需要模拟一些外部依赖项,例如:
src/app/common/simple-dialog/simple-dialog.component.spec.ts
  ...
    providers: [{
      provide: MatDialogRef,
      useValue: {}
    }, {
      provide: MAT_DIALOG_DATA,
      useValue: {} // Add any data you wish to test if it is passed/used correctly
    }],
  ...

记住,不要在所有测试通过之前进入下一阶段!

摘要

现在你应该熟悉如何创建高质量的身份验证和授权体验了。我们开始时强调了完成和记录整个应用的高水平 UX 设计的重要性,这样我们才能适当地设计出一个很棒的有条件导航体验。我们创建了可重用的 UI 服务,以便我们可以方便地将警报注入到应用的流程控制逻辑中。

我们讨论了基于令牌的身份验证和 JWT,以便不泄露任何重要的用户信息。我们了解到缓存和 HTTP 拦截器是必要的,这样用户不必在每个请求中输入他们的登录信息。最后,我们讨论了路由守卫,以防止用户意外进入未经授权使用的屏幕,并重申了应用程序的真正安全性应该在服务器端实现的观点。

在下一章中,我们将详细介绍一系列 Angular 示例,以完成我们的业务线应用—LemonMart 的实现。

第十五章:Angular 应用程序设计和技巧

在本章中,我们将完成 LemonMart 的实现。作为先路由的方法的一部分,我将展示如何创建可重用的可路由组件,同时支持数据绑定——使用辅助路由布置组件的能力,使用 resolve guards 减少样板代码,并利用类、接口、枚举、验证器和管道来最大程度地重用代码。此外,我们将创建多步骤表单,并实现带分页的数据表格,并探索响应式设计。在本书中,我们将触及 Angular 和 Angular Material 提供的大部分主要功能。

在这一章,训练车轮已经卸下。我将提供一般指导来帮助您开始实施;然而,您将需要自己尝试并完成实施。如果需要帮助,您可以参考本书附带的完整源代码,或在Github.com/duluca/lemon-mart上查看最新的示例。

在本章中,您将学习以下主题:

  • 面向对象类设计

  • 可复用的可路由组件

  • 缓存服务响应

  • HTTP POST 请求

  • 多步骤响应表单

  • 解析守卫

  • 使用辅助路由进行主/细节视图

  • 带分页的数据表格

用户类和面向对象编程

到目前为止,我们只是使用接口来表示数据,并且当在各个组件和服务之间传递数据时,我们仍然希望继续使用接口。然而,我们需要创建一个默认对象来初始化BehaviorSubject。在面向对象编程OOP)中,让User对象拥有这个功能而不是一个服务,这样做非常有意义。所以,让我们实现一个User类来实现这个目标。

user/user文件夹内,定义一个IUser接口和UserModule中提供的User类:

src/app/user/user/user.ts
import { Role } from '../../auth/role.enum'

export interface IUser {
  id: string
  email: string
  name: {
    first: string
    middle: string
    last: string
  }
  picture: string
  role: Role
  userStatus: boolean
  dateOfBirth: Date
  address: {
    line1: string
    line2: string
    city: string
    state: string
    zip: string
  }
  phones: IPhone[]
}

export interface IPhone {
  type: string
  number: string
  id: number
}

export class User implements IUser {
  constructor(
    public id = '',
    public email = '',
    public name = { first: '', middle: '', last: '' },
    public picture = '',
    public role = Role.None,
    public dateOfBirth = null,
    public userStatus = false,
    public address = {
      line1: '',
      line2: '',
      city: '',
      state: '',
      zip: '',
    },
    public phones = []
  ) {}

  static BuildUser(user: IUser) {
    return new User(
      user.id,
      user.email,
      user.name,
      user.picture,
      user.role,
      user.dateOfBirth,
      user.userStatus,
      user.address,
      user.phones
    )
  }
}

请注意,在构造函数中使用默认值定义所有属性为public属性,我们一举两得;否则,我们将需要分别定义属性并初始化它们。这样,我们就实现了一个简洁的实现。

您还可以实现计算属性以在模板中使用,比如可以方便地显示用户的fullName

src/app/user/user/user.ts  
get fullName() {
  return `${this.name.first} ${this.name.middle} ${this.name.last}`
}

使用static BuildUser函数,您可以快速用从服务器接收的数据填充对象。您还可以实现toJSON()函数来自定义对象在发送数据到服务器之前的序列化行为。

重用组件

我们需要一个能够显示给定用户信息的组件。这些信息最自然的呈现位置是当用户导航到/user/profile时。您可以看到User概要文件的模拟:

用户概要模拟

用户信息也在应用程序的其他地方进行了模拟显示,在/manager/users

用户管理模拟

为了最大限度地提高代码重用率,我们需要确保设计一个能在两种情境下使用的User组件。

例如,让我们完成两个与用户资料相关的屏幕的实现。

带有多步鉴权功能的响应式表单的用户资料

现在,让我们实现一个多步输入表单来捕获用户资料信息。我们还将使用媒体查询使这个多步表单对移动设备具有响应性。

  1. 让我们首先添加一些辅助数据,这些数据将帮助我们显示具有选项的输入表单:
src/app/user/profile/data.ts
export interface IUSState {
  code: string
  name: string
}

export function USStateFilter(value: string): IUSState[] {
  return USStates.filter(state => {
    return (
      (state.code.length === 2 && state.code.toLowerCase() === value.toLowerCase()) ||
      state.name.toLowerCase().indexOf(value.toLowerCase()) === 0
    )
  })
}

export enum PhoneType {
  Mobile,
  Home,
  Work,
}

const USStates = [
  { code: 'AK', name: 'Alaska' },
  { code: 'AL', name: 'Alabama' },
  { code: 'AR', name: 'Arkansas' },
  { code: 'AS', name: 'American Samoa' },
  { code: 'AZ', name: 'Arizona' },
  { code: 'CA', name: 'California' },
  { code: 'CO', name: 'Colorado' },
  { code: 'CT', name: 'Connecticut' },
  { code: 'DC', name: 'District of Columbia' },
  { code: 'DE', name: 'Delaware' },
  { code: 'FL', name: 'Florida' },
  { code: 'GA', name: 'Georgia' },
  { code: 'GU', name: 'Guam' },
  { code: 'HI', name: 'Hawaii' },
  { code: 'IA', name: 'Iowa' },
  { code: 'ID', name: 'Idaho' },
  { code: 'IL', name: 'Illinois' },
  { code: 'IN', name: 'Indiana' },
  { code: 'KS', name: 'Kansas' },
  { code: 'KY', name: 'Kentucky' },
  { code: 'LA', name: 'Louisiana' },
  { code: 'MA', name: 'Massachusetts' },
  { code: 'MD', name: 'Maryland' },
  { code: 'ME', name: 'Maine' },
  { code: 'MI', name: 'Michigan' },
  { code: 'MN', name: 'Minnesota' },
  { code: 'MO', name: 'Missouri' },
  { code: 'MS', name: 'Mississippi' },
  { code: 'MT', name: 'Montana' },
  { code: 'NC', name: 'North Carolina' },
  { code: 'ND', name: 'North Dakota' },
  { code: 'NE', name: 'Nebraska' },
  { code: 'NH', name: 'New Hampshire' },
  { code: 'NJ', name: 'New Jersey' },
  { code: 'NM', name: 'New Mexico' },
  { code: 'NV', name: 'Nevada' },
  { code: 'NY', name: 'New York' },
  { code: 'OH', name: 'Ohio' },
  { code: 'OK', name: 'Oklahoma' },
  { code: 'OR', name: 'Oregon' },
  { code: 'PA', name: 'Pennsylvania' },
  { code: 'PR', name: 'Puerto Rico' },
  { code: 'RI', name: 'Rhode Island' },
  { code: 'SC', name: 'South Carolina' },
  { code: 'SD', name: 'South Dakota' },
  { code: 'TN', name: 'Tennessee' },
  { code: 'TX', name: 'Texas' },
  { code: 'UT', name: 'Utah' },
  { code: 'VA', name: 'Virginia' },
  { code: 'VI', name: 'Virgin Islands' },
  { code: 'VT', name: 'Vermont' },
  { code: 'WA', name: 'Washington' },
  { code: 'WI', name: 'Wisconsin' },
  { code: 'WV', name: 'West Virginia' },
  { code: 'WY', name: 'Wyoming' },
]

  1. 安装一个帮助库来以编程方式访问 TypeScript 枚举值
$ npm i ts-enum-util
  1. common/validations.ts中添加新的验证规则
src/app/common/validations.ts
...

export const OptionalTextValidation = [Validators.minLength(2), Validators.maxLength(50)]
export const RequiredTextValidation = OptionalTextValidation.concat([Validators.required])
export const OneCharValidation = [Validators.minLength(1), Validators.maxLength(1)]
export const BirthDateValidation = [
  Validators.required,
  Validators.min(new Date().getFullYear() - 100),
  Validators.max(new Date().getFullYear()),
]
export const USAZipCodeValidation = [
  Validators.required,
  Validators.pattern(/^\d{5}(?:[-\s]\d{4})?$/),
]
export const USAPhoneNumberValidation = [
  Validators.required,
  Validators.pattern(/^\D?(\d{3})\D?\D?(\d{3})\D?(\d{4})$/),
]
  1. 现在按照以下方式实现profile.component.ts
src/app/user/profile/profile.component.ts
import { Role as UserRole } from '../../auth/role.enum'
import { $enum } from 'ts-enum-util'
...
@Component({
  selector: 'app-profile',
  templateUrl: './profile.component.html',
  styleUrls: ['./profile.component.css'],
})
export class ProfileComponent implements OnInit {
  Role = UserRole
  PhoneTypes = $enum(PhoneType).getKeys()
  userForm: FormGroup
  states: Observable<IUSState[]>
  userError = ''
  currentUserRole = this.Role.None

  constructor(
    private formBuilder: FormBuilder,
    private router: Router,
    private userService: UserService,
    private authService: AuthService
  ) {}

  ngOnInit() {
    this.authService.authStatus.subscribe(
      authStatus => (this.currentUserRole = authStatus.userRole)
    )

    this.userService.getCurrentUser().subscribe(user => {
      this.buildUserForm(user)
    })

    this.buildUserForm()
  }
  ...
}

加载时,我们从userService请求当前用户,但这可能需要一段时间,因此我们必须首先用this.buildUserForm()构建一个空表单。在这个函数中,您还可以实现一个 resolve 守卫,如后面将要讨论的,根据路由提供的userId加载用户,并将数据传递到buildUserForm(routeUser),然后跳过加载currentUser以增加此组件的可重用性。

表单组

我们的表单有许多输入字段,因此我们将使用FormGroup,由this.formBuilder.group创建以容纳我们的各种FormControl对象。此外,子FormGroup对象将允许我们保持数据结构的正确形状。

开始构建buildUserForm函数,如下所示:

src/app/user/profile/profile.component.ts
...
  buildUserForm(user?: IUser) {
    this.userForm = this.formBuilder.group({
      email: [
        {
          value: (user && user.email) || '',
          disabled: this.currentUserRole !== this.Role.Manager,
        },
        EmailValidation,
      ],
      name: this.formBuilder.group({
        first: [(user && user.name.first) || '', RequiredTextValidation],
        middle: [(user && user.name.middle) || '', OneCharValidation],
        last: [(user && user.name.last) || '', RequiredTextValidation],
      }),
      role: [
        {
          value: (user && user.role) || '',
          disabled: this.currentUserRole !== this.Role.Manager,
        },
        [Validators.required],
      ],
      dateOfBirth: [(user && user.dateOfBirth) || '', BirthDateValidation],
      address: this.formBuilder.group({
        line1: [
          (user && user.address && user.address.line1) || '',
          RequiredTextValidation,
        ],
        line2: [
          (user && user.address && user.address.line2) || '',
          OptionalTextValidation,
        ],
        city: [(user && user.address && user.address.city) || '', RequiredTextValidation],
        state: [
          (user && user.address && user.address.state) || '',
          RequiredTextValidation,
        ],
        zip: [(user && user.address && user.address.zip) || '', USAZipCodeValidation],
      }),
      ...
    })
    ...
  }
...

buildUserForm可选择接受一个IUser以预填表单,否则所有字段都设置为默认值。userForm本身是顶层FormGroup。其中添加了各种FormControls,例如email,根据需要连接到它们的验证器。注意nameaddress是它们自己的FormGroup对象。这种父子关系确保表单数据的正确结构,在序列化为 JSON 时,这适配了IUser的结构,以保证我们应用程序和服务端代码的运用。

您将独立完成userForm的实现,按照章节提供的示例代码,并且在接下来的几个章节中我将逐步解释代码的某些关键功能。

分步表单和响应式布局

Angular Material Stepper 附带了MatStepperModule。该步骤条允许将表单输入分解为多个步骤,以便用户不会被一次性处理数十个输入字段而感到不知所措。用户仍然可以跟踪他们在过程中的位置,作为开发人员的副作用,我们将我们的<form>实现分解并逐步强制执行验证规则,或者创建可以跳过或必填的可选工作流程。与所有 Material 用户控件一样,步骤条已经设计成具有响应式 UX。在接下来的几节中,我们将实现包括不同表单输入技术的三个步骤:

  1. 账户信息

    • 输入验证

    • 使用媒体查询进行响应式布局

    • 计算属性

    • 日期选择器

  2. 联系信息

    • 自动完成支持

    • 动态表单数组

  3. 评论

    • 只读视图

    • 数据保存和清除

让我们为用户模块准备一些新的 Material 模块:

  1. 创建一个user-material.module,其中包含以下 Material 模块:
MatAutocompleteModule,
MatDatepickerModule,
MatDividerModule,
MatLineModule,
MatNativeDateModule,
MatRadioModule,
MatSelectModule,
MatStepperModule,
  1. 确保user.module正确导入:

    1. 新的user-material.module

    2. 基线app-material.module

    3. 必须引入FormsModuleReactiveFormsModuleFlexLayoutModule

当我们开始添加子 Material 模块时,将根material.module.ts文件重命名为app-material.modules.ts是合理的,与app-routing.module.ts的命名方式一致。今后,我将使用后一种约定。

  1. 现在,开始实现“账户信息”步骤的第一行:
src/app/user/profile/profile.component.html <mat-toolbar color="accent"> <h5>User Profile</h5>
</mat-toolbar>

<mat-horizontal-stepper #stepper="matHorizontalStepper">
  <mat-step [stepControl]="userForm">
    <form [formGroup]="userForm">
      <ng-template matStepLabel>Account Information</ng-template>
      <div class="stepContent">
        <div fxLayout="row" fxLayout.lt-sm="column" [formGroup]="userForm.get('name')" fxLayoutGap="10px">
          <mat-form-field fxFlex="40%">
            <input matInput placeholder="First Name" aria-label="First Name" formControlName="first">
            <mat-error *ngIf="userForm.get('name').get('first').hasError('required')">
              First Name is required
            </mat-error>
            <mat-error *ngIf="userForm.get('name').get('first').hasError('minLength')">
              Must be at least 2 characters
            </mat-error>
            <mat-error *ngIf="userForm.get('name').get('first').hasError('maxLength')">
              Can't exceed 50 characters
            </mat-error>
          </mat-form-field>
          <mat-form-field fxFlex="20%">
            <input matInput placeholder="MI" aria-label="Middle Initial" formControlName="middle">
            <mat-error *ngIf="userForm.get('name').get('middle').invalid">
              Only inital
            </mat-error>
          </mat-form-field>
          <mat-form-field fxFlex="40%">
            <input matInput placeholder="Last Name" aria-label="Last Name" formControlName="last">
            <mat-error *ngIf="userForm.get('name').get('last').hasError('required')">
              Last Name is required
            </mat-error>
            <mat-error *ngIf="userForm.get('name').get('last').hasError('minLength')">
              Must be at least 2 characters
            </mat-error>
            <mat-error *ngIf="userForm.get('name').get('last').hasError('maxLength')">
              Can't exceed 50 characters
            </mat-error>
          </mat-form-field>
        </div>
       ...
      </div>
    </form>
   </mat-step>
...
</mat-horizontal-stepper>
  1. 请注意理解当前步骤条和表单配置的工作原理,你应该看到第一行渲染,并从模拟数据中拉取:

多步表单 - 第 1 步

  1. 为了完成表单的实现,请参考本章提供的示例代码或GitHub.com/duluca/lemon-mart上的参考实现。

在你的实现过程中,你会注意到“评论”步骤使用名为<app-view-user>的指令。这个组件的最简版本在下面的 ViewUser 组件部分实现了。然而,现在可以自由地在页面内实现这个功能,并在“可绑定和路由数据”部分重构代码。

在下面的截图中,你可以看到在桌面端完成的多步表单的实现效果:

桌面端多步表单

注意,在使用fxLayout.lt-sm="column"替代fxLayout="row"的情况下,使一行具有响应式布局形式,如下所示:

移动端多步表单

让我们看看下一节中日期选择器字段是如何工作的。

计算属性和日期选择器

如果你想根据用户输入显示已计算的属性,可以按照这里所示的模式进行:

src/app/user/profile/profile.component.ts ...
get dateOfBirth() {
  return this.userForm.get('dateOfBirth').value || new Date()
}

get age() {
  return new Date().getFullYear() - this.dateOfBirth.getFullYear()
}
...

模板中的计算属性使用如下所示:

src/app/user/profile/profile.component ...
<mat-form-field fxFlex="50%">
  <input matInput placeholder="Date of Birth" aria-label="Date of Birth" formControlName="dateOfBirth" [matDatepicker]="dateOfBirthPicker">
  <mat-hint *ngIf="userForm.get('dateOfBirth').touched">{{this.age}} year(s) old</mat-hint>
  <mat-datepicker-toggle matSuffix [for]="dateOfBirthPicker"></mat-datepicker-toggle>
  <mat-datepicker #dateOfBirthPicker></mat-datepicker>
  <mat-error *ngIf="userForm.get('dateOfBirth').invalid">
    Date must be with the last 100 years
  </mat-error>
</mat-form-field>
...

在下面的情况中,你可以看到它的实际效果:

使用 DatePicker 选择日期

选择日期后,将显示计算的年龄,如下所示:

计算年龄属性

现在,让我们继续下一步,联系信息,并看看我们如何实现方便的方式来显示和输入地址字段的州部分。

Type ahead 支持

buildUserForm中,我们设置了对address.state的监听器,以支持类型前输入下拉筛选体验:

src/app/user/profile/profile.component.ts ...
this.states = this.userForm
  .get('address')
  .get('state')
  .valueChanges.pipe(startWith(''), map(value => USStateFilter(value)))
...

在模板上,使用mat-autocomplete绑定到过滤后的州数组,并使用async管道:

src/app/user/profile/profile.component.html ...
<mat-form-field fxFlex="30%">
  <input type="text" placeholder="State" aria-label="State" matInput formControlName="state" [matAutocomplete]="stateAuto">
  <mat-autocomplete #stateAuto="matAutocomplete">
    <mat-option *ngFor="let state of states | async" [value]="state.name">
      {{ state.name }}
    </mat-option>
  </mat-autocomplete>
  <mat-error *ngIf="userForm.get('address').get('state').hasError('required')">
    State is required
  </mat-error>
</mat-form-field>
...

当用户输入V字符时,它是这样的样子:

下拉框与 Typeahead 支持

在下一节中,让我们启用多个电话号码的输入。

动态表单数组

请注意phones是一个数组,可能允许多个输入。我们可以通过使用this.formBuilder.array构建FormArray及使用几个辅助函数来实现这一点:

src/app/user/profile/profile.component.ts
...
  phones: this.formBuilder.array(this.buildPhoneArray(user ? user.phones : [])),
...
  private buildPhoneArray(phones: IPhone[]) {
    const groups = []

    if (!phones || (phones && phones.length === 0)) {
      groups.push(this.buildPhoneFormControl(1))
    } else {
      phones.forEach(p => {
        groups.push(this.buildPhoneFormControl(p.id, p.type, p.number))
      })
    }
    return groups
  }

  private buildPhoneFormControl(id, type?: string, number?: string) {
    return this.formBuilder.group({
      id: [id],
      type: [type || '', Validators.required],
      number: [number || '', USAPhoneNumberValidation],
    })
  }
...

BuildPhoneArray支持使用单个电话输入初始化表单或使用现有数据填充表单,与BuildPhoneFormControl协同工作。当用户单击 Add 按钮创建新的条目行时,后一个函数非常有用:

src/app/user/profile/profile.component.ts
...  
  addPhone() {
    this.phonesArray.push(
      this.buildPhoneFormControl(this.userForm.get('phones').value.length + 1)
    )
  }

  get phonesArray(): FormArray {
    return <FormArray>this.userForm.get('phones')
  }
...

phonesArray属性 getter 是一个常见的模式,可以更轻松地访问某些表单属性。然而,在这种情况下,这也是必要的,因为我们必须将get('phones')转换为FormArray,以便我们可以在模板上访问它的length属性:

src/app/user/profile/profile.component.html
...
<mat-list formArrayName="phones">
  <h2 mat-subheader>Phone Number(s)</h2>
  <button mat-button (click)="this.addPhone()">
    <mat-icon>add</mat-icon>
    Add Phone
  </button>
  <mat-list-item *ngFor="let position of this.phonesArray.controls let i=index" [formGroupName]="i">
  <mat-form-field fxFlex="100px">
    <mat-select placeholder="Type" formControlName="type">
      <mat-option *ngFor="let type of this.PhoneTypes" [value]="type">
      {{ type }}
      </mat-option>
    </mat-select>
  </mat-form-field>
  <mat-form-field fxFlex fxFlexOffset="10px">
    <input matInput type="text" placeholder="Number" formControlName="number">
    <mat-error *ngIf="this.phonesArray.controls[i].invalid">
      A valid phone number is required
    </mat-error>
  </mat-form-field>
  <button fxFlex="33px" mat-icon-button (click)="this.phonesArray.removeAt(i)">
    <mat-icon>close</mat-icon>
  </button>
  </mat-list-item>
</mat-list>
...

remove函数是内联实现的。

我们来看看它应该如何工作:

使用 FormArray 进行多个输入

现在我们已经完成了输入数据,我们可以继续进行步进器的最后一步:Review。然而,正如之前提到的,Review 步骤使用app-view-user指令来显示其数据。让我们先构建该视图。

ViewUser 组件

这是<app-view-user>指令的最小实现,这是 Review 步骤的先决条件。

user下创建一个新的viewUser组件,如下所示:

src/app/user/view-user/view-user.component.ts
import { Component, OnInit, Input } from '@angular/core'
import { IUser, User } from '../user/user'

@Component({
  selector: 'app-view-user',
  template: `
    <mat-card>
      <mat-card-header>
        <div mat-card-avatar><mat-icon>account_circle</mat-icon></div>
        <mat-card-title>{{currentUser.fullName}}</mat-card-title>
        <mat-card-subtitle>{{currentUser.role}}</mat-card-subtitle>
      </mat-card-header>
      <mat-card-content>
        <p><span class="mat-input bold">E-mail</span></p>
        <p>{{currentUser.email}}</p>
        <p><span class="mat-input bold">Date of Birth</span></p>
        <p>{{currentUser.dateOfBirth | date:'mediumDate'}}</p>
      </mat-card-content>
      <mat-card-actions *ngIf="!this.user">
        <button mat-button mat-raised-button>Edit</button>
      </mat-card-actions>
    </mat-card>
  `,
  styles: [
    `
    .bold {
      font-weight: bold
    }
  `,
  ],
})
export class ViewUserComponent implements OnChanges {
  @Input() user: IUser
  currentUser = new User()

  constructor() {}

  ngOnChanges() {
    if (this.user) {
      this.currentUser = User.BuildUser(this.user)
    }
  }
}

上面的组件使用@Input进行输入绑定,从外部组件获取符合IUser接口的用户数据。我们实现ngOnChanges事件,每当绑定的数据发生变化时触发。在此事件中,我们使用User.BuildUser将存储在this.user中的简单 JSON 对象填充为User类的实例,并将其分配给this.currentUser。模板使用此变量,因为像currentUser.fullName这样的计算属性只有在数据驻留在User类的实例中时才会起作用。

现在,我们准备完成多步表单。

检查组件并保存表单

在多步表单的最后一步,用户应该能够进行审查,然后保存表单数据。作为良好的做法,成功的POST请求将返回保存的数据到浏览器。然后我们可以使用从服务器收到的信息重新加载表单:

src/app/user/profile/profile.component 
...
async save(form: FormGroup) {
  this.userService
    .updateUser(form.value)
    .subscribe(res => this.buildUserForm(res), err => (this.userError = err))
 }
...

如果有错误,它们将被设置为userError来显示。在保存之前,我们将以紧凑的形式呈现数据,使用可重用组件将表单数据绑定到:

src/app/user/profile/profile.component.html
...
<mat-step [stepControl]="userForm">
  <form [formGroup]="userForm" (ngSubmit)="save(userForm)">
  <ng-template matStepLabel>Review</ng-template>
  <div class="stepContent">
    Review and update your user profile.
    <app-view-user [user]="this.userForm.value"></app-view-user>
  </div>
  <div fxLayout="row" class="margin-top">
    <button mat-button matStepperPrevious color="accent">Back</button>
    <div class="flex-spacer"></div>
    <div *ngIf="userError" class="mat-caption error">{{userError}}</div>
    <button mat-button color="warn" (click)="stepper.reset()">Reset</button>
    <button mat-raised-button matStepperNext color="primary" type="submit" [disabled]="this.userForm.invalid">Update</button>
  </div>
  </form>
</mat-step>
...

最终产品应该是这样的:

审查步骤

注意重置表单的选项。添加一个警报对话框来确认重置用户输入数据将是良好的用户体验。

现在用户配置文件输入完成,我们正在逐渐地朝着最终目标迈进,即创建一个主/细节视图,其中经理可以点击用户并查看其个人资料详细信息。我们仍然需要添加更多的代码,并且在此过程中,我们已经陷入了一种向组件加载必要数据的样板代码模式。在下一部分中,我们将了解 resolve 守卫,以便我们可以简化我们的代码并减少样板内容。

解析守卫

解析守卫是路由守卫的一种类型,如 第十四章中所述,设计身份验证和授权。 解析守卫可以通过从路由参数中读取记录 ID 异步加载必要的数据,并在组件激活和初始化时准备好这些数据。

解析守卫的主要优势包括加载逻辑的可重用性,减少样板代码以及摆脱依赖关系,因为组件可以接收到其所需的数据而无需导入任何服务:

  1. user/user下创建一个新的 user.resolve.ts 类:
src/app/user/user/user.resolve.ts
import { Injectable } from '@angular/core'
import { Resolve, ActivatedRouteSnapshot } from '@angular/router'
import { UserService } from './user.service'
import { IUser } from './user'

@Injectable()
export class UserResolve implements Resolve<IUser> {
  constructor(private userService: UserService) {}

  resolve(route: ActivatedRouteSnapshot) {
    return this.userService.getUser(route.paramMap.get('userId'))
  }
}

  1. 您可以像这样使用 resolve 守卫:
example
{
  path: 'user',
  component: ViewUserComponent,
  resolve: {
    user: UserResolve,
  },
},
  1. routerLink将是这样的:
example
['user', {userId: row.id}]
  1. 在目标组件的  ngOnInit 挂钩中,您可以这样读取已解析的用户:
example
this.route.snapshot.data['user']

在我们更新ViewUserComponent和路由以利用 resolve 守卫后,您可以在接下来的两个部分中观察这种行为。

具有绑定和路由数据的可重用组件

现在,让我们重构viewUser组件,以便我们可以在多个上下文中重复使用它。一个是它可以使用 resolve 守卫加载自己的数据,适用于主/细节视图,另一个是可以将当前用户绑定到它上,在我们在前一节中构建的多步输入表单的审查步骤中已经完成了绑定:

  1. 用以下更改更新viewUser组件:
src/app/user/view-user/view-user.component.ts
...
import { ActivatedRoute } from '@angular/router'

export class ViewUserComponent implements OnChanges, OnInit {
  ...
  constructor(private route: ActivatedRoute) {}

  ngOnInit() {
    if (this.route.snapshot && this.route.snapshot.data['user']) {
      this.currentUser = User.BuildUser(this.route.snapshot.data['user'])
      this.currentUser.dateOfBirth = Date.now() // for data mocking purposes only
    }
  }
  ...

现在我们有了两个独立的事件。一个用于ngOnChanges,它处理this.user已绑定的情况下this.currentUser被分配了哪个值。 ngOnInit只会在组件首次初始化或路由到达时触发一次。在这种情况下,如果路由的任何数据已被解析,那么它将被分配给this.currentUser

要能够在多个延迟加载的模块中使用此组件,我们必须将其包装在自己的模块中。

  1. app下创建一个新的shared-components.module.ts
src/app/shared-components.module.ts
import { NgModule } from '@angular/core'
import { ViewUserComponent } from './user/view-user/view-user.component'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { FlexLayoutModule } from '@angular/flex-layout'
import { CommonModule } from '@angular/common'
import { MaterialModule } from './app-material.module'

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    ReactiveFormsModule,
    FlexLayoutModule,
    MaterialModule,
  ],
  declarations: [ViewUserComponent],
  exports: [ViewUserComponent],
})
export class SharedComponentsModule {}

  1. 确保在你打算在UserManager模块中使用ViewUserComponent时,将SharedComponentsModule模块引入到每个功能模块中。

  2. User模块的声明中移除ViewUserComponent

我们现在已经具备开始实现主/细节视图的关键要素。

主/细节视图辅助路由

路由器优先架构的真正力量在于辅助路由的使用,通过仅通过路由器配置影响组件的布局,从而允许在不同布局中重新组合现有组件的丰富场景。辅助路由是彼此独立的路由,它们可以在标记中已定义的命名插座中呈现内容,例如<router-outlet name="master"><router-outlet name="detail">。此外,辅助路由可以具有自己的参数、浏览器历史、子级和嵌套辅助路由。

在以下示例中,我们将使用辅助路由实现基本的主/细节视图:

  1. 实现一个带有两个命名插座的简单组件:
src/app/manager/user-management/user-manager.component.ts
template: `
    <div class="horizontal-padding">
      <router-outlet name="master"></router-outlet>
      <div style="min-height: 10px"></div>
      <router-outlet name="detail"></router-outlet>
    </div>
  `
  1. manager下创建一个userTable组件

  2. 更新manager-routing.module以定义辅助路由:

src/app/manager/manager-routing.module.ts
  ...
      {
        path: 'users',
        component: UserManagementComponent,
        children: [
          { path: '', component: UserTableComponent, outlet: 
         'master' },
          {
            path: 'user',
            component: ViewUserComponent,
            outlet: 'detail',
            resolve: {
              user: UserResolve,
            },
          },
        ],
        canActivate: [AuthGuard],
        canActivateChild: [AuthGuard],
        data: {
          expectedRole: Role.Manager,
        },
      },
  ...

这意味着当用户导航到/manager/users时,他们将看到UserTableComponent,因为它是用default路径实现的。

  1. manager.module中提供UserResolve,因为viewUser依赖于它

  2. userTable中实现一个临时按钮

src/app/manager/user-table/user-table.component.html
<a mat-button mat-icon-button [routerLink]="['/manager/users', { outlets: { detail: ['user', {userId: 'fakeid'}] } }]" skipLocationChange>
  <mat-icon>visibility</mat-icon>
</a>

假设用户点击了上述定义的View detail按钮,那么ViewUserComponent将为具有给定userId的用户呈现。在下一张截图中,您可以看到在下一节中实现数据表后,View Details按钮将是什么样子:

查看详情按钮

您可以为主和详细信息定义多种组合和备用组件,从而允许无限可能的动态布局。然而,设置routerLink可能是一个令人沮丧的体验。根据确切的条件,您必须在链接中提供或不提供所有或一些插座。例如,在上述场景中,如果链接是['/manager/users', { outlets: { master: [''], detail: ['user', {userId: row.id}] } }],则路由将悄无声息地加载失败。预计这些怪癖将在未来的 Angular 版本中得到解决。

现在,我们已经完成了对ViewUserComponent的解析守卫的实现,你可以使用 Chrome Dev Tools 查看数据是否被正确加载。在调试之前,请确保我们在第十三章,持续集成和 API 设计中创建的模拟服务器正在运行。

  1. 确保模拟服务器正在运行,通过执行 docker run -p 3000:3000 -t duluca/lemon-mart-swagger-server 或者 npm run mock:standalone

  2. 在 Chrome Dev Tools 中,在this.currentUser 赋值后设置断点,如下所示:

Dev 工具调试 ViewUserComponent

你会注意到,在 ngOnInit 函数中正确设置了this.currentUser,展示了解析守卫的真正好处。ViewUserComponent 是详细视图;现在让我们实现带有分页的数据表作为主视图。

带有分页的数据表

我们已经创建了铺设主/详细视图的脚手架。在主出口中,我们将有一个用户的分页数据表,因此让我们实现 UserTableComponent,其中包含一个名为 dataSourceMatTableDataSource 属性。我们需要能够使用标准分页控件(如 pageSizepagesToSkip)批量获取用户数据,并且能够通过用户提供的 searchText 进一步缩小选择范围。

让我们先为 UserService 添加必要的功能。

  1. 实现一个新的接口 IUsers 来描述分页数据的数据结构
src/app/user/user/user.service.ts
...
export interface IUsers {
  items: IUser[]
  total: number
}
  1. UserService 添加 getUsers
src/app/user/user/user.service.ts
...
getUsers(pageSize: number, searchText = '', pagesToSkip = 0): Observable<IUsers> {
  return this.httpClient.get<IUsers>(`${environment.baseUrl}/v1/users`, {
    params: {
      search: searchText,
      offset: pagesToSkip.toString(),
      limit: pageSize.toString(),
    },
  })
}
...
  1. 设置带有分页、排序和过滤的UserTable
src/app/manager/user-table/user-table.component
import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core'
import { FormControl } from '@angular/forms'
import { MatPaginator, MatSort, MatTableDataSource } from '@angular/material'
import { merge, of } from 'rxjs'
import { catchError, debounceTime, map, startWith, switchMap } from 'rxjs/operators'
import { OptionalTextValidation } from '../../common/validations'
import { IUser } from '../../user/user/user'
import { UserService } from '../../user/user/user.service'

@Component({
  selector: 'app-user-table',
  templateUrl: './user-table.component.html',
  styleUrls: ['./user-table.component.css'],
})
export class UserTableComponent implements OnInit, AfterViewInit {
  displayedColumns = ['name', 'email', 'role', 'status', 'id']
  dataSource = new MatTableDataSource()
  resultsLength = 0
  _isLoadingResults = true
  _hasError = false
  errorText = ''
  _skipLoading = false

  search = new FormControl('', OptionalTextValidation)

  @ViewChild(MatPaginator) paginator: MatPaginator
  @ViewChild(MatSort) sort: MatSort

  constructor(private userService: UserService) {}

  ngOnInit() {}

  ngAfterViewInit() {
    this.dataSource.paginator = this.paginator
    this.dataSource.sort = this.sort

    this.sort.sortChange.subscribe(() => (this.paginator.pageIndex = 0))

    if (this._skipLoading) {
      return
    }

    merge(
      this.sort.sortChange,
      this.paginator.page,
      this.search.valueChanges.pipe(debounceTime(1000))
    )
      .pipe(
        startWith({}),
        switchMap(() => {
          this._isLoadingResults = true
          return this.userService.getUsers(
            this.paginator.pageSize,
            this.search.value,
            this.paginator.pageIndex
          )
        }),
        map((data: { total: number; items: IUser[] }) => {
          this._isLoadingResults = false
          this._hasError = false
          this.resultsLength = data.total

          return data.items
        }),
        catchError(err => {
          this._isLoadingResults = false
          this._hasError = true
          this.errorText = err
          return of([])
        })
      )
      .subscribe(data => (this.dataSource.data = data))
  }

  get isLoadingResults() {
    return this._isLoadingResults
  }

  get hasError() {
    return this._hasError
  }
}

初始化分页、排序和筛选属性后,我们使用 merge 方法来监听所有三个数据流的更改。如果有一个发生了变化,整个 pipe 就会被触发,其中包含对 this.userService.getUsers 的调用。然后将结果映射到表的 datasource 属性,否则捕获和处理错误。

  1. 创建一个包含以下 Material 模块的 manager-material.module
MatTableModule, 
MatSortModule, 
MatPaginatorModule, 
MatProgressSpinnerModule
  1. 确保 manager.module 正确导入:

    1. 新的 manager-material.module

    2. 基线的 app-material.module

    3. 必需的 FormsModuleReactiveFormsModuleFlexLayoutModule

  2. 最后,实现 userTable 模板:

src/app/manager/user-table/user-table.component.html
<div class="filter-row">
  <form style="margin-bottom: 32px">
    <div fxLayout="row">
      <mat-form-field class="full-width">
        <mat-icon matPrefix>search</mat-icon>
        <input matInput placeholder="Search" aria-label="Search" [formControl]="search">
        <mat-hint>Search by e-mail or name</mat-hint>
        <mat-error *ngIf="search.invalid">
          Type more than one character to search
        </mat-error>
      </mat-form-field>
    </div>
  </form>
</div>
<div class="mat-elevation-z8">
  <div class="loading-shade" *ngIf="isLoadingResults">
    <mat-spinner *ngIf="isLoadingResults"></mat-spinner>
    <div class="error" *ngIf="hasError">
      {{errorText}}
    </div>
  </div>
  <mat-table [dataSource]="dataSource" matSort>
    <ng-container matColumnDef="name">
      <mat-header-cell *matHeaderCellDef mat-sort-header> Name </mat-header-cell>
      <mat-cell *matCellDef="let row"> {{row.name.first}} {{row.name.last}} </mat-cell>
    </ng-container>
    <ng-container matColumnDef="email">
      <mat-header-cell *matHeaderCellDef mat-sort-header> E-mail </mat-header-cell>
      <mat-cell *matCellDef="let row"> {{row.email}} </mat-cell>
    </ng-container>
    <ng-container matColumnDef="role">
      <mat-header-cell *matHeaderCellDef mat-sort-header> Role </mat-header-cell>
      <mat-cell *matCellDef="let row"> {{row.role}} </mat-cell>
    </ng-container>
    <ng-container matColumnDef="status">
      <mat-header-cell *matHeaderCellDef mat-sort-header> Status </mat-header-cell>
      <mat-cell *matCellDef="let row"> {{row.status}} </mat-cell>
    </ng-container>
    <ng-container matColumnDef="id">
      <mat-header-cell *matHeaderCellDef fxLayoutAlign="end center">View Details</mat-header-cell>
      <mat-cell *matCellDef="let row" fxLayoutAlign="end center" style="margin-right: 8px">
        <a mat-button mat-icon-button [routerLink]="['/manager/users', { outlets: { detail: ['user', {userId: row.id}] } }]" skipLocationChange>
          <mat-icon>visibility</mat-icon>
        </a>
      </mat-cell>
    </ng-container>
    <mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
    <mat-row *matRowDef="let row; columns: displayedColumns;">
    </mat-row>
  </mat-table>

  <mat-paginator [pageSizeOptions]="[5, 10, 25, 100]"></mat-paginator>
</div>

只有主视图,表格看起来像这样的截图:

UserTable

如果点击查看图标,ViewUserComponent 将在详细视图中渲染,如下所示:

主/详细视图

然后可以将 Edit 按钮连接起来,将 userId 传递给 UserProfile,以便编辑和更新数据。或者,您可以将 UserProfile 直接呈现在详细视图中。

带有分页的数据表完成了 LemonMart 的实现目的。现在让我们确保我们所有的测试都通过,然后再继续。

更新单元测试

由于我们引入了新的 userService,为其创建一个虚假实现,使用与 authServicecommonTestingProviders 相同的模式。

  1. UserService 实现 IUserService 接口
src/app/user/user/user.service.ts
export interface IUserService {
  currentUser: BehaviorSubject<IUser>
  getCurrentUser(): Observable<IUser>
  getUser(id): Observable<IUser>
  updateUser(user: IUser): Observable<IUser>
  getUsers(pageSize: number, searchText: string, pagesToSkip: number): Observable<IUsers>
}
...
export class UserService extends CacheService implements IUserService {
  1. 实现虚假用户服务
src/app/user/user/user.service.fake.ts
import { Injectable } from '@angular/core'
import { BehaviorSubject, Observable, of } from 'rxjs'

import { IUser, User } from './user'
import { IUsers, IUserService } from './user.service'

@Injectable()
export class UserServiceFake implements IUserService {
  currentUser = new BehaviorSubject<IUser>(new User())

  constructor() {}

  getCurrentUser(): Observable<IUser> {
    return of(new User())
  }

  getUser(id): Observable<IUser> {
    return of(new User((id = id)))
  }

  updateUser(user: IUser): Observable<IUser> {
    return of(user)
  }

  getUsers(pageSize: number, searchText = '', pagesToSkip = 0): Observable<IUsers> {
    return of({
      total: 1,
      items: [new User()],
    } as IUsers)
  }
}
  1. commonTestingProviders 中添加用户服务的虚假到
src/app/common/common.testing.ts
export const commonTestingProviders: any[] = [
  ...
  { provide: UserService, useClass: UserServiceFake },
]
  1. SharedComponentsModule添加到commonTestingModules
src/app/common/common.testing.ts
export const commonTestingModules: any[] = [
  ...
  SharedComponentsModule
]
  1. 实例化UserTableComponent的默认数据

在修复了提供者和导入后,您会注意到UserTableComponent仍然无法创建。这是因为,组件初始化逻辑要求定义dataSource。如果未定义,组件将无法创建。但是,我们可以在第二个beforeEach方法中轻松修改组件属性,该方法在TestBed注入了真实的、模拟的或伪造的依赖项到组件类之后执行。查看下面加粗的变化以进行测试数据设置:

src/app/manager/user-table/user-table.component.spec.ts ...
  beforeEach(() => {
    fixture = TestBed.createComponent(UserTableComponent)
    component = fixture.componentInstance
 component.dataSource = new MatTableDataSource()
 component.dataSource.data = [new User()]
 component._skipLoading = true
    fixture.detectChanges()
  })
...

到目前为止,您可能已经注意到通过更新我们的一些中心配置,一些测试通过了,并且其余的测试可以通过应用我们在整本书中一直在使用的各种模式来解决。例如user-management.component.spec.ts使用了我们创建的常用测试模块和提供者:

src/app/manager/user-management/user-management.component.spec.ts      
providers: commonTestingProviders,
imports: commonTestingModules.concat([ManagerMaterialModule]),

当您使用提供者和伪造品时,请记住正在测试哪个模块、组件、服务或类,并小心仅提供依赖项的伪造品。

ViewUserComponent是一个特殊情况,我们无法使用我们的常用测试模块和提供者,否则我们将最终创建一个循环依赖。在这种情况下,需要手动指定需要引入的模块。

  1. 继续修复单元测试配置,直到所有测试都通过!

在本书中,我们没有涵盖任何功能单元测试,其中我们将测试一些业务逻辑以测试其正确性。相反,我们专注于保持自动生成的测试处于工作状态。我强烈建议使用 Angular 自带的优秀框架来实现单元测试,覆盖关键业务逻辑。

您始终可以选择进一步编写基本的单元测试,使用 Jasmine 在隔离环境中测试类和函数。Jasmine 具有丰富的测试双功能,能够模拟和监视依赖项。编写和维护这种基本单元测试更容易、更便宜。但是,这个主题本身是一个深入的主题,超出了本书的范围。

总结

在本章中,我们完成了所有主要的 Angular 应用程序设计考虑以及配方,以便能够轻松地实现业务应用程序。我们讨论了应用面向对象的类设计来使数据的填充或序列化更容易。我们创建了可以通过路由器激活或嵌入另一个带有数据绑定的组件的可重用组件。我们表明您可以将数据POST到服务器并缓存响应。我们还创建了一个响应屏幕尺寸变化的丰富多步输入表单。通过利用解析守卫从组件中删除样板代码,我们构建了一个主/细节视图。然后,使用辅助路由实现了数据表格分页。

总的来说,通过采用先路由设计、架构和实施方法,我们对应用程序的设计有了一个很好的高层次理解我们想要实现的目标。此外,通过及早识别重用机会,我们能够优化我们的实施策略,提前实现可重用组件,而不会面临过度设计解决方案的风险。

在下一章中,我们将在 AWS 上建立一个高可用的基础架构来托管 LemonMart。我们将更新项目,使用新的脚本来实现无停机蓝绿部署。

第十六章:在 AWS 上高可用的云基础架构

互联网是一个充满敌意的环境。有好的和坏的参与者。坏参与者可以试图攻击你的安全性,或者试图通过分布式拒绝服务DDoS)攻击来使你的网站崩溃。如果你幸运的话,好的参与者会喜欢你的网站,并且不会停止使用它。他们会给你建议来改进你的网站,但也可能遇到 bug,并且他们可能会如此热情以至于你的网站因为高流量而变得非常缓慢。在互联网上进行真实世界的部署需要很多专业知识才能做到正确。作为一名全栈开发者,你只能了解关于硬件、软件和网络的一些微妙之处。幸运的是,随着云服务提供商的出现,许多这方面的专业知识已经被转化为软件配置,由提供商解决了繁琐的硬件和网络问题。

云服务提供商最好的功能之一是云可扩展性,指的是你的服务器可以自动扩展以响应意外的高流量,并在流量恢复到正常水平时缩减成本。亚马逊云服务AWS)不仅仅实现了基本的云可扩展性,并且引入了高可用性和容错概念,允许在本地和全球进行弹性的部署。我选择介绍 AWS,是因为它的功能远远超出了我在本书中所涉及到的范围。通过 Route 53,你可以获得免费的 DDoS 保护;通过 API Gateway,你可以创建 API 密钥;通过 AWS Lambda,你可以处理成千上万的交易,每个月只需几美元;通过 CloudFront,你可以在世界各大城市的秘密边缘位置缓存你的内容。此外,蓝绿部署可以让你实现软件无停机部署。

总的来说,你将在本章学习到的工具和技术适用于任何云提供商,并且已经成为任何全栈开发者的关键知识。我们将讨论以下主题:

  • 创建和保护 AWS 账户

  • 右尺寸的基础设施

  • 简单的负载测试以优化实例

  • 配置和部署到 AWS ECS Fargate

  • 脚本化的蓝绿部署

  • 计费

右尺寸的基础设施

优化你的基础设施的目的是保护公司的收入,同时最大程度地减少操作基础设施的成本。你的目标应该是确保用户不会遇到高延迟,即性能不佳,或者更糟的是未完成或丢失的请求,同时使你的创业项目能够持续发展。

网页应用程序性能的三大支柱如下:

  1. CPU 利用率

  2. 内存使用

  3. 网络带宽

我故意将磁盘访问排除在关键考虑指标之外,因为只有在应用服务器或数据存储上执行的特定工作负载才会受到影响。只要应用资源由 内容交付网络CDN)交付,磁盘访问很少会对提供 Web 应用程序的性能产生影响。也就是说,仍然要留意任何意外的磁盘访问,比如频繁创建临时和日志文件。例如,Docker 可能会产生可以轻松填满驱动器的大量日志。

在理想情况下,CPU、内存和网络带宽使用应该均匀地在可用容量的 60-80% 之间利用。如果由于磁盘 I/O、慢的第三方服务或低效的代码等各种其他因素导致性能问题,很可能其中一种指标会接近或达到最大容量,而另外两种指标则处于空转或严重未被利用。这是一个机会,可以使用更多的 CPU、内存或带宽来弥补性能问题,并且均匀利用可用资源。

将目标定在 60-80% 的利用率的原因是为了留出一些时间来为新实例(服务器或容器)进行配置并准备好为用户提供服务。在超出预定阈值后,当正在配置新实例时,您可以继续为日益增多的用户提供服务,从而最小化未满足的请求。

在本书中,我反对过度设计或完美解决方案。在当今复杂的 IT 环境中,几乎不可能预测您会在哪里遇到性能瓶颈。您的工程师可能很容易地花费 10 万美元以上的工程小时数,而解决问题的解决方案可能是几百美元的新硬件,无论是网络交换机、固态硬盘、CPU 还是更多内存。

如果您的 CPU 太忙,您可能需要向您的代码中引入更多的记账逻辑,比如索引、哈希表或字典,您可以将其缓存在内存中,以加速您逻辑的后续步骤或中间步骤。例如,如果您不断运行数组查找操作来定位记录的特定属性,您可以对该记录进行操作,将记录的 ID 和/或属性保存在内存中的哈希表中将能将您的运行成本从 O(n) 减少到 O(1)

按照前面的例子,您可能会发现使用哈希表消耗了太多内存。在这种情况下,您可能希望更积极地将缓存转移到速度较慢但更充足的数据存储中,利用您的备用网络带宽,比如 Redis 实例。

如果您的网络利用率过高,您可能需要调查使用具有过期链接的 CDN、客户端缓存、限制请求速率、针对滥用其配额的客户设置 API 访问限制,或优化您的实例,让其网络容量相比 CPU 或内存容量不成比例更多。

优化实例

在早些时候的示例中,我演示了使用我的 duluca/minimal-node-web-server Docker 镜像来托管我们 Angular 应用程序。尽管 Node.js 是一个非常轻量级的服务器,但它简单地不能对只用作 Web 服务器进行优化。此外,Node.js 具有单线程执行环境,这使其成为为许多并发用户同时提供静态内容的贫乏选择。

您可以通过执行 docker stats 观察 Docker 镜像使用的资源:

$ docker stats
CONTAINER ID  CPU %  MEM USAGE / LIMIT    MEM %  NET I/O         BLOCK I/O  PIDS
27d431e289c9  0.00%  1.797MiB / 1.952GiB  0.09%  13.7kB / 285kB  0B / 0B       2

这里是 Node 和基于 NGINX 的服务器在空闲时利用的系统资源的比较结果:

服务器 **              镜像大小** **             内存使用**
duluca/minimal-nginx-web-server 16.8 MB 1.8 MB
duluca/minimal-node-web-server 71.8 MB 37.0 MB

然而,空闲状态值只能讲述故事的一部分。为了更好地理解,我们必须进行一项简单的负载测试,以查看内存和 CPU 在负载下的利用情况。

简单的负载测试

为了更好地了解我们服务器的性能特征,让我们为它们添加一些负载和压力:

  1. 使用 docker run 启动您的容器:
$ docker run --name <imageName> -d -p 8080:<internal_port> <imageRepo>

如果您使用 npm 脚本为 Docker,执行以下命令来启动您的容器:

$ npm run docker:debug
  1. 执行以下 bash 脚本以启动负载测试:
$ curl -L http://bit.ly/load-test-bash [](http://bit.ly/load-test-bash) | bash -s 100 "http://localhost:8080"

此脚本将向服务器发送 100requests/second 的请求,直到您终止它。

  1. 执行 docker stats 以观察性能特征。

这里是 CPU 和内存利用的高层次观察:

CPU 利用率统计 **        低** **         中** **          高** **   最大内存**
duluca/minimal-nginx-web-server 2% 15% 60% 2.4 MB
duluca/minimal-node-web-server 20% 45% 130% 75 MB

正如您所见,两个服务器提供完全相同的内容,但性能存在显著差异。请注意,基于每秒请求的这种测试适用于比较分析,并不一定反映实际使用情况。

很明显,我们的 NGINX 服务器将为我们带来最高的性价比。有了最佳解决方案,让我们在 AWS 上部署应用程序。

部署到 AWS ECS Fargate

AWS 弹性容器服务ECS)Fargate 是在云中部署容器的一种经济高效且易于配置的方式。

ECS 由四个主要部分组成:

  1. 容器仓库,弹性容器注册表ECR),您可以在其中发布您的 Docker 镜像

  2. 服务、任务和任务定义,您可以在其中为容器定义运行时参数和端口映射,服务作为任务运行

  3. 群集,EC2 实例的集合,可以在其中调配和扩展任务

  4. Fargate 是一种托管的集群服务,它对 EC2 实例、负载均衡器和安全组问题进行了抽象。

在发表时,Fargate 仅在 AWS us-east-1区域可用。

我们的目标是创建高可用的蓝绿部署,意味着我们的应用程序至少在服务器故障或部署期间有一个实例在运行。

配置 ECS Fargate

你可以在 AWS 服务菜单下访问 ECS 函数,选择弹性容器服务链接。

如果这是你第一次登录,你必须通过一个教程,在这里你将被要求创建一个样本应用。我建议你完成教程后删除你的样本应用。为了删除服务,你需要更新你的服务任务的数量为 0。此外,删除默认集群以避免任何意外费用。

创建一个 Fargate 集群

让我们从配置 Fargate 集群开始,当配置其他 AWS 服务时,它将充当一个锚点。我们的集群最终将运行一个集群服务,在随后的章节中逐步构建。

在发布时,AWS Fargate 只在 AWS 美国东部地区可用,支持更多地区和即将推出对 Amazon Elastic Container Service for Kubernetes(Amazon EKS)的支持。Kubernetes 是一个广泛使用的开源替代品,相对于 AWS ECS 具有更丰富的容器编排能力,支持本地、云和混合部署。

让我们创建集群:

  1. 导航到弹性容器服务

  2. 点击 Clusters | Create Cluster

  3. 选择仅具有网络...由 AWS Fargate 提供支持的模板

  4. 点击下一步,你会看到创建集群的步骤,如图所示:

AWS ECS 创建集群

  1. 输入集群名称为 fargate-cluster

  2. 创建一个 VPC,将你的资源与其他 AWS 资源隔离开来

  3. 点击创建集群完成设置

你将看到你的操作摘要,如下所示:

AWS ECS Fargate 集群

现在,你已经在自己的虚拟私有云VPC)中创建了一个集群,你可以在弹性容器服务 | 集群下查看它。

创建容器库

接下来,我们需要设置一个存储库,在这里我们可以发布我们在本地或 CI 环境中构建的容器映像:

  1. 导航到弹性容器服务

  2. 点击 Repositories | 创建存储库

  3. 将存储库名称输入为 lemon-mart

  4. 复制屏幕上生成的存储库 URI

  5. 将 URI 粘贴在你的应用程序的package.json中,作为新的imageRepo变量:

package.json ...
"config": {
  “imageRepo”: “000000000000.dkr.ecr.us-east-1.amazonaws.com/lemon-mart”,
  ...
}
  1. 点击创建存储库

  2. 点击下一步,然后点击完成设置

在摘要屏幕中,你将得到进一步关于如何在 Docker 中使用你的存储库的指导。在本章的后面,我们将介绍如何使用脚本为我们处理这个问题。

AWS ECS 仓库

你可以在弹性容器服务 | 存储库下查看你的新存储库。我们将在接下来的npm 脚本 for AWS部分介绍如何发布你的映像。

创建任务定义

在我们的仓库中定义了一个容器目标后,我们可以定义一个任务定义,其中包含运行我们的容器所需的元数据,例如端口映射、保留 CPU 和内存分配:

  1. 转到 Elastic Container Service

  2. 点击 Task Definitions | 创建新任务定义

  3. 选择 Fargate 启动类型兼容性

  4. 将任务定义名称设置为lemon-mart-task

  5. 选择任务角色none(稍后可以添加一个以启用访问其他 AWS 服务)

  6. 输入任务大小0.5 GB

  7. 输入任务 CPU0.25 CPU

  8. 点击添加容器:

    1. 将容器名称设置为lemon-mart

    2. 对于 Image,粘贴之前的镜像仓库 URI,但在其后追加:latest标签,以便始终拉取仓库中的最新镜像,例如000000000000.dkr.ecr.us-east-1.amazonaws.com/lemon-mart:latest

    3. 为 NGINX 设置软限制为128 MB,为 Node.js 设置为256 MB

    4. 在端口映射下,为 NGINX 指定容器端口为80,为 Node.js 指定为3000

  9. 接受剩下的默认值

  10. 点击添加;这是在创建任务定义之前您的任务定义将看起来像的样子:

AWS ECS 任务定义

  1. 点击创建以完成设置

在 Elastic Container Service | Task Definitions 下查看您的新任务定义。

请注意,默认设置将启用 AWS CloudWatch 日志记录,这是您可以在后期访问容器实例的控制台日志的一种方式。在此示例中,将创建名为/ecs/lemon-mart-task的 CloudWatch 日志组。

在 Cloud Watch | Logs 下查看您的新日志组。

如果您正在添加需要持久数据的容器,则任务定义允许您定义卷并将文件夹挂载到您的 Docker 容器中。我已发布了一篇关于在您的 ECS 容器中配置 AWS 弹性文件系统 (EFS)的指南,网址为bit.ly/mount-aws-efs-ecs-container

创建弹性负载均衡器

在高可用部署中,我们将希望在两个不同的可用区AZs)上运行两个容器实例,如我们刚刚创建的任务定义所定义的那样。对于这种动态扩展和收缩,我们需要配置一个应用负载均衡器ALB)来处理请求路由和排空:

  1. 在另一个选项卡上,导航至 EC2 | 负载均衡器 | 创建负载均衡器

  2. 创建一个应用负载均衡器

  3. 输入名称lemon-mart-alb

为了支持监听器下的 SSL 流量,你可以在端口443上添加一个新的 HTTPS 监听器。通过 AWS 服务和向导,可以方便地设置 SSL。在 ALB 配置过程中,AWS 提供了链接到这些向导以创建你的证书。然而,这是一个复杂的过程,取决于你现有的域名托管和 SSL 证书设置。在本书中,我将跳过与 SSL 相关的配置。你可以在我发布的指南bit.ly/setupAWSECSCluster中找到 SSL 相关的步骤。

  1. 在可用区中,选择为您的 fargate-cluster 创建的 VPC

  2. 选择所有列出的可用区

  3. 展开标签,添加一个键/值对以便识别 ALB,比如"App": " LemonMart"

  4. 点击下一步

  5. 选择默认的 ELB 安全策略

  6. 点击下一步

  7. 创建一个新的集群特定安全组,lemon-mart-sg,仅允许端口80入站或443(如果使用 HTTPS)。

在下一节中创建集群服务时,请确保此处创建的安全组是在服务创建期间选择的那个。否则,您的 ALB 将无法连接到您的实例。

  1. 点击下一步

  2. 将新的目标组命名为lemon-mart-target-group

  3. 将协议类型从instance更改为ip

  4. 在健康检查下,保持默认路由/,如果在 HTTP 上提供网站

健康检查对于扩展和部署操作至关重要。这是 AWS 用来检查实例是否已成功创建的机制。

如果部署 API 和/或将所有 HTTP 调用重定向到 HTTPS,请确保你的应用定义了一个不重定向到 HTTPS 的自定义路由。在 HTTP 服务器 GET /healthCheck 返回简单的I'm healthy消息,并验证这不会重定向到 HTTPS。否则,你将通过许多痛苦和痛苦来试图弄清楚问题所在,因为所有的健康检查都失败了,而部署却莫名其妙地失败了。duluca/minimal-node-web-server提供 HTTPS 重定向功能,以及开箱即用的仅 HTTP /healthCheck端点。使用duluca/minimal-nginx-web-server,你将需要提供自己的配置。

  1. 点击下一步

  2. 注册任何目标或 IP 范围。如果这是由 ECS Fargate 魔法般地为您管理的,如果您自己这样做,您将为半破碎的基础设施提供。

  3. 点击下一步:审核;您的 ALB 设置应该与所示的类似:

示图

AWS 应用负载均衡器设置

  1. 点击创建完成设置

在下一节中创建集群服务时,您将使用 lemon-mart-alb。

创建集群服务

现在,我们将通过使用任务定义和我们创建的 ALB 在我们的集群中创建一个服务,将它们整合起来:

  1. 转到弹性容器服务

  2. 点击集群 | fargate-cluster

  3. 在服务选项卡下,点击创建

  4. 选择启动类型Fargate

  5. 选择您之前创建的任务定义

请注意,任务定义是有版本的,比如 lemon-mart-task:1。如果要对任务定义进行更改,AWS 将创建 lemon-mart-task:2。您需要使用此新版本更新服务,以使更改生效。

  1. 输入服务名称 lemon-mart-service

  2. 任务数量 2

  3. 最小可用百分比 50

  4. 最大百分比 200

  5. 点击下一步

为了确保在部署过程中保持高可用性,将最小健康百分比设置为 100。Fargate 的定价是按秒计费的,因此在部署应用程序时,您将额外支付额外的实例费用,而旧实例正在被取消。

  1. 在配置网络下,选择与之前集群相同的 VPC

  2. 选择所有现有的子网;应至少有两个以保证高可用性

  3. 选择在上一部分中创建的安全组—lemon-mart-sg

  4. 将负载均衡器类型选择为应用负载均衡器

  5. 选择 lemon-mart-alb 选项

  6. 通过点击“添加到负载均衡器”按钮,为 ALB(应用负载均衡器)添加容器端口,例如 803000

  7. 选择您已经定义的监听端口

  8. 选择您之前定义的目标组

  9. 取消勾选“启用服务发现集成”

  10. 点击下一步

  11. 如果您希望实例在达到一定限制时能够自动扩展和缩减,则设置自动缩放

我建议在服务的初始设置过程中跳过自动扩展的设置,以便更容易排除任何潜在的配置问题。您可以随后再回来进行设置。自动任务缩放策略依赖于警报,如 CPU 利用率。

  1. 点击下一步,并审查您所做的更改,如图所示:

AWS Fargate 集群服务设置

  1. 最后,点击“保存”完成设置

观察您在 Elastic Container Service | Clusters | fargate-cluster | lemon-mart-service 下的新服务。在将图像发布到容器存储库之前,您的 AWS 服务将无法启动实例,因为健康检查将持续失败。发布图像后,您将希望确保服务的事件标签中没有错误。

AWS 是一个复杂的系统,通过 Fargate,您可以避免很多复杂性。然而,如果您有兴趣使用自己的 Ec2 实例建立自己的 ECS 集群,您可以通过 1-3 年的保留实例获得重大折扣。我有一份完整的设置指南,可在 bit.ly/setupAWSECSCluster 上找到。

我们手动执行了许多步骤来创建我们的集群。AWS CloudFormation 可以通过提供可定制的配置模板或从头开始编写模板来解决这个问题。如果您希望认真对待 AWS,这种代码即基础设施的设置绝对是上策。

对于生产部署,请确保您的配置由 CloudFormation 模板定义,这样就可以轻松地重新配置,而不是在部署相关的意外失误发生时。

配置 DNS

如果您使用 AWS Route 53 来管理您的域名,那么很容易将域名或子域名分配给 ALB:

  1. 转到 Route 53 | 托管区域

  2. 选择您的域名,例如thejavascriptpromise.com

  3. 点击创建记录集

  4. 将名称输入为lemonmart

  5. 将别名设置为

  6. 从负载均衡器列表中选择 lemon-mart-alb

  7. 点击创建以完成设置

Route 53 - 创建记录集

现在,您的站点将通过您刚刚定义的子域名可达,例如http://lemonmart.thejavascriptpromise.com

如果不使用 Route 53,不必惊慌。在您的域名提供商的网站上,编辑Zone文件以创建A记录到 ELB 的 DNS 地址,完成后即可。

获取 DNS 名称

为了获得负载均衡器的 DNS 地址,请执行以下步骤:

  1. 转到 EC2 | 负载均衡器

  2. 选择 lemon-mart-alb

  3. 在描述标签中记录 DNS 名称;考虑以下示例:

DNS name:
lemon-mart-alb-1871778644.us-east-1.elb.amazonaws.com (A Record)

准备 Angular 应用

本节假设您已经根据第十章*,为生产发布准备 Angular 应用*的详细说明设置了 Docker 和npm Scripts for Docker。您可以在bit.ly/npmScriptsForDocker获取这些脚本的最新版本。

实现优化的Dockerfile

Dockerfile 
FROM duluca/minimal-nginx-web-server:1.13.8-alpine
COPY dist /var/www
CMD 'nginx'

请注意,如果您正在使用npm Scripts for Docker,请将内部图像端口从3000更新到80,如下所示:

"docker:runHelper": "cross-conf-env docker run -e NODE_ENV=local --name $npm_package_config_imageName -d -p $npm_package_config_imagePort:80 $npm_package_config_imageRepo",

添加 npm Scripts for AWS

就像npm Scripts for Docker一样,我开发了一组脚本,称为npm Scripts for AWS,适用于 Windows 10 和 macOS。这些脚本将使您能够以出色、无停机时间、蓝绿色方式上传和发布您的 Docker 镜像。您可以在bit.ly/npmScriptsForAWS获取这些脚本的最新版本:

  1. 确保您的项目已经设置了bit.ly/npmScriptsForDocker

  2. 创建.env文件并设置AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY

.env
AWS_ACCESS_KEY_ID=your_own_key_id
AWS_SECRET_ACCESS_KEY=your_own_secret_key
  1. 确保您的.env文件在您的.gitignore文件中,以保护您的秘密信息

  2. 安装或升级到最新的 AWS CLI:

    • 在 macOS 上 brew install awscli

    • 在 Windows 上choco install awscli

  3. 使用您的凭证登录 AWS CLI:

    1. 运行aws configure

    2. 您将需要从配置 IAM 账户时获得的访问密钥 ID 和访问密钥 Commands 的各位。

    3. 设置默认区域名称,如us-east-1

  4. 更新package.json,添加新的config属性,其中包含以下配置属性:

package.json
  ...
  "config": {
    ...
    "awsRegion": "us-east-1",
    "awsEcsCluster": "fargate-cluster",
    "awsService": "lemon-mart-service"
  },
 ...

确保您从配置npm Scripts for Docker时更新了package.json,以便imageRepo属性具有您新的 ECS 存储库的地址。

  1. package.json添加 AWS scripts,示例如下:
package.json
...
"scripts": {
  ...
  "aws:login": "run-p -cs aws:login:win aws:login:mac",
  "aws:login:win": "cross-conf-env aws ecr get-login --no-include-email --region $npm_package_config_awsRegion > dockerLogin.cmd && call dockerLogin.cmd && del dockerLogin.cmd",
 "aws:login:mac": "eval $(aws ecr get-login --no-include-email --region $npm_package_config_awsRegion)"
}

npm run aws:login 调用特定于平台的命令,自动执行从 AWS CLI 工具获取 Docker 登录命令的多步操作,如下所示:

example
$ aws ecr get-login --no-include-email --region us-east-1
docker login -u AWS -p eyJwYXl...3ODk1fQ== https://073020584345.dkr.ecr.us-east-1.amazonaws.com

你首先需要执行 aws ecr get-login,然后复制粘贴得到的 docker login 命令并执行它,以便你的本地 Docker 实例指向 AWS ECR:

package.json
...
"scripts": {
  ...
  "aws:deploy": "cross-conf-env docker run --env-file ./.env duluca/ecs-deploy-fargate -c $npm_package_config_awsEcsCluster -n $npm_package_config_awsService -i $npm_package_config_imageRepo:latest -r $npm_package_config_awsRegion --timeout 1000"
  }
...

npm run aws:deploy 拉取一个 Docker 容器,该容器本身执行蓝绿部署,使用你通过 aws ecr 命令提供的参数。这是如何运作的细节超出了本书的范围。要查看更多使用本地 aws ecr 命令的示例,请参考 aws-samples 存储库 github.com/aws-samples/ecs-blue-green-deployment

请注意, duluca/ecs-deploy-fargate 蓝绿部署脚本是原始 silintl/ecs-deploy 镜像的一个分支,经过修改以支持使用 PR https://github.com/silinternational/ecs-deploy/pull/129 的 AWS ECS Fargate。一旦 silintl/ecs-deploy 合并了这个变更,我建议你使用 silintl/ecs-deploy 进行蓝绿部署:

package.json
...
"scripts": {
  ...
  "aws:release": "run-s -cs aws:login docker:publish aws:deploy"
}
...

最后, npm run aws:release 只需按正确的顺序运行 aws:logindocker:publishaws:deploy 命令。

发布

你的项目已配置为部署在 AWS 上。你主要需要使用我们创建的两个命令来构建和发布镜像:

  1. 执行 docker:debug 来测试、构建、标记、运行、跟踪并在浏览器中启动你的应用程序来测试镜像:
$ npm run docker:debug
  1. 执行 aws:release 配置 Docker 登录 AWS,发布最新的镜像构建,并将其发布到 ECS:
 $ npm run aws:release
  1. 验证你的任务在服务级别正在运行:

AWS ECS 服务

确保运行计数和期望计数相同。

  1. 验证你的实例在任务级别正在运行:

AWS ECS 任务实例

记下公网 IP 地址并导航到它; 例如 http://54.164.92.137,你应该能看到你的应用程序或正在运行的 LemonMart。

  1. 验证负载均衡器在 DNS 级别的设置是否正确。

  2. 导航到 ALB DNS 地址,例如 http://lemon-mart-alb-1871778644.us-east-1.elb.amazonaws.com,确认应用程序呈现如下:

LemonMart 运行在 AWS Fargate 上

瞧!你的网站应该已经启动并运行了。

在后续版本发布中,你将能够观察到蓝绿部署的进行,如下所示:

AWS 服务在蓝绿部署期间

有两个任务正在运行,另外两个新任务正在预配置。在新任务得到验证的同时,运行数量会上升到四个任务。在新任务得到验证并从旧任务中排出连接后,运行数量将恢复为两个。

你可以通过配置 CircleCI 与你的 AWS 凭据,使用已安装了awscli工具的容器,并运行npm Scripts for AWS来自动化你的部署。使用这种技术,你可以实现对暂存环境的持续部署或对生产环境的持续交付。

摘要

在本章中,你了解了正确保护你的 AWS 账户的微妙之处和各种安全考虑。我们讨论了调整基础架构的概念。你以隔离的方式进行了简单的负载测试,以找出两个 Web 服务器之间性能的相对差异。拥有了一个经过优化的 Web 服务器,你配置了 AWS ECS Fargate 集群,实现了高可用的云基础架构。通过使用 AWS 的 npm 脚本,你学会了如何编写可重复和可靠的无停机蓝绿部署。最后,你了解了在 AWS 和其他云提供商(如 Heroku、Zeit Now 和 Digital Ocean)上运行基础架构的基本成本。

posted @ 2024-05-18 12:03  绝不原创的飞龙  阅读(15)  评论(0编辑  收藏  举报