Angular9-高级教程-全-

Angular9 高级教程(全)

原文:Pro Angular 9

协议:CC BY-NC-SA 4.0

一、做好准备

Angular 利用了服务器端开发的一些最佳方面,并使用它们来增强浏览器中的 HTML,从而为构建更简单、更容易的富应用奠定了基础。Angular 应用是围绕一个清晰的设计模式构建的,该模式强调创建

  • 可扩展的:一旦你理解了基础知识,就很容易弄清楚一个复杂的 Angular 应用是如何工作的——这意味着你可以轻松地增强应用,为你的用户创造新的有用的功能。

  • 可维护:Angular app 易于调试和修复,意味着长期维护简化。

  • 可测试性:Angular 对单元测试和端到端测试有很好的支持,这意味着你可以在你的用户之前找到并修复缺陷。

  • 标准化 : Angular 构建在 web 浏览器的固有功能上,不会妨碍您,允许您创建符合标准的 web 应用,这些应用利用最新的 HTML 和功能,以及流行的工具和框架。

Angular 是一个开源的 JavaScript 库,由 Google 赞助和维护。它已经被用在一些最大最复杂的网络应用中。在这本书里,我向你展示了在你自己的项目中获得 Angular 的好处所需要知道的一切。

你需要知道什么?

在阅读本书之前,您应该熟悉 web 开发的基础知识,了解 HTML 和 CSS 的工作原理,并具备 JavaScript 的工作知识。如果你对这些细节有些模糊,我在第五章第五章、第六章和第七章中提供了我在本书中使用的 HTML、CSS 和 JavaScript 的刷新工具。不过,你不会找到关于 HTML 元素和 CSS 属性的全面参考,因为一本关于 Angular 的书中没有足够的篇幅来涵盖所有的 HTML。

这本书的结构是什么?

这本书分为三部分,每一部分都涵盖了一系列相关的主题。

第一部分:Angular 入门

本书的第一部分提供了您需要为本书的其余部分做准备的信息。它包括本章和关键技术的入门/刷新者,包括 HTML、CSS 和 TypeScript,后者是 Angular 开发中使用的 JavaScript 的超集。我还将向您展示如何构建您的第一个 Angular 应用,并带您完成构建一个更真实的应用(名为 SportsStore)的过程。

第二部分:详细的 Angular

本书的第二部分将带您浏览 Angular 提供的用于创建应用的构建模块,依次浏览每一个模块。Angular 包含了许多内置功能,我将对这些功能进行深入描述,并提供了无尽的定制选项,我将对所有这些进行演示。

第三部分:高级 Angular 特征

本书的第三部分解释了如何使用高级特性来创建更复杂和可伸缩的应用。我演示了如何在 Angular 应用中发出异步 HTTP 请求,如何使用 URL 路由在应用中导航,以及如何在应用的状态改变时动画显示 HTML 元素。

这本书没有涵盖什么?

这本书是给有经验的 web 开发者的,他们是 Angular 的新手。它没有解释 web 应用或编程的基础,尽管有关于 HTML、CSS 和 JavaScript 的入门章节。我没有详细描述服务器端开发——如果您想创建支持 Angular 应用所需的后端服务,请参阅我的其他书籍。

而且,尽管我喜欢深入书中的细节,但并不是每一个 Angular 都在主流开发中有用,我必须将我的书保持在可打印的大小。当我决定省略一个特性时,是因为我认为它不重要,或者是因为使用我所涉及的技术可以达到相同的结果。

Angular 开发需要什么软件?

你需要一个代码编辑器和第二章中描述的工具。Angular 开发所需的一切都是免费的,可以在 Windows、macOS 和 Linux 上使用。

我如何设置开发环境?

第二章通过创建一个简单的应用来介绍 Angular,作为这个过程的一部分,我将告诉你如何创建一个使用 Angular 的开发环境。

如果我对这些例子有疑问怎么办?

首先要做的是回到这一章的开头,重新开始。大多数问题都是由于遗漏了一个步骤或者没有完全按照清单来做造成的。请密切注意代码清单中的重点,它突出了需要进行的更改。

接下来,查看勘误表/更正表,它包含在本书的 GitHub 资源库中。尽管我和我的编辑尽了最大的努力,技术书籍还是很复杂,错误是不可避免的。请查看勘误表,了解已知错误列表以及解决这些错误的说明。

如果您仍然有问题,那么从本书的 GitHub 资源库 https://github.com/Apress/pro-angular-9 下载您正在阅读的章节的项目,并将其与您的项目进行比较。我通过阅读每一章来创建 GitHub 存储库的代码,所以您的项目中应该有相同内容的相同文件。

如果你仍然不能让例子工作,那么你可以联系我在adam@adam-freeman.com寻求帮助。请在邮件中明确你在看哪本书,哪个章节/例子导致了这个问题。请记住,我会收到很多电子邮件,我可能不会立即回复。

如果我发现书中有错误怎么办?

您可以在adam@adam-freeman.com通过电子邮件向我报告错误,尽管我要求您首先检查这本书的勘误表/更正列表,您可以在本书的 GitHub 资源库 https://github.com/Apress/pro-angular-9 中找到,以防它已经被报告。

我在 GitHub 存储库的勘误表/更正文件中添加了可能给读者造成困惑的错误,尤其是示例代码的问题,并对第一个报告该错误的读者表示感谢。我保留了一个不太严重的问题的列表,这通常意味着例子周围的文本中的错误,当我编写新版本时,我会使用它们。

有很多例子吗?

个载荷的例子。学习 Angular 的最好方法是通过例子,我已经尽可能多地将它们打包到本书中。为了使本书中的例子数量最大化,我采用了一个简单的约定来避免一遍又一遍地列出文件的内容。我第一次在一个章节中使用一个文件时,我会列出完整的内容,就像我在清单 1-1 中所做的一样。我在列表的标题中包含了文件的名称,以及您应该创建它的文件夹。当我修改代码时,我用粗体显示修改后的语句。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { AppComponent } from "./app.component";
import { StoreModule } from "./store/store.module";

@NgModule({
    imports: [BrowserModule, StoreModule],
    declarations: [AppComponent],
    bootstrap: [AppComponent]
})
export class AppModule { }

Listing 1-1.A Complete Example Document

该列表摘自第七章。不要担心它做什么;请注意,这是一个完整的列表,显示了文件的全部内容。

当我对同一个文件进行一系列修改时,或者当我对一个大文件进行小的修改时,我只向您展示发生变化的元素,以创建一个部分清单。您可以发现部分清单,因为它以省略号(...)开始和结束,如清单 1-2 所示。

...
<table class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <tr *ngFor="let item of getProducts(); let i = index" pa-attr>
        <td>{{i + 1}}</td>
        <td>{{item.name}}</td>
        <td pa-attr pa-attr-class="bg-warning">{{item.category}}</td>
        <td pa-attr pa-attr-class="bg-info">{{item.price}}</td>
    </tr>
</table>
...

Listing 1-2.A Partial Listing

列表 1-2 来自后面的章节。您可以看到只显示了body元素及其内容,并且我突出显示了一些语句。这就是我如何将您的注意力吸引到清单中已经更改的部分,或者强调示例中显示我所描述的特性或技术的部分。在某些情况下,我需要对同一个文件的不同部分进行修改,在这种情况下,为了简洁起见,我省略了一些元素或语句,如清单 1-3 所示。

import { ApplicationRef, Component } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";
import { ProductFormGroup } from "./form.model";

@Component({
    selector: "app",
    templateUrl: "app/template.html"
})
export class ProductComponent {
    model: Model = new Model();
    form: ProductFormGroup = new ProductFormGroup();

    // ...other members omitted for brevity...

    showTable: boolean = true;
}

Listing 1-3.Omitting Statements for Brevity

这种约定让我可以提供更多的例子,但是这确实意味着很难找到一种特定的技术。为此,我在第二部分和第三部分中描述 Angular 特性的章节以一个汇总表开始,该表描述了本章中包含的技术以及演示如何使用它们的清单。

从哪里可以获得示例代码?

你可以从 https://github.com/Apress/pro-angular-9 下载本书所有章节的范例项目。

我如何联系作者?

你可以在adam@adam-freeman.com给我发邮件。自从我第一次在我的书中发表电子邮件地址已经有几年了。我不能完全肯定这是一个好主意,但我很高兴我这样做了。我收到了来自世界各地的电子邮件,来自各行各业工作或学习的读者,无论如何,在大多数情况下,这些电子邮件都是积极的,礼貌的,并且很高兴收到。

我试着及时回复,但我会收到很多邮件,有时会积压,尤其是当我埋头写完一本书的时候。我总是试图帮助那些被书中的一个例子困住的读者,尽管我要求你在联系我之前按照本章前面描述的步骤去做。

虽然我欢迎读者的电子邮件,但有一些常见问题的答案永远是“不”。我担心我不会为你的新公司编写代码,不会帮助你完成大学作业,不会参与你的开发团队的设计争议,也不会教你如何编程。

如果我真的喜欢这本书呢?

请发电子邮件到adam@adam-freeman.com告诉我。收到一个快乐的读者的来信总是一件令人高兴的事,我很感激花时间发送这些邮件。写这些书可能很难,而这些邮件为坚持一项有时感觉不可能的活动提供了必要的动力。

如果这本书让我生气了,我想投诉怎么办?

你仍然可以在adam@adam-freeman.com给我发邮件,我仍然会尽力帮助你。请记住,只有当你解释了问题是什么,以及你希望我做些什么时,我才能提供帮助。你应该明白,有时唯一的结果是接受我不是你的作者,只有当你归还这本书并选择另一本时,我们才会结束。我会仔细考虑让你心烦意乱的事情,但是经过 25 年的写书生涯,我逐渐明白,并不是每个人都喜欢读我喜欢写的书。

摘要

在这一章中,我概述了这本书的内容和结构。学习 Angular 开发的最好方法是通过例子,所以在下一章,我将直接向您展示如何设置您的开发环境,并使用它来创建您的第一个 Angular 应用。

二、你的第一个 Angular 应用

开始使用 Angular 的最佳方式是深入研究并创建一个 web 应用。在本章中,我将向您展示如何设置您的开发环境,并带您完成创建一个基本应用的过程,从功能的静态模型开始,应用 Angular 特性来创建一个动态的 web 应用,尽管是一个简单的应用。在第七章 7 章–10 章中,我将向你展示如何创建一个更加复杂和真实的 Angular 应用,但是现在,一个简单的例子将足以演示 Angular 应用的主要组件,并为本书这一部分的其他章节做好准备。

如果你没有遵循本章的所有内容,也不要担心。Angular 有一个陡峭的学习曲线,所以这一章的目的只是介绍 Angular 开发的基本流程,并给你一些东西是如何组合在一起的感觉。现在这一切都没有意义,但是当你读完这本书的时候,你会明白我在这一章中采取的每一步,以及更多。

准备开发环境

角发育需要一些准备。在接下来的部分中,我将解释如何设置和准备创建您的第一个项目。流行的开发工具中广泛支持 Angular,您可以挑选自己喜欢的。

安装 Node.js

许多用于 Angular 开发的工具都依赖于 Node . js——也称为 Node——它创建于 2009 年,是用 JavaScript 编写的服务器端应用的一个简单高效的运行时。Node.js 基于 Chrome 浏览器中使用的 JavaScript 引擎,并提供了一个在浏览器环境之外执行 JavaScript 代码的 API。

Node.js 作为应用服务器已经取得了成功,但对于本书来说,它很有趣,因为它为新一代跨平台开发和构建工具提供了基础。Node.js 团队的一些聪明的设计决策和 Chrome JavaScript 运行时提供的跨平台支持创造了一个机会,被热情的工具作者抓住了。简而言之,Node.js 已经成为 web 应用开发的基础。

重要的是,您下载的 Node.js 版本与我在本书中使用的版本相同。尽管 Node.js 相对稳定,但仍不时会有突破性的 API 变化,这可能会使我在本章中包含的示例无法工作。

我使用的版本是 12.15.0,这是我撰写本文时的当前长期支持版本。在您阅读本文时,可能会有更高的版本,但是对于本书中的示例,您应该坚持使用 12.15.0 版本。在 https://nodejs.org/dist/v12.15.0 可以获得完整的 12.15.0 版本,包括 Windows 和 macOS 的安装程序以及其他平台的二进制包。运行安装程序,确保选中“npm 包管理器”选项和两个添加到路径选项,如图 2-1 所示。

img/421542_4_En_2_Fig1_HTML.jpg

图 2-1。

配置节点安装

安装完成后,运行清单 2-1 中所示的命令。

node -v

Listing 2-1.Running Node.js

如果安装正常进行,您将会看到下面显示的版本号:

v12.15.0

Node.js 安装程序包括节点包管理器(NPM),用于管理项目中的包。运行清单 2-2 中所示的命令,确保 NPM 正在工作。

npm -v

Listing 2-2.Running NPM

如果一切正常,您将看到以下版本号:

6.13.4

安装 angular-cli 软件包

angular-cli包已经成为开发期间创建和管理 Angular 项目的标准方式。在这本书的最初版本中,我演示了如何从零开始建立一个 Angular 项目,这是一个冗长且容易出错的过程,通过angular-cli得到了简化。要安装angular-cli,打开一个新的命令提示符并运行清单 2-3 中所示的命令。

npm install --global @angular/cli@9.0.1

Listing 2-3.Installing the angular-cli Package

注意在global参数前有两个连字符。如果你使用的是 Linux 或者 macOS,你可能需要使用sudo,如清单 2-4 所示。

sudo npm install --global @angular/cli@9.0.1

Listing 2-4.Using sudo to Install the angular-cli Package

安装编辑器

Angular 开发可以用任何一个程序员的编辑器来完成,从中有数不尽的选择。一些编辑器增强了对 Angular 的支持,包括突出显示关键术语和良好的工具集成。

选择编辑器时,最重要的考虑因素之一是过滤项目内容的能力,以便您可以专注于文件的子集。在一个 Angular 项目中可能会有很多文件,并且许多文件都有相似的名称,因此能够找到并编辑正确的文件是非常重要的。编辑器以不同的方式实现了这一点,要么显示打开进行编辑的文件列表,要么提供排除具有特定扩展名的文件的能力。

本书中的例子不依赖于任何特定的编辑器,我使用的所有工具都是从命令行运行的。如果你还没有一个 web 应用开发的首选编辑器,那么我推荐使用 Visual Studio Code,它是微软免费提供的,对 Angular 开发有极好的支持。可以从 https://code.visualstudio.com 下载 Visual Studio Code。

安装浏览器

最后要选择的是在开发过程中用来检查工作的浏览器。所有的当代浏览器都有很好的开发者支持,并且与 Angular 配合得很好。我在这本书里一直使用谷歌浏览器,这也是我推荐你使用的浏览器。

创建和准备项目

一旦你有了 Node.js、angular-cli包、编辑器和浏览器,你就有足够的基础来开始开发过程。

创建项目

要创建项目,选择一个方便的位置,并使用命令提示符运行清单 2-5 中所示的命令,这将创建一个名为todo的新 Angular 项目。

Note

如果您在 Windows 上使用 PowerShell,那么在运行清单 2-5 中的命令之前,您可能需要使用Set-ExecutionPolicy RemoteSigned命令来启用脚本执行。

ng new todo --routing false --style css --skip-git --skip-tests

Listing 2-5.Creating the Angular Project

ng命令由angular-cli包提供,ng new建立一个新项目。自变量配置项目,选择适用于第一个项目的选项(配置选项在第十一章中描述)。

安装过程会创建一个名为todo的文件夹,其中包含开始 Angular 开发所需的所有配置文件、一些开始开发的占位符文件以及开发、运行和部署 Angular 应用所需的 NPM 包。(项目创建可能需要一段时间,因为有大量的包要下载。)

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

创建项目后,使用您喜欢的代码编辑器打开它进行编辑。todo文件夹包含许多用于 Angular 开发的工具的配置文件(在第十一章中描述),而src/app文件夹包含应用的代码和内容,也是大多数开发工作完成的文件夹。图 2-2 显示了 Visual Studio Code 中出现的项目文件夹的初始内容,并突出显示了src/app文件夹。您可能会看到与其他编辑器略有不同的视图,其中一些编辑器隐藏了开发过程中不经常直接使用的文件和文件夹,例如node_modules文件夹,它包含了 Angular 开发工具所依赖的包。

img/421542_4_En_2_Fig2_HTML.jpg

图 2-2。

项目文件夹的内容

启动开发工具

一切就绪,现在是测试 Angular 开发工具的时候了。使用命令提示符运行todo文件夹中清单 2-6 所示的命令。

ng serve

Listing 2-6.Starting the Angular Development Tools

该命令启动 Angular 开发工具,这些工具通过初始构建过程为开发会话准备应用。此过程需要一段时间,并将生成类似于以下内容的输出:

Compiling @angular/core : es2015 as esm2015

Compiling @angular/common : es2015 as esm2015

Compiling @angular/platform-browser : es2015 as esm2015

Compiling @angular/platform-browser-dynamic : es2015 as esm2015

chunk {main} main.js, main.js.map (main) 57.8 kB [initial] [rendered]
chunk {polyfills} polyfills.js, polyfills.js.map (polyfills) 140 kB [initial] [rendered]
chunk {runtime} runtime.js, runtime.js.map (runtime) 6.15 kB [entry] [rendered]
chunk {styles} styles.js, styles.js.map (styles) 9.74 kB [initial] [rendered]
chunk {vendor} vendor.js, vendor.js.map (vendor) 2.67 MB [initial] [rendered]
Date: 2020-02-09T11:23:46.619Z - Hash: 3f280025364478cce5b4 - Time: 12859ms
** Angular Live Development Server is listening on localhost:4200, open your browser on http://localhost:4200/ **
: Compiled successfully.

Date: 2020-02-09T11:23:47.497Z - Hash: 3f280025364478cce5b4
5 unchanged chunks

Time: 465ms
: Compiled successfully.

如果您看到略有不同的输出,请不要担心,只要准备工作完成后,您会看到“编译成功”的消息。

项目中的开发工具包括一个 HTTP 服务器。一旦构建过程完成,打开一个新的浏览器窗口并使用它来请求http://localhost:4200,您将看到如图 2-3 所示的内容,它显示了创建项目时添加到项目中的占位符内容。

img/421542_4_En_2_Fig3_HTML.jpg

图 2-3。

占位符内容

向项目中添加 Angular 特征

现在开发工具正在运行,我将创建一个简单的 Angular 应用来管理待办事项列表。用户将能够看到待办事项列表,检查已完成的项目,并创建新项目。为了使应用简单,我假设只有一个用户,并且我不必担心保存应用中数据的状态,这意味着如果关闭或重新加载浏览器窗口,对待办事项列表的更改将会丢失。(后面的例子,包括在第 7–10 章中开发的 SportsStore 应用,演示了持久数据存储。)

创建数据模型

大多数应用的起点是数据模型,它描述了应用运行的领域。数据模型可能很大很复杂,但是对于我的待办应用,我只需要描述两件事:一个待办事项和这些事项的列表。

Angular 应用是用 TypeScript 编写的,TypeScript 是 JavaScript 的超集。我在第六章中介绍了 TypeScript,但是它的主要优点是支持静态数据类型,这使得 JavaScript 开发对于 C#和 Java 开发人员来说更加熟悉。(JavaScript 有一个基于原型的类型系统,许多开发人员对此感到困惑。)命令包括将 TypeScript 代码编译成可由浏览器执行的纯 JavaScript 所需的包。

为了启动应用的数据模型,我在todo/src/app文件夹中添加了一个名为todoItem.ts的文件,其内容如清单 2-7 所示。(TypeScript 文件的扩展名为.ts。)

export class TodoItem {

    constructor(taskVal: string, completeVal: boolean = false) {
        this.task = taskVal;
        this.complete = completeVal;
    }

    task: string;
    complete: boolean;
}

Listing 2-7.The Contents of the todoItem.ts File in the src/app Folder

清单 2-7 中使用的语言特性是标准 JavaScript 特性和 TypeScript 提供的额外特性的混合。当代码被编译时,TypeScript 特性被移除,结果是可以被浏览器执行的 JavaScript 代码。

例如,exportclassconstructor关键字就是标准的 JavaScript。并不是所有的浏览器都支持这些特性,这些特性是 JavaScript 规范中相对较新的内容,正如我在第十一章中解释的那样,Angular 应用的构建过程可以将这种类型的特性翻译成老浏览器可以理解的代码。

export关键字与 JavaScript 模块相关。当使用模块时,每个 TypeScript 或 JavaScript 文件都被认为是一个自包含的功能单元,而export关键字用于标识您想要在应用的其他地方使用的数据或类型。JavaScript 模块用于管理项目中文件之间的依赖关系,避免手动管理 HTML 文件中一组复杂的script元素。有关模块如何工作的详细信息,请参见第十一章。class关键字声明一个类,constructor关键字表示一个类构造器。与 C#等其他语言不同,JavaScript 不使用类名来表示构造函数。

Tip

如果您不熟悉 JavaScript 规范的最新版本中添加的特性,也不用担心。第五章和第六章提供了使用使 Angular 更容易使用的特性编写 JavaScript 的入门知识,第六章也描述了一些有用的特定于 TypeScript 的特性。

清单 2-7 中的其他特性由 TypeScript 提供。当您第一次开始使用 TypeScript 时,最不和谐的特性之一是它的简洁构造函数特性,尽管您很快就会依赖它。清单 2-7 中定义的TodoItem类定义了一个接收两个参数的构造函数,名为taskcomplete。这些参数的值被分配给同名的public属性。如果没有为complete参数提供值,那么将使用默认值false

简洁的构造函数避免了可能需要的样板代码块,这些代码块容易出现打字错误或者只是忘记给属性分配参数。如果没有简洁的构造函数,我将不得不像这样编写TodoItem类:

...
class TodoItem {

    constructor(taskVal: string, completeVal: boolean = false) {
        this.task = taskVal;
        this.complete = completeVal;
    }

    task: string;
    complete: boolean;
}
...

事实上,我本可以不用简洁的构造函数来编写TodoItem类。TypeScript 试图在不碍事的情况下提供帮助,您可以忽略或禁用它的所有功能,更多地依赖 JavaScript 的标准功能。正如我在后面的章节中解释的那样,Angular 开发依赖于一些特性,但是您可以逐渐接受 TypeScript 特性,或者,如果您愿意,只选择您喜欢的特性。

标题 TypeScript 特性是静态类型。清单 2-7 中的每个构造函数参数都被标注了一个类型,如下所示:

...
constructor(taskVal: string, completeVal: boolean = false) {
...

如果在调用构造函数时使用了不兼容的类型,TypeScript 编译器将报告错误。如果您从 C#或 Java 开始进行 Angular 开发,这似乎是显而易见的,但这不是 JavaScript 通常的工作方式。

创建待办事项列表类

为了创建一个表示待办事项列表的类,我在src/app文件夹中添加了一个名为todoList.ts的文件,并添加了清单 2-8 中所示的代码。

import { TodoItem } from "./todoItem";

export class TodoList {

    constructor(public user: string, private todoItems: TodoItem[] = []) {
        // no statements required
    }

    get items(): readonly TodoItem[]  {
        return this.todoItems;
    }

    addItem(task: string) {
        this.todoItems.push(new TodoItem(task));
    }
}

Listing 2-8.The Contents of the todoList.ts File in the src/app Folder

import关键字声明了对TodoItem类的依赖,并指定了定义它的代码文件。TodoList类定义了一个接收初始待办事项集合的构造函数。我不想无限制地访问TodoItem对象的数组,所以我定义了一个名为items的属性,它返回一个只读数组,这是使用readonly关键字完成的。对于任何试图修改数组内容的语句,TypeScript 编译器都会生成错误,如果您使用的编辑器具有良好的 TypeScript 支持,如 Visual Studio Code,则编辑器的自动完成功能不会提供会触发编译器错误的方法和属性。

向用户显示数据

我需要一种方法向用户显示模型中的数据值。在 Angular 中,这是使用一个模板来完成的,这个模板是 HTML 的一个片段,包含 Angular 评估的表达式,并将结果插入到发送给浏览器的内容中。该项目的angular-cli设置在src/app文件夹中创建了一个名为app.component.html的模板文件。我编辑了这个文件,删除了占位符内容,并添加了清单 2-9 中所示的内容。

<h3>
  {{ username }}'s To Do List
  <h6>{{ itemCount }} Items</h6>
</h3>

Listing 2-9.Replacing the Contents of the app.component.html File in the src/app Folder

我很快会在这个文件中添加更多的元素,但是两个 HTML 元素就足够了。在一个模板中包含一个数据值是通过使用双括号来完成的— {{}}——Angular 会计算您放在双括号之间的任何内容,以获得要显示的值。

{{}}字符是数据绑定的一个例子,这意味着它们创建了模板和数据值之间的关系。数据绑定是一个重要的 Angular 特性,当我在示例应用中添加特性时,您将在本章中看到更多的数据绑定(我将在本书的第二部分详细描述它们)。在这种情况下,数据绑定告诉 Angular 获取usernameitemCount属性的值,并将它们插入到h3div元素的内容中。

一旦保存了文件,Angular 开发工具就会尝试构建项目。编译器将生成以下错误:

ERROR in src/app/app.component.html:1:8 - error TS2339: Property 'username' does not exist on type 'AppComponent'.

    1 {{ username }}'s To Do List
             ~~~~~~~~~
      src/app/app.component.ts:5:16
        5   templateUrl: './app.component.html',
                         ~~~~~~~~~~~~~~~~~~~~~~
        Error occurs in the template of component AppComponent.

    src/app/app.component.html:2:9 - error TS2339: Property 'itemCount' does not exist on type 'AppComponent'.

    2 <h6>{{ itemCount }} Incomplete Items</h6>
              ~~~~~~~~~~
      src/app/app.component.ts:5:16
        5   templateUrl: './app.component.html',
                         ~~~~~~~~~~~~~~~~~~~~~~
        Error occurs in the template of component AppComponent.

出现这些错误是因为我在数据绑定中使用的属性不存在,所以 Angular 无法获得我告诉它在模板中使用的值。我将在下一节中解决这个问题。

更新组件

Angular 组件负责管理模板,并为其提供所需的数据和逻辑。如果这似乎是一个宽泛的说法,那是因为组件是 Angular 应用的一部分,它承担了大部分繁重的工作。因此,它们可以用于各种任务。

在这种情况下,我需要一个组件作为数据模型类和模板之间的桥梁,这样我就可以创建一个TodoList类的实例,用一些示例TodoItem对象填充它,并且在这样做的时候,为模板提供它需要的usernameitemCount属性。angular-cli设置在todo/src/app文件夹中创建了一个名为app.component.ts的占位符组件文件,我对其进行了编辑,以做出清单 2-10 中突出显示的更改。

import { Component } from '@angular/core';
import { TodoList } from "./todoList";
import { TodoItem } from "./todoItem";

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  private list = new TodoList("Bob", [
    new TodoItem("Go for run", true),
    new TodoItem("Get flowers"),
    new TodoItem("Collect tickets"),
  ]);

  get username(): string {
    return this.list.user;
  }

  get itemCount(): number {
    return this.list.items
      .filter(item => !item.complete).length;
  }
}

Listing 2-10.Editing the Contents of the app.component.ts File in the src/app Folder

清单中的代码可以分为三个主要部分,如以下部分所述。

了解进口

import关键字与export关键字相对应,用于声明对 JavaScript 模块内容的依赖。在清单 2-10 中,import关键字被使用了三次。

...
import { Component } from '@angular/core';
import { TodoList } from "./todoList";
import { TodoItem } from "./todoItem";
...

清单中的第一个import语句用于加载@angular/core模块,该模块包含关键的 Angular 功能,包括对组件的支持。当处理模块时,import语句指定在花括号中导入的类型。在这种情况下,import语句用于从模块加载Component类型。@angular/core模块包含许多打包在一起的类,这样浏览器就可以将它们全部加载到一个 JavaScript 文件中。

其他的import语句用于声明对前面定义的数据模型类的依赖。这种导入的目标以./开始,这表明该模块是相对于当前文件定义的。

注意,import语句都不包含文件扩展名。这是因为一个import语句的目标和浏览器加载的文件之间的关系是由 Angular build 工具处理的,它将应用打包并发送给浏览器,我将在第十一章中对此进行更详细的解释。

了解装修工

清单中最奇怪的代码部分是这样的:

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

这是一个装饰器的例子,它提供了一个类的元数据。这是@Component装饰器,顾名思义,它告诉 Angular 这是一个组件。装饰器通过其属性提供配置信息。这个@Component装饰器指定了三个属性:selectortemplateUrlstyleUrls

属性指定了一个 CSS 选择器,它匹配组件将要应用到的 HTML 元素。这个装饰器指定的app-root元素是由angular-cli包默认设置的。它对应于一个添加到index.html文件中的 HTML 元素,您可以在src文件夹中找到该文件,它是用以下内容创建的:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Todo</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
  <app-root></app-root>
</body>
</html>

我突出显示了 HTML 元素。属性告诉 Angular 由组件生成的内容应该插入到元素中。

templateUrl属性用于指定组件的模板,即该组件的app.component.html文件。styleUrls属性指定了一个或多个 CSS 样式表,用于样式化组件及其模板产生的元素。稍后我将使用这个特性来改进示例应用的外观。

理解课程

清单的最后一部分定义了一个类,Angular 可以实例化这个类来创建组件。

...
export class AppComponent {
  private list = new TodoList("Bob", [
    new TodoItem("Go for run", true),
    new TodoItem("Get flowers"),
    new TodoItem("Collect tickets"),
  ]);

  get username(): string {
    return this.list.user;
  }

  get itemCount(): number {
    return this.list.items.filter(item => !item.complete).length;
  }
}
...

这些语句定义了一个名为AppComponent的类,该类有一个私有的list属性,该属性被赋予一个TodoList对象,并由一组TodoItem对象填充。AppComponent类定义了名为usernameitemCount的只读属性,这些属性依赖于TodoList对象来产生它们的值。username属性返回TodoList.user属性的值,itemCount属性使用标准的 JavaScript 数组特性来过滤由TodoList管理的Todoitem对象,以选择那些不完整的对象,并返回它找到的匹配对象的数量。

使用λ函数生成itemCount属性的值,也称为粗箭头函数,这是表达标准 JavaScript 函数的一种更简洁的方式。lambda 表达式中的箭头读作“goes to”,例如“item goes to not item.complete”Lambda 表达式是 JavaScript 语言规范中的一个新成员,它提供了一种使用函数作为参数的传统方法的替代方法,如下所示:

...
return this.model.items.filter(function (item) { return !item.complete });
...

当您保存对 TypeScript 文件的更改时,Angular 开发工具将构建项目。这一次应该没有错误,因为组件已经定义了模板所需的属性。浏览器窗口将自动重新加载,显示图 2-4 中的输出。

img/421542_4_En_2_Fig4_HTML.jpg

图 2-4。

在示例应用中生成内容

HTML 元素的样式

我已经到了 Angular 生成内容的地步,但是结果只是纯文本。我将把引导 CSS 框架添加到应用中,并用它来设计内容的样式。有许多好的 CSS 框架可用,但 Bootstrap 是最受欢迎的一个。我在第四章提供了使用 Bootstrap 的简单介绍,所以如果你以前没有用过也不用担心。停止 Angular 开发工具,使用命令提示符运行清单 2-11 中所示的命令,将引导包添加到项目中。

npm install bootstrap@4.4.1

Listing 2-11.Adding a Package to the Example Project

这个命令安装 4.4.1 版的引导包,这是我在本书中使用的版本。要在发送到浏览器的 HTML 内容中包含引导 CSS 样式,请将清单 2-12 中所示的条目添加到angular.json文件的styles部分,该文件是在创建项目时通过ng new命令添加到todo文件夹中的。

Caution

angular.json文件中有两个styles部分。将清单 2-12 中所示的设置添加到最靠近文件顶部的位置。

...
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
  "builder": "@angular-devkit/build-angular:browser",
    "options": {
      "outputPath": "dist/todo",
      "index": "src/index.html",
      "main": "src/main.ts",
      "polyfills": "src/polyfills.ts",
      "tsConfig": "tsconfig.app.json",
      "aot": true,
      "assets": [
        "src/favicon.ico",
        "src/assets"
       ],
       "styles": [
         "src/styles.css",
         "node_modules/bootstrap/dist/css/bootstrap.min.css"
       ],
       "scripts": []
    },
...

Listing 2-12.Configuring CSS in the angular.json File in the todo Folder

正如我在第十一章中解释的那样,angular.json文件用于配置项目工具,清单中显示的语句将引导 CSS 文件合并到项目中,这样它将包含在发送到浏览器的内容中。

Bootstrap 通过向类中添加元素来工作。在清单 2-13 中,我已经将模板中的元素添加到类中,这些元素将改变它们的外观。

<h3 class="bg-primary text-center text-white p-2">
  {{ username }}'s To Do List
  <h6 class="mt-1">{{ itemCount }} Incomplete Items</h6>
</h3>

Listing 2-13.Styling Content in the app.component.html File in the src/app Folder

使用命令提示符运行清单 2-14 中所示的命令,这将再次启动 Angular 开发工具。

ng serve

Listing 2-14.Starting the Angular Development Tools

浏览器可能会自动更新,但如果没有,则手动重新加载以查看样式化的内容,如图 2-5 所示。

img/421542_4_En_2_Fig5_HTML.jpg

图 2-5。

应用生成的 HTML 样式

显示待办事项列表

下一步是显示待办事项。清单 2-15 向组件添加一个属性,提供对列表中项目的访问。

import { Component } from '@angular/core';
import { TodoList } from "./todoList";
import { TodoItem } from "./todoItem";

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  private list = new TodoList("Bob", [
    new TodoItem("Go for run"),
    new TodoItem("Get flowers"),
    new TodoItem("Collect tickets"),
  ]);

  get username(): string {
    return this.list.user;
  }

  get itemCount(): number {
    return this.list.items.filter(item => !item.complete).length;
  }

  get items(): readonly TodoItem[] {
    return this.list.items;
  }
}

Listing 2-15.Adding a Property in the app.component.ts File in the src/app Folder

为了向用户显示每个项目的细节,我将清单 2-16 中所示的元素添加到模板中。

<h3 class="bg-primary text-center text-white p-2">
  {{ username }}'s To Do List
  <h6 class="mt-1">{{ itemCount }} Incomplete Items</h6>
</h3>

<table class="table table-striped table-bordered table-sm">
  <thead>
      <tr><th>#</th><th>Description</th><th>Done</th></tr>
  </thead>
  <tbody>
      <tr *ngFor="let item of items; let i = index">
          <td>{{ i + 1 }}</td>
          <td>{{ item.task }}</td>
          <td [ngSwitch]="item.complete">
              <span *ngSwitchCase="true">Yes</span>
              <span *ngSwitchDefault>No</span>
          </td>
      </tr>
  </tbody>
</table>

Listing 2-16.Adding Elements in the app.component.html File in the src/app Folder

对模板的添加依赖于几个不同的 Angular 特征。第一个是*ngFor表达式,用于为数组中的每一项重复一个内容区域。这是一个指令的例子,我在第 13–16 章中描述了它(指令是 Angular 发展的一个很大的部分,这就是为什么它们在几章中被描述)。*ngFor表达式应用于元素的属性,如下所示:

...
<tr *ngFor="let item of items; let i = index">
...

这个表达式告诉 Angular 将它所应用到的tr元素作为一个模板,应该为组件的items属性返回的每个对象重复这个模板。表达式的let item部分指定每个对象应该被分配给一个名为item的变量,这样它就可以在模板中被引用。

ngFor表达式还跟踪正在处理的数组中当前对象的索引,并将其赋给第二个变量i

...
<tr *ngFor="let item of items; let i = index">
...

结果是,tr元素及其内容将被复制并插入到由items属性返回的每个对象的 HTML 文档中;对于每次迭代,可以通过名为item的变量访问当前的待办对象,通过名为i的变量访问对象在数组中的位置。

Tip

使用*ngFor时记住*字符很重要。我会在第十三章中解释它的含义。

tr模板中,有两个数据绑定,可以通过{{}}字符识别,如下所示:

...
<td>{{ i + 1 }}</td>
<td>{{ item.task }}</td>
...

这些绑定引用由*ngFor表达式创建的变量。绑定不仅仅用于引用属性和方法名;它们也可以用来执行简单的 JavaScript 操作。您可以在第一个绑定中看到这样的例子,我将变量i和 1 相加。

Tip

对于简单的转换,你可以像这样直接在绑定中嵌入你的 JavaScript 表达式,但是对于更复杂的操作,Angular 有一个叫做 pipes 的特性,我在第十八章中描述过。

tr模板中剩余的模板表达式演示了如何有选择地生成内容。

...
<td [ngSwitch]="item.complete">
    <span *ngSwitchCase="true">Yes</span>
    <span *ngSwitchDefault>No</span>
</td>
...

[ngSwitch]表达式是一个条件语句,用于根据指定的值将不同的元素集插入到文档中,该值在本例中是item.complete属性。嵌套在td元素中的是两个用*ngSwitchCase*ngSwitchDefault标注的span元素,它们相当于普通 JavaScript switch块的casedefault关键字。我在第十三章中详细描述了ngSwitch(以及第十四章中方括号的含义),但结果是当item.complete属性值为true时第一个span元素被添加到文档中,当item.completefalse时第二个span元素被添加到文档中。结果是item.complete属性的true / false值被转换成包含YesNospan元素。当您保存对模板的更改时,浏览器将重新加载,并显示待办事项表,如图 2-6 所示。

img/421542_4_En_2_Fig6_HTML.jpg

图 2-6。

显示待办事项表

如果您使用浏览器的 F12 开发工具,您将能够看到模板生成的 HTML 内容。(查看页面源代码是做不到这一点的,它只显示了服务器发送的 HTML,而没有显示 Angular 使用 DOM API 所做的更改。)

您可以看到模型中的每个待办事项如何在表格中生成一行,该行填充有local项和i变量,以及开关表达式如何显示 Yes 或 No 来指示任务是否已完成。

...
<tr>
    <td>2</td>
    <td>Get flowers</td>
    <td><span>No</span></td>
</tr>
...

创建双向数据绑定

目前,模板只包含单向数据绑定,这意味着它们用于显示数据值,但不能改变它。Angular 还支持双向数据绑定,可以用来显示数据值和修改它。HTML 表单元素使用双向绑定,清单 2-17 向模板添加了一个 checkbox input元素,允许用户将待办事项标记为完成。

<h3 class="bg-primary text-center text-white p-2">
  {{ username }}'s To Do List
  <h6 class="mt-1">{{ itemCount }} Incomplete Items</h6>
</h3>

<table class="table table-striped table-bordered table-sm">
  <thead>
      <tr><th>#</th><th>Description</th><th>Done</th></tr>
  </thead>
  <tbody>
      <tr *ngFor="let item of items; let i = index">
          <td>{{ i + 1 }}</td>
          <td>{{ item.task }}</td>
          <td><input type="checkbox" [(ngModel)]="item.complete" /></td>
          <td [ngSwitch]="item.complete">
              <span *ngSwitchCase="true">Yes</span>
              <span *ngSwitchDefault>No</span>
          </td>
      </tr>
  </tbody>
</table>

Listing 2-17.Adding a Two-Way Binding in the app.component.html File in the src/app Folder

ngModel模板表达式在数据值(本例中为item.complete属性)和表单元素(本例中为input元素)之间创建了一个双向绑定。当您保存对模板的更改时,您将看到一个包含复选框的新列出现在表格中。复选框的初始值是使用item.complete属性设置的,就像常规的单向绑定一样,但是当用户切换复选框时,Angular 通过更新指定的模型属性来响应。

当保存对模板的更改时,Angular 开发工具将报告一个错误,因为ngModel功能尚未启用。Angular 应用有一个根模块,用于配置应用。示例应用的根模块在app.module.ts文件中定义,清单 2-18 中显示的更改启用了双向绑定特性。

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from "@angular/forms";

import { AppComponent } from './app.component';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule, FormsModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Listing 2-18.Enabling a Feature in the app.module.ts File in the src/app Folder

Angular 提供的功能是在单独的 JavaScript 模块中提供的,这些模块必须用一个import语句添加到应用中,并使用由NgModule装饰器定义的imports属性进行注册。当 Angular 开发工具构建应用时,它们将由imports属性指定的特性合并到发送到浏览器的文件中。对根模块的更改不会自动处理,所以停止 Angular 开发工具,运行todo文件夹中清单 2-19 所示的命令,再次启动它们。(开发工具将不再报告错误,但是在您重新启动工具之前,复选框将不起作用。)

ng serve

Listing 2-19.Starting the Angular Development Tools

浏览器将在包含复选框的表格中显示一个附加列。每个复选框的状态基于一个TodoItem对象的complete属性的值。为了演示复选框是用双向绑定设置的,我留下了包含 Yes/No 值的列。当您切换复选框时,相应的是/否值也会改变,如图 2-7 所示。

img/421542_4_En_2_Fig7_HTML.jpg

图 2-7。

使用双向数据绑定更改模型值

请注意,未完成项目的数量也会更新。这揭示了一个重要的 Angular 特征:数据模型是活动的。这意味着当数据模型发生变化时,数据绑定(甚至是单向数据绑定)也会更新。这简化了 web 应用的开发,因为这意味着您不必担心在应用状态改变时显示更新。

过滤待办事项

复选框允许更新数据模型,下一步是删除标记为完成的待办事项。清单 2-20 改变了组件的items属性,这样它就可以过滤掉所有已经完成的项目。

import { Component } from '@angular/core';
import { TodoList } from "./todoList";
import { TodoItem } from "./todoItem";

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  private list = new TodoList("Bob", [
    new TodoItem("Go for run", true),
    new TodoItem("Get flowers"),
    new TodoItem("Collect tickets"),
  ]);

  get username(): string {
    return this.list.user;
  }

  get itemCount(): number {
    return this.items.length;
  }

  get items(): readonly TodoItem[] {
    return this.list.items.filter(item => !item.complete);
  }
}

Listing 2-20.Filtering To-Do Items in the app.component.ts File in the src/app Folder

filter 方法是一个标准的 JavaScript 数组函数,因为这是我之前在itemCount属性中使用的同一表达式,所以我更新了该属性以避免代码重复。由于数据模型是动态的,变化会立即反映在数据绑定中,所以选中某个项目的复选框会将其从视图中移除,如图 2-8 所示。

img/421542_4_En_2_Fig8_HTML.png

图 2-8。

过滤待办事项

添加待办事项

如果没有向列表中添加新项目的能力,待办事项应用就没有多大用处。清单 2-21 向模板中添加元素,允许用户输入任务的细节。

<h3 class="bg-primary text-center text-white p-2">
  {{ username }}'s To Do List
  <h6 class="mt-1">{{ itemCount }} Incomplete Items</h6>
</h3>

<div class="container-fluid">
  <div class="row">
    <div class="col">
      <input class="form-control" placeholder="Enter task here" #todoText />
    </div>
    <div class="col-auto">
      <button class="btn btn-primary" (click)="addItem(todoText.value)">
        Add
      </button>
    </div>
  </div>
</div>

<div class="m-2">
  <table class="table table-striped table-bordered table-sm">
    <thead>
        <tr><th>#</th><th>Description</th><th>Done</th></tr>
    </thead>
    <tbody>
        <tr *ngFor="let item of items; let i = index">
            <td>{{ i + 1 }}</td>
            <td>{{ item.task }}</td>
            <td><input type="checkbox" [(ngModel)]="item.complete" /></td>
            <td [ngSwitch]="item.complete">
                <span *ngSwitchCase="true">Yes</span>
                <span *ngSwitchDefault>No</span>
            </td>
        </tr>
    </tbody>
  </table>
</div>

Listing 2-21.Adding Elements in the app.component.html File in the src/app Folder

大多数新元素创建一个网格布局来显示一个input元素和一个button元素。input 元素有一个属性,其名称以#字符开头,用于定义一个变量来引用模板数据绑定中的元素。

...
<input class="form-control" placeholder="Enter task here" #todoText />
...

变量的名称是todoText,它被应用于button元素的绑定所使用。

...
<button class="btn btn-primary mt-1" (click)="addItem(todoText.value)">
...

这是一个事件绑定的例子,它告诉 Angular 调用一个名为addItem的组件方法,使用input元素的value属性作为方法参数。清单 2-22 向组件添加了addItem方法。

Tip

现在不要担心区分绑定。我在第二部分中解释了 Angular 支持的不同类型的绑定,以及每种绑定需要的不同类型的括号或圆括号的含义。它们并不像第一次出现时那么复杂,尤其是当你看到它们是如何融入其他 Angular 框架的时候。

import { Component } from '@angular/core';
import { TodoList } from "./todoList";
import { TodoItem } from "./todoItem";

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  private list = new TodoList("Bob", [
    new TodoItem("Go for run", true),
    new TodoItem("Get flowers"),
    new TodoItem("Collect tickets"),
  ]);

  get username(): string {
    return this.list.user;
  }

  get itemCount(): number {
    return this.items.length;
  }

  get items(): readonly TodoItem[] {
    return this.list.items.filter(item => !item.complete);
  }

  addItem(newItem) {
    if (newItem != "") {
        this.list.addItem(newItem);
    }
  }
}

Listing 2-22.Adding a Method in the app.component.ts File in the src/app Folder

addItem方法接收模板中事件绑定发送的文本,并使用它向待办事项列表添加一个新项目。这些改变的结果是你可以通过在input元素中输入文本并点击添加按钮来创建新的待办事项,如图 2-9 所示。

img/421542_4_En_2_Fig9_HTML.jpg

图 2-9。

创建待办事项

显示已完成的待办事项

基本特性已经就绪,现在是时候结束这个项目了。我首先从模板中删除了表中的 Yes/No 列,并添加了显示已完成任务的选项,如清单 2-23 所示。

<h3 class="bg-primary text-center text-white p-2">
  {{ username }}'s To Do List
  <h6 class="mt-1">{{ itemCount }} {{ showComplete ? "" : "Incomplete" }} Items</h6>
</h3>

<div class="container-fluid">
  <div class="row">
    <div class="col">
      <input class="form-control" placeholder="Enter task here" #todoText />
    </div>
    <div class="col-auto">
      <button class="btn btn-primary" (click)="addItem(todoText.value)">
        Add
      </button>
    </div>
  </div>
</div>

<div class="m-2">
  <table class="table table-striped table-bordered table-sm">
    <thead>
        <tr><th>#</th><th>Description</th><th>Done</th></tr>
    </thead>
    <tbody>
        <tr *ngFor="let item of items; let i = index">
            <td>{{ i + 1 }}</td>
            <td>{{ item.task }}</td>
            <td><input type="checkbox" [(ngModel)]="item.complete" /></td>
            <!-- <td [ngSwitch]="item.complete">
                <span *ngSwitchCase="true">Yes</span>
                <span *ngSwitchDefault>No</span>
            </td> -->
        </tr>
    </tbody>
  </table>
</div>

<div class="bg-secondary text-white text-center p-2">
  <div class="form-check">
    <input class="form-check-input" type="checkbox" [(ngModel)]="showComplete" />
    <label class="form-check-label" for="defaultCheck1">
      Show Completed Tasks
    </label>
  </div>
</div>

Listing 2-23.Adding and Removing Elements in the app.component.html File in the src/app Folder

新元素提供了一个复选框,它有一个名为showComplete的属性的双向数据绑定。在新的表达式中使用相同的属性来改变告诉用户显示多少项的文本。正如我前面提到的,数据绑定可以包含 JavaScript 表达式,在这种情况下,我使用showComplete属性的值来控制单词Incomplete是否包含在输出中。

在清单 2-24 中,我添加了showComplete属性的定义,并使用它的值来决定是否向用户显示已完成的任务。

import { Component } from '@angular/core';
import { TodoList } from "./todoList";
import { TodoItem } from "./todoItem";

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  private list = new TodoList("Bob", [
    new TodoItem("Go for run", true),
    new TodoItem("Get flowers"),
    new TodoItem("Collect tickets"),
  ]);

  get username(): string {
    return this.list.user;
  }

  get itemCount(): number {
    return this.items.length;
  }

  get items(): readonly TodoItem[] {
    return this.list.items.filter(item => this.showComplete || !item.complete);
  }

  addItem(newItem) {
    if (newItem != "") {
        this.list.addItem(newItem);
    }
  }

  showComplete: boolean = false;
}

Listing 2-24.Showing Completed Tasks in the app.component.ts File in the src/app Folder

结果是用户可以决定是否查看已完成的任务,如图 2-10 所示。

img/421542_4_En_2_Fig10_HTML.jpg

图 2-10。

显示已完成的任务

摘要

在这一章中,我向你展示了如何创建你的第一个简单的 Angular 应用,它允许用户创建新的待办事项并将现有的事项标记为完成。

如果这一章中的所有内容都有意义,请不要担心。在这个阶段,重要的是理解 Angular 应用的一般形状,它是围绕数据模型、组件和模板构建的。如果你把这三个关键的组成部分记在心里,那么你就会对接下来的事情有一个背景。在下一章,我将 Angular 放在上下文中。

三、将 Angular 放在上下文中

在这一章中,我将 Angular 放在 web 应用开发的环境中,为后面的章节打下基础。Angular 的目标是将只适用于服务器端开发的工具和功能引入到 web 客户端,这样做可以使开发、测试和维护丰富复杂的 web 应用变得更加容易。

Angular 的工作原理是允许你扩展 HTML,这看起来是一个奇怪的想法,直到你习惯了它。Angular 应用通过自定义元素表达功能,复杂的应用可以生成一个包含标准和自定义标记的 HTML 文档。

Angular 支持的开发风格是通过使用模型-视图-控制器 (MVC)模式派生出来的,尽管这有时被称为模型-视图- 什么的,因为在使用 Angular 时可以遵循这种模式的无数变体。在本书中,我将把重点放在标准 MVC 模式上,因为它是最成熟和最广泛使用的模式。在接下来的章节中,我解释了 Angular 可以带来显著好处的项目的特征(以及那些存在更好的替代方案的项目),描述了 MVC 模式,并描述了一些常见的陷阱。

This Book and the Angular Release Schedule

谷歌对 Angular 采取了积极的发布时间表。这意味着每六个月就会有一个小版本和一个大版本。次要版本不应该破坏任何现有功能,并且应该主要包含错误修复。主要版本可能包含重大更改,并且可能不提供向后兼容性。

要求读者每六个月购买这本书的新版本似乎不公平也不合理,尤其是因为即使在主要版本中,大多数 Angular 特征也不太可能改变。取而代之的是,我将在本书 https://github.com/Apress/pro-angular-9 的 GitHub 资源库中发布主要版本的更新。

对我来说(对出版社来说)这是一个正在进行的实验,但目标是通过补充书中包含的例子来延长这本书的寿命。

我不承诺更新会是什么样的,它们会采取什么形式,或者在我把它们折叠成这本书的新版本之前,我会花多长时间来制作它们。请保持开放的心态,并在新版本发布时查看这本书的资源库。如果您对如何改进更新有任何想法,请发电子邮件至adam@adam-freeman.com告诉我。

了解 Angular 的优势

Angular 不是所有问题的解决方案,知道什么时候应该使用 Angular,什么时候应该寻求替代方案是很重要的。Angular 提供了以前只有服务器端开发人员才能使用的功能,但完全是在浏览器中。这意味着 Angular 在每次加载应用了 Angular 的 HTML 文档时都有大量的工作要做——必须编译 HTML 元素,必须评估数据绑定,必须执行组件和其他构建块,等等。所有这些工作都是构建我在第二章中演示的特性以及我在本书后面描述的特性所必需的。

这种工作需要时间,时间的长短取决于 HTML 文档的复杂程度、相关的 JavaScript 代码,以及关键的浏览器质量和设备的处理能力。在功能强大的台式机上使用最新的浏览器时,你不会注意到任何延迟,但功能不足的智能手机上的旧浏览器确实会减慢 Angular 应用的初始设置。

目标是尽可能少地执行这种设置,并在执行时向用户交付尽可能多的应用。这意味着仔细考虑您构建的 web 应用的类型。从广义上讲,web 应用有两种:往返单页

了解往返和单页应用

很长一段时间以来,web 应用的开发都遵循一个往返模型。浏览器向服务器请求一个初始的 HTML 文档。用户交互——比如单击一个链接或提交一个表单——使浏览器请求并接收一个全新的 HTML 文档。在这种应用中,浏览器本质上是 HTML 内容的呈现引擎,所有的应用逻辑和数据都驻留在服务器上。浏览器发出一系列无状态的 HTTP 请求,服务器通过动态生成 HTML 文档来处理这些请求。

许多当前的 web 开发仍然是针对往返应用的,尤其是因为它们对浏览器的要求很少,这确保了尽可能广泛的客户端支持。但是往返应用也有一些缺点:它们让用户在请求和加载下一个 HTML 文档时等待,它们需要大型的服务器端基础设施来处理所有请求和管理所有应用状态,并且它们需要更多的带宽,因为每个 HTML 文档都必须是自包含的(导致服务器的每个响应中都包含大量相同的内容)。

单页应用采取了不同的方法。一个初始的 HTML 文档被发送到浏览器,但是用户交互会导致 Ajax 请求将小的 HTML 片段或数据插入到向用户显示的现有元素集中。初始的 HTML 文档永远不会被重新加载或替换,当 Ajax 请求被异步执行时,用户可以继续与现有的 HTML 交互,即使这只是意味着看到一个“数据加载”消息。

Angular 擅长单页应用,尤其是复杂的往返应用。对于更简单的项目,直接使用 DOM API 或者通过更简单的库(比如 jQuery)使用通常是更好的选择,尽管没有什么可以阻止您在所有项目中使用 Angular。

单页应用模型对于 Angular 来说是完美的,不仅仅是因为初始化过程,还因为使用 MVC 模式(我将在本章后面描述)的好处真正开始在更大更复杂的项目中体现出来,这些项目正在向单页模型推进。

Tip

你可能遇到的另一个短语是渐进式网络应用 (PWAs)。渐进式应用即使在与网络断开连接时也能继续工作,并且可以访问推送通知等功能。PWA 不是 Angular 特有的,但是我在第十章演示了如何使用简单的 PWA 特性。

比较 Angular to React 和 Vue.js

Angular 有两个主要的竞争对手:React 和 Vue.js。它们之间有一些低级的差异,但是,在大多数情况下,所有这些框架都很优秀,它们都以相似的方式工作,并且它们都可以用来创建丰富而流畅的客户端应用。

这些框架之间的主要区别在于开发人员的体验。例如,Angular 要求您使用 TypeScript 才能有效。如果您习惯于使用 C#或 Java 之类的语言,那么 TypeScript 将会很熟悉,并且可以避免处理 JavaScript 语言的一些奇怪之处。Vue.js 和 React 不需要 TypeScript(尽管这两个框架都支持),但倾向于将 HTML、JavaScript 和 CSS 内容混合在一个文件中,这不是每个人都喜欢的。

我的建议很简单:选择你最喜欢的框架,如果你不喜欢,就换一个。这可能看起来是一种不科学的方法,但是这并不是一个坏的选择,并且您会发现许多核心概念会在框架之间延续,即使您进行了转换。

理解 MVC 模式

术语模型-视图-控制器从 20 世纪 70 年代末就开始使用,起源于施乐 PARC 公司的 Smalltalk 项目,当时它被认为是一种组织早期 GUI 应用的方法。最初 MVC 模式的一些细节依赖于 Smalltalk 特有的概念,例如屏幕工具,但是更广泛的思想仍然适用于应用,并且它们特别适合于 web 应用。

MVC 模式最初通过 Ruby on Rails 和 ASP.NET MVC 框架等工具包在 web 开发的服务器端站稳了脚跟。近年来,MVC 模式也被视为管理客户端 web 开发日益丰富和复杂的一种方式,Angular 就是在这种环境下出现的。

应用 MVC 模式的关键是实现关注点分离的关键前提,其中应用中的数据模型与业务和表示逻辑相分离。在客户端 web 开发中,这意味着分离数据、对数据进行操作的逻辑以及用于显示数据的 HTML 元素。结果是客户端应用更容易开发、维护和测试。

三个主要的构建模块是模型控制器视图。在图 3-1 中,你可以看到 MVC 模式应用于服务器端开发的传统阐述。

img/421542_4_En_3_Fig1_HTML.jpg

图 3-1。

MVC 模式的服务器端实现

我从我的一本Pro ASP.NET 核心书中获得了这个数字,该书描述了微软的 MVC 模式的服务器端实现。您可以看到期望是从数据库中获得模型,并且应用的目标是服务来自浏览器的 HTTP 请求。这是我前面描述的往返 web 应用的基础。

当然,浏览器中存在 Angular,导致了 MVC 主题的扭曲,如图 3-2 所示。

img/421542_4_En_3_Fig2_HTML.jpg

图 3-2。

MVC 模式的客户端实现

MVC 模式的客户端实现从服务器端组件获取数据,通常是通过 RESTful web 服务,我在第二十四章对此进行了描述。控制器和视图的目标是操作模型中的数据以执行 DOM 操作,从而创建和管理用户可以与之交互的 HTML 元素。这些交互被反馈给控制器,形成一个交互应用的闭环。

Angular 对其构建块使用了稍微不同的术语,这意味着使用 Angular 实现的 MVC 模型看起来更像图 3-3 。

img/421542_4_En_3_Fig3_HTML.jpg

图 3-3。

MVC 模式的 Angular 实现

该图显示了 Angular 构建块到 MVC 模式的基本映射。为了支持 MVC 模式,Angular 提供了一系列额外的特性,我在整本书中都有描述。

Tip

使用像 Angular 这样的客户端框架并不排除使用服务器端 MVC 框架,但是您会发现 Angular 客户端承担了一些原本存在于服务器端的复杂性。这通常是一件好事,因为它将工作从服务器转移到了客户端,这样就可以用更少的服务器容量支持更多的客户端。

Patterns and Pattern Zealots

一个好的模式描述了一种解决问题的方法,这种方法对其他项目中的其他人有效。模式是食谱,而不是规则,您需要调整任何模式以适应您的特定项目,就像厨师调整食谱以适应不同的烤箱和配料一样。

你偏离一个模式的程度应该由需求和经验来决定。你在类似的项目中应用一个模式所花费的时间将会告诉你什么对你有用,什么对你没用。如果您是一个模式的新手,或者您正在着手一个新的项目,那么您应该尽可能地坚持这个模式,直到您真正理解等待您的好处和陷阱。但是,注意不要围绕一个模式来改革你的整个开发工作,因为大范围的中断通常会导致生产力的损失,破坏你希望该模式给出的任何结果。

模式是灵活的工具,而不是固定的规则,但并不是所有的开发人员都理解这种差异,有些人成为了模式狂热者。这些人花更多的时间谈论模式,而不是将其应用到项目中,并认为任何偏离他们对模式的解释都是严重的犯罪。我的建议是简单地忽略这种人,因为任何一种接触都会让你筋疲力尽,而且你永远无法改变他们的想法。相反,只要继续做一些工作,并通过实际的应用和交付演示一个模式的灵活应用如何产生好的结果。

记住这一点,你会看到我在本书的例子中遵循了 MVC 模式的广泛概念,但是我调整了模式来演示不同的特性和技术。这就是我在我自己的项目中的工作方式——拥抱模式中提供价值的部分,将那些不提供价值的部分放在一边。

理解模型

模型 MVC 中的M——包含用户使用的数据。有两种广泛的模型类型:视图模型,它只表示从组件传递到模板的数据,以及域模型,它包含业务域中的数据,以及创建、存储和操作这些数据的操作、转换和规则,统称为模型逻辑

Tip

许多不熟悉 MVC 模式的开发人员对在数据模型中包含逻辑的想法感到困惑,认为 MVC 模式的目标是将数据与逻辑分离。这是一个误解:MVC 框架的目标是将应用分成三个功能区域,每个区域可能包含逻辑数据。目标不是从模型中消除逻辑。相反,它是为了确保模型只包含用于创建和管理模型数据的逻辑。

你不可能在没有被单词 business 绊倒的情况下阅读 MVC 模式的定义,这是不幸的,因为许多 web 开发远远超出了导致这种术语的业务线应用。然而,业务应用仍然是开发世界的一大块,如果你正在编写,比如说,一个销售会计系统,那么你的业务领域将包含与销售会计相关的过程,你的领域模型将包含帐户数据和创建、存储和管理帐户的逻辑。如果你是在创建猫视频网站,那么你还是有业务领域的;只是它可能不适合公司的结构。您的领域模型将包含 cat 视频以及创建、存储和操作这些视频的逻辑。

许多 Angular 模型会有效地将逻辑推到服务器端,并通过 RESTful web 服务调用它,因为浏览器中很少支持数据持久性,而且通过 Ajax 更容易获得所需的数据。我会在第二十四章中解释 Angular 如何用于 RESTful web 服务。对于 MVC 模式中的每一个元素,我将描述哪些应该包含,哪些不应该包含。使用 MVC 模式构建的应用中的模型应该

  • 包含域数据

  • 包含创建、管理和修改域数据的逻辑(即使这意味着通过 web 服务执行远程逻辑)

  • 提供一个清晰的 API,公开模型数据和操作

型号不应

  • 公开如何获得或管理模型数据的细节(换句话说,数据存储机制或远程 web 服务的细节不应该向控制器和视图公开)

  • 包含基于用户交互转换模型的逻辑(因为这是组件的工作)

  • 包含向用户显示数据的逻辑(这是模板的工作)

确保模型与控制器和视图隔离的好处是你可以更容易地测试你的逻辑(我在第二十九章描述了 Angular 单元测试),并且增强/维护整个应用更加简单和容易。

最好的域模型包含持久获取和存储数据的逻辑,包含创建、读取、更新和删除操作的逻辑(统称为 CRUD)或查询和修改数据的独立模型,称为命令和查询责任分离(CQRS)模式。

这可能意味着模型直接包含逻辑,但更常见的是,模型将包含调用 RESTful web 服务的逻辑,以调用服务器端数据库操作(当我构建一个真实的 Angular 应用时,我将在第八章中演示,我将在第二十四章中详细描述)。

了解控制器/组件

控制器在 Angular 中被称为组件,是 Angular web app 中的结缔组织;它们充当数据模型和视图之间的管道。组件添加了表示模型的各个方面并对其执行操作所需的业务领域逻辑。遵循 MVC 模式的组件应该

  • 包含设置模板初始状态所需的逻辑

  • 包含模板所需的逻辑/行为,以呈现模型中的数据

  • 包含基于用户交互更新模型所需的逻辑/行为

组件不应

  • 包含操作 DOM 的逻辑(这是模板的工作)

  • 包含管理数据持久性的逻辑(这是模型的工作)

了解视图数据

领域模型并不是 Angular 应用中的唯一数据。组件可以创建视图数据(也称为视图模型数据视图模型)来简化模板及其与组件的交互。

了解视图/模板

视图,在 Angular 中被称为模板,是使用通过数据绑定增强的 HTML 元素定义的。正是数据绑定使得 Angular 如此灵活,它们将 HTML 元素转化为动态 web 应用的基础。我将在第二部分详细解释 Angular 提供的不同类型的数据绑定。模板应该

  • 包含向用户显示数据所需的逻辑和标记

模板不应

  • 包含复杂的逻辑(最好放在一个组件或其他有 Angular 的构建块中,如指令、服务或管道)

  • 包含创建、存储或操作领域模型的逻辑

模板可以包含逻辑,但是应该简单,少用。除了最简单的方法调用或表达式之外,在模板中放置任何东西都会使整个应用更难测试和维护。

理解 RESTful 服务

Angular 应用中的领域模型逻辑通常在客户端和服务器端分开。服务器包含持久性存储,通常是一个数据库,并包含管理它的逻辑。例如,在 SQL 数据库的情况下,所需的逻辑将包括打开到数据库服务器的连接,执行 SQL 查询,并处理结果以便将它们发送到客户机。

您不希望客户端代码直接访问数据存储—这样做会在客户端和数据存储之间产生紧密耦合,这会使单元测试变得复杂,并且在不更改客户端代码的情况下很难更改数据存储。

通过使用服务器来协调对数据存储的访问,可以防止紧耦合。客户端上的逻辑负责从服务器获取数据,并不知道在后台如何存储或访问数据的细节。

在客户机和服务器之间传递数据有很多种方式。最常见的一种是使用异步 JavaScript 和 XML (Ajax)请求来调用服务器端代码,让服务器发送 JSON 并使用 HTML 表单对数据进行更改。

这种方法可以很好地工作,并且是 RESTful web 服务的基础,RESTful web 服务使用 HTTP 请求的性质对数据执行 CRUD 操作。

Note

REST 是 API 的一种风格,而不是一个定义良好的规范,对于什么样的 web 服务才是 RESTful 的还存在争议。争论的一点是纯粹主义者不认为返回 JSON 的 web 服务是 RESTful 的。就像任何关于架构模式的分歧一样,分歧的原因是任意的和乏味的,根本不值得担心。就我而言,JSON 服务是 RESTful 的,我在本书中也是这样看待它们的。

在 RESTful web 服务中,被请求的操作通过 HTTP 方法和 URL 的组合来表达。例如,想象这样一个 URL:

http://myserver.mydomain.com/people/bob

RESTful web 服务没有标准的 URL 规范,但其思想是使 URL 不言自明,这样 URL 所指的内容就很明显了。在这种情况下,很明显有一个名为people的数据对象集合,并且 URL 指向该集合中标识为bob的特定对象。

Tip

在实际项目中并不总是能够创建这种不言自明的 URL,但是您应该努力保持简单,不要通过 URL 暴露数据存储的内部结构(因为这只是组件之间的另一种耦合)。尽可能保持你的 URL 简单,并保持 URL 格式和服务器中数据结构之间的映射。

URL 标识我想要操作的数据对象,HTTP 方法指定我想要执行什么操作,如表 3-1 所述。

表 3-1。

响应 HTTP 方法时通常执行的操作

|

方法

|

描述

|
| --- | --- |
| GET | 检索由 URL 指定的数据对象 |
| PUT | 更新由 URL 指定的数据对象 |
| POST | 创建新的数据对象,通常使用表单数据值作为数据字段 |
| DELETE | 删除由 URL 指定的数据对象 |

您不必使用 HTTP 方法来执行我在表中描述的操作。一个常见的变化是 POST 方法通常用于双重用途,如果存在对象,它将更新对象,如果不存在,它将创建一个对象,这意味着不使用 PUT 方法。在第二十四章中,我描述了 Angular 为 Ajax 和 RESTful 服务提供的支持。

Idempotent HTTP Methods

您可以在 HTTP 方法和数据存储上的操作之间实现任何映射,尽管我建议您尽可能遵循我在表中描述的约定。

如果您偏离了正常的方法,请确保遵循 HTTP 规范中定义的 HTTP 方法的本质。GET 方法是nullipent,这意味着响应这个方法而执行的操作应该只检索数据,而不修改数据。浏览器(或任何中间设备,如代理)希望能够重复发出 GET 请求,而不改变服务器的状态(尽管这并不意味着服务器的状态不会因为来自其他客户机的请求而在相同的 GET 请求之间改变)。

PUT 和 DELETE 方法是等幂,这意味着多个相同的请求应该具有与单个请求相同的效果。因此,例如,使用带有/people/bob URL 的 DELETE 方法应该为第一个请求从people集合中删除bob对象,然后对后续请求不做任何事情。(同样,当然,如果另一个客户机重新创建了bob对象,这就不成立。)

POST 方法既不是无效的也不是等幂的,这就是为什么一个常见的 RESTful 优化是处理对象创建更新。如果没有bob对象,使用 POST 方法将创建一个对象,对同一 URL 的后续 POST 请求将更新已创建的对象。

只有当您实现自己的 RESTful web 服务时,所有这些才是重要的。如果您正在编写一个使用 RESTful 服务的客户端,那么您只需要知道每个 HTTP 方法对应的数据操作。我将在第八章中演示如何使用这样的服务,并在第二十四章中更详细地描述 HTTP 请求的 Angular 特性。

常见的设计陷阱

在这一节中,我描述了我在 Angular 项目中遇到的三个最常见的设计陷阱。这些不是编码错误,而是 web 应用整体形状的问题,这些问题阻碍了项目团队获得 Angular 和 MVC 模式所能提供的好处。

把逻辑放在错误的地方

最常见的问题是将逻辑放入了错误的组件中,从而破坏了 MVC 关注点分离。以下是这一问题的三种最常见形式:

  • 将业务逻辑放在模板中,而不是组件中

  • 将领域逻辑放在组件中,而不是模型中

  • 当使用 RESTful 服务时,将数据存储逻辑放在客户端模型中

这些都是棘手的问题,因为它们需要一段时间才能显现为问题。应用仍然可以运行,但是随着时间的推移,它将变得更加难以增强和维护。在第三种情况下,只有当数据存储发生变化时,问题才会变得明显(这种情况很少发生,直到项目成熟,并且已经超出了最初的用户预测)。

Tip

对逻辑应该去哪里有一个感觉需要一些经验,但是如果你使用单元测试,你会更早发现问题,因为你必须写的覆盖逻辑的测试不能很好地适应 MVC 模式。我在第二十九章中描述了单元测试的 Angular 支持。

当你在 Angular 发展中获得更多经验时,知道在哪里放置逻辑成为第二天性,但是这里有三个规则:

  • 模板逻辑应该只准备用于显示的数据,而不修改模型。

  • 组件逻辑不应该直接从模型中创建、更新或删除数据。

  • 模板和组件不应该直接访问数据存储。

如果您在开发时记住这些,您将避免最常见的问题。

采用数据存储数据格式

当开发团队构建依赖于服务器端数据存储的应用时,下一个问题就出现了。在一个从 RESTful 服务获取数据的设计良好的 Angular 应用中,服务器的工作是隐藏数据存储实现细节,并以有利于简化客户端的适当数据格式向客户端呈现数据。例如,决定客户端需要如何表示日期,然后确保您在数据存储中使用该格式——如果数据存储本身不支持该格式,则由服务器执行转换。

只是足够制造麻烦的知识

Angular 是一个复杂的框架,在你习惯它之前,你会感到困惑。有许多不同的构建模块可用,它们可以以不同的方式组合来实现类似的结果。这使得 Angular 开发变得灵活,并且意味着您将通过创建适合您的项目和工作风格的特性组合来开发您自己的问题解决风格。

精通 Angular 需要时间。在了解 Angular 的不同部分是如何组合在一起的之前,你很容易就开始创建自己的项目。你可能在没有真正理解它为什么会起作用的情况下生产出一些有用的东西,当你需要做出改变的时候,这是一个灾难的处方。我的建议是慢慢来,花时间了解 Angular 提供的所有功能。无论如何,尽早开始创建项目,但是要确保你真的了解它们是如何工作的,并且当你找到更好的方法来达到你想要的结果时,准备好做出改变。

摘要

在这一章中,我为 Angular 提供了一些上下文。我解释了 Angular 如何支持 MVC 模式进行应用开发,并且简要概述了 REST 以及如何使用它来表达 HTTP 请求上的数据操作。我通过描述 Angular 项目中三个最常见的设计问题结束了这一章。在下一章中,我将提供一个快速入门的 HTML 和引导 CSS 框架,我将在本书的例子中使用。

四、HTML 和 CSS 入门

开发人员通过许多途径进入 web 应用开发的世界,并不总是基于 web 应用所依赖的基本技术。在这一章中,我提供了一个 HTML 的简单入门,并介绍了引导 CSS 库,我用它来设计本书中的例子。在第 5 和 6 章中,我介绍了 JavaScript 和 TypeScript 的基础知识,并给出了理解本书其余部分中的例子所需的信息。如果你是一个有经验的开发者,你可以跳过这些初级章节,直接跳到第七章中,在那里我使用 Angular 创建了一个更复杂和真实的应用。

准备示例项目

对于这一章,我只需要一个简单的示例项目。我首先创建了一个名为HtmlCssPrimer的文件夹,在其中创建了一个名为package.json的文件,并添加了清单 4-1 中所示的内容。

{
  "dependencies": {
    "bootstrap": "4.4.1"
  }
}

Listing 4-1.The Contents of the package.json File in the HtmlCssPrimer Folder

HtmlCssPrimer文件夹中运行以下命令,下载并安装package.json文件中指定的 NPM 软件包:

npm install

接下来,我在HtmlCssPrimer文件夹中创建了一个名为index.html的文件,并添加了清单 4-2 中所示的内容。

<!DOCTYPE html>
<html>
<head>
    <title>ToDo</title>
    <meta charset="utf-8" />
    <link href="node_modules/bootstrap/dist/css/bootstrap.min.css"
          rel="stylesheet" />
</head>
<body class="m-1">

    <h3 class="bg-primary text-white p-3">Adam's To Do List</h3>

    <div class="my-1">
        <input class="form-control" />
        <button class="btn btn-primary mt-1">Add</button>
    </div>

    <table class="table table-striped table-bordered">
        <thead>
            <tr>
                <th>Description</th>
                <th>Done</th>
            </tr>
        </thead>
        <tbody>
            <tr><td>Buy Flowers</td><td>No</td></tr>
            <tr><td>Get Shoes</td><td>No</td></tr>
            <tr><td>Collect Tickets</td><td>Yes</td></tr>
            <tr><td>Call Joe</td><td>No</td></tr>
        </tbody>
    </table>
</body>
</html>

Listing 4-2.The Contents of the index.html File in the HtmlCssPrimer Folder

这是一个简单的 HTML 文档,包含一个基本的待办事项列表,类似于我在第二章 ?? 中用 Angular 创建的那个。在HtmlCssPrimer文件夹中运行以下命令,下载并运行 HTTP 服务器:

npx lite-server@2.5.4

lite-server包包含一个开发 HTTP 服务器,当它检测到文件改变时,会自动触发浏览器刷新。一旦包被下载,服务器将启动,一个浏览器窗口将打开,显示如图 4-1 所示的内容。(如果您的系统配置为阻止浏览器自动打开,您可以请求http://localhost:3000。)

img/421542_4_En_4_Fig1_HTML.jpg

图 4-1。

运行示例应用

理解 HTML

HTML 的核心是元素,它告诉浏览器 HTML 文档的每个部分代表什么样的内容。以下是示例 HTML 文档中的一个元素:

...
<td>Buy Flowers</td>
...

如图 4-2 所示,这个元素有三个部分:开始标签、结束标签和内容。

img/421542_4_En_4_Fig2_HTML.jpg

图 4-2。

简单 HTML 元素的剖析

这个元素的名称(也称为标签名称或者仅仅是标签)是td,它告诉浏览器标签之间的内容应该被当作一个表格单元格。您可以通过将标签名称放在尖括号中(<>字符)来开始一个元素,并以类似的方式使用标签来结束一个元素,除了您还可以在左尖括号(<)后添加一个/字符。出现在标签之间的是元素的内容,可以是文本(比如本例中的Buy Flowers)或其他 HTML 元素。

了解空元素

HTML 规范包括不允许包含内容的元素。这些被称为 void自闭元素,它们没有单独的结束标记,就像这样:

...
<input />
...

在单个标记中定义了一个 void 元素,并在最后一个尖括号(>字符)前添加了一个/字符。input元素是最常用的 void 元素,其目的是允许用户通过文本字段、单选按钮或复选框提供输入。在后面的章节中,你会看到很多使用这个元素的例子。

了解属性

您可以通过向元素添加属性来为浏览器提供附加信息。以下是示例文档中带有属性的元素:

...
<link href="node_modules/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet" />
...

这是一个link元素,它将内容导入文档。有两个属性,我已经强调过了,所以它们更容易看到。属性总是被定义为开始标签的一部分,这些属性有一个名为和一个值为

本例中两个属性的名称是hrefrel。对于link元素,href属性指定要导入的内容,rel属性告诉浏览器这是哪种内容。这个link元素的属性告诉浏览器导入bootstrap.min.css文件,并把它当作一个样式表,它是一个包含 CSS 样式的文件。

应用不带值的属性

并非所有属性都应用了值;只需将它们添加到元素中,就可以告诉浏览器您想要某种特定的行为。下面是一个具有这种属性的元素的示例(不是来自示例文档;我只是虚构了这个示例元素):

...
<input class="form-control" required />
...

这个元素有两个属性。第一个是class,它被赋值,就像前面的例子一样。另一个属性就是required这个词。这是一个不需要值的属性的例子。

在属性中引用文字值

Angular 依赖 HTML 元素属性来应用它的许多功能。大多数时候,属性的值是作为 JavaScript 表达式来计算的,比如这个元素,摘自第二章:

...
<td [ngSwitch]="item.complete">
...

应用于td元素的属性告诉 Angular 读取一个对象上名为complete的属性的值,该对象已被分配给一个名为item的变量。有时,您需要提供一个特定的值,而不是让 Angular 从数据模型中读取一个值,这需要额外的引用来告诉 Angular 它正在处理一个文字值,如下所示:

...
<td [ngSwitch]="'Apples'">
...

属性值包含字符串Apples,用单引号和双引号引起来。当 Angular 计算属性值时,它会看到单引号并将该值作为文字字符串处理。

了解元素内容

元素可以包含文本,但也可以包含其他元素,如下所示:

...
<thead>
    <tr>
        <th>Description</th>
        <th>Done</th>
    </tr>
</thead>
...

HTML 文档中的元素形成一个层次结构。html元素包含body元素,后者包含内容元素,每个内容元素可以包含其他元素,依此类推。在清单中,thead元素包含tr元素,而tr元素又包含th元素。排列元素是 HTML 中的一个关键概念,因为它将外部元素的重要性传递给内部元素。

了解文档结构

有一些关键元素定义了 HTML 文档的基本结构:DOCTYPEhtmlheadbody元素。以下是这些元素之间的关系,其余内容已删除:

<!DOCTYPE html>
<html>
<head>
    ...head content...
</head>
<body>
    ...body content...
</body>
</html>

这些元素中的每一个在 HTML 文档中都扮演着特定的角色。元素告诉浏览器这是一个 HTML 文档,更确切地说,这是一个 HTML5 文档。早期版本的 HTML 需要额外的信息。例如,下面是 HTML4 文档的DOCTYPE元素:

...
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
    "http://www.w3.org/TR/html4/strict.dtd">
...

html元素表示包含 HTML 内容的文档区域。这个元素总是包含另外两个关键的结构元素:headbody。正如我在本章开始时解释的那样,我不打算讨论单个的 HTML 元素。它们太多了,描述 HTML5 完全花了我 HTML 书 1000 多页。也就是说,表 4-1 提供了我在清单 4-2 中的index.html文件中使用的元素的简要描述,以帮助您理解元素如何告诉浏览器它们代表哪种内容。

表 4-1。

示例文档中使用的 HTML 元素

|

元素

|

描述

|
| --- | --- |
| DOCTYPE | 指示文档中内容的类型 |
| body | 降级包含内容元素的文档区域 |
| button | 表示一个按钮;通常用于向服务器提交表单 |
| div | 通用元素;通常用于为文档添加结构,以用于演示目的 |
| h3 | 降级标题 |
| head | 降级包含元数据的文档区域 |
| html | 表示文档中包含 HTML 的区域(通常是整个文档) |
| input | 降级用于从用户处收集单个数据项的字段 |
| link | 将内容导入 HTML 文档 |
| meta | 提供关于文档的描述性数据,如字符编码 |
| table | 表示表格,用于将内容组织成行和列 |
| tbody | 表示表格的正文(与页眉或页脚相对) |
| td | 将表格行中的内容单元格降级 |
| th | 降级表格行中的标题单元格 |
| thead | 降级表格的标题 |
| title | 表示文档的标题;由浏览器用来设置窗口或选项卡的标题 |
| tr | 降级表格中的行 |

Understanding the Document Object Model

当浏览器加载并处理一个 HTML 文档时,它会创建文档对象模型 (DOM)。DOM 是一种模型,其中 JavaScript 对象用于表示文档中的每个元素,DOM 是一种机制,通过它您可以以编程方式处理 HTML 文档的内容。

在 Angular 中很少直接使用 DOM,但是理解浏览器维护由 JavaScript 对象表示的 HTML 文档的动态模型是很重要的。当 Angular 修改这些对象时,浏览器会更新其显示的内容以反映修改。这是 web 应用的关键基础之一。如果我们不能修改 DOM,我们就不能创建客户端 web 应用。

了解引导程序

HTML 元素告诉浏览器它们代表什么样的内容,但是它们不提供任何关于内容应该如何显示的信息。关于如何显示元素的信息是使用级联样式表 (CSS)提供的。CSS 由可用于配置元素外观各个方面的属性和允许应用这些属性的选择器组成。

CSS 是灵活和强大的,但它需要时间和对细节的密切关注来获得良好、一致的结果,特别是当一些传统浏览器实现的功能不一致时。CSS 框架提供了一组样式,可以很容易地应用这些样式来在整个项目中产生一致的效果。

使用最广泛的框架是 Bootstrap,它由 CSS 类和 JavaScript 代码组成,CSS 类可以应用于元素以保持一致的样式,JavaScript 代码执行额外的增强。我在本书中使用了引导 CSS 样式,因为它们让我不必在每一章中定义自定义样式就可以对我的例子进行样式化。我在本书中根本没有使用引导 JavaScript 特性,因为示例的交互部分是使用 Angular 提供的。

关于 Bootstrap,我不想讲太多细节,因为这不是本书的主题,但是我想给你足够的信息,这样你就可以知道例子的哪些部分是 Angular 特征,哪些部分是 Bootstrap 样式。参见 http://getbootstrap.com 了解 Bootstrap 提供的特性的全部细节。

应用基本引导类

引导样式是通过class属性应用的,该属性用于对相关元素进行分组。class属性不仅用于应用 CSS 样式,而且是最常见的用法,它支持 Bootstrap 和类似框架的操作方式。下面是一个带有class属性的 HTML 元素,取自index.html文件:

...
<button class="btn btn-primary mt-1">Add</button>
...

class属性将button元素分配给三个类,它们的名称由空格分隔:btnbtn-primarymt-1。这些类对应于 Bootstrap 定义的样式,如表 4-2 所述。

表 4-2。

三个按钮元素类

|

名字

|

描述

|
| --- | --- |
| btn | 这个类应用按钮的基本样式。它可以应用于buttona元素,以提供一致的外观。 |
| btn-primary | 该类应用样式上下文来提供关于按钮用途的视觉提示。请参见“使用上下文类”一节。 |
| mt-1 | 这个类在元素的顶部和它周围的内容之间添加一个间隙。请参见“使用边距和填充”一节。 |

使用上下文类

使用像 Bootstrap 这样的 CSS 框架的主要优点之一是简化了在整个应用中创建一致主题的过程。Bootstrap 定义了一组样式上下文,用于一致地设计相关元素的样式。这些上下文在表 4-3 中描述,用于将引导样式应用于元素的类的名称中。

表 4-3。

自举风格的上下文

|

名字

|

描述

|
| --- | --- |
| primary | 该上下文用于指示主要动作或内容区域。 |
| secondary | 该上下文用于指示内容的支持区域。 |
| success | 此上下文用于指示成功的结果。 |
| info | 该上下文用于呈现附加信息。 |
| warning | 该上下文用于显示警告。 |
| danger | 此上下文用于表示严重警告。 |
| muted | 这种语境是用来淡化内容的。 |
| dark | 该上下文通过使用深色来增加对比度。 |
| white | 该上下文用于通过使用白色来增加对比度。 |

Bootstrap 提供了允许样式上下文应用于不同类型元素的类。下面是应用于h3元素的primary上下文,取自本章开始时创建的index.html文件:

...
<h3 class="bg-primary text-white p-3">Adam's To Do List</h3>
...

元素被分配到的类之一是bg-primary,它使用样式上下文的颜色来设置元素的背景颜色。下面是应用于button元素的相同样式上下文:

...
<button class="btn btn-primary mt-1">Add</button>
...

btn-primary类使用样式上下文的颜色来设计按钮或锚元素的样式。使用相同的上下文来设计不同元素的样式将确保它们的外观是一致和互补的,如图 4-3 所示,该图突出显示了应用了样式上下文的元素。

img/421542_4_En_4_Fig3_HTML.jpg

图 4-3。

使用样式上下文保持一致性

使用边距和填充

Bootstrap 包括一些实用程序类,用于添加填充(元素内边缘与其内容之间的空间)和边距(元素边缘与其周围元素之间的空间)。使用这些类的好处是它们在整个应用中应用一致的间距。

这些类的名称遵循一种定义良好的模式。下面是在本章开始时创建的index.html文件中的body元素,已经对其应用了边距:

...
<body class="m-1">
...

对元素应用边距和填充的类遵循一个定义良好的命名模式:首先是字母m(用于边距)或p(用于填充),然后是一个连字符,然后是一个数字,指示应该应用多少空间(0表示没有间距,或者123表示增加的数量)。您还可以添加一个字母,仅将间距应用于特定的边,因此t用于顶部、b用于底部、l用于左侧、r用于右侧、x用于左侧和右侧、y用于顶部和底部。

为了帮助把这个方案放在上下文中,表 4-4 列出了在index.html文件中使用的组合。

表 4-4。

示例引导边距和填充类

|

名字

|

描述

|
| --- | --- |
| p-1 | 这个类将填充应用到元素的所有边缘。 |
| m-1 | 这个类将边距应用于元素的所有边缘。 |
| mt-1 | 这个类将边距应用于元素的上边缘。 |
| mb-1 | 这个类将边距应用于元素的下边缘。 |

更改元素大小

您可以使用大小修改类来更改某些元素的样式。这些是通过组合基本类名、连字符和lgsm来指定的。在清单 4-3 中,我使用 Bootstrap 为按钮提供的大小修改类,将button元素添加到了index.html文件中。

<!DOCTYPE html>
<html>
<head>
    <title>ToDo</title>
    <meta charset="utf-8" />
    <link href="node_modules/bootstrap/dist/css/bootstrap.min.css"
          rel="stylesheet" />
</head>
<body class="m-1">

    <h3 class="bg-primary text-white p-3">Adam's To Do List</h3>

    <div class="my-1">
        <input class="form-control" />
        <button class="btn btn-lg btn-primary mt-1">Add</button>
        <button class="btn btn-primary mt-1">Add</button>
        <button class="btn btn-sm btn-primary mt-1">Add</button>
    </div>

    <table class="table table-striped table-bordered">
        <thead>
            <tr>
                <th>Description</th>
                <th>Done</th>
            </tr>
        </thead>
        <tbody>
            <tr><td>Buy Flowers</td><td>No</td></tr>
            <tr><td>Get Shoes</td><td>No</td></tr>
            <tr><td>Collect Tickets</td><td>Yes</td></tr>
            <tr><td>Call Joe</td><td>No</td></tr>
        </tbody>
    </table>
</body>
</html>

Listing 4-3.Using Button Size Modification Classes in the index.html File in the HtmlCssPrimer Folder

btn-lg类创建一个大按钮,btn-sm类创建一个小按钮。省略 size 类将使用元素的默认大小。请注意,我能够将一个上下文类和一个大小类结合起来。引导类修改一起工作,给你完全的控制元素的样式,创造出如图 4-4 所示的效果。

img/421542_4_En_4_Fig4_HTML.jpg

图 4-4。

更改元素大小

使用引导程序设计表格

Bootstrap 包括对样式化table元素及其内容的支持,这是我在本书中使用的一个特性。表 4-5 列出了使用表的关键引导类。

表 4-5。

表格的引导 CSS 类

|

名字

|

描述

|
| --- | --- |
| table | 对一个table元素及其行应用常规样式 |
| table-striped | 对table正文中的行应用隔行条带化 |
| table-bordered | 将边框应用于所有行和列 |
| table-hover | 当鼠标悬停在表格中的某一行上时,显示不同的样式 |
| table-sm | 减少表格中的间距以创建更紧凑的布局 |

所有这些类都直接应用于table元素,如清单 4-4 所示,其中突出显示了应用于index.html文件中的表的引导类。

...
<table class="table table-striped table-bordered">
    <thead>
        <tr>
            <th>Description</th>
            <th>Done</th>
        </tr>
    </thead>
    <tbody>
        <tr><td>Buy Flowers</td><td>No</td></tr>
        <tr><td>Get Shoes</td><td>No</td></tr>
        <tr><td>Collect Tickets</td><td>Yes</td></tr>
        <tr><td>Call Joe</td><td>No</td></tr>
    </tbody>
</table>
...

Listing 4-4.Using Bootstrap to Style Tables

Tip

注意,在定义清单 4-4 中的表格时,我使用了thead元素。如果一个tbody元素没有被使用,浏览器会自动添加任何tr元素,这些元素是table元素的直接后代。如果您在使用 Bootstrap 时依赖于这种行为,您会得到奇怪的结果,因为应用于table元素的大多数 CSS 类会导致样式被添加到tbody元素的后代中。

使用 Bootstrap 创建表单

Bootstrap 包括表单元素的样式,允许它们与应用中的其他元素保持一致。在清单 4-5 中,我扩展了index.html文件中的表单元素,并临时删除了表格。

<!DOCTYPE html>
<html>
<head>
    <title>ToDo</title>
    <meta charset="utf-8" />
    <link href="node_modules/bootstrap/dist/css/bootstrap.min.css"
          rel="stylesheet" />
</head>
<body class="m-2">
    <h3 class="bg-primary text-white p-3">Adam's To Do List</h3>
    <form>
        <div class="form-group">
            <label>Task</label>
            <input class="form-control" />
        </div>
        <div class="form-group">
            <label>Location</label>
            <input class="form-control" />
        </div>
        <div class="form-group">
            <input type="checkbox" />
            <label>Done</label>
        </div>
        <button class="btn btn-primary">Add</button>
    </form>
</body>
</html>

Listing 4-5.Defining Additional Form Elements in the index.html File in the HtmlCssPrimer Folder

表单的基本样式是通过将form-group类应用到包含labelinput元素的div元素来实现的,其中输入元素被分配给form-control类。Bootstrap 对元素进行样式化,使label显示在input元素上方,而input元素占据 100%的可用水平空间,如图 4-5 所示。

img/421542_4_En_4_Fig5_HTML.jpg

图 4-5。

样式表单元素

使用引导程序创建网格

Bootstrap 提供了样式类,可用于创建不同种类的网格布局,从 1 列到 12 列不等,并支持响应式布局(网格布局根据屏幕宽度而变化)。清单 4-6 替换了示例 HTML 文件的内容来演示网格特性。

<!DOCTYPE html>
<html>
<head>
    <title>ToDo</title>
    <meta charset="utf-8" />
    <link href="node_modules/bootstrap/dist/css/bootstrap.min.css"
          rel="stylesheet" />
    <style>
        .row > div {
                    border: 1px solid lightgrey; padding: 10px;
                    background-color: aliceblue; margin: 5px 0;
                }
    </style>
</head>
<body class="m-2">
    <h3>Grid Layout</h3>
    <div class="container">
        <div class="row">
            <div class="col-1">1</div>
            <div class="col-1">1</div>
            <div class="col-2">2</div>
            <div class="col-2">2</div>
            <div class="col-6">6</div>
        </div>

        <div class="row">
            <div class="col-3">3</div>
            <div class="col-4">4</div>
            <div class="col-5">5</div>
        </div>

        <div class="row">
            <div class="col-6">6</div>
            <div class="col-6">6</div>
        </div>

        <div class="row">
            <div class="col-11">11</div>
            <div class="col-1">1</div>
        </div>

        <div class="row">
            <div class="col-12">12</div>
        </div>
    </div>
</body>
</html>

Listing 4-6.Using a Bootstrap Grid in the index.html File in the HtmlCssPrimer Folder

自举网格布局系统易于使用。一个顶级的div元素被分配给container类(或者是container-fluid类,如果你想让它跨越可用空间的话)。通过将row类应用到div元素来指定列,这具有为div元素包含的内容设置网格布局的效果。

每行定义 12 列,您可以通过指定一个名为col-后跟列数的类来指定每个子元素将占用多少列。例如,类col-1指定一个元素占据一列,col-2指定两列,依此类推,直到col-12,它指定一个元素填充整个行。在清单中,我用row类创建了一系列的div元素,每个元素都包含我应用了col-*类的进一步的div元素。在图 4-6 中可以看到浏览器中的效果。

Tip

Bootstrap 不会对一行中的元素应用任何样式,这就是为什么我使用了一个style元素来创建一个自定义的 CSS 样式,该样式设置背景颜色、设置行间距并添加边框。

img/421542_4_En_4_Fig6_HTML.jpg

图 4-6。

创建引导网格布局

创建响应式网格

响应式网格根据浏览器窗口的大小调整布局。响应式网格的主要用途是允许移动设备和桌面显示相同的内容,充分利用任何可用的屏幕空间。为了创建一个响应网格,用表 4-6 中显示的类之一替换单个单元格上的col-*类。

表 4-6。

响应网格的引导 CSS 类

|

引导类

|

描述

|
| --- | --- |
| col-sm-* | 当屏幕宽度大于 576 像素时,网格单元水平显示。 |
| col-md-* | 当屏幕宽度大于 768 像素时,网格单元水平显示。 |
| col-lg-* | 当屏幕宽度大于 992 像素时,网格单元格水平显示。 |
| col-xl-* | 当屏幕宽度大于 1200 像素时,网格单元水平显示。 |

当屏幕的宽度小于类支持的宽度时,网格行中的单元格垂直堆叠,而不是水平堆叠。清单 4-7 展示了index.html文件中的响应网格。

<!DOCTYPE html>
<html>
<head>
    <title>ToDo</title>
    <meta charset="utf-8" />
    <link href="node_modules/bootstrap/dist/css/bootstrap.min.css"
          rel="stylesheet" />
    <style>
        #gridContainer {padding: 20px;}
        .row > div {
                     border: 1px solid lightgrey; padding: 10px;
                     background-color: aliceblue; margin: 5px 0;
                    }
    </style>

</head>
<body class="m-1">

    <h3>Grid Layout</h3>
    <div class="container">
            <div class="row">
                <div class="col-sm-3">3</div>
                <div class="col-sm-4">4</div>
                <div class="col-sm-5">5</div>
            </div>

            <div class="row">
                <div class="col-sm-6">6</div>
                <div class="col-sm-6">6</div>
            </div>

            <div class="row">
                <div class="col-sm-11">11</div>
                <div class="col-sm-1">1</div>
            </div>
    </div>
</body>
</html>

Listing 4-7.Creating a Responsive Grid in the index.html File in the HtmlCssPrimer Folder

我从前面的例子中删除了一些网格行,并用col-sm-*替换了col-*类。其效果是当浏览器窗口宽度大于 576 像素时,该行中的单元格将水平堆叠,当浏览器窗口宽度小于 576 像素时,该行中的单元格将水平堆叠,如图 4-7 所示。

img/421542_4_En_4_Fig7_HTML.jpg

图 4-7。

创建响应式网格布局

创建简化的网格布局

对于本书中依赖于 Bootstrap 网格的大多数示例,我使用一种简化的方法,在单行中显示内容,并且只需要指定列数,如清单 4-8 所示。

<!DOCTYPE html>
<html>
<head>
    <title>ToDo</title>
    <meta charset="utf-8" />
    <link href="node_modules/bootstrap/dist/css/bootstrap.min.css"
          rel="stylesheet" />
</head>
<body class="m-1">
    <h3 class="bg-primary text-white p-3">Adam's To Do List</h3>
    <div class="container-fluid">
        <div class="row">
            <div class="col-4">
                <form>
                    <div class="form-group">
                        <label>Task</label>
                        <input class="form-control" />
                    </div>
                    <div class="form-group">
                        <label>Location</label>
                        <input class="form-control" />
                    </div>
                    <div class="form-group">
                        <input type="checkbox" />
                        <label>Done</label>
                    </div>
                    <button class="btn btn-primary">Add</button>
                </form>
            </div>
            <div class="col-8">
                <table class="table table-striped table-bordered">
                    <thead>
                        <tr>
                            <th>Description</th>
                            <th>Done</th>
                        </tr>
                    </thead>
                    <tbody>
                        <tr><td>Buy Flowers</td><td>No</td></tr>
                        <tr><td>Get Shoes</td><td>No</td></tr>
                        <tr><td>Collect Tickets</td><td>Yes</td></tr>
                        <tr><td>Call Joe</td><td>No</td></tr>
                    </tbody>
                </table>
            </div>
        </div>
    </div>
</body>
</html>

Listing 4-8.Using a Simplified Grid Layout in the index.html File in the HtmlCssPrimer Folder

这个清单使用col-4col-8类并排显示两个div元素,允许显示待办事项的表单和表格水平显示,如图 4-8 所示。

img/421542_4_En_4_Fig8_HTML.jpg

图 4-8。

使用简化的网格布局

摘要

在这一章中,我提供了 HTML 和引导 CSS 框架的简要概述。您需要很好地掌握 HTML 和 CSS,以便在 web 应用开发中真正有效,但最好的学习方法是通过第一手经验,本章中的描述和示例将足以让您入门,并为前面的示例提供足够的背景信息。在下一章,我将继续初级主题,介绍我在本书中使用的 JavaScript 的基本特性。

五、JavaScript 和 TypeScript:第一部分

在这一章中,我快速浏览了 JavaScript 语言应用于 Angular 开发的最重要的基本特性。我没有足够的空间来完整地描述 JavaScript,所以我把重点放在了你需要快速掌握并遵循本书中的例子的要点上。在第六章中,我描述了一些你需要的更高级的 JavaScript 特性和一些 TypeScript 提供的额外特性。

JavaScript 语言是通过定义新特性的标准流程来管理的。JavaScript 的每一个新版本都拓宽了 JavaScript 开发人员可用的特性,并使 JavaScript 的使用与更传统的语言(如 C#或 Java)更加一致。

现代浏览器会自我更新,这意味着,例如,谷歌 Chrome 用户很可能拥有一个最新版本的浏览器,该浏览器至少实现了一些最新的 JavaScript 特性。可悲的是,不自我更新的旧浏览器仍在广泛使用,这意味着你不能依赖于应用中可用的现代功能。

有两种方法可以解决这个问题。第一种方法是只使用核心 JavaScript 特性,这些特性可以在应用所面向的浏览器中找到。第二种方法是使用编译器处理您的 JavaScript 文件,并将它们转换成可以在旧浏览器上运行的代码。这是 Angular 发展中使用的第二种方法,也是我在本章中描述的。表 5-1 总结了本章内容。

表 5-1。

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 创建 JavaScript 功能 | 使用 JavaScript 语句 | five |
| 创建根据命令执行的语句组 | usb 功能 | 6, 7, 10–12 |
| 定义可以处理比参数更多或更少的参数的函数 | 使用默认或 rest 参数 | 8, 9 |
| 更简洁地表达功能 | 使用箭头功能 | Thirteen |
| 存储值和对象以备后用 | 使用letvar关键字声明变量 | 14–16 |
| 存储基本数据值 | 使用 JavaScript 基本类型 | 17–20 |
| 控制 JavaScript 代码流 | 使用条件语句 | Twenty-one |
| 确定两个对象或值是否相同 | 使用质量和标识运算符 | 22–23 |
| 显式转换类型 | 使用to<type>方法 | 24–26 |
| 将相关的对象或值按顺序存储在一起 | 使用数组 | 27–33 |

准备示例项目

为了创建本章的示例项目,打开一个新的命令提示符,导航到一个方便的位置,并运行清单 5-1 中所示的命令。

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

ng new JavaScriptPrimer --routing false --style css --skip-git --skip-tests

Listing 5-1.Creating the Example Project

该命令创建一个名为JavaScriptPrimer的项目,用于 Angular 开发。在这一章中我不做任何 Angular 的开发,但是我将使用 Angular 开发工具作为一种方便的方式来演示不同的 JavaScript 和 TypeScript 特性。为了做好准备,我用清单 5-2 中所示的一条 JavaScript 语句替换了src文件夹中的main.ts文件的内容。

Tip

注意文件的扩展名。尽管本章只使用 JavaScript 特性,但它依赖 TypeScript 编译器将它们转换成可以在任何浏览器中运行的代码。这意味着必须使用.ts文件,然后允许 TypeScript 编译器创建相应的.js文件,供浏览器使用。

console.log("Hello");

Listing 5-2.Replacing the Contents of the main.ts File in the src Folder

运行JavaScriptPrimer文件夹中清单 5-3 所示的命令,启动 Angular development 编译器和 HTTP 服务器。

ng serve --open

Listing 5-3.Starting the Development Tools

一个新的浏览器标签或窗口将会打开,但将会是空的,如图 5-1 所示,因为我替换了main.ts文件的内容,浏览器没有任何东西可以显示。

img/421542_4_En_5_Fig1_HTML.jpg

图 5-1。

运行示例应用

本章中示例的所有结果都显示在浏览器的 JavaScript 控制台中。打开浏览器的 F12 开发工具(之所以这样叫是因为它们通常是通过按 F12 键打开的)并查看控制台选项卡,如图 5-2 所示。

img/421542_4_En_5_Fig2_HTML.jpg

图 5-2。

谷歌浏览器 JavaScript 控制台

JavaScript 控制台显示了调用清单 5-2 中的console.log函数的结果。我将只显示文本结果,而不是显示每个示例的浏览器 JavaScript 控制台的屏幕截图,如下所示:

Hello

了解基本工作流程

将单词 Hello 写入 JavaScript 控制台是一个简单的例子,但是在幕后还有很多事情要做。为了了解开发工作流程,将清单 5-4 中所示的语句添加到main.ts文件中。

console.log("Hello");
console.log("Apples");

Listing 5-4.Adding a Statement in the main.ts File in the src Folder

当保存对main.ts文件的更改时,会发生以下过程:

  1. TypeScript 编译器将检测到对main.ts文件的更改,并编译它以生成一个可以在任何浏览器中运行的新的main.js文件。生成的代码与编译器生成的其他 JavaScript 代码组合成一个名为的文件。

  2. 开发 HTTP 服务器通知浏览器重新加载 HTML 文档。

  3. 浏览器重新加载 HTML 文档,并开始处理其中包含的元素。它加载由 HTML 文档中的script元素指定的 JavaScript 文件,包括一个指定包含来自main.ts文件的语句的包文件的文件。

  4. 浏览器执行最初在main.ts文件中的语句,将两条消息写到浏览器的 JavaScript 控制台。

总体结果是,您将看到显示以下消息:

Hello
Apples

对于一个简单的应用来说,这似乎是一个很大的步骤,但是这个过程允许使用 TypeScript 特性,并自动检测更改、运行编译器和更新浏览器。

使用语句

基本的 JavaScript 构建块是语句。每条语句代表一条命令,语句通常以分号(;)结束。分号是可选的,但是使用分号会使代码更容易阅读,并且允许在一行中有多个语句。在清单 5-5 中,我在 JavaScript 文件中添加了一对语句。

console.log("Hello");
console.log("Apples");
console.log("This is a statement");
console.log("This is also a statement");

Listing 5-5.Adding JavaScript Statements in the main.ts File in the src Folder

浏览器依次执行每条语句。在本例中,所有语句都只是将消息写入控制台。结果如下:

Hello
Apples
This is a statement
This is also a statement

定义和使用函数

当浏览器收到 JavaScript 代码时,它会按照定义的顺序执行其中包含的语句。这就是上一个示例中发生的情况。浏览器加载器加载了main.js文件,其中包含的语句被逐一执行,都向控制台写了一条消息。

你也可以将语句打包成一个函数,直到浏览器遇到一个调用该函数的语句时才会执行,如清单 5-6 所示。

let myFunc = function () {
    console.log("This is a statement");
};

myFunc();

Listing 5-6.Defining a JavaScript Function in the main.ts File in the src Folder

定义一个函数很简单:使用let关键字,后跟您想要给函数起的名字,再加上等号(=)和function关键字,再加上括号(()字符)。您希望函数包含的语句用大括号括起来(字符{})。

在清单中,我使用了名称myFunc,该函数包含一个向 JavaScript 控制台写入消息的语句。在浏览器到达另一个调用myFunc函数的语句之前,函数中的语句不会被执行,如下所示:

...
myFunc();
...

在函数中执行语句会产生以下输出:

This is a statement

除了演示函数是如何定义的,这个例子没有什么特别的用处,因为函数在定义后会立即被调用。当响应某种变化或事件(如用户交互)而调用函数时,函数会更有用。

用参数定义函数

JavaScript 允许您为函数定义参数,如清单 5-7 所示。

let myFunc = function(name, weather) {
    console.log("Hello " + name + ".");
    console.log("It is " + weather + " today");
};

myFunc("Adam", "sunny");

Listing 5-7.Defining Functions with Parameters in the main.ts File in the src Folder

我给myFunc函数添加了两个参数,称为nameweather。JavaScript 是一种动态类型语言,这意味着在定义函数时不必声明参数的数据类型。当我在本章后面讲述 JavaScript 变量时,我会回到动态类型。要调用带参数的函数,需要在调用函数时提供值作为参数,如下所示:

...
myFunc("Adam", "sunny");
...

该清单的结果如下:

Hello Adam.
It is sunny today

使用默认和 Rest 参数

调用函数时提供的参数数量不需要与函数中的参数数量相匹配。如果调用函数时使用的参数少于它拥有的参数,那么任何没有提供值的参数的值都是undefined,这是一个特殊的 JavaScript 值。如果调用函数时使用的参数多于实际参数,那么多余的参数将被忽略。

这样做的结果是,您不能创建两个具有相同名称和不同参数的函数,并期望 JavaScript 根据您在调用函数时提供的参数来区分它们。这被称为多态性,尽管它在 Java 和 C#等语言中受支持,但在 JavaScript 中不可用。相反,如果您定义了两个同名的函数,那么第二个定义将替换第一个定义。

有两种方法可以修改函数,以响应函数定义的参数数量和用于调用函数的参数数量之间的不匹配。默认参数处理实参比参数少的情况,它们允许你为没有实参的参数提供默认值,如清单 5-8 所示。

let myFunc = function (name, weather = "raining") {
    console.log("Hello " + name + ".");
    console.log("It is " + weather + " today");
};

myFunc("Adam");

Listing 5-8.Using a Default Parameter in the main.ts File in the src Folder

函数中的weather参数已被赋予默认值raining,如果仅使用一个参数调用该函数,将使用该值,产生以下结果:

Hello Adam.
It is raining today

Rest 参数用于在用附加参数调用函数时捕获任何附加参数,如清单 5-9 所示。

let myFunc = function (name, weather, ...extraArgs) {
    console.log("Hello " + name + ".");
    console.log("It is " + weather + " today");
    for (let i = 0; i < extraArgs.length; i++) {
        console.log("Extra Arg: " + extraArgs[i]);
    }
};

myFunc("Adam", "sunny", "one", "two", "three");

Listing 5-9.Using a Rest Parameter in the main.ts File in the src Folder

rest 参数必须是函数定义的最后一个参数,其名称以省略号为前缀(三个句点,...)。rest 参数是一个数组,任何额外的参数都将被赋给它。在清单中,该函数将每个额外的参数打印到控制台,产生以下结果:

Hello Adam.
It is sunny today
Extra Arg: one
Extra Arg: two
Extra Arg: three

定义返回结果的函数

您可以使用return关键字从函数中返回结果。清单 5-10 显示了一个返回结果的函数。

let myFunc = function(name) {
    return ("Hello " + name + ".");
};

console.log(myFunc("Adam"));

Listing 5-10.Returning a Result from a Function in the main.ts File in the src Folder

这个函数定义了一个参数,并用它来产生一个结果。我调用函数并将结果作为参数传递给console.log函数,如下所示:

...
console.log(myFunc("Adam"));
...

请注意,您不必声明该函数将返回一个结果或表示结果的数据类型。该清单的结果如下:

Hello Adam.

将函数用作其他函数的参数

JavaScript 函数可以作为对象传递,这意味着您可以使用一个函数作为另一个函数的参数,如清单 5-11 所示。

let myFunc = function (nameFunction) {
    return ("Hello " + nameFunction() + ".");
};

console.log(myFunc(function () {
    return "Adam";
}));

Listing 5-11.Using a Function as an Argument to Another Function in the main.ts File in the src Folder

myFunc函数定义了一个名为nameFunction的参数,它调用这个参数来获取插入到它返回的字符串中的值。我将一个返回Adam作为参数的函数传递给myFunc,它产生以下输出:

Hello Adam.

函数可以链接在一起,从小而容易测试的代码片段中构建更复杂的功能,如清单 5-12 所示。

let myFunc = function (nameFunction) {
    return ("Hello " + nameFunction() + ".");
};

let printName = function (nameFunction, printFunction) {
    printFunction(myFunc(nameFunction));
}

printName(function () { return "Adam" }, console.log);

Listing 5-12.Chaining Functions Calls in the main.ts File in the src Folder

这个例子产生了与清单 5-11 相同的结果。

使用箭头功能

箭头函数(也称为胖箭头函数或 lambda 表达式)是定义函数的另一种方法,通常用于定义仅用作其他函数的参数的函数。清单 5-13 用箭头函数替换了前面例子中的函数。

let myFunc = (nameFunction) => ("Hello " + nameFunction() + ".");

let printName = (nameFunction, printFunction) => printFunction(myFunc(nameFunction));

printName(function () { return "Adam" }, console.log);

Listing 5-13.Using Arrow Functions in the main.ts File in the src Folder

这些函数与清单 5-12 中的函数执行相同的工作。箭头函数有三个部分:输入参数、等号和大于号(“箭头”),最后是函数结果。只有当 arrow 函数需要执行多条语句时,才需要关键字return和花括号。本章后面还有更多箭头函数的例子。

使用变量和类型

let关键字用于声明变量,或者在一条语句中为变量赋值。用let声明的变量的作用域是定义它们的代码区域,如清单 5-14 所示。

let messageFunction = function (name, weather) {
    let message = "Hello, Adam";
    if (weather == "sunny") {
        let message = "It is a nice day";
        console.log(message);
    } else {
        let message = "It is " + weather + " today";
        console.log(message);
    }
    console.log(message);
}

messageFunction("Adam", "raining");

Listing 5-14.Using let to Declare Variables in the main.ts File in the src Folder

在这个例子中,有三个语句使用let关键字来定义一个名为message的变量。每个变量的范围限于定义它的代码区域,产生以下结果:

It is raining today
Hello, Adam

这似乎是一个奇怪的例子,但是还有另一个关键字可以用来声明变量:varlet关键字是 JavaScript 规范中相对较新的内容,旨在解决var行为方式中的一些奇怪之处。清单 5-15 以清单 5-14 为例,将let替换为var

let messageFunction = function (name, weather) {
    var message = "Hello, Adam";
    if (weather == "sunny") {
        var message = "It is a nice day";
        console.log(message);
    } else {
        var message = "It is " + weather + " today";
        console.log(message);
    }
    console.log(message);
}

messageFunction("Adam", "raining");

Listing 5-15.Using var to Declare Variables in the main.ts File in the src Folder

当您保存列表中的更改时,您将看到以下结果:

It is raining today
It is raining today

问题是var关键字创建的变量的作用域是包含函数,这意味着所有对message的引用都是指同一个变量。这甚至会给有经验的 JavaScript 开发人员带来意想不到的结果,这也是引入更传统的let关键字的原因。

Using Let and Const

let关键字用于定义变量,const关键字用于定义不会改变的常数值。对任何不希望修改的值使用const关键字是一个很好的实践,这样如果试图修改,就会收到一个错误。然而,这是我很少遵循的一种做法——一部分是因为我太习惯于使用var关键字,另一部分是因为我用一系列语言编写代码,并且有一些我避免使用的特性,因为当我从一种语言切换到另一种语言时,它们会绊倒我。如果你是 JavaScript 新手,那么我建议你试着正确使用constlet,避免步我后尘。

使用可变闭包

如果你在另一个函数中定义一个函数——创建内部外部函数——那么内部函数能够访问外部函数的变量,使用一个叫做闭包的特性,如清单 5-16 所示。

let myFunc = function(name) {
    let myLocalVar = "sunny";
    let innerFunction = function () {
        return ("Hello " + name + ". Today is " + myLocalVar + ".");
    }
    return innerFunction();

};
console.log(myFunc("Adam"));

Listing 5-16.Using Variable Closure in the main.ts File in the src Folder

这个例子中的内部函数能够访问外部函数的局部变量,包括它的name参数。这是一个强大的特性,意味着您不必在内部函数上定义参数来传递数据值,但是需要小心,因为当使用像counterindex这样的普通变量名时,很容易得到意想不到的结果,您可能没有意识到您正在重用外部函数中的变量名。此示例产生以下结果:

Hello Adam. Today is sunny.

使用基本类型

JavaScript 定义了一组基本的原语类型:stringnumberboolean。这似乎是一个很短的列表,但是 JavaScript 设法将很大的灵活性融入到这三种类型中。

Tip

我在这里简化。您可能会遇到另外三种原语。已经声明但没有赋值的变量是undefined,而null值用来表示一个变量没有值,就像其他语言一样。最后一个原语类型是Symbol,它是一个不可变的值,表示一个惟一的 ID,但是在编写本文时还没有广泛使用。

使用布尔值

boolean类型有两个值:truefalse。清单 5-17 显示了正在使用的两个值,但是这种类型在条件语句中使用时最有用,比如一个if语句。该清单中没有控制台输出。

let firstBool = true;
let secondBool = false;

Listing 5-17.Defining boolean Values in the main.ts File in the src Folder

使用字符串

您可以使用双引号或单引号字符来定义string值,如清单 5-18 所示。

let firstString = "This is a string";
let secondString = 'And so is this';

Listing 5-18.Defining string Variables in the main.ts File in the src Folder

您使用的引号字符必须匹配。例如,你不能用单引号开始一个字符串,然后用双引号结束。这个清单没有输出。JavaScript 为string对象提供了一组基本的属性和方法,其中最有用的在表 5-2 中有描述。

表 5-2。

有用的字符串属性和方法

|

名字

|

描述

|
| --- | --- |
| length | 此属性返回字符串中的字符数。 |
| charAt(index) | 此方法返回包含指定索引处的字符的字符串。 |
| concat(string) | 此方法返回一个新字符串,该字符串将调用该方法的字符串和作为参数提供的字符串连接在一起。 |
| indexOf(term, start) | 该方法返回第一个索引,在该索引处term出现在字符串中,如果没有匹配,则返回-1。可选的start参数指定搜索的起始索引。 |
| replace(term, newTerm) | 该方法返回一个新字符串,其中所有的term实例都被替换为newTerm。 |
| slice(start, end) | 此方法返回包含起始和结束索引之间的字符的子字符串。 |
| split(term) | 这个方法将一个字符串分割成一个由term分隔的值数组。 |
| toUpperCase()toLowerCase() | 这些方法返回所有字符都是大写或小写的新字符串。 |
| trim() | 此方法返回一个新字符串,其中所有的前导和尾随空白字符都已被删除。 |

使用模板字符串

一个常见的编程任务是将静态内容与数据值结合起来,以生成可以呈现给用户的字符串。传统的方法是通过字符串连接,这是我在本章的例子中一直使用的方法,如下所示:

...
let message = "It is " + weather + " today";
...

JavaScript 还支持模板字符串,它允许内联指定数据值,这有助于减少错误,带来更自然的开发体验。清单 5-19 显示了模板字符串的使用。

let messageFunction = function (weather) {
    let message = `It is ${weather} today`;
    console.log(message);
}

messageFunction("raining");

Listing 5-19.Using a Template String in the main.ts File

模板字符串以反斜杠(```ts 字符)开始和结束,数据值由花括号表示,前面有一个美元符号。例如,这个字符串将变量weather的值合并到模板字符串中:

...
let message = `It is ${weather} today`;
...

```ts

此示例产生以下输出:

It is raining today


#### 使用数字

`number`类型用于表示*整数*和*浮点*(也称为*实数*)。清单 5-20 提供了一个演示。

let daysInWeek = 7;
let pi = 3.14;
let hexValue = 0xFFFF;

Listing 5-20.Defining number Values in the main.ts File in the src Folder


您不必指定使用哪种号码。您只需表达您需要的值,JavaScript 就会相应地执行。在清单中,我定义了一个整数值、一个浮点值,并在一个值前面加上了`0x`来表示一个十六进制值。

## 使用 JavaScript 运算符

JavaScript 定义了一组非常标准的操作符。我在表 5-3 中总结了最有用的。

表 5-3。

有用的 JavaScript 运算符

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

操作员

 | 

描述

 |
| --- | --- |
| `++, --` | 前或后递增和递减 |
| `+, -, *, /, %` | 加法、减法、乘法、除法、余数 |
| `<, <=, >, >=` | 小于,小于等于,大于,大于等于 |
| `==, !=` | 平等和不平等测试 |
| `===, !==` | 同一性和非同一性测试 |
| `&&, &#124;&#124;` | 逻辑 AND 和 OR (&#124;&#124;用于合并空值) |
| `=` | 分配 |
| `+` | 串并置 |
| `?:` | 三操作数条件语句 |

### 使用条件语句

许多 JavaScript 操作符与条件语句一起使用。在本书中,我倾向于使用`if/else`和`switch`语句。清单 5-21 展示了两者的用法,如果你使用过几乎任何一种编程语言,你都会很熟悉。

let firstName = "Adam";

if (firstName == "Adam") {
console.log("firstName is Adam");
} else if (firstName == "Jacqui") {
console.log("firstName is Jacqui");
} else {
console.log("firstName is neither Adam or Jacqui");
}

switch (firstName) {
case "Adam":
console.log("firstName is Adam");
break;
case "Jacqui":
console.log("firstName is Jacqui");
break;
default:
console.log("firstName is neither Adam or Jacqui");
break;
}

Listing 5-21.Using the if/else and switch Conditional Statements in the main.ts File in the src Folder


清单中的结果如下:

firstName is Adam
firstName is Adam


### 相等运算符与相同运算符

等式和等式运算符特别值得注意。相等运算符将尝试将操作数强制(转换)为相同的类型来评估相等性。这是一个方便的特性,只要你意识到它正在发生。清单 5-22 展示了等式操作符的作用。

let firstVal = 5;
let secondVal = "5";

if (firstVal == secondVal) {
console.log("They are the same");
} else {
console.log("They are NOT the same");
}

Listing 5-22.Using the Equality Operator in the main.ts File in the src Folder


该脚本的输出如下:

They are the same


JavaScript 将两个操作数转换成相同的类型,并对它们进行比较。本质上,相等运算符测试值是否相同,而不管它们的类型如何。为了帮助防止这种错误,TypeScript 编译器将生成一个警告,尽管它仍然会生成 JavaScript 代码,因为这种类型的比较是合法的:

ERROR in src/main.ts:4:5 - error TS2367: This condition will always return 'false' since the types 'number' and 'string' have no overlap.


如果你想测试确保值*和*的类型是相同的,那么你需要使用恒等运算符(`===`,三个等号,而不是两个等号的运算符),如清单 5-23 所示。

let firstVal = 5;
let secondVal = "5";

if (firstVal === secondVal) {
console.log("They are the same");
} else {
console.log("They are NOT the same");
}

Listing 5-23.Using the Identity Operator in the main.ts File in the src Folder


在本例中,identity 运算符将认为这两个变量是不同的。该运算符不强制类型。该脚本的结果如下:

They are NOT the same


### 显式转换类型

字符串连接操作符(`+`)比加法操作符(`+`)具有更高的优先级,这意味着 JavaScript 将优先于加法连接变量。这可能会造成混乱,因为 JavaScript 也会自由地转换类型以产生结果——而不总是预期的结果,如清单 5-24 所示。

let myData1 = 5 + 5;
let myData2 = 5 + "5";

console.log("Result 1: " + myData1);
console.log("Result 2: " + myData2);

Listing 5-24.String Concatenation Operator Precedence in the main.ts File


该脚本的结果如下:

Result 1: 10
Result 2: 55


第二种结果是引起混乱的那种。通过运算符优先级和类型转换的组合,原本应该是加法运算的操作被解释为字符串串联。为了避免这种情况,可以显式转换值的类型,以确保执行正确的操作,如以下部分所述。

#### 将数字转换为字符串

如果您正在处理多个数字变量,并希望将它们连接成字符串,那么您可以使用`toString`方法将数字转换成字符串,如清单 5-25 所示。

let myData1 = (5).toString() + String(5);
console.log("Result: " + myData1);

Listing 5-25.Using the number.toString Method in the main.ts File in the src Folder


注意,我将数值放在括号中,然后调用了`toString`方法。这是因为在调用`number`类型定义的方法之前,您必须允许 JavaScript 将文字值转换成`number`。我还展示了实现相同效果的另一种方法,即调用`String`函数,并将数值作为参数传入。这两种技术具有相同的效果,都是将一个`number`转换成一个`string`,这意味着`+`操作符用于字符串连接而不是加法。该脚本的输出如下:

Result: 55


还有一些其他的方法可以让你更好地控制一个数字如何被表示成一个字符串。我在表 5-4 中简要描述了这些方法。表格中显示的所有方法都由`number`类型定义。

表 5-4。

有用的数字到字符串的方法

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

方法

 | 

描述

 |
| --- | --- |
| `toString()` | 此方法返回一个表示以 10 为基数的数字的字符串。 |
| `toString(2)toString(8)toString(16)` | 此方法返回以二进制、八进制或十六进制表示法表示数字的字符串。 |
| `toFixed(n)` | 该方法返回一个表示小数点后有`n`位的实数的字符串。 |
| `toExponential(n)` | 该方法返回一个字符串,该字符串使用指数表示法表示一个数字,小数点前有一位数字,小数点后有`n`位数字。 |
| `toPrecision(n)` | 该方法返回一个字符串,该字符串表示一个具有`n`个有效数字的数字,如果需要,可以使用指数符号。 |

#### 将字符串转换为数字

补充技术是将字符串转换为数字,这样您就可以执行加法而不是连接。你可以用`Number`函数来实现,如清单 5-26 所示。

let firstVal = "5";
let secondVal = "5";

let result = Number(firstVal) + Number(secondVal);
console.log("Result: " + result);

Listing 5-26.Converting Strings to Numbers in the main.ts File in the src Folder


该脚本的输出如下:

Result: 10


`Number`函数解析字符串值的方式很严格,但是您可以使用另外两个更灵活的函数,它们会忽略后面的非数字字符。这些功能是`parseInt`和`parseFloat`。我已经在表 5-5 中描述了所有三种方法。

表 5-5。

对数字方法有用的字符串

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

方法

 | 

描述

 |
| --- | --- |
| `Number(str)` | 此方法分析指定的字符串以创建整数或实数值。 |
| `parseInt(str)` | 此方法分析指定的字符串以创建整数值。 |
| `parseFloat(str)` | 此方法分析指定的字符串以创建整数或实数值。 |

## 使用数组

JavaScript 数组的工作方式类似于大多数其他编程语言中的数组。清单 5-27 展示了如何创建和填充一个数组。

let myArray = new Array();
myArray[0] = 100;
myArray[1] = "Adam";
myArray[2] = true;

Listing 5-27.Creating and Populating an Array in the main.ts File in the src Folder


我通过调用`new Array()`创建了一个新数组。这创建了一个空数组,我将它赋给了变量`myArray`。在随后的语句中,我为数组中的不同索引位置赋值。(这个清单中没有控制台输出。)

在这个例子中有一些事情需要注意。首先,在创建数组时,我不需要声明数组中的项数。JavaScript 数组会自动调整大小以容纳任意数量的项目。第二点是,我不必声明数组将保存的数据类型。任何 JavaScript 数组都可以包含任何混合的数据类型。在这个例子中,我给数组分配了三个项目:一个`number`、一个`string`和一个`boolean`。

### 使用数组文本

array literal 样式允许您在一条语句中创建和填充一个数组,如清单 5-28 所示。

let myArray = [100, "Adam", true];

Listing 5-28.Using the Array Literal Style in the main.ts File in the src Folder


在这个例子中,我通过在方括号(`[`和`]`)之间指定我想要的数组中的项目,指定了应该给`myArray`变量分配一个新的数组。(这个清单中没有控制台输出。)

### 读取和修改数组的内容

使用方括号(`[`和`]`)读取给定索引处的值,将所需的索引放在括号之间,如清单 5-29 所示。

let myArray = [100, "Adam", true];
console.log("Index 0: " + myArray[0]);

Listing 5-29.Reading the Data from an Array Index in the main.ts File in the src Folder


只需给索引赋值,就可以修改 JavaScript 数组中任何位置的数据。就像常规变量一样,您可以在索引处切换数据类型,不会有任何问题。清单的输出如下所示:

Index 0: 100


清单 5-30 展示了如何修改一个数组的内容。

let myArray = [100, "Adam", true];
myArray[0] = "Tuesday";
console.log("Index 0: " + myArray[0]);

Listing 5-30.Modifying the Contents of an Array in the main.ts File in the src Folder


在这个例子中,我将一个`string`赋值给数组中的位置`0`,这个位置以前是由一个`number`持有的,并产生以下输出:

Index 0: Tuesday


### 枚举数组的内容

使用一个`for`循环或者使用`forEach`方法来枚举数组的内容,该方法接收一个被调用来处理数组中每个元素的函数。清单 5-31 显示了这两种方法。

let myArray = [100, "Adam", true];

for (let i = 0; i < myArray.length; i++) {
console.log("Index " + i + ": " + myArray[i]);
}

console.log("---");

myArray.forEach((value, index) => console.log("Index " + index + ": " + value));

Listing 5-31.Enumerating the Contents of an Array in the main.ts File in the src Folder


JavaScript `for`循环的工作方式与许多其他语言中的循环一样。使用`length`属性确定数组中有多少个元素。

传递给`forEach`方法的函数有两个参数:要处理的当前项的值和该项在数组中的位置。在这个清单中,我使用了一个 arrow 函数作为`forEach`方法的参数,这是它们擅长的一种用法(在本书中你会看到它的使用)。清单的输出如下所示:

Index 0: 100
Index 1: Adam
Index 2: true

Index 0: 100
Index 1: Adam
Index 2: true


### 使用扩展运算符

spread 运算符用于扩展数组,以便其内容可以用作函数参数或与其他数组组合。在清单 5-32 中,我使用了 spread 操作符来扩展一个数组,这样它的条目就可以合并到另一个数组中。

let myArray = [100, "Adam", true];
let otherArray = [...myArray, 200, "Bob", false];

for (let i = 0; i < otherArray.length; i++) {
console.log(Array item ${i}: ${otherArray[i]});
}

Listing 5-32.Using the Spread Operator in the main.ts File in the src Folder


spread 操作符是一个省略号(三个句点的序列),它导致数组被解包。

...
let otherArray = [...myArray, 200, "Bob", false];
...


使用 spread 操作符,我可以在定义`otherArray`时将`myArray`指定为一个项,结果是第一个数组的内容将被解包并作为项添加到第二个数组中。此示例产生以下结果:

Array item 0: 100
Array item 1: Adam
Array item 2: true
Array item 3: 200
Array item 4: Bob
Array item 5: false


### 使用内置数组方法

JavaScript `Array`对象定义了许多可以用来处理数组的方法,表 5-6 中描述了其中最有用的方法。

表 5-6。

有用的数组方法

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

方法

 | 

描述

 |
| --- | --- |
| `concat(otherArray)` | 此方法返回一个新数组,该数组将调用它的数组与指定为参数的数组连接起来。可以指定多个数组。 |
| `join(separator)` | 该方法将数组中的所有元素连接起来形成一个字符串。该参数指定用于分隔各项的字符。 |
| `pop()` | 此方法移除并返回数组中的最后一项。 |
| `shift()` | 此方法移除并返回数组中的第一个元素。 |
| `push(item)` | 此方法将指定的项追加到数组的末尾。 |
| `unshift(item)` | 此方法在数组的开头插入一个新项。 |
| `reverse()` | 此方法返回一个新数组,该数组包含逆序排列的项。 |
| `slice(start,end)` | 此方法返回数组的一部分。 |
| `sort()` | 此方法对数组进行排序。可选的比较功能可用于执行自定义比较。 |
| `splice(index, count)` | 该方法从指定的`index`开始,从数组中移除`count`项。移除的项作为方法的结果返回。 |
| `unshift(item)` | 此方法在数组的开头插入一个新项。 |
| `every(test)` | 该方法为数组中的每一项调用`test`函数,如果函数为所有项返回`true`,则返回`true`,否则返回`false`。 |
| `some(test)` | 如果为数组中的每一项调用`test`函数至少返回一次`true`,则该方法返回`true`。 |
| `filter(test)` | 该方法返回一个新数组,其中包含了`test`函数返回的`true`项。 |
| `find(test)` | 该方法返回数组中第一个项目,对于该项目,`test`函数返回`true`。 |
| `findIndex(test)` | 该方法返回数组中第一项的索引,对于该数组,`test`函数返回`true`。 |
| `foreach(callback)` | 这个方法为数组中的每一项调用`callback`函数,如前一节所述。 |
| `includes(value)` | 如果数组包含指定的值,这个方法返回`true`。 |
| `map(callback)` | 该方法返回一个新数组,其中包含为数组中的每一项调用`callback`函数的结果。 |
| `reduce(callback)` | 该方法返回通过调用回调函数为数组中的每一项生成的累计值。 |

由于表 5-6 中的许多方法返回一个新数组,这些方法可以链接在一起处理一个过滤后的数据数组,如清单 5-33 所示。

let products = [
{ name: "Hat", price: 24.5, stock: 10 },
{ name: "Kayak", price: 289.99, stock: 1 },
{ name: "Soccer Ball", price: 10, stock: 0 },
{ name: "Running Shoes", price: 116.50, stock: 20 }
];

let totalValue = products
.filter(item => item.stock > 0)
.reduce((prev, item) => prev + (item.price * item.stock), 0);

console.log("Total value: $" + totalValue.toFixed(2));

Listing 5-33.Processing a Data Array in the main.ts File in the src Folder


我使用`filter`方法选择数组中`stock`值大于零的项目,并使用`reduce`方法确定这些项目的总值,产生以下输出:

Total value: $2864.99


## 摘要

在这一章中,我提供了一个关于 JavaScript 的简单入门,重点放在核心功能上,它将帮助你开始学习这门语言。我在本章中描述的一些特性是 JavaScript 规范中最近增加的,需要 TypeScript 编译器将它们转换成可以在旧浏览器中运行的代码。我将在下一章继续这个主题,并介绍一些在 Angular 开发中使用的更高级的 JavaScript 特性。

# 六、JavaScript 和 TypeScript:第二部分

在这一章中,我描述了一些对 Angular 开发有用的更高级的 JavaScript 特性。我解释了 JavaScript 如何处理对象,包括对类的支持,还解释了 JavaScript 功能如何打包到 JavaScript 模块中。我还介绍了 TypeScript 提供的一些特性,这些特性不是 JavaScript 规范的一部分,我在本书后面的一些示例中依赖这些特性。表 6-1 总结了本章内容。

表 6-1。

章节总结

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"> <col class="tcol3 align-left"></colgroup> 
| 

问题

 | 

解决办法

 | 

列表

 |
| --- | --- | --- |
| 通过指定属性和值创建对象 | 使用`new`关键字或使用对象文字 | 1–3 |
| 使用模板创建对象 | 定义一个类 | 4, 5 |
| 从另一个类继承行为 | 使用`extends`关键字 | six |
| 将 JavaScript 特性打包在一起 | 创建一个 JavaScript 模块 | seven |
| 声明对模块的依赖 | 使用`import`关键字 | 8–11 |
| 声明属性、参数和变量使用的类型 | 使用 TypeScript 类型批注 | 12–17 |
| 指定多种类型 | 使用联合类型 | 18–20 |
| 创建临时类型组 | 使用元组 | Twenty-one |
| 按关键字对值进行分组 | 使用可索引类型 | Twenty-two |
| 控制对类中方法和属性的访问 | 使用访问控制修饰符 | Twenty-three |

## 准备示例项目

对于这一章,我继续使用第五章的 JavaScriptPrimer 项目。准备本章不需要做任何更改,在`JavaScriptPrimer`文件夹中运行以下命令将启动 TypeScript 编译器和开发 HTTP 服务器:

```ts
ng serve --open

一个新的浏览器窗口将会打开,但它将是空的,因为我在上一章中删除了占位符内容。本章中的示例依靠浏览器的 JavaScript 控制台来显示消息。如果查看控制台,您会看到以下结果:

Total value: $2864.99

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

使用对象

有几种方法可以在 JavaScript 中创建对象。清单 6-1 给出了一个简单的例子。

Note

本章中的一些示例会导致 TypeScript 编译器报告错误。这些示例仍然有效,您可以忽略这些消息,因为 TypeScript 提供了一些额外的功能,我将在本章的后面介绍这些功能。

let myData = new Object();
myData.name = "Adam";
myData.weather = "sunny";

console.log("Hello " + myData.name + ".");
console.log("Today is " + myData.weather + ".");

Listing 6-1.Creating an Object in the main.ts File in the src Folder

我通过调用new Object()创建一个对象,并将结果(新创建的对象)赋给一个名为myData的变量。一旦创建了对象,我就可以通过赋值来定义对象的属性,就像这样:

...
myData.name = "Adam";
...

在这个语句之前,我的对象没有名为name的属性。当语句执行后,该属性确实存在,并被赋予了值Adam。您可以通过将变量名和属性名与句点组合来读取属性值,如下所示:

...
console.log("Hello " + myData.name + ".");
...

清单的结果如下:

Hello Adam.
Today is sunny.

使用对象文字

您可以使用对象文字格式在一个步骤中定义一个对象及其属性,如清单 6-2 所示。

let myData = {
    name: "Adam",
    weather: "sunny"
};

console.log("Hello " + myData.name + ". ");
console.log("Today is " + myData.weather + ".");

Listing 6-2.Using the Object Literal Format in the main.ts File in the src Folder

使用冒号(:)将您要定义的每个属性与其值分开,使用逗号(,)将属性分开。效果与前面的示例相同,清单的结果如下:

Hello Adam.
Today is sunny.

将函数用作方法

我最喜欢 JavaScript 的一个特性是可以向对象添加函数。定义在对象上的函数被称为方法。清单 6-3 展示了如何以这种方式添加方法。

let myData = {
    name: "Adam",
    weather: "sunny",
    printMessages: function () {
        console.log("Hello " + this.name + ". ");
        console.log("Today is " + this.weather + ".");
    }
};
myData.printMessages();

Listing 6-3.Adding Methods to an Object in the main.ts File in the src Folder

在这个例子中,我使用了一个函数来创建一个名为printMessages的方法。注意,为了引用对象定义的属性,我必须使用this关键字。当一个函数作为一个方法使用时,该函数通过特殊变量this被隐式传递给调用该方法的对象作为参数。清单的输出如下所示:

Hello Adam.
Today is sunny.

定义类别

类是用于创建具有相同功能的对象的模板。对类的支持是 JavaScript 规范中最近增加的内容,旨在使 JavaScript 与其他主流编程语言的工作更加一致,在整个 angle 开发中都使用了类。清单 6-4 展示了如何使用类来表达上一节中对象定义的功能。

class MyClass {

    constructor(name, weather) {
        this.name = name;
        this.weather = weather;
    }

    printMessages() {
        console.log("Hello " + this.name + ". ");
        console.log("Today is " + this.weather + ".");
    }
}

let myData = new MyClass("Adam", "sunny");
myData.printMessages();

Listing 6-4.Defining a Class in the main.ts File in the src Folder

如果您使用过另一种主流语言,如 Java 或 C#,那么 JavaScript 类将会很熟悉。class关键字用于声明一个类,后跟类名,在本例中是MyClass

当使用该类创建一个新对象时,调用constructor函数,它提供了一个接收数据值和进行该类所需的任何初始设置的机会。在这个例子中,构造函数定义了用于创建同名变量的nameweather参数。像这样定义的变量被称为属性

类可以有被定义为函数的方法,尽管不需要使用function k关键字。示例中有一个方法叫做printMessages,它使用nameweather属性的值将消息写入浏览器的 JavaScript 控制台。

Tip

类也可以有静态方法,用关键字static表示。静态方法属于类,而不是它们创建的对象。我在清单 6-14 中包含了一个静态方法的例子。

new关键字用于从一个类中创建一个对象,如下所示:

...
let myData = new MyClass("Adam", "sunny");
...

该语句使用MyClass类作为模板创建一个新对象。在这种情况下,MyClass被用作函数,传递给它的参数将被该类定义的constructor函数接收。这个表达式的结果是一个新对象,它被分配给一个名为myData的变量。一旦创建了一个对象,就可以通过为其赋值的变量来访问它的属性和方法,如下所示:

...
myData.printMessages();
...

此示例在浏览器的 JavaScript 控制台中产生以下结果:

Hello Adam.
Today is sunny.

JavaScript Classes vs. Prototypes

类特性没有改变 JavaScript 处理类型的基本方式。相反,它只是提供了一种大多数程序员更熟悉的使用它们的方法。在幕后,JavaScript 仍然使用传统的类型系统,这是基于原型的。举个例子,清单 6-4 中的代码也可以写成这样:

var MyClass = function MyClass(name, weather) {
    this.name = name;
    this.weather = weather;
}

MyClass.prototype.printMessages = function () {
    console.log("Hello " + this.name + ". ");
    console.log("Today is " + this.weather + ".");
};

var myData = new MyClass("Adam", "sunny");
myData.printMessages();

使用类时,Angular 开发更容易,这是我在本书中一直采用的方法。ES6 中引入的许多特性被归类为语法糖,这意味着它们使 JavaScript 的某些方面更容易理解和使用。术语句法糖可能看起来带有贬义,但是 JavaScript 有一些奇怪的特性,其中许多特性帮助开发人员避免常见的陷阱。

定义类 Getter 和 Setter 属性

JavaScript 类可以在其构造函数中定义属性,从而产生一个可以在应用的其他地方读取和修改的变量。Getters 和 setters 作为常规属性出现在类之外,但它们允许引入额外的逻辑,这对于验证或转换新值或以编程方式生成值很有用,如清单 6-5 所示。

class MyClass {
    constructor(name, weather) {
        this.name = name;
        this._weather = weather;
    }

    set weather(value) {
        this._weather = value;
    }

    get weather() {
        return `Today is ${this._weather}`;
    }

    printMessages() {
        console.log("Hello " + this.name + ". ");
        console.log(this.weather);
    }
}

let myData = new MyClass("Adam", "sunny");
myData.printMessages();

Listing 6-5.Using Getters and Setters in the main.ts File in the src Folder

getter 和 setter 被实现为前面带有关键字getset的函数。JavaScript 类中没有访问控制的概念,惯例是在内部属性的名称前加上下划线(_字符)。在清单中,weather属性是用一个更新名为_weather的属性的 setter 和一个将_weather值合并到模板字符串中的 getter 实现的。此示例在浏览器的 JavaScript 控制台中产生以下结果:

Hello Adam.
Today is sunny

使用类继承

使用extends关键字,类可以从其他类继承行为,如清单 6-6 所示。

class MyClass {
    constructor(name, weather) {
        this.name = name;
        this._weather = weather;
    }

    set weather(value) {
        this._weather = value;
    }

    get weather() {
        return `Today is ${this._weather}`;
    }

    printMessages() {
        console.log("Hello " + this.name + ". ");
        console.log(this.weather);
    }
}

class MySubClass extends MyClass {

    constructor(name, weather, city) {
        super(name, weather);
        this.city = city;
    }

    printMessages() {
        super.printMessages();
        console.log(`You are in ${this.city}`);
    }
}

let myData = new MySubClass("Adam", "sunny", "London");
myData.printMessages();

Listing 6-6.Using Class Inheritance in the main.ts File in the src Folder

extends关键字用于声明将被继承的类,称为超类基类。在清单中,MySubClass继承了MyClasssuper关键字用于调用超类的构造函数和方法。MySubClassMyClass功能的基础上增加了对城市的支持,在浏览器的 JavaScript 控制台中产生了以下结果:

Hello Adam.
Today is sunny
You are in London

使用 JavaScript 模块

JavaScript 模块用于管理 web 应用中的依赖关系,这意味着您不需要管理大量单独的代码文件来确保浏览器下载应用的所有代码。相反,在编译过程中,应用需要的所有 JavaScript 文件被组合成一个更大的文件,称为,浏览器下载的就是这个文件。

Note

早期版本的 Angular 依赖于一个模块加载器,它会发送单独的 HTTP 请求来获取应用所需的 JavaScript 文件。对开发工具的更改通过切换到使用在构建过程中创建的包简化了这个过程。

创建和使用模块

添加到项目中的每个 TypeScript 或 JavaScript 文件都被视为一个模块。为了演示,我在src文件夹中创建了一个名为modules的文件夹,向其中添加了一个名为NameAndWeather.ts的文件,并添加了清单 6-7 中所示的代码。

export class Name {
    constructor(first, second) {
        this.first = first;
        this.second = second;
    }

    get nameMessage() {
        return `Hello ${this.first} ${this.second}`;
    }
}

export class WeatherLocation {
    constructor(weather, city) {
        this.weather = weather;
        this.city = city;
    }

    get weatherMessage() {
        return `It is ${this.weather} in ${this.city}`;
    }
}

Listing 6-7.The Contents of the NameAndWeather.ts File in the src/modules Folder

默认情况下,JavaScript 或 TypeScript 文件中定义的类、函数和变量只能在该文件中访问。export关键字用于使特性在文件之外可访问,以便它们可以被应用的其他部分使用。在这个例子中,我将关键字export应用到了NameWeatherLocation类,这意味着它们可以在模块之外使用。

Tip

我在NameAndWeather.ts文件中定义了两个类,其效果是创建了一个包含两个类的模块。Angular 应用中的惯例是将每个类放入它自己的文件中,这意味着每个类都在它自己的模块中定义,并且你会在本书的清单中看到关键字export

import关键字用于声明对模块提供的特性的依赖。在清单 6-8 中,我在main.ts文件中使用了NameWeatherLocation类,这意味着我必须使用import关键字来声明对它们和它们所来自的模块的依赖。

import { Name, WeatherLocation } from "./modules/NameAndWeather";

let name = new Name("Adam", "Freeman");
let loc = new WeatherLocation("raining", "London");

console.log(name.nameMessage);
console.log(loc.weatherMessage);

Listing 6-8.Importing Specific Types in the main.ts File in the src Folder

这是我在本书的大多数例子中使用import关键字的方式。该关键字后面是花括号,其中包含当前文件中的代码所依赖的特性的逗号分隔列表,后面是from关键字,再后面是模块名。在本例中,我从modules文件夹中的NameAndWeather模块中导入了NameWeatherLocation类。请注意,指定模块时不包括文件扩展名。

停止 Angular 开发工具,并运行以下命令再次启动它们,这将确保新文件夹包含在构建过程中:

ng serve

main.ts文件被编译时,Angular 开发工具检测到对NameAndWeather.ts文件中代码的依赖。这种依赖性确保了 JavaScript 包文件中包含了NameWeatherLocation类,您将在浏览器的 JavaScript 控制台中看到以下输出,显示模块中的代码用于产生结果:

Hello Adam Freeman
It is raining in London

请注意,我不必将NaneAndWeather.ts文件包含在要发送到浏览器的文件列表中。仅仅使用import关键字就足以声明依赖关系,并确保应用所需的代码包含在发送给浏览器的 JavaScript 文件中。

(您将看到警告您尚未定义属性的错误。暂时忽略这些警告;我将在本章的后面解释它们是如何解决的。)

Understanding Module Resolution

在本书的import语句中,你会看到两种不同的指定模块的方式。第一个是相对模块,其中模块名以./为前缀,如清单 6-8 中的例子:

...
import { Name, WeatherLocation } from "./modules/NameAndWeather";
...

该语句指定了相对于包含import语句的文件定位的模块。在这种情况下,NameAndWeather.ts文件位于modules目录中,该目录与main.ts文件位于同一目录中。另一种类型的导入是不相关的。这是第二章的一个非相关的例子,你会在整本书中看到:

...
import { Component } from "@angular/core";
...

这个import语句中的模块不是以./开始的,构建工具通过在node_modules文件夹中寻找一个包来解决依赖关系。在这种情况下,依赖关系是由@angular/core包提供的特性,它是在由ng new命令创建时添加到项目中的。

重命名导入

在有许多依赖项的复杂项目中,您可能需要使用来自不同模块的同名的两个类。为了重现这种情况,我在src/modules文件夹中创建了一个名为DuplicateName.ts的文件,并定义了清单 6-9 中所示的类。

export class Name {

    get message() {
        return "Other Name";
    }
}

Listing 6-9.The Contents of the DuplicateName.ts File in the src/modules Folder

这个类不做任何有用的事情,但是它被称为Name,这意味着使用清单 6-8 中的方法导入它将会导致冲突,因为编译器将无法区分这两个同名的类。解决方案是使用as关键字,它允许在从模块导入类时为类创建别名,如清单 6-10 所示。

import { Name, WeatherLocation } from "./modules/NameAndWeather";
import { Name as OtherName } from "./modules/DuplicateName";

let name = new Name("Adam", "Freeman");
let loc = new WeatherLocation("raining", "London");
let other = new OtherName();

console.log(name.nameMessage);
console.log(loc.weatherMessage);
console.log(other.message);

Listing 6-10.Using a Module Alias in the main.ts File in the src Folder

DuplicateName模块中的Name类被导入为OtherName,这使得它在使用时不会与NameAndWeather模块中的Name类发生冲突。此示例产生以下输出:

Hello Adam Freeman
It is raining in London
Other Name

导入模块中的所有类型

另一种方法是将模块作为一个对象导入,该对象具有它所包含的每种类型的属性,如清单 6-11 所示。

import * as NameAndWeatherLocation from "./modules/NameAndWeather";
import { Name as OtherName } from "./modules/DuplicateName";

let name = new NameAndWeatherLocation.Name("Adam", "Freeman");
let loc = new NameAndWeatherLocation.WeatherLocation("raining", "London");
let other = new OtherName();

console.log(name.nameMessage);
console.log(loc.weatherMessage);
console.log(other.message);

Listing 6-11.Importing a Module as an Object in the main.ts File in the src Folder

本例中的import语句导入NameAndWeather模块的内容,并创建一个名为NameAndWeatherLocation的对象。这个对象具有与模块中定义的类相对应的NameWeather属性。这个例子产生与清单 6-10 相同的输出。

有用的 TypeScript 功能

TypeScript 是 JavaScript 的超集,提供的语言特性建立在 JavaScript 规范所提供的特性之上。在接下来的几节中,我将展示对于 Angular 开发最有用的 TypeScript 特性,我在本书的示例中使用了其中的许多特性。

Tip

TypeScript 支持比我在本章中描述的更多的特性。我将在后面的章节中介绍一些额外的特性,但是要获得完整的参考资料,请查看位于 www.typescriptlang.org 的 TypeScript 主页。

使用类型注释

headline TypeScript 特性支持类型注释,这有助于通过在编译代码时应用类型检查来减少常见的 JavaScript 错误,这种方式让人想起 C#或 Java 等语言。如果您很难接受 JavaScript 类型系统(或者甚至没有意识到有这样一个系统),那么类型注释可以在很大程度上防止最常见的错误。(另一方面,如果您喜欢常规 JavaScript 类型的自由,您可能会发现 TypeScript 类型注释的限制性和烦人性。)

为了展示类型注释解决的问题,我在JavaScriptPrimer文件夹中创建了一个名为tempConverter.ts的文件,并添加了清单 6-12 中的代码。

export class TempConverter {

    static convertFtoC(temp) {
        return ((parseFloat(temp.toPrecision(2)) - 32) / 1.8).toFixed(1);
    }
}

Listing 6-12.The Contents of the tempConverter.ts File in the src Folder

TempConverter类包含一个名为convertFtoC的简单静态方法,该方法接受以华氏度表示的温度值,并返回以摄氏度表示的相同温度。

本代码中有一些不明确的假设。convertFtoC方法期望接收一个number值,在此基础上调用toPrecision方法来设置浮点数字的个数。该方法返回一个string,尽管在没有仔细检查代码的情况下很难判断出来(toFixed方法的结果是一个string)。

这些隐含的假设会导致问题,尤其是当一个开发人员使用另一个开发人员编写的 JavaScript 代码时。在清单 6-13 中,我故意制造了一个错误,将温度作为string值传递,而不是该方法期望的number

import { Name, WeatherLocation } from "./modules/NameAndWeather";
import { Name as OtherName } from "./modules/DuplicateName";
import { TempConverter } from "./tempConverter";

let name = new Name("Adam", "Freeman");
let loc = new WeatherLocation("raining", "London");
let other = new OtherName();

let cTemp = TempConverter.convertFtoC("38");

console.log(name.nameMessage);
console.log(loc.weatherMessage);
console.log(`The temp is ${cTemp}C`);

Listing 6-13.Using the Wrong Type in the main.ts File in the src Folder

当浏览器执行代码时,您将在浏览器的 JavaScript 控制台中看到以下消息(具体工作方式可能因您使用的浏览器而异):

temp.toPrecision is not a function

当然,不使用 TypeScript 也可以解决这种问题,但这意味着任何 JavaScript 应用中的大量代码都被用来检查正在使用的类型。TypeScript 解决方案是使用添加到 JavaScript 代码中的类型注释,使类型强制成为编译器的工作。在清单 6-14 中,我给TempConverter类添加了类型注释。

export class TempConverter {

    static convertFtoC(temp: number) : string {
        return ((parseFloat(temp.toPrecision(2)) - 32) / 1.8).toFixed(1);
    }
}

Listing 6-14.Adding Type Annotations in the tempConverter.ts File in the src Folder

类型注释使用冒号(:字符)后跟类型来表示。示例中有两个注释。第一个指定了convertFtoC方法的参数应该是一个number

...
static convertFtoC(temp: number) : string {
...

另一个注释指定该方法的结果是一个字符串。

...
static convertFtoC(temp: number) : string {
...

当您保存对文件的更改时,TypeScript 编译器将运行。报告的错误包括:

Argument of type '"38"' is not assignable to parameter of type 'number'.

TypeScript 编译器检查到传递给文件main.ts中的convertFtoC方法的值的类型与类型注释不匹配,并报告了一个错误。这是 TypeScript 类型系统的核心;这意味着您不必在您的类中编写额外的代码来检查您是否收到了预期的类型,并且它还使得确定方法结果的类型变得容易。为了解决报告给编译器的错误,清单 6-15 更新了调用convertFtoC方法的语句,以便它使用一个number

import { Name, WeatherLocation } from "./modules/NameAndWeather";
import { Name as OtherName } from "./modules/DuplicateName";
import { TempConverter } from "./tempConverter";

let name = new Name("Adam", "Freeman");
let loc = new WeatherLocation("raining", "London");
let other = new OtherName();

let cTemp = TempConverter.convertFtoC(38);

console.log(name.nameMessage);
console.log(loc.weatherMessage);
console.log(other.message);
console.log(`The temp is ${cTemp}C`);

Listing 6-15.Using a Number Argument in the main.ts File in the src Folder

保存更改时,您将看到浏览器的 JavaScript 控制台中显示以下消息:

Hello Adam Freeman
It is raining in London
Other Name
The temp is 3.3C

类型注释属性和变量

类型注释也可以应用于属性和变量,确保编译器可以验证应用中使用的所有类型。在清单 6-16 中,我给NameAndWeather模块中的类添加了类型注释。

export class Name {
    first: string;
    second: string;

    constructor(first: string, second: string) {
        this.first = first;
        this.second = second;
    }

    get nameMessage() : string {
        return `Hello ${this.first} ${this.second}`;
    }
}

export class WeatherLocation {
    weather: string;
    city: string;

    constructor(weather: string, city: string) {
        this.weather = weather;
        this.city = city;
    }

    get weatherMessage() : string {
        return `It is ${this.weather} in ${this.city}`;
    }
}

Listing 6-16.Adding Annotations in the NameAndWeather.ts File in the src/modules Folder

属性是用类型注释声明的,遵循与参数和结果注释相同的模式。清单 6-17 中的变化解决了 TypeScript 编译器报告的其余错误,该编译器抱怨是因为它不知道构造函数中创建的属性的类型。

接收构造函数参数并将它们的值赋给变量的模式如此常见,以至于 TypeScript 包含了一个优化,如清单 6-17 所示。

export class Name {

    constructor(private first: string, private second: string) {}

    get nameMessage() : string {
        return `Hello ${this.first} ${this.second}`;
    }
}

export class WeatherLocation {

    constructor(private weather: string, private city: string) {}

    get weatherMessage() : string {
        return `It is ${this.weather} in ${this.city}`;
    }
}

Listing 6-17.Using Parameters in the NameAndWeather.ts File in the src/modules Folder

关键字private是访问控制修饰符的一个例子,我在“使用访问修饰符”一节中描述了它。将关键字应用于构造函数参数具有自动定义 class 属性并为其分配参数值的效果。清单 6-17 中的代码是清单 6-16 的更简洁版本。

指定多种类型或任何类型

TypeScript 允许指定多种类型,用一个竖线(|字符)分隔。当一个方法可以接受或返回多种类型时,或者当一个变量可以被赋予不同类型的值时,这是很有用的。清单 6-18 修改了convertFtoC方法,使其接受numberstring值。

export class TempConverter {

    static convertFtoC(temp: number | string): string {
        let value: number = (<number>temp).toPrecision
            ? <number>temp : parseFloat(<string>temp);
        return ((parseFloat(value.toPrecision(2)) - 32) / 1.8).toFixed(1);
    }
}

Listing 6-18.Accepting Multiple Types in the tempConverter.ts File in the src Folder

temp参数的type声明已经更改为number | string,这意味着该方法可以接受任何一种类型。这叫做联体式。在该方法中,类型断言用于确定接收到的是哪种类型。这是一个有点尴尬的过程,但是参数值被转换成一个数字值,以检查结果上是否定义了一个toPrecision方法,如下所示:

...
(<number>temp).toPrecision
...

尖括号(<>字符)用于声明一个类型断言,该断言试图将一个对象转换为指定的类型。您也可以使用as关键字获得相同的结果,如清单 6-19 所示。

export class TempConverter {

    static convertFtoC(temp: number | string): string {
        let value: number = (temp as number).toPrecision
            ? temp as number : parseFloat(<string>temp);
        return ((parseFloat(value.toPrecision(2)) - 32) / 1.8).toFixed(1);
    }
}

Listing 6-19.Using the as Keyword in the tempConverter.ts File in the src Folder

指定联合类型的另一种方法是使用any关键字,它允许将任何类型赋给变量、用作参数或从方法返回。清单 6-20 用any关键字替换了convertFtoC方法中的联合类型。

Tip

当您省略类型注释时,TypeScript 编译器将隐式应用any关键字。

export class TempConverter {

    static convertFtoC(temp: any): string {
        let value: number;
        if ((temp as number).toPrecision) {
            value = temp;
        } else if ((temp as string).indexOf) {
            value = parseFloat(<string>temp);
        } else {
            value = 0;
        }
        return ((parseFloat(value.toPrecision(2)) - 32) / 1.8).toFixed(1);
    }
}

Listing 6-20.Specifying Any Type in the tempConverter.ts File in the src Folder

使用元组

元组是固定长度的数组,数组中的每一项都是指定的类型。这是一个听起来模糊的描述,因为元组是如此灵活。例如,清单 6-21 使用一个元组来表示一个城市及其当前的天气和温度。

import { Name, WeatherLocation } from "./modules/NameAndWeather";
import { Name as OtherName } from "./modules/DuplicateName";
import { TempConverter } from "./tempConverter";

let name = new Name("Adam", "Freeman");
let loc = new WeatherLocation("raining", "London");
let other = new OtherName();

let cTemp = TempConverter.convertFtoC("38");

let tuple: [string, string, string];
tuple = ["London", "raining", TempConverter.convertFtoC("38")]

console.log(`It is ${tuple[2]} degrees C and ${tuple[1]} in ${tuple[0]}`);

Listing 6-21.Using a Tuple in the main.ts File in the src Folder

元组被定义为一个类型数组,使用数组索引器访问单个元素。此示例在浏览器的 JavaScript 控制台中生成以下消息:

It is 3.3 degrees C and raining in London

使用可索引类型

可索引类型将一个键与一个值相关联,创建一个类似地图的集合,可用于将相关的数据项收集在一起。在清单 6-22 中,我使用了一个可索引类型来收集多个城市的信息。

import { Name, WeatherLocation } from "./modules/NameAndWeather";
import { Name as OtherName } from "./modules/DuplicateName";
import { TempConverter } from "./tempConverter";

let cities: { [index: string]: [string, string] } = {};

cities["London"] = ["raining", TempConverter.convertFtoC("38")];
cities["Paris"] = ["sunny", TempConverter.convertFtoC("52")];
cities["Berlin"] = ["snowing", TempConverter.convertFtoC("23")];

for (let key in cities) {
    console.log(`${key}: ${cities[key][0]}, ${cities[key][1]}`);
}

Listing 6-22.Using Indexable Types in the main.ts File in the src Folder

cities变量定义为可索引类型,键为字符串,数据值为[string, string]元组。使用数组风格的索引器来赋值和读取,比如cities["London"]。可以使用一个for...in循环访问可索引类型的键集合,如示例所示,它在浏览器的 JavaScript 控制台中产生以下输出:

London: raining, 3.3
Paris: sunny, 11.1
Berlin: snowing, -5.0

只有numberstring值可以用作可索引类型的键,但这是一个有用的特性,我将在后面章节的示例中使用。

使用访问修饰符

JavaScript 不支持访问保护,这意味着可以从应用的任何部分访问类、它们的属性和它们的方法。有一个惯例是在实现成员的名字前加一个下划线(_字符),但是这只是对其他开发人员的一个警告,并不是强制的。

TypeScript 提供了三个关键字,用于管理访问,并由编译器强制执行。表 6-2 描述了关键字。

表 6-2。

TypeScript 访问修饰符关键字

|

关键字

|

描述

|
| --- | --- |
| public | 该关键字用于表示可以在任何地方访问的属性或方法。如果没有使用关键字,这是默认的访问保护。 |
| private | 该关键字用于表示只能在定义它的类中访问的属性或方法。 |
| protected | 该关键字用于表示只能在定义它的类中或由扩展该类的类访问的属性或方法。 |

清单 6-23 向TempConverter类添加了一个private方法。

export class TempConverter {

    static convertFtoC(temp: any): string {
        let value: number;
        if ((temp as number).toPrecision) {
            value = temp;
        } else if ((temp as string).indexOf) {
            value = parseFloat(<string>temp);
        } else {
            value = 0;
        }
        return TempConverter.performCalculation(value).toFixed(1);
    }

    private static performCalculation(value: number): number {
        return (parseFloat(value.toPrecision(2)) - 32) / 1.8;
    }
}

Listing 6-23.Using an Access Modifier in the tempConverter.ts File in the src Folder

performCalculation方法被标记为private,这意味着如果应用的任何其他部分试图调用该方法,TypeScript 编译器将报告一个错误代码。

摘要

在这一章中,我描述了 JavaScript 支持处理对象和类的方式,解释了 JavaScript 模块如何工作,并介绍了对 angle 开发有用的 TypeScript 特性。在下一章中,我将开始创建一个现实项目的过程,在本书第二部分深入探讨各个细节之前,该项目将概述不同的 Angular 特征如何共同创建应用。

七、SportsStore:一个真正的应用

在第二章,我构建了一个快速简单的 Angular 应用。小而集中的例子允许我展示特定的 Angular 特征,但它们可能缺乏上下文。为了帮助克服这个问题,我将创建一个简单但现实的电子商务应用。

我的应用名为 SportsStore,将遵循各地在线商店采用的经典方法。我将创建一个客户可以按类别和页面浏览的在线产品目录,一个用户可以添加和删除产品的购物车,以及一个客户可以输入送货细节和下订单的收银台。我还将创建一个管理区域,其中包括用于管理目录的创建、读取、更新和删除(CRUD)工具——我将保护它,以便只有登录的管理员才能进行更改。最后,我将向您展示如何准备和部署 Angular 应用。

我在这一章和接下来的章节中的目标是通过创建尽可能真实的例子来给你一个真实的 Angular 发展的感觉。当然,我想把重点放在 Angular 上,所以我简化了与外部系统的集成,比如数据存储,并完全省略了其他部分,比如支付处理。

SportsStore 是我在几本书中使用的一个例子,尤其是因为它展示了使用不同的框架、语言和开发风格来实现相同结果的方法。你不需要阅读我的任何其他书籍来理解这一章,但是如果你已经拥有我的Pro ASP.NET Core 3书,你会发现这种对比很有趣。

我在 SportsStore 应用中使用的 Angular 特征将在后面的章节中详细介绍。我不会在这里重复所有的内容,我告诉您的内容足以让您理解示例应用,并让您参考其他章节以获得更深入的信息。你可以从头到尾阅读 SportsStore 章节,了解 Angular 的工作原理,也可以从细节章节跳转到深度章节。无论哪种方式,都不要指望马上理解所有的东西——Angular 有许多活动的部分,SportsStore 应用旨在向您展示它们是如何组合在一起的,而不是深入到我在本书剩余部分描述的细节中。

准备项目

要创建 SportsStore 项目,请打开命令提示符,导航到一个方便的位置,然后运行以下命令:

ng new SportsStore --routing false --style css --skip-git --skip-tests

angular-cli包将为 Angular 开发创建一个新项目,包含配置文件、占位符内容和开发工具。项目设置过程可能需要一些时间,因为有许多 NPM 软件包需要下载和安装。

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

安装附加的 NPM 软件包

除了核心 Angular 包和由ng new命令设置的构建工具之外,SportsStore 项目还需要额外的包。运行以下命令导航到SportsStore文件夹并添加所需的包:

cd SportsStore
npm install bootstrap@4.4.1
npm install @fortawesome/fontawesome-free@5.12.1
npm install --save-dev json-server@0.16.0
npm install --save-dev jsonwebtoken@8.5.1

使用清单中显示的版本号很重要。在添加包时,您可能会看到关于未满足对等依赖关系的警告,但是您可以忽略它们。有些包是使用--save-dev参数安装的,这表明它们是在开发过程中使用的,不会成为 SportsStore 应用的一部分。

向应用添加 CSS 样式表

一旦安装了这些包,将清单 7-1 中所示的语句添加到angular.json文件中,将来自引导 CSS 框架和字体 Awesome 包的 CSS 文件合并到应用中。我将对 SportsStore 应用中的所有 HTML 内容使用 Bootstrap CSS 样式,并且我将使用字体 Awesome 包中的图标向用户呈现购物车的摘要。

...
"architect": {
  "build": {
    "builder": "@angular-devkit/build-angular:browser",
    "options": {
      "outputPath": "dist/SportsStore",
      "index": "src/index.html",
      "main": "src/main.ts",
      "polyfills": "src/polyfills.ts",
      "tsConfig": "src/tsconfig.app.json",
      "assets": [
        "src/favicon.ico",
        "src/assets"
      ],
      "styles": [
        "src/styles.css",
        "node_modules/bootstrap/dist/css/bootstrap.min.css",
        "node_modules/@fortawesome/fontawesome-free/css/all.min.css"
      ],
      "scripts": []
    },
...

Listing 7-1.Adding CSS to the angular.json File in the SportsStore Folder

准备 RESTful Web 服务

SportsStore 应用将使用异步 HTTP 请求来获取由 RESTful web 服务提供的模型数据。正如我在第二十四章中所描述的,REST 是一种设计 web 服务的方法,它使用 HTTP 方法或动词来指定操作和 URL 来选择操作所应用的数据对象。

在前一节中,我将json-server包添加到项目中。这是一个从 JSON 数据或 JavaScript 代码创建 web 服务的优秀包。将清单 7-2 中所示的语句添加到package.json文件的scripts部分,这样json-server包就可以从命令行启动。

...
"scripts": {
  "ng": "ng",
  "start": "ng serve",
  "build": "ng build",
  "test": "ng test",
  "lint": "ng lint",
  "e2e": "ng e2e",
  "json": "json-server data.js -p 3500 -m authMiddleware.js"
},
...

Listing 7-2.Adding a Script in the package.json File in the SportsStore Folder

为了给json-server包提供要处理的数据,我在SportsStore文件夹中添加了一个名为data.js的文件,并添加了如清单 7-3 所示的代码,这将确保无论何时启动json-server包都可以获得相同的数据,这样我在开发过程中就有了一个固定的参考点。

Tip

创建配置文件时,注意文件名很重要。有些具有.json扩展名,这意味着它们包含 JSON 格式的静态数据。其他文件的扩展名为.js,这意味着它们包含 JavaScript 代码。Angular 开发所需的每个工具都有关于其配置文件的预期。

module.exports = function () {
    return {
        products: [
            { id: 1, name: "Kayak", category: "Watersports",
                description: "A boat for one person", price: 275 },
            { id: 2, name: "Lifejacket", category: "Watersports",
                description: "Protective and fashionable", price: 48.95 },
            { id: 3, name: "Soccer Ball", category: "Soccer",
                description: "FIFA-approved size and weight", price: 19.50 },
            { id: 4, name: "Corner Flags", category: "Soccer",
                description: "Give your playing field a professional touch",
                price: 34.95 },
            { id: 5, name: "Stadium", category: "Soccer",
                description: "Flat-packed 35,000-seat stadium", price: 79500 },
            { id: 6, name: "Thinking Cap", category: "Chess",
                description: "Improve brain efficiency by 75%", price: 16 },
            { id: 7, name: "Unsteady Chair", category: "Chess",
                description: "Secretly give your opponent a disadvantage",
                price: 29.95 },
            { id: 8, name: "Human Chess Board", category: "Chess",
                description: "A fun game for the family", price: 75 },
            { id: 9, name: "Bling King", category: "Chess",
                description: "Gold-plated, diamond-studded King", price: 1200 }
        ],
        orders: []
    }
}

Listing 7-3.The Contents of the data.js File in the SportsStore Folder

这段代码定义了将由 RESTful web 服务呈现的两个数据集合。products集合包含销售给客户的产品,而orders集合将包含客户已经下的订单(但是目前是空的)。

RESTful web 服务存储的数据需要受到保护,这样普通用户就不能修改产品或更改订单的状态。json-server包不包含任何内置的认证特性,所以我在SportsStore文件夹中创建了一个名为authMiddleware.js的文件,并添加了清单 7-4 中所示的代码。

const jwt = require("jsonwebtoken");

const APP_SECRET = "myappsecret";
const USERNAME = "admin";
const PASSWORD = "secret";

const mappings = {
    get: ["/api/orders", "/orders"],
    post: ["/api/products", "/products", "/api/categories", "/categories"]
}

function requiresAuth(method, url) {
    return (mappings[method.toLowerCase()] || [])
        .find(p => url.startsWith(p)) !== undefined;
}

module.exports = function (req, res, next) {
    if (req.url.endsWith("/login") && req.method == "POST") {
        if (req.body && req.body.name == USERNAME && req.body.password == PASSWORD) {
            let token = jwt.sign({ data: USERNAME, expiresIn: "1h" }, APP_SECRET);
            res.json({ success: true, token: token });
        } else {
            res.json({ success: false });
        }
        res.end();
        return;
    } else if (requiresAuth(req.method, req.url)) {
        let token = req.headers["authorization"] || "";
        if (token.startsWith("Bearer<")) {
            token = token.substring(7, token.length - 1);
            try {
                jwt.verify(token, APP_SECRET);
                next();
                return;
            } catch (err) { }
        }
        res.statusCode = 401;
        res.end();
        return;
    }
    next();
}

Listing 7-4.The Contents of the authMiddleware.js File in the SportsStore Folder

这段代码检查发送到 RESTful web 服务的 HTTP 请求,并实现一些基本的安全特性。这是与 Angular 开发没有直接关系的服务器端代码,所以如果它的目的不是很明显,也不用担心。我在第九章解释认证授权过程,包括如何用 Angular 认证用户。

Caution

除了 SportsStore 应用之外,不要使用清单 7-4 中的代码。它包含硬连线到代码中的弱密码。这对于 SportsStore 项目来说很好,因为重点是用 Angular 进行客户端开发,但是这并不适合真实的项目。

准备 HTML 文件

每个 Angular web 应用都依赖于浏览器加载的 HTML 文件,该文件用于加载和启动应用。编辑SportsStore/src文件夹中的index.html文件,删除占位符内容,并添加清单 7-5 中所示的元素。

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>SportsStore</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body class="m-2">
  <app>SportsStore Will Go Here</app>
</body>
</html>

Listing 7-5.Preparing the index.html File in the src Folder

HTML 文档包含一个app元素,它是 SportsStore 功能的占位符。还有一个base元素,这是 Angular URL 路由特性所需要的,我在第八章将它添加到了 SportsStore 项目中。

创建文件夹结构

设置 Angular 应用的一个重要部分是创建文件夹结构。ng new命令建立一个项目,将应用的所有文件放在src文件夹中,Angular 文件放在src/app文件夹中。为了给项目添加一些结构,创建如表 7-1 所示的附加文件夹。

表 7-1。

SportsStore 项目所需的附加文件夹

|

文件夹

|

描述

|
| --- | --- |
| SportsStore/src/app/model | 该文件夹将包含数据模型的代码。 |
| SportsStore/src/app/store | 该文件夹将包含基本购物功能。 |
| SportsStore/src/app/admin | 该文件夹将包含管理功能。 |

运行示例应用

确保所有更改都已保存,并在 SportsStore 文件夹中运行以下命令:

ng serve --open

该命令将启动由ng new命令设置的开发工具,每当检测到变更时,该工具将自动编译并打包src文件夹中的代码和内容文件。一个新的浏览器窗口将会打开并显示如图 7-1 所示的内容。

img/421542_4_En_7_Fig1_HTML.jpg

图 7-1。

运行示例应用

开发 web 服务器将在端口 4200 上启动,因此应用的 URL 将是http://localhost:4200。您不必包含 HTML 文档的名称,因为index.html是服务器响应的默认文件。(您将在浏览器的 JavaScript 控制台中看到错误,暂时可以忽略。)

启动 RESTful Web 服务

要启动 RESTful web 服务,打开一个新的命令提示符,导航到SportsStore文件夹,并运行以下命令:

npm run json

RESTful web 服务被配置为在端口 3500 上运行。为了测试 web 服务请求,使用浏览器请求 URL http://localhost:3500/products/1。浏览器将显示清单 7-3 中定义的产品之一的 JSON 表示,如下所示:

{
  "id": 1,
  "name": "Kayak",
  "category": "Watersports",
  "description": "A boat for one person",
  "price": 275
}

准备 Angular 投影特征

每个 Angular 的项目都需要一些基本的准备。在接下来的小节中,我将替换占位符内容来构建 SportsStore 应用的基础。

更新根组件

根组件是 Angular 构建块,它将管理清单 7-5 中 HTML 文档中app元素的内容。一个应用可以包含许多组件,但是总有一个根组件负责呈现给用户的顶层内容。我在SportsStore/src/app文件夹中编辑了名为app.component.ts的文件,并用清单 7-6 中的语句替换了现有代码。

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

@Component({
  selector: "app",
  template: `<div class="bg-success p-2 text-center text-white">
                    This is SportsStore
               </div>`
})
export class AppComponent { }

Listing 7-6.The Contents of the app.component.ts File in the src/app Folder

@Component装饰器告诉 Angular,AppComponent类是一个组件,它的属性配置如何应用组件。所有组件属性都在第十七章中描述,但是列表中显示的属性是最基本和最常用的。selector属性告诉 Angular 如何在 HTML 文档中应用组件,而template属性定义了组件将显示的 HTML 内容。组件可以定义内联模板,就像这样,或者它们使用外部 HTML 文件,这样可以更容易地管理复杂的内容。

AppComponent类中没有代码,因为 Angular 项目中的根组件只是用来管理显示给用户的内容。最初,我将手动管理根组件显示的内容,但是在第八章中,我使用了一个名为 URL 路由的特性来根据用户动作自动调整内容。

更新根模块

有两种 Angular 模块:特征模块和根模块。功能模块用于对相关的应用功能进行分组,以使应用更易于管理。我为应用的每个主要功能区域创建功能模块,包括数据模型、呈现给用户的商店界面和管理界面。

根模块用于描述对 Angular 的应用。描述包括运行应用需要哪些特性模块,应该加载哪些定制特性,以及根组件的名称。根组件文件的常规名称是app.module.ts,它创建在SportsStore/src/app文件夹中。目前不需要对此文件进行任何更改;清单 7-7 显示了它的初始内容。

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Listing 7-7.The Contents of the app.module.ts File in the src/app Folder

与根组件类似,根模块的类中没有代码。这是因为根模块的真正存在只是为了通过@NgModule装饰器提供信息。属性imports告诉 Angular 它应该加载BrowserModule特性模块,该模块包含 web 应用所需的核心 Angular 特性。

declarations属性告诉 Angular 它应该加载根组件,providers属性告诉 Angular 应用使用的共享对象,bootstrap属性告诉 Angular 根组件是AppComponent类。当我向 SportsStore 应用添加特性时,我将向这个装饰器的属性添加信息,但是这个基本配置足以启动应用。

检查引导文件

下一步是启动应用的引导文件。这本书的重点是使用 Angular 来创建在 web 浏览器中工作的应用,但是 Angular 平台可以移植到不同的环境中。引导文件使用 Angular 浏览器平台加载根模块并启动应用。不需要对SportsStore/src文件夹中的main.ts文件的内容进行修改,如清单 7-8 所示。

import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { AppModule } from './app/app.module';
import { environment } from './environments/environment';

if (environment.production) {
  enableProdMode();
}

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

Listing 7-8.The Contents of the main.ts File in the src Folder

开发工具检测项目文件的变化,编译代码文件,并自动重新加载浏览器,产生如图 7-2 所示的内容。

img/421542_4_En_7_Fig2_HTML.jpg

图 7-2。

启动 SportsStore 应用

启动数据模型

开始任何新项目的最佳地方是数据模型。我想让您看到一些工作中的 Angular 特性,因此,我将使用虚拟数据来实现一些基本功能,而不是从头到尾定义数据模型。在第八章中,我将使用这些数据创建面向用户的特性,然后返回到数据模型,将其连接到 RESTful web 服务。

创建模型类

每个数据模型都需要描述数据模型中包含的数据类型的类。对于 SportsStore 应用,这意味着描述商店中销售的产品和从客户那里收到的订单的类。

能够描述产品就足以开始使用 SportsStore 应用,我将创建其他模型类来支持我实现的特性。我在SportsStore/src/app/model文件夹中创建了一个名为product.model.ts的文件,并添加了清单 7-9 中所示的代码。

export class Product {

    constructor(
        public id?: number,
        public name?: string,
        public category?: string,
        public description?: string,
        public price?: number) { }
}

Listing 7-9.The Contents of the product.model.ts File in the src/app/model Folder

Product类定义了一个接受idnamecategorydescriptionprice属性的构造函数,这些属性对应于用来填充清单 7-3 中 RESTful web 服务的数据结构。参数名称后面的问号(?字符)表示这些是可选参数,在使用Product类创建新对象时可以省略,这在编写使用 HTML 表单填充模型对象属性的应用时很有用。

创建虚拟数据源

为了准备从虚拟数据到真实数据的转换,我将使用数据源提供应用数据。应用的其余部分不知道数据来自哪里,这将无缝地切换到使用 HTTP 请求获取数据。

我在SportsStore/src/app/model文件夹中添加了一个名为static.datasource.ts的文件,并定义了清单 7-10 中所示的类。

import { Injectable } from "@angular/core";
import { Product } from "./product.model";
import { Observable, from } from "rxjs";

@Injectable()
export class StaticDataSource {
    private products: Product[] = [
        new Product(1, "Product 1", "Category 1", "Product 1 (Category 1)", 100),
        new Product(2, "Product 2", "Category 1", "Product 2 (Category 1)", 100),
        new Product(3, "Product 3", "Category 1", "Product 3 (Category 1)", 100),
        new Product(4, "Product 4", "Category 1", "Product 4 (Category 1)", 100),
        new Product(5, "Product 5", "Category 1", "Product 5 (Category 1)", 100),
        new Product(6, "Product 6", "Category 2", "Product 6 (Category 2)", 100),
        new Product(7, "Product 7", "Category 2", "Product 7 (Category 2)", 100),
        new Product(8, "Product 8", "Category 2", "Product 8 (Category 2)", 100),
        new Product(9, "Product 9", "Category 2", "Product 9 (Category 2)", 100),
        new Product(10, "Product 10", "Category 2", "Product 10 (Category 2)", 100),
        new Product(11, "Product 11", "Category 3", "Product 11 (Category 3)", 100),
        new Product(12, "Product 12", "Category 3", "Product 12 (Category 3)", 100),
        new Product(13, "Product 13", "Category 3", "Product 13 (Category 3)", 100),
        new Product(14, "Product 14", "Category 3", "Product 14 (Category 3)", 100),
        new Product(15, "Product 15", "Category 3", "Product 15 (Category 3)", 100),
    ];

    getProducts(): Observable<Product[]> {
        return from([this.products]);
    }
}

Listing 7-10.The Contents of the static.datasource.ts File in the src/app/model Folder

StaticDataSource类定义了一个名为getProducts的方法,它返回虚拟数据。调用getProducts方法的结果是一个Observable<Product[]>,它是一个产生Product对象数组的Observable

Observable类由 Reactive Extensions 包提供,Angular 使用它来处理应用中的状态变化。我在第二十三章中描述了Observable类,但是对于这一章来说,知道一个Observable对象代表一个将在未来某个时刻产生结果的异步任务就足够了。Angular 公开了它对某些特性的Observable对象的使用,包括发出 HTTP 请求,这就是为什么getProducts方法返回一个Observable<Product[]>而不是简单地同步返回数据。

@Injectable装饰器已经应用于StaticDataSource类。这个装饰器用来告诉 Angular 这个类将被用作一个服务,它允许其他类通过一个叫做依赖注入的特性来访问它的功能,这个特性在第 19 和 20 章中有描述。随着应用的形成,您将看到服务是如何工作的。

Tip

注意,我必须从@angular/core JavaScript 模块导入Injectable,这样我就可以应用@Injectable装饰器。我不会强调我为 SportsStore 示例导入的所有不同的 Angular 类,但是您可以在描述它们相关特性的章节中获得完整的细节。

创建模型库

数据源负责向应用提供它所需要的数据,但是对这些数据的访问通常是通过一个来完成的,这个库负责将这些数据分发到各个应用构建模块,这样就可以隐藏如何获得数据的细节。我在SportsStore/src/app/model文件夹中添加了一个名为product.repository.ts的文件,并定义了清单 7-11 中所示的类。

import { Injectable } from "@angular/core";
import { Product } from "./product.model";
import { StaticDataSource } from "./static.datasource";

@Injectable()
export class ProductRepository {
    private products: Product[] = [];
    private categories: string[] = [];

    constructor(private dataSource: StaticDataSource) {
        dataSource.getProducts().subscribe(data => {
            this.products = data;
            this.categories = data.map(p => p.category)
                .filter((c, index, array) => array.indexOf(c) == index).sort();
        });
    }

    getProducts(category: string = null): Product[] {
        return this.products
            .filter(p => category == null || category == p.category);
    }

    getProduct(id: number): Product {
        return this.products.find(p => p.id == id);
    }

    getCategories(): string[] {
        return this.categories;
    }
}

Listing 7-11.The Contents of the product.repository.ts File in the src/app/model Folder

当 Angular 需要创建一个新的存储库实例时,它将检查这个类,并发现它需要一个StaticDataSource对象来调用ProductRepository构造函数并创建一个新对象。

存储库构造器调用数据源的getProducts方法,然后对返回的Observable对象使用subscribe方法来接收产品数据。关于Observable物体如何工作的细节,参见第二十三章。

创建特征模块

我将定义一个 Angular 特征模型,它将允许数据模型功能在应用中的其他地方轻松使用。我在SportsStore/src/app/model文件夹中添加了一个名为model.module.ts的文件,并定义了清单 7-12 中所示的类。

Tip

不要担心所有的文件名似乎相似和混乱。在阅读本书的其他章节时,您将会习惯 Angular 应用的结构方式,并且很快就能看到 Angular 项目中的文件,知道它们的用途。

import { NgModule } from "@angular/core";
import { ProductRepository } from "./product.repository";
import { StaticDataSource } from "./static.datasource";

@NgModule({
    providers: [ProductRepository, StaticDataSource]
})
export class ModelModule { }

Listing 7-12.The Contents of the model.module.ts File in the src/app/model Folder

@NgModule装饰器用于创建特性模块,它的属性告诉 Angular 应该如何使用模块。这个模块中只有一个属性providers,它告诉 Angular 哪些类应该被用作依赖注入特性的服务,这在第 19 和 20 章中有描述。特征模块和@NgModule装饰器在第二十一章中描述。

开店

既然数据模型已经就绪,我可以开始构建商店功能,这将让用户看到待售的产品并为它们下订单。商店的基本结构将是一个两列布局,带有允许过滤产品列表的类别按钮和一个包含产品列表的表格,如图 7-3 所示。

img/421542_4_En_7_Fig3_HTML.jpg

图 7-3。

商店的基本结构

在接下来的部分中,我将使用 Angular 特征和模型中的数据来创建图中所示的布局。

创建商店组件和模板

随着您对 Angular 的熟悉,您将会了解到,可以将各种功能组合起来,以不同的方式解决同一问题。我试图在 SportsStore 项目中引入一些变化,以展示一些重要的 Angular 特征,但为了能够快速启动项目,我暂时将事情保持简单。

考虑到这一点,store 功能的起点将是一个新的组件,它是一个向 HTML 模板提供数据和逻辑的类,该模板包含动态生成内容的数据绑定。我在SportsStore/src/app/store文件夹中创建了一个名为store.component.ts的文件,并定义了清单 7-13 中所示的类。

import { Component } from "@angular/core";
import { Product } from "../model/product.model";
import { ProductRepository } from "../model/product.repository";

@Component({
    selector: "store",
    templateUrl: "store.component.html"
})
export class StoreComponent {

    constructor(private repository: ProductRepository) { }

    get products(): Product[] {
        return this.repository.getProducts();
    }

    get categories(): string[] {
        return this.repository.getCategories();
    }
}

Listing 7-13.The Contents of the store.component.ts File in the src/app/store Folder

@Component装饰器已经应用于StoreComponent类,它告诉 Angular 它是一个组件。装饰器的属性告诉 Angular 如何将组件应用于 HTML 内容(使用一个叫做store的元素)以及如何找到组件的模板(在一个叫做store.component.html的文件中)。

StoreComponent类提供了支持模板内容的逻辑。构造函数接收一个ProductRepository对象作为参数,它是通过第 20 和 21 章中描述的依赖注入特性提供的。该组件定义了productscategories属性,这些属性将用于使用从存储库中获得的数据在模板中生成 HTML 内容。为了给组件提供模板,我在SportsStore/src/app/store文件夹中创建了一个名为store.component.html的文件,并添加了清单 7-14 中所示的 HTML 内容。

<div class="container-fluid">
  <div class="row">
    <div class="col bg-dark text-white">
      <a class="navbar-brand">SPORTS STORE</a>
    </div>
  </div>
  <div class="row text-white">
    <div class="col-3 bg-info p-2">
      {{categories.length}} Categories
    </div>
    <div class="col-9 bg-success p-2">
      {{products.length}} Products
    </div>
  </div>
</div>

Listing 7-14.The Contents of the store.component.html File in the src/app/store Folder

模板很简单,只是入门。大多数元素为商店布局提供了结构,并应用了一些引导 CSS 类。目前只有两个 Angular 数据绑定,分别用{{}}字符表示。这些是字符串插值绑定,它们告诉 Angular 计算绑定表达式并将结果插入到元素中。这些绑定中的表达式显示了商店组件提供的产品和类别的数量。

创建商店功能模块

目前还没有太多的商店功能,但即使如此,还需要做一些额外的工作来将其连接到应用的其余部分。为了创建商店功能的 Angular 特征模块,我在SportsStore/src/app/store文件夹中创建了一个名为store.module.ts的文件,并添加了清单 7-15 中所示的代码。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { FormsModule } from "@angular/forms";
import { ModelModule } from "../model/model.module";
import { StoreComponent } from "./store.component";

@NgModule({
    imports: [ModelModule, BrowserModule, FormsModule],
    declarations: [StoreComponent],
    exports: [StoreComponent]
})
export class StoreModule { }

Listing 7-15.The Contents of the store.module.ts File in the src/app/store Folder

@NgModule装饰器配置模块,使用imports属性告诉 Angular 存储模块依赖于模型模块以及BrowserModuleFormsModule,它们包含 web 应用和 HTML 表单元素的标准 Angular 特性。装饰器使用declarations属性告诉 Angular 关于StoreComponent类的信息,而exports属性告诉 Angular 该类也可以用于应用的其他部分,这很重要,因为它将被根模块使用。

更新根组件和根模块

应用基本模型和商店功能需要更新应用的根模块以导入两个特征模块,还需要更新根模块的模板以添加商店模块中的组件将应用到的 HTML 元素。清单 7-16 显示了对根组件模板的更改。

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

@Component({
    selector: "app",
    template: "<store></store>"
})
export class AppComponent { }

Listing 7-16.Adding an Element in the app.component.ts File in the src/app Folder

store元素替换了根组件模板中先前的内容,并对应于清单 7-13 中@Component装饰器的selector属性值。清单 7-17 显示了根模块所需的更改,以便对包含商店功能的特征模块进行 Angular 加载。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { AppComponent } from "./app.component";
import { StoreModule } from "./store/store.module";

@NgModule({
    imports: [BrowserModule, StoreModule],
    declarations: [AppComponent],
    bootstrap: [AppComponent]
})
export class AppModule { }

Listing 7-17.Importing Feature Modules in the app.module.ts File in the src/app Folder

当您将更改保存到根模块时,Angular 将拥有加载应用和显示来自 store 模块的内容所需的所有细节,如图 7-4 所示。

如果您没有看到预期的结果,那么停止 Angular 开发工具,并使用ng serve命令再次启动它们。这将重复项目的构建过程,并且应该反映您所做的更改。

上一节中创建的所有构件一起工作来显示内容——诚然很简单——显示有多少产品以及它们属于多少类别。

img/421542_4_En_7_Fig4_HTML.jpg

图 7-4。

SportsStore 应用的基本功能

添加商店特色产品详情

Angular 开发的本质是从一个缓慢的开始开始,因为项目的基础已经就位,基本的构建块已经创建。但是一旦这样做了,就可以相对容易地创建新的功能。在接下来的部分中,我将向商店添加一些功能,以便用户可以看到提供的产品。

显示产品详情

显而易见的起点是展示产品的细节,这样顾客就可以看到有什么优惠。清单 7-18 通过数据绑定将 HTML 元素添加到商店组件的模板中,为组件提供的每个产品生成内容。

<div class="container-fluid">
  <div class="row">
    <div class="col bg-dark text-white">
      <a class="navbar-brand">SPORTS STORE</a>
    </div>
  </div>
  <div class="row">
    <div class="col-3 bg-info p-2 text-white">
      {{categories.length}} Categories
    </div>
    <div class="col-9 p-2">
      <div *ngFor="let product of products" class="card m-1 p-1 bg-light">
        <h4>
          {{product.name}}
          <span class="badge badge-pill badge-primary float-right">
            {{ product.price | currency:"USD":"symbol":"2.2-2" }}
          </span>
        </h4>
        <div class="card-text bg-white p-1">{{product.description}}</div>
      </div>
    </div>
</div>

Listing 7-18.Adding Elements in the store.component.html File in the src/app/store Folder

大多数元素控制内容的布局和外观。最重要的变化是添加了 Angular 数据绑定表达式。

...
<div *ngFor="let product of products" class="card m-1 p-1 bg-light">
...

这是一个指令的例子,它转换应用它的 HTML 元素。这个特定的指令叫做ngFor,它通过为组件的products属性返回的每个对象复制div元素来转换它。Angular 包括一系列内置指令,执行最常见的所需任务,如第十三章所述。

因为它复制了div元素,所以当前对象被赋给一个名为product的变量,这允许它在其他数据绑定中被引用,比如这个,它将当前产品的name描述属性的值作为div元素的内容插入:

...
<div class="card-text p-1 bg-white">{{product.description}}</div>
...

并非应用数据模型中的所有数据都可以直接显示给用户。Angular 包含一个名为 pipes 的特性,这些类用于转换或准备数据值,以便在数据绑定中使用。Angular 包含了几个内置管道,包括currency管道,它将数字值格式化为货币,如下所示:

...
{{ product.price | currency:"USD":"symbol":"2.2-2" }}
...

应用管道的语法可能有点笨拙,但是这个绑定中的表达式告诉 Angular 使用currency管道格式化当前产品的price属性,使用美国的货币惯例。保存对模板的修改,您将看到数据模型中的产品列表显示为一个长列表,如图 7-5 所示。

img/421542_4_En_7_Fig5_HTML.jpg

图 7-5。

显示产品信息

添加类别选择

添加对按类别过滤产品列表的支持需要准备 store 组件,以便它跟踪用户想要显示的类别,并需要更改检索数据的方式以使用该类别,如清单 7-19 所示。

import { Component } from "@angular/core";
import { Product } from "../model/product.model";
import { ProductRepository } from "../model/product.repository";

@Component({
    selector: "store",
    templateUrl: "store.component.html"
})
export class StoreComponent {
    public selectedCategory = null;

    constructor(private repository: ProductRepository) {}

    get products(): Product[] {
        return this.repository.getProducts(this.selectedCategory);
    }

    get categories(): string[] {
        return this.repository.getCategories();
    }

    changeCategory(newCategory?: string) {
        this.selectedCategory = newCategory;
    }
}

Listing 7-19.Adding Category Filtering in the store.component.ts File in the src/app/store Folder

这些变化很简单,因为它们建立在本章开始时花了很长时间创建的基础上。selectedCategory属性被分配给用户选择的类别(其中null表示所有类别),并在updateData方法中用作getProducts方法的参数,将过滤委托给数据源。changeCategory方法将这两个成员放在一个方法中,当用户选择类别时可以调用这个方法。

清单 7-20 显示了对组件模板的相应更改,为用户提供了一组按钮,用于更改所选的类别,并显示选择了哪个类别。

<div class="container-fluid">
  <div class="row">
    <div class="col bg-dark text-white">
      <a class="navbar-brand">SPORTS STORE</a>
    </div>
  </div>
  <div class="row">
    <div class="col-3 p-2">
      <button class="btn btn-block btn-outline-primary" (click)="changeCategory()">
        Home
      </button>
      <button *ngFor="let cat of categories"
              class="btn btn-outline-primary btn-block"
              [class.active]="cat == selectedCategory" (click)="changeCategory(cat)">
        {{cat}}
      </button>
    </div>
    <div class="col-9 p-2">
      <div *ngFor="let product of products" class="card m-1 p-1 bg-light">
        <h4>
          {{product.name}}
          <span class="badge badge-pill badge-primary float-right">
            {{ product.price | currency:"USD":"symbol":"2.2-2" }}
          </span>
        </h4>
        <div class="card-text bg-white p-1">{{product.description}}</div>
      </div>
    </div>
  </div>
</div>

Listing 7-20.Adding Category Buttons in the store.component.html File in the src/app/store Folder

模板中有两个新的button元素。第一个是 Home 按钮,它有一个事件绑定,当按钮被单击时调用组件的changeCategory方法。该方法没有提供任何参数,其效果是将类别设置为null,并选择所有产品。

ngFor绑定已经应用于另一个button元素,其中一个表达式将为组件的categories属性返回的数组中的每个值重复该元素。button有一个click事件绑定,其表达式调用changeCategory方法选择当前类别,该类别将过滤显示给用户的产品。还有一个class绑定,当与按钮关联的类别是选中的类别时,它将button元素添加到active类中。当类别被过滤时,这为用户提供了视觉反馈,如图 7-6 所示。

img/421542_4_En_7_Fig6_HTML.jpg

图 7-6。

选择产品类别

添加产品分页

按类别过滤产品有助于使产品列表更易于管理,但更典型的方法是将列表分成更小的部分,并将每个部分显示为一个页面,并带有在页面之间移动的导航按钮。清单 7-21 增强了商店组件,这样它就可以跟踪当前页面和页面上的商品数量。

import { Component } from "@angular/core";
import { Product } from "../model/product.model";
import { ProductRepository } from "../model/product.repository";

@Component({
    selector: "store",
    templateUrl: "store.component.html"
})
export class StoreComponent {
    public selectedCategory = null;
    public productsPerPage = 4;
    public selectedPage = 1;

    constructor(private repository: ProductRepository) {}

    get products(): Product[] {
        let pageIndex = (this.selectedPage - 1) * this.productsPerPage
        return this.repository.getProducts(this.selectedCategory)
            .slice(pageIndex, pageIndex + this.productsPerPage);
    }

    get categories(): string[] {
        return this.repository.getCategories();
    }

    changeCategory(newCategory?: string) {
        this.selectedCategory = newCategory;
    }

    changePage(newPage: number) {
        this.selectedPage = newPage;
    }

    changePageSize(newSize: number) {
        this.productsPerPage = Number(newSize);
        this.changePage(1);
    }

    get pageNumbers(): number[] {
        return Array(Math.ceil(this.repository
            .getProducts(this.selectedCategory).length / this.productsPerPage))
                .fill(0).map((x, i) => i + 1);
    }
}

Listing 7-21.Adding Pagination Support in the store.component.ts File in the src/app/store Folder

这个清单中有两个新特性。第一个是获取产品页面的能力,第二个是改变页面的大小,允许改变每个页面包含的产品数量。

奇怪的是,该组件必须解决这个问题。Angular 提供的内置ngFor指令有一个限制,它只能为数组或集合中的对象生成内容,而不能使用计数器。由于我需要生成带编号的页面导航按钮,这意味着我需要创建一个包含我需要的数字的数组,如下所示:

...
return Array(Math.ceil(this.repository.getProducts(this.selectedCategory).length
    / this.productsPerPage)).fill(0).map((x, i) => i + 1);
...

该语句创建一个新数组,用值0填充它,然后使用map方法生成一个带有数字序列的新数组。这足以很好地实现分页特性,但感觉有些笨拙,我将在下一节演示一种更好的方法。清单 7-22 展示了对商店组件模板的更改,以实现分页特性。

<div class="container-fluid">
  <div class="row">
    <div class="col bg-dark text-white">
      <a class="navbar-brand">SPORTS STORE</a>
    </div>
  </div>
  <div class="row">
    <div class="col-3 p-2">
      <button class="btn btn-block btn-outline-primary" (click)="changeCategory()">
        Home
      </button>
      <button *ngFor="let cat of categories"
              class="btn btn-outline-primary btn-block"
              [class.active]="cat == selectedCategory" (click)="changeCategory(cat)">
        {{cat}}
      </button>
    </div>
    <div class="col-9 p-2">
      <div *ngFor="let product of products" class="card m-1 p-1 bg-light">
        <h4>
          {{product.name}}
          <span class="badge badge-pill badge-primary float-right">
            {{ product.price | currency:"USD":"symbol":"2.2-2" }}
          </span>
        </h4>
        <div class="card-text bg-white p-1">{{product.description}}</div>
      </div>

      <div class="form-inline float-left mr-1">
        <select class="form-control" [value]="productsPerPage"
                (change)="changePageSize($event.target.value)">
          <option value="3">3 per Page</option>
          <option value="4">4 per Page</option>
          <option value="6">6 per Page</option>
          <option value="8">8 per Page</option>
        </select>
      </div>

      <div class="btn-group float-right">
        <button *ngFor="let page of pageNumbers" (click)="changePage(page)"
                class="btn btn-outline-primary"
                [class.active]="page == selectedPage">
          {{page}}
        </button>
      </div>
    </div>
  </div>
</div>

Listing 7-22.Adding Pagination in the store.component.html File in the src/app/store Folder

新元素添加了一个select元素,允许改变页面的大小,以及一组在产品页面中导航的按钮。新元素具有数据绑定,将它们绑定到组件提供的属性和方法。结果是一组更易于管理的产品,如图 7-7 所示。

Tip

清单 7-22 中的select元素由静态定义的option元素填充,而不是使用来自组件的数据创建的。这样做的一个影响是,当选择的值被传递给changePageSize方法时,它将是一个string值,这就是为什么在用于设置清单 7-21 中的页面大小之前,参数被解析为一个number。从 HTML 元素接收数据值时必须小心,以确保它们是预期的类型。在这种情况下,TypeScript 类型批注没有帮助,因为数据绑定表达式是在运行时计算的,而此时 TypeScript 编译器已经生成了不包含额外类型信息的 JavaScript 代码。

img/421542_4_En_7_Fig7_HTML.jpg

图 7-7。

产品分页

创建自定义指令

在本节中,我将创建一个自定义指令,这样我就不必生成一个充满数字的数组来创建页面导航按钮。Angular 提供了大量的内置指令,但是创建您自己的指令来解决特定于您的应用的问题或者支持内置指令没有的特性是一个简单的过程。我在src/app/store文件夹中添加了一个名为counter.directive.ts的文件,并用它来定义清单 7-23 中所示的类。

import {
    Directive, ViewContainerRef, TemplateRef, Input, Attribute, SimpleChanges
} from "@angular/core";

@Directive({
    selector: "[counterOf]"
})
export class CounterDirective {

    constructor(private container: ViewContainerRef,
        private template: TemplateRef<Object>) {
    }

    @Input("counterOf")
    counter: number;

    ngOnChanges(changes: SimpleChanges) {
        this.container.clear();
        for (let i = 0; i < this.counter; i++) {
            this.container.createEmbeddedView(this.template,
                new CounterDirectiveContext(i + 1));
        }
    }
}

class CounterDirectiveContext {
    constructor(public $implicit: any) { }
}

Listing 7-23.The Contents of the counter.directive.ts File in the src/app/store Folder

这是一个结构指令的例子,在第十六章中有详细描述。这个指令通过一个counter属性应用于元素,并依赖 Angular 提供的特殊特性来重复创建内容,就像内置的ngFor指令一样。在这种情况下,自定义指令不会生成集合中的每个对象,而是生成一系列可用于创建页面导航按钮的数字。

Tip

该指令删除它创建的所有内容,并在页数改变时重新开始。在更复杂的指令中,这可能是一个昂贵的过程,我会在第十六章解释如何提高性能。

要使用该指令,必须将其添加到其特性模块的declarations属性中,如清单 7-24 所示。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { FormsModule } from "@angular/forms";
import { ModelModule } from "../model/model.module";
import { StoreComponent } from "./store.component";
import { CounterDirective } from "./counter.directive";

@NgModule({
    imports: [ModelModule, BrowserModule, FormsModule],
    declarations: [StoreComponent, CounterDirective],
    exports: [StoreComponent]
})
export class StoreModule { }

Listing 7-24.Registering the Custom Directive in the store.module.ts File in the src/app/store Folder

现在该指令已经注册,可以在商店组件的模板中使用它来替换ngFor指令,如清单 7-25 所示。

<div class="container-fluid">
  <div class="row">
    <div class="col bg-dark text-white">
      <a class="navbar-brand">SPORTS STORE</a>
    </div>
  </div>
  <div class="row">

    <div class="col-3 p-2">
      <button class="btn btn-block btn-outline-primary" (click)="changeCategory()">
        Home
      </button>
      <button *ngFor="let cat of categories"
           class="btn btn-outline-primary btn-block"
           [class.active]="cat == selectedCategory" (click)="changeCategory(cat)">
        {{cat}}
      </button>
    </div>

    <div class="col-9 p-2">
      <div *ngFor="let product of products" class="card m-1 p-1 bg-light">
        <h4>
          {{product.name}}
          <span class="badge badge-pill badge-primary float-right">
            {{ product.price | currency:"USD":"symbol":"2.2-2" }}
          </span>
        </h4>
        <div class="card-text bg-white p-1">{{product.description}}</div>
      </div>

      <div class="form-inline float-left mr-1">
        <select class="form-control" [value]="productsPerPage"
                (change)="changePageSize($event.target.value)">
          <option value="3">3 per Page</option>
          <option value="4">4 per Page</option>
          <option value="6">6 per Page</option>
          <option value="8">8 per Page</option>
        </select>
      </div>

      <div class="btn-group float-right">
        <button *counter="let page of pageCount" (click)="changePage(page)"
            class="btn btn-outline-primary" [class.active]="page == selectedPage">
          {{page}}
        </button>
      </div>

    </div>
  </div>
</div>

Listing 7-25.Replacing the Built-in Directive in the store.component.html File in the src/app/store Folder

新的数据绑定依赖于一个名为pageCount的属性来配置自定义指令。在清单 7-26 中,我用一个提供表达式值的简单的number代替了数字数组。

import { Component } from "@angular/core";
import { Product } from "../model/product.model";
import { ProductRepository } from "../model/product.repository";

@Component({
    selector: "store",
    templateUrl: "store.component.html"
})
export class StoreComponent {
    public selectedCategory = null;
    public productsPerPage = 4;
    public selectedPage = 1;

    constructor(private repository: ProductRepository) {}

    get products(): Product[] {
        let pageIndex = (this.selectedPage - 1) * this.productsPerPage
        return this.repository.getProducts(this.selectedCategory)
            .slice(pageIndex, pageIndex + this.productsPerPage);
    }

    get categories(): string[] {
        return this.repository.getCategories();
    }

    changeCategory(newCategory?: string) {
        this.selectedCategory = newCategory;
    }

    changePage(newPage: number) {
        this.selectedPage = newPage;
    }

    changePageSize(newSize: number) {
        this.productsPerPage = Number(newSize);
        this.changePage(1);
    }

    get pageCount(): number {
        return Math.ceil(this.repository
            .getProducts(this.selectedCategory).length / this.productsPerPage)
    }

    //get pageNumbers(): number[] {
    //    return Array(Math.ceil(this.repository
    //        .getProducts(this.selectedCategory).length / this.productsPerPage))
    //            .fill(0).map((x, i) => i + 1);
    //}
}

Listing 7-26.Supporting the Custom Directive in the store.component.ts File in the src/app/store Folder

SportsStore 应用在视觉上没有变化,但本节已经演示了可以使用定制代码来补充内置的 Angular 功能,这些代码是根据特定项目的需求定制的。

摘要

在这一章中,我启动了 SportsStore 项目。本章的前一部分花在创建项目的基础上,包括创建应用的根构建块和开始特性模块的工作。一旦有了基础,我就能够快速地添加向用户显示虚拟模型数据的特性,添加分页,并按类别过滤产品。在本章的最后,我创建了一个自定义指令来演示如何通过自定义代码来补充 Angular 提供的内置特性。在下一章,我将继续构建 SportsStore 应用。

八、SportsStore:订单和结账

在本章中,我继续向我在第七章中创建的 SportsStore 应用添加特性。我添加了对购物车和结账流程的支持,并用来自 RESTful web 服务的数据替换了虚拟数据。

准备示例应用

本章不需要准备,继续使用第七章中的 SportsStore 项目。要启动 RESTful web 服务,请打开命令提示符并在SportsStore文件夹中运行以下命令:

npm run json

打开第二个命令提示符,在SportsStore文件夹中运行以下命令,启动开发工具和 HTTP 服务器:

ng serve --open

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

创建购物车

用户需要一个购物车,产品可以放入其中,并用于开始结帐过程。在接下来的小节中,我将向应用添加一个购物车,并将其集成到商店中,以便用户可以选择他们想要的产品。

创建购物车模型

购物车功能的起点是一个新的模型类,它将用于收集用户选择的产品。我在src/app/model文件夹中添加了一个名为cart.model.ts的文件,并用它来定义清单 8-1 中所示的类。

import { Injectable } from "@angular/core";
import { Product } from "./product.model";

@Injectable()
export class Cart {
    public lines: CartLine[] = [];
    public itemCount: number = 0;
    public cartPrice: number = 0;

    addLine(product: Product, quantity: number = 1) {
        let line = this.lines.find(line => line.product.id == product.id);
        if (line != undefined) {
            line.quantity += quantity;
        } else {
            this.lines.push(new CartLine(product, quantity));
        }
        this.recalculate();
    }

    updateQuantity(product: Product, quantity: number) {
        let line = this.lines.find(line => line.product.id == product.id);
        if (line != undefined) {
            line.quantity = Number(quantity);
        }
        this.recalculate();
    }

    removeLine(id: number) {
        let index = this.lines.findIndex(line => line.product.id == id);
        this.lines.splice(index, 1);
        this.recalculate();
    }

    clear() {
        this.lines = [];
        this.itemCount = 0;
        this.cartPrice = 0;
    }

    private recalculate() {
        this.itemCount = 0;
        this.cartPrice = 0;
        this.lines.forEach(l => {
            this.itemCount += l.quantity;
            this.cartPrice += (l.quantity * l.product.price);
        })
    }
}

export class CartLine {

    constructor(public product: Product,
        public quantity: number) {}

    get lineTotal() {
        return this.quantity * this.product.price;
    }
}

Listing 8-1.The Contents of the cart.model.ts File in the src/app/model Folder

单个产品选择被表示为一组CartLine对象,每个对象包含一个Product对象和一个数量。Cart类跟踪已经被选中的商品的总数和它们的总价格。

在整个应用中应该使用一个单独的Cart对象,确保应用的任何部分都可以访问用户的产品选择。为了实现这一点,我将使Cart成为一个服务,这意味着 Angular 将负责创建一个Cart类的实例,并在需要创建一个具有Cart构造函数参数的组件时使用它。这是 Angular 依赖注入特性的另一个用途,可用于在整个应用中共享对象,这将在第 19 和 20 章中详细描述。已经应用于清单中的Cart类的@Injectable装饰器表明这个类将被用作服务。

Note

严格地说,只有当一个类有自己的构造函数参数需要解析时,才需要使用@Injectable装饰器,但是无论如何应用它都是一个好主意,因为它提供了一个信号,表明这个类打算用作服务。

清单 8-2 将Cart类注册为模型特征模块类的providers属性中的服务。

import { NgModule } from "@angular/core";
import { ProductRepository } from "./product.repository";
import { StaticDataSource } from "./static.datasource";
import { Cart } from "./cart.model";

@NgModule({
    providers: [ProductRepository, StaticDataSource, Cart]
})
export class ModelModule { }

Listing 8-2.Registering the Cart as a Service in the model.module.ts File in the src/app/model Folder

创建购物车摘要组件

组件是 angle 应用的基本构建块,因为它们允许轻松创建代码和内容的离散单元。SportsStore 应用将在页面的标题区域向用户显示他们的产品选择摘要,我将通过创建一个组件来实现这一点。我在src/app/store文件夹中添加了一个名为cartSummary.component.ts的文件,并用它来定义清单 8-3 中所示的组件。

import { Component } from "@angular/core";
import { Cart } from "../model/cart.model";

@Component({
    selector: "cart-summary",
    templateUrl: "cartSummary.component.html"
})
export class CartSummaryComponent {

    constructor(public cart: Cart) { }
}

Listing 8-3.The Contents of the cartSummary.component.ts File in the src/app/store Folder

当 Angular 需要创建这个组件的实例时,它必须提供一个Cart对象作为构造函数参数,使用我在上一节中配置的服务,将Cart类添加到特性模块的providers属性中。服务的默认行为意味着一个单独的Cart对象将被创建并在整个应用中共享,尽管有不同的服务行为可用(如第二十章所述)。

为了给组件提供模板,我在组件类文件所在的文件夹中创建了一个名为cartSummary.component.html的 HTML 文件,并添加了清单 8-4 中所示的标记。

<div class="float-right">
  <small>
    Your cart:
    <span *ngIf="cart.itemCount > 0">
      {{ cart.itemCount }} item(s)
      {{ cart.cartPrice | currency:"USD":"symbol":"2.2-2" }}
    </span>
    <span *ngIf="cart.itemCount == 0">
      (empty)
    </span>
  </small>
  <button class="btn btn-sm bg-dark text-white"
      [disabled]="cart.itemCount == 0">
    <i class="fa fa-shopping-cart"></i>
  </button>
</div>

Listing 8-4.The Contents of the cartSummary.component.html File in the src/app/store Folder

该模板使用其组件提供的Cart对象来显示购物车中的商品数量和总费用。还有一个按钮,当我在本章后面将它添加到应用时,它将启动结帐过程。

Tip

清单 8-4 中的按钮元素是使用字体 Awesome 定义的类来设计的,字体 Awesome 是第七章的package.json文件中的一个包。这个开源包为 web 应用中的图标提供了出色的支持,包括我在 SportsStore 应用中需要的购物车。详见 http://fontawesome.io

清单 8-5 向 store 特性模块注册新组件,为下一节使用它做准备。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { FormsModule } from "@angular/forms";
import { ModelModule } from "../model/model.module";
import { StoreComponent } from "./store.component";
import { CounterDirective } from "./counter.directive";
import { CartSummaryComponent } from "./cartSummary.component";

@NgModule({
    imports: [ModelModule, BrowserModule, FormsModule],
    declarations: [StoreComponent, CounterDirective, CartSummaryComponent],
    exports: [StoreComponent]
})
export class StoreModule { }

Listing 8-5.Registering the Component in the store.module.ts File in the src/app/store Folder

将购物车整合到商店中

商店组件是将购物车和购物车小部件集成到应用中的关键。清单 8-6 更新了商店组件,这样它的构造函数就有了一个Cart参数,并定义了一个将产品添加到购物车的方法。

import { Component } from "@angular/core";
import { Product } from "../model/product.model";
import { ProductRepository } from "../model/product.repository";
import { Cart } from "../model/cart.model";

@Component({
    selector: "store",
    templateUrl: "store.component.html"
})
export class StoreComponent {
    public selectedCategory = null;
    public productsPerPage = 4;
    public selectedPage = 1;

    constructor(private repository: ProductRepository,
                private cart: Cart) { }

    get products(): Product[] {
        let pageIndex = (this.selectedPage - 1) * this.productsPerPage
        return this.repository.getProducts(this.selectedCategory)
            .slice(pageIndex, pageIndex + this.productsPerPage);
    }

    get categories(): string[] {
        return this.repository.getCategories();
    }

    changeCategory(newCategory?: string) {
        this.selectedCategory = newCategory;
    }

    changePage(newPage: number) {
        this.selectedPage = newPage;
    }

    changePageSize(newSize: number) {
        this.productsPerPage = Number(newSize);
        this.changePage(1);
    }

    get pageCount(): number {
        return Math.ceil(this.repository
            .getProducts(this.selectedCategory).length / this.productsPerPage)
    }

    addProductToCart(product: Product) {
        this.cart.addLine(product);
    }
}

Listing 8-6.Adding Cart Support in the store.component.ts File in the src/app/store Folder

为了完成购物车到商店组件的集成,清单 8-7 添加了将购物车汇总组件应用到商店组件的模板的元素,并为每个产品描述添加了一个按钮,该按钮带有调用addProductToCart方法的事件绑定。

<div class="container-fluid">
  <div class="row">
    <div class="col bg-dark text-white">
      <a class="navbar-brand">SPORTS STORE</a>
      <cart-summary></cart-summary>
    </div>
  </div>
  <div class="row">

    <div class="col-3 p-2">
      <button class="btn btn-block btn-outline-primary" (click)="changeCategory()">
        Home
      </button>
      <button *ngFor="let cat of categories"
          class="btn btn-outline-primary btn-block"
          [class.active]="cat == selectedCategory" (click)="changeCategory(cat)">
        {{cat}}
      </button>
    </div>

    <div class="col-9 p-2">
      <div *ngFor="let product of products" class="card m-1 p-1 bg-light">
        <h4>
          {{product.name}}
          <span class="badge badge-pill badge-primary float-right">
            {{ product.price | currency:"USD":"symbol":"2.2-2" }}
          </span>
        </h4>
        <div class="card-text bg-white p-1">
          {{product.description}}
          <button class="btn btn-success btn-sm float-right"
                  (click)="addProductToCart(product)">
            Add To Cart
          </button>
        </div>
      </div>

      <div class="form-inline float-left mr-1">
        <select class="form-control" [value]="productsPerPage"
                (change)="changePageSize($event.target.value)">
          <option value="3">3 per Page</option>
          <option value="4">4 per Page</option>
          <option value="6">6 per Page</option>
          <option value="8">8 per Page</option>
        </select>
      </div>

      <div class="btn-group float-right">
        <button *counter="let page of pageCount" (click)="changePage(page)"
            class="btn btn-outline-primary" [class.active]="page == selectedPage">
          {{page}}
        </button>
      </div>

    </div>
  </div>
</div>

Listing 8-7.Applying the Component in the store.component.html File in the src/app/store Folder

结果是为每个产品添加一个按钮,如图 8-1 所示。整个购物车过程尚未完成,但是您可以在页面顶部的购物车摘要中看到每次添加的效果。

img/421542_4_En_8_Fig1_HTML.jpg

图 8-1。

向 SportsStore 应用添加购物车支持

请注意,单击添加到购物车按钮之一会自动更新摘要组件的内容。发生这种情况是因为两个组件共享一个Cart对象,并且当 Angular 评估另一个组件中的数据绑定表达式时,一个组件所做的更改会被反映出来。

添加 URL 路由

大多数应用需要在不同的时间向用户显示不同的内容。在 SportsStore 应用中,当用户单击 Add To Cart 按钮时,他们应该看到所选产品的详细视图,并有机会开始结帐过程。

Angular 支持一个名为 URL 路由的特性,它使用浏览器显示的当前 URL 来选择显示给用户的组件。这种方法使得创建组件松散耦合的应用变得容易,并且不需要在应用的其他地方进行相应的修改就可以容易地进行更改。URL 路由也使得改变用户通过应用的路径变得容易。

对于 SportsStore 应用,我将添加对三个不同 URL 的支持,如表 8-1 中所述。这是一个简单的配置,但是路由系统有很多特性,在章节 25 到 27 中有详细描述。

表 8-1。

SportsStore 应用支持的 URL

|

统一资源定位器

|

描述

|
| --- | --- |
| /store | 该 URL 将显示产品列表。 |
| /cart | 这个 URL 将详细显示用户的购物车。 |
| /checkout | 此 URL 将显示结帐过程。 |

在接下来的小节中,我将为 SportsStore 购物车和订单结帐阶段创建占位符组件,然后使用 URL 路由将它们集成到应用中。一旦实现了 URL,我将返回组件并添加更多有用的特性。

创建购物车详细信息和结帐组件

在将 URL 路由添加到应用之前,我需要创建将由/cart/checkoutURL 显示的组件。我只需要一些基本的占位符内容就可以开始了,只是为了清楚地显示哪个组件。我首先在src/app/store文件夹中添加一个名为cartDetail.component.ts的文件,并定义清单 8-8 中所示的组件。

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

@Component({
    template: `<div><h3 class="bg-info p-1 text-white">Cart Detail Component</h3></div>`
})
export class CartDetailComponent {}

Listing 8-8.The Contents of the cartDetail.component.ts File in the src/app/store Folder

接下来,我在src/app/store文件夹中添加了一个名为checkout.component.ts的文件,并定义了清单 8-9 中所示的组件。

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

@Component({
    template: `<div><h3 class="bg-info p-1 text-white">Checkout Component</h3></div>`
})
export class CheckoutComponent { }

Listing 8-9.The Contents of the checkout.component.ts File in the src/app/store Folder

该组件遵循与购物车组件相同的模式,并显示一条占位符消息。清单 8-10 在商店功能模块中注册组件,并将它们添加到exports属性中,这意味着它们可以在应用的其他地方使用。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { FormsModule } from "@angular/forms";
import { ModelModule } from "../model/model.module";
import { StoreComponent } from "./store.component";
import { CounterDirective } from "./counter.directive";
import { CartSummaryComponent } from "./cartSummary.component";
import { CartDetailComponent } from "./cartDetail.component";
import { CheckoutComponent } from "./checkout.component";

@NgModule({
    imports: [ModelModule, BrowserModule, FormsModule],
    declarations: [StoreComponent, CounterDirective, CartSummaryComponent,
        CartDetailComponent, CheckoutComponent],
    exports: [StoreComponent, CartDetailComponent, CheckoutComponent]
})
export class StoreModule { }

Listing 8-10.Registering Components in the store.module.ts File in the src/app/store Folder

创建和应用路由配置

现在我有了一系列要显示的组件,下一步是创建路由配置,告诉 Angular 如何将 URL 映射到组件中。一个 URL 到一个组件的每个映射被称为一个 URL 路由或者仅仅是一个路由。在第三部分中,我创建了更复杂的路由配置,我在一个单独的文件中定义路由,但是对于这个项目,我将遵循一个更简单的方法,在应用根模块的@NgModule装饰器中定义路由,如清单 8-11 所示。

Tip

Angular 路由特性要求 HTML 文档中有一个base元素,它提供了应用路由所依据的基本 URL。当我在第七章中创建 SportsStore 项目时,这个元素被ng new命令添加到了index.html文件中。如果忽略该元素,Angular 将报告错误,并且无法应用管线。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { AppComponent } from "./app.component";
import { StoreModule } from "./store/store.module";
import { StoreComponent } from "./store/store.component";
import { CheckoutComponent } from "./store/checkout.component";
import { CartDetailComponent } from "./store/cartDetail.component";
import { RouterModule } from "@angular/router";

@NgModule({
    imports: [BrowserModule, StoreModule,
        RouterModule.forRoot([
            { path: "store", component: StoreComponent },
            { path: "cart", component: CartDetailComponent },
            { path: "checkout", component: CheckoutComponent },
            { path: "**", redirectTo: "/store" }
        ])],
    declarations: [AppComponent],
    bootstrap: [AppComponent]
})
export class AppModule { }

Listing 8-11.Creating the Routing Configuration in the app.module.ts File in the src/app Folder

RouterModule.forRoot方法传递一组路由,每个路由将一个 URL 映射到一个组件。列表中的前三个路由匹配表 8-1 中的 URL。最后一个路由是一个通配符,它将任何其他 URL 重定向到/store,这将显示StoreComponent

当使用路由特性时,Angular 查找router-outlet元素,该元素定义了对应于当前 URL 的组件应该显示的位置。清单 8-12 用router-outlet元素替换根组件模板中的store元素。

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

@Component({
    selector: "app",
    template: "<router-outlet></router-outlet>"
})
export class AppComponent { }

Listing 8-12.Defining the Routing Target in the app.component.ts File in the src/app Folder

当您保存更改并且浏览器重新加载 HTML 文档时,Angular 将应用路由配置。浏览器窗口中显示的内容没有改变,但是如果您检查浏览器的 URL 栏,您将能够看到路由配置已经被应用,如图 8-2 所示。

img/421542_4_En_8_Fig2_HTML.jpg

图 8-2。

URL 路由的影响

在应用中导航

路由配置就绪后,就可以通过更改浏览器的 URL 来添加对组件间导航的支持了。URL 路由功能依赖于浏览器提供的 JavaScript API,这意味着用户不能简单地在浏览器的 URL 栏中键入目标 URL。相反,导航必须由应用来执行,要么在组件或其他构建块中使用 JavaScript 代码,要么在模板中向 HTML 元素添加属性。

当用户单击添加到购物车按钮之一时,应该显示购物车细节组件,这意味着应用应该导航到/cart URL。清单 8-13 向组件方法添加导航,当用户点击按钮时,组件方法被调用。

import { Component } from "@angular/core";
import { Product } from "../model/product.model";
import { ProductRepository } from "../model/product.repository";
import { Cart } from "../model/cart.model";
import { Router } from "@angular/router";

@Component({
    selector: "store",
    templateUrl: "store.component.html"
})
export class StoreComponent {
    public selectedCategory = null;
    public productsPerPage = 4;
    public selectedPage = 1;

    constructor(private repository: ProductRepository,
        private cart: Cart,
        private router: Router) { }

    get products(): Product[] {
        let pageIndex = (this.selectedPage - 1) * this.productsPerPage
        return this.repository.getProducts(this.selectedCategory)
            .slice(pageIndex, pageIndex + this.productsPerPage);
    }

    get categories(): string[] {
        return this.repository.getCategories();
    }

    changeCategory(newCategory?: string) {
        this.selectedCategory = newCategory;
    }

    changePage(newPage: number) {
        this.selectedPage = newPage;
    }

    changePageSize(newSize: number) {
        this.productsPerPage = Number(newSize);
        this.changePage(1);
    }

    get pageCount(): number {
        return Math.ceil(this.repository
            .getProducts(this.selectedCategory).length / this.productsPerPage)
    }

    addProductToCart(product: Product) {
        this.cart.addLine(product);
        this.router.navigateByUrl("/cart");
    }
}

Listing 8-13.Navigating Using JavaScript in the store.component.ts File in the app/src/store Folder

构造函数有一个Router参数,它是 Angular 在创建组件的新实例时通过依赖注入特性提供的。在addProductToCart方法中,Router.navigateByUrl方法用于导航到/cart URL。

还可以通过向模板中的元素添加routerLink属性来完成导航。在清单 8-14 中,routerLink属性已经应用于购物车汇总组件模板中的购物车按钮。

<div class="float-right">
  <small>
    Your cart:
    <span *ngIf="cart.itemCount > 0">
      {{ cart.itemCount }} item(s)
      {{ cart.cartPrice | currency:"USD":"symbol":"2.2-2" }}
    </span>
    <span *ngIf="cart.itemCount == 0">
      (empty)
    </span>
  </small>
  <button class="btn btn-sm bg-dark text-white"
      [disabled]="cart.itemCount == 0" routerLink="/cart">
    <i class="fa fa-shopping-cart"></i>
  </button>
</div>

Listing 8-14.Adding Navigation in the cartSummary.component.html File in the src/app/store Folder

routerLink属性指定的值是单击button时应用将导航到的 URL。当购物车为空时,这个特殊的按钮被禁用,因此只有当用户将产品添加到购物车时,它才会执行导航。

为了增加对routerLink属性的支持,必须将RouterModule模块导入到特征模块中,如清单 8-15 所示。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { FormsModule } from "@angular/forms";
import { ModelModule } from "../model/model.module";
import { StoreComponent } from "./store.component";
import { CounterDirective } from "./counter.directive";
import { CartSummaryComponent } from "./cartSummary.component";
import { CartDetailComponent } from "./cartDetail.component";
import { CheckoutComponent } from "./checkout.component";
import { RouterModule } from "@angular/router";

@NgModule({
    imports: [ModelModule, BrowserModule, FormsModule, RouterModule],
    declarations: [StoreComponent, CounterDirective, CartSummaryComponent,
        CartDetailComponent, CheckoutComponent],
    exports: [StoreComponent, CartDetailComponent, CheckoutComponent]
})
export class StoreModule { }

Listing 8-15.Importing the Router Module in the store.module.ts File in the src/app/store Folder

要查看导航的效果,请保存文件的更改,一旦浏览器重新加载了 HTML 文档,请单击“添加到购物车”按钮之一。浏览器将导航到/cart网址,如图 8-3 所示。

img/421542_4_En_8_Fig3_HTML.jpg

图 8-3。

使用 URL 路由

守卫路由

请记住,导航只能由应用来执行。如果您直接在浏览器的 URL 栏中更改 URL,浏览器将从 web 服务器请求您输入的 URL。响应 HTTP 请求的 Angular development 服务器将通过返回index.html的内容来响应任何与文件不对应的 URL。这通常是一个有用的行为,因为这意味着当单击浏览器的重新加载按钮时,您不会收到 HTTP 错误。但是,如果应用希望用户按照特定的路径在应用中导航,这可能会导致问题。

例如,如果您单击添加到购物车按钮之一,然后单击浏览器的重新加载按钮,HTTP 服务器将返回index.html文件的内容,Angular 将立即跳转到购物车详细信息组件,跳过应用中允许用户选择产品的部分。

对于一些应用,能够开始使用不同的 URL 是有意义的,但如果不是这样,那么 Angular 支持路由守卫,用于管理路由系统。

为了防止应用以/cart/order URL 开始,我在SportsStore/src/app文件夹中添加了一个名为storeFirst.guard.ts的文件,并定义了清单 8-16 中所示的类。

import { Injectable } from "@angular/core";
import {
    ActivatedRouteSnapshot, RouterStateSnapshot,
    Router
} from "@angular/router";
import { StoreComponent } from "./store/store.component";

@Injectable()
export class StoreFirstGuard {
    private firstNavigation = true;

    constructor(private router: Router) { }

    canActivate(route: ActivatedRouteSnapshot,
        state: RouterStateSnapshot): boolean {
        if (this.firstNavigation) {
            this.firstNavigation = false;
            if (route.component != StoreComponent) {
                this.router.navigateByUrl("/");
                return false;
            }
        }
        return true;
    }
}

Listing 8-16.The Contents of the storeFirst.guard.ts File in the src/app Folder

有不同的方法来保护路由,如第二十七章所述,这是一个防止路由被激活的保护的例子,它被实现为一个定义了canActivate方法的类。这个方法的实现使用 Angular 提供的描述将要导航到的路由的上下文对象,并检查目标组件是否是一个StoreComponent。如果这是第一次调用canActivate方法,并且将要使用一个不同的组件,那么Router.navigateByUrl方法用于导航到根 URL。

清单中应用了@Injectable装饰符,因为路由守卫是服务。清单 8-17 使用根模块的providers属性将守卫注册为服务,并使用canActivate属性守卫每条路由。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { AppComponent } from "./app.component";
import { StoreModule } from "./store/store.module";
import { StoreComponent } from "./store/store.component";
import { CheckoutComponent } from "./store/checkout.component";
import { CartDetailComponent } from "./store/cartDetail.component";
import { RouterModule } from "@angular/router";
import { StoreFirstGuard } from "./storeFirst.guard";

@NgModule({
    imports: [BrowserModule, StoreModule,
        RouterModule.forRoot([
            {
                path: "store", component: StoreComponent,
                canActivate: [StoreFirstGuard]
            },
            {
                path: "cart", component: CartDetailComponent,
                canActivate: [StoreFirstGuard]
            },
            {
                path: "checkout", component: CheckoutComponent,
                canActivate: [StoreFirstGuard]
            },
            { path: "**", redirectTo: "/store" }
        ])],
    providers: [StoreFirstGuard],
    declarations: [AppComponent],
    bootstrap: [AppComponent]
})
export class AppModule { }

Listing 8-17.Guarding Routes in the app.module.ts File in the src/app Folder

如果您现在点击“添加到购物车”按钮后重新加载浏览器,您将会看到浏览器自动返回到安全位置,如图 8-4 所示。

img/421542_4_En_8_Fig4_HTML.jpg

图 8-4。

守卫路由

完成购物车详细信息功能

既然应用有了导航支持,是时候完成详细描述用户购物车内容的视图了。清单 8-18 从 cart detail 组件中移除内联模板,在同一目录中指定一个外部模板,并向构造函数添加一个Cart参数,该参数可在模板中通过一个名为cart的属性进行访问。

import { Component } from "@angular/core";
import { Cart } from "../model/cart.model";

@Component({
    templateUrl: "cartDetail.component.html"
})
export class CartDetailComponent {

    constructor(public cart: Cart) { }
}

Listing 8-18.Changing the Template in the cartDetail.component.ts File in the src/app/store Folder

为了完成购物车细节特性,我在src/app/store文件夹中创建了一个名为cartDetail.component.html的 HTML 文件,并添加了清单 8-19 中所示的内容。

<div class="container-fluid">
  <div class="row">
    <div class="col bg-dark text-white">
      <a class="navbar-brand">SPORTS STORE</a>
    </div>
  </div>
  <div class="row">
    <div class="col mt-2">
      <h2 class="text-center">Your Cart</h2>
      <table class="table table-bordered table-striped p-2">
        <thead>
          <tr>
            <th>Quantity</th>
            <th>Product</th>
            <th class="text-right">Price</th>
            <th class="text-right">Subtotal</th>
          </tr>
        </thead>
        <tbody>
          <tr *ngIf="cart.lines.length == 0">
            <td colspan="4" class="text-center">
              Your cart is empty
            </td>
          </tr>
          <tr *ngFor="let line of cart.lines">
            <td>
              <input type="number" class="form-control-sm"
                     style="width:5em"
                     [value]="line.quantity"
                     (change)="cart.updateQuantity(line.product,
                                $event.target.value)" />
            </td>
            <td>{{line.product.name}}</td>
            <td class="text-right">
                {{line.product.price | currency:"USD":"symbol":"2.2-2"}}
            </td>
            <td class="text-right">
                {{(line.lineTotal) | currency:"USD":"symbol":"2.2-2" }}
            </td>
            <td class="text-center">
              <button class="btn btn-sm btn-danger"
                      (click)="cart.removeLine(line.product.id)">
                Remove
              </button>
            </td>
          </tr>
        </tbody>
        <tfoot>
          <tr>
            <td colspan="3" class="text-right">Total:</td>
            <td class="text-right">
              {{cart.cartPrice | currency:"USD":"symbol":"2.2-2"}}
            </td>
          </tr>
        </tfoot>
      </table>
    </div>
  </div>
  <div class="row">
    <div class="col">
    <div class="text-center">
      <button class="btn btn-primary m-1" routerLink="/store">
          Continue Shopping
      </button>
      <button class="btn btn-secondary m-1" routerLink="/checkout"
              [disabled]="cart.lines.length == 0">
        Checkout
      </button>
    </div>
  </div>
</div>

Listing 8-19.The Contents of the cartDetail.component.html File in the src/app/store Folder

该模板显示一个表格,其中显示了用户的产品选择。对于每个产品,都有一个input元素可用于更改数量,还有一个 Remove 按钮可将其从购物车中删除。还有两个导航按钮,允许用户返回产品列表或继续结账过程,如图 8-5 所示。Angular 数据绑定和共享的Cart对象的结合意味着对购物车的任何更改都会立即生效,重新计算价格;如果您单击 Continue Shopping 按钮,这些更改将反映在产品列表上方显示的购物车摘要组件中。

img/421542_4_En_8_Fig5_HTML.jpg

图 8-5。

完成购物车详细信息功能

处理订单

能够收到顾客的订单是网上商店最重要的方面。在接下来的几节中,我将在应用的基础上添加对从用户处接收最终细节并检查它们的支持。为了保持过程简单,我将避免处理支付和履行平台,它们通常是后端服务,并不特定于 Angular 应用。

扩展模型

为了描述用户下的订单,我在src/app/model文件夹中添加了一个名为order.model.ts的文件,并定义了清单 8-20 中所示的代码。

import { Injectable } from "@angular/core";
import { Cart } from "./cart.model";

@Injectable()
export class Order {
    public id: number;
    public name: string;
    public address: string;
    public city: string;
    public state: string;
    public zip: string;
    public country: string;
    public shipped: boolean = false;

    constructor(public cart: Cart) { }

    clear() {
        this.id = null;
        this.name = this.address = this.city = null;
        this.state = this.zip = this.country = null;
        this.shipped = false;
        this.cart.clear();
    }
}

Listing 8-20.The Contents of the order.model.ts File in the src/app/model Folder

Order类将是另一个服务,这意味着整个应用将共享一个实例。当 Angular 创建Order对象时,它将检测Cart构造函数参数,并提供应用中其他地方使用的同一个Cart对象。

更新存储库和数据源

为了处理应用中的订单,我需要扩展存储库和数据源,以便它们可以接收Order对象。清单 8-21 向接收订单的数据源添加一个方法。因为这仍然是虚拟数据源,所以该方法只是从订单中产生一个 JSON 字符串,并将其写入 JavaScript 控制台。在下一节中,当我创建一个使用 HTTP 请求与 RESTful web 服务通信的数据源时,我将对这些对象做一些更有用的事情。

import { Injectable } from "@angular/core";
import { Product } from "./product.model";
import { Observable, from } from "rxjs";
import { Order } from "./order.model";

@Injectable()
export class StaticDataSource {
    private products: Product[] = [
        new Product(1, "Product 1", "Category 1", "Product 1 (Category 1)", 100),
        new Product(2, "Product 2", "Category 1", "Product 2 (Category 1)", 100),
        new Product(3, "Product 3", "Category 1", "Product 3 (Category 1)", 100),
        new Product(4, "Product 4", "Category 1", "Product 4 (Category 1)", 100),
        new Product(5, "Product 5", "Category 1", "Product 5 (Category 1)", 100),
        new Product(6, "Product 6", "Category 2", "Product 6 (Category 2)", 100),
        new Product(7, "Product 7", "Category 2", "Product 7 (Category 2)", 100),
        new Product(8, "Product 8", "Category 2", "Product 8 (Category 2)", 100),
        new Product(9, "Product 9", "Category 2", "Product 9 (Category 2)", 100),
        new Product(10, "Product 10", "Category 2", "Product 10 (Category 2)", 100),
        new Product(11, "Product 11", "Category 3", "Product 11 (Category 3)", 100),
        new Product(12, "Product 12", "Category 3", "Product 12 (Category 3)", 100),
        new Product(13, "Product 13", "Category 3", "Product 13 (Category 3)", 100),
        new Product(14, "Product 14", "Category 3", "Product 14 (Category 3)", 100),
        new Product(15, "Product 15", "Category 3", "Product 15 (Category 3)", 100),
    ];

    getProducts(): Observable<Product[]> {
        return from([this.products]);
    }

    saveOrder(order: Order): Observable<Order> {
        console.log(JSON.stringify(order));
        return from([order]);
    }
}

Listing 8-21.Handling Orders in the static.datasource.ts File in the src/app/model Folder

为了管理订单,我在src/app/model文件夹中添加了一个名为order.repository.ts的文件,并用它来定义清单 8-22 中所示的类。目前订单库中只有一个方法,但是当我创建管理特性时,我会在第九章中添加更多的功能。

Tip

您不必为应用中的每个模型类型使用不同的存储库,但我经常这样做,因为负责多个模型类型的单个类可能会变得复杂且难以维护。

import { Injectable } from "@angular/core";
import { Observable } from "rxjs";
import { Order } from "./order.model";
import { StaticDataSource } from "./static.datasource";

@Injectable()
export class OrderRepository {
    private orders: Order[] = [];

    constructor(private dataSource: StaticDataSource) {}

    getOrders(): Order[] {
        return this.orders;
    }

    saveOrder(order: Order): Observable<Order> {
        return this.dataSource.saveOrder(order);
    }
}

Listing 8-22.The Contents of the order.repository.ts File in the src/app/model Folder

更新功能模块

清单 8-23 使用模型特征模块的providers属性将Order类和新的存储库注册为服务。

import { NgModule } from "@angular/core";
import { ProductRepository } from "./product.repository";
import { StaticDataSource } from "./static.datasource";
import { Cart } from "./cart.model";
import { Order } from "./order.model";
import { OrderRepository } from "./order.repository";

@NgModule({
    providers: [ProductRepository, StaticDataSource, Cart,
                Order, OrderRepository]
})
export class ModelModule { }

Listing 8-23.Registering Services in the model.module.ts File in the src/app/model Folder

收集订单详细信息

下一步是从用户那里收集完成订单所需的详细信息。Angular 包含了处理 HTML 表单和验证其内容的内置指令。清单 8-24 准备 checkout 组件,切换到外部模板,接收Order对象作为构造函数参数,并提供一些额外的支持来帮助模板。

import { Component } from "@angular/core";
import { NgForm } from "@angular/forms";
import { OrderRepository } from "../model/order.repository";
import { Order } from "../model/order.model";

@Component({
    templateUrl: "checkout.component.html",
    styleUrls: ["checkout.component.css"]
})
export class CheckoutComponent {
    orderSent: boolean = false;
    submitted: boolean = false;

    constructor(public repository: OrderRepository,
                public order: Order) {}

    submitOrder(form: NgForm) {
        this.submitted = true;
        if (form.valid) {
            this.repository.saveOrder(this.order).subscribe(order => {
                this.order.clear();
                this.orderSent = true;
                this.submitted = false;
            });
        }
    }
}

Listing 8-24.Preparing for a Form in the checkout.component.ts File in the src/app/store Folder

当用户提交表单时,submitOrder方法将被调用,表单由一个NgForm对象表示。如果表单包含的数据有效,那么Order对象将被传递给存储库的saveOrder方法,购物车和订单中的数据将被重置。

@Component decorator 的styleUrls属性用于指定一个或多个 CSS 样式表,这些样式表应该应用于组件模板中的内容。为了给用户输入 HTML 表单元素的值提供验证反馈,我在src/app/store文件夹中创建了一个名为checkout.component.css的文件,并定义了清单 8-25 中所示的样式。

input.ng-dirty.ng-invalid { border: 2px solid #ff0000 }
input.ng-dirty.ng-valid { border: 2px solid #6bc502 }

Listing 8-25.The Contents of the checkout.component.css File in the src/app/store Folder

Angular 将元素添加到ng-dirtyng-validng-valid类中,以指示它们的验证状态。在第十四章中描述了全套的验证类,但是清单 8-25 中样式的效果是在有效的input元素周围添加一个绿色边框,在无效的元素周围添加一个红色边框。

拼图的最后一块是组件的模板,它为用户提供填充一个Order对象的属性所需的表单域,如清单 8-26 所示。

<div class="container-fluid">
  <div class="row">
    <div class="col bg-dark text-white">
      <a class="navbar-brand">SPORTS STORE</a>
    </div>
  </div>
</div>

<div *ngIf="orderSent" class="m-2 text-center">
  <h2>Thanks!</h2>
  <p>Thanks for placing your order.</p>
  <p>We'll ship your goods as soon as possible.</p>
  <button class="btn btn-primary" routerLink="/store">Return to Store</button>
</div>
<form *ngIf="!orderSent" #form="ngForm" novalidate
      (ngSubmit)="submitOrder(form)" class="m-2">
  <div class="form-group">
    <label>Name</label>
    <input class="form-control" #name="ngModel" name="name"
            [(ngModel)]="order.name" required />
    <span *ngIf="submitted && name.invalid" class="text-danger">
      Please enter your name
    </span>
  </div>
  <div class="form-group">
    <label>Address</label>
    <input class="form-control" #address="ngModel" name="address"
            [(ngModel)]="order.address" required />
    <span *ngIf="submitted && address.invalid" class="text-danger">
      Please enter your address
    </span>
  </div>
  <div class="form-group">
    <label>City</label>
    <input class="form-control" #city="ngModel" name="city"
            [(ngModel)]="order.city" required />
    <span *ngIf="submitted && city.invalid" class="text-danger">
      Please enter your city
    </span>
  </div>
  <div class="form-group">
    <label>State</label>
    <input class="form-control" #state="ngModel" name="state"
            [(ngModel)]="order.state" required />
    <span *ngIf="submitted && state.invalid" class="text-danger">
      Please enter your state
    </span>
  </div>
  <div class="form-group">
    <label>Zip/Postal Code</label>
    <input class="form-control" #zip="ngModel" name="zip"
            [(ngModel)]="order.zip" required />
    <span *ngIf="submitted && zip.invalid" class="text-danger">
      Please enter your zip/postal code
    </span>
  </div>
  <div class="form-group">
    <label>Country</label>
    <input class="form-control" #country="ngModel" name="country"
            [(ngModel)]="order.country" required />
    <span *ngIf="submitted && country.invalid" class="text-danger">
      Please enter your country
    </span>
  </div>
  <div class="text-center">
    <button class="btn btn-secondary m-1" routerLink="/cart">Back</button>
    <button class="btn btn-primary m-1" type="submit">Complete Order</button>
  </div>
</form>

Listing 8-26.The Contents of the checkout.component.html File in the src/app/store Folder

该模板中的forminput元素使用 Angular 特性来确保用户为每个字段提供值,如果用户在没有完成表单的情况下单击 Complete Order 按钮,它们会提供视觉反馈。这种反馈一部分来自应用清单 8-25 中定义的样式,一部分来自在用户试图提交无效表单之前保持隐藏的span元素。

Tip

要求值只是 Angular 验证表单域的方式之一,正如我在第十四章中解释的,你也可以很容易地添加你自己的自定义验证。

要查看该过程,从产品列表开始,单击其中一个添加到购物车按钮,将产品添加到购物车。点击 Checkout 按钮,你会看到如图 8-6 所示的 HTML 表单。单击 Complete Order 按钮,无需在任何input元素中输入文本,您将看到验证反馈消息。填写表格并单击“完成订单”按钮;您将看到如图所示的确认消息。

img/421542_4_En_8_Fig6_HTML.jpg

图 8-6。

完成订单

如果您查看浏览器的 JavaScript 控制台,您会看到订单的 JSON 表示,如下所示:

{"cart":
    {"lines":[
        {"product":{"id":1,"name":"Product 1","category":"Category 1",
         "description":"Product 1 (Category 1)","price":100},"quantity":1}],
         "itemCount":1,"cartPrice":100},
    "shipped":false,
    "name":"Joe Smith","address":"123 Main Street",
    "city":"Smallville","state":"NY","zip":"10036","country":"USA"
}

使用 RESTful Web 服务

既然基本的 SportsStore 功能已经就绪,是时候用一个从 RESTful web 服务获取数据的数据源来替换这个虚拟数据源了,这个 RESTful web 服务是在第七章的项目设置期间创建的。

为了创建数据源,我在src/app/model文件夹中添加了一个名为rest.datasource.ts的文件,并添加了清单 8-27 中所示的代码。

import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Observable } from "rxjs";
import { Product } from "./product.model";
import { Cart } from "./cart.model";
import { Order } from "./order.model";

const PROTOCOL = "http";
const PORT = 3500;

@Injectable()
export class RestDataSource {
  baseUrl: string;

  constructor(private http: HttpClient) {
    this.baseUrl = `${PROTOCOL}://${location.hostname}:${PORT}/`;
  }

  getProducts(): Observable<Product[]> {
    return this.http.get<Product[]>(this.baseUrl + "products");
  }

  saveOrder(order: Order): Observable<Order> {
    return this.http.post<Order>(this.baseUrl + "orders", order);
  }
}

Listing 8-27.The Contents of the rest.datasource.ts File in the src/app/model Folder

Angular 提供了一个名为HttpClient的内置服务,用于发出 HTTP 请求。RestDataSource构造函数接收HttpClient服务,并使用浏览器提供的全局location对象来确定请求将被发送到的 URL,即加载应用的主机上的端口 3500。

RestDataSource类定义的方法对应于由静态数据源定义的方法,但是使用第二十四章中描述的HttpClient服务来实现。

Tip

当通过 HTTP 获取数据时,网络拥塞或服务器负载可能会延迟请求,使用户只能看到没有数据的应用。在第二十七章中,我解释了如何配置路由系统来防止这个问题。

应用数据源

为了完成本章,我将通过重新配置应用来应用 RESTful 数据源,这样从虚拟数据到 REST 数据的切换是通过对单个文件的更改来完成的。清单 8-28 改变了模型特征模块中数据源服务的行为。

import { NgModule } from "@angular/core";
import { ProductRepository } from "./product.repository";
import { StaticDataSource } from "./static.datasource";
import { Cart } from "./cart.model";
import { Order } from "./order.model";
import { OrderRepository } from "./order.repository";
import { RestDataSource } from "./rest.datasource";
import { HttpClientModule } from "@angular/common/http";

@NgModule({
  imports: [HttpClientModule],
  providers: [ProductRepository, Cart, Order, OrderRepository,
    { provide: StaticDataSource, useClass: RestDataSource }]
})
export class ModelModule { }

Listing 8-28.Changing the Service Configuration in the model.module.ts File in the src/app/model Folder

imports属性用于声明对HttpClientModule特性模块的依赖,该模块提供了在清单 8-27 中使用的HttpClient服务。对providers属性的更改告诉 Angular,当它需要用StaticDataSource构造函数参数创建一个类的实例时,它应该使用RestDataSource来代替。由于两个对象定义了相同的方法,动态 JavaScript 类型系统意味着替换是无缝的。当所有更改都已保存,浏览器重新加载应用时,您将看到虚拟数据已被通过 HTTP 获得的数据所替换,如图 8-7 所示。

img/421542_4_En_8_Fig7_HTML.jpg

图 8-7。

使用 RESTful web 服务

如果您经历选择产品和结帐的过程,您可以看到数据源已经通过导航到以下 URL 将订单写入 web 服务:

http://localhost:3500/db

这将显示数据库的全部内容,包括订单的集合。您将不能请求/orders URL,因为它需要认证,我将在下一章中设置认证。

Tip

请记住,当您停止服务器并使用npm run json命令再次启动它时,RESTful web 服务提供的数据会被重置。

摘要

在本章中,我继续向 SportsStore 应用添加特性,添加对用户可以放入产品的购物车和完成购物过程的结帐过程的支持。为了完成这一章,我用一个向 RESTful web 服务发送 HTTP 请求的数据源替换了伪数据源。在下一章中,我将创建允许管理 SportsStore 数据的管理特性。

九、SportsStore:管理

在本章中,我将继续通过添加管理功能来构建 SportsStore 应用。需要访问管理功能的用户相对较少,因此在不太可能使用管理代码和内容时,强制所有用户下载这些代码和内容是一种浪费。相反,我将把管理特性放在一个单独的模块中,只在需要时才加载。

准备示例应用

本章不需要准备,继续使用第八章中的 SportsStore 项目。要启动 RESTful web 服务,请打开命令提示符并在SportsStore文件夹中运行以下命令:

npm run json

打开第二个命令提示符,在SportsStore文件夹中运行以下命令,启动开发工具和 HTTP 服务器:

ng serve --open

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

创建模块

创建功能模块的过程与您在前面章节中看到的模式相同。关键的区别在于,应用的任何其他部分都不依赖于模块或它包含的类,这一点很重要,否则会破坏模块的动态加载,并导致 JavaScript 模块加载管理代码,即使它没有被使用。

管理特性的起点是身份验证,这将确保只有授权用户才能管理应用。我在src/app/admin文件夹中创建了一个名为auth.component.ts的文件,并用它来定义清单 9-1 中所示的组件。

import { Component } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Router } from "@angular/router";

@Component({
    templateUrl: "auth.component.html"
})
export class AuthComponent {
    public username: string;
    public password: string;
    public errorMessage: string;

    constructor(private router: Router) {}

    authenticate(form: NgForm) {
        if (form.valid) {
            // perform authentication
            this.router.navigateByUrl("/admin/main");
        } else {
            this.errorMessage = "Form Data Invalid";
        }
    }
}

Listing 9-1.The Content of the auth.component.ts File in the src/app/admin Folder

该组件定义了用户名和密码的属性,这些属性将用于验证用户,一个errorMessage属性将用于在出现问题时显示消息,一个authenticate方法将执行验证过程(但目前不做任何事情)。

为了给组件提供模板,我在src/app/admin文件夹中创建了一个名为auth.component.html的文件,并添加了清单 9-2 中所示的内容。

<div class="bg-info p-2 text-center text-white">
  <h3>SportsStore Admin</h3>
</div>
<div class="bg-danger mt-2 p-2 text-center text-white"
     *ngIf="errorMessage != null">
  {{errorMessage}}
</div>
<div class="p-2">
  <form novalidate #form="ngForm" (ngSubmit)="authenticate(form)">
    <div class="form-group">
      <label>Name</label>
      <input class="form-control" name="username"
             [(ngModel)]="username" required />
    </div>
    <div class="form-group">
      <label>Password</label>
      <input class="form-control" type="password" name="password"
             [(ngModel)]="password" required />
    </div>
    <div class="text-center">
      <button class="btn btn-secondary m-1" routerLink="/">Go back</button>
      <button class="btn btn-primary m-1" type="submit">Log In</button>
    </div>
  </form>
</div>

Listing 9-2.The Content of the auth.component.html File in the src/app/admin Folder

该模板包含一个 HTML 表单,该表单对组件的属性使用双向数据绑定表达式。有一个提交表单的按钮,一个导航回根 URL 的按钮,以及一个只有在显示错误消息时才可见的div元素。

为了创建管理特性的占位符,我在src/app/admin文件夹中添加了一个名为admin.component.ts的文件,并定义了清单 9-3 中所示的组件。

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

@Component({
    templateUrl: "admin.component.html"
})
export class AdminComponent {}

Listing 9-3.The Contents of the admin.component.ts File in the src/app/admin Folder

该组件目前不包含任何功能。为了给组件提供一个模板,我在src/app/admin文件夹中添加了一个名为admin.component.html的文件,占位符内容如清单 9-4 所示。

<div class="bg-info p-2 text-white">
  <h3>Placeholder for Admin Features</h3>
</div>

Listing 9-4.The Contents of the admin.component.html File in the src/app/admin Folder

为了定义特性模块,我在src/app/admin文件夹中添加了一个名为admin.module.ts的文件,并添加了清单 9-5 中所示的代码。

import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormsModule } from "@angular/forms";
import { RouterModule } from "@angular/router";
import { AuthComponent } from "./auth.component";
import { AdminComponent } from "./admin.component";

let routing = RouterModule.forChild([
    { path: "auth", component: AuthComponent },
    { path: "main", component: AdminComponent },
    { path: "**", redirectTo: "auth" }
]);

@NgModule({
    imports: [CommonModule, FormsModule, routing],
    declarations: [AuthComponent, AdminComponent]
})
export class AdminModule { }

Listing 9-5.The Contents of the admin.module.ts File in the src/app/admin Folder

RouterModule.forChild方法用于定义功能模块的路由配置,然后包含在模块的imports属性中。

动态加载的模块必须是自包含的,并且包含 Angular 需要的所有信息,包括支持的路由 URL 和它们显示的组件。如果应用的任何其他部分依赖于该模块,那么它将与应用代码的其余部分一起包含在 JavaScript 包中,这意味着所有用户都必须为他们不会使用的功能下载代码和资源。

但是,允许动态加载的模块声明对应用主要部分的依赖。这个模块依赖于数据模型模块中的功能,它已经被添加到模块的imports中,以便组件可以访问模型类和存储库。

配置 URL 路由系统

动态加载的模块通过路由配置进行管理,当应用导航到特定的 URL 时,路由配置会触发加载过程。清单 9-6 扩展了应用的路由配置,因此/admin URL 将加载管理功能模块。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { AppComponent } from "./app.component";
import { StoreModule } from "./store/store.module";
import { StoreComponent } from "./store/store.component";
import { CheckoutComponent } from "./store/checkout.component";
import { CartDetailComponent } from "./store/cartDetail.component";
import { RouterModule } from "@angular/router";
import { StoreFirstGuard } from "./storeFirst.guard";

@NgModule({
    imports: [BrowserModule, StoreModule,
        RouterModule.forRoot([
            {
                path: "store", component: StoreComponent,
                canActivate: [StoreFirstGuard]
            },
            {
                path: "cart", component: CartDetailComponent,
                canActivate: [StoreFirstGuard]
            },
            {
                path: "checkout", component: CheckoutComponent,
                canActivate: [StoreFirstGuard]
            },
            {
                path: "admin",
                loadChildren: () => import("./admin/admin.module")
                    .then(m => m.AdminModule),
                canActivate: [StoreFirstGuard]
            },
            { path: "**", redirectTo: "/store" }
        ])],
    providers: [StoreFirstGuard],
    declarations: [AppComponent],
    bootstrap: [AppComponent]
})
export class AppModule { }

Listing 9-6.Configuring a Dynamically Loaded Module in the app.module.ts File in the src/app Folder

新的路径告诉 Angular,当应用导航到/admin URL 时,它应该从admin/admin.module.ts文件中加载一个由名为AdminModule的类定义的特性模块,其路径是相对于app.module.ts文件指定的。当 Angular 处理管理模块时,它会将包含的路由信息合并到整个路由集中,并完成导航。

导航到管理 URL

最后的准备步骤是为用户提供导航到/admin URL 的能力,以便加载管理功能模块并向用户显示其组件。清单 9-7 在商店组件的模板中添加了一个按钮来执行导航。

<div class="container-fluid">
  <div class="row">
    <div class="col bg-dark text-white">
      <a class="navbar-brand">SPORTS STORE</a>
      <cart-summary></cart-summary>
    </div>
  </div>
  <div class="row">

    <div class="col-3 p-2">
      <button class="btn btn-block btn-outline-primary" (click)="changeCategory()">
        Home
      </button>
      <button *ngFor="let cat of categories"
          class="btn btn-outline-primary btn-block"
          [class.active]="cat == selectedCategory" (click)="changeCategory(cat)">
        {{cat}}
      </button>
      <button class="btn btn-block btn-danger mt-5" routerLink="/admin">
        Admin
      </button>
    </div>

    <div class="col-9 p-2">

      <!-- ...elements omitted for brevity... -->

    </div>
  </div>
</div>

Listing 9-7.Adding a Navigation Button in the store.component.html File in the src/app/store Folder

要反映这些更改,请停止开发工具,并通过在SportsStore文件夹中运行以下命令来重新启动它们:

ng serve

使用浏览器导航至http://localhost:4200,并使用浏览器的 F12 开发工具查看加载应用时浏览器发出的网络请求。管理模块的文件将不会被加载,直到你点击管理按钮,此时 Angular 将请求文件并显示如图 9-1 所示的登录页面。

img/421542_4_En_9_Fig1_HTML.jpg

图 9-1。

使用动态加载的模块

在表单域中输入任意名称和密码,点击登录按钮,查看占位符内容,如图 9-2 所示。如果您将任何一个表单域留空,将会显示一条警告消息。

img/421542_4_En_9_Fig2_HTML.jpg

图 9-2。

占位符管理功能

实施身份验证

RESTful web 服务已经过配置,因此它要求对管理特性所要求的请求进行身份验证。在接下来的小节中,我将通过向 RESTful web 服务发送 HTTP 请求来添加对用户身份验证的支持。

了解认证系统

当 RESTful web 服务对用户进行身份验证时,它将返回一个 JSON Web 令牌(JWT ),应用必须将它包含在后续的 HTTP 请求中,以表明身份验证已经成功执行。您可以在 https://tools.ietf.org/html/rfc7519 阅读 JWT 规范,但是对于 SportsStore 应用来说,只需知道 Angular 应用可以通过向/login URL 发送 POST 请求来验证用户,包括在请求体中包含名称和密码属性的 JSON 格式的对象。我在第七章的申请中添加的验证码只有一组有效凭证,如表 9-1 所示。

表 9-1。

RESTful Web 服务支持的身份验证凭证

|

用户名

|

密码

|
| --- | --- |
| admin | secret |

正如我在第七章中提到的,您不应该在实际项目中硬编码凭证,但这是您在 SportsStore 应用中需要的用户名和密码。

如果正确的凭证被发送到/login URL,那么来自 RESTful web 服务的响应将包含一个 JSON 对象,如下所示:

{
  "success": true,
   "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjoiYWRtaW4iLCJleHBpcmVz
           SW4iOiIxaCIsImlhdCI6MTQ3ODk1NjI1Mn0.lJaDDrSu-bHBtdWrz0312p_DG5tKypGv6cA
           NgOyzlg8"
}

success属性描述认证操作的结果,而token属性包含 JWT,它应该包含在使用Authorization HTTP 头的后续请求中,格式如下:

Authorization: Bearer<eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjoiYWRtaW4iLC
    JleHBpcmVzSW4iOiIxaCIsImlhdCI6MTQ3ODk1NjI1Mn0.lJaDDrSu-
    bHBtdWrz0312p_DG5tKypGv6cANgOyzlg8>

我配置了服务器返回的 JWT 令牌,使它们在一小时后过期。如果向服务器发送了错误的凭证,那么响应中返回的 JSON 对象将只包含一个设置为falsesuccess属性,如下所示:

{
  "success": false
}

扩展数据源

RESTful 数据源将完成大部分工作,因为它负责向/login URL 发送认证请求,并在后续请求中包含 JWT。清单 9-8 向RestDataSource类添加了身份验证,并定义了一个变量,该变量将在获得 JWT 后存储它。

import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Observable } from "rxjs";
import { Product } from "./product.model";
import { Cart } from "./cart.model";
import { Order } from "./order.model";
import { map } from "rxjs/operators";

const PROTOCOL = "http";
const PORT = 3500;

@Injectable()
export class RestDataSource {
    baseUrl: string;
    auth_token: string;

    constructor(private http: HttpClient) {
        this.baseUrl = `${PROTOCOL}://${location.hostname}:${PORT}/`;
    }

    getProducts(): Observable<Product[]> {
        return this.http.get<Product[]>(this.baseUrl + "products");
    }

    saveOrder(order: Order): Observable<Order> {
        return this.http.post<Order>(this.baseUrl + "orders", order);
    }

    authenticate(user: string, pass: string): Observable<boolean> {
        return this.http.post<any>(this.baseUrl + "login", {
            name: user, password: pass
        }).pipe(map(response => {
            this.auth_token = response.success ? response.token : null;
            return response.success;
        }));
    }
}

Listing 9-8.Adding Authentication in the rest.datasource.ts File in the src/app/model Folder

创建身份验证服务

我将创建一个可用于执行身份验证并确定应用是否已通过身份验证的服务,而不是直接向应用的其余部分公开数据源。我在src/app/model文件夹中添加了一个名为auth.service.ts的文件,并添加了清单 9-9 中所示的代码。

import { Injectable } from "@angular/core";
import { Observable } from "rxjs";
import { RestDataSource } from "./rest.datasource";

@Injectable()
export class AuthService {

    constructor(private datasource: RestDataSource) {}

    authenticate(username: string, password: string): Observable<boolean> {
        return this.datasource.authenticate(username, password);
    }

    get authenticated(): boolean {
        return this.datasource.auth_token != null;
    }

    clear() {
        this.datasource.auth_token = null;
    }
}

Listing 9-9.The Contents of the auth.service.ts File in the src/app/model Folder

authenticate方法接收用户的凭证,并将它们传递给数据源authenticate方法,如果认证过程成功,返回一个将产生trueObservable,否则返回falseauthenticated属性是一个 getter 专用属性,如果数据源已经获得了一个认证令牌,它将返回trueclear方法从数据源中移除令牌。

清单 9-10 向模型特征模块注册新服务。它还为RestDataSource类添加了一个providers条目,该类在前面的章节中仅被用作StaticDataSource类的替代品。由于AuthService类有一个RestDataSource构造函数参数,它需要在模块中有自己的条目。

import { NgModule } from "@angular/core";
import { ProductRepository } from "./product.repository";
import { StaticDataSource } from "./static.datasource";
import { Cart } from "./cart.model";
import { Order } from "./order.model";
import { OrderRepository } from "./order.repository";
import { RestDataSource } from "./rest.datasource";
import { HttpClientModule } from "@angular/common/http";
import { AuthService } from "./auth.service";

@NgModule({
  imports: [HttpClientModule],
  providers: [ProductRepository, Cart, Order, OrderRepository,
    { provide: StaticDataSource, useClass: RestDataSource },
    RestDataSource, AuthService]
})
export class ModelModule { }

Listing 9-10.Configuring the Services in the model.module.ts File in the src/app/model Folder

启用身份验证

下一步是连接从用户处获取凭证的组件,以便它通过新服务执行身份验证,如清单 9-11 所示。

import { Component } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Router } from "@angular/router";
import { AuthService } from "../model/auth.service";

@Component({
    templateUrl: "auth.component.html"
})
export class AuthComponent {
    public username: string;
    public password: string;
    public errorMessage: string;

    constructor(private router: Router,
                private auth: AuthService) { }

    authenticate(form: NgForm) {
        if (form.valid) {
            this.auth.authenticate(this.username, this.password)
                .subscribe(response => {
                    if (response) {
                        this.router.navigateByUrl("/admin/main");
                    }
                    this.errorMessage = "Authentication Failed";
                })
        } else {
            this.errorMessage = "Form Data Invalid";
        }
    }
}

Listing 9-11.Enabling Authentication in the auth.component.ts File in the src/app/admin Folder

为了防止应用直接导航到管理特性,这将导致 HTTP 请求在没有令牌的情况下被发送,我在src/app/admin文件夹中添加了一个名为auth.guard.ts的文件,并定义了清单 9-12 中所示的路由保护。

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

@Injectable()
export class AuthGuard {

    constructor(private router: Router,
                private auth: AuthService) { }

    canActivate(route: ActivatedRouteSnapshot,
        state: RouterStateSnapshot): boolean {

        if (!this.auth.authenticated) {
            this.router.navigateByUrl("/admin/auth");
            return false;
        }
        return true;
    }
}

Listing 9-12.The Contents of the auth.guard.ts File in the src/app/admin Folder

清单 9-13 将路由保护应用于由管理功能模块定义的路由之一。

import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormsModule } from "@angular/forms";
import { RouterModule } from "@angular/router";
import { AuthComponent } from "./auth.component";
import { AdminComponent } from "./admin.component";
import { AuthGuard } from "./auth.guard";

let routing = RouterModule.forChild([
    { path: "auth", component: AuthComponent },
    { path: "main", component: AdminComponent, canActivate: [AuthGuard] },
    { path: "**", redirectTo: "auth" }
]);

@NgModule({
    imports: [CommonModule, FormsModule, routing],
    providers: [AuthGuard],
    declarations: [AuthComponent, AdminComponent]
})
export class AdminModule {}

Listing 9-13.Guarding a Route in the admin.module.ts File in the src/app/admin Folder

要测试身份验证系统,请单击 Admin 按钮,输入一些凭证,然后单击 Log In 按钮。如果凭证是表 9-1 中的凭证,那么您将看到管理功能的占位符。如果您输入其他凭据,将会看到一条错误消息。图 9-3 说明了这两种结果。

Tip

令牌不会永久存储,因此如果可以,请在浏览器中重新加载应用以再次启动,并尝试一组不同的凭据。

img/421542_4_En_9_Fig3_HTML.jpg

图 9-3。

测试身份验证功能

扩展数据源和存储库

身份验证系统就绪后,下一步是扩展数据源,以便它可以发送经过身份验证的请求,并通过 order 和 product repository 类公开这些特性。清单 9-14 向包含认证令牌的数据源添加方法。

import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Observable } from "rxjs";
import { Product } from "./product.model";
import { Cart } from "./cart.model";
import { Order } from "./order.model";
import { map } from "rxjs/operators";
import { HttpHeaders } from '@angular/common/http';

const PROTOCOL = "http";
const PORT = 3500;

@Injectable()
export class RestDataSource {
    baseUrl: string;
    auth_token: string;

    constructor(private http: HttpClient) {
        this.baseUrl = `${PROTOCOL}://${location.hostname}:${PORT}/`;
    }

    getProducts(): Observable<Product[]> {
        return this.http.get<Product[]>(this.baseUrl + "products");
    }

    saveOrder(order: Order): Observable<Order> {
        return this.http.post<Order>(this.baseUrl + "orders", order);
    }

    authenticate(user: string, pass: string): Observable<boolean> {
        return this.http.post<any>(this.baseUrl + "login", {
            name: user, password: pass
        }).pipe(map(response => {
            this.auth_token = response.success ? response.token : null;
            return response.success;
        }));
    }

    saveProduct(product: Product): Observable<Product> {
        return this.http.post<Product>(this.baseUrl + "products",
            product, this.getOptions());
    }

    updateProduct(product): Observable<Product> {
        return this.http.put<Product>(`${this.baseUrl}products/${product.id}`,
            product, this.getOptions());
    }

    deleteProduct(id: number): Observable<Product> {
        return this.http.delete<Product>(`${this.baseUrl}products/${id}`,
            this.getOptions());
    }

    getOrders(): Observable<Order[]> {
        return this.http.get<Order[]>(this.baseUrl + "orders", this.getOptions());
    }

    deleteOrder(id: number): Observable<Order> {
        return this.http.delete<Order>(`${this.baseUrl}orders/${id}`,
            this.getOptions());
    }

    updateOrder(order: Order): Observable<Order> {
        return this.http.put<Order>(`${this.baseUrl}orders/${order.id}`,
            order, this.getOptions());
    }

    private getOptions() {
        return {
            headers: new HttpHeaders({
                "Authorization": `Bearer<${this.auth_token}>`
            })
        }
    }
}

Listing 9-14.Adding New Operations in the rest.datasource.ts File in the src/app/model Folder

清单 9-15 向产品存储库类添加了新方法,允许创建、更新或删除产品。saveProduct方法负责创建和更新产品,这是一种在使用由组件管理的单个对象时工作良好的方法,您将在本章后面看到演示。清单还将构造函数参数的类型更改为RestDataSource

import { Injectable } from "@angular/core";
import { Product } from "./product.model";
//import { StaticDataSource } from "./static.datasource";
import { RestDataSource } from "./rest.datasource";

@Injectable()
export class ProductRepository {
    private products: Product[] = [];
    private categories: string[] = [];

    constructor(private dataSource: RestDataSource) {
        dataSource.getProducts().subscribe(data => {
            this.products = data;
            this.categories = data.map(p => p.category)
                .filter((c, index, array) => array.indexOf(c) == index).sort();
        });
    }

    getProducts(category: string = null): Product[] {
        return this.products
            .filter(p => category == null || category == p.category);
    }

    getProduct(id: number): Product {
        return this.products.find(p => p.id == id);
    }

    getCategories(): string[] {
        return this.categories;
    }

    saveProduct(product: Product) {
        if (product.id == null || product.id == 0) {
            this.dataSource.saveProduct(product)
                .subscribe(p => this.products.push(p));
        } else {
            this.dataSource.updateProduct(product)
                .subscribe(p => {
                    this.products.splice(this.products.
                        findIndex(p => p.id == product.id), 1, product);
                });
        }
    }

    deleteProduct(id: number) {
        this.dataSource.deleteProduct(id).subscribe(p => {
            this.products.splice(this.products.
                findIndex(p => p.id == id), 1);
        })
    }
}

Listing 9-15.Adding New Operations in the product.repository.ts File in the src/app/model Folder

清单 9-16 对订单存储库进行了相应的更改,添加了允许修改和删除订单的方法。

import { Injectable } from "@angular/core";
import { Observable } from "rxjs";
import { Order } from "./order.model";
//import { StaticDataSource } from "./static.datasource";
import { RestDataSource } from "./rest.datasource";

@Injectable()
export class OrderRepository {
    private orders: Order[] = [];
    private loaded: boolean = false;

    constructor(private dataSource: RestDataSource) { }

    loadOrders() {
        this.loaded = true;
        this.dataSource.getOrders()
            .subscribe(orders => this.orders = orders);
    }

    getOrders(): Order[] {
        if (!this.loaded) {
            this.loadOrders();
        }
        return this.orders;
    }

    saveOrder(order: Order): Observable<Order> {
        return this.dataSource.saveOrder(order);
    }

    updateOrder(order: Order) {
        this.dataSource.updateOrder(order).subscribe(order => {
            this.orders.splice(this.orders.
                findIndex(o => o.id == order.id), 1, order);
        });
    }

    deleteOrder(id: number) {
        this.dataSource.deleteOrder(id).subscribe(order => {
            this.orders.splice(this.orders.findIndex(o => id == o.id), 1);
        });
    }
}

Listing 9-16.Adding New Operations in the order.repository.ts File in the src/app/model Folder

订单存储库定义了一个loadOrders方法,该方法从存储库中获取订单,并确保在执行身份验证之前不会将请求发送到 RESTful web 服务。

创建管理功能结构

现在,身份验证系统已经就绪,存储库提供了完整的操作,我可以创建显示管理特性的结构了,这是通过构建现有的 URL 路由配置来创建的。表 9-2 列出了我将支持的 URL 以及每个 URL 将呈现给用户的功能。

表 9-2。

管理功能的 URL

|

名字

|

描述

|
| --- | --- |
| /admin/main/products | 导航到此 URL 将在一个表中显示所有产品,以及允许编辑或删除现有产品和创建新产品的按钮。 |
| /admin/main/products/create | 导航到这个 URL 将向用户呈现一个用于创建新产品的空编辑器。 |
| /admin/main/products/edit/1 | 导航到该 URL 将向用户呈现一个填充的编辑器,用于编辑现有产品。 |
| /admin/main/orders | 导航到这个 URL 将向用户显示一个表中的所有订单,以及标记已发货订单和通过删除取消订单的按钮。 |

创建占位符组件

我发现向 Angular 项目添加特性的最简单方法是定义具有占位符内容的组件,并围绕它们构建应用的结构。一旦结构就位,我就返回组件并详细实现特性。对于管理特性,我首先将一个名为productTable.component.ts的文件添加到src/app/admin文件夹中,并定义清单 9-17 中所示的组件。该组件将负责显示产品列表,以及编辑和删除它们或创建新产品所需的按钮。

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

@Component({
    template: `<div class="bg-info p-2 text-white">
                <h3>Product Table Placeholder</h3>
              </div>`
})
export class ProductTableComponent {}

Listing 9-17.The Contents of the productTable.component.ts File in the src/app/admin Folder

我在src/app/admin文件夹中添加了一个名为productEditor.component.ts的文件,并用它来定义清单 9-18 中所示的组件,这将用于允许用户输入创建或编辑组件所需的细节。

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

@Component({
    template: `<div class="bg-warning p-2 text-white">
                <h3>Product Editor Placeholder</h3>
              </div>`
})
export class ProductEditorComponent { }

Listing 9-18.The Contents of the productEditor.component.ts File in the src/app/admin Folder

为了创建负责管理客户订单的组件,我在src/app/admin文件夹中添加了一个名为orderTable.component.ts的文件,并添加了清单 9-19 中所示的代码。

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

@Component({
    template: `<div class="bg-primary p-2 text-white">
                <h3>Order Table Placeholder</h3>
              </div>`
})
export class OrderTableComponent { }

Listing 9-19.The Contents of the orderTable.component.ts File in the src/app/admin Folder

准备通用内容和功能模块

上一节中创建的组件将负责特定的特性。为了将这些特性结合在一起并允许用户在它们之间导航,我需要修改占位符组件的模板,我一直使用这个模板来演示成功的身份验证尝试的结果。我用清单 9-20 中所示的元素替换了占位符内容。

<div class="container-fluid">
    <div class="row">
        <div class="col bg-dark text-white">
            <a class="navbar-brand">SPORTS STORE</a>
        </div>
    </div>
    <div class="row mt-2">
        <div class="col-3">
            <button class="btn btn-outline-info btn-block"
                    routerLink="/admin/main/products"
                    routerLinkActive="active">
                Products
            </button>
            <button class="btn btn-outline-info btn-block"
                    routerLink="/admin/main/orders"
                    routerLinkActive="active">
                Orders
            </button>
            <button class="btn btn-outline-danger btn-block" (click)="logout()">
                Logout
            </button>
        </div>
        <div class="col-9">
            <router-outlet></router-outlet>
        </div>
    </div>
</div>

Listing 9-20.Replacing the Content in the admin.component.html File in the src/app/admin Folder

该模板包含一个router-outlet元素,用于显示前一节中的组件。还有一些按钮将应用导航到/admin/main/products/admin/main/ordersURL,这将选择产品或订单功能。这些按钮使用routerLinkActive属性,当由routerLink属性指定的路由激活时,该属性用于将元素添加到 CSS 类中。

该模板还包含一个Logout按钮,该按钮有一个事件绑定,该事件绑定的目标是一个名为logout的方法。清单 9-21 将该方法添加到组件中,该组件使用认证服务来移除不记名令牌并将应用导航到默认 URL。

import { Component } from "@angular/core";
import { Router } from "@angular/router";
import { AuthService } from "../model/auth.service";

@Component({
    templateUrl: "admin.component.html"
})
export class AdminComponent {

    constructor(private auth: AuthService,
                private router: Router) { }

    logout() {
        this.auth.clear();
        this.router.navigateByUrl("/");
    }
}

Listing 9-21.Implementing the Logout Method in the admin.component.ts File in the src/app/admin Folder

清单 9-22 启用将用于每个管理特性的占位符组件,并扩展 URL 路由配置以实现来自表 9-2 的 URL。

import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormsModule } from "@angular/forms";
import { RouterModule } from "@angular/router";
import { AuthComponent } from "./auth.component";
import { AdminComponent } from "./admin.component";
import { AuthGuard } from "./auth.guard";
import { ProductTableComponent } from "./productTable.component";
import { ProductEditorComponent } from "./productEditor.component";
import { OrderTableComponent } from "./orderTable.component";

let routing = RouterModule.forChild([
    { path: "auth", component: AuthComponent },
    {
        path: "main", component: AdminComponent, canActivate: [AuthGuard],
        children: [
            { path: "products/:mode/:id", component: ProductEditorComponent },
            { path: "products/:mode", component: ProductEditorComponent },
            { path: "products", component: ProductTableComponent },
            { path: "orders", component: OrderTableComponent },
            { path: "**", redirectTo: "products" }
        ]
    },
    { path: "**", redirectTo: "auth" }
]);

@NgModule({
    imports: [CommonModule, FormsModule, routing],
    providers: [AuthGuard],
    declarations: [AuthComponent, AdminComponent,
        ProductTableComponent, ProductEditorComponent, OrderTableComponent]
})
export class AdminModule {}

Listing 9-22.Configuring the Feature Module in the admin.module.ts File in the src/app/admin Folder

单独的路由可以使用children属性来扩展,该属性用于定义以嵌套的router-outlet元素为目标的路由,我在第二十五章中对此进行了描述。正如您将看到的,组件可以从 Angular 获得活动路由的详细信息,因此它们可以调整自己的行为。路由可以包括路由参数,例如:mode:id,它们匹配任何 URL 段,并且可以用来向组件提供信息,这些信息可以用来改变它们的行为。

保存所有更改后,点击 Admin 按钮,使用密码secret验证为admin。你会看到新的布局,如图 9-4 所示。点击产品和订单按钮将改变清单 9-20 中router-outlet元素显示的组件。点击注销按钮将退出管理区。

img/421542_4_En_9_Fig4_HTML.jpg

图 9-4。

行政布局结构

实现产品功能

呈现给用户的初始管理功能将是一个产品列表,能够创建新产品以及删除或编辑现有产品。清单 9-23 从产品表组件中删除了占位符内容,并添加了实现该特性所需的逻辑。

import { Component } from "@angular/core";
import { Product } from "../model/product.model";
import { ProductRepository } from "../model/product.repository";

@Component({
    templateUrl: "productTable.component.html"
})
export class ProductTableComponent {

    constructor(private repository: ProductRepository) { }

    getProducts(): Product[] {
        return this.repository.getProducts();
    }

    deleteProduct(id: number) {
        this.repository.deleteProduct(id);
    }
}

Listing 9-23.Replacing Content in the productTable.component.ts File in the src/app/admin Folder

组件方法提供了对存储库中产品的访问,并允许删除产品。其他操作将由编辑器组件处理,该组件将使用组件模板中的路由 URL 来激活。为了提供模板,我在src/app/admin文件夹中添加了一个名为productTable.component.html的文件,并添加了清单 9-24 中所示的标记。

<table class="table table-sm table-striped">
    <thead>
        <tr>
            <th>ID</th><th>Name</th><th>Category</th><th>Price</th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        <tr *ngFor="let p of getProducts()">
            <td>{{p.id}}</td>
            <td>{{p.name}}</td>
            <td>{{p.category}}</td>
            <td>{{p.price | currency:"USD":"symbol":"2.2-2"}}</td>
            <td>
                <button class="btn btn-sm btn-warning m-1"
                        [routerLink]="['/admin/main/products/edit', p.id]">
                    Edit
                </button>
                <button class="btn btn-sm btn-danger" (click)="deleteProduct(p.id)">
                    Delete
                </button>
            </td>
        </tr>
    </tbody>
</table>
<button class="btn btn-primary" routerLink="/admin/main/products/create">
    Create New Product
</button>

Listing 9-24.The Contents of the productTable.component.html File in the src/app/admin Folder

该模板包含一个表,该表使用ngFor指令为组件的getProducts方法返回的每个产品生成一行。每一行都包含一个调用组件的delete方法的删除按钮,还包含一个导航到指向编辑器组件的 URL 的编辑按钮。编辑器组件也是“创建新产品”按钮的目标,尽管使用了不同的 URL。

实现产品编辑器

组件可以接收关于当前路由 URL 的信息,并相应地调整它们的行为。编辑器组件需要使用这个特性来区分创建新组件和编辑现有组件的请求。清单 9-25 向编辑器组件添加了创建或编辑产品所需的功能。

import { Component } from "@angular/core";
import { Router, ActivatedRoute } from "@angular/router";
import { NgForm } from "@angular/forms";
import { Product } from "../model/product.model";
import { ProductRepository } from "../model/product.repository";

@Component({
    templateUrl: "productEditor.component.html"
})
export class ProductEditorComponent {
    editing: boolean = false;
    product: Product = new Product();

    constructor(private repository: ProductRepository,
                private router: Router,
                activeRoute: ActivatedRoute) {

        this.editing = activeRoute.snapshot.params["mode"] == "edit";
        if (this.editing) {
            Object.assign(this.product,
                repository.getProduct(activeRoute.snapshot.params["id"]));
        }
    }

    save(form: NgForm) {
        this.repository.saveProduct(this.product);
        this.router.navigateByUrl("/admin/main/products");
    }
}

Listing 9-25.Adding Functionality in the productEditor.component.ts File in the src/app/admin Folder

Angular 在创建 component 类的新实例时会提供一个ActivatedRoute对象作为构造函数参数,这个对象可以用来检查激活的 route。在这种情况下,组件会判断是应该编辑还是应该创建产品,如果是编辑,就从存储库中检索当前的详细信息。还有一个save方法,它使用存储库来保存用户所做的更改。

为了给组件提供模板,我在src/app/admin文件夹中添加了一个名为productEditor.component.html的文件,并添加了清单 9-26 中所示的标记。

<div class="bg-primary p-2 text-white" [class.bg-warning]="editing"
     [class.text-dark]="editing">
    <h5>{{editing  ? "Edit" : "Create"}} Product</h5>
</div>
<form novalidate #form="ngForm" (ngSubmit)="save(form)">
    <div class="form-group">
        <label>Name</label>
        <input class="form-control" name="name" [(ngModel)]="product.name" />
    </div>
    <div class="form-group">
        <label>Category</label>
        <input class="form-control" name="category" [(ngModel)]="product.category" />
    </div>
    <div class="form-group">
        <label>Description</label>
        <textarea class="form-control" name="description"
                  [(ngModel)]="product.description">
        </textarea>
    </div>
    <div class="form-group">
        <label>Price</label>
        <input class="form-control" name="price" [(ngModel)]="product.price" />
    </div>
    <button type="submit" class="btn btn-primary m-1" [class.btn-warning]="editing">
        {{editing ? "Save" : "Create"}}
    </button>
    <button type="reset" class="btn btn-secondary" routerLink="/admin/main/products">
        Cancel
    </button>
</form>

Listing 9-26.The Contents of the productEditor.component.html File in the src/app/admin Folder

该模板包含一个表单,其中包含由Product模型类定义的属性字段,除了由 RESTful web 服务自动分配的id属性。

表单中的元素调整其外观,以区分编辑和创建功能。要查看该组件如何工作,请进行身份验证以访问管理功能,然后单击出现在产品表下的“创建新产品”按钮。填写表单,单击 Create 按钮,新产品将被发送到 RESTful web 服务,在那里它将被分配一个 ID 属性并显示在 product 表中,如图 9-5 所示。

img/421542_4_En_9_Fig5_HTML.jpg

图 9-5。

创造新产品

编辑过程以类似的方式工作。点击其中一个编辑按钮,查看当前的详细信息,使用表单字段对其进行编辑,点击保存按钮保存更改,如图 9-6 所示。

img/421542_4_En_9_Fig6_HTML.jpg

图 9-6。

编辑现有产品

实施订单功能

订单管理功能既漂亮又简单。它需要一个列出订单集的表,以及将 shipped 属性设置为 true 或完全删除订单的按钮。清单 9-27 用支持这些操作所需的逻辑替换组件中的占位符内容。

import { Component } from "@angular/core";
import { Order } from "../model/order.model";
import { OrderRepository } from "../model/order.repository";

@Component({
    templateUrl: "orderTable.component.html"
})
export class OrderTableComponent {
    includeShipped = false;

    constructor(private repository: OrderRepository) {}

    getOrders(): Order[] {
        return this.repository.getOrders()
            .filter(o => this.includeShipped || !o.shipped);
    }

    markShipped(order: Order) {
        order.shipped = true;
        this.repository.updateOrder(order);
    }

    delete(id: number) {
        this.repository.deleteOrder(id);
    }
}

Listing 9-27.Adding Operations in the orderTable.component.ts File in the src/app/admin Folder

除了提供将订单标记为已发货和删除订单的方法之外,该组件还定义了一个getOrders方法,该方法允许根据名为includeShipped的属性值来包含或排除已发货的订单。这个属性在模板中使用,我通过将一个名为orderTable.component.html的文件添加到src/app/admin文件夹中来创建这个模板,其标记如清单 9-28 所示。

<div class="form-check">
    <label class="form-check-label">
    <input type="checkbox" class="form-check-input" [(ngModel)]="includeShipped"/>
        Display Shipped Orders
    </label>
</div>
<table class="table table-sm">
    <thead>
        <tr><th>Name</th><th>Zip</th><th colspan="2">Cart</th><th></th></tr>
    </thead>
    <tbody>
        <tr *ngIf="getOrders().length == 0">
            <td colspan="5">There are no orders</td>
        </tr>
        <ng-template ngFor let-o [ngForOf]="getOrders()">
            <tr>
                <td>{{o.name}}</td><td>{{o.zip}}</td>
                <th>Product</th><th>Quantity</th>
                <td>
                    <button class="btn btn-warning m-1" (click)="markShipped(o)">
                        Ship
                    </button>
                    <button class="btn btn-danger" (click)="delete(o.id)">
                        Delete
                    </button>
                </td>
            </tr>
            <tr *ngFor="let line of o.cart.lines">
                <td colspan="2"></td>
                <td>{{line.product.name}}</td>
                <td>{{line.quantity}}</td>
            </tr>
        </ng-template>
    </tbody>
</table>

Listing 9-28.The Contents of the orderTable.component.html File in the src/app/admin Folder

请记住,RESTful web 服务提供的数据在每次流程启动时都会被重置,这意味着您必须使用购物车并结帐来创建订单。完成后,您可以使用管理工具的 Orders 部分检查和管理它们,如图 9-7 所示。

img/421542_4_En_9_Fig7_HTML.jpg

图 9-7。

管理订单

摘要

在本章中,我创建了一个动态加载的 Angular 特征模块,其中包含管理产品目录和处理订单所需的管理工具。在下一章中,我将完成 SportsStore 应用,并准备将其部署到生产环境中。

十、SportsStore:先进的功能和部署

在这一章中,我通过添加允许 SportsStore 应用离线工作的渐进式功能来准备部署该应用,并向您展示如何准备和部署该应用到 Docker 容器中,该容器可以在大多数托管平台上使用。

准备示例应用

本章不需要准备,继续使用第九章中的 SportsStore 项目。

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

添加渐进式功能

渐进式 web 应用 (PWA)的行为更像本机应用,这意味着它可以在没有网络连接的情况下继续工作,其代码和内容被缓存,因此可以立即启动,并且可以使用通知等功能。渐进式 web 应用特性并不是 Angular 特有的,但是在接下来的章节中,我将渐进式特性添加到 SportsStore 应用中,向您展示这是如何实现的。

Tip

开发和测试 PWA 的过程可能很费力,因为只有在为生产而构建应用时才能完成,这意味着不能使用自动构建工具。

安装 PWA 包

Angular 团队提供了一个 NPM 包,可用于将 PWA 特性引入 Angular 项目。运行SportsStore文件夹中清单 10-1 所示的命令,下载并安装 PWA 包。

Tip

注意这个命令是ng add,而不是我在其他地方用来添加包的npm install命令。ng add命令专门用于安装软件包,如@angular/pwa,这些软件包被设计用于增强或重新配置 Angular 项目。

ng add @angular/pwa

Listing 10-1.Installing a Package

缓存数据 URL

@angular/pwa包配置应用,以便缓存 HTML、JavaScript 和 CSS 文件,这将允许应用在没有网络可用时启动。我还希望缓存产品目录,以便应用有数据呈现给用户。在清单 10-2 中,我在ngsw-config.json文件中添加了一个新的部分,用于为 Angular 应用配置 PWA 特性,并由@angular/pwa包添加到项目中。

{
  "index": "/index.html",
  "assetGroups": [{
    "name": "app",
    "installMode": "prefetch",
    "resources": {
      "files": [
        "/favicon.ico",
        "/index.html",
        "/*.css",
        "/*.js"
      ]
    }
  }, {
    "name": "assets",
    "installMode": "lazy",
    "updateMode": "prefetch",
    "resources": {
      "files": [
        "/assets/**",
        "/font/*"
      ]
    }
  }],
  "dataGroups": [
    {
        "name": "api-product",
        "urls": ["/api/products"],
        "cacheConfig" : {
            "maxSize": 100,
            "maxAge": "5d"
        }
    }],
    "navigationUrls": [
      "/**"
    ]
}

Listing 10-2.Caching the Data URLs in the ngsw-config.json File in the SportsStore Folder

当新版本可用时,运行应用所需的 PWA 代码和内容将被缓存和更新,确保使用配置文件的assetGroups部分中的配置,在更新可用时应用一致的更新。

使用配置文件的dataGroups部分缓存应用的数据,这允许使用自己的缓存设置管理数据。在这个清单中,我配置了缓存,使其包含来自 100 个请求的数据,并且这些数据在五天内有效。最后的配置部分是navigationUrls,它指定了将被定向到index.html文件的 URL 的范围。在这个例子中,我使用了通配符来匹配所有的 URL。

Note

我只是触及了您可以在 PWA 中使用的缓存功能的表面。有很多选择,包括尝试连接到网络,然后在没有连接的情况下返回到缓存数据。详见 https://angular.io/guide/service-worker-intro

响应连接变化

SportsStore 应用不是渐进式功能的理想候选,因为下单需要连接。当应用在没有连接的情况下运行时,为了避免用户混淆,我将禁用结帐过程。用于添加渐进式功能的 API 提供有关连接状态的信息,并在应用脱机和联机时发送事件。为了向应用提供其连接性的细节,我在src/app/model文件夹中添加了一个名为connection.service.ts的文件,并使用它来定义清单 10-3 中所示的服务。

import { Injectable } from "@angular/core";
import { Observable, Subject } from "rxjs";

@Injectable()
export class ConnectionService {
    private connEvents: Subject<boolean>;

    constructor() {
        this.connEvents = new Subject<boolean>();
        window.addEventListener("online",
            (e) => this.handleConnectionChange(e));
        window.addEventListener("offline",
            (e) => this.handleConnectionChange(e));
    }

    private handleConnectionChange(event) {
        this.connEvents.next(this.connected);
    }

    get connected() : boolean {
        return window.navigator.onLine;
    }

    get Changes(): Observable<boolean> {
        return this.connEvents;
    }
}

Listing 10-3.The Contents of the connection.service.ts File in the src/app/model Folder

该服务为应用的其余部分预设连接状态,通过浏览器的navigator.onLine属性获取状态,并响应onlineoffline事件,这些事件在连接状态改变时触发,并通过浏览器提供的addEventListener方法访问。在清单 10-4 中,我将新服务添加到数据模型的模块中。

import { NgModule } from "@angular/core";
import { ProductRepository } from "./product.repository";
import { StaticDataSource } from "./static.datasource";
import { Cart } from "./cart.model";
import { Order } from "./order.model";
import { OrderRepository } from "./order.repository";
import { RestDataSource } from "./rest.datasource";
import { HttpClientModule } from "@angular/common/http";
import { AuthService } from "./auth.service";
import { ConnectionService } from "./connection.service";

@NgModule({
  imports: [HttpClientModule],
  providers: [ProductRepository, Cart, Order, OrderRepository,
    { provide: StaticDataSource, useClass: RestDataSource },
    RestDataSource, AuthService, ConnectionService]
})
export class ModelModule { }

Listing 10-4.Adding a Service in the model.module.ts File in the src/app/model Folder

为了防止用户在没有连接的情况下结账,我更新了 cart detail 组件,以便它在其构造函数中接收连接服务,如清单 10-5 所示。

import { Component } from "@angular/core";
import { Cart } from "../model/cart.model";
import { ConnectionService } from "../model/connection.service";

@Component({
    templateUrl: "cartDetail.component.html"
})
export class CartDetailComponent {
    public connected: boolean = true;

    constructor(public cart: Cart, private connection: ConnectionService) {
        this.connected = this.connection.connected;
        connection.Changes.subscribe((state) => this.connected = state);
    }
}

Listing 10-5.Receiving a Service in the cartDetail.component.ts File in the src/app/store Folder

该组件定义了一个connected属性,该属性由服务设置,然后在收到更改时更新。为了完成这个特性,我修改了 checkout 按钮,使其在没有连接时被禁用,如清单 10-6 所示。

...
<div class="row">
  <div class="col">
  <div class="text-center">
    <button class="btn btn-primary m-1" routerLink="/store">
        Continue Shopping
    </button>
    <button class="btn btn-secondary m-1" routerLink="/checkout"
            [disabled]="cart.lines.length == 0 || !connected">
      {{  connected ?  'Checkout' : 'Offline' }}
    </button>
  </div>
</div>
...

Listing 10-6.Reflecting Connectivity in the cartDetail.component.html File in the src/app/store Folder

为部署准备应用

在接下来的小节中,我准备了 SportsStore 应用,以便可以对其进行部署。

创建数据文件

当我在第八章中创建 RESTful web 服务时,我为json-server包提供了一个 JavaScript 文件,它在服务器每次启动时执行,并确保总是使用相同的数据。这对生产没有帮助,所以我在SportsStore文件夹中添加了一个名为serverdata.json的文件,其内容如清单 10-7 所示。当json-server包被配置为使用 JSON 文件时,应用所做的任何更改都将被持久化。

{
    "products": [
        { "id": 1, "name": "Kayak", "category": "Watersports",
            "description": "A boat for one person", "price": 275 },
        { "id": 2, "name": "Lifejacket", "category": "Watersports",
            "description": "Protective and fashionable", "price": 48.95 },
        { "id": 3, "name": "Soccer Ball", "category": "Soccer",
            "description": "FIFA-approved size and weight", "price": 19.50 },
        { "id": 4, "name": "Corner Flags", "category": "Soccer",
            "description": "Give your playing field a professional touch",
            "price": 34.95 },
        { "id": 5, "name": "Stadium", "category": "Soccer",
            "description": "Flat-packed 35,000-seat stadium", "price": 79500 },
        { "id": 6, "name": "Thinking Cap", "category": "Chess",
            "description": "Improve brain efficiency by 75%", "price": 16 },
        { "id": 7, "name": "Unsteady Chair", "category": "Chess",
            "description": "Secretly give your opponent a disadvantage",
            "price": 29.95 },
        { "id": 8, "name": "Human Chess Board", "category": "Chess",
            "description": "A fun game for the family", "price": 75 },
        { "id": 9, "name": "Bling Bling King", "category": "Chess",
            "description": "Gold-plated, diamond-studded King", "price": 1200 }
    ],
    "orders": []
}

Listing 10-7.The Contents of the serverdata.json File in the SportsStore Folder

创建服务器

部署应用时,我将使用单个 HTTP 端口来处理对应用及其数据的请求,而不是我在开发中使用的两个端口。使用单独的端口在开发中更简单,因为这意味着我可以使用 Angular development HTTP 服务器,而不必集成 RESTful web 服务。Angular 没有为部署提供 HTTP 服务器,因为我必须提供一个,所以我将对它进行配置,使它能够处理两种类型的请求,并包括对 HTTP 和 HTTPS 连接的支持,如侧栏中所述。

Using Secure Connections for Progressive Web Applications

当您向应用添加渐进式功能时,您必须部署它,以便可以通过安全的 HTTP 连接访问它。如果你不这样做,渐进的功能将无法工作,因为底层技术——称为服务工作器——将不会被浏览器允许通过常规的 HTTP 连接。

您可以使用 localhost 测试渐进的特性,正如我稍后演示的那样,但是在部署应用时需要 SSL/TLS 证书。如果您没有证书,那么一个好的起点是 https://letsencrypt.org ,您可以在那里免费获得一个证书,尽管您应该注意,您还需要拥有您打算部署以生成证书的域或主机名。出于本书的目的,我将 SportsStore 应用及其渐进式功能部署到了 sportsstore.adam-freeman.com ,这是一个我用于开发测试和接收电子邮件的域。这不是一个提供公共 HTTP 服务的域,您将无法通过该域访问 SportsStore 应用。

运行SportsStore文件夹中清单 10-8 中所示的命令,安装创建 HTTP/HTTPS 服务器所需的包。

npm install --save-dev express@4.17.1
npm install --save-dev connect-history-api-fallback@1.6.0
npm install --save-dev https@1.0.0

Listing 10-8.Installing Additional Packages

我向 SportsStore 添加了一个名为server.js的文件,其内容如清单 10-9 所示,它使用新添加的包来创建一个 HTTP 和 HTTPS 服务器,该服务器包含将提供 RESTful web 服务的json-server功能。(json-server软件包是专门为集成到其他应用中而设计的。)

const express = require("express");
const https = require("https");
const fs = require("fs");
const history = require("connect-history-api-fallback");
const jsonServer = require("json-server");
const bodyParser = require('body-parser');
const auth = require("./authMiddleware");
const router = jsonServer.router("serverdata.json");

const enableHttps = false;

const ssloptions = {}

if (enableHttps) {
    ssloptions.cert =  fs.readFileSync("./ssl/sportsstore.crt");
    ssloptions.key = fs.readFileSync("./ssl/sportsstore.pem");
}

const app = express();

app.use(bodyParser.json());
app.use(auth);
app.use("/api", router);
app.use(history());
app.use("/", express.static("./dist/SportsStore"));

app.listen(80,
    () => console.log("HTTP Server running on port 80"));

if (enableHttps) {
    https.createServer(ssloptions, app).listen(443,
        () => console.log("HTTPS Server running on port 443"));
} else {
    console.log("HTTPS disabled")
}

Listing 10-9.The Contents of the server.js File in the SportsStore Folder

服务器可以从ssl文件夹中的文件读取 SSL/TLS 证书的详细信息,这是您应该放置证书文件的位置。如果您没有证书,那么您可以通过将enableHttps的值设置为false来禁用 HTTPS。您仍然可以使用本地服务器测试应用,但是您将无法在部署中使用渐进式功能。

更改 Repository 类中的 Web 服务 URL

既然 RESTful 数据和应用的 JavaScript 和 HTML 内容将由同一个服务器交付,我需要更改应用用来获取数据的 URL,如清单 10-10 所示。

import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Observable } from "rxjs";
import { Product } from "./product.model";
import { Cart } from "./cart.model";
import { Order } from "./order.model";
import { map } from "rxjs/operators";
import { HttpHeaders } from '@angular/common/http';

const PROTOCOL = "http";
const PORT = 3500;

@Injectable()
export class RestDataSource {
    baseUrl: string;
    auth_token: string;

    constructor(private http: HttpClient) {
        //this.baseUrl = `${PROTOCOL}://${location.hostname}:${PORT}/`;
        this.baseUrl = "/api/"
    }

    // ...methods omitted for brevity...
}

Listing 10-10.Changing the URL in the rest.datasource.ts File in the src/app/model Folder

构建和测试应用

要构建用于生产的应用,运行清单SportsStore文件夹中的 10-11 所示的命令。

ng build --prod

Listing 10-11.Building the Application for Production

该命令构建了应用的优化版本,而没有支持开发工具的附加功能。构建过程的输出放在dist/SportsStore文件夹中。除了 JavaScript 文件之外,还有一个从SportsStore/src文件夹中复制的index.html文件,修改后可以使用新构建的文件。

Note

Angular 提供了对服务器端渲染的支持,其中应用运行在服务器中,而不是浏览器中。这种技术可以改善应用启动时间的感知,并可以改善搜索引擎的索引。这是一个应该谨慎使用的功能,因为它有严重的局限性,会破坏用户体验。由于这些原因,我没有在本书中讨论服务器端渲染。您可以在 https://angular.io/guide/universal 了解更多关于此功能的信息。

构建过程可能需要几分钟才能完成。一旦构建就绪,运行清单 10-12 中的命令来启动 HTTP 服务器。如果您没有将服务器配置为使用有效的 SSL/TLS 证书,那么您应该在server.js文件中更改enableHttps常量的值,然后运行清单 10-12 中的命令。

node server.js

Listing 10-12.Starting the Production HTTP Server

一旦服务器启动,打开一个新的浏览器窗口并导航到http://localhost,您将看到如图 10-1 所示的熟悉内容。

img/421542_4_En_10_Fig1_HTML.jpg

图 10-1。

测试应用

测试渐进式功能

打开 F12 开发工具,导航到网络选项卡,点击在线右侧的箭头,选择离线,如图 10-2 所示。这模拟了一个没有连接的设备,但由于 SportsStore 是一个渐进式 web 应用,它已经被浏览器缓存了,连同它的数据。

img/421542_4_En_10_Fig2_HTML.jpg

图 10-2。

离线

一旦应用离线,单击浏览器的重新加载按钮,应用将从浏览器的缓存中加载。如果您单击“添加到购物车”按钮,您将看到“结帐”按钮被禁用,如图 10-3 所示。取消选中离线复选框,按钮的文本会改变,这样用户就可以下订单了。

img/421542_4_En_10_Fig3_HTML.jpg

图 10-3。

反映应用中的连接状态

容器化 SportsStore 应用

为了完成本章,我将为 SportsStore 应用创建一个容器,以便将其部署到生产中。在撰写本文时,Docker 是创建容器最流行的方式,它是 Linux 的精简版,功能仅够运行应用。大多数云平台或托管引擎都支持 Docker,其工具运行在最流行的操作系统上。

安装 Docker

第一步是在你的开发机器上下载并安装 Docker 工具,可以从 www.docker.com/products 获得。有适用于 macOS、Windows 和 Linux 的版本,也有一些适用于 Amazon 和 Microsoft 云平台的专门版本。Docker 桌面的免费社区版对于本章来说已经足够了。

准备应用

第一步是为 NPM 创建一个配置文件,该文件将用于下载应用在容器中使用所需的附加包。我在SportsStore文件夹中创建了一个名为deploy-package.json的文件,内容如清单 10-13 所示。

{
  "dependencies": {
      "@fortawesome/fontawesome-free": "5.12.1",
      "bootstrap": "4.4.1"
  },

  "devDependencies": {
    "json-server": "0.16.0",
    "jsonwebtoken": "8.5.1",
    "express": "4.17.1",
    "https": "1.0.0",
    "connect-history-api-fallback": "1.6.0"
  },

  "scripts": {
    "start":  "node server.js"
  }
}

Listing 10-13.The Contents of the deploy-package.json File in the SportsStore Folder

dependencies部分省略了 Angular 和所有其他运行时包,这些包是在项目创建时添加到package.json文件中的,因为构建过程将应用所需的所有 JavaScript 代码合并到了dist/SportsStore文件夹中的文件中。devDependencies部分包括生产 HTTP/HTTPS 服务器所需的工具。

设置了deploy-package.json文件的scripts部分,这样npm start命令将启动生产服务器,该服务器将提供对应用及其数据的访问。

创建 Docker 容器

为了定义容器,我在SportsStore文件夹中添加了一个名为Dockerfile(没有扩展名)的文件,并添加了清单 10-14 中所示的内容。

FROM node:12.15.0

RUN mkdir -p /usr/src/sportsstore

COPY dist/SportsStore /usr/src/sportsstore/dist/SportsStore
COPY ssl /usr/src/sportsstore/ssl

COPY authMiddleware.js /usr/src/sportsstore/
COPY serverdata.json /usr/src/sportsstore/
COPY server.js /usr/src/sportsstore/server.js
COPY deploy-package.json /usr/src/sportsstore/package.json

WORKDIR /usr/src/sportsstore

RUN npm install

EXPOSE 80

CMD ["node", "server.js"]

Listing 10-14.The Contents of the Dockerfile File in the SportsStore Folder

Dockerfile的内容使用用Node.js配置的基本映像,并复制运行应用所需的文件,包括包含应用的包文件和将用于安装在部署中运行应用所需的包的package.json文件。

为了加快容器化过程,我在SportsStore文件夹中创建了一个名为.dockerignore的文件,其内容如清单 10-15 所示。这告诉 Docker 忽略node_modules文件夹,这在容器中是不需要的,并且需要很长的处理时间。

node_modules

Listing 10-15.The Contents of the .dockerignore File in the SportsStore Folder

SportsStore文件夹中运行清单 10-16 中所示的命令,创建一个包含 SportsStore 应用以及它所需的所有工具和软件包的映像。

Tip

SportsStore 项目必须包含一个ssl目录,即使您尚未安装证书。这是因为当在Dockerfile中使用COPY命令时,无法检查文件是否存在。

docker build . -t sportsstore  -f  Dockerfile

Listing 10-16.Building the Docker Image

图像是容器的模板。当 Docker 处理 Docker 文件中的指令时,将下载并安装 NPM 包,并将配置和代码文件复制到映像中。

运行应用

一旦创建了映像,使用清单 10-17 中所示的命令创建并启动一个新的容器。

Tip

确保在启动 Docker 容器之前停止清单 10-12 中启动的测试服务器,因为两者都使用相同的端口来监听请求。

docker run -p 80:80 -p 443:443 sportsstore

Listing 10-17.Starting the Docker Container

您可以通过在浏览器中打开http://localhost来测试应用,这将显示运行在容器中的 web 服务器提供的响应,如图 10-4 所示。

img/421542_4_En_10_Fig4_HTML.jpg

图 10-4。

运行容器化 SportsStore 应用

要停止容器,运行清单 10-18 中所示的命令。

docker ps

Listing 10-18.Listing the Containers

您将看到一个正在运行的容器列表,如下所示(为简洁起见,我省略了一些字段):

CONTAINER ID        IMAGE               COMMAND                 CREATED
ecc84f7245d6        sportsstore         "docker-entrypoint.s…"  33 seconds ago

使用容器 ID 列中的值,运行清单 10-19 中所示的命令。

docker stop ecc84f7245d6

Listing 10-19.Stopping the Container

该应用已准备好部署到任何支持 Docker 的平台上,尽管只有在为应用部署到的域配置了 SSL/TLS 证书的情况下,渐进式功能才会起作用。

摘要

本章完成了 SportsStore 应用,展示了如何为部署准备 Angular 应用,以及将 Angular 应用放入 Docker 之类的容器有多容易。这部分书到此结束。在第二部分中,我开始深入研究细节,并向您展示我用来创建 SportsStore 应用的特性是如何深入工作的。

十一、了解 Angular 项目和工具

在这一章中,我解释了一个 Angular 项目的结构和用于开发的工具。在本章结束时,您将了解项目的各个部分是如何组合在一起的,并有一个应用后续章节中描述的更高级功能的基础。

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

创建新的 Angular 项目

您在第二章中安装的angular-cli包包含了创建一个新的 Angular 项目所需的所有功能,该项目包含一些占位符内容以启动开发,它还包含一组紧密集成的工具,用于构建、测试和准备 Angular 应用以进行部署。

要创建一个新的 Angular 项目,打开一个命令提示符,导航到一个方便的位置,并运行清单 11-1 中所示的命令。

ng new example --routing false --style css --skip-git --skip-tests

Listing 11-1.Creating a Project

ng new命令创建新项目,参数是项目名称,在本例中是exampleng new命令有一组参数,这些参数决定了所创建项目的形状;表 11-1 描述了最有用的。

表 11-1。

有用的 ng 新选项

|

争吵

|

描述

|
| --- | --- |
| --collection | 此选项指定 schematics 集合。Schematics 是用于创建项目和条目的模板,但不在本书中使用。 |
| --directory | 此选项用于指定项目目录的名称。它默认为项目名称。 |
| --dry-run | 此选项用于模拟项目创建过程,而不执行它。 |
| --force | 如果为真,此选项将覆盖任何现有文件。 |
| --inline-style | 此选项指定将使用组件中定义的样式而不是单独的 CSS 文件中定义的样式来配置项目。 |
| --inline-template | 此选项指定将使用组件中定义的模板而不是单独的 HTML 文件中定义的模板来配置项目。 |
| --minimal | 该选项创建一个项目,而不添加对测试框架的支持。 |
| --package-manager | 该选项用于指定软件包管理器,该管理器将用于下载和安装 Angular 所需的软件包。如果省略,将使用 NPM。其他选项有yarnpnpmcnpm。默认的包管理器适用于大多数项目。 |
| --prefix | 该选项将前缀应用于所有组件选择器,如“了解 Angular 应用如何工作”一节所述。 |
| --routing | 此选项用于在项目中创建路由模块。我会在第 25–27 章中详细解释路由功能的工作原理。 |
| --skip-git | 使用该选项可以防止在项目中创建 Git 存储库。如果创建项目时没有使用该选项,则必须安装 Git 工具。 |
| --skip-install | 此选项阻止下载和安装 Angular 应用和项目开发工具所需的包的初始操作。 |
| --skip-tests | 此选项阻止添加测试工具的初始配置。 |
| --style | 此选项指定如何处理样式表。在本书中,我使用了css选项,但是也支持流行的 CSS 预处理程序,比如 SCSS、萨斯等等。 |

ng new命令执行的项目初始化过程可能需要一些时间来完成,因为项目需要大量的包,既要运行 Angular 应用,也要运行我在本章中描述的开发和测试工具。

了解项目结构

使用您喜欢的代码编辑器打开example文件夹,您将看到如图 11-1 所示的文件和文件夹结构。该图显示了 Visual Studio Code 表示项目的方式;其他编辑可能会以不同的方式呈现项目内容。

img/421542_4_En_11_Fig1_HTML.jpg

图 11-1。

新 Angular 项目的内容

表 11-2 描述了通过ng new命令添加到新项目中的文件和文件夹,这些文件和文件夹为大多数 Angular 开发提供了起点。

表 11-2。

新 Angular 项目中的文件和文件夹

|

名字

|

描述

|
| --- | --- |
| e2e | 该文件夹包含用于端到端测试的文件,该文件被设置为使用量角器包。我在本书中不描述端到端测试,因为它需要额外的基础设施,但您可以在 www.protractortest.org 了解更多信息。 |
| node_modules | 该文件夹包含应用和 Angular 开发工具所需的 NPM 软件包,如“了解软件包文件夹”一节所述。 |
| src | 该文件夹包含应用的源代码、资源和配置文件,如“了解源代码文件夹”一节中所述。 |
| .editorconfig | 该文件包含配置文本编辑器的设置。并非所有的编辑器都响应此文件,但它可能会覆盖您定义的首选项。您可以在 http://editorconfig.org 了解更多关于可以在该文件中设置的编辑器设置。 |
| .gitignore | 该文件包含使用 Git 时从版本控制中排除的文件和文件夹的列表。 |
| angular.json | 该文件包含 Angular 开发工具的配置。 |
| package.json | 该文件包含应用和开发工具所需的 NPM 包的详细信息,并定义了运行开发工具的命令,如“了解包文件夹”一节中所述。 |
| package-lock.json | 该文件包含安装在node_modules文件夹中的所有软件包的版本信息,如“了解软件包文件夹”一节所述。 |
| README.md | 这是一个自述文件,包含开发工具的命令列表,这些命令在“使用开发工具”一节中进行了描述。 |
| tsconfig.json | 该文件包含 TypeScript 编译器的配置设置。在大多数 Angular 项目中,您不需要更改编译器配置。 |
| tstlint.json | 该文件包含 TypeScript linter 的设置,如“使用 linter”一节中所述。 |

您不会在每个项目中都需要所有这些文件,您可以删除不需要的文件。例如,我倾向于删除README.md.editorconfig.gitignore文件,因为我已经熟悉了工具命令,我不喜欢覆盖我的编辑器设置,并且我不使用 Git 进行版本控制。

了解源代码文件夹

src文件夹包含应用的文件,包括源代码和静态资产,比如图像。这个文件夹是大多数开发活动的焦点,图 11-2 显示了使用ng new命令创建的src文件夹的内容。

img/421542_4_En_11_Fig2_HTML.jpg

图 11-2。

src 文件夹的内容

app文件夹是您为应用添加定制代码和内容的地方,随着您添加特性,它的结构会变得更加复杂。其他文件支持开发过程,如表 11-3 所述。

表 11-3。

src 文件夹中的文件和文件夹

|

名字

|

描述

|
| --- | --- |
| app | 此文件夹包含应用的源代码和内容。该文件夹的内容是本书“了解 Angular 应用如何工作”一节和其他章节的主题。 |
| assets | 该文件夹用于应用所需的静态资源,如图像。 |
| environments | 该文件夹包含定义不同环境设置的配置文件。默认情况下,唯一的配置设置是production标志,当应用为部署而构建时,它被设置为true,如“了解应用引导”一节中所述。 |
| favicon.ico | 该文件包含一个图标,浏览器将在应用的选项卡中显示该图标。默认图像是 Angular 徽标。 |
| index.html | 这是在开发过程中发送到浏览器的 HTML 文件,如“理解 HTML 文档”一节所述。 |
| main.ts | 该文件包含执行时启动应用的 TypeScript 语句,如“了解应用引导”一节中所述。 |
| polyfills.ts | 该文件用于在项目中包含多填充,以提供对某些浏览器(尤其是 Internet Explorer)中原本不可用的功能的支持。 |
| styles.css | 该文件用于定义应用于整个应用的 CSS 样式。 |
| tests.ts | 这是 Karma 测试包的配置文件,我在第二十九章中描述过。 |

了解包文件夹

JavaScript 应用开发的世界依赖于一个丰富的包生态系统,其中一些包包含 Angular 框架,该框架将通过开发过程中在后台使用的小包发送到浏览器。一个 Angular 项目需要很多包;例如,本章开头创建的示例项目需要 850 多个包。

许多这样的包只有几行代码,但是它们之间有一个复杂的依赖层次,太大了以至于不能手工管理,所以使用了包管理器。包管理器得到了项目所需包的初始列表。然后检查这些包中的每一个包的依赖关系,这个过程一直继续,直到创建了完整的一组包。所有需要的包都被下载并安装在node_modules文件夹中。

使用dependenciesdevDependencies属性在package.json文件中定义初始的一组包。dependencies属性用于列出应用运行所需的包。下面是来自示例应用中的package.json文件的dependencies包,尽管您可能会在您的项目中看到不同的版本号:

...
"dependencies": {
  "@angular/animations": "~9.0.0",
  "@angular/common": "~9.0.0",
  "@angular/compiler": "~9.0.0",
  "@angular/core": "~9.0.0",
  "@angular/forms": "~9.0.0",
  "@angular/platform-browser": "~9.0.0",
  "@angular/platform-browser-dynamic": "~9.0.0",
  "@angular/router": "~9.0.0",
  "bootstrap": "⁴.4.1",
  "rxjs": "~6.5.4",
  "tslib": "¹.10.0",
  "zone.js": "~0.10.2"
},
...

大多数包提供 Angular 功能,少数支持包在后台使用。对于每个包,package.json文件包括可接受版本号的详细信息,使用表 11-4 中描述的格式。

表 11-4。

软件包版本编号系统

|

格式

|

描述

|
| --- | --- |
| 9.0.0 | 直接表示版本号将只接受具有精确匹配版本号的包,例如 9.0.0。 |
| * | 使用星号表示接受要安装的任何版本的软件包。 |
| >9.0.0 >=9.0.0 | 在版本号前面加上>或> =接受任何大于或等于给定版本的软件包版本。 |
| <9.0.0 <=9.0.0 | 在版本号前加上 |
| ~9.0.0 | 在版本号前加一个波浪号(~字符)接受要安装的版本,即使修补程序级别号(三个版本号中的最后一个)不匹配。例如,指定~9.0.0意味着您将接受版本 9.0.1 或 9.0.2(将包含版本 9.0.0 的补丁),但不接受版本 9.1.0(将是一个新的次要版本)。 |
| ⁹.0.0 | 在版本号前加一个插入符号(^字符)将接受版本,即使次要版本号(三个版本号中的第二个)或补丁号不匹配。例如,指定⁹.0.0 意味着您将接受版本 9.1.0 和 9.2.0,但不接受版本 10.0.0。 |

package.json文件的dependencies部分指定的版本号将接受较小的更新和补丁。当涉及到文件的devDependencies部分时,版本灵活性更加重要,该部分包含了开发所需的包的列表,但这些包不是最终应用的一部分。在示例应用的package.json文件的devDependencies部分列出了 19 个包,每个包都有自己的可接受版本范围。

...
"devDependencies": {
  "@angular-devkit/build-angular": "~0.900.1",
  "@angular/cli": "~9.0.1",
  "@angular/compiler-cli": "~9.0.0",
  "@angular/language-service": "~9.0.0",
  "@types/node": "¹².11.1",
  "@types/jasmine": "~3.5.0",
  "@types/jasminewd2": "~2.0.3",
  "codelyzer": "⁵.1.2",
  "jasmine-core": "~3.5.0",
  "jasmine-spec-reporter": "~4.2.1",
  "karma": "~4.3.0",
  "karma-chrome-launcher": "~3.1.0",
  "karma-coverage-istanbul-reporter": "~2.1.0",
  "karma-jasmine": "~2.0.1",
  "karma-jasmine-html-reporter": "¹.4.2",
  "protractor": "~5.4.3",
  "ts-node": "~8.3.0",
  "tslint": "~5.18.0",
  "typescript": "~3.7.5"
}
...

同样,您可能会看到不同的细节,但关键的一点是,包之间的依赖关系的管理太复杂,无法手动完成,而是委托给包管理器。使用最广泛的包管理器是 NPM,它安装在 Node.js 旁边,是本书第二章准备工作的一部分。

当你创建一个项目时,开发所需的所有包都被自动下载并安装到node_modules文件夹中,但是表 11-5 列出了一些你可能会发现在开发过程中有用的命令。所有这些命令都应该在项目文件夹中运行,这个文件夹包含了package.json文件。

表 11-5。

有用的 NPM 命令

|

命令

|

描述

|
| --- | --- |
| npm install | 该命令执行在package.json文件中指定的包的本地安装。 |
| npm install package@version | 该命令执行包的特定版本的本地安装,并更新package.json文件以将包添加到dependencies部分。 |
| npm install package@version --save-dev | 该命令执行包的特定版本的本地安装,并更新package.json文件以将包添加到devDependencies部分。 |
| npm install --global package@version | 此命令执行特定版本软件包的全局安装。 |
| npm list | 该命令列出了所有本地包及其依赖项。 |
| npm run <script name> | 该命令执行在package.json文件中定义的脚本之一,如下所述。 |
| npx package@version | 这个命令下载并执行一个包。 |

Understanding Global And Local Packages

NPM 可以安装特定于单个项目的包(称为本地安装,或者可以从任何地方访问(称为全局安装)。很少有软件包需要全局安装,但有一个例外,那就是本书第二章中安装的@angular/cli软件包。@angular-cli包需要全局安装,因为它用于创建新项目。项目所需的单个包被本地安装到node_modules文件夹中。

表 11-5 中描述的最后两个命令很奇怪,但是包管理器传统上包括对运行在package.json文件的scripts部分中定义的命令的支持。在 Angular 项目中,该特性用于提供对工具的访问,这些工具在开发过程中使用,并为应用的部署做准备。下面是示例项目中package.json文件的scripts部分:

...
"scripts": {
    "ng": "ng",
    "start": "ng serve",
    "build": "ng build",
    "test": "ng test",
    "lint": "ng lint",
    "e2e": "ng e2e"
},
...

表 11-6 总结了这些命令,我将在本章后面的章节或本书这一部分后面的章节中演示它们的用法。

表 11-6。

package.json 文件的脚本部分中的命令

|

名字

|

描述

|
| --- | --- |
| ng | 该命令运行ng命令,该命令提供对 Angular 开发工具的访问。 |
| start | 该命令启动开发工具,相当于ng serve命令。 |
| build | 该命令执行生产构建过程。 |
| test | 该命令启动单元测试工具,这些工具在第二十九章中有描述,它相当于ng test命令。 |
| lint | 这个命令启动 TypeScript linter,如“使用 linter”一节所述,它相当于ng lint命令。 |
| e2e | 该命令启动端到端测试工具,相当于ng e2e命令。 |

这些命令通过使用npm run后跟您需要的命令名来运行,并且必须在包含package.json文件的文件夹中完成。因此,如果您想在示例项目中运行lint命令,导航到example文件夹并键入npm run lint。使用命令ng lint可以得到相同的结果。

npx命令对于用一个命令下载和执行一个包很有用,我在本章后面的“运行生产构建”一节中会用到它。不是所有的包都被设置为使用npx,这是一个最近的特性。

使用开发工具

使用ng new命令创建的项目包括一套完整的开发工具,用于监控应用的文件,并在检测到变更时构建项目。运行example文件夹中清单 11-2 所示的命令,启动开发工具。

ng serve

Listing 11-2.Starting the Development Tools

命令启动构建过程,该过程在命令提示符下生成如下消息:

...
10% building modules 4/7 modules 3 active
...

在该过程结束时,您将看到已经创建的包的摘要,如下所示:

...
chunk {main} main.js, main.js.map (main) 9.29 kB [initial] [rendered]
chunk {polyfills} polyfills.js, polyfills.js.map (polyfills) 140 kB [initial] [rendered]
chunk {runtime} runtime.js, runtime.js.map (runtime) 6.15 kB [entry] [rendered]
chunk {styles} styles.js, styles.js.map (styles) 9.75 kB [initial] [rendered]
chunk {vendor} vendor.js, vendor.js.map (vendor) 2.67 MB [initial] [rendered]
Hash: 93087e33d02b97e55e3c - Time: 5540ms
** Angular Live Development Server is listening on localhost:4200, open your browser on http://localhost:4200/ **
: Compiled successfully.
...

了解开发 HTTP 服务器

为了简化开发过程,该项目包含了一个与构建过程紧密集成的 HTTP 服务器。在初始构建过程之后,HTTP 服务器被启动,并显示一条消息,告诉您正在使用哪个端口来侦听请求,如下所示:

...
** Angular Live Development Server is listening on localhost:4200, open your browser on http://localhost:4200/ **
...

默认端口是 4200,但是如果您已经在使用端口 4200,您可能会看到不同的消息。打开一个新的浏览器窗口,请求http://localhost:4200,你会看到ng new命令添加到项目中的占位符内容,如图 11-3 所示。

img/421542_4_En_11_Fig3_HTML.jpg

图 11-3。

使用 HTTP 开发服务器

ng serve命令构建应用并启动 HTTP 服务器,这样您就可以看到应用了。

了解构建过程

当您运行ng serve时,项目被构建,以便它可以被浏览器使用。这个过程需要三个重要的工具:TypeScript 编译器、Angular 编译器和一个名为 webpack 的包。

Angular 应用是使用包含表达式的 TypeScript 文件和 HTML 模板创建的,这两种文件浏览器都无法理解。TypeScript 编译器负责将 TypeScript 文件编译成 JavaScript,Angular 编译器负责将模板转换成 JavaScript 语句,这些语句在模板文件中创建 HTML 元素并计算它们包含的表达式。

构建过程通过 webpack 管理,web pack 是一个模块捆绑器,这意味着它从编译器获取 JavaScript 输出,并将其合并到一个可以发送到浏览器的模块中。这个过程被称为捆绑,这是对一个重要功能的平淡描述,它是开发 Angular 应用时您将依赖的关键工具之一,尽管您不会直接处理它,因为它是由 Angular 开发工具为您管理的。

当您运行清单 11-2 中的命令时,您会在 webpack 处理应用时看到一系列消息。Webpack 从main.ts文件中的代码开始,这是应用的入口点,并遵循它包含的import语句来发现它的依赖项,对每个有依赖项的文件重复这个过程。Webpack 通过import语句工作,编译每个声明了依赖关系的 TypeScript 和模板文件,为整个应用生成 JavaScript 代码。

Note

本节描述开发构建过程。有关准备应用进行部署的过程的详细信息,请参见“了解生产构建过程”一节。

编译过程的输出被组合成一个文件,称为。在捆绑过程中,webpack 会报告其在模块中的工作过程,并找到需要包含在捆绑包中的模块,如下所示:

...
10% building modules 4/7 modules 3 active
...

在该过程结束时,您将看到已经创建的包的摘要,如下所示:

...
chunk {main} main.js, main.js.map (main) 9.29 kB [initial] [rendered]
chunk {polyfills} polyfills.js, polyfills.js.map (polyfills) 140 kB [initial] [rendered]
chunk {runtime} runtime.js, runtime.js.map (runtime) 6.15 kB [entry] [rendered]
chunk {styles} styles.js, styles.js.map (styles) 9.75 kB [initial] [rendered]
chunk {vendor} vendor.js, vendor.js.map (vendor) 2.67 MB [initial] [rendered]
Hash: 93087e33d02b97e55e3c - Time: 5540ms
** Angular Live Development Server is listening on localhost:4200, open your browser on http://localhost:4200/ **
: Compiled successfully.
...

初始构建过程可能需要一段时间才能完成,因为生产了五个包,如表 11-7 中所述。(每个文件都有对应的.map文件,用来让浏览器中的 JavaScript 调试更容易。)

表 11-7。

由成 Angular 构建过程产生的束

|

名字

|

描述

|
| --- | --- |
| main.js | 该文件包含从src/app文件夹产生的编译输出。 |
| polyfills.js | 此文件包含目标浏览器不支持的应用使用的功能所需的 JavaScript 聚合填充。 |
| runtime.js | 该文件包含加载其他模块的代码。 |
| styles.js | 该文件包含添加应用全局 CSS 样式表的 JavaScript 代码。 |
| vendor.js | 该文件包含应用所依赖的第三方包,包括 Angular 包。 |

了解应用捆绑包

仅当第一次运行ng serve命令时,才执行完整的构建过程。此后,如果组成包的文件发生变化,包将被重新构建。您可以通过用清单 11-3 中所示的元素替换app.component.html文件的内容来看到这一点。

<div class="bg-primary text-center text-white p-2">
    Hello, World
</div>

Listing 11-3.Replacing the Contents of the app.component.html File in the src/app Folder

当您保存更改时,只有main.js包将被重新构建,您将在命令提示符下看到如下消息:

4 unchanged chunks
chunk {main} main.js, main.js.map (main) 9.39 kB [initial] [rendered]
Time: 140ms
: Compiled successfully.

有选择地编译文件和准备包可以确保开发过程中的变化效果可以很快看到。图 11-4 显示了清单 11-3 变更的效果。

img/421542_4_En_11_Fig4_HTML.jpg

图 11-4。

更改 main.js 包中使用的文件

Understanding Hot Reloading

在开发过程中,Angular 开发工具在main.js包中增加了对一个叫做热重装的特性的支持。这个特性意味着您可以自动看到清单 11-3 中的变化效果。添加到捆绑包中的 JavaScript 代码打开了一个返回到 Angular development HTTP 服务器的连接。当一个变更触发一个构建时,服务器通过 HTTP 连接发送一个信号,这将导致浏览器自动重新加载应用。

了解 Polyfills 包

默认情况下,Angular 构建过程以最新版本的浏览器为目标,如果您需要为旧浏览器提供支持,这可能会是一个问题(旧浏览器经常出现在企业应用中)。polyfills.js包用于为没有本地支持的旧版本提供 JavaScript 特性的实现。polyfills.js文件的内容由polyfills.ts文件决定,该文件可以在src文件夹中找到。默认情况下,仅启用一个聚合填充,这将启用 Zone.js 库,Angular 使用该库在浏览器中执行更改检测。通过将import语句添加到polyfills.ts文件,您可以将自己的聚合填充添加到包中。

Tip

您还可以通过编辑browserslist文件来修改编译器生成的 JavaScript 和 CSS,该文件通过ng new命令添加到项目文件夹中。该文件的主要用途是启用对 Internet Explorer 的支持,默认情况下是禁用的。

了解样式包

styles.js包用于将 CSS 样式表添加到应用中。包文件包含使用浏览器 API 定义样式的 JavaScript 代码,以及应用所需的 CSS 样式表的内容。(使用 JavaScript 来分发 CSS 文件似乎有悖常理,但它工作得很好,并且具有使应用自包含的优势,因此它可以作为一系列 JavaScript 文件进行部署,而不依赖于在部署 web 服务器上设置的附加资产。)

使用angular.json文件的样式部分将 CSS 样式表添加到应用中。运行清单 11-4 中所示的命令,将引导 CSS 包添加到项目中。

npm install bootstrap@4.4.1

Listing 11-4.Installing the Bootstrap CSS Package

主 CSS 样式表是node_modules/bootstrap/dist/css/bootstrap.min.css文件。要将样式表合并到应用中,将文件名添加到angular.json文件的styles部分,如清单 11-5 所示。

...
"architect": {
  "build": {
    "builder": "@angular-devkit/build-angular:browser",
    "options": {
      "outputPath": "dist/example",
      "index": "src/index.html",
      "main": "src/main.ts",
      "polyfills": "src/polyfills.ts",
      "tsConfig": "tsconfig.app.json",
      "aot": true,
      "assets": [
        "src/favicon.ico",
        "src/assets"
      ],
      "styles": [
        "src/styles.css",
        "node_modules/bootstrap/dist/css/bootstrap.min.css"
      ],
      "scripts": []
    },
...

Listing 11-5.Adding a Stylesheet in the angular.json File in the example Folder

angular.json文件中有两个styles部分,您必须确保将文件名添加到最靠近文件顶部的部分。开发工具没有检测到对angular.json文件的更改,所以通过键入Control+C来停止它们,并运行example文件夹中清单 11-6 中所示的命令来再次启动它们。

ng serve

Listing 11-6.Starting the Angular Development Tools

在初始启动期间,将创建一个新的styles.js包。如果浏览器没有重新连接到开发 HTTP 服务器,重新加载浏览器窗口,你会看到新样式的效果,如图 11-5 所示。(这些样式是由我添加到清单 11-3 中的div元素的类应用的。)

img/421542_4_En_11_Fig5_HTML.jpg

图 11-5。

添加样式表

最初的包只包含了src文件夹中的styles.css文件,默认情况下这个文件夹是空的。现在这个包包含了引导样式表,这个包要大得多,如构建消息所示。

...
chunk {styles} styles.js, styles.js.map (styles) 1.17 MB [initial] [rendered]
...

对于某些风格来说,这似乎是一个很大的文件,但是正如我在“理解产品构建过程”一节中解释的那样,只有在开发过程中才会有这么大。

使用棉绒

linter 是一种检查源代码的工具,以确保它符合一组编码约定和规则。用ng new命令创建的项目包括一个名为 TSLint 的 TypeScript linter,它支持的规则在 https://palantir.github.io/tslint 中描述,涵盖了从可能导致意外结果的常见错误到样式问题的所有内容。

您可以在tslint.json文件中启用和禁用林挺规则,许多规则都有配置选项,可以微调它们检测到的问题。为了演示 linter 是如何工作的,我对一个 TypeScript 文件做了两处修改,如清单 11-7 所示。

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

debugger;

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'example'
}

Listing 11-7.Making Changes in the app.component.ts File in the src/app Folder

我添加了一个debugger语句,并删除了语句末尾的分号,该语句为AppComponent类中的title属性设置值。这两个更改都违反了默认的TSLint规则,您可以通过打开一个新的命令提示符,导航到example项目文件夹,并使用清单 11-8 中所示的命令运行 linter 来查看结果。

ng lint

Listing 11-8.Running the TypeScript Linter

linter 检查项目中的 TypeScript 文件,并报告它遇到的任何问题。清单 11-7 中的更改导致以下消息:

...
ERROR: C:/Users/adam/Documents/Books/Pro Angular 9/Source Code/Current/example/src/app/app.component.ts:3:1 - Use of debugger statements is forbidden
ERROR: C:/Users/adam/Documents/Books/Pro Angular 9/Source Code/Current/example/src/app/app.component.ts:11:20 - Missing semicolon
ERROR: C:/Users/adam/Documents/Books/Pro Angular 9/Source Code/Current/example/src/app/app.module.ts:16:27 - file should end with a newline
Lint errors found in the listed files.
...

这些消息强调了我所做的更改,但是也报告了app.module.ts文件没有以换行符结尾,这是另一个默认规则。

林挺没有集成到常规的构建过程中,并且是手动执行的。林挺最常见的用途是在提交对版本控制系统的更改之前检查潜在的问题,尽管一些项目团队通过将它集成到其他过程中来更广泛地使用林挺工具。

您可能会发现有个别语句会导致 linter 报告错误,但您无法更改。您可以在代码中添加一个注释,告诉 linter 忽略下一行,而不是完全禁用该规则,如下所示:

...
// tslint:disable-next-line
...

如果您有一个充满问题的文件,但您无法进行更改(通常是因为应用的其他部分应用了约束),那么您可以通过在页面顶部添加以下注释来禁用整个文件的林挺:

...
/* tslint:disable */
...

这些注释允许您忽略那些不符合规则但不能更改的代码,同时仍然林挺项目的其余部分。

为了解决 linter 警告,我注释掉了调试器语句并恢复了app.component.ts文件中的分号,如清单 11-9 所示。我还在app.module.ts文件的末尾添加了一个换行符。

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

//debugger;

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'example';
}

Listing 11-9.Addressing Linting Warnings in the app.component.ts File in the src/app Folder

The Joy and Misery of Linting

Linters 可能是一个强大的工具,特别是在一个技术和经验水平参差不齐的开发团队中。Linters 可以检测导致意外行为或长期维护问题的常见问题和细微错误。一个很好的例子是 JavaScript 操作符=====之间的区别,当执行了错误类型的比较时,linter 会发出警告。我喜欢这种林挺,我喜欢在完成一个主要的应用特性之后,或者在将代码提交到版本控制之前,通过林挺过程运行我的代码。

但是棉绒也可能成为分裂和冲突的工具。除了检测编码错误之外,linters 还可以用于强制执行关于缩进、括号放置、分号和空格的使用以及许多其他样式问题的规则。大多数开发人员都有自己的风格偏好——我当然也有:我喜欢缩进四个空格,我喜欢左括号在同一行,以及它们所涉及的表达式。我知道一些程序员有不同的偏好,正如我知道那些人是完全错误的,总有一天会明白过来并开始正确地格式化他们的代码。

Linters 允许对格式有强烈看法的人强加给别人,通常打着“固执己见”的旗号,这可能会变得“令人讨厌”逻辑是,开发人员浪费时间争论不同的编码风格,每个人被迫以相同的方式编写会更好,这通常是观点强烈的人更喜欢的方式,并且忽略了这样一个事实,即开发人员只会争论其他事情,因为争论很有趣。

我尤其不喜欢林挺的格式,我认为这是分裂和不必要的。我经常在读者无法获得书籍示例时帮助他们(如果你需要帮助,我的电子邮件地址是adam@adam-freeman.com),我每周都会看到各种各样的编码风格。但我没有强迫读者按照我的方式编码,而是让我的代码编辑器将代码重新格式化为我喜欢的格式,这是每个有能力的编辑器都提供的功能。

我的建议是少用林挺,把注意力放在会引起真正问题的问题上。将格式化决策留给个人,当您需要阅读由具有不同偏好的团队成员编写的代码时,依靠代码编辑器进行重新格式化。

了解 Angular 应用的工作原理

当您第一次开始使用 Angular 时,它看起来像魔术一样,并且很容易因为害怕破坏某些东西而对项目文件进行修改。尽管 Angular 应用中有许多文件,但它们都有特定的用途,它们一起工作来完成一件远非神奇的事情:向用户显示 HTML 内容。在本节中,我将解释示例 Angular 应用是如何工作的,以及每个部分是如何实现最终结果的。

如果您在上一节中停止了 Angular 开发工具来运行 linter,运行在example文件夹中的清单 11-10 中显示的命令来再次启动它们。

ng serve

Listing 11-10.Starting the Angular Development Tools

一旦初始构建完成,使用浏览器请求http://localhost:4200,您将看到如图 11-6 所示的内容。

img/421542_4_En_11_Fig6_HTML.jpg

图 11-6。

运行示例应用

在接下来的小节中,我将解释如何将项目中的文件组合起来,以产生如图所示的响应。

理解 HTML 文档

运行应用的起点是位于src文件夹中的index.html文件。当浏览器向开发 HTTP 服务器发送请求时,它会收到这个文件,其中包含以下元素:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Example</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
  <app-root></app-root>
</body>
</html>

body只包含一个app-root元素,其用途很快就会清楚。index.html文件的内容在被发送到浏览器时被修改,以包含 JavaScript 文件的script元素,如下所示:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Example</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
  <app-root></app-root>
  <script src="runtime.js" type="module"></script>
  <script src="polyfills.js" type="module"></script>
  <script src="styles.js" type="module"></script>
  <script src="vendor.js" type="module"></script>
  <script src="main.js" type="module"></script></body>
</html>

Tip

您可能不熟悉 HTML 文件中用于script元素的type属性的module值。我在“理解差动加载”一节中解释了使用它的原因。

了解应用引导

浏览器按照它们的script元素出现的顺序执行 JavaScript 文件,从runtime.js文件开始,它包含处理其他 JavaScript 文件内容的代码。

接下来是polyfills.js文件,它包含实现浏览器不支持的特性的代码,然后是styles.js文件,它包含应用需要的 CSS 样式。vendor.js文件包含应用需要的第三方代码,包括 Angular 框架。该文件在开发过程中可能很大,因为它包含所有的 Angular 特征,即使应用不需要它们。优化过程用于为部署准备应用,如本章后面所述。

最后一个文件是main.js包,其中包含定制的应用代码。这个包的名称取自应用的入口点,即src文件夹中的main.ts文件。一旦处理完其他包文件,就会执行main.ts文件中的语句来初始化 Angular 并运行应用。下面是由ng new命令创建的main.ts文件的内容:

import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { AppModule } from './app/app.module';
import { environment } from './environments/environment';

if (environment.production) {
  enableProdMode();
}

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

import语句声明了对其他 JavaScript 模块的依赖,提供了对 Angular 特性的访问(对@angular模块的依赖,包含在vendor.js文件中)和应用中的定制代码(AppModule依赖)。最后一个导入是针对环境设置的,它用于为开发、测试和生产平台创建不同的配置设置,如以下代码:

...
if (environment.production) {
  enableProdMode();
}
...

Angular 有一个生产模式,该模式禁用一些在开发过程中执行的有用检查,这些检查将在后面的章节中介绍。启用生产模式意味着提高性能,也意味着检查结果不会在浏览器的 JavaScript 控制台中报告,用户可以在那里看到它们。通过调用从@angular/core模块导入的enableProdMode函数启用生产模式。

为确定是否应启用生产模式,执行检查以查看environment.production是否为true。该检查对应于src/environments文件夹中的environment.prod.ts文件的内容,它设置该值,并在构建应用以准备部署时应用。结果是,如果应用是为生产而构建的,那么生产模式将被启用,但在其余时间被禁用。

文件中剩下的语句负责启动应用。

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

platformBrowserDynamic函数初始化用于网络浏览器的 Angular 平台,从@angular/platform-browser-dynamic模块导入。Angular 被设计成可以在一系列不同的环境中运行,调用platformBrowserDynamic函数是在浏览器中启动应用的第一步。

下一步是调用bootstrapModule方法,该方法接受应用的角根模块,默认为AppModuleAppModulesrc/app文件夹中的app.module.ts文件导入,在下一节描述。bootstrapModule方法为 Angular 提供了进入应用的入口点,并代表了@angular模块提供的功能与项目中的定制代码和内容之间的桥梁。该语句的最后一部分使用catch关键字通过将引导错误写入浏览器的 JavaScript 控制台来处理这些错误。

了解根部 Angular 模块

术语模块在 Angular 应用中有双重作用,指的是 JavaScript 模块和 Angular 模块。JavaScript 模块用于跟踪应用中的依赖关系,并确保浏览器只接收它需要的代码。Angular 模块用于配置 Angular 应用的一部分。

每个应用都有一个 Angular 模块,负责向 Angular 描述应用。对于用ng new命令创建的应用,根模块被称为AppModule,它被定义在src/app文件夹的app.module.ts文件中,包含以下代码:

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

import { AppComponent } from './app.component';

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

AppModule类没有定义任何成员,但是它通过其@NgModule装饰器的配置属性为 Angular 提供了基本信息。我将在后面的章节中描述用于配置 Angular 模块的不同属性,但是现在感兴趣的是bootstrap属性,它告诉 Angular 它应该加载一个名为AppComponent的组件,作为应用启动过程的一部分。组件是 Angular 应用中的主要构件,名为AppComponent的组件提供的内容将显示给用户。

理解 Angular 分量

由根 Angular 模块选择的名为AppComponent的组件定义在src/app文件夹的app.component.ts文件中。下面是app.component.ts文件的内容,我在本章前面编辑了它来演示林挺:

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

//debugger;

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'example';
}

@Component装饰器的属性配置了它的行为。属性告诉 Angular 这个组件将被用来替换一个名为app-root的 HTML 元素。templateUrlstyleUrls属性告诉 Angular,组件想要呈现给用户的 HTML 内容可以在一个名为app.component.html的文件中找到,应用于 HTML 内容的 CSS 样式在一个名为app.component.css的文件中定义(尽管 CSS 文件在新项目中是空的)。

下面是app.component.html文件的内容,我在本章的前面编辑了它来演示热重载和 CSS 样式的使用:

<div class="bg-primary text-center text-white p-2">
  Hello, World
</div>

该文件包含常规的 HTML 元素,但是,正如您将了解到的,Angular 特性是通过使用定制的 HTML 元素或通过向常规 HTML 元素添加属性来应用的。

了解内容显示

当应用启动时,Angular 处理index.html文件,定位与根组件的selector属性匹配的元素,并用根组件的templateUrlstyleUrls属性指定的文件内容替换它。这是使用浏览器为 JavaScript 应用提供的域对象模型(DOM) API 完成的,只有在浏览器窗口中右键单击并从弹出菜单中选择 Inspect,才能看到更改,产生以下结果:

<html lang="en"><head>
  <meta charset="utf-8">
  <title>Example</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  <style>/* You can add global styles to this file, and also import other
         style files */ sourceMappingURL=data:application/json;base64,
         eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbInNyYyyBmaWxlLCBhbmQgYWxzbyBpb
         Igc3R5bGUgZmlsZXMgKi9cbiJdfQ== */
  </style>
  <style>
     /*!
      * Bootstrap v4.4.1 (https://getbootstrap.com/)
      * Copyright 2011-2019 The Bootstrap Authors
      ...
  </style>
  <style>/*#sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uI
           jozLCJzb3VyY2VzIjpbXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IiIsImZpb
           cmMvYXBwL2FwcC5jb21wb25lbnQuY3NzIn0= */
  </style>
</head>
<body>
    <app-root _nghost-utj-c11="" ng-version="9.0.0">
        <div _ngcontent-utj-c11="" class="bg-primary text-center text-white p-2">
            Hello, World
        </div>
    </app-root>
    <script src="runtime.js" type="module"></script>
    <script src="polyfills.js" type="module"></script>
    <script src="styles.js" type="module"></script>
    <script src="vendor.js" type="module"></script>
    <script src="main.js" type="module"></script>
</body>
</html>

app-root元素包含组件模板中的div元素,属性在初始化过程中由 Angular 添加。

style元素表示app文件夹中的styles.css文件、src/app文件夹中的app.component.css文件以及使用清单 11-5 中的angular.json文件添加的引导 CSS 样式表的内容。styles.cssapp.component.css文件是由ng new命令创建的空文件,但是我已经删除了大部分引导 CSS 样式,因为太多了,无法在此列出。动态生成的div元素与清单 11-3 中指定的class属性的组合产生了如图 11-7 所示的结果。

img/421542_4_En_11_Fig7_HTML.jpg

图 11-7。

显示组件的内容

了解生产构建流程

在开发过程中,重点是快速编译,以便结果可以尽快显示在浏览器中,从而实现良好的迭代开发过程。在开发过程中,编译器和捆绑器不应用任何优化,这就是捆绑文件如此之大的原因。大小并不重要,因为浏览器与服务器运行在同一台机器上,并且会立即加载。

在部署应用之前,它是使用优化过程构建的。要运行这种类型的构建,运行示例文件夹中清单 11-11 中所示的命令。

ng build --prod

Listing 11-11.Performing the Production Build

这个命令执行生产编译过程,它生成的包更小,只包含应用所需的代码。您可以在编译器生成的消息中看到包的详细信息。

...
Generating ES5 bundles for differential loading...
ES5 bundle generation complete.

chunk {2} polyfills-es2015.ca64e4516afbb1b890d5.js (polyfills) 35.6 kB [initial] [rendered]
chunk {3} polyfills-es5.277e2e1d6fb2daf91a5c.js (polyfills-es5) 127 kB [initial] [rendered]
chunk {1} main-es2015.c38e85d415502ff8dc25.js (main) 102 kB [initial] [rendered]
chunk {1} main-es5.c38e85d415502ff8dc25.js (main) 122 kB [initial] [rendered]
chunk {0} runtime-es2015.0811dcefd377500b5b1a.js (runtime) 1.45 kB [entry] [rendered]
chunk {0} runtime-es5.0811dcefd377500b5b1a.js (runtime) 1.45 kB [entry] [rendered]
chunk {4} styles.18138bb15891daf44583.css (styles) 154 kB [initial] [rendered]
...

热重装等特性不会添加到包中,并且不再生产大的vendor.js包。相反,main.js包包含应用和它所依赖的第三方代码部分。对于大多数应用,这意味着生产main.js文件只包含应用使用的 Angular 特征,而vendor.js文件包含所有 Angular 包。

了解提前编译

开发构建过程将 decoratorss 留在输出中,decorator 描述了 Angular 应用的构建块。然后这些被浏览器中的 Angular 运行时转换成 API 调用,这就是所谓的实时 (JIT)编译。生产构建过程启用了一个名为的提前 (AOT)编译特性,该特性可以转换装饰器,这样就不必在每次应用运行时都这样做。

结合其他构建优化,结果是一个加载和启动速度更快的 Angular 应用。缺点是额外的编译需要时间,如果您在开发期间启用优化构建,这可能会令人沮丧。

了解差异负载

生产构建过程为每个包生成两个 JavaScript 文件,以支持一个名为差异加载的特性。支持现代语言特性的浏览器可以使用这个包的较小版本。不支持最新 JavaScript 特性的旧浏览器使用大版本的捆绑包,它表达了相同的特性,但用更详细的代码表达,并添加了一些内容来解决缺少的语言特性。Angular 并没有向所有浏览器提供冗长的代码,而是生成了两组包文件,这样,main-es2015.c38e85d415502ff8dc25.js文件包含了现代浏览器将使用的简洁版本的代码,而main-es5.c38e85d415502ff8dc25.js文件包含了旧浏览器可以运行的更冗长的代码。(文件名的c38e85d415502ff8dc25部分是一个校验和,确保新的包版本不会被缓存和意外使用,称为缓存破坏。)

生产包文件是在dist/example文件夹中创建的,同时还有一个生产版本的index.html文件,其中包含用于差异加载的script元素,如下所示:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Example</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="stylesheet" href="styles.18138bb15891daf44583.css"></head>
<body>
  <app-root></app-root>
  <script src="runtime-es2015.0811dcefd377500b5b1a.js" type="module"></script>
  <script src="runtime-es5.0811dcefd377500b5b1a.js" nomodule defer></script>
  <script src="polyfills-es5.277e2e1d6fb2daf91a5c.js" nomodule defer></script>
  <script src="polyfills-es2015.ca64e4516afbb1b890d5.js" type="module"></script>
  <script src="main-es2015.c38e85d415502ff8dc25.js" type="module"></script>
  <script src="main-es5.c38e85d415502ff8dc25.js" nomodule defer></script>
</body>
</html>

现代浏览器的包文件有一个设置为moduletype属性。

...
<script src="runtime-es2015.0811dcefd377500b5b1a.js" type="module"></script>
...

老版本的浏览器不理解这个值,不会处理这些script元素。相反,他们将使用其他元素,这些元素具有nomoduledefer属性。

...
<script src="runtime-es5.0811dcefd377500b5b1a.js" nomodule defer></script>
...

这些元素将被现代浏览器忽略,这意味着每个类别的浏览器都获得正确的文件集。

Tip

请注意,在产品构建过程中,CSS 样式被放在带有文件扩展名.css的标准样式表中,而不是放在开发过程中生成的 JavaScript 文件中。

表 11-8 显示了在开发过程中,在现代浏览器的生产中,以及在旧浏览器的生产中,包文件的总大小。

表 11-8。

总束尺寸

|

目标

|

总尺寸

|
| --- | --- |
| 发展 | 2.8MB |
| 生产(旧浏览器) | 404KB |
| 生产(现代浏览器) | 293KB |

您可以看到生产构建过程产生了更小的包,现代浏览器接收的文件比遗留浏览器更小,即使对于一个简单的示例项目也是如此。然而,优化过程需要时间,这就是为什么在开发过程中不使用它。

运行生产版本

为了测试生产版本,运行example文件夹中清单 11-12 所示的命令。

npx http-server@0.12.1 dist/example --port 5000

Listing 11-12.Running the Production Build

这个命令将下载并执行 0.12.1 版的http-server包,它提供了一个简单的、自包含的 HTTP 服务器。该命令告诉http-server包提供dist/example文件夹的内容,并监听端口 5000 上的请求。打开一个新的 web 浏览器并请求http://localhost:5000,您将看到示例应用的生产版本,如图 11-8 所示(尽管,除非您检查浏览器发送的获取包文件的 HTTP 请求,否则您不会看到与前面图中所示的开发版本有任何不同)。

img/421542_4_En_11_Fig8_HTML.jpg

图 11-8。

运行生产版本

一旦测试了生产版本,使用Control+C停止 HTTP 服务器。

在 Angular 项目中开始开发

您已经看到了 Angular 应用的初始构件是如何组合在一起的,以及引导过程是如何将内容显示给用户的。在这一节中,我将向项目添加一个简单的数据模型,这是大多数开发人员的典型起点,并向组件添加一些特性,而不仅仅是本章前面添加的静态内容。

创建数据模型

在应用的所有构建块中,数据模型是 Angular 规范最少的一个。在应用的其他地方,Angular 要求应用特定的装饰器或使用 API 的部分,但对模型的唯一要求是它提供对应用所需数据的访问;如何做到这一点的细节以及数据看起来像什么是留给开发人员的。

这可能感觉有点奇怪,并且很难知道如何开始,但是,在它的核心,模型可以分为三个部分。

  • 描述模型中数据的类

  • 加载和保存数据的数据源,通常保存到服务器

  • 允许操作模型中的数据的存储库

在接下来的部分中,我创建了一个简单的模型,它提供了我在接下来的章节中描述 Angular 特征所需的功能。

创建描述性模型类

顾名思义,描述类描述了应用中的数据。在一个真实的项目中,通常会有很多类来完整地描述应用所操作的数据。从本章开始,我将创建一个简单的类作为数据模型的基础。我在src/app文件夹中添加了一个名为product.model.ts的文件,代码如清单 11-13 所示。

Understanding Angular Schematics

Schematics 是智能模板,可用于创建从简单文件到完整项目的所有内容。例如,ng new命令使用原理图特征创建一个项目。使用ng new命令创建的项目还包括默认的 schematics,这些 schematics 充当向 Angular 项目添加通常需要的项目的模板,例如组件和 TypeScript 类。

schematics 功能很聪明,而且 schematics 很有用,但前提是您了解 Angular 的工作原理。在本书中,示例所需的所有文件都是显式创建的,因此您可以看到每个文件是如何使用的,并理解每个文件在 Angular 中扮演的角色。如果您想了解更多关于 schematics 的信息,那么运行ng generate命令,这将显示可用的 schematics 列表。一旦你理解了 Angular 是如何工作的,你会发现 schematics 很方便,但是在此之前,我建议你手动添加文件到你的项目中。

文件名遵循 Angular 描述性命名惯例。名称的productmodel部分告诉您这是数据模型中与产品相关的部分,而.ts扩展名表示一个 TypeScript 文件。您不必遵循这种约定,但是 Angular 项目通常包含许多文件,并且隐晦的名称使得在源代码中导航很困难。

export class Product {

    constructor(public id?: number,
        public name?: string,
        public category?: string,
        public price?: number) { }
}

Listing 11-13.The Contents of the product.model.ts File in the src/app Folder

Product类定义了产品标识符、产品名称、类别和价格的属性。属性被定义为可选的构造函数参数,如果你使用 HTML 表单创建对象,这是一个有用的方法,我将在第十四章中演示。

创建数据源

数据源为应用提供数据。最常见的数据源使用 HTTP 从 web 服务请求数据,我在第二十四章中描述了这一点。对于这一章,我需要一些更简单的东西,我可以在每次应用启动时重置为已知状态,以确保您从示例中获得预期的结果。我用清单 11-14 中所示的代码在src/app文件夹中添加了一个名为datasource.model.ts的文件。

import { Product } from "./product.model";

export class SimpleDataSource {
    private data: Product[];

    constructor() {
        this.data = new Array<Product>(
            new Product(1, "Kayak", "Watersports", 275),
            new Product(2, "Lifejacket", "Watersports", 48.95),
            new Product(3, "Soccer Ball", "Soccer", 19.50),
            new Product(4, "Corner Flags", "Soccer", 34.95),
            new Product(5, "Thinking Cap", "Chess", 16));
    }

    getData(): Product[] {
        return this.data;
    }
}

Listing 11-14.The Contents of the datasource.model.ts File in the src/app Folder

该类中的数据是硬连线的,这意味着当浏览器重新加载时,应用中所做的任何更改都将丢失。这在真实的应用中毫无用处,但是对于书本上的例子来说非常理想。

创建模型库

完成简单模型的最后一步是定义一个存储库,该存储库将提供对来自数据源的数据的访问,并允许在应用中对其进行操作。我在src/app文件夹中添加了一个名为repository.model.ts的文件,并用它来定义清单 11-15 中所示的类。

import { Product } from "./product.model";
import { SimpleDataSource } from "./datasource.model";

export class Model {
    private dataSource: SimpleDataSource;
    private products: Product[];
    private locator = (p: Product, id: number) => p.id == id;

    constructor() {
        this.dataSource = new SimpleDataSource();
        this.products = new Array<Product>();
        this.dataSource.getData().forEach(p => this.products.push(p));
    }

    getProducts(): Product[] {
        return this.products;
    }

    getProduct(id: number): Product {
        return this.products.find(p => this.locator(p, id));
    }

    saveProduct(product: Product) {
        if (product.id == 0 || product.id == null) {
            product.id = this.generateID();
            this.products.push(product);
        } else {
            let index = this.products
                .findIndex(p => this.locator(p, product.id));
            this.products.splice(index, 1, product);
        }
    }

    deleteProduct(id: number) {
        let index = this.products.findIndex(p => this.locator(p, id));
        if (index > -1) {
            this.products.splice(index, 1);
        }
    }

    private generateID(): number {
        let candidate = 100;
        while (this.getProduct(candidate) != null) {
            candidate++;
        }
        return candidate;
    }
}

Listing 11-15.The Contents of the repository.model.ts File in the src/app Folder

Model类定义了一个构造函数,它从数据源类获取初始数据,并通过一组方法提供对它的访问。这些方法是存储库定义的典型方法,如表 11-9 所述。

表 11-9。

Web 窗体代码块的类型

|

名字

|

描述

|
| --- | --- |
| getProducts | 该方法返回一个包含模型中所有Product对象的数组。 |
| getProduct | 这个方法根据 ID 返回一个单独的Product对象。 |
| saveProduct | 该方法更新一个现有的Product对象或向模型添加一个新的对象。 |
| deleteProduct | 该方法根据 ID 从模型中删除一个Product对象。 |

存储库的实现可能看起来很奇怪,因为数据对象存储在标准的 JavaScript 数组中,但是由Model类定义的方法呈现数据,就好像它是由id属性索引的Product对象的集合。在为模型数据编写存储库时,有两个主要考虑事项。首先,它应该尽可能高效地呈现将要显示的数据。对于示例应用,这意味着以一种可以迭代的形式呈现模型中的所有数据,比如数组。这很重要,因为迭代会经常发生,正如我在后面的章节中解释的那样。Model类的其他操作效率较低,但会较少使用。

第二个要考虑的是能够呈现 Angular 要处理的不变数据。我在第十三章中解释了为什么这很重要,但是就实现存储库而言,这意味着getProducts方法在被多次调用时应该返回相同的对象,除非其他方法之一或应用的另一部分对getProducts方法提供的数据进行了更改。如果一个方法每次返回一个不同的对象,即使它们是包含相同对象的不同数组,Angular 也会报告一个错误。考虑到这两点意味着实现存储库的最佳方式是将数据存储在一个数组中,并接受低效率。

创建组件和模板

模板包含组件想要呈现给用户的 HTML 内容。模板的范围可以从单个 HTML 元素到复杂的内容块。

为了创建一个模板,我在src/app文件夹中添加了一个名为template.html的文件,并添加了清单 11-16 中所示的 HTML 元素。

<div class="bg-info text-white m-2 p-2">
    There are {{model.getProducts().length}} products in the model
</div>

Listing 11-16.The Contents of the template.html File in the src/app Folder

这个模板的大部分是标准 HTML,但是双括号字符之间的部分(div元素中的{{}})是数据绑定的一个例子。当显示模板时,Angular 将处理其内容,发现绑定,并评估它包含的表达式,以产生将由数据绑定显示的内容。

支持模板所需的逻辑和数据由其组件提供,该组件是一个应用了@Component装饰器的 TypeScript 类。为了给清单 11-16 中的模板提供一个组件,我在src/app文件夹中添加了一个名为component.ts的文件,并定义了清单 11-17 中所示的类。

import { Component } from "@angular/core";
import { Model } from "./repository.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();
}

Listing 11-17.The Contents of the component.ts File in the src/app Folder

@Component装饰器配置组件。属性指定了指令将被应用到的 HTML 元素,即app。在@Component指令中的templateUrl属性指定了将被用作app元素内容的内容,对于这个例子,这个属性指定了template.html文件。

组件类,在这个例子中是ProductComponent,负责为模板提供绑定所需的数据和逻辑。ProductComponent类定义了一个名为model的属性,它提供了对Model对象的访问。

我用于组件选择器的app元素与ng new命令在创建项目时使用的元素不同,它应该出现在index.html文件中。在清单 11-18 中,我修改了index.html文件以引入一个app元素来匹配清单 11-17 中的组件选择器。

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Example</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
  <app></app>
</body>
</html>

Listing 11-18.Changing the Custom Element in the index.html File in the app Folder

这不是你在实际项目中需要做的事情,但它进一步证明了 Angular 应用以简单和可预测的方式组合在一起,你可以改变你需要或想要的任何部分。

配置根部 Angular 模块

我在上一节中创建的组件不会成为应用的一部分,直到我将它注册到根 Angular 模块。在清单 11-19 中,我使用了import关键字来导入组件,并且使用了@NgModule配置属性来注册组件。

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

//import { AppComponent } from './app.component';
import { ProductComponent } from "./component";

@NgModule({
  declarations: [ProductComponent],
  imports: [BrowserModule],
  providers: [],
  bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 11-19.Registering a Component in the app.module.ts File in the src/app Folder

我在import语句中使用了名称ProductComponent,并将该名称添加到了declarations数组中,该数组配置了应用中的一组组件和其他特性。我还更改了bootstrap属性的值,以便新组件是应用启动时使用的组件。

运行example文件夹中清单 11-20 所示的命令,启动 Angular 开发工具。

ng serve

Listing 11-20.Starting the Angular Development Tools

一旦初始构建过程完成,使用 web 浏览器请求http://localhost:4200,这将产生如图 11-9 所示的响应。

img/421542_4_En_11_Fig9_HTML.jpg

图 11-9。

新组件和模板的效果

执行了标准的 Angular 引导序列,但是使用了我在上一节中创建的定制组件和模板,而不是创建项目时设置的组件和模板。

摘要

在这一章中,我创建了一个 Angular 项目,并用它来介绍它包含的工具,并解释一个简单的 Angular 应用是如何工作的。在下一章,我将从数据绑定开始深入研究细节。

十二、使用数据绑定

前一章中的示例应用包含一个显示给用户的简单模板,该模板包含一个数据绑定,该数据绑定显示数据模型中有多少对象。在这一章中,我将描述 Angular 提供的基本数据绑定,并演示如何使用它们来生成动态内容。在后面的章节中,我将描述更高级的数据绑定,并解释如何用自定义特性扩展 Angular 绑定系统。表 12-1 将数据绑定放在上下文中。

表 12-1。

将数据绑定放在上下文中

|

问题

|

回答

|
| --- | --- |
| 它们是什么? | 数据绑定是嵌入到模板中的表达式,用于在 HTML 文档中生成动态内容。 |
| 它们为什么有用? | 数据绑定提供了 HTML 文档和模板文件中的 HTML 元素与应用中的数据和代码之间的链接。 |
| 它们是如何使用的? | 数据绑定作为 HTML 元素的属性或字符串中的特殊字符序列来应用。 |
| 有什么陷阱或限制吗? | 数据绑定包含简单的 JavaScript 表达式,计算这些表达式可以生成内容。主要的缺陷是在绑定中包含太多的逻辑,因为这样的逻辑不能在应用的其他地方正确地测试或使用。数据绑定表达式应该尽可能简单,并依赖组件(和其他有 Angular 的特性,如管道)来提供复杂的应用逻辑。 |
| 有其他选择吗? | 不。数据绑定是 Angular 开发的重要组成部分。 |

表 12-2 总结了本章内容。

表 12-2。

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 在 HTML 文档中动态显示数据 | 定义数据绑定 | 1–4 |
| 配置 HTML 元素 | 使用标准属性或特性绑定 | 5, 8 |
| 设置元素的内容 | 使用字符串插值绑定 | 6, 7 |
| 配置元素被分配到的类 | 使用类绑定 | 9–13 |
| 配置应用于元素的各个样式 | 使用样式绑定 | 14–17 |
| 手动触发数据模型更新 | 使用浏览器的 JavaScript 控制台 | 18, 19 |

为本章做准备

对于这一章,我继续使用第十一章中的示例项目。为了准备本章,我向组件类添加了一个方法,如清单 12-1 所示。

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

import { Component } from "@angular/core";
import { Model } from "./repository.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();

    getClasses(): string {
        return this.model.getProducts().length == 5 ? "bg-success" : "bg-warning";
    }
}

Listing 12-1.Adding a Method in the component.ts File in the src/app Folder

example文件夹中运行以下命令,启动 Angular 开发工具:

ng serve

打开一个新的浏览器并导航到http://localhost:4200以查看将要显示的内容,如图 12-1 所示。

img/421542_4_En_12_Fig1_HTML.jpg

图 12-1。

运行示例应用

了解单向数据绑定

单向数据绑定用于为用户生成内容,是 Angular 模板中使用的基本特征。术语单向指的是数据向一个方向流动的事实,这意味着数据从组件数据绑定流动,以便可以在模板中显示。

Tip

还有其他类型的 Angular 数据绑定,我将在后面的章节中介绍。事件绑定从另一个方向流动,从模板中的元素到应用的其余部分,它们允许用户交互。双向绑定允许数据双向流动,最常用于表单中。其他绑定详见 13 和 14 章节。

为了开始单向数据绑定,我替换了模板的内容,如清单 12-2 所示。

<div [ngClass]="getClasses()" >
    Hello, World.
</div>

Listing 12-2.The Contents of the template.html File in the src/app Folder

当您保存对模板的更改时,开发工具将重新构建应用并触发浏览器重新加载,显示如图 12-2 所示的输出。

img/421542_4_En_12_Fig2_HTML.jpg

图 12-2。

使用单向数据绑定

这是一个简单的例子,但它展示了数据绑定的基本结构,如图 12-3 所示。

img/421542_4_En_12_Fig3_HTML.jpg

图 12-3。

数据绑定的剖析

数据绑定有以下四个部分:

  • 主机元素是 HTML 元素,绑定将通过改变它的外观、内容或行为来影响它。

  • 方括号告诉 Angular 这是一个单向数据绑定。当 Angular 在数据绑定中看到方括号时,它将计算表达式,并将结果传递给绑定的目标,以便修改主机元素。

  • 目标指定绑定将做什么。有两种不同类型的目标:一个指令或者一个属性绑定

  • 表达式是 JavaScript 的一个片段,使用模板的组件来提供上下文,这意味着组件的属性和方法可以包含在表达式中,就像示例绑定中的getClasses方法一样。

查看清单 12-2 中的绑定,您可以看到主机元素是一个div元素,这意味着这是绑定想要修改的元素。表达式调用组件的getClasses方法,该方法在本章开始时定义。该方法根据数据模型中对象的数量返回一个包含引导 CSS 类的字符串。

...
getClasses(): string {
    return this.model.getProducts().length == 5 ? "bg-success" : "bg-warning";
}
...

如果数据模型中有五个对象,那么方法返回bg-success,这是一个应用绿色背景的引导类。否则,该方法返回bg-warning,这是一个应用琥珀色背景的引导类。

数据绑定的目标是一个指令,它是一个专门为支持数据绑定而编写的类。Angular 附带了一些有用的内置指令,您可以创建自己的指令来提供定制功能。内置指令的名称以ng开头,这告诉您ngClass目标是内置指令之一。目标通常给出指令做什么的指示,顾名思义,ngClass指令将在一个或多个类中添加或移除主机元素,当表达式被求值时,这些类的名称被返回。

综上所述,数据绑定将根据数据模型中项的数量将div元素添加到bg-successbg-warning类中。

由于应用启动时模型中有五个对象(因为初始数据被硬编码到第十一章中创建的SimpleDataSource类中),getClasses方法返回bg-success并产生如图 12-3 所示的结果,给div元素添加绿色背景。

了解绑定目标

当 Angular 处理数据绑定的目标时,它首先检查它是否匹配一个指令。大多数应用将依赖 Angular 提供的内置指令和提供应用特定功能的自定义指令的混合。您通常可以判断指令何时是数据绑定的目标,因为名称将是独特的,并给出了该指令的一些用途。内置指令可以通过前缀ng来识别。清单 12-2 中的绑定提示您,目标是一个内置指令,与宿主元素的类成员资格相关。为了快速参考,表 12-3 描述了基本的内置 Angular 指令以及它们在本书中的描述位置。(在后面的章节中还描述了其他指令,但这些是最简单的,也是您最常使用的。)

表 12-3。

基本的内置 Angular 指令

|

名字

|

描述

|
| --- | --- |
| ngClass | 该指令用于将宿主元素分配给类,如“设置类和样式”一节中所述。 |
| ngStyle | 该指令用于设置单个样式,如“设置类和样式”一节所述。 |
| ngIf | 如第十三章所述,该指令用于在表达式的值为true时在 HTML 文档中插入内容。 |
| ngFor | 该指令为数据源中的每一项在 HTML 文档中插入相同的内容,如第十三章所述。 |
| ngSwitchngSwitchCasengSwitchDefault | 这些指令用于根据表达式的值选择插入 HTML 文档的内容块,如第十三章所述。 |
| ngTemplateOutlet | 该指令用于重复内容块,如第十三章所述。 |

了解属性绑定

如果绑定目标与指令不对应,那么 Angular 会检查目标是否可以用来创建属性绑定。有五种不同类型的属性绑定,在表 12-4 中列出,以及详细描述它们的细节。

表 12-4。

Angular 属性绑定

|

名字

|

描述

|
| --- | --- |
| [property] | 这是标准属性绑定,用于在表示文档对象模型(DOM)中的主机元素的 JavaScript 对象上设置属性,如“使用标准属性和属性绑定”一节中所述。 |
| [attr.name] | 这是属性绑定,用于设置没有 DOM 属性的宿主 HTML 元素的属性值,如“使用属性绑定”一节所述。 |
| [class.name] | 这是特殊的类属性绑定,用于配置宿主元素的类成员资格,如“使用类绑定”一节所述。 |
| [style.name] | 这是特殊的样式属性绑定,用于配置宿主元素的样式设置,如“使用样式绑定”一节所述。 |

理解表达

数据绑定中的表达式是 JavaScript 代码的一个片段,对其求值以提供目标值。表达式可以访问组件定义的属性和方法,这就是清单 12-2 中的绑定如何能够调用getClasses方法来为ngClass指令提供主机元素应该添加到的类的名称。

表达式不限于调用方法或从组件读取属性;他们还可以执行大多数标准的 JavaScript 操作。作为一个例子,清单 12-3 显示了一个表达式,它有一个与getClasses方法的结果连接的字符串值。

<div [ngClass]="'text-white m-2 p-2 ' + getClasses()" >
    Hello, World.
</div>

Listing 12-3.Performing an Operation in the template.html File in the src/app Folder

表达式用双引号括起来,这意味着字符串文字必须用单引号定义。JavaScript 连接操作符是+字符,表达式的结果将是两个字符串的组合,如下所示:

text-white m-2 p-2 bg-success

其效果是,ngClass指令将主机元素添加到四个类中,text-whitem-2p-2,Bootstrap 使用这些类来设置文本颜色,并在元素内容周围添加边距和填充;和bg-success,它设置背景颜色。图 12-4 显示了这两类的组合。

img/421542_4_En_12_Fig4_HTML.jpg

图 12-4。

在 JavaScript 表达式中组合类

编写表达式时很容易忘乎所以,在模板中包含复杂的逻辑。这可能会导致问题,因为表达式不会被 TypeScript 编译器检查,也不容易进行单元测试,这意味着在部署应用之前,错误很可能不会被发现。为了避免这个问题,表达式应该尽可能简单,并且理想情况下,只用于从组件中检索数据并将其格式化以供显示。所有复杂的检索和处理逻辑都应该在组件或模型中定义,在那里可以对其进行编译和测试。

理解括号

方括号([]字符)告诉 Angular 这是一个单向数据绑定,其中有一个表达式应该被求值。如果您省略了括号并且目标是一个指令,Angular 仍然会处理绑定,但是不会计算表达式,并且引号字符之间的内容将作为文字值传递给指令。清单 12-4 用一个没有方括号的绑定向模板添加了一个元素。

<div [ngClass]="'text-white m-2 p-2 ' + getClasses()">
  Hello, World.
</div>
<div ngClass="'text-white m-2 p-2 ' + getClasses()">
  Hello, World.
</div>

Listing 12-4.Omitting the Brackets in a Data Binding in the template.html File in the src/app Folder

如果您在浏览器的 DOM 查看器中检查 HTML 元素(通过在浏览器窗口中右键单击并从弹出菜单中选择 Inspect 或 Inspect Element),您将看到它的class属性已被设置为文字字符串,如下所示:

class="'text-white m-2 p-2 ' + getClasses()"

浏览器将尝试处理主机元素被分配到的类,但是元素的外观不会像预期的那样,因为类与 Bootstrap 使用的名称不对应。这是一个常见的错误,所以首先要检查一个绑定是否没有达到您预期的效果。

方括号并不是 Angular 在数据绑定中使用的唯一方括号。为了快速参考,表 12-5 提供了一套完整的括号,每个括号的含义,以及它们的详细描述。

表 12-5。

尖括号

|

名字

|

描述

|
| --- | --- |
| [target]="expr" | 方括号表示单向数据绑定,数据从表达式流向目标。这种绑定的不同形式是本章的主题。 |
| {{expression}} | 这是字符串插值绑定,在“使用字符串插值绑定”一节中有所描述。 |
| (target) ="expr" | 圆括号表示单向绑定,数据从目标流向表达式指定的目的地。这是用于处理事件的绑定,如第十四章所述。 |
| [(target)] ="expr" | 这种括号的组合——被称为盒中香蕉——表示一种双向绑定,其中数据在由表达式指定的目标和目的地之间双向流动,如第十四章中所述。 |

了解主体元素

宿主元素是数据绑定中最简单的部分。数据绑定可以应用于模板中的任何 HTML 元素,一个元素可以有多个绑定,每个绑定可以管理元素外观或行为的不同方面。在后面的示例中,您将看到具有多个绑定的元素。

使用标准属性和特性绑定

如果绑定的目标与指令不匹配,Angular 将尝试应用属性绑定。接下来的部分描述了最常见的属性绑定:标准属性绑定和属性绑定。

使用标准属性绑定

浏览器使用文档对象模型来表示 HTML 文档。HTML 文档中的每个元素,包括 host 元素,都使用 DOM 中的 JavaScript 对象来表示。像所有 JavaScript 对象一样,用于表示 HTML 元素的对象也有属性。这些属性用于管理元素的状态,例如,value属性用于设置input元素的内容。当浏览器解析 HTML 文档时,它会遇到每个新的 HTML 元素,在 DOM 中创建一个对象来表示它,并使用元素的属性来设置对象属性的初始值。

标准属性绑定允许您使用表达式的结果为表示宿主元素的对象设置属性值。例如,将一个绑定的目标设置为value将会设置一个input元素的内容,如清单 12-5 所示。

<div [ngClass]="'text-white m-2 p-2 ' + getClasses()">
  Hello, World.
</div>
<div class="form-group m-2">
  <label>Name:</label>
  <input class="form-control" [value]="model.getProduct(1)?.name || 'None'" />
</div>

Listing 12-5.Using the Standard Property Binding in the template.html File in the src/app Folder

本例中的新绑定指定value属性应该绑定到一个表达式的结果,该表达式调用数据模型上的一个方法,通过指定一个键从存储库中检索数据对象。有可能没有带有那个键的数据对象,在这种情况下,存储库方法将返回null

为了防止将null用于主机元素的 value 属性,绑定使用空条件操作符(?字符)来安全地导航该方法返回的结果,如下所示:

...
<input class="form-control" [value]="model.getProduct(1)?.name || 'None'" />
...

如果来自getProduct方法的结果不是null,那么表达式将读取name属性的值并将其用作结果。但是如果方法的结果是null,那么name属性将不会被读取,空合并操作符(||字符)将会把结果设置为None

Getting to Know the HTML Element Properties

使用属性绑定可能需要做一些工作来确定需要设置哪个属性,因为 HTML 规范中存在不一致的地方。大多数属性的名称与设置其初始值的属性的名称相匹配,例如,如果您习惯于在一个input元素上设置value属性,那么您可以通过设置value属性达到相同的效果。但是有些属性名和属性名不匹配,有些属性根本不是由属性配置的。

Mozilla Foundation 为所有用于在 DOM 中表示 HTML 元素的对象提供了一个有用的参考。对于每个元素,Mozilla 提供了可用属性的摘要以及每个属性的用途。从HTMLElement ( developer.mozilla.org/en-US/docs/Web/API/HTMLElement)开始,它提供了所有元素共有的功能。然后,您可以分支到特定元素的对象中,比如用于表示input元素的HTMLInputElement

当您保存对模板的修改时,浏览器会重新加载并显示一个input元素,其内容是模型库中关键字为1的数据对象的name属性,如图 12-5 所示。

img/421542_4_En_12_Fig5_HTML.jpg

图 12-5。

使用标准属性绑定

使用字符串插值绑定

Angular 提供了一个特殊版本的标准属性绑定,称为字符串插值绑定,用于在宿主元素的文本内容中包含表达式结果。要理解这种特殊绑定为什么有用,考虑一下如何使用标准属性绑定来设置元素的内容会有所帮助。属性用于设置 HTML 元素的内容,这意味着元素的内容可以使用数据绑定来设置,如清单 12-6 所示。

<div [ngClass]="'text-white m-2 p-2 ' + getClasses()"
          [textContent]="'Name: ' + (model.getProduct(1)?.name || 'None')">
</div>
<div class="form-group m-2">
  <label>Name:</label>
  <input class="form-control" [value]="model.getProduct(1)?.name || 'None'" />
</div>

Listing 12-6.Setting an Element’s Content in the template.html File in the src/app Folder

新绑定中的表达式将一个文字字符串与一个方法调用的结果连接起来,以设置div元素的内容。

这个例子中的表达式很难写,需要特别注意引号、空格和括号,以确保输出中显示预期的结果。对于更复杂的绑定来说,这个问题变得更糟,因为在静态内容块中散布着多个动态值。

字符串插值绑定通过允许在元素内容中定义表达式片段简化了这个过程,如清单 12-7 所示。

<div [ngClass]="'text-white m-2 p-2 ' + getClasses()">
  Name: {{ model.getProduct(1)?.name || 'None' }}
</div>
<div class="form-group m-2">
  <label>Name:</label>
  <input class="form-control" [value]="model.getProduct(1)?.name || 'None'" />
</div>

Listing 12-7.Using the String Interpolation Binding in the template.html File in the src/app Folder

字符串插值绑定使用成对的花括号({{}})来表示。一个元素可以包含多个字符串插值绑定。

Angular 将 HTML 元素的内容与方括号的内容结合起来,为textContent属性创建一个绑定。结果和清单 12-6 一样,如图 12-6 所示,但是编写绑定的过程更简单,更不容易出错。

img/421542_4_En_12_Fig6_HTML.jpg

图 12-6。

使用字符串插值绑定

使用属性绑定

HTML 和 DOM 规范中有一些奇怪的地方,这意味着并非所有的 HTML 元素属性在 DOM API 中都有等价的属性。对于这些情况,Angular 提供了属性绑定,用于设置主机元素的属性,而不是设置在 DOM 中表示它的 JavaScript 对象的值。

最常用的没有相应属性的属性是colspan,它用于设置一个td元素在一个表中所占的列数。清单 12-8 展示了如何使用属性绑定来根据数据模型中对象的数量设置colspan元素。

<div [ngClass]="'text-white m-2 p-2 ' + getClasses()">
  Name: {{model.getProduct(1)?.name || 'None'}}
</div>
<div class="form-group m-2">
  <label>Name:</label>
  <input class="form-control" [value]="model.getProduct(1)?.name || 'None'" />
</div>
<table class="table table-sm table-bordered table-striped mt-2">
    <tr>
        <th>1</th><th>2</th><th>3</th><th>4</th><th>5</th>
    </tr>
    <tr>
        <td [attr.colspan]="model.getProducts().length">
            {{model.getProduct(1)?.name || 'None'}}
        </td>
    </tr>
</table>

Listing 12-8.Using an Attribute Binding in the template.html File in the src/app Folder

属性绑定是通过定义一个目标来应用的,该目标在属性的名称前加上前缀attr.(术语attr,后跟一个句点)。在清单中,我使用了属性绑定来设置表中一个td元素上的colspan元素的值,如下所示:

...
<td [attr.colspan]="model.getProducts().length">
...

Angular 将计算表达式并将colspan属性的值设置为结果。由于数据模型是从五个数据对象开始的,结果是colspan属性创建了一个跨越五列的表格单元,如图 12-7 所示。

img/421542_4_En_12_Fig7_HTML.jpg

图 12-7。

使用属性绑定

设置类别和样式

Angular 在属性绑定中为将宿主元素分配给类以及配置单个样式属性提供了特殊支持。我将在接下来的章节中描述这些绑定,以及提供密切相关特性的ngClassngStyle指令的细节。

使用类绑定

有三种不同的方法可以使用数据绑定来管理元素的类成员资格:标准属性绑定、特殊类绑定和ngClass指令。表 12-6 中描述了这三种方法,每种方法的工作方式略有不同,在不同的情况下都很有用,如下文所述。

表 12-6。

Angular 类绑定

|

例子

|

描述

|
| --- | --- |
| <div [class]="expr"></div> | 此绑定计算表达式,并使用结果替换任何现有的类成员身份。 |
| <div [class.myClass]="expr"></div> | 这个绑定对表达式求值,并使用结果来设置元素的成员资格myClass。 |
| <div [ngClass]="map"></div> | 该绑定使用 map 对象中的数据设置多个类的类成员资格。 |

用标准绑定设置元素的所有类

标准属性绑定可用于在一个步骤中设置元素的所有类,这在组件中有一个方法或属性以单个字符串返回元素所属的所有类(名称用空格分隔)时非常有用。清单 12-9 显示了组件中getClasses方法的修改,它基于Product对象的price属性返回不同的类名字符串。

import { Component } from "@angular/core";
import { Model } from "./repository.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();

    getClasses(key: number): string {
        let product = this.model.getProduct(key);
        return "p-2 " + (product.price < 50 ? "bg-info" : "bg-warning");
    }
}

Listing 12-9.Providing All Classes in a Single String in the component.ts File in the src/app Folder

来自getClasses方法的结果将包括p-2类,它为所有的Product对象在主机元素的内容周围添加填充。如果price属性的值小于 50,结果中将包含bg-info类,如果值大于等于 50,将包含bg-warning类(这些类设置不同的背景颜色)。

Tip

您必须确保类名由空格分隔。

清单 12-10 显示了模板中使用的标准属性绑定,使用组件的getClasses方法设置主机元素的class属性。

<div class="text-white m-2">
  <div [class]="getClasses(1)">
    The first product is {{model.getProduct(1).name}}.
  </div>
  <div [class]="getClasses(2)">
    The second product is {{model.getProduct(2).name}}
  </div>
</div>

Listing 12-10.Setting Class Memberships in the template.html File in the src/app Folder

当使用标准属性绑定来设置class属性时,表达式的结果将替换元素所属的任何以前的类,这意味着只有当绑定表达式返回所有需要的类时,才能使用它,如本例所示,产生如图 12-8 所示的结果。

img/421542_4_En_12_Fig8_HTML.jpg

图 12-8。

设置类成员资格

使用特殊的类绑定设置单个类

特殊的类绑定提供了比标准属性绑定更细粒度的控制,并允许使用表达式管理单个类的成员资格。如果您希望在元素的现有类成员基础上构建,而不是完全替换它们,这将非常有用。清单 12-11 展示了特殊类绑定的使用。

<div class="text-white m-2">
  <div [class]="getClasses(1)">
    The first product is {{model.getProduct(1).name}}.
  </div>
  <div class="p-2"
       [class.bg-success]="model.getProduct(2).price < 50"
       [class.bg-info]="model.getProduct(2).price >= 50">
    The second product is {{model.getProduct(2).name}}
  </div>
</div>

Listing 12-11.Using the Special Class Binding in the template.html File in the src/app Folder

这个特殊的类绑定是用一个 target 指定的,target 组合了术语class,后跟一个句点,再跟一个被管理成员的类名。在清单中,有两个特殊的类绑定,它们管理bg-successbg-info类的成员。

如果表达式的结果是真值,那么特殊的类绑定会将主机元素添加到指定的类中(如“理解真值和假值”侧栏中所述)。在这种情况下,如果price属性小于 50,则主机元素将是bg-success类的成员,如果价格属性大于等于 50,则主机元素将是bg-info类的成员。

这些绑定相互独立,不会干扰元素所属的任何现有类,例如p-2类,Bootstrap 使用它在元素内容周围添加填充。

Understanding Truthy and Falsy

JavaScript 有一个奇怪的特性,表达式的结果可能是真的,也可能是假的,这为粗心的人提供了一个陷阱。以下结果总是假的:

  • false ( boolean)值

  • 0 ( number)值

  • 空字符串("")

  • null

  • undefined

  • NaN(特殊数值)

所有其他值都是真实的,这可能会令人困惑。例如,"false"(内容为单词false的字符串)为 truthy。避免混淆的最好方法是只使用评估为booleantruefalse的表达式。

使用 nclass 指令设置类

ngClass指令是标准和特殊属性绑定的一种更灵活的替代方式,根据表达式返回的数据类型表现不同,如表 12-7 所述。

表 12-7。

ngClass 指令支持的表达式结果类型

|

名字

|

描述

|
| --- | --- |
| String | 主机元素被添加到由字符串指定的类中。多个类由空格分隔。 |
| Array | 数组中的每个对象都是宿主元素将被添加到的类的名称。 |
| Object | 对象上的每个属性都是一个或多个类的名称,用空格分隔。如果属性值为 true,则宿主元素将被添加到类中。 |

字符串和数组特性很有用,但是使用对象(称为映射)来创建复杂的类成员关系策略的能力使得ngClass指令特别有用。清单 12-12 显示了返回地图对象的组件方法的添加。

import { Component } from "@angular/core";
import { Model } from "./repository.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();

    getClasses(key: number): string {
        let product = this.model.getProduct(key);
        return "p-2 " + (product.price < 50 ? "bg-info" : "bg-warning");
    }

    getClassMap(key: number): Object {
        let product = this.model.getProduct(key);
        return {
            "text-center bg-danger": product.name == "Kayak",
            "bg-info": product.price < 50
        };
    }
}

Listing 12-12.Returning a Class Map Object in the component.ts File in the src/app Folder

getClassMap方法返回一个对象,该对象的属性值是一个或多个类名,其值基于Product对象的属性值,该对象的键被指定为方法参数。例如,当密钥为 1 时,该方法返回此对象:

...
{
  "text-center bg-danger":true,
  "bg-info":false
}
...

第一个属性将主机元素分配给text-center类(Bootstrap 使用它来水平居中文本)和bg-danger类(它设置元素的背景颜色)。第二个属性的值为false,这意味着主机元素不会被添加到bg-info类中。指定一个不会导致元素被添加到类中的属性可能看起来很奇怪,但是,您很快就会看到,表达式的值会自动更新以反映应用中的变化,并且能够定义一个以这种方式指定成员资格的 map 对象可能会很有用。

清单 12-13 显示了getClassMap和它返回的映射对象,这些对象被用作针对ngClass指令的数据绑定的表达式。

<div class="text-white m-2">
  <div class="p-2" [ngClass]="getClassMap(1)">
    The first product is {{model.getProduct(1).name}}.
  </div>
  <div class="p-2" [ngClass]="getClassMap(2)">
    The second product is {{model.getProduct(2).name}}.
  </div>
  <div class="p-2" [ngClass]="{'bg-success': model.getProduct(3).price < 50,
                                'bg-info': model.getProduct(3).price >= 50}">
        The third product is {{model.getProduct(3).name}}
  </div>
</div>

Listing 12-13.Using the ngClass Directive in the template.html File in the src/app Folder

前两个div元素有使用getClassMap方法的绑定。第三个div元素显示了另一种方法,即在模板中定义地图。对于这个元素,bg-infobg-warning类的成员关系与Product对象的价格属性的值相关联,如图 12-9 所示。应该小心使用这种技术,因为表达式包含不容易测试的 JavaScript 逻辑。

img/421542_4_En_12_Fig9_HTML.jpg

图 12-9。

使用 nclass 指令

使用样式绑定

有三种不同的方法可以使用数据绑定来设置主机元素的样式属性:标准属性绑定、特殊样式绑定和ngStyle指令。表 12-8 中描述了这三种方法,并在以下章节中进行了演示。

表 12-8。

有 Angular 的样式绑定

|

例子

|

描述

|
| --- | --- |
| <div [style.myStyle]="expr"></div> | 这是标准的属性绑定,用于将单个样式属性设置为表达式的结果。 |
| <div [style.myStyle.units]="expr"></div> | 这是特殊的样式绑定,它允许将样式值的单位指定为目标的一部分。 |
| <div [ngStyle]="map"></div> | 此绑定使用地图对象中的数据设置多个样式属性。 |

设置单一样式属性

标准属性绑定和特殊样式绑定用于设置单个样式属性的值。这些绑定之间的区别在于,标准属性绑定必须包含样式所需的单元,而特殊绑定允许将单元包含在绑定目标中。为了演示不同之处,清单 12-14 向组件添加了两个新属性。

import { Component } from "@angular/core";
import { Model } from "./repository.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();

    getClasses(key: number): string {
        let product = this.model.getProduct(key);
        return "p-2 " + (product.price < 50 ? "bg-info" : "bg-warning");
    }

    getClassMap(key: number): Object {
        let product = this.model.getProduct(key);
        return {
            "text-center bg-danger": product.name == "Kayak",
            "bg-info": product.price < 50
        };
    }

    fontSizeWithUnits: string = "30px";
    fontSizeWithoutUnits: string= "30";
}

Listing 12-14.Adding Properties in the component.ts File in the src/app Folder

fontSizeWithUnits属性返回一个值,该值包括一个数量和表示该数量的单位:30 像素。属性只返回数量,没有任何单位信息。清单 12-15 展示了如何将这些属性用于标准和特殊绑定。

Caution

不要试图使用标准属性绑定来针对style属性设置多个样式值。由表示 DOM 中主机元素的 JavaScript 对象的style属性返回的对象是只读的。有些浏览器会忽略这一点,允许进行更改,但结果是不可预测的,也是不可靠的。如果你想设置多个样式属性,那么为每个属性创建一个绑定或者使用ngStyle指令。

<div class="text-white m-2">
  <div class="p-2 bg-warning">
    The <span [style.fontSize]="fontSizeWithUnits">first</span>
    product is {{model.getProduct(1).name}}.
  </div>
  <div class="p-2 bg-info">
    The <span [style.fontSize.px]="fontSizeWithoutUnits">second</span>
    product is {{model.getProduct(2).name}}
  </div>
</div>

Listing 12-15.Using Style Bindings in the template.html File in the src/app Folder

绑定的目标是style.fontSize,它设置用于主机元素内容的字体大小。这个绑定的表达式使用了fontSizeWithUnits属性,其值包括设置字体大小所需的单位px像素。

特殊绑定的目标是style.fontSize.px,它告诉 Angular 表达式的值指定了像素数。这允许绑定使用组件的fontSizeWithoutUnits属性,它不包括单元。

Tip

您可以使用 JavaScript 属性名称格式([style.fontSize])或 CSS 属性名称格式([style.font-size])来指定样式属性。

两个绑定的结果是一样的,都是将span元素的字体大小设置为 30 像素,产生如图 12-10 所示的结果。

img/421542_4_En_12_Fig10_HTML.jpg

图 12-10。

设置单个样式属性

使用 ngStyle 指令设置样式

ngStyle指令允许使用一个地图对象设置多个样式属性,类似于ngClass指令的工作方式。清单 12-16 显示了添加一个组件方法,返回一个包含样式设置的地图。

import { Component } from "@angular/core";
import { Model } from "./repository.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();

    getClasses(key: number): string {
        let product = this.model.getProduct(key);
        return "p-2 " + (product.price < 50 ? "bg-info" : "bg-warning");
    }

    getStyles(key: number) {
        let product = this.model.getProduct(key);
        return {
            fontSize: "30px",
            "margin.px": 100,
            color: product.price > 50 ? "red" : "green"
        };
    }
}

Listing 12-16.Creating a Style Map Object in the component.ts File in the src/app Folder

getStyle方法返回的 map 对象表明ngStyle指令能够支持可用于属性绑定的两种格式,包括值中的单位或属性名。下面是当 key 参数的值为 1 时,getStyles方法产生的 map 对象:

...
{
  "fontSize":"30px",
  "margin.px":100,
  "color":"red"
}
...

清单 12-17 显示了模板中使用ngStyle指令的数据绑定,其表达式调用getStyles方法。

<div class="text-white m-2">
  <div class="p-2 bg-warning">
    The <span [ngStyle]="getStyles(1)">first</span>
    product is {{model.getProduct(1).name}}.
  </div>
  <div class="p-2 bg-info">
    The <span [ngStyle]="getStyles(2)">second</span>
    product is {{model.getProduct(2).name}}
  </div>
</div>

Listing 12-17.Using the ngStyle Directive in the template.html File in the src/app Folder

结果是每个span元素接收一组定制的样式,基于传递给getStyles方法的参数,如图 12-11 所示。

img/421542_4_En_12_Fig11_HTML.jpg

图 12-11。

使用 ngStyle 指令

更新应用中的数据

当您开始使用 Angular 时,似乎需要花费很多精力来处理数据绑定,记住在不同的情况下需要哪种绑定。你可能想知道这是否值得努力。

绑定值得理解,因为当它们所依赖的数据改变时,它们的表达式会被重新计算。例如,如果您使用字符串插值绑定来显示属性值,则当属性值更改时,绑定将自动更新。

为了提供一个演示,我将跳到前面,向您展示如何手动控制更新过程。这不是正常 Angular 开发中需要的技术,但它提供了一个为什么绑定如此重要的坚实的演示。清单 12-18 显示了对支持演示的组件的一些更改。

import { ApplicationRef, Component } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();

    constructor(ref: ApplicationRef) {
        (<any>window).appRef = ref;
        (<any>window).model = this.model;
    }

    getProductByPosition(position: number): Product {
        return this.model.getProducts()[position];
    }

    getClassesByPosition(position: number): string {
        let product = this.getProductByPosition(position);
        return "p-2 " + (product.price < 50 ? "bg-info" : "bg-warning");
    }
}

Listing 12-18.Preparing the Component in the component.ts File in the src/app Folder

我已经从@angular/core模块中导入了ApplicationRef类型。当 Angular 执行引导过程时,它创建一个ApplicationRef对象来表示应用。清单 12-18 使用 Angular 依赖注入特性,向接收ApplicationRef对象作为参数的组件添加一个构造函数,我在第十九章中对此进行了描述。现在不讨论细节,像这样声明一个构造函数参数告诉 Angular,当一个新的实例被创建时,组件想要接收ApplicationRef对象。

在构造函数中,有两个语句使演示成为可能,但如果在实际项目中使用,会破坏使用 TypeScript 和 Angular 的许多好处。

...
(<any>window).appRef = ref;
(<any>window).model = this.model;
...

这些语句在全局名称空间中定义变量,并将ApplicationRefModel对象分配给它们。保持全局名称空间尽可能清晰是一个好习惯,但是公开这些对象允许通过浏览器的 JavaScript 控制台操作它们,这对本例很重要。

添加到构造函数中的其他方法允许根据位置从存储库中检索Product对象,而不是根据它的键,并根据price属性的值生成不同的类别映射。

清单 12-19 显示了对模板的相应更改,它使用ngClass指令来设置类成员资格,并使用字符串插值绑定来显示 Product.name 属性的值。

<div class="text-white m-2">
  <div [ngClass]="getClassesByPosition(0)">
    The first product is {{getProductByPosition(0).name}}.
  </div>
  <div [ngClass]="getClassesByPosition(1)">
    The second product is {{getProductByPosition(1).name}}
  </div>
</div>

Listing 12-19.Preparing for Changes in the template.html File in the src/app Folder

保存对组件和模板的更改。浏览器重新加载页面后,在浏览器的 JavaScript 控制台中输入以下语句,然后按 Return 键:

model.products.shift()

该语句对模型中的Product对象的数组调用shift方法,从数组中移除第一个项目并返回它。您还看不到任何变化,因为 Angular 不知道模型已经被修改。要让 Angular 检查更改,请在浏览器的 JavaScript 控制台中输入以下语句,然后按 Return 键:

appRef.tvick()

tick方法启动 Angular 变化检测过程,其中 Angular 查看应用中的数据和数据绑定中的表达式,并处理任何变化。模板中的数据绑定使用特定的数组索引来显示数据,现在已经从模型中移除了一个对象,绑定将被更新以显示新的值,如图 12-12 所示。

img/421542_4_En_12_Fig12_HTML.jpg

图 12-12。

手动更新应用模型

值得花点时间来思考一下变更检测过程运行时发生了什么。Angular 重新评估了模板中绑定的表达式,并更新了它们的值。反过来,ngClass指令和字符串插值绑定通过改变它们的类成员和显示新内容来重新配置它们的宿主元素。

发生这种情况是因为 Angular 数据绑定是活动的,这意味着在初始内容显示给用户之后,表达式、目标和主机元素之间的关系继续存在,并动态地反映应用状态的变化。我承认,当您不必使用 JavaScript 控制台进行更改时,这种效果更令人印象深刻。我会在第十四章的中解释 Angular 如何允许用户使用事件和表单来触发变化。

摘要

在本章中,我描述了 Angular 数据绑定的结构,并向您展示了如何使用它们来创建应用中的数据和显示给用户的 HTML 元素之间的关系。我介绍了属性绑定,并描述了如何使用两个内置指令— ngClassngStyle。在下一章,我将解释更多的内置指令是如何工作的。

十三、使用内置指令

在这一章中,我描述了内置指令,这些指令负责创建 web 应用的一些最常见的必需功能:有选择地包含内容,在不同的内容片段之间进行选择,以及重复内容。我还描述了 Angular 对用于单向数据绑定的表达式和提供这些表达式的指令的一些限制。表 13-1 将内置模板指令放在上下文中。

表 13-1。

将内置指令放在上下文中

|

问题

|

回答

|
| --- | --- |
| 它们是什么? | 本章中描述的内置指令负责选择性地包含内容、在内容片段之间进行选择,以及为数组中的每个项目重复内容。还有设置元素样式和类成员的指令,如第十三章所述。 |
| 它们为什么有用? | 使用这些指令可以执行的任务是 web 应用开发中最常见和最基本的任务,它们为根据应用中的数据调整显示给用户的内容提供了基础。 |
| 它们是如何使用的? | 这些指令应用于模板中的 HTML 元素。这一章(以及本书的其余部分)都有例子。 |
| 有什么陷阱或限制吗? | 使用内置模板指令的语法要求您记住,其中一些指令(包括ngIfngFor)必须以星号为前缀,而其他指令(包括ngClassngStylengSwitch)必须用方括号括起来。我在“理解微模板指令”边栏中解释了为什么这是必需的,但是这很容易忘记并得到意想不到的结果。 |
| 有其他选择吗? | 你可以编写你自己的定制指令——这个过程我在第 15 和 16 章中描述过——但是内置指令写得很好并且经过了全面的测试。对于大多数应用,最好使用内置指令,除非它们不能准确提供所需的功能。 |

表 13-2 总结了本章内容。

表 13-2。

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 基于数据绑定表达式有条件地显示内容 | 使用ngIf指令 | 1–3 |
| 基于数据绑定表达式的值在不同内容之间进行选择 | 使用ngSwitch指令 | 4, 5 |
| 为由数据绑定表达式产生的每个对象生成一段内容 | 使用ngFor指令 | 6–12 |
| 重复内容块 | 使用ngTemplateOutlet指令 | 13–14 |
| 防止模板错误 | 避免将修改应用状态作为数据绑定表达式的副作用 | 15–19 |
| 避免上下文错误 | 确保数据绑定表达式仅使用模板组件提供的属性和方法 | 20–22 |

准备示例项目

本章依赖于在第十一章中创建并在第十二章中修改的example项目。为了准备本章的主题,清单 13-1 显示了对组件类的更改,删除了不再需要的特性,并添加了新的方法和属性。

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

import { ApplicationRef, Component } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();

    constructor(ref: ApplicationRef) {
        (<any>window).appRef = ref;
        (<any>window).model = this.model;
    }

    getProductByPosition(position: number): Product {
        return this.model.getProducts()[position];
    }

    getProduct(key: number): Product {
        return this.model.getProduct(key);
    }

    getProducts(): Product[] {
        return this.model.getProducts();
    }

    getProductCount(): number {
        return this.getProducts().length;
    }

    targetName: string = "Kayak";
}

Listing 13-1.Changes in the component.ts File in the src/app Folder

清单 13-2 显示了模板文件的内容,它通过调用组件的新getProductCount方法显示了数据模型中的产品数量。

<div class="text-white m-2">
  <div class="bg-info p-2">
    There are {{getProductCount()}} products.
  </div>
</div>

Listing 13-2.The Contents of the template.html File in the src/app Folder

从命令行的example文件夹中运行以下命令,启动 TypeScript 编译器和开发 HTTP 服务器:

ng serve

打开一个新的浏览器窗口并导航至http://localhost:4200以查看如图 13-1 所示的内容。

img/421542_4_En_13_Fig1_HTML.jpg

图 13-1。

运行示例应用

使用内置指令

Angular 附带了一组内置指令,提供了 web 应用中通常需要的特性。表 13-3 描述了可用的指令,我将在接下来的章节中演示这些指令(除了ngClassngStyle指令,它们将在第十二章中介绍)。

表 13-3。

内置指令

|

例子

|

描述

|
| --- | --- |
| <div *ngIf="expr"></div> | 如果表达式的计算结果为true,则ngIf指令用于在 HTML 文档中包含一个元素及其内容。指令名称前的星号表示这是一个微模板指令,如“了解微模板指令”侧栏中所述。 |
| <div [ngSwitch]="expr">  <span *ngSwitchCase="expr"></span>  <span *ngSwitchDefault></span></div> | ngSwitch指令用于根据表达式的结果在 HTML 文档中包含的多个元素之间进行选择,然后将表达式的结果与使用ngSwitchCase指令定义的各个表达式的结果进行比较。如果没有一个ngSwitchCase值匹配,那么将使用已经应用了ngSwitchDefault指令的元素。ngSwitchCasengSwitchDefault指令前的星号表示它们是微模板指令,如“理解微模板指令”侧栏中所述。 |
| <div *ngFor="#item of expr"></div> | ngFor指令用于为数组中的每个对象生成相同的元素集。指令名称前的星号表示这是一个微模板指令,如“了解微模板指令”侧栏中所述。 |
| <ng-template [ngTemplateOutlet]="myTempl"></ngtemplate> | ngTemplateOutlet指令用于重复模板中的一块内容。 |
| <div ngClass="expr"></div> | 如第十二章所述,ngClass指令用于管理类成员。 |
| <div ngStyle="expr"></div> | 如第十二章所述,ngStyle指令用于管理直接应用于元素的样式(与通过类应用样式相反)。 |

使用 ngIf 指令

ngIf是最简单的内置指令,用于在表达式的值为true时在文档中包含一段 HTML,如清单 13-3 所示。

<div class="text-white m-2">
  <div class="bg-info p-2">
    There are {{getProductCount()}} products.
  </div>

  <div *ngIf="getProductCount() > 4" class="bg-info p-2 mt-1">
    There are more than 4 products in the model
  </div>

  <div *ngIf="getProductByPosition(0).name != 'Kayak'" class="bg-info p-2 mt-1">
    The first product isn't a Kayak
  </div>
</div>

Listing 13-3.Using the ngIf Directive in the template.html File in the src/app Folder

ngIf指令已经应用于两个div元素,表达式检查模型中Product对象的数量以及第一个Product的名称是否为Kayak

第一个表达式求值为true,表示div元素及其内容将包含在 HTML 文档中;第二个表达式的值为false,这意味着第二个div元素将被排除。图 13-2 显示了结果。

Note

指令在 HTML 文档中添加和删除元素,而不仅仅是显示或隐藏它们。如果您想保留元素并控制它们的可见性,可以使用第十二章中描述的属性或样式绑定,方法是将hidden元素属性设置为true或将display样式属性设置为none

img/421542_4_En_13_Fig2_HTML.jpg

图 13-2。

使用 ngIf 指令

Understanding Micro-Template Directives

一些指令,如ngForngIf以及与ngSwitch一起使用的嵌套指令,都带有星号前缀,如*ngFor*ngIf*ngSwitch。星号是使用依赖于作为模板一部分提供的内容的指令的简写,称为微模板。使用微模板的指令被称为结构指令,我在第十六章向您展示如何创建它们时会再次提到这个描述。

清单 13-3 将ngIf指令应用于div元素,告诉指令使用div元素及其内容作为它处理的每个对象的微模板。在幕后,Angular 扩展了微模板和指令,如下所示:

...
<ng-template ngIf="model.getProductCount() > 4">
    <div class="bg-info p-2 mt-1">
        There are more than 4 products in the model
    </div>
</ng-template>
...

您可以在模板中使用这两种语法,但是如果您使用紧凑语法,那么您必须记住使用星号。我在第十四章中解释了如何创建你自己的微模板指令。

像所有指令一样,用于ngIf的表达式将被重新计算,以反映数据模型中的变化。在浏览器的 JavaScript 控制台中运行以下语句,删除第一个数据对象并运行更改检测流程:

model.products.shift()
appRef.tick()

修改模型的效果是删除第一个div元素,因为现在的Product对象太少,添加第二个div元素,因为数组中第一个Productname属性不再是Kayak。图 13-3 显示了变化。

img/421542_4_En_13_Fig3_HTML.jpg

图 13-3。

重新计算指令表达式的影响

使用 nsswitch 指令

ngSwitch指令根据表达式结果选择几个元素中的一个,类似于 JavaScript switch语句。清单 13-4 显示了用于根据模型中对象的数量选择元素的ngSwitch指令。

<div class="text-white m-2">
  <div class="bg-info p-2">
    There are {{getProductCount()}} products.
  </div>

  <div class="bg-info p-2 mt-1" [ngSwitch]="getProductCount()">
    <span *ngSwitchCase="2">There are two products</span>
    <span *ngSwitchCase="5">There are five products</span>
    <span *ngSwitchDefault>This is the default</span>
  </div>
</div>

Listing 13-4.Using the ngSwitch Directive in the template.html File in the src/app Folder

ngSwitch指令语法使用起来可能会很混乱。应用了ngSwitch指令的元素总是包含在 HTML 文档中,并且指令名没有前缀星号。它必须在方括号中指定,如下所示:

...
<div class="bg-info p-2 mt-1" [ngSwitch]="getProductCount()">
...

每个内部元素,在本例中是span元素,是一个微模板,指定目标表达式结果的指令以星号为前缀,如下所示:

...
<span *ngSwitchCase="5">There are five products</span>
...

ngSwitchCase指令用于指定表达式结果。如果ngSwitch表达式计算出指定的结果,那么该元素及其内容将包含在 HTML 文档中。如果表达式没有计算出指定的结果,那么元素及其内容将从 HTML 文档中排除。

ngSwitchDefault指令应用于一个 fallback 元素——相当于 JavaScript switch语句中的default标签——如果表达式结果与ngSwitchCase指令指定的任何结果都不匹配,该元素就会包含在 HTML 文档中。

对于应用中的初始数据,清单 13-4 中的指令产生以下 HTML:

...
<div class="bg-info p-2 mt-1" ng-reflect-ng-switch="5">
    <span>There are five products</span>
</div>
...

已经应用了ngSwitch指令的div元素总是包含在 HTML 文档中。对于模型中的初始数据,其ngSwitchCase指令的结果为5span元素也被包括在内,产生如图 13-4 左侧所示的结果。

img/421542_4_En_13_Fig4_HTML.jpg

图 13-4。

使用 nsswitch 指令

ngSwitch绑定响应数据模型中的变化,您可以通过在浏览器的 JavaScript 控制台中执行以下语句来测试:

model.products.shift()
appRef.tick()

这些语句从模型中删除第一个项目,并强制 Angular 运行变化检测过程。两个ngSwitchCase指令的结果都不匹配getProductCount表达式的结果,因此ngSwitchDefault元素被包含在 HTML 文档中,如图 13-4 的右图所示。

避免文字值问题

当使用ngSwitchCase指令指定文字字符串值时,会出现一个常见的问题,必须注意得到正确的结果,如清单 13-5 所示。

<div class="text-white m-2">
  <div class="bg-info p-2">
    There are {{getProductCount()}} products.
  </div>

  <div class="bg-info p-2 mt-1" [ngSwitch]="getProduct(1).name">
    <span *ngSwitchCase="targetName">Kayak</span>
    <span *ngSwitchCase="'Lifejacket'">Lifejacket</span>
    <span *ngSwitchDefault>Other Product</span>
  </div>
</div>

Listing 13-5.Component and String Literal Values in the template.html File in the src/app Folder

分配给ngSwitchCase指令的值也是表达式,这意味着您可以调用方法、执行简单的内联操作以及读取属性值,就像您对基本数据绑定所做的那样。

例如,当评估ngSwitch表达式的结果与组件定义的targetName属性的值匹配时,该表达式告诉 Angular 包含指令应用到的span元素:

...
<span *ngSwitchCase="targetName">Kayak</span>
...

如果你想比较一个结果和一个特定的字符串,你必须用双引号引起来,就像这样:

...
<span *ngSwitchCase="'Lifejacket'">Lifejacket</span>
...

ngSwitch表达式的值等于文字字符串值Lifejacket时,该表达式告诉 Angular 包含span元素,产生如图 13-5 所示的结果。

img/421542_4_En_13_Fig5_HTML.jpg

图 13-5。

在 nsswitch 指令中使用表达式和文字值

使用 ngFor 指令

ngFor指令为数组中的每个对象重复一段内容,提供了相当于foreach循环的模板。在清单 13-6 中,我使用了ngFor指令来填充一个表格,为模型中的每个Product对象生成一行。

<div class="text-white m-2">
  <div class="bg-info p-2">
    There are {{getProductCount()}} products.
  </div>

  <table class="table table-sm table-bordered mt-1 text-dark">
    <tr><th>Name</th><th>Category</th><th>Price</th></tr>
    <tr *ngFor="let item of getProducts()">
      <td>{{item.name}}</td>
      <td>{{item.category}}</td>
      <td>{{item.price}}</td>
    </tr>
  </table>
</div>

Listing 13-6.Using the ngFor Directive in the template.html File in the src/app Folder

ngFor指令一起使用的表达式比其他内置指令更复杂,但是当您看到不同部分如何组合在一起时,它就开始有意义了。下面是我在示例中使用的指令:

...
<tr *ngFor="let item of getProducts()">
...

名称前的星号是必需的,因为该指令正在使用微模板,如“了解微模板指令”侧栏中所述。随着您对 Angular 的熟悉,这将变得更有意义,但是首先,您只需要记住这个指令需要一个星号(或者,正如我经常做的,直到您看到浏览器的 JavaScript 控制台中显示一个错误,然后然后记住)。

对于表达式本身,有两个不同的部分,用关键字of连接。表达式的右边部分提供将被枚举的数据源。

...
<tr *ngFor="let item of getProducts()">
...

这个例子指定组件的getProducts方法作为数据源,这允许内容是模型中每个Product对象的。右边是一个独立的表达式,这意味着您可以在模板中准备数据或执行简单的操作。

ngFor表达式的左侧定义了一个模板变量,由let关键字表示,这就是数据在 Angular 模板内的元素之间传递的方式。

...
<tr *ngFor="let item of getProducts()">
...

ngFor指令将变量分配给数据源中的每个对象,以便嵌套元素可以使用该变量。示例中的本地模板变量称为item,它用于访问td元素的Product对象的属性,如下所示:

...
<td>{{item.name}}</td>
...

总之,示例中的指令告诉 Angular 枚举由组件的getProducts方法返回的对象,将每个对象分配给一个名为item的变量,然后生成一个tr元素及其td子元素,评估它们包含的模板表达式。

对于清单 13-6 中的例子,结果是一个表格,其中ngFor指令用于为模型中的每个Product对象生成表格行,并且每个表格行包含显示Product对象的namecategoryprice属性的值的td元素,如图 13-6 所示。

img/421542_4_En_13_Fig6_HTML.jpg

图 13-6。

使用 ngFor 指令创建表行

使用其他模板变量

最重要的模板变量是引用正在处理的数据对象的变量,在前面的例子中是item。但是ngFor指令支持一系列其他的值,这些值也可以被赋给变量,然后在嵌套的 HTML 元素中被引用,如表 13-4 中所描述的,并在接下来的章节中演示。

表 13-4。

本地模板值的 NGF

|

名字

|

描述

|
| --- | --- |
| index | 该number值被分配给当前对象的位置。 |
| odd | 如果当前对象在数据源中的位置是奇数,这个boolean值将返回true。 |
| even | 如果当前对象在数据源中的位置是偶数,那么这个boolean值返回true。 |
| first | 如果当前对象是数据源中的第一个对象,这个boolean值返回true。 |
| last | 如果当前对象是数据源中的最后一个对象,这个boolean值返回true。 |

使用索引值

index值被设置为当前数据对象的位置,并针对数据源中的每个对象递增。在清单 13-7 中,我定义了一个使用ngFor指令填充的表,该表将index值赋给一个名为i的本地模板变量,然后在字符串插值绑定中使用该变量。

<div class="text-white m-2">
  <div class="bg-info p-2">
    There are {{getProductCount()}} products.
  </div>

  <table class="table table-sm table-bordered mt-1 text-dark">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <tr *ngFor="let item of getProducts(); let i = index">
      <td>{{i +1}}</td>
      <td>{{item.name}}</td>
      <td>{{item.category}}</td>
      <td>{{item.price}}</td>
    </tr>
  </table>
</div>

Listing 13-7.Using the Index Value in the template.html File in the src/app Folder

一个新术语被添加到ngFor表达式中,用分号(;字符)与现有术语分开。新表达式使用let关键字将index值赋给一个名为i的本地模板变量,如下所示:

...
<tr *ngFor="let item of getProducts(); let i = index">
...

这允许使用绑定在嵌套元素中访问该值,如下所示:

...
<td>{{i + 1}}</td>
...

index值是从零开始的,给value加 1 创建一个简单的计数器,产生如图 13-7 所示的结果。

img/421542_4_En_13_Fig7_HTML.jpg

图 13-7。

使用索引值

使用奇数值和偶数值

当数据项的index值为odd时,odd值为true。相反,当数据项的索引值为even时,even值为true。一般来说,你只需要使用oddeven值,因为它们是boolean值,当evenfalseoddtrue,反之亦然。在清单 13-8 中,odd值用于管理表中tr元素的类成员。

<div class="text-white m-2">
  <div class="bg-info p-2">
    There are {{getProductCount()}} products.
  </div>

  <table class="table table-sm table-bordered mt-1">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <tr *ngFor="let item of getProducts(); let i = index; let odd = odd"
        class="text-white" [class.bg-primary]="odd" [class.bg-info]="!odd">
      <td>{{i + 1}}</td>
      <td>{{item.name}}</td>
      <td>{{item.category}}</td>
      <td>{{item.price}}</td>
    </tr>
  </table>
</div>

Listing 13-8.Using the odd Value in the template.html File in the src/app Folder

我使用了一个分号,并在ngFor表达式中添加了另一个术语,将odd值赋给一个本地模板变量,该变量也称为odd

...
<tr *ngFor="let item of getProducts(); let i = index; let odd = odd"
    class="text-white" [class.bg-primary]="odd" [class.bg-info]="!odd">
...

这似乎是多余的,但是你不能直接访问ngFor值,必须使用一个本地变量,即使它有相同的名字。我使用了class绑定来将替换行分配给bg-primarybg-info类,这两个类是引导背景色类,它们将表格行分成条纹,如图 13-8 所示。

img/421542_4_En_13_Fig8_HTML.jpg

图 13-8。

使用奇数值

Expanding the *ngFor Directive

注意,在清单 13-8 中,我可以在表达式中使用模板变量,该变量应用于定义它的同一个tr元素。这是可能的,因为ngFor是一个微模板指令——由名字前面的*表示——所以 Angular 扩展了 HTML,看起来像这样:

...
<table class="table table-sm table-bordered mt-1">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <ng-template ngFor let-item [ngForOf]="getProducts()"
            let-i="index" let-odd="odd">
        <tr [class.bg-primary]="odd" [class.bg-info]="!odd">
            <td>{{i + 1}}</td>
            <td>{{item.name}}</td>
            <td>{{item.category}}</td>
            <td>{{item.price}}</td>
        </tr>
    </ng-template>
</table>
...

您可以看到,ng-template元素定义了变量,使用了有些笨拙的let-<name>属性,然后由其中的trtd元素访问这些属性。正如 Angular 中的许多内容一样,一旦你理解了幕后发生的事情,看似通过魔法发生的事情就会变得简单明了,我将在第十六章中详细解释这些特性。使用*ngFor语法的一个很好的理由是,它提供了一种更优雅的方式来表达指令表达式,尤其是当有多个模板变量时。

使用第一个和最后一个值

只有数据源提供的序列中的第一个对象的first值为true,其他所有对象的first值为false。相反,仅对于序列中的最后一个对象,last值为true。清单 13-9 使用这些值来区别对待序列中的第一个和最后一个对象。

<div class="text-white m-2">
  <div class="bg-info p-2">
    There are {{getProductCount()}} products.
  </div>

  <table class="table table-sm table-bordered mt-1">
    <tr class="text-dark">
      <th></th><th>Name</th><th>Category</th><th>Price</th>
    </tr>
    <tr *ngFor="let item of getProducts(); let i = index; let odd = odd;
            let first = first; let last = last" class="text-white"
        [class.bg-primary]="odd" [class.bg-info]="!odd"
        [class.bg-warning]="first || last">
      <td>{{i + 1}}</td>
      <td>{{item.name}}</td>
      <td>{{item.category}}</td>
      <td *ngIf="!last">{{item.price}}</td>
    </tr>
  </table>
</div>

Listing 13-9.Using the first and last Values in the template.html File in the src/app Folder

ngFor表达式中的新术语将firstlast值分配给模板变量firstlast。这些变量随后被绑定在tr元素上的class使用,当其中一个为true时,它将该元素分配给bg-warning类,并被td元素之一上的ngIf指令使用,这将排除数据源中last项的元素,产生如图 13-9 所示的效果。

img/421542_4_En_13_Fig9_HTML.jpg

图 13-9。

使用第一个和最后一个值

最小化元素操作

当数据模型发生变化时,ngFor指令评估其表达式,并更新代表其数据对象的元素。更新过程可能会很昂贵,尤其是当数据源被替换为包含表示相同数据的不同对象的数据源时。替换数据源似乎是一件奇怪的事情,但这在 web 应用中经常发生,尤其是当数据是从 web 服务中检索时,就像我在第二十四章中描述的那些。相同的数据值由新的对象表示,这对于 Angular 来说存在效率问题。为了演示这个问题,我向组件添加了一个方法来替换数据模型中的一个Product对象,如清单 13-10 所示。

import { Product } from "./product.model";
import { SimpleDataSource } from "./datasource.model";

export class Model {
    private dataSource: SimpleDataSource;
    private products: Product[];
    private locator = (p:Product, id:number) => p.id == id;

    constructor() {
        this.dataSource = new SimpleDataSource();
        this.products = new Array<Product>();
        this.dataSource.getData().forEach(p => this.products.push(p));
    }

    // ...other methods omitted for brevity...

    swapProduct() {
        let p = this.products.shift();
        this.products.push(new Product(p.id, p.name, p.category, p.price));
    }
}

Listing 13-10.Replacing an Object in the repository.model.ts File in the src/app Folder

swapProduct方法从数组中删除第一个对象,并添加一个新对象,该对象的idnamecategoryprice属性具有相同的值。这是一个用新对象表示数据值的例子。

使用浏览器的 JavaScript 控制台运行以下语句来修改数据模型并运行更改检测流程:

model.swapProduct()
appRef.tick()

ngFor指令检查它的数据源时,它发现要执行两个操作来反映数据的变化。第一个操作是销毁表示数组中第一个对象的 HTML 元素。第二个操作是创建一组新的 HTML 元素来表示数组末尾的新对象。

Angular 无法知道它正在处理的数据对象是否具有相同的值,也无法知道它是否可以通过简单地移动 HTML 文档中的现有元素来更有效地完成工作。

在这个例子中,这个问题只影响到两个元素,但是当应用中的数据使用 Ajax 从外部数据源刷新时,这个问题就更加严重了,每次收到响应时,所有的数据模型对象都可以被替换。因为它不知道真正的变化很少,ngFor指令必须销毁所有的 HTML 元素并重新创建它们,这可能是一个昂贵且耗时的操作。

为了提高更新的效率,您可以定义一个组件方法来帮助 Angular 确定何时两个不同的对象表示相同的数据,如清单 13-11 所示。

import { ApplicationRef, Component } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();

    // ...constructor and methods omitted for brevity...

    getKey(index: number, product: Product) {
        return product.id;
    }
}

Listing 13-11.Adding the Object Comparison Method in the component.ts File in the src/app Folder

该方法必须定义两个参数:数据源中对象的位置和数据对象。方法的结果唯一标识一个对象,如果两个对象产生相同的结果,则认为它们是相等的。

如果两个Product对象具有相同的id值,它们将被视为相等。告诉ngFor表达式使用比较方法是通过给表达式添加一个trackBy项来完成的,如清单 13-12 所示。

<div class="text-white m-2">
  <div class="bg-info p-2">
    There are {{getProductCount()}} products.
  </div>

  <table class="table table-sm table-bordered mt-1">
    <tr class="text-dark">
      <th></th><th>Name</th><th>Category</th><th>Price</th>
    </tr>
    <tr *ngFor="let item of getProducts(); let i = index; let odd = odd;
            let first = first; let last = last; trackBy:getKey" class="text-white"
        [class.bg-primary]="odd" [class.bg-info]="!odd"
        [class.bg-warning]="first || last">
      <td>{{i + 1}}</td>
      <td>{{item.name}}</td>
      <td>{{item.category}}</td>
      <td *ngIf="!last">{{item.price}}</td>
    </tr>
  </table>
</div>

Listing 13-12.Providing an Equality Method in the template.html File in the src/app Folder

有了这个变化,ngFor指令将知道使用清单 13-12 中定义的swapProduct方法从数组中删除的Product等同于添加到数组中的那个,尽管它们是不同的对象。可以移动现有的元素,而不是删除和创建元素,这是一个执行起来更简单、更快速的任务。

仍然可以对元素进行更改——例如通过ngIf指令,该指令将删除一个td元素,因为新对象将是数据源中的last项,但即使这样也比单独处理对象要快。

Testing the Equality Method

检查等式方法是否有效果有点棘手。我发现最好的方法是使用浏览器的 F12 开发工具,在这种情况下使用 Chrome 浏览器。

应用加载后,在浏览器窗口中右键单击包含单词 Kayaktd元素,并从弹出菜单中选择 Inspect。这将打开开发者工具窗口并显示元素面板。

单击左边的省略号按钮(标有...)并从菜单中选择添加属性。添加一个值为oldid属性。这将产生如下所示的元素:

<td id="old">Kayak</td>

添加一个id属性使得使用 JavaScript 控制台访问表示 HTML 元素的对象成为可能。切换到控制台面板,输入以下语句:

window.old

当您点击 Return 时,浏览器将通过元素的id属性值来定位元素,并显示以下结果:

<td id="old">Kayak</td>

现在在 JavaScript 控制台中执行以下语句,每执行一个语句后按 Return 键:

model.swapProduct()
appRef.tick()

一旦处理了对数据模型的更改,在 JavaScript 控制台中执行以下语句将确定添加了id属性的td元素是否已被移动或销毁:

window.old

如果元素已被移动,那么您将会在控制台中看到该元素,如下所示:

<td id="old">Kayak</td>

如果元素已经被破坏,那么就不会有id属性为old的元素,浏览器会显示undefined字样。

使用 ngTemplateOutlet 指令

ngTemplateOutlet指令用于在指定的位置重复一个内容块,当您需要在不同的地方生成相同的内容并希望避免重复时,这个指令会很有用。清单 13-13 显示了正在使用的指令。

<ng-template #titleTemplate>
  <h4 class="p-2 bg-success text-white">Repeated Content</h4>
</ng-template>

<ng-template [ngTemplateOutlet]="titleTemplate"></ng-template>

<div class="bg-info p-2 m-2 text-white">
  There are {{getProductCount()}} products.
</div>

<ng-template [ngTemplateOutlet]="titleTemplate"></ng-template>

Listing 13-13.Using the ngTemplateOutlet Directive in the template.html File in the src/app Folder

第一步是使用指令定义包含要重复的内容的模板。这是通过使用ng-template元素并使用引用变量为其命名来完成的,如下所示:

...
<ng-template #titleTemplate let-title="title">
  <h4 class="p-2 bg-success text-white">Repeated Content</h4>
</ng-template>
...

当 Angular 遇到一个引用变量时,它将它的值设置为它所定义的元素,在本例中是ng-template元素。第二步是使用ngTemplateOutlet指令将内容插入 HTML 文档,如下所示:

...
<ng-template [ngTemplateOutlet]="titleTemplate"></ng-template>
...

表达式是分配给应该插入的内容的引用变量的名称。该指令用指定的ng-template元素的内容替换主机元素。HTML 文档中既不包含包含重复内容的ng-template元素,也不包含作为绑定宿主元素的元素。图 13-10 显示了指令是如何使用重复内容的。

img/421542_4_En_13_Fig10_HTML.jpg

图 13-10。

使用 ngTemplateOutlet 指令

提供上下文数据

ngTemplateOutlet指令可用于为重复内容提供上下文对象,该对象可用于在ng-template元素中定义的数据绑定,如清单 13-14 所示。

<ng-template #titleTemplate let-text="title">
  <h4 class="p-2 bg-success text-white">{{text}}</h4>
</ng-template>

<ng-template [ngTemplateOutlet]="titleTemplate"
             [ngTemplateOutletContext]="{title: 'Header'}">
</ng-template>

<div class="bg-info p-2 m-2 text-white">
  There are {{getProductCount()}} products.
</div>

<ng-template [ngTemplateOutlet]="titleTemplate"
             [ngTemplateOutletContext]="{title: 'Footer'}">
</ng-template>

Listing 13-14.Providing Context Data in the template.html File in the src/app Folder

为了接收上下文数据,包含重复内容的ng-template元素定义了一个指定变量名称的let-属性,类似于用于ngFor指令的扩展语法。表达式的值给let-变量赋值,如下所示:

...
<ng-template #titleTemplate let-text="title">
...

本例中的let-属性创建了一个名为text的变量,该变量通过计算表达式title来赋值。为了提供计算表达式所依据的数据,应用了ngTemplateOutletContext指令的ng-template元素提供了一个 map 对象,如下所示:

...
<ng-template [ngTemplateOutlet]="titleTemplate"
          [ngTemplateOutletContext]="{title: 'Footer'}">
</ng-template>
...

这个新绑定的目标是ngTemplateOutletContext,它看起来像另一个指令,但实际上是一个输入属性的例子,一些指令用它来接收数据值,我在第十五章中对此进行了详细描述。绑定的表达式是一个 map 对象,其属性名对应于另一个ng-template元素上的let-属性。结果是可以使用绑定来定制重复的内容,如图 13-11 所示。

img/421542_4_En_13_Fig11_HTML.jpg

图 13-11。

为重复内容提供上下文数据

了解单向数据绑定限制

尽管单向数据绑定和指令中使用的表达式看起来像 JavaScript 代码,但您不能使用所有的 JavaScript(或 TypeScript)语言功能。我将在接下来的章节中解释这些限制及其原因。

使用幂等表达式

单向数据绑定必须是幂等的,这意味着它们可以在不改变应用状态的情况下被重复评估。为了说明原因,我向组件的getProductCount方法添加了一个调试语句,如清单 13-15 所示。

Note

Angular 不支持修改应用状态,但是必须使用我在第十四章中描述的技术。

...
getProductCount(): number {
    console.log("getProductCount invoked");
    return this.getProducts().length;
}
...

Listing 13-15.Adding a Statement in the component.ts File in the src/app Folder

当保存更改并且浏览器重新加载页面时,您将在浏览器的 JavaScript 控制台中看到一长串类似这样的消息:

...
getProductCount invoked
getProductCount invoked
getProductCount invoked
getProductCount invoked
...

如消息所示,在浏览器中显示内容之前,Angular 对绑定表达式进行了多次评估。如果一个表达式修改了应用的状态,比如从队列中删除一个对象,那么当模板显示给用户时,您不会得到预期的结果。为了避免这个问题,Angular 限制了表达式的使用方式。在清单 13-16 中,我向组件添加了一个counter属性来帮助演示。

import { ApplicationRef, Component } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();

    // ...constructor and methods omitted for brevity...

    targetName: string = "Kayak";

    counter: number = 1;
}

Listing 13-16.Adding a Property in the component.ts File in the src/app Folder

在清单 13-17 中,我添加了一个绑定,当它被求值时,它的表达式增加计数器。

<ng-template #titleTemplate let-text="title">
  <h4 class="p-2 bg-success text-white">{{text}}</h4>
</ng-template>

<ng-template [ngTemplateOutlet]="titleTemplate"
             [ngTemplateOutletContext]="{title: 'Header'}">
</ng-template>

<div class="bg-info p-2 m-2 text-white">
  There are {{getProductCount()}} products.
</div>

<ng-template [ngTemplateOutlet]="titleTemplate"
             [ngTemplateOutletContext]="{title: 'Footer'}">
</ng-template>

<div class="bg-info p-2">
  Counter: {{counter = counter + 1}}
</div>

Listing 13-17.Adding a Binding in the template.html File in the src/app Folder

当浏览器加载页面时,您会在 JavaScript 控制台中看到一个错误,如下所示:

...
ERROR in Template parse errors:
Parser Error: Bindings cannot contain assignments at column 11 in [ Counter: {{counter = counter + 1}} ] in C:/example/src/app/template.html@17:4 ("
  <div class="bg-info p-2">
    [ERROR ->]Counter: {{counter = counter + 1}}
  </div>"): C:/Users/example/src/app/template.html@17:4
...

如果数据绑定表达式包含可用于执行赋值的运算符,如=+=-+++--,Angular 将报告错误。此外,当 Angular 在开发模式下运行时,它会执行额外的检查,以确保单向数据绑定在计算完表达式后没有被修改。为了演示,清单 13-18 向组件添加了一个属性,该属性从模型数组中移除并返回一个Product对象。

import { ApplicationRef, Component } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();

    // ...constructor and methods omitted for brevity...

    counter: number = 1;

    get nextProduct(): Product {
        return this.model.getProducts().shift();
    }
}

Listing 13-18.Modifying Data in the component.ts File in the src/app Folder

在清单 13-19 中,您可以看到我用来读取nextProduct属性的数据绑定。

<ng-template #titleTemplate let-text="title">
  <h4 class="p-2 bg-success text-white">{{text}}</h4>
</ng-template>

<ng-template [ngTemplateOutlet]="titleTemplate"
             [ngTemplateOutletContext]="{title: 'Header'}">
</ng-template>

<div class="bg-info p-2 m-2 text-white">
  There are {{getProductCount()}} products.
</div>

<ng-template [ngTemplateOutlet]="titleTemplate"
             [ngTemplateOutletContext]="{title: 'Footer'}">
</ng-template>

<div class="bg-info p-2 text-white">
  Next Product is {{nextProduct.name}}
</div>

Listing 13-19.Binding to a Property in the template.html File in the src/app Folder

保存模板时的响应取决于 F12 开发人员工具是否打开。如果是,那么调试器将暂停应用的执行,因为检测变化的代码包含一个debugger语句。如果您关闭 F12 工具,重新加载浏览器窗口,然后再次打开这些工具,您将在 JavaScript 控制台中看到以下错误:

...
Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'null: 4'. Current value: 'null: 3'.
...

理解表达式上下文

当 Angular 对表达式求值时,它是在模板组件的上下文中进行的,这就是模板能够在没有任何前缀的情况下访问方法和属性的方式,如下所示:

...
<div class="bg-info p-2">
    There are {{getProductCount()}} products.
</div>
...

当 Angular 处理这些表达式时,组件提供了getProductCount方法,Angular 使用指定的参数调用该方法,然后将结果合并到 HTML 文档中。据说该组件提供了模板的表达式上下文

表达式上下文意味着不能访问模板组件之外定义的对象,尤其是模板不能访问全局名称空间。全局名称空间用于定义常见的实用程序,例如console对象,它定义了我一直用来将调试信息写出到浏览器的 JavaScript 控制台的log方法。全局名称空间还包括Math对象,它提供了对一些有用的算术方法的访问,比如minmax

为了演示这种限制,清单 13-20 向模板添加了一个字符串插值绑定,它依赖于Math.floor方法将number值向下舍入到最接近的整数。

<ng-template #titleTemplate let-text="title">
  <h4 class="p-2 bg-success text-white">{{text}}</h4>
</ng-template>

<ng-template [ngTemplateOutlet]="titleTemplate"
             [ngTemplateOutletContext]="{title: 'Header'}">
</ng-template>

<div class="bg-info p-2 m-2 text-white">
  There are {{getProductCount()}} products.
</div>

<ng-template [ngTemplateOutlet]="titleTemplate"
             [ngTemplateOutletContext]="{title: 'Footer'}">
</ng-template>

<div class='bg-info p-2'>
  The rounded price is {{Math.floor(getProduct(1).price)}}
</div>

Listing 13-20.Accessing the Global Namespace in the template.html File in the src/app Folder

Angular 处理模板时,会在浏览器的 JavaScript 控制台中产生以下错误:

error TS2339: Property 'Math' does not exist on type 'ProductComponent'.

错误消息没有特别提到全局名称空间。相反,Angular 试图使用组件作为上下文来评估表达式,但未能找到一个Math属性。

如果您想要访问全局命名空间中的功能,那么它必须由组件提供,作为模板的代表。在这个例子中,组件可以只定义一个分配给全局对象的Math属性,但是模板表达式应该尽可能的清晰和简单,所以一个更好的方法是定义一个为模板提供它所需要的特定功能的方法,如清单 13-21 所示。

import { ApplicationRef, Component } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();

    // ...constructor and methods omitted for brevity...

    counter: number = 1;

    get nextProduct(): Product {
        return this.model.getProducts().shift();
    }

    getProductPrice(index: number): number {
        return Math.floor(this.getProduct(index).price);
    }
}

Listing 13-21.Defining a Method in the component.ts File in the src/app Folder

在清单 13-22 中,我已经更改了模板中的数据绑定,以使用新定义的方法。

<ng-template #titleTemplate let-text="title">
  <h4 class="p-2 bg-success text-white">{{text}}</h4>
</ng-template>

<ng-template [ngTemplateOutlet]="titleTemplate"
             [ngTemplateOutletContext]="{title: 'Header'}">
</ng-template>

<div class="bg-info p-2 m-2 text-white">
  There are {{getProductCount()}} products.
</div>

<ng-template [ngTemplateOutlet]="titleTemplate"
             [ngTemplateOutletContext]="{title: 'Footer'}">
</ng-template>

<div class="bg-info p-2 text-white">
  The rounded price is {{getProductPrice(1)}}
</div>

Listing 13-22.Access Global Namespace Functionality in the template.html File in the src/app Folder

Angular 处理模板时,会调用getProductPrice方法,间接利用全局名称空间中的Math对象,产生如图 13-12 所示的结果。

img/421542_4_En_13_Fig12_HTML.jpg

图 13-12。

访问全局名称空间功能

摘要

在本章中,我解释了如何使用内置模板指令。我向您展示了如何使用ngIfngSwitch指令选择内容,以及如何使用ngFor指令重复内容。我解释了为什么有些指令名称带有星号前缀,并描述了这些指令和一般单向数据绑定使用的模板表达式的限制。在下一章,我将描述数据绑定是如何用于事件和表单元素的。

十四、使用事件和表单

在这一章中,我继续描述基本的 Angular 功能,重点是响应用户交互的特性。我将解释如何创建事件绑定,以及如何使用双向绑定来管理模型和模板之间的数据流。web 应用中用户交互的主要形式之一是使用 HTML 表单,我将解释如何使用事件绑定和双向数据绑定来支持它们并验证用户提供的内容。表 14-1 将事件和表单放在上下文中。

表 14-1。

将事件绑定和表单放在上下文中

|

问题

|

回答

|
| --- | --- |
| 它们是什么? | 事件绑定在事件被触发时计算表达式,例如用户按键、移动鼠标或提交表单。更广泛的与表单相关的功能在此基础上构建,以创建自动验证的表单,从而确保用户提供有用的数据。 |
| 它们为什么有用? | 这些特性允许用户更改应用的状态,更改或添加模型中的数据。 |
| 它们是如何使用的? | 每种功能都有不同的使用方式。有关详细信息,请参见示例。 |
| 有什么陷阱或限制吗? | 与所有 Angular 绑定一样,主要缺陷是使用错误的括号来表示绑定。请密切注意本章中的例子,并在没有得到预期结果时检查您应用绑定的方式。 |
| 还有其他选择吗? | 不。这些功能是 Angular 的核心部分。 |

表 14-2 总结了本章内容。

表 14-2。

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 启用表单支持 | 将@angular/forms模块添加到应用 | 1–3 |
| 对事件做出反应 | 使用事件绑定 | 4–6 |
| 获取事件的详细信息 | 使用$event对象 | seven |
| 引用模板中的元素 | 定义模板变量 | eight |
| 允许数据在元素和组件之间双向流动 | 使用双向数据绑定 | 9, 10 |
| 捕捉用户输入 | 使用 HTML 表单 | 11, 12 |
| 验证用户提供的数据 | 执行表单验证 | 13–22 |
| 使用 JavaScript 代码定义验证信息 | 使用基于模型的表单 | 23–28 |
| 扩展内置的表单验证功能 | 定义自定义表单验证类 | 29–30 |

准备示例项目

对于这一章,我将继续使用我在第十一章中创建的示例项目,并在此后的章节中进行修改。

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

导入表单模块

本章中演示的特性依赖于 Angular forms 模块,该模块必须导入 Angular 模块,如清单 14-1 所示。

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { ProductComponent } from "./component";
import { FormsModule } from "@angular/forms";

@NgModule({
  declarations: [ProductComponent],
  imports: [BrowserModule, FormsModule],
  providers: [],
  bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 14-1.Declaring a Dependency in the app.module.ts File in the src/app Folder

NgModule装饰器的imports属性指定了应用的依赖关系。将FormsModule添加到依赖项列表中可以启用表单功能,并使它们可以在整个应用中使用。

准备组件和模板

清单 14-2 从组件类中移除了构造函数和一些方法,以保持代码尽可能简单,并添加了一个名为selectedProduct的新属性。

import { ApplicationRef, Component } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();

    getProduct(key: number): Product {
        return this.model.getProduct(key);
    }

    getProducts(): Product[] {
        return this.model.getProducts();
    }

    selectedProduct: Product;
}

Listing 14-2.Simplifying the Component in the component.ts File in the src/app Folder

清单 14-3 简化了组件的模板,只留下一个使用ngFor指令填充的表格。

<div class="m-2">
  <table class="table table-sm table-bordered">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <tr *ngFor="let item of getProducts(); let i = index">
      <td>{{i + 1}}</td>
      <td>{{item.name}}</td>
      <td>{{item.category}}</td>
      <td>{{item.price}}</td>
    </tr>
  </table>
</div>

Listing 14-3.Simplifying the Template in the template.html File in the src/app Folder

要启动开发服务器,请打开命令提示符,导航到example文件夹,然后运行以下命令:

ng serve

打开一个新的浏览器窗口并导航至http://localhost:4200以查看如图 14-1 所示的表格。

img/421542_4_En_14_Fig1_HTML.jpg

图 14-1。

运行示例应用

使用事件绑定

事件绑定用于响应主机元素发送的事件。清单 14-4 展示了事件绑定,它允许用户与 Angular 应用交互。

<div class="m-2">
  <div class="bg-info text-white p-2">
    Selected Product: {{selectedProduct || '(None)'}}
  </div>
  <table class="table table-sm table-bordered m-2">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <tr *ngFor="let item of getProducts(); let i = index">
      <td (mouseover)="selectedProduct=item.name">{{i + 1}}</td>
      <td>{{item.name}}</td>
      <td>{{item.category}}</td>
      <td>{{item.price}}</td>
    </tr>
  </table>
</div>

Listing 14-4.Using the Event Binding in the template.html File in the src/app Folder

当您保存对模板的更改时,您可以通过将鼠标指针移动到 HTML 表格的第一列上来测试绑定,该表格显示一系列数字。当鼠标从一行移动到另一行时,该行显示的产品名称显示在页面顶部,如图 14-2 所示。

img/421542_4_En_14_Fig2_HTML.jpg

图 14-2。

使用事件绑定

这是一个简单的例子,但它显示了事件绑定的结构,如图 14-3 所示。

img/421542_4_En_14_Fig3_HTML.jpg

图 14-3。

事件绑定的剖析

事件绑定包含以下四个部分:

  • 主机元素是绑定的事件源。

  • 圆括号告诉 Angular 这是一个事件绑定,这是一种单向绑定的形式,数据从元素流向应用的其余部分。

  • 事件指定绑定用于哪个事件。

  • 当事件被触发时,表达式被求值。

查看清单 14-4 中的绑定,您可以看到主机元素是一个td元素,这意味着这是将成为事件源的元素。绑定指定了mouseover事件,当鼠标指针移动到主机元素占据的屏幕部分时,该事件被触发。

与单向绑定不同,事件绑定中的表达式可以改变应用的状态,并且可以包含赋值操作符,比如=。绑定的表达式将值item.name赋给一个名为selectedProduct的变量。selectedProduct变量用于模板顶部的字符串插值绑定,如下所示:

...
<div class="bg-info text-white p-2">
    Selected Product: {{selectedProduct || '(None)'}}
</div>
...

selectedProduct变量的值被事件绑定改变时,字符串插值绑定显示的值被更新。不再需要使用ApplicationRef.tick方法手动启动变更检测过程,因为本章中的绑定和指令会自动处理该过程。

Working with DOM Events

如果您不熟悉 HTML 元素可以发送的事件,那么在developer.mozilla.org/en-US/docs/Web/Events有一个很好的总结。然而,有许多事件,并不是所有浏览器都广泛或一致地支持它们。一个很好的起点是mozilla.org页面的“DOM Events”和“HTML DOM Events”部分,它们定义了用户与元素的基本交互(点击、移动指针、提交表单等等),并且可以在大多数浏览器中使用。

如果您使用不太常见的事件,那么您应该确保它们在您的目标浏览器中可用并按预期工作。优秀的 http://caniuse.com 提供了不同浏览器实现哪些特性的细节,但是你也应该进行彻底的测试。

显示所选产品的表达式使用 null 合并运算符来确保用户始终看到一条消息,即使没有选择产品也是如此。一个更简洁的方法是定义一个执行这个检查的方法,如清单 14-5 所示。

import { ApplicationRef, Component } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();

    getProduct(key: number): Product {
        return this.model.getProduct(key);
    }

    getProducts(): Product[] {
        return this.model.getProducts();
    }

    selectedProduct: string;

    getSelected(product: Product): boolean {
        return product.name == this.selectedProduct;
    }
}

Listing 14-5.Enhancing the Component in the component.ts File in the src/app Folder

我定义了一个名为getSelected的方法,它接受一个Product对象,并将其名称与selectedProduct属性进行比较。在清单 14-6 中,getSelected方法被一个类绑定用来控制bg-info类的成员资格,这个类是一个引导类,为一个元素分配背景颜色。

<div class="m-2">
  <div class="bg-info text-white p-2">
    Selected Product: {{selectedProduct || '(None)'}}
  </div>
  <table class="table table-sm table-bordered m-2">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <tr *ngFor="let item of getProducts(); let i = index"
        [class.bg-info]="getSelected(item)">
      <td (mouseover)="selectedProduct=item.name">{{i + 1}}</td>
      <td>{{item.name}}</td>
      <td>{{item.category}}</td>
      <td>{{item.price}}</td>
    </tr>
  </table>
</div>

Listing 14-6.Setting Class Membership in the template.html File in the src/app Folder

结果是当selectedProduct属性值与用于创建它们的Product对象的name属性相匹配时,tr元素被添加到bg-info类中,当mouseover事件被触发时,事件绑定会改变这些属性,如图 14-4 所示。

img/421542_4_En_14_Fig4_HTML.jpg

图 14-4。

通过事件绑定突出显示表行

这个例子展示了用户交互如何将新数据驱动到应用中,并启动变化检测过程,导致 Angular 重新评估字符串插值和类绑定所使用的表达式。这种数据流是 Angular 应用的生命所在:第 12 和 13 章中描述的绑定和指令动态响应应用状态的变化,创建完全在浏览器中生成和管理的内容。

What Happened to Dynamically Created Properties?

早期版本的 Angular 允许模板使用在运行时创建的、没有在组件中定义的属性。这种技术利用了 JavaScript 的动态特性,尽管在应用被编译用于生产时它被标记为错误。Angular 9 引入了新的构建工具来防止这种把戏,确保模板使用的工具必须由组件定义。

使用事件数据

前面的例子使用事件绑定来连接组件提供的两段数据:当mouseevent被触发时,绑定的表达式使用由组件的getProducts方法提供给ngfor指令的数据值来设置selectedProduct属性。

事件绑定也可用于使用浏览器提供的详细信息,将新数据从事件本身引入应用。清单 14-7 向模板中添加了一个input元素,并使用事件绑定来监听input事件,当input元素的内容发生变化时就会触发该事件。

<div class="m-2">
  <div class="bg-info text-white p-2">
    Selected Product: {{selectedProduct || '(None)'}}
  </div>
  <table class="table table-sm table-bordered m-2">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <tr *ngFor="let item of getProducts(); let i = index"
        [class.bg-info]="getSelected(item)">
      <td (mouseover)="selectedProduct=item.name">{{i + 1}}</td>
      <td>{{item.name}}</td>
      <td>{{item.category}}</td>
      <td>{{item.price}}</td>
    </tr>
  </table>
  <div class="form-group">
    <label>Product Name</label>
    <input class="form-control" (input)="selectedProduct=$event.target.value" />
  </div>
</div>

Listing 14-7.Using an Event Object in the template.html File in the src/app Folder

当浏览器触发一个事件时,它会提供一个描述它的对象。不同类别的事件(鼠标事件、键盘事件、表单事件等)有不同类型的事件对象,但所有事件都共享表 14-3 中描述的三个属性。

表 14-3。

所有 DOM 事件对象共有的属性

|

名字

|

描述

|
| --- | --- |
| type | 该属性返回一个string,它标识已经触发的事件的类型。 |
| target | 该属性返回触发事件的object,它通常是表示 DOM 中 HTML 元素的对象。 |
| timeStamp | 该属性返回一个包含事件触发时间的number,以 1970 年 1 月 1 日以来的毫秒数表示。 |

事件对象被分配给一个名为$event的模板变量,清单 14-7 中的绑定表达式使用这个变量来访问事件对象的target属性。

在 DOM 中,input元素由一个HTMLInputElement对象表示,该对象定义了一个value属性,可以用来获取和设置input元素的内容。绑定表达式通过将组件的selectedProduct属性的值设置为input元素的 value 属性的值来响应input事件,如下所示:

...
<input class="form-control" (input)="selectedProduct=$event.target.value" />
...

当用户编辑input元素的内容时会触发input事件,因此组件的selectedProduct属性会在每次击键后用input元素的内容更新。当用户键入input元素时,使用字符串插值绑定,输入的文本显示在浏览器窗口的顶部。

selectedProduct属性与它们代表的产品名称匹配时,应用于tr元素的ngClass绑定设置表格行的背景颜色。而且,现在selectedProduct属性的值是由input元素的内容驱动的,键入一个产品的名称将导致相应的行被高亮显示,如图 14-5 所示。

img/421542_4_En_14_Fig5_HTML.jpg

图 14-5。

使用事件数据

使用不同的绑定协同工作是有效的 Angular 开发的核心,它使得创建能够立即响应用户交互和数据模型变化的应用成为可能。

使用模板引用变量

在第十三章中,我解释了如何使用模板变量在模板内传递数据,比如使用ngFor指令时为当前对象定义一个变量。模板引用变量是模板变量的一种形式,可以用来引用模板中的元素,如清单 14-8 所示。

<div class="m-2">
  <div class="bg-info text-white p-2">
    Selected Product: {{product.value || '(None)'}}
  </div>
  <table class="table table-sm table-bordered m-2">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <tr *ngFor="let item of getProducts(); let i = index"
        (mouseover)="product.value=item.name"
        [class.bg-info]="product.value==item.name">
      <td (mouseover)="selectedProduct=item.name">{{i + 1}}</td>
      <td>{{item.name}}</td>
      <td>{{item.category}}</td>
      <td>{{item.price}}</td>
    </tr>
  </table>
  <div class="form-group">
    <label>Product Name</label>
    <input #product class="form-control" (input)="false" />
  </div>
</div>

Listing 14-8.Using a Template Variable in the template.html File in the src/app Folder

参考变量使用#字符定义,后跟变量名。在清单中,我像这样定义了一个名为product的变量:

...
<input #product class="form-control" (input)="false" />
...

当 Angular 遇到模板中的引用变量时,它会将其值设置为它所应用到的元素。在这个例子中,product引用变量被赋予了代表 DOM 中的input元素的对象,即HTMLInputElement对象。同一模板中的其他绑定可以使用引用变量。字符串插值绑定演示了这一点,它也使用了product变量,如下所示:

...
Selected Product: {{product.value || '(None)'}}
...

如果value属性返回null,则该绑定显示由已经分配给产品变量或字符串(None)HTMLInputElement定义的value属性。模板变量也可用于更改元素的状态,如以下绑定所示:

...
<tr *ngFor="let item of getProducts(); let i = index"
    (mouseover)="product.value=item.name"
    [class.bg-info]="product.value==item.name">
...

事件绑定通过在已经分配给product变量的HTMLInputElement上设置value属性来响应mouseover事件。结果是将鼠标移动到其中一个tr元素上将会更新input元素的内容。

这个例子有一个尴尬的地方,那就是在input元素上绑定input事件。

...
<input #product class="form-control" (input)="false" />
...

当用户编辑input元素的内容时,Angular 不会更新模板中的数据绑定,除非该元素上有事件绑定。将绑定设置为false给 Angular 一些东西来评估,这样更新过程就会开始,并在整个模板中分发input元素的当前内容。这是将模板引用变量的角色扩展得太远的一种怪癖,在大多数实际项目中您不需要这样做。正如您将在后面的例子和章节中看到的,大多数数据绑定依赖于模板组件定义的变量。

Filtering Key Events

每当input元素中的内容发生变化时,就会触发input事件。这提供了一组即时响应的更改,但这并不是每个应用都需要的,尤其是当更新应用状态涉及到昂贵的操作时。

事件绑定具有内置支持,在绑定到键盘事件时更具选择性,这意味着只有在按下特定键时才会执行更新。下面是一个响应每次击键的绑定:

...
<input #product class="form-control" (keyup)="selectedProduct=product.value" />
...

keyup事件是一个标准的 DOM 事件,其结果是当用户在输入input元素时释放每个键,应用就会更新。我可以通过将名称指定为事件绑定的一部分来更具体地确定我感兴趣的键,如下所示:

...
<input #product class="form-control"
    (keyup.enter)="selectedProduct=product.value" />
...

绑定将响应的键是通过在 DOM 事件名称后附加一个句点,后跟键名来指定的。这个绑定是针对回车键的,结果是在按下该键之前,input元素中的更改不会被推送到应用的其余部分。

使用双向数据绑定

可以组合绑定来为单个元素创建双向数据流,允许 HTML 文档在应用模型改变时做出响应,也允许应用在元素发出事件时做出响应,如清单 14-9 所示。

<div class="m-2">
  <div class="bg-info text-white p-2">
    Selected Product: {{selectedProduct || '(None)'}}
  </div>
  <table class="table table-sm table-bordered m-2">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <tr *ngFor="let item of getProducts(); let i = index"
        [class.bg-info]="getSelected(item)">
      <td (mouseover)="selectedProduct=item.name">{{i + 1}}</td>
      <td>{{item.name}}</td>
      <td>{{item.category}}</td>
      <td>{{item.price}}</td>
    </tr>
  </table>
  <div class="form-group">
    <label>Product Name</label>
    <input class="form-control" (input)="selectedProduct=$event.target.value"
           [value]="selectedProduct || ''" />
  </div>
  <div class="form-group">
    <label>Product Name</label>
    <input class="form-control" (input)="selectedProduct=$event.target.value"
           [value]="selectedProduct || ''" />
  </div>
</div>

Listing 14-9.Creating a Two-Way Binding in the template.html File in the src/app Folder

每个input元素都有一个事件绑定和一个属性绑定。事件绑定通过更新组件的selectedProduct属性来响应input事件。属性绑定将selectedProduct属性的值绑定到元素的value属性。

结果是两个input元素的内容被同步,编辑其中一个会导致另一个也被更新。而且,由于模板中还有其他依赖于selectedProduct属性的绑定,编辑input元素的内容也会改变字符串插值绑定显示的数据,并改变高亮显示的表格行,如图 14-6 所示。

img/421542_4_En_14_Fig6_HTML.jpg

图 14-6。

创建双向数据绑定

当您在浏览器中试验它时,这是一个最有意义的例子。在其中一个input元素中输入一些文本,您会看到同样的文本显示在另一个input元素和div元素中,它们的内容由字符串插值绑定管理。如果您在其中一个输入元素中输入一个产品的名称,比如 Kayak 或 Lifejacket,那么您还会看到表中相应的行被突出显示。

mouseover事件的事件绑定仍然生效,这意味着当您将鼠标指针移动到表中的第一行时,selectedProduct值的变化将导致input元素显示产品名称。

使用 ngModel 指令

ngModel指令用于简化双向绑定,这样您就不必对同一个元素同时应用事件和属性绑定。清单 14-10 展示了如何用ngModel指令替换单独的绑定。

<div class="m-2">
  <div class="bg-info text-white p-2">
    Selected Product: {{selectedProduct || '(None)'}}
  </div>
  <table class="table table-sm table-bordered m-2">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <tr *ngFor="let item of getProducts(); let i = index"
        [class.bg-info]="getSelected(item)">
      <td (mouseover)="selectedProduct=item.name">{{i + 1}}</td>
      <td>{{item.name}}</td>
      <td>{{item.category}}</td>
      <td>{{item.price}}</td>
    </tr>
  </table>
  <div class="form-group">
    <label>Product Name</label>
    <input class="form-control" [(ngModel)]="selectedProduct" />
  </div>
  <div class="form-group">
    <label>Product Name</label>
    <input class="form-control" [(ngModel)]="selectedProduct" />
  </div>
</div>

Listing 14-10.Using the ngModel Directive in the template.html File in the src/app Folder

使用ngModel指令需要结合属性和事件绑定的语法,如图 14-7 所示。

img/421542_4_En_14_Fig7_HTML.jpg

图 14-7。

双向数据绑定的剖析

方括号和圆括号的组合用于表示双向数据绑定,圆括号放在方括号内:[()]。Angular 开发团队称之为盒中香蕉绑定,因为这就是括号和圆括号像这样放置时的样子[()]。嗯,算是吧。

绑定的目标是ngModel指令,它包含在 Angular 中是为了简化在表单元素上创建双向数据绑定,比如本例中使用的input元素。

双向数据绑定的表达式是属性的名称,用于在后台设置各个绑定。当input元素的内容改变时,新的内容将用于更新selectedProduct属性的值。同样,当selectedProduct的值改变时,它将被用来更新元素的内容。

指令知道标准 HTML 元素定义的事件和属性的组合。在幕后,一个事件绑定被应用到input事件,一个属性绑定被应用到value属性。

Tip

记住在ngModel绑定中使用括号和圆括号是很重要的。如果您只使用括号—(ngModel)—那么您正在为一个不存在的名为ngModel的事件设置一个事件绑定。结果是一个元素不会被更新或者不会更新应用的其余部分。您可以使用带有方括号的ngModel指令—[ngModel]—Angular 将设置元素的初始值,但不会监听事件,这意味着用户所做的更改不会自动反映在应用模型中。

使用表单

大多数 web 应用依赖表单从用户那里接收数据,上一节描述的双向ngModel绑定为在 angle 应用中使用表单提供了基础。在本节中,我将创建一个表单,允许创建新产品并将其添加到应用的数据模型中,然后描述 Angular 提供的一些更高级的表单特性。

向示例应用添加表单

清单 14-11 显示了创建表单时将使用的组件的一些增强,并删除了一些不再需要的特性。

import { ApplicationRef, Component } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();

    getProduct(key: number): Product {
        return this.model.getProduct(key);
    }

    getProducts(): Product[] {
        return this.model.getProducts();
    }

    newProduct: Product = new Product();

    get jsonProduct() {
        return JSON.stringify(this.newProduct);
    }

    addProduct(p: Product) {
        console.log("New Product: " + this.jsonProduct);
    }
}

Listing 14-11.Enhancing the Component in the component.ts File in the src/app Folder

清单添加了一个名为newProduct的新属性,用于存储用户输入表单的数据。还有一个带有 getter 的jsonProduct属性,它返回newProduct属性的 JSON 表示,并将在模板中用于显示双向绑定的效果。(我不能直接在模板中创建对象的 JSON 表示,因为 JSON 对象是在全局名称空间中定义的,正如我在第十三章中解释的,不能直接从模板表达式中访问。)

最后添加的是一个addProduct方法,它将jsonProduct方法的值写出到控制台;这将让我演示一些基本的与表单相关的特性,然后在本章的后面添加对更新数据模型的支持。

在清单 14-12 中,模板内容已经被一系列由Product类定义的属性的input元素所取代。

<div class="m-2">
  <div class="bg-info text-white mb-2 p-2">Model Data: {{jsonProduct}}</div>

  <div class="form-group">
    <label>Name</label>
    <input class="form-control" [(ngModel)]="newProduct.name" />
  </div>
  <div class="form-group">
    <label>Category</label>
    <input class="form-control" [(ngModel)]="newProduct.category" />
  </div>
  <div class="form-group">
    <label>Price</label>
    <input class="form-control" [(ngModel)]="newProduct.price" />
  </div>
  <button class="btn btn-primary" (click)="addProduct(newProduct)">Create</button>
</div>

Listing 14-12.Adding Input Elements in the template.html File in the src/app Folder

每个input元素用一个label分组在一起,并包含在一个div元素中,该元素使用 Bootstrap form-group类进行样式化。单个的input元素被分配给 Bootstrap form-control类来管理布局和风格。

ngModel绑定已经应用于每个input元素,以创建一个与组件的newProduct对象上的相应属性的双向绑定,如下所示:

...
<input class="form-control" [(ngModel)]="newProduct.name" />
...

还有一个button元素,它有一个调用组件的addProduct方法的click事件的绑定,将newProduct值作为参数传入。

...
<button class="btn btn-primary" (click)="addProduct(newProduct)">Create</button>
...

最后,字符串插值绑定用于在模板顶部显示组件的newProduct属性的 JSON 表示,如下所示:

...
<div class="bg-info text-white mb-2 p-2">Model Data: {{jsonProduct}}</div>
...

如图 14-8 所示,总体结果是一组input元素,它们更新由组件管理的Product对象的属性,这些属性立即反映在 JSON 数据中。

img/421542_4_En_14_Fig8_HTML.jpg

图 14-8。

使用表单元素在数据模型中创建新对象

当单击 Create 按钮时,组件的newProduct属性的 JSON 表示被写入浏览器的 JavaScript 控制台,产生如下结果:

New Product: {"name":"Running Shoes","category":"Running","price":"120.23"}

添加表单数据验证

此时,任何数据都可以输入到表单的input元素中。数据验证在 web 应用中是必不可少的,因为用户会输入范围惊人的数据值,这可能是错误的,也可能是因为他们希望尽可能快地结束该过程,并输入垃圾值以继续。

Angular 基于 HTML5 标准使用的方法,提供了一个可扩展的系统来验证表单元素的内容。您可以向input元素添加四个属性,每个属性定义一个验证规则,如表 14-4 中所述。

表 14-4。

内置的 Angular 验证属性

|

属性

|

描述

|
| --- | --- |
| required | 此属性用于指定必须提供的值。 |
| minlength | 此属性用于指定最小字符数。 |
| maxlength | 此属性用于指定最大字符数。这种类型的验证不能直接应用于表单元素,因为它与同名的 HTML5 属性冲突。它可以与基于模型的表单一起使用,这将在本章的后面介绍。 |
| pattern | 此属性用于指定用户提供的值必须匹配的正则表达式。 |

您可能对这些属性很熟悉,因为它们是 HTML 规范的一部分,但是 Angular 在这些属性的基础上构建了一些额外的特性。清单 14-13 删除了除了一个input元素之外的所有元素,以尽可能简单地演示向表单添加验证的过程。(我将在本章的后面部分恢复缺失的元素。)

<div class="m-2">
  <div class="bg-info p-2 mb-2">Model Data: {{jsonProduct}}</div>

  <form novalidate (ngSubmit)="addProduct(newProduct)">
    <div class="form-group">
      <label>Name</label>
      <input class="form-control"
             name="name"
             [(ngModel)]="newProduct.name"
             required
             minlength="5"
             pattern="^[A-Za-z ]+$" />
    </div>
    <button class="btn btn-primary" type="submit">
      Create
    </button>
  </form>
</div>

Listing 14-13.Adding Form Validation in the template.html File in the src/app Folder

Angular 要求被验证的元素定义name属性,该属性用于在验证系统中标识元素。因为这个input元素被用来捕获Product.name属性的值,所以元素上的name属性被设置为name

这个清单向input元素添加了四个验证属性中的三个。required属性指定用户必须提供一个值,minlength属性指定应该至少有三个字符,pattern属性指定只允许字母字符和空格。

Angular 使用的验证属性与 HTML 5 规范使用的属性相同,所以我在form元素中添加了novalidate属性,它告诉浏览器不要使用它的原生验证特性,这些特性在不同的浏览器中实现不一致,通常会造成障碍。因为 Angular 将提供验证,所以不需要浏览器自己实现这些特性。

最后,注意一个form元素已经被添加到模板中。虽然您可以单独使用input元素,但是只有当存在form元素时,Angular 验证特性才起作用,如果您将ngControl指令添加到不包含在form中的元素,Angular 将报告一个错误。

当使用一个form元素时,惯例是为一个叫做ngSubmit的特殊事件使用一个事件绑定,如下所示:

...
<form novalidate (ngSubmit)="addProduct(newProduct)">
...

绑定处理form元素的submit事件。如果您愿意,您可以在form中的单个button元素上绑定到click事件来实现相同的效果。

使用验证类设计元素的样式

一旦您保存了清单 14-13 中的模板更改,并且浏览器重新加载了 HTML,在浏览器窗口中右键单击input元素并从弹出窗口中选择检查或检查元素。浏览器将在开发者工具窗口中显示元素的 HTML 表示,您将看到input元素已经被添加到三个类中,如下所示:

...
<input class="form-control ng-pristine ng-invalid ng-touched" minlength="5"
    name="name" pattern="^[A-Za-z ]+$" required="" ng-reflect-name="name">
...

一个input元素被分配到的类提供了它的验证状态的细节。有三对验证类,如表 14-5 所述。元素总是每对中的一个类的成员,总共三个类。相同的类被应用于form元素,以显示它包含的所有元素的整体验证状态。随着input元素状态的改变,ngControl指令会自动切换单个元素和表单元素的类。

表 14-5。

Angular 形式验证类

|

名字

|

描述

|
| --- | --- |
| ng-untouchedng-touched | 如果用户没有访问过一个元素,那么这个元素就被分配给ng-untouched类,这通常是通过在表单域中跳转来完成的。一旦用户访问了一个元素,它就会被添加到ng-touched类中。 |
| ng-pristineng-dirty | 如果一个元素的内容没有被用户改变,则该元素被分配给ng-pristine类,否则被分配给ng-dirty类。一旦内容被编辑,一个元素会保留在ng-dirty类中,即使用户返回到之前的内容。 |
| ng-validng-invalid | 如果一个元素的内容满足应用于它的验证规则所定义的标准,则该元素被分配给ng-valid类,否则被分配给ng-invalid类。 |

这些类可以用来设计表单元素的样式,为用户提供验证反馈。清单 14-14 向模板添加了一个style元素,并定义了指示用户何时输入了无效或有效数据的样式。

Tip

在实际应用中,样式应该在单独的样式表中定义,并通过index.html文件或使用组件的装饰器设置(我在第十七章中描述)包含在应用中。为了简单起见,我将样式直接包含在模板中,但是这使得实际的应用更难维护,因为当使用多个模板时,很难弄清楚样式来自哪里。

<style>
    input.ng-dirty.ng-invalid { border: 2px solid #ff0000 }
    input.ng-dirty.ng-valid { border: 2px solid #6bc502 }
</style>

<div class="m-2">
  <div class="bg-info p-2 mb-2">Model Data: {{jsonProduct}}</div>

  <form novalidate (ngSubmit)="addProduct(newProduct)">
    <div class="form-group">
      <label>Name</label>
      <input class="form-control"
             name="name"
             [(ngModel)]="newProduct.name"
             required
             minlength="5"
             pattern="^[A-Za-z ]+$" />
    </div>
    <button class="btn btn-primary" type="submit">
      Create
    </button>
  </form>
</div>

Listing 14-14.Providing Validation Feedback in the template.html File in the src/app Folder

这些样式为内容已被编辑且有效(因此属于ng-dirtyng-valid类)和内容无效(因此属于ng-dirtyng-invalid类)的input元素设置绿色和红色边框。使用ng-dirty类意味着元素的外观不会改变,直到用户输入一些内容。

Angular 在每次击键或焦点改变后验证内容并改变input元素的类成员。浏览器检测到元素的变化并动态应用样式,这在用户向表单输入数据时为用户提供验证反馈,如图 14-9 所示。

img/421542_4_En_14_Fig9_HTML.jpg

图 14-9。

提供验证反馈

当我开始输入时,input元素显示为无效,因为没有足够的字符来满足minlength属性。一旦有五个字符,边框为绿色,表示数据有效。当我键入字符2时,边框再次变成红色,因为pattern属性被设置为只允许字母和空格。

Tip

如果您查看图 14-9 中页面顶部的 JSON 数据,您会看到数据绑定仍然在更新,即使数据值无效。验证与数据绑定一起运行,在没有检查整个表单是否有效的情况下,您不应该处理表单数据,如“验证整个表单”一节中所述。

显示字段级验证消息

使用颜色来提供验证反馈告诉用户有问题,但是并没有提供用户应该做什么的任何指示。ngModel指令提供了对它所应用的元素的验证状态的访问,这可以用来向用户显示指导。清单 14-15 使用ngModel指令提供的支持,为应用于input元素的每个属性添加验证消息。

<style>
    input.ng-dirty.ng-invalid { border: 2px solid #ff0000 }
    input.ng-dirty.ng-valid { border: 2px solid #6bc502 }
</style>

<div class="m-2">
  <div class="bg-info p-2 mb-2">Model Data: {{jsonProduct}}</div>

  <form novalidate (ngSubmit)="addProduct(newProduct)">
    <div class="form-group">
      <label>Name</label>
      <input class="form-control"
             name="name"
             [(ngModel)]="newProduct.name"
             #name="ngModel"
             required
             minlength="5"
             pattern="^[A-Za-z ]+$" />
      <ul class="text-danger list-unstyled" *ngIf="name.dirty && name.invalid">
        <li *ngIf="name.errors.required">
          You must enter a product name
        </li>
        <li *ngIf="name.errors.pattern">
          Product names can only contain letters and spaces
        </li>
        <li *ngIf="name.errors.minlength">
          Product names must be at least
          {{name.errors.minlength.requiredLength}} characters
        </li>
      </ul>
    </div>
    <button class="btn btn-primary" type="submit">
      Create
    </button>
  </form>
</div>

Listing 14-15.Adding Validation Messages in the template.html File in the src/app Folder

为了让验证工作,我必须创建一个模板引用变量来访问表达式中的验证状态,我是这样做的:

...
<input class="form-control" name="name" [(ngModel)]="newProduct.name"
    #name="ngModel" required minlength="5" pattern="^[A-Za-z ]+$"/>
...

我创建了一个名为name的模板引用变量,并将其值设置为ngModel。使用ngModel值有点令人困惑:这是由ngModel指令提供的一个特性,用于访问验证状态。一旦你阅读了第 15 和第十六章,这将更有意义,在这两章中,我解释了如何创建自定义指令,你将看到它们如何提供对其特性的访问。对于本章来说,知道为了显示验证消息,您需要创建一个模板引用变量并将其分配给ngModel来访问input元素的验证数据就足够了。分配给模板参考变量的对象定义了表 14-6 中描述的属性。

表 14-6。

验证对象属性

|

名字

|

描述

|
| --- | --- |
| path | 此属性返回元素的名称。 |
| valid | 如果元素的内容有效,该属性返回true,否则返回false。 |
| invalid | 如果元素的内容无效,该属性返回true,否则返回false。 |
| pristine | 如果元素的内容没有改变,这个属性返回true。 |
| dirty | 如果元素的内容已经改变,这个属性返回true。 |
| touched | 如果用户已经访问了元素,这个属性返回true。 |
| untouched | 如果用户没有访问过元素,这个属性返回true。 |
| errors | 此属性返回一个对象,该对象的属性对应于存在验证错误的每个属性。 |
| value | 该属性返回元素的value,它在定义自定义验证规则时使用,如“创建自定义表单验证器”一节所述。 |

清单 14-15 以列表形式显示验证信息。只有当至少有一个验证错误时,才应该显示该列表,所以我对ul元素应用了ngIf指令,并使用了一个使用dirtyinvalid属性的表达式,如下所示:

...
<ul class="text-danger list-unstyled" *ngIf="name.dirty && name.invalid">
...

ul元素中,有一个li元素对应于每个可能发生的验证错误。每个li元素都有一个使用表 14-6 中描述的errors属性的ngIf指令,如下所示:

...
<li *ngIf="name.errors.required">You must enter a product name</li>
...

只有当元素的内容没有通过required验证检查时,才会定义errors.required属性,这将li元素的可见性与验证检查的结果联系起来。

Using the Safe Navigation Property With Forms

只有当存在验证错误时,才会创建errors属性,这就是为什么我要在ul元素的表达式中检查invalid属性的值。另一种方法是使用安全导航属性,该属性在模板中用于导航一系列属性,如果其中一个属性返回null,则不会产生错误。下面是定义清单 14-15 中模板的另一种方法,它不检查valid属性,而是依赖于安全导航属性:

...
<ul class="text-danger list-unstyled" *ngIf="name.dirty">
    <li *ngIf="name.errors?.required">
        You must enter a product name
    </li>
    <li *ngIf="name.errors?.pattern">
        Product names can only contain letters and spaces
    </li>
    <li *ngIf="name.errors?.minlength">
        Product names must be at least
        {{name.errors.minlength.requiredLength}} characters
    </li>
</ul>
...

如果属性是nullundefined,在属性名后添加一个?字符告诉 Angular 不要试图访问任何后续的属性或方法。在这个例子中,我在errors属性后应用了?字符,这意味着如果error属性没有被定义,Angular 不会尝试读取requiredpatternminlength属性。

errors对象定义的每个属性返回一个对象,该对象的属性提供了为什么内容没有通过属性验证检查的细节,这可以用来使验证消息对用户更有帮助。表 14-7 描述了为每个属性提供的error属性。

表 14-7。

Angular 形式验证错误描述属性

|

名字

|

描述

|
| --- | --- |
| required | 如果required属性已经应用于 input 元素,则该属性返回true。这不是特别有用,因为这可以从required属性存在的事实中推断出来。 |
| minlength.requiredLength | 该属性返回满足minlength属性所需的字符数。 |
| minlength.actualLength | 该属性返回用户输入的字符数。 |
| pattern.requiredPattern | 该属性返回使用pattern属性指定的正则表达式。 |
| pattern.actualValue | 此属性返回元素的内容。 |

这些属性不会直接显示给用户,用户不太可能理解包含正则表达式的错误消息,尽管它们在开发过程中对解决验证问题很有用。例外情况是minlength.requiredLength属性,它有助于避免重复分配给元素上的minlength属性的值,如下所示:

...
<li *ngIf="name.errors.minlength">
  Product names must be at least {{name.errors.minlength.requiredLength}} characters
</li>
...

总的结果是一组验证消息,一旦用户开始编辑input元素,这些消息就会显示出来,并且会改变以反映每个新的击键,如图 14-10 所示。

img/421542_4_En_14_Fig10_HTML.jpg

图 14-10。

显示验证消息

使用组件显示验证消息

在复杂的表单中,为所有可能的验证错误包含单独的元素很快就会变得冗长。一个更好的方法是向组件添加一些逻辑来准备方法中的验证消息,然后可以通过模板中的ngFor指令向用户显示这些消息。清单 14-16 显示了一个组件方法的添加,该方法接受一个input元素的验证状态并产生一个验证消息数组。

import { ApplicationRef, Component } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();

    getProduct(key: number): Product {
        return this.model.getProduct(key);
    }

    getProducts(): Product[] {
        return this.model.getProducts();
    }

    newProduct: Product = new Product();

    get jsonProduct() {
        return JSON.stringify(this.newProduct);
    }

    addProduct(p: Product) {
        console.log("New Product: " + this.jsonProduct);
    }

    getValidationMessages(state: any, thingName?: string) {
        let thing: string = state.path || thingName;
        let messages: string[] = [];
        if (state.errors) {
            for (let errorName in state.errors) {
                switch (errorName) {
                    case "required":
                        messages.push(`You must enter a ${thing}`);
                        break;
                    case "minlength":
                        messages.push(`A ${thing} must be at least
                            ${state.errors['minlength'].requiredLength}
                            characters`);
                        break;
                    case "pattern":
                        messages.push(`The ${thing} contains
                             illegal characters`);
                        break;
                }
            }
        }
        return messages;
    }
}

Listing 14-16.Generating Validation Messages in the component.ts File in the src/app Folder

getValidationMessages方法使用表 14-6 中描述的属性为每个错误生成验证消息,并以字符串数组的形式返回。为了使这段代码尽可能广泛地适用,该方法接受一个值,该值描述了一个input元素打算从用户那里收集的数据项,然后该数据项用于生成错误消息,如下所示:

...
messages.push(`You must enter a ${thing}`);
...

这是 JavaScript 字符串插值特性的一个例子,它允许像模板一样定义字符串,而不必使用+操作符来包含数据值。注意,模板字符串是用反斜杠字符表示的(字符```ts,而不是普通的 JavaScript 字符')。如果调用方法时没有收到参数,getValidationMessages方法默认使用path属性作为描述性字符串,如下所示:

...
let thing: string = state.path || thingName;
...

```ts

清单 14-17 展示了如何在模板中使用`getValidationMessages`为用户生成验证错误消息,而不需要为每个消息定义单独的元素和绑定。

Model Data: {{jsonProduct}}
  • {{error}}

Listing 14-17.Getting Validation Messages in the template.html File in the src/app Folder


没有视觉上的变化,但是可以使用相同的方法为多个元素生成验证消息,这导致了一个更简单的模板,更易于阅读和维护。

### 验证整个表单

显示单个字段的验证错误消息非常有用,因为它有助于强调需要修复的问题。但是验证整个表单也很有用。在用户尝试提交表单之前,一定要注意不要让错误消息淹没用户,此时任何问题的摘要都是有用的。在准备阶段,清单 14-18 向组件添加了两个新成员。

import { ApplicationRef, Component } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Model } from "./repository.model";
import { Product } from "./product.model";

@Component({
selector: "app",
templateUrl: "template.html"
})
export class ProductComponent {
model: Model = new Model();

// ...other methods omitted for brevity...

formSubmitted: boolean = false;

submitForm(form: NgForm) {
    this.formSubmitted = true;
    if (form.valid) {
        this.addProduct(this.newProduct);
        this.newProduct = new Product();
        form.reset();
        this.formSubmitted = false;
    }
}

}

Listing 14-18.Enhancing the Component in the component.ts File in the src/app Folder


`formSubmitted`属性将用于指示表单是否已经提交,并将用于防止整个表单的验证,直到用户尝试提交。

当用户提交表单并接收一个`NgForm`对象作为参数时,将调用`submitForm`方法。此对象表示表单并定义一组验证属性;这些属性用于描述表单的整体验证状态,例如,如果表单包含的任何元素存在验证错误,那么`invalid`属性将为`true`。除了 validation 属性,`NgForm`还提供了`reset`方法,该方法重置表单的验证状态,并将其返回到原始状态。

其效果是,当用户执行提交时,整个表单将被验证,如果没有验证错误,在表单被重置之前,一个新的对象将被添加到数据模型中,以便可以再次使用它。清单 14-19 显示了利用这些新特性和实现表单范围验证所需的模板更改。

<div class="bg-danger text-white p-2 mb-2"
     *ngIf="formSubmitted && form.invalid">
  There are problems with the form
</div>

<div class="form-group">
  <label>Name</label>
  <input class="form-control"
         name="name"
         [(ngModel)]="newProduct.name"
         #name="ngModel"
         required
         minlength="5"
         pattern="^[A-Za-z ]+$" />
  <ul class="text-danger list-unstyled"
      *ngIf="(formSubmitted || name.dirty) && name.invalid">
    <li *ngFor="let error of getValidationMessages(name)">
      {{error}}
    </li>
  </ul>
</div>
<button class="btn btn-primary" type="submit">
  Create
</button>

Listing 14-19.Performing Form-Wide Validation in the template.html File in the src/app Folder


`form`元素现在定义了一个名为`form`的引用变量,它已经被赋值给`ngForm`。这就是`ngForm`指令如何通过我在第十五章中描述的过程提供对其功能的访问。然而,现在重要的是要知道整个表单的验证信息可以通过`form`引用变量来访问。

清单还更改了`ngSubmit`绑定的表达式,以便它调用控制器定义的`submitForm`方法,传入模板变量,如下所示:

...

...

这个对象作为`submitForm`方法的参数接收,用于检查表单的验证状态,并重置表单,以便可以再次使用。

清单 14-19 还添加了一个`div`元素,该元素使用组件的`formSubmitted`属性和`valid`属性(由`form`模板变量提供),以便在表单包含无效数据时显示一条警告消息,但仅在表单提交之后。

此外,`ngIf`绑定已经更新,可以显示字段级验证消息,这样当表单提交后,即使元素本身没有被编辑,它们也会显示出来。结果是一个验证摘要,只有当用户提交包含无效数据的表单时才会显示,如图 14-11 所示。

![img/421542_4_En_14_Fig11_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-frontend-framework-zh/raw/master/docs/pro-ng9/img/421542_4_En_14_Fig11_HTML.jpg)

图 14-11。

显示验证摘要消息

#### 显示摘要验证消息

在复杂的表单中,向用户提供必须解决的所有验证错误的摘要会很有帮助。分配给`form`模板引用变量的`NgForm`对象通过名为`controls`的属性提供对单个元素的访问。此属性返回一个对象,该对象具有表单中每个单独元素的属性。例如,示例中有一个代表`input`元素的`name`属性,该属性被分配了一个代表该元素的对象,并定义了可用于单个元素的相同验证属性。在清单 14-20 中,我为组件添加了一个方法,该方法接收分配给表单元素的模板引用变量的对象,并使用其`controls`属性为整个表单生成一个错误消息列表。

import { ApplicationRef, Component } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Model } from "./repository.model";
import { Product } from "./product.model";

@Component({
selector: "app",
templateUrl: "template.html"
})
export class ProductComponent {
model: Model = new Model();

// ...other methods omitted for brevity...

getFormValidationMessages(form: NgForm): string[] {
    let messages: string[] = [];
    Object.keys(form.controls).forEach(k => {
        this.getValidationMessages(form.controls[k], k)
            .forEach(m => messages.push(m));
    });
    return messages;
}

}

Listing 14-20.Generating Form-Wide Validation Messages in the component.ts File in the src/app Folder


对于表单中的每个控件,`getFormValidationMessages`方法通过调用清单 14-16 中定义的`getValidationMessages`方法来构建它的消息列表。`Object.keys`方法从由`controls`属性返回的对象定义的属性中创建一个数组,该属性使用`forEach`方法枚举。

在清单 14-21 中,我使用了这种方法在表单的顶部包含单独的消息,一旦用户单击 Create 按钮,就可以看到这些消息。

<div class="bg-danger text-white p-2 mb-2"
     *ngIf="formSubmitted && form.invalid">
  There are problems with the form
  <ul>
    <li *ngFor="let error of getFormValidationMessages(form)">
      {{error}}
    </li>
  </ul>
</div>

<div class="form-group">
  <label>Name</label>
  <input class="form-control"
         name="name"
         [(ngModel)]="newProduct.name"
         #name="ngModel"
         required
         minlength="5"
         pattern="^[A-Za-z ]+$" />
  <ul class="text-danger list-unstyled"
      *ngIf="(formSubmitted || name.dirty) && name.invalid">
      <li *ngFor="let error of getValidationMessages(name)">
        {{error}}
      </li>
    </ul>
</div>
<button class="btn btn-primary" type="submit">
  Create
</button>

Listing 14-21.Displaying Form-Wide Validation Messages in the template.html File in the src/app Folder


结果是验证消息显示在`input`元素旁边,并在提交后收集在表单顶部,如图 14-12 所示。

![img/421542_4_En_14_Fig12_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-frontend-framework-zh/raw/master/docs/pro-ng9/img/421542_4_En_14_Fig12_HTML.jpg)

图 14-12。

显示整体验证摘要

#### 禁用提交按钮

这一部分的最后一个调整是在用户提交表单后禁用按钮,防止用户再次单击它,直到所有的验证错误都得到解决。这是一种常用的技术,尽管它对应用没有什么影响,应用不会接受来自包含无效值的表单的数据,但它向用户提供了有用的提示,即在验证问题解决之前,他们不能继续操作。

在清单 14-22 中,我在`button`元素上使用了属性绑定,并为`price`属性添加了一个`input`元素,以展示这种方法如何扩展表单中的多个元素。

    <div class="bg-danger text-white p-2 mb-2"
             *ngIf="formSubmitted && form.invalid">
        There are problems with the form
        <ul>
            <li *ngFor="let error of getFormValidationMessages(form)">
                {{error}}
            </li>
        </ul>
    </div>

    <div class="form-group">
        <label>Name</label>
        <input class="form-control"
               name="name"
               [(ngModel)]="newProduct.name"
               #name="ngModel"
               required
               minlength="5"
               pattern="^[A-Za-z ]+$" />
        <ul class="text-danger list-unstyled"
            *ngIf="(formSubmitted || name.dirty) && name.invalid">
            <li *ngFor="let error of getValidationMessages(name)">
                {{error}}
            </li>
        </ul>
    </div>

    <div class="form-group">
        <label>Price</label>
        <input class="form-control" name="price" [(ngModel)]="newProduct.price"
               #price="ngModel" required pattern="^[0-9\.]+$" />
        <ul class="text-danger list-unstyled"
            *ngIf="(formSubmitted || price.dirty) && price.invalid">
            <li *ngFor="let error of getValidationMessages(price)">
                {{error}}
            </li>
        </ul>
    </div>

    <button class="btn btn-primary" type="submit"
            [disabled]="formSubmitted && form.invalid"
            [class.btn-secondary]="formSubmitted && form.invalid">
        Create
    </button>
</form>

Listing 14-22.Disabling the Button and Adding an Input Element in the template.html File in the src/app Folder


为了特别强调,当表单已经提交并且包含无效数据时,我使用类绑定将`button`元素添加到`btn-secondary`类中。该类应用了一个引导 CSS 样式,如图 14-13 所示。

![img/421542_4_En_14_Fig13_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-frontend-framework-zh/raw/master/docs/pro-ng9/img/421542_4_En_14_Fig13_HTML.jpg)

图 14-13。

禁用提交按钮

## 使用基于模型的表单

上一节中的表单依赖 HTML 元素和属性来定义组成表单的字段,并应用验证约束。这种方法的优点是熟悉和简单。缺点是大型表单变得复杂且难以维护,每个字段都需要自己的内容块来管理其布局和验证要求,并显示任何验证消息。

Angular 提供了另一种方法,称为*基于模型的表单*,其中表单的细节及其验证是在代码中定义的,而不是在模板中。这种方法可以更好地扩展,但是它需要一些前期工作,并且结果不如在模板中定义一切那样自然。在接下来的小节中,我将建立并应用一个模型来描述表单及其所需的验证。

### 启用基于模型的表单功能

对基于模型的表单的支持需要在应用的 Angular 模块中声明一个新的依赖项,如清单 14-23 所示。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";

@NgModule({
imports: [BrowserModule, FormsModule, ReactiveFormsModule],
declarations: [ProductComponent],
bootstrap: [ProductComponent]
})
export class AppModule {}

Listing 14-23.Enabling Model-Based Forms in the app.module.ts File in the src/app Folder


基于模型的表单特性是在名为`ReactiveFormsModule`的模块中定义的,该模块是在本章开始时添加到项目中的`@angular/forms` JavaScript 模块中定义的。

### 定义表单模型类

我将从定义描述表单的类开始,这样我就可以让模板尽可能简单。您不必完全遵循这种方法,但是如果您打算采用基于模型的表单,在模型中处理尽可能多的表单并最小化模板的复杂性是有意义的。我在`src/app`文件夹中添加了一个名为`form.model.ts`的文件,并添加了清单 14-24 中所示的代码。

import { FormControl, FormGroup, Validators } from "@angular/forms";

export class ProductFormControl extends FormControl {
label: string;
modelProperty: string;

constructor(label:string, property:string, value: any, validator: any) {
    super(value, validator);
    this.label = label;
    this.modelProperty = property;
}

}

export class ProductFormGroup extends FormGroup {

constructor() {
    super({
        name: new ProductFormControl("Name", "name", "", Validators.required),

        category: new ProductFormControl("Category", "category", "",
            Validators.compose([Validators.required,
                Validators.pattern("^[A-Za-z ]+$"),
                Validators.minLength(3),
                Validators.maxLength(10)])),

        price: new ProductFormControl("Price", "price", "",
            Validators.compose([Validators.required,
                Validators.pattern("^[0-9\.]+$")]))
    });
}

}

Listing 14-24.The Contents of the form.model.ts File in the src/app Folder


清单中定义的两个类扩展了 Angular 用来在后台管理表单及其内容的类。`FormControl`类用于表示表单中的单个元素,比如`input`元素,`FormGroup`类用于管理`form`元素及其内容。

新的子类增加了一些特性,使得以编程方式生成 HTML 表单变得更加容易。`ProductFormControl`类用属性扩展了`FormControl`类,这些属性指定了与`input`元素相关联的`label`元素的文本,以及`input`元素将表示的`Product`类属性的名称。

`ProductFormGroup`类扩展了`FormGroup`。这个类的重要部分是`ProductFormGroup`类的构造函数,它负责建立用于创建和验证表单的模型。`FormGroup`类的构造函数是`ProductFormGroup`的超类,它接受一个对象,该对象的属性名对应于模板中`input`元素的名称,每个元素被分配一个`ProductFormControl`对象来表示它,并指定所需的验证检查。传递给超级构造函数的对象中的第一个属性是最简单的。

...
name: new ProductFormControl("Name", "name", "", Validators.required),
...


这个属性叫做`name`,它告诉 Angular 它对应于模板中一个叫做`name`的`input`元素。`ProductFormControl`构造函数的参数指定了将与`input`元素(`Name`)关联的`label`元素的内容、`input`元素将绑定到的`Product`类属性的名称(`name`)、数据绑定的初始值(空字符串)以及所需的验证检查。Angular 在`@angular/forms`模块中定义了一个名为`Validators`的类,该类具有每个内置验证检查的属性,如表 14-8 所述。

表 14-8。

验证程序属性

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

名字

 | 

描述

 |
| --- | --- |
| `Validators.required` | 该属性对应于`required`属性,并确保输入一个值。 |
| `Validators.minLength` | 该属性对应于`minlength`属性,并确保最少的字符数。 |
| `Validators.maxLength` | 该属性对应于`maxlength`属性,并确保最大字符数。 |
| `Validators.pattern` | 该属性对应于`pattern`属性,并匹配一个正则表达式。 |

可以使用`Validators.compose`方法组合验证器,以便对单个元素执行多次检查,如下所示:

...
category: new ProductFormControl("Category", "category", "",
Validators.compose([Validators.required,
Validators.pattern("[1]+$"),
Validators.minLength(3),
Validators.maxLength(10)])),
...


`Validators.compose`方法接受一组验证器。由`pattern`、`minLength`和`maxLength`验证器定义的构造函数参数对应于属性值。这个元素的总体效果是值是必需的,必须只包含字母字符和空格,并且必须在 3 到 10 个字符之间。

下一步是将生成验证错误消息的方法从组件移到新的表单模型类中,如清单 14-25 所示。这将所有与表单相关的代码放在一起,有助于保持组件尽可能简单。(我还在`ProductFormControl`类的`getValidationMessages`方法中添加了对`maxLength`验证器的验证消息支持。)

import { FormControl, FormGroup, Validators } from "@angular/forms";

export class ProductFormControl extends FormControl {
label: string;
modelProperty: string;

constructor(label:string, property:string, value: any, validator: any) {
    super(value, validator);
    this.label = label;
    this.modelProperty = property;
}

getValidationMessages() {
    let messages: string[] = [];
    if (this.errors) {
        for (let errorName in this.errors) {
            switch (errorName) {
                case "required":
                    messages.push(`You must enter a ${this.label}`);
                    break;
                case "minlength":
                    messages.push(`A ${this.label} must be at least
                        ${this.errors['minlength'].requiredLength}
                        characters`);
                    break;
                case "maxlength":
                    messages.push(`A ${this.label} must be no more than
                        ${this.errors['maxlength'].requiredLength}
                        characters`);
                    break;
                case "pattern":
                    messages.push(`The ${this.label} contains
                         illegal characters`);
                    break;
            }
        }
    }
    return messages;
}

}

export class ProductFormGroup extends FormGroup {

constructor() {
    super({
        name: new ProductFormControl("Name", "name", "", Validators.required),

        category: new ProductFormControl("Category", "category", "",
            Validators.compose([Validators.required,
                Validators.pattern("^[A-Za-z ]+$"),
                Validators.minLength(3),
                Validators.maxLength(10)])),

        price: new ProductFormControl("Price", "price", "",
            Validators.compose([Validators.required,
                Validators.pattern("^[0-9\.]+$")]))
    });
}

get productControls(): ProductFormControl[] {
    return Object.keys(this.controls)
        .map(k => this.controls[k] as ProductFormControl);
}

getValidationMessages(name: string): string[] {
    return (this.controls['name'] as ProductFormControl).getValidationMessages();
}

getFormValidationMessages() : string[] {
    let messages: string[] = [];
    Object.values(this.controls).forEach(c =>
        messages.push(...(c as ProductFormControl).getValidationMessages()));
    return messages;
}

}

Listing 14-25.Moving the Validation Message Methods in the form.model.ts File in the src/app Folder


验证消息的生成方式与以前相同,只是做了一些小的调整,以反映代码现在是表单模型而不是组件的一部分这一事实。

### 使用模型进行验证

现在我有了一个表单模型,我可以用它来验证表单。清单 14-26 显示了组件类是如何更新的,以支持基于模型的表单,并使表单模型类可用于模板。它还删除了生成验证错误消息的方法,这些方法被移到了清单 14-25 中的表单模型类中。

import { ApplicationRef, Component } from "@angular/core";
import { NgForm, FormGroup } from "@angular/forms";
import { Model } from "./repository.model";
import { Product } from "./product.model";
import { ProductFormGroup, ProductFormControl } from "./form.model";

@Component({
selector: "app",
templateUrl: "template.html"
})
export class ProductComponent {
model: Model = new Model();
formGroup: ProductFormGroup = new ProductFormGroup();

getProduct(key: number): Product {
    return this.model.getProduct(key);
}

getProducts(): Product[] {
    return this.model.getProducts();
}

newProduct: Product = new Product();

get jsonProduct() {
    return JSON.stringify(this.newProduct);
}

addProduct(p: Product) {
    console.log("New Product: " + this.jsonProduct);
}

formSubmitted: boolean = false;

submitForm() {
    Object.keys(this.formGroup.controls)
        .forEach(c => this.newProduct[c] = this.formGroup.controls[c].value);
    this.formSubmitted = true;
    if (this.formGroup.valid) {
        this.addProduct(this.newProduct);
        this.newProduct = new Product();
        this.formGroup.reset();
        this.formSubmitted = false;
    }
}

}

Listing 14-26.Using a Form Model in the component.ts File in the src/app Folder


清单从`form.model`模块导入了`ProductFormGroup`类,并使用它来定义一个名为`form`的属性,这使得定制表单模型类可以在模板中使用。

清单 14-27 更新模板以使用基于模型的特性来处理验证,替换模板中定义的基于属性的验证配置。

<div class="bg-danger text-white p-2 mb-2"
        *ngIf="formSubmitted && formGroup.invalid">
    There are problems with the form
    <ul>
        <li *ngFor="let error of formGroup.getFormValidationMessages()">
            {{error}}
        </li>
    </ul>
</div>

<div class="form-group">
    <label>Name</label>
    <input class="form-control" name="name" formControlName="name" />
    <ul class="text-danger list-unstyled"
            *ngIf="(formSubmitted || formGroup.controls['name'].dirty) &&
                formGroup.controls['name'].invalid">
        <li *ngFor="let error of formGroup.getValidationMessages('name')">
            {{error}}
        </li>
    </ul>
</div>

<div class="form-group">
    <label>Category</label>
    <input class="form-control" name="name" formControlName="category" />
    <ul class="text-danger list-unstyled"
            *ngIf="(formSubmitted || formGroup.controls['category'].dirty) &&
                formGroup.controls['category'].invalid">
        <li *ngFor="let error of formGroup.getValidationMessages('category')">
            {{error}}
        </li>
    </ul>
</div>

<div class="form-group">
    <label>Price</label>
    <input class="form-control" name="price" formControlName="price" />
    <ul class="text-danger list-unstyled"
            *ngIf="(formSubmitted || formGroup.controls['price'].dirty) &&
            formGroup.controls['price'].invalid">
        <li *ngFor="let error of formGroup.getValidationMessages('price')">
            {{error}}
        </li>
    </ul>
</div>

<button class="btn btn-primary" type="submit"
    [disabled]="formSubmitted && formGroup.invalid"
    [class.btn-secondary]="formSubmitted && formGroup.invalid">
        Create
</button>

Listing 14-27.Using a Form Model in the template.html File in the src/app Folder


第一个变化是对`form`元素的。使用基于模型的验证需要使用`formGroup`指令,如下所示:

...

...

分配给`formGroup`指令的值是组件的`form`属性,它返回`ProductFormGroup`对象,这是表单验证信息的来源。

接下来的变化是对`input`元素的。单个验证属性和被赋予特殊值`ngForm`的模板变量已经被删除。添加了一个新的`forControlName`属性,使用清单 14-24 中的`ProductFormGroup`中使用的名称,将`input`元素标识到基于模型的表单系统中。

...

...


这个属性允许 Angular 添加和删除`input`元素的验证类。在这种情况下,`formControlName`属性已经被设置为`name`,这告诉 Angular 应该使用特定的验证器来验证这个元素。

...
name: new ProductFormControl("Name", "name", "", Validators.required),
...


`FormGroup`类提供了一个`controls`属性,该属性返回它所管理的`FormControl`对象的集合,按名称进行索引。可以从集合中检索单个的`FormControl`对象,或者检查这些对象以获得验证状态,或者用来生成验证消息。

作为清单 14-27 中更改的一部分,我添加了获取数据所需的所有三个`input`元素来创建新的`Product`对象,每个元素都使用验证模型进行检查,如图 14-14 所示。

![img/421542_4_En_14_Fig14_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-frontend-framework-zh/raw/master/docs/pro-ng9/img/421542_4_En_14_Fig14_HTML.jpg)

图 14-14。

使用基于模型的表单验证

### 从模型中生成元素

清单 14-27 中有很多重复。验证属性已经被移到代码中,但是每个`input`元素仍然需要一个内容支持框架来处理它的布局并向用户显示它的验证消息。

下一步是通过使用表单模型生成表单中的元素来简化模板,而不仅仅是验证它们。清单 14-28 展示了如何将标准 Angular 指令与表单模型相结合,以编程方式生成表单。

<div class="bg-danger text-white p-2 mb-2"
        *ngIf="formSubmitted && formGroup.invalid">
    There are problems with the form
    <ul>
        <li *ngFor="let error of formGroup.getFormValidationMessages()">
            {{error}}
        </li>
    </ul>
</div>

<div class="form-group" *ngFor="let control of formGroup.productControls">
    <label>{{control.label}}</label>
    <input class="form-control"
        name="{{control.modelProperty}}"
        formControlName="{{control.modelProperty}}" />
    <ul class="text-danger list-unstyled"
            *ngIf="(formSubmitted || control.dirty) && control.invalid">
        <li *ngFor="let error of control.getValidationMessages()">
            {{error}}
        </li>
    </ul>
</div>

<button class="btn btn-primary" type="submit"
    [disabled]="formSubmitted && formGroup.invalid"
    [class.btn-secondary]="formSubmitted && formGroup.invalid">
        Create
</button>

Listing 14-28.Using the Model to Generate the Form in the template.html File in the src/app Folder


这个清单使用了`ngFor`指令来创建表单元素,表单元素使用了由`ProductFormControl`和`ProductFormGroup`模型类提供的描述。每个元素都配置了与清单 14-27 中相同的属性,但是它们的值取自模型描述,这允许模板被简化并且依赖于模型来定义表单元素和它们的验证。

一旦有了一个基本的表单模型,就可以对它进行扩展,以反映应用的需求。例如,您可以添加新元素,扩展`FormControl`子类以包含附加信息(例如`input`元素的`type`属性的值),为字段生成`select`元素,并提供占位符值来帮助指导用户。

## 创建自定义表单验证器

Angular 支持定制表单验证器,它可以用来执行特定于应用的验证策略,而不是内置验证器提供的通用验证。为了演示,我在`src/app`文件夹中添加了一个名为`limit.formvalidator.ts`的文件,并用它来定义清单 14-29 中所示的类。

import { FormControl } from "@angular/forms";

export class LimitValidator {

static Limit(limit:number) {
    return (control:FormControl) : {[key: string]: any} => {
        let val = Number(control.value);
        if (val != NaN && val > limit) {
            return {"limit": {"limit": limit, "actualValue": val}};
        } else {
            return null;
        }
    }
}

}

Listing 14-29.The Contents of the limit.formvalidator.ts File in the src/app Folder


自定义验证器是创建用于执行验证的函数的工厂。在这种情况下,`LimitValidator`类定义了`Limit`方法,它是`static`,是返回验证函数的工厂。`Limit`方法的参数是应该允许通过验证的最大值。

当 Angular 调用由`Limit`方法返回的验证函数时,它提供一个`FormControl`方法作为参数。清单中的定制验证函数使用`value`属性获取用户输入的值,将其转换为`number`,并与允许的限制进行比较。

验证函数为有效值返回`null`,并为无效值返回一个包含错误详细信息的对象。为了描述验证错误,该对象定义了一个属性,指定哪个验证规则失败了,在本例中是`limit`,并为该属性分配了另一个提供详细信息的对象。`limit`属性返回一个对象,该对象的`limit`属性设置为验证限制,而`actualValue`属性设置为用户输入的值。

### 应用自定义验证程序

清单 14-30 展示了表单模型如何被扩展以支持新的定制验证器类,并将其应用于产品的`price`属性的`input`元素。

import { FormControl, FormGroup, Validators } from "@angular/forms";
import { LimitValidator } from "./limit.formvalidator";

export class ProductFormControl extends FormControl {
label: string;
modelProperty: string;

constructor(label:string, property:string, value: any, validator: any) {
    super(value, validator);
    this.label = label;
    this.modelProperty = property;
}

getValidationMessages() {
    let messages: string[] = [];
    if (this.errors) {
        for (let errorName in this.errors) {
            switch (errorName) {
                case "required":
                    messages.push(`You must enter a ${this.label}`);
                    break;
                case "minlength":
                    messages.push(`A ${this.label} must be at least
                        ${this.errors['minlength'].requiredLength}
                        characters`);
                    break;
                case "maxlength":
                    messages.push(`A ${this.label} must be no more than
                        ${this.errors['maxlength'].requiredLength}
                        characters`);
                    break;
                case "pattern":
                    messages.push(`The ${this.label} contains
                         illegal characters`);
                    break;
                case "limit":
                    messages.push(`A ${this.label} cannot be more
                        than ${this.errors['limit'].limit}`);
                    break;
            }
        }
    }
    return messages;
}

}

export class ProductFormGroup extends FormGroup {

constructor() {
    super({
        name: new ProductFormControl("Name", "name", "", Validators.required),

        category: new ProductFormControl("Category", "category", "",
            Validators.compose([Validators.required,
                Validators.pattern("^[A-Za-z ]+$"),
                Validators.minLength(3),
                Validators.maxLength(10)])),

        price: new ProductFormControl("Price", "price", "",
            Validators.compose([Validators.required,
                LimitValidator.Limit(100),
                Validators.pattern("^[0-9\.]+$")]))
    });
}

get productControls(): ProductFormControl[] {
    return Object.keys(this.controls)
        .map(k => this.controls[k] as ProductFormControl);
}

getValidationMessages(name: string): string[] {
    return (this.controls['name'] as ProductFormControl).getValidationMessages();
}

getFormValidationMessages() : string[] {
    let messages: string[] = [];
    Object.values(this.controls).forEach(c =>
        messages.push(...(c as ProductFormControl).getValidationMessages()));
    return messages;
}

}

Listing 14-30.Applying a Custom Validator in the form.model.ts File in the src/app Folder


结果是输入到`Price`域的值有一个限制`100`,更大的值显示验证错误信息,如图 14-15 所示。

![img/421542_4_En_14_Fig15_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-frontend-framework-zh/raw/master/docs/pro-ng9/img/421542_4_En_14_Fig15_HTML.jpg)

图 14-15。

自定义验证消息

## 摘要

在这一章中,我介绍了 Angular 使用事件和表单支持用户交互的方式。我解释了如何创建事件绑定,如何创建双向绑定,以及如何使用`ngModel`指令简化它们。我还描述了 Angular 为管理和验证 HTML 表单提供的支持。在下一章,我将解释如何创建自定义指令。

# 十五、创建属性指令

在这一章中,我将描述如何使用自定义指令来补充 Angular 的内置指令所提供的功能。本章的重点是*属性指令*,这是可以创建的最简单的类型,可以改变单个元素的外观或行为。在第十六章中,我解释了如何创建*结构指令*,用来改变 HTML 文档的布局。组件也是一种指令,我会在第十七章解释它们是如何工作的。

在这些章节中,我通过重新创建一些内置指令提供的特性来描述定制指令是如何工作的。这不是你在真实项目中通常会做的事情,但是它提供了一个有用的基线,可以用来解释这个过程。表 15-1 将属性指令放入上下文中。

表 15-1。

将属性指令放在上下文中

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

问题

 | 

回答

 |
| --- | --- |
| 它们是什么? | 属性指令是能够修改它们所应用到的元素的行为或外观的类。第十二章中描述的样式和类绑定就是属性指令的例子。 |
| 它们为什么有用? | 内置指令涵盖了 web 应用开发中最常见的任务,但并不能处理所有情况。自定义指令允许定义特定于应用的功能。 |
| 它们是如何使用的? | 属性指令是已经应用了`@Directive`装饰器的类。它们在负责模板的组件的`directives`属性中启用,并使用 CSS 选择器应用。 |
| 有什么陷阱或限制吗? | 创建自定义指令时的主要陷阱是编写代码来执行任务,这些任务可以使用指令功能(如输入和输出属性以及宿主元素绑定)来更好地处理。 |
| 有其他选择吗? | Angular 支持另外两种类型的指令——结构指令和组件指令——它们可能更适合给定的任务。如果您希望避免编写自定义代码,有时可以组合内置指令来创建特定的效果,尽管结果可能很脆弱,并导致难以阅读和维护的复杂 HTML。 |

表 15-2 总结了本章内容。

表 15-2。

章节总结

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"> <col class="tcol3 align-left"></colgroup> 
| 

问题

 | 

解决办法

 | 

列表

 |
| --- | --- | --- |
| 创建属性指令 | 将`@Directive`应用于一个类 | 1–5 |
| 访问主体元素属性值 | 将`@Attribute`装饰器应用于构造函数参数 | 6–9 |
| 创建数据绑定输入属性 | 将`@Input`装饰器应用于一个类属性 | 10–11 |
| 当数据绑定输入属性值更改时接收通知 | 实现`ngOnChanges`方法 | Twelve |
| 定义事件 | 应用`@Output`装饰器 | 13, 14 |
| 在宿主元素上创建属性绑定或事件绑定 | 应用`@HostBinding`或`@HostListener`装饰器 | 15–19 |
| 导出指令的功能以便在模板中使用 | 使用`@Directive`装饰器的`exportAs`属性 | 20, 21 |

## 准备示例项目

正如我在本书的这一部分所做的那样,我将继续使用上一章的示例项目。为了准备这一章,我已经重新定义了表单,以便它更新组件的`newProduct`属性,而不是第十四章中使用的基于模型的表单,如清单 15-1 所示。

Tip

你可以从 [`https://github.com/Apress/pro-angular-9`](https://github.com/Apress/pro-angular-9) 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

```ts
<style>
  input.ng-dirty.ng-invalid { border: 2px solid #ff0000 }
  input.ng-dirty.ng-valid { border: 2px solid #6bc502 }
</style>

<div class="row m-2">
  <div class="col-6">
    <form class="m-2" novalidate (ngSubmit)="submitForm()">
      <div class="form-group">
          <label>Name</label>
          <input class="form-control" name="name" [(ngModel)]="newProduct.name" />
      </div>
      <div class="form-group">
        <label>Category</label>
        <input class="form-control" name="category"
            [(ngModel)]="newProduct.category" />
      </div>
      <div class="form-group">
        <label>Price</label>
        <input class="form-control" name="price" [(ngModel)]="newProduct.price" />
      </div>
      <button class="btn btn-primary" type="submit">Create</button>
    </form>
  </div>

  <div class="col-6">
    <table class="table table-sm table-bordered table-striped">
      <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
      <tr *ngFor="let item of getProducts(); let i = index">
        <td>{{i + 1}}</td>
        <td>{{item.name}}</td>
        <td>{{item.category}}</td>
        <td>{{item.price}}</td>
      </tr>
    </table>
  </div>
</div>

Listing 15-1.Preparing the Template in the template.html File in the src/app Folder

这个清单使用 Bootstrap 网格布局并排放置表单和表格。清单 15-2 移除了 JSON 输出jsonProduct属性,更新了组件的addProduct方法,以便向数据模型添加一个新对象,并简化了submitForm方法。

import { ApplicationRef, Component } from "@angular/core";
import { NgForm, FormGroup } from "@angular/forms";
import { Model } from "./repository.model";
import { Product } from "./product.model";
import { ProductFormGroup, ProductFormControl } from "./form.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();
    formGroup: ProductFormGroup = new ProductFormGroup();

    getProduct(key: number): Product {
        return this.model.getProduct(key);
    }

    getProducts(): Product[] {
        return this.model.getProducts();
    }

    newProduct: Product = new Product();

    addProduct(p: Product) {
        this.model.saveProduct(p);
    }

    formSubmitted: boolean = false;

    submitForm() {
        this.addProduct(this.newProduct);
    }
}

Listing 15-2.Modifying the Data Model in the component.ts File in the src/app Folder

要启动应用,导航到example项目文件夹并运行以下命令:

ng serve

打开一个新的浏览器窗口并导航至http://localhost:4200以查看图 15-1 中的表格。当您提交表单时,数据将被验证,或者显示错误消息,或者将一个新项目添加到数据模型并显示在表中。

img/421542_4_En_15_Fig1_HTML.jpg

图 15-1。

运行示例应用

创建简单的属性指令

最好的起点是开始创建一个指令,看看它们是如何工作的。我用清单 15-3 中所示的代码在src/app文件夹中添加了一个名为attr.directive.ts的文件。该文件的名称表明它包含指令。我将文件名的第一部分设置为attr,以表明这是一个属性指令的例子。

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

@Directive({
    selector: "[pa-attr]",
})
export class PaAttrDirective {

    constructor(element: ElementRef) {
        element.nativeElement.classList.add("bg-success", "text-white");
    }
}

Listing 15-3.The Contents of the attr.directive.ts File in the src/app Folder

指令是已经应用了@Directive装饰器的类。装饰器需要selector属性,该属性用于指定如何将指令应用于元素,使用标准 CSS 样式选择器来表达。我使用的选择器是[pa-attr],它将匹配任何具有名为pa-attr的属性的元素,而不管元素类型或分配给该属性的值。

自定义指令被赋予一个独特的前缀,以便于识别。前缀可以是对您的应用有意义的任何内容。我为我的指令选择了前缀Pa,反映了这本书的标题,这个前缀用于由selector decorator 属性和属性类的名称指定的属性中。前缀的大小写进行了更改,以反映其用途,因此选择器属性名称使用了首字母小写字符(pa-attr),指令类名称使用了首字母大写字符(PaAttrDirective)。

Note

前缀Ng / ng保留用于内置 Angular 特征,不应使用。

指令构造函数定义了一个单独的ElementRef参数,Angular 在创建指令的新实例时提供这个参数,这个参数代表主机元素。ElementRef类定义了一个属性nativeElement,它返回浏览器使用的对象来表示域对象模型中的元素。该对象提供对操作元素及其内容的方法和属性的访问,包括classList属性,它可用于管理元素的类成员,如下所示:

...
element.nativeElement.classList.add("bg-success", "text-white");
...

总之,PaAttrDirective类是一个指令,它应用于具有pa-attr属性的元素,并将这些元素添加到bg-successtext-white类,引导 CSS 库使用它们为元素分配背景和文本颜色。

应用自定义指令

应用自定义指令有两个步骤。首先是更新模板,以便有一个或多个元素与指令使用的selector匹配。在示例指令的情况下,这意味着将pa-attr属性添加到元素中,如清单 15-4 所示。

...
<table class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <tr *ngFor="let item of getProducts(); let i = index" pa-attr>
        <td>{{i + 1}}</td>
        <td>{{item.name}}</td>
        <td>{{item.category}}</td>
        <td>{{item.price}}</td>
    </tr>
</table>
...

Listing 15-4.Adding a Directive Attribute in the template.html File in the src/app Folder

指令的选择器匹配任何具有属性的元素,不管是否为其分配了值或者该值是什么。应用指令的第二步是改变 Angular 模块的配置,如清单 15-5 所示。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule  } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";

@NgModule({
    imports: [BrowserModule, FormsModule, ReactiveFormsModule],
    declarations: [ProductComponent, PaAttrDirective],
    bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 15-5.Configuring the Component in the app.module.ts File in the src/app Folder

NgModule装饰器的declarations属性声明了应用将使用的指令和组件。如果指令和组件之间的关系和区别目前看起来很混乱,不要担心;这将在第十七章中变得清晰。

一旦这两个步骤都完成了,那么应用于模板中的tr元素的pa-attr属性将触发自定义指令,该指令使用 DOM API 将元素添加到bg-successtext-white类中。由于tr元素是ngFor指令使用的微模板的一部分,表中的所有行都会受到影响,如图 15-2 所示。(您可能需要重新启动 Angular 开发工具才能看到变化。)

img/421542_4_En_15_Fig2_HTML.jpg

图 15-2。

应用自定义指令

在指令中访问应用数据

上一节中的例子展示了一个指令的基本结构,但是它没有做任何仅仅通过使用绑定在tr元素上的class属性不能执行的事情。当指令可以与宿主元素和应用的其余部分交互时,它们就变得很有用。

读取主体元素属性

使指令更有用的最简单方法是使用应用于主机元素的属性来配置它,这允许为指令的每个实例提供自己的配置信息,并相应地调整其行为。

举例来说,清单 15-6 将该指令应用于模板表中的一些td元素,并添加了一个属性,该属性指定了主机元素应该添加到的类。该指令的选择器意味着它将匹配任何具有pa-attr属性的元素,不管标签类型如何,并且将像在tr元素上一样在td元素上工作。

...
<table class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <tr *ngFor="let item of getProducts(); let i = index" pa-attr>
        <td>{{i + 1}}</td>
        <td>{{item.name}}</td>
        <td pa-attr pa-attr-class="bg-warning">{{item.category}}</td>
        <td pa-attr pa-attr-class="bg-info">{{item.price}}</td>
    </tr>
</table>
...

Listing 15-6.Adding Attributes in the template.html File in the src/app Folder

属性pa-attr已经应用于两个td元素,还有一个名为pa-attr-class的新属性,用于指定指令应该将主机元素添加到的类。清单 15-7 显示了获取pa-attr-class属性的值并使用它来改变元素的指令所需的改变。

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

@Directive({
  selector: "[pa-attr]",
})
export class PaAttrDirective {

  constructor(element: ElementRef, @Attribute("pa-attr-class") bgClass: string) {
    element.nativeElement.classList.add(bgClass || "bg-success", "text-white");
  }
}

Listing 15-7.Reading an Attribute in the attr.directive.ts File in the src/app Folder

为了接收pa-attr-class属性的值,我添加了一个名为bgClass的新构造函数参数,其中已经应用了@Attribute装饰器。这个装饰器是在@angular/core模块中定义的,它指定了属性的名称,当创建一个新的 directive 类实例时,应该使用这个属性为构造函数参数提供一个值。Angular 为每个匹配选择器的元素创建一个新的装饰器实例,并使用该元素的属性为已经用@Attribute装饰过的指令构造函数参数提供值。

在构造函数中,属性的值被传递给classList.add方法,默认值允许将指令应用于具有pa-attr属性但没有pa-attr-class属性的元素。

结果是添加元素的类现在可以使用属性来指定,产生如图 15-3 所示的结果。

img/421542_4_En_15_Fig3_HTML.jpg

图 15-3。

使用主机元素属性配置指令

使用单个主体元素属性

使用一个属性来应用一个指令,而使用另一个属性来配置它是多余的,让一个属性完成双重任务更有意义,如清单 15-8 所示。

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

@Directive({
    selector: "[pa-attr]",
})
export class PaAttrDirective {

    constructor(element: ElementRef, @Attribute("pa-attr") bgClass: string) {
        element.nativeElement.classList.add(bgClass || "bg-success", "text-white");
    }
}

Listing 15-8.Reusing an Attribute in the attr.directive.ts File in the src/app Folder

@Attribute装饰器现在将pa-attr属性指定为bgClass参数值的来源。在清单 15-9 中,我已经更新了模板以反映两用属性。

...
<table class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <tr *ngFor="let item of getProducts(); let i = index" pa-attr>
        <td>{{i + 1}}</td>
        <td>{{item.name}}</td>
        <td pa-attr="bg-warning">{{item.category}}</td>
        <td pa-attr="bg-info">{{item.price}}</td>
    </tr>
</table>
...

Listing 15-9.Applying a Directive in the template.html File in the src/app Folder

这个示例产生的结果没有明显的变化,但是它简化了在 HTML 模板中应用指令的方式。

创建数据绑定输入属性

@Attribute读取属性的主要限制是值是静态的。Angular 指令的真正威力来自于对表达式的支持,这些表达式可以更新以反映应用状态的变化,并可以通过更改宿主元素来做出响应。

指令使用数据绑定输入属性接收表达式,也称为输入属性,或者简称为输入。清单 15-10 改变了应用的模板,使得应用于trtd元素的pa-attr属性包含表达式,而不仅仅是静态类名。

...
<table class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <tr *ngFor="let item of getProducts(); let i = index"
            [pa-attr]="getProducts().length < 6 ? 'bg-success' : 'bg-warning'">
        <td>{{i + 1}}</td>
        <td>{{item.name}}</td>
        <td [pa-attr]="item.category == 'Soccer' ? 'bg-info' : null">
            {{item.category}}
        </td>
        <td [pa-attr]="'bg-info'">{{item.price}}</td>
    </tr>
</table>
...

Listing 15-10.Using Expressions in the template.html File in the src/app Folder

清单中有三个表达式。第一个应用于tr元素,使用组件的getProducts方法返回的对象数量来选择一个类。

...
<tr *ngFor="let item of getProducts(); let i = index"
    [pa-attr]="getProducts().length < 6 ? 'bg-success' : 'bg-warning'">
...

第二个表达式应用于Category列的td元素,它为Product对象指定了bg-info类,这些对象的Category属性为所有其他值返回Soccernull

...
<td [pa-attr]="item.category == 'Soccer' ? 'bg-info' : null">
...

第三个也是最后一个表达式返回一个固定的字符串值,我用单引号将它括起来,因为这是一个表达式而不是静态属性值。

...
<td [pa-attr]="'bg-info'">{{item.price}}</td>
...

请注意,属性名是用方括号括起来的。这是因为在指令中接收表达式的方法是创建一个数据绑定,就像第 13 和 14 章中描述的内置指令一样。

Tip

忘记使用方括号是一个常见的错误。没有它们,Angular 只会将表达式的原始文本传递给指令,而不会对其进行计算。如果在应用自定义指令时遇到错误,这是首先要检查的。

实现数据绑定的另一面意味着在 directive 类中创建一个输入属性,并告诉 Angular 如何管理它的值,如清单 15-11 所示。

import { Directive, ElementRef, Attribute, Input } from "@angular/core";

@Directive({
    selector: "[pa-attr]"
})
export class PaAttrDirective {

    constructor(private element: ElementRef) {}

    @Input("pa-attr")
    bgClass: string;

    ngOnInit() {
        this.element.nativeElement.classList.add(this.bgClass || "bg-success",
            "text-white");
    }
}

Listing 15-11.Defining an Input Property in the attr.directive.ts File in the src/app Folder

输入属性是通过将@Input decorator 应用于属性并使用它来指定包含表达式的属性的名称来定义的。这个清单定义了一个输入属性,它告诉 Angular 将指令的bgClass属性的值设置为包含在pa-attr属性中的表达式的值。

Tip

如果属性的名称对应于主机元素上属性的名称,您不需要向@Input decorator 提供参数。因此,如果您将@Input()应用到一个名为myVal的属性,那么 Angular 将在主机元素上寻找一个myVal属性。

在本例中,构造函数的角色发生了变化。当 Angular 创建一个指令类的新实例时,构造函数被调用来创建一个新的指令对象,然后才是输入属性集的值。这意味着构造函数无法访问输入属性值,因为 Angular 不会设置它的值,直到构造函数完成并生成新的指令对象。为了解决这个问题,指令可以实现生命周期钩子方法,Angular 使用这些方法在指令被创建后和应用运行时为指令提供有用的信息,如表 15-3 所述。

表 15-3。

指令生命周期挂钩方法

|

名字

|

描述

|
| --- | --- |
| ngOnInit | 在 Angular 为指令声明的所有输入属性设置了初始值之后,将调用此方法。 |
| ngOnChanges | 当输入属性的值已经改变时,并且就在调用ngOnInit方法之前,调用该方法。 |
| ngDoCheck | 当 Angular 运行它的变化检测过程时调用这个方法,以便指令有机会更新任何与输入属性没有直接关联的状态。 |
| ngAfterContentInit | 当指令的内容已经初始化时,调用此方法。有关使用该方法的示例,请参见第十六章中的“接收查询更改通知”一节。 |
| ngAfterContentChecked | 在作为更改检测过程的一部分检查了指令的内容之后,将调用此方法。 |
| ngOnDestroy | 在 Angular 销毁指令之前立即调用此方法。 |

为了在主机元素上设置类,清单 15-11 中的指令实现了ngOnInit方法,该方法在 Angular 设置了bgClass属性的值之后被调用。仍然需要构造函数来接收提供对主机元素访问的ElementRef对象,该对象被分配给一个名为element的属性。

结果是 Angular 将为每个tr元素创建一个指令对象,评估在pa-attr属性中指定的表达式,使用结果来设置输入属性的值,然后调用ngOnInit方法,这允许指令响应新的输入属性值。

要查看效果,请使用该表单向示例应用添加一个新产品。由于模型中最初有五个项目,tr元素的表达式将选择bg-success类。添加新项时,Angular 会创建 directive 类的另一个实例,并对表达式求值以设置 input 属性的值;由于现在模型中有六个项目,表达式将选择bg-warning类,该类为新行提供不同的背景颜色,如图 15-4 所示。

img/421542_4_En_15_Fig4_HTML.jpg

图 15-4。

在自定义指令中使用输入属性

响应输入属性更改

在前一个例子中发生了一些奇怪的事情:添加一个新项目影响了新元素的外观,但没有影响现有的元素。在幕后,Angular 已经为它创建的每个指令更新了bgClass属性的值——表列中的每个td元素一个——但是指令没有注意到,因为更改属性值不会自动导致指令响应。

为了处理变更,指令必须实现ngOnChanges方法,以便在输入属性的值发生变更时接收通知,如清单 15-12 所示。

import { Directive, ElementRef, Attribute, Input,
         SimpleChange } from "@angular/core";

@Directive({
    selector: "[pa-attr]"
})
export class PaAttrDirective {

    constructor(private element: ElementRef) {}

    @Input("pa-attr")
    bgClass: string;

    ngOnChanges(changes: {[property: string]: SimpleChange }) {
        let change = changes["bgClass"];
        let classList = this.element.nativeElement.classList;
        if (!change.isFirstChange() && classList.contains(change.previousValue)) {
            classList.remove(change.previousValue);
        }
        if (!classList.contains(change.currentValue)) {
            classList.add(change.currentValue);
        }
    }
}

Listing 15-12.Receiving Change Notifications in the attr.directive.ts File in the src/app Folder

在调用ngOnInit方法之前,调用一次ngOnChanges方法,然后每当指令的输入属性发生变化时,再次调用该方法。ngOnChanges参数是一个对象,其属性名引用每个改变的输入属性,其值是在@angular/core模块中定义的SimpleChange对象。TypeScript 将这种数据结构表示如下:

...
ngOnChanges(changes: {[property: string]: SimpleChange }) {
...

SimpleChange类定义了表 15-4 中所示的成员。

表 15-4。

SimpleChange 类的属性和方法

|

名字

|

描述

|
| --- | --- |
| previousValue | 此属性返回输入属性的上一个值。 |
| currentValue | 此属性返回输入属性的当前值。 |
| isFirstChange() | 如果这是对发生在ngOnInit方法之前的ngOnChanges方法的调用,则该方法返回true。 |

理解向ngOnChanges方法呈现更改的最简单的方法是将对象序列化为 JSON,然后查看它。

...
{
    "target": {
        "previousValue":"bg-success",
        "currentValue":"bg-warning"
    }
}
...

这去掉了isFirstChange方法,但是它确实有助于展示 argument 对象中的每个属性被用来指示输入属性的变化的方式。

当响应输入属性值的更改时,指令必须确保撤销先前更新的效果。在示例指令的情况下,这意味着从previousValue类中移除元素并将其添加到currentValue类中。

使用isFirstChange方法很重要,这样你就不会撤销一个实际上还没有应用的值,因为第一次给输入属性赋值时调用了ngOnChanges方法。

处理这些更改通知的结果是,当 Angular 重新计算表达式并更新输入属性时,指令会做出响应。现在,当你向应用中添加一个新产品时,所有tr元素的背景颜色都会更新,如图 15-5 所示。

img/421542_4_En_15_Fig5_HTML.jpg

图 15-5。

响应输入属性更改

创建自定义事件

输出属性是一个 Angular 特性,它允许指令将自定义事件添加到它们的主机元素中,通过它可以将重要变化的细节发送到应用的其余部分。使用@Output装饰器定义输出属性,该装饰器在@angular/core模块中定义,如清单 15-13 所示。

import { Directive, ElementRef, Attribute, Input,
         SimpleChange, Output, EventEmitter } from "@angular/core";
import { Product } from "./product.model";

@Directive({
    selector: "[pa-attr]"
})
export class PaAttrDirective {

    constructor(private element: ElementRef) {
        this.element.nativeElement.addEventListener("click", e => {
            if (this.product != null) {
                this.click.emit(this.product.category);
            }
        });
    }

    @Input("pa-attr")
    bgClass: string;

    @Input("pa-product")
    product: Product;

    @Output("pa-category")
    click = new EventEmitter<string>();

    ngOnChanges(changes: {[property: string]: SimpleChange }) {
        let change = changes["bgClass"];
        let classList = this.element.nativeElement.classList;
        if (!change.isFirstChange() && classList.contains(change.previousValue)) {
            classList.remove(change.previousValue);
        }
        if (!classList.contains(change.currentValue)) {
            classList.add(change.currentValue);
        }
    }
}

Listing 15-13.Defining an Output Property in the attr.directive.ts File in the src/app Folder

EventEmitter类为 Angular 指令提供了事件机制。清单创建了一个EventEmitter对象,并将其赋给一个名为click的变量,如下所示:

...
@Output("pa-category")
click = new EventEmitter<string>();
...

类型参数表明当事件被触发时,事件的监听器将接收一个字符串。指令可以向它们的事件监听器提供任何类型的对象,但是常见的选择是stringnumber值、数据模型对象和 JavaScript Event对象。

当鼠标按钮在主机元素上单击时,清单中的定制事件被触发,该事件向其侦听器提供使用ngFor指令创建表格行的Product对象的category。其效果是,该指令响应宿主元素上的 DOM 事件,并生成自己的自定义事件作为响应。DOM 事件的侦听器是使用浏览器的标准addEventListener方法在指令类构造函数中设置的,如下所示:

...
constructor(private element: ElementRef) {
    this.element.nativeElement.addEventListener("click", e => {
        if (this.product != null) {
            this.click.emit(this.product.category);
        }
    });
}
...

该指令定义了一个输入属性来接收Product对象,该对象的类别将在事件中发送。(该指令能够在构造函数中引用输入属性值的值,因为 Angular 将在调用分配用于处理 DOM 事件的函数之前设置属性值。)

清单中最重要的语句是使用EventEmitter对象发送事件的语句,这是使用EventEmitter.emit方法完成的,在表 15-5 中有描述,以供快速参考。emit方法的参数是您希望事件侦听器接收的值,在本例中是category属性的值。

表 15-5。

EventEmitter 方法

|

名字

|

描述

|
| --- | --- |
| emit(value) | 该方法触发与EventEmitter相关联的定制事件,向侦听器提供作为方法参数接收的对象或值。 |

将一切联系在一起的是@Output装饰器,它在指令类EventEmitter属性和将用于绑定到模板中事件的名称之间创建一个映射,如下所示:

...
@Output("pa-category")
click = new EventEmitter<string>();
...

装饰器的参数指定了将在应用于主机元素的事件绑定中使用的属性名。如果 TypeScript 属性名称也是自定义事件的名称,则可以省略该参数。我在清单中指定了pa-category,这允许我在指令类中将事件称为click,但是需要一个更有意义的外部名称。

绑定到自定义事件

Angular 通过使用与内置事件相同的绑定语法,很容易绑定到模板中的自定义事件,这在第十四章中有描述。清单 15-14 将pa-product属性添加到模板中的tr元素,为指令提供其Product对象,并为pa-category事件添加一个绑定。

...
<table class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <tr *ngFor="let item of getProducts(); let i = index"
            [pa-attr]="getProducts().length < 6 ? 'bg-success' : 'bg-warning'"
            [pa-product]="item" (pa-category)="newProduct.category = $event">
        <td>{{i + 1}}</td>
        <td>{{item.name}}</td>
        <td [pa-attr]="item.category == 'Soccer' ? 'bg-info' : null">
            {{item.category}}
        </td>
        <td [pa-attr]="'bg-info'">{{item.price}}</td>
    </tr>
</table>
...

Listing 15-14.Binding to a Custom Event in the template.html File in the src/app Folder

术语$event用于访问指令传递给EventEmitter.emit方法的值。这意味着在本例中,$event将是一个包含产品类别的string值。从事件中接收的值用于设置类别input元素的值,这意味着单击表格中的一行将在表单中显示产品的类别,如图 15-6 所示。

img/421542_4_En_15_Fig6_HTML.jpg

图 15-6。

使用输出属性定义和接收自定义事件

创建宿主元素绑定

示例指令依赖浏览器的 DOM API 来操作其主机元素,既添加和删除类成员,又接收click事件。在 Angular 应用中使用 DOM API 是一项有用的技术,但这意味着您的指令只能在 web 浏览器中运行的应用中使用。Angular 旨在在一系列不同的执行环境中运行,并不是所有的执行环境都能提供 DOM API。

即使您确定某个指令可以访问 DOM,也可以使用标准的 angle 指令特性(属性和事件绑定)以更优雅的方式获得相同的结果。可以在 host 元素上使用类绑定,而不是使用 DOM 来添加和删除类。可以使用事件绑定来处理鼠标点击,而不是使用addEventListener方法。

在幕后,当在 web 浏览器中使用该指令时,Angular 使用 DOM API 实现这些特性——或者当该指令在不同的环境中使用时,使用一些等效的机制。

主机元素上的绑定是使用两个装饰器定义的,@HostBinding@HostListener,它们都是在@angular/core模块中定义的,如清单 15-15 所示。

import { Directive, ElementRef, Attribute, Input,
         SimpleChange, Output, EventEmitter, HostListener, HostBinding }
            from "@angular/core";
 import { Product } from "./product.model";

@Directive({
    selector: "[pa-attr]"
})
export class PaAttrDirective {

    @Input("pa-attr")
    @HostBinding("class")
    bgClass: string;

    @Input("pa-product")
    product: Product;

    @Output("pa-category")
    click = new EventEmitter<string>();

    @HostListener("click")
    triggerCustomEvent() {
        if (this.product != null) {
            this.click.emit(this.product.category);
        }
    }
}

Listing 15-15.Creating Host Bindings in the attr.directive.ts File in the src/app Folder

@HostBinding decorator 用于在主机元素上设置一个属性绑定,并应用于一个指令属性。清单在主机元素的class属性和装饰者的bgClass属性之间建立了一个绑定。

Tip

如果想要管理元素的内容,可以使用@HostBinding装饰器绑定到textContent属性。例子见第十九章。

@HostListener装饰器用于在主机元素上设置事件绑定,并应用于一个方法。该清单为click事件创建了一个事件绑定,当鼠标按钮被按下并释放时,该事件将调用triggerCustomEvent方法。顾名思义,triggerCustomEvent方法使用EventEmitter.emit方法通过输出属性调度定制事件。

使用主机元素绑定意味着可以删除指令构造函数,因为不再需要通过ElementRef对象访问 HTML 元素。相反,Angular 负责设置事件侦听器,并通过属性绑定设置元素的类成员。

虽然指令代码要简单得多,但是指令的效果是一样的:单击一个表格行设置一个input元素的值,使用表单添加一个新项目会触发不属于Soccer类别的产品的表格单元格的背景颜色的变化。

在宿主元素上创建双向绑定

Angular 为创建支持双向绑定的指令提供了特殊的支持,因此它们可以与ngModel使用的香蕉盒样式一起使用,并且可以双向绑定到模型属性。

双向绑定功能依赖于命名约定。为了演示它是如何工作的,清单 15-16 向template.html文件添加了一些新元素和绑定。

...
<div class="col-6">

  <div class="form-group bg-info text-white p-2">
    <label>Name:</label>
    <input class="bg-primary text-white" [paModel]="newProduct.name"
        (paModelChange)="newProduct.name = $event" />
  </div>

  <table class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <tr *ngFor="let item of getProducts(); let i = index"
            [pa-attr]="getProducts().length < 6 ? 'bg-success' : 'bg-warning'"
            [pa-product]="item" (pa-category)="newProduct.category = $event">
        <td>{{i + 1}}</td>
        <td>{{item.name}}</td>
        <td [pa-attr]="item.category == 'Soccer' ? 'bg-info' : null">
            {{item.category}}
        </td>
        <td [pa-attr]="'bg-info'">{{item.price}}</td>
    </tr>
  </table>
</div>
...

Listing 15-16.Applying a Directive in the template.html File in the src/app Folder

我将创建一个支持两个单向绑定的指令。当newProduct.name属性的值改变时,目标为paModel的绑定将被更新,这提供了从应用到指令的数据流,并将用于更新input元素的内容。定制事件paModelChange将在用户更改 name input 元素的内容时被触发,并将从指令向应用的其余部分提供数据流。

为了实现这个指令,我在src/app文件夹中添加了一个名为twoway.directive.ts的文件,并用它来定义清单 15-17 中所示的指令。

import { Input, Output, EventEmitter, Directive,
         HostBinding, HostListener, SimpleChange } from "@angular/core";

@Directive({
    selector: "input[paModel]"
})
export class PaModel {

    @Input("paModel")
    modelProperty: string;

    @HostBinding("value")
    fieldValue: string = "";

    ngOnChanges(changes: { [property: string]: SimpleChange }) {
        let change = changes["modelProperty"];
        if (change.currentValue != this.fieldValue) {
            this.fieldValue = changes["modelProperty"].currentValue || "";
        }
    }

    @Output("paModelChange")
    update = new EventEmitter<string>();

    @HostListener("input", ["$event.target.value"])
    updateValue(newValue: string) {
        this.fieldValue = newValue;
        this.update.emit(newValue);
    }
}

Listing 15-17.The Contents of the twoway.directive.ts File in the src/app Folder

该指令使用了之前描述过的功能。该指令的selector属性指定它将匹配具有paModel属性的input元素。内置的ngModel双向指令支持一系列的表单元素,并且知道每个元素使用哪些事件和属性,但是我想保持这个例子简单,所以我将只支持input元素,它定义了一个value属性来获取和设置元素内容。

使用输入属性和ngOnChanges方法实现了paModel绑定,该方法通过在input元素的value属性上绑定主机来更新输入元素的内容,从而响应表达式值的变化。

使用一个主机监听器在input事件上实现paModelChange事件,然后通过输出属性发送一个更新。注意,事件调用的方法能够通过给@HostListener装饰器指定一个额外的参数来接收事件对象,如下所示:

...
@HostListener("input", ["$event.target.value"])
updateValue(newValue: string) {
...

@HostListener装饰器的第一个参数指定了将由监听器处理的事件的名称。第二个参数是一个数组,用于为修饰方法提供参数。在这个例子中,input事件将由监听器处理,当updateValue方法被调用时,它的newValue参数将被设置为Event对象的target.value属性,使用$event引用该属性。

为了启用该指令,我将它添加到 Angular 模块中,如清单 15-18 所示。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule  } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";

@NgModule({
    imports: [BrowserModule, FormsModule, ReactiveFormsModule],
    declarations: [ProductComponent, PaAttrDirective, PaModel],
    bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 15-18.Registering the Directive in the app.module.ts File in the src/app Folder

当您保存更改并重新加载浏览器时,您将看到一个新的input元素,它响应对模型属性的更改,并在其宿主元素的内容发生更改时更新模型属性。绑定中的表达式指定了 HTML 文档左侧表单中Name字段使用的相同模型属性,这为测试它们之间的关系提供了一种便捷的方式,如图 15-7 所示。

img/421542_4_En_15_Fig7_HTML.jpg

图 15-7。

测试双向数据流

Tip

对于此示例,您可能需要停止 Angular 开发工具,重新启动它们,并重新加载浏览器。Angular 开发工具并不总是正确地处理变更。

最后一步是简化绑定并应用香蕉盒样式的括号,如清单 15-19 所示。

...
<div class="col-6">

    <div class="form-group bg-info text-white p-2">
      <label>Name:</label>
      <input class="bg-primary text-white" [(paModel)]="newProduct.name" />
    </div>

    <table class="table table-sm table-bordered table-striped">
        <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
        <tr *ngFor="let item of getProducts(); let i = index"
                [pa-attr]="getProducts().length < 6 ? 'bg-success' : 'bg-warning'"
                [pa-product]="item" (pa-category)="newProduct.category=$event">
            <td>{{i + 1}}</td>
            <td>{{item.name}}</td>
            <td [pa-attr]="item.category == 'Soccer' ? 'bg-info' : null">
                {{item.category}}
            </td>
            <td [pa-attr]="'bg-info'">{{item.price}}</td>
        </tr>
    </table>
</div>
...

Listing 15-19.Simplifying the Bindings in the template.html File in the src/app Folder

当 Angular 遇到[()]括号时,它扩展绑定以匹配清单 15-16 中使用的格式,目标是paModel输入属性并设置paModelChange事件。只要一个指令将这些暴露给 Angular,就可以使用香蕉盒括号将其作为目标,从而产生一个更简单的模板语法。

导出在模板变量中使用的指令

在前面的章节中,我使用模板变量来访问内置指令提供的功能,比如ngForm。作为一个例子,下面是第十四章中的一个元素:

...
<form novalidate #form="ngForm" (ngSubmit)="submitForm(form)">
...

form模板变量赋值ngForm,然后用它来访问 HTML 表单的验证信息。这是一个说明指令如何提供对其属性和方法的访问的示例,以便在数据绑定和表达式中使用它们。

清单 15-20 修改了上一节中的指令,以便它提供是否扩展了其主机元素中的文本的细节。

import { Input, Output, EventEmitter, Directive,
    HostBinding, HostListener, SimpleChange } from "@angular/core";

@Directive({
    selector: "input[paModel]",
    exportAs: "paModel"
})
export class PaModel {

    direction: string = "None";

    @Input("paModel")
    modelProperty: string;

    @HostBinding("value")
    fieldValue: string = "";

    ngOnChanges(changes: { [property: string]: SimpleChange }) {
        let change = changes["modelProperty"];
        if (change.currentValue != this.fieldValue) {
            this.fieldValue = changes["modelProperty"].currentValue || "";
            this.direction = "Model";
        }
    }

    @Output("paModelChange")
    update = new EventEmitter<string>();

    @HostListener("input", ["$event.target.value"])
    updateValue(newValue: string) {
        this.fieldValue = newValue;
        this.update.emit(newValue);
        this.direction = "Element";
    }
}

Listing 15-20.Exporting a Directive in the twoway.directive.ts File in the src/app Folder

@Directive装饰器的exportAs属性指定了一个名称,该名称将用于引用模板变量中的指令。这个例子使用paModel作为exportAs属性的值,并且您应该尝试使用能够清楚地表明哪个指令提供了该功能的名称。

清单向指令添加了一个名为direction的属性,用于指示数据何时从模型流向元素,或者从元素流向模型。

当您使用exportAs decorator 时,您提供了对该指令定义的所有方法和属性的访问,这些方法和属性将在模板表达式和数据绑定中使用。一些开发人员给不在指令之外使用的方法和属性的名字加上下划线(_字符)或者使用private关键字。这是对其他开发人员的一个提示,有些方法和属性不应该使用,但 Angular 并没有强制执行。清单 15-21 为指令的导出功能创建一个模板变量,并在样式绑定中使用它。

...
<div class="col-6">

    <div class="form-group bg-info text-white p-2">
        <label>Name:</label>
        <input class="bg-primary text-white" [(paModel)]="newProduct.name"
            #paModel="paModel" />
        <div class="bg-primary text-white">Direction: {{paModel.direction}}</div>
    </div>

    <table class="table table-sm table-bordered table-striped">
        <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
        <tr *ngFor="let item of getProducts(); let i = index"
                [pa-attr]="getProducts().length < 6 ? 'bg-success' : 'bg-warning'"
                [pa-product]="item" (pa-category)="newProduct.category = $event">
            <td>{{i + 1}}</td>
            <td>{{item.name}}</td>
            <td [pa-attr]="item.category == 'Soccer' ? 'bg-info' : null">
                {{item.category}}
            </td>
            <td [pa-attr]="'bg-info'">{{item.price}}</td>
        </tr>
    </table>
</div>
...

Listing 15-21.Using Exported Directive Functionality in the template.html File in the src/app Folder

模板变量称为paModel,它的值是指令的exportAs属性中使用的名称。

...
#paModel="paModel"
...

Tip

您不必为变量和指令使用相同的名称,但这有助于明确功能的来源。

一旦定义了模板变量,就可以在插值绑定中使用它,或者将其作为绑定表达式的一部分。我选择了一个字符串插值绑定,它的表达式使用指令的direction属性的值。

...
<div class="bg-primary text-white">Direction: {{paModel.direction}}</div>
...

结果是您可以看到在绑定到newProduct.name模型属性的两个input元素中键入文本的效果。当你输入一个使用ngModel指令的,那么字符串插值绑定会显示Model。当你键入使用paModel指令的元素时,字符串插值绑定会显示Element,如图 15-8 所示。

img/421542_4_En_15_Fig8_HTML.jpg

图 15-8。

从指令中导出功能

摘要

在本章中,我描述了如何定义和使用属性指令,包括输入和输出属性以及主机绑定的使用。在下一章中,我将解释结构化指令是如何工作的,以及如何用它们来改变 HTML 文档的布局或结构。

十六、创建结构化指令

结构指令通过添加和删除元素来改变 HTML 文档的布局。它们建立在第十五章中描述的可用于属性指令的核心特性之上,并附加了对微模板的支持,微模板是组件使用的模板中定义的内容的小片段。您可以识别出什么时候使用了结构化指令,因为它的名称前面会有一个星号,比如*ngIf*ngFor。在这一章中,我将解释如何定义和应用结构化指令,它们如何工作,以及它们如何响应数据模型中的变化。表 16-1 将结构指令放在上下文中。

表 16-1。

将结构指令放在上下文中

|

问题

|

回答

|
| --- | --- |
| 它们是什么? | 结构化指令使用微型模板向 HTML 文档添加内容。 |
| 它们为什么有用? | 结构化指令允许根据表达式的结果有条件地添加内容,或者为数据源(如数组)中的每个对象重复相同的内容。 |
| 它们是如何使用的? | 结构化指令应用于一个ng-template元素,该元素包含构成其微模板的内容和绑定。template 类使用 Angular 提供的对象来控制内容的包含或重复内容。 |
| 有什么陷阱或限制吗? | 除非小心处理,否则结构化指令会对 HTML 文档进行大量不必要的修改,这会破坏 web 应用的性能。正如本章后面的“处理集合级数据更改”一节中所解释的,仅在需要时进行更改是很重要的。 |
| 还有其他选择吗? | 您可以将内置指令用于常见任务,但是编写定制的结构化指令可以为您的应用定制行为。 |

表 16-2 总结了本章内容。

表 16-2。

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 创建结构指令 | 将@Directive装饰器应用于接收视图容器和模板构造器参数的类 | 1–6 |
| 创建迭代结构指令 | 在结构指令类中定义一个ForOf输入属性,并迭代它的值 | 7–12 |
| 在结构化指令中处理数据更改 | 使用差异检测ngDoCheck方法中的变化 | 13–19 |
| 查询已应用结构指令的宿主元素的内容 | 使用@ContentChild@ContentChildren装饰器 | 20–26 |

准备示例项目

在这一章中,我继续使用我在第十一章中创建的示例项目,并且一直使用至今。为了准备本章,我简化了模板,去掉了表单,只留下了表格,如清单 16-1 所示。(我将在本章的后面添加表单。)

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

<div class="m-2">
  <table class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <tbody class="text-white">
      <tr *ngFor="let item of getProducts(); let i = index"
          [pa-attr]="getProducts().length < 6 ? 'bg-success' : 'bg-warning'"
          [pa-product]="item" (pa-category)="newProduct.category=$event">
        <td>{{i + 1}}</td>
        <td>{{item.name}}</td>
        <td [pa-attr]="item.category == 'Soccer' ? 'bg-info' : null">
          {{item.category}}
        </td>
        <td [pa-attr]="'bg-info'">{{item.price}}</td>
      </tr>
    </tbody>
  </table>
</div>

Listing 16-1.Simplifying the Template in the template.html File in the src/app Folder

example文件夹中运行以下命令,启动开发工具:

ng serve

打开一个新的浏览器窗口并导航到http://localhost:4200以查看如图 16-1 所示的内容。

img/421542_4_En_16_Fig1_HTML.jpg

图 16-1。

运行示例应用

创建简单的结构指令

从结构化指令开始的一个好地方是重新创建由ngIf指令提供的功能,这相对简单,易于理解,并且为解释结构化指令如何工作提供了一个良好的基础。我首先对模板进行修改,然后反向编写支持它的代码。清单 16-2 显示了模板的变化。

<div class="m-2">

    <div class="checkbox">
        <label>
            <input type="checkbox" [(ngModel)]="showTable" />
            Show Table
        </label>
    </div>

    <ng-template [paIf]="showTable">
        <table class="table table-sm table-bordered table-striped">
            <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
            <tr *ngFor="let item of getProducts(); let i = index"
                [pa-attr]="getProducts().length < 6 ? 'bg-success' : 'bg-warning'"
                [pa-product]="item" (pa-category)="newProduct.category=$event">
                <td>{{i + 1}}</td>
                <td>{{item.name}}</td>
                <td [pa-attr]="item.category == 'Soccer' ? 'bg-info' : null">
                    {{item.category}}
                </td>
                <td [pa-attr]="'bg-info'">{{item.price}}</td>
            </tr>
        </table>
    </ng-template>
</div>

Listing 16-2.Applying a Structural Directive in the template.html File in the src/app Folder

这个清单使用完整的模板语法,其中指令应用于一个ng-template元素,该元素包含指令将使用的内容。在这种情况下,ng-template元素包含了table元素及其所有内容,包括绑定、指令和表达式。(还有一个简洁的语法,我在本章后面会用到。)

ng-template元素有一个标准的单向数据绑定,目标是一个名为paIf的指令,如下所示:

...
<ng-template [paIf]="showTable">
...

这个绑定的表达式使用了一个名为showTable的属性值。这与模板中另一个新绑定中使用的属性相同,该属性已应用于复选框,如下所示:

...
<input type="checkbox" checked="true" [(ngModel)]="showTable" />
...

本节的目标是创建一个结构指令,当showTable属性为true时,它将把ng-template元素的内容添加到 HTML 文档中,这将在复选框被选中时发生,当showTable属性为false时,它将删除ng-template元素的内容,这将在复选框未被选中时发生。清单 16-3 向组件添加了showTable属性。

import { ApplicationRef, Component } from "@angular/core";
import { NgForm, FormGroup } from "@angular/forms";
import { Model } from "./repository.model";
import { Product } from "./product.model";
import { ProductFormGroup, ProductFormControl } from "./form.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();
    formGroup: ProductFormGroup = new ProductFormGroup();
    showTable: boolean = false;

    getProduct(key: number): Product {
        return this.model.getProduct(key);
    }

    getProducts(): Product[] {
        return this.model.getProducts();
    }

    newProduct: Product = new Product();

    addProduct(p: Product) {
        this.model.saveProduct(p);
    }

    formSubmitted: boolean = false;

    submitForm() {
        this.addProduct(this.newProduct);
    }
}

Listing 16-3.Adding a Property in the component.ts File in the src/app Folder

实现结构指令类

从模板中可以知道指令应该做什么。为了实现这个指令,我在src/app文件夹中添加了一个名为structure.directive.ts的文件,并添加了清单 16-4 中所示的代码。

import {
    Directive, SimpleChange, ViewContainerRef, TemplateRef, Input
} from "@angular/core";

@Directive({
    selector: "[paIf]"
})
export class PaStructureDirective {

    constructor(private container: ViewContainerRef,
        private template: TemplateRef<Object>) { }

    @Input("paIf")
    expressionResult: boolean;

    ngOnChanges(changes: { [property: string]: SimpleChange }) {
        let change = changes["expressionResult"];
        if (!change.isFirstChange() && !change.currentValue) {
            this.container.clear();
        } else if (change.currentValue) {
            this.container.createEmbeddedView(this.template);
        }
    }
}

Listing 16-4.The Contents of the structure.directive.ts File in the src/app Folder

@Directive装饰器的selector属性用于匹配具有paIf属性的主机元素;这对应于我在清单 16-1 中添加的模板。

有一个名为expressionResult的输入属性,指令使用它从模板接收表达式的结果。该指令实现了ngOnChanges方法来接收变更通知,因此它可以响应数据模型中的变更。

这是一个结构化指令的第一个迹象来自构造函数,它要求 Angular 使用一些新类型提供参数。

...
constructor(private container: ViewContainerRef,
    private template: TemplateRef<Object>) {}
...

ViewContainerRef对象用于管理视图容器的内容,它是 HTML 文档中出现ng-template元素的部分,也是指令负责的部分。

顾名思义,视图容器负责管理一组视图。视图是 HTML 元素的一个区域,包含指令、绑定和表达式,它们是使用ViewContainerRef类提供的方法和属性创建和管理的,其中最有用的在表 16-3 中描述。

表 16-3。

有用的 ViewContainerRef 方法和属性

|

名字

|

描述

|
| --- | --- |
| element | 该属性返回一个代表容器元素的ElementRef对象。 |
| createEmbeddedView(template) | 此方法使用模板来创建新视图。详情见表后文字。该方法还接受上下文数据的可选参数(如“创建迭代结构指令”一节所述)和一个指定视图插入位置的索引位置。结果是一个ViewRef对象,可以与该表中的其他方法一起使用。 |
| clear() | 该方法从容器中移除所有视图。 |
| length | 该属性返回容器中视图的数量。 |
| get(index) | 该方法返回代表指定索引处视图的ViewRef对象。 |
| indexOf(view) | 该方法返回指定的ViewRef对象的索引。 |
| insert(view, index) | 此方法在指定索引处插入一个视图。 |
| remove(Index) | 此方法移除并销毁指定索引处的视图。 |
| detach(index) | 该方法将视图从指定的索引中分离出来,而不破坏它,这样就可以用insert方法重新定位它。 |

需要表 16-3 中的两个方法来重新创建ngIf指令的功能:createEmbeddedView向用户显示ng-template元素的内容,clear再次删除它。

createEmbeddedView方法将视图添加到视图容器中。这个方法的参数是一个TemplateRef对象,它代表了ng-template元素的内容。

该指令接收TemplateRef对象作为其构造函数参数之一,Angular 将在创建该指令类的新实例时自动为其提供一个值。

综上所述,当 Angular 处理template.html文件时,它发现了ng-template元素及其绑定,并确定它需要创建一个PaStructureDirective类的新实例。Angular 检查了PaStructureDirective构造函数,可以看到它需要为其提供ViewContainerRefTemplateRef对象。

...
constructor(private container: ViewContainerRef,
    private template: TemplateRef<Object>) {}
...

ViewContainerRef对象表示 HTML 文档中被ng-template元素占据的位置,而TemplateRef对象表示ng-template元素的内容。Angular 将这些对象传递给构造函数,并创建指令类的新实例。

Angular 然后开始处理表达式和数据绑定。如第十五章所述,Angular 在初始化期间(就在ngOnInit方法被调用之前)调用ngOnChanges方法,并且每当指令表达式的值改变时再次调用。

PaStructureDirective类对ngOnChanges方法的实现使用接收到的SimpleChange对象,根据表达式的当前值显示或隐藏ng-template元素的内容。当表达式为true时,指令通过将ng-template元素的内容添加到容器视图中来显示它们。

...
this.container.createEmbeddedView(this.template);
...

当表达式的结果是false时,该指令清除视图容器,这将从 HTML 文档中移除元素。

...
this.container.clear();
...

该指令不了解ng-template元素的内容,只负责管理它的可见性。

启用结构指令

该指令必须在 Angular 模块中启用才能使用,如清单 16-5 所示。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule  } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";
import { PaStructureDirective } from "./structure.directive";

@NgModule({
    imports: [BrowserModule, FormsModule, ReactiveFormsModule],
    declarations: [ProductComponent, PaAttrDirective, PaModel, PaStructureDirective],
    bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 16-5.Enabling the Directive in the app.module.ts File in the src/app Folder

结构指令以与属性指令相同的方式启用,并在模块的declarations数组中指定。

一旦你保存了修改,浏览器会重新加载 HTML 文档,你可以看到新指令的效果:table元素,也就是ng-template元素的内容,只有在复选框被选中时才会显示,如图 16-2 所示。(如果您在选中该框时没有看到更改或表格没有显示,请重新启动 Angular development tools,然后重新加载浏览器窗口。)

img/421542_4_En_16_Fig2_HTML.jpg

图 16-2。

创建结构指令

Note

元素的内容正在被销毁和重新创建,而不是简单的隐藏和显示。如果您想显示或隐藏内容而不从 HTML 文档中删除它,那么您可以使用样式绑定来设置displayvisibility属性。

使用简明结构指令语法

元素的使用有助于说明视图容器在结构化指令中的作用。简洁的语法去掉了ng-template元素,并将指令及其表达式应用于它所包含的最外层元素,如清单 16-6 所示。

Tip

简明结构指令语法旨在更易于使用和阅读,但这只是您使用哪种语法的偏好问题。

<div class="m-2">
  <div class="checkbox">
    <label>
      <input type="checkbox" [(ngModel)]="showTable" />
      Show Table
    </label>
  </div>

  <table *paIf="showTable"
         class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <tbody class="text-white">
      <tr *ngFor="let item of getProducts(); let i = index"
          [pa-attr]="getProducts().length < 6 ? 'bg-success' : 'bg-warning'"
          [pa-product]="item" (pa-category)="newProduct.category=$event">
        <td>{{i + 1}}</td>
        <td>{{item.name}}</td>
        <td [pa-attr]="item.category == 'Soccer' ? 'bg-info' : null">
          {{item.category}}
        </td>
        <td [pa-attr]="'bg-info'">{{item.price}}</td>
      </tr>
    </tbody>
  </table>
</div>

Listing 16-6.Using the Concise Structural Directive Syntax in the template.html File in the src/app Folder

ng-template元素已被移除,该指令已被应用于table元素,如下所示:

...
<table *paIf="showTable" class="table table-sm table-bordered table-striped">
...

该指令的名称以星号(*字符)为前缀,告诉 Angular 这是一个使用简明语法的结构化指令。当 Angular 解析template.html文件时,它会发现指令和星号,并处理这些元素,就像文档中有一个ng-template元素一样。不需要对指令类进行任何更改来支持简洁的语法。

创建迭代结构指令

Angular 为需要迭代数据源的指令提供了特殊的支持。演示这一点的最佳方式是重新创建另一个内置指令:ngFor

为了准备新的指令,我已经从template.html文件中移除了ngFor指令,插入了一个ng-template元素,并应用了一个新的指令属性和表达式,如清单 16-7 所示。

<div class="m-2">
    <div class="checkbox">
      <label>
        <input type="checkbox" [(ngModel)]="showTable" />
        Show Table
      </label>
    </div>

    <table *paIf="showTable"
            class="table table-sm table-bordered table-striped">
        <thead>
            <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
        </thead>
        <tbody>
            <ng-template [paForOf]="getProducts()" let-item>
                <tr><td colspan="4">{{item.name}}</td></tr>
            </ng-template>
        </tbody>
    </table>
</div>

Listing 16-7.Preparing for a New Structural Directive in the template.html File in the src/app Folder

迭代结构指令的完整语法有点奇怪。在清单中,ng-template元素有两个用于应用指令的属性。第一个是标准绑定,其表达式获得指令所需的数据,绑定到名为paForOf的属性。

...
<ng-template [paForOf]="getProducts()" let-item>
...

这个属性的名称很重要。当使用一个ng-template元素时,数据源属性的名称必须以Of结尾,以支持简洁的语法,我将很快介绍这一点。

第二个属性用于定义隐式值,当指令遍历数据源时,它允许在ng-template元素中引用当前处理的对象。与其他模板变量不同,隐式变量没有赋值,它的目的只是定义变量名。

...
<ng-template [paForOf]="getProducts()" let-item>
...

在这个例子中,我使用了let-item来告诉 Angular,我希望将隐式值赋给一个名为item的变量,然后在字符串插值绑定中使用该变量来显示当前数据项的name属性。

...
<td colspan="4">{{item.name}}</td>
...

查看ng-template元素,您可以看到新指令的目的是遍历组件的getProducts方法,并为每个方法生成一个显示name属性的表行。为了实现这个功能,我在src/app文件夹中创建了一个名为iterator.directive.ts的文件,并定义了清单 16-8 中所示的指令。

import { Directive, ViewContainerRef, TemplateRef,
             Input, SimpleChange } from "@angular/core";

@Directive({
    selector: "[paForOf]"
})
export class PaIteratorDirective {

    constructor(private container: ViewContainerRef,
        private template: TemplateRef<Object>) {}

    @Input("paForOf")
    dataSource: any;

    ngOnInit() {
        this.container.clear();
        for (let i = 0; i < this.dataSource.length; i++) {
            this.container.createEmbeddedView(this.template,
                 new PaIteratorContext(this.dataSource[i]));
        }
    }
}

class PaIteratorContext {
    constructor(public $implicit: any) {}
}

Listing 16-8.The Contents of the iterator.directive.ts File in the src/app Folder

@Directive装饰器中的selector属性匹配具有paForOf属性的元素,该属性也是dataSource输入属性的数据源,并提供将被迭代的对象的源。

一旦设置了输入属性的值,就会调用ngOnInit方法,该指令使用clear方法清空视图容器,并使用createEmbeddedView方法为每个对象添加一个新视图。

当调用createEmbeddedView方法时,该指令提供两个参数:通过构造函数接收的TemplateRef对象和一个上下文对象。TemplateRef对象提供要插入到容器中的内容,上下文对象提供隐式值的数据,隐式值是使用名为$implicit的属性指定的。这个对象及其$implicit属性被分配给item模板变量,并在字符串插值绑定中被引用。为了以类型安全的方式为模板提供上下文对象,我定义了一个名为PaIteratorContext的类,它唯一的属性名为$implicit

ngOnInit方法揭示了使用视图容器的一些重要方面。首先,视图容器可以由多个视图填充,在本例中,数据源中的每个对象有一个视图。ViewContainerRef类提供了管理这些创建好的视图所需的功能,您将在接下来的章节中看到。

第二,一个模板可以被重用来创建多个视图。在这个例子中,ng-template元素的内容将用于为数据源中的每个对象创建相同的trtd元素。td元素包含一个数据绑定,在创建每个视图时由 Angular 处理,并用于根据其数据对象定制内容。

第三,指令不了解它所处理的数据,也不了解正在生成的内容。Angular 负责为指令提供它需要的来自应用其余部分的上下文,通过输入属性提供数据源,通过TemplateRef对象为每个视图提供内容。

启用该指令需要添加一个 Angular 模块,如清单 16-9 所示。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule  } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";
import { PaStructureDirective } from "./structure.directive";
import { PaIteratorDirective } from "./iterator.directive";

@NgModule({
    imports: [BrowserModule, FormsModule, ReactiveFormsModule],
    declarations: [ProductComponent, PaAttrDirective, PaModel,
        PaStructureDirective, PaIteratorDirective],
    bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 16-9.Adding a Custom Directive in the app.module.ts File in the src/app Folder

结果是该指令遍历其数据源中的对象,并使用ng-template元素的内容为每个对象创建一个视图,为表格提供行,如图 16-3 所示。您需要选中该框来显示该表。(如果您没有看到更改,请启动 Angular 开发工具并重新加载浏览器窗口。)

img/421542_4_En_16_Fig3_HTML.jpg

图 16-3。

创建迭代结构指令

提供额外的上下文数据

结构化指令可以为模板提供额外的值,这些值将被分配给模板变量并在绑定中使用。例如,ngFor指令提供了oddevenfirstlast值。上下文值是通过定义$implicit属性的同一个对象提供的,在清单 16-10 中,我重新创建了与ngFor提供的值相同的一组值。

import { Directive, ViewContainerRef, TemplateRef,
             Input, SimpleChange } from "@angular/core";

@Directive({
    selector: "[paForOf]"
})
export class PaIteratorDirective {

    constructor(private container: ViewContainerRef,
        private template: TemplateRef<Object>) {}

    @Input("paForOf")
    dataSource: any;

    ngOnInit() {
        this.container.clear();
        for (let i = 0; i < this.dataSource.length; i++) {
            this.container.createEmbeddedView(this.template,
                 new PaIteratorContext(this.dataSource[i],
                     i, this.dataSource.length));
        }
    }
}

class PaIteratorContext {
    odd: boolean; even: boolean;
    first: boolean; last: boolean;

    constructor(public $implicit: any,
            public index: number, total: number ) {

        this.odd = index % 2 == 1;
        this.even = !this.odd;
        this.first = index == 0;
        this.last = index == total - 1;
    }
}

Listing 16-10.Providing Context Data in the iterator.directive.ts File in the src/app Folder

这个清单在PaIteratorContext类中定义了额外的属性,并扩展了它的构造函数,以便它接收额外的参数,这些参数用于设置属性值。

这些增加的效果是,上下文对象属性可以用来创建模板变量,然后可以在绑定表达式中引用这些变量,如清单 16-11 所示。

<div class="m-2">
    <div class="checkbox">
      <label>
        <input type="checkbox" [(ngModel)]="showTable" />
        Show Table
      </label>
    </div>

    <table *paIf="showTable"
            class="table table-sm table-bordered table-striped">
        <thead>
            <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
        </thead>
        <tbody>
            <ng-template [paForOf]="getProducts()" let-item let-i="index"
                    let-odd="odd" let-even="even">
                <tr [class.bg-info]="odd" [class.bg-warning]="even">
                    <td>{{i + 1}}</td>
                    <td>{{item.name}}</td>
                    <td>{{item.category}}</td>
                    <td>{{item.price}}</td>
                </tr>
            </ng-template>
        </tbody>
    </table>
</div>

Listing 16-11.Using Structural Directive Context Data in the template.html File in the src/app Folder

模板变量是使用let-<name>属性语法创建的,并被赋予一个上下文数据值。在这个清单中,我使用了oddeven上下文值来创建同名的模板变量,然后将它们合并到tr元素上的类绑定中,从而得到条带化的表格行,如图 16-4 所示。该清单还添加了表格单元格来显示所有的Product属性。

img/421542_4_En_16_Fig4_HTML.jpg

图 16-4。

使用指令上下文数据

使用简明结构语法

迭代结构指令支持简洁的语法并省略了ng-template元素,如清单 16-12 所示。

<div class="m-2">
    <div class="checkbox">
      <label>
        <input type="checkbox" [(ngModel)]="showTable" />
        Show Table
      </label>
    </div>

    <table *paIf="showTable"
            class="table table-sm table-bordered table-striped">
        <thead>
            <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
        </thead>
        <tbody>
            <tr *paFor="let item of getProducts(); let i = index; let odd = odd;
                    let even = even" [class.bg-info]="odd" [class.bg-warning]="even">
                <td>{{i + 1}}</td>
                <td>{{item.name}}</td>
                <td>{{item.category}}</td>
                <td>{{item.price}}</td>
            </tr>
        </tbody>
    </table>
</div>

Listing 16-12.Using the Concise Syntax in the template.html File in the src/app Folder

这是一个比属性指令所要求的更大的变化。最大的变化是用于应用指令的属性。当使用完整语法时,使用选择器指定的属性将指令应用于ng-template元素,如下所示:

...
<ng-template [paForOf]="getProducts()" let-item let-i="index" let-odd="odd"
    let-even="even">
...

使用简明语法时,属性的Of部分被省略,名称以星号为前缀,括号被省略。

...
<tr *paFor="let item of getProducts(); let i = index; let odd = odd;
            let even = even" [class.bg-info]="odd" [class.bg-warning]="even">
...

另一个变化是将所有的上下文值合并到指令的表达式中,替换单个的let-属性。主数据值成为初始表达式的一部分,附加的上下文值用分号分隔。

不需要对指令进行任何修改来支持简明语法,它的选择器和输入属性仍然指定一个名为paForOf的属性。Angular 负责扩展简洁的语法,该指令不知道也不关心是否使用了一个ng-template元素。

处理属性级数据更改

迭代结构指令所使用的数据源可能会发生两种变化。第一种情况发生在单个对象的属性改变时。这会对包含在ng-template元素中的数据绑定产生连锁效应,或者直接通过隐式值的变化,或者间接地通过指令提供的额外上下文值。Angular 自动处理这些变化,在依赖它们的绑定中反映上下文数据的任何变化。

为了演示,在清单 16-13 中,我在 context 类的构造函数中添加了一个对标准 JavaScript setInterval函数的调用。传递给setInterval的函数改变了oddeven属性,并改变了用作隐式值的Product对象的price属性的值。

...
class PaIteratorContext {
    odd: boolean; even: boolean;
    first: boolean; last: boolean;

    constructor(public $implicit: any,
            public index: number, total: number ) {

        this.odd = index % 2 == 1;
        this.even = !this.odd;
        this.first = index == 0;
        this.last = index == total - 1;

        setInterval(() => {
            this.odd = !this.odd; this.even = !this.even;
            this.$implicit.price++;
        }, 2000);
    }
}
...

Listing 16-13.Modifying Individual Objects in the iterator.directive.ts File in the src/app Folder

每两秒钟,oddeven属性的值就会反转一次,并且price的值会递增。保存更改后,您会看到表格行的颜色发生变化,价格缓慢上升,如图 16-5 所示。

img/421542_4_En_16_Fig5_HTML.jpg

图 16-5。

针对单个数据源对象的自动更改检测

处理集合级别的数据更改

第二种类型的更改发生在添加、移除或替换集合中的对象时。Angular 不会自动检测这种变化,这意味着迭代指令的ngOnChanges方法不会被调用。

接收关于集合级更改的通知是通过实现ngDoCheck方法来完成的,只要在应用中检测到数据更改,就会调用该方法,而不管更改发生在哪里或者是哪种类型的更改。ngDoCheck方法允许一个指令响应变化,即使它们没有被 Angular 自动检测到。然而,实现ngDoCheck方法需要谨慎,因为它代表了一个会破坏 web 应用性能的陷阱。为了演示这个问题,清单 16-14 实现了ngDoCheck方法,以便当有变化时,指令更新它显示的内容。

import { Directive, ViewContainerRef, TemplateRef,
             Input, SimpleChange } from "@angular/core";

@Directive({
    selector: "[paForOf]"
})
export class PaIteratorDirective {

    constructor(private container: ViewContainerRef,
        private template: TemplateRef<Object>) {}

    @Input("paForOf")
    dataSource: any;

    ngOnInit() {
        this.updateContent();
    }

    ngDoCheck() {
        console.log("ngDoCheck Called");
        this.updateContent();
    }

    private updateContent() {
        this.container.clear();
        for (let i = 0; i < this.dataSource.length; i++) {
            this.container.createEmbeddedView(this.template,
                 new PaIteratorContext(this.dataSource[i],
                     i, this.dataSource.length));
        }
    }
}

class PaIteratorContext {
    odd: boolean; even: boolean;
    first: boolean; last: boolean;

    constructor(public $implicit: any,
            public index: number, total: number ) {

        this.odd = index % 2 == 1;
        this.even = !this.odd;
        this.first = index == 0;
        this.last = index == total - 1;

        // setInterval(() => {
        //     this.odd = !this.odd; this.even = !this.even;
        //     this.$implicit.price++;
        // }, 2000);
    }
}

Listing 16-14.Implementing the ngDoCheck Methods in the iterator.directive.ts File in the src/app Folder

ngOnInitngDoCheck方法都调用一个新的updateContent方法,该方法清除视图容器的内容并为数据源中的每个对象生成新的模板内容。我还注释掉了对PaIteratorContext类中setInterval函数的调用。

为了理解集合级更改和ngDoCheck方法的问题,我需要将表单恢复到组件的模板,如清单 16-15 所示。

<div class="row m-2">
    <div class="col-4">
        <form class="m-2" novalidate (ngSubmit)="submitForm()">
            <div class="form-group">
                <label>Name</label>
                <input class="form-control" name="name"
                    [(ngModel)]="newProduct.name" />
            </div>
            <div class="form-group">
                <label>Category</label>
                <input class="form-control" name="category"
                    [(ngModel)]="newProduct.category" />
            </div>
            <div class="form-group">
                <label>Price</label>
                <input class="form-control" name="price"
                    [(ngModel)]="newProduct.price" />
            </div>
            <button class="btn btn-primary" type="submit">Create</button>
        </form>
    </div>
    <div class="col-8">
        <div class="checkbox">
        <label>
            <input type="checkbox" [(ngModel)]="showTable" />
            Show Table
        </label>
        </div>

        <table *paIf="showTable"
                class="table table-sm table-bordered table-striped">
            <thead>
                <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
            </thead>
            <tbody>
                <tr *paFor="let item of getProducts(); let i = index; let odd = odd;
                        let even = even" [class.bg-info]="odd"
                        [class.bg-warning]="even">
                    <td>{{i + 1}}</td>
                    <td>{{item.name}}</td>
                    <td>{{item.category}}</td>
                    <td>{{item.price}}</td>
                </tr>
            </tbody>
        </table>
    </div>
</div>

Listing 16-15.Restoring the HTML Form in the template.html File in the src/app Folder

当您保存对模板的更改时,HTML 表单将显示在产品表的旁边,如图 16-6 所示。(您必须选中该框才能显示该表。)

img/421542_4_En_16_Fig6_HTML.jpg

图 16-6。

恢复模板中的表

ngDoCheck方法的问题在于,每当 Angular 检测到应用中的任何地方发生变化时,它就会被调用——而这些变化发生的频率比您预期的要高。

为了演示变化发生的频率,我在清单 16-14 中的指令的ngDoCheck方法中添加了对console.log方法的调用,这样每次调用ngDoCheck方法时,浏览器的 JavaScript 控制台都会显示一条消息。使用 HTML 表单创建一个新产品,并查看有多少条消息被写到浏览器的 JavaScript 控制台,每条消息代表 Angular 检测到的一个变化,并导致对ngDoCheck方法的调用。

每当 input 元素获得焦点、每次触发按键事件、每次执行验证检查等等时,都会显示一条新消息。一个快速测试是在 Running 类别中添加一个价格为 100 美元的跑鞋产品,在我的系统上生成了 27 条消息,尽管确切的数量会根据您在元素之间导航的方式、您是否需要纠正输入错误等等而有所不同。

对于这 27 次中的每一次,结构指令销毁并重新创建其内容,这意味着用新的指令和绑定对象产生新的trtd元素。

在示例应用中只有几行数据,但这些都是开销很大的操作,而且真正的应用可能会因为内容被反复破坏和重新创建而陷入停顿。这个问题最糟糕的部分是,除了一个更改之外,所有的更改都是不必要的,因为在新的Product对象被添加到数据模型之前,表中的内容不需要更新。对于所有其他更改,该指令销毁了其内容并创建了一个完全相同的替换。

幸运的是,Angular 提供了一些工具来更有效地管理更新,并只在需要时更新内容。对于应用中的所有更改,仍将调用ngDoCheck方法,但是指令可以检查其数据,以查看是否发生了需要新内容的任何更改,如清单 16-16 所示。

import { Directive, ViewContainerRef, TemplateRef,
             Input, SimpleChange, IterableDiffer, IterableDiffers,
             ChangeDetectorRef, CollectionChangeRecord, DefaultIterableDiffer
} from "@angular/core";

@Directive({
    selector: "[paForOf]"
})
export class PaIteratorDirective {
    private differ: DefaultIterableDiffer<any>;

    constructor(private container: ViewContainerRef,
        private template: TemplateRef<Object>,
        private differs: IterableDiffers,
        private changeDetector: ChangeDetectorRef) {
    }

    @Input("paForOf")
    dataSource: any;

    ngOnInit() {
        this.differ =
           <DefaultIterableDiffer<any>> this.differs.find(this.dataSource).create();
    }

    ngDoCheck() {
        let changes = this.differ.diff(this.dataSource);
        if (changes != null) {
            console.log("ngDoCheck called, changes detected");
            changes.forEachAddedItem(addition => {
                this.container.createEmbeddedView(this.template,
                     new PaIteratorContext(addition.item,
                         addition.currentIndex, changes.length));
            });
        }
    }
}

class PaIteratorContext {
    odd: boolean; even: boolean;
    first: boolean; last: boolean;

    constructor(public $implicit: any,
            public index: number, total: number ) {

        this.odd = index % 2 == 1;
        this.even = !this.odd;
        this.first = index == 0;
        this.last = index == total - 1;
    }
}

Listing 16-16.Minimizing Content Changes in the iterator.directive.ts File in the src/app Folder

这样做的目的是计算出集合中是否有对象被添加、删除或移动。这意味着每次调用ngDoCheck方法时,该指令都必须做一些工作,以避免在没有集合更改要处理时不必要的和昂贵的 DOM 操作。

该过程从构造函数开始,构造函数接收两个新的参数,当创建 directive 类的新实例时,Angular 将提供这两个参数的值。IterableDiffersChangeDetectorRef对象用于在ngOnInit方法中设置数据源集合的变更检测,如下所示:

...
ngOnInit() {
    this.differ =
        <DefaultIterableDiffer<any>> this.differs.find(this.dataSource).create();
}
...

Angular 包含内置类,称为differents,可以检测不同类型对象的变化。IterableDiffers.find方法接受一个对象并返回一个能够为该对象创建不同类的IterableDifferFactory对象。IterableDifferFactory类定义了一个create方法,该方法返回一个DefaultIterableDiffer对象,该对象将使用构造函数中接收到的ChangeDetectorRef对象执行实际的变更检测。

这个咒语的重要部分是DefaultIterableDiffer对象,它被赋予了一个名为differ的属性,以便在调用ngDoCheck方法时可以使用。

...
ngDoCheck() {
    let changes = this.differ.diff(this.dataSource);
    if (changes != null) {
        console.log("ngDoCheck called, changes detected");
        changes.forEachAddedItem(addition => {
            this.container.createEmbeddedView(this.template,
                new PaIteratorContext(addition.item,
                    addition.currentIndex, changes.length));
        });
    }
}
...

DefaultIterableDiffer.diff方法接受一个对象进行比较,并返回一个更改列表,如果没有更改,则返回null。检查null结果可以让指令在应用的其他地方调用ngDoCheck方法进行更改时避免不必要的工作。diff方法返回的对象提供了表 16-4 中描述的属性和方法来处理变更。

表 16-4。

默认可变差异。差异结果方法和属性

|

名字

|

描述

|
| --- | --- |
| collection | 此属性返回已检查更改的对象集合。 |
| length | 此属性返回集合中对象的数量。 |
| forEachItem(func) | 此方法为集合中的每个对象调用指定的函数。 |
| forEachPreviousItem(func) | 此方法为集合的早期版本中的每个对象调用指定的函数。 |
| forEachAddedItem(func) | 此方法为集合中的每个新对象调用指定的函数。 |
| forEachMovedItem(func) | 这个方法为每个位置已经改变的对象调用指定的函数。 |
| forEachRemovedItem(func) | 此方法为从集合中移除的每个对象调用指定的函数。 |
| forEachIdentityChange(func) | 此方法为每个标识已更改的对象调用指定的函数。 |

传递给表 16-4 中描述的方法的函数将接收一个CollectionChangeRecord对象,该对象使用表 16-5 中显示的属性描述一个项目以及它是如何改变的。

表 16-5。

CollectionChangeRecord 属性

|

名字

|

描述

|
| --- | --- |
| item | 此属性返回数据项。 |
| trackById | 如果使用了trackBy函数,该属性返回标识值。 |
| currentIndex | 此属性返回集合中项的当前索引。 |
| previousIndex | 此属性返回集合中该项的上一个索引。 |

清单 16-16 中的代码只需要处理数据源中的新对象,因为这是应用的其余部分可以执行的唯一更改。如果diff方法的结果不是null,那么我使用forEachAddedItem方法为每个被检测到的新对象调用一个粗箭头函数。该函数为每个新对象调用一次,并使用表 16-5 中的属性在视图容器中创建新视图。

清单 16-16 中的变化包括一个新的控制台消息,只有当指令检测到数据变化时,该消息才会被写入浏览器的 JavaScript 控制台。如果您重复添加新产品的过程,您将会看到只有在应用首次启动和单击 Create 按钮时才会显示该消息。ngDoCheck方法仍在被调用,指令每次都要检查数据变化,所以仍有不必要的工作在进行。但是这些操作比销毁然后重新创建 HTML 元素要便宜和耗时得多。

跟踪视图

当您处理新数据项的创建时,处理变化检测是简单的。其他操作——比如处理删除或修改——更复杂,需要指令跟踪哪个视图与哪个数据对象相关联。

为了演示,我将添加对从数据模型中删除一个Product对象的支持。首先,清单 16-17 向组件添加了一个方法,使用产品的键来删除产品。这不是一个要求,因为模板可以通过组件的model属性访问存储库,但是当所有数据都以相同的方式访问和使用时,它可以帮助应用更容易理解。

import { ApplicationRef, Component } from "@angular/core";
import { NgForm, FormGroup } from "@angular/forms";
import { Model } from "./repository.model";
import { Product } from "./product.model";
import { ProductFormGroup, ProductFormControl } from "./form.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();
    formGroup: ProductFormGroup = new ProductFormGroup();
    showTable: boolean = false;

    getProduct(key: number): Product {
        return this.model.getProduct(key);
    }

    getProducts(): Product[] {
        return this.model.getProducts();
    }

    newProduct: Product = new Product();

    addProduct(p: Product) {
        this.model.saveProduct(p);
    }

    deleteProduct(key: number) {
        this.model.deleteProduct(key);
    }

    formSubmitted: boolean = false;

    submitForm() {
        this.addProduct(this.newProduct);
    }
}

Listing 16-17.Adding a Delete Method in the component.ts File in the src/app Folder

清单 16-18 更新了模板,使结构化指令生成的内容包含一列button元素,这些元素将删除与包含它的行相关联的数据对象。

...
<table *paIf="showTable"
        class="table table-sm table-bordered table-striped">
    <thead>
        <tr><th></th><th>Name</th><th>Category</th><th>Price</th><th></th></tr>
    </thead>
    <tbody>
        <tr *paFor="let item of getProducts(); let i = index; let odd = odd;
                let even = even" [class.bg-info]="odd"
                [class.bg-warning]="even">
            <td style="vertical-align:middle">{{i + 1}}</td>
            <td style="vertical-align:middle">{{item.name}}</td>
            <td style="vertical-align:middle">{{item.category}}</td>
            <td style="vertical-align:middle">{{item.price}}</td>
            <td class="text-center">
                <button class="btn btn-danger btn-sm"
                        (click)="deleteProduct(item.id)">
                    Delete
                </button>
            </td>
        </tr>
    </tbody>
</table>
...

Listing 16-18.Adding a Delete Button in the template.html File in the src/app Folder

button元素有调用组件的deleteProduct方法的click事件绑定。我还在现有的td元素上设置了 CSS 样式属性vertical-align的值,以便表格中的文本与按钮文本对齐。最后一步是处理结构化指令中的数据变化,这样当一个对象从数据源中移除时它就会响应,如清单 16-19 所示。

import {
    Directive, ViewContainerRef, TemplateRef,
    Input, SimpleChange, IterableDiffer, IterableDiffers,
    ChangeDetectorRef, CollectionChangeRecord, DefaultIterableDiffer, ViewRef
} from "@angular/core";

@Directive({
    selector: "[paForOf]"
})
export class PaIteratorDirective {
    private differ: DefaultIterableDiffer<any>;
    private views: Map<any, PaIteratorContext> = new Map<any, PaIteratorContext>();

    constructor(private container: ViewContainerRef,
        private template: TemplateRef<Object>,
        private differs: IterableDiffers,
        private changeDetector: ChangeDetectorRef) {
    }

    @Input("paForOf")
    dataSource: any;

    ngOnInit() {
        this.differ =
            <DefaultIterableDiffer<any>>this.differs.find(this.dataSource).create();
    }

    ngDoCheck() {
        let changes = this.differ.diff(this.dataSource);
        if (changes != null) {
            changes.forEachAddedItem(addition => {
                let context = new PaIteratorContext(addition.item,
                    addition.currentIndex, changes.length);
                context.view = this.container.createEmbeddedView(this.template,
                    context);
                this.views.set(addition.trackById, context);
            });
            let removals = false;
            changes.forEachRemovedItem(removal => {
                removals = true;
                let context = this.views.get(removal.trackById);
                if (context != null) {
                    this.container.remove(this.container.indexOf(context.view));
                    this.views.delete(removal.trackById);
                }
            });
            if (removals) {
                let index = 0;
                this.views.forEach(context =>
                    context.setData(index++, this.views.size));
            }
        }
    }
}

class PaIteratorContext {
    index: number;
    odd: boolean; even: boolean;
    first: boolean; last: boolean;
    view: ViewRef;

    constructor(public $implicit: any,
            public position: number, total: number ) {
        this.setData(position, total);
    }

    setData(index: number, total: number) {
        this.index = index;
        this.odd = index % 2 == 1;
        this.even = !this.odd;
        this.first = index == 0;
        this.last = index == total - 1;
    }
}

Listing 16-19.Responding to a Removed Item in the iterator.directive.ts File in the src/app Folder

处理移除的对象需要两个任务。第一项任务是通过删除与由forEachRemovedItem方法提供的条目相对应的视图来更新视图集。这意味着跟踪数据对象和表示它们的视图之间的映射,我通过向PaIteratorContext类添加一个ViewRef属性并使用一个Map来收集它们,通过CollectionChangeRecord.trackById属性的值进行索引。

当处理集合更改时,该指令通过从Map中检索相应的PaIteratorContext对象、获取其ViewRef对象并将其传递给ViewContainerRef.remove元素来处理每个被移除的对象,以从视图容器中移除与该对象相关联的内容。

第二个任务是更新那些保留的对象的上下文数据,以便依赖于视图在视图容器中的位置的绑定被正确地更新。该指令为留在Map中的每个上下文对象调用PaIteratorContext.setData方法,以更新视图在容器中的位置,并更新正在使用的视图总数。如果没有这些改变,由上下文对象提供的属性将不能准确地反映数据模型,这意味着行的背景颜色将不会有条纹,删除按钮将不会指向正确的对象。

这些更改的效果是每个表格行都包含一个删除按钮,该按钮从数据模型中删除相应的对象,从而触发表格的更新,如图 16-7 所示。

img/421542_4_En_16_Fig7_HTML.jpg

图 16-7。

从数据模型中删除对象

查询主体元素内容

指令可以查询它们的主机元素的内容来访问它包含的指令,称为内容子元素,这允许指令协调它们自己一起工作。

Tip

指令也可以通过共享服务一起工作,我在第十九章对此进行了描述。

为了演示如何查询内容,我在src/app文件夹中添加了一个名为cellColor.directive.ts的文件,并用它来定义清单 16-20 中所示的指令。

import { Directive, HostBinding } from "@angular/core";

@Directive({
    selector: "td"
})
export class PaCellColor {

    @HostBinding("class")
    bgClass: string = "";

    setColor(dark: Boolean) {
        this.bgClass = dark ? "bg-dark" : "";
    }
}

Listing 16-20.The Contents of the cellColor.directive.ts File in the src/app Folder

PaCellColor类定义了一个简单的属性指令,它对td元素进行操作,并绑定到主机元素的class属性。setColor方法接受一个布尔参数,当值为true时,将class属性设置为bg-dark,这是深色背景的引导类。

在本例中,PaCellColor类将是嵌入到主机元素内容中的指令。目标是编写另一个指令,该指令将查询其主机元素以定位嵌入的指令并调用其setColor方法。为此,我在src/app文件夹中添加了一个名为cellColorSwitcher.directive.ts的文件,并用它来定义清单 16-21 中所示的指令。

import { Directive, Input, Output, EventEmitter,
         SimpleChange, ContentChild } from "@angular/core";
import { PaCellColor } from "./cellColor.directive";

@Directive({
    selector: "table"
})
export class PaCellColorSwitcher {

    @Input("paCellDarkColor")
    modelProperty: Boolean;

    @ContentChild(PaCellColor)
    contentChild: PaCellColor;

    ngOnChanges(changes: { [property: string]: SimpleChange }) {
        if (this.contentChild != null) {
            this.contentChild.setColor(changes["modelProperty"].currentValue);
        }
    }
}

Listing 16-21.The Contents of the cellColorSwitcher.directive.ts File in the src/app Folder

PaCellColorSwitcher类定义了一个对table元素进行操作的指令,并定义了一个名为paCellDarkColor的输入属性。这个指令的重要部分是contentChild属性。

...
@ContentChild(PaCellColor)
contentChild: PaCellColor;
...

@ContentChild decorator 告诉 Angular,该指令需要查询主机元素的内容,并将查询的第一个结果赋给该属性。@ContentChild director 的参数是一个或多个指令类。在这种情况下,@ContentChild装饰器的参数是PaCellColor,它告诉 Angular 定位包含在主机元素内容中的第一个PaCellColor对象,并将其分配给被装饰的属性。

Tip

您还可以使用模板变量名进行查询,这样@ContentChild("myVariable")将会找到已经分配给myVariable的第一个指令。

查询结果为PaCellColorSwitcher指令提供了对子组件的访问,并允许它调用setColor方法来响应对输入属性的更改。

Tip

如果您想在结果中包含孩子的后代,那么您可以配置查询,就像这样:@ContentChild(PaCellColor, { descendants: true})

在清单 16-22 中,我修改了模板中的复选框,因此它使用ngModel指令来设置一个绑定到PaCellColorSwitcher指令的输入属性的变量。

...
<div class="col-8">

    <div class="checkbox">
        <label>
            <input type="checkbox" [(ngModel)]="darkColor" />
            Dark Cell Color
        </label>
    </div>

    <table class="table table-sm table-bordered table-striped"
            [paCellDarkColor]="darkColor">
        <thead>
            <tr><th></th><th>Name</th><th>Category</th><th>Price</th><th></th></tr>
        </thead>
        <tbody>
            <tr *paFor="let item of getProducts(); let i = index; let odd = odd;
                    let even = even" [class.bg-info]="odd"
                    [class.bg-warning]="even">
                    <td style="vertical-align:middle">{{i + 1}}</td>
                    <td style="vertical-align:middle">{{item.name}}</td>
                    <td style="vertical-align:middle">{{item.category}}</td>
                    <td style="vertical-align:middle">{{item.price}}</td>
                    <td class="text-center">
                        <button class="btn btn-danger btn-sm"
                                (click)="deleteProduct(item.id)">
                            Delete
                        </button>
                    </td>
            </tr>
        </tbody>
    </table>
</div>
...

Listing 16-22.Applying the Directives in the template.html File in the src/app Folder

清单 16-23 向组件添加了darkColor属性。

import { ApplicationRef, Component } from "@angular/core";
import { NgForm, FormGroup } from "@angular/forms";
import { Model } from "./repository.model";
import { Product } from "./product.model";
import { ProductFormGroup, ProductFormControl } from "./form.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();
    formGroup: ProductFormGroup = new ProductFormGroup();
    showTable: boolean = false;
    darkColor: boolean = false;

    getProduct(key: number): Product {
        return this.model.getProduct(key);
    }

    getProducts(): Product[] {
        return this.model.getProducts();
    }

    newProduct: Product = new Product();

    addProduct(p: Product) {
        this.model.saveProduct(p);
    }

    deleteProduct(key: number) {
        this.model.deleteProduct(key);
    }

    formSubmitted: boolean = false;

    submitForm() {
        this.addProduct(this.newProduct);
    }
}

Listing 16-23.Defining a Property in the component.ts File in the src/app Folder

最后一步是用 Angular 模块的declarations属性注册新指令,如清单 16-24 所示。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule  } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";
import { PaStructureDirective } from "./structure.directive";
import { PaIteratorDirective } from "./iterator.directive";
import { PaCellColor } from "./cellColor.directive";
import { PaCellColorSwitcher } from "./cellColorSwitcher.directive";

@NgModule({
    imports: [BrowserModule, FormsModule, ReactiveFormsModule],
    declarations: [ProductComponent, PaAttrDirective, PaModel,
        PaStructureDirective, PaIteratorDirective,
        PaCellColor, PaCellColorSwitcher],
    bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 16-24.Registering New Directives in the app.module.ts File in the src/app Folder

保存更改时,您会在表格上方看到一个新的复选框。当您选中该框时,ngModel指令将导致PaCellColorSwitcher指令的输入属性被更新,这将调用使用@ContentChild装饰器找到的PaCellColor指令对象的setColor方法。视觉效果很小,因为只有第一个PaCellColor指令受到影响,它是表格左上角显示数字1的单元格,如图 16-8 所示。(如果没有看到颜色变化,那么重新启动 Angular development tools,重新加载浏览器。)

img/421542_4_En_16_Fig8_HTML.jpg

图 16-8。

对内容子项进行操作

查询多个子内容

@ContentChild decorator 找到匹配参数的第一个指令对象,并将其分配给被修饰的属性。如果你想接收所有匹配参数的指令对象,那么你可以使用@ContentChildren装饰器,如清单 16-25 所示。

import { Directive, Input, Output, EventEmitter,
         SimpleChange, ContentChildren, QueryList } from "@angular/core";
import { PaCellColor } from "./cellColor.directive";

@Directive({
    selector: "table"
})
export class PaCellColorSwitcher {

    @Input("paCellDarkColor")
    modelProperty: Boolean;

    @ContentChildren(PaCellColor, {descendants: true})
    contentChildren: QueryList<PaCellColor>;

    ngOnChanges(changes: { [property: string]: SimpleChange }) {
        this.updateContentChildren(changes["modelProperty"].currentValue);
    }

    private updateContentChildren(dark: Boolean) {
        if (this.contentChildren != null && dark != undefined) {
            this.contentChildren.forEach((child, index) => {
                child.setColor(index % 2 ? dark : !dark);
            });
        }
    }
}

Listing 16-25.Querying Multiple Children in the cellColorSwitcher.directive.ts File in the src/app Folder

当您使用@ContentChildren装饰器时,查询的结果通过QueryList提供,它使用表 16-6 中描述的方法和属性提供对指令对象的访问。descendants配置属性用于选择后代元素,如果没有该值,则只选择直接子元素。

表 16-6。

查询列表成员

|

名字

|

描述

|
| --- | --- |
| length | 此属性返回匹配的指令对象的数量。 |
| first | 此属性返回第一个匹配的指令对象。 |
| last | 此属性返回最后匹配的指令对象。 |
| map(function) | 这个方法为每个匹配的指令对象调用一个函数来创建一个新的数组,相当于Array.map方法。 |
| filter(function) | 该方法为每个匹配的指令对象调用一个函数来创建一个数组,该数组包含函数返回 true 的对象,相当于Array.filter方法。 |
| reduce(function) | 这个方法为每个匹配的指令对象调用一个函数来创建一个值,相当于Array.reduce方法。 |
| forEach(function) | 这个方法为每个匹配的指令对象调用一个函数,相当于Array.forEach方法。 |
| some(function) | 该方法为每个匹配的指令对象调用一个函数,如果函数至少返回一次true,则返回true,相当于Array.some方法。 |
| changes | 该属性用于监视更改的结果,如即将到来的“接收查询更改通知”一节中所述。 |

在清单中,该指令通过调用updateContentChildren方法来响应输入属性值的变化,该方法又在QueryList上使用forEach方法,并在每第二个匹配查询的指令上调用setColor方法。图 16-9 显示复选框被选中时的效果。

img/421542_4_En_16_Fig9_HTML.jpg

图 16-9。

操作多个子内容

接收查询更改通知

内容查询的结果是实时的,这意味着它们会自动更新以反映主体元素内容中的添加、更改或删除。当查询结果发生变化时,接收通知需要使用Observable接口,该接口由自动添加到项目中的 Reactive Extensions 包提供。我会在第二十三章更详细地解释Observable对象是如何工作的,但是现在,知道 Angular 在内部使用它们来管理变化就足够了。

在清单 16-26 中,我已经更新了PaCellColorSwitcher指令,这样当QueryList中的子内容集发生变化时,它就会收到通知。

import { Directive, Input, Output, EventEmitter,
         SimpleChange, ContentChildren, QueryList } from "@angular/core";
import { PaCellColor } from "./cellColor.directive";

@Directive({
    selector: "table"
})
export class PaCellColorSwitcher {

    @Input("paCellDarkColor")
    modelProperty: Boolean;

    @ContentChildren(PaCellColor, {descendants: true})
    contentChildren: QueryList<PaCellColor>;

    ngOnChanges(changes: { [property: string]: SimpleChange }) {
        this.updateContentChildren(changes["modelProperty"].currentValue);
    }

    ngAfterContentInit() {
        this.contentChildren.changes.subscribe(() => {
            setTimeout(() => this.updateContentChildren(this.modelProperty), 0);
        });
    }

    private updateContentChildren(dark: Boolean) {
        if (this.contentChildren != null && dark != undefined) {
            this.contentChildren.forEach((child, index) => {
                child.setColor(index % 2 ? dark : !dark);
            });
        }
    }
}

Listing 16-26.Receiving Notifications in the cellColorSwitcher.directive.ts File in the src/app Folder

在调用ngAfterContentInit生命周期方法之前,不会设置内容子查询属性的值,所以我使用这个方法来设置更改通知。QueryList类定义了一个返回反应扩展Observable对象的changes方法,该对象定义了一个subscribe方法。这个方法接受一个函数,当QueryList的内容发生变化时,这个函数被调用,这意味着与@ContentChildren装饰器的参数匹配的指令集合发生了一些变化。我传递给subscribe方法的函数调用updateContentChildren方法来设置颜色,但是它是在对setTimeout函数的调用中完成的,这会延迟方法调用的执行,直到subscribe回调函数完成之后。如果不调用setTimeout,Angular 将会报告一个错误,因为该指令试图在现有内容更新被完全处理之前开始一个新的内容更新。这些变化的结果是深色会自动应用于使用 HTML 表单时创建的新表格单元格,如图 16-10 所示。

img/421542_4_En_16_Fig10_HTML.jpg

图 16-10。

对内容查询更改通知采取行动

摘要

在本章中,我通过重新创建内置的ngIfngFor指令的功能,解释了结构化指令是如何工作的。我解释了视图容器和模板的使用,描述了应用结构化指令的完整而简洁的语法,并向您展示了如何创建一个迭代数据对象集合的指令,以及指令如何查询它们的主机元素的内容。在下一章,我将介绍组件并解释它们与指令的不同。

十七、了解组件

组件是拥有自己的模板的指令,而不是依赖于从其他地方提供的内容。组件可以访问前面章节中描述的所有指令特性,仍然有一个宿主元素,仍然可以定义输入和输出属性,等等。但是它们也定义了自己的内容。

很容易低估模板的重要性,但是属性和结构指令有局限性。指令可以做有用和强大的工作,但是它们对应用它们的元素没有太多的洞察力。指令在作为通用工具时最有用,例如ngModel指令,它可以应用于任何数据模型属性和任何表单元素,而不考虑数据或元素的用途。

相比之下,组件与其模板的内容紧密相关。组件提供数据和逻辑,这些数据和逻辑将由应用于模板中 HTML 元素的数据绑定使用,这些数据和逻辑提供用于评估数据绑定表达式的上下文,并充当指令和应用其余部分之间的粘合剂。组件也是一个有用的工具,允许将大 Angular 的项目分解成可管理的块。

在这一章中,我将解释组件是如何工作的,并解释如何通过引入一些额外的组件来重构一个应用。表 17-1 将组件放在上下文中。

表 17-1。

将组件放在上下文中

|

问题

|

回答

|
| --- | --- |
| 它们是什么? | 组件是定义它们自己的 HTML 内容和 CSS 样式(可选)的指令。 |
| 它们为什么有用? | 组件使得定义自包含的功能块成为可能,这使得项目更易于管理,并且允许功能更容易被重用。 |
| 它们是如何使用的? | @Component decorator 应用于一个类,该类注册在应用的 Angular 模块中。 |
| 有什么陷阱或限制吗? | 不需要。组件提供了指令的所有功能,还提供了自己的模板。 |
| 有其他选择吗? | Angular 应用必须包含至少一个用于引导过程的组件。除此之外,您不必添加额外的组件,尽管最终的应用变得难以管理。 |

表 17-2 总结了本章内容。

表 17-2。

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 创建组件 | 将@Component指令应用于一个类 | 1–5 |
| 定义组件显示的内容 | 创建内联或外部模板 | 6–8 |
| 在模板中包含数据 | 在组件的模板中使用数据绑定 | nine |
| 组件之间的协调 | 使用输入或输出属性 | 10–16 |
| 在应用了组件的元素中显示内容 | 投影主体元素的内容 | 17–21 |
| 样式组件内容 | 创建组件样式 | 22–30 |
| 查询组件模板中的内容 | 使用@ViewChildren装饰器 | Thirty-one |

准备示例项目

在这一章中,我继续使用我在第十一章中创建的示例项目,并且一直在修改。准备本章不需要做任何改动。

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

example文件夹中运行以下命令,启动 Angular 开发工具:

ng serve

打开新的浏览器并导航至http://localhost:4200以查看图 17-1 中的内容。

img/421542_4_En_17_Fig1_HTML.jpg

图 17-1。

运行示例项目

用组件构建应用

目前,示例项目只包含一个组件和一个模板。Angular 应用需要至少一个组件,称为根组件,它是 Angular 模块中指定的入口点。

只有一个组件的问题是,它最终包含了应用所有功能所需的逻辑,其模板包含了向用户公开这些功能所需的所有标记。结果是单个组件及其模板负责处理大量任务。示例应用中的组件负责以下内容:

  • 提供 Angular 作为应用的入口点,作为根组件

  • 提供对应用数据模型的访问,以便可以在数据绑定中使用它

  • 定义用于创建新产品的 HTML 表单

  • 定义用于显示产品的 HTML 表格

  • 定义包含表单和表格的布局

  • 创建新产品时检查表单数据是否有效

  • 维护用于防止无效数据被用来创建数据的状态信息

  • 维护关于是否应该显示表格的状态信息

对于这样一个简单的应用来说,有很多事情要做,而且并非所有这些任务都是相关的。随着开发的进行,这种影响往往会逐渐增加,但这意味着应用更难测试,因为单个功能无法有效隔离,并且更难增强和维护,因为代码和标记变得越来越复杂。

将组件添加到应用中,可以将功能分成构建块,这些构建块可以在应用的不同部分重复使用,并可以单独测试。在接下来的小节中,我将创建一些组件,这些组件将示例应用中包含的功能分解为可管理的、可重用的和独立的单元。在这个过程中,我将解释组件提供的不同于指令的特性。为了准备这些变化,我简化了现有组件的模板,如清单 17-1 所示。

<div class="row text-white m-2">
  <div class="col-4 p-2 bg-success">
    Form will go here
  </div>
  <div class="col-8 p-2 bg-primary">
    Table will go here
  </div>
</div>

Listing 17-1.Simplifying the Content of the template.html File in the src/app Folder

当您保存对模板的更改时,您将看到图 17-2 中的内容。当我开发新组件并将它们添加到应用中时,占位符将被应用功能替换。

img/421542_4_En_17_Fig2_HTML.jpg

图 17-2。

简化现有模板

创建新组件

为了创建一个新的组件,我在src/app文件夹中添加了一个名为productTable.component.ts的文件,并用它来定义清单 17-2 中所示的组件。

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

@Component({
    selector: "paProductTable",
    template: "<div>This is the table component</div>"
})
export class ProductTableComponent {

}

Listing 17-2.The Contents of the productTable.component.ts File in the src/app Folder

组件是一个已经应用了@Component装饰器的类。这是一个组件所能得到的最简单的东西,它提供了足够的功能来作为一个组件,而不需要做任何有用的事情。

定义组件的文件的命名惯例是使用一个描述性的名称,表明组件的用途,后跟一个句点,然后是component.ts。对于这个将用于生成产品表的组件,文件名是productTable.component.ts。类名应该同样具有描述性。这个组件的类被命名为ProductTableComponent

@Component装饰器描述并配置组件。表 17-3 中描述了最有用的装饰器属性,其中还包括描述它们的细节(本章并未涵盖所有属性)。

表 17-3。

组件装饰器属性

|

名字

|

描述

|
| --- | --- |
| animations | 该属性用于配置动画,如第二十八章所述。 |
| encapsulation | 此属性用于更改视图封装设置,该设置控制组件样式如何与 HTML 文档的其余部分隔离。有关详细信息,请参见“设置视图封装”一节。 |
| selector | 此属性用于指定用于匹配宿主元素的 CSS 选择器,如表后所述。 |
| styles | 此属性用于定义仅应用于组件模板的 CSS 样式。样式是作为 TypeScript 文件的一部分内联定义的。有关详细信息,请参见“使用组件样式”一节。 |
| styleUrls | 此属性用于定义仅应用于组件模板的 CSS 样式。样式是在单独的 CSS 文件中定义的。有关详细信息,请参见“使用组件样式”一节。 |
| template | 该属性用于指定内联模板,如“定义模板”一节中所述。 |
| templateUrl | 该属性用于指定外部模板,如“定义模板”一节中所述。 |
| providers | 该属性用于为服务创建本地提供者,如第十九章所述。 |
| viewProviders | 该属性用于为仅可用于查看孩子的服务创建本地提供者,如第二十章所述。 |

对于第二个组件,我在src/app文件夹中创建了一个名为productForm.component.ts的文件,并添加了清单 17-3 中所示的代码。

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

@Component({
    selector: "paProductForm",
    template: "<div>This is the form component</div>"
})
export class ProductFormComponent {

}

Listing 17-3.The Contents of the productForm.component.ts File in the src/app Folder

这个组件同样简单,目前只是一个占位符。在本章的后面,我将添加一些更有用的特性。要启用这些组件,它们必须在应用的 Angular 模块中声明,如清单 17-4 所示。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule  } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";
import { PaStructureDirective } from "./structure.directive";
import { PaIteratorDirective } from "./iterator.directive";
import { PaCellColor } from "./cellColor.directive";
import { PaCellColorSwitcher } from "./cellColorSwitcher.directive";
import { ProductTableComponent } from "./productTable.component";
import { ProductFormComponent } from "./productForm.component";

@NgModule({
    imports: [BrowserModule, FormsModule, ReactiveFormsModule],
    declarations: [ProductComponent, PaAttrDirective, PaModel,
        PaStructureDirective, PaIteratorDirective,
        PaCellColor, PaCellColorSwitcher, ProductTableComponent,
        ProductFormComponent],
    bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 17-4.Enabling New Components in the app.module.ts File in the src/app Folder

使用一个import语句将组件类引入范围,并添加到NgModule装饰器的declarations数组中。最后一步是添加一个与组件的选择器属性相匹配的 HTML 元素,如清单 17-5 所示,这将为组件提供其主机元素。

<div class="row text-white m-2">
  <div class="col-4 p-2 bg-success">
    <paProductForm></paProductForm>
  </div>
  <div class="col-8 p-2 bg-primary">
    <paProductTable></paProductTable>
  </div>
</div>

Listing 17-5.Adding a Host Element in the template.html File in the src/app Folder

当所有的更改都被保存后,浏览器将显示如图 17-3 所示的内容,这表明 HTML 文档的一部分现在处于新组件的管理之下。

img/421542_4_En_17_Fig3_HTML.jpg

图 17-3。

添加新组件

了解新的应用结构

新组件改变了应用的结构。以前,根组件负责应用显示的所有 HTML 内容。然而,现在有了三个组件,一些 HTML 内容的责任已经委托给了新添加的组件,如图 17-4 所示。

img/421542_4_En_17_Fig4_HTML.jpg

图 17-4。

新的应用结构

当浏览器加载index.html文件时,Angular 引导程序启动,Angular 处理应用的模块,该模块提供了应用所需的组件列表。Angular 检查其配置中每个组件的装饰器,包括用于标识哪些元素将成为宿主的selector属性的值。

Angular 然后开始处理index.html文件的主体,并找到由ProductComponent组件的selector属性指定的app元素。Angular 用组件的模板填充app元素,该模板包含在template.html文件中。Angular 检查template.html文件的内容,并找到paProductFormpaProductTable元素,它们与新添加组件的选择器属性相匹配。Angular 用每个组件的模板填充这些元素,产生如图 17-3 所示的占位符内容。

有一些重要的新关系需要理解。首先,浏览器窗口中显示的 HTML 内容现在由几个模板组成,每个模板由一个组件管理。其次,ProductComponent现在是ProductFormComponentProductTableComponent对象的父组件,这种关系是由新组件的主机元素在template.html文件(即ProductComponent模板)中定义的事实形成的。同样,新组件是ProductComponent的子组件。当涉及到 Angular 组件时,父子关系是一个重要的关系,正如我在后面的章节中描述组件如何工作时所看到的。

定义模板

尽管应用中有新的组件,但它们目前没有太大的影响,因为它们只显示占位符内容。每个组件都有自己的模板,该模板定义了用于替换 HTML 文档中其宿主元素的内容。有两种不同的方法来定义模板:在@Component装饰器中内联或者在 HTML 文件中外部定义。

我添加的新组件使用模板,其中一段 HTML 被分配给@Component装饰器的template属性,如下所示:

...
template: "<div>This is the form component</div>"
...

这种方法的优点是简单:组件和模板在一个文件中定义,它们之间的关系不会混淆。内联模板的缺点是,如果包含很多 HTML 元素,它们会失去控制,难以阅读。

Note

另一个问题是,在您键入时突出显示语法错误的编辑器通常依赖于文件扩展名来判断应该执行哪种类型的检查,而不会意识到template属性的值是 HTML,只会将其视为字符串。

如果您正在使用 TypeScript,那么您可以使用多行字符串使内联模板更具可读性。多行字符串用反斜杠字符表示(```ts 字符,也称为重音符,它们允许字符串跨多行,如清单 17-6 所示。

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

@Component({
    selector: "paProductTable",
    template: `<div class='bg-info p-2'>
                   This is a multiline template
               </div>`
})
export class ProductTableComponent {

}

Listing 17-6.Using a Multiline String in the productTable.component.ts File in the src/app Folder

```ts

多行字符串允许保留模板中 HTML 元素的结构,这使得阅读更容易,并增加了模板的大小,在变得难以管理之前,实际上可以内联包含模板。图 17-5 显示了清单 17-6 中模板的效果。

![img/421542_4_En_17_Fig5_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-frontend-framework-zh/raw/master/docs/pro-ng9/img/421542_4_En_17_Fig5_HTML.jpg)

图 17-5。

使用多行内联模板

Tip

我的建议是对任何包含两三个以上简单元素的模板使用外部模板(在下一节中解释),主要是为了利用现代编辑器提供的 HTML 编辑和语法突出显示功能,这可以大大减少运行应用时发现的错误数量。

#### 定义外部模板

外部模板在不同于组件其余部分的文件中定义。这种方法的优点是代码和 HTML 没有混合在一起,这使得阅读和单元测试都更容易,这也意味着当您处理模板文件时,代码编辑器将知道他们正在处理 HTML 内容,这可以通过突出显示错误来帮助减少编码时的错误。

外部模板的缺点是,您必须管理项目中的更多文件,并确保每个组件都与正确的模板文件相关联。最好的方法是遵循一致的文件命名策略,这样文件包含给定组件的模板就显而易见了。Angular 的惯例是使用惯例`<componentname>.component.<type>`创建成对的文件,这样当您看到一个名为`productTable.component.ts`的文件时,您就知道它包含一个用 TypeScript 编写的名为`Products`的组件,当您看到一个名为`productTable.component.html`的文件时,您就知道它包含一个用于`Products`组件的外部模板。

Tip

这两种类型的模板的语法和功能是相同的,唯一的区别是内容存储在哪里,是与组件代码存储在同一个文件中,还是存储在一个单独的文件中。

为了使用命名约定定义外部模板,我在`src/app`文件夹中创建了一个名为`productTable.component.html`的文件,并添加了清单 17-7 中所示的标记。

This is an external template

Listing 17-7.The Contents of the productTable.component.html File in the src/app Folder


这是我从第十一章开始就一直用于根组件的模板。为了指定一个外部模板,在`@Component`装饰器中使用了`templateURL`属性,如清单 17-8 所示。

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

@Component({
selector: "paProductTable",
templateUrl: "productTable.component.html"
})
export class ProductTableComponent {

}

Listing 17-8.Using an External Template in the productTable.component.ts File in the src/app Folder


注意使用了不同的属性:`template`用于内联模板,`templateUrl`用于外部模板。图 17-6 显示了使用外部模板的效果。

![img/421542_4_En_17_Fig6_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-frontend-framework-zh/raw/master/docs/pro-ng9/img/421542_4_En_17_Fig6_HTML.jpg)

图 17-6。

使用外部模板

#### 在组件模板中使用数据绑定

一个组件的模板可以包含所有的数据绑定,并以任何内置指令或已在应用的 Angular 模块中注册的自定义指令为目标。每个组件类都提供了用于评估其模板中的数据绑定表达式的上下文,并且默认情况下,每个组件都是相互隔离的。这意味着组件不必担心使用其他组件使用的相同的属性和方法名,并且可以依靠 Angular 来保持一切独立。作为一个例子,清单 17-9 显示了一个名为`model`的属性被添加到表单子组件中,如果它们没有保持分离的话,就会与根组件中同名的属性发生冲突。

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

@Component({
selector: "paProductForm",
template: "

{{model}}
"
})
export class ProductFormComponent {

model: string = "This is the model";

}

Listing 17-9.Adding a Property in the productForm.component.ts File in the src/app Folder


component 类使用`model`属性存储一条消息,该消息使用字符串插值绑定显示在模板中。图 17-7 显示了结果。

![img/421542_4_En_17_Fig7_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-frontend-framework-zh/raw/master/docs/pro-ng9/img/421542_4_En_17_Fig7_HTML.jpg)

图 17-7。

在子组件中使用数据绑定

#### 使用输入属性在组件之间进行协调

很少有组件是孤立存在的,需要与应用的其他部分共享数据。组件可以定义输入属性来接收其宿主元素上的数据绑定表达式的值。表达式将在父组件的上下文中进行计算,但结果将传递给子组件的属性。

为了演示,清单 17-10 向表格组件添加了一个输入属性,它将使用该属性来接收应该显示的模型数据。

import { Component, Input } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";

@Component({
selector: "paProductTable",
templateUrl: "productTable.component.html"
})
export class ProductTableComponent {

@Input("model")
dataModel: Model;

getProduct(key: number): Product {
    return this.dataModel.getProduct(key);
}

getProducts(): Product[] {
    return this.dataModel.getProducts();
}

deleteProduct(key: number) {
    this.dataModel.deleteProduct(key);
}

showTable: boolean = true;

}

Listing 17-10.Defining an Input Property in the productTable.component.ts File in the src/app Folder


该组件现在定义了一个输入属性,该属性将被赋予分配给主机元素上的`model`属性的值表达式。`getProduct`、`getProducts`和`deleteProduct`方法使用输入属性来提供对组件模板中绑定的数据模型的访问,这在清单 17-11 中进行了修改。在本章后面的清单 17-14 中,当我增强模板时会用到`showTable`属性。

There are {{getProducts().length}} items in the model

Listing 17-11.Adding a Data Binding in the productTable.component.html File in the src/app Folder


向子组件提供它所需要的数据意味着向它的宿主元素添加一个绑定,这是在父组件的模板中定义的,如清单 17-12 所示。

Listing 17-12.Adding a Data Binding in the template.html File in the src/app Folder


这个绑定的作用是为子组件提供对父组件的`model`属性的访问。这可能是一个令人困惑的特性,因为它依赖于这样一个事实,即主机元素是在父组件的模板中定义的,而输入属性是由子组件定义的,如图 17-8 所示。

![img/421542_4_En_17_Fig8_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-frontend-framework-zh/raw/master/docs/pro-ng9/img/421542_4_En_17_Fig8_HTML.jpg)

图 17-8。

在父组件和子组件之间共享数据

子组件的 host 元素作为父组件和子组件之间的桥梁,input 属性允许组件向子组件提供它需要的数据,产生如图 17-9 所示的结果。

![img/421542_4_En_17_Fig9_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-frontend-framework-zh/raw/master/docs/pro-ng9/img/421542_4_En_17_Fig9_HTML.jpg)

图 17-9。

将数据从父组件共享到子组件

##### 在子组件模板中使用指令

一旦定义了输入属性,子组件就可以使用所有的数据绑定和指令,要么使用父组件提供的数据,要么定义自己的数据。在清单 17-13 中,我恢复了前面章节中的原始表格功能,它显示了数据模型中的`Product`对象列表,以及一个决定是否显示表格的复选框。该功能以前由根组件及其模板管理。

NameCategoryPrice
{{i + 1}} {{item.name}} {{item.category}} {{item.price}}

Listing 17-13.Restoring the Table in the productTable.component.html File in the src/app Folder


使用相同的 HTML 元素、数据绑定和指令(包括像`paIf`和`paFor`这样的自定义指令),产生如图 17-10 所示的结果。关键区别不在于表的外观,而在于它现在由一个专用组件管理的方式。

![img/421542_4_En_17_Fig10_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-frontend-framework-zh/raw/master/docs/pro-ng9/img/421542_4_En_17_Fig10_HTML.jpg)

图 17-10。

恢复表格显示

#### 使用输出属性在组件之间进行协调

子组件可以使用定义自定义事件的输出属性,这些事件表示重要的更改,并允许父组件在事件发生时做出响应。清单 17-14 展示了向表单组件添加一个输出属性,当用户创建一个新的`Product`对象时将触发该属性。

import { Component, Output, EventEmitter } from "@angular/core";
import { Product } from "./product.model";

@Component({
selector: "paProductForm",
templateUrl: "productForm.component.html"
})
export class ProductFormComponent {
newProduct: Product = new Product();

@Output("paNewProduct")
newProductEvent = new EventEmitter<Product>();

submitForm(form: any) {
    this.newProductEvent.emit(this.newProduct);
    this.newProduct = new Product();
    form.reset();
}

}

Listing 17-14.Defining an Output Property in the productForm.component.ts File in the src/app Folder


输出属性称为`newProductEvent`,组件在调用`submitForm`方法时触发它。除了 output 属性之外,清单中增加的内容基于根控制器中的逻辑,它以前管理表单。我还移除了内联模板,在`src/app`文件夹中创建了一个名为`productForm.component.html`的文件,内容如清单 17-15 所示。

Listing 17-15.The Contents of the productForm.component.html File in the src/app Folder


该表单包含使用双向绑定配置的标准元素。

子组件的主机元素充当到父组件的桥梁,父组件可以在自定义事件中注册兴趣,如清单 17-16 所示。

Listing 17-16.Registering for the Custom Event in the template.html File in the src/app Folder


新绑定通过将事件对象传递给`addProduct`方法来处理自定义事件。子组件负责管理表单元素并验证其内容。当数据通过验证时,自定义事件被触发,数据绑定表达式在父组件的上下文中进行计算,父组件的`addProduct`方法将新对象添加到模型中。由于模型已经通过其 input 属性与 table 子组件共享,新的数据显示给用户,如图 17-11 所示。

![img/421542_4_En_17_Fig11_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-frontend-framework-zh/raw/master/docs/pro-ng9/img/421542_4_En_17_Fig11_HTML.jpg)

图 17-11。

在子组件中使用自定义事件

#### 投影主体元素内容

如果组件的主机元素包含内容,可以使用特殊的`ng-content`元素将其包含在模板中。这被称为*内容投影*,它允许创建将模板中的内容与宿主元素中的内容相结合的组件。为了演示,我在`src/app`文件夹中添加了一个名为`toggleView.component.ts`的文件,并用它来定义清单 17-17 中所示的组件。

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

@Component({
selector: "paToggleView",
templateUrl: "toggleView.component.html"
})
export class PaToggleView {

showContent: boolean = true;

}

Listing 17-17.The Contents of the toggleView.component.ts File in the src/app Folder


该组件定义了一个`showContent`属性,该属性将用于确定主机元素的内容是否会显示在模板中。为了提供模板,我在`src/app`文件夹中添加了一个名为`toggleView.component.html`的文件,并添加了清单 17-18 中所示的元素。

Listing 17-18.The Contents of the toggleView.component.html File in the src/app Folder


重要的元素是`ng-content`,Angular 将用宿主元素的内容替换。`ngIf`指令已经应用到了`ng-content`元素,因此只有当模板中的复选框被选中时,它才可见。清单 17-19 向 Angular 模块注册组件。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";
import { PaStructureDirective } from "./structure.directive";
import { PaIteratorDirective } from "./iterator.directive";
import { PaCellColor } from "./cellColor.directive";
import { PaCellColorSwitcher } from "./cellColorSwitcher.directive";
import { ProductTableComponent } from "./productTable.component";
import { ProductFormComponent } from "./productForm.component";
import { PaToggleView } from "./toggleView.component";

@NgModule({
imports: [BrowserModule, FormsModule, ReactiveFormsModule],
declarations: [ProductComponent, PaAttrDirective, PaModel,
PaStructureDirective, PaIteratorDirective,
PaCellColor, PaCellColorSwitcher, ProductTableComponent,
ProductFormComponent, PaToggleView],
bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 17-19.Registering the Component in the app.module.ts File in the src/app Folder


最后一步是将新组件应用于包含内容的主机元素,如清单 17-20 所示。

Listing 17-20.Adding a Host Element with Content in the template.html File in the src/app Folder


`paToggleView`元素是新组件的宿主,它包含`paProductTable`元素,该元素应用创建产品表的组件。结果是有一个控制表格可见性的复选框,如图 17-12 所示。新组件不知道其宿主元素的内容,只有通过`ng-content`元素才能将其包含在模板中。

![img/421542_4_En_17_Fig12_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-frontend-framework-zh/raw/master/docs/pro-ng9/img/421542_4_En_17_Fig12_HTML.jpg)

图 17-12。

在模板中包含主体元素内容

### 完成组件重组

以前包含在根组件中的功能已经分配给新的子组件。剩下的就是整理根组件,删除不再需要的代码,如清单 17-21 所示。

import { ApplicationRef, Component } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";
import { ProductFormGroup } from "./form.model";

@Component({
selector: "app",
templateUrl: "template.html"
})
export class ProductComponent {
model: Model = new Model();

addProduct(p: Product) {
    this.model.saveProduct(p);
}

}

Listing 17-21.Removing Obsolete Code in the component.ts File in the src/app Folder


根组件的许多职责已经转移到应用的其他地方。从本章开始的原始列表中,只有以下内容仍然是根组件的责任:

*   提供 Angular 作为应用的入口点,作为根组件

*   提供对应用数据模型的访问,以便可以在数据绑定中使用它

子组件承担了其余的责任,提供了更简单、更易于开发、更易于维护的自包含功能块,并且可以根据需要重用。

## 使用组件样式

组件可以定义仅应用于其模板中的内容的样式,这允许组件设置内容的样式,而不受其父组件或其他前身定义的样式的影响,也不会影响其子组件和其他后代组件中的内容。可以使用`@Component`装饰器的`styles`属性内联定义样式,如清单 17-22 所示。

import { Component, Output, EventEmitter } from "@angular/core";
import { Product } from "./product.model";

@Component({
selector: "paProductForm",
templateUrl: "productForm.component.html",
styles: ["div { background-color: lightgreen }"]
})
export class ProductFormComponent {
newProduct: Product = new Product();

@Output("paNewProduct")
newProductEvent = new EventEmitter<Product>();

submitForm(form: any) {
    this.newProductEvent.emit(this.newProduct);
    this.newProduct = new Product();
    form.reset();
}

}

Listing 17-22.Defining Inline Styles in the productForm.component.ts File in the src/app Folder


属性被设置为一个数组,其中每个条目包含一个 CSS 选择器和一个或多个属性。在清单中,我指定了将`div`元素的背景色设置为`lightgreen`的样式。即使整个组合的 HTML 文档中有`div`个元素,这种样式也只会影响定义它们的组件的模板中的元素,在本例中是表单组件,如图 17-13 所示。

![img/421542_4_En_17_Fig13_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-frontend-framework-zh/raw/master/docs/pro-ng9/img/421542_4_En_17_Fig13_HTML.jpg)

图 17-13。

定义内联组件样式

Tip

开发工具创建的包中包含的样式仍然适用,这就是为什么元素仍然使用 Bootstrap 进行样式化。

### 定义外部组件样式

内联样式提供了与内联模板相同的优点和缺点:它们很简单,并且将所有内容保存在一个文件中,但是它们很难阅读、管理,并且会使代码编辑器感到困惑。

另一种方法是在单独的文件中定义样式,并使用装饰器中的`styleUrls`属性将它们与组件关联起来。外部样式文件遵循与模板和代码文件相同的命名约定。我在`src/app`文件夹中添加了一个名为`productForm.component.css`的文件,并用它来定义清单 17-23 中显示的样式。

div {
background-color: lightcoral;
}

Listing 17-23.The Contents of the productForm.component.css File in the src/app Folder


这与内联定义的样式相同,但使用了不同的颜色值来确认这是组件使用的 CSS。在清单 17-24 中,组件的装饰器已经被更新以指定样式文件。

import { Component, Output, EventEmitter } from "@angular/core";
import { Product } from "./product.model";

@Component({
selector: "paProductForm",
templateUrl: "productForm.component.html",
styleUrls: ["productForm.component.css"]
})
export class ProductFormComponent {
newProduct: Product = new Product();

@Output("paNewProduct")
newProductEvent = new EventEmitter<Product>();

submitForm(form: any) {
    this.newProductEvent.emit(this.newProduct);
    this.newProduct = new Product();
    form.reset();
}

}

Listing 17-24.Using External Styles in the productForm.component.ts File in the src/app Folder


属性被设置为一个字符串数组,每个字符串指定一个 CSS 文件。图 17-14 显示添加外部样式文件的效果。

![img/421542_4_En_17_Fig14_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-frontend-framework-zh/raw/master/docs/pro-ng9/img/421542_4_En_17_Fig14_HTML.jpg)

图 17-14。

定义外部组件样式

### 使用高级样式功能

在组件中定义样式是一个有用的特性,但是您不会总是得到您期望的结果。一些高级功能允许您控制组件样式的工作方式。

#### 设置视图封装

默认情况下,特定于组件的样式是通过编写已应用于组件的 CSS 来实现的,这样它就以特殊属性为目标,然后 Angular 将这些属性添加到组件模板中包含的所有顶级元素中。如果您使用浏览器的 F12 开发工具检查 DOM,您将会看到清单 17-23 中的外部 CSS 文件的内容被改写成这样:

...

...


选择器已经过修改,因此它匹配具有名为`_ngcontent-jwe-c40`的属性的`div`元素(尽管您可能会在浏览器中看到不同的名称,因为属性的名称是由 Angular 动态生成的)。

为了确保`style`元素中的 CSS 只影响由组件管理的 HTML 元素,模板中的元素被修改,因此它们具有相同的动态生成的属性,如下所示:

...

...

这被称为组件的*视图封装*行为,Angular 正在做的是模拟一个被称为*阴影 DOM* 的特性,它允许域对象模型的各个部分被隔离,因此它们有自己的范围,这意味着 JavaScript、样式和模板可以应用于 HTML 文档的一部分。Angular 模拟这种行为的原因是它仅由最新版本的现代浏览器实现,但还有两个其他封装选项,它们是使用`@Component`装饰器中的`encapsulation`属性设置的。

Tip

可以在 [`http://developer.mozilla.org/en-US/docs/Web/Web_Components/Shadow_DOM`](http://developer.mozilla.org/en-US/docs/Web/Web_Components/Shadow_DOM) 了解更多暗影 DOM。在 [`http://caniuse.com/#feat=shadowdom`](http://caniuse.com/#feat=shadowdom) 可以看到哪些浏览器支持阴影 DOM 特性。

从`ViewEncapsulation`枚举中为`encapsulation`属性赋值,该枚举在`@angular/core`模块中定义,它定义了表 17-4 中描述的值。

表 17-4。

视图封装值

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

名字

 | 

描述

 |
| --- | --- |
| `Emulated` | 指定该值后,Angular 通过编写内容和样式来添加属性,从而模拟阴影 DOM,如前所述。如果在`@Component`装饰器中没有指定`encapsulation`值,这是默认行为。 |
| `ShadowDom` | 指定该值时,Angular 使用浏览器的阴影 DOM 功能。只有在浏览器实现阴影 DOM 或使用多填充时,这种方法才有效。 |
| `None` | 当指定了这个值时,Angular 只是将未修改的 CSS 样式添加到 HTML 文档的 head 部分,并让浏览器使用普通的 CSS 优先规则来确定如何应用这些样式。 |

应谨慎使用`ShadowDom`和`None`值。浏览器对 shadow DOM 特性的支持是有限的,并且变得更加复杂,因为有一个 shadow DOM 特性的早期版本为了支持当前的方法而被放弃了。

`None`选项将组件定义的所有样式添加到 HTML 文档的`head`部分,并让浏览器决定如何应用它们。这样做的好处是可以在所有浏览器中工作,但是结果是不可预测的,并且不同组件定义的样式之间没有隔离。

为了完整起见,清单 17-25 显示了被设置为`Emulated`的`encapsulation`属性,这是缺省值,并且在 Angular 支持的所有浏览器中都有效,不需要 polyfills。

import { Component, Output, EventEmitter, ViewEncapsulation } from "@angular/core";
import { Product } from "./product.model";

@Component({
selector: "paProductForm",
templateUrl: "productForm.component.html",
styleUrls: ["productForm.component.css"],
encapsulation: ViewEncapsulation.Emulated
})
export class ProductFormComponent {
newProduct: Product = new Product();

@Output("paNewProduct")
newProductEvent = new EventEmitter<Product>();

submitForm(form: any) {
    this.newProductEvent.emit(this.newProduct);
    this.newProduct = new Product();
    form.reset();
}

}

Listing 17-25.Setting View Encapsulation in the productForm.component.ts File in the src/app Folder


#### 使用阴影 DOM CSS 选择器

使用影子 DOM 意味着存在常规 CSS 选择器无法跨越的界限。为了帮助解决这个问题,有一些特殊的 CSS 选择器在使用依赖于阴影 DOM 的样式时很有用(即使它正在被模拟),如表 17-5 中所述,并在下面的章节中演示。

表 17-5。

阴影 DOM CSS 选择器

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

名字

 | 

描述

 |
| --- | --- |
| `:host` | 该选择器用于匹配组件的宿主元素。 |
| `:host-context(classSelector)` | 该选择器用于匹配作为特定类成员的宿主元素的祖先。 |
| `/deep/ or >>>` | 父组件使用该选择器来定义影响子组件模板中元素的样式。这个选择器应该只在`@Component`装饰器的`encapsulation`属性被设置为`emulated`时使用,如“设置视图封装”一节所述。 |

##### 选择主体元素

组件的宿主元素出现在其模板之外,这意味着其样式中的选择器只应用于宿主元素包含的元素,而不是元素本身。这可以通过使用与主机元素匹配的`:host`选择器来解决。清单 17-26 定义了一个仅当鼠标指针悬停在主机元素上时才应用的样式,这是通过组合`:host`和`:hover`选择器指定的。

div {
background-color: lightcoral;
}
:host:hover {
font-size: 25px;
}

Listing 17-26.Matching the Host Element in the productForm.component.css File in the src/app Folder


当鼠标指针在 host 元素上时,其`font-size`属性将被设置为 25px,这将使表单中所有元素的文本大小增加到 25 磅,如图 17-15 所示。

![img/421542_4_En_17_Fig15_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-frontend-framework-zh/raw/master/docs/pro-ng9/img/421542_4_En_17_Fig15_HTML.jpg)

图 17-15。

在构件样式中选择主体元素

##### 选择主体元素的祖先

`:host-context`选择器用于根据宿主元素祖先(在模板之外)的类成员来设计组件模板中元素的样式。这是一个比`:host`更有限的选择器,不能用于指定除类选择器之外的任何东西,不支持匹配标签类型、属性或任何其他选择器。清单 17-27 显示了`:host-context`选择器的使用。

div {
background-color: lightcoral;
}
:host:hover {
font-size: 25px;
}
:host-context(.angularApp) input {
background-color: lightgray;
}

Listing 17-27.Selecting Ancestors in the productForm.component.css File in the src/app Folder


只有当宿主元素的祖先元素之一是名为`angularApp`的类的成员时,清单中的选择器才会将组件模板中`input`元素的`background-color`属性设置为`lightgrey`。在清单 17-28 中,我将`index.html`文件中的`app`元素添加到了`angularApp`类中,它是根组件的宿主元素。

<!doctype html>

Example

Listing 17-28.Adding the Host Element to a Class in the index.html File in the src/app Folder


图 17-16 显示了清单 17-28 变更前后选择器的效果。

![img/421542_4_En_17_Fig16_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-frontend-framework-zh/raw/master/docs/pro-ng9/img/421542_4_En_17_Fig16_HTML.jpg)

图 17-16。

选择主体元素的祖先

##### 将样式推入子组件的模板

组件定义的样式不会自动应用于子组件模板中的元素。作为示范,清单 17-29 向根组件的`@Component`装饰器添加了一个样式。

import { ApplicationRef, Component } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";
import { ProductFormGroup } from "./form.model";

@Component({
selector: "app",
templateUrl: "template.html",
styles: ["div { border: 2px black solid; font-style:italic }"]
})
export class ProductComponent {
model: Model = new Model();

addProduct(p: Product) {
    this.model.saveProduct(p);
}

}

Listing 17-29.Defining Styles in the component.ts File in the src/app Folder


选择器匹配所有的`div`元素,应用边框并改变字体样式。图 17-17 显示了结果。

![img/421542_4_En_17_Fig17_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-frontend-framework-zh/raw/master/docs/pro-ng9/img/421542_4_En_17_Fig17_HTML.jpg)

图 17-17。

应用常规 CSS 样式

有些 CSS 样式属性,如`font-style`,默认情况下是继承的,这意味着在父组件中设置这样的属性会影响子组件模板中的元素,因为浏览器会自动应用该样式。

其他属性,比如`border`,默认情况下不被继承,在父组件中设置这样的属性对子组件模板没有影响,除非使用`/deep/`或`>>>`选择器,如清单 17-30 所示。(这些选择器是彼此的别名,具有相同的效果。)

import { ApplicationRef, Component } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";
import { ProductFormGroup } from "./form.model";

@Component({
selector: "app",
templateUrl: "template.html",
styles: ["/deep/ div { border: 2px black solid; font-style:italic }"]
})
export class ProductComponent {
model: Model = new Model();

addProduct(p: Product) {
    this.model.saveProduct(p);
}

}

Listing 17-30.Pushing a Style into Child Templates in the component.ts File in the src/app Folder


样式选择器使用`/deep/`将样式推送到子组件的模板中,这意味着所有的`div`元素都被赋予了一个边框,如图 17-18 所示。

![img/421542_4_En_17_Fig18_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-frontend-framework-zh/raw/master/docs/pro-ng9/img/421542_4_En_17_Fig18_HTML.jpg)

图 17-18。

将样式推入子组件模板

## 查询模板内容

组件可以查询其模板的内容来定位指令或组件的实例,这被称为*视图子代*。这些类似于第十六章中描述的指令内容子查询,但有一些重要的区别。

在清单 17-31 中,我向管理查询`PaCellColor`指令的表的组件添加了一些代码,创建该表是为了演示指令内容查询。该指令仍然在 Angular 模块中注册,并选择`td`元素,因此 Angular 会将它应用到表格组件内容的单元格中。

import { Component, Input, ViewChildren, QueryList } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";
import { PaCellColor } from "./cellColor.directive";

@Component({
selector: "paProductTable",
templateUrl: "productTable.component.html"
})
export class ProductTableComponent {

@Input("model")
dataModel: Model;

getProduct(key: number): Product {
    return this.dataModel.getProduct(key);
}

getProducts(): Product[] {
    return this.dataModel.getProducts();
}

deleteProduct(key: number) {
    this.dataModel.deleteProduct(key);
}

showTable: boolean = true;

@ViewChildren(PaCellColor)
viewChildren: QueryList<PaCellColor>;

ngAfterViewInit() {
    this.viewChildren.changes.subscribe(() => {
        this.updateViewChildren();
    });
    this.updateViewChildren();
}

private updateViewChildren() {
    setTimeout(() => {
        this.viewChildren.forEach((child, index) => {
            child.setColor(index % 2 ? true : false);
        })
    }, 0);
}

}

Listing 17-31.Selecting View Children in the productTable.component.ts File in the src/app Folder


有两个属性装饰器用于查询模板中定义的指令或组件,如表 17-6 所述。

表 17-6。

视图子查询属性装饰者

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

名字

 | 

描述

 |
| --- | --- |
| `@ViewChild(class)` | 这个装饰器告诉 Angular 查询指定类型的第一个指令或组件对象,并将其分配给属性。类名可以用模板变量替换。多个类或模板可以用逗号分隔。 |
| `@ViewChildren(class)` | 这个装饰器将指定类型的所有指令和组件对象分配给属性。可以用模板变量代替类,多个值之间用逗号分隔。结果在第十六章中描述的`QueryList`对象中提供。 |

在清单中,我使用了`@ViewChildren`装饰器从组件的模板中选择所有的`PaCellColor`对象。除了不同的属性装饰器,组件有两种不同的生命周期方法,用于提供关于模板如何被处理的信息,如表 17-7 所述。

表 17-7。

其他组件生命周期方法

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

名字

 | 

描述

 |
| --- | --- |
| `ngAfterViewInit` | 当组件的视图已经初始化时,调用此方法。视图查询的结果是在调用此方法之前设置的。 |
| `ngAfterViewChecked` | 在作为更改检测过程的一部分检查了组件视图之后,调用此方法。 |

在清单中,我实现了`ngAfterViewInit`方法来确保 Angular 已经处理了组件的模板并设置了查询结果。在方法中,我执行了对`updateViewChildren`方法的初始调用,该方法对`PaCellColor`对象进行操作,并使用`QueryList.changes`属性设置了当查询结果改变时将被调用的函数,如第十六章所述。如第十六章所述,子视图在对`setTimeout`函数的调用中被更新。结果是每隔一个表格单元格的颜色发生变化,如图 17-19 所示。

![img/421542_4_En_17_Fig19_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-frontend-framework-zh/raw/master/docs/pro-ng9/img/421542_4_En_17_Fig19_HTML.jpg)

图 17-19。

查询视图子级

Tip

如果您使用了`ng-content`元素,您可能需要组合视图子查询和内容子查询。模板中定义的内容使用清单 17-31 中显示的技术进行查询,但是项目内容——替换了`ng-content`元素——使用第十六章中描述的子查询进行查询。

## 摘要

在这一章中,我回顾了组件的主题,并解释了如何将指令的所有特性与提供它们自己的模板的能力结合起来。我解释了如何构建应用来创建小模块组件,以及组件如何使用输入和输出属性在它们之间进行协调。我还向您展示了组件如何定义只应用于其模板而不应用于应用其他部分的 CSS 样式。在下一章中,我将介绍管道,它用于准备在模板中显示的数据。

# 十八、使用和创建管道

管道是转换数据值的小段代码,因此它们可以在模板中显示给用户。管道允许在自包含的类中定义转换逻辑,这样就可以在整个应用中一致地应用它。表 18-1 将管道放在上下文中。

表 18-1。

将管道放在上下文中

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

问题

 | 

回答

 |
| --- | --- |
| 它们是什么? | 管道是用于准备向用户显示的数据的类。 |
| 它们为什么有用? | 管道允许在单个类中定义准备逻辑,该类可以在整个应用中使用,从而确保数据以一致的方式呈现。 |
| 它们是如何使用的? | `@Pipe` decorator 应用于一个类,用于指定一个名称,通过这个名称管道可以在模板中使用。 |
| 有什么陷阱或限制吗? | 管道应该简单,并专注于准备数据。让功能渗透到由其他构建块负责的领域,比如指令或组件,是很诱人的。 |
| 还有其他选择吗? | 您可以在组件或指令中实现数据准备代码,但是这使得在应用的其他部分重用变得更加困难。 |

表 18-2 总结了本章内容。

表 18-2。

章节总结

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"> <col class="tcol3 align-left"></colgroup> 
| 

问题

 | 

解决办法

 | 

列表

 |
| --- | --- | --- |
| 格式化包含在模板中的数据值 | 在数据绑定表达式中使用管道 | 1–6 |
| 创建自定义管道 | 将`@Pipe`装饰器应用于一个类 | 7–9 |
| 使用多个管道格式化数据值 | 使用竖线字符将管道名称连接在一起 | Ten |
| 指定 Angular 应何时重新评估管道的输出 | 使用`@Pipe`装饰器的`pure`属性 | 11–14 |
| 格式化数值 | 使用`number`管道 | 15, 16 |
| 格式化货币值 | 使用`currency`管道 | 17, 18 |
| 格式化百分比值 | 使用`percent`管道 | 19–22 |
| 更改字符串的大小写 | 使用`uppercase`或`lowercase`管 | 23, 24 |
| 将对象序列化为 JSON 格式 | 使用`json`管道 | Twenty-five |
| 从数组中选择元素 | 使用`slice`管道 | Twenty-six |
| 将对象或映射格式化为键/值对 | 使用`keyvalue`管道 | Twenty-seven |
| 选择要为字符串或数字值显示的值 | 使用`i18nSelect`或`i18nPlural`管 | 28–30 |

## 准备示例项目

我将继续使用在第十一章中首次创建的示例项目,该项目已经在以后的章节中进行了扩展和修改。在前一章的最后几个例子中,组件样式和视图子查询让应用看起来非常花哨,我将在本章中淡化这一点。在清单 18-1 中,我禁用了应用于表单元素的内嵌组件样式。

Tip

你可以从 [`https://github.com/Apress/pro-angular-9`](https://github.com/Apress/pro-angular-9) 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

```ts
import { Component, Output, EventEmitter, ViewEncapsulation } from "@angular/core";
import { Product } from "./product.model";

@Component({
    selector: "paProductForm",
    templateUrl: "productForm.component.html",
    // styleUrls: ["productForm.component.css"],
    // encapsulation: ViewEncapsulation.Emulated
})
export class ProductFormComponent {
    newProduct: Product = new Product();

    @Output("paNewProduct")
    newProductEvent = new EventEmitter<Product>();

    submitForm(form: any) {
        this.newProductEvent.emit(this.newProduct);
        this.newProduct = new Product();
        form.reset();
    }
}

Listing 18-1.Disabling CSS Styles in the productForm.component.ts File in the src/app Folder

为了禁用表格单元格的棋盘颜色,我更改了PaCellColor指令的选择器,使其匹配一个当前没有应用于 HTML 元素的属性,如清单 18-2 所示。

import { Directive, HostBinding } from "@angular/core";

@Directive({
    selector: "td[paApplyColor]"
})
export class PaCellColor {

    @HostBinding("class")
    bgClass: string = "";

    setColor(dark: Boolean) {
        this.bgClass = dark ? "bg-dark" : "";
    }
}

Listing 18-2.Changing the Selector in the cellColor.directive.ts File in the src/app Folder

清单 18-3 禁用了根组件定义的深度样式。

import { ApplicationRef, Component } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";
import { ProductFormGroup } from "./form.model";

@Component({
    selector: "app",
    templateUrl: "template.html",
    //styles: ["/deep/ div { border: 2px black solid;  font-style:italic }"]
})
export class ProductComponent {
    model: Model = new Model();

    addProduct(p: Product) {
        this.model.saveProduct(p);
    }
}

Listing 18-3.Disabling CSS Styles in the component.ts File in the src/app Folder

下一个变化是简化ProductTableComponent类,删除不再需要的方法和属性,并添加将在后面的示例中使用的新属性,如清单 18-4 所示。

import { Component, Input, ViewChildren, QueryList } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";

@Component({
    selector: "paProductTable",
    templateUrl: "productTable.component.html"
})
export class ProductTableComponent {

    @Input("model")
    dataModel: Model;

    getProduct(key: number): Product {
        return this.dataModel.getProduct(key);
    }

    getProducts(): Product[] {
        return this.dataModel.getProducts();
    }

    deleteProduct(key: number) {
        this.dataModel.deleteProduct(key);
    }

    taxRate: number = 0;
    categoryFilter: string;
    itemCount: number = 3;
}

Listing 18-4.Simplifying the Code in the productTable.component.ts File in the src/app Folder

最后,我从根组件的模板中移除了一个组件元素,以禁用显示和隐藏表格的复选框,如清单 18-5 所示。

<div class="row m-2">
  <div class="col-4 p-2">
    <paProductForm (paNewProduct)="addProduct($event)"></paProductForm>
  </div>
  <div class="col-8 p-2">
    <paProductTable [model]="model"></paProductTable>
  </div>
</div>

Listing 18-5.Simplifying the Elements in the template.html File in the src/app Folder

example文件夹中运行以下命令,启动 Angular 开发工具:

ng serve

打开一个新的浏览器选项卡并导航至http://localhost:4200以查看如图 18-1 所示的内容。

img/421542_4_En_18_Fig1_HTML.jpg

图 18-1。

运行示例应用

了解管道

管道是在指令或组件接收数据之前转换数据的类。这听起来可能不像是一项重要的工作,但是管道可以用来轻松、一致地执行一些最常见的开发任务。

作为一个演示如何使用管道的简单例子,清单 18-6 应用了一个内置管道来转换应用显示的表的Price列中的值。

<table class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th><th></th></tr>
    <tr *paFor="let item of getProducts(); let i = index; let odd = odd;
            let even = even" [class.bg-info]="odd" [class.bg-warning]="even">
        <td style="vertical-align:middle">{{i + 1}}</td>
        <td style="vertical-align:middle">{{item.name}}</td>
        <td style="vertical-align:middle">{{item.category}}</td>
        <td style="vertical-align:middle">
            {{item.price | currency:"USD":"symbol" }}
        </td>
        <td class="text-center">
            <button class="btn btn-danger btn-sm" (click)="deleteProduct(item.id)">
                Delete
            </button>
        </td>
    </tr>
</table>

Listing 18-6.Using a Pipe in the productTable.component.html File in the src/app Folder

应用管道的语法类似于命令提示符使用的样式,其中使用竖线符号(|字符)将值“管道化”以进行转换。图 18-2 显示了包含管道的数据绑定的结构。

img/421542_4_En_18_Fig2_HTML.jpg

图 18-2。

用管道进行数据绑定的剖析

清单 18-6 中使用的管道名称是currency,它将数字格式化为货币值。管道的参数由冒号(:字符)分隔。第一个管道参数指定应该使用的货币代码,在本例中是USD,代表美元。第二个管道参数是symbol,它指定是否应该显示货币符号,而不是它的代码。

当 Angular 处理表达式时,它获取数据值并将其传递给管道进行转换。管道产生的结果然后被用作数据绑定的表达式结果。在本例中,绑定是字符串插值,图 18-3 显示了结果。

img/421542_4_En_18_Fig3_HTML.jpg

图 18-3。

使用货币管道的效果

创建自定义管道

我将在本章的后面回到 Angular 提供的内置管道,但是理解管道如何工作以及它们能够做什么的最好方法是创建一个自定义管道。我在src/app文件夹中添加了一个名为addTax.pipe.ts的文件,并定义了清单 18-7 中所示的类。

import { Pipe } from "@angular/core";

@Pipe({
    name: "addTax"
})
export class PaAddTaxPipe {

    defaultRate: number = 10;

    transform(value: any, rate?: any): number {
        let valueNumber = Number.parseFloat(value);
        let rateNumber = rate == undefined ?
            this.defaultRate : Number.parseInt(rate);
        return valueNumber + (valueNumber * (rateNumber / 100));
    }
}

Listing 18-7.The Contents of the addTax.pipe.ts File in the src/app Folder

管道是应用了@Pipe装饰器的类,它实现了一个叫做transform的方法。@Pipe装饰器定义了两个属性,用于配置管道,如表 18-3 所述。

表 18-3。

@Pipe 装饰器属性

|

名字

|

描述

|
| --- | --- |
| name | 此属性指定在模板中应用管道的名称。 |
| pure | 当true时,仅当其输入值或参数改变时,该管道才被重新评估。这是默认值。请参见“创建不纯管道”部分了解详细信息。 |

示例管道在一个名为PaAddTaxPipe的类中定义,其装饰器name属性指定管道将使用模板中的addTax来应用。transform方法必须接受至少一个参数,Angular 用它来提供管道格式化的数据值。管道在transform方法中工作,其结果由 Angular 在绑定表达式中使用。在本例中,transform方法接受一个数字值,其结果是收到的值加上销售税。

转换方法还可以定义用于配置管道的附加参数。在示例中,可选的rate参数可用于指定销售税率,默认为 10%。

Caution

在处理由transform方法接收的参数时要小心,并确保将它们解析或转换成您需要的类型。运行时不强制执行 TypeScript 类型注释,Angular 将传递给你它正在处理的任何数据值。

注册自定义管道

管道使用 Angular 模块的declarations属性注册,如清单 18-8 所示。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule  } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";
import { PaStructureDirective } from "./structure.directive";
import { PaIteratorDirective } from "./iterator.directive";
import { PaCellColor } from "./cellColor.directive";
import { PaCellColorSwitcher } from "./cellColorSwitcher.directive";
import { ProductTableComponent } from "./productTable.component";
import { ProductFormComponent } from "./productForm.component";
import { PaToggleView } from "./toggleView.component";
import { PaAddTaxPipe } from "./addTax.pipe";

@NgModule({
    imports: [BrowserModule, FormsModule, ReactiveFormsModule],
    declarations: [ProductComponent, PaAttrDirective, PaModel,
        PaStructureDirective, PaIteratorDirective,
        PaCellColor, PaCellColorSwitcher, ProductTableComponent,
        ProductFormComponent, PaToggleView, PaAddTaxPipe],
    bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 18-8.Registering a Custom Pipe in the app.module.ts File in the src/app Folder

应用自定义管道

一旦注册了自定义管道,就可以在数据绑定表达式中使用它。在清单 18-9 中,我将管道应用于表格中的price值,并添加了一个select元素,允许指定税率。

<div>
    <label>Tax Rate:</label>
    <select [value]="taxRate || 0" (change)="taxRate=$event.target.value">
        <option value="0">None</option>
        <option value="10">10%</option>
        <option value="20">20%</option>
        <option value="50">50%</option>
    </select>
</div>

<table class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th><th></th></tr>
    <tr *paFor="let item of getProducts(); let i = index; let odd = odd;
            let even = even" [class.bg-info]="odd" [class.bg-warning]="even">
        <td style="vertical-align:middle">{{i + 1}}</td>
        <td style="vertical-align:middle">{{item.name}}</td>
        <td style="vertical-align:middle">{{item.category}}</td>
        <td style="vertical-align:middle">
            {{item.price | addTax:(taxRate || 0) }}
        </td>
        <td class="text-center">
            <button class="btn btn-danger btn-sm" (click)="deleteProduct(item.id)">
                Delete
            </button>
        </td>
    </tr>
</table>

Listing 18-9.Applying the Custom Pipe in the productTable.component.html File in the src/app Folder

为了方便起见,我完全在模板中定义了税率。select元素有一个绑定,将它的value属性设置为一个名为taxRate的组件变量,或者如果属性没有被定义,则默认为0event绑定处理change事件并设置taxRate属性的值。当使用ngModel指令时,您不能指定一个回退值,这就是我拆分绑定的原因。

在应用定制管道时,我使用了竖线字符,后跟管道装饰器中的name属性指定的值。管道的名称后面跟一个冒号,冒号后面跟一个表达式,表达式的计算结果是为管道提供它的参数。在这种情况下,如果已经定义了taxRate属性,将使用该属性,回退值为零。

管道是 Angular 数据绑定的动态特性的一部分,如果底层数据值发生变化或者用于参数的表达式发生变化,将调用管道的transform方法来获取更新的值。通过改变 select 元素显示的值可以看出管道的动态性质,这将定义或改变taxRate属性,这将依次更新自定义管道添加到price属性的数量,如图 18-4 所示。

img/421542_4_En_18_Fig4_HTML.jpg

图 18-4。

使用自定义管道

组合管道

addTax管道应用税率,但是由计算产生的小数金额是难看的——并且没有帮助,因为很少有税务当局坚持精确到 15 个小数位数。

我可以通过添加对自定义管道的支持来将数字值格式化为货币来解决这个问题,但是这需要复制我在本章前面使用的内置currency管道的功能。一个更好的方法是将两个管道的功能结合起来,这样来自定制addTax管道的输出就被输入到内置的currency管道中,然后用于产生显示给用户的值。

管道以这种方式使用竖线字符链接在一起,管道的名称按照数据流动的顺序指定,如清单 18-10 所示。

...
<td style="vertical-align:middle">
    {{item.price | addTax:(taxRate || 0) | currency:"USD":"symbol"  }}
</td>
...

Listing 18-10.Combining Pipes in the productTable.component.html File in the src/app Folder

item.price属性的值被传递给addTax管道,它添加销售税,然后传递给currency管道,它将数字值格式化为货币金额,如图 18-5 所示。

img/421542_4_En_18_Fig5_HTML.jpg

图 18-5。

结合管道的功能

制造不纯的管道

pure decorator 属性用于告诉 Angular 何时调用管道的transform方法。pure属性的缺省值是true,它告诉 Angular,只有当输入数据值(模板中竖线字符之前的数据值)改变或者当它的一个或多个参数被修改时,管道的转换方法才会生成一个新值。这被称为管道,因为它没有独立的内部状态,并且它的所有依赖关系都可以使用 Angular 变化检测过程来管理。

pure装饰器属性设置为false会创建一个不纯管道,并告知 Angular 该管道有其自己的状态数据,或者当有新值时,该管道依赖于在变化检测过程中可能不会被拾取的数据。

当 Angular 执行其变化检测过程时,它将不纯管道视为数据值的来源,并调用transform方法,即使没有数据值或参数变化。

不纯管道最常见的需求是当它们处理数组的内容并且数组中的元素发生变化时。正如您在第十六章中看到的,Angular 不会自动检测数组中发生的变化,也不会在添加、编辑或删除数组元素时调用 pure pipe 的 transform 方法,因为它只看到相同的数组对象被用作输入数据值。

Caution

不纯管道应该谨慎使用,因为 Angular 必须在应用中有任何数据更改或用户交互时调用transform方法,以防它可能导致与pipe不同的结果。如果你创建了一个不纯的管道,那么尽可能保持简单。执行复杂的操作,比如对数组进行排序,会严重影响 Angular 应用的性能。

作为演示,我在src/app文件夹中添加了一个名为categoryFilter.pipe.ts的文件,并用它来定义清单 18-11 中所示的管道。

import { Pipe } from "@angular/core";
import { Product } from "./product.model";

@Pipe({
    name: "filter",
    pure: true
})
export class PaCategoryFilterPipe {

    transform(products: Product[], category: string): Product[] {
        return category == undefined ?
            products : products.filter(p => p.category == category);
    }
}

Listing 18-11.The Contents of the categoryFilter.pipe.ts File in the src/app Folder

这是一个纯粹的过滤器,接收一组Product对象,只返回那些category属性与category参数匹配的对象。清单 18-12 显示了在 Angular 模块中注册的新管道。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule  } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";
import { PaStructureDirective } from "./structure.directive";
import { PaIteratorDirective } from "./iterator.directive";
import { PaCellColor } from "./cellColor.directive";
import { PaCellColorSwitcher } from "./cellColorSwitcher.directive";
import { ProductTableComponent } from "./productTable.component";
import { ProductFormComponent } from "./productForm.component";
import { PaToggleView } from "./toggleView.component";
import { PaAddTaxPipe } from "./addTax.pipe";
import { PaCategoryFilterPipe } from "./categoryFilter.pipe";

@NgModule({
    imports: [BrowserModule, FormsModule, ReactiveFormsModule],
    declarations: [ProductComponent, PaAttrDirective, PaModel,
        PaStructureDirective, PaIteratorDirective,
        PaCellColor, PaCellColorSwitcher, ProductTableComponent,
        ProductFormComponent, PaToggleView, PaAddTaxPipe,
        PaCategoryFilterPipe],
    bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 18-12.Registering a Pipe in the app.module.ts File in the src/app Folder

清单 18-13 显示了新管道在绑定表达式中的应用,该绑定表达式以ngFor指令以及允许选择过滤器类别的新select元素为目标。

<div>
    <label>Tax Rate:</label>
    <select [value]="taxRate || 0" (change)="taxRate=$event.target.value">
        <option value="0">None</option>
        <option value="10">10%</option>
        <option value="20">20%</option>
        <option value="50">50%</option>
    </select>
</div>

<div>
    <label>Category Filter:</label>
    <select [(ngModel)]="categoryFilter">
        <option>Watersports</option>
        <option>Soccer</option>
        <option>Chess</option>
    </select>
</div>

<table class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th><th></th></tr>
    <tr *paFor="let item of getProducts() | filter:categoryFilter;
            let i = index; let odd = odd; let even = even"
            [class.bg-info]="odd" [class.bg-warning]="even">
        <td style="vertical-align:middle">{{i + 1}}</td>
        <td style="vertical-align:middle">{{item.name}}</td>
        <td style="vertical-align:middle">{{item.category}}</td>
        <td style="vertical-align:middle">
            {{item.price | addTax:(taxRate || 0) | currency:"USD":"symbol"  }}
        </td>
        <td class="text-center">
            <button class="btn btn-danger btn-sm" (click)="deleteProduct(item.id)">
                Delete
            </button>
        </td>
    </tr>
</table>

Listing 18-13.Applying a Pipe in the productTable.component.html File in the src/app Folder

要查看问题,使用select元素过滤表中的产品,以便只显示那些在Soccer类别中的产品。然后使用表单元素在该类别中创建一个新产品。点击创建按钮会将产品添加到数据模型中,但是新产品不会显示在表格中,如图 18-6 所示。

img/421542_4_En_18_Fig6_HTML.jpg

图 18-6。

纯管道引起的问题

该表没有更新,因为就 Angular 而言,filter管道的任何输入都没有改变。组件的getProducts方法返回相同的数组对象,并且categoryFilter属性仍然设置为Soccer。Angular 没有意识到在由getProducts方法返回的数组中有一个新的对象。

解决方法是将管道的pure属性设置为false,如清单 18-14 所示。

import { Pipe } from "@angular/core";
import { Product } from "./product.model";

@Pipe({
    name: "filter",
    pure: false
})
export class PaCategoryFilterPipe {

    transform(products: Product[], category: string): Product[] {
        return category == undefined ?
            products : products.filter(p => p.category == category);
    }
}

Listing 18-14.Marking a Pipe as Impure in the categoryFilter.pipe.ts File in the src/app Folder

如果重复测试,您将看到新产品现在正确显示在表格中,如图 18-7 所示。

img/421542_4_En_18_Fig7_HTML.jpg

图 18-7。

使用不纯的管子

使用内置管道

Angular 包括一组内置管道,用于执行通常需要的任务。这些管道在表 18-4 中描述,并在以下章节中演示。

表 18-4。

内置管道

|

名字

|

描述

|
| --- | --- |
| number | 这个管道执行数字值的区分位置的格式化。有关详细信息,请参见“格式化数字”一节。 |
| currency | 这个管道对货币金额执行区分位置的格式化。有关详细信息,请参见“格式化货币值”一节。 |
| percent | 这个管道执行百分比值的区分位置的格式化。有关详细信息,请参见“格式化百分比”一节。 |
| date | 这个管道执行日期的区分位置的格式化。有关详细信息,请参见“格式化日期”一节。 |
| uppercase | 这个管道将字符串中的所有字符转换为大写。有关详细信息,请参见“更改字符串大小写”一节。 |
| Lowercase | 这个管道将字符串中的所有字符转换成小写。有关详细信息,请参见“更改字符串大小写”一节。 |
| titlecase | 这个管道将字符串中的所有字符转换成大小写。有关详细信息,请参见“更改字符串大小写”一节。 |
| json | 这个管道将一个对象转换成一个 JSON 字符串。有关详细信息,请参见“将数据序列化为 JSON”一节。 |
| slice | 这个管道从数组中选择项或从字符串中选择字符,如“数据数组切片”一节所述。 |
| keyvalue | 这个管道将一个对象或映射转换成一系列的键/值对,如“格式化键/值对”一节所述。 |
| i18nSelect | 这个管道为一组值选择要显示的文本值,如“选择值”一节中所述。 |
| i18nPlural | 这个管道为一个值选择一个多元字符串,如“多元值”一节所述。 |
| async | 这个管道订阅一个可观察值或一个承诺,并显示它产生的最新值。该管道在第二十三章中演示。 |

格式化数字

number管道使用区域敏感规则格式化number值。清单 18-15 展示了number管道的使用,以及指定将要使用的格式的参数。我已经从模板中移除了定制管道和相关的select元素。

<table class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th><th></th></tr>
    <tr *paFor="let item of getProducts(); let i = index; let odd = odd;
            let even = even" [class.bg-info]="odd" [class.bg-warning]="even">
        <td style="vertical-align:middle">{{i + 1}}</td>
        <td style="vertical-align:middle">{{item.name}}</td>
        <td style="vertical-align:middle">{{item.category}}</td>
        <td style="vertical-align:middle">{{item.price | number:"3.2-2" }}</td>
        <td class="text-center">
            <button class="btn btn-danger btn-sm" (click)="deleteProduct(item.id)">
                Delete
            </button>
        </td>
    </tr>
</table>

Listing 18-15.Using the number Pipe in the productTable.component.html File in the src/app Folder

number管道接受一个参数,该参数指定格式化结果中包含的位数。该参数采用以下格式(请注意分隔这些值的句点和连字符,并且整个参数以字符串形式引用):

"<minIntegerDigits>.<minFactionDigits>-<maxFractionDigits>"

表 18-5 描述了格式参数的每个元素。

表 18-5。

数字管道参数的元素

|

名字

|

描述

|
| --- | --- |
| minIntegerDigits | 该值指定最小位数。默认值为 1。 |
| minFractionDigits | 该值指定小数位数的最小值。默认值为 0。 |
| maxFractionDigits | 该值指定小数位数的最大值。默认值为 3。 |

清单中使用的参数是"3.2-2",它指定至少应该使用三位数字来显示数字的整数部分,并且应该始终使用两位小数。这产生了如图 18-8 所示的结果。

img/421542_4_En_18_Fig8_HTML.jpg

图 18-8。

格式化数字值

number管道是位置敏感的,这意味着相同的格式参数将根据用户的区域设置产生不同格式的结果。Angular 应用默认使用en-US语言环境,并要求显式加载其他语言环境,如清单 18-16 所示。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";
import { PaStructureDirective } from "./structure.directive";
import { PaIteratorDirective } from "./iterator.directive";
import { PaCellColor } from "./cellColor.directive";
import { PaCellColorSwitcher } from "./cellColorSwitcher.directive";
import { ProductTableComponent } from "./productTable.component";
import { ProductFormComponent } from "./productForm.component";
import { PaToggleView } from "./toggleView.component";
import { PaAddTaxPipe } from "./addTax.pipe";
import { PaCategoryFilterPipe } from "./categoryFilter.pipe";
import { LOCALE_ID } from "@angular/core";
import localeFr from '@angular/common/locales/fr';
import { registerLocaleData } from '@angular/common';

registerLocaleData(localeFr);

@NgModule({
    imports: [BrowserModule, FormsModule, ReactiveFormsModule],
    declarations: [ProductComponent, PaAttrDirective, PaModel,
        PaStructureDirective, PaIteratorDirective,
        PaCellColor, PaCellColorSwitcher, ProductTableComponent,
        ProductFormComponent, PaToggleView, PaAddTaxPipe,
        PaCategoryFilterPipe],
    providers: [{ provide: LOCALE_ID, useValue: "fr-FR" }],
    bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 18-16.Setting the Locale in the app.module.ts File in the src/app Folder

设置语言环境需要从包含每个地区数据的模块中导入所需的语言环境,并通过调用从@angular/common模块中导入的registerLocaleData函数来注册它。在清单中,我已经导入了fr-FR地区,这是针对法语的,因为在法国人们说法语。最后一步是配置providers属性,我在第二十章中描述过,但是清单 18-16 中配置的效果是启用fr-FR区域设置,这改变了数值的格式,如图 18-9 所示。

img/421542_4_En_18_Fig9_HTML.jpg

图 18-9。

区分区域设置的格式

格式化货币值

currency管道格式化代表货币数量的number值。清单 18-6 使用这个管道引入主题,清单 18-17 显示了相同管道的另一个应用,但是增加了数字格式说明符。

<table class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th><th></th></tr>
    <tr *paFor="let item of getProducts(); let i = index; let odd = odd;
            let even = even" [class.bg-info]="odd" [class.bg-warning]="even">
        <td style="vertical-align:middle">{{i + 1}}</td>
        <td style="vertical-align:middle">{{item.name}}</td>
        <td style="vertical-align:middle">{{item.category}}</td>
        <td style="vertical-align:middle">
            {{item.price | currency:"USD":"symbol":"2.2-2" }}
        </td>
        <td class="text-center">
            <button class="btn btn-danger btn-sm" (click)="deleteProduct(item.id)">
                Delete
            </button>
        </td>
    </tr>
</table>

Listing 18-17.Using the currency Pipe in the productTable.component.html File in the src/app Folder

可以使用四个参数配置货币管道,如表 18-6 所述。

表 18-6。

Web 窗体代码块的类型

|

名字

|

描述

|
| --- | --- |
| currencyCode | 此字符串参数使用 ISO 4217 代码指定货币。如果省略该参数,默认值为USD。在 http://en.wikipedia.org/wiki/ISO_4217 可以看到货币代码列表。 |
| display | 该字符串指示是否应显示货币符号或代码。支持的值有code(使用货币代码)symbol(使用货币符号)symbol-narrow(显示货币有窄宽符号时的简洁形式)。默认值为symbol。 |
| digitInfo | 该字符串参数指定了数字的格式,使用了与number管道支持的相同的格式指令,如“格式化数字”一节所述。 |
| locale | 此字符串参数指定货币的区域设置。这默认为LOCALE_ID值,其配置如清单 18-16 所示。 |

清单 18-17 中指定的参数告诉管道使用美元作为货币(它有 ISO 代码USD),在输出中显示符号而不是代码,并格式化数字,使其至少有两位整数和两位小数。

这个管道依赖国际化 API 来获取货币的详细信息——尤其是它的符号——但是不会自动选择货币来反映用户的区域设置。

这意味着数字的格式和货币符号的位置受应用的区域设置影响,而不管管道指定的货币是什么。示例应用仍然被配置为使用fr-FR语言环境,它产生如图 18-10 所示的结果。

img/421542_4_En_18_Fig10_HTML.jpg

图 18-10。

区分位置的货币格式

为了恢复到默认的语言环境,清单 18-18 从应用的根模块中删除了fr-FR设置。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";
import { PaStructureDirective } from "./structure.directive";
import { PaIteratorDirective } from "./iterator.directive";
import { PaCellColor } from "./cellColor.directive";
import { PaCellColorSwitcher } from "./cellColorSwitcher.directive";
import { ProductTableComponent } from "./productTable.component";
import { ProductFormComponent } from "./productForm.component";
import { PaToggleView } from "./toggleView.component";
import { PaAddTaxPipe } from "./addTax.pipe";
import { PaCategoryFilterPipe } from "./categoryFilter.pipe";
import { LOCALE_ID } from "@angular/core";
import localeFr from '@angular/common/locales/fr';
import { registerLocaleData } from '@angular/common';

registerLocaleData(localeFr);

@NgModule({
  imports: [BrowserModule, FormsModule, ReactiveFormsModule],
  declarations: [ProductComponent, PaAttrDirective, PaModel,
    PaStructureDirective, PaIteratorDirective,
    PaCellColor, PaCellColorSwitcher, ProductTableComponent,
    ProductFormComponent, PaToggleView, PaAddTaxPipe,
    PaCategoryFilterPipe],
  //providers: [{ provide: LOCALE_ID, useValue: "fr-FR" }],
  bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 18-18.Removing the locale Setting in the app.module.ts File in the src/app Folder

图 18-11 显示了结果。

img/421542_4_En_18_Fig11_HTML.jpg

图 18-11。

格式化货币值

格式化百分比

percent管道将number值格式化为百分比,其中 0 到 1 之间的值格式化为 0 到 100%。这个管道有可选参数,用于指定数字格式选项,使用与number管道相同的格式,并覆盖默认的区域设置。清单 18-19 重新引入了定制销售税过滤器,并用内容被百分比过滤器格式化的option元素填充关联的select元素。

<div>
    <label>Tax Rate:</label>
    <select [value]="taxRate || 0" (change)="taxRate=$event.target.value">
        <option value="0">None</option>
        <option value="10">{{ 0.1 | percent }}</option>
        <option value="20">{{ 0.2 | percent }}</option>
        <option value="50">{{ 0.5 | percent }}</option>
        <option value="150">{{ 1.5 | percent }}</option>
    </select>
</div>

<table class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th><th></th></tr>
    <tr *paFor="let item of getProducts(); let i = index; let odd = odd;
            let even = even" [class.bg-info]="odd" [class.bg-warning]="even">
        <td style="vertical-align:middle">{{i + 1}}</td>
        <td style="vertical-align:middle">{{item.name}}</td>
        <td style="vertical-align:middle">{{item.category}}</td>
        <td style="vertical-align:middle">
            {{item.price | addTax:(taxRate || 0) | currency:"USD":"symbol":"2.2-2" }}
        </td>
        <td class="text-center">
            <button class="btn btn-danger btn-sm" (click)="deleteProduct(item.id)">
                Delete
            </button>
        </td>
    </tr>
</table>

Listing 18-19.Formatting Percentages in the productTable.component.html File in the src/app Folder

大于 1 的值被格式化为大于 100%的百分比。你可以在图 18-12 所示的最后一项中看到这一点,其中值 1.5 产生 150%的格式化值。

img/421542_4_En_18_Fig12_HTML.jpg

图 18-12。

格式化百分比值

百分比值的格式是区分位置的,尽管不同地区之间的差异可能很细微。例如,虽然en-US语言环境会产生 10%这样的结果,数字和百分号相邻,但包括fr-FR在内的许多语言环境会产生10 %这样的结果,数字和百分号之间有一个空格。

格式化日期

date管道执行日期的位置敏感格式化。日期可以用 JavaScript Date对象来表示,比如一个代表自 1970 年以来的毫秒数的number值,或者一个格式良好的字符串。清单 18-20 向ProductTableComponent类添加了三个属性,每个属性都以date管道支持的格式之一对日期进行编码。

import { Component, Input, ViewChildren, QueryList } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";

@Component({
    selector: "paProductTable",
    templateUrl: "productTable.component.html"
})
export class ProductTableComponent {

    @Input("model")
    dataModel: Model;

    getProduct(key: number): Product {
        return this.dataModel.getProduct(key);
    }

    getProducts(): Product[] {
        return this.dataModel.getProducts();
    }

    deleteProduct(key: number) {
        this.dataModel.deleteProduct(key);
    }

    taxRate: number = 0;
    categoryFilter: string;
    itemCount: number = 3;

    dateObject: Date = new Date(2020, 1, 20);
    dateString: string = "2020-02-20T00:00:00.000Z";
    dateNumber: number = 1582156800000;
}

Listing 18-20.Defining Dates in the productTable.component.ts File in the src/app Folder

这三个属性描述了同一个日期,即 2020 年 2 月 20 日,但没有指定时间。在清单 18-21 中,我使用了date管道来格式化所有三个属性。

<div class="bg-info p-2 text-white">
    <div>Date formatted from object: {{ dateObject | date }}</div>
    <div>Date formatted from string: {{ dateString | date }}</div>
    <div>Date formatted from number: {{ dateNumber | date }}</div>
</div>

<table class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th><th></th></tr>
    <tr *paFor="let item of getProducts(); let i = index; let odd = odd;
            let even = even" [class.bg-info]="odd" [class.bg-warning]="even">
        <td style="vertical-align:middle">{{i + 1}}</td>
        <td style="vertical-align:middle">{{item.name}}</td>
        <td style="vertical-align:middle">{{item.category}}</td>
        <td style="vertical-align:middle">
            {{item.price | addTax:(taxRate || 0) | currency:"USD":"symbol":"2.2-2" }}
        </td>
        <td class="text-center">
            <button class="btn btn-danger btn-sm" (click)="deleteProduct(item.id)">
                Delete
            </button>
        </td>
    </tr>
</table>

Listing 18-21.Formatting Dates in the productTable.component.html File in the src/app Folder

管道计算出它正在处理的数据类型,解析该值以获得日期,然后格式化它,如图 18-13 所示。

img/421542_4_En_18_Fig13_HTML.jpg

图 18-13。

格式化日期

date管道接受一个指定应该使用的日期格式的参数。可以使用表 18-7 中描述的符号选择输出的单个日期部分。

表 18-7。

日期管道格式符号

|

名字

|

描述

|
| --- | --- |
| yyy | 这些符号选择年份。 |
| MMMMMMMM | 这些符号选择月份。 |
| ddd | 这些符号选择日期(作为一个数字)。 |
| EEEEEEE | 这些符号选择日期(作为名称)。 |
| jjj | 这些符号选择小时。 |
| hhhHHH | 这些符号以 12 小时和 24 小时形式选择小时。 |
| mmm | 这些符号选择分钟。 |
| sss | 这些符号选择秒。 |
| Z | 此符号选择时区。 |

表 18-7 中的符号以不同的简洁程度提供了对日期组件的访问,因此如果月份是二月,M将返回2MM将返回02MMM将返回Feb,而MMMM将返回February,假设您使用的是en-US区域设置。date管道还支持常用组合的预定义日期格式,如表 18-8 所述。

表 18-8。

预定义的日期管道格式

|

名字

|

描述

|
| --- | --- |
| short | 这种格式相当于组件字符串yMdjm。它以简洁的格式显示日期,包括时间部分。 |
| medium | 这种格式相当于组件字符串yMMMdjms。它以更广泛的格式显示日期,包括时间部分。 |
| shortDate | 这种格式相当于组件字符串yMd。它以简洁的格式显示日期,不包括时间部分。 |
| mediumDate | 这种格式相当于组件字符串yMMMd。它以更广泛的格式显示日期,并排除了时间部分。 |
| longDate | 这种格式相当于组件字符串yMMMMd。它显示日期,但不包括时间部分。 |
| fullDate | 这种格式相当于组件字符串yMMMMEEEEd。它完全显示日期,但不包含日期格式。 |
| shortTime | 这种格式相当于组件字符串jm。 |
| mediumTime | 这种格式相当于组件字符串jms。 |

date管道还接受一个指定时区的参数和一个可用于覆盖区域设置的参数。清单 18-22 显示了使用预定义格式作为date管道的参数,以不同的方式呈现相同的日期。

...
<div class="bg-info p-2 text-white">
  <div>Date formatted as shortDate: {{ dateObject | date:"shortDate" }}</div>
  <div>Date formatted as mediumDate: {{ dateObject | date:"mediumDate" }}</div>
  <div>Date formatted as longDate: {{ dateObject | date:"longDate" }}</div>
</div>
...

Listing 18-22.Formatting Dates in the productTable.component.html File in the src/app Folder

格式化参数被指定为文字字符串。注意格式字符串的大小写要正确,因为shortDate将被解释为表 18-8 中的预定义格式之一,但是shortdate(带有小写字母d)将被解释为表 18-7 中的一系列字符并产生无意义的输出。

Caution

日期解析/格式化是一个复杂而耗时的过程。因此,date管道的pure属性是true;因此,对Date对象的单个组件的更改不会触发更新。如果您需要反映日期显示方式的变化,那么您必须更改包含date管道的绑定所引用的Date对象。

日期格式是区分位置的,这意味着您将收到不同地区的不同组件。不要假设在一个地区有意义的日期格式在另一个地区会有任何意义。图 18-14 显示了en-USfr-FR地区的格式化日期。

img/421542_4_En_18_Fig14_HTML.jpg

图 18-14。

区分位置的日期格式

更改字符串大小写

uppercaselowercasetitlecase管道分别将字符串中的所有字符转换成大写或小写。清单 18-23 显示了应用于产品表中单元格的前两个管道。

<table class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th><th></th></tr>
    <tr *paFor="let item of getProducts(); let i = index; let odd = odd;
            let even = even" [class.bg-info]="odd" [class.bg-warning]="even">
        <td style="vertical-align:middle">{{i + 1}}</td>
        <td style="vertical-align:middle">{{item.name | uppercase }}</td>
        <td style="vertical-align:middle">{{item.category | lowercase }}</td>
        <td style="vertical-align:middle">
            {{item.price | addTax:(taxRate || 0) | currency:"USD":"symbol":"2.2-2" }}
        </td>
        <td class="text-center">
            <button class="btn btn-danger btn-sm" (click)="deleteProduct(item.id)">
                Delete
            </button>
        </td>
    </tr>
</table>

Listing 18-23.Changing Character Case in the productTable.component.html File in the src/app Folder

这些管道使用标准的 JavaScript 字符串方法toUpperCasetoLowerCase,对区域设置不敏感,如图 18-15 所示。

img/421542_4_En_18_Fig15_HTML.jpg

图 18-15。

改变字符大小写

titlecase管道将每个单词的第一个字符大写,其余字符使用小写。清单 18-24 将titlecase管道应用于表格单元格。

<table class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th><th></th></tr>
    <tr *paFor="let item of getProducts(); let i = index; let odd = odd;
            let even = even" [class.bg-info]="odd" [class.bg-warning]="even">
        <td style="vertical-align:middle">{{i + 1}}</td>
        <td style="vertical-align:middle">{{item.name | titlecase }}</td>
        <td style="vertical-align:middle">{{item.category | lowercase }}</td>
        <td style="vertical-align:middle">
            {{item.price | addTax:(taxRate || 0) | currency:"USD":"symbol":"2.2-2" }}
        </td>
        <td class="text-center">
            <button class="btn btn-danger btn-sm" (click)="deleteProduct(item.id)">
                Delete
            </button>
        </td>
    </tr>
</table>

Listing 18-24.Applying the Pipe in the productTable.component.html File in the src/app Folder

图 18-16 显示了管道的效果。

img/421542_4_En_18_Fig16_HTML.jpg

图 18-16。

使用标题盒管道

将数据序列化为 JSON

json管道创建一个数据值的 JSON 表示。这个管道不接受任何参数,它使用浏览器的JSON.stringify方法来创建 JSON 字符串。清单 18-25 应用这个管道来创建数据模型中对象的 JSON 表示。

<div class="bg-info p-2 text-white">
    <div>{{ getProducts() | json }}</div>
</div>

<table class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th><th></th></tr>
    <tr *paFor="let item of getProducts(); let i = index; let odd = odd;
            let even = even" [class.bg-info]="odd" [class.bg-warning]="even">
        <td style="vertical-align:middle">{{i + 1}}</td>
        <td style="vertical-align:middle">{{item.name | titlecase }}</td>
        <td style="vertical-align:middle">{{item.category | lowercase }}</td>
        <td style="vertical-align:middle">
            {{item.price | addTax:(taxRate || 0) | currency:"USD":"symbol":"2.2-2" }}
        </td>
        <td class="text-center">
            <button class="btn btn-danger btn-sm" (click)="deleteProduct(item.id)">
                Delete
            </button>
        </td>
    </tr>
</table>

Listing 18-25.Creating a JSON String in the productTable.component.html File in the src/app Folder

这个管道在调试过程中很有用,它的 decorator 的pure属性是false,因此应用中的任何更改都会导致管道的transform方法被调用,从而确保显示甚至是集合级别的更改。图 18-17 显示了从示例应用的数据模型中的对象生成的 JSON。

img/421542_4_En_18_Fig17_HTML.jpg

图 18-17。

生成用于调试的 JSON 字符串

切片数据数组

slice管道对数组或字符串进行操作,并返回它所包含的元素或字符的子集。这是一个不纯的管道,这意味着它将反映它所操作的数据对象中发生的任何变化,但也意味着切片操作将在应用中的任何变化之后执行,即使该变化与源数据无关。

slice管道选择的对象或角色由两个参数指定,如表 18-9 所述。

表 18-9。

切片管道参数

|

名字

|

描述

|
| --- | --- |
| start | 必须指定此参数。如果该值为正数,则结果中包含的项的起始索引从数组中的第一个位置开始计数。如果该值为负,则管道从数组末尾开始倒数。 |
| end | 该可选参数用于指定结果中应包含多少来自start索引的项目。如果省略该值,将包括所有在start索引之后(或在负值情况下之前)的项目。 |

清单 18-26 展示了slice管道与select元素的结合使用,后者指定了产品表中应该显示多少个项目。

<div>
    <label>Number of items:</label>
    <select [value]="itemCount || 1" (change)="itemCount=$event.target.value">
        <option *ngFor="let item of getProducts(); let i = index" [value]="i + 1"
                [selected]="(i + 1) === itemCount">
            {{i + 1}}
        </option>
    </select>
</div>

<table class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th><th></th></tr>
    <tr *paFor="let item of getProducts() | slice:0:(itemCount || 1);
            let i = index; let odd = odd; let even = even"
            [class.bg-info]="odd" [class.bg-warning]="even">
        <td style="vertical-align:middle">{{i + 1}}</td>
        <td style="vertical-align:middle">{{item.name | titlecase }}</td>
        <td style="vertical-align:middle">{{item.category | lowercase }}</td>
        <td style="vertical-align:middle">
            {{item.price | addTax:(taxRate || 0) | currency:"USD":"symbol":"2.2-2" }}
        </td>
        <td class="text-center">
            <button class="btn btn-danger btn-sm" (click)="deleteProduct(item.id)">
                Delete
            </button>
        </td>
    </tr>
</table>

Listing 18-26.Using the slice Pipe in the productTable.component.html File in the src/app Folder

用通过ngFor指令创建的option元素填充select元素。这个指令不直接支持特定次数的迭代,所以我使用了index变量来生成所需的值。select元素设置了一个名为itemCount的属性,用作slice管道的第二个参数,如下所示:

...
<tr *paFor="let item of getProducts() | slice:0:(itemCount || 1);
    let i = index; let odd = odd; let even = even"
    [class.bg-info]="odd" [class.bg-warning]="even">
...

其效果是通过改变select元素显示的值来改变产品表中显示的商品数量,如图 18-18 所示。

img/421542_4_En_18_Fig18_HTML.jpg

图 18-18。

使用切片管道

格式化键/值对

keyvalue管道对一个对象或一个地图进行操作,并返回一系列键/值对。序列中的每个对象都被表示为具有keyvalue属性的对象,清单 18-27 演示了如何使用管道来枚举由getProducts方法返回的数组内容。

<table class="table table-sm table-bordered table-striped">
    <tr><th>Key</th><th>Value</th></tr>
    <tr *paFor="let item of getProducts() | keyvalue">
        <td>{{ item.key }}</td>
        <td>{{ item.value | json }}</td>
    </tr>
</table>

Listing 18-27.Using the keyvalue Pipe in the productTable.component.html File in the src/app Folder

在数组上使用时,键是数组索引,值是数组中的对象。使用json过滤器格式化数组中的对象,产生如图 18-19 所示的结果。

img/421542_4_En_18_Fig19_HTML.jpg

图 18-19。

使用键值管道

选择值

i18nSelect管道根据一个值选择一个字符串值,允许向用户显示上下文相关的值。值和字符串之间的映射被定义为一个简单的映射,如清单 18-28 所示。

import { Component, Input, ViewChildren, QueryList } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";

@Component({
    selector: "paProductTable",
    templateUrl: "productTable.component.html"
})
export class ProductTableComponent {

    @Input("model")
    dataModel: Model;

    getProduct(key: number): Product {
        return this.dataModel.getProduct(key);
    }

    getProducts(): Product[] {
        return this.dataModel.getProducts();
    }

    deleteProduct(key: number) {
        this.dataModel.deleteProduct(key);
    }

    taxRate: number = 0;
    categoryFilter: string;
    itemCount: number = 3;

    dateObject: Date = new Date(2020, 1, 20);
    dateString: string = "2020-02-20T00:00:00.000Z";
    dateNumber: number = 1582156800000;

    selectMap = {
        "Watersports": "stay dry",
        "Soccer": "score goals",
        "other": "have fun"
    }
}

Listing 18-28.Mapping Values to Strings in the productTable.component.ts File in the src/app Folder

当与其他值不匹配时,另一个映射用作后备。在清单 18-29 中,我已经应用了管道来选择向用户显示的消息。

<table class="table table-sm table-bordered table-striped">
    <tr><th>Name</th><th>Category</th><th>Message</th></tr>
    <tr *paFor="let item of getProducts()">
        <td>{{ item.name }}</td>
        <td>{{ item.category }}</td>
        <td>Helps you {{ item.category | i18nSelect:selectMap }} </td>
    </tr>
</table>

Listing 18-29.Using the Pipe in the productTable.component.html File in the src/app Folder

管道以地图作为参数,并产生如图 18-20 所示的响应。

img/421542_4_En_18_Fig20_HTML.jpg

图 18-20。

使用 i18n 选择管道选择值

多元化价值观

i18nPlural管道用于选择描述数值的表达式。值和表达式之间的映射被表示为一个简单的映射,如清单 18-30 所示。

import { Component, Input, ViewChildren, QueryList } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";

@Component({
    selector: "paProductTable",
    templateUrl: "productTable.component.html"
})
export class ProductTableComponent {

    // ...other statments omitted for brevity...

    selectMap = {
        "Watersports": "stay dry",
        "Soccer": "score goals",
        "other": "have fun"
    }

    numberMap = {
        "=1": "one product",
        "=2": "two products",
        "other": "# products"
    }
}

Listing 18-30.Mapping Numbers to Strings in the productTable.component.ts File in the src/app Folder

每个映射都用等号后跟数字来表示。other值是一个后备,它产生的结果可以引用使用#占位符的数值。清单 18-31 显示了使用示例映射可以产生的结果。

<table class="table table-sm table-bordered table-striped">
    <tr><th>Name</th><th>Category</th><th>Message</th></tr>
    <tr *paFor="let item of getProducts()">
        <td>{{ item.name }}</td>
        <td>{{ item.category }}</td>
        <td>Helps you {{ item.category | i18nSelect:selectMap }} </td>
    </tr>
</table>

<div class="bg-info text-white p-2">
    <div>There are {{ 1 | i18nPlural:numberMap }} </div>
    <div>There are {{ 2 | i18nPlural:numberMap }} </div>
    <div>There are {{ 100 | i18nPlural:numberMap }} </div>
</div>

Listing 18-31.Using the Pipe in the productTable.component.html File in the src/app Folder

映射被指定为管道的参数,列表 18-31 中的值产生如图 18-21 所示的结果。

img/421542_4_En_18_Fig21_HTML.jpg

图 18-21。

使用 i18n 管道选择值

摘要

在这一章中,我介绍了管道,并解释了如何使用它们来转换数据值,以便在模板中呈现给用户。我演示了创建定制管道的过程,解释了一些管道是纯的而另一些不是,并演示了 Angular 提供的用于处理常见任务的内置管道。在下一章中,我将介绍服务,它可以用来简化 Angular 应用的设计,并允许构建模块轻松地进行协作。

十九、使用服务

服务是提供通用功能以支持应用中其他构件的对象,例如指令、组件和管道。关于服务,重要的是它们被使用的方式,这是通过一个叫做依赖注入的过程。使用服务可以增加 Angular 应用的灵活性和可伸缩性,但是依赖注入可能是一个很难理解的话题。为此,我慢慢地开始这一章,解释服务和依赖注入可以用来解决的问题,依赖注入如何工作,以及为什么你应该考虑在你自己的项目中使用服务。在第二十章中,我介绍了 Angular 为服务提供的一些更高级的特性。表 19-1 将服务放在上下文中。

表 19-1。

将服务置于环境中

|

问题

|

回答

|
| --- | --- |
| 它们是什么? | 服务是定义其他构建块(如组件或指令)所需功能的对象。服务与常规对象的区别在于,它们是由外部提供者提供给构建块的,而不是直接使用new关键字创建或由输入属性接收的。 |
| 它们为什么有用? | 服务简化了应用的结构,使移动或重用功能变得更容易,并使隔离有效单元测试的构建块变得更容易。 |
| 它们是如何使用的? | 类使用构造函数参数声明对服务的依赖,然后使用应用已配置的服务集解析这些参数。服务是已经应用了@Injectable装饰器的类。 |
| 有什么陷阱或限制吗? | 依赖注入是一个有争议的话题,并不是所有的开发人员都喜欢使用它。如果您不执行单元测试,或者如果您的应用相对简单,实现依赖注入所需的额外工作不太可能带来任何长期回报。 |
| 还有其他选择吗? | 服务和依赖注入是难以避免的,因为 Angular 使用它们来提供对内置功能的访问。但是如果您愿意的话,并不要求您为自己的定制功能定义服务。 |

表 19-2 总结了本章内容。

表 19-2。

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 避免手动分发共享对象的需要 | 使用服务 | 1–14, 21–28 |
| 声明对服务的依赖 | 添加一个带有所需服务类型的构造函数参数 | 15–20 |

准备示例项目

我继续使用从第十一章开始的本章中的示例项目。为了准备本章,我用清单 19-1 中所示的元素替换了ProductTable组件模板的内容。

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

<table class="table table-sm table-bordered table-striped">
    <thead>
        <tr><th></th><th>Name</th><th>Category</th><th>Price</th><th></th></tr>
    </thead>
    <tbody>
        <tr *paFor="let item of getProducts(); let i = index">
            <td>{{i + 1}}</td>
            <td>{{item.name}}</td>
            <td>{{item.category}}</td>
            <td>{{item.price | currency:"USD":"symbol" }}</td>
            <td class="text-center">
                <button class="btn btn-danger btn-sm"
                        (click)="deleteProduct(item.id)">
                    Delete
                </button>
            </td>
        </tr>
    </tbody>
</table>

Listing 19-1.Replacing the Contents of the productTable.component.html File in the src/app Folder

example文件夹中运行以下命令,启动 TypeScript 编译器和开发 HTTP 服务器:

ng serve

打开一个新的浏览器窗口并导航至http://localhost:4200以查看图 19-1 所示的内容。

img/421542_4_En_19_Fig1_HTML.jpg

图 19-1。

运行示例应用

理解对象分布问题

在第十七章中,我向项目中添加了组件,以帮助打破应用的整体结构。为此,我使用输入和输出属性来连接组件,使用主体元素来桥接 Angular 在父组件及其子组件之间强制实施的隔离。我还向您展示了如何查询视图子模板的内容,它补充了第十六章中描述的内容子特性。

如果谨慎应用,这些用于协调指令和组件的技术会非常强大和有用。但是它们也可能最终成为在整个应用中分发共享对象的通用工具,其结果是增加了应用的复杂性,并将组件紧密地绑定在一起。

演示问题

为了帮助演示这个问题,我将向项目添加一个共享对象和两个依赖于它的组件。我在src/app文件夹中创建了一个名为discount.service.ts的文件,并定义了清单 19-2 中所示的类。我将在本章的后面解释文件名的service部分的意义。

export class DiscountService {
    private discountValue: number = 10;

    public get discount(): number {
        return this.discountValue;
    }

    public set discount(newValue: number) {
        this.discountValue = newValue || 0;
    }

    public applyDiscount(price: number) {
        return Math.max(price - this.discountValue, 5);
    }
}

Listing 19-2.The Contents of the discount.service.ts File in the src/app Folder

DiscountService类定义了一个名为discountValue的私有属性,用于存储一个数字,该数字将用于降低数据模型中的产品价格。这个值是通过名为discount的 getters 和 setters 公开的,有一个名为applyDiscount的便利方法可以降低价格,同时确保价格不低于 5 美元。

对于第一个使用了DiscountService类的组件,我在src/app文件夹中添加了一个名为discountDisplay.component.ts的文件,并添加了清单 19-3 中所示的代码。

import { Component, Input } from "@angular/core";
import { DiscountService } from "./discount.service";

@Component({
    selector: "paDiscountDisplay",
    template: `<div class="bg-info text-white p-2">
                The discount is {{discounter.discount}}
               </div>`
})
export class PaDiscountDisplayComponent {

    @Input("discounter")
    discounter: DiscountService;
}

Listing 19-3.The Contents of the discountDisplay.component.ts File in the src/app Folder

DiscountDisplayComponent使用一个内嵌模板来显示折扣金额,该金额是从通过input属性接收的DiscountService对象中获得的。

对于使用DiscountService类的第二个组件,我在src/app文件夹中添加了一个名为discountEditor.component.ts的文件,并添加了清单 19-4 中所示的代码。

import { Component, Input } from "@angular/core";
import { DiscountService } from "./discount.service";

@Component({
    selector: "paDiscountEditor",
    template: `<div class="form-group">
                   <label>Discount</label>
                   <input [(ngModel)]="discounter.discount"
                        class="form-control" type="number" />
               </div>`
})
export class PaDiscountEditorComponent {

    @Input("discounter")
    discounter: DiscountService;
}

Listing 19-4.The Contents of the discountEditor.component.ts File in the src/app Folder

DiscountEditorComponent使用带有input元素的内嵌模板,允许编辑折扣金额。input元素在针对ngModel指令的DiscountService.discount属性上有一个双向绑定。清单 19-5 显示了 Angular 模块中启用的新组件。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";
import { PaStructureDirective } from "./structure.directive";
import { PaIteratorDirective } from "./iterator.directive";
import { PaCellColor } from "./cellColor.directive";
import { PaCellColorSwitcher } from "./cellColorSwitcher.directive";
import { ProductTableComponent } from "./productTable.component";
import { ProductFormComponent } from "./productForm.component";
import { PaToggleView } from "./toggleView.component";
import { PaAddTaxPipe } from "./addTax.pipe";
import { PaCategoryFilterPipe } from "./categoryFilter.pipe";
import { LOCALE_ID } from "@angular/core";
import localeFr from '@angular/common/locales/fr';
import { registerLocaleData } from '@angular/common';
import { PaDiscountDisplayComponent } from "./discountDisplay.component";
import { PaDiscountEditorComponent } from "./discountEditor.component";

registerLocaleData(localeFr);

@NgModule({
  imports: [BrowserModule, FormsModule, ReactiveFormsModule],
  declarations: [ProductComponent, PaAttrDirective, PaModel,
    PaStructureDirective, PaIteratorDirective,
    PaCellColor, PaCellColorSwitcher, ProductTableComponent,
    ProductFormComponent, PaToggleView, PaAddTaxPipe,
    PaCategoryFilterPipe, PaDiscountDisplayComponent, PaDiscountEditorComponent],
  //providers: [{ provide: LOCALE_ID, useValue: "fr-FR" }],
  bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 19-5.Enabling the Components in the app.module.ts File in the src/app Folder

为了让新组件工作,我将它们添加到父组件的模板中,将新内容放在列出产品的表格下面,这意味着我需要编辑productTable.component.html文件,如清单 19-6 所示。

<table class="table table-sm table-bordered table-striped">
    <thead>
        <tr><th></th><th>Name</th><th>Category</th><th>Price</th><th></th></tr>
    </thead>
    <tbody>
        <tr *paFor="let item of getProducts(); let i = index">
            <td>{{i + 1}}</td>
            <td>{{item.name}}</td>
            <td>{{item.category}}</td>
            <td>{{item.price | currency:"USD":"symbol" }}</td>
            <td class="text-center">
                <button class="btn btn-danger btn-sm"
                        (click)="deleteProduct(item.id)">
                    Delete
                </button>
            </td>
        </tr>
    </tbody>
</table>

<paDiscountEditor [discounter]="discounter"></paDiscountEditor>
<paDiscountDisplay [discounter]="discounter"></paDiscountDisplay>

Listing 19-6.Adding Component Elements in the productTable.component.html File in the src/app Folder

这些元素对应于清单 19-3 和清单 19-4 中组件的selector属性,并使用数据绑定来设置输入属性的值。最后一步是在父组件中创建一个对象,它将为数据绑定表达式提供值,如清单 19-7 所示。

import { Component, Input, ViewChildren, QueryList } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";
import { DiscountService } from "./discount.service";

@Component({
    selector: "paProductTable",
    templateUrl: "productTable.component.html"
})
export class ProductTableComponent {
    discounter: DiscountService = new DiscountService();

    @Input("model")
    dataModel: Model;

    getProduct(key: number): Product {
        return this.dataModel.getProduct(key);
    }

    getProducts(): Product[] {
        return this.dataModel.getProducts();
    }

    deleteProduct(key: number) {
        this.dataModel.deleteProduct(key);
    }

    taxRate: number = 0;
    dateObject: Date = new Date(2020, 1, 20);
    dateString: string = "2020-02-20T00:00:00.000Z";
    dateNumber: number = 1582156800000;

    selectMap = {
        "Watersports": "stay dry",
        "Soccer": "score goals",
        "other": "have fun"
    }

    numberMap = {
        "=1": "one product",
        "=2": "two products",
        "other": "# products"
    }
}

Listing 19-7.Creating the Shared Object in the productTable.component.ts File in the src/app Folder

图 19-2 显示了新组件的内容。由一个组件提供的对input元素中的值的更改将反映在由另一个组件呈现的内容中,反映了共享的DiscountService对象及其discount属性的使用。

img/421542_4_En_19_Fig2_HTML.jpg

图 19-2。

向示例应用添加组件

直到最后阶段,添加新组件和共享对象的过程都是简单明了且符合逻辑的。问题出现在我创建和分发共享对象的方式上:类的实例。

因为 Angular 将组件彼此隔离,我没有办法在DiscountEditorComponentDiscountDisplayComponent之间直接共享DiscountService对象。每个组件都可以创建自己的DiscountService对象,但是这意味着来自编辑器组件的更改不会显示在显示组件中。

这就是我在 product table 组件中创建DiscountService对象的原因,它是折扣编辑器和显示组件的第一个共享祖先。这允许我通过 product table 组件的模板分发DiscountService对象,确保需要它的两个组件共享一个对象。

但是有几个问题。首先,ProductTableComponent类实际上并不需要或使用一个DiscountService对象来交付它自己的功能。它恰好是需要该对象的组件的第一个共同祖先。在ProductTableComponent类中创建共享对象会使这个类稍微复杂一点,并且更难有效地测试。这是复杂性的适度增加,但它会发生在应用需要的每个共享对象上——一个复杂的应用可能依赖于许多共享对象,每个对象最终都是由组件创建的,而这些组件恰好是依赖它们的类的第一个公共祖先。

第二个问题由术语第一个共同祖先暗示。ProductTableComponent类恰好是依赖于DiscountService对象的两个类的父类,但是想想如果我想移动DiscountEditorComponent使其显示在表单下而不是表格下会发生什么。在这种情况下,我必须沿着组件树向上搜索,直到找到一个共同的祖先,这将成为根组件。然后,我必须沿着组件树添加输入属性和修改模板,以便每个中间组件可以从其父组件接收DiscountService对象,并将其传递给任何有需要它的后代的子组件。这同样适用于任何依赖于接收一个DiscountService对象的指令,其中任何其模板包含以该指令为目标的数据绑定的组件必须确保它们也是分发链的一部分。

结果是应用中的组件和指令紧密地绑定在一起。如果您需要在应用的不同部分移动或重用组件,并且输入属性和数据绑定的管理变得难以管理,则需要进行重大的重构。

使用依赖注入将对象作为服务分发

将对象分配给依赖它们的类有一个更好的方法,那就是使用依赖注入,对象从外部源提供给类。Angular 包含一个内置的依赖注入系统,并提供外部对象源,称为提供者。在接下来的小节中,我重新编写了示例应用来提供DiscountService对象,而不需要使用组件层次结构作为分发机制。

准备服务

通过依赖注入管理和分发的任何对象都被称为服务,这就是为什么我选择了名称DiscountService作为定义共享对象的类,以及为什么这个类被定义在一个名为discount.service.ts的文件中。Angular 使用@Injectable装饰器表示服务类,如清单 19-8 所示。@Injectable装饰器没有定义任何配置属性。

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

@Injectable()
export class DiscountService {
    private discountValue: number = 10;

    public get discount(): number {
        return this.discountValue;
    }

    public set discount(newValue: number) {
        this.discountValue = newValue || 0;
    }

    public applyDiscount(price: number) {
        return Math.max(price - this.discountValue, 5);
    }
}

Listing 19-8.Preparing a Class as a Service in the discount.service.ts File in the src/app Folder

Tip

严格地说,只有当一个类有自己的构造函数参数需要解析时,才需要使用@Injectable装饰器,但是无论如何应用它都是一个好主意,因为它提供了一个信号,表明这个类打算用作服务。

准备相关组件

类使用其构造函数声明依赖关系。当 Angular 需要创建一个类的实例时——比如当它找到一个与组件定义的selector属性匹配的元素时——它的构造函数会被检查,每个参数的类型也会被检查。Angular 然后使用已经定义的服务来尝试满足依赖性。术语依赖注入的出现是因为每个依赖都被注入到构造函数中以创建新的实例。

对于示例应用,这意味着依赖于DiscountService对象的组件不再需要输入属性,而是可以声明一个构造函数依赖。清单 19-9 显示了DiscountDisplayComponent类的变化。

import { Component, Input } from "@angular/core";
import { DiscountService } from "./discount.service";

@Component({
  selector: "paDiscountDisplay",
  template: `<div class="bg-info text-white p-2">
                The discount is {{discounter.discount}}
               </div>`
})
export class PaDiscountDisplayComponent {

  constructor(public discounter: DiscountService) { }
}

Listing 19-9.Declaring a Dependency in the discountDisplay.component.ts File in the src/app Folder

同样的变化可以应用到DiscountEditorComponent类,用通过构造函数声明的依赖项替换输入属性,如清单 19-10 所示。

import { Component, Input } from "@angular/core";
import { DiscountService } from "./discount.service";

@Component({
    selector: "paDiscountEditor",
    template: `<div class="form-group">
                   <label>Discount</label>
                   <input [(ngModel)]="discounter.discount"
                        class="form-control" type="number" />
               </div>`
})
export class PaDiscountEditorComponent {

    constructor(public discounter: DiscountService) { }
}

Listing 19-10.Declaring a Dependency in the discountEditor.component.ts File in the src/app Folder

这些都是很小的变化,但是它们避免了使用模板和输入属性来分发对象的需要,并且产生了更灵活的应用。我现在可以从产品表组件中移除DiscountService对象,如清单 19-11 所示。

import { Component, Input, ViewChildren, QueryList } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";
import { DiscountService } from "./discount.service";

@Component({
    selector: "paProductTable",
    templateUrl: "productTable.component.html"
})
export class ProductTableComponent {
    //discounter: DiscountService = new DiscountService();

    @Input("model")
    dataModel: Model;

    getProduct(key: number): Product {
        return this.dataModel.getProduct(key);
    }

    getProducts(): Product[] {
        return this.dataModel.getProducts();
    }

    deleteProduct(key: number) {
        this.dataModel.deleteProduct(key);
    }

    taxRate: number = 0;
    dateObject: Date = new Date(2020, 1, 20);
    dateString: string = "2020-02-20T00:00:00.000Z";
    dateNumber: number = 1582156800000;

    selectMap = {
        "Watersports": "stay dry",
        "Soccer": "score goals",
        "other": "have fun"
    }

    numberMap = {
        "=1": "one product",
        "=2": "two products",
        "other": "# products"
    }
}

Listing 19-11.Removing the Shared Object in the productTable.component.ts File in the src/app Folder

由于父组件不再通过数据绑定提供共享对象,我可以将它们从模板中移除,如清单 19-12 所示。

<table class="table table-sm table-bordered table-striped">
    <thead>
        <tr><th></th><th>Name</th><th>Category</th><th>Price</th><th></th></tr>
    </thead>
    <tbody>
        <tr *paFor="let item of getProducts(); let i = index">
            <td>{{i + 1}}</td>
            <td>{{item.name}}</td>
            <td>{{item.category}}</td>
            <td>{{item.price | currency:"USD":"symbol" }}</td>
            <td class="text-center">
                <button class="btn btn-danger btn-sm"
                        (click)="deleteProduct(item.id)">
                    Delete
                </button>
            </td>
        </tr>
    </tbody>
</table>

<paDiscountEditor></paDiscountEditor>
<paDiscountDisplay></paDiscountDisplay>

Listing 19-12.Removing the Data Bindings in the productTable.component.html File in the src/app Folder

注册服务

最后一个变化是配置依赖注入特性,这样它就可以向需要它们的组件提供DiscountService对象。为了使服务在整个应用中可用,它被注册在 Angular 模块中,如清单 19-13 所示。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";
import { PaStructureDirective } from "./structure.directive";
import { PaIteratorDirective } from "./iterator.directive";
import { PaCellColor } from "./cellColor.directive";
import { PaCellColorSwitcher } from "./cellColorSwitcher.directive";
import { ProductTableComponent } from "./productTable.component";
import { ProductFormComponent } from "./productForm.component";
import { PaToggleView } from "./toggleView.component";
import { PaAddTaxPipe } from "./addTax.pipe";
import { PaCategoryFilterPipe } from "./categoryFilter.pipe";
import { LOCALE_ID } from "@angular/core";
import localeFr from '@angular/common/locales/fr';
import { registerLocaleData } from '@angular/common';
import { PaDiscountDisplayComponent } from "./discountDisplay.component";
import { PaDiscountEditorComponent } from "./discountEditor.component";
import { DiscountService } from "./discount.service";

registerLocaleData(localeFr);

@NgModule({
  imports: [BrowserModule, FormsModule, ReactiveFormsModule],
  declarations: [ProductComponent, PaAttrDirective, PaModel,
    PaStructureDirective, PaIteratorDirective,
    PaCellColor, PaCellColorSwitcher, ProductTableComponent,
    ProductFormComponent, PaToggleView, PaAddTaxPipe,
    PaCategoryFilterPipe, PaDiscountDisplayComponent, PaDiscountEditorComponent],
  providers: [DiscountService],
  bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 19-13.Registering a Service in the app.module.ts File in the src/app Folder

NgModule decorator 的providers属性被设置为将被用作服务的类的数组。目前只有一种服务,由DiscountService级提供。

当您保存对应用的更改时,不会有任何可见的更改,但是依赖注入特性将被用来为组件提供它们需要的DiscountService对象。

查看依赖注入更改

Angular 无缝地将依赖注入集成到它的特性集中。每当 Angular 遇到一个需要新构建块的元素,比如一个组件或一个管道,它就检查类构造函数来检查已经声明了哪些依赖项,并使用它的服务来尝试解决它们。用于解决依赖关系的服务集包括由应用定义的定制服务,例如在清单 19-13 中注册的DiscountService服务,以及一组由 Angular 提供的内置服务,将在后面的章节中描述。

上一节中引入依赖注入的更改并没有导致应用工作方式的巨大变化,或者说根本没有任何可见的变化。但是应用的组装方式有很大的不同,这使得它更加灵活和流畅。最好的演示是将需要DiscountService的组件添加到应用的不同部分,如清单 19-14 所示。

<form novalidate #form="ngForm" (ngSubmit)="submitForm(form)">
    <div class="form-group">
        <label>Name</label>
        <input class="form-control"
            name="name" [(ngModel)]="newProduct.name" />
    </div>
    <div class="form-group">
        <label>Category</label>
        <input class="form-control"
            name="category" [(ngModel)]="newProduct.category" />
    </div>
    <div class="form-group">
        <label>Price</label>
        <input class="form-control"
            name="name" [(ngModel)]="newProduct.price" />
    </div>
    <button class="btn btn-primary" type="submit">
            Create
    </button>
</form>

<paDiscountEditor></paDiscountEditor>
<paDiscountDisplay></paDiscountDisplay>

Listing 19-14.Adding Components in the productForm.component.html File in the src/app Folder

这些新元素复制了折扣显示和编辑器组件,因此它们出现在用于创建新产品的表单下方,如图 19-3 所示。

img/421542_4_En_19_Fig3_HTML.jpg

图 19-3。

复制具有从属关系的元件

有两点需要注意。首先,使用依赖注入使得向模板添加元素的过程变得简单,不需要修改祖先组件来使用输入属性提供一个DiscountService对象。

第二点需要注意的是,应用中所有声明依赖于DiscountService的组件都接收到了同一个对象。如果您编辑任一input元素中的值,更改将反映在另一个input元素和字符串插值绑定中,如图 19-4 所示。

img/421542_4_En_19_Fig4_HTML.jpg

图 19-4。

检查是否使用共享对象解决了依赖关系

在其他构建块中声明依赖项

不仅仅是组件可以声明构造函数依赖关系。一旦您定义了一个服务,您就可以更广泛地使用它,包括在应用的其他构建块中,比如管道和指令,如下面几节所演示的。

在管道中声明依赖关系

管道可以通过为每个必需的服务定义一个带有参数的构造函数来声明对服务的依赖。为了演示,我在src/app文件夹中添加了一个名为discount.pipe.ts的文件,并用它来定义清单 19-15 中所示的管道。

import { Pipe, Injectable } from "@angular/core";
import { DiscountService } from "./discount.service";

@Pipe({
    name: "discount",
    pure: false
})
export class PaDiscountPipe {

    constructor(private discount: DiscountService) { }

    transform(price: number): number {
        return this.discount.applyDiscount(price);
    }
}

Listing 19-15.The Contents of the discount.pipe.ts File in the src/app Folder

PaDiscountPipe类是一个接收price并通过调用DiscountService.applyDiscount方法生成结果的管道,其中服务通过构造函数接收。@Pipe装饰器中的pure属性是false,这意味着当DiscountService存储的值改变时,管道将被要求更新其结果,这不会被 Angular 变化检测过程识别。

Tip

正如第十八章中所解释的,这个特性应该小心使用,因为它意味着transform方法将在应用的每次更改后被调用,而不仅仅是在服务更改时。

清单 19-16 显示了在应用的 Angular 模块中注册的新管道。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";
import { PaStructureDirective } from "./structure.directive";
import { PaIteratorDirective } from "./iterator.directive";
import { PaCellColor } from "./cellColor.directive";
import { PaCellColorSwitcher } from "./cellColorSwitcher.directive";
import { ProductTableComponent } from "./productTable.component";
import { ProductFormComponent } from "./productForm.component";
import { PaToggleView } from "./toggleView.component";
import { PaAddTaxPipe } from "./addTax.pipe";
import { PaCategoryFilterPipe } from "./categoryFilter.pipe";
import { LOCALE_ID } from "@angular/core";
import localeFr from '@angular/common/locales/fr';
import { registerLocaleData } from '@angular/common';
import { PaDiscountDisplayComponent } from "./discountDisplay.component";
import { PaDiscountEditorComponent } from "./discountEditor.component";
import { DiscountService } from "./discount.service";
import { PaDiscountPipe } from "./discount.pipe";

registerLocaleData(localeFr);

@NgModule({
  imports: [BrowserModule, FormsModule, ReactiveFormsModule],
  declarations: [ProductComponent, PaAttrDirective, PaModel,
    PaStructureDirective, PaIteratorDirective,
    PaCellColor, PaCellColorSwitcher, ProductTableComponent,
    ProductFormComponent, PaToggleView, PaAddTaxPipe,
    PaCategoryFilterPipe, PaDiscountDisplayComponent, PaDiscountEditorComponent,
    PaDiscountPipe],
  providers: [DiscountService],
  bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 19-16.Registering a Pipe in the app.module.ts File in the src/app Folder

清单 19-17 显示了应用于产品表中Price列的新管道。

<table class="table table-sm table-bordered table-striped">
    <thead>
        <tr><th></th><th>Name</th><th>Category</th><th>Price</th><th></th></tr>
    </thead>
    <tbody>
        <tr *paFor="let item of getProducts(); let i = index">
            <td>{{i + 1}}</td>
            <td>{{item.name}}</td>
            <td>{{item.category}}</td>
            <td>{{item.price | discount | currency:"USD":"symbol" }}</td>
            <td class="text-center">
                <button class="btn btn-danger btn-sm"
                        (click)="deleteProduct(item.id)">
                    Delete
                </button>
            </td>
        </tr>
    </tbody>
</table>

<paDiscountEditor></paDiscountEditor>
<paDiscountDisplay></paDiscountDisplay>

Listing 19-17.Applying a Pipe in the productTable.component.html File in the src/app Folder

discount管道处理价格以应用折扣,然后将值传递给currency管道进行格式化。您可以通过更改折扣input元素之一的值来查看在管道中使用服务的效果,如图 19-5 所示。

img/421542_4_En_19_Fig5_HTML.jpg

图 19-5。

在管道中使用服务

在指令中声明依赖关系

指令也可以使用服务。正如我在第十七章中解释的,组件只是带有模板的指令,所以在组件中工作的任何东西也将在指令中工作。

为了演示如何在指令中使用服务,我在src/app文件夹中添加了一个名为discountAmount.directive.ts的文件,并用它来定义清单 19-18 中所示的指令。

import { Directive, HostBinding, Input,
    SimpleChange, KeyValueDiffer, KeyValueDiffers,
    ChangeDetectorRef } from "@angular/core";
import { DiscountService } from "./discount.service";

@Directive({
    selector: "td[pa-price]",
    exportAs: "discount"
})
export class PaDiscountAmountDirective {
    private differ: KeyValueDiffer<any, any>;

    constructor(private keyValueDiffers: KeyValueDiffers,
        private changeDetector: ChangeDetectorRef,
        private discount: DiscountService) { }

    @Input("pa-price")
    originalPrice: number;

    discountAmount: number;

    ngOnInit() {
        this.differ =
            this.keyValueDiffers.find(this.discount).create();
    }

    ngOnChanges(changes: { [property: string]: SimpleChange }) {
        if (changes["originalPrice"] != null) {
            this.updateValue();
        }
    }

    ngDoCheck() {
        if (this.differ.diff(this.discount) != null) {
            this.updateValue();
        }
    }

    private updateValue() {
        this.discountAmount = this.originalPrice
            - this.discount.applyDiscount(this.originalPrice);
    }
}

Listing 19-18.The Contents of the discountAmount.directive.ts File in the src/app Folder

指令没有与pipes使用的pure属性等价的属性,必须直接负责响应通过服务传播的变化。本指令显示产品的折扣金额。selector属性匹配具有pa-price属性的td元素,该属性也被用作输入属性来接收将要打折的价格。该指令使用exportAs属性导出其功能,并提供一个名为discountAmount的属性,其值设置为应用于产品的折扣。

关于这个指令还有另外两点需要注意。首先,DiscountService对象不是指令类中唯一的构造函数参数。

...
constructor(private keyValueDiffers: KeyValueDiffers,
            private changeDetector: ChangeDetectorRef,
            private discount: DiscountService) { }
...

KeyValueDiffersChangeDetectorRef参数也是 Angular 在创建 directive 类的新实例时必须解决的依赖关系。这些是 Angular 提供的内置服务的例子,它们提供了通常需要的功能。

第二点要注意的是指令如何处理它接收到的服务。使用DiscountService服务的组件和管道不必担心跟踪更新,因为 Angular 自动评估数据绑定的表达式,并在折扣率变化时更新它们(对于组件),或者因为应用中的任何变化都会触发更新(对于不纯的管道)。该指令的数据绑定在price属性上,如果被更改,该属性将触发更改。但是也存在对由DiscountService类定义的discount属性的依赖。使用通过构造函数接收的服务来检测discount属性的变化,这些服务类似于在第十六章中描述的用于跟踪可迭代序列变化的服务,但是它们操作于键值对对象,例如Map对象,或者定义属性的常规对象,例如DiscountService。当 Angular 调用ngDoCheck方法时,该指令使用键-值对 different 来查看是否发生了变化。(也可以通过跟踪 directive 类中以前的更新来处理这个更改方向,但是我想提供一个使用键值差异特性的示例。)

该指令还实现了ngOnChanges方法,以便它能够响应输入属性值的变化。对于这两种类型的更新,都调用了updateValue方法,该方法计算折扣价并将其分配给discountAmount属性。

清单 19-19 在应用的 Angular 模块中注册新指令。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";
import { PaStructureDirective } from "./structure.directive";
import { PaIteratorDirective } from "./iterator.directive";
import { PaCellColor } from "./cellColor.directive";
import { PaCellColorSwitcher } from "./cellColorSwitcher.directive";
import { ProductTableComponent } from "./productTable.component";
import { ProductFormComponent } from "./productForm.component";
import { PaToggleView } from "./toggleView.component";
import { PaAddTaxPipe } from "./addTax.pipe";
import { PaCategoryFilterPipe } from "./categoryFilter.pipe";
import { LOCALE_ID } from "@angular/core";
import localeFr from '@angular/common/locales/fr';
import { registerLocaleData } from '@angular/common';
import { PaDiscountDisplayComponent } from "./discountDisplay.component";
import { PaDiscountEditorComponent } from "./discountEditor.component";
import { DiscountService } from "./discount.service";
import { PaDiscountPipe } from "./discount.pipe";
import { PaDiscountAmountDirective } from "./discountAmount.directive";

registerLocaleData(localeFr);

@NgModule({
  imports: [BrowserModule, FormsModule, ReactiveFormsModule],
  declarations: [ProductComponent, PaAttrDirective, PaModel,
    PaStructureDirective, PaIteratorDirective,
    PaCellColor, PaCellColorSwitcher, ProductTableComponent,
    ProductFormComponent, PaToggleView, PaAddTaxPipe,
    PaCategoryFilterPipe, PaDiscountDisplayComponent, PaDiscountEditorComponent,
    PaDiscountPipe, PaDiscountAmountDirective],
  providers: [DiscountService],
  bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 19-19.Registering a Directive in the app.module.ts File in the src/app Folder

为了应用新的指令,清单 19-20 向表中添加了一个新列,使用字符串插值绑定来访问指令提供的属性,并将其传递给currency管道。

<table class="table table-sm table-bordered table-striped">
    <thead>
        <tr><th></th><th>Name</th><th>Category</th><th>Price</th><th></th></tr>
    </thead>
    <tbody>
        <tr *paFor="let item of getProducts(); let i = index">
            <td>{{i + 1}}</td>
            <td>{{item.name}}</td>
            <td>{{item.category}}</td>
            <td [pa-price]="item.price" #discount="discount">
                {{ discount.discountAmount | currency:"USD":"symbol"}}
            </td>
            <td class="text-center">
                <button class="btn btn-danger btn-sm"
                        (click)="deleteProduct(item.id)">
                    Delete
                </button>
            </td>
        </tr>
    </tbody>
</table>

<paDiscountEditor></paDiscountEditor>
<paDiscountDisplay></paDiscountDisplay>

Listing 19-20.Creating a New Column in the productTable.component.html File in the src/app Folder

该指令本来可以在textContent属性上创建一个主机绑定来设置其主机元素的内容,但是这会阻止使用currency管道。相反,该指令被分配给discount模板变量,然后在字符串插值绑定中使用该变量来访问并格式化discountAmount值。图 19-6 显示了结果。在折扣编辑器input元素中对折扣金额的更改将反映在新的表格列中。

img/421542_4_En_19_Fig6_HTML.jpg

图 19-6。

在指令中使用服务

理解测试隔离问题

示例应用包含一个相关的问题,服务和依赖注入可以用来解决这个问题。考虑如何在根组件中创建Model类。

import { ApplicationRef, Component } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";
import { ProductFormGroup } from "./form.model";

@Component({
    selector: "app",
    templateUrl: "app/template.html"
})
export class ProductComponent {
    model: Model = new Model();

    addProduct(p: Product) {
        this.model.saveProduct(p);
    }
}

根组件被定义为ProductComponent类,它通过创建一个Model类的新实例为其model属性设置一个值。这是可行的——并且是创建对象的完全合法的方式——但是它使得有效地执行单元测试变得更加困难。

当您能够隔离应用的一小部分并集中精力执行测试时,单元测试效果最好。但是当您创建一个ProductComponent类的实例时,您也隐式地创建了一个Model类的实例。如果您要对根组件的addProduct方法运行测试并发现一个问题,您将无法知道这个问题是在ProductComponent还是Model类中。

使用服务和依赖注入隔离组件

潜在的问题是,ProductComponent类与Model类紧密绑定,而后者又与SimpleDataSource类紧密绑定。依赖注入可以用来分离应用中的构件,这样每个类都可以独立地被隔离和测试。在接下来的几节中,我将介绍分解这些紧密耦合的类的过程,基本上遵循与上一节相同的过程,但是会更深入地研究示例应用。

准备服务

@Injectable装饰器用来表示服务,就像前面的例子一样。清单 19-21 显示了应用于SimpleDataSource类的装饰器。

import { Injectable } from "@angular/core";
import { Product } from "./product.model";

@Injectable()
export class SimpleDataSource {
    private data:Product[];

    constructor() {
        this.data = new Array<Product>(
        new Product(1, "Kayak", "Watersports", 275),
        new Product(2, "Lifejacket", "Watersports", 48.95),
        new Product(3, "Soccer Ball", "Soccer", 19.50),
        new Product(4, "Corner Flags", "Soccer", 34.95),
        new Product(5, "Thinking Cap", "Chess", 16));
    }

    getData(): Product[] {
        return this.data;
    }
}

Listing 19-21.Denoting a Service in the datasource.model.ts File in the src/app Folder

不需要其他更改。清单 19-22 显示了同样的装饰器被应用到数据仓库,由于这个类依赖于SimpleDataSource类,它声明它是一个构造函数依赖,而不是直接创建一个实例。

import { Injectable } from "@angular/core";
import { Product } from "./product.model";
import { SimpleDataSource } from "./datasource.model";

@Injectable()
export class Model {
    //private dataSource: SimpleDataSource;
    private products: Product[];
    private locator = (p:Product, id:number) => p.id == id;

    constructor(private dataSource: SimpleDataSource) {
        //this.dataSource = new SimpleDataSource();
        this.products = new Array<Product>();
        this.dataSource.getData().forEach(p => this.products.push(p));
    }

    // ...other members omitted for brevity...
}

Listing 19-22.Denoting a Service and Dependency in the repository.model.ts File in the src/app Folder

清单中需要注意的重要一点是,服务可以声明对其他服务的依赖。当 Angular 创建一个服务类的新实例时,它会检查构造函数,并尝试以处理组件或指令时相同的方式解析服务。

注册服务

这些服务必须被注册,以便 Angular 知道如何解析对它们的依赖,如清单 19-23 所示。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";
import { PaStructureDirective } from "./structure.directive";
import { PaIteratorDirective } from "./iterator.directive";
import { PaCellColor } from "./cellColor.directive";
import { PaCellColorSwitcher } from "./cellColorSwitcher.directive";
import { ProductTableComponent } from "./productTable.component";
import { ProductFormComponent } from "./productForm.component";
import { PaToggleView } from "./toggleView.component";
import { PaAddTaxPipe } from "./addTax.pipe";
import { PaCategoryFilterPipe } from "./categoryFilter.pipe";
import { LOCALE_ID } from "@angular/core";
import localeFr from '@angular/common/locales/fr';
import { registerLocaleData } from '@angular/common';
import { PaDiscountDisplayComponent } from "./discountDisplay.component";
import { PaDiscountEditorComponent } from "./discountEditor.component";
import { DiscountService } from "./discount.service";
import { PaDiscountPipe } from "./discount.pipe";
import { PaDiscountAmountDirective } from "./discountAmount.directive";
import { SimpleDataSource } from "./datasource.model";
import { Model } from "./repository.model";

registerLocaleData(localeFr);

@NgModule({
  imports: [BrowserModule, FormsModule, ReactiveFormsModule],
  declarations: [ProductComponent, PaAttrDirective, PaModel,
    PaStructureDirective, PaIteratorDirective,
    PaCellColor, PaCellColorSwitcher, ProductTableComponent,
    ProductFormComponent, PaToggleView, PaAddTaxPipe,
    PaCategoryFilterPipe, PaDiscountDisplayComponent, PaDiscountEditorComponent,
    PaDiscountPipe, PaDiscountAmountDirective],
    providers: [DiscountService, SimpleDataSource, Model],
  bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 19-23.Registering the Services in the app.module.ts File in the src/app Folder

准备从属组件

不是直接创建一个Model对象,根组件可以声明一个构造函数依赖,Angular 将在应用启动时使用依赖注入来解析它,如清单 19-24 所示。

import { ApplicationRef, Component } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";
import { ProductFormGroup } from "./form.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    //model: Model = new Model();

    constructor(public model: Model) { }

    addProduct(p: Product) {
        this.model.saveProduct(p);
    }
}

Listing 19-24.Declaring a Service Dependency in the component.ts File in the src/app Folder

现在 Angular 需要解决一系列的依赖关系。当应用启动时,Angular 模块指定ProductComponent类需要一个Model对象。Angular 检查了Model类,发现它需要一个SimpleDataSource对象。Angular 检查了SimpleDataSource对象,发现没有已声明的依赖关系,因此知道这是链的结尾。它创建一个SimpleDataSource对象,并将其作为参数传递给Model构造函数,以创建一个Model对象,然后可以将其传递给ProductComponent类构造函数,以创建将用作根组件的对象。所有这些都是自动发生的,基于每个类定义的构造函数和@Injectable装饰器的使用。

这些改变不会在应用的工作方式上产生任何可见的变化,但是它们允许一种完全不同的方式来执行单元测试。ProductComponent类要求提供一个Model对象作为构造函数参数,这允许使用模拟对象。

打破应用中类之间的直接依赖关系意味着它们中的每一个都可以出于单元测试的目的而被隔离,并通过它们的构造函数被提供给模拟对象,从而允许方法或一些其他特性的效果被一致且独立地评估。

完成服务的采用

一旦您开始在应用中使用服务,这个过程通常就有了自己的生命,并且您开始检查您创建的构建块之间的关系。你引入服务的程度是——至少部分是——个人偏好的问题。

一个很好的例子是在根组件中使用Model类。尽管该组件实现了一个使用Model对象的方法,但它这样做是因为它需要处理来自它的一个子组件的定制事件。根组件需要一个Model对象的另一个原因是使用输入属性通过它的模板将其传递给另一个子组件。

这种情况并不是一个大问题,您可能更喜欢在一个项目中拥有这些类型的关系。毕竟,对于单元测试来说,每一个组件都可以被隔离,而且它们之间的关系有一定的用途,尽管是有限的。组件之间的这种关系有助于理解应用提供的功能。

另一方面,使用服务越多,项目中的构建块就越能成为独立的和可重用的功能块,随着项目的成熟,这可以简化添加或更改功能的过程。

没有绝对的对错,你必须找到适合你、适合你的团队、最终适合你的用户和客户的平衡点。不是每个人都喜欢使用依赖注入,也不是每个人都执行单元测试。

我倾向于尽可能广泛地使用依赖注入。我发现当我开始一个新项目时,我的应用的最终结构可能与我期望的有很大不同,并且依赖注入提供的灵活性帮助我避免了重复的重构周期。所以,为了完成这一章,我将把Model服务的使用推进到应用的其余部分,打破根组件和它的直接子组件之间的耦合。

更新根组件和模板

我要做的第一个更改是从根组件中移除Model对象,以及使用它的方法和模板中的输入属性,该模板将模型分发给其中一个子组件。清单 19-25 显示了组件类的变化。

import { Component } from "@angular/core";
//import { Model } from "./repository.model";
//import { Product } from "./product.model";
//import { ProductFormGroup } from "./form.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    //model: Model = new Model();

    //constructor(public model: Model) { }

    //addProduct(p: Product) {
    //    this.model.saveProduct(p);
    //}
}

Listing 19-25.Removing the Model Object from the component.ts File in the src/app Folder

修改后的根组件类没有定义任何功能,现在只在其模板中提供顶级应用内容。清单 19-26 显示了根模板中的相应变化,删除了定制事件绑定和输入属性。

<div class="row m-2">
  <div class="col-4 p-2">
    <paProductForm></paProductForm>
  </div>
  <div class="col-8 p-2">
    <paProductTable></paProductTable>
  </div>
</div>

Listing 19-26.Removing the Data Bindings in the template.html File in the src/app Folder

更新子组件

为创建新的Product对象提供表单的组件依赖于根组件来处理它的定制事件和更新模型。如果没有这种支持,组件现在必须声明一个Model依赖并自己执行更新,如清单 19-27 所示。

import { Component, Output, EventEmitter, ViewEncapsulation } from "@angular/core";
import { Product } from "./product.model";
import { Model } from "./repository.model";

@Component({
    selector: "paProductForm",
    templateUrl: "productForm.component.html"
})
export class ProductFormComponent {
    newProduct: Product = new Product();

    constructor(private model: Model) { }

    // @Output("paNewProduct")
    // newProductEvent = new EventEmitter<Product>();

    submitForm(form: any) {
        //this.newProductEvent.emit(this.newProduct);
        this.model.saveProduct(this.newProduct);
        this.newProduct = new Product();
        form.reset();
    }
}

Listing 19-27.Working with the Model in the productForm.component.ts File in the src/app Folder

管理产品对象表的组件使用一个输入属性从其父对象接收一个Model对象,但是现在必须通过声明一个构造函数依赖来直接获得它,如清单 19-28 所示。

import { Component, Input, ViewChildren, QueryList } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";
import { DiscountService } from "./discount.service";

@Component({
    selector: "paProductTable",
    templateUrl: "productTable.component.html"
})
export class ProductTableComponent {
    //discounter: DiscountService = new DiscountService();

    constructor(private dataModel: Model) { }

    // @Input("model")
    // dataModel: Model;

    getProduct(key: number): Product {
        return this.dataModel.getProduct(key);
    }

    getProducts(): Product[] {
        return this.dataModel.getProducts();
    }

    deleteProduct(key: number) {
        this.dataModel.deleteProduct(key);
    }

    taxRate: number = 0;
    dateObject: Date = new Date(2020, 1, 20);
    dateString: string = "2020-02-20T00:00:00.000Z";
    dateNumber: number = 1582156800000;

    selectMap = {
        "Watersports": "stay dry",
        "Soccer": "score goals",
        "other": "have fun"
    }

    numberMap = {
        "=1": "one product",
        "=2": "two products",
        "other": "# products"
    }
}

Listing 19-28.Declaring a Model Dependency in the productTable.component.ts File in the src/app Folder

当保存所有更改并且浏览器重新加载 Angular 应用时,您将在浏览器窗口中看到相同的功能,但是功能的连接方式发生了很大的变化,每个组件通过依赖注入特性获得它需要的共享对象,而不是依赖其父组件来提供。

摘要

在这一章中,我解释了依赖注入可以用来解决的问题,并演示了定义和消费服务的过程。我描述了如何使用服务来增加应用结构的灵活性,以及依赖注入如何使隔离构建块成为可能,从而可以有效地对它们进行单元测试。在下一章中,我将描述 Angular 为服务提供的高级特性。

二十、使用服务供应器

在前一章中,我介绍了服务,并解释了如何使用依赖注入来分发它们。当使用依赖注入时,用于解析依赖的对象由服务提供者创建,通常被称为提供者。在这一章中,我将解释提供者是如何工作的,描述不同类型的提供者,并演示如何在应用的不同部分创建提供者来改变服务的行为方式。表 20-1 将提供者放在上下文中。

表 20-1。

将服务供应器放在上下文中

|

问题

|

回答

|
| --- | --- |
| 它们是什么? | 提供者是在 Angular 第一次需要解决依赖关系时创建服务对象的类。 |
| 它们为什么有用? | 提供者允许创建服务对象来满足应用的需求。最简单的提供者只是创建一个指定类的实例,但是也有其他提供者可以用来定制服务对象的创建和配置方式。 |
| 它们是如何使用的? | 提供者是在 Angular 模块装饰器的providers属性中定义的。它们也可以由组件和指令来定义,以便向它们的子节点提供服务,如“使用本地提供者”一节中所述。 |
| 有什么陷阱或限制吗? | 很容易产生意想不到的行为,尤其是在与本地供应器合作时。如果遇到问题,请检查您创建的本地提供程序的范围,并确保您的依赖项和提供程序使用相同的令牌。 |
| 有其他选择吗? | 许多应用将只需要第十九章中描述的基本依赖注入特性。只有当您无法使用基本功能构建应用,并且对依赖注入有很好的理解时,才应该使用本章中的功能。 |

Why You Should Consider Skipping this Chapter

依赖注入在开发人员中激起了强烈的反应,并使观点两极分化。如果你是依赖注入的新手,还没有形成自己的观点,那么你可能想跳过这一章,只使用我在第十九章中描述的特性。这是因为像我在本章中描述的那些特性正是许多开发人员害怕使用依赖注入并强烈反对使用它的原因。

基本的 Angular 依赖注入特性很容易理解,并且在使应用更容易编写和维护方面有直接和明显的好处。本章中描述的特性提供了对依赖注入工作方式的细粒度控制,但是它们也可能急剧增加 Angular 应用的复杂性,并最终破坏基本特性提供的许多好处。

如果你决定要知道所有的细节,那就继续读下去。但是如果你是依赖注入领域的新手,你可能更愿意跳过这一章,直到你发现第十九章的基本特性没有提供你需要的功能。

表 20-2 总结了本章内容。

表 20-2。

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 改变服务的创建方式 | 使用服务供应器 | 1–3 |
| 使用类指定服务 | 使用类提供程序 | 4–6, 10–13 |
| 为服务定义任意令牌 | 使用InjectionToken类 | 7–9 |
| 使用对象指定服务 | 使用值提供者 | 14–15 |
| 使用函数指定服务 | 使用工厂提供者 | 16–18 |
| 使用一个服务指定另一个服务 | 使用现有的服务供应器 | Nineteen |
| 更改服务的范围 | 使用本地服务供应器 | 20–28 |
| 控制依赖关系的解析 | 使用@Host@Optional@SkipSelf装饰器 | 29–30 |

准备示例项目

就像这本书这一部分的其他章节一样,我将继续处理在第十一章创建的项目,以及最近在第十九章修改的项目。为了准备本章,我在src/app文件夹中添加了一个名为log.service.ts的文件,并用它来定义清单 20-1 中所示的服务。

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

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

export enum LogLevel {
    DEBUG, INFO, ERROR
}

@Injectable()
export class LogService {
    minimumLevel: LogLevel = LogLevel.INFO;

    logInfoMessage(message: string) {
        this.logMessage(LogLevel.INFO, message);
    }

    logDebugMessage(message: string) {
        this.logMessage(LogLevel.DEBUG, message);
    }

    logErrorMessage(message: string) {
        this.logMessage(LogLevel.ERROR, message);
    }

    logMessage(level: LogLevel, message: string) {
        if (level >= this.minimumLevel) {
            console.log(`Message (${LogLevel[level]}): ${message}`);
        }
    }
}

Listing 20-1.The Contents of the log.service.ts File in the src/app Folder

该服务将不同严重性级别的日志消息写入浏览器的 JavaScript 控制台。我将在本章的后面注册并使用这个服务。

创建服务并保存更改后,在example文件夹中运行以下命令,启动 Angular 开发工具:

ng serve

打开一个新的浏览器窗口,导航到http://localhost:4200查看应用,如图 20-1 所示。

img/421542_4_En_20_Fig1_HTML.jpg

图 20-1。

运行示例应用

使用服务供应器

正如我在前面的章节中解释的,类使用它们的构造函数参数来声明对服务的依赖。当 Angular 需要创建该类的新实例时,它会检查构造函数,并使用内置和自定义服务的组合来解析每个参数。清单 20-2 更新了DiscountService类,使其依赖于前一节中创建的LogService类。

import { Injectable } from "@angular/core";
import { LogService } from "./log.service";

@Injectable()
export class DiscountService {
    private discountValue: number = 10;

    constructor(private logger: LogService) { }

    public get discount(): number {
        return this.discountValue;
    }

    public set discount(newValue: number) {
        this.discountValue = newValue || 0;
    }

    public applyDiscount(price: number) {
        this.logger.logInfoMessage(`Discount ${this.discount}`
            + ` applied to price: ${price}`);
        return Math.max(price - this.discountValue, 5);
    }
}

Listing 20-2.Creating a Dependency in the discount.service.ts File in the src/app Folder

清单 20-2 中的变化阻止了应用的运行。Angular 处理 HTML 文档并开始创建组件的层次结构,每个组件都有需要指令和数据绑定的模板,它遇到依赖于DiscountService类的类。但是它不能创建DiscountService的实例,因为它的构造函数需要一个LogService对象,而且它不知道如何处理这个类。

当您保存清单 20-2 中的更改时,您将在浏览器的 JavaScript 控制台中看到类似这样的错误:

NullInjectorError: No provider for LogService!

Angular 将创建依赖注入所需对象的责任委托给提供者,每个提供者管理一种类型的依赖。当它需要创建一个DiscountService类的实例时,它会寻找一个合适的提供者来解析LogService依赖关系。由于没有这样的提供者,Angular 无法创建启动应用所需的对象并报告错误。

创建提供者最简单的方法是将服务类添加到分配给 Angular 模块的providers属性的数组中,如清单 20-3 所示。(我借此机会删除了本模块中不再需要的一些陈述。)

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";
import { PaStructureDirective } from "./structure.directive";
import { PaIteratorDirective } from "./iterator.directive";
import { PaCellColor } from "./cellColor.directive";
import { PaCellColorSwitcher } from "./cellColorSwitcher.directive";
import { ProductTableComponent } from "./productTable.component";
import { ProductFormComponent } from "./productForm.component";
import { PaToggleView } from "./toggleView.component";
import { PaAddTaxPipe } from "./addTax.pipe";
import { PaCategoryFilterPipe } from "./categoryFilter.pipe";
import { LOCALE_ID } from "@angular/core";
import localeFr from '@angular/common/locales/fr';
import { registerLocaleData } from '@angular/common';
import { PaDiscountDisplayComponent } from "./discountDisplay.component";
import { PaDiscountEditorComponent } from "./discountEditor.component";
import { DiscountService } from "./discount.service";
import { PaDiscountPipe } from "./discount.pipe";
import { PaDiscountAmountDirective } from "./discountAmount.directive";
import { SimpleDataSource } from "./datasource.model";
import { Model } from "./repository.model";
import { LogService } from "./log.service";

registerLocaleData(localeFr);

@NgModule({
  imports: [BrowserModule, FormsModule, ReactiveFormsModule],
  declarations: [ProductComponent, PaAttrDirective, PaModel,
    PaStructureDirective, PaIteratorDirective,
    PaCellColor, PaCellColorSwitcher, ProductTableComponent,
    ProductFormComponent, PaToggleView, PaAddTaxPipe,
    PaCategoryFilterPipe, PaDiscountDisplayComponent, PaDiscountEditorComponent,
    PaDiscountPipe, PaDiscountAmountDirective],
    providers: [DiscountService, SimpleDataSource, Model, LogService],
  bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 20-3.Creating a Provider in the app.module.ts File in the src/app Folder

当您保存更改时,您将已经定义了 Angular 处理LogService依赖项所需的提供者,并且您将在浏览器的 JavaScript 控制台中看到如下所示的消息:

Message (INFO): Discount 10 applied to price: 16

您可能想知道为什么清单 20-3 中的配置步骤是必需的。毕竟,Angular 可以假设它应该在第一次需要时创建一个新的LogService对象。

事实上,Angular 提供了一系列不同的提供者,每个提供者都以不同的方式创建对象,让您控制服务创建过程。表 20-3 描述了一组可用的提供者,这些提供者将在下面的章节中描述。

表 20-3。

Angular 提供者

|

名字

|

描述

|
| --- | --- |
| 类别提供者 | 此提供程序是使用类配置的。对服务的依赖由 Angular 创建的类的实例来解决。 |
| 价值提供者 | 此提供程序是使用对象配置的,该对象用于解析对服务的依赖关系。 |
| 工厂供应商 | 此提供程序是使用函数配置的。使用通过调用函数创建的对象来解析对服务的依赖。 |
| 现有服务供应器 | 此提供程序是使用另一个服务的名称配置的,并允许为服务创建别名。 |

使用类提供程序

这个提供者是最常用的,也是我通过在清单 20-3 中向模块的providers属性添加类名而应用的。这个清单展示了速记语法,还有一个文字语法可以达到同样的结果,如清单 20-4 所示。

...
@NgModule({
  imports: [BrowserModule, FormsModule, ReactiveFormsModule],
  declarations: [ProductComponent, PaAttrDirective, PaModel,
    PaStructureDirective, PaIteratorDirective,
    PaCellColor, PaCellColorSwitcher, ProductTableComponent,
    ProductFormComponent, PaToggleView, PaAddTaxPipe,
    PaCategoryFilterPipe, PaDiscountDisplayComponent, PaDiscountEditorComponent,
    PaDiscountPipe, PaDiscountAmountDirective],
    providers: [DiscountService, SimpleDataSource, Model,
      { provide: LogService, useClass: LogService }],
  bootstrap: [ProductComponent]
})
...

Listing 20-4.Using the Class Provider Literal Syntax in the app.module.ts File in the src/app Folder

提供者被定义为类,但是可以使用 JavaScript 对象文字格式来指定和配置它们,如下所示:

...
{ provide: LogService, useClass: LogService }
...

类提供者支持三个属性,这些属性在表 20-4 中描述,并在下面的章节中解释。

表 20-4。

类提供程序的属性

|

名字

|

描述

|
| --- | --- |
| provide | 此属性用于指定标记,该标记用于标识将被解析的提供程序和依赖项。请参见“理解令牌”一节。 |
| useClass | 此属性用于指定将由提供程序实例化以解析依赖关系的类。请参见“了解 useClass 属性”一节。 |
| multi | 该属性可用于提供一组服务对象来解析依赖关系。请参阅“解析多个对象的依赖关系”一节。 |

了解令牌

所有提供者都依赖于一个令牌,Angular 使用这个令牌来标识提供者可以解析的依赖关系。最简单的方法是使用一个类作为令牌,这就是我在清单 20-4 中所做的。但是,您可以使用任何对象作为令牌,这允许将依赖项和对象的类型分开。这有助于增加依赖注入配置的灵活性,因为它允许提供程序提供不同类型的对象,这对于本章后面介绍的一些更高级的提供程序很有用。举个简单的例子,清单 20-5 使用类提供者来注册在本章开始时创建的日志服务,使用一个字符串作为令牌,而不是一个类。

...
@NgModule({
  imports: [BrowserModule, FormsModule, ReactiveFormsModule],
  declarations: [ProductComponent, PaAttrDirective, PaModel,
    PaStructureDirective, PaIteratorDirective,
    PaCellColor, PaCellColorSwitcher, ProductTableComponent,
    ProductFormComponent, PaToggleView, PaAddTaxPipe,
    PaCategoryFilterPipe, PaDiscountDisplayComponent, PaDiscountEditorComponent,
    PaDiscountPipe, PaDiscountAmountDirective],
    providers: [DiscountService, SimpleDataSource, Model,
      { provide: "logger", useClass: LogService }],
  bootstrap: [ProductComponent]
})
...

Listing 20-5.Registering a Service with a Token in the app.module.ts File in the src/app Folder

在清单中,新提供者的provide属性被设置为logger。Angular 将自动匹配其令牌是一个类的提供者,但是它需要一些其他令牌类型的额外帮助。清单 20-6 显示了更新后的DiscountService类对日志服务的依赖,使用logger令牌访问。

import { Injectable, Inject } from "@angular/core";
import { LogService } from "./log.service";

@Injectable()
export class DiscountService {
    private discountValue: number = 10;

    constructor(@Inject("logger") private logger: LogService) { }

    public get discount(): number {
        return this.discountValue;
    }

    public set discount(newValue: number) {
        this.discountValue = newValue || 0;
    }

    public applyDiscount(price: number) {
        this.logger.logInfoMessage(`Discount ${this.discount}`
            + ` applied to price: ${price}`);
        return Math.max(price - this.discountValue, 5);
    }
}

Listing 20-6.Using a String Provider Token in the discount.service.ts File in the src/app Folder

@Inject decorator 应用于构造函数参数,用于指定应该用来解析依赖关系的令牌。当 Angular 需要创建一个DiscountService类的实例时,它将检查构造函数并使用@Inject装饰器参数来选择将用于解析依赖关系的提供者,解析对LogService类的依赖关系。

使用不透明令牌

当使用简单类型作为提供者标记时,应用的两个不同部分可能会尝试使用同一个标记来标识不同的服务,这意味着可能会使用错误的对象类型来解析依赖关系并导致错误。

为了帮助解决这个问题,Angular 提供了InjectionToken类,该类提供了一个围绕string值的对象包装器,可以用来创建唯一的令牌值。在清单 20-7 中,我使用了InjectionToken类来创建一个令牌,该令牌将用于标识对LogService类的依赖。

import { Injectable, InjectionToken } from "@angular/core";

export const LOG_SERVICE = new InjectionToken("logger");

export enum LogLevel {
    DEBUG, INFO, ERROR
}

@Injectable()
export class LogService {
    minimumLevel: LogLevel = LogLevel.INFO;

    // ...methods omitted for brevity...
}

Listing 20-7.Using the InjectionToken Class in the log.service.ts File in the src/app Folder

InjectionToken类的构造函数接受一个描述服务的string值,但是将成为令牌的是InjectionToken对象。依赖项必须在用于在模块中创建提供程序的同一个InjectionToken上声明;这就是使用const关键字创建令牌的原因,它可以防止对象被修改。清单 20-8 显示了使用新令牌的提供者配置。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";
import { PaStructureDirective } from "./structure.directive";
import { PaIteratorDirective } from "./iterator.directive";
import { PaCellColor } from "./cellColor.directive";
import { PaCellColorSwitcher } from "./cellColorSwitcher.directive";
import { ProductTableComponent } from "./productTable.component";
import { ProductFormComponent } from "./productForm.component";
import { PaToggleView } from "./toggleView.component";
import { PaAddTaxPipe } from "./addTax.pipe";
import { PaCategoryFilterPipe } from "./categoryFilter.pipe";
import { LOCALE_ID } from "@angular/core";
import localeFr from '@angular/common/locales/fr';
import { registerLocaleData } from '@angular/common';
import { PaDiscountDisplayComponent } from "./discountDisplay.component";
import { PaDiscountEditorComponent } from "./discountEditor.component";
import { DiscountService } from "./discount.service";
import { PaDiscountPipe } from "./discount.pipe";
import { PaDiscountAmountDirective } from "./discountAmount.directive";
import { SimpleDataSource } from "./datasource.model";
import { Model } from "./repository.model";
import { LogService, LOG_SERVICE } from "./log.service";

registerLocaleData(localeFr);

@NgModule({
  imports: [BrowserModule, FormsModule, ReactiveFormsModule],
  declarations: [ProductComponent, PaAttrDirective, PaModel,
    PaStructureDirective, PaIteratorDirective,
    PaCellColor, PaCellColorSwitcher, ProductTableComponent,
    ProductFormComponent, PaToggleView, PaAddTaxPipe,
    PaCategoryFilterPipe, PaDiscountDisplayComponent, PaDiscountEditorComponent,
    PaDiscountPipe, PaDiscountAmountDirective],
    providers: [DiscountService, SimpleDataSource, Model,
      { provide: LOG_SERVICE, useClass: LogService }],
  bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 20-8.Creating a Provider Using an InjectionToken in the app.module.ts File in the src/app Folder

最后,清单 20-9 展示了更新后的DiscountService类,使用InjectionToken而不是string来声明一个依赖项。

import { Injectable, Inject } from "@angular/core";
import { LogService, LOG_SERVICE } from "./log.service";

@Injectable()
export class DiscountService {
    private discountValue: number = 10;

    constructor( @Inject(LOG_SERVICE) private logger: LogService) { }

    public get discount(): number {
        return this.discountValue;
    }

    public set discount(newValue: number) {
        this.discountValue = newValue || 0;
    }

    public applyDiscount(price: number) {
        this.logger.logInfoMessage(`Discount ${this.discount}`
            + ` applied to price: ${price}`);
        return Math.max(price - this.discountValue, 5);
    }
}

Listing 20-9.Declaring a Dependency in the discount.service.ts File in the src/app Folder

应用提供的功能没有区别,但是使用InjectionToken意味着服务之间不会混淆。

了解使用类别属性

类提供者的useClass属性指定了将被实例化以解析依赖关系的类。提供者可以配置任何类,这意味着您可以通过更改提供者配置来更改服务的实现。应谨慎使用此功能,因为服务对象的接收方需要特定的类型,并且在应用在浏览器中运行之前,不匹配不会导致错误。(TypeScript 类型强制对依赖项注入没有任何影响,因为它发生在运行时类型批注被 TypeScript 编译器处理之后。)

改变类最常见的方法是使用不同的子类。在清单 20-10 中,我扩展了LogService类来创建一个服务,该服务在浏览器的 JavaScript 控制台中编写不同格式的消息。

import { Injectable, InjectionToken } from "@angular/core";

export const LOG_SERVICE = new InjectionToken("logger");

export enum LogLevel {
    DEBUG, INFO, ERROR
}

@Injectable()
export class LogService {
    minimumLevel: LogLevel = LogLevel.INFO;

    logInfoMessage(message: string) {
        this.logMessage(LogLevel.INFO, message);
    }

    logDebugMessage(message: string) {
        this.logMessage(LogLevel.DEBUG, message);
    }

    logErrorMessage(message: string) {
        this.logMessage(LogLevel.ERROR, message);
    }

    logMessage(level: LogLevel, message: string) {
        if (level >= this.minimumLevel) {
            console.log(`Message (${LogLevel[level]}): ${message}`);
        }
    }
}

@Injectable()
export class SpecialLogService extends LogService {

    constructor() {
        super()
        this.minimumLevel = LogLevel.DEBUG;
    }

    logMessage(level: LogLevel, message: string) {
        if (level >= this.minimumLevel) {
            console.log(`Special Message (${LogLevel[level]}): ${message}`);
        }
    }
}

Listing 20-10.Creating a Subclassed Service in the log.service.ts File in the src/app Folder

SpecialLogService类扩展了LogService并提供了自己的logMessage方法的实现。清单 20-11 更新了提供者配置,因此useClass属性指定了新服务。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";
import { PaStructureDirective } from "./structure.directive";
import { PaIteratorDirective } from "./iterator.directive";
import { PaCellColor } from "./cellColor.directive";
import { PaCellColorSwitcher } from "./cellColorSwitcher.directive";
import { ProductTableComponent } from "./productTable.component";
import { ProductFormComponent } from "./productForm.component";
import { PaToggleView } from "./toggleView.component";
import { PaAddTaxPipe } from "./addTax.pipe";
import { PaCategoryFilterPipe } from "./categoryFilter.pipe";
import { LOCALE_ID } from "@angular/core";
import localeFr from '@angular/common/locales/fr';
import { registerLocaleData } from '@angular/common';
import { PaDiscountDisplayComponent } from "./discountDisplay.component";
import { PaDiscountEditorComponent } from "./discountEditor.component";
import { DiscountService } from "./discount.service";
import { PaDiscountPipe } from "./discount.pipe";
import { PaDiscountAmountDirective } from "./discountAmount.directive";
import { SimpleDataSource } from "./datasource.model";
import { Model } from "./repository.model";
import { LogService, LOG_SERVICE, SpecialLogService } from "./log.service";

registerLocaleData(localeFr);

@NgModule({
  imports: [BrowserModule, FormsModule, ReactiveFormsModule],
  declarations: [ProductComponent, PaAttrDirective, PaModel,
    PaStructureDirective, PaIteratorDirective,
    PaCellColor, PaCellColorSwitcher, ProductTableComponent,
    ProductFormComponent, PaToggleView, PaAddTaxPipe,
    PaCategoryFilterPipe, PaDiscountDisplayComponent, PaDiscountEditorComponent,
    PaDiscountPipe, PaDiscountAmountDirective],
    providers: [DiscountService, SimpleDataSource, Model,
      { provide: LOG_SERVICE, useClass: SpecialLogService }],
  bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 20-11.Configuring the Provider in the app.module.ts File in the src/app Folder

令牌和类的组合意味着对LOG_SERVICE不透明令牌的依赖将使用SpecialLogService对象来解析。保存更改时,您将在浏览器的 JavaScript 控制台中看到类似这样的消息,表明派生服务已被使用:

Special Message (INFO): Discount 10 applied to price: 275

当设置useClass属性来指定依赖类期望的类型时,必须小心。指定子类是最安全的选择,因为基类的功能保证可用。

解析具有多个对象的依赖关系

可以将类提供程序配置为提供一组对象来解决依赖关系,如果您希望提供一组配置方式不同的相关服务,这将非常有用。为了提供一个数组,使用同一个令牌配置多个类提供者,并将multi属性设置为true,如清单 20-12 所示。

...
@NgModule({
  imports: [BrowserModule, FormsModule, ReactiveFormsModule],
  declarations: [ProductComponent, PaAttrDirective, PaModel,
    PaStructureDirective, PaIteratorDirective,
    PaCellColor, PaCellColorSwitcher, ProductTableComponent,
    ProductFormComponent, PaToggleView, PaAddTaxPipe,
    PaCategoryFilterPipe, PaDiscountDisplayComponent, PaDiscountEditorComponent,
    PaDiscountPipe, PaDiscountAmountDirective],
    providers: [DiscountService, SimpleDataSource, Model,
      { provide: LOG_SERVICE, useClass: LogService, multi: true },
      { provide: LOG_SERVICE, useClass: SpecialLogService, multi: true }],
  bootstrap: [ProductComponent]
})
...

Listing 20-12.Configuring Multiple Service Objects in the app.module.ts File in the src/app Folder

Angular 依赖注入系统将通过创建LogServiceSpecialLogService对象,将它们放在一个数组中,并将它们传递给依赖类的构造函数,来解析对LOG_SERVICE标记的依赖。接收服务的类必须期待一个数组,如清单 20-13 所示。

import { Injectable, Inject } from "@angular/core";
import { LogService, LOG_SERVICE, LogLevel } from "./log.service";

@Injectable()
export class DiscountService {
    private discountValue: number = 10;
    private logger: LogService;

    constructor( @Inject(LOG_SERVICE) loggers: LogService[]) {
        this.logger = loggers.find(l => l.minimumLevel == LogLevel.DEBUG);
    }

    public get discount(): number {
        return this.discountValue;
    }

    public set discount(newValue: number) {
        this.discountValue = newValue || 0;
    }

    public applyDiscount(price: number) {
        this.logger.logInfoMessage(`Discount ${this.discount}`
            + ` applied to price: ${price}`);
        return Math.max(price - this.discountValue, 5);
    }
}

Listing 20-13.Receiving Multiple Services in the discount.service.ts File in the src/app Folder

构造函数以数组的形式接收服务,它使用数组的find方法定位第一个minimumLevel属性为LogLevel.Debug的记录器,并将其分配给logger属性。applyDiscount方法调用服务的logDebugMessage方法,这导致类似这样的消息显示在浏览器的 JavaScript 控制台中:

Special Message (INFO): Discount 10 applied to price: 275

使用值提供者

当您想自己负责创建服务对象,而不是将它留给类提供者时,可以使用值提供者。当服务是简单类型时,例如stringnumber值,这也是有用的,这是提供对公共配置设置的访问的有用方式。值提供者可使用文字对象应用,并支持表 20-5 中描述的属性。

表 20-5。

值提供者属性

|

名字

|

描述

|
| --- | --- |
| provide | 该属性定义服务令牌,如本章前面的“理解令牌”一节所述。 |
| useValue | 此属性指定将用于解析依赖关系的对象。 |
| multi | 此属性用于允许组合多个提供程序来提供一个对象数组,这些对象将用于解析对令牌的依赖关系。有关示例,请参见本章前面的“解决多个对象的依赖关系”一节。 |

值提供程序的工作方式与类提供程序相同,只是它是用对象而不是类型配置的。清单 20-14 展示了如何使用值提供者来创建一个配置了特定属性值的LogService类的实例。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";
import { PaStructureDirective } from "./structure.directive";
import { PaIteratorDirective } from "./iterator.directive";
import { PaCellColor } from "./cellColor.directive";
import { PaCellColorSwitcher } from "./cellColorSwitcher.directive";
import { ProductTableComponent } from "./productTable.component";
import { ProductFormComponent } from "./productForm.component";
import { PaToggleView } from "./toggleView.component";
import { PaAddTaxPipe } from "./addTax.pipe";
import { PaCategoryFilterPipe } from "./categoryFilter.pipe";
import { LOCALE_ID } from "@angular/core";
import localeFr from '@angular/common/locales/fr';
import { registerLocaleData } from '@angular/common';
import { PaDiscountDisplayComponent } from "./discountDisplay.component";
import { PaDiscountEditorComponent } from "./discountEditor.component";
import { DiscountService } from "./discount.service";
import { PaDiscountPipe } from "./discount.pipe";
import { PaDiscountAmountDirective } from "./discountAmount.directive";
import { SimpleDataSource } from "./datasource.model";
import { Model } from "./repository.model";
import { LogService, LOG_SERVICE, SpecialLogService, LogLevel } from "./log.service";

let logger = new LogService();
logger.minimumLevel = LogLevel.DEBUG;

registerLocaleData(localeFr);

@NgModule({
  imports: [BrowserModule, FormsModule, ReactiveFormsModule],
  declarations: [ProductComponent, PaAttrDirective, PaModel,
    PaStructureDirective, PaIteratorDirective,
    PaCellColor, PaCellColorSwitcher, ProductTableComponent,
    ProductFormComponent, PaToggleView, PaAddTaxPipe,
    PaCategoryFilterPipe, PaDiscountDisplayComponent, PaDiscountEditorComponent,
    PaDiscountPipe, PaDiscountAmountDirective],
    providers: [DiscountService, SimpleDataSource, Model,
      { provide: LogService, useValue: logger }],
  bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 20-14.Using the Value Provider in the app.module.ts File in the src/app Folder

这个值提供者被配置为解析在模块类之外创建和配置的特定对象对LogService标记的依赖性。

值提供者——实际上是所有的提供者——可以使用任何对象作为标记,如前一节所述,但我还是回到了使用类型作为标记,因为这是最常用的技术,而且它与 TypeScript 构造函数参数类型化配合得非常好。清单 20-15 显示了对DiscountService的相应更改,它使用类型化的构造函数参数声明了一个依赖项。

import { Injectable, Inject } from "@angular/core";
import { LogService, LOG_SERVICE, LogLevel } from "./log.service";

@Injectable()
export class DiscountService {
    private discountValue: number = 10;

    constructor(private logger: LogService) { }

    public get discount(): number {
        return this.discountValue;
    }

    public set discount(newValue: number) {
        this.discountValue = newValue || 0;
    }

    public applyDiscount(price: number) {
        this.logger.logInfoMessage(`Discount ${this.discount}`
            + ` applied to price: ${price}`);
        return Math.max(price - this.discountValue, 5);
    }
}

Listing 20-15.Declaring a Dependency Using a Type in the discount.service.ts File in the src/app Folder

使用工厂提供程序

工厂提供者使用函数来创建解析依赖关系所需的对象。该提供程序支持表 20-6 中描述的属性。

表 20-6。

工厂提供者属性

|

名字

|

描述

|
| --- | --- |
| provide | 该属性定义服务令牌,如本章前面的“理解令牌”一节所述。 |
| deps | 该属性指定了一个提供者标记数组,该数组将被解析并传递给由useFactory属性指定的函数。 |
| useFactory | 此属性指定将创建服务对象的函数。解析由deps属性指定的令牌所产生的对象将作为参数传递给函数。函数返回的结果将被用作服务对象。 |
| multi | 此属性用于允许组合多个提供程序来提供一个对象数组,这些对象将用于解析对令牌的依赖关系。有关示例,请参见本章前面的“解决多个对象的依赖关系”一节。 |

这是在如何创建服务对象方面提供最大灵活性的提供者,因为您可以定义适合您的应用需求的函数。清单 20-16 显示了一个创建LogService对象的工厂函数。

...
@NgModule({
  imports: [BrowserModule, FormsModule, ReactiveFormsModule],
  declarations: [ProductComponent, PaAttrDirective, PaModel,
    PaStructureDirective, PaIteratorDirective,
    PaCellColor, PaCellColorSwitcher, ProductTableComponent,
    ProductFormComponent, PaToggleView, PaAddTaxPipe,
    PaCategoryFilterPipe, PaDiscountDisplayComponent, PaDiscountEditorComponent,
    PaDiscountPipe, PaDiscountAmountDirective],
    providers: [DiscountService, SimpleDataSource, Model,
      {
          provide: LogService, useFactory: () => {
              let logger = new LogService();
              logger.minimumLevel = LogLevel.DEBUG;
              return logger;
          }
      }],
  bootstrap: [ProductComponent]
})
...

Listing 20-16.Using the Factory Provider in the app.module.ts File in the src/app Folder

这个例子中的函数很简单:它不接收任何参数,只创建一个新的LogService对象。当使用deps属性时,这个提供者真正的灵活性就来了,它允许在其他服务上创建依赖关系。在清单 20-17 中,我定义了一个指定调试级别的令牌。

import { Injectable, InjectionToken } from "@angular/core";

export const LOG_SERVICE = new InjectionToken("logger");
export const LOG_LEVEL = new InjectionToken("log_level");

export enum LogLevel {
    DEBUG, INFO, ERROR
}

@Injectable()
export class LogService {
    minimumLevel: LogLevel = LogLevel.INFO;

    // ...methods omitted for brevity...
}

@Injectable()
export class SpecialLogService extends LogService {

    // ...methods omitted for brevity...
}

Listing 20-17.Defining a Logging-Level Service in the log.service.ts File in the src/app Folder

在清单 20-18 中,我定义了一个使用LOG_LEVEL令牌创建服务的值提供者,并在创建LogService对象的工厂函数中使用该服务。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";
import { PaStructureDirective } from "./structure.directive";
import { PaIteratorDirective } from "./iterator.directive";
import { PaCellColor } from "./cellColor.directive";
import { PaCellColorSwitcher } from "./cellColorSwitcher.directive";
import { ProductTableComponent } from "./productTable.component";
import { ProductFormComponent } from "./productForm.component";
import { PaToggleView } from "./toggleView.component";
import { PaAddTaxPipe } from "./addTax.pipe";
import { PaCategoryFilterPipe } from "./categoryFilter.pipe";
import { LOCALE_ID } from "@angular/core";
import localeFr from '@angular/common/locales/fr';
import { registerLocaleData } from '@angular/common';
import { PaDiscountDisplayComponent } from "./discountDisplay.component";
import { PaDiscountEditorComponent } from "./discountEditor.component";
import { DiscountService } from "./discount.service";
import { PaDiscountPipe } from "./discount.pipe";
import { PaDiscountAmountDirective } from "./discountAmount.directive";
import { SimpleDataSource } from "./datasource.model";
import { Model } from "./repository.model";
import { LogService, LOG_SERVICE, SpecialLogService,
  LogLevel, LOG_LEVEL} from "./log.service";

let logger = new LogService();
logger.minimumLevel = LogLevel.DEBUG;

registerLocaleData(localeFr);

@NgModule({
  imports: [BrowserModule, FormsModule, ReactiveFormsModule],
  declarations: [ProductComponent, PaAttrDirective, PaModel,
    PaStructureDirective, PaIteratorDirective,
    PaCellColor, PaCellColorSwitcher, ProductTableComponent,
    ProductFormComponent, PaToggleView, PaAddTaxPipe,
    PaCategoryFilterPipe, PaDiscountDisplayComponent, PaDiscountEditorComponent,
    PaDiscountPipe, PaDiscountAmountDirective],
    providers: [DiscountService, SimpleDataSource, Model,
      { provide: LOG_LEVEL, useValue: LogLevel.DEBUG },
      { provide: LogService,
        deps: [LOG_LEVEL],
        useFactory: (level) => {
          let logger = new LogService();
          logger.minimumLevel = level;
          return logger;
       }}],
  bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 20-18.Using Factory Dependencies in the app.module.ts File in the src/app Folder

值提供者使用LOG_LEVEL标记将简单值定义为服务。工厂提供者在其deps数组中指定该令牌,依赖注入系统解析该令牌并将其作为参数提供给工厂函数,工厂函数使用它来设置新LogService对象的minimumLevel属性。

使用现有的服务供应器

该提供程序用于为服务创建别名,以便可以使用多个令牌将它们作为目标,使用表 20-7 中描述的属性。

表 20-7。

现有的提供程序属性

|

名字

|

描述

|
| --- | --- |
| provide | 该属性定义服务令牌,如本章前面的“理解令牌”一节所述。 |
| useExisting | 此属性用于指定另一个提供程序的令牌,该提供程序的服务对象将用于解析对此服务的依赖关系。 |
| multi | 此属性用于允许组合多个提供程序来提供一个对象数组,这些对象将用于解析对令牌的依赖关系。有关示例,请参见本章前面的“解决多个对象的依赖关系”一节。 |

当您想要重构一组提供程序,但不想消除所有过时的标记以避免重构应用的其余部分时,此提供程序会很有用。清单 20-19 展示了这个提供者的用法。

...
@NgModule({
  imports: [BrowserModule, FormsModule, ReactiveFormsModule],
  declarations: [ProductComponent, PaAttrDirective, PaModel,
    PaStructureDirective, PaIteratorDirective,
    PaCellColor, PaCellColorSwitcher, ProductTableComponent,
    ProductFormComponent, PaToggleView, PaAddTaxPipe,
    PaCategoryFilterPipe, PaDiscountDisplayComponent, PaDiscountEditorComponent,
    PaDiscountPipe, PaDiscountAmountDirective],
    providers: [DiscountService, SimpleDataSource, Model,
      { provide: LOG_LEVEL, useValue: LogLevel.DEBUG },
      { provide: "debugLevel", useExisting: LOG_LEVEL },
      { provide: LogService,
        deps: ["debugLevel"],
        useFactory: (level) => {
          let logger = new LogService();
          logger.minimumLevel = level;
          return logger;
       }}],
  bootstrap: [ProductComponent]
})
...

Listing 20-19.Creating a Service Alias in the app.module.ts File in the src/app Folder

新服务的令牌是字符串debugLevel,它用LOG_LEVEL令牌作为提供者的别名。使用任何一个标记都将导致依赖项被解析为相同的值。

使用本地供应器

当 Angular 创建一个类的新实例时,它使用一个注入器来解析任何依赖关系。它是一个注入器,负责检查类的构造函数,以确定已经声明了哪些依赖项,并使用可用的提供程序来解析它们。

到目前为止,所有的依赖注入示例都依赖于在应用的 Angular 模块中配置的提供者。但是 Angular 依赖注入系统更复杂:有一个对应于应用的组件和指令树的注入器层次结构。每个组件和指令都可以有自己的注入器,每个注入器都可以配置自己的一组提供者,称为本地提供者

当存在要解决的从属关系时,Angular 将注射器用于最近的组件或指令。注入器首先尝试使用自己的一组本地提供者来解决依赖关系。如果没有设置本地提供程序,或者没有可用于解析此特定依赖关系的提供程序,则注入器会咨询父组件的注入器。重复这个过程——父组件的注入器试图使用它自己的一组本地提供者来解决依赖关系。如果有合适的提供者可用,则使用它来提供解决依赖性所需的服务对象。如果没有合适的提供者,那么请求将被传递到层次结构中的下一级,传递给原始注入者的祖父级。层次结构的顶部是根 Angular 模块,其提供者是报告错误之前的最后手段。

在 Angular 模块中定义提供者意味着应用中某个令牌的所有依赖项都将使用同一个对象来解析。正如我在下面几节中解释的那样,在注入器层次结构的更底层注册提供者可以改变这种行为,并改变创建和使用服务的方式。

了解单个服务对象的局限性

使用单个服务对象可能是一个强大的工具,允许应用不同部分中的构建块共享数据和响应用户交互。但有些服务并不适合如此广泛地共享。举个简单的例子,清单 20-20 向第十八章中创建的管道之一添加了对LogService的依赖。

import { Pipe, Injectable } from "@angular/core";
import { DiscountService } from "./discount.service";
import { LogService } from "./log.service";

@Pipe({
    name: "discount",
    pure: false
})
export class PaDiscountPipe {

    constructor(private discount: DiscountService,
                private logger: LogService) { }

    transform(price: number): number {
        if (price > 100) {
            this.logger.logInfoMessage(`Large price discounted: ${price}`);
        }
        return this.discount.applyDiscount(price);
    }
}

Listing 20-20.Adding a Service Dependency in the discount.pipe.ts File in the src/app Folder

管道的转换方法使用作为构造函数参数接收的LogService对象,当它转换的price值大于 100 时,生成日志消息。

问题是这些日志消息被由DiscountService对象生成的消息淹没了,每次应用折扣时它都会创建一条消息。显而易见的是,当模块提供者的工厂函数创建LogService对象时,要改变它的最低级别,如清单 20-21 所示。

...
@NgModule({
  imports: [BrowserModule, FormsModule, ReactiveFormsModule],
  declarations: [ProductComponent, PaAttrDirective, PaModel,
    PaStructureDirective, PaIteratorDirective,
    PaCellColor, PaCellColorSwitcher, ProductTableComponent,
    ProductFormComponent, PaToggleView, PaAddTaxPipe,
    PaCategoryFilterPipe, PaDiscountDisplayComponent, PaDiscountEditorComponent,
    PaDiscountPipe, PaDiscountAmountDirective],
    providers: [DiscountService, SimpleDataSource, Model,
      { provide: LOG_LEVEL, useValue: LogLevel.ERROR },
      { provide: "debugLevel", useExisting: LOG_LEVEL },
      { provide: LogService,
        deps: ["debugLevel"],
        useFactory: (level) => {
          let logger = new LogService();
          logger.minimumLevel = level;
          return logger;
       }}],
  bootstrap: [ProductComponent]
})
...

Listing 20-21.Changing the Logging Level in the app.module.ts File in the src/app Folder

当然,这并没有达到预期的效果,因为在整个应用中使用了相同的LogService对象,过滤DiscountService消息意味着管道消息也被过滤。

我可以增强LogService类,这样每个日志消息源都有不同的过滤器,但是这很快就变得复杂了。相反,我将通过创建一个本地提供者来解决这个问题,以便在应用中有多个LogService对象,每个对象都可以单独配置。

在组件中创建本地提供程序

组件可以定义本地提供者,这允许应用的一部分创建和使用单独的服务器。组件支持两个装饰器属性来创建本地提供者,如表 20-8 所述。

表 20-8。

本地提供程序的组件装饰器属性

|

名字

|

描述

|
| --- | --- |
| providers | 此属性用于创建用于解析视图和内容子级的依赖关系的提供程序。 |
| viewProviders | 此属性用于创建一个提供程序,该提供程序用于解析视图子级的依赖关系。 |

解决我的LogService问题的最简单方法是使用providers属性建立一个本地提供者,如清单 20-22 所示。

import { Component, Input, ViewChildren, QueryList } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";
import { DiscountService } from "./discount.service";
import { LogService } from "./log.service";

@Component({
    selector: "paProductTable",
    templateUrl: "productTable.component.html",
    providers:[LogService]

})
export class ProductTableComponent {

    constructor(private dataModel: Model) { }

    getProduct(key: number): Product {
        return this.dataModel.getProduct(key);
    }

    getProducts(): Product[] {
        return this.dataModel.getProducts();
    }

    deleteProduct(key: number) {
        this.dataModel.deleteProduct(key);
    }

    taxRate: number = 0;
    dateObject: Date = new Date(2020, 1, 20);
    dateString: string = "2020-02-20T00:00:00.000Z";
    dateNumber: number = 1582156800000;

    selectMap = {
        "Watersports": "stay dry",
        "Soccer": "score goals",
        "other": "have fun"
    }

    numberMap = {
        "=1": "one product",
        "=2": "two products",
        "other": "# products"
    }
}

Listing 20-22.Creating a Local Provider in the productTable.component.ts File in the src/app Folder

当 Angular 需要创建一个新的管道对象时,它会检测对LogService的依赖,并开始沿着应用层次向上工作,检查它找到的每个组件,以确定它们是否有可用于解决依赖的提供者。ProductTableComponent确实有一个LogService提供者,用于创建解析管道依赖性的服务。这意味着现在应用中有两个LogService对象,每个都可以单独配置,如图 20-2 所示。

img/421542_4_En_20_Fig2_HTML.jpg

图 20-2。

创建本地提供程序

由组件提供者创建的LogService对象使用其minimumLevel属性的默认值,并将显示LogLevel.INFO消息。模块创建的LogService对象将用于解析应用中的所有其他依赖项,包括由DiscountService类声明的依赖项,该对象被配置为只显示LogLevel.ERROR消息。当您保存更改时,您将看到来自管道(从组件接收服务)的日志消息,而不是来自DiscountService(从模块接收服务)的日志消息。

了解供应商备选方案

如表 20-8 所述,有两个属性可用于创建本地提供者。为了演示这些属性的不同,我在src/app文件夹中添加了一个名为valueDisplay.directive.ts的文件,并用它来定义清单 20-23 中所示的指令。

import { Directive, InjectionToken, Inject, HostBinding} from "@angular/core";

export const VALUE_SERVICE = new InjectionToken("value_service");

@Directive({
    selector: "[paDisplayValue]"
})
export class PaDisplayValueDirective {

    constructor( @Inject(VALUE_SERVICE) serviceValue: string) {
        this.elementContent = serviceValue;
    }

    @HostBinding("textContent")
    elementContent: string;
}

Listing 20-23.The Contents of the valueDisplay.directive.ts File in the src/app Folder

VALUE_SERVICE opaque 令牌将用于定义基于值的服务,该清单中的指令声明了对该服务的依赖,以便可以在主机元素的内容中显示该服务。清单 20-24 显示了正在定义的服务和在 Angular 模块中注册的指令。为了简洁起见,我还简化了模块中的LogService提供者。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";
import { PaStructureDirective } from "./structure.directive";
import { PaIteratorDirective } from "./iterator.directive";
import { PaCellColor } from "./cellColor.directive";
import { PaCellColorSwitcher } from "./cellColorSwitcher.directive";
import { ProductTableComponent } from "./productTable.component";
import { ProductFormComponent } from "./productForm.component";
import { PaToggleView } from "./toggleView.component";
import { PaAddTaxPipe } from "./addTax.pipe";
import { PaCategoryFilterPipe } from "./categoryFilter.pipe";
import { LOCALE_ID } from "@angular/core";
import localeFr from '@angular/common/locales/fr';
import { registerLocaleData } from '@angular/common';
import { PaDiscountDisplayComponent } from "./discountDisplay.component";
import { PaDiscountEditorComponent } from "./discountEditor.component";
import { DiscountService } from "./discount.service";
import { PaDiscountPipe } from "./discount.pipe";
import { PaDiscountAmountDirective } from "./discountAmount.directive";
import { SimpleDataSource } from "./datasource.model";
import { Model } from "./repository.model";
import { LogService, LOG_SERVICE, SpecialLogService,
  LogLevel, LOG_LEVEL} from "./log.service";
import { VALUE_SERVICE, PaDisplayValueDirective} from "./valueDisplay.directive";

let logger = new LogService();
logger.minimumLevel = LogLevel.DEBUG;

registerLocaleData(localeFr);

@NgModule({
  imports: [BrowserModule, FormsModule, ReactiveFormsModule],
  declarations: [ProductComponent, PaAttrDirective, PaModel,
    PaStructureDirective, PaIteratorDirective,
    PaCellColor, PaCellColorSwitcher, ProductTableComponent,
    ProductFormComponent, PaToggleView, PaAddTaxPipe,
    PaCategoryFilterPipe, PaDiscountDisplayComponent, PaDiscountEditorComponent,
    PaDiscountPipe, PaDiscountAmountDirective, PaDisplayValueDirective],
    providers: [DiscountService, SimpleDataSource, Model, LogService,
      { provide: VALUE_SERVICE, useValue: "Apples" }],
  bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 20-24.Registering the Directive and Service in the app.module.ts File in the src/app Folder

提供者为VALUE_SERVICE服务设置一个值Apples。下一步是应用新的指令,这样一个实例是组件的视图子级,另一个是内容子级。清单 20-25 设置内容子实例。

<div class="row m-2">
  <div class="col-4 p-2">
    <paProductForm>
      <span paDisplayValue></span>
    </paProductForm>
  </div>
  <div class="col-8 p-2">
    <paProductTable></paProductTable>
  </div>
</div>

Listing 20-25.Applying a Content Child Directive in the template.html File in the src/app Folder

清单 20-26 投射主机元素的内容,并添加新指令的视图子实例。

<form novalidate #form="ngForm" (ngSubmit)="submitForm(form)">
    <div class="form-group">
        <label>Name</label>
        <input class="form-control"
            name="name" [(ngModel)]="newProduct.name" />
    </div>
    <div class="form-group">
        <label>Category</label>
        <input class="form-control"
            name="category" [(ngModel)]="newProduct.category" />
    </div>
    <div class="form-group">
        <label>Price</label>
        <input class="form-control"
            name="name" [(ngModel)]="newProduct.price" />
    </div>
    <button class="btn btn-primary" type="submit">
            Create
    </button>
</form>

<div class="bg-info text-white m-2 p-2">
    View Child Value: <span paDisplayValue></span>
</div>
<div class="bg-info text-white m-2 p-2">
    Content Child Value: <ng-content></ng-content>
</div>

Listing 20-26.Adding Directives in the productForm.component.html File in the src/app Folder

当您保存更改时,您将看到新的元素,如图 20-3 所示,两者显示相同的值,因为VALUE_SERVICE的唯一提供者是在模块中定义的。

img/421542_4_En_20_Fig3_HTML.jpg

图 20-3。

查看和内容子指令

为所有孩子创建本地提供程序

@Component decorator 的providers属性用于定义提供者,这些提供者将用于解析所有子元素的服务依赖关系,而不管它们是在模板中定义的(视图子元素)还是从主机元素中投影的(内容子元素)。清单 20-27 在两个新指令实例的父组件中定义了一个VALUE_SERVICE提供者。

import { Component, Output, EventEmitter, ViewEncapsulation } from "@angular/core";
import { Product } from "./product.model";
import { Model } from "./repository.model";
import { VALUE_SERVICE } from "./valueDisplay.directive";

@Component({
    selector: "paProductForm",
    templateUrl: "productForm.component.html",
    providers: [{ provide: VALUE_SERVICE, useValue: "Oranges" }]
})
export class ProductFormComponent {
    newProduct: Product = new Product();

    constructor(private model: Model) { }

    submitForm(form: any) {
        this.model.saveProduct(this.newProduct);
        this.newProduct = new Product();
        form.reset();
    }
}

Listing 20-27.Defining a Provider in the productForm.component.ts File in the src/app Folder

新的供应器改变了服务价值。当 Angular 开始创建新指令的实例时,它通过沿着应用层次向上搜索来开始搜索提供者,并找到清单 20-27 中定义的VALUE_SERVICE提供者。服务值被指令的两个实例使用,如图 20-4 所示。

img/421542_4_En_20_Fig4_HTML.jpg

图 20-4。

为组件中的所有子组件定义提供程序

为视图子级创建提供程序

viewProviders属性定义了用于解析视图子级而非内容子级的依赖关系的提供程序。清单 20-28 使用viewProviders属性为VALUE_SERVICE定义一个提供者。

import { Component, Output, EventEmitter, ViewEncapsulation } from "@angular/core";
import { Product } from "./product.model";
import { Model } from "./repository.model";
import { VALUE_SERVICE } from "./valueDisplay.directive";

@Component({
    selector: "paProductForm",
    templateUrl: "productForm.component.html",
    viewProviders: [{ provide: VALUE_SERVICE, useValue: "Oranges" }]
})
export class ProductFormComponent {
    newProduct: Product = new Product();

    constructor(private model: Model) { }

    submitForm(form: any) {
        this.model.saveProduct(this.newProduct);
        this.newProduct = new Product();
        form.reset();
    }
}

Listing 20-28.Defining a View Child Provider in the productForm.component.ts File in the src/app Folder

Angular 在解析视图子级而不是内容子级的依赖关系时使用提供程序。这意味着子内容的依赖关系在应用的层次结构中向上引用,就好像组件没有定义提供者一样。在本例中,这意味着视图子节点将接收组件提供者创建的服务,内容子节点将接收模块提供者创建的服务,如图 20-5 所示。

img/421542_4_En_20_Fig5_HTML.jpg

图 20-5。

为视图子级定义提供程序

Caution

不支持使用providersviewProviders属性为同一服务定义提供者。如果这样做,视图和内容的孩子都将收到由viewProviders提供者创建的服务。

控制依赖关系解析

Angular 提供了三个装饰器,可以用来提供关于如何解决依赖关系的指令。这些装饰器在表 20-9 中描述,并在以下章节中演示。

表 20-9。

依赖关系解析装饰器

|

名字

|

描述

|
| --- | --- |
| @Host | 这个装饰器将对提供者的搜索限制在最近的组件上。 |
| @Optional | 如果不能解决依赖关系,这个装饰器会阻止 Angular 报告错误。 |
| @SkipSelf | 这个装饰器排除了依赖关系被解析的组件/指令所定义的提供者。 |

限制供应器搜索

@Host decorator 限制对合适的提供者的搜索,以便一旦到达最近的组件就停止搜索。装饰器通常与@Optional结合使用,这样可以防止 Angular 在无法解决服务依赖时抛出异常。清单 20-29 展示了在示例中向指令添加两个装饰器。

import { Directive, InjectionToken, Inject,
         HostBinding, Host, Optional} from "@angular/core";

export const VALUE_SERVICE = new InjectionToken("value_service");

@Directive({
    selector: "[paDisplayValue]"
})
export class PaDisplayValueDirective {

    constructor( @Inject(VALUE_SERVICE) @Host() @Optional() serviceValue: string) {
        this.elementContent = serviceValue || "No Value";
    }

    @HostBinding("textContent")
    elementContent: string;
}

Listing 20-29.Adding Dependency Decorators in the valueDisplay.directive.ts File in the src/app Folder

当使用@Optional decorator 时,您必须确保如果服务不能被解析,类能够运行,在这种情况下,服务的构造函数参数是undefined。最近的组件为它的视图子组件而不是内容子组件定义了一个服务,这意味着该指令的一个实例将接收一个服务对象,而另一个不会,如图 20-6 所示。

img/421542_4_En_20_Fig6_HTML.jpg

图 20-6。

控制如何解决依赖关系

跳过自定义提供程序

默认情况下,组件定义的提供程序用于解析其依赖关系。可以将@SkipSelf decorator 应用于构造函数参数,告诉 Angular 忽略本地提供者,并在应用层次结构的下一级开始搜索,这意味着本地提供者将仅用于解析子元素的依赖关系。在清单 20-30 中,我添加了对用@SkipSelf修饰的VALUE_SERVICE提供者的依赖。

import { Component, Output, EventEmitter, ViewEncapsulation,
    Inject, SkipSelf } from "@angular/core";
import { Product } from "./product.model";
import { Model } from "./repository.model";
import { VALUE_SERVICE } from "./valueDisplay.directive";

@Component({
    selector: "paProductForm",
    templateUrl: "productForm.component.html",
    viewProviders: [{ provide: VALUE_SERVICE, useValue: "Oranges" }]
})
export class ProductFormComponent {
    newProduct: Product = new Product();

    constructor(private model: Model,
            @Inject(VALUE_SERVICE) @SkipSelf() private serviceValue: string) {
        console.log("Service Value: " + serviceValue);
    }

    submitForm(form: any) {
        this.model.saveProduct(this.newProduct);
        this.newProduct = new Product();
        form.reset();
    }
}

Listing 20-30.Skipping Local Providers in the productForm.component.ts File in the src/app Folder

当您保存更改且浏览器重新加载页面时,您将在浏览器的 JavaScript 控制台中看到以下消息,显示本地定义的服务值(Oranges)已被跳过,并允许 Angular 模块解析相关性:

Service Value:pples

摘要

在这一章中,我解释了提供者在依赖注入中扮演的角色,并解释了如何使用它们来改变服务解决依赖的方式。我描述了可用于创建服务对象的不同类型的提供者,并演示了指令和组件如何定义它们自己的提供者来解析它们自己及其子对象的依赖关系。在下一章中,我将描述模块,它是 Angular 应用的最终构建模块。

二十一、使用和创建模块

在这一章中,我将描述最后一个有 Angular 的构件:模块。在本章的第一部分,我描述了根模块,每个 Angular 应用都用它来描述 Angular 应用的配置。在这一章的第二部分,我描述了特性模块,它们被用来给应用增加结构,这样相关的特性就可以被组合成一个单元。表 21-1 将模块放在上下文中。

表 21-1。

将模块放在上下文中

|

问题

|

回答

|
| --- | --- |
| 它们是什么? | 模块向 Angular 提供配置信息。 |
| 它们为什么有用? | 根模块描述了 Angular 的应用,设置了组件和服务等基本功能。特性模块对于向复杂项目添加结构很有用,这使得它们更容易管理和维护。 |
| 它们是如何使用的? | 模块是已经应用了@NgModule装饰器的类。装饰器使用的属性对于根模块和特性模块有不同的含义。 |
| 有什么陷阱或限制吗? | 提供者没有模块范围,这意味着由一个特性模块定义的提供者将是可用的,就像它们是由根模块定义的一样。 |
| 有其他选择吗? | 每个应用都必须有一个根模块,但是功能模块的使用完全是可选的。但是,如果不使用功能模块,应用中的文件可能会变得难以管理。 |

表 21-2 总结了本章内容。

表 21-2。

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 描述应用及其包含的构造块 | 使用根模块 | 1–7 |
| 将相关功能分组在一起 | 创建特征模块 | 8–28 |

准备示例项目

与本书这一部分的其他章节一样,我将使用在第十一章中创建的示例项目,并且此后在每一章中都进行了扩展和扩展。

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

为了准备本章,我已经从组件模板中移除了一些功能。清单 21-1 显示了产品表的模板,其中我注释掉了折扣编辑器和显示组件的元素。

<table class="table table-sm table-bordered table-striped">
    <thead>
        <tr><th></th><th>Name</th><th>Category</th><th>Price</th><th></th></tr>
    </thead>
    <tbody>
        <tr *paFor="let item of getProducts(); let i = index">
            <td>{{i + 1}}</td>
            <td>{{item.name}}</td>
            <td>{{item.category}}</td>
            <td [pa-price]="item.price" #discount="discount">
                {{ discount.discountAmount | currency:"USD":"symbol"}}
            </td>
            <td class="text-center">
                <button class="btn btn-danger btn-sm"
                        (click)="deleteProduct(item.id)">
                    Delete
                </button>
            </td>
        </tr>
    </tbody>
</table>

<!-- <paDiscountEditor></paDiscountEditor> -->
<!-- <paDiscountDisplay></paDiscountDisplay> -->

Listing 21-1.The Contents of the productTable.component.html File in the src/app Folder

清单 21-2 显示了来自产品表单组件的模板,其中我已经注释掉了我在第二十章中用来演示视图子代和内容子代的提供者之间的区别的元素。

<form novalidate #form="ngForm" (ngSubmit)="submitForm(form)">
    <div class="form-group">
        <label>Name</label>
        <input class="form-control"
            name="name" [(ngModel)]="newProduct.name" />
    </div>
    <div class="form-group">
        <label>Category</label>
        <input class="form-control"
            name="category" [(ngModel)]="newProduct.category" />
    </div>
    <div class="form-group">
        <label>Price</label>
        <input class="form-control"
            name="name" [(ngModel)]="newProduct.price" />
    </div>
    <button class="btn btn-primary" type="submit">
            Create
    </button>
</form>

<!-- <div class="bg-info text-white m-2 p-2">
    View Child Value: <span paDisplayValue></span>
</div>
<div class="bg-info text-white m-2 p-2">
    Content Child Value: <ng-content></ng-content>
</div> -->

Listing 21-2.The Contents of the productForm.component.html File in the src/app Folder

example文件夹中运行以下命令,启动 Angular 开发工具:

ng serve

打开一个新的浏览器窗口并导航到http://localhost:4200以查看如图 21-1 所示的内容。

img/421542_4_En_21_Fig1_HTML.jpg

图 21-1。

运行示例应用

了解根模块

每个角都至少有一个模块,称为根模块。根模块通常定义在src/app文件夹中名为app.module.ts的文件中,它包含一个应用了@NgModule装饰器的类。清单 21-3 显示了示例应用的根模块。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";
import { PaStructureDirective } from "./structure.directive";
import { PaIteratorDirective } from "./iterator.directive";
import { PaCellColor } from "./cellColor.directive";
import { PaCellColorSwitcher } from "./cellColorSwitcher.directive";
import { ProductTableComponent } from "./productTable.component";
import { ProductFormComponent } from "./productForm.component";
import { PaToggleView } from "./toggleView.component";
import { PaAddTaxPipe } from "./addTax.pipe";
import { PaCategoryFilterPipe } from "./categoryFilter.pipe";
import { LOCALE_ID } from "@angular/core";
import localeFr from '@angular/common/locales/fr';
import { registerLocaleData } from '@angular/common';
import { PaDiscountDisplayComponent } from "./discountDisplay.component";
import { PaDiscountEditorComponent } from "./discountEditor.component";
import { DiscountService } from "./discount.service";
import { PaDiscountPipe } from "./discount.pipe";
import { PaDiscountAmountDirective } from "./discountAmount.directive";
import { SimpleDataSource } from "./datasource.model";
import { Model } from "./repository.model";
import { LogService, LOG_SERVICE, SpecialLogService,
  LogLevel, LOG_LEVEL} from "./log.service";
import { VALUE_SERVICE, PaDisplayValueDirective} from "./valueDisplay.directive";

let logger = new LogService();
logger.minimumLevel = LogLevel.DEBUG;

registerLocaleData(localeFr);

@NgModule({
  imports: [BrowserModule, FormsModule, ReactiveFormsModule],
  declarations: [ProductComponent, PaAttrDirective, PaModel,
    PaStructureDirective, PaIteratorDirective,
    PaCellColor, PaCellColorSwitcher, ProductTableComponent,
    ProductFormComponent, PaToggleView, PaAddTaxPipe,
    PaCategoryFilterPipe, PaDiscountDisplayComponent, PaDiscountEditorComponent,
    PaDiscountPipe, PaDiscountAmountDirective, PaDisplayValueDirective],
    providers: [DiscountService, SimpleDataSource, Model, LogService,
      { provide: VALUE_SERVICE, useValue: "Apples" }],
  bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 21-3.The Root Module in the app.module.ts File in the src/app Folder

一个项目中可以有多个模块,但是根模块是在引导文件中使用的模块,引导文件习惯上称为main.ts,在src文件夹中定义。清单 21-4 显示了示例项目的main.ts文件。

import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { AppModule } from './app/app.module';
import { environment } from './environments/environment';

if (environment.production) {
  enableProdMode();
}

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

Listing 21-4.The Angular Bootstrap in the main.ts File in the src Folder

Angular 应用可以在不同的环境中运行,比如 web 浏览器和本地应用容器。引导文件的工作是选择平台并识别根模块。platformBrowserDynamic方法创建浏览器运行时,bootstrapModule方法用于指定模块,该模块是清单 21-3 中的AppModule类。

定义根模块时,使用表 21-3 中描述的@NgModule装饰器属性。(还有一些附加的装饰器属性,将在本章后面介绍。)

表 21-3。

@NgModule 装饰器根模块属性

|

名字

|

描述

|
| --- | --- |
| imports | 此属性指定支持应用中的指令、组件和管道所需的 Angular 模块。 |
| declarations | 此属性用于指定应用中使用的指令、组件和管道。 |
| providers | 此属性定义模块的注入器将使用的服务提供者。正如第二十章中所描述的,这些提供者将在整个应用中可用,并且在没有服务的本地提供者可用时使用。 |
| bootstrap | 此属性指定应用的根组件。 |

了解导入属性

属性用来列出应用需要的其他模块。在示例应用中,这些都是 Angular 框架提供的模块。

...
imports: [BrowserModule, FormsModule, ReactiveFormsModule],
...

BrowserModule提供了在 web 浏览器中运行 Angular 应用所需的功能。其他两个模块为处理 HTML 表单和基于模型的表单提供支持,如第十四章所述。还有其他 Angular 模块,将在后面的章节中介绍。

属性还用于声明对定制模块的依赖,定制模块用于管理复杂的 Angular 应用和创建可重用功能的单元。我在“创建特性模块”一节中解释了如何定义定制模块。

了解声明属性

declarations属性用于向 Angular 提供应用所需的指令、组件和管道的列表,统称为可声明类。示例项目根模块中的declarations属性包含一个很长的类列表,每个类都可以在应用的其他地方使用,只是因为它在这里列出了。

...
declarations: [ProductComponent, PaAttrDirective, PaModel,
  PaStructureDirective, PaIteratorDirective,
  PaCellColor, PaCellColorSwitcher, ProductTableComponent,
  ProductFormComponent, PaToggleView, PaAddTaxPipe,
  PaCategoryFilterPipe, PaDiscountDisplayComponent, PaDiscountEditorComponent,
  PaDiscountPipe, PaDiscountAmountDirective, PaDisplayValueDirective],
...

请注意,内置的可声明类,如第十三章中描述的指令和第十八章中描述的管道,不包含在根模块的declarations属性中。这是因为它们是BrowserModule模块的一部分,当您将一个模块添加到imports属性时,它的可声明类在应用中自动可用。

了解提供者属性

providers属性用于定义服务提供者,当没有合适的本地提供者可用时,该服务提供者将用于解析依赖性。第十九章和第二十章详细描述了服务供应器的使用。

了解 bootstrap 属性

bootstrap属性指定应用的根组件。当 Angular 处理主 HTML 文档(通常称为index.html)时,它检查根组件,并使用@Component装饰器中的selector属性的值来应用它们。

Tip

bootstrap属性中列出的组件也必须包含在declarations列表中。

下面是示例项目根模块中的bootstrap属性:

...
bootstrap: [ProductComponent]
...

ProductComponent类提供了根组件,其selector属性指定了app元素,如清单 21-5 所示。

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

@Component({
  selector: "app",
  templateUrl: "template.html"
})
export class ProductComponent {

}

Listing 21-5.The Root Component in the component.ts File in the src/app Folder

当我开始第十一章中的示例项目时,根组件有很多功能。但是自从引入了额外的组件后,这个组件的作用已经减少了,它现在本质上是一个占位符,告诉 Angular 将app/template.html文件的内容投影到 HTML 文档中的app元素,这允许加载在应用中执行实际工作的组件。

这种方法没有错,但是它确实意味着应用中的根组件没有太多事情要做。如果这种冗余感觉不整洁,那么你可以在根模块中指定多个根组件,它们都将用于 HTML 文档中的目标元素。为了演示,我已经从根模块的bootstrap属性中移除了现有的根组件,并用负责产品表单和产品表的组件类来替换它,如清单 21-6 所示。

...
@NgModule({
  imports: [BrowserModule, FormsModule, ReactiveFormsModule],
  declarations: [ProductComponent, PaAttrDirective, PaModel,
    PaStructureDirective, PaIteratorDirective,
    PaCellColor, PaCellColorSwitcher, ProductTableComponent,
    ProductFormComponent, PaToggleView, PaAddTaxPipe,
    PaCategoryFilterPipe, PaDiscountDisplayComponent, PaDiscountEditorComponent,
    PaDiscountPipe, PaDiscountAmountDirective, PaDisplayValueDirective],
    providers: [DiscountService, SimpleDataSource, Model, LogService,
      { provide: VALUE_SERVICE, useValue: "Apples" }],
    bootstrap: [ProductFormComponent, ProductTableComponent]
})
...

Listing 21-6.Specifying Multiple Root Components in the app.module.ts File in the src/app Folder

清单 21-7 反映了主 HTML 文档中根组件的变化。

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Example</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body class="m-2 row">
    <div class="col-8 p-2">
        <paProductTable></paProductTable>
    </div>
    <div class="col-4 p-2">
        <paProductForm></paProductForm>
    </div>
</body>
</html>

Listing 21-7.Changing the Root Component Elements in the index.html File in the src Folder

与前面的例子相比,我颠倒了这些组件出现的顺序,只是为了在应用的布局中创建一个可察觉的变化。当所有的更改都被保存并且浏览器重新加载页面后,你会看到新的根组件被显示出来,如图 21-2 所示。

img/421542_4_En_21_Fig2_HTML.jpg

图 21-2。

使用多个根组件

模块的服务提供者用于解析所有根组件的依赖关系。在示例应用的情况下,这意味着整个应用共享一个单一的Model服务对象,它允许用 HTML 表单创建的产品自动显示在表格中,即使这些组件已经被提升为根组件。

创建功能模块

根模块已经变得越来越复杂,因为我在前面的章节中添加了一些特性,加载 JavaScript 模块的一长串import语句和跨越几行的@NgModule装饰器的declarations属性中的一组类,如清单 21-8 所示。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";
import { PaStructureDirective } from "./structure.directive";
import { PaIteratorDirective } from "./iterator.directive";
import { PaCellColor } from "./cellColor.directive";
import { PaCellColorSwitcher } from "./cellColorSwitcher.directive";
import { ProductTableComponent } from "./productTable.component";
import { ProductFormComponent } from "./productForm.component";
import { PaToggleView } from "./toggleView.component";
import { PaAddTaxPipe } from "./addTax.pipe";
import { PaCategoryFilterPipe } from "./categoryFilter.pipe";
import { LOCALE_ID } from "@angular/core";
import localeFr from '@angular/common/locales/fr';
import { registerLocaleData } from '@angular/common';
import { PaDiscountDisplayComponent } from "./discountDisplay.component";
import { PaDiscountEditorComponent } from "./discountEditor.component";
import { DiscountService } from "./discount.service";
import { PaDiscountPipe } from "./discount.pipe";
import { PaDiscountAmountDirective } from "./discountAmount.directive";
import { SimpleDataSource } from "./datasource.model";
import { Model } from "./repository.model";
import { LogService, LOG_SERVICE, SpecialLogService,
  LogLevel, LOG_LEVEL} from "./log.service";
import { VALUE_SERVICE, PaDisplayValueDirective} from "./valueDisplay.directive";

let logger = new LogService();
logger.minimumLevel = LogLevel.DEBUG;

registerLocaleData(localeFr);

@NgModule({
  imports: [BrowserModule, FormsModule, ReactiveFormsModule],
  declarations: [ProductComponent, PaAttrDirective, PaModel,
    PaStructureDirective, PaIteratorDirective,
    PaCellColor, PaCellColorSwitcher, ProductTableComponent,
    ProductFormComponent, PaToggleView, PaAddTaxPipe,
    PaCategoryFilterPipe, PaDiscountDisplayComponent, PaDiscountEditorComponent,
    PaDiscountPipe, PaDiscountAmountDirective, PaDisplayValueDirective],
    providers: [DiscountService, SimpleDataSource, Model, LogService,
      { provide: VALUE_SERVICE, useValue: "Apples" }],
    bootstrap: [ProductFormComponent, ProductTableComponent]
})
export class AppModule { }

Listing 21-8.The Contents of the app.module.ts File in the src/app Folder

特征模块用于对相关功能进行分组,以便将其作为单个实体使用,就像 Angular 模块BrowserModule一样。例如,当我需要使用这些特性来处理表单时,我不必为每个单独的指令、组件或管道添加import语句和declarations条目。相反,我只是将BrowserModule添加到装饰者的imports属性中,它包含的所有功能在整个应用中都是可用的。

当您创建一个功能模块时,您可以选择专注于一个应用功能,或者选择将一组提供应用基础结构的相关构建块进行分组。我将在接下来的小节中介绍这两种方法,因为它们的工作方式略有不同,并且有不同的考虑因素。特征模块使用相同的@NgModule装饰器,但是有一组重叠的配置属性,其中一些是新的,一些与根模块共同使用,但是有不同的效果。我将在下面的章节中解释如何使用这些属性,但是表 21-4 提供了一个快速参考的摘要。

表 21-4。

功能模块的@NgModule 装饰器属性

|

名字

|

描述

|
| --- | --- |
| imports | 此属性用于导入模块中的类所需的模块。 |
| providers | 此属性用于定义模块的提供者。当加载功能模块时,提供者集合与根模块中的提供者组合在一起,这意味着功能模块的服务在整个应用中都是可用的(而不仅仅是在模块内)。 |
| declarations | 此属性用于指定模块中的指令、组件和管道。此属性必须包含在模块内使用的类以及由模块向应用的其余部分公开的类。 |
| exports | 此属性用于定义模块的公共导出。它包含来自declarations属性的一些或全部指令、组件和管道,以及来自imports属性的一些或全部模块。 |

创建模型模块

术语模型模块可能是一个绕口令,但是当使用特性模块重构应用时,它通常是一个很好的起点,因为应用中的几乎所有其他构建块都依赖于模型。

第一步是创建包含该模块的文件夹。模块文件夹在src/app文件夹中定义,并被赋予一个有意义的名称。对于这个模块,我创建了一个src/app/model文件夹。

用于 Angular 文件的命名约定使得移动和删除多个文件变得容易。在example文件夹中运行以下命令来移动文件(它们将在 Windows PowerShell、Linux 和 macOS 中工作):

mv src/app/*.model.ts src/app/model/
mv src/app/limit.formvalidator.ts src/app/model/

结果是表 21-5 中列出的文件被移动到model文件夹中。

表 21-5。

模块所需的文件移动

|

文件

|

新位置

|
| --- | --- |
| src/app/datasource.model.ts | src/app/model/datasource.model.ts |
| src/app/form.model.ts | src/app/model/form.model.ts |
| src/app/limit.formvalidator.ts | src/app/model/limit.formvalidator.ts |
| src/app/product.model.ts | src/app/model/product.model.ts |
| src/app/repository.model.ts | src/app/model/repository.model.ts |

如果在移动文件后尝试构建项目,TypeScript 编译器将列出一系列编译器错误,因为一些关键的可声明类不可用。我将很快处理这些问题。

创建模块定义

下一步是定义一个模块,将已经移动到新文件夹的文件中的功能集合在一起。我在src/app/model文件夹中添加了一个名为model.module.ts的文件,并定义了清单 21-9 中所示的模块。

import { NgModule } from "@angular/core";
import { SimpleDataSource } from "./datasource.model";
import { Model } from "./repository.model";

@NgModule({
    providers: [Model, SimpleDataSource]
})
export class ModelModule { }

Listing 21-9.The Contents of the model.module.ts File in the src/app/model Folder

功能模块的目的是有选择地向应用的其余部分公开文件夹的内容。这个模块的@NgModule装饰器只使用providers属性来定义ModelSimpleDataSource服务的类提供者。当您在特性模块中使用提供者时,它们被注册到根模块的注入器中,这意味着它们在整个应用中都是可用的,这正是示例应用中的数据模型所需要的。

Tip

一个常见的错误是认为模块中定义的服务只能被该模块中的类访问。Angular 中没有模块范围。由功能模块定义的提供者就像由根模块定义的一样被使用。由特性模块中的指令和组件定义的本地提供者可用于它们的视图和内容子级,即使它们是在其他模块中定义的。

更新应用中的其他类

将类移动到model文件夹会破坏应用其他部分的import语句。下一步是更新那些import语句以指向新模块。受影响的文件有四个:attr.directive.tscategoryFilter.pipe.tsproductForm.component.tsproductTable.component.ts。清单 21-10 显示了attr.directive.ts文件所需的更改。

import { Directive, ElementRef, Attribute, Input,
    SimpleChange, Output, EventEmitter, HostListener, HostBinding }
from "@angular/core";
import { Product } from "./model/product.model";

@Directive({
    selector: "[pa-attr]"
})
export class PaAttrDirective {

    // ...statements omitted for brevity...
}

Listing 21-10.Updating the Import Reference in the attr.directive.ts File in the src/app Folder

唯一需要的改变是更新在import语句中使用的路径,以反映代码文件的新位置。清单 21-11 显示了应用于categoryFilter.pipe.ts文件的相同变化。

import { Pipe } from "@angular/core";
import { Product } from "./model/product.model";

@Pipe({
    name: "filter",
    pure: false
})
export class PaCategoryFilterPipe {

    transform(products: Product[], category: string): Product[] {
        return category == undefined ?
            products : products.filter(p => p.category == category);
    }
}

Listing 21-11.Updating the Import Reference in the categoryFilter.pipe.ts File in the src/app Folder

清单 21-12 更新了productForm.component.ts文件中的import语句。

import { Component, Output, EventEmitter, ViewEncapsulation,
    Inject, SkipSelf } from "@angular/core";
import { Product } from "./model/product.model";
import { Model } from "./model/repository.model";
import { VALUE_SERVICE } from "./valueDisplay.directive";

@Component({
    selector: "paProductForm",
    templateUrl: "productForm.component.html",
    viewProviders: [{ provide: VALUE_SERVICE, useValue: "Oranges" }]
})
export class ProductFormComponent {

    // ...statements omitted for brevity...
}

Listing 21-12.Updating Import Paths in the productForm.component.ts File in the src/app Folder

清单 21-13 更新最终文件productTable.component.ts中的路径。

import { Component, Input, ViewChildren, QueryList } from "@angular/core";
import { Model } from "./model/repository.model";
import { Product } from "./model/product.model";
import { DiscountService } from "./discount.service";
import { LogService } from "./log.service";

@Component({
    selector: "paProductTable",
    templateUrl: "productTable.component.html",
    providers:[LogService]

})
export class ProductTableComponent {

    // ...statements omitted for brevity...
}

Listing 21-13.Updating Import Paths in the productTable.component.ts File in the src/app Folder

Using a Javascript Module with an Angular Module

创建一个 Angular 模块允许将相关的应用特性组合在一起,但是当应用的其他地方需要时,仍然需要从自己的文件中导入每一个特性,正如您在本节的清单中看到的那样。

您还可以定义一个 JavaScript 模块来导出 Angular 模块的面向公众的特性,这样就可以使用与用于@angular/core模块相同的import语句来访问它们。要使用 JavaScript 模块,在定义 Angular 模块的 TypeScript 文件旁边的module文件夹中添加一个名为index.ts的文件,对于本节中的示例来说,这个文件是src/app/model文件夹。对于您想在应用之外使用的每个应用特性,添加一个export...from这样陈述:

...
export { ModelModule } from "./model.module";
export { Product } from "./product.model";
export { ProductFormGroup } from "./form.model";
export { SimpleDataSource } from "./datasource.model";
export { LimitValidator } from "./limit.formvalidator";
export { Model } from "./repository.model";
...

这些语句导出各个 TypeScript 文件的内容。然后,您可以导入所需的功能,而不必指定单独的文件,如下所示:

...
import { Component, Output, EventEmitter, ViewEncapsulation,
    Inject, SkipSelf } from "@angular/core";
import { Product, Model } from "./model";
import { VALUE_SERVICE } from "./valueDisplay.directive";
...

使用文件名index.ts意味着您只需在import语句中指定文件夹的名称,从而产生一个更整洁且与 Angular 核心包更一致的结果。

也就是说,我没有在自己的项目中使用这种技术。使用一个index.ts文件意味着你必须记住将每个特性添加到 Angular 和 JavaScript 模块中,这是一个额外的步骤,我经常忘记做。相反,我使用本章介绍的方法,直接从包含应用特性的文件中导入。

更新根模块

最后一步是更新根模块,以便功能模块中定义的服务在整个应用中可用。清单 21-14 显示了所需的更改。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";
import { PaStructureDirective } from "./structure.directive";
import { PaIteratorDirective } from "./iterator.directive";
import { PaCellColor } from "./cellColor.directive";
import { PaCellColorSwitcher } from "./cellColorSwitcher.directive";
import { ProductTableComponent } from "./productTable.component";
import { ProductFormComponent } from "./productForm.component";
import { PaToggleView } from "./toggleView.component";
import { PaAddTaxPipe } from "./addTax.pipe";
import { PaCategoryFilterPipe } from "./categoryFilter.pipe";
import { LOCALE_ID } from "@angular/core";
import localeFr from '@angular/common/locales/fr';
import { registerLocaleData } from '@angular/common';
import { PaDiscountDisplayComponent } from "./discountDisplay.component";
import { PaDiscountEditorComponent } from "./discountEditor.component";
import { DiscountService } from "./discount.service";
import { PaDiscountPipe } from "./discount.pipe";
import { PaDiscountAmountDirective } from "./discountAmount.directive";
//import { SimpleDataSource } from "./datasource.model";
//import { Model } from "./repository.model";
import { LogService, LOG_SERVICE, SpecialLogService,
  LogLevel, LOG_LEVEL} from "./log.service";
import { VALUE_SERVICE, PaDisplayValueDirective} from "./valueDisplay.directive";
import { ModelModule } from "./model/model.module";

let logger = new LogService();
logger.minimumLevel = LogLevel.DEBUG;

registerLocaleData(localeFr);

@NgModule({
  imports: [BrowserModule, FormsModule, ReactiveFormsModule, ModelModule],
  declarations: [ProductComponent, PaAttrDirective, PaModel,
    PaStructureDirective, PaIteratorDirective,
    PaCellColor, PaCellColorSwitcher, ProductTableComponent,
    ProductFormComponent, PaToggleView, PaAddTaxPipe,
    PaCategoryFilterPipe, PaDiscountDisplayComponent, PaDiscountEditorComponent,
    PaDiscountPipe, PaDiscountAmountDirective, PaDisplayValueDirective],
    providers: [DiscountService, LogService,
      { provide: VALUE_SERVICE, useValue: "Apples" }],
    bootstrap: [ProductFormComponent, ProductTableComponent]
})
export class AppModule { }

Listing 21-14.Updating the Root Module in the app.module.ts File in the src/app Folder

我导入了特性模块,并将其添加到根模块的导入列表中。因为特性模块为ModelSimpleDataSource定义了提供者,所以我从根模块的提供者列表中删除了条目,并删除了相关的导入语句。

一旦保存了更改,就可以运行ng serve来启动 Angular 开发工具。应用将被编译,并且修改后的根模块将提供对模型服务的访问。浏览器中显示的内容没有可见的更改,并且更改仅限于项目的结构。(您可能需要重新启动 Angular 开发工具,并重新加载浏览器以查看更改。)

创建实用功能模块

模型模块是一个很好的起点,因为它展示了特性模块的基本结构以及它与根模块的关系。然而,对应用的影响是轻微的,并且没有实现大量的简化。

复杂性的下一步是实用功能模块,它将应用中所有常见的功能组合在一起,比如管道和指令。在一个实际的项目中,您可能会更有选择性地将这些类型的构建块组合在一起,以便有几个模块,每个模块包含相似的功能。对于示例应用,我将把所有管道、指令和服务移到一个模块中。

创建模块文件夹并移动文件

与前面的模块一样,第一步是创建文件夹。对于这个模块,我创建了一个名为src/app/common的文件夹。在example文件夹中运行以下命令,移动管道和指令的 TypeScript 文件:

mv src/app/*.pipe.ts src/app/common/
mv src/app/*.directive.ts src/app/common/

这些命令应该可以在 Windows PowerShell、Linux 和 macOS 中运行。应用中的一些指令和管道依赖于通过依赖注入提供给它们的DiscountServiceLogServices类。在example文件夹中运行以下命令,将服务的 TypeScript 文件移动到module文件夹中:

mv src/app/*.service.ts src/app/common/

结果是表 21-6 中列出的文件被移动到common模块文件夹中。

表 21-6。

模块所需的文件移动

|

文件

|

新位置

|
| --- | --- |
| app/addTax.pipe.ts | app/common/addTax.pipe.ts |
| app/attr.directive.ts | app/common/attr.directive.ts |
| app/categoryFilter.pipe.ts | app/common/categoryFilter.pipe.ts |
| app/cellColor.directive.ts | app/common/cellColor.directive.ts |
| app/cellColorSwitcher.directive.ts | app/common/cellColorSwitcher.directive.ts |
| app/discount.pipe.ts | app/common/discount.pipe.ts |
| app/discountAmount.directive.ts | app/common/discountAmount.directive.ts |
| app/iterator.directive.ts | app/common/iterator.directive.ts |
| app/structure.directive.ts | app/common/structure.directive.ts |
| app/twoway.directive.ts | app/common/twoway.directive.ts |
| app/valueDisplay.directive.ts | app/common/valueDisplay.directive.ts |
| app/discount.service.ts | app/common/discount.service.ts |
| app/log.service.ts | app/common/log.service.ts |

更新新模块中的类

一些被移动到新文件夹中的类有import语句,这些语句必须被更新以反映模型模块的新路径。清单 21-15 显示了对attr.directive.ts文件所需的更改。

import { Directive, ElementRef, Attribute, Input,
         SimpleChange, Output, EventEmitter, HostListener, HostBinding }
            from "@angular/core";
import { Product } from "../model/product.model";

@Directive({
    selector: "[pa-attr]"
})
export class PaAttrDirective {

    // ...statements omitted for brevity...
}

Listing 21-15.Updating the Imports in the attr.directive.ts File in the src/app/common Folder

清单 21-16 显示了对categoryFilter.pipe.ts文件的相应更改。

import { Pipe } from "@angular/core";
import { Product } from "../model/product.model";

@Pipe({
    name: "filter",
    pure: false
})
export class PaCategoryFilterPipe {

    transform(products: Product[], category: string): Product[] {
        return category == undefined ?
            products : products.filter(p => p.category == category);
    }
}

Listing 21-16.Updating the Imports in the categoryFilter.pipe.ts File in the src/app/common Folder

创建模块定义

下一步是定义一个模块,将已经移动到新文件夹的文件中的功能集合在一起。我在src/app/common文件夹中添加了一个名为common.module.ts的文件,并定义了清单 21-17 中所示的模块。

import { NgModule } from "@angular/core";
import { PaAddTaxPipe } from "./addTax.pipe";
import { PaAttrDirective } from "./attr.directive";
import { PaCategoryFilterPipe } from "./categoryFilter.pipe";
import { PaCellColor } from "./cellColor.directive";
import { PaCellColorSwitcher } from "./cellColorSwitcher.directive";
import { PaDiscountPipe } from "./discount.pipe";
import { PaDiscountAmountDirective } from "./discountAmount.directive";
import { PaIteratorDirective } from "./iterator.directive";
import { PaStructureDirective } from "./structure.directive";
import { PaModel } from "./twoway.directive";
import { VALUE_SERVICE, PaDisplayValueDirective} from "./valueDisplay.directive";
import { DiscountService } from "./discount.service";
import { LogService } from "./log.service";
import { ModelModule } from "../model/model.module";

@NgModule({
    imports: [ModelModule],
    providers: [LogService, DiscountService,
        { provide: VALUE_SERVICE, useValue: "Apples" }],
    declarations: [PaAddTaxPipe, PaAttrDirective, PaCategoryFilterPipe,
        PaCellColor, PaCellColorSwitcher, PaDiscountPipe,
        PaDiscountAmountDirective, PaIteratorDirective, PaStructureDirective,
        PaModel, PaDisplayValueDirective],
    exports: [PaAddTaxPipe, PaAttrDirective, PaCategoryFilterPipe,
        PaCellColor, PaCellColorSwitcher, PaDiscountPipe,
        PaDiscountAmountDirective, PaIteratorDirective, PaStructureDirective,
        PaModel, PaDisplayValueDirective]
})
export class CommonModule { }

Listing 21-17.The Contents of the common.module.ts File in the src/app/common Folder

这是一个比数据模型所需模块更复杂的模块。在接下来的小节中,我将描述装饰器的每个属性所使用的值。

了解进口

模块中的一些directivespipes依赖于本章前面创建的model模块中定义的服务。为了确保该模块中的特性可用,我添加了公共模块的imports属性。

了解提供者

providers属性确保特性模块中的指令和管道所服务的服务能够访问它们所需要的服务。这意味着添加类提供者来创建LogServiceDiscountService服务,这些服务将在模块加载时被添加到根模块的提供者中。这些服务不仅可用于common模块中的指令和管道;它们也将在整个应用中可用。

理解声明

属性用于向 Angular 提供模块中的指令和管道(以及组件,如果有的话)的列表。在功能模块中,该属性有两个用途:它使可声明类能够在模块中包含的任何模板中使用,并且它允许模块使那些可声明类在模块外部可用。我在本章的后面创建了一个包含模板内容的模块,但是对于这个模块来说,declarations属性的值是为了准备exports属性而必须使用的,这将在下一节描述。

了解出口

对于包含用于应用其他地方的指令和管道的模块,exports属性在@NgModule装饰器中是最重要的,因为它定义了当模块被导入到应用的其他地方时,它所提供的一组指令、组件和管道。exports属性可以包含单独的类和模块类型,尽管两者都必须已经在declarationsimports属性中列出。当模块被导入时,列出的类型表现得好像它们已经被添加到导入模块的declarations属性中。

更新应用中的其他类

既然已经定义了模块,我可以更新应用中的其他文件,这些文件包含现在属于common模块的类型的import语句。清单 21-18 显示了对discountDisplay.component.ts文件所需的更改。

import { Component, Input } from "@angular/core";
import { DiscountService } from "./common/discount.service";

@Component({
  selector: "paDiscountDisplay",
  template: `<div class="bg-info text-white p-2">
                The discount is {{discounter.discount}}
               </div>`
})
export class PaDiscountDisplayComponent {

  constructor(public discounter: DiscountService) { }
}

Listing 21-18.Updating the Import in the discountDisplay.component.ts File in the src/app Folder

清单 21-19 显示了对discountEditor.component.ts文件的修改。

import { Component, Input } from "@angular/core";
import { DiscountService } from "./common/discount.service";

@Component({
    selector: "paDiscountEditor",
    template: `<div class="form-group">
                   <label>Discount</label>
                   <input [(ngModel)]="discounter.discount"
                        class="form-control" type="number" />
               </div>`
})
export class PaDiscountEditorComponent {

    constructor(public discounter: DiscountService) { }
}

Listing 21-19.Updating the Import Reference in the discountEditor.component.ts File in the src/app Folder

清单 21-20 显示了对productForm.component.ts文件的修改。

import { Component, Output, EventEmitter, ViewEncapsulation,
    Inject, SkipSelf } from "@angular/core";
import { Product } from "./model/product.model";
import { Model } from "./model/repository.model";
import { VALUE_SERVICE } from "./common/valueDisplay.directive";

@Component({
    selector: "paProductForm",
    templateUrl: "productForm.component.html",
    viewProviders: [{ provide: VALUE_SERVICE, useValue: "Oranges" }]
})
export class ProductFormComponent {

    // ...statements omitted for brevity...
}

Listing 21-20.Updating the Import Reference in the productForm.component.ts File in the src/app Folder

最后的更改是对productTable.component.ts文件的,如清单 21-21 所示。

import { Component, Input, ViewChildren, QueryList } from "@angular/core";
import { Model } from "./model/repository.model";
import { Product } from "./model/product.model";
import { DiscountService } from "./common/discount.service";
import { LogService } from "./common/log.service";

@Component({
    selector: "paProductTable",
    templateUrl: "productTable.component.html",
    providers:[LogService]

})
export class ProductTableComponent {

    // ...statements omitted for brevity...
}

Listing 21-21.Updating the Import Reference in the productTable.component.ts File in the src/app Folder

更新根模块

最后一步是更新根模块,以便它加载common模块来提供对它包含的指令和管道的访问,如清单 21-22 所示。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { ProductTableComponent } from "./productTable.component";
import { ProductFormComponent } from "./productForm.component";
import { PaDiscountDisplayComponent } from "./discountDisplay.component";
import { PaDiscountEditorComponent } from "./discountEditor.component";
import { ModelModule } from "./model/model.module";
import { CommonModule } from "./common/common.module";

@NgModule({
    imports: [BrowserModule, FormsModule, ReactiveFormsModule,
        ModelModule, CommonModule],
    declarations: [ProductComponent, ProductTableComponent,
        ProductFormComponent, PaDiscountDisplayComponent, PaDiscountEditorComponent],
    bootstrap: [ProductFormComponent, ProductTableComponent]
})
export class AppModule { }

Listing 21-22.Importing a Feature Module in the app.module.ts File in the src/app Folder

随着common模块的创建,根模块得到了极大的简化,并被添加到了imports列表中。指令和管道的所有单独的类都已经从declarations列表中删除,并且它们相关的import语句也已经从文件中删除。当common模块被导入时,在其exports属性中列出的所有类型都将被添加到根模块的declarations属性中。

一旦保存了本节中的更改,就可以运行ng serve命令来启动 Angular 开发工具。同样,呈现给用户的内容没有明显的变化,不同之处都在应用的结构上。

使用组件创建特征模块

我要创建的最后一个模块将包含应用的组件。创建模块的过程与前面示例中的过程相同,这将在后面的部分中描述。

创建模块文件夹并移动文件

该模块将被称为components,我创建了文件夹src/app/components来包含这些文件。在example文件夹中运行以下命令,将指令 TypeScript、HTML 和 CSS 文件移动到新文件夹中,并删除相应的 JavaScript 文件:

mv src/app/*.component.ts src/app/components/
mv src/app/*.component.html src/app/components/
mv src/app/*.component.css src/app/components/

这些命令的结果是组件代码文件、模板和样式表被移动到新文件夹中,如表 21-7 所列。

表 21-7。

组件模块所需的文件移动

|

文件

|

新位置

|
| --- | --- |
| src/app/app.component.ts | src/app/components/app.component.ts |
| src/app/app.component.html | src/app/components/app.component.html |
| src/app/app.component.css | src/app/components/app.component.css |
| src/app/discountDisplay.component.ts | src/app/components/discountDisplay.component.ts |
| src/app/discountEditor.component.ts | src/app/components/discountEditor.component.ts |
| src/app/productForm.component.ts | src/app/components/productForm.component.ts |
| src/app/productForm.component.html | src/app/components/productForm.component.html |
| src/app/productForm.component.css | src/app/components/productForm.component.css |
| src/app/productTable.component.ts | src/app/components/productTable.component.ts |
| src/app/productTable.component.html | src/app/components/productTable.component.html |
| src/app/productTable.component.css | src/app/components/productTable.component.css |
| src/app/toggleView.component.ts | src/app/components/toggleView.component.ts |
| src/app/toggleView.component.html | src/app/components/toggleView.component.ts |

创建模块定义

为了创建这个模块,我在src/app/components文件夹中添加了一个名为components.module.ts的文件,并添加了清单 21-23 中所示的语句。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { CommonModule } from "../common/common.module";
import { FormsModule, ReactiveFormsModule } from "@angular/forms"
import { PaDiscountDisplayComponent } from "./discountDisplay.component";
import { PaDiscountEditorComponent } from "./discountEditor.component";
import { ProductFormComponent } from "./productForm.component";
import { ProductTableComponent } from "./productTable.component";

@NgModule({
    imports: [BrowserModule, FormsModule, ReactiveFormsModule, CommonModule],
    declarations: [PaDiscountDisplayComponent, PaDiscountEditorComponent,
        ProductFormComponent, ProductTableComponent],
    exports: [ProductFormComponent, ProductTableComponent]
})
export class ComponentsModule { }

Listing 21-23.The Contents of the components.module.ts File in the src/app/components Folder

这个模块导入了BrowserModuleCommonModule来确保指令可以访问它们需要的服务和可声明的类。它导出了ProductFormComponentProductTableComponent组件,这是根组件的bootstrap属性中使用的两个组件。其他组件是模块私有的。

更新其他类

将 TypeScript 文件移动到components文件夹需要对import语句中的路径进行一些更改。清单 21-24 显示了discountDisplay.component.ts文件所需的变更。

import { Component, Input } from "@angular/core";
import { DiscountService } from "../common/discount.service";

@Component({
  selector: "paDiscountDisplay",
  template: `<div class="bg-info text-white p-2">
                The discount is {{discounter.discount}}
               </div>`
})
export class PaDiscountDisplayComponent {

  constructor(public discounter: DiscountService) { }
}

Listing 21-24.Updating a Path in the discountDisplay.component.ts File in the src/app/component Folder

清单 21-25 显示了对discountEditor.component.ts文件所需的更改。

import { Component, Input } from "@angular/core";
import { DiscountService } from "../common/discount.service";

@Component({
    selector: "paDiscountEditor",
    template: `<div class="form-group">
                   <label>Discount</label>
                   <input [(ngModel)]="discounter.discount"
                        class="form-control" type="number" />
               </div>`
})
export class PaDiscountEditorComponent {

    constructor(public discounter: DiscountService) { }
}

Listing 21-25.Updating a Path in the discountEditor.component.ts File in the src/app/component Folder

清单 21-26 显示了productForm.component.ts文件所需的更改。

import { Component, Output, EventEmitter, ViewEncapsulation,
    Inject, SkipSelf } from "@angular/core";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { VALUE_SERVICE } from "../common/valueDisplay.directive";

@Component({
    selector: "paProductForm",
    templateUrl: "productForm.component.html",
    viewProviders: [{ provide: VALUE_SERVICE, useValue: "Oranges" }]
})
export class ProductFormComponent {
    newProduct: Product = new Product();

    constructor(private model: Model,
            @Inject(VALUE_SERVICE) @SkipSelf() private serviceValue: string) {
        console.log("Service Value: " + serviceValue);
    }

    submitForm(form: any) {
        this.model.saveProduct(this.newProduct);
        this.newProduct = new Product();
        form.reset();
    }
}

Listing 21-26.Updating a Path in the productForm.component.ts File in the src/app/component Folder

清单 21-27 显示了对productTable.component.ts文件所需的更改。

import { Component, Input, ViewChildren, QueryList } from "@angular/core";
import { Model } from "../model/repository.model";
import { Product } from "../model/product.model";
import { DiscountService } from "../common/discount.service";
import { LogService } from "../common/log.service";

@Component({
    selector: "paProductTable",
    templateUrl: "productTable.component.html",
    providers:[LogService]

})
export class ProductTableComponent {

    constructor(private dataModel: Model) { }

    getProduct(key: number): Product {
        return this.dataModel.getProduct(key);
    }

    getProducts(): Product[] {
        return this.dataModel.getProducts();
    }

    deleteProduct(key: number) {
        this.dataModel.deleteProduct(key);
    }

    taxRate: number = 0;
    dateObject: Date = new Date(2020, 1, 20);
    dateString: string = "2020-02-20T00:00:00.000Z";
    dateNumber: number = 1582156800000;

    selectMap = {
        "Watersports": "stay dry",
        "Soccer": "score goals",
        "other": "have fun"
    }

    numberMap = {
        "=1": "one product",
        "=2": "two products",
        "other": "# products"
    }
}

Listing 21-27.Updating a Path in the productTable.component.ts File in the src/app/component Folder

更新根模块

最后一步是更新根模块,删除对单个文件的过时引用,并导入新模块,如清单 21-28 所示。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { ProductTableComponent } from "./components/productTable.component";
import { ProductFormComponent } from "./components/productForm.component";
// import { PaDiscountDisplayComponent } from "./discountDisplay.component";
// import { PaDiscountEditorComponent } from "./discountEditor.component";
import { ModelModule } from "./model/model.module";
import { CommonModule } from "./common/common.module";
import { ComponentsModule } from "./components/components.module";

@NgModule({
    imports: [BrowserModule, FormsModule, ReactiveFormsModule,
        ModelModule, CommonModule, ComponentsModule],
    declarations: [ProductComponent],
    bootstrap: [ProductFormComponent, ProductTableComponent]
})
export class AppModule { }

Listing 21-28.Importing a Feature Module in the app.module.ts File in the src/app Folder

重新启动 Angular 开发工具来构建和显示应用。向应用添加模块从根本上简化了根模块,并允许在自包含的块中定义相关功能,这些块可以在与应用的其余部分相对隔离的情况下进行扩展或修改。

摘要

在这一章中,我描述了最后一个有 Angular 的构件:模块。我解释了根模块的作用,并演示了如何创建特性模块来为应用添加结构。在本书的下一部分,我将描述 Angular 提供的特性,这些特性将构建模块塑造成复杂的、响应性强的应用。

二十二、创建示例项目

在本书前一部分的所有章节中,我向示例项目添加了类和内容来演示不同的 Angular 特性,然后,在第二十一章中,我引入了特性模块来给项目添加一些结构。结果是一个项目有很多冗余和未使用的功能,对于这本书的这一部分,我将开始一个新的项目,它采用了前几章的一些核心功能,并为后面的章节提供了一个清晰的基础。

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

开始示例项目

要创建项目并用工具和占位符内容填充它,打开一个新的命令提示符,导航到一个方便的位置,并运行清单 22-1 中所示的命令。

ng new exampleApp --routing false --style css --skip-git --skip-tests

Listing 22-1.Creating the Example Project

为了将本书这一部分中使用的项目与早期的示例区分开来,我创建了一个名为 exampleApp 的项目。项目初始化过程需要一段时间才能完成,因为所有必需的包都已下载。

添加和配置引导 CSS 包

在本章和本书的其余部分,我继续使用 Bootstrap CSS 框架来设计 HTML 元素的样式。运行清单 22-2 中的命令,导航到exampleApp文件夹,并将引导包添加到项目中。

cd exampleApp
npm install bootstrap@4.4.1

Listing 22-2.Installing the Bootstrap Package

将清单 22-3 中所示的行添加到angular.json文件中,以将引导 CSS 样式包含在 Angular 开发工具准备的包中。

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

Listing 22-3.Configuring a CSS File in the angular.json File in the exampleApp Folder

创建项目结构

为了准备示例应用的内容,我添加了一系列子文件夹,用于包含应用代码和一些功能模块,如表 22-1 中所列。

表 22-1。

为示例应用创建的文件夹

|

名字

|

描述

|
| --- | --- |
| src/app/model | 该文件夹将包含一个包含数据模型的功能模块。 |
| src/app/core | 该文件夹将包含一个功能模块,该模块包含提供应用核心功能的组件。 |
| src/app/messages | 该文件夹将包含用于向用户显示消息和错误的功能模块。 |

创建模型模块

第一个特性模块将包含项目的数据模型,它类似于第二部分中使用的数据模型,尽管它不包含表单验证逻辑,表单验证逻辑将在其他地方处理。

创建产品数据类型

为了定义应用所基于的基本数据类型,我在src/app/model文件夹中添加了一个名为product.model.ts的文件,并定义了清单 22-4 中所示的类。

export class Product {

    constructor(public id?: number,
                public name?: string,
                public category?: string,
                public price?: number) {}
}

Listing 22-4.The Contents of the product.model.ts File in the src/app/model Folder

创建数据源和存储库

为了给应用提供一些初始数据,我在src/app/model文件夹中创建了一个名为static.datasource.ts的文件,并定义了清单 22-5 中所示的服务。在第二十四章之前,这个类将被用作数据源,在那里我将解释如何使用异步 HTTP 请求从 web 服务请求数据。

Tip

当在一个特征模块中创建文件时,我更倾向于遵循 Angular 文件的命名规则,特别是当模块的用途从它的名字就可以看出来的时候。

import { Injectable } from "@angular/core";
import { Product } from "./product.model";

@Injectable()
export class StaticDataSource {
    private data: Product[];

    constructor() {
        this.data = new Array<Product>(
            new Product(1, "Kayak", "Watersports", 275),
            new Product(2, "Lifejacket", "Watersports", 48.95),
            new Product(3, "Soccer Ball", "Soccer", 19.50),
            new Product(4, "Corner Flags", "Soccer", 34.95),
            new Product(5, "Thinking Cap", "Chess", 16));
    }

    getData(): Product[] {
        return this.data;
    }
}

Listing 22-5.The Contents of the static.datasource.ts File in the src/app/model Folder

下一步是定义存储库,应用的其余部分将通过它来访问模型数据。我在src/app/model文件夹中创建了一个名为repository.model.ts的文件,并用它来定义清单 22-6 中所示的类。

import { Injectable } from "@angular/core";
import { Product } from "./product.model";
import { StaticDataSource } from "./static.datasource";

@Injectable()
export class Model {
    private products: Product[];
    private locator = (p: Product, id: number) => p.id == id;

    constructor(private dataSource: StaticDataSource) {
        this.products = new Array<Product>();
        this.dataSource.getData().forEach(p => this.products.push(p));
    }

    getProducts(): Product[] {
        return this.products;
    }

    getProduct(id: number): Product {
        return this.products.find(p => this.locator(p, id));
    }

    saveProduct(product: Product) {
        if (product.id == 0 || product.id == null) {
            product.id = this.generateID();
            this.products.push(product);
        } else {
            let index = this.products
                .findIndex(p => this.locator(p, product.id));
            this.products.splice(index, 1, product);
        }
    }

    deleteProduct(id: number) {
        let index = this.products.findIndex(p => this.locator(p, id));
        if (index > -1) {
            this.products.splice(index, 1);
        }
    }

    private generateID(): number {
        let candidate = 100;
        while (this.getProduct(candidate) != null) {
            candidate++;
        }
        return candidate;
    }
}

Listing 22-6.The Contents of the repository.model.ts File in the src/app/model Folder

完成模型模块

为了完成数据模型,我需要定义模块。我在src/app/model文件夹中创建了一个名为model.module.ts的文件,并用它来定义清单 22-7 中所示的 Angular 模块。

import { NgModule } from "@angular/core";
import { StaticDataSource } from "./static.datasource";
import { Model } from "./repository.model";

@NgModule({
    providers: [Model, StaticDataSource]
})
export class ModelModule { }

Listing 22-7.The Contents of the model.module.ts File in the src/app/model Folder

创建核心模块

核心模块将包含应用的核心功能,构建在第二部分描述的特性之上,为用户提供模型中的产品列表以及创建和编辑它们的能力。

创建共享状态服务

为了帮助该模块中的组件进行协作,我将添加一个记录当前模式的服务,记录用户是在编辑还是在创建产品。我在src/app/core文件夹中添加了一个名为sharedState.model.ts的文件,并定义了清单 22-8 中所示的枚举和类。

Tip

我使用了model.ts文件名,而不是service.ts,因为这个类的角色将在后面的章节中改变。请暂时容忍我,尽管我打破了命名惯例。

export enum MODES {
    CREATE, EDIT
}

export class SharedState {
    mode: MODES = MODES.EDIT;
    id: number;
}

Listing 22-8.The Contents of the sharedState.model.ts File in the src/app/core Folder

SharedState类包含两个属性,反映当前模式和正在操作的数据模型对象的 ID。

创建表格组件

该组件将为用户提供一个表,该表列出了应用中的所有产品,并且将成为应用中的主要焦点,通过允许创建、编辑或删除对象的按钮提供对其他功能区域的访问。清单 22-9 显示了我在src/app/core文件夹中创建的table.component.ts文件的内容。

import { Component } from "@angular/core";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { MODES, SharedState } from "./sharedState.model";

@Component({
    selector: "paTable",
    templateUrl: "table.component.html"
})
export class TableComponent {

    constructor(private model: Model, private state: SharedState) { }

    getProduct(key: number): Product {
        return this.model.getProduct(key);
    }

    getProducts(): Product[] {
        return this.model.getProducts();
    }

    deleteProduct(key: number) {
        this.model.deleteProduct(key);
    }

    editProduct(key: number) {
        this.state.id = key;
        this.state.mode = MODES.EDIT;
    }

    createProduct() {
        this.state.id = undefined;
        this.state.mode = MODES.CREATE;
    }
}

Listing 22-9.The Contents of the table.component.ts File in the src/app/core Folder

该组件提供了与第二部分相同的基本功能,并增加了editProductcreateProduct方法。当用户想要编辑或创建产品时,这些方法会更新共享状态服务。

创建表格组件模板

为了给表格组件提供一个模板,我在src/app/core文件夹中添加了一个名为table.component.html的 HTML 文件,并添加了清单 22-10 中所示的标记。

<table class="table table-sm table-bordered table-striped">
    <tr>
        <th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
    </tr>
    <tr *ngFor="let item of getProducts()">
        <td>{{item.id}}</td>
        <td>{{item.name}}</td>
        <td>{{item.category}}</td>
        <td>{{item.price | currency:"USD" }}</td>
        <td class="text-center">
            <button class="btn btn-danger btn-sm mr-1"
                    (click)="deleteProduct(item.id)">
                Delete
            </button>
            <button class="btn btn-warning btn-sm" (click)="editProduct(item.id)">
                Edit
            </button>
        </td>
    </tr>
</table>
<button class="btn btn-primary m-1" (click)="createProduct()">
    Create New Product
</button>

Listing 22-10.The Contents of the table.component.html File in the src/app/core Folder

该模板使用ngFor指令为数据模型中的每个产品在表中创建行,包括调用deleteProducteditProduct方法的按钮。表外还有一个button元素,当它被单击时调用组件的createProduct方法。

创建表单组件

对于这个项目,我将创建一个表单组件,它将管理一个 HTML 表单,该表单允许创建新产品,并允许修改现有产品。为了定义组件,我在src/app/core文件夹中添加了一个名为form.component.ts的文件,并添加了清单 22-11 中所示的代码。

import { Component } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model"
import { MODES, SharedState } from "./sharedState.model";

@Component({
    selector: "paForm",
    templateUrl: "form.component.html",
    styleUrls: ["form.component.css"]
})
export class FormComponent {
    product: Product = new Product();

    constructor(private model: Model,
            private state: SharedState) { }

    get editing(): boolean {
        return this.state.mode == MODES.EDIT;
    }

    submitForm(form: NgForm) {
        if (form.valid) {
            this.model.saveProduct(this.product);
            this.product = new Product();
            form.reset();
        }
    }

    resetForm() {
        this.product = new Product();
    }
}

Listing 22-11.The Contents of the form.component.ts File in the src/app/core Folder

相同的组件和表单将用于创建新产品和编辑现有产品,因此与第二部分中的等效组件相比,有一些额外的功能。视图中将使用editing属性来表示共享状态服务的当前设置。resetForm方法是另一个新增加的方法,它重置了用于向表单提供数据值的对象。submitForm方法没有改变,它依赖于数据模型来确定传递给saveProduct方法的对象是模型的新增对象还是现有对象的替换对象。

创建表单组件模板

为了给组件提供一个模板,我在src/app/core文件夹中添加了一个名为form.component.html的 HTML 文件,并添加了清单 22-12 中所示的标记。

<div class="bg-primary text-white p-2" [class.bg-warning]="editing">
    <h5>{{editing  ? "Edit" : "Create"}} Product</h5>
</div>

<form novalidate #form="ngForm" (ngSubmit)="submitForm(form)" (reset)="resetForm()" >

    <div class="form-group">
        <label>Name</label>
        <input class="form-control" name="name"
               [(ngModel)]="product.name" required />
    </div>

    <div class="form-group">
        <label>Category</label>
        <input class="form-control" name="category"
               [(ngModel)]="product.category" required />
    </div>

    <div class="form-group">
        <label>Price</label>
        <input class="form-control" name="price"
               [(ngModel)]="product.price"
               required pattern="^[0-9\.]+$" />
    </div>

    <button type="submit" class="btn btn-primary m-1"
            [class.btn-warning]="editing" [disabled]="form.invalid">
        {{editing ? "Save" : "Create"}}
    </button>
    <button type="reset" class="btn btn-secondary m-1">Cancel</button>
</form>

Listing 22-12.The Contents of the form.component.html File in the src/app/core Folder

该模板最重要的部分是form元素,它包含创建或编辑产品所需的namecategoryprice属性的input元素。模板顶部的标题和表单的提交按钮根据编辑模式改变它们的内容和外观,以区分不同的操作。

创建表单组件样式

为了保持示例简单,我使用了基本的表单验证,没有任何错误消息。相反,我依赖于使用 Angular 验证类应用的 CSS 样式。我在src/app/core文件夹中添加了一个名为form.component.css的文件,并定义了清单 22-13 中所示的样式。

input.ng-dirty.ng-invalid { border: 2px solid #ff0000 }
input.ng-dirty.ng-valid { border: 2px solid #6bc502 }

Listing 22-13.The Contents of the form.component.css File in the src/app/core Folder

完成核心模块

为了定义包含组件的模块,我在src/app/core文件夹中添加了一个名为core.module.ts的文件,并创建了清单 22-14 中所示的 Angular 模块。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { FormsModule } from "@angular/forms";
import { ModelModule } from "../model/model.module";
import { TableComponent } from "./table.component";
import { FormComponent } from "./form.component";
import { SharedState } from "./sharedState.model";

@NgModule({
    imports: [BrowserModule, FormsModule, ModelModule],
    declarations: [TableComponent, FormComponent],
    exports: [ModelModule, TableComponent, FormComponent],
    providers: [SharedState]
})
export class CoreModule { }

Listing 22-14.The Contents of the core.module.ts File in the src/app/core Folder

此模块导入本章前面创建的核心 Angular 功能、Angular 形状特征和应用的数据模型。它还为SharedState服务设置了一个提供者。

创建消息模块

messages 模块将包含一个用于报告应该向用户显示的消息或错误的服务,以及一个呈现它们的组件。这是整个应用都需要的功能,实际上不属于其他两个模块。

创建消息模型和服务

为了表示应该向用户显示的消息,我在src/app/messages文件夹中添加了一个名为message.model.ts的文件,并添加了清单 22-15 中所示的代码。

export class Message {

    constructor(public text: string,
        public error: boolean = false) { }
}

Listing 22-15.The Contents of the message.model.ts File in the src/app/messages Folder

Message类定义了表示将向用户显示的文本以及消息是否表示错误的属性。接下来,我在src/app/messages文件夹中创建了一个名为message.service.ts的文件,并使用它来定义清单 22-16 中所示的服务,该服务将用于注册应该显示给用户的消息。

import { Injectable } from "@angular/core";
import { Message } from "./message.model";

@Injectable()
export class MessageService {
    private handler: (m: Message) => void;

    reportMessage(msg: Message) {
        if (this.handler != null) {
            this.handler(msg);
        }
    }

    registerMessageHandler(handler: (m: Message) => void) {
        this.handler = handler;
    }
}

Listing 22-16.The Contents of the message.service.ts File in the src/app/messages Folder

该服务充当生成错误消息的应用部分和需要接收错误消息的应用部分之间的代理。我将在第二十三章中介绍反应式扩展包的特性时改进这个服务的工作方式。

创建组件和模板

现在我有了消息源,我可以创建一个组件,将它们显示给用户。我在src/app/messages文件夹中添加了一个名为message.component.ts的文件,并定义了清单 22-17 中所示的组件。

import { Component } from "@angular/core";
import { MessageService } from "./message.service";
import { Message } from "./message.model";

@Component({
    selector: "paMessages",
    templateUrl: "message.component.html",
})
export class MessageComponent {
    lastMessage: Message;

    constructor(messageService: MessageService) {
        messageService.registerMessageHandler(m => this.lastMessage = m);
    }
}

Listing 22-17.The Contents of the message.component.ts File in the src/app/messages Folder

该组件接收一个MessageService对象作为它的构造函数参数,并使用它注册一个处理函数,当服务接收到一条消息时将调用该函数,将最近的消息分配给一个名为lastMessage的属性。为了给组件提供模板,我在src/app/messages文件夹中创建了一个名为message.component.html的文件,并添加了清单 22-18 中所示的标记,向用户显示消息。

<div *ngIf="lastMessage"
     class="bg-info text-white p-2 text-center"
     [class.bg-danger]="lastMessage.error">
        <h4>{{lastMessage.text}}</h4>
</div>

Listing 22-18.The Contents of the message.component.html File in the src/app/messages Folder

完成消息模块

我在src/app/messages文件夹中添加了一个名为message.module.ts的文件,并定义了清单 22-19 中所示的模块。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { MessageComponent } from "./message.component";
import { MessageService } from "./message.service";

@NgModule({
    imports: [BrowserModule],
    declarations: [MessageComponent],
    exports: [MessageComponent],
    providers: [MessageService]
})
export class MessageModule { }

Listing 22-19.The Contents of the message.module.ts File in the src/app/messages Folder

完成项目

为了将所有不同的模块放在一起,我对根模块做了清单 22-20 中所示的更改。

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
//import { AppComponent } from './app.component';
import { ModelModule } from "./model/model.module";
import { CoreModule } from "./core/core.module";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { MessageModule } from "./messages/message.module";
import { MessageComponent } from "./messages/message.component";

@NgModule({
  imports: [BrowserModule, ModelModule, CoreModule, MessageModule],
  bootstrap: [TableComponent, FormComponent, MessageComponent]
})
export class AppModule { }

Listing 22-20.Configuring the Application in the app.module.ts File in the src/app Folder

该模块导入本章创建的功能模块,并指定三个引导组件,其中两个在CoreModule中定义,一个来自MessageModule。这将显示产品表和表单以及任何消息或错误。

最后一步是更新 HTML 文件,使其包含与引导组件的selector属性相匹配的元素,如清单 22-21 所示。

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>ExampleApp</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body class="m-2">
  <paMessages></paMessages>
  <div class="row m-2">
    <div class="col-8 p-2">
      <paTable></paTable>
    </div>
    <div class="col-4 p-2">
      <paForm></paForm>
    </div>
  </div>
</body>
</html>

Listing 22-21.Adding Custom Elements in the index.html File in the src Folder

exampleApp文件夹中运行以下命令,启动 Angular 开发工具并构建项目:

ng serve

一旦初始构建过程完成,打开一个新的浏览器窗口并导航到http://localhost:4200以查看如图 22-1 所示的内容。

img/421542_4_En_22_Fig1_HTML.jpg

图 22-1。

运行示例应用

并不是示例应用中的所有东西都可以工作。您可以通过单击“创建新产品”和“编辑”按钮在两种操作模式之间切换,但是编辑功能不起作用。在接下来的章节中,我完成了核心功能并添加了新特性。

摘要

在这一章中,我创建了将在本书的这一部分使用的示例项目。基本结构与前几章中使用的示例相同,但是没有我用来演示早期特性的多余代码和标记。在下一章中,我将介绍 Reactive Extensions 包,它用于处理 Angular 应用中的更新。

二十三、使用反应式扩展

Angular 有很多特性,但最引人注目的是更改在应用中传播的方式,这样填写表单字段或单击按钮就会立即更新应用状态。但是 Angular 能够检测到的变化是有限的,有些特性需要直接使用 Angular 用来在整个应用中分发更新的库。这个库被称为反应式扩展,也称为 RxJS。

在这一章中,我将解释为什么高级项目需要使用 Reactive Extensions,介绍 Reactive Extensions 的核心特性(称为ObserverObservable),并展示如何使用它们来增强应用,以便用户可以编辑模型中的现有对象,以及创建新的对象。表 23-1 将反应式扩展放入上下文中。

表 23-1。

将反应式扩展库放在上下文中

|

问题

|

回答

|
| --- | --- |
| 这是什么? | Reactive Extensions 库提供了一种异步事件分发机制,在 Angular 内部广泛用于变化检测和事件传播。 |
| 为什么有用? | RxJS 允许标准 Angular 变化检测过程没有处理的应用部分接收重要事件的通知并做出适当的响应。因为 RxJS 是使用 Angular 所必需的,所以它的功能很容易使用。 |
| 如何使用? | 一个Observer被创建,它收集事件并通过一个Observable将它们分发给订阅者。实现这一点的最简单的方法是创建一个Subject,它同时提供ObserverObservable功能。可以使用一组操作符来管理到订阅者的事件流。 |
| 有什么陷阱或限制吗? | 一旦您掌握了基础知识,RxJS 包就很容易使用,尽管有太多的功能需要进行一些实验才能找到有效实现特定结果的组合。 |
| 还有其他选择吗? | RxJS 需要访问一些 Angular 特性,比如更新子查询和查看子查询,以及发出异步 HTTP 请求。 |

Note

本章的重点是在 Angular 项目中最有用的 RxJS 特性。RxJS 包有很多特性,如果你想了解更多信息,你可以在 https://github.com/reactivex/rxjs 查阅项目主页。

表 23-2 总结了本章内容。

表 23-2。

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 在应用中分发事件 | 使用反应式扩展 | 1–5 |
| 等待模板中的异步结果 | 使用async管道 | 6–9 |
| 使用事件实现组件间的协作 | 使用一个Observable | 10–12 |
| 管理事件流 | 使用运算符,如filtermap | 13–18 |

准备示例项目

本章使用在第二十二章中创建的 exampleApp 项目。本章不需要修改。在exampleApp文件夹中运行以下命令,启动 Angular 开发工具:

ng serve

打开一个新的浏览器选项卡并导航至http://localhost:4200以查看图 23-1 中所示的内容。

img/421542_4_En_23_Fig1_HTML.jpg

图 23-1。

运行示例应用

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

理解问题

Angular 擅长检测用于数据绑定的表达式的变化。它无缝而高效地做到了这一点,其结果是一个框架,使创建动态应用变得容易。通过单击 Create New Product 按钮,您可以在示例应用中看到工作中的变更检测。提供共享状态信息的服务由表组件更新,然后反映在控制表单组件管理的元素外观的数据绑定中,如图 23-2 所示。当您单击“创建新产品”按钮时,表单中的标题和按钮的颜色会立即改变。

img/421542_4_En_23_Fig2_HTML.jpg

图 23-2。

更新数据绑定表达式

随着应用中对象数量的增加,变化检测可能会失控,并对应用的性能造成巨大的消耗,尤其是在功能较弱的设备上,如手机和平板电脑。Angular 没有跟踪应用中的所有对象,而是专注于数据绑定,特别是当属性值改变时。

这就产生了一个问题,因为 Angular 自动管理 HTML 元素的绑定,但是它不支持对组件内部的服务变化做出响应。

通过单击表格中的一个编辑按钮,您可以看到组件中缺少更改的直接后果。尽管数据绑定会立即更新,但是当单击按钮时,组件不会收到通知,也不知道它需要更新填充表单元素以进行编辑的属性。

缺少更新意味着表单组件需要依靠最初在第十五章中描述的ngDoCheck方法来确定重要的变化何时发生,如清单 23-1 所示。

import { Component } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model"
import { MODES, SharedState } from "./sharedState.model";

@Component({
    selector: "paForm",
    templateUrl: "form.component.html",
    styleUrls: ["form.component.css"]
})
export class FormComponent {
    product: Product = new Product();
    lastId: number;

    constructor(private model: Model,
        private state: SharedState) { }

    get editing(): boolean {
        return this.state.mode == MODES.EDIT;
    }

    submitForm(form: NgForm) {
        if (form.valid) {
            this.model.saveProduct(this.product);
            this.product = new Product();
            form.reset();
        }
    }

    resetForm() {
        this.product = new Product();
    }

    ngDoCheck() {
        if (this.lastId != this.state.id) {
            this.product = new Product();
            if (this.state.mode == MODES.EDIT) {
                Object.assign(this.product, this.model.getProduct(this.state.id));
            }
            this.lastId = this.state.id;
        }
    }
}

Listing 23-1.Monitoring Service Changes in the form.component.ts File in the src/app/core Folder

要查看这一更改的效果,请单击表中的一个编辑按钮,表单中将填充要编辑的详细信息。当您编辑完表单中的值后,单击保存按钮,数据模型将被更新,反映您在表中的更改,如图 23-3 所示。

img/421542_4_En_23_Fig3_HTML.jpg

图 23-3。

更新产品

这段代码的问题是,每当 Angular 检测到应用中的任何变化,就会调用ngDoCheck方法。无论发生什么或在哪里发生,Angular 都必须调用ngDoCheck方法来给组件一个自我更新的机会。您可以最小化在ngDoCheck方法中完成的工作量,但是随着应用中指令和组件数量的增加,变更事件的数量和对ngDoCheck方法的调用数量也会增加,这会降低应用的性能。

正确处理变更检测也比您想象的要困难。例如,尝试使用示例应用编辑一个产品,单击 Save 按钮来存储模型中的更改,然后再次单击 Edit 按钮来编辑同一个产品:什么都不会发生。这是实现ngDoCheck方法时的一个常见错误,即使组件本身触发了一个变化,也会调用这个方法,混淆了ngDoCheck方法中试图避免做不必要工作的检查。总的来说,这种方法不可靠,成本高,而且扩展性不好。

用反应式扩展解决问题

反应式扩展库在 angle 应用中非常有用,因为它为发送和接收通知提供了一个简单明确的系统。这听起来不像是一个巨大的成就,但它支撑了大多数内置的 Angular 功能,并且它可以被应用直接使用,以避免使用ngDoCheck实现变化检测所带来的问题。为了准备直接使用反应式扩展,清单 23-2 定义了一个不透明的令牌,该令牌将用于提供一个使用反应式扩展分发更新的服务,并更改SharedState类,以便它定义一个构造函数。这些变化会暂时中断应用,因为 Angular 在试图实例化一个实例以用作服务时,将无法为SharedState构造函数提供值。一旦反应式扩展所需的更改完成,应用将再次开始工作。

import { InjectionToken } from "@angular/core";

export enum MODES {
    CREATE, EDIT
}

export const SHARED_STATE = new InjectionToken("shared_state");

export class SharedState {
    constructor(public mode: MODES, public id?: number) { }
}

Listing 23-2.Defining a Provider Token in the sharedState.model.ts File in the src/app/core Folder

理解可观测量

关键的反应式扩展构件是一个Observable,它代表了一个可观察到的事件序列。一个对象,比如一个组件,可以订阅一个Observable并在每次事件发生时接收一个通知,允许它只在事件被观察到时才做出响应,而不是每次应用中的任何地方发生变化时都做出响应。

an Observable提供的基本方法是subscribe,它接受三个函数作为参数,如表 23-3 所示。

表 23-3。

可观察订户论点

|

名字

|

描述

|
| --- | --- |
| onNext | 当一个新事件发生时,这个函数被调用。 |
| onError | 当错误发生时,这个函数被调用。 |
| onCompleted | 当事件序列结束时,调用该函数。 |

订阅一个Observable只需要onNext函数,尽管实现其他函数来提供错误处理并在您期望事件序列结束时做出响应是一个很好的实践。对于这个例子来说,事件不会结束,但是对于Observable的其他用途,比如处理 HTTP 响应,知道事件序列何时结束会更有用。清单 23-3 修改了表单组件,使其声明了对Observable服务的依赖。

import { Component, Inject } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model"
import { MODES, SharedState, SHARED_STATE } from "./sharedState.model";
import { Observable } from "rxjs";

@Component({
  selector: "paForm",
  templateUrl: "form.component.html",
  styleUrls: ["form.component.css"]
})
export class FormComponent {
  product: Product = new Product();
  // lastId: number;

  constructor(private model: Model,
    @Inject(SHARED_STATE) public stateEvents: Observable<SharedState>) {

    stateEvents.subscribe((update) => {
      this.product = new Product();
      if (update.id != undefined) {
        Object.assign(this.product, this.model.getProduct(update.id));
      }
      this.editing = update.mode == MODES.EDIT;
    });
  }

  editing: boolean = false;

  submitForm(form: NgForm) {
    if (form.valid) {
      this.model.saveProduct(this.product);
      this.product = new Product();
      form.reset();
    }
  }

  resetForm() {
    this.product = new Product();
  }

  //ngDoCheck() {
  //  if (this.lastId != this.state.id) {
  //    this.product = new Product();
  //    if (this.state.mode == MODES.EDIT) {
  //      Object.assign(this.product, this.model.getProduct(this.state.id));
  //    }
  //    this.lastId = this.state.id;
  //  }
  //}
}

Listing 23-3.Using an Observable in the form.component.ts File in the src/app/core Folder

Reactive Extensions NPM 包为它提供的每种类型都包含了单独的 JavaScript 模块,因此您可以从rxjs模块导入Observable类型。

为了接收通知,组件声明了对SHARED_STATE服务的依赖,该服务作为Observable<SharedState>对象被接收。这个对象是一个Observerable,它的通知将是SharedState对象,这将代表用户启动的编辑或创建操作。该组件调用Observable.subscribe方法,该方法提供一个接收每个SharedState对象并使用它来更新其状态的函数。

What About Promises?

您可能习惯于使用Promise对象来表示异步活动。Observable s 执行相同的基本任务,但更灵活,功能更多。Angular 确实提供了对使用Promise对象的支持,这在您转换到 Angular 和使用依赖于Promise对象的库时会很有用。

反应式扩展提供了一个Observable.fromPromise方法,该方法将使用一个Promise作为事件源来创建一个Observable。如果你有一个Observable并且因为某种原因需要一个Promise,还有一个Observable.toPromise方法。

此外,还有一些 Angular 功能可以让您选择使用,例如第二十七章中描述的防护功能,这两种功能都支持。

但是 Reactive Extensions 库是使用 Angular 的一个重要部分,你会在本书这一部分的章节中经常遇到它。我建议您在遇到Observable时使用它,并尽量减少与Promise对象的相互转换。

理解观察者

反应式扩展Observer提供了创建更新的机制,使用表 23-4 中描述的方法。

表 23-4。

观察者方法

|

名字

|

描述

|
| --- | --- |
| next(value) | 此方法使用指定的值创建一个新事件。 |
| error(errorObject) | 此方法报告一个错误,用参数描述,该参数可以是任何对象。 |
| complete() | 此方法结束序列,指示不再发送事件。 |

清单 23-4 更新了表格组件,这样当用户点击 Create New Product 按钮或其中一个编辑按钮时,它会使用一个Observer来发送事件。

import { Component, Inject } from "@angular/core";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { MODES, SharedState, SHARED_STATE } from "./sharedState.model";
import { Observer } from "rxjs";

@Component({
    selector: "paTable",
    templateUrl: "table.component.html"
})
export class TableComponent {

    constructor(private model: Model,
        @Inject(SHARED_STATE) public observer: Observer<SharedState>) { }

    getProduct(key: number): Product {
        return this.model.getProduct(key);
    }

    getProducts(): Product[] {
        return this.model.getProducts();
    }

    deleteProduct(key: number) {
        this.model.deleteProduct(key);
    }

    editProduct(key: number) {
        this.observer.next(new SharedState(MODES.EDIT, key));
    }

    createProduct() {
        this.observer.next(new SharedState(MODES.CREATE));
    }
}

Listing 23-4.Using an Observer in the table.component.ts File in the src/app/core Folder

该组件声明了对SHARED_STATE服务的依赖,该服务作为一个Observer<SharedState>对象被接收,这意味着一个Observer将发送使用SharedState对象描述的事件。editProductcreateProduct方法已经更新,因此它们调用观察器上的next方法来表示状态的变化。

理解主题

这两个组件都使用SHARED_STATE标记声明了对服务的依赖,但是每个组件都希望获得不同的类型:表格组件希望接收一个Observer<SharedState>对象,而表单组件希望接收一个Observable<SharedState>对象。

反应式扩展库提供了Subject类,它实现了ObserverObservable功能。这使得创建允许用单个对象产生和消费事件的服务变得容易。在清单 23-5 中,我已经修改了在@NgModule装饰者的providers属性中声明的服务,以使用一个Subject对象。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { FormsModule } from "@angular/forms";
import { ModelModule } from "../model/model.module";
import { TableComponent } from "./table.component";
import { FormComponent } from "./form.component";
import { SharedState, SHARED_STATE } from "./sharedState.model";
import { Subject } from "rxjs";

@NgModule({
    imports: [BrowserModule, FormsModule, ModelModule],
    declarations: [TableComponent, FormComponent],
    exports: [ModelModule, TableComponent, FormComponent],
    providers: [{ provide: SHARED_STATE, useValue: new Subject<SharedState>() }]
})
export class CoreModule { }

Listing 23-5.Changing the Service in the core.module.ts File in the src/app/core Folder

基于值的提供者告诉 Angular 使用一个Subject<SharedState>对象来解析对SHARED_STATE令牌的依赖,这将为组件提供它们协作所需的功能。

结果是,更改共享服务使其成为一个Subject允许表格组件发出不同的事件,这些事件由表单组件接收并用于更新其状态,而不需要依赖笨拙且昂贵的ngDoCheck方法。由于订阅了Observable的组件知道它接收到的所有事件必定源自于Observer,因此也不需要试图找出哪些变化是由本地组件生成的,哪些变化来自于其他地方。这意味着像不能编辑同一个产品两次这样的琐碎问题会消失,如图 23-4 所示。

img/421542_4_En_23_Fig4_HTML.jpg

图 23-4。

使用反应式扩展的效果

The Different Types of Subject

清单 23-5 使用了Subject类,这是创建既是Observer又是Observable的对象的最简单方法。它的主要限制是,当使用subscribe方法创建一个新订户时,直到下次调用next方法时,它才会收到一个事件。如果您正在动态地创建组件或指令的实例,并且希望它们一创建就有一些上下文数据,那么这可能是没有用的。

Reactive Extensions 库包含了一些专门的Subject类的实现,可以用来解决这个问题。BehaviorSubject类跟踪它处理的最后一个事件,并在新订阅者调用subscribe方法时将其发送给新订阅者。ReplaySubject类做了一些类似的事情,除了它跟踪它的所有事件,并把它们发送给新的订阅者,允许他们在订阅之前赶上发送的任何事件。

使用异步管道

Angular 包含了async管道,它可以用来在视图中直接使用Observable对象,选择从事件序列中接收到的最后一个对象。这是一个不纯的管道,如第十八章所述,因为它的变化是由使用它的视图之外驱动的,这意味着它的transform方法将被经常调用,即使没有从Observable接收到新的事件。清单 23-6 显示了将async管道添加到由表单组件管理的视图中。

<div class="bg-primary text-white p-2" [class.bg-warning]="editing">
  <h5>{{editing  ? "Edit" : "Create"}} Product</h5>
  Last Event: {{ stateEvents | async | json }}
</div>

<form novalidate #form="ngForm" (ngSubmit)="submitForm(form)" (reset)="resetForm()">

     ...elements omitted for brevity...

</form>

Listing 23-6.Using the Async Pipe in the form.component.html File in the src/app/core Folder

字符串插值绑定表达式从组件(即Observable<SharedState>对象)获取stateEvents属性,并将其传递给async管道,后者跟踪最近接收到的事件。然后,async过滤器将事件传递给json管道,后者创建事件对象的 JSON 表示。结果是你可以跟踪表单组件接收到的事件,如图 23-5 所示。

img/421542_4_En_23_Fig5_HTML.jpg

图 23-5。

显示可观察的事件

这不是最有用的数据显示,但它确实提供了一些有用的调试见解。在这种情况下,最近的事件的mode值为 1,这对应于编辑模式,而id值为 4,这是角标志产品的 ID。

将异步管道与自定义管道一起使用

async管道可以与定制管道一起使用,以更加用户友好的方式呈现事件数据。为了演示,我在src/app/core文件夹中添加了一个名为state.pipe.ts的文件,并用它来定义清单 23-7 中所示的管道。

import { Pipe } from "@angular/core";
import { SharedState, MODES } from "./sharedState.model";
import { Model } from "../model/repository.model";

@Pipe({
    name: "formatState",
    pure: true
})
export class StatePipe {

    constructor(private model: Model) { }

    transform(value: any): string {
        if (value instanceof SharedState) {
            let state = value as SharedState;
            return MODES[state.mode] + (state.id != undefined
                ? ` ${this.model.getProduct(state.id).name}` : "");
        } else {
            return "<No Data>"
        }
    }
}

Listing 23-7.The Contents of the state.pipe.ts File in the src/app/core Folder

在清单 23-8 中,我已经将管道添加到核心模块的声明集中。

Tip

TypeScript 枚举有一个有用的功能,通过它可以获得值的名称。因此,例如,表达式MODES[1]将返回EDIT,因为这是索引 1 处的MODES枚举值的名称。清单 23-7 中的管道使用这个特性向用户呈现状态更新。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { FormsModule } from "@angular/forms";
import { ModelModule } from "../model/model.module";
import { TableComponent } from "./table.component";
import { FormComponent } from "./form.component";
import { SharedState, SHARED_STATE } from "./sharedState.model";
import { Subject } from "rxjs";
import { StatePipe } from "./state.pipe";

@NgModule({
    imports: [BrowserModule, FormsModule, ModelModule],
    declarations: [TableComponent, FormComponent, StatePipe],
    exports: [ModelModule, TableComponent, FormComponent],
    providers: [{ provide: SHARED_STATE, useValue: new Subject<SharedState>() }]
})
export class CoreModule { }

Listing 23-8.Registering the Pipe in the core.module.ts File in the src/app/core Folder

清单 23-9 显示了用于替换表单组件管理的模板中内置json管道的新管道。

<div class="bg-primary text-white p-2" [class.bg-warning]="editing">
  <h5>{{editing  ? "Edit" : "Create"}} Product</h5>
  Last Event: {{ stateEvents | async | formatState }}
</div>

<form novalidate #form="ngForm" (ngSubmit)="submitForm(form)" (reset)="resetForm()">

    ...elements omitted for brevity...

</form>

Listing 23-9.Applying a Custom Pipe in the form.component.html File in the src/app/core Folder

这个例子演示了从Observable对象接收的事件可以像任何其他对象一样被处理和转换,如图 23-6 所示,它说明了一个定制管道是如何建立在由async管道提供的核心功能之上的。

img/421542_4_En_23_Fig6_HTML.jpg

图 23-6。

格式化通过可观察序列接收的值

向上扩展应用功能模块

相同的反应式扩展构件可以在应用中的任何地方使用,并使构件之间的协作变得容易,即使反应式扩展的使用并没有暴露给应用的所有协作部分。作为示范,清单 23-10 展示了向MessageService类添加一个Subject来分发应该显示给用户的消息。

import { Injectable } from "@angular/core";
import { Message } from "./message.model";
import { Observable } from "rxjs";
import { Subject } from "rxjs";

@Injectable()
export class MessageService {
    private subject = new Subject<Message>();

    reportMessage(msg: Message) {
        this.subject.next(msg);
    }

    get messages(): Observable<Message> {
        return this.subject;
    }
}

Listing 23-10.Using a Subject in the message.service.ts File in the src/app/messages Folder

以前的消息服务实现只支持应该向用户显示的消息的单个接收者。我本来可以添加管理多个接收者的代码,但是考虑到应用已经使用了反应式扩展,将这项工作委托给Subject类要简单得多,它可以很好地扩展,如果应用中有多个订阅者,不需要任何额外的代码或测试。

清单 23-11 显示了消息组件的相应变化,它将向用户显示最近的消息。

import { Component } from "@angular/core";
import { MessageService } from "./message.service";
import { Message } from "./message.model";
import { Observable } from "rxjs";

@Component({
    selector: "paMessages",
    templateUrl: "message.component.html",
})
export class MessageComponent {
    lastMessage: Message;

    constructor(messageService: MessageService) {
        messageService.messages.subscribe(m => this.lastMessage = m);
    }
}

Listing 23-11.Observing Messages in the message.component.ts File in the src/app/messages Folder

最后一步是生成一些要显示的消息。在清单 23-12 中,我修改了核心特性模块的配置,这样SHARED_STATE提供者使用一个工厂函数来创建用于分发状态变化事件的Subject,并添加一个订阅,将事件提供给消息服务。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { FormsModule } from "@angular/forms";
import { ModelModule } from "../model/model.module";
import { TableComponent } from "./table.component";
import { FormComponent } from "./form.component";
import { SharedState, SHARED_STATE } from "./sharedState.model";
import { Subject } from "rxjs";
import { StatePipe } from "./state.pipe";
import { MessageModule } from "../messages/message.module";
import { MessageService } from "../messages/message.service";
import { Message } from "../messages/message.model";
import { Model } from "../model/repository.model";
import { MODES } from "./sharedState.model";

@NgModule({
    imports: [BrowserModule, FormsModule, ModelModule, MessageModule],
    declarations: [TableComponent, FormComponent, StatePipe],
    exports: [ModelModule, TableComponent, FormComponent],
    providers: [{
        provide: SHARED_STATE,
        deps: [MessageService, Model],
        useFactory: (messageService, model) => {
            let subject = new Subject<SharedState>();
            subject.subscribe(m => messageService.reportMessage(
                    new Message(MODES[m.mode] + (m.id != undefined
                        ? ` ${model.getProduct(m.id).name}` : "")))
                );
            return subject;
        }
    }]
})
export class CoreModule { }

Listing 23-12.Feeding the Message Service in the core.module.ts File in the src/app/core Folder

代码有点乱,但结果是表格组件发送的每个状态变化事件都被消息组件显示出来,如图 23-7 。Reactive Extensions 使连接应用的各个部分变得很容易,清单中的代码如此密集的原因是它还使用了Model服务从数据模型中查找名称,以使事件更容易阅读。

img/421542_4_En_23_Fig7_HTML.jpg

图 23-7。

在消息服务中使用反应式扩展

超越基础

前面章节中的例子涵盖了ObservableObserverSubject的基本用法。然而,在使用可用于高级或复杂应用的反应式扩展时,还有更多的功能可用。全套操作在 https://github.com/reactivex/rxjs 中描述,但在这一章中,我演示了一小部分在 Angular 应用中最可能需要的特性,如表 23-5 中所述。表格中描述的方法用于控制从Observable对象接收事件的方式。

表 23-5。

用于选择事件的有用的反应式扩展方法

|

名字

|

描述

|
| --- | --- |
| filter | 该方法调用一个函数来评估从Observable接收的每个事件,并丢弃该函数返回的事件false。 |
| map | 该方法调用一个函数来转换从Observable接收的每个事件,并传递该函数返回的对象。 |
| distinctUntilChanged | 此方法禁止显示事件,直到事件对象发生变化。 |
| skipWhile | 此方法筛选事件,直到满足指定的条件,然后将事件转发给订阅者。 |
| takeWhile | 此方法将事件传递给订阅服务器,直到满足指定的条件,之后筛选事件。 |

过滤事件

并只选择那些需要的。清单 23-13 展示了如何使用filter方法过滤出与特定产品相关的事件。

import { Component, Inject } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { MODES, SharedState, SHARED_STATE } from "./sharedState.model";
import { Observable } from "rxjs";
import { filter } from "rxjs/operators";

@Component({
    selector: "paForm",
    templateUrl: "form.component.html",
    styleUrls: ["form.component.css"]
})
export class FormComponent {
    product: Product = new Product();

    constructor(private model: Model,
        @Inject(SHARED_STATE) public stateEvents: Observable<SharedState>) {

            stateEvents.pipe(filter(state => state.id != 3))
            .subscribe((update) => {
                this.product = new Product();
                if (update.id != undefined) {
                    Object.assign(this.product, this.model.getProduct(update.id));
                }
                this.editing = update.mode == MODES.EDIT;
            });
    }

    editing: boolean = false;

    submitForm(form: NgForm) {
        if (form.valid) {
            this.model.saveProduct(this.product);
            this.product = new Product();
            form.reset();
        }
    }

    resetForm() {
        this.product = new Product();
    }
}

Listing 23-13.Filtering Events in the form.component.ts File in the src/app/core Folder

要使用表 23-5 中描述的方法,需要一个rxjs/operators包的import语句,如下所示:

...
import { filter } from "rxjs/operators";
...

使用pipe方法将filter方法应用于Observable,如下所示:

...
stateEvents.pipe(filter(state => state.id != 3)).subscribe((update) => {
...

filter方法的参数是一个选择所需事件的语句,这些事件被传递给使用subscribe方法提供的函数。

您可以通过单击足球产品的 Edit 按钮来查看效果,该产品具有过滤器函数正在检查的 ID。async管道显示一个EDIT事件已经通过共享服务发送,但是filter方法阻止它被组件的subscribe函数接收。结果是表单没有反映状态的变化,也没有填充所选择的产品信息,如图 23-8 所示。

img/421542_4_En_23_Fig8_HTML.jpg

图 23-8。

过滤事件

转变事件

map方法用于转换从Observable接收的对象。您可以使用此方法以任何方式转换事件对象,方法的结果将替换事件对象。清单 23-14 使用map方法来改变事件对象属性的值。

import { Component, Inject } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { MODES, SharedState, SHARED_STATE } from "./sharedState.model";
import { Observable } from "rxjs";
import { filter, map } from "rxjs/operators";

@Component({
    selector: "paForm",
    templateUrl: "form.component.html",
    styleUrls: ["form.component.css"]
})
export class FormComponent {
    product: Product = new Product();

    constructor(private model: Model,
        @Inject(SHARED_STATE) public stateEvents: Observable<SharedState>) {

            stateEvents
            .pipe(map(state => new SharedState(state.mode, state.id == 5
                ? 1 : state.id)))
            .pipe(filter(state => state.id != 3))
            .subscribe((update) => {
                this.product = new Product();
                if (update.id != undefined) {
                    Object.assign(this.product, this.model.getProduct(update.id));
                }
                this.editing = update.mode == MODES.EDIT;
            });
    }

    editing: boolean = false;

    submitForm(form: NgForm) {
        if (form.valid) {
            this.model.saveProduct(this.product);
            this.product = new Product();
            form.reset();
        }
    }

    resetForm() {
        this.product = new Product();
    }
}

Listing 23-14.Transforming Events in the form.component.ts File in the src/app/core Folder

在这个例子中,传递给map方法的函数寻找id值为 5 的SharedState对象,当找到时,将值改为 1。结果是点击思维帽产品的编辑按钮选择 Kayak 产品进行编辑,如图 23-9 所示。

img/421542_4_En_23_Fig9_HTML.jpg

图 23-9。

转变事件

Caution

当使用map方法时,不要修改作为函数参数接收的对象。该对象依次传递给所有订阅者,您所做的任何更改都会影响后续的订阅者。这意味着一些订阅者将接收未修改的对象,一些订阅者将接收由map方法返回的对象。相反,创建一个新对象,如清单 23-14 所示。

注意,用于准备和创建对一个Observable对象的订阅的方法可以链接在一起。在这个例子中,map方法的结果通过管道传递给filter方法,后者的结果再传递给subscribe方法的函数。以这种方式将方法链接在一起允许为处理和接收事件的方式创建复杂的规则。

使用不同的事件对象

map方法可以用来产生任何对象,并且不限于改变它接收的对象的属性值。在清单 23-15 中,我使用了map方法来产生一个数字,它的值编码了操作和它所应用的对象。

...
constructor(private model: Model,
    @Inject(SHARED_STATE) public stateEvents: Observable<SharedState>) {
    stateEvents
        .pipe(map(state => state.mode == MODES.EDIT ? state.id : -1))
        .pipe(filter(id => id != 3))
        .subscribe((id) => {
            this.editing = id != -1;
            this.product = new Product();
            if (id != -1) {
                Object.assign(this.product, this.model.getProduct(id))
            }
        });
}
...

Listing 23-15.Projecting a Different Type in the form.component.ts File in the src/app/core Folder

让一个简单的数据类型表示一个操作并指定它的目标没有什么好处。事实上,它通常会导致问题,因为这意味着组件假设模型中永远不会有一个对象的id属性为-1。但是作为一个简单的例子,它演示了map方法如何投射不同的类型,以及这些类型如何沿着反应式扩展方法链传递,这意味着由map方法产生的number值被接收为要由filter方法处理的值,并依次由subscribe方法处理,这两个方法的函数都被更新以处理新的数据值。

只接收不同的事件

distinctUntilChanged方法过滤事件序列,以便只将不同的值传递给订阅者。要查看可以用它来解决的问题,单击 Kayak 产品的 Edit 按钮并更改Category字段的值。不点击保存按钮,再次点击 Kayak 的编辑按钮,您将看到您的编辑被丢弃。在清单 23-16 中,我已经将distinctUntilChanged方法添加到方法链中,这样它就可以应用于由map方法产生的number值。只有不同的值将被转发给filtersubscribe方法。

import { Component, Inject } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { MODES, SharedState, SHARED_STATE } from "./sharedState.model";
import { Observable } from "rxjs";
import { filter, map, distinctUntilChanged } from "rxjs/operators";

@Component({
    selector: "paForm",
    templateUrl: "form.component.html",
    styleUrls: ["form.component.css"]
})
export class FormComponent {
    product: Product = new Product();

    constructor(private model: Model,
        @Inject(SHARED_STATE) public stateEvents: Observable<SharedState>) {
            stateEvents
            .pipe(map(state => state.mode == MODES.EDIT ? state.id : -1))
            .pipe(distinctUntilChanged())
            .pipe(filter(id => id != 3))
            .subscribe((id) => {
                this.editing = id != -1;
                this.product = new Product();
                if (id != -1) {
                    Object.assign(this.product, this.model.getProduct(id))
                }
            });
    }

    editing: boolean = false;

    submitForm(form: NgForm) {
        if (form.valid) {
            this.model.saveProduct(this.product);
            this.product = new Product();
            form.reset();
        }
    }

    resetForm() {
        this.product = new Product();
    }
}

Listing 23-16.Preventing Duplicate Events in the form.component.ts File in the src/app/core Folder

如果您重复 Kayak 编辑过程,您将看到当您为正在编辑的产品单击编辑按钮时,更改不再被丢弃,因为这将产生与前一事件相同的值。编辑不同的产品将导致map方法发出不同的number值,该值将由distinctUntilChanged方法传递。

使用自定义等式检查器

distinctUntilChanged方法可以在像number这样的简单数据类型之间进行简单的比较,但是它不知道如何比较对象,并且会假设任意两个对象是不同的。为了解决这个问题,你可以指定一个比较函数来检查事件是否不同,如清单 23-17 所示。

import { Component, Inject } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { MODES, SharedState, SHARED_STATE } from "./sharedState.model";
import { Observable } from "rxjs";
import { filter, map, distinctUntilChanged } from "rxjs/operators";

@Component({
    selector: "paForm",
    templateUrl: "form.component.html",
    styleUrls: ["form.component.css"]
})
export class FormComponent {
    product: Product = new Product();

    constructor(private model: Model,
        @Inject(SHARED_STATE) public stateEvents: Observable<SharedState>) {
            stateEvents
            .pipe(distinctUntilChanged((firstState, secondState) =>
                firstState.mode == secondState.mode
                    && firstState.id == secondState.id))
            .subscribe(update => {
                this.product = new Product();
                if (update.id != undefined) {
                    Object.assign(this.product, this.model.getProduct(update.id));
                }
                this.editing = update.mode == MODES.EDIT;
            });
    }

    editing: boolean = false;

    submitForm(form: NgForm) {
        if (form.valid) {
            this.model.saveProduct(this.product);
            this.product = new Product();
            form.reset();
        }
    }

    resetForm() {
        this.product = new Product();
    }
}

Listing 23-17.Using a Equality Checker in the form.component.ts File in the src/app/core Folder

这个清单删除了mapfilter方法,并为distinctUntilChanged方法提供了一个函数,该函数通过比较SharedState对象的modeid属性来比较它们。不同的对象被传递给提供给subscribe方法的函数。

接受和跳过事件

skipWhiletakeWhile方法用于指定导致事件被过滤或传递给订阅者的条件。必须小心使用这些方法,因为很容易指定将永久筛选来自订阅服务器的事件的条件。在清单 23-18 中,我使用了skipWhile方法来过滤事件,直到用户点击创建新产品按钮,之后事件将被传递。

import { Component, Inject } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { MODES, SharedState, SHARED_STATE } from "./sharedState.model";
import { Observable } from "rxjs";
import { filter, map, distinctUntilChanged, skipWhile } from "rxjs/operators";

@Component({
    selector: "paForm",
    templateUrl: "form.component.html",
    styleUrls: ["form.component.css"]
})
export class FormComponent {
    product: Product = new Product();

    constructor(private model: Model,
        @Inject(SHARED_STATE) public stateEvents: Observable<SharedState>) {
            stateEvents
            .pipe(skipWhile(state => state.mode == MODES.EDIT))
            .pipe(distinctUntilChanged((firstState, secondState) =>
                firstState.mode == secondState.mode
                    && firstState.id == secondState.id))
            .subscribe(update => {
                this.product = new Product();
                if (update.id != undefined) {
                    Object.assign(this.product, this.model.getProduct(update.id));
                }
                this.editing = update.mode == MODES.EDIT;
            });
    }

    editing: boolean = false;

    submitForm(form: NgForm) {
        if (form.valid) {
            this.model.saveProduct(this.product);
            this.product = new Product();
            form.reset();
        }
    }

    resetForm() {
        this.product = new Product();
    }
}

Listing 23-18.Skipping Events in the form.component.ts File in the src/app/core Folder

单击表中的编辑按钮仍然会生成事件,这些事件将由订阅到Subjectasync管道显示,没有任何过滤或跳过。但是表单组件不接收那些事件,如图 23-10 所示,因为它的订阅被skipWhile方法过滤,直到接收到一个mode属性不是MODES.EDIT的事件。单击 Create New Product 按钮会生成一个结束跳过的事件,之后组件将接收所有事件。

img/421542_4_En_23_Fig10_HTML.jpg

图 23-10。

跳过事件

摘要

在这一章中,我介绍了 Reactive Extensions 包,并解释了如何用它来处理应用中不受 Angular 变化检测过程管理的部分的变化。我演示了如何使用ObservableObserverSubject对象在应用中分发事件,向您展示了内置的async管道是如何工作的,并介绍了一些最有用的操作符,这些操作符是 Reactive Extensions 库为控制事件流向订户而提供的。在下一章,我将解释如何在 Angular 应用中进行异步 HTTP 请求,以及如何使用 RESTful web 服务。

二十四、发出 HTTP 请求

从第十一章开始的所有例子都依赖于硬连线到应用中的静态数据。在这一章中,我将演示如何使用异步 HTTP 请求,通常称为 Ajax 请求,与 web 服务进行交互,将真实数据输入到应用中。表 24-1 将 HTTP 请求放在上下文中。

表 24-1。

将异步 HTTP 请求放在上下文中

|

问题

|

回答

|
| --- | --- |
| 它们是什么? | 异步 HTTP 请求是由浏览器代表应用发送的 HTTP 请求。术语异步是指在浏览器等待服务器响应的同时,应用继续运行。 |
| 它们为什么有用? | 异步 HTTP 请求允许 Angular 应用与 web 服务进行交互,以便将持久数据加载到应用中,并将更改发送到服务器并保存。 |
| 它们是如何使用的? | 使用HttpClient类发出请求,该类通过依赖注入作为服务交付。这个类为浏览器的XMLHttpRequest特性提供了一个 Angular 友好的包装器。 |
| 有什么陷阱或限制吗? | 使用 Angular HTTP 特性需要使用反应式扩展Observable对象,如第二十三章所述。 |
| 还有其他选择吗? | 如果您愿意,您可以直接使用浏览器的XMLHttpRequest对象,并且一些应用——那些不需要处理持久数据的应用——可以在根本不发出 HTTP 请求的情况下编写。 |

表 24-2 总结了本章内容。

表 24-2。

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 在 Angular 应用中发送 HTTP 请求 | 使用Http服务 | 1–8 |
| 执行休息操作 | 使用 HTTP 方法和 URL 来指定操作和该操作的目标 | 9–11 |
| 提出跨来源请求 | 使用HttpClient服务自动支持 CORS(也支持 JSONP 请求) | 12–13 |
| 在请求中包含标头 | 在Request对象中设置headers属性 | 14–15 |
| 响应 HTTP 错误 | 创建错误处理程序类 | 16–19 |

准备示例项目

本章使用在第二十二章中创建的 exampleApp 项目。对于这一章,我依赖一个用 JSON 数据响应 HTTP 请求的服务器。运行exampleApp文件夹中清单 24-1 所示的命令,将json-server包添加到项目中。

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

npm install json-server@0.16.0

Listing 24-1.Adding a Package to the Project

我在package.json文件的scripts部分添加了一个条目来运行json-server包,如清单 24-2 所示。

...
"scripts": {
    "ng": "ng",
    "start": "ng serve",
    "build": "ng build",
    "test": "ng test",
    "lint": "ng lint",
    "e2e": "ng e2e",
    "json": "json-server --p 3500 restData.js"
},
...

Listing 24-2.Adding a Script Entry in the package.json File in the exampleApp Folder

配置模型特征模块

@angular/common/http JavaScript 模块包含一个名为HttpClientModule的 Angular 模块,在创建 HTTP 请求之前,必须将它导入到应用的根模块或某个特性模块中。在清单 24-3 中,我将模块导入到模型模块中,这是示例应用中的自然位置,因为我将使用 HTTP 请求用数据填充模型。

import { NgModule } from "@angular/core";
import { StaticDataSource } from "./static.datasource";
import { Model } from "./repository.model";
import { HttpClientModule } from "@angular/common/http";

@NgModule({
  imports: [HttpClientModule],
  providers: [Model, StaticDataSource]
})
export class ModelModule { }

Listing 24-3.Importing a Module in the model.module.ts File in the src/app/model Folder

创建数据文件

为了给json-server包提供一些数据,我在exampleApp文件夹中添加了一个名为restData.js的文件,并添加了清单 24-4 中所示的代码。

module.exports = function () {
    var data = {
        products: [
            { id: 1, name: "Kayak", category: "Watersports", price: 275 },
            { id: 2, name: "Lifejacket", category: "Watersports", price: 48.95 },
            { id: 3, name: "Soccer Ball", category: "Soccer", price: 19.50 },
            { id: 4, name: "Corner Flags", category: "Soccer", price: 34.95 },
            { id: 5, name: "Stadium", category: "Soccer", price: 79500 },
            { id: 6, name: "Thinking Cap", category: "Chess", price: 16 },
            { id: 7, name: "Unsteady Chair", category: "Chess", price: 29.95 },
            { id: 8, name: "Human Chess Board", category: "Chess", price: 75 },
            { id: 9, name: "Bling Bling King", category: "Chess", price: 1200 }
        ]
    }
    return data
}

Listing 24-4.The Contents of the restData.js File in the exampleApp Folder

json-server包可以处理 JSON 或 JavaScript 文件。如果您使用一个 JSON 文件,那么它的内容将被修改以反映客户的变更请求。我选择了 JavaScript 选项,它允许以编程方式生成数据,并且意味着重新启动该过程将返回到原始数据。

更新表单组件

在第二十三章中,我配置了管理 HTML 表单的组件来忽略由表格组件生成的事件,直到第一次点击创建新产品按钮。为了避免混淆结果,清单 24-5 禁用了应用于ObservableskipWhiledistinctUntilChanged方法。

...
constructor(private model: Model,
    @Inject(SHARED_STATE) public stateEvents: Observable<SharedState>) {
        stateEvents
        // .pipe(skipWhile(state => state.mode == MODES.EDIT))
        // .pipe(distinctUntilChanged((firstState, secondState) =>
        //     firstState.mode == secondState.mode
        //         && firstState.id == secondState.id))
        .subscribe(update => {
            this.product = new Product();
            if (update.id != undefined) {
                Object.assign(this.product, this.model.getProduct(update.id));
            }
            this.editing = update.mode == MODES.EDIT;
        });
}
...

Listing 24-5.Disabling Event Skipping in the form.component.ts File in the src/app/core Folder

运行示例项目

打开一个新的命令提示符,导航到exampleApp文件夹,运行以下命令启动数据服务器:

npm run json

这个命令将启动json-server,它将监听端口 3500 上的 HTTP 请求。打开新的浏览器窗口并导航至http://localhost:3500/products/2。服务器将使用以下数据进行响应:

{ "id": 2, "name": "Lifejacket", "category": "Watersports", "price": 48.95 }

保持json-server运行,并通过在exampleApp文件夹中运行以下命令,使用单独的命令提示符启动 Angular 开发工具:

ng serve

使用浏览器导航至http://localhost:4200以查看图 24-1 中所示的内容。

img/421542_4_En_24_Fig1_HTML.jpg

图 24-1。

运行示例应用

理解 RESTful Web 服务

向应用交付数据的最常见方法是使用表述性状态转移模式(称为 REST)来创建数据 web 服务。REST 没有详细的规范,这导致很多不同的方法都打着 RESTful 的旗号。然而,在 web 应用开发中有一些有用的统一思想。

RESTful web 服务的核心前提是包含 HTTP 的特性,以便请求方法——也称为动词——指定服务器要执行的操作,请求 URL 指定操作将应用到的一个或多个数据对象。

例如,在示例应用中,下面是一个可能指向特定产品的 URL:

http://localhost:3500/products/2

URL 的第一段—products—用于指示将被操作的对象集合,并允许单个服务器提供多个服务,每个服务都有自己的数据。第二个片段——2——在products集合中选择一个单独的对象。在本例中,id属性的值唯一地标识了一个对象,并将在 URL 中使用,在本例中,指定了Lifejacket对象。

用于发出请求的 HTTP 动词或方法告诉 RESTful 服务器应该对指定的对象执行什么操作。在上一节中测试 RESTful 服务器时,浏览器发送了一个 HTTP GET 请求,服务器将其解释为检索指定对象并将其发送给客户机的指令。正是由于这个原因,浏览器显示了一个表示Lifejacket对象的 JSON。

表 24-3 显示了 HTTP 方法和 URL 的最常见组合,并解释了当发送到 RESTful 服务器时它们各自的作用。

表 24-3。

RESTful Web 服务中常见的 HTTP 动词及其作用

|

动词

|

统一资源定位器

|

描述

|
| --- | --- | --- |
| GET | /products | 这种组合检索products集合中的所有对象。 |
| GET | /products/2 | 这个组合从products集合中检索出id2的对象。 |
| POST | /products | 该组合用于向products集合添加一个新对象。请求体包含新对象的 JSON 表示。 |
| PUT | /products/2 | 该组合用于替换products集合中id2的对象。请求体包含替换对象的 JSON 表示。 |
| PATCH | /products/2 | 这个组合用于更新集合products中对象属性的子集,集合id2。请求体包含要更新的属性和新值的 JSON 表示。 |
| DELETE | /products/2 | 该组合用于从products集合中删除id2的产品。 |

需要谨慎,因为一些 RESTful web 服务的工作方式可能存在相当大的差异,这是由用于创建它们的框架和开发团队的偏好的差异造成的。确认 web 服务如何使用动词以及在 URL 和请求正文中需要什么来执行操作是很重要的。

一些常见的变体包括不接受任何包含id值的请求主体的 web 服务(以确保它们是由服务器的数据存储唯一生成的),或者不支持所有动词的任何 web 服务(通常忽略PATCH请求,只接受使用PUT动词的更新)。

替换静态数据源

从 HTTP 请求开始的最佳方式是将示例应用中的静态数据源替换为从 RESTful web 服务中检索数据的数据源。这将为描述 Angular 如何支持 HTTP 请求以及如何将它们集成到应用中提供基础。

创建新的数据源服务

为了创建一个新的数据源,我在src/app/model文件夹中添加了一个名为rest.datasource.ts的文件,并添加了清单 24-6 中所示的语句。

import { Injectable, Inject, InjectionToken } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Observable } from "rxjs";
import { Product } from "./product.model";

export const REST_URL = new InjectionToken("rest_url");

@Injectable()
export class RestDataSource {

  constructor(private http: HttpClient,
    @Inject(REST_URL) private url: string) { }

  getData(): Observable<Product[]> {
    return this.http.get<Product[]>(this.url);
  }
}

Listing 24-6.The Contents of the rest.datasource.ts File in the src/app/model Folder

这是一个看起来简单的类,但是有一些重要的工作特性,我将在接下来的章节中描述。

设置 HTTP 请求

Angular 提供了通过HttpClient类进行异步 HTTP 请求的能力,该类在@angular/common/http JavaScript 模块中定义,并作为服务在HttpClientModule功能模块中提供。数据源使用其构造函数声明了对HttpClient类的依赖,如下所示:

...
constructor(private http: HttpClient, @Inject(REST_URL) private url: string) { }
...

使用另一个构造函数参数,这样请求发送到的 URL 就不必硬连接到数据源中。当我配置特性模块时,我将使用REST_URL不透明令牌创建一个提供者。通过构造函数接收的HttpClient对象用于在数据源的getData方法中发出 HTTP GET 请求,如下所示:

...
getData(): Observable<Product[]> {
    return this.http.get<Product[]>(this.url);
}
...

HttpClient类定义了一组发出 HTTP 请求的方法,每个方法使用不同的 HTTP 动词,如表 24-4 中所述。

表 24-4。

HttpClient 方法

|

名字

|

描述

|
| --- | --- |
| get(url) | 此方法向指定的 URL 发送 GET 请求。 |
| post(url, body) | 此方法使用指定的对象作为主体发送 POST 请求。 |
| put(url, body) | 此方法使用指定的对象作为主体发送 PUT 请求。 |
| patch(url, body) | 此方法使用指定的对象作为主体发送修补请求。 |
| delete(url) | 此方法向指定的 URL 发送删除请求。 |
| head(url) | 这个方法发送一个 HEAD 请求,它与 GET 请求具有相同的效果,只是服务器只返回头部,而不返回请求体。 |
| options(url) | 此方法向指定的 URL 发送选项请求。 |
| request(method, url, options) | 该方法可用于发送带有任何动词的请求,如“整合 HTTP 请求”一节所述。 |

Tip

表 24-4 中的方法接受一个可选的配置对象,如“配置请求头”一节所示。

处理响应

表 24-4 中描述的方法接受一个类型参数,HttpClient类用它来解析从服务器收到的响应。RESTful web 服务器返回 JSON 数据,这已经成为 web 服务使用的事实上的标准,HttpClient对象会自动将响应转换成一个Observable,当它完成时会产生一个类型参数的实例。这意味着,如果您调用get方法,例如,使用一个Product[]类型参数,那么来自get方法的响应将是一个Observable<Product[]>,它代表 HTTP 请求的最终响应。

...
getData(): Observable<Product[]> {
  return this.http.get<Product[]>(this.url);
}
...

Caution

表 24-4 中的方法准备了一个 HTTP 请求,但是直到Observer对象的subscribe方法被调用,它才被发送到服务器。但是要小心,因为每次调用subscribe方法都会发送一次请求,这就很容易在无意中多次发送相同的请求。

配置数据源

下一步是为新数据源配置一个提供者,并创建一个基于值的提供者,用一个请求将被发送到的 URL 来配置它。清单 24-7 显示了对model.module.ts文件的修改。

import { NgModule } from "@angular/core";
// import { StaticDataSource } from "./static.datasource";
import { Model } from "./repository.model";
import { HttpClientModule } from "@angular/common/http";
import { RestDataSource, REST_URL } from "./rest.datasource";

@NgModule({
  imports: [HttpClientModule],
  providers: [Model, RestDataSource,
    { provide: REST_URL, useValue: `http://${location.hostname}:3500/products` }]
})
export class ModelModule { }

Listing 24-7.Configuring the Data Source in the model.module.ts File in the src/app/model Folder

这两个新的提供者将RestDataSource类作为服务启用,并使用REST_URL不透明令牌来配置 web 服务的 URL。我移除了StaticDataSource类的提供者,不再需要它了。

使用 REST 数据源

最后一步是更新 repository 类,以便它声明对新数据源的依赖,并使用它来获取应用数据,如清单 24-8 所示。

import { Injectable } from "@angular/core";
import { Product } from "./product.model";
//import { StaticDataSource } from "./static.datasource";
import { Observable } from "rxjs";
import { RestDataSource } from "./rest.datasource";

@Injectable()
export class Model {
    private products: Product[] = new Array<Product>();
    private locator = (p: Product, id: number) => p.id == id;

    constructor(private dataSource: RestDataSource) {
        //this.products = new Array<Product>();
        //this.dataSource.getData().forEach(p => this.products.push(p));
        this.dataSource.getData().subscribe(data => this.products = data);
    }

    getProducts(): Product[] {
        return this.products;
    }

    getProduct(id: number): Product {
        return this.products.find(p => this.locator(p, id));
    }

    saveProduct(product: Product) {
        if (product.id == 0 || product.id == null) {
            product.id = this.generateID();
            this.products.push(product);
        } else {
            let index = this.products
                .findIndex(p => this.locator(p, product.id));
            this.products.splice(index, 1, product);
        }
    }

    deleteProduct(id: number) {
        let index = this.products.findIndex(p => this.locator(p, id));
        if (index > -1) {
            this.products.splice(index, 1);
        }
    }

    private generateID(): number {
        let candidate = 100;
        while (this.getProduct(candidate) != null) {
            candidate++;
        }
        return candidate;
    }
}

Listing 24-8.Using the New Data Source in the repository.model.ts File in the src/app/model Folder

构造函数依赖关系已经改变,因此存储库在创建时将接收到一个RestDataSource对象。在构造函数中,调用数据源的getData方法,使用subscribe方法接收从服务器返回的数据对象并对其进行处理。

当您保存更改时,浏览器将重新加载应用,并将使用新的数据源。一个异步 HTTP 请求将被发送到 RESTful web 服务,它将返回如图 24-2 所示的更大的一组数据对象。

img/421542_4_En_24_Fig2_HTML.jpg

图 24-2。

获取应用数据

保存和删除数据

数据源能够从服务器获取数据,但是它还需要以另一种方式发送数据,持久化用户对模型中的对象所做的更改,并存储所创建的新对象。清单 24-9 使用 Angular HttpClient类向数据源类添加方法来发送保存或更新对象的 HTTP 请求。

import { Injectable, Inject, InjectionToken } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Observable } from "rxjs";
import { Product } from "./product.model";

export const REST_URL = new InjectionToken("rest_url");

@Injectable()
export class RestDataSource {
    constructor(private http: HttpClient,
        @Inject(REST_URL) private url: string) { }

    getData(): Observable<Product[]> {
        return this.http.get<Product[]>(this.url);
    }

    saveProduct(product: Product): Observable<Product> {
        return this.http.post<Product>(this.url, product);
    }

    updateProduct(product: Product): Observable<Product> {
        return this.http.put<Product>(`${this.url}/${product.id}`, product);
    }

    deleteProduct(id: number): Observable<Product> {
        return this.http.delete<Product>(`${this.url}/${id}`);
    }
}

Listing 24-9.Sending Data in the rest.datasource.ts File in the src/app/model Folder

saveProductupdateProductdeleteProduct方法遵循相同的模式:它们调用其中一个HttpClient类方法并返回一个Observable<Product>作为结果。

当保存一个新对象时,对象的 ID 由服务器生成,因此它是唯一的,客户端不会无意中对不同的对象使用相同的 ID。在这种情况下,使用 POST 方法,请求被发送到/products URL。当更新或删除一个现有对象时,ID 是已知的,一个 PUT 请求被发送到一个包含该 ID 的 URL。因此,例如,更新 ID 为 2 的对象的请求被发送到/products/2 URL。类似地,要删除该对象,需要向同一个 URL 发送一个删除请求。

这些方法的共同点是服务器是权威的数据存储,来自服务器的响应包含服务器保存的对象的正式版本。正是这个对象作为这些方法的结果返回,通过Observable<Product>提供。

清单 24-10 显示了 repository 类中的相应变化,这些变化利用了新的数据源特性。

import { Injectable } from "@angular/core";
import { Product } from "./product.model";
import { Observable } from "rxjs";
import { RestDataSource } from "./rest.datasource";

@Injectable()
export class Model {
    private products: Product[] = new Array<Product>();
    private locator = (p: Product, id: number) => p.id == id;

    constructor(private dataSource: RestDataSource) {
        this.dataSource.getData().subscribe(data => this.products = data);
    }

    getProducts(): Product[] {
        return this.products;
    }

    getProduct(id: number): Product {
        return this.products.find(p => this.locator(p, id));
    }

    saveProduct(product: Product) {
        if (product.id == 0 || product.id == null) {
            this.dataSource.saveProduct(product)
                .subscribe(p => this.products.push(p));
        } else {
            this.dataSource.updateProduct(product).subscribe(p => {
                let index = this.products
                    .findIndex(item => this.locator(item, p.id));
                this.products.splice(index, 1, p);
            });
        }
    }

    deleteProduct(id: number) {
        this.dataSource.deleteProduct(id).subscribe(() => {
            let index = this.products.findIndex(p => this.locator(p, id));
            if (index > -1) {
                this.products.splice(index, 1);
            }
        });
    }
}

Listing 24-10.Using the Data Source Features in the repository.model.ts File in the src/app/model Folder

这些更改使用数据源将更新发送到服务器,并使用结果更新本地存储的数据,以便应用的其余部分显示这些数据。要测试这些更改,请单击 Kayak 产品的 Edit 按钮,并将其名称更改为 Green Kayak。点击保存按钮,浏览器会向服务器发送一个 HTTP PUT 请求,服务器会返回一个修改后的对象,该对象被添加到存储库的products数组中,并显示在表格中,如图 24-3 所示。

img/421542_4_En_24_Fig3_HTML.jpg

图 24-3。

向服务器发送上传请求

您可以通过使用浏览器请求http://localhost:3500/products/1来检查服务器是否已经存储了更改,这将产生对象的以下表示:

{
  "id": 1,
  "name": "Green Kayak",
  "category": "Watersports",
  "price": 275
}

整合 HTTP 请求

数据源类中的每个方法都复制了相同的基本模式,即使用特定于动词的HttpClient方法发送 HTTP 请求。这意味着对 HTTP 请求方式的任何更改都必须在四个不同的地方重复,以确保使用 GET、POST、PUT 和 DELETE 动词的请求都得到正确的更新和一致的执行。

HttpClient类定义了request方法,该方法允许将 HTTP 动词指定为参数。清单 24-11 使用request方法合并数据源类中的 HTTP 请求。

import { Injectable, Inject, InjectionToken } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Observable } from "rxjs";
import { Product } from "./product.model";

export const REST_URL = new InjectionToken("rest_url");

@Injectable()
export class RestDataSource {
    constructor(private http: HttpClient,
        @Inject(REST_URL) private url: string) { }

    getData(): Observable<Product[]> {
        return this.sendRequest<Product[]>("GET", this.url);
    }

    saveProduct(product: Product): Observable<Product> {
        return this.sendRequest<Product>("POST", this.url, product);
    }

    updateProduct(product: Product): Observable<Product> {
        return this.sendRequest<Product>("PUT",
            `${this.url}/${product.id}`, product);
    }

    deleteProduct(id: number): Observable<Product> {
        return this.sendRequest<Product>("DELETE", `${this.url}/${id}`);
    }

    private sendRequest<T>(verb: string, url: string, body?: Product)
           : Observable<T> {
        return this.http.request<T>(verb, url, {
            body: body
        });
    }
}

Listing 24-11.Consolidating HTTP Requests in the rest.datasource.ts File in the src/app/model Folder

request方法接受 HTTP 动词、请求的 URL 和用于配置请求的可选对象。配置对象用于使用body属性设置请求主体,而HttpClient将自动负责编码主体对象并在请求中包含它的序列化表示。

表 24-5 描述了最有用的属性,可以指定这些属性来配置使用request方法发出的 HTTP 请求。

表 24-5。

有用的请求方法配置对象属性

|

名字

|

描述

|
| --- | --- |
| headers | 该属性返回一个允许指定请求头的HttpHeaders对象,如“配置请求头”一节所述。 |
| body | 此属性用于设置请求正文。发送请求时,分配给该属性的对象将被序列化为 JSON。 |
| withCredentials | 当true时,该属性用于在进行跨站点请求时包含身份验证 cookies。作为跨源资源共享(CORS)规范的一部分,此设置必须仅用于响应中包含Access-Control-Allow-Credentials标头的服务器。有关详细信息,请参见“提出跨来源请求”一节。 |
| responseType | 此属性用于指定服务器预期的响应类型。默认值为json,表示 JSON 数据格式。 |

提出跨来源请求

默认情况下,浏览器执行一个安全策略,允许 JavaScript 代码只在与包含它们的文档相同的内发出异步 HTTP 请求。此策略旨在降低跨站点脚本(CSS)攻击的风险,这种攻击会诱使浏览器执行恶意代码。这种攻击的细节超出了本书的范围,但是可以在 http://en.wikipedia.org/wiki/Cross-site_scripting 找到的文章很好地介绍了这个主题。

对于 Angular 开发人员来说,同源策略在使用 web 服务时可能是个问题,因为它们通常位于包含应用 JavaScript 代码的源之外。如果两个 URL 具有相同的协议、主机和端口,则它们被视为来源相同,如果不是这样,则被视为来源不同。包含示例应用的 JavaScript 代码的 HTML 文件的 URL 是http://localhost:3000/index.html。表 24-6 总结了与应用的 URL 相比,相似的 URL 具有相同或不同的来源。

表 24-6。

URL 及其来源

|

统一资源定位器

|

原产地比较

|
| --- | --- |
| http://localhost:3000/otherfile.html | 相同的起源 |
| http://localhost:3000/app/main.js | 相同的起源 |
| https://localhost:3000/index.html | 出身不同;协议不同 |
| http://localhost:3500/products | 出身不同;端口不同 |
| http://angular.io/index.html | 出身不同;主机不同 |

如上表所示,RESTful web 服务的 URLhttp://localhost:3500/products有不同的来源,因为它使用了与主应用不同的端口。

使用 Angular HttpClient类发出的 HTTP 请求将自动使用跨源资源共享向不同的源发送请求。使用 CORS,浏览器在异步 HTTP 请求中包含标头,向服务器提供 JavaScript 代码的来源。来自服务器的响应包括告诉浏览器它是否愿意接受请求的头。CORS 的详细情况不在本书讨论范围内,但在 https://en.wikipedia.org/wiki/Cross-origin_resource_sharing 有题目介绍,在 www.w3.org/TR/cors 有 CORS 规格。

对于 Angular 开发人员来说,只要接收异步 HTTP 请求的服务器支持该规范,CORS 就是自动处理的事情。为示例提供 RESTful web 服务的json-server包支持 CORS,并将接受来自任何来源的请求,这就是示例一直工作的原因。如果您希望看到 CORS 的实际应用,请使用浏览器的 F12 开发人员工具来观察在您编辑或创建产品时发出的网络请求。您可能会看到使用OPTIONS动词发出的请求,称为预检请求,浏览器使用它来检查是否允许向服务器发出 POST 或 PUT 请求。这个请求和随后向服务器发送数据的请求将包含一个Origin头,而响应将包含一个或多个Access-Control-Allow头,服务器通过这些头确定它愿意从客户端接受什么。

所有这些都是自动发生的,唯一的配置选项是表 24-5 中描述的withCredentials属性。当该属性为true时,浏览器将包含认证 cookies,来自源的头将包含在对服务器的请求中。

使用 JSONP 请求

只有当 HTTP 请求发送到的服务器支持 CORS 时,它才可用。对于没有实现 CORS 的服务器,Angular 还提供了对 JSONP 的支持,这允许更有限形式的跨源请求。

JSONP 通过向文档对象模型添加一个script元素来工作,该元素在其src属性中指定跨源服务器。浏览器向服务器发送一个 GET 请求,服务器返回 JavaScript 代码,当执行该代码时,向应用提供它需要的数据。JSONP 本质上是一种绕过浏览器同源安全策略的黑客行为。JSONP 只能用于发出 GET 请求,它比 CORS 存在更大的安全风险。因此,JSONP 应该只在 CORS 不可用时使用。

JSONP 的 Angular 支持在一个名为HttpClientJsonpModule的特性模块中定义,这个特性模块在@angular/common/http JavaScript 模块中定义。为了启用 JSONP,清单 24-12 将HttpClientJsonpModule添加到模型模块的导入集中。

import { NgModule } from "@angular/core";
//import { StaticDataSource } from "./static.datasource";
import { Model } from "./repository.model";
import { HttpClientModule, HttpClientJsonpModule } from "@angular/common/http";
import { RestDataSource, REST_URL } from "./rest.datasource";

@NgModule({
    imports: [HttpClientModule, HttpClientJsonpModule],
    providers: [Model, RestDataSource,
        { provide: REST_URL, useValue: `http://${location.hostname}:3500/products` }]
})
export class ModelModule { }

Listing 24-12.Enabling JSONP in the model.module.ts File in the src/app/model Folder

Angular 通过HttpClient服务提供对 JSONP 的支持,该服务负责管理 JSONP HTTP 请求和处理响应,否则这将是一个冗长且容易出错的过程。清单 24-13 展示了使用 JSONP 为应用请求初始数据的数据源。

import { Injectable, Inject, InjectionToken } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Observable } from "rxjs";
import { Product } from "./product.model";

export const REST_URL = new InjectionToken("rest_url");

@Injectable()
export class RestDataSource {
    constructor(private http: HttpClient,
        @Inject(REST_URL) private url: string) { }

    getData(): Observable<Product[]> {
        return this.http.jsonp<Product[]>(this.url, "callback");
    }

    saveProduct(product: Product): Observable<Product> {
        return this.sendRequest<Product>("POST", this.url, product);
    }

    updateProduct(product: Product): Observable<Product> {
        return this.sendRequest<Product>("PUT",
            `${this.url}/${product.id}`, product);
    }

    deleteProduct(id: number): Observable<Product> {
        return this.sendRequest<Product>("DELETE", `${this.url}/${id}`);
    }

    private sendRequest<T>(verb: string, url: string, body?: Product)
            : Observable<T> {
        return this.http.request<T>(verb, url, {
            body: body
        });
    }
}

Listing 24-13.Making a JSONP Request in the rest.datasource.ts File in the src/app/model Folder

JSONP 只能用于 get 请求,这些请求是使用HttpClient.jsonp方法发送的。当您调用这个方法时,您必须提供请求的 URL 和回调参数的名称,回调参数必须设置为callback,如下所示:

...
return this.http.jsonp<Product[]>(this.url, "callback");
...

当 Angular 发出 HTTP 请求时,它用动态生成的函数的名称创建一个 URL。如果您查看浏览器发出的网络请求,您会看到初始请求被发送到如下 URL:

http://localhost:3500/products?callback=ng_jsonp_callback_0

服务器 JavaScript 函数匹配 URL 中使用的名称,并将从请求中接收的数据传递给它。JSONP 是一种进行跨来源请求的更有限的方式,并且与 CORS 不同,它绕过了浏览器的安全策略,但在必要时它可以是一种有用的后备措施。

配置请求标头

如果您使用的是商业 RESTful web 服务,那么您通常需要设置一个请求头来提供一个 API 键,这样服务器就可以将请求与您的应用相关联,以便进行访问控制和计费。您可以通过配置传递给request方法的配置对象来设置这种头——或者任何其他头,如清单 24-14 所示。(这个清单还返回到对所有请求使用request方法,而不是 JSONP。)

import { Injectable, Inject, InjectionToken } from "@angular/core";
import { HttpClient, HttpHeaders } from "@angular/common/http";
import { Observable } from "rxjs";
import { Product } from "./product.model";

export const REST_URL = new InjectionToken("rest_url");

@Injectable()
export class RestDataSource {
    constructor(private http: HttpClient,
        @Inject(REST_URL) private url: string) { }

    getData(): Observable<Product[]> {
        return this.sendRequest<Product[]>("GET", this.url);
    }

    saveProduct(product: Product): Observable<Product> {
        return this.sendRequest<Product>("POST", this.url, product);
    }

    updateProduct(product: Product): Observable<Product> {
        return this.sendRequest<Product>("PUT",
            `${this.url}/${product.id}`, product);
    }

    deleteProduct(id: number): Observable<Product> {
        return this.sendRequest<Product>("DELETE", `${this.url}/${id}`);
    }

    private sendRequest<T>(verb: string, url: string, body?: Product)
            : Observable<T> {
        return this.http.request<T>(verb, url, {
            body: body,
            headers: new HttpHeaders({
                "Access-Key": "<secret>",
                "Application-Name": "exampleApp"
            })
        });
    }
}

Listing 24-14.Setting a Request Header in the rest.datasource.ts File in the src/app/model Folder

headers属性被设置为一个HttpHeaders对象,该对象可以使用一个映射对象来创建,该映射对象的属性对应于标题名和应该用于标题名的值。如果您使用浏览器的 F12 开发人员工具来检查异步 HTTP 请求,您将会看到清单中指定的两个头与浏览器创建的标准头一起被发送到服务器,如下所示:

...
Accept:*/*
Accept-Encoding:gzip, deflate, sdch, br
Accept-Language:en-US,en;q=0.8
access-key:<secret>
application-name:exampleApp
Connection:keep-alive
...

如果您对请求头有更复杂的需求,那么您可以使用由HttpHeaders类定义的方法,如表 24-7 中所述。

表 24-7。

HttpHeaders 方法

|

名字

|

描述

|
| --- | --- |
| keys() | 返回集合中的所有标题名 |
| get(name) | 返回指定标题的第一个值 |
| getAll(name) | 返回指定标题的所有值 |
| has(name) | 如果集合包含指定的标题,则返回true |
| set(header, value) | 返回一个新的HttpHeaders对象,用一个值替换指定标题的所有现有值 |
| set(header, values) | 返回一个新的HttpHeaders对象,用一个值数组替换指定标题的所有现有值 |
| append(name, value) | 向指定标头的值列表中追加一个值 |
| delete(name) | 从集合中移除指定的标头 |

HTTP 头可以有多个值,这就是为什么有些方法会为头追加值或替换集合中的所有值。清单 24-15 创建一个空的HttpHeaders对象,并用有多个值的头填充它。

...
private sendRequest<T>(verb: string, url: string, body?: Product)
    : Observable<T> {

    let myHeaders = new HttpHeaders();
    myHeaders = myHeaders.set("Access-Key", "<secret>");
    myHeaders = myHeaders.set("Application-Names", ["exampleApp", "proAngular"]);

    return this.http.request<T>(verb, url, {
        body: body,
        headers: myHeaders
    });
}
...

Listing 24-15.Setting Multiple Header Values in the rest.datasource.ts File in the src/app/model Folder

当浏览器向服务器发送请求时,它们将包括以下标题:

...
Accept:*/*
Accept-Encoding:gzip, deflate, sdch, br
Accept-Language:en-US,en;q=0.8
access-key:<secret>
application-names:exampleApp,proAngular
Connection:keep-alive
...

处理错误

目前,应用中没有错误处理,这意味着如果 HTTP 请求出现问题,Angular 不知道该怎么办。为了更容易生成错误,我在 product 表中添加了一个按钮,该按钮将导致一个 HTTP 请求来删除一个在服务器上不存在的对象,如清单 24-16 所示。

<table class="table table-sm table-bordered table-striped">
    <tr>
        <th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
    </tr>
    <tr *ngFor="let item of getProducts()">
        <td>{{item.id}}</td>
        <td>{{item.name}}</td>
        <td>{{item.category}}</td>
        <td>{{item.price | currency:"USD" }}</td>
        <td class="text-center">
            <button class="btn btn-danger btn-sm mr-1"
                    (click)="deleteProduct(item.id)">
                Delete
            </button>
            <button class="btn btn-warning btn-sm" (click)="editProduct(item.id)">
                Edit
            </button>
        </td>
    </tr>
</table>
<button class="btn btn-primary m-1" (click)="createProduct()">
    Create New Product
</button>
<button class="btn btn-danger" (click)="deleteProduct(-1)">
    Generate HTTP Error
</button>

Listing 24-16.Adding an Error Button in the table.component.html File in the src/app/core Folder

button元素调用组件的deleteProduct方法,参数为-1。组件将要求存储库删除这个对象,这将导致一个 HTTP 删除请求被发送到不存在的/products/-1。如果您打开浏览器的 JavaScript 控制台并单击 new 按钮,您将看到来自服务器的响应,如下所示:

DELETE http://localhost:3500/products/-1 404 (Not Found)

改善这种情况意味着在出现错误时检测到这种错误并通知用户,用户通常不会查看 JavaScript 控制台。一个真正的应用也可能通过记录错误来响应错误,这样以后就可以对它们进行分析,但是为了简单起见,我只显示一条错误消息。

生成用户就绪消息

处理错误的第一步是将 HTTP 异常转换成可以向用户显示的内容。默认的错误消息是写入 JavaScript 控制台的消息,包含太多信息,无法向用户显示。用户不需要知道请求被发送到的 URL 仅仅知道发生了什么样的问题就足够了。

转换错误消息的最佳方式是使用catchErrorthrowError方法。使用catchError方法和pipe方法来接收发生在Observable序列中的任何错误,使用throwError方法来创建一个新的Observable来包含错误。清单 24-17 将两种方法都应用于数据源。

import { Injectable, Inject, InjectionToken } from "@angular/core";
import { HttpClient, HttpHeaders } from "@angular/common/http";
import { Observable, throwError } from "rxjs";
import { Product } from "./product.model";
import { catchError } from "rxjs/operators";

export const REST_URL = new InjectionToken("rest_url");

@Injectable()
export class RestDataSource {
    constructor(private http: HttpClient,
        @Inject(REST_URL) private url: string) { }

    getData(): Observable<Product[]> {
        return this.sendRequest<Product[]>("GET", this.url);
    }

    saveProduct(product: Product): Observable<Product> {
        return this.sendRequest<Product>("POST", this.url, product);
    }

    updateProduct(product: Product): Observable<Product> {
        return this.sendRequest<Product>("PUT",
            `${this.url}/${product.id}`, product);
    }

    deleteProduct(id: number): Observable<Product> {
        return this.sendRequest<Product>("DELETE", `${this.url}/${id}`);
    }

    private sendRequest<T>(verb: string, url: string, body?: Product)
        : Observable<T> {

        let myHeaders = new HttpHeaders();
        myHeaders = myHeaders.set("Access-Key", "<secret>");
        myHeaders = myHeaders.set("Application-Names", ["exampleApp", "proAngular"]);

        return this.http.request<T>(verb, url, {
            body: body,
            headers: myHeaders
        }).pipe(catchError((error: Response) =>
             throwError(`Network Error: ${error.statusText} (${error.status})`)));
    }

}

Listing 24-17.Transforming Errors in the rest.datasource.ts File in the src/app/model Folder

当出现错误时,传递给catchError方法的函数被调用,并接收描述结果的Response对象。throwError函数创建一个新的仅包含一个错误对象的可观察对象,在本例中,该对象用于生成一条错误消息,其中包含 HTTP 状态代码和响应中的状态文本。

如果保存更改,然后再次单击 Generate HTTP Error 按钮,错误消息仍会被写入浏览器的 JavaScript 控制台,但会更改为由catchError / throwError方法生成的格式。

EXCEPTION: Network Error: Not Found (404)

处理错误

错误已经被转换,但没有被处理,这就是为什么它们仍然在浏览器的 JavaScript 控制台中被报告为异常。有两种方法可以处理这些错误。第一个是为由HttpClient对象创建的Observable对象的subscribe方法提供一个错误处理函数。这是定位错误并为存储库提供重试操作或尝试以其他方式恢复的机会的有用方法。

第二种方法是替换内置的 Angular 错误处理功能,该功能响应应用中任何未处理的错误,并在默认情况下将它们写入控制台。正是这个特性写出了前面几节中显示的消息。

对于示例应用,我想用一个使用消息服务的错误处理程序来覆盖默认的错误处理程序。我在src/app/messages文件夹中创建了一个名为errorHandler.ts的文件,并用它来定义清单 24-18 中所示的类。

import { ErrorHandler, Injectable, NgZone } from "@angular/core";
import { MessageService } from "./message.service";
import { Message } from "./message.model";

@Injectable()
export class MessageErrorHandler implements ErrorHandler {

    constructor(private messageService: MessageService, private ngZone: NgZone) {
    }

    handleError(error) {
        let msg = error instanceof Error ? error.message : error.toString();
        this.ngZone.run(() => this.messageService
            .reportMessage(new Message(msg, true)), 0);
    }
}

Listing 24-18.The Contents of the errorHandler.ts File in the src/app/messages Folder

@angular/core模块中定义了ErrorHandler类,它通过一个handleError方法来响应错误。清单中显示的类用一个使用MessageService报告错误的实现替换了这个方法的默认实现。

构造函数接收一个ngZone对象,它是异步操作 Angular 支持的一部分,也是变化检测特性的一个重要部分。在这个清单中,ngZone对象的run方法用于报告一个错误消息,以便操作触发变更检测过程并向用户显示错误。

为了替换默认的ErrorHandler,我在消息模块中使用了一个类提供者,如清单 24-19 所示。

import { NgModule, ErrorHandler } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { MessageComponent } from "./message.component";
import { MessageService } from "./message.service";
import { MessageErrorHandler } from "./errorHandler";

@NgModule({
    imports: [BrowserModule],
    declarations: [MessageComponent],
    exports: [MessageComponent],
    providers: [MessageService,
        { provide: ErrorHandler, useClass: MessageErrorHandler }]
})
export class MessageModule { }

Listing 24-19.Configuring an Error Handler in the message.module.ts File in the src/app/messages Folder

错误处理功能使用MessageService向用户报告错误消息。一旦保存了这些更改,点击生成 HTTP 错误按钮会产生一个用户可以看到的错误,如图 24-4 所示。

img/421542_4_En_24_Fig4_HTML.jpg

图 24-4。

处理 HTTP 错误

摘要

在这一章中,我解释了如何在 Angular 应用中进行异步 HTTP 请求。我介绍了 RESTful web 服务和 Angular HttpClient类提供的可用于与它们交互的方法。我解释了浏览器如何限制对不同来源的请求,以及 Angular 如何支持 CORS 和 JSONP 在应用来源之外发出请求。在下一章,我将介绍 URL 路由特性,它允许导航复杂的应用。

二十处、路由和导航:第一部分

Angular 路由功能允许应用通过响应浏览器 URL 的更改来更改显示给用户的组件和模板。这允许创建复杂的应用,用最少的代码以开放和灵活的方式修改它们所呈现的内容。为了支持这一特性,可以使用数据绑定和服务来更改浏览器的 URL,从而允许用户在应用中导航。

随着项目复杂性的增加,路由变得非常有用,因为它允许应用的结构与组件和指令分开定义,这意味着可以在路由配置中对结构进行更改,而不必应用于单个组件。

在这一章中,我演示了基本的路由系统是如何工作的,并将其应用到示例应用中。在第 26 和 27 章中,我解释了更高级的路由特性。表 25-1 将路由放在上下文中。

表 25-1。

将路由和导航放在上下文中

|

问题

|

回答

|
| --- | --- |
| 这是什么? | 路由使用浏览器的 URL 来管理向用户显示的内容。 |
| 为什么有用? | 路由允许应用的结构与应用中的组件和模板分开。对应用结构的更改是在路由配置中进行的,而不是在单个组件和指令中进行的。 |
| 如何使用? | 路由配置被定义为一组片段,用于匹配浏览器的 URL 并选择一个组件,该组件的模板显示为一个名为router-outlet的 HTML 元素的内容。 |
| 有什么陷阱或限制吗? | 路由配置可能变得难以管理,尤其是当 URL 模式是在特定的基础上逐渐定义的时候。 |
| 还有其他选择吗? | 您不必使用路由功能。您可以通过创建一个组件来获得类似的结果,该组件的视图使用ngIfngSwitch指令选择向用户显示的内容,尽管随着应用的规模和复杂性的增加,这种方法比使用路由更加困难。 |

表 25-2 总结了本章内容。

表 25-2。

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 使用 URL 导航选择向用户显示的内容 | 使用 URL 路由 | 1–7 |
| 使用 HTML 元素导航 | 应用routerLink属性 | 8–10 |
| 响应路由变更 | 使用路由服务接收通知 | Eleven |
| 在 URL 中包含信息 | 使用路由参数 | 12–18 |
| 使用代码导航 | 使用Router服务 | Nineteen |
| 接收路由活动的通知 | 处理路由事件 | 20–21 |

准备示例项目

本章使用在第二十二章中创建的 exampleApp 项目。为了准备本章的专题,需要做一些修改。该应用被配置为在两个位置显示从表组件发送到产品组件的状态更改事件:通过消息服务和在表单组件的模板中。不再需要这些消息,清单 25-1 从组件的模板中移除了事件显示。

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

<div class="bg-primary text-white p-2" [class.bg-warning]="editing">
    <h5>{{editing  ? "Edit" : "Create"}} Product</h5>
    <!-- Last Event: {{ stateEvents | async | formatState }} -->
</div>

<form novalidate #form="ngForm" (ngSubmit)="submitForm(form)" (reset)="resetForm()" >

    <div class="form-group">
        <label>Name</label>
        <input class="form-control" name="name"
               [(ngModel)]="product.name" required />
    </div>

    <div class="form-group">
        <label>Category</label>
        <input class="form-control" name="category"
               [(ngModel)]="product.category" required />
    </div>

    <div class="form-group">
        <label>Price</label>
        <input class="form-control" name="price"
               [(ngModel)]="product.price"
               required pattern="^[0-9\.]+$" />
    </div>

    <button type="submit" class="btn btn-primary m-1"
            [class.btn-warning]="editing" [disabled]="form.invalid">
        {{editing ? "Save" : "Create"}}
    </button>
    <button type="reset" class="btn btn-secondary m-1">Cancel</button>
</form>

Listing 25-1.Removing the Event Display in the form.component.html File in the src/app/core Folder

清单 25-2 禁用了将状态改变事件推入消息服务的代码。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { FormsModule } from "@angular/forms";
import { ModelModule } from "../model/model.module";
import { TableComponent } from "./table.component";
import { FormComponent } from "./form.component";
import { SharedState, SHARED_STATE } from "./sharedState.model";
import { Subject } from "rxjs";
import { StatePipe } from "./state.pipe";
import { MessageModule } from "../messages/message.module";
import { MessageService } from "../messages/message.service";
import { Message } from "../messages/message.model";
import { Model } from "../model/repository.model";
import { MODES } from "./sharedState.model";

@NgModule({
  imports: [BrowserModule, FormsModule, ModelModule, MessageModule],
  declarations: [TableComponent, FormComponent, StatePipe],
  exports: [ModelModule, TableComponent, FormComponent],
  providers: [{
    provide: SHARED_STATE,
    deps: [MessageService, Model],
    useFactory: (messageService, model) => {
      return new Subject<SharedState>();
      //let subject = new Subject<SharedState>();
      //subject.subscribe(m => messageService.reportMessage(
      //  new Message(MODES[m.mode] + (m.id != undefined
      //    ? ` ${model.getProduct(m.id).name}` : "")))
      //);
      //return subject;
    }
  }]
})
export class CoreModule { }

Listing 25-2.Disabling State Change Events in the core.module.ts File in the src/app/core Folder

打开一个新的命令提示符,导航到exampleApp文件夹,运行以下命令启动提供 RESTful web 服务器的服务器:

npm run json

打开一个单独的命令提示符,导航到exampleApp文件夹,运行以下命令启动 Angular 开发工具:

ng serve

打开一个新的浏览器窗口并导航到http://localhost:4200以查看如图 25-1 所示的内容。

img/421542_4_En_25_Fig1_HTML.jpg

图 25-1。

运行示例应用

路由入门

目前,应用中的所有内容始终对用户可见。对于示例应用,这意味着表格和表单总是可见的,并且由用户来跟踪他们正在使用应用的哪一部分来完成手头的任务。

这对于一个简单的应用来说很好,但是对于一个复杂的项目来说就变得难以管理了,这个项目可能有很多功能区域,如果一次全部显示出来的话,这些功能区域会变得令人不知所措。

URL 路由使用 web 应用的一个自然且众所周知的方面:URL,向应用添加结构。在这一节中,我将通过将 URL 路由应用到示例应用来介绍它,这样表格或表单都是可见的,活动组件是根据用户的操作选择的。这将为解释路由如何工作提供一个良好的基础,并为更高级的功能奠定基础。

创建路由配置

应用路由的第一步是定义路由,这是 URL 和将显示给用户的组件之间的映射。路由配置通常在名为app.routing.ts的文件中定义,该文件在src/app文件夹中定义。我创建了这个文件,并添加了清单 25-3 中所示的语句。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";

const routes: Routes = [
    { path: "form/edit", component: FormComponent },
    { path: "form/create", component: FormComponent },
    { path: "", component: TableComponent }]

export const routing = RouterModule.forRoot(routes);

Listing 25-3.The Contents of the app.routing.ts File in the src/app Folder

Routes类定义了一个路由集合,每个路由告诉 Angular 如何处理一个特定的 URL。这个例子使用了最基本的属性,其中,path指定了 URL,component属性指定了将向用户显示的组件。

path属性是相对于应用的其余部分指定的,这意味着清单 25-3 中的配置设置了表 25-3 中所示的路由。

表 25-3。

示例中创建的路由

|

统一资源定位器

|

显示的组件

|
| --- | --- |
| http://localhost:4200/form/edit | FormComponent |
| http://localhost:4200/form/create | FormComponent |
| http://localhost:4200/ | TableComponent |

使用RouterModule.forRoot方法将路由打包成一个模块。forRoot方法产生一个包含路由服务的模块。还有一种不包含服务的forChild方法,这将在第二十六章中演示,在那里我将解释如何为特性模块创建路径。

虽然在定义路由时最常用的是pathcomponent属性,但是还有一系列附加属性可用于定义具有高级功能的路由。这些属性在表 25-4 中有描述,以及描述它们的详细位置。

表 25-4。

用于定义路由的路由属性

|

名字

|

描述

|
| --- | --- |
| path | 此属性指定路由的路径。 |
| component | 该属性指定当活动 URL 与path匹配时将被选择的组件。 |
| pathMatch | 这个属性告诉 Angular 如何将当前 URL 匹配到path属性。有两个允许的值:full,它要求path值完全匹配 URL,和prefix,它允许path值匹配 URL,即使 URL 包含不属于path值的附加段。使用redirectTo属性时,该属性是必需的,如第二十六章所示。 |
| redirectTo | 此属性用于创建一个路由,该路由在激活时将浏览器重定向到不同的 URL。详见第二十六章。 |
| children | 该属性用于指定子路由,该子路由在活动组件模板中包含的嵌套router-outlet元素中显示附加组件,如第二十六章所示。 |
| outlet | 该属性用于支持多个出口元素,如第二十七章所述。 |
| resolve | 该属性用于定义在激活路由之前必须完成的工作,如第二十七章所述。 |
| canActivate | 如第二十七章所述,该属性用于控制何时激活一条路由。 |
| canActivateChild | 如第二十七章所述,该属性用于控制何时可以激活子路由。 |
| canDeactivate | 如第二十七章所述,该属性用于控制何时可以停用一条路由,以便激活一条新路由。 |
| loadChildren | 该属性用于配置仅在需要时加载的模块,如第二十七章所述。 |
| canLoad | 此属性用于控制何时可以加载按需模块。 |

Understanding Route Ordering

定义路由的顺序非常重要。Angular 依次将浏览器导航到的 URL 与每条路由的 path 属性进行比较,直到找到匹配项。这意味着应该首先定义最具体的路由,随后的路由的具体性逐渐降低。这对于清单 25-3 中的路由来说没什么大不了的,但是当使用路由参数(在本章的“使用路由参数”一节中描述)或者添加子路由(在第二十六章中描述)时就变得很重要了。

如果您发现您的路由配置没有导致您期望的行为,那么定义路由的顺序是首先要检查的。

创建路由组件

使用路由时,根组件专用于管理应用不同部分之间的导航。这是创建时由ng new命令添加到项目中的app.component.ts文件的典型用途,在清单 25-4 中,我已经为这种用途更新了它的内容。

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

@Component({
    selector: "app",
    templateUrl: "./app.component.html"
})
export class AppComponent { }

Listing 25-4.Replacing the Contents of the app.component.ts File in the src/app Folder

该组件是其模板的载体,该模板是src/app文件夹中的app.component.html文件。在清单 25-5 中,我已经替换了默认内容。

<paMessages></paMessages>
<router-outlet></router-outlet>

Listing 25-5.Replacing the Contents of the app.component.html File in the src/app File

元素显示应用中的任何消息和错误。出于路由的目的,重要的是router-outlet元素——称为出口——因为它告诉 Angular 这是路由配置匹配的组件应该显示的位置。

更新根模块

下一步是更新根模块,以便新的根组件用于引导应用,如清单 25-6 所示,它还导入包含路由配置的模块。

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { ModelModule } from "./model/model.module";
import { CoreModule } from "./core/core.module";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { MessageModule } from "./messages/message.module";
import { MessageComponent } from "./messages/message.component";
import { AppComponent } from './app.component';
import { routing } from "./app.routing";

@NgModule({
    imports: [BrowserModule, ModelModule, CoreModule, MessageModule, routing],
    declarations: [AppComponent],
    bootstrap: [AppComponent]
})
export class AppModule { }

Listing 25-6.Enabling Routing in the app.module.ts File in the src/app Folder

完成配置

最后一步是更新index.html文件,如清单 25-7 所示。

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>ExampleApp</title>
  <base href="/">

  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body class="m-2">
  <app></app>
</body>
</html>

Listing 25-7.Configuring Routing in the index.html File in the src Folder

app元素应用新的根组件,其模板包含router-outlet元素。当你保存更改并且浏览器重新加载应用时,你将只看到产品表,如图 25-2 所示。应用的默认 URL 对应于显示产品表的路由。

img/421542_4_En_25_Fig2_HTML.jpg

图 25-2。

使用路由向用户显示组件

Tip

对于本例,您可能需要停止 Angular 开发工具,并使用ng serve命令再次启动它们。

添加导航链接

基本的路由配置已经就绪,但是无法在应用中导航:当您单击 Create New Product 或 Edit 按钮时,什么都不会发生。

下一步是将链接添加到应用中,这将改变浏览器的 URL,并在这样做时,触发路由更改,从而向用户显示不同的组件。清单 25-8 将这些链接添加到表格组件的模板中。

<table class="table table-sm table-bordered table-striped">
    <tr>
        <th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
    </tr>
    <tr *ngFor="let item of getProducts()">
        <td>{{item.id}}</td>
        <td>{{item.name}}</td>
        <td>{{item.category}}</td>
        <td>{{item.price | currency:"USD" }}</td>
        <td class="text-center">
            <button class="btn btn-danger btn-sm mr-1"
                    (click)="deleteProduct(item.id)">
                Delete
            </button>
            <button class="btn btn-warning btn-sm" (click)="editProduct(item.id)"
                    routerLink="/form/edit">
                Edit
            </button>
        </td>
    </tr>
</table>
<button class="btn btn-primary m-1" (click)="createProduct()"
        routerLink="/form/create">
    Create New Product
</button>
<button class="btn btn-danger" (click)="deleteProduct(-1)">
    Generate HTTP Error
</button>

Listing 25-8.Adding Navigation Links in the table.component.html File in the src/app/core Folder

routerLink属性应用来自执行导航改变的路由包的指令。该指令可以应用于任何元素,尽管它通常应用于button和锚(a)元素。应用于编辑按钮的routerLink指令的表达式告诉 Angular 瞄准/form/edit路由。

...
<button class="btn btn-warning btn-sm" (click)="editProduct(item.id)"
        routerLink="/form/edit">
    Edit
</button>
...

应用于 Create New Product 按钮的相同指令告诉 Angular 以/create路由为目标。

...
<button class="btn btn-primary m-1" (click)="createProduct()"
        routerLink="/form/create">
    Create New Product
</button>
...

添加到表组件模板的路由链接将允许用户导航到表单。清单 25-9 中所示的表单组件模板的添加将允许用户使用取消按钮再次返回。

<div class="bg-primary text-white p-2" [class.bg-warning]="editing">
    <h5>{{editing  ? "Edit" : "Create"}} Product</h5>
    <!-- Last Event: {{ stateEvents | async | formatState }} -->
</div>

<form novalidate #form="ngForm" (ngSubmit)="submitForm(form)" (reset)="resetForm()" >

    <div class="form-group">
        <label>Name</label>
        <input class="form-control" name="name"
               [(ngModel)]="product.name" required />
    </div>

    <div class="form-group">
        <label>Category</label>
        <input class="form-control" name="category"
               [(ngModel)]="product.category" required />
    </div>

    <div class="form-group">
        <label>Price</label>
        <input class="form-control" name="price"
               [(ngModel)]="product.price"
               required pattern="^[0-9\.]+$" />
    </div>

    <button type="submit" class="btn btn-primary m-1"
            [class.btn-warning]="editing" [disabled]="form.invalid">
        {{editing ? "Save" : "Create"}}
    </button>
    <button type="reset" class="btn btn-secondary m-1" routerLink="/">
            Cancel
    </button>
</form>

Listing 25-9.Adding a Navigation Link in the form.component.html File in the src/app/core Folder

分配给routerLink属性的值以显示产品表的路由为目标。清单 25-10 更新了包含模板的特征模块,以便它导入RouterModule,这是包含选择routerLink属性的指令的 Angular 模块。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { FormsModule } from "@angular/forms";
import { ModelModule } from "../model/model.module";
import { TableComponent } from "./table.component";
import { FormComponent } from "./form.component";
import { SharedState, SHARED_STATE } from "./sharedState.model";
import { Subject } from "rxjs";
import { StatePipe } from "./state.pipe";
import { MessageModule } from "../messages/message.module";
import { MessageService } from "../messages/message.service";
import { Message } from "../messages/message.model";
import { Model } from "../model/repository.model";
import { MODES } from "./sharedState.model";
import { RouterModule } from "@angular/router";

@NgModule({
    imports: [BrowserModule, FormsModule, ModelModule, MessageModule, RouterModule],
    declarations: [TableComponent, FormComponent, StatePipe],
    exports: [ModelModule, TableComponent, FormComponent],
    providers: [{
        provide: SHARED_STATE,
        deps: [MessageService, Model],
        useFactory: (messageService, model) => {
            return new Subject<SharedState>();
        }
    }]
})
export class CoreModule { }

Listing 25-10.Enabling the Routing Directive in the core.module.ts File in the src/app/core Folder

了解路由的影响

重启 Angular 开发工具,您将能够使用编辑、创建新产品和取消按钮在应用中导航,如图 25-3 所示。

img/421542_4_En_25_Fig3_HTML.jpg

图 25-3。

使用路由在应用中导航

并非应用中的所有功能都可以工作,但是这是探索将路由添加到应用中的效果的好时机。输入应用的根 URL(http://localhost:4200),然后单击“创建新产品”按钮。当您单击按钮时,Angular routing 系统将浏览器显示的 URL 更改为:

http://localhost:4200/form/create

如果您在转换过程中观察开发 HTTP 服务器的输出,您会注意到服务器没有接收到新内容的请求。这种改变完全是在 Angular 应用中完成的,不会产生任何新的 HTTP 请求。

新的 URL 由 Angular 路由系统处理,该系统能够将新的 URL 与来自app.routing.ts文件的该路由进行匹配。

...
{ path: "form/create", component: FormComponent },
...

当路由系统将 URL 与路由匹配时,它会考虑到index.html文件中的base元素。base元素配置有/href值,当 URL 为/form/create时,该值与路由中的path相结合以进行匹配。

component属性告诉 Angular 路由系统应该向用户显示FormComponent。创建了一个FormComponent类的新实例,它的模板内容被用作根组件模板中router-outlet元素的内容。

如果您单击表单下方的 Cancel 按钮,则会重复该过程,但这一次,浏览器会返回到应用的根 URL,该 URL 与路径组件为空字符串的路由相匹配。

{ path: "", component: TableComponent }

这条路由告诉 Angular 向用户显示TableComponent。创建了一个TableComponent类的新实例,它的模板被用作router-outlet元素的内容,向用户显示模型数据。

这就是路由的本质:浏览器的 URL 发生变化,导致路由系统查询其配置,以确定应该向用户显示哪个组件。有很多选项和特性是可用的,但这是路由的核心目的,如果你记住这一点,你就不会犯太大的错误。

The Perils of Changing the URL Manually

routerLink指令使用 JavaScript API 设置 URL,告诉浏览器这是相对于当前文档的更改,而不是需要向服务器发出 HTTP 请求的更改。

如果您在浏览器窗口中输入一个与路由系统匹配的 URL,您将看到一个看起来像预期的变化,但实际上完全是另外一回事的效果。在浏览器中手动输入以下 URL 时,请注意开发 HTTP 服务器的输出:

http://localhost:4200/form/create

浏览器不是在 Angular 应用中处理更改,而是向服务器发送 HTTP 请求,服务器重新加载应用。一旦应用被加载,路由系统检查浏览器的 URL,匹配配置中的一个路由,然后显示FormComponent

这样做的原因是,对于与磁盘上的文件不对应的 URL,开发 HTTP 服务器将返回index.html文件的内容。例如,请求以下 URL:

http://localhost:4200/this/does/not/exist

浏览器将显示一个错误,因为请求已经向浏览器提供了index.html文件的内容,浏览器已经使用该文件加载并启动示例 Angular 应用。当路由系统检查 URL 时,它找不到匹配的路由并产生一个错误。

有两点需要注意。首先,当您测试应用的路由配置时,您应该检查浏览器发出的 HTTP 请求,因为您有时会因为错误的原因看到正确的结果。在速度快的机器上,你可能甚至没有意识到应用已经被浏览器重新加载和重启了。

其次,您必须记住,必须使用routerLink指令(或路由器模块提供的类似功能之一)来更改 URL,而不是使用浏览器的 URL 栏手动更改。

最后,由于用户不知道编程和手动 URL 更改之间的区别,你的路由配置应该能够处理不对应于路由的 URL,如第二十六章所述。

完成路由实施

将路由添加到应用中是一个好的开始,但是应用的许多功能都不起作用。例如,单击编辑按钮会显示表单,但它不会被填充,也不会显示表示编辑的颜色提示。在接下来的小节中,我使用路由系统提供的特性来完成应用的连接,这样一切都可以按预期工作。

处理组件中的工艺路由变更

表单组件工作不正常,因为它没有收到用户单击按钮编辑产品的通知。出现这个问题是因为路由系统仅在需要时才创建组件类的新实例,这意味着仅在单击编辑按钮后才创建FormComponent对象。如果单击表单下的 Cancel 按钮,然后再次单击该表中的 Edit 按钮,将会创建第二个FormComponent实例。

这导致了 product 组件和 table 组件通过一个反应式扩展Subject进行通信的时间问题。一个Subject只将事件传递给在subscribe方法被调用后到达的订阅者。路由的引入意味着FormComponent对象是在描述编辑操作的事件被发送后创建的。

这个问题可以通过用一个BehaviorSubject替换Subject来解决,当订阅者调用 subscribe 方法时,它会将最近的事件发送给订阅者。但是更好的方法——特别是因为这是关于路由系统的一章——是使用 URL 在组件之间进行协作。

Angular 提供了一种服务,组件可以接收该服务以获得当前路由的详细信息。服务和它提供访问的类型之间的关系初看起来可能很复杂,但是当您看到示例是如何展开的以及路由的一些不同使用方式时,它就有意义了。

组件声明依赖关系的类称为ActivatedRoute。在本节中,它定义了一个重要的属性,如表 25-5 所述。还有一些其他的属性,将在本章的后面描述,但是你可以暂时忽略它们。

表 25-5。

ActivatedRoute 属性

|

名字

|

描述

|
| --- | --- |
| snapshot | 该属性返回一个描述当前路由的ActivatedRouteSnapshot对象。 |

snapshot属性返回ActivatedRouteSnapshot类的一个实例,该实例使用表 25-6 中描述的属性,提供有关导致当前组件显示给用户的路径的信息。

表 25-6。

基本 ActivatedRouteSnapshot 属性

|

名字

|

描述

|
| --- | --- |
| url | 该属性返回一个由UrlSegment对象组成的数组,每个对象描述了 URL 中与当前路径匹配的一段。 |
| params | 这个属性返回一个Params对象,它描述了 URL 参数,通过名称进行索引。 |
| queryParams | 这个属性返回一个Params对象,它描述了 URL 查询参数,按名称进行索引。 |
| fragment | 该属性返回一个包含 URL 片段的string。 |

对于这个例子来说,url属性是最重要的,因为它允许组件检查当前 URL 的段,并从中提取执行操作所需的信息。url属性返回一组UrlSegment对象,这些对象提供了表 25-7 中描述的属性。

表 25-7。

URLSegment 属性

|

名字

|

描述

|
| --- | --- |
| path | 此属性返回包含段值的字符串。 |
| parameters | 该属性返回参数的索引集合,如“使用路由参数”一节中所述。 |

为了确定用户激活了什么路由,表单组件可以声明对ActivatedRoute的依赖,然后使用它接收到的对象来检查 URL 的段,如清单 25-11 所示。

import { Component, Inject } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { MODES, SharedState, SHARED_STATE } from "./sharedState.model";
import { Observable } from "rxjs";
//import { filter, map, distinctUntilChanged, skipWhile } from "rxjs/operators";
import { ActivatedRoute } from "@angular/router";

@Component({
    selector: "paForm",
    templateUrl: "form.component.html",
    styleUrls: ["form.component.css"]
})
export class FormComponent {
    product: Product = new Product();

    constructor(private model: Model, activeRoute: ActivatedRoute) {
        this.editing = activeRoute.snapshot.url[1].path == "edit";
    }

    editing: boolean = false;

    submitForm(form: NgForm) {
        if (form.valid) {
            this.model.saveProduct(this.product);
            this.product = new Product();
            form.reset();
        }
    }

    resetForm() {
        this.product = new Product();
    }
}

Listing 25-11.Inspecting the Active Route in the form.component.ts File in the src/app/core Folder

该组件不再使用反应式扩展来接收事件。相反,它检查活动路径的 URL 的第二段,以设置editing属性的值,该值决定它是否应该显示其创建或编辑模式。如果你点击表格中的编辑按钮,你将会看到正确的颜色显示,如图 25-4 所示。

img/421542_4_En_25_Fig4_HTML.jpg

图 25-4。

在元件中使用活动管线

使用路由参数

当我为应用设置路由配置时,我定义了两条针对表单组件的路由,如下所示:

...
{ path: "form/edit", component: FormComponent },
{ path: "form/create", component: FormComponent },
...

当 Angular 试图将一条路由与一个 URL 相匹配时,它会依次查看每一段,并检查它是否与正在导航的 URL 相匹配。这两个 URL 都由静态段组成,这意味着在 Angular 激活路由之前,它们必须与导航的 URL 完全匹配。

Angular 路由可以更加灵活,包括路由参数,它允许段的任何值与导航 URL 中的相应段相匹配。这意味着以具有相似 URL 的相同组件为目标的路由可以合并成一个单一的路由,如清单 25-12 所示。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";

const routes: Routes = [
    { path: "form/:mode", component: FormComponent },
    { path: "", component: TableComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 25-12.Consolidating Routes in the app.routing.ts File in the src/app Folder

修改后的 URL 的第二段定义了一个路由参数,由冒号(:字符)后跟一个名称表示。在这种情况下,路由参数称为mode。该路由将匹配任何包含两段的 URL,其中第一段是form,如表 25-8 中所总结的。第二段的内容将被分配给一个名为mode的参数。

表 25-8。

与路由参数匹配的 URL

|

统一资源定位器

|

结果

|
| --- | --- |
| http://localhost:4200/form | 不匹配-线段太少 |
| http://localhost:4200/form/create | 匹配,将create分配给mode参数 |
| http://localhost:4200/form/london | 匹配,将london分配给mode参数 |
| http://localhost:4200/product/edit | 不匹配—第一段不是form |
| http://localhost:4200/form/edit/1 | 不匹配-分段太多 |

使用路由参数使得以编程方式处理路由变得更加简单,因为参数的值可以使用其名称来获得,如清单 25-13 所示。

import { Component, Inject } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { MODES, SharedState, SHARED_STATE } from "./sharedState.model";
import { Observable } from "rxjs";
//import { filter, map, distinctUntilChanged, skipWhile } from "rxjs/operators";
import { ActivatedRoute } from "@angular/router";

@Component({
    selector: "paForm",
    templateUrl: "form.component.html",
    styleUrls: ["form.component.css"]
})
export class FormComponent {
    product: Product = new Product();

    constructor(private model: Model, activeRoute: ActivatedRoute) {
        this.editing = activeRoute.snapshot.params["mode"] == "edit";
    }

    editing: boolean = false;

    submitForm(form: NgForm) {
        if (form.valid) {
            this.model.saveProduct(this.product);
            this.product = new Product();
            form.reset();
        }
    }

    resetForm() {
        this.product = new Product();
    }
}

Listing 25-13.Reading a Route Parameter in the form.component.ts File in the src/app/core Folder

组件不需要知道 URL 的结构来获取它需要的信息。相反,它可以使用由ActivatedRouteSnapshot类提供的params属性来获取参数值的集合,按名称进行索引。该组件获取mode参数的值,并使用它来设置editing属性。

使用多个路由参数

为了告诉表单组件当用户单击编辑按钮时选择了哪个产品,我需要使用第二个路由参数。由于 Angular 根据 URL 所包含的段的数量来匹配 URL,这意味着我需要再次分割以表单组件为目标的路由,如清单 25-14 所示。这种合并然后扩展路由的循环是大多数开发项目的典型特征,因为您增加了路由 URL 中包含的信息量,从而为应用添加了功能。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";

const routes: Routes = [
    { path: "form/:mode/:id", component: FormComponent },
    { path: "form/:mode", component: FormComponent },
    { path: "", component: TableComponent }]

export const routing = RouterModule.forRoot(routes);

Listing 25-14.Adding a Route in the app.routing.ts File in the src/app Folder

新路径将匹配任何包含三段的 URL,其中第一段是form。为了创建以这条路由为目标的 URL,我需要对模板中的routerLink表达式使用不同的方法,因为我需要为产品表中的每个编辑按钮动态生成第三段,如清单 25-15 所示。

<table class="table table-sm table-bordered table-striped">
    <tr>
        <th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
    </tr>
    <tr *ngFor="let item of getProducts()">
        <td>{{item.id}}</td>
        <td>{{item.name}}</td>
        <td>{{item.category}}</td>
        <td>{{item.price | currency:"USD" }}</td>
        <td class="text-center">
            <button class="btn btn-danger btn-sm mr-1"
                    (click)="deleteProduct(item.id)">
                Delete
            </button>
            <button class="btn btn-warning btn-sm" (click)="editProduct(item.id)"
                    [routerLink]="['/form', 'edit', item.id]">
                Edit
            </button>
        </td>
    </tr>
</table>
<button class="btn btn-primary m-1" (click)="createProduct()"
        routerLink="/form/create">
    Create New Product
</button>
<button class="btn btn-danger" (click)="deleteProduct(-1)">
    Generate HTTP Error
</button>

Listing 25-15.Generating Dynamic URLs in the table.component.html File in the src/app/core Folder

routerLink属性现在用方括号括起来,告诉 Angular 它应该将属性值视为数据绑定表达式。表达式被设置为一个数组,每个元素包含一个段的值。前两段是文字字符串,将被包含在目标 URL 中,无需修改。第三段将被求值,以包含由ngIf指令处理的当前Product对象的id属性值,就像模板中的其他表达式一样。routerLink指令将组合各个片段来创建一个 URL,如/form/edit/2

清单 25-16 显示了表单组件如何获得新的路由参数值,并使用它来选择要编辑的产品。

import { Component, Inject } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { ActivatedRoute } from "@angular/router";

@Component({
    selector: "paForm",
    templateUrl: "form.component.html",
    styleUrls: ["form.component.css"]
})
export class FormComponent {
    product: Product = new Product();

    constructor(private model: Model, activeRoute: ActivatedRoute) {
        this.editing = activeRoute.snapshot.params["mode"] == "edit";
        let id = activeRoute.snapshot.params["id"];
        if (id != null) {
            Object.assign(this.product, model.getProduct(id) || new Product());
        }
    }

    editing: boolean = false;

    submitForm(form: NgForm) {
        if (form.valid) {
            this.model.saveProduct(this.product);
            this.product = new Product();
            form.reset();
        }
    }

    resetForm() {
        this.product = new Product();
    }
}

Listing 25-16.Using the New Route Parameter in the form.component.ts File in the src/app/core Folder

当用户点击编辑按钮时,被激活的路由 URL 告诉表单组件需要一个编辑操作,并指定要修改的产品,从而允许表单被正确填充,如图 25-5 所示。

img/421542_4_En_25_Fig5_HTML.jpg

图 25-5。

使用 URL 段提供信息

Tip

注意,我检查以确认我已经能够从清单 25-16 中的数据模型中检索到一个Product对象,如果不是这样,就创建一个新对象。这一点很重要,因为模型中的数据是异步获取的,如果用户直接请求 URL,在显示表单组件时可能还没有到达。这也可能是开发中的一个问题,应用中代码的更改会触发重新编译,然后重新加载您在进行更改之前导航到的任何 URL。结果是一个错误,因为 Angular 试图直接导航到一个您预期在填充数据模型之前不需要的路径。在第二十七章中,我解释了如何阻止路由被激活,直到一个特定的条件成立,比如数据到达。

使用可选的路由参数

可选的 route 参数允许 URL 包含为应用的其余部分提供提示或指导的信息,但这对于应用的工作来说不是必需的。

这种类型的路由参数使用 URL 矩阵符号表示,这不是 URL 规范的一部分,但浏览器仍然支持。以下是一个具有可选路由参数的 URL 示例:

http://localhost:4200/form/edit/2;name=Lifejacket;price=48.95

可选的路由参数由分号(;字符)分隔,这个 URL 包括名为nameprice的可选参数。

作为如何使用可选参数的演示,清单 25-17 显示了添加一个可选的路由参数,该参数包括要编辑的对象作为 URL 的一部分。这些信息并不重要,因为表单组件可以从模型中获取数据,但是通过路由 URL 接收数据可以避免一些工作。

<table class="table table-sm table-bordered table-striped">
    <tr>
        <th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
    </tr>
    <tr *ngFor="let item of getProducts()">
        <td>{{item.id}}</td>
        <td>{{item.name}}</td>
        <td>{{item.category}}</td>
        <td>{{item.price | currency:"USD" }}</td>
        <td class="text-center">
            <button class="btn btn-danger btn-sm mr-1"
                    (click)="deleteProduct(item.id)">
                Delete
            </button>
            <button class="btn btn-warning btn-sm" (click)="editProduct(item.id)"
                    [routerLink]="['/form', 'edit', item.id,
                    {name: item.name, category: item.category, price: item.price}]">
                Edit
            </button>
        </td>
    </tr>
</table>
<button class="btn btn-primary m-1" (click)="createProduct()"
        routerLink="/form/create">
    Create New Product
</button>
<button class="btn btn-danger" (click)="deleteProduct(-1)">
    Generate HTTP Error
</button>

Listing 25-17.An Optional Route Parameter in the table.component.html File in the src/app/core Folder

可选值表示为文字对象,其中属性名标识可选参数。在这个例子中,有namecategoryprice属性,它们的值使用由ngIf指令处理的对象来设置。可选参数将生成如下所示的 URL:

http://localhost:4200/form/edit/5;name=Stadium;category=Soccer;price=79500

清单 25-18 显示了表单组件如何检查可选参数是否存在。如果它们已经包含在 URL 中,那么参数值被用来避免对数据模型的请求。

import { Component, Inject } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { ActivatedRoute } from "@angular/router";

@Component({
    selector: "paForm",
    templateUrl: "form.component.html",
    styleUrls: ["form.component.css"]
})
export class FormComponent {
    product: Product = new Product();

    constructor(private model: Model, activeRoute: ActivatedRoute) {
        this.editing = activeRoute.snapshot.params["mode"] == "edit";
        let id = activeRoute.snapshot.params["id"];
        if (id != null) {
            let name = activeRoute.snapshot.params["name"];
            let category = activeRoute.snapshot.params["category"];
            let price = activeRoute.snapshot.params["price"];

            if (name != null && category != null && price != null) {
                this.product.id = id;
                this.product.name = name;
                this.product.category = category;
                this.product.price = Number.parseFloat(price);
            } else {
                Object.assign(this.product, model.getProduct(id) || new Product());
            }
        }
    }

    editing: boolean = false;

    submitForm(form: NgForm) {
        if (form.valid) {
            this.model.saveProduct(this.product);
            this.product = new Product();
            form.reset();
        }
    }

    resetForm() {
        this.product = new Product();
    }
}

Listing 25-18.Receiving Optional Parameters in the form.component.ts File in the src/app/core Folder

可选路由参数的访问方式与必需参数相同,组件负责检查它们是否存在,如果它们不是 URL 的一部分,则继续处理。在这种情况下,如果 URL 不包含它所寻找的可选参数,组件就可以返回查询数据模型。

在代码中导航

使用routerLink属性可以很容易地在模板中设置导航,但是应用通常需要在组件或指令中代表用户启动导航。

为了让指令和组件等构建模块访问路由系统,Angular 提供了Router类,该类通过依赖注入作为服务提供,其最有用的方法和属性在表 25-9 中描述。

表 25-9。

选定的路由器方法和属性

|

名字

|

描述

|
| --- | --- |
| navigated | 如果至少有一个导航事件,这个boolean属性返回true,否则返回false。 |
| url | 此属性返回活动 URL。 |
| isActive(url, exact) | 如果指定的 URL 是由活动路由定义的 URL,则该方法返回trueexact参数指定了指定 URL 中的所有段是否都必须与当前 URL 匹配,以便该方法返回true。 |
| events | 这个属性返回一个Observable<Event>,它可以用来监控导航的变化。有关详细信息,请参见“接收导航事件”部分。 |
| navigateByUrl(url, extras) | 此方法导航到指定的 URL。该方法的结果是一个Promise,当导航成功时用true解决,当导航失败时用false解决,当有错误时被拒绝。 |
| navigate(commands, extras) | 此方法使用线段数组导航。extras对象可用于指定 URL 的改变是否与当前路由相关。该方法的结果是一个Promise,当导航成功时用true解决,当导航失败时用false解决,当有错误时被拒绝。 |

navigatenavigateByUrl方法使得在构建块(比如组件)内部执行导航变得容易。清单 25-19 展示了在创建或更新产品后,使用表单组件中的Router将应用重定向回表格。

import { Component, Inject } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { ActivatedRoute, Router } from "@angular/router";

@Component({
    selector: "paForm",
    templateUrl: "form.component.html",
    styleUrls: ["form.component.css"]
})
export class FormComponent {
    product: Product = new Product();

    constructor(private model: Model, activeRoute: ActivatedRoute,
            private router: Router) {

        this.editing = activeRoute.snapshot.params["mode"] == "edit";
        let id = activeRoute.snapshot.params["id"];
        if (id != null) {
            let name = activeRoute.snapshot.params["name"];
            let category = activeRoute.snapshot.params["category"];
            let price = activeRoute.snapshot.params["price"];

            if (name != null && category != null && price != null) {
                this.product.id = id;
                this.product.name = name;
                this.product.category = category;
                this.product.price = Number.parseFloat(price);
            } else {
                Object.assign(this.product, model.getProduct(id) || new Product());
            }
        }
    }

    editing: boolean = false;

    submitForm(form: NgForm) {
        if (form.valid) {
            this.model.saveProduct(this.product);
            //this.product = new Product();
            //form.reset();
            this.router.navigateByUrl("/");
        }
    }

    resetForm() {
        this.product = new Product();
    }
}

Listing 25-19.Navigating Programmatically in the form.component.ts File in the src/app/core Folder

组件接收Router对象作为构造函数参数,并在submitForm方法中使用它导航回应用的根 URL。在submitForm方法中被注释掉的两条语句不再需要,因为一旦表单组件不再显示,路由系统就会销毁它,这意味着不需要重置表单的状态。

结果是单击表单中的保存或创建按钮将导致应用显示产品表,如图 25-6 所示。

img/421542_4_En_25_Fig6_HTML.jpg

图 25-6。

以编程方式导航

接收导航事件

在许多应用中,有些组件或指令不直接参与应用的导航,但仍然需要知道导航何时发生。示例应用在消息组件中包含一个示例,它向用户显示通知和错误。该组件总是显示最新的消息,即使该信息已经过时并且对用户没有帮助。要查看问题,请单击“生成 HTTP 错误”按钮,然后单击“创建新产品”按钮或其中一个编辑按钮。即使您在应用中导航到其他位置,错误消息仍会显示出来。

Router类定义的events属性返回一个Observable<Event>,它发出一系列Event对象来描述路由系统的变化。通过观察器提供五种类型的事件,如表 25-10 所述。

表 25-10。

路由器事件观察器提供的事件类型

|

名字

|

描述

|
| --- | --- |
| NavigationStart | 导航过程开始时发送此事件。 |
| RoutesRecognized | 当路由系统将 URL 与路由匹配时,会发送此事件。 |
| NavigationEnd | 导航过程成功完成时会发送此事件。 |
| NavigationError | 当导航过程产生错误时,会发送此事件。 |
| NavigationCancel | 导航过程取消时会发送此事件。 |

所有事件类都定义了一个id属性和一个url属性,前者返回每次导航增加的数字,后者返回目标 URL。RoutesRecognizedNavigationEnd事件还定义了一个urlAfterRedirects属性,该属性返回已经导航到的 URL。

为了解决消息传递系统的问题,清单 25-20 订阅由Router.events属性提供的Observer,并在收到NavigationEndNavigationCancel事件时清除显示给用户的消息。

import { Component } from "@angular/core";
import { MessageService } from "./message.service";
import { Message } from "./message.model";
import { Observable } from "rxjs";
import { Router, NavigationEnd, NavigationCancel } from "@angular/router";
import { filter } from "rxjs/operators";

@Component({
    selector: "paMessages",
    templateUrl: "message.component.html",
})
export class MessageComponent {
    lastMessage: Message;

    constructor(messageService: MessageService, router: Router) {
        messageService.messages.subscribe(m => this.lastMessage = m);
        router.events
            .pipe(filter(e => e instanceof NavigationEnd
                || e instanceof NavigationCancel))
            .subscribe(e => { this.lastMessage = null; });
    }
}

Listing 25-20.Responding to Events in the message.component.ts File in the src/app/messages Folder

filter方法用于从Observer中选择一种类型的事件,subscribe方法更新lastMessage属性,这将清除组件显示的消息。清单 25-21 将路由功能导入消息模块。(因为根模块已经导入了路由特性,所以这并不是应用工作所必需的,但是让每个模块导入它所需要的所有特性是一个很好的实践。)

import { NgModule, ErrorHandler } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { MessageComponent } from "./message.component";
import { MessageService } from "./message.service";
import { MessageErrorHandler } from "./errorHandler";
import { RouterModule } from "@angular/router";

@NgModule({
    imports: [BrowserModule, RouterModule],
    declarations: [MessageComponent],
    exports: [MessageComponent],
    providers: [MessageService,
        { provide: ErrorHandler, useClass: MessageErrorHandler }]
})
export class MessageModule { }

Listing 25-21.Importing the Routing Module in the message.module.ts File in the src/app/messages Folder

这些变化的结果是,直到下一次导航事件,消息才会显示给用户,如图 25-7 所示。

img/421542_4_En_25_Fig7_HTML.jpg

图 25-7。

响应导航事件

移除事件绑定和支持代码

使用路由系统的一个好处是,它可以简化应用,用导航更改替换事件绑定和它们调用的方法。完成路由实现的最后一项更改是删除以前用于组件间协调的机制的最后痕迹。清单 25-22 注释掉了表格组件模板中的事件绑定,这些事件绑定用于在用户单击 Create New Product 或 Edit 按钮时做出响应。(删除按钮的事件绑定仍然是必需的。)

<table class="table table-sm table-bordered table-striped">
    <tr>
        <th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
    </tr>
    <tr *ngFor="let item of getProducts()">
        <td>{{item.id}}</td>
        <td>{{item.name}}</td>
        <td>{{item.category}}</td>
        <td>{{item.price | currency:"USD" }}</td>
        <td class="text-center">
            <button class="btn btn-danger btn-sm mr-1"
                    (click)="deleteProduct(item.id)">
                Delete
            </button>
            <button class="btn btn-warning btn-sm"
                [routerLink]="['/form', 'edit', item.id,
                    {name: item.name, category: item.category, price: item.price}]">
                Edit
            </button>
        </td>
    </tr>
</table>
<button class="btn btn-primary m-1" routerLink="/form/create">
    Create New Product
</button>
<button class="btn btn-danger" (click)="deleteProduct(-1)">
    Generate HTTP Error
</button>

Listing 25-22.Removing Event Bindings in the table.component.html File in the src/app/core Folder

清单 25-23 显示了组件中相应的变化,这些变化删除了事件绑定调用的方法,并删除了对服务的依赖,该服务用于通知何时应该编辑或创建产品。

import { Component, Inject } from "@angular/core";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
//import { MODES, SharedState, SHARED_STATE } from "./sharedState.model";
//import { Observer } from "rxjs";

@Component({
    selector: "paTable",
    templateUrl: "table.component.html"
})
export class TableComponent {

    constructor(private model: Model,
        /*@Inject(SHARED_STATE) private observer: Observer<SharedState>*/) { }

    getProduct(key: number): Product {
        return this.model.getProduct(key);
    }

    getProducts(): Product[] {
        return this.model.getProducts();
    }

    deleteProduct(key: number) {
        this.model.deleteProduct(key);
    }

    //editProduct(key: number) {
    //    this.observer.next(new SharedState(MODES.EDIT, key));
    //}

    //createProduct() {
    //    this.observer.next(new SharedState(MODES.CREATE));
    //}
}

Listing 25-23.Removing Event Handling Code in the table.component.ts File in the src/app/core Folder

不再需要组件用于协调的服务,清单 25-24 从核心模块中禁用它。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { FormsModule } from "@angular/forms";
import { ModelModule } from "../model/model.module";
import { TableComponent } from "./table.component";
import { FormComponent } from "./form.component";
//import { SharedState, SHARED_STATE } from "./sharedState.model";
import { Subject } from "rxjs";
import { StatePipe } from "./state.pipe";
import { MessageModule } from "../messages/message.module";
import { MessageService } from "../messages/message.service";
import { Message } from "../messages/message.model";
import { Model } from "../model/repository.model";
//import { MODES } from "./sharedState.model";
import { RouterModule } from "@angular/router";

@NgModule({
    imports: [BrowserModule, FormsModule, ModelModule, MessageModule, RouterModule],
    declarations: [TableComponent, FormComponent, StatePipe],
    exports: [ModelModule, TableComponent, FormComponent],
    //providers: [{
    //    provide: SHARED_STATE,
    //    deps: [MessageService, Model],
    //    useFactory: (messageService, model) => {
    //        return new Subject<SharedState>();
    //    }
    //}]
})
export class CoreModule { }

Listing 25-24.Removing the Shared State Service in the core.module.ts File in the src/app/core Folder

结果是表格和表单组件之间的协调完全通过路由系统来处理,路由系统现在负责显示组件并管理组件之间的导航。

摘要

在本章中,我介绍了 Angular 路由特性,并演示了如何在应用中导航到一个 URL 来选择显示给用户的内容。我向您展示了如何在模板中创建导航链接,如何在组件或指令中执行导航,以及如何以编程方式响应导航更改。在下一章,我将继续描述角路由系统。

二十六、路由和导航:第二部分

在前一章中,我介绍了 Angular URL 路由系统,并解释了如何使用它来控制显示给用户的组件。路由系统有很多特性,我将在本章和第二十七章中继续描述。本章的重点是创建更复杂的路由,包括匹配任何 URL 的路由、将浏览器重定向到其他 URL 的路由、在组件内导航的路由以及选择多个组件的路由。表 26-1 总结了这一章。

表 26-1。

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 用单个路由匹配多个 URL | 使用路由通配符 | 1–9 |
| 将一个 URL 重定向到另一个 URL | 使用重定向路由 | Ten |
| 在组件内导航 | 使用相对 URL | Eleven |
| 当激活的 URL 改变时接收通知 | 使用由ActivatedRoute类提供的Observable对象 | Twelve |
| 特定管线处于活动状态时设置元素的样式 | 使用routerLinkActive属性 | 13–16 |
| 使用布线系统显示嵌套元件 | 定义子路由并使用router-outlet元素 | 17–21 |

准备示例项目

对于这一章,我将继续使用在第二十二章中创建的 exampleApp 项目,并在随后的每一章中对其进行修改。为了准备本章,我在 repository 类中添加了两个方法,如清单 26-1 所示。

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

import { Injectable } from "@angular/core";
import { Product } from "./product.model";
import { Observable } from "rxjs";
import { RestDataSource } from "./rest.datasource";

@Injectable()
export class Model {
    private products: Product[] = new Array<Product>();
    private locator = (p: Product, id: number) => p.id == id;

    constructor(private dataSource: RestDataSource) {
        this.dataSource.getData().subscribe(data => this.products = data);
    }

    getProducts(): Product[] {
        return this.products;
    }

    getProduct(id: number): Product {
        return this.products.find(p => this.locator(p, id));
    }

    getNextProductId(id: number): number {
        let index = this.products.findIndex(p => this.locator(p, id));
        if (index > -1) {
            return this.products[this.products.length > index + 2
                ? index + 1 : 0].id;
        } else {
            return id || 0;
        }
    }

    getPreviousProductid(id: number): number {
        let index = this.products.findIndex(p => this.locator(p, id));
        if (index > -1) {
            return this.products[index > 0
                ? index - 1 : this.products.length - 1].id;
        } else {
            return id || 0;
        }
    }

    saveProduct(product: Product) {
        if (product.id == 0 || product.id == null) {
            this.dataSource.saveProduct(product)
                .subscribe(p => this.products.push(p));
        } else {
            this.dataSource.updateProduct(product).subscribe(p => {
                let index = this.products
                    .findIndex(item => this.locator(item, p.id));
                this.products.splice(index, 1, p);
            });
        }
    }

    deleteProduct(id: number) {
        this.dataSource.deleteProduct(id).subscribe(() => {
            let index = this.products.findIndex(p => this.locator(p, id));
            if (index > -1) {
                this.products.splice(index, 1);
            }
        });
    }
}

Listing 26-1.Adding Methods in the repository.model.ts File in the src/app/model Folder

新方法接受一个 ID 值,定位相应的产品,然后返回存储库用来收集数据模型对象的数组中的下一个和上一个对象的 ID。我将在本章的后面使用这个特性来允许用户浏览数据模型中的对象集。

为了简化示例,清单 26-2 删除了表单组件中的语句,这些语句接收产品的详细信息,以便使用可选的路由参数进行编辑。我还更改了构造函数参数的访问级别,这样我就可以在组件的模板中直接使用它们。

import { Component, Inject } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { ActivatedRoute, Router } from "@angular/router";

@Component({
    selector: "paForm",
    templateUrl: "form.component.html",
    styleUrls: ["form.component.css"]
})
export class FormComponent {
    product: Product = new Product();

    constructor(public model: Model, activeRoute: ActivatedRoute,
        public router: Router) {

        this.editing = activeRoute.snapshot.params["mode"] == "edit";
        let id = activeRoute.snapshot.params["id"];
        if (id != null) {
            Object.assign(this.product, model.getProduct(id) || new Product());
        }
    }

    editing: boolean = false;

    submitForm(form: NgForm) {
        if (form.valid) {
            this.model.saveProduct(this.product);
            this.router.navigateByUrl("/");
        }
    }

    resetForm() {
        this.product = new Product();
    }
}

Listing 26-2.Removing Optional Parameters in the form.component.ts File in the src/app/core Folder

清单 26-3 从表格组件的模板中删除可选参数,这样它们就不会包含在编辑按钮的导航 URL 中。

<table class="table table-sm table-bordered table-striped">
    <tr>
        <th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
    </tr>
    <tr *ngFor="let item of getProducts()">
        <td>{{item.id}}</td>
        <td>{{item.name}}</td>
        <td>{{item.category}}</td>
        <td>{{item.price | currency:"USD" }}</td>
        <td class="text-center">
            <button class="btn btn-danger btn-sm mr-1"
                    (click)="deleteProduct(item.id)">
                Delete
            </button>
            <button class="btn btn-warning btn-sm"
                [routerLink]="['/form', 'edit', item.id]">
                Edit
            </button>
        </td>
    </tr>
</table>
<button class="btn btn-primary m-1" routerLink="/form/create">
    Create New Product
</button>
<button class="btn btn-danger" (click)="deleteProduct(-1)">
    Generate HTTP Error
</button>

Listing 26-3.Removing Route Parameters in the table.component.html File in the src/app/core Folder

向项目中添加组件

我需要在应用中添加一些组件来演示本章中涉及的一些特性。这些组件很简单,因为我关注的是路由系统,而不是为应用添加有用的功能。我在src/app/core文件夹中创建了一个名为productCount.component.ts的文件,并用它来定义清单 26-4 中所示的组件。

Tip

如果一个组件只通过路由系统显示,那么可以从@Component装饰器中省略selector属性。我倾向于添加它,这样我也可以使用 HTML 元素来应用组件。

import {
    Component, KeyValueDiffer, KeyValueDiffers, ChangeDetectorRef
} from "@angular/core";
import { Model } from "../model/repository.model";

@Component({
    selector: "paProductCount",
    template: `<div class="bg-info text-white p-2">There are
                  {{count}} products
               </div>`
})
export class ProductCountComponent {
    private differ: KeyValueDiffer<any, any>;
    count: number = 0;

    constructor(private model: Model,
        private keyValueDiffers: KeyValueDiffers,
        private changeDetector: ChangeDetectorRef) { }

    ngOnInit() {
        this.differ = this.keyValueDiffers
            .find(this.model.getProducts())
            .create();
    }

    ngDoCheck() {
        if (this.differ.diff(this.model.getProducts()) != null) {
            this.updateCount();
        }
    }

    private updateCount() {
        this.count = this.model.getProducts().length;
    }
}

Listing 26-4.The Contents of the productCount.component.ts File in the src/app/core Folder

这个component使用一个内联模板来显示数据模型中的产品数量,当数据模型改变时,这个模板会更新。接下来,我在src/app/core文件夹中添加了一个名为categoryCount.component.ts的文件,并定义了清单 26-5 中所示的组件。

import {
    Component, KeyValueDiffer, KeyValueDiffers, ChangeDetectorRef
} from "@angular/core";
import { Model } from "../model/repository.model";

@Component({
    selector: "paCategoryCount",
    template: `<div class="bg-primary p-2 text-white">
                    There are {{count}} categories
               </div>`
})
export class CategoryCountComponent {
    private differ: KeyValueDiffer<any, any>;
    count: number = 0;

    constructor(private model: Model,
        private keyValueDiffers: KeyValueDiffers,
        private changeDetector: ChangeDetectorRef) { }

    ngOnInit() {
        this.differ = this.keyValueDiffers
            .find(this.model.getProducts())
            .create();
    }

    ngDoCheck() {
        if (this.differ.diff(this.model.getProducts()) != null) {
            this.count = this.model.getProducts()
                .map(p => p.category)
                .filter((category, index, array) => array.indexOf(category) == index)
                .length;
        }
    }
}

Listing 26-5.The Contents of the categoryCount.component.ts File in the src/app/core Folder

该组件使用一个差异来跟踪数据模型中的变化,并计算唯一类别的数量,这是使用一个简单的内联模板显示的。对于最后一个组件,我在src/app/core文件夹中添加了一个名为notFound.component.ts的文件,并用它来定义清单 26-6 中所示的组件。

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

@Component({
    selector: "paNotFound",
    template: `<h3 class="bg-danger text-white p-2">Sorry, something went wrong</h3>
               <button class="btn btn-primary" routerLink="/">Start Over</button>`
})
export class NotFoundComponent {}

Listing 26-6The notFound.component.ts File in the src/app/core Folder

当路由系统出现问题时,该组件会显示一条静态消息。清单 26-7 向核心模块添加了新的组件。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { FormsModule } from "@angular/forms";
import { ModelModule } from "../model/model.module";
import { TableComponent } from "./table.component";
import { FormComponent } from "./form.component";
import { Subject } from "rxjs";
import { StatePipe } from "./state.pipe";
import { MessageModule } from "../messages/message.module";
import { MessageService } from "../messages/message.service";
import { Message } from "../messages/message.model";
import { Model } from "../model/repository.model";
import { RouterModule } from "@angular/router";
import { ProductCountComponent } from "./productCount.component";
import { CategoryCountComponent } from "./categoryCount.component";
import { NotFoundComponent } from "./notFound.component";

@NgModule({
    imports: [BrowserModule, FormsModule, ModelModule, MessageModule, RouterModule],
    declarations: [TableComponent, FormComponent, StatePipe,
        ProductCountComponent, CategoryCountComponent, NotFoundComponent],
    exports: [ModelModule, TableComponent, FormComponent]
})
export class CoreModule { }

Listing 26-7.Declaring Components in the core.module.ts File in the src/app/core Folder

打开一个新的命令提示符,导航到exampleApp文件夹,运行以下命令启动提供 RESTful web 服务器的服务器:

npm run json

打开一个单独的命令提示符,导航到exampleApp文件夹,运行以下命令启动 Angular 开发工具:

ng serve

打开一个新的浏览器窗口并导航到http://localhost:4200以查看如图 26-1 所示的内容。

img/421542_4_En_26_Fig1_HTML.jpg

图 26-1。

运行示例应用

使用通配符和重定向

应用中的路由配置会很快变得复杂,并包含冗余和奇怪的内容,以迎合应用的结构。Angular 提供了两个有用的工具,可以帮助简化路由,也可以在出现问题时进行处理,如以下部分所述。

在路由中使用通配符

角路由系统支持一个特殊的路径,由两个星号(**字符)表示,允许路由匹配任何 URL。通配符路径的基本用途是处理导航,否则会产生路由错误。清单 26-8 向表格组件的模板中添加了一个按钮,该按钮导航到一个尚未由应用的路由配置定义的路由。

<table class="table table-sm table-bordered table-striped">
    <tr>
        <th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
    </tr>
    <tr *ngFor="let item of getProducts()">
        <td>{{item.id}}</td>
        <td>{{item.name}}</td>
        <td>{{item.category}}</td>
        <td>{{item.price | currency:"USD" }}</td>
        <td class="text-center">
            <button class="btn btn-danger btn-sm mr-1"
                    (click)="deleteProduct(item.id)">
                Delete
            </button>
            <button class="btn btn-warning btn-sm"
                [routerLink]="['/form', 'edit', item.id]">
                Edit
            </button>
        </td>
    </tr>
</table>
<button class="btn btn-primary m-1" routerLink="/form/create">
    Create New Product
</button>
<button class="btn btn-danger" (click)="deleteProduct(-1)">
    Generate HTTP Error
</button>
<button class="btn btn-danger m-1" routerLink="/does/not/exist">
    Generate Routing Error
</button>

Listing 26-8.Adding a Button in the table.component.html File in the src/app/core Folder

单击该按钮将要求应用导航到 URL /does/not/exist,因为没有为其配置路由。当一个 URL 与一个 URL 不匹配时,会抛出一个错误,然后由错误处理类拾取并处理,这导致消息组件显示一个警告,如图 26-2 所示。

img/421542_4_En_26_Fig2_HTML.jpg

图 26-2。

默认导航错误

这不是处理未知路由的有用方法,因为用户不知道什么是路由,也可能没有意识到应用试图导航到有问题的 URL。

更好的方法是使用通配符 route 来处理尚未定义的 URL 的导航,并选择一个组件来为用户提供更有用的消息,如清单 26-9 所示。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";

const routes: Routes = [
    { path: "form/:mode/:id", component: FormComponent },
    { path: "form/:mode", component: FormComponent },
    { path: "", component: TableComponent },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 26-9.Adding a Wildcard Route in the app.routing.ts File in the src/app Folder

清单中的新路由使用通配符选择NotFoundComponent,当点击生成路由错误按钮时,显示如图 26-3 所示的消息。

img/421542_4_En_26_Fig3_HTML.jpg

图 26-3。

使用通配符路由

单击重新开始按钮导航到/ URL,这将选择要显示的表格组件。

在路由中使用重定向

路由不必选择组件;它们也可以用作别名,将浏览器重定向到不同的 URL。重定向是使用路由中的redirectTo属性定义的,如清单 26-10 所示。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";

const routes: Routes = [
    { path: "form/:mode/:id", component: FormComponent },
    { path: "form/:mode", component: FormComponent },
    { path: "does", redirectTo: "/form/create", pathMatch: "prefix" },
    { path: "table", component: TableComponent },
    { path: "", redirectTo: "/table", pathMatch: "full" },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 26-10.Using Route Redirection in the app.routing.ts File in the src/app Folder

redirectTo属性用于指定浏览器将被重定向到的 URL。定义重定向时,还必须指定pathMatch属性,使用表 26-2 中描述的值之一。

表 26-2。

路径匹配值

|

名字

|

描述

|
| --- | --- |
| prefix | 该值配置路由,使其匹配以指定路径开头的 URL,忽略任何后续的段。 |
| full | 该值配置路由,使其仅匹配由path属性指定的 URL。 |

在清单 26-10 中添加的第一个路由指定了一个prefixpathMatch值和一个does的路径,这意味着它将匹配任何第一段是does的 URL,比如通过生成路由错误按钮导航到的/does/not/exist URL。当浏览器导航到具有该前缀的 URL 时,路由系统会将其重定向到/form/create URL,如图 26-4 所示。

img/421542_4_En_26_Fig4_HTML.jpg

图 26-4。

执行路由重定向

清单 26-10 中的其他路由将空路径重定向到显示表格组件的/table URL。这是一种使 URL 模式更明显的常用技术,因为它匹配默认 URL ( http://localhost:4200/)并将其重定向到对用户来说更有意义和更容易记住的内容(http://localhost:4200/table)。在这种情况下,pathMatch属性值是full,尽管这没有任何影响,因为它已经应用于空路径。

在组件内导航

上一章中的示例在不同的组件之间导航,因此单击表格组件中的按钮可以导航到表单组件,反之亦然。

这不是唯一可能的导航方式。您还可以在组件内导航。为了演示,清单 26-11 向表单组件添加了按钮,允许用户编辑上一个或下一个数据对象。

<div class="bg-primary text-white p-2" [class.bg-warning]="editing">
    <h5>{{editing  ? "Edit" : "Create"}} Product</h5>
    <!-- Last Event: {{ stateEvents | async | formatState }} -->
</div>

<div *ngIf="editing" class="p-2">
    <button class="btn btn-secondary m-1"
            [routerLink]="['/form', 'edit', model.getPreviousProductid(product.id)]">
        Previous
    </button>
    <button class="btn btn-secondary"
            [routerLink]="['/form', 'edit', model.getNextProductId(product.id)]">
        Next
    </button>
</div>

<form novalidate #form="ngForm" (ngSubmit)="submitForm(form)" (reset)="resetForm()" >

    <div class="form-group">
        <label>Name</label>
        <input class="form-control" name="name"
               [(ngModel)]="product.name" required />
    </div>

    <div class="form-group">
        <label>Category</label>
        <input class="form-control" name="category"
               [(ngModel)]="product.category" required />
    </div>

    <div class="form-group">
        <label>Price</label>
        <input class="form-control" name="price"
               [(ngModel)]="product.price"
               required pattern="^[0-9\.]+$" />
    </div>

    <button type="submit" class="btn btn-primary m-1"
            [class.btn-warning]="editing" [disabled]="form.invalid">
        {{editing ? "Save" : "Create"}}
    </button>
    <button type="reset" class="btn btn-secondary m-1" routerLink="/">
            Cancel
    </button>
</form>

Listing 26-11.Adding Buttons to the form.component.html File in the src/app/core Folder

这些按钮绑定了针对数据模型中前一个和下一个对象的表达式的routerLink指令。这意味着,例如,如果您单击救生衣表格中的编辑按钮,下一个按钮将导航到编辑足球的 URL,上一个按钮将导航到 kayak 的 URL。

响应正在进行的路由更改

尽管单击“上一步”和“下一步”按钮时 URL 会发生变化,但显示给用户的数据不会发生变化。Angular 试图在导航过程中保持高效,它知道“上一步”和“下一步”按钮导航到的 URL 是由当前显示给用户的同一个组件处理的。它不是创建组件的新实例,而是简单地告诉组件所选的路由已经更改。

这是一个问题,因为表单组件没有设置为接收更改通知。它的构造函数接收 Angular 用来提供当前路由细节的ActivatedRoute对象,但是只使用它的snapshot属性。当 Angular 更新ActivatedRoute对象中的值时,组件的构造函数早已被执行,这意味着它错过了通知。当应用的配置意味着每次用户想要创建或编辑产品时都要创建一个新的表单组件时,这种方法是有效的,但是这已经不够了。

幸运的是,ActivatedRoute类定义了一组属性,允许相关方通过反应扩展Observable对象接收通知。这些属性对应于由快照属性返回的ActivatedRouteSnapshot对象提供的属性(在第二十五章中描述),但是当有任何后续变化时发送新的事件,如表 26-3 中所述。

表 26-3。

ActivatedRoute 类的可观察属性

|

名字

|

描述

|
| --- | --- |
| url | 该属性返回一个Observable<UrlSegment[]>,它在每次路由改变时提供一组 URL 段。 |
| params | 该属性返回一个Observable<Params>,它在每次路由改变时提供 URL 参数。 |
| queryParams | 该属性返回一个Observable<Params>,它在每次路由改变时提供 URL 查询参数。 |
| fragment | 该属性返回一个Observable<string>,它在每次路由改变时提供 URL 片段。 |

这些属性可以被需要处理导航变化的组件使用,这些变化不会导致向用户显示不同的组件,如清单 26-12 所示。

Tip

如果您需要组合来自路由的不同数据元素,例如同时使用路段和参数,那么为一个数据元素订阅Observer,并使用snapshot属性获取您需要的其余数据。

import { Component, Inject } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { ActivatedRoute, Router } from "@angular/router";

@Component({
    selector: "paForm",
    templateUrl: "form.component.html",
    styleUrls: ["form.component.css"]
})
export class FormComponent {
    product: Product = new Product();

    constructor(public model: Model, activeRoute: ActivatedRoute,
        public  router: Router) {

        activeRoute.params.subscribe(params => {
            this.editing = params["mode"] == "edit";
            let id = params["id"];
            if (id != null) {
                Object.assign(this.product, model.getProduct(id) || new Product());
            }
        })
    }

    editing: boolean = false;

    submitForm(form: NgForm) {
        if (form.valid) {
            this.model.saveProduct(this.product);
            this.router.navigateByUrl("/");
        }
    }

    resetForm() {
        this.product = new Product();
    }
}

Listing 26-12.Observing Route Changes in the form.component.ts File in the src/app/core Folder

组件订阅了Observer<Params>,每次活动路由改变时,它都会向订阅者发送一个新的Params对象。由ActivatedRoute属性返回的Observer对象在调用 subscribe 方法时发送最近一次路由更改的细节,确保组件的构造函数不会错过导致它被调用的初始导航。

结果是,组件可以对不会导致 Angular 创建新组件的路由更改做出反应,这意味着单击下一个或上一个按钮会更改已被选择进行编辑的产品,如图 26-5 所示。

img/421542_4_En_26_Fig5_HTML.jpg

图 26-5。

响应路由变更

Tip

当激活的路由改变显示给用户的组件时,导航的效果是明显的。当仅仅是数据改变时,它可能不那么明显。为了帮助强调变化,Angular 可以应用动画来引起对导航效果的注意。详见第二十八章。

激活管线的样式链接

路由系统的一个常见用途是在它们选择的内容旁边显示多个导航元素。为了演示,清单 26-13 向应用添加了一个新的路由,该路由将允许使用包含类别过滤器的 URL 来定位表格组件。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";

const routes: Routes = [
    { path: "form/:mode/:id", component: FormComponent },
    { path: "form/:mode", component: FormComponent },
    { path: "does", redirectTo: "/form/create", pathMatch: "prefix" },
    { path: "table/:category", component: TableComponent },
    { path: "table", component: TableComponent },
    { path: "", redirectTo: "/table", pathMatch: "full" },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 26-13.Defining a Route in the app.routing.ts File in the src/app Folder

清单 26-14 更新了TableComponent类,以便它使用路由系统来获取活动路由的细节,并将category路由参数的值赋给一个可以在模板中访问的category属性。在getProducts方法中使用了category属性来过滤数据模型中的对象。

import { Component, Inject } from "@angular/core";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { ActivatedRoute } from "@angular/router";

@Component({
    selector: "paTable",
    templateUrl: "table.component.html"
})
export class TableComponent {
    category: string = null;

    constructor(public model: Model, activeRoute: ActivatedRoute) {
        activeRoute.params.subscribe(params => {
            this.category = params["category"] || null;
        })
    }

    getProduct(key: number): Product {
        return this.model.getProduct(key);
    }

    getProducts(): Product[] {
        return this.model.getProducts()
            .filter(p => this.category == null || p.category == this.category);
    }

    get categories(): string[] {
        return this.model.getProducts()
            .map(p => p.category)
            .filter((category, index, array) => array.indexOf(category) == index);
    }

    deleteProduct(key: number) {
        this.model.deleteProduct(key);
    }
}

Listing 26-14.Adding Category Filter Support in the table.component.ts File in the src/app/core Folder

还有一个新的categories属性,将在模板中用于生成过滤的类别集。最后一步是将 HTML 元素添加到允许用户应用过滤器的模板中,如清单 26-15 所示。

<div class="container-fluid">
    <div class="row">
        <div class="col-auto">
            <button class="btn btn-secondary btn-block"
                    routerLink="/" routerLinkActive="bg-primary">
                All
            </button>
            <button *ngFor="let category of categories"
                    class="btn btn-secondary btn-block px-3"
                    [routerLink]="['/table', category]"
                    routerLinkActive="bg-primary">
                {{category}}
            </button>
        </div>
        <div class="col">
            <table class="table table-sm table-bordered table-striped">
                <tr>
                    <th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
                </tr>
                <tr *ngFor="let item of getProducts()">
                    <td>{{item.id}}</td>
                    <td>{{item.name}}</td>
                    <td>{{item.category}}</td>
                    <td>{{item.price | currency:"USD" }}</td>
                    <td class="text-center">
                        <button class="btn btn-danger btn-sm mr-1"
                                (click)="deleteProduct(item.id)">
                            Delete
                        </button>
                        <button class="btn btn-warning btn-sm"
                            [routerLink]="['/form', 'edit', item.id]">
                            Edit
                        </button>
                    </td>
                </tr>
            </table>
        </div>
    </div>
</div>
<div class="p-2 text-center">
    <button class="btn btn-primary m-1" routerLink="/form/create">
        Create New Product
    </button>
    <button class="btn btn-danger" (click)="deleteProduct(-1)">
        Generate HTTP Error
    </button>
    <button class="btn btn-danger m-1" routerLink="/does/not/exist">
        Generate Routing Error
    </button>
</div>

Listing 26-15.Adding Filter Elements in the table.component.html File in the src/app/core Folder

该示例的重要部分是使用了routerLinkActive属性,该属性用于指定一个 CSS 类,当由routerLink属性指定的 URL 与活动路由匹配时,该元素将被分配给该 CSS 类。

清单指定了一个名为bg-primary的类,它改变了按钮的外观,使选中的类别更加明显。当与添加到清单 26-14 中的组件的功能相结合时,结果是一组按钮,允许用户查看单个类别中的产品,如图 26-6 所示。

img/421542_4_En_26_Fig6_HTML.jpg

图 26-6。

过滤产品

如果您单击足球按钮,应用将导航到/table/Soccer URL,并且表格将只显示足球类别中的那些产品。足球按钮也将被高亮显示,因为routerLinkActive属性意味着 Angular 将把button元素添加到 Bootstrap bg-primary类中。

修复全部按钮

导航按钮揭示了一个常见的问题,即 All 按钮总是被添加到active类中,即使用户已经过滤了表以显示特定的类别。

这是因为默认情况下,routerLinkActive属性在活动 URL 上执行部分匹配。在这个例子中,/ URL 将总是导致 All 按钮被激活,因为它在所有 URL 的开始。这个问题可以通过配置routerLinkActive指令来解决,如清单 26-16 所示。

...
<div class="col-auto">
    <button class="btn btn-secondary btn-block"
        routerLink="/table" routerLinkActive="bg-primary"
        [routerLinkActiveOptions]="{exact: true}">
        All
    </button>
    <button *ngFor="let category of categories"
            class="btn btn-secondary btn-block px-3"
            [routerLink]="['/table', category]"
            routerLinkActive="bg-primary">
        {{category}}
    </button>
</div>
...

Listing 26-16.Configuring the Directive in the table.component.html File in the src/app/core Folder

这个配置是通过绑定接受文字对象的routerLinkActiveOptions属性来执行的。exact属性是唯一可用的配置设置,用于控制匹配活动路由 URL。将该属性设置为true会将元素添加到由routerLinkActive属性指定的类中,仅当与活动路径的 URL 完全匹配时。有了这个改变,只有当所有的产品都显示时,“全部”按钮才会高亮显示,如图 26-7 所示。

img/421542_4_En_26_Fig7_HTML.jpg

图 26-7。

修复所有按钮问题

创建子路由

子路由允许组件通过在模板中嵌入router-outlet元素来响应 URL 的一部分,从而创建更复杂的内容安排。我将使用本章开始时创建的简单组件来演示子路由是如何工作的。这些组件将显示在产品表的上方,所显示的组件将在表 26-4 中显示的 URL 中指定。

表 26-4。

他们将选择的 URL 和组件

|

统一资源定位器

|

成分

|
| --- | --- |
| /table/products | 将显示ProductCountComponent。 |
| /table/categories | 将显示CategoryCountComponent。 |
| /table | 两个组件都不会显示。 |

清单 26-17 显示了应用路由配置的变化,以实现表中的路由策略。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";
import { ProductCountComponent } from "./core/productCount.component";
import { CategoryCountComponent } from "./core/categoryCount.component";

const routes: Routes = [
    { path: "form/:mode/:id", component: FormComponent },
    { path: "form/:mode", component: FormComponent },
    { path: "does", redirectTo: "/form/create", pathMatch: "prefix" },
    {
        path: "table",
        component: TableComponent,
        children: [
            { path: "products", component: ProductCountComponent },
            { path: "categories", component: CategoryCountComponent }
        ]
    },
    { path: "table/:category", component: TableComponent },
    { path: "table", component: TableComponent },
    { path: "", redirectTo: "/table", pathMatch: "full" },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 26-17.Configuring Routes in the app.routing.ts File in the src/app Folder

子路由是使用children属性定义的,该属性被设置为一个路由数组,其定义方式与顶级路由相同。当 Angular 使用整个 URL 来匹配具有子路由的路由时,只有当浏览器导航到的 URL 包含既匹配顶级段又匹配由其中一个子路由指定的段时,才会有匹配。

Tip

请注意,我已经在路径为table/:category的路径之前添加了新的路径。Angular 尝试按照路径定义的顺序匹配路径。table/:category路径将匹配/table/products/table/categoriesURL,并引导表格组件过滤不存在类别的产品。通过首先放置更具体的路由,/table/products/table/categoriesURL 将在table/:category路径被考虑之前被匹配。

创建子路由出口

子路由选择的组件显示在父路由选择的组件模板中定义的router-outlet元素中。在本例中,这意味着子路由将指向表格组件模板中的一个元素,如清单 26-18 所示,其中还添加了将导航到新路由的元素。

<div class="container-fluid">
    <div class="row">
        <div class="col-auto">
            <button class="btn btn-secondary btn-block"
                routerLink="/table" routerLinkActive="bg-primary"
                [routerLinkActiveOptions]="{exact: true}">
                All
            </button>
            <button *ngFor="let category of categories"
                    class="btn btn-secondary btn-block px-3"
                    [routerLink]="['/table', category]"
                    routerLinkActive="bg-primary">
                {{category}}
            </button>
        </div>
        <div class="col">
            <button class="btn btn-info mx-1" routerLink="/table/products">
                Count Products
            </button>
            <button class="btn btn-primary mx-1" routerLink="/table/categories">
                Count Categories
            </button>
            <button class="btn btn-secondary mx-1" routerLink="/table">
                Count Neither
            </button>
            <div class="my-2">
                <router-outlet></router-outlet>
            </div>
            <table class="table table-sm table-bordered table-striped">
                <tr>
                    <th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
                </tr>
                <tr *ngFor="let item of getProducts()">
                    <td>{{item.id}}</td>
                    <td>{{item.name}}</td>
                    <td>{{item.category}}</td>
                    <td>{{item.price | currency:"USD" }}</td>
                    <td class="text-center">
                        <button class="btn btn-danger btn-sm mr-1"
                                (click)="deleteProduct(item.id)">
                            Delete
                        </button>
                        <button class="btn btn-warning btn-sm"
                            [routerLink]="['/form', 'edit', item.id]">
                            Edit
                        </button>
                    </td>
                </tr>
            </table>
        </div>
    </div>
</div>
<div class="p-2 text-center">
    <button class="btn btn-primary m-1" routerLink="/form/create">
        Create New Product
    </button>
    <button class="btn btn-danger" (click)="deleteProduct(-1)">
        Generate HTTP Error
    </button>
    <button class="btn btn-danger m-1" routerLink="/does/not/exist">
        Generate Routing Error
    </button>
</div>

Listing 26-18.Adding an Outlet in the table.component.html File in the src/app/core Folder

button元素有routerLink属性,指定表 26-4 中列出的 URL,还有一个router-outlet元素,用于显示选中的组件,如图 26-8 所示,如果浏览器导航到/table URL,则没有组件。

img/421542_4_En_26_Fig8_HTML.jpg

图 26-8。

使用子路由

从子路由中访问参数

子路由可以使用顶级路由的所有可用功能,包括定义路由参数,甚至拥有自己的子路由。由于 Angular 将孩子与其父母隔离的方式,路由参数在子路由中值得特别注意。对于本节,我将添加对表 26-5 中描述的 URL 的支持。

表 26-5。

示例应用支持的新 URL

|

名字

|

描述

|
| --- | --- |
| /table/:category/products | 这条路由将过滤表格的内容并选择ProductCountComponent。 |
| /table/:category/categories | 这条路由将过滤表格的内容并选择CategoryCountComponent。 |

清单 26-19 定义了支持表中所示 URL 的路由。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";
import { ProductCountComponent } from "./core/productCount.component";
import { CategoryCountComponent } from "./core/categoryCount.component";

const childRoutes: Routes = [
    { path: "products", component: ProductCountComponent },
    { path: "categories", component: CategoryCountComponent },
    { path: "", component: ProductCountComponent }
];

const routes: Routes = [
    { path: "form/:mode/:id", component: FormComponent },
    { path: "form/:mode", component: FormComponent },
    { path: "does", redirectTo: "/form/create", pathMatch: "prefix" },
    { path: "table", component: TableComponent, children: childRoutes },
    { path: "table/:category", component: TableComponent, children: childRoutes },
    { path: "", redirectTo: "/table", pathMatch: "full" },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 26-19.Adding Routes in the app.routing.ts File in the src/app Folder

children属性的类型是一个Routes对象,当您需要在 URL 模式的不同部分应用同一组子路由时,这使得路由配置中的重复最小化变得容易。在清单中,我在名为childRoutesRoutes对象中定义了子路由,并在两个不同的顶级路由中将它用作children属性的值。

为了能够定位这些新路由,清单 26-20 改变了出现在表格上方的按钮的目标,以便它们相对于当前的 URL 进行导航。我已经删除了“既不计数”按钮,因为当空路径子路由与 URL 匹配时,将显示ProductCountComponent

...
<div class="col">
    <button class="btn btn-info mx-1" routerLink="products">
        Count Products
    </button>
    <button class="btn btn-primary mx-1" routerLink="categories">
        Count Categories
    </button>
    <button class="btn btn-secondary mx-1" routerLink="/table">
        Count Neither
    </button>
    <div class="my-2">
        <router-outlet></router-outlet>
    </div>

    <table class="table table-sm table-bordered table-striped">
...

Listing 26-20.Using Relative URLs in the table.component.html File in the src/app/core Folder

当 Angular 匹配路由时,它提供给通过ActivatedRoute对象选择的组件的信息被分离,这样每个组件只接收选择它的那部分路由的细节。

在清单 26-20 中添加的路由的情况下,这意味着ProductCountComponentCategoryCountComponent接收到一个ActivatedRoute对象,该对象仅描述了选择它们的子路由,带有单个线段/products/categories。同样,TableComponent组件接收一个ActivatedRoute对象,它不包含用于匹配子路由的线段。

幸运的是,ActivatedRoute类提供了一些属性,这些属性提供了对剩余路由的访问,允许父母和孩子访问剩余的路由信息,如表 26-6 中所述。

表 26-6。

子-父路由信息的 ActivatedRoute 属性

|

名字

|

描述

|
| --- | --- |
| pathFromRoot | 该属性返回一个由ActivatedRoute对象组成的数组,这些对象代表了用于匹配当前 URL 的所有路由。 |
| parent | 该属性返回一个代表选择组件的路由的父路由的ActivatedRoute。 |
| firstChild | 该属性返回一个ActivatedRoute,表示用于匹配当前 URL 的第一个子路由。 |
| children | 该属性返回一个由ActivatedRoute对象组成的数组,这些对象表示用于匹配当前 URL 的所有子路由。 |

清单 26-21 展示了ProductCountComponent组件如何访问用于匹配当前 URL 的更广泛的路由集,以获取类别路由参数的值,并在针对单个类别过滤表格内容时调整其输出。

import {
    Component, KeyValueDiffer, KeyValueDiffers, ChangeDetectorRef
} from "@angular/core";
import { Model } from "../model/repository.model";
import { ActivatedRoute } from "@angular/router";

@Component({
    selector: "paProductCount",
    template: `<div class="bg-info p-2">There are {{count}} products</div>`
})
export class ProductCountComponent {
    private differ: KeyValueDiffer<any, any>;
    count: number = 0;
    private category: string;

    constructor(private model: Model,
            private keyValueDiffers: KeyValueDiffers,
            private changeDetector: ChangeDetectorRef,
            activeRoute: ActivatedRoute) {

        activeRoute.pathFromRoot.forEach(route => route.params.subscribe(params => {
            if (params["category"] != null) {
                this.category = params["category"];
                this.updateCount();
            }
        }))
    }

    ngOnInit() {
        this.differ = this.keyValueDiffers
            .find(this.model.getProducts())
            .create();
    }

    ngDoCheck() {
        if (this.differ.diff(this.model.getProducts()) != null) {
            this.updateCount();
        }
    }

    private updateCount() {
        this.count = this.model.getProducts()
            .filter(p => this.category == null || p.category == this.category)
            .length;
    }
}

Listing 26-21.Ancestor Routes in the productCount.component.ts File in the src/app/core Folder

pathFromRoot属性特别有用,因为它允许组件检查所有用于匹配 URL 的路由。Angular 最大限度地减少了处理导航所需的路由更新,这意味着如果只有父组件发生了变化,则由子路由选择的组件不会通过其ActivatedRoute对象接收到变化通知。正是因为这个原因,我订阅了由pathFromRoot属性返回的所有ActivatedRoute对象的更新,确保组件总是检测到category路由参数值的变化。

要查看结果,保存更改,单击 Watersports 按钮过滤表格内容,然后单击 Count Products 按钮,选择ProductCountComponent。组件报告的产品数量将与表格中的行数相对应,如图 26-9 所示。

img/421542_4_En_26_Fig9_HTML.jpg

图 26-9。

访问用于匹配 URL 的其他路由

摘要

在这一章中,我继续描述 Angular URL 路由系统提供的特性,超越了前一章描述的基本特性。我解释了如何创建通配符和重定向路由,如何创建相对于当前 URL 导航的路由,以及如何创建子路由来显示嵌套组件。在下一章,我将完成对 URL 路由系统的描述,重点放在最高级的特性上。

二十七、路由和导航:第三部分

在这一章中,我继续描述 Angular URL 路由系统,重点放在最高级的特性上。我解释了如何控制路由激活,如何动态加载特性模块,以及如何在一个模板中使用多个 outlet 元素。表 27-1 总结了本章内容。

表 27-1。

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 延迟导航直到任务完成 | 使用路径解析器 | 1–7 |
| 阻止路由激活 | 使用激活防护装置 | 8–14 |
| 防止用户离开当前内容 | 使用去活保护装置 | 15–19 |
| 将功能模块的加载推迟到需要时 | 创建动态加载的模块 | 20–25 |
| 控制何时使用动态加载的模块 | 使用装载防护装置 | 26–28 |
| 使用路由来管理多个路由器出口 | 在同一模板中使用命名插座 | 29–34 |

准备示例项目

对于这一章,我将继续使用在第二十二章中创建的 exampleApp 项目,并在随后的每一章中对其进行修改。为了准备本章,我简化了路由配置,如清单 27-1 所示。

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";
import { ProductCountComponent } from "./core/productCount.component";
import { CategoryCountComponent } from "./core/categoryCount.component";

const childRoutes: Routes = [
    { path: "products", component: ProductCountComponent },
    { path: "categories", component: CategoryCountComponent },
    { path: "", component: ProductCountComponent }
];

const routes: Routes = [
    { path: "form/:mode/:id", component: FormComponent },
    { path: "form/:mode", component: FormComponent },
    { path: "table", component: TableComponent, children: childRoutes },
    { path: "table/:category", component: TableComponent, children: childRoutes },
    { path: "", redirectTo: "/table", pathMatch: "full" },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 27-1.Simplifying the Routes in the app.routing.ts File in the src/app Folder

打开一个新的命令提示符,导航到exampleApp文件夹,运行以下命令启动提供 RESTful web 服务器的服务器:

npm run json

打开一个单独的命令提示符,导航到exampleApp文件夹,运行以下命令启动 Angular 开发工具:

ng serve

打开一个新的浏览器窗口并导航到http://localhost:4200以查看如图 27-1 所示的内容。

img/421542_4_En_27_Fig1_HTML.jpg

图 27-1。

运行示例应用

守卫路由

目前,用户可以在任何时间导航到应用中的任何位置。这并不总是一个好主意,要么是因为应用的某些部分可能并不总是准备好,要么是因为应用的某些部分在执行特定操作之前受到限制。为了控制导航的使用,角撑保护,它们被指定为路由配置的一部分,使用Routes类定义的属性,如表 27-2 所述。

表 27-2。

警卫的路由属性

|

名字

|

描述

|
| --- | --- |
| resolve | 此属性用于指定在某些操作(如从服务器加载数据)完成之前延迟路由激活的保护。 |
| canActivate | 此属性用于指定将用于确定是否可以激活路由的防护。 |
| canActivateChild | 此属性用于指定将用于确定是否可以激活子路由的防护。 |
| canDeactivate | 此属性用于指定将用于确定是否可以停用路由的安全措施。 |
| canLoad | 该属性用于保护动态加载功能模块的路由,如“动态加载功能模块”一节所述。 |

使用解析器延迟导航

保护路由的一个常见原因是确保应用在激活路由之前已经收到了它所需要的数据。示例应用从 RESTful web 服务异步加载数据,这意味着在浏览器被要求发送 HTTP 请求的时刻和收到响应并处理数据的时刻之间可能会有延迟。您可能没有注意到这个延迟,因为浏览器和 web 服务运行在同一台机器上。在已部署的应用中,更有可能出现延迟,这是由网络拥塞、高服务器负载或许多其他因素造成的。

为了模拟网络拥塞,清单 27-2 修改了 RESTful 数据源类,在从 web 服务收到响应后引入了一个延迟。

import { Injectable, Inject, InjectionToken } from "@angular/core";
import { HttpClient, HttpHeaders } from "@angular/common/http";
import { Observable, throwError } from "rxjs";
import { Product } from "./product.model";
import { catchError, delay } from "rxjs/operators";

export const REST_URL = new InjectionToken("rest_url");

@Injectable()
export class RestDataSource {
    constructor(private http: HttpClient,
        @Inject(REST_URL) private url: string) { }

    getData(): Observable<Product[]> {
        return this.sendRequest<Product[]>("GET", this.url);
    }

    saveProduct(product: Product): Observable<Product> {
        return this.sendRequest<Product>("POST", this.url, product);
    }

    updateProduct(product: Product): Observable<Product> {
        return this.sendRequest<Product>("PUT",
            `${this.url}/${product.id}`, product);
    }

    deleteProduct(id: number): Observable<Product> {
        return this.sendRequest<Product>("DELETE", `${this.url}/${id}`);
    }

    private sendRequest<T>(verb: string, url: string, body?: Product)
        : Observable<T> {

        let myHeaders = new HttpHeaders();
        myHeaders = myHeaders.set("Access-Key", "<secret>");
        myHeaders = myHeaders.set("Application-Names", ["exampleApp", "proAngular"]);

        return this.http.request<T>(verb, url, {
            body: body,
            headers: myHeaders
        })
        .pipe(delay(5000))
        .pipe(catchError((error: Response) =>
            throwError(`Network Error: ${error.statusText} (${error.status})`)));
    }
}

Listing 27-2.Adding a Delay in the rest.datasource.ts File in the src/app/model Folder

延迟是使用反应式扩展delay方法添加的,用于创建一个五秒的延迟,这个延迟足够长,可以创建一个明显的暂停,而不会因为每次重新加载应用而等待得太痛苦。要更改延迟,增加或减少delay方法的参数,它以毫秒表示。

延迟的结果是,当应用等待数据加载时,用户看到的是一个不完整且混乱的布局,如图 27-2 所示。

img/421542_4_En_27_Fig2_HTML.jpg

图 27-2。

等待数据

Note

该延迟适用于所有 HTTP 请求,这意味着如果您创建、编辑或删除产品,您所做的更改在五秒钟内不会反映在产品表中。

创建解析服务

一个解析器用于确保一个任务在一个路由被激活之前被执行。为了创建一个解析器,我在src/app/model文件夹中添加了一个名为model.resolver.ts的文件,并定义了清单 27-3 中所示的类。

import { Injectable } from "@angular/core";
import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router";
import { Observable } from "rxjs";
import { Model } from "./repository.model"
import { RestDataSource } from "./rest.datasource";
import { Product } from "./product.model";

@Injectable()
export class ModelResolver {

    constructor(
        private model: Model,
        private dataSource: RestDataSource) { }

    resolve(route: ActivatedRouteSnapshot,
            state: RouterStateSnapshot): Observable<Product[]> {

        return this.model.getProducts().length == 0
            ? this.dataSource.getData() : null;
    }
}

Listing 27-3.The Contents of the model.resolver.ts File in the src/app/model Folder

解析器是定义接受两个参数的resolve方法的类。第一个参数是一个ActivatedRouteSnapshot对象,它使用第二十五章中描述的属性描述了正在被导航到的路由。第二个参数是一个RouterStateSnapshot对象,它描述了通过一个名为url的属性的当前路径。这些参数可用于使解析器适应将要执行的导航,尽管清单中的解析器不需要这两个参数,它使用相同的行为,而不管导航到的路由和来自的路由。

Note

本章描述的所有防护都可以实现在@angular/router模块中定义的接口。例如,解析器可以实现一个名为Resolve的接口。这些接口是可选的,我在本章中没有用到它们。

resolve方法可以返回三种不同类型的结果,如表 27-3 所述。

表 27-3。

resolve 方法允许的结果类型

|

结果类型

|

描述

|
| --- | --- |
| Observable<any> | 当Observer发出一个事件时,浏览器将激活新的路由。 |
| Promise<any> | 当Promise解析时,浏览器将激活新路由。 |
| 还有其他结果吗 | 一旦该方法产生结果,浏览器将激活新路由。 |

ObservablePromise结果在处理异步操作时很有用,比如使用 HTTP 请求请求数据。Angular 会等到异步操作完成后再激活新路由。任何其他结果都被解释为同步操作的结果,Angular 将立即激活新路由。

清单 27-3 中的解析器使用其构造函数通过依赖注入接收ModelRestDataSource对象。当调用resolve方法时,它检查数据模型中对象的数量,以确定对 RESTful web 服务的 HTTP 请求是否已经完成。如果数据模型中没有对象,resolve方法从RestDataSource.getData方法返回Observable,当 HTTP 请求完成时,它将发出一个事件。Angular 将订阅Observable并延迟激活新路由,直到它发出一个事件。如果模型中有对象,则resolve方法返回null,由于这既不是Observable也不是Promise,Angular 将立即激活新路由。

Tip

结合异步和同步结果意味着解析器将延迟导航,直到 HTTP 请求完成并且数据模型被填充。这很重要,因为每次应用试图导航到应用了解析器的路径时,都会调用resolve方法。

注册解析程序服务

下一步是将解析器注册为其特性模块中的服务,如清单 27-4 所示。

import { NgModule } from "@angular/core";
import { Model } from "./repository.model";
import { HttpClientModule, HttpClientJsonpModule } from "@angular/common/http";
import { RestDataSource, REST_URL } from "./rest.datasource";
import { ModelResolver } from "./model.resolver";

@NgModule({
    imports: [HttpClientModule, HttpClientJsonpModule],
    providers: [Model, RestDataSource, ModelResolver,
        { provide: REST_URL, useValue: "http://localhost:3500/products" }]
})
export class ModelModule { }

Listing 27-4.Registering the Resolver in the model.module.ts File in the src/app/model Folder

应用解析器

使用resolve属性将解析器应用于路由,如清单 27-5 所示。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";
import { ProductCountComponent } from "./core/productCount.component";
import { CategoryCountComponent } from "./core/categoryCount.component";
import { ModelResolver } from "./model/model.resolver";

const childRoutes: Routes = [
    {   path: "",
        children: [{ path: "products", component: ProductCountComponent },
                   { path: "categories", component: CategoryCountComponent },
                   { path: "", component: ProductCountComponent }],
        resolve: { model: ModelResolver }
    }
];

const routes: Routes = [
    { path: "form/:mode/:id", component: FormComponent },
    { path: "form/:mode", component: FormComponent },
    { path: "table", component: TableComponent, children: childRoutes },
    { path: "table/:category", component: TableComponent, children: childRoutes },
    { path: "", redirectTo: "/table", pathMatch: "full" },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 27-5.Applying a Resolver in the app.routing.ts File in the src/app Folder

resolve属性接受一个 map 对象,其属性值是将应用于路径的解析器类。(属性名称无关紧要。)我想将解析器应用于显示产品表的所有视图,因此为了避免重复,我创建了一个具有resolve属性的路由,并将其用作现有子路由的父路由。

显示占位符内容

Angular 在激活它所应用到的任何路由之前使用解析器,这使得用户在模型被来自 RESTful web 服务的数据填充之前看不到产品表。可悲的是,这仅仅意味着当浏览器等待服务器响应时,用户看到的是一个空窗口。为了解决这个问题,清单 27-6 增强了解析器,使用消息服务告诉用户当数据被加载时发生了什么。

import { Injectable } from "@angular/core";
import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router";
import { Observable } from "rxjs";
import { Model } from "./repository.model"
import { RestDataSource } from "./rest.datasource";
import { Product } from "./product.model";
import { MessageService } from "../messages/message.service";
import { Message } from "../messages/message.model";

@Injectable()
export class ModelResolver {

    constructor(
        private model: Model,
        private dataSource: RestDataSource,
        private messages: MessageService) { }

    resolve(route: ActivatedRouteSnapshot,
        state: RouterStateSnapshot): Observable<Product[]> {

        if (this.model.getProducts().length == 0) {
            this.messages.reportMessage(new Message("Loading data..."));
            return this.dataSource.getData();
        }
    }
}

Listing 27-6.Displaying a Message in the model.resolver.ts File in the src/app/model Folder

当接收到NavigationEnd事件时,显示来自服务的消息的组件清除其内容,这意味着当数据被加载时占位符将被移除,如图 27-3 所示。

img/421542_4_En_27_Fig3_HTML.jpg

图 27-3。

使用解析器确保数据已加载

使用解析器来防止 URL 输入问题

正如我在第二十五章中解释的,当开发 HTTP 服务器收到一个没有对应文件的 URL 请求时,它将返回index.html文件的内容。与自动浏览器重新加载功能相结合,很容易在项目中进行更改,并让浏览器重新加载一个 URL,从而使应用跳转到一个特定的 URL,而无需经过应用期望的导航步骤并设置所需的状态数据。

要查看问题示例,请单击产品表中的编辑按钮之一,然后重新加载浏览器页面。浏览器将请求一个类似于http://localhost:3500/form/edit/1的 URL,但是这并没有达到预期的效果,因为在收到来自 RESTful 服务器的 HTTP 响应之前,激活路由的组件试图从模型中检索一个对象。因此,表单是空的,如图 27-4 所示。

img/421542_4_En_27_Fig4_HTML.jpg

图 27-4。

重新加载任意 URL 的效果

为了避免这个问题,可以更广泛地应用解析器,以便它保护其他路由,如清单 27-7 所示。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";
import { ProductCountComponent } from "./core/productCount.component";
import { CategoryCountComponent } from "./core/categoryCount.component";
import { ModelResolver } from "./model/model.resolver";

const childRoutes: Routes = [
    {
        path: "",
        children: [{ path: "products", component: ProductCountComponent },
                   { path: "categories", component: CategoryCountComponent },
                   { path: "", component: ProductCountComponent }],
        resolve: { model: ModelResolver }
    }
];

const routes: Routes = [
    {
        path: "form/:mode/:id", component: FormComponent,
        resolve: { model: ModelResolver }
    },
    {
        path: "form/:mode", component: FormComponent,
        resolve: { model: ModelResolver }
    },
    { path: "table", component: TableComponent, children: childRoutes },
    { path: "table/:category", component: TableComponent, children: childRoutes },
    { path: "", redirectTo: "/table", pathMatch: "full" },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 27-7.Applying the Resolver to Other Routes in the app.routing.ts File in the src/app Folder

ModelResolver类应用于以FormComponent为目标的路由可以防止图 27-4 中所示的问题。还有其他方法可以解决这个问题,包括我在第八章中为 SportsStore 应用使用的方法,该方法使用了本章“防止路由激活”一节中描述的路由保护功能。

用警卫阻止航行

解析器用于在应用执行一些先决工作(如加载数据)时延迟导航。Angular 提供的其他保护措施用于控制是否可以进行导航,当您希望提醒用户防止潜在的不必要的操作(如放弃数据编辑)或限制对应用部分的访问时,除非应用处于特定状态(如当用户已通过身份验证时),这可能很有用。

路由保护的许多用途都引入了与用户的额外交互,以获得执行操作的明确批准或获得额外的数据,如身份验证凭证。在本章中,我将通过扩展消息服务来处理这种交互,这样消息就可以要求用户输入。在清单 27-8 中,我在Message模型类中添加了一个可选的responses构造函数参数/属性,这将允许消息包含给用户的提示和当它们被选中时将被调用的回调。responses属性是一个 TypeScript 元组的数组,其中第一个值是响应的名称,它将被呈现给用户,第二个值是回调函数,它将被传递名称作为它的参数。

export class Message {

    constructor(public text: string,
        public error: boolean = false,
        public responses?: [string, (string) => void][]) { }
}

Listing 27-8.Adding Responses in the message.model.ts File in the src/app/messages Folder

实现该特性所需的唯一其他更改是向用户显示响应选项。清单 27-9 在每个response的消息文本下添加了button元素。点击按钮将调用回调函数。

<div *ngIf="lastMessage"
     class="bg-info text-white p-2 text-center"
     [class.bg-danger]="lastMessage.error">
    <h4>{{lastMessage.text}}</h4>
</div>
<div class="text-center my-2">
    <button *ngFor="let resp of lastMessage?.responses; let i = index"
            (click)="resp1"
            class="btn btn-primary m-2" [class.btn-secondary]="i > 0">
        {{resp[0]}}
    </button>
</div>

Listing 27-9.Presenting Responses in the message.component.html File in the src/app/core Folder

阻止路由激活

防护可用于防止路由被激活,有助于防止应用进入不需要的状态或警告用户执行操作的影响。为了演示,我将保护/form/create URL,以防止用户开始创建新产品的过程,除非用户同意应用的条款和条件。

路由激活的保护是定义了名为canActivate的方法的类,该方法接收与解析器相同的ActivatedRouteSnapshotRouterStateSnapshot参数。可以实现canActivate方法来返回三种不同的结果类型,如表 27-4 所述。

表 27-4。

canActivate 方法允许的结果类型

|

结果类型

|

描述

|
| --- | --- |
| boolean | 当执行同步检查以查看路由是否可以激活时,这种类型的结果非常有用。一个true结果将激活路由,而一个false结果将不会激活路由,实际上忽略了导航请求。 |
| Observable<boolean> | 当执行异步检查以查看路由是否可以激活时,这种类型的结果非常有用。Angular 将等待,直到Observable发出一个值,该值将用于确定该路由是否被激活。使用这种结果时,通过调用complete方法终止Observable很重要;否则 Angular 只会一直等下去。 |
| Promise<boolean> | 当执行异步检查以查看路由是否可以激活时,这种类型的结果非常有用。Angular 将等待直到Promise被解决,如果产生true则激活路由。如果Promise让出false,那么该路由将不会被激活,实际上忽略了导航请求。 |

首先,我在src/app文件夹中添加了一个名为terms.guard.ts的文件,并定义了清单 27-10 中所示的类。

import { Injectable } from "@angular/core";
import {
    ActivatedRouteSnapshot, RouterStateSnapshot,
    Router
} from "@angular/router";
import { MessageService } from "./messages/message.service";
import { Message } from "./messages/message.model";

@Injectable()
export class TermsGuard {

    constructor(private messages: MessageService,
                private router: Router) { }

    canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot):
        Promise<boolean> | boolean {

        if (route.params["mode"] == "create") {

            return new Promise<boolean>((resolve) => {
                let responses: [string, () => void][]
                    = [["Yes", () => resolve(true)], ["No",  () => resolve(false)]];
                this.messages.reportMessage(
                    new Message("Do you accept the terms & conditions?",
                        false, responses));
            });
        } else {
            return true;
        }
    }
}

Listing 27-10.The Contents of the terms.guard.ts File in the src/app Folder

canActivate方法可以返回两种不同类型的结果。第一种类型是boolean,它允许警卫对不需要保护的路由立即做出响应,在这种情况下,它是任何缺少一个名为mode的参数,其值为create。如果路由匹配的 URL 不包含这个参数,canActivate方法返回true,告诉 Angular 激活路由。这一点很重要,因为编辑和创建要素都依赖于相同的路径,并且防护不应干扰编辑操作。

另一种类型的结果是一个Promise<boolean>,为了多样化,我用它代替了Observable<true>Promise使用对消息服务的修改来请求用户的响应,确认他们接受(未指定的)条款和条件。用户有两种可能的反应。如果用户单击 Yes 按钮,那么承诺将被解析并产生true,它告诉 Angular 激活路由,显示用于创建新产品的表单。如果用户点击 No 按钮,这个Promise将解析并产生false,这告诉 Angular 忽略导航请求。

清单 27-11 将TermsGuard注册为服务,这样它就可以在应用的路由配置中使用。

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { ModelModule } from "./model/model.module";
import { CoreModule } from "./core/core.module";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { MessageModule } from "./messages/message.module";
import { MessageComponent } from "./messages/message.component";
import { AppComponent } from './app.component';
import { routing } from "./app.routing";
import { TermsGuard } from "./terms.guard"

@NgModule({
    imports: [BrowserModule, ModelModule, CoreModule, MessageModule, routing],
    declarations: [AppComponent],
    providers: [TermsGuard],
    bootstrap: [AppComponent]
})
export class AppModule { }

Listing 27-11.Registering the Guard as a Service in the app.module.ts File in the src/app Folder

最后,清单 27-12 将保护应用于路由配置。使用canActivate属性将激活保护应用于路由,该属性被分配给一组保护服务。所有守卫的canActivate方法必须返回true(或者返回一个Observable或者最终产生truePromise)Angular 才会激活路由。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";
import { ProductCountComponent } from "./core/productCount.component";
import { CategoryCountComponent } from "./core/categoryCount.component";
import { ModelResolver } from "./model/model.resolver";
import { TermsGuard } from "./terms.guard";

const childRoutes: Routes = [
    {
        path: "",
        children: [{ path: "products", component: ProductCountComponent },
                   { path: "categories", component: CategoryCountComponent },
                   { path: "", component: ProductCountComponent }],
        resolve: { model: ModelResolver }
    }
];

const routes: Routes = [
    {
        path: "form/:mode/:id", component: FormComponent,
        resolve: { model: ModelResolver }
    },
    {
        path: "form/:mode", component: FormComponent,
        resolve: { model: ModelResolver },
        canActivate: [TermsGuard]
    },
    { path: "table", component: TableComponent, children: childRoutes },
    { path: "table/:category", component: TableComponent, children: childRoutes },
    { path: "", redirectTo: "/table", pathMatch: "full" },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 27-12.Applying the Guard to a Route in the app.routing.ts File in the src/app Folder

创建并应用激活防护的效果是当用户点击创建新产品按钮时会得到提示,如图 27-5 所示。如果他们通过点击 Yes 按钮来响应,那么导航请求将被完成,Angular 将激活选择表单组件的路由,这将允许创建新产品。如果用户单击“否”按钮,导航请求将被取消。在这两种情况下,路由系统都会发出一个事件,向用户显示消息的组件会接收到该事件,从而清除其显示并确保用户不会看到过时的消息。

img/421542_4_En_27_Fig5_HTML.jpg

图 27-5。

保护路由激活

巩固子路由守卫

如果您有一组子路由,您可以使用子路由保护来防止它们被激活,这个类定义了一个名为canActivateChild的方法。该保护被应用于应用配置中的父路由,并且每当任何子路由将要被激活时,就调用canActivateChild方法。该方法接收与其他守卫相同的ActivatedRouteSnapshotRouterStateSnapshot对象,并可以返回表 27-4 中描述的结果类型集。

在这个例子中,通过在实现canActivateChild方法之前改变配置,可以更容易地处理这个防护,如清单 27-13 所示。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";
import { ProductCountComponent } from "./core/productCount.component";
import { CategoryCountComponent } from "./core/categoryCount.component";
import { ModelResolver } from "./model/model.resolver";
import { TermsGuard } from "./terms.guard";

const childRoutes: Routes = [
    {
        path: "",
        canActivateChild: [TermsGuard],
        children: [{ path: "products", component: ProductCountComponent },
                   { path: "categories", component: CategoryCountComponent },
                   { path: "", component: ProductCountComponent }],
        resolve: { model: ModelResolver }
    }
];

const routes: Routes = [
    {
        path: "form/:mode/:id", component: FormComponent,
        resolve: { model: ModelResolver }
    },
    {
        path: "form/:mode", component: FormComponent,
        resolve: { model: ModelResolver },
        canActivate: [TermsGuard]
    },
    { path: "table", component: TableComponent, children: childRoutes },
    { path: "table/:category", component: TableComponent, children: childRoutes },
    { path: "", redirectTo: "/table", pathMatch: "full" },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 27-13.Guarding Child Routes in the app.routing.ts File in the src/app Folder

使用canActivateChild属性将子路由保护应用于路由,该属性设置为实现canActivateChild方法的服务类型数组。在 Angular 激活路由的任何子路由之前,将调用此方法。清单 27-14 将canActivateChild方法添加到上一节的守卫类中。

import { Injectable } from "@angular/core";
import {
    ActivatedRouteSnapshot, RouterStateSnapshot,
    Router
} from "@angular/router";
import { MessageService } from "./messages/message.service";
import { Message } from "./messages/message.model";

@Injectable()
export class TermsGuard {

    constructor(private messages: MessageService,
        private router: Router) { }

    canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot):
        Promise<boolean> | boolean {

        if (route.params["mode"] == "create") {

            return new Promise<boolean>((resolve, reject) => {
                let responses: [string, (string) => void][] = [
                    ["Yes", () => resolve(true)],
                    ["No", () => resolve(false)]
                ];
                this.messages.reportMessage(
                    new Message("Do you accept the terms & conditions?",
                        false, responses));
            });
        } else {
            return true;
        }
    }

    canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot):
        Promise<boolean> | boolean {

        if (route.url.length > 0
            && route.url[route.url.length - 1].path == "categories") {

            return new Promise<boolean>((resolve, reject) => {
                let responses: [string, (string) => void][] = [
                    ["Yes", () => resolve(true)],
                    ["No ", () => resolve(false)]
                ];

                this.messages.reportMessage(
                    new Message("Do you want to see the categories component?",
                        false, responses));
            });
        } else {
            return true;
        }
    }
}

Listing 27-14.Implementing Child Route Guards in the terms.guard.ts File in the src/app Folder

守卫只保护categories子路由,对于任何其他路由将立即返回true。守卫提示用户使用消息服务,但是如果用户单击 No 按钮,则执行不同的操作。除了拒绝活动路由之外,守卫使用Router服务导航到不同的 URL,该服务作为构造函数参数接收。当用户被重定向到一个组件时,这是一种常见的身份验证模式,该组件将在尝试受限操作时请求安全凭据。在这种情况下,示例更简单,警卫导航到显示不同组件的同级路由。(您可以在第九章的 SportsStore 应用中看到使用路由卫士导航的示例。)

要查看守卫的效果,点击计数类别按钮,如图 27-6 所示。点击 Yes 按钮响应提示,将显示CategoryCountComponent,显示表格中的类别数。点击否将拒绝当前航路并导航到显示ProductCountComponent的航路。

img/421542_4_En_27_Fig6_HTML.jpg

图 27-6。

保护子路由

Note

仅当现用航路改变时,才应用防护。因此,例如,如果您在/table URL 处于活动状态时单击计数类别按钮,那么您将会看到提示,单击是将会更改活动路由。但是,如果您再次单击计数类别按钮,将不会发生任何事情,因为当目标路由和活动路由相同时,Angular 不会触发路由更改。

防止路由停用

当您开始使用路由时,您会倾向于关注路由被激活以响应导航和向用户呈现新内容的方式。但是同样重要的是 route 去激活,这发生在应用导航离开一条路由的时候。

停用保护最常见的用途是防止用户在有未保存的数据编辑时进行导航。在这一节中,我将创建一个防护,当用户在编辑产品时将要放弃未保存的更改时,它会向用户发出警告。为此,清单 27-15 更改了FormComponent类以简化守卫的工作。

import { Component, Inject } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { ActivatedRoute, Router } from "@angular/router";

@Component({
    selector: "paForm",
    templateUrl: "form.component.html",
    styleUrls: ["form.component.css"]
})
export class FormComponent {
    product: Product = new Product();
    originalProduct = new Product();

    constructor(public model: Model, activeRoute: ActivatedRoute,
        public router: Router) {

        activeRoute.params.subscribe(params => {
            this.editing = params["mode"] == "edit";
            let id = params["id"];
            if (id != null) {
                Object.assign(this.product, model.getProduct(id) || new Product());
                Object.assign(this.originalProduct, this.product);
            }
        })
    }

    editing: boolean = false;

    submitForm(form: NgForm) {
        if (form.valid) {
            this.model.saveProduct(this.product);
            this.originalProduct = this.product;
            this.router.navigateByUrl("/");
        }
    }

    //resetForm() {
    //    this.product = new Product();
    //}
}

Listing 27-15.Preparing for the Guard in the form.component.ts File in the src/app/core Folder

当组件开始编辑时,它创建一个从数据模型中获得的Product对象的副本,并将其分配给originalProduct属性。停用保护将使用该属性来查看是否有未保存的编辑。为了防止守卫中断保存操作,在导航请求之前,originalProduct属性被设置为submitForm方法中的编辑产品对象。

模板中需要相应的更改,这样取消按钮就不会调用表单的重置事件处理程序,如清单 27-16 所示。

<div class="bg-primary text-white p-2" [class.bg-warning]="editing">
    <h5>{{editing  ? "Edit" : "Create"}} Product</h5>
</div>

<div *ngIf="editing" class="p-2">
    <button class="btn btn-secondary m-1"
            [routerLink]="['/form', 'edit', model.getPreviousProductid(product.id)]">
        Previous
    </button>
    <button class="btn btn-secondary"
            [routerLink]="['/form', 'edit', model.getNextProductId(product.id)]">
        Next
    </button>
</div>

<form novalidate #form="ngForm" (ngSubmit)="submitForm(form)">

    <div class="form-group">
        <label>Name</label>
        <input class="form-control" name="name"
               [(ngModel)]="product.name" required />
    </div>

    <div class="form-group">
        <label>Category</label>
        <input class="form-control" name="category"
               [(ngModel)]="product.category" required />
    </div>

    <div class="form-group">
        <label>Price</label>
        <input class="form-control" name="price"
               [(ngModel)]="product.price"
               required pattern="^[0-9\.]+$" />
    </div>

    <button type="submit" class="btn btn-primary m-1"
            [class.btn-warning]="editing" [disabled]="form.invalid">
        {{editing ? "Save" : "Create"}}
    </button>
    <button type="button" class="btn btn-secondary m-1" routerLink="/">
            Cancel
    </button>
</form>

Listing 27-16.Disabling Form Reset in the form.component.html File in the src/app/core Folder

为了创建防护,我在src/app/core文件夹中添加了一个名为unsaved.guard.ts的文件,并定义了清单 27-17 中所示的类。

import { Injectable } from "@angular/core";
import {
    ActivatedRouteSnapshot, RouterStateSnapshot,
    Router
} from "@angular/router";
import { Observable, Subject } from "rxjs";
import { MessageService } from "../messages/message.service";
import { Message } from "../messages/message.model";
import { FormComponent } from "./form.component";

@Injectable()
export class UnsavedGuard {

    constructor(private messages: MessageService,
                private router: Router) { }

    canDeactivate(component: FormComponent, route: ActivatedRouteSnapshot,
        state: RouterStateSnapshot): Observable<boolean> | boolean {

        if (component.editing) {
            if (["name", "category", "price"]
                .some(prop => component.product[prop]
                    != component.originalProduct[prop])) {
                let subject = new Subject<boolean>();

                let responses: [string, (string) => void][] = [
                    ["Yes", () => {
                        subject.next(true);
                        subject.complete();
                    }],
                    ["No", () => {
                        this.router.navigateByUrl(this.router.url);
                        subject.next(false);
                        subject.complete();
                    }]
                ];
                this.messages.reportMessage(new Message("Discard Changes?",
                    true, responses));
                return subject;
            }
        }
        return true;
    }
}

Listing 27-17.The Contents of the unsaved.guard.ts File in the src/app/core Folder

停用保护定义了一个名为canDeactivate的类,它接收三个参数:即将被停用的组件以及ActivatedRouteSnapshotRouteStateSnapshot对象。该保护检查组件中是否有未保存的编辑,如果有,则提示用户。为了多样化,这种保护使用一个Observable<true>,实现为一个Subject<true>而不是一个Promise<true>,根据用户选择的响应来告诉 Angular 它是否应该激活路由。

Tip

注意,在调用了next方法之后,我在Subject上调用了complete方法。Angular 将无限期地等待调用complete方法,实际上冻结了应用。

下一步是在包含它的模块中注册一个服务,如清单 27-18 所示。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { FormsModule } from "@angular/forms";
import { ModelModule } from "../model/model.module";
import { TableComponent } from "./table.component";
import { FormComponent } from "./form.component";
import { Subject } from "rxjs";
import { StatePipe } from "./state.pipe";
import { MessageModule } from "../messages/message.module";
import { MessageService } from "../messages/message.service";
import { Message } from "../messages/message.model";
import { Model } from "../model/repository.model";
import { RouterModule } from "@angular/router";
import { ProductCountComponent } from "./productCount.component";
import { CategoryCountComponent } from "./categoryCount.component";
import { NotFoundComponent } from "./notFound.component";
import { UnsavedGuard } from "./unsaved.guard";

@NgModule({
    imports: [BrowserModule, FormsModule, ModelModule, MessageModule, RouterModule],
    declarations: [TableComponent, FormComponent, StatePipe,
        ProductCountComponent, CategoryCountComponent, NotFoundComponent],
    providers: [UnsavedGuard],
    exports: [ModelModule, TableComponent, FormComponent]
})
export class CoreModule { }

Listing 27-18.Registering the Guard as a Service in the core.module.ts File in the src/app/core Folder

最后,清单 27-19 将防护应用于应用的路由配置。使用canDeactivate属性将停用保护应用于路由,该属性设置为一组保护服务。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";
import { ProductCountComponent } from "./core/productCount.component";
import { CategoryCountComponent } from "./core/categoryCount.component";
import { ModelResolver } from "./model/model.resolver";
import { TermsGuard } from "./terms.guard";
import { UnsavedGuard } from "./core/unsaved.guard";

const childRoutes: Routes = [
    {
        path: "",
        canActivateChild: [TermsGuard],
        children: [{ path: "products", component: ProductCountComponent },
                   { path: "categories", component: CategoryCountComponent },
                   { path: "", component: ProductCountComponent }],
        resolve: { model: ModelResolver }
    }
];

const routes: Routes = [
    {
        path: "form/:mode/:id", component: FormComponent,
        resolve: { model: ModelResolver },
        canDeactivate: [UnsavedGuard]
    },
    {
        path: "form/:mode", component: FormComponent,
        resolve: { model: ModelResolver },
        canActivate: [TermsGuard]
    },
    { path: "table", component: TableComponent, children: childRoutes },
    { path: "table/:category", component: TableComponent, children: childRoutes },
    { path: "", redirectTo: "/table", pathMatch: "full" },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 27-19.Applying the Guard in the app.routing.ts File in the src/app Folder

要查看保护效果,请单击表格中的一个编辑按钮。编辑其中一个文本字段中的数据;然后单击取消、下一个或上一个按钮。在允许 Angular 激活您选择的路由之前,警卫会提示您,如图 27-7 所示。

img/421542_4_En_27_Fig7_HTML.jpg

图 27-7。

保护路由去激活

动态加载功能模块

Angular 支持仅在需要时加载功能模块,称为动态加载惰性加载。这对于并非所有用户都需要的功能非常有用。在接下来的小节中,我将创建一个简单的功能模块,并演示如何配置应用,以便 Angular 仅在应用导航到特定 URL 时加载该模块。

Note

动态加载模块是一种权衡。对于大多数用户来说,该应用将更小,下载速度更快,从而改善他们的整体体验。但是需要动态加载特性的用户将不得不等待 Angular 获取模块及其依赖项。这种效果可能是不和谐的,因为用户不知道一些特性已经被加载,而另一些特性没有。当您创建动态加载的模块时,您正在平衡改善某些用户的体验和使其他用户的体验变差。考虑你的用户是如何归入这些群体的,注意不要降低你最有价值和最重要的客户的体验。

创建简单的特征模块

动态加载的模块必须只包含并非所有用户都需要的功能。我不能使用现有的模块,因为它们为应用提供了核心功能,这意味着我需要一个新的模块来完成本章的这一部分。我首先在src/app文件夹中创建一个名为ondemand的文件夹。为了给新模块一个组件,我在example/app/ondemand文件夹中添加了一个名为ondemand.component.ts的文件,并添加了清单 27-20 中所示的代码。

Caution

重要的是不要在应用的其他部分和动态加载的模块中的类之间创建依赖关系,这样 JavaScript 模块加载器就不会在需要模块之前加载它。

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

@Component({
    selector: "ondemand",
    templateUrl: "ondemand.component.html"
})
export class OndemandComponent { }

Listing 27-20.The Contents of the ondemand.component.ts File in the src/app/ondemand Folder

为了给组件提供模板,我添加了一个名为ondemand.component.html的文件,并添加了清单 27-21 中所示的标记。

<div class="bg-primary text-white p-2">This is the ondemand component</div>
<button class="btn btn-primary m-2" routerLink="/" >Back</button>

Listing 27-21.The ondemand.component.html File in the src/app/ondemand Folder

该模板包含一条消息,当组件被选中时,这条消息会很明显,并且包含一个button元素,当被单击时,这个元素会导航回应用的根 URL。

为了定义这个模块,我添加了一个名为ondemand.module.ts的文件,并添加了清单 27-22 中所示的代码。

import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { OndemandComponent } from "./ondemand.component";

@NgModule({
    imports: [CommonModule],
    declarations: [OndemandComponent],
    exports: [OndemandComponent]
})
export class OndemandModule { }

Listing 27-22.The Contents of the ondemand.module.ts File in the src/app/ondemand Folder

该模块导入了CommonModule功能,该功能用于代替特定于浏览器的BrowserModule来访问按需加载的功能模块中的内置指令。

动态加载模块

设置动态加载模块有两个步骤。第一个是在特征模块内设置一个路由配置,提供允许 Angular 在模块加载时选择一个组件的规则。清单 27-23 向特征模块添加一条路由。

import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { OndemandComponent } from "./ondemand.component";
import { RouterModule } from "@angular/router";

let routing = RouterModule.forChild([
    { path: "", component: OndemandComponent }
]);

@NgModule({
    imports: [CommonModule, routing],
    declarations: [OndemandComponent],
    exports: [OndemandComponent]
})
export class OndemandModule { }

Listing 27-23.Defining Routes in the ondemand.module.ts File in the src/app/ondemand Folder

动态加载模块中的路由使用与应用主要部分相同的属性来定义,并且可以使用所有相同的功能,包括子组件、保护和重定向。列表中定义的路由与空路径匹配,并选择OndemandComponent进行显示。

一个重要的区别是用于生成包含路由信息的模块的方法,如下所示:

...
let routing = RouterModule.forChild([
    { path: "", component: OndemandComponent }
]);
...

当我创建应用范围的路由配置时,我使用了RouterModule.forRoot方法。这是用于在应用的根模块中设置路由的方法。创建动态加载的模块时,必须使用RouterModule.forChild方法;该方法创建一个路由配置,当模块被加载时,该配置被合并到整个路由系统中。

创建动态加载模块的路由

设置动态加载模块的第二步是在应用的主要部分创建一条路由,为 Angular 提供模块的位置,如清单 27-24 所示。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";
import { ProductCountComponent } from "./core/productCount.component";
import { CategoryCountComponent } from "./core/categoryCount.component";
import { ModelResolver } from "./model/model.resolver";
import { TermsGuard } from "./terms.guard";
import { UnsavedGuard } from "./core/unsaved.guard";

const childRoutes: Routes = [
    {
        path: "",
        canActivateChild: [TermsGuard],
        children: [{ path: "products", component: ProductCountComponent },
                   { path: "categories", component: CategoryCountComponent },
                   { path: "", component: ProductCountComponent }],
        resolve: { model: ModelResolver }
    }
];

const routes: Routes = [
    {
        path: "ondemand",
        loadChildren: () => import("./ondemand/ondemand.module")
                                .then(m => m.OndemandModule)
    },
    {
        path: "form/:mode/:id", component: FormComponent,
        resolve: { model: ModelResolver },
        canDeactivate: [UnsavedGuard]
    },
    {
        path: "form/:mode", component: FormComponent,
        resolve: { model: ModelResolver },
        canActivate: [TermsGuard]
    },
    { path: "table", component: TableComponent, children: childRoutes },
    { path: "table/:category", component: TableComponent, children: childRoutes },
    { path: "", redirectTo: "/table", pathMatch: "full" },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 27-24.Creating an On-Demand Route in the app.routing.ts File in the src/app Folder

属性用于向 Angular 提供模块应该如何加载的细节。该属性被赋予一个调用import的函数,将路径传递给模块。结果是一个Promise,它的then方法用于在模块被导入后选择它。清单中的函数告诉 Angular 从ondemand/ondemand.module文件中加载OndemandModule类。

使用动态加载的模块

剩下的就是添加对 URL 导航的支持,这将激活随需应变模块的路由,如清单 27-25 所示,它为表格组件的模板添加了一个按钮。

<div class="container-fluid">
    <div class="row">
        <div class="col-auto">
            <button class="btn btn-secondary btn-block"
                routerLink="/table" routerLinkActive="bg-primary"
                [routerLinkActiveOptions]="{exact: true}">
                All
            </button>
            <button *ngFor="let category of categories"
                    class="btn btn-secondary btn-block px-3"
                    [routerLink]="['/table', category]"
                    routerLinkActive="bg-primary">
                {{category}}
            </button>
        </div>
        <div class="col">

                < !-- ...elements omitted for brevity... -->

        </div>
    </div>
</div>
<div class="p-2 text-center">
    <button class="btn btn-primary m-1" routerLink="/form/create">
        Create New Product
    </button>
    <button class="btn btn-danger" (click)="deleteProduct(-1)">
        Generate HTTP Error
    </button>
    <button class="btn btn-danger m-1" routerLink="/does/not/exist">
        Generate Routing Error
    </button>
    <button class="btn btn-danger" routerLink="/ondemand">
        Load Module
    </button>
</div>

Listing 27-25.Adding Navigation in the table.component.html File in the src/app/core Folder

不需要特别的措施来定位加载模块的路由,清单中的加载模块按钮使用标准的routerLink属性来导航到清单 27-24 中添加的路由所指定的 URL。

要查看动态模块加载是如何工作的,使用以下命令在exampleApp文件夹中重新启动 Angular 开发工具,这将重建模块,包括按需模块:

ng serve

现在使用浏览器的开发工具来查看应用启动时加载的文件列表。在您单击“加载模块”按钮之前,您不会看到对按需模块中任何文件的 HTTP 请求。点击按钮时,Angular 使用路由配置加载模块,检查其路由配置,并选择将向用户显示的组件,如图 27-8 所示。

img/421542_4_En_27_Fig8_HTML.jpg

图 27-8。

动态加载模块

保护动态模块

您可以防止动态加载模块,以确保只有当应用处于特定状态时,或者当用户明确同意等待 Angular 进行加载时,才加载模块(后一种选项通常只用于管理功能,在这种情况下,用户应该对应用的结构有所了解)。

模块的防护必须在应用的主体部分定义,所以我在src/app文件夹中添加了一个名为load.guard.ts的文件,并定义了清单 27-26 中所示的类。

import { Injectable } from "@angular/core";
import { Route, Router } from "@angular/router";
import { MessageService } from "./messages/message.service";
import { Message } from "./messages/message.model";

@Injectable()
export class LoadGuard {
    private loaded: boolean = false;

    constructor(private messages: MessageService,
                private router: Router) { }

    canLoad(route: Route): Promise<boolean> | boolean {

        return this.loaded || new Promise<boolean>((resolve, reject) => {
            let responses: [string, (string) => void] [] = [
                ["Yes", () => {
                    this.loaded = true;
                    resolve(true);
                }],
                ["No", () => {
                    this.router.navigateByUrl(this.router.url);
                    resolve(false);
                }]
            ];

            this.messages.reportMessage(
                new Message("Do you want to load the module?",
                    false, responses));
        });
    }
}

Listing 27-26.The Contents of the load.guard.ts File in the src/app Folder

动态加载守卫是实现名为canLoad的方法的类,当 Angular 需要激活它所应用的路由时调用该方法,并提供一个描述路由的Route对象。

只有当加载模块的 URL 第一次被激活时,才需要这个保护,所以它定义了一个loaded属性,当模块被加载时,该属性被设置为true,以便后续的请求被立即批准。否则,该保护遵循与前面示例相同的模式,并返回一个Promise,当用户单击消息服务显示的一个按钮时,该问题将被解决。清单 27-27 将防护注册为根模块中的服务。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ModelModule } from "./model/model.module";
import { CoreModule } from "./core/core.module";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { MessageModule } from "./messages/message.module";
import { MessageComponent } from "./messages/message.component";
import { routing } from "./app.routing";
import { AppComponent } from "./app.component";
import { TermsGuard } from "./terms.guard"
import { LoadGuard } from "./load.guard";

@NgModule({
    imports: [BrowserModule, CoreModule, MessageModule, routing],
    declarations: [AppComponent],
    providers: [TermsGuard, LoadGuard],
    bootstrap: [AppComponent]
})
export class AppModule { }

Listing 27-27.Registering the Guard as a Service in the app.module.ts File in the src/app Folder

应用动态加载保护

使用canLoad属性将动态加载的保护应用于路由,该属性接受一组保护类型。清单 27-28 将清单 27-26 中定义的LoadGuard类应用于动态加载模块的路径。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";
import { ProductCountComponent } from "./core/productCount.component";
import { CategoryCountComponent } from "./core/categoryCount.component";
import { ModelResolver } from "./model/model.resolver";
import { TermsGuard } from "./terms.guard";
import { UnsavedGuard } from "./core/unsaved.guard";
import { LoadGuard } from "./load.guard";

const childRoutes: Routes = [
    {
        path: "",
        canActivateChild: [TermsGuard],
        children: [{ path: "products", component: ProductCountComponent },
                   { path: "categories", component: CategoryCountComponent },
                   { path: "", component: ProductCountComponent }],
        resolve: { model: ModelResolver }
    }
];

const routes: Routes = [
    {
        path: "ondemand",
        loadChildren: () => import("./ondemand/ondemand.module")
                                .then(m => m.OndemandModule),
        canLoad: [LoadGuard]
    },
    {
        path: "form/:mode/:id", component: FormComponent,
        resolve: { model: ModelResolver },
        canDeactivate: [UnsavedGuard]
    },
    {
        path: "form/:mode", component: FormComponent,
        resolve: { model: ModelResolver },
        canActivate: [TermsGuard]
    },
    { path: "table", component: TableComponent, children: childRoutes },
    { path: "table/:category", component: TableComponent, children: childRoutes },
    { path: "", redirectTo: "/table", pathMatch: "full" },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 27-28.Guarding the Route in the app.routing.ts File in the src/app Folder

结果是,在 Angular 第一次试图激活路径时,用户被提示确定是否要加载模块,如图 27-9 所示。

img/421542_4_En_27_Fig9_HTML.jpg

图 27-9。

保护动态负载

锁定指定的销售点

一个模板可以包含多个router-outlet元素,这允许一个 URL 选择多个组件显示给用户。

为了演示这个特性,我需要向ondemand模块添加两个新组件。我首先在src/app/ondemand文件夹中创建一个名为first.component.ts的文件,并用它来定义清单 27-29 中所示的组件。

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

@Component({
    selector: "first",
    template: `<div class="bg-primary text-white p-2">First Component</div>`
})
export class FirstComponent { }

Listing 27-29.The Contents of the first.component.ts File in the src/app/ondemand Folder

该组件使用一个内联模板来显示一条消息,其目的只是为了清楚地表明路由系统选择了哪个组件。接下来,我在src/app/ondemand文件夹中创建了一个名为second.component.ts的文件,并创建了清单 27-30 中所示的组件。

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

@Component({
    selector: "second",
    template: `<div class="bg-info text-white p-2">Second Component</div>`
})
export class SecondComponent { }

Listing 27-30.The Contents of the second.component.ts File in the src/app/ondemand Folder

该组件与清单 27-29 中的组件几乎相同,不同之处仅在于它通过其内联模板显示的消息。

创建额外的出口元素

当您在同一个模板中使用多个 outlet 元素时,Angular 需要一些方法来区分它们。这是使用name属性来完成的,它允许一个插座被唯一地标识,如清单 27-31 所示。

<div class="bg-primary text-white p-2">This is the ondemand component</div>
<div class="container-fluid">
    <div class="row">
        <div class="col-12 p-2">
            <router-outlet></router-outlet>
        </div>
    </div>
    <div class="row">
        <div class="col-6 p-2">
            <router-outlet name="left"></router-outlet>
        </div>
        <div class="col-6 p-2">
            <router-outlet name="right"></router-outlet>
        </div>
    </div>
</div>
<button class="btn btn-primary m-2" routerLink="/">Back</button>

Listing 27-31.Adding Outlets in the ondemand.component.html File in the src/app/ondemand Folder

新元素创造了三个新的出口。最多可以有一个router-outlet元素没有name元素,称为主出口。这是因为省略name属性与应用值为primary的属性具有相同的效果。到目前为止,本书中的所有路由示例都依赖于主出口向用户显示组件。

所有其他的router-outlet元素必须有一个具有唯一名称的name元素。我在清单中使用的名字是leftright,因为应用于包含商店的div元素的类使用 CSS 将这两个商店并排放置。

下一步是创建一个路由,它包括应该在每个 outlet 元素中显示哪个组件的细节,如清单 27-32 所示。如果 Angular 找不到匹配特定出口的路由,则该元素中不会显示任何内容。

import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { OndemandComponent } from "./ondemand.component";
import { RouterModule } from "@angular/router";
import { FirstComponent } from "./first.component";
import { SecondComponent } from "./second.component";

let routing = RouterModule.forChild([
    {
        path: "",
        component: OndemandComponent,
        children: [
            { path: "",
              children: [
                   { outlet: "primary", path: "", component: FirstComponent, },
                   { outlet: "left", path: "", component: SecondComponent, },
                   { outlet: "right", path: "", component: SecondComponent, },
              ]},
        ]
    },
]);

@NgModule({
    imports: [CommonModule, routing],
    declarations: [OndemandComponent, FirstComponent, SecondComponent],
    exports: [OndemandComponent]
})
export class OndemandModule { }

Listing 27-32.Targeting Outlets in the ondemand.module.ts File in the src/app/ondemand Folder

outlet属性用于指定路由适用的出口元素。列表中的布线配置匹配所有三个插座的空路径,并为它们选择新创建的组件:主插座将显示FirstComponentleftright插座将显示SecondComponent,如图 27-10 所示。要亲自查看效果,请单击“加载模块”按钮,并在出现提示时单击“是”按钮。

img/421542_4_En_27_Fig10_HTML.jpg

图 27-10。

使用多个路由器插座

Tip

如果您省略了outlet属性,那么 Angular 会假设路由以主出口为目标。我倾向于在所有路由中包含outlet属性,以强调哪些路由匹配 outlet 元素。

当 Angular 激活路由时,它会查找每个插座的匹配项。所有三个新的出口都有与空路径匹配的路由,这使得 Angular 能够呈现图中所示的组件。

使用多个插座时导航

更改每个出口显示的组件意味着创建一组新的路由,然后导航到包含这些路由的 URL。清单 27-33 设置了一条与路径/ondemand/swap匹配的路由,该路由将切换三个插座显示的组件。

import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { OndemandComponent } from "./ondemand.component";
import { RouterModule } from "@angular/router";
import { FirstComponent } from "./first.component";
import { SecondComponent } from "./second.component";

let routing = RouterModule.forChild([
    {
        path: "",
        component: OndemandComponent,
        children: [
            {
                path: "",
                children: [
                    { outlet: "primary", path: "", component: FirstComponent, },
                    { outlet: "left", path: "", component: SecondComponent, },
                    { outlet: "right", path: "", component: SecondComponent, },
                ]
            },
            {
                path: "swap",
                children: [
                    { outlet: "primary", path: "", component: SecondComponent, },
                    { outlet: "left", path: "", component: FirstComponent, },
                    { outlet: "right", path: "", component: FirstComponent, },
                ]
            },
        ]
    },
]);

@NgModule({
    imports: [CommonModule, routing],
    declarations: [OndemandComponent, FirstComponent, SecondComponent],
    exports: [OndemandComponent]
})
export class OndemandModule { }

Listing 27-33.Setting Routes for Outlets in the ondemand.module.ts File in the src/app/ondemand Folder

清单 27-34 将button元素添加到组件的模板中,该模板将导航到清单 27-33 中的两组路由,交替显示给用户的组件集。

<div class="bg-primary text-white p-2">This is the ondemand component</div>
<div class="container-fluid">
    <div class="row">
        <div class="col-12 p-2">
            <router-outlet></router-outlet>
        </div>
    </div>
    <div class="row">
        <div class="col-6 p-2">
            <router-outlet name="left"></router-outlet>
        </div>
        <div class="col-6 p-2">
            <router-outlet name="right"></router-outlet>
        </div>
    </div>
</div>
<button class="btn btn-secondary m-2" routerLink="/ondemand">Normal</button>
<button class="btn btn-secondary m-2" routerLink="/ondemand/swap">Swap</button>
<button class="btn btn-primary m-2" routerLink="/">Back</button>

Listing 27-34.Navigating to Outlets in the ondemand.component.html File in the src/app/ondemand Folder

结果是,点击交换和正常按钮将导航到其子节点告诉 Angular 每个插座元件应显示哪些组件的路由,如图 27-11 所示。

img/421542_4_En_27_Fig11_HTML.jpg

图 27-11。

使用导航定位多个出口元素

摘要

在这一章中,我描述了 Angular URL 路由特性,并解释了如何保护路由以控制路由何时被激活,如何仅在需要时加载模块,以及如何使用多个 outlet 元素向用户显示组件。在下一章,我将向你展示如何将动画应用到 Angular 应用中。

二十八、使用动画

在这一章中,我描述了 Angular 动画系统,它使用数据绑定来动画化 HTML 元素,以反映应用状态的变化。从广义上讲,动画在 Angular 应用中有两个作用:强调内容的变化和平滑它们。

当内容以对用户不明显的方式变化时,强调变化是很重要的。在示例应用中,在编辑产品时使用“上一个”和“下一个”按钮会更改数据字段,但不会创建任何其他可视更改,这会导致用户可能不会注意到的转换。动画可以用来吸引人们对这种变化的注意,帮助用户注意到动作的结果。

平滑变化可以使应用更易于使用。当用户单击 Edit 按钮开始编辑产品时,示例应用显示的内容会以一种令人不快的方式切换。使用动画来减缓过渡可以帮助提供内容变化的上下文感,并使其不那么突兀。在这一章中,我将解释动画系统是如何工作的,以及如何用它来吸引用户的注意力或减少突然的过渡。表 28-1 将 Angular 动画放在上下文中。

表 28-1。

将 Angular 动画放在上下文中

|

问题

|

回答

|
| --- | --- |
| 它们是什么? | 动画系统可以改变 HTML 元素的外观来反映应用状态的变化。 |
| 它们为什么有用? | 如果使用得当,动画可以让应用更容易使用。 |
| 它们是如何使用的? | 动画使用特定于平台的模块中定义的函数来定义,使用@Component装饰器中的animations属性来注册,并使用数据绑定来应用。 |
| 有什么陷阱或限制吗? | 主要的限制是只有少数浏览器完全支持 Angular 动画,因此,不能依赖它在 Angular 支持其其他功能的所有浏览器上正常工作。 |
| 还有其他选择吗? | 唯一的选择是不要让应用动起来。 |

表 28-2 总结了本章内容。

表 28-2。

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 将用户的注意力吸引到元素状态的转换上 | 应用动画 | 1–9 |
| 动画显示从一个元素状态到另一个元素状态的变化 | 使用元素过渡 | 9–14 |
| 并行执行动画 | 使用动画组 | Fifteen |
| 在多个动画中使用相同的样式 | 使用通用样式 | Sixteen |
| 动画元素的位置或大小 | 使用元素转换 | Seventeen |
| 使用动画应用 CSS 框架样式 | 使用 DOM 和 CSS APIs | 18, 19 |

准备示例项目

在这一章中,我继续使用 exampleApp 项目,它最初是在第二十二章中创建的,从那以后一直是每一章的焦点。以下各节中的更改为本章中描述的功能准备了示例应用。

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

禁用 HTTP 延迟

本章的第一个准备步骤是禁用添加到异步 HTTP 请求的延迟,如清单 28-1 所示。

import { Injectable, Inject, InjectionToken } from "@angular/core";
import { HttpClient, HttpHeaders } from "@angular/common/http";
import { Observable, throwError } from "rxjs";
import { Product } from "./product.model";
import { catchError, delay } from "rxjs/operators";

export const REST_URL = new InjectionToken("rest_url");

@Injectable()
export class RestDataSource {
    constructor(private http: HttpClient,
        @Inject(REST_URL) private url: string) { }

    getData(): Observable<Product[]> {
        return this.sendRequest<Product[]>("GET", this.url);
    }

    saveProduct(product: Product): Observable<Product> {
        return this.sendRequest<Product>("POST", this.url, product);
    }

    updateProduct(product: Product): Observable<Product> {
        return this.sendRequest<Product>("PUT",
            `${this.url}/${product.id}`, product);
    }

    deleteProduct(id: number): Observable<Product> {
        return this.sendRequest<Product>("DELETE", `${this.url}/${id}`);
    }

    private sendRequest<T>(verb: string, url: string, body?: Product)
        : Observable<T> {

        let myHeaders = new HttpHeaders();
        myHeaders = myHeaders.set("Access-Key", "<secret>");
        myHeaders = myHeaders.set("Application-Names", ["exampleApp", "proAngular"]);

        return this.http.request<T>(verb, url, {
            body: body,
            headers: myHeaders
        })
        //.pipe(delay(5000))
        .pipe(catchError((error: Response) =>
            throwError(`Network Error: ${error.statusText} (${error.status})`)));
    }
}

Listing 28-1.Disabling the Delay in the rest.datasource.ts File in the src/app/model Folder

简化表格模板和路由配置

本章中的许多示例适用于产品表中的元素。本章的最后准备工作是简化表格组件的模板,这样我就可以专注于清单中的少量内容。

清单 28-2 显示了简化的模板,它删除了产生 HTTP 和路由错误的按钮,以及计算类别或产品的按钮和出口元素。该清单还删除了允许按类别过滤表格的按钮。

<table class="table table-sm table-bordered table-striped">
    <tr>
        <th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
    </tr>
    <tr *ngFor="let item of getProducts()">
        <td>{{item.id}}</td>
        <td>{{item.name}}</td>
        <td>{{item.category}}</td>
        <td>{{item.price | currency:"USD" }}</td>
        <td class="text-center">
            <button class="btn btn-danger btn-sm mr-1"
                    (click)="deleteProduct(item.id)">
                Delete
            </button>
            <button class="btn btn-warning btn-sm"
                [routerLink]="['/form', 'edit', item.id]">
                Edit
            </button>
        </td>
    </tr>
</table>
<div class="p-2 text-center">
    <button class="btn btn-primary m-1" routerLink="/form/create">
        Create New Product
    </button>
</div>

Listing 28-2.Simplifying the Template in the table.component.html File in the src/app/core Folder

清单 28-3 更新了应用的 URL 路由配置,这样路由就不会以已经从表格组件的模板中删除的 outlet 元素为目标。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";
import { ProductCountComponent } from "./core/productCount.component";
import { CategoryCountComponent } from "./core/categoryCount.component";
import { ModelResolver } from "./model/model.resolver";
import { TermsGuard } from "./terms.guard";
import { UnsavedGuard } from "./core/unsaved.guard";
import { LoadGuard } from "./load.guard";

const routes: Routes = [
    {
        path: "form/:mode/:id", component: FormComponent,
        canDeactivate: [UnsavedGuard]
    },
    { path: "form/:mode", component: FormComponent, canActivate: [TermsGuard] },
    { path: "table", component: TableComponent },
    { path: "table/:category", component: TableComponent },
    { path: "", redirectTo: "/table", pathMatch: "full" },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 28-3.Updating the Routing Configuration in the app.routing.ts File in the src/app Folder

打开一个新的命令提示符,导航到exampleApp文件夹,运行以下命令启动提供 RESTful web 服务器的服务器:

npm run json

打开一个单独的命令提示符,导航到exampleApp文件夹,运行以下命令启动 Angular 开发工具:

ng serve

打开一个新的浏览器窗口并导航到http://localhost:4200以查看如图 28-1 所示的内容。

img/421542_4_En_28_Fig1_HTML.jpg

图 28-1。

运行示例应用

Angular 动画入门

与大多数 Angular 特性一样,最好从一个例子开始,这个例子将让我介绍动画是如何工作的,以及它如何适应 Angular 功能的其余部分。在接下来的部分中,我创建了一个基本的动画,它将影响产品表中的行。一旦您看到了基本特性是如何工作的,我将深入研究每个不同配置选项的细节,并深入解释它们是如何工作的。

但是首先,我将向应用添加一个select元素,允许用户选择一个类别。当选择一个类别时,该类别中产品的表格行将显示为两种样式中的一种,如表 28-3 所述。

表 28-3。

动画示例的样式

|

描述

|

风格

|
| --- | --- |
| 该产品属于所选类别。 | 表格行将有绿色背景和较大的文本。 |
| 该产品不在所选类别中。 | 表格行将具有红色背景和较小的文本。 |

启用动画模块

动画特性包含在它们自己的模块中,这些模块必须导入到应用的根模块中,如清单 28-4 所示。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ModelModule } from "./model/model.module";
import { CoreModule } from "./core/core.module";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { MessageModule } from "./messages/message.module";
import { MessageComponent } from "./messages/message.component";
import { routing } from "./app.routing";
import { AppComponent } from "./app.component";
import { TermsGuard } from "./terms.guard"
import { LoadGuard } from "./load.guard";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";

@NgModule({
    imports: [BrowserModule, CoreModule, MessageModule, routing,
              BrowserAnimationsModule],
    declarations: [AppComponent],
    providers: [TermsGuard, LoadGuard],
    bootstrap: [AppComponent]
})
export class AppModule { }

Listing 28-4.Importing the Animation Module in the app.module.ts File in the src/app Folder

创建动画

为了开始制作动画,我在src/app/core文件夹中创建了一个名为table.animations.ts的文件,并添加了清单 28-5 中所示的代码。

import { trigger, style, state, transition, animate } from "@angular/animations";

export const HighlightTrigger = trigger("rowHighlight", [
    state("selected", style({
        backgroundColor: "lightgreen",
        fontSize: "20px"
    })),
    state("notselected", style({
        backgroundColor: "lightsalmon",
        fontSize: "12px"
    })),
    transition("selected => notselected", animate("200ms")),
    transition("notselected => selected", animate("400ms"))
]);

Listing 28-5.The Contents of the table.animations.ts File in the src/app/core Folder

用于定义动画的语法可以是密集的,并且依赖于在@angular/animations模块中定义的一组函数。在接下来的几节中,我从顶部开始,一步步深入到细节,解释清单中使用的每个动画构建块。

Tip

如果以下部分中描述的所有构建模块不能立即理解,也不要担心。这是一个功能区域,只有当您看到所有部分是如何组合在一起时,它才开始变得更有意义。

定义样式组

动画系统的核心是样式组,它是一组将应用于 HTML 元素的 CSS 样式属性和值。使用style函数定义样式组,该函数接受 JavaScript 对象文字,提供属性名和值之间的映射,如下所示:

...
style({
    backgroundColor: "lightgreen",
    fontSize: "20px"
})
...

这个样式组告诉 Angular 将背景颜色设置为lightgreen并将字体大小设置为 20 像素。

CSS Property Name Conventions

使用style函数时,有两种方法可以指定 CSS 属性。您可以使用 JavaScript 属性命名约定,这样设置元素背景颜色的属性就被指定为backgroundColor(所有单词,无连字符,后续单词大写)。这是我在清单 28-5 中使用的约定:

...
style({
    backgroundColor: "lightgreen",
    fontSize: "20px"
})),
...

或者,您可以使用 CSS 约定,其中相同的属性表示为background-color(全部小写,单词之间有连字符)。如果使用 CSS 格式,则必须用引号将属性名括起来,以防止 JavaScript 试图将连字符解释为算术运算符,如下所示:

...
state("green", style({
    "background-color": "lightgreen",
    "font-size": "20px"
})),
...

只要保持一致,使用哪种命名约定并不重要。在撰写本文时,如果您混合和匹配属性名称约定,Angular 将无法正确应用样式。为了获得一致的结果,选择一个命名约定,并将其用于您在整个应用中设置的所有样式属性。

定义元素状态

Angular 需要知道何时需要对一个元素应用一组样式。这是通过定义元素状态来完成的,元素状态提供了一个名称,通过该名称可以引用样式集。元素状态是使用state函数创建的,该函数接受名称和应该与之关联的样式集。这是清单 28-5 中定义的两种元素状态之一:

...
state("selected", style({
    backgroundColor: "lightgreen",
    fontSize: "20px"
})),
...

列表中有两种状态,称为selectednotselected,它们将对应于表格行描述的产品是否在用户选择的类别中。

定义状态转换

当一个 HTML 元素处于使用state函数创建的状态之一时,Angular 将应用该状态的样式组中的 CSS 属性。transition函数用于告诉 Angular 应该如何应用新的 CSS 属性。清单 28-5 中有两个转换。

...
transition("selected => notselected", animate("200ms")),
transition("notselected => selected", animate("400ms"))
...

传递给transition函数的第一个参数告诉 Angular 该指令适用于哪个状态。参数是一个指定两种状态的字符串和一个表示它们之间关系的箭头。有两种箭头可供选择,如表 28-4 所示。

表 28-4。

动画过渡箭头类型

|

|

例子

|

描述

|
| --- | --- | --- |
| => | selected => notselected | 这个箭头指定了两个状态之间的单向转换,例如当元素从selected状态移动到notselected状态时。 |
| <=> | selected <=> notselected | 该数组指定了两种状态之间的双向转换,例如当元素从selected状态转移到notselected状态,以及从notselected状态转移到selected状态时。 |

清单 28-5 中定义的转换使用单向箭头告诉 Angular 当一个元素从selected状态转移到notselected状态以及从notselected状态转移到selected状态时,它应该如何响应。

transition函数的第二个参数告诉 Angular 当状态发生变化时应该采取什么动作。animate函数告诉 Angular 在由两个元素状态定义的 CSS 样式集中定义的属性之间逐渐过渡。传递给清单 28-5 中的animate函数的参数指定了这个逐渐过渡应该花费的时间,要么 200 毫秒,要么 400 毫秒。

Guidance for Applying Animations

开发人员在应用动画时经常会忘乎所以,导致应用让用户感到沮丧。动画应该少用,应该简单,应该快速。使用动画来帮助用户理解你的应用,而不是作为展示你艺术技巧的工具。用户,尤其是公司业务线应用,必须重复执行相同的任务,过多和过长的动画只会碍事。

我深受这种倾向的困扰,如果不加检查,我的应用的行为就像拉斯维加斯的老丨虎丨机。我遵循两条规则来控制问题。首先,我连续 20 次执行应用中的主要任务或工作流。在示例应用中,这可能意味着创建 20 个产品,然后编辑 20 个产品。我会删除或缩短我发现自己必须等待完成的任何动画,然后才能继续下一步。

第二条规则是,我不会在开发过程中禁用动画。当我在开发一个特性的时候,注释掉一个动画是很有诱惑力的,因为我在写代码的时候会执行一系列的快速测试。但是任何妨碍我的动画也会妨碍用户,所以我把动画留在原地并调整它们——通常减少它们的持续时间——直到它们变得不那么突兀和烦人。

当然,你不必遵循我的规则,但重要的是要确保动画对用户有帮助,而不是快速工作的障碍或令人分心的烦恼。

定义触发器

最后一项工作是动画触发器,它将元素状态和转换打包,并分配一个可用于在组件中应用动画的名称。触发器是使用trigger函数创建的,如下所示:

...
export const HighlightTrigger = trigger("rowHighlight", [...])
...

第一个参数是触发器的名称,在本例中是rowHighlight,第二个参数是应用触发器时可用的状态和转换的数组。

应用动画

一旦定义了动画,就可以通过使用@Component装饰器的animations属性将它应用到一个或多个组件。清单 28-6 将清单 28-5 中定义的动画应用到表格组件,并添加一些支持动画所需的附加特性。

import { Component, Inject } from "@angular/core";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { ActivatedRoute } from "@angular/router";
import { HighlightTrigger } from "./table.animations";

@Component({
    selector: "paTable",
    templateUrl: "table.component.html",
    animations: [HighlightTrigger]
})
export class TableComponent {
    category: string = null;

    constructor(private model: Model, activeRoute: ActivatedRoute) {
        activeRoute.params.subscribe(params => {
            this.category = params["category"] || null;
        })
    }

    getProduct(key: number): Product {
        return this.model.getProduct(key);
    }

    getProducts(): Product[] {
        return this.model.getProducts()
            .filter(p => this.category == null || p.category == this.category);
    }

    get categories(): string[] {
        return this.model.getProducts()
            .map(p => p.category)
            .filter((category, index, array) => array.indexOf(category) == index);
    }

    deleteProduct(key: number) {
        this.model.deleteProduct(key);
    }

    highlightCategory: string = "";

    getRowState(category: string): string {
        return this.highlightCategory == "" ? "" :
            this.highlightCategory == category ? "selected" : "notselected";
    }
}

Listing 28-6.Applying an Animation in the table.component.ts File in the src/app/core Folder

属性被设置为一个触发器数组。您可以内联定义动画,但它们会很快变得复杂,使整个组件难以阅读,这就是为什么我使用一个单独的文件并从中导出一个常量值,然后将它赋给animations属性。

其他变化是在用户选择的类别和将分配给元素的动画状态之间提供映射。将使用一个select元素来设置highlightCategory属性的值,并在getRowState方法中使用该值来告诉 Angular 清单 28-7 中定义的动画状态应该根据产品类别进行分配。如果产品在所选择的类别中,那么该方法返回selected;否则,它返回notselected。如果用户没有选择类别,则返回空字符串。

最后一步是将动画应用到组件的模板,告诉 Angular 哪些元素将被动画化,如清单 28-7 所示。这个清单还添加了一个select元素,它使用ngModel绑定来设置组件的highlightCategory属性的值。

<div class="form-group bg-info text-white p-2">
    <label>Category</label>
    <select [(ngModel)]="highlightCategory" class="form-control">
        <option value="">None</option>
        <option *ngFor="let category of categories">
            {{category}}
        </option>
    </select>
</div>
<table class="table table-sm table-bordered table-striped">
    <tr>
        <th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
    </tr>
    <tr *ngFor="let item of getProducts()"
            [@rowHighlight]="getRowState(item.category)">
        <td>{{item.id}}</td>
        <td>{{item.name}}</td>
        <td>{{item.category}}</td>
        <td>{{item.price | currency:"USD" }}</td>
        <td class="text-center">
            <button class="btn btn-danger btn-sm mr-1"
                    (click)="deleteProduct(item.id)">
                Delete
            </button>
            <button class="btn btn-warning btn-sm"
                [routerLink]="['/form', 'edit', item.id]">
                Edit
            </button>
        </td>
    </tr>
</table>
<div class="p-2 text-center">
    <button class="btn btn-primary m-1" routerLink="/form/create">
        Create New Product
    </button>
</div>

Listing 28-7.Applying an Animation in the table.component.html File in the src/app/core Folder

使用特殊的数据绑定将动画应用于模板,这些数据绑定将动画触发器与 HTML 元素相关联。绑定的目标告诉 Angular 要应用哪个动画触发器,绑定的表达式告诉 Angular 如何计算出元素应该被分配到哪个状态,如下所示:

...
<tr *ngFor="let item of getProducts()" [@rowHighlight]="getRowState(item.category)">
...

绑定的目标是动画触发器的名称,以字符@为前缀,表示动画绑定。这个绑定告诉 Angular 它应该将rowHighlight触发器应用到tr元素。表达式告诉 Angular 它应该调用组件的getRowState方法,使用item.category值作为参数,计算出元素应该被分配到哪个状态。图 28-2 展示了动画数据绑定的剖析,以供快速参考。

img/421542_4_En_28_Fig2_HTML.jpg

图 28-2。

动画数据绑定的剖析

测试动画效果

上一节中的更改在产品表上方添加了一个select元素。要查看动画的效果,重新启动 Angular development tools,请求http://localhost:4200,然后从窗口顶部的列表中选择 Soccer。Angular 将使用触发器来计算每个元素应该应用于哪个动画状态。足球类产品的表格行将被分配到selected状态,而其他行将被分配到notselected状态,产生如图 28-3 所示的效果。

img/421542_4_En_28_Fig3_HTML.jpg

图 28-3。

选择产品类别

新的样式突然被应用。要查看更平滑的过渡,请从列表中选择 Chess 类别,当 Chess 行被分配到selected状态而其他行被分配到notselected状态时,您将看到一个渐变动画。发生这种情况是因为动画触发器包含这些状态之间的转换,告诉 Angular 动画 CSS 样式的变化,如图 28-4 所示。早期的更改没有过渡,因此 Angular 默认立即应用新样式。

img/421542_4_En_28_Fig4_HTML.jpg

图 28-4。

动画状态之间的逐渐过渡

Tip

用一系列截图来捕捉动画的效果是不可能的,我最多能做的就是呈现一些中间状态。这是一个需要第一手实验来理解的特性。我鼓励你从 GitHub 下载本章的项目,并创建自己的动画。

要理解 Angular 动画系统,您需要理解用于定义和应用动画的不同构建块之间的关系,可以这样描述:

  1. 评估数据绑定表达式告诉 Angular 主体元素被分配到哪个动画状态。

  2. 数据绑定目标告诉 Angular 哪个动画目标定义了元素状态的 CSS 样式。

  3. 状态告诉 Angular 哪些 CSS 样式应该应用于元素。

  4. 转换告诉 Angular,当评估数据绑定表达式导致元素状态发生变化时,它应该如何应用 CSS 样式。

当你通读本章的其余部分时,记住这四点,你会发现动画系统更容易理解。

了解内置动画状态

动画状态用于定义动画的最终结果,将应用于元素的样式组合在一起,元素的名称可由动画触发器选择。Angular 提供了两种内置状态,使得管理元素的外观更加容易,如表 28-5 所述。

表 28-5。

内置动画状态

|

状态

|

描述

|
| --- | --- |
| * | 这是一种回退状态,如果元素不处于动画触发器定义的任何其他状态,将应用该状态。 |
| void | 当元素不是模板的一部分时,它们处于 void 状态。例如,当ngIf指令的表达式计算为false时,主机元素处于void状态。该状态用于动画显示元素的添加和移除,如下一节所述。 |

星号(*字符)用于表示特殊状态,Angular 应用于不在动画触发器定义的任何其他状态中的元素。清单 28-8 为示例应用中的动画添加了回退状态。

import { trigger, style, state, transition, animate } from "@angular/animations";

export const HighlightTrigger = trigger("rowHighlight", [
    state("selected", style({
        backgroundColor: "lightgreen",
        fontSize: "20px"
    })),
    state("notselected", style({
        backgroundColor: "lightsalmon",
        fontSize: "12px"
    })),
    state("*", style({
        border: "solid black 2px"
    })),
    transition("selected => notselected", animate("200ms")),
    transition("notselected => selected", animate("400ms"))
]);

Listing 28-8.Using the Fallback State in the table.animations.ts File in the src/app/core Folder

在示例应用中,一旦用户用select元素选择了一个值,元素就只被分配到selectednotselected状态。后退状态定义了一个样式组,该样式组将应用于元素,直到它们进入其他状态之一,如图 28-5 所示。

img/421542_4_En_28_Fig5_HTML.jpg

图 28-5。

使用回退状态

了解元素转换

转场是动画系统的真正力量;它们告诉 Angular 它应该如何管理从一种状态到另一种状态的变化。在接下来的部分中,我将描述创建和使用转换的不同方式。

为内置状态创建转换

表 28-5 中描述的内置状态可用于转换。后退状态可以通过表示任何状态来简化动画配置,如清单 28-9 所示。

import { trigger, style, state, transition, animate } from "@angular/animations";

export const HighlightTrigger = trigger("rowHighlight", [
    state("selected", style({
        backgroundColor: "lightgreen",
        fontSize: "20px"
    })),
    state("notselected", style({
        backgroundColor: "lightsalmon",
        fontSize: "12px"
    })),
    state("*", style({
        border: "solid black 2px"
    })),
    transition("* => notselected", animate("200ms")),
    transition("* => selected", animate("400ms"))
]);

Listing 28-9.Using the Fallback State in the table.animations.ts File in the src/app/core Folder

清单中的转换告诉 Angular 如何处理从任何状态到notselectedselected状态的变化。

添加和移除动画元素

void状态用于定义当一个元素被添加到模板或从模板中删除时的转换,如清单 28-10 所示。

import { trigger, style, state, transition, animate } from "@angular/animations";

export const HighlightTrigger = trigger("rowHighlight", [
    state("selected", style({
        backgroundColor: "lightgreen",
        fontSize: "20px"
    })),
    state("notselected", style({
        backgroundColor: "lightsalmon",
        fontSize: "12px"
    })),
    state("void", style({
        opacity: 0
    })),
    transition("* => notselected", animate("200ms")),
    transition("* => selected", animate("400ms")),
    transition("void => *", animate("500ms"))
]);

Listing 28-10Using the Void State in the table.animations.ts File in the src/app/core Folder

这个清单包含了对void状态的定义,它将opacity属性设置为零,这使得元素透明,因此不可见。还有一个转换告诉 Angular 将从void状态到任何其他状态的变化制作成动画。效果是当浏览器逐渐增加不透明度值直到达到填充不透明度时,表格中的行淡入视图,如图 28-6 所示。

img/421542_4_En_28_Fig6_HTML.jpg

图 28-6。

动画元素添加

控制过渡动画

到目前为止,本章中的所有例子都使用了最简单形式的animate函数,它指定了两个状态之间的转换需要多长时间,如下所示:

...
transition("void => *", animate("500ms"))
...

通过提供初始延迟并指定如何计算样式属性的中间值,传递给animate方法的string参数可用于对过渡的动画方式进行更细粒度的控制。

EXPRESSING ANIMATION DURATIONS

动画的持续时间使用 CSS 时间值来表示,CSS 时间值是包含一个或多个数字的字符串值,后跟代表秒的s或代表毫秒的ms。例如,该值指定 500 毫秒的持续时间:

...
transition("void => *", animate("500ms"))
...

持续时间可以灵活地表示,相同的值可以表示为几分之一秒,如下所示:

...
transition("void => *", animate("0.5s"))
...

我的建议是在整个项目中坚持使用一套单位以避免混淆,尽管使用哪一套并不重要。

指定计时功能

timing 函数负责在转换过程中计算 CSS 属性的中间值。表 28-6 中描述了网络动画规范中定义的计时功能。

表 28-6。

动画计时功能

|

名字

|

描述

|
| --- | --- |
| linear | 该函数等量改变数值。这是默认设置。 |
| ease-in | 此功能从随时间推移而增加的微小变化开始,从而产生一个缓慢启动并加速的动画。 |
| ease-out | 该函数从随时间推移而减少的较大变化开始,导致动画快速开始,然后变慢。 |
| ease-in-out | 这个函数从大的变化开始,变小直到中间点,之后又变大。结果是动画开始很快,中间变慢,最后又加速。 |
| cubic-bezier | 该函数用于使用贝塞尔曲线创建中间值。详见 http://w3c.github.io/web-animations/#time-transformations 。 |

清单 28-11 将一个计时函数应用于示例应用中的一个转换。计时函数在animate函数的参数中的持续时间之后指定。

import { trigger, style, state, transition, animate } from "@angular/animations";

export const HighlightTrigger = trigger("rowHighlight", [
    state("selected", style({
        backgroundColor: "lightgreen",
        fontSize: "20px"
    })),
    state("notselected", style({
        backgroundColor: "lightsalmon",
        fontSize: "12px"
    })),
    state("void", style({
        opacity: 0
    })),
    transition("* => notselected", animate("200ms")),
    transition("* => selected", animate("400ms ease-in")),
    transition("void => *", animate("500ms"))
]);

Listing 28-11.Applying a Timing Function in the table.animations.ts File in the src/app/core Folder

指定初始延迟

可以向animate方法提供一个初始延迟,当有多个过渡同时执行时,该方法可用于错开动画。延迟被指定为传递给animate函数的参数中的第二个值,如清单 28-12 所示。

import { trigger, style, state, transition, animate } from "@angular/animations";

export const HighlightTrigger = trigger("rowHighlight", [
    state("selected", style({
        backgroundColor: "lightgreen",
        fontSize: "20px"
    })),
    state("notselected", style({
        backgroundColor: "lightsalmon",
        fontSize: "12px"
    })),
    state("void", style({
        opacity: 0
    })),
    transition("* => notselected", animate("200ms")),
    transition("* => selected", animate("400ms 200ms ease-in")),
    transition("void => *", animate("500ms"))
]);

Listing 28-12.Adding an Initial Delay in the table.animations.ts File in the src/app/core Folder

本例中的 200 毫秒延迟对应于元素转换到notselected状态时使用的动画的持续时间。其效果是,在selected元素被改变之前,改变选中的类别将显示返回到notselected状态的元素。

在过渡期间使用附加样式

animate函数可以接受一个样式组作为它的第二个参数,如清单 28-13 所示。在动画持续期间,这些样式会逐渐应用到主体元素。

import { trigger, style, state, transition, animate } from "@angular/animations";

export const HighlightTrigger = trigger("rowHighlight", [
    state("selected", style({
        backgroundColor: "lightgreen",
        fontSize: "20px"
    })),
    state("notselected", style({
        backgroundColor: "lightsalmon",
        fontSize: "12px"
    })),
    state("void", style({
        opacity: 0
    })),
    transition("* => notselected", animate("200ms")),
    transition("* => selected",
        animate("400ms 200ms ease-in",
            style({
                backgroundColor: "lightblue",
                fontSize: "25px"
            }))
    ),
    transition("void => *", animate("500ms"))
]);

Listing 28-13.Defining Transition Styles in the table.animations.ts File in the src/app/core Folder

这一改变的效果是,当一个元素转换到selected状态时,它的外观将被动画化,因此背景颜色将是lightblue,字体大小将是 25 像素。在动画结束时,由selected状态定义的样式将被一次应用,创建一个快照效果。

动画结束时外观的突然变化可能会令人不舒服。另一种方法是将transition函数的第二个参数改为动画数组。这定义了将按顺序应用于元素的多个动画,只要它没有定义样式组,最终的动画将用于过渡到由状态定义的样式。清单 28-14 使用这个特性向过渡添加两个动画,最后一个将应用由selected状态定义的样式。

import { trigger, style, state, transition, animate } from "@angular/animations";

export const HighlightTrigger = trigger("rowHighlight", [
    state("selected", style({
        backgroundColor: "lightgreen",
        fontSize: "20px"
    })),
    state("notselected", style({
        backgroundColor: "lightsalmon",
        fontSize: "12px"
    })),
    state("void", style({
        opacity: 0
    })),
    transition("* => notselected", animate("200ms")),
    transition("* => selected",
        [animate("400ms 200ms ease-in",
            style({
                backgroundColor: "lightblue",
                fontSize: "25px"
            })),
            animate("250ms", style({
                backgroundColor: "lightcoral",
                fontSize: "30px"
            })),
            animate("200ms")]
    ),
    transition("void => *", animate("500ms"))
]);

Listing 28-14.Using Multiple Animations in the table.animations.ts File in the src/app/core Folder

这个过渡中有三个动画,最后一个将应用由selected状态定义的样式。表 28-7 描述了动画的顺序。

表 28-7。

转换到选定状态时的动画序列

|

持续时间

|

样式属性和值

|
| --- | --- |
| 400 毫秒 | backgroundColor: lightblue; fontSize: 25px |
| 250 毫秒 | backgroundColor: lightcoral; fontSize: 30px |
| 200 毫秒 | backgroundColor: lightgreen; fontSize: 20px |

使用select元素选择一个类别来查看动画序列。图 28-7 显示了每个动画中的一帧。

img/421542_4_En_28_Fig7_HTML.jpg

图 28-7。

在过渡中使用多个动画

执行并行动画

Angular 能够同时执行动画,这意味着您可以在不同的时间段更改不同的 CSS 属性。并行动画被传递给group函数,如清单 28-15 所示。

import { trigger, style, state, transition, animate, group }
    from "@angular/animations";

export const HighlightTrigger = trigger("rowHighlight", [
    state("selected", style({
        backgroundColor: "lightgreen",
        fontSize: "20px"
    })),
    state("notselected", style({
        backgroundColor: "lightsalmon",
        fontSize: "12px"
    })),
    state("void", style({
        opacity: 0
    })),
    transition("* => notselected", animate("200ms")),
    transition("* => selected",
        [animate("400ms 200ms ease-in",
            style({
                backgroundColor: "lightblue",
                fontSize: "25px"
            })),
            group([
                animate("250ms", style({
                    backgroundColor: "lightcoral",
                })),
                animate("450ms", style({
                    fontSize: "30px"
                })),
            ]),
            animate("200ms")]
    ),
    transition("void => *", animate("500ms"))
]);

Listing 28-15.Performing Parallel Animations in the table.animations.ts File in the src/app/core Folder

该清单用一对并行动画替换了序列中的一个动画。属性backgroundColorfontSize的动画将同时开始,但持续时间不同。当组中的两个动画都完成时,Angular 将移动到最终动画,该动画将针对状态中定义的样式。

了解动画样式组

Angular 动画的结果是将元素置于新状态,并使用关联样式组中的属性和值来设置样式。在这一节中,我将解释一些使用样式组的不同方式。

Tip

并不是所有的 CSS 属性都可以被动画化,在那些可以被动画化的属性中,有些被浏览器处理得更好。根据经验,最好的结果是使用其值可以很容易地插值的属性来实现,这允许浏览器提供元素状态之间的平滑过渡。这意味着使用值为颜色或数值的属性,如背景、文本和字体颜色、不透明度、元素大小和边框,通常会获得良好的结果。参见https:// www.w3.org/TR/css3-transitions/#animatable-properties 了解可用于动画系统的完整属性列表。

在可重用组中定义通用样式

当您创建更复杂的动画并在整个应用中应用它们时,您会不可避免地发现需要在多个地方应用一些常见的 CSS 属性值。style函数可以接受一个对象数组,所有这些对象被组合在一起以创建组中的整体样式集。这意味着您可以通过定义包含公共样式的对象并在多个样式组中使用它们来减少重复,如清单 28-16 所示。(为了保持示例简单,我还删除了上一节中定义的样式序列。)

import { trigger, style, state, transition, animate, group } from "@angular/animations";

const commonStyles = {
    border: "black solid 4px",
    color: "white"
};

export const HighlightTrigger = trigger("rowHighlight", [
    state("selected", style([commonStyles, {
        backgroundColor: "lightgreen",
        fontSize: "20px"
    }])),
    state("notselected", style([commonStyles, {
        backgroundColor: "lightsalmon",
        fontSize: "12px",
        color: "black"
    }])),
    state("void", style({
        opacity: 0
    })),
    transition("* => notselected", animate("200ms")),
    transition("* => selected", animate("400ms 200ms ease-in")),
    transition("void => *", animate("500ms"))
]);

Listing 28-16.Defining Common Styles in the table.animations.ts File in the src/app/core Folder

commonStyles对象定义了bordercolor属性的值,并在一个数组中与常规样式对象一起传递给style函数。Angular 按顺序处理样式对象,这意味着您可以通过在后面的对象中重新定义样式值来覆盖样式值。例如,notselected州的第二个样式对象用一个自定义值覆盖了color属性的公共值。结果是两种动画状态的样式都包含了border属性的公共值,而selected状态的样式也使用了color属性的公共值,如图 28-8 所示。

img/421542_4_En_28_Fig8_HTML.jpg

图 28-8。

定义公共属性

使用元素转换

到目前为止,本章中的所有例子都有影响元素外观的动画属性,如背景色、字体大小或不透明度。动画还可以用于应用 CSS 元素变换效果,这些效果用于移动、调整大小、旋转或倾斜元素。这些效果是通过在样式组中定义一个transform属性来应用的,如清单 28-17 所示。

import { trigger, style, state, transition, animate, group }
    from "@angular/animations";

const commonStyles = {
    border: "black solid 4px",
    color: "white"
};

export const HighlightTrigger = trigger("rowHighlight", [
    state("selected", style([commonStyles, {
        backgroundColor: "lightgreen",
        fontSize: "20px"
    }])),
    state("notselected", style([commonStyles, {
        backgroundColor: "lightsalmon",
        fontSize: "12px",
        color: "black"
    }])),
    state("void", style({
        transform: "translateX(-50%)"
    })),
    transition("* => notselected", animate("200ms")),
    transition("* => selected", animate("400ms 200ms ease-in")),
    transition("void => *",  animate("500ms"))
]);

Listing 28-17.Using an Element Transformation in the table.animations.ts File in the src/app/core Folder

属性的值是 ??,它告诉 Angular 沿着 x 轴移动元素长度的 50%。transform属性已经被应用到void状态,这意味着当元素被添加到模板中时,它将被用在元素上。该动画包含从void状态到任何其他状态的转换,并告诉 Angular 在 500 毫秒内将这些变化制作成动画。结果是新元素最初会向左移动,然后在半秒钟内滑回到默认位置,如图 28-9 所示。

img/421542_4_En_28_Fig9_HTML.jpg

图 28-9。

变换元素

表 28-8 描述了可以应用于元素的一组转换。

表 28-8。

CSS 转换函数

|

功能

|

描述

|
| --- | --- |
| translateX(offset) | 这个函数沿着 x 轴移动元素。移动量可以指定为百分比或长度(以像素或其他 CSS 长度单位之一表示)。正值将元素向右平移,负值向左平移。 |
| translateY(offset) | 这个函数沿着 y 轴移动元素。 |
| translate(xOffset, yOffset) | 该函数沿两个轴移动元素。 |
| scaleX(amount) | 这个函数沿着 x 轴缩放元素。缩放尺寸表示为元素常规尺寸的一部分,因此0.5将元素缩小到原始宽度的 50 %, 2.0 将使宽度加倍。 |
| scaleY(amount) | 此函数沿 y 轴缩放元素。 |
| scale(xAmount, yAmount) | 该函数沿两个轴缩放元素。 |
| rotate(angle) | 这个函数顺时针旋转元素。旋转量用 Angular 表示,如90deg3.14rad。 |
| skewX(angle) | 该函数将元素沿 x 轴倾斜一个指定的 Angular,表达方式与rotate函数相同。 |
| skewY(angle) | 该函数将元素沿 y 轴倾斜一个指定的 Angular,表达方式与rotate函数相同。 |
| skew(xAngle, yAngle) | 该函数沿两个轴倾斜元素。 |

Tip

通过用空格分隔,可以在一个transform属性中应用多个转换,就像这样:transform: "scale(1.1, 1.1) rotate(10deg)"

应用 CSS 框架样式

如果您正在使用类似 Bootstrap 的 CSS 框架,您可能希望将类应用于元素,而不是必须定义属性组。没有直接使用 CSS 类的内置支持,但是文档对象模型(DOM)和 CSS 对象模型(CSSOM)提供 API 访问来检查已加载的 CSS 样式表,并查看它们是否适用于 HTML 元素。为了获得由类定义的样式集,我在src/app/core文件夹中创建了一个名为animationUtils.ts的文件,并添加了清单 28-18 中所示的代码。

Caution

这种技术可能需要在使用大量复杂样式表的应用中进行大量处理,并且您可能需要调整代码以适应不同的浏览器和不同的 CSS 框架。

export function getStylesFromClasses(names: string | string[],
        elementType: string = "div") : { [key: string]: string | number } {

    let elem = document.createElement(elementType);
    (typeof names == "string" ? [names] : names).forEach(c => elem.classList.add(c));

    let result = {};

    for (let i = 0; i < document.styleSheets.length; i++) {
        let sheet = document.styleSheets[i] as CSSStyleSheet;
        let rules = sheet.rules || sheet.cssRules;
        for (let j = 0; j < rules.length; j++) {
            if (rules[j].type == CSSRule.STYLE_RULE) {
                let styleRule = rules[j] as CSSStyleRule;
                if (elem.matches(styleRule.selectorText)) {
                    for (let k = 0; k < styleRule.style.length; k++) {
                        result[styleRule.style[k]] =
                            styleRule.style[styleRule.style[k]];
                    }
                }
            }
        }
    }
    return result;
}

Listing 28-18.The Contents of the animationUtils.ts File in the src/app/core Folder

getStylesFromClass方法接受单个类名或类名数组以及它们应该应用的元素类型,默认为一个div元素。创建一个元素并将其分配给类,然后检查 CSS 样式表中定义的哪个 CSS 规则适用于它。每个匹配样式的样式属性被添加到一个对象中,该对象可用于创建 Angular 动画样式组,如清单 28-19 所示。

import { trigger, style, state, transition, animate, group }
    from "@angular/animations";
import { getStylesFromClasses } from "./animationUtils";

export const HighlightTrigger = trigger("rowHighlight", [
    state("selected", style(getStylesFromClasses(["bg-success", "h2"]))),
    state("notselected", style(getStylesFromClasses("bg-info"))),
    state("void", style({
        transform: "translateX(-50%)"
    })),
    transition("* => notselected", animate("200ms")),
    transition("* => selected", animate("400ms 200ms ease-in")),
    transition("void => *", animate("500ms"))
]);

Listing 28-19.Using Bootstrap Classes in the table.animations.ts File in the src/app/core Folder

selected状态使用 Bootstrap bg-successh2类中定义的样式,notselected状态使用 Bootstrap bg-info类定义的样式,产生如图 28-10 所示的结果。

img/421542_4_En_28_Fig10_HTML.jpg

图 28-10。

在 Angular 动画中使用 CSS 框架样式

摘要

我在本章中描述了 Angular 动画系统,并解释了它如何使用数据绑定来动画化应用状态的变化。在下一章,我将描述 Angular 提供的支持单元测试的特性。

二十九、Angular 单元测试

在这一章中,我描述了 Angular 为单元测试组件和指令提供的工具。一些有 Angular 的构建块,比如管道和服务,可以使用我在本章开始时设置的基本测试工具进行独立测试。组件(在较小程度上还有指令)与它们的宿主元素和模板内容有着复杂的交互,并且需要特殊的特性。表 29-1 将 Angular 单元测试放在上下文中。

表 29-1。

放置 Angular 单元测试上下文

|

问题

|

回答

|
| --- | --- |
| 这是什么? | Angular 组件和指令需要特殊的测试支持,以便它们与应用基础设施的其他部分的交互可以被隔离和检查。 |
| 为什么有用? | 独立的单元测试能够评估实现组件或指令的类所提供的基本逻辑,但不能捕获与宿主元素、服务、模板和其他重要 Angular 特征的交互。 |
| 如何使用? | Angular 提供了一个测试平台,允许创建一个真实的应用环境,然后用于执行单元测试。 |
| 有什么陷阱或限制吗? | 像 Angular 的大部分内容一样,单元测试工具很复杂。可能需要花费一些时间和精力,才能轻松地编写和运行单元测试,并且确定已经隔离了应用中正确的测试部分。 |
| 还有其他选择吗? | 如上所述,您不必对项目进行单元测试。但是如果你确实想进行单元测试,那么你将需要使用本章中描述的 Angular 特性。 |

Deciding Whether to Unit Test

单元测试是一个有争议的话题。本章假设你想做单元测试,并向你展示如何设置工具,并把它们应用到 Angular 组件和指令中。这不是对单元测试的介绍,我也没有努力说服持怀疑态度的读者单元测试是值得的。如果你想了解单元测试,这里有一篇很好的文章: https://en.wikipedia.org/wiki/Unit_testing

我喜欢单元测试,我也在自己的项目中使用它——但并不是所有的项目,也不像你所期望的那样始终如一。我倾向于专注于为我知道很难编写的特性和功能编写单元测试,这些特性和功能很可能是部署中的错误来源。在这些情况下,单元测试有助于我思考如何最好地实现我需要的东西。我发现仅仅考虑我需要测试什么就有助于产生关于潜在问题的想法,这是在我开始处理实际的错误和缺陷之前。

也就是说,单元测试是一种工具,而不是宗教,只有你自己知道你需要多少测试。如果你不觉得单元测试有用,或者如果你有更适合你的不同的方法论,那么不要仅仅因为它是时髦的就觉得你需要单元测试。(然而,如果你没有更好的方法论,你根本没有在测试,那么你很可能是在让用户发现你的 bug,这很少是理想的。)

表 29-2 总结了本章内容。

表 29-2。

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 对组件执行基本测试 | 初始化测试模块并创建组件的实例。如果组件有外部模板,则必须执行额外的编译步骤。 | 1–9, 11–13 |
| 测试组件的数据绑定 | 使用DebugElement类来查询组件的模板。 | Ten |
| 测试组件对事件的响应 | 使用 debug 元素触发事件。 | 14–16 |
| 测试组件的输出属性 | 订阅由组件创建的EventEmitter。 | 17, 18 |
| 测试组件的输入属性 | 创建一个测试组件,它的模板应用被测组件。 | 19, 20 |
| 执行依赖异步操作的测试 | 使用whenStable方法推迟测试,直到操作效果处理完毕。 | 21, 22 |
| 测试指令 | 创建一个测试组件,其模板应用测试中的指令。 | 23, 24 |

准备示例项目

我继续使用前面章节中的 exampleApp 项目。我需要一个简单的目标来关注单元测试,所以清单 29-1 改变了路由配置,从而默认加载了ondemand特性模块。

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";
import { ProductCountComponent } from "./core/productCount.component";
import { CategoryCountComponent } from "./core/categoryCount.component";
import { ModelResolver } from "./model/model.resolver";
import { TermsGuard } from "./terms.guard";
import { UnsavedGuard } from "./core/unsaved.guard";
import { LoadGuard } from "./load.guard";

const routes: Routes = [
    {
        path: "ondemand",
        loadChildren: () => import("./ondemand/ondemand.module")
                                .then(m => m.OndemandModule)
    },
    { path: "", redirectTo: "/ondemand", pathMatch: "full" }
]

export const routing = RouterModule.forRoot(routes);

Listing 29-1.Changing the Routing Configuration in the app.routing.ts File in the src/app Folder

这个模块包含一些简单的组件,我将用它们来演示不同的单元测试特性。为了保持应用显示的内容简单,清单 29-2 整理了特性模块中顶层组件显示的模板。

<div class="container-fluid">
    <div class="row">
        <div class="col-12 p-2">
            <router-outlet></router-outlet>
        </div>
    </div>
    <div class="row">
        <div class="col-6 p-2">
            <router-outlet name="left"></router-outlet>
        </div>
        <div class="col-6 p-2">
            <router-outlet name="right"></router-outlet>
        </div>
    </div>
</div>
<button class="btn btn-secondary m-2" routerLink="/ondemand">Normal</button>
<button class="btn btn-secondary m-2" routerLink="/ondemand/swap">Swap</button>

Listing 29-2.Simplifying the ondemand.component.html File in the src/app/ondemand Folder

打开一个新的命令提示符,导航到exampleApp文件夹,运行以下命令启动提供 RESTful web 服务器的服务器:

npm run json

本章没有直接使用 RESTful web 服务,但是运行它可以防止错误。打开一个单独的命令提示符,导航到exampleApp文件夹,运行以下命令启动 Angular 开发工具:

ng serve

打开一个新的浏览器窗口并导航到http://localhost:4200以查看如图 29-1 所示的内容。

img/421542_4_En_29_Fig1_HTML.jpg

图 29-1。

运行示例应用

运行简单的单元测试

当使用ng new命令创建一个新项目时,单元测试所需的所有包和工具都会基于 Jasmine 测试框架进行安装。为了创建一个简单的单元测试来确认一切正常,我创建了src/app/tests文件夹,并在其中添加了一个名为app.component.spec.ts的文件,其内容如清单 29-3 所示。单元测试的命名约定使得测试应用于哪个文件变得显而易见。

describe("Jasmine Test Environment", () => {
    it("is working", () => expect(true).toBe(true));
});

Listing 29-3.Replacing the Contents of the app.component.spec.ts File in the src/app/tests Folder

我很快会解释使用 Jasmine API 的基础知识,您可以暂时忽略语法。使用新的命令提示符,导航到exampleApp文件夹,并运行以下命令:

ng test

这个命令启动 Karma test runner,它打开一个新的浏览器选项卡,内容如图 29-2 所示。

img/421542_4_En_29_Fig2_HTML.jpg

图 29-2。

启动 Karma 测试运行程序

浏览器窗口用于运行测试,但是重要的信息被写出到用于启动测试工具的命令提示符中,在那里您会看到如下消息:

Chrome 80.0.3987 (Windows 10.0.0): Executed 1 of 1 SUCCESS (0.118 secs / 0.005 secs)

这表明项目中的单个单元测试已经被成功地定位和执行。每当您对项目中的一个 JavaScript 文件进行更新时,单元测试就会被定位和执行,任何问题都会被写到命令提示符中。为了展示一个错误是什么样子,清单 29-4 改变了单元测试,使它失败。

describe("Jasmine Test Environment", () => {
    it("is working", () => expect(true).toBe(false));
});

Listing 29-4.Making a Unit Test Fail in the app.component.spec.ts File in the src/app/tests Folder

该测试将会失败,并会产生以下输出,该输出指出了失败的测试以及出错的原因:

Chrome 80.0.3987 (Windows 10.0.0) Jasmine Test Environment is working FAILED
        Error: Expected true to be false.
...
Chrome 80.0.3987 (Windows 10.0.0): Executed 1 of 1 (1 FAILED) ERROR
(0.125 secs / 0.118 secs)

和茉莉一起工作

Jasmine 提供的 API 将 JavaScript 方法链接在一起以定义单元测试。你可以在 http://jasmine.github.io 找到 Jasmine 的完整文档,但是表 29-3 描述了 Angular 测试最有用的函数。

表 29-3。

有用的茉莉花方法

|

名字

|

描述

|
| --- | --- |
| describe(description, function) | 此方法用于对一组相关的测试进行分组。 |
| beforeEach(function) | 此方法用于指定在每个单元测试之前执行的任务。 |
| afterEach(function) | 此方法用于指定在每个单元测试之后执行的测试。 |
| it(description, function) | 此方法用于执行测试操作。 |
| expect(value) | 该方法用于识别测试结果。 |
| toBe(value) | 此方法指定测试的预期值。 |

你可以在清单 29-4 中看到如何使用表 29-3 中的方法来创建单元测试。

...
describe("Jasmine Test Environment", () => {
    it("is working", () => expect(true).toBe(false));
});
...

您还可以看到测试失败的原因,因为已经使用了expecttoBe方法来检查truefalse是否相等。因为情况并非如此,所以测试失败。

toBe方法不是评估单元测试结果的唯一方法。表 29-4 显示了 Angular 提供的其他评估方法。

表 29-4。

有用的 Jasmine 评估方法

|

名字

|

描述

|
| --- | --- |
| toBe(value) | 此方法断言结果与指定的值相同(但不必是同一个对象)。 |
| toEqual(object) | 此方法断言结果是与指定值相同的对象。 |
| toMatch(regexp) | 此方法断言结果匹配指定的正则表达式。 |
| toBeDefined() | 这个方法断言结果已经被定义。 |
| toBeUndefined() | 此方法断言结果尚未定义。 |
| toBeNull() | 该方法断言结果为空。 |
| toBeTruthy() | 该方法断言结果是真实的,如第十二章所述。 |
| toBeFalsy() | 该方法断言结果为 falsy,如第十二章所述。 |
| toContain(substring) | 此方法断言结果包含指定的子字符串。 |
| toBeLessThan(value) | 此方法断言结果小于指定值。 |
| toBeGreaterThan(value) | 此方法断言结果大于指定值。 |

清单 29-5 展示了如何在测试中使用这些评估方法,取代前一节中失败的测试。

describe("Jasmine Test Environment", () => {
    it("test numeric value", () => expect(12).toBeGreaterThan(10));
    it("test string value", () => expect("London").toMatch("^Lon"));
});

Listing 29-5.Replacing the Unit Test in the app.component.spec.ts File in the src/app/tests Folder

当您将更改保存到文件中时,测试将被执行,结果将显示在命令提示符中。

测试 Angular 组件

不能孤立地测试 Angular 应用的构建块,因为它们依赖于 Angular 和项目的其他部分提供的底层特性,包括它包含的服务、指令、模板和模块。因此,测试一个构建块(比如一个组件)意味着使用 Angular 提供的测试实用程序来重新创建足够的应用,让组件正常工作,以便可以对其执行测试。在这一节中,我将介绍在OnDemand特性模块中的FirstComponent类上执行单元测试的过程,该模块是在第二十七章中添加到项目中的。提醒一下,下面是组件的定义:

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

@Component({
    selector: "first",
    template: `<div class="bg-primary text-white p-2">First Component</div>`
})
export class FirstComponent { }

这个组件非常简单,它本身没有要测试的功能,但是它足以演示测试过程是如何应用的。

使用 TestBed 类

Angular 单元测试的核心是一个名为TestBed的类,它负责模拟 Angular 应用环境,以便可以执行测试。表 29-5 描述了TestBed方法提供的最有用的方法,所有这些方法都是静态的,如第六章所述。

表 29-5。

有用的试验台方法

|

名字

|

描述

|
| --- | --- |
| configureTestingModule | 该方法用于配置 Angular 测试模块。 |
| createComponent | 此方法用于创建组件的实例。 |
| compileComponents | 该方法用于编译组件,如“使用外部模板测试组件”一节所述。 |

configureTestingModule方法用于配置测试中使用的 Angular 模块,使用由@NgModel装饰器支持的相同属性。就像在真实的应用中一样,一个组件不能在单元测试中使用,除非它已经被添加到模块的declarations属性中。这意味着大多数单元测试的第一步是配置测试模块。为了演示,我在src/app/tests文件夹中添加了一个名为first.component.spec.ts的文件,其内容如清单 29-6 所示。

import { TestBed } from "@angular/core/testing";
import { FirstComponent } from "../ondemand/first.component";

describe("FirstComponent", () => {

    beforeEach(() => {
        TestBed.configureTestingModule({
            declarations: [FirstComponent]
        });
    });
});

Listing 29-6.The Contents of the first.component.spec.ts File in the src/app/tests Folder

@angular/core/testing模块中定义了TestBed类,configureTestingModule接受一个对象,该对象的declarations属性告诉测试模块将使用FirstComponent类。

Tip

注意在beforeEach函数中使用了TestBed类。如果你试图在这个函数之外使用TestBed,你会看到一个关于使用Promise s 的错误

下一步是创建组件的一个新实例,以便它可以在测试中使用。这是使用createComponent方法完成的,如清单 29-7 所示。

import { TestBed, ComponentFixture} from "@angular/core/testing";
import { FirstComponent } from "../ondemand/first.component";

describe("FirstComponent", () => {

    let fixture: ComponentFixture<FirstComponent>;
    let component: FirstComponent;

    beforeEach(() => {
        TestBed.configureTestingModule({
            declarations: [FirstComponent]
        });
        fixture = TestBed.createComponent(FirstComponent);
        component = fixture.componentInstance;
    });

    it("is defined", () => {
        expect(component).toBeDefined()
    });
});

Listing 29-7.Creating a Component in the first.component.spec.ts File in the src/app/tests Folder

createComponent方法的参数告诉测试床它应该实例化哪个组件类型,在本例中是FirstComponent。结果是一个ComponentFixture<FirstComponent>对象,它使用表 29-6 中描述的方法和属性提供了测试组件的特性。

表 29-6。

有用的组件夹具方法和属性

|

名字

|

描述

|
| --- | --- |
| componentInstance | 该属性返回组件对象。 |
| debugElement | 此属性返回组件的测试宿主元素。 |
| nativeElement | 该属性返回表示组件宿主元素的 DOM 对象。 |
| detectChanges() | 该方法使测试床检测状态变化,并将它们反映在组件的模板中。 |
| whenStable() | 这个方法返回一个Promise,当一个操作的效果被完全应用时,它被解析。有关详细信息,请参见“用异步操作进行测试”一节。 |

在清单中,我使用componentInstance属性获取测试平台已经创建的FirstComponent对象,并执行一个简单的测试,以确保它是通过使用expect方法选择component对象作为测试目标并使用toBeDefined方法执行测试而创建的。我将在接下来的小节中演示其他方法和属性。

为依赖关系配置测试平台

Angular 应用最重要的特性之一是依赖注入,它允许组件和其他构建块通过使用构造函数参数声明对它们的依赖来接收服务。清单 29-8 向FirstComponent类添加了对数据模型存储库服务的依赖。

import { Component } from "@angular/core";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";

@Component({
    selector: "first",
    template: `<div class="bg-primary p-a-1">
                There are
                    <span class="strong"> {{getProducts().length}} </span>
                products
               </div>`
})
export class FirstComponent {

    constructor(private repository: Model) {}

    category: string = "Soccer";

    getProducts(): Product[] {
        return this.repository.getProducts()
            .filter(p => p.category == this.category);
    }
}

Listing 29-8.Adding a Service Dependency in the first.component.ts File in the src/app/ondemand Folder

该组件使用存储库来提供一个经过过滤的Product对象集合,这些对象通过一个名为getProducts的方法公开,并使用一个category属性进行过滤。内联模板有一个相应的数据绑定,显示了getProducts方法返回的产品数量。

能够对组件进行单元测试意味着为它提供存储库服务。只要通过测试模块进行配置,Angular 测试平台将负责解决依赖性。有效的单元测试通常要求组件与应用的其余部分隔离,这意味着模拟或伪造的对象(也称为测试替身)被用作单元测试中真实服务的替代品。清单 29-9 配置测试平台,以便使用一个假的存储库来为组件提供服务。

import { TestBed, ComponentFixture} from "@angular/core/testing";
import { FirstComponent } from "../ondemand/first.component";
import { Product } from "..//model/product.model";
import { Model } from "../model/repository.model";

describe("FirstComponent", () => {

    let fixture: ComponentFixture<FirstComponent>;
    let component: FirstComponent;

    let mockRepository = {
        getProducts: function () {
            return [
                new Product(1, "test1", "Soccer", 100),
                new Product(2, "test2", "Chess", 100),
                new Product(3, "test3", "Soccer", 100),
            ]
        }
    }

    beforeEach(() => {
        TestBed.configureTestingModule({
            declarations: [FirstComponent],
            providers: [
                { provide: Model, useValue: mockRepository }
            ]
        });
        fixture = TestBed.createComponent(FirstComponent);
        component = fixture.componentInstance;
    });

    it("filters categories", () => {
        component.category = "Chess"
        expect(component.getProducts().length).toBe(1);
        component.category = "Soccer";
        expect(component.getProducts().length).toBe(2);
        component.category = "Running";
        expect(component.getProducts().length).toBe(0);
    });
});

Listing 29-9.Providing a Service in the first.component.spec.ts File in the src/app/tests Folder

变量mockRepository被赋予一个对象,该对象提供一个getProducts方法,该方法返回可用于测试已知结果的固定数据。为了给组件提供服务,传递给TestBed.configureTestingModule方法的对象的providers属性以与真实 Angular 模块相同的方式进行配置,使用值提供者通过mockRepository变量解析对Model类的依赖。测试调用组件的getProducts方法,并将结果与预期结果进行比较,改变category属性的值来检查不同的过滤器。

测试数据绑定

前面的例子展示了如何在单元测试中使用组件的属性和方法。这是一个好的开始,但是许多组件还会在包含在它们的模板中的数据绑定表达式中包含功能的小片段,这些也应该被测试。清单 29-10 检查组件模板中的数据绑定是否正确显示了模拟数据模型中的产品数量。

import { TestBed, ComponentFixture} from "@angular/core/testing";
import { FirstComponent } from "../ondemand/first.component";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { DebugElement } from "@angular/core";
import { By } from "@angular/platform-browser";

describe("FirstComponent", () => {

    let fixture: ComponentFixture<FirstComponent>;
    let component: FirstComponent;
    let debugElement: DebugElement;
    let bindingElement: HTMLSpanElement;

    let mockRepository = {
        getProducts: function () {
            return [
                new Product(1, "test1", "Soccer", 100),
                new Product(2, "test2", "Chess", 100),
                new Product(3, "test3", "Soccer", 100),
            ]
        }
    }

    beforeEach(() => {
        TestBed.configureTestingModule({
            declarations: [FirstComponent],
            providers: [
                { provide: Model, useValue: mockRepository }
            ]
        });
        fixture = TestBed.createComponent(FirstComponent);
        component = fixture.componentInstance;
        debugElement = fixture.debugElement;
        bindingElement = debugElement.query(By.css("span")).nativeElement;
    });

    it("filters categories", () => {
        component.category = "Chess"
        fixture.detectChanges();
        expect(component.getProducts().length).toBe(1);
        expect(bindingElement.textContent).toContain("1");

        component.category = "Soccer";
        fixture.detectChanges();
        expect(component.getProducts().length).toBe(2);
        expect(bindingElement.textContent).toContain("2");

        component.category = "Running";
        fixture.detectChanges();
        expect(component.getProducts().length).toBe(0);
        expect(bindingElement.textContent).toContain("0");
    });
});

Listing 29-10.Unit Testing a Data Binding in the first.component.spec.ts File in the src/app/tests Folder

ComponentFixture.debugElement属性返回一个代表组件模板根元素的DebugElement对象,表 29-7 列出了由DebugElement类描述的最有用的方法和属性。

表 29-7。

有用的调试属性和方法

|

名字

|

描述

|
| --- | --- |
| nativeElement | 此属性返回表示 DOM 中 HTML 元素的对象。 |
| children | 这个属性返回一个代表这个元素的子元素的DebugElement对象数组。 |
| query(selectorFunction) | 为组件模板中的每个 HTML 元素向selectorFunction传递一个DebugElement对象,该方法返回函数返回true的第一个DebugElement。 |
| queryAll(selectorFunction) | 这类似于query方法,除了结果是函数返回true的所有DebugElement对象。 |
| triggerEventHandler(name, event) | 此方法触发一个事件。有关详细信息,请参见“测试组件事件”一节。 |

定位元素是通过queryqueryAll方法完成的,这两个方法接受检查DebugElement对象的函数,如果它们应该包含在结果中,则返回true。在@angular/platform-browser模块中定义的By类使得通过表 29-8 中描述的静态方法定位组件模板中的元素变得更加容易。

表 29-8。

By 方法

|

名字

|

描述

|
| --- | --- |
| By.all() | 这个方法返回一个匹配任何元素的函数。 |
| By.css(selector) | 该方法返回一个使用 CSS 选择器匹配元素的函数。 |
| By.directive(type) | 该方法返回一个函数,该函数与应用了指定指令类的元素相匹配,如“测试输入属性”一节中所示。 |

在清单中,我使用By.css方法定位模板中的第一个span元素,并通过nativeElement属性访问表示它的 DOM 对象,这样我就可以在单元测试中检查textContent属性的值。

请注意,每次更改组件的category属性后,我都会调用ComponentFixture对象的detectChanges方法,如下所示:

...
component.category = "Soccer";
fixture.detectChanges();
expect(component.getProducts().length).toBe(2);
expect(bindingElement.textContent).toContain("2");
...

该方法告诉 Angular 测试环境处理任何更改,并评估模板中的数据绑定表达式。如果没有这个方法调用,对组件category值的更改将不会反映在模板中,测试将会失败。

使用外部模板测试组件

Angular 组件被编译成工厂类,要么在浏览器中,要么由我在第十章中演示的超前编译器编译。作为这个过程的一部分,Angular 处理任何外部模板,并将它们作为文本包含在 JavaScript 代码中,生成的代码类似于内联模板。当使用外部模板对组件进行单元测试时,必须显式执行编译步骤。在清单 29-11 中,我修改了应用于FirstComponent类的@Component装饰器,这样它就指定了一个外部模板。

import { Component } from "@angular/core";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";

@Component({
    selector: "first",
    templateUrl: "first.component.html"
})
export class FirstComponent {

    constructor(private repository: Model) {}

    category: string = "Soccer";

    getProducts(): Product[] {
        return this.repository.getProducts()
            .filter(p => p.category == this.category);
    }
}

Listing 29-11.Specifying a Template in the first.component.ts File in the src/app/ondemand Folder

为了提供模板,我在exampleApp/app/ondemand文件夹中创建了一个名为first.component.html的文件,并添加了清单 29-12 中所示的元素。

<div class="bg-primary text-white p-2">
    There are
        <span class="strong"> {{getProducts().length}} </span>
    products
</div>

Listing 29-12.The first.component.html File in the exampleApp/app/ondemand Folder

这与之前内联定义的内容相同。清单 29-13 更新了组件的单元测试,通过显式编译组件来处理外部模板。

import { TestBed, ComponentFixture, async } from "@angular/core/testing";
import { FirstComponent } from "../ondemand/first.component";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { DebugElement } from "@angular/core";
import { By } from "@angular/platform-browser";

describe("FirstComponent", () => {

    let fixture: ComponentFixture<FirstComponent>;
    let component: FirstComponent;
    let debugElement: DebugElement;
    let spanElement: HTMLSpanElement;

    let mockRepository = {
        getProducts: function () {
            return [
                new Product(1, "test1", "Soccer", 100),
                new Product(2, "test2", "Chess", 100),
                new Product(3, "test3", "Soccer", 100),
            ]
        }
    }

    beforeEach(async(() => {
        TestBed.configureTestingModule({
            declarations: [FirstComponent],
            providers: [
                { provide: Model, useValue: mockRepository }
            ]
        });
        TestBed.compileComponents().then(() => {
            fixture = TestBed.createComponent(FirstComponent);
            component = fixture.componentInstance;
            debugElement = fixture.debugElement;
            spanElement = debugElement.query(By.css("span")).nativeElement;
        });
    }));

    it("filters categories", () => {
        component.category = "Chess"
        fixture.detectChanges();
        expect(component.getProducts().length).toBe(1);
        expect(spanElement.textContent).toContain("1");
    });
});

Listing 29-13.Compiling a Component in the first.component.spec.ts File in the src/app/tests Folder

使用TestBed.compileComponents方法编译组件。编译过程是异步的,compileComponents方法返回一个Promise,当编译完成时,必须使用它来完成测试设置。为了在单元测试中更容易处理异步操作,@angular/core/testing模块包含一个名为async的函数,它与beforeEach方法一起使用。

测试组件事件

为了演示如何测试组件对事件的响应,我在FirstComponent类中定义了一个新属性,并添加了一个已经应用了@HostBinding装饰器的方法,如清单 29-14 所示。

import { Component, HostListener} from "@angular/core";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";

@Component({
    selector: "first",
    templateUrl: "first.component.html"
})
export class FirstComponent {

    constructor(private repository: Model) {}

    category: string = "Soccer";
    highlighted: boolean = false;

    getProducts(): Product[] {
        return this.repository.getProducts()
            .filter(p => p.category == this.category);
    }

    @HostListener("mouseenter", ["$event.type"])
    @HostListener("mouseleave", ["$event.type"])
    setHighlight(type: string) {
        this.highlighted = type == "mouseenter";
    }
}

Listing 29-14.Adding Event Handling in the first.component.ts File in the src/app/ondemand Folder

已经配置了setHighlight方法,这样当主机元素的mouseentermouseleave事件被触发时,它将被调用。清单 29-15 更新组件的模板,以便它在数据绑定中使用新的属性。

<div class="bg-primary text-white p-2" [class.bg-success]="highlighted">
    There are
    <span class="strong"> {{getProducts().length}} </span>
    products
</div>

Listing 29-15.Binding to a Property in the first.component.html File in the src/app/ondemand Folder

事件可以通过DebugElement类定义的triggerEventHandler方法在单元测试中触发,如清单 29-16 所示。

import { TestBed, ComponentFixture, async } from "@angular/core/testing";
import { FirstComponent } from "../ondemand/first.component";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { DebugElement } from "@angular/core";
import { By } from "@angular/platform-browser";

describe("FirstComponent", () => {

    let fixture: ComponentFixture<FirstComponent>;
    let component: FirstComponent;
    let debugElement: DebugElement;
    let divElement: HTMLDivElement;

    let mockRepository = {
        getProducts: function () {
            return [
                new Product(1, "test1", "Soccer", 100),
                new Product(2, "test2", "Chess", 100),
                new Product(3, "test3", "Soccer", 100),
            ]
        }
    }

    beforeEach(async(() => {
        TestBed.configureTestingModule({
            declarations: [FirstComponent],
            providers: [
                { provide: Model, useValue: mockRepository }
            ]
        });
        TestBed.compileComponents().then(() => {
            fixture = TestBed.createComponent(FirstComponent);
            component = fixture.componentInstance;
            debugElement = fixture.debugElement;
            divElement = debugElement.children[0].nativeElement;
        });
    }));

    it("handles mouse events", () => {
        expect(component.highlighted).toBeFalsy();
        expect(divElement.classList.contains("bg-success")).toBeFalsy();
        debugElement.triggerEventHandler("mouseenter", new Event("mouseenter"));
        fixture.detectChanges();
        expect(component.highlighted).toBeTruthy();
        expect(divElement.classList.contains("bg-success")).toBeTruthy();
        debugElement.triggerEventHandler("mouseleave", new Event("mouseleave"));
        fixture.detectChanges();
        expect(component.highlighted).toBeFalsy();
        expect(divElement.classList.contains("bg-success")).toBeFalsy();
    });
});

Listing 29-16.Triggering Events in the first.component.spec.ts File in the src/app/tests Folder

清单中的测试检查组件和模板的初始状态,然后触发mouseentermouseleave事件,检查每个事件的影响。

测试输出属性

测试输出属性是一个简单的过程,因为用来实现它们的EventEmitter对象是可以在单元测试中订阅的Observable对象。清单 29-17 向被测组件添加一个输出属性。

import { Component, HostListener, Output, EventEmitter} from "@angular/core";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";

@Component({
    selector: "first",
    templateUrl: "first.component.html"
})
export class FirstComponent {

    constructor(private repository: Model) {}

    category: string = "Soccer";
    highlighted: boolean = false;

    @Output("pa-highlight")
    change = new EventEmitter<boolean>();

    getProducts(): Product[] {
        return this.repository.getProducts()
            .filter(p => p.category == this.category);
    }

    @HostListener("mouseenter", ["$event.type"])
    @HostListener("mouseleave", ["$event.type"])
    setHighlight(type: string) {
        this.highlighted = type == "mouseenter";
        this.change.emit(this.highlighted);
    }
}

Listing 29-17.Adding an Output Property in the first.component.ts File in the src/app/ondemand Folder

该组件定义了一个名为change的输出属性,用于在调用setHighlight方法时发出一个事件。清单 29-18 显示了一个针对输出属性的单元测试。

import { TestBed, ComponentFixture, async } from "@angular/core/testing";
import { FirstComponent } from "../ondemand/first.component";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { DebugElement } from "@angular/core";
import { By } from "@angular/platform-browser";

describe("FirstComponent", () => {

    let fixture: ComponentFixture<FirstComponent>;
    let component: FirstComponent;
    let debugElement: DebugElement;

    let mockRepository = {
        getProducts: function () {
            return [
                new Product(1, "test1", "Soccer", 100),
                new Product(2, "test2", "Chess", 100),
                new Product(3, "test3", "Soccer", 100),
            ]
        }
    }

    beforeEach(async(() => {
        TestBed.configureTestingModule({
            declarations: [FirstComponent],
            providers: [
                { provide: Model, useValue: mockRepository }
            ]
        });
        TestBed.compileComponents().then(() => {
            fixture = TestBed.createComponent(FirstComponent);
            component = fixture.componentInstance;
            debugElement = fixture.debugElement;
        });
    }));

    it("implements output property", () => {
        let highlighted: boolean;
        component.change.subscribe(value => highlighted = value);
        debugElement.triggerEventHandler("mouseenter", new Event("mouseenter"));
        expect(highlighted).toBeTruthy();
        debugElement.triggerEventHandler("mouseleave", new Event("mouseleave"));
        expect(highlighted).toBeFalsy();
    });
});

Listing 29-18.Testing an Output Property in the first.component.spec.ts File in the src/app/tests Folder

我本可以在单元测试中直接调用组件的setHighlight方法,但是我选择了触发mouseentermouseleave事件,这将间接地激活输出属性。在触发事件之前,我使用subscribe方法从output属性接收事件,然后用它来检查预期的结果。

测试输入属性

测试输入属性的过程需要一些额外的工作。首先,我向用于接收数据模型存储库的FirstComponent类添加了一个输入属性,替换了构造函数接收的服务,如清单 29-19 所示。我还删除了主机事件绑定和输出属性,以保持示例简单。

import { Component, HostListener, Input } from "@angular/core";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";

@Component({
    selector: "first",
    templateUrl: "first.component.html"
})
export class FirstComponent {

    category: string = "Soccer";
    highlighted: boolean = false;

    getProducts(): Product[] {
        return this.model == null ? [] : this.model.getProducts()
            .filter(p => p.category == this.category);
    }

    @Input("pa-model")
    model: Model;
}

Listing 29-19.Adding an Input Property in the first.component.ts File in the src/app/ondemand Folder

使用名为pa-model的属性设置input属性,并在getProducts方法中使用。清单 29-20 展示了如何编写一个针对输入属性的单元测试。

import { TestBed, ComponentFixture, async } from "@angular/core/testing";
import { FirstComponent } from "../ondemand/first.component";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { DebugElement } from "@angular/core";
import { By } from "@angular/platform-browser";
import { Component, ViewChild } from "@angular/core";

@Component({
    template: `<first [pa-model]="model"></first>`
})
class TestComponent {

    constructor(public model: Model) { }

    @ViewChild(FirstComponent)
    firstComponent: FirstComponent;
}

describe("FirstComponent", () => {

    let fixture: ComponentFixture<TestComponent>;
    let component: FirstComponent;
    let debugElement: DebugElement;

    let mockRepository = {
        getProducts: function () {
            return [
                new Product(1, "test1", "Soccer", 100),
                new Product(2, "test2", "Chess", 100),
                new Product(3, "test3", "Soccer", 100),
            ]
        }
    }

    beforeEach(async(() => {
        TestBed.configureTestingModule({
            declarations: [FirstComponent, TestComponent],
            providers: [
                { provide: Model, useValue: mockRepository }
            ]
        });
        TestBed.compileComponents().then(() => {
            fixture = TestBed.createComponent(TestComponent);
            fixture.detectChanges();
            component = fixture.componentInstance.firstComponent;
            debugElement = fixture.debugElement.query(By.directive(FirstComponent));
        });
    }));

    it("receives the model through an input property", () => {
        component.category = "Chess";
        fixture.detectChanges();
        let products = mockRepository.getProducts()
            .filter(p => p.category == component.category);
        let componentProducts = component.getProducts();
        for (let i = 0; i < componentProducts.length; i++) {
            expect(componentProducts[i]).toEqual(products[i]);
        }
        expect(debugElement.query(By.css("span")).nativeElement.textContent)
            .toContain(products.length);
    });
});

Listing 29-20.Testing an Input Property in the first.component.spec.ts File in the src/app/tests Folder

这里的技巧是定义一个组件,它只需要设置测试,并且它的模板包含一个元素,该元素与您想要作为目标的组件的选择器相匹配。在这个例子中,我用在@Component装饰器中定义的内联模板定义了一个名为TestComponent的组件类,该模板包含一个具有pa-model属性的first元素,该元素对应于应用于FirstComponent类的@Input装饰器。

测试组件类被添加到测试模块的declarations数组中,并使用TestBed.createComponent方法创建一个实例。我在TestComponent类中使用了@ViewChild装饰器,这样我就可以获得测试所需的FirstComponent实例。为了获得FirstComponent根元素,我使用了DebugElement.query方法和By.directive方法。

结果是我能够访问组件及其测试的根元素,这将设置category属性,然后验证来自组件的结果以及通过其模板中的数据绑定得到的结果。

使用异步操作进行测试

另一个需要特殊措施的领域是处理异步操作。为了演示这是如何做到的,清单 29-21 修改了被测组件,以便它使用第二十四章中定义的RestDataSource类来获取数据。这不是一个打算在模型特性模块之外使用的类,但是它提供了一组有用的异步方法来返回Observable对象,所以我已经突破了应用的预期结构,以便我可以演示测试技术。

import { Component, HostListener, Input } from "@angular/core";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { RestDataSource } from "../model/rest.datasource";

@Component({
    selector: "first",
    templateUrl: "first.component.html"
})
export class FirstComponent {
    _category: string = "Soccer";
    _products: Product[] = [];
    highlighted: boolean = false;

    constructor(public datasource: RestDataSource) {}

    ngOnInit() {
        this.updateData();
    }

    getProducts(): Product[] {
        return this._products;
    }

    set category(newValue: string) {
        this._category;
        this.updateData();
    }

    updateData() {
        this.datasource.getData()
            .subscribe(data => this._products = data
                .filter(p => p.category == this._category));
    }
}

Listing 29-21.An Async Operation in the first.component.ts File in the src/app/ondemand Folder

组件通过数据源的getData方法获取数据,该方法返回一个Observable对象。组件订阅了Observable,并用数据对象更新了它的_product属性,这些数据对象通过getProducts方法暴露给模板。

清单 29-22 展示了如何使用 Angular 为单元测试中的异步操作提供的工具来测试这类组件。

import { TestBed, ComponentFixture, async, fakeAsync, tick } from "@angular/core/testing";
import { FirstComponent } from "../ondemand/first.component";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { DebugElement } from "@angular/core";
import { By } from "@angular/platform-browser";
import { Component, ViewChild } from "@angular/core";
import { RestDataSource } from "../model/rest.datasource";
import { Observable } from "rxjs";
import { Injectable } from "@angular/core";

@Injectable()
class MockDataSource {
    public data = [
        new Product(1, "test1", "Soccer", 100),
        new Product(2, "test2", "Chess", 100),
        new Product(3, "test3", "Soccer", 100),
    ];

    getData(): Observable<Product[]> {
        return new Observable<Product[]>(obs => {
            setTimeout(() => obs.next(this.data), 1000);
        })
    }
}

describe("FirstComponent", () => {

    let fixture: ComponentFixture<FirstComponent>;
    let component: FirstComponent;
    let dataSource = new MockDataSource();

    beforeEach(async(() => {
        TestBed.configureTestingModule({
            declarations: [FirstComponent],
            providers: [
                { provide: RestDataSource, useValue: dataSource }
            ]
        });
        TestBed.compileComponents().then(() => {
            fixture = TestBed.createComponent(FirstComponent);
            component = fixture.componentInstance;
        });
    }));

    it("performs async op", fakeAsync( () => {
        dataSource.data.push(new Product(100, "test100", "Soccer", 100));

        fixture.detectChanges();

        tick(1000);

        fixture.whenStable().then(() => {
            expect(component.getProducts().length).toBe(3);
        });
    }));
});

Listing 29-22.Testing an Async Operation in the first.component.spec.ts File in the src/app/tests Folder

本例中的模拟对象比我之前创建的对象更加完整,只是为了展示实现相同目标的不同方式。需要注意的重要一点是,它实现的getData方法在返回样本数据之前引入了一秒钟的延迟。

这个延迟很重要,因为这意味着在单元测试中调用detectChanges方法的效果不会立即影响组件。为了模拟时间的流逝,我使用了fakeAsynctick方法,并且为了处理异步变化,我调用了由ComponentFixture类定义的whenStable方法,该方法返回一个Promise,当所有的变化都被完全处理后,该方法将解析。这允许我推迟对测试结果的评估,直到模拟数据源返回的Observable将它的数据交付给组件。

测试 Angular 方向

测试指令的过程类似于测试输入属性所需的过程,因为测试组件和模板用于创建测试环境,在该环境中可以应用指令。为了测试一个指令,我在src/app/ondemand文件夹中添加了一个名为attr.directive.ts的文件,并添加了清单 29-23 中所示的代码。

Note

我在这个例子中展示了一个属性指令,但是本节中的技术同样可以用来测试结构化指令。

import {
    Directive, ElementRef, Attribute, Input, SimpleChange
} from "@angular/core";

@Directive({
    selector: "[pa-attr]"
})
export class PaAttrDirective {

    constructor(private element: ElementRef) { }

    @Input("pa-attr")
    bgClass: string;

    ngOnChanges(changes: { [property: string]: SimpleChange }) {
        let change = changes["bgClass"];
        let classList = this.element.nativeElement.classList;
        if (!change.isFirstChange() && classList.contains(change.previousValue)) {
            classList.remove(change.previousValue);
        }
        if (!classList.contains(change.currentValue)) {
            classList.add(change.currentValue);
        }
    }
}

Listing 29-23.The Contents of the attr.directive.ts File in the src/app/ondemand Folder

这是一个基于第十五章中一个例子的属性指令。为了创建一个以指令为目标的单元测试,我在src/app/tests文件夹中添加了一个名为attr.directive.spec.ts的文件,并添加了清单 29-24 中所示的代码。

import { TestBed, ComponentFixture } from "@angular/core/testing";
import { Component, DebugElement, ViewChild } from "@angular/core";
import { By } from "@angular/platform-browser";
import { PaAttrDirective } from "../ondemand/attr.directive";

@Component({
    template: `<div><span [pa-attr]="className">Test Content</span></div>`
})
class TestComponent {
    className = "initialClass"

    @ViewChild(PaAttrDirective)
    attrDirective: PaAttrDirective;
}

describe("PaAttrDirective", () => {

    let fixture: ComponentFixture<TestComponent>;
    let directive: PaAttrDirective;
    let spanElement: HTMLSpanElement;

    beforeEach(() => {
        TestBed.configureTestingModule({
            declarations: [TestComponent, PaAttrDirective],
        });
        fixture = TestBed.createComponent(TestComponent);
        fixture.detectChanges();
        directive = fixture.componentInstance.attrDirective;
        spanElement = fixture.debugElement.query(By.css("span")).nativeElement;
    });

    it("generates the correct number of elements", () => {
        fixture.detectChanges();
        expect(directive.bgClass).toBe("initialClass");
        expect(spanElement.className).toBe("initialClass");

        fixture.componentInstance.className = "nextClass";
        fixture.detectChanges();
        expect(directive.bgClass).toBe("nextClass");
        expect(spanElement.className).toBe("nextClass");
    });
});

Listing 29-24.The Contents of the attr.directive.spec.ts File in the src/app/tests Folder

文本组件有一个应用指令的内联模板和一个在数据绑定中引用的属性。@ViewChild decorator 提供了对 Angular 在处理模板时创建的 directive 对象的访问,单元测试能够检查更改数据绑定使用的值是否对 directive 对象和它所应用的元素有影响。

摘要

在这一章中,我演示了 Angular 组件和指令进行单元测试的不同方法。我解释了安装测试框架和工具的过程,以及如何创建测试床来应用测试。我演示了如何测试组件的不同方面,以及如何将相同的技术应用于指令。

这就是我要教你的关于 Angular 的一切。我首先创建了一个简单的应用,然后带您全面浏览了框架中的不同构建块,向您展示了如何创建、配置和应用它们来创建 web 应用。

我希望你在你的角项目中取得成功,我只能希望你喜欢读这本书,就像我喜欢写它一样。

第一部分:从 Angular 开始

第二部分:Angular 细节

第三部分:高级 Angular 特性


  1. A-Za-z ↩︎

posted @ 2024-08-13 14:05  绝不原创的飞龙  阅读(22)  评论(0)    收藏  举报