Angular-学习手册第二版-全-

Angular 学习手册第二版(全)

原文:zh.annas-archive.org/md5/6C06861E49CB1AD699C8CFF7BAC7E048

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

自 2010 年以来,我们已经走了很长的路,当时 AngularJS 首次发布。互联网并不是真正用来作为一个应用平台,而是用来呈现静态页面的。当然,随着开发人员开始将其越来越多地视为他们的主要应用平台,这种情况已经发生了改变。能够触达数十亿人的承诺实在太诱人了。这意味着网络必须成熟起来。多年来已经尝试了不同的方法,比如 JSP、GWT、.NET 的 Web Forms 等等,这些方法或多或少地取得了成功。显而易见的是,当 AngularJS 出现时,它被视为救世主。它让每个人都能够快速地使用 JavaScript、CSS、HTML 甚至使用 AJAX 创建应用程序。它仍然是构建小到中型应用程序的有效选择。

某物使用起来越容易,人们就越有可能像番茄酱一样开始不断地添加更多内容并在各处使用它。AngularJS 从来都不是为大型企业应用程序而设计的。互联网不断发展,浏览器中提供了越来越多的功能。有一个想法,希望将所有这些新功能纳入其中,但同时确保 AngularJS 可以用于真正的大型应用程序。做出了一个决定,从头开始创建 Angular,作为 AngularJS 的继任者会更容易。因此,2016 年 9 月 14 日,Angular 的发布版本问世。从那时起,Angular 的主要版本以惊人的速度发布。

我们现在使用的是第 5 版。这并不意味着 Angular 的核心概念已经改变,它们已经被保留下来。在这一过程中引入了某些重大变化,但每个主要版本首先都是为了修复错误,引入新功能,并真正致力于使 Angular 应用程序尽可能快速,占用空间尽可能小。这是在当今以移动为先的世界中值得追求的目标。

本书旨在向读者介绍 Angular 的所有主要方面,并向您展示如何构建小型、中型甚至大型应用程序。您并不需要太多的知识来开始使用 Angular 应用程序,但它有许多层面。随着应用程序规模的增长,您将希望关心如何使其更美观、更快速、更易于维护等等。本书就是以此为出发点编写的。慢慢阅读本书。如果您想读几章并构建一些应用程序,那就去做吧。如果您想直接跳入更高级的功能,那也可以。

我们希望您会像我们写作时一样享受阅读本书。

本书内容包括

第一章,在 Angular 中创建我们的第一个组件,介绍了语义版本控制。这是一个重要的概念,因此您可以根据自己的需求决定是否采用新版本。本章还向读者介绍了 Angular CLI,并且读者将迈出编写 Angular 应用程序的第一步。

第二章,IDE 和插件,向您介绍了最流行的 IDE。还描述了最常见的 Angular 插件和代码片段,以进一步提高开发人员的生产力。

第三章,介绍 TypeScript,介绍了 TypeScript,这是编写 Angular 应用程序的选择语言。TypeScript 不仅仅是添加类型。您的代码可以变得更加优雅和安全,使用正确的功能将为您节省大量输入时间。

第四章,在我们的组件中实现属性和事件,介绍了如何向组件发送数据以及如何将方法绑定到它们,以便组件能够与上游进行通信。

第五章,使用管道和指令增强我们的组件,展示了如何使用管道和指令使您的组件更一致和可重用。

第六章,使用 Angular 组件构建应用程序,直接着手于构建真实应用程序的目标。我们讨论了如何思考以及如何使用最常见的结构指令来控制数据的显示方式,并在被 UI 元素操作时如何行为。

第七章,使用 Angular 进行异步数据服务,介绍了 RxJS 库,它不仅帮助我们处理 AJAX,还促进了反应式应用程序模式。在 RxJS 下,所有异步事物都成为一个概念,这引入的可能性是无限的。

第八章,Firebase,解释了 Firebase,这是谷歌的产品,允许您拥有后端作为服务。Firebase 让您专注于构建 Angular 应用程序,同时它会处理几乎所有其他事情。最好的部分是 Firebase 的反应性,这使得像聊天应用程序和协作应用程序一样轻松创建。

第九章,路由,解释了路由的概念,这样您就可以轻松扩展您的应用程序。

第十章,Angular 中的表单,涵盖了处理表单和用户输入的两种主要方式:基于模板的和反应式方法。

第十一章,Angular Material,带您了解 Angular Material,它不仅提供美观的界面,还配备了一堆组件,使得快速组装令人印象深刻的应用程序变得轻而易举。

第十二章,使用 Angular 对组件进行动画处理,介绍了 Angular 如何支持开发人员利用和控制相当高级的动画。

第十三章,Angular 中的单元测试,解释了 Angular 中的单元测试。Angular 团队确实为测试添加了一流的支持,因此您只需很少的代码,就能测试您的所有可能构造。从组件、服务和指令到端到端测试,应有尽有。

附录 A,SystemJS,介绍了 SystemJS,这是一个模块加载器,曾经是设置 Angular 应用程序的唯一方式。这仍然是设置项目的有效方式。本附录将涵盖 SystemJS 的核心部分,并特别关注 Angular 设置部分。

附录 B,使用 Angular 的 Webpack,旨在向开发人员展示如何使用 Webpack 设置您的 Angular 项目。肯定存在一定数量的用户群体,他们希望完全控制 Web 项目的每个方面。如果您是其中之一,那么这个附录就是为您准备的。

这本书需要什么

为了真正欣赏这本书,我们假设您对 HTML、CSS 和 JavaScript 有一定程度的了解,以及使用 AJAX 调用服务。我们还假设您对 REST 有一定的了解。现代 Web 应用程序开发已经变得非常艰巨,但我们希望在阅读完本书后,您会觉得对正在发生的事情有更多的了解,并且您也会觉得能够承担起使用 Angular 进行下一个 Web 开发项目的责任。

由于您将花费大部分时间编写 JavaScript、HTML 或 CSS 代码,我们只假设您可以访问一个体面的文本编辑器。您使用的编辑器越成熟,您将获得的帮助就越多,这就是为什么我们在本书中介绍了一些插件和最佳实践,以使您的日常工作变得不那么痛苦。

这本书适合谁

这本书适用于没有 Angular 先前知识但在 JavaScript、Node.js、HTML 和 CSS 方面有经验,并且对单页面应用的概念相当熟悉的 Web 开发人员。

惯例

在本书中,您将找到一些文本样式,用于区分不同类型的信息。以下是这些样式的一些示例及其含义的解释。文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名显示如下:“导入响应式Forms模块。”

代码块设置如下:

class AppComponent {
 title:string = 'hello app';
}

任何命令行输入或输出都将按照以下方式编写:

npm install -g @angular/cli

新术语重要单词以粗体显示。屏幕上看到的单词,例如菜单或对话框中的单词,会在文本中以这种方式出现:“我们点击左侧的数据库菜单选项。”

警告或重要说明会以这种方式出现。提示和技巧会以这种方式出现。

第一章:在 Angular 中创建我们的第一个组件

在进行 Angular 开发时,有一些事情是好知道的,还有一些事情是我们需要知道的,以便开始我们伟大的旅程。其中一件好知道的事情是语义化版本控制。这是好知道的,因为这是 Angular 团队选择处理更改的方式。当您前往angular.io/或 Stack Overflow 等网站搜索解决方案时,这将有望使您更容易找到未来应用程序开发挑战的正确解决方案。

另一个重要但有时令人痛苦的话题是项目设置。这是一个必要的恶,需要在项目开始时完成,但在早期正确处理这个问题可以减少随着应用程序的增长而产生的许多摩擦。因此,本章的很大一部分致力于揭开谜团,并使您作为开发人员能够避免未来的挫折和偏头痛。

在本章结束时,我们还将能够创建我们的第一个应用程序,并了解 Angular 应用程序的结构。总之,以下是本章将探讨的主要主题。

在这一章中,我们将:

  • 了解语义化版本控制的重要性,以及 Angular 对其的看法

  • 了解我们如何使用 Angular CLI 设置项目

  • 创建我们的第一个应用程序,并开始了解 Angular 中的核心概念

这只是 Angular-介绍语义化版本控制

使用语义化版本控制是关于管理期望。这是关于管理您的应用程序或库的用户在发生更改时会做出何种反应。更改会因各种原因而发生,无论是修复代码中的错误还是添加/更改/删除功能。框架或库的作者用来传达某个更改的影响的方式是通过增加软件的版本号。

一个可供生产使用的软件通常具有版本 1.0 或 1.0.0(如果您想更具体)。

在更新软件时可能会发生三种不同级别的更改。要么您对其进行修补并有效地纠正某些问题。要么您进行次要更改,这基本上意味着您添加功能。或者最后您进行主要更改,这可能会完全改变软件的工作方式。让我们在接下来的章节中更详细地描述这些变化。

版本更改

补丁变更意味着我们将最右边的数字增加一。将软件从 1.0.0 更改为 1.0.1 是一个小改变,通常是一个错误修复。作为软件的用户,你不需要担心;如果有什么变化,你应该高兴地发现某些东西突然工作得更好了。关键是,你可以放心地开始使用 1.0.1。

小改变

这意味着软件从 1.0.0 增加到 1.1.0。当我们增加中间数字时,我们正在处理更严重的变化。当软件功能被添加时,这个数字应该增加,而且它仍然应该向后兼容。在这种情况下,采用 1.1.0 版本的软件也是安全的。

主要变更

在这个阶段,版本号从 1.0.0 增加到 2.0.0。现在你需要留意了。在这个阶段,事情可能已经发生了很大的变化,构造可能已经被重命名或删除。它可能不兼容早期版本。我说“可能”是因为很多软件作者仍然确保有相当的向后兼容性,但这里的主要观点是没有保证,没有合同,保证它仍然可以工作。

那 Angular 呢?

Angular 的第一个版本大多数人都称为 Angular 1;后来它被称为 AngularJS。它没有使用语义化版本。大多数人实际上仍然将其称为 Angular 1。

然后 Angular 出现了,在 2016 年它达到了生产就绪状态。Angular 决定采用语义化版本,这在开发者社区引起了一些混乱,特别是当宣布将会有 Angular 4 和 5 等版本时。谷歌以及谷歌开发者专家开始向人们解释,他们希望人们称最新版本的框架为 Angular - 只是 Angular。你可以对这个决定的智慧进行争论,但事实仍然是,新的 Angular 正在使用语义化版本。这意味着 Angular 与 Angular 4 以及 Angular 11 等版本是相同的平台,如果有的话。采用语义化版本意味着作为 Angular 用户,你可以依赖事物一直以相同的方式工作,直到谷歌决定增加主要版本。即使在那时,你可以选择是保持在最新的主要版本上,还是想要升级你现有的应用程序。

一个全新的开始

如前所述,Angular 是 AngularJS 框架的全面重写,引入了全新的应用程序架构,完全使用 TypeScript 从头开始构建,TypeScript 是 JavaScript 的严格超集,它增加了可选的静态类型和对接口和装饰器的支持。

简而言之,Angular 应用程序基于一种架构设计,由 Web 组件树组成,它们通过各自特定的 I/O 接口相互连接。每个组件在底层利用了完全改进的依赖注入机制。

公平地说,这是对 Angular 真正含义的简单描述。然而,即使是 Angular 中最简单的项目也符合这些定义特征。在接下来的章节中,我们将专注于学习如何构建可互操作的组件和管理依赖注入,然后再转向路由、Web 表单和 HTTP 通信。这也解释了为什么我们在本书中不会明确提及 AngularJS。显然,浪费时间和页面提及对主题没有任何有用见解的东西是没有意义的,而且我们假设你可能不了解 Angular 1.x,因此这种知识在这里没有任何价值。

Web 组件

Web 组件是一个概念,它包括四种技术,旨在一起使用以构建具有更高视觉表现力和可重用性的功能元素,从而实现更模块化、一致和可维护的 Web。这四种技术如下:

  • 模板:这些是用于构造我们的内容的 HTML 片段

渲染

  • 自定义元素:这些模板不仅包含传统的 HTML 元素,还包括提供更多呈现元素或 API 功能的自定义包装项

  • 影子 DOM:这提供了一个沙盒,用于封装每个自定义元素的 CSS 布局规则和 JavaScript 行为

  • HTML 导入:HTML 不再仅限于承载 HTML 元素,还可以承载其他 HTML 文档

从理论上讲,Angular 组件确实是一个包含模板的自定义元素,用于承载其布局的 HTML 结构,后者由一个封装在影子 DOM 容器中的作用域 CSS 样式表控制。让我们用简单的英语来重新表达一下。想象一下 HTML5 中的 range 输入控件类型。这是一种方便的方式,可以为用户提供一个方便的输入控件,用于输入两个预定义边界之间的值。如果您以前没有使用过它,请在空白的 HTML 模板中插入以下标记,并在浏览器中加载它:

<input id="mySlider" type="range" min="0" max="100" step="10">

在浏览器中,您将看到一个漂亮的输入控件,其中包含一个水平滑块。使用浏览器开发者工具检查这样的控件将揭示一组隐藏的 HTML 标记,这些标记在您编辑 HTML 模板时并不存在。这就是影子 DOM 在起作用,具有由其自己封装的 CSS 控制的实际 HTML 模板,具有高级的拖动功能。您可能会同意,自己做这件事将是很酷的。好消息是,Angular 为您提供了交付这个功能所需的工具集,因此我们可以构建我们自己的自定义元素(输入控件、个性化标记和自包含小部件),其中包含我们选择的内部 HTML 标记和我们自己的样式表,不会受到页面托管我们组件的 CSS 的影响。

为什么选择 TypeScript 而不是其他语法?

Angular 应用程序可以使用多种语言和语法进行编码:ECMAScript 5、Dart、ECMAScript 6、TypeScript 或 ECMAScript 7。

TypeScript 是 ECMAScript 6(也称为 ECMAScript 2015)的类型超集,可以编译成普通的 JavaScript,并得到现代操作系统的广泛支持。它具有健全的面向对象设计,支持注解、装饰器和类型检查。

我们选择(并显然推荐)TypeScript 作为本书中指导如何开发 Angular 应用程序的首选语法的原因是 Angular 本身就是用这种语言编写的。精通 TypeScript 将使开发人员在理解框架的内部机制时具有巨大优势。

另一方面,值得注意的是,当涉及管理依赖注入和组件之间的类型绑定时,TypeScript 对注解和类型内省的支持变得至关重要,因为它可以以最小的代码占用量实现,我们将在本书的后面看到。

最终,如果这是您的偏好,您可以使用纯 ECMAScript 6 语法执行您的 Angular 项目。甚至本书提供的示例也可以通过删除类型注解和接口,或者用最冗长的 ES6 方式替换 TypeScript 中处理依赖注入的方式,轻松地转换为 ES6。

为了简洁起见,我们只会涵盖使用 TypeScript 编写的示例,并实际推荐其使用,因为由于类型注解,它具有更高的表达能力,并且通过基于类型内省的依赖注入的整洁方式。

使用 Angular CLI 设置我们的工作空间

有不同的方法可以开始,可以使用angular.io/网站上的 Angular 快速入门存储库,或安装脚手架工具 Angular CLI,或者最后,您可以使用 Webpack 来设置您的项目。值得指出的是,创建新的 Angular 项目的标准方式是使用Angular CLI并搭建您的项目。快速入门存储库使用的 Systemjs 曾经是构建 Angular 项目的默认方式。它现在正在迅速减少,但仍然是设置 Angular 项目的有效方式。因此,建议感兴趣的读者查看附录 A,SystemJS以获取更多信息。

如今,设置前端项目比以往任何时候都更加繁琐。我们过去只需在我们的 JavaScript 代码中包含必要的脚本,以及用于我们的 CSS 的link标签和用于我们的资产的img标签等。生活过去很简单。然后前端开发变得更加雄心勃勃,我们开始将我们的代码拆分成模块,我们开始使用预处理器来处理我们的代码和 CSS。总的来说,我们的项目变得更加复杂,我们开始依赖构建系统,如 Grunt、Gulp、Webpack 等。大多数开发人员并不是配置的铁杆粉丝,他们只想专注于构建应用程序。然而,现代浏览器更多地支持最新的 ECMAScript 标准,一些浏览器甚至开始支持在运行时解析的模块。尽管如此,这远非得到广泛支持。与此同时,我们仍然必须依赖工具进行捆绑和模块支持。

使用领先的框架(如 React 或 Angular)设置项目可能会非常困难。您需要知道要导入哪些库,并确保文件按正确的顺序处理,这将引入我们的脚手架工具主题。对于 AngularJS,使用 Yeoman 快速搭建新应用程序并预先配置许多好东西是非常流行的。React 有一个名为create-react-app的脚手架工具,您可能已经保存了它,它为 React 开发人员节省了无数小时。随着复杂性的增加,脚手架工具几乎成为必需品,但也是每个小时都用于产生业务价值而不是解决配置问题的地方。

创建 Angular CLI 工具的主要动机是帮助开发人员专注于应用程序构建,而不是太多地关注配置。基本上,通过一个简单的命令,您应该能够快速搭建一个应用程序,向其添加新构造,运行测试,或创建一个生产级捆绑包。Angular CLI 支持所有这些。

先决条件

您需要开始的是安装 Git 和 Node.js。Node.js 还将安装一个称为 NPM 的东西,这是一个您以后将用来安装项目所需文件的节点包管理器。完成后,您就可以设置您的 Angular 应用程序了。您可以在nodejs.org找到 Node.js 的安装文件。

安装它的最简单方法是访问该网站:

https://nodejs.org/en/download/

安装 Node.js 也将安装一个称为 NPM 的东西,即 Node 包管理器,您将需要它来安装依赖项等。Angular CLI 需要 Node 6.9.0 和 NPM 3 或更高版本。目前在该网站上,您可以选择长期支持版本和当前版本。长期支持版本应该足够了。

安装

安装 Angular CLI 就像在您的终端中运行以下命令一样简单:

npm install -g @angular/cli

在某些系统上,您可能需要提升权限才能这样做;在这种情况下,以管理员身份运行您的终端窗口,在 Linux/macOS 上运行以下命令:

sudo npm install -g @angular/cli

第一个应用

一旦安装了 Angular CLI,就到了创建第一个项目的时候。为此,请进入您选择的目录并输入以下内容:

ng new <give it a name here>

输入以下内容:

ng new TodoApp

这将创建一个名为TodoApp的目录。在运行了上述命令之后,您需要做两件事才能在浏览器中看到您的应用程序:

  • 导航到刚创建的目录

  • 提供应用程序

这将通过以下命令完成:

cd TodoApp
npm start

此时,在http://localhost:4200上打开你的浏览器,你应该会看到以下内容:

测试

Angular CLI 不仅提供使您的应用程序工作的代码,还提供设置测试和包含测试的代码。运行所说的测试就像在终端中输入以下内容一样简单:

npm test

你应该会看到以下内容:

为什么这样会起作用?让我们看一下刚刚创建的package.json文件和scripts标签。这里指定的所有内容都可以使用以下语法运行:

npm run <key>

在某些情况下,不需要输入run,只需输入以下内容即可:

npm <key>

这适用于starttest命令。

以下清单清楚地表明,可以运行的命令不仅仅是我们刚刚学到的starttest

"scripts": {
 "ng": "ng",
 "start": "ng serve",
 "build": "ng build",
 "test": "ng test",
 "lint": "ng lint",
 "e2e": "ng e2e"
}

到目前为止,我们已经学会了如何安装 Angular CLI。使用 Angular CLI,我们已经学会了:

  1. 搭建一个新项目。

  2. 启动项目,看看它在浏览器中显示出来。

  3. 运行测试。

这是相当了不起的成就。我们将在后面的章节中重新讨论 Angular CLI,因为它是一个非常有能力的工具,能够做更多的事情。

你好,Angular

我们即将迈出建立我们的第一个组件的第一步。Angular CLI 已经为我们搭建了项目,并且已经完成了大量的繁重工作。我们所需要做的就是创建一个新文件,并开始填充它的内容。百万美元的问题是要输入什么?

所以让我们开始构建我们的第一个组件。创建一个组件需要三个步骤。那就是:

  1. 导入组件装饰器构造。

  2. 用组件装饰器装饰一个类。

  3. 将组件添加到它的模块中(这可能在两个不同的地方)。

创建组件

首先,让我们导入组件装饰器:

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

然后为你的组件创建类:

class AppComponent {
 title:string = 'hello app';
}

然后使用Component装饰器装饰你的类:

@Component({
 selector: 'app',
 template: `<h1>{{ title }}</h1>`
})
export class AppComponent { 
 title: string = 'hello app';
}

我们给Component装饰器,也就是函数,传入一个对象字面量作为输入参数。这个对象字面量目前包括selectortemplate键,所以让我们解释一下它们是什么。

选择器

selector是在模板中引用时应该使用的名称。我们称之为app,我们会这样引用它:

<app></app>

模板/templateUrl

templatetemplateUrl是您的视图。在这里,您可以编写 HTML 标记。在我们的对象字面量中使用template关键字意味着我们可以在与组件类相同的文件中定义 HTML 标记。如果我们使用templateUrl,那么我们将在一个单独的文件中放置我们的 HTML 标记。

上面的示例还列出了标记中的双大括号:

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

这将被视为插值,表达式将被替换为AppComponenttitle字段的值。因此,渲染时,组件将如下所示:

hello app

告诉模块

现在我们需要引入一个全新的概念,一个 Angular 模块。在 Angular 中创建的所有类型的构造都应该在模块中注册。Angular 模块充当对外界的门面,它只是一个由@NgModule装饰的类。就像@Component装饰器一样,@NgModule装饰器以对象字面量作为输入参数。为了将我们的组件注册到 Angular 模块中,我们需要给对象字面量添加declarations属性。declarations属性是一个数组类型,通过将我们的组件添加到该数组中,我们就将其注册到了 Angular 模块。

以下代码显示了创建一个 Angular 模块以及将组件注册到其中的过程,通过将其添加到declarations关键字数组中:

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

@NgModule({ 
  declarations: [AppComponent] 
})
export class AppModule {}

此时,我们的 Angular 模块已经知道了这个组件。我们需要在我们的模块中添加一个属性bootstrapbootstrap关键字表示这里放置的任何内容都作为整个应用程序的入口组件。因为目前我们只有一个组件,所以将我们的组件注册到这个bootstrap关键字是有意义的:

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

确实可以有多个入口组件,但通常情况下只有一个。

然而,对于任何未来的组件,我们只需要将它们添加到declarations属性中,以确保模块知道它们。

到目前为止,我们已经创建了一个组件和一个 Angular 模块,并将组件注册到了该模块。但我们还没有一个可工作的应用程序,因为我们还需要采取一步。我们需要设置引导。

设置一个引导文件

main.ts文件是您的引导文件,它应该具有以下内容:

import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';

platformBrowserDynamic().bootstrapModule(AppModule);

在前面的代码片段中,我们所做的是将最近创建的模块作为输入参数传递给方法调用bootstrapModule()。这将有效地使该模块成为应用程序的入口模块。这就是我们创建一个工作应用程序所需的全部。让我们总结一下我们所采取的步骤:

  1. 创建一个组件。

  2. 创建一个模块,并在其声明属性中注册我们创建的组件。

  3. 还要在模块的 bootstrap 属性中注册我们的组件,以使其成为应用程序的入口点。我们将来创建的其他组件只需要添加到declarations属性中即可。

  4. 通过将所创建的模块作为输入参数传递给bootstrapModule()方法来引导我们创建的模块。

到目前为止,作为读者的你已经不得不吞下大量的信息,并相信我们的话。别担心,你将有机会在本章以及接下来的章节中更加熟悉组件和 Angular 模块。目前,重点只是让你快速上手,通过提供 Angular CLI 这个强大的工具,向你展示实际上只需要几个步骤就可以将应用程序渲染到屏幕上。

深入了解 Angular 组件

我们已经走了很长的路,从第一次接触 TypeScript 到学习如何编写 Angular 组件的基本脚本结构。然而,在跳入更抽象的主题之前,让我们尝试构建另一个组件,这样我们就真正掌握了创建组件的工作原理。

组件方法和数据更新

在相同的文件夹中创建一个新的timer.component.ts文件,并用以下非常简单的组件基本实现填充它。不要担心增加的复杂性,因为我们将在代码块之后审查每一次更改:

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

@Component({
 selector: 'timer',
 template: `<h1>{{ minutes }}:{{ seconds }} </h1>>`
})
export class TimerComponent {
 minutes: number;
 seconds: number;

 constructor(){
 this.minutes = 24;
 this.seconds = 59;
 }
}

到目前为止,我们通过创建TimerComponent类并用@Component装饰它,创建了一个全新的组件,就像我们在之前的部分学到的那样。我们在之前的部分学到,还有更多要做的,即告诉 Angular 模块这个新组件存在。Angular 模块已经创建好了,所以你只需要将我们的新组件添加到它的declarations属性中,就像这样:

@NgModule({
 declarations: [
 AppComponent, TimerComponent
 ],
 bootstrap: [AppComponent]
})

只要我们只有AppComponent,我们并没有真正看到拥有一个 Angular 模块的意义。有了两个组件在我们的模块中注册,这一点就改变了。当一个组件与 Angular 模块注册时,它就可以被模块中的其他构造使用。它可以被它们的template/templateUrl使用。这意味着我们可以在AppComponent中渲染TimerComponent

因此,让我们回到我们的AppComponent文件,并更新其模板以显示这一点:

@Component({
 selector: 'app',
 template: `<h1>{{ title }}</h1> <timer></timer>`
})
export class AppComponent { 
 title: string = 'hello app';
}

在前面的代码中,我们用粗体突出显示了如何将TimerComponent添加到AppComponents模板中。或者我们通过其selector属性名称timer来引用TimerComponent

让我们再次展示TimerComponent,并且突出显示selector属性,因为这是一个非常重要的事情要理解;也就是说,如何将一个组件放置在另一个组件中:

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

@Component({
  selector: 'timer',
 template: `<h1>{{ minutes }}:{{ seconds }} </h1>>`
})
export class TimerComponent {
 minutes: number;
 seconds: number;

 constructor(){
 this.minutes = 24;
 this.seconds = 59;
 }
}

我们想要做的不仅仅是显示一些数字,对吧?我们实际上希望它们代表一个倒计时,我们可以通过引入这些更改来实现这一点。让我们首先引入一个我们可以迭代的函数,以便更新倒计时。在构造函数之后添加这个函数:

tick() {
 if(--this.seconds < 0) {
 this.seconds = 59;
 if(--this.minutes < 0) {
 this.minutes = 24;
 this.seconds = 59;
 }
 }
}

Angular 中的选择器是区分大小写的。正如我们将在本书的后面看到的那样,组件是指令的一个子集,可以支持各种选择器。在创建组件时,我们应该通过强制使用破折号命名约定在selector属性中设置一个自定义标签名称。在视图中呈现该标记时,我们应该始终将标记关闭为非 void 元素。因此,<custom-element></custom-element>是正确的,而<custom-element />将触发异常。最后但同样重要的是,某些常见的驼峰命名可能会与 Angular 实现发生冲突,因此应避免使用它们。

从静态到实际数据

正如你在这里看到的,TypeScript 中的函数需要用它们返回的值的类型进行注释,或者如果没有值,则只需使用 void。我们的函数评估了分钟和秒钟的当前值,然后要么减少它们的值,要么将其重置为初始值。然后通过从类构造函数触发时间间隔来每秒调用此函数:

constructor() {
 this.minutes = 24;
 this.seconds = 59;
 setInterval(() => this.tick(), 1000);
}

在这里,我们在我们的代码中第一次发现了箭头函数(也称为 lambda 函数,fat arrow 等),这是 ECMAScript 6 带来的新的函数语法,我们将在第三章中更详细地介绍它,介绍 TypeScripttick函数也被标记为私有,因此它不能在PomodoroTimerComponent对象实例之外被检查或执行。

到目前为止一切顺利!我们有一个工作中的番茄工作计时器,从 25 分钟倒数到 0,然后重新开始。问题是我们在这里和那里复制了代码。因此,让我们稍微重构一下,以防止代码重复:

constructor() {
 this.reset();
 setInterval(() => this.tick(), 1000);
}

reset() {
 this.minutes = 24;
 this.seconds = 59;
}

private tick() {
 if(--this.seconds < 0) {
 this.seconds = 59;
 if(--this.minutes < 0) {
 this.reset();
 }
 }
}

我们已经将分钟和秒的初始化(和重置)包装在我们的resetPomodoro函数中,该函数在实例化组件或倒计时结束时被调用。不过等一下!根据番茄工作法,番茄工作者可以在番茄工作时间之间休息,甚至在意外情况发生时暂停。我们需要提供某种交互性,以便用户可以启动、暂停和恢复当前的番茄工作计时器。

向组件添加交互性

Angular 通过声明式接口提供了一流的事件支持。这意味着很容易连接事件并将其指向方法。将数据绑定到不同的 HTML 属性也很容易,你即将学到。

首先修改我们的模板定义:

@Component({
 selector: 'timer',
 template: `
 <h1>{{ minutes }}: {{ seconds }} </h1>
 <p>
 <button (click)="togglePause()"> {{ buttonLabel }}</button>
 </p>
 `
})

我们使用了多行文本字符串!ECMAScript 6 引入了这个概念。

模板字符串,它是支持嵌入表达式、插入文本绑定和多行内容的字符串文字。我们将在第三章中更详细地了解它们,介绍 TypeScript

与此同时,只需专注于我们引入了一个新的 HTML 块,其中包含一个带有事件处理程序的按钮,该处理程序监听点击事件并在点击时执行togglePause()方法。这个(click)属性可能是你以前没有见过的,尽管它完全符合 W3C 标准。再次强调,我们将在第四章中更详细地介绍这个内容,在我们的组件中实现属性和事件。让我们专注于togglePause()方法和新的buttonLabel绑定。首先,让我们修改我们的类属性,使其看起来像这样:

export class TimerComponent {
 minutes: number;
 seconds: number;
 isPaused: boolean;
 buttonLabel: string;
 // rest of the code will remain as it is below this point
}

我们引入了两个新字段。第一个是buttonLabel,其中包含稍后将显示在我们新创建的按钮上的文本。isPaused是一个新创建的变量,将根据计时器的状态而假设一个true/false值。因此,我们可能需要一个地方来切换这个字段的值。让我们创建我们之前提到的togglePause()方法:

togglePause() {
 this.isPaused = !this.isPaused;
 // if countdown has started
 if(this.minutes < 24 || this.seconds < 59) {
 this.buttonLabel = this.isPaused ? 'Resume' : 'Pause';
 }
}

简而言之,togglePause()方法只是将isPaused的值切换到相反的状态,然后根据这样一个新值以及计时器是否已启动(这将意味着任何时间变量的值低于初始化值)或者没有,我们为按钮分配不同的标签。

现在,我们需要初始化这些值,似乎没有比这更好的地方。因此,reset()函数是初始化影响我们类状态的变量的地方:

reset() {
 this.minutes = 24;
 this.seconds = 59;
 this.buttonLabel = 'Start';
 this.togglePause();
}

每次执行togglePause()时,我们都会重置它,以确保每当它达到需要重置的状态时,倒计时行为将切换到先前的相反状态。控制倒计时的控制器方法中只剩下一个调整:

private tick() {
 if(!this.isPaused) {
 this.buttonLabel = 'Pause';
 if(--this.seconds < 0) {
 this.seconds = 59;
 if(--this.minutes < 0) {
 this.reset();
 }
 }
 }
}

显然,当计时器应该暂停时,我们不希望倒计时继续,因此我们将整个脚本包装在一个条件中。除此之外,当倒计时没有暂停时,我们将希望在按钮上显示不同的文本,并且当倒计时达到结束时再次显示;停止然后重置 Pomodoro 到其初始值将是预期的行为。这加强了在resetPomodoro中调用togglePause函数的必要性。

改进数据输出

到目前为止,我们已经重新加载了浏览器,并尝试了新创建的切换功能。然而,显然还有一些需要一些润色的地方:当秒表显示的秒数小于 10 时,它显示的是一个单个数字,而不是我们在数字时钟和手表中习惯看到的两位数。幸运的是,Angular 实现了一组声明性辅助工具,可以格式化我们模板中的数据输出。我们称它们为管道,我们将在第四章中详细介绍它们,在我们的组件中实现属性和事件。目前,让我们在我们的组件模板中引入数字管道,并将其配置为始终显示两位数的秒数输出。更新我们的模板,使其看起来像这样:

@Component({
 selector: 'timer',
 template: `
 <h1>{{ minutes }}: {{ seconds | number: '2.0' }}</h1>
 <p>
 <button (click)="togglePause()">{{ buttonLabel }}</button>
 </p>
 `
})

基本上,我们在模板中的插值绑定后面加上了管道名称,用管道(|)符号分隔,因此得名。重新加载模板,您将看到秒数始终显示两位数,而不管它所代表的值如何。

我们已经创建了一个完全功能的番茄工作法定时器小部件,我们可以重复使用或嵌入到更复杂的应用程序中。第六章,使用 Angular 组件构建应用程序,将指导我们在更大的组件树的上下文中嵌入和嵌套我们的组件的过程。

与此同时,让我们添加一些 UI 美化,使我们的组件更具吸引力。我们已经在按钮标签中引入了一个 class 属性,以期待在项目中实现 Bootstrap CSS 框架。让我们导入通过 npm 安装项目依赖时下载的实际样式表。打开timer.html,并在<head>元素的末尾添加以下片段:

<link href="http://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/CSS/bootstrap.min.CSS" rel="stylesheet" integrity="sha384-1q8mTJOASx8j1Au+a5WDVnPi2lkFfwwEAa8hDDdjZlpLegxhjVME1fgjWPGmkzs7" crossorigin="anonymous">

现在,让我们通过在我们的组件之前插入一个漂亮的页面标题来美化我们的 UI:

<body>
 <nav class="navbar navbar-default navbar-static-top">
 <div class="container">
 <div class="navbar-header">
 <strong class="navbar-brand">My Timer</strong>
 </div>
 </div>
 </nav>
</body>

调整组件按钮的 Bootstrap 按钮类将赋予它更多个性,将整个模板包裹在一个居中容器中将确实增强 UI。所以让我们更新我们的模板,使其看起来像这样:

<div class="text-center">
 <img src="assets/img/timer.png" alt="Timer">
 <h1> {{ minutes }}:{{ seconds | number:'2.0' }}</h1>
 <p>
 <button class="btn btn-danger" (click)="togglePause()">{{ buttonLabel }}</button>
 </p>
</div>

总结

根据现代网络标准,我们研究了 Web 组件以及 Angular 组件如何提供简单直接的 API 来构建我们自己的组件。我们介绍了 TypeScript 及其语法的一些基本特性,作为第三章《介绍 TypeScript》的准备工作。我们看到了如何设置我们的工作空间,以及在哪里找到我们需要的依赖项,将 TypeScript 引入项目并在项目中使用 Angular 库,了解了每个依赖项在我们应用程序中的作用。

我们的第一个组件教会了我们创建组件的基础知识,也让我们更加熟悉另一个重要概念,Angular 模块,以及如何引导应用程序。我们的第二个组件让我们有机会讨论控制器类的形式,其中包含属性字段、构造函数和实用函数,以及为什么元数据注解在 Angular 应用程序的上下文中如此重要,以定义我们的组件将如何在其所在的 HTML 环境中集成。我们的第一个 Web 组件具有自己的模板,这些模板以变量插值的形式声明性地托管属性绑定,通过管道方便地格式化。绑定事件监听器现在比以往任何时候都更容易,其语法符合标准。

下一章将详细介绍我们需要了解的所有 TypeScript 特性,以便迅速掌握 Angular。

第二章:IDE 和插件

在继续我们对 Angular 的旅程之前,是时候看看 IDE 了。当涉及到进行敏捷工作流程时,我们最喜欢的代码编辑器可以成为无与伦比的盟友,其中包括运行时的 TypeScript 编译、静态类型检查和内省,以及代码完成和可视化辅助调试和构建我们的应用程序。话虽如此,让我们重点介绍一些主要的代码编辑器,并概览它们在开发 Angular 应用程序时如何帮助我们。如果您只是满足于从命令行触发 TypeScript 文件的编译,并且不想获得可视化的代码辅助,请随意跳到下一节。否则,直接跳转到涵盖您选择的 IDE 的下一节。

在这一章中,您将学习以下内容:

  • 最常见的编辑器

  • 安装和配置插件以提高您的生产力

  • 了解一些代码片段,这些代码片段将使您成为一个更快的编码人员,因为它们为您提供了最常见情况下的现成代码。

IDE

集成开发环境IDE)是我们用来指代比记事本或简单编辑器更强大的东西的术语。编写代码意味着我们有不同的要求,如果我们要写一篇文章的话。编辑器需要能够指示我们输入错误,为我们提供有关我们的代码的见解,或者最好是给我们所谓的自动完成,一旦我们开始输入其开头字母,它就会给我们一个方法列表。编码编辑器可以而且应该是您最好的朋友。对于前端开发,有很多很好的选择,没有哪个环境真的比其他环境更好;这取决于哪种对您最有效。让我们踏上发现之旅,让您来判断哪种环境最适合您。

Atom

由 GitHub 开发,高度可定制的环境和安装新包的便利性已经使 Atom 成为许多人的首选 IDE。

为了在编写 Angular 应用程序时优化 TypeScript 的体验,您需要安装 Atom TypeScript 包。您可以通过 APM CLI 安装,也可以使用内置的包安装程序。包含的功能与在安装了 Microsoft 包后在 Sublime 中的功能基本相同:自动代码提示、静态类型检查、代码内省或保存时自动构建等。除此之外,该包还包括一个方便的内置tsconfig.json生成器。

Sublime Text 3

这可能是当今最广泛使用的代码编辑器之一,尽管最近失去了一些动力,用户更青睐其他新兴竞争对手,如 GitHub 自己的 Atom。如果这是您的首选编辑器,我们将假设它已经安装在您的系统上,并且您还安装了 Node(这是显而易见的,否则,您首先无法通过 NPM 安装 TypeScript)。为了提供对 TypeScript 代码编辑的支持,您需要安装微软的 TypeScript 插件,可在github.com/Microsoft/TypeScript-Sublime-Plugin上找到。请参考此页面以了解如何安装插件以及所有快捷键和键映射。

安装成功后,只需按下Ctrl + Space Bar 即可根据类型内省显示代码提示。除此之外,我们还可以通过按下F7功能键触发构建过程,并将文件编译为我们正在工作的 JavaScript。实时代码错误报告是另一个可以从命令菜单中启用的花哨功能。

Webstorm

这款由 IntelliJ 提供的优秀代码编辑器也是基于 TypeScript 编写 Angular 应用程序的不错选择。该 IDE 内置支持 TypeScript,因此我们可以从第一天开始开发 Angular 组件。WebStorm 还实现了一个内置的转译器,支持文件监视,因此我们可以将 TypeScript 代码编译为纯粹的 JavaScript,而无需依赖任何第三方插件。

Visual Studio Code

由 Microsoft 支持的代码编辑器 Visual Studio Code 正在成为 Angular 中的一个严肃竞争者,主要是因为它对 TypeScript 的出色支持。TypeScript 在很大程度上是由 Microsoft 推动的项目,因此有意为其流行的编辑器之一内置对该语言的支持是有道理的。这意味着我们可能想要的所有不错的功能已经内置,包括语法和错误高亮显示以及自动构建。

使 Visual Studio 变得如此出色的真正原因不仅仅是其设计和易用性,还有许多插件可供选择,对于 Angular 开发来说有一些非常棒的插件,让我们来看看其中的一些领先者。

Angular 语言服务

通过搜索Angular 语言,您可以获得与之匹配的插件列表。安装排在前面的插件。

完成后,您将通过以下方式丰富 Visual Studio Code:

  • 代码完成

  • 转到定义

  • 快速信息

  • AOT 诊断消息

只是为了演示其能力,让我们像这样向我们的代码添加一个描述字段:

现在让我们编辑模板,并意识到我们在模板中有代码完成:

当我们开始输入时,会显示一个视觉指示器,并为我们提供完成单词的选项,如果我们选择建议的文本。另一个强大的功能是支持悬停在字段名称上,单击它,然后转到它所属的组件类。这使得快速查找定义变得非常容易。这被称为转到定义功能。要使用该功能,您只需悬停在名称上,然后在 Mac 上按住命令按钮。正如前面所述,非常简单,非常强大。

Typescript Hero

要使用此插件,只需像这样开始编码,并单击左侧的灯泡图标,以自动将导入添加到您的文件中:

具有体面的代码完成和导入是必不可少的,除非您喜欢磨损手指。还有一些代码片段和代码片段,可以让您的编码速度更快。

Angular 5 Typescript 代码片段(Dan Wahlin,John Papa)

这是一个非常强大的插件。它带有三种不同类型的代码片段:

  • Angular 片段

  • RxJS 片段

  • HTML 片段

它的工作方式如下。输入一个片段快捷方式,当被要求时按Enter,代码将被添加:

a-component

Enter将得到以下代码:

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

@Component({
 selector: 'selector-name',
 templateUrl: 'name.component.html'
})
export class NameComponent implements OnInit {
 constructor() {}

 ngOnInit(){}
}

正如你所看到的,你几乎不费吹灰之力就能得到大量的代码。总共有 42 个片段,它们都列在 Visual Studio 的插件描述中。

还有很多插件,但这些将在刚开始时产生真正的影响。这一切都是关于高效生产,而不是浪费时间输入不必要的字符。

总结

本章的重点是试图让你作为软件开发者更有能力。编辑器有很多选择,其中一些我们选择了更详细地介绍。还有许多插件和片段可以节省不少按键。归根结底,你的重点和精力应该花在解决问题和构建解决方案上,而不是让手指累坏。当然,你可以下载更多的插件、片段和快捷方式,但这些是一个很好的开始。我们鼓励你更多地了解你的编辑器及其可能性,因为这将使你更快速、更高效。

在下一章中,你将学习有关 Typescript 的所有内容,从基础到专业水平。本章将涵盖引入类型解决了什么问题,以及语言结构本身。Typescript 作为 JavaScript 的超集,包含了许多强大的概念,并且与 Angular 框架非常契合,你即将发现。

第三章:介绍 TypeScript

在上一章中,我们构建了我们的第一个组件,并使用 TypeScript 来塑造代码脚本,从而赋予其形式。本书中的所有示例都使用其语法。正如我们将在本书中看到的,使用 TypeScript 编写我们的脚本并利用其静态类型将使我们在其他脚本语言上具有显着优势。

本章不是对 TypeScript 语言的全面概述。我们将只关注语言的核心元素,并在我们学习 Angular 的过程中详细研究它们。好消息是,TypeScript 并不那么复杂,我们将设法涵盖它的大部分相关部分。

在本章中,我们将:

  • 看看 TypeScript 背后的背景和原理

  • 发现在线资源,练习学习

  • 回顾类型化值的概念以及如何表示它们

  • 构建我们自己的类型,基于类和接口

  • 学会更好地组织我们的应用架构与模块

理解 TypeScript 的案例

早期 JavaScript 驱动的小型 Web 应用程序的自然演变

将厚重的单片客户端揭示了 ECMAScript 5 JavaScript 规范的缺陷。简而言之,一旦规模和复杂性增加,大规模 JavaScript 应用程序就会遭受严重的可维护性和可扩展性问题。

随着新的库和模块需要无缝集成到我们的应用程序中,这个问题变得更加重要。缺乏良好的互操作机制导致了一些非常繁琐的解决方案,似乎从未符合要求。

作为对这些问题的回应,ECMAScript 6(也称为 ES6 或 ES2015)承诺通过引入更好的模块加载功能、改进的语言架构以更好地处理作用域,并引入各种语法糖来更好地管理类型和对象,来解决这些可维护性和可扩展性问题。基于类的编程的引入成为了在构建大规模应用程序时采用更 OOP 方法的机会。

微软接受了这一挑战,花了近两年的时间构建了一种语言的超集,结合了 ES6 的约定,并借鉴了 ES7 的一些提案。其想法是推出一些有助于通过静态类型检查、更好的工具和代码分析来构建企业应用程序的东西,以际降低错误率。

在由 C#首席架构师 Anders Hejlsberg 领导的两年开发之后,TypeScript 0.8 终于在 2012 年推出,并在两年后达到了 1.0 版本。TypeScript 不仅领先于 ECMAScript 6,而且还实现了相同的功能,并通过类型注释引入了可选的静态类型,从而确保了编译时的类型检查。这有助于在开发过程的早期阶段捕获错误。声明文件的支持也为开发人员提供了描述其模块接口的机会,以便其他开发人员可以更好地将其集成到其代码工作流程和工具中。

TypeScript 的好处

以下信息图提供了对不同功能的俯视。

区分 ECMAScript 6 和 ECMAScript 5,然后区分 TypeScript 与这两者。

作为 ECMAScript 6 的超集,采用 TypeScript 在下一个项目中的主要优势之一是低入门门槛。如果你了解 ECMAScript 6,那么你几乎已经具备了一切,因为 TypeScript 中的所有附加功能都是可选的。你可以选择并引入在实践中帮助你实现目标的功能。总的来说,有很多有力的论点支持在下一个项目中倡导使用 TypeScript,所有这些显然也适用于 Angular。以下是一些论点的简要概述,仅举几例:

  • 用类型注释我们的代码可以确保不同代码单元的一致集成,并提高代码的可读性和理解性。

  • TypeScript 的内置类型检查器将在运行时分析您的代码,并帮助您在执行代码之前防止错误。

  • 使用类型可以确保应用程序的一致性。与前两者结合使用,从长远来看,整体代码错误的印记得到最小化。

  • TypeScript 通过类字段、私有成员、枚举等长期需求的功能扩展了类。

  • 使用装饰器为我们打开了以前无法企及的方式来扩展我们的类和实现。

  • 创建接口和类型定义文件(本书不涉及)确保了我们的库在其他系统和代码库中的平稳无缝集成。

  • TypeScript 在商店中不同 IDE 的支持非常好,我们可以从代码高亮、实时类型检查和自动编译中受益,而且没有任何成本。

  • TypeScript 的语法肯定会让来自其他背景(如 Java、C#、C ++等)的开发人员感到满意。

在野外介绍 TypeScript 资源

现在,我们将看看在哪里可以获得更多支持来学习和测试我们对 TypeScript 的新知识。

TypeScript 官方网站

显然,我们首先要去官方网站了解这门语言:www.typescriptlang.org。在那里,我们可以找到更详尽的语言介绍以及 IDE 和企业支持者的链接。然而,我们肯定会经常回顾的最重要部分是学习部分和 play 沙盒。

学习部分为我们提供了快速教程,让我们迅速掌握这门语言。这可能是对我们在上一章讨论的内容的一个回顾,但我们建议您跳过它,转而查看示例页面和语言规范,后者是指向 GitHub 上语言完整广泛文档的直接链接。这对新用户和有经验的用户都是无价的资源。

play 部分提供了一个方便的沙盒,包括一些现成的代码示例,涵盖了语言的一些最常见特性。我们鼓励您利用这个工具来测试我们在本章中将看到的代码示例。

TypeScript 官方 wiki

在上一章中,当我们谈到使用 TypeScript 编译器 API 执行命令时,我们提到了 TypeScript 的 wiki 中最基本的参数。

TypeScript 的代码完全开源在 GitHub 上,微软团队在存储库网站上提供了对代码不同方面的良好文档。我们鼓励您随时查看,如果您有问题或想深入了解语言特性或语法方面的任何内容。

wiki 位于:github.com/Microsoft/TypeScript/wiki

TypeScript 中的类型

使用 TypeScript 或任何其他编程语言基本上意味着使用数据,这些数据可以表示不同类型的内容。这就是我们所知的类型,一个用来表示这样的数据可以是文本字符串、整数值或这些值类型的数组等的名词。这对 JavaScript 来说并不新鲜,因为我们一直在隐式地使用类型,但是以一种灵活的方式。这意味着任何给定的变量都可以假定(或返回,在函数的情况下)任何类型的值。有时,这会导致我们的代码出现错误和异常,因为我们的代码返回的类型与我们期望的类型发生了冲突。虽然这种灵活性仍然可以通过我们将在本章后面看到的任何类型来强制执行,但是静态地为我们的变量标注类型可以给我们和我们的 IDE 提供一个很好的图片,说明我们应该在每个代码实例中找到什么样的数据。这成为在编译时帮助我们调试应用程序的无价方式,而不至于为时已晚。要调查语言特性的工作原理,我建议您使用游乐场,有两个原因。第一个原因是学习该功能的工作原理。第二个原因是了解它产生的相应的 ES5 代码。我建议使用以下游乐场进行此操作:www.typescriptlang.org/play/.

字符串

我们代码中可能最广泛使用的原始类型之一将是字符串类型,我们用一段文本填充一个变量。

var brand: string = 'Chevrolet';

检查变量名称旁边的类型赋值,用冒号符号分隔。这就是我们在 TypeScript 中注释类型的方式,就像我们在上一章中看到的那样。

回到字符串类型,我们可以使用单引号或双引号,与 ECMAScript6 相同。我们可以使用相同类型定义支持文本插值的多行文本字符串,使用占位变量:

var brand: string = 'Chevrolet';
var message: string = `Today it's a happy day! I just bought a new ${brand} car`;

声明我们的变量 - ECMAScript 6 的方式

TypeScript 作为 ECMAScript 6 的超集,支持表达性声明名词,比如let,它告诉我们变量的作用域是最近的封闭块(函数for循环或任何封闭语句)。另一方面,const是一个指示,这种方式声明的值一旦被填充就应该始终具有相同的类型或值。在本章的其余部分,我们将强制使用传统的var符号来声明变量,但请记住在适当的地方使用letconst

let 关键字

在代码中的许多情况下,我一直在使用var来声明对象、变量和其他构造。但是在 ES6 或 TypeScript 中开始时,这是不被鼓励的。这是有原因的,因为 ES5 只有方法作用域。对于大多数从其他语言转到 JavaScript 的开发人员来说,这可能有点震惊。首先,我们所说的函数作用域是什么意思?我们的意思是变量在函数的上下文中是唯一的,就像这样:

function test() {
 var a;
}

在该函数中不能有其他变量a。如果你声明了更多的变量,那么你将有效地重新定义它。好的,但是什么时候作用域不起作用呢?例如,在for-循环中就没有作用域。在 Java 中,你会这样写:

for (int i = 0; i < arr.length; i++) {
}

在 Java 中,你会知道变量i永远不会泄漏到for-循环之外,你可以这样写:

int i = 3;
for (int i = 0; i < arr.length; i++) {
}

并且要知道for-循环之外的变量i不会影响for-循环内的变量i,它们会被分隔或作用域化,就像它被称为的那样。好的,所以 ES5 JavaScript 的用户已经有了这个语言缺陷很长时间了,最近 ES6 和 Typescript 分别添加了一个修复这个问题的方法,即let关键字。像这样使用它:

let i = 3;
for (let i = 0; i < arr.length; i++) {
}

这样运行的原因是 TypeScript 编译器将其转换为以下 ES5 代码:

var i = 3;
for (var i_1 = 0; i_1 < arr.length; i_1++) {
}

编译器基本上会在for-循环中重新命名变量,以防发生名称冲突。所以记住,不再使用var,当有疑问时只需使用let关键字。

Const

const关键字是一种方式,让你传达这些数据永远不应该被改变。随着代码库的增长,很容易发生错误的更改;这样的错误可能是代价高昂的。为了在编译时支持这一点,const关键字可以帮助你。以以下方式使用它:

const PI = 3.14;
PI = 3 // not allowed

编译器甚至会指出不允许这样做,并显示以下消息:

Cannot assign to PI because it is a constant or a read-only property

这里需要注意一点:这仅适用于顶层。如果您将对象声明为const,则需要注意这一点:

const obj = {
 a : 3
}
obj.a = 4; // actually allowed

声明objconst并不会冻结整个对象,而是obj指向的内容。因此,以下内容将不被允许:

obj = {}

在这里,我们积极改变了obj指向的内容,而不是它的一个子属性,因此这是不允许的,你会得到与之前相同的编译错误。

数字

数字可能是除了字符串和布尔值之外最常见的原始数据类型。与 JavaScript 一样,数字定义了浮点数。数字类型还定义了十六进制、十进制、二进制和八进制文字:

var age: number = 7;
var height: number = 5.6;

布尔值

布尔类型定义了可以是TrueFalse的数据,表示条件的满足:

var isZeroGreaterThanOne: boolean = false;

数组

将错误的成员类型分配给数组,并处理由此引起的异常,现在可以通过Array类型轻松避免,我们在其中描述了仅包含某些类型的数组。语法只需要在类型注释中使用后缀[],如下所示:

var brand: string[] = ['Chevrolet', 'Ford', 'General Motors'];
var childrenAges: number[] = [8, 5, 12, 3, 1];

如果我们尝试向childrenAges数组添加一个类型不是数字的新成员,运行时类型检查器将抱怨,确保我们的类型成员保持一致,我们的代码是无错误的。

使用 any 类型的动态类型

有时,很难根据我们在某一时刻拥有的信息推断数据类型,特别是当我们将遗留代码移植到 TypeScript 或集成松散类型的第三方库和模块时。不用担心,TypeScript 为我们提供了一个方便的类型来处理这些情况。any类型与所有其他现有类型兼容,因此我们可以使用它对任何数据值进行类型标注,并在以后分配任何值给它。然而,这种强大的功能也伴随着巨大的责任。如果我们绕过静态类型检查的便利,我们就会在通过我们的模块传递数据时打开类型错误的大门,我们将需要确保整个应用程序的类型安全:

var distance: any;
// Assigning different value types is perfectly fine
distance = '1000km':
distance = '1000'
// Allows us to seamlessly combine different types
var distance: any[] = ['1000km', '1000'];

空值和未定义的 JavaScript 文字需要特别提到。简而言之,它们在any类型下进行了类型化。这样以后就可以将这些文字分配给任何其他变量,而不管其原始类型如何。

自定义类型

在 Typescript 中,如果需要,您可以使用以下方式使用type关键字自定义类型:

type Animal = 'Cheetah' | 'Lion';

现在我们创建的是一个具有x个允许值的类型。让我们从这种类型创建一个变量:

var animal: Animal = 'Cheetah';

这是完全允许的,因为 Cheetah 是允许的值之一,并且按预期工作。有趣的部分发生在我们给变量赋予它不期望的值时:

var animal: Animal = 'Turtle';

这导致了以下编译器错误:

error TS2322: Type '"Turtle"' is not assignable to type 'Animal'.

Enum

Enum 基本上是一组唯一的数值,我们可以通过为每个数值分配友好的名称来表示它们。枚举的用途不仅限于为数字分配别名。我们可以将它们用作以方便和可识别的方式列出特定类型可以假定的不同变化的方法。

枚举使用 enum 关键字声明,不使用 var 或任何其他变量声明名词,并且它们从 0 开始编号成员,除非为它们分配了显式的数值:

enum Brands { Chevrolet, Cadillac, Ford, Buick, Chrysler, Dodge };
var myCar: Brands = Brands.Cadillac;

检查 myCar 的值将返回 1(这是 enumCadillac 所持有的索引)。正如我们已经提到的,我们可以在 enum 中分配自定义数值:

enum BrandsReduced { Tesla = 1, GMC, Jeep };
var myTruck: BrandsReduced = BrandsReduced.GMC;

检查 myTruck 将产生 2,因为第一个枚举值已经设置为 1。只要这些值是整数,我们就可以将值分配给所有的 enum 成员:

enum StackingIndex {
 None = 0,
 Dropdown = 1000,
 Overlay = 2000,
 Modal = 3000
};
var mySelectBoxStacking: StackingIndex = LayerStackingIndex.Dropdown;

最后值得一提的一个技巧是查找与给定数值映射的枚举成员的可能性:

enum Brands { Chevrolet, Cadillac, Ford, Buick, Chrysler, Dodge };
var MyCarBrandName: string = Brands[1];

应该提到的是,从 TypeScript 2.4 开始,可以将字符串值分配给枚举。

Void

void 类型确实表示任何类型的缺失,其使用受限于注释不返回实际值的函数。因此,也没有返回类型。我们已经有机会在上一章中通过一个实际例子看到这一点:

resetPomodoro(): void {
 this.minutes = 24;
 this.seconds = 59;
}

类型推断

对我们的数据进行类型标注是可选的,因为 TypeScript 足够聪明,可以在上下文中推断出变量和函数返回值的数据类型,并且具有一定的准确性。当无法进行类型推断时,TypeScript 将以动态的 any 类型分配给松散类型的数据,以减少类型检查的成本。

推断工作的一个例子可以在以下代码中看到:

var brand = 'Chevrolet';

这具有相同的效果,也就是说,如果您尝试将不兼容的数据类型分配给它,它将导致编译错误,就像这样:

var brand: string = 'Chevrolet';
var brand2 = 'Chevrolet';
brand = false; // compilation error
brand = 114; // compilation error

函数、lambda 和执行流

与 JavaScript 一样,函数是处理机器,我们在其中分析输入,消化信息,并对提供的数据应用必要的转换,以便转换我们应用程序的状态或返回一个输出,该输出将用于塑造我们应用程序的业务逻辑或用户交互。

TypeScript 中的函数与普通 JavaScript 并没有太大的区别,除了函数本身以及 TypeScript 中的其他所有内容一样,可以用静态类型进行注释,因此,它们更好地通知编译器它们在签名中期望的信息以及它们的返回数据类型(如果有的话)。

在我们的函数中注释类型

以下示例展示了在 TypeScript 中如何注释常规函数:

function sayHello(name: string): string {
 return 'Hello, ' + name;
}

我们可以清楚地看到与普通 JavaScript 中的常规函数语法有两个主要区别。首先,在函数签名中注释了参数的类型信息。这是有道理的,因为编译器将希望检查在执行函数时提供的数据是否具有正确的类型。除此之外,我们还通过在函数声明中添加后缀字符串来注释返回值的类型。在这些情况下,给定的函数不返回任何值,类型注释 void 将为编译器提供所需的信息,以进行适当的类型检查。

正如我们在前一节中提到的,TypeScript 编译器足够聪明,可以在没有提供注释时推断类型。在这种情况下,编译器将查看提供的参数和返回语句,以推断返回类型。

TypeScript 中的函数也可以表示为匿名函数的表达式,我们将函数声明绑定到一个变量上:

var sayHello = function(name: string): string {
 return 'Hello, ' + name;
}

然而,这种语法也有一个缺点。虽然允许以这种方式对函数表达式进行类型化,但由于类型推断,编译器在声明的变量中缺少类型定义。我们可能会假设指向类型为字符串的函数的变量的推断类型显然是字符串。但事实并非如此。指向匿名函数的变量应该用函数类型进行注释。基本上,函数类型通知了函数负载中期望的类型以及函数执行返回的类型(如果有的话)。这整个块,以(arguments: type) =>返回类型的形式,成为我们的编译器期望的类型注释:

var sayHello: (name: string) => string = function(name: string): string {
 return 'Hello, ' + name;
}

你可能会问为什么会有这样繁琐的语法?有时,我们会声明可能依赖于工厂或函数绑定的变量。然后,尽可能向编译器提供尽可能多的信息总是一个好习惯。这个简单的例子可能会帮助你更好地理解:

// Two functions with the same typing but different logic.
function sayHello(input: string): string {
 return 'Hello, ' + input;
}

function sayHi(input: string): string{
 return 'Hi, ' + input;
}

// Here we declare the variable with is own function type
var greetMe: (name: string) => string;
greetMe = sayHello; 

这样,我们也确保以后的函数赋值符合在声明变量时设置的类型注解。

TypeScript 中的函数参数

由于编译器执行的类型检查,TypeScript 中的函数参数需要特别注意。

可选参数

参数是 TypeScript 编译器应用的类型检查的核心部分。TypeScript 通过在参数名称后面添加?符号来提供可选功能,这允许我们在函数调用中省略第二个参数。

function greetMe(name: string, greeting?: string): string {
 console.log(greeting);
 if(!greeting) { greeting = 'Hello'; }
 return greeting + ', ' + name;
}

console.log( greetMe('Chris') );

这段代码将尝试打印出问候变量,并产生一个合适的问候。像这样运行这段代码:

greetMe('Chris');

将给我们以下结果:

undefined
Hello Chris

因此,可选参数实际上不会被设置,除非你明确地这样做。这更多是一种构造,让你可以帮助决定哪些参数是必需的,哪些是可选的。让我们举个例子:

function add(mandatory: string, optional?: number) {}

你可以以以下方式调用这个函数:

add('some string');
add('some string', 3.14);

两个版本都是允许的。在函数签名中使用可选参数会强制你将它们放在最后,就像前面的例子一样。以下例子说明了什么不应该做:

function add(optional?: number, mandatory: string) {}

这将创建这样一种情况,其中两个参数都是必需的:

add(11); // error. mandatory parameter missing

即使编译器会抱怨并说以下内容:

A required parameter cannot follow an optional parameter

记住,可选参数很好,但要放在最后。

默认参数

TypeScript 给了我们另一个功能来应对前面描述的情况,即默认参数,我们可以在执行函数时设置参数的默认值,当没有明确赋值时参数将采用默认值。语法非常简单,我们可以在重构前面的例子时看到:

function greetMe(name: string, greeting: string = 'Hello'): string {
 return `${greeting}, ${name}`;
}

与可选参数一样,默认参数必须放在函数签名中非默认参数的后面。有一个非常重要的区别,就是默认参数总是安全的。为什么它们是安全的,可以从下面的 ES5 代码中看出。下面的 ES5 代码是将上面的 TypeScript 编译为 ES5 得到的结果代码。下面的代码表明编译器添加了一个 IF 子句,检查变量greeting是否为 undefined,如果是,则给它一个起始值:

function greetMe(name, greeting){
 if (greeting === void 0) { greeting = 'Hello'; }

 return greeting + ', ' + name;
}

正如你所看到的,编译器添加了一个 if 子句来检查你的值,如果没有设置,它会添加你之前提供的值。

当你处理默认参数时,类型会被推断出来,因为你给它们赋了一个值。在前面的代码片段中,greeting 被赋予字符串值'Hello',因此被推断为字符串类型。

剩余参数

在定义函数时,JavaScript 的灵活性之一是接受以 arguments 对象形式的无限数量的未声明的参数。在 TypeScript 这样的静态类型上下文中,这可能是不可能的,但通过 REST 参数对象实际上是可能的。在这里,我们可以在参数列表的末尾定义一个额外的参数,前面加上省略号(...)并且类型为数组:

function greetPeople(greeting: string, ...names: string[]): string{
 return greeting + ', ' + names.join(' and ') + '!';
}

alert(greetPeople('Hello', 'John', 'Ann', 'Fred'));

需要注意的是,剩余参数必须放在参数列表的末尾,不需要时可以省略。让我们看一下生成的 ES5 代码,以了解 TypeScript 编译器生成了什么:

function greetPeople(greeting) {
 var names = [];
 for (var _i = 1; _i < arguments.length; _i++) {
 names[_i - 1] = arguments[_i];
 }
 return greeting + ', ' + names.join(' and ') + '!';
}

alert(greetPeople('Hello', 'John', 'Ann', 'Fred'));

我们可以看到这里使用了内置的 arguments 数组。而且,它的内容被复制到names数组中:

for (var _i = 1; _i < arguments.length; _i++) {
 names[_i -1] = arguments[_i];
}

当你想一想的时候,这真的是非常合理的。所以,当你不知道参数的数量时,剩余参数就是你的朋友。

函数签名的重载

方法和函数的重载在其他语言中是一种常见模式,比如 C#。然而,在 TypeScript 中实现这种功能与 JavaScript 相冲突,因为 JavaScript 并没有提供一种优雅的方式来直接集成这种功能。因此,唯一的解决方法可能是为每个重载编写函数声明,然后编写一个通用函数,它将包装实际的实现,并且其类型参数和返回类型与所有其他函数兼容:

function hello(name: string): string {}
function hello(name: string[]): string {}
function hello(name: any, greeting?: string): string {
 var namesArray: string[];
 if (Array.isArray(names)) {
 namesArray = names;
 } else {
 namesArray = [names];
 }
 if (!greeting) {
 greeting = 'Hello';
 }
 return greeting + ', ' + namesArray.join(' and ') + '!';
}

在上面的例子中,我们暴露了三种不同的函数签名,每个函数签名都具有不同的类型注释。如果有必要,我们甚至可以定义不同的返回类型。为此,我们只需使用任何返回类型注释包裹函数即可。

更好的函数语法和 lambda 的范围处理

ECMAScript 6 引入了箭头函数的概念(在其他语言中也称为 lambda 函数,如 Python、C#、Java 或 C++),旨在简化一般函数语法,并提供一种处理函数范围的可靠方法,传统上由于处理this关键字的范围问题而处理。

第一印象是它的极简语法,大多数情况下,我们会看到箭头函数作为单行匿名表达式:

var double = x => x * 2;

该函数计算给定数字x的两倍,并返回结果,尽管我们在表达式中没有看到任何函数或返回语句。如果函数签名包含多个参数,我们只需要将它们都包裹在大括号中:

var add = (x, y) => x + y;

这使得这种语法在开发mapreduce等功能操作时非常方便:

var reducedArray = [23, 5, 62, 16].reduce((a, b) => a + b, 0);

箭头函数也可以包含语句。在这种情况下,我们希望将整个实现包裹在大括号中:

var addAndDouble = (x, y) => {
 var sum = x + y;
 return sum * 2;
}

但是,这与范围处理有什么关系呢?基本上,this 的值取决于我们执行函数的上下文。对于一种以出色的功能编程灵活性自豪的语言来说,这是一件大事,其中回调等模式至关重要。在回调函数中引用this时,我们失去了上下文的追踪,这通常迫使我们使用约定,例如将this的值分配给一个名为 self 或 that 的变量,稍后在回调中使用。包含间隔或超时函数的语句是这一点的完美例子:

function delayedGreeting(name): void {
 this.name = name;
 this.greet = function(){
 setTimeout(function() {
 alert('Hello ' + this.name);
 }, 0);
 }
}

var greeting = new delayedGreeting('Peter');
greeting.greet(); // alert 'Hello undefined'

在执行上述脚本时,我们不会得到预期的Hello Peter警报,而是一个不完整的字符串,突出显示对Mr. Undefined!的讨厌的问候。基本上,这种构造在评估超时调用内部的函数时会破坏 this 的词法作用域。将此脚本转换为箭头函数将解决问题:

function delayedGreeting(name): void {
 this.name = name;
 this.greet = function() {
 setTimeout(() => alert('Hello ' + this.name), 0);
 }
}

即使我们将箭头函数中的语句拆分为由花括号包裹的几行代码,this 的词法作用域仍将指向 setTimeout 调用外部的适当上下文,从而实现更加优雅和清晰的语法。

一般特性

在 TypeScript 中有一些一般特性,它们并不特别适用于类、函数或参数,而是使编码更加高效和有趣。这个想法是,你写的代码行数越少,就越好。这不仅仅是关于行数更少,还关乎让事情更清晰。在 ES6 中有许多这样的特性,TypeScript 也实现了这些特性,但在这里,我只会列出一些可能会出现在你的 Angular 项目中的特性。

展开参数

展开参数使用与 REST 参数相同的语法...省略号,但用法不同。它不是作为函数内部的参数使用,而是在函数体内使用。

让我们来说明一下这意味着什么:

var newItem = 3;
var oldArray = [ 1, 2 ];
var newArray = [
 ...oldArray,
 newItem
];
console.log( newArray )

这将输出:

1,2,3

我们在这里做的是向现有数组添加一个项目,而不改变旧数组。oldArray 变量仍然包含 1,2,但 newArray 包含 1,2,3。这个一般原则被称为不可变性,它基本上意味着不要改变,而是从旧状态创建一个新状态。这是函数式编程中使用的原则,既作为一种范式,也是出于性能原因。

你也可以在对象上使用 REST 参数;是的,真的。你可以这样写:

var oldPerson = { name : 'Chris' };
var newPerson = { ...oldPerson, age : 37 }; 
console.log( newPerson );

运行此代码的结果是:

{ name: 'Chris', age: 37 }

两个对象之间的合并。就像列表的例子一样,我们不会改变先前的变量 oldPerson。一个 newPerson 变量将从 oldPerson 获取信息,但同时将其新值添加到其中。看看 ES5 代码,你就会明白为什么:

var __assign = ( this && this.__assign ) || Object.assign || function(t) {
 for (var s, i = n, n = arguments.length; i < n; i++) {
 s = arguments[i];
 for (var p in s) if (Object.prototype.hasOwnProperty.call( s, p )) {
 t[ p ] = s[ p ];
 }
 return t;
 };
 var oldPerson = { name : 'Chris' };
 var newPerson = __assign({}, oldPerson, { age: 37 });
 console.log( newPerson );
}

这里发生的是定义了一个assign函数。该函数循环遍历oldPerson变量的键,并将其分配给一个新对象,最后添加newPerson变量的内容。如果你看一下前面的函数,它要么定义一个执行此操作的函数,要么使用 ES6 标准中的Object.assign(如果可用)。

模板字符串

模板字符串的目的是让你的代码更清晰。想象一下以下情景:

var url = 'http://path_to_domain' + 
'path_to_resource' + 
'?param=' + parameter + 
'=' + 'param2=' + 
parameter2;

那么,这有什么问题吗?答案是可读性。很难想象结果字符串会是什么样子,但你也很容易错误地编辑以前的代码,突然间,结果将不是你想要的。大多数语言都使用格式化函数来解决这个问题,这正是模板字符串的作用,一个格式化函数。它的使用方式如下:

var url = `${baseUrl}/${path_to_resource}?param=
 ${parameter}&param2={parameter2}`;

这是一个更简洁的表达方式,因此更容易阅读,所以一定要使用它。

泛型

泛型是一个表达式,表示我们有一个通用的代码行为,无论数据类型如何,我们都可以使用它。泛型经常用于操作集合,因为集合通常具有类似的行为,无论类型如何。但泛型也可以用于方法等结构。其想法也是,泛型应该指示你是否要以不允许的方式混合类型。

function method<T>(arg: T): T {
 return arg;
}
console.log(method<number>(1)); // works
console.log(method<string>(1)); // doesn't work

在前面的例子中,T 直到你实际使用该方法时才确定。正如你所看到的,T 的类型根据你调用它的方式从数字变化到 String。它还确保你输入了正确类型的数据。这可以在以下行中看到:

console.log(method<string>(1)); // doesn't work

在这里,我们明确指定 T 应该是一个字符串,但我们坚持要输入一个数字类型的值。编译器明确指出这是不允许的。

然而,你可以更具体地指定 T 应该是什么类型。通过输入以下内容,你确保 TArray 类型,因此你输入的任何类型的值都必须遵循这一规定:

function method<T>(arg: T[]): T[] {
 console.log(arg.length); // Array has a .length, so no more error
 return arg;
}

class A extends Array {
}

class Person {
}

var p = new Array<Person>();
var person = new Person();
var a = new A();

method<Person>(p);
method<A>(a);
method<Person>(person);

在这种情况下,我们决定 T 应该是 PersonA 类型,并且我们还看到输入需要是数组类型:

function method<T>(arg: T[]) {}

因此,输入单个对象是不允许的。那么我们为什么要这样做呢?在这种情况下,我们希望确保某些方法是可用的,比如 .length,并且在某一时刻,我们不在乎我们是在操作 A 类型还是 Person 类型的东西。

你还可以决定你的类型 T 应该遵循这样一个接口:

interface Shape {
 area(): number;
}

class Square implements Shape {
 area() { return 1; }
}

class Circle implements Shape {
 area() { return 2; }
}

function allAreas<T extends Shape>(...args: T[]): number {
 let total = 0;
 args.forEach (x => {
 total += x.area();
 });
 return total;
}

allAreas(new Square(), new Circle());

以下行限制了 T 可以是什么:

T extends Shape

正如你所看到的,如果你有许多不同数据类型可以关联的共同行为,泛型是非常强大的。你可能最初不会编写自己的泛型代码,但了解正在发生的事情是很好的。

类、接口和类继承

现在我们已经概述了 TypeScript 最相关的部分,是时候看看如何将所有内容组合起来构建 TypeScript 类了。这些类是 TypeScript 和 Angular 应用程序的构建模块。

尽管名词类在 JavaScript 中是一个保留字,但语言本身从未对传统的面向对象的类有过实际的实现,就像 Java 或 C#等其他语言那样。JavaScript 开发人员过去常常模仿这种功能,利用函数对象作为构造函数类型,然后使用 new 运算符对其进行实例化。其他常见的做法,比如扩展我们的函数对象,是通过应用原型继承或使用组合来实现的。

现在,我们有了一个实际的类功能,足够灵活和强大,可以实现我们应用程序所需的功能。我们已经有机会在上一章中了解类。现在让我们更详细地看一下它们。

类的解剖-构造函数、属性、方法、getter 和 setter

以下代码片段说明了一个类的结构。请注意,类的属性成员首先出现,然后我们包括一个构造函数和几个方法和属性访问器。它们中没有一个使用保留字 function,并且所有成员和方法都正确地用类型进行了注释,除了构造函数:

class Car {
 private distanceRun: number = 0;
 color: string;

 constructor(public isHybrid: boolean, color: string = 'red') {
 this.color = color;
 }

 getCasConsumsption(): string {
 return this.ishybrid ? 'Very low' : 'Too high!';
 }

 drive(distance: number): void {
 this.distanceRun += distance;
 }

 static honk(): string {
 return 'HOOONK!';
 }

 get distance(): number {
 return this.distanceRun;
 }
}

这个类的布局可能会让我们想起我们在第一章中构建的组件类,在 Angular 中创建我们的第一个组件。基本上,类语句包含了我们可以分解为的几个元素。

  • 成员Car类的任何实例都将具有两个属性-color 类型为字符串,distanceRun类型为数字,它们只能从类内部访问。如果我们实例化这个类,distanceRun或任何其他标记为私有的成员或方法,它们将不会作为对象 API 的一部分公开。

  • 构造函数:构造函数在创建类的实例时立即执行。通常,我们希望在这里使用构造函数签名中提供的数据初始化类成员。我们还可以利用构造函数签名本身来声明类成员,就像我们在isHybrid属性中所做的那样。为此,我们只需要使用 private 或 public 等访问修饰符作为构造函数参数的前缀。与我们在前面的部分中分析函数时看到的一样,我们可以定义剩余参数、可选参数或默认参数,就像在前面的示例中使用颜色参数时一样,当它没有明确定义时会回退到红色。

  • 方法:方法是一种特殊类型的成员,表示一个函数,因此可以返回或不返回一个类型化的值。基本上,它是对象 API 的一部分的函数。方法也可以是私有的。在这种情况下,它们基本上用作类的内部范围内的辅助函数,以实现其他类成员所需的功能。

  • 静态成员:标记为静态的成员与类相关联,而不是与该类的对象实例相关联。这意味着我们可以直接使用静态成员,而不必首先实例化对象。事实上,静态成员无法从对象实例中访问,因此它们无法使用 this 访问其他类成员。这些成员通常作为辅助或工厂方法包含在类定义中,以提供与任何特定对象实例无关的通用功能。

  • 属性访问器:在 ES5 中,我们可以使用Object.defineProperty以非常冗长的方式定义自定义 setter/getter。现在,事情变得更简单了。为了创建属性访问器(通常指向内部私有字段,如所提供的示例),我们只需要使用以 set(使其可写)和 get(使其可读)命名的类型化方法前缀作为我们要公开的属性。

作为个人练习,为什么不将前面的代码片段复制到游乐场页面(www.typescriptlang.org/Playground)并执行它呢?我们甚至可以在类定义之后直接附加此片段,运行代码并在浏览器的开发者工具控制台中检查输出,看Car类的实例对象如何运行。

var myCar = new Car(false);
console.log(myCar.color);  // 'red'
// Public accessor returns distanceRun:
console.log(myCar.distance)  // 0
myCar.drive(15);
console.log(myCar.distance);  // 15 (0 + 15)
myCar.drive(21);
console.log(myCar.distance);  // 36 (15 + 21)
// What's my carbon footprint according to my car type?
myCar.getGasConsumption();  // 'Too high!'
Car.honk();  // 'HOOONK!' no object instance required

我们甚至可以执行一个额外的测试,并在我们的代码中添加以下非法语句,尝试访问私有属性distanceRun,甚至通过 distance 成员应用一个值,而该成员没有 getter。

console.log(myCar.distanceRun);
myCar.distance = 100;

在将这些代码语句插入到代码编辑器中后,红色的下划线会提示我们正在尝试做一些不正确的事情。尽管如此,我们可以继续转译和运行代码,因为 ES5 将遵守这些做法。总的来说,如果我们尝试在这个文件上运行tsc编译器,运行时将退出并显示以下错误跟踪:

example_26.ts(21,7): error TS1056: Accessors are only available when targeting ECMAScript 5 and higher example_26.ts(29,13): error TS2341: Property 'distanceRun' is private and only accessible within class 'Car'

带有访问器的构造函数参数

通常,在创建一个类时,你需要给它命名,定义一个构造函数,并创建一个或多个后备字段,就像这样:

class Car {
 make: string;
 model: string;
 constructor(make: string, model: string) {
 this.make = make;
 this.model = model;
 }
}

对于每个你需要添加到类中的字段,通常需要做以下操作:

  • 在构造函数中添加一个条目

  • 在构造函数中添加一个赋值

  • 创建后备字段

这真的很无聊,也不太高效。TypeScript 已经做到了,所以我们不需要通过在构造函数参数上使用访问器来输入后备字段。我们现在可以输入:

constuctor( public make: string, private model: string ) {}

给参数添加一个公共访问器意味着它将创建一个公共字段,给它一个私有访问器意味着它将为我们创建一个私有字段,就像这样:

class Car {
 public make: string;  // creating backing field
 private model: string;

 constructor(make: string, model: string) {
 this.make = make;  //doing assignment
 this.model = model;
 }
}

尝试访问这些字段会像这样:

var car = new Car('Ferrari', 'F40');
car.make  // Ferrari
car.model  // not accessible as it is private

在 ES5 中,我们没有字段的概念,所以它消失了,但是构造函数中的赋值仍然存在:

function Car(make) {
 this.make = make;
 this.model = model;
}

但是,在 TypeScript 中,你再也不需要做任何这些事情了。

class Car {
 constructor(public make: string, public model: string) {}
}

正如你所看到的,超过一半的代码消失了;这确实是 TypeScript 的一个卖点,因为它可以帮你省去输入大量乏味的代码。

TypeScript 中的接口

随着应用程序规模的扩大,创建更多的类和结构,我们需要找到方法来确保代码的一致性和规则的遵从。解决一致性和类型验证问题的最佳方法之一就是创建接口。

简而言之,接口是一个定义特定字段模式和任何类型(无论是类、函数签名)的代码蓝图,实现这些接口的类型都应该符合这个模式。当我们想要强制对由工厂生成的类进行严格类型检查时,当我们定义函数签名以确保有效载荷中存在某个类型的属性,或者其他情况时,这就变得非常有用。

让我们开始吧!在这里,我们定义了Vehicle接口。Vehicle不是一个类,而是任何实现它的类必须遵守的合同模式:

interface Vehicle {
 make: string;
}

任何实现Vehicle接口的类必须具有名为make的成员,根据此示例,它必须被定义为字符串类型。否则,TypeScript 编译器会抱怨:

class Car implements Vehicle {
 // Compiler will raise a warning if 'make' is not defined
 make: string;
}

因此,接口非常有用,可以定义任何类型必须满足的最小成员集,成为确保代码库一致性的宝贵方法。

重要的是要注意,接口不仅用于定义最小的类模式,还用于定义任何类型。这样,我们可以利用接口的力量来强制存在于类中的某些字段和方法以及后来用作函数参数、函数类型、特定数组中包含的类型以及甚至变量的对象属性。接口也可以包含可选成员,甚至成员。

让我们创建一个例子。为此,我们将所有接口类型的前缀都加上I(大写)。这样,在引用它们时,使用我们的 IDE 代码自动完成功能会更容易找到它们的类型。

首先,我们定义了一个Exception接口,该接口模拟了一个具有强制消息属性成员和可选id成员的类型:

interface Exception {
 message: string;
 id?: number;
}

我们也可以为数组元素定义接口。为此,我们必须定义一个仅有一个成员的接口,定义索引为数字或字符串(用于字典集合),然后定义我们希望该数组包含的类型。在这种情况下,我们希望创建一个包含Exception类型的数组的接口。这是一个包含字符串消息属性和可选 ID 号成员的类型,就像我们在前面的例子中说的那样:

interface ExceptionArrayItem {
 [index: number]: IException;
}

现在,我们定义了未来类的蓝图,其中包括一个带有类型数组和一个返回类型定义的方法:

interface ErrorHandler {
 exception: ExceptionArrayItem[];
 logException(message: string; id?: number: void;)
}

我们还可以为独立的对象类型定义接口。当定义模板构造函数或方法签名时,这是非常有用的,我们稍后将在本例中看到:

interface ExceptionHandlerSettings {
 logAllExceptions: boolean;
}

最后但并非最不重要的是,在接下来的课程中,我们将实现所有这些接口类型:

class ErrorHandler implements ErrorHandler {
 exceptions: ExceptionArrayItem[];
 logAllExceptions: boolean;
 constructor(settings: ExceptionHandlerSettings) {
 this.logAllExceptions = settings.logAllExceptions;
 }

 logException(message: string, id?: number): void {
 this.exception.push({ message, id });
 }
}

基本上,我们在这里定义了一个错误处理程序类,它将管理一组异常并公开一个方法,通过将它们保存到前述数组中来记录新的异常。这两个元素由ErrorHandler接口定义,并且是强制性的。类构造函数期望由ExceptionHandlerSettings接口定义的参数,并使用它们来填充异常成员,其类型为Exception。在不带有有效载荷中的logAllExceptions参数的情况下实例化ErrorHandler类将触发错误。

到目前为止,我一直在解释接口,就像我们在其他高级语言中习惯看到的那样,但是 TypeScript 中的接口是经过增强的;让我通过以下代码来举例说明:

interface A {
 a
}

var instance = <A>{ a: 3 };
instance.a = 5;

在这里,我们声明了一个接口,但同时也在这里从接口创建了一个实例:

var instance = <A>{ a: 3 };

这很有趣,因为这里没有涉及到类。这意味着编写一个模拟库是小菜一碟。让我们稍微解释一下我们所说的模拟库。当你在开发代码时,你可能会先考虑接口,然后再考虑具体的类。这是因为你知道需要存在哪些方法,但可能还没有确定这些方法应该如何执行任务。想象一下,你正在构建一个订单模块。你的订单模块中有逻辑,你知道在某个时候需要与一个数据库服务进行通信,这将帮助你保存订单。你为所述数据库服务制定了一个合同,一个接口。你推迟了对该接口的实现。在这一点上,一个模拟库可以创建一个从接口生成的模拟实例。你的代码此时可能看起来像这样:

class OrderProcessor {
 constructor(private databaseService: DatabaseService) {}

 process(order) {
 this.databaseService.save(order);
 }
}

interface DatabaseService {
} 

let orderProcessor = new OrderProcessor(mockLibrary.mock<DatabaseService>());
orderProcessor.process(new Order());

因此,此时的模拟使我们能够推迟对DatabaseService的实现,直到我们完成了OrderProcessor的编写。它还使OrderProcessor的测试体验变得更好。在其他语言中,我们需要引入第三方依赖的模拟库,而现在我们可以利用 TypeScript 中的内置构造来实现以下类型:

var databaseServiceInstance = <DatabaseService>{};

这将给我们一个DatabaseService的实例。不过,需要警告一下,你需要为你的实例添加一个process()方法。你的实例最初是一个空对象。

这不会引起编译器的任何问题;这意味着这是一个强大的功能,但它留给你来验证你创建的东西是否正确。

让我们强调一下 TypeScript 功能的强大之处,通过查看一些更多的代码案例,这样能够模拟掉一些东西就会很值得。让我们重申,在代码中模拟任何东西的原因是为了更容易地进行测试。

假设您的代码看起来像这样:

class Stuff {
 srv:AuthService = new AuthService();
 execute() {
 if (srv.isAuthenticated())  // do x
 else  // do y
 }
}

测试这个的更好方法是确保Stuff类依赖于抽象,这意味着AuthService应该在其他地方创建,并且我们与AuthService的接口而不是具体实现进行交流。因此,我们将修改我们的代码看起来像这样:

interface AuthService {
 isAuthenticated(): boolean;
}

class Stuff {
 constructor(srv:AuthService) {}
 execute() {
 if (srv.isAuthenticated()) { /* do x */ }
 else { /* do y */ }
 }
}

要测试这个类,我们通常需要创建AuthService的具体实现,并将其作为Stuff实例的参数使用,就像这样:

class MockAuthService implements AuthService {
 isAuthenticated() { return true; }
}
var srv = new AuthService();
var stuff = new Stuff(srv);

然而,如果您想要模拟掉每个想要模拟掉的依赖项的话,这将变得相当乏味。因此,大多数语言中都存在模拟框架。其想法是给模拟框架一个接口,它将从中创建一个具体的对象。您永远不需要创建一个模拟类,就像我们之前所做的那样,但这将是模拟框架内部要做的事情。使用所述的模拟框架,它看起来会像这样:

var instance = mock<Type>();

到目前为止,我们已经说过从接口创建实例是多么容易,就像这样:

var instance = <A>{ a: 3 };

这意味着创建一个模拟框架就像输入以下内容一样容易:

function mock<T>(startData) {
 return <T>Object.assign({}, startData);
}

并且以以下方式使用它:

interface IPoint {
 x;
 y;
}

class Point implements IPoint {
 x;
 y;
}
var point = mock<IPoint>({ x: 3 });
console.log(point);

让我们通过强调类可以实现多个接口,但也可以让接口变得更加强大并且大大简化测试来总结一下关于接口的这一部分。

通过类继承扩展类

就像类可以由接口定义一样,它也可以扩展其他类的成员和功能,就好像它们是自己的一样。我们可以通过在类名后添加关键字extends,包括我们想要继承其成员的类的名称,使一个类继承自另一个类。

class Sedan extends Car {
 model: string;
 constructor(make: string, model: string) {
 super(maker);
 this.model = model;
 }
}

在这里,我们从一个父类Car扩展,该类已经公开了一个 make 成员。我们可以填充父类已定义的成员,甚至通过执行super()方法执行它们自己的构造函数,该方法指向父构造函数。我们还可以通过附加具有相同名称的方法来覆盖父类的方法。尽管如此,我们仍然能够执行原始父类的方法,因为它仍然可以从 super 对象中访问。回到接口,它们也可以从其他接口继承定义。简而言之,一个接口可以从另一个接口继承。

作为一种谨慎的提醒,ES6 和 TypeScript 不支持多重继承。因此,如果您想从不同的来源借用功能,您可能希望改用组合或中间类。

TypeScript 中的装饰器

装饰器是一种非常酷的功能,最初由 Google 在 AtScript(TypeScript 的超集,最终于 2015 年初合并到 TypeScript 中)中提出,并且也是 ECMAScript 7 当前标准提案的一部分。简而言之,装饰器是一种向类声明添加元数据的方式,供依赖注入或编译指令使用(blogs.msdn.com/b/somasegar/archive/2015/03/05/typescript-lt-3-angular.aspx)。通过创建装饰器,我们正在定义可能对我们的类、方法或函数的行为产生影响,或者仅仅改变我们在字段或参数中定义的数据的特殊注释。在这个意义上,装饰器是一种强大的方式,可以增强我们类型的本机功能,而不需要创建子类或从其他类型继承。

这是 TypeScript 最有趣的功能之一。事实上,在 Angular 中设计指令和组件或管理依赖注入时,它被广泛使用,我们将从第五章 使用管道和指令增强我们的组件开始看到。

装饰器可以很容易地通过其名称的@前缀来识别,它们通常位于它们装饰的元素的上方,包括方法负载或不包括方法负载。

我们可以定义最多四种不同类型的装饰器,具体取决于每种类型所要装饰的元素:

  • 类装饰器

  • 属性装饰器

  • 方法装饰器

  • 参数装饰器

让我们逐个看一下!

类装饰器

类装饰器允许我们增强一个类或对其任何成员执行操作,并且装饰器语句在类被实例化之前执行。

创建一个类装饰器只需要定义一个普通函数,其签名是指向我们想要装饰的类的构造函数的指针,类型为函数(或任何其他继承自函数的类型)。正式声明定义了一个ClassDecorator,如下所示:

declare type ClassDecorator = <TFunction extends Function>(Target: TFunction) => TFunction | void;

是的,很难理解这些胡言乱语的含义,对吧?让我们通过一个简单的例子来把一切放在上下文中,就像这样:

function Banana(target: Function): void {
 target.prototype.banana = function(): void {
 console.log('We have bananas!');
 }
}

@Banana
class FruitBasket {
 constructor() {
 // Implementation goes here...
 }
}
var basket = new FruitBasket();
basket.banana();  // console will output 'We have bananas!'

正如我们所看到的,我们通过正确地使用Banana装饰器,获得了一个在FruitBasket类中原本未定义的banana()方法。不过值得一提的是,这实际上不会编译通过。编译器会抱怨FruitBasket没有banana()方法,这是理所当然的。TypeScript 是有类型的。在 ES5 中,我们可以做任何我们想做的事情,任何错误都会在运行时被发现。所以在这一点上,我们需要告诉编译器这是可以的。那么,我们该如何做呢?一种方法是在创建篮子实例时,像这样给它赋予任意类型:

var basket: any = new FruitBasket();
basket.banana();

我们在这里所做的是将变量 basket 主动赋予any类型,从而抵制 TypeScript 编译器将类型推断为FruitBasket的冲动。通过使用 any 类型,TypeScript 无法知道我们对它所做的是否正确。另一种实现相同效果的方法是这样类型:

var basket = new FruitBasket();
(basket as any).banana();

在这里,我们使用as运算符进行了即时转换,从而告诉编译器这是可以的。

扩展类装饰器函数签名

有时,我们可能需要在实例化时自定义装饰器的操作方式。别担心!我们可以设计带有自定义签名的装饰器,然后让它们返回一个与我们在设计不带参数的类装饰器时定义的相同签名的函数。作为一个经验法则,带参数的装饰器只需要一个函数,其签名与我们想要配置的参数匹配。这样的函数必须返回另一个函数,其签名与我们想要定义的装饰器的签名匹配。

下面的代码片段展示了与前面例子相同的功能,但它允许开发人员自定义问候消息:

function Banana(message: string) {
 return function(target: Function) {
 target.prototype.banana = function(): void {
 console.log(message);
 }
 }
}

@Greeter('Bananas are yellow!')
class FruitBasket {
 constructor() {
 // Implementation goes here...
 }
}
var basket = new FruitBasket();
basket.banana();  // console will output 'Bananas are yellow'

属性装饰器

属性装饰器是用于应用于类字段的,并且可以通过创建一个PropertyDecorator函数来轻松定义,其签名接受两个参数:

  • Target:这是我们想要装饰的类的原型

  • Key:这是我们想要装饰的属性的名称

特定类型的装饰器的可能用例可能包括日志记录

在实例化此类的对象的类字段分配的值,甚至对这些字段的数据更改做出反应。让我们看一个实际的例子,涵盖了这两种行为:

function Jedi(target: Object, key: string) {
 var propertyValue: string = this[key];
 if (delete this[key]) {
 Object.defineProperty(target, key, {
 get: function() {
 return propertyValue;
 }, 
 set: function(newValue){
 propertyValue = newValue;
 console.log(`${propertyValue} is a Jedi`);
 }
 });
 }
}

class Character {
 @Jedi
 name: string;
}

var character = new Character();
character.name = 'Luke';  // console outputs 'Luke is a Jedi'
character.name = 'Yoda';  // console outputs 'Yoda is a Jedi'

这里适用于带参数的类装饰器的相同逻辑,尽管返回函数的签名略有不同,以匹配我们已经看到的无参数装饰器声明的签名。

以下示例描述了我们如何记录给定类属性的更改,并在发生这种情况时触发自定义函数:

function NameChanger(callbackObject: any): Function {
 return function(target: Object, key: string): void {
 var propertyValue: string = this[key];
 if (delete this[key]) {
 Object.defineProperty(target, key, {
 get: function() {
 return propertyValue;
 }, 
 set: function(newValue) {
 propertyValue = newValue;
 callbackObject.changeName.call(this, propertyValue);
 }
 });
 }
 }
}

class Fruit {
 @NameChanger ({
 changeName: function(string,newValue: string): void {
 console.log(`You are now known as ${newValue}`);
 }
 })
 name: string;
}

var character = new Character();
character.name 'Anakin';  // console: 'You are now known as Anakin'
character.name = 'Lord Vader';  //console: 'You are now known as Lord Vader'

方法装饰器

这些特殊的装饰器可以检测、记录并干预方法的执行方式。为此,我们只需要定义一个MethodDecorator函数,其有效负载接受以下参数:

  • Target:这被定义为一个对象,代表被装饰的方法。

  • Key:这是给定方法的实际名称的字符串。

  • Value:这是给定方法的属性描述符。实际上,它是一个哈希对象,其中包含了一个名为 value 的属性,其中包含对方法本身的引用。

让我们看看如何在实际示例中利用MethodDecorator函数。在后来的 TypeScript 版本中,这种语法已经改变。然而,想法是在方法执行之前和之后拦截。那么,为什么你想这样做呢?嗯,有一些有趣的情况:

  • 您想了解有关方法如何被调用的更多信息,例如args,结果等

  • 您想知道某个方法运行了多长时间

让我们为每种情况创建一个装饰器:

function Log(){
 return function(target, propertyKey: string, 
 descriptor: PropertyDescriptor) {
 var oldMethod = descriptor.value;
 descriptor.value = function newFunc( ...args:any[]){
 let result = oldMethod.apply(this, args);
 console.log(`${propertyKey} is called with ${args.join(',') and
 result ${result}`);
 return result;
 }
 }
}

class Hero {
 @Log()
 attack(...args:[]) { return args.join(); }
}

var hero = new Hero();
hero.attack();

在这里,我们正在讨论descriptor.value,其中包含我们实际的函数,正如你所看到的,我们:

  • 保存对旧方法的引用

  • 我们通过替换descriptor.value指向的内容来重新定义方法

  • 在我们的新函数内部执行旧方法

  • 我们记录使用了什么参数以及结果如何变化

到目前为止,我们已经解释了如何向方法添加日志信息,但还有另一种情况我们也想描述一下,即测量执行时间。我们可以使用与之前类似的方法,但有一些细微的差别:

function Timer(){
 return function(target, propertyKey: string, descriptor: PropertyDescriptor) {
 var oldMethod = descriptor.value;
 descriptor.value = function() {
 var start = new Date();
 let result = oldMethod.apply(this, args);
 var stop = new Date();
 console.log(`Method took ${stop.getMilliseconds() - 
 start.getMilliseconds()}ms to run`);
 return result;
 }
 }
}

我们仍然做了很多相同的事情,但让我们用几个要点来总结一下:

  • 保存对旧方法的引用

  • 重新定义descriptor.value

  • 在方法执行前启动计时器

  • 执行方法

  • 在方法执行后停止计时器

请记住,装饰器函数的作用域限定在目标参数中表示的类中,因此我们可以利用这一点来为类增加我们自己的自定义成员。在这样做时要小心,因为这可能会覆盖已经存在的成员。在本例中,我们不会对此进行任何尽职调查,但在将来的代码中要小心处理。方法装饰器是非常强大的,但不要总是使用它们,而是在像前面那样它们发挥作用的情况下使用。

参数装饰器

我们最后一轮的装饰器将涵盖ParameterDecorator函数,该函数可以访问位于函数签名中的参数。这种装饰器并不意图改变参数信息或函数行为,而是查看参数值,然后在其他地方执行操作,例如,记录日志或复制数据。ParameterDecorator函数接受以下参数:

  • Target:这是包含被装饰参数的函数的对象原型,通常属于一个类

  • Key:这是包含装饰参数的函数签名的函数的名称

  • 参数索引:这是装饰器应用的参数数组中的索引

以下示例显示了参数装饰器的工作示例:

function Log(target: Function, key: string, parameterIndex: number) {
 var functionLogged = key || target.prototype.constructor.name;
 console.log(`
 The parameter in position 
 ${parameterIndex} at ${functionLogged} has been decorated`
 );
}

class Greeter {
 greeting: string;
 constructor (@Log phrase: string) {
 this.greeting = phrase;
 }
}
// The console will output right after the class above is defined:
// 'The parameter in position 0 at Greeter has been decorated'

您可能已经注意到functionLogged变量的奇怪赋值。这是因为目标参数的值将根据被装饰参数的函数而变化。因此,如果我们装饰构造函数参数或方法参数,它是不同的。前者将返回对类原型的引用,后者将只返回构造函数。当装饰构造函数参数时,key 参数也将是未定义的。

正如我们在本节开头提到的,参数装饰器并不意味着修改装饰的参数的值或更改这些参数所在的方法或构造函数的行为。它们的目的通常是记录或准备容器对象,以通过更高级别的装饰器(如方法或类装饰器)实现额外的抽象层或功能。这种情况的典型案例包括记录组件行为或管理依赖注入,正如我们将在第五章中看到的,“通过管道和指令增强我们的组件”。

使用模块组织我们的应用程序

随着我们的应用规模和规模的增长,总会有一个时候,我们需要更好地组织我们的代码,使其可持续且更具重用性。模块是对这种需求的响应,所以让我们看看它们是如何工作的,以及我们如何在应用程序中实现它们。模块可以是内部的或外部的。在本书中,我们将主要关注外部模块,但现在概述这两种类型是一个好主意。

内部模块

简而言之,内部模块是包含一系列类、函数、对象或变量的单例包装器,其范围在内部,远离全局或外部范围。我们可以通过在我们希望从外部访问的元素前加上关键字export来公开模块的内容,就像这样:

module Greetings {
 export class Greeting {
 constructor(public name: string) {
 console.log(`Hello ${name}`);
 }
 }

 export class XmasGreeting {
 constructor(public name: string){
 console.log(`Merry Xmas ${name}`);
 }
 }
}

我们的“问候”模块包含两个类,可以通过导入模块并通过其名称访问要使用的类来从模块外部访问:

import XmasGreeting = Greeting.XmasGreeting;
var xmasGreeting = XmasGreeting('Joe');
// console outputs 'Merry Xmas Joe'

在查看前面的代码之后,我们可以得出结论,内部模块是将元素分组和封装在命名空间上下文中的一种好方法。我们甚至可以将我们的模块拆分成几个文件,只要模块声明在这些文件中保持相同的名称。为了做到这一点,我们将希望使用引用标签引用我们散布在这个模块中的不同文件中的对象:

/// <reference path="greetings/XmasGreeting.ts" />

然而,内部模块的主要缺点是,为了使它们在我们的 IDE 领域之外工作,我们需要将它们全部放在同一个文件或应用程序范围内。我们可以将所有生成的 JavaScript 文件作为脚本插入到我们的网页中,利用诸如 Grunt 或 Gulp 的任务运行器,或者甚至使用 TypeScript 编译器中的--outFile标志,将工作区中找到的所有.ts文件编译成一个单独的捆绑包,使用引用标签到所有其他模块作为我们编译的起点的引导文件:

tsc --outFile app.js module.ts

这将编译所有的 TypeScript 文件,遵循引用标签引用的依赖文件的路径。如果我们忘记以这种方式引用任何文件,它将不会包含在最终的构建文件中,所以另一个选项是在编译命令中列出包含独立模块的所有文件,或者只需添加一个包含模块综合列表的.txt文件来捆绑。或者,我们可以只使用外部模块。

外部模块

外部模块基本上是我们在构建旨在增长的应用程序时所需要的解决方案。基本上,每个外部模块都在文件级别上工作,其中每个文件都是模块本身,模块名称将与没有.js扩展名的文件名匹配。我们不再使用模块关键字,每个标有导出前缀的成员将成为外部模块 API 的一部分。在上一个示例中描述的内部模块一旦方便地保存在Greetings.ts文件中,将变成这样:

export class Greeting {
 constructor(public name: string) {
 console.log(`Hello ${name}`);
 }
}

export class XmasGreeting {
 constructor(public name: string) {
 console.log(`Merry Xmas ${name}`);
 }
}

导入此模块并使用其导出的类需要以下代码:

import greetings = require('Greetings');
var XmasGreetings = greeting.XmasGreetings();
var xmasGreetings = new XmasGreetings('Pete');
// console outputs 'Merry Xmas Pete'

显然,传统 JavaScript 不支持 require 函数,因此我们需要告诉编译器我们希望在目标 JavaScript 文件中实现该功能。幸运的是,TypeScript 编译器在其 API 中包含了--module参数,因此我们可以为我们的项目配置所选择的依赖加载器:commonjs用于基于 node 的导入,amd用于基于 RequireJS 的导入,umd用于实现通用模块定义规范的加载器,或者 system 用于基于 SystemJS 的导入。我们将在本书中重点介绍 SystemJS 模块加载器:

tsc --outFile app.js --module commonjs

生成的文件将被适当地填充,因此模块可以使用我们选择的模块加载器跨文件加载依赖项。

TypeScript > 1.5 的 ES6 模块

在你的 Angular 项目中使用模块的方式是使用 ES6 语法的外部模块,所以让我们了解一下这意味着什么的基础知识。如本节前面提到的,每个模块一个文件,我们可以使用export关键字导出它。然而,你如何消费依赖在语法上有所不同;让我们通过创建一个 ES6 模块service.ts和另一个模块consumer.ts来说明这一点,后者旨在消费前者:

//service.ts
export class Service {
 getData() {} 
}

//consumer.ts import {} from './service';

这里有两件事要注意,在consumer.ts文件中:

  • 使用大括号{}导入

  • 使用 from 关键字来找到我们的文件

大括号{}给了我们选择想要导入的构造的机会。想象一下如果service.ts更复杂,像这样:

//service-v2.ts
export class Service {
 getData(){}
}

export const PI = 3.14

作为消费者,我们现在可以选择这样导入Service和/或PI

//consumer-v2.ts
import { Service, PI } from './service-v2'

然而,也可以使用另一种语法来导出你的构造。到目前为止,我们一直在为每个想要导出的东西输入export;在我们的service.ts的第三部分service-v3.ts中,我们可以这样输入它:

//service-v3.ts
class Service {}

const PI = 3.14;

export { Service, PI }

进行导出的第三种方式是默认的export。有一个default关键字,这意味着我们在导入时不必使用大括号{}

//service-v4.ts
export default function(a, b) {
 return a + b;
}

//consumer-v3.ts import service from './service-v4';

总结

这绝对是一篇长篇大论,但这篇关于 TypeScript 的介绍绝对是必要的,以便理解 Angular 许多最精彩部分背后的逻辑。它让我们有机会不仅介绍语言语法,还解释了它作为构建 Angular 框架的首选语法成功背后的原因。我们审查了它的类型架构,以及如何使用各种参数化签名的高级业务逻辑设计函数,甚至发现了如何通过使用强大的新箭头函数来绕过与作用域相关的问题。这一章最相关的部分可能是类、方法、属性和访问器的概述,以及我们如何通过接口处理继承和更好的应用程序设计。模块和装饰器是本章探讨的其他重要特性,正如我们很快将看到的那样,对这些机制的充分了解对于理解 Angular 中的依赖注入是至关重要的。

有了这些知识,我们现在可以恢复对 Angular 的调查,并自信地面对组件创建的相关部分,比如样式封装、输出格式化等等。第四章,在我们的组件中实现属性和事件,将使我们接触到高级模板创建技术、数据绑定技术、指令和管道。所有这些特性将使我们能够将新获得的 TypeScript 知识付诸实践。

第四章:在我们的组件中实现属性和事件

到目前为止,我们有机会俯瞰新的 Angular 生态系统中组件的概述,它们的角色是什么,它们的行为如何,以及开始构建我们自己的组件来表示小部件和功能块所需的工具是什么。此外,TypeScript 证明是这项努力的完美伴侣,因此我们似乎拥有了进一步探索 Angular 为创建公开属性和发出事件所带来的可能性的一切所需的一切。

在本章中,我们将:

  • 发现我们可以使用的所有语法可能性来绑定内容

我们的模板

  • 为我们的组件创建公共 API,以便我们可以从它们的属性和事件处理程序中受益

  • 看看如何在 Angular 中实现数据绑定

  • 通过视图封装来减少 CSS 管理的复杂性

更好的模板语法

在第一章 在 Angular 中创建我们的第一个组件中,我们看到了如何在我们的组件中嵌入 HTML 模板,但我们甚至没有触及 Angular 模板开发的表面。正如我们将在本书中看到的,模板实现与 Shadow DOM 设计原则紧密耦合,并且它为我们在视图中以声明方式绑定属性和事件带来了大量的语法糖,以简化任务。

简而言之,Angular 组件可以公开一个公共 API,允许它们与其他组件或容器进行通信。这个 API 可能包括输入属性,我们用它来向组件提供数据。它还可以公开输出属性,我们可以将事件监听器绑定到它,从而及时获取有关组件状态变化的信息。

让我们看看 Angular 是如何通过快速简单的示例来解决将数据注入和注出我们的组件的问题的。请关注这些属性背后的哲学。我们将有机会在稍后看到它们的实际应用。

使用输入属性进行数据绑定

让我们重新审视定时器组件的功能,这是我们在第一章中已经看到的

在 Angular 中创建我们的第一个组件,让我们假设我们希望我们的组件具有可配置的属性,以便我们可以增加或减少倒计时时间:

<timer [seconds]="25"></timer>

请注意大括号之间的属性。这告诉 Angular 这是一个输入属性。模拟timer组件的类将包含一个seconds属性的 setter 函数,该函数将根据该值的变化来更新自己的倒计时持续时间。我们可以注入一个数据变量或一个实际的硬编码值,如果这样的值是文本字符串,则必须在双引号内用单引号括起来。

有时我们会看到这种语法,用于将数据注入到组件的自定义属性中,而在其他时候,我们将使用这种括号语法使原生 HTML 属性对组件字段具有响应性,就像这样:

<h1 [hidden]="hideMe">
 This text will not be visible if 'hideMe' is true
</h1>

在绑定表达式时的一些额外语法糖

Angular 团队已经为我们的组件指令和 DOM 元素提供了一些快捷方式,用于执行常见的转换,比如调整属性和类名或应用样式。在这里,我们有一些在属性中声明性地定义绑定时的时间节省示例:

<div [attr.hidden]="isHidden">...</div>
<input [class.is-valid]="isValid">
<div [style.width.px]="myWidth"></div>

在第一种情况下,如果isHidden表达式评估为truediv将启用隐藏属性。除了布尔值之外,我们还可以绑定任何其他数据类型,比如字符串值。在第二种情况下,如果isValid表达式评估为trueis-valid类名将被注入到 class 属性中。在我们的第三个例子中,div将具有一个样式属性,显示出一个以像素为单位设置的width属性的值,该值由myWidth表达式设置。您可以在 Angular 速查表(angular.io/guide/cheatsheet)中找到更多这种语法糖的例子,该速查表可在官方 Angular 网站上找到。

使用输出属性进行事件绑定

假设我们希望我们的计时器组件在倒计时结束时通知我们,以便我们可以执行组件范围之外的其他操作。我们可以通过输出属性实现这样的功能:

<timer (countdownComplete)="onCountdownCompleted()"></timer>

注意大括号之间的属性。这告诉 Angular,这样的属性实际上是一个输出属性,将触发我们绑定到它的事件处理程序。在这种情况下,我们将希望在包装此组件的容器对象上创建一个onCountownCompleted事件处理程序。

顺便说一句,驼峰命名不是巧合。这是 Angular 中应用于所有输出和输入属性名称的命名约定。

我们将找到与我们已知的交互事件映射的输出属性,例如clickmouseovermouseoutfocus等等:

<button (click)="doSomething()">click me</button>

输入和输出属性的作用

掌握前面章节中详细介绍的概念的最佳方法是实践。在第一章中,我们学习了如何使用 Webpack 或 Angular-CLI 从头开始构建应用程序。由于 Angular-CLI 被认为是设置项目的标准方式,让我们只使用它,并通过输入以下内容来创建一个新项目:

ng new InputOutputDemo

此时,我们有一个完全可用的项目,可以通过输入ng serve轻松启动。

让我们快速回顾一下 Angular 项目的结构,这样我们就知道如何处理我们即将创建的所有新构造。以下文件特别值得关注:

  • main.ts:这个文件引导我们的应用程序。

  • app/app.module.ts:这个文件声明了我们的根模块,任何新的构造都必须添加到这个模块的 declarations 属性中,或者您需要为这些未来的构造添加一个专门的模块。通常建议为我们的新构造拥有一个专门的模块。

在前面的项目列表中,我们提到了根模块的概念。我们提到这个概念是为了提醒自己关于 Angular 模块的一般情况。Angular 模块包含一堆彼此相关的构造。您可以通过使用@NgModule装饰器来识别 Angular 模块;模块本身只是一个普通的类。@NgModule装饰器以对象字面量作为输入,并且在这个对象字面量中注册属于模块的一切。

如前面的项目列表中所述,为我们的新构造添加一个专门的模块被认为是一个良好的做法,所以让我们这样做:

@NgModule({
 declarations: []
})
export class InputModule {}

此时,我们将declarations属性数组留空。一旦声明了我们的组件,我们将把它添加到该数组中。

这个模块目前还不属于应用程序,但它需要在根模块中注册。打开app.module.ts文件,并将新创建的模块添加到import数组中,就像这样:

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

让我们剥离我们在第一章中看到的定时器示例,在 Angular 中创建我们的第一个组件,并讨论一个更简单的例子。让我们看一下TimerComponent文件,并用以下组件类替换其内容:

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

@Component({
 selector : 'countdown-timer',
 template : '<h1>Time left: {{seconds}}</h1>'
})
export class CountdownTimerComponent {
 seconds: number = 25;
 intervalId: any;

 constructor() {
 this.intervalId = setInterval(() => this.tick(), 1000);
 }

 private tick(): void {
 if(--this.seconds < 1) {
 clearInterval(this.intervalId);
 }
 }
} 

太棒了!我们刚刚定义了一个简单但非常有效的倒计时组件,它将从 25 秒倒数到 0(你看到上面的seconds字段了吗?TypeScript 支持在声明时初始化成员)。一个简单的setInterval()循环执行一个名为tick()的自定义私有函数,它减少秒数的值直到达到零,此时我们只需清除间隔。

然而,现在我们只需要在某个地方嵌入这个组件,所以让我们创建另一个组件,除了作为前一个组件的 HTML 包装主机之外,没有其他功能。在同一个文件中,在CountdownTimerComponent类之后创建这个新组件:

@Component({
 selector: 'timer',
 template: '<countdown-timer></countdown-timer>'
})
export class TimerComponent {}

按照之前的承诺,我们还将把我们新创建的组件添加到它所属的模块的declarations数组中,就像这样:

@NgModule({
 declarations: [CountdownTimerComponent, TimerComponent]
})
export class InputModule {}

首先这样做的原因是确保这些组件可以相互使用,就像CountdownTimerComponentTimerComponent的模板中使用的情况一样。

在 Angular 中,组件基本上是带有视图模板的指令。我们还可以找到没有视图的指令,它们基本上为宿主元素添加新功能,或者它们只是作为不带 UI 的自定义元素包装其他元素。或者,它们通过 API 为其他组件提供更多功能。

我们将在下一章和整本书中详细探讨指令。你一定想知道为什么我们创建了这个没有实现的主机或父TimerComponent组件。很快,我们将为它增加一些更多的功能,但现在让我们将其用作初始化组件树的概念验证。

声明式设置自定义值

你可能会同意,设置自定义倒计时器的功能会很好,对吧?输入属性证明是实现这一点的一个很好的方式。为了利用这个功能,我们将不得不调整文件顶部的import语句。

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

@Component({
 selector: 'countdown-timer',
 template: '<h1>Time left: {{ seconds }}</h1>'
})
export class CountdownTimerComponent {
  @Input() seconds : number;
 intervalId;
 // rest of the implementation remains the same
}

你可能已经注意到,我们不再初始化seconds字段了,现在它被一个属性装饰器修饰(就像我们在第三章中看到的那样,介绍 TypeScript)。我们刚刚开始定义我们组件的 API。

属性命名区分大小写。Angular 强制执行的约定是对组件输入和输出属性都应用驼峰命名法,正如我们很快将看到的那样。

接下来,我们只需要在容器组件的模板中添加所需的属性:

@Component({
 selector: 'timer',
 template: `
 <div class="container text-center">
 <countdown-timer [seconds]="25"></countdown-timer>
 </div>`
})

请注意,我们根本没有更新TimerComponent。我们只更新了它的CountdownComponent子组件。然而,它全新的 API 可以在任何最终将其包含在自己模板中作为子组件的组件中使用,因此我们可以从模板中声明性地设置其属性,或者甚至可以从TimerComponent控制器类中的属性中以命令方式绑定值。

当使用@Input()标记类属性时,我们可以配置在 HTML 中实例化组件时希望该属性具有的名称。为此,我们只需要在装饰器签名中引入我们选择的名称,就像这样:@Input('name_of_the_property')。无论如何,这种做法是不鼓励的,因为在组件 API 中公开与其控制器类中定义的属性名称不同的属性名称只会导致混淆。

通过自定义事件在组件之间进行通信

现在我们的子组件正在被其父组件配置,如何

我们可以实现从子组件到父组件的通信吗?这就是自定义事件发挥作用的地方!为了创建适当的事件绑定,我们只需要在组件中配置一个输出属性,并将事件处理程序函数附加到它上面。

为了触发自定义事件,我们需要引入EventEmitter,以及@Output装饰器,其功能与我们学到的关于@Input装饰器完全相反:

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

EventEmitter是 Angular 的内置事件总线。简而言之,EventEmitter类支持发出Observable数据并订阅Observer消费者对数据更改。它的简单接口基本上包括两种方法,emit()subscribe(),因此可以用于触发自定义事件以及同步和异步地监听事件。我们将在第七章中更详细地讨论 Observables,使用 Angular 进行异步数据服务。目前,我们可以通过EventEmitterAPI 来生成事件,组件中托管我们发出事件的组件可以观察并附加事件处理程序。这些事件通过使用@Input()装饰器注释的任何属性在组件范围之外获得可见性。

以下代码显示了一个实际的实现,从前面的例子中跟进:

@Component({
 selector : 'countdown-timer',
 template : '<h1>Time left: {{ seconds }}</h1>'
})
export class CountdownTimerComponent {
 @Input() seconds : number;
 intervalId: any;
  @Output() complete: EventEmitter<any> = new EventEmitter();
 constructor() {
 this.intervalId = setInterval( () => this.tick(), 1000 );
 }

 private tick(): void {
 if(--this.seconds < 1) {
 clearTimeout(this.intervalId);
 // an event is emitted upon finishing the countdown
      this.complete.emit(null);
 }
 }
}

一个名为complete的新属性被方便地注释为EventEmitter类型,并立即初始化。稍后,我们将访问它的emit方法,以便在倒计时结束时生成一个自定义事件。emit()方法需要一个任意类型的必需参数,因此我们可以向事件订阅者发送数据值(如果不需要,则为 null)。

现在,我们只需要设置我们的宿主组件,以便它将监听此完成事件或输出属性,并订阅一个事件处理程序:

@Component({
 selector : 'timer',
 template : `
 <div class="container text-center">
 <img src="assets/img/timer.png" />
 <countdown-timer [seconds]="25"
                 (complete)="onCountdownCompleted()">
 </countdown-timer>`
})
export class TimerComponent {
 onCountdownCompleted(): void {
 alert('Time up !')
 }
}

为什么是complete而不是onComplete?Angular 支持另一种语法形式,称为规范形式,用于输入和输出属性。在输入属性的情况下,一个属性表示为[seconds]可以表示为bind-seconds,无需使用括号。关于输出属性,这些可以表示为on-complete而不是(complete)。这就是为什么我们从不在输出属性名称前加上on前缀,因为这将出现在输出属性上,比如on-complete,如果我们最终决定在我们的项目中更喜欢规范语法形式。

我们已经学会了如何使用组件的输入数据。数据将驻留在容器中,组件将在容器模板内呈现。这意味着组件可以通过我们输入的方式突然访问容器的数据:

<component [property]="propertyOnContainer">

在组件方面,代码如下所示:

@Component({
 selector : 'component'
})
export class Component {
  @Input() property;
}

我们还学习了输出,也就是如何从组件向容器进行通信。为了实现这一点,我们在组件上添加了另一个属性,如下所示:

<component (event)="methodOnContainer()" [property]="propertyOnContainer">

在组件方面,我们将使用一个名为Output的装饰器,如下所示:

@Component({
 selector : 'component'
})
export class Component {
  @Output() event = new EventEmitter<any>();
}

并积极调用绑定的方法,我们会输入:

event.emit();

接下来要学习的是如何从组件传递数据到容器。

通过自定义事件发出数据

既然我们知道如何从组件 API 发出自定义事件,为什么不再进一步,将数据信号发送到组件范围之外呢?我们已经讨论过EventEmitter<T>类的emit()事件在其签名中接受由T注释表示的任何给定数据。让我们扩展我们的示例以通知倒计时的进度。为什么我们要这样做呢?基本上,我们的组件在屏幕上显示一个可视倒计时,但我们可能希望以编程方式观察倒计时的进度,以便在倒计时结束或达到某一点时采取行动。

让我们用另一个输出属性更新我们的计时器组件,与之匹配

原始的并在每次迭代seconds属性时发出自定义事件,

如下所示:

class CountdownTimerComponent {
 @Input() seconds: number;
  @Output() complete: EventEmitter<any> = new EventEmitter();
 @Output() progress: EventEmitter<number> = new EventEmitter();
 intervalId;

 constructor() {
 this.intervalId = setInterval(() => this.tick(), 1000);
 }

 private tick(): void {
 if(--this.seconds < 1) {
 clearTimeout(this.intervalId);
      this.complete.emit(null);
 }
    this.progress.emit(this.seconds);
 }
}

现在,让我们重建主机组件的模板,以反映倒计时的实际进度。我们已经通过显示倒计时来做到这一点,但这是由CountdownTimerComponent在内部处理的功能。现在,我们将在该组件之外跟踪倒计时:

@Component({
 selector: 'timer',
 template: `
 <div class="container text-center">
 <countdown-timer [seconds]="25"
                 (progress)="timeout = $event"
                 (complete)="onCountdownCompleted()" >
 </countdown-timer>
 <p *ngIf="timeout < 10">
 Beware! Only
 <strong>{{ timeout }} seconds</strong>
 </p>
 </div>` 
})
export class TimerComponent {
 timeout: number;
 onCountdownCompleted(): void {
 alert('Time up')
 }
}

我们利用这一轮更改来将超时值正式化为主机组件的属性。这使我们能够在我们的自定义事件处理程序中将新值绑定到该属性,就像我们在前面的示例中所做的那样。我们不是将事件处理程序方法绑定到(progress)处理程序,而是引用$event保留变量。它是指向progress output属性的有效负载的指针,反映了我们在执行this.progress.emit(this.seconds)时传递给emit()函数的值。简而言之,$eventCountdownTimerComponentthis.seconds所假定的值。通过将这样的值分配给模板中的timeout类属性,我们还更新了模板中插入的段落中表达的绑定。当timeout小于10时,此段落将变为可见。

<countdown-timer [seconds]="25"
           (progress)="timeout = $event"
           (complete)="onCountdownCompleted()">
</countdown-timer>

在本节中,我们看到了如何从组件发送数据到容器。基本上有两种方法:

  • $event分配给容器属性

  • 使用$event作为函数参数调用容器方法

第一个版本就是我们所演示的:

<countdown [seconds]="25" (progress)="timeout = $event" >
</countdown>

组件调用它如下:

progress.emit(data);

第二个版本是对前面示例的小改写:

<countdown [seconds]="25" (progress)="onProgress($event)">
</countdown>

我们会以与组件相同的方式调用它,但不同之处在于我们需要声明一个容器方法onProgress,这样timeout属性就会以这种方式设置:

onProgress(data) {
 this.timeout = data;
}

模板中的本地引用

我们之前已经看到了如何使用双大括号语法通过数据插值将数据绑定到我们的模板。除此之外,我们经常会在属于我们组件或甚至常规 HTML 控件的元素中看到以井号(#)为前缀的命名标识符。这些引用标识符,即本地名称,用于在我们的模板视图中引用标记为它们的组件,然后以编程方式访问它们。它们也可以被组件用来引用虚拟 DOM 中的其他元素并访问其属性。

在前一节中,我们看到了如何通过progress事件订阅倒计时的进度。但是,如果我们能深入检查组件,或者至少是它的公共属性和方法,并在不必监听progress事件的情况下读取seconds属性在每个滴答间隔中的值,那该多好啊?好吧,给组件本身设置一个本地引用将打开其公共外观的大门。

让我们在TimerComponent模板中标记我们的CountdownTimerComponent实例,使用一个名为#counter的本地引用。从那一刻起,我们将能够直接访问组件的公共属性,比如seconds,甚至在模板的其他位置绑定它。这样,我们甚至不需要依赖progress事件发射器或timeout类字段,甚至可以操纵这些属性的值。这在下面的代码中显示:

@Component({
 selector: 'timer',
 template: `
 <div class="container text-center">
 <countdown-timer [seconds]="25"
 (complete)="onCountdownCompleted()"
                 #counter >
 </countdown-timer>
 <p>
 <button class="btn btn-default"
 (click)="counter.seconds = 25">
 reset
 </button>
 </p>
 <p *ngIf="counter.seconds < 10">
 Beware, only !
 <strong>{{ counter.seconds }} seconds</strong>
 </p>
 </div>`
})
export class TimerComponent {
 // timeout: any /* No longer required */
 onCountdownCompleted(): void {
 alert('Time up'); 
 }
}

输入和输出属性的替代语法

除了@Input()@Output()装饰器之外,还有一种替代语法,我们可以通过@Component装饰器来定义组件的inputoutput属性。它的元数据实现通过inputsoutputs属性名称分别提供对这两个功能的支持。

因此,CountdownTimerComponent的 API 可以这样实现:

@Component({
 selector : 'countdown-timer',
 template : '<h1>Time left: {{seconds}}</h1>',
  inputs : ['seconds'],
  outputs : ['complete','progress']
})
export class CountdownTimerComponent {
  seconds: number;
 intervalId;
  complete: EventEmitter<any> = new EventEmitter();
 progress: EventEmitter<any> = new EventEmitter();
 // And so on..
}

总的来说,这种语法是不鼓励的,仅出于参考目的而包含在这里。首先,我们通过在两个地方定义 API 端点的名称来重复代码,增加了重构代码时出错的风险。另外,通常惯例是尽量保持装饰器的实现尽可能简洁,以提高可读性。

我强烈建议您坚持使用@Input@Output装饰器。

从组件类配置我们的模板

组件元数据还支持一些设置,有助于简化模板管理和配置。另一方面,Angular 利用了 Web 组件的 CSS 封装功能。

内部和外部模板

随着应用程序的规模和复杂性的增长,我们的模板也可能会增长,承载其他组件和更大的 HTML 代码块。将所有这些代码嵌入到我们的组件类定义中将变得繁琐和不愉快,而且也很容易出错。为了防止这种情况发生,我们可以利用templateUrl属性,指向一个包含我们组件 HTML 标记的独立 HTML 文件。

回到我们之前的例子,我们可以重构TimerComponent类的@Component装饰器,指向一个包含我们模板的外部 HTML 文件。在我们的timer.component.ts文件所在的工作区中创建一个名为timer.component.html的新文件,并用我们在TimerComponent类中配置的相同 HTML 填充它:

<div class="container text-center">
 <countdown [seconds]="25"
 (complete)="onCountdownCompleted()"
 #counter >
 </countdown>
 <p>
 <button class="btn btn-default"
 (click)="counter.seconds = 25">
 Reset countdown to 25 seconds
 </button>
 </p>
 <p *ngIf="counter.seconds < 10">
 Beware only !
 <strong>{{ seconds }} seconds</strong> left
 </p>
</div>

现在,我们可以修改@Component装饰器,指向该文件,而不是在装饰器元数据中定义 HTML:

@Component({
 selector: 'timer',
 templateUrl: './timer.component.html'
})
export class TimerComponent {
 // Class follows below
}

外部模板遵循 Angular 中的某种约定,由最流行的 Angular 编码风格指南强制执行,即与它们所属的组件共享相同的文件名,包括我们可能附加到组件文件名的任何前缀或后缀。在第六章中探索组件命名约定时,我们将看到这一点,使用 Angular 组件构建应用程序。这样,更容易识别,甚至可以使用 IDE 的内置模糊查找工具搜索,哪个 HTML 文件实际上是特定组件的模板。

在哪种情况下创建独立模板而不是将模板标记保留在组件内?这取决于模板的复杂性和大小。在这种情况下,常识将是您最好的顾问。

封装 CSS 样式

为了更好地封装我们的代码并使其更具重用性,我们可以在组件内定义 CSS 样式。这些内部样式表是使我们的组件更具共享性和可维护性的好方法。有三种不同的方法来定义我们组件的 CSS 样式。

styles 属性

我们可以通过组件装饰器中的styles属性为我们的 HTML 元素和类名定义样式,如下所示:

@Component({
 selector : 'my-component',
 styles : [`
 p {
 text-align: center;
 }
 table {
 margin: auto;
 }
 `]
})
export class ExampleComponent {}

此属性将接受一个字符串数组,每个字符串包含 CSS 规则,并在我们启动应用程序时将这些规则嵌入到文档的头部以应用于模板标记。我们可以将样式规则内联为一行,也可以利用 ES2015 模板字符串来缩进代码并使其更可读,就像前面的示例中所示。

styleUrls 属性

就像styles一样,styleUrls也会接受一个字符串数组,尽管每个字符串都代表一个外部样式表的链接。这个属性也可以与styles属性一起使用,根据需要定义不同的规则集:

@Component({
 selector: 'my-component',
 styleUrls: ['path/to/my-stylesheet.css'], // use this
 styles : [
 `
 p { text-align : center; }
 table { margin: auto; }
 `
 ]  // and this at the same time
})
export class MyComponent {}

内联样式表

我们还可以将样式规则附加到模板本身,无论是内联模板还是通过templateUrl参数提供的模板:

@Component({
 selector: 'app',
 template: `
 <style> p { color : red; } </style>
 <p>I am a red paragraph </p>
 `
})
export class AppComponent {}

管理视图封装

所有前面的部分(stylesstyleUrls和内联样式表)都将受到 CSS 特异性的通常规则的约束(developer.mozilla.org/en/docs/Web/CSS/Specificity)。在支持 Shadow DOM 的浏览器上,由于作用域样式,CSS 管理和特异性变得轻而易举。CSS 样式适用于组件中包含的元素,但不会超出其边界。

此外,Angular 将嵌入这些样式表到文档的头部,因此它们可能会影响我们应用程序的其他元素。为了防止这种情况发生,我们可以设置不同级别的视图封装。

简而言之,封装是 Angular 需要在组件内管理 CSS 作用域的方式,适用于支持阴影 DOM 的浏览器和不支持它的浏览器。为此,我们利用ViewEncapsulation enum,它可以采用以下任何值:

  • 模拟:这是默认选项,基本上是通过在特定选择器下沙盒化 CSS 规则来模拟阴影 DOM 中的本地作用域。推荐使用此选项,以确保我们的组件样式不会受到站点上其他现有库的影响。

  • 本地:使用渲染器的本地阴影 DOM 封装机制,仅适用于支持阴影 DOM 的浏览器。

  • 无:不提供模板或样式封装。样式将按原样注入到文档的头部。

让我们看一个实际的例子。首先,将ViewEncapsulation enum导入脚本,然后创建一个模拟值的封装属性。然后,让我们为倒计时文本创建一个样式规则,以便任何<h1> (!)标签都呈现为深红色:

import {
 Component,
 EventEmitter, 
 Input,
 Output, 
 ViewEncapsulation
} from '@angular/core';
@Component({
 selector: 'countdown-timer',
 template: '<h1>Time left: {{seconds}}</h1>',
 styles: ['h1 { color: #900}'],
 encapsulation: ViewEncapsulation.Emulated 
})
export class CountdownTimerCoponent { 
 // Etc
}

现在,点击浏览器的开发工具检查器,并检查生成的 HTML,以发现 Angular 如何将 CSS 注入到页面的<head>块中。刚刚注入的样式表已经被沙盒化,以确保我们在组件设置中以非常不具体的方式定义的全局 CSS 规则仅适用于由CountdownTimerComponent组件专门作用域的匹配元素。

我们建议您尝试不同的值,并查看 CSS 代码如何注入到文档中。您将立即注意到每种变化提供的隔离等级不同。

总结

本章引导我们了解了 Angular 中为组件创建强大 API 的选项,这样我们就可以在组件之间提供高水平的互操作性,通过分配静态值或管理绑定来配置其属性。我们还看到了一个组件如何可以作为另一个子组件的宿主组件,实例化前者的自定义元素在其自己的模板中,为我们的应用程序中更大的组件树奠定了基础。输出参数为我们提供了所需的交互层,通过将我们的组件转换为事件发射器,使它们可以以一种不可知的方式与任何可能最终托管它们的父组件进行通信。模板引用为我们的自定义元素创建了引用的途径,我们可以以声明性的方式从模板内部使用它们的属性和方法。我们还讨论了如何将组件的 HTML 模板隔离在外部文件中,以便于将来的维护,以及如何对我们想要绑定到组件的任何样式表执行相同的操作,以防我们不想将组件样式内联绑定。对 Angular 中处理视图封装的内置功能的概述为我们提供了一些额外的见解,让我们了解了如何可以从每个组件的角度受益于 Shadow DOM 的 CSS 封装,以及在不支持时如何进行 polyfill。

在 Angular 中,我们仍然有很多关于模板管理的东西要学习,主要是关于你在使用 Angular 过程中会广泛使用的两个概念。我指的是指令和管道,在第五章中我们将对其进行详细介绍,《使用管道和指令增强我们的组件》。

第五章:通过管道和指令增强我们的组件

在之前的章节中,我们构建了几个组件,借助输入和输出属性在屏幕上呈现数据。我们将利用本章的知识,通过使用指令和管道,将我们的组件提升到一个新的水平。简而言之,管道为我们提供了在模板中绑定的信息进行解析和转换的机会,而指令允许我们进行更有野心的功能,我们可以访问宿主元素的属性,并绑定我们自己的自定义事件监听器和数据绑定。

在本章中,我们将:

  • 全面了解 Angular 的内置指令

  • 探讨如何使用管道来优化我们的数据输出

  • 看看如何设计和构建我们自己的自定义管道和指令

  • 利用内置对象来操作我们的模板

  • 将所有前述主题和更多内容付诸实践,以构建一个完全交互式的待办事项表

Angular 中的指令

Angular 将指令定义为没有视图的组件。事实上,组件是具有关联模板视图的指令。之所以使用这种区别,是因为指令是 Angular 核心的一个重要部分,每个指令(普通指令和组件指令)都需要另一个存在。指令基本上可以影响 HTML 元素或自定义元素的行为和显示其内容。

核心指令

让我们仔细研究一下框架的核心指令,然后您将在本章后面学习如何构建自己的指令。

NgIf

正如官方文档所述,ngIf指令根据表达式删除或重新创建 DOM 树的一部分。如果分配给ngIf指令的表达式求值为false,则该元素将从 DOM 中移除。否则,元素的克隆将重新插入 DOM 中。我们可以通过利用这个指令来增强我们的倒计时器,就像这样:

<timer> [seconds]="timeout"></timer>
<p *ngIf="timeout === 0">Time up!</p>

当我们的计时器达到 0 时,将在屏幕上呈现显示“时间到!”文本的段落。您可能已经注意到了在指令前面加上的星号。这是因为 Angular 将标有ngIf指令的 HTML 控件(以及其所有 HTML 子树,如果有的话)嵌入到<ng-template>标记中,稍后将用于在屏幕上呈现内容。涵盖 Angular 如何处理模板显然超出了本书的范围,但让我们指出,这是 Angular 提供的一种语法糖,作为其他更冗长的基于模板标记的语法的快捷方式。

也许您想知道使用*ngIf="conditional"在屏幕上呈现一些 HTML 片段与使用[hidden]="conditional"有什么区别。前者将克隆并注入模板化的 HTML 片段到标记中,在条件评估为false时从 DOM 中删除它,而后者不会从 DOM 中注入或删除任何标记。它只是设置带有该 DOM 属性的已存在的 HTML 片段的可见性。

NgFor

ngFor指令允许我们遍历集合(或任何其他可迭代对象),并将其每个项目绑定到我们选择的模板,我们可以在其中定义方便的占位符来插入项目数据。每个实例化的模板都作用域限定在外部上下文中,我们可以访问其他绑定。假设我们有一个名为Staff的组件:它具有一个名为 employees 的字段,表示一个Employee对象数组。我们可以这样列出这些员工和职位:

<ul>
 <li *ngFor="let employee of employees">
 Employee {{ employee.name }}, {{ employee.position }}
 </li>
</ul>

正如我们在提供的示例中看到的,我们将从每次循环中获取的可迭代对象中的每个项目转换为本地引用,以便我们可以轻松地在我们的模板中绑定这个项目。需要强调的是,表达式以关键字let开头。

该指令观察底层可迭代对象的更改,并将根据项目在集合中添加、删除或重新排序而添加、删除或排序呈现的模板。

高级循环

除了只循环列表中的所有项目之外,还可以跟踪其他可用属性。每个属性都可以通过在声明项目后添加另一个语句来使用:

<div *ngFor="let items of items; let property = property">{{ item }}</div>

First/last,这是一个布尔值,用于跟踪我们是否在循环中的第一个或最后一个项目上,如果我们想要以不同的方式呈现该项目。可以通过以下方式访问它:

<div *ngFor="let item of items; let first = first">
 <span [ngClass]="{ 'first-css-class': first, 'item-css-class' : !first }">
 {{ item }}
 </span>
</div>

Index,是一个数字,告诉我们我们在哪个索引上;它从 0 开始。

Even/odd是一个布尔值,指示我们是否在偶数或奇数索引上。

TrackBy,要解释trackBy做什么,让我们首先谈谈它试图解决的问题。问题是,*ngFor指向的数据可能会发生变化,元素可能会被添加或删除,甚至整个列表可能会被替换。对于添加/删除元素的天真方法是对所有这些元素在 DOM 树上进行创建/删除。如果使用相同的天真方法来显示新列表而不是我们用来显示这个旧列表,那将是非常昂贵和缓慢的。Angular 通过将 DOM 元素保存在内存中来处理这个问题,因为创建是昂贵的。在内部,Angular 使用称为对象标识的东西来跟踪列表中的每个项目。然而,trackBy允许您从对象标识更改为项目上的特定属性。默认的对象标识在大多数情况下都很好,但是如果您开始遇到性能问题,请考虑更改*ngFor应查看的项目的属性,如下所示:

@Component({
 template : `
 <*ngFor="let item of items; trackBy: trackFunction">{{ item }}</div>
 `
})
export class SomeComponent {
 trackFunction(index, item) {
 return item ? item.id : undefined;
 }
}

Else

Else 是 Angular 4.0 的一个新构造,并且是一个简写,可以帮助您处理条件语句。想象一下,如果您有以下内容:

<div *ngIf="hero">
 {{ hero.name }}
</div>
<div *ngIf="!hero">
 No hero set
</div>

我们在这里的用例非常清楚;如果我们设置了一个人,那么显示它的名字,否则显示默认文本。我们可以使用else以另一种方式编写这个:

<div *ngIf="person; else noperson">{{person.name}}</div>
<div #noperson>No person set</div>

这里发生的是我们如何定义我们的条件:

person; else noperson

我们说如果person已设置,那么继续,如果没有显示模板nopersonnoperson也可以应用于普通的 HTML 元素以及ng-template

应用样式

在您的标记中应用样式有三种方法:

  • 插值

  • NgStyle

  • NgClass

插值

这个版本是关于使用花括号并让它们解析应该应用什么类/类。您可以编写一个看起来像这样的表达式:

<div class="item {{ item.selected ? 'selected' : ''}}"

这意味着如果您的项目具有选定的属性,则应用 CSS 类 selected,否则应用空字符串,即没有类。虽然在许多情况下这可能足够,但它也有缺点,特别是如果需要应用多个样式,因为有多个需要检查的条件。

插值表达式在性能方面被认为是昂贵的,通常是不鼓励使用的。

NgStyle

正如你可能已经猜到的那样,这个指令允许我们通过评估自定义对象或表达式来绑定 CSS 样式。我们可以绑定一个对象,其键和值映射 CSS 属性,或者只定义特定属性并将数据绑定到它们:

<p [ngStyle]="{ 'color': myColor, 'font-weight': myFontWeight }">
 I am red and bold
</p>

如果我们的组件定义了myColormyFontWeight属性,分别具有redbold的值,那么文本的颜色和粗细将相应地改变。该指令将始终反映组件内所做的更改,我们还可以传递一个对象,而不是按属性基础绑定数据:

<p [ngStyle]="myCssConfig">I am red and bold</p>

NgClass

ngStyle类似,ngClass允许我们以一种方便的声明性语法在 DOM 元素中定义和切换类名。然而,这种语法有其自己的复杂性。让我们看看这个例子中可用的三种情况:

<p [ngClass]="{{myClassNames}}">Hello Angular!</p>

例如,我们可以使用字符串类型,这样如果myClassNames包含一个由空格分隔的一个或多个类的字符串,所有这些类都将绑定到段落上。

我们也可以使用数组,这样每个元素都会被添加。

最后但同样重要的是,我们可以使用一个对象,其中每个键对应于由布尔值引用的 CSS 类名。标记为true的每个键名将成为一个活动类。否则,它将被移除。这通常是处理类名的首选方式。

ngClass还有一种替代语法,格式如下:

[ngClass]="{ 'class' : boolean-condition, 'class2' : boolean-condition-two }"

简而言之,这是一个逗号分隔的版本,在条件为true时将应用一个类。如果有多个条件为true,则可以应用多个类。如果在更现实的场景中使用,它会看起来像这样:

<span [ngClass] ="{
 'light' : jedi.side === 'Light',
 'dark' : jedi.side === 'Dark'
}">
{{ jedi.name }}
</span>

生成的标记可能如下,如果jedi.side的值为light,则将 CSS 类 light 添加到 span 元素中:

<span class="light">Luke</span>

NgSwitch、ngSwitchCase 和 ngSwitchDefault

ngSwitch指令用于根据显示每个模板所需的条件在特定集合内切换模板。实现遵循几个步骤,因此在本节中解释了三个不同的指令。

ngSwitch将评估给定的表达式,然后切换和显示那些带有ngSwitchCase属性指令的子元素,其值与父ngSwitch元素中定义的表达式抛出的值匹配。需要特别提到带有ngSwitchDefault指令属性的子元素。该属性限定了当其ngSwitchCase兄弟元素定义的任何其他值都不匹配父条件表达式时将显示的模板。

我们将在一个例子中看到所有这些:

<div [ngSwitch]="weatherForecaseDay">
 <ng-template ngSwitchCase="today">{{weatherToday}}</ng-template>
 <ng-template ngSwitchCase="tomorrow">{{weatherTomorrow}}</ng-template>
 <ng-template ngSwitchDefault>
 Pick a day to see the weather forecast
 <ng-template>
</div>

[ngSwitch]参数评估weatherForecastDay上下文变量,每个嵌套的ngSwitchCase指令将针对其进行测试。我们可以使用表达式,但我们希望将ngSwitchCase包装在括号中,以便 Angular 可以正确地将其内容评估为上下文变量,而不是将其视为文本字符串。

NgPluralNgPluralCase的覆盖范围超出了本书的范围,但基本上提供了一种方便的方法来呈现或删除与开关表达式匹配的模板 DOM 块,无论是严格的数字还是字符串,类似于ngSwitchngSwitchWhen指令的方式。

使用管道操作模板绑定

因此,我们看到了如何使用指令根据我们的组件类管理的数据来呈现内容,但是还有另一个强大的功能,我们将在日常实践中充分利用 Angular。我们正在谈论管道。

管道允许我们在视图级别过滤和引导我们表达式的结果,以转换或更好地显示我们绑定的数据。它们的语法非常简单,基本上由管道符号分隔的要转换的表达式后面跟着管道名称(因此得名):

@Component({
 selector: 'greeting',
 template: 'Hello {{ name | uppercase }}'
})
export class GreetingComponent{ name: string; }

在前面的例子中,我们在屏幕上显示了一个大写的问候语。由于我们不知道名字是大写还是小写,所以我们通过在视图级别转换名称的值来确保一致的输出。管道是可链式的,Angular 已经内置了各种管道类型。正如我们将在本章中进一步看到的,我们还可以构建自己的管道,以在内置管道不足以满足需求的情况下对数据输出进行精细调整。

大写/小写管道

大写/小写管道的名称就是它的含义。就像之前提供的示例一样,这个管道可以将字符串输出设置为大写或小写。在视图中的任何位置插入以下代码,然后自行检查输出:

<p>{{ 'hello world' | uppercase}}</p>  // outputs HELLO WORLD
<p>{{ 'wEIrD hElLo' | lowercase}}</p>  // outputs weird hello

小数、百分比和货币管道

数值数据可以有各种各样的类型,当涉及到更好的格式化和本地化输出时,这个管道特别方便。这些管道使用国际化 API,因此只在 Chrome 和 Opera 浏览器中可靠。

小数管道

小数管道将帮助我们使用浏览器中的活动区域设置定义数字的分组和大小。其格式如下:

number_expression | number[:digitInfo[:locale]]

在这里,number_expression是一个数字,digitInfo的格式如下:

{minIntegerDigits}.{minFractionDigits}-{maxFractionDigits}

每个绑定对应以下内容:

  • minIntegerDigits:要使用的整数位数的最小数字。默认为 1。

  • minFractionDigits:分数后的最小数字位数。默认为 0。

  • maxFractionDigits:分数后的最大数字位数。默认为 3。

请记住,每个数字和其他细节的可接受范围将取决于您的本地国际化实现。让我们尝试通过创建以下组件来解释这是如何工作的:

import { Component, OnInit } from  '@angular/core'; @Component({ selector:  'pipe-demo', template: ` <div>{{ no  |  number }}</div>   <!-- 3.141 --> <div>{{ no  |  number:'2.1-5' }}</div> <! -- 03.14114 --> <div>{{ no  |  number:'7.1-5' }}</div> <!-- 0,000,003.14114 -->
 <div>{{ no  |  number:'7.1-5':'sv' }}</div> <!-- 0 000 003,14114 -->
 ` }) export  class  PipeDemoComponent { no:  number  =  3.1411434344; constructor() { } }

这里有一个四种不同表达式的示例,展示了我们如何操作数字、分数以及区域设置。在第一种情况下,我们除了使用number管道之外没有给出任何指令。在第二个示例中,我们指定了要显示的小数位数和数字,通过输入number: '2.1-5'。这意味着我们在分数标记的左侧显示两个数字,右侧显示 5 个数字。因为左侧只有 3 个数字,我们需要用零来填充。右侧我们只显示 5 位小数。在第三个示例中,我们指示它显示 7 个数字在分数标记的左侧,右侧显示 5 个数字。这意味着我们需要在左侧填充 6 个零。这也意味着千位分隔符被添加了。我们的第四个示例演示了区域设置功能。我们看到显示的结果是千位分隔符的空格字符,小数点的逗号。

不过有一件事要记住;要使区域设置起作用,我们需要在根模块中安装正确的区域设置。原因是 Angular 只有从一开始就设置了 en-US 区域设置。不过添加更多区域设置非常容易。我们需要将以下代码添加到app.module.ts中:

import { BrowserModule } from  '@angular/platform-browser'; import { NgModule } from  '@angular/core'; import { AppComponent } from  './app.component'; import { PipeDemoComponent } from  "./pipe.demo.component"; 
import { registerLocaleData } from  '@angular/common'; import localeSV from '@angular/common/locales/sv'; 
registerLocaleData(localeSV**);** 
@NgModule({
  declarations: [ AppComponent, PipeDemoComponent ],
 imports: [ BrowserModule
 ],
 providers: [], bootstrap: [AppComponent] })
export  class  AppModule { }

百分比管道

百分比管道将数字格式化为本地百分比。除此之外,它继承自数字管道,以便我们可以进一步格式化输出,以提供更好的整数和小数大小和分组。它的语法如下:

number_expression | percent[:digitInfo[:locale]]

货币管道

这个管道将数字格式化为本地货币,支持选择货币代码,如美元的 USD 或欧元的 EUR,并设置我们希望货币信息显示的方式。它的语法如下:

number_expression | currency[:currencyCode[:display[:digitInfo[:locale]]]]

在前面的语句中,currencyCode显然是 ISO 4217 货币代码,而display是一个字符串

可以是code,假设值为symbolsymbol-narrow。值symbol-narrow指示是否使用货币符号(例如,$)。值symbol指示在输出中使用货币代码(例如 USD)。与小数和百分比管道类似,我们可以通过digitInfo值格式化输出,还可以根据区域设置格式化。

在下面的示例中,我们演示了所有三种形式:

import { Component, OnInit } from  '@angular/core'; 
@Component({ selector:  'currency-demo', template: ` <p>{{ 11256.569  |  currency:"SEK":'symbol-narrow':'4.1-2' }}</p> <!--kr11,256.57 --> <p>{{ 11256.569  |  currency:"SEK":'symbol':'4.1-3' }}</p> <!--SEK11,256.569 --> <p>{{ 11256.569  |  currency:"SEK":'code' }}</p> <!--SEK11,256.57 --> `
})
export  class  CurrencyDemoComponent { constructor() { } }  

切片管道

这个管道的目的相当于Array.prototype.slice()String.prototype.slice()在减去集合列表、数组或字符串的子集(切片)时所起的作用。它的语法非常简单,遵循与前述slice()方法相同的约定:

expression | slice: start[:end]

基本上,我们配置一个起始索引,我们将从中开始切片项目数组或字符串的可选结束索引,当省略时,它将回退到输入的最后索引。

开始和结束参数都可以取正值和负值,就像 JavaScript 的slice()方法一样。请参考 JavaScript API 文档,了解所有可用场景的详细情况。

最后但并非最不重要的是,请注意,在操作集合时,返回的列表始终是副本,即使所有元素都被返回。

日期管道

你一定已经猜到了,日期管道根据请求的格式将日期值格式化为字符串。格式化输出的时区将是最终用户机器的本地系统时区。它的语法非常简单:

date_expression | date[:format[:timezone[:locale]]]

表达式输入必须是一个日期对象或一个数字(自 UTC 纪元以来的毫秒数)。格式参数是高度可定制的,并接受基于日期时间符号的各种变化。为了我们的方便,一些别名已经被提供为最常见的日期格式的快捷方式:

  • '中等':这相当于'yMMMdjms'(例如,对于 en-US,Sep 3, 2010, 12:05:08 PM)

  • '短':这相当于'yMdjm'(例如,9/3/2010, 12:05 PM

对于 en-US)

  • 'fullDate':这相当于'yMMMMEEEEd'(例如,对于 en-US,Friday, September 3, 2010)

  • '长日期':这相当于'yMMMMd'(例如,September 3, 2010)

  • '中等日期':这相当于'yMMMd'(例如,对于 en-US,Sep 3, 2010)

  • '短日期':这相当于'yMd'(例如,对于 en-US,9/3/2010)

  • '中等时间':这相当于'jms'(例如,对于 en-US,12:05:08 PM)

  • '短时间':这相当于'jm'(例如,对于 en-US,12:05 PM)

  • json 管道

JSON 管道

JSON 可能是定义中最直接的管道;它基本上以对象作为输入,并以 JSON 格式输出它:

import { Component } from  '@angular/core'; 
@Component({
  selector:  'json-demo', template: ` {{ person | json **}}** 
 **<!--{ "name": "chris", "age": 38, "address": { "street": "Oxford Street", "city": "London" }** } --> `
})
export  class  JsonDemoComponent { person  = { name:  'chris', age:  38, address: { street:  'Oxford Street', city:  'London' }
 }

 constructor() { } }  

使用 Json 管道的输出如下:{ "name": "chris", "age": 38, "address": { "street": "Oxford Street", "city": "London" } }。这表明管道已将单引号转换为双引号,从而生成有效的 JSON。那么,我们为什么需要这个?一个原因是调试;这是一个很好的方式来查看复杂对象包含什么,并将其漂亮地打印到屏幕上。正如您从前面的字段'person'中看到的,它包含一些简单的属性,但也包含复杂的'address'属性。对象越深,json 管道就越好。

i18n 管道

作为 Angular 对提供强大国际化工具集的坚定承诺的一部分,已经提供了一组针对常见 i18n 用例的管道。本书将只涵盖两个主要的管道,但很可能在将来会发布更多的管道。请在完成本章后参考官方文档以获取更多信息。

i18nPlural 管道

i18nPlural管道有一个简单的用法,我们只需评估一个数字值与一个对象映射不同的字符串值,根据评估的结果返回不同的字符串。这样,我们可以根据数字值是零、一、二、大于N等不同的情况在我们的模板上呈现不同的字符串。语法如下:

expression | i18nPlural:mapping[:locale]

让我们看看这在你的组件类上的一个数字字段jedis上是什么样子的:

<h1> {{ jedis | i18nPlural:jediWarningMapping }} </h1>

然后,我们可以将这个映射作为我们组件控制器类的一个字段:

export class i18DemoComponent {
 jedis: number = 11;
 jediWarningMapping: any = {
 '=0': 'No jedis',
 '=1' : 'One jedi present',
 'other' : '# jedis in sight'
 }
}

我们甚至通过在字符串映射中引入'#'占位符来绑定表达式中评估的数字值。当找不到匹配的值时,管道将回退到使用键'other'设置的映射。

i18nSelect 管道

i18nSelect管道类似于i18nPlural管道,但它评估的是一个字符串值。这个管道非常适合本地化文本插值或根据状态变化提供不同的标签,例如。例如,我们可以回顾一下我们的计时器,并以不同的语言提供 UI:

<button (click)="togglePause()">
 {{ languageCode | i18nSelect:localizedLabelsMap }}
</button>

在我们的控制器类中,我们可以填充localizedLabelsMap,如下所示:

export class TimerComponent {
 languageCode: string ='fr';
 localizedLabelsMap: any = {
 'en' : 'Start timer',
 'es' : 'Comenzar temporizador',
 'fr' : 'Demarrer une sequence',
 'other' : 'Start timer' 
 }
}

重要的是要注意,我们可以在除了本地化组件之外的用例中使用这个方便的管道,而是根据映射键和类似的东西提供字符串绑定。与i18nPlural管道一样,当找不到匹配的值时,管道将回退到使用'other'键设置的映射。

异步管道

有时,我们管理可观察数据或仅由组件类异步处理的数据,并且我们需要确保我们的视图及时反映信息的变化,一旦可观察字段发生变化或异步加载在视图渲染后完成。异步管道订阅一个可观察对象或承诺,并返回它发出的最新值。当发出新值时,异步管道标记组件以检查更改。我们将在第七章中返回这个概念,使用 Angular 进行异步数据服务

将所有内容放在任务列表中

现在你已经学会了所有的元素,可以让你构建完整的组件,是时候把所有这些新知识付诸实践了。在接下来的页面中,我们将构建一个简单的任务列表管理器。在其中,我们将看到一个包含我们需要构建的待办事项的任务表。

我们还将直接从可用任务的积压队列中排队任务。这将有助于显示完成所有排队任务所需的时间,并查看我们工作议程中定义了多少任务。

设置我们的主 HTML 容器

在构建实际组件之前,我们需要先设置好我们的工作环境,为此,我们将重用在上一个组件中使用的相同的 HTML 样板文件。请将您迄今为止所做的工作放在一边,并保留我们在以前的示例中使用的package.jsontsconfig.jsontypings.jsonindex.html文件。如果需要的话,随时重新安装所需的模块,并替换我们index.html模板中的 body 标签的内容:

<nav class="navbar navbar-default navbar-static-top">
 <div class="container">
 <div class="navbar-header">
 <strong class="navbar-brand">My Tasks</strong>
 </div>
 </div>
</nav>
<tasks></tasks>

简而言之,我们刚刚更新了位于我们新的<tasks>自定义元素上方的标题布局的标题,该元素替换了以前的<timer>。您可能希望更新app.module.ts文件,并确保将任务作为一个可以在我们模块之外可见的组件,输入到exports关键数组中:

@NgModule({
  declarations : [ TasksComponent ],
 imports : [ ],
 providers : [],
  exports : [ TasksComponent ]
})
export class TaskModule{}

让我们在这里强调一下,到目前为止,应用程序有两个模块:我们的根模块称为AppModule和我们的TaskModule。我们的根模块应该像这样导入我们的TaskModule

@NgModule({
 imports : [
 BrowserModule,
    TaskModule
 ]
})
export class AppModule {}

使用 Angular 指令构建我们的任务列表表格

创建一个空的 tasks.ts 文件。您可能希望使用这个新创建的文件从头开始构建我们的新组件,并在其中嵌入我们将在本章后面看到的所有伴随管道、指令和组件的定义。

现实生活中的项目从未以这种方式实现,因为我们的代码必须符合“一个类,一个文件”的原则,利用 ECMAScript 模块将事物粘合在一起。第六章,使用 Angular 组件构建应用程序,将向您介绍构建 Angular 应用程序的一套常见最佳实践,包括组织目录树和不同元素(组件、指令、管道、服务等)的可持续方式。相反,本章将利用tasks.ts将所有代码包含在一个中心位置,然后提供我们现在将涵盖的所有主题的鸟瞰视图,而无需在文件之间切换。请记住,这实际上是一种反模式,但出于教学目的,我们将在本章中最后一次采用这种方法。文件中声明元素的顺序很重要。如果出现异常,请参考 GitHub 中的代码存储库。

在继续我们的组件之前,我们需要导入所需的依赖项,规范我们将用于填充表格的数据模型,然后搭建一些数据,这些数据将由一个方便的服务类提供。

让我们首先在我们的tasks.ts文件中添加以下代码块,导入我们在本章中将需要的所有标记。特别注意我们从 Angular 库中导入的标记。我们已经介绍了组件和输入,但其余的内容将在本章后面进行解释:

import { 
 Component,
 Input,
 Pipe,
 PipeTransform,
 Directive,
 OnInit,
 HostListener
 } from '@angular/core';

已经导入了依赖标记,让我们在导入的代码块旁边定义我们任务的数据模型:

/// Model interface
interface Task {
 name: string;
 deadline: Date;
 queued: boolean;
 hoursLeft: number;
}

Task模型接口的架构非常容易理解。每个任务都有一个名称,一个截止日期,一个字段用于通知需要运送多少单位,以及一个名为queued的布尔字段,用于定义该任务是否已被标记为在下一个会话中完成。

您可能会惊讶我们使用接口而不是类来定义模型实体,但当实体模型不需要实现方法或在构造函数或 setter/getter 函数中进行数据转换时,这是完全可以的。当后者不需要时,接口就足够了,因为它以简单且更轻量的方式提供了我们需要的静态类型。

现在,我们需要一些数据和一个服务包装类,以集合Task对象的形式提供这样的数据。在这里定义的TaskService类将起到作用,因此请在Task接口之后立即将其附加到您的代码中:

/// Local Data Service
class TaskService {
 public taskStore: Array<Task> = [];
 constructor() {
 const tasks = [
 {
 name : 'Code and HTML table',
 deadline : 'Jun 23 2015',
 hoursLeft : 1
 }, 
 {
 name : 'Sketch a wireframe for the new homepage',
 deadline : 'Jun 24 2016',
 hoursLeft : 2
 }, 
 {
 name : 'Style table with bootstrap styles',
 deadline : 'Jun 25 2016',
 hoursLeft : 1
 }
 ];

 this.taskStore = tasks.map( task => {
 return {
 name : task.name,
 deadline : new Date(task.deadline),
 queued : false,
 hoursLeft : task.hoursLeft 
 };
 })
 }
}

这个数据存储相当简单明了:它公开了一个taskStore属性,返回一个符合Task接口的对象数组(因此受益于静态类型),其中包含有关名称、截止日期和时间估计的信息。

现在我们有了一个数据存储和一个模型类,我们可以开始构建一个 Angular 组件,该组件将使用这个数据源来呈现我们模板视图中的任务。在您之前编写的代码之后插入以下组件实现:

/// Component classes
// - Main Parent Component
@Component({
 selector : 'tasks',
 styleUrls : ['tasks.css'],
 templateUrl : 'tasks.html'
})
export class TaskComponent {
 today: Date;
 tasks: Task[];
 constructor() {
 const TasksService: TaskService = new TasksService();
 this.tasks = tasksService.taskStore;
 this.today = new Date();
 }
}

正如您所见,我们通过引导函数定义并实例化了一个名为TasksComponent的新组件,选择器为<tasks>(我们在填充主index.html文件时已经包含了它,记得吗?)。这个类公开了两个属性:今天的日期和一个任务集合,它将在组件视图中的表中呈现,我们很快就会看到。为此,在其构造函数中实例化了我们之前创建的数据源,并将其映射到作为Task对象类型的模型数组,由任务字段表示。我们还使用 JavaScript 内置的Date对象的实例初始化了 today 属性,其中包含当前日期。

正如您所见,组件选择器与其控制器类命名不匹配。我们将在本章末深入探讨命名约定,作为第六章《使用 Angular 组件构建应用程序》的准备工作。

现在让我们创建样式表文件,其实现将非常简单明了。在我们的组件文件所在的位置创建一个名为tasks.css的新文件。然后,您可以使用以下样式规则填充它:

h3, p {
 text-align : center;
}

table {
 margin: auto;
 max-width: 760px;
}

这个新创建的样式表非常简单,以至于它可能看起来有点多余作为一个独立的文件。然而,在我们的示例中,这是展示组件元数据的styleUrls属性功能的好机会。

关于我们的 HTML 模板,情况大不相同。这一次,我们也不会在组件中硬编码我们的 HTML 模板,而是将其指向外部 HTML 文件,以更好地管理我们的呈现代码。请在与我们的主要组件控制器类相同的位置创建一个 HTML 文件,并将其保存为tasks.html。创建完成后,使用以下 HTML 片段填充它:

<div class="container text-center">
 <img src="assets/img/task.png" alt="Task" />
 <div class="container">
 <h4>Tasks backlog</h4>
 <table class="table">
 <thead>
 <tr>
 <th> Task ID</th>
 <th>Task name</th>
 <th>Deliver by</th>
 <th></th>
 <th>Actions</th>
 </tr>
 </thead>
 <tbody>
 <tr *ngFor="let task of tasks; let i = index">
 <th scope="row">{{i}}</th>
 <td>{{ task.name | slice:0:35 }}</td>
 <span [hidden]="task.name.length < 35">...</span>
 </td>
 <td>
 {{ task.deadline | date:'fullDate' }}
 <span *ngIf="task.deadline < today" 
 class="label label-danger">
 Due
 </span>
 </td>
 <td class="text-center">
 {{ task.hoursLeft }}
 </td>
 <td>[Future options...]</td>
 </tbody>
 </table>
</div> 

基本上,我们正在创建一个基于 Bootstrap 框架的具有整洁样式的表格。然后,我们使用始终方便的ngFor指令渲染所有任务,提取并显示我们在本章早些时候概述ngFor指令时解释的集合中每个项目的索引。

请看我们如何通过管道格式化任务名称和截止日期的输出,以及如何方便地显示(或不显示)省略号来指示文本是否超过了我们为名称分配的最大字符数,方法是将 HTML 隐藏属性转换为绑定到 Angular 表达式的属性。所有这些呈现逻辑都标有红色标签,指示给定任务是否在截止日期之前到期。

您可能已经注意到,这些操作按钮在我们当前的实现中不存在。我们将在下一节中修复这个问题,在我们的组件中玩转状态。回到第一章,在 Angular 中创建我们的第一个组件,我们提到了点击事件处理程序来停止和恢复倒计时,然后在第四章,在我们的组件中实现属性和事件中更深入地讨论了这个主题,我们涵盖了输出属性。让我们继续研究,看看我们如何将 DOM 事件处理程序与我们组件的公共方法连接起来,为我们的组件添加丰富的交互层。

在我们的任务列表中切换任务

将以下方法添加到您的TasksComponent控制器类中。它的功能非常基本;我们只是简单地切换给定Task对象实例的 queued 属性的值:

toggleTask(task: Task): void {
 task.queued = !task.queued;
}

现在,我们只需要将其与我们的视图按钮连接起来。更新我们的视图,包括在ngFor循环中创建的按钮中的点击属性(用大括号括起来,以便它充当输出属性)。现在,我们的Task对象将具有不同的状态,让我们通过一起实现ngSwitch结构来反映这一点:

<table class="table">
 <thead>
 <tr>
 <th>Task ID</th>
 <th>Task name</th>
 <th>Deliver by</th>
 <th>Units to ship</th>
 <th>Actions</th>
 </tr>
 </thead>
 <tbody>
 <tr *ngFor="let task of tasks; let i = index">
 <th scope="row">{{i}}
 <span *ngIf="task.queued" class="label label-info">Queued</span>
 </th>
 <td>{{task.name | slice:0:35}}
 <span [hidden]="task.name.length < 35">...</span>
 </td>
 <td>{{ task.deadline | date:'fullDate'}}
 <span *ngIf="task.deadline < today" class="label label-danger">Due</span>
 </td>
 <td class="text-center">{{task.hoursLeft}}</td>
 <td>
 <button type="button" 
 class="btn btn-default btn-xs"
 (click)="toggleTask(task)"
 [ngSwitch]="task.queued">
 <ng-template ngSwitchCase="false">
 <i class="glyphicon glyphicon-plus-sign"></i>
 Add
 </ng-template>
 <ng-template ngSwitchCase="true">
 <i class="glyphicon glyphicon-minus-sign"></i>
 Remove
 <ng-template>
 <ng-template ngSwitchDefault>
 <i class="glyphicon glyphicon-plus-sign"></i>
 Add
 </ng-template>
 </button>
 </td>
 </tbody>
</table>

我们全新的按钮可以在我们的组件类中执行“toggleTask()”方法,将Task对象作为参数传递给ngFor迭代对应的对象。另一方面,先前的ngSwitch实现允许我们根据Task对象在任何给定时间的状态来显示不同的按钮标签和图标。

我们正在用从 Glyphicons 字体系列中获取的字体图标装饰新创建的按钮。这些图标是我们之前安装的 Bootstrap CSS 捆绑包的一部分,与 Angular 无关。请随意跳过使用它或用另一个图标字体系列替换它。

现在执行代码并自行检查结果。整洁,不是吗?但是,也许我们可以通过向任务列表添加更多功能来从 Angular 中获得更多的效果。

在我们的模板中显示状态变化

现在我们可以从表中选择要完成的任务,很好地显示出我们需要运送多少个单位的一些视觉提示将是很好的。逻辑如下:

  • 用户审查表上的任务,并通过点击每个任务来选择要完成的任务

  • 每次点击一行时,底层的Task对象状态都会发生变化,并且其布尔排队属性会被切换

  • 状态变化立即通过在相关任务项上显示queued标签来反映在表面上

  • 用户得到了需要运送的单位数量的提示信息和交付所有这些单位的时间估计

  • 我们看到在表格上方显示了一排图标,显示了所有要完成的任务中所有单位的总和

这个功能将不得不对我们处理的Task对象集的状态变化做出反应。好消息是,由于 Angular 自己的变化检测系统,使组件完全意识到状态变化变得非常容易。

因此,我们的第一个任务将是调整我们的TasksComponent类,以包括一种计算和显示排队任务数量的方法。我们将使用这些信息来在我们的组件中渲染或不渲染一块标记,其中我们将通知我们排队了多少任务,以及完成所有任务需要多少累计时间。

我们类的新queuedTasks字段将提供这样的信息,我们将希望在我们的类中插入一个名为updateQueuedTasks()的新方法,该方法将在实例化组件或排队任务时更新其数值。除此之外,我们将创建一个键/值映射,以便稍后根据排队任务的数量使用I18nPlural管道来呈现更具表现力的标题头:

class TasksComponent {
 today: Date;
 tasks: Task[];
 queuedTasks: number;
 queuedHeaderMapping: any = {
 '=0': 'No tasks',
 '=1': 'One task',
 'other' : '# tasks'
 };

 constructor() {
 const TasksService: TasksService = new TasksService();
 this.tasks = tasksService.tasksStore;
 this.today = new Date();
 this.updateQueuedTasks();
 }

 toggleTask(task: Task) {
 task.queued = !task.queued;
 this.updateQueuedTasks();
 }

 private updateQueuedTasks() {
 this.queuedTasks = this.tasks
 .filter( task:Task => task.queued )
 .reduce((hoursLeft: number, queuedTask: Task) => {
 return hoursLeft + queuedTask.hoursLeft;
 }, 0)
 }
}

updateQueuedTasks()方法利用 JavaScript 的原生Array.filter()Array.reduce()方法从原始任务集合属性中构建一个排队任务列表。应用于结果数组的reduce方法给出了要运送的单位总数。现在有了一个有状态的计算排队单位数量的方法,是时候相应地更新我们的模板了。转到tasks.html并在<h4>Tasks backlog</h4>元素之前注入以下 HTML 代码块。代码如下:

<div>
 <h3>
 {{queuedTasks | i18nPlural:queueHeaderMapping}}
 for today
 <span class="small" *ngIf="queuedTasks > 0">
 (Estimated time: {{ queuedTasks > 0 }})
 </span>
 </h3>
</div>
<h4>Tasks backlog</h4>
<!-- rest of the template remains the same -->

前面的代码块始终呈现一个信息性的标题,即使没有任务排队。我们还将该值绑定在模板中,并使用它通过表达式绑定来估算通过每个会话所需的分钟数。

我们正在在模板中硬编码每个任务的持续时间。理想情况下,这样的常量值应该从应用程序变量或集中设置中绑定。别担心,我们将在接下来的章节中看到如何改进这个实现。

保存更改并重新加载页面,然后尝试在表格上切换一些任务项目,看看信息如何实时变化。令人兴奋,不是吗?

嵌入子组件

现在,让我们开始构建一个微小的图标组件,它将嵌套在TasksComponent组件内部。这个新组件将显示我们大图标的一个较小版本,我们将用它来在模板上显示排队等待完成的任务数量,就像我们在本章前面描述的那样。让我们为组件树铺平道路,我们将在第六章中详细分析,使用 Angular 组件构建应用程序。现在,只需在之前构建的TasksComponent类之前包含以下组件类。

我们的组件将公开一个名为 task 的公共属性,我们可以在其中注入一个Task对象。组件将使用这个Task对象绑定,根据该任务的hoursLeft属性所需的会话次数,在模板中复制渲染的图像,这都是通过ngFor指令实现的。

在我们的tasks.ts文件中,在TasksComponent之前注入以下代码块:

@Component({
 selector : 'task-icons',
 template : `
 <img *ngFor="let icon of icons"
 src="/assets/img/task.png"
 width="50">`
})
export class TaskIconsComponent implements OnInit {
 @Input() task: Task;
 icons: Object[] = [];
 ngOnInit() {
 this.icons.length = this.task.hoursLeft;
 this.icons.fill({ name : this.task.name });
 }
}

在我们继续迭代我们的组件之前,重要的是要确保我们将组件注册到一个模块中,这样其他构造体就可以知道它的存在,这样它们就可以在它们的模板中使用该组件。我们通过将它添加到其模块对象的declarations属性中来注册它:

@NgModule({
 imports : [ /* add needed imports here */ ]
 declarations : [ 
 TasksComponent,
   TaskIconsComponent  
 ]
})
export class TaskModule {}

现在TaskModule知道了我们的组件,我们可以继续改进它。

我们的新TaskIconsComponent具有一个非常简单的实现,具有一个非常直观的选择器,与其驼峰命名的类名匹配,以及一个模板,在模板中,我们根据控制器类的 icons 数组属性中填充的对象的数量,多次复制给定的<img>标签,这是通过 JavaScript API 中的Array对象的 fill 方法填充的(fill 方法用静态值填充数组的所有元素作为参数传递),在ngOnInit()中。等等,这是什么?我们不应该在构造函数中实现填充图标数组成员的循环吗?

这种方法是我们将在下一章概述的生命周期钩子之一,可能是最重要的一个。我们之所以在这里填充图标数组字段,而不是在构造方法中,是因为我们需要在继续运行 for 循环之前,每个数据绑定属性都得到适当的初始化。否则,太早访问输入值任务将会返回一个未定义的值。

OnInit接口要求在实现此接口的控制器类中集成一个ngOnInit()方法,并且一旦所有已定义绑定的输入属性都已检查,它将被执行。我们将在第六章中对组件生命周期钩子进行概述,使用 Angular 组件构建应用程序

我们的新组件仍然需要找到其父组件。因此,让我们在TasksComponent的装饰器设置的 directives 属性中插入对组件类的引用:

@Component({
 selector : 'tasks',
 styleUrls : ['tasks.css'],
 templateUrl : 'tasks.html'
})

我们的下一步将是在TasksComponent模板中注入<task-icons>元素。回到tasks.html,并更新条件块内的代码,以便在hoursLeft大于零时显示。代码如下:

<div>
 <h3>
 {{ hoursLeft | i18nPlural:queueHeaderMapping }}
 for today
 <span class="small" *ngIf="hoursLeft > 0">
 (Estimated time : {{ hoursLeft * 25 }})
 </span>
 </h3> 
 <p>
 <span *ngFor="let queuedTask of tasks">
      <task-icons
 [task]="queuedTask"
 (mouseover)="tooltip.innerText = queuedTask.name"
 (mouseout)="tooltip.innerText = 'Mouseover for details'">
 </task-icons>
 </span>
 </p>
 <p #tooltip *ngIf="hoursLeft > 0">Mouseover for details</p>
</div>
<h4>Tasks backlog</h4>
<!-- rest of the template remains the same -->

然而,仍然有一些改进的空间。不幸的是,图标大小在TaskIconsComponent模板中是硬编码的,这使得在其他需要不同大小的上下文中重用该组件变得更加困难。显然,我们可以重构TaskIconsComponent类,以公开一个size输入属性,然后将接收到的值直接绑定到组件模板中,以便根据需要调整图像的大小。

@Component({
 selector : 'task-icon',
 template : `
 <img *ngfor="let icon of icons" 
 src="/assets/img/task.png" 
 width="{{size}}">`
})
export class TaskIconsComponent implements OnInit {
 @Input() task: Task;
 icons : Object[] = [];
  @Input() size: number;
 ngOnInit() {
 // initialise component here
 }
}

然后,我们只需要更新tasks.html的实现,以声明我们需要的大小值:

<span *ngFor="let queuedTask of tasks">
 <task-icons 
 [task]="queuedTask" 
    size="50" 
 (mouseover)="tooltip.innerText = queuedTask.name">
 </task-icons>
</span>

请注意,size属性没有用括号括起来,因为我们绑定了一个硬编码的值。如果我们想要绑定一个组件变量,那么该属性应该被正确声明为[size]="{{mySizeVariable}}"

我们插入了一个新的 DOM 元素,只有在剩余小时数时才会显示出来。我们通过在 H3 DOM 元素中绑定hoursLeft属性,显示了一个实际的标题告诉我们剩余多少小时,再加上一个总估计时间,这些都包含在{{ hoursLeft * 25 }}表达式中。

ngFor指令允许我们遍历 tasks 数组。在每次迭代中,我们渲染一个新的<task-icons>元素。

我们在循环模板中将每次迭代的Task模型对象,由queuedTask引用表示,绑定到了<task-icons>的 task 输入属性中。

我们利用了<task-icons>元素来包含额外的鼠标事件处理程序,这些处理程序指向以下段落,该段落已标记为#tooltip本地引用。因此,每当用户将鼠标悬停在任务图标上时,图标行下方的文本将显示相应的任务名称。

我们额外努力,将由<task-icons>渲染的图标大小作为组件 API 的可配置属性。我们现在有了实时更新的图标,当我们切换表格上的信息时。然而,新的问题已经出现。首先,我们正在显示与每个任务剩余时间匹配的图标组件,而没有过滤掉那些未排队的图标。另一方面,为了实现所有任务所需的总估计时间,显示的是总分钟数,随着我们添加更多任务,这个信息将毫无意义。

也许,现在是时候修改一下了。自定义管道来拯救真是太好了!

构建我们自己的自定义管道

我们已经看到了管道是什么,以及它们在整个 Angular 生态系统中的目的是什么,但现在我们将更深入地了解如何构建我们自己的一组管道,以提供对数据绑定的自定义转换。

自定义管道的解剖

定义管道非常容易。我们基本上需要做以下事情:

  • 导入PipePipeTransform

  • 实现PipeTransform接口

  • Pipe组件添加到模块中

实现Pipe的完整代码看起来像这样:

import { Pipe, PipeTransform, Component } from '@angular/core';

@Pipe({
 name : 'myPipeName'
})
export class MyPipe implements PipeTransform {
 transform( value: any, ...args: any[]): any {
 // We apply transformations to the input value here
 return something;
 }
}
@Component({
 selector : 'my-selector',
 template : '<p>{{ myVariable | myPipeName: "bar"}}</p>'
})
export class MyComponent {
 myVariable: string = 'Foo';
}

让我们逐步分解即将到来的小节中的代码。

导入

我们导入了以下结构:

import { Pipe, PipeTransform, Component }

定义我们的管道

Pipe是一个装饰器,它接受一个对象文字;我们至少需要给它一个名称属性:

@Pipe({ name : 'myPipeName' })

这意味着一旦使用,我们将像这样引用它的名称属性:

{{ value | myPipeName }}

PipeTransform是我们需要实现的接口。我们可以通过将其添加到我们的类中轻松实现:

@Pipe({ name : 'myPipeName' })
export class MyPipeClass {
 transform( value: any, args: any[]) {
 // apply transformation here
 return 'add banana ' + value; 
 }
}

在这里,我们可以看到我们有一个 transform 方法,但第一个参数是值本身,其余是args,一个包含您提供的任意数量参数的数组。我们已经展示了如何使用这个Pipe,但是如果提供参数,它看起来有点不同,就像这样:

{{ value | myPipeName:arg1:arg2 }}

值得注意的是,对于我们提供的每个参数,它最终都会出现在args数组中,并且我们用冒号分隔它。

注册它

要使一个构造可用,比如一个管道,你需要告诉模块它的存在。就像组件一样,我们需要像这样添加到 declarations 属性中:

@NgModule({
 declarations : [ MyPipe ]
})
export ModuleClass {}

纯属性

我们可以向我们的@Pipe装饰器添加一个属性,pure,如下所示:

@Pipe({ name : 'myPipe', pure : false })
export class MyPipe implements PipeTransform {
 transform(value: any, ...args: any[]) {}
}

“为什么我们要这样做?”你问。嗯,有些情况下可能是必要的。如果你有一个像这样处理原始数据的管道:

{{ "decorate me" |  myPipe }}

我们没有问题。但是,如果它看起来像这样:

{{ object | myPipe }}

我们可能会遇到问题。考虑组件中的以下代码:

export class Component {
 object = { name : 'chris', age : 37 }

 constructor() {
 setTimeout(() => this.object.age = 38 , 3000)
 }
}

假设我们有以下Pipe实现来配合它:

@Pipe({ name : 'pipe' })
export class MyPipe implements PipeTransform {
 transform(value:any, ...args: any[]) {
 return `Person: ${value.name} ${value.age}` 
 }
}

这起初会是输出:

Chris 37

然而,你期望输出在 3 秒后改变为Chris 38,但它没有。管道只关注引用是否已更改。在这种情况下,它没有,因为对象仍然是相同的,但对象上的属性已更改。告诉它对更改做出反应的方法是指定pure属性,就像我们在开始时所做的那样。因此,我们更新我们的Pipe实现如下:

@Pipe({ name : 'pipe', pure: false })
export class MyPipe implements PipeTransform {
 transform(value: any, ...args:any[]) {
 return `Person: ${value.name} ${value.age}`
 }
}

现在,我们突然看到了变化发生。不过,需要注意的是,这实际上意味着transform方法在每次变更检测周期被触发时都会被调用。因此,这对性能可能会造成损害。如果设置pure属性,你可以尝试缓存该值,但也可以尝试使用 reducer 和不可变数据以更好地解决这个问题:

// instead of altering the data like so
this.jedi.side = 'Dark'

// instead do
this.jedi = Object.assign({}, this.jedi, { side : 'Dark' });

前面的代码将更改引用,我们的 Pipe 不会影响性能。总的来说,了解 pure 属性的作用是很好的,但要小心。

更好地格式化时间输出的自定义管道

当排列要完成的任务时,观察总分钟数的增加并不直观,因此我们需要一种方法将这个值分解为小时和分钟。我们的管道将被命名为formattedTime,并由formattedTimePipe类实现,其唯一的 transform 方法接收一个表示总分钟数的数字,并返回一个可读的时间格式的字符串(证明管道不需要返回与载荷中接收到的相同类型)。:

@Pipe({
 name : 'formattedTime'
})
export class FormattedTimePipe implements PipeTransform {
 transform(totalMinutes : number) {
 let minutes : number = totalMinutes % 60;
 let hours : numbers = Math.floor(totalMinutes / 60);
 return `${hours}h:{minutes}m`;
 }
}

我们不应该错过强调管道的命名约定,与我们在组件中看到的一样,管道类的名称加上Pipe后缀,再加上一个与该名称匹配但不带后缀的选择器。为什么管道控制器的类名和选择器之间存在这种不匹配?这是常见的做法,为了防止与第三方管道和指令定义的其他选择器发生冲突,我们通常会给我们自定义管道和指令的选择器字符串添加一个自定义前缀。

@Component({
 selector : 'tasks',
 styleUrls : [ 'tasks.css' ],
 templateUrl : 'tasks.html'
})
export class TasksComponent {}

最后,我们只需要调整tasks.html模板文件中的 HTML,以确保我们的 EDT 表达式格式正确:

<span class="small">
 (Estimated time: {{ queued * 25 | formattedTime }})
</span>

现在,重新加载页面并切换一些任务。预计时间将以小时和分钟正确呈现。

最后,我们不要忘记将我们的Pipe构造添加到其模块tasks.module.ts中:

@NgModule({
 declarations: [TasksComponent, FormattedTimePipe]
})
export class TasksModule {}

使用自定义过滤器过滤数据

正如我们已经注意到的,我们目前为每个任务在从任务服务提供的集合中显示一个图标组件,而没有过滤出哪些任务标记为排队,哪些不是。管道提供了一种方便的方式来映射、转换和消化数据绑定,因此我们可以利用其功能来过滤我们ngFor循环中的任务绑定,只返回那些标记为排队的任务。

逻辑将非常简单:由于任务绑定是一个Task对象数组,我们只需要利用Array.filter()方法来获取那些queued属性设置为trueTask对象。我们可能会额外配置我们的管道以接受一个布尔参数,指示我们是否要过滤出排队或未排队的任务。这些要求的实现如下,您可以再次看到选择器和类名的惯例:

@Pipe({
 name : 'queuedOnly'
})
export class QueuedOnlyPipe implements PipeTransform {
 transform(tasks: Task[]), ...args:any[]): Task[] {
 return tasks.filter( task:Task => task.queued === args[0])
 }
}

实现非常简单,所以我们不会在这里详细介绍。然而,在这个阶段有一件值得强调的事情:这是一个不纯的管道。请记住,任务绑定是一个有状态对象的集合,随着用户在表格上切换任务,其长度和内容将发生变化。因此,我们需要指示管道利用 Angular 的变更检测系统,以便其输出在每个周期都被后者检查,无论其输入是否发生变化。然后,将管道装饰器的pure属性配置为false就可以解决问题。

现在,我们只需要更新使用此管道的组件的 pipes 属性:

@Component({
 selector : 'tasks',
 styleUrls : ['tasks.css'],
 templateUrl : 'tasks.html'
})
export class TasksComponent {
 // Class implementation remains the same
}

然后,在tasks.html中更新ngFor块,以正确过滤出未排队的任务:

<span *ngFor="queuedTask of tasks | queuedOnly:true">
 <task-icons
 [task]="queuedTask"
 (mouseover)="tooltip.innerText = queuedTask.name"
 (mouseout)="tooltip.innerText = 'Mouseover for details'">
 </task-icons>
</span>

请检查我们如何将管道配置为queuedOnly: true。将布尔参数值替换为false将使我们有机会列出与我们未选择的队列相关的任务。

保存所有工作并重新加载页面,然后切换一些任务。您将看到我们的整体 UI 如何根据最新更改做出相应的反应,我们只列出与排队任务的剩余小时数相关的图标。

构建我们自己的自定义指令

自定义指令涵盖了广泛的可能性和用例,我们需要一整本书来展示它们提供的所有复杂性和可能性。

简而言之,指令允许您将高级行为附加到 DOM 中的元素上。如果指令附有模板,则它将成为一个组件。换句话说,组件是具有视图的 Angular 指令,但我们可以构建没有附加视图的指令,这些指令将应用于已经存在的 DOM 元素,使其 HTML 内容和标准行为立即对指令可用。这也适用于 Angular 组件,其中指令将在必要时访问其模板和自定义属性和事件。

自定义指令的解剖

声明和实现自定义指令非常容易。我们只需要导入Directive类,以为其附属的控制器类提供装饰器功能:

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

然后,我们定义一个由@Directive装饰器注释的控制器类,在其中我们将定义指令选择器、输入和输出属性(如果需要)、应用于宿主元素的可选事件,以及可注入的提供者令牌,如果我们的指令构造函数在实例化时需要特定类型由 Angular 注入器实例化自己(我们将在第六章中详细介绍这一点,使用 Angular 组件构建应用程序):

让我们先创建一个非常简单的指令来热身:

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

@Directive({
 selector : '[highlight]'
})
export class HighLightDirective {
 constructor( private elementRef: ElementRef, private renderer : Renderer2 ) {
 var nativeElement = elementRef.nativeElement;
 this.renderer.setProperty( nativeElement,'backgroundColor', 'yellow');
 }
}

要使用它就像输入一样简单:

<h1 highlight></h1>

我们在这里使用了两个 actor,ElementRefRenderer2,来操作底层元素。我们可以直接使用elementRef.nativeElement,但这是不鼓励的,因为这可能会破坏服务器端渲染或与服务工作者交互时。相反,我们使用Renderer2的实例进行所有操作。

注意我们不输入方括号,而只输入选择器名称。

我们在这里快速回顾了一下,注入了ElementRef并访问了nativeElement属性,这是实际元素。我们还像在组件和管道上一样,在类上放置了一个@Directive装饰器。创建指令时要有的主要思维方式是考虑可重用的功能,不一定与某个特定功能相关。之前选择的主题是高亮,但我们也可以相对容易地构建其他功能,比如工具提示、可折叠或无限滚动功能。

属性和装饰器,比如选择器、@Input()@Output()(与输入和输出相同),可能会让您回想起我们概述组件装饰器规范时的时间。尽管我们尚未详细提到所有可能性,但选择器可以声明为以下之一:

  • element-name: 通过元素名称选择

  • .class: 通过类名选择

  • [attribute]: 通过属性名称选择

  • [attribute=value]: 通过属性名称和值选择

  • not(sub_selector): 仅在元素不匹配时选择

sub_selector

  • selector1, selector2: 如果selector1selector2匹配,则选择

除此之外,我们还会找到主机参数,该参数指定了与主机元素(即我们指令执行的元素)相关的事件、动作、属性和属性,我们希望从指令内部访问。因此,我们可以利用这个参数来绑定与容器组件或任何其他目标元素(如窗口、文档或主体)的交互处理程序。这样,当编写指令事件绑定时,我们可以引用两个非常方便的本地变量:

  • $event: 这是触发事件的当前事件对象。

  • $target: 这是事件的来源。这将是一个 DOM 元素或一个 Angular 指令。

除了事件,我们还可以更新属于主机组件的特定 DOM 属性。我们只需要将任何特定属性用大括号括起来,并在我们指令的主机定义中将其作为键值对与指令处理的表达式链接起来。

可选的主机参数还可以指定应传播到主机元素的静态属性,如果尚未存在。这是一种方便的方式,可以使用计算值注入 HTML 属性。

Angular 团队还提供了一些方便的装饰器,这样我们就可以更加直观地在代码中声明我们的主机绑定和监听器,就像这样:

@HostBinding('[class.valid]')
isValid: boolean; // The host element will feature class="valid"
// is the value of 'isValid' is true.
@HostListener('click', ['$event'])
onClick(e) {
 // This function will be executed when the host 
  // component triggers a 'click' event.
}

在接下来的章节中,我们将更详细地介绍指令和组件的配置接口,特别关注它的生命周期管理以及我们如何轻松地将依赖项注入到我们的指令中。现在,让我们只是构建一个简单但强大的指令,它将对我们的 UI 的显示和维护产生巨大的影响。

监听事件

到目前为止,我们已经能够创建我们的第一个指令,但这并不是很有趣。然而,添加监听事件的能力会使它变得更有趣,所以让我们来做吧。我们需要使用一个叫做HostListener的辅助工具来监听事件,所以我们首先要导入它:

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

我们需要做的下一件事是将它用作装饰器并装饰一个方法;是的,一个方法,而不是一个类。它看起来像下面这样:

@Directive({
 selector : '[highlight]'
})
export class HighlightDirective {
 @HostListener('click')
 clicked() {
 alert('clicked') 
 }
}

使用这个指令点击一个元素将会导致一个警告窗口弹出。添加事件非常简单,所以让我们尝试添加mouseovermouseleave事件:

@Directive({
 selector : '[highlight]'
})
export class HighlightDirective {
 private nativeElement;

 constructor(elementRef: ElementRef, renderer: Renderer2) {
 this.nativeElement = elementRef.nativeElement;
 }

 @HostListener('mousenter')
 onMouseEnter() {
 this.background('red');
 }

 onMouseLeave('mouseleave') {
 this.background('yellow');
 }

 private background(bg:string) {
 this.renderer.setAttribute(nativeElement,'backgroundColor', bg);
 }
}

这给了我们一个指令,当鼠标悬停在组件上时,背景会变成红色,当鼠标离开时会恢复为黄色

添加输入数据

我们的指令对于使用什么颜色是相当静态的,所以让我们确保它们可以从外部设置。要添加第一个输入,我们需要使用我们的老朋友@Input装饰器,但是不像我们习惯的那样不给它任何参数作为输入,我们需要提供指令本身的名称,如下所示:

<div highlight="orange"></div>

@Directive({ selector : '[highlight]' })
export class HighlightDirective 
 private nativeElement;

 constructor(elementRef: ElementRef, renderer: Renderer2) {
 this.nativeElement = elementRef.nativeElement;
 }

 @Input('highlight') color:string;

 @HostListener('mousenter')
 onMouseEnter(){
 this.background(this.color);
 }

 onMouseLeave() {
 this.background('yellow'); 
 }

 private background(bg: string) {
 this.renderer( nativeElement, 'background', bg );
 }
}

在这一点上,我们已经处理了第一个输入;我们用以下方法做到了这一点:

@Input('highlight') color: string;

但是,我们如何向我们的指令添加更多的输入?我们将在下一小节中介绍这个问题。

添加多个输入属性

所以你想要添加另一个输入,这也相对容易。我们只需要在我们的 HTML 元素中添加一个属性,如下所示:

<div [highlight]="orange" defaultColor="yellow">

在代码中我们输入:

@Directive({})
export class HighlightDirective {
 @Input() defaultColor
 constructor() {
 this.background(this.defaultColor);
 }
 // the rest omitted for brevity
}

然而,我们注意到在我们进行第一次mousenter + mouseleave之前,我们没有颜色,原因是构造函数在我们的defaultColor属性被设置之前运行。为了解决这个问题,我们需要稍微不同地设置输入。我们需要像这样使用一个属性:

private defaultColor: string;

@Input()
set defaultColor(value) { 
 this.defaultColor = value;
 this.background(value); 
}

get defaultColor(){ return this.defaultColor; }

总结一下关于使用输入的部分,很明显我们可以使用@Input装饰器来处理一个或多个输入。然而,第一个输入应该是指令的选择器名称,第二个输入是你给它的属性的名称。

第二个例子 - 错误验证

让我们利用对指令的这些新知识,构建一个指示字段错误的指令。我们认为错误是指我们着色元素并显示错误文本:

import { Directive, ElementRef, Input } from '@angular/core';
@Directive({
 selector: '[error]'
})
export class ErrorDirective {
 error:boolean;
 private nativeElement;
 @Input errorText: string;
 @Input()
 set error(value: string) {
 let val = value === 'true' ? true : false;
 if(val){ this.setError(); }
 else { this.reset(); }
 }

 constructor(
 private elementRef: ElementRef, 
 private renderer: Renderer2
 ) {
 this.nativeElement = elementRef.nativeElement;
 }

 private reset() { 
 this.renderer.setProperty(nativeElement, 'innerHTML', '');
 this.renderer.setProperty(nativeElement, 'background', '') 
 }

 private setError(){
 this.renderer.setProperty(nativeElement, 'innerHTML', this.errorText);
 this.renderer.setProperty(nativeElement, 'background', 'red');
 }
}

而要使用它,我们只需输入:

<div error="{{hasError}}" errorText="display this error">

构建一个任务提示自定义指令

到目前为止,我们已经构建了一个高亮指令以及一个错误显示指令。我们已经学会了如何处理事件以及多个输入。

关于提示信息的简短说明。当我们悬停在一个元素上时,会出现提示信息。通常你要做的是在元素上设置 title 属性,就像这样:

<div title="a tooltip"></div>

通常有几种方法可以在这样的组件上构建提示信息。一种方法是绑定到title属性,就像这样:

<task-icons [title]="task.name"></task-icons>

然而,如果你有更多的逻辑想法,将所有内容都添加到标记中可能不太好,所以在这一点上,我们可以创建一个指令来隐藏提示信息,就像这样:

@Directive({ selector : '[task]' })
export class TooltipDirective {
 private nativeElement;
 @Input() task:Task;
 @Input() defaultTooltip: string;

 constructor(private elementRef: ElementRef, private renderer : Renderer2) {
 this.nativeElement = elementRef.nativeElement;
 }

 @HostListener('mouseover')
 onMouseOver() {
 let tooltip = this.task ? this.task.name : this.defaultTooltip;
 this.renderer.setProperty( this.nativeElement, 'title', tooltip );
 }
}

使用它将是:

<div [task]="task">

然而,我们还可以采取另一种方法。如果我们想在悬停在一个元素上时改变另一个元素的 innerText 呢?这是很容易做到的,我们只需要将我们的指令传递给另一个元素,并更新它的 innerText 属性,就像这样:

<div [task]="task" [elem]="otherElement" defaultTooltip="default text" >
<div #otherElement>

当然,这意味着我们需要稍微更新我们的指令到这样:

@Directive({ selector : '[task]' })
export class TooltipDirective {
 private nativeElement;
 @Input() task:Task;
 @Input() defaultTooltip: string;

 constructor(private elementRef: ElementRef, private renderer : Renderer2) {
 this.nativeElement = elementRef.nativeElement;
 }

 @HostListener('mouseover')
 onMouseOver() {
 let tooltip = this.task ? this.task.name : this.defaultTooltip;
    this.renderer.setProperty( this.nativeElement, 'innerText', tooltip );
 }
}

关于自定义指令和管道的命名约定

谈到可重用性,通常的约定是在选择器前面添加一个自定义前缀。这可以防止与其他库定义的选择器发生冲突,这些库可能在我们的项目中使用。同样的规则也适用于管道,正如我们在介绍我们的第一个自定义管道时已经强调的那样。

最终,这取决于你和你采用的命名约定,但建立一个可以防止这种情况发生的命名约定通常是一个好主意。自定义前缀绝对是更容易的方法。

总结

现在我们已经达到这一点,可以说你几乎知道构建 Angular 组件所需的一切,这些组件确实是所有 Angular 2 应用程序的核心和引擎。在接下来的章节中,我们将看到如何更好地设计我们的应用程序架构,因此在整个组件树中管理依赖注入,使用数据服务,利用新的 Angular 路由器在需要时显示和隐藏组件,并管理用户输入和身份验证。

然而,这一章是 Angular 开发的支柱,我们希望您和我们一样喜欢它,当我们写关于模板语法、基于属性和事件的组件 API、视图封装、管道和指令时。现在,准备好迎接新的挑战——我们将从学习如何编写组件转向发现如何使用它们来构建更大的应用程序,同时强调良好的实践和合理的架构。我们将在下一章中看到所有这些。

第六章:使用 Angular 组件构建应用程序

我们已经达到了一个阶段,在这个阶段,我们可以通过在其他组件中嵌套组件来成功开发更复杂的应用程序,形成一种组件树。然而,将所有组件逻辑捆绑在一个唯一的文件中绝对不是正确的方法。我们的应用程序很快可能变得难以维护,并且正如我们将在本章后面看到的那样,我们将错过 Angular 的依赖管理机制可以为游戏带来的优势。

在本章中,我们将看到如何基于组件树构建应用程序架构,以及新的 Angular 依赖注入机制如何帮助我们以最小的工作量和最佳结果声明和使用应用程序中的依赖项。

在本章中,我们将涵盖以下主题:

  • 目录结构和命名约定的最佳实践

  • 依赖注入的不同方法

  • 将依赖项注入到我们的自定义类型中

  • 在整个组件树中覆盖全局依赖项

  • 与宿主组件交互

  • 概述指令生命周期

  • 概述组件生命周期

介绍组件树

基于 Web 组件架构的现代 Web 应用程序通常符合一种树形层次结构,其中顶层主要组件(通常放置在主 HTML 索引文件的某个位置)充当全局占位符,子组件成为其他嵌套子组件的宿主,依此类推。

这种方法有明显的优势。一方面,可重用性不会受到损害,我们可以轻松地在组件树中重用组件。其次,由此产生的细粒度减少了构想、设计和维护更大型应用程序所需的负担。我们可以简单地专注于单个 UI 部分,然后将其功能包装在新的抽象层周围,直到我们从头开始包装一个完整的应用程序。

或者,我们可以从另一个角度来处理我们的 Web 应用程序,从更通用的功能开始,最终将应用程序拆分为更小的 UI 和功能部分,这些部分成为我们的 Web 组件。后者已成为构建基于组件的架构时最常见的方法。我们将在本书的其余部分坚持这一方法,将架构视为下图所示的架构:

Application bootstrap
Root module
 Root component that is Application component
 Component A
 Component B
 Component B-I
 Component B-II
 Component C
 Component D
Feature module
 Component E
 Component F
Common module
 Component G
 Component H

为了清晰起见,本章将借用我们在前几章中编写的代码,并将其拆分为组件层次结构。我们还将为最终应用程序中所有支持类和模型分配一些空间,以塑造我们的番茄工具。这将成为学习 Angular 中内置的依赖注入机制的绝佳机会,我们将在本章后面看到。

可扩展应用程序的通用约定

公平地说,我们已经解决了现代网页开发人员在构建应用程序时所面临的许多常见问题,无论是小型还是大型应用程序。因此,定义一个架构来将上述问题分离成单独的领域文件夹,满足媒体资产和共享代码单元的需求是有意义的。

Angular 将代码和资产分离的方法是通过将它们组织到不同的文件夹中,同时引入 Angular 模块的概念。在这些模块中注册构造。通过引入模块,我们的组件中的许多噪音已经消失,我们的组件可以自由地使用同一模块中的其他构造,有时甚至可以使用其他模块中的构造,前提是导入其所在的模块。

值得强调的是,当我们谈论 Angular 模块时,我们指的是@NgModule装饰器,当我们谈论模块时,我们指的是 ES2015 构造。

有时,两个上下文可能需要共享相同的实体,这是可以接受的(只要在我们的项目中不成为常见情况,这将表示严重的设计问题)。还值得强调的是,我们使用“上下文”一词来描述构造的逻辑边界。上下文最好保留在一个 Angular 模块中。因此,每当使用“上下文”一词时,都要考虑在代码中将其转换为一个 Angular 模块。

以下示例应用于我们之前在番茄工作法组件上的工作,基本上构成了我们整个应用程序的上下文和不同构造。

  • 任务上下文:

  • 任务模块

  • 任务模型

  • 任务服务

  • 任务表组件

  • 任务番茄钟组件

  • 任务工具提示指令

  • 计时器上下文:

  • 计时器模块

  • 计时器功能

  • 计时器组件

  • 管理员上下文:

  • 管理员模块

  • 认证服务

  • 登录组件

  • 编辑器组件

  • 共享上下文:

  • 共享模块

  • 跨功能共享的组件

  • 跨功能共享的管道

  • 跨功能共享的指令

  • 全局模型和服务

  • 共享媒体资产

正如我们所看到的,第一步是定义应用程序需要的不同功能,要记住的是,每个功能在与其他功能隔离时应该是有意义的。一旦我们定义了所需的功能集,我们将为每个功能创建一个模块。然后,每个模块将填充代表其特征的组件、指令、管道、模型和服务。在定义功能集时,请始终记住封装和可重用性的原则。

最初,在启动项目时,您应该根据它们的名称命名您的构造,所以说我们有Admin上下文,它应该看起来像这样:

//admin/

admin.module.ts
authentication.service.ts
login.component.ts
editor.component.ts

通过快速浏览,您应该能够看到构造包含的内容,因此使用类似于以下的命名标准:

<name>.<type>.ts // example login.service.ts

当然,这不是唯一的方法。还有另一种完全可以接受的方法,即为每种类型创建子目录,因此您之前的admin目录可能看起来像这样:

//admin/

admin.module.ts
services/
 authentication.service.ts
components/
 login.component.ts
 login.component.html
 editor.component.ts
 create-user.component.ts
pipes/
 user.pipe.ts

值得注意的是,为了便于调试,您应该在文件名中保留类型。否则,当在浏览器中寻找特定文件以设置断点时,比如登录服务,如果您开始输入login.ts,然后出现以下情况可能会相当令人困惑:

  • components/login.ts

  • services/login.ts

  • pipes/login.ts

有一个官方的样式指南,告诉您应该如何组织代码以及如何命名您的构造。遵循指南肯定有好处;对新手来说很容易,代码看起来更一致等等。您可以在这里阅读更多信息;angular.io/guide/styleguide。请记住,无论您选择是否完全遵循此样式指南,一致性都很重要,因为这将使维护代码变得更容易。

文件和 ES6 模块命名约定

我们的每个功能文件夹将托管各种文件,因此我们需要一致的命名约定,以防止文件名冲突,同时确保不同的代码单元易于定位。

以下列表总结了社区强制执行的当前约定:

  • 每个文件应包含一个代码单元。简而言之,每个组件、指令、服务、管道等都应该存在于自己的文件中。这样,我们有助于更好地组织代码。

  • 文件和目录以小写 kebab-case 命名。

  • 表示组件、指令、管道和服务的文件应该在它们的名称后面添加一个类型后缀:video-player.ts将变成video-player.component.ts

  • 任何组件的外部 HTML 模板或 CSS 样式表文件名都将与组件文件名匹配,包括后缀。例如,我们的video-player.component.ts可能会有video-player.component.cssvideo-player.component.html

  • 指令选择器和管道名称采用驼峰式命名,而组件选择器采用小写 kebab-case 命名。此外,强烈建议添加我们选择的自定义前缀,以防止与其他组件库发生名称冲突。例如,跟随我们的视频播放器组件,它可以表示为<vp-video-player>,其中vp-(代表 video-player)是我们的自定义前缀。

  • 模块的命名遵循 PascalCased 规则

自描述名称,以及它所代表的类型。例如,如果我们看到一个名为VideoPlayerComponent的模块,我们可以轻松地知道它是一个组件。在选择器中使用的自定义前缀(在我们的示例中为vp-)不应该成为模块名称的一部分。

  • 模型和接口需要特别注意。根据您的应用程序架构,模型类型的相关性会更多或更少。诸如 MVC、MVVM、Flux 或 Redux 的架构从不同的角度和重要性等级处理模型。最终,您和您选择的架构设计模式将决定以一种方式或另一种方式处理模型和它们的命名约定。本书在这方面不会表达观点,尽管我们在示例应用程序中强制执行接口模型,并将为它们创建模块。

  • 我们应用程序中的每个业务逻辑组件和共享上下文都旨在以简单直接的方式与其他部分集成。每个子域的客户端都不关心子域本身的内部结构。例如,如果我们的定时器功能发展到需要重新组织成不同的文件夹层次结构,其功能的外部消费者应该保持不受影响。

从 facade/barrel 到 NgModule

随着应用程序的增长,有必要将构造分组为逻辑组。随着应用程序的增长,您还意识到并非所有构造都应该能够相互通信,因此您还需要考虑限制这一点。在框架中添加@NgModule之前,自然的做法是考虑外观模块,这基本上意味着我们创建了一个具有决定将被导出到外部世界的唯一目的的特定文件。这可能看起来像下面这样:

import TaskComponent from './task.component';
import TaskDetailsComponent from './task-details.component';
// and so on
export {
 TaskComponent,
 TaskDetailsComponent,
 // other constructs to expose
}

一切未明确导出的内容都将被视为私有或内部特性。使用其中一个导出的构造将像输入一样简单:

import { TaskComponent } from './task.component.ts';
// do something with the component above

这是一种处理分组和限制访问的有效方式。当我们深入研究下一小节中的@NgModule时,我们将牢记这两个特性。

使用 NgModule

随着@NgModule的到来,我们突然有了一种更合乎逻辑的方式来分组我们的构造,并且也有了一种自然的方式来决定什么可以被导出或不导出。以下代码对应于前面的外观代码,但它使用了@NgModule

import { NgModule } from  '@angular/core'; import { TaskDetailComponent } from  './task.detail.component'; import { TaskDetailsComponent } from  './task.details.component'; import { TaskComponent } from  './task.component';   @NgModule({
  declarations: [TaskComponent, TaskDetailsComponent], exports: [TaskComponent, TaskDetailComponent] })
export  class  TaskModule { }

这将创建相同的效果,该构造称为特性模块。exports关键字表示了什么是公开访问的或不是。然而,获取公开访问的内容看起来有点不同。而不是输入:

import { TaskDetailComponent } from 'app/tasks/tasks';

我们需要将我们的特性模块导入到我们的根模块中。这意味着我们的根模块将如下所示:

import { TaskModule } from './task.module';

@NgModule({
  imports: [ TasksModule ]
 // the rest is omitted for brevity
}) 

这将使我们能够在模板标记中访问导出的组件。因此,在您即将构建的应用程序中,请考虑什么属于根模块,什么是特性的一部分,以及什么是更常见的并且在整个应用程序中都使用。这是您需要拆分应用程序的方式,首先是模块,然后是适当的构造,如组件、指令、管道等。

在 Angular 中依赖注入是如何工作的

随着我们的应用程序的增长和发展,我们的每一个代码实体在内部都需要其他对象的实例,这在软件工程领域更为常见的称为依赖关系。将这些依赖关系传递给依赖客户端的行为称为注入,它还涉及另一个名为注入器的代码实体的参与。注入器将负责实例化和引导所需的依赖关系,以便在成功注入客户端后立即可以使用。这非常重要,因为客户端对如何实例化自己的依赖关系一无所知,只知道它们实现的接口以便使用它们。

Angular 具有一流的依赖注入机制,可以轻松地将所需的依赖关系暴露给 Angular 应用程序中可能存在的任何实体,无论是组件、指令、管道还是任何其他自定义服务或提供者对象。事实上,正如我们将在本章后面看到的,任何实体都可以利用 Angular 应用程序中的依赖注入(通常称为 DI)。在深入讨论这个主题之前,让我们先看看 Angular 的 DI 试图解决的问题。

让我们看看我们是否有一个音乐播放器组件,它依赖于一个“播放列表”对象来向用户播放音乐:

import { Component } from  '@angular/core'; import { Playlist } from  './playlist.model'; @Component({
  selector:  'music-player', templateUrl:  './music-player.component.html' })
export  class  MusicPlayerComponent { playlist:  Playlist; constructor() { this.playlist  =  new  Playlist();
 }}
}

“播放列表”类型可能是一个通用类,在其 API 中返回一个随机的歌曲列表或其他内容。现在这并不重要,因为唯一重要的是我们的MusicPlayerComponent实体确实需要它来提供功能。不幸的是,先前的实现意味着这两种类型紧密耦合,因为组件在自己的构造函数中实例化了播放列表。这意味着如果需要,我们无法以整洁的方式更改、覆盖或模拟“播放列表”类。这也意味着每次我们实例化一个MusicPlayerComponent时都会创建一个新的“播放列表”对象。在某些情况下,这可能是不希望的,特别是如果我们希望在整个应用程序中使用单例并因此跟踪播放列表的状态。

依赖注入系统试图通过提出几种模式来解决这些问题,而构造函数注入模式是 Angular 强制执行的模式。前面的代码片段可以重新思考如下:

import { Component } from  '@angular/core'; import { Playlist } from  './playlist.model'; @Component({
 selector: 'music-player',
 templateUrl: './music-player.component.html'
})
export class MusicPlayerComponent {
 constructor(private playlist: Playlist) {}
}

现在,Playlist是在我们的组件外部实例化的。另一方面,MusicPlayerComponent期望在组件实例化之前已经有这样一个对象可用,以便通过其构造函数注入。这种方法使我们有机会覆盖它或者模拟它。

基本上,这就是依赖注入的工作原理,更具体地说是构造函数注入模式。但是,这与 Angular 有什么关系呢?Angular 的依赖注入机制是通过手动实例化类型并通过构造函数注入它们吗?显然不是,主要是因为我们也不会手动实例化组件(除非编写单元测试时)。Angular 具有自己的依赖注入框架,顺便说一句,这个框架可以作为其他应用程序的独立框架使用。

该框架提供了一个实际的注入器,可以审视构造函数中用于注释参数的标记,并返回每个依赖类型的单例实例,因此我们可以立即在类的实现中使用它,就像前面的例子一样。注入器不知道如何创建每个依赖项的实例,因此它依赖于在应用程序引导时注册的提供者列表。这些提供者实际上提供了对标记为应用程序依赖项的类型的映射。每当一个实体(比如一个组件、一个指令或一个服务)在其构造函数中定义一个标记时,注入器会在该组件的已注册提供者池中搜索与该标记匹配的类型。如果找不到匹配项,它将委托给父组件的提供者进行搜索,并将继续向上进行提供者的查找,直到找到与匹配类型的提供者或者达到顶层组件。如果提供者查找完成后没有找到匹配项,Angular 将抛出异常。

后者并不完全正确,因为我们可以使用@Optional参数装饰器在构造函数中标记依赖项,这种情况下,如果找不到提供者,Angular 将不会抛出任何异常,并且依赖参数将被注入为 null。

每当提供程序解析为与该令牌匹配的类型时,它将返回此类型作为单例,因此将被注入器作为依赖项注入。公平地说,提供程序不仅仅是将令牌与先前注册的类型进行配对的键/值对集合,而且还是一个工厂,它实例化这些类型,并且也实例化每个依赖项自己的依赖项,以一种递归依赖项实例化的方式。

因此,我们可以这样做,而不是手动实例化Playlist对象:

import { Component } from  '@angular/core'; import { Playlist } from  './playlist'; @Component({
  selector:  'music-player', templateUrl:  './music-player.component.html', providers: [Playlist**]** })
export  class  MusicPlayerComponent { constructor(private  playlist:  Playlist) {} }

@Component装饰器的providers属性是我们可以在组件级别注册依赖项的地方。从那时起,这些类型将立即可用于该组件的构造函数注入,并且,正如我们将在接下来看到的,也可用于其子组件。

关于提供程序的说明

在引入@NgModule之前,Angular 应用程序,特别是组件,被认为是负责其所需内容的。因此,组件通常会要求其需要的依赖项以正确实例化。在上一节的示例中,MusicPlayerComponent请求一个Playlist依赖项。虽然这在技术上仍然是可能的,但我们应该使用我们的新@NgModule概念,而不是在模块级别提供构造。这意味着先前提到的示例将在模块中注册其依赖项,如下所示:

@NgModule({
 declarations: [MusicComponent, MusicPlayerComponent]
 providers: [Playlist, SomeOtherService]
})

在这里,我们可以看到PlaylistSomeOtherService将可用于注入,对于在 declarations 属性中声明的所有构造。正如你所看到的,提供服务的责任在某种程度上已经转移。正如之前提到的,这并不意味着我们不能在每个组件级别上提供构造,存在这样做有意义的用例。然而,我们想强调的是,通常情况是将需要注入的服务或其他构造放在模块的providers属性中,而不是组件中。

跨组件树注入依赖项

我们已经看到,provider 查找是向上执行的,直到找到匹配项。一个更直观的例子可能会有所帮助,所以让我们假设我们有一个音乐应用程序组件,在其指令属性(因此也在其模板中)中托管着一个音乐库组件,其中包含我们下载的所有曲目的集合,还托管着一个音乐播放器组件,因此我们可以在我们的库中播放任何曲目:

MusicAppComponent
 MusicLibraryComponent
 MusicPlayerComponent

我们的音乐播放器组件需要我们之前提到的Playlist对象的一个实例,因此我们将其声明为构造函数参数,并方便地用Playlist标记进行注释:

MusicAppComponent
 MusicLibraryComponent
 MusicPlayerComponent(playlist: Playlist)

MusicPlayerComponent实体被实例化时,Angular DI 机制将会遍历组件构造函数中的参数,并特别关注它们的类型注解。然后,它将检查该类型是否已在组件装饰器配置的 provider 属性中注册。代码如下:

@Component({
 selector: 'music-player',
 providers: [Playlist]
})
export class MusicPlayerComponent {
 constructor(private playlist: Playlist) {}
}

但是,如果我们想在同一组件树中的其他组件中重用Playlist类型呢?也许Playlist类型在其 API 中包含了一些不同组件在应用程序中同时需要的功能。我们需要为每个组件在 provider 属性中声明令牌吗?幸运的是不需要,因为 Angular 预见到了这种必要性,并通过组件树带来了横向依赖注入。

在前面的部分中,我们提到组件向上进行 provider 查找。这是因为每个组件都有自己的内置注入器,它是特定于它的。然而,该注入器实际上是父组件注入器的子实例(依此类推),因此可以说 Angular 应用程序不是一个单一的注入器,而是同一个注入器的许多实例。

我们需要以一种快速且可重用的方式扩展Playlist对象在组件树中的注入。事先知道组件从自身开始执行提供者查找,然后将请求传递给其父组件的注入器,我们可以通过在父组件中注册提供者,甚至是顶级父组件中注册提供者来解决这个问题,这样依赖项将可用于每个子组件的注入。在这种情况下,我们可以直接在MusicAppComponent中注册Playlist对象,而不管它是否需要它进行自己的实现:

@Component({
 selector: 'music-app',
 providers: [Playlist],
 template: '<music-library></music-library>'
})
export class MusicAppComponent {}

即使直接子组件可能也不需要依赖项进行自己的实现。由于它已经在其父MusicAppComponent组件中注册,因此无需再次在那里注册:

@Component({
 selector: 'music-library',
 template: '<music-player></music-player>'
})
export class MusicLibraryComponent {}

最后,我们到达了我们的音乐播放器组件,但现在它的providers属性中不再包含Playlist类型作为注册令牌。实际上,我们的组件根本没有providers属性。它不再需要这个,因为该类型已经在组件层次结构的某个地方注册,立即可用于所有子组件,无论它们在哪里:

@Component({
 selector: 'music-player'
})
export class MusicPlayerComponent {
 constructor(private playlist: playlist) {}
}

现在,我们看到依赖项如何向下注入组件层次结构,以及组件如何执行提供者查找,只需检查其自己注册的提供者并将请求向上冒泡到组件树中。但是,如果我们想限制这种注入或查找操作呢?

限制依赖项向下注入组件树

在我们之前的例子中,我们看到音乐应用组件在其提供者集合中注册了播放列表令牌,使其立即可用于所有子组件。有时,我们可能需要限制依赖项的注入,仅限于层次结构中特定组件旁边的那些指令(和组件)。我们可以通过在组件装饰器的viewProviders属性中注册类型令牌来实现这一点,而不是使用我们已经看到的 providers 属性。在我们之前的例子中,我们可以仅限制Playlist的向下注入一级:

@Component({
 selector: 'music-app',
 viewProviders : [Playlist],
 template: '<music-library></music-library>'
})
export class MusicAppComponent {}

我们正在告知 Angular,Playlist提供程序只能被位于MusicAppComponent视图中的指令和组件的注入器访问,而不是这些组件的子级。这种技术的使用是组件的专属,因为只有它们具有视图。

限制提供程序查找

就像我们可以限制依赖注入一样,我们可以将依赖查找限制在仅限于直接上一级。为此,我们只需要将@Host()装饰器应用于那些我们想要限制提供程序查找的依赖参数:

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

@Component {
 selector: 'music-player'
}
export class MusicPlayerComponent {
 constructor(@Host() playlist:Playlist) {}
}

根据前面的例子,MusicPlayerComponent注入器将在其父组件的提供程序集合(在我们的例子中是MusicLibraryComponent)中查找Playlist类型,并在那里停止,抛出异常,因为Playlist没有被父级注入器返回(除非我们还用@Optional()参数装饰器装饰它)。

为了澄清这个功能,让我们做另一个例子:

@Component({
 selector: 'granddad',
 template: 'granddad <father>'
 providers: [Service]
})
export class GranddadComponent {
 constructor(srv:Service){}
}

@Component({
 selector: 'father',
 template: 'father <child>'
})
export class FatherComponent {
 constructor(srv:Service) {} // this is fine, as GranddadComponent provides Service
}

@Component({
 selector: 'child',
 template: 'child'
})
export class ChildComponent {
  constructor(@Host() srv:Service) {} // will cause an error
}

在这种情况下,我们会得到一个错误,因为Child组件只会向上查找一级,尝试找到服务。由于它向上两级,所以找不到。

在注入器层次结构中覆盖提供程序

到目前为止,我们已经看到了 Angular 的 DI 框架如何使用依赖标记来内省所需的类型,并从组件层次结构中可用的任何提供程序集中返回它。然而,在某些情况下,我们可能需要覆盖与该标记对应的类实例,以便需要更专业的类型来完成工作。Angular 提供了特殊工具来覆盖提供程序,甚至实现工厂,该工厂将返回给定标记的类实例,不一定匹配原始类型。

我们在这里不会详细涵盖所有用例,但让我们看一个简单的例子。在我们的例子中,我们假设Playlist对象应该在组件树中的不同实体中可用。如果我们的MusicAppComponent指令托管另一个组件,其子指令需要Playlist对象的更专业版本,该怎么办?让我们重新思考我们的例子:

MusicAppComponent
 MusicChartsComponent
 MusicPlayerComponent
 MusicLibraryComponent
 MusicPlayerComponent

这是一个有点牵强的例子,但它肯定会帮助我们理解覆盖依赖项的要点。 Playlist实例对象从顶部组件向下都是可用的。 MusicChartsComponent指令是一个专门为畅销榜中的音乐提供服务的组件,因此其播放器必须仅播放热门歌曲,而不管它是否使用与MusicLibraryComponent相同的组件。我们需要确保每个播放器组件都获得适当的播放列表对象,这可以在MusicChartsComponent级别通过覆盖与Playlist标记对应的对象实例来完成。以下示例描述了这种情况,利用了provide函数的使用:

import { Component } from '@angular/core';
import { Playlist } from './playlist';

import { TopHitsPlaylist } from './top-hits/playlist';

@Component({
 selector: 'music-charts',
 template: '<music-player></music-player>',
 providers: [{ provide : Playlist, useClass : TopHitsPlaylist }]
})
export class MusicChartsComponent {}

provide关键字创建了一个与第一个参数中指定的标记(在本例中为Playlist)映射的提供程序,而useClass属性本质上是用来从该组件和下游重写播放列表为TopHitsPlaylist

我们可以重构代码块以使用viewProviders,以确保(如果需要)子实体仍然接收Playlist的实例,而不是TopHitsPlaylist。或者,我们可以走额外的路线,并使用工厂根据其他要求返回我们需要的特定对象实例。以下示例将根据布尔条件变量的评估返回Playlist标记的不同对象实例:

function playlistFactory() {
 if(condition) { 
 return new Playlist(); 
 }
 else { 
 return new TopHitsPlaylist(); 
 }
}

@Component({
 selector: 'music-charts',
 template: '<music-player></music-player>',
 providers: [{ provide : Playlist, useFactory : playlistFactory }]
})
export class MusicChartsComponent {}

所以,你可以看到这有多强大。例如,我们可以确保在测试时,我们的数据服务突然被模拟数据服务替换。关键是很容易告诉 DI 机制根据条件改变其行为。

扩展注入器支持到自定义实体

指令和组件需要依赖项进行内省、解析和注入。其他实体,如服务类,通常也需要这样的功能。在我们的示例中,我们的Playlist类可能依赖于与第三方通信的 HTTP 客户端的依赖项,以获取歌曲。注入这种依赖的操作应该像在类构造函数中声明带注释的依赖项一样简单,并且有一个注入器准备好通过检查类提供程序或任何其他提供程序来获取对象实例。

只有当我们认真思考后者时,我们才意识到这个想法存在一个漏洞:自定义类和服务不属于组件树。因此,它们不会从任何内置的注入器或父注入器中受益。我们甚至无法声明提供者属性,因为我们没有用@Component@Directive装饰器修饰这些类型的类。让我们看一个例子:

class Playlist {
 songs: Song[];
 constructor(songsService: SongsService) {
 this.songs = songsService.fetch();
 }
}

我们可能会尝试这样做,希望当实例化这个类以将其注入到MusicPlayerComponent中时,Angular 的 DI 机制会内省Playlist类构造函数的songsService参数。不幸的是,我们最终得到的只是这样的异常:

It cannot resolve all parameters for Playlist (?). Make sure they all have valid type or annotations.

这有点误导,因为Playlist中的所有构造函数参数都已经被正确注释了,对吧?正如我们之前所说,Angular DI 机制通过内省构造函数参数的类型来解析依赖关系。为了做到这一点,需要预先创建一些元数据。每个被装饰器修饰的 Angular 实体类都具有这些元数据,这是 TypeScript 编译装饰器配置细节的副产品。然而,还需要其他依赖项的依赖项没有装饰器,因此也没有为它们创建元数据。这可以通过@Injectable()装饰器轻松解决,它将为这些服务类提供 DI 机制的可见性。

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

@Injectable()
class Playlist {
 songs: string[];

 constructor(private songsService: SongsService) {
 this.songs = this.songsService.fetch();
 }
}

你会习惯在你的服务类中引入装饰器,因为它们经常依赖于与组件树无关的其他依赖项,以便提供功能。

实际上,无论构造函数是否具有依赖关系,都将所有服务类装饰为@Injectable()是一个很好的做法。这样,我们可以避免因为忽略这一要求而导致的错误和异常,一旦服务类增长,并且在将来需要更多的依赖关系。

使用bootstrapModule()初始化应用程序

正如我们在本章中所看到的,依赖查找一直冒泡直到顶部的第一个组件。这并不完全正确,因为 DI 机制还会检查bootstrapModule()函数的额外步骤。

据我们所知,我们使用 bootstrapModule() 函数来通过在其第一个参数中声明根模块来启动我们的应用程序,然后指出根组件,从而启动应用程序的组件树。

在文件 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);

从上述代码中可以得出的结论是,Angular 已经改变了引导的方式。通过添加 @NgModule,我们现在引导一个根模块而不是一个根组件。然而,根模块仍然需要指向一个应用程序启动的入口点。让我们来看看根模块是如何做到这一点的:

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

@NgModule({
 bootstrap: [AppComponent]
 // the rest omitted for brevity
})

注意 bootstrap 键的存在,我们如何指出根组件 AppComponent。还要注意 bootstrap 属性是一个数组。这意味着我们可以有多个根组件。每个根组件都将具有自己的注入器和服务单例集,彼此之间没有任何关系。接下来,让我们谈谈我们可以在其中进行修改的不同模式。

在开发和生产模式之间切换

Angular 应用程序默认在开发模式下引导和初始化。在开发模式下,Angular 运行时会向浏览器控制台抛出警告消息和断言。虽然这对于调试我们的应用程序非常有用,但当应用程序处于生产状态时,我们不希望显示这些消息。好消息是,可以禁用开发模式,转而使用更为安静的生产模式。这个操作通常是在引导我们的应用程序之前执行的:

import { environment } from './environments/environment';
// other imports omitted for brevity
if(environment.production) {
 enableProdMode();
}

//bootstrap
platformBrowserDynamic().bootstrapModule(AppModule);

我们可以看到,调用 enableProdMode() 是启用生产模式的方法。

Angular CLI 中的不同模式

值得注意的是,将不同的环境配置保存在不同的文件中是一个好主意,如下所示:

import { environment } from './environments/environment';

environments 目录包括两个不同的文件:

  • environment.ts

  • environment.prod.ts

第一个文件看起来像这样:

export const environment = {
 production: false
}

第二个文件看起来像这样:

export const environment = {
 production: true
}

根据我们调用 ng build 命令的方式,将使用其中的一个文件:

ng build --env=prod // uses environment.prod.ts
ng build // by default uses environment.ts 

要找出哪些文件映射到哪个环境,您应该查看 angular-cli.json 文件:

// config omitted for brevity
"environments" : {
 "dev": "environments/environment.ts",
 "prod": "environments/environment.prod.ts"
}

介绍应用程序目录结构

在前几章和本章的各个部分中,我们已经看到了布局 Angular 应用程序的不同方法和良好实践。这些准则涵盖了从命名约定到如何组织文件和文件夹的指针。从现在开始,我们将通过重构所有不同的接口、组件、指令、管道和服务,将所有这些知识付诸实践,使其符合最常见的社区约定。

到本章结束时,我们将拥有一个最终的应用程序布局,将我们迄今所见的一切都包含在以下站点架构中:

app/
 assets/ // global CSS or image files are stored here
 core/
 (application wide services end up here)
 core.module.ts
 shared/
 shared.module.ts // Angular module for shared context
 timer/
 ( timer-related components and directives )
 timer.module.ts // Angular module for timer context
 tasks/
 ( task-related components and directive )
 task.module.ts // Angular module for task context
 app
 app.component.ts
 app.module.ts // Angular module for app context
 main.ts // here we bootstrap the application
 index.html
 package.json
 tsconfig.json
 typings.json

很容易理解项目的整体原理。现在,我们将组合一个应用程序,其中包含两个主要上下文:计时器功能和任务列表功能。每个功能可以包含不同范围的组件、管道、指令或服务。每个功能的内部实现对其他功能或上下文是不透明的。每个功能上下文都公开了一个 Angular 模块,该模块导出了每个上下文提供给上层上下文或应用程序的功能部分(即组件,一个或多个)。所有其他功能部分(内部指令和组件)对应用程序的其余部分是隐藏的。

可以说很难划清界限,区分哪些属于特定上下文,哪些属于另一个上下文。有时,我们构建功能部分,比如某些指令或管道,可以在整个应用程序中重用。因此,将它们锁定到特定上下文并没有太多意义。对于这些情况,我们确实有共享上下文,其中存储着任何旨在在应用程序级别可重用的代码单元,而不是与组件无关的媒体文件,如样式表或位图图像。

app.component.ts文件包含并导出应用程序根组件,该组件声明并在其自己的注入器中注册其子组件所需的依赖项。正如您已经知道的,所有 Angular 应用程序必须至少有一个根模块和一个根组件,由bootstrapModule()函数初始化。这个操作实际上是在main.ts文件中执行的,该文件由index.html文件触发。

在这样的上下文中定义一个组件或一组相关组件可以提高可重用性和封装性。唯一与应用程序紧密耦合的组件是顶级根组件,其功能通常非常有限,基本上是在其模板视图中呈现其他子组件或作为路由器组件,正如我们将在后续章节中看到的那样。

最后一部分是包含 TypeScript 编译器、类型和npm配置的 JSON 文件。由于 Angular 框架的版本不断发展,我们不会在这里查看这些文件的实际内容。你应该知道它们的目的,但一些具体内容,比如对等依赖版本,经常会发生变化,所以最好参考本书的 GitHub 仓库获取每个文件的最新版本。不过,package.json文件需要特别提及。有一些常见的行业惯例和流行的种子项目,比如 Angular 官方网站提供的项目。我们提供了几个npm命令来简化整个安装过程和开发工作。

按照 Angular 的方式重构我们的应用程序

在本节中,我们将把我们在前几章中创建的代码分割成代码单元,遵循单一职责原则。因此,除了将每个模块分配到其自己的专用文件中之外,不要期望代码有太多变化。这就是为什么我们将更多地关注如何分割事物,而不是解释每个模块的目的,你应该已经知道了。无论如何,如果需要,我们将花一分钟讨论变化。

让我们从在你的工作文件夹中创建与前一节中看到的相同的目录结构开始。我们将在路上为每个文件夹填充文件。

共享上下文或将所有内容存储在一个公共模块中

共享上下文是我们存储任何构造的地方,其功能旨在一次被多个上下文使用,因为它对这些上下文也是不可知的。一个很好的例子是我们一直在用来装饰我们组件的番茄钟位图,它应该存储在app/shared/assets/img路径下(顺便说一句,请确实将它保存在那里)。

另一个很好的例子是对模型数据建模的接口,特别是当它们的模式可以在不同功能上下文中重复使用时。例如,当我们在第四章中定义了QueuedOnlyPipe时,我们只对记录集中项目的排队属性进行了操作。然后,我们可以认真考虑实现一个Queued接口,以便以后在具有该属性的模块中提供类型检查。这将使我们的管道更具重用性和模型无关性。代码如下:

//app/shared/queueable.model.ts

export interface Queueable {
 queued: boolean;
}

请注意这个工作流程:首先,我们定义与这个代码单元对应的模块,然后导出它,并将其标记为默认,这样我们就可以从其他地方按名称导入它。接口需要以这种方式导出,但在本书的其余部分,我们通常会在同一语句中声明并导出模块。

有了这个接口,我们现在可以安全地重构QueuedOnlyPipe,使其完全不依赖于Task接口,以便在任何需要过滤记录集的上下文中完全重用,无论它们代表什么。代码如下:

// app/shared/queued.only.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';
import { Queueable } from '../interfaces/queuable';

@Pipe({ name : 'queuedOnly' })
export class QueuedOnlyPipe implements PipeTransform {
 transform(queueableItems: Queueable[], ...args) :Queueable[] {
 return queuableItems.filter( 
 queueableItem:Queueable => queueableItem.queued === args[0]
 )
 }
}

正如您所看到的,每个代码单元都包含一个单一的模块。这个代码单元符合 Angular 文件名的命名约定,清楚地以驼峰命名法陈述了模块名称,再加上类型后缀(在这种情况下是.pipe)。实现也没有改变,除了我们用Queuable类型注释了所有可排队的项目,而不是之前的任务注释。现在,我们的管道可以在任何实现Queueable接口的模型存在的地方重复使用。

然而,有一件事情需要引起您的注意:我们不是从源位置导入Queuable接口,而是从一个名为shared.ts的文件中导入,该文件位于上一级目录。这是共享上下文的门面文件,我们将从该文件公开所有公共共享模块,不仅供消费共享上下文模块的客户端使用,还供共享上下文内部的模块使用。这是一个情况:如果共享上下文内的任何模块更改其位置,我们需要更新门面,以便任何其他引用该模块的元素在同一上下文中保持不受影响,因为它通过门面来消费它。现在是一个很好的时机来介绍我们的共享模块,以前它将是一个门面文件:

//app/shared/shared.module.ts

import { QueuedOnlyPipe } from './pipes/queued-only.pipe';

@NgModule({
 declarations: [QueuedOnlyPipe],
 exports: [QueuedOnlyPipe]
})
export class SharedModule {}

与门面文件的主要区别在于,我们可以通过向SharedModule添加方法和注入服务等方式向其添加各种业务逻辑。

到目前为止,我们只通过SharedModule的 exports 属性公开了管道、指令和组件,但是其他东西如类和接口呢?嗯,我们可以在需要时直接要求它们,就像这样:

import { Queueable } from '../shared/queueable';

export class ProductionService {
 queueable: Queueable;
}

现在我们有一个可工作的Queuable接口和一个SharedModule,我们可以创建其他接口,这些接口将在整本书中使用,对应于Task实体,以及我们需要的其他管道:

//app/task/task.model.ts

import { Queueable } from './queueable';

export interface Task extends Queueable {
 name: string;
 deadline: Date;
 pomodorosRequired: number;
}

我们通过使用 extends(而不是 implements)在 TypeScript 中将一个接口实现到另一个接口上。现在,对于FormattedTimePipe

//app/shared/formatted.time.pipe.ts

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({ name : 'formattedTime' })
export class FormattedTimePipe {
 transform(totalMinutes: number) {
 let minutes: number = totalMinutes % 60;
 let hours: number = Math.floor( totalMinutes / 60 );
 return `${hours}h:${minutes}m`;
 }
}

最后,我们需要更新我们的SharedModule,以包含这个Pipe

//app/shared/shared.module.ts

import { QueuedOnlyPipe } from './pipes/queued-only.pipe';
import { FormattedTimePipe } from './pipes/formatted-time.pipe';

@NgModule({
 declarations: [QueuedOnlyPipe, FormattedTimePipe],
 exports: [QueuedOnlyPipe, FormattedTimePipe]
})
export class SharedModule {}

总结一下我们在这里做的事情,我们创建了两个接口,TaskQueueable。我们还创建了两个管道,QueuedOnlyPipeFormattedTimePipe。我们将后者添加到我们的@NgModule的 declarations 关键字中,至于接口,我们将使用import关键字根据需要将它们引入应用程序。不再需要通过门面文件公开它们。

共享上下文中的服务

让我们谈谈在共享上下文中拥有服务的影响,以及@NgModule的添加带来了什么。我们需要关心两种类型的服务:

  • 一个瞬态服务;这个服务创建自己的新副本,可能包含内部状态,对于每个创建的副本,它都有自己的状态

  • 一个单例,只能有一个此服务,如果它有状态,我们需要确保在整个应用程序中只有一个此服务的副本

在 Angular 中使用依赖注入,将服务放在模块的提供者中将确保它们最终出现在根注入器上,因此如果我们有这种情况,它们将只创建一个副本:

// app/task/task.module.ts

@NgModule({
 declarations: [TaskComponent],
 providers: [TaskService]
})
export class TaskModule {} 

早些时候,我们在TaskModule中声明了一个TaskService。让我们来定义另一个模块:

@NgModule({
 declarations: [ProductsComponent]
 providers: [ProductsService] 
})
export class ProductsModule {}

只要我们在根模块中导入这两个模块,就像这样:

//app/app.module.ts

@NgModule({
 imports: [TaskModule, ProductsModule]
})
export class AppModule {}

我们现在已经创建了一个情况,ProductsServiceTaskService可以被注入到ProductsComponentTaskComponent的构造函数中,这要归功于ProductsModuleTaskModule都被导入到AppModule中。到目前为止,我们还没有问题。然而,如果我们开始使用延迟加载,我们就会遇到问题。在延迟加载中,用户导航到某个路由,我们的模块与其构造一起被加载到包中。如果延迟加载的模块或其构造之一实际上注入了,比如ProductsService,那么它将不是TaskModuleProductsModule正在使用的相同ProductsService实例,这可能会成为一个问题,特别是如果状态是共享的。解决这个问题的方法是创建一个核心模块,一个被AppModule导入的模块;这将确保服务永远不会因错误而被再次实例化。因此,如果ProductsService在多个模块中使用,特别是在延迟加载的模块中使用,建议将其移动到核心模块。因此,我们从这样做:

@NgModule({
 providers: [ProductsService],
})
export class ProductsModule {}

将我们的ProductService移动到核心模块:

@NgModule({
 providers: [ProductsService]
})
export class CoreModule {}

当然,我们需要将新创建的CoreModule添加到我们的根模块中,就像这样:

@NgModule({
 providers: [],
 imports: [CoreModule, ProductsModule, TasksModule]
})
export class AppModule {}

有人可能会认为,如果我们的应用程序足够小,早期创建一个核心模块可能被视为有点过度。反对这一观点的是,Angular 框架采用移动优先的方法,作为开发人员,你应该延迟加载大部分模块,除非有充分的理由不这样做。这意味着当你处理可能被共享的服务时,你应该将它们移动到一个核心模块中。

在上一章中,我们构建了一个数据服务来为我们的数据表填充任务数据集。正如我们将在本书后面看到的那样,数据服务将被应用程序的其他上下文所使用。因此,我们将其分配到共享上下文中,并通过我们的共享模块进行暴露:

//app/task/task.service.ts

import { Injectable } from '@angular/core';
import { Task } from '../interfaces/task';

@Injectable()
export class TaskService {
 taskStore: Task[] = [];
 constructor() {
 const tasks = [
 {
 name : 'task 1',
 deadline : 'Jun 20 2017 ',
 pomodorosRequired : 2
 },
 {
 name : 'task 2',
 deadline : 'Jun 22 2017',
 pomodorosRequired : 3
 }
 ];

 this.taskStore = tasks.map( task => {
 return {
 name : task.name,
 deadline : new Date(task.deadline),
 queued : false,
 pomodorosRequired : task.pomodorosRequired
 }
 });
 }
}

请注意我们如何导入Injectable()装饰器并在我们的服务上实现它。它在构造函数中不需要任何依赖项,因此依赖于此服务的其他模块在声明构造函数时不会有任何问题。原因很简单:在我们的服务中默认应用@Injectable()装饰器实际上是一个很好的做法,以确保它们在开始依赖其他提供者时仍然能够无缝注入,以防我们忘记对它们进行装饰。

从中央服务配置应用程序设置

在之前的章节中,我们在我们的组件中硬编码了很多东西:标签、持续时间、复数映射等等。有时,我们的上下文意味着具有高度的特定性,并且在那里拥有这些信息是可以接受的。但是,有时我们可能需要更灵活和更方便的方式来全局更新这些设置。

对于这个例子,我们将使所有l18n管道映射和设置都可以从共享上下文中的一个中央服务中获得,并像往常一样从shared.ts门面暴露出来。

以下代码描述了一个将保存应用程序所有配置的SettingsService

// app/core/settings.service.ts
import { Injectable } from '@angular/core';

@Injectable()
export class SettingsService {
 timerMinutes: number;
 labelsMap: any;
 pluralsMap: any;

 contructor() {
 this.timerMinutes = 25;
 this.labelsMap = {
 timer : {
 start : 'Start Timer',
 pause : 'Pause Timer',
 resume : 'Resume Countdown',
 other : 'Unknown'
 }
 };

 this.pluralsMap = {
 tasks : {
 '=0' : 'No pomodoros',
 '=1' : 'One pomodoro',
 'other' : '# pomodoros'
 }
 }
 }
}

请注意我们如何将与上下文无关的映射属性暴露出来,这些属性实际上是有命名空间的,以更好地按上下文分组不同的映射。

将此服务分成两个特定的服务并将它们放置在各自的上下文文件夹中,至少就l18n映射而言,这是完全可以的。请记住,诸如时间持续等数据将在不同的上下文中使用,正如我们将在本章后面看到的那样。

在我们的共享模块中将所有内容整合在一起

通过所有最新的更改,我们的shared.module.ts应该是这样的:

// app/shared/shared.module.ts

import { NgModule } from '@angular/core';
import { FormattedTimePipe } from './pipes/formatted-time-pipe';
import { QueuedOnlyPipe } from './pipes/queued-only-pipe';

import { SettingsService } from './services/settings.service';
import { TaskService } from './services/task.service';

@NgModule({
 declarations: [FormattedTimePipe, QueuedOnlyPipe],
  providers: [SettingsService, TaskService],
  exports: [FormattedTimePipe, QueuedOnlyPipe]
})
export class SharedModule {}

我们的SharedModule从前面暴露了FormattedTimePipeQueuedOnlyPipe,但是有一些新的添加;即,我们添加了provider关键字的内容。我们添加了我们的服务,SettingsServiceTaskService

现在,当这个模块被另一个模块消耗时,会发生一件有趣的事情;所以,让我们在下面的代码中看看这样的情景:

// app/app.module.ts

import { NgModule } from '@angular/core';
import { SharedModule } from './shared/shared.module';

@NgModule({
  imports: [SharedModule]
 // the rest is omitted for brevity
})
export class AppModule {}

从前面部分部分知道了导入另一个模块的影响。我们知道SharedModule中包含的所有内容现在都可以在AppModule中使用,但还有更多。SharedModuleprovider关键字中提到的任何内容都可以被注入。所以,假设我们有以下app.component.ts文件:

// app/app.component.ts

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

@Component({
 selector: 'app',
 template: 'app'
})
export class AppComponent {
 constructor(
    private settingsService:SettingsService, 
 private taskService: TaskService
 ) {}
}

正如你所看到的,现在我们可以自由地注入来自其他模块的服务,只要它们是:

  • 在其模块的provider关键字中提到

  • 它们所在的模块被另一个模块导入

总之,到目前为止,我们已经学会了如何将组件和服务添加到共享模块中,还学会了我们需要在声明和export关键字中注册组件,对于服务,我们需要将它们放在provider关键字中。最后,我们需要import它们所在的模块,你的共享构件就可以在应用程序中使用了。

创建我们的组件

有了我们共享的上下文,现在是时候满足我们的另外两个上下文了:定时器和任务。它们的名称足够描述它们的功能范围。每个上下文文件夹将分配组件、HTML 视图模板、CSS 和指令文件,以提供它们的功能,还有一个外观文件,导出此功能的公共组件。

生命周期钩子简介

生命周期钩子是你在指令或组件的生命周期中监视阶段的能力。这些钩子本身是完全可选的,但如果你了解如何使用它们,它们可能会有很大的帮助。有些钩子被认为是最佳实践,而其他钩子则有助于调试和理解应用程序中发生的情况。一个钩子带有一个定义你需要实现的方法的接口。Angular 框架确保调用钩子,只要你将接口添加到组件或指令中,并通过实现接口指定的方法来履行合同。因为我们刚刚开始学习如何构建你的应用程序,现在可能还没有理由使用某些钩子。所以,我们将有理由在后面的章节中返回这个主题。

你可以使用的钩子如下:

  • OnInit

  • OnDestroy

  • OnChanges

  • DoCheck

  • AfterContentInit

  • AfterContentChecked

  • AfterViewInit

  • AfterViewChecked

在本节中,我们将涵盖本章中的前三个钩子,因为其余的涉及到更复杂的主题。我们将在本书的后续章节中重新讨论剩下的五个钩子。

OnInit - 一切开始的地方

使用这个钩子就像添加OnInit接口并实现ngOnInit()方法一样简单:

export class ExampleComponent implements OnInit {
 ngOnInit() {}
}

不过,让我们谈谈为什么存在这个钩子。构造函数应该相对空,并且除了设置初始变量之外不应包含逻辑。在构造对象时不应该有任何意外,因为有时您构造的是用于业务使用的对象,有时它是在单元测试场景中创建的。

以下是在类的构造函数中执行的适当操作的示例。在这里,我们展示了对类成员变量的赋值:

export class Component {
 field: string;
 constructor(field: string) {
 this.field = field;
 }
}

以下示例显示了不应该做的事情。在代码中,我们在构造函数中订阅了一个 Observable。在某些情况下,这是可以接受的,但通常更好的做法是将这种代码放在ngOnInit()方法中:

export class Component {
 data:Entity;
 constructor(private http:Http) {
 this.http.get('url')
 .map(mapEntity)
 .subscribe( x => this.data = x);
 }
}

最好建立订阅,如之前使用OnInit接口提供的ngOnInit()方法所示。

当然,这是一个建议,而不是一项法律。如果您没有使用这个钩子,那么显然您需要使用构造函数或类似的方法来执行前面的 HTTP 调用。除了仅仅说构造函数应该为空以美观和处理测试时,还有另一个方面,即输入值的绑定。输入变量不会立即设置,因此依赖于构造函数中的输入值会导致运行时错误。让我们举例说明上述情景:

@Component({
 selector: 'father',
 template: '<child [prop]='title'></child>'
})
export class FatherComponent {
 title: string = 'value';
}

@Component({
 selector: 'child',
 template: 'child'
})
export class ExampleComponent implements OnInit {
 @Input prop;

 constructor(private http:Http) {
    // prop NOT set, accessing it might lead to an error
 console.log('prop constructor',prop) 
 }

 ngOnInit() {
    console.log('prop on init', prop) // prop is set and is safe to use
 }
}

在这个阶段,您可以确保所有绑定已经正确设置,并且可以安全地使用 prop 的值。如果您熟悉 jQuery,那么ngOnInit的作用很像$(document).ready()的构造,总的来说,当组件设置完成时发生的仪式在这一点上已经发生。

OnDestroy - 当组件从 DOM 树中移除时调用

这种典型用例是在组件即将离开 DOM 树时进行一些自定义清理。它由OnDestroy接口和ngOnDestroy()方法组成。

为了演示其用法,让我们看一下下面的代码片段,我们在其中实现了OnDestroy接口:

@Component({
 selector: 'todos',
 template: `
 <div *ngFor="let todo of todos">
 <todo [item]="todo" (remove)="remove($event)">
 </div>
 `
})
export class TodosComponent {
 todos;

 constructor() {
 this.todos = [{
 id : 1,
 name : 'clean'
 }, {
 id : 2,
 name : 'code' 
 }]
 }

 remove(todo) {
    this.todos = this.todos.filter( t => t.id !== todo.id );
 }
}

@Component({
 selector: 'todo',
 template: `
 <div *ngIf="item">{{item.name}} <button (click)="remove.emit(item)">Remove</button></div>
 `
})
export class TodoComponent implements OnDestroy {
 @Output() remove = new EventEmitter<any>();
 @Input() item;
  ngOnDestroy() { console.log('todo item removed from DOM'); }
}

我们之前的片段试图突出显示当TodoComponent的一个实例从 DOM 树中移除时。TodosComponent渲染了一个TodoComponents列表,当调用remove()方法时,目标TodoComponent被移除,从而触发TodoComponent上的ngOnDestroy()方法。

好的,很好,所以我们有一种方法来捕获组件被销毁的确切时刻...那又怎样呢?

这是我们清理资源的地方;通过清理,我们的意思是:

  • 超时,间隔应该在这里被取消订阅

  • 可观察流应该被取消订阅

  • 其他清理

基本上,任何导致印记的东西都应该在这里清理。

OnChanges - 发生了变化

这个钩子的使用方式如下:

export class ExampleComponent implements OnChanges {
 ngOnChanges(changes:  SimpleChanges) { }
}

注意我们的方法如何接受一个名为changes的输入参数。这是一个对象,其中所有已更改的属性作为changes对象的键。每个键指向一个对象,其中包含先前值和当前值,如下所示:

{
 'prop' : { currentValue : 11, previousValue : 10 }
 // below is the remaining changed properties
}

上述代码假设我们有一个带有prop字段的类,如下所示:

export class ExampleComponent {
 prop: string;
}

那么,是什么导致事物发生变化?嗯,这是绑定的变化,也就是说,我们设置了@Input属性,如下所示:

export  class  TodoComponent  implements  OnChanges { @Input() item; ngOnChanges(changes:  SimpleChanges) { for (let  change  in  changes) { console.log(` '${change}' changed from
 '${changes[change].previousValue}' to
 '${changes[change].currentValue}' `
 ) }
 }
}

这里值得注意的一点是,我们跟踪的是引用的变化,而不是对象的属性变化。例如,如果我们有以下代码:

<todo [item]="todoItem">

如果todoItem上的 name 属性发生了变化,使得todoItem.name变为code而不是coding,这不会导致报告变化。然而,如果整个项目被替换,就像下面的代码一样:

this.todoItem = { ...this.todoItem, { name : 'coding' });

那么这将导致一个变化事件被发出,因为todoItem现在指向一个全新的引用。希望这能稍微澄清一点。

计时器功能

我们的第一个功能是属于计时器功能的,这也是最简单的功能。它包括一个独特的组件,其中包含我们在前几章中构建的倒计时计时器:

import { Component } from  '@angular/core'; import { SettingsService } from  "../core/settings.service"; @Component({
  selector:  'timer-widget', template: ` <div  class="text-center"> <h1> {{ minutes }}:{{ seconds  |  number }}</h1> <p>
 <button  (click)="togglePause()"  class="btn btn-danger"> {{ buttonLabelKey  |  i18nSelect: buttonLabelsMap }} </button>
 </p>
 </div>
 `
})
export  class  TimerWidgetComponent  {
 minutes:  number; seconds:  number; isPaused:  boolean; buttonLabelKey:  string; buttonLabelsMap:  any; constructor(private  settingsService:  SettingsService) { this.buttonLabelsMap  =  this.settingsService.labelsMap.timer; }

 ngOnInit() { this.reset(); setInterval(()  =>  this.tick(),  1000); }

 reset() { this.isPaused  =  true; this.minutes  =  this.settingsService.timerMinutes  -  1; this.seconds  =  59; this.buttonLabelKey  =  'start'; }

 private  tick():  void  { if  (!this.isPaused) { this.buttonLabelKey  =  'pause'; if  (--this.seconds  <  0) {
 this.seconds  =  59;
 if  (--this.minutes  <  0) {
 this.reset();
 }
 }
 }
 }

 togglePause():  void  {
 this.isPaused  =  !this.isPaused;
 if  (this.minutes  <  this.settingsService.timerMinutes  ||
 this.seconds  <  59
 ) {
 this.buttonLabelKey  =  this.isPaused  ?  'resume'  :  'pause';
 }
 }
}

正如你所看到的,实现方式与我们在第一章中已经看到的在 Angular 中创建我们的第一个组件基本相同,唯一的区别是通过OnInit接口钩子在 init 生命周期阶段初始化组件。我们利用l18nSelect管道更好地处理定时器每个状态所需的不同标签,从SettingsService中消耗标签信息,该服务在构造函数中注入。在本章的后面部分,我们将看到在哪里注册该提供程序。分钟数也是从服务中获取的,一旦后者绑定到类字段。

通过我们将其添加到declarations关键字以及exported关键字,后者用于启用外部访问,该组件通过TimerModule文件timer.module.ts公开导出:

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

@NgModule({
 // tell other constructs in this module about it
 declarations: [TimerWidgetComponent], 
 // usable outside of this module
 exports: [TimerWidgetComponent] 
})
export class TimerModule() {}

我们还需要记住将我们新创建的模块导入到app.module.ts中的根模块中:

import { NgModule } from '@angular/core';
import { TimerModule } from './timer/timer.module';

@NgModule({
  imports: [TimerModule]
 // the rest is omitted for brevity
})

在这一点上,我们已经创建了一个很好的结构,然后我们将为定时器功能创建更多构造。

任务功能

任务功能包含了一些更多的逻辑,因为它涉及两个组件和一个指令。让我们从创建TaskTooltipDirective所需的核心单元开始:

import { Task } from  './task.model'; import { Input, Directive, HostListener } from  '@angular/core'; @Directive({
  selector:  '[task]' })
export  class  TaskTooltipDirective { private  defaultTooltipText:  string;
 @Input() task:  Task;
 @Input() taskTooltip:  any;

 @HostListener('mouseover')
 onMouseOver() {
 if (!this.defaultTooltipText  &&  this.taskTooltip) {
 this.defaultTooltipText  =  this.taskTooltip.innerText;
 }
 this.taskTooltip.innerText  =  this.defaultTooltipText;
 }
}

指令保留了所有原始功能,并只导入了 Angular 核心类型和所需的任务类型。现在让我们来看一下TaskIconsComponent

import { Component, Input, OnInit } from '@angular/core';
import { Task } from './task.model';

@Component({
 selector: 'task-icons',
 template: `
 <img *ngFor="let icon of icons"
 src="/app/shared/assets/img/pomodoro.png"
 width="{{size}}">`
})
export class TaskIconsComponent implements OnInit {
 @Input() task: Task;
 @Input() size: number;
 icons: Object[] = [];

 ngOnInit() {
 this.icons.length = this.task.noRequired;
 this.icons.fill({ name : this.task.name });
 }
}

到目前为止一切顺利。现在,让我们转到TasksComponent。这将包括:

  • 组件文件tasks.component.ts,其中用 TypeScript 描述了逻辑

  • CSS 文件tasks.component.css,其中定义了样式

  • 模板文件tasks.component.html,其中定义了标记

从 CSS 文件开始,它将如下所示:

// app/task/tasks.component.css

h3, p {
 text-align: center;
}

.table {
 margin: auto;
 max-width: 860px;
}

继续 HTML 标记:

// app/task/tasks.component.html

<div  class="container text-center"> <h3>
 One point = 25 min, {{ queued | i18nPlural: queueHeaderMapping }} 
 for today
 <span  class="small" *ngIf="queued > 0">
 (Estimated time : {{ queued * timerMinutes | formattedTime }})
 </span>
 </h3>
 <p>
 <span  *ngFor="let queuedTask of tasks | queuedOnly: true"> <task-icons
 [task]="queuedTask" [taskTooltip]="tooltip"
 size="50">
 </task-icons>
 </span>
 </p>
 <p  #tooltip  [hidden]="queued === 0">
 Mouseover for details
 </p>
 <h4>Tasks backlog</h4>
 <table  class="table">
 <thead>
 <tr>
 <th>Task ID</th>
 <th>Task name</th>
 <th>Deliver by</th>
 <th>Points required</th>
 <th>Actions</th>
 </tr>
 </thead>
 <tbody>
 <tr  *ngFor="let task of tasks; let i = index">
 <th  scope="row">{{ (i+1) }}
 <span  *ngIf="task.queued"  class="label label-info">
 Queued</span>
 </th>
 <td>{{ task.name | slice:0:35 }}
 <span  [hidden]="task.name.length < 35">...</span>
 </td>
 <td>{{ task.deadline | date: 'fullDate' }}
 <span  *ngIf="task.deadline < today"  class="label label-danger">
 Due</span>
 </td>
 <td  class="text-center">{{ task.noRequired }}</td>
 <td>
 <button  type="button"  class="btn btn-default btn-xs"  [ngSwitch]="task.queued"  (click)="toggleTask(task)">
 <ng-template  [ngSwitchCase]="false">
 <i  class="glyphicon glyphicon-plus-sign"></i>
 Add
 </ng-template>
 <ng-template  [ngSwitchCase]="true">
 <i  class="glyphicon glyphicon-minus-sign"></i>
 Remove
 </ng-template>
 <ng-template  ngSwitchDefault>
 <i  class="glyphicon glyphicon-plus-sign"></i>
 Add
 </ng-template>
 </button>
 </td>
 </tr>
 </tbody>
 </table>
</div>

请花一点时间查看应用于外部组件文件的命名约定,文件名与组件自身匹配,以便在上下文文件夹内的扁平结构中识别哪个文件属于什么。还请注意我们如何从模板中移除了主位图,并用名为timerMinutes的变量替换了硬编码的时间持续。这个变量在绑定表达式中计算完成所有排队任务的时间估计。我们将看到这个变量是如何在以下组件类中填充的:

// app/task/tasks.component.ts

import { Component, OnInit } from  '@angular/core'; import { TaskService } from  './task.service'; import { Task } from  "./task.model"; import { SettingsService } from  "../core/settings.service"; @Component({
  selector:  'tasks', styleUrls: ['tasks.component.css'], templateUrl:  'tasks.component.html' })
export  class  TasksComponent  implements  OnInit { today:  Date;
 tasks:  Task[];
 queued:  number;
 queueHeaderMapping:  any;
 timerMinutes:  number; constructor( private  taskService:  TaskService,
 private  settingsService:  SettingsService) {
 this.tasks  =  this.taskService.taskStore;
 this.today  =  new  Date();
 this.queueHeaderMapping  =  this.settingsService.pluralsMap.tasks;
 this.timerMinutes  =  this.settingsService.timerMinutes;
 }

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

 toggleTask(task:  Task):  void  { task.queued  =  !task.queued;
 this.updateQueued();
 }

 private  updateQueued():  void  { this.queued  =  this.tasks
 .filter((Task:  Task)  =>  Task.queued)
 .reduce((no:  number,  queuedTask:  Task)  =>  {
 return  no  +  queuedTask.noRequired;
 },  0);
 }
}

TasksComponent的实现有几个值得强调的方面。首先,我们可以在组件中注入TaskServiceSettingsService,利用 Angular 的 DI 系统。这些依赖项可以直接从构造函数中注入访问器,立即成为私有类成员。然后从绑定的服务中填充任务数据集和时间持续时间。

现在让我们将所有这些构造添加到TaskModule中,也就是文件task.module.ts,并导出所有指令或组件。然而,值得注意的是,我们这样做是因为我们认为所有这些构造可能需要在应用的其他地方引用。我强烈建议您认真考虑在exports关键字中放什么,不要放什么。您的默认立场应该是尽量少地进行导出:

import { NgModule } from '@angular/core';
@NgModule({
  declarations: [TasksComponent, TaskIconsComponent, TasksTooltipDirective],
  exports: [TasksComponent],
 providers: [TaskService]
 // the rest omitted for brevity
})

我们现在已经将构造添加到declarations关键字中,以便模块知道它们,还有exports关键字,以便导入我们的TaskModule的其他模块能够使用它们。下一个任务是设置我们的AppComponent,或者也称为根组件。

定义顶级根组件

准备好所有功能上下文后,现在是时候定义顶级根组件了,它将作为整个应用程序的启动组件,以树形层次结构的一簇组件展开。根组件通常具有最少的实现。主要子组件最终会演变成子组件的分支。

以下是根组件模板的示例。这是您的应用程序将驻留在其中的主要可视组件。在这里,定义应用程序标题、菜单或用于路由的视口是有意义的。

//app/app.component.ts

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

@Component({
 selector: 'app',
 template: `
 <nav class="navbar navbar-default navbar-static-top">
 <div class="container">
 <div class="navbar-header">
 <strong class="navbar-brand">My App</strong>
 </div>
 </div>
 </nav>
 <tasks></tasks>
 `
})
export class AppComponent {}

之前已经提到过,但值得重复。我们在app.component.ts文件中使用的任何构造都不属于AppModule,都需要被导入。从技术上讲,被导入的是这些构造所属的模块。您还需要确保这些构造通过在所述模块的exports关键字中提到而得到适当的暴露。通过前面的根组件,我们可以看到在app.component.ts的模板中使用了两个不同的组件,即<timer-widget><pomodoro-tasks>。这两个组件属于不同的模块,第一个组件属于TimerModule,第二个组件属于TaskModule。这意味着AppModule需要导入这两个模块才能编译。因此,app.module.ts应该如下所示:

import { NgModule } from '@angular/core';
import { TimerModule } from './timer/timer.module';
import { TasksModule } from './tasks/tasks.module';

@NgModule({
 imports: [ TimerModule, TasksModule ]
 // omitted for brevity
})
export class AppModule {}

总结

本章确实为您从现在开始将在 Angular 上构建的所有优秀应用奠定了基础。实际上,Angular 依赖管理的实现是这个框架的一大亮点,也是一个节省时间的工具。基于组件树的应用架构不再是什么高深的技术,我们在构建其他框架(如 AngularJS 和 React)中的 Web 软件时在某种程度上也遵循了这种模式。

本章结束了我们对 Angular 核心及其应用架构的探索,建立了我们在这个新的令人兴奋的框架上构建应用时将遵循的标准。

在接下来的章节中,我们将专注于非常具体的工具和模块,这些工具和模块可以帮助我们解决日常问题,从而打造我们的 Web 项目。我们将看到如何使用 Angular 开发更好的 HTTP 网络客户端。

第七章:使用 Angular 进行异步数据服务

连接到数据服务和 API,并处理异步信息是我们作为开发人员在日常生活中的常见任务。在这方面,Angular 为其热情的开发人员提供了无与伦比的工具集,帮助他们消费、消化和转换从数据服务中获取的各种数据。

有太多的可能性,需要一本整书来描述你可以通过连接到 API 或通过 HTTP 异步地从文件系统中消费信息所能做的一切。在本书中,我们只是浅尝辄止,但本章涵盖的关于 HTTP API 及其伴随的类和工具的见解将为您提供一切所需,让您的应用程序在短时间内连接到 HTTP 服务,而您可以根据自己的创造力来发挥它们的全部潜力。

在本章中,我们将:

  • 看看处理异步数据的不同策略

  • 介绍 Observables 和 Observers

  • 讨论函数式响应式编程和 RxJS

  • 审查 HTTP 类及其 API,并学习一些不错的服务模式

  • 了解 Firebase 以及如何将其连接到您的 Angular 应用程序

  • 通过实际的代码示例来看待前面提到的所有要点

处理异步信息的策略

从 API 中获取信息是我们日常实践中的常见操作。我们一直在通过 HTTP 获取信息——当通过向认证服务发送凭据来对用户进行身份验证时,或者在我们喜爱的 Twitter 小部件中获取最新的推文时。现代移动设备引入了一种无与伦比的消费远程服务的方式,即推迟请求和响应消费,直到移动连接可用。响应速度和可用性变得非常重要。尽管现代互联网连接速度超快,但在提供此类信息时总会涉及响应时间,这迫使我们建立机制以透明地处理应用程序中的状态,以便最终用户使用。

这并不局限于我们需要从外部资源消费信息的情景。

异步响应-从回调到承诺

有时,我们可能需要构建依赖于时间作为某个参数的功能,并且需要引入处理应用程序状态中这种延迟变化的代码模式。

针对所有这些情况,我们一直使用代码模式,比如回调模式,触发异步操作的函数期望在其签名中有另一个函数,该函数在异步操作完成后会发出一种通知,如下所示:

function  notifyCompletion() {
 console.log('Our asynchronous operation has been completed'); }

function  asynchronousOperation(callback) {
 setTimeout(() => { callback(); }, 5000); }

asynchronousOperation(notifyCompletion);

这种模式的问题在于,随着应用程序的增长和引入越来越多的嵌套回调,代码可能变得相当混乱和繁琐。为了避免这种情况,Promises引入了一种新的方式来构想异步数据管理,通过符合更整洁和更稳固的接口,不同的异步操作可以在同一级别链接甚至可以从其他函数中分割和返回。以下代码介绍了如何构造Promise

function getData() {
 return new Promise((resolve, reject) => {
 setTimeout(() => { 
 resolve(42); 
 }, 3000);
 })
}

getData().then((data) => console.log('Data',data)) // 42

前面的代码示例可能有点冗长,但它确实为我们的函数提供了更具表现力和优雅的接口。至于链式数据,我们需要了解我们要解决的问题。我们正在解决一种称为回调地狱的东西,看起来像这样:

getData(function(data){
 getMoreData(data, function(moreData){
 getEvenMoreData(moreData, function(evenMoreData) {
 // done here
 });
 });
});

如前面的代码所示,我们有一个情况,即在执行下一个异步调用之前,我们依赖于先前的异步调用和它带回的数据。这导致我们不得不在回调中执行一个方法,然后在回调中执行另一个方法,依此类推。你明白了吧——代码很快就会变得很糟糕,也就是所谓的回调地狱。继续讨论链式异步调用的主题,链式是解决回调地狱的答案,Promises允许我们像这样链接它们:

getData()
 .then(getMoreData)
 .then(getEvenMoreData);

function getData() { 
 return new Promise(resolve) => resolve('data'); 
}

function getMoreData(data) {
 return new Promise((resolve, reject) => resolve('more data'));
}

function getEvenMoreData(data) {
 return new Promise((resolve, reject) => resolve('even more data'));
}

在前面的代码中,.then()方法调用的链接显示了我们如何清晰地将一个异步调用排在另一个异步调用之后,并且先前的异步调用已经将其结果输入到即将到来的async方法中。

因此,Promises以其强大的编码能力风靡编程领域,似乎没有开发人员会质疑它们为游戏带来的巨大价值。那么,为什么我们需要另一种范式呢?嗯,因为有时我们可能需要产生一个响应输出,该输出遵循更复杂的处理过程,甚至取消整个过程。这不能通过Promises来实现,因为它们一旦被实例化就会被触发。换句话说,Promises不是懒惰的。另一方面,在异步操作被触发但尚未完成之前取消它的可能性在某些情况下可能非常方便。Promises只允许我们解决或拒绝异步操作,但有时我们可能希望在达到那一点之前中止一切。此外,Promises表现为一次性操作。一旦它们被解决,我们就不能期望收到任何进一步的信息或状态变化通知,除非我们从头开始重新运行一切。此外,我们有时需要更主动地实现异步数据处理。这就是 Observable 出现的地方。总结一下 Promises 的限制:

  • 它们无法被取消

  • 它们会立即执行

  • 它们只是一次性操作;没有简单的重试方法

  • 它们只会响应一个值

Observable 简而言之

Observable 基本上是一个异步事件发射器,通知另一个元素,称为观察者,状态已经改变。为了做到这一点,Observable 实现了所有需要产生和发射这样的异步事件的机制,并且可以在任何时候被触发和取消,无论它是否已经发出了预期的数据事件。

这种模式允许并发操作和更高级的逻辑,因为订阅 Observable 异步事件的观察者将会反应 Observable 的状态变化。

这些订阅者,也就是我们之前提到的观察者,会一直监听 Observable 中发生的任何事情,直到 Observable 被处理掉,如果最终发生的话。与此同时,信息将在整个应用程序中更新,而不会触发例行程序。

我们可能可以在一个实际的例子中更透明地看到所有这些。让我们重新设计我们在评估基于 Promise 的异步操作时涵盖的示例,并用setInterval命令替换setTimeout命令:

function notifyCompletion() {
 console.log('Our asynchronous operation has been completed');
}

function asynchronousOperation() {
 let promise = new Promise((resolve, reject) => {
 setInterval(resolve, 2000); });

 return promise;
}

asynchronousOperation().then(notifyCompletion);

复制并粘贴上述片段到浏览器的控制台窗口,看看会发生什么。文本“我们的异步操作已经完成”将在 2 秒后只显示一次,并且不会再次呈现。承诺自行解决,整个异步事件在那一刻终止。

现在,将浏览器指向在线 JavaScript 代码 playground,比如 JSBIN(jsbin.com/),并创建一个新的代码片段,只启用 JavaScript 和 Console 选项卡。然后,确保您从“添加库”选项下拉菜单中添加 RxJS 库(我们将需要这个库来创建 Observables,但不要惊慌;我们将在本章后面介绍这个库),并插入以下代码片段:

let observable$ = Rx.Observable.create(observer => {
 setInterval(() => {
 observer.next('My async operation');
 }, 2000);
});

observable$.subscribe(response => console.log(response));

运行它,并期望在右窗格上出现一条消息。2 秒后,我们将看到相同的消息出现,然后再次出现。在这个简单的例子中,我们创建了一个observable,然后订阅了它的变化,将其发出的内容(在这个例子中是一个简单的消息)作为一种推送通知输出到控制台。

Observable 返回一系列事件,我们的订阅者会及时收到这些事件的通知,以便他们可以相应地采取行动。这就是 Observable 的魔力所在——Observable 不执行异步操作并终止(尽管我们可以配置它们这样做),而是开始一系列连续的事件,我们可以订阅我们的订阅者。

如果我们注释掉最后一行,什么也不会发生。控制台窗格将保持沉默,所有的魔法将只在我们订阅我们的源对象时开始。

然而,这还不是全部。在这些事件到达订阅者之前,这个流可以成为许多操作的主题。就像我们可以获取一个集合对象,比如数组,并对其应用map()filter()等函数方法来转换和操作数组项一样,我们也可以对我们的 Observable 发出的事件流进行相同的操作。这就是所谓的响应式函数编程,Angular 充分利用这种范式来处理异步信息。

在 Angular 中的响应式函数编程

Observable 模式是我们所知的响应式函数编程的核心。基本上,响应式函数脚本的最基本实现涵盖了我们需要熟悉的几个概念:

  • 可观察对象

  • 观察者

  • 时间线

  • 一系列具有与对象集合相同行为的事件

  • 一组可组合的操作符,也称为响应式扩展

听起来令人生畏?其实不是。相信我们告诉你,到目前为止你所经历的所有代码比这复杂得多。这里的重大挑战是改变你的思维方式,学会以一种反应式的方式思考,这是本节的主要目标。

简而言之,我们可以说,响应式编程涉及将异步订阅和转换应用于事件的 Observable 流。我们可以想象你现在的无表情,所以让我们组合一个更具描述性的例子。

想想交互设备,比如键盘。键盘上有用户按下的按键。用户按下每一个按键都会触发一个按键事件。该按键事件包含大量元数据,包括但不限于用户在特定时刻按下的特定按键的数字代码。当用户继续按键时,会触发更多的keyUp事件,并通过一个虚拟时间线传输。keyUp 事件的时间线应该如下图所示:

从前面的 keyUps 时间线中可以看出,这是一系列连续的数据,其中 keyUp 事件可以在任何时候发生;毕竟,用户决定何时按下这些按键。还记得我们写的 Observable 代码,包含setTimeout吗?那段代码能够告诉一个概念观察者,每隔 2 秒就应该发出另一个值。那段代码和我们的 keyUps 有什么区别?没有。嗯,我们知道定时器间隔触发的频率,而对于 keyUps,我们并不知道,因为这不在我们的控制之中。但这真的是唯一的区别,这意味着 keyUps 也可以被视为一个 Observable:

let key = document.getElementId('.search'); 
/* 
we assume there exist a button in the DOM like this 
<input class="search" placeholder="searchfor"></input>
*/

let stream = Rx.Observable.fromEvent(key, 'keyUp');
stream.subscribe((data) => console.log('key up happened', data))

所以,我真正告诉你的是,超时以及 keyUps 可以被视为同一个概念,即 Observable。这样更容易理解所有异步事物。然而,我们还需要另一个观察,即无论发生什么异步概念,它都是以列表的方式发生的。

尽管时间可能不同,但它仍然是一系列事件,就像一个列表。列表通常有一堆方法来投影、过滤或以其他方式操作它的元素,猜猜,Observable 也可以。列表可以执行这样的技巧:

let mappedAndFiltered = list
 .map(item => item + 1)
 .filter(item > 2);

因此,Observables 可以如下:

let stream = Rx.Observable
 .create(observer => {
 observer.next(1);
 observer.next(2);
 })
 .map(item => item + 1)
 .filter(item > 2);

在这一点上,区别只是命名不同。对于列表,.map().filter()被称为方法。对于 Observable,相同的方法被称为 Reactive Extensions 或操作符。想象一下,在这一点上,keyUps和超时可以被描述为 Observables,并且我们有操作符来操作数据。现在,更大的飞跃是意识到任何异步的东西,甚至是 HTTP 调用,都可以被视为 Observables。这意味着我们突然可以混合和匹配任何异步的东西。这使得一种称为丰富组合的东西成为可能。无论异步概念是什么,它和它的数据都可以被视为一个流,你是一个可以按照自己的意愿来弯曲它的巫师。感到有力量——你现在可以将你的应用程序转变为一个反应式架构。

RxJS 库

如前所述,Angular 依赖于 RxJS,这是 ReactiveX 库的 JavaScript 版本,它允许我们从各种情景中创建 Observables 和 Observable 序列,比如:

  • 交互事件

  • 承诺

  • 回调函数

  • 事件

在这个意义上,响应式编程并不旨在取代承诺或回调等异步模式。相反,它也可以利用它们来创建 Observable 序列。

RxJS 提供了内置支持,用于转换、过滤和组合生成的事件流的广泛的可组合操作符。其 API 提供了方便的方法来订阅观察者,以便我们的脚本和组件可以相应地对状态变化或交互输入做出响应。虽然其 API 如此庞大,以至于详细介绍超出了本书的范围,但我们将重点介绍其最基本的实现,以便您更好地理解 Angular 如何处理 HTTP 连接。

在深入研究 Angular 提供的 HTTP API 之前,让我们创建一个简单的 Observable 事件流的示例,我们可以用 Reactive Extensions 来转换,并订阅观察者。为此,让我们使用前一节中描述的情景。

我们设想用户通过键盘与我们的应用程序进行交互,可以将其转化为按键的时间线,因此成为一个事件流。回到 JSBIN,删除 JavaScript 窗格的内容,然后写下以下片段:

let keyboardStream$ = Rx.Observable
 .fromEvent(document, 'keyup')
 .map(x => x.which);

前面的代码相当自描述。我们利用Rx.Observable类及其fromEvent方法来创建一个事件发射器,该发射器流式传输在文档对象范围内发生的keyup事件。每个发射的事件对象都是一个复杂对象。因此,我们通过将事件流映射到一个新流上,该新流仅包含与每次按键对应的键码,来简化流式传输的对象。map方法是一种响应式扩展,具有与 JavaScript 中的map函数方法相同的行为。这就是为什么我们通常将这种代码风格称为响应式函数式编程。

好了,现在我们有了一个数字按键的事件流,但我们只对观察那些通知我们光标键击中的事件感兴趣。我们可以通过应用更多的响应式扩展来从现有流构建一个新流。因此,让我们用keyboardStream过滤这样一个流,并仅返回与光标键相关的事件。为了清晰起见,我们还将这些事件映射到它们的文本对应项。在前面的片段后面添加以下代码块:

let cursorMovesStream$ = keyboardStream
 .filter(x => {
 return  x > 36 && x < 41;
 })
 .map(x => {
 let direction;
 switch(x) {
 case 37:
 direction = 'left';
 break;
 case 38:
 direction = 'up';
 break;
 case 39:
 direction = 'right';
 break;
 default:
 direction = 'down';
 }
 return direction;
 });

我们本可以通过将filtermap方法链接到keyboardStream Observable 来一次性完成所有操作,然后订阅其输出,但通常最好分开处理。通过以这种方式塑造我们的代码,我们有一个通用的键盘事件流,以后可以完全不同的用途重复使用。因此,我们的应用程序可以扩展,同时保持代码占用空间最小化。

既然我们提到了订阅者,让我们订阅我们的光标移动流,并将move命令打印到控制台。我们在脚本的末尾输入以下语句,然后清除控制台窗格,并单击输出选项卡,以便我们可以在上面输入代码,以便我们可以尝试不同的代码语句:

cursorMovesStream$.subscribe(e => console.log(e));

单击输出窗格的任意位置将焦点放在上面,然后开始输入随机键盘键和光标键。

你可能想知道我们如何将这种模式应用到从 HTTP 服务中获取信息的异步场景中。基本上,你到目前为止已经习惯了向 AJAX 服务提交异步请求,然后通过回调函数处理响应或者通过 promise 进行处理。现在,我们将通过返回一个 Observable 来处理调用。这个 Observable 将在流的上下文中作为事件发出服务器响应,然后通过 Reactive Extensions 进行更好地处理响应。

介绍 HTTP API

现在,在我们深入描述 Angular 框架在HttpClient服务实现方面给我们的东西之前,让我们谈谈如何将XmlHttpRequest包装成一个 Observable。为了做到这一点,我们首先需要意识到有一个合同需要履行,以便将其视为成功的包装。这个合同由以下内容组成:

  • 使用observer.next(data)来发出任何到达的数据

  • 当我们不再期望有更多的数据时,我们应该调用observer.complete()

  • 使用observer.error(error)来发出任何错误

就是这样;实际上非常简单。让我们看看XmlHttpRequest调用是什么样子的:

const request = new XMLHttpRequest();

request.onreadystatechange = () => {
 if(this.readyState === 4 and this.state === 200) {
 // request.responseText
 } else {
 // error occurred here
 }
}

request.open("GET", url);
request.send();

好的,所以我们有一个典型的回调模式,其中onreadystatechange属性指向一个方法,一旦数据到达就会被调用。这就是我们需要知道的所有内容来包装以下代码,所以让我们来做吧:

let stream$ = Rx.Observable.create(observer => {
 let request = new XMLHttpRequest();
 request.onreadystatechange = () => {
 if(this.readyState === 4 && this.state === 200) {
 observer.next( request.responseText )
 observer.complete();
 } else {
 observer.error( request.responseText ) 
 }
 }
})

就是这样,包装完成了;你现在已经构建了自己的 HTTP 服务。当然,这还不够,我们还有很多情况没有处理,比如 POST、PUT、DELETE、缓存等等。然而,重要的是让你意识到 Angular 中的 HTTP 服务为你做了所有繁重的工作。另一个重要的教训是,将任何类型的异步 API 转换为与我们其他异步概念很好契合的 Observable 是多么容易。所以,让我们继续使用 Angular 的 HTTP 服务实现。从这一点开始,我们将使用HttpClient服务。

HttpClient类提供了一个强大的 API,它抽象了处理通过各种 HTTP 方法进行异步连接所需的所有操作,并以一种简单舒适的方式处理响应。它的实现经过了很多精心的考虑,以确保程序员在开发利用这个类连接到 API 或数据资源的解决方案时感到轻松自在。

简而言之,HttpClient类的实例(已经作为Injectable资源实现,并且可以在我们的类构造函数中作为依赖提供者注入)公开了一个名为request()的连接方法,用于执行任何类型的 HTTP 连接。Angular 团队为最常见的请求操作(如 GET、POST、PUT 以及每个现有的 HTTP 动词)创建了一些语法快捷方式。因此,创建一个异步的 HTTP 请求就像这样简单:

let  request  =  new  HttpRequest('GET', 'jedis.json');
let myRequestStream:Observable<any> = http.request(request);

而且,所有这些都可以简化为一行代码:

let myRequestStream: Observable<any> = http.get('jedis.json');

正如我们所看到的,HttpClient类的连接方法通过返回一个 Observable 流来操作。这使我们能够订阅观察者到流中,一旦返回,观察者将相应地处理信息,可以多次进行:

let myRequestStream = http
 .get<Jedi[]>('jedis.json')
  .subscribe(data => console.log(data));

在前面的例子中,我们给get()方法一个模板化类型,它为我们进行了类型转换。让我们更加强调一下这一点:

.get<Jedi[]>('jedis.json')

这个事实使我们不必直接处理响应对象并执行映射操作将我们的 JSON 转换为 Jedi 对象列表。我们只需要记住我们资源的 URL,并指定一个类型,你订阅的内容就可以立即用于我们服务的订阅。

通过这样做,我们可以根据需要重新发起 HTTP 请求,我们的其余机制将相应地做出反应。我们甚至可以将 HTTP 调用表示的事件流与其他相关调用合并,并组合更复杂的 Observable 流和数据线程。可能性是无限的。

处理头部

在介绍HttpClient类时,我们提到了HttpRequest类。通常情况下,您不需要使用低级别的类,主要是因为HttpClient类提供了快捷方法,并且需要声明正在使用的 HTTP 动词(GET、POST 等)和要消耗的 URL。话虽如此,有时您可能希望在请求中引入特殊的 HTTP 头,或者自动附加查询字符串参数到每个请求中,举例来说。这就是为什么这些类在某些情况下会变得非常方便。想象一个使用情况,您希望在每个请求中添加身份验证令牌,以防止未经授权的用户从您的 API 端点中读取数据。

在以下示例中,我们读取身份验证令牌并将其附加为标头到我们对数据服务的请求。与我们的示例相反,我们将options哈希对象直接注入到HttpRequest构造函数中,跳过创建对象实例的步骤。Angular 还提供了一个包装类来定义自定义标头,我们将在这种情况下利用它。假设我们有一个 API,希望所有请求都包括名为Authorization的自定义标头,附加在登录系统时收到的authToken,然后将其持久化在浏览器的本地存储层中,例如:

const authToken = window.localStorage.getItem('auth_token');

let headers = new HttpHeaders();
headers.append('Authorization', `Token ${authToken}`);
let request = new HttpRequest('products.json', { headers: headers });

let authRequest = http.request(request);

再次强调,除了这种情况,您很少需要创建自定义请求配置,除非您希望在工厂类或方法中委托请求配置的创建并始终重用相同的Http包装器。Angular 为您提供了所有的灵活性,可以在抽象化应用程序时走得更远。

处理执行 HTTP 请求时的错误

处理我们请求中引发的错误,通过检查Response对象返回的信息实际上非常简单。我们只需要检查其Boolean属性的值,如果响应的 HTTP 状态在 2xx 范围之外,它将返回false,清楚地表明我们的请求无法成功完成。我们可以通过检查status属性来双重检查,以了解错误代码或type属性,它可以假定以下值:basiccorsdefaulterroropaque。检查响应标头和HttpResponse对象的statusText属性将提供有关错误来源的深入信息。

总的来说,我们并不打算在每次收到响应消息时检查这些属性。Angular 提供了一个 Observable 操作符来捕获错误,在其签名中注入我们需要检查的HttpResponse对象的先前属性:

http.get('/api/bio')
.subscribe(bio => this.bio = bio)
.catch(error: Response => Observable.of(error));

值得注意的是,我们通过使用catch()操作符捕获错误,并通过调用Observable.of(error)返回一个新的操作符,让我们的错误作为我们创建的新 Observable 的输入。这对我们来说是一个不会使流崩溃的方法,而是让它继续存在。当然,在更真实的情况下,我们可能不只是创建一个新的 Observable,而是可能记录错误并返回完全不同的东西,或者添加一些重试逻辑。关键是,通过catch()操作符,我们有一种捕获错误的方法;如何处理它取决于您的情况。

在正常情况下,您可能希望检查除了错误属性之外的更多数据,除了在更可靠的异常跟踪系统中记录这些信息之外。

注入 HttpClient 服务

HttpClient服务可以通过利用 Angular 独特的依赖注入系统注入到我们自己的组件和自定义类中。因此,如果我们需要实现 HTTP 调用,我们需要导入HttpClientModule并导入HttpClient服务:

// app/biography/biography.module.ts
import { HttpClientModule } from '@angular/common/http';

@NgModule({
  imports: [ HttpClientModule ]
})
export class BiographyModule {}

// app/biography/biography.component.ts

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

@Component({
 selector: 'bio',
 template: '<div>{{bio}}</div>'
})
export class BiographyComponent {
 bio: string;

 constructor(private http: HttpClient) {
 const  options  = {}; this.http.get('/api/bio', { ...options, responseType:  'text' }) .catch(err  =>  Observable.of(err)) .subscribe(x  => this.bio= bio)
 }
}

在提供的代码中,我们只是按照我们在上一节中指出的bio示例进行。请注意我们如何导入HttpClient类型,并将其作为依赖项注入到Biography构造函数中。

通常,我们需要在应用程序的不同部分执行多个 HTTP 调用,因此通常建议创建一个DataService和一个DataModule,它包装了HttpClientModuleHttpClient服务。

以下是创建这样一个DataService的示例:

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

@Injectable()
export class DataService {
 constructor(private http:HttpClient) {}

 get(url, options?) {}
 post(url, payload, options?) {}
 put(url, payload, options?) {}
 delete(url) {}
}

相应的DataModule将如下所示:

import {DataService} from './data.service';
import {HttpModule} from '@angular/http';

@NgModule({
  imports: [HttpClientModule],
 providers: [DataService] 
})

如果您想为调用后端添加自己的缓存或授权逻辑,这就是要做的地方。另一种方法是使用HttpInterceptors,在本章的即将到来的部分中将提供使用HttpInterceptors的示例。

当然,任何想要使用这个DataModule的模块都需要导入它,就像这样:

@NgModule({
  imports: [DataModule],
 declarations: [FeatureComponent]
})
export class FeatureModule {}

我们的FeatureModule中的任何构造现在都可以注入DataService,就像这样:

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

@Component({})
export class FeatureComponent {
 constructor(private service: DataService) { }
}

一个真实的案例研究 - 通过 HTTP 提供 Observable 数据

在上一章中,我们将整个应用程序重构为模型、服务、管道、指令和组件文件。其中一个服务是TaskService类,它是我们应用程序的核心,因为它提供了我们构建任务列表和其他相关组件所需的数据。

在我们的示例中,TaskService 类包含在我们想要传递的信息中。在实际情况下,您需要从服务器 API 或后端服务中获取该信息。让我们更新我们的示例以模拟这种情况。首先,我们将从 TaskService 类中删除任务信息,并将其包装成一个实际的 JSON 文件。让我们在共享文件夹中创建一个新的 JSON 文件,并用我们在原始 TaskService.ts 文件中硬编码的任务信息填充它,现在以 JSON 格式:

[{
 "name": "Code an HTML Table",
 "deadline": "Jun 23 2015",
 "pomodorosRequired": 1

}, {
 "name": "Sketch a wireframe for the new homepage",
 "deadline": "Jun 24 2016",
 "pomodorosRequired": 2

}, {
 "name": "Style table with Bootstrap styles",
 "deadline": "Jun 25 2016",
 "pomodorosRequired": 1

}, {
 "name": "Reinforce SEO with custom sitemap.xml",
 "deadline": "Jun 26 2016",
 "pomodorosRequired": 3
}]

将数据正确包装在自己的文件中后,我们可以像使用实际后端服务一样从我们的 TaskService 客户端类中使用它。但是,为此我们需要在 main.ts 文件中进行相关更改。原因是,尽管在安装所有 Angular 对等依赖项时安装了 RxJS 包,但反应式功能操作符(例如map())并不会立即可用。我们可以通过在应用程序初始化流的某个步骤中插入以下代码行来一次性导入所有这些内容,例如在main.ts的引导阶段:

import 'rxjs/Rx';

然而,这将导入所有反应式功能操作符,这些操作符根本不会被使用,并且会消耗大量带宽和资源。相反,惯例是只导入所需的内容,因此在 main.ts 文件的顶部追加以下导入行:

import 'rxjs/add/operator/map';
import { bootstrap } from '@angular/platform-browser-dynamic';
import AppModule from './app.module';

bootstrapModule(AppModule);

当以这种方式导入反应式操作符时,它会自动添加到 Observable 原型中,然后可以在整个应用程序中使用。应该说,可讳操作符的概念刚刚在 RxJS 5.5 中引入。在撰写本书时,我们刚刚在修补操作员原型的转变中,如上所述,并进入可讳操作符空间。对于感兴趣的读者,请查看这篇文章,其中详细描述了这对您的代码意味着什么。更改并不是很大,但仍然有变化:blog.angularindepth.com/rxjs-understanding-lettable-operators-fe74dda186d3

利用 HTTP - 重构我们的 TaskService 以使用 HTTP 服务

所有依赖项都已经就位,现在是重构的时候了

我们的 TaskService.ts 文件。打开服务文件,让我们更新导入语句块:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';

import { Task } from './task.model';

首先,我们导入HttpClientResponse符号,以便稍后可以注释我们的对象。Observable 符号是从 RxJS 库导入的,以便我们可以正确注释我们的异步 HTTP 请求的返回类型。我们还从文件task.model.ts导入Task作为模型(它是一个接口),如下所示:

export interface Task {
 name: string;
 deadline: string;
 pomodorosRequired: number;
 queued: boolean;
}

我们将通过两个步骤重构此服务:

  1. 重写服务以使用 HTTP 服务。

  2. 实现存储/反馈模式并给服务一个状态。

使用 Angular HTTP 服务

现在,我们将使用 HTTP 服务替换现有的静态数据实现。为此,我们调用 HTTP 服务的http.get()方法来获取数据,但我们还需要使用 map 操作符来获得我们可以向外显示的结果:

import { HttpClient } from '@angular/common/http';
import { Task } from './task.model';

export default class TaskService {
 constructor(private http:HttpClient) {}

 getTasks(): Observable<Task[]> {
 return this.http.get<Task[]>(`tasks.json`)
 }
}

要使用先前定义的服务,我们只需要告诉模块关于它。我们通过将其添加到providers关键字来实现这一点:

// app/tasks/task.module.ts

@NgModule({
 imports: [ /* add dependant modules here */ ],
 declarations: [ ./* add components and directives here */ ]
 providers: [TaskService],
})
export class TaskModule {}

此后,我们需要在使用者组件中注入TaskService并以适当的方式显示它:

// app/tasks/task.component.ts

@Component({
 template: `
 <div *ngFor="let task of tasks">
 {{ task.name }}
 </div>
 `
})
export class TasksComponent {
 tasks:Task[];
 constructor(private taskService:TaskService){
 this.taskService.getTasks().subscribe( tasks => this.tasks = tasks)
 }
}

大多数情况下使用有状态的 TaskService

到目前为止,我们已经介绍了如何将 HTTP 服务注入到服务构造函数中,并且已经能够从组件订阅这样的服务。在某些情况下,组件可能希望直接处理数据而不是使用 Observables。实际上,我们大多数情况下都是这样。因此,我们不必经常使用 Observables;HTTP 服务正在利用 Observables,对吧?我们正在谈论组件层。目前,我们在组件内部正在发生这种情况:

// app/tasks/task.service.ts

@Component({
 template: `
 <div *ngFor="let task of tasks$ | async">
 {{ task.name }}
 </div>
 `
})
export class TaskListComponent {
  tasks$:Observable<Task[]>; 
 constructor(private taskService: TaskService ) {}

 ngOnInit() {
 this.tasks$ = this.taskService.getTasks(); 
 }
} 

在这里,我们看到我们将taskService.getTasks()分配给一个名为tasks$的流。tasks$变量末尾的$是什么?这是我们用于流的命名约定;让我们尝试遵循任何未来流/可观察字段的命名约定。我们在 Angular 的上下文中将 Observable 和 stream 互换使用,它们的含义是相同的。我们还让| async异步管道与*ngFor一起处理它并显示我们的任务。

我们可以以更简单的方式做到这一点,就像这样:

// app/tasks/tas.alt.component.ts

@Component({
 template: `
 <div *ngFor="let task of tasks">
 {{ task.name }}
 </div>
 `
})
export class TaskComponent {
 constructor(private taskService: TaskService ) {}

  get tasks() {
 return this.taskService.tasks;
 } 
} 

因此,发生了以下更改:

  • ngOnInit()和分配给tasks$流的部分被移除了

  • 异步管道被移除

  • 我们用tasks数组替换了tasks$

这还能工作吗?答案在于我们如何定义我们的服务。我们的服务需要暴露一个项目数组,并且我们需要确保当我们从 HTTP 获取到一些数据时,或者当我们从其他地方接收到数据时,比如来自 Web 套接字或类似 Firebase 的产品时,数组会发生变化。

我们刚刚提到了两种有趣的方法,套接字和 Firebase。让我们解释一下它们是什么,以及它们如何与我们的服务相关。Web 套接字是一种利用 TCP 协议建立双向通信的技术,所谓的全双工连接。那么,在 HTTP 的背景下提到它为什么有趣呢?大多数情况下,您会有简单的场景,您可以通过 HTTP 获取数据,并且可以利用 Angular 的 HTTP 服务。有时,数据可能来自全双工连接,除了来自 HTTP。

那么 Firebase 呢?Firebase 是谷歌的产品,允许我们在云中创建数据库。正如可以预料的那样,我们可以对数据库执行 CRUD 操作,但其强大之处在于我们可以设置订阅并监听其发生的更改。这意味着我们可以轻松创建协作应用程序,其中许多客户端正在操作相同的数据源。这是一个非常有趣的话题。这意味着您可以快速为您的 Angular 应用程序提供后端,因此,出于这个原因,它值得有自己的章节。它也恰好是本书的下一章。

回到我们试图表达的观点。从理论上讲,添加套接字或 Firebase 似乎会使我们的服务变得更加复杂。实际上,它们并不会。您需要记住的唯一一件事是,当这样的数据到达时,它需要被添加到我们的tasks数组中。我们在这里做出的假设是,处理来自 HTTP 服务以及来自 Firebase 或 Web 套接字等全双工连接的任务是有趣的。

让我们看看在我们的代码中涉及 HTTP 服务和套接字会是什么样子。您可以通过使用包装其 API 的库轻松利用套接字。

大多数浏览器原生支持 WebSockets,但仍被认为是实验性的。话虽如此,依然有意义依赖于一个帮助我们处理套接字的库,但值得注意的是,当 WebSockets 变得不再是实验性的时候,我们将不再考虑使用库。对于感兴趣的读者,请查看官方文档developer.mozilla.org/en-US/docs/Web/API/WebSockets_API

有一个这样的库是socket.io库;可以通过以下方式安装它:

npm install socket.io

要开始在 Angular 中使用这个,您需要:

  1. 导入socket.io-client

  2. 通过调用io(url)建立连接;这将返回一个套接字,您可以向其添加订阅。

  3. 等待包含我们想要在应用程序中显示的有效负载的传入事件。

  4. 生成事件并在想要与后端通信时发送可能的有效负载

以下代码将只向您展示如何执行这些步骤。然而,套接字的实现还有更多,比如创建后端。要了解使用 Angular 和socket.io的完整示例是什么样子,鼓励感兴趣的读者查看 Torgeir Helgwold 的以下文章:

www.syntaxsuccess.com/viewarticle/socket.io-with-rxjs-in-angular-2.0

这实际上不是一个 HTTP 主题,这就是为什么我们只显示代码中感兴趣的部分,这是我们将接收数据并将其添加到任务数组中的地方。我们还强调了套接字的设置和拆除。强调是用粗体来做的,如下所示:

import * as io from 'socket.io-client'**;** export class TaskService {
 subscription;
 tasks:Task[] = [];
 constructor(private http:HttpClient) {
 this.fetchData();

    this.socket = io(this.url**);  // establishing a socket connection** this.socket.on('task', (data) => { 
 // receive data from socket based on the 'task' event happening
 this.tasks = [ ..this.tasks, data ];
 });
 }

 private fetchData() {
 this.subscription = 
 this.http.get<Task[]>('/tasks')
 .subscribe( data => this.tasks = data );
 }

 // call this from the component when component is being destroyed
 destroy() {
    this.socket.removeAllListeners('task');  // clean up the socket
 connection
 } 
}

这是一个非常简单的示例,非常适合在模板中显示数据,并在tasks数组更改时更新模板。正如您所看到的,如果我们涉及socket,那也没关系;我们的模板仍然会被更新。

这种做法还包括另一种情况——两个或更多兄弟组件如何通信?答案很简单:它们使用TaskService。如果您希望其他组件的模板得到更新,那么只需更改任务数组的内容,它将反映在 UI 中。以下是此代码:

@Component({
 template: `
 <div *ngFor="let task of tasks">
 {{ task.name }}
 </div>
 <input [(ngModel)]="newTask" />
 <button (click)="addTask()" ></button>
 ` 
})
export class FirstSiblingComponent {
 newTask: string;

 constructor(private service: TaskService) {}

  get tasks() {
 return this.taskService.tasks;
 }

  addTask() {
 this.service.addTask({ name : this.newTask });
 this.newTask = '';
 }
}

这意味着我们还需要向我们的服务添加一个addTask()方法,如下所示:

import * as io from 'socket.io-client'**;** export class TaskService {
 subscription;
 tasks: Task[] = [];
 constructor(private http:Http) {
 this.fetchData();

 this.socket = io(this.url);  // establishing a socket connection

 this.socket.on('task', (data) => { 
 // receive data from socket based on the 'task' event happening
 this.tasks = [ ..this.tasks, data ];
 });
 }

 addTask(task: Task) {
 this.tasks = [ ...this.tasks, task]; 
 }

 private fetchData() {
 this.subscription = 
 this.http.get('/tasks')
 .subscribe(data => this.tasks = data);
 }

 // call this from the component when component is being destroyed
 destroy() {
 this.socket.removeAllListeners('task');  // clean up the socket
 connection
 } 
}

另一个组件在设置taskService、公开tasks属性和操作tasks列表方面看起来基本相同。无论哪个组件采取主动通过用户交互更改任务列表,另一个组件都会收到通知。我想强调这种通用方法的工作原理。为了使这种方法起作用,您需要通过组件中的 getter 公开任务数组,如下所示:

get tasks() {
 return this.taskService.tasks;
}

否则,对它的更改将不会被接收。

然而,有一个缺点。如果我们想确切地知道何时添加了一个项目,并且,比如说,基于此显示一些 CSS,那该怎么办?在这种情况下,您有两个选择:

  • 在组件中设置套接字连接并在那里监听数据更改。

  • 在任务服务中使用行为主题而不是任务数组。来自 HTTP 或套接字的任何更改都将通过subject.next()写入主题。如果这样做,那么当发生更改时,您可以简单地订阅该主题。

最后一个选择有点复杂,无法用几句话解释清楚,因此下一节将专门解释如何在数组上使用BehaviourSubject

进一步改进-将 TaskService 转变为有状态、更健壮的服务

RxJS 和 Observables 并不仅仅是为了与 Promises 一一对应而到来。RxJS 和响应式编程到来是为了推广一种不同类型的架构。从这样的架构中出现了适用于服务的存储模式。存储模式是确保我们的服务是有状态的,并且可以处理来自 HTTP 以外更多地方的数据。数据可能来自的潜在地方可能包括,例如:

  • HTTP

  • localStorage

  • 套接字

  • Firebase

在网络连接间歇性中断时处理服务调用

首先,您应该确保如果网络连接中断,应用程序仍然可以正常工作,至少在读取数据方面,您对应用程序用户有责任。对于这种情况,如果 HTTP 响应未能传递,我们可以使用localStorage进行回答。然而,这意味着我们需要在我们的服务中编写以下方式工作的逻辑:

if(networkIsDown) { 
 /* respond with localStorage instead */
} else { 
 /* respond with network call */
}

让我们拿出我们的服务,并稍微修改一下以适应离线状态:

export class TaskService {
 getTasks() {
 this.http .get<Task[]>('/data/tasks.json')  .do( data  => {  localStorage.setItem('tasks', JSON.stringify(data)) })
      .catch(err) => {
 return this.fetchLocalStorage();
 })
 }

 private fetchLocalStorage(){
 let tasks = localStorage.getItem('tasks');
 const tasks = localStorage.getItem('tasks') || [];
    return Observable.of(tasks);
 }
}

正如您所看到的,我们做了两件事:

  • 我们添加.do()运算符来执行副作用;在这种情况下,我们将响应写入localStorage

  • 我们添加了catch()操作符,并响应一个包含先前存储的数据或空数组的新 Observable

用这种方式解决问题没有错,而且在很多情况下,这甚至可能足够好。然而,如果像之前建议的那样,数据从许多不同的方向到达,会发生什么?如果是这种情况,那么我们必须有能力将数据推送到流中。通常,只有观察者可以使用observer.next()推送数据。

还有另一个构造,SubjectSubject具有双重性质。它既能向流中推送数据,也可以被订阅。让我们重写我们的服务以解决外部数据的到达,然后添加Sock.io库支持,这样您就会看到它是如何扩展的。我们首先使服务具有状态。诱人的做法是直接编写如下代码:

export class TaskService {
  tasks: Task[];
 getTasks() {
 this.http .get<Task[]>('/data/tasks.json')  .do( data  => { **this.tasks = mapTasks( data );** localStorage.setItem('tasks', JSON.stringify(data)) })
 .catch(err) => {
 return this.fetchLocalStorage();
 })
 }
}

我们建议的前述更改是加粗的,并且包括创建一个tasks数组字段,并对到达的数据进行任务字段的赋值。这样做是有效的,但可能超出了我们的需求。

引入 store/feed 模式

不过,我们可以做得更好。我们可以更好地做到这一点,因为我们实际上不需要创建那个最后的数组。在这一点上,你可能会想,让我弄清楚一下;你希望我的服务具有状态,但没有后备字段?嗯,有点,而且使用一种称为BehaviourSubject的东西是可能的。BehaviourSubject具有以下属性:

  • 它能够充当ObserverObservable,因此它可以推送数据并同时被订阅

  • 它可以有一个初始值

  • 它将记住它上次发出的值

因此,使用BehaviourSubject,我们实际上一举两得。它可以记住上次发出的数据,并且可以推送数据,使其在连接到其他数据源(如 Web 套接字)时非常理想。让我们首先将其添加到我们的服务中:

export class TaskService {
  private internalStore:BehaviourSubject;

 constructor() {
    this.internalStore = new BehaviourSubject([]); // setting initial
 value 
 }

 get store() {
    return this.internalStore.asObservable();
 }

 private fetchTasks(){
 this.http .get<Task[]>('/data/tasks.json')  .map(this.mapTasks) .do(data  => { **this.internalStore.next( data )** localStorage.setItem('tasks', JSON.stringify(data)) })
 .catch( err  => {
 return this.fetchLocalStorage();
 });
 }
}

在这里,我们实例化了BehaviourSubject,并且可以看到它的默认构造函数需要一个参数,即初始值。我们给它一个空数组。这个初始值是呈现给订阅者的第一件事。从应用程序的角度来看,在等待第一个 HTTP 调用完成时展示第一个值是有意义的。

我们还定义了一个store()属性,以确保当我们向外部公开BehaviourSubject时,我们将其作为Observable。这是防御性编码。因为主题上有一个next()方法,允许我们将值推送到其中;我们希望将这种能力从不在我们服务中的任何人身上夺走。我们这样做是因为我们希望确保任何添加到其中的内容都是通过TaskService类的公共 API 处理的:

get store() {
 return this.internalStore.asObservable();
}

最后的更改是添加到.do()操作符的

// here we are emitting the data as it arrives
.do(data  => { this.internalStore.next(data)  })

这将确保我们服务的任何订阅者始终获得最后发出的数据。在组件中尝试以下代码:

@Component({})
export class TaskComponent {
 constructor(taskService: TaskService ) {
 taskService.store.subscribe( data => {
 console.log('Subscriber 1', data);
 })

 setTimeout(() => {
 taskService.store
 .subscribe( data => console.log('Subscriber 2', data)); // will get the latest emitted value
 }, 3000)
 } 
}

在这一点上,我们已经确保无论何时开始订阅taskService.store,无论是立即还是在 3 秒后,如前面的代码所示,我们仍然会获得最后发出的数据。

持久化数据

如果我们需要持久化来自组件表单的内容怎么办?那么,我们需要做以下操作:

  • 在我们的服务上公开一个add()方法

  • 进行一个http.post()调用

  • 调用getTasks()以确保它重新获取数据

让我们从更简单的情况开始,从组件中添加任务。我们假设用户已经输入了创建应用程序 UI 中的Task所需的所有必要数据。从组件中调用了一个addTask()方法,这反过来调用了服务上类似的addTask()方法。我们需要向我们的服务添加最后一个方法,并且在该方法中调用一个带有 POST 请求的端点,以便我们的任务得到持久化,就像这样:

export class TaskService {
 addTask(task) {
 return this.http.post('/tasks', task);
 }
}

在这一点上,我们假设调用组件负责在组件上执行各种 CRUD 操作,包括显示任务列表。通过添加任务并持久化它,提到的列表现在将缺少一个成员,这就是为什么有必要对getTasks()进行新的调用。因此,如果我们有一个简单的服务,只有一个getTasks()方法,那么它将返回一个任务列表,包括我们新持久化的任务,如下所示:

@Component({})
export class TaskComponent implements OnInit {
 ngOnInit() {
 init();
 }

 private init(){
 this.taskService.getTasks().subscribe( data => this.tasks = data )
 }

 addTask(task) {
 this.taskService.addTask(task).subscribe( data => {
 this.taskService.getTasks().subscribe(data => this.tasks = data)
 });
 }
}

好的,如果我们有一个简化的TaskService,缺少我们漂亮的存储/反馈模式,那么这将起作用。不过,有一个问题——我们在使用 RxJS 时出错了。我们所说的错误是什么?每次我们使用addTask()时,我们都建立了一个新的订阅。

你想要的是以下内容:

  • 订阅任务流

  • 清理阶段,订阅被取消订阅

让我们先解决第一个问题;一个流。我们假设我们需要使用我们的TaskService的有状态版本。我们将组件代码更改为这样:

@Component({})
export class TaskComponent implements OnInit{
 private subscription;

 ngOnInit() {
 this.subscription = this.taskService.store.subscribe( data => this.tasks = data );
 }

 addTask(task) {
 this.taskService.addTask( task ).subscribe( data => {
 // tell the store to update itself? 
 });
 }
}

正如你所看到的,我们现在订阅了 store 属性,但是我们已经将taskService.addTask()方法内的重新获取行为移除,改为这样:

this.taskService.addTask(task).subscribe( data => {
 // tell the store to update itself? 
})

我们将把这个刷新逻辑放在taskService中,像这样:

export class TaskService {
 addTask(task) {
 this.http
 .post('/tasks', task)
 .subscribe( data => { this.fetchTasks(); })
 }
}

现在,一切都按预期运行。我们在组件中有一个订阅任务流,刷新逻辑被我们通过调用fetchTasks()方法推回到服务中。

我们还有一项业务要处理。我们如何处理订阅,更重要的是,我们如何处理取消订阅?记得我们如何向组件添加了一个subscription成员吗?那让我们完成了一半。让我们为我们的组件实现一个OnDestroy接口并实现这个约定:

@Component({
 template : `
 <div *ngFor="let task of tasks">
 {{ task.name }}
 </div>
 `
})
export class TaskComponent implements OnInit, implements OnDestroy{
 private subscription;
 tasks: Task[];

 ngOnInit() {
 this.subscription = this.taskService.store.subscribe( data => this.tasks = data );
 }

   ngOnDestroy() { 
 this.subscription.unsubscribe();
 }

 addTask(task) {
 this.taskService.addTask( task );
 }
} 

通过实现OnDestroy接口,我们有一种方法在订阅上调用unsubscribe(),我们在OnDestroy接口让我们实现的ngOnDestroy()方法中这样做。因此,我们为自己清理了一下。

实现OnInit接口和OnDestroy接口的模式是在创建组件时应该做的事情。在ngOnInit()方法中设置订阅和组件需要的其他任何内容是一个良好的实践,相反,在ngOnDestroy()方法中取消订阅和其他类型的构造是一个良好的实践。

然而,还有一种更好的方法,那就是使用async管道。async管道将消除保存订阅引用并调用.unsubscribe()的需要,因为这在async管道内部处理。我们将在本章的后续部分更多地讨论async管道,但是这是组件利用它而不是OnDestroy接口的样子:

@Component({
 template: `
 <div *ngFor="let task of tasks | async">
 {{ task.name }}
 </div>
 `
})
export class TaskComponent implements OnInit{
 get tasks() {
 return this.taskService.store; 
 }

 addTask(task) {
 this.taskService.addTask( task );
 }
} 

我们的代码刚刚删除了很多样板代码,最好的部分是它仍然在工作。只要你的所有数据都在一个组件中显示,那么async管道就是最好的选择;然而,如果你获取的数据是在其他服务之间共享或者作为获取其他数据的先决条件,那么使用async管道可能就不那么明显了。

最重要的是,最终你要求使用这些技术之一。

刷新我们的服务

我们几乎描述完了我们的TaskService,但还有一个方面我们需要涵盖。我们的服务没有考虑到第三方可能对终端数据库进行更改。如果我们远离组件或重新加载整个应用程序,我们将看到这些更改。如果我们想在更改发生时看到这些更改,我们需要有一些行为告诉我们数据何时发生了变化。诱人的是想到一个轮询解决方案,只是在一定的时间间隔内刷新数据。然而,这可能是一个痛苦的方法,因为我们获取的数据可能包含一个庞大的对象图。理想情况下,我们只想获取真正发生变化的数据,并将其修改到我们的应用程序中。在宽带连接时代,为什么我们如此关心这个问题?这是问题所在——一个应用程序应该能够在移动应用上使用,速度和移动数据合同可能是一个问题,所以我们需要考虑移动用户。以下是一些我们应该考虑的事情:

  • 数据的大小

  • 轮询间隔

如果数据的预期大小真的很大,那么向一个端点发出请求并询问它在一定时间后发生了什么变化可能是一个好主意;这将大大改变有效载荷的大小。我们也可以只要求返回一个部分对象图。轮询间隔是另一个需要考虑的事情。我们需要问自己:我们真的需要多久才能重新获取所有数据?答案可能是从不。

假设我们选择一种方法,我们要求获取增量(在一定时间后的变化);它可能看起来像下面这样:

constructor(){
 lastFetchedDate;
 INTERVAL_IN_SECONDS = 30;

 setInterval(() => {
 fetchTasksDelta( lastFetchedDate );
 lastFetchedDate = DateTime.now;
 }, this.INTERVAL_IN_SECONDS * 1000)
}

无论你采取什么方法和考虑,记住并不是所有用户都在宽带连接上。值得注意的是,越来越多的刷新场景现在 tend to be solved with Web Sockets,所以你可以在服务器和客户端之间创建一个开放的连接,服务器可以决定何时向客户端发送一些新数据。我们将把这个例子留给你,亲爱的读者,使用 Sockets 进行重构。

我们现在有一个可以:

  • 无状态

  • 能够处理离线连接

  • 为其他数据服务提供服务,比如 sockets

  • 能够在一定的时间间隔内刷新数据

所有这些都是通过BehaviourSubjectlocalStorage实现的。不要把 RxJS 只当作Promise的附加功能,而是使用它的构造和操作符来构建健壮的服务和架构模式。

HttpInterceptor

拦截器是一段可以在您的 HTTP 调用和应用程序的其余部分之间执行的代码。它可以在您即将发送请求时以及接收响应时挂钩。那么,我们用它来做什么呢?应用领域有很多,但有些可能是:

  • 为所有出站请求添加自定义令牌

  • 将所有传入的错误响应包装成业务异常;这也可以在后端完成

  • 重定向请求到其他地方

HttpInterceptor是从@angular/common/http导入的一个接口。要创建一个拦截器,您需要按照以下步骤进行:

  1. 导入并实现HttpInterceptor接口

  2. 在根模块提供程序中注册拦截器

  3. 编写请求的业务逻辑

创建一个模拟拦截器

让我们采取所有先前提到的步骤,并创建一个真正的拦截器服务。想象一下,对某个端点的所有调用都被定向到一个 JSON 文件或字典。这样做将创建一个模拟行为,您可以确保所有出站调用都被拦截,并在它们的位置上,您用适当的模拟数据回应。这将使您能够以自己的节奏开发 API,同时依赖于模拟数据。让我们深入探讨一下这种情况。

让我们首先创建我们的服务。让我们称之为MockInterceptor。它将需要像这样实现HttpInterceptor接口:

import { HttpInterceptor } from '@angular/common/http'; export  class  MockInterceptor  implements  **HttpInterceptor** {  constructor() { } intercept(request:  HttpRequest<any>, next:  HttpHandler):  Observable<HttpEvent<any>> { }
}

为了履行接口的约定,我们需要有一个接受请求和next()处理程序作为参数的intercept()方法。此后,我们需要确保从intercept()方法返回HttpEvent类型的 Observable。我们还没有在那里写任何逻辑,所以这实际上不会编译。让我们在intercept()方法中添加一些基本代码,使其工作,像这样:

import { HttpInterceptor } from '@angular/common/http'; export  class  MockInterceptor  implements  HttpInterceptor {  constructor() { } intercept(request:  HttpRequest<any>, next:  HttpHandler):  Observable<HttpEvent<any>> { return  next.handle(request**);** }
}

我们添加了对next.handle(request)的调用,这意味着我们接受传入的请求并将其传递到管道中。这段代码并没有做任何有用的事情,但它可以编译,并且教会我们,无论我们在intercept()方法中做什么,我们都需要使用请求对象调用next.handle()

让我们回到最初的目标——模拟出站请求。这意味着我们想要用我们的请求替换出站请求。为了实现我们的模拟行为,我们需要做以下事情:

  • 调查我们的出站请求,并确定我们是要用模拟来回应还是让它通过

  • 如果我们想要模拟它,构造一个模拟响应

  • 使用providers为一个模块注册我们的新拦截器

让我们在intercept()方法中添加一些代码,如下所示:

import { HttpInterceptor } from '@angular/common/http';

export class MockInterceptor implements HttpInterceptor {
 constructor() { }

 intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
 if (request.url.startsWith('/starwars') &&  request.method  ===  'GET') { const  url  =  request.url; const  newUrl  =  `data${url.substring('/starwars'.length)}.json`; const  req  =  new  HttpRequest('GET', newUrl); return  next.handle(req); } else { return  next.handle(request); }
 }
}

我们在这里基本上是在说,我们正在尝试对某个东西执行 GET 请求。/starwars将会拦截它,而不是响应一个 JSON 文件。所以,/starwars/ships将会导致我们响应ships.json/starwars/planets将会导致planets.json。你明白了吧;所有其他请求都会被放行。

我们还有一件事要做——告诉我们的模块这个拦截器存在。我们打开我们的模块文件并添加以下内容:

@NgModule({
 imports: [BrowserModule, HttpClientModule]
 providers: [{ 
 provide:  HTTP_INTERCEPTORS, 
 useClass:  MockInterceptor, 
 multi:  true **}**] })

一些最佳实践

在处理 Angular 中的数据服务时,特别是涉及到 Observables 时,有一些最佳实践需要遵循,其中包括:

  • 处理你的错误。这是不言而喻的,希望这对你来说并不是什么新鲜事。

  • 确保任何手动创建的 Observables 都有一个清理方法。

  • 取消订阅你的流/可观察对象,否则可能会出现资源泄漏。

  • 使用 async 管道来为你管理订阅/取消订阅过程。

到目前为止,我们还没有讨论如何在手动创建 Observables 时创建清理方法,这就是为什么我们将在一个小节中进行讨论。

在 Firebase 部分已经提到了 async 管道几次,但值得再次提及并通过解释它在订阅/取消订阅流程中的作用来建立对它的了解。

异步操作符

async 管道是一个 Angular 管道,因此它用在模板中。它与流/可观察对象一起使用。它发挥了两个作用:它帮助我们少打字,其次,它节省了整个设置和拆除订阅的仪式。

如果它不存在,当尝试从流中显示数据时,很容易会输入以下内容:

@Component({
 template: `{{ data }}`
})
export class DataComponent implements OnInit, implements OnDestroy {
 subscription;
 constructor(private service){ }

 ngOnInit() {
 this.subscription = this.service.getData()
 .subscribe( data => this.data = data )
 }

 ngOnDestroy() {
 this.subscription.unsubscribe(); 
 }
}

正如你所看到的,我们需要订阅和取消订阅数据。我们还需要引入一个数据属性来分配它。async 管道为我们节省了一些按键,所以我们可以像这样输入我们的组件:

@Component({
 template: `{{ data | async }}`
})
export class DataComponent implements OnInit {
 data$;
 constructor(private service){ }

 ngOnInit() {
 this.data$ = this.service.getData();
 }
}

这是少了很多代码。我们删除了:

  • OnDestroy接口

  • subscription变量

  • 任何订阅/取消订阅的调用

我们确实需要添加{{ data | async }},这是一个相当小的添加。

然而,如果我们得到的是一个更复杂的对象,并且我们想要显示它的属性,我们必须在模板中输入类似这样的内容:

{{ (data | ansync)?.title }}
{{ (data | ansync)?.description }}
{{ (data | ansync)?.author }}

我们这样做是因为数据还没有设置,此时访问属性会导致运行时错误,因此我们使用了?操作符。现在,这看起来有点冗长,我们可以使用-操作符来解决这个问题,就像这样:

<div *ngIf="data | async as d">
 {{ d.title }}
 {{ d.description }}
 {{ d.author }}
</div>

现在看起来好多了。使用async pipe将减少大量样板代码。

做一个好公民 - 在自己之后清理

好的,所以我已经告诉过你调用.unsubscribe()的重要性,你现在应该相信我,如果不调用它,资源就不会被清理。当你处理有着永无止境的数据流的流时,比如滚动事件,或者在需要创建自己的 Observables 时,了解这一点非常重要。我现在将展示一些 Observable 的内部,以使事情更清晰:

let stream$ = Observable.create( observer => {
 let i = 0;
 let interval = setInterval(() => {
 observer.next(i++);
 }, 2000)
})

let subscription = stream$.subscribe( data => console.log( data ));
setTimeout((
 subscription.unsubscribe();
) =>, 3000)

这是一个创建自己的 Observable 的例子。你以为只因为你按照指示调用了.unsubscribe()就安全了?错。间隔会继续计时,因为你没有告诉它停止。慌乱中,你关闭了浏览器标签,希望 Observable 消失 - 现在你是安全的。正确的方法是添加一个清理函数,就像这样:

let stream$ = Observable.create( observer => {
 let i = 0;
 let interval = setInterval(() => {
 observer.next(i++);
 }, 2000);

 return function cleanUp() {
 clearInterval( interval );
 }
})

let subscription = stream$.subscribe( data => console.log( data ));
setTimeout(() => subscription.unsubscribe(), 3000);

调用subscription.unsubscribe()时,它将在内部调用cleanUp()函数。大多数,如果不是全部,用于创建 Observables 的工厂方法都会定义自己的cleanUp()函数。重要的是,你应该知道,如果你冒险创建自己的 Observable,请参考本节,做一个好公民,并实现cleanUp()函数。

总结

正如我们在本章开头指出的,要详细介绍 Angular HTTP 连接功能所能做的所有伟大事情,需要不止一个章节,但好消息是我们已经涵盖了几乎所有我们需要的工具和类。

其余的就留给你的想象力了,所以随时可以尽情发挥,通过创建全新的 Twitter 阅读客户端、新闻源小部件或博客引擎,以及组装各种你选择的组件来将所有这些知识付诸实践。可能性是无限的,你可以选择各种策略,从 Promises 到 Observables。你可以利用响应式功能扩展和强大的Http类的令人难以置信的功能。

正如我们已经强调的那样,天空是无限的。但是,我们还有一条漫长而令人兴奋的道路在前方。现在我们知道了如何在我们的组件中消费异步数据,让我们来探索如何通过将用户路由到不同的组件中,为我们的应用提供更广泛的用户体验。我们将在下一章中介绍这个内容。

第八章:Firebase

Firebase 是一个移动和 Web 应用平台,最初由 Firebase Inc.在 2011 年开发,2014 年被 Google 收购。从那时起,它已经从云中的响应式数据库发展成了一个完整的产品套件。然而,我们将专注于数据库方面的事情,因为这对于 Angular 开发人员来说是有趣的部分。所以,最好的方式是将 Firebase 视为后端作为服务。这意味着使用 Firebase,没有理由构建自己的 REST 服务;你只需要连接到它。

值得指出的是,它最终是一个有付费计划的产品,但绝对可以在不支付任何费用的情况下创建玩具项目。

好的,后端作为服务,知道了。然而,它真正的卖点在于它是响应式的。它是响应式的,意味着如果你订阅了数据库上的一个集合,而客户端在某处对该集合进行了更改,你将收到通知并可以相应地采取行动。这听起来熟悉吗?是的,你想对了:它听起来像 RxJS 和 Observables,这就是为什么 Firebase API 已经被封装在了称为 AngularFire2 的 RXJS 中,这是一个你可以轻松从npm安装并添加到你的项目中的 Angular 模块。

因此,使用 Firebase 的商业案例首先是当你想要创建协作应用程序时。我要大胆地说,这就像是 Web 套接字,但在云中并且有一个底层数据库,所以不仅有通信部分,还有数据部分。

在这一章中,你将学到:

  • Firebase 是什么

  • 在你的 Angular 应用中利用 AngularFire2

  • 如何监听并对变化做出反应

  • 如何使用 CRUD 操作来操作你的 Firebase 数据

  • 为什么处理身份验证和授权很重要以及如何设置它们

三向绑定与双向绑定

我们有不同类型的绑定。AngularJS 使双向绑定变得著名。这意味着能够从两个不同的方向改变数据:

  • 视图中的变化会改变控制器上的数据

  • 控制器上的变化会反映在视图中

至于三向绑定,我们是什么意思?让我们通过一个应用来说明这一点;最好通过一张图片来描述。

你需要在这里想象的是,我们开发了一个使用 Firebase 的应用程序。我们在两个不同的浏览器窗口中启动了该应用程序。在第一个窗口中,我们进行了一项更改,这一更改在第二个浏览器窗口中得到了反映,例如,向列表中添加一个项目。那么,会发生什么步骤呢?

我们在这里看到的最好是从右到左阅读:

  • 实例一:用户更改视图

  • 实例一:更改传播到模型

  • 这会触发与 Firebase 数据库实例的同步

  • 第二个实例正在监听同步

  • 第二个实例的模型正在更新

  • 第二个实例的视图正在更新

你看到了:在一个地方进行更改,在两个或更多实例中看到结果,取决于你生成了多少实例。

关于存储的一些话-列表的问题

在深入了解 Firebase 之前,让我们首先解释为什么我们首先谈论列表。在关系数据库中,我们将使用 SQL、表和标准形式来定义我们的数据库。这在 Firebase 数据库中并不适用,因为它由 JSON 结构组成,看起来像这样:

{
 "person": { "age": 11, "name": "Charlie", "address": "First Street, Little Town" },
 "orders": { "someKeyHash": { "orderDate": "2010-01-01", "total": 114 },
 "someOtherKeyHash": { "orderDate": "2010-02-28" }
}

请注意,在关系数据库中,订单集合将是一个orders表,有很多行。在这里,它似乎是一个对象;为什么呢?

列表中的对象-解决删除问题

列表通常与列表中的每个项目关联一个索引,如下所示:

  • 0:项目 1

  • 1:项目 2

  • 2:项目 3

这没有问题,直到你开始考虑当许多同时用户开始访问相同的数据时会发生什么。只要我们进行读取,就没有问题。但是如果我们尝试其他操作,比如删除,会发生什么呢?

通常情况下,当你删除东西时,索引会被重新分配。如果我们删除前面的item2,我们将得到一个新的情况,看起来像这样:

  • 0:项目 1

  • 1:项目 3

想象我们根据索引进行删除,你的数据看起来像这样:

  • 0:项目 1

  • 1:项目 2

  • 2:项目 3

  • 3:项目 4

现在,两个不同的用户可以访问这些数据,其中一个想要删除索引 1,另一个想要删除索引 3。我们可能会采用一个锁定算法,所以一个用户在几毫秒之前删除了索引 1,而另一个用户删除了索引 3。第一个用户的意图是删除item2,第二个用户的意图是删除item4。第一个用户成功地完成了他们的目标,但第二个用户删除了一个超出范围的索引。

这意味着在多用户数据库中删除索引上的东西是疯狂的,但在 Firebase 的情况下,这意味着当它们被存储时,列表不是列表;它们是对象,看起来像这样:

{
 212sdsd: 'item 1',
 565hghh: 'item 2'
 // etc
}

这避免了删除问题,因此是列表以其方式表示的原因。

AngularFire2

AngularFire2 是将 Firebase API 封装在 Observables 中的库的名称。这意味着我们可以在想要监听更改等情况时,对它可能的外观有一些预期。我们将在本章的后面部分回到更改场景。

官方存储库可以在以下链接找到:

github.com/angular/angularfire2

如何进行 CRUD 和处理身份验证的出色文档可以在上述链接的页面底部找到。

核心类

在深入研究 AngularFire2 之前,了解一些基本信息是很有必要的;这些信息涉及核心对象及其职责:

  • AngularFireAuth

  • FirebaseObjectObservable

  • FirebaseListObservable

AngularFireAuth处理身份验证。FirebaseObjectObservable是您想要与之交谈的核心对象,当您知道您处理的数据库属性是对象类型时。最后,FirebaseListObservable是一个类似列表的对象。从前面我们知道 Firebase 列表并不真正是列表,但这并不妨碍该对象具有列表通常具有的方法。

管理工具

管理工具可以在firebase.google.com/找到。一旦进入,点击右上角的 GO TO CONSOLE 链接。您应该有一个 Gmail 帐户。如果有的话,那么您也有一个 Firebase 帐户,您只需要设置数据库。然后,您应该选择创建一个项目;给它一个您选择的标题和您的位置。

它应该是这样的:

完成这些步骤后,您将被带到管理页面,它看起来像这样:

上述屏幕截图显示了左侧菜单,然后右侧是内容窗格。右侧显示的内容根据您在左侧选择的内容而变化。正如您所看到的,您可以控制很多东西。在开始创建数据库时,您最重要的选项是:

  • 身份验证:在这里,您设置想要的身份验证类型:无身份验证、用户名/密码、社交登录等。

  • 数据库:在这里,您设计数据库应该是什么样子。这里还有一些选项卡,让我们控制数据库集合的授权。

其他选项也很有趣,但对于本节的目的来说,这超出了我们的范围。

定义您的数据库

我们转到左侧的数据库菜单选项。在下面的截图中,我已经向根节点添加了一个节点,即 book 节点:

在我们的根元素上悬停,我们会看到一个+字符,允许我们向根节点添加一个子节点。当然,我们也可以通过单击特定元素并向其添加子节点来创建更复杂的对象,看起来像这样:

正如您所看到的,我们可以很容易地构建出我们的数据库,它具有类似 JSON 的外观。

将 AngularFire2 添加到您的应用程序

到了将 Firebase 支持添加到我们的 Angular 应用程序的时候。为了做到这一点,我们需要做以下事情:

  1. 下载 AngularFire2 的npm库。

  2. 将该库导入到我们的 Angular 应用程序中。

  3. 设置我们的 Firebase 配置,以便 Firebase 让我们检索数据。

  4. 注入适当的 Firebase 服务,以便我们可以访问数据。

  5. 在一个组件中呈现数据。

链接github.com/angular/angularfire2/blob/master/docs/install-and-setup.md是在您的 Angular 应用程序中设置 Firebase 的官方链接。这可能会随时间而改变,因此如果在更新 AngularFire2 库后,书中的说明似乎不再起作用,可以值得检查此页面。不过,让我们按照步骤进行。

下载 AngularFire2 库就像输入这样简单:

npm install angularfire2 firebase --save

下一步是从 Firebase 管理页面获取配置数据,并将其保存到配置对象中。返回管理页面。通过按下以下按钮,您可以转到配置的正确页面:

  • 概述

  • 将 Firebase 添加到您的 Web 应用程序

此时,您已经有了一个对象形式的配置,具有以下属性:

let config =  { apiKey:  "<your api key>", authDomain:  "<your auth domain>", databaseURL:  "<your database url>", projectId:  "<your project id>", storageBucket:  "<your storage bucket>", messagingSenderId:  "<your messaging senderid>"  };

先前的值取决于您的项目。我只能建议您从管理页面复制配置,以进行下一步。

下一步是使用@angular-cli搭建一个 Angular 应用程序,并查找app.module.ts文件。在其中,我们将把我们的配置分配给以下变量:

export  const  environment  = {
 firebase: { apiKey:  "<your api key>", authDomain:  "<your auth domain>", databaseURL:  "<your database url>", projectId:  "<your project id>", storageBucket:  "<your storage bucket>", messagingSenderId:  "<your messaging sender id>"  } }

现在我们需要指示模块导入我们需要的模块。基本上,有三个模块可以导入:

  • AngularFireModule:这用于初始化应用程序

  • AngularFireDatabaseModule:这用于访问数据库;这是必要的导入

  • AngularFireAuthModule:这用于处理身份验证;一开始不是必要的,但随着应用程序的增长,它肯定会变得必要 - 安全性问题?

让我们导入前两个,这样我们就可以使用 Firebase 并从中提取一些数据:

import { AngularFireModule } from  'angularfire2'; import { AngularFireDatabaseModule } from  'angularfire2/database'; @NgModule({
 imports: [
 AngularFireModule.initializeApp(environment.firebase),
 AngularFireDatabaseModule
 ]
})

在这一点上,我们已经完成了配置 Angular 模块,可以继续进行AppComponent,这是我们将注入 Firebase 服务的地方,这样我们最终可以从 Firebase 中提取一些数据:

@Component({
 template : `to be defined`
})
export class AppComponent {
 constructor(private angularFireDatabase: AngularFireDatabase) {
 this.angularFireDatabase
 .object('/book')
 .valueChanges()
 .subscribe(data => {
 console.log('our book', data);
 });
 }
}

就是这样:一个完整的 Firebase 设置,从下载 AngularFire2 到显示您的第一个数据。

保护我们的应用程序

保护我们的应用程序至关重要。这不是一个如果,这是一个必须,除非您正在构建一个玩具应用程序。目前在 Firebase 中有三种方法可以做到这一点:

  • 身份验证:这是我们验证用户输入正确凭据以登录应用程序的地方

  • 授权:这是我们设置用户有权访问/修改应用程序中哪些资源的地方

  • 验证:这是我们确保只有有效数据被持久化在数据库中的地方

身份验证 - 允许访问应用程序

身份验证意味着当您尝试登录时我们识别您。如果您的凭据与数据库中的用户匹配,那么应用程序应该让您进入;否则,您将被拒之门外。Firebase 有不同的身份验证方式。目前,以下身份验证方法是可能的:

  • 电子邮件/密码

  • 电话

  • 谷歌

  • Facebook

  • Twitter

  • GitHub

  • 匿名

这基本上是您在 2017 年所期望的:从简单的电子邮件/密码身份验证到 OAuth 的一切。

授权 - 决定谁有权访问哪些数据,以及如何访问

至于授权,可以设置规则:

  • 在整个数据库上

  • 每个集合

同样重要的是要知道规则是通过以下方式执行的:

  • 原子地:适用于特定元素

  • 级联:适用于特定元素及其所有子元素

权限级别要么是:

  • 读取:这将使读取资源的内容成为可能

  • 写入:这将使您能够修改资源

  • 拒绝:这将阻止对目标资源的任何写入或读取操作

这需要一个例子。想象一下,您有以下数据库结构:

foo {
 bar: {
 child: 'value'
 }
}

原子授权意味着我们需要明确;如果我们不明确,那么默认情况下是拒绝访问。让我们试着对前面的结构强制执行一些规则。我们转到数据库菜单选项下的规则部分。规则被定义为一个 JSON 对象,如下所示:

rules: {
 "foo": {
 "bar": {
 ".read": true,
 ".write": false,
 "child": {} 
 }
 }
}

这意味着我们已经为bar设置了一个明确的原子规则,并且该规则被其子元素继承,也就是说,它以级联方式起作用。另一方面,foo没有规则。如果尝试访问这些集合,这将产生以下后果:

// deny
this.angularFireDatabase.object('/foo');
// read allowed, write not allowed
this.angularFireDatabase.object('/foo/bar'); 
// read allowed, write not allowed
this.angularFireDatabase.object('/foo/bar/child');

这解释了现行规则的类型。我敦促您通过研究以下链接来深入了解这个主题:

验证

这是一个非常有趣的话题。这里所指的是,我们可以通过设置关于数据形状的规则来控制允许进入我们集合的数据。您基本上指定了数据必须具备的一组要求,以便插入或更新被认为是可以执行的。就像读/写授权规则一样,我们指定一个规则对象。让我们描述两种不同版本的验证,这样你就能掌握它:

  • 传入数据必须包括这些字段

  • 传入数据必须在此范围内

我们可以这样描述第一种情况:

{
 "rules": {
 "order": {
 "name": {},
 "quantity"
 }
 }
}

这是一个代码片段,展示了前面的规则生效时的影响:

// will fail as 'quantity' is a must have field
angularFireDatabase.object('/order').set({ name : 'some name' });

在第二种情况下,我们可以这样设置规则:

{
 "rules": {
 "order": {
 "quantity": {
 ".validate": "newData.isNumber() && newData.val() >=0 
 && newData.val() <= 100" 
 }
 }
 }
}

前面指定的规则规定,任何传入的数据必须是数字类型,必须大于或等于0,并且小于或等于100

这是一个代码片段,展示了这个规则的影响:

// fails validation
angularFireDatabase.object('order').set({ quantity : 101 })

正如你所看到的,这使得我们非常容易保护我们的数据免受不必要的输入,从而保持数据库的整洁和一致。

处理数据 - CRUD

现在,我们来到了令人兴奋的部分:如何处理数据,读取我们的数据,添加更多数据等等。简而言之,术语创建,读取,更新,删除CRUD)。

因此,当我们使用 CRUD 时,我们需要了解我们正在操作的结构的一些信息。我们需要知道它是对象还是列表类型。在代码方面,这意味着以下内容:

this.angularFireDatabase.object(path).<operation>
this.angularFireDatabase.list(path).<operation>

前面说到,我们可以将我们从数据库中查看的数据视为对象或列表。根据我们的选择,这将影响我们可以使用的方法,也会影响返回的数据样式。如果数据库中有类似列表的结构,并选择将其视为对象,这一点尤其明显。假设我们有以下存储结构:

{
 id1: { value : 1 },
 id2: { value : 2 }
}

如果我们选择将其视为列表,我们将得到以下响应:

[{
 $key: id1,
 value : 1
},
{
 $key: id2,
 value : 2
}]

这意味着我们可以使用push()等方法向其中添加内容。

如果我们选择将数据视为一个对象,那么它将返回如下内容:

{
 id1: { value : 1 },
 id2: { value : 2 }
}

这可能是你想要的,也可能不是。因此请记住,如果它是一个列表,就把它当作一个列表。如果你选择.object()而不是.list(),Firebase 不会因此对你进行惩罚,但这可能会使数据更难处理。

读取数据

让我们看看读取的情况。以下代码将从数据库中的属性读取数据:

let stream$ = this.angulatFireDatabase.object('/book').valueChanges();

由于它是一个流,这意味着我们可以通过两种方式之一获取数据:

  • 使用 async 管道,在模板中显示可观察对象本身

  • subscribe()方法中获取数据并将其分配给类的属性。

如果我们进行第一种情况,代码将如下所示:

@Component({
 template: ` <div  *ngIf="$book | async; let book;">
 {{ ( book | async )?.title }}
 </div>
 `
})
export  class  Component { book$:  Observable<Book>; constructor(private  angularFireDatabase:  AngularFireDatabase) { this.book$  =  this.angularFireDatabase
 .object('/book')
 .valueChanges()
 .map(this.mapBook); }
 private  mapBook(obj):  Book { return  new  Book(obj); }
}

class  Book { constructor(title:  string) { } }

值得强调的是我们如何请求数据库中的路径,并使用.map()操作符转换结果:

this.book = this.angularFireDatabase
 .object('/book')
 .map(this.mapBook);

在模板中,我们使用 async 管道和一个表达式来显示我们的Book实体的标题,当它已经解析时:

<div *ngIf="book$ | async; let book">
 {{ book.title }}
</div>

如果我们进行第二种情况,代码将如下所示:

@Component({
 template: `
 <div>
 {{ book.title }}
 </div>
 `
})
export class BookComponent 
{
 book:Book;

 constructor(private angularFireDatabase: AngularFireDatabase) {
 this.angularFireDatabase.object('/book')
 .map(mapBook).subscribe( data => this.book = data );
 }

 mapBook(obj): Book {
 return new Book(obj);
 }
}

class Book {
 constructor(title:string) {} 
}

这将减少一些输入,但现在你必须记住取消订阅你的流;这在之前的例子中没有添加。在可能的情况下,请使用 async 管道。

更改数据

有两种类型的数据更改可能发生:

  • 破坏性更新:我们覆盖了原有的内容

  • 非破坏性更新:我们将传入的数据与已有的数据合并

用于破坏性更新的方法称为set(),使用方式如下:

this.angularFireDatabase.object('/book').set({ title : 'Moby Dick' })

考虑到我们之前的数据如下:

{
 title: 'The grapes of wrath',
 description: 'bla bla'
}

现在,它已经变成了:

{
 title: 'Moby Dick'
}

这正是我们所说的破坏性更新:我们覆盖了title属性,但我们也失去了description属性,因为整个对象被替换了。

如果数据的破坏不是您想要的,那么您可以使用更轻的update()方法。使用它就像写下面这样简单:

this.angularFireDatabase.object('/book').update({ publishingYear : 1931 })

假设在update()操作之前,数据看起来像下面这样:

{
 title: 'Grapes of wrath',
 description: 'Tom Joad and his family are forced from the farm'
} 

现在看起来像这样:

{
 title : 'Grapes of wrath',
 description : 'Tom Joad and his family are forced from the farm...',
 publishingYear : 1931
}

记住根据您的意图选择适当的更新操作,因为这会有所不同。

删除数据

删除数据很简单。我们需要将其分成两个不同的部分,因为它们有些不同,一个是删除对象,一个是删除列表中的项目。

在 Firebase 中,有不同的订阅数据的方式。您可以使用称为valueChanges()的方法订阅更改。这将为您提供要显示的数据。只要您想要显示数据,那么使用此方法就可以了。但是,当您开始想要更改特定数据,比如从列表中删除项目,或者简而言之,当您需要知道您要操作的资源的确切键值时,那么您需要一个新的函数。这个函数被称为snapshotChanges()。使用该函数会给您一个更原始的资源版本。在这种情况下,您需要挖掘出要显示的值。

让我们从第一种情况开始,即删除一个对象。

删除对象

让我们看看两种不同的remove()场景。在第一种情况下,我们想要删除我们的路径指向的内容。想象一下我们正在查看路径/书。那么,我们的删除代码非常简单:

this.angularFireDatabase.list('/books').remove();

删除列表中的项目

在 Firebase 中,从 Firebase 控制台查看数据库时,列表看起来像这样:

books {
 0 : 'tomato',
 1: 'cucumber' 
}

当然,每个项目都有一个内部表示,指向具有哈希值的键。我们有以下情景;我们想要删除列表中的第一项。我们编写的代码看起来像这样:

this.angularFireDatabase.list('/books').remove(<key>)

现在我们发现我们不知道要删除的项目的键是什么。这就是我们开始使用snapshotChanges()方法并尝试找出这一点的地方:

this.angularFireDatabase
 .list('/books')
 .snapshotChanges()
 .subscribe( list => {
 console.log('list',list);
 })

列表参数是一个列表,但列表项是一个包含我们需要的键以及我们打算在 UI 中显示的值的复杂对象。我们意识到这是正确的方法,并决定在我们的流上使用map()函数将其转换为书籍列表。

首先,我们修改我们的book.model.ts文件,包含一个 key 属性,就像这样:

export class Book {
 title: string;
  key: string;
 constructor(json) {
    this.title = json.payload.val();
 this.key = json.key; 
 }
}

我们可以看到,我们需要改变如何访问数据;我们的数据可以在payload.val()下找到,而我们的key很容易检索到。有了这个知识,我们现在可以构建一个列表:

@Component({})
export class BookComponent {
 books$:Observable<Book[]>

 constructor(private angularFireDatabase:AngularFireDatabase){
 this.books$ = this.angularFireDatabase
 .list('/books')
 .snapshotChanges()
 .map(this.mapBooks);
 }

 private mapBooks(data): Book[] {
 return data.map(json => new Book(json));
 }

 remove(key) {
 this,books$.remove(key);
 }

在以下代码片段中,我们循环遍历列表中的所有书籍,并为列表中的每本书创建一个删除按钮。我们还将每个删除按钮连接到book.key,也就是我们的key,这是我们在向 Firebase 通信删除操作时需要的。

<div *ngFor="let book of books | async">
 {{ book.title }}
 <button (click)="remove(book.key)">Remove</button>
</div>

响应变化

Firebase 的云数据库不仅仅是一个看起来像 JSON 的数据库,它还在数据发生变化时推送数据。您可以监听这种变化。这不仅为您提供了云存储,还为您提供了以更协作和实时的方式构建应用程序的机会。许多系统已经像这样工作,例如大多数售票系统、聊天应用程序等。

想象一下,使用 Firebase 构建的系统,例如,预订电影票。您可以看到一个人何时预订了一张票,或者在聊天系统中收到了一条消息,而无需轮询逻辑或刷新应用程序;构建起来几乎是小菜一碟。

AngularFire2,即 Firebase 上的 Angular 框架,使用 Observables。Observables 在发生变化时传达这些变化。从之前的知识中,我们知道可以通过给 subscribe 方法一个回调来监听这些变化,就像这样:

this.angularFireDatabase
 .list('/tickets')
 .valueChanges()
 .subscribe(tickets => {
 // this is our new ticket list
 });

作为开发人员,您可以拦截这种变化发生时,通过注册subscribe()方法,例如,显示 CSS 动画以吸引用户对变化做出响应,以便他们可以相应地做出响应。

添加身份验证

除非我们至少有一些适当的身份验证,否则我们无法真正构建一个应用程序并称其为发布准备就绪。基本上,我们不能信任任何人使用我们的数据,只有经过身份验证的用户。在 Firebase 中,您可以为数据库设置最高级别的身份验证。在管理工具中点击数据库菜单选项卡,然后选择规则选项卡。应该显示如下内容:

{
 "rules": {
 ".read": "auth != null",
 ".write": "auth != null"
 }
}

让我们来强调以下行:

".read": "auth != null"

在这种情况下,这设置了你整个数据库的读取权限,并且我们给了它值auth != null。这意味着你需要认证才能有任何读取数据库的权限。你可以看到在下一行我们有相同的值,但这次是针对一个叫做.write的规则,它控制着写入权限。

这是一个很好的默认权限。当然,在测试数据库时,你可能想要将值auth == null来关闭认证,但记得将值设置回auth != null,否则你会让你的数据库完全开放。

设置任何类型的认证意味着我们需要执行一些步骤,即:

  • 确保规则是开启的,也就是auth != null

  • 启用安全方法

  • 添加用户或令牌(如果是 OAuth)

  • 在应用中使用AuthService来以编程方式登录用户

使用邮箱/密码进行简单认证

让我们设置一个简单的用户/密码认证。点击认证菜单选项,然后选择登录方法选项卡。然后,启用邮箱/密码选项。它应该看起来像这样:

在这一点上,我们需要添加一个用户,一个被允许访问我们数据的用户。所以,让我们设置这个用户。我们去到用户选项卡,然后点击“添加用户”按钮。它应该看起来像这样:

好的,现在我们有一个邮箱为a@b.com,密码为abc123的用户。我们仍然需要登录这样一个用户,数据库才会显示数据给我们。如果我们不登录,我们的应用看起来会非常空,没有任何数据。我们还会在控制台日志中得到很多错误,说我们没有权限查看数据。

在之前设置 Firebase 时,我们只设置了数据库本身,而没有设置认证部分。由于 Firebase 是一个 Angular 模块,我们需要遵循一些规则:

  • 导入模块并将其添加到@NgModuleimport关键字中

  • AngularFireAuth服务放入@NgModule中的providers关键字中,这样组件就能够将其注入到其构造函数中

  • 执行一个编程登录

模块方面的事情看起来像下面这样:

import { AngularFireAuthModule, 
 AngularFireAuth } from  'angularfire2/auth'; @NgModule({
 imports: [
 AngularFireAuthModule
 ],
 providers: [AngularFireAuth]
})

现在,我们准备将服务注入到组件中并执行登录:

import { AngularFireDatabase } from 'angularfire2/database';
import { AngularFireAuth } from 'anguarfire2/auth';

@Component({
 template : `
 <div *ngFor="let b of books$ | async">
 {{ b.title }} {{ b.author }}
 </div>
 <div *ngIf="book$ | async; let book">
 {{ book.title }} {{ book.author }}
 </div>
 `
})
export class BookComponent {
 user;
 book$: Observable<Book>;
 books$: Observable<Book[]>;

 constructor(
 private authService: AngularFireAuth,
 private angularFireDatabase: AngularFireDatabase
 ) {
 this.user  = this.authService.authState;
 this.authService.auth
 .signInWithEmailAndPassword('a@b.com','abc123'**)**
 .then(success  => { this.book  =  this.angularFireDatabase .object('/book')
 .valueChanges().map(this.mapBook); this.books  =  this.angularFireDatabase
 .list('/books')
 .valueChanges()
 .map(this.mapBooks); }, 
 err  => console.log(err)
 );
 }
}

在这里,我们做了两件有趣的事情。

首先,我们将authServiceauthState分配给一个用户。这是一个 Observable,一旦登录,将包含你的用户。我们现在已经学会了可以使用 async 管道显示 Observables。然而,我们有兴趣从这个用户中获取两件事,uidemail,这样我们就可以看到我们以正确的用户身份登录了。编写模板代码看起来像这样是很诱人的:

<div *ngIf="user | async; let user">
 User : {{ user.uid }} {{ user.email }}
</div>

这为我们创建了一个名为 user 的变量,我们可以在登录后引用它。正如预期的那样,一旦登录,这将为我们打印出用户。

现在,我们来看看我们之前的代码的第二部分,登录调用:

authService
.auth .signInWithEmailAndPassword('a@b.com','abc123')
.then(success  => { this.book  = this.angularFireDatabase.object('/book')
 .map(this.mapBook); this.books$  = this.angularFireDatabase.list('/books')
 .map(this.mapBooks);
 }, 
 err  =>  console.log(err)
)

在这里,我们与authServiceauth属性交谈,并调用signInWithEmailAndPassword(email, password)方法。我们传递凭据。该方法返回一个 promise,解决了这个 promise 后,我们设置了我们的属性bookbooks。如果我们不这样做,首先进行身份验证,我们将会得到很多“访问不允许”的错误。

这里有更多的signInWith...方法,如下所示:

我们敦促你亲自尝试一下。

至于认证方式,我们只是触及了表面。以下是完整的登录方法范围:

尝试一下,看看哪些对你和你的应用程序有用。

总结

Firebase 是一种强大的技术,本质上是云端的后端;它具有响应式 API。AngularFire2 是包装 Firebase 的库的名称。该库专门用于与 Angular 一起使用。

可以监听来自 Firebase 的更改。AngularFire2 通过 RxJS 和 Observables 传达这些更改,这使得我们很容易将 Firebase 纳入我们的应用程序中,一旦我们掌握了使用 HTTP 的 Observables 的基础知识。

希望这是一个有教育意义的章节,进一步激励你选择在 Angular 中使用 RxJS 作为异步操作的首选。

本章是关于独立产品 Firebase 的。重点是要展示在你的指尖上有一种非常强大的技术,它扩展了你对 RxJS 的新知识。

在下一章中,我们将涵盖构建 Angular 应用程序的一个非常重要的方面,即路由。路由是一个核心概念,它允许我们将应用程序分成几个逻辑页面。我们谈论逻辑页面而不是实际页面,因为我们正在构建单页面应用程序(SPA)。你会问什么是区别?路由组件,你将在下一章中了解更多信息,将帮助你定义可以路由到的组件,以及帮助你定义应用程序中可以切换的视口。把你的应用程序想象成一个通行证或者一个框架。在应用程序的框架内,你可以定义诸如顶部菜单或左侧菜单之类的东西,但中间的绘画是你的应用程序中可以切换的部分。我们称之为可替换部分的页面。

第九章:路由

在之前的章节中,我们在应用程序中分离关注点并添加不同的抽象层,以增加应用程序的可维护性做得很好。然而,我们忽视了视觉方面,以及用户体验部分。

此刻,我们的用户界面中充斥着组件和各种东西,散布在单个屏幕上,我们需要提供更好的导航体验和一种直观地改变应用程序状态的逻辑方式。

这是路由变得特别重要的时刻,它给了我们建立应用程序导航叙事的机会,允许我们将不同的兴趣领域分割成不同的页面,这些页面通过一系列链接和 URL 相互连接。

然而,我们的应用程序只是一组组件,那么我们如何在它们之间部署导航方案呢?Angular 路由器是以组件化为目标而构建的。我们将看到如何创建自定义链接,并在接下来的页面中让组件对其做出反应。

在本章中,我们将:

  • 了解如何定义路由以在组件之间切换,并将它们重定向到其他路由

  • 根据请求的路由触发路由并在我们的视图中加载组件

  • 处理和传递不同类型的参数

  • 深入了解更高级的路由

  • 查看不同的保护路由的方式

  • 揭示如何通过查看不同的异步策略来改善响应时间

为 Angular 路由器添加支持

在应用程序中使用路由意味着您希望在导航中在不同主题之间进行切换。通常会使用顶部菜单或左侧菜单,并点击链接以到达目的地。这会导致浏览器中的 URL 发生变化。在单页应用程序SPA)中,这不会导致页面重新加载。要设置 Angular 路由器非常容易,但我们需要一些准备工作才能被认为已经设置好:

  • index.html中指定基本元素

  • 导入RouterModule并告知根模块

  • 设置路由字典

  • 确定应用程序视口的放置位置,即确定内容应放置在页面的哪个位置

  • 如果您想要调查诸如路由或查询参数之类的事情,或者如果您需要以编程方式将用户路由到应用程序中的另一页,则与路由服务进行交互。

指定基本元素

我们需要告诉 Angular 我们想要使用的基本路径,这样它才能在用户浏览网站时正确构建和识别 URL,正如我们将在下一节中看到的那样。我们的第一个任务将是在<HEAD>元素内插入一个基本href语句。在<head>标签内的代码语句的末尾添加以下代码行:

//index.html

<base href="/">

基础标签告诉浏览器在尝试加载外部资源(如媒体或 CSS 文件)时应该遵循的路径,一旦它深入到 URL 层次结构中。

导入和设置路由模块

现在,我们可以开始玩转路由库中存在的所有好东西。首先,我们需要导入RouterModule,我们在应用程序的根模块中执行此操作。因此,我们打开一个名为app.module.ts的文件,并在文件顶部插入以下行:

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

一旦我们这样做了,就该将RouterModule添加为AppModule类的依赖项了。

RouterModule是一个有点不同的模块;它需要在添加为依赖模块的同时进行初始化。它看起来像这样:

@NgModule({
 imports: [RouterModule.forRoot(routes, <optional config>)]
})

我们可以看到这里指向了我们尚未定义的变量路由。

定义路由

routes是一个路由条目列表,指定了应用程序中存在哪些路由以及哪些组件应该响应特定路由。它可以看起来像这样:

let routes = [{
 path: 'products',
 component: ProductsComponent 
}, {
 path: '**',
 component: PageNotFound 
}]

路由列表中的每个项目都是一个带有多个属性的对象。最重要的两个属性是pathcomponent。path 属性是路由路径,注意,您应该指定不带前导/的路径值。因此,将其设置为products,与前面的代码一样,意味着我们定义了用户导航到/products时会发生什么。component属性指向应该响应此路由的组件。指出的组件、模板和数据是用户在导航到该路由时将看到的内容。

第一个指定的路由定义了路径/products,最后一个路由项指定了**,这意味着它匹配任何路径。顺序很重要。如果我们首先定义了路由项**,那么products将永远不会被命中。最后定义**的原因是,我们希望有一个路由来处理用户输入未知路由的情况。现在,我们可以向用户展示一个由PageNotFound组件模板定义的漂亮页面,而不是向用户显示空白页面。

您可以在路由项上定义更多属性,也可以设置更复杂的路由。现在这就够了,这样我们就可以对路由设置有一个基本的理解。

定义一个视口

一旦我们走到这一步,就是定义一个视口,路由内容应该在其中呈现。通常,我们会构建一个应用程序,其中一部分内容是静态的,另一部分可以被切换,就像这样:

//app.component.html

<body>
 <!- header content ->
 <!- router content ->
 <!- footer content ->
</body>

在这一点上,我们涉及router-outlet元素。这是一个告诉路由器这是你应该呈现内容的元素。更新您的app.component.html看起来像这样:

<body>
 <!- header content ->
 <router-outlet> </router-outlet>
 <!- footer content ->
</body>

现在我们已经导入并初始化了router模块。我们还为两个路由定义了一个路由列表,并且已经定义了路由内容应该呈现的位置。这就是我们建立路由器的最小设置所需的一切。在下一节中,我们将看一个更现实的例子,并进一步扩展我们对路由模块的了解以及它可以帮助我们的知识。

构建一个实际的例子-设置路由服务

让我们描述一下问题领域。在本书的过程中,我们一直在处理番茄钟会话的上下文中的任务。到目前为止,我们一直在一个大的可视堆中创建所有需要的组件和其他构造。从用户的角度来看,更自然的方法是想象我们有专门的视图可以在之间导航。以下是用户的选择:

  • 用户到达我们的应用程序并检查待办任务的当前列表。用户可以安排任务按顺序完成,以获得下一个番茄钟会话所需的时间估计。

  • 如果需要,用户可以跳转到另一个页面并查看创建任务表单(我们将创建表单,但直到下一章才实现其编辑功能)。

  • 用户可以随时选择任何任务并开始完成它所需的番茄钟会话。

  • 用户可以在已经访问过的页面之间来回移动。

让我们看看前面的用户交互,并翻译一下这意味着我们应该支持哪些不同的视图:

  • 需要有一个列出所有任务的页面

  • 应该有一个包含创建任务表单的页面

  • 最后,应该有一种方法在页面之间来回导航

为演示目的构建一个新组件

到目前为止,我们已经构建了两个明确定义的组件,我们可以利用它们来提供多页面导航。但为了提供更好的用户体验,我们可能需要第三个。我们现在将介绍表单组件,我们将在第十章中更详细地探讨,作为我们示例中更多导航选项的一种方式。

我们将在任务特性文件夹中创建一个组件,预期在下一章中使用该表单来发布新任务。在每个位置指出的位置创建以下文件:

// app/tasks/task-editor.component.ts file

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

@Component({
 selector: 'tasks-editor',
 templateUrl: 'app/tasks/task-editor.component.html'
})
export default class TaskEditorComponent {
 constructor() {}
}

// app/tasks/task-editor.component.html file

<form class="container">
 <h3>Task Editor:</h3>
 <div class="form-group">
 <input type="text"
 class="form-control"
 placeholder="Task name"
 required>
 </div>
 <div class="form-group">
 <input type="Date"
 class="form-control"
 required>
 </div>
 <div class="form-group">
 <input type="number"
 class="form-control"
 placeholder="Points required"
 min="1"
 max="4"
 required>
 </div>
 <div class="form-group">
 <input type="checkbox" name="queued">
 <label for="queued"> this task by default?</label>
 </div>
 <p>
 <input type="submit" class="btn btn-success" value="Save">
 <a href="/" class="btn btn-danger">Cancel</a>
 </p>
</form>

这是组件的最基本定义。我们需要从我们的特性模块中公开这个新组件。最后,我们需要在路由列表中为这个组件输入路由项并配置路由。在app/tasks/task.module.ts文件中添加以下代码片段:

import { TasksComponent } from './tasks.component';
import { TaskEditorComponent } from './task.editor.component';
import { TaskTooltipDirective } from './task.tooltip.directive';

@NgModule({
 declarations: [
 TasksComponent,
 TaskEditorComponent,
 TaskTooltipDirective
 ],
 exports: [
 TasksComponent,
 TaskEditorComponent,
 TaskTooltipDirective
 ]
})
export class TaskModule{}

现在是时候配置路由了。我们需要分两步完成:

  • 创建包含我们路由的模块routes.ts

  • 在根模块中设置路由

首要任务是定义路由:

// app/routes.ts file

[{
 path: '',
 component : HomeComponent
 },{ 
 path: 'tasks',
 name: 'TasksComponent',
 component: TasksComponent
 }, {
 path: 'tasks/editor',
 name: 'TaskEditorComponent',
 component: TaskEditorComponent
 }, {
 path: 'timer',
 name: 'TimerComponent',
 component: TimerComponent
 }
]

第二个任务是初始化路由。我们在根模块中完成这个任务。要初始化路由,我们需要调用RouteModule及其静态方法forRoot,并将路由列表作为参数提供给它:

// app/app.module.ts file

import { RouterModule } from '@angular/router';
import routes from './routes';

@NgModule({
 ...
 imports: [RouterModule.forRoot(routes)]
 ...
})

清理路由

到目前为止,我们已经设置了路由,使它们按照应该的方式工作。然而,这种方法并不那么容易扩展。随着应用程序的增长,将会有越来越多的路由添加到routes.ts文件中。就像我们将组件和其他结构移动到它们各自的特性目录中一样,我们也应该将路由移动到它们应该属于的地方。到目前为止,我们的路由列表包括一个属于计时器特性的路由项,两个属于任务特性的路由项,以及一个指向默认路由/的路由项。

我们的清理工作将包括:

  • 为每个特性目录创建一个专用的routes.ts文件

  • 在每个具有路由的特性模块中调用RouteModule.forChild

  • 从任何不严格适用于整个应用程序的根模块中删除路由,例如** = route not found

这意味着应用程序结构现在看起来像以下内容:

/timer
 timer.module.ts
 timer.component.ts
 routes.ts
/app
 app.module.ts
 app.component.ts
 routes.ts
/task
 task.module.ts
 task.component.ts
 routes.ts
 ...

创建了一些文件后,我们准备初始化我们的功能路由。基本上,对于/timer/routes.ts/task/routes.ts,初始化是相同的。因此,让我们看一下routes.ts文件和预期的更改:

import routes from './routes';

@NgModule({
 imports: [
 RouteModule.forChild(routes)
 ]
})
export class FeatureModule {}

这里的重点是,将路由从app/routes.ts移动到<feature>/routes.ts意味着我们在各自的模块文件中设置路由,即<feature>/<feature>.module.ts。此外,当设置功能路由时,我们调用RouteModule.forChild,而不是RouteModule.forRoot

路由指令 - RouterOutlet、RouterLink 和 RouterLinkActive

我们已经在为 Angular 路由添加支持部分提到,为了设置路由,有一些基本的必要步骤使路由工作。让我们回顾一下它们是什么:

  • 定义路由列表

  • 初始化Route模块

  • 添加视口

对于这个实际示例的目的和目的,我们已经完成了前两项,剩下的是添加视口。一个指令处理 Angular 的视口;它被称为RouterOutlet,只需要放置在设置路由的组件模板中。因此,通过打开app.component.html并添加<router-outlet></router-outlet>,我们解决了列表上的最后一个项目。

当然,路由还有很多内容。一个有趣的事情,这是每个路由器都期望的,就是能够在定义的路由给定的情况下生成可点击的链接。routerLink指令为我们处理这个,并且以以下方式使用:

<a  routerLink="/"  routerLinkActive="active">Home</a>

routerLink指向路由路径,注意前导斜杠。这将查找我们的路由列表中定义的与路由路径/对应的路由项。经过对我们的代码的一些调查,我们找到了一个看起来像下面这样的路由项:

[{
 path : '',
 component : HomeComponent
}]

特别注意在定义路由时,我们不应该有前导斜杠,但是在使用该路由项创建链接并使用routerLink指令时,我们应该有一个尾随斜杠。

这产生了以下元素:

<a _ngcontent-c0="" routerlink="/" routerlinkactive="active" ng-reflect-router-link="/" ng-reflect-router-link-active="active" href="/" class="active">Home</a>

看起来很有趣,关键是href设置为/,类已设置为 active。

最后一部分很有趣,为什么类会被设置为活动状态?这就是routerLinkActive="active"为我们做的。它调查当前路由是否与我们当前所在的routerLink元素相对应。如果是,它将被授予活动 CSS 类。考虑以下标记:

<a  routerLink="/"  routerLinkActive="active" >Home</a> <a  routerLink="/tasks"routerLinkActive="active" >Tasks</a>
<a routerLink="/timer"routerLinkActive="active" >Timer</a> 

只有一个元素会被设置为活动类。如果浏览器的 URL 指向/tasks,那么它将是第二项,而不是第一项。添加活动类的事实给了你作为开发者的机会,可以为活动菜单元素设置样式,因为我们正在创建一个链接列表,就像前面的代码所定义的那样。

命令式地触发路由

导航的方式不仅仅是点击具有routerLink指令的元素。我们也可以通过代码或命令式地处理导航。为此,我们需要注入一个具有导航能力的导航服务。

让我们将导航服务,也称为Router,注入到一个组件中:

@Component({
 template : `
 <Button (click)="goToTimer()">Go to timer</Button>
 `
})
export class Component {
 constructor(private router:Router) {}

 goToTimer() {
 this.router.navigate(['/timer']);
 }
}

如你所见,我们设置了一个goToTimer方法,并将其与按钮的点击事件关联起来。在这个方法中,我们调用了router.navigate(),它接受一个数组。数组中的第一项是我们的路由;请注意末尾斜杠的使用。这就是命令式导航的简单方式。

处理参数

到目前为止,我们在路由中配置了相当基本的路径,但是如果我们想要构建支持在运行时创建参数或值的动态路径呢?创建(和导航到)从我们的数据存储中加载特定项目的 URL 是我们每天需要处理的常见操作。例如,我们可能需要提供主细节浏览功能,因此主页面中的每个生成的 URL 都包含在用户到达细节页面时加载每个项目所需的标识符。

我们基本上在这里解决了一个双重问题:在运行时创建具有动态参数的 URL,并解析这些参数的值。没问题;Angular 路由已经帮我们解决了这个问题,我们将通过一个真实的例子来看看。

构建详细页面 - 使用路由参数

首先,让我们回到任务列表组件模板。我们有一个路由,可以带我们到任务列表,但是如果我们想要查看特定的任务,或者想要将任务显示在特定的页面上呢?我们可以通过以下方式轻松解决:

  1. 更新任务组件,为每个项目添加导航功能,让我们能够导航到任务详细视图。

  2. 为一个任务设置路由,其 URL 路径将是tasks/:id

  3. 创建一个TaskDetail组件,只显示一个任务。

让我们从第一个要点开始:更新tasks.component.ts

应该说的是,我们可以用两种方式解决这个问题:

  • 进行命令式导航

  • 使用routerLink构建一个带有参数的路由

让我们先尝试展示如何进行命令式导航:

// app/tasks/tasks.component.html file

@Component({ selector:  'tasks', template: ` <div*ngFor="let task of store | async">
 {{ task.name }}
 <button (click)="navigate(task)">Go to detail</button>
 </div>  `
})
export class TasksComponent {
 constructor(private router: Router) {}

 navigate(task:Task) {
 this.router.navigate(['/tasks',task.id]);
 }
}

让我们强调以下代码片段:

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

这将产生一个看起来像/tasks/13/tasks/99的链接。在这种情况下,1399只是编造的数字,用来展示路由路径可能是什么样子的。

导航的第二种方式是使用routerLink指令。为了实现这一点,我们的前面的模板将略有不同:

<div*ngFor="let task of store | async">
 {{ task.name }}
 <a [routerLink]="['/tasks/',task.id]">Go to detail</a>
</div>

这两种方式都可以,只需选择最适合你的方式。

现在对于列表中的第二项,即设置路由,这将匹配先前描述的路由路径。我们打开task/routes.ts并向列表中添加以下条目:

[
 ...
 {
 path : '/tasks/:id',
 component : TaskDetailComponent
 }
 ...
]

有了这个路由,我们列表中的最后一项需要修复,即定义TaskDetailComponent。让我们从一个简单版本开始:

import { Component } from  '@angular/core'; @Component({
  selector:  'task-detail', template: 'task detail' })
export  class  TaskDetailComponent {  }

有了这一切,我们能够点击列表中的任务并导航到TaskDetailComponent。然而,我们在这里并不满意。这样做的真正原因是为了更详细地查找任务。因此,我们在TaskDetail组件中缺少一个数据调用到我们的TaskService,在那里我们要求只获取一个任务。记得我们到TaskDetail的路由是/tasks/:id吗?为了正确调用我们的TaskService,我们需要从路由中提取出 ID 参数,并在调用我们的TaskService时使用它作为参数。如果我们路由到/tasks/13,我们需要使用getTask(13)调用TaskService,并期望得到一个Task

因此,我们有两件事要做:

  1. 从路由中提取出路由参数 ID。

  2. TaskService中添加一个getTask(taskId)方法。

为了成功完成第一个任务,我们可以注入一个叫做ActivatedRoute的东西,并与它的params属性交谈,这是一个 Observable。来自该 Observable 的数据是一个对象,其中一个属性是我们的路由参数:

this.route .params  .subscribe( params  => {
 let id = params['id'];  });  

好吧,这只解决了问题的一半。我们能够以这种方式提取出 ID 参数的值,但我们并没有对它做任何处理。我们也应该进行数据获取。

如果我们添加switchMap语句,那么我们可以获取数据,进行数据调用,并返回数据的结果,如下所示:

@Component({
 template: `
 <div *ngIf="(task$ | async) as task">
 {{ task.name }}
 </div>
 `
})
export class TaskDetailComponent implements OnInit {
 task$:Observable<Task>;

 constructor(private route:ActivatedRoute) {}

 ngOnInit() {
 this.task$ = this.route .params
 .switchMap( params => 
 this.taskService.getTask(+params['id'])
 )
 }
}

最后一步是向TaskService添加getTask方法:

export class TaskService{
 ...
 getTask(id): Observable<Task> {
 return this.http.get(`/tasks/${id}`).map(mapTask);
 }
}

过滤您的数据-使用查询参数

到目前为止,我们一直在处理tasks/:id格式的路由参数。像这样形成的链接告诉我们上下文是任务,并且要到达特定任务,我们需要指定其编号。这是关于缩小到我们感兴趣的特定数据的。查询参数有不同的作用,它们旨在对数据进行排序或缩小数据集的大小:

// for sorting
/tasks/114?sortOrder=ascending

// for narrowing down the data set
/tasks/114?page=3&pageSize=10

查询参数被识别为?字符之后发生的一切,并且由&符号分隔。要获取这些值,我们可以使用ActivatedRoute,就像我们处理路由参数一样,但是我们要查看ActivatedRouter实例上的不同集合:

constructor(private route: ActivatedRoute) {}

getData(){
 this.route.queryParamMap
 .switchMap( data  => { let  pageSize  =  data.get('pageSize'); let  page  =  data.get('page'); return  this._service.getTaskLimited(pageSize,page); })

高级功能

到目前为止,我们已经涵盖了基本的路由,包括路由参数和查询参数。不过,Angular 路由器非常强大,能够做更多的事情,比如:

  • 定义子路由,每个组件都可以有自己的视口

  • 相对导航

  • 命名出口,同一个模板中可以有不同的视口

  • 调试,您可以轻松启用调试,展示基于您的路由列表的路由工作方式

子路由

什么是子路由?子路由是一个概念,我们说一个路由有子路由。我们可以像这样为一个功能编写路由:

{
 path : 'products',
 component : ProductListComponent
},
{
 path : 'products/:id',
 component : ProductsDetail 
},
{
 path : 'products/:id/orders',
 component : ProductsDetailOrders
}

然而,如果我们想要有一个产品容器组件,并且在该组件中,我们想要显示产品列表或产品详细信息会发生什么?对于这种情况,我们希望以不同的方式分组我们的路由。我们已经明确表示Product容器是您应该路由到的父组件。因此,当转到路由/products时,它将是第一个响应者。让我们从设置products路由开始。它应该监听/products URL,并且有ProductsContainerComponent做出响应,如下所示:

{
 path: 'products',
 component : ProductsContainerComponent
}

我们的其他路由可以作为其子路由添加,如下所示:

{
 path: 'products',
 component : ProductsContainerComponent,
 children : [{
 path : '',
 component : ProductListComponent 
 }, {
 path: ':id',
 component : ProductDetailComponent
 }, {
 path : ':id/orders',
 component : ProductsDetailOrders
 }]
}

现在,从组织的角度来看,这可能更有意义,但在技术上有一些区别;ProductsContainer将需要有自己的router-outlet才能工作。因此,到目前为止,我们应用的快速概述如下:

/app . // contains router-outlet
 /products 
 ProductsContainerComponent // contains router outlet
 ProductListComponent
 ProductDetailComponent
 ProductsDetailOrders

这样做的主要原因是我们可以创建一个容器,为其提供一些页眉或页脚信息,并呈现可替换的内容,就像我们可以为应用程序组件的模板做的那样:

// ProductsContainerComponent template
<!-- header -->
<router-outlet></router-outlet>
<!-- footer -->

总之,容器方法的好处如下:

  • 创建子路由意味着我们可以将功能着陆页视为页面视图或视口,因此我们可以定义诸如页眉、页脚和页面的一部分作为可以替换的内容

  • 在定义路由路径时,我们需要写得更少,因为父路由已经被假定

绝对导航与相对导航

有两种导航方式:绝对路由和相对路由。绝对路由是从路由根目录指定其路由,例如/products/2/orders,而相对路由则知道其上下文。因此,相对路由可能看起来像/orders,因为它已经知道自己在/products/2,所以完整的路由将读作/products/2/orders

您可能只使用绝对路径就可以了;但是使用相对路径也有好处:重构变得更容易。想象一下移动一堆组件,突然所有硬编码的路径都是错误的。您可能会认为您应该创建路由的类型化版本,例如routes.ProductList,这样您只需要在一个地方进行更改。这可能是这样,那么您就处于一个良好的状态。然而,如果您不采用这些工作方式,那么相对路由就适合您。因此,让我们看一个示例用法:

this.router.navigate(['../'], { relativeTo:  this.route });

在这里,我们向上走了一级。想象一下,我们在/products。这会把我们带回到/。这里的重要部分是包括第二个参数并指定relativeTo: this.route部分。

命名出口

如果您只是不断地添加它们,那么我们可以在组件模板中有多个出口指令。

<router-outlet></router-outlet>
<router-outlet></router-outlet>
<router-outlet></router-outlet>
<router-outlet></router-outlet>

我们将内容呈现出四次。这并不是我们添加多个出口的真正原因。我们添加多个router-outlet是为了能够给它们取不同的名称。然而,这样做的商业案例是什么呢?想象一下,我们想要显示一个页眉部分和一个正文部分;根据我们所在的路由部分不同,它们会有所不同。它可能看起来像这样:

<router-outlet name="header"></router-outlet>
<router-outlet name="body"></router-outlet>

现在,我们能够在路由时针对特定的router-outlet进行定位。那么我们该如何:

  • 定义应该定位到特定命名出口的路由?

  • 导航到命名出口?

  • 清除命名的出口?

以下代码显示了我们如何设置路由:

{ path:  'tasks', component:  JedisShellComponent,
 children : [{
 path: '',
 component : JediHeaderComponent,
 outlet : 'header'
 },
 {
 path: '',
 component : JediComponent,
 outlet : 'body'
 }] }  

前面的代码显示了我们如何设置一个外壳页面,它被称为外壳,因为它充当了命名出口的外壳。这意味着我们的外壳组件看起来像这样:

static data
<router-outlet name="header"></router-outlet>
<router-outlet name="body"></router-outlet>
some static data after the outlet

我们还设置了两个子路由,分别指向一个命名的出口。想法是当我们路由到/tasks时,TaskHeaderComponent将被渲染到头部出口,TaskComponent将被渲染到主体出口。

还有一种完全不同的使用路由的方式,即作为弹出出口。这意味着我们可以将内容渲染到一个出口,然后再将其移走。为了实现这一点,我们需要设置路由如下:

{
 path : 'info',
 component : PopupComponent,
 outlet : 'popup'
}

这需要与一个命名的出口一起定义,就像这样:

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

首先浏览到一个页面,这个PopupComponent将不可见,但我们可以通过设置一个方法来使其可见,比如这样:

@Component({
 template : `
 <button (click)="openPopup()"></button>
 `
})
export class SomeComponent {
 constructor(private router: Router) {}

 openPopup(){ this.router.navigate([{ outlets: { popup : 'info' }}]) }
}

这里有趣的部分是router.navigate的参数是{ outlets : { <name-of-named-outlet> : <name-of-route> } }

通过这种语法,我们可以看到只要路由正确设置,就可以在其中渲染任何内容。所以,假设路由看起来像这样:

{
 path : 'info',
 component : PopupComponent,
 outlet : 'popup'
},
{
 path : 'error',
 component : ErrorComponent,
 outlet : 'popup'
}

现在,我们有两个可能被渲染到popup出口的候选者。要渲染错误组件,只需写入以下内容:

this.router.navigate([{ outlets: { popup : 'error' }])

还有一件事情我们需要解决,那就是如何移除命名出口的内容。为此,我们需要修改我们的组件如下:

@Component({
 template : `
 <button (click)="openPopup()"></button>
 `
})
export class SomeComponent {
 constructor(private router: Router) {}

 openPopup(){ this.router.navigate([{ outlets: { popup : 'info'} }]) }

 closePopup() { this.router.navigate([{ outlets: { popup: null }}]) }
}

我们添加了closePopup()方法,里面我们要做的是针对我们命名的popup出口并提供一个空参数,就像这样:

this.router.navigate([ outlets: { popup: null } ])

调试

为什么我们要调试路由?嗯,有时路由不会按我们的想法工作;在这种情况下,了解更多关于路由的行为和原因是很有帮助的。要启用调试,您需要提供一个启用调试的配置对象,就像这样:

RouterModule.forRoot(routes,{ enableTracing:  true  })

尝试从我们的起始页面路由到,比如,/products将会是这样:

我们可以看到这里触发了几个事件:

  • NavigationStart:导航开始时

  • RoutesRecognized:解析 URL 并识别 URL

  • 路由配置加载开始:在读取延迟加载配置时触发

  • RouteConfigLoadEnd:路由已经延迟加载完成

  • GuardsCheckStart:评估路由守卫,也就是说,我们能否前往这个路由

  • GuardsCheckEnd:路由守卫检查完成

  • ResolveStart: 尝试在路由到路径之前获取我们需要的数据

  • ResolveEnd: 完成解析它所依赖的数据

  • NavigationCancel: 有人或某物取消了路由

  • NavigationEnd: 完成路由

有很多可能发生的事件。正如您从前面的图像中所看到的,我们的项目列表涵盖的事件比图像显示的更多。这是因为我们没有任何懒加载的模块,因此这些事件不会被触发,而且我们也没有设置任何解析守卫,例如。此外,NavigationCancel只有在某种原因导致路由失败时才会发生。了解触发了哪些事件以及何时触发是很重要的,这样您就会知道代码的哪一部分可能出错。我们将在下一节中仔细研究事件GuardsCheckStartGuardsCheckEnd,以确定您是否有权限访问特定路由。

通过位置策略微调我们生成的 URL

正如您所见,每当浏览器通过routerLink的命令或通过Router对象的 navigate 方法的执行导航到一个路径时,显示在浏览器位置栏中的 URL 符合我们习惯看到的标准化 URL,但实际上是一个本地 URL。从不会向服务器发出调用。URL 显示自然结构的事实是由于 HTML5 历史 API 的pushState方法在幕后执行,并允许导航以透明的方式添加和修改浏览器历史记录。

有两个主要的提供者,都是从LocationStrategy类型继承而来,用于表示和解析浏览器 URL 中的状态:

  • PathLocationStrategy: 这是位置服务默认使用的策略,遵循 HTML5 pushState模式,产生没有哈希碎片的清晰 URL(example.com/foo/bar/baz)。

  • HashLocationStrategy: 此策略利用哈希片段来表示浏览器 URL 中的状态(example.com/#foo/bar/baz)。

无论Location服务默认选择的策略是什么,您都可以通过选择HashLocationStrategy作为首选的LocationStrategy类型,回退到基于旧的哈希导航。

为此,请转到app.module.ts并告诉路由器,从现在开始,每当注入器需要绑定到LocationStrategy类型以表示或解析状态(内部选择PathLocationStrategy),它应该不使用默认类型,而是使用HashLocationStrategy

您只需要在RouterModule.forRoot()方法中提供第二个参数,并确保useHash设置为true

....
@NgModule({
 imports : [
 RouterModule.forRoot(routes, { useHash : true })
 ]
})

使用 AuthGuard 和 CanActivate hook 来保护路由

我们可以使用CanActivate有两种方式:

  • 限制需要登录的数据访问

  • 限制需要具有正确角色的数据访问

因此,这实质上涉及潜在的身份验证和授权。我们需要做的是:

  • 创建一个需要评估您是否有权限的服务

  • 将该服务添加到路由定义中

这只是您创建的任何服务,但它需要实现CanActivate接口。所以,让我们创建它:

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

  canActivate() {
 return this.authService.isAuthenticated();
 }
}

我们所做的是通过声明canActivate()方法来实现CanActivate接口。我们还注入了一个我们假装存在的AuthService实例。关键是canActivate()方法应该在导航应该继续时返回true,在应该停止时返回false

现在,下一步是将此服务添加到路由配置;我们通过添加到canActivate属性保存的列表来实现:

{
 path : 'products',
 component: ProductsShell,
  canActivate: [ AuthGuard ]
}

让我们尝试一下,看看如果我们从canActivate()方法返回truefalse,我们的路由调试会发生什么变化:

GuardsCheckEnd中,我们看到shouldActivate: true属性被发出。这是因为我们的canActivate方法当前返回true,也就是说,我们允许路由发生。

让我们看看如果我们将canActivate更改为返回false会发生什么:

在这里,我们可以看到在GuardsCheckEnd事件中,shouldActivate现在的值为false。我们还可以看到发出了NavigationCancel事件。最终结果是,基于canActivate()方法返回false,我们不被允许改变路由。现在轮到您实现一个真正的身份验证/授权方法并使其真正起作用。

Resolve - 在路由之前获取和解析数据

使用此钩子的原因是,我们可以延迟路由发生,直到我们获取了所有必要的数据。但是,您不应该有任何长时间运行的操作。更真实的情况是,您已经导航到了一个产品路由,比如/products/114,并且想要在数据库中查找该产品并将其提供给路由。

您需要以下内容来实现这一点:

  • 实现Resolve<T>接口

  • resolve()方法返回一个Promise

  • 将服务设置为模块的提供者

  • 在提供数据的路由的 resolve 属性中设置服务

让我们实现这个服务:

@Injectable()
export class ProductResolver implement Resolve<Product> {
 constructor(
 private http:Http, 
 private service: DataService,
 private router:Router
 ) {}

 resolve(route:  ActivatedRouteSnapshot) {
    let id = route.paramMap.get('id');
    return this.service.getProduct( id ).then( data => {
 if(data) { 
 return data; 
 }
 else { 
 this.router.navigate(['/products']); 
 }
 }, error => { this.router.navigate(['/errorPage']) });
 }
}

// product.service.ts
export class DataService {
 getProduct(id) {
 return http.get(`/products/${id}`)
 .map( r => r.json )
 .map(mapProduct)
 .toPromise()
 }
}

在这一点上,我们已经实现了Resolve<T>接口,并确保从resolve()方法返回一个Promise。我们还有一些逻辑,如果我们得到的数据不是我们期望的,或者发生错误,我们将重定向用户。

作为下一步,我们需要将服务添加到我们模块的providers关键字中:

@NgModule({
 ...
  providers: [ProductResolver]
 ...
})

对于最后一步,我们需要将服务添加到路由中:

{
 path: 'products/:id',
  resolve: [ProductResolver],
 component: ProductDetail
}

CanDeactivate - 处理取消和保存

好的,我们有以下情况:用户在一个页面上,他们填写了很多数据,然后决定按下一个导航链接离开页面。在这一点上,作为开发者,你想建立以下内容:

  • 如果用户填写了所有数据,他们应该继续导航

  • 如果用户没有填写所有数据,他们应该有离开页面的选项,或者留下来继续填写数据

为了支持这些情景,我们需要做以下事情:

  1. 创建一个实现CanDeactivate接口的服务。

  2. 将目标组件注入到服务中。

  3. 将该服务设置为模块的提供者。

  4. 在路由中将服务设置为canDeactivate响应器。

  5. 使目标组件可注入,并将其设置为模块的提供者。

  6. 编写逻辑来处理所有字段都填写的情况 - 如果字段缺失,则保持路由,如果字段缺失,则显示一个确认消息,让用户决定是否继续路由或不继续。

从服务开始,它应该是这样的:

@Injectable()
export class CanDeactivateService implements CanDeactivate {
 constructor(private component: ProductDetailComponent) {}

 canDeactivate(): boolean | Promise<boolean> {
 if( component.allFieldsAreFilledIn() ) {
 return true;
 }

 return this.showConfirm('Are you sure you want to navigate away,
 you will loose data');
 }

 showConfirm() {
 return new Promise(resolve => resolve( confirm(message) ))
 }
}

值得强调的是,我们如何在canDeactivate方法中定义逻辑,使其返回类型要么是Boolean,要么是Promise<boolean>。这使我们有自由在所有有效字段都填写的情况下提前终止方法。如果没有,我们向用户显示一个确认消息,直到用户决定该做什么。

第二步是告诉模块关于这个服务:

@NgModule({
  providers: [CanDeactivateService]
})

现在,要改变路由:

{
 path : 'products/:id',
 component : ProductDetailComponent,
 canDeactivate : [ CanDeactivateService ]
}

接下来,我们要做一些我们通常不做的事情,即将组件设置为可注入的;这是必要的,这样它才能被注入到服务中:

@Component({})
@Injectable()
export class ProductDetailComponent {}

这意味着我们需要将组件作为模块中的提供者添加:

@NgModule({
 providers: [
 CanDeactivateService, ProductDetailComponent
 ]
})

异步路由 - 提高响应时间

最终,您的应用程序将会变得越来越庞大,您放入其中的数据量也会增长。这样做的结果是,应用程序在初始启动时需要很长时间,或者应用程序的某些部分需要很长时间才能启动。有一些方法可以解决这个问题,比如懒加载和预加载。

懒加载

懒加载意味着我们不会一开始就加载整个应用程序。我们的应用程序的部分可以被隔离成只有在需要时才加载的块。今天,这主要集中在路由上,这意味着如果您请求一个以前没有访问过的特定路由,那么该模块及其所有构造将被加载。这不是默认情况下存在的东西,但是您可以很容易地设置它。

让我们看看一个现有模块及其路由,看看我们如何将其变成一个懒加载模块。我们将不得不在以下地方进行更改:

  • 我们特性模块的路由列表

  • 在我们应用程序的路由中添加一个路由条目,使用特定的懒加载语法

  • 删除其他模块中对特性模块的所有引用

首先,让我们快速查看一下我们特性模块在更改之前的路由列表:

// app/lazy/routes.ts
let routes = [{
 path : 'lazy',
 component : LazyComponent
}]

// app/lazy/lazy.module.ts
@NgModule({
 imports: [RouterModule.forChild(routes)]
})
export class LazyModule {}

我们的第一项任务是将第一个路由条目的路径从 lazy 更改为'',一个空字符串。听起来有点违反直觉,但有一个解释。

我们要做的第二件事是纠正第一件事;我们需要在我们的应用程序模块路由中添加一个懒加载路由条目,就像这样:

// app/routes.ts
let routes = [{
 path:  'lazy', loadChildren:  'app/lazy/lazy.module#LazyModule' }];

正如您所看到的,我们添加了loadChildren属性,该属性期望一个字符串作为值。这个字符串值应该指向模块的位置,因此它看起来像<从根目录到模块的路径>#<模块类名>

最后一步是删除其他模块中对该模块的所有引用,原因很自然:如果您还没有导航到/lazy,那么服务或组件等实际上还不存在,因为它的捆绑包还没有加载到应用程序中。

最后,让我们看看这在调试模式下是什么样子。第一张图片将展示在我们导航到懒加载模块之前的样子:

在这里,我们有我们项目设置生成的正常捆绑包。现在让我们导航到我们的懒加载路由:

我们可以看到一个名为5.chunk.js的捆绑包已经被添加,它包含了我们新加载的模块及其所有构造。

不过,需要小心的是,不要在想要在其他地方使用的延迟加载模块中放置构造。相反,你可以让你的lazy模块依赖于其他模块中的服务和构造,只要它们不是延迟加载的。因此,一个很好的做法是尽可能多地将模块延迟加载,但共享功能不能延迟加载,出于上述原因。

CanLoad - 除非用户有权限,否则不要延迟加载

延迟加载是一个很棒的功能,可以通过确保应用程序只启动绝对需要的捆绑包来大大减少加载时间。然而,即使你确保大多数模块都是延迟加载的,你需要更进一步,特别是如果你的应用程序有任何身份验证或授权机制。

考虑以下情况,假设你的多个模块需要用户进行身份验证或具有管理员角色。如果用户在这些区域不被允许,那么当用户路由到它们的路径时加载这些模块是没有意义的。为了解决这种情况,我们可以使用一个叫做CanLoad的守卫。CanLoad确保我们首先验证是否根据条件延迟加载某个模块是有意义的。你需要做以下事情来使用它:

  1. 在服务中实现CanLoad接口和canLoad()方法。

  2. 将前述服务添加到路由的CanLoad属性中。

以下创建了一个实现CanLoad接口的服务:

@Injectable()
export class CanLoadService implements CanLoad {
  canLoad(route: Route) {
 // replace this to check if user is authenticated/authorized
 return false;
 }
}

从代码中可以看出,canLoad()方法返回一个布尔值。在这种情况下,我们让它返回false,这意味着模块不会被加载。

我们需要做的第二件事是更新路由以使用这个服务作为canLoad守卫:

{
 path:  'lazy', loadChildren :  'app/lazy/lazy.module#LazyModule', canLoad: [CanLoadService**]** }  

如果我们尝试浏览到localhost:4200/lazy,我们无法前往,因为我们的canLoad通过返回false告诉我们不能。查看控制台,我们还看到以下内容:

在这里,它说由于守卫,无法加载子级,所以守卫起作用。

注意当你更新CanLoadServicecanLoad()方法来返回true时,一切都像应该的那样正常加载。

不要忘记将CanLoadService添加到根模块的 providers 数组中。

预加载

到目前为止,我们一直在讨论急加载和懒加载。在这种情况下,急加载意味着我们一次性加载整个应用程序。懒加载是指我们将某些模块标识为只在需要时加载的模块,也就是说,它们是懒加载的。然而,在这两者之间还有一些东西:预加载模块。但是,为什么我们需要介于两者之间的东西呢?嗯,想象一下,我们可以非常肯定地知道,普通用户在登录后 30 秒内会想要访问产品模块。将产品模块标记为应该懒加载的模块是有道理的。如果它可以在登录后立即在后台加载,那么当用户导航到它时,它就已经准备好了。这正是预加载为我们做的事情。

我们通过发出以下命令来启用预加载:

@NgModule({
 imports: [
 RouterModule.forRoot(routes, {
      preloadingStrategy:  PreloadAllModules
 })
 ]
})

PreloadAllModules值预加载每个懒加载的路由,除了那些由canLoad守卫保护的路由。这是有道理的:canLoad只有在我们经过身份验证/授权或者基于我们设置的其他条件时才加载。

因此,如果我们有一堆模块都被设置为懒加载,比如产品、管理员、类别等等,所有这些模块都会根据PreloadAllModules在初始启动后立即加载。这在桌面上可能已经足够了。然而,如果你使用的是 3G 等移动连接,这可能会太重了。在这一点上,我们需要更好、更精细的控制。我们可以实现自己的自定义策略来做到这一点。我们需要做以下几件事来实现这一点:

  1. 创建一个实现PreloadingStrategypreload方法的服务。

  2. 如果应该预加载,preload()方法必须调用load()方法,否则应该返回一个空的 Observable。

  3. 通过在路由上使用数据属性或使用服务来定义路由是否应该预加载。

  4. 将创建策略服务设置为preloadingStrategy的值。

首先,定义我们的服务,我们可以这样创建它:

@Injectable() export  class  PreloadingStrategyService  implements PreloadingStrategy {  preload(route:  Route, load: () =>  Observable<any>):  Observable<any> { if(route.data.preload) {
      **return** load**();** } else { return Observable.of(null**);** }
 }
}

我们可以看到,如果我们的route.data包含预加载布尔值,我们会调用 load 方法。

现在,为了正确设置路由:

{
 path:  'anotherlazy', loadChildren:  'app/anotherlazy/anotherlazy.module#AnotherLazyModule', data: { preload: true } }  

数据属性已设置为包含我们的preload属性的对象。

现在是最后一步。让我们让RouterModule.forRoot()意识到这个服务的存在:

@NgModule({
 imports: [
 RouterModule.forRoot(routes, {
      preloadingStrategy: PreloadingStrategyService 
 })
 ]
})

简而言之,这是一种非常有效的方式,可以确保用户在不陷入急切加载或等待懒加载的情况下获得最佳体验。

总结

我们现在已经揭示了 Angular 路由器的强大功能,希望您喜欢探索这个库的复杂性。在路由器模块中,绝对闪亮的一点是我们可以通过简单而强大的实现涵盖大量选项和场景。

我们已经学习了设置路由和处理不同类型参数的基础知识。我们还学习了更高级的功能,比如子路由。此外,我们还学习了如何保护我们的路由免受未经授权的访问。最后,我们展示了异步路由的全部功能,以及如何通过延迟加载和预加载来真正提高响应时间。

在下一章中,我们将加强我们的任务编辑组件,展示 Angular 中 Web 表单的基本原理以及使用表单控件获取用户输入的最佳策略。

第十章:Angular 中的表单

使用表单通常是我们从网络收集数据的方式,以便稍后持久化。我们对表单体验有期望,比如:

  • 轻松声明不同类型的输入字段

  • 设置不同类型的验证并向用户显示任何验证错误

  • 支持不同的策略来阻止提交,如果表单包含错误

处理表单有两种方法:模板驱动表单和响应式表单。没有一种方法被认为比另一种更好;你只需要选择最适合你情况的方法。两种方法之间的主要区别是谁负责什么:

  • 在模板驱动的方法中,模板负责创建元素、表单,并设置验证规则,同步是通过双向数据绑定实现的

  • 在响应式方法中,Component类负责创建表单、其元素,并设置验证。

在本章中,我们将:

  • 了解模板驱动表单

  • 绑定数据模型和表单和输入控件的接口类型

  • 使用响应式表单方法设计表单

  • 深入了解输入验证的替代方法

  • 构建我们自己的自定义验证器

模板驱动表单

模板驱动表单是使用 Angular 设置表单的两种不同方式之一。这种方法完全是在模板中进行设置,非常类似于 AngularJS 中使用的方法。因此,如果您有 AngularJS 的背景,这种方法对您来说将非常熟悉。

将简单表单转换为模板驱动表单

我们定义了以下表单,包括一个form标签,两个input字段和一个button,如下所示:

<form>
 <input id="name" name="name" placeholder="first name" required>
 <input id="surname" name="surname" placeholder="surname" required>
 <button>Save</button>
</form>

在这里,我们明显有两个需要的input字段,因此input元素有required属性。我们还有一个保存按钮。我们对这样一个表单的要求是,在所有必填字段填写完毕之前,不应提交其数据。为了实现这一点,我们需要做两件事:

  • 将输入字段的值保存到一个对象中,使用[(ngModel)]

  • 只有在没有错误时才提交表单,使用ngForm指令

现在我们将表单更改为如下所示:

<form #formPerson="ngForm">
 <input [(ngModel)]="person.firstName"  id="name"  name="name"
  placeholder="first name"  required>
 <input [(ngModel)]="person.surname"  id="surname"  name="surname"
  placeholder="surname"  required>
 <button (click)="submit()" *ngIf="formPerson.form.valid">Save</button> </form>

让我们谈谈我们所做的更改。首先,我们有以下代码片段:

<form (ngSubmit)="save()" #formPerson="ngForm">

我们创建了一个名为formPerson的视图引用,其值为ngForm。这意味着我们有一个对表单的引用。表单视图引用现在包含了许多有趣的属性,这些属性将帮助我们确定表单是否准备好提交。

至于我们所做的第二个改变,我们将输入数据连接到了ngModel

<input [(ngModel)]="person.name"  id="name"  name="name"
  placeholder="first name"  required>

ngModel允许我们对属性创建双向绑定。它被称为香蕉在盒子里,这实际上是一个记忆规则,让你能够记住如何输入它。我们分两步创建它。首先是ngModel,然后我们添加香蕉,括号,就像这样:(ngModel)。之后我们把香蕉放在盒子里。方括号将作为我们的盒子,这意味着我们最终有了[(ngModel)]。记住,它被称为香蕉在盒子里,而不是盒子在香蕉里

在这里,我们通过使用ngModel指令,确保了输入的值被保存到person.name

最后,我们使用*ngIf指令装饰了我们的按钮元素,就像这样:

<button *ngIf="formHero.form.valid">Save</button>

我们使用了*ngIf指令来隐藏按钮,如果表单被证明是无效的。正如你所看到的,我们正在利用我们的表单视图引用及其有效属性。如果表单有效,则显示按钮;否则,隐藏它。

这是设置模板驱动表单的基础知识。让我们通过查看一下来深入了解一下:

  • 正在呈现的 CSS 是什么,这样我们就可以根据表单状态适当地进行呈现

  • 如何检测输入元素上的特定错误

输入字段错误-从 CSS 的角度来看

根据输入元素所处的状态,会分配不同的 CSS 类。让我们看看一个具有必填属性的输入元素,在我们输入任何数据之前。我们期望它告诉我们有什么地方出错了,因为input字段为空,并且我们已经为其添加了required属性:

<input id="name" name="name" placeholder="first name" required ng-reflect-required ng-reflect-name="name" ng-reflect-model class="ng-untouched ng-pristine ng-invalid">

我们可以看到已设置以下类:

  • ng-untouched,这意味着还没有人尝试按提交按钮

  • ng-pristine,这基本上意味着尚未尝试向该字段输入数据。如果您输入一个字符并删除该字符,则它将被设置为false

  • ng-invalid,这意味着验证器正在反应并指出有错误

在字段中输入一个字符,我们看到ng-pristine消失了。在两个字段中输入一些字符并点击提交,我们看到ng-untouched变成了ng-touched。这也导致ng-invalid变成了ng-valid

好的,现在我们更好地了解了 CSS 在什么时候会变成什么样,并且可以适当地为我们的组件设置样式。

检测具有命名引用的输入字段上的错误

到目前为止,当我们想知道我们的表单是否有效时,我们一直在查看表单引用。我们可以做得更好,我们可以检测特定输入控件是否有错误。输入控件可能有多个验证器,这意味着我们可能有多个验证错误要显示。那么我们如何检测呢?要完成这个任务,需要采取一些步骤:

我们需要:

  1. 为每个输入元素创建一个视图引用,并为其分配值ngModel

  2. 给每个元素添加一个name属性。

让我们更新我们的表单代码,并根据前面的步骤添加视图引用和name属性:

<form #formPerson="ngForm">
 <input #firstName="ngModel" [(ngModel)]="person.name"  id="name"
  name="name"  placeholder="first name"  required>
 <input #surName="ngModel" [(ngModel)]="person.surname"  id="surname"
  name="surname"  placeholder="surname"  required>
 <button *ngIf="formPerson.form.valid">Save</button> </form>

一旦我们完成了前期工作,就是时候谈谈我们可以检测到哪些错误了。感兴趣的错误有两种类型:

  • 一般错误,即指示输入控件有问题,但不指定具体问题是什么

  • 特定错误,将指示确切的错误类型,例如,值太短

让我们从一般错误开始:

<input #firstName="ngModel" [(ngModel)]="person.name"  id="name"
  name="name"  placeholder="first name"  required> {{ firstName.valid }} // an empty field sets this to false

我们使用我们的视图引用firstName并查询其 valid 属性,该属性指示是否存在错误。

现在来看看其他更详细的错误。要检测更详细的错误,我们使用视图引用上的 errors 对象,并使用 JSON 管道输出整个对象:

{{ firstName.errors | json }}  // outputs { required: true }

这意味着我们突然可以知道是否设置了特定错误,因此我们可以决定基于特定错误的存在来显示条件文本,就像这样:

<div *ngIf="firstName.errors && firstName.errors.required">
 First name is a required field
</div>

其他特定错误将填充 errors 对象,你需要做的唯一的事情就是知道错误的名称。如果有疑问,可以使用 JSON 管道输出 errors 对象,以找出特定验证器的验证错误名称以及相应的验证错误值。

改进表单

到目前为止,我们已经涵盖了了解表单何时出错以及如何根据特定错误显示文本的基本机制。让我们通过一些更多的例子来扩展这些知识。首先,我们将向我们的输入字段添加更多的验证类型:

<input minlength="3" required #name="ngModel" name="name">
{{ name.errors | json }}

现在我们已经将minlength添加为我们元素的验证规则,除了现有的 required 规则。Required 是优先错误,所以它会首先显示。如果我们输入一些字符,那么 required 错误就会消失。现在它应该显示以下内容:

{"minlength": { "requiredLength": 3, "actualLength": 1 } }

就像 required 错误一样,我们可以仅为此错误显示错误文本,如下所示:

<div *ngIf="name.errors && name.errors.minlength" >
 Name value is too short
</div>

已经为我们编写了一些验证规则:

  • required,要求值不能为空

  • requiredTrue,特别要求值为true

  • minlength,表示值需要具有一定的最小长度

  • maxlength,表示值不能超过一定长度

  • pattern,强制值遵循RegEx模式

  • nullValidator,检查值不为空

  • compose,如果您想将多个验证器组合成一个,验证规则是取所有提供的验证器的并集的结果

尝试看看这些是否符合您的情况。您可能会发现一些验证规则缺失。如果是这种情况,那么可以通过创建自定义验证器来解决。我们将在本章后面介绍如何构建自定义验证器规则。

在正确的时间显示错误

到目前为止,我们的表单在至少存在一个错误时不显示提交按钮。这里有一些替代方法。有时,当按钮不存在或显示为禁用时,可能会被认为 UI 出现了问题。这与您在其他地方构建 UI 的方式有关。一致的方法更好。因此,我们可以控制表单如何提交的不同方式。

以下是主要方法:

  • 当表单中没有错误时显示提交按钮,我们已经知道如何做到这一点。这种方法可能看起来像我们忘记正确设计表单,因为当表单出现错误时,按钮似乎完全消失了。

  • 在表单存在错误时禁用提交按钮。如果伴随着显示验证错误,这样做会很好,以避免任何误解为什么它被禁用。

  • 只有当没有错误时才启用提交调用,这里的主要区别是提交按钮是可点击的,但提交操作不会发生。这个版本的缺点是让用户感觉好像什么都没有发生。这种方法需要配合显示阻止表单提交的验证错误。

这是你会编写第一种方法的方式。在这里,如果表单无效,我们会隐藏按钮:

<button *ngIf="form.valid">Save</button>

第二种方法涉及将按钮设置为禁用状态。我们可以通过绑定到disabled属性来实现:

<button [disabled]="form.valid">Save</button>

第三种和最后一种方法是创建一个布尔条件,需要返回true才能执行其他语句:

<button (ngSubmit)="form.valid && submit()">Save</button>

响应式表单

对于响应式表单,我们有一种程序化的方法来创建表单元素并设置验证。我们在Component类中设置所有内容,只需在模板中指出我们创建的结构。

在这种方法中涉及的关键类包括:

  • FormGroup,它是一个包含一到多个表单控件的分组

  • FormControl,表示一个输入元素

AbstractControl

FormGroupFormControl都继承自AbstractControl,其中包含许多有趣的属性,我们可以查看并根据某个状态以不同的方式渲染 UI。例如,您可能希望在从未与表单交互过和已经交互过的表单之间在 UI 上有所区别。还有可能想知道某个控件是否已经被交互过,以了解哪些值将成为更新的一部分。可以想象,有很多情况下了解特定状态是很有趣的。

以下列表包含所有可能的状态:

  • controls,一个通过构造函数new FormGroup(group)添加的FormControl实例列表。

  • value,表示键值对的字典。键是你在创建时给FormControl的引用,值是你在输入控件中输入的内容{ :'<reference>', <value entered> }

  • dirty,一旦我们在表单中输入了内容,它就被认为是脏的。

  • disabled,表单可以被禁用。

  • pristine,一个没有任何控件被交互的表单。

  • status,一个表示它是否有效的字符串表示,如果无效则显示无效。

  • touched,提交按钮至少被按下一次。

  • untouched,提交按钮尚未被按下。

  • 启用,布尔值,表示表单是否启用。

  • 有效,如果没有错误,这个是true

  • 无效,与有效相反。

程序化和动态的方法

我们对事情的处理方式是程序化的,我们有两种可能的方法:

  • 我们可以创建具有 N 个元素的表单。这意味着我们可以生成完全动态的表单,包括输入控件的种类和数量,以及应该使用的表单。一个典型的例子是创建一个内容管理系统,其中页面和它们的内容完全可以从配置文件或数据库中配置。

  • 我们可以创建深层结构。通常我们有一个表单和其中的 N 个元素,但是响应式表单允许我们在表单中嵌套表单。

注意这里FormGroup被称为组而不是Form。这是因为你应该把它看作只是一种分组,而不一定是唯一的。你可以很容易地有这样的结构:

  • 人:FormGroup

  • 姓名:FormControl

  • 姓氏:FormControl

  • 年龄:FormControl

  • 地址:FormGroup

  • 城市:FormControl

  • 国家:FormControl

这里我们有一个Person的表示,我们可以看到我们想要单独处理这个人的地址输入,因此有了这种层次结构。

将表单转换为动态表单

FormGroup是由许多表单控件组成的结构。要创建这样的结构,我们需要做以下事情:

  1. 导入响应式Forms模块。

  2. 通过代码实例化尽可能多的FormControls

  3. 将控件放在一个字典中。

  4. 将字典分配为FormGroup的输入。

  5. 将我们的Form组实例与[formGroup]指令关联。

  6. 将每个FormControl实例与[formControlName]指令关联。

第一步是导入模块:

@NgModule({
 imports: [ReactiveFormsModule]
})

第二步是创建表单控件。让我们创建两个不同的控件,一个带有验证,一个没有:

const control = new FormControl('some value');
const control2 = new FormControl('other value', Validators.required);

第三步是为此创建一个字典:

const group = {};
group['ctrl1'] = control;
group['ctrl2'] = control2;

第四步是将组分配给formGroup实例:

const formGroup = new FormGroup(group);

你的完整代码应该看起来像这样:

import { FormControl, FormGroup } from  '@angular/forms'; import { Component, OnInit } from  '@angular/core'; 
@Component({
  selector:  'dynamic', template: ` dynamic
 <div  [formGroup]="form">
 dynamic <input  [formControl]="group['ctrl1']"  placeholder="name"> </div>`
})
export  class  DynamicComponent  implements  OnInit { form:FormGroup; group; constructor() { this.group  = {}; this.group['ctrl1'] =  new  FormControl('start value'); this.form  =  new  FormGroup(this.group); }

 ngOnInit() { } }  

你的表单 UI 应该看起来像这样。你可以看到,你的起始值被设置为输入控件:

添加带有验证规则的控件

让我们给一个表单控件添加一个验证器:

this.group['ctrl2'] = new FormControl('',Validators.required)

如果你调查一下这个新添加的表单的标记,你会发现它的 CSS 类确实被设置为ng-invalid,因为它的值为空。

接下来的紧要问题是,我如何引用单个元素,以便知道它们可能具有或不具有哪些错误?答案很简单,在您的表单成员下,类型为FormGroup,有一个包含控件的控件字典。其中一个这些控件就像模板表单中的视图引用一样工作:

ctrl2 valid {{ form.controls['ctrl2'].valid }} {{ form.controls['ctrl2'].errors  |  json }}

如前面的代码片段中所示,我们可以通过form.controls['key']引用单个控件。它具有 valid 和 errors 属性,因此我们可以显示单个错误,就像这样:

<div *ngIf="form.controls['ctrl2'].errors.required">This field is required</div>

重构 - 使代码更加动态

到目前为止,我们已经了解了FormGroupFormControl以及相关指令的基本机制,但是我们的代码看起来非常静态,让我们来解决这个问题。我们需要有一种数据集,通过循环创建我们的Form控件:

this.questions  = [{ Question :  'What is Supermans real name', Key :  '1' },{
 Question :  'Who is Lukes father', Key :  '2' }];

this.questionGroup  = {}; this.questions.forEach( qa  => { this.questionGroup[qa.Key] =  new  FormControl('',Validators.required) });

this.dynamicForm  =  new  FormGroup( this.questionGroup );

现在来定义 UI。我们有一个问题列表,我们使用*ngFor来显示:

<form (ngSubmit)="submit()"  [formGroup]="dynamicForm"> <div  *ngFor="let q of questions"> {{ q.Question }} <input  [formControl]="questionGroup[q.Key]"  placeholder="fill in answer"> </div>
 <button>Save</button>
</form>

我们遍历问题数组,并为[formControl]指令分配适当的控件。从我们的问题实例中,我们还能够输出问题本身。这看起来更加动态。

现在我们只剩下一步,那就是访问用户实际填写的值:

submit() {
 console.log( this.dynamicForm.value ) // { "1" : "", "2" : "Darth" }
}

这给了我们一个控件引用的字典,以及用户在按下提交按钮时输入的任何值。

更新我们的组件表单模型 - 使用 setValue 和 patchValue

首先,让我们稍微回顾一下如何以编程方式创建表单。我们过去使用字典变量并将其传递给FormGroup构造函数,但我们也可以跳过该变量并在内联中定义字典,就像以下代码中一样:

const form = new FormGroup({
 name: new FormControl(''),
 surname: new FormControl(''),
 age: new FormControl 
})

要更改表单中的任何值,我们可以使用两种方法之一:

  • setValue(),它将替换所有值

  • patchValue(),它只会更新提到的控件

setValue

使用此方法完全替换所有值。只要提到表单创建时的所有值,那么就没问题,就像这样:

form.setValue({
 name: 'chris',
 surname: 'noring',
 age: 37
})

然而,如果您忘记了一个字段,您将收到一个错误,指示您必须为所有字段指定一个值:

form.setValue({
 name: 'chris',
 surname: 'noring'
})

如果您只想进行部分更新,那么patchValue()函数就是为您准备的。

patchValue

使用patchValue()就像输入以下内容一样简单:

form.patchValue({
 name: 'chris',
 surname: 'noring'
})

例如,如果在调用patchValue()之前的值如下:

{
 name: 'christoffer',
 surname: 'n',
 age: 36
}

然后应用form.patchValue(),之前定义的,将导致生成的表单包含以下内容:

{
 name: 'chris',
 surname: 'noring',
 age: 36
}

仔细检查后,我们可以看到姓和名已经更新,但年龄属性保持不变。

清理我们的表单创建并引入 FormBuilder

到目前为止,我们一直是这样创建我们的表单的:

const form = new FormGroup({
 name: new FormControl(''),
 surname: new FormControl(''),
 age: new FormControl,
 address: new FormGroup({
 city: 'London',
 country: 'UK'
 }) 
})

然而,这构成了很多噪音。我们可以使用一个叫做FormBuilder的结构来消除很多噪音。要使用FormBuilder,我们需要执行以下操作:

  1. @angular/forms导入它。

  2. 将它注入到构造函数中。

  3. 使用实例并在FormBuilder实例上调用 group 函数。

让我们在以下代码片段中展示这一点:

import { FormBuilder } from '@angular/forms'

@Component({
})
export class FormComponent {
 formGroup: FormGroup;
 constructor(private formBuilder: FormBuilder) {
 this.formGroup = this.formBuilder.group({
 name :'',
 surname :'',
 age: 0,
 address : this.formBuilder.group({
 city: 'London',
 country : 'UK'
 })
 });
 }
}

这看起来更容易阅读,我们不必明确处理FormGroupFormControl数据类型,尽管这是隐式创建的。

有三种不同的方式来为我们的元素指定值:

  • elementName:'',这里默认值被设置为原始值

  • elementName:{value:'',disabled:false},在这里我们将elementName分配给整个对象,对象中的 value 属性是默认值将变为的值

  • elementName:['默认值',<可选验证器>],在这里我们为它分配一个完整的数组,数组中的第一项是默认值,第二到第 N 个值是验证器

以下是使用所有三种方法的代码的样子:

this.dynamicForm2  =  this.formBuilder.group({
 // set to a primitive fullname: 'chris'**,
** // setting a default value age: { value : 37, disabled: true **},** // complex type 'address' address : this.formBuilder.group({
 // default value + x number of validators
    **city: ['', Validators.required, Validators.minLength],** 
    **country: [''] // default value, no validators**
 })  });

在这里,我们在前面的后备代码中呈现了提到的字段。正如您所看到的,组对象中的键名称对应于标记中的formControlName属性:

<form  (ngSubmit)="submit(dynamicForm2)"  [formGroup]="dynamicForm2"> <input  formControlName="fullname"> <input  formControlName="age"> <div formGroupName='address'>
 <input **formControlName="city"**>
 <input f**ormControlName="country"**>
 </div> <button>Save</button> </form>

但是如何显示特定的错误呢?这很容易,看起来像这样:

<div  *ngIf="dynamicForm2.get('address').hasError('required')">

请注意,我们如何通过类dynamicForm2的属性名称引用表单,我们调用get()方法并指定键作为参数,最后,我们调用hasError并要求特定的错误。在这种特殊情况下,地址属性在代码中被定义为由城市和国家组成。像这样指定错误只会告诉我们城市或国家中有一个错误,或者两者都有错误。

构建自定义验证器

有时默认验证器可能无法涵盖应用程序中可能出现的所有情况。幸运的是,编写自定义验证器非常容易。

自定义验证器只是一个需要返回指定错误对象或 null 的函数。Null 表示我们没有错误。

开始定义这样一个函数很容易:

import { AbstractControl, ValidatorFn } from  '@angular/forms'; export  function  minValueValidator(compareToThisValue:  number):  ValidatorFn {  return (control:  AbstractControl): {[key:  string]:  any} => { const  lessThan  =  parseInt( control.value ) <  compareToThisValue; return  lessThan  ? {'minValue'</span>: {value:  control.value}} :  null; };
}

在这种情况下,我们正在构建一个minValue验证器。外部函数接受我们将要比较的参数。我们返回一个测试控件值与我们比较值的内部函数。如果我们的条件为true,我们会引发一个错误,其中我们返回一个错误结构{ 'minValue' : { value : control.value } },或者如果为false,我们返回 null。

要使用这个新的验证器,我们只需要在我们的组件文件中导入它并输入以下内容:

formBuilder.group({
 age : [0, minValueValidator(18)]
})

要在模板中显示错误消息,如果出现此错误,我们只需写入:

<div *ngIf="form.get('age').hasError('minValue')">
 You must be at least 18
</div>

观察状态变化和响应

到目前为止,我们已经看到了如何使用FormBuilder以编程方式创建表单,以及如何在代码中指定所有字段及其验证。我们还没有真正讨论为什么响应式表单被称为reactive。事实是,当表单中的输入字段发生更改时,我们可以监听并做出相应的反应。适当的反应可能是禁用/启用控件,提供视觉提示或其他操作。你明白了。

这是如何实现的呢?这是通过我们声明的字段与它们连接的两个可观察对象statusChangesvalueChanges的事实而实现的。通过订阅它们,我们能够监听更改并进行前面段落中提到的建议更改。

一个有趣的案例,用于演示我们如何观察状态变化的情况是登录。在登录场景中,我们希望用户输入他们的用户名和密码,然后按下按钮。在这种情况下,我们应该能够支持用户:

  • 如果输入的用户名有问题,可能为空或以不允许的方式输入,显示提示

  • 如果没有输入所有必填字段,则禁用登录按钮。

如果用户名没有正确构造,我们选择显示提示。除非用户已经开始输入值,我们不想显示提示。

让我们分步进行。首先构建我们的组件,如下所示:

@Component({
 template: `
 <div class="form-group" [formGroup]="loginForm">
 <input type="text"
 class="form-control"
 placeholder="Your username">
 <p *ngIf="showUsernameHint"class="help-block">
 That does not look like a proper username
 </p>
 </div>
 `
})
export class LoginComponent {
 loginForm: FormGroup;
 notValidCredentials: boolean = false;
 showUsernameHint: boolean = false;

 constructor(
 formBuilder: FormBuilder,
 private router: Router
 ) {
 this.loginForm = formBuilder.group({
 username: ['', Validators.compose([
 Validators.required,
 Validators.email])],
 password: ['', Validators.required]
 });
 }
}

在这里,我们设置了一个具有两个输入字段的表单,一个username字段和一个password字段。我们还声明了这两个字段是必填的,通过我们设置的验证规则的方式。下一步是设置对用户名字段的订阅,以便我们可以收到有关其更改的通知。需要进行的更改已用粗体标出:

@Component({
 template : `
 <div class="form-group">
 <input type="text"
 class="form-control"
 placeholder="Your username"
           [formControlName]="username">
 <p *ngIf="showUsernameHint"class="help-block">
 That does not look like a proper username
 </p>
 </div>`
})
export class LoginComponent {
 loginForm: FormGroup;
 notValidCredentials: boolean = false;
 showUsernameHint: boolean = false;

 constructor(
 formBuilder: FormBuilder,
 private router: Router
 ) {
 this.loginForm = formBuilder.group({
 username: ['', Validators.compose([
 Validators.required,
 Validators.email])],
 password: ['', Validators.required]
 });

    const username:AbstractControl = this.loginForm.get('username');
 username.valueChanges.subscribe(value => {
 this.showUsernameHint = (username.dirty && 
 value.indexOf('@') < 0);
 });
 }
}

我们可以看到,我们分两步来做这件事。首先,我们通过向loginForm请求来创建一个对用户名字段的引用,如:this.loginForm.controls['username']。然后,我们通过调用username.subscribe(...)来设置对表单控件引用username:FormControl的订阅。在.subscribe()内部,我们评估是否将this.showUsernameHint变量设置为truefalse。逻辑是,如果缺少@字符并且用户已经开始输入,则显示视觉提示。将提示设置为true将触发模板显示提示文本,如下所示:

<p *ngIf="showUsernameHint"class="help-block">
 That does not look like a proper username
</p>

当然,创建登录组件还有更多内容,比如将用户名和密码发送到端点并将用户重定向到适当的页面等,但这段代码展示了响应式的特性。希望这清楚地传达了如何利用表单的响应式特性并做出相应的响应。

总结

在本节中,我们已经了解到 Angular 为创建表单提供了两种不同的方式,即模板驱动和响应式表单,并且不能说其中任何一种方法比另一种更好。我们还介绍了不同类型的验证存在,并且现在知道如何创建自己的验证。

在下一章中,我们将看看如何利用 Angular Material 框架来美化我们的应用程序,使其看起来更加美观。Angular Material 带有许多组件和样式,可以直接在你的下一个项目中使用。所以,让我们给你的 Angular 项目一些应有的关注。

第十一章:角材料

当您开发应用程序时,您需要一个清晰的策略来创建您的用户界面。该策略应包括使用良好的对比色;具有一致的外观和感觉;它应该在不同的设备和浏览器上运行良好;以及许多其他要求。简而言之,在今天的 Web 平台上构建应用程序时,对用户界面和用户体验有很多要求。难怪大多数开发人员认为 UI/UX 是一项艰巨的任务,因此转向可以减轻大部分工作的 UI 框架。有一些框架比其他框架更常用,即:

  • Twitter Bootstrap

  • 基础

  • HTML5 快速入门

然而,有一个新的设计语言,Material Design。本章将尝试解释什么是 Material Design,并将查看哪些框架实现了 Material Design 的原则,我们将特别关注为 Angular 特别制作的 Angular Material。

在本章中,我们将:

  • 了解 Material Design 是什么以及它的一点历史

  • 了解更多已知的实现

  • 深入了解 Angular Material 及其组成部分

  • 使用 Angular Material 构建 Angular 应用程序

Material Design

Material Design 是谷歌在 2014 年开发的设计语言。谷歌表示,他们的新设计语言是基于纸张和墨水的。Material Design 的创作者试图用以下引用来解释他们试图达到的目标:

“我们挑战自己为我们的用户创建一种视觉语言,将好设计的经典原则与技术和科学的创新和可能性相结合。”

他们进一步解释了目标:

  • 开发一个统一的基础系统,使跨平台和设备尺寸的体验统一

  • 移动规则是基本的,但触摸、语音、鼠标和键盘都是一流的输入方法

很明显,设计语言希望在各种设备上对用户界面和用户交互的外观和感觉只有一个看法。此外,输入在用户界面的整体体验中起着重要作用。

Material Design 基于三个原则:

  • 材料是隐喻

  • 大胆、图形、有意

  • 动作赋予意义

总的来说,可以说设计语言背后有很多理论,而且关于这个主题有很好的文档,如果你希望深入了解,可以在官方文档网站material.io/.找到更多信息。

现在,如果你是一名设计师并且关心图形理论,这一切可能非常有趣。我们猜想你正在阅读这本书的人是一名开发者,现在你可能会问自己一个问题。那又怎样,为什么我要在意呢?

每当谷歌着手构建某物时,它都会变得很大。并非所有东西都能经受时间的考验,但是这背后有足够的实力,谷歌已经在许多自己的产品上使用了这一设计,如 Firebase、Gmail、Google Plus 等。

当然,单独的设计语言并不那么有趣,至少对于开发者来说是这样,这就引出了我们下一节的内容,即基于 Material Design 原则的多种实现。在接下来的部分中会详细介绍。

已知的实现

对于开发者来说,设计是为了理清你的代码并为用户提供良好的视觉和可用性体验。目前,Material Design 存在三种主要的实现。

它们是:

  • Materialize,materializecss.com/about.html.GitHub 上的 24,000 多个星星告诉你它被广泛使用。它可以作为独立使用,但也可以与 AngularJS 和 React 等框架进行绑定。它提供导航元素、组件等,是一个不错的选择。

  • AngularJS Material,material.angularjs.org/latest/,是谷歌专为 AngularJS 开发的实现。它非常强大,包括主题、导航元素、组件和指令。

  • Angular Material,material.angular.io/,是谷歌专为 Angular 构建的实现。我们将在本章的其余部分重点介绍这个实现。

如果你是 Angular 开发者,那么 AngularJS Material 或 Materialize 都是有效的选择,因为后者具有 AngularJS 绑定,可以在krescruz.github.io/angular-materialize/找到。Materialize 可以被许多其他应用程序框架使用,是这三种选择中最通用的。Angular Material 专为 Angular 而设计。

现在是时候详细了解 Angular Material 了。

Angular Material

该库是为新的 Angular 实现 Material Design 而开发的。它仍在不断发展中,但已经有足够的组件可以使用。您应该知道它仍处于 Beta 阶段,因此如果考虑采用它,需要一定的谨慎。官方文档可在material.angular.io找到,存储库可在github.com/angular/material2找到。这是一个相当受欢迎的库,拥有 10,000 多个星标。

Angular Material 通过以下要点来宣传自己:

  • 从零到应用的冲刺:目的是让您作为应用开发者能够轻松上手。目标是尽量减少设置的工作量。

  • 快速一致:这意味着性能是一个主要关注点,同时也保证在所有主要浏览器上运行良好。

  • 多功能:这强调了两个主要点,应该有大量易于定制的主题,还有很好的本地化和国际化支持。

  • 为 Angular 优化:它是由 Angular 团队自己构建的,这意味着对 Angular 的支持是一个重要的优先事项。

该框架包括以下部分:

  • 组件:这意味着有大量的构件可帮助您取得成功,如不同类型的输入、按钮、布局、导航、模态框和展示表格数据的不同方式。

  • 主题:该库预装了主题,但也很容易引用外部主题。还有一个主题指南,如果您想创建自定义主题,可以在material.angular.io/guide/theming.找到。

  • 图标:Material Design 带有超过 900 个图标,因此您很可能会找到所需的图标。要查看所有图标,请访问material.io/icons/.

  • 手势:UI 中并非所有操作都是按钮点击。由于 Material Design 支持移动端,因此通过 HammerJs 库支持移动手势。

安装

我知道你可能迫不及待地想要尝试一下,所以让我们不要再拖延了。首先,我们需要安装它。让我们首先确保我们有一个准备好安装它的 Angular 项目,通过告诉 Angular CLI 为我们搭建一个项目。

ng new AngularMaterialDemo

现在是时候安装 Angular Material 所需的依赖项了:

npm install --save @angular/material @angular/cdk

现在让我们也安装支持动画。这对它的工作并不是绝对必要的,但我们想要一些很酷的动画,对吧?

需要安装以下内容:

npm install @angular/animations

因此,我们已经安装了 Angular Material,并准备在我们的应用程序中使用它。正如我们从之前的章节中学到的,要使用外部的 Angular 模块,我们需要导入它们。一旦完成了这一步,我们就可以开始使用这些模块公开导出的构造。实际上,有许多要导入的模块,取决于我们的需求,例如,每个控件都有自己的模块,但动画只有一个。

我们的第一个 Angular Material 应用程序

到目前为止,您已经使用 Angular CLI 搭建了一个 Angular 应用程序。您已经安装了必要的节点模块,并迫不及待地想要在 Angular Material 中使用这些构造。我们期望我们的 Angular Material 应用程序有两个方面,一些漂亮的渲染以及一些漂亮的动画。要开始使用 UI 控件,比如按钮或复选框,我们需要导入它们对应的模块。为了获得 UI 渲染和动画行为,我们需要添加必要的模块并选择要使用的主题。

让我们从我们需要的模块开始,即BrowserAnimationsModule。要开始使用它,我们导入它并在我们的根模块中注册它,就像这样:

import { 
 BrowserAnimationsModule 
} from '@angular/platform-browser/animations';  @NgModule({
  imports: [ BrowserAnimationsModule ]
})
export class AppModule {}

在这一点上,我们实际上还没有添加要使用的 UI 元素,所以让我们把这作为下一个业务顺序。我们的第一个示例将是关于按钮。要使用 Angular Material 按钮,我们需要将MatButtonModule添加到我们的根模块中:

import { BrowserAnimationsModule } from  '@angular/platform-browser/animations'; import { MatButtonModule } from  '@angular/material'; @NgModule({
  imports: [ 
 BrowserAnimationsModule, 
    MatButtonModule 
 ]
})
export class AppModule {}

我们还需要一件事,即主题。如果我们不添加主题,我们将得到一个看起来很无聊的灰色按钮。然而,如果我们有一个主题,我们将得到与 Material Design 相关的所有漂亮的动画。

要添加主题,我们需要在styles.css文件中添加一个条目。这个文件用于为整个应用程序设置 CSS 样式。所以让我们在styles.css中添加必要的行:

@import  "~@angular/material/prebuilt-themes/indigo-pink.css";

波浪号运算符~通知 webpack,即为 Angular CLI 提供动力的底层引擎,应将此路径视为 webpack 处理的别名路径,而不仅仅是常规字段路径或 URL

现在我们准备使用我们的第一个 Angular Material UI 元素。我们选择的是 Material Design 按钮。要使用它,我们需要在要在其上实现 Material Design 渲染和行为的元素上添加mat-button属性。

我们从根模块app.module.ts开始,添加以下条目:

@Component({
 template : `
 <button mat-button>Click me!</button>
 `
})

在模板中,通过添加mat-button属性,普通按钮变成了 Material Design 按钮。mat-button是一个指令,为我们的按钮提供了新的外观以及相关的动画。现在点击按钮应该会产生一个漂亮的动画。

这展示了使用 Angular Material 是多么简单,但还有更多,远远不止这些。让我们在接下来的部分讨论大多数组件。

组件概述

Angular Material 包括许多不同类型的组件,包括:

  • 表单控件:通过表单控件,我们指的是我们用来从表单收集数据的任何类型的控件,比如自动完成、复选框、普通输入、单选按钮、选择列表等。

  • 导航:通过导航,我们指的是菜单、侧边栏或工具栏等。

  • 布局:布局指的是我们如何在页面上放置数据,比如使用列表、卡片或选项卡。

  • 按钮:这些就是它们听起来的样子,你可以按的按钮。但是你可以使用许多不同的按钮,比如图标按钮、凸起按钮等。

  • 弹出窗口和模态框:这些是特定的窗口,阻止任何用户交互,直到您与弹出窗口或模态框进行交互为止。

  • 数据表:这只是以表格方式显示数据。您需要什么样的表格取决于您的数据是庞大的并且需要分页,还是需要排序,或者两者兼而有之。

按钮

到目前为止,我们的应用程序只包括一个简单的按钮,我们是这样声明的:

<button mat-button>simple button</button>

然而,还有很多其他类型的按钮,包括:

  • mat-button,这是一个普通的按钮

  • mat-raised-button,这是一个带有阴影显示的凸起按钮,以表示其凸起状态

  • mat-icon-button,这个按钮是用来与图标一起使用的

  • mat-fab,这是一个圆形按钮

  • mat-button-toggle,这是一个指示是否已按下的按钮,具有按下/未按下状态

按钮的标记如下:

<button  mat-button>Normal button</button> <button  mat-raised-button>Raised button</button> <button  mat-fab>Fab button</button> <button  mat-icon-button>
 <mat-icon  class="mat-icon material-icons"  role="img"  aria-hidden="true">home</mat-icon>
 Icon button
</button>
<mat-button-toggle>Button toggle</mat-button-toggle>

值得注意的是,我们需要导入MatButtonToggleModule才能使用mat-button-toggle按钮。按钮看起来像下面这样:

要使用这些按钮,我们需要确保导入和注册它们所属的模块。让我们更新我们的根模块,使其看起来像下面这样:

import { BrowserModule } from  '@angular/platform-browser'; import { NgModule } from  '@angular/core';  import { BrowserAnimationsModule } from  '@angular/platform-browser/animations'; import { 
 MatButtonModule, 
 MatIconModule, 
 MatButtonToggleModule 
} from '@angular/material'**;** import { AppComponent } from  './app.component'; 
@NgModule({
  declarations: [ AppComponent
 ],
 imports: [ BrowserModule, BrowserAnimationsModule, MatButtonModule, MatIconModule,
 MatButtonToggleModule
 ],  bootstrap: [AppComponent] })
export  class  AppModule { }

我们可以看到我们需要注册MatIconModule来支持使用mat-icon指令,并且我们还需要注册MatButtonToggleModule来使用<mat-button-toggle> UI 元素,一个切换按钮。

表单控件

表单控件是关于以不同的方式收集输入数据,以便您可以通过调用 HTTP 端点来持久化数据。

Material Design 中有许多不同类型的控件,包括:

  • 自动完成:此控件使用户可以在输入字段中开始输入并在输入时显示建议列表。这有助于缩小输入可以接受的可能值。

  • 复选框:这是一个经典的复选框,表示一个处于选中或未选中状态的状态。

  • 日期选择器:这是一个控件,使用户可以在日历中选择日期。

  • 输入:这是一个经典的输入控件。Material Design 通过有意义的动画增强了控件,因此您可以清楚地看到您何时正在输入或不在输入。

  • 单选按钮:这是一个经典的单选按钮,就像输入控件一样,Material Design 对此的处理是在编辑时添加动画和过渡,以创造更好的用户体验。

  • 选择:这是一个经典的选择列表,提示用户从列表中选择一个或多个项目。

  • 滑块:滑块使您可以通过拖动滑块按钮向右或向左增加或减少值。

  • 滑动切换:这只是一个复选框,但是一个更好的版本,其中滑块被滑向左边或右边。

输入

输入字段是一个经典的输入字段,您可以在其中设置不同的验证规则。但是,您可以很容易地添加在输入字段上以一种漂亮和反应灵敏的方式显示错误的能力。

为了实现这一点,我们需要:

  • formControl与我们的输入字段关联

  • 将我们的输入定义为MatInput并添加验证规则

  • 定义一个mat-error元素和一个何时应该显示的规则

对于第一个项目,我们执行以下操作:

<mat-form-field>
 <input  matInput  placeholder="Name" [formControl]="nameInput">
</mat-form-field>

这为我们设置了一个输入控件和一个formControl的引用,这样我们就可以监听输入的变化。这需要与我们在app.component.ts文件中添加一个引用的代码一起使用,就像这样:

nameInput:FormControl;

constructor() {
 this.nameInput = new FormControl();
}

然后,我们需要向输入添加matInput指令,并添加一个验证规则,使其看起来像这样:

<mat-form-field>
 <input [formControl]="nameInput" required matInput >
</mat-form-field>

最后,我们添加mat-error元素,并将mat-input-container包装在一个表单元素中。在这一点上,我们需要记住在根模块中包含FormsModule。我们还需要设置一个规则,用*ngIf来确定mat-error元素何时显示:

<form name="person-form">
 <mat-input-container>
 <input [formControl]="nameInput" required matInput >
    <mat-error *ngIf="nameInput.hasError('required')">
 Name field is required
 </mat-error>
 </mat-input-container>
</form>

前面的标记设置了输入元素和何时显示验证规则,但正如前面提到的,我们需要在根模块中包含FormsModule作为最后一步,让我们看看它是什么样子的:

import {FormsModule} from '@angular/forms';

@NgModule({
 imports: [FormsModule]
})
export class AppModule {}

所有这些都汇总成以下内容:

当验证错误被触发时,它看起来是这样的:

我们已经介绍了 Angular Material 包含的所有表单控件的一个子集,即自动完成、复选框、日期选择器,最后是展示验证错误的普通输入。还有其他表单控件,如单选按钮、选择器、滑块和滑动切换,我们鼓励您按照自己的节奏进行探索。

自动完成

自动完成的想法是帮助用户缩小输入字段可能具有的可能值。在普通的输入字段中,您只需输入一些内容,希望验证会告诉您输入的内容是否不正确。使用自动完成,您在输入时会看到一个列表。随着您的输入,列表会被缩小,您可以随时决定停止输入,而是从列表中选择一个项目。这是一个时间节省者,因为您不必输入整个项目的名称,它还增强了正确性,因为用户被要求从列表中选择,而不是输入整个内容。

由于这是自动更正的完整行为,这意味着我们需要提供一个可能答案的列表,还需要一个输入框来接收输入。

我们需要按照五个步骤设置这个控件:

  1. 导入并在根模块中注册所有必要的模块。

  2. 定义一个包含输入控件的mat-form-field

  3. 定义一个mat-autocomplete控件,这是可能选项的列表。

  4. 通过视图引用链接这两个控件。

  5. 添加一个过滤器,当用户输入时,可以缩小自动完成控件的范围。

让我们从第一步开始,导入所有必要的内容。在这里,我们需要自动完成功能,但由于我们将使用表单,特别是响应式表单,我们还需要该模块。我们还需要一些表单来支持我们打算使用的输入字段:

import { BrowserModule } from  '@angular/platform-browser'; import { NgModule } from  '@angular/core'; import { AppComponent } from  './app.component'; import { MatButtonModule } from  '@angular/material'; import { BrowserAnimationsModule } from  '@angular/platform-browser/animations'; import { MatIconModule } from  '@angular/material/icon'; import { MatButtonToggleModule } from  '@angular/material/button-toggle'; import { MatAutocompleteModule } from  '@angular/material'; import { ReactiveFormsModule } from  '@angular/forms'; import { MatFormFieldModule } from  '@angular/material/form-field'; import { MatInputModule } from  '@angular/material/input'**;** 
@NgModule({
  declarations: [ AppComponent
 ],
 imports: [ BrowserModule, BrowserAnimationsModule, MatButtonModule, MatIconModule, MatButtonToggleModule, MatAutocompleteModule, ReactiveFormsModule, MatFormFieldModule, MatInputModule
 ],
 providers: [], bootstrap: [AppComponent] })
export  class  AppModule { }

现在我们准备向app.component.html文件模板添加一些标记:

<mat-form-field>
 <input  type="text"  **matInput**  placeholder="jedis" [formControl]="myControl"  >
</mat-form-field>

此时,我们已经定义了输入控件并添加了matInput指令。我们还添加了一个formControl引用。我们添加这个引用是为了以后能够监听输入的变化。输入的变化很有趣,因为我们能够对其做出反应并过滤我们的列表,这本质上就是自动完成所做的事情。下一个要做的事情是定义一组值,一旦用户开始输入,我们就需要向他们建议这些值,所以让我们接着做吧:

<mat-autocomplete #auto="matAutocomplete">
 <mat-option *ngFor="let jedi of jedis" [value]="jedi"> {{ jedi }}
 </mat-option>
</mat-autocomplete>

我们有了列表,但缺少输入字段和建议列表之间的任何连接。在修复之前,我们首先需要查看我们的组件类,并向其添加一些代码以支持先前的标记:

export  class  AppComponent {  myControl:  FormControl; jedis  = [ 'Luke', 'Yoda', 'Darth Vader', 'Palpatine', 'Dooku', 'Darth Maul'
 ];

 constructor() { this.myControl  =  new  FormControl();
 }
} 

到目前为止,我们已经分别定义了matInputmat-autocomplete,现在是将两者连接起来的时候了。我们通过向mat-autocomplete添加一个视图引用,以便matInput可以引用它,就像这样:

<mat-autocomplete #auto="matAutocomplete">
 <mat-option *ngFor="let jedi of jedis" [value]="jedi"> {{ jedi }}
 </mat-option>
</mat-autocomplete>

并且为了在matInput中引用它,我们引入MatAutocomplete指令,就像这样:

<form  action="">
 <mat-input-container  name="container">
 <mat-form-field hintLabel="Max 30 characters"> <input  name="input" type="text"
 #input
 matInput
 placeholder="type the name of the jedi" [formControl]="jediControl"
 **[matAutocomplete]= "auto"**>
 <mat-hint align="end">{{input.value?.length || 0}}/30</mat-hint> 
 </mat-form-field> </mat-input-container>
</form>

正如您所看到的,matAutocomplete指向auto视图引用,因此当我们将焦点设置到输入字段并开始输入时,列表就会被触发。

在前面的代码中,我们添加了另一个有用的东西,即提示。向输入添加提示是向用户传达应在输入字段中输入什么的好方法。通过添加属性hintLabel,我们能够告诉用户应该输入什么。您甚至可以通过使用mat-hint元素在用户输入时介绍一些提示,让他们知道他们的输入情况如何。让我们仔细看一下刚才完成了我们所描述的工作的前面的代码:

<mat-form-field **hintLabel="Max 30 characters"**>
 <input  name="input" type="text"
 #input
 matInput
 placeholder="type the name of the jedi" [formControl]="jediControl"
 [matAutocomplete]= "auto">
  **<mat-hint align="end">{{input.value?.length || 0}}/30</mat-hint>** 
</mat-form-field> 

尝试在适用的地方使用hintLabelmat-hint元素,这将极大地帮助您的用户。

如果您正确输入了所有内容,您应该在 UI 中看到类似于这样的东西:

看起来不错!当你将输入聚焦时,列表会显示出来。然而,你会注意到随着你的输入,列表并没有真正被过滤掉。这是因为我们没有捕捉到你在输入控件中输入时的事件。所以让我们接下来做这个。

监听输入变化意味着我们监听我们的表单控件及其valueChanges属性,如下所示:

myControl.valueChanges

如果你仔细看,你会发现这是一个 Observable。这意味着我们可以使用操作符来过滤掉我们不想要的内容。我们对所需内容的定义是以我们在输入框中输入的文本开头的jedis。这意味着我们可以将其完善为如下所示的样子:

import { Component } from  '@angular/core'; import { FormControl } from  "@angular/forms"; import { Observable } from  "rxjs/Observable"; import  'rxjs/add/operator/map'; @Component({
  selector:  'app-root', templateUrl:  './app.component.html', styleUrls: ['./app.component.css'] })
export  class  AppComponent { title  =  'app'; myControl:  FormControl; jedis  = [ 'Luke', 'Yoda', 'Darth Vader', 'Palpatine', 'Dooku', 'Darth Maul'
 ];

 filteredJedis$:  Observable<string[]>; constructor() { this.myControl  =  new  FormControl(); this.filteredJedis$  =  this.myControl .valueChanges .map(input  =>  this.filter(input**));** }

  filter(key:  string):  Array<string> { return  this.jedis.filter(jedi  =>  jedi.startsWith(key)); }
}

现在我们只需要改变我们的模板,让mat-option看向filteredJedis而不是jedis数组,如下所示:

<mat-autocomplete #auto="matAutocomplete">
 <mat-option *ngFor="let jedi of **filteredJedis$ | async**" [value]="jedi"> {{ jedi }}
 </mat-option>
</mat-autocomplete>

测试一下,我们看到它似乎是有效的。

复选框

这是一个经典的复选框,包含选中、未选中和未确定的状态。使用起来非常简单,但你需要导入一些模块来使用它,如下所示:

import { MatCheckboxModule } from @angular/material/checkbox;

@NgModule({
 imports: [MatCheckboxModule]
})

标记应该是这样的:

<mat-checkbox [checked]="propertyOnTheComponent" >Check me<mat-checkbox>

因此,基本上,只需将<mat-checkbox>添加为元素名称,并确保将checked属性绑定到我们组件上的属性。

日期选择器

通常情况下,使用日期选择器,你可以做的远不止从弹出日历中选择日期。你可以禁用日期范围,格式化日期,按年度和月度显示日期等等。我们只会探讨如何开始并运行它,但我们鼓励你在material.angular.io/components/datepicker/overview探索此控件的文档。

首先,我们需要导入必要的模块:

import { 
 MatDatepickerModule, 
MatNativeDateModule } from  '@angular/material';

@NgModule({
 imports: [MatDatepickerModule, MatNativeDateModule]
})

对于标记,我们需要做以下事情:

  • 定义一个带有matInput指令的输入。选定的日期将放在这里。

  • 定义一个<mat-datepicker>元素。这是弹出式日历。

  • 创建两个控件之间的连接。

对于第一个要点,我们在标记中声明它,如下所示:

<mat-form-field>
 <input matInput  placeholder="Choose a date">  </mat-form-field>  

我们可以看到,我们通过使用formControl指令指出了在我们组件中称为 input 的formControl实例。我们还添加了matInput指令,以赋予我们的输入字段漂亮的材料外观和感觉。

对于第二个任务,我们定义<mat-datepicker>元素,如下所示:

<mat-datepicker></mat-datepicker>

现在我们需要建立它们之间的连接,就像我们在自动完成控件中所做的那样,我们在<mat-datepicker>元素中定义一个视图引用picker,并通过将该引用分配给输入元素中的matDatepicker指令来引用它,所以它看起来像下面这样:

<div>
 <mat-form-field>
 <input  matInput [matDatepicker]="picker"> <mat-datepicker-toggle  matSuffix [for]="picker">
 </mat-datepicker-toggle> <mat-datepicker #picker></mat-datepicker> </mat-form-field>
</div>

因此,总之,我们在mat-datepicker元素中添加了一个视图引用,并通过将其分配给输入元素中的[matDatePicker]指令来引用该引用。

我们还添加了一个按钮,用于切换日历的可见性。我们通过使用<mat-datepicker-toggle>元素并将其分配给picker视图引用来实现这一点:

<mat-datepicker-toggle  matSuffix [for]="picker"></mat-datepicker-toggle>

最后,您的创建现在应该看起来像下面这样:

导航

导航是我们在应用程序中移动的方式。我们有不同的方式来做到这一点,比如点击链接或者点击菜单项。Angular Material 为此提供了三个组件:

  • 菜单:这是一个弹出列表,您可以从中选择许多不同的菜单选项

  • 侧边栏:这个组件就像一个停靠在页面左侧或右侧的菜单,并以应用程序内容的遮罩形式呈现在应用程序上

  • 工具栏:这是用户可以使用的常用操作的典型工具栏

在这一部分,我们将展示使用菜单的完整示例,但我们鼓励您继续探索,学习如何使用侧边栏(material.angular.io/components/sidenav/overview)以及工具栏组件(material.angular.io/components/toolbar/overview)。

菜单

菜单组件就是它听起来的样子,它是为了让您轻松地向用户呈现菜单。它使用三个主要指令,mat-menumat-menu-item,最后,MatMenuTriggerFor。每个菜单只有一个mat-menu,以及尽可能多的mat-menu-itemsMatMenuTriggerFor用于触发菜单,通常将其附加到按钮上。

使菜单工作可以分为三个步骤:

  1. 定义一个mat-menu控件。

  2. 添加尽可能多的mat-menu-items

  3. 通过添加MatMenuTriggerFor指令将触发器添加到按钮。

在我们执行任何操作之前,我们需要导入MatMenuModule以便能够使用先前提到的构造,所以让我们这样做:

import {MatMenuModule} from '@angular/material';

@NgModule({
 imports: [MatMenuModule]
})

现在我们准备定义我们的菜单,如下所示:

<mat-menu>
</mat-menu>

之后,我们添加所需的项目:

<mat-menu>
 <button mat-menu-item >Item1</button>
 <button mat-menu-item >Item2</button>
</mat-menu>

最后,我们通过添加一个按钮来触发matMenuTriggerFor指令来添加触发器,就像这样:

<button [matMenuTriggerFor]="menu">Trigger menu</button>
<mat-menu #menu>
 <button mat-menu-item >Item1</button>
 <button mat-menu-item >Item1</button>
</mat-menu>

注意matMenuTriggerFor指向menu视图引用。

您的最终结果应该看起来像这样:

当然,并非所有菜单都是这么简单。迟早您会遇到需要嵌套菜单的情况。Material UI 很容易支持这一点。支持这一点的整体方法在于为您需要的每个菜单定义mat-menu,然后连接它们。然后您需要定义什么操作导致触发哪个子菜单。听起来困难吗?其实并不是。让我们从我们的顶级菜单,我们的根菜单开始。让我们给菜单项一些有意义的名称,就像这样:

<button [matMenuTriggerFor]="menu">Trigger menu</button>
<mat-menu #menu>
 <button mat-menu-item >File</button>
 <button mat-menu-item >Export</button>
</mat-menu>

在这一点上,我们有两个菜单项,最后一个wxport需要一些子选项。想象一下我们在程序中处理表格数据,支持将数据导出为 CSV 或 PDF 是有意义的。让我们添加一个子菜单,就像这样:

<button [matMenuTriggerFor]="rootMenu">Trigger menu</button>
<mat-menu #rootMenu>
 <button mat-menu-item>File</button>
 <button mat-menu-item>Export</button>
</mat-menu>

<mat-menu #subMenu>
 <button mat-menu-item>CSV</button>
 <button mat-menu-item>PDF</button>
</mat-menu>

好的,现在我们有两个不同的菜单,但我们需要添加连接,使rootMenu项触发subMenu显示。让我们再次使用matMenutriggerFor指令来添加,就像这样:

<button [matMenuTriggerFor]="rootMenu">Trigger menu</button>
<mat-menu #rootMenu>
 <button mat-menu-item >File</button>
 <button mat-menu-item [matMenuTriggerFor]="subMenu">Export</button>
</mat-menu>

<mat-menu #subMenu>
 <button mat-menu-item>CSV</button>
 <button mat-menu-item>PDF</button>
</mat-menu>

这应该呈现一个看起来像下面这样的菜单:

菜单有更多的用途,不仅仅是渲染一些菜单项并通过按钮触发它们。其他需要考虑和尝试的事情包括通过添加图标使其看起来更专业,或者迎合无障碍。现在您已经了解了如何创建简单菜单以及嵌套菜单的基础知识,去探索吧。

布局

布局是关于定义如何在页面上放置内容。Angular Material 为此目的提供了不同的组件,即:

  • 列表:这是一种将内容呈现为项目列表的方式。列表可以用链接、图标来丰富,甚至可以是多行的。

  • 网格列表:这是一个帮助你将内容排列成块的控件。您需要定义列数,组件将确保填充视觉空间。

  • 卡片:这是一个包装内容并添加阴影的组件。您也可以为其定义一个标题。

  • 选项卡:这让您可以在不同的选项卡之间划分内容。

  • 步进器:这是一个将您的组件分成向导式步骤的组件。

  • 展开面板:这个组件的工作方式基本上类似于手风琴,它使您能够以列表的方式布置组件,并为每个项目添加标题。每个项目都可以展开,一次只能展开一个项目。

在本节中,我们将介绍列表和网格列表组件。我们建议您自行探索卡片组件,material.angular.io/components/card/overview,选项卡组件,material.angular.io/components/tabs/overview,步进器,material.angular.io/components/stepper/overview,以及展开面板,material.angular.io/components/expansion/overview

列表

列表控件由一个mat-list元素和一些mat-list-items组成。其标记如下:

<mat-list>
 <mat-list-item>Item1</mat-list-item>
 <mat-list-item>Item1</mat-list-item>
</mat-list>

就是这样,就是这样。为了你的努力,你将获得一个看起来像这样的列表:

当然,列表可以更加复杂,包含链接、图标等。一个更有趣的例子可能是这样的:

我想你已经明白了,这里有列表项,我可以在其中放入任何我想要的东西。要了解更多关于功能的信息,请点击以下链接查看列表文档:material.angular.io/components/list/overview.

网格列表

网格列表用于以行和列的列表形式显示内容,同时确保填充视口。如果您希望最大限度地自由决定如何显示内容,这是一个非常好的组件。这是一个名为MatGridListModule的单独模块。我们需要将其添加到我们导入的模块列表中,就像这样:

import { MatGridListModule } from '@angular/material';

@NgModule({
 imports: [MatGridListModule]
})

该组件由一个mat-grid-list元素和一些mat-grid-tile元素组成。

让我们首先添加mat-grid-list元素:

<mat-grid-list cols=4 rowHeight="300px">
</mat-grid-list>

值得注意的是我们如何设置列数和每行的高度。现在是添加内容的时候了。我们通过添加一些mat-grid-tile实例来实现:

<mat-grid-list cols=4 rowHeight="300px">
 <mat-grid-tile *ngFor="let tile of tiles" [colspan]="tile.cols" [rowspan]="tile.rows" [style.background]="tile.color"> {{ tile.text }}
 </mat-grid-tile>
</mat-grid-list>

在这里,我们正在定义一个*ngFor,指向我们的瓷砖列表。我们还绑定到[colspan],决定它应该占用多少列空间,[rowspan],确定它应该占用多少行,最后,我们绑定到我们样式中的背景属性。

该组件如下所示:

tiles  = [ {text:  'One', cols:  3, rows:  1, color:  'lightblue'}, {text:  'Two', cols:  1, rows:  2, color:  'lightgreen'}, {text:  'Three', cols:  1, rows:  1, color:  'lightpink'}, {text:  'Four', cols:  2, rows:  1, color:  '#DDBDF1'}, ];

我们鼓励您探索卡片和选项卡组件,以了解更多关于剩余布局组件的信息。

弹出窗口和模态

有不同的方式可以吸引用户的注意。一种方法是在页面内容上显示对话框,并提示用户采取行动。另一种方法是在用户悬停在特定部分时显示该部分的信息。

Angular Material 为此提供了三种不同的组件:

  • 对话框:这只是一个简单的模态对话框,显示在内容的顶部。

  • Tooltip:当您悬停在指定区域时,它会显示一段文本。

  • Snackbar:这在页面底部显示信息消息。信息消息只在短时间内可见。它旨在向用户传达由于某种操作(例如保存表单)而发生的事情。

对话框

对话框组件非常强大,因为它帮助我们创建一个模态框。它可以根据您的喜好进行定制,并且设置起来有点棘手。但不用担心,我们会指导您完成整个过程。我们需要做的是:

  1. 导入对话框模块。

  2. 创建一个作为我们对话框的组件。

  3. 创建一个组件和一个按钮,触发该模块。

  4. 将我们的对话框添加到模块的entryComponents属性中。

首先,我们导入必要的模块,如下所示:

import { MatDialogModule } from '@angular/material';

@NgModule({
 imports: [MatDialogModule]
})

接下来,我们创建一个将容纳我们对话框的组件。它是一个普通的组件,有模板和后台类,但它确实需要注入一个MatDialogRef。它应该看起来像这样:

import { MatDialogRef } from  "@angular/material"; import { Component } from  "@angular/core"; @Component({
  selector:  'my-dialog', template: ` <h1  mat-dialog-title>Perform action?</h1> <mat-dialog-content>Save changes to Jedi?</mat-dialog-content> <mat-dialog-actions>
 <button  mat-button  [mat-dialog-close]="true">Yes</button>
 <button  mat-button  mat-dialog-close>No</button> </mat-dialog-actions>
`  })
export  class  DialogComponent { constructor(public  dialogRef:  MatDialogRef<DialogComponent>) { console.log('dialog opened'); }
}

我们在模板中定义了以下一般结构:

<h1 mat-dialog-title>Save changes to Jedi?</h1>
<mat-dialog-content>
</mat-dialog-content>
<mat-dialog-actions>
 <button mat-button [mat-dialog-close]>Yes</button>
 <button mat-button mat-dialog-close >No</button> 
</mat-dialog-actions>

乍一看,我们定义了一个标题、一个内容和一个操作字段,其中定义了按钮。为了发送不同的值回来,我们使用[mat-dialog-close]并为其分配一个值。

至于代码部分,我们注入了一个类型为MyDialogMatDialogRef实例,这正是我们所在的组件。

我们需要做的第三件事是设置一个宿主组件,在其中有一个按钮,当点击时将启动一个对话框。所以让我们用以下代码来做到这一点:

import { Component } from  "@angular/core"; import { MatDialog } from  "@angular/material/dialog"; import { DialogComponent } from  "./dialog.component"; 
@Component({
  selector:  'dialog-example', template: ` <button  (click)="openDialog()">Open Dialog</button> `
})
export  class  DialogExampleComponent { selectedOption; constructor(private  dialog:  MatDialog) { }

  openDialog() { let  dialogRef  =  this.dialog.open(DialogComponent); dialogRef.afterClosed().subscribe(result  => {
 // do something with 'result'  });
 }
}

在这里,我们做了两件事,我们使用类型调用dialog.open(),这是我们的对话框组件。此外,通过监听调用dialogRef.afterClosed()时返回的 Observable,我们能够检查来自对话框的结果。在这一点上,没有太多结果可以查看,但在下一节中,我们将看一个更高级的对话框示例,我们将使用这种方法。

最后,我们需要转到我们的app.module.ts文件,并将我们的DialogComponent对话框添加到entryComponents数组中,如下所示:

@NgModule({
 entryComponents: [DialogComponent]
})

因此,在 Angular 模块的entryComponents数组中添加内容对我们来说是一个全新的概念,它实际上是做什么的?当我们将组件添加到该列表中时,我们告诉编译器这个组件需要被编译,并且需要一个ComponentFactory,以便我们可以动态创建它。因此,将任何组件放在这里的标准是,我们希望动态加载组件或按类型加载组件。这正是我们的DialogComponent的情况。在调用this.dialog.open(DialogComponent)之前,它实际上并不存在。在那时,它会在幕后运行一个名为ViewContainerRef.createComponent()的方法。简而言之,我们需要在每次打开对话框时实例化DialogComponent。因此,不要忘记entryComponents,否则它将无法工作。您可以在angular.io/guide/ngmodule-faq#what-is-an-entry-component上阅读更多关于entryComponents的信息。

您的对话框最终会看起来像这样:

一个更高级的例子-向对话框发送数据和从对话框发送数据

之前,我们介绍了一个简单的对话框示例,我们学会了如何打开对话框并关闭它。那只是皮毛。真正有趣的是我们如何向对话框发送数据,以便它预先加载一些数据,并且我们如何将在对话框内收集的数据发送回打开它的宿主组件。我们将研究这两种情况。

向对话框发送数据的业务案例是,这样它就可以从一些数据开始,例如,显示现有记录并在对话框中进行更新。

通过向dialog.open()方法添加第二个参数,我们可以向对话框组件发送数据,以便它可以显示:

// jedi.model.ts
interface  Jedi  {
 name:  string; }

import { Component } from  "@angular/core"; import { MatDialog } from  "@angular/material/dialog"; import { DialogComponent } from  "./dialog.component"; 
@Component({
  selector:  'dialog-example', template: ` <button  (click)="openDialog()">Open Dialog</button> `
})
export  class  DialogExampleComponent { selectedOption; jedi:  Jedi;

 constructor(private  dialog:  MatDialog) {
 this.jedi  =  {  name:  'Luke'  };
 }

 openDialog() {
  let dialogRef = this.dialog.open(DialogComponent, {
 data: { jedi: this.jedi }
 });

 dialogRef.afterClosed().subscribe(result  =>  {
 console.log(result);
 });
 }
}  

在对话框组件方面,我们需要告诉它我们发送的数据。我们通过注入MAT_DIALOG_DATA来实现这一点,所需的更改如下所示:

import { MatDialogRef, MAT_DIALOG_DATA } from  "@angular/material"; import { Component, Inject } from  "@angular/core"; @Component({
  selector:  'my-dialog',
 template: `
 <h1  mat-dialog-title>Save changes to jedi?</h1>
 <mat-dialog-content>
      <input matInput [(ngModel)]="data.jedi.name" **/>**
 </mat-dialog-content>
 <mat-dialog-actions>
 <button  mat-button  (click)="saveAndClose()">Yes</button>  <button  mat-button  mat-dialog-close>No</button>
 </mat-dialog-actions>
 `, })
export  class  DialogComponent { constructor(
 public  dialogRef:  MatDialogRef<DialogComponent>,
    @Inject(MAT_DIALOG_DATA) public data:  any
 ) {
 console.log('dialog opened');
 }

 saveAndClose() {
 this.dialogRef.close('save');
 }
}

现在,因为我们已经从host类发送了数据绑定的jedi实例,所以我们在Dialog类中对其进行的任何更改都将反映在host类中。这解决了从host类发送数据到对话框的问题,但是如果我们想要从对话框发送数据回来怎么办?我们可以通过在dialogRef.close()方法调用中发送一个参数来轻松实现,就像这样:

export  class  DialogComponent { constructor(
 public  dialogRef:  MatDialogRef<DialogComponent>,
    @Inject(MAT_DIALOG_DATA) public data:  any
 ) {
 console.log('dialog opened');
 }

 saveAndClose() {
    this.dialogRef.close('save'**);**
 }
}

要对数据进行操作,我们只需订阅从调用afterClose()得到的 Observable。如下所示加粗说明:

import { Component } from  "@angular/core"; import { MatDialog } from  "@angular/material/dialog"; import { DialogComponent } from  "./dialog.component"; 
@Component({
  selector:  'dialog-example', template: ` <button  (click)="openDialog()">Open Dialog</button> `
})
export  class  DialogExampleComponent { selectedOption;
 jedi:  Jedi;

 constructor(private  dialog:  MatDialog) {
 this.jedi  = { name:  'Luke' }; }

 openDialog() {
 let  dialogRef  =  this.dialog.open(DialogComponent, {
 data: { jedi:  this.jedi } });

   dialogRef
 .afterClosed()
 .subscribe(result => {
 // will print 'save' if we pressed 'Yes' button
 console.log(result);
 });
}}

数据表

我们可以以不同的方式显示数据。以行和列的形式显示数据是快速获得概览的有效方式。但是,您可能需要按列对数据进行排序,以便快速聚焦于感兴趣的数据。此外,数据量可能非常大,需要通过分页的方式显示。Angular Material 通过提供以下组件来解决这些问题:

  • 表格:这以行和列的形式布置数据,并带有标题

  • 排序表格:这允许您对数据进行排序

  • 分页器:这允许您将数据分成页面,并在页面之间导航

应该说,在大多数情况下,当尝试向应用程序添加表格时,预期表格可以进行排序,并且数据可以进行分页,以免完全压倒用户。因此,让我们逐步看看如何实现所有这些。

表格

表格组件能够让我们以列和行的形式呈现数据。我们需要做以下工作才能让表格组件正常运行:

  1. 在我们的根模块中导入和注册MatTableModule

  2. 构建我们打算显示的数据。

  3. 定义我们表格的标记。

首先要做的是导入必要的模块,可以通过以下代码轻松完成:

import {MatTableModule} from '@angular/material';

@NgModule({
 imports: [MatTableModule]
})

在这一点上,我们开始构建我们的数据并创建MatTableDataSource类的一个实例。代码如下:

// app/jedi.model.ts
export class interface Jedi {
 name: string;
 side: string;
}

// app/table.example.component.ts
@Component({
 selector: 'example-table',
 template : `
 <div>
 <mat-table  #table  [dataSource]="tableSource"  matSort>
 // header 'Name' <ng-container  matColumnDef="name"> <mat-header-cell  *matHeaderCellDef  mat-sort-header> Name</mat-header-cell> <mat-cell  *matCellDef="let element"> {{element.name}} 
 </mat-cell>
 </ng-container>

 // header 'Side'
 <ng-container  matColumnDef="side">
 <mat-header-cell  *matHeaderCellDef  mat-sort-header> Side </mat-header-cell>
 <mat-cell  *matCellDef="let element"> {{element.side}} 
 </mat-cell>
 </ng-container>

 <mat-header-row  *matHeaderRowDef="displayedColumns"></mat-header-row>
 <mat-row  *matRowDef="let row; columns: displayedColumns;"></mat-row>
 </mat-table>
 <mat-paginator  #paginator  [pageSize]="2"  [pageSizeOptions]="[1, 5, 10]">
 </mat-paginator>
</div>
 `
})
export class ExampleTableComponent {
 jediSource:  Array<Jedi>; tableSource:  MatTableDataSource<Jedi>; displayedColumns:  string[];

 constructor() { this.displayedColumns  = ['name', 'side']; this.jediSource  = [{ name:  'Yoda', side:  'Good' }, {
 name:  'Darth', side:  'Evil' }, {
 name:  'Palpatine', side:  'Evil' }];

   this.tableSource  =  new  MatTableDataSource<Jedi>(this.jediSource**);**
 } }

值得注意的是,我们如何从对象数组构建了一个MatTableDataSource实例。我们将在标记中使用这个实例,并将其指定为数据源。接下来要做的是构建支持这个表格的标记。代码如下:

<mat-table #table [dataSource]="tableSource">
 // header 'Name'
 <ng-container  matColumnDef="name"> <mat-header-cell *matHeaderCellDef> Name </mat-header-cell> <mat-cell *matCellDef="let element"> {{element.name}} **</mat-cell>** </ng-container>

 // header 'Side'
 <ng-container  matColumnDef="side"> <mat-header-cell *matHeaderCellDef> Side </mat-header-cell> <mat-cell *matCellDef="let element"> {{element.side}} </mat-cell> </ng-container>

  <mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row> <mat-row *matRowDef="let row; columns: displayedColumns;"**></mat-row>** </mat-table>

我们在先前的代码中指出了几个值得关注的地方。表格的列是通过创建一个包含mat-header-cellng-container元素来构建的,其中定义了标题,以及一个mat-cell,我们在其中说明了应该放入哪些数据。在代码中稍后的mat-header-row元素使我们能够指出列应该出现的顺序。我们可以在先前的代码片段中看到,这实际上只是一个字符串数组。最后,通过mat-row元素,我们简单地显示表格的所有行。最终结果应该是这样的:

排序

先前的图表构成了一个漂亮的表格,但缺少一个非常标准的功能,即排序功能。我们期望通过点击标题,它将分别按升序和降序排序,并且能够识别常见的数据类型,如字符串和整数,并正确排序这些数据。好消息是,这非常容易实现。我们需要做以下工作来确保我们的表格可以排序:

  1. 导入并注册MatSortModule

  2. 创建一个类型为MatSortViewChild并将其分配给dataSources的 sort 属性。

  3. matSortHeader指令添加到应该能够排序的标题上。

我们通过向根模块添加以下代码来完成第一步:

import { MatSortModule } from  '@angular/material/sort'; @NgModule({
 imports: [MatSortModule]
})

然后,我们进入我们的组件,并添加MatSort ViewChild并将其分配给 sort 属性,如前所述:

import { Component, ViewChild } from  '@angular/core'; import { MatTableDataSource, MatSort } from  "@angular/material"; 
@Component({
  selector:  'table-demo', templateUrl:  './table.demo.component.html', styleUrls: ['./table.demo.component.css'] })
export  class  AppComponent {  @ViewChild(MatSort) sort:  MatSort**;** jediSource:  Array<Jedi>; tableSource:  MatTableDataSource<Jedi>; displayedColumns:  string[];

 constructor() { this.displayedColumns  = ['name', 'side']; this.jediSource  = [{ name:  'Yoda', side:  'Good' }, {
 name:  'Darth', side:  'Evil' },
 {
 name:  'Palpatine', side:  'Evil' }];

 this.tableSource  =  new  MatTableDataSource<Jedi>(this.jediSource);
 }

  ngAfterViewInit() { this.tableSource.sort  =  this.sort; }

在这一点上,我们需要修复标记,然后排序应该可以工作。我们需要对标记进行的更改只是简单地将matSort指令应用到整个表格,以及对每个应该可以排序的标题应用mat-sort-header。现在标记的代码如下:

<mat-table #table [dataSource]="tableSource" **matSort**>
 // header 'Name'
 <ng-container  matColumnDef="name"> <mat-header-cell *matHeaderCellDef mat-sort-header> Name </mat-header-cell> <mat-cell *matCellDef="let element"> {{element.name}} </mat-cell**>** </ng-container>

 // header 'Side'
 <ng-container  matColumnDef="side"> <mat-header-cell *matHeaderCellDef **mat-sort-header**> Side </mat-header-cell> <mat-cell *matCellDef="let element"> {{element.side}} </mat-cell> </ng-container>

 <mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
 <mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row> </mat-table>

现在 UI 应该通过列Name旁边的箭头指示数据排序的方向,如下图所示:

分页

到目前为止,我们的表格看起来相当不错。除了显示数据外,它甚至可以进行排序。不过,我们意识到在大多数情况下,表格的数据通常相当长,这导致用户要么不得不滚动,要么逐页浏览数据。我们可以通过分页元素来解决后一种选项。要使用它,我们需要做以下工作:

  1. 导入并注册MatPaginatorModule

  2. paginator ViewChild实例分配给数据源的 paginator 属性。

  3. 在标记中添加一个mat-paginator元素。

从我们列表中的第一项开始,我们需要将以下代码添加到我们的根模块中:

import {MatPaginatorModule} from '@angular/material/paginator';

@NgModule({
 imports: [MatPaginatorModule]
})

之后,我们需要将paginator属性分配给我们的tableSource.paginator,就像之前描述的那样。代码如下所示:

import { Component, ViewChild } from  '@angular/core'; import { MatTableDataSource, MatSort } from  "@angular/material"; 
@Component({
  selector:  'table-demo', template: ` <mat-table #table [dataSource]="tableSource" **matSort**>

 // header 'Name'
 <ng-container  matColumnDef="name"> <mat-header-cell *matHeaderCellDef mat-sort-header> Name</mat-header-cell> <mat-cell *matCellDef="let element"> {{element.name}} 
 </mat-cell**>** </ng-container>

 // header 'Side'
 <ng-container  matColumnDef="side"> <mat-header-cell *matHeaderCellDef **mat-sort-header**> Side</mat-header-cell> <mat-cell *matCellDef="let element"> {{element.side}}</mat-cell> </ng-container>

 <mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
 <mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row>
 </mat-table>
 `, styleUrls: ['./table.demo.component.css'] })
export  class  AppComponent { @ViewChild(MatSort) sort: MatSort;  **@ViewChild(MatPaginator) paginator: MatPaginator;** jediSource:  Array<Jedi>; tableSource:  MatTableDataSource<Jedi>; displayedColumns:  string[]; 
 constructor() { this.displayedColumns  = ['name', 'side']; this.jediSource  = [{ name:  'Yoda', side:  'Good' }, {
 name:  'Darth', side:  'Evil' },
 {
 name:  'Palpatine', side:  'Evil' }];

 this.tableSource  =  new  MatTableDataSource<Jedi>(this.jediSource);
 }

 ngAfterViewInit() {
 this.tableSource.sort = this.sort; this.tableSource.paginator = paginator; }

我们剩下的部分就是改变标记,应该有以下改变(加粗的变化):

<div>
 <mat-table #table [dataSource]="tableSource"  matSort>

 // header 'Name'
 <ng-container  matColumnDef="name"> <mat-header-cell *matHeaderCellDef  mat-sort-header> Name</mat-header-cell> <mat-cell *matCellDef="let element"> {{element.name}}</mat-cell> </ng-container>

 // header 'Side'
 <ng-container  matColumnDef="side"> <mat-header-cell *matHeaderCellDef  mat-sort-header> Side</mat-header-cell> <mat-cell *matCellDef="let element"> {{element.side}} </mat-cell> </ng-container>

 <mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row> <mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row>
 </mat-table>

 <mat-paginator #paginator [pageSize]="2" [pageSizeOptions]="[1, 5, 10]">
 </mat-paginator>
</div>  

在这里,我们清楚地表明,我们标记的唯一添加是底部的mat-paginator元素。在这里,我们指定了我们的视图引用,还有页面大小以及我们应该能够切换到的页面。

总结

我们努力解释了什么是 Material Design,这是一种以纸张和墨水为主题的设计语言。之后,我们提到了最著名的 Material Design 实现。

接下来,我们把大部分注意力放在了 Angular Material 上,这是专为 Angular 设计的 Material Design 实现,以及它由不同的组件组成。我们亲自动手解释了如何安装它,设置它,甚至如何使用不同的表单控件和输入按钮。

我们还花了一些时间来介绍组件的其他方面,比如布局、导航、模态框和表格数据。希望你已经阅读了本章,并发现你现在对 Material Design 有了一般的了解,特别是对 Angular Material,你可以确定它是否适合你的下一个 Angular 应用程序。

第十二章:使用 Angular 为组件添加动画

如今,动画是现代用户体验设计的基石之一。远非仅仅是用来美化 UI 的视觉点缀,它们已经成为视觉叙事的重要组成部分。动画为以非侵入方式传达信息铺平了道路,成为了一个廉价但强大的工具,用来告知用户在与我们的应用程序交互时发生的基础过程和事件。一旦一个动画模式变得普遍,并且受众接受它作为现代标准,我们就获得了一个无价的工具,用来增强我们应用程序的用户体验。动画是与语言无关的,不一定绑定在单一设备或环境(Web、桌面或移动),并且当明智地使用时,它们对于观看者来说是令人愉悦的。换句话说,动画是不可或缺的,而 Angular 2 对现代视觉开发的这一方面有着强烈的承诺。

随着所有现代浏览器都支持 CSS3 的新特性来处理动画,Angular 2 提供了支持通过一个简单但强大的 API 来实现命令式动画脚本。本章将涵盖几种实现动画效果的方法,从利用纯粹的 CSS 来应用基于类的动画,到实现脚本例程,其中 Angular 完全负责处理 DOM 转换。

在这一章中,我们涵盖以下主题:

  • 使用纯粹的 CSS 创建动画

  • 利用ngClass指令来更好地使用类命名动画

处理转换

  • 查看 Angular 内置的 CSS 钩子,为每个定义样式

转换状态

  • 引入动画触发器,并在模板中声明性地将这些动画附加到元素上

  • 使用AnimationBuilder API 来为组件添加动画

  • 设计处理动画的指令

使用纯粹的 CSS 创建动画

基于 CSS 的动画的诞生是现代网页设计中的重要里程碑。在那之前,我们过去常常依赖 JavaScript 来通过复杂和繁琐的脚本来操作 DOM 元素,通过间隔、超时和各种循环来实现我们网页应用中的动画。不幸的是,这既不可维护也不可扩展。

然后,现代浏览器采用了最近引入的 CSS 变换、过渡、关键帧和动画属性带来的功能。这在 Web 交互设计的背景下成为了一个改变游戏规则的因素。虽然像Microsoft Internet Explorer这样的浏览器对这些技术的支持远非理想,但其他可用的浏览器(包括微软自己的 Edge)对这些 CSS API 提供了全面的支持。

MSIE 仅在版本 10 及以上提供对这些动画技术的支持。

我们假设您对 CSS 动画的工作原理有广泛的了解,因此本书的范围显然不包括这些技术的覆盖。总之,我们可以强调 CSS 动画通常是通过这些方法之一或两者的组合来实现的:

  • 过渡属性将作为 DOM 元素应用的所有或部分 CSS 属性的观察者。每当这些 CSS 属性中的任何一个发生变化时,DOM 元素不会立即采用新值,而是会经历一个稳定的过渡到新状态。

  • 命名关键帧动画,我们在一个唯一的名称下定义了一个或多个 CSS 属性演变的不同步骤,稍后将在给定选择器的动画属性中填充,能够设置额外的参数,如延迟、动画缓动的持续时间或动画的迭代次数。

正如我们在前面提到的两种情况中所看到的,使用带有动画设置的 CSS 选择器是与动画相关的一切的起点,这就是我们现在要做的。让我们构建一个花哨的脉冲动画,以模拟装饰我们的番茄钟的位图中的心跳样式效果。

这次我们将使用基于关键帧的动画,因此我们将首先在单独的样式表中构建实际的 CSS 例程。整个动画基于一个简单的插值,我们将一个对象放大 10%,然后再缩小到初始状态。然后将这个基于关键帧的缓动命名并包装在一个名为pulse的 CSS 类中,它将在一个无限循环中执行动画,每次迭代需要 1 秒完成。

所有用于实现此动画的 CSS 规则将存储在外部样式表中,作为计时器小部件组件的一部分,位于timer feature文件夹内:

// app/timer/timer.widget.component.css

@keyframes pulse {
 0% {
 transform: scale3d(1, 1, 1);
 }
 50% {
 transform: scale3d(1.1, 1.1, 1.1);
 }
 100% {
 transform: scale3d(1, 1, 1);
 }
}

.pulse {
 animation: pulse 1s infinite;
}

.task { background: red;
 width: 30px;
 height: 30px;
 border-radius: 50%; }

从这一点开始,任何带有此类名称的 DOM 元素都将像心脏一样跳动。这种视觉效果实际上是一个很好的提示,表明元素正在进行某种操作,因此在倒计时进行时将其应用于计时器小部件中的主图标位图将有助于传达当前正在以生动的方式进行某种活动的感觉。

谢天谢地,我们有一个很好的方法,只在倒计时活动时应用这样的效果。我们在TimerWidgetComponent模板中使用isPaused绑定。将其值绑定到NgClass指令,以便仅在组件暂停时渲染类名,这样就可以打开计时器小部件代码单元文件,并添加对我们刚刚创建的样式表的引用,并按照之前描述的方式应用指令:

// app/timer/timer.widget.component.ts

import { Component } from  "@angular/core"; 
@Component({
 selector:  'timer-widget',
 styleUrls: ['timer.widget.component.css'],
 template: `
 <div  class="text-center">
 <div  class="task"  [ngClass]="{ pulse: !isPaused }"></div>
 <h3><small>{{ taskName }}</small></h3>
 <h1> {{ minutes }}:{{ seconds  |  number: '2.0' }} </h1>
 <p>
 <button  (click)="togglePause()"  class="btn btn-danger">
 Toggle
 </button>
 </p>
 </div>` })
export  class  TimerWidgetComponent { taskName:  string  =  'task';
 minutes  =  10;
 seconds  =  20;
 isPaused  =  true; 
 togglePause() {
 this.isPaused  =  !this.isPaused;
 }
}

就是这样!运行我们的番茄钟应用程序,点击顶部的Timer链接,进入计时器组件页面,并在启动倒计时后实时检查视觉效果。停止并再次恢复,以查看效果仅在倒计时活动时应用。

介绍 Angular 动画

动画触发器的想法是,当某个属性从一个状态变化到另一个状态时,您可以显示动画。要定义触发器,我们首先需要安装和导入我们需要的库,具体来说是BrowserAnimationsModule,所以让我们这样做。

我们通过输入以下命令来安装库:

npm install @angular/animations --save

现在让我们导入并设置带有BrowsersAnimationsModule的模块:

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

@NgModule({
 imports: [BrowserAnimationsModule]
})

之后,是时候导入一堆我们需要设置触发器本身的结构:

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

导入的结构具有以下功能:

  • trigger:这定义了组件中动画目标的属性;它需要一个名称作为第一个参数,以及作为第二个参数的状态和转换数组

  • state:这定义了属性值以及它应该具有的 CSS 属性;您需要为属性可以假定的每个值定义一个这样的属性

  • transition:这定义了当您从一个属性值转到另一个属性值时动画应该如何播放

  • animate:当我们从一个状态值转移到下一个状态时,执行定义的动画

我们的第一个触发器

让我们快速看一下动画触发器可能是什么样子,然后解释各个部分:

animations: [
 trigger('sizeAnimation', [
 state('small', style({
 transform:'scale(1)', 
 backgroundColor: 'green'
 })),
 state('large', style({
 transform: '(1.4)', 
 backgroundColor: 'red'
 })),
 transition('small => large', animate('100ms ease-in')),
 transition('large => small', animate('100ms ease-out'))
 ])
]

animations数组是我们添加到组件对象中的内容,比如模板或styleUrls。在animations数组中有许多触发器定义。trigger需要一个名称和一个项目数组,就像这样:

trigger('name', [ ... items ]) 

这些项目要么是状态定义,要么是过渡。有了这个知识,更容易理解我们正在看的是什么。目前,我们选择将触发器称为animationName。它定义了两个状态和两个过渡。状态表示值已更改为此状态,我们相应地通过执行样式来做出反应,这就是为什么代码应该被理解为以下内容:

state(
 'when I change to this value', 
 style({ /*apply these style changes*/ }))

请注意,样式属性是驼峰式命名,而不是短横线命名,例如,写backgroundColor而不是background-color,就像你在 CSS 中习惯的那样。

看看我们的例子,我们是在说以下内容:

  • 如果有人触发sizeAnimation并且值设置为small,那么应用这个变换:scale(1)backgroundColor: 'green'

  • 如果有人触发sizeAnimation并且值设置为large,那么应用这个变换:scale(1.4)backgroundColor: 'red'

剩下的两个项目是两个transition调用。这指示动画如何以平滑的方式应用动画。您可以这样阅读过渡定义:

transition(' when I go from this state > to this state ', animate( 100ms ease-in))

因此,当我们从一个状态转换到另一个状态时,我们应用一个缓动函数,并且还定义了动画应该执行多长时间。让我们回顾一下我们的代码:

transition('small => large', animate('100ms ease-in')),
transition('large => small',animate('100ms ease-out'))

我们这样解释它:

  • 当我们从值smalllarge时,执行100ms的动画并使用ease-in函数

  • 当我们从值largesmall时,执行100ms的动画并使用ease-out函数

连接部分

现在我们已经完全解析了我们的trigger语句,我们还有最后一件事要做,那就是将触发器连接到它需要查看的属性。所以,让我们在模板中添加一些代码:

@Component({
 selector:  'example', template:  `
 <button  (click)="makeBigger()">Make bigger</button>
 <button  (click)="makeSmaller()">Make smaller</button>
 <p  class="animate"  [@sizeAnimation]="state">some text</p>
 `
 ,
 animations:  [
 trigger('sizeAnimation', [
 state('small',  style({
 transform:'scale(1)',
 backgroundColor:  'green'})),
 state('large',  style({
 transform:  'scale(1.4)',
 backgroundColor :  'red'
 })),
 transition('small => large',  animate('100ms ease-in')),
 transition('large => small',animate('100ms ease-out'))
 ])
 ],
 styles: [`
 .animate  {
 background:  green;
 width:  100px;
 }
 `] })
export  class  ExampleComponent  {
 state:  string;

 makeBigger() {
 this.state  =  'large';
 }

 makeSmaller() {
 this.state  =  'small';
 }
}

现在,要注意的关键是[@animationName]='state';这是我们说触发器应该查看组件state属性,我们已经知道state应该具有哪些值才能触发动画。

通配符状态

我们为触发器定义的状态不仅仅是两个。在某些情况下,无论我们来自什么状态值,应用转换都更有意义。对于这些情况,有通配符状态。使用通配符状态很容易。您只需转到转换定义并用*替换状态值,如下所示:

transition('* => larger') 

这意味着无论我们之前处于什么状态,当我们的state属性假定一个larger值时,转换都会发生。

空状态

void状态不同于通配符状态。Void 与说如果一个元素之前不存在,那么它就有void值是一样的。在退出时,它假定一个值。因此,转换调用的定义如下:

transition(' void => *') 

通过向我们的模板添加一些代码,让我们使其更真实:

<button  (click)="abraCadabra()">Abracadabra</button> <button  (click)="poof()">Poof</button>   <p  class="elem"  [@flyInOut]="state"  *ngIf="showMe">
 Show me
</p>

在这里,我们添加了一个按钮,设置为调用abraCadabra()来显示元素,以及一个调用poof()的按钮,它将隐藏元素。现在让我们向组件添加一些代码:

trigger('flyInOut', [
 state('in', style({transform:  'translateX(0)'})), transition('void => *', [ style({transform:  'translateX(-100%)'}), animate(500) ]),
 transition('* => void', [ animate(500, style({transform:  'translateX(200%)'})) ])
])

这个触发器定义如下,如果一个元素从不存在到存在,void => *,那么从-100%x位置0进行动画。当从存在到不存在时,将其移出画面,将其移动到x位置200%

现在是最后一部分,我们的组件代码:

abraCadabra() { this.state  =  'in'; this.showMe  =  true; }

poof() {
 this.showMe  =  false; }  

在这里,我们可以看到调用abraCadabra()方法将触发状态'in',并将布尔值showMe设置为true将触发转换void => *。这解释了void状态的主要目的,即在先前元素不存在时使用。

动画回调

有时候,您可能想要知道何时启动动画以及动画何时完成。在这里有好消息;我们可以找出这一点,并执行我们需要的任何代码。

我们需要做的是监听触发器的startdone属性,如下所示:

[@sizeAnimation.start]=animationStarted($event)
[@sizeAnimation.done]="animationDone($event)"
[@sizeAnimation]="state"

当然,我们需要向我们的组件添加代码,使其看起来像这样:

animationStarted() {
 // animation started, execute code
}

animationDone() {
 // animation ended, execute code
}

使用 AnimationBuilder 对组件进行动画处理

到目前为止,我们已经介绍了如何使用纯 CSS 进行动画处理,或者通过定义一个触发器来连接到我们的标记。还有另一种更程序化的动画处理方法。这种方法使用一个名为AnimationBuilder的服务。使这种方法起作用涉及一些关键因素,即:

  • AnimationBuilder:这是一个我们注入的服务;它有一个名为build的方法,当调用时创建一个AnimationFactory的实例

  • AnimationFactory:这是在AnimationBuilder实例上调用build()的结果;它已经被赋予了许多样式转换和一个或多个动画

  • AnimationPlayer:播放器需要一个元素来应用动画指令

让我们解释这些要点,这样我们就能理解发生了什么,什么时候发生,以及对哪个元素发生了什么。首先,我们需要将AnimationBuilder注入到组件的构造函数中,并且还需要注入一个elementRef实例,这样我们就有了动画的目标,就像这样:

import { AnimationBuilder } from '@angular/animations';

@Component({})
export class Component {
 constructor(
 private animationBuilder:AnimationBuilder,
 private elementRef: ElementRef
 ) {
 }
}

在这一点上,我们可以访问animationBuilder的一个实例,并准备好设置我们的样式转换和动画,所以让我们接着做:

ngOnInit() {
 const animationFactory = this.animationBuilder.build([
 style({ width : '0px' }), // set starter value
 animate(1000, style({ width:  '100px' }))  // animate to this new value ])
}

在这里,我们定义了一个将宽度初始设置为0px的转换,以及一个将宽度在1秒内设置为100px的动画。我们还将调用animationBuilder.build()的结果分配给了一个名为 animation 的变量,它的类型是AnimationFactory。下一步是创建一个动画播放器的实例,并决定要将此动画应用到哪个元素:

const  elem  =  this.elementRef.nativeElement.querySelector('.text'); const animationPlayer  =  animationFactory.create(elem);

我们在这里做了两件事;首先,我们指出了模板中我们想要应用动画的元素。接下来,我们通过调用animation.create(elem)并将我们的元素作为输入来创建一个动画播放器的实例。现在缺少的是在 UI 中创建元素,这样我们的querySelector()才能找到它。我们需要创建一个带有 CSS 类文本的元素,这正是我们在下面的代码中所做的:

@Component({
 template : `
 <p class="text">Animate this text</p> 
 ` 
})
export class ExampleComponent {}

最后一步是在我们的动画播放器实例上调用play()方法:

animationPlayer.play();

在浏览器中播放动画。您可以通过向我们的style({})方法调用添加更多属性来轻松扩展动画,就像这样:

ngOnInit() {
 const animation = this.builder.build([
 style({ 
 width : '0px', 
 height : '0px' 
 }),   // set starter values
 animate(1000, style({ 
 width:  '100px', 
 height:  '40px' })) ])
}

总之,AnimationBuilder是一种强大的方式,可以创建可重用的动画,您可以轻松地将其应用到您选择的元素上。

创建一个可重用的动画指令

到目前为止,我们已经看到了如何创建AnimationBuilder以及如何使用它来随意地以编程方式创建和应用动画。使其可重用的一种方法是将其包装在一个指令中。创建指令是一件相当简单的事情,我们已经做过几次了;我们需要记住的是,我们的指令将被应用到一个元素上,而这个元素就是我们的动画将要被应用到的东西。让我们总结一下我们需要在列表中做的事情:

  1. 创建一个指令。

  2. 注入AnimationBuilder

  3. 创建我们的动画。

  4. 创建一个动画播放器。

  5. 播放动画。

这个事情清单与我们解释AnimationBuilder的工作原理非常相似,而且应该是这样的;毕竟,指令是这里唯一的新东西。让我们定义我们的指令和动画;实际上并没有太多要做的。

@Directive({
 selector : '[highlight]'
})
export class HighlightDirective implements OnInit {
 constructor( 
 private elementRef: ElementRef,
 private animationBuilder: AnimationBuilder 
 ) {}

 ngOnInit() {
 const animation = this.animationBuilder.build([
 style({ width: '0px' }),
 animate(1000, style({ width : '100px' }))
 ]);
 const player = animation.create( this.elementRef.nativeElement );
 player.play();
 }
}

这就是我们需要的一切。现在我们可以将我们的指令应用到任何元素上,就像这样:

<p highlight>animate me</p>

总结

我们只是触及了处理动画的表面。要了解你可以做的一切,请阅读官方文档angular.io/guide/animations

在本章中,我们开始学习如何定义原始的 CSS 动画。然后,我们解释了动画触发器以及如何以声明方式将定义的动画附加到元素上。然后,我们看了如何以编程方式定义动画并随意将其附加到元素上。我们最后做的事情就是将我们的程序化动画打包到一个指令中。关于动画还有很多要学习的,但现在你应该对存在的 API 有基本的了解以及何时使用它们。走出去,让你的应用充满生机,但记住,少即是多。

第十三章:Angular 中的单元测试

前几章的辛勤工作已经变成了一个我们可以引以为傲的工作应用程序。但是,我们如何确保未来的可维护性?一套全面的自动化测试层将成为我们的生命线,一旦我们的应用程序开始扩展,我们就必须减轻由新功能与已经存在的功能相冲突而引起的错误的影响。

测试(更具体地说,单元测试)应该由开发人员在项目开发过程中进行。然而,在本章中,我们将简要介绍测试 Angular 模块的所有复杂性,因为项目已经处于成熟阶段。

在本章中,您将看到如何实现测试工具,以对应用程序的类和组件进行适当的单元测试。

在本章中,我们将:

  • 看看测试的重要性,更具体地说,单元测试

  • 构建测试管道的测试规范

  • 为具有或不具有依赖项的组件设计单元测试

  • 对我们的路由进行测试

  • 为服务实现测试,模拟依赖项和存根

  • 拦截 XHR 请求并提供模拟响应以进行精细控制

  • 了解如何测试指令作为没有视图的组件

  • 介绍其他概念和工具,如 Karma、代码覆盖工具

和端到端(E2E)测试

为什么我们需要测试?

什么是单元测试?如果您已经熟悉单元测试和测试驱动开发,可以安全地跳过下一节。如果不熟悉,让我们说单元测试是工程哲学的一部分,它支持高效和敏捷的开发过程,通过在代码开发之前为代码添加一层自动化测试。核心概念是每一段代码都有自己的测试,并且这两段代码都是由正在开发该代码的开发人员构建的。首先,我们设计针对我们要交付的模块的测试,检查其输出和行为的准确性。由于模块还没有实现,测试将失败。因此,我们的工作是以使模块通过自己的测试的方式构建模块。

单元测试是相当有争议的。虽然人们普遍认为测试驱动开发对于确保代码质量和随时间的维护是有益的,但并不是每个人在日常实践中都进行单元测试。为什么呢?嗯,在开发代码的同时构建测试有时可能会感觉像是一种负担,特别是当测试的规模比它旨在测试的功能部分还要大时。

然而,支持测试的论点比反对它的论点多得多:

  • 构建测试有助于更好的代码设计。我们的代码必须符合测试要求,而不是相反。在这种意义上,如果我们试图测试现有的一段代码,并且在某个时候发现自己被阻止了,那么这段代码很可能设计不良,并展示出需要重新思考的复杂接口。另一方面,构建可测试的模块可以帮助早期发现对其他模块的副作用。

  • 重构经过测试的代码是防止在后期引入错误的生命线。任何开发都意味着随着时间的推移而发展,每次重构都会引入错误的风险,这些错误可能只会在我们应用程序的另一个部分中出现。单元测试是确保我们在早期捕捉错误的好方法,无论是在引入新功能还是更新现有功能时。

  • 构建测试是记录我们的代码 API 和功能的好方法。当一个不熟悉代码库的人接手开发工作时,这将成为无价的资源。

这只是一些论点,但你可以在网上找到无数关于测试代码好处的资源。如果你还不感到满意,不妨试一试。否则,让我们继续我们的旅程,看看测试的整体形式。

单元测试的解剖结构

有许多不同的方法来测试一段代码,但在本章中,我们将看看测试的解剖结构,它由什么组成。测试任何代码的第一件事是测试框架。测试框架应该提供用于构建测试套件的实用函数,每个套件包含一个或多个测试规范。那么这些概念是什么呢?

  • 测试套件:套件为一组测试创建了一个逻辑分组。例如,一个套件可以是产品页面的所有测试。

  • 测试规范:这是单元测试的另一个名称。

以下显示了一个测试文件的样子,我们在其中使用了一个测试套件,并放置了许多相关的测试。我们选择的框架是 Jasmine。在 Jasmine 中,describe()函数帮助我们定义一个测试套件。describe()方法以名称作为第一个参数,以函数作为第二个参数。在describe()函数内部有许多对it()方法的调用。it()函数是我们的单元测试;它以测试名称作为第一个参数,以函数作为第二个参数:

// Test suite
describe('A math library', () => { 
 // Test spec
 it('add(1,1,) should return 2', () => {
 // Test spec implementation goes here
 });
});

每个测试规范检查套件描述参数中描述的功能的特定功能,并在其主体中声明一个或多个期望。每个期望都取一个值,我们称之为期望值,并通过匹配器函数与实际值进行比较,该函数检查期望值和实际值是否相匹配。这就是我们所说的断言,测试框架将根据这些断言的结果通过或失败规范。代码如下:

// Test suite
describe('A math library', () => {
 // Test spec
 it('add(1,1) should return 2', () => {
 // Test assertion
 expect(add(1,1,)).toBe(2);
 });

 it('subtract(2,1)', () =>{
 //Test assertion
 expect(subtract(2,1)).toBe(1);
 })
});

在前面的例子中,add(1,1)将返回实际值,这个值应该与toBe()匹配器函数中声明的期望值相匹配。

在前面的例子中值得注意的是添加了第二个测试,测试了我们的subtract()函数。我们可以清楚地看到,这个测试处理了另一个数学运算,因此将这两个测试分组在一个套件下是有意义的。

到目前为止,我们已经了解了测试套件以及如何根据其功能对测试进行分组。此外,我们已经了解了调用要测试的代码并断言它是否按照你所想的那样做的概念。然而,单元测试还有更多值得了解的概念,即设置和拆卸功能。设置功能是在测试运行之前设置代码的功能。这是一种使代码更清晰的方式,因此您可以专注于调用代码和断言。拆卸功能是设置功能的相反,专门用于拆卸最初设置的内容;本质上,这是一种在测试后进行清理的方式。让我们看看这在实践中是什么样子,使用 Jasmine 框架的代码示例。在 Jasmine 中,beforeEach()方法用于设置功能;它在每个单元测试之前运行。afterEach()方法用于运行拆卸逻辑。代码如下:

describe('a Product service', () => {
 let productService;

 beforeEach(() => {
 productService = new ProductService(); 
 });

 it('should return data', () => {
 let actual = productService.getData();
 assert(actual.length).toBe(1);
 });

 afterEach(() => {
 productService = null; 
 });
});

我们可以在前面的代码中看到beforeEach()函数负责实例化productService,这意味着测试只需要关心调用生产代码和断言结果。这使得测试看起来更清晰。不过,实际上,测试往往需要进行大量的设置,有一个beforeEach()函数可以使测试看起来更清晰;最重要的是,它往往使添加新测试变得更容易,这是很棒的。最终你想要的是经过充分测试的代码;编写和维护这样的代码越容易,对你的软件就越好。

在 Angular 中进行测试的介绍

单元测试的解剖部分,我们熟悉了单元测试及其一般概念,比如测试套件、测试规范和断言。掌握了这些知识,现在是时候深入了解在 Angular 中进行单元测试了。不过,在我们开始为 Angular 编写测试之前,我们将首先介绍 Angular CLI 中存在的工具,以使单元测试成为一种愉快的体验。在 Angular 中进行单元测试时,了解它由哪些主要部分组成是很重要的。在 Angular 中,这些部分包括:

  • Jasmine,测试框架

  • Angular 测试工具

  • Karma,一个用于运行单元测试的测试运行器,还有其他功能

  • Protractor,Angular 的 E2E 测试框架

配置和设置

在配置方面,当使用 Angular CLI 时,你不需要做任何事情来使其工作。一旦你搭建一个项目,就可以运行你的第一个测试,它就会工作。当你深入研究 Angular 中的单元测试时,你需要了解一些概念,这些概念可以提高你测试不同构造的能力,比如组件和指令。Angular CLI 使用 Karma 作为测试运行器。关于 Karma 我们需要知道的是它使用一个karma.conf.js文件,一个配置文件,其中指定了很多东西,比如:

  • 增强你的测试运行器的各种插件。

  • 在哪里找到要运行的测试?应该说的是,通常在这个文件中有一个 files 属性,指定了在哪里找到应用程序和测试。然而,对于 Angular CLI,这个规范是在另一个名为src/tscconfig-spec.json的文件中找到的。

  • 你选择的覆盖工具的设置,一个衡量你的测试覆盖生产代码程度的工具。

  • 报告者,在控制台窗口、浏览器或其他方式中报告每个执行的测试。

  • 用于运行测试的浏览器:例如,Chrome 或 PhantomJS。

使用 Angular CLI,您很可能不需要自己更改或编辑此文件。知道它的存在以及它为您做了什么是很好的。

Angular 测试工具

Angular 测试工具有助于创建一个测试环境,使得为各种构造编写测试变得非常容易。它由TestBed类和各种辅助函数组成,位于@angular/core/testing命名空间下。随着本章的进行,我们将学习这些是什么以及它们如何帮助我们测试各种构造。我们将很快介绍最常用的概念,以便在我们进一步介绍它们时您对它们有所了解:

  • TestBed类是最重要的概念,它创建自己的测试模块。实际上,当您测试一个构造以将其从所在的模块中分离出来并重新连接到TestBed创建的测试模块时。TestBed类有一个configureTestModule()辅助方法,我们用它来设置所需的测试模块。TestBed还可以实例化组件。

  • ComponentFixture是一个包装组件实例的类。这意味着它具有一些功能,并且它有一个成员,即组件实例本身。

  • DebugElement,就像ComponentFixture一样,充当包装器。但是,它包装的是 DOM 元素,而不是组件实例。它还有一个注入器,允许我们访问已注入到组件中的服务。稍后会详细介绍这个主题。

这是对我们的测试环境、使用的框架和库以及一些重要概念的简要概述,我们将在接下来的部分中大量使用它们。

组件测试简介

到目前为止,我们进行任何 Angular 操作的通常方法是使用 Angular CLI。处理测试也不例外。Angular CLI 让我们创建测试,调试它们并运行它们;它还让我们了解我们的测试覆盖了代码及其许多场景的程度。让我们快速看一下如何使用 Angular CLI 进行单元测试,并尝试理解默认情况下给我们的内容。

如果您想跟着本章的代码进行编写,可以使用旧的 Angular 项目并为其添加测试,或者创建一个新的独立项目,如果您只想专注于实践测试。选择权在您。

如果您选择创建一个新项目,然后键入以下内容进行搭建:

ng new AngularTestDemo
// go make coffee :)
cd AngularTestDemo
ng serve

Angular CLI 已经设置好了测试,所以我们需要做的唯一的事情就是跟随它的步伐并添加更多的测试,但让我们首先检查一下我们已经得到了什么,并学习一些很棒的命令,以使测试工作更容易。

我们想要做的第一件事是:

  • 调查 Angular CLI 给我们的测试

  • 运行测试

通过查看搭建的directory/app,我们看到了以下内容:

app.component.ts
app.component.spec.ts

我们看到一个组件被声明,连同一个单元测试。这意味着我们可以对我们的组件进行测试,这是非常好的消息,因为它节省了我们一些输入。

让我们看一下给我们的测试:

import { TestBed, async } from  '@angular/core/testing'; import { AppComponent } from  './app.component'; 
describe('AppComponent', () => {  beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ AppComponent
 ],
 }).compileComponents();
 }));

 it('should create the app', async(() => { const  fixture  =  TestBed.createComponent(AppComponent); const  app  =  fixture.debugElement.componentInstance; expect(app).toBeTruthy();
 }));

 it(`should have as title 'app works!'`, async(() => { const  fixture  =  TestBed.createComponent(AppComponent); const  app  =  fixture.debugElement.componentInstance; expect(app.title).toEqual('app works!'); }));

 it('should render title in a h1 tag', async(() => { const  fixture  =  TestBed.createComponent(AppComponent); fixture.detectChanges(); const  compiled  =  fixture.debugElement.nativeElement;
 const actual = compiled.querySelector('h1').textContent; expect(actual).toContain('app works!');
  }));
});

这是很多代码,但我们会逐步分解它。我们看到在文件的开头有测试设置,编写了三个不同的测试。让我们先看一下设置阶段:

beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ AppComponent
 ],
 }).compileComponents(); }));

在这里我们调用beforeEach(),就像我们在 Jasmine 测试中通常做的那样,以便在每个测试实际发生之前运行代码。在beforeEach()中,我们调用TestBed.configureTestingModule()方法,带有一个对象作为参数。这个对象类似于我们给NgModule作为参数的对象。这意味着我们可以利用我们对NgModule以及如何设置 Angular 模块的知识,并将其应用到如何设置测试模块,因为它实际上是一样的。从代码中可以看出,我们指定了一个包含AppComponent的声明数组。对于NgModule来说,这意味着AppComponent属于该模块。最后,我们调用了compileComponents()方法,设置完成。

那么compileComponents()是做什么的呢?根据它的名称,它编译了在测试模块中配置的组件。在编译过程中,它还内联外部 CSS 文件以及外部模板。通过调用compileComponents(),我们也关闭了进一步配置测试模块实例的可能性。

我们测试文件的第二部分是测试。看一下第一个测试:

it('should create the app', async(() => {
&gt; const  fixture  =  TestBed.createComponent(AppComponent); const  app  =  fixture.componentInstance; expect(app).toBeTruthy(); }));

我们看到我们调用了TestBed.createComponent(AppComponent),这返回一个类型为ComponentFixture<AppComponent>的对象。我们可以通过调用这个对象进一步进行交互:

const  app  =  fixture.debugElement.componentInstance;

这给了我们一个组件实例,这就是当我们从以下类实例化一个对象时得到的东西:

@Component({})
export class AppComponent {
 title: string;
}

第一个测试只是想验证我们能否创建一个组件,expect条件测试的就是这个,即expect(app)是真实的,意思是它是否被声明;而事实上它是。

对于第二个测试,我们实际上是要调查我们的组件是否包含我们认为的属性和值;所以测试看起来像这样:

it(`should have as title 'app works!'`, async(() => { const  fixture  =  TestBed.createComponent(AppComponent); const  app  =  fixture.debugElement.componentInstance; expect(app.title).toEqual('app works!'); }));

现在,这个测试创建了一个组件,但它也调用了fixture.detectChanges,这告诉 Angular 强制进行变更检测。这将确保构造函数中的代码和任何ngInit()(如果存在)都被执行。

通过组件规范,我们期望在创建组件时title属性应该被设置,就像这样:

@Component({})
export class AppComponent {
 title: string = 'app works!'
}

这正是第二个测试正在测试的:

expect(app.title).toEqual('app works!');

让我们看看如何通过在app.component.ts中添加一个字段来扩展它的功能:

@Component({})
export class AppComponent {
 title: string;
 description: string;
 constructor() {
 this.title = 'app works'
    this.description ='description';
 }
}

我们添加了描述字段,并用一个值进行了初始化;我们将测试这个值是否设置为我们的属性。因此,我们需要在我们的测试中添加额外的expect条件,所以测试现在看起来像这样:

it(`should have as title 'app works!'`, async(() => { const  fixture  =  TestBed.createComponent(AppComponent); const  app  =  fixture.debugElement.componentInstance; expect(app.title).toEqual('app works!');
   **expect(app.description).toEqual('description');** }));

正如你所看到的,我们有了额外的expect条件,测试通过了,这正是应该的。不过,不要只听我们的话;让我们使用 node 命令运行我们的测试运行程序。我们通过输入以下内容来做到这一点:

npm test

这将执行测试运行程序,应该看起来像这样:

这意味着我们知道如何扩展我们的组件并对其进行测试。作为奖励,我们现在也知道如何运行我们的测试。让我们看看第三个测试。它有点不同,因为它测试模板:

it('should render title in a h1 tag', async(() => { const  fixture  =  TestBed.createComponent(AppComponent); fixture.detectChanges(); const  compiled  =  fixture.debugElement.nativeElement; expect(compiled.querySelector('h1').textContent).toContain('app works!'); }));

我们不再与fixture.debugElement.componentInstance交谈,而是与fixture.debugElement.nativeElement交谈。这将允许我们验证预期的 HTML 标记是否与我们认为的一样。当我们可以访问nativeElement时,我们可以使用querySelector并找到我们在模板中定义的元素并验证它们的内容。

通过查看我们得到的测试,我们获得了很多见解。我们现在知道以下内容:

  • 我们通过调用TestBed.configureTestingModule()并传递一个类似于我们传递给NgModule的对象来设置测试

  • 我们调用TestBed.createComponent(<Component>)来获取对组件的引用

  • 我们调用debugElement.componentInstance来获取到实际的组件,我们可以测试组件对象上应该存在的属性的存在和值

  • 我们调用debugElement.nativeElement来获取对nativeElement的引用,现在可以开始验证生成的 HTML

  • 我们还学会了如何通过输入npm test在浏览器中运行我们的测试

fixture.debugElement.nativeElement指向 HTML 元素本身。当我们使用querySelector()方法时,实际上使用的是 Web API 中可用的方法;这不是 Angular 方法。

具有依赖关系的组件测试

我们已经学到了很多,但让我们面对现实,我们构建的任何组件都不会像我们在前面的部分中编写的那样简单。几乎肯定会至少有一个依赖项,看起来像这样:

@Component({})
export class ExampleComponent {
 constructor(dependency:Dependency) {}
}

我们有不同的方法来处理测试这样的情况。不过有一点是清楚的:如果我们正在测试组件,那么我们不应该同时测试服务。这意味着当我们设置这样的测试时,依赖项不应该是真正的东西。在进行单元测试时,处理这种情况有不同的方法;没有一种解决方案比另一种严格更好:

  • 使用存根意味着我们告诉依赖注入器注入我们提供的存根,而不是真正的东西

  • 注入真正的东西,但附加一个间谍,调用我们组件中的方法

无论采用何种方法,我们都确保测试不会执行诸如与文件系统交谈或尝试通过 HTTP 进行通信等副作用;使用这种方法,我们是隔离的。

使用存根来替换依赖项

使用存根意味着我们完全替换了以前的东西。指导TestBed进行这样的操作就像这样简单:

TestBed.configureTestingModule({
 declarations: [ExampleComponent]
 providers: [{ 
 provide: DependencyService, 
 useClass: DependencyServiceStub 
 }]
});

我们像在NgModule中那样定义一个providers数组,并给它一个指出我们打算替换的定义的列表项,然后给它替换;那就是我们的存根。

现在让我们构建我们的DependencyStub看起来像这样:

class DependencyServiceStub {
 getData() { return 'stub'; }
}

就像使用@NgModule一样,我们能够用我们自己的存根覆盖我们的依赖的定义。想象一下我们的组件看起来像下面这样:

import { Component } from  '@angular/core'; import { DependencyService } from  "./dependency.service"; 
@Component({
 selector:  'example',
 template: `
 <div>{{ title }}</div>
 `
})
export  class  ExampleComponent { title:  string; 
 constructor(private  dependency:  DependencyService) {
 this.title  =  this.dependency.getData();
 }
}

在构造函数中传递依赖的一个实例。通过正确设置我们的测试模块,使用我们的存根,我们现在可以编写一个像这样的测试:

it(`should have as title 'stub'`, async(() => { const  fixture  =  TestBed.createComponent(AppComponent); const  app  =  fixture.debugElement.componentInstance; expect(app.title).toEqual('stub'**);** }));

测试看起来正常,但在组件代码中调用依赖项时,我们的存根会代替它并做出响应。我们的依赖应该被覆盖,正如你所看到的,expect(app.title).toEqual('stub')假设存根会回答,而它确实会回答。

对依赖方法进行间谍监视

前面提到的使用存根的方法并不是在单元测试中隔离自己的唯一方法。我们不必替换整个依赖项,只需替换组件正在使用的部分。替换某些部分意味着我们指出依赖项上的特定方法,并对其进行间谍监视。间谍是一个有趣的构造;它有能力回答你想要的问题,但你也可以看到它被调用了多少次以及使用了什么参数,因此间谍可以为你提供更多关于发生了什么的信息。让我们看看我们如何设置一个间谍:

beforeEach(() => {
 TestBed.configureTestingModule({
 declarations: [ExampleComponent],
 providers: [DependencyService]
 });

 dependency = TestBed.get(DependencyService);

 spy = spyOn( dependency,'getData');
 fixture = TestBed.createComponent(ExampleComponent);
})

现在你可以看到,实际的依赖项被注入到了组件中。之后,我们获取了组件的引用,即我们的 fixture 变量。然后,我们使用TestBed.get('Dependency')来获取组件内的依赖项。在这一点上,我们通过spyOn( dependency,'getData')来对其getData()方法进行间谍监视。

然而,这还不够;我们还需要指示间谍在被调用时如何回应。让我们来做到这一点:

spyOn(dependency,'getData').and.returnValue('spy value');

现在我们可以像往常一样编写我们的测试:

it('test our spy dependency', () => {
 var component = fixture.debugElement.componentInstance;
 expect(component.title).toBe('spy value');
});

这符合预期,我们的间谍回应得当。还记得我们说过间谍不仅能够回应一个值,还能够检查它们是否被调用以及使用了什么吗?为了展示这一点,我们需要稍微改进我们的测试,并检查这个扩展功能,就像这样:

it('test our spy dependency', () => {
 var component = fixture.debugElement.componentInstance;
 expect(spy.calls.any()).toBeTruthy();
})

您还可以检查它被调用的次数,使用spy.callCount,或者它是否被调用以及具体的参数:spy.mostRecentCalls.argsspy.toHaveBeenCalledWith('arg1', 'arg2')。请记住,如果您使用间谍,请确保它通过您需要进行这些检查来支付自己的代价;否则,您可能还不如使用存根。

间谍是 Jasmine 框架的一个特性,而不是 Angular。建议感兴趣的读者在tobyho.com/2011/12/15/jasmine-spy-cheatsheet/上进一步研究这个主题。

异步服务

很少有服务是良好且行为端正的,就是它们是同步的意义上。大部分时间,您的服务将是异步的,而从中返回的最可能是一个 observable 或一个 promise。如果您正在使用 RxJS 与Http服务或HttpClient,它将是一个 observable,但如果使用fetchAPI,它将是一个 promise。这两种处理 HTTP 的方法都很好,但 Angular 团队将 RxJS 库添加到 Angular 中,以使开发人员的生活更轻松。最终由您决定,但我们建议使用 RxJS。

Angular 已经准备好了两种构造来处理测试时的异步场景。

  • async()whenStable():这段代码确保任何承诺都会立即解决;尽管看起来更同步

  • fakeAsync()tick():这段代码做了 async 的事情,但在使用时看起来更同步

让我们描述一下async()whenStable()的方法。当我们调用服务时,我们的服务现在已经成熟并且正在执行一些异步操作,比如超时或 HTTP 调用。无论如何,答案不会立即传达给我们。然而,通过结合使用async()whenStable(),我们可以确保任何承诺都会立即解决。想象一下我们的服务现在是这样的:

export class AsyncDependencyService {
 getData(): Promise<string> {
 return new Promise((resolve, reject) => {
 setTimeout(() => { resolve('data') }, 3000);
 })
 }
}

我们需要更改我们的 spy 设置,以返回一个 promise 而不是返回一个静态字符串,就像这样:

spy = spyOn(dependency,'getData')
.and.returnValue(Promise.resolve('spy data'));

我们确实需要在我们的组件内部进行更改,就像这样:

import { Component, OnInit } from  '@angular/core'; import { AsyncDependencyService } from  "./async.dependency.service"; @Component({
 selector:  'async-example',
 template: `
 <div>{{ title }}</div>
 `
})
export  class  AsyncExampleComponent { title:  string; 
 constructor(private  service:  AsyncDependencyService) {
 this.service.getData().then(data  =>  this.title  =  data);
 }
}

此时,是时候更新我们的测试了。我们需要做两件事。我们需要告诉我们的测试方法使用async()函数,就像这样:

it('async test', async() => {
 // the test body
})

我们还需要调用fixture.whenStable(),以确保 promise 有足够的时间来解决,就像这样:

import { TestBed } from  "@angular/core/testing"; import { AsyncExampleComponent } from  "./async.example.component"; import { AsyncDependencyService } from  "./async.dependency.service"; 
describe('test an component with an async service', () => { let  fixture;

 beforeEach(() => { TestBed.configureTestingModule({
 declarations: [AsyncExampleComponent],
 providers: [AsyncDependencyService]
 });

 fixture  =  TestBed.createComponent(AsyncExampleComponent);
 });

 it('should contain async data', async () => { const  component  =  fixture.componentInstance;
    fixture.whenStable.then(() => {
 fixture.detectChanges();
 expect(component.title).toBe('async data');
 });
 });
});

这种做法可以正常工作,但感觉有点笨拙。还有另一种方法,使用fakeAsync()tick()。基本上,fakeAsync()替换了async()调用,我们摆脱了whenStable()。然而,最大的好处是我们不再需要将断言语句放在 promise 的then()回调中。这给我们提供了看起来是同步的代码。回到fakeAsync(),我们需要调用tick(),它只能在fakeAsync()调用内部调用,就像这样:

it('async test', fakeAsync() => {
 let component = fixture.componentInstance;
 fixture.detectChanges();
 fixture.tick();
 expect(component.title).toBe('spy data');
});

正如您所看到的,这看起来更清晰;您想要使用哪个版本进行异步测试取决于您。

测试管道

管道基本上是实现PipeTransform接口的类,因此公开了通常是同步的transform()方法。因此,管道非常容易测试。我们将从测试一个简单的管道开始,创建一个测试规范,就像我们提到的,紧挨着它的代码单元文件。代码如下:

import { Pipe, PipeTransform } from  '@angular/core'; 
@Pipe({
  name:  'formattedpipe' })
export  class  FormattedPipe  implements  PipeTransform { transform(value:  any, ...args:  any[]):  any { return  "banana"  +  value; }
}

我们的代码非常简单;我们取一个值并添加banana。为它编写一个测试同样简单。我们需要做的唯一一件事就是导入管道并验证两件事:

  • 它是否有一个 transform 方法

  • 它产生了预期的结果

以下代码为前面列出的每个要点编写了一个测试:

import FormattedTimePipe from './formatted-time.pipe';
import { TestBed } from  '@angular/core/testing';

describe('A formatted time pipe' , () => {
 let fixture;
 beforeEach(() => {
 fixture = new FormattedTimePipe();
 }) // Specs with assertions
  it('should expose a transform() method', () => {
 expect(typeof formattedTimePipe.transform).toEqual('function');
 });

  it('should produce expected result', () => {
 expect(fixture.transform( 'val' )).toBe('bananaval');
 })
});

在我们的beforeEach()方法中,我们通过实例化管道类来设置 fixture。在第一个测试中,我们确保transform()方法存在。接着是我们的第二个测试,断言transform()方法产生了预期的结果。

使用 HttpClientTestingController 模拟 HTTP 响应

一旦你理解了如何开始模拟 HTTP,就会变得非常简单。让我们首先看一下我们打算测试的服务:

import { HttpClient } from  '@angular/common/http';  import { Injectable } from  '@angular/core'; @Injectable() export  class  JediService { apiUrl:  string  =  'something'; constructor(private  http:  HttpClient) {} getJedis() { return  this.http.get(`/api/jedis`); }
}

在测试我们的服务时,有两个重要的参与者:

  • HttpTestingController,我们可以指示这个类监听特定的 URL 以及在被调用时如何做出响应

  • 我们要测试的是我们的服务;我们真正想要做的唯一一件事就是调用它

与所有测试一样,我们有一个设置阶段。在这里,我们需要导入包含我们的HttpTestingController的模块HttpClientTestingModule。我们还需要告诉它为我们提供服务,就像这样:

import { 
 HttpClientTestingModule, 
 HttpTestingController 
} from '@angular/common/http/testing';
import { JediService } from  './jedi.service'; describe('testing our service', () => {
 beforeEach(() => {
 TestBed.configureTestingModule({
     imports: [HttpClientTestingModule],
 providers: [JediService] 
 });
 });
});

下一步是设置测试,通过设置我们需要获取我们的服务的实例以及HttpTestingController来设置。我们还需要指示后者期望的 API 调用类型,并提供适当的模拟数据以做出响应:

it('testing getJedis() and expect a list of jedis back', () => {
 // get an instance of a Jedi service and HttpTestingController
  const jediService = TestBed.get(JediService);
  const http = TestBed.get(HttpTestingController);

 // define our mock data
 const  expected  = [{ name:  'Luke' }, { name:  'Darth Vader' }]; let  actual  = [];

 // we actively call getJedis() on jediService, 
 // we will set that response to our 'actual' variable jediService.getJedis().subscribe( data  => {  expect(data).toEqual(expected**);**  });

 /* 
 when someone calls URL /api/jedis 
 we will resolve that asynchronous operation 
 with .flush() while also answering with 
 'expected' variable as response data
 */
 http.expectOne('/api/jedis').flush(expected);  });

我们为前面的代码片段提供了内联注释,但只是为了再次描述发生了什么,我们的测试有三个阶段:

  1. 安排:这是我们获取JediService实例以及HttpTestingController实例的地方。我们还通过设置expected变量来定义我们的模拟数据。

  2. 行动:我们通过调用jediService.getJedis()来执行测试。这是一个 observable,所以我们需要订阅它的内容。

  3. 断言:我们通过调用flush(expected)来解析异步代码,并断言我们通过进行断言expect(actual).toEqual(expected)得到了正确的数据。

如您所见,伪造对 HTTP 的调用非常容易。让我们展示整个单元测试代码:

import { HttpTestingController, 
 HttpClientTestingModule } from  '@angular/common/http/testing/'; import { TestBed } from '@angular/core/testing'; import { JediService } from  './jedi-service';    describe('a jedi service', () => {  beforeEach(() =>  TestBed.configureTestingModule({ imports: [HttpClientTestingModule], providers: [JediService] }));

 it('should list the jedis', () => { const  jediService  =  TestBed.get(JediService); const  http  =  TestBed.get(HttpTestingController); // fake response
 const  expected  = [{ name:  'Luke' }, { name:  'Darth Vader' }]; let  actual  = []; jediService.getJedis().subscribe( data  => { expect(data).toEqual(expected); });

 http.expectOne('/api/jedis').flush(expected);  });
});

输入和输出

到目前为止,我们已经测试了组件,即我们已经测试了组件上的简单属性以及如何处理依赖项,同步和异步,但组件还有更多内容。组件还可以具有应该进行测试的输入和输出。因为我们的上下文是绝地武士,我们知道绝地武士通常有方法可以转向光明面或黑暗面。想象一下我们的组件在绝地管理系统的上下文中使用;我们希望能够将绝地武士转向黑暗面,也能够将其带回光明面。我们讨论的当然是切换功能。

因此,想象一下我们有一个看起来像这样的组件:

@Component({
 selector : 'jedi-detail'
 template : `
 <div class="jedi" 
 (click)="switchSide.emit(jedi)">
 {{ jedi.name }} {{ jedi.side }}
 </div>
 `
})
export class JediComponent {
 @Input() jedi:Jedi;
 @Output() switchSide = new EventEmitter<Jedi>(); 
}

测试这样一个组件应该以两种方式进行:

  • 我们应该验证我们的输入绑定是否正确设置

  • 我们应该验证我们的输出绑定是否正确触发,以及接收到的内容

@Input开始,对其进行测试如下:

describe('A Jedi detail component', () => {
 it('should display the jedi name Luke when input is assigned a Jedi object', () => {
 const component = fixture.debugElement.componentInstance;
 component.jedi = new Jedi(1, 'Luke', 'Light');
 fixture.detectChanges();
 expect(component.jedi.name).toBe('Luke');
 });
});

这里值得注意的是我们对fixture.detectChanges()的调用,这确保了绑定发生在组件中。

让我们现在来看看如何测试@Output。我们需要做的是以某种方式触发它。我们需要点击模板中定义的 div。为了接收switchSide属性发出的值,我们需要订阅它,所以我们需要做两件事:

  • 找到div元素并触发点击

  • 订阅数据的发射并验证我们是否收到了jedi对象

至于获取 div 的引用,可以很容易地完成,如下所示:

const elem = fixture.debugElement.query(By.css('.jedi'));
elem.triggerEventHandler('click', null);

对于第二部分,我们需要订阅switchSide Observable 并捕获数据,如下所示:

it('should invoke switchSide with the correct Jedi instance, () => {
 let selectedJedi;
 // emitting data
 component.switchSide.subscribe(data => {
 expect(data.name).toBe('Luke');
 });
 const elem = fixture.debugElement.query(By.css('.jedi'));
 elem.triggerEventHandler('click', null);
})

通过这段代码,我们能够间接触发输出的发射,通过点击事件监听输出,通过订阅。

测试路由

就像组件一样,路由在我们的应用程序提供高效用户体验方面发挥着重要作用。因此,测试路由变得至关重要,以确保无缝的性能。我们可以对路由进行不同的测试,并且需要针对不同的场景进行测试。这些场景包括:

  • 确保导航指向正确的路由地址

  • 确保正确的参数可用,以便您可以为组件获取正确的数据,或者过滤组件需要的数据集

  • 确保某个路由最终加载预期的组件

测试导航

让我们看看第一个要点。要加载特定路由,我们可以在Router类上调用navigateToUrl(url)方法。一个很好的测试是确保当组件中发生某种状态时,会调用这样的方法。例如,可能会有一个创建组件页面,在保存后应该导航回到列表页面,或者缺少路由参数应该导航回到主页。在组件内部进行程序化导航有多个很好的理由。让我们看一些组件中的代码,其中进行这样的导航:

@Component({})
export class ExampleComponent {
 constructor(private router: Router) {}

 back() {
 this.router.navigateByUrl('/list'); 
 }
}

在这里我们可以看到调用back()方法将执行导航。为此编写测试非常简单。测试应该测试navigateToUrl()方法是否被调用。我们的方法将包括在路由服务中存根化以及在navigateToUrl()方法本身上添加一个间谍。首先,我们定义一个存根,然后指示我们的测试模块使用该存根。我们还确保我们创建了组件的一个实例,以便稍后在其上调用back()方法,就像这样:

describe('Testing routing in a component using a Stub', () => {
 let component, fixture;

 class RouterStub {
 navigateByUrl() {}
 } 

 beforeEach(() => {
 TestBed.configureTestingModule({
 declarations: [ExampleRoutingComponent],
 providers: [{
 // replace 'Router' with our Stub
        provide: Router, useClass: RouterStub
 }]
 }).compileComponents();
 })

 beforeEach(() => {
 fixture  =  TestBed.createComponent(Component); component  =  fixture.debugElement.componentInstance;
 })
 // ... test to be defined here
}

接下来我们需要做的是定义我们的测试并注入路由实例。一旦我们这样做了,我们就可以在navigateToUrl()方法上设置一个间谍:

import { inject } from '@angular/core/testing';

it('test back() method', inject([Router], router: Router) => {
 const spy = spyOn(router, 'navigateByUrl');
 // ... more to come here
})

现在在这一点上,我们希望测试测试的是方法是否被调用。编写这样的测试可以被视为防御性的。和测试正确性一样重要的是,编写测试以确保另一个开发人员,或者你自己,不会删除应该工作的行为。因此,让我们添加一些验证逻辑,以确保我们的间谍被调用:

import { inject } from '@angular/core/testing';

it('test back() method', inject([Router], (router: Router)) => {
 const spy = spyOn(router, 'navigateByUrl');
 // invoking  our back method that should call the spy in turn
 component.back();
 expect(spy.calls.any()).toBe(true);
}))

整个测试现在是用存根替换原始的路由服务。我们在存根上的navigateByUrl()方法上附加了一个间谍,最后我们断言该间谍在调用back()方法时被调用如预期:

describe('Testing routing in a component', () => {
 class RouterStub {
 navigateByUrl() {}
 }

 beforeEach(() => {
 TestBed.configureTestingModule({
 providers: [{
 provide: Router, useClass: RouterStub
 }]
 }).compileComponents();
 });

 beforeEach(() => {
 fixture  =  TestBed.createComponent(Component); component  =  fixture.debugElement.componentInstance;
 });

 it('should call navigateToUrl with argument /list', () => {
 spyOn(router, 'navigateByUrl');
 /* 
 invoking our back() method 
 that should call the spy in turn
 */
 component.back();
 expect(router.navigateByUrl).toHaveBeenCalledWithArgs('/list');
 })
})

通过 URL 测试路由

到目前为止,我们已经通过在导航方法上放置间谍来测试路由,并且在具有路由参数的情况下,我们必须为 Observable 构建一个模拟。不过,还有另一种方法,那就是让路由发生,然后调查我们最终停留在哪里。假设我们有以下情景:我们在列表组件上,想要导航到详细组件。导航发生后,我们想要调查我们所处的状态。让我们首先定义我们的列表组件:

import { Router } from  '@angular/router'; import { Component, OnInit } from  '@angular/core'; 
@Component({
  selector:  'list-component', template : `` })
export  class  ListComponent {
 constructor(private  router:  Router) {}

 goToDetail() { this.router.navigateByUrl('detail/1'); }  }  

如您所见,我们有一个goToDetail()方法,如果调用,将会将您导航到一个新的路由。但是,为了使其工作,我们需要在模块文件中正确设置路由,如下所示:

const  appRoutes:  Routes  = [ { path:  'detail/:id', component:  DetailComponent } ];

@NgModule({
 ...
 imports: [ BrowserModule, FormsModule, HttpClientModule, RouterModule.forRoot(appRoutes**),** TestModule
 ],
 ...  })
export  class  AppModule { }

重要的部分在于appRoutes的定义和在导入数组中调用RouterModule.forRoot()

现在是定义此测试的时候了。我们需要与一个名为RouterTestingModule的模块进行交互,并且我们需要为该模块提供应该包含的路由。RouterTestingModule是一个非常合格的路由存根版本,因此从原则上讲,与创建自己的存根没有太大区别。不过,可以这样看待,您可以创建自己的存根,但随着您使用越来越多的高级功能,使用高级存根很快就会得到回报。

我们将首先指示我们的RouterTestingModule,当命中detail/:id路由时,它应该加载DetailComponent。这与我们如何从我们的root模块设置路由没有太大区别。好处在于,我们只需要为我们的测试设置我们需要的路由,而不是应用中的每一个路由都需要设置:

beforeEach(() => { TestBed.configureTestingModule({ imports: [ RouterTestingModule.withRoutes([{ 
 path:  'detail/:id', 
 component:  DetailComponent }]) 
 ], declarations: [ListComponent, DetailComponent] });
});

完成设置后,我们需要在测试中获取组件的副本,以便调用将我们从列表组件导航出去的方法。您的测试应该如下所示:

it('should navigate to /detail/1 when invoking gotoDetail()', async() => { let  fixture  =  TestBed.createComponent(ListComponent); let  router =  TestBed.get(Router); let  component  =  fixture.debugElement.componentInstance;
  fixture.whenStable().then(() => { expect(router.url).toBe('/detail/1');
 });
  **component.goToDetail();** }) 

这里重要的部分是调用使我们导航的方法:

component.goToDetail();

以及我们验证我们的路由器确实已经改变状态的断言:

expect(router.url).toBe('/detail/1');

测试路由参数

您将拥有一些执行路由的组件和一些被路由到的组件。有时,被路由到的组件会有一个参数,通常它们的路由看起来像这样:/jedis/:id。然后,组件的任务是挖出 ID 参数,并在匹配此 ID 的具体绝地武士上进行查找。因此,将调用一个服务,并且响应应该填充我们组件中的适当参数,然后我们可以在模板中显示。这样的组件通常看起来像这样:

import { ActivatedRoute, Router } from  '@angular/router'; import { Component, OnInit } from  '@angular/core'; import { Observable } from  'rxjs/Rx'; import { Jedi } from  './jedi.model'; import { JediService } from  './jedi.service';   @Component({
  selector:  'detail-component', templateUrl:  'detail.component.html' })
export  class ExampleRoutingParamsComponent{
 jedi: Jedi; constructor( private  router:  Router, private  route:  ActivatedRoute, private  jediService  :  JediService ) {
 route.paramMap.subscribe( p  => { const  id  =  p.get('id'); jediService.getJedi( id ).subscribe( data => this.jedi = data ); });
 }  }  

值得强调的是我们如何获取路由中的参数。我们与ActivatedRouter实例交互,我们将其命名为route,以及它的paramMap属性,这是一个可观察对象,如下所示:

route.paramMap.subscribe( p  => { const  id  =  p.get('id'); jediService.getJedi(id).subscribe( data => this.jedi = data ) })

那么我们想要测试什么呢?我们想知道,如果某个路由包含一个 ID 参数,那么我们的jedi属性应该通过我们的服务正确填充。我们不想进行实际的 HTTP 调用,因此我们的JediService需要以某种方式进行模拟,并且还有另一件使事情复杂化的事情,即route.paramMap也需要被模拟,而那个东西是一个可观察对象。

这意味着我们需要一种创建可观察对象存根的方法。这可能听起来有点令人生畏,但实际上并不是;多亏了Subject,我们可以很容易地做到这一点。Subject具有一个很好的能力,即我们可以订阅它,但我们也可以向它传递值。有了这个知识,让我们开始创建我们的ActivatedRouteStub

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

class ActivatedRouteStub {
 private subject: Subject<any>;

 constructor() {
 this.subject = new Subject();
 }

 sendParameters( params : {}) {
 this.subject.next(convertToParamMap(params)); // emitting data
 }

 get paramMap() {
 return this.subject.asObservable();
 }
}

现在,让我们解释一下这段代码,我们添加了sendValue()方法,以便它可以将我们给它的值传递给主题。我们公开了paramMap属性,作为一个可观察对象,这样我们就可以在主题发出任何值时监听它。但这如何与我们的测试相关呢?嗯,在存储阶段,我们希望在beforeEach()内调用存根的sendValue。这是我们模拟通过路由到达我们的组件并传递参数的一种方式。在测试本身中,我们希望监听路由参数何时被发送给我们,以便我们可以将其传递给我们的jediService。因此,让我们开始勾勒测试。我们将分两步构建测试:

  1. 第一步是通过传递ActivatedRouteStub来支持对ActivatedRoute的模拟。

  2. 第二步是设置jediService的模拟,确保拦截所有 HTTP 调用,并且当发生 HTTP 调用时我们能够用模拟数据做出响应。

首先,我们设置测试,就像我们迄今为止所做的那样,调用TestBed.configureTestingModule()并传递一个对象。我们提到我们已经为激活的路由构建了一个存根,并且我们需要确保提供这个存根而不是真正的ActivatedRoute。代码如下所示:

describe('A detail component', () => {
 let fixture, component, activatedRoute;

 beforeEach(() => {
 TestBed.configureTestingModule({
      providers: [{ 
 provide: ActivatedRoute, 
 useClass: ActivatedRouteStub 
 }, JediService] 
 })
 })
})

这意味着当我们的组件在构造函数中获取ActivatedRoute依赖注入时,它将注入ActivatedRouteStub,就像这样:

@Component({})
export class ExampleRoutingParamsComponent {
 // will inject ActivatedRouteStub 
 constructor(activatedRoute: ActivatedRoute) {} 
}

继续我们的测试,我们需要做三件事:

  • 实例化组件

  • 将路由参数传递给我们的ActivatedRouteStub,以便发出路由参数

  • 订阅ActivatedRouteStub,以便我们可以断言参数确实被发出

让我们将这些添加到我们的测试代码中:

beforeEach(() => {
 fixture = TestBed.createComponent(ExampleRoutingParamsComponent);
 component  =  fixture.debugElement.componentInstance; activatedRoute  =  TestBed.get(ActivatedRoute); })

现在我们已经设置好了 fixture、组件和我们的activatedRouteStub。下一步是将实际的路由参数传递给activatedRouteStub,并设置一个subscribe来知道何时接收到新的路由参数。我们在测试本身中执行这个操作,而不是在beforeEach()方法中,就像这样:

it('should execute the ExampleRoutingParamsComponent', () => {
 // listen for the router parameter
 activatedRoute.paramMap.subscribe(para  => { const  id  =  para.get('id');
 // assert that the correct routing parameter is being emitted expect(id).toBe(1);
 });
 // send the route parameter 
 activatedRoute.sendParameters({ id :  1 }); })

那么这对我们的组件意味着什么?在这个阶段我们测试了多少我们的组件?让我们看看我们的DetailComponent,并突出显示到目前为止我们测试覆盖的代码:

@Component({})
export class ExampleRoutingParamsComponent {
 constructor( activatedRoute: ActivatedRoute ) {
 activatedRoute.paramMap.subscribe( paramMap => {
 const id = paramMap.get('id');
 // TODO call service with id parameter
 })
 }
}

正如你所看到的,在测试中,我们已经覆盖了activatedRoute的模拟,并成功订阅了它。在组件和测试中都缺少的是要考虑到调用一个调用 HTTP 的服务。让我们首先将该代码添加到组件中,就像这样:

@Component({})
export class ExampleRoutingParamsComponent implements OnInit {
 jedi: Jedi;
 constructor(
 private activatedRoute: ActivatedRoute, 
 private jediService: JediService ) {}

 ngOnInit() { 
 this.activatedRoute.paramMap.subscribe(route => {
 const id = route.get('id')
 this.jediService.getJedi(id).subscribe(data => this.jedi = data);
 });
 }
}

在代码中,我们添加了Jedi字段以及对this.jediService.getJedi()的调用。我们订阅了结果,并将操作的结果分配给了Jedi字段。为这部分添加测试支持是我们在前面关于模拟 HTTP 的部分已经涵盖过的。重复这一点是很好的,所以让我们添加必要的代码到单元测试中,就像这样:

it('should call the Http service with link /api/jedis/1', () => {
 .. rest of the test remains the same

 const  jediService  =  TestBed.get(JediService); const  http  =  TestBed.get(HttpTestingController);

  // fake response
 const  expected  = { name:  'Luke', id:  1 }; let  actual  = {}; http.expectOne('/api/jedis/1').flush(expected);

 ... rest of the test remains the same })

我们在这里做的是通过从TestBed.get()方法请求JediService的副本。此外,我们要求一个HttpTestingController的实例。我们继续定义我们想要响应的预期数据,并指示HttpTestingController的实例应该期望调用/api/jedis/1,当发生这种情况时,预期的数据应该被返回。所以现在我们有一个测试,涵盖了测试ActivatedRoute参数的场景,以及 HTTP 调用。测试的完整代码如下:

import { Subject } from  'rxjs/Rx'; import { ActivatedRoute, convertToParamMap } from  '@angular/router'; import { TestBed } from  '@angular/core/testing'; import { HttpClientTestingModule, 
 HttpTestingController } from  "@angular/common/http/testing"; import { JediService } from  './jedi-service'; import { ExampleRoutingParamsComponent } from  './example.routing.params.component'; class  ActivatedRouteStub {  subject:  Subject<any>; constructor() { this.subject  =  new  Subject();
 }

 sendParameters(params: {}) {
 const  paramMap  =  convertToParamMap(params);  this.subject.next( paramMap ); }

 get  paramMap() { return  this.subject.asObservable(); }
}

describe('A detail component', () => { let  activatedRoute, fixture, component; beforeEach(async() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule ], declarations: [ ExampleRoutingParamsComponent ], providers: [
 { provide:  ActivatedRoute, useClass:  ActivatedRouteStub }, 
 JediService ] });
 })

 beforeEach(() => { fixture  =  TestBed.createComponent(ExampleRoutingParamsComponent); component  =  fixture.componentInstance; activatedRoute  =  TestBed.get(ActivatedRoute); });

 it('should call the Http service with the route /api/jedis/1 and should display the jedi name corresponding to the id number in the route', async() => { activatedRoute.paramMap.subscribe((para) => { const  id  =  para.get('id'); expect(id).toBe(1); });

 activatedRoute.sendParameters({ id :  1 }); const  http  =  TestBed.get(HttpTestingController); // fake response
 const  expected  = { name:  'Luke', id:  1 }; let  actual  = {}; http.expectOne('/api/jedis/1').flush(expected); fixture.detectChanges(); fixture.whenStable().then(() => { expect(component.jedi.name).toBe('Luke'); });
 });
});

那么我们从测试路由参数中学到了什么?由于我们需要创建我们的ActivatedRouteStub,所以有点麻烦,但总的来说,还是相当简单的。

测试指令

我们的单元测试 Angular 元素之旅的最后一站将涵盖指令。指令通常在整体形状上会相当简单,基本上就是没有附加视图的组件。指令通常与组件一起工作的事实给了我们一个很好的想法,该如何进行测试。

指令可以简单地表示为没有外部依赖项。它看起来像这样:

@Directive({
 selector: 'some-directive'
})
export class SomeDirective {
 someMethod() {}
}

测试很容易,你只需要从SomeDirective类中实例化一个对象。然而,你的指令可能会有依赖项,在这种情况下,我们需要通过将其附加到组件来隐式测试指令。让我们看一个例子。让我们首先定义指令,就像这样:

import { Directive, 
 ElementRef, 
 HostListener } from  '@angular/core'; 
@Directive({ selector:  '[banana]' }) export  class  BananaDirective { constructor(private  elementRef:  ElementRef) { } @HostListener('mouseover') onMouseOver() { this.elementRef.nativeElement.style.color  =  'yellow'; }

 @HostListener('mouseout') onMouseOut() { this.elementRef.nativeElement.style.color  =  'inherit';
 }
}

在这里,你看到的是一个简单的指令,如果我们悬停在上面,它会将字体颜色变成黄色。我们需要将它附加到一个组件上。让我们接下来定义一个元素,就像这样:

import { Component } from  '@angular/core'; @Component({
  selector:  'banana', template: ` <p  class="banana"  banana>hover me</p> `
})
export  class  BananaComponent  {}  

在这里,我们可以看到我们将元素作为属性添加到组件模板中定义的p标签中。

接下来,让我们来看看我们的测试。我们现在知道如何编写测试,特别是如何测试元素,所以下面的测试代码应该不会让你感到意外:

import { By } from  '@angular/platform-browser'; import { TestBed } from  "@angular/core/testing"; import { BananaComponent } from  './banana.component'; import { BananaDirective } from  './banana.directive'; describe('A banana directive', () => {  beforeEach(() => { TestBed.configureTestingModule({ declarations: [BananaDirective, BananaComponent] }).compileComponents(); });

 it('should set color property to yellow when mouseover event happens', () => { const  fixture  =  TestBed.createComponent(BananaComponent); const  element  =  fixture.debugElement.query(By.css('.banana')); element.triggerEventHandler('mouseover', null); fixture.detectChanges(); expect(element.nativeElement.style.color).toBe('yellow'); });
})

beforeEach()方法中,我们与TestBed交谈,配置我们的测试模块,并告诉它关于BananaDirectiveBananaComponent的信息,代码如下:

  beforeEach(() => { TestBed.configureTestingModule({ declarations: [ BananaDirective, BananaComponent ] }).compileComponents(); });

在测试本身中,我们再次使用TestBed来创建一个组件。然后,我们通过 CSS 类找到我们的元素。我们找到元素以便触发一个事件,即mouseover。触发mouseover事件将触发指令中的代码,使字体颜色变为黄色。触发事件后,我们可以使用这行代码来断言元素的字体颜色:

expect(element.nativeElement.style.color).toBe('yellow');

现在,这就是测试指令的简单方法,即使它有依赖关系。关键是,如果是这种情况,您需要一个元素来放置指令,并且您通过元素隐式测试指令。

前方的道路

这个最后的测试示例总结了我们对 Angular 单元测试的探索,但请记住,我们只是触及了皮毛。一般来说,测试 Web 应用程序,特别是 Angular 应用程序,会出现许多通常需要特定方法的情况。请记住,如果一个特定的测试需要繁琐和复杂的解决方案,那么我们可能面临着模块重新设计的一个好案例。

我们应该从这里走向何方?有几条路径可以增进我们对 Angular 中 Web 应用程序测试的知识,并使我们成为优秀的测试忍者。

在测试堆栈中引入代码覆盖率报告

我们如何知道我们的测试有多远地测试了应用程序?我们能确定我们没有留下任何未经测试的代码吗?如果有,它是否相关?我们如何检测超出当前测试范围的代码片段,以便更好地评估它们是否值得测试?

这些问题可以通过在应用程序测试堆栈中引入代码覆盖率报告来轻松解决。代码覆盖工具旨在跟踪我们单元测试层的范围,并生成一个教育性报告,告诉您测试规范的整体覆盖范围以及仍未覆盖的代码片段。

有几种工具可以在我们的应用程序中实施代码覆盖率分析,目前最流行的是 Blanket(blanketjs.org)和 Istanbul(gotwarlost.github.io/istanbul)。在这两种情况下,安装过程都非常快速和简单。

实施端到端测试

在本章中,我们看到了如何通过评估 DOM 的状态来测试 UI 的某些部分。这给了我们一个很好的想法,从最终用户的角度来看事物会是什么样子,但最终这只是一个经过推敲的猜测。

端到端(E2E)测试是一种测试 Web 应用程序的方法,使用自动化代理程序,可以按照用户的流程从开始到结束进行程序化测试。与单元测试的要求相反,这里并不关心代码实现的细微差别,因为 E2E 测试涉及从用户端点开始到结束测试我们的应用程序。这种方法允许我们以集成的方式测试应用程序。而单元测试侧重于每个部分的可靠性,E2E 测试评估整体拼图的完整性,发现单元测试经常忽视的组件之间的集成问题。

对于 Angular 框架的上一个版本,Angular 团队构建了一个强大的工具,名为 Protractor(www.protractortest.org/),其定义如下:

“端到端测试运行器,模拟用户交互,将帮助您验证 Angular 应用程序的健康状况。”

测试的语法会变得非常熟悉,因为它也使用 Jasmine 来组织测试规范。不幸的是,E2E 超出了本书的范围,但有几个资源可以帮助您扩展对这一主题的了解。在这方面,我们推荐书籍《Angular 测试驱动开发》,Packt Publishing,它提供了关于使用 Protractor 为我们的 Angular 应用程序创建 E2E 测试套件的广泛见解。

摘要

我们已经走到了旅程的尽头,这绝对是一个漫长但令人兴奋的旅程。在本章中,您看到了在我们的 Angular 应用程序中引入单元测试的重要性,单元测试的基本形式,以及为我们的测试设置 Jasmine 的过程。您还看到了如何为我们的组件、指令、管道、路由和服务编写强大的测试。我们还讨论了在掌握 Angular 过程中的新挑战。可以说前方仍有很长的道路要走,而且绝对是一个令人兴奋的道路。

本章的结束也意味着这本书的结束,但体验将超越其界限。Angular 仍然是一个相当年轻的框架,因此,它将为社区带来的所有伟大事物尚未被创造出来。希望您能成为其中的创造者之一。如果是这样,请让作者知道。

感谢您抽出时间阅读这本书。

第十四章:SystemJS

SystemJS 是一个模块加载器,可以在以下 GitHub 链接中找到github.com/SystemJS/SystemJS

它是建立在原始的 ES6 模块加载器 polyfill 之上的。它旨在解决在浏览器中加载模块的基本问题,目前除非浏览器得到一些来自库的帮助,否则是行不通的,比如 SystemJS。

在这个附录中,我们将涵盖:

  • SystemJS 本身

  • 一个实用的 SystemJS 示例,使用 Angular 的快速启动存储库

SystemJS 介绍

SystemJS 从上到下加载文件,然后从下到上实例化。不过,这意味着什么呢?这意味着如果你有一个名为Module1的文件需要加载,它依赖于Module2,那么Module1将首先被加载。加载完毕后,我们有了执行代码的部分,它采取相反的方向。在这种情况下,它将执行Module2,以获得它的一个实例并将其传递给Module1

SystemJS 的典型用法如下:

System.import('./file.js').then( file => // do something )

SystemJS 在处理脚本时会进行不同的步骤:

  1. 规范化文件路径:路径可以是相对的、绝对的和别名的,SystemJS 将所有这些转换为一个格式

  2. XHR 或提供它:当一个模块被要求时,可能会发生两种情况;如果它已经在之前被预加载过,它将从内部注册表中加载,或者会为它发出 XHR 请求

  3. 准备好使用:在最后一步中,模块将被执行,添加到注册表中,并解析其承诺

使用快速启动存储库快速入门

要开始使用quickstart存储库,你需要使用以下命令获取该项目的副本:

git clone https://github.com/angular/quickstart.git quickstart

这将从 GitHub 下载所有所需的文件,并将它们放在一个名为quickstart的目录中。现在,进入该目录:

cd quickstart

该项目将指定一堆它依赖的库。你需要安装这些库。这可以通过输入以下命令来完成:

npm install

最后,我们需要提供应用程序,也就是在浏览器中显示它。这可以通过输入以下命令来完成:

npm start

值得一提的是,该存储库使用 SystemJS 作为模块加载器和引导我们的 Angular 应用程序。

理解各个部分

获得 GitHub 存储库或使用脚手架工具是很好的。您可以快速开始,并且几乎立即感到高效。不过,这里有一个。如果出了问题,我们该如何解决?为了能够做到这一点,我们需要更好地了解底层发生了什么。

使用 SystemJS 设置任何 Angular 项目的基本概念

这些概念构成了您的应用程序的核心。它们将出现在每个项目中:

  • 起始网页

  • Node 包管理器npm

  • SystemJS

  • TypeScript 设置和 TypeScript 定义文件

  • Linting

让我们讨论这些概念,以介绍设置。

所有 Web 项目都需要一个起始网页。

Node.js 是服务器端的 JavaScript。在Angular 构建的上下文中,Node.js 被用来引入许多库(来自 npm)来帮助处理诸如打包、测试和最小化等任务。至少要对如何使用 Node.js 及其生态系统有一定了解是至关重要的。关于这一点的更详细描述将在接下来的小节中进行。

至于 SystemJS,它是一个模块打包工具。JavaScript 项目不再只是写在一个文件中;有时候,它们是由成千上万个文件组成的。这些文件之间的关系是通过使用模块系统来实现的,而 SystemJS 是众多模块打包工具之一。Angular 团队选择了 TypeScript 作为编写 Angular 应用程序的通用语言,这意味着我们需要正确设置 TypeScript 来编译它,并确保 TypeScript 知道如何使用 ES5 编写的依赖库。

最后,linting 是确保我们在编写代码时遵循最佳实践的过程,既为了一致性,也为了避免错误。

现在,让我们详细讨论这些概念。

起始网页 - index.html

这个文件的目的是呈现给 Web 服务器,最终将其渲染成一个应用程序。它将包含一些标记,但更重要的是我们应用程序运行所需的script标签。

index.html也包含了许多script标签。这些script标签是项目运行所需的。

核心文件 - Angular 依赖的文件

许多浏览器缺乏 ES2015 带来的一些功能。为了解决这个问题,我们可以通过添加一些称为 polyfill 的东西来增强我们的浏览器,以弥补这些缺失的功能。除了利用现代 JavaScript 的 polyfill 之外,Angular 还使用了一种全新的方式来检测应用程序中的变化,这是通过使用zone.js库来实现的。最后,Angular 团队决定使用 Rxjs 来处理 HTTP 请求。他们甚至进一步将其集成到许多其他方面,比如处理表单和路由。这三个东西构成了我们需要导入的核心功能,以使我们的应用程序正常工作。

core-js

这个文件将 ES2015 的功能带给了 ES5 浏览器。由于您将使用相当多的 ES2015 构造,这对一切都是必要的:

<script scr="node_modules/core-js/client/shim.min.js"></script>

zone.js

这个文件被 Angular 用来处理变化检测和数据绑定,没有这个库什么都不会工作:

<script scr="node_modules/zone.js/dist/zone.js"></script>

rxjs

RxJS 是 Angular 大量使用的异步库,用于处理从 HTTP 请求到表单和路由的所有内容。

SystemJS - 我们的模块加载器

SystemJS 是您用来处理模块加载的库,由两个链接组成:

  • SystemJS 核心文件

  • SystemJS 配置文件

前者是 SystemJS 运行所需的,后者是您指示 SystemJS 加载哪些文件以及找到您的应用程序和相关资产的位置。

这指出了核心 SystemJS 文件:

<script src="node_modules/SystemJS/dist/system.scr.js"></script>

这指出了如何配置 SystemJS。您需要调用这个文件SystemJS.config.js

<script src="SystemJS.config.js"></script>

查看SystemJS.config.js显示了以下配置调用:

System.config({
 paths: {
 // paths serve as alias
 'npm:': 'node_modules/'
 },
 // map tells the System loader where to look for things
 map: {
 // our app is within the app folder
 'app': 'app',
 // angular bundles
 '@angular/core': 'npm:@angular/core/bundles/core.umd.js',
 '@angular/common': 'npm:@angular/common/bundles/common.umd.js',
 '@angular/compiler': 'npm:@angular/compiler/bundles/compiler.umd.js',
 '@angular/platform-browser': 'npm:@angular/platform-browser/bundles/platform-browser.umd.js',
 '@angular/platform-browser-dynamic': 'npm:@angular/platform-browser-dynamic/bundles/platform-browser-dynamic.umd.js',
 '@angular/http': 'npm:@angular/http/bundles/http.umd.js',
 '@angular/router': 'npm:@angular/router/bundles/router.umd.js',
 '@angular/forms': 'npm:@angular/forms/bundles/forms.umd.js',
 // other libraries
 'rxjs': 'npm:rxjs',
 'angular-in-memory-web-api': 'npm:angular-in-memory-web-api/bundles/in-memory-web-api.umd.js'
 },
 // packages tells the System loader how to load when no filename and/or no extension
 packages: {
 app: {
 defaultExtension: 'js',
 meta: {
 './*.js': {
 loader: 'SystemJS-angular-loader.js'
 }
 }
 },
 rxjs: {
 defaultExtension: 'js'
 }
 }
});

看起来相当长而令人生畏,但让我们分解不同的部分,如下所示:

  • paths:系统文件的别名位置。值得注意的是,我们通过输入以下内容来创建对node_modules的别名:
 path: { 'npm:': 'node_modules/'}

这将在以后为我们服务,当我们需要提及应用程序需要的所有库时。

  • map:这是我们需要告诉 SystemJS 它可以在哪里找到所有部分的地方。

以下代码片段显示了以下内容:

    • 找到我们的应用程序的位置,名为 app 的键
  • 找到 Angular 文件的位置,名为@angular/...

  • 找到支持库的位置,这些库包括 angular 库(框架分成许多较小的库)以及上一节中提到的核心库

 map : {
 app : app,  // instruct that our app can be found in the app directory
 '@angular/core': 'npm:@angular/core/bundles/core.umd.js'
 // supporting libraries omitted for brevity
 }

在这里,我们可以看到我们在引用@angular/core时使用了别名npm,这意味着以下内容:

 'npm: @angular/core/bundles/core.umd.js'

使用以下完整路径:

 'node_modules/@angular/core/bundles/core.umd.js'
  • packages: 这是配置文件的最后部分。它指示应该首先加载应用程序文件夹中的哪些文件,也提供了defaultExtension

Node.js 设置 - package.json

package.json是 Node.js 项目的描述文件。它包括元数据信息,如nameauthordescription,但它还包含一个script属性,允许我们运行执行工作的脚本,例如:

  • 创建一个 bundle

  • 运行测试

  • 执行 linting

要运行script标签中的命令之一,您需要输入:

npm run <command>

您的应用程序将依赖于许多库来构建和运行。列在dependenciesdevDependencies中的库将通过您输入npm install来下载。

dependenciesdevDependencies中列出哪些库应该有一个语义上的区别。最终将帮助应用程序运行的任何内容都将最终出现在dependencies中,包括 Angular 库以及支持库。devDependencies有些不同;这里放置的内容更多是支持性质的。例如 TypeScript,Linter,测试库以及用于处理 CSS 和创建 bundle 本身的不同工具。

至于dependencies中的 angular 部分,这些都是纯 Angular 依赖项,用@angular表示:

  • @angular/common

  • @angular/compiler

  • @angular/core

  • @angular/forms

  • @angular/http

  • @angular/platform-browser

  • @angular/platform-browser-dynamic

  • @angular/router

其余的依赖项是我们在本节中提到的Angular 依赖的核心文件列表:

  • core-js

  • reflect-metadata.js

  • rxjs

  • system.js

  • zone.js

TypeScript 设置

tsconfig.json是 TypeScript 编译器将处理并确定编译应该如何发生的文件。

以下是基本设置:

target: 'es5',
module : 'commonjs',
emitDecoratorMetadata : true, // needed for compilation to work
experimentalDecorators : true // needed for compilation to work

如前面的代码注释中所述,emitDecoratorMetadataexperimentalDecorators需要设置为true,因为 Angular 大量使用这些功能。

摘要

本附录介绍了 SystemJS,并描述了它如何处理文件以及以何种顺序处理文件,因为它是一个模块加载器。随后,介绍了官方的快速启动存储库。然后,我们看了 SystemJS 需要的不同部分或者它需要解决的问题。在这一点上,我们已经准备好深入了解如何使用 SystemJS 来设置 Angular 应用程序。我们还看了 Angular 框架需要 SystemJS 加载的核心部分以及顺序。离开这个附录,我们现在对 SystemJS 解决的问题有了更清晰的理解,以及如何使用它来设置 Angular 应用程序。值得注意的是,大多数 Angular 应用程序都在使用 Angular CLI 或 webpack,但这绝对是一个将在一段时间内得到支持的好选择。

第十五章:与 Angular 一起使用 webpack

Webpack 是一个模块捆绑器。它能够捆绑不同的资产,如 JavaScript、CSS 和 HTML。webpack 非常受欢迎,正在成为设置应用程序的首选方式。然而,在前端世界中,事物变化很快。这使得重要的是理解需要解决的问题,而不是特定捆绑工具的技术细节。

在本附录中,您将:

  • 了解 webpack 中的重要概念

  • 学习如何在简单的 Web 项目中使用 webpack

  • 利用 webpack 设置 Angular 项目

核心概念

基本上,webpack 尝试通过爬取文件中的所有导入语句来创建依赖关系图。想象一下,您有以下代码片段:

//main.js
import { Lib } from './lib'; 
Lib.doStuff)() // lib.js

//lib.js
import { OtherLib } from './otherlib'
OtherLib.doStuff()

在这种情况下,它会推断main.js依赖于lib.js,而lib.js又依赖于otherlib.js,从而创建了一系列依赖关系。

爬取所有导入语句并找出所有依赖关系的最终结果是生成一个捆绑包,您可以将其作为index.html的一部分并呈现给浏览器进行渲染。

加载程序

webpack 需要一个加载程序来理解特定的文件扩展名并对其进行操作。我们所说的扩展名是.ts.js.html等。我们为什么关心呢?在设置时,我们需要确保已设置了适当的加载程序,以便处理我们关心的特定文件扩展名。在 webpack 中,当您想要处理扩展名时,您设置规则。规则可以如下所示:

rules: [{
 test: /\.blaha$/,
 use: 'blaha-loader'
}]

test属性是一个正则表达式,您可以在其中指定要查找的文件扩展名。

loader属性是您指定加载程序名称的地方。webpack 内置了许多加载程序,但如果需要,也可以下载它。

插件

插件可以在构建过程的不同步骤触发。这意味着您可以在某个步骤执行额外的工作。要使用插件,您需要在plugins属性中指定它,如下所示:

plugins: [new MyAwesomePlugin()]

在我们进入 Angular webpack 设置之前,让我们首先确定我们到目前为止学到了什么。webpack 能够处理 JavaScript、CSS、TypeScript 等,并创建我们可以包含在起始 HTML 文件中的捆绑文件,通常称为index.html。此外,如果通过config文件进行配置,我们可以设置一些规则。每个规则由一个正则表达式组成,该正则表达式将捕获特定文件结束的所有文件,并将指向一个处理捕获文件的加载器。还有一些称为插件的东西,它们能够在特定的生命周期步骤给我们提供进一步的功能。然而,如果我们将这些知识付诸实践,那将是很好的,所以让我们在下一节中这样做。

Webpack - 第一个项目

为了正确地为设置 Angular 项目做准备,让我们首先通过一个简单的项目来展示我们将用来设置 Angular 的所有常见场景。

首先,我们需要安装 webpack。通过运行以下命令来完成:

npm install webpack -g

安装成功后,是时候试一试了。首先,让我们创建几个文件,内容如下:

//index.html
<html></html>

//app.js
var math = require('./mathAdd');
console.log('expect 1 and 2 to equal 3, actual =', math(1,2));

//mathAdd.js
module.exports = function(first, second){
 return first + second;
}

运行以下命令:

webpack ./app.js bundle.js

这将从app.js开始爬取所有依赖项,并从中创建一个bundle.js文件。要使用所述的bundle.js,请在index.html中添加一个脚本标签,使其看起来如下:

<html>
 <script src="bundle.js"></script>
</html>

要在浏览器中查看您的应用程序,您需要一个可以托管您的文件的 Web 服务器。有许多小型、轻量级的 Web 服务器;例如,Python 自带一个。我要推荐一个叫做http-server的服务器。可以通过在终端中输入以下内容轻松安装:

npm install http-server -g

安装完成后,将自己放在与index.html文件相同的目录中,并输入以下内容来调用 Web 服务器:

http-server -p 5000

在浏览器中导航到http://localhost:5000,并打开devtools;应该显示如下内容:

expect 1 and 2 to equal 3, actual = 3

恭喜,您已成功创建了您的第一个 webpack 捆绑文件,并且您有一个可工作的应用程序。

改进我们的项目-使用配置文件

能够轻松创建一个捆绑文件是很好的,但这并不真实。大多数 webpack 项目将使用config文件而不是在命令行上调用 webpack。所以让我们这样做:让我们创建一个名为Webpack.config.jsconfig文件,并将以下代码添加到其中:

//webpack.config.js
module.exports =
{
 entry: "./app.js",
 output: { filename : "bundle.js" }
}

这本质上重新创建了我们在命令行上写的内容,即从app.js开始,并确保生成的捆绑文件名为bundle.js

现在在命令行中键入webpack

再次启动您的应用程序,并确保一切仍然正常。成功!我们已经从命令行转移到了配置文件。

但是,我们不希望一直在终端中输入webpack。我们希望在更改时重新构建捆绑包,因此让我们添加该功能:

module.exports = {
 entry: "./app.js",
 output: { filename : "bundle.js" },
 watch: true
}

注意额外的属性watch

在终端中输入webpack,现在 webpack 进程不会像以前那样退出,而是继续运行并等待我们进行更改。

例如,将app.js的操作更改为以下内容:

var math = require('./mathAdd');
console.log('expect 1 and 2 to equal 3, actual =', math(1,2));

保存文件并注意捆绑包在终端中的重新构建。这很棒,但我们可以做得更好。我们可以添加一个 Web 服务器,它会在更改时自动启动和重新启动我们的应用程序。我在谈论一种叫做热重载的东西。基本上,对代码进行更改,重新创建捆绑包,浏览器反映更改。为此,我们需要做两件事:

  • 安装一个与 webpack 兼容的 HTTP 服务器实用程序

  • config文件中启用热重载

要安装 webpack HTTP 服务器实用程序,我们输入以下内容:

npm install webpack-dev-server -g

现在让我们将config文件更新为以下内容:

var webpack = require('webpack');

module.export = {
 entry: './app.js',
 output: { filename : 'bundle.js' },
 watch: true,
 plugins: [new Webpack.HotModuleReplacementPlugin()]
}

已添加两个功能。这是第一个:

var webpack = require('Webpack');

这是第二个:

plugins: [new Webpack.HotModuleReplacementPlugin()]

我们已添加了一个热重载插件。使用以下命令启动应用程序:

webpack-dev-server

现在,Web 服务器会监听更改;如果发生更改,它将重新构建捆绑包,并在 Web 浏览器中显示更改。

为我们的项目添加更多功能

在现代 Web 应用程序项目中,我们可以做更多有趣的事情。其中之一是能够使用所有最新的 ES2015 功能,以及能够将我们的捆绑包拆分成更多专用的捆绑包,比如一个用于应用程序,一个用于第三方库。webpack 可以轻松支持这两个功能。

创建多个捆绑包

有多个原因可以解释为什么您希望为应用程序创建多个捆绑包。可能是您有多个页面,您不希望每个页面加载一个沉重的捆绑包,而只需要它所需的 JavaScript。您可能还希望将第三方库与应用程序本身分开。让我们尝试看看如何创建多个捆绑包。

我们的理想情况是,我们希望有三个不同的文件,app.jsinit.jsvendor.js:

  • app.js:这是我们的应用程序所在的位置

  • init.js:这应该包含捆绑包共有的内容,也就是我们的 webpack 运行时

  • vendor.js:这是我们依赖的第三方库所在的地方,比如querylodash

为了实现这一点,我们需要更改配置文件,以便如下所示:

module.exports = {
 entry : {
 app: "./app.js",
 vendor: ["angular"]
 },
 output: { filename : "[name].js" },
 watch: true,
 plugins: [
 new Webpack.HotModuleReplacementPlugin(),
 new Webpack.optimize.CommonsChunkPlugin("init")
 ]
}

让我们来分解一下:

entry: {
 app: "./app.js",
 vendor: ["angular"]
}

我们过去在这里有一个指向app.js的入口。现在我们想要有两个入口,但是用于不同的事情。Vendor 指向一个库数组。这意味着当 webpack 看到a:require('angular')时,它知道要将node_modules/angular库放在vendor.js中,它将创建。

第二个感兴趣的部分是:

plugins: [ new Webpack.optimize.CommonsChunkPlugin('init') ]

在这里,我们说要将我们共有的一切(在这种情况下是 webpack 运行时)放在init.js中。

使用 webpack 设置 Angular

掌握了 webpack 的核心概念以及如何添加额外功能的知识后,我们现在应该准备好启动 Angular 项目了。首先,创建以下文件:

  • webpack:在设置 webpack 时,通常最好将配置设置为以下三个文件:

  • webpack.common.js:这是大部分配置将发生的地方

  • webpack.dev.js:这是dev环境特定的配置

  • webpack.prod.js:这是prod环境特定的配置

  • package.json:此文件将列出我们依赖的库,以便正确引导 Angular。这些列在devDependenciesdependencies中。我们还将在script中列出一些命令,以便启动应用程序,以便在 web 服务器上运行。此外,我们还将创建用于测试的命令和用于创建生产捆绑包的命令。

  • tsconfig.json:这个文件是为 TypeScript 编译器准备的。值得注意的是,我们希望启用某些功能,使应用程序能够正常工作,比如emitDecoratorMetadataexperimentalDecorators

通用配置

这个文件的简要概述如下:

  • Entry,应用程序的入口点

  • Module.rules,一个指定如何加载某些文件以及使用什么加载器的对象

  • 插件,一个在 webpack 生命周期中为我们提供额外功能的插件数组

entry部分指定将有三个不同的捆绑:polyfillsvendorapp。你可能会问为什么是这三个捆绑?嗯,为polyfills有一个单独的捆绑是有道理的,因为它是与其他不同的概念。polyfills捆绑确保我们选择的浏览器具有来自 ES2015 的所有最新功能。vendor捆绑是我们放置所有被认为是我们应用程序的辅助程序的库,但并不是应用程序本身。app捆绑真正是我们应用程序的所在;它包含我们的业务代码。

以下代码片段显示了创建前面提到的三个捆绑所需的配置应该是什么样子的:

entry : {
 'polyfills': './src/polyfills.ts',
 'vendor': './src/vendor.ts',
 'app': './src/main.ts'
}

module部分定义了一系列规则。提醒一下,规则是关于处理特定文件扩展名的。每个规则都包括一个test属性,定义要查找的文件扩展名。它还包括一个loader属性,指向能够处理该文件扩展名的加载程序。例如,如果文件扩展名是.sass,加载程序能够将 Sass 编译成 CSS 文件。

以下代码片段举例说明了如何设置规则来处理 HTML 文件:

module : {
 rules : [
 {
 test: /\.HTML$/,
 loader: 'HTML-loader'
 }
 // other rules emitted for brevity
 ]
} 

我们可以看到一个正则表达式测试.html扩展名,并让HTML-loader处理它。我们项目的完整规则列表应该设置规则来处理 TypeScript、资源(图像)、CSS 和 HTML。如果我们都有了,就可以开始了。

我们还需要通过设置一些插件来增强构建过程,即:

  • ContextReplacementPlugin

  • CommonChunksPlugin

  • HTMLWebpackPlugin

ContextReplacementPlugin的工作是用另一个上下文替换一个上下文。但这到底是什么意思呢?最常见的用例是使用动态的require语句,就像这样:

require('directory/' + name + '.js')

在编译时,webpack 无法确定要包含哪些文件。为了确保它在运行时能够正常工作,它会包含该目录中的所有内容。一个常见情况是处理翻译文件。您可能在这样的目录中有数百个文件,包含所有这些文件会使捆绑文件变得不必要地庞大。因此,您可以使用该插件,并给它一个过滤参数,缩小文件数量,就像这样:

new Webpack.ContextReplacementPlugin(
 /directory\//, //when trying to resolve a file from this directory
 /(sv-SE|se).js // narrow down the search by only including files
 that match this
)

当您尝试创建多个捆绑文件时,将使用CommonChunksPlugin,就像这样:

entry : {
 'polyfills': './src/polyfills.ts',
 'vendor': './src/vendor.ts',
 'app': './src/main.ts'
}

为了避免每个捆绑包都包含 webpack 运行时和其他常见部分;可以使用上述插件来提取常见部分。有许多调用这个插件的方法;这里是一个:

plugins: [ new Webpack.optimize.CommonsChunkPlugin('init') ]

这将创建一个init.js文件。

webpack 生成了许多文件,如 HTML 和 JavaScript 文件。你可以在index.html中链接到所有这些文件,但这变得相当麻烦。更好的处理方法是使用HTMLWebpackPlugin,它将为你注入这些linkscript标签。

没有这个插件,你的index.html会看起来像这样:

<link href="app.css"></link>
<script src="app.bundle.js"></script>
<script src="page1.bundle.js"></script>
<script src="page2.bundle.js"></script>
<script src="common.bundle.js"></script>

你明白了,使用这个插件几乎是必须的,至少如果你想确保将index.html与你的解决方案同步,并避免不必要的输入,需要添加/更改脚本标签。

我们需要做的是使这个插件工作,指向需要注入scriptlink标签的位置,如下所示:

new HtmlWebpackPlugin({
 template: 'src/index.HTML'
})

到目前为止,我们已经涵盖了创建的捆绑包,需要设置处理所有不同文件扩展名的规则,以及需要的插件。这是 webpack 设置的核心。然而,配置需要根据我们处理的是开发环境还是生产环境有所不同。

开发配置

webpack 在开发模式和生产模式下以不同的方式处理你的文件。首先,你的 JavaScript 文件都是在内存中的,也就是说,没有文件实际写入输出目录,如下所示:

output: {
 path: helpers.root('dist')
 // other config is omitted
}

在开发环境中,我们关心设置源映射。源映射记住了在所有东西被合并成一个或多个捆绑包之前文件结构是什么样子的。当文件在 IDE 中与项目结构相似时,调试变得更容易。设置源映射的一种方法是输入以下内容:

devtool: 'cheap-module-eval-source-map'

生产配置

在生产配置中,通过使用UglifyJS插件进行最小化设置是很重要的。这很重要,因为我们希望我们的应用尽可能小,这样它加载起来会很快。我们的用户中可能有更多的人在 3G 连接上,所以我们需要迎合所有类型的用户:

new Webpack.optimize.UglifyJsPlugin({
 mangle: { keep_fnames : true } // keep file names
})

测试

任何值得一提的开发人员都应该关心编写测试。测试的设置并不难。

我们需要以下文件来使测试工作:

  • karma.conf.js:我们正在使用 karma 作为测试运行器。这需要一个config文件,设置测试的位置,是否在无头浏览器或真实浏览器中运行我们的测试,以及许多其他内容。

这个文件中需要注意的config是:

 preprocessors: {
 './karma-test-shim.js': ['Webpack', 'sourcemap']
 }

预处理步骤是必需的,以便将我们的 TypeScript 文件编译成 ES5 JavaScript。它还将设置适当的源映射,并指出从 Angular 框架中需要哪些文件才能使我们的测试正常运行。

另一个值得一提的属性是:

 var WebpackConfig = require('./webpack.test');
 module.exports = function(config) {
 var _config = {
 Webpack : WebpackConfig
 }

 // other config omitted
 config.set(_config);
 }

这指向了Webpack.test.js文件中指定的配置。

  • webpack.test.js:这只是Webpack.common.js的副本,正常的配置。然而,通过将其制作成一个单独的文件,我们有能力稍后覆盖某些配置。

  • karma-test-shim.js:如前所述,这个文件负责导入运行所需的 Angular 框架的所有部分,框架的核心部分,以及与测试相关的专用部分。完整的文件如下:

 Error.stackTraceLimit = Infinity;

 require('core-js/es6');
 require('core-js/es7/reflect');
 require('zone.js/dist/zone');
 require('zone.js/dist/long-stack-trace-zone');
 require('zone.js/dist/proxy');
 require('zone.js/dist/sync-test');
 require('zone.js/dist/jasmine-patch');
 require('zone.js/dist/async-test');
 require('zone.js/dist/fake-async-test');

 var appContext = require.context('./src', true, /\.spec\.ts/);
 appContext.keys().forEach(appContext);

 var testing = require('@angular/core/testing');
 var browser = require('@angular/platform-browser-dynamic/testing');

 testing.TestBed.initTestEnvironment(
 browser.BrowserDynamicTestingModule,
 browser.platformBrowserDynamicTesting()
 );

值得注意的是以下一行:

 var appContext = require.context('./scr, true, /\.spec\.ts/');

这定义了在尝试定位要运行的测试时要查找的内容。因此,让我们创建一个匹配这种模式的测试,test.spec.ts,在src目录下:

describe('should return true', () => {
 it('true is true', () => expect(true).toBe(true) );
});

所有这些都设置正确后,你应该能够输入:

npm run test

这应该启动 Chrome 浏览器。你应该看到以下内容:

按下调试按钮将显示以下屏幕,清楚地指示正在运行我们的测试和结果,即通过测试。

总结

本附录描述了 webpack 与 Angular 的配合工作方式。此外,我们已经探讨了与设置 Angular 应用程序相关的部分,甚至如何设置单元测试,这是强烈建议尽早适应的。希望你通过这个附录感到有所启发,并且觉得设置并不那么复杂。通常情况下,项目的设置只需要一次,你只需要在项目开始时进行一次设置,之后几乎不再碰。为了简洁起见,我们没有展示很多配置,而是讨论了不同配置文件如何一起工作来使我们的设置生效。然而,如果你想详细研究配置,可以在以下 GitHub 存储库中找到:github.com/softchris/angular4-Webpack

posted @ 2024-05-18 12:03  绝不原创的飞龙  阅读(10)  评论(0编辑  收藏  举报