ANgular-实战-全-

ANgular 实战(全)

原文:Angular in Action

译者:飞龙

协议:CC BY-NC-SA 4.0

1

Angular:一个现代 Web 平台

本章涵盖

  • Angular 作为现代应用程序的平台

  • 选择 Angular 的关键原因

  • Angular 的架构以及组件如何构成其基础

  • AngularJS 与 Angular 的区别

  • ES2015 和 TypeScript 以及 Angular 如何使用它们

Angular 是一个现代 Web 应用程序平台,承诺为开发者提供一套全面的工具和能力,以构建大型、健壮的应用程序。Angular 的核心价值主张是使构建适用于几乎所有平台的应用程序成为可能——无论是移动、Web 还是桌面。Angular 团队不仅专注于构建一个健壮的应用程序框架,还构建了一个完整的生态系统。

所有这些内容有点多,这也是 Angular 成为如此令人兴奋的技术之一的原因。让我们先仔细看看为什么你会选择 Angular 作为下一个项目的开发工具。

1.1 为什么选择 Angular?

构建能够满足用户需求的应用程序并非易事。应用程序的质量和复杂性不断增长,用户对质量和功能的需求也在增加。Angular 的存在是为了帮助开发者交付满足这些需求的应用程序。

如果你还没有确定 Angular 作为首选工具,让我们快速概述一些你应该认真考虑 Angular 的主要原因。一些内容在 1.3 节中有更详细的介绍,但以下是我经验中的主要亮点:

  • 受网络标准启发,由现代功能增强 — 任何今天构建 Web 应用程序的人都知道,有无数不同的方式和想法来设计应用程序。Angular 尝试围绕常见标准(如利用最新的 JavaScript 语言功能)设计和开发其框架及开发过程,使用现代功能(如采用 TypeScript 进行类型强制)。

  • 包含开发工具,提供定制选项 — Angular 通过其 CLI 工具(用于生成、构建、测试和部署应用程序)提供了一个共同的开发者体验,同时使这些相同的工具易于集成到定制解决方案(如定制构建工具链)和第三方工具(如不同的编辑器或 IDE)中。

  • 强大的生态系统和庞大的社区 — 存在着越来越多的第三方库、UI 库、博客文章和活动。Angular 的庞大和活跃社区提供了一个很好的学习基础,并应增强人们对它将继续保持有价值技术的信心。

  • 由 Google 赞助,开源社区驱动—Google 有一支工程师、经理和传教士团队,他们专门致力于将 Angular 带给 Google 的其余部分和整个网络社区。在 Google 内部,有成千上万的“内部客户”依赖 Angular,Angular 团队利用这些经验来指导未来的开发,并收到了大量的外部贡献,这些贡献共同塑造了 Angular 的未来(你也可以加入其中!)。

Angular 不仅仅是一个为世界上一些顶级网站提供动力的 JavaScript 库。我对开源社区充满热情,并倡导人们将其作为日常例行公事的一部分参与项目。Angular 社区中的项目是我投入大量精力和贡献的地方,我也邀请你加入我。虽然我确实参与了 Angular 项目本身,但我主要贡献于 Angular 生态系统中的项目,例如 Clarity,一个 UI 组件库和设计语言。

你可能是一位试图弄清楚 Angular 是否满足你需求的开发者,或者你可能是一位试图理解这项技术角色、或试图弄清楚如何改进你当前应用程序的经理。无论你从哪里开始,Angular 生态系统都有很多可以提供的。

1.2 你将学到什么

本书旨在全面地介绍 Angular,但它也旨在让你了解生态系统的各个方面。方法始终是经验性的,你将学习一个主题,并通过自己构建它来观察概念如何变得生动。本书结束时,你应该能够制作高质量的 Angular 应用程序,并拥有构建职业和应用程序的基础知识和经验。

本书的关键要点包括以下内容:

  • Angular 的工作原理—我们将探讨一些关键内部概念,这些概念使得它成为构建应用程序的一个非常有吸引力的平台。你将学习这些概念,并通过构建示例来展示它们作为功能应用程序的一部分。

  • 如何构建应用程序—在大多数章节中,我们将一步步地通过一些真实世界的例子进行讲解。代码示例全面,并针对每一章设定了特定的目标。

  • 了解生态系统—每个例子都使用了一些第三方库和功能。这有助于你看到更真实的发展体验,并为构建你自己的应用程序打下基础。

  • 从我的经验中获得实用见解—在许多例子及其说明中,我分享了我从经验中得到的实用建议,包括避免某些事情的建议(即使这些代码是合法的)以及当有多种方法可供选择时如何进行选择。

到本书结束时,你应该能够使用 Angular 设计和构建网络应用。如果你对技术方面不太感兴趣(也许是一个管理者),你仍然可以从中获得很多相同的教训,以获得 Angular 的工作方式和它为你的项目提供的内容的稳固参考框架。

在这本书中,有一些内容我无法涵盖,但这并不意味着你不能从它们相关的许多事物中学习。以下不是本书涵盖的核心主题:

  • 如何编写库 — 这本书侧重于如何使用 Angular 构建应用,从许多方面来看,构建库有不同的指南和建议。那将是另一本书的内容。但如果你不知道如何构建应用,构建库也会很困难。

  • 每个可用的 API 和功能 — 本书没有涵盖许多 API 和功能,主要是因为它们很少被使用。我相信这本书将使你能够提升自己的技能,以便在项目需要时快速学习这些附加功能。

  • 如何设计你的应用和 用户体验原则 — 这是一个非常大的主题,我无法全面涵盖。我已经在章节示例中尝试展示了几种不同的想法和模式,以给你一些启发,但这些都往往是基于个人观点的。我希望你能花时间比较每种设计,并知道由于这些是示例而非实际项目,因此可能会有局限性。

Angular 是一个不断发展的项目,具有新的功能和有时是现有功能的弃用。我已经非常小心地确保所教授的概念是核心思想,这些思想不太可能改变(尽管它们可能会得到增强)。如果有任何更改破坏了一些示例代码或概念,请检查每个章节或本书的 GitHub 项目,或者论坛,其中应该有一个已知更改和错误列表。

为了更好地理解 Angular 在当今网络中的影响,让我们回顾几年前的历史,看看是什么带我们来到这里。

1.3 从 AngularJS 到 Angular 的旅程

网络应用在 2009-2010 年左右成熟,当时 Web 2.0 的潮流最终让位于更好的应用方法和框架。术语网络应用也因此变得更加精确,这或许在很大程度上得益于 HTML5 和 EcmaScript 5(JavaScript 的基础)的标准化,它主要关注构建几乎完全在浏览器中运行的稳健应用的能力。

2009 年,Miško Hevery 宣布了 AngularJS,这成为构建网络应用最受欢迎的框架(如果不是受欢迎的),AngularJS 项目被引入谷歌,并于 2010 年 10 月正式发布了 1.0 版本。当时还有许多其他可行的框架,但 AngularJS 与广泛的开发者群体产生了共鸣。

Angular 2 版本于 2014 年 9 月正式宣布,历时两年(加上宣布之前的一些时间)开发。它于 2016 年 9 月以 Angular 2 版本发布,Angular 4 于 2017 年 3 月发布。Angular 团队将继续按照每六个月一次的节奏提供主要版本,重点关注易于升级。根据你阅读的时间,Angular 6 或甚至 10 可能是最新的版本。

但你阅读这本书不是为了了解过去——你对构建现代 Web 应用程序感兴趣。也许你已经构建了 Angular 1 应用程序,或者甚至从 Angular 2 的一些指南开始。本书的重点是构建现代 Web 应用程序,Angular 提供了优雅地完成这一目标的平台。

在整本书中,我会偶尔提到 AngularJS,以帮助有经验的读者建立联系,但当我使用没有数字的Angular时,我始终指的是 Angular 2 或更高版本。查看angular.io (图 1.1)获取更多信息。

c01-1.png

图 1.1 Angular 网站是关于文档、活动和 Angular 的一切的绝佳资源。

1.4 Angular:一个平台,而不是一个框架

框架和平台之间有一些重要的区别。通常,框架只是用于构建应用程序的代码库,而平台则更加全面,包括框架之外的工具和支持。AngularJS 专注于在浏览器中构建 Web 应用程序,显然是一个框架。它有一个庞大的第三方模块生态系统,可以轻松地用于向应用程序添加功能,但核心只是构建浏览器中的 Web 应用程序。

Angular 附带了一个更精简的核心库,并且将附加功能作为单独的包提供,可以根据需要使用。它还拥有许多工具,这些工具使其超越了简单的框架,包括以下内容:

  • 专门用于应用程序开发、测试和部署的 CLI

  • 在许多后端服务器平台上提供离线渲染功能

  • 基于桌面、移动和浏览器的应用程序执行环境

  • 完备的 UI 组件库,例如 Material Design

其中一些功能在 AngularJS 中以某种形式存在,但大多数是社区解决方案,并在 AngularJS 之后添加。相比之下,Angular 是考虑到这些平台功能而开发的。

这些部分仍在完善中,并将继续发展成为更稳健的选项。

1.4.1 Angular CLI

现代开发通常需要设置许多工具才能开始一个项目,这导致了更多工具的诞生,以帮助管理这些工具。一个典型的项目需要管理构建过程(资产优化)、测试(单元测试和端到端测试)以及本地开发支持(本地服务器)。

Angular CLI(通常简称为 CLI)是构建提供这些功能以及更多功能的 Angular 应用的官方工具链。本书使用 CLI 进行所有示例,并鼓励你在自己的项目中使用它。你可以自己构建构建工具,但这仅在 CLI 无法满足你的需求时才建议。

你可以使用 npm 安装 CLI。它确实需要你安装一个较新的 NodeJS 版本才能正常运行:

npm install -g @angular/cli 

CLI 有许多有助于 Angular 应用程序开发的特性。以下是主要特性:

  • 生成新的项目骨架—你不必从现有项目创建新项目或自己创建所有文件,CLI 会为你生成一个包含基本应用程序的完整项目。

  • 生成新的应用程序组件—需要一个新的组件?很简单;它可以为你生成文件。它可以生成组件、服务、路由和管道,并且它还会在构建过程中自动确保它们完全连接。

  • 管理整个构建工具链—因为文件在提供给客户端(如 TypeScript 编译)之前需要被处理,所以 CLI 会处理你的源文件并将它们构建成用于开发或生产的优化版本。

  • 提供本地主机开发服务器—CLI 处理构建流程,然后启动一个监听 localhost 的服务器,以便你可以查看结果,并具有实时重新加载功能。

  • 集成代码检查和格式化代码—通过使用 CLI 对你的代码进行风格和语义错误检查,CLI 可以帮助强制执行代码质量,并且它还可以自动将你的代码格式化为特定的风格规则。

  • 支持运行单元测试和端到端测试—测试至关重要,因此 CLI 为运行单元测试设置了 Karma,并与 Protractor 合作执行端到端测试。它将自动捕获并执行新生成的测试。

你可以为 CLI 添加其他功能和能力。要查看完整的功能列表,你可以运行 ng help 来输出当前的帮助文档。你还可以在 cli.angular.io 上了解更多关于 CLI 的信息。

1.4.2 服务器渲染和编译器

在 Angular 中,编译输出与浏览器解耦,这使得 Angular 应用可以在不同的环境中渲染,例如服务器或桌面应用程序。这种设计模式有许多很好的副作用,因为 Angular 能够在客户端和服务器上渲染,这使得它更加灵活,并开辟了许多不同的机会。

这里有两个因素在起作用——首先,Angular 的解耦编译器,然后是可选的通用渲染支持。要启用通用渲染,需要一个解耦的编译器,因为你可以根据环境实现不同的渲染模式。

Angular 中的编译器是整个解决方案中非常重要的一部分。它负责解析数据绑定、注册事件处理程序,并为组件渲染出结果 HTML。

“服务器端渲染”这个术语涉及到这样一个观点:运行执行 Angular 代码的 JavaScript 引擎的位置不应该很重要。应该能够以浏览器 JavaScript 引擎、NodeJS 或甚至更不常见的引擎(如 Java 的 Nashorn 引擎)的方式,在通用环境中运行 Angular。这极大地增加了 Angular 的使用方式。

这为什么很重要?让我们探索一些主要的使用案例:

  • 服务器端渲染以实现更快的加载速度——如今,移动设备是访问互联网的主要方式,而移动连接通常速度慢且不可靠。服务器端渲染选项允许您在服务器上解析数据绑定和渲染组件,这样发送给用户的初始负载就可以预先初始化。它还可以优化并发送必要的字节以实现快速初始加载时间,并在需要时懒加载其他资源。

  • 浏览器中的性能——JavaScript 的一个主要痛点是它是单线程的,这意味着 JavaScript 一次只能处理一条指令。在现代浏览器中,一种称为 Web Workers 的新技术允许 Angular 将编译器的一些执行推送到另一个进程。这意味着可以发生更多的处理,并且它使得动画和用户交互更加平滑。

  • 搜索引擎优化(SEO)——人们非常关注搜索引擎如何爬取重量级的 JavaScript 应用程序。通用渲染意味着我们可以检测爬虫并为它们渲染网站,这样内容就可以准备好了,无需担心爬虫是否执行 JavaScript(有些会,有些不会)。这无疑将增强 Angular 应用程序的 SEO 努力。

  • 多平台支持——许多开发者希望使用其他平台作为他们的后端,例如.NET 或 PHP。如果有一个支持的渲染器,Angular 可以在所选平台上编译。Angular 将提供对 NodeJS 的支持,但社区正在积极构建和维护对其他平台(如 Java 和 Go)的渲染支持。

所有这些在构建 Web 应用程序中已经存在了多年,Angular 提供了一个全面的解决方案。好处是您不需要做很多工作就能在您的应用程序中启用这些功能。

这是在撰写本文时的一个发展领域,正确设置它是一个高级话题,我无法深入探讨。但 Angular 文档和 CLI 正在不断改进,以向您展示如何轻松地整合这些类型的优势。

1.4.3 移动和桌面功能

渲染能力使 Angular 能够与原生移动和桌面应用程序协同工作。像 Cordova 这样的工具已经存在了一段时间;它们允许你创建混合应用程序——Web 应用程序被包裹在某种类型的原生壳中。但 Angular 的渲染设计使得支持渲染到不同的原生平台成为可能。

最大的价值在于你可以在你的 Angular 应用程序之间共享大量代码,即使有些是为构建移动应用程序而设计的,而有些则是 Web 应用程序。这在大型团队中尤其有价值。

Angular 的移动和桌面功能是编译器设计的扩展。以下工具都在 Angular 的核心之外,但使用 Angular 的设计来驱动一些强大的设计模式:

  • Ionic(移动)—这个出色且流行的混合应用程序框架(图 1.2)已更新以与 Angular 兼容。数百万个移动应用程序都是使用 Ionic 创建的,它主要专注于构建混合应用程序。UI 组件都是为在浏览器中运行而设计的,但看起来和感觉像原生 UI 组件。

  • 原生脚本(移动)—这是另一个流行的移动框架,可以创建原生移动应用程序。原生脚本实现了原生 UI 组件,但允许你编写 Angular 组件来描述你的应用程序。

  • React Native(移动、桌面)—从名字上看,你会正确地假设 React Native 实际上是 React 框架生态系统的一部分。但通过自定义渲染,可以使用 React Native 工具生成原生移动应用程序。

c01-2.png

图 1.2  Ionic 是一个流行的强大移动框架,适用于 Angular。

  • Windows 通用(桌面)—Windows 支持使用 JavaScript 构建原生 Windows 应用程序。你可以使用 Angular 作为应用程序层,但仍需构建原生 Windows 应用程序。

  • 电子(桌面)—基于 NodeJS 的 Electron 是一个非常流行的跨平台应用程序框架。它实现了一套 API 来钩入原生操作系统,并允许你利用 Angular 来驱动应用程序的内部逻辑。

  • 渐进式 Web 应用程序(移动、桌面)—渐进式 Web 应用程序(PWA)的功能并不仅限于 Angular。它们本质上是关于模糊 Web 和原生之间的界限。截至本文撰写时,它们处于实验性支持状态。这是构建未来应用程序的一个令人兴奋的潜在途径。

这些不同的选项支持了 Angular 中解耦编译器的强大功能。这也意味着可能会出现许多、许多更多的示例和用例,允许你构建几乎在任何地方都能运行的 Angular 应用程序。

1.4.4 UI 库

为 Angular 构建的 UI 库目录正在不断增长。它们为开发者提供了易于消费的 UI 组件集合。你无需自己构建图表或标签组件,可以使用众多预构建选项之一。

根据你团队的大小和技能组合,实现自己的 UI 组件可能会很具挑战性。制作真正可重用和加固的 UI 组件是困难的。这些组件很少是使你的应用程序真正独特的东西,因此很难花费时间和金钱来构建它们。

这些库非常丰富。数量众多,以至于我无法涵盖所有选项。你会注意到它们提供的功能有很多重叠,因此比较它们可能会很困难。我们将探讨一些最受欢迎的选项,但我建议在选择选项之前进行额外的调查:

  • Angular Material (github.com/angular/material2) — Material Design 是由 Google 创建的官方设计规范。它在现实世界对象的概念中有着深厚的根基,因此得名 Material。Angular Material 是 Angular 团队提供的官方 UI 组件库,并根据设计规范实现了多个 UI 组件。它拥有开源许可。

  • Covalent (teradata.github.io/covalent) — 这个库通过添加许多额外的组件和能力扩展了 Angular Material 项目,但仍保留了 Material Design 的原则。它是 Teradata 的工作成果。它拥有开源许可。

  • Clarity (vmware.github.io/clarity) — 如 图 1.3 所示,这个库来自 VMware。它被设计为一个库和 Web 应用程序的设计规范。它包含许多特定于 Angular 的组件,还有一些图标和一个通用的 CSS 框架。它拥有开源许可。

  • ng-bootstrap (ng-bootstrap.github.io) — 基于 Bootstrap CSS 框架,ng-bootstrap 根据 Bootstrap 的设计实现了组件。它是由创建 AngularJS UI Bootstrap 项目的同一团队构建的。它拥有开源许可。

  • Kendo UI (www.telerik.com/kendo-angular-ui/) — 来自 NativeScript 的同一公司,Kendo UI 是一个集成到许多不同框架中的 UI 库。该公司正在构建一套专为 Angular 定制的原生 UI 组件。它拥有商业许可。

  • PrimeNG (www.primefaces.org/primeng/) — PrimeNG 是由 PrimeTek 开发的 UI 组件丰富集合,拥有超过 60 个组件。它提供了许多主题,并专为移动设备和桌面设计。它拥有开源许可。

  • *Wijmo (wijmo.com/angular2/) — 包含一些非常复杂的数据网格组件,Wijmo 实现了这一系列 Angular 组件,而不需要像 jQuery 这样的其他库的支持。该 UI 库具有商业许可证。

c01-3.png

图 1.3 清晰度设计系统是 Angular UI 库中最受欢迎的之一。

  • *Ionic (ionic.io) — 主要针对移动端,Ionic 是一个包含易于主题化、原生设备集成、实用服务和自身 CLI(应用程序开发工作流程)的组件库。该公司还提供移动应用程序开发的商业服务。它具有开源许可证。

  • *Fuel-UI (fuelinteractive.github.io/fuel-ui/) — 由 Fuel Travel 提供的另一个基于 Bootstrap CSS 框架的组件、指令和管道集。它具有开源许可证。

你当然不需要使用 UI 库,但大多数开发者会发现它们很有用。任何合理的 UI 库都应该经过相当充分的测试,让你能更多地关注使你的应用程序独特的地方。

1.5 组件架构

许多现代应用程序都采用了基于组件的方法来开发应用程序。目的是以独立的方式设计你应用程序的每一部分,以限制程序各个部分之间的耦合和重复。在许多方面,组件是在你的应用程序中创建自定义 HTML 元素的一种方式。

思考组件架构的最简单方法之一是查看一个包含大量离散部分的页面示例,并检查各个部分是如何相互关联的。图 1.4 展示了未来章节的一个示例,并直观地分解了各种组件部分。

c01-4.png

图 1.4 通过展示组件如何嵌套和组合以创建更复杂的布局来展示组件架构

图表显示了本书章节示例中的一个独立部分,说明了几个组件如何组合在一起创建这个显示。你可以看到各个部分相互独立,但它们也共同工作以创建项目列表。它们之间显然存在层次关系。右侧的组件列表显示了每个组件与其他组件之间的父子关系,这正是 HTML 元素在页面上协同工作的基本方式。

HTML 本身是一种组件语言。每个元素都有一定的角色和功能,并且它们可以轻松嵌套以创建更复杂的功能。它们是隔离的,但仍然可以轻松地操作以完成当前所需的任何任务。一些元素协同工作。例如,INPUT元素在FORM内部使用,以描述一组输入控件。许多元素在发生某些事情时也可以发出事件;例如,FORM在表单提交时可以发出事件。这允许你根据触发的事件将额外的逻辑连接到 HTML 元素,这是前端应用程序开发的基础。

希望组件架构看起来相当容易接近,并且与你对网络的当前理解保持一致。目的是将应用程序的各个部分(尤其是视觉 UI 元素)分解成离散的、模块化的组件。

实现组件架构有许多方法,正如许多网络应用程序库(如 React 和 Ember)所证明的那样。Angular 有一个非常明显的基于组件的架构(所有 Angular 应用程序都是组件)。React 和 Ember 也为其应用程序提供了对组件的一级支持。那些有 jQuery 经验的人也可以想象 jQuery 插件在概念上可以类似于组件,尽管它们并不那么一致或规范。甚至 Web 2.0 时代的基礎概念(想想小工具!)也是围绕构建组件来展开的。

1.5.1 组件的关键特性

组件有一些概念驱动着它们的设计和架构。本节将更详细地探讨这些概念,但也要关注 Angular 如何在整本书的实践中应用这些概念:

  • 封装 — 将组件逻辑保持在单一位置

  • 隔离 — 将组件内部隐藏对外部行为者

  • 可复用性 — 允许以最小的努力进行组件复用

  • 事件驱动 — 在组件的生命周期中发出事件

  • 可定制 — 使其可能对组件进行样式化和扩展

  • 声明式 — 使用具有简单声明性标记的组件

当我们构建组件时,上述原则是我们设计最佳组件时应该考虑的。这些概念以前以各种形式存在,但很少全部被明确实现并标准化为网络平台。

互联网标准组织(W3C),作为网络的主要标准机构,正在制定官方的 Web 组件规范。为了实现网络组件的完整愿景,需要几个标准:

  • 自定义元素(封装、声明式、可复用性、事件驱动)

  • 阴影 DOM(隔离、封装、可定制)

  • 模板(封装、隔离)

  • JavaScript 模块(封装、隔离、可复用性)

到目前为止,该规范并未在所有浏览器中得到完全采用,并且可能永远不会得到完全采用。标准也可能发生变化,但在这里深入探讨规范的细节并不至关重要。重要的是,这四个概念是组件理念的核心。让我们更详细地探讨它们,看看它们如何使组件架构成为可能。

自定义元素

HTML 是网络的通用语言,因为它以相当简洁的元素集描述了页面内容。作为一种标记语言,它是一种描述内容的声明性方式。自定义元素意味着能够通过我们自己的附加元素扩展 HTML,增加可能性的词汇表。你可以在www.w3.org/TR/custom-elements/上阅读关于官方规范的更多信息。

自定义元素的官方规范旨在允许开发者创建新的 HTML 元素,这些元素本质上可以自然地、本地地融合到 DOM 中。换句话说,使用自定义元素应该与其他任何 HTML 元素的使用没有区别。例如,想象你想创建一个实现标签页界面的自定义元素。你可能会想创建如下代码所示的自定义元素,并在图 1.5 中:

<tabs>
  <tab title="About">
    <h1>This is the about tab</h1>
  </tab>
  <tab title="Profile">
    <h2>This is the profile tab</h2>
  </tab>
  <tab title="Contact Us">
    <form>
      <textarea name="message"></textarea>
      <input type="submit" value="Send">
  </tab>
</tabs> 

这看起来和感觉就像自然的 HTML,因为这些将会有两个自定义元素:tabs 和tab元素。这里的真正价值在于实现标签页的简便性。使用 jQuery,你最终会创建大量的div元素,应用多个自定义 ID 或类,并在其上添加一些 JavaScript。

这些标签页也可以发出事件。例如,每当活动标签更改时,可能会有一个tabChange事件。你的应用程序中的任何内容都可以监听此事件并相应地采取行动。每个自定义元素都可以实现任何看似对组件生命周期实用的数量的事件。

自定义元素也可以实现自己的样式,因此标签页可以默认具有特定的外观和感觉。任何使用标签页的人都可以编写自己的 CSS 来修改它以适应特定的使用场景,但自定义元素可以有一个默认的外观,就像许多 HTML 元素一样。

自定义元素拥有构建组件所需的大量功能。实际上,我们可以在自定义元素上停止,并且相当满意。它为我们提供了一种声明性的方式来创建可重用的组件,该组件封装了组件的内部机制,使其与应用程序的其他部分隔离开来,但可以发出事件以使其他组件能够钩入组件的生命周期。Angular 在其组件实现中使用了这些概念。

c01-5.png

图 1.5 自定义元素适合于正常的 HTML 层次结构,但可以实现新的行为。

Angular 提供自己的机制来创建自定义元素,这实际上就是一个 Angular 组件。每个 Angular 组件都是一个自定义元素,并满足我们期望从自定义元素获得的四个原则(以及更多)。

1.5.2 Shadow DOM

尽管名字听起来有些不吉利,但当你试图在组件内部隔离样式行为时,Shadow DOM实际上是你的最佳朋友。Shadow DOM 是一个独立的文档对象模型(DOM)树,与典型的 CSS 继承分离,允许你在 Shadow DOM 内部和外部之间创建一个屏障。例如,如果你在 Shadow DOM 内部有一个按钮,而在外部也有一个按钮,那么在 Shadow DOM 外部编写的按钮 CSS 不会影响其内部的按钮。这对于 Angular 来说很重要,因为它允许我们更好地控制 CSS 如何影响组件的显示方式。

CSS 是一种强大的语言,但大多数 Web 开发者都遇到过 CSS 样式意外修改了除目标元素之外的其他元素的问题,尤其是在添加外部 CSS 源时。Shadow DOM 提供了一种真正封装组件 HTML 和 CSS 的方法,使其与其他页面部分分离,这被称为 Light DOM。你可以在www.w3.org/TR/shadow-dom上阅读官方规范。

开发者应该熟悉标准的 Light DOM,它定义了与元素样式和可见性相关的标准 DOM 行为。当你编写一个 CSS 规则时,CSS 选择器是唯一限制哪些元素接收特定样式的途径。在大多数情况下,CSS 都是通过某种系统方法编写的,以明确 CSS 样式如何应用。这导致了众多优秀的 CSS 网格和组件框架的出现,如 Bootstrap 和 Foundation。它还为我们提供了一系列 CSS 选择器命名法,如 CSS 的可伸缩模块化架构(SMACSS)和块元素修饰符(BEM)。尽管我们已经找到了管理 Light DOM 的方法,但这并没有改变根本的行为,即有人仍然可能通过添加一个不遵循指南的规则来破坏整个应用程序。

由于 CSS 选择器的贪婪特性,总是试图匹配尽可能多的元素,因此在用 CSS 进行页面样式缩放时一直伴随着痛苦。与 Light DOM 相比,Shadow DOM 赋予我们能力将 DOM 的一部分移入一个新领域,这个新领域不会与 Light DOM 样式交互。

在许多科幻故事中,人物可能会以某种方式被困在现实的一个新维度中,这个维度与正常现实分离,他们通常无法通过现实之间的某种“桥梁”进行交互。同样,我喜欢将使用 Shadow DOM 视为将当前上下文转移到与 Light DOM 非常有限连接的新维度,因此允许我们编写在无法修改其他样式的条件下渲染的 CSS 和 HTML。

开发者可以创建一个新的 Shadow DOM(称为 阴影根),这将切割出一个与 Light DOM 有限交互的独立 DOM 树。您仍然需要将此根作为节点附加到 DOM 树中。阴影边界 是 Light DOM 和 Shadow DOM 之间的线。有许多细微差别和功能使得某些形式的样式可以针对边界内或边界外,但我会将这些细节留给你,如果需要的话再深入研究。

在 图 1.6 中,您可以看到一个简单的示例,其中图像中间输出的第一行文本具有黑色背景和白色文字,而第二行文本(位于阴影根内)没有。

c01-6.png

图 1.6 Shadow DOM 示例,其中来自阴影根之外的风格不会跨越边界并应用于内部元素

Shadow DOM 为浏览器中样式和模板提供了最佳封装形式。它能够以某种方式隔离组件的内部,使得外部样式和脚本不会意外地附加并修改它。它确实提供了一些定制功能,允许您跨越阴影边界进行通信。这些功能在我们想要构建复杂且可重用的组件时尤为重要,这些组件可以完全自包含样式。

不幸的是,Shadow DOM 的支持可能不是所有浏览器都提供,可能需要 polyfill。第四章将更详细地探讨这个问题,但 Angular 允许我们编写使用 Shadow DOM、Shadow DOM 的模拟版本或仅 Light DOM 的组件。

1.5.3 模板

模板 是一个强大的功能,它允许我们创建用于组件中的独立 DOM 片段。我们的自定义元素需要某种内部结构,并且我们通常需要能够重用这种标记。理想情况下,这不应该使主文档杂乱无章,HTML5 引入了一个新的 template 标签来帮助我们。您可以在 www.w3.org/TR/html5/semantics-scripting.html#the-template-element 阅读规范。

在模板内部编写的任何标记都只是一个不属于当前页面的片段,除非它被显式初始化。换句话说,如果你查看 DOM 树,模板中的内容不会出现。如果你的标记包含 CSS、内联脚本、图像元素或其他通常触发浏览器操作的元素,这些操作将不会在模板使用之前运行。

模板通常与 Shadow DOM 一起使用,因为它允许你定义模板,然后将其注入到 shadow root 中。没有模板,Shadow DOM API 将需要我们逐个节点地注入内容。它们也被 Angular 作为组件生命周期和编译过程的一部分使用,允许 Angular 在数据变化和需要重新编译时保持模板的隔离、惰性副本作为数据。

模板的作用与整体组件架构很好地融合,并与 Shadow DOM 和自定义元素协同工作。它们提供了一层封装,允许你定义一个在需要时才激活的模板,从而将模板与应用程序的其他部分隔离开来。

1.5.3 JavaScript 模块

无论是 HTML 还是 JavaScript,传统上都没有在应用程序生命周期中加载额外文件或资产的原生方法。你必须确保所有需要的文件在页面加载时都已加载,或者使用一些通常依赖于发起 XHR 请求或向页面添加新脚本标签的解决方案。尽管这些方法有效,但它们并不特别优雅,也不总是容易使用。

今天,我们在 JavaScript 中有模块和模块加载器,这为在整个应用程序生命周期中加载和执行代码提供了原生方法,而不仅仅是页面加载时。以前,开发者必须提前构建包含所有资产的 Web 应用程序包,并将整个包交付给用户。模块(图 1.7)为我们提供了许多有趣的功能,其中许多对使用过具有包或模块功能的其他语言的开发者来说都很熟悉,比如 Java、Python 或 Go。

c01-7.png

图 1.7 Angular 提供了包含所有你需要构建应用程序的服务和对象的模块(如动画),但首先你必须导入它们。

本质上,模块并不是严格意义上的组件技术。模块是可以用来生成组件、创建可重用服务或执行 JavaScript 能做的任何其他事情的独立 JavaScript 代码块。它们本质上是封装应用程序代码并选择应用程序其他部分可以使用的内容的一种方式。

c01-8.png

图 1.8 从不同模块使用导入将对象加载到文件中

在 JavaScript 中,一个模块是任何包含 export 关键字的 JavaScript 代码文件。模块导出它们想要暴露给应用程序的值,并且可以保持内部逻辑的其他部分私有。然后,为了使用导出的值,你必须首先从另一个模块中导入它(图 1.8)。

在 图 1.8(来自后续章节的片段)中,我们首先从外部模块导入一些东西,这些模块是本文件中其余代码所依赖的。ComponentDoCheck 对象是从 @angular/core 包(它是我们的 node 模块目录的一部分)导入的,而 AccountService 是基于提供的文件路径导入的。

这些模块之所以强大,是因为它们 封装 了单个 JavaScript 文件的全部内容,形成一个单一的整体。它们 隔离 代码,并允许开发者有条件地导出值以共享。它们还通过定义在 JavaScript 应用程序中共享值的通用机制来支持 可重用性,这在之前只能通过直接在全局作用域上放置值或通过创建一些非标准的服务来管理依赖注入来实现,就像 Angular 1 所做的那样。

HTML 导入是一个类似的概念,它作为 HTML 规范的一部分被提出,将提供类似的功能。但很可能会采用 JavaScript 模块而不是 HTML 导入。有一些库使用 HTML 导入,例如 Polymer,通过使用 polyfill 库来实现。

Angular 本身完全围绕模块的概念构建。源代码广泛使用它们。当你编写自己的应用程序时,建议你也使用它们。执行 Angular 应用程序本质上是在加载一个包含应用程序引导逻辑的模块,然后它开始加载和触发其他模块。虽然不推荐,但可以使用 ES5 语法编写不使用模块的 Angular 应用程序,这将在下文中讨论。

1.6 现代 JavaScript 和 Angular

Angular 被设计用来利用许多相对较新的网络平台特性。其中大部分在 2015 年随着 ES2015(也称为 ES6,但我会使用其官方名称 ES2015)的发布成为 JavaScript 规范的一部分;其他特性在撰写本文时仍在开发中,但很可能在未来版本中得到采用。

这些特性在许多地方都有很好的介绍,所以我就不详细说明了。尽管它们可以与 AngularJS 一起使用,但 Angular 是设计用来利用这些功能的。我将快速介绍一些最重要的方面,即以下内容:

  • 装饰器

  • 模块

  • 模板字符串

让我们来看一个所有这些特性共同作用时的例子,然后回顾一下它们是如何组合在一起的。以下列表是一个功能性强但简单的 Angular 组件,你将在本书中看到更多使用相同概念但更复杂方式的例子。

列表 1.1 现代 JavaScript 语法

import {Component} from '@angular/core';     
 @Component({     
 selector: 'my-component',
 template: `
<div>     
 <h4>{{title}}</h4>
</div>     
`     
})
export class MyComponent {     
 constructor() {
 this.title = 'My Component';
 }
}      

让我们从底部开始,然后逐步进行。在 ES2015 中,类被引入作为一种定义对象的新方法,实际上它是一个函数。类用于创建组件、指令、管道和服务,尽管它们也可以以其他方式使用。使用 class 关键字创建 MyComponent 类,它是一个具有名为 title 的属性的对象。

类是 JavaScript 中创建对象的语法糖。它们没有为 JavaScript 引入新的继承类型,这一点很重要要记住。熟悉其他语言中类对象的开发者可能会意外地将概念带入 JavaScript,但在这个情况下,类的概念并没有改变 JavaScript 中原型继承的工作方式。

在类内部有一个特殊的方法叫做 constructor()。当创建对象的新副本时,它会立即执行。只要你的方法命名为 constructor(),它就会在创建过程中被使用。

类也很有用,因为它们有助于确保关键字 this 引用对象本身。关键字 this 是 JavaScript 中常见的障碍,类有助于确保其行为更加一致。

export 关键字表示文件是一个模块。任何模块都被隔离到一个私有空间中,除非值被导出,否则它将不可用于其他文件或模块。这打破了 JavaScript 中值的全局作用域,并在模块之间提供了适当的分离。因为 MyComponent 类被导出,所以它可以被导入到另一个模块中(此处未显示)。

在文件顶部,import 语句从 angular/core 模块中导入 Component 值,这使得它可以在本模块中使用。

然后在中间,我们使用 @Component 装饰器,这是一种向类添加元数据的方式。装饰器总是以 @ 符号开头,Angular 使用这些装饰器来理解声明的类的类型。在这种情况下,它是一个组件,Angular 将根据这个装饰器知道如何渲染组件。还有其他几个装饰器,例如 InjectablePipe,我们将在稍后看到它们的作用。

最后,装饰器接受一个包含与组件本身相关的元数据的对象。在这个例子中,它有两个属性用于选择器和内联 HTML 模板。装饰器定义了可以传递给这里的属性,但它们允许你自定义 Angular 处理类的方式。

1.6.1 可观察对象

除了新的语法之外,可观察对象 是 JavaScript 应用程序管理异步活动的新模式。它们也是 JavaScript 语言中要原生实现的功能的草案,因此这个模式有了一定的分量。RxJS 是我们将用来帮助我们实现应用程序中可观察对象的库。

Promise是另一种帮助处理异步调用的结构,例如,用于进行 API 请求很有用。Promise 的一个主要限制是它们只对一次调用周期有用。例如,如果你想在用户点击事件上让 Promise 返回一个值,那么这个 Promise 将在第一次点击时解决。但你可能对处理每个用户点击动作感兴趣。通常,你会使用事件监听器来处理这种情况,这允许你在一段时间内处理事件。这是一个重要的区别:可观察对象就像事件处理器一样,它们会随着时间的推移持续处理数据,并允许你连续处理那个数据流。

响应式编程是可观察对象提供的更高层次的名字,它是一种处理异步数据流的模式。如果你这么想,一个 Web 应用中的许多事物实际上都是异步数据流。用户在表单输入中输入按键实际上是一系列单个字符的流。计时器和间隔生成随时间推移的活动流。WebSockets 随时间流过数据。这很简单,但挑战在于如何理解这一切。

Angular 经常使用可观察对象模式,掌握基础知识是有用的。在本书的整个过程中,你会在多个地方看到可观察对象,它们都以相同的基本方式工作。我们不会在这里担心构造可观察对象。相反,我们将专注于当它们被提供给你时如何使用它们。

要使用可观察对象,你需要订阅数据流,并传递一个函数,该函数会在有新数据时运行。我们将在第二章中看到这个操作的示例,当我们发起 HTTP 请求时,但让我们先快速看一下一些语法:

this.http.get('/api/user').subscribe(user => {
    // Do something with the user record
}, (error) => {
    // Handle the error
}) 

这个片段正在使用 HTTP 库来发起一个get请求,该请求返回一个可观察对象。然后我们订阅这个可观察对象,当数据返回或错误被处理时,我们的回调函数就会被触发。它与 Promise 没有太大区别,只不过可观察对象可以继续发送数据。让我们看一个不同的例子:

this.keyboardService.keypress().subscribe(key => {
    // Do something with the key record
}, (error) => {
    // Handle the error
}) 

在这个例子中,假设keyboardService.keypress()返回一个可观察对象,并发出有关按下哪个键的详细信息。这就像一个事件监听器,只不过它以流的形式出现。

可观察对象的另一个有趣的能力是它们可以组合成多种组合。可观察对象可以组合、扁平化成一个,过滤,等等。我们将在第九章中看到一个示例,我们将合并两个可观察对象流,并在一个地方处理它们发出的数据。在这本书中,我们不会使用许多更复杂的功能,但你可能对它们是如何工作的感兴趣,所以我推荐阅读《RxJS in Action》(www.manning.com/books/rxjs-in-action)这本书。

1.7 TypeScript 和 Angular

Angular 本身是用 TypeScript 编写的,它是 JavaScript 的超集,引入了强制类型信息的能力。它可以与任何版本的 JavaScript 一起使用,因此你可以用它与任何 ES3(这不是一个打字错误)或更新的版本一起使用。

TypeScript 的基本价值主张是它可以对变量持有的值类型施加限制。例如,一个变量可能只能持有数字,或者它可能持有字符串数组。JavaScript 有类型(不要让任何人告诉你不是这样!),但变量没有类型,所以你可以将任何类型的值存储在任何变量中。这也催生了各种比较运算符,如 == 用于松散相等或 === 用于严格相等。

TypeScript 可以帮助在它们影响应用程序之前捕获许多简单的语法错误。有时你可以编写有效的 JavaScript,但现实世界表明,有效的语法并不总是意味着有效的行为。以下是一个例子:

var bill = 20;
var tip = document.getElementById('tip').value; // Contains '5'
console.log(bill + tip); // 205 

这个片段展示了简单的小费计算器示例,其中你从输入元素中获取值并将其添加到账单中,以获取总付款金额。这里的问题是 tip 变量实际上是一个字符串(因为它是一个文本输入)。将数字和字符串相加可能是新 JavaScript 开发者最常见的陷阱之一,尽管这可能会发生在任何人身上。如果你使用 TypeScript 来强制类型,这段代码可以编写为警告这种常见的错误:

var bill: number = 20;
var tip: number = document.getElementById('tip').value; // 5, error!
var total: number = bill + tip; // error! 

在这里,我们使用 TypeScript 声明所有这些变量必须各自持有数字值,通过使用 :number。这是一个简单的语法,位于 JavaScript 内部,告诉 TypeScript 变量应该持有哪种类型的值。tip 值将报错,因为它被分配了一个字符串,然后总值将报错,因为它尝试将数字和字符串类型相加,结果是一个字符串。

对于经验丰富的 JavaScript 开发者来说,这可能是明显的错误,但你有多少次让新开发者参与你的代码库?你有多少次重构你的代码?你能确保在维护应用程序的过程中,你的应用程序仍在传递相同的值类型吗?没有 TypeScript,你在使用每个值之前必须进行严格的比较检查。

许多开发者都在 wonder 为什么他们应该费心学习和使用 TypeScript。在我看来,以下是使用 TypeScript 的主要理由:

  • 使你的代码更清晰 — 具有类型的变量更容易理解,因为其他开发者(或六个月后的你自己)不必太费脑筋就能想清楚变量应该是什么。

  • 启用更智能的编辑器 — 当你使用 TypeScript 与支持编辑器时,你将获得代码的自动 IntelliSense 支持。当你编写代码时,编辑器可以建议已知的变量或函数,并告诉你它期望的类型值。

  • 在运行代码前捕获错误—TypeScript 会在你运行浏览器中的代码之前捕获语法错误,帮助你减少编写无效代码时的反馈循环。

  • 完全可选—当你需要时可以使用类型,也可以选择在不重要的地方省略它们。

我希望你已经认可了 TypeScript 的价值。如果没有,我希望你在阅读本书的过程中能更仔细地审视它。本书在示例中使用 TypeScript,以提供更多的清晰度并进一步展示 TypeScript 的强大功能。随着我们在示例中使用功能,我会尝试提供关于 TypeScript 特性和功能的额外见解,但你始终可以在 www.typescriptlang.org/docs/tutorial.html 上学习所有你需要知道的内容。

即使你选择不在你的应用程序中使用 TypeScript 进行类型检查,你仍然可以使用 TypeScript 来编译你的应用程序。因为 Angular CLI 已经内部使用了 TypeScript,你可能在使用它而自己却不知道。如果你决定构建自己的构建工具,TypeScript 仍然是一个值得考虑的编译器选项。

如果你想知道是否在 Angular 应用程序中使用 TypeScript 是必需的,技术上答案是无需。有方法可以编写不使用 TypeScript 的应用程序,并在一定程度上避免使用 TypeScript。但出于故意,这部分内容并未被文档化,因为 Angular 有许多功能在没有使用 TypeScript 的情况下无法正常工作。如果你担心它难以学习,请不要担心。它很简单,本书中我会在几个地方解释一些你可能之前未曾见过的 TypeScript 的细微差别。

摘要

本章介绍了 Angular 作为开发平台,而不仅仅是应用框架。Angular 有许多特性和功能。以下是一个简要总结:

  • Angular 是一个平台,内置了许多关键元素,如工具、UI 库和测试,这些都可以轻松集成到你的应用程序项目中。

  • 应用程序本质上是由组件组合而成的。这些组件建立在封装、隔离和可重用性的核心原则之上,应该具有事件、可定制性和声明性。

  • ES6 和 TypeScript 为 Angular 的架构和语法提供了很多基础,使其成为一个无需构建大量自定义语言功能的强大框架。

2

构建你的第一个 Angular 应用程序

本章涵盖

  • Angular 组件及其如何构成你的应用程序的基础

  • 使用装饰器定义多种类型的组件

  • 学习如何使用服务在应用程序中共享数据

  • 设置路由以显示不同的页面

在本章中,你将从零开始构建一个完整的 Angular 应用程序,同时学习 Angular 的主要概念。你将看到 TypeScript 的某些功能在实际应用中的表现,以及 JavaScript 的新功能和即将推出的功能。

这个项目将保持专注和简单,同时仍然代表了你将在典型应用程序中使用的许多功能。你将创建的应用程序是一个股票跟踪应用程序,数据来自 Yahoo! Finance。它将能够获取当前的股票价格,从列表中添加或删除股票,并根据当天的盈亏调整视觉显示。

在本章中,我们将逐步构建这个应用程序。我们将专注于通过示例应用程序,以足够的细节来理解本章中的各个部分和复杂性:

  • 引导 应用程序 — 要启动应用程序,我们将使用 引导 功能在加载完成后启动一切。这发生在应用程序生命周期中一次,我们将引导 App 组件。

  • 创建组件 — Angular 全是关于 组件,我们将为不同的目的创建几个组件。我们将了解它们是如何构建的,以及它们如何嵌套以创建复杂的应用程序。

  • 创建服务和使用 HttpClient — 为了代码重用,我们将把一些帮助管理股票列表的逻辑封装到一个 服务 中,并使用 Angular 的 HttpClient 服务来加载股票报价数据。

  • 在模板中使用管道和指令 — 使用 管道,我们可以在显示过程中将数据从一种格式转换为另一种格式,例如将时间戳格式化为本地日期格式。指令 是修改模板中 DOM 元素行为的实用工具,例如重复部分或条件性地显示元素。

  • 设置路由 — 大多数应用程序都需要允许用户在应用程序中导航的能力,通过使用 路由器,我们可以了解如何在不同的组件之间进行路由。

使用有限的代码,你可以创建一个健壮的应用程序,执行多个复杂任务。后续章节将详细介绍每个单独的功能,以更全面地了解 Angular 提供的一切。

你应该熟悉 ES2015 以及 JavaScript 语言的最新功能。我不会详细介绍新的语言结构,例如导入或类。我建议花些时间阅读 Mozilla 开发者网络 (developer.mozilla.org/en-US/docs/Web/JavaScript) 以获取更多详细信息,或者选择一本书来阅读。

2.1 预览章节项目

当我们完成时,应用应该看起来像你在图 2.1 和 2.2 中看到的那样。在我们构建它们之前,我们会简要地介绍各个部分,这样你就能看到它们是如何组合在一起的。

首先,有一个 API 从 Yahoo! Finance 加载当前的股票价格数据;它在 Heroku 上部署,但本章没有涵盖,但你可以在 github.com/angular-in-action/api 上查看 API 的代码。它是一个标准的 REST API,不需要身份验证。我们将创建一个服务来帮助我们访问和从 API 加载数据。

当应用加载时,它会显示仪表板页面 (figure 2.1),其中包含一系列卡片。每个卡片包含一个股票、当前价格和当天的价格变动(以货币值和百分比表示)。卡片的背景将为红色表示负变动,绿色表示正变动,或灰色表示无变动。这些卡片是组件的实例,它们接收股票数据并确定如何渲染卡片。

c02-1.png

图 2.1 — 股票跟踪应用的仪表板页面,包含链接和摘要卡片

最后,顶部的导航栏有两个链接,一个是仪表板,另一个是管理视图,这允许在视图之间进行一般导航。我们将使用 Angular Router 来设置这些路由并管理浏览器如何确定显示哪个视图。

当你在导航栏中点击管理链接时,你会看到管理页面 (figure 2.2),其中列出了股票。在这里,你可以通过点击删除按钮来删除任何股票。你也可以通过在文本区域中输入股票代码并按 Enter 键来添加新的股票。

c02-2.png

图 2.2 — 股票跟踪应用的管理页面,包含更改要显示的符号列表的表单

这个页面是一个单独的组件,但它包含一个表单,用户输入更改后立即更新。可以通过在输入字段中输入新的股票代码并按 Enter 键来扩展列表,或者通过点击删除按钮来减少列表。在两种情况下,符号列表都会立即更改,如果你回到仪表板,你会看到更新的列表出现。

这个项目有一些限制,你应该知道。为了保持示例的专注和简单,有一些细节没有包含在应用中:

  • 没有坚持 — 每次你在浏览器中刷新应用时,股票列表都会重置为默认列表。

  • 缺乏 错误检查 — 一些情况可能会引发错误或导致异常行为,例如尝试添加一个不存在的股票。

  • 没有单元测试 — 对于这个示例,我专注于代码,并故意省略了单元测试,这些将在后面介绍。

这个示例旨在为您提供 Angular 应用构建的概述——而不是提供一个坚不可摧的应用。我在本章末尾提供了一些有趣的挑战,以及许多可以想象的可能功能。

2.2 设置项目

我们将使用 Angular CLI 从头开始构建这个示例。如果您需要查看这个项目的代码,可以在 GitHub 上找到github.com/angular-in-action/stocks,每个步骤都有标签,这样您可以使用 Git 跟踪,或者您可以复制章节中的内容。

如果您还没有设置 Angular CLI,请回到第一章并设置它。本书中使用的是 CLI 版本 1.5,所以如果您使用的是旧版本,您可能需要升级。

在终端中,从一个您想要在其中生成新项目文件夹的目录开始。然后您可以使用以下命令生成新项目,并启动开发服务器:

ng new stocks
cd stocks
ng serve 

这将花费一些时间,因为 CLI 从 npm 安装了多个包,这取决于您网络的速率以及注册表的繁忙程度。一旦完成,您可以使用浏览器在 http://localhost:4200 查看应用。您应该看到一个简单的页面,上面写着有关这是一个新的 Angular 应用的内容,如图 2.3 所示。新项目的默认内容会随着时间的推移而变化,所以如果看起来有点不同,请不要担心。

c02-3.png

图 2.3 CLI 生成了一个包含一些默认内容的空白应用。

如果您看到类似的屏幕,那么一切应该都设置好了,准备就绪。这不是最令人兴奋的示例,但它为您自动设置了一些东西。我们现在将浏览已经生成的内容以及它是如何显示这个简单信息的。

2.3 基本应用骨架

CLI 生成了一个包含许多文件的新项目。我们暂时将关注最重要的文件,并随着时间的推移了解更多关于其他文件的信息。需要注意的是,CLI 以特定的方式生成文件,更改文件位置或名称可能会导致 CLI 失败。目前,我建议您在更熟悉之前不要移动文件,除非您计划稍后构建自己的工具。随着时间的推移,CLI 生成的确切文件和文件名可能会发生变化,所以如果您遇到任何问题,请查看 CLI 的变更日志和文档。

项目包含几个目录和文件。主要文件列在表 2.1 中,以及它们在应用程序中的通用角色。这些大多数是针对开发各个方面的配置,例如代码风格检查规则、单元测试配置和 CLI 配置。

表 2.1 CLI 生成的项目顶级内容及其角色

资产 角色
e2e 端到端测试文件夹,包含一个基本的存根测试
node_modules 标准的 NPM 模块目录,不应在此处放置代码
src 应用程序的源目录
.editorconfig 编辑器配置默认值
.angular-cli.json 关于此项目的 CLI 配置文件
karma.conf.js Karma 单元测试运行器的配置文件
package.json 标准的 NPM 包清单文件
protractor.conf.js Protractor 端到端测试运行器的配置文件
README.md 标准的 README 文件,包含启动信息
tsconfig.json TypeScript 编译器的默认配置文件
tslint.json TypeScript 代码风格检查的配置文件

在本章中,你将只修改位于 src 目录内的文件,该目录包含所有应用程序代码。表 2.2 列出了 src 目录内生成的所有资产。这看起来可能有很多文件,但它们各自都扮演着角色,如果你不确定某个文件的作用,现在就先不要动它。

表 2.2 src 目录的内容及其角色

资产 角色
app 包含主要应用程序组件和模块
assets 用于存储静态资源(如图片)的空目录
environments 环境配置,允许你为不同的目标(如开发或生产)构建
favicon.ico 作为浏览器收藏夹图标显示的图片
index.html 应用程序的根 HTML 文件
main.ts 网络应用程序代码的入口点
polyfills.ts 导入一些在特定浏览器上正确运行 Angular 所需的常见 polyfills
styles.css 全局样式表
test.ts 单元测试的入口点,不属于应用程序部分
tsconfig.app.json 应用的 TypeScript 编译器配置
tsconfig.spec.json 单元测试的 TypeScript 编译器配置
typings.d.ts 类型定义配置

现在你已经对生成的内容有了大致的了解,我们将检查构成应用程序逻辑的一些关键文件。下一节将更详细地探讨 Angular 如何将应用程序目录的内容渲染到你在屏幕上看到的结果。

2.4 Angular 渲染基本应用程序

在我们开始构建应用程序之前,你需要了解这个基本脚手架是如何工作的,以及我们需要添加什么。这是一个快速浏览,以便你尽可能快地开始,所以请期待在本书后面的内容中会有更深入和细致的探讨。在第三章中,我们将花更多的时间来探讨这些主题,以获得对如何构建一切的更深入理解。

Angular 至少需要一个组件和一个模块。一个 组件 是 Angular 应用程序的基本构建块,它类似于任何其他 HTML 元素。一个 模块 是 Angular 将应用程序的不同部分组织成一个 Angular 可以理解的单一单元的方式。你可能将组件想象成 LEGO® 砖块,它们可以有多种不同的形状、大小和颜色,而模块则是 LEGO® 砖块包装的方式。组件用于功能性和结构,而模块用于包装和分发。

2.4.1 App 组件

我们将从查看 src/app/app.component.ts 文件开始。这个文件包含了一个被称为 App 组件 的内容,它是应用程序的根组件。在 LEGO® 的术语中,你可以将这个组件想象成你用来开始构建的大绿色平台。下面的列表显示了组件的代码。再次强调,具体的代码可能会随时间而变化,所以如果它略有不同,请不要担心——它将具有相同的基本要求。

列表 2.1 生成 App 组件(src/app/app.component.ts)

import { Component } from '@angular/core';     
 @Component({     
 selector: 'app-root',
 templateUrl: './app.component.html',
 [styleUrls: ['./app.component.css']](#c02-codeannotation-0002)
})     
export class AppComponent {     
 title = 'app works!';
}      

如果你是 TypeScript 新手,那么列表中可能包含一些不熟悉的语法,所以让我们仔细看看代码的每个部分。首先,你导入 Component 注解。它用于通过添加与组件相关的详细信息来装饰 App 组件,但这些信息不是其控制器逻辑的一部分,即 AppComponent 类。Angular 会查看这些注解,并使用它们与 AppComponent 控制器类一起在运行时创建组件。

@Component 注解通过接受一个对象来声明这个类是一个组件。它有一个选择器属性,用于声明组件的 HTML 选择器。这意味着组件在模板中使用时,会通过添加一个 HTML 标签 <app-root></app-root> 来实现。

templateUrl 属性声明了一个指向包含 HTML 模板的模板的链接。同样,styleUrls 属性包含了一个指向任何应为此组件加载的 CSS 文件的链接数组。@Component 注解可以有更多属性,你将在本章中看到更多关于这些属性的使用。

最后,你可以看到 AppComponent 类有一个名为 title 的单个属性。其值是你应该在浏览器中看到的值,因此这是最终出现在浏览器中的值的来源。Angular 极大地依赖于 ES2015 类来创建对象,并且 Angular 中的几乎所有实体都是通过类和注解创建的。

现在,让我们通过打开 src/app/app.component.html 来查看与 App 组件相关的标记,如下所示:

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

如您所见,这只是一个简单的标题标签,但其中定义了双大括号之间的 title 属性。这是一种常见的绑定值到模板的方式(也许您熟悉 Mustache 模板),这意味着 Angular 将用组件中 title 属性的值替换 {{title}}。这被称为 插值,常用于在模板中显示数据。

我们已经查看过 App 组件,但现在我们需要查看 App 模块,以了解如何使用 Angular 进行连接和渲染。

2.4.2 应用模块

App 模块是一种打包方式,有助于告诉 Angular 可用哪些内容进行渲染。就像大多数食品项目都有包装来描述内部的各种成分和其他重要值一样,模块描述了渲染模块所需的各种依赖项。

应用程序中至少有一个模块,但出于不同的原因(稍后介绍),可以创建多个模块。在这种情况下,这是之前提到的 App 组件以及大多数应用程序所需的其他功能(如路由、表单和 HttpClient)。

CLI 为我们生成了模块,因此我们可以在 src/app/app.module.ts 中查看它,如下所示。再次强调,这些内容可能会随时间变化,但结构和目的保持不变。

列表 2.2 App 模块(src/app/app.module.ts)

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

import { AppComponent } from './app.component';     
 @NgModule({     
 declarations: [
 AppComponent
 [],](#c02-codeannotation-0007)
 imports: [
 BrowserModule,
 [],](#c02-codeannotation-0008)
 [providers: [],](#c02-codeannotation-0009)
 [bootstrap: [AppComponent]](#c02-codeannotation-0010)
})
export class AppModule { }     

就像组件一样,模块也是一个具有装饰器的对象。这里的对象称为 AppModule,而 NgModule 是装饰器。第一个块是导入大多数应用程序通用的任何 Angular 依赖项以及 App 组件。

NgModule 装饰器接受一个具有几个不同属性的对象。declarations 属性用于提供一个组件和指令列表,以便在整个应用程序中可用。

imports 属性是一个数组,包含此模块所依赖的其他模块——在本例中是浏览器模块(一组必需的功能)。如果您包含其他模块,例如第三方模块或您自己创建的模块,它们也需要在此列出。

下一个属性是 providers 属性,默认为空。任何创建的服务都需要在此列出,我们将在稍后看到如何进行此操作。

最后,bootstrap 属性定义了在运行时启动哪些组件。通常,这将是相同的 App 组件,CLI 已经为我们设置好了。bootstrap 属性应与下一节中启动的组件相匹配。

我们编写的代码为 Angular 创建了一个配置,以便它查看和理解如何渲染。接下来要查看的是启动时执行的代码,这被称为 启动

2.4.3 启动应用程序

应用程序必须在运行时引导以启动渲染过程。到目前为止,我们只声明了代码,但现在我们将看到它是如何执行的。CLI 负责连接基于 webpack 的构建工具。

首先,查看 .angular-cli.json 文件。你会看到一个应用程序数组,其中一个属性是 main 属性。默认情况下,它指向 src/app/main.ts 文件。这意味着当应用程序构建时,它将自动调用 main.ts 文件的内容作为第一组指令。

main.ts 文件的作用是引导 Angular 应用程序。main.ts 文件的内容包含在以下列表中,并且只包含几个基本指令。

列表 2.3 启动时调用的主文件(src/app/main.ts)

import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';     
import { enableProdMode } from '@angular/core';     
import { environment } from './environments/environment';     
import { AppModule } from './app/';     

if (environment.production) {     
 enableProdMode();
}     

platformBrowserDynamic().bootstrapModule(AppModule);     

第一部分导入了一些依赖项,特别是 platformBrowserDynamicAppModule。名称有点长,但 platformBrowserDynamic 对象用于告诉 Angular 正在加载哪个模块,在这种情况下是之前提到的 AppModule。我在书中稍后介绍了模块的渲染,但到目前为止,重要的是要理解这是代码开始执行的地方。

通过查看 index.html 文件,我们可以审查最后一件事情。如果你记得 App 组件代码,有一个 app-root 选择器用于在标记中识别组件。你应该在 src/index.html 文件中看到以下内容:

<body>
  <app-root></app-root>
</body> 

一旦应用程序通过 列表 2.3 中的代码引导,Angular 将寻找 app-root 元素并将其替换为渲染的组件。这就是你在 图 2.1 中看到的内容,但在加载一切的同时,你会看到一个“正在加载...”的消息。在组件渲染之前,所有资产都需要加载和初始化,这可能需要一点时间。这被称为 即时编译(JiT),意味着所有内容都是按需在浏览器中加载和渲染的。JiT 仅适用于开发,可能在未来的版本中被移除。

我想添加一些小的细节,帮助我们通过添加一些基本的 CSS 和标记来设计应用程序的其余部分。首先,我们需要向我们的 src/index.html 添加两个链接标签:

<link rel="stylesheet" href="//storage.googleapis.com/code.getmdl.io/1.0.1/material.indigo-orange.min.css">
<link rel="stylesheet" href="//fonts.googleapis.com/icon?family=Material+Icons"> 

这将加载一些字体图标和应用程序的全局样式,这些样式基于 Material Design Lite 项目。这是你可以加载外部样式库或其他资产引用的一种方式。

我们希望给我们的应用程序添加一些全局样式。将以下内容添加到 src/styles.css 文件中——它将为应用程序提供一个浅灰色背景:

body {
  background: #f3f3f3;
} 

最后,我们想要设置一些基本标记来构建我们的应用程序。让我们用以下列表中的标记替换 src/app/app.component.html 文件的内容。

列表 2.4 基本标记脚手架(src/app/app.component.html)

<div class="mdl-layout mdl-js-layout mdl-layout--fixed-header">
  <header class="mdl-layout__header">
    <div class="mdl-layout__header-row">
      <span class="mdl-layout-title">Stock Tracker</span>
      <div class="mdl-layout-spacer"></div>
      <nav class="mdl-navigation mdl-layout--large-screen-only">
        <a class="mdl-navigation__link">Dashboard</a>
        <a class="mdl-navigation__link">Manage</a>
      </nav>
    </div>
  </header>
  <main class="mdl-layout__content" style="padding: 20px;">

  </main>
</div> 

这个标记基于 Material Design Lite 设计风格,用于创建基本的工具栏和主体。工具栏包含标题和两个链接(目前处于非活动状态),应该看起来像图 2.4。

c02-4.png

图 2.4 修改后的基本脚手架以使用 Material Design Lite 标记

好的,我们已经使用 CLI 创建了基本的应用程序脚手架,看到了 App 组件、App 模块和引导逻辑,并找到了渲染组件的标记。恭喜你,你已经制作了你的第一个 Angular 应用程序!好吧,我知道这还不是那么令人印象深刻——但这是每个 Angular 应用程序的基本部分。在接下来的章节中,我们将从这个基本应用程序开始,逐步构建到完整的股票跟踪示例。要开始,你将学习如何创建一个从 API 加载数据的 Angular 服务。

2.5 构建服务

服务 是对象,它们抽象了一些你计划在多个地方重用的常用逻辑。由于它们是对象,它们可以执行你需要它们做的任何事情。使用 ES2015 模块,这些类被导出,因此任何组件都可以按需导入它们。它们也可以有函数,甚至静态值,如字符串或数字,作为在应用程序的各个部分之间共享数据的方式。

另一种思考服务的方式是将它们视为可共享的对象,你的应用程序的任何部分都可以按需导入。它们能够抽象一些逻辑或数据(例如从源加载某些数据所需的逻辑),因此很容易在任何组件中使用。

尽管服务通常有助于管理数据,但它们并不局限于任何特定的工作。服务的目的是使代码可重用。一个服务可能是一组需要共享的常用方法。你可以有各种“辅助方法”,例如解析数据格式或需要在多个地方运行的认证逻辑,你不想反复编写。

在应用程序中,你将需要有一个股票列表,用于仪表板和管理页面。这是一个使用服务来帮助管理和在不同组件间共享数据的完美场景。

CLI 为我们提供了一种创建具有所需脚手架的服务的好方法。它还将为该服务生成一个简单的服务和一个测试存根。要生成一个服务,你运行以下命令:

ng generate service services/stocks 

CLI 会生成 src/app/services 目录下的文件。它包含最基本的服务,该服务不执行任何操作。让我们继续填写整个服务的代码,并了解它是如何工作的。你最终将补充以下列表中的代码。股票服务将有一个包含股票代码列表的数组,并公开一组方法来检索或修改股票列表。

列表 2.5 股票服务(src/app/services/stocks.service.ts)

import { Injectable } from '@angular/core';     
import { HttpClient } from '@angular/common/http';     

[let stocks: Array<string> = ['AAPL', 'GOOG', 'FB', 'AMZN', 'TWTR'];     ](#c02-codeannotation-0016)
let service: string = 'https://angular2-in-action-api.herokuapp.com';     

export interface StockInterface {     
 symbol: string;
 lastTradePriceOnly: number;
 change: number;
 changeInPercent: number;
}     

@Injectable()     
export class StocksService {     
 constructor(private http: HttpClient) {}
 get() {
 return stocks.slice();
 }

 add(stock) {
 stocks.push(stock);
 return this.get();
 }

 remove(stock) {
 stocks.splice(stocks.indexOf(stock), 1);
 return this.get();
 }

 load(symbols) {
 if (symbols) {
 return this.http.get<Array<StockInterface>>(service + '/stocks/snapshot?symbols=' + symbols.join());
 }
 }
} 

服务首先需要导入其依赖项;一个是服务的装饰器,另一个是 HttpClient 服务。然后它声明两个变量;一个是跟踪股票符号列表,另一个是 API 端点 URL。

然后定义并导出 StockInterface 接口供其他组件使用。这提供了 TypeScript 对股票对象应包含内容的定义,TypeScript 使用它来确保数据的使用保持一致。我们将在以后使用它来确保在它们被使用时正确地为我们股票对象进行类型化。

StocksService 类被导出并由 Injectable 装饰器装饰。装饰器用于设置适当的连接,以便 Angular 知道如何在其他地方使用它,所以如果你忘记包含装饰器,类可能无法注入到你的应用程序中。

在构造函数方法中,使用 TypeScript 技术声明一个名为 http 的私有变量,并给它一个 HttpClient 类型。Angular 可以检查类型定义并确定如何将请求的对象注入到类中。如果你是 TypeScript 新手,请记住,每次你在变量声明后看到冒号,你都是在定义应该分配给该变量的对象类型。

该服务包含四个方法。get() 方法是一个简单的返回 stocks 数组当前值的函数,但它总是返回一个副本而不是直接值。这样做是为了封装股票值并防止它们被直接修改。add() 方法向 stocks 数组添加一个新项目并返回新修改后的值。remove() 方法将从 stocks 数组中删除一个项目。

最后,load() 方法调用 HttpClient 服务来加载当前股票价格数据。HttpClient 服务被调用并返回一个可观察对象(observable),这是一个用于处理异步事件(如 API 调用中的数据)的构造。我们在第一章中简要介绍了可观察对象,将在其他章节中看到更多,但这是你第一次看到它们在实际操作中的样子。

HttpClient 有一个小功能,作为 get() 方法的一部分出现,并被放置在两个尖括号之间:

this.http.get<Array<StockInterface>>(... 

这被称为 类型变量,是 TypeScript 的一个特性,允许你告诉 http.get() 方法它应该期望什么类型的对象,在这种情况下,它将期望得到一个符合 StockInterface(我们的股票对象)的对象数组。这是可选的,但如果尝试访问不存在的属性,它对编译器非常有帮助。

我们还需要进行一个额外的步骤,因为命令行界面(CLI)不会自动将服务注册到 App 模块中,我们还需要将 HttpClient 注册到应用程序中。打开 src/app/app.module.ts 文件,并在顶部附近添加这两个导入:

import { HttpClientModule } from '@angular/common/http';
import { StocksService } from './services/stocks.service'; 

这将导入 Stocks 服务和HttpClientModule到文件中,但我们需要将HttpClientModule注册到应用程序中。找到在 NgModule 中定义的导入部分,并像下面这样更新它以包括HttpClientModule

 imports: [
    BrowserModule,
    HttpClientModule
  ], 

现在我们需要将新的StocksService注册到providers属性中,以通知 Angular 它应该对模块可用:

providers: [StocksService], 

你的服务已经配置好并准备好使用,但我们还没有在我们的应用程序中使用它。下一节将探讨如何使用它。

此服务并不复杂。它主要设计用于抽象数组的修改,以便它不会被直接修改,并从 API 加载数据。当应用程序运行时,stocks数组可以被修改,并且更改将反映在仪表板和管理组件中,正如你很快就会看到的。因为它被导出,所以当需要时很容易导入。

现在,你将创建一个使用一些默认指令并允许可配置属性修改组件显示的组件。

2.6 创建你的第一个组件

你已经看到了一个基本组件(App 组件)。现在,你将构建一个更复杂的组件,该组件使用一些指令和管道,并有一个属性。我们将创建一个显示股票价格信息基本摘要卡的组件。

此组件将只从其父组件接收要显示的数据,并根据输入值修改自己的显示。例如,父组件将传递特定股票的当前数据,而摘要组件将使用每日变化来确定背景应该是绿色还是红色,这取决于股票是上涨还是下跌。

本组件的关键目标如下:

  • 接收股票数据并显示

  • 根据当天的活动更改背景颜色(增长时为绿色,减少时为红色)

  • 格式化值以正确显示,例如货币或百分比值

图 2.5 显示了组件的位置,我们甚至将其连接到从 API 加载数据。最终,我们将实例化多个此组件的副本,以显示每个股票的卡片。

c02-5.png

图 2.5 单个显示股票数据的摘要组件

显然,当你运行此操作时,股票价值将根据最新数据而变化,但你可以看到显示当前数据的卡片。让我们深入了解构建这张卡片,然后我们将逐步讲解它是如何产生这个输出的各个部分。

返回终端并运行以下命令:

ng generate component components/summary 

CLI 将在 src/app/components/summary 目录内生成一个新的组件。我们首先必须创建 src/app/components 目录,因为 CLI 不会自动为你创建缺失的文件夹。这有助于将组件组织到单个目录中,尽管你也可以选择在其他地方生成它们。

现在组件的内容与 App 组件最初出现时的内容非常相似。它包含一个空的 CSS 文件、基本的 HTML 模板、测试占位符以及已经初始化并带有组件注解的空类。

我们将首先设置组件的模板,然后我们将创建控制器来管理它。打开src/app/components/summary/summary.component.html文件,将其内容替换为以下列表中的内容。

列表 2.6 摘要组件模板

[<div class="mdl-card stock-card mdl-shadow--2dp" [ngClass]="{increase: isPositive(), decrease: isNegative()}" style="width: 100%;">     ](#c02-codeannotation-0025)
 <span>
    <div class="mdl-card__title">
      <h4 style="color: #fff; margin: 0">
 {{stock?.symbol?.toUpperCase()}}<br />
 {{stock?.lastTradePriceOnly | currency:'USD':'symbol':'.2'}}<br />
 {{stock?.change | currency:'USD':'symbol':'.2'}} ({{stock?.changeInPercent | percent:'.2'}})
 </h4>
    </div>
  </span>
</div> 

模板包含一些标记来结构化卡片,使其看起来像一张材料设计卡片。如果我们查看第一行,我们会看到这个片段作为div元素上的一个属性:

[ngClass]="{increase: isPositive(), decrease: isNegative()}" 

这是一个特殊类型的属性,称为指令。指令允许您修改模板中 DOM 元素的行为和显示。把它们想象成 HTML 元素上的属性,可以导致元素改变其行为,例如禁用 HTML 输入元素的disabled属性。指令使得添加一些条件逻辑或以其他方式修改模板的行为或渲染方式成为可能。

NgClass 指令能够向元素添加或移除 CSS 类。它被分配了一个值,这个值是一个包含 CSS 类名的属性对象,这些属性映射到控制器上的一个方法(待编写)。如果方法返回 true,它将添加该类;如果为 false,它将被移除。在这个片段中,卡片在当天交易为正时将获得increase CSS 类,在为负时将获得decrease CSS 类。

Angular 内置了一些指令,您将在本章中看到更多。指令通常接受一个表达式(如本例中的我们的对象),该表达式由 Angular 评估并传递给指令。表达式可能评估为布尔值或其他原始值,或者解析为一个函数调用,该函数调用将在指令运行之前执行以返回一个值。根据表达式的值,指令可能会做不同的事情,例如根据表达式是 true 还是 false 来显示或隐藏。

我们之前看到了一个插值的例子,但现在有一个更复杂的例子,用于显示股票的符号。控制器预期有一个名为stock的属性,它是一个包含各种值的对象:

{{stock?.symbol?.toUpperCase()}} 

双大括号语法是显示页面中某些值的方式。这被称为插值,如果您还记得之前的内容,这要复杂一些。大括号之间的内容被称为Angular 表达式,它将与控制器(如指令)进行评估,这意味着它将尝试在控制器上找到一个属性来显示。如果失败,通常它会抛出一个错误,但安全导航运算符?.将在属性缺失时静默失败,并且不会显示任何内容。

此代码块将显示股票符号,但为大写形式。大多数 JavaScript 表达式都是有效的 Angular 表达式,尽管有些事情是不同的,例如安全导航运算符。调用原型方法(如 toUpperCase())的能力仍然存在,这就是它能够将文本渲染为大写的原因。

下一个插值显示了最后交易价格,并添加了一个名为 管道 的另一个功能,这些管道直接添加到表达式中以格式化输出。插值表达式通过管道符号 | 扩展,然后通过冒号 : 分隔的命名管道进行配置(可选)。价格值返回为普通浮点数(如 111.8),这与货币格式不同,货币应显示为 $111.80:

{{stock?.lastTradePriceOnly | currency:'USD':'symbol':'.2'}} 

管道只修改显示之前的数据,并不改变控制器中的值。在这段代码中,双大括号表示您希望将存储在 stock.lastTradePriceOnly 属性中的数据绑定以显示。数据通过货币管道传输,该管道根据美元数值将值转换为金融数字,并四舍五入到两位小数。现在让我们看看下一行:

{{stock?.change | currency:'USD':'symbol':'.2'}} ({{stock?.changeInPercent | percent:'.2'}}) 

下一行也有两个不同的插值绑定,使用货币或百分比管道。第一个将转换为相同的货币格式,但第二个将百分比作为小数(如 0.06)转换成 6%。Angular 文档可以详细说明所有可用的选项以及如何为每个管道使用它们。

此模板单独不起作用;它需要一个控制器来连接数据和方法。让我们打开 src/app/components/summary/summary.component.ts 文件,并替换您在以下列表中看到的代码。

列表 2.7 摘要组件控制器

import { Component, Input } from '@angular/core';     
 @Component({     
 selector: 'summary',
 [styleUrls: ['./summary.component.css'],](#c02-codeannotation-0030)
 templateUrl: './summary.component.html'
})     
export class SummaryComponent {     
 @Input() stock: any;
 isNegative() {
 return (this.stock && this.stock.change < 0);
 }

 isPositive() {
 return (this.stock && this.stock.change > 0);
 }
} 

此控制器导入依赖项,这几乎是任何用 TypeScript 编写的文件的第一个代码块。组件元数据描述了选择器、链接样式和链接模板文件,这些构成了组件。我们稍后将向样式添加一些 CSS。

摘要控制器类以一个名为 stock 的属性开始,该属性前带有 Input 注解。这表示此属性将由父组件提供,并将其传递给摘要。属性通过属性绑定到元素,如您在此处所见——此示例将设置父组件的 stockData 的值在摘要组件的 stock 属性中:

<summary [stock]="stockData"></summary> 

因为输入是通过绑定属性传递的,所以它将评估表达式并将其传递到该属性中,以便摘要组件可以消费。Angular 表达式在存在绑定时表现相同。它们试图在控制器中找到对应值以绑定到属性。

最后,有两个方法用于检查股票值是正数还是负数。股票也可能是中性的,所以这是默认状态,只有当股票发生变化时,其中一个方法才会返回 true。这些方法由 NgClass 指令使用,如模板中之前所述,以确定是否添加特定的 CSS 类。

我们想要添加的最后部分是 CSS 类本身。Angular 有一些有趣的方式来封装 CSS 样式,以确保它们只应用于单个组件。我们稍后会深入探讨具体细节,但请打开 src/app/components/summary/summary.component.css 文件,并添加样式,如下面的列表所示。

列表 2.8 摘要组件 CSS 样式

:host .stock-card {     
 background: #333333; 
}
:host .stock-card.increase {     
 background: #558B2F;
  color: #fff;
}
:host .stock-card.decrease {     
 background:#C62828;
  color: #fff;
} 

这是一种典型的 CSS,尽管你可能以前没有见过或使用过 :host 选择器。因为组件需要尽可能自包含,它们依赖于第一章中讨论的 Shadow DOM 概念。当 Angular 渲染此组件时,它会修改输出以确保 CSS 选择器是唯一的,并且不会意外地干扰页面上的其他元素。这种行为是可以配置的,但稍后我们会介绍。

主选择器是一种指定您希望样式应用于宿主元素的途径,因此在这种情况下,它将查看摘要组件元素本身,而不是其内容。这里 CSS 的主要目的是确定摘要组件的背景颜色。

我们已经走过了摘要组件的生成过程,并构建了一个功能组件。让我们快速使用它来了解一下它的行为。

查看 src/app/app.module.ts 文件,你会看到 CLI 已经修改了模块,使其包含在 App 模块中的摘要组件。这里没有需要做的事情,但我想要指出这一点。

现在查看 src/app/app.component.ts 并更新为以下列表的内容。这将包括股票服务,并使用它将股票数据存储到属性中。然后我们将使用它来显示摘要卡片。

列表 2.9 应用组件控制器

import { Component } from '@angular/core';
import { StocksService, StockInterface } from './services/stocks.service';
     
 @Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
 stocks: Array<StockInterface>;
 constructor(service: StocksService) {
    service.load(['AAPL']).subscribe(stocks => {
 this.stocks = stocks;
 });
  }
} 

在这里,我们将加载的股票数据存储在一个名为 stocks 的属性中。我们还提供了一些类型信息,这些信息是从我们的股票服务中导入的,这样 TypeScript 就知道期望什么类型的值。最后,我们不是将数据记录到控制台,而是将其存储在 stocks 属性中。

现在,我们需要更新 src/app/app.component.html 文件以使用摘要组件。以下是您需要从模板中更新的片段:

<main class="mdl-layout__content" style="padding: 20px;" *ngIf="stocks">
  <summary [stock]="stocks[0]"></summary>
</main> 

添加的第一行是 *ngIf="stocks",这是一个指令,只有当表达式为真时才会渲染元素内的内容。在这种情况下,它不会渲染摘要组件,直到股票数据被加载。

中间行显示了单个摘要组件的实例化,stocks数组的第一个值绑定到stock属性上。数据以数组形式返回,因此我们直接访问第一个值。回想一下在摘要组件中声明的输入值,它也命名为stock

保存此文件并运行应用后,它应该最终显示一张包含苹果股票当前数据的单个摘要卡片。我们已经创建了第一个组件,并在应用内部显示了它!

接下来,你将创建另一个组件,并将其与摘要组件一起使用,以创建显示股票列表及其当前状态的仪表板。

2.7 使用组件和服务的组件

我们已经准备好将之前创建的摘要组件和股票服务合并成一个可工作的仪表板组件。这个组件将包含应用的一个完整页面,正如你在图 2.6 中看到的那样。这个组件将使用股票服务来管理数据的加载,然后使用摘要组件的副本显示每一支股票。

c02-6.png

图 2.6 仪表板组件连接了加载数据并显示五个摘要组件实例

我们将看到如何正确地编排一个完整的视图,而不是我们迄今为止的孤立示例。要开始,我们可以再次使用 CLI 来生成另一个组件:

ng generate component components/dashboard 

这将在src/app/components/dashboard目录中输出新的 HTML、CSS、控制器和单元测试文件。它还将组件添加到 App 模块中,使其立即可使用。让我们通过修改src/app/app.component.html文件中的内容来重置我们的工作项目,以显示这个新组件:

 <main class="mdl-layout__content" style="padding: 20px;">
    <dashboard></dashboard>
  </main> 

这应该显示应用中的默认组件消息,因为这是 CLI 生成的默认代码。我们还需要从应用组件控制器中移除一些逻辑;它现在应该看起来像这里所示。这移除了应用组件本身中的导入和股票数据的加载,我们将在稍后将其放入仪表板中。将src/app/app.component.ts的内容替换为以下内容:

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

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

太好了!我们现在已经清理了应用组件,准备开始构建仪表板。我们的首要任务是设置仪表板控制器。它的任务是使用股票服务来加载数据,并使其可供组件使用。

打开src/app/components/dashboard/dashboard.component.ts控制器,并用以下列表中的代码替换它。

列表 2.10 仪表板控制器

import { Component, OnInit } from '@angular/core';     
import { StocksService, StockInterface } from '../../services/stocks.service';     

@Component({
  selector: 'dashboard',
  templateUrl: './dashboard.component.html',
  styleUrls: ['./dashboard.component.css']
})
export class DashboardComponent implements OnInit {     
 stocks: Array<StockInterface>;
 symbols: Array<string>;
 constructor(private service: StocksService) {
 this.symbols = service.get();
 }

 ngOnInit() {
 this.service.load(this.symbols).subscribe(stocks => this.stocks = stocks);
 }
} 

控制器首先导入 Component 注解和 OnInit 接口。如果你之前没有实现接口,接口是一种强制类包含所需方法的手段——在这种情况下,名为 ngOnInit 的方法。随着项目规模的增大,利用 TypeScript 强制代码类型和接口的能力是有帮助的。

DashboardComponent 类是组件控制器,它声明必须实现 OnInit 的要求。如果不实现,TypeScript 将无法编译代码并抛出错误。它有两个属性:一个股票数组和一个表示要显示的股票符号的字符串数组。最初它们是空数组,因此我们需要将它们加载以使组件能够渲染。

constructor 方法在组件创建时立即运行。它将 Stocks 服务导入到 service 属性,然后从它请求当前的股票符号列表。这是因为这是一个同步操作,直接从内存中加载一个值。

但我们不会在构造函数中从服务加载数据,原因有很多。我们将在本书的后面深入探讨这些复杂性,但主要原因是由于组件的渲染方式。构造函数在组件渲染的早期阶段触发,这意味着通常,值还没有准备好被消费。组件暴露了多个生命周期钩子,允许你在渲染的各个阶段执行命令,从而让你对何时发生事件有更大的控制权。

在我们的代码中,我们使用 ngOnInit 生命周期钩子调用服务来加载股票数据。它使用在构造函数中加载的股票符号列表。然后我们订阅等待结果返回并将它们存储在 stocks 属性中。这使用了处理异步请求的可观察方法。我们将在稍后深入探讨可观察对象。这里我们使用它们是因为 HttpClient 为我们返回了一个可观察对象,以便接收响应。尽管它是一个单一的事件,但它被暴露为一个数据流。

现在我们需要通过添加模板来完成组件。打开 src/app/components/dashboard/dashboard.component.html 文件,并用以下列表的内容替换它。

列表 2.11 Dashboard 组件模板

<div class="mdl-grid">
 <div class="mdl-cell mdl-cell--12-col" *ngIf="!stocks" style="text-align: center;">
 Loading
 </div>
 <div class="mdl-cell mdl-cell--3-col" *ngFor="let stock of stocks">
 [<summary [stock]="stock"></summary>](#c02-codeannotation-0049)
 </div>
</div> 

模板包含一些类,用于使用 Material Design Lite UI 框架的网格结构。模板还包含另一个 NgIf 属性,在加载数据时显示加载消息,就像我们之前使用的那样。一旦股票数据从 API 返回,加载消息将被隐藏。

然后我们看到另一个具有新指令 NgFor 的元素。像 NgIf 一样,它以*开头,表达式类似于你会在传统的 JavaScript for循环中使用的表达式。表达式包含let stock of stocks,这意味着它将遍历stocks数组中的每个项目,并通过名为stock的局部变量暴露它。再次强调,这是你在 JavaScript for循环中会看到的行为,但应用于 HTML 元素上下文中。

NgFor 将为每个股票项目创建 Summary 组件的一个实例。它将股票数据绑定到组件中。每个 Summary 组件的副本与其他组件不同,并且它们不会直接共享数据。

你现在已经完成了仪表板视图,它使用服务和另一个组件来渲染体验。当你现在运行应用程序时,你应该看到五个默认股票作为页面上的单独卡片出现。网格布局应该将它们排列成四列。

接下来,你将构建一个新的组件,该组件具有一个表单,用于管理在显示股票时使用的股票符号列表。

2.8 带有表单和事件的组件

我们想要管理显示的股票,因此我们需要添加另一个具有用于编辑股票列表的表单的组件(图 2.7)。此表单将允许用户输入要添加到列表中的新股票符号,并将有一个当前股票列表,其中包含一个按钮,可以从中删除股票。这个股票列表在整个应用程序中共享,因此任何更改都会在其他地方复制。

c02-7.png

图 2.7 具有用于添加项目并删除现有股票按钮的 Manage 组件

表单在应用程序中至关重要,Angular 自带内置支持用于构建具有许多功能的复杂表单。Angular 中的表单由任意数量的控件组成,这些控件是表单可能包含的各种输入和字段(如文本输入、复选框或某些自定义元素)。

让我们从生成用于管理视图的新组件开始。使用 CLI 运行以下命令,并记住,这将自动将组件注册到 App 模块中,使其准备好使用:

ng generate component components/manage 

现在更新 src/app/app.component.html 文件,并更改主元素的内容,如以下代码所示,以便 Manage 组件在应用程序中显示。然后当你运行应用程序时,它将显示任何新组件的默认消息:

<main class="mdl-layout__content" style="padding: 20px;">
  <manage></manage>
</main> 

我们还需要将FormsModule添加到我们的应用程序中,因为我们将要使用 Angular 自动包含的表单功能。打开 src/app/app.module.ts 文件并添加一个新的导入:

import { FormsModule } from '@angular/forms'; 

然后更新模块的导入定义,声明FormsModule,如下所示:

imports: [
  BrowserModule,
  HttpClientModule,
  FormsModule,
], 

让我们开始制作我们的 Manage 组件,通过更新控制器添加一些逻辑。在 图 2.7 中,你会看到我们需要加载存储在内存中的符号列表。还需要有两个方法:一个用于处理股票的删除,另一个用于将新的股票符号添加到列表中。

打开 src/app/components/manage/manage.component.ts 并更新它以匹配以下列表。这将包括为该视图所需的额外方法和设置。

列表 2.12 Manage 组件控制器

import { Component } from '@angular/core';     
 import { StocksService } from '../../services/stocks.service';     

@Component({     
 selector: 'manage',
 templateUrl: './manage.component.html',
 [styleUrls: ['./manage.component.css']](#c02-codeannotation-0051)
})     
export class ManageComponent {     
 symbols: Array<string>;
 stock: string;

 constructor(private service: StocksService) {
 this.symbols = service.get();
 }

 add() {
 this.symbols = this.service.add(this.stock.toUpperCase());
 this.stock = '';
 }

 remove(symbol) {
 this.symbols = this.service.remove(symbol);
 }
} 

如常,我们首先导入组件的依赖项。然后使用 @Component 注解声明组件元数据。接着声明 class 对象,其中包含两个属性:第一个是从股票服务检索到的符号数组,第二个是用于存储输入值的属性。我们将在模板中看到 stock 属性是如何与输入字段关联的,但这是它首次定义的地方。

构造函数使用服务获取股票符号数组并将其存储在 symbols 属性上。这不需要 OnInit 生命周期钩子,因为它是对内存中存在的数据进行同步请求。

然后有两个方法用于向列表中添加或删除符号。服务始终返回 stocks 符号数组的副本,因此我们必须使用服务方法来管理列表(该列表封装在服务中,不能直接修改)。add 方法将向符号列表添加新项,然后将修改后的列表存储到符号列表中。相反,remove 方法将从数组中删除项并刷新控制器中的符号列表。

此控制器满足了处理表单操作的需求,但现在我们需要创建模板来显示表单及其内容。打开 src/app/components/manage/manage.component.html 并添加以下列表中的内容。

列表 2.13 Manage 组件模板

<div class="mdl-grid">
  <div class="mdl-cell mdl-cell--4-col"></div>
  <div class="mdl-cell mdl-cell--4-col">
 <form style="margin-bottom: 5px;" (submit)="add()">
 [<input name="stock" [(ngModel)]="stock" class="mdl-textfield__input" type="text" placeholder="Add Stock" />](#c02-codeannotation-0057)
 </form>
    <table class="mdl-data-table mdl-data-table--selectable mdl-shadow--2dp" style="width: 100%;">
      <tbody>
 <tr *ngFor="let symbol of symbols">
 <td class="mdl-data-table__cell--non-numeric">{{symbol}}</td>
 <td style="padding-top: 6px;">
 <button class="mdl-button" (click)="remove(symbol)">Remove</button>
 </td>
        </tr>
      </tbody>
    </table>
  </div>
  <div class="mdl-cell mdl-cell--4-col"></div>
</div> 

在这个模板中,只有相当数量的标记用于网格布局。任何以 mdl- 开头的类都是 Material Design Lite 的网格和 UI 库提供的样式的一部分。

第一个有趣的章节是表单,它有一个我们之前没有见过的属性类型。(submit)="add()" 属性是添加事件监听器的方式,称为 事件绑定。当表单提交时(通过按 Enter 键完成),它将调用 add 方法。任何被括号包围的属性都是事件绑定,事件名称应与不带 on 的事件名称匹配(onsubmitsubmit)。

表单包含一个单独的输入元素,它还有一个新的属性类型。[(ngModel)]="stock"属性是一个双向绑定,它将在输入值或控制器中的属性值发生变化时同步两者的值。这样,当用户在文本字段中键入时,值将立即对控制器可用。当用户按下 Enter 键时,submit事件触发,并使用stock属性的值添加新的符号。我将在稍后更详细地介绍表单概念,但这是您对简单表单构建的第一次预览。

下一个部分使用 NgFor 遍历符号列表。我之前已经介绍了它是如何工作的,所以这里不再详细说明。对于每个符号,它将创建一个名为symbol的局部变量,创建一个新的表格行以绑定值,以及一个用于删除项目的按钮。

remove按钮包含另一个事件绑定,这次是处理点击事件。(click)="remove(symbol)"属性向点击事件添加了一个事件监听器,并将调用控制器中的remove方法,传递符号。因为按钮有多个实例,每个实例都会传递局部变量,以便知道要删除哪个符号。

最后的任务是为应用程序添加路由,以激活两个视图的路由,使其像两个不同的页面一样操作。

2.9 应用程序路由

应用程序的最后一部分是路由,它配置了应用程序可以渲染的不同页面。大多数应用程序都需要某种形式的路由,以便在预期的时间显示应用程序的正确部分。Angular 有一个与 Angular 架构配合良好的路由器,通过将组件映射到路由。

路由器通过在模板中声明一个出口来工作,这是最终渲染的组件将被显示的地方。将出口视为内容的默认占位符,直到内容准备好显示,它将是空的。

为了设置我们的路由,我们将管理组件和仪表板组件链接到两个路由。我们将自己处理配置,因为 CLI 不支持在此特定版本中设置路由。首先,在src/app/app.routes.ts创建一个新文件,并用以下列表中的代码填充它。

列表 2.14 应用程序路由配置

import { Routes, RouterModule } from '@angular/router';     
 import { DashboardComponent } from './components/dashboard/dashboard.component';     
import { ManageComponent } from './components/manage/manage.component';     

const routes: Routes = [     
 {
 path: '',
 component: DashboardComponent
 },
 {
 path: 'manage',
 component: ManageComponent
 }
[];     ](#c02-codeannotation-0063)

export const AppRoutes = RouterModule.forRoot(routes);     

此文件的主要目的是配置应用程序的路由,我们首先导入RouterModule和路由类型定义。RouterModule用于激活路由器,并在初始化时接受路由配置。我们还导入了两个可路由组件,即仪表板和管理组件,这样我们就可以在路由配置中正确地引用它们。

路由被定义为对象数组,这些对象至少有一个属性——在本例中是两个,一个是 URL 路径,一个是组件。对于第一个路由,没有路径,因此它充当应用程序索引(将是 http://localhost:4200),并链接到仪表板组件。第二个路由提供了一个 URL 路径为 manage(将是 http://localhost:4200/manage),并链接到管理组件。这可能是你用 Angular 做的最常见类型的路由,尽管有许多配置和嵌套路由的方法。

最后,我们创建了一个新的值AppRoutes,它被分配给RouterModule.forRoot(routes)的结果。我们将在稍后进一步探讨forRoot方法的行为,但这是一个向模块传递配置的方法。在这种情况下,我们传递了路由数组。我们导出这个值,以便我们可以将其导入到我们的 App 模块中并注册它。

打开 src/app/app.module.ts 文件,并在导入的末尾添加一行新行,导入你在上一个文件中创建的AppRoutes对象:

import { AppRoutes } from './app.routes'; 

现在更新模块的imports属性,以包含AppRoutes对象。这将注册路由器模块和我们的配置到应用程序中:

 imports: [
    BrowserModule,
    HttpClientModule,
    FormsModule,
    AppRoutes
  ], 

最后一步是声明一个路由器渲染的位置,并更新链接以使用路由器进行导航。最后一次打开 src/app/app.component.html 文件,进行一些修改。首先,你将更改主元素的内容,使其包含一个不同的元素,即路由器出口:

<main class="mdl-layout__content" style="padding: 20px;">
  <router-outlet></router-outlet>
</main> 

这声明了路由器应在应用程序中渲染组件的具体位置。这正是我们在构建组件时放置组件的地方,因此应该很清楚这是最佳位置。

然后我们需要更新链接以使用一个新的指令来设置路由之间的导航。RouterLink 指令绑定到一个路径数组,用于构建 URL:

<nav class="mdl-navigation mdl-layout--large-screen-only">
  <a class="mdl-navigation__link" [routerLink]="['/']">Dashboard</a>
  <a class="mdl-navigation__link" [routerLink]="['/manage']">Manage</a>
</nav> 

指令解析数组并尝试匹配已知路由。一旦匹配到路由,它将向锚标签添加一个href属性,以正确链接到该路由。

路由器能够进行更高级的配置,例如嵌套路由、接受参数和拥有多个出口。我将在第七章中更详细地介绍路由器。

现在项目已完成,你可以在浏览器中重新加载应用程序以查看其运行,如之前预览的那样。恭喜!你已经运行了一个有效的 Angular 应用程序,现在你可以尝试让它做更多的事情。

摘要

恭喜你完成了一个功能性的 Angular 应用程序!我们快速地介绍了许多 Angular 功能,但你现在应该了解如何将各个部分组装成一个应用程序。以下是一些主要收获的快速回顾:

  • Angular 应用程序是包含组件树的组件。根应用程序在页面加载时启动以初始化应用程序。

  • 组件是一个带有 @Component 注解的 ES6 类,它为 Angular 添加了元数据以便正确渲染该类。

  • 服务也是 ES6 模块,并且应该设计为可移植性。任何 ES6 类都可以使用,即使它不是专门为 Angular 设计的。

  • 指令是修改模板的属性,例如 NgIf,它根据表达式的值有条件地显示或隐藏 DOM 元素。

  • Angular 内置了表单支持,包括自动验证、分组以及将数据绑定到任何表单控件的能力,以及使用事件。

  • Angular 中的路由是基于路径映射到组件的。路由将渲染单个组件,并且该组件也将能够渲染它需要的任何附加组件。

3

应用程序的基本要素

本章涵盖

  • Angular 如何将代码组织成模块

  • 应用程序的渲染方式

  • Angular 中的实体类型

  • Angular 的模板语法和功能

  • 变更检测和依赖注入

本章涵盖了 Angular 应用程序的基本要素,以便您了解所有内容是如何结合在一起的。这将是一个很好的基础知识参考。

它专注于概念,没有编码项目。您可能急于开始编码,我当然理解这一点。我建议您花时间完整阅读本章,但您也可以从每个部分的头几段开始浏览。

在大学期间,我在德国留学,并有幸进行了一些旅行。我参加了很多旅行,但有两件事我记忆犹新:一次是德国南部的盐矿之旅,另一次是意大利佛罗伦萨的乌菲齐博物馆之旅。我了解了很多关于盐矿的有趣事实,但我对采矿没有真正的背景。另一方面,我一直在上艺术史课程,这使得乌菲齐博物馆的经历更加令人满意。

我希望您已经阅读了第二章,它提供了 Angular 的实战之旅,因为我会将其作为参考,在我们讨论应用程序基本要素时使用。这可能感觉有点像盐矿之旅,您快速跑过并看到了一些有趣的事物。现在我将填补背景故事,给您一个更完整的 Angular 图景,让您拥有自己的乌菲齐体验。

第二章可能看起来相当简单,或者可能很难保持所有内容清晰。与大多数技术一样,有很多潜在的学习内容,但大多数并不是你日常会用到的东西。我在学习 Angular 时有过清晰和困惑的时刻,而大多数情况下问题是我一次专注于太多事情,而不是专注于核心问题并从这里构建。

基于这次经验,我将更详细地为您讲解第二章应用程序的关键方面,并讨论其中的核心概念。我将添加一些更多概念,最终为您提供一个更全面的 Angular 应用程序视图以及它在屏幕上的渲染方式。

图 3.1 展示了第二章的股票应用程序。如果您想再次看到这个示例的实际操作,请访问 angular-in-action.github.io/stocks/。您可能还记得用于生成此应用程序的一些内容,包括几个组件、一个服务、指令等。让我们来看看 Angular 中的这些实体,并更好地理解它们的作用。

c03-1.png

图 3.1 第二章的股票应用程序,我们将用它来更详细地描述 Angular 概念

3.1 Angular 中的实体

Angular 有几种顶级实体类型,您在第二章中都已经看到了。但我们没有在它们如何打包和渲染到应用程序的上下文中给予它们太多关注。

这些不同的实体具有特定的角色和能力,您将使用它们的各种组合来创建您的应用程序。以下是类型的快速概述:

  • 模块 — 帮助您将依赖项组织成离散单元的对象

  • 组件 — 将组成您应用程序结构和大部逻辑的新元素

  • 指令 — 修改元素以赋予它们新功能或改变行为的对象

  • 管道 — 在渲染之前格式化数据的函数

  • 服务 — 填充特定角色(如数据访问或辅助工具)的可重用对象

在 Angular 中,您所写的大部分内容都将属于这五种类型之一(好吧,我确信有一些例外)。查看第二章中的库存应用程序,我们可以看到这些不同实体是如何发挥作用的。图 3.2 概述了实体之间的基本关系以及它们最终是如何结合在一起的。

c03-2.png

图 3.2 — 实体及其在应用程序执行过程中的利用方式

如您所见,所有这些实体最终合并为一个应用程序,并生成最终的用户体验。我经常会提到这些实体类型,所以让我们更深入地了解每一个。

3.1.1 模块

模块 是存储相关实体以方便重用和分发的容器。Angular 本身由几个模块组成,您所消耗的任何外部库也将被打包成模块。

在 Angular 中有两种模块,我们需要明确它们的区别。有 JavaScript 模块(特别是 ES2015 中添加到语言中的模块,而不是其他模块系统如 CommonJS 和 AMD),然后是 Angular 模块。

JavaScript 模块是语言结构,是一种将代码分离到不同文件的方法,这些文件可以根据需要加载。我们在代码中大量使用 JavaScript 模块,但它们不是 Angular 模块。第二章中我们编写的每个 TypeScript 文件都是一个 JavaScript 模块,因为它要么导入了某些值,要么导出了某些值。

虽然 JavaScript 模块是语言结构,但 Angular 模块是用于组织类似实体组(如所有需要路由的东西)的逻辑结构,并且由 Angular 用于理解需要加载的内容以及存在的依赖项。回想一下第二章,您的应用程序有一个 App 模块,它包含所有用于 Angular 渲染的应用程序逻辑。必须始终有一个 App 模块,但您的应用程序可能还会有其他模块——无论是官方 Angular 模块、第三方模块还是您可能创建的其他模块。

模块是通过创建一个类并用 @NgModule 装饰器装饰它来声明的。以下列表显示了为第二章股票示例创建的模块。

列表 3.1 — 第二章的 App 模块

@NgModule({     
 declarations: [
 AppComponent,
 SummaryComponent
 [],](#c03-codeannotation-0002)
 imports: [
 BrowserModule,
 FormsModule,
 HttpModule
 [],](#c03-codeannotation-0003)
 [providers: [StocksService],](#c03-codeannotation-0004)
 [bootstrap: [AppComponent]](#c03-codeannotation-0005)
}) 
export class AppModule { }     

这个模块声明是由 CLI 为我们生成的(尽管我们也可以自己编写),它向 Angular 提供关键信息,以便它能够理解如何渲染和加载应用程序。@NgModule 装饰器包含 App 模块的元数据,而空类则充当存储数据的容器。

declarations 数组包含应用程序主模块希望向整个应用程序提供所有组件和指令的列表。同样,providers 数组包含你希望向整个应用程序提供所有服务的列表。

imports 数组包含了这个模块所依赖的其他模块的列表。如果你遇到另一个模块无法加载的问题,这是你首先需要检查的地方,看看它是否已注册到 Angular。

为了开始渲染,Angular 还需要知道要在屏幕上渲染哪些组件,它查看 bootstrap 数组以获取此列表。几乎总是,这只会包含一个组件,但在一些罕见的情况下,你可能需要在加载时渲染多个组件。

这里列出的属性中还有一些你没有看到,但它们的使用频率较低。当它们与我们的用例相关时,我会介绍它们。

3.1.2 组件

理解组件对于理解 Angular 至关重要,正如理解单词对于语言至关重要。在我们深入技术实现细节之前,本节将重点关注组件的角色和设计。

组件 是一个封装的元素,它维护自己的内部逻辑,以决定如何渲染某些输出,例如我们第二章中的 Summary 组件。在 HTML 中,一个选择元素可以被视为一个组件。使用 Angular,我们通过组件创建自己的 HTML 元素,尽管它们不仅仅是那样。组件可以拥有任何数量的能力或属性,你可以定义。

作为复习,以下是第一章中讨论的组件的关键原则,在那里我们看到了一些使构建组件成为可能的前端技术。这些原则侧重于组件的最佳设计方式和它们在 Angular 中的行为:

  • 封装 — 保持组件逻辑的隔离

  • 隔离 — 保持组件内部隐藏

  • 可重用性 — 允许以最小的努力重用组件

  • 基于事件 — 在组件的生命周期中发出事件

  • 可定制 — 可以为组件进行样式设计和扩展

  • 声明式 — 使用简单的声明性标记的组件

组件可能不会实现所有这些原则,但它们确实应该是你思考的指南针。这也有助于跟踪组件之间的关系——你可以将多个组件嵌套在一起以组成更复杂的交互。

让我们以一个仅使用 HTML 元素的登录表单为例。你从一个form元素开始,它包裹整个表单结构。这为表单内其余元素提供了上下文,但它不提供任何用户可以与之交互的 UI 元素。为此,我们需要使用一个文本输入来输入用户名,一个密码输入来输入密码,以及某种类型的按钮来触发表单操作。在图 3.3 中,我们有一个包含这些元素的基本表单示例。

c03-3.png

图 3.3 Facebook 上的登录表单,其中包含多个嵌套元素以生成单个表单

表单事件提供了访问表单内输入值的能力。这类似于 Angular 中的数据绑定概念,其中一个元素的价值与另一个元素连接起来。然后表单有一个提交按钮,当点击时,触发表单的提交事件。

这应该是一个相当标准的 HTML,但现在让我们看看一组类似排列的 Angular 组件。在第二章中,我们有一个包含多个 Summary 组件实例的 Dashboard 组件。在图 3.4 中,我概述了两种组件类型。

c03-4.png

图 3.4 嵌套组件来自第二章的仪表盘屏幕

Dashboard 组件持有所有股票的数据,并将这些信息绑定到各个 Summary 组件中。每个 Summary 组件使用提供的股票数据来显示自己。仪表盘数据中的任何变化都会导致子 Summary 组件被更新。

组件的基本交互是从父组件向下推送数据,通常通过绑定,以及向上回传,通常通过事件。组件之间还有其他通信方式(我将在稍后更详细地介绍),但你在使用 Angular 的大部分时间将用于将组件树组合成一个有意义的界面。

3.1.3 指令

Angular 倾向于直接将逻辑和能力放入应用程序的 HTML 标记中,而指令是教授 HTML 元素新技能的强大工具。

你过去可能使用过 jQuery 插件来增强现有元素的新行为。有无数插件可以将常规元素转换为幻灯片、标签和其他东西。这些插件的工作方式是,它们取一个现有元素并应用新的功能,例如使图片在模态窗口中打开。

同样,指令可以将一个普通元素转换并赋予它一些自然不存在的额外能力。想象一下,你正在构建一个表单,其中用户不小心点击任何链接进行导航是很重要的。你可以创建一个指令,根据用户是否已经开始使用表单或是否已经完成它来禁用链接,并且它将内部修改锚链接以禁用href,从而禁用链接的可点击性。

让我们再次回顾第二章,看看我们是如何使用不同的指令来赋予常规元素新技能的。图 3.5 显示了仪表板视图中使用的指令,其中包括 NgFor、NgClass 和 NgIf。该图注解了这些指令如何改变元素的行为。

这里有一个添加指令的例子,该指令将根据属性值中的值(称为表达式)来渲染或移除元素:

<div *ngIf="!stocks"> 

c03-5.png

图 3.5 第二章股票应用中使用的指令

*ngIf 是一个指令,它被用作元素的属性,并且会评估它所分配的值(关于这一点,将在第 3.6 节中进一步讨论)。在这种情况下,如果表达式为真,它将渲染元素——否则它将从 DOM 中移除。NgIf 赋予元素条件渲染或被移除的能力,这可能是 JavaScript 在网页上最常见的使用方式。

指令分为三类:属性指令、结构指令和组件。我们已经讨论了组件,并且应该明白组件是如何向 HTML 添加新功能,使其也成为一种指令的。但是,组件是特殊的,因为它们是唯一具有模板的指令类型,因此我建议将它们视为一种独立的实体。

属性指令类似于我们之前的例子,它们会修改元素的外观或行为。NgClass 指令是 Angular 提供的一个例子,我们在第二章中看到了它。存在许多内置的属性指令,因此你不必构建很多自己的。通常,它们通过改变它们所关联的元素的各个属性来实现,例如,NgClass 指令会改变附加的类列表。大多数指令都是属性指令。

另一方面,结构指令根据某些条件修改 DOM 树。在第二章中,我们也看到了 NgIf 作为条件显示 DOM 元素的方式,以及 NgFor 作为遍历项目列表并显示它们的方式。由于它们很灵活,Angular 中内置的结构指令类型较少。它们通过向或从页面添加或删除 DOM 元素来实现。

我们在第二章中使用了三个指令,如图 3.5 所示。NgIf 用于在数据加载前隐藏卡片列表。NgFor 用于遍历每个股票并创建 N 个副本。然后使用 NgClass 根据股票价格的正负变化来更改卡片背景。但我们没有详细讨论一些其他指令。

Angular 提供的首要默认指令包括以下内容(还有由表单和路由模块提供的一些):

  • NgClass — 有条件地应用一个类到元素

  • NgStyle — 有条件地应用一组样式到元素

  • NgIf — 有条件地插入或从 DOM 中删除一个元素

  • NgFor — 遍历一个项目集合

  • NgSwitch — 有条件地显示一组选项中的一个

如果在股票应用中没有指令,我们就必须编写 JavaScript 来动态创建多个摘要卡片,而且随着时间的推移,这会变得更加难以管理。指令使生活变得更加容易,因为它们通过修改元素来赋予它新的功能,而无需使用 JavaScript 动态进入模板并实时修改它。我们不必使用像 jQuery 这样的东西来修改 DOM,并将我们的逻辑放在外部、分离的位置。

3.1.4 管道

通常,你希望以不同于存储格式的格式显示数据。通常,你希望将日期存储为时间戳值,但这并不特别用户友好。使用管道,我们可以在渲染过程中转换视图中的数据,而不会改变底层数据值。

使用管道字符(|)将管道添加到模板表达式中。例如,你可以有一个看起来像这样的表达式:

{{user.registered_date | date:'shortDate'}} 

左侧的表达式与我们本章中看到的一致,但在右侧添加管道后,则会对表达式的值应用转换。在这种情况下,它将使用日期管道来格式化用户的注册日期。它还接受一个选项;冒号(:)表示下一个值作为管道的配置选项传递。在这个例子中,你传递了一个配置选项,该选项根据'shortDate'格式来格式化日期,我将在稍后介绍。

在股票应用中,我们使用了货币和百分比管道来显示内容。图 3.6 指出了使用的管道。

c03-6.png

图 3.6 管道在股票应用中的使用

Angular 自带一套默认管道,涵盖了众多常见用例。默认管道始终可用,无需注入或导入,因此我们可以在模板中使用它们。通过添加管道字符和管道名称来使用管道。

使用管道会改变数据渲染的方式,但不会改变属性的值。它会创建输出值的副本,对其进行修改,并显示最终的结果值。例如,FB 股票的价格存储为一个数字,但在渲染时,该值的副本会被转换为字符串,并格式化为货币形式。

我喜欢尽可能创建自己的管道来处理格式化。这样做的好处是逻辑容易重用,并且可以将代码从组件控制器中分离出来。每次进行任何类型的数据格式化时,都应该考虑它是否可以成为一个管道。你可能会只创建少量管道,因为它们通常是可重用且易于共享的。

3.1.5 服务

最后一个主要实体类型是服务,它们是在应用程序中跨组件重用 JavaScript 逻辑片段的一种方式。开发者经常需要编写一些执行重复性任务的代码,我们不希望它在各个地方重复。有时这些服务是访问数据(如我们在第二章中构建的)的网关,而有时它们更像是辅助函数,例如自定义排序算法。我将在第 3.4 节中更深入地讨论依赖注入,以解释这些服务是如何被提供和共享的。

Angular 提供了一些内置的服务,许多第三方模块也会公开服务。在第二章中,我们构建了一个使用 Angular Http 服务请求数据的服务——我们创建了一个 Angular 服务的包装器,以便我们更容易在应用程序中使用它。尽管以下列表中的代码已被简化以突出 HttpClient 服务的使用,但让我们再次查看这个代码片段。

列表 3.2 股票服务,简化版

import { Injectable } from '@angular/core';     
import { HttpClient } from '@angular/common/http';     

let service: string = 'https://angular2-in-action-api.herokuapp.com';

@Injectable()
export class StocksService {

 constructor(private http: HttpClient) {}
 load(symbols) {
 if (symbols) {
 return this.http.get(service + '/stocks/snapshot?symbols=' + symbols.join());
 }
 }
} 

在这个例子中,我移除了一些与使用 HttpClient 服务无关的额外代码。首先将 HttpClient 导入到文件中,然后通过控制器将其注入到StocksService中。这些步骤是在 Angular 中使服务可由对象使用的方法。例如,如果这是一个组件控制器,相同的两个步骤也会适用。

load()方法使用 HttpClient 服务进行GET请求,但它构建了正确的 URL 来调用,这使得在其他地方使用它更容易。服务是放置此类逻辑的理想场所,这种逻辑简化了代码的使用,并使其可重用。

服务也是数据访问的理想场所,而组件控制器则不是。在 Angular 中,关注点分离的原则适用,并且保持单个实体专注于单一任务对于可维护性和可测试性非常重要。创建只做一件事且做得好的服务。

我经常为任何与组件控制器没有固有联系的东西或可以轻松抽象到组件外部的东西编写服务。有时我可能只在一个地方使用一个服务,但它有助于更好地组织代码的逻辑和意图,同时也有助于集中测试。如果将来我需要重用那个服务,它已经准备好了。我发现编写另一个服务比过度设计现有服务要容易。

现在我们已经掌握了 Angular 中的主要实体,我们可以讨论它们如何在应用程序的渲染过程中结合在一起。

3.2 Angular 开始渲染应用的方式

Angular CLI 生成了一个相当简单的 app,在屏幕上显示一条消息,正如我们在第二章中看到的。许多事情结合在一起才能使那条简单的消息出现,尽管我们很快介绍了应用是如何运行的,但我们还需要花更多的时间和精力深入了解细节。

在 Angular 中,CLI 生成了一个相当轻量级的 app。它可能稍微小一些,但 easiest to consider the generated app as the base for future development, and I’ll often refer to it as the base app. 我们将关注当你运行 ng new app-name 时生成的 app。

Angular 有一个启动机制,用于启动渲染。图 3.7 显示了在引导过程中涉及的主要实体以及基于基础应用的渲染内容。

c03-7.png

图 3.7  Angular 如何将基础应用渲染到浏览器中

页面加载立即,引导程序被调用以开始 Angular 的执行。你可能想知道引导是如何开始的?CLI 使用 webpack 进行构建,并将所有 JavaScript 编译后添加为 script 标签到 index.html 的底部。(如果你对 webpack 的编译方式感兴趣,可以在 webpack.github.io/ 上了解更多。)这时,它将运行代码以开始你的应用。

现在 Angular 已经启动,它加载你的 App 模块,并读取任何需要加载和启动的附加依赖项。在基础应用中,Browser 模块在进一步执行之前被加载到应用程序中。

然后 Angular 渲染 App 组件,这是你应用程序的根元素。当这个 App 组件渲染时,任何子组件也会作为组件树的一部分进行渲染。这就像 DOM 树,但任何特殊的 Angular 模板语法都必须由 Angular 进行渲染。在渲染过程中,它还将解析绑定并为声明任何内容的任何内容设置事件监听器。一旦完成这些,完整的应用程序应该被渲染出来,并可供用户开始交互。

应用程序的生命周期在用户开始使用应用程序时继续,应用程序将开始做出反应。当用户在应用程序中导航时,屏幕上的组件将被移除,新的组件将被加载和渲染。对用户做出反应和渲染组件树的周期将继续,直到应用程序关闭。

图 3.7 没有详细说明组件树如何成为应用程序,我们将在第四章中更详细地了解这一点。

3.3 编译器的类型

Angular 提供了两种类型的编译器,称为即时编译器(JiT)和预编译器(AoT)。它们的主要区别在于编译器的工具和时机,这可能会改变应用程序的行为以及它如何被提供给用户。

在 JiT 编译中,这意味着应用程序的编译仅在所有资源都加载完毕后才会发生在浏览器中。这意味着在页面最初加载和能够看到内容之间会有延迟。你可以在第二章的示例中看到这一点,因为有一个相当基本的“加载”信息显示,直到一切准备就绪并且编译器运行完毕。

另一方面,AoT 是一种在发送到浏览器之前渲染内容的方法。这意味着一旦应用程序资源加载完成,用户将接收到显示内容所需的确切内容,而无需任何加载信息。

另一个重大区别是,与 JiT 相比,应用程序在执行之前必须加载编译器库,而 AoT 版本能够从发送中删除此负载,从而实现更快的加载体验。

在 AoT 中,我们能够在应用程序在服务之前编译的情况下执行许多有趣的优化。它提供的另一个可能性是应用程序的服务器端渲染,这对于预渲染具有用户特定数据的应用程序可能很有用。

你应该努力确保你的应用程序使用 AoT 编译器进行编译,因为每次你为生产构建应用程序时,它都会使用 AoT 编译器。在 Angular 的未来版本中,一旦 AoT 编译对于开发足够快,JiT 编译器可能会被完全移除。

在本书的所有开发中,我们将使用 JiT,因为它渲染和预览应用程序的速度要快得多。我们还将介绍如何设置 AoT 编译,并在为生产目的构建应用程序时获得其好处。

3.4 依赖注入

除了最基本的代码之外,所有代码都依赖于使用来自应用程序其他部分的对象。问题是,随着代码库的增大,确保各个部分被封装同时仍然易于访问变得更加困难。因此,许多编程语言或框架都有某种机制来促进对象的跟踪和共享。

有许多方法可以组织您的代码,以便您能够轻松地共享对象。依赖注入(DI)是一种获取对象的模式,它使用注册表来维护可用对象列表,并允许您请求所需的对象。您不必传递对象,而可以在需要时请求所需的对象。

您可能想知道这与使用 JavaScript 模块导入和导出有何不同。为什么在 JavaScript 现在有了模块的情况下,我们还需要另一种方法来传递代码?依赖注入不应与 JavaScript 模块导入混淆。Angular 需要能够跟踪应用程序的哪些部分需要特定的服务。JavaScript 没有意识到依赖项是如何相互关联的,这可能是理解如何最佳组装依赖项的有用信息。此外,使用 Angular 注入依赖项将解决任何额外的依赖项。

DI 系统有几个关键部分。首先是 injector。这是 Angular 提供用于请求和注册依赖项的服务。注入器通常在幕后工作,但偶尔会直接使用。大多数时候,您将通过在属性上声明类型注解来调用注入器。您可能还记得在第二章中我们如何像这样注入 HttpClient 服务:

constructor(private http: HttpClient) {} 

因为我们声明类型为 HttpClient(Angular 中已知的服务),应用程序将使用注入器来确保 http 属性包含 HttpClient 服务的实例。这看起来像是魔法,但这仅仅是一种别名您想要请求的依赖项,而不直接调用注入器 API 的方式。

DI 的第二部分是 providers。提供者负责创建请求的对象的实例。注入器知道可用提供者的列表,并根据名称(上面是 HttpClient),调用提供者的工厂函数并返回请求的对象。

在 NgModule 的 providers 数组中注册的任何内容都可以在您的应用程序代码中注入。您可以在任何地方注入,但我更喜欢使用 TypeScript 方法,正如我们之前看到的,其中构造函数属性被注解为要注入的特定类型的服务。或者,您可以使用 @Inject 装饰器来注入 Http 服务,如下所示:

constructor(private @Inject(HttpClient) http) {} 

这个装饰器将依赖注入与 TypeScript 类型信息以相同的方式连接起来。无论哪种方式,您都会得到相同的结果。

提供者不必暴露给根模块,而可以仅使特定组件或组件树可见。我们将在第六章中更详细地探讨这一点,但到目前为止,您应该知道 DI 有很多可以利用的力量。

现在我们来看看 Angular 如何了解应用程序中的更改,以及这如何导致应用程序重新渲染。

3.5 变更检测

简而言之,变更检测是保持数据和渲染视图之间同步的机制。更改总是从模型到视图的下传,Angular 采用从父组件到子组件的单向传播更改。这有助于确保如果父组件发生变化,任何子组件也会因为潜在关联数据而被检查。

Angular 将运行变更检测过程来检查自上次运行过程以来值是否已更改。JavaScript 没有保证通知对象任何更改的方法,因此 Angular 运行此过程。虽然运行这些检查可能听起来很重,但有一些优化允许这个过程在几毫秒内发生。

为了实现这一点,当 Angular 渲染一个组件时,它会创建一个特殊类,称为变更检测器。这个类负责跟踪组件数据的状态,并在变更检测运行之间检测是否有任何值发生了变化。

当在组件中检测到值的变化时,它将更新该组件,并可能更新任何子组件。此外,由于 Angular 应用程序是组件树,Angular 可以确定哪些组件可能会受到更改的影响,并限制涉及的工作。

Angular 有两种触发更改的方式。默认模式会在每次变更检测过程中遍历整个树以寻找更改。OnPush 模式告诉 Angular,组件只关心从父组件输入到组件中的任何值的更改,并赋予 Angular 在已知父组件未更改的情况下跳过在变更检测期间检查组件的能力。

变更检测由事件、接收 HTTP 响应或计时器/间隔触发。最好的理解方式是,每当发生异步操作时,变更检测过程开始确定可能发生了什么更改,因为同步调用已经在 Angular 的正常渲染流程中处理了。可以这样想:你可以打开你的车,但直到你挂上档位,踩下油门或刹车,车辆都处于闲置状态,等待驾驶员给它一些事情做。

如果我们回顾组件树的设计方式,你会记得树是如何将数据推送到子组件,以及事件是如何将数据向上冒泡的。变更检测是允许组件在父组件中的数据发生变化时更新的机制,并确保视图和数据同步。

3.6 模板表达式和绑定

组件始终有一个模板,因此它是我们深入研究模板如何塑造应用程序行为的逻辑起点。与构建 Web 应用程序的许多其他方法不同,Angular 允许将逻辑和定制直接放置到模板中,这使得模板更加声明式。一开始这可能会让您感到有些奇怪,但我发现这是一种优雅的应用程序设计方式。当然,可能会出现一些陷阱,但请保持开放的心态,看看它是如何被 Angular 所接受的。有时人们认为这是将表示层和业务逻辑混合在一起,这在某种程度上是正确的,但它允许我们编写更干净的代码。

模板本身是普通的 HTML,但通过 Angular 的一些能力,HTML 标记获得了全新的生命。模板可以利用存储在模板逻辑中的控制器中的值。我们在第二章中看到了一些模板,它们演示了几个概念:

  • 插值 — 在页面上显示内容

  • 属性和属性绑定 — 将组件控制器中的数据链接到其他元素的属性或属性

  • 事件绑定 — 向元素添加事件监听器

  • 指令 — 修改元素的行为或添加额外的结构

  • 管道 — 在显示在页面上之前格式化数据

在整个模板中,您会看到模板表达式。这些类似于普通的 JavaScript 表达式(任何可以用分号结束的语句),并且所有值都会相对于组件控制器进行解析。与 JavaScript 中的表达式相比,模板表达式有一些额外的功能和限制:

  • 它们无法访问全局变量,如consolewindow

  • 它们不能用于向变量赋值(除了在事件中)。

  • 它们不能使用new++--|&运算符。

  • 它们提供了新的运算符:|用于管道和 Elvis 运算符?.用于允许空属性。

模板表达式用于三个地方:用于插值、属性绑定和事件绑定。插值绑定,就像我们在本节中看到的例子一样,是属性绑定的简写。

让我们假设我们有一个控制器,它有一个user属性和一个save方法。在页面上,我们想要显示用户的姓名和简介图片,并有一个表单,以便他们可以更新他们的详细信息。基本结构可能看起来像图 3.8。我们将使用这个例子来了解数据是如何从控制器流到模板的,或者事件是如何从模板流到控制器的。

c03-8.png

图 3.8 控制器如何将数据绑定到模板中进行插值、属性和事件绑定,每个都使用不同的语法

绑定是数据或方法从模板中的控制器使用的通道;它们允许控制器中的数据流入模板,或者从模板调用事件回控制器。

让我们更详细地了解绑定和 Angular 提供的模板功能,然后我们将看到它们是如何应用到我们章节示例中的。

3.6.1 插值

插值可能是 Angular 中最常用的模板语法类型。我们在第二章中多次使用它,但没有深入探讨其工作原理。插值解析绑定并在页面上以字符串形式显示结果值。

绑定通过获取一个表达式,评估它,并用结果替换绑定来工作。这与电子表格可以取一个公式(例如,添加单元格列的值)类似,通过将公式与存储在电子表格中的数据解析来计算结果值,然后在单元格中显示该值(代替公式)。以下是我们的插值示例:

<p>{{user.name}}</p> 

插值始终使用{{value}}语法将数据绑定到模板中。对于使用过 mustache 模板的人来说,这是一个熟悉的模式,因为双大括号之间的任何内容都会被评估以渲染一些文本。以下是一些将值绑定到视图中的有效插值表达式:

<!-- 1\. Calculates the value of two numbers, adds to 30 -->
{{10 + 20}}
<!-- 2\. Outputs a string "Just a simple string" -->
{{'Just a simple string'}}
<!-- 3\. Binds into an attribute value, to link to profile -->
<a href="/users/{{user.user_id}}">View Profile</a>
<!-- 4\. Outputs first and last name -->
{{user.first_name}} {{user.last_name}}
<!-- 5\. Calls a method in the controller that should return a string -->
{{getName()}} 

前两个表达式评估简单值。然而,大多数时候,您将引用组件中的值来显示或评估,正如您在前面的代码中的五个示例中所看到的。这些表达式在组件的上下文中进行评估,这意味着您的组件控制器应该有一个名为user的属性和一个getName()方法。表达式上下文是视图如何解析特定值的方式,因此{{user.name}}是根据控制器中的user.name属性解析的,如图 3.8 所示。

接下来,我们将探讨属性绑定及其如何用于修改我们想要以某种方式动态化的元素属性。

3.6.2 属性绑定

除了插值之外,另一种绑定类型是属性绑定,它允许您将值绑定到元素的属性上以修改其行为或外观。这可以包括诸如classdisabledhreftextContent等属性。属性绑定还允许您绑定到自定义组件属性(称为inputs——在第四章中详细讨论)。例如,如果您从数据库中加载一个包含图像 URL 的记录,您可以将该 URL 绑定到一个img元素上以显示该图像:

<img [src]="user.img" /> 

实际上,插值是绑定到元素textContent属性的简写。它们在许多情况下都可以完成相同的事情,因此您可以选择使用最自然的感觉。

属性绑定的语法是将属性放在括号内的元素上([])。名称应与属性匹配,通常为驼峰式,如 textContent。我们可以将插值模板重写为使用属性绑定,如下所示:

`<p [textContent]="user.name"></p>` 

插值是元素 textContent 属性的属性绑定的快捷方式。

与插值类似,绑定是在组件上下文中评估的,因此绑定将引用控制器的属性。这里你有 [src]="user.img" 属性绑定,它与 src="{{user.img}}" 做的是同样的事情。两者都会评估表达式并将值绑定到图像的 src 属性,但语法不同。属性绑定不使用花括号,并将引号内的所有内容作为表达式进行评估。我在将数据绑定到属性时几乎总是使用属性绑定而不是插值。

重新说明:插值是元素 textContent 属性的属性绑定的快捷方式。我们可以将我们的插值示例重写如下:

<p `[textContent]="user.name"></p>` 

这会导致在这种情况下渲染用户名称的相同输出,但这种方式并不常见,因为它使得创建较长的文本字符串更困难。此外,大多数开发者会发现插值版本更易于阅读和简洁。这可能会让您对插值的工作方式有新的认识,因为底层,插值以这种方式评估其自己的绑定。

使用 [] 语法绑定到元素的属性,而不是属性。这是一个重要的区别,因为属性是 DOM 元素的属性。这使得可以使用任何有效的 HTML 元素属性(例如 imgsrc 属性)。您不是将数据绑定到属性,而是直接将数据绑定到元素属性,这非常高效。

注意,即使 HTML 属性不是驼峰式,属性有时也是驼峰式。例如,表格单元格元素的 rowspan 属性暴露为元素的 rowSpan 属性。如果您进行插值,可以使用 rowspan="{{rows}}";如果您进行属性绑定,则必须使用 [rowSpan]="rows"。我知道这可能会有些令人困惑,所以当您调试绑定时,请务必检查名称是否匹配。

3.6.3 特殊属性绑定

有几种特殊的属性绑定用于设置元素的类和样式属性。它们与您通常绑定的许多属性都不同,因为这些属性包含一个类或样式的列表,而不是设置单个属性,Angular 有特殊的语法来设置这些属性。

元素上的类属性是一个DOMTokenList,它是一个花哨的数组。你可以使用[class]="getClass()"并设置一个类或类的字符串,但这会与元素上已设置的任何类发生冲突。通常,你想要切换一个单独的类,你可以通过在属性中使用[class.className]语法来实现。它将看到属性绑定的class.前缀并知道你正在绑定一个名为className的特定类。让我们看看一个例子以及它是如何渲染的:

<!-- isActive() returns true or false in order to set active class -->
<h1 class="leading" [class.active]="isActive()">Title</h1>
<!-- Renders to the following -->
<h1 class="leading accent">Title</h1> 

类绑定语法用于针对特定类来添加或从元素中删除。它也仅添加到现有类中,而不是完全替换它们,就像你使用[class]="getClass()"那样。

同样,style属性是一个CSSStyleDeclaration对象,它是一个特殊对象,它包含所有 CSS 属性。Angular 有相同类型的语法来设置单个样式属性。使用[style.styleName]你可以设置任何有效 CSS 样式的值。例如

<!-- getColor() returns a valid color -->
<h1 [style.color]="getColor()">Title</h1>
<h1 [style.line-height.em]="'2'">Title</h1>
<!-- Renders to the following -->
<h1 style="color: blue;">Title</h1>
<h1 style="line-height: 2em;">Title</h1> 

这里可以使用任何有效的 CSS 属性,并且它将直接在元素上渲染为style值。你注意到第二个例子有一个第三项,.em吗?对于接受单位的属性,你可以使用这种语法来声明由表达式返回的值的单位。你也可以省略它,让表达式返回单位。

我发现这些特殊绑定在简单或边缘情况下非常有用,在这些情况下我需要做出简单的更改。我通常使用 NgClass 或 NgStyle,因为如果你试图在同一个元素上更改多个类或样式规则,这种语法就会变得繁琐。

3.6.4 属性绑定

一些元素属性不能直接绑定,因为某些 HTML 元素具有属性,这些属性并不是作为元素的属性提供的。aria(可访问性)属性就是这样一个例子,它不会作为属性添加到元素中。

你总是可以在开发者工具中检查元素以查看可用的属性。这是验证你是否可以绑定到特定属性的最快方式。一旦你验证了该属性没有作为属性公开,你就有了一个 Angular 支持的替代语法来绑定这些属性。

aria属性用于向辅助设备指示有关元素的信息,例如aria-required,它标记输入为提交所必需。通常,你会使用这样的属性:

`<input id="username" type="text" aria-required="true" />` 

想象这个字段可能并不总是必需的,因为你的表单可能需要根据情况提供用户名或电子邮件。如果你尝试做aria-required="{{isRequired()}}"[aria-required]="isRequired()",你会得到模板解析错误。因为这个属性不是属性,所以不能直接绑定。

解决方案是使用特殊的属性绑定语法,它看起来像属性绑定,但你在方括号中放入属性名称,并带有前缀attr.,如下所示:

<input id="username" type="text" [attr.aria-required]="isRequired()" /> 

Angular 现在将绑定到属性而不是不存在的属性。没有多少属性不是也是属性,但如果你遇到模板解析错误,你的绑定不是一个已知的原生属性,那么你很可能绑定到了这些属性之一。

在大多数情况下,你不需要使用属性绑定,但很可能你偶尔会需要它们。

3.6.5 事件绑定

到目前为止,所有数据都是从组件流向模板元素的。这对于显示数据来说很棒,但我们需要一种方法让模板中的元素能够反向绑定到组件。好消息是 JavaScript 内置了一个很好的机制,通过使用事件来向上传递数据。

当人们使用应用程序时,他们会通过与它们交互生成各种事件。每次他们移动鼠标、点击、键入或触摸屏幕时,JavaScript 都会生成事件。你可能之前已经编写过事件监听器,我们将使用 Angular 的事件绑定来做同样的事情。你也可以创建自己的事件,并在需要时触发它们。

首先,让我们通过一些通用用例来了解事件绑定的使用场景。当用户登录你的应用时,他们会填写登录凭证并提交表单(通常是通过按 Enter 键或点击按钮)。事件是表单提交,你希望这个事件触发组件中的某些行为。传统上,你会创建一个监听表单提交事件的监听器,但使用 Angular,我们可以创建一个绑定,它会调用组件控制器中的方法来处理事件(图 3.9)。

c03-9.png

图 3.9 事件绑定将模板中的事件与控制器中的方法关联起来。

事件绑定的语法使用括号()来绑定到已知事件。你将在括号内使用事件名称,不包含名称中的on部分。例如,对于一个表单提交事件,你会这样写:

<form (submit)="save()">...</form> 

这将在表单上创建一个事件监听器,当表单提交时,会在组件控制器中调用save()方法。上下文很重要,因为事件绑定只绑定到当前组件,但如果你可以触发事件,并且如果父元素和组件正在监听,这些事件会冒泡到它们。如果你需要 HTML 中可用标准事件的参考,developer.mozilla.org/en-US/docs/Web/Events是一个极好的参考。第四章将更深入地介绍事件。

组件和指令可以发出它们自己的事件,你可以监听这些事件。第四章将详细探讨如何实现这一点,但让我们也看看第二章的一个例子。在管理视图中,我们有一个表单,允许你添加新的股票。这是表单的再次展示:

<form style="margin-bottom: 5px;" (submit)="add()">
  <input name="stock" [(ngModel)]="stock" class="mdl-textfield__input" type="text" placeholder="Add Stock" />
</form> 

它分为两部分:具有 submit 事件绑定的 form 元素和包含用户通过键盘输入的数据的 input。当用户按下 Enter 键时,表单提交事件触发,调用控制器中的 add() 方法。该方法检查输入框中的值并将股票添加到列表中。所有这些都是由 submit 事件触发的。

我们还看到了一种特殊的绑定语法:双向绑定方法。它结合了属性和事件绑定语法,Angular 喜欢称之为“盒子里的香蕉”(如果你输入 [()] 并发挥想象力,它确实有点像那样)。熟悉 AngularJS 的人会熟悉它如何允许你在模板或控制器中值变化时同步绑定值。它是通过执行常规属性绑定并在幕后为你设置事件绑定来实现的。你只能在与表单元素一起使用 NgModel,但可以在属性上使用双向绑定语法。通常,你只想在绝对需要时才限制使用这种双向绑定。

事件绑定对于组件和模板之间的通信方式以及组件之间如何相互通信非常重要。事件绑定的语法和概念相当简单,但可以用于更复杂的编排,以增强组件之间的通信。

摘要

在本章中,我们已对 Angular 的基础知识进行了全面覆盖。本章应作为本书剩余部分的参考指南。主要收获如下:

  • 一个 Angular 应用程序是一棵组件树,并且始终有一个根应用程序组件。

  • 各种实体类型(模块、组件、指令、管道、服务)各自具有特定的角色和目的。

  • Angular 有两种类型的编译器,即时编译(AoT)和即时编译(JiT),以提供不同的方式来渲染应用程序。

  • 依赖注入对于 Angular 跟踪应用程序中的所有对象并在请求时使它们可用是基本的。

  • 当用户输入或其他事件导致异步变化时,变更检测会保持组件与模型数据的一致性。

  • 模板包含几种类型的绑定:用于显示数据的插值绑定、用于修改元素属性的属性绑定、用于修改元素非属性值的属性绑定以及用于处理事件的事件绑定。

4

组件基础知识

本章涵盖

  • 组件的基础知识和它们的作用

  • @Component 装饰器和它最重要的属性

  • 渲染组件

  • 使用输入和输出将数据传递到组件中

  • 使用模板和样式自定义组件

  • 使用投影将内容注入到组件中

组件对于 Angular 应用程序的结构至关重要,几乎每个功能都以某种方式与它们相关联。毕竟,没有组件就无法创建 Angular 应用程序。这意味着能够利用组件的能力对任何开发者来说都是至关重要的。它们如此重要,以至于我在下一章中专门讨论了涉及组件的更多高级主题。

您在第二章的示例中看到了一些组件的实际应用,但在这章中,我们将从组件的基础知识开始,以确保您对它们的声明和设计有一个清晰的概述。然后,我们将探讨您最常使用的组件的一些附加功能。

一个组件包括一个模板,这是用于描述其视觉布局和行为的 HTML 标记。我们将探讨如何充分利用这些模板,了解它们是如何渲染的,以及如何为它们提供个性化的样式。

组件还会创建一个视图,这是用户可以与之交互的组件的渲染结果,它由渲染组件模板组成。模板可能包含对其他组件的引用,这将触发它们在父组件的渲染过程中也被渲染。如第三章所述,Angular 应用程序是一个以 App 组件为起点的组件树。

在本章中,我们将构建一个看起来逼真的仪表板,其中包含多个组件,我们将使用模拟数据来简化实现,并专注于组件本身。让我们设置这个示例。

4.1 设置本章示例

在本章中,我们将构建一个组件树,这些组件将通过各种方式相互通信和共享信息。除了 App 组件外,我们还将创建七个(是的,七个)其他组件。您可以在图 4.1 中看到应用程序的视觉输出,其中每种类型的组件都被识别出来。

如您所见,这是一个虚构的数据中心仪表板,显示了底部的两个集群(每个集群有三个节点)以及 CPU 和内存使用的综合指标。所有数据每 15 秒随机生成一次,但当使用量超过预期水平时,颜色也会从绿色变为红色。右上角还有一个重载按钮,可以生成一组新的数据,以便您可以看到组件更新其行为。

由于 Angular 应用程序是一个组件树,我们可以可视化组件之间的关系,正如你在图 4.2 中看到的那样。这一点很重要,因为尽管组件是独立声明的,但它们相互构建以创建用户的整体应用程序和体验。

这棵树显示了组件之间的关系,其中一条线指向子组件。这棵树中的两个组件有虚线——它们是按需动态创建在页面上的,并不总是存在。节点详情组件有六条虚线,因为任何节点行组件都可以触发它显示。

我们稍后还会查看组件树的部分,以了解各种组件是如何通信的。HTML 元素的嵌套方式与 Angular 实例化和渲染它们的方式直接相关,因此对于任何编写 HTML 的人来说,理解这个树是如何构建的应该是相当自然的。

c04-1.png

图 4.1 完成后的应用程序,其中标注了三种组件类型

c04-2.png

图 4.2 组件树显示了每个组件实例之间的关系

4.1.1 获取代码

要开始,我们将从 GitHub 获取代码,以便我们准备好正确的设置。你可以通过运行以下命令使用git克隆仓库:

git clone -b start https://github.com/angular-in-action/datacenter.git 

或者,你可以从github.com/angular-in-action/datacenter/archive/start.zip下载它。

打开目录并确保运行npm install以获取所有依赖项。这可能需要一点时间,但之后你应该可以通过运行ng serve来查看应用程序。

本章将使用 ng-bootstrap 项目,该项目可在ng-bootstrap.github.io找到。这是 Angular 的流行 Bootstrap CSS 和组件框架的实现。我们将仅使用可用 UI 库的一小部分,但它是一个流行的选项,并将让你了解它如何用于你自己的项目。

我们已经有一个基本的 Navbar 组件,用于显示顶部菜单。当你查看运行中的应用程序时,结果应该是一个灰色 navbar 粘附在页面顶部,就像你在图 4.3 中看到的那样。

c04-3.png

图 4.3 带有重载按钮的应用程序 Navbar

这就带我们到了这样一个点,我们有一个基本功能的应用程序,只有一个组件在显示。在我们构建下一步之前,让我们回顾一下组件在我们应用程序中的生命周期和角色。

4.2 组件的组成和生命周期

组件有一个生命周期,从它们的初始实例化开始,一直持续到它们被销毁并从应用程序中移除。在我们理解生命周期之前,我们应该更仔细地看看组件中包含的内容。

随着时间的推移,掌握组件的组成对于创建更复杂和高效的 Angular 应用程序至关重要。我见过拥有数百到数千个组件的大型、真实世界的 Angular 应用程序,这些应用程序的质量在很大程度上是组件设计得好的副产品。

组件有几个不同的部分组合在一起,创建出用户可以与之交互的最终 UI,如 图 4.4 所示。

c04-4.png

图 4.4 — 组成并影响组件行为的理念

当我们使用 CLI 生成组件时,它会创建一组包含在渲染过程中组合的资源的文件。以下是组成组件的主要内容的列表:

  • 组件元数据装饰器 — 所有组件都必须使用 @Component() 装饰器进行注解,以便正确地将组件注册到 Angular 中。元数据包含许多属性,有助于修改组件的行为或渲染方式。

  • 控制器 — 控制器是带有 @Component() 装饰器的类,它包含组件的所有属性和方法。大部分逻辑都存在于控制器中。

  • 模板 — 没有模板的组件就不是组件。组件的标记定义了用户可以看到的 UI 的布局和内容,模板的渲染版本将查看控制器中的值以绑定任何数据。

任何有效的组件都必须存在这三个部分。此外,还有一些可选的功能可以与组件一起使用,以在某些情况下增强它们。前两个是向组件注入值的理念,其余的是修改结果组件行为、外观或与其他组件交互的理念:

  • 提供者和宿主 — 如果服务尚未在根模块级别提供,则可以直接将服务注入到组件中。您还可以控制这些服务的发现方式和它们可用的地方。

  • 输入 — 组件可以通过组件输入接受传递给它们的数据,这使得父组件能够直接将数据绑定到子组件中,这是向下传递组件树数据的一种方式。

  • 样式和封装 — 可选地,组件可以包含一组仅应用于组件的 CSS 样式。这为组件的设计提供了一层封装,因为组件样式不需要全局注入。组件可以配置样式注入和封装到应用程序中的方式。

  • 动画 —Angular 提供了一个动画库,使得对组件的过渡和动画进行样式化变得简单,并且可以定义关键帧或动画状态来切换。

  • 输出 —输出是与事件链接的属性,可以用来监听数据变化或其他父组件可能感兴趣的事件,也可以用来在组件树中共享数据。

  • 生命周期钩子 —在组件的渲染和生命周期中,你可以使用各种钩子来触发应用程序逻辑的执行。例如,你可以在组件实例化时运行初始化逻辑,并在销毁时运行清理逻辑。你还可以使用这些钩子将数据带入组件,因此生命周期钩子与输入和输出都很好地协同工作。

这里还有一些未涵盖的功能,但你总是可以在文档中找到它们,并且我们将在需要时使用它们。你可以通过查看angular.io/api/core/Component文档中额外的@Component装饰器属性来获取详细信息。

现在你已经知道了组件的组成部分,我们可以更容易地查看组件的生命周期,并了解它们如何在屏幕上渲染。

4.2.1 组件生命周期

组件从创建到删除都有生命周期,理解这个流程将有助于你设计高质量的组件。组件的生命周期行为可能会因所使用的构建工具而略有不同,但在大多数情况下,该工具将是 Angular CLI。

在图 4.5 中,你可以看到组件生命周期中发生的主要阶段。第一个主要动作是将组件注册到 App 模块中。这通常是因为我们将组件声明为 NgModule 元数据的一部分,并在应用程序引导期间发生,但组件也可以在应用程序运行时动态注册。当组件被注册时,它创建一个组件工厂类并将其存储以供以后使用。

然后,在应用程序生命周期中,将会有某些东西请求该组件。这通常是因为组件在模板中被找到,编译器需要该组件,但有时组件也可能被手动请求。此时,需要加载组件的一个实例。有一个组件注册表,其中包含属于该模块的组件,Angular 将查找相关的组件并检索其组件工厂,该工厂是在应用程序运行之前使用 CLI 在编译过程中生成的。这个特殊类知道如何实例化组件的新实例。

当组件实例化时,会读取元数据并触发构造函数方法。任何构造逻辑都会在组件的生命早期运行,您应该小心不要放置任何可能依赖于子组件可用的逻辑,因为模板还没有被解析。

c04-5.png

图 4.5 应用运行期间的组件生命周期

组件元数据将被 Angular 完全处理,包括解析组件模板、样式和绑定。如果模板包含任何子组件,这些子组件也将启动相同的生命周期,但它们不会阻止此组件继续渲染。

在这个时刻,我们已经初始化了组件,开始了一个周期,其中子组件被完全渲染,应用程序状态发生变化,组件被更新。在这个周期中,生命周期钩子会触发,以提醒您在安全执行某些操作的重要时刻。例如,有一个生命周期钩子会告诉您任何输入是否已更改;另一个会告诉您所有子组件是否已完全解析(以防您有依赖于它们的逻辑)。

在某个时刻,组件可能不再在应用程序中需要。在那个时刻,Angular 将销毁组件(及其所有子组件)。任何新的实例都需要从组件工厂类中重新创建,就像我们之前看到的那样。

4.2.2 生命周期钩子

在应用程序渲染过程和响应用户输入时,可以使用各种钩子在各个检查点运行代码。当您需要在执行代码之前知道某些条件为真时,这些钩子非常有用,例如确保子组件已初始化,或者当检测到变化时。

在第二章中,我们看到了OnInit生命周期钩子的实际应用。它在周期早期运行,但在所有绑定都解析完毕之后,因此可以更安全地知道所有数据都对组件可用。

只有当组件中定义了生命周期钩子时,Angular 才会运行生命周期钩子。生命周期钩子不同于事件监听器——它们是具有特定名称的特殊方法,如果定义了,会在组件的生命周期中调用。

生命周期钩子的命名方式与您在表 4.1 中看到的一样,但您在控制器中实现钩子时,也需要以ng作为前缀。OnInit需要实现为ngOnInit()方法。

表 4.1 生命周期钩子和它们的作用列表

生命周期钩子 作用
OnChanges 任何输入绑定更改时都会触发。它将提供一个对象(SimpleChange),其中包含当前值和上一个值,以便您可以检查更改的内容。这主要用于读取绑定值的变化。
OnInit 这在组件完全初始化后运行一次(尽管不一定是在所有子组件都准备好之后),即在第一个 OnChanges 钩子之后。这是进行任何初始化代码的最佳位置,例如从 API 加载数据。
OnDestroy 在组件完全移除之前,OnDestroy 钩子允许你运行一些逻辑。这在你需要停止监听传入的数据或清除计时器时非常有用。
DoCheck 每次变更检测运行以确定应用程序是否需要更新时,DoCheck 生命周期钩子允许你实现自己的变更检测类型。
AfterContentInit 当任何内容子组件已完全初始化后,此钩子将允许你执行完成设置内容子组件所需的任何初始工作,例如,如果你需要验证传入的内容是否有效。
AfterContentChecked 每次 Angular 检查内容子组件时,都可以运行此钩子,这样你可以实现额外的变更检测逻辑。
AfterViewInit 此钩子允许你在所有视图子组件已初始渲染后运行逻辑。这让你知道整个组件树已经完全初始化并且可以被操作。
AfterViewChecked 当 Angular 检查组件视图以及任何视图子组件已被检查时,你可以实现额外的逻辑来确定是否发生了变化。

OnInitOnChangesOnDestroy 钩子是最常用的生命周期钩子。DoCheckAfterContentCheckedAfterViewChecked 钩子最有用,可以跟踪在任意变更检测过程中需要运行的逻辑,并在必要时做出响应。OnInitAfterContentInitAfterViewInit 钩子主要用于在组件的初始渲染期间运行逻辑以设置其状态,并且每个钩子确保了不同级别的组件完整性(例如,它是否已准备好或子组件是否也已准备好)。

你可能想知道内容子组件和视图子组件之间的区别。让我们简要地谈谈组件是如何嵌套的以及这如何影响组件的渲染。

4.2.3 组件嵌套

因为 Angular 是一个组件树,你将在组件内部嵌套其他组件。有两种嵌套组件的方式,并且根据它们的渲染方式有不同的名称。再次查看图 4.1 中的章节示例,你会看到组件是如何嵌套在一起以创建更复杂的界面的。

通常,组件通过在另一个组件的模板中声明来嵌套。任何嵌套在另一个模板内部的组件都称为 视图子组件,之所以这样命名是因为模板代表了组件的视图,因此在该视图中是一个子组件。视图子组件是在组件模板内部声明的。

有时组件会接受要插入其模板中的内容,这被称为 内容子组件,之所以这样命名,是因为这些组件作为内容插入到组件内部,而不是直接在模板中声明。当使用组件时,内容子组件是在开标签和闭标签之间声明的。

让我们举一个例子,以确保我们可以看到区别。想象我们有一个 UserProfile 组件,其模板如下:

<user-avatar [avatar]="avatar"></user-avatar>
<ng-content></ng-content> 

第一行使用了一个组件 UserAvatar,它是一个视图子组件。注意它在组件模板中的声明方式。然后是 NgContent 元素——我将在后面更详细地介绍它,但简单来说,它是一个渲染额外内容的地方。通过 NgContent 传递的任何组件都会被视为内容子组件。当我们使用 UserProfile 组件时,其使用方式可能如下所示:

<user-profile [avatar]="user.avatar">
  <user-details [user]="user"></user-details>
</user-profile> 

当我们使用 UserProfile 组件时,我们通过在开标签和闭标签之间声明它,将另一个组件 User­Details 传递给该组件。这就是内容子组件如何传递给组件,并将其放置在 UserProfile 组件中的 NgContent 元素所在的位置。

在我们的示例中,我们有两个嵌套组件,UserAvatar 作为视图子组件,UserDetails 作为内容子组件。这种区别在于它们的声明位置,与组件本身的设计无关。

通常,单个组件的代码应专注于其自身业务,而无需过多关注子组件(无论是哪种类型)。但有一些用例,你会构建一个需要区分这些类型组件及其子组件的组件。关注点始终是使组件过于耦合,但有时这是不可避免的,甚至可能是希望的(例如,Tabs 和 Tab 组件一起工作以创建标签界面),因此这种区分可能很重要。

现在我们已经涵盖了组件背后的许多高级概念,我们可以回到我们的示例。下一步是创建我们的第二个组件,并生成一些数据以供显示。

4.3 组件类型

基本上只有一种组件类型,但我喜欢根据它们的作用将其分为四个类别。所有组件都是声明并按基本相同的方式运行的,但它们可以以不同的方式实例化,可能包含或不包含状态,或者与其他应用方面的耦合使其与其他组件不同。

我认为这些分类对于描述组件的作用和提供关于它们应该如何设计的总体指导是有用的。这不应被视为必须遵循的严格规则集——你肯定会构建不符合这些指南的组件,这是完全可以接受的。将这些想法放在心里,我相信它们会很有帮助。以下是组件的四个角色以及我给它们取的名字:

  • App 组件 — 这是根应用程序组件,每个应用程序只有一个这样的组件。

  • 显示组件 — 这是一个无状态的组件,它反映了传递给它的值,使其具有高度的复用性。

  • 数据组件 — 这是一个通过从外部源加载数据来帮助将数据带入应用程序的组件。

  • 路由组件 — 当使用路由器时,每个路由都会渲染一个组件,这使得组件本质上与路由相关联。

Angular 没有提供这样的标准命名法来表示组件的不同角色,因此您无法根据这些名称搜索相关内容。真正的价值在于理解,通常最好为您的组件指定特定的角色,而不是试图让它们做太多事情。让我们更详细地看看这些不同的角色以及为什么它们被指定为不同的组。

App 组件

App 组件是一个特殊案例组件。正如您所知,每个 Angular 应用程序都是从渲染一个组件开始的,如果您使用了 CLI,它将是 AppComponent(但如果您更改了名称,它可以是任何名称)。

这里是我为您推荐的 App 组件的指导方针:

  • 保持简单 — 如果可能,不要在组件中放入任何逻辑。更像是容器。如果 App 组件没有复杂的依赖行为,那么更容易重用和优化其他组件。

  • 用于应用程序布局脚手架 — 模板是组件的主要部分,您将在本章后面看到我们如何在这个组件中创建主要的应用程序布局。

  • 避免加载数据 — 通常您会避免在这个组件中加载数据,因为我喜欢将数据加载得更接近使用该数据的组件。您可能需要加载一些全局数据(例如用户会话),尽管这也可以单独完成。在不太复杂的应用程序中,您可能需要加载数据,因为抽象化较小的应用程序可能更复杂。

在我看来,最好的规则是尽可能保持 App 组件的简单性。通常,我只有一个模板和一个空控制器。目的是避免在应用程序控制器中做太多的“全局”类型逻辑和配置,以增加其他组件的模块化,并保持逻辑在需要它的组件内部。

显示组件

显示组件的角色可能是您在构建 Angular 专业技能时最常见的一个。这些组件通常用于渲染内容,并且通常提供必要的数据来显示。大多数第三方组件都将扮演这个角色,因为它是最解耦的组件类型。

这里是为 Display 组件建议的主要指导方针:

  • 解耦 — 确保组件与其他组件没有实际的耦合,除了在需要时可能作为输入传递数据到它。

  • 使其尽可能灵活 — 避免使这些组件过于复杂,并添加大量的配置和选项。随着时间的推移,你可能会增强它们,但我发现最好是从简单开始,然后逐步添加。

  • 不要加载数据 — 总是通过输入绑定接受数据,而不是通过 HTTP 或服务动态加载数据。

  • 拥有干净的 API — 接受输入绑定以将数据输入组件,并发出任何需要推送到其他组件的动作的事件。

  • 可选地使用服务进行配置 — 有时候你可能需要提供配置默认值,而不是在每次使用组件时都要声明首选项,你可以使用设置应用程序默认值的服务。

有时候,让你的组件更加隔离和具体以显示输出可能会感觉过度。组件越封装和隔离,就越容易重用。

我经常发现,当我开始重构一些代码时,我首先会识别出代码中可以独立显示的各个部分。我可能会注意到很多重复的代码片段,它们大多具有相同的功能,并将它们重构为一个单一组件。

这些组件通常在控制器中有一个模板和很少的逻辑。这是完全可以接受的,因为它允许你轻松地在应用程序中重用模板片段。

数据组件

数据组件负责加载数据或管理数据。大多数应用程序需要从外部来源(HTTP 或用户输入)获取数据,最好将其包含在数据组件中,而不是显示组件中。我发现大多数开发者首先构建这些组件,然后最终开始将部分抽象为路由或显示组件类型。

数据组件主要关于处理、检索或接受数据。通常,它们依赖于服务来处理数据的加载,正如我们在第二章中看到的。以下是一些关于数据组件的考虑因素:

  • 使用适当的生命周期钩子 — 为了进行初始数据加载,始终利用最佳的生命周期钩子来触发数据的加载或持久化。我们将在本章后面更详细地探讨这一点。

  • 不要担心可重用性 — 这些组件不太可能被重用,因为它们有特殊的作用来管理数据,这很难解耦。

  • 设置显示组件 — 考虑这个组件如何加载其他显示组件所需的数据,并处理来自用户交互的任何数据。

  • 在内部隔离业务逻辑 — 这可以是一个存储应用程序业务逻辑的好地方,因为每次你管理数据时,你很可能会处理一个针对特定用例的特定实现。

我试图限制处理数据的组件数量,以避免创建太多的专用组件。每个应用程序都是不同的,也许你也利用了一个不错的 UI 库,不需要创建很多自己的显示组件,因此,你为应用程序编写的代码可能更侧重于数据组件(尽管整体应用程序仍将包含大量由第三方模块提供的显示组件)。

路由组件

路由组件是直接链接到路由的任何组件。这些组件不太可重用,通常是为应用程序中的特定路由专门创建的。

这些组件也通常遵循数据组件的原则,因为路由通常需要为新视图加载更多数据。我区分它们的原因是,单个路由可能渲染出多个数据组件,例如在具有多个组件加载度量信息的仪表板中。

路由组件应主要遵循以下指南:

  • 路由模板脚手架 — 路由将渲染此组件,因此这是放置与路由关联的模板的最合逻辑的地方。

  • 加载数据或依赖数据组件 — 根据路由的复杂性,路由组件可能为路由加载数据或依赖一个或多个数据组件来完成这项工作。如果你不确定,我建议最初在路由组件中加载数据,并在视图变得更加复杂时解耦。

  • 处理路由参数 — 在导航过程中,很可能会遇到路由参数(例如正在查看的内容项的 ID),这是处理这些参数的最佳位置,这些参数通常决定了从后端加载哪些内容。

每个你可以导航到的路由都链接到一个组件,因此你创建的路由组件数量直接关联到你添加到应用程序中的路由数量。你可以为不同的路由路由到相同的组件,但这并不常见。

4.4 创建数据组件

我们将首先创建一个组件,以帮助我们管理数据。这是一个数据中心仪表板,因此它提供的数据主要是对数据中心健康至关重要的各种度量值的数值。我们将创建一个名为“仪表板组件”的组件,它将托管我们的数据并在应用程序中显示它。

目前我们将直接将原始数据打印到屏幕上,直到我们创建其他组件。在本节结束时,你的应用程序应该看起来像图 4.6。

c04-6.png

图 4.6 具有仪表板组件的应用程序,该组件生成随机数据集并显示原始数据

首先,使用 CLI 生成一个新的组件。然后我们将逻辑添加到控制器中,并看到几个生命周期钩子的实际应用。这将是一个很好的数据组件的例子,因为它将处理整个应用程序的数据,而不会过多地处理内容的显示:

ng generate component dashboard 

现在打开src/app/dashboard/dashboard.component.ts文件,并用以下列表中的内容替换其内容。

列表 4.1 Dashboard 组件控制器

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

interface Metric {     
 used: number,
 available: number
};     
interface Node {     
 name: string,
 cpu: Metric,
 mem: Metric
};     

@Component({
  selector: 'app-dashboard',
  templateUrl: './dashboard.component.html',
  styleUrls: ['./dashboard.component.css']
})
export class DashboardComponent implements OnInit, OnDestroy {     
 cpu: Metric;
 mem: Metric;
 [cluster1: Node[];](#c04-codeannotation-0003)
 [cluster2: Node[];](#c04-codeannotation-0003)
 interval: any;

 ngOnInit(): void {
 this.generateData();

 this.interval = setInterval(() => {
 this.generateData();
 }, 15000);
 }

 ngOnDestroy(): void {
 clearInterval(this.interval);
 }

  generateData(): void {
    this.cluster1 = []; 
    this.cluster2 = [];
    this.cpu = { used: 0, available: 0 };
    this.mem = { used: 0, available: 0 };
    for (let i = 1; i < 4; i++) this.cluster1.push(this.randomNode(i));
    for (let i = 4; i < 7; i++) this.cluster2.push(this.randomNode(i));
  }

  private randomNode(i): Node {
    let node = {
      name: 'node' + i, 
      cpu: { available: 16, used: this.randomInteger(0, 16) }, 
      mem: { available: 48, used: this.randomInteger(0, 48) }
    };
    this.cpu.used += node.cpu.used;
    this.cpu.available += node.cpu.available;
    this.mem.used += node.mem.used;
    this.mem.available += node.mem.available;

    return node;
  }

  private randomInteger(min: number = 0, max: number = 100): number {
    return Math.floor(Math.random() * max) + 1;
  }
} 

我们首先导入OnInitOnDestroy接口,我们在创建控制器时使用这些接口来为 TypeScript 编译器提供更好的智能,以便了解什么构成了有效的控制器实现。在这种情况下,OnInitOnDestroy是接口,它们告诉 TypeScript 必须分别实现ngOnInitngOnDestroy方法。

为了增加额外的清晰度,MetricNode接口描述了我们用于数据的结构。这是可选的,但我建议利用接口来正确执行代码,因为接口有助于随着时间的推移维护代码。该组件有五个属性,其中四个包含仪表板的数据值,最后一个用于维护对间隔的引用,以便稍后可以清除它。

然后,我们使用NgOnInit生命周期钩子生成数据,并设置一个每 15 秒生成新数据集的间隔。当 Dashboard 组件从应用程序中移除时,也使用NgOnDestroy生命周期钩子来清除间隔。任何使用间隔的时候,你都会想要确保在不再需要它时清除间隔,否则随着时间的推移,你将创建内存泄漏,因为即使导航到另一个页面,间隔仍然会继续存在。

其余的代码是一组用于生成数据的方法,我不会详细说明。它将为两个集群中的六个节点生成内存和 CPU 指标,并汇总总利用率指标。随着我们构建下一个几个组件,如何利用这些数据将变得更加明显。

现在在组件模板中,该模板位于文件src/app/dashboard/dashboard.component.html中,我们将原始数据绑定到屏幕上,以便我们可以看到它是如何生成的。用以下单个插值绑定替换该文件的原始内容,该绑定将数据绑定到一个簇:

<p>{{cluster1 | json}}</p> 

我们还没有将仪表板添加到我们的应用程序中,所以打开src/app/app.component.html文件,并将仪表板添加到模板的底部,如下所示。它应该看起来像本节前面看到的图 4.3:

<app-navbar></app-navbar>
<app-dashboard></app-dashboard> 

至此,Dashboard 组件的内容就到这里了——目前是这样。在本章的后面部分,当我们构建更多用于显示数据的组件时,我们将向 Dashboard 添加更多内容,从下一个组件开始,该组件将显示 CPU 和内存指标,并通过输入接受数据。

4.5 使用组件的输入

当你创建自己的组件时,你可以定义可以接受通过属性绑定输入的属性。任何默认 HTML 元素属性也可以绑定到组件,但你可以定义组件可以用来管理其生命周期或显示的附加属性。

我们在第二章中看到了这一点,以下是我们用于将股票数据绑定到摘要组件的代码片段。你看到股票属性周围的方括号,表示值绑定到在股票属性中找到的数据:

<summary [stock]="stock"></summary> 

默认情况下,组件的所有属性都不是立即可绑定的,正如你之前看到的。它们必须声明为输入,以便允许属性进行绑定。一个原因是我们要封装我们的组件,最好不要自动公开所有属性。另一个原因是 Angular 需要知道哪些属性存在,以便它可以正确处理将值连接到组件的线路。

在我们的应用程序中,我们想显示整个数据中心的一些度量数据。这如图 4.7 所示,这是我们在本节中将要完成的工作的结果。这些组件的作用是显示整个数据中心的 CPU 和内存度量信息。因为这些组件之间唯一的区别是数据,我们将使其可重用,使用输入将数据传递到组件中。

c04-7.png

图 4.7 在与不同数据共享相同显示的应用中添加度量组件

4.5.1 输入基础

在我们进一步深入研究之前,让我们构建一个具有输入的组件,并看看它的实际效果。使用 CLI 生成一个新组件,如下所示:

ng generate component metric 

我们首先将查看组件控制器,因此打开 src/app/metric/metric.component.ts 文件,并更新为以下列表中的内容。

列表 4.2 度量组件控制器

import { Component, Input } from '@angular/core';     
 @Component({
  selector: 'app-metric',
  templateUrl: './metric.component.html',
  styleUrls: ['./metric.component.css']
})
export class MetricComponent {
 @Input() title: string = '';
 @Input() description: string = '';
 @Input('used') value: number = 0;
 @Input('available') max: number = 100;

 isDanger() {
 return this.value / this.max > 0.7;
 }
} 

度量组件首先导入 Input 装饰器,该装饰器用于装饰任何你想定义为输入的属性。在这种情况下,我们声明了四个输入,这意味着该组件的所有属性都可用于绑定。然后我们实现了一个简单的 isDanger() 方法,它将告诉我们利用率是否超过 70%,因此我们可以以不同的方式显示度量。

前两个属性是 titledescriptionInput 装饰器位于前面,它将根据属性的名称使每个属性可用于绑定。尽管我为每个属性声明了类型,但重要的是要注意,它们在运行时并不验证输入是否与该类型匹配。你仍然需要清理输入或处理无效值。

最后两个属性将可选值传递给Input装饰器,这是一种更改绑定中使用的属性名称的方法,从组件内部使用的名称。这允许我们将used属性别名到value属性,将available属性别名到max属性。当你绑定到度量组件时,你将使用绑定[used]="node.used",但在组件内部它将使用属性value

你可能会听到一些人提到组件 API,他们谈论的是组件输入。因为这是将数据传递到组件中的主要方式,所以它就像一个如何在应用程序中消费组件的合同。能够重命名输入对于代码清晰度有益,因为它允许你以不同的方式暴露绑定,与内部实现不同。但我建议为了简单和调试,使名称保持一致。

我还建议你确保你的输入绑定和属性名称尽可能清晰。为了节省字符而使属性名称短通常不值得,因为开发者如果不深入代码,就不知道该属性是关于什么的。

随着你构建更多组件并在更大的项目上工作,你可能会开始创建在项目和应用程序之间重复使用的组件。这也是 Angular 的一个核心原则——使组件的重复使用变得容易。

现在,让我们设置度量组件模板,并且我们将使用 ng-bootstrap 进度条组件作为如何消费具有输入的另一个组件的示例。打开src/app/metric/metric.component.html文件,并将其更新为以下列表的内容。

列表 4.3 度量组件模板

<div class="card card-block">
  <div class="card-body">
 [<nav class="navbar navbar-dark bg-primary mb-1" [ngClass]="{'bg-danger': isDanger(), 'bg-success': !isDanger()}">](#c04-codeannotation-0009)
 <h1 class="navbar-brand mb-0">{{title}}</h1>
 </nav>
 <h4 class="card-title">{{value}}/{{max}} ({{value / max | percent:'1.0-2'}})</h4>
 <p class="card-text">
 {{description}}
 </p>
 [<ngb-progressbar [value]="value" [max]="max" [type]="isDanger() ? 'danger' : 'success'"></ngb-progressbar>](#c04-codeannotation-0013)
 </div>
</div> 

此模板在很短的空间内使用了大量的 Angular 模板语法功能。首先,我们使用 bootstrap CSS 样式创建度量组件的卡片布局,因此卡片类来自 bootstrap。

在此模板中,所有绑定都将解析为在控制器中定义的四个输入属性,但请注意,我们使用的是属性名称,而不是传递给Input的任何别名名称。

nav元素用于创建包含标题的顶部标题栏。使用 NgClass,它将根据整体值的使用量应用bg-dangerbg-success类。

有几个基本的插值绑定,例如titlevaluemaxdescription。我们还有一个更复杂的插值表达式的示例,它将value除以max并将其格式化为百分比。

最后,我们有一个使用 ng-bootstrap 进度条组件的示例。根据进度条的文档,它接受我们声明的三个绑定,为 valuemaxtype 提供绑定。valuemax 是数字,而 type 是用于着色条形的引导类(如 dangersuccess)。

进度条是一个设计良好的组件的绝佳例子。它有一组清晰的输入属性。它不需要太多的努力来消费,并内部化了大部分逻辑。

因为我们在之前将 ng-bootstrap 模块添加到我们的 App 模块中,所以所有由 ng-bootstrap 提供的组件都可以使用,而无需为它们做出任何特殊请求。大多数第三方库体验都将类似,一旦你将第三方模块添加到你的应用中,你的应用就可以轻松消费第三方提供的值。

让我们将这个指标组件显示在屏幕上。打开 src/app/dashboard/dashboard.component.html 文件,并用以下列表中的代码替换其内容。记住,仪表板组件包含现在可以绑定到组件中的数据。

列表 4.4 使用指标组件

<div class="container mt-2">
  <div class="card card-block">
    <nav class="navbar navbar-dark bg-inverse mb-1">
      <h1 class="navbar-brand mb-0">Overall Metrics</h1>
    </nav>
    <div class="row">
 <app-metric class="col-sm-6"
 [[used]="cpu.used"](#c04-codeannotation-0014)
 [[available]="cpu.available"](#c04-codeannotation-0014)
 [[title]="'CPU'"](#c04-codeannotation-0014)
 [[description]="'utilization of CPU cores'">](#c04-codeannotation-0014)
 </app-metric>
 <app-metric class="col-sm-6"
 [[used]="mem.used"](#c04-codeannotation-0015)
 [[available]="mem.available"](#c04-codeannotation-0015)
 [[title]="'Memory'"](#c04-codeannotation-0015)
 [[description]="'utilization of memory in GB'">](#c04-codeannotation-0015)
 </app-metric>
    </div>
  </div>
</div> 

在这里,我们定义了一些标记来为引导样式创建一个新的容器和用于显示的卡片块。真正有意义的是使用了指标组件。注意,我们仍然能够将一个类应用到元素上,因为它在渲染时被当作一个常规的 HTML 元素处理。

我们在组件中定义的四个属性被列为一个属性,并带有括号包围的绑定语法。记住,这是告诉 Angular 对父组件(仪表板组件)中的表达式进行评估并返回其值给子组件(指标组件)的语法。因为它们被标记为输入,我们可以将这些属性绑定到非标准 HTML 属性上。对于 usedavailable 属性,我们将其绑定到 cpumem 控制器值上的相应属性。然后对于 titledescription,我们提供一个字符串字面量,因为它们被评估为一个表达式。

你注意到这个组件更通用了吗?它接受它需要显示的四个输入,甚至包括一些文本。我们可以轻松地多次使用相同的组件,并为每个指标提供独特的显示。这通常比制作许多几乎执行相同任务的组件更受欢迎。

4.5.2 截获输入

我们目前盲目地接受传递给组件的任何值,并使用它们而不进行任何验证。很容易忘记验证或清理数据输入,尤其是在构建组件树并确信正在传递的数据类型时。

问题在于,随着应用程序的增长或组件以新的方式被重用,跟踪输入变得更加困难。在可能的情况下尝试验证输入也是良好的实践,以加固你的组件。

例如,如果我们将绑定到一个度量组件的值的类型更改错误,度量组件将会有一些问题。自己试一试。将 [used]="cpu.used" 改为 [used]="'fail'"。这会将绑定更改为传递一个字符串而不是一个数字。度量组件将抛出一个错误,因为最终它将尝试将字符串和数字相除,这是无效的。

我喜欢使用一个方法来拦截需要通过获取和设置方法进行验证的输入。这是一个 JavaScript 中已经存在了一段时间的功能,但我直到最近才看到它被广泛使用。

这里的主要思想是,而不是直接绑定到一个属性,你将输入绑定到设置方法,该方法将真实值存储在一个私有属性上,这样你就可以保护它免受直接访问。设置方法也是你运行任何验证逻辑的地方。然后你使用获取方法在任何请求时返回私有属性的值。

让我们修改我们的度量组件,使用这种方法来验证输入值并保护我们的模板免受非数字值的除法错误。打开 src/app/metric/metric.component.ts 文件,将其更改为以下列表中的内容。

列表 4.5 度量组件拦截输入

export class MetricComponent {
  @Input() title: string;
  @Input() description: string;
 private _value: number = 0;
 private _max: number = 100;

 @Input('used')
 set value(value: number) {
 if (isNaN(value)) value = 0;
 this._value = value;
 }

 get value(): number { return this._value; }
 @Input('available')
 set max(max: number) {
 if (isNaN(max)) max = 100;
 this._max = max;
 }

 get max(): number { return this._max; }
 isDanger() {
    return this.value / this.max > 0.7;
  }
} 

如果你之前没有使用过获取或设置方法,它们是前面带有 getset 关键字的函数。方法的名字必须与属性的名称匹配,所以在这个例子中 get value(){} 将会在任何请求 this.value 属性时被调用。同样,任何将新值存储到 this.value 中的操作都会调用 set value(value){} 方法,并传递存储的值。

我们首先创建了两个新的私有属性,_value_max。使用 private 关键字,TypeScript 将确保它们不会直接暴露在控制器中,因此它们不能在这个控制器之外被修改。名称前的下划线是一个常见的约定,用来通知开发者该属性被认为是私有的。

我们实现了值设置方法,并使用 Input 装饰器对其进行装饰。这注册了一个普通的 input 属性,但在绑定到组件时,它将通过这个函数传递。该函数会检查值是否为数字——如果不是,它将将其设置为 0 并将此值存储在 _value 属性上。然后我们实现了获取方法来检索 _value 属性。我们对 max 属性也做了同样的基本处理,但如果输入无效,则将其默认设置为 100

我们现在已经保护了输入,防止了无效值,但还有其他场景你可能想要拦截值。你可能想在它们被使用之前格式化一些值,例如确保单词首字母大写。

这种方法的主要问题是每次你使用 getter 方法读取属性时,它都会运行 getter 方法内的逻辑。在大多数情况下,这可能不是什么大问题,比如如果你只是返回一个私有属性,就像我们在这里做的那样,但如果你有更复杂的逻辑,这些函数可能会对渲染速度产生影响。另外,不要在 getter 函数中进行任何修改!这样做会导致意外的行为,因为你无法确定 getter 函数会被调用多少次。

我们还有另一种方法来拦截和修改输入,当它们进入组件时,但首先我们将看看如何在组件内部进行内容投影。

4.6 内容投影

想象一下,你创建了一个 Card 组件,并且希望卡片内容区域能够灵活地接受开发者需要插入的任何类型的标记。我们可以通过使用内容投影来实现这一点,它允许我们声明将外部内容插入到我们的组件中的位置。

内容投影是 Angular 从 Web 组件中实现的一个概念。如果我们考虑显示组件的作用,接受标记以在组件内部显示的需求相当普遍。标签页、卡片、导航栏、模态框、对话框、侧边栏——可以接受通用一组标记以在组件内部显示的 UI 元素类型清单可以继续下去。

因为我们想要创建可重用的显示组件,内容投影是我们需要使用的关键功能。好消息是它相对简单易实现,所以让我们看看它是如何发挥作用的。

我们将构建两个组件,帮助我们创建一个表格来显示服务器集群中的每个节点。如果你这么想,表格已经使用了内容投影,因为你创建了一个表格,然后在标题内嵌套行,然后在行内插入单元格。参见图 4.8。

使用 CLI 生成两个新的组件,一个用于 Nodes 组件,另一个用于 Nodes Row 组件:

ng generate component nodes
ng generate component nodes-row 

c04-8.png

图 4.8 Nodes 组件使用内容投影来显示节点行组件

我们将首先让 Nodes 组件启动并运行,我们只从模板开始。控制器是空的,对于这个用例,我们不需要任何控制器逻辑来使用内容投影。打开 src/app/nodes/nodes.component.html,并用以下列表替换其内容。

列表 4.6 Nodes 组件模板

<thead>
  <tr>
    <th>Node</th>
 [<th [colSpan]="2">CPU</th>](#c04-codeannotation-0021)
 [<th [colSpan]="2">Memory</th>](#c04-codeannotation-0021)
    <th>Details</th>
  </tr>
</thead>
<ng-content></ng-content>     

您可以看到这个模板包含表格标题的标记。我想指出的是,这里使用了属性绑定到一个非标准名称;在这种情况下,属性是 colspan,但元素上的属性是 colSpan。在这里使用绑定的唯一好处是,通常您需要绑定到一个表达式而不是一个静态值,所以您不会这样做。

真正有趣的部分是 NgContent 元素,它告诉 Angular 这个组件有一个内容插入点。当有人使用这个元素并在元素内部嵌套额外的标记时,它将被放置在 NgContent 元素当前所在的位置。这允许您精确地选择内容插入的位置(因此得名 插入点)。

我们很快就会看到它是如何工作的,但我们需要对组件装饰器进行一个小小的修改。打开 src/app/nodes/nodes.component.ts 文件,将选择器更改为以下内容:

selector: '[app-nodes]', 

我们在这里更改选择器,使其查找属性 app-nodes 而不是名为 app-nodes 的元素。选择器可以接受任何有效的 CSS 选择器。在这种情况下,我们使用属性 CSS 选择器来定位具有 app-nodes 属性的元素。我们这样做是为了能够将此组件应用到另一个元素上。

节点组件创建我们的表格标题,现在我们需要创建一个处理显示内容单个行的组件。打开 src/app/nodes-row/nodes-row.component.html 文件,并用以下列表中的代码替换其内容。

列表 4.7 节点行组件模板

<th scope="row">{{node.name}}</th>
[<td [class.table-danger]="isDanger('cpu')">     ](#c04-codeannotation-0023)
 {{node.cpu.used}}/{{node.cpu.available}}
</td>
<td [class.table-danger]="isDanger('cpu')">
 ({{node.cpu.used / node.cpu.available | percent}})
</td>
<td [class.table-danger]="isDanger('mem')">
  {{node.mem.used}}/{{node.mem.available}}
</td>
<td [class.table-danger]="isDanger('mem')">
  ({{node.mem.used / node.mem.available | percent}})
</td>
<td><button class="btn btn-secondary">View</button></td> 

模板由一组显示各种数据的表格单元格组成。数据单元格使用特殊的 class 绑定,如果值超过 70% 的阈值,则条件性地应用 table-danger CSS 类到单元格。它还包含一个绑定,将值分割以产生利用率百分比,并使用百分比管道格式化值。

现在您可能正在想,组件必须通过输入接收 node 对象,因为,正如我们之前讨论的,仪表板组件持有所有数据。为了使其工作,我们需要正确设置我们的组件控制器,并实现在此模板中调用的 isDanger() 方法。为此,打开 src/app/nodes-row/nodes-row.component.ts 文件,并用以下列表中的内容替换其内容。

列表 4.8 节点行组件控制器

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

@Component({
 [selector: '[app-nodes-row]',](#c04-codeannotation-0025)
 templateUrl: './nodes-row.component.html',
  styleUrls: ['./nodes-row.component.css']
})
export class NodesRowComponent {
 @Input() node: any;
 isDanger(prop) {
 [return this.node[prop].used / this.node[prop].available > 0.7;](#c04-codeannotation-0027)
 }
} 

我们还想使用属性选择器来为这个组件,因此相应地更新选择器。然后我们设置 node 属性作为输入,以便我们可以将值绑定到这个组件中,并实现 isDanger() 方法来计算使用是否超过我们设定的 70% 阈值。

这个组件没有内容插入,因为它只描述了一个表格行。但由于我们会多次使用它,因此将其抽象成一个独立的组件是谨慎的。这使得它成为一个完美的例子,即一个显示组件根据提供的输入数据修改其显示。

现在,我们可以看到这两个组件在行动中的效果。打开 src/app/dashboard/dashboard.component.html 文件,并将以下列表中的代码添加到模板的底部(不要删除任何内容——添加它)。

列表 4.9 使用节点和节点行组件的仪表板

<div class="container mt-2">
  <div class="card card-block">
    <div class="card-body">
      <nav class="navbar navbar-dark bg-inverse mb-1">
        <h1 class="navbar-brand mb-0">Cluster 1</h1>
      </nav>
 <table app-nodes class="table table-hover">
 [<tr app-nodes-row *ngFor="let node of cluster1" [node]="node"></tr>](#c04-codeannotation-0029)
 </table>
      <nav class="navbar navbar-dark bg-inverse mb-1">
        <h1 class="navbar-brand mb-0">Cluster 2</h1>
      </nav>
      <table app-nodes class="table table-hover">
        <tr app-nodes-row *ngFor="let node of cluster2" [node]="node"></tr>
      </table>
    </div>
  </div>
</div> 

仪表板现在实现了另一个包含两个集群表格的部分。因为我们使用了属性选择器来定位表格元素上的节点组件,所以它将在表格内部应用组件模板。这将插入节点组件模板中的表格头元素,但它也包含 NgContent 插入点。

表格有一个子元素,即表格行元素,节点行组件应用于该行。它还有一个 ngFor 来遍历给定集群中的所有节点,因此每个表格将创建三个实例。最后,表格行还有一个绑定来捕获特定的节点值。表格行显示了几个不同的 Angular 功能协同工作,以便轻松迭代列表并显示抽象为组件的表格行。

我们已经在一个地方注入了内容,但如果我们想有多个插入点怎么办?我们可以通过命名我们的插入点来实现这一点。为了演示,让我们用子元素替换我们绑定度量组件标题和描述的方式。

打开 src/app/metric/metric.component.html 并更新它以反映以下列表中的代码。我们可以通过添加一个具有 CSS 选择器的属性来使用多个 NgContent 元素,用于定位。

列表 4.10 具有内容投影的度量组件模板

<div class="card card-block">
  <div class="card-body">
    <nav class="navbar navbar-dark bg-primary mb-1" [ngClass]="{'bg-danger': isDanger(), 'bg-success': !isDanger()}">
 <h1 class="navbar-brand mb-0">**<ng-content select="metric-title"></ng-content>**</h1>
 </nav>
    <h4 class="card-title">{{value}}/{{max}} ({{value / max | percent:'1.0-2'}})</h4>
    <p class="card-text">
 **<ng-content select="metric-description"></ng-content>**
    </p>
    <ngb-progressbar [value]="value" [max]="max" [type]="isDanger() ? 'danger' : 'success'"></ngb-progressbar>
  </div>
</div> 

我们已经用 NgContent 元素替换了插值绑定,在两种情况下它都有一个 select 属性。这是一个 CSS 选择器,Angular 在渲染时会查找它以确定要插入什么内容。在这种情况下,我们期望有两个元素,metric-titlemetric-description

这意味着度量组件需要通过这些名称有两个子元素来正确显示该内容,但如果缺少这些子元素,它将显示为空白。您可以使用其他 CSS 选择器,并且它将根据这些选择器定位元素,例如类名或属性。

我们还应该从组件中移除标题和描述输入,因此打开 src/app/metric/metric.component.ts 并删除这两行:

@Input() title: string;
@Input() description: string; 

现在我们需要更新我们的仪表板组件,使用这些新元素而不是直接绑定到属性,因为从度量组件中移除它们后,它将抛出错误。打开 src/app/dashboard/dashboard.component.html 并修改部分,如下所示。

列表 4.11 将度量信息投影到组件中

<div class="container mt-2">
  <div class="row">
    <app-metric class="col-sm-6" [used]="cpu.used" [available]="cpu.available">
 <metric-title>CPU</metric-title>
 <metric-description>utilization of CPU cores</metric-description>
 </app-metric>
    <app-metric class="col-sm-6" [used]="mem.used" [available]="mem.available">
      <metric-title>Memory</metric-title>
      <metric-description>utilization of memory in GB</metric-description>
    </app-metric>
  </div>
</div> 

如您在此处所见,我们已经用我们通过 NgContent 选择属性声明的名称替换了 titledescription 绑定,使用自定义元素。结果的用户界面看起来相同,但它确实将元素按照它们的位置插入,因此您可以在元素内部嵌套更多的标记。

现在如果您运行代码,您将收到 Angular 的编译错误。它将尝试解析这些新元素,识别出它们不是已注册的组件,并抛出错误。我们可以通过在 App 模块中设置一些配置来修复这个问题,告诉 Angular 不要对找到它不理解元素感到不安。

打开 src/app/app.module.ts 文件并做两个小的修改。第一个是导入 NO_ERRORS_SCHEMA 对象从 @angular/core

import { NgModule, NO_ERRORS_SCHEMA } from '@angular/core'; 

然后,您将在 NgModule 定义中添加一个名为 schemas 的新属性。在 bootstrap 属性之后添加它,如下所示:

 bootstrap: [AppComponent],
  schemas: [NO_ERRORS_SCHEMA]
}) 

Angular 现在将禁用对未知元素的错误抛出,并允许您根据元素名称创建内容插入点。或者,您可以使用其他 CSS 选择器(如类或属性)而不需要这个模式修复。我发现元素在许多情况下更易于访问且更清晰,所以我仍然建议这样做。您唯一失去的是未知组件名称的错误处理,这通常有助于捕获您的标记中的错误。

内容投影功能强大且非常有用,适用于充当显示组件角色的组件。如您所见,您可以使用命名或未命名的内容插入点来包含提供给组件模板的标记,所有这些只需使用 NgContent 元素即可。

这就完成了关于组件的第一章,在下一章中,我们将深入探讨更高级的主题,例如如何优化变更检测并监视输入的变化。

摘要

在本关于组件的第一章中,您学到了很多关于组件基础和许多有用的构建不同目的组件的方法。组件是任何 Angular 应用程序的构建块,Angular 的其他每个功能都以某种方式源自它们。我们已经涵盖了

  • 组件是包含组件类、用 HTML 实现的模板以及与组件相关联的 CSS(用于设置组件样式)的独立元素。

  • 组件可以在应用程序内部扮演各种角色。这些角色是我所说的应用程序、显示、数据和路由组件。尽管它们不是铁的规则,但最好设计您的组件以处理一组单独的任务,以保持它们的专注。

  • @Component装饰器具有多种配置能力,尽管你不太可能在同一个组件中使用它们全部,但你肯定会在某个时候需要利用其中大部分。

  • 我谈到了如何使用Input装饰器或inputs属性定义的输入属性将数据传递给组件。你也看到了输入属性在组件类的构造方法中不可用,但在 NgOnInit 组件生命周期事件处理器中可用。

  • 组件有时需要接受额外的标记并在组件内部显示它,这被称为内容投影。你看到了如何使用 NgContent 将外部内容插入到你的组件中。

5

高级组件

本章涵盖

  • 如何处理和优化变更检测

  • 不同组件之间的通信

  • 样式化和样式封装的不同方式

  • 动态即时渲染组件

第四章涵盖了组件的许多基础知识,但还有更多内容!在这里,我们将深入研究在构建更有趣的应用程序时将派上用场的额外功能。

我们将更详细地探讨变更检测的工作原理,并查看如何使用 OnPush 能力来减少 Angular 需要执行的工作量以提高渲染效率。

虽然组件可以使用输入和输出,但还有其他方式让组件相互通信。我们将探讨为什么您可能选择使用不同的方法以及如何实现它。

有三种主要方式为组件渲染样式,选择不同的模式可能会对组件的显示方式产生重大影响。您可能希望尽可能内部化 CSS 样式,或者您可能不想内部化任何样式,而是使用全局 CSS 样式。

最后,我们将探讨如何动态渲染组件,以及为什么您可能想要这样做。这并不常见,但在某些时刻它是有用的。

如第四章所述,我强烈建议您保持组件的专注。随着我们更多地了解组件功能,您会发现避免过度加载组件非常重要。

本章将继续使用第四章的示例,因此请参考它以了解如何设置示例。所有内容都将建立在您所学的基础上,因此示例将扩展以展示组件的更多高级功能。

我可以几乎 100%的确定性说没有任何组件会使用每一个单独的能力,因为这很可能会使其无法正常工作。然而,掌握这些额外的概念将帮助您编写更复杂和动态的应用程序。让我们先从查看变更检测和如何优化性能开始。

5.1 变更检测与优化

Angular 附带一个变更检测框架,该框架确定当输入发生变化时组件何时需要渲染。组件需要对其组件树中某处所做的更改做出反应,而它们改变的方式是通过输入。

变更总是由某些异步活动触发的,例如当用户与页面交互时。当这些变更发生时,应用程序状态或数据发生变化的机会(尽管没有保证)存在。以下是一些示例:

  • 用户点击按钮以触发表单提交(用户活动)。

  • 每隔x秒触发一个间隔来刷新数据(间隔或定时器)。

  • 回调、可观察对象或承诺被解决(XHR 请求、事件流)。

这些都是事件或异步处理程序,但它们可能来自不同的来源。我们将在其他章节中深入探讨可观察对象和 XHR 请求的行为方式,但在这里我们好奇的是用户操作和间隔触发器如何在 Angular 中引起变化。

Angular 必须知道发生了异步活动,但 JavaScript 中的 setIntervalsetTimeout API 是在 Angular 的意识之外发生的。Angular 已经对 setIntervalsetTimeout 的默认实现进行了猴子补丁,以便在间隔或超时解决时正确触发 Angular 的变化检测。同样,当在 Angular 中处理事件绑定时,它知道要触发变化检测。如果你在 Angular 之外编写需要触发变化检测的代码,有一些特殊的事情要做,但这里不会涉及。

一旦触发变化检测机制,它将从组件树的顶部开始,检查每个节点以查看组件模型是否已更改并需要渲染。这就是为什么必须让输入属性为 Angular 所知,否则它将无法知道如何检测更改。

Angular 有两种变化检测模式:默认和 OnPush。默认模式将在每个变化检测周期中始终检查组件的变化。Angular 已经高度优化了此过程,使其运行这些检查非常高效——在大多数情况下只需几毫秒。当数据在组件之间容易突变时,这一点很重要,并且确保应用程序中的值没有变化可能很困难。

你还可以使用 OnPush 模式,该模式明确告诉 Angular,这个组件只有在其中一个组件输入发生变化时才需要检查变化。这意味着如果父组件没有变化,那么可以知道子组件的输入不会变化,因此可以跳过该组件(以及任何孙组件)的变化检测。仅仅因为输入发生了变化并不意味着组件本身必须发生变化;也许输入是一个具有变化属性的但组件不使用的对象。跟踪你的应用程序中的数据结构可以帮助优化值传递和变化检测触发的方式。

图 5.1 展示了两种变化检测类型。想象有一个具有两个属性的组件树,属性 'b' 通过某些用户输入被更改。默认模式将在组件中更新值,然后检查其下所有组件的变化。OnPush 模式只检查具有特定于更改属性的输入绑定的子组件,并跳过检查其他组件。

c05-1.png

图 5.1  变化检测默认从顶部开始,按树形结构向下进行,或者使用 OnPush 仅在具有更改输入的树形结构中向下进行。

我建议花时间阅读关于变更检测的详细信息,可以从帮助创建它的其中一人 Victor Savkin 那里了解:vsavkin.com/change-detection-in-angular-2-4f216b855d4c

我们的 Nodes Row 组件是使用 OnPush 的候选者,因为所有内容都是通过输入进入组件的,并且组件的状态始终与传入的值相关联(例如,如果利用率超过 70% 并且需要应用危险类)。打开 src/app/nodes-row/nodes-row.component.ts,我们将对其进行一些小的调整(见以下列表),以启用 OnPush。

[列表 5.1] 使用 OnPush 的 Nodes Row 组件

import { Component, Input, ChangeDetectionStrategy } from '@angular/core';     
 @Component({
  selector: '[app-nodes-row]',
  templateUrl: './nodes-row.component.html',
  styleUrls: ['./nodes-row.component.css'],
 changeDetection: ChangeDetectionStrategy.OnPush
}) 

就这样!导入并声明组件的 changeDetection 属性为 OnPush,现在你的组件只有在输入发生变化时才会进行变更检测。这种策略是组件元数据的一个属性,默认设置为默认模式(信不信由你!)。

现在我们可以将相同的更改应用到我们的 Metric 组件上,因为它在渲染时也只反映提供的输入值。试着亲自对那个文件做同样的更改。

使用 OnChanges 生命周期钩子拦截和检测变更还有另一种方法,因为我们已经在 Metric 组件中通过 getter/setter 方法拦截了输入,所以让我们再次修改它以使用 OnChanges 和 OnPush 模式。

打开 src/app/metric/metric.component.ts 并将其更新为你在列表 5.2 中看到的代码。它用 OnChanges 生命周期钩子替换了 getter 和 setter 方法,并使用了 OnPush 模式。

列表 5.2 使用 OnPush 模式和 OnChanges 生命周期钩子的度量组件

import { Component, Input, ChangeDetectionStrategy, OnChanges } from '@angular/core';     
 @Component({
  selector: 'app-metric',
  templateUrl: './metric.component.html',
  styleUrls: ['./metric.component.css'],
 changeDetection: ChangeDetectionStrategy.OnPush
})
export class MetricComponent implements OnChanges {     
 @Input('used') value: number = 0;
 @Input('available') max: number = 100;

 ngOnChanges(changes) {
 if (changes.value && isNaN(changes.value.currentValue)) this.value = 0;
 if (changes.max && isNaN(changes.max.currentValue)) this.max = 0;
 }

  isDanger() {
    return this.value / this.max > 0.7;
  }
} 

如果你自己实现了 OnPush 模式,它应该从导入策略辅助工具开始,然后添加 changeDetection 属性。在这里,你还需要导入 OnChanges 接口并声明类以实现它。

我们仍然需要声明我们的输入属性,所以我们回到之前的方法,不使用 getter 和 setter 方法来声明它们。ngOnChanges 方法实现了 OnChanges 生命周期钩子,它提供了一个对象作为参数,该对象包含任何已更改的输入,然后可以访问它们的当前和先前值。例如,如果父组件中只更改了 value 输入,那么在生命周期钩子上将只设置 change.value 属性。

OnChange生命周期钩子内部,我们运行相同的检查以确定值是否为数值,如果验证失败,则重置值。需要注意的是,任何对输入的更改都会传播到任何子组件(如果有)。使用这种方法的价值在于,我们不是创建私有属性,同时拦截和验证输入,并且逻辑不会在组件请求属性时运行。它只会在特定组件的输入发生变化时为该组件运行生命周期钩子。

如果你需要在每次在组件上运行变更检测时(无论是否使用 OnPush),都可以使用 DoCheck 生命周期钩子。这允许你运行一些逻辑,可以检查 Angular 无法自动检测的组件中存在的更改。如果你需要触发你自己的类型的变化检测,这个生命周期钩子将帮助你做到这一点。它不太常用,但请务必注意其存在,以便在 OnChanges 无法满足你的需求的情况下使用。

在本节中,我们通过仅在输入发生变化时检查它们来优化了节点行和度量组件的变更检测。度量组件现在还使用 OnChanges 生命周期钩子来验证输入,这可以更高效,并挂钩到变更检测生命周期。

5.2 组件间的通信

通信组件之间有几种方式,我们已经在输入作为从父组件向子组件传递数据的方式进行了深入探讨。但这并不给我们提供向上通信到父组件或树中的其他组件(如果不是直接后代)的方式。

当你考虑构建你的组件时,尤其是高度模块化的组件,它们在发生某些事情时可能会需要发出事件,以便其他组件可以轻松地利用知道其他地方正在发生的事情。例如,你可以创建一个标签组件,并发出一个描述当前可见标签的事件。另一个组件可能对知道标签选择何时发生变化感兴趣,这样你就可以更新该组件的状态,例如一个基于当前标签的上下文帮助信息的附近面板。

你已经看到输入是推动数据沿着组件树向下传递给子组件的方式,而事件则是将数据和通知向上传递给父组件的方式。当以这种方式使用时,Angular 将这些事件视为输出。我们将使用输出通知父组件何时发生变化,这样我们就可以对这些事件做出反应。

此外,我们将在本节中探讨几种使用其他组件的方法:使用视图子组件(它让你访问组件控制器中的子组件控制器)和使用局部变量(它让你在组件的模板中访问子组件控制器)。每种方法都有不同的设计,可能在多个用例中都适用,但我们将通过我们的应用程序来展示每个方法的作用。

让我们再次查看我们的应用程序组件树,但这次我们将注释输入和通信流程(图 5.2)。我们希望能够点击 Navbar 组件中的按钮,使其在仪表板中生成数据(如刷新按钮)。Navbar 组件和 Dashboard 组件是 App 组件的子组件,那么我们如何让它们进行通信呢?使用事件和局部模板变量来实现这一点相当简单。

c05-2.png

图 5.2 组件共享数据、发出事件以及通过局部变量访问其他组件

在图 5.2 中,你可以看到 Dashboard 组件将数据绑定到所有子组件,而 Nodes 组件也将一些数据绑定到其子组件。但所有数据都是从 Dashboard 组件流向子组件的。

然而,使用 Navbar 组件时,我们需要一种与 Dashboard 组件通信的方式,告诉它再次生成数据。我们将使用一个输出事件(onRefresh),当用户点击刷新按钮时,它会通知 App 组件。然后一旦 App 组件检测到按钮被点击,它可以通过告诉 Dashboard 重新生成数据来处理该事件,这是通过在模板中使用局部变量引用 Dashboard 控制器来实现的。

5.2.1 输出事件和模板变量

为了说明这一点是如何工作的,我们需要对我们的 Navbar 和 App 组件做一些修改。让我们首先打开src/app/navbar/navbar.component.ts文件。我们需要声明一个输出事件,就像我们声明输入一样,这样 Angular 就能理解如何注册和处理该事件,如下面的列表所示。

列表 5.3 使用输出的 Navbar 组件

import { Component, Output, EventEmitter } from '@angular/core';     
 @Component({
  selector: 'app-navbar',
  templateUrl: './navbar.component.html',
  styleUrls: ['./navbar.component.css']
})
export class NavbarComponent  {
 @Output() onRefresh: EventEmitter<null> = new EventEmitter<null>();
 refresh() {
 this.onRefresh.emit();
 }
} 

我们首先导入Output装饰器和EventEmitter工厂对象。我们需要这两个来设置输出事件。EventEmitter是一个特殊对象,它帮助我们发出与 Angular 的变更检测一起工作的自定义事件。

接下来,我们声明一个新的属性onRefresh,并给它添加@Output()装饰器。这将通知 Angular 现在有一个基于属性名称的事件输出,这将允许我们然后在模板中使用事件绑定来监听此事件,如(onRefresh)="..expression.."。与@Input()装饰器一样,你可以选择性地传递一个别名来更改用于事件绑定的事件名称。

输出类型为EventEmitter<null>,这意味着这个变量将持有不发出任何数据的EventEmitter。可选地,它可以声明发出数据,例如包含事件触发时刻的日期对象。

到目前为止,我们已经正确地连接了输出,但我们仍然需要想出一个方法来发出刷新事件。我们已经在 Navbar 组件中添加了refresh()方法来调用EventEmitter对象提供的onRefresh.emit()方法。这将触发事件并通知父组件,该组件正在监听。

现在我们需要将点击事件绑定添加到 Navbar 按钮,以便它能够触发自定义输出事件。打开 src/app/navbar/navbar.component.html 文件,并更新按钮行以包含点击处理程序,如下所示。它应该调用refresh()方法:

<button class="btn btn-success" type="button" **(click)="refresh()****"**>Reload</button> 

在 navbar 中点击“重新加载”按钮现在将触发refresh()方法,然后它会发出自定义输出事件onRefresh。您可以在图 5.3 中看到发生了什么。

c05-3.png

图 5.3 组件树概述,用户点击按钮并通过输出事件触发数据刷新

我们的应用组件(Navbar 和 Dashboard 的父组件)现在在用户点击按钮时会收到警报,但我们还没有实现事件绑定来捕获和响应它。为了做到这一点,打开 src/app/app.component.html 文件,并修改它如下:

<app-navbar (onRefresh)="dashboard.generateData()"></app-navbar>
<app-dashboard #dashboard></app-dashboard> 

在第一行,我们添加了事件绑定以响应onRefresh输出事件,但它需要调用来自 Dashboard 组件的方法。因为 Dashboard 组件有生成数据的方法,我们需要一种从 App 组件调用它的方法。第二行在 Dashboard 组件的模板上添加了#dashboard。这表示一个局部模板变量,可以通过变量dashboard访问,它引用 Dashboard 组件控制器,这样我们就可以从 Dashboard 组件调用方法——它允许我们在 App 组件模板的任何地方调用来自 Dashboard 组件的方法,即使我们不在 Dashboard 组件内部。但只有公共方法可用——例如,像randomInteger()这样的私有方法不可用。

如果没有组件监听该事件,那么它将不会在组件树中传播。输出事件只发送到父组件,与像点击或按键这样的常规 DOM 事件(它们会沿着 DOM 树传播)不同。

这个事件发射技巧允许我们在模板的另一个部分使用一个组件,在这种情况下,我们使用它来处理onRefresh事件,通过从 Dashboard 组件调用dashboard.generateData()方法。这对于访问同一模板中存在的组件来说非常方便。主要的缺点是它只允许你从模板访问组件控制器,而不是从控制器访问,这意味着 App 组件控制器不能直接调用 Dashboard 组件的方法。幸运的是,还有另一种方法,即使用 View Child 来引用另一个组件。

5.2.2 使用 View Child 引用组件

为了在父控制器内部访问子组件的控制器,我们可以利用ViewChild将这个控制器注入到我们的组件中。这为我们提供了对子控制器的直接引用,因此我们可以从 App 组件控制器实现调用 Dashboard 组件。

ViewChild是一个控制器属性装饰器,类似于InjectOutput,它告诉 Angular 用对特定子组件控制器引用填充该属性。它仅限于注入子组件,所以如果你尝试注入一个不是直接后代的组件,它将提供一个未定义的值。

让我们通过更新 App 组件来查看这个功能,使其获取 Dashboard 组件的引用并直接处理 Navbar 发出的onRefresh事件。打开 src/app/app.component.ts 文件,将其替换为列表 5.4 中看到的内容。

列表 5.4 App 组件控制器

import { Component, ViewChild } from '@angular/core';     
import { DashboardComponent } from './dashboard/dashboard.component';     
 @Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
 @ViewChild(DashboardComponent) dashboard: DashboardComponent;
 refresh() {
 this.dashboard.generateData();
 }
} 

在我们导入ViewChild装饰器之后,我们还需要导入DashboardComponent本身。我们需要确保它在编译期间可用,并且我们可以直接引用它。

然后,App 组件获得一个单一的属性,dashboard@ViewChild()装饰器位于前面,我们传递对DashboardComponent的引用,这样 Angular 就能确切知道要使用哪个组件。我们还给这个属性指定了DashboardComponent的类型,因为它是一个该控制器的实例。

最后,我们添加了一个refresh()方法,它调用 Dashboard 组件控制器来生成数据。与模板变量方法相比,这为我们提供了在模板中可能难以或不可能完成的工作的机会。

我们需要更新 App 组件模板,以调用新的控制器方法而不是使用模板变量。回到 src/app/app.component.html,你应该将内容更改为以下内容:

<app-navbar (onRefresh)="refresh()"></app-navbar>
<app-dashboard></app-dashboard> 

现在模板不再引用 Dashboard 组件,而是当输出事件被触发时调用 App 组件的refresh方法。结果是相同的——Dashboard 将生成一组新的数据——但方法不同。

你可能想知道应该使用哪种方法,这主要取决于你希望将逻辑存储在哪里。如果你需要做的不仅仅是访问子控制器的属性或方法,那么你可能需要使用ViewChild方法。这确实会在两个组件之间造成耦合,在可能的情况下应该避免。但如果可以直接在模板中引用子控制器并调用方法,这样可以节省代码并减少耦合。

5.3 组件样式化和封装模式

Angular 可以使用不同的渲染方式,这会改变组件的样式化方式。组件通常被设计来管理自己的状态,这包括组件显示所需的视觉样式。几乎总是有一些全局 CSS 样式,你将应用它们来为你的应用程序提供默认样式的功能基础,但组件可以保留自己的样式,这些样式将在与应用程序其他部分隔离的情况下渲染。

如果你为组件添加 CSS 样式,这些样式不会全局暴露,你将避免处理来自一个组件的 CSS 规则覆盖另一个组件的情况。有一种方法可以将组件样式渲染到全局样式,但这只在少数情况下推荐使用。

向组件添加样式有多种方式。最好以相同的方式在你的所有组件中添加样式,因为混合和匹配可能会产生一些有趣(有时是意外的)副作用。如果你使用了一个执行不同操作的第三方库,这可能会带来潜在的挑战,所以请关注你的依赖项如何工作,以确保它们不会发生冲突。

5.3.1 向组件添加样式

样式可以通过多种方式添加。你始终可以使用全局方法添加 CSS,即在应用程序的 index.html 文件中包含链接的 CSS 文件,或在.angular-cli.json文件的styles属性中引用该文件(我们已经为 Ng-bootstrap 做了这件事)。这些样式是通用的,并将应用于页面中任何匹配的元素。这对于需要到处使用的常见和共享样式来说很好,但当你想要有独立定义自己样式的组件时,这就不太好了。

为了更好地隔离我们的样式,我们可以使用以下方法之一——这些是添加特定于单个组件的样式的途径:

  • 内联 CSS — 组件模板可以包含内联 CSS 或 style 属性来设置元素的样式。这些是在使用 Angular 或其他技术时添加样式规则到 HTML 元素的默认方式。

  • 组件链接 CSS — 使用组件的styleUrls属性和指向外部 CSS 文件的链接。Angular 将加载 CSS 文件并将规则注入到你的应用的style元素中。

  • 组件内联 CSS — 使用组件的styles属性和 CSS 规则数组,Angular 会将规则注入到你的应用的style元素中。

让我们看看这些不同的方法如何在示例中使用。这里有一个简单的组件,它从五个不同的方法中应用了样式:

  • 全局 CSS 规则

  • 内联 CSS style 元素

  • 内联 style 声明

  • 组件 styles 属性

  • 组件 styleUrls 属性链接到 CSS 文件

我们将修改导航栏中的 Reload 按钮以具有不同的背景颜色,这样我们就可以看到这些不同的方法是如何应用的。我们首先向 CLI 为每个组件生成的 CSS 文件中添加一些 CSS,该文件通过 styleUrls 属性链接。打开 src/app/navbar/navbar.component.css 文件,并添加以下 CSS 规则:

.btn { background-color: #e32738; } 

这覆盖了由 bootstrap CSS 库设置的全球颜色,并给我们的按钮一个红色(而不是默认的绿色)。注意,页面上的其他按钮(在 Nodes Row 组件中)没有改变,即使它们也应用了 .btn 类。我们将在稍后解释这是如何发生的,但这是 Angular 封装功能的视觉结果。

接下来,我们将通过更新 src/app/navbar/navbar.component.ts 文件中的组件元数据来添加一些样式,使用以下 styles 属性:

@Component({
  selector: 'app-navbar',
  templateUrl: './navbar.component.html',
 styleUrls: ['./navbar.component.css'],
  styles: [`.btn { background-color: #999999; }`]
}) 

styles 属性让我们提供一组字符串(这里我们使用反引号字符来创建一个字符串字面量),这些字符串应包含有效的 CSS 规则。现在当你查看应用程序时,它应该看起来像是一个灰色按钮。这意味着 styles 属性内的样式将覆盖通过 styleUrls 属性加载的 CSS 文件中的任何样式。这里的技巧是,最后声明的将获胜,所以当你既有 stylesstyleUrls 时,最后声明的将覆盖第一个,无论使用哪一个,这取决于编译的方式。(在撰写本文时,CLI 如果你在 CLI 中声明了 stylesstyleUrls,会发出警告,但它似乎仍然可以工作。)

现在打开 src/app/navbar/navbar.component.html 中的导航栏模板,并将以下样式元素添加到模板中:

<style>.btn { background-color: #3274b8; }</style> 

保存后,应用程序将重新加载,突然你的按钮现在变成了蓝色,这意味着内联样式声明将覆盖通过 stylesstyleUrls 属性提供的任何值。

添加样式的最后一种方式是直接在元素本身上使用 style 属性。仍然在组件模板中,修改按钮以包含此内联 CSS 声明,使按钮变为紫色:

<button class="btn btn-success" type="button" **style="background-color: #8616f6"** (click)="refresh()">Reload</button> 

这个练习向我们展示了,如果 CSS 规则在所有这些地方都设置了不同的值,你可能会对哪个规则被应用感到惊讶。需要注意的是,使用 !important 值将使该特定规则高于其他任何规则,如果你敢使用它的话。如果同一规则在多个地方声明,以下是根据规则应用的优先级顺序,每个项目都会覆盖其下方的规则:

  1. 1 内联样式属性规则

  2. 2 模板中的内联样式块规则

  3. 3 组件styles规则或styleUrls规则(如果两者都有,则最后声明的具有优先级)

  4. 4 全局 CSS 规则

使用内联样式声明的规则始终是最高优先级的规则。这可能是预期的,因为除了声明!important值之外,内联样式通常由浏览器赋予最高优先级。模板中的样式块是下一个最高优先级。然后是组件styles数组或styleUrls外部文件中声明的规则,最后是全球样式。

所有 CSS 规则都添加到文档头部的新style元素中。当应用渲染时,组件将被加载,这些样式将被添加到文档头部。但根据封装模式的不同,这些样式的渲染方式可能会改变样式的处理方式。

重要提示:所有这些指南都是基于使用 CLI 进行构建的。有可能改变构建过程并获得不同的优先级或结果,只是让你知道。现在让我们看看不同的封装模式是如何工作的,以及为什么我们可能想要更改默认设置。

5.3.2 封装模式

Angular 希望确保你可以构建模块化的组件,这些组件可以轻松共享。一个关键能力是确保组件的 CSS 样式不会溢出到应用的其余部分,这被称为样式封装。你有没有使用过包含一些 CSS 的第三方库,这些 CSS 与你的应用程序中的其他内容冲突,导致某些内容显示不正确?很可能是你遇到了这种情况,Angular 提供了一些选项来避免这种情况。

直到最近,还没有确定的方法来封装特定 DOM 元素的 CSS 样式或 HTML 元素。最常见的方法是使用特定的类命名约定,正如在大多数流行的 CSS 库中找到的那样。这些约定为 CSS 类提供了一种特定的命名法,限制了相同类在其他地方被使用的可能性。尽管这些方法通常有效,但无法保证类名或样式不会冲突,并且它不会为内部 HTML 元素提供封装。

进入 Shadow DOM,这是官方的浏览器原生封装样式标准。Shadow DOM 为我们提供了一套良好的功能,以确保我们的样式不会冲突并渗入或渗出组件,尽管它可能不支持旧版浏览器。如果你需要复习 Shadow DOM,请参考第一章。

Angular 为视图提供了三种封装模式。在早期的样式示例中,你使用的是默认的模拟模式。

  • None — 在视图渲染过程中没有使用封装,组件的 DOM 受 CSS 的正常规则约束。当模板注入到应用中时,不会修改模板,除了从模板中移除任何 CSS 样式元素到文档头部。

  • 模拟 — 模拟封装通过在运行时向 CSS 规则添加唯一 CSS 选择器来模拟样式封装。CSS 可以轻松地从全局 CSS 规则中级联到组件中。

  • 原生 — 使用原生 Shadow DOM 进行样式和标记封装,并提供最佳的封装。所有样式都注入到 shadow root 中,因此本地化到组件。组件声明的任何模板或样式都不会在组件外部可见。

每种封装模式以不同的方式应用样式,因此了解封装模式和样式渲染视图的方式很重要。无论使用哪种封装模式,应用样式的顺序都是一致的,但底层样式注入和修改应用的方式确实会改变。

现在,让我们更详细地看看不同的模式,它们的行为方式,以及为什么你可能决定选择不同于默认模式的模式。当我们查看每种模式时,尝试在 Metric 组件中设置模式,并检查它在页面中渲染时的输出,以了解它是如何将样式添加到文档头部的。

无封装模式

如果你在组件中未设置封装,你将绕过任何原生或模拟的 Shadow DOM 功能。它将组件渲染到 HTML 文档中,就像你直接将 HTML 标记写入 body 一样。这是没有样式的组件的默认模式。

要设置此模式,你需要使用组件元数据的 encapsulation 属性来设置,当然,你需要从 @angular/core 中导入 ViewEncapsulation 枚举:

encapsulation: ViewEncapsulation.None 

一旦设置了模式,为组件声明的任何样式都将从组件模板提升到文档头部,这是对标记的唯一实际修改。style 元素以原样移动到文档头部,这是一种注入全局 CSS 规则的方式。另一个稍后渲染的组件可能会注入一个竞争 CSS 规则,因此渲染顺序也很重要。

这里总结了为什么你可能使用或避免使用无封装模式的组件的原因:

  • 样式泄漏 — 有时应用程序设计时使用了 CSS 库,其中封装每个组件的内部样式是不必要或不受欢迎的。如果你没有将样式放入你的组件中,那么封装可能不是必要的。

  • 全局样式泄漏 — 由于缺乏封装,标题元素的全球背景样式被应用,这可能或可能不是期望的行为。

  • 模板未修改 — 因为这种模式以原样注入模板(在重新定位样式之后),你的 DOM 元素将不会应用任何特殊转换。

当使用无封装模式时,你不能使用任何特殊的 Shadow DOM 选择器,如 :host::shadow。因为这些选择器没有上下文,因为没有启用任何 Shadow DOM(原生或模拟)功能。

现在让我们看看模拟模式,并比较其行为。

模拟 Shadow DOM 封装模式

模拟模式通过向 HTML 标记和 CSS 规则添加独特属性来对任何样式应用一些转换,通过使它们独特来增加 CSS 规则的特定性。因为这不是真正的封装,所以被称为 模拟,但它具有许多相同的优点。这是 Angular 组件的默认模式,无论它们如何在组件中声明样式(无论它们如何在组件中声明)。

模拟模式主要关于防止组件中的样式泄漏到全局规则(当不使用封装时会发生这种情况)。为了实现这一点,视图将通过使用独特的属性来渲染模板和样式,以增加组件内部 CSS 规则的特定性。模拟模式是默认模式,但如果你想显式声明它,你会在组件元数据上设置该属性:

encapsulation: ViewEncapsulation.Emulated

在渲染过程中,样式首先从组件模板或组件属性中提取出来。然后生成一个独特的属性,类似于 _ngcontent-ofq-3。它使用 _ngcontent 前缀和一个独特的后缀,以便每个组件都可以有一个独特的 CSS 选择器。相同组件的多个实例具有相同的独特属性。最后,视图通过向组件 DOM 节点添加独特属性并将其添加到 CSS 选择器规则中来渲染。

这里简要概述一下模拟封装模式的行为以及你为什么想要(或不想)使用它:

  • 样式隔离 — 样式和标记的渲染会添加独特的属性,以确保 CSS 规则不与全局样式冲突。

  • 样式泄漏 — 全局样式仍然可以泄漏到组件中,这可以用来允许共享常见的样式。如果全局规则中添加了规则,而你不想让它们泄漏到组件中,这可能会与组件冲突。

  • 独特选择器 — 渲染的 DOM 获得一个独特的属性,这意味着如果你想在组件中应用全局样式,你需要相应地编写 CSS 选择器。

现在让我们通过查看原生模式及其如何使用 Shadow DOM 来完成封装模式的讨论。

原生 Shadow DOM 封装模式

Shadow DOM 是一种强大的工具,用于封装从应用其余部分中分离的标记和样式。Angular 可以使用 Shadow DOM 将所有内容注入,而不是将其放入主文档中。这意味着模板和样式确实与应用的其余部分隔离。

原生浏览器对 Shadow DOM 的支持有限,可以在 caniuse.com/#feat=shadowdom 检查。Shadow DOM 的好处可能不会扩展到你需要支持的 所有浏览器,但有一个很好的 polyfill 可以在 webcomponents.org/polyfills/shadow-dom/ 中提供支持。即使有 polyfill,较旧的浏览器可能仍然不受支持,因此你应该考虑所有需求。

当组件渲染时,它会在组件中创建一个 shadow root。模板被注入到 shadow root 中,以及来自兄弟和任何父组件的样式。对于文档的其余部分,这个 shadow root 保护了内容不被组件外部可见。

Angular 希望嵌套组件能够共享样式,但通过 shadow root,Angular 通过将它们注入到 shadow root 中使这些样式对组件可用。

这里是对原生模式工作方式及其为什么可能或可能不想使用它的总结:

  • 使用 Shadow DOM — 对于真正的封装,原生选项是最好的选择。它将保护你的组件免受文档样式的干扰,并将标记封装起来。

  • 父级和兄弟级样式渗透 — 由于 Angular 渲染组件的方式,它还会注入父级和兄弟级组件的样式,这可能导致样式渗透(你可能不希望发生)的问题。

  • 有限支持 — 浏览器对 Shadow DOM 的支持有限,可能需要使用 polyfill 来允许其在应用程序中使用。

到目前为止,我们已经探讨了如何将数据绑定到组件的视图中。这对于动态注入数据和修改视图属性非常重要。我们还探讨了如何使用事件绑定从视图回调到组件,这可以用来更新值或调用方法。我们还探讨了样式化组件和封装样式的各种方法。

我们已经覆盖了很多内容,但现在我们将探讨 Angular 指令和管道。它们提供了修改我们视图中数据或元素显示的额外选项,并为模板添加了额外的逻辑。

5.4 动态渲染组件

应用程序有时需要根据当前应用程序状态动态渲染组件。你可能不知道屏幕上需要哪个组件,或者可能是用户交互需要在新页面上显示新的组件。

你已经看到了一些相当常见的情况,在这些情况下,动态组件通常是一个很好的解决方案。例如

  • 显示动态内容的页面模态

  • 条件显示的警报

  • 可能会动态扩展内容量的轮播或选项卡

  • 需要之后删除的折叠内容

这些情况都有一个共同点:它们不一定需要在屏幕上,或者依赖于它们自身能力之外的条件。Angular 给我们使用较低级别的 API 的能力,这些 API 允许我们按需渲染一个模板中尚未存在的组件。

我将展示两个使用 ng-bootstrap 生成模态框的示例,然后讨论如何使用 Angular 的 API 创建一个警报组件来自行完成所有操作。使用 ng-bootstrap,大部分魔法都隐藏在一个有用的服务背后,但它将使我们能够在手动构建之前快速获得所需的功能。

5.4.1 使用 Ng-bootstrap 模态框动态组件

让我们从生成一个新的组件开始。这个组件将在你点击节点行组件中的查看按钮时显示节点的详细信息。我们将称这个组件为节点详情组件:

ng generate component nodes-detail 

现在用代码替换组件的控制器,该控制器位于 src/app/nodes-detail/nodes-detail.component.ts,替换为列表 5.5 中的代码。这个控制器有类似的逻辑来判断节点是否超出了其利用率。这是将在模态框内部打开的组件,并且只有在被调用时才会加载。

列表 5.5 节点详情组件控制器

import {Component, Input} from '@angular/core';
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';     
 @Component({
  selector: 'app-nodes-detail',
  templateUrl: './nodes-detail.component.html',
  styleUrls: ['./nodes-detail.component.css']
})
export class NodesDetailComponent {
 @Input() node;
 constructor(public activeModal: NgbActiveModal) {}
 isDanger(prop) {
    return this.node[prop].used / this.node[prop].available > 0.7;
  }

  getType(prop) {
    return (this.isDanger(prop)) ? 'danger' : 'success';
  }
} 

Ng-bootstrap 库提供了一个有用的服务,名为 NgbActiveModal,它是模态控制器的实例,用于加载节点详情组件。它将允许我们在需要时关闭模态框,无论是按需还是基于用户操作。当我们添加这个组件的模板时,这一点将更加明显。

与迄今为止的其他组件不同,这个组件将不会从父模板中调用。但我们需要传递一个数据输入给它,所以我们仍然需要声明输入属性。

现在我们需要模板来使组件功能化。打开 src/app/nodes-detail/nodes-detail.component.html,并用以下列表替换现有的代码。

列表 5.6 节点详情组件模板

<div class="modal-header">
 <button type="button" class="close" aria-label="Close" (click)="activeModal.dismiss()">
 <span aria-hidden="true">&times;</span>
  </button>
  <h4 class="modal-title">{{node.name}}</h4>
</div>
<div class="modal-body container">
  <div class="col-xs-6">
 [<app-metric [used]="node.cpu.used" [available]="node.cpu.available">](#c05-codeannotation-0019)
 <metric-title>CPU</metric-title>
 </app-metric>
  </div>
  <div class="col-xs-6">
 [<app-metric [used]="node.mem.used" [available]="node.mem.available">](#c05-codeannotation-0020)
 <metric-title>Memory</metric-title>
 </app-metric>
  </div>
</div> 

在这里有很多标记需要用于显示带有 Bootstrap CSS 样式的模态框,但它有一个包含标题和关闭按钮的模态框头部,以及包含两个度量组件的主体。请注意,我们可以在动态组件内部使用任何已注册到我们的应用程序模块中的组件,这很方便。

记得在我们的控制器中有一个名为 activeModal 的属性,它是一个 NgbActiveModal 服务的实例。我们在模板中使用它来调用 dismiss 方法,这将关闭模态框本身。这就是为什么我们在控制器中包含了这个属性。您还可以看到 node 属性,这是我们的唯一输入绑定,它被用来显示数据或将数据传递到其他组件。

此组件的内容与应用程序的其他部分相当相似,所以我们不需要在上面花费时间。我们现在感兴趣的是如何从 Nodes Row 组件触发模态的打开。打开 src/app/nodes-row/nodes-row.component.html 文件,并在按钮中添加以下事件绑定:

<td><button class="btn btn-secondary" (click)="open(node)">View</button></td> 

现在我们需要打开 src/app/nodes-row/nodes-row.component.ts 并实现这个新方法。以下列表中的代码包含了更新的控制器,并且变更已经为您标注。

列表 5.7 Nodes Row 组件模板中添加的模态

import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';     
import { NodesDetailComponent } from '../nodes-detail/nodes-detail.component';     
 @Component({
  selector: '[app-nodes-row]',
  templateUrl: './nodes-row.component.html',
  styleUrls: ['./nodes-row.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class NodesRowComponent {
  @Input() node: any;

 constructor(private modalService: NgbModal) {}
 isDanger(prop) {
    return this.node[prop].used / this.node[prop].available > 0.7;
  }

 open(node) {
 const modal = this.modalService.open(NodesDetailComponent);
 modal.componentInstance.node = node;
 }
} 

在这里,我们导入 NgbModal 服务,这将允许我们创建一个新的模态。我们在 Nodes Detail 组件中看到了 NgbActiveModal 服务,一旦模态及其组件被创建,它将允许 Nodes Detail 组件引用活动的模态实例。我们还需要导入 Nodes Detail 组件。构造函数还将 modalService 属性设置为 NgbModal 服务的实例。

open() 方法中,我们传递一个节点数据的引用以供使用。然后我们使用 modalService 创建一个新的模态实例,该实例将渲染的组件作为参数。它将新创建的组件的引用存储在 componentInstance 属性中,这使得我们可以设置在点击时传入的 node 输入绑定。

这就连接了我们触发模态所需的所有内容。但是,如果您尝试它,模态可能还不会正常工作,因为还有一些小的细节需要实现,这将允许我们打开这个模态。

首先,打开 src/app/dashboard/dashboard.component.html 文件,并在模板底部添加以下代码行——我们需要为 Modal 服务提供一个渲染组件的位置:

<template ngbModalContainer></template> 

这是一个占位符模板元素,它上面有 NgbModalContainer 指令,这告诉 Ng-bootstrap 在模板中的哪个位置渲染此组件。组件必须在模板的某个位置进行渲染,这是 Ng-bootstrap 定义渲染位置的方式。

其次,我们需要向我们的 App 模块添加一个新的条目。当 CLI 处理 Angular 时,它需要知道可能被动态渲染的组件有哪些,因为它将不同地处理它们。打开 src/app/app.module.ts 文件,并在 NgModule 装饰器中添加一行新内容:

entryComponents: [NodesDetailComponent], 

条目组件是指需要在浏览器中动态渲染的任何组件,这还包括与路由链接的组件(关于这一点将在第七章中详细介绍)。CLI 默认会尝试优化组件,并且不包含组件工厂类。但是,为了动态渲染,需要组件工厂类来渲染,因此这告诉 CLI 编译器如何在构建时正确处理它。

这个例子对于 Ng-bootstrap 的模态实现来说有点具体,所以它只能让我们对如何构建自己的动态组件有有限的了解。

5.4.2 动态创建组件并渲染

Ng-bootstrap 模态示例是一个创建模态的好方法,但它抽象了一些能力。我们想直接看到它是如何工作的,并将基于我们到目前为止对组件的了解来创建我们自己的动态渲染组件。

当我们动态渲染一个组件时,Angular 需要一些东西。它需要知道要渲染哪个组件,在哪里渲染它,以及它可以从哪里获取它的副本。所有这些都在任何模板的编译过程中发生,但在这个情况下我们没有模板,必须自己调用 API。我们将使用以下 Angular 功能来处理此过程:

  • ViewContainerRef——这是对 Angular 理解的应用程序中元素的引用,它为我们提供了一个可以渲染组件的参考点。

  • ViewChild——这将使我们能够将控制器中的元素引用为ViewContainerRef类型,从而获得渲染组件所需的 API 访问权限。

  • ComponentFactoryResolver——这是一个 Angular 服务,它为我们提供任何已添加到入口组件列表中的组件的组件工厂(这是渲染所必需的)。

在构建我们的示例时,您将看到这三个功能协同工作。我们将构建一个当数据刷新时出现的警告框,并在一段时间后自行消失。这将让我们了解如何动态渲染组件并将其从页面上删除,这与你从 Ng-bootstrap 模态服务中获得的效果非常相似。

首先,生成一个新的组件。从命令行运行以下命令来设置这个新组件:

ng generate component alert 

这个组件的模板将会很简单;它包含一些带有 bootstrap 风格的标记,用于创建一个警告框并绑定最后刷新的日期。打开 src/app/alert/alert.component.html 文件,并用以下内容替换其内容:

<div class="container mt-2">
  <div class="alert alert-warning" role="alert">
    The data was refreshed at {{date | date:'medium'}}
  </div>
</div> 

同样,组件控制器将除了单个输入属性外为空。打开 src/app/alert/alert.component.ts 文件,并用以下列表中的代码替换其内容。

列表 5.8 警告组件控制器

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

@Component({
  selector: 'app-alert',
  templateUrl: './alert.component.html',
  styleUrls: ['./alert.component.css']
})
export class AlertComponent {
  @Input() date: Date;
} 

到目前为止,一切顺利。这个组件没有我们在这个章节中没有看到过的特殊之处,所以我将进入下一步。因为这个组件将会被动态渲染,我们需要将其添加到entryComponents列表中,所以再次打开 src/app/app.modules.ts 文件,并将其添加到列表中。到这一点,我们的组件本身已经准备好被动态渲染了:

entryComponents: [
  NodesDetailComponent,
  AlertComponent
], 

现在我们可以开始工作,触发组件在屏幕上渲染的机制。与模态示例类似,我们需要在我们的应用程序中创建一个模板元素,以便用于渲染,因此打开 src/app/dashboard/dashboard.component.html 文件,并更新它以包含以下模板:

<app-navbar (onRefresh)="refresh()"></app-navbar>
<ng-template #alertBox></ng-template>
<app-dashboard></app-dashboard> 

#alertBox 属性是另一个模板局部变量,我们可以用它来稍后识别这个元素。这将是我们渲染组件旁边的元素。打开 src/app/app.component.ts 文件,并用以下列表中的代码替换它。

列表 5.9  应用程序组件控制器

import { Component, ViewChild, ComponentFactoryResolver, ComponentRef, ViewContainerRef } from '@angular/core';     
import { DashboardComponent } from './dashboard/dashboard.component';
import { AlertComponent } from './alert/alert.component';     
 @Component({
  selector: 'app-root',
 templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
 alertRef: ComponentRef<AlertComponent>;
 @ViewChild(DashboardComponent) dashboard: DashboardComponent;
 @ViewChild('alertBox', {read: ViewContainerRef}) alertBox: ViewContainerRef;

 constructor(private ComponentFactoryResolver: ComponentFactoryResolver) {}
 alert(date) {
 if (!this.alertRef) {
 const alertComponent = this.ComponentFactoryResolver.resolveComponentFactory(AlertComponent);
 this.alertRef = this.alertBox.createComponent(alertComponent);
 }

 this.alertRef.instance.date = date;
 this.alertRef.changeDetectorRef.detectChanges();

 setTimeout(() => this.destroyAlert(), 5000);
 }

 destroyAlert() {
 if (this.alertRef) {
 this.alertRef.destroy();
 delete this.alertRef;
 }
 }

  refresh() {
    this.dashboard.generateData();
  }
} 

这可能一开始看起来很复杂,所以让我们分解一下。它的工作方式和构造方式相当简单。

首先,我们必须导入一些额外的对象,我们将在进行过程中查看它们的角色。我们还导入了 Alert 组件本身,这样我们就可以在渲染期间正确地引用它。

然后我们添加两个属性,alertRef 是指向 Alert 组件的组件引用(这是声明的类型)。我们希望有这个引用,这样我们就可以跟踪警报,并在需要时将其删除。第二个属性是另一个视图子组件,称为 alertBoxViewChild 语法不同,因为它允许我们传入一个字符串来通过该名称引用一个本地模板变量,然后“读取”它作为特定类型的实体——在这种情况下,是一个 ViewContainerRef。它将根据模板变量获取元素,并将其解析为 ViewContainerRef 类型。这将使我们能够访问一个关键的 API。这些只是属性,到目前为止,还没有实例化。

构造函数将 ComponentFactoryResolver 属性设置为工厂解析器服务,这是我们渲染组件之前查找组件工厂类副本所需的。

这里的主要魔法发生在 alert() 方法内部。我们将逐行分析。首先,它检查是否已经在 alertRef 上存储了某些内容(这意味着 Alert 组件已经被创建),如果是这样,它就会跳过创建另一个警报,并继续更新绑定。但是,如果还没有 Alert 组件,它就使用 ComponentFactoryResolver.resolveComponentFactory() 方法来获取组件工厂的实例(这似乎有点多余,但这是 API 名称)。此时,组件将以原始形式(尚未渲染)可用。

下一行使用 alertBox 从我们之前收到的工厂实例创建组件。记住,alertBox 是我们将注入组件的元素的实例,它被 ViewContainerRef 实例包装。此时,组件将被渲染到声明模板元素的模板中。

接下来的两行(在条件之外)设置了组件的绑定数据,然后触发变更检测以运行。因为我们手动更改了绑定数据,所以我们需要通知 Angular 检查某些内容(这不同于 Angular 的典型渲染过程!)。

最后,设置一个超时,在五秒后调用deleteAlert方法,这样警报就不会永远留在屏幕上。如果我们更仔细地查看这个方法,你可以看到它将检查是否存在警报组件的现有实例。如果是的话,它将使用所有组件都有的destroy()方法将其从页面上移除。

如果你尝试运行这个示例,你会发现它还没有工作。我们遗漏了一个重要的步骤!我们没有任何地方调用应用程序组件的alert()方法,所以它不会出现。为了做到这一点,我们将从仪表板组件中发出一个事件,当数据生成时触发,同时附带更新时的戳记。

打开src/app/dashboard/dashboard.component.ts。我们将添加一个新的输出事件。确保在文件顶部导入OutputEventEmitter对象。然后添加一个新属性,就像你在这里看到的那样:

@Output() onRefresh: EventEmitter<Date> = new EventEmitter<Date>(); 

现在在generateData()方法中,将此行添加到方法末尾:

this.onRefresh.emit(new Date()); 

这在仪表板组件中设置了一个新的输出事件,并且这次我们在发射阶段传递了一些数据。我们可以在应用程序组件中捕获这些信息,并将其传递给我们的警报组件。现在这很简单——我们只需要再次更新src/app/app.component.html文件,通过添加一个事件绑定到仪表板组件:

<app-dashboard (onRefresh)="alert($event)"></app-dashboard> 

哇!我们的警报组件现在应该在每次数据生成事件后出现,无论你是否点击右上角的按钮,还是等待 15 秒自动重新生成。它还会在五秒后自动关闭警报。

这是对动态组件的一次快速浏览。除了这种方法之外,还有几种不同的方法可以生成组件,但这是一个你可以用在你的应用程序中的可靠方法。

摘要

我们在本章中非常忙碌。我们涵盖了关于组件的大量内容,包括它们的工作原理、各种功能以及更多。以下是本章中我们讨论的内容:

  • 我们更详细地研究了变更检测,以及如何利用OnPush模式更好地优化组件渲染时的性能。

  • 我们研究了几个生命周期事件处理器。还有许多其他的事件处理器,它们也有自己的用例:

  • OnInit生命周期钩子在构造函数之后以及输入属性可用时只触发一次。

  • OnChanges生命周期钩子在输入属性更改时触发。

  • OnDestroy生命周期钩子在组件即将从页面上移除时触发。

  • 我们讨论了如何使用输出属性和内置的EventEmitter在组件之间进行通信,以及如何将子组件作为视图子组件引用。

  • 我们讨论了使用 CSS 对组件进行样式设计,以及不同的封装模式如何影响页面上的内容渲染方式。

  • 我们通过两个示例来结束本次讨论,展示了如何动态渲染组件。第一个示例是一个预构建的服务,使用 Ng-bootstrap 模态服务按需渲染我们的组件,而第二个示例则完全由我们自行管理。

6

服务

本章涵盖

  • 服务及其角色

  • 创建许多不同类型的服务

  • 使用服务来帮助检索和管理数据

  • 用服务替换控制器中的逻辑

  • 理解依赖注入如何与服务一起工作

在第 4-5 章中,我们有了仪表板组件,它为应用程序的其余部分生成了一些数据。但这仅仅是因为我们不想在示例中引入更多的复杂性。在大多数情况下,这并不理想,因为这种逻辑难以重用,并使组件变得不必要地复杂。

你的应用程序将需要管理许多任务,其中许多任务将超出组件的责任范围,例如管理数据访问、管理应用程序范围内的配置以及实用函数。Angular 的 HttpService 是一个很好的例子,它使得在不重复实现的情况下重用制作 HTTP 请求的逻辑变得容易。尽管 Angular 和许多库为你提供了可以消费的服务,但你也可以也应该创建自己的服务。

服务 基本上是 JavaScript 对象,它们以其他应用程序部分可以轻松消费的方式提供常用逻辑。例如,需要用户登录的应用程序将需要一个服务来帮助管理用户状态。或者你可能有一个服务,它帮助管理如何向你的 API 发送请求,并将必要的逻辑封装在使用的组件之外。

在你的应用程序中共享的代码几乎总是最好放在服务中。在 Angular 中,大多数情况下,服务也是你可以通过依赖注入将其注入到控制器中的东西,尽管没有确切的定义来界定什么使一个对象成为服务。

为了帮助你,我想出了几种松散的分类,这些分类在提供关于服务可以创建的各种方式的见解方面非常有用。一个服务可能适合这些类别中的多个,但我通常尽量使我的服务专注于这些角色之一:

  • 可注入的服务是典型的 Angular 服务,为应用程序提供功能并与 Angular 的 DI 系统一起工作以注入到组件中。一个例子是处理如何从 API 加载数据的服务。

  • 不可注入的服务是未与 Angular 的 DI 系统绑定且仅导入到文件中的 JavaScript 对象。这可以用来在 Angular 的 DI 之外使服务可用,例如在应用程序的主文件中。

  • 辅助服务是使使用组件或功能更容易的服务。一个例子是帮助管理当前页面上活动的警报的服务。

  • 数据服务用于在应用程序中共享数据。一个例子是包含登录用户数据的对象。

在第四章中,将数据逻辑提取到服务中,以将其与组件分离会更合适。在第五章中,我们讨论了设计组件以专注于特定角色,这应该包括保持控制器专注于通过将责任委托给服务来管理数据的最低限度的任务。组件用于显示 UI,而服务旨在帮助管理数据或其他可重用的逻辑片段。

服务的目标是负责一组特定的任务,这反过来又帮助保持应用程序其他方面的专注。服务的大小并不像保持其专注于任务那样重要。

我们将探讨使用服务的方法,我还命名了一些我认为最常见的服务模式。

6.1 设置章节示例

我们将构建一个使用生成数据的应用程序,这些数据每分钟变化几次,以模拟真实市场的变化(见图 6.1)。你将能够买卖股票,但与真实股票市场不同,如果你输光了所有资金,你总是可以重置你的账户。

c06-1.png

图 6.1 由服务驱动的幻想投资组合股票交易应用程序

股票价格随着时间的推移稳步变化(好或坏),就像真实的股票市场一样。股票 API 自动处理这些变化,所以每次请求数据时,它都会用新的值刷新。如果你持有股票几分钟,你很容易就能赚钱或亏钱。如果你想看看你的投资组合表现如何,第二天检查一下,看看你是否做出了好的或坏的投资,因为价值可能已经上升或下降了 25%。你的投资组合也会被保存,所以如果你稍后回来,它会记住你的状态,但你也可以在亏得太多时从头开始。

我们将使用几个服务来帮助我们管理这个应用程序。首先,我们将创建一个服务来帮助管理用户的账户数据,例如他们购买了哪些股票以及他们在账户中剩余多少钱。另一个服务将帮助我们向应用程序提供配置数据,告诉我们 API 服务位于何处。我们将在服务中使用 Angular 的 Http 库来帮助我们从 API 加载数据。最后,我们将创建一个服务来帮助我们管理在本地存储中的数据存储,以便在重新加载之间保持体验。每个服务都符合章节引言中讨论的某个类别。

与之前的章节不同,这次我们将从一个已经设置了一些组件的应用程序开始,这样我们可以专注于构建服务,而不必担心实现之前章节中已经涵盖的其他方面。一些代码片段被注释掉了,因为如果启用它们,将会抛出错误,我们将在构建支持这些服务的服务时取消注释它们。

在本章中,我们将使用来自 VMware 团队中的 Clarity UI 库。它建立在 Bootstrap 4 的 CSS 网格和布局之上,但提供了一个符合他们公司指南的设计规范。我们将使用一些组件,你可以快速了解这个特定的 UI 库与其他章节中使用的库相比是如何表现的。

6.1.1 获取章节文件

设置这个章节项目你有两种选择。你可以从 GitHub 下载项目作为 zip 文件,或者使用 Git 克隆仓库。如果你使用 Git,请运行以下命令:

git clone -b start https://github.com/angular-in-action/portfolio.git 

或者,你也可以从github.com/angular-in-action/portfolio/archive/start.zip下载并解压项目文件。无论哪种方式,你现在都应该在电脑上有一个代码副本,你还需要安装项目的 node 模块。导航到目录并运行以下命令来安装模块,然后预览项目:

npm install
ng serve 

初始时,应用程序看起来有点空,因为它不能正确渲染,直到我们设置服务来帮助管理数据。正如你在图 6.2 中看到的那样,应用程序有一个基本的布局,包括一个页眉和两个卡片。

c06-2.png

图 6.2 本章示例的起点;使用页眉和卡片作为占位符

目前大部分显示内容都被注释掉了(因为在这个阶段会抛出很多错误),所以我们只能看到框架。随着我们生成服务,数据将开始出现,就像你在图 6.1 中看到的那样。然而,布局和结构相当直接,如果你对使用 Clarity UI 库感兴趣,可以查看它。

6.1.2 示例数据

这个应用程序需要股票数据,我会快速介绍它是如何生成并提供给应用程序的。应用程序加载的股票列表是纽约证券交易所技术股票的一个子集,大约有 160 只股票。你可能认识其中的一些股票,但不是全部;它们是真实的公司和符号。但是,与每个相关的价格都是随机分配的。有一个后端进程每 5 秒钟更改价格值。计算是加权的,所以大多数变化是 1-2 美分的变动,但股票也可以在 5 秒内迅速上涨到 40 美分,尽管这种情况很少发生。

我们将创建一个服务,帮助我们从远程服务加载数据到我们的应用程序中。使用它,我们将定期刷新应用程序中的数据,以向用户展示最新的股票数据。

你还可以通过查看 API 项目 github.com/angular-in-action/api 来查看驱动 API 变化的代码。你也可以自己本地运行服务,但这在这里没有涵盖。如果你需要帮助,最好在 API 存储库上发布一个问题。你不太可能需要自己运行它,但如果你想要调整其行为或服务不可用,你可能需要这样做。

好的,让我们开始构建一些服务。我们将从一个帮助我们加载股票价格的服务开始,这样我们就可以在我们的应用程序中开始看到数据了。

6.2 创建 Angular 服务

我们将构建我们的第一个服务,它将帮助我们维护我们的幻想投资组合的账户信息。它将帮助向应用程序的其他部分提供有关账户中可用资金、已投资金额、当前拥有的股票清单等信息。这个服务的重点是管理账户,就像你管理银行或投资账户一样。

应用程序已经为你生成了一个文件,但如果你要自己创建一个新的服务,你可以像这里看到的那样使用 CLI。最后一个参数是服务的名称,它既用于文件名也用于类名,因此这将生成一个名为 AccountService 的服务:

ng generate service account 

在 图 6.3 中,你可以看到我们正在努力实现的结果。标题很快将显示用户的一些基本账户信息。

c06-3.png

图 6.3 创建账户服务的结果是在顶部栏中显示账户信息。

打开 src/app/services/account.service.ts。我们将添加一些基本属性,通过替换其内容为以下列表中看到的内容来实现。我们将在整个应用程序中多次扩展这个服务,但这将帮助我们开始展示一些基本数据。

列表 6.1 账户服务基础

import { Injectable } from '@angular/core';     
import { Stock } from './stocks.model';     

const defaultBalance: number = 10000;     
 @Injectable()     
export class AccountService { 
 private _balance: number = defaultBalance;
 private _cost: number = 0;
 private _value: number = 0;
 [private _stocks: Stock[] = [];](#c06-codeannotation-0004)

 get balance(): number { return this._balance; }
 get cost(): number { return this._cost; }
 get value(): number { return this._value; }
 [get stocks(): Stock[] { return this._stocks; }](#c06-codeannotation-0005)

 purchase(stock: Stock): void {
 stock = Object.assign({}, stock);
 if (stock.price < this.balance) {
 this._balance = this.debit(stock.price, this.balance);
 stock.cost = stock.price;
 this._cost = this.credit(stock.price, this.cost);
 stock.change = 0;
 this._stocks.push(stock);
 this.calculateValue();
 }
 }

 sell(index: number): void {
 [let stock = this.stocks[index];](#c06-codeannotation-0006)
 if (stock) {
 this._balance = this.credit(stock.price, this.balance);
 this._stocks.splice(index, 1);
 this._cost = this.debit(stock.cost, this.cost);
 this.calculateValue();
 }
 }

 init() {
     
 }

 reset() {
 [this._stocks = [];](#c06-codeannotation-0007)
 this._balance = defaultBalance;
 this._value = this._cost = 0;
 }

 calculateValue() {
 this._value = this._stocks
 .map(stock => stock.price)
 .reduce((a, b) => {return a + b}, 0);
 }

 private debit(amount: number, balance: number): number {
 return (balance * 100 - amount * 100) / 100;
 }

 private credit(amount: number, balance: number): number {
 return (balance * 100 + amount * 100) / 100;
 }
} 

这个服务包含几个属性和方法来管理账户。在顶部,我们导入我们的依赖项,包括 Injectable 装饰器和 Stock 接口(它描述了股票的类型信息)。声明了 defaultBalance 常量,因为最终我们将在几个地方使用它,并希望有一个地方可以引用它。

Injectable 装饰器用于将类连接到 Angular 依赖注入系统。它不接收任何参数,就像 Component 装饰器一样。任何你想注册到 DI 的类,你都需要用 Injectable 装饰器来装饰。这不会立即使服务在其他地方可用——我们很快就会看到这一点。

类的其余部分实现了账户服务的逻辑,包括拥有一些不能直接操作的私有属性和用于读取这些属性值的获取器方法。

该服务公开了两个方法:一个用于购买股票,另一个用于销售股票。这个应用程序的一个重要方面是您只能购买单个股票实例,这简化了我们应用程序的几个其他方面。账户服务将保留已购买股票及其价格列表,当它们被出售时,可以计算该投资的回报。

最后几个方法用于计算各种值,例如所有股票的当前价值或借方和贷方,以及重置这些值。在 JavaScript 中,添加或减去浮点数有时会导致奇怪的结果,例如 0.1+0.2=0.30000000000000004——如果您搜索这个话题,会发现很多关于这个问题的讨论。为了避免这种行为,货币值首先转换为分,然后进行数学运算,然后再转换回货币。

我们想使用这个服务在页眉中显示数据,页眉是在 App 组件中定义的。我们需要使其可用,以便 App 组件模板可以从账户服务中读取属性值。打开 src/app/app.component.ts 文件,并修改其顶部部分以匹配以下列表中的内容。现在文件的其他部分将保持不变。

列表 6.2 App 组件消费账户服务

import { Component, OnInit, OnDestroy } from '@angular/core';
import { AccountService } from './services/account.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
 styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit, OnDestroy {

 constructor(private accountService: AccountService) {}
 // skipping some content

 reset(): void {
 this.accountService.reset();
 }

// file continues 

在构造函数方法中,Angular 可以使用属性的类型注解来确定应该分配给该属性的内容。在这个例子中,这意味着依赖注入系统将提供一个 AccountService 的副本,并将其存储在 accountService 属性上。我们稍后会进一步探讨它是如何工作的,但首先让我们完成我们的第一个服务。

我们在 reset 方法中使用账户服务,这将允许用户点击按钮重新开始模拟。在这个列表中,我也跳过了很多代码,以便专注于更改。

为了使账户服务可注入,我们需要确保它在提供者数组中已注册。打开 src/app/app.module.ts 文件并导入账户服务:

import { AccountService } from './services/account.service'; 

然后在 NgModule 中,将 AccountService 添加到 providers 数组:

providers: [
  LocalStorageService,
  CurrencyPipe,
  AccountService,
] 

这是将服务连接起来以便消费的第二部分,因为它使组件(或更具体地说,注入器)了解如何注入服务。如果您不将服务添加到 providers 数组中,依赖注入系统将永远不会知道它,并且无法编译该服务。

我们需要快速查看我们的投资和股票组件,以便我们可以实现股票的购买和销售。这两个组件几乎需要以相同的方式使用账户服务。

股票组件通过输入绑定(从应用程序组件传递过来)获取所有股票的列表,并有一个购买股票的按钮。它使用分页数据网格,使用户能够查看所有股票的整个列表,而无需一次性显示所有股票。当点击购买按钮时,我们需要调用账户服务的 purchase 方法。为此,我们需要打开 src/app/stocks/stocks.component.ts 文件并实现 buy 方法:

buy(stock): void {
  this.accountService.purchase(stock);
} 

投资组件显示用户目前拥有的股票列表,并查看账户服务以获取这些信息。它不是使用绑定,而是查看服务以确定拥有的股票列表是否已更改,并且在点击卖出按钮时调用账户服务的 sell 方法。我们需要对投资组件进行更改以支持这两种情况。

打开 src/app/investments/investments.component.ts 并将其内容替换为以下列表中看到的代码。

列表 6.3 投资组件控制器

import { Component, DoCheck } from '@angular/core';
import { AccountService } from '../services/account.service';

@Component({
  selector: 'app-investments',
  templateUrl: './investments.component.html',
  styleUrls: ['./investments.component.css']
})
export class InvestmentsComponent implements DoCheck {     
 cost: number = 0;
  value: number = 0;
  change: number = 0;
  stocks: any = [];

 constructor(private accountService: AccountService) {}
 ngDoCheck() {
 if (this.accountService.stocks.length !== this.stocks.length) {
 this.stocks = this.accountService.stocks;
 }
 if (this.cost !== this.accountService.cost || this.value !== this.accountService.value) {
 this.cost = this.accountService.cost;
 this.value = this.accountService.value;
 this.change = this.accountService.value - this.accountService.cost;
 }
 }

 sell(index): void {
 this.accountService.sell(index);
 }
} 

投资控制器不接受用户购买的股票列表的输入绑定,因此它实现了 DoCheck 生命周期钩子。如果您还记得第四章,这个钩子会在任何更改检测运行时执行。

ngDoCheck 方法中,我们首先检查拥有的股票列表是否已更改,如果是,则更新内部的 stocks 值。然后我们检查服务上的本地 costvalue 属性是否已更改,如果是,则更新我们的内部属性。这是一种从服务中获取值并将它们存储在组件中的方法。这允许我们维护一个内部状态,只有当服务中的值发生变化时,该状态才会改变,就像输入绑定一样。

我们还实现了 sell 方法,以便卖出股票的按钮调用账户服务来卖出项目。一旦项目卖出,账户服务将更新其自身的值,然后更改检测将触发 ngDoCheck 方法运行并更新当前值。

查看这些组件的模板,看看模板是如何直接绑定到存储在服务上的值的。这是完全有效的,当这些值发生变化时,更改检测仍然会触发。

最后,通过取消注释 src/app/app.component.html 文件中的具有 header-nav 类的 div 来更新文件。这将显示顶部栏,现在它将填充来自账户服务的值。

现在我们已经看到了一个服务的实际应用,我们可以花更多的时间来查看服务是如何被注入的。

6.3 依赖注入和注入树

服务通常依赖于 Angular 的依赖注入系统,这是我们能够在组件中包含依赖的方式。为了了解服务是如何注入到您的应用程序中的,我们应该进一步挖掘 DI 的工作原理。基本规则并不复杂,但它们确实允许您创建一些有趣且可能复杂的场景(有意或无意)。

您已经在所有章节的示例中看到了它的工作情况,在最后一节中,我们将 Account 服务注入到 Investments 组件中,就像您在这里看到的那样:

constructor(private accountService: AccountService) {} 

第三章讨论了依赖注入的基本原理。依赖注入 通过拥有一个注入器(当请求依赖项时可以注入依赖项的实体)和一个提供者(知道如何构建和实例化对象的实体)来工作。一个模块拥有该模块内部所有实体的顶级注入器。

在第四章中,我们看到了组件是如何构建成组件树,其中 App 组件是根组件,其他组件都由此衍生。我们还没有讨论的事实是,每个组件都有一个注入器。

每次在组件元数据中声明 providers 数组时,该组件将获得自己的注入器。对于一个没有声明任何提供者的组件,它将共享其父组件(或如果它没有父组件,则是父组件的父组件,依此类推)的注入器。这与事件冒泡的方式类似——注入器将遍历注入器树,直到找到所需的内容(或者在找不到时抛出错误)。

这是分层注入系统的基础,这是一个对前一段描述的更华丽的称呼。注入器在层次结构中创建(与组件树相对应的注入器树),但它们还允许我们隔离应用程序的哪些部分可以注入特定的对象,甚至可以覆盖更高阶注入器的工作方式。

在大型应用程序中,隔离对于管理复杂性和防止冲突变得更加重要。想象一个大型应用程序,比如会计程序或电子商务平台。在这种情况下,多个团队在同一应用程序上工作是很现实的,而且很可能某些服务的名称完全相同。如果这些服务都在最高级别注册,它们将发生冲突。但是,如果它们得到适当的管理和在较低级别注册,更接近它们的使用位置,那么冲突就可以避免。这就是隔离原则在起作用。

c06-4.png

图 6.4 模块级别的提供者从模块中注入,而组件级别的提供者在组件树中的相应级别创建单个实例。高亮块是提供服务的位置。

在图 6.4 中,左侧显示如果您在模块级别声明 Posts 服务的提供者,它对该模块内的任何内容都是可用的。当 Blogs 或 Forum 组件请求注入服务时,它会通过组件注入器层次结构向上到 App 组件,然后到 App 模块以找到提供者。这两个组件都获取到同一实例的服务引用,因为服务是由同一个提供者提供的。

在右侧,Forums 和 Blogs 组件都提供并注入了 Posts 服务,因此它们不需要向上到层次结构中查找服务,这意味着它们正在获取单独创建的实例。尽管 App 模块有提供者,但它从未被触及,因为这两个组件都提供了相同名称的服务。

这意味着没有所有依赖项的单个全局注册表(与 NodeJS 模块是包的平面列表不同),而是(可能)有多个注入器,每个注入器维护自己的列表。这为我们提供了在平面依赖项列表中找不到的灵活性。

我们还能够在一个树节点处重映射提供者。像我们之前提到的 Posts 服务示例一样,想象一下 Forums 组件想要注入服务的不同版本(可能是由于重构或需要传递一个不是服务的值)。使用这种语法,我们可以提供不同的类,该类将以相同的名称注入:

providers: [
  { provide: PostsService, useClass: PostsServiceV2 }
], 

根据您需要修改提供者的具体方式,有几种不同的方法可以覆盖提供者。表 6.1 列出了提供者及其它们所启用的基本示例和不同能力。

表 6.1 使用次要值或实现覆盖提供者的方法

提供者类型 用途
别名 您可以让注入器将一个服务重映射到另一个服务。如果您不能直接修改组件但需要更改服务,这可能很有用。它不会创建服务的新实例。
providers: [{provide: PostsService, useExisting: PostsServiceV2}]
注入器可以在现有服务的地方注入不同服务的新实例。如果您想用新实例替换现有服务,这很有用。
providers: [{provide: PostsService, useClass: PostsServiceV2}]
工厂 在某些情况下,您可能需要使用工厂模式自己构建服务的实例,通常是因为在构建过程中需要配置类型值,而这个值在事先是未知的。您需要创建一个工厂函数,该函数返回服务的新实例,并由注入器使用。您必须声明该服务需要注入的任何依赖项。
providers: [{provide: PostsService, useFactory: PostsServiceFactory, deps: [HttpClient]}]
一个值可以用来替代服务,这意味着你不需要创建一个完整的类。例如,一个静态值已经注入,比如配置对象,而你希望在运行时覆盖它。你也可以用一个简单的对象定义来替换服务,这在测试期间的模拟特别有用。
providers: [{provide: PostsService, useValue: PostsServiceV2}]

有一个需要注意的细微差别:当你注册提供者时,对象的名称被捕获为一个令牌,并在以后查找值时使用。虽然你在providers数组中传递服务本身的引用,但内部 DI 使用服务的名称作为令牌来查找该服务。

当我们注入一个服务并给它一个类型值时,内部依赖注入(DI)会根据该类型的名称查找服务,并返回相应的值。在我们覆盖默认提供者值的情况下,DI 会理解返回覆盖的值。

为什么我们想在 JavaScript 中使用依赖注入而不是常规模块加载?通常有几个关键原因会让你想要让你的服务可注入:

  • 它允许你专注于消费服务,而不必担心如何创建它们。

  • 它为你解决了外部依赖,简化了消费。

  • 测试你的 Angular 实体更容易,因为你总是可以注入一个模拟服务而不是真实的服务。

  • 它可以让你在不担心应用程序的其他部分可能在何处也注入服务的情况下控制服务的注入。

  • 它为每个注入树提供了一个干净的服务实例。

依赖注入(DI)功能强大,对于我们的应用程序易于管理至关重要,但如果你总是尝试结合这些功能,跟上这些细微差别可能会有些挑战。以下是一些关于 DI 和服务的注意事项:

  • 在最低级别注入— 不要将所有内容都添加到 App 模块的providers数组中,尝试将其添加到最低组件的providers数组中。这将最小化服务的“表面面积”,使其仅对可能使用它的组件可用。

  • 明智地为服务命名— 给你的服务起有语义和有意义的名字。PostsService 可能已经足够清晰,或者根据上下文可能是 BlogPostsService。我发现,与猜测一个名为 BPService 的服务可能是什么相比,多打几个字符更容易,尤其是在多个人在开发你的应用程序时。

  • 保持服务专注— 不要创建一个包含大量能力并注入到各个地方的大服务,而是创建一个合理数量的服务,执行特定的任务。你的服务越长,维护和测试就越困难,它很可能会在你的应用程序中变得混乱。

  • 保持服务有意义—*在保持服务专注的同时,你还需要平衡添加另一个服务的实用性。你是否需要一个只使用一次的服务?这可能会增加很少的好处而带来更多的复杂性,所以要在服务数量和它们的作用之间找到合适的平衡。

  • 使用一致的模式—*为了保持一致性,为你的服务遵循相同的设计。例如,如果你有多个处理 REST API 调用的服务,你可能会希望给它们提供相同类型的操作,比如 get 来加载一个对象,或者 save 来存储一条记录。

这就总结了你可能想要了解的 Angular 中关于依赖注入的大部分内容。它可以用多种方式使用,这使得它既灵活又强大。但也可以在某些不需要或不可用依赖注入的场景中绕过它,正如我们接下来将要发现的那样。

6.4 无依赖注入的服务

现在你已经看到了如何结合使用服务和依赖注入,让我们来看看绕过依赖注入的服务。在某些情况下,你可能不需要使用依赖注入,或者你可能需要一个在 Angular 完全准备好之前就可以使用的服务。例如,你可能想创建一个在 Angular 开始之前可以用来设置配置的服务,或者你可能想创建一个用于通用操作的辅助服务。

这种方法使用 JavaScript 模块在文件之间导出和导入值。这与你在任何项目中使用 JavaScript 模块的方式没有区别。简而言之,大多数时候,你会使你的服务可注入。没有太多理由不这样做,除非你的服务在 Angular 的应用程序生命周期之外使用。可以这样想:如果 Angular 尚未开始渲染应用程序,那么 Angular 就无法为你注入一个服务来使用。

我们将构建一个具有静态方法的类,用于获取和设置配置值,以便在整个应用程序中使用。我们不会将其制作成一个可注入的服务,因为我们想在应用程序启动之前使用它——我们想在 Angular 开始渲染之前设置配置值,这样在 Angular 需要时就不会有配置值未设置的情况发生。

首先,打开 src/app/services/config.service.ts 文件,并使用以下列表中的代码替换其内容。这是一个基本的静态类,用于维护配置,尽管在这个例子中我们只有一个属性。

列表 6.4 不使用依赖注入的配置服务

export class ConfigService {
  private _api: string;

  static set(property, value) {
    this['_' + property] = value;
  }

  static get(property) {
    return this['_' + property];
  }
} 

在这个服务中,有一个属性用于存储 API 的 URL,以及两个静态方法用于获取和设置该属性。根据需要,我们可以添加更多属性,但在这个应用程序中,我们只使用这种配置。你可以用很多种方式编写这个服务,但关键在于它是一个普通的 JavaScript 类,并且不使用依赖注入。

现在,让我们在应用程序引导之前在 src/main.ts 文件中设置这个值。打开文件,并在当前导入之后添加以下列表中的粗体两行。

列表 6.5 主文件

import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
**import { ConfigService } from './app/services/config.service';**

**ConfigService.set('api', 'https://angular-in-action-portfolio.firebaseio.com/stocks.json');**

if (environment.production) {
  enableProdMode();
}

platformBrowserDynamic().bootstrapModule(AppModule); 

如果你还记得,main.ts 文件是应用程序的入口点。这是应用程序启动时首先执行的代码,因此这将立即设置 API URL。这允许我们在应用程序中的任何时候使用 Config 服务来获取这个值。

你当然可以使用 DI 来创建一个处理配置的服务,但如果你需要尽早设置配置,这是一个你可能考虑的替代方案。

现在,我们将使用这个 Config 服务在另一个服务中,该服务将管理加载股票数据,我们将深入研究 Angular 提供的 HttpClient 服务。

6.5 使用 HttpClient 服务

Angular 自带 HttpClient 服务,可以帮助从 API 请求数据。我们在第二章示例应用程序中看到了 Http 服务的作用,但我们需要给它更多的关注。此外,我们将使用我们创建的 Config 和 Account 服务来使应用程序的大部分其他部分开始工作。

HttpClient 是它自己的模块的一部分,所以你必须确保它在你的项目中。它是 @angular/common 包的一部分,你可能已经安装了,如果没有,请使用 npm 安装它。如果你使用自己的工具和模块加载器,如 SystemJS,你必须确保它知道这个模块。

被认为是最佳实践的是永远不要在组件控制器中直接使用 HttpClient(尽管许多文章甚至文档可能展示了这种情况),因为这有助于创建关注点的分离。这就是为什么我们将创建一个新的服务,该服务将使用 HttpClient 服务并从控制器中抽象一些逻辑。

HttpClient 服务使用可观察对象来处理响应的各个方面。因为它返回一个可观察对象,你也可以使用 RxJS 来将其转换为承诺(这里没有涵盖,但在 RxJS 文档中可以找到),尽管我通常不鼓励这样做,除非有充分的理由。

到本节结束时,应用程序将看起来像图 6.5,其中数据被加载,数据最终将填充到卡片中。它将在数据网格和价格指示器中显示股票列表。

c06-5.png

图 6.5 使用 HttpClient 和股票服务加载到应用程序中的数据

要开始,打开 src/app/services/stocks.service.ts 并将其内容替换为以下列表中的内容。

列表 6.6 包装 HttpClient 服务使用的股票服务

import { Injectable } from '@angular/core';     
import { HttpClient } from '@angular/common/http';     
import { ConfigService } from './config.service';     
import { Stock } from './stocks.model';     

@Injectable()
export class StocksService {     
 constructor(private http: HttpClient) { }
 getStocks() {
 return this.http.get<Array<Stock>>(ConfigService.get('api'));
 }
} 

Stocks 服务主要提供一项功能:便于加载数据。我们通过一个使用 HttpClient 服务请求数据并返回结果订阅的方法来保持对此的关注。它首先导入依赖项,正如你现在所期望的,这包括我们之前创建的 Config 服务和来自 @angular/common/http 包的 HttpClient 服务。然后 StockService 类被装饰为 Injectable

构造函数将 HttpClient 服务注入到类中,但请注意我们并没有对 Config 做同样的事情,因为它不是一个可注入的服务,记住——它只是一个具有静态方法的对象,因此我们可以直接调用它。

现在服务的重要部分是 getStocks 方法。当它被调用时,它将返回 HttpClient 将创建的响应的可观察对象,这样我们就可以在其他地方使用 subscribe 来获取响应数据。HttpClient 有其他 HTTP 动词的方法,如 postputdelete。你可以在angular.io/guide/http的文档中了解更多信息。这里没有错误处理,因为那需要在方法的调用位置进行处理。

在某些方面,这个简单的服务抽象掉了 HttpClient 在我们的组件中的使用,因为我们不应该关心数据是从哪里来的。控制器应该请求数据并从服务中接收数据,而不是必须构造 URL 并直接进行 HTTP 请求。这在大多数编程范式中被认为是最佳实践。

当所有这些放在一起时,我们将使用 getStocks 方法来设置我们的 HTTP 请求,然后订阅它以触发请求并处理响应。请注意,调用 getStocks 不会触发 HTTP 请求——只有当我们订阅它时,它才会触发。打开 src/app/app.component.ts 文件,并用以下列表中的代码替换它。

列表 6.7 App 组件控制器以使用 Stocks 服务

import { Component, OnInit, OnDestroy } from '@angular/core';
import { AccountService } from './services/account.service';
import { Stock } from './services/stocks.model';     
import { StocksService } from './services/stocks.service';     

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
  providers: 
 [StocksService
  ]
})
export class AppComponent implements OnInit, OnDestroy {
  refresh: boolean = true;
  stocks: Stock[] = [];
  interval: any;

  constructor(
    private accountService: AccountService, 
 private stocksService: StocksService) {}
 ngOnInit() {
 this.load();

 this.interval = setInterval(() => {
 if (this.refresh) {
 this.load();
 }
 }, 15000);
 }

 toggleRefresh(): void {
 this.refresh = !this.refresh;
 }

 ngOnDestroy() {
 clearInterval(this.interval);
 }

  reset(): void {
    this.accountService.reset();
  }

 private load() {
 this.stocksService.getStocks().subscribe(stocks => {
 this.stocks = stocks;
 }, error => {
 console.error(`There was an error loading stocks: ${error}`);
 });
 }
} 

在我们可以使用服务之前,必须将其导入并添加到 providers 数组中。然后它作为构造函数的参数注入到组件中。这与我们为 Account 服务所做的是同一件事,只是在这里我们是在组件本身中做的。这意味着只有 App 组件及其任何子组件才能访问此服务,而不是整个模块。

当 App 组件初始化时,它将调用 ngOnInit 方法一次性加载数据,然后设置一个间隔每 15 秒重新加载。请注意,在重新加载之前,它还会检查 refresh 属性是否为 true,因为我们会有一个切换来可选地禁用数据刷新。当组件被销毁时,我们使用 ngOnDestroy 来清除间隔以防止内存泄漏。

load方法是一个私有方法,它使用 Stocks 服务来加载数据。我们调用getStocks().subscribe()来构造 HTTP 请求并触发它。当收到响应时,我们将结果存储在stocks属性上。我们还有一个错误处理器,如果发生错误,它会将错误记录到控制台。

我们将要进行的最后一步是取消 src/app/app.component.html 文件中除第一行包含 alert 组件之外的所有内容的注释。这样做将使所有组件现在都出现在我们的应用程序中,并填充了数据!你会注意到 App 组件将数据绑定到不同的组件中,如下面的示例代码所示。每次数据重新加载时,绑定都会更新,组件将刷新其数据副本并显示:

<app-ticker [stocks]="stocks"></app-ticker> 

由于我们创建了一个服务来处理构建 HTTP 请求以加载数据的复杂性,我们使控制器保持得更加专注。组件不需要知道数据是如何返回的——它可能是硬编码的或动态加载的——它只知道如何请求服务中的数据。当你有数据访问时,你想要遵循这个模式。组件确实需要理解如何处理来自服务的响应,在这种情况下,组件只需要理解它得到了一个可观察对象。

你可能会在你的应用程序中创建几个这样的服务,随着时间的推移,你可能会想出你喜欢的特定模式来重复使用。我发现创建一个基本的 HttpClient 服务并将其扩展以创建针对各种 API 端点的特定服务是有用的。你为服务选择的设计可以极大地取决于 API 设计。

虽然 HttpClient 是加载数据最常用的服务,但你的应用程序仍然可以使用其他协议,如 Websockets、推送通知或其他 API,例如音频录制。服务可以被设计为抽象每个数据源的复杂性,并允许组件保持专注。

现在,让我们看看我们如何可以拦截请求和响应来处理我们想要执行额外工作的情况,比如添加常见的头信息或针对特定类型的响应。我们可以轻松地从共同点转换请求和响应,这是强大的。

6.5.1 HttpInterceptor

通常,你会在它们在其他地方处理之前拦截 HTTP 请求或响应。例如,你可能想要向特定域的所有出站请求添加一个授权头,或者你可能想要捕获任何错误响应并将它们记录下来。HttpClient 通过创建一个实现HttpInterceptor接口的服务,提供了一个很好的方式来拦截请求和响应,用于这些类型的目的。

在我们的情况下,当股票 API 数据返回时,我们希望在其中添加一些逻辑,以便我们可以更新我们的账户余额。当我们购买一些股票时,股票的当前价值和我们的投资组合会在账户服务中进行跟踪。当我们从 API 获取最新数据时,我们希望也更新账户服务的状态,以反映最新的定价(并且我们希望我们赚了钱而不是亏了钱)。

HttpInterceptor 的构建方式与大多数 Angular 服务类似,但它必须有一个名为 intercept 的方法。你将始终拦截请求,然后可选地跟随它以拦截响应。为了看到这个动作,让我们创建一个拦截器,它将拦截对股票数据的任何请求,并使用最新价格更新账户服务。使用以下命令生成一个新的服务:

ng generate service services/interceptor 

打开位于 src/services/interceptor.service.ts 的新文件,并用以下列表中的值替换其内容。

列表 6.8 HTTPInterceptor

import { Injectable } from '@angular/core'; 
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/do';
import { HttpEvent, HttpInterceptor, HttpResponse, HttpHandler, HttpRequest } from '@angular/common/http';
import { AccountService } from './account.service';
import { Stock } from './stocks.model';
import { ConfigService } from './config.service';

@Injectable()
export class StocksInterceptor implements HttpInterceptor {     
 constructor(private accountService: AccountService) {}

 intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>>
 const request = req.clone();
 request.headers.append('Accept', 'application/json');
 return next.handle(request).do(event => {
 if (event instanceof HttpResponse && event.url === ConfigService.get('api')) {
 const stocks = event.body as Array<Stock>;
 let symbols = this.accountService.stocks.map(stock => stock.symbol);
 stocks.forEach(stock => {
 this.accountService.stocks.map(item => {
 if (stock.symbol === item.symbol) {
 item.price = stock.price;
 item.change = ((stock.price * 100) - (item.cost * 100)) / 100;
 }
 });
 });
 this.accountService.calculateValue();

 return stocks;
 }
 });
  }
} 

需要一些设置,我们可以慢慢来。在我们导入依赖项之后,我们使用 Injectable 声明一个服务,并使其实现 HttpInterceptor 接口。此接口检查我们的类上是否有 intercept 方法,如果没有,则抛出编译错误。我们还需要访问账户服务,因此我们在构造函数中注入它。

intercept 方法将在任何 HTTP 请求中被调用,并且你将获得两个参数。第一个是 HttpRequest 对象,它包含有关被调用 URL、有效载荷、任何头信息等的数据。第二个参数是 HttpHandler 对象,这是我们告诉 Angular 我们已经完成请求修改的方式。如果我们不这样做,请求将失败。

If we want to make changes to the request before it’s fired, we would modify those values at the top of the `intercept` method. The `HttpRequest` object is immutable, so if you want to modify a request you have to first clone the object, as we do here. Then we add a new header to the request to say we want to accept JSON data. You can review all the properties for the request at [`angular.io/api/common/http/HttpRequest`](https://angular.io/api/common/http/HttpRequest). Once we’ve made our changes to the request, we then need to tell Angular to handle them. The `HttpHandler` object has a method called `handle` (aptly named) that will take our modified request and pass it along to the HttpClient to use for making the HTTP request. We could end it here if we only wanted to modify the request, but because we also want to use the response, we’re using the RxJS operator `do` to run our custom logic on the response after it arrives. The `do` operator allows us to do actions that receive the data on the stream, but don’t modify the response itself. This is appropriate in this case because we only want to use the response to modify the Account service, and it would also make sense in a case where you’d do something like logging or caching of the response. If you wanted to modify the response object directly, you’d most likely use the RxJS `map` operator to modify the stream. Once we’re inside of our `do` method, we grab a copy of the data and then implement some logic to look through the stocks we’ve purchased in the Account service and update their current price and value based on the latest prices. When the Account service gets the updated values, the portfolio values will update. Now that we have our interceptor, we need to correctly set it up so the application is aware of it. Because there may be multiple interceptors, we have to declare our service provider in a different manner. It will be declared in the App module `providers` array, but in this case we’re going to use a *multiprovider*. That means you can provide a new service that gets bundled into a list of other providers. This makes sense if you want to have more than one interceptor (which is possible), but want to be able to refer to all interceptors as a single entity. Open up src/app/app.module.ts and import the `HTTP_INTERCEPTORS` token: ``` import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; ``` Modify the `providers` array like this: ``` providers: [ LocalStorageService, CurrencyPipe, AccountService, { provide: HTTP_INTERCEPTORS, useClass: StocksInterceptor, multi: true } ], ``` Here we use an object that will attach our `StocksInterceptor` to the list of `HTTP_INTERCEPTORS`. This allows us to define multiple interceptors, all to be added to the same list, which is a realistic scenario. There are so many potential uses for `HttpInterceptors`, and in my experience they’re often the best solution. Although this example is useful, the best uses are when you want to apply changes to all HTTP requests or responses. In this case, we’re only applying the response logic if the URL matches our stock endpoint, so it could be better to handle this logic elsewhere. But I like this interceptor because the logic it powers is about the Account service and not directly about the Stocks service, so it makes sense to separate it like this. That said, I’d recommend that you use interceptors when you need to apply logic to more than one type of request or response, or when the additional action is beyond the scope of the normal service. Next we’ll take a look at helper services that have a more limited role in the application and are primarily there to keep complexity down or reduce code duplication. ## 6.6 Helper services As you build your application, you may see some code start to reappear in various places. That’s what all services help to reduce, but sometimes you’ll find that some code feels out of place and needs to be extracted into a service. When you do that, you’re creating a service that exposes helper functions to simplify your components. We haven’t seen much of this in our examples yet because our applications have been fairly compact. Imagine you have some custom sorting logic that you want to use when the user selects various filters for a list of items, but that the logic might be used in multiple components. That would be an example of reusing the same logic in multiple places, but also extracting the complexities from the component level to a service to keep your components focused. I have a few ways to identify code that would be good to be extracted into a helper service. First, if I have to unit test similar logic in multiple places, I know there’s a chance to simplify my application and my tests. I also look for code that’s particularly difficult to read, often because it’s nested several blocks deep. That kind of code becomes challenging to unit test (though you might also want to think about how to reduce the depth as well). Finally, I try to keep track of code that does tasks that are secondary to the primary goal of a function, such as a block that has to do some JSON parsing and validation before being stored on the model. In our application we’d like to make it easy to access local storage so we can cache the current application state. I think of it as a data access helper service. Getting and storing data in local storage isn’t terribly difficult, except we’re putting in JSON data that has to be parsed or stringified before local storage can handle it. We’ll create a local storage service to help us manage accessing and storing JSON data and then update our application to check for any cached data on initialization. This will allow us to remember the purchase history for the user (in the current browser only). Open up app/src/services/local-storage.service.ts and add the code from the following listing. This is a fairly simple service, but it helps us abstract these steps from the controllers. **Listing 6.9** Local storage service ``` import { Injectable } from '@angular/core'; @Injectable() export class LocalStorageService { get(key: string, fallback: any) { ``` ``` let value = localStorage.getItem(key); return (value) ? JSON.parse(value) : fallback; } set(key: string, value: any) { ``` ``` localStorage.setItem(key, JSON.stringify(value)); } } ``` This class is much more basic than the Stocks service, but it still performs an important function. The `get` method lets us request an entry from local storage based on a key and parse it into an object for us. It also accepts a fallback value, which is returned if there is no cached data. The `set` method stores some data into local storage but will stringify the data before it’s stored, because local storage only stores strings. This service is built assuming that you want to store objects or arrays into storage, but you can also store some primitive values like numbers or strings. If you wanted to better handle some of those data formats, it would require more complexity, and later on you could easily expand the service to handle those situations without having to change any of your current uses. That’s another great benefit to abstracting this type of logic: You can improve upon it and give it more capabilities as long as you support the current functionality. In order to use this service, we’ll want to cache the application state anytime users buy or sell stocks. That’s handled by our Account service, so we’ll want to open up src/app/services/account.service.ts and add the bold sections in the following listing. The entire file is included for your convenience. **Listing 6.10** Account service using Local storage service ``` import { Injectable } from '@angular/core'; import { Stock } from './stocks.model'; **import { LocalStorageService } from './local-storage.service';** ``` ``` const defaultBalance: number = 10000; @Injectable() export class AccountService { private _balance: number = defaultBalance; private _cost: number = 0; private _value: number = 0; private _stocks: Stock[] = []; **constructor(private localStorageService: LocalStorageService) {}** ``` ``` get balance(): number { return this._balance; } get cost(): number { return this._cost; } get value(): number { return this._value; } get stocks(): Stock[] { return this._stocks; } purchase(stock: Stock): void { stock = Object.assign({}, stock); if (stock.price < this.balance) { this._balance = this.debit(stock.price, this.balance); stock.cost = stock.price; this._cost = this.credit(stock.price, this.cost); stock.change = 0; this._stocks.push(stock); this.calculateValue(); **this.cacheValues();** ``` ``` } } sell(index: number): void { let stock = this.stocks[index]; if (stock) { this._balance = this.credit(stock.price, this.balance); this._stocks.splice(index, 1); this._cost = this.debit(stock.cost, this.cost); this.calculateValue(); **this.cacheValues();** } } calculateValue() { this._value = this._stocks .map(stock => stock.price) .reduce((a, b) => {return a + b}, 0); } **init() {** ``` ``` [**this._stocks = this.localStorageService.get('stocks', []);**](#c06-codeannotation-0039) **this._balance = this.localStorageService.get('balance', defaultBalance);** **this._cost = this.localStorageService.get('cost', 0);** **}** reset() { this._stocks = []; this._balance = defaultBalance; this._value = this._cost = 0; **this.cacheValues();** } **private cacheValues() {** ``` ``` **this.localStorageService.set('stocks', this.stocks);** **this.localStorageService.set('balance', this.balance);** **this.localStorageService.set('cost', this.cost);** **}** private debit(amount: number, balance: number): number { return (balance * 100 - amount * 100) / 100; } private credit(amount: number, balance: number): number { return (balance * 100 + amount * 100) / 100; } } ``` As you should expect by now, we start by importing the service into our file and then inject it into the class. I hope you’re wondering how this works, since there is no `providers` array like in the components. Services must be injected somewhere into a provider, and before you started, the Local storage service was already added to the app module’s `providers` array. See src/app/app.module.ts to confirm that the Local storage service is imported and added into the `providers` array. That means this provider is available anywhere in this module, so the Account service can inject it. We added the new method `cacheValues` to our list of private methods, which will call the Local storage service to save the requested properties. Then both the `purchase` and `sell` methods call `cacheValues` to save the application state. The last method is an `init` method, and this is used to initialize the data from local storage if it was stored previously. Otherwise, it uses the default values and starts the application with a fresh session. This doesn’t get called automatically, because this isn’t a lifecycle hook like NgOnInit. I’ll address this in a moment. Since we moved the local storage work into a helper service, it’s much easier to use and allows us to keep the Account service more focused as well. You could imagine extracting the Account service’s debit and credit methods into a helper service if you needed to do a lot of financial calculations. I find opportunities for helper services easy to spot after I’ve written some code, though not always obvious when I first write the code. The last little change we’ll make is to call the `init` method, which we’ll do from the App component. Open up the src/app/app.component.ts file and add a call to the `init` method as the first line of `ngOnInit`, like this: ``` ngOnInit() { this.accountService.init(); this.load(); this.interval = setInterval(() => { if (this.refresh) { this.load(); } }, 15000); } ``` This will initialize the data from local storage when you start up the application, allowing you to continue an existing session. The Account service is already injected, so we need to use it. These kinds of helper services can take many different forms, so don’t worry too much about making them until you see similar or repeated logic in multiple places or a code snippet is clearly best separated. As you create more Angular applications, you’ll get better at creating services earlier on, but I suggest focusing on components first and refactoring to services after you get more familiar. ## 6.7 Services for sharing There are many ways to deal with application data and sharing state, as we’ve already seen in our components in previous chapters’ examples. Remember, the primary way to pass data between components is through using input or event bindings, but another option is to store data in a service and use dependency injection to make it available anywhere it’s needed. What you do is externalize some data into a service, and anywhere you need to use it, you inject that service. If you use it in a component’s template with data bindings, then change detection will automatically catch any changes on the service and render. Alternatively, you can implement your own change detection in cases where that might be needed using the `DoCheck` lifecycle hook (similar to what we did earlier). The service can share data and also methods. You may want to protect access to data, similar to the Config service, or you may make it more public. Over time, as you build larger and more complex applications, you’ll likely want to ensure that you lock down the way your services expose data so it doesn’t get messed up. We’re going to create a small service to help us display alerts when certain events occur, as you see in figure 6.6. ![c06-6.png](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/ng-ia/img/c06-6.png) **Figure 6.6** Alert component and service appears on purchase For example, when the user purchases or sells a stock, we can show an alert with the details of the exchange. Open up the src/app/services/alert.service.ts file and update it to match the code in the following listing. **Listing 6.11** Alert service ``` import { Injectable } from '@angular/core'; @Injectable() export class AlertService { show: boolean = false; ``` ``` type: string = 'info'; message: string; timer: any; alert(message: string, type: string = 'info', autohide: number = 5000) {       ``` ``` alert(message: string, type: string = 'info', autohide: number = 5000) { this.show = true; this.type = type; this.message = message; if (this.timer) { clearTimeout(this.timer); } if (autohide) { this.timer = setTimeout(() => { this.close(); }, autohide); } } close() { ``` ``` this.show = false; } } ``` The Alert service contains three properties that describe whether the alert is visible, what type it is, and the message to display. These values are initialized with default values, and the `alert` method accepts arguments that will change the state. The `close` method changes the state to disable the alert. This service exposes three properties and two methods that mutate those properties. This service doesn’t do anything on its own—it has to be linked to the Alert component to bind these properties into the component’s behaviors. I’ve already set up the component template to correctly bind these values where needed if the service is injected into the component, which is what we need to do next. Open up src/app/alert/alert.component.ts—we need to import and inject the service. At the top of the file, make sure to import like you see here: ``` import { AlertService } from '../services/alert.service'; ``` Then the class needs to inject the service in the constructor method, like this: ``` export class AlertComponent { constructor(private service: AlertService) { } } ``` While we’re looking at the Alert component, note that the template found in src/app/alert/alert.component.html is already trying to reference the service to bind to these properties, such as `*ngIf="service.show"`. The template directly references the values in the service, so if the service property changes, so will the binding. Now that the Alert component and service are wired up, we can start to use the alerts, right? Not quite. We haven’t yet registered the provider, so we need to add it to the App module. Open up src/app/app.module.ts and import the Alert service: ``` import { AlertService } from './services/alert.service'; ``` Then add it to the `providers` array to make it available to the application for injection: ``` providers: AlertService, LocalStorageService, CurrencyPipe, // ... rest of the providers ``` The App component template should still have the Alert component commented out, so we need to fix that. Open up src/app/app.component.html and uncomment the top line so it will render: ``` <app-alert></app-alert> ``` Now we need to call the Alert service and have it trigger an alert! We’ll do this in two different places. In the Account service, we’ll show an alert when you buy or sell a stock, and in the App component, we’ll show an alert when you toggle the refresh or reset the app state. While we’re still in the App component, let’s add the first set of alerts. Update the `toggleRefresh` method to call the service, like you see here: ``` toggleRefresh(): void { this.refresh = !this.refresh; let onOff = (this.refresh) ? 'on' : 'off'; this.alertService.alert(`You have turned automatic refresh ${onOff}`, 'info', 0); } ``` This calls the Alert service, passing in a message and type and disabling the auto close feature. Likewise, modify the `reset` method like this: ``` reset(): void { this.accountService.reset(); this.alertService.alert(`You have reset your portfolio!`); } ``` This calls the service but only sets a message and leaves the other defaults in place. Now we can implement the alerts in the Account service. Open up src/app/services/account.service.ts and import the Alert service: ``` import { AlertService } from './alert.service'; ``` Then make sure to inject the service onto the controller constructor: ``` constructor(private localStorageService: LocalStorageService, private alertService: AlertService) {} ``` Now update the `purchase` and `sell` methods, as you see in the following listing. **Listing 6.12** Account service with alerts ``` purchase(stock: Stock): void { // ... The rest of the method should remain this.cacheValues(); [this.alertService.alert(`You bought ${stock.symbol} for $${stock.price}`, 'success'); ``` ``` } else { this.alertService.alert(`You have insufficient funds to buy ${stock.symbol}`, 'danger'); } } sell(index: number): void { // ... The rest of the method should remain this.cacheValues(); this.alertService.alert(`You sold ${stock.symbol} for $${stock.price}`, 'success'); ``` ``` } else { this.alertService.alert(`You do not own the ${stock.symbol} stock.`, 'danger'); ``` #B ``` } } ``` Now when the user purchases or sells a stock, the Alert service will get called and trigger the alert to display. In each use, it passes a different message and type based on the circumstances. If you run the application at this point, you’ll now see the alert slide in when you buy and sell. That completes the Alert component and service, and it allows us to easily display an alert from anywhere in the application. We already have a service that shares data, the Account service, but I want to cover one more service and look at it from this perspective. I suggest that you go back and look at the Account service more closely, see how it shares data and makes it available, and consider the pros and cons that it may bring. Using a service like you see here couples the service and component and means that you can only have one instance of the Alert component. If you had more than one, they’d share the same state, and you’d get multiple copies of the same alert. You could design the service to manage multiple components with some logic if you wanted to, but I suggest thinking about other options. Although I demonstrate services for sharing data and state here and in chapter 2, I want to emphasize that this can be a tricky approach to scale as you build your application. It can also be problematic if you don’t provide the service at the module level, or if you provide it again (by accident of course) elsewhere and get a different copy. Let’s wrap up services by looking at a few less-common services provided by Angular and how you can learn to use them. ## 6.8 Additional services Angular has a few other services you can use, like the Http service. To find all the services Angular has to offer, the API documentation lists hundreds of objects that Angular exposes. Sadly, none of them is listed as a service. But here’s a little secret: Some of those objects can be used like services! When you view the API documentation ([`angular.io/api`](https://angular.io/api)), you’ll notice the API is coded with different categories, including Pipe, Class, Function, and Decorator. What this list includes is everything that Angular itself exports, which means you can import any of these items and use them, and some of them can even be injected. One example is the Location service, which is a class by definition, but acts as a service by providing functionality to read and write values to the browser’s URL. Another example is the Date pipe, which can be used to format dates outside of a template. As you explore the API, you’ll find other entities that can be used as a service. Although you could import and use any of the API entities, many are only useful in specific situations or for Angular’s implementation details. I’ll use the Currency pipe in our example. You’ll recall that we show an alert when you buy or sell a stock that shows the sales price. We want to format that number, as we do when we use the Currency pipe in a template. But that isn’t possible because the message is set inside a service. If you remember from our previous chapter examples, the Currency pipe is used like in the following code snippet. It has a binding value and then the pipe symbol with various arguments to configure how it will process the values. In this case, the configuration formats the value in USD, with a dollar symbol, and formats the value to two decimal points: ``` {{stock.price | currency:'USD':true:'.2'}} ``` I want to point out that the Currency pipe is already added as a provider in the App module, which means we can use it with dependency injection in our services. Open up src/app/services/account.service.ts. We’re going to start by importing the Currency pipe. Add the following to the imports at the top of the file: ``` import { CurrencyPipe } from '@angular/common'; ``` We need to inject the Currency pipe directly into the service through the `constructor`: ``` constructor(private localStorageService: LocalStorageService, private alertService: AlertService, private currencyPipe: CurrencyPipe) {} ``` Now we can use this pipe in our service when a stock is bought or sold. Look for the line in the `purchase` method that calls the Alert service and modify it to the following: ``` this.alertService.alert(`You bought ${stock.symbol} for ` + this.currencyPipe.transform(stock.price, 'USD', true, '.2'), 'success'); ``` Likewise, find the corresponding line in the `sell` method and modify it like this: ``` this.alertService.alert(`You sold ${stock.symbol} for ` + this.currencyPipe.transform(stock.price, 'USD', true, '.2'), 'success'); ``` That’s all we need to do to use the Currency pipe. Pipes always have a `transform` method, which is how the pipe is internally processed when the template encounters a pipe, and because we’re calling the pipe directly, we follow the same process. It takes the input value (which in the template would be the binding value) as the first argument, and then additional arguments are passed in the same as if it were used in the template. There are many more services and capabilities in the API that you’ll find uses for over time that I don’t have room to cover. But before you build a service, you can look through the API to see if Angular exposes something that handles it already. ## Summary We’ve gone through a lot of capabilities for services and how they work. Services are fundamental for Angular applications, and you’ll be building or using many of them. Here is a quick recap of what you’ve learned in this chapter: * Services are best for taking on specific tasks, such as data access and managing configuration. * Services can be injected anywhere in the application as long as they’ve been registered with a provider. * Angular provides an HttpClient service that helps manage making XHR requests, and you can wrap your own services around it to simplify data access. * Services can hold values that get propagated, but you need to be aware of the injection tree to avoid getting different copies of the service. * A class could be used like a service, without having to use dependency injection, but it should be limited to only situations that make sense. * Angular exposes many entities in the API that you can use in your application, such as a Location service for managing URLs. Next up, we’ll talk about routing and how to create different types of navigation for your Angular application.

7

路由

本章涵盖

  • 路由是什么以及如何创建它们的示例

  • 不同的导航模式及其优点

  • 如何保护页面不被未经授权的访问

  • 将代码组织成模块

  • 使用二级路由进行多路由

大多数应用程序在应用程序的生命周期中都需要能够在不同的页面之间导航的能力。通常,一个应用程序至少有几个基本页面,例如登录页面、主页、用户账户页面等等。“路由”这个术语用来描述应用程序在用户导航时更改页面内容的能力。我们之前的章节示例没有使用路由;它们被限制为一次性显示所有内容。

互联网已经形成了一种使用 URL 来维护用户当前位置的成熟模式。这传统上是通过浏览器从服务器请求页面,服务器响应必要的 HTML、CSS 和 JavaScript 资源来实现的。当单页应用程序(SPA)变得可行时,路由的作用必须完全转移到浏览器,通过允许 JavaScript 操作浏览器中的当前 URL 来维护应用程序内的当前位置,即使应用程序完全在浏览器中运行。

Angular 提供了一个全面的路由库,使得创建简单和复杂的导航模式变得简单。Angular 的路由库使我们能够轻松地定义路由,并取代了服务器处理基于当前 URL 显示内容的需要。我不再使用“页面”这个词来描述用户可以访问的不同位置,而是使用“视图”这个词来描述这些不同的上下文,例如登录视图或仪表板视图。

你将看到如何将应用程序结构化为不同的功能模块的示例。到目前为止,我们只为我们的应用程序创建了一个模块。当我们引入路由到我们的应用程序中时,应用程序的复杂性和大小很可能会增加。我们将看到如何创建不同的模块,以使应用程序的不同部分相互分离。尽管我们可以在其他章节中这样做,但本章示例在不同功能之间的分离最为明显。

我们首先要做的是设置好本章的示例。

7.1 设置本章示例

在本章中,我们将搭建一个基本的社区网站,用户可以在网站上浏览论坛、博客,并与其他用户聊天(图 7.1)。我已经创建了显示这些功能的组件和服务,但省略了使其通过路由功能化的能力。

在示例中,有一个论坛部分,您可以查看论坛中的所有主题,然后查看特定主题的所有帖子。还有一个博客部分,您可以查看可以单独查看的博客帖子列表。最后,还有一个聊天功能,它会在其他内容上方弹出,让您在浏览网站其他部分的同时与自动机器人聊天。该应用程序还具有登录功能,如果您导航到未知的 URL,则会显示错误页面。

这些功能中的每一个至少有一个路由,通常更多。让我们看看本章中我们将构建的简短路由列表。这些路由代表应用程序中的重要位置,用户可以到达:

  • 论坛列表,也作为默认视图

  • 单个论坛,显示主题列表

  • 单个主题,显示主题中的帖子

  • 博客帖子列表

  • 单个博客帖子

  • 选择与其他用户聊天的聊天框

  • 与其他用户聊天的聊天框

  • 登录视图

  • 未找到错误视图

c07-1.png

图 7.1 具有论坛、博客和聊天章节的社区应用程序示例

由于我已经创建了应用程序的大部分内容,我们将从下载现有代码开始,无论是使用 Git 还是从 GitHub 下载存档。这有助于我们专注于仅添加路由所需的必要部分。如果您使用 Git,则可以运行以下命令:

git clone -b start https://github.com/angular-in-action/community.git 

否则,您应该从 GitHub 下载并解压项目:github.com/angular-in-action/community/archive/start.zip。现在您应该在您的计算机上有一个代码副本。您还需要安装项目的 node 模块。导航到目录并运行以下命令以安装模块并预览项目:

npm install
ng serve 

初始时,应用程序看起来是空的,但当我们开始设置路由时,页面将开始工作。它应该看起来像您在图 7.2 中看到的那样。该应用程序的数据存储在几个静态 TypeScript 文件(命名为 data.ts)中,只是为了避免为这个演示构建 API 并保持其快速。我还通过避免允许用户创建新的论坛帖子或博客帖子的复杂性来简化了应用程序。您从本章中获得的路由知识将帮助您在下一章介绍表单后轻松添加这些功能。

c07-2.png

图 7.2 基于起点应用应呈现的样子

现在您已经获得了文件和所有设置,让我们开始创建我们的第一组路由!

7.2 路由定义和路由设置

Angular 路由器依赖于几个基本概念,但它非常灵活,你可以创建许多不同类型的导航体验的组合。路由器负责协调用户在点击和与应用程序交互时的导航选项。

在最基本的情况下,路由器根据当前 URL 渲染组件,例如,让主视图上的 Home 组件渲染。它还允许你创建子路由,其中只有组件的内部部分根据路由而改变,例如,当页面上有始终存在的常用部分,但内部内容根据 URL 交换时。它还可以支持位于当前路由“之上”的辅助路由,并且具有完全独立的路由历史,如实时支持聊天框。

我们的第一步是将路由器包含到项目中,然后我们将创建我们的第一个几个路由,如下所示:

  • /login — 显示登录页面

  • / — 如果加载根页面,则重定向到默认的/forums 页面

  • ** — 匹配任何未定义的路由的回退,如 404 页面

本节的目标是设置好路由器,设置默认路由,并定义一个回退路由。你可能会在所有使用路由器的项目中遵循这些步骤。

Angular 路由器作为一个单独的包(@angular/router)被包含,CLI 将其作为 npm 包的一部分包含,但实际上并没有将其加载到应用程序模块中。如果你有一个没有模块包的项目,你可以通过 npm 像这样安装它:

npm install --save @angular/router 

当我们包含路由器时,我们还需要定义一组适用于我们应用程序的路由。首先,我们将有两个路由。第一个是登录界面,你应该注意到应用中相应的登录组件。第二个是 404 未找到页面,该页面在应用中也有相应的 NotFound 组件。

所有路由都必须定义,定义仅是一个具有至少一个属性的基本对象。你可以使用十几个属性来定义路由,但典型的路由包含一个路径(或 URL)和一个组件(根据路径显示的组件)。我们将在本章中看到一些其他属性的应用,但首先我们将关注这两个。

让我们在设置应用程序中的路由器时首先定义登录路由。打开 src/app/app.module.ts 文件,并将以下导入添加到文件中——这包括路由器模块和一个描述如何定义路由的接口:

import { RouterModule, Routes } from '@angular/router'; 

现在我们可以定义我们的第一个路由了。创建一个包含以下值的新变量,然后我们将查看这两个变量以了解它们定义的路由类型。你可以在最后一个import语句之后放置这个变量:

const appRoutes: Routes = [
  { path: 'login', component: LoginComponent },
  { path: '', redirectTo: '/forums', pathMatch: 'full' }, 
  { path: '**', component: NotFoundComponent },
]; 

appRoutes变量,它被类型化为Routes,是一个简单对象的数组。这些对象可以有不同的属性,我们将在本章中使用其中许多属性。你可以在这里看到所有属性:angular.io/api/router/Routes。表 7.1 列出了本章中我们将使用的属性,其中大部分。

表 7.1 本章中使用的路由属性及其用途

属性 可接受值 用途
path 字符串,或通配符匹配器** 定义用于路由的 URL 路径;如果路由嵌套,则附加到任何父路径上
component 组件的引用 识别与特定路由相关联的组件
redirectTo 另一个有效路由的字符串 从一个路由(路径)重定向用户到redirectTo中定义的路由
pathMatch ‘full’,‘prefix’ 确定匹配策略,是否匹配路由的完整或部分 URL
children 路由数组 列出作为此路由子路由加载的路由
outlet 命名出口的字符串 告诉路由在特定的路由出口中加载
loadChildren 模块的路径字符串 允许你在请求特定路由时懒加载新的模块
canActivate 守卫引用的数组 允许你在某些条件下防止使用路由,例如未登录

第一个路由是最基本的,也可能是最常见的路由类型。它定义了当在 URL 中找到某个路径时,Angular 应该渲染指定的组件。路径不能以反斜杠开头。此路由意味着如果浏览器位于 URL http://localhost:4200/login,它将渲染 Login 组件。

第二种路由是一个重定向路由。Angular 允许你将路径重定向到其他 URL,就像你可能在你后端服务器上做的那样。redirectTo属性定义了要导航的新 URL,而pathMatch属性声明在重定向触发之前路径必须完全匹配。如果你想重定向一个路径及其所有子路径,可以将pathMatch设置为'prefix'值。在这种情况下,根路径 http://localhost:4200/将重定向到 http://localhost:4200/forums。我们还没有实现论坛链接,所以它将渲染第三种路由。

第三种路由与第一种类似,因为它定义了一个路径和一个组件,但带有两个星号的路径使其成为一个通配符、捕获所有类型的路由。如果你访问 Angular 不知道的任何路由,例如 http://localhost:4200/not-a-route,它将渲染 NotFound 组件。

你可能想在应用程序中有一个通配符路由来防止导航错误。我建议从一开始就在所有应用程序中设置一个类似于 NotFound 组件的组件。

我们已经定义了我们的路由。现在我们需要设置路由以使用它们。在模块的 imports 部分中,你需要添加路由模块,就像你在这里看到的那样。我已经加粗了添加的新行:

 imports: [
    BrowserModule,
    FormsModule,
    ClarityModule.forRoot(),
    ForumsModule,
 **RouterModule.forRoot(appRoutes)**
  ], 

我们所做的是在我们的应用程序中包含了路由,并使用 forRoot() 方法声明我们在主 App 模块中使用路由。它还接受一个参数,这个参数应该是该模块声明的路由数组。如果你在这里不传递任何内容,路由将不知道任何你的路由,所以这是将配置传递给路由的方式。

最后一步是确保你的应用程序中某个地方有一个路由出口组件。你可能想知道这些组件将渲染在哪个地方。如果你还记得第二章的内容,路由需要知道为给定路由渲染内容的地点,而路由出口就是标记位置。你的应用程序中至少需要一个路由出口。

打开 src/app/app.component.html 文件,你会注意到它包含用于标题和导航的标记,以及一个内容区域,目前该区域只包含一个标题元素。由于导航将在所有路由之间保持一致,它存在于路由出口之外。这意味着我们不需要在每个路由中重新定义导航,我倾向于将其视为一个始终处于活动状态的“全局模板”。通常,全局导航或页脚是很好的候选者,但任何你需要复制粘贴到每个组件中的内容可能最好放在全局模板中。

将标题元素替换为以下元素以定义路由出口。我已经为你加粗了更改:

<div class="content-container">
  <div class="content-area">
 **<router-outlet></router-outlet>**
  </div>
</div> 

当你运行应用程序时,访问 http://localhost:4200,你应该在屏幕上看到 NotFound 组件被渲染。在地址栏中输入 http://localhost:4200/login 并导航到该页面,你应该看到 Login 组件被渲染。

到目前为止,我们已经看到了基本的路由定义、通配符匹配器以及如何设置带有出口的路由库。每次使用路由时,你都会经历这些基本步骤。在某些应用程序中,你可能甚至不需要它就变得比这更复杂!

但大多数时候,你需要导航到不同的 URL 并传递信息,如 ID 以导航到特定项目。现在让我们看看如何构建具有参数的路由,同时查看如何使用功能模块与我们的应用程序一起使用。

7.3 功能模块和路由

Angular 功能模块的目的是创建易于维护的隔离代码,并将逻辑上相关的内容放在一起。当你有可以分割的应用程序的不同部分时,这被认为是一种最佳实践。我认为最重要的好处是代码的隔离,但它还可以帮助创建测试、优化构建过程以及实现路由的懒加载(这是我们目前更感兴趣的)。

功能模块可以包含功能集的路由定义(见图 7.3)。例如,我们的应用论坛部分应该是一个独立的功能模块,所有与论坛相关的路由都可以在其中定义。这样最好地实现了解耦,因此我们不需要在功能模块外部定义路由。

c07-3.png

图 7.3 路由由不同的模块定义。

本章示例有两个额外的模块:论坛模块和博客模块。论坛模块包含网站论坛部分的组件和服务,而博客模块有自己的组件和服务来渲染博客部分。它们已经在仓库中创建好了,如果你之前没有花时间研究功能模块,你可以查看 src/app/forums 和 src/app/blogs 目录中的它们。

我们可以在我们的功能模块中声明一些路由,但我们必须像上一步一样包含路由器并定义路由。我们将介绍一个关键的区别,但为了开始,打开 src/app/forums/forums.module.ts 文件并导入路由器模块:

import { RouterModule, Routes } from '@angular/router'; 

现在我们将创建另一个包含路由定义的数组。在最后的import语句之后,创建以下路由定义变量:

const forumsRoutes: Routes = [
  { path: 'forums', component: ForumsComponent }
]; 

这将定义一个路由,通过渲染论坛组件来显示网站上可用的论坛列表。现在我们需要将这个路由列表注册到路由器中,但我们需要使用稍微不同的语法来使其适用于功能模块。在论坛模块的imports部分,添加与加粗部分相同的路由器模块:

imports: [
  BrowserModule,
  FormsModule,
  ClarityModule.forChild(),
 **RouterModule.forChild(forumsRoutes),**
], 

在这里,我们使用forChild()而不是forRoot(),因为我们正在声明属于导入模块的路由,而不是主 App 模块的路由。否则,过程是相同的,路由定义遵循相同的规则。

功能模块已经作为导入之一包含在 App 模块中。你必须将功能模块导入主 App 模块才能激活它。一旦保存这些更改,你就可以通过访问 http://localhost:4200/forums 来查看论坛页面。

当你规划你的应用程序时,我发现最好的做法是寻找将功能组织到单独模块的机会。总是有可能在以后将事物移动到功能模块中,但如果你知道你将会有几个可以组织的关键功能,你可以在开发早期设置模块。

当尝试确定哪些事物属于一起时,我经常查看我期望我的应用程序拥有的 URL 结构。在本章示例中,我期望将功能放在/forums 和/blogs 作为两个独立的根路径下,因此它们是它们自己模块的好候选者。

7.4 路由参数

URL 被设计用来存储信息,有时这些信息会告诉你重要的细节,例如要加载的资源 ID 或其他状态数据。根据你想要如何结构化你的 URL,值可能是路径的一部分或查询的一部分。

我们将在稍后查看如何使用查询变量;现在让我们专注于如何使用 URL 参数来指示记录 ID。在我们的示例中,我们有一个论坛列表显示,我们希望点击一个论坛来查看其中的帖子。这些路由的 URL 可能如下所示:

/forums/1-announcement 

这里的不同之处在于,这个 URL 的第二部分将根据我们想要查看的论坛而变化,因为它包含别名(即 ID 和标题的组合)。Angular 通过路由参数支持这一点,这些参数易于实现。首先,让我们向我们的论坛模块添加一个新的路由,以表示带有参数的路径。打开src/app/forums/forums.module.ts文件,并将一个新的路由添加到forumsRoutes数组中,正如你在这里加粗看到的那样:

const forumsRoutes: Routes = [
  { path: 'forums', component: ForumsComponent },
 **{ path: 'forums/:forum_alias', component: ForumComponent }**
]; 

在这里,我们定义了一个带有参数的路径,这个参数是我们想声明的任何名称,前面跟着冒号(:)符号。这将尝试匹配以forums开头的任何路由,并且路径的第二部分可以是任何值,但如果路径有第三个部分,路由器就不会匹配到这个路由。

为了允许用户在应用程序中导航,我们需要创建与 Angular 路由器协同工作的链接。实现这一点的办法是使用 Angular 在你想成为可点击链接的元素上使用的特殊routerLink指令。

让我们在顶部导航栏中添加一个routerLink来链接到论坛页面,并查看如何使用基本实现创建链接。打开src/app/app.component.html并更新包含当前无效链接到论坛的行,使用routerLink,正如你在这里加粗看到的那样:

<a class="nav-link" **routerLink="/forums"**><span class="nav-text">Forums</span></a> 

在这个routerLink的使用中,它接受一个包含适当路径的字符串。如果路径以正斜杠开头,它将把 URL 视为从域名开始的绝对路径。它也可以是一个不带斜杠的相对路径。这可能是使用routerLink最常见的方式。

我们在第二章中看到了 routerLink,但让我们花点时间来谈谈它为什么存在以及它做了什么。为了方便导航,链接必须知道要访问哪个 URL,通常 href 是一个锚标签的属性,它向浏览器提供这个信息。当你使用 href 与链接一起时,浏览器将从服务器请求一个新的 URL,这不是我们想要的。在 Angular 中,routerLink 是一个属性指令,表示预期的导航路由,并允许 Angular 路由器处理实际的导航。简而言之,如果你使用 href 来链接到一个页面,即使它是一个有效的 Angular 路由,它也会触发从服务器加载页面,这比使用路由要慢得多。这是客户端路由的一个主要原则。

创建链接只需要这些,但你也可以使用 routerLink 绑定一个表达式来创建更动态的链接。为了演示这一点,我们将从论坛列表添加一个链接来加载单个论坛页面。打开 src/forums/forums/forums.component.html(是的,我知道文件路径有点冗余)并更新表格行中的 NgFor 以包含一个 routerLink 来链接到论坛:

<tr *ngFor="let forum of forums" **[routerLink]="[forum.alias]"**> 

你会注意到在这种情况下,我们正在绑定一个值到它(通过使用 [] 符号包裹)。当你绑定一个值时,它期望一个路径段数组,它将使用这个数组来构造完整的 URL。在这种情况下,我们在这个数组中设置了论坛别名值。默认情况下,它将路由视为相对于当前 URL 的相对路径,这意味着它将附加到当前路由。在这个页面上,URL 是 /forums,每个链接将通过别名路由到论坛,例如 /forums/1-announcements。

我喜欢用这种方式使用 routerLink,对于没有参数的任何路径,当有参数时,我喜欢将值数组绑定到 routerLink。我发现这样更容易阅读,但你可以根据自己的方法来决定。

注意,我们还在表格行元素上放置了一个 routerLink,这在传统上不是一个链接。Angular 足够智能,可以在任何带有 routerLink 的元素上添加正确的点击事件监听器来处理导航,所以你可以自由地使用它。但请注意,它不会向任何非锚标签的元素添加 href 属性。

7.4.2 在组件中访问路由参数

现在我们需要将路由参数信息传递到我们的论坛组件中,以便它知道要显示哪个论坛。通常,你会使用这个路由参数信息来调用你的某个服务来加载数据,我们在这里也会这样做。

Angular 提供了一个包含当前活动路由元数据的服务的功能。它为你提供了丰富的信息,详情请参阅 angular.io/api/router/ActivatedRoute。它包含诸如当前 URL、查询或路由参数(及其当前值)、任何子路由或父路由的信息等详细信息。

使用此服务,我们将访问当前的参数信息。打开 src/app/forums/forum/forum.component.ts,并按以下列表更新类。它将注入新的服务并处理获取路由参数的访问。

列表 7.1 论坛组件获取路由参数

export class ForumComponent implements OnInit {
  forum: Forum;

 constructor(
 private route: ActivatedRoute,
 private router: Router,
    private forumsService: ForumsService) { }

  ngOnInit() {
 this.route.params.subscribe((params: Params) => {
 [this.forum = this.forumsService.forum(params['forum_alias']);](#c07-codeannotation-0003)
 [if (!this.forum) this.router.navigate(['/not-found']);](#c07-codeannotation-0004)
 });
  }
} 

当用户导航到如/forums/1-announcements 之类的页面时,该组件会被激活。我们首先将活动路由注入到route属性中,将路由服务注入到router属性中。您通常需要在文件顶部导入它们,但应该已经为您完成了。

OnInit内部,我们创建了一个订阅来监听参数何时发生变化。ActivatedRoute返回的许多值都作为可观察对象公开,因此您需要订阅以获取值。主要原因是当您有嵌套路由,其中父路由和子路由同时激活时,父组件可以订阅以获取任何子路由加载时的更新。

您订阅的params可观察对象将返回一个Params对象类型,这允许您访问诸如params['forum_alias']之类的属性。然后我们使用我们的服务通过传递 params 中的论坛别名来获取请求的论坛,并将其设置为forum属性。

仅因为向组件提供了参数,并不能保证提供的别名是有效的,这是我们应处理的情况。因此,如果论坛不存在,我们将用户重定向到未找到页面。

在本章的其余部分,我们将在需要访问参数时随时编写相同的基基本活动路由可观察对象。还有其他方法可以访问页面上的当前参数,但它们有一些弱点,通常不使用或推荐。一个例子是使用 promise 而不是可观察对象一次性获取值,但缺点是您只会得到一次参数,如果路由改变而没有重新创建组件,组件就不会知道新的参数。

如果您熟悉可观察对象,您可能已经注意到我们没有从params可观察对象中取消订阅,因为可观察对象通常在组件被销毁后持续存在,就像事件监听器一样。通常,您应该手动取消订阅,否则您最终会得到内存泄漏,但在这个情况下这是允许的,因为 Angular 会在活动路由不再活动时立即销毁此可观察对象。如果您愿意,您可以在组件的OnDestroy钩子中取消订阅。

在这一点上,如果您访问 http://localhost:4200/forums,您应该能够点击一个论坛,并看到该论坛的标题出现在一个新页面中,URL 也会改变。尝试几个论坛以确保值根据您查看的论坛而变化。

现在我们需要查看单个论坛中的线程,我们将使用子路由来帮助我们定义这些与特定论坛组织上相关的路由。

7.5 子路由

每个路由只有一个组件可能会有些限制,并且可能会在多个组件之间重新声明常用部分,从而增加额外的复杂性。我们讨论了顶级导航栏如何在所有路由中保持活跃和可见,因为它存在于路由出口之外。我们可以将这个相同的原理应用到我们的路由上,这样路由的一部分即使在您在子路由之间导航时也会保持活跃。

例如,在我们的应用程序中有一个包含论坛标题头部的 Forum 组件(图 7.4)。无论您是在查看线程列表还是在查看特定线程,它都将保持活跃。您可以想象这个头部栏也可以包含常见功能,例如在此论坛中创建新线程的按钮或报告垃圾邮件的按钮。

c07-4.png

图 7.4 — 子路由在父组件内部渲染,正如您在这里看到的,Forums 组件托管着 Forum 组件。

我们刚刚定义了一条路径,路径为 /forums/:forum_alias。当我们开始查看我们论坛中的特定线程时,我们将继续使用这个路径基础,并且我们希望它看起来像这样:/forums/:forum_alias/:thread_alias。当你查看这里的 URL 结构时,它显示你期望论坛别名和线程别名都能正确导航,这是一个使用子路由的好地方。

基于此,您可以看到子路由始终共享其父路由的路径,并为新的子路由扩展它。以下是一个基本列表,显示了这些不同路由之间的关系:

  • /forums — 论坛组件,顶级路由

  • /forums/:forum_alias — 子路由,用于显示特定的论坛

  • /forums/:forum_alias/:thread_alias — 也是特定论坛的子路由,显示特定的线程

子路由通过创建另一个本地化的父组件的路由出口来工作,然后所有子路由都将在这个新的路由出口内部渲染。图 7.4 展示了在我们的例子中嵌套路由出口将如何工作,通过有两个路由和组件同时活跃。尽管没有限制您可以有嵌套路由出口的数量,但根据我的经验,我建议不要超过三个,因为定义更多子路由的正确路由会变得更加具有挑战性。

让我们从添加这个新的路由出口开始,这应该有助于您看到子路由将去哪里,然后我们将定义这些路由。打开 src/app/forums/forum/forum.component.html,并在文件底部添加一个新的路由出口:

<header class="header">
  <div class="branding">
    <span class="title">{{forum?.title}}</span>
  </div>
</header>
**<router-outlet></router-outlet>** 

这定义了任何子组件将被渲染的位置,所以即使在子组件路由活跃时,标题也会始终被渲染。现在你可以将我们的路由视为两个级别:App 组件中的路由出口和这个本地化的路由出口。

接下来,我们将定义我们的路由,然后我们可以退后一步,看看一切是如何结合在一起的。打开 src/app/forums/forums.module.ts 并更新 forumsRoutes 以匹配以下列表。

列表 7.2 带有子路由的论坛模块路由

const forumsRoutes: Routes = 
  { path: 'forums', component: ForumsComponent },
  { 
    path: 'forums/:forum_alias', 
    component: ForumComponent,
 [children: [
 { path: '', component: ThreadsComponent },
 { path: ':thread_alias', component: ThreadComponent }
 ]
  }
]; 

在这里,我们为 forums/:forum_alias 路由添加了一个新的 children 属性。这就是我们表示我们想要加载到论坛组件的新路由出口中的路由的方式。

我们添加了两个路由。第一个有一个空路径,当我们在父路由上时将渲染。这将显示属于论坛的线程列表。第二个是通过接受线程别名参数来显示单个线程。这两个路由现在是论坛路由的子路由。

子路由也有助于说明在获取路由参数时使用可观察对象的重要性。父组件即使在子组件可能被销毁的情况下仍然保持活跃和可见。

现在我们可以完善 Thread 和 Threads 组件的功能,以便加载正确的数据并链接到线程。我们首先帮助 Threads 组件加载要显示的正确线程列表。打开 src/app/forums/threads/threads.component.ts 并按照以下列表进行更新。

列表 7.3 线程组件获取线程列表

export class ThreadsComponent implements OnInit {
  threads: Thread[];

 constructor(private route: ActivatedRoute, private forumsService: ForumsService) { }

 ngOnInit() {
 this.route.params.subscribe((params: Params) => {
 [this.threads = this.forumsService.forum(params['forum_alias']).threads;](#c07-codeannotation-0008)
 });
 }
} 

这几乎与我们从 Forum 组件获取参数和加载数据的方式相同。唯一的真正区别是我们从服务中获取线程而不是论坛数据。这种模式将会重复,我不会过多地详细说明。

接下来,打开 src/app/forums/threads/threads.component.html 文件,我们将为单个线程路由添加一个 routerLink。用以下内容替换现有的表格行:

<tr *ngFor="let thread of threads" [routerLink]="[thread.alias]"> 

注意,这又是一个相对链接,所以它将线程别名附加到当前 URL 上,就像 /forums/:forum_alias/:thread_alias。这将激活正确的路由。

现在我们能够导航到特定的线程,我们只需更新 Thread 组件以获取活动路由,以便访问参数。由于路由的激活方式,这会有所不同,让我们看看。打开 src/app/forums/thread/thread.component.ts 并按照以下列表进行更新。

列表 7.4 线程组件加载线程并访问父路由

export class ThreadComponent implements OnInit {
  forum: Forum;
  thread: Thread;

  constructor(private route: ActivatedRoute, private forumsService: ForumsService) { }     

 ngOnInit() {
 this.route.params.subscribe((params: Params) => {
 [let forum = this.route.snapshot.parent.params['forum_alias'];](#c07-codeannotation-0010)
 [this.thread = this.forumsService.thread(forum, params['thread_alias']);](#c07-codeannotation-0011)
 });
  }
} 

如往常一样,我们注入活动路由并订阅 params。但在本路由中,我们有访问父路由信息的能力,因为当前活动路由信息不包含我们需要的所有参数。活动路由信息包含这些信息,所以我们查看 snapshot 属性以深入了解父路由。一旦我们得到它,我们就使用服务来加载线程的数据。

你可能会想知道为什么我们不需要对其他子路由做同样的事情。因为这个子路由没有路径(记住我们定义的路径为 ''),它将与父路由共享 params 和路径。在设计 URL 时,你需要记住这一点。

确保你已经保存了更改并再次预览应用程序。在这个时候,你应该能够点击顶部导航栏中的论坛链接来查看论坛,选择一个论坛进行查看,然后选择一个特定的线程。我们已经通过添加一个新的路由出口并将它们定义为该路由的子路由来创建了两个子路由。

有时你需要的东西类似于子路由,但又与特定的父路由断开连接。让我们深入了解二级路由,讨论它们是什么,以及如何使用它们。

7.6 二级路由

在桌面应用程序中,电子邮件应用程序(如 Outlook 或 Apple Mail)通常会打开一个新窗口来编写新电子邮件,用户可以在窗口之间切换以继续工作。Gmail 有一个功能允许你在应用程序内部的一个小窗口中创建新电子邮件,同时继续在应用程序的其他部分导航——这与二级路由的概念相同。二级路由旨在允许用户在继续使用电子邮件应用程序的同时草拟电子邮件。

这些是二级路由的例子,有时也称为 辅助 路由,其中应用程序的一部分出现,但保持与主应用程序不同的状态。聊天、帮助或文档窗口也是二级路由的例子。

Angular 支持这些类型的路由,它们的行为就像为一系列路由有一个新的根级路由出口。虽然通常一个就足够满足大多数用例,但你仍然可以创建多个二级路由出口。二级路由的规则基本上与其他路由相同,但有时我们可能需要指定额外的细节以确保使用正确的路由出口,如图 7.5 所示。

c07-5.png

图 7.5 主出口和二级路由出口可以同时激活,并且可以独立于彼此改变它们的路由状态。

在这个例子中,我们想要有一个聊天功能,允许任何用户打开一个与另一个用户的聊天框,就像你在图 7.5 中看到的那样。这将位于当前页面的顶部,以便用户可以继续浏览网站并阅读论坛或博客文章。

7.6.1 定义二级路由

打开 src/app/app.component.html 并在文件底部添加一行,包含另一个路由出口。这个出口有一个 name 属性,允许我们将加载的路线目标到这个出口而不是主出口:

<router-outlet name="chat"></router-outlet> 

接下来,我们需要定义一些将附加到这个路由的路线。它们如下所示:

  • /users 这将显示你可以与之聊天的用户列表。

  • */users/:username —* 这将是与其他用户聊天的体验。

打开 src/app/app.module.ts 并更新 appRoutes 以包含这两个新路由,正如你在这里加粗看到的那样:

const appRoutes: Routes = [
  { path: 'login', component: LoginComponent },
  **{ path: 'users', component: ChatListComponent, outlet: 'chat', },**
 **{ path: 'users/:username', component: ChatComponent, outlet: 'chat', },**
  { path: '', redirectTo: '/forums', pathMatch: 'full' },
  { path: '**', component: NotFoundComponent },
]; 

注意它们有一个路径和组件,就像其他路由一样,但我们还声明了 outlet 属性。这就是我们告诉路由器在名为 chat 的新路由出口中渲染此组件的方式。注意我们也可以在二级路由中使用路由参数,因此我们将能够以相同的方式访问这些参数。我还将这些路由放在重定向和通配符路由之前,因为那些路由旨在充当后备路由。

应用程序右上角有两个按钮:一个用于聊天的语音气泡按钮和一个注销按钮(我们很快将实现)。我们想在语音气泡按钮上添加一个 routerLink 以打开这个聊天框。回到 app.component.html 文件并更新围绕图标的链接,正如你在这里加粗看到的那样:

<a class="nav-link nav-icon" **[routerLink]="[{outlets: { chat: ['users']}}]"**>
        <clr-icon shape="talk-bubbles"></clr-icon>
      </a> 

在这里,我们使用 routerLink 属性的绑定版本,并传递一个包含对象的数组。这里的语法更冗长,因为我们必须传递额外的数据以便路由器理解这是一个二级路由。该对象包含 outlets 属性,它然后有一个将出口名称映射到特定路由请求的对象。在这种情况下,我们传递 {chat: ['users']},这告诉它使用聊天出口,然后转到用户路径。这种模式将重复用于使用二级路由,因此你将看到更多结构示例。

到目前为止,我们有一个二级路由和触发它的方法。查看应用程序并点击右上角的语音气泡图标以打开一个包含用户列表的新窗口。它允许你选择一个要交谈的用户,但不会导航到与该用户的聊天窗口。现在让我们添加在二级路由内导航的能力。

7.6.2 在二级路由之间导航

一旦你在二级路由中渲染组件,你仍然可以使用相同的规则将不同的路由链接在一起。只要使用相对链接,你就不必指定出口。路由的上下文将由路由器理解,这将有助于简化你的链接。

只要提供绝对路径(例如 /forums),你就可以将主路由从二级路由更改。从二级路由使用绝对路径将更改主出口,但二级路由将保持在同一路由上。

要使这个功能正常工作,让我们打开 src/app/chat-list/chat-list.component.html 并更新链接以启动聊天。当你点击一个用户名时,它会设置一个名为 talkTo 的属性,并带有该用户名。然后我们希望导航到一个带有该用户名的相对路径,因为我们定义了二级路由为 /users/:username。向链接中添加加粗的 routerLink

<a class="btn btn-sm btn-link btn-primary" *ngIf="talkTo" **[routerLink]="[talkTo]"**>Start Chat with {{talkTo}}</a> 

绑定现在将用户导航到聊天组件,与该用户进行有趣的讨论。但聊天组件还没有访问路由参数来知道与哪个用户交谈,因此我们需要将其添加到我们的聊天组件中。

打开 src/app/chat/chat.component.ts 并更新构造函数和 ngOnInit,如下所示。控制器的其余部分专注于处理聊天体验。

列表 7.5 聊天组件访问路由参数

 constructor(
 private route: ActivatedRoute,
 private router: Router,
    private chatBotService: ChatBotService,
    private userService: UserService) { }

 ngOnInit() {
 this.route.params.subscribe((params: Params) => {
 [this.messages = [];](#c07-codeannotation-0013)
 this.user = this.userService.getUser();
 [this.guest = params['username'];](#c07-codeannotation-0013)
 });
 } 

这并没有什么新奇的,除了它设置了组件模型以拥有新的聊天体验。你可以查看组件控制器的其余部分以了解其行为,但在这个阶段,聊天框将开始正常工作。你可以输入一条消息并按 Enter,然后三秒钟后,另一个“用户”将回复一些轶事。这显然不是一个真实的聊天体验,但我想要让它更真实。

7.6.3 关闭次要路由和程序化路由

所有美好的事物都有结束的时候,在某个时候你将需要退出这个次要路由。例如,你可能会在某个时候结束聊天会话。我们可以关闭次要路由,如果我们需要它回来,我们只需再次打开它。要关闭次要路由,你只需将当前路由设置为 null,这样就会将其移除。

我还想向你展示如何使用程序化(或命令式)路由。有时你可能需要控制器或服务为你更改路由,而不是总是等待用户点击带有 routerLink 的元素。

如果你查看聊天和聊天列表组件模板,你会注意到文件顶部附近有一个关闭按钮。目前它有一个点击处理程序来调用 close() 方法,该方法目前是空的。虽然你可以使用 routerLink,但我想在这里演示程序化导航。

打开 src/app/chat/chat.component.tssrc/app/chat-list/chat-list.component.ts 文件,并更新 close 方法,如下所示。你还需要确保在构造函数中正确注入路由(提示:聊天组件已经有了,但聊天列表没有):

close() {
  this.router.navigate([{outlets: {chat: null}}]);
} 

路由服务提供了一个 navigate 方法,其语法与 routerLink 完全相同。在这种情况下,我们声明聊天出口应导航到 null,这告诉 Angular 移除次要路由。我们将在下一个程序化路由用例中看到,但我通常发现尽可能使用 routerLink 而不是程序化路由是最好的。

现在次要路由已被清除,没有其他清理工作。它将从应用程序中移除,要再次打开它,只需用一个新的有效路由重新激活即可。

我建议你在它们提供最大价值时才使用二级路由。在正确的情况下,它们非常有用,但最好保持简单。类似于避免过多的嵌套子路由,添加过多的二级路由会增加可以避免的复杂性。

我们现在有点问题。我们允许任何用户无论是否登录都可以打开与其他用户的聊天窗口。使用路由,我们可以通过满足某些条件来保护路由不被激活,从而防止未经授权的访问。

7.7 路由守卫以限制访问

Angular 允许你控制允许路由渲染的条件,这通常是为了防止应用程序进入不良状态。例如,你不应该允许未经认证的用户查看需要他们登录的应用程序部分,否则他们可能会遇到很多错误。

守卫就像路由更改的生命周期钩子,允许应用程序在更改发生之前验证某些条件或加载数据,正如你在图 7.6 中看到的那样。当触发路由更改(无论是用户点击链接还是应用程序程序化更改路由)时,Angular 将检查是否注册了任何守卫。例如,如果未经认证的用户尝试加载显示已登录用户账户详情的页面,应用程序应实现一个守卫来在激活路由之前检查用户的登录状态。术语激活用来描述路由是否允许加载。

c07-6.png

图 7.6 守卫在路由激活之前运行,可以阻止访问。

有几种类型的守卫,在管理路由激活时提供了不同的选项。它们被定义为服务,然后链接到特定的路由以生效。这允许你多次使用相同的守卫。以下是五种类型的守卫及其基本角色:

  • CanActivate—用于确定路由是否可以被激活(例如用户验证)

  • CanActivateChild—与 CanActivate 相同,但专门用于子路由

  • CanDeactivate—用于确定当前路由是否可以被停用(例如,防止在不确认的情况下离开未保存的表单)

  • CanLoad—用于在加载之前确定用户是否可以导航到懒加载的模块

  • Resolve—用于访问路由数据并将数据传递给组件的提供者列表

我们将实现一个针对聊天路由的守卫,以便用户必须先登录。从该列表中,CanActivate守卫是我们完成此任务的最佳选择。

我们将生成一个新的服务来解决此问题,因此运行以下命令来设置AuthGuard服务:

ng generate service services/auth-guard 

现在打开src/app/services/auth-guard.service.ts文件,并用以下列表中的代码替换其内容。

列表 7.6 限制路由访问的 AuthGuard 服务

import { Injectable } from '@angular/core';     
import { CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';     
import { UserService } from './user.service';     

@Injectable()
export class AuthGuardService implements CanActivate {     
 constructor(
 private userService: UserService,
 private router: Router) {}

 canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
 if (!this.userService.isGuest()) {
 return true;
 } else {
 [this.router.navigate(['/login'], {](#c07-codeannotation-0019)
 queryParams: {
 return: state.url
 }
 });
 return false;
 }
  }
} 

它就像一个正常的服务一样开始,通过导入依赖项,我们想要实现 CanActivate 接口以确保我们正确设置了我们的服务。构造函数将 UserServiceRouter 注入到对象中,这允许我们在服务内部访问路由器。

canActivate 方法必须使用此名称实现,因为 Angular 正在期待这种方式。这就是为什么接口实现是有用的;如果设置不正确,它将警告你。它接收两个参数:首先是当前激活路由的快照,其次是应用程序尝试激活的新路由。在这个例子中,我只使用了新路由的元数据,但它们允许你检查当前路由和请求的路由详细信息。

一旦进入方法内部,我们检查 UserService 以查看用户是否已登录。如果是,我们返回 true 告诉守卫允许激活路由是可接受的。否则,我们告诉路由导航到登录路由,并且我们还传递一个包含一些元数据的对象。我们稍后会更详细地查看它。

到目前为止,我们已经实现了守卫以返回 truefalse,这取决于用户是否有效。如果不是,它将重定向到登录屏幕。在登录过程之后,我们希望将他们重定向回他们尝试查看的页面,这就是元数据对象发挥作用的地方。让我们再次看看它:

this.router.navigate(['/login'], {
  queryParams: { 
    return: state.url
  }
}); 

路由器接受一个可选对象作为第二个参数,在这里我们使用它来设置一个查询参数。这些变量位于问号之后的 URL 中,就像这里加粗显示的那样:

/login**?return=/forums(chat:user).** 

我们使用它来记住用户尝试导航到的 URL,通过查看 state.url 属性(这是路由器尝试激活但未能激活的路由)。这是登录流程中的常见模式,你当然也可以以相同的方式使用查询参数来处理许多其他原因。

这就是我们的守卫;现在我们需要将其应用到一些路由上。为此,我们将更新 src/app/app.module.ts 文件中的 appRoutes;以下内容需要加粗。我们需要首先导入新的 AuthGuardService,然后为路由添加一个新的 canActivate 属性以进行安全保护:

**import { AuthGuardService } from './services/auth-guard.service';**

const appRoutes: Routes = [
  { path: 'login', component: LoginComponent },
  { path: 'users', component: ChatListComponent, outlet: 'chat', **canActivate: [AuthGuardService]** },
  { path: 'users/:username', component: ChatComponent, outlet: 'chat', **canActivate: [AuthGuardService]** },
  { path: '', redirectTo: '/forums', pathMatch: 'full' },
  { path: '**', component: NotFoundComponent },
]; 

我们还需要将 AuthGuardService 添加到我们的应用程序提供者列表中,以便应用程序知道它的存在。像这样在模块提供者数组中添加它:

 providers: [
 **AuthGuardService,**
    UserService
  ], 

我们现在已将服务作为守卫附加到我们的聊天路由上。如果你预览应用程序并尝试打开聊天,它将重定向你到登录屏幕。守卫在聊天路由完全激活之前触发,因此组件永远不会渲染,所以你不必担心该组件有未经认证的用户。

我们希望更新登录组件,以帮助用户在登录后重定向回请求的 URL。对于这个登录表单,你可以使用任何值。只要输入任何随机的用户名和密码,它就高兴。它还会将登录持久化到 localStorage 中,所以它会记住你。使用以下列表中的内容更新src/app/login/login.component.ts文件。

列表 7.7 登录组件,登录后重定向

import { Component } from '@angular/core';     
import { UserService } from '../services/user.service';     
import { Router, ActivatedRoute } from '@angular/router';     

@Component({
  selector: 'app-login',
  templateUrl: './login.component.html',
  styleUrls: ['./login.component.css']
})
export class LoginComponent {
  username: string = '';
  password: string = '';
  return: string = '';

 constructor(
 private userService: UserService,
 private router: Router,
 private route: ActivatedRoute) {}

 ngOnInit() {
 this.route.queryParams
 .subscribe(params: Query => {
 [this.return = params['return'] || '/forums';](#c07-codeannotation-0022)
 if (!this.userService.isGuest()) {
 this.go();
 }
 });
 }

 login() {
 if (this.username && this.password) {
 this.userService.login(this.username);
 this.go();
 }
 }

 private go() {
 this.router.navigateByUrl(this.return);
 }
} 

在导入依赖项并将服务注入到组件中之后,你将实现另一个可观察对象来获取queryParams,就像你获取路由params一样。因为这是一个不同类型的值,你必须订阅激活路由的不同属性。

在回调内部,从 URL(或如果不存在,则设置为/forums)获取返回 URL。然后它检查用户是否已经登录,并立即重定向到页面。这是为了帮助那些已经登录但意外地 landed on the login page 的人。

当用户点击登录按钮时,组件会调用login()方法,并通过调用用户服务和重定向来处理用户登录。然后go()方法调用router.navigateByUrl()方法,这与典型的router.navigate()方法不同,因为它接受一个字符串。因为我们从 URL 获取返回路径,所以它将依赖于路由来解析 URL 并确定正确的 URL。

现在,我们需要更新注销功能,这将从UserService中移除当前用户状态。服务已经存在来处理这个问题,但我们需要启用按钮以调用该服务。

首先打开src/app/app.component.ts并更新注销按钮的链接,就像这里加粗显示的那样:

<a class="nav-link nav-icon" **(click)="logout()" *ngIf="!userService.isGuest()"**>
  <clr-icon shape="logout"></clr-icon>
</a> 

如果用户是访客,这将禁用注销按钮,并调用logout方法来处理注销。我们可能遇到的问题是,如果你已经激活了聊天出口,然后点击注销,它不会关闭次要聊天出口。那是因为它只在路由激活时进行检查,所以我们需要手动检查它是否打开并关闭它。

为了解决这个问题,我们需要对UserService进行一些小的修改。打开src/app/services/user.service.ts。首先确保导入 Router 和 ActivatedRoute 服务:

import { Router } from '@angular/router'; 

然后我们想要像这里看到的那样将这些依赖注入到构造函数中:

constructor(private router: Router) {} 

最后,我们需要更新带有加粗部分的logout方法,这将始终确保在注销时关闭路由出口:

logout() {
  username = '';
  guest = true;
  localStorage.setItem('username', '');
 **this.router.navigate([{outlets: {chat: null}}]);**
} 

这是之前用来关闭聊天出口的相同代码,我们在这里运行它以确保出口被关闭。如果它目前不活跃,则不会发生任何事情。

当你必须处理应用程序状态,如已登录用户或表单时,你很可能会需要守卫。它们非常有帮助,但像大多数事情一样,最好适量使用。你可以在同一路由上拥有多个守卫,但守卫越多,跟踪应用程序逻辑就越困难。

另一方面,你应该确保使用守卫而不是直接在组件控制器中添加逻辑来检查用户是否可以激活路由。组件应尽可能少地关注路由配置(显然的例外是从 URL 中获取参数信息)。

我们最后一个主要功能是将 Angular 模块仅在需要时懒加载到应用程序中,这可以提升大型应用程序的初始加载时间。

7.8 懒加载

如果你正在构建应用程序并使用功能模块来组织代码,那么你可以使用懒加载(或异步加载)与路由(图 7.7)。这样做将允许整个模块仅在需要时加载,这可以节省核心包的文件大小,甚至可能限制只有有权使用它的人(如管理模块)的访问。

如果你还没有将你的应用程序组织成不同的模块,那么将它们懒加载到应用程序中是不可能的,因为没有逻辑隔离。基本思想是 Angular 构建过程可以分析代码路径并根据其使用情况优化代码,以生成不同的文件,但它依赖于 Angular 模块作为确定代码关系的主要方式。

c07-7.png

图 7.7 懒加载模块仅在请求路由时发生,并且必须在路由激活之前加载。

在我们的章节示例中,我们有三个模块(不包括 Angular 或第三方模块):

  • AppModule

  • ForumsModule

  • BlogsModule

AppModule 是我们的主要应用程序,ForumsModule 直接导入到 AppModule 中。但我们还没有导入 BlogsModule,当我们想要懒加载时,我们不想直接将模块导入到我们的应用程序中。

让我们通过设置 BlogsModule 来自行查看这个功能。打开 src/app/app.module.ts 并更新路由,如下所示(加粗):

const appRoutes: Routes = [
  { path: 'login', component: LoginComponent },
  { path: 'users', component: ChatListComponent, outlet: 'chat', canActivate: [ AuthGuardService ] },
  { path: 'users/:username', component: ChatComponent, outlet: 'chat', canActivate: [ AuthGuardService ] },
 **{ path: 'blogs', loadChildren: 'app/blogs/blogs.module#BlogsModule' },**
  { path: '', redirectTo: '/forums', pathMatch: 'full' },
  { path: '**', component: NotFoundComponent },
]; 

新的路由包含一个路径和博客,loadChildren属性用于定义当浏览器尝试访问以/blogs 开头的任何 URL 时应该懒加载的模块的路径。loadChildren属性接受一个指向实际模块文件的文件路径,从 src 目录开始,不包括.ts 扩展名。然后有一个哈希(#)符号,后面跟着模块名称。这种特殊语法让 Angular 知道该模块的位置和名称,以便它可以正确地创建它。

这是你需要做的所有配置,以启用模块的懒加载,但我们还需要对 BlogsModule 做一些工作,以便它按预期工作。到目前为止,它还没有声明任何路由,这意味着这还不能工作。

AppModuleForumsModule中,我们在文件中创建了一个变量,并将路由存储在同一个文件中。但你也可以创建一个所谓的路由模块来存储这个配置。如果你的配置很复杂,并且你想要保持你的模块文件更干净,这可能是有利的。

我个人发现,如果我的应用程序很小,路由模块只是额外的文件,但当我的应用程序变大时,它们就更有意义了。我认为保持声明路由的一致性比选择哪种路由更重要。但如果你需要一个通用的指南,那么我建议如果你有超过三个模块,就使用路由模块。选择权在你。

当你使用 Angular CLI 创建模块时,你可以通过添加--routing标志来让它为你生成一个路由模块:

ng generate module mymodule --routing 

由于 BlogsModule 已经存在,我们需要手动为博客模块创建路由模块。在src/app/blogs下创建一个新的文件blogs-routing.module.ts,并添加以下列表中的内容。

列表 7.8 博客路由模块

import { NgModule } from '@angular/core';     
import { Routes, RouterModule } from '@angular/router';     

import { BlogsComponent } from './blogs/blogs.component';     
import { BlogComponent } from './blog/blog.component';     

const routes: Routes = [     
 { path: '', component: BlogsComponent },
 { path: ':post_id', component: BlogComponent }
[];     ](#c07-codeannotation-0026)

@NgModule({
 [imports: [RouterModule.forChild(routes)],](#c07-codeannotation-0027)
 [exports: [RouterModule],](#c07-codeannotation-0027)
  providers: []
})
export class BlogsRoutingModule { } 

这个模块与普通模块有点不同,因为它只专注于设置路由所需的必要部分。在你导入所有依赖项之后,你仍然会声明一个包含路由的变量,就像在其他示例中那样。注意,这些路径不包含 blogs 前缀,因为我们已经在我们的懒加载路由定义中定义了该前缀。这些路由很简单,因为它们映射到一个要渲染的组件。

最后,我们在模块的imports中声明了RouterModule.forChild()方法,就像我们在ForumsModule中做的那样。然后我们也在exports中添加了 RouterModule,这使得路由指令可以用于这个模块的模板。

我们在这里没有声明任何重定向或回退路由。那是因为我们在AppModule中定义的那个仍然会捕获任何未知的 URL。路由可以在应用程序的任何地方声明,Angular 会尝试匹配到最佳的 URL,这展示了良好 URL 规划的重要性。

为了使用路由模块,我们需要将其导入到我们的 BlogsModule 中。打开src/app/blogs/blogs.module.ts并在此处添加新的导入行:

import { BlogsRoutingModule } from './blogs-routing.module'; 

然后我们需要像这样将其添加到模块的导入中:

 imports: [
    CommonModule,
    BlogsRoutingModule,
  ], 

这样就完成了路由模块的设置,如果你是用 CLI 生成的,那么文件已经为你准备好了,你可以添加你的路由。现在我们想要通过在导航栏的博客部分添加一个链接来使用 BlogsModule。已经有一个链接了,所以我们只需要添加routerLink。打开src/app/app.component.html并更新链接到博客:

<a class="nav-link" **routerLink="/blogs"**><span class="nav-text">Blogs</span></a> 

现在当你运行应用程序并点击导航栏中的“博客”时,它将带你进入“博客”部分。如果你打开开发者工具,你还可以观察 HTTP 请求,以查看博客模块是按需异步加载的。当你本地工作时,这发生得非常快,所以不应该有任何延迟,但从远程服务器下载该文件会有轻微的延迟。

博客组件的其他部分已经为你设置了 routerLink,但如果你愿意,可以停下来回顾一下。那里没有我们之前没有做过几次的新内容。

这就总结了你可能需要在大多数应用程序中使用的主要 Angular 路由功能。尽管有一些功能我没有涵盖到,但我很少有机会使用本章未详细说明的功能。

在结束这一章之前,我想回顾一下我从经验中总结的一些关于构建良好路由和使用 Angular 路由的最佳实践。

7.9 路由最佳实践

关于如何设计应用程序的 URL 结构,已经有很多争论,我对构成良好设计的一些方面有特别强烈的看法。也有人争论 URL 设计是否真的那么重要,因为大多数时候用户不会在地址栏中输入 URL。

我不能强迫你遵循任何特定的规则,但我确实想与你分享我的信念和经验。我相信 URL 设计对于优质应用程序至关重要。良好的 URL 结构使应用程序更容易维护,可以帮助用户导航你的网站,并应有助于维护导航状态。就像你可能花时间规划页面布局一样,你也应该花时间规划应用程序的 URL 结构。

我建议以下作为最佳实践,这些实践我已经在我的应用程序中实施,并相信它们最有可能为你提供一个坚实的基础:

  • 保持 URL 短小 — 当可能时,URL 应该只长到需要那么长。随着应用程序的增长,这可能有点棘手,所以从一开始就保持警惕是保持进度最佳的方式。你的 URL 可能需要考虑一些 SEO 因素,我建议你保持简单。

  • 优先使用路径参数 — 查询变量的使用应限制于短暂的状态数据,例如搜索过滤器变量。当你使用 URL 中的 ID 或资源别名时,它们几乎总是路径参数。

  • 倾向于拼写单词 — 我强烈倾向于不缩写变量或路径,因为这更难阅读,而且一些缩写并不总是清晰易懂。尽管你应该保持 URL 短小,但你不应该缩短你使用的单词。

  • 使用连字符来分隔多词—URL 的可读性很重要,如果你将多个单词放在一起(例如 /forums/2-introductions/89-cloned-didactic-knowledge-user),在单词之间使用连字符是最容易阅读的方式。这也便于解析。

  • 限制使用二级路由—虽然二级路由很有趣,但它们增加了复杂性。如果一个二级路由只与某些应用程序路由相关,那么你就需要跟踪何时启用或禁用该二级路由。例如,如果你有一个银行应用程序,并且有一个用于开设新账户的二级路由,你需要注意用户的行为,并确定何时可能适合关闭它。

  • 仔细考虑为你的用例选择正确的守卫—有五种类型的守卫,有时它们可以弯曲以执行不同的任务。要清楚你的守卫的目的,并使用正确的守卫。

  • 使用功能模块以便可以使用懒加载—功能模块对于代码隔离很有帮助,但能够懒加载模块非常有用。与后来不得不重构以使用懒加载相比,从功能模块开始而不使用懒加载要容易得多。

  • 保持简单—这一点适用于所有事物,但我认为这是路由中最重要的基本原则。如果你只需要 10 条具有更好参数的路由,而你却有了 100 条路由,事情会迅速失控。时刻寻找简化所有事物的途径!

Angular 路由器的规则相当直接,大部分复杂性都来自于这些功能的组合。这就是 Angular 路由器及其设计的内在力量。它既简单又能够处理复杂场景。我警告不要无谓地将简单场景变得复杂,但一旦你对路由器有所掌握,你将能够构建处理任何用例的路由。

摘要

Angular 路由器非常强大,但其基本原理足够简单,可以快速掌握并付诸实践。我们已经讨论了服务的大量功能及其工作方式。服务对于 Angular 应用程序是基本的,你将构建或使用许多服务。以下是你学到的快速回顾:

  • RouterModule 需要包含在任何使用路由的模块中。当你将其设置在 AppModule 中时,你调用forRoot()方法来为应用程序设置它。

  • 路由定义为包含适用于这些路由的属性的数组,例如路径、要渲染的组件和/或子路由。

  • 路由可以接受参数,无论是路径还是作为查询变量,这样你就可以使用不同参数重用路由。这些参数通过激活路由详情提供给组件控制器。

  • 使用 routerLink,你可以将任何元素链接到特定的路由。它可以提供一个包含有效路径的基本字符串,或者你也可以使用绑定语法来传递作为数组的额外数据。

  • 子路由在父组件内部渲染,并允许你重用代码,例如共享通用导航或解析数据。

  • 次级路由对于创建独立且与主路由分离的路由很有用。这对于无论你在哪个页面上都活跃的体验很有用,例如文档窗口。

  • 使用守卫来限制对路由的访问,在激活路由之前解析数据,或防止路由被停用。你必须创建一个服务并将其包含在路由定义中,以启用守卫。

  • 功能模块可以被懒加载到应用程序中,这让你能够减少最初下载给用户的代码文件大小。它仅在用户导航到属于功能模块的路由时才加载模块。

在下一章中,我们将深入探讨表单,并使其易于捕获用户输入。

8

构建自定义指令和管道

本章涵盖

  • 如何创建你自己的指令

  • 结构性指令和属性指令之间的区别

  • 如何使用指令来修改另一个组件

  • 如何制作自定义管道

  • 管道纯度是什么以及如何设计有状态或无状态的管道

Angular 附带了许多指令和管道,以覆盖最广泛的使用场景。第三章介绍了默认设置,因此你可以回顾它们是什么以及如何使用。这包括 NgFor 和 NgIf 等指令,用于遍历列表或条件显示项目,以及 Currency 和 Number 等管道,用于格式化货币或数字显示。

本章重点介绍创建自定义指令和管道,并讨论为什么你可能需要创建它们。内置的管道和指令可能满足许多用例,但有时你的应用程序需求将受益于自定义实现,使使用更加方便。

有两种自定义指令,它们各自有自己的用例和能力。我们将创建一个修改元素某些属性的指令,一个修改组件默认行为的指令,以及一个改变元素添加到页面方式的指令。

同样,我们还将构建一些自定义管道来展示它们能为你提供什么价值。就像指令一样,根据你的需求,你可以构建两种类型的管道。我们将创建三个不同的管道来展示它们有用的场景,并讨论最佳实践和性能设计。

打包的指令和管道覆盖了大部分主要用例,但通常开发者需要自己制作。我发现构建应用程序的早期阶段往往可以不使用任何指令完成,但一旦我开始注意到重复或不必要的复杂性,我就开始创建自己的。有时我并不总是看到设计自定义管道或指令的最佳方式,直到我已经构建了一些需要改进的其他东西。

因此,在本章中,我将探讨将自定义管道和指令添加到第二章示例应用程序中的视角,你应该已经熟悉这个应用程序。我相信在现有应用程序的上下文中,更容易看到它们的作用和价值,而不是仅仅构建孤立的示例。

完全诚实地说,仅通过在组件中添加更多逻辑,就完全有可能永远不创建自己的指令或管道——那么为什么还要费这个劲呢?主要原因适用于指令和管道:

  • 重用和减少——而不是每个组件都必须实现类似的逻辑,它可以被抽象出来并轻松重用。这也减少了代码足迹并有助于标准化逻辑。

  • 可维护性和专注的组件——组件有时会成为与组件本身无关的代码和逻辑的垃圾场。将这些移除可以使维护组件变得更加容易。

  • 可测试性 — 将所有内容分解成更小的模块意味着你可以创建更小的测试用例并限制排列组合。

当然,确实有一些场景下需要使用自定义指令或管道,或者这是最佳解决方案。例如,当我们需要创建一些自定义表单验证时,自定义指令是有用的,正如我们将在下一章中探讨的那样。

你需要创建自己的情况的数量会因项目而异,我发现我创建过的一次通常对其他项目也很有用。

8.1 设置章节示例

在本章中,我们将从第二章的股票应用程序示例中添加一些自定义管道和指令。这样做将帮助我们在此章节中减少需要审查的新事物的数量,同时给我们一些有用的示例。

如果你跳过了第二章或者不记得那个例子中的具体内容,该应用程序是一个基本的股票跟踪应用程序。它显示股票列表及其当前状态,并允许你添加或从该列表中删除项目。它还会从服务中加载真实数据。我们不会对应用程序的行为方式做出任何重大改变,但我们将通过使用自定义管道和指令来简化一些事情。你可以在图 8.1 中看到它将是什么样子。

c08-1.png

图 8.1 — 本章我们将构建的内容扩展了第二章的示例。

无论你是否已经有了第二章的样本,我们都会从一个新的仓库开始。要使用 Git 下载,克隆仓库并使用以下命令检查起始点:

git clone https://github.com/angular-in-action/stocks-enhanced
cd stocks-enhanced
git checkout start 

否则,你可以从github.com/angular-in-action/stocks-enhanced/archive/start.zip下载归档文件并解压文件。

如同往常,你需要运行 npm install 来下载所有依赖项。然后运行 ng serve 来启动本地开发服务器。

8.2 创建自定义指令

指令有两种类型:结构属性。它们的声明和使用方式基本上是相同的,但它们有一个关键的区别,你已经在之前的章节中看到过。当我们想要更改元素的一个属性(如背景颜色或高度)时,我们需要使用属性指令。但如果我们想要控制元素本身在页面上的渲染方式,我们将依赖于结构指令。

我们有一些用例,我们希望元素仅在特定条件为真时出现在页面上,特别是加载指示器。这是一个很好的例子,说明了我们如何使用指令来改变元素的渲染方式。同样,我们也需要动态地改变一个元素的背景颜色,我们通过使用指令修改元素上的类来实现这一点。

更具体地说,回想一下,你必须用*前缀来修饰ngIf——例如,*ngIf="loaded = true". 这意味着 NgIf 是一个结构指令,能够控制 DOM 元素是否渲染。另一方面,ngClass指令是一个属性指令,当使用时没有*,例如[ngClass]="{active: true}"。NgClass 仅修改元素的属性——在这种情况下,应用了 CSS 类,但不会渲染 DOM。

区别在于结构指令添加或删除 DOM 元素的能力。这类似于构建的概念:你可以重新设计现有的建筑(属性指令)或处理建筑本身的创建(结构指令)。结构指令可以渲染 DOM,而属性指令只能修改属性。结构 NgIf 指令可以根据提供的条件值确定 DOM 元素是否渲染。NgClass 属性指令不会创建元素,而是更改元素的类列表。

在图 8.2 中,你可以看到属性指令如何修改组件,以及结构指令如何创建(或销毁)组件。在本章的示例中,NgFor 结构指令创建了 Summary 组件的多个实例,而 NgClass 属性则修改了这些相同实例的背景颜色。

c08-2.png

图 8.2 结构或属性指令如何修改 DOM

因此,结构指令和属性指令之间的主要区别在于,结构指令旨在修改元素的 DOM 树,而属性指令旨在仅修改单个元素的属性或 DOM。在本章中,我们将构建这两种类型的示例。

记住,结构和属性指令都没有模板。技术上,组件是第三种类型的指令,并且是唯一具有模板的类型。如果你需要为你的实现使用模板,你应该使用组件。

指令可以像组件一样注入服务,这让你能够访问一些有趣的功能。如果你有自定义服务,你的指令可以利用它们来处理诸如判断用户是否登录以及有条件地显示或隐藏内容等用例。

我们将首先构建一个属性指令,然后是一个用于修改组件的第二个属性指令,最后是一个结构指令。让我们开始吧!

8.2.1 创建属性指令

我们的第一个指令将帮助我们管理 Summary 组件的颜色,这是显示当前股票信息的卡片。结果不会改变用户的视觉体验,但会抽象出这种能力以便它可以被重用。目前,Summary 控制器包含由 NgClass 指令使用的逻辑,根据当天的价值变化将背景颜色更改为绿色或红色。

虽然这不一定是一个问题,但我们可以将其抽象成它自己的属性指令,以防我们以后想再次使用它。这是属性指令的一个很好的用途,因为它只是管理应用于元素的类列表,如图 8.3 所示。

c08-3.png

图 8.3 指令可以修改元素的背景属性,因此组件不需要管理逻辑。

Angular CLI 允许我们快速生成指令框架,所以我们将使用以下命令来生成我们需要的文件——文件将被放置在 src/app/directives 目录中:

ng generate directive directives/card-type 

与组件一样,它也会为你添加指令到 App 模块中,节省一点工作量。我们将实现这个指令,使其也接受一个包含组件使用的股票数据的输入,这样我们就可以检测到正确的类来附加。

让我们继续创建我们的指令。打开 src/app/directives/card-type.directive.ts 并将其内容替换为以下列表中所示的内容。

列表 8.1 卡片类型指令

import { Directive, ElementRef, Input, OnInit } from '@angular/core';     
 @Directive({     
 [selector: '[cardType]'](#c08-codeannotation-0002)
})
export class CardTypeDirective implements OnInit {     
 @Input() cardType: number = 0;
 @Input() increaseClass = 'increase';
 @Input() decreaseClass = 'decrease';

 constructor(private el: ElementRef) {}
 ngOnInit() {
 if (this.cardType) {
 if (this.cardType >= 0) {
 this.el.nativeElement.classList.add(this.increaseClass);
 this.el.nativeElement.classList.remove(this.decreaseClass);
 } else if (this.cardType <= 0) {
 this.el.nativeElement.classList.add(this.decreaseClass);
 this.el.nativeElement.classList.remove(this.increaseClass);
 } else {
 this.el.nativeElement.classList.remove(this.increaseClass);
 this.el.nativeElement.classList.remove(this.decreaseClass);
 }
 }
 }
} 

你首先应该注意到这与组件看起来多么相似,因为组件实际上是一种具有模板的特殊类型的指令。指令使用它们应用到的元素的模板,而不是拥有自己的模板。

指令首先导入我们需要的一些内容,然后我们有 Directive 装饰器。在这里,我们只定义了选择器 [cardType],这是将其作为元素属性工作的 CSS 形式。这个装饰器应用于导出的类 CardTypeDirective

指令可以接受输入,我们定义了一些属性来接受输入绑定。通过定义一个与指令选择器同名的内容,我们就可以绑定到指令,例如 <div [cardType]="stock"></div>cardType 属性将接受一个数字,并且根据数字是正数还是负数,将应用适当的类。其他两个输入允许某人更改应用的类名,并且如果选择不定义它们,它们有默认值。这使得这个指令比所有内容都硬编码更灵活和可重用。

构造函数用于注入一个包含对应用指令的元素的引用的属性。然后我们在生命周期钩子中使用它根据 cardType 数字是正数还是负数来更改类。如果卡片既不是正数也不是负数,则不会应用任何类。

现在,让我们使用这个指令并看看它如何工作。打开 src/app/components/summary/summary.component.html 并更新模板的第一行,如下所示,这将移除 NgClass 并添加卡片上的淡入动画:

<div class="mdl-card stock-card mdl-shadow--2dp" [cardType]="stock.change" [@fadeIn]="'in'" style="width: 100%;"> 

你也可以打开 src/app/components/summary/summary.component.ts 文件,并将控制器中的 isNegativeisPositive 方法移除,因为它们不再被使用。我们没有最终使用 increaseClassdecreaseClass 输入绑定,但你也可以尝试一下。

当你在这个时候运行代码时,你应该实际上看不到任何变化,除了卡片在加载时淡入的动画。我们能够将逻辑重构为一个单独的指令,使其更灵活且易于复用,同时不会破坏当前实现。

指令的主要作用是促进复用。cardType 属性指令通过将使用 NgClass 的具体实现解耦为一个更抽象的指令,该指令接受一个数字以添加相同的类来实现这一点。你可能最初不会用这些类型的小指令来构建你的应用程序,但要注意这种复用机会。

本例的另一个关键方面是如何通过移除对组件本身不必要逻辑来简化 Summary 组件。尽管之前的方式本质上并没有什么不好,但它确实要求 Summary 组件包含管理添加类的方法。再次强调,寻找机会保持组件的专注性,并通过将不必要的功能移至外部来简化它们的角色。

8.2.2 使用事件修改带有指令的组件

cardType 指令被用来修改一个 div 元素,但我们也可以将自定义指令应用到组件上以修改它们。很多时候,你会使用外部组件库,并希望它们能以稍微不同的方式工作。在一定程度上,你可以修改这些组件而无需自己重新实现它们。这不需要不同类型的指令——它仍然是一个属性指令——但它确实展示了你可以如何修改自己没有编写的组件。

让我们假设 Summary 卡本身是一个第三方组件,我们并没有自己编写。我们不喜欢它默认的行为方式,我们可以创建一个指令来让我们改变其默认行为或添加新功能。从根本上讲,这与在普通 HTML 元素上放置指令没有区别,但人们很少考虑这种方法而不是直接修改第三方库。在我们的特定例子中,我们想在悬停时给卡片添加悬停效果和阴影,以使其具有独特的感受。

图 8.4 中的示例对于展示如何在不直接修改组件的情况下修改组件非常有用。我们将添加事件监听器来处理鼠标事件并覆盖背景颜色。

c08-4.png

图 8.4 处理悬停事件并间接修改组件的指令

要开始,我们需要生成另一个指令。在终端中运行以下命令以创建 CardHover 指令:

ng generate directive directives/card-hover 

现在打开 src/app/directives/card-hover.directive.ts 文件,并用以下列表中的代码替换其内容。

列表 8.2 CardHover 指令

import { Directive, ElementRef, OnInit, HostListener } from '@angular/core';     
 @Directive({
 [selector: '[cardHover]'](#c08-codeannotation-0007)
})
export class CardHoverDirective implements OnInit {
  card: any;

  constructor(private el: ElementRef) {} 

 ngOnInit() {
 this.card = this.el.nativeElement.querySelector('.mdl-card');

 if (this.card.classList.contains('increase')) {
 this.card.style.backgroundColor = 'rgb(63,81,181)';
 } else if (this.card.classList.contains('decrease')) {
 this.card.style.backgroundColor = 'rgb(255,171,64)';
 } else {
 this.card.style.backgroundColor = '';
 }
 }

 @HostListener('mouseover') onMouseOver() {
 this.card.style.boxShadow = '2px 2px 1px #999';
 this.card.style.top = '-2px';
 }

 @HostListener('mouseout') onMouseOut() {
 this.card.style.boxShadow = '';
 this.card.style.top = '';
 }
} 

这个指令的结构与之前的类似,包括导入依赖项和定义选择器属性。这个指令的重点是 ngOnInitonMouseOveronMouseOut 这三个方法。

ngOnInit 方法中,我们首先通过查询元素来获取卡片引用。然后我们检查类以确定股价是正数还是负数,并将元素的背景颜色属性更改为覆盖组件提供的默认值。

然后在 onMouseOveronMouseOut 方法中,我们更改样式以给卡片添加阴影并将它们稍微向上移动,使它们看起来稍微悬停在页面之上。事件监听器是通过使用 @HostListener 装饰器触发的,它接受一个参数用于监听的事件。这是一个事件绑定,但通过指令的上下文完成。有许多情况下你需要一个指令来监听事件以处理你的逻辑。

要使用这个指令,我们只需更新 src/app/components/dashboard/dashboard.component.html 文件中 Summary 组件的使用,如下所示:

<summary [stock]="stock" cardHover></summary> 

只需添加属性,新的行为就会附加到 Summary 组件上,新的背景颜色应该会随着悬停效果一起出现。这个指令专门设计用来修改现有元素,我认为这是解决组件不完全按我们需求执行问题的良好解决方案。

这个例子展示了修改组件的一种方法,但你也可以实现一个 Input 来捕获数据,以便在需要比仅元素本身更多的数据时进行额外的处理。

这两个属性指令示例描述了在考虑如何为最大影响定制自己的指令时相互作用的多个概念。有时它们需要尽可能通用以实现最大重用,但像我们在这里所做的那样,为特定用例创建它们通常很有用。

您可能想知道为什么我们使用 JavaScript 来更改元素样式。我们当然可以编写一些 CSS 来处理一些逻辑,因为已经应用了适当的类名以供选择器使用。但我想要展示使用 JavaScript 进行元素操作的使用,因为您可以使用它比 CSS 做更多的事情。请记住您的用例和需求,如果您只能使用 CSS,那么这完全没问题,但指令为您提供了访问整个元素和 DOM API 的权限。

让我们转换话题,讨论另一种类型的指令,它允许我们操作元素本身的存在。

8.2.3 创建结构性指令

结构性指令允许您修改元素的 DOM 树,而不仅仅是元素本身。这包括能够移除元素并用其他内容替换它,创建额外的元素等等。

如本章前面所讨论的,结构性指令的使用场景更为有限,而 NgIf、NgFor 和 NgSwitch 的内置示例可能已经提供了您所需的一切。

但您仍然想了解它,对吧?让我们创建一个指令,该指令将延迟元素渲染一定数量的毫秒。您可以使用此功能使元素淡入页面——在我们的例子中,我们希望每张卡片有不同的延迟,以便它们按顺序淡入,如图 8.5 所示。

c08-5.png

图 8.5 添加延迟指令以使卡片依次淡入

我们将创建一个,然后回到一个关键点,即为什么它们在使用时总是以一个 * 符号开头。在我们通过示例了解之后,这会更容易理解。

首先,通过在终端运行以下命令来创建一个新的指令:

ng generate directive directives/delay 

打开 src/app/directives/delay.directive.ts 文件,并用以下列表中的代码替换其内容。

列表 8.3 延迟指令

import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';     
 @Directive({
  selector: '[delay]'
})
export class DelayDirective {
 @Input() set delay(ms: number) {
 setTimeout(() => {
 this.viewContainer.createEmbeddedView(this.templateRef);
 }, ms);
 }

 constructor(
 private templateRef: TemplateRef<any>,
 private viewContainer: ViewContainerRef
 ) { }
} 

在此指令中,我们正在导入依赖项,其中 InputTemplateRefViewContainerRef 是必需的。TemplateRefViewContainerRef 分别是引用我们指令附加到的元素的模板以及包含它的视图的引用。

当 Angular 渲染结构指令时,它创建一个占位符空间,称为嵌入式视图,指令可以决定在这个新的视图容器中插入什么。使用ViewContainerRef,我们可以访问这个视图并创建任意数量的视图。ViewContainerRef的文档可以在angular.io/api/core/ViewContainerRef找到,这是一个深入了解 Angular 中视图工作方式的好地方。但主要点是,我们最终得到一个空容器,我们可以向其中添加一个或多个视图。

在渲染视图容器后,它将模板提取出来,使其从页面上移除,但仍然可以通过TemplateRef访问和可用。这意味着 Angular 不会渲染模板,除非我们的指令明确调用必要的渲染方法。这就是如何处理元素的底层渲染。

现在在我们的构造函数中注入这两个引用,这样我们就可以在 setter 方法中使用它们。Input被定义为 setter(这是 JavaScript 的一个特性),并使用与选择器相同的名称,因此我们可以轻松地将数据绑定到指令中。delay属性接受一个数字,表示延迟渲染元素的毫秒数。这个数字立即传递给一个setTimeout函数,该函数在指定的延迟后调用下一行:

this.viewContainer.createEmbeddedView(this.templateRef); 

这一行是我们创建视图容器中的新视图,并传递模板引用以进行渲染的地方。这就是 NgIf 在底层通过检查传递给 NgIf 的值的真伪性来创建或清除视图的方式。

现在回到结构指令中的*的问题。在我们的属性指令中,我们没有TemplateRefViewContainerRef,而*是描述给 Angular 的语法方式,表示这个指令需要在元素渲染之前捕获模板并创建视图容器。这个指令的使用方式是*delay="1000",表示在显示之前延迟 1 秒。

让我们在 Summary 组件中使用这个功能,所以打开src/app/components/dashboard/dashboard.component.html并更新迭代卡的模板:

<div class="mdl-cell mdl-cell--3-col" *ngFor="let stock of stocks; index as i">
  <summary [stock]="stock" *delay="i * 100" cardTone></summary>
</div> 

我们已经扩展了 NgFor,使其也能创建一个变量i,该变量在循环中持有当前索引,然后我们将索引乘以 100 并传递给 Delay 指令。这将按顺序延迟每个卡的显示 100 毫秒,并且,与 Summary 组件上的动画相结合,它也将优雅地动画化。

结构指令比属性指令稍微复杂一些,因为你是在处理 Angular 视图而不是已经渲染的元素。幸运的是,结构指令适用的用例相对较少。

你创建的大多数指令可能都是属性类型的。我在我的工作中发现这一点是正确的,而且一般来说,NgFor、NgIf 和 NgSwitch 提供的功能覆盖了大多数结构指令的使用场景。我所见到的许多结构指令的例子,与这三个内置指令一样,执行了相同的基本任务,并具有一些定制的能力。例如,许多数据表组件都有自己的 NgFor 实现,与数据表更好地集成,并可以帮助提供分页等功能。

随着你越来越多地使用指令,你可能还需要花更多时间熟悉与视图和渲染元素相关的 Angular API。如果你的需求需要,你将能够实现更复杂的场景。

我们已经完成了自定义指令,现在让我们看看管道,以及我们如何可以创建自定义的方式来格式化我们的数据,以便在显示之前。

8.3 创建自定义管道

管道基本上是一种格式化数据的方式,根据你拥有的数据,你可能发现创建自己的管道集来简化模板是有用的。管道通常简单且易于实现,如果它们可以节省你重复的格式化逻辑,那么你应该创建自己的。

管道是在数据被渲染到页面之前传递数据的函数。这与组件有 OnInit 生命周期钩子来处理组件渲染前的逻辑类似,管道为绑定做同样的事情。你可以看到当存在或不存在管道时,Angular 如何处理绑定。图 8.6。

c08-6.png

图 8.6 数据在模板绑定渲染之前通过管道传递。

在本章的示例中,有几个使用货币和百分比管道来格式化数据的例子。这些都是管道如何变得非常有价值的简单但很好的例子。想象一下,如果你的应用程序需要支持不同的货币,货币管道可以找出每种货币通常是如何格式化的。

基本上有两种类型的管道:纯管道和不纯管道。纯管道不维护任何状态信息,而不纯管道维护状态。

纯管道和不纯管道的渲染方式也不同,如图 8.7 所示。纯管道仅在传递到管道中的输入值发生变化时运行,这使得它更高效,因为它不会经常运行。另一方面,不纯管道会在每次变更检测运行时执行,因为不纯管道具有可能发生变化的状态,无论输入是否发生变化,都会渲染不同的输出。

c08-7.png

图 8.7 Angular 如何处理纯管道和不纯管道

如果你开始构建管道,理解这一点非常重要。选择使管道为纯管道或不纯管道可能会对管道的执行方式以及应用程序的性能产生重大影响。

我们将构建纯管道和不纯管道的示例,但你应该始终尝试构建纯管道。我敢打赌,98%的所有自定义管道都是纯的——或者应该是纯的。当我们构建不纯管道时,我们将更详细地探讨为什么是这样,因为将状态存储在管道中很棘手,并且使其实现更加复杂。

让我们构建一个帮助我们显示股票变动信息的管道,并且我们将使其成为一个纯管道。

8.3.1 创建纯管道

因为几乎你创建的每个管道都将是一个纯管道,所以让我们先看看如何为我们的应用程序创建一个。管道不像指令那样复杂,因为它们是在值被渲染到模板之前修改值的一种方式。纯管道之所以被称为纯管道,是因为它们实现了一个纯函数——一个不维护任何内部状态,并且对于相同的输入返回相同输出的函数。

纯函数对于性能很重要,因为 Angular 不需要在每次变化检测生命周期中运行它们,除非输入值已更改。这可以节省相当多的开销,出于性能原因。

纯管道实现为一个接受绑定值和可能传递的任何额外参数的单个函数。这个方法被称为transform,因为它接受输入值,执行某种类型的转换,并返回一个结果。

我们将创建一个自定义管道,用于处理显示股票价格变动和百分比数据。目前,我们已经在 Summary 组件模板中实现了这个功能,我们希望替换掉下面这个长行:

{{stock?.change | currency:'USD':true:'.2'}} ({{stock?.changeInPercent | percent}}) 

用这个:

{{stock | change}} 

原文相当长——想象一下在多个地方写它。我们可能会遇到输出不一致的问题,所以我们要创建一个可以使其可重用和更干净的管道。

让我们创建一个新的管道,称为 Change 管道,以简化这个片段。正如你所期望的,有一个使用 CLI 生成新管道的方法。使用终端,运行以下命令:

ng generate pipe pipes/change 

新文件生成在 src/app/pipes 目录下。让我们打开 src/app/pipes/change.pipe.ts,并用以下列表中的代码替换它。

列表 8.4 Change 管道

import { Pipe, PipeTransform } from '@angular/core';     
import { CurrencyPipe, PercentPipe } from '@angular/common';     
import { StockInterface } from '../services/stocks.service';     
 @Pipe({     
 name: 'change'
})     
export class ChangePipe implements PipeTransform {     
 constructor(private currencyPipe: CurrencyPipe, private percentPipe: PercentPipe) {}
 transform(stock: StockInterface, showPercent: boolean = true): any {
 let value = `${this.currencyPipe.transform(stock.change, 'USD', 'symbol', '.2')}`;
 if (showPercent) {
 value += ` (${this.percentPipe.transform(stock.changeInPercent, '.2')})`;
 }
 return value;
  }
} 

这个管道使用了 Currency 和 Percent 管道,并且首先导入所有依赖项。@Pipe装饰器表示我们正在创建一个管道,我们给它一个name属性来定义管道的名称——这需要是唯一的,所以请仔细考虑。

当类被定义时,它还实现了PipeTransform接口,以帮助确保我们正确构建管道。构造函数随后将 Currency 和 Percent 管道注入到类中,这样我们就可以使用它们了。

最后,实际的工作发生在 transform 方法中,它接受一个或多个参数。第一个是传递给管道的值,它总是由绑定提供,并且预期是一个 stock 对象。其余的参数是可能按需传递的可选参数,它们是可选的,因为接口不需要它们。在这种情况下,我们声明一个参数 showPercent,作为一个布尔值,表示我们是否想要添加百分比值。然后方法使用所需的格式构造一个字符串,并将其返回以供显示。

我们将 transform 方法实现为一个纯函数。每次你向它传递相同的参数时,你都会得到相同的输出。如果值有可能会改变,它就不是纯函数或纯管道。

现在我们可以将其用于我们的 Summary 组件。打开 src/app/components/summary/summary.component.html 并将最后一行中的 stock.change 改为以下内容:

{{stock | change}} 

我们能够将带有两个插值绑定的长行简化为一个。注意,我们没有向它传递任何参数,因为默认情况下它将显示百分比变化。你可以通过将管道调用改为传递布尔值来禁用此功能,如下所示:

`{{stock | change:false}}` 

创建任何纯管道都将遵循这个基本框架,显著的变化将在于你如何转换值。确保返回一个字符串或可以转换成字符串的值。你不应该返回数组或对象,例如。

当我们创建一个不纯的管道并看到它是如何被应用程序生命周期调用的,管道纯度的价值就变得更加明显。现在让我们看看这个,并创建两个示例来了解它们在什么情况下是有用的。

8.3.2 创建不纯管道

有时候,你想要格式化数据的方式依赖于管道的状态或值本身——它以这种方式实现了 transform 方法,即它不总是提供相同的输出,即使输入相同。

由于这种设计,不纯的管道会在每次变更检测生命周期中执行,无论输入本身是否已更改。这是唯一知道最终绑定结果是否已更改(记住,它可能依赖于某些会改变结果的其他状态)以及 Angular 是否需要更新渲染视图的方法。

Angular 提供的异步管道是一个不纯的管道,并且有一个很好的用例。它允许你将一个可观察对象或承诺传递给管道,当异步返回一个值时,结果将被评估。它本质上是在监听 async 事件(取决于类型)触发后直接显示该值。你可以用它来调用返回承诺的 API,将承诺传递给与异步管道绑定的绑定,值将在 API 返回值后出现。

为了更好地理解异步管道的行为,我发现实现一个记录它被调用次数到控制台的管道很有帮助,并且最终输出就是你将在 图 8.8 中看到的。这告诉你包含此管道的组件进行了多少次变更检测。

c08-8.png

图 8.8 变更检测管道,每次组件被检查变更时都会发出警报

首先使用终端生成一个新的管道。我们将创建一个名为 ChangeDetector 的新管道:

ng generate pipe pipes/change-detector 

打开新创建的管道 src/app/pipes/change-detector.pipe.ts。将其替换为以下列表中的内容。

列表 8.5 变更检测管道

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({     
 name: 'changeDetector',
 pure: false
})     
export class ChangeDetectorPipe implements PipeTransform {
 count: number = 0;
 transform(value: any, args?: any): any {
 this.count++;
 console.log(`Component change detection executed ${this.count} times`);
 return value;
 }
} 

此管道默认不转换任何值。我们首先在装饰器中将 pure 属性设置为 false,以告诉 Angular 这个管道是不纯的,并且必须始终重新评估。然后我们存储一个包含数值的属性来保存它被调用的次数。最后,transform 方法增加计数,输出一条消息,并返回未更改的值。

如果这是一个纯管道,它只会当绑定值改变时增加计数,但这将运行更多次。为了确切知道运行了多少次,让我们将它放在 App 组件上。

打开 src/app/app.component.html 并更新包含标题的行,如下所示:

<span class="mdl-layout-title">{{'Stock Tracker' | changeDetector}}</span> 

这将字符串 'Stock Tracker' 绑定到 ChangeDetector 管道,如果你打开控制台,它应该会输出很多像 "Component change detection executed 32 times" 这样的消息。实际的数字可能会略有不同,如果你对你的应用程序进行了自定义。

现在想象一下,这个转换方法做了更多困难的工作。也许你有一个大数组要扫描以找到如何将键映射到值,并且性能可能不是最优的。你可能甚至有一个异步调用来处理。这些都是创建不纯管道时可能遇到的潜在陷阱,就像你在 图 8.9 中看到的那样。

c08-9.png

图 8.9 一个不纯管道的示例,通过在管道本身维护状态,在页面顶部加载新闻

我认为这个例子很好地说明了发生了什么,但关于完成任务的例子呢?我们可以构建另一个不纯管道,它接受一个值然后通过 HTTP 请求加载新闻,就像你在 图 8.9 中看到的那样。

使用以下命令生成一个新的管道,然后替换 src/app/pipes/news.pipe.ts 中的内容为以下列表中的代码:

ng generate pipe pipes/news 

列表 8.6 不纯新闻管道

import { Pipe, PipeTransform } from '@angular/core';
import { StocksService } from '../services/stocks.service';

@Pipe({     
 name: 'news',
 pure: false
})     
export class NewsPipe implements PipeTransform {
 cachedSource: string = '';
 news: string = 'loading...';

  constructor(private service: StocksService) {}

 transform(source: string, args?: any): any {
 if (source !== this.cachedSource) {
 this.cachedSource = source;
 this.service.getNewsSnapshot(source).subscribe(news => {
 this.news = `<a href="${news.url}" target="_blank">${news.title}</a>`;
 });
 }

 return this.news;
 }
} 

这个管道再次被声明为一个不纯管道,并且它有两个有状态的属性来保存源和结果新闻标题。

transform方法更为复杂。它在每次变更检测运行时都会执行,因此我们通过首先检查输入源是否已更改来创建一个屏障,因为我们缓存了最后提供的源。如果值已更改(或首次提供),我们将通过 HTTP 请求来加载新闻数据。一旦数据返回,我们就使用title更新news属性的值,并将其渲染出来。

你可能会想知道 Angular 是如何知道 API 请求何时完成的。好吧,因为我们底层使用了 Angular HttpClient 服务,Angular 知道响应何时完成,这触发了另一轮变更检测。因为这是一个不纯的管道,它会再次运行,并将news值渲染出来。

我们需要将其放入模板中才能运行,所以打开src/app/app.component.html并添加一些额外的行到标题中,如下面的列表所示,其中加粗的部分。

列表 8.7 带有新闻管道的应用程序标题

<header class="mdl-layout__header">
  <div class="mdl-layout__header-row">
    <span class="mdl-layout-title">{{'Stock Tracker' | changeDetector}}</span>
    **<div class="mdl-layout-spacer"></div>**
 **<span>Top News Headline: <span [innerHTML]="'cnbc' | news"></span></span>**
    <div class="mdl-layout-spacer"></div>
    <nav class="mdl-navigation mdl-layout--large-screen-only">
      <a class="mdl-navigation__link" [routerLink]="['/']">Dashboard</a>
      <a class="mdl-navigation__link" [routerLink]="['/manage']">Manage</a>
    </nav>
  </div>
</header> 

在这里,我们只是添加了一个渲染顶级新闻标题的位置。如果你想要获取不同的新闻源,可以传递几个不同的值:

  • the-wall-street-journal

  • bloomberg

  • cnbc

  • financial-times

  • the-new-york-times

现在当你预览应用程序时,你应该看到新闻标题出现在页面顶部,并有一个链接到文章。每当应用程序组件被渲染(每次加载时只渲染一次),它都会从 API 请求新闻。如果将其放在渲染更频繁或重复渲染的同一页面的其他类型组件上可能会很危险。

这个例子中重要的是,将其实现为一个组件而不是管道会更好。实际上,没有太多理由将其做成管道。

虽然不纯管道很有趣,但我确实认为如果你能避免的话应该避免使用它们。即使是异步管道在生产环境中使用也有问题,因为它使得错误处理变得困难。它们是为了帮助解决一小部分用例而设计的,也许主要用于开发,但它们使得应用程序逻辑更加复杂和难以预测。

摘要

你已经成功创建了几个指令和管道!这些技能对于代码复用和使代码专注于特定任务非常有用。以下是本章的关键要点:

  • 指令有三种类型:属性、结构和组件。

  • 属性指令是最常见的创建方式,非常适合修改现有元素。

  • 结构性指令不太常见,旨在用于修改 DOM 元素的存在或结构。

  • 纯管道非常有用,允许你使用纯函数在输出之前转换一个值。

  • 不纯管道允许你在管道内部维护状态,但它们会在每次变更检测检查时运行,如果可能的话应避免使用。

9

表单

本章涵盖

  • 使用 Angular 的表单库创建表单

  • 在使用响应式表单或模板表单之间做出决定

  • 使用自定义逻辑验证表单

  • 访问数据和观察输入变化

  • 提交表单数据并优雅地处理错误

  • 创建自定义表单控件

几乎每个应用程序都以某种方式使用表单,即使只是做一些简单的事情,比如登录或管理设置。HTML 默认提供了一些表单元素,如输入、选择和按钮,Angular 提供了一种方法来使用这些原生元素并为其添加一些功能。我们在之前的几个例子中使用了表单,但在本章中,我们将更深入地探讨它们。

Angular 提供了两种构建表单的方法:响应式表单模板表单。我将在稍后详细讨论它们之间的区别,尽管它们主要在于你是在控制器中还是在模板中定义表单。你不必局限于只选择其中一种表单,但通常应用程序会尝试保持与其中之一的一致性。

使用模板表单时,我们将看到如何主要使用 NgModel 指令来描述你的表单,该指令用于定义表单结构。使用响应式表单时,你自己在控制器中声明表单结构,然后模板将其渲染出来。

尽管表单可能变得非常复杂,但所有领域的基本原则都是相当标准的。有表单控件(如输入、选择等字段,它们持有值),还有表单按钮(如保存、取消或重置)。当在 Angular 中处理表单时,基本原则保持一致,无论表单变得多么复杂。

经常会有这样的情况,表单需要使用额外的第三方组件来帮助,例如日期选择器或范围滑块。浏览器可能实现了一些新的表单控件,但它们很少是标准的,其他浏览器可能根本不支持它们。尽管我们不会专注于创建类似表单控件的自定义组件,但有许多优秀的库提供了额外的功能,或者你可以通过查阅文档来构建自己的自定义表单控件。我个人只有在绝对必要时才会创建这些组件,这种情况很少发生。

9.1 设置本章示例

我们将构建一个新的应用程序,帮助我们管理发票和客户。想象一下,你是一名自由职业者或小型企业主,你需要管理客户。这个应用程序将是一个很好的工具,用于跟踪发送发票并确保你得到支付(这很重要,对吧?)。

表单本身故意不复杂,但它们确实简洁地展示了表单的大部分需求。你可以将本章中看到的示例转换为更大的、更复杂的表单,而无需学习额外的概念。唯一的区别通常在于规模。

该应用程序还针对移动设备形态进行了设计,这与我们之前的示例形成了一个很好的小转折。它使用来自 Teradata 的 Covalent UI 库,该库扩展了 Angular Material Design 库的概念。如果您不知道,移动浏览器通常对最新的 HTML5 输入类型(如搜索或数字字段)的支持最好,这些我们将用于我们的示例。我建议您在本章中使用 Chrome。

Chrome 的开发者工具中有一个有用的设备模拟器,如图 9.1 所示,我建议您在构建和使用此应用程序时使用它。它允许您模拟各种移动设备的尺寸,并了解您的应用程序在这些尺寸上的外观。它并不真正以真实的方式模拟设备,但它确实提供了一个方便的预览方式。

c09-1.png

图 9.1 使用 Chrome 开发者工具中的设备工具模拟移动设备。

如其他示例一样,这个示例也托管在 GitHub 上,您可以通过克隆仓库来获取源代码。确保我们在开始时检查正确的标签,这样您就可以一起编码,或者查看最新的 master 分支以获取最终版本:

git clone https://github.com/angular-in-action/invoice 
cd invoice
git checkout start 

否则,您可以从github.com/angular-in-action/invoice/archive/start.zip下载存档文件,并解压这些文件。

当您启动应用程序时,您会注意到已经存在许多服务和组件。我提前提供了大部分代码,这样我们就可以专注于表单的关键功能。即使表单组件也存在,它们使用了标准的 HTML 表单。当您尝试保存它们时,它们目前不会执行任何操作,这正是我们将在本章中更新和实现的内容。

您需要运行npm install来安装所有依赖项,然后运行ng serve来启动本地开发服务器。但这还不是全部。此应用程序还有一个本地 API 服务器,我们同样需要运行。您需要打开另一个终端会话并运行以下命令:

npm run api 

这将启动一个本地服务器,提供我们的应用程序数据。当您保存和编辑记录时,数据将持久保存在名为 db.json 的本地文件中,这对于我们的应用程序很重要。

当您运行示例时,浏览器控制台可能会有一些警告——您可以安全地忽略这些警告。它们指的是本章示例中不需要的功能。

现在,在我们进入表单之前,让我们回顾一下应用程序的其余部分。

9.1.1 在开始之前回顾应用程序

该应用程序中有六个可路由视图。让我们简要地谈谈其中一些不包含表单的视图。我们将专注于本章中的两个路由;我们将使用模板驱动表单构建其中一个表单,另一个使用响应式表单。让我们看看应用程序的几个屏幕。

图 9.2 展示了应用程序的一些屏幕,例如列表、详细和表单视图。有两个列表视图,一个用于客户,一个用于发票。这两个列表视图都相当简单,因为它们从 API 加载一个列表并渲染它。它们还包括一个按钮,可以带您进入表单以创建新记录。您可以在发票和客户组件中看到这两个视图。

c09-2.png

图 9.2 仿制在移动设备大小的发票应用程序屏幕。从左到右:发票列表、客户列表、发票详细视图和客户表单。

客户和发票的详细视图也非常相似,因为它们只是显示给定记录的相关数据。还有一个按钮允许您编辑该记录。您可以在发票和客户组件中预览这些视图。

最后,我们将要工作的两个视图是表单视图。客户或发票表单将允许您创建或编辑现有记录,所需的表单字段已经通过标准 HTML 提供。我们将更新这些表单和控制器以处理保存、删除和取消事件。这些可以在 InvoiceForm 和 CustomerForm 组件中找到。

在组件内部,您会看到一些新内容。TdLoading指令是 Covalent 库中的一个功能,用于在加载数据时显示加载指示器。MdInput指令将使输入符合 Material Design 规范。还有几个以Md-开头的其他元素,它们都是来自 Material Design 库的 UI 组件,用于结构或控件。最好查阅 Covalent 和 Material Design 文档,以解决您可能对这些工具的使用有疑问的问题。请注意 package.json 文件中使用的特定版本,并确保您正确地查找它。

对于客户和发票 API 也有服务。您可能想审查它们,作为扩展一个服务以创建另一个服务的方法。发票和客户服务都扩展了 Rest 服务,该服务实现了所需的 API 调用。特殊实例(发票和客户)提供了一个属性,该属性由 Rest 服务用于构造 URL。

好的,让我们使用模板驱动方法创建客户表单。

9.2 模板驱动表单

我们已经在我们的几个示例中使用了模板驱动表单,关键标志是您在表单控件上看到NgModel指令。AngularJS 开发者将熟悉本节中描述的模式。

模板表单之所以得名,主要是因为表单控件是在组件的模板中定义的。在这种情况下,您可以将模板视为最终决定哪些内容是表单的一部分。例如,如果您在页面上有一个作为表单一部分并连接到表单控件的input元素,那么它也将由控制器定义。

表单的主要目标是能够同步视图中的数据与控制器中的数据,以便可以提交进行处理。次要目标是执行验证、通知错误以及处理取消等其他事件。

因为表单形式主要定义在模板层,这也意味着验证错误主要是通过模板来管理的。我们将探讨如何添加验证并提醒用户关于无效字段。

在 图 9.3 中,你可以看到我们将在此部分构建的客户表单。这三个字段将是表单数据的一部分,并允许我们捕获输入以进行处理。

要开始,我们需要开始处理我们的表单控件并将它们连接起来,以便使用 NgModel 在控制器和模板之间绑定模型。

9.2.1 使用 NgModel 将模型数据绑定到输入

让我们从单个表单控件开始,看看将其转换为 Angular 可以使用的内容需要什么。在 CustomerForm 组件中,你应该看到这个输入:

<input mdInput placeholder="Customer Name" value=""> 

c09-3.png

图 9.3 客户表单,包含三个字段以绑定数据

目前它只是一个普通的表单元素(使用 MdInput 使其成为 Material Design),但通过添加 NgModel 指令,我们可以将其转换为 Angular 表单控件。在这个过程中,我们还可以删除 value 属性,因为它不再需要:

<input name="customer" mdInput placeholder="Customer Name" **[(ngModel)]="customer.name"**> 

NgModel 指令是表单模块的一部分,并确保表单控件的值基于控制器中的 customer 属性值设置。但是,当它在视图中更改时,它也会将其值设置到控制器中。如果你查看控制器,将没有这样的属性,NgModel 将为你创建它。

你应该记得之前章节中的 [()] 语法,但为了刷新你的记忆,它是在 Angular 中进行双向数据绑定的一种方式。这意味着控制器现在将有一个名为 customer 的属性,如果视图或控制器更改该值,另一个也会立即更新。AngularJS 开发者会很好地了解这个概念,它也存在于 Angular 中。

让我们继续使用 NgModel 将所有表单控件连接起来。在 CustomerForm 模板中,我们需要修改现有的表单控件,如下所示。打开 src/app/customer-form/customer-form.component.html 并更新它。

列表 9.1 使用 NgModel 的 CustomerForm

<md-card-content>
  <md-input-container>
 [<input name="customer" mdInput placeholder="Customer Name" **[(ngModel)]="customer.name"**>](#c09-codeannotation-0001)
 </md-input-container>
  <md-input-container>
 [<input name="email" mdInput type="email" placeholder="Email" **[(ngModel)]="customer.email"**>](#c09-codeannotation-0001)
 </md-input-container>
  <md-input-container>
 [<input name="phone" mdInput type="tel" placeholder="Phone" **[(ngModel)]="customer.phone"**>](#c09-codeannotation-0002)
 </md-input-container>
{{customer | json}}     
</md-card-content> 

在这些表单控件中,我们现在使用 NgModel 来实现双向绑定。请注意,我们还将模型值设置为 customer 属性的一部分,因此数据存储在一个对象中。除了只有一个控件的表单外,强烈建议始终使用此类模型。这有助于我们稍后能够将所有客户数据存储在同一个对象上,而不是控制器的不同属性中。

到目前为止,这不会对我们的表单产生任何显著的可见变化,但它会在后台更改输入值时向客户模型添加新属性。如果你观察客户插值绑定,你会看到当表单输入中的值发生变化时,模型也会更新。

使用 NgModel 有几点需要注意。首先,你始终使用双向绑定语法与之一起使用——否则它不起作用。其次,使用 NgModel 时,输入应该始终有一个名称,因为它需要内部信息。最后,你注意到省略了 value 属性,因为 NgModel 会覆盖它,所以最好将其省略。

保存这些更改,然后转到客户列表,选择一个,点击右下角的编辑图标以查看表单。你还应该检查浏览器控制台,以确保没有输入错误。

这很好,因为我们的主要对象基本上已经完成。我们只需添加 NgModel,当进行更改时,我们的表单元素现在在模板和控制器中都被跟踪。下一步是开始验证这些表单字段。利用 NgModel 的力量,我们可以跟踪表单字段的验证状态,并向用户报告有意义的错误。

9.2.2 使用 NgModel 验证表单控件

HTML 已经提供了一些内置的表单验证,可以添加到表单元素中,例如 requiredminlength。Angular 与这些属性一起工作,并且会自动根据它们验证输入。

让我们以我们的客户姓名输入字段为例。我们所需做的只是添加额外的 required 属性来验证输入,强制对此字段进行验证:

<input name="customer" mdInput placeholder="Customer Name" [(ngModel)]="customer.name" **required**> 

当表单控件具有无效值时,我们还可以检查字段的状态,并显示关于错误的提示信息,如图 9.4 所示。

c09-4.png

图 9.4 带有验证错误的客户表单

是时候为所有字段设置验证,并查看如何访问这些字段的状态了。根据以下列表中加粗的内容更新 CustomerForm 模板片段。

列表 9.2 使用 NgModel 验证 CustomerForm 字段

<md-input-container>
 [<input name="customer" mdInput placeholder="Customer Name" [(ngModel)]="customer.name" **required #name="ngModel"**>](#c09-codeannotation-0005)
 **<md-error *ngIf="name.touched && name.invalid">**
 **Name is required**
 **</md-error>**
</md-input-container>
<md-input-container>
  <input name="email" mdInput type="email" placeholder="Email" [(ngModel)]="customer.email" **required #email="ngModel"**>
 **<md-error *ngIf="email.touched && email.invalid"> **
 **A valid email is required**
 **</md-error>**
</md-input-container>
<md-input-container>
  <input name="phone" mdInput type="tel" placeholder="Phone" [(ngModel)]="customer.phone" **required #phone="ngModel" minlength="7"**>
 **<md-error *ngIf="phone.touched && phone.errors?.required"> **
 **Phone number is required**
 **</md-error>**
 **<md-error *ngIf="phone.touched && phone.errors?.minlength">** 
 **Not a valid phone number**
 **</md-error>**
</md-input-container> 

现在表单控件每个都带有 required 属性和本地模板变量。电话号码还有一个 minlength 属性,因为我们期望电话号码至少有七个数字。我们在组件章节中使用了本地模板变量来访问模板中其他控制器中的值,这里也是同样的情况。例如,#name="ngModel" 是将模板变量 name 定义为对 NgModel 结果的引用的方式,即表单控件数据。记住,模板变量仅在定义它们的模板内有效,因此你无法从控制器中访问它们。

此表单控件数据是 Angular 中的 FormControl 类型,您可以在 API 文档中查看以了解更多关于它能为您做什么的信息。它有许多属性,例如validinvalidpristinedirty。这些是布尔值,您可以使用它们轻松地确定某事是真是假。参见表 9.1 以了解最有用的表单控件属性。

表 9.1 表单控件验证属性

属性 含义
valid 表单控件对所有验证都是有效的。
invalid 表单控件至少有一个无效的验证。
disabled 表单控件被禁用,无法与之交互。
enabled 表单控件处于启用状态,可以点击或编辑。
errors 一个对象,要么包含有无效验证的键,要么当所有验证都有效时为 null。
pristine 表单控件尚未被用户更改。
dirty 表单控件已被用户更改。
touched 表单控件曾获得焦点,然后焦点离开了该字段。
untouched 表单控件尚未获得焦点。

MdError 元素来自 Material Design 库,当NgIf为真时显示一个小的验证错误。例如,*ngIf="email.touched && email.invalid"会在表单控件无效且用户离开该字段焦点时显示错误。(作为旁注,如果值是从数据库加载的但无效,则前面的验证将失败,因此您应该考虑您应用程序的需求。)这很好,因为错误不会立即出现,而只有在用户尝试用无效值离开字段时才会出现。您可以使用表 9.1 中属性的不同的组合来确定何时显示验证错误。当您创建新项目时,所有必填字段都将无效,但只有在用户尝试编辑它们之前不会显示验证错误。

注意电话号码的验证消息有两个不同的验证:requiredminlength。然后我们能够查看控件的错误对象,以确定是否特定的验证失败,并显示适当的消息。在这种情况下,如果用户留空,它会提示该字段是必填的,但如果用户只输入了四个字符,它会显示至少需要七个数字。

还值得注意的是,Angular 会根据表单控件的验证状态应用各种 CSS 类。这些类与 表 9.1 中的属性相对应,但前面带有 ng- 前缀。例如,一个无效的表单控件将应用 `ng-invalid` 类。如果你想在没有任何特殊工作的情况下为有效或无效的控件定制样式,这将非常有用。我们在这里没有这样做,但你当然可以利用这一点。一些 Angular UI 库可能自带对这些类的支持。

Though this validation is helpful, it’s still possible to submit the form with invalid values. We’ll prevent this from happening in a moment, but first I want to wrap up validation by creating our own validation directive. ### 9.2.3 Custom validation with directives The validation for our phone number is somewhat lacking. We really would want it to enforce not just the length but also that the content matches a known phone format. Unfortunately, even the `tel` input type doesn’t do that for us, so we’ll have to implement our own custom validation using a directive. Our best effort so far has been to enforce a `minlength` validation, but that only cares about the number of characters, not the actual value. Although there is the `pattern` validation attribute in HTML, which allows you to declare a regular expression to validate the input, it’s not very usable and doesn’t work in all browsers. We’ll need to create two things to make this happen: a customer validator function and a directive that uses the validator function. Start by creating a new directory at src/app/validators; then create a file inside it named phone.validator.ts, and add the code from the following listing. **Listing 9.3** Phone validator ``` import { AbstractControl, ValidatorFn } from '@angular/forms'; const expression = /((\(\d{3}\) ?)|(\d{3}-))?\d{3}-\d{4}/;      ``` ``` export function PhoneValidator(): ValidatorFn {      ``` ``` [return (control: AbstractControl): { [key: string]: any } => {](#c09-codeannotation-0010) ``` ``` const valid = expression.test(control.value) && control.value.length < 14; ``` ``` return valid ? null : { phone: true }; }; } ``` This is a bit terse, so let’s look at it step by step. First, we’re defining a regular expression that should validate the primary phone number formats. You could select a different expression if your needs require. Then we’re exporting a function that will return a function. The `ValidatorFn` interface expects that this returned function will accept a control as a parameter and return either null or an object with validation errors. Our `PhoneValidator` function will return the real validation function to use during the validation. It accepts a single argument, which is the form control. For the most part, you only care about the `control.value` property, which holds the current value of the form control. Then inside of the validation function, it tests the current value against the expression and returns either null, to mean it’s valid, or an object if it’s invalid, with a property explaining what is invalid. If it returns an object, it expects you to give it a property with a value. Here it’s a Boolean, but it could be any value you want to expose. Normally, I find Boolean is suitable unless you want to also provide the error message as a string. You can access the value in the local template `control.errors` property. To use this validator we need to create a directive. Using the Angular CLI, generate a new directive like so: ``` ng generate directive validators/phone ``` Now open src/app/validators/phone.directive.ts and add the code found in the following listing to it. This will take the validator function we created a moment ago and make it possible to apply it to an element as an attribute. **Listing 9.4** Phone validator directive ``` import { Directive } from '@angular/core'; import { Validator, AbstractControl, NG_VALIDATORS } from '@angular/forms'; import { PhoneValidator } from './phone.validator'; @Directive({ [selector: '[phone][ngModel]',](#c09-codeannotation-0012) ``` ``` [providers: [{ provide: NG_VALIDATORS, useExisting: PhoneDirective, multi: true }]](#c09-codeannotation-0013) ``` ``` }) export class PhoneDirective implements Validator {      ``` ``` private validator = PhoneValidator(); ``` ``` [validate(control: AbstractControl): { [key: string]: any } {](#c09-codeannotation-0016) ``` ``` return this.validator(control); } } ``` This is also a bit terse, but what we’re doing is implementing the necessary pieces to wire up the directive to Angular’s list of validators and implement the same interface. We start by defining the selector to expect to have both phone and `NgModel` attributes on the form control. This means if you just put `phone` as an attribute, it won’t use this directive for validation, because `NgModel` is required. The directive also has a `providers` array and uses a multiprovider, which allows a single token (like `NG_VALIDATORS`) to have multiple dependencies. `NG_VALIDATORS` contains a list of default validation dependencies, and this extends that list by adding one more of our own. This isn’t very common, but it’s required in this situation. Our directive then exports a class, which implements the `Validator` interface. This expects that there will be a `validate` method defined in the class, which we have done. We also have a property that holds an instance of our `validator` function that we imported, and then inside of the `validate` method we call our custom validator and pass in the control. There’s a bit of juggling of the form control in this custom validation process, but when you look at these two files together, it should be clearer how they relate to one another. To implement this new `validator` directive, we need to update our `phone` form control, as you see in the following listing. **Listing 9.5** Updated phone form control ``` <md-input-container> [<input name="phone" mdInput type="tel" placeholder="Phone" [(ngModel)]="customer.phone" required **phone** #phone="ngModel">](#c09-codeannotation-0017) ``` ``` <md-error ***ngIf="phone.touched && phone.errors?.phone"**> ``` ``` Not a valid phone number </md-error> </md-input-container> ``` The form control removes the `minlength` attribute and replaces it with the `phone` attribute. This makes the form control now aware of the phone validation, and when the number isn’t a correct phone number we can tell by looking at the `errors.phone` property. Recall our `validator` function returns an object with `{phone: true}`, so this is where we see it returned to us. We also removed the additional error message for it being required, as our new validation covers that scenario as well. To review, when we add the `phone` attribute, the `NgModel` will validate using the Phone validator directive. Internally, the Phone validator directive registers itself with the default list of validators that `NgModel` knows about by declaring the multiprovider (a special kind of provider that can be registered more than once) for `NG_VALIDATORS`. It then implements a `validate` method, which calls the `validator` function we created at the beginning. There are a few steps here, but that’s the price we pay for the flexibility provided by Angular’s platform. Congrats! You’ve now got a custom validation directive that you can reuse on any form control, or you can create additional ones for different scenarios. Now we need to wrap up this form by handling events to either submit, cancel, or delete. ### 9.2.4 Handling submit or cancel events We’ve got all the data and validation we would like on this form, so now it’s time to handle the various events that might happen with it. The most important is to handle the submit event, but also we want to allow the user to cancel from saving the edits or delete the record if it exists. The controller already implements all the methods we need to handle these scenarios, so we just need to write up our form to call them properly. You can review the methods in there and see how they work. The first thing we should do is update our form element. Angular does another thing to forms that isn’t visible by default. It automatically implements an `NgForm` on a form even if you don’t declare a directive (unlike how you have to declare `NgModel`). When it does this, it essentially attaches an `NgForm` controller that then maintains the form controls in the form. `NgForm` provides a couple of features we’ll need; the first is that it can tell us if the entire form is valid (not just an individual field) and help us implement an event binding for submitting the form. Find the `form` element at the top of the CustomerForm template and update it to have these additional values shown in bold: ``` <form *ngIf="customer" **#form="ngForm" (ngSubmit)="save()"**> ``` First we create another template variable and reference the NgForm controller. This is the same idea we used for our form controls with NgModel, except this local template variable will reference the entire form. Then we have an `(ngSubmit)` event handler to call the `save` method. Now we just need to update our buttons at the bottom to call the correct methods. The following code in bold contains the pieces to add to the buttons in the `card actions` element near the bottom: ``` <md-card-actions> <button type="button" md-button **(click)="delete()" *ngIf="customer.id"**>Delete</button> <button type="button" md-button **(click)="cancel()"**>Cancel</button> <button type="submit" md-raised-button color="primary" **[disabled]="form.invalid"**>Save</button> </md-card-actions> ``` The first two buttons are standard buttons, so we just use the `click` event binding to call the appropriate method. The delete button is hidden if we’re creating the record by checking whether there is an ID, which is only set after creation. The submit button doesn’t have an event binding, because that’s already being handled by `ngSubmit`. But we do bind to the `disabled` property and look at the `form.invalid` property to determine if the entire form is invalid. That about wraps up template-driven forms. Everything about our form was described in the template, primarily by adding `NgModel` directives to our form controls. Using local template variables that referenced the `NgModel` of a control, we could inspect the validation errors for a field and show appropriate error messages. We also were able to build a custom validator for phone numbers that works like any default validation attribute. Finally, we handled the submit event and checked the validation of the overall form before enabling the submit button. Not too bad for a modest amount of code! The final version of the customer form can be seen here in the following listing. **Listing 9.6** Final customer form template ``` <div *tdLoading="'customer'"> <form *ngIf="customer" #form="ngForm" (ngSubmit)="save()"> <md-card> <md-card-header>Edit Customer</md-card-header> <md-card-content> <md-input-container> <input name="customer" mdInput placeholder="Customer Name" [(ngModel)]="customer.name" required #name="ngModel"> <md-error *ngIf="name.touched && name.invalid"> Name is required </md-error> </md-input-container> <md-input-container> <input name="email" mdInput type="email" placeholder="Email" [(ngModel)]="customer.email" required #email="ngModel"> <md-error *ngIf="email.touched && email.invalid"> A valid email is required </md-error> </md-input-container> <md-input-container> <input name="phone" mdInput type="tel" placeholder="Phone" [(ngModel)]="customer.phone" required phone #phone="ngModel"> <md-error *ngIf="phone.touched && phone.errors?.required"> Phone number is required </md-error> <md-error *ngIf="phone.touched && phone.errors?.phone"> Not a valid phone number </md-error> </md-input-container> </md-card-content> <md-card-actions> <button type="button" md-button (click)="delete()" *ngIf="customer.id">Delete</button> <button type="button" md-button (click)="cancel()">Cancel</button> <button type="submit" md-raised-button color="primary" [disabled]="form.invalid">Save</button> </md-card-actions> </md-card> </form> </div> ``` Now it’s time to implement the other form for creating or editing an invoice in the reactive form style. It will approach the form from the controller first and have less logic in the template to manage. ## 9.3 Reactive forms The alternative to template-driven forms, *reactive* forms, is the other way to design your forms in Angular. The name *reactive* comes from the style of programming known as reactive, where you have immutable data structures and your views never mutate them directly. That means no two-way binding is allowed. The basic idea is that your form has a copy of the original model that it uses while the user is editing the form, and upon saving, you trigger an action like saving it to the database and update the original model. Template-driven forms only have one shared model, and because values are being constantly synced between the two, there may be timing issues of values changing in multiple places. One of my favorite aspects of reactive forms is that you can use an observable to watch a particular form control for changes. I might do this to handle a task like autocomplete, for example. It’s been useful for me on several occasions, and template-driven forms don’t have a good way to do this. Reactive forms still have a template, because you need to define the markup associated with the form. The main difference in the template is you won’t use `NgForm` or `NgModel` on any of the form controls; instead we’ll use a different directive to link a particular form control in the template to the corresponding form control declared in the controller. There are a few other differences in the way that reactive forms behave. Because template-driven forms employ two-way binding concepts, they’re inherently asynchronous in their handling. During the rendering of a template-driven form, the `NgModel` directive is building the form up for you behind the scenes. This takes more than one change detection cycle, though, causing potential race conditions where you expect a form element to be registered but it hasn’t yet. This doesn’t happen with reactive forms, because you define the form in your controller, so it’s not dependent on change detection cycles. The challenges with timing of template-driven forms tend to only appear when you try to access form controls or the form itself too early, and require you to wait until the `AfterViewInit` lifecycle hook to ensure the view has fully rendered. The Angular documentation covers some details about the differences and virtues of each approach as well and is worth reviewing: [`angular.io/guide/forms`](https://angular.io/guide/forms). Setting aside some of the internal mechanical differences, let’s focus on what reactive forms look like. In a template-driven form the `NgModel` builds the form controls, but with reactive we need to define our form programmatically in the controller. When you settle on using one form approach, it’s not easy or advisable to mix them in the same form, though you could in different forms. In this section, we’ll build the InvoiceForm component form, and you can see the result in figure 9.5. It has more fields, but visually isn’t all that different from the last form. Let’s start by building the entire form for our InvoiceForm component. We already have the markup ready to go, so we need to define it for Angular. ### 9.3.1 Defining your form The first step is to define the form for our invoice, and this is done to ensure that the controller is aware of all of the aspects of the form. This will define a separate model that exists just for the form. When we load the invoice data from the API, we’ll load it into the form, rather than directly bind the form to it like we saw with `NgModel`. Angular provides a service called FormBuilder, which is a helpful tool to build a reactive form. We’ll use this to build the description of our form. It lets us define each of the form controls and any validations we want to apply to them. We’ll be editing the InvoiceForm component in this section, so start by opening src/app/invoice-form/invoice-form.component.ts and update the constructor like you see in listing 9.7. This only includes the top portion of the file—to focus on the changing pieces, which are in bold. ![c09-5.png](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/ng-ia/img/c09-5.png) **Figure 9.5** Invoice form with more controls, built in reactive style **Listing 9.7** Using FormBuilder to define the form ``` export class InvoiceFormComponent implements OnInit { **invoiceForm: FormGroup; ** ``` ``` invoice: Invoice; customer: Customer; customers: Customer[]; total = 0; constructor( private loadingService: TdLoadingService, private invoicesService: InvoicesService, private router: Router, private dialogService: TdDialogService, private customersService: CustomersService, private formBuilder: FormBuilder, private route: ActivatedRoute) { **this.invoiceForm = this.formBuilder.group({** ``` ``` **id: [''],** **service: ['', Validators.required],** [**customerId: ['', Validators.required],**](#c09-codeannotation-0021) ``` ``` **rate: ['', Validators.required],** **hours: ['', Validators.required],** **date: ['', Validators.required],** [**paid: ['']**](#c09-codeannotation-0022) ``` ``` **});** } ``` To begin, we set a property on the controller to hold our form. It’s of the `FormGroup` type, which is an object designed to hold various form controls together. Then inside of the constructor, we’ll use the FormBuilder service to build a group of controls. It accepts an object that contains properties with the name of the control set to an array that holds at least one value. The first value is the value it should hold, which we’re defaulting to empty for all of them. For some properties, we only define the default value. For other properties, we can add additional items to the array that must be validator functions. We’ll create a custom one in a little bit, but for now we’re assigning the required validation to each. That’s all we need to do to define our form. But it will always be a blank form, so when we’re editing a record we need to load the data into the form. We do this in the `OnInit` lifecycle hook where we load the data. In the following listing, you can see the snippet for the data loading and add the bolded line that sets the form state based on the data. **Listing 9.8** Setting the form state ``` this.route.params.map((params: Params) => params.invoiceId).subscribe(invoiceId => { if (invoiceId) { this.invoicesService.get<Invoice>(invoiceId).subscribe(invoice => { **this.invoiceForm.setValue(invoice); ** ``` ``` this.invoice = invoice; this.loadingService.resolve('invoice'); }); } else { this.invoice = new Invoice(); this.loadingService.resolve('invoice'); } }); ``` The `invoiceForm` has a `setValue` method, which takes a data model and sets properties based on that. Otherwise, it’s a new form, and the default values were already declared earlier in the controller when we defined the form. In the case where we’re editing and have an existing invoice, it gets set into the form after it’s been loaded from the API. Now we need to update our template so the form controls are aware of this form and its data. ### 9.3.2 Implementing the template The form controls in our template are currently unaware of our reactive form, and this step is about linking the form controls in the template and form controls defined in the controller. Form controls in a template exist like a normal HTML form by default. But for this all to work right, they need to know about the form and its current state so they can display properly. The InvoiceForm template has a couple of UI components from Material Design: a date picker and a slide toggle. These act like normal form elements, and you can learn more about them in the documentation. Much as we used `NgModel` to link a form control to the form, we’ll use a different directive called `FormControlName`. This will indicate which form control should be bound into that element, based on the name provided when we built the form. Open src/app/invoice-form/invoice-form.component.html and make the additions to the form controls, as you see in bold in the following listing, to wire up the controls. **Listing 9.9** InvoiceForm template with form controls ``` <div *tdLoading="'invoice'"> [<form *ngIf="invoice" **[formGroup]="invoiceForm"**>](#c09-codeannotation-0023) ``` ``` <md-card> <md-card-header>Edit Invoice</md-card-header> <md-card-content> <md-input-container> <input name="service" mdInput type="text" placeholder="Service" **formControlName="service"**> ``` ``` </md-input-container> <md-input-container> [<input mdInput [mdDatepicker]="picker" placeholder="Choose a date" **formControlName="date"**>](#c09-codeannotation-0026) ``` ``` <button type="button" mdSuffix [mdDatepickerToggle]="picker"></button> </md-input-container> <md-datepicker #picker></md-datepicker> <md-input-container> <input name="hours" mdInput type="number" placeholder="Hours" **formControlName="hours"**> </md-input-container> <md-input-container> <input name="rate" mdInput type="number" placeholder="Rate" **formControlName="rate"**> </md-input-container> <div> <md-select name="customerId" placeholder="Customer" **formControlName="customerId"**> <md-option [value]="customer.id" *ngFor="let customer of customers">{{customer?.name}}</md-option> </md-select> </div> <div class="toggler"> <md-slide-toggle **formControlName="paid"**>Paid</md-slide-toggle> ``` ``` </div> <div class="total"> Total: {{total | currency:'USD':true:'.2'}} </div> </md-card-content> <md-card-actions> <button type="button" md-button>Delete</button> <button type="button" md-button>Cancel</button> <button type="submit" md-raised-button color="primary">Save</button> </md-card-actions> </md-card> </form> </div> ``` The first step is to use the `FormGroup` directive to bind the form we declared to the form element. If you miss this step, the form won’t know about the model you defined. Then we just linked the form controls with the name used when we built the form, and at this point the form will now render properly. We’ll have to work out the details of saving in a little bit, but otherwise it’s a fully functional form. Now I think it would be nice for us to display the invoice total in the page so users know the invoice total based on the rate and hours input. We can do this by observing form controls, so let’s see how we can use that. ### 9.3.3 Watching changes Unlike in template-driven forms, our reactive form controller has the source of truth for the form state, and it gives us the ability to observe a form or a single control for changes. This lets us run logic that might be useful, such as validation or saving progress. In our case, we want to display the total invoice amount at the bottom, and that requires multiplying the `hours` and `rate`. Each form control exposes an observable that we can use to subscribe to changes, and we’ll use it to get both `hours` and `rate` values. The template already has a place for the total at the bottom, but it shows 0 all the time. Although we could try to do math directly in the interpolation binding, it gets a little bit messy and harder to test. We’d rather handle this in the controller. Using the form, we can get a specific control using `invoiceForm.get('hours')`. You pass a string that’s the name of the form control, and you get the instance of that control. This instance provides a number of properties and capabilities, one of which is the `valueChanges` observable. Let’s make this work by adding a little bit to the end of the `OnInit` method. You can see the snippet to add here in the following listing. **Listing 9.10** Observing state changes in the form ``` Observable.combineLatest( this.invoiceForm.get('rate').valueChanges, this.invoiceForm.get('hours').valueChanges ).subscribe(([rate = 0, hours = 0]) => { this.total = rate * hours; }); ``` This snippet might be new to you, but we’re using the `combineLatest` operator from RxJS. This operator takes two observables, which are references to the stream of value changes of the `rate` and `hours` controls, and merges them into one. We can then get the latest values from the stream and multiply them to get the current total. Imagine you had more complex math here, such as adding in taxes, or perhaps there was another value to plug in. Doing math in the interpolation binding directly would quickly get out of hand, and this provides you direct access to run calculations when values change. This is also a pattern of reactive, because in this case you’re reacting to changes in the form state and updating the total. When you use `invoiceForm.get('rate')`, you’re also able to access the same properties from table 9.1 (form control status properties). You can check whether the control is `valid`, `pristine`, `touched`, or what errors exist. This might be helpful for you to do additional validation or checks. We can also implement our own validator functions as we did before and see how to plug them into the form. ### 9.3.4 Custom validators with reactive forms Previously, when we implemented custom validation, we created both a validation function and a directive. With reactive forms, we only need to create the validation function and then add it into the form when we create it with FormBuilder. We’ll update our validation messages as well to use the validation rules we defined, as you see in figure 9.6. ![c09-6.png](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/ng-ia/img/c09-6.png) **Figure 9.6** Validation rules in the invoice form Imagine our invoicing application had the restriction that hours had to be always rounded to the quarter hour—like 1 hour, 1.25, 1.5, 1.75, or 2\. It should not allow values like 1.1 or 1.7\. This is fairly common when invoicing by time, and the way we enforce this is to validate the `hours` input and see if it’s valid by quarter hour. We’ll build a validator function like we did previously, but we won’t have to wrap it up in a directive. Start by making a new file at src/app/validators/hours.validator.ts, and add the code from the following listing to it. **Listing 9.11** Hour validator ``` import { AbstractControl, ValidatorFn } from '@angular/forms'; [export function HoursValidator(control: AbstractControl) : { [key: string]: any } {     ](#c09-codeannotation-0028) ``` ``` return (Number.isInteger(control.value * 4)) ? null : { hours: true }; ``` ``` } ``` This is very succinct, but in contrast to the previous validator, we’re directly exporting the validation function. When we created a custom validator earlier for a directive, we needed a function to return a validator function, whereas here we export the validator function directly. When the validator function runs, it multiplies the value by 4 and checks if it is an integer. That means any valid hourly increment will multiply an integer by 4 and return null for valid. Otherwise, it returns the object with the key and Boolean. Now we need to make our form aware of this validation function, and that’s done when we construct the form using FormBuilder. In the component controller, update the form definition like you see in the following listing. You’ll need to import the `HoursValidator` function into the file. **Listing 9.12** Using HoursValidator ``` this.invoiceForm = this.formBuilder.group({ id: [''], service: ['', Validators.required], customerId: ['', Validators.required], rate: ['', Validators.required], [hours: ['', **[Validators.required, HoursValidator]**],](#c09-codeannotation-0030) ``` ``` date: ['', Validators.required], paid: [''] }); ``` Because we’re directly constructing the form, we just need to pass the custom validation function into the control. Notice how the `hours` control also now has an array for the second item in the array. That’s because if you have multiple validators, they need to be grouped here. The form control takes the default value, synchronous validators, and asynchronous validators as a third array item. We haven’t looked at async validators, but the only difference is that they might take a moment to run. Imagine you needed a validator that checked whether a username was already taken; that probably requires making an API call. The only difference when you implement an async validator is that you need to return a promise or observable, and Angular handles it. We’d also like to show validation errors in the template, so we’ll need to add the same type of error messages we saw earlier. But the way we access the form elements to check their validity is slightly different. Open the template again and update the fields with error messages, as you see in the following listing. **Listing 9.13** Validation messages ``` <md-card-content> <md-input-container> <input name="service" mdInput type="text" placeholder="Service" formControlName="service"> **<md-error *ngIf="invoiceForm.get('service').touched && invoiceForm.get('service').invalid"> ** ``` ``` **Service is required** **</md-error>** </md-input-container> <md-input-container> <input mdInput [mdDatepicker]="picker" placeholder="Choose a date" formControlName="date"> <button type="button" mdSuffix [mdDatepickerToggle]="picker"></button> **<md-error *ngIf="invoiceForm.get('date').touched && invoiceForm.get('date').invalid">** **Date is required** **</md-error>** </md-input-container> <md-datepicker #picker></md-datepicker> <md-input-container> <input name="hours" mdInput type="number" placeholder="Hours" formControlName="hours"> **<md-error *ngIf="invoiceForm.get('hours').touched && invoiceForm.get('hours').invalid">** **Hours must be in quarter hour increments** **</md-error>** </md-input-container> <md-input-container> <input name="rate" mdInput type="number" placeholder="Rate" formControlName="rate"> **<md-error *ngIf="invoiceForm.get('rate').touched && invoiceForm.get('rate').invalid">** **Hourly rate is required** **</md-error>** </md-input-container> <div> <md-select name="customerId" placeholder="Customer" formControlName="customerId"> ``` ``` [<md-option [value]="customer.id" *ngFor="let customer of customers">{{customer?.name}}</md-option>](#c09-codeannotation-0032) </md-select> </div> <div class="toggler"> <md-slide-toggle formControlName="paid">Paid</md-slide-toggle> </div> <div class="total"> Total: {{total | currency:'USD':true:'.2'}} </div> </md-card-content> ``` Here we’ve added the same `MdError` to display errors, except we use `invoiceForm.get('rate')` to access the form control. The same properties from the earlier table are still available to you, but instead of having a local template variable to get a reference to it, we reference it from the form itself. Now that we have the form validated as we would like, we need to be able to submit it. Let’s see how that’s done with reactive forms now. ### 9.3.5 Handling submit or cancel events The final step is to submit the form when it’s ready. The steps are almost identical, except we manage the data in a different way before we submit it to the service. The `NgSubmit` event binding is still available for us to capture submit events to handle, so we’ll use that again. Open the InvoiceForm component template again and update the form element like you see here in bold: ``` <form *ngIf="invoice" [formGroup]="invoiceForm" **(ngSubmit)="save()"**> ``` While you have the template open, let’s also wire up the buttons at the bottom. Add the bolded parts to your buttons: ``` <md-card-actions> <button type="button" md-button **(click)="delete()" *ngIf="invoice.id"**>Delete</button> <button type="button" md-button **(click)="cancel()"**>Cancel</button> <button type="submit" md-raised-button color="primary" **[disabled]="invoiceForm.invalid"**>Save</button> </md-card-actions> ``` Here we’re implementing the click handlers on the delete and cancel buttons, and also disabling the save button unless the form is valid. Notice how we’re using the `InvoiceForm` properties to determine the form state, similar to how we used `NgForm` with template-driven forms. The last step is to update the `save` method in the controller so it gets its data from the form. Because the data was bound into the form when we loaded the component, we need to extract it back out before we save. Update the `save` method as you see here in bold: ``` save() { if (this.invoice.id) { this.invoicesService.update<Invoice>(this.invoice.id, **this.invoiceForm.value**).subscribe(response => { this.viewInvoice(response.id); }); } else { this.invoicesService.create<Invoice>(**this.invoiceForm.value**).subscribe(response => { this.viewInvoice(response.id); }); } } ``` You can see that when we need to get the data back out of the form, we can look at the `invoiceForm.value` property. This gives us an object representing the same form model with the values for each field. We pass this into the service to either create or update a record and see our values being saved correctly. We’re now finished with our invoice form, and you can see both the controller and template in listings 9.14 and 9.15 to ensure you have everything correct. **Listing 9.14** InvoiceForm component controller ``` import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Params, Router } from '@angular/router'; import { TdLoadingService, TdDialogService } from '@covalent/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { InvoicesService, Invoice, CustomersService, Customer } from '@aia/services'; import { Observable } from 'rxjs/Observable'; import 'rxjs/add/observable/combineLatest'; import { HoursValidator } from '../validators/hours.validator'; @Component({ selector: 'app-invoice-form', templateUrl: './invoice-form.component.html', styleUrls: ['./invoice-form.component.css'] }) export class InvoiceFormComponent implements OnInit { invoiceForm: FormGroup; invoice: Invoice; customer: Customer; customers: Customer[]; total = 0; constructor( private loadingService: TdLoadingService, private invoicesService: InvoicesService, private router: Router, private dialogService: TdDialogService, private customersService: CustomersService, private formBuilder: FormBuilder, private route: ActivatedRoute) { this.invoiceForm = this.formBuilder.group({ id: [''], service: ['', Validators.required], customerId: ['', Validators.required], rate: ['', Validators.required], hours: ['', [Validators.required, HoursValidator]], date: ['', Validators.required], paid: [''] }); } ngOnInit() { this.loadingService.register('invoice'); this.loadingService.register('customers'); this.customersService.query().subscribe(customers => { this.customers = customers; this.loadingService.resolve('customers'); }); this.route.params.map((params: Params) => params.invoiceId).subscribe(invoiceId => { if (invoiceId) { this.invoicesService.get<Invoice>(invoiceId).subscribe(invoice => { this.invoiceForm.setValue(invoice); this.invoice = invoice; this.loadingService.resolve('invoice'); }); } else { this.invoice = new Invoice(); this.loadingService.resolve('invoice'); } }); Observable.combineLatest( this.invoiceForm.get('rate').valueChanges, this.invoiceForm.get('hours').valueChanges ).subscribe(([rate = 0, hours = 0]) => { this.total = rate * hours; }); } save() { if (this.invoice.id) { this.invoicesService.update<Invoice>(this.invoice.id, this.invoiceForm.value).subscribe(response => { this.viewInvoice(response.id); }); } else { this.invoicesService.create<Invoice>(this.invoiceForm.value).subscribe(response => { this.viewInvoice(response.id); }); } } delete() { this.dialogService.openConfirm({ message: 'Are you sure you want to delete this invoice?', title: 'Confirm', acceptButton: 'Delete' }).afterClosed().subscribe((accept: boolean) => { if (accept) { this.loadingService.register('invoice'); this.invoicesService.delete(this.invoice.id).subscribe(response => { this.loadingService.resolve('invoice'); this.invoice.id = null; this.cancel(); }); } }); } cancel() { if (this.invoice.id) { this.router.navigate(['/invoices', this.invoice.id]); } else { this.router.navigateByUrl('/invoices'); } } private viewInvoice(id: number) { this.router.navigate(['/invoices', id]); } } ``` **Listing 9.15** InvoiceForm component template ``` <div *tdLoading="'invoice'"> <form *ngIf="invoice" [formGroup]="invoiceForm" (ngSubmit)="save()"> <md-card> <md-card-header>Edit Invoice</md-card-header> <md-card-content> <md-input-container> <input name="service" mdInput type="text" placeholder="Service" formControlName="service"> <md-error *ngIf="invoiceForm.get('service').touched && invoiceForm.get('service').invalid"> Service is required </md-error> </md-input-container> <md-input-container> <input mdInput [mdDatepicker]="picker" placeholder="Choose a date" formControlName="date"> <button type="button" mdSuffix [mdDatepickerToggle]="picker"></button> <md-error *ngIf="invoiceForm.get('date').touched && invoiceForm.get('date').invalid"> Date is required </md-error> </md-input-container> <md-datepicker #picker></md-datepicker> <md-input-container> <input name="hours" mdInput type="number" placeholder="Hours" formControlName="hours"> <md-error *ngIf="invoiceForm.get('hours').touched && invoiceForm.get('hours').invalid"> Hours must be in quarter hour increments </md-error> </md-input-container> <md-input-container> <input name="rate" mdInput type="number" placeholder="Rate" formControlName="rate"> <md-error *ngIf="invoiceForm.get('rate').touched && invoiceForm.get('rate').invalid"> Hourly rate is required </md-error> </md-input-container> <div> <md-select name="customerId" placeholder="Customer" formControlName="customerId"> <md-option [value]="customer.id" *ngFor="let customer of customers">{{customer?.name}}</md-option> </md-select> </div> <div class="toggler"> <md-slide-toggle formControlName="paid">Paid</md-slide-toggle> </div> <div class="total"> Total: {{total | currency:'USD':true:'.2'}} </div> </md-card-content> <md-card-actions> <button type="button" md-button (click)="delete()" *ngIf="invoice.id">Delete</button> <button type="button" md-button (click)="cancel()">Cancel</button> <button type="submit" md-raised-button color="primary" [disabled]="invoiceForm.invalid">Save</button> </md-card-actions> </md-card> </form> </div> ``` That covers the majority of what you need to know about both reactive and template-driven forms. There are certainly more minor features that exist for additional cases, but this foundation should get you building forms, and you can learn about other features as you go. ### 9.3.6 Which form approach is better? That is a trick question, to my mind, though you probably want a bit more of an explanation. Rather than tell you to use one and never the other, I’ll share from my experience why I use both. Excluding the mechanical differences of the two form libraries, I find the most important aspect is how they approach defining the form. The patterns they employ can work in most situations. Most of the time I suggest reactive forms. I like the guarantees reactive provides and the way you define the model and let the template react. I prefer my templates to reflect state, not create state. By that I mean how `NgModel` creates the controls for you behind the scenes and binds data up to the controller. If you need an answer, I would recommend reactive forms, if you really pinned me down. But you may have noticed this is the first time we’ve seen reactive forms in the book. Sometimes it’s simpler to use `NgModel`, especially when it’s a single form field. In simple scenarios, I find template-driven forms to be more approachable with low overhead, but when a form becomes more complex, then I recommend reactive forms. I think the most important thing is to be consistent in your applications. Although you can mix and match as much as you like, there’s a mental drawback to that when you write and test them. Before I close out the chapter, let’s see how to implement your own form controls in cases where your application needs controls that don’t exist out of the box or in libraries. ## 9.4 Custom form controls There are scenarios where your application requires a different form control that isn’t defined in HTML or in a third-party library. All form controls have a few basic requirements, and Angular already implements them for the built-in HTML form elements. Regardless of whether you use reactive or template-driven forms, there has to be some logic to write up the native HTML element (or custom component) with the forms library. There are essentially two places to track the current value of a form control: in the form and the control. Angular provides the `ControlValueAccessor` interface as a way to implement a custom control that works with forms, which we’ll use in conjunction with the Angular Material library components to create our own custom control. In our application, there are several candidates for creating custom form controls, but we’ll be transforming the current `hours` input field from the invoice form into a custom form control. We’ll implement some basic features that make it easier to use, but also encapsulate the internal logic of the control. As you see in figure 9.7, the `hours` form field now has several buttons underneath that help you dial in the value by smaller increments. As you change the values, the form element will continue to validate and update the total invoice value at the bottom, as you would expect. Our first step is to create a new component to house our custom control. To do this, use the CLI as you see here: ``` ng generate component hours-control ``` Once the component is created, open the src/app/hours-control/hours-control.component.ts file and replace the contents with what you see in listing 9.16. There’s a lot happening in this file, so we’ll look at the various pieces closely. ![c09-7.png](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/ng-ia/img/c09-7.png) **Figure 9.7** New `hours` custom control that connects with Angular forms **Listing 9.16** HoursControl controller ``` import { Component, forwardRef } from '@angular/core'; import { ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, FormControl } from '@angular/forms'; import { HoursValidator } from '../validators/hours.validator'; @Component({ selector: 'app-hours-control', templateUrl: './hours-control.component.html', styleUrls: ['./hours-control.component.css'], providers: [{ ``` ``` provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => HoursControlComponent), multi: true }, { provide: NG_VALIDATORS, useExisting: forwardRef(() => HoursControlComponent), multi: true [}]](#c09-codeannotation-0033) }) export class HoursControlComponent implements ControlValueAccessor {      ``` ``` hours = 0; ``` ``` validateFn = HoursValidator; onChange = (v: any) => {}; update() { ``` ``` this.onChange(this.hours); } keypress($event) { ``` ``` if ($event.key === 'ArrowUp') { this.setValue(.25); } else if ($event.key === 'ArrowDown') { this.setValue(-.25); } } setValue(change: number) { ``` ``` this.hours += change; this.update(); } validate(control: FormControl) { ``` ``` return this.validateFn(control); } writeValue(value: any) { ``` ``` if (value !== undefined) { this.hours = value; } } registerOnChange(fn) { ``` ``` this.onChange = fn; } registerOnTouched() {} ``` ``` } ``` There’s a lot happening here in a short amount of space, so let’s break things down. The HoursControl component implements the `ControlValueAccessor` interface, which ensures that your form control is designed to work correctly with Angular forms. It requires that a control implements the three methods found at the end of the controller: `writeValue`, `registerOnChange`, and `registerOnTouched`. The `writeValue` method is used by Angular to pass a value into the form control from the form itself. This is similar to binding a value into the component, though it works with the form controls like `NgModel,` and it passes the value from the form into the control. The `registerOnChange` method accepts a function that the form library will pass in that your control needs to call whenever the value changes. It stores this function on the `onChange` property of the controller, and the default `noop` function is defined so the component compiles correctly. In other words, it gives you a method to call that passes the current form value up to the form. The `registerOnTouch` method isn’t implemented here, but it allows you to accept a method to handle touch events. This might be useful on controls that have some kind of touch impact, such as a toggle switch. But there isn’t much for us to implement for a form control that takes a number input. In the component metadata, we see some providers are declared. Recall that we did something similar when we created our directive for validation. Here we have to declare two providers—the first is to register this component with `NG_VALUE_ACCESSOR`. This marks this component as a form control and registers it with dependency injection so Angular can access it later. The second is to register the component with `NG_VALIDATORS`. This control has validation internally, so we need to register the control on the validators provider for Angular to access later. Because the control has a `validate` method, Angular can call this method to determine whether the control is valid or not. This is the same as with creating a `Validator` directive as we did earlier in listing 9.4. In this case, though, we import the `HoursValidator` function and reuse it inside the component. The rest of the methods are there to handle the internal actions of the control. The `update` method is responsible for calling the change event handler, which will alert the form that the control’s internal state value has changed. The `keypress` method is just a nice feature that allows us to bind to the `keyup` event, and if the user pressed up or down arrows, it will increment or decrement the current value by 0.25\. Finally, the `setValue` method is called by the row of buttons to add or subtract from the current value. In summary, this component really has three roles. First, it implements an internal model to track the current value of the control (the number of hours) and allows that value to be manipulated by buttons or keypresses. Second, it provides validation and ensures the number provided is to a quarter of an hour. Third, it wires up the necessary methods for Angular forms to be made aware of the current state of the control. Next we need to look at the template, so let’s go ahead and implement it so we can see everything together. Open src/app/hours-control/hours-control.component.html and replace its contents with the code from the following listing. **Listing 9.17** HoursControl template ``` <md-input-container> <input name="hours" mdInput type="number" placeholder="Hours" ``` ``` [[(ngModel)]="hours" hours (keyup)="keypress($event)"](#c09-codeannotation-0043) #control="ngModel" (change)="update()"> <md-error *ngIf="control.touched && control.invalid"> ``` ``` Hours must be a number in increments of .25 </md-error> </md-input-container> <div layout="row"> <button type="button" md-button flex (click)="setValue(1)">+ 1</button> ``` ``` <button type="button" md-button flex (click)="setValue(.25)">+ .25</button> <button type="button" md-button flex (click)="setValue(-.25)">- .25</button> <button type="button" md-button flex (click)="setValue(-1)">- 1</button>       </div> ``` In our template, we encapsulate the entire form control that we want to provide, which includes the buttons and the original input box. Because we’re still accepting text input, we use a standard `input` element. But we’re also setting up the `NgModel`, a validation directive (which we’ll create next), and two event bindings for `keyup` and change. This control has built-in error validation and uses the same Angular Material patterns we saw earlier in the chapter. It shows a message if the control is invalid, and if the control has been focused on. It’s nice that the validation messaging is built in, because it doesn’t need to be implemented later. If you have the same controls in many places with the same validation, this might be useful. If you want to ensure that this control is more reusable, with different validation types, this might not be ideal. The last set of elements is the buttons that add or subtract from the current state. When they’re clicked, the internal hours model is updated, and the form is alerted to the change as well. There’s a bit of CSS that we need to add for the control to look correct, so open src/app/hours-control/hours-control.css and add the code from the following listing. **Listing 9.18** HoursControl stylings ``` :host { width: 100%; display: block; } md-input-container { width: 100%; } button { padding: 0; min-width: 25%; } ``` This makes sure that a few pieces of the control play nicely with the UI library, since we’ve changed the way it usually expects elements to be laid out. You probably noticed that we have a validation directive for hours on the input, but we haven’t created a directive version of this validator yet. That’s simple to do, and we need to do so before we use this control. Create a new directive by running the following command: ``` ng generate directive validators/hours ``` Then open the directive file at src/app/validators/hours.directive.ts and replace its contents with the code in the following listing. **Listing 9.19** Hours validation directive ``` import { Directive } from '@angular/core'; import { Validator, AbstractControl, NG_VALIDATORS } from '@angular/forms'; import { HoursValidator } from './hours.validator'; @Directive({ [selector: '[hours][ngModel]',](#c09-codeannotation-0046) ``` ``` providers: [{ provide: NG_VALIDATORS, useExisting: HoursDirective, multi: true }] }) export class HoursDirective implements Validator { private validator = HoursValidator; validate(control: AbstractControl): { [key: string]: any } { return this.validator(control); } } ``` This directive looks almost identical to the one we created earlier, except it references the `HoursValidator` function. I recommend reviewing the details from listing 9.3 for specifics if you have any questions. Now we have everything we need to use our new control. This control is meant to be used in the InvoiceForm component, so open the template found at src/app/invoice-form/invoice-form.component.html and replace the existing hours input element with our newly created form control, as you see here in bold in the snippet of the whole template: ``` <md-datepicker #picker></md-datepicker> **<app-hours-control formControlName="hours"></app-hours-control>** <md-input-container> <input name="rate" mdInput type="number" placeholder="Rate" formControlName="rate"> </md-input-container> ``` Because this is a custom form control, we can use it with reactive forms or template-driven forms without issue. Congratulations! You’ve created your own control and can now make as many as you want. But wait—there are a couple of caveats to building your own controls, and to this particular example. Custom controls seem like a great idea, but they can also be a lot of work to build properly. For example, does your custom control work well on mobile or touch devices? Does it have proper support for screen readers and other accessibility requirements? Does it work in multiple applications or is it too custom for your application? These are important questions to ask, and also to verify whether your controls work for the largest set of users. One of the major reasons I advocate using an existing UI library is that the good libraries will have solved these issues ahead of time for you. Before going off to build a custom control, see if you can think clearly about the user experience and determine whether an existing control could be used instead of a new one. Users tend to struggle more with custom form elements that they haven’t seen before, so it can be very practical to adjust the application slightly so it can use already existing controls before you make a new one. Because this chapter is using a specific UI library, I’ve implemented the form controls in a way that fits with that library. Therefore, it’s limited to being used only with Angular Material, which may limit the use of your control. On the other hand, if you can expect to always use Angular Material (or the UI library of choice), then the custom control may be saving you a lot of repetition. At the time of writing, the Angular Material library doesn’t support creating your own form controls that work nicely with the input container. (See [`github.com/angular/material2/issues/4672`](https://github.com/angular/material2/issues/4672).) This is why I ultimately encapsulated the entire form control and surrounding markup. It makes the example more verbose than you might need, so you should consider how to simplify your form controls if possible. This example uses a standard `input` element inside, which is why `NgModel` was used, but in many custom form controls you may not have an input, so you wouldn’t use `NgModel`. In those cases, you simply make sure that as the control state changes (such as a toggler that goes from true to false), you call the change handler so the form knows the state changes. That wraps up forms, both reactive and template-driven, as well as creating your own controls. Forms are very important to most applications, and you should now have the tools to craft feature-rich and usable forms. ## Summary We’ve built two forms, in both the reactive and template-driven styles in this chapter. Along the way we also managed to learn about most of what forms have to offer. Here’s a brief summary of the key takeaways: * Template-driven forms define the form using `NgModel` on form controls. * You can apply normal HTML validation attributes, and `NgModel` will automatically try to validate based on those rules. * Custom validation is possible through a custom validator function and directive, which gets registered with the built-in list of validators. * The `NgForm` directive, though it can be transparent, exposes features to help manage submit events and overall form validation inspection. * Reactive forms are different in that you define the form model in the controller and link form controls using `FormControlName`. * You can observe the changes of a form control with reactive forms and run logic every time a new value is emitted. * Reactive forms declare validation in the controller form definition, and creating custom validations is easier because they don’t require a directive. * Ultimately, both form patterns are available to you. I tend to use reactive forms, especially as the form gets more complex. * Creating a new form control requires implementing the `ControlValueAccessor` methods and registering it with the controls provider.

10

测试你的应用程序

本章涵盖

  • 测试你的 Angular 应用程序的价值

  • 如何设置和创建单元测试以测试单个部分

  • 指令、服务、组件和管道的单元测试策略

  • 如何实现端到端测试以测试整个应用程序

  • 其他测试策略

我们构建的所有应用程序都能从测试中受益。有些开发者害怕编写测试,而另一些开发者可能想知道为什么我没有从一开始就涵盖测试。我认为测试是必不可少的,尽管它需要一套新的概念和工具,这就是为什么我等到现在才讲。

Angular 被设计成高度可测试的,使用 Angular CLI 创建的项目会自动为我们设置基本的测试脚手架和工具。在本章中,我们将使用 CLI 提供的工具和配置。有使用其他工具经验的开发者可以选择自己设置其他东西,并使用本章中的思想和概念与这些工具一起使用。

当开发者谈论测试时,他们通常指的是单元测试。如果你不熟悉单元测试,这些是在隔离状态下测试应用程序单个部分(单元)的方法。例如,我们将测试一个组件,而无需在完整的应用程序中渲染,以及一个管道在没有在模板中使用的情况下进行测试。如果我们将单元测试与制造汽车进行比较,它就像在组装成整车之前,单独测试每个部分,如轮子、电子设备、引擎等。这是我们将在本章中详细探讨的第一种测试类型。

端到端(e2e)测试,也称为集成测试,是一种测试整个应用程序的方法。与单独隔离每个单元相反,这些测试用于确保所有部分协同工作。在我们的汽车类比中,这就像进行一次试驾并断言所有部件确实协同工作得很好。

如果你以前从未测试过你的代码,编写好的测试将降低维护应用程序所需的努力水平,并让你更有信心它按预期运行。不同的项目有不同的要求,但进行测试通常意味着你要么每次更改都手动测试所有内容,要么人们无法依赖你的应用程序。根据谁决定你应用程序的测试策略,你应该确保主张花时间和精力编写高质量的测试,以确保一切正常工作。

测试在 Angular 中是一个如此大的主题,以至于有一本专门介绍它的书,名为《Testing Angular Applications》(Manning,2018)。我在这里非常快速地涵盖了大量的基础知识,但还有更多可以深入研究的内容,我建议你看看这本书。你可以在www.manning.com/books/testing-angular-applications找到它。

在本章中,我们将重点介绍如何为组件、服务、管道和指令编写单元测试。我们还将创建一些端到端测试,以导航和检查页面上的元素。这将非常实用,因此为了让我们开始,让我们设置示例。

10.1 测试工具和设置本章示例

股票跟踪应用程序再次出现,因为它具有最佳的测试组合。我们将使用第八章中创建的自定义管道和指令的版本,这样我们也可以测试它们。

我们不会修改应用程序的行为(除了几个示例,测试会向我们展示如何改进我们的代码)——我们只会将代码添加到各种测试文件中。当我们生成新的组件、管道、指令或其他内容时,Angular CLI 已经为我们生成了测试文件,因此我们只需实现测试即可。

此代码位于不同的仓库中,你可以使用 Git 如下获取它:

git clone https://github.com/angular-in-action/testing 
cd testing
git checkout start 

否则,你可以从github.com/angular-in-action/testing/archive/start.zip下载归档文件并解压文件。

如同往常,你需要运行npm install来下载所有依赖项,然后你可以运行ng serve来启动本地开发服务器。我已经清除了所有测试,这样你就可以轻松运行命令。它将提到一个错误,例如“Executed 0 of 0”,但一旦我们创建了一个测试,这个错误就会消失——所以现在你可以忽略它。

让我们来看看 Angular 自带的各种测试工具。

10.1.1 测试工具

Angular 提供了一套有见地的工具选择,用于创建和运行测试。一些工具仅适用于单元测试或端到端测试,而另一些则适用于两者。测试必须在真实浏览器中运行,因为这是 Web 应用程序执行的环境,因此有一些技术能够使测试执行 JavaScript 代码。

Jasmine 是第一个工具。它是一个用于编写测试的框架。Jasmine 是 JavaScript(或在我们的情况下是 TypeScript),但它能够连接到浏览器,使用浏览器的 JavaScript 引擎执行测试。当我们在本章中编写测试时,你将始终使用 Jasmine 框架。一个测试最终由任何设置和拆除特定测试案例所需的代码组成,然后是一组期望,以验证你的代码按预期行为。例如,你将使用管道转换一些文本,以查看它是否返回预期的结果。你可以在jasmine.github.io/了解更多关于 Jasmine 的信息。

接下来是 Karma,这是一个最初由 Angular 团队创建的工具,用于帮助执行单元测试。Karma 可以控制任何类型的框架,但它已经配置好了,可以直接与 Angular CLI 一起控制 Jasmine。它可以做一些有趣的事情,比如同时运行多个浏览器的测试,并为持续集成工具提供功能。你可以在karma-runner.github.io/1.0/index.html了解更多关于 Karma 的信息。

Protractor 是下一个工具。它旨在通过帮助管理测试运行方式(如 Karma)以及通过提供一个额外的测试框架,该框架与 Jasmine 一起使用来测试和控制浏览器,就像真实用户可能做的那样来帮助运行端到端类型的测试。它是基于 WebDriver 的,这是一个关于如何通过自动化控制浏览器的规范。更多了解请访问www.protractortest.org

你可能已经注意到,所有这些工具都需要浏览器,你有一些选择可以使用哪些浏览器。我通常推荐使用 Chrome 进行本地开发,因为它开箱即用,但你也应该了解如何使用这些工具在不同的浏览器上测试,以确保一切正常。不是所有浏览器都支持相同的功能或以完全相同的方式表现,所以请记住这一点。

在幕后还有一些其他的小工具;它们是这些主要工具的插件。本章的目的不是涵盖所有可能的配置方式。我们将专注于开箱即用的功能,随着你的需求变化,你可以扩展到更复杂的场景。

10.2 单元测试

我将从单元测试开始,因为很可能你会编写比其他类型的测试更多的单元测试。它们是你可以为应用程序编写的最低级别的测试——它们验证最小的部分是否按预期工作。记得之前提到的在汽车组装前测试每个组件的类比,以确保车辆使用的是高质量零件。

你可以用多种方式编写单元测试,但 Angular 有一些在大多数场景中都适用的通用指南。从根本上说,编写单元测试有两种方式:

  • 创建真正隔离的单元测试,其中你自己构建实体

  • 使用 Angular 渲染测试床模块以渲染实体并验证行为

真正隔离的单元测试最适合管道和,通常,服务,其中你在 Angular 之外创建类的实例。这意味着你将无法使用依赖注入,你将看到你可以如何模拟或手动注入依赖项。这些测试非常快,并将你的测试表面积减少到最低水平。如果你可以使用这种方法编写测试,建议你这样做。

测试床模块测试更为复杂,但更适合在需要验证在 Angular 应用程序上下文中渲染内容时使用组件和有时是指令。在这种情况下,你实际上是在创建一个临时的 Angular 应用程序,它包含运行所需的最小数量的东西。正如你可以想象的那样,这些运行速度略慢,但提供了更多构建测试的能力。

10.2.1 单元测试的结构

所有单元测试都共享一些基本概念,所以让我们先从更仔细地查看单元测试的基本结构开始。每个测试都有几个部分,其中一些是可选的。这一切都来自 Jasmine 测试框架,所以如果你需要更多关于某个部分的背景信息,你总是可以查看 Jasmine 文档。

部分如下:

  • describe—一个用于包含覆盖相同整体实体的测试集的容器,例如描述管道的测试套件。

  • it—一个用于描述单个功能的单个测试的容器,例如测试处理保存的特定组件方法。

  • expect—一种断言值满足预期要求的方式,例如方法响应必须等于 true。

  • beforeEach/beforeAll—这些是在每个单独的测试之前或所有测试之前执行代码的一种方式,例如设置逻辑来为每个测试构造实体的新实例。它们只适用于定义它们的 Describe 块内。

  • afterEach/afterAll—这些允许你在每个测试之后或所有测试之后执行代码,例如在测试之间清理任何内容或重置任何共享状态。它们只适用于定义它们的 Describe 块内。

在大多数测试中,你会使用所有这些,除了 afterEach/afterAll 块。你将想要确保每个测试都使用你想要测试的对象的干净实例来设置,而 beforeEach 通常是最实际的方法。

让我们开始编写一些测试,看看一切是如何运作的。我们首先关注纯管道,这样我们可以看到最基本的测试类型以及这些部分是如何一起工作的。

10.2.2 测试管道

管道通常是测试中最容易的事情,尤其是纯管道。因为它们是只实现一个函数的简单类,我们可以轻松地运行它们,而无需创建一个完整的 Angular 应用程序。

我认为直接进入测试并逐步了解基本步骤是最好的。我们将从编写我们的 Change 管道测试开始,该测试可以在 src/app/pipes/change.pipe.ts 找到。这个管道是纯的,唯一困难的地方是它作为服务注入了两个其他管道。打开 src/app/pipes/change.pipe.spec.ts 中的测试规范文件,并用以下列表中的内容替换其内容。

列表 10.1 修改管道测试

import { ChangePipe } from './change.pipe';     
import { CurrencyPipe, PercentPipe } from '@angular/common';     

describe('ChangePipe', () => {     
 const currencyPipe = new CurrencyPipe('en-us');
 const percentPipe = new PercentPipe('en-us');
 const pipe = new ChangePipe(currencyPipe, percentPipe);
 const stock = {
 symbol: 'abc',
 lastTradePriceOnly: 10,
 change: 1,
 changeInPercent: 0.05
 };

 it('create an instance', () => {
 expect(pipe).toBeTruthy();
 });

 it('should transform a stock value', () => {
 expect(pipe.transform(stock)).toEqual(`$1.00 (5.00%)`);
 stock.change = -3.45;
 stock.changeInPercent = -0.0345;
 expect(pipe.transform(stock)).toEqual(`-$3.45 (-3.45%)`);
  });
}); 

这个测试首先导入构建 Change 管道所需的组件,因为它依赖于 Currency 和 Percent 管道,所以这些也被导入。然后你创建一个 describe 块来包裹文件的其余部分,因为所有其余的内容都将位于 Change 管道的上下文中。注意,describe 块接受两个参数:一个字符串和一个函数。字符串用于测试套件日志记录,以帮助确定测试是如何运行的,而函数则在测试计划运行时执行。

describe 块内部,你设置了一些变量,用于手动创建 Change 管道的实例。在单独构建这些实体时,你必须使用新操作符来实例化实例,并将任何配置传递给对象构造函数。还有一个变量用于存储样本 stock 对象,你稍后会用它来验证行为。

第一个 it 块描述了一个简单的测试,以确保管道创建成功。通常,有一个简单的测试来验证在创建过程中没有抛出错误是非常有用的。注意,it 语句接受两个参数,一个字符串和一个函数,就像 describe 块一样。在这种情况下,当测试运行时,字符串会被添加到控制台输出的 describe 块中,这样你就可以跟踪 describe 块和通过或失败的特定测试。

it 块内部,有一个单独的 expect 语句。这是触发成功或失败的测试语句。如果管道构建不正确,它将标记此测试为失败,并提供相关的错误信息。

下一个 it 块通过传递样本 stock 对象并期望它等于特定的字符串来测试 transform 方法。我还更改了 stock 对象的值,并用负值测试它,以验证这种情况。你可以编写一系列断言来测试各种场景,这在 transform 方法更复杂时非常有用。

现在需要运行测试,你可以通过在命令行中运行 ng test 命令来完成。这将打开一个新的浏览器窗口,显示测试结果(类似于你在图 10.1 中看到的结果),以及命令行中的结果。恭喜你,你已经编写了你的第一个测试!

这个测试测试了 Change 管道的每一行,我们可以通过生成测试覆盖率报告来查看。你可以通过运行带有 –cc 标志的测试命令来生成覆盖率报告,如下所示:

`ng test –cc` 

它将生成一个名为 coverage 的新目录,您可以在浏览器中查看报告。这允许您以可视化的方式查看哪些代码行被执行或未执行,并有助于识别您可能遗漏的区域。但这并不一定意味着您的测试是全面的。您可能以某种方式测试代码,使其仅在通过条件下执行,但如果您提供不同的参数,它可能会失败。在编写测试时,请注意这一点。

测试一个不纯的管道并没有什么特别之处,除了您必须跟踪一些状态。您可以通过查看章节示例的最终版本来查看其他管道测试,并看到更多示例的实际操作。

10.2.3 测试服务、存根和模拟 HTTP 请求

服务是另一种类型的实体,有时可以通过创建一个隔离的实体来测试。我们将为我们的服务这样做,尽管您将开始看到为什么这对许多测试来说可能是一个棘手的事情。

一些服务可能几乎没有依赖项。在这种情况下,我们的 Stocks 服务只有一个:Angular 的 HttpClient 服务。技巧在于我必须手动构建 HttpClient 服务,这是可能的,但如果您有很多依赖项,这可能会变得难以控制。我们将在下一节中查看如何使用测试模块,您还可以查看这个项目的最终版本,其中包含测试模块版本的此测试的注释。

c10-1.png

图 10.1 完整的测试规范报告来自章节示例

因为这项服务使用 HttpClient 服务,所以我们还需要提供一个模拟 HTTP 请求的解决方案。我们不希望在单元测试中执行真实的 HTTP 调用,因为它们速度慢、不可预测,并且需要可用的真实 API。幸运的是,Angular 提供了一种拦截 HTTP 请求的方法,并允许我们发送回一个我们可以测试的模拟响应体。因为 Angular 对 HttpClient 服务进行了广泛的测试,我们希望相信 Angular 的测试足以确保 HttpClient 服务正常工作,但这让我们可以测试以确保我们使用 HttpClient 服务的正确实现。

在我们创建这个测试之前,让我们看看我提前包含的模拟对象。文件 src/app/services/stocks.mock.ts 包含几个我们可以在整个应用程序中重用的变量。文件的一个片段如下所示。您需要具有与 API 预期响应相匹配的实际对象,因此我将它们存储在这个文件中。您将在剩余的测试中多次导入各种模拟。

列表 10.2 股票模拟数据样本

[export const MockSymbolsList: string[] = ['AAPL', 'GOOG', 'FB', 'AMZN', 'TWTR'];     ](#c10-codeannotation-0006)
export const MockNewsResponse: any = {     
 "author": "Kelli B. Grant",
    "title": "Happy about hitting that $435M Powerball jackpot? Congratulations — now here's your tax bill",
    ... more data
};
[export const MockStocksResponse: any[] =      
 {
        "symbol": "AAPL",
        ... more data
    },
    {
        "symbol": "GOOG",
        ... more 
    } 

我们将在各种测试中使用这些对象,具体取决于我们要测试的内容。它们是真实对象,我已经将它们包含在项目中,以便我们可以使用已知的数据来测试。它们是 JSON 对象,因此与使用的原始数据相同,而不是任何其他内容的模拟。

现在,让我们来看一下测试。打开 src/app/services/stocks.service.spec.ts 并将其内容替换为以下列表中的代码。这个测试将创建服务的一个实例,拦截 HTTP 请求,并提供模拟响应。

列表 10.3 股票服务测试

import { TestBed, inject } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { StocksService } from './stocks.service';
import { MockStocksResponse, MockSymbolsList, MockNewsResponse } from './stocks.mock';

describe('Service: Stocks', () => {     
 const baseUrl = 'https://angular2-in-action-api.herokuapp.com';
 let service, http;

 beforeEach(() => {
 TestBed.configureTestingModule({
 [imports: [ HttpClientTestingModule ],](#c10-codeannotation-0010)
 [providers: [ StocksService ]](#c10-codeannotation-0010)
 });

 service = TestBed.get(StocksService);
 http = TestBed.get(HttpTestingController);
 });

 afterEach(() => {
 http.verify();
 });

 it('should instantiate', () => {
 expect(service).toBeTruthy();
 });

 it('should manage a list of stocks', () => {
 expect(service.get()).toEqual(MockSymbolsList);
 service.add('TEST');
 [expect(service.get()).toEqual([...MockSymbolsList, 'TEST']);](#c10-codeannotation-0013)
 service.remove('TEST');
 expect(service.get()).toEqual(MockSymbolsList);
 });

 it('should load the stock data from API', (done) => {
 service.load(MockSymbolsList).subscribe(result => {
 expect(result).toEqual(MockStocksResponse);
 done();
 });

 const request = http.expectOne(baseUrl + '/stocks/snapshot?symbols=' + MockSymbolsList.join(','));
 request.flush(MockStocksResponse);
 });

 it('should load the news data from API', (done) => {
 service.getNewsSnapshot('abc').subscribe(result => {
 expect(result).toEqual(MockNewsResponse);
 done();
 });

 const request = http.expectOne(baseUrl + '/stocks/news/snapshot?source=abc');
 request.flush(MockNewsResponse);
 });
}); 

在这个测试中还有更多的事情在进行,但其中大部分都与模拟 Http 所需的设置有关。describe 块首先声明了一些仅在这些测试中包含的变量。然后 beforeEach 块构建了一个测试模块,这样我们就可以使用依赖注入(手动设置需要注入依赖项的测试通常很困难)来正确实例化服务。我们将在下一节中更详细地讨论这个测试模块,但到目前为止,可以将其视为创建一个临时的 Angular 模块并获取服务引用以便测试消费的有用方式。

afterEach 块用于运行一个测试来验证没有未正确解决的额外 HTTP 请求。这有助于避免测试发起一个未预期的 HTTP 请求,因为这可能表明测试存在问题或调用的方法没有得到充分测试。

然后我们有一个简单的测试来检查服务是否已实例化,还有一个测试来检查符号的操作。如果服务未能加载,那可能是因为依赖项发生了变化或是一个在此处会被捕获的编码错误。由于符号操作方法会改变符号列表,因此它们很容易测试。测试首先检查服务是否获取到预期的默认股票列表,添加一个新项目并验证它是否被保留,最后将其删除并进行验证。

模拟 HTTP 请求

最后两个测试非常相似,因为它们都涉及发起 HTTP 请求。让我们仔细地走过第一个,第二个随着我们的进行也会变得清晰。

当我们声明 it 块时,我们首先也将 done 参数传递给测试函数。我们将使用它来告诉测试何时完成——否则它就会不同步。我们将做的是通过我们的服务触发一个 HTTP 请求,然后稍后调用一个将触发响应的服务,这样我们就可以在发起 HTTP 请求之前处理一些设置。

第一步是使用服务发起一个 HTTP 请求,就像你在这里看到的那样:

service.load(MockSymbolsList).subscribe(result => {
  expect(result).toEqual(MockStocksResponse);
  done();
}); 

这意味着我们想要调用load方法,订阅响应,并在它返回时验证它是否符合我们的预期。最后,它调用done函数来告诉测试已完成。这将使可观察者立即订阅,但由于我们导入了HttpClientTestingModule,它不会立即发起请求,从而允许此代码块之后的代码执行。

然后,我们使用HttpTestingController(它是HttpClientTestingModule的一部分,我们在beforeEach中注入了它),并声明我们期望调用一个 URL。我们构建预期的 URL,将其传递给http.expectOne,然后测试将知道要寻找这个 URL。如果由于某种原因未能请求,afterEach将验证它没有被调用并抛出错误。这是以下代码的第一行,第二行告诉测试最终flush或解决此 URL 的任何挂起请求,并带有一个要发送的有效负载参数:

const request = http.expectOne(baseUrl + '/stocks/snapshot?symbols=' + MockSymbolsList.join(','));
request.flush(MockStocksResponse); 

由于我们的服务应该已经发起了这个请求,在这个时候,结果应该像真实响应一样返回。订阅方法将发出MocksStockResponse对象(因为flush方法发送的就是这个),然后测试将验证匹配以完成测试。新闻测试遵循相同的模式。

这完成了我们对服务的测试。你可以扩展这个测试套件来测试不同的边缘情况,例如服务器返回 500 状态错误,或者服务器返回无效的正文。这些额外的测试将帮助你识别服务的弱点。就目前而言,这个服务主要假设一切正常工作,并且可以使其更加健壮。

模拟服务

在我们为组件编写测试之前,我们希望模拟Stocks服务。这将有助于简化我们的其他测试,因为我们的模拟不会调用 HTTP,这使我们能够避免在每个调用服务获取数据的组件中进行 HTTP 模拟。

模拟可以以多种方式创建和使用。Angular 允许我们使用依赖注入在真实事物的地方替换模拟,这使得模拟非常容易使用。甚至可以模拟除了服务之外的其他实体,尽管它们不太常见,且本章未涉及。

关于如何和什么要模拟有许多观点,我不想在这里陷入争论。我通常建议保持简单,最初只构建必要的部分。模拟是额外的代码,需要维护,因为它们需要与你的服务保持同步,所以请保持它们简单和轻量。有些人认为你应该为特定的测试模拟任何依赖项,但你应该意识到这会带来构建和维护的成本。

让我们通过向src/app/services/stocks.service.stub.ts添加一个新文件并包含以下列表中的内容来创建我们的模拟。它将创建一个新的类,使用模拟数据,并公开与真实服务相同的方法。

列表 10.4 占位符 Stocks 服务

import { Observable } from 'rxjs/Rx';
import { HttpClient } from '@angular/common/http';
import { MockNewsResponse, MockStocksResponse, MockSymbolsList } from './stocks.mock';
import { StocksService } from './stocks.service';

export class StubStocksService extends StocksService {     
 constructor() {
 super({} as HttpClient);
 }

 [load(symbols: string[]) {](#c10-codeannotation-0018)
 return Observable.of(MockStocksResponse);
 }

 getNewsSnapshot(source: string) {
 return Observable.of(MockNewsResponse);
 }
} 

这个占位符是通过扩展 Stocks 服务创建的,这样我们就不必实现所有方法。这使我们能够专注于仅编写我们需要更改的方法的代码。因为我们正在扩展方法,所以我们还需要在构造函数中调用 super 方法来传递依赖项。这里有趣的是,我们传递了一个空对象,但将其转换为 HttpClient 服务,这样编译器就会高兴,但实际上并不需要使用真实的服务。我们同样为 getNewsSnapshot 创建了相同的行为。

Stocks 服务定义了 addremoveget 方法,这些方法不需要进行占位符处理,因为它们在没有副作用的情况下工作得很好。通常,你可以在测试中使用真实服务或其方法。以下是一些常见服务副作用及其原因的列表:

  • 异步 — 如果存在异步事件,测试必须以处理该行为的方式进行编写。这可能会使测试变得更具挑战性,但为这些事件编写占位符可以消除这种挑战。

  • 外部依赖项 — 当实体需要外部依赖项(如 Http 服务)时,可能需要跳过额外的步骤来设置,而占位符可以避免这种情况。

  • 内部状态 — 有时服务或实体具有需要你在测试中操作的内部状态,但服务没有公开。占位符可以公开额外的逻辑,允许你修改该状态。

  • 重定向 — 任何实体试图将用户从当前上下文重定向的情况通常都会破坏测试。占位符可以防止位置变化。这对于处理用户身份验证等测试通常是一个挑战。

在创建占位符之前仔细考虑是否需要它。大多数应用程序将受益于至少为一些服务提供占位符,但并非所有内容都适合作为占位符。例如,假设你有一个从 localStorage 读取和设置的服务;这个服务的范围非常集中,并且没有前述列表中给出的任何副作用,因此它是一个很好的候选者,可能不需要进行占位符处理。

如果你更喜欢始终使用 TypeScript 的严格类型,如果占位符没有实现与真实服务完全相同的接口,你可能会遇到问题。你可以通过编写占位符以返回具有相同类型的值来纠正这一点,或者你可以在测试中放宽代码的类型安全性。

10.2.4 测试组件和使用测试模块

组件是应用程序的构建块,它们有两个我们感兴趣测试的关键部分:控制器和视图。我们可以测试控制器中的单个方法(例如从服务加载数据的方法),但我们还想要验证模板上的行为(例如是否正确显示了数据)。因为我们的测试包括测试这两者,设置组件测试需要额外的步骤。

当我们想要测试一个组件时,我们需要首先创建一个测试模块,这是一个微型 Angular 应用程序,包含设置和运行所需的最小内容。你导入和包含在测试模块中的内容越多,运行测试的速度就越慢,测试的隔离性就越低。技术上,你可以在每个测试之前引导正常的 Angular 应用程序,但这与单元测试的概念相悖,并且会使你的测试显著变慢。

测试模块的设置发生在测试的beforeEach语句中,并且与定义常规 Angular 模块非常相似。你可以声明一系列提供者、导入、声明等,然后可以获取一些目标组件的引用,以便于测试。

要看到这个功能在实际中的运用,让我们为组件编写第一个测试。管理组件是一个很好的起点,所以打开 src/app/components/manage/manage.component.spec.ts 文件,并包含以下列表中的代码。

列表 10.5 管理组件测试

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';

import { ManageComponent } from './manage.component';
import { StocksService } from '../../services/stocks.service';
import { StubStocksService } from '../../services/stocks.service.stub';
import { MockSymbolsList } from '../../services/stocks.mock';

describe('ManageComponent', () => {
 let component: ManageComponent;
 let fixture: ComponentFixture<ManageComponent>;
 let el: HTMLElement;

 beforeEach(() => {
 TestBed.configureTestingModule({
 imports: [
 FormsModule,
 [],](#c10-codeannotation-0020)
 declarations: [
 ManageComponent
 [],](#c10-codeannotation-0020)
 providers: [
 { provide: StocksService, useClass: StubStocksService }
 []](#c10-codeannotation-0020)
 });
 }))

 beforeEach(() => {
 fixture = TestBed.createComponent(ManageComponent);
 component = fixture.componentInstance;
 el = fixture.debugElement.nativeElement;
 fixture.detectChanges();
 });

  it('should create the component', () => {
    expect(component).toBeTruthy();
  });

 it('should add or remove symbols from the list', () => {
 expect(component.symbols).toEqual(MockSymbolsList);
 component.stock = 'ABC';
 component.add();
 [expect(component.symbols).toEqual([...MockSymbolsList, 'ABC']);](#c10-codeannotation-0022)
 component.remove('ABC');
 expect(component.symbols).toEqual(MockSymbolsList);
 });

 it('should render the template with a list of stocks', () => {
 const items = el.querySelectorAll('td.mdl-data-table__cell--non-numeric');

 for (let i = 0; i < items.length; ++i) {
 [expect(items[i].innerHTML).toContain(MockSymbolsList[i]);](#c10-codeannotation-0023)
 }
 });
}); 

这个测试包含通常的import块,其中包含来自 Angular 的ComponentFixtureTestBed对象。我们将使用这些对象来创建测试模块并引导组件。当 Angular CLI 生成组件测试时,它将为你创建一个具有测试模块的测试,类似于你在这里看到的那样。describe块包含一些变量引用,稍后我们将为它们赋值。然后你需要在测试模块中添加任何额外的组件所需的组件。在这个例子中,我们要求表单模块,因为我们使用了 NgModel,以及股票服务,并且存根服务作为股票服务注入。

TestBed.configureTestingModule方法将设置一个包含基本 Angular 服务(如默认管道和指令)的模块,并返回一个TestBed对象。如果你没有使用 Angular CLI,你可能需要调用compileComponents方法(有关详细信息,请参阅 Angular 文档)。

下一个beforeEach块使用TestBed创建要测试的组件的实例,并将组件实例和原生 DOM 元素分配给变量。fixture.detectChanges方法用于手动触发变更检测并确保组件反映了它们当前的状态——否则状态可能不准确。第一个测试是另一个简单的检查,以查看组件是否正确创建。

第二个测试练习了用于操作符号列表的组件方法。在这种情况下,Manage 组件实现调用模拟服务而不是真实服务,因为我们已经在测试模块提供者中声明了这一点。测试很简单:它检查默认的符号数组与模拟值,添加然后删除一个项目,并检查符号列表是否按预期构建。我喜欢将这种测试称为控制器单元测试,因为它直接使用控制器方法。

最终测试检查渲染视图,以查看组件是否按预期显示了数据。它使用 DOM 元素查询所有行,并验证每个符号是否在 DOM 中显示。我喜欢将这种测试称为视图单元测试,因为它观察视图的渲染输出。有时一个测试会做两件事,但我试图保持它们集中。

这总结了组件测试的主要原则,包括创建测试模块、包括或模拟依赖项、渲染组件以及测试控制器或视图。

模拟指令或组件和使用 DebugElement

有时候你不想在测试中渲染指令或子组件。在这个例子中,我们的 Delay 指令给我们带来了一些麻烦,因为它延迟了项目的渲染,我们的测试要么等待它完成,要么以其他方式绕过它。但我们可以模拟 Delay 指令,让它立即显示。当我们直接测试它时,我们会得到 Delay 指令工作的测试覆盖率。

我们想测试仪表盘组件,它加载了一些内容。为了只关注仪表盘组件,我们还将模拟 Summary 组件,因为它有动画可能会妨碍测试。

还有一种方法可以查看渲染视图,即使用 Angular 的DebugElement。与直接使用原生 DOM 元素相比,DebugElement给我们提供了一些额外的选项,其中最强大的是允许我们根据指令而不是仅 CSS 选择器查询元素。我们将在本例中看到如何使用它。

让我们为仪表盘组件创建测试。打开 src/app/components/dashboard/dashboard.component.spec.ts 文件,并用以下列表中的内容替换它。我们将模拟 Summary 组件和Delay指令,并使用DebugElement和指令进行查询。

列表 10.6 仪表盘组件测试

/* tslint:disable:no-unused-variable */
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { DebugElement, Directive, Input, TemplateRef, ViewContainerRef, Component } from '@angular/core';

import { DashboardComponent } from './dashboard.component';
import { CardTypeDirective } from '../../directives/card-type.directive';
import { CardHoverDirective } from '../../directives/card-hover.directive';
import { StocksService } from '../../services/stocks.service';
import { StubStocksService } from '../../services/stocks.service.stub';
import { MockSymbolsList } from '../../services/stocks.mock';

@Directive({     
 [selector: '[delay]'](#c10-codeannotation-0024)
})     
class StubDelayDirective {     
 @Input() set delay(ms: number) { this.viewContainer.createEmbeddedView(this.templateRef); }
 constructor(private templateRef: TemplateRef<any>, private viewContainer: ViewContainerRef) { }
}     

@Component({     
 selector: 'summary',
 template: '<div class="mdl-card">{{stock}}</div>'
})     
class StubSummaryComponent {     
 @Input() stock;
}     

describe('DashboardComponent', () => {
  let component: DashboardComponent;
  let fixture: ComponentFixture<DashboardComponent>;
  let de: DebugElement;

  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: 
        DashboardComponent,
 [StubSummaryComponent,
 StubDelayDirective,
 CardTypeDirective,
 CardHoverDirective,
      ],
      providers: [
        { provide: StocksService, useClass: StubStocksService }
      ]
    });
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(DashboardComponent);
    component = fixture.componentInstance;
 de = fixture.debugElement;
 expect(component.stocks).toBeFalsy();
 fixture.detectChanges();
 expect(component.stocks).toBeTruthy();
  });

  it('should create the component', () => {
    expect(component).toBeTruthy();
  });

 it('should render the template', () => {
 expect(de.query(By.css('.mdl-cell')).properties.innerHTML).not.toContain('Loading');
 expect(de.queryAll(By.directive(StubSummaryComponent)).length).toEqual(MockSymbolsList.length);
 });
}); 

我在这个测试中将Delay指令占位符内联放置,因为我在其他地方不会用到它。我发现除非你需要在多个地方重用占位符,比如在股票服务中,否则保留它在本地是有用的。这个占位符所做的只是实现一个立即渲染视图而不延迟的指令,但它确实有相同的选择器以确保它在组件中使用。真正的延迟组件在测试中会引起问题,因为它不会立即渲染,如果我们等待它完成,可能需要一段时间,这取决于项目数量。

同样,我们也将摘要组件占位符内联声明,因为它在其他地方没有使用,它是一个实现组件最小接口的简单组件。该组件接受单个输入(如果我们没有添加输入,它将无法编译),并且期望有一个带有mdl-card类的元素。

因为这是一个隔离的测试,我们将占位符作为测试模块的一部分声明,并包括其他没有异步特性的指令的真实版本。如果我们不包括它们,测试会抱怨无法渲染这些指令。在beforeEach中,我们还放置了几个expect语句,检查组件渲染后股票是否已加载。

这里的主要测试对视图运行两个expect断言。仪表板组件没有很多方法可以用来测试控制器,除了OnInit,它加载数据。因为那是在beforeEach块中检查的,所以我们可以专注于视图。测试使用TestBed实例中的DebugElement,并使用查询能力根据 CSS 选择器检查元素。第一个查询检查加载文本不再显示。第二个查询查找所有属于占位符摘要组件类型的元素(记住,组件是指令)。这些查询使用By对象为您构造选择器,基于指令或 CSS 选择器。DebugElement还允许您访问元素属性,如innerHTML属性。

我建议选择使用原生的 DOM 元素或DebugElement方法来检查组件视图。混合使用是可以的,但一致性是一个良好的目标。

使用输入和动画测试组件

有时我们的组件有输入,我们需要在测试中处理这些输入。例如,摘要组件接受股票价值的输入,我们想要确保它按预期渲染,即使股票价格已经上涨或下跌。

摘要组件也有一个动画,允许它淡入,我们的测试模块需要能够处理这种情况。Angular 允许我们绕过动画,这样它们就不会干扰快速运行的测试。

到现在为止,摘要组件的测试已经很熟悉了,所以我们只关注新的部分。打开 src/app/components/summary/summary.component.spec.ts 并使用以下列表中的代码。

列表 10.7 摘要组件测试

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';     
 import { CurrencyPipe, PercentPipe } from '@angular/common';
import { SummaryComponent } from './summary.component';
import { CardTypeDirective } from '../../directives/card-type.directive';
import { ChangePipe } from '../../pipes/change.pipe';

describe('SummaryComponent', () => {
  let component: SummaryComponent;
  let fixture: ComponentFixture<SummaryComponent>;
  let el: HTMLElement;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: 
 [NoopAnimationsModule
 ],
      declarations: [
        SummaryComponent,
        ChangePipe,
        CardTypeDirective,
      ],
      providers: [
        CurrencyPipe,
        PercentPipe
      ]
    });
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(SummaryComponent);
    component = fixture.componentInstance;
    el = fixture.debugElement.nativeElement;
  });

  it('should create the component', () => {
    expect(component).toBeTruthy();
  });

  it('should render for a positive change', () => {
 component.stock = { symbol: 'abc', lastTradePriceOnly: 10, change: .25, changeInPercent: 0.025 };
 fixture.detectChanges();

 const content = el.querySelector('.mdl-card h4').innerHTML;
 expect(content).toContain('ABC');
 expect(content).toContain('$10.00');
 expect(content).toContain('$0.25 (2.50%)');
  });

  it('should render for a negative change', () => {
    component.stock = { symbol: 'abc', lastTradePriceOnly: 8.34, change: -1.43, changeInPercent: -0.0243 };
    fixture.detectChanges();

    const content = el.querySelector('.mdl-card h4').innerHTML;
    expect(content).toContain('ABC');
 expect(content).toContain('$8.34');
    expect(content).toContain('-$1.43 (-2.43%)');
  });
}); 

使用 NoopAnimationsModule 来支持组件所需的动画依赖项,但在运行时,动画不会动画化。这对于测试用例来说很好,因为动画需要时间并且会减慢测试速度。如果你想要测试动画,你可能希望使用端到端风格测试来执行测试。

为了处理输入的测试,我们将它当作一个普通属性来处理。实际上,它确实是一个普通属性,只不过当它被使用时,它有从绑定接收数据的能力。在我们的测试中,我们主要关注它是如何工作的,而不是绑定是否工作。我们在仪表板组件中测试了绑定,所以我们可以相信它将有一个绑定。在测试中,我们将 stock 属性设置为一个已知值,然后运行变更检测来更新视图。

然后,我们通过检查视图的内容并检查根据股票数据渲染的值是否符合预期来检查视图。我们还有一个测试来验证不同的股票数据仍然可以按预期渲染结果。

使用路由测试组件

最后还有一个组件需要测试,那就是 App 组件,它包含一个我们需要在测试中处理的路由出口。App 组件还有一个导航栏,它渲染了我们要测试的最新新闻条目。在测试它时,它与任何其他组件没有太大区别。这个组件直接使用的任何东西都需要以某种方式导入或模拟,包括设置路由和使用模拟服务。

让我们来看看包含路由出口的组件测试,看看它与其他组件有什么不同。打开 src/app/app.component.spec.ts 并将其内容替换为以下列表中的内容。

列表 10.8 App 组件测试

import { TestBed, ComponentFixture } from '@angular/core/testing';
import { RouterModule } from '@angular/router';
import { APP_BASE_HREF } from '@angular/common';

import { AppComponent } from './app.component';
import { ChangeDetectorPipe } from './pipes/change-detector.pipe';
import { NewsPipe } from './pipes/news.pipe';
import { StocksService } from './services/stocks.service';
import { StubStocksService } from './services/stocks.service.stub';
import { MockNewsResponse } from './services/stocks.mock';

describe('AppComponent', () => {
  let component: AppComponent;
  let fixture: ComponentFixture<AppComponent>;
  let el: HTMLElement;
  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [
 [RouterModule.forRoot([]),](#c10-codeannotation-0033)
 ],
      declarations: [
        AppComponent,
        ChangeDetectorPipe,
        NewsPipe,
      ],
      providers: 
        { provide: StocksService, useClass: StubStocksService },
 [{ provide: APP_BASE_HREF, useValue: '/' }
 ]
    });
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(AppComponent);
    component = fixture.debugElement.componentInstance;
    el = fixture.debugElement.nativeElement;
    expect(component).toBeTruthy();
    fixture.detectChanges();
  });

 it('should load the news', () => {
 expect(el.innerHTML).toContain(MockNewsResponse.title);
 });
}); 

这个测试的设置与其他测试类似,只是它设置了路由模块。路由模块使路由出口和路由链接指令对测试模块可用,以便渲染。它还要求我们提供 APP_BASE_HREF,但需要用值覆盖它。它可以是一个有效的路径,但几乎总是希望使用 /

然后,在测试中,我们检查组件是否在视图中渲染了模拟新闻标题。这是一种快速验证新闻管道是否运行并使用模拟股票服务返回模拟新闻数据的方法。

这就完成了我们在这个示例中组件的测试。随着你构建更复杂的组件,测试也将变得更加复杂。这是一个很好的理由,要创建许多较小的组件而不是几个大的组件,所以请记住这一点。此外,如果你有良好的关注点分离(例如数据访问在服务中,渲染视图在组件中),你可以更容易地存根这些不同的方面并编写针对较小范围的测试。

10.2.5 指令测试

指令可能是最难测试的东西,因为它们是元素的修饰符。通过将它们附加到元素或组件上并验证视图是否相应地更改来测试指令。它们可以附加到你的应用程序中的真实组件上,但我更喜欢为测试创建一个存根组件以简化测试场景。

我们将使用两种不同的方法进行测试。第一种是通过传递一个构造的组件来创建指令。第二种是允许测试模块为我们构造它。

你可能选择自己构建它的原因是你可以直接在指令上调用元素。否则,指令在测试模块中不易访问。

我们将在这个部分查看三个指令中的两个进行测试。第三个指令没有提供任何新内容,但你可以查看 GitHub 仓库中的测试。第一个测试将是针对 CardHover 属性指令,第二个将是针对 Delay 结构指令。

让我们从 CardHover 指令开始。在这个测试中,我们将自己创建指令,但仍然需要一个测试模块来设置存根组件。回想一下,这是为了帮助我们封装确定卡片是否被悬停的逻辑,以及修改 Summary 组件的默认颜色。打开src/app/directives/card-hover.directive.spec.ts并使用以下列表中的代码来完成测试。

列表 10.9 CardHover 指令测试

import { Component, ElementRef } from '@angular/core';
import { By } from '@angular/platform-browser';
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { CardHoverDirective } from './card-hover.directive';

@Component({     
 template: `<div cardHover class="mdl-card decrease">Content</div>`
})     
class MockComponent {}     

describe('CardHoverDirective', () => {
  let directive: CardHoverDirective;
  let card: HTMLElement;

 beforeEach(() => {
 TestBed.configureTestingModule({
 declarations: [
 MockComponent,
 []](#c10-codeannotation-0037)
 });
 });

  beforeEach(() => { 
 const fixture = TestBed.createComponent(MockComponent);
 card = fixture.debugElement.query(By.css('[cardHover]')).nativeElement;
     
 directive = new CardHoverDirective(new ElementRef(fixture.debugElement.nativeElement));
 directive.ngOnInit();
  });

  it('should apply the correct background colors', () => {
 expect(card.style.backgroundColor.replace(/ /g, '')).toContain('rgb(255,171,64)');
 card.classList.remove('decrease');
 card.classList.add('increase');
 directive.ngOnInit();
 expect(card.style.backgroundColor.replace(/ /g, '')).toContain('rgb(63,81,181)');
 card.classList.remove('increase');
 directive.ngOnInit();
 expect(card.style.backgroundColor).toEqual('');
  });

  it('should apply hover styling', () => {
 directive.onMouseOver();
 expect(card.style.top).toEqual('-2px');
 directive.onMouseOut();
 expect(card.style.top).toEqual('');
  });
}); 

与组件相比,当你比较这个测试时,有很多熟悉的部分,但差异很重要。首先,我们创建一个具有预期类和结构以及应用了指令的存根组件。测试模块被创建并设置,以便可以正确实例化存根组件。

在下一个beforeEach块中,我们执行两个重要任务。首先,我们从测试模块获取编译后的组件,然后创建一个新的 CardHover 指令实例。请注意,指令期望一个包含ElementRef的参数,ElementRef是对组件原生 DOM 元素的包装。指令存在于测试模块之外,因此我们必须直接调用生命周期方法,例如directive.ngOnInit

现在我们有了这个指令,我们可以通过查看渲染的卡片并检查其背景颜色是否与当前应用于卡片的预期值匹配来测试它。我们直接操作元素,然后再次运行 ngOnInit 生命周期钩子来模拟变化。请注意,我们不需要使用 fixture 来运行变更检测,因为我们通过自己创建指令绕过了测试模块。

最后一个测试直接调用两个鼠标事件方法,并验证当它们触发时,卡片的样式是否符合预期。任何需要测试的方法都可以像这样直接调用,但前提是在手动创建时能够获取指令的引用。

尽管我们通过自己创建它来测试了这个指令,但通常更喜欢使用测试模块来设置环境。这确保了事物被正确连接,可以使生活变得稍微容易一些,但代价是直接调用指令方法。如果你想要验证方法内部,可能需要创建一组直接调用指令的测试,然后使用测试模块进行其余部分的测试。

在测试中处理异步任务

我们将要编写的最后一个测试还展示了我们在测试中处理异步任务的两个方法。我们将测试延迟指令,该指令等待渲染组件一定数量的毫秒。与 Http 服务允许我们模拟响应并在测试中处理它的方式不同,我们需要使用 Angular 的两个选项之一来确保测试在异步事件发生时也能正确执行所有操作。

Angular 提供了一种模拟异步事件的方法,特别是间隔和超时,并提供了一种在继续执行之前等待异步事件最终完成的方法。我们将使用这两种方法各写两次相同的测试,以便你可以并排看到它们。默认情况下,Jasmine 假设测试是同步的,但确实提供了一个可选的 done 回调对象,你可以使用它来处理异步任务,但使用 Angular 的测试工具,这通常是不必要的,并且建议使用这里展示的两个选项。

延迟指令测试位于 src/app/directives/delay.directive.spec.ts。打开它,并用以下列表中的代码替换其内容。

列表 10.10 延迟指令测试

import { Component } from '@angular/core';
import { TestBed, ComponentFixture, fakeAsync, async, tick } from '@angular/core/testing';
import { DelayDirective } from './delay.directive';

@Component({     
 template: `<div *delay="delay"><h1>DELAYED</h1></div>`
})     
class MockComponent {     
 delay = 10;
}     

describe('DelayDirective', () => {
  let fixture: ComponentFixture<MockComponent>;
  let el: HTMLElement;

  beforeEach(() => {
 TestBed.configureTestingModule({
 declarations: [
 MockComponent,
 DelayDirective
 []](#c10-codeannotation-0043)
 });
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(MockComponent);
    el = fixture.debugElement.nativeElement;
  });

 it('should show after the specified delay using fakeAsync', fakeAsync(() => {
 expect(el.innerHTML).not.toContain('DELAYED');
 fixture.detectChanges();
 tick(10);
 expect(el.innerHTML).toContain('DELAYED');
 }));

 it('should show after the specified delay using async', async(() => {
 expect(el.innerHTML).not.toContain('DELAYED');
 fixture.detectChanges();
 fixture.whenStable().then(() => {
 fixture.detectChanges();
 expect(el.innerHTML).toContain('DELAYED');
 });
 }));
}); 

在这个例子中,我们创建了一个存根组件,并确保它实现了延迟指令。我们将延迟设置为 10 毫秒,这样在运行测试时不会造成长时间的延迟。如果我们将其设置为 1 秒,测试执行将暂停 1 秒——这是不希望的。然后通过声明模拟组件和延迟指令来配置测试模块。

第一次测试使用fakeAsync处理程序来包装测试的执行。它通过为测试返回一个同步函数来运行,同时也暴露了额外的tick对象。在fakeAsync处理程序内部,你可以运行期望来验证在初始化时,指令不会显示组件的内容,然后调用tick来触发任何计时器事件。我们传递tick(10)来在 10 毫秒处触发计时器事件,这是我们在模拟组件中配置的值。如果你有多个计时器事件,你可以使用tick来逐步通过不同的计时器事件并验证每个步骤之间的行为。tick函数仅在fakeAsync处理程序内部可用。

第二次测试使用async处理程序,它处理告诉测试等待某些异步活动解决。在这里,我们使用fixture.whenStable().then()来处理等待 10 毫秒,以便组件完全渲染,因为指令延迟了渲染。在then承诺处理程序内部,我们运行我们的期望来查看组件是否已渲染。这种方法的一个缺点是超时等待延迟设置的时间,如果你有很多测试做类似的事情,这会大大减慢你的测试速度。

我建议尽可能使用fakeAsync,但请注意,它不能帮助你处理 HTTP 调用。当你使用代码中的异步调用时,确保考虑如何最好地测试它,并尽量避免尽可能多地嵌套或同时进行异步调用。这些会增加测试复杂性,也可能使你的组件更难实现。

这完成了这个应用的单元测试。现在它有 25 个测试,如果你运行代码覆盖率分析,它声称已经测试了 100%的代码。经验丰富的开发者知道这并不意味着测试覆盖了每个场景或边缘情况,因为许多这些测试可以扩展以验证更多的错误条件,例如。

现在我们将退出单元测试,并专注于如何使用端到端测试一次性测试整个应用,以模拟真实用户启动应用。

10.3 端到端测试

虽然你应用的所有单元都在独立运行时工作得很好,但这并不一定保证它们作为一个整体或与真实世界的数据源一起工作。端到端测试是通过像真实用户一样控制应用来测试应用整体的方式。

端到端测试旨在从用户的角度模拟相同的体验,其中他们点击链接、在输入字段中输入,以及其他方式与应用交互。在编写端到端测试时,保持你的注意力集中在这个点上很重要,因为这意味着你应该只编写用户真实可行的测试。例如,当你在不同页面之间导航时,你应该让测试点击导航栏中的链接,而不是在浏览器中更改 URL。

我认为思考端到端测试的最佳方式是考虑用户在使用你的应用程序时成功所必需的几个应用程序路径。例如,如果你的应用程序是基于创建发票的,那么考虑测试用户创建、编辑、管理或删除他们发票的主要路径。你可能还希望关注关键路径,如注册服务、登录流程和其他类似的临界任务。

起初可能并不明显,但创建和维护端到端测试的成本可能更高。这些测试可能听起来是编写最全面的测试类型,但现实是它们比单元测试更难编写和维护。它们也不像单元测试那样提供明确的保证——端到端测试中的失败可能或可能不意味着存在问题。由于它们有更多变量,如真实的后端和单元测试中受控的状态变化,因此它们可能难以一致地重复。端到端测试的执行速度也比单元测试慢得多,因为它们需要加载整个应用程序,因此预期它们需要更多时间。

我喜欢提醒大家,端到端测试之所以困难,是因为你必须控制许多变量,而不是因为它们本质上难以编写。想象一下你正在测试一个论坛应用程序。你需要一个可以信赖的测试用户,该用户可以登录并发布消息。但是当这个测试用户发布消息时会发生什么?它是否出现在网站上?也许你可以在一个测试或预发布环境中运行测试,而不是在真实的生产网站上,但这需要有一个测试或预发布网站可用。这样的考虑使得测试的编排变得具有挑战性。系统越大,与所有参与应用程序的各方就如何设置和管理这种测试进行协商就越重要。

考虑到这些潜在的挑战,你可能对测试非关键或很少使用的流程不太感兴趣。成本可能对你来说太高,以至于你不必担心测试那些仅使用 1%的网站部分。

尽管如此,让我们继续前进,为我们的股票应用程序编写一些端到端测试。我们很幸运,我们的应用程序简单且无状态。每次我们重新加载,应用程序都会重置,但这对于大多数应用程序来说并不典型,所以也要记住这一点。

Angular CLI 为使用 Protractor 运行端到端测试提供了内置的脚手架,Protractor 是一个使用 WebDriver API 远程控制浏览器的工具。Protractor 是为 AngularJS 编写的,有一些功能不支持 Angular 2+应用程序(有关更多详细信息,请参阅 Protractor 文档,见protractortest.org)。记住,如果你尝试使用 Protractor 中不工作的功能,它可能只适用于 AngularJS。

当 Angular CLI 生成项目时,它会创建一个名为 e2e 的目录,其中包含一组适用于默认应用的基本测试。我们将稍后更新它,包含四个测试,以验证仪表盘和管理页面是否正确加载,然后测试添加和删除股票。

在我们编写测试之前,我们将创建一个名为 页面对象 的东西,这是一种定义你想要测试的页面方面的一种方式,它允许你编写看起来更干净的测试。我们不得不编写类似于 CSS 选择器的东西,称为 页面定位器,它们负责在页面上找到与页面交互的元素。

Protractor 提供了两个重要的对象用于查找元素:elementby。通过使用它们的各种组合,你可以在页面上找到一个元素,找到多个元素,找到一个列表然后从中选择一个特定的项目,等等。element 对象返回一个包装器内的元素(类似于 jQuery 对元素所做的操作)并暴露了一些有用的方法来执行点击元素等操作。element 还接受一个参数,应该使用 by 对象,这有助于你构建类似元素查询的东西。你可以使用 by.css('.someClass') 通过 CSS 选择器定位一个元素。这被称为 页面定位器

让我们通过编写一些代码来看看这个动作。在 e2e 目录下创建一个新的文件 dashboard.po.ts,并使用以下列表中的代码。这是 DashboardPage 对象,它很简单。页面越大,它可能包含的东西就越多。

列表 10.11 仪表盘页面对象

import { element, by } from 'protractor';     
 export class DashboardPage {
 navigateTo() {
 return element(by.linkText('Dashboard')).click();
 }

 getCards() {
 return element.all(by.css('.mdl-cell'));
 }
} 

作为一段独立的代码,这可能会在最初看起来有些奇怪,但它相当直接。Protractor 给我们 elementby 对象,这些对象用于在页面上查找元素。element 类似于一个元素包装器对象,而 by 对象暴露了基于 CSS 选择器、链接文本、按钮名称或输入名称等标准查找元素的方法。这些是你将用来像真实用户一样交互的基本调用。

我们定义了一个类,它给我们一些帮助我们导航或选择元素的方法。首先,navigateTo 方法在导航栏中查找 Dashboard 链接并触发其上的 click 事件,这会触发导航到该页面。getCards 方法查找页面上所有具有 mdl-cell 类的元素,这恰好是包含股票项目的所有卡片。

让我们继续创建另一个 ManagePage。在 e2e 目录下创建另一个文件 manage.po.ts,并包含以下列表中的代码。

列表 10.12 管理页面对象

import { element, by } from 'protractor';

export class ManagePage {
 navigateTo() {
 return element(by.linkText('Manage')).click();
 }

 getSymbols() {
 return element.all(by.css('.mdl-data-table__cell--non-numeric'));
 }

 getRemoveButton(index) {
 return element.all(by.buttonText('Remove')).get(index);
 }

 getAddInput() {
 return element(by.name('stock'));
 }
} 

在这个页面对象中还有一些其他的东西,因为我们有在这个页面上添加和删除股票的能力。类以一个navigate方法开始,该方法点击导航栏中的管理链接。下一个方法getSymbols找到包含股票符号的所有表格单元格。然后getRemoveButton方法接受一个索引值,并将基于该行的符号的删除按钮点击。最后,getAdd­Input找到股票名称输入字段。

Protractor 在其 API 中提供了更多选项,所以我建议你查阅文档以获取完整详情。利用页面定位器可以给你提供许多在页面上查找元素以进行交互的选项。

好吧——现在我们可以编写测试了。想法是测试易于阅读,代码读起来非常类似于用户的操作;它们应该覆盖用户在使用应用程序时的期望。打开e2e/app.e2e-spec.ts文件,并用以下列表中的代码替换其内容。

列表 10.13 应用程序端到端测试

import { DashboardPage } from './dashboard.po';
import { ManagePage } from './manage.po';
import { protractor, browser, by, element } from 'protractor';

describe('Stock App', () => {
  let dashboard: DashboardPage;
  let manage: ManagePage;

 beforeEach(() => {
 dashboard = new DashboardPage();
 manage = new ManagePage();
 browser.get('/');
 });

 it('should load the dashboard default list', () => {
 dashboard.navigateTo();
 dashboard.getCards().then(stocks => {
 expect(stocks.length).toEqual(5);
 });
 });

 it('should load the manage stocks view', () => {
 manage.navigateTo();
 manage.getSymbols().then(symbols => {
 expect(symbols.length).toEqual(5);
 });
 });

 it('should add a new stock and be updated in dashboard', () => {
 dashboard.navigateTo();
 dashboard.getCards().then(stocks => {
 expect(stocks.length).toEqual(5);
 });

 manage.navigateTo();
 manage.getAddInput().sendKeys('MSFT', protractor.Key.RETURN);
 expect(manage.getSymbols().last().getText()).toEqual('MSFT');

 dashboard.navigateTo();
 dashboard.getCards().then(stocks => {
 expect(stocks.length).toEqual(6);
 });
 });

 it('should remove a stock and be updated in dashboard', () => {
 dashboard.navigateTo();
 dashboard.getCards().then(stocks => {
 expect(stocks.length).toEqual(5);
 });

 manage.navigateTo();
 manage.getRemoveButton(0).click();
 expect(manage.getSymbols().first().getText()).not.toEqual('AAPL');

 dashboard.navigateTo();
 dashboard.getCards().then(stocks => {
 expect(stocks.length).toEqual(4);
 });
 });
}); 

注意测试是如何使用与单元测试中相同的describebeforeEachit块编写的。这是因为我们正在使用 Jasmine 作为测试框架来描述测试,这有助于保持事情熟悉。在beforeEach阶段,我们为管理和仪表板页面构造一个新的页面对象,然后告诉浏览器加载主页面。

第一个测试检查仪表板本身是否以预期的卡片数量加载。它是通过导航到页面并查询卡片列表来做到这一点的。注意它是一个承诺,因为可能需要等待一些时间以触发任务。Protractor 通常足够智能,会在执行代码之前等待页面稳定下来,然后我们在回调中运行我们的期望。第二个测试类似,但它会转到管理页面并检查符号列表。

第三个和第四个测试操作状态并验证在添加或删除符号后它是否正确渲染。它们都从验证仪表板具有预期的卡片数量开始,然后导航到管理页面。测试要么添加或删除符号,然后导航回仪表板以验证是否反映了更改。

通常你会根据功能将测试拆分。在这个应用程序中,有一个主要功能:查看和管理股票列表。如果你有额外的功能,比如创建账户、登录过程,或者可能是一个新闻页面,那么每个功能最好都组织到自己的文件中。

这使我们了解了编写端到端测试的基本知识,并展示了它们的主要作用和价值。尽管它们有一些缺点并且可能难以维护,但它们像真实用户一样测试系统,并允许你将主要用户流程和目标编码到一系列测试中。

在结束本章之前,我想讨论一些人们在现实场景中关于测试的常见问题。过于频繁地,开发者没有投资于测试或陷入测试正确方法的困境。尽管这些答案是我的观点,但我认为它们值得考虑。

10.4 附加测试策略

当我教授测试时,有时学生的反应是测试工作量很大。许多问题归结为询问测试是否值得努力。假设你像我一样认为测试很重要,那么你如何决定要实施哪些策略和测试,哪些可以安全地忽略?

根据你的经验和项目要求,你可能有很多问题,例如以下这些:

  • 测试多少才算足够?

  • 我什么时候编写测试?

  • 我应该写端到端测试还是单元测试?

  • 如果我没有时间编写测试怎么办?

  • 其他类型的测试呢?

我不能给你提供适用于所有项目的明确答案,但我愿意分享我的一些测试策略。关于测试有许多策略和观点。两个极端如下:

  • 那些主张在创建实际代码之前为每一行代码编写测试的人

  • 认为测试是浪费时间并减缓开发的人

在这个范围内,我更接近前者,但我也认识到有些测试价值很小,而且有其他确保代码行为的方法。你会发现我的大多数观点都是务实的,介于两个极端之间。

10.4.1 测试多少才算足够?

这是一个经常被问及的问题,但很少有人知道如何回答。有时人们想要具体的代码覆盖率指标,并认为这已经足够,而其他人则希望测试覆盖应用程序的重要方面。我不相信有魔法公式,但我认为有一个简单的方法可以确定你是否进行了足够的测试。

想象一下,你的应用程序目前平均每天有 100 人使用。该应用程序相对简单,已经使用了几个月,并且只有几个错误报告。你可能会对应用程序及其行为感到自信——但这只是你的直觉,这不应该是你决定是否进行足够测试的方式。

你应该做的是使用使用指标和错误记录工具来分析数据,看看用户是否成功使用你的应用程序或收到错误。用数据来告诉你应用程序的质量,以及了解人们如何使用它。你可以使用类似 Google Analytics 这样的工具来跟踪用户并向你报告指标;这需要一些设置并关注报告。有许多优秀的工具——寻找最适合你需求的网络分析和错误跟踪工具。

开发者以 IWFM 综合症(“对我有效”)而闻名。我想你和我一样,都曾犯过这种综合症的错。这就是为什么你对质量的直觉实际上是一个借口,用来忽视测试而不是衡量质量。你必须记住,你的用户正在使用与你不同的计算机、浏览器和网络连接速度。

当你使用数据来衡量时,你可以更好地了解你的用户,并在错误发生时捕捉到它们。此外,用户更有可能离开你的应用程序而不是报告一个错误,所以并不是因为没有人抱怨就意味着它运行正确。

10.4.2 我应该在什么时候编写测试?

鸡生蛋还是蛋生鸡?测试在前还是实现在前?像 TDD(测试驱动开发)这样的策略建议你在编写任何实际代码之前应该先编写测试。其他方法则主张先编写实现,然后再想出如何编写测试来支持实现。

在实现之前编写测试的目的是确保你在编写代码之前清楚地思考实现过程。假设你能在心中正确地想象出实现过程,先编写测试,然后再编写实现。对我来说,这里最大的缺点是它假设你能够清楚地想象出实现过程——如果你错了,你不得不重新编写测试。

先编写实现再编写测试的目的是为了节省你的时间,因为你首先关注的是实现,然后才是测试。你可能不需要在开始编码之前计划得那么仔细,因此这通常更快。那里最大的缺点是可能会错过细微之处,并编写出确认有缺陷行为的测试。例如,如果你的代码没有处理边缘情况,而你又没有编写测试来检查它,测试仍然会通过。

我对此的看法是介于两者之间。我倾向于在构建特定功能时编写测试,但我不担心总是提前编写。这通常是因为我还在与具有复杂响应的 API 一起工作,它们与我的工作同时进行。我建议你不要等到最后再回头写一大堆测试。当你这样做时,你往往会编写出验证你实现的测试,这可能意味着如果你没有正确考虑潜在的情况,你可能会编写出验证有缺陷行为的测试。

10.4.3 我应该编写端到端测试还是单元测试?

我听说过这样的论点,如果你写好了一种类型的测试,那么应用程序不需要不同类型的测试。这种观点通常意味着开发者对创建特定类型的测试有信心,并且不觉得其他类型的测试会同样有价值。

我相信你应该至少编写一些端到端测试,但主要关注单元测试。记住每种类型测试的作用,并编写最能验证该行为的类型。例如,如果你想验证 API 是否按预期返回数据,你需要编写端到端测试。同样,如果你想确保在广泛的输入值下管道能够正常工作,单元测试是最合理的。

端到端测试可能具有挑战性,因为它们是在真实数据源上运行的,如果你正在创建或删除数据库中的对象,你必须正确处理,以免搞乱系统。如果一个端到端测试失败,它可能会在系统中留下测试数据,并且没有正确清理。

简而言之,除非变得过于复杂,否则优先考虑单元测试——然后创建端到端测试。端到端测试应专注于用户流程,单元测试应专注于特定实体的行为。

10.4.4 如果我没有时间写测试怎么办?

通常这种论点只会在你独自编写一个相当小的应用程序,并且你对代码非常了解时出现。现实是,你的记忆是有限的,没有进行广泛的手动测试,很难对不会破坏其他任何东西的更改有信心。

如果你稍微换一种思维方式,你可能没有时间浪费在不写测试上,如果你考虑手动测试应用程序可能花费的时间损失,或者处理客户报告的 bug 的支持成本。

可能你完成了一个项目,几个月甚至几年后需要再次回到它。你对这个项目的记忆可能会大大减弱,你的编码风格也可能发生了变化。没有测试,你就是在盲目地更改代码,并寄希望于最好的结果。我认为这就像牛仔式编程

我不喜欢处理支持问题或手动测试我的应用程序。当我修复一个 bug 时,我会尝试编写一个测试来验证该 bug 不会再次发生。我建议你也养成这个习惯,并且你可以慢慢地添加测试,在你没有测试的地方。

我知道有时项目只是概念验证,有时客户没有足够的预算来覆盖适当测试应用程序的时间。但概念验证往往会变成生产应用程序,客户会不断回来要求更改,很快应用程序就会失去控制。我鼓励你争取时间或预算,将测试作为优先事项。你的未来自己(以及老板或客户)会感谢你的。

10.4.5 关于其他类型的测试怎么办?

这里没有涵盖的其他测试类型可能对你很有用。这些包括诸如可用性测试(观察用户完成一系列任务以查看他们是否可以轻松完成)、跨浏览器兼容性测试(分析应用程序在各种浏览器中的渲染方式,并记录任何差异)、以及性能测试(分析应用程序的效率,加载所需时间以及其他影响实际和感知性能的因素)。

你可以测试的事情如此之多,以至于你可能会轻易地花所有时间编写测试而不是改进应用程序。而不是告诉你需要采用哪些类型的测试,我提供以下想法供你考虑:

  • 自动化缓慢或手动流程 — 这通常与测试相关,但也适用于其他方面,如构建应用程序。例如,如果你在验证代码在不同浏览器中正确运行上花费了大量精力,那么从长远来看,构建一个能够在多个浏览器(如使用 selenium 网格服务)中自动运行测试的测试基础设施可能会更好。

  • 测试不能失败的区域 — 考虑登录或注册的过程。如果这些失败,你会很快失去用户,这可能会成为一个重大问题。你可能会考虑如何持续测试失败,例如进行自动测试,每五分钟尝试登录一个用户,并报告失败情况。

  • 跟踪错误和活动 — 很少有用户会麻烦报告错误或联系支持,即使在付费产品上也是如此。你不能假设用户会告诉你关于错误的信息,因此你应该考虑跟踪应用程序中的错误(前端和后端),以便你可以看到用户面临的问题,并找出如何修复它们。

  • 根据需要添加其他类型的测试 — 没有必要启动一个新项目并构建所有可能的测试类型。相反,当发现痛点时,应用新的测试类型。例如,如果你收到报告称某些页面在不同浏览器中看起来不同,你可能会采用视觉差异测试,这有助于自动化页面的外观,以便你可以识别出不一致或变化的情况。

  • 基于用户和结果进行测试 — 了解你的用户以及你期望他们获得的结果,并找出哪些测试有助于增加你对用户需求得到满足的信心。如果你期望用户的结果是他们会注册你的服务,那么你应该广泛测试营销和注册页面。

测试是一个很大的主题,它远远超出了 Angular 的范畴。Web 应用程序有许多潜在的测试和验证区域。好的应用程序将找到测试范围的正确组合,首先测试最关键和最薄弱的区域,然后随着时间的推移扩展测试覆盖范围。

摘要

在本章中,我们对测试进行了快速浏览,并对我们的库存跟踪应用程序进行了实际数量的测试:

  • 单元测试和端到端测试是你可以为 Angular 编写的两种主要测试类型。单元测试是为了验证应用程序的各个部分在独立工作时是否正常工作,而端到端测试则是验证应用程序作为一个整体是否正常工作。

  • 管道通常是最容易进行单元测试的,因为它们通常是纯函数,容易传递参数。

  • 服务可以很容易地进行测试,特别是如果它们的依赖性最小的话。如果可能的话,手动构建服务进行测试是首选的,并且最好也为其他测试编写存根服务。

  • 组件是你将要测试的主要构建块——通过直接测试控制器方法或测试视图的渲染方式——并且它们依赖于使用测试模块来实例化一个轻量级的 Angular 应用程序。

  • 测试指令与组件测试非常相似,只是你需要测试它们如何通过观察渲染组件的变化来修改组件。

  • 端到端测试可以编写成以典型用户的方式与应用程序交互,通过点击或输入字段。端到端测试维护起来比较困难,因此它们应该设计成首先测试最关键的功能。

  • 还有许多其他类型的测试和考虑因素,但大多数通用的 Web 开发实践都可以轻松应用于 Angular 应用程序,并且在必要时推荐添加。

11

生产环境中的 Angular

本章涵盖

  • 如何正确构建 Angular 以用于生产环境

  • 架构应用程序以实现最佳实践的途径

  • 检查您的最终包以确定依赖项如何影响您的应用程序

  • 部署选项和示例 Dockerfile

因此,您已经构建了您的应用程序——接下来是什么?这是一个棘手的问题,但我们将尝试在本章中解开它。您可以做或关注很多事情,但并非所有事情在所有项目中都是必要的。有许多通用的 Web 开发事项您可以做,例如在您的服务器上启用 gzip,以及一些特定于 Angular 的事项,例如确保您使用即时编译运行构建。

我们通过运行ng serve来开发应用程序的方式对于开发来说是完全可接受的,但对于生产来说则不可接受。开发服务器不是为处理真实 Web 流量而设计的,可能会暴露漏洞,并且可能会在没有警告的情况下轻易崩溃。您将想要使用一个加固的 Web 服务器来托管您的应用程序。

您可能正在为客户或企业编写应用程序。我在这两个领域都有经验可以分享。例如,在企业环境中,通常会有不同的约束或不同团队之间更大的责任分离。

我将涵盖许多重要的生产主题,但请记住,总有改进的空间,并且几乎有无限种方式来编排构建应用程序的工具。本章重点介绍大多数应用程序应该做的事情,并为您提供一个良好的知识基础,以便应用于您可能正在工作的任何独特或定制环境中。

11.1 为生产构建 Angular

在本书的整个过程中,我们一直在进行应用程序的开发构建。当我们想在真实环境中部署我们的应用程序时,这不会对我们有太大的帮助——它包含大量额外的代码,没有优化,并且需要清理。我们的目标是优化最终构建资产,以便尽可能小和高效。我们还想确保文件可以尽可能快地下载。

请记住,Angular 一直在不断发展,新版本将添加额外的功能和平台及工具的变化。我预计本章中的所有内容在原则上都将随着时间的推移保持准确,但确实有一些具体细节将会发生变化,因此请关注发布情况,并在新功能到来时利用改进。

11.1.1 生产构建

CLI 提供了一个生产构建选项,它为构建生成的最终代码添加了多个优化步骤。在开发过程中,我们尽可能快地做所有事情,但代价是文件大小更大。这完全没问题,因为您正在本地服务器上工作,发送 5MB 并不太慢——但对于使用 3G 移动连接的人来说,这将是无法忍受的慢。

您可以通过运行以下 CLI 命令来运行生产构建:

ng build --prod 

CLI 将构建资源并将它们放入一个名为 dist 的目录中。这些文件现在已准备好作为一组静态资源部署到您的目标服务器。我稍后会详细介绍这一点,但 CLI 使生成这些资源变得非常简单。

当它进行生产构建时,会发生一些重要的事情(请注意,随着 CLI 工具的演变,此列表可能会发生变化):

  • 使用生产环境 — 如果您已使用环境配置目标,则在构建过程中将自动使用生产版本。

  • 使用压缩 — 通过运行工具来最小化文件,以减少执行任务所需的字节数。

  • 打包资源 — 而不是每个文件单独加载,它将它们分割成更小的包。这些包根据用途分组,例如将供应商文件放在一个包中,polyfills 放在另一个包中,应用程序放在另一个包中,等等。

  • 设置基本href — 如果您计划将应用程序部署到域的根目录之外,您必须设置基本href以匹配文件路径。您可以通过传递--base-href标志来配置它。

  • 文件名哈希化 — 为了防止缓存问题,文件使用唯一的哈希值命名,这样当您部署新版本时,浏览器不会忽略更新并使用其内部缓存。

  • 文件复制 — 任何静态资源,如 CSS、图像或字体,都将复制到 dist 目录中。

  • 摇树优化 — 这些包本身通过摇树优化进行优化,这是一个从包中删除不可达或未调用的代码的过程。由于 ES2015 模块,工具可以确定哪些代码行被执行或未执行,并“摇出”未使用的部分。这是一个复杂的功能,并且截至本文撰写时,有一些已知问题需要修复。

  • AoT 编译 — 启用提前编译组件模板,有助于加快初始渲染时间,并允许 Angular 不传输大量用于编译的额外代码。

您可以通过使用标志来禁用其中的一些功能,例如aot=false,并且您可以通过查看 CLI 文档来了解其他标志,以启用其他功能,如翻译或替代输出目录。

11.1.2 优化目标浏览器

您是否为要针对的浏览器制定了一个计划?如果没有,您应该弄清楚您计划支持哪些浏览器的哪些版本。在持续更新的浏览器时代,持续评估您支持的浏览器以及它们可能存在的缺陷变得很重要。

默认情况下,在项目中包含 polyfills 以填补缺失特性的差距,并且您可以自定义使用的内容。如果您对支持的浏览器以及应用程序使用的功能有很好的了解,您可以自定义 polyfills。

文件 src/polyfills.ts 包含一个导入到项目中的 polyfills 列表。其中一些可能需要,但其他一些可能不需要。你需要评估你的代码库来确定,但检查一下是否可以移除其中的一些以优化包大小。

11.1.3 进阶式网络应用(Progressive Web Apps)

进阶式网络应用(Progressive Web Apps,简称 PWAs)是一类具有一些特定功能的特定应用程序。它们旨在帮助提供快速加载的移动体验网络应用,同时也可以离线工作。你可以在developers.google.com/web/progressive-web-apps上了解更多关于它们的信息。

在撰写本文时,Angular 仍在开发使用 Angular 制作 PWA 的工具。Angular 专门为此工作而建立的网站(mobile.angular.io)值得关注。随着它从 alpha 状态发展到稳定功能,你可以期待通过 CLI(正如它已经做到的那样)提供工具。该网站应提供如何尝试它的说明。

目标是使 Angular 应用程序能够简单部署为 PWAs,并且 Angular 文档已经是这个想法的一个工作示例。在 Angular 中支持 PWA 对需要在移动环境中工作的应用程序是一个巨大的推动。

11.1.4 国际化(i18n)

Angular 内置了对提供应用程序本地化翻译的支持,但这相当长,并不是每个应用程序都需要它。Angular 关于国际化的文档(angular.io/guide/i18n)非常详尽,这是 Angular 社区预计将在未来几年投资和改进的领域。由于该指南中已经覆盖得很好,我将更多地讨论概念而不是实现。

翻译是困难和耗时的,但对于许多人来说是不可避免的。Angular 无法提供实际进行翻译工作的支持——即转换一种语言到另一种语言所需的工作——但它被设计成适合典型的翻译工作流程。基本思想是,所有文本消息——模板中用语言编写的任何内容——都可以从应用程序中提取出来,翻译,然后为不同的语言重新编译。

这里是实现应用程序中 i18n 的典型步骤。通常,你越早设置你的 i18n 流程,实施和维护就会越容易:

  1. 1 识别和格式化应用程序中的任何静态消息。根据你的偏好和翻译管理方式,有多种方法可以做到这一点。

  2. 2 使用 CLI (ng xi18n) 从应用程序中提取消息,这将生成一个 Xliff 1.2(默认)、Xliff 2 或 XMB 格式的文件。

  3. 3 翻译员的工作是手动将原始语言的消息列表翻译成另一种语言。请预期这一步骤需要花费时间,并且你需要确定负责这项工作的人员(可能是你,也可能是你团队的一员,或者团队外部的人)。

  4. 4 使用 CLI 构建应用程序的新版本,并定义在编译期间使用的翻译文件(ng build --i18n-file)。这将输出一个完整的构建,其中模板内的翻译消息已被替换。

到本文撰写时,一个主要的限制是翻译不能动态加载到运行中的应用程序中。这些消息在构建过程中被硬编码到输出结果中。如果你有多个语言,你必须为每种语言构建一次,传递适当的 i18n 文件。这当然是一个潜在的改进领域,因此我再次建议你查阅文档,看看它是否变得更加灵活。

11.1.5 使用替代构建工具

虽然空间限制了我不能在这里涵盖所有场景,但许多人正在为 Angular 开发各种替代构建工具,从使用不同的模块加载器(如 SystemJS)到不同的摇树优化器(如 Rollup)。

这些工具都试图优化构建过程的各个方面,同时提供自己的解决方案。如果你的项目越大,或者某个特定方面需要优化的重要性越高,你可能就越需要考虑额外的工具。

虽然选择使用自己的构建工具路线当然是一个可行的选项,但在大多数情况下,我强烈建议你使用 CLI。这是一个节省时间的工具,它随着 Angular 平台的发展而不断成长。

在 CLI 可用之前,我最初设置了自家的工具,维护起来非常费时。我可能对某些事情有完全的控制权,但代价是花费更多的时间和精力。我也与那些使用 Grunt 或 Gulp 维护自己工具的项目合作过,我发现它们为项目添加了大量代码来管理 CLI 提供的相同任务。不可避免的是,通常只有一个人理解构建工具,这使得其他人难以理解正在发生的事情。

如果你有自己的需求,考虑将 Angular CLI 作为流程的一部分使用,而不是复制其行为,特别是那些与其构建应用程序的方式相关的行为。如果你想在构建后进行额外的处理,最好在 CLI 运行后进行。

在撰写本文时,CLI 正在被重新设计为一个更完整的软件开发工具包(SDK),以便更好地集成到定制解决方案和工具中。我预计这将演变成一个更强大的工具集,提供更大的灵活性和更多的选择。

11.1.6 服务器端渲染或预渲染

一些应用程序有重要的需求,例如搜索引擎优化(SEO),这意味着它们需要被搜索引擎轻松抓取。或者,可能快速查看页面内容很重要(就像我们在第七章中的论坛和聊天应用程序示例中那样)。

Angular 有一个名为 platform-server 的包,它提供了一种在服务器上渲染 Angular 的方法。就像 PWA 支持,在撰写本文时,它仍在积极开发中。您可以在 github.com/angular/universal 上了解更多信息。这种方法的目的是在服务器上渲染您应用程序的页面,这对爬虫和用户来说都是最好的。用户将比现在更早地看到页面内容,因为渲染版本会在初始内容显示后启动 Angular。

想想一个博客文章可能看起来怎样,你可能会开始阅读,但直到 Angular 在后台完成其余资源的加载,点击链接的能力将会延迟。Angular 足够智能,能够捕获这些事件,并在后台加载完成后重新播放它们,这样就不会阻止用户交互——这主要是关于在 Angular 启动之前显示内容。

这对许多应用程序可能非常有用,但对其他应用程序的价值不大。您的用例将决定它对您的有用性,但预计服务器端渲染将成为 Angular 平台未来版本中的一个重要功能。截至 Angular 4,渲染是官方构建过程的一部分,但仍有许多路线图任务要解决。

11.1.7 构建管道

那些简单地将文件上传到远程服务器并让其运行的日子已经过去了。大多数应用程序,包括使用 Angular 构建的应用程序,都需要构建工具和编排来构建生产版本。今天,我们有广泛的工具来帮助处理称为 持续集成和交付(CI/CD)的过程。有许多方法可以解决构建编排问题,但基本通常包括一系列工具,这些工具会自动运行以验证您应用程序的特定构建,运行测试,为生产构建它,并将其推送到生产环境。

Angular 本身使用一系列工具来辅助框架的自动化测试,并且由于它是一个开源项目,它可以自由地这样做。许多工具为开源项目提供免费账户,或者如果您想尝试它们,可以提供试用账户。在这里,我不会专注于任何特定的工具,而是会关注您应该考虑将哪些类型的事情放入自己的管道中。

与构建管道相关的 Angular 并没有什么特别之处,除了 Angular 计划从 Google 生态系统推出一系列工具,这些工具在工具稳定时将是应用程序可以利用的绝佳选择。请注意发布说明和项目公告,因为更多 CI/CD 工具的添加肯定会是一个主要的功能。

11.2 Angular 架构选择

如果你没有在编写代码之前规划应用程序的架构,你就错过了一个思考有助于你以后的设计选择的机会。审查你的架构并考虑你可以做出的简化或优化选择,即使晚些时候也比没有好。

本节涵盖了编写应用程序时可能被认为是最佳实践列表的内容,你应该在大多数情况下遵循这些方法。它们都会对你的应用程序的质量、性能或稳定性产生影响。

这些也都是你在构建应用程序时需要持续做出的选择。它们不是你一次性采取的步骤,而是你应该在构建和维护应用程序时应用的原则。

11.2.1 懒加载路由

当谈到使你的应用程序快速加载和运行时,懒加载路由可能是你可以采取的最重要的一步。性能的最大衡量标准之一是应用程序加载并准备好与用户交互所需的时间。

在这条路上有许多里程碑,但用户能够更快地开始使用你的应用程序,他们留下来的可能性就越大。这在移动设备上尤其如此,因为缓慢的加载和渲染会在几秒钟后迅速降低用户留存率。

当你审视你的应用程序时,考虑每个路由的结构。你可能希望将默认路由捆绑在一起而不进行懒加载,但大多数其他路由可能都是将其分离到自己的模块并懒加载的好候选者。

第七章第 7.8 节涵盖了懒加载,所以请回顾那个部分以刷新关于其工作原理和如何在应用程序中应用的具体细节。我的建议是将每个路由都做成自己的功能模块(除了默认路由,如主页和可能登录页面)。当你这样做时,构建文件将根据每个路由分成许多不同的文件,只有核心包在应用程序加载时加载。其余的将在用户在应用程序中导航时加载。

想象一下,如果你的应用程序有 10 个路由和一些共享服务和组件。这 10 个路由可能代表了应用程序代码重量的 60-70%(不包括库),因此通过提取它们可以实现显著的节省。每个应用程序都会有所不同,但路由越多,潜在节省的可能性就越大。

您还可以考虑预加载懒加载的模块,这是一种在初始路由加载后,在后台下载模块的方式。这意味着后续路由将更快地渲染,但这确实会加载应用程序文件。如果您在移动设备上,如果您的应用程序非常大或者很少加载路由,这可能不是很好。

如果不明确使用,也有策略仅预加载特定模块,但这需要一些额外的定制配置。这两项策略都由前 Angular 核心成员 Victor Savkin 在vsavkin.com/angular-router-preloading-modules-ba3c75e424cb上进行了很好的阐述。

11.2.2 减少外部依赖

外部库有很多有用的,但任何事物都有代价。总是存在权衡,但这是您应该仔细考虑的事情。如果您包含一个依赖项,请准备好跟踪它在您的应用程序中的使用情况。

在所有早期的章节示例中,我都包含了一些外部依赖项,如 UI 库。这些库提供了巨大的价值,因为我们不必自己构建和实现这些功能。这就是包含外部依赖项的全部原因。

从另一方面来看,您包含的依赖项越多,您的应用程序就会变得越大。您对这些依赖项几乎没有控制权,我们中的许多人几乎没有花时间仔细检查它们,如果不是完全没有的话。

有一些工具和技术可以帮助您检查这些依赖项在您的应用程序中的影响。然而,大多数时候,您需要运用常识并限制自己只关注最重要的依赖项。我对依赖项的主要担忧如下。

安全问题

库通常被视为黑盒,它们可能包含安全漏洞。密切关注它们并审查它们。依赖项也可能导入额外的子依赖项,这可能会使安全漏洞难以跟踪。

您可以查看 Node Security 项目以帮助跟踪 Node 模块中已知的漏洞,请访问nodesecurity.io/opensource。这是一个免费工具,尽管它也有一些付费解决方案。它会检查您的项目 package.json 文件,并将其与漏洞数据库进行比较。

质量和大小

并非每个依赖都是针对生产用途编写的。许多依赖之所以被编写,是因为它们解决了某个问题,并且被发布供他人使用。始终衡量它们对您应用程序的影响,并在存在多个选项的情况下确保评估它们——或者看看自己动手做是否更好。

我经常通过查看依赖项的测试类型和质量来审查依赖项。遗憾的是,许多依赖项没有任何测试,或者测试不足。这是因为事情往往被迅速发布,作为一种爱好。

就尺寸影响而言,您可以使用一个名为 source-map-explorer 的工具,如图 11.1 所示,来查看每个目录和文件对您整体捆绑包尺寸的影响。

c11-1.png

图 11.1 source-map-explorer 允许您查看您捆绑包中每个部分的尺寸,以识别潜在的优化。

要使用 source-map-explorer,您需要使用以下命令在您的系统上安装它:

npm install -g source-map-explorer 

然后,您需要构建应用程序,但请确保您使用带有 sourcemaps 的方式构建它。您可以进行生产构建,但切换 sourcemaps 为开启(在生产构建中默认是关闭的):

ng build --prod --sourcemap=true 

这会将您的文件输出到 dist 目录中,然后您可以通过运行以下命令来检查各种捆绑文件,其中第二个参数是要分析的文件名:

source-map-explorer dist/vendor.4e84f21c95c687f96a48.bundle.js 

将打开一个浏览器窗口,看起来像图 10.1。图像中的文本相当小,但您可以点击各种文件并放大到该包的内部。例如,图 10.1 显示,第八章示例中的 Angular Material 包占供应商捆绑包的约 35%,而 Covalent 占约 18%,RxJS 占约 6%。了解这一点是有帮助的——您想看到模块对您捆绑包尺寸的影响。运行此工具针对所有您的捆绑输出文件,并查看哪些组件最大,或者 polyfill 有多重。

兼容性和可支持性

外部依赖不一定得到维护。当出现问题时,您能信任它们会被修复吗?一个例子是 TypeScript,它添加了 Angular 所依赖的功能。

有几种方法可以确定可支持性,而且这样做在很大程度上是主观的。我个人喜欢花时间在项目网站上,通常是 GitHub,看看它有多活跃。是否有许多没有响应的 bug 报告?关于提交活动呢?仅仅因为过去几个月没有活动并不意味着它已经被放弃。一些项目会显示有用的徽章来声明依赖是否得到积极维护。

与之相反,打开一个问题并询问项目是否活跃,我发现找到一个小改进来做出贡献会更好。您可以通过添加几行到文档或 README 文件中来实现这一点,但目标是给项目提供一些价值,并使其成为一个容易合并的贡献。总是有可以改进的文档,所以利用这一点来赢得维护者的一丝好感,并了解他们的响应性。

当谈到兼容性时,您需要确保您有良好的测试环境。验证事物是否正常工作的唯一方法是测试,并且您希望尽可能自动化这一点。

另一个有用的项目是 Green Keeper,它可以帮助你自动将项目更新到最新版本。它还尝试使用较新版本运行你的测试,以帮助确定是否存在任何破坏性变化——良好的测试对于这一点来说至关重要。你可以在 greenkeeper.io 找到该项目。

尽量减少导入的项目

当你决定需要外部依赖项时,通常有方法可以优化它在你的应用程序中的使用。设计良好的库允许你只导入你需要的特定部分,而不是整个库。任何额外的导入项都可能成为最终构建中的死代码。这因库而异,但一个例子是 RxJS,它允许你导入你需要的特定项目,而不是整个库。同样,Covalent UI 和 Angular Material 库都允许你根据需要导入库的特定部分,而不是整个包。这可以节省总体代码量,我也喜欢这种做法,因为它可以明确指定要导入的内容。

从长远来看,如果你能够优化你的包文件以删除任何未使用的代码,这可能不会成为一个问题。但在此期间,实践尽可能少地导入项目,并专注于你真正需要的东西,仍然是一个好主意。

11.2.3 保持更新

当 Angular 4 发布时,几乎每个应用程序都可以升级而不会引起任何需要处理的破坏性变化。考虑到 Angular 2 和 4 之间所做的更改和改进的数量,这相当令人印象深刻。

其中一项关键改进与 Angular 编译器有关,由于它在底层进行了优化,应用程序包的大小显著减小。节省量因应用程序而异,但据估计,编译器可以将组件的大小减少约 60%。应用程序要实现这些节省,只需更新即可!

随着 Angular 的发展,这种类型的场景将继续存在。Angular 库将不断发展和优化自身,构建工具将变得越来越智能,而使用最新和更高版本的人将能够利用这些好处。

正如我之前强调的,Angular 更多的是一个平台,而不仅仅是一个框架。工具、各种库和其他生态系统组件的组合正在不断改进。我曾与 Angular 团队坐下来讨论优先事项和目标,优化是一个始终出现的话题。

这里的要点是不要将自己锁定在 Angular 的某个版本上并忘记升级。跟踪开发笔记并享受定期的增量发布。主要版本可能每六个月发布一次,可能包含一些破坏性更改。Angular 希望最小化破坏性更改的数量,并为保持最新版本提供清晰的升级路径。这确实意味着随着时间的推移对您的应用程序进行更改,但如果不这样做,您就会失去所有潜在的好处。此外,您可能容易受到已报告并解决的任何安全问题的攻击。

11.3 部署

部署应用程序有无数种潜在方式,关于哪种方式最好也有很多意见。您可能必须与特定的选项合作,因为这是您公司或团队一直使用的,或者您可能能够选择自己的。无论如何,一些基本要点是一致的。本节探讨了这些考虑因素和几种部署方法。我希望它能作为您制定自己的部署策略的有用指南。

无论您计划使用哪个服务器,您都应该确保一些事情设置正确以正常工作:

  • gzip 压缩 — 配置您的服务器以启用 gzip 压缩发送文件。gzip 是大多数浏览器支持的简单配置值,可以显著减少通过网络发送的文件大小。节省的量各不相同,但 30% 或更多并不罕见。

  • 通过重写 URL 回退到 index.html — 任何单页应用程序(Angular 或其他)都需要服务器处理重写 URL 以提供索引文件,即使请求了其他文件也是如此。想象一下,您到达 example.com/blog/my-post-123。服务器将首先在 /blog/my-post-123 位置查找资产,但该文件不存在,因为它只是一个 Angular 路由。相反,服务器需要回退到发送 index.html 文件,以便 Angular 可以执行。

  • CORS 或反向代理 — 许多 API 都运行在不同的域名(甚至子域名)上,因此很容易在 API 服务器中启用 CORS 头部。或者,您可以使用反向代理来映射 URL,以便它们通过您的域名代理到另一个域名。

根据您的服务器,可能还有其他优化或配置可供您使用。这里提出的建议并不全面,因此最好进一步研究一下您的服务器提供了哪些功能。

一旦您运行了 CLI build 命令,您就可以将 dist 目录放在处理服务静态文件的服务器上。您应该自动化这个过程,但有一些方法可以在 GitHub pages、Heroku 或 Firebase 等服务上免费部署它。

这是最常见且最简单的方法,但有时你可能想要将其封装在容器中并以此方式部署。以下示例使用 nginx 服务器构建一个包含应用程序文件的容器。你可以在你的项目目录中创建一个名为 Dockerfile 的文件,并使用列表 11.1 中的代码。你还需要在你的目录中有一个 nginx.conf 文件,如列表 11.2 所示。

列表 11.1 样本 nginx Dockerfile

FROM nginx

COPY ./nginx.conf /etc/nginx/nginx.conf
COPY ./dist /usr/share/nginx/html 

列表 11.2 样本 nginx 配置

events {}
http {
  server {
    listen       80;
    server_name  myapp;

    # Redirect to index if file not found
 location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
        try_files $uri$args $uri$args/ /index.html;
    }
    # Additional configurations go here
  }
} 

前面的两个列表将允许你构建一个包含 nginx 以服务你的应用程序的容器。假设你在机器上安装了 Docker,你可以运行以下命令来构建和运行容器:

ng build --prod
docker build . -t myapp
docker run -p 80:8080 myapp 

配置这个 Dockerfile 有多种方式,这只是一个基本的入门级示例。你还可以微调 nginx 配置,包括处理错误页面和调整 gzip 设置。

因为 Angular 为你提供了构建的静态资源,将它们注入到许多不同的服务器环境中很容易。

恭喜!你已经设计、构建并现在部署了你的 Angular 应用程序!在这个时候,你可以要么放松享受你的成功,要么开始你的下一个项目!

摘要

本章涵盖了构建和部署 Angular 到生产环境的许多细节。你可以做很多细致的工作,并且有很多优化机会。随着时间的推移,Angular 平台将添加新的功能,你将想要利用这些功能。以下是本章涵盖的关键亮点:

  • Angular CLI 提供了一个生产构建选项,在构建你的应用程序最终包资源时会执行多项任务。它可以优化并移除死代码,实现缓存破坏名称,并提供一个包含你应用程序所需一切内容的目录。

  • 一些最佳实践包括懒加载你的路由和只导入必要的库组件。随着你向应用程序添加更多依赖项,可能会增加你的包大小,或者影响你维护应用程序的能力。

  • 部署到服务器上很容易,因为你得到了最终的渲染资源。你可以将这些静态文件打包到容器中,或者包含在其他服务器环境中。确保你已经处理了回退到 index.html 文件和其他服务器考虑事项。

A

附录:从 AngularJS 升级到 Angular

AngularJS(版本 1.x)是有史以来最受欢迎和最广泛使用的框架之一,许多用其构建的应用至今仍在运行。只要有一批开发者提交问题和补丁,它就会继续得到支持,但未来的发展方向无疑是升级到 Angular(2.x 或更新版本)。本附录讨论了在现有 AngularJS 应用中前进的策略,旨在帮助你明确在决定下一步行动时有哪些选择。

升级到 Angular 需要投资和计划。两者之间有许多概念上的相似之处,但 Angular 是从 AngularJS 完全重写的,并改变了一些重要的设计模式。在大多数情况下,“升级”的能力并不简单,需要仔细考虑。

你有三个基本选择:不升级、从头开始重写,或者进行增量升级。每个选项都有其优点和缺点,因此你需要权衡自己的需求与它们。我已经按照从最不可能到最可能的选择顺序排列了它们。

我还在应用的业务价值与其复杂度的谱系上为你绘制了选项图。图 1 只是一个示例,并不一定反映你应用的现实情况,但它的目的是给你一些思考如何考虑选择的视角。

bappa-1.png

图 1 选项图,根据业务价值与应用复杂度绘制

尽管我认为每个人都应该升级或重写,但权衡技术挑战与你的业务需求同样重要。做出改变总是有成本的,你想要确保你准确估计并最小化这一成本。

让我们从第一个选项开始:不要升级,继续使用 AngularJS。

A.1 选项 1:不升级

我觉得这是一个不太常被考虑的选项,但你可以决定不升级你的应用到 Angular。这听起来可能与我们这本书所讨论的内容相悖,某种程度上确实如此,但现有应用有许多需要考虑的因素,这可能会使它成为最可行的选项。

这里列举了一些你可能考虑仅仅维持现有应用而不是升级的各种原因:

  • 非常大的应用可能非常复杂,以至于考虑进行重大更改的想法很难接受。请小心不要以你的应用太大而无法升级为借口,因为升级是绝对可能的,但也许应用在现有设计中投入了太多的资源,改变的成本可能难以承担。

  • 有些应用程序非常稳定,不需要做很多工作。甚至可能没有人再积极工作了。这对于不是你业务关键路径的应用程序尤其如此,例如内部工具。也许你的努力应该花在构建或改进更重要的应用程序上。

  • 一些应用程序必须支持旧版浏览器,升级到 Angular 可能会破坏支持。随着大多数旧浏览器被供应商弃用、退役或具有自动升级机制,这一点已经变得不那么有说服力了。例如,根据你的客户类型,今天支持 IE8 对某些应用程序来说是可能的,但这也令人沮丧,因为该浏览器容易受到攻击且过时。

我认为这归结为一个问题:你的应用程序是否有任何强烈的商业或技术原因不升级?如果有,权衡它们与你的客户现实情况以及对他们最好的情况。使用过时或不安全的浏览器的客户对他们来说是不利的,也许正确的决定是不支持这样的场景。但这可能需要改变视角。

如果这里的最终决定是放弃应用程序而不升级,你必须非常清楚后果。但你也可以期待,只要还有一定数量的用户在使用 AngularJS,它就会得到支持。查看 AngularJS 网站,看看是否有任何具体的截止日期或保证,但截至写作时,还没有设定停止 AngularJS 维护的计划。

如果你处于企业环境中,这可能是一个最简单的选择。它可以让你免于参与业务发展周期、资源规划和其他公司可能实施的过程。但无论你扮演什么角色或你的公司有多大,关键是要专注于为什么不要升级的理由,而不是为什么要升级的理由。遗留应用程序是生活的事实,但根据经验,我知道应用程序升级得越早,就越有可能得到维护——否则,它往往会慢慢消失在遗忘之中。但也许那才是最好的道路。

无论怎样,请答应我,你不会用 AngularJS开始新的项目!利用这本书的知识,用 Angular 创建新的应用程序。

A.2 选项 2:从头开始重写

你的下一个选择是重新开始,丢弃现有的应用程序。一些开发者会喜欢这个想法,而其他人可能会对仅仅丢弃所有这些工作的想法感到反感。

如果你决定准备好跃跃欲试并升级到 Angular,重写你的应用程序有许多优点。其中大多数适用于决定在一段时间内升级任何应用程序,但这里有一些 Angular 特有的注意事项要提及:

  • 您不必处理任何遗留代码。AngularJS 做了一些最终被证明会导致问题的操作,例如过度依赖双向数据绑定。如果您的应用程序正在运行较旧的 AngularJS 版本,那么升级可能比您运行最新版本时是一个更大的任务。

  • 新的应用程序可以使用最佳实践进行重建,这些实践如果您在升级现有应用程序时可能会与之抗争。即使您之前没有 TypeScript,您也可以从头开始使用 TypeScript。

  • 由于您可以将重写视为重新构思应用程序目的和目标的机会,因此实现新功能或删除旧功能会更容易。

当然,这种方法也有其缺点,并且它们通常被忽视:

  • 重写意味着您必须从头开始一切,包括重新思考您的构建管道、重新设计您的测试等等。

  • 如果您依赖于任何 AngularJS 库,它们可能不存在于 Angular 中,或者可能有所不同。如果您需要寻找替代方案、编写自己的库或重新设计应用程序,这将增加您的努力水平。

  • 通过重写很难完全复制具有相同功能和特性的应用程序。几乎可以肯定,某些功能的行为会发生改变,即使是非常轻微的改变,这也可能对您的用户造成问题。

当应用程序足够小,可以在合理的时间内进行重写时(合理的定义各不相同,但一个好的经验法则是几周而不是几个月),我更喜欢这个选项。逐步升级一个小型应用程序最终可能会花费您更多的时间和精力,但请确保您能够考虑到应用程序的稳定性,并且不要为了用户体验而牺牲。

重写的主要目标应该是构建一个能够解决您业务需求的最佳 Angular 应用程序。如果您主要关注技术方面,那么您的努力可能会对业务产生负面影响。这在企业环境中尤其如此,因为期望通常由产品经理和客户设定和管理。

这在最初的企业环境中往往是一个有问题的选择。通常的想法是,“如果它没有坏,就不要修复它。”但您可以根据未来的目标和结果来为重写辩护。我提出论点,如果我们今天不采用新技术,我们将来将不得不付出更多代价来迁移应用程序。再次强调,业务目标应该驱动决策,但通常业务目标包括长期成功,因此技术选择也应该支持这一点。

A.3 选项 3:逐步升级

最终的选择是开始一个增量升级过程,这意味着部分使用 AngularJS 运行你的应用程序,另一部分使用 Angular。这是允许你在逐步将应用程序的部分组件转换为 Angular 的同时,维护当前应用程序的策略。

我认为这是最常见的情况,因为任何具有商业价值和一定复杂性的应用程序都会符合这个类别。以下是增量升级的一些优点:

  • 你可以逐步逐块迁移应用程序,这样现有的应用程序仍然可以正常工作。

  • 工具存在,可以并行运行这两个版本,并且足够灵活,允许你采用几种不同的方法来处理重构。

  • 它允许开发者专注于使用 Angular 构建新功能,而无需重写旧功能。

当然,还有一些负面因素你应该知道:

  • 你最终会在应用程序中同时加载 Angular 和 AngularJS,这会增加应用程序的大小。

  • 编写不佳的 AngularJS 应用程序迁移起来更困难,可能需要一些工作来简化这个过程。

  • 如果你没有正确规划和执行,可能会在升级过程中卡住,永远无法完全完成。你需要能够全身心投入,以确保用户在长期内不会得到糟糕的体验。

我认为大多数应用程序都属于这个类别,你可能会想了解更多关于最佳升级方法的信息。Angular 文档对升级工具的介绍相当全面,我不打算在这里重复介绍,但它没有讨论你可以使用的一些方法。

我强烈建议你确保你的 AngularJS 应用程序首先是与最新的 AngularJS 版本保持一致,并遵循样式指南。如果你在这个领域有一些工作要做,你应该首先投资于这个领域。

一旦你有一个设计良好的 AngularJS 应用程序,Victor Savkin 提出了两种非常好的方法,他称之为 水平垂直切片。他有一系列在线文章关于升级 AngularJS 应用程序,从这里开始:blog.nrwl.io/ngupgrade-in-depth-436a52298a00。它概述了升级的额外步骤,我强烈推荐。那里还列出了书籍,你可能觉得有用。

水平切片的想法是尝试升级 AngularJS 应用程序中最低、最可重用的组件。这包括像 UI 组件和不是路由组件的组件。想法是这些底层组件已经相当解耦,你可以直接用用 Angular 编写的版本替换它们。这样做的好处是你可以一次只更改一个组件。

垂直切片是指同时更改给定路由的每个组件的方法。这意味着路由组件及其依赖项将需要同时更改。这很有益处,因为你永远不会在同一个路由中混合 Angular 和 AngularJS,但这意味着升级每个路由可能需要更多的时间投入。

在大多数情况下,我建议使用垂直切片,因为将 Angular 和 AngularJS 混合在同一个路由中更难以推理和调试。垂直切片还意味着你可以一次性升级并使整个页面工作,虽然每个增量步骤可能需要更长的时间,但总体上由于需要更少的步骤,可能会更快。

摘要

将 AngularJS 应用程序升级到 Angular 不是一个轻率做出的决定。你应该仔细考虑选项,并理解你的业务和技术需求:

  • 你可以选择完全不升级,并信任 AngularJS 提供的支持。那些不在关键业务路径上的稳定应用可能是合适的选择。

  • 或者,你可以选择一次性将整个应用程序重写为 Angular。这可能适用于较小的应用程序或那些可以在不中断当前应用程序开发的情况下承受全面重构的应用程序。

  • 逐步升级是大多数应用的首选方案,因为它允许你逐步将组件从 AngularJS 更新到 Angular。水平(从树的最底层组件向上工作)和垂直(为每个升级步骤处理整个路由)切片策略是思考如何进行升级的最佳方式。

posted @ 2025-11-09 18:00  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报