Angular-高效指南-全-

Angular 高效指南(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎阅读《高效 Angular》:通过使用 Nx、RxJS、NgRx 和 Cypress 有效地使用 Angular,开发任何规模的应用程序。本书是掌握 Angular 和利用其强大生态系统构建可扩展和专业的 Web 应用程序的综合指南。无论你是经验丰富的 Angular 开发者还是刚开始你的旅程,这本书都将为你提供构建可扩展、企业级应用程序所需的知识和技能。

Angular 是由 Google 开发和维护的一个强大的前端框架。自其诞生以来,它已成为构建动态 Web 应用程序最受欢迎的框架之一。Angular 的基于组件的架构,结合其强大的 TypeScript 基础,为开发者提供了一个强大且可扩展的平台,用于构建现代应用程序。

在本书中,我们将深入研究 Angular 的复杂性,引导你了解其功能和最佳实践。本书不仅关于编写代码,还关于理解使 Angular 成为构建任何规模应用程序的多功能工具的底层原理。从使用 Nx 设置可扩展的环境到掌握 RxJS 和 NgRx 的响应式编程,每一章都旨在为你提供实用的见解和可以直接应用于你项目的真实世界示例。

你将学习如何使用 Nx 设计可扩展的前端架构并设置 Angular monorepos。深入探索 Angular 最强大的功能,包括依赖注入、路由、Signals、指令、管道、动画和独立组件。你将发现如何使用 NgRx 管理应用程序状态,并理解使用 RxJS 和 Angular Signals 的响应式编程,确保你的应用程序既高效又可扩展。

本书将引导你了解 Angular 生态系统的复杂性,使你能够轻松构建专业级的应用程序。

本书面向对象

本书专为希望提升 Angular 技能的前端工程师量身定制。无论你是经验丰富的开发者还是刚开始,本书都将引导你了解 Angular 生态系统的复杂性,使你能够轻松构建专业级的应用程序。你将学习如何利用 Angular 框架中的强大功能构建任何规模的应用程序。动手实例将教你关于最新 Angular 功能,如独立组件、Signals 和控制流。你将了解前端架构,使用 Nx 构建 Angular monorepo 应用程序,使用 RxJS 进行响应式编程,以及使用 NgRx 管理应用程序状态。到本书结束时,你将了解所有 Angular 功能,以便有效地使用它们。

本书涵盖内容

第一章适用于 Angular 应用的可扩展前端架构,为您提供有关前端架构的知识,使您在设置工作空间和开发可扩展的前端系统时能做出正确的决策。在本章中,您还将创建自己的 Nx 单一仓库,轻松处理数百个 Angular 应用程序和库,无需费劲。

第二章强大的 Angular 功能,让您了解最新的发展动态,并涵盖了 Angular 框架中最强大的功能。您将了解新的概念,如独立组件和信号。此外,本章将深入探讨 Angular 路由、组件通信和依赖注入。

第三章使用指令、管道和动画增强您的应用程序,向您展示如何通过利用指令和管道来编写更可重用的代码。您将了解在开发自定义管道和指令或使用 Angular 框架的内置选项时,最佳实践、常见陷阱和设计选择。此外,本章还将教授您如何在 Angular 框架内创建和重用 UI 动画,以使您的网络应用程序更加生动。

第四章像专业人士一样构建表单,提供了一步一步的指南,用于开发模板驱动、响应式和动态表单。您将了解如何为每种方法验证输入字段以及如何处理错误消息。到本章结束时,您将构建一个模板驱动表单,将其转换为响应式表单,并最终使用动态表单方法构建相同的表单。

第五章创建动态 Angular 组件,专注于开发满足复杂设计需求的可重用组件。您将了解内容投影、投影槽、模板变量和引用、动态创建组件、懒加载非路由组件以及开发自己的小部件组件,这些组件可以根据用户输入按需加载和挂载组件。

第六章在 Angular 中应用代码约定和设计模式,讨论了如何将结构和模式融入您的 Angular 应用程序。从代码约定和最佳实践到常用的设计模式,如外观模式、装饰者和工厂模式,本章将为您提供开发可扩展和健壮的 Angular 应用程序所需的知识和结构。我们将通过开发一个通用的类型安全 HTTP 服务来结束本章,该服务具有模型适配器,可自动将您的 DTO 转换为视图模型,反之亦然。

第七章精通 Angular 中的响应式编程,强调响应式编程的优势,并展示您如何在 Angular 应用程序中使用 RxJS 和信号来实现它。您将学习如何使用 RxJS 处理异步数据流,以及使用信号处理同步数据。我们将探讨如何结合 RxJS 和信号,并区分在 Angular 应用程序中处理响应性时何时使用 RxJS 和何时使用信号。

第八章优雅地处理应用程序状态,教您了解应用程序状态以及如何在 Angular 应用程序中优雅地处理它的所有必要知识。我们将创建一个门面服务来连接您的组件层与您的应用程序状态,并实现三种不同的方法来处理全局应用程序状态。我们将首先创建一个使用 RxJS 的自定义应用程序状态解决方案。然后,我们将将其转换为使用信号的自定义解决方案。最后,我们将探讨 RxJS 和信号在处理复杂应用程序的全局应用程序状态时的不足,并实现 NgRx 来减轻这些不足。

第九章提升 Angular 应用程序的性能和安全性,通过详细解释 Angular 变更检测机制并展示影响应用程序性能的其他因素,帮助您开发出性能更优的 Angular 应用程序,并展示如何减少这些因素的影响。此外,本章还教您如何通过探索恶意行为者的可能攻击面以及如何减轻它们来开发安全的 Angular 应用程序。

第十章Angular 应用程序的国际化、本地化和无障碍性,使您能够开发出对不同障碍、说不同语言或习惯不同格式的人群都无障碍的 Angular 应用程序。您将实现 Transloco 库用于 i18n 和 i10n,并了解 Web 无障碍指南。

第十一章测试 Angular 应用程序,为您提供编写 Angular 应用程序自动化测试的实践经验。您将了解在前端应用程序中使用的不同类型的测试,以及如何使用 Jest 编写和运行单元测试,以及如何使用 Cypress 测试框架编写和运行端到端测试。

第十二章**, 部署 Angular 应用程序,概述了在 Nx 单仓库中构建和部署 Angular 应用程序的步骤。您将学习如何分析您的包大小、运行 linting 和构建命令,以及如何在 Nx 单仓库中为一个或多个项目运行。

为了充分利用这本书

要充分利用本书,您应该有使用现代前端框架构建小型到中型前端应用的经验。您还应该至少了解 Angular 框架的基础知识。

本书涵盖的软件/硬件 操作系统要求
Angular 17.1 Windows、macOS 或 Linux
TypeScript 5.3.3
Nx 18.0.3
Cypress 13.0.0
Jest 29.4.1

如果您使用的是本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件 github.com/PacktPublishing/Effective-Angular。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富的书籍和视频目录的代码包,可在 github.com/PacktPublishing/ 获取。查看它们!

使用的约定

在本书中使用了多种文本约定。

文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号。以下是一个例子:“我将在 Nx monorepo 根目录下的 jest.preset.js 中仅添加以下配置。”

代码块应如下设置:

const mockTranslationService = {
  translocoService: { translate: jest.fn() },
  translationsLoaded: signal(false) as WritableSignal<boolean>,
};

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

TestBed.overrideComponent(AppComponent, {
  add: {
    imports: [StubNavbarComponent],
  },
  remove: {
    imports: [NavbarComponent],
  },
});

任何命令行输入或输出都应如下编写:

npx nx run <project-name>:test

粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以粗体显示。以下是一个例子:“在浏览器中,你会看到支出概览,就像之前一样。”

小贴士或重要注意事项

看起来是这样的。

联系我们

我们始终欢迎读者的反馈。

一般反馈: 如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及本书标题。

勘误: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将不胜感激,如果您能向我们报告此错误。请访问 www.packtpub.com/support/errata 并填写表格。

盗版: 如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过电子邮件发送至 copyright@packt.com,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

分享您的想法

一旦您阅读了《Effective Angular》,我们很乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢在路上阅读,但无法随身携带您的印刷书籍吗?

您的电子书购买是否与您选择的设备不兼容?

别担心,现在每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件

按照以下简单步骤获取好处:

  1. 扫描二维码或访问以下链接

packt.link/free-ebook/978-1-80512-553-2

  1. 提交您的购买证明

  2. 就这样!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件中

第一部分:Angular 基础知识及设置可扩展的 Nx 工作空间

在本部分中,您将了解前端架构以及创建工作空间时需要考虑的内容。此外,您将设置自己的 Nx 单仓库,该仓库可以处理数百个 Angular 应用程序和库。在创建您的 Nx 单仓库后,您将了解 Angular 框架中的最新功能,例如信号和独立组件。当您完全了解 Angular 框架的最新发展时,您将深入了解 Angular 路由、组件通信和依赖注入。然后,您将获得 Angular 指令、管道和动画的实际操作经验,并了解最佳实践、它们对性能的影响以及使用指令、管道或动画时的常见陷阱。您将通过创建模板驱动、响应式和动态表单来完成第一部分,了解您使用每种方法时的差异以及如何充分验证它们。

本部分包括以下章节:

  • 第一章为 Angular 应用程序构建可扩展的前端架构

  • 第二章强大的 Angular 功能

  • 第三章使用指令、管道和动画增强您的应用程序

  • 第四章像专业人士一样构建表单

第一章:Angular 应用程序的可扩展前端架构

Angular 是一个强大且功能丰富的框架,用于构建 Web 应用程序。根据 2023 年 Stack Overflow 开发者调查,在专业开发者中,它排在 ReactJS、NodeJS 和 jQuery 之后,是第四大最受欢迎的 Web 技术。由于其提供的结构和工具,Angular 在构建大型 Web 应用程序或由多个应用程序和库组成的企业解决方案时经常被选中。

本书将指导你有效地使用 Angular 框架来开发和测试任何规模的应用程序。你将从了解前端架构和设置一个可扩展的工作空间开始,该工作空间使用 Nx 准备好处理数百个 Angular 应用程序。接下来,你将探索 Angular 框架中最强大和最新的功能。你将学习使用 RxJS、Signals 和 NgRx 进行响应式编程和状态管理,并能够使用 Jest 和 Cypress 测试 Angular 应用程序。完成本书后,你将能够有效地使用 Angular 框架,并开发可扩展的企业级 Angular 应用程序,利用 Angular 提供的所有工具,同时实施最佳实践和良好的设计模式。

在本章中,你将使用 Nx 创建你的 Angular 工作空间。你将从了解我们所说的可扩展前端架构及其在开始编写代码之前考虑架构的重要性开始。你还将了解前端架构的不同模式,以及从头开始构建企业级解决方案时应考虑的因素。

最后,你将探索并使用 Nx,这是一个构建工具,允许你创建可扩展的 Angular 单仓库。在本章结束时,你将了解前端架构的关键方面,并拥有自己的 Nx 单仓库用于 Angular,可以轻松处理数百个应用程序。

本章将涵盖以下主要主题:

  • 理解可扩展的前端应用程序

  • 可扩展前端架构的不同方法

  • Nx 是什么?为什么你应该使用它?

  • 设置可扩展的 Angular 工作空间

技术要求

在本章结束时,我们将创建一个包含 Angular 应用程序和库的 Nx 单仓库。为了跟上进度,你需要安装一些工具。请注意,我们只会使用免费可用的工具。

你将需要以下工具:

  • Visual Studio CodeVS Code)作为你的集成开发环境IDE

  • Chrome 浏览器

  • Angular 17.1 或更高版本

  • NodeJS 版本 v20.11.0 或更高版本

  • TypeScript 版本 5.3.3 或更高版本

  • Nx 版本 v18.0.7 或更高版本

在本书的整个过程中,我们将使用 Angular 17.1、NodeJS 20.11.0、TypeScript 5.3.3 和 Nx 18.0.7。

本书对应的 GitHub 仓库可在 github.com/PacktPublishing/Effective-Angular 找到。

理解可扩展的前端应用程序

现代网络应用程序不断变得更大、更复杂。因此,开发可扩展的前端应用程序比以往任何时候都更加关键。为了创建可扩展的前端应用程序,我们需要了解在应用程序上下文中可扩展性的含义。

可扩展性是什么?

当你听到可扩展性这个词时,可能会首先想到处理更多的流量。然而,在前端应用程序的上下文中,当我们谈论可扩展性时,我们主要指的是代码库的可扩展性。或者,更简洁地说,代码易于扩展,模块或微前端可以轻松添加到软件中,而无需做太多工作。组件和库是可重用的;代码易于维护、测试和调试,即使应用程序规模扩大。你可以与不同的团队一起在应用程序的不同部分工作,并且加入编写类似代码的新团队既容易实现也容易执行。应用程序具有良好的性能和较小的包大小。编译和构建时间保持较低,如果需要,可以迅速部署到不同的预发布环境中。

要在您的前端应用程序中实现这些成就,您必须创建一个良好的架构、一套工具和规则,让每个人都能遵守。您的架构将包括诸如您的存储库类型、文件夹结构、架构模式、设计模式、编程语言、框架以及构建、测试、代码检查和部署应用程序的工具等元素。为架构的每个部分做出正确的决策有助于创建可扩展的应用程序,这些应用程序易于维护和扩展。

在做出架构决策时,你应该旨在创建一个快速、松散耦合、可测试的系统。你希望避免系统不同部分之间的直接依赖,这样当业务引入变化时,你就不需要重构整个应用程序。

在简要介绍了什么是可扩展的前端应用程序之后,让我们了解良好的前端架构的重要性。

为什么前端架构很重要?

良好的架构对于维护软件开发者的良好工作流程至关重要,并使得新团队和团队成员的加入变得容易。如果开发者花费大量时间重构或等待构建或测试完成,他们就会分心去做更不 productive 的事情。

在良好的架构支持下,即使应用规模扩大,代码库仍然可以管理。没有良好的架构,代码会变得杂乱无章,难以调试或扩展。随着时间的推移,这些问题会越积越多,开发者将花费更多的时间在调试和重构上,而不是创建新功能,尤其是在业务需求不断变化的大型企业解决方案中。不知不觉中,你将面临一个错综复杂的依赖关系网,添加简单的东西将变得非常耗时。

假设你正在开发一个包含日历组件的员工排班应用程序。你希望避免日历和排班应用程序之间的紧密耦合。当管理层告诉你公司正在添加另一个应用程序——比如说一个包含日历的项目管理工具——你不想因为两者紧密耦合而重新设计整个日历组件和排班应用程序。

下图显示了有和无架构的开发过程。没有架构时,你开始得快,但最终你会缓慢前行。有了架构,你将有一个一致且可预测的工作节奏:

图 1.1:开发速度

图 1.1:开发速度

现在你已经理解了我们所说的可扩展前端应用程序的含义以及为什么良好的架构对于实现它们至关重要,我们将深入了解一些扩展前端应用程序的方法,并了解它们的优缺点。

前端应用程序的扩展方法

当涉及到软件架构时,考虑你正在构建的内容、它能够发展到什么程度、所处的环境以及谁将负责它,这是至关重要的。根据这些参数,你将希望创建一个足够灵活的架构,以便在不过度设计的情况下增长和适应,避免使事情比必要的更复杂和耗时。

例如,如果你正在为一家小型家族企业构建一个简单的网站,你不需要复杂的架构和设计模式;这会使事情比必要的更复杂和耗时。网站的需求可能保持大致相同,代码库将保持小而易于管理。但是,当你构建由多个应用程序组成的商业软件时,这些应用程序的需求和效用将发生很大变化,你需要确保软件为此做好准备。

在本节中,你将了解不同的架构选择,以确保你不会过度或不足设计你的前端应用程序。你将了解在 Angular 应用程序中常用的一些仓库结构和架构模式。无需多言,让我们了解单仓库和多仓库之间的差异。

单仓库或多仓库

单仓库多仓库是两种在源代码管理应用程序(如 GitHub)中存储代码的选项。如果你为每个项目或应用程序创建一个新的仓库,它被称为多仓库或多仓库结构。同时,当所有应用程序都在一个大型仓库中时,它被称为单仓库结构。对于大型公司来说,单仓库可以轻松包含数百个应用程序。

在开发大型前端环境时,你应该选择哪个选项?让我们开始探讨这两种解决方案的优缺点。

单仓库的优势

单仓库的一些关键优势如下:

  • 易于代码共享:单一代码库使得在不同项目和团队之间共享代码变得容易。你可以在一个目录中拥有所有项目的代码,因此检查或重用另一个项目的代码,以及在库中共享事物变得简单。这有助于防止代码重复,为你解决类似问题提供灵感和指导,并允许你快速检查错误是否起源于你的代码或库的实现。

  • 不同应用程序之间的统一性:因为所有代码都存放在一个仓库中,所以在不同应用程序之间强制统一性更容易。你可以确保使用相同的工具,应用代码约定和最佳实践,并且所有应用程序都进行类似的测试。此外,使用单一代码库进行整个系统的测试也更容易。

  • 无版本冲突:在单一代码库中,依赖项通常在所有应用程序中共享相同的版本。这确保了不会发生版本冲突,并且所有应用程序中的实现都是一致的。此外,这也确保了那些没有积极开发的应用程序仍然会定期更新其依赖项。

  • 跨项目重构:如果你想要重构在许多应用程序中出现的某些内容,你可以一次性对所有应用程序进行重构,或者高效地运行脚本来执行这些操作。

  • 快速代码移动和调试:代码可以快速从一个项目移动到另一个项目,当你遇到库中的错误时,你可以立即修复错误,而无需中断工作,继续你的工作。

  • 共享提交时间线:最后,单一代码库有一个共享的提交时间线。这使得在多个应用程序中创建原子提交变得安全。这是因为如果出现问题,你总是可以通过提交时间线回滚到所有应用程序的共同状态。

无论这些优势如何,单一代码库也有一些缺点。我们将在下一节中详细探讨。

单一代码库的缺点

单一代码库的一些缺点如下:

  • 大型文件夹结构:单一代码库可能会让人望而生畏,因为所有代码和库都生活在一个大解决方案中。如果你还没有使用过单一代码库,这可能需要一些时间来适应。因为很容易使用其他项目的代码,所以你需要格外小心,不要在应用程序之间创建不必要的依赖关系。

  • 复杂的包更新:在单一代码库中更新包和依赖项可能很复杂,因为通常所有项目都需要同时更新。

  • 破坏性变更:因为没有不同的版本,当你对库进行更改时,你可能会在不注意的情况下破坏其他应用程序。

  • 构建时间慢:在单一代码库中,如果管理不当,构建和测试可能会变得耗时。

  • 部署挑战:与所有内容都按性质分离和模块化的多代码库相比,模块化部署应用程序可能更具挑战性。

大多数缺点都可以通过合适的工具来缓解。对于由 Angular 应用程序组成的单仓库,你可以使用 Nx,这样你就可以处理单仓库而无需这些缺点。在这本书中,我们将使用单仓库和 Nx。

现在,让我们继续讨论多仓库的优点和缺点。

多仓库的优点

多仓库的关键优点如下:

  • 应用程序之间的高隔离度:多仓库最明显的优点是应用程序之间的高隔离度。每个项目都有一个仓库,开发者可以在该仓库内做任何他们想做的事情,而不会过多地影响其他项目。

  • 依赖管理的灵活性:当使用多仓库时,每个项目可以独立管理其依赖项的版本。这为项目团队在更新依赖项时提供了更多的自由度,并提供了更多的稳定性。

  • 单个工具:在使用多仓库时,有更多的灵活性来使用不同的工具和编程语言。

  • 易于管理:多仓库通常更容易管理,特别是对于小型团队来说。与单仓库相比,每个仓库中的代码较少,因此你的更改影响的东西更少。

  • 模块化部署简单:模块化部署你的应用程序更加简单。

  • 与微前端架构一致:最后,如果你正在使用微前端架构进行开发,使用多仓库可能会感觉与你的整体架构更加协调。然而,微前端可以通过单仓库和多仓库结构来实现。

在你选择单仓库或多仓库结构之前,你还必须考虑多仓库的缺点。这些缺点将在下一节中描述。

多仓库的缺点

多仓库的一些常见缺点如下:

  • 代码共享的困难:首先,因为代码是独立存放的,所以应用程序之间共享代码变得更加困难。这可能导致对同一问题的不同实现或在不同项目中重复代码。团队通常会创建自己的解决方案,而不是为解决共享问题的库做出贡献。

  • 项目和库之间的边界:当你依赖另一个仓库中存放的库,并且遇到一个错误时,修复问题将更加耗时。通常,你需要等待其他团队修复库中的错误并部署新版本,你才能继续。

  • 测试多个仓库时的挑战:测试应用程序可能会变得更加具有挑战性。主要是在应用程序由位于不同仓库的不同模块组成时,测试整个系统可能会很困难。

  • 创建 CI/CD 管道和部署的困难:随着你需要多个仓库来完成任务,创建整个系统的 CI/CD 管道和部署可能会变得更加具有挑战性。

  • 依赖冲突:最后,你可能会遇到依赖冲突。不同的应用程序需要在生产环境中协同工作,并且可能依赖于相似的依赖项。当这些应用程序使用不同版本的依赖项时,可能会遇到兼容性问题。

在本节中,你了解了将代码存储在单仓库或聚合仓库中的优缺点。接下来,你将了解与 Angular 应用程序一起使用的不同架构模式。

Angular 应用的架构模式

架构模式关注于如何结构化你的代码,并提供将业务逻辑从特定实现中抽象出来的规则。架构模式是一个广泛的话题,甚至有整本书专门讨论它;因此,在这个部分中不可能涵盖所有内容。尽管如此,我们仍将简要介绍一些与 Angular 和 Nx 一起使用的最常见架构模式。在第五章中,我们将深入探讨设计模式,同时关注代码实现而不是提供系统的概述。

现在,让我们深入探讨一些架构模式,了解它们试图实现什么,如何实现,以及它们的优缺点。

Angular 应用中的常见架构模式

大多数架构模式在核心上试图实现相同的目标,只是有一些细微差别和不同的术语。关于 Angular 应用的架构模式,它们试图将领域和业务逻辑与实现和视图分离。这样做可以使系统松散耦合,易于测试、更改和扩展,而不会在错误的位置创建依赖项。在本节中,我们将介绍模型-视图-控制器MVC)、六边形架构和分层架构。这三种模式是 Angular 应用中最常用的架构模式之一。其他值得注意的模式包括模型-视图-视图模型MVVM)、联合架构和整洁架构。

不再拖延,让我们来了解 MVC 模式以及如何在 Angular 应用中使用它。

Angular 应用中的 MVC 模式

MVC是软件开发领域中应用最广泛的架构模式之一。该模式最初用于后端,但现在也用于前端应用程序,或者至少是类似于 MVC 模式的应用程序。MVC 模式常用于 Angular 应用程序,因为它与框架提供的工具很好地匹配。正如其名称所暗示的,MVC 由三个部分组成——模型视图控制器

  • 模型声明数据模型并处理业务和数据相关的逻辑。

  • 视图向用户显示模型的当前状态。

  • 控制器充当视图和模型之间的桥梁。控制器将用户输入传递给模型,以便模型可以执行操作并相应地更新,之后控制器将更新的值返回给视图。

如果我们将这种模式转换为 Angular 应用,模型将是一个处理数据和数据模型的服务。视图将是 HTML 模板。最后,控制器将是 HTML 文件背后的 TypeScript 文件,通常称为组件类。更好的实现是将组件类视为视图的一部分,并添加一个额外的外观服务(一个额外的抽象层,在视图和业务或状态相关的逻辑之间创建一个简单的接口和通信层;我们将在第五章中更详细地讨论外观模式),在模型服务和视图之间充当控制器。

如果你坚持 MVC 架构的原始实现,模型将直接将更新的值提供给视图。为了实现这一点,在图 1.2中,我们会消除步骤 4步骤 5,然后直接跳转到视图而不是控制器。在 Angular 中,当使用组件类作为控制器时,这种情况有点类似,这会导致业务逻辑和组件类之间的紧密耦合。正因为如此,我更喜欢添加一个单独的外观服务,充当控制器,以完全将组件类与我的应用程序的业务和状态层分离。通过将组件从状态和业务逻辑中分离出来,你最终得到的是松散耦合,这使得在整个应用程序中更改实现变得更加容易:

图 1.2:MVC 模式

图 1.2:MVC 模式

现在你已经了解了 MVC 模式的内容以及如何在你的 Angular 应用中实现它,让我们来学习下一个常见的架构模式:六边形架构。

Angular 应用中的六边形架构模式

与 MVC 和其他一些架构模式,如分层架构、MVVC 和 MVP 相比,六边形架构相对较新。六边形架构是在 2005 年提出的,而人们直到最近几年才开始在 Angular 应用中实施它。它之所以受到欢迎,是因为领域驱动开发DDD)成为了一个热门话题,六边形架构非常适合与 DDD 结合使用。六边形架构的主要原则是通过端口和适配器将核心应用程序逻辑与 UI 和数据实现分离。正因为如此,这种架构也通常被称为端口和适配器架构。但是,我听到你在想,端口和适配器是什么?

简而言之,端口是接口(或抽象类),它们将您的核心逻辑与 UI 和代码实现分开。这些接口规定了 UI 和代码实现如何与您的应用程序核心通信。适配器是通过端口连接到您的应用程序核心的 UI 和代码实现。在六边形架构中,端口和适配器有两种类型,UI 和数据相关端口和适配器——换句话说,主要和次要适配器和端口。这一概念在 图 1**.3 中得到了说明:

图 1.3:六边形架构模式

图 1.3:六边形架构模式

在实现六边形架构时,您将为应用程序中的每个领域都有一组端口和适配器。我喜欢在端口接口和适配器之间使用外观服务,以在 UI、实现和应用程序核心之间提供更多抽象。当使用外观服务时,外观可以被视为端口本身。只需确保外观实现了一个接口,这样就可以为与核心和适配器的通信定义一组固定的规则。

因为端口定义了与您的应用程序核心通信的固定规则集,所以当业务需求发生变化时,您可以轻松地更改实现。您可以在不触及业务逻辑或数据实现的情况下交换 UI 组件,并且可以在不触及视图或应用程序核心的情况下更改数据持久化或检索方式。您需要做的唯一事情是确保您的新实现可以连接到端口使用的相同接口,以便连接一切。这种方法提供了出色的灵活性和松散耦合的系统。

为了澄清问题,我想回顾一下 图 1**.3 并将其翻译成 Angular 应用。我们将从左到右进行。在最左边,我们有主要适配器。用户面对的或触发的所有内容都被视为主要适配器:组件、指令、解析器、守卫和事件监听器。向右一步,我们将找到主要端口。这些常规 TypeScript 接口(或外观服务)规定了 UI 层如何与应用程序核心通信。我们的应用程序核心位于中间,在那里我们访问状态管理并在 Angular 服务中定义业务和应用程序逻辑。在应用程序核心的右侧,我们有我们的次要端口。这些端口规定了应用程序代码如何与 HTTP 服务、状态管理、内存持久化、事件调度器和其他数据或 API 相关逻辑通信。与主要端口一样,次要端口也是常规 TypeScript 接口(或外观服务)。在最右边,我们有我们的次要适配器。次要适配器实现了我们的 HTTP 服务、本地存储持久化、状态管理和事件调度器。

现在你已经了解了六边形架构是什么以及你如何在 Angular 应用中实现它,让我们来看看我们将讨论的第三种和最后一种架构模式:分层架构。

Angular 应用中的分层架构模式

如其名所示,分层架构模式使用不同的层来分离关注点。对于应用的每个部分,你应在架构中创建一个层,该层位于另一个层之上。当你将分层架构模式应用于你的 Angular 应用时,你应该至少有三个(主要)层:核心层、抽象层和表示层。在这些顶级层中,你可以有额外的子层或同级元素。如果你的应用架构需要更多层,你可以根据需要添加。

分层架构最重要的地方是每个层只能与自身上下层的层通信;链中的其余层都是禁止的。另一个基本特点是事件和动作向上流动,数据向下流动。用户在表示层触发事件或执行操作。该层通知抽象层操作和相应的变化。抽象层将这些动作和变化发送到核心层,在那里执行业务逻辑并持久化数据变化。当核心层执行了应用逻辑并持久化了变化后,数据将从核心层通过抽象层流向表示层,在那里更新用户视图:

图 1.4:分层架构模式

图 1.4:分层架构模式

在本书中,我们将使用类似于图 1**.4所示的分层架构,其中表示层包含愚昧的、包装的和智能的组件。愚昧的组件只有组件输入来接收数据,以及输出以通知父组件有变化。包装组件也是愚昧的,但它们用于组合多个组件并提供可重用的布局或动画。尽管包装容器可以围绕愚昧的组件,但在数据流和依赖分离方面它们是相同的,这就是为什么它们在架构设计中并排放置。在愚昧的组件之上,我们有智能组件。这些组件通常是指特定的业务用例或页面,它们注入门面服务并实现组件逻辑和状态。

我们架构中的下一主要层是抽象层,其中包含门面服务。这些门面服务是常规的 Angular 服务,实现了门面设计模式。这些门面服务提供了额外的抽象,并作为我们智能组件和应用核心层之间的桥梁。

我们的最后一个大层是核心层,其中包含全局状态管理、业务和应用逻辑,以及 HTTP 服务。我们的 HTTP 服务层位于状态管理和业务逻辑层之上。我们有一些独立的服务,它们只做一件事,那就是获取数据并将其传递给我们的其他核心层;较低的核心层永远不会直接获取数据,因此我们有一个额外的抽象层,并且有更好的关注点分离。

现在你已经了解了分层、六边形和 MVC 架构模式,让我们继续,简要了解每种模式的优缺点。

比较架构模式

在我们讨论的Angular 应用中的常见架构模式部分中,所有三种模式都将业务逻辑与实现和展示代码分离。它们都提供了抽象层,但在创建这些层的方法上有所不同。首先,MVC 模式可能看起来最容易实现,但如果你不添加外观服务,你的组件类中可能会有太多的依赖和实现;特别是如果你让 MVC 的模型部分直接与视图通信。这不仅会紧密耦合你的视图与你的业务逻辑,还可能触发大量的 DOM 更新。更改实现可能会变得困难,单元测试也需要大量的模拟。

接下来,我们有六边形架构。我喜欢这种架构,但它引入了大量的样板代码,并且可能感觉实现起来很复杂,如果你团队中有许多初级开发者,这可能不是合适的选择。尽管如此,六边形架构的一个显著优点是,一旦一切设置完成,你可以轻松地更改实现。代码的单元测试也因为一切都被分离到端口和适配器而变得简单直接。

最后,我们有分层架构。这个架构结合了 MVC 和六边形架构的优点:我们为我们的核心、实现和视图提供了清晰的划分和良好的抽象。向你的架构中添加更多层很简单,规则也很容易理解,这使得它成为不同经验水平开发团队的优秀解决方案。由于良好的分离和抽象,你可以简单地更改实现,单元测试仍然很容易。

现在,你已经知道了 MVC、六边形和分层架构是什么,你也了解了每种实现的优缺点。在下一节中,你将简要了解什么是设计模式以及它们与架构模式的不同。

在 Angular 应用中使用的设计模式

虽然架构模式关注于我们对代码进行分割和抽象的高层次概述,但设计模式关注于我们在代码中如何实现事物。在开发 Angular 应用程序时,我们已经在使用一些内置的设计模式。这是因为 Angular 是一个具有强烈意见的框架,其核心具有强大的面向对象编程OOP)原则。Angular 应用程序中默认使用的某些设计模式包括观察者模式、依赖注入、装饰器、组件和单例模式。这些以及其他设计模式,如工厂和继承模式,都嵌入到 Angular 框架的工作方式中,并且在使用框架时应该在整个应用程序中使用。因为这些模式在 Angular 框架的工具和工作方式中有所隐藏,你可能在使用它们时实际上并不真正理解它们的核心工作原理。除了嵌入在 Angular 框架中的这些设计模式之外,你还可以通过引入更多的设计模式来改进你的代码。其中一些与 Angular 结合得非常好,例如外观模式。

与架构模式一样,设计模式确保你在实现代码时遵循特定的规则,从而产生易于调整和扩展的代码。它们防止你在代码中建立错误的依赖关系,并提供了一种结构化和经过实战检验的方式来解决软件工程中的常见问题。目前,我想简要地解释设计模式,并列出 Angular 框架默认使用的一些设计模式。在第五章中,你将更详细地了解设计模式。你将了解不同的模式、何时使用它们以及如何正确实现它们。

你现在已经知道了设计模式是什么,以及它们与架构模式的不同之处。你已经了解了一些在 Angular 中通过设计使用的设计模式,并且你可以添加更多模式来改进你的代码实现。接下来我们将讨论的是 Nx,它使得结构化、创建、维护和测试大型 Angular 单一代码库变得容易。

什么是 Nx 以及为什么你应该使用它?

在本节中,你将了解 Nx 是什么以及为什么它是开发大规模 Angular 应用的绝佳工具。Nx 正在迅速成为开发大型单一代码库前端应用的首选工具。那么,Nx 究竟是什么,为什么你应该使用它?

Nx 是一个帮助你加速、简化和标准化你的开发、测试、构建和部署过程的工具。Nx 工具提供了一系列你可以在开发每个阶段利用的功能和集成。Nx 的创建是为了让你可以通过选择和添加你想要使用或添加到当前环境中的内容来逐步采用它。在其核心,Nx 帮助你完成以下任务:

  • 加快您应用程序的构建和测试时间。

  • 在单一代码库项目中管理依赖关系和运行任务。

  • 快速搭建新的代码片段、应用程序和库,无需担心配置构建工具。

  • 将新工具集成到单一代码库工作空间的项目中。

  • 确保不同项目的代码内部的一致性和统一性。

  • 通过自动代码迁移来更新应用程序和工具。

在前面的任务中,多个工具、功能和选项,如命令行界面(CLI)、生成器和插件,帮助您实现目标并简化流程。Nx 提供的工具和功能在其生态系统中分为不同的模块。

首先,您有 Nx 的命令行界面CLI)。类似于 Angular CLI,它允许您运行创建工作空间、搭建项目、测试项目或提供和构建等任务的命令。接下来,Nx 包包含了 Nx 提供的所有基本技术:任务运行、工作空间分析、构建缓存、搭建和自动代码迁移。然后,还有插件,这些是扩展 Nx 基础的 NPM 包,可以由 Nx 社区为各种目的创建,例如生成项目、集成工具、添加或更新库。Nx 的另一个元素是其Devkit,它可以用来构建插件,以扩展 Nx 工具以满足您的特定需求。Nx 还有一种称为Nx 云的东西,它通过远程缓存和分布式任务执行加快 CI,但这超出了本书的范围。最后,我们有Nx 控制台,它是 VS Code、IntelliJ 和 VIM 的扩展,使得管理您的 Nx 工作空间和运行 Nx 命令变得更加容易。

既然您已经了解了 Nx 的核心功能,让我们更详细地考察它,看看它如何帮助您为 Angular 应用程序构建可扩展的单一代码库。

Nx 如何帮助您构建可扩展的 Angular 单一代码库。

现在您已经了解了 Nx 的核心功能,让我们深入探讨它,看看它是如何帮助您构建、测试和标准化您的 Angular 应用程序的。我们将从 Nx 提供的主要功能之一开始:加快构建、服务和测试等任务的执行速度。

通过计算缓存和增量构建来提高构建时间。

通常,当我们使用 Angular CLI 运行任务,如 ng buildng serve 时,我们的整个应用程序以及它所依赖的所有库都需要被编译以完成构建或提供服务。随着应用程序的增长,这可能会变得耗时。结果是 CI 构建缓慢,开发者在每次想要启动或测试应用程序时都需要等待应用程序编译。Nx 通过增量构建和计算缓存帮助解决这些问题。

使用计算缓存,Nx 将检查自上次您运行命令以来是否有任何更改。如果没有更改或构建计算等于之前的缓存运行,Nx 不会重新运行命令,而是从其缓存系统中获取结果。首先,它将查看本地缓存,如果您设置了 Nx 云的远程缓存,它也会检查是否可以在远程缓存中找到相同的计算哈希。如果 Nx 找不到相同的计算哈希,它将运行命令并将结果的哈希存储在 Nx 缓存中。

除了计算缓存外,Nx 通过增量构建帮助加快我们的构建和编译时间。在使用增量构建时,我们只构建自上次构建以来已更改的项目。在常规场景中,我们会构建应用程序及其所使用的所有库。随着应用程序的增长和依赖多个库,每次构建应用程序时重建所有内容都可能变得耗时且成本高昂。要使用增量构建,您的库必须是可构建的,这样 Nx 才能缓存库,并且只有在它们自上次构建以来已更改时才构建它们。当您构建较小的应用程序时,您可能不希望使用具有计算缓存的库,因为使库可构建也有一些开销。当您在 Nx 工作区中创建库时,您可以选择是否希望它为标准、可构建或可发布的。我们将在 结构化 Angular 应用程序和库 部分更深入地探讨这个话题。

使用 Nx 在 monorepo 中有效地运行任务

在单个 Angular 项目中使用 Angular CLI 运行任务,如 ng buildng testng lint,是直接的。但是,当您有一个包含数十、数百甚至数千个应用程序和库的 monorepo 时,事情会变得更加复杂。在许多场景中,您可能希望同时运行多个(或所有)项目的任务。有时,当项目中的某些内容发生变化时,您想运行特定的任务,或者您需要知道库中的更改是否影响了其他项目并导致它们崩溃。如果您通过逐个运行命令来执行这些任务,这会很快变得难以管理,并且构建用于监视和检查受影响项目的工具也会变得复杂。幸运的是,Nx 拥有我们运行多个或受影响项目任务、监视更改并对其做出响应所需的一切。

当您使用 Nx CLI 时,建议不要在 monorepo 中使用 Angular CLI。我们希望 Nx 做所有它的魔法,如果我们开始使用 Angular CLI 生成东西,这是不可能的。幸运的是,Nx 已经为您准备好了!

让我们从基础知识开始——使用 Nx CLI 运行单个项目的命令。为单个项目运行任务与使用 Angular CLI 运行任务类似。例如,如果我们想为名为 testApp 的应用程序运行测试,我们可以运行以下命令:

nx test testApp

如果我们想为多个项目运行任务,可以使用 run-many 关键字与 -p 标志结合来定义我们想要运行任务的那些项目。如果您省略 -p 标志并且仅使用 run-many 关键字,则任务将在所有项目中运行。我们还可以添加 -t 标志以同时运行多个任务。例如,如果我们想构建、检查和测试所有项目,我们可以运行以下命令:

nx run-many -t build lint test

现在,假设我们只想构建、检查和测试 testApptestApp2。为此,我们可以运行以下命令:

run-many -t build lint test -p testApp testApp2

如您所见,使用 Nx CLI 运行命令很简单,即使您需要在单一仓库中的多个或所有项目中执行此操作。即使您想为子集或所有项目同时运行多个任务,也可以快速且只需一个命令即可完成。

另一个有用的选项是监视特定项目的更改,并在监视的项目发生更改时运行脚本。例如,您可以监视应用程序的更改并回显项目名称和更改的文件名。这可以针对单个项目、项目子集或所有项目进行,就像运行常规命令一样。以下是一个此命令将如何显示的示例:

nx watch --projects=testApp,testApp2 --includeDependentProjects -- echo \$NX_PROJECT_NAME \$NX_FILE_CHANGES

最后,我们需要某种机制来检测受影响的项目并为它们运行命令。当单一仓库增长时,每次更改库时运行所有项目的测试变得耗时。考虑以下场景。我们有五个应用程序和三个库被这些应用程序使用。现在,如果我们更改库的代码,并且所有五个项目都使用这个库,我们的更改可能会影响并破坏我们未亲自工作的其他四个项目。如果我们没有注意到我们更改破坏了另一个应用程序就发布了应用程序或库,这可能会成为一个巨大的问题。当我们对库进行更改时,我们需要知道单一仓库中哪些项目会受到这些更改的影响,并运行适当的测试以查看一切是否仍然正常工作。为此场景,Nx 有 affected commands。通过运行这些命令,您可以运行受您更改影响的所有项目的任务,如检查和测试。以下是一个此类命令的示例:

nx affected -t test

有了这些,您就掌握了在 Nx 单一仓库中运行任务的基本知识。接下来,让我们探索 Nx 如何帮助我们保持单一仓库项目中代码和设置的标准化和统一。

确保 Nx 单一仓库中的统一性和一致性

可扩展的 Angular 单仓库的另一个关键方面是单仓库内不同应用程序的代码一致性和一致性。当你有数百个项目在你的单仓库中时,你不想每个项目都有不同的代码约定和实现。这将使开发者更难开始在其他项目中工作,并且也使得查找相似代码或批量重构代码变得更加困难。当每个人都使用相同的约定和代码模式时,每个开发者都可以在单仓库内的每个项目中工作,并在需要时进行更改。

Nx 通过为单仓库内的所有项目应用全局代码检查规则来帮助我们保持一致性。你可以根据自己的需求设置这些代码检查规则,甚至可以创建自定义的代码检查规则。除了全局代码检查规则之外,你还可以为单个项目应用特定的代码检查规则。

Nx 生成器是强制 Nx 单仓库内一致性的另一种极好方式。生成器用于生成应用程序、库、组件和其他代码片段。你甚至可以使用 Nx 生成器修改单仓库中的代码或设置和更改配置文件。当你需要对你的单仓库中的多个文件应用重构时,这可以非常有用。

使用生成器确保所有在单仓库内工作的开发者以相同的方式创建事物。可以通过终端执行生成器,尽管当使用 Nx 控制台时,你可以获得一个用于执行它们的良好用户界面。你还可以覆盖内置的生成器,以便显示更少的选项,并设置默认值。这减少了差异,使得生成器对经验较少的开发者更容易使用。当我们在 设置可扩展的 Angular 工作区 部分创建 Nx 单仓库时,我们将更深入地探讨这个话题。现在,重要的是要知道,在可能的情况下,你必须使用生成器。

我还想提到关于一致性的最后一个方面是 Prettier。Nx 内置了对代码格式化工具 Prettier 的支持。与代码检查规则一样,格式化规则可以配置为整个单仓库以及单仓库内的单个项目。你还可以使用 Nx CLI 运行命令来检测未格式化的代码行。当你配置 VS Code 在 保存 时自动格式化,Prettier 将在保存文件时格式化你的代码。

现在你已经学会了如何使用 Nx 管理单仓库,我们将探讨如何在 Nx 单仓库中更新和管理依赖。

Nx 依赖管理和代码迁移

在 Nx monorepo 中管理依赖项很容易,因为整个 monorepo 只有一个 package.json 文件(这只适用于集成 monorepo,但当我们创建 设置可扩展的 Angular 工作空间 部分中的 monorepo 时,我们将更详细地介绍这一点)并且大多数包可以自动更新,包括更新所需的配置和代码更改。可以通过使用 nx migrate 命令来运行自动化更新,如下所示:

nx migrate [packageAndVersion]

通常情况下,您将更新到 Nx 包的最新版本及其依赖项;在这种情况下,您可以使用以下命令:

nx migrate latest

运行前面的命令将更新您的 package.json 文件并生成一个 migrations.json 文件,其中包含 Nx 和其插件需要运行以成功更新所有包的所有迁移。要执行这些迁移,您需要运行带有额外标志的 migrate 命令,如下所示:

nx migrate --run-migrations

运行此命令后,您的包、配置文件和代码将更新为使用 Nx 和工作空间内配置的插件的最新版本。对于没有插件的包,您需要像处理 NPM 包一样手动进行更新。具有插件的包将使用 Nx 迁移为您完成所有工作。Nx monorepo 中有 Angular、Jest、Cypress 以及您将使用的大多数其他插件的插件。

另一个用于管理依赖项的酷特性是 Nx 允许您在图上可视化依赖项。通过这种方式,您可以查看哪些项目相互依赖,并放大这些节点以更好地查看依赖项。这也适用于项目和任务。如果您有运行多个任务的链式任务,您还可以在漂亮的图上可视化此任务链。Nx 图是一个非常有价值的工具,因为可视化有助于您更好地理解您的 monorepo 结构。如果您无法理解图中的依赖项,那么它们在您的代码中可能也无法理解。您可以通过运行以下命令打开图:

nx graph

如果您想查看受影响的项目图,可以运行以下命令:

nx affected:graph

最后,您可以使用标签在您的 monorepo 中设置边界,以便项目只能导入它们应该导入的内容。如果您尝试导入不应使用的库,代码将无法编译,并将显示错误信息,告知您不允许在项目中使用该库。当我们创建 设置可扩展的 Angular 工作空间 部分中的 monorepo 时,我们也将设置这些边界。

如前所述,Nx 是一个庞大的主题,为我们提供了许多用于管理 monorepo 的工具。随着我们通过这本书的进展,我们将学习更多关于 Nx 的知识,但到目前为止,您已经对 Nx 是什么以及它如何帮助您管理任何大小的 Angular monorepo 有了一个很好的了解。现在,我们将继续创建一个 Nx monorepo,包括一些 Angular 项目和库。

设置可扩展的 Angular 工作空间

在本节最后,我们将创建一个 Nx monorepo,以及一些用于演示目的的占位符 Angular 应用程序和库。我们将首先在 VS Code 中添加一些扩展,以使我们的开发者体验更加愉快。

添加以下扩展:

  • Angular Essentials(版本 16):Angular Essentials 包含八个不同的扩展,这些扩展对 Angular 开发很有用

  • Angular 支持

  • Nx 控制台

安装这些扩展后,你可能需要重新加载 VS Code 以应用它们,因此请重启您的 VS Code 应用程序。之后,您应该在 VS Code 左侧看到 Nx 图标;这是 Nx 控制台。当您成功安装了各种 VS Code 扩展后,您必须全局安装 NxCypress NPM 包。为此,您可以在您选择的终端中运行以下命令。我喜欢使用集成的 VS Code 终端:

npm i -g nx cypress

安装这些 NPM 包后,我们可以创建我们的 Nx 工作区。找到您想创建 Nx monorepo 的文件夹,并在该位置打开终端。要创建 Nx 工作区,请运行以下命令:

npx create-nx-workspace

当您运行此命令时,您将收到几个问题:

  1. 您想在何处创建工作区?(business-tools-monorepo。)

  2. 您想使用哪个堆栈?(Angular:使用现代工具配置 Angular 应用程序。)

  3. 独立项目或集成 monorepo?(集成 monorepo:Nx 创建一个包含多个项目的 monorepo。)

  4. 应用程序名称(Invoicing)。

  5. 您想使用哪个打包器?(esbuild。)

  6. 默认样式表格式(SASS(.scss))。

  7. 您想启用 服务器端渲染SSR)和 静态站点生成SSG)吗?(否。)

  8. 用于端到端测试的测试运行器(Cypress)。

  9. 设置具有缓存、分发和测试去抖动的 CI(跳过)。

  10. 您想使用远程缓存以加快构建速度吗?(是。)

回答这些问题后,Nx monorepo 将在名为 business-tools-monorepo 的文件夹中创建。我们将在 Nx 在我们的新工作区中创建了什么? 这一部分深入了解 Nx 为我们创建的内容,但首先,我想解释您可以使用 Nx 创建的不同类型的工作区。

Nx 工作区以独立项目、基于包的 monorepos 和集成 monorepos 的形式提供:

  • 独立项目非常适合您想从一个单一项目开始但保持扩展到大型 monorepo 的可能性时。Nx 对单一项目也很有用,因为您可以使用它们的生成器和通过 Nx 迁移和执行程序自动更新。

  • 接下来,我们有基于包的 monorepos。当您需要向 Nx 工作区添加大量令人兴奋的项目时,此解决方案很有益。基于包的仓库中,Nx 工作区中的每个项目都有自己的依赖项。您将从 Nx 获得改进的速度和任务运行,但 Nx 不会妨碍您。

  • 最后,还有集成的 Nx 单一代码仓库。这是我们将会使用,并且是利用 Nx 提供的所有工具的设置。

现在,让我们看看 Nx 在生成单一代码仓库时创建了什么。

Nx 在我们的新工作区中创建了什么?

当你在 V SCode 中打开 business-tools-monorepo 文件夹时,你将在单一代码仓库的根目录下看到五个文件夹和一些文件。我们将回顾必要的文件并解释文件夹,但首先,让我们简要概述 Nx 在生成单一代码仓库时为我们做了什么。

Nx 创建了我们的单一代码仓库,并为其构建 Angular 应用程序进行了配置。它安装了 Nx Angular 插件以及开发 Angular 应用程序所需的所有基本 NPM 包。除此之外,Nx 还为计算缓存、ESLint、Cypress e2e 测试、使用 Jest 的单元测试和 Prettier 代码格式化进行了配置。Nx 还创建了我们的第一个 Angular 应用程序,apps 文件夹。apps 文件夹将包含我们在 Nx 单一代码仓库中创建的所有应用程序。如果你打开 apps 文件夹,你会注意到 Nx 还创建了一个 invoicing-e2e 项目。这是一个用于端到端测试我们的 Angular 发票应用程序的 Cypress 项目。所有这些都将直接可用。Nx 为我们进行了所有配置。在文件夹结构中,我们将看到一个名为 apps 的文件夹。

现在,让我们看看 Nx 在我们的工作区中创建的一些重要文件:

  • nx.json: 这用于配置 Nx CLI 及其默认配置

  • .eslintrc.json: 这个文件可以在你的单一代码仓库的根目录下找到,以及在每个特定项目中。根目录下的文件包含应用于我们单一代码仓库中所有项目的全局 ESLint 规则。特定项目中的文件包含特定项目的 ESLint 规则。

  • project.json: 这个文件可以在我们的 Angular 应用程序的根目录下找到(apps\invoicing\project.json)。在一个常规的 Angular 应用程序中,这将是我们自己的 angular.json 文件。它们具有相同的内容和用法。project.json 文件将在我们的 Nx 单一代码仓库中的每个项目中创建。

  • tsconfig.base.json: 这用于全局 TypeScript 设置和为库导入设置别名。

我们将在本书中学习更多关于这些文件以及如何编辑它们的知识,当我们开始向我们的 Nx 单一代码仓库添加项目、库和插件时。

提升我们的 Nx 工作区以实现更好的 Angular 开发

我们已经有一个很好的 Nx 工作空间,它将很好地扩展。您可以在整个工作空间中统一创建 Angular 应用程序、库和其他类。对于您创建的每个应用程序或库,Jest 单元测试已配置,并且对于每个应用程序,您将获得一个 Cypress 项目,其中包含运行端到端测试所需的一切。ESLint 和 Prettier 已配置在整个 monorepo 中,以保持不同项目之间的代码一致性。monorepo 使用 Nx 迁移进行自动化更新;您可以使用 Nx 图形可视化您的依赖项和任务,并有效地使用 Nx 任务运行器对您的 monorepo 运行命令。我喜欢采取的第一步是在 apps 文件夹旁边创建一个 libs 文件夹。在这个 libs 文件夹内部,我们将存储所有我们的库。为了进一步提高,我们需要添加一些额外的 ESlint 规则,创建符合我们需求的自定义生成器,并使用标签设置项目边界。

添加 ESlint 规则以改进我们的 Angular 代码

我们可以使用 lint 规则来保持我们的代码一致性,并确保开发者不会以未预期的方式实现代码。Linting 可以提前捕获错误并强制执行最佳实践。Nx 已经为我们的 monorepo 添加了 Angular 特定的 lint 规则,使用了两个 @angular-eslint 包。为了使我们的 monorepo 更健壮,我们希望添加一些针对 Angular 和 RxJS 定制的额外 ESlint 规则。我们将添加的 lint 规则不是必需的,但建议在设置 monorepo 时使用它们或添加规则以符合您的偏好。这很重要,因为这样所有参与您代码的人都将遵循相同的实现和代码编写风格指南规则。每个公司和代码库都有自己的约定,所以请确保您有 lint 规则来强制执行它们。如果您找不到强制执行您代码约定的 lint 规则,Nx 也允许您添加自定义 lint 规则。

首先,我们将添加一些 NPM 包作为开发依赖项:

npm i --save-dev eslint-plugin-deprecation eslint-plugin-rxjs eslint-plugin-rxjs-angular

当命令运行完成后,这些包将被添加到 Nx monorepo 根目录下的 package.json 文件中,并且属于这些包的 lint 规则可以添加到 .eslintrc.json 文件中。我们只会在 monorepo 根目录的 .eslintrc.json 文件中添加规则。这些 lint 规则将应用于 monorepo 中的所有项目。我们还需要将 parserOptions 添加到 .eslintrc.json 文件中,因为我们正在使用 TypeScript 和基于类型的 lint 规则。您可以在本书的 GitHub 仓库中找到更新后的 .eslintrc.json 文件(技术要求部分中的链接)。

一旦您添加了额外的 lint 规则和 parserOptions,我们就可以继续创建一些自定义 Nx 生成器。

为我们的 Angular monorepo 创建自定义 Nx 生成器

生成器是执行统一任务的优秀工具,因为使用生成器的每个人都会得到相同的结果。首先,我们将专注于覆盖内置的 Angular 生成器。在整个书中,我们将创建和使用生成器来重构我们的单仓库中的代码以及创建自定义代码片段。

在本节中,我们将覆盖创建 Angular 库的生成器。我们将显著减少使用生成器时可以输入的选项,添加自定义选项以强制目录结构,并添加项目边界标签。选项越少,就越符合你组织使用的约定,从而实现更多的一致性和减少经验较少的开发者的问题。

让我们从使用 Nx 生成器创建一个 Nx 插件开始:

  1. 要使用插件生成器,通过运行以下命令安装 NPM 包:

    npm i @nx/plugin
    
  2. 现在,在 VS Code 的左侧,点击 Nx 图标以打开 Nx 控制台。

  3. 进入生成 & 运行目标选项卡,点击生成。这将在 VS Code 顶部打开一个带有搜索栏的下拉菜单。

  4. 在这个搜索栏中,输入plugin并选择@nx/plugin - plugin Create a Nx plugin选项。这将在 VS Code 中打开一个新窗口,你可以在这个窗口中生成你的插件。

  5. 默认情况下,生成器会要求你填写两个字段,一个名称和一个导入路径,以及一个目录,其中名称是必需的。

  6. 将你的插件命名为workspace-generators-plugin。对于导入路径,输入@business-tools/workspace-generators-plugin,对于目录,输入libs

  7. 点击显示 所有选项

  8. projectNameAndRootFormat下,选择派生

  9. 然后,点击右上角的生成按钮。

这将在你的单仓库根目录下的libs文件夹中生成你的插件。

接下来,我们将创建我们的自定义生成器:

  1. 首先,在 Nx 控制台中再次点击生成

  2. 在搜索栏中,输入generator并选择@nx/plugin - generator

  3. 现在,在新打开的窗口中,给生成器起一个名字,例如generate-angular-library

  4. 对于libs\workspace-generators-plugin

  5. 点击显示 所有选项

  6. projectNameAndRootFormat下,选择派生

  7. 然后,再次点击右上角的生成按钮。

当此过程完成后,你将在workspace-generators-pluginsrc文件夹中找到一个generators文件夹。在这个generators文件夹中,你会找到你的自定义生成器,其名称为generate-angular-library。在你的自定义生成器中有一系列文件,但在我们开始探索它们之前,让我们看看我们将用这个自定义生成器覆盖的内容:

  1. 返回你的 Nx 控制台并再次点击生成

  2. 这次,搜索library并选择选项 @nx/angular - library

  3. 当你检查这个库的新生成器窗口时,你会找到七个选项来填写;如果你点击显示所有选项,你将有 30 个选项可以填写。并不是所有开发者都知道在这里选择什么,如果我们把它留给开发者,那么在我们创建 monorepo 内的库时,我们会得到太多的变化。现在,让我们关闭生成 Angular 库的窗口,并开始用我们的自定义生成器来覆盖它。

当我们覆盖一个现有的生成器时,我们对三个不同的文件感兴趣:generator.tsschema.jsonschema.d.ts。我们将在generator.ts文件中编写我们的逻辑;schema.json文件将包含 Nx 控制台窗口的信息,而schema.d.ts将包含generator.ts文件中我们选项的接口。让我们分别检查这三个文件,从generator.ts文件开始。

Nx 已经在这个generator.ts文件中为我们生成了一堆代码,但我们将不会担心所有这些,而是用以下一小段代码来替换它:

import { Tree } from '@nx/devkit';
import { libraryGenerator } from '@nx/angular/generators';
import { GenerateAngularLibraryGeneratorSchema } from './schema';
export async function generateAngularLibraryGenerator(
  tree: Tree,
  options: GenerateAngularLibraryGeneratorSchema
) {
  await libraryGenerator(tree, options);
}
export default generateAngularLibraryGenerator;

我们清除了generateAngularLibraryGenerator函数中的所有内容,并用@nx/angular/generators中的libraryGenerator方法来替换它。这个libraryGenerator方法是我们刚才检查过的内置 Angular 库生成器。当你想要覆盖内置生成器时,你可以在你的node_modules文件夹或相应包的 GitHub 仓库中找到它们。现在,如果我们构建并运行我们的自定义生成器,我们只能输入一个名称,因为这是我们schema.json文件中唯一有的东西,所以我们的自定义生成器会使用默认设置为我们生成一个 Angular 库。因此,我们从填写 30 个选项减少到了只填写一个名称。然而,我们想要添加额外的选项,并为一些内置选项选择特定的值。我们可以通过编辑libraryGenerator方法来实现,如下所示:

await libraryGenerator(tree, {
  name: options.name,
  simpleName: true,
  standalone: true,
  buildable: true,
  prefix: `bt-libs-${options.type}`,
  style: ‹scss›,
  changeDetection: ‹OnPush›,
  directory: `libs/${options.domain}/${options.type}`,
  tags: `domain:${options.domain}, type:${options.type}`,
  importPath: `@bt-libs/${options.domain}/${options.type}/${options.name}`,
});

我们生成的库现在将是可构建的,使用独立组件,具有 on-push 变更检测,使用 SCSS 样式,有标签,有导入路径,并且有配置好的目录结构。我们在options对象中使用名称、域和类型属性。名称默认配置,所以让我们在schema.jsonschema.d.ts文件中添加我们想要公开的选项。

schema.d.ts文件中,我们将找到用于generator.ts文件中使用的options对象的接口。这个接口默认声明了一个name属性。我们希望向接口添加一个域和类型,并且只允许预设的字符串值用于这些属性。我们可以通过如下编辑接口来实现:

export interface GenerateAngularLibraryGeneratorSchema {
  name: string;
  domain: 'finance' | 'hr' | 'marketing' | 'inventory' | 'shared' ;
  type: 'ui' | 'data-access' | 'feature' | 'util' | 'all' ;
}

最后,我们想要更新我们的schema.json文件。这个schema.json文件定义了我们在使用生成器时可以在 Nx 控制台或终端中填写的值。当我们生成自定义生成器时,Nx 为我们添加了一个名称属性,因此我们必须添加域和类型。我们可以在schema.json文件内的properties对象中添加额外的对象来实现这一点。要包含域,你可以添加以下代码:

"domain": {
  "type": "string",
  "description": "Domain of the library",
  "$default": {
    "$source": "argv",
    "index": 1
  },
  "x-prompt": {
    "message": "What domain would you like to use?",
    "type": "list",
    "items": ["finance", "hr", "marketing", "inventory", "shared"]
  }
}

对于type字段,你可以在domain对象下添加一个类似的对象。index属性需要比type对象高一个单位,因为这个索引表示当我们使用生成器时,该字段将在 Nx 控制台中显示的位置。

现在,如果我们构建并运行生成器,我们将有三个字段需要填写。当我们填写这些字段时,一个包含我们添加的所有配置的库将为我们生成。

但我们还可以进一步提高生成器的性能。我们可以通过在类型中添加一个'all'选项来实现这一点。当选择'all'时,我们将一次性为每个类型生成一个库,并且我们可以添加清理逻辑来移除生成器附带的基本组件——在大多数情况下,我们不希望组件的名称与库同名。为了实现这些改进,我们需要将libraryGenerator方法提取到generateAngularLibraryGenerator函数下方的独立函数中:

async function generateLibrary(
  tree: Tree,
  options: GenerateAngularLibraryGeneratorSchema,
  type: string
) {
  await libraryGenerator(tree, {
    name: options.name,
    simpleName: true,
    standalone: true,
    buildable: true,
    prefix: `bt-libs-${type}`,
    style: ‹scss›,
    changeDetection: ‹OnPush›,
    directory: `${options.domain}/${type}`,
    tags: `domain:${options.domain}, type:${type}`,
    importPath: `@bt-libs/${options.domain}/${type}/${options.name}`,
  });
}

接下来,我们需要在generateAngularLibraryGenerator函数上方添加一个包含我们类型的数组,如下所示:

const TYPES = ['ui', 'data-access', 'feature', 'util'];
export async function generateAngularLibraryGenerator(
  tree: Tree,
  options: GenerateAngularLibraryGeneratorSchema
) {
  ………
}

现在,在generateAngularLibraryGenerator函数内部,我们将用for循环替换libraryGenerator。这将当options.type等于'all'时,为每个可用类型添加一个库:

  if (options.type === ‹all›) {
    for (const type of TYPES) {
      await generateLibrary(tree, options, type);
    }
  } else {
    await generateLibrary(tree, options, options.type);
  }

现在唯一需要做的事情就是添加一些逻辑来清理初始组件文件,并更新index.ts文件以移除该初始组件的导出。否则,如果我们创建一个名为common-components的库,例如,它将创建一个名为common-components-component的组件,而这并不是我们想要的。

为了清理我们的库,我们将使用由generateAngularLibraryGenerator函数暴露给我们的tree参数。这个tree对象包含我们的 monorepo 树,其中包含所有我们的文件夹和文件。为了在生成库时移除初始组件文件并更新index.ts文件,我们可以在generateAngularLibraryGenerator函数的底部添加以下代码:

  const path = `libs/${options.domain}/${options.type}/${options.name}/src`;
  tree.delete(`${path}/lib/${options.name}`);
  tree.write(`${path}/index.ts`, ‹›);

一旦你更新并保存了所有内容,你就可以测试生成器。

  1. 在 VS Code 中打开 Nx 控制台。在顶部,你会找到一个名为projects的部分。

  2. 在这个部分下方,你会找到你的workspace-generators-plugin项目。如果你展开它,你会看到三个选项:构建lint测试

  3. 当你悬停在构建选项上时,你会看到一个播放按钮。请点击这个按钮来构建workspace-generators-plugin项目。

  4. 当插件构建成功后,重启 VS Code,并在您的 Nx 控制台中点击generate。您应该看到@business-tools/workspace-generators-plugin - generate-angular-library 生成器

您只需在添加新生成器或调整schema.json时重启 VS Code,因为 Nx 图示在您打开 VS Code 时加载。

当我们使用生成器时,有三个字段可以输入名称、域和类型。让我们创建一个名为common-components的库,然后选择libs\shared\ui\common-components路径。

我们还将覆盖组件生成器。您可以尝试自己进行此操作。如果您仍然觉得有挑战性,您可以访问本书的 GitHub 仓库并从中获取代码(技术要求部分中的链接)。如果您自己进行了覆盖,请检查您是否与我们一样实现了它,以便您可以在整本书中使用相同的生成器。

这样,您就知道了如何覆盖生成器,并已创建了您的第一个自定义生成器来构建 Angular 库。接下来,我们将为 Nx 单仓库创建项目边界。

设置 Angular 单仓库的项目边界

当您在一个大型的单仓库中工作,其中不同的团队在单独的项目上工作时,设置一些边界是至关重要的。如果您有一个仅针对单仓库一个域的库,您不希望有人通过将此库导入另一个域来创建意外的依赖关系。

例如,如果您有一个针对金融相关应用的库,您不希望它被导入到营销应用中并创建我们金融和营销域之间的依赖关系。更糟糕的是,如果有人在这两个应用之间建立了直接依赖关系。如果我们这样做,我们不得不构建和部署两个应用,而我们只想更新和部署其中一个。

使用 Nx,我们可以通过project.json文件设置边界。边界定义在.eslintrc.json文件中。我们将在根目录的.eslintrc.json文件中仅创建全局边界。在这个.eslintrc.json文件中,您将找到以下 lint 规则:

        "@nx/enforce-module-boundaries"
          "error",
          {
            "enforceBuildableLibDependency": true,
            "depConstraints": [
              {
                "sourceTag": "*",
                "onlyDependOnLibsWithTags": ["*"]
              }
            ]
          }

您可以更新depConstraints数组来更新您的 Nx 单仓库的边界。默认情况下,该数组内的对象使用*作为通配符,允许任何项目导入其他所有项目。我建议移除该对象并设置严格的项目边界。例如,如果您只想让金融领域的项目导入具有相同领域的其他项目,您可以将此对象添加到depConstraints数组中:

{
 "sourceTag": "domain:finance",
 "onlyDependOnLibsWithTags": ["domain:finance"]
}

sourceTag组件定义了我们正在针对的标签,而onlyDependOnLibsWithTags定义了允许导入的标签。如果您想查看我为单仓库配置的约束,您可以从本书的 GitHub 仓库中获取它们,但您可以自由地以您喜欢的任何方式设置约束。

在配置了边界之后,如果你尝试导入不允许导入的内容,你将在 VS Code 中遇到 lint 错误。在初始设置之后,你可能需要重启 VS Code,以便边界生效而不运行 linter。

现在我们已经改进了 Nx 单仓库,并且它准备好托管许多 Angular 应用和库,让我们通过讨论我们单仓库中 Angular 应用和库的良好结构来结束讨论。

结构化 Angular 应用和库

我们必须讨论的最后一件事是如何在 Nx 单仓库中结构化我们的 Angular 应用和库。将你的应用视为容器,它们使用库中的组件和逻辑构建页面,这是一种好的做法。这激励了模块化方法,并使你更容易作为一个专门的库项目来有一个良好的关注点分离,因为专门的库项目比在你的应用中使用文件夹来分离代码有更大的边界。一般来说,你可以使用 80/20 规则,其中 80%的代码生活在专门的库项目中,而 20%生活在你的应用项目中。

这些库不必与使用它们的工程分开构建。如果是这样,关于你的部署过程,一切都会保持不变,但你不会利用 Nx 增量构建和计算缓存。如果你想使用增量构建或将你的库发布到外部注册表,如 NPM,你可以在生成它们时将它们标记为可构建或可发布的。在我们的自定义生成器中,我们默认使库可构建。对于小型应用,你可以考虑使用常规库,除非你想发布它们。

将代码放入库中并不一定意味着代码必须是通用目的的,并且必须被多个实体使用;将代码放入 Nx 库中可能是纯粹为了组织目标。这促使你以更 API 驱动的方式思考你的代码,通常会导致更干净的实现和更少的依赖,反过来,这可能会导致可重用的代码,但这不是必须的。在组织库和应用时,你应该考虑不同的业务领域。一般来说,组织的团队与这些业务领域是一致的;因此,在你的单仓库项目中有一个类似的组织结构是有意义的。

你是创建一个新的库还是重用现有的一个?

创建新应用的决策相当直接。如果是一个可以独立使用或销售的 UI 产品,那么它可能应该是一个应用。对于库来说,有时很难定义何时创建一个新的库,何时向现有的库添加代码。与大多数编程决策一样,是否开始或重用库是关于权衡的。

创建新库并将代码拆分的主要好处是,构建和测试等任务将更快完成,您可以通过 Nx 图更好地可视化您的架构,并且通过使用标签,您对项目边界的控制也更多。重用库的优点是,您可以在没有任何约束的情况下更好地将相关代码分组在一起,因此更容易实验且更不容易出错。特别是当您的代码库快速演变时,暂时将所有内容保留在一个库中可能更容易。一个好的做法是在开发速度放缓时将事物拆分成多个库。

库通常分为 UI、功能、实用和数据处理库。您的 UI 库应仅包含无脑的展示组件。功能库包含可以访问您外观服务的智能组件,并且是为业务案例或应用程序内的页面而创建的。数据处理库包含状态管理逻辑,并提供与后端 API 通信所需的一切。最后,我们有实用库,它托管辅助函数和其他有用的低级实用工具。

所有库都应按其相应的应用程序或包含多个应用程序的业务域分组。对于大型公司,库可以根据每个业务域的范围定义来按特定业务域的各个部分分组。当您想要将应用程序或库移动到 Nx 单一代码库中的新文件夹时,您需要通过运行以下命令使用 Nx 的 move 生成器:

nx g move --project some-library target/folder/path

如果您想删除应用程序或库,应使用 Nx 的 remove 生成器,如下所示:

nx g remove some-project-name

对于移动和删除项目,您可以使用 Nx 控制台以更直观的方式进行。使用 Nx 控制台或在终端中运行相应的 Nx 命令执行这些操作非常重要,因为它们将自动更新 Nx 单一代码库中的所有配置文件。

图 1**.5 展示了一个建议的文件夹结构,尽管这完全取决于您以及您所构建的结构和需求:

图 1.5:文件夹结构

图 1.5:文件夹结构

在进入下一章之前,让我们清理我们的单一代码库并添加一些占位符项目。首先,删除我们之前创建的发票应用程序及其相应的端到端项目。您可以通过右键单击项目并选择“invoicing-e2e项目”来完成此操作;然后,删除“invoicing项目”。一旦删除了这两个项目,我们就可以使用我们从本书 GitHub 仓库中获取的自定义应用程序生成器创建一些占位符应用程序。

创建以下项目:

  • expenses-registration(在 finance 域下)

  • social-media-dashboard(在 marketing 域下)

我们创建的 placeholder 库可以保留,因为它已经配备了标签和域名,并且是用自定义生成器创建的。随着我们的继续,我们将创建额外的库,并开始添加一些代码。

摘要

在本章中,您创建了一个包含一些占位符项目的 Nx 单一代码仓库,并且现在可以扩展到数百个应用程序。您学习了如何在单一代码仓库中结构化您的 Angular 应用程序和库,并且现在知道如何利用 Nx 的基本功能。

在下一章中,我们将探索 Angular 框架中一些最新和最强大的功能。

第二章:强大的 Angular 功能

Angular 为构建健壮的 Web 应用程序提供了所需的所有内置工具。在本章中,你将了解 Angular 框架中最新和最强大的功能。我们还将深入了解 Angular 组件通信、路由器,以及 Angular 框架中可能最重要和最强大的部分:依赖注入。到本章结束时,你将了解如何注入、消费、提供和调整依赖项的层次结构,如何组件间通信,以及如何有效地使用 Angular 路由器。

本章将涵盖以下主要主题:

  • Angular 为什么如此强大?

  • Angular 框架中的新功能

  • 深入了解 Angular 路由器

  • 组件通信

  • 依赖注入

Angular 为什么如此强大?

Angular 与其他流行的前端框架和库(如 ReactJS 和 VueJS)不同,因为它具有强烈的意见,并且框架本身包含了开发复杂 Web 应用程序所需的一切。

有效使用 Angular 确保了一定程度的一致性,并实施了最佳实践。这是因为 Angular 为你做了许多决定,例如使用 TypeScript,并依赖于 面向对象编程OOP)原则和内置工具来处理诸如路由、HTTP 请求和测试等常见问题!由于 Angular 内置了所有功能,你不需要引入大量的外部包,从而减少了潜在漏洞或停止维护的包的表面。这些方面通常使得 Angular 成为构建复杂前端系统或由多个应用程序组成的企业的首选框架。

Angular 内置了构建 Web 应用程序所需的所有强大和有用的功能。如果你正在阅读这本书,你应该已经熟悉了主要功能,但我们仍将提及对框架最重要的那些:

  • 组件和服务:组件和服务是 Angular 应用程序的基本构建块。组件用于开发可重用的 UI 元素和由这些 UI 元素组成的页面。Angular 服务通过依赖注入注入到你的应用程序中,并与后端 API 通信,处理状态管理,提供数据,并实现业务逻辑。

  • 依赖注入:Angular 依赖注入是框架的基本概念之一,通常被认为是最强大的功能。依赖注入允许你在应用程序中注入值和逻辑。

  • 信号:信号是 Angular 框架中的一个新概念,用于定义有状态的属性。Angular 跟踪信号值的使用位置和方法,以优化变更检测,从而提高性能。信号也是响应式的,允许你在信号值变化时自动做出反应。

  • HTTP 客户端: 内置的 HTTP 客户端提供了一个优雅直观的界面,用于与 API 通信和获取数据。凭借内置的功能,如请求和响应拦截器、错误处理和基于观察器的响应,Angular HTTP 客户端提供了处理 HTTP 逻辑所需的一切。

  • component类及其对应的模板,便于在不进行手动干预的情况下更新数据。在 Angular 框架中,数据绑定可以通过三种方式完成:使用方括号符号从组件类绑定到视图,使用事件和圆括号符号从视图绑定到组件类,以及使用方括号和圆括号符号进行双向数据绑定,也称为“盒子里的香蕉”。

  • 路由器: Angular Router 简化了单页应用程序的创建,并具有动态路由功能。它允许开发者定义路由并将它们与特定的组件关联起来,从而实现应用程序内不同视图和页面之间的无缝导航。

  • 指令: Angular 指令是框架的基石,允许您通过添加额外的功能和行为或添加和删除 DOM 元素来扩展 HTML 元素。

  • 管道: 管道用于在 HTML 模板中转换和格式化数据。使用管道有助于保持模板的整洁和简洁,同时避免在组件代码中过度使用逻辑。

  • 表单: 表单是每个 Web 应用的核心。Angular 表单有两种类型:模板驱动表单响应式表单。凭借验证、错误处理和数据同步等功能,表单有助于您开发健壮的应用程序。

这些功能只是框架提供的最强大功能中的一部分。让我们继续前进,探索 Angular 团队在最新版本中做了哪些更改。框架正在迅速变化,新的概念和工具正在被引入,以使 Angular 更加强大和面向未来。

Angular 框架的新功能

网络开发的世界正在迅速发展,因此,像 Angular 这样的框架必须不断成长以保持相关性。在本节中,我们将探讨 Angular 框架中的新功能以及为什么进行这些更改。

独立组件

在 Angular 14 版本中,独立组件作为开发者预览被引入;15 版本将其发布为生产使用。Angular 团队引入独立组件是为了简化我们构建 Angular 应用程序的方式。在独立组件之前,所有内容都必须在 ngModules 中声明。许多开发者不喜欢 ngModules,并且与 ngModules 相关的错误可能难以解决。

使用独立组件,您可以在不使用 ngModules 的情况下构建应用程序。组件、指令和管道可以被标记为独立,然后它们不需要在 ngModule 中声明。

第一章中,我们为库和应用程序创建了 Nx 生成器,并为两者应用了独立标志。因此,当我们生成 Angular 应用程序时,它们是以独立组件创建的。让我们看看您expenses-registration应用程序中的app.component.ts文件中的装饰器:

@Component({
  standalone: true,
  imports: [RouterModule], ………
})

如果您将此与非独立组件进行比较,您可能会注意到两个不同寻常的地方:standalone标志设置为true,组件变为独立。当某物是独立的,它必须直接在imports数组中导入所有依赖项,而不是从 ngModule 中获取依赖项。例如,在app.component.html中,我们在模板中使用路由出口,因此组件需要导入RouterModule;您也可以只导入RouterOutlet

您也可以创建不包含任何 ngModules 的应用程序。为此,使用独立组件而不是 ngModule 来引导应用程序。我们需要查看main.ts文件以了解如何实现这一点。在使用 ngModules 的应用程序中,您将在main.ts文件中找到类似以下内容:

platformBrowserDynamic().bootstrapModule(AppModule).catch()

在这种情况下,Angular 应用程序使用 AppModule(根 ngModule)引导。当您想要在没有 ngModules 的情况下工作,您可以更改这一点,并用您的根独立组件代替引导。如果您查看expenses-registration 应用程序main.ts文件,您将找到以下内容:

bootstrapApplication(AppComponent, appConfig).catch(……);

如您所见,我们使用bootstrapApplication函数而不是bootstrapModule。我们向bootstrapApplication函数提供了一个独立组件和一个配置对象。此配置对象配置了诸如路由、HTTP 客户端和第三方模块等内容。我们将在之后多次回到这个配置对象。现在,请记住,当使用独立组件引导时,您可以在此处配置应用程序设置。

您还可以将 ngModules 与独立组件混合使用。如果您有一个已经使用 ngModules 的应用程序,您可以在现有代码旁边开始使用独立组件,并保持您的模块不变。

现在您已经了解了独立组件的基础知识以及如何使用它们,让我们探索用于依赖注入的新inject函数。

使用inject函数进行依赖注入

在 Angular 14 中引入的另一个酷炫特性是inject函数,它是构造函数依赖注入的替代方案。到目前为止,构造函数依赖注入是向您的 Angular 应用程序注入依赖的唯一方式:

constructor(private userService: UserService) {}

使用inject函数,我们有一个类似以下替代方法:

private userService = inject(UserService);

当我们到达依赖注入部分时,我们将更深入地探讨这种新语法。现在,我们将继续探讨下一个新特性:指令组合。

指令组合

component装饰器。每次你在模板中使用组件时,配置的指令将自动应用,而无需将指令添加到 HTML 元素中。你还可以在其他指令内部使用指令组合,从而产生应用多个指令的指令。在第三章中,我们将更深入地探讨指令组合的主题。

目前,你只需要知道你可以在组件装饰器内部配置指令以共享常见行为并减少模板复杂性。现在你了解了指令组合,让我们稍微探索一下 Angular Signal。

Angular Signal

Angular Signal是在 Angular 16 中引入的,这是 Angular 从 AngularJS 过渡到 Angular 以来框架最重要的变化之一。在 Angular 17 中,框架还引入了 Signal 组件输入和查询 Signal,用于使用 Signal 查询模板元素。有了 Signal,我们在 Angular 框架中有一个响应式原语,可以用来管理应用程序状态。

Signal 允许你声明、计算、修改和消费值以响应式的方式,这意味着当 Signal 的值发生变化时,Signal 将自动通知所有消费者。因为 Signal 是响应式的,所以当 Signal 的值发生变化时,你可以自动做出反应,当 Signal 被赋予新值时执行逻辑或更新其他值。Signal 围绕值并通过对 getter 的暴露来提供它们,这使得 Angular 框架能够跟踪谁在消费 Signal,并在值变化时通知消费者。Signal 可以围绕简单值或复杂数据结构,可以是可写的或只读的。以下是一个 Signal 和计算出的 Signal 值的简单示例:

@Component({ ……… , template: `
    <div>Count: {{count()}}</div>
    <div>Double: {{double()}}</div>`
})
export class AppComponent {
  count = signal(10);
  double = computed(() => this.count() * 2);
}

在前面的例子中,我们有一个count signal值和一个计算出的信号,该信号将计数翻倍。当count signal值发生变化时,计算出的信号将自动计算新的值。为了更好地解释 Signal 的优势,让我们定义什么是响应式原语。在 JavaScript 中,你有原始值和非原始值。JavaScript 的原始值有stringnumberbigintbooleansymbolnullundefined。非原始值是对象。

JavaScript 的非原始值是引用类型,这意味着如果你将它们分配给新变量,你不会创建一个新的对象,而是创建对现有对象的引用。原始值不是这样工作的;如果你将一个字符串分配给新变量,它不会持有原始变量的引用,而是创建一个新的字符串。原始值是不可变的,非原始值是可变的。这意味着你可以在创建非原始值之后修改它。如果你重新分配一个字符串,它是一个新的字符串,而不是具有不同值的相同字符串。当你调整对象时,它仍然是同一个对象,只是具有不同的值。

响应式原语是一个不可变值,当它被设置为新值时,会通知消费者。所有消费者都可以自动跟踪并对此响应式原语的变化做出反应。

重要提示

信号本身是一个响应式原语,且是不可变的。你只能通过在信号上使用set()update()方法来更新信号并通知信号的消费者。

然而,信号持有的值并不是不可变的!所以,如果你使用非原始值(一个对象或数组)作为信号值,你仍然可以更新值而不更新信号本身。

由于信号是响应式原语,Angular 框架可以更好地检测变化并优化变化检测和渲染,从而提高性能。信号是迈向 Angular 版本的第一步,该版本具有完全细粒度变化检测,无需Zone.js根据浏览器事件检测变化。在撰写本文时,Angular 假设任何触发的浏览器事件处理器都可以更改绑定到模板的任何数据。正因为如此,每次浏览器事件被触发时,Angular 都会检查整个组件树以查找变化,因为它无法以细粒度方式检测它们。这会显著消耗资源并负面地影响性能。

第七章中,我们将更深入地探讨信号,并查看不同的实现以及如何将它们与 RxJS 结合使用。现在,你只需要知道信号可以用来管理应用程序状态,并且它们引入了一个响应式原语,这可以显著提高你的 Angular 应用程序的反应性和性能。

现在你已经知道了信号是什么以及为什么它们很重要,让我们来学习新的 Angular 控制流系统。

Angular 控制流

控制流系统是在 Angular 17 中引入的,它提供了一个新的机制来在 HTML 模板中显示、隐藏和重复元素。在 Angular 17 之前,你只能使用*ngIf*ngFor*ngSwitch指令在 HTML 模板中显示、隐藏或重复元素。

截至 Angular 17,你可以互换使用指令和新的控制流系统。让我们使用新的控制流语法查看每个选项的示例,从@if控制流开始:

@if (a > b) {
  {{a}} is greater than {{b}}
} @else if (b > a) {
  {{a}} is less than {{b}}
} @else {
  {{a}} is equal to {{b}}
}

如你所见,新的控制流语法使用@if@else if@else来在 HTML 模板内定义if-else语句。新的控制流语法使得创建if-else语句变得容易得多。你可以使用控制流和指令语法,所以选择你和你团队更喜欢的。现在你已经看到了@if控制流的例子,让我们看看如何使用新的控制流语法在模板内重复元素:

@for (item of items; track item.id) {
  <li> {{ item.name }}</li>
} @empty {
  <li> There are no items.</li>
}

@for 语法可以与 *ngFor 指令互换使用。新的控制流语法要求你定义一个 track 属性。你将 track 属性分配给一个唯一的标识符,例如 ID,这允许 Angular 只在渲染的列表发生变化时重新渲染更改的项目。你还可以提供一个 @empty 块,在提供给 @for 块的数组为空时显示内容。现在,你已经知道了如何使用新的控制流语法在模板中重复元素,让我们来了解 *ngSwitch 指令的替代方案:

@switch (condition) {
  @case (caseA) { Case A. }
  @case (caseB) { Case B. }
  @default { Default case. }
}

就像其他控制流块一样,你可以使用 @switch 块与 *ngSwitch 指令互换。新的 Angular 控制流还引入了一个新概念:@defer 块。

@defer 块允许你在 HTML 模板中懒加载组件或原生 HTML 元素。@defer 块可以根据不同的触发条件懒加载和显示元素,例如当条件满足时、当元素进入视口时、当用户与占位符交互时,或者基于计时器。@defer 块可以提高性能,因为当用户访问页面时,不需要加载那么多的组件。此外,@defer 块减少了包的大小,因为懒加载的页面元素不需要包含在初始应用程序包中。以下是一个带有占位符的 @defer 块的示例:

@defer (on viewport) {
  <calendar-cmp />
} @placeholder { <div>Calendar placeholder</div> }

现在你已经了解了新的控制流语法,让我们来探索 Angular 框架引入的其他功能。

其他值得注意的新 Angular 功能

在我们进入本章的下一节之前,我们将简要回顾对 Angular 框架所做的其他值得注意的改进:

  • 类型表单:在 Angular 14 中,响应式表单被完全类型安全化。现在,表单控件、组和数组中的值在整个响应式表单 API 中都是类型安全的,从而使得表单更加安全。

  • title 属性添加到你的路由配置中。此 title 属性将设置页面标题,而无需其他实现。

  • NgOptimizedImage 是一个用于优化图像获取、渲染和大小的内置图像指令。自 Angular 15 以来,它已经稳定可用。

  • 函数式方法:自 Angular 15 以来,你可以使用函数式方法进行 HTTP 拦截器、路由解析器和路由守卫。

  • 路由参数映射:此功能允许你自动将路由数据、查询参数和路径参数映射到你的组件输入。因此,你不再需要订阅,从而减少了复杂性和样板代码。

  • OnDestroy 注入器,它允许你注入 DestroyRef 并更灵活地访问 OnDestroy 生命周期钩子。它允许你订阅 OnDestroy 生命周期,以及在你的组件外部注入它。

  • 自闭合标签:在 Angular 16 中,你可以为你的组件选择器使用自闭合标签。这可以提高你的 HTML 模板的可读性。

  • 必需输入:在 Angular 16 中,你可以使组件输入成为必需的。如果在模板中没有提供输入,编译器将指定一个错误。

还有更多新添加的功能,例如 Vite 支持,以及更好的页面激活,但本节中提到的功能对你的日常开发实践来说是最重要的。

现在你已经了解了添加到 Angular 框架中的新功能,我们将继续深入探讨特定主题,从 Angular 路由开始。

深入了解 Angular 路由

本节将介绍 Angular 路由,这是一个强大的工具,用于处理 Angular 应用程序中的导航。路由器负责实现无缝的页面转换,更新浏览器 URL,以及处理路由数据、重定向、查询参数、路径参数、路由解析器,以及保护路由免受未经授权的访问者。

让我们先创建两个新的组件,我们可以导航到它们。

创建新组件

我们将使用一个 Nx 生成器来完成这个任务。你可以编写一个自定义生成器,但我现在将使用内置的组件生成器。在你的 expenses-registration 文件夹内的 app 文件夹上右键点击,并选择 Nx 控制台。在下拉菜单中,搜索 component 并选择 @nx/angular - component

按照以下步骤生成必要的组件:

  1. pages/expenses-overview-page

  2. 选择 独立 复选框。

  3. 点击 显示所有选项

  4. 变更检测 选择框中,选择 OnPush

在右上角,点击 生成

完成这些步骤后,重复相同的步骤来创建第二个组件。你只需将名称更改为 pages/expenses-approval-page

现在,让我们使用以下命令来运行 finance-expenses-registration 应用程序

nx serve finance-expenses-registration

你还可以使用 Nx 控制台来运行你的应用程序。只需在 项目 选项卡下选择应用程序,并在悬停在 运行 上后点击 播放 按钮。

当你在 http://localhost:4200/ 打开应用程序时,你会看到一个空白屏幕。这是因为你只在 app.component.html 文件中有一个路由出口,它显示当前路由,而我们还没有为我们的应用程序配置任何路由。

你的应用程序正在运行,并且有两个组件可以路由,所以让我们为你的应用程序配置一些路由。

配置 Angular 应用程序中的路由

RouterModule之前,我们通过在forRootforChild方法中提供路由来配置它。因为我们使用的是最新的 Angular 技术,所以我们将不会使用 ngModules。当你使用独立组件启动时,你的路由配置方式不同。当你打开main.ts文件时,你会看到一个appConfig对象被传递给bootstrapApplication函数。打开你的app.config.ts文件以定位这个appConfig对象。在里面,你会找到你的路由配置:

provideRouter(appRoutes, withEnabledBlockingInitialNavigation())

路由是通过在ApplicationConfig对象的providers数组中添加provideRouter函数来配置的。当 Nx 创建应用程序时,它已经为我们设置了这一点。

provideRouter函数内部,你会找到一个Route对象的数组和一个with EnabledBlockingInitialNavigation函数,后者是使用服务器端渲染进行路由所必需的。我们并没有使用服务器端渲染,所以你可以删除withEnabledBlockingInitialNavigation

打开你的app.routes.ts文件来设置你应用程序的路由。首先,我们将在appRoutes数组中添加两个Route对象——一个用于费用审批页面,另一个用于费用概览页面:

export const appRoutes: Route[] = [{ path: 'expenses-overview', component: ExpensesOverviewPageComponent },
{ path: 'expenses-approval', component: ExpensesApprovalPageComponent }];

如你所见,每个对象都有两个属性:一个用于定义 URL 路径的path属性,一个用于指定当我们到达路径时加载的组件的component属性。对于ExpensesOverviewPageComponent,我们配置了expenses-overview,这意味着它可以通过http://localhost:4200/expenses-overview访问。

当你导航到这个 URL 时,你会看到Router作为一个依赖项。然后,你可以使用以下语法:

this.router.navigate(['expenses-overview']); //Option 1
this.router.navigateByUrl('/expenses-overview'); //Option 2

你可以使用routerLink来导航你的 HTML 模板,如下所示:

<a [routerLink]="['path', { outlets: { sidebar: 'path'} }]">Click to navigate</a>

现在你已经配置了两个路由,让我们来看看你还可以在你的路由配置中配置什么。

路由配置选项

在本节中,你将使你的路由更加健壮,并探索你可以在你的路由对象上配置的属性。

添加页面标题

title属性是在 Angular 14 中添加的,用于动态设置 HTML 页面标题。在 Angular 14 之前,你需要订阅和大量的逻辑来设置页面标题。随着title属性的引入,Angular 在幕后处理所有这些,并为你设置页面标题。你可以使用简单的字符串或ResolveFn<T>来设置你的标题。当你使用字符串时,你可以使用以下语法:

{ path: '', component: ExpensesApprovalPageComponent, title: 'Expenses Approval Page' }

我们将使用ResolveFn<T>来动态设置页面标题,如下所示:

export const titleResolver: ResolveFn<string> =
  (route: ActivatedRouteSnapshot) =>
    route.routeConfig?.path?.replace('-', ' ') ?? '';

这是一个简单的例子,我们取路由路径并将连字符替换为空格,但你可以添加任何你想要的逻辑。一旦你定义了你的标题解析器,你就可以像这样将其分配给你的路由配置:

{ path: '', component: SomeComponent, title: titleResolver}

你还可以通过覆盖 TitleStrategy 类来覆盖 Angular 用于向页面添加标题的默认行为。这仅在边缘情况下有用,但了解这是可能的总是好的。本书中不会涵盖示例,但你可以在本书的 GitHub 仓库中找到简单的 TitleStrategy 覆盖:github.com/PacktPublishing/Effective-Angular/blob/main/apps/finance/expenses-registration/src/app/app.routes.ts

懒加载独立组件

loadComponent 属性在你的路由配置中。

自从 Angular 15 以来,路由也支持默认导入的自动展开。使用自动展开,你不需要将 .then() 方法链式调用以展开路由的导入。因此,我们可以使懒加载的语法更短、更简单。通过将你的组件类的导出改为 default 导出,将你的路由改为懒加载路由,如下所示:

export default class ExpensesApprovalPageComponent {}

一次完成这些后,你可以这样配置懒加载路由:

{ path: '……', loadComponent: () => import('@pages/expenses-approval-page/expenses-approval-page.component') }

为你的其他路由做同样的操作,以确保所有路由都是懒加载的。

接下来,我们将学习如何使用多个路由出口和辅助路由。

路由出口和辅助路由

如果你想在减少包大小的同时开发动态用户界面,命名路由出口是一个实现这一目标的好方法。我们不会实现命名路由出口,但我确实想解释它们是如何工作的。

使用命名路由出口,你可以懒加载页面特定部分,并根据应用状态懒加载这些页面部分的组件。例如,你可以根据应用状态在每一页或同一页上显示不同的侧边栏。因为你可以懒加载这些侧边栏组件,所以它们不会成为你的主应用包的一部分,只有在显示时才会加载。要使用命名路由出口,你需要配置一个具有 outlet 属性的路由,如下所示:

{ path: 'list', component: SomeComponet, component property with the loadComponent property and import the component.
Routes with the `outlet` property defined can only be loaded by a router outlet with the same name specified on it. You can define named router outlets by adding a name attribute on the router outlet HTML tag like this:

<router-outlet name="sidebar"><router-outlet/>


 With the named router outlet in your template and a route configuration with the `outlet` property defined, you have everything set up. Your main router outlet will work as expected and navigate to the expenses overview page when you add `/expenses-overview` after your root URL. The named router outlets work differently. The routes that are used by your named router outlets are called *auxiliary routes* and can be seen as sub-routes that operate independently from your main route. These auxiliary routes form a special kind of URL that looks like this: `http://localhost:4200/expenses-overview(sidebar:list)`.
As you can see, round brackets are added to your URL to represent your auxiliary routes. There is only one auxiliary route in our example, but there could be more, and they would all be inside the round brackets separated by a double forward slash. Your auxiliary routes are isolated inside these round brackets so that you can activate different auxiliary routes for the same main route.
Routing to auxiliary routes inside your TypeScript files can be done like this:

this.router.navigate(['path', { outlets: { sidebar: 'path'} }]);


 When using `routerLink` in your HTML templates, you must add the following syntax to your HTML tag:

`[routerLink]="['path', { outlets: { sidebar: 'path'} }]"


 Now that you know about named router outlets, let’s learn about route guards.
Route guards
The `canActivate`, `canMatch`, `canActivateChild`, and `canDeactivate` properties declare **route guards** in your route configurations. Route guards help you to secure routes and prevent users from accessing a route they are not intended to access. All four properties define a type of route guard that prevents the user from performing a specific routing task, such as activating or deactivating a route.
The implementations with the rules when these guards should allow or block a user are created by yourself and can contain any logic you need. Each route can configure multiple guard types, and you can add various implementations for each type. You can configure these guard types and the implementations for them in your route configurations, like this:

{ path: '……', loadComponent: ……,

canActivate: [IsLoggedInGuard, IsAdminGuard],

canDeactivate: [hasDoneSomeTaskGuard] },


 In *Chapter 9*, we will create route guards and look at their implementations; for now, you just need to know that you can protect routes with route guards.
Now that you know about route guards, let’s move on and start learning about child routes.
Defining child routes
Route configurations can also define **child routes**, which helps organize your routes better and easily create an initiative URL structure. Child routes are defined like this:

{ path: 'dashboard', component: DashboardComponent,

children: [{ path: 'summary', component: SummaryComponent }]}


 The preceding example would load `SummaryComponent` on the `/dashboard/summary` route. You can configure the same route without using child routes, but using child routes offers some advantages. The most apparent benefit is that you can better organize your routes. Another advantage of child routes is that you can share route resolvers and guards. When you use a route guard on a parent route, the guard will automatically be applied to all child routes. You can also use child routes to omit the round brackets in the URLs of your auxiliary routes from the named router outlets. However, there are some drawbacks to this compared to regular auxiliary routes. When using child routes to omit the round brackets, you can’t load different auxiliary routes on the same main URL; instead, you need to add a new configuration for each route and auxiliary route combination.
Fallback routes and redirecting
You can configure **fallback routes** by using a double asterisk for the path. Your fallback route will be triggered when no route to the current browser URL is found. Most of the time, the fallback route is used to display a **404 Page Not Found** error. You can configure fallback routes like this:

{ path: '**', component: NotFoundComponent }


 When working with child routes and named router outlets, you can configure multiple fallback routes, but in most scenarios, one fallback that redirects to a `/expenses-overview` route when they load the root route. You can add this redirect to your `appRoutes` array like so:

{ path: '', pathMatch: 'full', redirectTo: '/expenses-overview' }


 Now that you know about fallback routes, let’s dive into route resolvers.
Route resolvers
**Route resolvers** can resolve data before a route is activated and provide that data to your component. That might sound nice, but your route won’t be activated until the data is fetched and can be passed to the route. As a result, when you fetch asynchronous data and the API isn’t responding, the route will not be activated, and the user will be staring at a white screen. Resolvers should only be used if you have some edge case where a component cannot work without having specific data before the component renders. A simple implementation of a route resolver function looks like this:

export const userDataResolver: ResolveFn<User> = (

route: ActivatedRouteSnapshot) => inject(UserService)

.getUserData(route.paramMap.get("userId")).catch(……);


 You can declare the route resolver on your route configurations like this:

{ path: 'path', resolve: productResolver, component: ……},


 You can access the resolved data inside your components using the data property of the route snapshot:

protected readonly route = inject(ActivatedRoute)

ngOnInit() { this.route.snapshot.data; }


 Don’t use route resolvers unless you don’t have any other option. Such scenarios don’t arise often, if at all; however, I wanted to mention resolvers and make you aware of them and their drawbacks. When working on Angular applications, you will find route resolvers quite often in the code base.
Now that you’ve created components, set up routes, and learned about the Angular router, we will learn about component communication.
Component communication
This section will dive deep into **component communication**, starting with input and output decorators. Before we begin, let’s create a new component with the Nx generator so that we have something to work with.
Name your new component `navbar` and add it to the `shared-ui-common-components` library. Don’t forget to check the `standalone` checkbox and select `OnPush` for `changeDetection`. When the component has been created, add it to the `index.ts` area of your library:

export * from './lib/navbar/navbar.component';


 After that, add the `navbar` component to the `app.component.html` file of your `expenses-registration` application. It’s important to note that you need to add the `NavBarComponent` class to the `imports` array of your `app` component decorator. This is because we are using standalone components, and a standalone component needs to import everything it uses. Once you’ve added the `navbar` component to the template of your app component, you can get the code for the HTML and SCSS of the navbar from this book’s GitHub repository: [`github.com/PacktPublishing/Effective-Angular`](https://github.com/PacktPublishing/Effective-Angular).
Because `navbar` is also a standalone component, you need to add `RouterLink` and `CommonModule` to the `imports` array of the component decorator. These two imports are necessary because we use the `routerLink` and `*ngFor` directives in the template of the `navbar` component.
Now that you’ve created and added the `navbar` component to the `app` component template, we can look into parent-child component communication.
Receiving values with the @input() decorator
As we explained in *Chapter 1*, when we develop Angular applications, we divide our components into smart and dumb components. Dumb components are presentational components that are used in the templates of smart components. These dumb components should only receive data through `@Input()` decorators (alternatively, you can use the new `input()` Signal that was introduced in Angular 17; we will dive deeper into Signals in *Chapter 7*, so for now, we will use the decorator); dumb components do not inject services for data as that is the responsibility of smart components.
`@Input()` decorators are only defined on child components; the parent components pass data to the input. A component can be considered a child component when it’s declared inside another component’s HTML template. On the other hand, the component that declares a component in its HTML template is regarded as the parent component. Dumb components are always meant to be child components, whereas smart components can be both. Still, smart components are generally used as parent components and seldom declare input and output decorators.
Our newly created `navbar` component is a dumb component that’s used as a building block for our pages. Since it’s a dumb component, it must rely on input decorators to receive its data. A `navbar` component needs `navbar` items, so let’s define an interface and input. First, define the interface in a new file or underneath your `navbar` component’s class:

export interface NavbarItem {label: string; route: string;}


 Here, we defined the interface. Now, let’s add the input to the `navbar` component, like this:

@Input()navbarItems 字段的装饰器,它告诉 Angular 编译器该属性可以接收来自父组件的输入。我们给字段分配了 NavbarItem 数组类型和一个空数组的默认值。如果你不提供默认值,编译器将开始抱怨;你可以通过在属性名称后添加一个感叹号来防止这种情况,如下所示:

@Input() navbarItemsnavbar component in the app component template from our *expenses-registration application*, making navbar a child component of the app component. To pass our new input property data, let’s declare a NavbarItem array inside the app component class, like this:

navItems: NavbarItem[] = 使用 navbarItems 输入属性将 navItems 数组传递给导航栏组件。我们可以在应用程序组件的 HTML 模板中这样做,在那里我们声明导航栏组件的 HTML 选择器标签。你可以使用此语法传递 navItems 数组作为输入:

<bt-libs-navbar [navbarItems]="navItems" />

在左侧,方括号之间,你将使用在 navbar 组件内部声明的输入属性的属性名——在我们的例子中是 navbarItems。在右侧,你必须分配一个在父组件中声明的值作为输入——在我们的例子中是 navItems 数组。

重要的是要知道,当一个组件接收到输入值时,ngOnChanges 生命周期钩子会被触发——一旦这个组件被创建,在 ngOnInit 生命周期钩子运行之前,然后每次输入接收到新值时再次触发。你可以在 ngOnChanges 方法中这样访问先前和新的输入值:

ngOnChanges(changes: SimpleChanges) {console.log(changes)};

SimpleChanges 对象中的当前值应该等于组件中声明的输入属性的值。因此,如果你需要当前值,你也可以访问组件属性。在我们的例子中,这将是指 navbarItems

ngOnChanges 生命周期钩子是在接收到输入值时执行额外逻辑的好地方。然而,当你有很多输入属性并且想要为每个属性接收新值时执行逻辑,这可能会变得很混乱。如果这种情况发生,或者你想将值转换成其他形式,你可以使用 @Input() 装饰器作为获取器和设置器。

假设我们希望在每次接收到值时都为主页添加 NavbarItem 到我们的输入中,这样我们就不必在传递给 navbar 组件的输入对象中声明主页及其路由。我们可以通过将输入转换为具有获取器和设置器的输入来实现这一点。首先,向 navbar 组件添加一个私有属性,如下所示:

private _navItems: NavbarItem[] = [];

现在,将 navbarItems 输入属性更改为获取器和设置器,并在获取器和设置器中使用私有属性,如下所示:

@Input()
set navbarItems(value: NavbarItem[]) {
  this._navItems = [{label: 'home', route: '/'}, ...value];
}
navbarItems property inside the navbar component or template, it will use the getter, which returns the private property, including the extra home page item. After you change the input property, you can remove the object for the home navbar item from the navItems array declared in your app component.
Since Angular 16.1, you can achieve the same with the `@Input` decorator instead of creating a getter and setter. Using the `transform` property requires a lot less code and looks much cleaner. Let’s convert our getter and setter into the `transform` property. First, remove the private `_navItems` property and the getter and setter we just added and replace them with this:

使用转换逻辑添加 @Input({addHome})。你可以将此函数添加到单独的文件或位于导航栏组件类下面的同一文件中。函数看起来像这样:

function addHome(items: NavbarItem[]) {
  return [{ label: 'home', route: '/' }, ...items];
}

这就是你所需要的;不需要更多的私有属性或 getter 和 setter 来转换输入值!如果你需要执行其他逻辑,例如设置组件属性或在 component 类内部运行函数,你仍然可以使用 getter 和 setter 方法。

最后,由于 Angular 16 的推出,你还可以使输入属性成为必需。当你使输入属性成为必需时,它需要在组件在组件模板中使用时在 HTML 标签上声明。在模板中使用组件的父组件必须将输入属性添加到 HTML 标签中,并传递一个有效的值;否则,编译器将抛出错误。为了使我们的 navbarItem 输入属性成为必需,我们可以像这样更改输入装饰器:

@Input({ transform: addHome, @Output() decorator.
Emitting values with the @Output() decorator
Child components also need a way to send events and data to the parent component. For example, if you have a table component in which you can display and update data, the table component shouldn’t inject services to receive and update the data. This would result in a tight coupling of the table component and the data it displays. Each time you use the table with different data, it needs to add extra services and new logic to persist the data updates, and this is not a desirable situation.
Instead, the table component should be a dumb component that receives data as inputs and emits an event with the updated data as output. By doing so, your table component remains reusable and doesn’t create unnecessary dependencies. The parent components are smart components that are used for specific business use cases or pages, so each can implement whatever logic is needed to handle the data updates for its specific page or business use case without creating unwanted dependencies.
To emit an event to a parent component, we need to create something that can emit our events. We can do this with the following syntax:

@output 装饰器和我们的 EventEmitter 的属性名。在右侧,我们将属性分配给新的 EventEmitter,并在箭头符号之间添加我们希望发出的类型 - 在这个例子中,这是 tableData。

接下来,你需要在父组件的 HTML 模板中监听 dataChanged 事件,其中定义了数据表组件。监听 @Output 与监听常规 DOM 事件(如 clickmouseleave)的工作方式相同:

<bt-lib-table (click; in our example, we named the event dataChanged. On the right-hand side, you call a function you’ve created in the component class of the parent component. So, $event will contain whatever values you emit from the child component.
Lastly, we must *emit* events from the child component using the `dataChanged` property. Inside the `table` component, whenever the data changes and you want to emit an event to the parent, you can use the following syntax:

在 this.dataChanged.dataChanged 属性上调用 .emit() 方法。你可以在括号内传递你想要向父组件发出的任何值。在这个例子中,这将是最新的表格数据。为了更好地说明输入和输出机制的工作原理,图 2**.1 展示了数据从父组件流向子组件以及相反的过程:

图 2.1:层次依赖创建

图 2.1:层次依赖创建

现在你已经知道如何使用 @output 装饰器向父组件发出事件,让我们探索如何结合输入和输出装饰器来创建自定义的双向数据绑定。

使用 @Input 和 @Output 的双向数据绑定

select 下拉组件具有两个输入属性,一个用于选择选项,另一个用于选中选项。除此之外,该组件还有一个 selectionChanged 输出,当进行新的选择时,会发出一个值。

在父组件中,我们有一个用于选择组件当前选中值的属性。这个属性被用作选中输入的输入,并且每当选择新的值并且发出 selectionChanged 输出时,需要更新这个属性。为了实现这一点,你必须在父组件的模板内部有类似以下的内容:

<select [selected]= "selectedValue" (selectionChanged)="this.selectedValue = $event" />

我们可以通过双向数据绑定和香蕉盒语法来改进前面的代码片段。为了使双向数据绑定工作,子组件中的输入和输出属性必须具有相同的名称;只需要在名称后添加 changed。因此,在我们的例子中,输入被命名为 selected,这意味着输出需要被命名为 selectedChanged。当我们使用这种命名约定时,Angular 会知道将其处理为双向数据绑定。

要使用双向数据绑定,在父组件中,我们必须更新 HTML 如下所示:

<select selectedValue property of the parent component as an input to the selected input property of the child component. When the child component emits the selectedChanged event, the selectedValue property will automatically be updated in the parent component. Take a look at the following syntax:

<select [(selected)]= "selectedValue" />


 This is the same as using the following:

<select [selected]= "selectedValue" (selectedChanged)="this.selectedValue = $event" />


 As you can see, the banana-in-a-box syntax combined with the square and round brackets is much cleaner and more compact. Alternatively, you can use the new `model()` Signal that was introduced as a developer preview in Angular 17.2, but we will cover this scenario in *Chapter 7*.
Now that you know how to input, output, and two-way bind properties with the input and output properties, let’s look for another way to communicate data between components and routes.
Other component communication methods
There are a few other means of communication for Angular components. You can access public properties and methods of child components with the `@ViewChild` decorator, communicate with child, parent, sibling, and unconnected components with *services*, and pass data in various manners to components with the *router*. Let’s start with the `@``ViewChild` decorator.
Using the @ViewChild decorator to access child components
The `@ViewChild` decorator is used to access template elements inside the component class. As in alternative to the decorator, you can also use the new `viewChild()` Signal that was introduced in Angular 17.2; we will cover this in more detail in *Chapter 7*. Using the `@ViewChild` decorator to access or update child properties and methods is straightforward but has some drawbacks. When using `@ViewChild` to communicate with your components, you can mutate values within your child component, which can lead to unexpected behavior and bugs that are hard to debug. Besides that, it makes your component hard to test. If you have a scenario where you need to update properties from the child in the parent, here’s the syntax:

@ViewChild(NavbarComponent) navBar!: NavbarComponent;


 Here, you declare the decorator; inside the function brackets of the decorator, you enter the child component’s class name, then give it a variable name and type it with the component’s class name. After the view of the parent component has been initialized, you can access the child component and its public properties and methods like this:

this.navBar.navbarItems;


 As mentioned previously, I’m not a fan of this decorator, and it’s recommended not to use it unless you need to achieve something that can’t be done in another way.
Now that you know how to use the `@ViewChild` decorator to access properties and methods in child components, let’s explore communication through the Angular router.
Component communication with the Angular router
The *router* is meant to navigate between routes but can also send data to a component that’s been loaded on a route. The most common examples are route parameters and additional query parameters in the route, but you can also add data to the route using the `data` property in the route configuration or with route resolvers.
Let’s say we have the following route configuration:

const routes = [{ path: 'dashboard/:id',

component: DashboardComponent,

data: {caption: 'Dashboard caption'},

resolve: {permissions: DashboardResolver}}]


 We also have this URL in our browser: [`some-url.com/dashboard/123?queryParam=paramValue`](https://some-url.com/dashboard/123?queryParam=paramValue).
When we reach this route, the `dashboard` component will be loaded. In that component, we can access the dashboard ID, the caption we added to the `data` property, the resolved permission data, and the value of the `query` parameter. To do so, you need to inject `ActivatedRoute` into the constructor or use the `inject` function:

private route = inject(ActivatedRoute);


 After that, you can access the properties in the route snapshot like this:

this.route.snapshot.paramMap.get('id');

this.route.snapshot.queryParamMap.get('queryParam');

this.route.snapshot.data['caption'];

this.route.snapshot.data['permissions'];


 You can also access the properties more reactively by subscribing – just remove the `snapshot` property and then add your subscription logic instead of accessing the properties through the `get` method:

this.route.queryParamMap.subscribe(……)


 You can subscribe to the router’s `paramMap`, `queryParamMap`, and `data` objects. Since Angular 16, you can also directly bind the route values to your component’s `@Input()` decorators. To achieve this, you need to add `withComponentInputBinding` to your app config where you provide your routes:

提供路由器(appRoutes,组件类,如下所示:

@Input() caption?: string;
@Input() id?: string;
@Input() queryParam?: string;
@Input() permissions?: string;

如果您想使用不同的属性名称,您可以像这样为输入创建别名:

@Input('caption') captionFromRouteData?: string;

这可能会给您带来一个 linting 错误,因为不建议别名输入,但使用输入别名绑定路由数据时,这不是一个坏习惯。因此,在这种情况下,如果您遇到错误,可以禁用 linting 规则。

现在您已经了解了使用装饰器和路由数据进行组件通信的所有内容,我们将开始探讨依赖注入,它可以用于向应用程序的每个部分提供数据。

依赖注入

@Injectable() 装饰器),但它们可以是字符串、函数或您希望在应用程序中提供的任何其他内容。

Injector 抽象是 Angular 依赖注入系统的核心元素,它促进了依赖项提供者和消费者之间的连接。确保您区分 Injector@Injectable() 装饰器,后者将类标记为依赖注入的候选者。Injector 抽象检查依赖项的实例是否已经创建,如果依赖项已经注册,则提供它;如果没有注册,则提供依赖项并注册。当您的应用程序启动时,Angular 创建一个应用程序范围的 root Injector,并为所有应用程序中不可访问的依赖项创建所需的其他 Injector

提供依赖项

您可以提供类和其他值,如字符串、日期、对象和函数作为依赖项。两者提供方式不同。我们将从最常见的情况开始,即提供类。

提供作为依赖项的类

当您在 Angular 中提供类时,它很可能是 Angular 服务,但您可以通过依赖注入提供任何类。准备类以进行依赖注入的最常见方法是使用@Injectable()装饰器,如下所示:

@Injectable()
class SampleService {}

@Injectable()装饰器表明一个类用于依赖注入;您可以通过将其标记为根注入器或将其添加到特定组件或 ngModule 的providers数组中来实现这一点。@Injectable()装饰器还确保 Angular 可以执行如摇树优化等优化。您也可以在providers数组中提供没有@Injectable()装饰器的类,但除非您需要在特定位置提供这些类,否则这是一个好习惯。以下是如何在组件的providers数组中提供服务的示例:

@Component({……… , providers: [SampleService] })
class ListComponent {}

当使用组件级别的providers数组时,提供的依赖项对于每个组件实例以及组件树中使用的所有子组件或指令都可用。如果您在 ngModule 的providers数组中添加依赖项,则该依赖项可以在该模块内的任何地方注入和访问。您可以在模块的providers数组中声明类,如下所示:

@NgModule({ declarations: [ListComponent],
  providers: [SampleService]})
class AppModule {}

在我们的支出注册应用程序中,我们没有 ngModules。当在不使用 ngModule 开发 Angular 应用程序时,您可以在ApplicationConfig对象中提供依赖项;这类似于将依赖项标记为根注入器,因为它将在整个应用程序中可用。在ApplicationConfig对象内部将类添加到providers数组中的操作如下:

export const appConfig: ApplicationConfig = {
  providers: [SampleService],
};

最后,将您的类作为依赖项提供的最常见方法是将其标记为注入器。您可以在@Injectable()装饰器中使用providedIn属性来完成此操作,如下所示:

@Injectable(@Injectable() decorator, let’s learn how to provide classes and other values such as strings, Booleans, and functions using provider objects.
Providing dependencies with provider objects
When you provide dependencies with a `providers` array. You can use two properties to declare a dependency with a provider object:

*   `provide` property holds the `Injector` instance.
*   `Injector` how to create the dependency, and it can be defined with four values:
    *   `useClass`: This tells Angular to provide the given class when the corresponding provider token is used
    *   `useExisting`: This aliases another provider token and accesses the same dependency with two different tokens
    *   `useFactory`: This defines a factory function to construct the dependencies based on some logic
    *   `useValue`: This provides a static value such as a string or date as a dependency

Now, let’s explore the four provider definitions in more detail.
Declaring provider objects with useClass
Here’s an example of a provider object using the `useClass` provider definition:

providers: [{ provide: Logger, useClass: Logger }]


 The preceding syntax is the same as using the following syntax:

providers: [Logger]


 In the scenario where you only supply a class name, the provider object is automatically created behind the scenes. The provider object uses the class for the provider token and definition, so when would you use the provider object instead of only using the class?
Commonly, `useClass` is used when you want to overwrite a dependency injection class with a new implementation. In the preceding example, we provided a `Logger` class; let’s say you create a new `BetterLogger` class extending the original `Logger` class. If you have a large application and the `Logger` service is used throughout your application, it’s a lot of work to change the service everywhere it’s declared. Instead of updating all dependency injection consumers, you can create a provider object and return the `BetterLogger` class for the `Logger` token:

providers: [{ provide: Logger, useClass: BetterLogger }]


 If you provide the `Logger` service inside the `providers` array as well, you must make sure that you declare the new provider object with the `BetterLogger` class below your previous provider object, or simply remove the old object. If the `Logger` service is provided using the `providedIn` property inside the injectable decorator, the overwrite with `BetterLogger` will just work without any gotchas.
Now that you know how to create provider objects with the `useClass` provider definition, let’s examine the `useExisting` provider definition.
Declaring provider objects with useExisting
The `useExisting` property allows you to map one provider token to another provider token, making sure two tokens will return the same dependency:

providers: [BetterLogger,

{ provide: Logger, BetterLogger 和 Logger 标记。这两个都将提供相同的服务实例。请注意不要为此使用 useClass 定义:

providers: [ BetterLogger,
  { provide: Logger, Logger and BetterLogger that each return an instance of the BetterLogger class instead of the same instance.
Now that you know about `useExisting`, let’s explore `useFactory`.
Declaring provider objects with useFactory
The `useFactory` provider definition allows you to define a function as a dependency. This can be just a regular function you want to inject into multiple places of your application or a factory function that constructs a service class. Let’s say you have `AdminDashboardService` and a regular `DashboardService` you want to inject using the `DashboardService` tokens, depending on the active user role. You can achieve this with `useFactory`. First, create your factory function:

const DashboardServiceFactory = (userService: UserService) => userService.user.isAdmin ? new AdminDashboardService() : new DashboardService();


 Next, declare the provider object inside your `providers` array, like this:

{ provide: DashboardService,

UserService 用于检查当前用户是否为管理员用户。因此,您需要在提供者对象的 deps 数组中添加 UserService。

现在您已经了解了useFactory,让我们导出useValue

使用 useValue 声明提供者对象

useValue提供者定义是最简单的一种。它返回一个常量值,如字符串、日期或布尔值。它对于提供诸如基本网站 URL、基本 API URL 或其他常量值等非常有用:

{ provide: BASE_URL, InjectionToken object.
Using InjectionToken as a provider token
When you declare a provider object, you supply the `provide` property with a provider token. The provider token is used to inject the dependency into the consumers of the dependency. You can use three values for the provider token, but only two should be used. You can use a class name, `InjectionToken`, or a string. Only the class name and `InjectionToken` should be used. When providing a class-based dependency, you should use the class name as the provider token; when using a non-class-based dependency, you should use an `InjectionToken` object. You can create an `InjectionToken` object like this:

export const BASE_URL = BASE_URL – and assign it with new InjectionToken. The value between the arrow brackets is the type of your dependency – in this example, a string – and the value between the round brackets is a description for your InjectionToken. Now, you can use this InjectionToken in a provider object like this:

{ provide: BASE_URL, useValue: 'www.someurl.com/'}

当分配提供者令牌时,您也可以使用一个简单的字符串,如下所示:

{ provide: InjectionToken instead of a string:

*   `InjectionToken` objects are type-safe and allow TypeScript to perform type-checking on your injected value. When you use a simple string, the compiler will not know what type your dependency is.
*   When using a string, you can run into name collisions, meaning you can assign two dependencies with the same string, which can result in wrongly injected values, errors, and bugs. This doesn’t have to be because you define two dependencies with the same string; it can also happen when a dependency you use from a third-party library uses the same string as a provider token. The `InjectionToken` object ensures a unique value is used for your provider token.
*   When you minify your code during the production build, string values can be renamed. This can result in problems with your dependency injection system.

Now that you know how to provide dependencies and what `InjectionToken` objects are, let’s learn how to inject and consume dependencies.
Injecting dependencies
There are two ways to inject dependencies: `inject` function, you can improve some architectural patterns and inject values in places where you don’t have a constructor. You can inject services everywhere within your Angular applications – in component classes, services, other classes, and even in functions you export. Let’s examine how to inject class-based dependencies using constructor injection and the `inject` function, starting with constructor injection:

constructor(private logger: LoggerService) {}


 Doing the same thing with the `inject` function looks like this:

private logger = InjectionToken as a provider token. The syntax to do this is slightly different in the case of constructor injection. You need to use the @Inject() decorator function inside your constructor like this:

constructor(InjectionToken-based dependencies with the inject function can be done like this:

private logger = inject(LoggerService);


 As you can see, you don’t need the `@Inject()` decorator for the `inject` function; you can simply use `InjectionToken` inside the function brackets of the `inject` function, and the rest is done for you. After injecting a dependency, you can use it like any other value:

inject function, why you should use it instead of constructor injection, and where to declare the inject function.

使用 inject 函数进行更好的依赖注入

inject 函数比构造函数注入更灵活,因为它可以在更多地方使用,并且在使用继承时表现更好。您可以在任何地方声明 inject 函数,但它需要在 注入上下文 内运行;否则,您将得到一个错误。注入上下文位于类的构造函数中,在 useFactory 的工厂函数、路由守卫、路由解析器和 HTTP 拦截器中初始化类内部的字段:

export class AppComponent {
  private url = inject(BASE_URL); // Is injection context
  constructor() { // Is injection context }
  someMethod() { // No injection context }
}

inject 函数的主要优点之一是它允许您将逻辑抽象到函数中。假设您想获取仪表板;您可以使用 inject 函数将此逻辑抽象到单独的函数中。为此,您可以创建一个如下所示的函数:

export const fetchDashboards = (): Observable<Dashboard[]> => inject(HttpClient).get<Dashboard[]>('api/dashboards');

在这里,我们使用箭头函数。因为我们没有使用括号,所以箭头函数直接返回箭头后面的内容。这与写这个相同:

export const fetchDashboards = (): Observable<Dashboard[]> => inject function to inject the Angular HttpClient as a dependency to fetch the dashboards. You can use this fetchDashboards function inside the injection context of your components and services. For example, you can assign the function to a component property and subscribe to it in your template using the async pipe:

export default class DashboardsListComponent {

dashboards$ = fetchDashboards();

}


 Now, inside the template of this component, you can do something like this:

{{dashboard.title}}


 When using the `fetchDashboards` function outside the injection context, such as in a method, you get an error telling you that the `inject` function can’t be used outside the injection context. But there is a solution to this: you can use JavaScript closure to use the `fetchDashboards` function anywhere, even outside the injection context. To use closure, adjust the `fetchDashboards` function so that it returns a function:

返回组件类时,必须将此闭包函数分配给一个属性,如下所示:

protected _fetchDashboards = fetchDashboards();

fetchDashboards 函数返回另一个函数,因此 _fetchDashboards 属性也是一个函数。现在 _fetchDashboards 函数持有返回获取仪表板的可观察 HTTP 调用的函数,可以在您的类的任何地方使用,也可以在注入上下文之外使用:

export default class AppComponent {
  private _fetchDashboards = fetchDashboards();
  loadDashboards() { this._fetchDashboards().subscribe(…) }
}

在将 fetchDashboards 函数转换为可以使用闭包之前,它不能在组件的 loadDashboards 函数内部使用,因为这是在注入上下文之外。通过使用闭包并将函数返回到我们的注入上下文中的一个属性,我们现在可以在注入上下文之外使用该函数来获取仪表板。使用这种模式可以实现逻辑和具有单一职责的函数的高度抽象,这些函数可以在整个应用程序中共享,同时保持组件类简单且干净。

除了将逻辑抽象到专用函数之外,inject函数在处理继承时提供了一些优势。当在基类及其继承自基类的类中同时使用依赖注入时,你需要调用super()并将依赖项传递给基类。以下是一个简单的示例:

export class baseService {
  constructor(private router: Router) { }
}
export class DashboardService extends baseService {
  constructor(private logger: Logger, router: Router) {
    super(router);
  }}

调用super()并传递依赖项可能会成为一种阻碍,看起来很杂乱。使用inject函数,你可以防止这种情况发生。通过在两个类中都使用inject函数,你不再需要传递任何东西或调用super()方法:

export class baseService {private router = inject(Router);}
export class DashboardService extends baseService {
  private logger = inject(Logger);
}

如你所见,你不需要将router传递给基服务,也不再需要调用super();这看起来要干净得多!假设你只在基类内部使用router进行导航。在这种情况下,你甚至可以更进一步,将导航功能抽象成一个单独的闭包函数,就像我们之前在fetchDashboards函数中所做的那样:

export const navigateFn = () => (url: string) => inject(Router).navigate([url]);
export class baseService {
  protected _navigateFn = navigateFn();
}

由于我们使用了一个返回另一个箭头函数的箭头函数,我们可以在注入上下文外部使用_navigateFn。在继承基类的服务内部,你可以使用以下代码进行导航:

this._navigateFn('some/url');

现在你已经知道了为什么、何时以及如何使用inject函数而不是构造函数注入,让我们来探讨依赖注入是如何创建注入服务的实例以及什么是分层注入器。

依赖实例、注入层次结构和解析修饰符

你需要学习的最后一件事是 Angular 如何创建你注入的依赖项的实例,注入层次结构是如何工作的,以及如何使用解析修饰符来控制它。

每次你注入一个依赖项时,Angular 都会检查依赖项是如何提供的,从注入层次结构的最低级别开始检查——即你组件或指令内部的providers数组。如果 Angular 找不到提供者,它将开始在注入层次结构中向上移动,首先移动到父组件;如果它在任何父组件中找不到提供者,Angular 将检查组件的 ngModule 或如果你不使用 ngModules,则检查你的ApplicationConfig对象。如果 Angular 在 ngModules 中找不到提供者,它将在根注入器中查找带有@Injectable装饰器和providedIn根的服务。如果 Angular 仍然找不到注入依赖项的提供者,它将抛出一个错误。如果你在服务中注入一个依赖项,Angular 将跳过组件层次结构的步骤。

假设我们想将 LoggerService 注入为依赖项。当 Angular 找到 LoggerService 的提供者时,它会检查注入器是否已经在提供者所在的层次级别创建了 LoggerService 的实例。如果注入器已经为给定的层次级别创建了 LoggerService,则返回已创建的实例。否则,它将创建一个实例然后返回。对于每个层次级别,将创建一个单例实例,并由所有较低层次级别的消费者共享。如果有两个或更多兄弟组件声明了提供者,将为每个提供者创建一个单例,每个组件将使用自己的 LoggerService 单例,并将其与所有后续子组件和指令共享。

图 2**.2 展示了层次依赖的创建和共享方式:

图 2.2:层次依赖创建

图 2.2:层次依赖创建

了解 Angular 依赖注入如何创建和共享依赖项,使您能够为应用程序的所有部分提供正确的实例。如果您想使单例在应用程序的所有依赖项消费者之间共享,您只需在根注入器中提供依赖项即可。在某些更复杂和边缘情况下,您可能需要能够更多地控制 Angular 查找提供者的方式。在这种情况下,您可以使用解析修饰符来调整行为。

您可以对注入的依赖项应用四种解析修饰符。每种修饰符都有自己的功能,并且您应用这些修饰符的方式取决于您是否使用构造函数注入或 inject 函数。让我们来检查这四种解析修饰符及其功能:

  • @Optional(): @Optional() 修饰符确保依赖项是可选的。如果 Angular 找不到给定依赖项的提供者,它不会抛出错误。

  • @SkipSelf(): 使用 @SkipSelf() 修饰符,您告诉 Angular 应该从依赖项层次结构中的第一个父组件开始查找提供者。

  • @Self(): @Self() 修饰符告诉 Angular 只查看组件或指令本身以查找提供者。

  • @Host(): @Host() 属性将组件标记为在搜索提供者时的最后一步,即使树中还有更高层的组件。当找到 @Host() 属性时,Angular 会停止寻找依赖提供者。

现在您已经了解了可用的解析修饰符及其功能,让我们看看如何将它们应用到依赖项上。当您想将解析修饰符应用到使用构造函数注入注入的依赖项时,您可以使用以下语法:

constructor(inject function, you can use the following syntax:

logger = inject(Logger, {optional: true, self: true});


 Now that you know everything you need to know about Angular dependency injection, let’s create a simple service before we move on to the next chapter.
To create this service, we are going to use the Nx generator again. First, create a new library called `expenses` under the `finance` domain with its type set to `data-access`. Next, open your Nx console again and click `service` and select **@schematics/angular -** **service**.
Follow these steps to generate the service:

1.  In the `services/expenses`.
2.  In the `finance-data-access-expenses`.
3.  In the top-right corner, click **Generate**.
4.  Go to the `index.ts` file of your *expenses library* and add the following code:

    ```

    export * from './lib/services/expenses.service';

    ```js

That’s all for now! With that, you’ve created a service and know everything you need to know about Angular dependency injection.
Summary
In this chapter, we explored some of the most fundamental features in the Angular framework and learned what Angular added in their latest major releases. You learned about Signals, the new control flow syntax, and `@defer` blocks. We covered how to communicate between components using input and output decorators, as well as less conventional methods for component communication using router data and the `viewChild` decorator. You also learned about the Angular router and how to configure route objects for more advanced scenarios and auxiliary routes. Finally, you learned about dependency injection in great detail. You now know what the difference is between constructor injection and dependency injection when using the new `inject` function. We also created some `provides` arrays and demonstrated how to declare the injection value and token. Lastly, you learned about the injector hierarchy and how you can control the provider that should be used by applying resolution modifiers.
In the next chapter, we will learn about Angular directives, pipes, and animations.













第三章:使用指令、管道和动画增强您的应用程序

在构建前端应用程序时,我们经常需要在 HTML 模板中增强、转换、添加、删除或替换 DOM 元素和值。Angular 框架通过使用指令、管道和动画来简化这一过程。本章将解释如何在 Angular 中创建和使用指令、管道和动画。到本章结束时,您将了解指令的所有细节,从指令组合到使用强大选择器创建指令。您还将了解如何创建自定义管道并有效地使用内置管道。最后,我们将探讨如何在使用 Angular 构建的应用程序中构建和重用动画。

本章将涵盖以下主要主题:

  • 使用和创建 Angular 指令

  • 使用 Angular 管道转换值

  • 创建和重用惊人的动画

使用和创建 Angular 指令

指令分为两种不同类型:属性指令结构指令。Angular 有一个内置指令列表,并允许您创建自己的指令以覆盖您的个人用例。自 Angular 15 以来,引入了一个新功能:指令组合。指令组合允许您在组件装饰器内部分配指令,而不是在它们的模板中。指令组合还可以用于在其它指令的装饰器内部声明指令,从而实现同时应用多个指令的指令。

当您想在独立组件中使用指令时,需要将指令添加到组件的 imports 数组中。如果是内置指令,您还可以导入 CommonModule,因为 CommonModule 包含所有内置指令。当您使用内置的 Nx 生成器生成组件时,默认会添加 CommonModule。本节将向您介绍有关指令的所有内容,从属性指令开始。

Angular 属性指令

属性指令作为修改 DOM 元素属性、行为或外观的工具。指令根据装饰有指令装饰器的类中定义的逻辑进行修改。属性指令通过将属性指令的选择器添加到 HTML 标签中分配,如下所示:

<div appRedBackgroundHover. When you use an attribute directive with an input, you use the square bracket syntax like this:

点击

 In the preceding example, `ngClass` is the attribute directive, and we add it between square brackets because it receives an input. Now that you know how to use attribute directives in HTML templates, let’s explore the most commonly used built-in attribute directives and, after that, create a custom attribute directive.
Common built-in attribute directives
The most commonly used built-in attribute directives are the following:

*   `ngClass` or `class`: These directives are used to conditionally add CSS classes.
*   `ngStyle` or `style`: These directives are used to conditionally add inline styling.
*   `ngModel`: This directive is used for two-way data binding on “from” elements.

More built-in attribute directives exist, but these additional built-in directives belong to specific Angular packages, such as `routerLink` from the `formGroup` from the `routerLink` in *Chapter 2*, and we will learn more about directives from the forms package in *Chapter 4*. Now, let us learn how to create custom attribute directives.
Creating custom attribute directives
We will start by creating a new library with our custom generator. Name the new library `common-directives` and select `libs\shared\ui\common-directives\src\lib folder`, right-click, and select `directive` and select **@nx/angular – directive**. Now, follow these steps:

1.  Enter `highlight` for the **name*** field.
2.  Click on **Show** **all options**.
3.  Check the **standalone** checkbox.
4.  Click on **generate** in the top right of the window.
5.  When the directive is generated, export the directive in the `index.ts` file of the library:

export * from './lib/highlight.directive';


 When you look at the `highlight.directive.ts` file, you will see an empty class with a directive decorator above it:

@Directive({

selector: ‹[btLibsUiHighlight]›,

standalone: true,

}) export class HighlightDirective {}


 Inside the decorator, you’ll see the standalone flag set to true, indicating that the directive is standalone and doesn’t have to be included in an `NgModule`. Besides that, you’ll find the directive selector. The selector is used to apply the directive.
Now, let’s add some logic in the `HighlightDirective` class so that our directive does something. Start by injecting `ElementRef`. `ElementRef` gives you access to the host DOM element, the HTML elements applying the directive. After injecting `ElementRef`, you need to use `ElementRef` to adjust the host element that applies the directive. Here is an example highlighting the host element with a background color:

导出类 HighlightDirective 实现 OnInit 接口 {

private el = inject(ElementRef).nativeElement;

ngOnInit() { ElementRef 通过使用构造函数注入代替 inject 函数,然后在构造函数的功能括号内添加背景颜色和文字颜色。或者,如果您只想在宿主元素上设置样式、CSS 类或属性,可以使用 @HostBinding() 装饰器:

@HostBinding('style.backgroundColor') get color() { return 'red'; }

现在,要应用这个指令,你需要在独立组件或 NgModule 中导入它,并在 HTML 元素上使用选择器,如下所示:

<div @Input() decorators. The most common way to pass a value to a directive is to add an @Input() with the same name as the directive selector. In our case, this would look as follows:

@Input() btLibsUiHighlight!: string;


 Now, you can use the `btLibsUiHighlight` property to assign the background color:

this.el.style.backgroundColor = this.btLibsUiHighlight;


 To give the directive input a value, you need to add square brackets around the directive when you declare it on an HTML element and give it the value you want:

`<div [btLibsUiHighlight]="'orange'">我被高亮显示


 If you don’t like using the selector name in your TypeScript file but instead use a more descriptive property name, you can alias the input like this:

@Input('btLibsUiHighlight') background!: string;


 This will probably give you a lint error because aliasing inputs is not generally recommended. Still, you can disable the lint error in this scenario if you prefer a more descriptive property name.
If you also want to customize the text color, add another `@Input()` property to the directive class. We will name the input property `textColor` and assign it a default value of `white`:

@Input() textColor = 'white';


 This is how you assign additional inputs and their values in the HTML template:

`<div [btLibsUiHighlight]="'orange'" btLibsUiHighlight 输入是必需的;额外的输入是可选的,但请记住为它们提供一个默认值;否则,你可能会遇到错误或不受欢迎的 UI 行为。你可以将两个输入都设置为可选的,并带有默认值;你必须重命名输入,以便没有输入与指令选择器同名:

@Input() background = 'black';
@Input() textColor = 'white';

接下来,你通过移除方括号并添加额外的输入来更改 HTML 元素上的指令,仅当你想要覆盖默认值时:

<div @HostListner() directive. Inside the function brackets of the @HostListner() directive, you need to add the browser event you want to listen for. This is how we can adjust our directive so it will apply the highlight for mouseenter and restore the original text and background color for mouseleave:

private el = inject(ElementRef).nativeElement;

private originalColor = 'black';

private originalBackground = 'white';

@HostListener('mouseenter') onMouseEnter() {

this.originalColor = this.el.style.color;

this.originalBackground = this.el.style.backgroundColor;

this.el.style.backgroundColor = this.background;

this.el.style.color = this.textColor;

}

@HostListener('mouseleave') onMouseLeave() {

this.el.style.backgroundColor = this.originalBackground;

this.el.style.color = this.originalColor;

}


 We’ve added a property to save the original background and text color to restore that for `mouseleave`. Lastly, we’ve added the host listeners so we can react to the `mouseenter` and leave the events of the host element. We’ve added the logic to adjust the background and text color inside the host listeners.
Now that you know how to create custom attribute directives and enhance them with inputs and host listeners, let’s explore structural directives.
Angular structural directives
**Structural directives** represent a tool for adding and removing DOM elements based on logic. The logic of when to add or remove DOM elements is defined in the directive class. Defining structural directives in your HTML templates is achieved like so:

<div *ngFor>指令;正如你所见,它以一个星号开头。这个星号是结构指令的典型前缀,有助于你在 HTML 模板中区分它们和属性指令。结构和属性指令之间的另一个区别是,你可以在 DOM 元素上声明多个属性指令,但只能有一个结构指令。如果你想应用多个结构指令,你需要用 ng-container 标签包裹 DOM 元素,并将额外的指令添加到 ng-container 标签中:

<ng-container *ngIf="expression">
  <div *ngFor=»let item of list»>{{item}}</div>
</ng-container>

Angular 提供了内置指令,并允许你创建自己的。首先,让我们看看一些常见的内置结构指令

常见的内置结构指令

最常用的内置结构指令如下:

  • *ngIf:这个指令用于有条件地显示或隐藏 DOM 元素(或者(相对于指令),你可以使用新的@if控制流语法,如第二章所示)。

  • *ngFor:这个指令用于在 HTML 模板中创建 for 循环,并为数组中的每个项目输出一个 DOM 元素(或者(相对于指令),你可以使用新的@for控制流语法,如第二章所示)。

  • *ngSwitch:这个指令用于在 HTML 模板中创建 switch case,并显示匹配的 switch case 的 DOM 元素(或者(相对于指令),你可以使用新的@switch控制流语法,如第二章所示)。

如果你曾经开发过 Angular 应用程序,你很可能已经见过并使用过所有这些结构指令,因为它们真的很常见。尽管如此,*ngIf*ngFor 指令还有一些额外的、不太为人所知的属性,我想解释并展示一下。之后,我们将创建一个自定义结构指令。

充分利用 *ngIf

*ngIf 指令用于有条件地显示 DOM 元素。DOM 元素不会渲染,除非属性或语句评估为 true。通常,当条件为 true 时,你需要显示一个 HTML 块,而当条件为 false 时,则显示另一个。一个常见的解决方案是使用两个 *ngIf 指令和一个相反的语句,如下所示:

<div *ngIf="showContent">Show if true</div>
<div *ngIf="!showContent">Show if false</div>

这是一个完全有效且非常易读的语法,但 *ngIf 也允许你创建一个 if-else 语句。你可以使用以下语法来做这件事:

<div *ngIf="showContent; else elseBlock">Show if true</div>
<ng-template #elseBlock>
  <div>Show if false</div>
</ng-template>

如你所见,这在 HTML 模板中略显笨重,因为你需要使用 ng-template 标签来显示当 else 语句被触发时的内容。你想要使用什么语法完全取决于你;没有更好的或更差的方法;这更多的是关于个人喜好。

有效使用 *ngFor

当使用 *ngFor 时,你可以添加许多属性来增强指令的使用。*ngFor 指令为列表中的每个项目输出 DOM 元素。通常,当你为列表输出 DOM 元素时,你想要知道当前索引,如果某个元素是第一个或最后一个元素,或者它是一个奇数或偶数索引。基于这些值,你可能想要添加一些样式类或在使用模板时使用特定的属性。*ngFor 指令允许你检测这些值,如下所示:

<div *ngFor="let item of list;
    let i = index; let isFirst = first;
    let isLast = last; let isEven = even; let isOdd = odd»
>
  Item at index {{ i }}: {{ item }}
  Is first: {{ isFirst }} Is last: {{ isLast }}
  Is even: {{ isEven }} Is odd: {{ isOdd }}
</div>

如你所见,你可以在定义 *ngFor 指令的 HTML 标签中添加变量来访问 indexfirstlastoddeven 等值。如果我们 list 是以下数组:[0, 1, 2, 3],那么前面的代码片段将在浏览器中输出以下结果:

Item at index 0: 0 Is first: true Is last: false Is even: true Is odd: false
Item at index 1: 1 Is first: false Is last: false Is even: false Is odd: true
Item at index 2: 2 Is first: false Is last: false Is even: true Is odd: false
Item at index 3: 3 Is first: false Is last: true Is even: false Is odd: true

除了 indexfirstlastoddeven 属性外,*ngFor 还有一些其他属性可以用来提高其性能。

默认情况下,当你使用 *ngFor 渲染某些内容并且列表中发生变化时,Angular 会重新渲染整个列表。正如你可以想象的那样,这会对你性能产生负面影响。你可以添加 trackBy 函数来改善这一点。当你使用 trackBy 函数时,Angular 会通过 indexID 来识别每个项目。通过这样做,它只会重新渲染发生变化的内容。建议你尽可能多地使用 trackBy 函数。在模板中,你可以这样定义 trackBy 函数:

<div *ngFor="let item of users; trackBy: trackByFunction">
{{ item }} </div>

在你的组件类中,你可以这样定义 trackBy 函数:

trackByFunction(index, user) { return user.id; }

现在你已经了解了 *ngIf*ngFor 的隐藏功能,让我们看看你如何可以创建自定义结构指令。

创建自定义结构指令

创建自己的结构化指令与创建属性指令类似,但指令类有一些关键的区别。此外,使用场景也不同。自定义属性指令对于诸如自动聚焦元素、将不同主题应用于特定元素、突出显示、文本缩放、工具提示和弹出窗口,以及添加 CSS 类、aria 属性或 ID 等很有用。自定义结构化指令用于删除或添加 DOM 元素;一些好的用例包括if false指令、"重复 x 次"函数,以及根据权限或特定窗口大小显示或隐藏元素。

我们将创建一个自定义指令,当条件为假时显示一个元素,这基本上是*ngIf指令的相反。首先,使用与生成自定义属性指令相同的步骤生成该指令(参见创建自定义属性指令部分)。唯一的区别将是名称;这次,将指令命名为ifFalse

当你的指令类生成后,你可以开始添加你的结构化指令的逻辑。在属性指令中,你注入了ElementRef;对于结构化指令,你必须注入TemplateRefViewContainerRef。当你将结构化指令添加到 HTML 元素时,Angular 会将其转换为嵌入式模板,使用类似于这样的ng-template标签:

<ng-template [ ngIf ]="condition">
  <div>Shown when condition is true</div>
</ng-template>

Angular 创建的嵌入式模板是使用TemplateRef访问的。嵌入式模板在没有结构化指令使用ViewContainerRef将其添加到视图容器之前不会渲染。ViewContainerRef为你提供了访问定义指令宿主元素的视图。

让我们从向你的指令类添加TemplateRefViewContainerRef开始。你可以通过构造函数注入或使用inject函数来注入TemplateRefViewContainerRef;我将像这样使用inject函数:

private templateRef = inject(TemplateRef);
private viewContainer = inject(ViewContainerRef);

我们还必须跟踪是否已经将嵌入式视图添加到视图容器中。为此,在指令类中添加另一个私有属性:

private embeddedTemplateAdded = false;

接下来,我们需要为我们的if false指令提供一个@Input(),这样我们就可以给它一个条件来评估。我们将使用@Input()设置器,因此每次它都会接收到一个新的值;当条件评估时,我们可以执行添加或删除嵌入式模板到视图的逻辑。@Input()设置器需要与指令选择器具有相同的名称:

@Input() set btLibsUiIfFalse(condition: boolean) {}

@Input()设置器内部,如果条件评估为false并且我们尚未将其添加到嵌入式模板中,我们将添加嵌入式模板作为视图容器的嵌入式视图。在if false指令的情况下,当条件评估为真,并且考虑到我们已经添加了嵌入式模板,我们希望清除视图容器,以便移除之前添加的嵌入式视图,并且 Angular 再次渲染原始 HTML,而不添加额外的嵌入式模板。为了实现这一点,你可以按如下方式更改@Input()

@Input() set btLibsUiIfFalse(condition: boolean) {
 if (!condition && !this.embeddedTemplateAdded) {
   this.viewContainer.createEmbeddedView(this.templateRef);
   this.embeddedTemplateAdded = true;
 } else if (condition && this.embeddedTemplateAdded) {
   this.viewContainer.clear();
   this.embeddedTemplateAdded = false;
 }}

正如你所见,当我们的检查通过时,我们会将TemplateRef作为一个嵌入式视图添加;否则,我们会清除视图容器,因为我们已经添加了TemplateRef

我们现在可以使用我们的自定义结构指令,就像使用其他任何指令一样,通过使用带有前缀星号的指令选择器:

<div *btLibsUiIfFalse="condition">shown when false</div>

现在你已经了解了如何使用内置指令、创建自定义属性和结构指令,让我们学习一下你可以使用指令选择器做什么。

指令选择器

btLibsUiHighlight指令并更改选择器,以便它默认应用于所有span元素:

selector: 'span, [btLibsUiHighlight]',

当你使用前面的示例作为选择器时,指令将自动应用于所有span元素,并且你可以使用btLibsUiHighlight选择器将其添加到其他元素。现在,假设你需要一个选项来排除一些span元素,那么默认情况下,所有span元素都会收到高亮指令,但当你想要退出时,你可以这样做。为了实现这一点,你可以在选择器中添加:not语法,如下所示:

selector: 'span:not([noHighlight]), [btLibsUiHighlight]',

现在,所有span元素都将应用高亮指令,除非你将noHighlight添加到span元素中。对于所有其他元素,你仍然需要添加btLibsUiHighlight以应用指令:

<span noHighlight>Test</span>

如果你想要排除 HTML 元素,你可以使用:not语法来实现,如下所示:

selector: '[btLibsUiHighlight]:not(label)',

当你使用前面的选择器时,你可以将btLibsUiHighlight指令添加到所有元素,但不能添加到label元素。当你尝试将指令添加到label元素时,编译器将抛出错误。

你也可以创建选择器,将指令应用于具有特定 ID、数据属性或应用于 HTML 元素的 CSS 类的 HTML 元素。以下是这三个选项的示例:

selector: '#someId, .someCssClass, [data-highlight="true"]'

现在你已经了解了关于内置指令、自定义指令和指令选择器的所有内容,让我们继续学习指令组合。

Angular 指令组合

指令组合是一个相对较新的概念,它在 Angular 15 版本中被引入。正如其词义所示,指令组合允许你在组件和指令上组合不同的指令。它允许你在组件和指令类装饰器中声明指令,而不是使用 HTML 模板添加它们。你可以使用指令组合来自动将指令应用于组件,就像指令选择器一样。指令组合还可以用来创建使用单个选择器应用多个指令的指令。

假设我们有一个标签和按钮组件以及一个类型和大小指令,允许你设置类型(主要或次要)和大小(小、中或大),这将为宿主元素应用特定的 CSS 类。如果你想自动将这两个指令应用于所有的按钮和标签,你可以使用指令组合来实现这一点。在组件装饰器内部添加一个 hostDirectives 数组以将指令添加到组件中。在 hostDirectives 数组中,你可以添加包含指令和装饰器输入的对象。如果装饰器没有输入,你可以将装饰器类添加到 hostDirectives 数组中。如果你总是想使用输入的默认值(假设输入有默认值),你不需要在 hostDirectives 数组中声明输入:

@Component({ ………, hostDirectives: [
{directive: TypeDirective, inputs: ['btLibsUiType']},
{directive: SizeDirective, inputs: ['btLibsUiSizeType']}]})
export class ButtonComponent { }

在定义了 hostDirectives 数组中的对象之后,当你在一个模板中声明按钮或标签组件时,这两个指令将自动应用。当使用指令组合时,你还可以为指令的输入值设置别名:

{directive: TypeDirective, inputs: ['btLibsUiType: style']}

现在,在你的 HTML 模板中,如果你想为 TypeDirectivebtLibsUiType 输入提供一个值,你可以使用以下语法:

<bt-libs-button [style]="'secondary'">XX</bt-libs-button>

在指令内部使用指令组合与在组件中相同。假设我们有一个 backgroundColorDirectivetextColorDirective;我们可以在 backgroundColorDirectivehostDirectives 数组中声明 textColorDirective。现在,当你使用 backgroundColorDirective 时,这两个指令都将应用,并且两个指令的输入都将暴露出来,前提是你已经在 backgroundColorDirectivehostDirectives 数组中定义了 textColorDirective 的输入。

当使用指令组合时,你需要使用独立的指令。否则,它将不会工作。此外,每次创建一个组件时,hostDirectives 数组中声明的所有指令都会创建一个新的实例。因为每个指令实例都是为宿主组件的每个实例创建的,所以在使用指令组合时必须小心。当你将太多指令放入常用组件内部时,你的内存使用量将会激增,并会负面影响你应用程序的性能。

在本节中,你学习了属性指令、结构指令、指令选择器和指令组合。现在我们将继续学习本章的下一部分,开始了解如何使用 Angular 管道来转换值。

使用 Angular 管道转换值

在 Angular 中,管道用于转换值。Angular 提供了许多有用的内置管道,并允许你创建自己的管道。让我们首先列出最强大和最常用的内置管道,并简要说明它们的使用目的:

  • AsyncPipeAsyncPipe用于处理模板中的异步值。它自动订阅并自动取消订阅,以防止内存泄漏。建议尽可能多地使用AsyncPipe

  • UpperCasePipe:此管道用于将文本值转换为全部大写字母。

  • LowerCasePipe:此管道用于将文本值转换为全部小写字母。

  • TitleCasePipe:此管道用于将每个单词的首字母大写。

  • CurrencyPipe:此管道用于将数值转换为带有货币符号的货币值。你还可以控制小数格式。

  • DatePipe:此管道用于根据你指定的格式格式化日期值。

如果你想要探索所有内置管道,你可以在这个网址找到完整的列表:angular.io/guide/pipes

现在你已经了解了最常用的内置管道,让我们看看如何使用管道。

在 HTML 模板和 TypeScript 文件中使用管道

管道通常用于 HTML 模板,但你也可以在 TypeScript 文件中使用它们。要在 HTML 模板中使用管道,你可以使用以下语法:

<div>{{currentDate | date}}</div>

在左侧,你有一个属性或值;然后,你通过使用垂直线(管道符号:|)来指示你将使用一个管道;在管道符号的右侧,你声明你想要使用的管道的名称;在我们的例子中,它是date。如果你的管道需要参数,你可以通过添加冒号并提供值来提供参数:

<div>{{currentDate | date: 'YYYY-MM-dd'}}</div>

当管道需要多个参数时,你可以通过添加另一个冒号并将值附加到冒号之后来将它们链接在一起,如下所示:

<div>{{currentDate | date: 'YYYY-MM-dd':'GMT'}}</div>

如果你需要应用多个管道,你可以将它们链接到一个值上。当你链式调用管道时,它们将按从左到右的顺序依次执行。链式调用管道使用以下语法:

<div>{{currentDate | date: 'YYYY-MM-dd' | uppercase}}</div>

如前所述,你还可以在 TypeScript 文件中使用管道。尽管管道主要用于 HTML 模板,但它们在 TypeScript 文件中也非常有用。你可以在组件的提供者数组中添加管道,然后通过依赖注入注入管道。注入管道后,你可以在组件类中通过调用transform方法来使用它:

const formattedDate = this.datePipe.transform(this.currentDate, 'dd/MM/yyyy');

当使用独立组件(正如我们正在做的那样)时,在可以使用管道之前,你需要将管道导入到组件中。你可以导入包含所有管道的CommonModule,或者如果你是一个简单的组件并且不需要CommonModule用于其他目的,你可以导入特定的管道。

现在你已经知道了如何在 HTML 模板和 TypeScript 文件中使用管道,让我们来学习纯管道和不纯管道。

它是纯的还是不纯的?

Angular 管道有两种类型:带有pure false标志。纯管道和不纯管道之间的区别在于它们的更新行为以及 Angular 如何对它们运行变更检测。

在创建纯管道时,你需要使用纯转换函数。纯函数是一个在给定相同输入时总是返回相同输出的函数。

Angular 仅在检测到输入值的纯变化时才运行纯管道。纯变化是指对原始值(数字、字符串、布尔值、bigint、symbol、undefined 和 null)的更改,或者当提供一个新引用对象(日期、数组、函数或对象)时。对引用对象的更改不被视为纯变化。因此,如果你有一个接受数组作为值的纯管道,更新数组将不会触发管道,因为这是一种不纯的变化。当你使用新数组分配属性时,管道将运行,因为它接收到了一个新的引用对象,这被视为纯变化。

当运行纯管道时,Angular 会跳过对引用对象的更新,因为检测纯变化比在对象上执行深度检查要快得多;正因为如此,Angular 可以快速确定你的管道是否需要再次执行,或者管道是否可以跳过。如果 Angular 必须在每个变更检测周期中执行深度检查或运行你的管道,这将极大地影响应用程序的性能。

因此,记住当你使用带有引用对象的纯管道时,除非你知道自己在做什么,否则你并不总是能得到你期望的结果。例如,假设你有一个仪表盘数组和一个管道,它只过滤数组以包括像这样的活动仪表盘:

<div *ngFor="let dashboard of dashboards | active">
    {{dashboard.name}}
</div>

现在,当使用push更新仪表盘数组时,管道将不会运行,因为仪表盘数组的引用没有改变。如果你使用新数组分配仪表盘的属性,引用将改变,Angular 的变更检测将触发活动管道并按预期过滤结果。

当使用不纯的管道时,Angular 会每次检测到变化时执行管道。这意味着 Angular 会每次按键或鼠标移动时运行管道。不纯的管道可能很有用,并且会按预期更新引用对象,但使用不纯管道时要小心,因为它们可能会显著减慢您的应用程序。当您使用不纯的管道时,您希望将组件的变更检测策略设置为 OnPush,这样您的管道就不会被频繁执行。当组件的变更检测设置为 OnPush 时,变更检测仅在组件接收到新的输入值或您手动触发时才会运行。尽可能地将变更检测策略设置为 OnPush 是一个好习惯,因为它将有助于提高您应用程序的性能。

现在您已经了解了纯管道和不纯管道之间的区别,让我们更多地了解 AsyncPipe,因为它是 Angular 提供给我们最重要的内置管道。在了解 AsyncPipe 之后,我们将学习如何创建自定义管道。

使用 AsyncPipe

最强大的内置管道是 AsyncPipe。尽管 AsyncPipe 是一个不纯的管道,但建议尽可能多地使用它来处理在模板中使用的可观察对象和承诺结果。使用 AsyncPipe 相比在组件类中使用订阅处理可观察对象具有优势。

首先,AsyncPipe 会自动订阅和取消订阅可观察对象,更重要的是,它会自动取消订阅。这一点非常重要,因为它可以防止内存泄漏。如果您没有正确清理订阅,最终会导致内存泄漏,您的应用程序将开始变慢,并显示出意外的行为,甚至可能崩溃。

为了演示 AsyncPipe,我们将在组件类中使用 RxJS 的 interval 操作符创建一个可观察对象,如下所示:

timer: Observable<number> = interval(2000);

这个 interval 可观察对象将每 2 秒发出下一个索引,从 0 开始。因此,2 秒后,可观察对象发出 0,再过 2 秒,可观察对象发出 1,依此类推。

我们可以在组件类内部订阅 interval 可观察对象,并将结果分配给我们在模板中显示的组件属性:

this.timer.subscribe((n) => { this.count = n; });

接下来,您可以在模板中使用 count 属性:

<div>{{count}}</div>

如果您为组件使用 OnPush 变更检测,您需要每次可观察对象接收到新值时手动调用变更检测。否则,模板中的 count 属性将不会更新。在上述方法中,您还必须添加逻辑,在组件销毁或可观察属性分配给另一个可观察对象时取消订阅您的可观察对象。现在,让我们看看如何使用 AsyncPipe 在我们的 HTML 模板中利用间隔可观察对象:

<div>{{timer | async}}</div>

如你所见,使用AsyncPipe很简单。你声明使用可观察者分配的属性(在我们的例子中,它被命名为timer),并在其旁边添加AsyncPipe。每当间隔可观察者发射新的值时,它都会反映在我们的模板中。不需要额外的属性来保存可观察者的结果,不需要取消订阅,也没有内存泄漏的风险!即使你使用新的可观察者分配timer属性,异步管道也会自动取消订阅旧的可观察者并订阅新的可观察者。

当使用异步管道时,建议使用OnPush变更检测,因为AsyncPipe是非纯的。AsyncPipe的另一个优点是,当管道接收到新值时,它会自动标记需要检查更改的组件模板。当你将变更检测策略设置为OnPush时,这很有用。当使用常规的可观察者订阅时,如果你使用OnPush策略,HTML 模板不会被标记为检查更改,这意味着你必须在订阅接收到新值后手动触发变更检测。

现在你对AsyncPipe以及为什么它是一个如此强大的工具有了更多的了解,让我们来探索如何创建你自己的管道。

构建你自己的管道

在我们的 Nx 单仓库中创建管道时,我们将在util库中这样做。在我们的例子中,我们将创建一个简单的管道,该管道将使用指定的因子乘以一个数字。创建此管道的正确位置是在共享域下的一个库中,并且是util类型。使用我们的自定义生成器创建一个名为common-pipes的新库,并选择shared作为其域,util作为其类型。

当你的新库生成后,按照以下步骤生成自定义管道:

  1. 关闭并重新打开 VSCode,以确保你的新库包含在 Nx 图示中。

  2. 右键单击此位置的文件夹:libs\shared\util\common-pipes\src\lib,并选择Nx 生成

  3. 输入pipe并点击@nx/angular – pipe

  4. 名称字段中输入multiply

  5. 点击显示 所有选项

  6. 选择独立复选框。

  7. 在右上角点击生成

  8. 当组件生成后,将以下内容添加到库中的index.ts文件:

    export * from './lib/multiply.pipe';
    

之后,你可以使用你的管道,但在使用之前,让我们添加一些逻辑。

当你打开multiply.pipe.ts文件时,你会看到 Nx 生成了一个MultiplyPipe类,该类实现了PipeTransform接口。该类还装饰了@Pipe()装饰器,其中独立标志设置为true,管道名称multiply被定义。在这个装饰器内部,你可以添加纯标志;你的管道默认是纯的。只有当你想创建一个非纯管道时,你才需要添加纯标志并设置为false。在我们的例子中,我们将创建一个简单、纯的管道,因此不需要将纯标志添加到装饰器中。

Nx 还向类中添加了一个转换函数,以符合PipeTransform接口。转换函数是管道的“难点”,在这里你添加你的转换逻辑。你可以调整转换函数如下:

transform(value: number, multiplier = 2): number {
  return value * multiplier;
}

如你所见,我们有一个值和一个乘数函数参数。值参数是我们在我们 HTML 模板的管道左侧声明的。multiplier是我们冒号后面的参数。我们给multiplier赋予了一个默认值2,所以在模板中声明管道时它是可选的。

当你想使用管道时,首先需要导入它;如果你使用 NgModules,管道需要在 NgModules 实例中导入;如果你使用独立组件,就像我们在这里做的那样,你必须将管道导入到你想要使用它的组件中。在你将管道导入到独立组件后,你可以在模板中使用它,如下所示:

<div>{{10 | multiply}}</div>

如果你想为管道提供一个自定义的乘数值,你可以使用以下语法:

<div>{{10 | multiply: 5}}</div>

如果你想在管道中添加更多参数,你可以在你的转换函数中添加更多参数来实现。假设你想在multiply管道中添加另一个乘数。你可以按照以下方式添加:

transform(value: number, multiplier = 2, additional = 1): number {
  return value * multiplier * additional;
}

现在,你可以在你的 HTML 模板中使用以下语法:

<div>{{10 | multiply: 5: 10}}</div>

我们的示例管道很简单,但你可以在你的转换函数中添加任何你想要的逻辑。只需确保在创建纯管道时使用纯函数,即当给定相同的输入时返回相同值且不影响任何其他代码的函数。当你创建不纯的管道时,确保不要添加耗时或资源密集型的代码,因为这会负面影响你的应用程序性能。以下是如何创建不纯管道的示例:

@Pipe({
  name: 'multiply',
  standalone: true,
  pure: false,
})

现在你已经知道管道用于转换值。Angular 提供了内置的管道用于常见的转换和处理异步值。你知道纯管道和不纯管道之间的区别,并且可以创建你自己的自定义管道。为了完成本章,我们将学习关于 Angular 动画的内容。

创建和重用令人惊叹的动画

在前面的章节中,你已经看到了如何使用指令操作 DOM 元素,以及如何使用管道转换模板值;在本节中,你将学习如何使用内置的动画模块为你的 HTML 元素和组件创建动画。

首先,你必须启用动画模块。为此,请转到你的 Nx 单仓库中expenses-registration application应用程序下的app.config.ts。在app.config.ts中,你会在bootstrapApplication函数内的main.ts中找到appConfig对象。要启用动画模块,请在appConfig对象的提供者数组中添加provideAnimations()函数,如下所示:

provideAnimations(),

如果你正在使用基于 NgModule 的应用程序,你需要在想要使用动画的 NgModule 中导入 BrowserAnimationsModule。在添加了 provideAnimations 函数或 BrowserAnimationsModule 之后,你可以在组件内部开始添加动画。为了演示动画,让我们在 common-components 库中创建一个可选择的标签组件。使用 Nx 生成器创建一个组件,命名为 selectable-label,为项目选择 common-components 库,勾选 OnPush。当组件生成后,在 common-components 库的 index.ts 中添加以下导出:

export * from './lib/selectable-label/selectable-label.component';

现在,将以下代码添加到 component 类中:

@Input() labelText!: string;
@Input() get selected() {
  return this._selected;
}
set selected(selected) {
  this._selected = selected;
  this.animationState = selected ? ‹selected› : ‹deselected›;
}
@Output() selectedChange = new EventEmitter<boolean>();
private _selected = false;
animationState = 'deselected';
onSelectionChanged() {
  this.selected = !this.selected;
  this.selectedChange.emit(this.selected);
}

将以下 CSS 添加到 SCSS 文件中:

span {
  color: white; background-color: #455b66;
  padding: 5px 15px; border-radius: 15px; cursor: pointer;
}

将以下 HTML 添加到 HTML 文件中:

<span (click)="onSelectionChanged()">{{labelText}}</span>

现在我们已经创建了一个简单的标签组件,让我们创建我们的动画。首先,在组件装饰器内部添加 animations 数组,如下所示:

@Component({ ………, animations: []})

在这个 animations 数组内部添加组件的动画。在数组中添加以下动画:

trigger('selectedState', [
  state('selected', style({ backgroundColor: '#382632' })),
  state('deselected', style({ backgroundColor: '#455b66'})),
  transition('selected <=> deselected', [animate('2s')])
])

这是一个简单的动画,将背景颜色从十六进制颜色 #382632 变换到 #455b66,并需要 2 秒来完成过渡。现在让我们逐行检查我们添加的内容。

动画触发器

我们动画的起始触发器:

selectedState. The animation metadata array contains state and transition functions that define the behavior of our animation. Let’s explore these functions in more detail.
Animation state
Inside our animation metadata array, by using our `selectedState` trigger, you’ll find the state functions:

state('selected', style({ backgroundColor: '#382632' })),

state('deselected', style({ backgroundColor: '#455b66'})),


 Your animation can have as many state functions as are needed. You can define animations without a state or with many state functions. Each state defines a state your animation can transition to. If you have an animation without any state functions, you can define the style changes inside the transition functions, and the animation will still run, but after it finishes, the HTML element will be as it was before the animation started. When you have a state, you can transition an element from one state to another. When the animation transitions to a specific animation state, the HTML element will stay styled as the state defined it until the animation transitions the HTML element to another animation state.
Each animation state receives a name to indicate the state and a style function to define the style properties with which to transition to for the specific animation state. It is important to note that the styles are indicated with camel case, so no hyphens are used. The background color CSS property becomes `backgroundColor`.
In our example, we have two states: `selected` and `deselected`. Inside the component class, we also have a property called `animationState`, which holds the current state of our animation. By default, it’s set to `deselected`. When we click on our label, we will set the `selected` property, and inside the setter of our `selected` property, we will set `animationState` to its proper value.
Animation transition
After our state functions, we define a transition function inside the animation metadata array:

transition('selected <=> deselected', [animate('2s')])


 Transition functions specify how to transition from one animation state to another and they can take three parameters: the transition statement, an animation metadata array, and an object that can define a delay for your transition.
Transition expression
The `transition` expression indicates what state transition to cover when using a specific `transition` function. The syntax of the `transition` expression reads from left to right and uses arrows to indicate the state transition. For example, `selected => deselected` would target state transitions from `selected` to `deselected`, `deselected => selected` would target state transitions from `deselected` to `selected`, and `selected <=> deselected` would target state transitions from `selected` to `deselected` and from `deselected` to `selected`. You can also use an asterisk inside your selection expression. The asterisk symbol is a wildcard and stands for every state. For example, `* => deselected` would trigger the transition if any state transfers to `deselected`, and `* => *` would trigger if any state transfers to another.
The `transition` expression has a few more special selectors that are similar to the asterisk. For example, you can use `:enter` and `:leave` as transition expressions. The `:enter` expression will be applied to an element entering the DOM, and `:leave` targets elements that are removed from the DOM. The `:enter` and `:leave` expressions do not care about the animation state an HTML element currently has. These two expressions are useful when combined with the `*ngIf` or `*``ngFor` directives.
You can also use `:increment` and `:decrement` as expressions. The `:increment` and `:decrement` expressions will trigger the animation when the value inside the HTML element is a number, and it gets incremented or decremented.
Animation metadata array
After the transition expression, the `transition` function also takes an animation metadata array as input. In our example, we only declared an `animate` function inside, which indicates how long the transition takes, which is 2 seconds in our case. The animate function can also take a `style` function like this:

animate('2s', style({ color: 'red' })),


 The `style` function inside the `animate` function is useful if you have no states for your animation or if you want to perform additional animations during the transition that will not last once the state transition is finished.
You can also define keyframes for your `animate` function. With keyframes, you can indicate different stages of the animation; the offset defines how far into the animation you are, with `0` defining the start and `1` defining the end of the animation:

animate('2s', keyframes([

style({ backgroundColor: ‹blue›, offset: 0}),

style({ backgroundColor: ‹red›, offset: 0.8}),

style({ backgroundColor: ‹#754600›, offset: 1.0})])),


 Besides the `animate` function, the animation metadata array inside the transition function can take more configurations. The most commonly used are `group` and `sequence`.
The `sequence` function is used to trigger multiple animate steps one after another. These can be steps that lead to the result of the state you are transitioning to or just additional animation steps that are not included in your animation state:

sequence([

animate(‹2s›, style({ backgroundColor: ‹#382632› })),

animate(‹2s›, style({ color: ‹orange› }))

])


 The `group` function is used to group different `animate` functions. When you group `animate` functions, they will be executed simultaneously during the transition. Each group is executed one after another:

group([

animate(‹2s›, style({ color: ‹white› })),

animate(‹2s›, style({ backgroundColor: ‹#455b66› })),

])

group([

animate(‹2s›, style({ fontSize: ‹24px› })),

animate(‹2s›, style({ opacity: ‹0.5› })),

])


 Now that you know how to define animations inside your component class, let’s examine how you can add those animations to HTML elements inside your template.
Adding animations to your template
Adding animations inside your HTML template is pretty straightforward. To add our animation to the selectable label, we need to add the following code line on the span tag:

[@selectedState]="animationState"


 On the left-hand side, you define the animation trigger, which is preceded by an `@` sign and enclosed by square brackets. On the right-hand side, you declare the animation state; in our case, we used the `animationState` property from our component class for this, but you can also add ternary operations.
You can also trigger events when the animation starts or finishes by adding the following code to your HTML tag with the animation defined on it:

(@selectedState.start)="onAnimationEvent($event)"

(@selectedState.done)="onAnimationEvent($event)"


 Lastly, you can disable an animation within HTML child elements based on a Boolean value and the `@.disabled` animation control binding like this:

………

 In the preceding example, all animations inside `div` with `@.disabled` are disabled if the `isDisabled` property is `true`.
Now that you know how to create and use animations, let’s explore how you can reuse animations.
Reusing animations
Creating animations, especially complex ones, can be a lot of work. If you want to apply them within multiple components, you don’t want to add duplicated code and create the same animation multiple times. To reuse animations, you can create an `animations.ts` file in your application or a `utils` library, depending on your use case. You can create exported functions in this file. Here is an example of our animation as a reusable animation:

export function selectedAnimation(): AnimationTriggerMetadata {

return trigger('selectedState', [

state(‹selected›, style({ backgroundColor: ‹#382632› })),

state(‹deselected›, style({ backgroundColor: ‹#455b66›})),

transition(‹selected <=> deselected›,[animate(‹2s›)]),])

}


 Now, inside the component class, you can define the animation as follows:

@Component({

………

animations: [selectedAnimation()],

})


 This maintains readability in your component and allows you to use the animation inside multiple components without creating it again.
Summary
In this chapter, you learned the difference between structural and attribute directives. You learned how to add and remove DOM elements using directives. You’ve learned to change the styling and behavior of DOM elements using directives, and you now know how to listen out for the events of host elements. You can use built-in directives and create your own. Besides directives, you learned how to transform values using Angular pipes. You learned about pure and impure pipes and how they can be made and impact your performance. Lastly, you made your own Angular animation and learned about animation triggers, states, and transform functions. Now, you know how to declare animations in your templates and how to reuse them throughout your application.
In the next chapter, you will learn about reactive and template-driven forms.




第四章:像专业人士一样构建表单

表单是许多前端应用的核心。它们允许您收集用户输入以保存并处理您的应用用户提供的数据。构建表单是 Angular 擅长的方面之一。Angular 提供了两种不同的构建表单的方法,这两种方法都提供了工具来验证单个表单字段以及整个表单的有效性。

在本章中,您将学习如何创建 Angular 的模板驱动响应式表单,同步您的表单字段与应用程序状态,验证表单和单个表单字段,并了解动态表单创建等高级概念。到本章结束时,您将能够像专业人士一样创建表单!

本章将涵盖以下主要主题:

  • 理解 Angular 中不同类型的表单

  • 构建模板驱动表单

  • 构建响应式表单

  • 动态创建表单

理解 Angular 中不同类型的表单

Angular 提供了两种不同类型的表单:模板驱动表单和响应式表单。本节将讨论模板驱动表单和响应式表单之间的区别,并帮助您评估哪种方法最适合您的需求。

让我们先解释一下构建表单的两种方法的关键特点。

模板驱动表单的特点

模板驱动表单是一种构建表单的方式,其中 HTML 模板扮演着核心角色。模板驱动表单在 HTML 模板中隐式定义表单控件和验证规则,使用指令。这意味着表单控件和验证规则是由您放置在 HTML 元素上的指令创建和管理的,而不是在 TypeScript 文件中手动创建表单控件和验证,并直接与表单 API 进行通信。

模板驱动的方法依赖于使用ngModel指令进行双向数据绑定,以同步用户输入和程序性更改的数据模型中的变化。当您将ngModel指令添加到 HTML 元素时,该指令会为您创建并管理一个FormControl实例。这些由ngModel指令创建的FormControl类用于跟踪和验证表单中单个字段的状态。

当使用模板驱动表单时,表单数据是可变的,这意味着您不是使用表单 API 来更新表单值,而是直接更改在FormControl实例中使用的值。

重要提示

还需要注意的是,模板驱动表单的数据流是异步的,通过事件和订阅进行更新。虽然这种异步行为由系统管理,但了解这一点很重要,因为它可能会影响测试,并且如果您不知道数据流是异步的,有时会导致意外的行为。

模板驱动的表单易于构建且快速,与信号(我们将在第七章中了解更多关于信号的内容)结合使用效果很好,可以减少触发变更检测的次数,从而提高性能。

然而,模板驱动的表单的数据模型和表单控件复用和测试起来比较复杂;此外,在构建大型表单时,你的 HTML 模板可能会变得臃肿。当创建需要严格测试、可重用表单模型和控件或需要动态构建方式的表单时,响应式表单可能更为合适。

响应式表单的特点

响应式表单提供了一种灵活的、以模型驱动的创建表单的方法。与模板驱动的表单相比,响应式表单提供了更多的程序化控制。在使用响应式方法时,你通过在 TypeScript 文件中创建FormGroupFormControlFormArray实例来显式定义表单模型。响应式表单的显式特性允许实现更复杂的逻辑、易于测试以及更好的表单控件和模型的重用性。在响应式表单中,验证规则也通过Validator类或自定义验证器显式定义,从而允许更复杂的验证。

响应式表单提供了对表单状态的精细控制,允许你通过程序方式设置、获取和操作值。一旦创建了一个表单及其表单控件,就不能直接修改其值。这使得响应式表单不可变。不可变的表单提供了一个更可靠的数据模型,这反过来又导致错误更少。

由于你直接使用表单 API 在你的 TypeScript 文件中定义表单模型,因此为响应式表单创建的表单模型可以很容易地复用和测试。在编写测试时,响应式表单非常简单,因为你可以直接在测试中使用表单 API,就像你在组件类中做的那样。

响应式表单在视图(我们所说的视图是指浏览器中显示的 HTML 模板)和数据模型之间具有同步的数据流。因此,Angular 可以精确地知道何时在响应式表单上运行变更检测,从而提高性能。

与模板驱动的表单相比,响应式表单虽然需要更多的初始设置,但在处理大型、复杂的表单时更为合适,因为在这种情况下,额外的控制、测试以及表单控件和模型的可重用性至关重要。

模板驱动的表单和响应式表单之间的主要差异

下表概述了模板驱动的表单和响应式表单之间的差异:

模板驱动的表单 响应式表单
表单、模型和验证创建 隐式使用 HTML 模板中的指令 显式使用 TypeScript 文件中的类
设置和创建表单 易于设置和简单 需要更多的初始设置,可能感觉更复杂
数据模型 非结构化和可变 结构化和不可变
数据流 异步 同步
与信号兼容 不好
测试性 单元测试困难 使用表单 API 容易测试
表单的可重用性和动态创建 更难重用或动态构建 容易重用和动态构建

表 4.1:模板驱动表单和响应式表单之间的关键区别

现在你已经了解了 Angular 模板驱动和响应式表单的关键特性,让我们深入探讨并学习如何创建这两种类型的表单。

构建模板驱动表单

在本节中,我们将构建一个模板驱动表单。你将学习如何将数据绑定到输入字段,分组表单字段,并在模板驱动表单中执行内置和自定义验证规则。你还将了解模板驱动表单在幕后是如何工作的,以更好地理解模板驱动表单。

到本节结束时,你将能够构建健壮的模板驱动表单,并为我们的演示应用程序创建一个模板驱动表单来添加费用。

创建带有表单组件的表单库

在我们开始创建表单之前,我们需要一个新的库。我们将使用我们在第一章中制作的自定义 Nx 生成器来生成新的库。

你可以就如何分离表单库进行辩论。你可以创建一个包含特定领域所有表单的库,为特定领域的每个应用程序创建一个表单库,或者为每个表单创建一个新的库。

使用每个表单的单个库是使用 Nx 缓存和增量构建系统的最佳方式,但它也在开发和维护方面带来了一些额外的开销。如果你的组织有很多在多个应用程序之间重用的表单,将它们拆分到单独的库中可能值得额外的设置,因为它将加快你的构建和管道。

在我们的示例中,我将创建一个专门用于 expenses-registration 应用程序 的表单库,以便该库将包含此特定应用程序的所有表单:

  1. 运行自定义 Nx 生成器以创建库。将其命名为expenses-registration-forms

  2. 选择域为finance,类型为ui。然后,点击生成

  3. 一旦生成了库,重新启动 VS Code,以便 Nx 规范与你的新库更新。

  4. 我们将使用 Nx 生成器为我们的模板驱动表单创建一个组件。将组件命名为add-expense

  5. 选择为项目创建的新库,勾选独立复选框,点击显示所有选项,并为changeDetection选项选择OnPush

  6. 在右上角点击生成

  7. 一旦生成了组件,将其导出到库的index.ts文件中。我喜欢以form结束表单选择器,所以我将组件选择器重命名为bt-libs-ui-add-expense-form

现在我们已经创建了库和组件,我们可以开始创建模板驱动的表单。

创建模板驱动的表单

我们将首先创建一个简单的 HTML 表单,然后逐步将其转换为 Angular 模板驱动的表单。将以下内容添加到您的add-expense.component.html文件中:

<form>
  <div class=»form-field">
    <label for=»description»>Description:</label>
    <input type=»text» id=»description» name=»description»>
  </div>
  …………
  <button type=»submit»>Submit</button>
</form>

您可以用任何您想要的额外表单字段替换。在这个例子中,我将拥有四个字段:描述不含增值税的金额增值税百分比日期

您可以在本书的 GitHub 仓库中找到完整的表单和样式:github.com/PacktPublishing/Effective-Angular/tree/feature/chapter-four/building-forms-like-a-pro

AddExpenseForm类导入到expenses-overview-page.component.ts中,并在相应的 HTML 文件中添加组件选择器以显示我们的 HTML 表单。当您点击提交按钮时,您会注意到页面被重新加载;这是提交表单时的默认原生行为。然而,当使用 Angular 等框架构建现代应用程序时,我们期望更好的用户体验,即在不重新加载页面的情况下处理表单提交,就像我们路由到页面而不重新加载页面一样。

我们将通过导入FormsModule开始将我们的原生 HTML 表单转换为 Angular 模板驱动的表单。这个FormsModule包含了我们构建模板驱动的表单所需的所有指令。如果您正在使用NgModules构建,您必须在相应的NgModule中导入FormsModule。我们正在使用独立组件,因此我们将FormsModule导入到构建表单的组件中。将FormsModule添加到我们新创建的add-expense组件的组件装饰器的imports数组中:

imports: [CommonModule, FormsModule, the page won’t be reloaded when you click your form’s submit button, which is precisely what we wanted.
But how can this be, since we didn’t change our form? The Angular `ngForm` directive prevents the default browser behavior when submitting the form, and because of that, the page isn’t reloading. But we didn’t add the directive to our form, so why isn’t the page reloading?
The answer lies in the selector of the `ngForm` directive. When we inspect the selector of the `ngForm` directive, we can see that the directive is automatically applied to all form tags, and because we’ve added `FormsModule`, the `ngForm` directive is applied to the form in our component. Even though the directive is automatically applied to HTML form tags, it’s advised to write declarative code and manually apply the directive to form tags. So, add the directive to your form tag like this:

………

 On the left, we added a template variable called `#addExpenseForm`, while on the right, we assigned this template variable with an instance of the `ngForm` directive. By assigning the directive to a template variable, we can use the directive in all sibling and child elements of our HTML form tag.
Now that we’ve added the forms module and the `ngForm` directive, we can start to configure the fields of our template-driven form.
Configuring template-driven form fields
To have a template-driven form, we need to connect the form’s input fields to an object in our component class and bind the fields to our `ngForm` instance to validate the entire form.
Let’s start by creating an interface for the object we want to use in our form.
In the `add-expense` folder, create a new file, `add-expense.interface.ts`, and add the interface reflecting the fields in your form.
For our example, this is the interface definition:

export interface AddExpense {

description: string;

amountExclVat: number | null;

vatPercentage: number | null;

date: Date | null;

}


 Also, export the interface in the `index.ts` file of the library. Now, in `add-expense.component.ts`, define a property of the `AddExpense` type, as follows:

@Input() expenseToAdd: AddExpense = { description: '', amountExclVat: null, vatPercentage: null, date: null }


 Here, we used an input with a default value. We used an input because the form will be a dumb component, and the parent component will input any default values other than empty values.
Note that you don’t have to use an object in a template-driven form; you can also use separated properties or a combination of properties and objects. Once you’ve defined the object, you need to bind the properties of the `expenseToAdd` object to the fields of your form. We can do this using the `ngModel` directive.
When you add the `ngModel` directive, behind the scenes, Angular registers `FormControl` in the `ngForm` instance. The `ngModel` directive allows for two-way data binding, meaning the values of the `expenseToAdd` object and our form will automatically be synchronized if we change the properties in our component class or if the user enters values inside the form inputs.
You connect the properties of the `expenseToAdd` object to the form inputs by adding the `ngModel` directive and the `name` attribute to each of the form inputs, like this:

<input ngModel 指令,就像我们在第二章中为输入和输出进行双向数据绑定时所做的那样。

就像使用双向数据绑定来绑定输入值一样,您也可以将这个ngModel拆分为单独的输入和输出,如下所示:

[ngModel]="expenseToAdd.description" (ngModelChange)="expenseToAdd.description = $event"

重要提示

使用输入和输出分离可以在处理信号或在进行数据模型绑定之前执行额外逻辑时非常有用。

当您创建输入字段时,向字段添加 name 属性很重要,因为这为 ngForm 实例提供了一个唯一的键来跟踪表单字段。如果您没有添加 name 属性,您的浏览器控制台将显示错误。现在,继续添加 ngModel 指令和 name 属性到您表单中的所有字段。在添加所有 ngModel 指令和 name 属性后,您可以通过临时将以下代码添加到您的 HTML 模板中来确认双向数据绑定是否工作:

{{ expenseToAdd | json }}

在将前面的代码添加到您的 HTML 模板后,expenseToAdd 对象将在您的屏幕上以 JSON 格式显示。当您开始在表单输入框中键入时,您可以看到 expenseToAdd 对象的属性正在更新。反之,当您在组件类中为 expenseToAdd 的属性赋值时,表单也会更新,就这样,您已经创建了一个具有双向数据绑定的表单。

ngModelngForm 指令还有一些其他有趣的配置,非常有用。让我们首先检查 ngModelOptions

其他表单字段选项

您可以使用 ngModelOptions 来配置模板驱动表单中的表单控件实例。ngModelOptions 指令可以用来定义 name 属性,控制更新行为,或者将 ngModel 实例标记为独立。

您可以通过将指令添加到声明 ngModel 指令的输入字段中来添加 ngModelOptions

[ngModelOptions]="{name: 'description', updateOn: 'blur', standalone: false}"

让我们更深入地了解您可以在 ngModelOptions 指令上设置的属性。

使用名称属性

当您设置 ngModelOptionsname 属性时,您可以移除 input 字段上的 name 属性,因为使用 ngModelOptions 中的 name 属性与提供 name 属性相同。

使用 updateOn 属性

接下来,我们有 updateOn 属性,它控制表单控件的更新行为,可以取三个值——changeblursubmit

  • change:这是默认值,除非整个表单的更新策略被更改为其他值,否则不需要显式设置。使用 change 值,表单控件将在每次按键时更新。

  • blur:当您将 updateOn 设置为 blur 时,表单控件将在您从相关输入字段失去焦点(即模糊)时更新。这可以在您运行一些可能需要大量时间或资源的逻辑时很有用。如果您在每次按键时运行重逻辑,您的性能将受到影响。

  • submit:如果您将 updateOn 设置为 submit,表单控件只有在表单提交时才会更新。就像 blur 一样,submit 选项可以帮助提高您表单的性能。

你还可以通过在 HTML 表单标签上使用 ngFormOptions 指令来更改你表单中所有表单控件的更新行为。ngFormOptions 指令只接受 updateOn 属性。当你更改整个表单的更新行为时,你仍然可以使用 ngModelOptions 指令覆盖它。

使用 standalone 属性

最后,我们有 standalone 属性。当你将表单控件标记为独立时,表单控件不会在 ngForm 实例中注册自己。当一个表单控件被标记为独立时,你不需要提供 name 属性,因为 ngForm 实例不需要跟踪输入的值。确定表单的有效性也不会考虑标记为独立的字段。这在你有不代表表单模型的表单字段时非常有用。

独立表单控件的另一个场景是单个输入,你不需要创建整个表单,但你想使用 ngModel 指令。一些例子可能是一个主题切换、语言选择或搜索。

分组模板驱动表单字段

通常,你有一组属于一起的表单字段。一个很好的例子是地址字段,如街道、邮政编码和房屋号码。你可能想作为一个组检查字段的有效性,添加样式或用户反馈,或者对组执行验证逻辑而不是单个字段。

对于这些用例,Angular 提供了 FormGroup 类。在模板驱动表单中,你可以使用 ngModelGroup 指令创建 FormGroup 类。当你声明 ngModel 组时,Angular 将在幕后创建一个 FormGroup 类。

声明 ngModelGroup 非常简单!只需将 ngModelGroup 指令添加到 HTML 标签中,该标签包含带有 ngModel 指令的多个 HTML 元素。

例如,我们可以将金额、不含增值税和增值税百分比字段分组。

我们可以这样操作:

  1. 将金额和增值税百分比字段的 HTML 包装在 fieldset 标签内,并在该 fieldset 标签上声明 ngModelGroup 指令。

  2. 为您的 FormGroup 分配一个名称,然后就可以了:

    <fieldset ngModelGroup="amount"></fieldset>
    

现在所有输入字段都已声明并使用 ngModel 指令绑定到您的数据字段,您需要一种方式来监听表单提交。

提交模板驱动表单

如你所见,我们在提交按钮上添加了 submit 类型:

<button type="submit">Submit</button>

这将触发一个提交事件。当这个提交事件发生时,Angular 将触发其自身的内部提交事件,称为 ngSubmit。我们可以像监听任何其他浏览器事件或组件输出一样监听这个 ngSubmit 事件:

<form #addExpenseForm="ngForm" ngSubmit on the form tag; on the right-hand side, you declare the ngSubmit event in round brackets; and on the left-hand side, you define a function that’s declared in the component class to handle the submission.
For our example, we will create an output event to output a clone of the form object and reset the form using the `ngForm` instance. To achieve this, we need a way to access the `ngForm` instance in our component class; we can do this using the `@``ViewChild` directive:

@ViewChild('addExpenseForm') form!: NgForm;


 Next, we need to add the component’s output, which will allow us to send the submitted values to the parent component so that it can act upon it accordingly:

@Output() addExpense = new EventEmitter();


 Lastly, we need to add the `onSubmit` method with our logic:

onSubmit() {

this.addExpense.emit(structuredClone(this.expenseToAdd));

this.form.reset();

}


 As you can see, we used the native `structuredClone` method to send a clone of the `expenseToAdd` object to the parent. We sent a clone because `expenseToAdd` is a reference object, meaning it will be cleared everywhere (also in the parent component) after we call `reset` on the form, and it will clear the object we bound to the `ngForm` instance.
Now that we’ve added the submit logic, we’ll start exploring validation rules, control status, and how to display messages to the user based on the control statuses and validity of the form and its controls.
Using built-in validation rules for template-driven forms
For a good user experience, it’s important to provide good user feedback on our form. You want to indicate when your form fields are valid or still require some changes to be made by the user. You also want to prevent the user from submitting the form when not all the fields are valid and provide good error messages for incorrect or incomplete form fields.
To achieve these feats, you must add form validations to the form fields and use the control statuses to add or remove styling and error messages. Let’s start by exploring how to validate form fields in template-driven forms.
Validating the form fields of template-driven forms can be done using directives. Most validations can be done using the built-in validation directives, but when needed, you can also create your own directives to validate template-driven form fields.
You can find all built-in validation directives on the official Angular website: [`angular.io/api/forms/Validators`](https://angular.io/api/forms/Validators).
Let’s learn how to add validators to our example form. We will start by making our form fields required using the `required` directive. You can apply the `required` validator by adding this directive to your `input` fields:

<input max 验证器。你仍然可以输入大于 100 的数字,但表单字段将是无效的。

要应用max验证器,将以下内容添加到输入标签中:

[max]="100"

你也可以有条件地添加验证器。使用方括号表示法,就像我们使用max验证器一样,并为其提供一个属性值,当该值设置为null时,验证规则将被禁用:

[max]="null" [disabled]="null"

如果你愿意,你可以向你的表单添加额外的验证规则;在 Angular 文档中查看验证器,并添加你想要使用的验证规则指令。

我简要地提一下模式验证器,因为这是一个特殊的验证器。模式验证器可以用于许多用例,因为它接受一个正则表达式,并检查输入字段中的值是否与正则表达式模式匹配。其他验证器用于单一目的,例如检查最大输入值或字段是否有值。

现在你已经知道了如何添加内置的验证规则,让我们来看看我们如何根据表单和表单控件的状态和有效性来样式化表单字段。

根据控制状态值对表单和表单字段进行样式设置

为了为你的应用程序用户提供良好的用户体验,提供有关表单及其字段状态的可视反馈非常重要。做到这一点的最佳方式是利用你的表单及其表单控件的控制状态。

表单以及其FormGroupFormControl实例,都由 Angular 通过控制状态和相应的 CSS 类进行更新。提供有关表单及其字段状态的可视反馈的最佳方式是为控制状态 CSS 类提供样式。因为这些样式在所有应用程序中都是共享的,所以将它们作为全局样式创建是一个好习惯。

让我们从在共享库文件夹下创建一个新的global-styling文件夹开始。在这个global-styling文件夹中,创建一个名为form-control-status.scss的文件。在你创建好文件夹和文件后,在Nx monorepo根目录下的package.json文件中的devDependencies部分添加以下内容:

"@global/styling": "file:libs/shared/global-styling"

接下来,你需要运行npm install,这样你就可以在Nx monorepo中的 CSS 文件中使用@global/styling来导入全局样式。

现在,是时候在我们的新创建的 CSS 文件中添加控制状态 CSS 类的样式了。

Angular 为我们提供了八个不同的控制状态 CSS 类,通过这些类我们可以对表单及其表单组和控件进行样式设置:

  • ng-valid:当表单控件或组根据验证规则有效时,应用此样式。当所有组和控件都有效时,应用于整个表单。

  • ng-invalid:当表单控件未通过所有验证规则时,应用此样式。当一个或多个控件无效时,应用于整个表单。

  • ng-pending:当异步验证正在进行验证时,应用此样式。

  • ng-pristine: 这是在未交互的表单控件上应用的。当没有任何表单控件被交互时,它应用于表单。

  • ng-dirty: 这是在已交互的表单控件上应用的。当至少有一个表单控件被交互时,它应用于表单。

  • ng-untouched: 这是在用户未聚焦或未交互过的表单控件上应用的。当没有字段被聚焦或交互时,它应用于表单。

  • ng-touched: 这是在用户已聚焦或交互过的表单控件上应用的。当至少有一个字段被聚焦或交互时,它应用于表单。

  • ng-submitted: 这是在表单提交时应用于表单元素的。

您可以使用这些 CSS 类来根据您的需求样式化表单。我们将在 add-expense 表单中根据其有效性样式化表单字段,但仅在表单已被触摸时这样做。

您可以在 form-control-status.scss 文件中添加以下内容:

form.ng-touched {
  .ng-valid:not(fieldset)  {
    border-left: 5px solid #42A948; /* green */
  }
  .ng-invalid:not(form, fieldset) {
    border-left: 5px solid #a94442; /* red */
  }
}

现在,我们只需要将此文件导入到 add-expense 表单的 CSS 文件中。打开您的 add-expense.component.scss 文件,并在文件顶部添加以下 import 语句以导入 form-control-status 样式:

@import '@global/styling/form-control-status';

添加导入后,样式将应用于表单。

现在您已经知道如何使用控件状态类来样式化表单及其表单组和控件,让我们学习如何根据表单控件的状态向用户显示消息。

根据表单控件的状态显示消息

您可以通过在表单字段无效时显示消息来进一步提高用户体验。您可以通过使用由 ngModel 创建的表单控件实例来实现这一点。

首先,在您声明 ngModel 的输入中添加一个模板变量,并将 ngModel 实例分配给模板变量,以便您可以在整个模板中使用它:

<input required [(ngModel)]="expenseToAdd.description"
       #description template variable and assigned it with ngModel. Now, you can access and control the form control instance created by the ngModel instance through the #decscription template variable.
Let’s add a `span` element with an error message underneath the input element:

This field is required


 Next, we want to ensure this message is only shown when the form has been touched, and the input validator needs to be satisfied. We can do this by using `ngForm` and the form control instance, which we bound to the template variables. The form control instance exposes several properties that we can check to determine the status of the correlating input field.
Like the control status CSS classes, the form and form control itself expose properties to assess the same statuses: `valid`, `invalid`, `pending`, `pristine`, `dirty`, `touched`, and `untouched`. The form control also exposes `disabled` and `enabled` as checks of the disabled status of the correlating input field.
Besides these Boolean properties, the form control exposes multiple values and methods; you can find all the properties on the official Angular website: [`angular.io/api/forms/FormControl`](https://angular.io/api/forms/FormControl).
Now, without further ado, let’s add a `*ngIf` statement to our span element so that it’s only displayed when the input form is touched and the required validator of the form control isn’t satisfied:

*ngIf="addExpenseForm.touched && description.hasError('required')"


 As you can see, we use the `hasError` method to check for the required validator. You can go ahead and add template variables for `ngModel` and error messages for the other fields in the form. For our VAT percentage field, we will add an additional message to check for the `max` validator:

<span *ngIf="addExpenseForm.touched && hasError with the max parameter to get the validity of the max validator. If you want to display the message under other conditions, you can change the *ngIf statement however you see fit.

提高您表单用户体验的另一种常见方法是防止用户在表单尚未有效时提交表单。您可以通过将表单的有效性绑定到提交按钮的 disabled 属性来实现这一点。

<button ngForm instance and made the button invalid when the invalid property of the form returned true.
Now that we’ve enhanced the user experience of our form by adding validation rules and supplying visual user feedback through styling and error messages, let’s move on and see how we can create custom validators for template-driven forms.
Custom validators for template-driven forms
To finish this section, we will learn how to create custom validators for template-driven forms. The built-in validators will cover most scenarios, but in some cases, you need to create custom validators – for example, when you want to check values in your database or state when you need to perform cross-field validations or need to perform other validations that can’t be done with built-in validators.
All the built-in validators for template-driven forms are directives, so we also need to create a directive to build custom validators.
Let’s start by creating a new library with our custom Nx generator. Name the library `form-validators`; then, select `max-word-count` and check the `template-driven-validators` folder and move the directive inside it. Now, add an export for the directive inside `index.ts`:

export * from './lib/template-driven-validators/max-word-count.directive';


 Now, let’s add some validation logic inside our directive. We’ll start by creating an input for our custom validator directive:

@Input('btLibsUtilMaxWordCount') maxWords = 1;


 Next, we must implement the `Validator` interface:

导出类 MaxWordCountDirective 实现了 Validator 接口。


 The `Validator` interface requires you to implement a `validate` function:

validate(control: AbstractControl): ValidationErrors|null{}


 As you can see, the `validate` function takes `AbstractControl` as input and returns either `null` or a `ValidationErrors` object. Inside the `validate` function, we will add our validation logic. When the validation passes, we will return `null`, and when the validation doesn’t pass, we will return a `ValidationErrors` object. To perform some validation logic, we need to access the form control of the HTML element on which our directive is declared.
The `AbstractControl` function parameter gives you access to a `FormControl` or `FormGroup` instance, depending on which HTML element you place the directive. When you place the directive on HTML elements that declare `ngModel`, you will receive a `FormControl` instance; if you put the directive on an HTML element with `ngModelGroup` declared, you will receive a `FormGroup` instance.
In our example, we will use the directive on elements with `ngModel` on it and receive a `FormControl` instance through the `function` parameter. We want to check the value of our form control and determine if more words are in the value than we defined in our `maxWords` input. When there are more words, our validation fails, and we return a `ValidationErrors` object; otherwise, we return `null`, which means our validation passes.
First, we must check the number of words:

const wordCount = control?.value?.trim().split(' ').length;


 As you can see, we use `control.value` to get the value from the form control. If there is a value, we trim it and split it into spaces to get the number of words in the string. Next, we check if the number of words is bigger than the `maxWords` input; if that is the case, we return the error object; otherwise, we return `null`. The `error` object can have any format you like:

返回 wordCount > this.maxWords ?   {btLibsUtilMaxWordCount: { count: wordCount }} : null;


 That is all the logic we need for our custom validator. But there is still one problem: when we declare the directive on our form, the form won’t know this directive is a custom validator; it will think it’s just a regular directive. Instead, we want our form to register the directive as a validator so that the form takes the directive into account when determining the form’s validity and its form groups and controls. We can achieve this by adding a provider to the directive decorator:

providers: [{

provide: NG_VALIDATORS,

useExisting: MaxWordCountDirective,

multi: true

}]


 By adding the `NG_VALIDATORS` provider to our directive, the `ngForm` instance will register the directive as a validator and include the directive when determining if the form and its groups and controls are valid.
To use the directive, you need to add the directive class’ name to the imports of our `add-expense` component since both the directive and our component are standalone. After adding the directive to the imports array, you can use the directive in the HTML template:

<input hasError 方法在控件上,就像我们检查必填错误时那样:

description.hasError('btLibsUtilMaxWordCount')

当你查看我们指令中返回的error对象时,你可能会注意到我们包含了一个带有我们表单控件中使用的单词数的count属性:

{btLibsUtilMaxWordCount: { count: wordCount }}

你可以通过在表单控件实例上使用getError方法来检索count值:

description.getError('btLibsUtilMaxWordCount').count

如你所见,我们使用了描述模板变量,它持有表单控件实例的引用,并调用getError来检索错误对象。你可以使用error对象在错误消息中向用户显示额外信息;在我们的例子中,你可以包括当前的单词数。

通过这样,你就知道了如何向模板驱动的表单添加自定义验证器,并在自定义错误发生时显示错误消息。接下来,我们将学习如何使用自定义验证器验证表单组。

使用自定义验证器验证表单组

在我们当前的示例中,我们正在使用在它上面声明了ngModel的 HTML 元素来声明我们的自定义验证器指令。然而,在某些情况下,你可能想要执行跨字段验证或一次性验证多个字段。一个典型的例子是,当你有一个密码和确认密码输入字段在你的表单中,并想要检查这两个字段是否持有相同的值。

当你想要对一组字段执行验证逻辑时,你可以在你的表单中使用ngModelGroup来分组字段,就像我们使用 VAT 字段那样。接下来,你必须声明带有ngModelGroup的 HTML 标签上的自定义验证器。

当你在带有ngModelGroup的元素上声明指令时,验证器指令将接收到FormGroup作为AbstractControl函数参数。然后,在自定义验证器内部,你可以访问表单组中各个表单控件的值,如下所示:

const password = control.get('password').value;
const confirm = control.get('password-confirm').value;

get方法内部的string值应该等于你在 HTML 模板中表单控件上声明的名称:

<input required [(ngModel)]="formObj.password" null if the validation passes and a ValidationErrors object when the validation fails.
Async validations with custom validators
Lastly, you can create asynchronous validators. These asynchronous validators work similarly to regular custom validators; there are only two differences. First, the provider for the asynchronous validators is different. If you want to create an asynchronous validator, you must change the provider to the following values:

providers: [{

provide: NG_ASYNC_VALIDATORS,

useExisting: UsernameAvailabilityDirective,

multi: true,

}]


 Secondly, you need to return `Promise` or `Observable` with either a `null` value or the `ValidationErrors` object. Here’s an example of such a function:

validate(control: AbstractControl): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> {

const username = control.value;

return checkUsernameInDatabase(username).pipe(

map((isAvailable) => (isAvailable ? null : { usernameTaken: true }))

);

}


 In this example, the `checkUsernameInDatabase` function is an API call that returns an observable, and we use RxJS’s pipe and map to map the result to `null` or the `ValidationErrors` object. You can use any asynchronous logic inside your validator, so long as you return `Promise` or `Observable` with `null` or the `ValidationErrors` object.
In this section, you learned how to create template-driven forms. You also learned how to create form controls using the `ngModel` directive, how to create form groups using `ngModelGroup`, and how to use built-in validators. Then, you learned about how to style a form and display error messages based on form control statuses. Lastly, you learned how to create custom validators for form controls, form groups, and asynchronous validation rules. Next, we will start learning how to build reactive forms.
Building reactive forms
In this section, you will learn how to build reactive forms. We will rebuild the form we used in the previous section but reactively. You will learn how to create `FormGroup`, `FormArray`, and `FormControl` instances, how to validate reactive forms, and how to create custom validators for reactive forms. You will also learn how to dynamically create form fields and how to change the update behavior of reactive form fields.
Creating a reactive form
Start by removing or commenting out the HTML template and TypeScript code for our template-driven form. I will comment out the code so that it remains an example of the template-driven approach.
Next, we’ll start with the same simple HTML form, including the description, amount excluding VAT, VAT percentage, and date fields. We will gradually transform the simple HTML form into a reactive form:

…………


 After creating the simple HTML form, we will start by importing `ReactiveFormsModule` inside our component. When building reactive forms, you import `ReactiveFormsModule` instead of the regular `FormsModule`. After adding `ReactiveFormsModule` to your component file, we can move on and start to create the form model using the `FormGroup` and `FormControl` classes.
At its core, an Angular form is a `FormGroup` class with `FormControl` elements inside `FormGroup`. When we created our template-driven form, Angular created a `FormGroup` class for our `ngForm` instance and added a `FormControl` element inside the `FormGroup` class for each `ngModel` directive we declared.
To construct our reactive form model, we need to do the same only manually:

addExpenseForm = new FormGroup({

description: new FormControl(''),

amountExclVat: new FormControl(null),

vatPercentage: new FormControl(null),

date: new FormControl(''),

});


 As you can see, we’ve created a `FormGroup` instance and added a `FormControl` element inside for each form field. Inside the function brackets of the form control, we’ve added either an empty string or `null`; these are the default values for the form control instances.
If you want different default values, you can change the values inside the function brackets of the `FormControl` instances. Later in this section, we will create an input by which you can send default values from the parent, just like we did with the template-driven form.
After creating the form model, you need to bind the form model to the form inside your HTML template. You can bind the form model to the template by using the `FormGroup` directive and assigning the directive with the form model, as follows:

<input formControlName directive with the key that was used to assign FormControl inside the FormGroup class you declared inside your TypeScript file. After adding the formControlName directive to all your form fields, the form model is bound to the HTML form. Now, when you change the values inside the input fields, `addExpenseForm` in your component class will be updated, and when you update the `FormControl` values inside your TypeScript file, the changes you made will be reflected inside the input fields in the browser. You can test this by adding a default value to one of the `FormControl` values inside your TypeScript file; when you do, the value should be reflected inside the HTML template. To test if changing the input also changes the values of `addExpenseForm` in your component class, you can temporarily add the following code to your template so that you can see the changes to the `addExpenseForm` object in real time:
{{addExpenseForm.value | json}}

 After creating the reactive form, you can remove the aforementioned line of code. Until then, it can be helpful to see if all values are synchronized.
Now that we’ve defined and tested our form fields, let’s learn how we can group form fields into reactive forms.
Grouping fields in reactive forms
In our template-driven form, we grouped the amount excluding VAT and VAT percentage fields using the `ngModelGroup` directive. By grouping fields, you can perform validation logic on the group instead of the individual fields, style the group, or just change the data structure to something better resembling your state or DTO objects.
Let’s also group the amount excluding VAT and the VAT percentage fields in our reactive form. Start by changing the HTML template and wrap the two input fields inside a fieldset HTML template:

………

 Next, change the form model so that it reflects the structure where the two fields are grouped inside a `FormGroup` class:

addExpenseForm = new FormGroup({

description: new FormControl('Test'),

amount: new FormGroup({

amountExclVat: new FormControl(null),

vatPercentage: new FormControl(null),

}),

date: new FormControl(''),

});


 When you’ve updated the form model, you need to bind the form group inside the HTML form. You can bind the form group using the `formGroupName` directive and assign it with the key of your `FormGroup` class inside the form model. In our case, this is `amount`:

………

 That is all you need to do. Now, when you log the form value, its structure will look like this:

{

"description": "",

"amount": {

"amountExclVat": null,

"vatPercentage": null },

"date": ""

}


 Now that you know how to group fields using the `FormGroup` class, let’s explore how we can dynamically add fields and `FormControl` instances to our form and form model.
Dynamically adding fields and FormControl instances
Unlike template-driven forms, when creating reactive forms, you can also group fields using the `FormArray` class. The `FormArray` class is useful when you don’t know how many values will be supplied, and the user can dynamically add and remove input fields in the form. An example of this might be tags or comments.
To demonstrate this, we will add another field to our reactive form so that we can add tags. Start by adding the `FormArray` class to your form model:

tags: new FormArray([ new FormControl('')])


 As you can see, `FormArray` takes an array instead of an object as a parameter. Inside this array, we declared a form control, which will be the first tag inside our form. Because `FormArray` takes an array, we can dynamically add (or remove) from controls, which, in turn, will add or remove input fields to/from our HTML form.
Now that we’ve added the `FormArray` class inside our form model, let’s add some HTML so that the user can add multiple tags.
Start by creating a `fieldset` value and declare the `formArrayName` directive on the HTML tag:


 Inside `fieldset`, add the following HTML:

<input [formControlName]="i" type="text" id="tag-{{i}}">


 The aforementioned HTML will output a label and input for each `FormControl` inside `FormArray`. Now, we only need a way to add and remove `FormControl` instances.
Let’s add two buttons to the HTML underneath the input tag:

<button *ngIf="i > 0" (click)="removeTag(i)">-

<button (click)=»addTag()">+


 Next, we need to add the logic for the click functions inside the component class:

addTag() {

this.addExpenseForm.controls.tags.insert(0, new FormControl(''));

}

removeTag(index: number) {

this.addExpenseForm.controls.tags.removeAt(index);

}


 With the aforementioned functions added to the component class, you can now add and remove `FormControl` instances inside the `FormArray` class, which will result in added or removed input fields.
But what if you have data in an object format instead of an array and you need to keep the form format, and just like our previous example, you don’t know how many or what fields you will have and what the keys for these fields will be?
An example could be when you receive a list of statuses that our expense has gone through, such as submitted, waiting for revision, checked, approved, and so on. For these scenarios, you can use the `FormRecord` class. We won’t add this to our form, but I will outline an example of how to handle a `FormRecord` class:

statuses: new FormRecord({})


 The `FormRecord` class receives an object with keys and `FormControl` or `FormGroup` instances. You can add the `FormControl` instances to the `FormRecord` class as follows:

this.form.controls.statuses.addControl('someKey', new FormControl(''));


 As you can see, we utilize the `addControl` method, which is exposed on the `FormRecord` class. The `addControl` method is also available on `FormGroup`, but you should use `FormRecord` when you don’t know what fields will be added beforehand. You can use the `FormGroup` when you do know what the entire model will look like. You can strongly type `FormGroup` and `FormRecord`. To strongly type `FormGroup`, use the following syntax:

address: new FormGroup({…………})


 In this example, we added an interface for `FormGroup` between the arrow brackets. By adding the type, you strongly typed `FormGroup` and can’t add fields not defined in the `IAddress` interface. When strongly typing a `FormRecord` class, we tell the record what value our dynamic controls will have. For example, if we have a list of keys with a Boolean value, we can use this syntax:

状态:new FormRecord<FormControl>({…………})


 Here, we tell `FormRecord` that each field that will be added should be a `FormContro` instance and that `FormControl` should have a Boolean value. If we have form controls with different values, we can change the Boolean value for string or any other type our controls will hold.
Now that you know how to group fields using `FormGroup` or dynamically add fields using `FormArray`, `FormGroup`, or `FormRecord`, let’s explore how we can control the update behavior of our fields and how to declare standalone controls in a reactive form.
Configuring update behavior and declaring standalone controls
With template-driven forms, we have the option to configure the update behavior of form control instances using the `ngModelOptions` directive; in reactive forms, we control the update behavior inside the `FormGroup`, `FormArray`, or `FormControl` class. You can add a configuration object to set the `updateOn` property. You can set the `updateOn` property for `FormGroup`, `FormArray`, and `FormControl` elements alike.
When you set the `updateOn` property for a `FormGroup` element of `FormArray`, it will be applied to all nested `FormControl` elements. If you define the `updateOn` property for a nested object inside the form model, that nested property will overrule the `updateOn` property of parent elements. Just like template-driven forms, you can set the update behavior to `change` (which is the default), `blur`, or `submit`:

description: new FormControl('', ngModelOptions 指令在我们的模板驱动表单中,你也有将输入字段标记为独立的选择。与模板驱动表单一样,你也可以有一个独立的响应式表单元素,但你不需要使用独立属性来设置它;相反,你只需声明一个没有 FormGroup 的 FormControl 实例:

searchInput = new FormControl('');

对于一个独立的响应式表单字段,你可以在你的 HTML 模板中使用 formControl 指令而不是 formControlName 指令:

<input [formControl]="searchInput" type="text">

你可以通过使用 FormControl 的 value 属性来访问你独立表单字段的值:

this.searchInput.value;

除了使用 value 属性,你也可以更反应性地访问值并响应 FormControl 的每个更新。你可以通过订阅 valueChanges 可观察对象来反应性地处理更改:

this.searchInput.valueChanges.subscribe(() => { ……… });

现在你已经知道了如何控制表单字段的更新行为以及如何反应性地创建独立的表单字段,让我们学习如何通过编程方式设置和更新表单值。

设置和更新值

通常,你需要在组件类内部以编程方式设置和更新值。在响应式表单中,你可以使用 setValue 来设置单个表单控件上的值,当你想要同时更新表单的多个字段时,可以使用 patchValue 方法。

在本节中,我们将创建我们的组件 @Input() 指令并使用 patchValue 来更新从父组件接收到的默认表单值。

首先,在 add-expense.interface.ts 文件中添加一个新的接口:

export interface AddExpenseReactive {
  description?: string;
  amount?: {
    amountExclVat?: number;
    vatPercentage?: number;
  };
  date?: string[];
  tags?: string[];
}

接下来,我们将添加 @Input() 指令及其设置器。在这个设置器内部,我们将使用 patchValue 方法:

@Input()
public set expenseToAdd(value: AddExpenseReactive) {
  this.addExpenseForm.patchValue(value);
}

patchValue方法将更新值对象内部提供的所有值。因此,如果值只包含描述键,则只更新描述;当值对象包含描述和金额,并且具有这两个属性时,所有这些值都将更新。唯一的例外是datetags字段。

如您可能已注意到,当我们定义界面中的日期时,我们将date属性定义为字符串数组类型;这是因为为了设置默认值,我们需要向表单控件提供字符串数组,如下所示:

['2023-10-15']

如果您从父组件提供类似格式的值,patchValue也会对日期有效;当您以简单字符串的形式提供它时,输入将不会被填充。

确保您还更新了FormControl内部的默认值;否则,您将因为控制内部和补丁值中的类型不匹配而得到编译器错误:

date: new FormControl(['']),

除了date字段外,tags字段也有所不同,因为我们使用它来动态地向我们的表单添加控件。当我们将addExpenseForm与表单模型关联时,我们的FormArray标签将接收一个默认值,即一个FormControl。因为我们只在FormArray内部添加了一个FormControl,所以当我们对表单使用patchValue方法时,只有一个标签将被设置,即使提供了更多标签。要更新FormArray的值,我们需要在我们的 setter 内部添加一些额外的逻辑:

this.addExpenseForm.controls.tags.clear();
value.tags?.forEach(tag => {
  this.addExpenseForm.controls.tags.push(new FormControl(tag)); });

首先,我们在FormArray标签上使用了clear方法。clear方法将清除FormArray内部声明的所有FormControl实例。在我们清除FormArray之后,我们将使用forEach循环为从父组件接收到的每个标签添加一个新的FormControl

现在,当我们从父组件提供包含值的对象时,我们的表单将填充这些值。

在某些场景中,您可能只想设置单个控件的值。当您只想设置单个控件的值时,您必须使用FormControl实例上的setValue方法:

this.addExpenseForm.controls.description.setValue('New description');

setValue方法不允许您将数字值分配给描述输入字段,这使得它成为一个类型安全和程序化的方式来设置表单控件的值。我们添加了输入以接收来自父组件的值。

通过这样,您已经学会了如何以编程方式为您的响应式表单设置和更新值。接下来,我们将开始学习响应式表单中的验证。

验证响应式表单

与模板驱动表单一样,您可以使用内置或自定义验证器来验证响应式表单。我们将首先查看内置验证器,然后创建一个自定义验证器。响应式表单具有与模板驱动表单相同的内置验证器,但我们不是使用指令声明它们,而是使用Validator类。在您的FormControl实例内部,您可以添加一个包含您想要应用的验证器的数组。

要将必需的验证器添加到我们的描述字段,我们可以使用以下语法:

description: new FormControl('', [ValidatorFn interface. We will create the same max word count validator, just like we did with the template-driven form, now only as a function instead of a directive.
To create the custom validator, start by creating a new folder, `reactive-validators`, inside the `form-validators` library. Inside this folder, create a new file named `max-word-count.function.ts`. We will use this new file to create our validator function:

export function maxWordCountValidator(maxWords: number): ValidatorFn {

return (control: AbstractControl): ValidationErrors | null => {

const wordCount = control?.value?.trim().split(' ').length;

return wordCount > maxWords ? { maxWordCount: { count: wordCount } } : null; }; }


 Here, we create the `maxWordCountValidator` function, which will receive an input for the maximum word count we will allow. Inside this function, we return an implementation of the `ValidatorFn` interface. Here, `ValidatorFn` is the same as the `validate` method we declared inside the directive; we check the word count and return `null` if the word count is equal to or smaller than the allowed count and return a `ValidationErrors` object otherwise.
Next, you can add this custom validator to your form controls:

description: new FormControl('', [Validators.required, null values for different validators; you simply call the addValidators or removeValidators method:

this.addExpenseForm.controls.description.addValidators(Validators.required);
this.addExpenseForm.controls.description.removeValidators(Validators.required);

通过这样,你已经学会了如何使用内置验证器,如何使用ValidatorFn实现创建验证器,以及如何动态添加和删除验证器。在下一节中,你将学习如何在响应式表单中提供视觉反馈。

在响应式表单中提供关于表单状态的视觉反馈

Angular 将控制状态 CSS 类应用于表单元素,就像它对模板驱动表单所做的那样。控制状态 CSS 类与用于模板驱动和响应式表单的相同,因此我们不需要对已经创建的样式进行任何更改,以便它们能够应用。FormGroupFormArrayFormRecordFormControl实例将根据它们当前的状态接收控制值 CSS 类。

唯一真正的区别是我们如何显示错误消息。我们在模板驱动表单中创建了一个模板变量,并将其绑定到ngModel实例以访问表单控件。当我们使用响应式表单时,我们通过我们创建的表单模型来访问表单控件实例:

<span *ngIf="addExpenseForm.touched && addExpenseForm.controls.description.hasError('required')">This field is required</span>

对于其他方面,没有变化,所以请继续添加您想要显示给用户的错误消息。我们已经在介绍模板驱动表单时讨论了提供视觉反馈,因此当涉及到响应式表单时,这就是我们需要涵盖的全部内容。

提交和重置响应式表单的工作方式与模板驱动表单相同,因此你可以从构建模板驱动表单部分复制提交和重置行为。为了完成这一章,我们将学习如何根据配置对象动态构建表单。

动态创建表单

创建一个良好的表单需要相当多的代码。无论你是使用模板驱动还是响应式方法,你都需要大量的 HTML;你需要定义模型,添加验证器,以及额外的逻辑,例如提交行为。

你可以使用一个基类来提供一些共享功能,但你也可以构建一个动态表单,该表单将根据 JSON 输入动态构建表单。在本节中,我们将构建一个动态表单的简单示例。你可以扩展动态表单以满足你的特定需求。例如,你可能想要从外部源获取配置或支持额外的验证器。

要开始我们的动态表单,在你的expenses-registration-forms库中创建一个名为dynamic-form的新表单组件。接下来,创建一个dynamic-control.interfaces.ts文件。我将在组件文件夹内创建新的接口,但你也可以将所有接口放在指定的文件夹中,或者使用你喜欢的任何其他文件夹结构。我们新的动态控制接口将定义表单控件的接口,该接口将动态生成:

export interface DynamicControl {
  controlKey: string;
  formFieldType?: 'input' | 'select';
  inputType?: string;
  label?: string;
  defaultValue?: any;
  selectOptions?: string[];
  updateOn: 'change' | 'blur' | 'submit';
  validators?: ValidatorFn[];
}

一旦我们定义了接口,我们需要向组件添加一个输入属性,该属性将接收一个DynamicControl对象的数组,并添加一个formModel属性,该属性将保存我们的表单模型:

@Input() formModelConfig: DynamicControl[] = [];
formModel = new FormGroup({});

当我们接收到表单配置作为输入时,我们需要构建我们的表单模型。我们可以使用ngOnChanges生命周期钩子来构建表单模型,每次我们接收到新的表单配置时:

ngOnChanges(changes: SimpleChanges) {
  if (changes[‹formModelConfig']) {
    this.formModel = new FormGroup({});
    this.formModelConfig.forEach((control) => {
      this.formModel.addControl(
        control.controlKey,
        new FormControl(control.defaultValue, { updateOn: control.updateOn, validators: control.validators }));
    }); }}

如你所见,我们检查changes对象是否包含新的formModelConfig;当formModelConfig包含在更改中时,我们使用forEach循环将formModelConfig的表单控件添加到我们的表单模型中。我们还需要一个提交函数和一个输出,它将在提交时将表单模型发送到父组件:

@Output() outputForm = new EventEmitter();
onSubmit() {
  this.outputForm.emit(structuredClone(this.formModel.value));
  this.formModel.reset();
}

对于我们的组件类,这就是我们所需要的一切。我们需要将其翻译成模板,以便我们的表单将根据配置构建。首先,添加表单标签,将其绑定到表单模型,并将ngSubmit函数添加到form标签:

<div class="form-container">
  <form [formGroup]="formModel" (ngSubmit)="onSubmit()">
  </form>
</div>

接下来,我们需要在formModelConfig内部为每个配置创建一个输入。我们将使用*ngFor循环为formModelConfig中的每个实例输出元素,并使用*ngSwitch指令来确定要创建哪个元素。我们将使用DynamicControl的属性将元素绑定到表单并提供所有正确的值:

<div class="form-field" *ngFor="let control of formModelConfig">
  <label for=»description»>{{control.label}}</label>
  <ng-container [ngSwitch]=»control.formFieldType»>
    <input *ngSwitchCase=»›input›»
            formControlName="{{control.controlKey}}"
            type="{{control.inputType}}">
    <select *ngSwitchCase=»›select›»
            formControlName="{{control.controlKey}}">
      <option
         *ngFor="let option of control.selectOptions"
         value="{{option}}">
      </option>
    </select>
  </ng-container>
</div>

一旦添加了此 HTML,每个表单元素将被渲染并绑定到表单模型。我们最后需要做的是添加错误信息。以下是在你的动态表单中显示错误信息的一个示例:

<span *ngIf="formModel.touched && formModel.get(control.controlKey)?.hasError('required')"> This field is required</span>

为你表单支持的每个错误信息添加一个额外的span。为了测试动态表单,你可以在expenses-overview-page.component中导入它,并在 HTML 模板中添加选择器:

<bt-libs-ui-dynamic-form [formModelConfig]="formModelConfig" (outputForm)="addExpense($event)" />

在组件类中创建formModelConfig,以便动态表单有字段可以生成:

formModelConfig: DynamicControl[] = [
  {
    controlKey: 'description', formFieldType: 'input',
    inputType: 'text', label: 'Description',
    defaultValue: '', updateOn: 'change',
    validators: [Validators.required]
  },
  {
    controlKey: 'amount', formFieldType: 'input',
    inputType: 'number', label: 'Amount excl. VAT',
    defaultValue: null, updateOn: 'change',
    validators: [Validators.required]
  }
]

将我们在响应式和模板驱动的表单中使用的其余字段添加到formModelConfig中;你会发现相同的表单将动态生成,包括验证规则和错误信息。

这只是一个动态表单的简单示例;如果你想添加额外的逻辑,例如允许表单组、表单数组和表单记录,你可以这样做。概念保持不变;只需调整模型,在组件类中添加逻辑以正确生成表单模型,并调整模板以便你可以按预期渲染它。

摘要

在本章中,你学习了 Angular 的不同类型表单。现在你知道了模板驱动和响应式表单之间的区别以及何时使用哪种类型。我们创建了一个包含验证、错误信息、默认值和基于控件状态的样式的模板驱动表单。我们还为模板驱动表单创建了一个自定义验证指令。接下来,我们为使用响应式表单做了同样的操作。

我们还创建了一个自定义验证函数,可以在响应式表单内部使用。我们学习了如何在响应式表单内部动态添加表单组、表单数组或表单记录类中的字段。然后,我们学习了如何在模板驱动和响应式表单中更改我们字段的更新行为。最后,你构建了一个动态表单,该表单根据配置构建表单模型,并相应地渲染表单,包括验证和错误信息。

在下一章中,你将学习如何创建动态组件,这些组件可以在许多场景中重复使用。








第二部分:处理应用程序状态和编写更干净、更可扩展的代码

在这部分,你将学习如何为你的 Angular 应用程序开发更干净、更可扩展和性能更好的代码。你将从开发适合更复杂 UI 场景的动态组件开始。你将学习如何按需懒加载单个组件以减少你的包大小并提高性能。然后,你将探索常用的约定和设计模式,以开发更健壮和可扩展的 Angular 应用程序。你将通过实际操作实现外观模式、使用 NgRx 进行状态管理和使用 RxJs 和 signals 进行响应式编程来完成这部分内容。

本部分包括以下章节:

  • 第五章创建动态 Angular 组件

  • 第六章在 Angular 中应用代码约定和设计模式

  • 第七章精通 Angular 中的响应式编程

  • 第八章优雅地处理应用程序状态

第五章:创建动态 Angular 组件

当我们创建组件时,组件的灵活性和可重用性应该是首要考虑的。你不想在组件内部有不必要的依赖,并确保组件可以在尽可能多的场景下提供服务,而不会变得过于复杂。

本章将教你如何使用内容投影、模板引用和模板出口创建真正动态的 UI 组件。我们将学习如何使用组件出口指令和视图容器引用动态渲染组件。

在本章结束时,你将了解何时以及如何将内容投影到 UI 组件中,有效地在你的组件内部使用模板,并根据某些条件在不同的地方输出代码。你还将能够动态加载和渲染组件,从而提高应用程序的灵活性和性能。

本章将涵盖以下主要主题:

  • 深入探讨 Angular 内容投影

  • 使用模板引用和变量

  • 动态渲染组件

深入探讨 Angular 内容投影

通常,在创建一个组件时,你需要在其中显示各种内容。一些好的例子包括模态组件、卡片组件或标签页组件。

让我们考虑一下模态组件;你希望该组件具有可见和隐藏状态、背景以及一些共享样式,以便所有模态都具有相同的视觉和感觉。然而,模态组件每个实例中的内容将大相径庭。有时,你希望在模态中显示一个表单,而其他时候,你希望用它来显示文本或向用户提供操作或配置。很可能,你应用程序中的每个模态都将包含不同的内容。

因此,一个问题出现了:我们如何满足这种需求并创建一个可以在其 HTML 模板中容纳任何所需内容的组件?

你可以硬编码所有选项并使用输入来配置组件,但这很快就会变得难以维护!使用内容投影是创建需要在模板内部显示各种内容的组件的正确方式。Angular 内容投影允许你在组件的 HTML 模板中定义占位符。你可以通过从声明动态组件的父组件 HTML 模板中投影占位符内的内容来填充这些占位符。我们将通过创建模态组件来探索和使用内容投影。

使用内容投影创建模态组件

让我们从在Nx monorepocommon-components库中创建一个模态组件开始。在component类中,添加一个用于模态显示状态的输入和一个用于关闭事件的输出。还要添加一个用于模态标题的输入:

@Input({ required: true }) title = '';
@Input({ required: true }) shown: boolean;
@Output() shownChange = new EventEmitter<boolean>();

现在,让我们为模态组件创建 HTML 模板。从ng-container元素开始。在ng-container元素内部放置模态容器和背景。我们还需要在ng-container元素上放置一个带有显示状态的*ngIf指令:

<ng-container *ngIf="shown">
  <div class=»modal-container»>
  </div>
  <div class=»backdrop»></div>
</ng-container>

接下来,您可以在模态容器内部添加模态标题和模态内容区域。模态标题将包含标题和一个“X”按钮来关闭模态:

<div class="modal-header">
  <h1>{{title}}</h1>
  <span (click)=»shown = false; shownChange.emit()">X
  </span>
</div>

最后,您必须添加模态内容区域。这是我们创建内容投影占位符的地方:

<div class="modal-content">
  <ng-content></ng-content>
</div>

您可以在本书的 GitHub 仓库中找到模态组件的 CSS:github.com/PacktPublishing/Effective-Angular/tree/feature/chapter-five/dynamic-components

如您所见,在模态内容区域内,我们定义了一个ng-content元素:

<ng-content></ng-content>

ng-content元素是投影内容的占位符。ng-content元素将显示我们从父组件投影到模态组件中的所有内容。

您通过在组件的打开和关闭选择器标签之间放置内容,从父组件将内容投影到模态组件中。为了测试模态的内容投影,请在expenses-overview组件内部导入模态组件,并添加一个布尔属性addExpenseShown。然后,将模态组件添加到 HTML 模板中,并在模态组件内部投影我们在上一章中创建的addExpenseForm,如下所示:

<bt-libs-modal [(shown)]="addExpenseShown" [title]="'Add expense'">
  <bt-libs-ui-add-expense-form (addExpense)="addExpense($event)" />
</bt-libs-modal>

现在,当模式显示时,它显示我们投影到组件中的添加费用表单。模态的 HTML 模板中的ng-content元素将被我们投影到模态组件中的添加费用表单所替换。

如您所见,ng-content插槽为您提供了模态组件所需的灵活性,并允许您轻松地将任何内容投影到模态中。与投影内容相关的任何逻辑都在父组件中处理,而不是在模态组件内部,从而实现了关注点的良好分离。

在我们投影的表单的情况下,如果您想处理表单组件的addExpense输出事件,您需要在expenses-overview组件内部处理该事件。对于投影内容的样式也是如此。如果您想对投影内容进行样式设置,您必须在您投影内容的组件的 CSS 文件中进行。在我们的例子中,这将是在add-expense.component.scss文件中。

您现在知道如何将内容投影到单个ng-content插槽中。接下来,您将通过使用多个ng-content插槽来学习如何处理更复杂的投影场景。

探索使用 ng-content 选择的多插槽内容投影

使用单个ng-content插槽提供了很多灵活性,但有时您需要多个位置来投影内容。

假设在一些模态设计中,标题和关闭按钮之间有自定义内容。为了覆盖这种情况,您需要两个地方来投影内容:一个在内容区域,一个在标题区域。当在组件中使用多个 ng-content 元素时,您需要在元素上使用具有定义 select 属性的 ng-content

<div class="modal-header">
  <h1>{{title}}</h1>
  <ng-content select=»[header-content]»></ng-content>
  <span (click)=»shown = false; shownChange.emit()">X</span>
</div>

在这里,我们在模态标题内添加了额外的 ng-content 元素,并分配了值为 header-content 的选择属性。如果我们添加第二个 ng-content 元素而没有定义选择属性,所有投影的内容都将最终出现在模态的 HTML 模板中的最后一个 ng-content 元素内。

为了使投影内容最终出现在特定的 ng-content 元素中,投影内容需要匹配 ng-content 的选择属性值。对于标题的 ng-content 元素,这意味着您需要将 header-content 属性添加到您想要投影到标题槽中的 HTML 中:

<bt-libs-modal [shown]="addExpenseShown" [title]="'Add expenses'">
  <bt-libs-ui-add-expense-form (addExpense)="addExpense($event)" />
  <div header-content>special header content</div>
</bt-libs-modal>

您可以将尽可能多的 HTML 投影到特定的 ng-content 元素中。当 Angular 确定内容将被投影的位置时,有三种场景。让我们逐一探索这些场景:

  • 如果投影的内容与模板中 ng-content 元素的选择属性值匹配,则内容将被投影到具有匹配选择值的 ng-content

  • 如果内容匹配多个 ng-content 选择器,内容将被投影到第一个具有匹配选择值的 ng-content 元素中。

  • 如果投影的 HTML 不匹配任何 ng-content 选择值,内容将被投影到后备 ng-content 元素上——即没有分配 select 属性的 ng-content

    <ng-content></ng-content>
    

在我们的模态组件中,后备槽定义在模态的内容区域内。如果所有内容槽都定义了选择属性,则没有后备槽。当没有后备 ng-content 元素时,任何不与至少一个 select 属性值匹配的投影内容将不会被投影和渲染。

ng-content 标签的 select 属性允许您创建强大的选择器,可以匹配各种内容。您可以根据我们在模态组件中做的那样匹配 HTML 属性,但您也可以匹配 HTML 标签。

例如,假设我们想要将所有的 div HTML 标签投影到标题槽中。我们可以调整选择器如下:

<ng-content header-content attribute from the projected content, and it will still be projected to the header slot because it’s a div HTML tag:

特殊的标题内容

 If you change the `select` attribute value to match a span HTML element, our `div` will end up in the fallback `ng-content` slot inside our content area of the modal component. You can also match on multiple values; for example, if you want to match all `div` and `p` tags, you can create a selector like this:


 You can also match HTML elements on CSS classes, IDs, or specific attribute values. Here is an example of each:


 Using good `select` values for your `ng-content` tags can significantly improve the developer’s experience within your team by preventing you and your teammates from looking up the correct selector for the slots each time they have to project content into the component.
Having multiple `ng-content` slots and making good use of the select attribute on `ng-content` tags allows for even more flexible components with more control over the projected content. But even with multiple `ng-content` elements, you might need more flexibility to cover all your component design needs. Sometimes, you need to output your projected content numerous times inside your UI component or conditionally display the content in different places of the HTML template.
Displaying projected content multiple times or conditionally
When you need to display the projected content multiple times or conditionally in the HTML template, your first instinct might be to add `ng-content` inside a `div` element and apply the `*ngFor`, `*ngIf`, or `*ngSwitch` directive (or the control flow syntax versions – that is, `@for`, `@if`, and `@switch`) to the `div` tag:


 If you try this, you will find that your content will be projected differently than expected. When using `*ngFor`, your content will only be projected once, and when using `*ngIf` or `*ngSwitch` combined with an `ng-content` element, only the first rendered `ng-content` element is displayed. So, the three directives (or control flow syntax) will not work in combination with the `ng-content` tag.
You can use control flow or the `*ngFor`, `*ngIf`, and `*ngSwitch` directives inside the parent component where you project the content:

<bt-libs-modal [shown]="addExpenseShown" [title]="'Add expenses'">

{{header}}

 Using these directives or control flow in the parent component is good enough for many scenarios, but sometimes, you must use the directives inside the component where the `ng-content` elements reside. You might need to use the directives inside the component receiving the projected content because of the design needs of the component or to create a better architecture with a good separation of concerns.
For scenarios where you need to use the `*ngFor`, `*ngIf`, or `*ngSwitch` directives or control flow in the component that’s receiving the projected content, you don’t use `ng-content` as the projection slot; instead, you need to use the `ng-template` element.
In this section, you learned how to project content using the `ng-content` element and where you can run into the limits of what you can do with the projected content when using `ng-content`. You also learned how to effectively use the `select` attribute and project content into multiple slots using `ng-content`.
In the next section, we will learn about `ng-template`, template variables, and template references.
Using template references and variables
In the previous section, we projected content using the `ng-content` element. Yet, we ran into a limitation that didn’t allow you to use control flow or the `*ngFor`, `*ngIf`, or `*ngSwitch` directives in combination with the `ng-content` element. We will start by demonstrating how to resolve this using the `ng-template` element and how you can create and use template variables within HTML templates.
We will create a `ng-template` element and use directives on the project content. The `display-scales` component is just a simple example to demonstrate the concept of content projection combined with structural directives such as `*ngFor`.
Start using the *Nx generator* to create the `display-scales` component next to the `modal` component. The `display-scales` component will receive an array of scale sizes as input and display the projected content in these different scale sizes using a `*``ngFor` directive.
When the `display-scales` component is created, add a `scales-projection.directive.ts` file to the `display-scales` folder. Inside the `scales-projection.directive.ts` file, you can add the following content:

@Directive({

selector: ‹[btLibsScalesProjection]',

standalone: true,

})

export class ScalesProjectionDirective {

constructor(public templateRef: TemplateRef<unknown>) { }

}


 The scales projection directive only injects the `display-scales` component uses the directive to access the projected content. Inside the `display-scales` component class, you need to add the input for receiving the scale sizes and a `@ContentChild()` decorator to access the projected content:

@Input({ required: true }) scaleSizes!: number[];

@ContentChild(ScalesProjectionDirective) content!: ScalesProjectionDirective;


 As you can see, we used `ScalesProjectionDirective` as a value for the `@ContentChild` decorator. The `@ContentChild` decorator will get the projected `ng-template` element from the HTML template and hold a reference to the projected content so that we can use the content within the `display-scales` component. Inside the HTML template of the `display-scales` component, you can add this:

<div *ngFor="let size of scaleSizes; let i = index" [style.transform]="'scale(' + size + ')'">

<ng-container [ngTemplateOutlet]=»content.templateRef»></ng-container>

</div>


 Here, we created a `div` element that will be rendered for each size inside the `scaleSizes` array the component receives as input. Inside the `div` element, we declared a `ng-container` element with the **ngTemplateOutlet** directive declared on the element.
This `ng-container` with the `ngTemplateOutlet` directive will display the projected content, similar to the `ng-content` element we used in the previous section. The `ngTemplateOutlet` directive needs to receive a `TemplateRef` property; this is why we use the `ng-template` element and the directive that injected `TemplateRef`.
We bind the `TemplateRef` property of the `@ContentChild` decorator value to the `ngTemplateOutlet` directive using `content.templateRef`. When using `ng-template` to project your content, you can use `*ngFor` and other structural directives in combination with the projected content. We couldn’t do this when using the `ng-content` element.
Next, you need to project a `TemplateRef` property into the component using `ScalesProjectionDirective`. By doing so, the `@ContentChild` decorator can access the `TemplateRef` property, and we can assign the projected template reference to the `ngTemplateOutlet` directive.
You can project a `TemplateRef` property by using the `ng-template` element. The `ng-template` element is the HTML representation of the `TemplateRef` class. The projected content needs to match the `@ContentChild` decorator, so we need to add the `ScalesProjectionDirective` decorator to the `ng-template` element we are about to project. Don’t forget to import the `display-scales` component and `ScalesProjectionDirective` into the standalone component where you are using the scales component and directive:

<bt-libs-display-scales [scaleSizes]="[0.8, 1, 1.2, 1.4]">

<ng-template btLibsScalesProjection>I scale!</ng-template>

<bt-libs-display-scales>


 After adding the preceding HTML into an HTML template (for example, in the `expenses-overview.component.html` file), you will display the scales component with the `I scale!` content projected.
When you try to project an additional `ng-template` element, you will notice that only one `ng-template` element is projected and used inside the `display-scales` component. Only the first `ng-template` element is used because we use the `@ContentChild` decorator inside the `display-scales` component.
If you want to project multiple `ng-template` elements with the same projection directive (in our case, `ScalesProjectionDirective`), you need to use the `@ContentChildren` decorator instead of the `@ContentChild` decorator. The `@ContentChildren` decorator creates a `QueryList` property of template references instead of a single template reference:

@QueryList,你需要遍历 QueryList 并为列表中的每个节点创建一个ng-container元素。你可以通过将 QueryList 转换为数组并使用*ngFor指令输出列表中的每个项来实现这一点:

<div *ngFor="let item of content.toArray()">
  <ng-container [ngTemplateOutlet]="item.templateRef"></ng-container>
</div>

如果你想要将内容投影到不同的槽位,就像我们使用ng-content标签和select属性所做的那样,你需要创建多个投影指令,如ScalesProjectionDirective,并使用多个@ContentChild装饰器来分离项目的ng-template元素:

@ContentChild(ScalesProjectionDirective) content!: ScalesProjectionDirective;
@ContentChild(@ContentChild elements with different ng-container elements, as follows:

<ng-container [ngTemplateOutlet]="content.templateRef"></ng-container>

`<ng-container [ngTemplateOutlet]="ng-template elements and use the *ngFor, *ngIf, and *ngSwitch directives or control flow syntax in combination with projected content.``

现在,让我们探索我们还可以用ng-template元素做什么,模板变量是什么,以及我们如何在组件中有效地使用它们。我们将从了解模板变量开始。

有效地使用模板变量

在构建组件时,我们经常需要在一个 HTML 模板的一部分中使用另一个 HTML 模板的一部分。你可以使用如@ViewChild@ContentChild之类的装饰器从你的 HTML 模板中访问元素,但你也可以使用模板变量。使用模板变量可以帮助简化你的代码,因为你可以处理在 HTML 模板的另一个地方需要模板的一部分的情况,所有这些都在你的 HTML 中;不需要在你的组件类中创建一个属性并在你的 HTML 模板中使用这个变量。

一个模板变量可以引用五个不同的元素:

  • HTML 模板中的 DOM 元素

  • 在 HTML 模板中使用的指令

  • 在 HTML 模板中使用的组件

  • 从 HTML 模板中使用的ng-template元素的TemplateRef`

  • 一个 Web 组件

模板变量是通过使用井号(#)符号与变量名结合来创建的。例如,如果你想从模板中的div元素创建一个模板变量,你可以使用以下语法:

<div #exampleVar is the template variable, and the variable holds a reference to the DOM element it’s placed on; in this example, the DOM element is the div element. You can now use the exampleVar template variable within your HTML template. When you assign the template variable with a DOM element, you can access all properties of the DOM element, similar to when you access it from within your component class:

<div>模板变量说:{{exampleVar.innerText}}</div>


 You can use the template variable with interpolation or anywhere else that you can use component properties inside your template:

<input #name placeholder="Enter your name" />

<button (click)="submitName(name.value)">提交</button>


 If you want to assign a template variable to a component, you can use the same syntax but only on the selector of a custom component:

<bt-libs-modal #modal [(shown)]="addExpenseShown"> ……</bt-libs-modal>

<bt-libs-modal>


 When you use a template variable on a component, you can access all the public properties and methods of the component. Sometimes, it can be useful to access the properties of the child component in this manner, but you should avoid calling public methods like so.
It’s not advised to call methods and potentially mutate the data of child components using template variables as it’s a bit of an anti-pattern. It would be best if you tried to do all parent-child component communication through inputs and outputs unless there is no other way. Yet it’s good to know you can access the properties through template variables so that you can recognize when someone else uses it or when you have a situation where you want or need to use it.
Another common and more accepted use case of template variables is assigning a directive to a template variable. We did this in *Chapter 4* when we created template-driven forms. The syntax for assigning a directive to a template variable differs slightly from assigning a DOM element or component to a template variable. When assigning a directive to a template variable, we must create the variable as normal and assign the directive using the is sign (`=`):

<form #expenseForm="ngForm">……</form>

<div *ngIf="!expenseForm.form.valid">

<p>无效的表单</p>

</div>


 In the preceding example, we assigned the `ngForm` directive to the `expenseForm` template variable. We can now access the `ngForm` directive and all its properties through the template variable. Using the template variable makes it easy to use all kinds of form values and statuses such as `pristine`, `dirty`, `valid`, and `invalid`. There’s no need to create a variable within your component class; you can handle everything from within your HTML template.
You can also use template variables to access `TemplateRef` instances created with `ng-template` elements. Accessing a `TemplateRef` instance through a template variable works similarly to assigning a template variable to a component or DOM element; you just add the template variable to the `ng-template` element:

在下一节中,我们将探讨如何使用 ng-template 元素(带有或没有模板变量)或使用组件类中的 TemplateRef 来显示 TemplateRef 元素。

有效使用 TemplateRef 元素

模板引用可以在您的 HTML 模板中使用 ng-template 元素定义。您可以在组件和指令类中使用 TemplateRef 类访问 TemplateRef 元素。TemplateRef 类和 ng-template 元素在形式上不同,但本质上是同一事物。

TemplateRef 元素可以用于许多用例。正如本章开头 使用模板引用和变量 部分的示例所示,当需要将投影内容与结构指令(如 *ngFor*ngIf*ngSwitch)结合时,可以使用 TemplateRef 进行内容投影。

TemplateRef 另一个被使用的地方是在自定义结构指令中。结构指令根据某些逻辑向视图容器添加或删除 TemplateRef 元素。当我们创建自定义结构指令时,我们在 第三章 中添加和删除了模板引用到视图容器。

一个 TemplateRef 元素也可以与模板上下文结合,构建真正动态的组件,可以使用不同的数据源显示不同的模板。您还可以使用 TemplateRef 在页面的不同位置显示内容——例如,当您在移动和桌面设计中将相同的内容放置在页面的不同位置时。

ng-template 元素在需要根据 *ngIf 指令的结果条件性地渲染内容时也表现出色。让我们继续探讨 TemplateRef 的用例,并创建一些示例。

将 ng-template 与 *ngIf 结合

您可以根据 *ngIf 语句的结果显示 ng-template 元素。在 Angular 应用的 HTML 模板中,您经常会看到类似的东西:

<div *ngIf="expenses"> ……… </div>
<div *ngIf="!expenses">Loading...</div>

您可以使用 ng-template 元素做同样的事情,并定义一个在 *ngIf 语句不满足时显示的模板:

<div *ngIf="expenses else loading"> ……… </div>
<ng-template #loading>
  <div>Loading...</div>
</ng-template>

我们没有在两个 div 元素上声明 *ngIf 指令,而是使用了 *ngIf else 语法。我们引用了放置在 ng-template 元素上的模板变量,用于 else 语句。现在,当没有支出时,将显示加载模板。这只是一个简单的例子,但如果您需要显示条件性大模板,使用模板有助于分离 HTML。此外,当您的 *ngIf 语句使用 async 管道时,这通常是情况,您不能使用第一种方法:

<div *ngIf="!expensesVm$ | async">Loading...</div>

当你在 *ngIf 指令内部使用 async 管道时,你无法在模板中使用逻辑非运算符(!)。你可以在组件类中使用 RxJS 管道运算符在你的可观察值中设置一个值。然而,使用 *ngIf else 语句和一个 ng-template 元素会更简单、更干净,如下所示:

<div *ngIf="expensesVm$ | async as expense; else loading">……</div>
<ng-template #loading>
  <div>Loading...</div>
</ng-template>

使用前面提到的语法,你可以使用 async 管道来处理你的可观察值并显示一个替代模板,只要可观察值没有解析出任何值,或者可观察值的结果被映射到假、空或未定义的值。

你甚至可以更进一步,将所有的 HTML 分割成单独的模板,使用 *ngIf then-else 语法结合 ng-template 元素来实现。使用 if-then-else 语法在你的 HTML 中实现清晰的分离,并在你有大模板且需要条件性地显示多个内容块时,有助于提高可维护性:

<ng-container *ngIf="expensesVm$ | async; then expenses; else loading"></ng-container>
<ng-template #expenses> <div>……</div> </ng-template>
<ng-template #loading> <div>Loading...</div> </ng-template>

你使用什么解决方案主要取决于你的偏好和团队的偏好。没有真正的约定或最佳实践。当与 async 管道一起工作时,我喜欢使用 ng-template 语法,而不是在我的可观察值上使用管道运算符来分配组件属性,这可以用于带有逻辑非运算符的 *ngIf 语句。

对于简单的同步值场景,我通常使用 *ngIf 和带有逻辑非运算符的 *ngIf 来显示正确的 HTML 内容 - 除非有两个大块 HTML 内容,在这种情况下,我更喜欢使用模板。

现在你已经知道了如何使用 ng-template 元素结合 *ngIf 指令来条件性地显示模板,让我们来探讨如何使用 ng-template 在同一页面的不同位置显示模板。

使用 ng-template 在正确的位置显示内容

通常,你的设计是这样的,同一块内容在移动端和桌面端视图中被放置在页面的不同位置:

图 5.1:移动端和桌面端视图的内容放置

图 5.1:移动端和桌面端视图的内容放置

图 5.1 所示,在移动端,内容块 CD 显示在元素 B 下方;在桌面端视图,相同的内容显示在元素 B 之上。对于块 A,情况相反。你不想重复 HTML 以块 ACD 为例,一次放在块 B 之上,一次放在块 B 之下。重复 HTML 会破坏 不要重复自己DRY)原则,并导致更难以维护和阅读的 HTML 模板。

在这种情况下,ng-template 元素可以提供帮助,让你可以在页面的不同位置显示相同的模板,而无需重复 HTML:

<ng-container *ngIf="isMobile" [ngTemplateOutlet]="A"></ng-container>
<ng-container *ngIf="!isMobile" [ngTemplateOutlet]="CD"></ng-container>
<div>Block B</div>
<ng-container *ngIf="!isMobile" [ngTemplateOutlet]="A"></ng-container>
<ng-container *ngIf="isMobile" [ngTemplateOutlet]="CD"></ng-container>
<ng-template #A>
  <div> …… </div>
</ng-template>
<ng-template #CD>
  <div> …… </div>
</ng-template>

如前述 HTML 片段所示,我们使用了一个 ng-template 元素来定义 A 块的 HTML,以及一个 ng-template 元素来定义 CD 块的 HTML。我们使用带有 ngTemplateOutlet 指令的 ng-container 元素显示模板,该指令引用在 ng-template 元素上声明的模板变量。

在这个例子中,我们使用了一个 isMobile 布尔值来隐藏或显示正确的 ng-container 元素,但你也可以将它们包裹在一个 div 元素中,并通过 CSS 隐藏它们。使用 ng-template 元素和 ng-container 元素结合,我们只需创建一次 ACD 块的 HTML,从而得到一个更干净、更易于维护的 HTML 模板。

你还可以进一步清理 HTML,并在你的组件类中分配 aboveBbelowB 模板。我不喜欢在我的组件类中添加额外的逻辑,但这只是我的个人偏好。

这里是一个如何在你的组件类中处理这个例子。首先,调整你的 HTML 如下:

<ng-container [ngTemplateOutlet]="aboveB"></ng-container>
<div>Block B</div>
<ng-container [ngTemplateOutlet]="belowB"></ng-container>

现在,在你的组件类中,通过使用 @ViewChild() 装饰器获取 ACD 模板引用,并为 aboveBbelowB TemplateRef 元素创建一个变量:

@ViewChild('A') templateA!: TemplateRef<unknown>;
@ViewChild('CD') templateCD!: TemplateRef<unknown>;
aboveB!: TemplateRef<unknown>;
belowB!: TemplateRef<unknown>;

ngAfterViewInit 生命周期钩子中,你可以使用正确的模板分配变量:

this.aboveB = this.isMobile ? this.templateA : this.templateCD;
this.belowB = this.isMobile ? this.templateCD : this.templateA;

如果你的组件设置为 OnPush 变更检测策略,你需要手动触发变更检测以更新和显示模板。如果你不使用 OnPush 变更检测策略,你的视图将自动更新。

当你有一个包含多个模态的页面时,也可以做类似的事情。而不是定义多个模态组件并将正确的内容投影到每个模态中,你可以创建一个包含 ng-container 元素和 ngTemplateOutlet 指令的模态,并在模态打开时设置正确的模板:

showModal = false;
modalTitle = '';
modalContent!: TemplateRef<unknown>;
openModal(title: string, content: TemplateRef<unknown>) {
  this.modalTitle = title;
  this.modalContent = content;
  this.showModal = true;
}

在前述代码片段中,我们创建了一个 openModel 函数,该函数将在需要打开其中一个模态组件时从 HTML 模板中被调用。我们将传递模态的标题和内容 TemplateRef 元素:

<button (click)="openModal('Title A', modalA)">Open modal A</button>
<button (click)="openModal('Title B', modalB)">Open modal B</button>
<bt-libs-modal [(shown)]="showModal" [title]="modalTitle">
  <ng-container [ngTemplateOutlet]="modalContent"></ng-container>
</bt-libs-modal>
<ng-template #modalA>
  <div>A...</div>
</ng-template>
<ng-template #modalB>
  <div>B...</div>
</ng-template>

在 HTML 中,我们现在只显示一个模态,并在其中显示 TemplateRef 元素,该元素在组件类的 openModal 方法中设置。当相应的按钮被点击时,我们将 HTML 模板中的 TemplateRef 元素发送到我们的 openModal 函数。

正如你可能注意到的,ng-template 元素和 ngTemplateOutlet 可以给你的组件带来很多灵活性,并让你轻松地在不同的位置显示不同的模板。但通过添加上下文和使用 TemplateRef 作为输入,ng-template 可以提供更多的灵活性。

使用模板和上下文创建动态组件

“我们已经看到了如何结合内容投影和 ng-template 元素。在本节中,我们将使用 TemplateRef 作为组件输入,并为 TemplateRef 提供额外的上下文,使其更加动态。展示带有上下文的 ng-template 的一个好例子是创建一个动态选择组件,因此请创建一个选择组件,位于 common-components 库中,紧挨着 modaldisplay-scales 组件。”

“一旦创建了 select 组件,首先添加一个用于选择选项的输入和一个 TemplateRef 元素。此外,添加一个用于默认 selectedIndex 选项的输入和一个输出,当选择发生变化时。最后,添加一个在做出选择时设置选定索引的函数,并使用输出发射选定值:”

@Input({ required: true }) options!: unknown[];
@Input() optionTemplate?: TemplateRef<unknown>;
@Input() selectedIndex?: number;
@Input() labelKey?: string;
@Output() selectedChange = new EventEmitter<unknown>();
onOptionChange(index: any) {
  this.selectedIndex = index.target.value;
  this.selectedChange.emit(this.options[index.target.value]);
}

“接下来,我们将添加动态选择组件的 HTML。首先创建一个选择元素和一个当没有选定值时的选项。接下来,添加一个带有 *ngFor 指令的 ng-container 元素,该指令遍历在选项输入中接收到的每个选项。我们还将跟踪 for 循环的索引,因为我们将在选项值中使用该索引。在 *ngFor 元素内部,我们将定义一个选项元素和一个另一个 ng-container 元素,该元素定义了 ngTemplateOutlet 指令,用于显示默认模板或作为输入接收到的模板。当选择选项没有特殊显示需求时,可以使用默认模板。在所有其他情况下,可以通过 optionTemplate 输入提供模板:”

<select (change)="onOptionChange($event)">
  <option [value]=»null» [selected]=»!selectedIndex">Make selection</option>
  <ng-container *ngFor="let option of options; index as i">
    <option [value]=»i" [selected]="i === selectedIndex">
      <ng-container [ngTemplateOutlet]=»optionTemplate || defaultTemplate»
        [ngTemplateOutletContext]="{ $implicit: option}">
      </ng-container>
    </option>
  </ng-container>
</select>
<ng-template #defaultTemplate let-option>
  {{ labelKey ? option[labelKey] : option }}
</ng-template>

“如您所见,我们还在 ng-container 元素中添加了 ngTemplateOutletContext 指令,它将显示模板。ngTemplateOutletContext 指令被分配给一个包含 $implicit 属性的对象。分配给 ngTemplateOutletContext 指令的对象可以在 ng-template 元素中使用,就像我们在前面的代码片段中所做的那样。您可以在 ng-template 元素中使用前面代码示例中的 $implicit 值。要使用 $implicit 值,您必须使用 let-propertyName 语法,如下所示:”

<ng-template ngTemplateOutletContext directive. When you have other context properties besides the $implicit property, you must use a slightly different syntax to use the additional property. Let’s say you have a shown property inside the ngTemplateOutletContext directive:

[显示属性,以便可以使用此语法在 ng-template 元素中使用:

ng-template element; the right-hand side needs to match the property you declared in the ngTemplateOutletContext directive. For example, you can replace let-shown with let-shownValue or anything else you desire, so long as it starts with let-:

<ng-template let-shownValue="shown">

<div>{{shownValue}}</div>

</ng-template>


 Now that you know how to use the context inside `ng-template` elements, let’s test the `select` component and see how we can provide a custom template for the `select` component.
If you’re using the `select` component in another standalone component, you must import the `select` component before you can declare it in the HTML template. Once you’ve imported the `select` component, you can use it in the template like this:

<bt-libs-select (selectedChange)="onOptionChange($event)" [options]="['Test', 'Test 2']">

</bt-libs-select>


 In a real-world scenario, you should define the input for the options inside the component class, but I’ve added it directly in the HTML for demonstration purposes. The default `ng-template` element will be used because we didn’t provide a `TemplateRef` element for the `optionTemplate` input of the `select` component.
Now, let’s create a custom template to provide to the `select` component. Let’s say you have an array of expenses that looks like this:

[{expense: 'Food',amount: 10},{expense: 'Gas',amount: 20}]


 Now, we can create a template to display the expense and amount inside the `select` component:

<ng-template #expenseSelect let-expense>

<span>产品: {{ expense.expense }}, 金额: {{ expense.amount }}</span>

</ng-template>


 When the template is created, you need to assign the template to the `optionTemplate` input of the `select` component, like this:

<bt-libs-select (selectedChange)="onOptionChange($event)" [optionTemplate]="expenseSelect" [options]="expenses">

</bt-libs-select>


 After providing the custom template to the `optionSelect` input, the select component will use the `expenseSelect` template instead of the default template. The `expenseSelect` template receives the expense object that’s used inside the template through the `ngTemplateOutletContext` directive. By using the `ng-template` element and the `ngTemplateOutletContext` directive, you can display any content you want inside the `select` component and use any array of objects that’s necessary to provide the data for the template, giving you all the flexibility you need for a truly dynamic component.
We covered a lot in this section. First, you learned about `TemplateRef` and how to use it inside the component class or HTML. Using the `ng-template` and `ng-content` elements, you learned how to use structural directives combined with projected content. You also learned how to provide context to `ng-template` elements using the `ngTemplateOutletContext` directive to build truly dynamic components. Finally, you learned about template variables and how to use them to access values inside your templates or display content conditionally.
In the next and last section of this chapter, you will learn about rendering components dynamically.
Rendering components dynamically
In some scenarios, you might want to load and render components dynamically. You might want to load and render components dynamically because you don’t know the layout or exact components of the page upfront or because you have data and resource-intensive components that you only want to load and render if the user needs them in the view.
Here are some common examples of when dynamically loading components is useful:

*   When building a website builder where customers can build up web pages based on a set of components you provide. With a website builder, you don’t know how the user will create the page’s layout and what components will be used. You want to load components dynamically whenever the user adds them to the page.
*   When you have a multi-step wizard where the content of the next steps differs based on the users’ choices during each step of the wizard.
*   Tabs, modals, and popups with resource-intensive components or where different components can be displayed based on the user interaction. You only want to load and render the components if the user requests them.
*   When you allow users to configure a list of widgets in your application. You don’t know what widgets the user will activate, and in what order they want them displayed, so it would make little sense to load and render them before the user configures them.
*   An ad banner component with different ads cycling through the ad banner component. When different teams frequently add new ad components for the banner, a static component structure would make little sense.

When you need to load and render components dynamically, there are three approaches: the `ngComponentOutlet` directive, the `ViewContainerRef` class, and, since Angular 17, the defer control flow. First, we’ll use `ngComponentOutlet` as this is the most straightforward solution that works for any Angular version. Next, we will show you how to render and load components dynamically with the defer control flow that was introduced in Angular 17.
Rendering components dynamically using ngComponentOutlet
To demonstrate dynamic component rendering using the `ngComponentOutlet` directive, we will create a widget component that can render different widgets with their own custom functionality and design. Let’s say the widget container can receive widget data as input and render the widget it receives as input; this way, you can display a different widget for each page if needed.
Start by generating a `widget-container` component inside the `common-components` library. This `widget-container` component will dynamically render widget components using the `ngComponentOutlet` directive.
Next, create two more components: a weather widget and a clock widget component. For demonstration purposes, we will leave the weather and clock widget templates as Nx generated them by default.
Now, in the widget container component, define an `ng-container` element in the template and add the `ngComponentOutlet` directive to the `ng-container` element:

<ng-container *ngComponentOutlet="widget.component" />


 As you can see, we assign the `ngComponentOutlet` directive with `widget.component`. So, let’s create an interface for the widget and add the widget property to the component class:

export interface widget { component: Type<any> | null };


 Make the widget property an input so that the widget container can receive this widget property from the parent where you declare the widget container component:

@Input() widget: widget = {component: null};


 As a simple example, this is all you need to render a component dynamically. We will extend this example quite a bit, but to showcase the dynamically rendered component, you can now use the widget container by adding the following to the HTML template of one of your components:

<bt-libs-widget-container *ngIf="activeWidget" [widget]="activeWidget"></bt-libs-widget-container>


 In the component class, you need to add the active widget property that’s used for the input of the widget container:

activeWidget!: widget;


 Let’s say we want to alternate between the clock and the weather widget every 5 seconds. We can use `setInterval` for this and assign the `activeWidget` property with the clock or weather widget:

protected readonly cd = inject(ChangeDetectorRef);

showWeather = true;

ngOnInit() {

setInterval(() => {

this.activeWidget = { component: this.showWeather ? WeatherWidgetComponent : ClockWidgetComponent };

this.showWeather = !this.showWeather;

this.cd.detectChanges();

}, 5000)

}


 When you open your component in the browser, you will see that after 5 seconds, the weather widget is shown, and the widget will alternate with the clock widget every 5 seconds after that. This is, of course, just a simple example and can be improved upon a lot, but it shows how to render the widgets dynamically quite well.
Let’s continue and see how we can improve the widget container and add additional flexibility. In some scenarios, your widget components might need data or access to a service to function properly. When the widget needs to do this, you can add an injector to the `ngComponentOutlet` directive.
Using an injector with ngComponentOutlet
You can provide additional data to your dynamically loaded components using the `injector` property of the `ngComponentOutlet` directive.
Let’s start by adding the `injector` property to the `ngComponentOutlet` directive inside the HTML template of the widget container component:

<ng-container *ngComponentOutlet="widget.component; 注入属性:

export interface widget { component: Type<any> | null; injector property, you must adjust the widget and the input the widget container component receives. First, we will adjust the widget. As an example, we will adjust the weather widget so that it can receive a city and a message that we will display in the HTML template.
Create a new file called `widget-tokens.ts` and add an interface for the weather widget data and an injection token, like this:

export interface WeatherWidgetData {city: string; message: string;}

export const WEATHERWIDGET = new InjectionToken('weather widgets');


 Inside the weather widget component class, you need to inject the `WEATHERWIDGET` injection token:

widgetData = inject(WEATHERWIDGET);


 Now, adjust the HTML template of the weather widget component so that it uses the values of the `WeatherWidgetData` interface:

{{widgetData.city}}: {{widgetData.message}}


 That is everything you need to do inside the weather widget component itself. So, to reiterate, you created an interface and injection tokens, you injected the injection token, and you will receive the injector from the widget container’s `ngComponentOutlet` injector property. To close the circle and make everything work, you need to provide the correct input to the widget container and include the injector that will provide the city and message data.
Before you added the injector, you provided the following as input to the widget container to display the weather widget:

{ component: WeatherWidgetComponent }


 To provide the city and message data for the weather widget, you need to add the `injector` property to the input so that the widget container can include it in the `ngComponentOutlet` directive.
You can create the `injector` property by using the `create()` method on the `Injector` class:

Injector.create()


 Inside this `create` method, there’s a `providers` object. This is similar to the provider objects you added inside the `providers` array to a component, module, or your application configuration object in your `app.config.ts` file:

{ providers: [{ provide: WEATHERWIDGET, useValue: { city: 'Amsterdam', message: 'Sunny' } }] }


 For the `provide` property, use the injection token you created inside the weather widget component file. For our example, you’ll use the `useValue` property and assign it to the city and message value you want to use. You can create any valid provider object here, so you can also provide services or factory classes instead of the `useValue` property. The entire `Injector.create()` method looks like this:

Injector.create({ providers: [{ provide: WEATHERWIDGET, useValue: { city: 'Amsterdam', message: 'Sunny' } }] })


 The entire input object for the widget container looks like this:

{ component: WeatherWidgetComponent, injector: Injector.create({ providers: [{ provide: WEATHERWIDGET, useValue: { city: 'Amsterdam', message: 'Sunny' } }] }) }


 Now, when the weather widget is displayed through the widget container, the `injector` property is passed along, and the city and message values are used within the HTML template of the weather widget. Cool stuff!
Using the `inject` property of the `ngComponentOutlet` directive allows you to provide any service, factory method, or static data to the dynamically rendered components, making that dynamically rendered component as flexible as any other component.
Since Angular version 16.2.0-next.4, you can simplify providing (simple) values to dynamic components a bit by using the inputs property on the `ngComponentOutlet` directive instead of the injector property. To use the inputs property on `ngComponentOutlet`, you need to add `@Input()` properties to the widget. In our example, we can add `city` and `message` `@Input()` properties to the weather widget:

@Input() city: string;

@Input() message: string;


 Once you’ve added the `@Input()` properties to the widget, you can provide the `@Input()` properties with values through the `ngComponentOutlet` `inputs` property, like this:

<ng-container *ngComponentOutlet="widget.component; 输入属性 ngComponentOutlet 指令的 inputs 属性需要接收一个对象;在我们的例子中,我们把这个对象命名为 widgetInputs。这个对象有一个键对应于组件的每个输入属性和相应的值。所以,在我们的例子中,widgetInputs 对象看起来是这样的:

widgetInputs = {
  'city': 'Amsterdam',
  'message': 'Sunny',
}

使用 inputs 属性要容易得多,但它只能提供具有简单值的对象。使用 injector 属性提供了更多的灵活性,因为它可以提供简单值,也可以提供类、服务以及你可以在 Angular 应用程序中注入的任何其他内容。

我们的小部件系统已经可以动态渲染组件并提供注入器给动态创建的组件。但是,动态渲染的组件仍然会预先加载,所以我们还需要稍作改进,以确保我们只在需要渲染时加载小部件组件。

懒加载动态组件

为了进一步改进,我们可以懒加载我们的动态组件。目前,当你加载页面时,所有小部件都会预先加载。如果我们只在需要时加载小部件组件,或者换句话说,懒加载组件,那就更好了。我们需要更改我们的小部件容器组件,以便它懒加载我们的动态渲染组件。

让我们从创建一个名为 widget-loaders.ts 的新文件开始。widget-loaders.ts 文件将列出一些类型和一个带有懒加载小部件导入语句的对象,有点像使用路由懒加载组件:

const widgetKeys = ['weatherWidget', 'clockWidget'] as const;
type WidgetKey = typeof widgetKeys[number];
export type WidgetLoader = { [key in WidgetKey]: () => Promise<any> };
export const widgetLoaders: WidgetLoader = {
  weatherWidget: () => import('../weather-widget/weather-widget.component'),
  clockWidget: () => import('../clock-widget/clock-widget.component'),
};
export type WidgetOption = WidgetLoader[keyof WidgetLoader];

我们首先创建了一个 widgetKeys 常量;在这里,你定义了可以在 widgetLoaders 对象内部使用的所有键。接下来,我们创建了一个 widgetKeys 常量的类型,以创建一个类型安全的 WidgetLoader 类型。WidgetLoader 类型定义了一个键值对,其中键只能是 widgetKeys 常量中声明的值。

接下来,我们创建了 widgetLoaders 对象,它使用 WidgetLoader 类型。widgetLoaders 对象将包含键值对,其中键是来自 widgetKeys 常量的值,值是懒加载小部件的导入语句。

最后,我们创建了一个 WidgetOption 类型,它允许你从 widgetLoaders 对象中获取单个值,而无需其他任何东西。现在,我们已经创建了一种类型安全的方式来定义和选择小部件加载器,我们可以开始调整小部件容器组件。

首先,我们必须移除我们已有的小部件输入,并用一个常规输入属性替换它。然后,我们可以添加一个 injector 属性的输入和一个来自 widgetLoaders 对象的 WidgetOption 属性的输入:

@Input() injector!: Injector;
@Input({ required: true }) widgetLoader!: WidgetOption;
widget: widget = { component: null, injector: null };

你还需要注入 ChangeDetectorRef,因为我们必须手动触发变更检测:

protected readonly cd = inject(ChangeDetectorRef);

接下来,你必须添加带有 async 关键字的 ngOnChanges 生命周期钩子,因为我们将会使用异步 await 来加载动态组件:

async ngOnChanges(changes: SimpleChanges) {}

ngOnChanges 生命周期钩子内部,我们将获取 widgetLoader 输入的当前值:

const widgetLoader: WidgetOption = changes['widgetLoader'].currentValue;

接下来,我们将使用 widgetLoader 值来懒加载小部件组件。当小部件组件加载完成后,我们将使用懒加载的组件和 injector 属性分配小部件属性。最后,我们需要触发变更检测以反映 UI 中的更改:

const widget = await widgetLoader();
this.widget = { component: widget[Object.keys(widget)[0]], injector: this.injector };
this.cd.detectChanges();

在将前面的更改添加到小部件容器组件后,一切就绪,你可以动态地懒加载和渲染小部件组件。为了测试这一点,我们需要在另一个组件中使用小部件容器组件,并给它一个小部件加载器和注入器作为输入:

<bt-libs-widget-container [widgetLoader]="widget" [injector]="injector"></bt-libs-widget-container>

例如,如果你想交替显示时钟和天气小部件,你可以在模板中添加小部件容器的组件类中添加以下代码:

widget: WidgetOption = widgetLoaders.weatherWidget;
injector: Injector | null = Injector.create({ providers: [{ provide: WEATHERWIDGET, useValue: { city: 'Amsterdam', message: 'Sunny' } }] });
protected readonly cd = inject(ChangeDetectorRef);
ngOnInit() {
  setInterval(() => {
    this.widget = this.widget === widgetLoaders.clockWidget ? widgetLoaders.weatherWidget : widgetLoaders.clockWidget;
    this.injector = this.widget === widgetLoaders.clockWidget ? null : Injector.create({ providers: [{ provide: WEATHERWIDGET, useValue: { city: 'Amsterdam', message: 'Sunny' } }] });
    this.cd.detectChanges();
  }, 5000)
}

当你检查浏览器开发者工具的 网络 选项卡时,你会看到当它们第一次进入视图时,天气和时钟小部件组件正在被加载。当小部件再次显示,并且组件已经加载时,浏览器不会再次加载它们,因为浏览器已经为你缓存了它们。现在,你有一个真正动态的小部件系统,你可以按需懒加载和渲染小部件。

使用延迟控制流动态渲染组件

在 Angular 17 中,defer-widget

这个 DeferWidgetComponent 将具有与我们的 widget-container 组件相同的功能,只是它将使用延迟控制流而不是 ngComponentOutlet 指令。你可以从创建一个名为 defer-widget 的组件开始,紧挨着 widget-container 组件。一旦你创建了组件,就在新创建的 defer-widget 文件夹中创建一个 widgets.enum.ts 文件。在 widgets.enum.ts 文件中添加以下 enum

export enum Widgets {
  Clock,
  Weather
}

接下来,您需要为活动小部件添加一个输入,即小部件数据,并在DeferWidgetComponent内部添加一个引用Widgets枚举的属性:

@Input() activeWidget!: Widgets;
@Input() activeData!: any;
widgets = Widgets;

DeferWidgetComponent的 HTML 模板内部,您需要在延迟块内添加您的组件。延迟块接收一个触发器作为参数。当延迟触发器被触发时,延迟块将加载并渲染延迟块内的内容。对于您的小部件组件,您需要使用when触发器。when触发器在提供的条件解析为true时加载并渲染内容。

我们还希望当条件再次为false时,内容能够再次隐藏。要隐藏当条件为false时的内容,您还需要添加一个*ngIf指令或使用if控制流。我将使用*ngIf指令:

@defer (when activeWidget === widgets.Clock) {
  <bt-libs-clock-widget *ngIf="activeWidget === widgets.Clock" />
}
@defer (when activeWidget === widgets.Weather) {
  <bt-libs-weather-widget *ngIf="activeWidget === widgets.Weather" [widgetData]="widgetData" />
}

如果没有*ngIf指令或控制流语法,当条件返回false时,小部件将不会消失。defer语法的when语句在解析为true后不会再次评估条件。

上述代码是您需要用于懒加载和渲染小部件的所有内容。如您所见,这比使用ngComponentOutlet指令要简单得多。

由于我们已经在某些地方放置了一些代码,我们仍然需要对天气小部件进行一些更改,以便我们的新延迟解决方案能够正常工作。在天气小部件组件内部,您有一个widgetData属性。您可以将它放在comment下,并用一个@Input()属性替换,如下所示:

// widgetData: WeatherWidgetData = inject(WEATHERWIDGET);
@Input() widgetData!: WeatherWidgetData | null;

我们之前注释掉的widgetData属性用于ngComponentOutlet指令方法。对于我们的新延迟方法,我们将使用常规组件,@Input()。您还可以更新天气组件的 HTML 模板,并且只有在接收到widgetData属性时才渲染城市和消息:

<p *ngIf="widgetData">{{widgetData.city}}: {{widgetData.message}}</p>

现在,一切应该都能正常工作!为了测试DeferWidgetComponent,我们可以在expensesOverviewComponent中使用它,我们现在使用widgetContainerComponent。在 HTML 模板中,您可以替换旧的容器小部件为新延迟小部件:

<bt-libs-defer-widget [activeWidget]="widget" [widgetData]="widgetData" />

现在,我们只需要在expensesOverviewComponent内部设置widgetwidgetData属性。您可以取消注释我们之前用于小部件容器的旧widgetinjector属性,并添加这两个属性:

widget!: Widgets;
widgetData: any = null;

最后,我们必须更改setInterval内部的逻辑。再次,您可以取消注释我们之前用于交替小部件容器中天气和时钟小部件的旧代码,并用此代码替换:

this.widget = this.widget === Widgets.Clock ? Widgets.Weather : Widgets.Clock;
this.widgetData = this.widget === Widgets.Clock ? null : { city: 'Amsterdam', message: 'Sunny' };
this.cd.detectChanges();

现在,当您保存一切时,新的延迟小部件组件应该与容器小部件组件工作得完全一样。5 秒后,时钟小部件将被懒加载并渲染,再过 5 秒,天气小部件将被懒加载并渲染。这两个小部件将每 5 秒交替一次。

通过这样,你已经使用ngComponentOutlet指令和延迟控制流语法创建了一个带有懒加载小部件的组件。使用ngComponentOutlet指令,你可以提供注入器,而且不需要使用*ngIf指令来移除小部件,但总体来说,延迟控制流语法感觉要干净和容易得多。

摘要

在本章中,我们学到了很多!首先,你学习了如何使用内容投影使组件更加灵活。我们创建了一个模态组件,并展示了使用单个插槽和多个插槽的内容投影。我们还了解到,你无法在投影内容上组合结构指令。

接下来,我们深入探讨了模板变量和模板引用。你学习了如何使用ng-template创建灵活和动态的组件,如何使用模板变量访问组件值和输入属性,以及如何根据特定条件显示不同的模板。你还学习了如何为ng-template元素提供上下文,以构建真正动态的组件,这些组件可以满足你对组件的所有设计需求。

最后,你学习了关于动态组件渲染和加载的内容。你学习了何时应该使用动态渲染或加载的组件,以及如何使用ngComponentOutlet@defer语法在运行时动态渲染和懒加载组件。

在下一章中,我们将开始学习 Angular 中的约定和设计模式,以便我们可以改进代码的设置和实现。







第六章:在 Angular 中应用代码规范和设计模式

在本章中,我们将探讨 Angular 应用程序中常用的代码规范、最佳实践和设计模式。你还将创建一个通用的 HTTP 服务,并使用 HTTP 拦截器模拟 API 响应。

遵循良好的代码规范可以使你编写出一致的代码。无论你是单独编写代码还是在团队中,规范都能确保你使用相似的语法来处理常见情况,并遵循最佳实践。使用良好的设计模式可以帮助你编写出扩展性好且经过实战检验的代码实现。

代码规范和最佳实践更多地关注过程和与风格相关的方面,例如使用 CLI、命名、使用类型或防止嵌套可观察对象。另一方面,设计模式关注的是你如何在代码库中设置、处理和实现常见情况、问题和流程。

在本章结束时,你将了解 Angular 应用程序中所有关于代码规范、最佳实践和常用设计模式的内容。在本章中,你将学习到的某些模式和原则包括继承、外观服务、可观察对象、响应式编程和反模式。本章将为后续章节提供一个良好的基础,在这些章节中,我们将深入探讨响应式编程和状态管理。本章将帮助你理解良好设计模式和代码规范的好处。

本章将涵盖以下主要内容:

  • 探索 Angular 应用程序中常用的代码规范和最佳实践

  • 探索 Angular 应用程序中常用的设计模式

  • 构建包含模型适配器的通用 HTTP 服务

探索 Angular 应用程序中常用的代码规范和最佳实践

在本章的第一部分,你将了解 Angular 应用程序中的代码规范和最佳实践。使用代码规范确保所有参与你项目的人使用相似的变量、文件和文件夹命名。良好的代码规范还能使代码更易于阅读,并允许你快速识别某些特性、实现或数据类型。此外,代码规范使你的代码更加一致,更容易调试、重构和理解。为你的项目设置良好的代码规范可以促进最佳实践的使用。代码规范还使新开发者更容易融入代码库,因为他们有一套规则可以遵循,这使他们能够以与其他代码库工作者相似的方式编写代码。

个人认为,创建一个包含你在项目中采用的所有代码规范和最佳实践的文档是一个好习惯。这样,新来的人除了在代码中看到的内容外,还有其他可以参考的东西。你公司采用的代码规范完全取决于编写和维护代码库的人。然而,Angular 社区中存在一些常用的规范和最佳实践。

首先,Angular 有一个 风格指南,其中声明了它认为的所有良好实践及其原因。你可以在官方 Angular 网站上找到该风格指南:angular.io/guide/styleguide

接下来,我们将学习 Angular 应用程序中常见的规范和最佳实践。我们将从命名和结构规范开始,然后继续介绍最佳实践。

命名规范

命名是规范的主要焦点。命名规范对于确保可维护性和可读性至关重要。良好的命名规范使你能够轻松地导航代码库并快速找到内容。命名规范适用于代码的多个方面,因此我们将它们分开,从文件夹和文件开始。

命名文件夹和文件

文件和文件夹名称应清楚地描述文件夹或文件的目的。这样,即使项目增长,你也能快速找到所需的文件和文件夹。对于文件夹,你应该使用单词,但如果确实使用了多个单词,你可以用破折号(-)将它们分开。对于文件名,你可以使用 feature.type.ts 的格式。

该功能描述了文件包含的内容,而类型则指代诸如组件、服务、指令、管道等事物(一些例子包括 expenses-list.component.tsexpenses.service.tsunit.directive.ts)。请使用传统的文件类型名称(.component.directive.service.pipe.module.directive.store.actions.stories)。对于单元测试,请使用 .spec 作为类型。最后,避免重复的文件夹或文件名是明智之举。随着你的单一代码库的增长,避免重复的文件或文件夹名称可能并不总是可能的,但尽可能长时间地避免它。

除了文件和文件夹的命名规范外,遵守代码中的命名规范同样至关重要。最好保持你的类、属性、函数、选择器和其他代码方面的命名一致性。代码中的良好命名规范帮助你快速识别代码的不同部分,提高可读性,并使重构和维护代码变得更加容易。

代码中的命名规范

Angular 主要是一个基于类的框架,所以让我们从命名类开始。所有类都应该使用大驼峰式命名法。大驼峰式命名法是指每个单词都以大写字母开头。类名应该等于文件功能与文件类型的组合。因此,expenses-list.component.ts变为ExpensesListComponent,而expenses.service.ts变为ExpensesService

Angular 应用程序的另一个重要部分是组件、指令和管道的选择器。对于组件和指令,有一个约定是使用前缀。使选择器前缀独特,以便你可以将其与可能使用的任何第三方库的选择器区分开来。

组件选择器全部使用小写字母,单词之间用连字符分隔。对于指令选择器,你使用常规的驼峰式命名法。在常规驼峰式命名法中,第一个单词以小写字母开头,所有后续单词以大写字母开头。对于管道选择器,你应该使用一个单词,全部小写,不带前缀。如果必须使用多个单词作为管道选择器,则使用常规驼峰式命名法。

在查看类(或函数文件)内的代码时,也有一些常见的约定:

  • 首先,我们使用驼峰式命名法来声明属性、函数和方法。为你的属性、函数和方法使用描述性名称也很重要。当你用函数处理事件或组件输出时,你应该在这些函数前加上on前缀(如onClickonAddExpenseonHover):

    <div (click)="on:
    
    

    @Output() saved = new EventEmitter();   // Good

    @Output() $):

    import { interval } from 'rxjs'
    numbers$ = interval(1000);
    
    
    

现在你已经了解了文件命名规范和代码命名规范,让我们来看看一些关于文件和项目结构的约定。

结构性约定

除了命名规范外,你还可以为你的文件和项目结构制定规范。像好的命名一样,在你的文件和项目中拥有可预测和良好的结构有助于可读性和可维护性。有了良好的文件结构,你可以轻松识别和找到所需的代码部分。

首先,使用单一规则是一个好的约定。每个文件应该只服务于单一目的。每个文件只有一个目的会使它们更容易阅读和维护,并保持文件大小适中。每个文件只有一个目的也使得定位错误变得容易。最好尝试将文件限制在最多 400 行。当一个文件超过 400 行代码时,这是一个很好的迹象,表明你可能需要将其拆分,并将一些方法移动到单独的文件中。

你应该关注你的文件大小以及函数的大小。理想情况下,函数不应超过 50 行,最好控制在 25 行以下。可能会有一些例外,但当函数变得更大时,将它们拆分成单独的函数会更好。当函数变得过大时,它们就变得难以阅读、测试和调试。

在 Angular 中,你可以在一个文件中编写模板、CSS 和逻辑,但将模板和 CSS 提取到它们自己的文件中是推荐的。使用单独的文件来存储模板和 CSS 促进了单一用途原则,即每个文件只有一个目的,这也有助于可读性和可维护性。如果你的模板只包含一个或两个 HTML 标签而没有额外的样式,你可以例外,将所有内容放在一个文件中。然而,我仍然会分离模板和组件类。除了将你的文件分离到专门的 HTML、CSS 和 TypeScript 文件中之外,良好的文件夹结构也有助于保持清晰的概述,因此让我们来看看我们的文件夹结构的一些约定。

尽可能长时间保持扁平的文件夹结构。拥有大量的嵌套文件夹可以更容易地找到所需的文件夹和文件,并可以使你对文件夹和文件结构的概述更加清晰。为你的项目中的每个领域创建一个文件夹,或者更好的是,创建一个库。你应该将这些库中的代码分割到data-accessfeaturesUIutils中。data-accessfeaturesUIutils库中的每个元素也应该是一个独立的库。

使用库可以促进 API 驱动的架构,并确保关注点的良好分离。通过采用 API 驱动的架构,你也将开始编写更多可重用的代码。在库的内部,你有一个index.ts文件来导出你需要在其他地方使用的内容。

接下来,建议使用DRY 原则。DRY 代表“不要重复自己”。当你的单一代码库变得更大时,有时你不可避免地会重复自己,但一般来说,你应该尽量只编写一次代码,并在需要的地方共享它。

最后,你需要一种方法来对你的文件中的代码进行排序。一个常见的排序方法是使用以下结构:

  1. @Input()装饰器

  2. @Output()装饰器

  3. 公共属性和私有属性

  4. 构造函数

  5. 获取器和设置器

  6. 生命周期钩子

  7. 公共方法和私有方法

将属性和方法按字母顺序排序(在将它们分为公共属性和方法和私有属性和方法之后)。尽可能初始化@Input()指令,并在使用生命周期钩子时,也要实现该接口。

你可以将上述约定扩展,使你的项目更加健壮和统一。你应该提出并尝试使用的约定数量没有限制,但就目前而言,你有一个良好的起点,并了解你的约定应该是什么样子,以及应该关注什么。接下来,我们将讨论 Angular 应用中的一些最佳实践。

在 Angular 应用中使用最佳实践

使用最佳实践确保你正确地做事,并且你的代码保持健壮、可维护和可扩展。

第一项最佳实践是尽可能多地使用 Angular CLI(或在使用 Nx 时使用 Nx CLI)。使用 CLI 生成组件、服务、指令、项目、库和其他元素可以确保一致性。当使用 Nx CLI 时,你还可以确保所有依赖项和设置都配置正确。

尽可能多地使用新的独立组件、指令和管道。使用新的独立 API 可以更好地隔离你的逻辑,使得调试和测试你的组件、管道和指令变得更容易。使用独立 API 还有助于减小你的包大小,从而实现更快的加载时间。你还应该使用新的 inject 函数代替构造函数注入进行依赖注入。inject 函数提供了更多的灵活性,并且在使用继承时不会造成阻碍。

总是在你的属性和方法上使用访问修饰符。在 Angular 中,我们有三种访问修饰符:publicprivateprotected。使用正确的访问修饰符可以轻松识别可以在哪里使用什么,并有助于防止错误和意外的行为。

与性能相关的最佳实践

与性能相关有许多最佳实践。首先,始终在 *ngFor 指令上使用 trackBy 函数。当你使用 Angular 17 中引入的新控制流语法时,你必须使用 track 函数。使用新的控制流语法是推荐的,因为它提高了可读性,并且你不需要导入公共模块来使用它们,因此可以稍微减小你的包大小。

接下来,你应该尽可能多地使用 懒加载,因为这可以确保你只下载用户请求的内容。使用新的独立组件,你可以轻松地懒加载每个路由,并且随着 Angular 17 中引入的新延迟块,你甚至可以懒加载 HTML 模板的不同部分。

你应该在组件内部尽可能多地使用 OnPush 变更检测策略。使用 OnPush 变更检测可以减少 Angular 渲染模板的次数。为了更好的变更检测和性能,你还应该尽可能多地利用 Angular 信号来管理应用程序中的同步状态(我们将在 第七章 中详细讨论信号)。

对于异步数据流,你应该尽可能多地使用 async 管道。当组件被销毁或属性被分配新的可观察对象时,async 管道会自动为你取消订阅;这可以防止内存泄漏并提高应用程序的性能。

不要在你的 HTML 模板中使用函数调用或获取器。在模板中调用函数或使用获取器会对应用程序的性能产生负面影响。使用 CDK 虚拟滚动 来显示大量列表。CDK 虚拟滚动只会渲染视图内部显示的元素,而不是整个列表。尽可能多地使用纯管道:

<cdk-virtual-scroll-viewport itemSize="50" class="example-viewport">
  <div *cdkVirtualFor="let item of items" class="example-item">{{item}}</div>
</cdk-virtual-scroll-viewport>

例如,当使用 ngOnChanges 生命周期来根据新接收的输入值分配属性时,有很大可能性你可以使用管道来处理相同的情况。使用纯管道对性能更好,并促进了可重用性。不要在管道内的数组上使用 filterforEachreducemap,因为这会负面影响性能。

最后,你应该尽可能长时间地缓存 API 请求,以及资源密集型方法也是如此。

使用最佳实践防止 bug

除了与性能相关的最佳实践外,还有一些最佳实践可以防止 bug 并提高可测试性和可维护性。避免在代码中使用 any 类型。将所有内容都进行强类型化可以防止 bug,提高建议,并使调试和测试更容易。

当组件类中存在未被模板内使用的可观察对象时,请使用 RxJS 的 takeUntilDestroyedtakeUntiltake 操作符。这三个操作符确保你的可观察对象订阅被正确取消。

此外,不要使用嵌套的可观察对象;相反,使用 RxJS 操作符如 combineLatestwithLatestFrom 来处理需要嵌套可观察对象的情况。嵌套的可观察对象可能导致内存泄漏和难以调试的 bug。嵌套的可观察对象也难以编写测试。

此外,在必须等待多个可观察对象以渲染 HTML 片段之前,避免使用多个带有 async 管道的 ng-container 元素。

不要这样做:

<ng-container *ngIf="obs$ | async as observable">
  <ng-container *ngIf=»obs2$ | async as observable2»>
  </ng-container>
</ng-container>

相反,在你的组件类中将两个可观察对象映射到单个可观察对象中,并在模板中使用 async 管道来使用这个单个可观察对象:

observables$ = combineLatest({a: of(123), b: of(456)}).pipe(map(({a, b}) => ({obs: a, obs2: b})));

合并可观察对象可以防止 bug,使你的模板更易读,并确保当可观察对象接收到新值时,一切都能正确更新。

现在你已经了解了一些防止 bug 的最佳实践,让我们来探讨关于设置和架构的最佳实践。

项目设置和架构的最佳实践

一个好的设置有助于提高整个代码库的可维护性和标准。我们已经在 第一章 中讨论了如何设置项目,但为了回顾,请使用智能和哑组件。

智能组件 与你的状态管理连接,而 哑组件 只通过输入和输出变化接收父组件的数据。这确保了你不会有不预期的依赖,并且你的组件专注于单一职责。尽可能在你的组件上使用 export default 以在懒加载时自动解包。使用默认导出和自动解包可以保持你的路由文件整洁易读。

使用 canMatch 路由守卫而不是 canActivatecanLoad 守卫。如果守卫返回 false,canMatch 守卫将不会下载代码。最后,你需要使用 lint 规则来强制执行你的约定和最佳实践。

你已经了解了命名和结构规范。你还学习了可以在你的 Angular 应用程序中使用的最佳实践。向前看,我会在我们前进的过程中提到其他最佳实践,但首先,我们将学习 Angular 应用程序中的常见设计模式。

探索 Angular 应用程序中常用的设计模式

设计模式帮助以预定义的方法解决常见的软件开发问题。设计模式就像构建应用程序的蓝图。设计模式告诉你代码应该如何表现,以及如何创建代码库的结构或分离代码库的各个部分。使用设计模式确保你拥有经过实战检验的解决方案,以良好的抽象级别解决常见问题,这样你的代码就可以扩展而不会变得混乱,不会到处纠缠着依赖,这通常被称为“意大利面代码”。

抽象在软件开发中意味着你将系统的细节和行为与实现逻辑分离。例如,如果你的 Angular 应用程序中有一个状态管理解决方案,你应该将你的状态管理实现与组件层分离。通过分离组件层和状态管理解决方案,你可以更改你的状态管理解决方案,而无需触及组件层。这提供了额外的灵活性,并且可以在你的应用程序增长和需求变化时,避免一些严重的重构。

以状态管理为例,一开始,你可能使用 RxJS 的SubjectBehaviorSubject以简单的方式管理状态,但随着应用程序的增长和状态的复杂性增加,你可能希望将其更改为像NgRxNgXs这样的东西,因为它们提供了更安全、更健壮和更灵活的方法来处理复杂的应用程序状态。

假设你的组件层与状态管理纠缠在一起。在这种情况下,你必须重构整个应用程序以切换状态管理实现。相反,如果你在组件层和状态管理解决方案之间有良好的抽象级别,你可以更改状态管理实现而不触及组件。

设计模式是解决软件开发中常见问题的良好起点,但它们并不是神圣的圣杯或一刀切解决方案。你应该始终考虑什么对你的应用程序有用;不要过度使用设计模式,在没有需要的地方使用它们。当有必要严格遵循设计模式时,你可以这样做,但当不适用时,适应模式以适应你的特定需求。

现在,不再拖延,让我们探索 Angular 应用程序中一些常用的设计模式。

Angular 中的创建型设计模式

创建型设计模式构成了我们在应用程序中创建类和对象的基础。在 Angular 应用程序中,创建型模式被框架用于创建组件、服务和其他应用程序的基本构建块。通过实现创建型模式,如工厂和单例模式,开发者可以确保代码模块化、可重用和可维护。

单例模式

单例模式用于创建一个对象或类的单个实例。使用单例模式确保所有与单例交互的代码都使用相同的实例。单例模式的另一个优点是良好的内存使用,因为您只需为对象或类分配一次内存。在图 6.1中,您可以看到单例模式的视觉表示:

图 6.1:单例模式

图 6.1:单例模式

如您在图 6.1中可以看到,有一个类被不同的消费者使用。当您不使用单例模式时,全局配置类会有多个实例,每个消费者使用自己的实例。现在您已经知道了单例模式是什么,让我们来探讨在 Angular 环境中单例模式通常是如何被使用的。

在 Angular 的上下文中,单例模式通常与依赖注入结合使用。当您在 Angular 应用程序中创建服务或提供其他依赖项时,通常是以单例的形式进行的,这意味着只创建一个依赖项实例,并由应用程序中的所有消费者共享。因为只有一个实例,所以您可以使用单例服务和类在 Angular 应用程序中管理状态或处理其他逻辑,例如配置和缓存。

要将依赖项作为单例提供,您必须在应用程序的根提供者数组中提供它。在没有模块的 Angular 应用程序中工作,根提供者数组位于您提供给bootstrapApplication方法的ApplicationConfig对象中。如果您在 Angular 应用程序中使用ngModules,您可以通过在应用程序模块中提供依赖项来将其作为单例。您还可以在服务方面使用providedIn根配置对象:

@Injectable({ providedIn: 'root'})
export class ExpensesService {}

通常情况下,您只能使用单例模式创建对象的单个实例。在 Angular 依赖项的情况下,单例是在提供依赖项的提供者数组上下文中创建的。我们已经在第二章中更详细地解释了提供者数组和依赖项的创建。

为了进一步阐明单例模式,让我们看看一些单例模式有用的实际例子:

  • 管理已登录用户:如果你有一个用于管理已登录用户的类,你想要这个类只有一个实例,这样就有了一个单一的真实来源。如果有多个类的副本,用户数据、登录状态和其他属性可能在类的不同实例之间有所不同,从而导致意外的行为。

  • 状态管理:当你有一个用于管理全局应用程序状态的类时,单例模式也是一个很好的选择。你想要确保所有需要全局应用程序状态的人都能收到相同的值,并且可以在同一源中更新它。如果有许多状态类的实例,这些实例可以持有不同的值,从而导致状态损坏。保持不同实例的同步可能是一项艰巨的任务,因此使用单一的真实来源是有意义的,并且使用更少的内存。

现在你已经了解了单例模式以及它在 Angular 应用程序中的使用情况,让我们来探索工厂模式。

工厂模式

工厂模式作为对象创建的灵活蓝图。在需要运行时确定所需对象的确切类型的情况下,工厂模式是有益的,它允许根据某些条件或参数进行动态实例化。通过封装对象创建,工厂模式防止客户端代码与特定类之间的紧密耦合,从而促进可维护性、可扩展性和更容易的修改。图 6.2展示了工厂模式的视觉表示:

图 6.2:工厂模式

图 6.2:工厂模式

正如你在图 6.2中可以看到的,我们向工厂提供一些信息(在图中,它是一系列产品详情的列表),然后工厂创建我们想要它创建的内容,并返回结果。在图中,工厂使用工厂创建并返回一个产品给组件。现在你对工厂模式有了更好的理解,让我们看看它在 Angular 应用程序的上下文中通常是如何使用的。

在 Angular 的上下文中,工厂模式也主要是与依赖注入结合使用的。你可以使用useFactory属性创建一个提供者,并为创建依赖值提供一个工厂方法。在提供者中使用useFactory属性在你想根据条件提供不同的类,或者你想在运行时向创建的类提供只能访问的值时非常有用:

providers: [
  { provide: LoggerService, useFactory: env.prod ? ProdLogger : DevLogger }
]

在 Angular 应用程序中,你通常还会在服务中使用工厂方法。很多时候,服务被用来创建特定的对象;这可以通过工厂方法来完成,这样你就有一种简洁的方式来创建对象。

createProduct(name: string, props: ProductProps): Product {
  return new Product(name, props);
}

现在你已经知道了工厂模式是用来以可预测的方式创建对象和类的。接下来,我们将讨论依赖注入模式。

依赖注入模式

依赖注入(DI)是 Angular 框架的核心。严格来说,DI 属于控制反转(IoC)原则的范畴。IoC 本质上将程序某些方面的控制权委托给外部框架或容器,允许它管理组件之间的流动和连接。但由于 DI 负责在 Angular 应用程序中创建和分配依赖项,我们可以将其归类为创建型设计模式。

依赖注入(DI)通过解耦组件和服务,促进了模块化,使得它们在应用程序中更具可重用性。通过简化依赖关系的管理,DI 使得通过添加或修改功能来扩展应用程序变得更加容易,而无需进行重大的代码更改。

Angular 的 DI 系统有助于识别和防止循环依赖,这可能导致运行时错误和难以调试的问题。依赖注入还强制执行类型安全,减少了与注入错误数据类型相关的错误风险。

我们已经在第二章中详细讨论了 Angular DI,因此现在我们将继续讨论结构型设计模式。

Angular 中的结构型设计模式

结构型设计模式是塑造 Angular 应用程序架构的基础。结构型设计模式有助于组织组件、服务和模块,定义它们在应用程序中的交互和协作方式。Angular 利用这些模式来建立清晰的结构,促进可扩展、模块化和可维护应用程序的开发。

例如,组件化架构和模块结构等模式是 Angular 应用程序固有的。装饰器模式在 Angular 中也被广泛使用(例如,@Component@Injectable)。外观模式在 Angular 应用程序中常用于在服务层和组件层之间提供抽象。

总体而言,在 Angular 中使用结构型设计模式通过定义组件、服务和模块在框架架构中的相互连接和协作方式,指导开发者创建组织良好、可扩展和适应性强应用程序。

组件化架构

组件化架构(CBA)是一种设计模式,其中我们通过组合单个、自包含和可重用的组件来构建应用程序。在 Angular 应用程序中,CBA 的应用显而易见。在构建组件时,保持它们尽可能的自包含和可重用非常重要。因为我们希望构建可重用的组件,所以在 Angular 组件中使用智能/愚笨原则(smart/dumb principle)非常重要。

我们已经在第一章第二章中讨论了智能组件和哑组件,但为了重申,智能组件主要是页面或大型功能组件,并且与你的业务逻辑和状态管理(或外观服务)有关。

哑组件是用于构建智能组件的 UI 元素。哑组件通过输入接收数据,并通过输出通知其他组件的变化。使用这种智能/哑方法强制执行良好的架构和组件的可重用性。

现在我们已经简要回顾了 CBA 模式,让我们继续学习装饰器模式。

装饰器模式

装饰器模式是一种结构型设计模式,它允许你在不改变对象本身的情况下修改类、函数和属性的行为。在 Angular 框架中最常用的装饰器如下:

  • @Component: 这个装饰器将一个类装饰为 Angular 组件,为 Angular 编译器提供元数据。它包括有关组件模板、样式以及其他配置的信息,如独立标志、导入、组件选择器和指令分解。

  • @Injectable: 这个装饰器将一个类装饰为可注入的服务,允许它通过 Angular 的 DI 系统注入到其他组件或服务中。

  • @NgModule: 这个装饰器将一个类装饰为 Angular 模块,提供定义模块依赖项、组件、指令、服务等的元数据。

  • @Input@Output: 这些是用于组件属性的装饰器,用于定义组件之间的输入和输出通信。

  • @HostListener: 这个装饰器用于装饰一个类方法,以声明一个 DOM 事件监听器。它用于指令中监听宿主元素上的事件。

  • @HostBinding: 这个装饰器用于装饰一个类属性,将其绑定到指令内的宿主元素属性。

  • @ViewChild@ViewChildren: 这些是用于在父组件或指令中查询和获取子组件或 DOM 元素引用的装饰器。

你也可以创建自己的自定义装饰器。例如,我们可以创建一个自定义装饰器,当函数被调用时记录下来,并在日志中包含提供的函数参数。首先,你需要在tsconfig.json中将experimentalDecorators设置为true

{
  "compilerOptions": { "experimentalDecorators": true}
}

在 Angular 的情况下,experimentalDecorators属性默认设置为true,因为 Angular 已经在框架内部使用了装饰器。要创建一个装饰器,你需要创建一个接受三个参数的函数:target(类原型),propertyKey(方法名称),和descriptor(方法属性描述符)。在装饰器内部,你通过将原始逻辑包装在一个新函数中来修改被装饰方法的行怍。这个新函数在调用原始方法之前记录一条消息:

export function LogMethod(target: unknown, propertyKey: string, descriptor: PropertyDescriptor) {
  const originalMethod = descriptor.value;
  descriptor.value = function (...args: unknown[]) {
    console.log(`Method ${propertyKey} is called with args: ${JSON.stringify(args)}`);
    return originalMethod.apply(this, args);
  };
  return descriptor;
}

现在,要使用装饰器,您只需将其添加到方法之上,如下所示:

@LogMethod
test(a: number, b: number) {
  return a + b;
}

如果您现在调用test方法,装饰器确保它被记录,包括ab参数:

this.test(1, 2);
Logs: Method test is called with args: [1,2]

您可以将自定义装饰器放置在共享域中的新custom-decorators库中,该库的类型为 util。我在custom-decorators库内部创建了一个func-logger.decorator.ts文件,并将装饰器的逻辑放在该文件中。

您现在知道 Angular 在哪里使用装饰器模式,以及您如何自己使用它来扩展或修改对象、函数和类的行为,而不修改对象本身。接下来,您将了解外观模式。

外观模式

我们在这本书中多次提到了外观模式。现在,是时候解释外观模式是什么了。外观模式是一种结构型设计模式,它为更大的、更复杂的类、子系统或 API 系统提供了一个简化的接口。在 Angular 应用程序的上下文中,外观模式通常用于创建组件层和实现状态管理解决方案和业务逻辑的服务之间的简单接口和抽象层。在图 6.3中,您将找到外观模式的视觉表示:

图 6.3:外观模式

图 6.3:外观模式

图 6.3中,为了获取组件所需的数据,需要调用三个不同的服务。如果您必须为所有组件执行此操作,您将创建大量的依赖关系,并在组件内部创建大量的逻辑。我们不是直接从组件中调用服务,而是在组件和服务之间放置一个外观。外观提供了一个简单的获取数据方法来检索组件内部所需的数据。外观没有组件内部的依赖关系,而是拥有所有对服务的依赖关系。通过使用外观,您可以保持组件简单且干净,没有依赖关系。此外,您创建了一个抽象层,因此您可以更改数据检索方式而不更改组件层;您只需更改外观获取数据的方式,组件仍然调用外观服务的获取数据方法。在大多数情况下,您在外观的右侧管理状态,在左侧放置组件。

在大型应用程序和单一代码库中,状态管理层可能会变得庞大且复杂,难以处理。对于像在大型单一代码库中的商业工具应用程序中获取所有费用这样简单的事情,你可能会发现自己需要访问多个状态文件(通常称为存储)和这些存储的选择器。因为状态的不同部分可能存在于不同的文件或甚至不同的库中,所以创建一个结合状态内部各部分的功能的函数并不像想象中那么简单。你需要另一个地方,那就是界面服务。同样,对于更新操作,在大系统中,这可能涉及到更新多个存储和处理多个回调或效果。

假设你创建了一个费用界面。在这个界面中,你创建了简单易调用的方法,并为你提供了访问组件层中所需一切的功能。例如,获取所有费用、获取筛选后的费用、更新单个费用或批量更新费用。这个界面有助于保持组件层中的简单性,并确保你只需调用一个函数就能获取或完成你需要使组件工作并相应更新状态的操作。

界面使得访问和更新你的状态变得简单,并提供了一个额外的抽象层,将你的状态管理实现与组件层解耦。这个抽象层允许你在不触及组件层的情况下更改你的状态管理解决方案。你只需更改状态管理并更新界面,你的组件层就会像没有变化一样继续工作。

如果你没有在组件层和状态管理之间添加界面,你需要进入每个使用状态的组件,并独立地更新它们,这会导致更多的工作,并且你遗漏某事的可能性更高。所以,即使你有一个简单的状态,你可以使用单个方法访问和更新大多数事物,但在组件层和状态管理之间添加一个界面仍然是明智的。

以下是一个界面服务可能看起来很简单的示例:

@Injectable({ providedIn: 'root' })
export class ExpenseFacadeService {
  protected readonly expenses: inject(ExpensesStore);
  protected readonly approvedExpenses: inject(ApprovedExpensesStore);
  getAllExpenses(): Expense[] {
    return [...this.expenses.getAll(), ...this.approvedExpenses.getAll()];
  }
  addExpense(expense: Expense): void {
    this.expenses.addExpense(new Expense(expense));
  }
  updateExpense(expense: Expense): void {
    if (expense.isApproved) {
      this.approvedExpenses.addExpense(expense);
    } else {
      this.expenses.addExpense(expense);
    }
  }
}

在前面的例子中,我们创建了一个界面服务,它公开了两种方法:一种用于获取所有费用,另一种用于更新费用。如果状态管理中的实现现在发生了变化,你只需在界面中更改它,而不是在组件中获取或更新费用的每个地方都进行更改。前面的代码只是一个示例;你不需要在单一代码库中添加这个功能。在第八章中,我们将在我们的单一代码库中创建一个界面。现在,你只需要知道界面模式是什么以及为什么它有用。

接下来,我们将探讨继承模式。

模型适配器模式

模型适配器模式是适配器模式的一种实现。模型适配器模式通常用于将 API 接收到的对象映射到前端(不是特指视图层中使用的模型)使用的模型表示。你可能会问自己,这有什么用?

假设你从 API 接收到一个具有title属性的对象:

{
  title: "My Title",
  ……
}

假设你在应用程序的 100 个不同地方使用了这个title属性。如果出于某种原因,后端必须将属性名从title更改为subject,你需要进入应用程序中的所有 100 个地方来将它们从title更改为subject。如果你有一个模型适配器,你可以在一个地方更改它们,并将新的subject属性映射到前端模型的title属性。

继承模式

继承模式是面向对象编程的基础概念,它建立了类之间的层次关系,使得一个类(子类或派生类)能够从另一个类(超类或基类)继承属性、方法和行为。在 Angular 应用程序中,服务和组件可以利用继承来形成层次关系,并共享通用功能和属性。基类通过子类公开共享的功能和属性。

继承是一种强大且有用的设计模式,但应该适度使用,尤其是在你的 Angular 应用程序的组件层中。过度使用继承会在基组件和子组件之间创建紧密耦合。基组件的变化可能会意外地影响多个派生组件,使系统脆弱且难以维护。具有多个继承级别的深层层次结构可能会引入复杂性,使代码库更难以理解和维护。通过创建过于复杂的继承结构进行过度设计可能会阻碍而不是帮助开发。

此外,在组件层的情况下,你通常可以创建管道和指令来共享常用功能。例如,你可以有一个基类,在其中添加处理禁用状态、处理一些常用的组件样式,如primaryalertdanger,以及添加在点击或双击时更改这种样式的选项。

这可能看起来像是一个有效的解决方案,但在某些情况下你可能只需要一个或两个选项。一些组件可能具有不同的样式类型,但不应能够根据点击来更改样式类型,而其他组件则不应能够被禁用。将不适用于组件的行为暴露给组件通常是一种不好的做法。因此,在这种情况下,创建三个处理禁用、样式和样式更改行为的指令,并使用指令组合来应用它们会更好。

因此,继承可以用来共享公共功能,但请确保它是解决问题的正确方案,这样你就不会创建大量暴露给子类而子类不会使用的功能和行为的基础类。我喜欢使用继承模式的一种方式是创建一个通用的 HTTP 服务。我们将在下一节创建一个带有模型适配器的通用 HTTP 服务。

现在你已经了解了 Angular 应用中最常用的结构设计模式,我们将开始学习 Angular 应用中的常见行为设计模式。

Angular 中的行为设计模式

行为设计模式关注对象和类之间如何通信和委托责任。使用行为模式确保你的代码保持灵活、模块化和可维护。

在 Angular 应用中最常见的行为模式是观察者模式,但其他模式,如拦截器、Redux 和策略模式,也经常被使用。让我们从最常用的开始:观察者模式。

观察者模式

如果你曾经使用过 Angular,你知道该框架严重依赖于可观察对象。Angular 框架集成了 RxJS 来管理可观察对象并有效地以响应式方式处理异步数据流。RxJS 是一个专注于处理可观察数据流的库。观察者模式允许你创建一对多关系,以便当一个对象发生变化时,你代码中所有相关的元素都会被通知并自动更新。

在观察者模式中,你有可观察对象观察者,它们更常被称为订阅者。我喜欢用杂志订阅的类比来解释观察者模式。

假设有一家杂志每周发布一期新杂志。杂志是可观察对象,订阅杂志的人是观察者或订阅者。每次杂志发布新期号时,所有订阅者都会收到通知,并自动在他们的邮箱中收到新期号。只要订阅者订阅杂志,就会收到杂志,当他们取消订阅时,就不再收到杂志。

所有希望接收该杂志的人都可以订阅该杂志。有一个杂志期号和许多读者,因此存在一对多关系,正如我们在观察者模式中所见。同样,也存在观察者和可观察对象,就像在观察者模式中一样。

可观察对象有两种类型,热可观察对象和冷可观察对象,在 Angular 应用和 RxJS 的上下文中,你可以设置可观察对象的方式有几种;我们将在第七章中深入探讨可观察对象类型、RxJS 以及处理可观察流。

拦截器模式

拦截器模式允许你拦截通信并在传输的数据上执行一些逻辑,然后停止或继续被拦截的通信。

在 Angular 应用程序的上下文中,你通常在两个不同的地方看到拦截器模式:

  • 路由守卫拦截路由变化并根据某些逻辑允许或阻止路由变化。

  • HTTP 拦截器拦截 HTTP 请求和响应。HTTP 拦截器通常用于向 HTTP 请求添加授权头并处理重试逻辑或日志记录。

我们将在第九章中创建一个路由守卫。现在,我们只创建一个 HTTP 拦截器。

创建 HTTP 拦截器可以通过类方法或函数方法完成。我们将使用函数方法,因为这是较新的方法,需要更少的样板代码。

要创建一个 HTTP 拦截器,你首先需要对你的ApplicationConfig对象做一些调整。首先,在你的providers数组中添加provideHttpClient函数:

providers: provideHttpClient function, add the withInterceptors function and provide it with an array:

provideHttpClient(withInterceptors function, 你将注册你的 HTTP 拦截器。你通过创建一个实现 HttpInterceptorFn 接口的函数来创建一个拦截器。该函数接受 HttpRequest 和 HttpHandlerFn 作为函数参数。HttpRequest 让你访问请求,HttpHandlerFn 在执行你的逻辑后调用以继续请求:

export const AuthInterceptor: HttpInterceptorFn = (
  req: HttpRequest<unknown>, next: HttpHandlerFn) =>  next(req.clone({ setHeaders: { Authorization: 'auth_token' } }));

上述代码只是一个简单的示例;我们添加一个字符串作为我们的授权令牌。实际上,你应该将令牌存储在安全的地方,例如环境变量中,并从中检索它。你还可以在拦截器中添加更多逻辑;这只是一个简单的示例,用于说明如何创建拦截器。要激活拦截器,你需要将其添加到withInterceptors函数的数组中:

provideHttpClient(withInterceptors([auth_token will be added to all your HTTP requests. Now that you know what the interceptor pattern is and how you can create functional HTTP interceptors, let’s move on to the Redux pattern.
Redux pattern
The **Redux pattern** is another commonly used design pattern within Angular applications. When you think about the Redux pattern, you might think of the Redux library, commonly used within the React framework, but the Redux pattern and the Redux library are two different things. The Redux pattern is a design pattern implemented by multiple state management libraries. Within Angular, the Redux pattern is commonly implemented by using the *NgRx* or *NgXs* state management libraries.
The Redux pattern focuses on predictable state management where the entire application state is stored in a single immutable state tree. The core principles of Redux involve defining actions that describe state changes and reducers that specify how those actions modify the state. The Redux pattern enforces a unidirectional data flow, allowing data changes to flow in a single direction, from actions to reducers to updating the application state. Changes to the state are made with pure functions called reducers (pure functions are functions that have the same output given the same input without performing any side effects). Retrieving the state is done by using pure functions named selectors.
In Angular applications, we create reactive code using observable streams. Because Angular uses observable streams, the libraries implementing the Redux pattern for Angular combine it with RxJS so that we can use the Redux Pattern in a reactive manner. This means actions are dispatched asynchronously, and you can use RxJS pipe operators in combination with the selectors for your state.
The Redux pattern is a bit too complex to explain in a couple of paragraphs, so we will come back to this topic in [*Chapter 8*. For now, you just need to know that the Redux pattern focuses on handling state in an immutable and unidirectional way and has four key elements:

*   **Actions**: It describes unique events to modify the state.
*   **Reducers**: It has pure function implementations to modify the state based on the described actions.
*   **Selectors**: It has a pure function to retrieve pieces of the state.
*   **Store**: It has a class defining the state.

You now know about creational, structural, and behavioral design patterns within Angular applications. You learned about patterns such as the singleton, factory, decorator, facade, observer, and Redux patterns. You learned when these patterns are used by the framework and how you can use them to improve your code.
Building a generic HTTP service containing a model adapter
To build your generic HTTP service with a model adapter, start by creating a new library named `generic-http of type data-access` in the domain shared. In this library, create a file named `generic-http.service.ts` and a file named `model-adapter.interface.ts`.
Inside the `model-adapter` interface file, add this interface:

export interface ModelAdapter<T, S> {

fromDto(dto: T): S;

toDto(model: S): T;

}


 We use generic types so we can make our model adapter type-safe. The `T` and `S` are placeholders for the **data transfer object** (**DTO**) and frontend model we will provide to the adapter. After creating the interface, start with the generic HTTP service.
The generic HTTP service will need a property for the API URL and the default HTTP headers, and the service needs to inject the HTTP client.

@Injectable({

providedIn: 'root'

})

export abstract class GenericHttpService<T, S> {

protected url;

defaultHeaders = new HttpHeaders();

protected readonly httpClient = inject(HttpClient);

}


 As you can see, we also use generic types in the model adapter so we can maintain a type-safe generic HTTP service. For the HTTP service, we will also use the `T` and `S` as placeholders for the generic types. Next, we add the `constructor` so we can pass an API endpoint, base URL, and model adapter to the generic HTTP service:

constructor(

private endpoint: string,

private baseUrl: string,

private adapter: ModelAdapter<T, S>

) {

this.url = this.baseUrl + '/api' + this.endpoint;

}


 Inside the `constructor` function brackets, we will construct the API URL from the base URL and the endpoint we receive from the constructor parameters. Now, you need to implement the API requests. The generic HTTP service will be used for common API requests: `get`, `get by id`, `post`, `put`, `patch`, and `delete`. Each request will use the URL we constructed, so this approach will only work if your API shares the same API route for these requests. Otherwise, you need to add an additional parameter to your constructor for the API routes (this can be done with a `Record` class, for example).
The API requests will include the request headers, and there will be an option to append additional headers if needed. Each request will also implement the model adapter so the objects received from the API will automatically be adapted to the frontend models. Here is an example of the `get` and `post` request:

public get(extraHttpRequestParams?: Partial): Observable<S[]> {

return this.httpClient.get<T[]>(${this.url}, this.prepareRequestOptions(extraHttpRequestParams)).pipe(

map((data: T[]) => data.map(item => this.adapter.fromDto(item) as S)));

}

public post(body: S, extraHttpRequestParams?: Partial): Observable {

return this.httpClient.post(${this.url}, this.adapter.toDto(body), this.prepareRequestOptions(extraHttpRequestParams)).pipe(map(data => this.adapter.fromDto(data as T) as S)) as Observable;

}


 You can implement the other requests by yourself or take them from the GitHub repository from this book.
As you can see, the requests implement a function `prepareRequestOptions`. This `prepare` **RequestOptions** function is used to append additional API headers if needed. The implementation for this function looks as follows:

public prepareRequestOptions(extraHttpRequestParams = {}) {

return {

headers: Object.assign(this.defaultHeaders, extraHttpRequestParams)

};

}


 Now you can add additional HTTP headers if needed. Now that you’ve created a generic HTTP service, let’s explore how you can use the service.
Using the generic HTTP service
Using the generic HTTP service is done with inheritance when you create other HTTP services. For example, we need an HTTP service in our finance `data-access` library to get, update, and delete our expenses. Suppose you’re not using the generic HTTP service with a model adapter. In that case, you will create an HTTP file in the finance `data-access` library that would look similar to the generic HTTP file, only with predefined types API URLs and models.
Because all these HTTP services commonly look more or less the same, with the exception of the API URL and the models, we created the generic HTTP service so we don’t have to rewrite the same every time. Instead, we can inherit the generic HTTP service and share the common functionality.
Let’s implement the generic HTTP service and start by creating a new folder named `HTTP` inside the finance `data-access` library in your *Nx monorepo*. Inside this new `HTTP` folder, create a file named `expenses.http.ts`. In the `expenses.http.ts` file, you have to create an `ExpensesHttpService` like this:

@Injectable({ providedIn: 'root' })

在金融数据访问库的 lib 文件夹中创建了一个名为models的新文件夹,并在models文件夹中添加了一个expenses.interfaces.ts文件。在这个expenses.interfaces.ts文件中,我将创建费用模型和 DTO 接口:

export interface ExpenseDto {
  id: number | null;
  title: string;
  amount: number;
  vatPercentage: number;   date: string;
  tags?: string[];
}
export interface ExpenseModel {
  id: number | null;
  description: string;
  amount: {
    amountExclVat: number;
    vatPercentage: number;
  };
  date: string;
  tags?: string[];
}

接下来,你可以创建一个名为adapters的新文件夹。adapters文件夹位于modelsHTTP文件夹相同的目录下。在adapters文件夹内,创建一个expense.adapter.ts文件,它将包含费用 DTO 和模型的模型适配器:

export class ExpensesModelAdapter implements ModelAdapter<ExpenseDto, ExpenseModel> {
  fromDto(dto: ExpenseDto): ExpenseModel {
    return {
      description: dto.title,
      amount: {
        amountExclVat: dto.amount,
        vatPercentage: dto.vatPercentage
      },
      date: dto.date,
      tags: dto.tags,
      id: dto.id
    };
  }
  toDto(model: ExpenseModel): ExpenseDto {
    return {
      id: model.id ? model.id : null,
      title: model.description,
      amount: model.amount.amountExclVat,
      vatPercentage: model.amount.vatPercentage,
      date: model.date,
      tags: model.tags ? model.tags : []
    };
  }
}

如你所见,模型适配器将费用 DTO 映射到费用模型,并将费用模型映射到费用 DTO。我们将把这个模型适配器提供给泛型 HTTP 服务的构造函数,这样我们的模型在从 API 接收或发送时将自动映射。最后一步是在你的费用 HTTP 服务中继承泛型 HTTP 服务。你通过使用extends关键字并添加泛型 HTTP 服务的类名来继承:

@Injectable({
  providedIn: 'root'
})
export class ExpensesHttpService extends GenericHttpService<ExpenseDto, ExpenseModel> {
  constructor() {
    super(
      ‹/expenses',
      ‹›,
      new ExpensesModelAdapter()
    );
  }
}

如你所见,我们还调用了一个super()方法,并给它提供了一些参数。super()方法用于调用继承类的constructor方法——在我们的例子中是GenericHttpService。你向super()方法提供GenericHttpService构造函数期望接收的属性,即endpointbaseUrl和模型适配器。

你可能也会注意到我们在GenericHttpService之后使用了箭头语法,并在其中提供了ExpenseDtoExpenseModel

GenericHttpService<ExpenseDto, ExpenseModel>

使用前面提到的语法,为GenericHttpService类中添加的泛型类型提供了泛型 HTTP 服务:

GenericHttpService<T, S>

通过使用泛型类型,我们确保泛型 HTTP 服务保持类型安全,当我们使用 HTTP 服务的各种方法时,我们会收到类型化的对象。在继承费用 HTTP 服务内部的泛型 HTTP 服务后,你就可以开始使用费用 HTTP 服务了。所有的方法,如getget by idpostdelete等,都是继承自泛型 HTTP 服务,无需再次实现。

如果你想要使用费用 HTTP 服务,你可以像使用任何其他 HTTP 服务或依赖项一样注入它:

protected readonly expensesApi = inject(ExpensesHttpService);

在注入 HTTP 服务后,你可以调用服务公开的任何方法:

this.expensesApi.get()
  .pipe(takeUntilDestroyed(this.destroyRef))
  .subscribe((data) => { console.log(‹data ==>›, data); });

当我们发起get请求时,你可能会注意到请求失败;这是因为我们没有实际运行的 API,对/api/expenses的请求返回了一个404 not found错误代码。让我们解决这个问题,并在发起 API 请求时提供一些模拟数据。

为我们的 API 请求提供模拟数据

提供模拟数据的方法有很多;我们将使用 HTTP 拦截器从资源文件夹中获取我们的模拟数据。这只是一个简单的实现,它有一些缺陷,但出于演示目的,它工作得非常好,并且设置起来并不费力。

我们首先在 Nx monorepo 中的generic-http库内创建一个包含mock.interceptor.ts文件的interceptors文件夹。在mock.interceptor.ts文件中,我们将创建一个拦截器,如果不在开发模式,则返回未修改的 HTTP 请求。

如果我们处于开发模式,拦截器将调整请求 URL 和请求方法,使得所有请求都是GET请求,并且它会尝试从我们的应用程序的assets文件夹(在我们的例子中,是费用登记应用程序)中获取一个 JSON 文件。

我们还将拦截 HTTP 响应,如果我们没有发出GET请求,我们将返回请求体而不是来自项目assets文件夹中的 JSON 文件的数据。

export const MockInterceptor: HttpInterceptorFn = (
  req: HttpRequest<unknown>,
  next: HttpHandlerFn,
) => {
  if (!isDevMode()) return next(req);
  const clonedRequest = req.clone({
    url: `assets${req.url}.json`,
    method: ‹GET›,
  });
  return next(clonedRequest).pipe(
    map((event: HttpEvent<unknown>) => {
      if (event instanceof HttpResponse && req.method !== 'GET') {
        // Modify the response body here
        return event.clone({ body: req.body });
      }
      return event;
    }));
};

如您所见,我们首先检查我们是否不在开发模式,如果是这样,我们就直接返回请求。如果我们处于开发模式,我们将克隆请求并调整请求 URL 和方法。方法被设置为GET,无论我们尝试发出什么请求。URL 以assets为前缀,因此我们针对应用程序的assets文件夹,并以.json结尾,因为我们将从assets文件夹中获取一个 JSON 文件。

以这种方式调整 URL 确保对以下 API URL /api/expenses 的请求将被转换为 /assets/api/expenses.json。接下来,我们使用 RxJS 管道和 map 操作符来监听 HTTP 响应,如果原始请求方法不是GET请求,则简单地返回请求体。

接下来,您必须在费用登记应用程序assets文件夹内创建一个api文件夹,并在该api文件夹中创建一个包含您的模拟数据的expenses.json文件:

[
  {
    "id": 1,
    "title": "Office Supplies",
    "amount": 50.0,
    "vatPercentage": 20,
    "date": "2019-01-04",
    "tags": [
       "printer"
    ]
  },
  ………
]

最后,您需要在您的ApplicationConfig对象中注册拦截器:

provideHttpClient(withInterceptors([ApplicationConfig, your API request will return with your mock data for GET requests and the request body for all other types of requests.
If you make API requests to other API endpoints, you need to add additional JSON files in the `assets` folder to mock the response for these requests. That was it for the generic HTTP service and model adapter. In the next chapter, we will start implementing RxJS and signals and use the observer pattern in practice.
Summary
In this chapter, you learned about Angular code conventions and best practices. You learned about naming conventions for your folders and files and naming conventions for properties, functions, and classes within your Angular applications. You also learned about best practices to improve performance and prevent bugs and hard-to-debug code.
Besides conventions and best practices, we looked at some of the most commonly used design patterns within Angular applications. You learned when the Angular framework uses specific patterns and how you can write cleaner and more scalable code using design patterns such as the facade, decorator, and inheritance patterns.
We finished the chapter by creating a generic HTTP service using the inheritance pattern. The HTTP service can easily be used to construct HTTP services for all your data access libraries. The generic HTTP service also has a model adapter to transform DTOs into your frontend models automatically. Lastly, because we don’t have an API, we made an HTTP interceptor to intercept our HTTP requests and provide mock data.
In the next chapter, we will learn about reactive programming and implement RxJS and signals within our expenses application.



第七章:在 Angular 中精通响应式编程

响应式编程有助于提高您应用程序的性能,并允许 Angular 更好地利用变更检测机制,减少应用程序需要重新渲染的次数。

在本章中,您将了解响应式编程。您将学习响应式编程是什么以及如何使用它来改进您的 Angular 应用程序。您还将了解 RxJS 库以及它如何反应性地管理异步数据流。本章将教会您如何使用不同的 RxJS 操作符,创建可重用的 RxJS 操作符,重用一组 RxJS 操作符,以及使用 RxJS 将其他 Observables 映射到视图模型中。

您还将了解 Angular Signals 以及如何使用 Signals 来反应性地管理同步数据流。最后,您将学习如何结合 RxJS 和 Signals,何时使用 RxJS,以及何时 Signals 占据主导地位。

到本章结束时,您将能够反应性地管理数据流,并了解有关 Angular Signals 的一切。

本章将涵盖以下主题:

  • 什么是响应式编程?

  • 使用 RxJS 进行响应式编程

  • 使用 Angular Signals 进行响应式编程

  • 结合 Signals 和 RxJS

什么是响应式编程?

响应式编程是一种像函数式、模块化、过程式或面向对象编程OOP)一样的声明式编程范式。编程范式是一组规则和原则,它指定了您编写代码的方式。它与建筑和设计模式类似,但它们在抽象的不同层面上运作。

编程范式是指导编写代码的整体风格、结构和方法的宏观概念,而架构和设计模式则提供了可重用的模板或蓝图,用于结构化代码、处理组件之间的通信、管理关系以及解决代码库中其他常见的设计挑战。

响应式编程处理数据流和变化的传播。简单来说,响应式编程决定了您如何处理可能在任何给定时间发生的事件和数据变化,也称为异步变化。正如其名称所暗示的,您使用响应式编程来对变化做出反应。使用响应式编程,代码的依赖部分将自动通知事件和数据变化,以便这些部分可以自动对变化做出反应。您可以将响应式编程视为一个系统,其中变化被推送到需要根据变化做出反应的代码部分,而不是您拉取当前状态,检查是否已更改,然后相应地更新依赖代码。

响应式编程的亮点

反应式系统通过仅在数据发生变化时订阅和处理数据来优化资源分配,减少了不必要的处理,提高了整体系统性能。这种方法允许系统更加响应和可扩展,同时节省计算资源,使其在高效处理异步数据流方面特别有利。你可以使用反应式编程来处理数据流和事件的常见示例包括 HTTP 请求、表单更改以及浏览器事件,如点击、鼠标移动和键盘事件。

反应式编程通过允许开发者轻松地将更简单的行为组合成更复杂的组合,从而促进了可组合性。通过mapfiltermerge等运算符,反应式系统允许你转换、组合和操作数据流。

这种固有的模块化使开发者能够构建更模块化和灵活的应用程序,其中不同的数据流可以无缝集成、转换和适应,以创建复杂且易于维护的系统。这种对可组合性的强调促进了代码的可重用性,并促进了高度可扩展和适应性强应用的创建。

另一个反应式编程的重要部分是处理非阻塞的事件和数据变化;这主要是在你开始进行反应式编程时性能提升的地方。非阻塞代码确保多个任务、事件和数据变化可以并行执行。换句话说,非阻塞代码在任务开始后直接运行代码,而不需要等待任务完成,因此你的代码在任务开始后直接继续到下一行代码。相比之下,阻塞代码会在代码完成之前等待,然后才移动到下一行代码。

反应式编程的缺点

反应式编程很棒,但它不仅仅是阳光和玫瑰;反应式编程也有一些缺点。

反应式系统可能会变得复杂,学习曲线,尤其是对于初级开发者来说,可能会很陡峭。除了一些难以理解的概念外,反应式编程可能难以调试,并且使用自动化测试进行测试也更困难。特别是当你为你的应用程序编写单元测试时,高度反应式系统可能会给你带来一些头疼。

现在,你已经了解了什么是反应式编程以及它如何提高你的应用程序性能并有效地处理事件和数据流。反应式编程提供了数据流和事件的简单可组合性,并以非阻塞的方式运行你的代码。你还知道反应式编程可能给你的代码库带来哪些挑战,以及对于初级开发者来说,理解一些反应式模式可能具有挑战性。现在,让我们更多地了解反应式编程在 Angular 应用程序中的应用。

反应式编程在 Angular 中的应用

响应式编程是 Angular 框架的核心。Angular 强烈依赖于 观察者(我们将在下一节中详细解释观察者)并且内置了 RxJS 库,以强大和可组合的方式管理观察者数据流。观察者是一种响应式设计模式,因为它包含了观察者,即数据发布者,以及订阅者或数据流的接收者。当观察者发出新的值时,订阅者会自动收到通知并相应地采取行动。

观察者(Observables)在 Angular 框架中被广泛应用于多个方面。以下是一些例子,展示了在 Angular 框架中你可以找到观察者的地方:

  • HTTP 请求:在 Angular 框架中,HTTP 请求默认返回观察者。在纯 JavaScript 中,HTTP 请求是通过 Promise 处理的。

  • events 观察者。使用路由 events 观察者,你可以监听诸如 NavigationStartNavigationEndGuardCheckStartGuardCheckEnd 等事件。

  • Angular 框架暴露了 valueChanges 观察者。使用这个观察者,你可以对表单或表单字段中的变化做出响应。

  • ViewChildren 装饰器,它返回的 QueryList 对象有一个 changes 事件。这个 changes 事件是一个观察者,当 ViewChildren 选择的项发生变化时,它会通知你。

这些例子只是 Angular 框架依赖观察者的几个实例。在用 Angular 制作的应用程序代码中,以及与 Angular 常常一起使用的库中,你会在许多地方遇到观察者。

除了观察者之外,Angular 还以其他方式使用响应式编程,例如在处理浏览器事件时使用 @Hostlistener() 装饰器:

@HostListener('document:keydown', ['$event'])
handleTheKeyboardEvent(event: KeyboardEvent) { …… }

在前面的代码片段中,我们使用了 @Hostlistener() 装饰器来监听 keydown 事件并对其进行响应式处理。Angular 使用响应式编程范式的另一个地方是 Angular Signals,这是在 Angular 16 中引入的。Angular Signals 是一个跟踪值变化并相应地通知感兴趣消费者的系统。Signals API 包含了计算属性和效果,这些属性和效果会在 Signal 值变化时自动更新或运行。Signals 适用于处理同步值的响应式,而 RxJS 在处理异步数据流方面表现出色。我们将在本章的 使用 Angular Signals 进行响应式编程 部分更深入地探讨 Signals。

既然你已经了解到 Angular 框架内置了响应式编程,从观察者和 RxJS 到事件处理和新的 Angular Signals,让我们继续下一节,学习如何在 Angular 应用程序中充分利用 RxJS。

使用 RxJS 进行响应式编程

在 Angular 应用程序的上下文中,关于响应式编程,RxJS 处于核心地位。RxJS 代表 Reactive Extensions Library for JavaScript。正如其名所示,它是一个用于处理 JavaScript 中响应性的库,并且它是内置的,默认情况下在 Angular 框架中使用。

RxJS 用于创建、消费、修改和组合异步和基于事件的流数据。在其核心,RxJS 围绕四个主要概念:可观测量、观察者、主题和操作符。让我们逐一深入探讨这些概念,从可观测量开始。

什么是可观测量?

可观测量是 RxJS 的基石。你可以将可观测量视为一个流或管道,它以异步方式在一段时间内发出不同的值。要接收可观测量数据流发出的值,你需要订阅可观测量。

现在,想象一个可观测量数据流就像一个水管。当你打开水龙头(订阅)时,水(数据)会通过水管(可观测量)流动,你在你的末端接收水滴(值)。水(数据)可能在你打开水龙头(订阅)之前就已经流动了,除非你安装了特殊的系统;你将只从你打开水龙头(订阅)的那一刻起接收水(数据),直到你关闭水龙头(取消订阅)。如果其他水龙头(订阅者)是打开的,水(数据)可能还会流向其他水龙头。简而言之,要接收数据流中的值,你需要订阅,而你之前订阅之前发出的所有值都将丢失,除非你有特殊的逻辑来存储这些值。要停止接收值,你需要取消订阅,并且发出值的流就像一条河流一样。

可观测量有两种类型:热可观测量和冷可观测量冷可观测量是单播的,意味着每个订阅者都会从头开始。就像 Netflix 上的电影一样;当有人开始播放电影时,电影将从开头开始。如果有人在另一个账户或电视上开始播放同一部电影,电影也将从开头开始。每个人从开始观看的那一刻起都会获得独特的观看体验。

相反,热可观测量是多播的,意味着有一个数据流被广播给每个订阅者。热可观测量可以与现场电视相比较。不同的人可以收听现场节目(数据流),但你已经错过的内容将不会为你重播。即使他们没有从开始观看,观看的人也会同时体验到相同的内容。

现在你已经清楚地理解了可观测量,并且知道了热可观测量和冷可观测量之间的区别,让我们来学习观察者。

使用观察者订阅可观测量

观察者是订阅可观察对象并接收数据流中可观察对象值的实体。你可以将观察者视为观看现场表演或播放 Netflix 电影的人(或订阅者)。订阅者有两个任务:订阅他们想要接收的流,并取消订阅他们不再想要或需要的流。

其中最关键的部分是成功取消不再需要的订阅。不取消可观察对象可能是使用代码中的可观察对象时最大的风险,也是最常见的错误来源。如果你没有正确清理你的订阅,你会遇到内存泄漏。内存泄漏会导致应用程序出现奇怪的行为,运行速度变慢,并最终在内存耗尽时崩溃你的应用程序。

让我们假设你有一个订阅的杂志,每周都会送到你家。如果你搬到了一个新的地址,你需要从订阅中取消订阅,并在新的地址上创建一个新的订阅。如果你不取消旧地址的订阅,而只是为新地址开始一个新的订阅,你将开始支付双倍费用,杂志将同时送到两个地址。如果你继续重复这个过程,你最终会耗尽资金,你的生活将崩溃。在你的应用程序中,情况相同,只是你不用金钱支付,而是用内存。

当你在组件内部创建一个订阅时,必须在组件销毁时取消订阅。否则,订阅将继续运行。下次你打开相同的组件时,将启动第二个订阅,因为旧的订阅仍在运行。结果,所有值都将被两个观察者接收。如果你继续重复这个过程,最终会有许多观察者接收相同的值,而你只需要一个观察者来接收这些值。当观察者太多时,应用程序将耗尽处理所有值的内存,应用程序将崩溃。

取消可观察对象的订阅

取消可观察对象的订阅可以以许多不同的方式完成。在 Angular 应用程序中,你通常可以在ngOnDestroy生命周期钩子内部取消订阅。你可以手动取消订阅,如下所示:

ngOnInit() {
  this.observable$.subscribe(…)
}
ngOnDestroy() {
  this.observable$.unsubscribe();
  this.observable$.complete();
}

在前面的代码中,我们在ngOnInit生命周期钩子内部订阅了一个可观察对象,并在ngOnDestroy生命周期钩子中通过在可观察对象上调用unsubscribe()complete()方法手动取消订阅。

当你的组件内部有多个可观察对象时,这种方法可能不是最佳选择。如果你有五个可观察对象,你必须手动取消和完成所有五个可观察对象的订阅。手动完成所有订阅会增加遗漏一个可观察对象的风险,并导致ngOnDestroy方法中包含大量重复代码的大方法。

你还需要为所有 Observables 创建一个本地属性,这会进一步污染你的文件,增加样板代码。在这种情况下,你需要将 Observable 订阅保存在一个属性中,以便你可以在ngOnDestroy方法中取消订阅。

另一个更好的选择是创建一个Subscription对象,并通过在Subscription对象上使用add()方法将所有订阅添加到这个对象中。在这种情况下,你只需要从Subscription对象中取消订阅,它将自动取消订阅并完成添加到Subscription对象中的所有订阅。

这里有一个如何使用Subscription对象方法的示例:

subscriptions = new Subscription()
ngOnInit() {
  this.subscriptions.add(this.observableA$.subscribe(…));
  this.subscriptions.add(this.observableB$.subscribe(…));
}
ngOnDestroy() {
  this.subscriptions.unsubscribe();
}

在前面的代码片段中,我们创建了一个subscriptions属性并将其分配给Subscription对象。接下来,我们使用subscriptions属性,并通过Subscription类的add方法将所有订阅添加到subscriptions属性中。最后,在ngOnDestroy方法内部,我们调用subscriptions对象的unsubscribe()方法,这将取消订阅并完成所有内部订阅。

使用Subscription类是一个不错的选择,但将活动订阅添加到Subscription类的语法看起来很杂乱。当你开始使用 RxJS 可连接操作符时,有一种方法更符合你代码的其他部分。我们将在本节稍后更详细地讨论可连接操作符,但现在,我想向你展示如何使用它们来自动取消订阅。

使用takeUntil()操作符取消订阅

首先,我们将看看takeUntil()操作符。你可以在 RxJS 管道方法内部添加takeUntil()操作符,该操作符用于你的 Observables。takeUntil()操作符会在触发器触发时自动取消你的订阅。这个触发器通常是 RxJS 的Subject(我们将在本节稍后更详细地讨论Subject)。当我们调用这个Subject的下一个方法时,takeUntil()操作符将被触发并取消订阅:

private destroy$ = new Subject<void>();
ngOnInit() {
  this.observable$.pipe(takeUntil(this.destroy$))
  .subscribe(……)
}
ngOnDestroy() {
  this.destroy$.next();
  this.destroy$.complete();
}

在前面的示例中,我们创建了一个名为destroy$Subject Observable。然后,我们在我们的 Observable 上使用pipe()函数,并在pipe()函数内部添加了takeUntil()操作符。takeUntil()操作符接收destroy$属性作为参数,一旦我们调用destroy$属性的next()方法,它就会被触发。最后,在ngOnDestroy生命周期方法内部,我们调用destroy$属性的next()方法,并通过调用complete()方法完成对destroy$ Observable 本身的完成。

使用takeUntil()操作符是许多 Angular 开发者取消订阅的首选解决方案。这是因为它与 RxJS 的pipe函数和其他可连接操作符配合得非常好。我想展示的最后一种取消订阅的选项是takeUntilDestroyed()操作符。

使用 takeUntilDestroyed() 操作符取消订阅

takeUntilDestroyed() 操作符是在 Angular 16 版本中添加的。它可以用来在组件销毁时自动取消订阅。当你将你的订阅声明在注入上下文(在构造函数中或声明属性的地方)内时,你只需要添加 takeUntilDestroyed() 操作符,它将为你管理一切:

data = observable$.pipe(ngOnInit, you must provide the takeUntilDestroyed() operator with a reference, DestroyRef. Here, DestroyRef is the ngOnDestroy life cycle in the form of an injectable:

protected readonly destroy = inject(DestroyRef);

ngOnInit() {

this.observable$.pipe(takeUntilDestroyed(this.destroy)).subscribe(…);

}


 As you can see, we created a property and assigned it to `DestroyRef`. We add this property as a function parameter to the `takeUntilDestroyed()` operator. That is all you need to do, and it will unsubscribe and complete your subscriptions automatically.
You now know why it’s essential to unsubscribe from Observables and how to do so using different approaches. Now, we will move on to the next major concept of RxJS: `Subject`.
Using special Observables – RxJS Subjects
The `Subject` Observables are `Subject` Observables are like `EventEmitter` Observables, which maintain a registry of all listeners.
Every `Subject` is an Observable, meaning you can subscribe to `Subject` to receive the values it emits. Each `Subject` is also an internal Observer object with the `next()`, `error()`, and `complete()` methods. The `next()` method is called to emit the next value in the data stream, `error()` is called automatically when an error occurs, and `complete()` can be called to complete the data stream.
Within RxJS, there are four different `Subjects`. Let’s take a look.
Subject
This is the basic RxJS `Subject` type. The `Subject` class allows you to create a hot Observable data stream. You can emit a new value using the `next()` method; the `Subject` class has no initial value or memory of the values that have already been emitted. Subscribers will only receive values that are emitted after they are subscribed; anything emitted before that point will not be received:

const subject = new Subject();

subject.subscribe({next: (v) => console.log(A: ${v})});

subject.next(1);

subject.subscribe({next: (v) => console.log(B: ${v})});

subject.next(2);

// 日志输出:

// A:1, A:2, B:2


 As shown in the preceding example, we use a `Subject` class to emit two values (`1` and `2`). The first value is emitted after the first subscriber (`A`), and the second value is emitted after both subscribers have subscribed. Because of this, subscriber `A` receives both values, while subscriber `B` only receives the second value.
A good use case for the `Subject` class is when multiple Observers must respond to a specific event, such as a selection or a changing toggle. The `Subject` class can be used if components only have to react to the change if the components are active during the event. Now that you know how `Subject` works, let’s examine `BehaviorSubject`.
BehaviorSubject
The `BehaviorSubject` class extends the `Subject` class and has two main differences from the regular `Subject`. The `BehaviorSubject` class receives an initial value and stores the last emitted value. When a subscriber subscribes to `BehaviorSubject`, the subscriber will immediately receive the last emitted value. When no value is emitted, the subscriber will receive the initial value instead.
The `Subject` class is good for emitting values that have to notify subscribers when an event happens, such as when multiple Observers need to react when something is added. The `BehaviorSubject` class, on the other hand, is well suited for values with state, such as `lastAddedItem`, where subscribers receive the last added item. Here, `lastAddedItem` will always emit the last item that has been added. In contrast, an `itemAdded` event using a `Subject` class will only notify subscribed Observers the moment the item is added and not after the fact:

const subject = new BehaviorSubject(0); // 初始值 0

subject.subscribe({next: (v) => console.log(A: ${v})});

subject.next(1);

subject.subscribe({next: (v) => console.log(B: ${v})});

subject.next(2);

// 日志输出:

// A:0, A:1, B:1, A:2, B:2


 As you can see, the `BehaviorSubject` class receives an initial value; in our case, the initial value is `0`. When subscriber `A` subscribes to `BehaviorSubject`, the subscriber immediately gets the initial value, `0`, and logs the value. After subscriber `A` has subscribed, we emit a new value: `1`. This new value is received and logged by subscriber `A`.
Next, subscriber `B` subscribes to `BehaviorSubject`. Because `1` is the last emitted value, subscriber `B` gets and logs it. Lastly, we emit a new value, `2`, which is received and logged by subscribers `A` and `B`.
Now that you know how `BehaviorSubject` works and how it differs from the regular `Subject`, let’s learn about `ReplaySubject`.
ReplaySubject
The `ReplaySubject` class is also an extension of the regular `Subject` class and behaves a bit like `BehaviorSubject` with some differences. The `ReplaySubject` class also stores values, just like `BehaviorSubject`, but unlike `BehaviorSubject`, `ReplaySubject` can store more than one value and doesn’t have an initial value.
Instead of an initial value, the `ReplaySubject` class receives a buffer size as a parameter. The buffer size determines how many values the `ReplaySubject` class stores and shares with a new subscriber upon subscription. Besides the buffer size, the `ReplaySubject` class can take a second parameter to determine how long the `ReplaySubject` class will store the emitted values in the buffer of `ReplaySubject`:

const subject = new ReplaySubject(100, 500);

subject.subscribe({

next: (v) => console.log(A: ${v}),

});

let i = 1;

setInterval(() => subject.next(i++), 200);

setTimeout(() => {

subject.subscribe({

next: (v) => console.log(B: ${v}),

});

}, 1000);

// 日志输出

// A:1, A:2, A:3, A:4, A:5, B:3, B:4, B:5, A:6, B:6

// ...


 In the preceding example, we have a `ReplaySubject` class with a buffer of `100` and a time window of `500` milliseconds. Subscriber `A` subscribes before we emit the first value. Next, we create an interval that emits a new number every 200 milliseconds. As a result, subscriber `A` will receive and log a new value every 200 milliseconds.
Lastly, we create a timeout of `1` second and add the second subscriber. Because we have a time window of `500` milliseconds, subscriber `B` will immediately receive all values that are emitted after the first `500` milliseconds – that is, the timeout of `1` second that has passed minus the time window of the `ReplaySubject` class.
As a result, subscriber `A` logs `1` to `5`; after 1 second, subscriber `B` joins and immediately receives values `3`, `4`, and `5`. After subscriber `B` receives the replay values, both subscribers receive all values emitted after that point.
AsyncSubject
The `AsyncSubject` class is also an extension of the regular `Subject` class. The `AsyncSubject` class only emits the last value to all its subscribers and only when the Observable data stream is completed:

const subject = new AsyncSubject();

subject.subscribe({

next: (v) => console.log(A: ${v}),

});

subject.next(1);

subject.next(2);

subject.subscribe({

next: (v) => console.log(B: ${v}),

});

subject.next(3);

subject.complete();

// 日志输出:

// A:3, B3


 In the preceding example, you can see that `AsyncSubject` only emitted the last value that was emitted before we completed the data stream. First, subscriber `A` subscribed to `AsyncSubject`. Next, we emitted two values, and then subscriber `B` subscribed to `AsyncSubject`. Lastly, we emitted the third value and completed the Observable stream. After we complete the stream, the last value is emitted to and logged by subscribers `A` and `B`.
Now, you know about Observables, Observers, and `Subjects`. You know that there are hot and cold Observables and the difference between the two. You also learned that Observers subscribe to Observables and how to unsubscribe from Observable data streams. You discovered that `Subjects` is a special kind of Observable that’s used to multicast values and that they can emit values using the `next()` method. Lastly, you learned about the four different `Subject` types and saw how you can visualize their differences. Next, we will learn about the last major concept in RxJS: operators.
Using and creating RxJS operators
In this section, you will learn about **RxJS operators**. You will learn what operators are, what types of operators there are, and how to use some of the most commonly used operators in the RxJS library. You will also learn how to create your own RxJS operators and combine multiple operators into a single operator.
While Observables are the foundation of the RxJS library, operators are what make the library so useful and powerful for handling Observable data streams. Operators allow you to easily compose and handle complex asynchronous code declaratively.
Types of operators
RxJS operators come in two different types: creational and pipeable operators.
In short, creational operators can be used to create new Observables with a standalone function, whereas pipeable  operators can be used to modify the Observable stream. Let’s explore both in more detail, starting with creational operators.
Creational operators
`of()` operator. The `of()` operator takes in one or more comma-separated values and turns these values into an Observable stream that emits one value after the other:

of() 操作符带有三个值:1、2 和 3。我们订阅了使用 of() 操作符创建的 Observable 流,并记录了这些值。这个订阅将导致三个独立的日志输出:值:1、值:2 和值:3。正如你所见,of() 操作符是一个相当简单直接的方式来创建 Observable 流。

另一个常用的创建操作符是 from() 操作符。from() 操作符创建一个数组、可迭代对象、Promise 或字符串的 Observable 流。如果你使用 from() 操作符将字符串转换成 Observable 流,字符串将被逐字符发射。以下是一个使用 from() 操作符的例子:

from([1, 2, 3, 4, 5]).subscribe(val => console.log(val));
//output: 1,2,3,4,5
from(new Promise(resolve => resolve('Promise to Observbale!'))).subscribe(val => console.log(val));
//output: Promise to Observbale!

在前面的例子中,我们使用了 from() 操作符从一个数组和一个 Promise 创建了一个 Observable 流。

另一个有用的创建操作符是 fromEvent() 操作符。fromEvent() 操作符可以从事件目标(如 clickhover)创建一个 Observable:

fromEvent() operator takes two arguments. The first is the target element – in our case, we took the document. Then, you declared the event you wanted to listen for; in our example, this is a click event.
With that, you’ve learned how to create a new Observable stream from scratch using creational operators. Next, you will learn how to create a new Observable stream by combining multiple existing Observable streams.
Creating an Observable from multiple Observable streams
As your Angular applications grow and the state of these applications becomes more complex, you often find yourself in a situation where you need the result of multiple Observable streams simultaneously. When you need the result from various Observable streams, you may be tempted to create nested subscriptions, but this isn’t a good solution since nested subscriptions can lead to strange behavior and hard-to-debug bugs.
In scenarios where you need the result of multiple Observables, you can use creational RxJS operators that focus on combining various Observables into a new single Observable stream. When using these operators, the combined Observables are referred to as `combineLatest()` operator.
The `combineLatest()` operator is best used when you have multiple long-lived Observables and need the values of all these Observables to construct the object or perform the logic you want. The `combineLatest()` operator will only output its first value when all of its inner Observables output at least one value; after that, `combineLatest()` outputs another value each time one of the inner Observables emits a new value. The `combineLatest()` operator will always use the last emitted value of all its inner Observables:

const amountExclVat = of(100);

const vatPercentage = of(20);

combineLatest([amountExclVat, vatPercentage]).subscribe({

next: ([amount, percentage]) => {

console.log('总计:', amount * (percentage / 100 + 1));

}

});


 As you can see, we provided `combineLatest()` with an array containing two Observables: one Observable with the amount excluding VAT and another Observable containing the VAT percentage. We need both Observable values to log the amount, including VAT. Instead of creating a nested subscription, we handled this with `combineLatest()`.
Inside the `combineLatest()` subscription, we also declare an array for the value of the Observable stream. We used `amount` and `percentage` as values, but you can name these properties however you like. Alternatively, you can use a different syntax and provide an object to `combineLatest()` instead of an array:

combineLatest({ amount: amountExclVat, percentage: vatPercentage }).subscribe({

next: (data) => { console.log('总计:', data.amount * (data.percentage / 100 + 1)) }

});


 Now, let’s consider another example where we emit different values over time so that you get a better understanding of how `combineLatest()` works and when and what values it will emit:

const a = new Subject();

const b = new Subject();

combineLatest([a, b]).subscribe({

next: ([a, b]) => { console.log(‹data›, a, b) }

});

a.next(1);

setTimeout(() => { b.next(2) }, 5000);

setTimeout(() => { a.next(10) }, 10000);


 In the preceding example, it takes 5 seconds before the `combineLatest()` operator emits the first value; this is because, after 5 seconds, both Observable `a` and `b` have emitted a value. Even though Observable A directly emits a value, `combineLatest()` will only emit a value after both A and B have emitted at least one value.
After 5 seconds have passed and both Observables have emitted a value, `combineLatest()` will emit a value, and we log `data: 1, 2`. After both Observables emit a value, `combineLatest()` will emit a new value whenever one of its Observables emits a new value. So, when Observable A emits a new value after another 5 seconds have passed, we log `data: 10, 2` inside the subscription of `combineLatest()`.
If, for example, you first emitted two values with Observable A (`1`, `10`) and then emitted a value with Observable B (`2`), `combineLatest()` will only emit one value, `data: 10, 2`. This is the case because both A and B need to emit a value before `combineLatest()` starts emitting values.
Now that you have a good idea of how `combineLatest()` works and how to use RxJS to create a new Observable based on multiple Observables, let’s explore other operators that create an Observable from multiple Observables:

*   `forkJoin()` operator is best used when you have multiple Observables and are only interested in the final value of each of these Observables. This means that each Observable has to be completed before `forkJoin()` emits a value. A good example of when `forkJoin()` is useful is when you must make multiple HTTP requests and only want to do something when all requests return a result. The `forkJoin()` operator can be compared with `Promise.all()`. It’s important to note that if one or more of the inner Observables has an error (and you don’t catch that error correctly), `forkJoin()` will not emit a value:

    ```

    forkJoin({ posts: this.http.get('…'), post2: this.http.get('…)}).subscribe(console.log);

    concat()操作符用于当你有多个内部 Observables 时,并且这些内部 Observables 的发射和完成顺序是重要的。所以,如果你有两个内部 Observables,concat()将发射第一个内部 Observables 的所有值,直到该 Observables 完成。在第一个 Observables 完成之前,第二个 Observables 已经发射的所有值将不会由 concat()发射。当第一个 Observables 完成时,`concat()`操作符将订阅第二个 Observables 并开始发射第二个 Observables 发射的值。如果你有更多的内部 Observables,这个过程将重复进行,并且`concat()`将在前一个 Observables 完成时订阅下一个 Observables:

    ```js
    const a = new Subject();
    const b = new Subject();
    concat(a, b).subscribe(console.log);
    a.next(1);
    a.next(2);
    b.next(3);
    a.complete();
    b.next(4);
    b.complete();
    merge() operator combines all inner Observables and emits the values as they come in. The merge() operator doesn’t wait for all Observables to emit a value, nor does it care about the order. When one of the Observables emits a value, the merge() operator will process it:

    ```

    const a = new Subject();

    const b = new Subject();

    merge(a, b).subscribe(console.log);

    b.next('B:1');

    a.next('A:1');

    a.next('A:2');

    b.next('B:2');

    // Logs: B:1, A:1, A:2, B:2

    ```js

    ```

    ```js

You now know how to create Observables with creational operators. You know there are creational operators such as `of()` and `from()` to create simple Observables and creational operators such as `combineLatest()` to create a new Observable based on multiple inner Observables.
Next, we will learn about pipeable operators and how they can be used to filter, modify, and transform Observable streams.
Pipeable operators
**Pipeable operators** take in an Observable as input and return a new and modified Observable without modifying the original Observable. When you subscribe or unsubscribe to the piped Observable, you also subscribe or unsubscribe to the original Observable. Pipeable operators can filter, map, transform, flatten, or modify the Observable stream. For example, pipeable operators can be used to unsubscribe upon a trigger automatically, take the first or last emission of an Observable steam, only emit an Observable value if specific conditions are met, or map the output of the Observable stream into a new object.
Using pipeable operators starts with using the `pipe()` function on an Observable. The `pipe()` function acts like a path for your Observable data, guiding it through different tools called operators. It’s like how materials in a factory move through various stations before becoming a finished product. Here, your data can go through these operators, where you can change it, pick out specific parts, or make it fit your needs. It’s a common scenario that developers use four, five, or even more operators inside a single `pipe()` function.
Let’s examine an example and learn about some commonly used pipeable operators:

const observable = of(1, 1, 2, 3, 4, 4, 5);

observable.pipe(

distinctUntilChanged(),

filter(value => value < 5),

map(value => value as number * 10)

).subscribe(results => {console.log('results:', results)});

// Logs: 10, 20, 30, 40


 In the preceding code, we created an Observable using the `of()` operator. On the Observable, we use the `pipe()` function with three different pipeable operators declared inside the pipe function: `distinctUntilChanged()`, `filter()`, and `map()`. At the end of the pipe function, we subscribe to the Observable stream. The values of the Observable stream move through the pipe and perform the operators on them one by one before ending up in the subscribe block of our code.
The first `distinctUntilChanged()` operator checks if the Observable value differs from the previous and filters it out if the value is the same as the last emitted value. Next, the `filter()` operator works similarly to the filter function on an array; in our case, we filter out all values that aren’t smaller than `5`. Lastly, we use the `map()` operator; this is also similar to the `map` function on an array and lets you map the value to a new value; in our case, we multiply by a factor of `10`. After applying all our operators, the Observable values that are logged in the subscription are `10`, `20`, `30`, and `40`; all other values of our Observable are filtered out.
As you can see, the pipeable operators are performed one after another, guiding the Observable value through a pipe where changes are applied to the value until it reaches the subscription or is filtered out. The `distinctUntilChanged()`, `filter()`, and `map()` operators are some of the most commonly used operators. In this chapter, you also learned about the `takeUntil()` and `takeUntilDestroyed()` operators, which are also commonly used.
Now, let’s continue by exploring some other powerful and commonly used operators and scenarios when pipeable operators are helpful, starting with flattening operators.
Flattening multiple Observable streams using flattening operators
As we’ve seen earlier in this section, sometimes, you need the value of multiple Observables. In some cases, you need all these values at once; in these scenarios, you can use the creational operators that create a new Observable based on multiple inner Observables.
But in other scenarios, you first need the value of one Observable to pass as an argument to another Observable. These scenarios where you have an outer Observable and an inner Observable, where the inner Observable relies on the value of the outer Observable, are commonly referred to as `concatAll()`, `mergeAll()`, `swtichAll()`, and `exhaustAll()`. To get a better understanding of this concept, let’s look at some examples.
Let’s say you have an Observable yielding an API URL. Next, you want to use this URL to make an HTTP request. In actuality, you’re only interested in the result of the API request and not so much in the result of the Observable yielding the URL. The URL is only needed to make the API request, and the API response is required to render your page or perform some logic. One approach would be to nest the two subscriptions, but as you’ve learned, this isn’t a good approach. The correct solution is to use a flattening operator to flatten the Observable stream into a single stream:

ngOnInit() {

this.urlObservable.pipe(

map((url) => this.http.get(url)),

concatAll()

).subscribe((data) => { console.log('data ==>', data) })

}


 As you can see, we have an outer Observable receiving an API URL and using two pipeable operators on this Observable. First, we use the `map()` operator to take the result of the URL Observable and use it to make the API request, which results in our second Observable. Next, we use the `concatAll()` operator to flatten the two Observables into a single Observable, only returning the result of the API request.
Inside the subscription, we log the result, which will be the data that’s returned by the API call. You can simplify this code even more by using the combined operator, `concatMap()`, which combines the `map()` and `concatAll()` operators into a single operator:

this.urlObservable.pipe(

concatMap((url) => this.http.get(url)),

).subscribe(……)


 These combined operators exist for all four flattening operators, so you have the following operators:

*   `concatMap()`
*   `mergeMap()`
*   `switchMap()`
*   `exhaustMap()`

Now that you’ve seen how you can use a flattening operator and that there are map operators that combine the map and flattening operators, let’s learn about the difference between them.
The concatMap() operator
The `concatAll()` operator is used when you want the first value of the outer Observable and all its inner concatenated Observables to complete before the second value of the outer Observable and its inner Observables are processed. Let’s consider the following example:

const clicks = fromEvent(document, 'click');

clicks.pipe(

concatMap(() => interval(1000).pipe(take(4)));

).subscribe(number => console.log(number));


 In the preceding code, we use the `fromEvent()` creational operator to create an Observable whenever we click the browser document (that would be any place in our app). Next, we use `concatMap()` to map the result of the click Observable into a new Observable using the RxJS `interval()` creational operator. The `interval()` operator will emit sequential numbers starting at zero; in our case, it will emit the following number every `1000` milliseconds.
We also used the `take()` pipeable operator on the interval Observable. This limits the number of emissions we take to `4`, so the interval Observable will emit `0`, `1`, `2`, and `3` as values and be unsubscribed and completed by the `take()` operator afterward.
Because we use the `concatMap()` flattening operator, when we click twice on the screen, both the outer and inner Observables will be triggered two times, but the first click Observable and its inner Observables will be processed first and only when that is completed the second sequence will start. So, our subscription part will log `0, 1, 2, 3, 0, 1,` `2, 3`.
The mergeMap() operator
Now, let’s consider the same scenario with the other flattening operators, starting with the `mergeMap()` operator:

const clicks = fromEvent(document, 'click');

clicks.pipe(

mergeMap(() => interval(1000).pipe(take(4))),

).subscribe(x => console.log(x));


 In the preceding example, we only changed `concatMap()` for the `mergeMap()` operator, yet the result will be completely different. The `mergeMap()` operator will not wait for the first inner and outer Observables to complete but will process the values as they come in.
So, if you click on the screen, wait for 2 seconds, and then click on the screen again, the values of the second click and its inner interval Observable will start to come in before the first stream has completed. If you click the first time on the screen, the first log will come in after one second and another one for every second.
Then, when you click again after a second, the first log of the second stream will come in and log another value for every second after that. In this case, the result of all logs would be `0, 1, 2, 0, 3, 1, 2, 3`. As you can see, the result is entirely different from the result we had with `concatMap()`. Now, let’s see what happens when we change `mergeMap()` to `switchMap()`.
The switchMap() operator
The `switchMap()` operator will switch the Observable stream from the first to the second stream when the second stream starts to emit values. The `switchMap()` operator will unsubscribe and complete the first stream so that the first stream will stop emitting values; the next stream will keep emitting until that stream is completed or until another stream comes:

const clicks = fromEvent(document, 'click');

clicks.pipe(

switchMap(() => interval(1000).pipe(take(4)));

).subscribe(x => console.log(x));


 So, with the preceding code, if we click on the screen now, wait for 2 seconds, and then click another time, our log will look like `0, 1, 0, 1, 2, 3`. As you can see in the logs, the first stream is completed the moment the second stream starts to emit values. Lastly, we have the `exhaustMap()` operator.
The exhaustMap() operator
The `exhaustMap()` operator will start to emit the values of the first Observable stream as soon as it starts to emit values. The `exhaustMap()` operator will not process any other Observable streams that come in while the first stream is still running. Only when the stream has been completed will new values be processed, so if you click while the first stream is still running, it will never be processed:

const clicks = fromEvent(document, 'click');

clicks.pipe(

exhaustMap(() => interval(1000).pipe(take(4))),

]).subscribe(x => console.log(x));


 In the preceding example, where we used the `exhaustMap()` operator and clicked and waited for 2 seconds before we made another click, only the first click will be processed because the first stream takes 4 seconds to complete. So, when we make the second click, the first stream is not completed yet, so `exhaustMap()` doesn’t process the second stream. The log of the preceding code will look like `0, 1,` `3, 4`.
Lastly, it’s important to note that when you use the `take()`, `takeUntil()`, or `takeUntilDestroyed()` operators inside the pipe of the outer Observable and also use flattening operators in the same `pipe()` function, the `take()`, `takeUntil()`, and `takeUntilDestroyed()` operators need to be declared after the flattening operators. The flattening operators will create their own Observables, and if you declare `take()`, `takeUntil()`, or `takeUntilDestroyed()` before the flattening operator, the Observables created by the flattening operators will not be unsubscribed and closed by `take()`, `takeUntil()`, or `takeUntilDestroyed()`.
You now know what higher-order Observables are and how you can handle them using flattening operators. You learned about combined operators that combine the `map()` and flattening operators, and you learned about using `take()`, `takeUntil()`, or `takeUntilDestroyed()` in combination with the flattening operators. Lastly, you learned about the `interval()` and `take()` operators.
Now, let’s start exploring other useful pipeable operators that serve a few more straightforward use cases and scenarios.
Powerful and useful RxJS operators
You have already learned much about RxJS and seen how you can handle some complex Observable scenarios by combining or flattening Observables. You’ve also seen how to unsubscribe Observables or filter values using pipeable operators. We will now walk through some commonly used pipeable operators that are useful in more straightforward scenarios:

*   `debounceTime()`: The `debounceTime()` operator takes a pause and waits for another value to come in within the defined timeframe. A good real-world example of this is a search or filter input field. Instead of bombarding your system with an update for every keystroke, a more efficient solution would be waiting until the user stops typing for a specific interval. This waiting can be done by using `debounceTime()`. You provide `debounceTime()` with a parameter indicating the milliseconds it should wait (`debounceTime(300)`) before processing the value; only when no new value is received within the specified timeframe will the value be passed on to the next operator or the subscription block.
*   `Skip()`: The `skip()` operator can skip a fixed number of emissions. Let’s say you have `ReplaySubject`, and for one of your subscriptions on `ReplaySubject`, you aren’t interested in the replayed emissions, only in the new emission. In this scenario, you can use the `skip()` operator and define the number of emissions you want to skip inside the operator: `skip(5)`.
*   `skipUntil()`: The `skipUntil()` operator works a bit like the `takeUntil()` operator; only it will skip the emissions until the inner Observable of `skipUntil()` receives a value. You could provide `skipUntil()` with a `Subject` class or something like an RxJS timer so that you only take values after a predefined interval has passed: `skipUntil(timer(5000))`.
*   `find()`: The `find()` operator works similarly to the `find` method on an array. It will only emit the first value it finds that matches the condition you provide the `find()` operator with. So, `find((item: any) => item.size === 'large')` will only pass on the first item through the pipe where the size property is equal to `large`.
*   `scan()`: The `scan()` operator is comparable to the `reduce` function on an array. It gives you access to the previous and current value and allows you to emit a new value based on the previous and current value. For example, you can combine the results or take the lowest or highest result of the two: ``scan((prev, curr) => `${prev} ${curr}`, '')``. Here, we combined the previous and current values using the `scan()` operator.

With that, we have covered some of the more commonly used operators and learned how to filter, map, limit, or transform the value stream with pipeable operators. You can find them in the official documentation if you want to learn more about operators and check out a complete list: [`rxjs.dev/guide/operators`](https://rxjs.dev/guide/operators).
Before we move on to the next section and start to learn about Angular Signals, let’s finish this section on RxJS by creating combined and reusable operators.
Creating combined and reusable RxJS operators
Creating a reusable operator or combining multiple operators can easily be done by creating a function that returns an RxJS `pipe()` function. Let’s say you find yourself making a filter pipe that filters odd numbers multiple times. Creating a function that does this would be easier so that you don’t have to repeat the logic numerous times. You can do this by creating a function that returns an RxJS pipe implementing the filter operator with the filter logic predefined in the operator:

export const discardOdd = () => pipe(

filter((v: number) => !(v % 2)),

);


 You can now use this pipeable operator like any other operator:

of(1, 2, 4, 5, 6, 7).pipe(discardOddDoubleEven()).subscribe(console.log);


 If you want to combine multiple operators, it works the same way: you must create a function that returns an RxJS pipe and declares all the operators you want to use inside the pipe function:

const discardOddDoubleEven = () => pipe(

filter((v: number) => !(v % 2)),

map((v: number) => v * 2)

);


 With that, you’ve learned all about operators and how to use pipeable and creational operators. You know how to combine and flatten multiple Observable streams and create reusable and combined operators using the `pipe()` function. You’ve seen how to create Observable streams and handle code reactively and asynchronously with Observables and RxJS.
Since the introduction of Angular Signals, you can also handle reactivity more synchronously, allowing you to do almost everything in your Angular applications in a reactive manner, both with synchronous and asynchronous code.
In the next section, you will learn everything about Angular Signals. You will learn what Signals are and how and when to use them.
Reactive programming using Angular Signals
We briefly discussed **Angular Signals** in *Chapter 2*, but let’s reiterate that and dive a bit deeper so that you can get a good grasp of Angular Signals and how they can help you handle code more reactively.
Angular Signals was introduced in Angular 16, and it’s one of the most significant changes for the framework since it went from AngularJS to Angular. With Signals, the Angular framework now has a reactive primitive in the Angular framework that allows you to declare, compute, mutate, and consume synchronous values reactively. A **reactive primitive** is an immutable value that alerts consumers when the primitive is set with a new value. Because all consumers are notified, the consumers can automatically track and react to changes in this reactive primitive.
Because Signals are reactive primitives, the Angular framework can better detect changes and optimize rendering, resulting in better performance. Signals are the first step to an Angular version with fully fine-grained and local change detection that doesn’t need Zone.js to detect changes based on browser events.
At the time of writing, Angular assumes that any triggered browser event handler can change any data bound to an HTML template. Because of that, each time a browser event is triggered, Angular checks the entire component tree for changes because it can’t detect changes in a fine-grained manner. This is a significant drain on resources and impacts performance negatively.
Because Signals notify interested parties of changes, Angular doesn’t have to check the entire component tree and can perform change detection more efficiently. While we aren’t at a stage yet where Angular can perform fully local change detection and only update components or properties with changes, by using Signals combined with OnPush change detection, you reduce the number of components Angular has to check for changes. Eventually, Signals will allow the framework to perform local change detection, where the framework only has to check and update components and properties that have changed values.
Besides change detection, Signals bring more advantages. Signal allows for a more reactive approach within your Angular code. While RxJS already does a fantastic job facilitating reactive programming within your Angular applications, RxJS focuses on handling asynchronous Observable data streams and isn’t suited to handle synchronous code.
On the other hand, Signals shine where RxJS falls short; Signals reactively handle synchronous code by automatically notifying all consumers when the synchronous value changes. All dependent code can then react and update accordingly when the Signal pushes a new value. Especially when you start to utilize Signal effects and computed Signals, you can take your reactivity to the next level! Signal effects and computed Signals will automatically compute new values or run side effects when the Signal value changes, making it easy to automatically update and run logic as a reaction to the changed value of synchronous code.
Another problem that Signals solves is the infamous and dreaded `ExpressionChanged` **AfterItHasBeenCheckedError** error. If you’ve worked with Angular, changes are pretty significant you’ve seen this error before. This error occurs because of how Angular currently detects changes. Because the change detection on Signals is different, as Angular knows when they change and doesn’t have to check for changes, the dreaded `ExpressionChangedAfterItHasBeenCheckedError` error will not occur when working with Signal values.
Signals wrap around values such as strings, numbers, arrays, and objects. The Signal then exposes the value through a getter, which allows the Angular framework to track who is consuming the Signal and notify the consumers when the value changes. Signals can wrap around simple values or complex data structures such as objects or arrays with nested structures. Signals can be read-only and writable. As you might expect, writeable Signals can be modified, whereas read-only Signals can only be read.
Now that you understand the theory behind Signals, let’s dive into some examples and learn how and when to use Angular Signals within Angular applications.
Using Signals, computed Signals, and Signals effects
The best way to better understand something is to use it. So, without further ado, let’s start learning about Signals by writing some code. Start by cleaning up your `expenses-overview` component, clear the entire HTML template, and remove any logic you still have in the component class. Your component class and the corresponding HTML template should be empty when you’re done.
To explain Signals step by step, we will initially use hardcoded expenses inside the `expenses-overview` component. We’ll start by creating an `expenses` Signals with an initial value containing an array with some expenses inside:

expenses = signal<ExpenseModel[]>([ …… ]);


 As you can see, we created a property and assigned it a `signal()` function. This function receives a parameter that sets the initial value of the Signal. In our example, we have added an array with some expenses for the initial value (you can create the mocked expenses based on `ExpenseModel`). You can manually add a type for your Signal using the arrow syntax, `<ExpenseModel[]>`, but the Signal also infers the type from the initial value. Let’s use this Signal inside our HTML template to output the expenses.
You can access a Signal like any other function – you use the property name of the Signal and add function brackets after it. In general, I don’t recommend using functions inside your HTML template, but Signals are an exception as they are non-computational functions; they return a value without computing anything. So, let’s output our Signal inside the HTML template:

Expenses Overview

……

@for (expense of expenses(); track expense.id){

……

}

Description
{{ expense.description }}

 Here, we’ve created an HTML table and used the control flow syntax to output a table row for each expense within our `expenses` Signal. We accessed the expenses by calling our Signal with `expenses()`. You can compose your own table headers and data rows or copy the HTML and CSS from this book’s GitHub repository. Now that you know how to create and use Signal values, next, you will learn how to update your Signals.
Updating Signals
A Signal can be updated by using the `set()` or `update()` method on it. The `set()` method sets an entirely new value, whereas the `update()` method allows you to use the current Signal value and construct a new value based on the current value of the Signal.
To demonstrate this, let’s add a modal with `AddExpenseComponent` inside the modal. Before you add the form inside the template, let’s update `AddExpenseComponent` so that it uses our new `ExpenseModel` instead of the `AddExpenseReactive` model we used in *Chapter 4*. Replace all instances of `AddExpenseReactive` with `ExpenseModel`. Now, change the `date` and `tags` fields in the `addExpenseForm` property to this:

date: new FormControl<string | null>(null, [Validators.required]),

tags: new FormArray<FormControl<string | null>>([

new FormControl('');

])


 Now that we’ve updated the form so that it uses `ExpenseModel`, let’s import `AddExpenseComponent` and `ModalComponent` into `ExpensesOverviewComponent` so that we can use them inside the HTML template. Next, create a new Signal in `ExpensesOverviewComponent` to control the state of the modal component:

showAddExpenseModal = signal(false);


 After adding the Signal to control the modal state, you can add both the modal and expense components to the HTML template, like this:

<bt-libs-modal [shown]=" showAddExpenseModal()" (shownChange)=" showAddExpenseModal.set(false)" [title]="'添加费用'">

<bt-libs-ui-add-expense-form #form (addExpense)="onAddExpense($event)" />


 As you can see, when the modal outputs the `shownChange` event, we use the `set()` method on the `showAddExpenseModal` Signal to set a new value for the Signal. In this scenario, we don’t care about the previous signal value because we know we want to close the modal when this event is fired. Because we don’t care about the previous value, we can use the `set()` method on the Signal to set a new value. Inside the component class, we need to add the `onAddExpense` method so that we can the expense we submit in the add expense form:

onAddExpense(expenseToAdd: ExpenseModel) {

this.expenses.update(expenses => [...expenses, expenseToAdd]);

this. showAddExpenseModal.set(false);

}


 In the preceding code, we used the `update()` method to change the `expenses` Signal and the `set()` method for the `showAddExpenseModal` Signal. We use the `update()` method for the `expenses` Signal to access the current state of `expenses` and add the new expense to the existing expenses. When we submit the form, we also want to close the modal. For this, we can use the `set()` method because we just wish to change the Signal to a `false` value and are not interested in the current value of the Signal. Lastly, we need a button to open the modal:

<button (click)="showAddExpenseModal.set(true)">添加费用


 After adding the button, you can open the modal and create a new expense using `addExpenseForm`.
Lastly, it’s good to know that when you update a Signal using `set()` or `update()` in a component with `OnPush` change detection, the component will automatically be marked by Angular to be checked for changes. As a result, Angular will automatically update the component on the next change detection cycle.
Now that you know how to create and update Signals, let’s learn about computed Signals.
Computed Signals
`set()` or `update()` method on them. Instead, computed Signals automatically update when one or more Signals they derive their value from changes.
Let’s start with a basic example to better understand computed Signals and how they work. You don’t have to add this example inside `ExpensesOverviewComponent`; it’s just for demonstration purposes:

const count: WritableSignal = signal(0);

const double: Signal = count Signal and a computed Signal named double. The computed Signal uses the computed function, and the count Signal is used inside the callback of the computed function. When the value of the count Signal changes to 1, the value of the computed Signal is automatically updated to 2.

重要的是要知道,计算信号仅在它所依赖的信号有新的稳定值时才会更新。注意我说的是稳定值;这是因为信号异步提供更新,并且有点像 RxJS 的switchMap操作符,如果在旧数据流完成之前新数据流到来,它会取消前一个数据流。所以,如果您连续多次更新信号而不暂停,信号将不会稳定其值,因此计算信号将仅在信号的最后一次值变化上运行。

计算信号非常强大且效率很高。计算信号将不会在首次读取计算值之前计算任何值。接下来,计算信号将缓存其值,当您再次读取计算信号时,它将简单地返回缓存的值而无需运行任何计算。如果计算信号使用的信号值发生变化,计算信号将运行新的计算并更新其值。

由于计算信号缓存其结果,您可以在计算信号的回调函数内部安全地使用计算密集型操作,如过滤和映射数组。就像常规信号一样,计算信号在值变化时通知所有消费者。因此,所有计算信号的消费者都将显示最新的计算值。

现在,让我们向ExpensesOverviewComponent添加一个计算信号以显示总金额,包括增值税:

totalInclVat = computed(() => this.expenses().reduce((total, { amount: { amountExclVat, vatPercentage } }) => amountExclVat / 100 * (100 + vatPercentage) + total, 0));

正如您所看到的,我们使用了computed函数,并在computed函数的回调中使用了expenses信号来检索当前的费用列表。我们可以使用Array.reduce函数在expenses数组上检索包括增值税在内的总成本。您可以像访问常规信号一样访问计算信号:

this.totalInclVat()

让我们在 HTML 模板中创建一个新的表格行以显示总价值。您可以在 HTML 模板中的for循环下方添加表格行:

<tr class="summary">
  <td>Total: {{totalInclVat()}}</td>
</tr>

假设你使用添加支出表单添加了一笔新支出。在这种情况下,你会注意到总金额会自动更新,因为计算信号使用expenses信号来评估总金额。当expenses信号发生变化时,计算信号也会根据expenses信号进行更新。

关于计算信号,还有一点值得了解的是,只有用于计算的信号才会被跟踪。例如,假设你添加了一个信号来控制是否显示或隐藏表格行摘要,以及另一个用于相应按钮文本的信号:

showSummary = signal(false);
summaryBtnText = computed(() => this.showSummary() ? 'Hide summary' : 'Show summary');

你现在可以在计算信号内部使用以下showSummary信号,如下所示:

totalInclVat = computed(() => this.showSummary() ? this.expenses().reduce(
    (total, { amount: { amountExclVat, vatPercentage } }) => amountExclVat / 100 * (100 + vatPercentage) + total,
    0
  ) : null);

在这个场景中,计算信号只会跟踪expenses信号,如果showSummary信号设置为true。如果showSummary信号设置为falseexpenses信号在computed函数内部永远不会被访问,因此它不会因为变化而被跟踪。所以,如果你在showSummary信号设置为false时更新expenses信号,计算信号将不会计算新的值。

现在你已经了解了计算信号是什么,如何在代码中使用它们以及计算信号如何更新它们的值,让我们来探索信号效果。

信号效果

信号效果是每次信号变化时都会运行的副作用。你可以在信号效果内部执行任何你想要的逻辑。信号效果的用例可能包括记录日志、更新本地存储、显示通知或执行无法从 HTML 模板内部处理的 DOM 操作。

你可以通过使用effect函数并为其提供一个回调来创建信号效果:

effect function is initialized. Furthermore, when you use a Signal inside the callback of the effect function, the effect function becomes dependent on that Signal, and the effect function will run each time one of the Signals it depends on has a new stable value.
It is also good to know that just as with computed Signals, a Signal effect only runs if the Signal within the `effect` function can be reached:

effect(() => {

if (this.showSummary()) {

console.log('更新后的支出:', this.expenses());

}

});


 In the preceding example, the `effect` function will not run if the `expenses` signal updates while the `showSummary` signal is evaluated to be `false`. Besides unreached Signals, you can also prevent the Signal’s `effect` function from reacting to a Signal by wrapping that Signal in the `untracked` function:

effect = effect(() => {

console.log('摘要:', this.showSummary());

console.log('支出:', untracked(this.expenses()));

});


 Another good thing to know about Signal effects is that they need access to the injection context. This means you need to declare the Signal inside the constructor or directly assign it to a property where you declare your component properties. An error will be thrown when you create a Signal effect outside the injection context. If you need to declare a Signal effect outside the injection context, you can provide the effect with the injection context like so:

injector = inject(Injector);

effect(() => {

console.log('更新后的支出:', this.expenses());

}, { injector: this.injector });


 By default, effects clean up when the injection context where the effect is declared is destroyed. If you don’t want this to happen and you need manual control over the destruction of the signal effect, you can configure the effect so that it uses manual cleanup:

expenseEffect = effect(() => {

console.log('更新后的支出:', this.expenses());

}, { manualCleanup to true, you have to call the destroy() function on the effect:

expenseEffect.destroy();

你还可以通过使用回调函数来挂钩信号效果的清理。这在你想在信号效果清理时执行一些逻辑时非常有用。以下是一个onCleanup回调的示例:

effect((onCleanup) => {
  onCleanup(() => { console.log('Cleanup logic')})
})

除了manualCleanuponCleanup回调之外,信号效果还有一个最后的配置选项。默认情况下,你不允许在信号效果内部更新信号;这是因为这很容易导致信号效果的无限执行。然而,你可以通过在信号效果上设置allowSignalWrites属性来规避这一点:

expenseEffect = effect(() => {
  console.log(‹Updated expenses:›, this.expenses());
}, { allowSignalWrites: true });

现在你已经了解了关于信号效果的所有内容,包括如何使用它们以及如何触发或配置它们,让我们来学习信号组件的输入。

Signal 组件输入

自 Angular 17.1 以来,您也可以使用 Signal 作为组件输入,而不是使用@Input()装饰器和ngOnChanges生命周期钩子。

让我们看看 Signal 输入的一个示例,并将其与@Input()装饰器进行比较(您不需要在 monorepo 中添加示例,只是为了说明目的):

@Input() data!: DataModel; // The old way of doing things
data = input<DataModel>(); // The signal input

如您所见,Signal 输入在声明输入属性方面有一个更直接的方法。您声明一个属性并使用input()函数为其赋值;可选地,您还可以添加箭头语法来为 Signal 输入添加类型。如果您想为 Signal 输入提供一个初始值,您可以将其作为函数参数提供,如下所示:

data = input({ values: [……], id: 1 });

就像输入装饰器一样,您也可以使输入成为必需的,使用输入别名,或创建输入上的转换函数:

data = input.required<DataModel>();
data = input({ values: [……], id: 1 }, { alias: 'product'});
data = input({ values: [……], id: 1 }, transform: sort<DataModel>);
export function sort<T>(data: T[]): T[] {
  return data.sort((a, b) => a.id - b.id)
}

如您所见,您可以使输入成为必需的,并使用配置对象为 Signal 输入提供一个别名和转换函数。Signal 输入使用更简单的语法,有助于改进变更检测机制,并允许您移除ngOnChanges生命周期钩子。

当您将 Signal 输入与计算 Signal 结合使用时,可以移除ngOnChanges生命周期钩子,因为所有需要在特定属性输入时更新的属性现在都可以通过基于输入 Signal 的计算 Signal 自动更新。您想要运行的任何附加逻辑都可以在响应 Signal 输入的 Signal 效果内部声明。

既然您已经了解了 Signal 输入,让我们来学习 Signal 查询,它用于以响应式的方式与 HTML 元素交互。

Signal 查询

通常,您需要从模板中选择 HTML 元素并在组件类内部与之交互。在 Angular 中,这通常是通过使用@ContentChild@ContentChildren@ViewChild@ViewChildren装饰器来实现的。在 Angular 17.2 中,引入了一种基于 Signal 的新方法,允许您以响应式的方式与 HTML 元素交互,并将它们与计算 Signal 和 Signal 效果相结合。使用基于 Signal 的方法而不是装饰器提供了一些额外的优势:

  • 您可以使查询结果更加可预测。

  • 所有基于 Signal 的查询都返回一个 Signal,当有多个值时,Signal 返回一个常规数组。装饰器返回多种返回类型,当您的查询返回多个值时,它们返回一个查询列表而不是常规数组。

  • 基于 Signal 的查询可以用于随时间变化的 HTML 元素,因为它们是条件渲染的或通过for循环输出的。指令方法在这两种情况下都有问题,并且不会在模板更新时自动通知您。

  • TypeScript 可以自动推断查询 HTML 元素或组件的类型。

现在您已经了解了基于查询的信号与指令方法相比的优势,让我们来探索基于查询的信号的语法。与定义指令不同,基于信号的方法与简单的函数一起工作。有四个不同的函数:viewChild()contentChild()viewChildren()contentChildren()。您向这些函数提供查询选择器,类似于您可以使用装饰器组合的查询选择器。以下是如何使用信号查询的示例:

@Component({
  template: `
      <div #el></div>
      <my-component />
  `
})
export class TestComponent {
  divEl = viewChild<ElementRef>('el');
  cmp = viewChild(MyComponent);
}

在前面的示例中,我们使用了viewChild()查询信号来检索具有#el ID 的<div>元素和<my-component>元素。或者,如果您想检索具有相同选择器的多个元素,您可以使用viewChildren()函数。

通过这些,您知道了如何使用基于信号的新方法查询模板元素。您还了解了新的基于信号的方法相对于装饰器的优势。如果您更喜欢装饰器或者代码库中有装饰器,仍然可以使用装饰器。

在本节中,您学习了关于信号的内容。您学习了如何使用set()update()方法创建、读取和更新信号。然后,我们在ExpensesOverviewComponent中添加了一些信号并学习了计算信号。最后,您学习了信号效果、信号组件输入和查询信号。

在上一节中,您学习了关于 RxJS 的内容;在下一节中,您将学习如何结合 RxJS 和信号。

结合信号和 RxJS

在本章中,您已经看到了信号和 RxJS 如何帮助您以响应式的方式管理数据变化。信号和 RxJS 都允许您在值发生变化时做出反应,并通过组合多个数据流或根据数据变化执行副作用来创建新值。因此,可能会出现以下问题:信号是否取代了 RxJS?我何时使用信号,何时使用 RxJS?

RxJS 有时可能感觉令人畏惧且复杂,因此一些开发者可能会倾向于完全用信号取代 RxJS。虽然这可能适用于某些应用程序,但 RxJS 和信号都在您的应用程序中有其位置,并解决不同的问题和需求。在许多情况下,您可以使用信号或 RxJS 为您的解决问题,但其中之一将更好地解决问题,并且用更少的代码行处理它。信号并不是要取代 RxJS,但信号将与之互补,并且在许多情况下,与您的 RxJS 代码一起工作。

由于信号和 RxJS 应该共存,Angular 创建了两个 RxJS 互操作性函数:toSignal函数。

使用 toSignal

toSignal函数用于将可观察对象转换为信号。toSignal函数与ASYNC管道非常相似,但具有更多的灵活性和配置选项,并且可以在应用程序的任何位置使用。语法相当简单;您使用toSignal函数并给它提供一个可观察对象:

counter = counterObservable$ into a counter Signal. The toSignal function will immediately subscribe to counterObservable$, receiving any values the Observable emits from that point. As with regular Signals, you can use the Signals that were created with the toSignal function inside computed Signals and Signal effects. When toSignal changes its value because the Observable emits a new value, any Signal effect or computed Signal depending on that Signal will be triggered.
The `toSignal` function will also automatically unsubscribe from the Observable, given that the `toSignal` function is used within the injection context. When you use the `toSignal` function outside the injection context or want to make it dependent on a different injection context, you can provide the `toSignal` function with an injection context, like so:

injector = inject(Injector);

counter = toSignal(this.countObs$,toSignal 函数中包含一个额外的配置对象,其中我们提供了 injector 属性。也可能存在您不希望 toSignal 函数在组件或注入上下文被销毁时自动取消订阅 Observable 的场景。

您可能需要根据系统的需求在更早或更晚的时候停止 Observable。对于这种情况,您可以为 toSignal 函数提供一个 manualCleanup 配置,类似于 Signal 效应:

counter = toSignal(this.countObs$, {manualCleanup to true, the toSignal function will receive values up to the point the Observable it depends on is completed. When the inner Observable has been completed, the Signal will keep returning the last emitted value; this is also the case if you don’t use the manualCleanup configuration. Besides having control over the unsubscribe process of the Observable used by the toSignal function, you can also provide an initialValue configuration.
The Observable you convert into a Signal might not immediately and synchronously emit a value upon subscription. Yet Signals always require an initial value, and if one isn’t provided or the value comes in asynchronously, the initial value of the Signal will be `undefined`. Because the initial value of the Signal is `undefined`, the type of the Signal will also be `undefined`. To prevent `undefined` being the initial, you can provide the `toSignal` function with an initial value using this syntax:

counter = toSignal(this.countObs$,{undefined 作为您的初始值可能在计算信号或使用 undefined 值的 Signal 效应中引起问题。

另一方面,一些 Observable 在订阅时会发出同步值;例如,考虑 BehaviorSubject。如果您使用在订阅时发出同步值的 Observable,您还需要在 toSignal 函数内部使用 requireSync 配置来配置这一点:

counter = toSignal(this.countObs$, {requireSync: true});

通过将 requireSync 选项设置为 truetoSignal 函数强制在订阅时接收初始值是同步的,跳过初始的 undefined 值并将 Signal 类型化为 undefined。最后,您可以配置 toSignal 函数应该如何处理 Observable 中发生的错误。

默认情况下,如果 Observable 抛出错误,Signal 将在读取 Signal 时抛出错误。您还可以将 rejectErrors 选项设置为 true;在这种情况下,toSignal 函数将忽略错误并继续返回 Observable 发射的最后一个良好值:

counter = toSignal(this.countObs$, {rejectErrors option to true, errors are handled in the same way the ASYNC pipe handles errors within Observables.
Using toObservable
The `toObservable` function is used to convert a Signal into an Observable. Here’s an example of how you can use the `toObservable` function:

counter = toSignal(this.countObs$);

countObs$ = toObservable 函数背后的场景,Angular 将使用 Signal 效应来跟踪在 toObservable 函数内部使用的 Signal 的值,并将更新的值发射到 Observable。因为 Angular 使用 Signal 效应来更新 Observable 的值,所以它只会发射 Signal 中的稳定变化。因此,如果您连续多次设置 Signal 而没有间隔,使用 toObservable 创建的 Observable 将只在 Signal 稳定时发射最后一个值。

默认情况下,toObservable 函数使用当前的注入上下文来创建 Signal 效应;如果您在注入上下文外部声明 toObservable 函数或想在另一个注入上下文中创建 Signal 效应,您可以向 toObservable 函数提供一个注入上下文:

injector = inject(Injector);
countObs$ = toObservable(this.counter, {toObservable function – there are no other configuration options; you use the toObservable function and provide the function with your Observable to convert the Signal into an Observable. You can also combine both the toSignal and toObservable functions in one go.
Here’s an example of how you could use a Signal input and the `toObservable` and `toSignal` functions to fetch a new product and convert it into a Signal each time the component receives a new ID input:

id = input(0);

product = toSignal(

toObservable(this.id).pipe(

switchMap((id) => this.service.getProduct(id as number)),

),

{ initialValue: null }

);


 Now that you know how to combine RxJS and Signals by using the `toSignal` and `toObservable` functions, let’s finish this chapter by providing a bit more clarity about when to use Signals and when to use RxJS.
Choosing between Signals and RxJS
As mentioned previously, neither Signals nor RxJS are one-size-fits-all solutions. When you’re building an application, chances are you’ll need both Signals and RxJS to create the most optimal code. The most straightforward distinction is that Signals handle synchronous code, and RxJS is used to handle synchronous code, but things aren’t always as simple. You could also convert synchronous code using the `toSignal` function. For clarity, we’ll go through some examples at face value and determine if using Signals or RxJS would be better. In the real world, there are always nuances, and you should take whatever best fits your scenario, the team, and the existing code of the application.
Let’s start with an HTML template. In an HTML template, you can use an RxJS Observable by using the `ASYNC` pipe, or you can use a Signal. I would try to use Signals in the HTML template as this simplifies the HTML template by maintaining a synchronous approach. Using Signals will also help improve the change detection mechanism Angular uses, which can improve your application’s performance.
There are more gray areas in the component classes where it might be more complex to determine whether to use a Signal or RxJS Observable. If we look at the local component state, I would use Signals and computed Signals to define the component state; this also allows you to consume the component state as signals inside the HTML template.
When it comes to handling user events, it depends a bit on how you need to process the values of the event. If it’s a simple event such as handling a form submission or a button click, a Signal will work perfectly fine to update the correlating values. If you need more control over the delivery of the value stream, combine multiple events, or map, filter, and transform the data stream before it reaches your application logic, RxJS will be a better fit.
A typical example is when you have a search input field that makes API requests. You don’t want to make too many API requests by firing an API call on each key-up event. Instead, you want to check if the user stopped typing for a specified interval. Using RxJS, this can be done using the `debounceTime` operator. You can handle the same functionality using a Signal, but this requires a lot more code, and it becomes more complex and less readable. Depending on your architecture, most other scenarios that are handled inside your components are connected with your facade services or state management.
Now, let’s discuss some different scenarios and compare Signals with RxJS. Events that have to be distributed throughout the application and where different parts of your application have to react differently are also best handled using RxJS, more specifically an RxJS `Subject`. Using a `Subject` class, each part of your application can listen for the Observable and react how it needs to react.
Defining simple synchronous global application states can be done using Signals and computed Signals. The current value of the state can be retrieved by using Signals. Additionally, you can define change events using RxJS subjects if different application parts need to perform different logic when the values change. You can trigger the RxJS subjects inside your state management using a signal effect. Using the Signal effect might only work if reacting on stabilized value changes is enough; if you need to react to multiple changes that follow on from each other, this approach will not work for you.
When you have more complex state or asynchronous sources that need to be modified, combined, filtered, or mapped before you can provide the values to the rest of your application, RxJS is the best solution to handle the data streams. Especially when you need to handle multiple nested Observables or if you want to combine various streams and need control over when and how the values of these different streams are processed, RxJS offers many more tools to handle this gracefully.
Inside your facade services, you can combine RxJS and Signals. Depending on the complexity and setup of your state, a good approach is to use the `toObservable` function and RxJS to create the models you need to expose to the view layer. Once you’ve mapped all the data streams into the models and values you need, you can use `toSignal`, Signals, and computed Signals to expose the values to the view layer. Then, inside the view layer, you can consume the Signals synchronously while the facade service updates them asynchronously.
Now that you have a better idea of when to use Signals and when to use RxJS, let’s move on to the next chapter and start learning about state management.
Summary
In this chapter, you learned about reactive programming. You learned what reactive programming is, how the Angular framework uses it, and how it can be utilized to make your code efficient, event-driven, and performant.
Next, we did a deep dive into RxJS and saw how it can be used to create and handle Observable streams. You learned about different types of Observables and how to combine, flatten, and modify Observable streams using RxJS operators. We also explored some of the most used RxJS operators and learned how to create operators using the pipe function.
After understanding RxJS, we moved on to Angular Signals. You learned why Angular introduced Signals into the framework and how they help simplify your Angular code and improve the performance of your applications. You learned about Signals, computed Signals, the Signal effect, and interoperability functions for Signals and RxJS. We finished this chapter by exploring when you should use Signals and when to use RxJS within your applications.
In the next chapter, we will take a deep dive into state management.







第八章:以优雅的方式处理应用状态

在本章中,你将了解应用状态。理解和处理应用状态是前端开发中最基本的部分之一。如果应用的状态变得混乱、纠缠且难以理解,你的开发过程和应用的品质都将受到影响。

为了帮助你更好地管理应用状态,我们将讨论你将在应用中找到的不同状态层级。你将学习如何划分和分割你的状态以实现最大效率。你还将创建一个使用 RxJS 和 Signals 的状态管理解决方案,并构建一个外观服务以从组件层访问状态。

接下来,你将学习如何使用 NgRx 库处理更复杂的状态。NgRx 是 Angular 社区中最常用的状态管理库,它使用 Redux 模式来管理状态。自从 Angular Signals 引入以来,NgRx 也提供了不同的方法来使用 Signals,同时使用我们喜爱的 NgRx 工具。

到本章结束时,你将使用不同的方法实现了状态管理解决方案。你将了解到在使用外观服务时更改状态管理解决方案是多么容易,并看到 Angular Signals 如何改变了我们在 Angular 应用中处理状态的方式。

本章将涵盖以下主题:

  • 理解应用状态

  • 使用 RxJS 处理全局应用状态

  • 使用 Signals 处理全局应用状态

  • 使用 NgRx 处理全局应用状态

理解应用状态

简而言之,应用状态是在特定时间点你的数据、配置和视图当前条件(或状态)的快照。应用状态是从浏览器加载应用的那一刻起,在应用中执行的所有动作的总和。状态是一个动态的景观,它影响着应用视图、用户交互、数据流和整体功能。

在你的应用中拥有良好的状态管理至关重要,这样所有组件都能向最终用户显示正确的数据,你也能在应用代码中有准确的数据进行操作。良好的状态管理可以防止意外的数据更改,从而避免在应用代码中执行你未打算执行的不正确视图和操作。

既然你已经了解了应用状态是什么以及为什么你需要它,那么让我们更深入地探讨,从应用状态的不同层级开始。

应用状态的层级

在前端开发的领域,我们可以区分两个层面的状态:全局状态和局部状态。在本节中,我们将深入探讨 Angular 上下文中全局和局部应用状态之间的细微差别,阐明它们在构建健壮和可维护的前端应用中的作用。

正如它们的名称所暗示的,局部状态是局部化到文件、组件或应用程序中的元素,而全局状态是通过整个应用程序共享的。全局应用程序状态作为共享信息在各个组件之间的中央存储库,确保应用程序行为的一致性和同步性。另一方面,局部应用程序状态封装了特定于单个 Angular 组件和服务的内部数据和配置。

通过理解全局和局部状态的二重性,你的 Angular 应用程序可以在可重用性、封装性和共享数据完整性之间达到和谐的平衡。让我们先深入了解一下 Angular 应用程序中的局部应用程序状态。

局部应用程序状态

当我们提到局部状态时,我们指的是那些被局部化到组件或服务中的属性,这些属性决定了该组件或服务如何行为以及如何向应用程序的用户展示数据。

一个局部状态的简单例子是一个具有count状态的Counter组件:

export class Counter {
  count = signal(0);
  add() { this.count.update((count) => count + 1) }
  subtract() { this.count.update((count) => count - 1) }
}

count属性用于向用户显示当前计数。count属性的声明和更新行为在当前组件内部处理。

在组件内部,你可以将状态视为局部状态,当状态属性不是在多个智能组件之间共享,并且在你从一个页面导航到另一个页面时不需要持久化时。

在服务内部,当状态涉及一个不与外界共享的私有属性,并且该属性不需要比服务文件的生命周期更长的时间来持久化时,可以将其视为局部状态。如果属性不符合这些标准,你可能需要将其定位在全局应用程序状态中的某个位置。

这里有一些 Angular 应用程序中局部状态的常见例子:

  • 禁用按钮状态

  • 形式有效性状态

  • 模态可见性

  • 排序和过滤

  • 手风琴状态

  • 选定的标签页状态

你现在对局部状态有了很好的理解。你知道什么是局部状态,如何识别它,以及处理 Angular 应用程序中局部状态的首选工具是什么。你还了解了一些局部状态的常见例子。接下来,你将学习关于全局应用程序状态的内容。

全局应用程序状态

与局部状态相对,全局应用程序状态指的是在 Angular 应用程序中跨多个组件和服务共享的数据和配置。你可以将你的全局应用程序状态视为一个数据集中式存储库。这个信息集中式存储库对于确保应用程序各个部分之间的一致性、同步性和高效通信至关重要。

与局限于特定组件或服务的局部状态不同,全局应用状态在整个应用程序中持续存在,这使得它在需要在不同组件和服务之间共享和同步数据,以及在整个用户会话期间特别有用。

在 Angular 应用程序中,全局状态通常在服务中处理。通过创建一个专门用于管理全局状态的服务,开发者可以确保组件有一个集中的访问点来获取关键信息。包含全局应用状态的服务通常被称为存储。例如,您可以使用名为UserStore的类调用服务来存储全局用户状态user.store.ts

在较小的 Angular 应用程序中,状态通常使用Subjects进行管理。更具体地说,BehaviorSubject存储和分发状态属性,而常规的Subject分发全局事件。随着 Signals 的引入,一些BehaviorSubject类可以被 Signals 替换。我们将在开始构建全局状态管理时,在使用 RxJS 处理全局应用状态部分详细看到这一点。

对于较大的 Angular 应用程序,NgRx、NgXs、Akita 和 Angular Query 等库是处理全局状态的首选方法。这些库增强了您优雅地管理状态的能力,并实现了结构化和经过实战检验的设计模式,以可预测和可扩展的方式管理和更新全局状态。

理解何时利用全局应用状态至关重要。如果需要将状态属性在多个智能组件之间共享或超出单个组件的生命周期,则全局状态可能更为合适。现在您已经了解了局部和全局状态是什么,何时使用哪一个,以及有哪些工具可以优雅地管理它们,让我们来学习一些状态管理中的重要概念。

状态管理中的基本概念

要在您的 Angular 应用程序中构建一个健壮的状态管理系统,您需要了解状态管理的基本概念。您需要知道这些概念,为什么它们是必要的,以及不使用它们的危险。

在本节中,我们将学习单向数据流、不可变性和副作用。状态管理的一些其他重要基础包括响应性和设计模式,如 Redux 模式,但我们已经在第六章和第七章中讨论过,所以我们将不会深入探讨这一点。

单向数据流

单向数据流是我们将要讨论的第一个状态管理概念。正如其名称所暗示的,这个概念表明数据应该在整个应用程序中单向流动。数据的变化通过定义良好的动作或事件发生,确保信息流清晰且可预测。单向数据流简化了调试,使代码更可预测,并提高了可维护性。它通过强制应用程序中的数据清晰流动来防止意外的副作用。

没有单向数据流,追踪状态变化的原因会变得具有挑战性,导致调试困难以及数据一致性的潜在问题。不受控制的数据流可能导致不可预测的行为,尤其是在大型和复杂的应用程序中。

单向数据流的概念在整个应用程序中都很重要,无论是局部还是全局应用程序状态。对于全局应用程序状态,我建议始终使用单向数据流。在局部组件状态中,有时你可以通过使用 Angular 双向数据绑定来做出例外。

为了帮助你理解单向数据流在 Angular 应用程序中的样子,这里有一个流示例:

  1. 状态从存储传递到外观服务。

  2. 状态从外观服务传递到智能组件。

  3. 智能组件将数据传递给(无状态的)子组件。

  4. 视图根据智能组件及其子组件的状态进行渲染。

  5. 视图中可以触发一个动作。

  6. 动作的事件和相关信息从(无状态的)子组件向上移动到智能组件。

  7. 智能组件或外观向存储派发一个动作。

  8. 存储根据派发的动作更新状态。

  9. 状态从存储传递到外观。

正如你所见,数据从存储开始,单向流动直到视图可以被渲染。当用户在视图中触发一个动作时,数据会以单向和可预测的方式流回存储,直到形成一个完整的循环。现在你已经了解了单向数据流是什么以及为什么它在状态管理中很重要,让我们来学习不可变性的概念。

不可变性

不可变性涉及不直接修改现有数据结构的实践。相反,会创建带有所需更改的新副本,以保持原始数据的完整性。不可变性通过提供一个单一的位置来修改你的状态,简化了状态管理。它有助于防止意外的状态更改和副作用,在跟踪和管理 Angular 应用程序中的复杂状态时尤其有价值。

通过不可变性,你可能发现跟踪状态变化和保持状态同步更容易。直接修改状态对象可能导致错误和意外的行为。不可变性主要在全局状态管理中使用,但随着 Signals 的引入,现在它也应用于 Angular 应用程序的本地状态。

副作用

副作用指的是当你的状态中的某个特定部分发生变化时,你执行的操作或更改。副作用可能包括以下内容:

  • 获取数据

  • 更新本地存储

  • 分发额外的动作

  • 设置局部变量

通过隔离副作用,你可以在你的应用程序中保持关注点的清晰分离。核心应用程序逻辑(reducers、actions 和 selectors)专注于状态变化,而副作用则单独处理。副作用在 Angular 框架的 Signals API 中自然引入,并在像 NgRx 和 NgXs 这样的流行状态管理库中使用。

因此,总结一下,在你的应用程序中存在本地和全局状态。本地状态局限于组件或服务,而全局状态影响整个应用程序。状态管理的一些基本概念包括单向数据流、不可变性和副作用。你了解了这些概念的优势以及为什么它们对于状态管理解决方案很重要。你还了解了状态管理是什么以及为什么你需要在应用程序中使用它。

在下一节中,你将开始构建一个全局状态管理解决方案,并创建一个门面服务以从你的智能组件内部访问状态。

使用 RxJS 处理全局应用程序状态

在本节中,你将创建一个简单的状态管理解决方案,使用 RxJS。这个状态管理解决方案的核心是 RxJS 的BehaviorSubject类。你还将创建一个门面服务以与状态管理解决方案交互。

门面将负责与状态管理解决方案和智能组件的所有通信。这使我们的智能组件与状态管理解决方案解耦,便于在需要时轻松交换我们的状态管理实现。

一旦我们创建了 RxJS 状态管理解决方案并将其与应用程序的组件层连接起来,我们就可以将状态管理和门面更改为在可能且合理的地方使用 Signals。

通过将状态管理解决方案从 RxJS 转换为 Signals,你将能够理解这两个概念并了解它们之间的区别。构建这两种方法也将为你提供最佳服务,以便你在加入的项目中遇到它们时能够识别并与之合作。让我们从构建 RxJS 状态管理解决方案开始。

使用 RxJS 构建状态管理解决方案

要开始构建状态管理解决方案,在财务域的data-access库中创建一个名为stores的文件夹。stores文件夹应位于lib文件夹内部,与adaptersHTTPmodelsservices文件夹处于同一级别。

创建一个服务

首先,你可以通过在新建的stores文件夹中使用Nx 生成器来创建一个服务。将新服务命名为expenses.store。因为我们使用的是 Nx 生成器,它将创建一个名为expenses.store.service.ts的文件;你可以手动删除.service部分,并对spec文件做同样的处理。

接下来,将类名从ExpensesStore改为ExpensesStoreService并移除constructor;当你准备好时,这应该在你的文件中:

@Injectable({ providedIn: 'root' })
export class ExpensesStore {}

接下来,你需要一个可以保存你支出列表状态的东西。我们将使用一个BehaviorSubject类,该类将发出一个ExpenseModel数组。BehaviorSubject类将是一个私有属性,因此你无法直接从我们的ExpensesStore类外部修改状态。

只有ExpenseStore类应该能够直接修改状态;应用程序的其他部分应该通过ExpenseStore以及更精确地说,通过外观(facade),来修改状态。允许应用程序的其他部分直接修改状态可能导致意外的状态修改,破坏你的应用程序。

由于BehaviorSubject类是私有的,你还需要一个公共属性,该属性将BehaviorSubject类暴露给外部世界作为一个可观察对象(Observable):

private expenses = new BehaviorSubject<ExpenseModel[]>([]);
expenses$ = this.expenses.asObservable();

如你所见,我们首先定义了expenses BehaviorSubject类,并通过在BehaviorSubject类上调用asObservable()方法创建了公共的expenses$可观察对象(Observable)。我们给expenses BehaviorSubject类提供了一个空数组作为其默认值。接下来,让我们添加一些逻辑来获取和分发我们的数据。

在我们的存储中获取和分发数据

接下来,我们将添加一些逻辑来执行 API 请求以检索支出,并通过BehaviorSubject类发出接收到的支出。为了实现这一点,首先注入我们在第六章中创建的ExpensesHttpService类:

protected expensesApi = inject(ExpensesHttpService);

接下来,你需要创建一个方法来执行 API 请求并更新BehaviorSubject类:

fetchExpenses(): void {
  this.expensesApi.get().subscribe({
    next: (expenses) => { this.expenses.next(expenses) },
    error: (err) => { console.log(‹err ==>›, err) }
  });
}

如你所见,我们创建了一个名为fetchExpenses的方法,并在该方法内部使用expensesApi来执行get请求。我们订阅了get请求并处理了订阅的nexterror事件。当get请求的订阅收到响应时,处理next事件;当get请求失败并返回错误状态时,处理error事件。

如果 API 请求成功响应,我们在expenses BehaviorSubject类上调用next()方法,并给它传递接收到的expenses作为参数。如果 API 返回错误,我们简单地记录错误。在生产应用程序中,你应该更好地处理这种情况,并使用托盘消息或类似的方式提醒用户。

添加额外的费用方法

接下来,你将想要添加通过 ID 获取费用、更新、删除和添加费用的方法。在创建这些方法之前,必须调整MockInterceptor以处理deletegetByID请求。你可以自己修改拦截器,或者从本书的 GitHub 仓库中获取调整后的MockInterceptorgithub.com/PacktPublishing/Effective-Angular

在调整MockInterceptor后,你可以在我们的费用存储中实现adddeleteupdategetByID方法。在这些方法内部,我们需要访问当前的费用列表。你可以通过expenses BehaviorSubject类的value属性访问当前的费用列表。让我们创建一个获取器,从我们的状态中检索当前的费用:

private get currentExpenses() {return this.expenses.value}

现在,我们可以开始添加方法。

添加费用

让我们先创建一个添加费用的方法:

addExpense(expense: ExpenseModel): void {
  this.expensesApi.post(expense).subscribe({
    next: (addedExpense) => {
      addedExpense.id = !addedExpense.id ? this.currentExpenses.length + 1 : addedExpense.id;
      this.expenses.next([...this.currentExpenses, addedExpense]);
    },
    error: (err) => { console.log(‹err ==>›, err) }
  })
}

如你所见,addExpense代码将expense作为函数参数。这个expense参数用于在expenseApi上调用POST请求。

当 API 返回响应时,我们更新ID属性(我们只更新ID属性,因为我们没有实际的后端。通常,ID会由后端填充)。更新ID属性后,我们将新创建的expense添加到expenses状态中。

删除费用

在创建addExpense方法后,你可以创建一个删除费用的方法:

deleteExpense(id: number): void {
  this.expensesApi.delete(id).subscribe({
    next: () => {
      this.expenses.next(this.currentExpenses.filter(expense => expense.id !== id));
    },
    error: (err) => { console.log(‹err ==>›, err) }
  })
}

delete方法相当直接。我们发起 API 请求,当 API 响应时,我们通过调用next()方法更新expenses状态,以获取新的费用列表。作为next()方法的参数,我们使用当前的expenses列表并使用filter过滤掉已删除的费用。如果 API 返回错误,我们记录错误,同样在生产应用程序中,我们向用户显示某种消息。

获取、获取和选择费用

在添加delete方法后,我们将添加getExpenseselectExpensefetchExpenseById方法。getExpenseselectExpense方法将是公共方法,而fetchExpenseById将是私有方法。我们还将创建一个expense Subject类和一个selectedExpense状态,使用BehaviorSubject类。

让我们先添加Subject类和selectedExpense状态:

private expense: Subject<ExpenseModel> = new Subject();
expense$: Observable<ExpenseModel> = this.expense.asObservable();
private selectedExpense: BehaviorSubject<ExpenseModel | null> = new BehaviorSubject<ExpenseModel | null>(null);
selectedExpense$ = this.selectedExpense.asObservable();

expense Subject 类和 selectedExpense 状态可以用来响应式地检索选定的费用。当您需要将选择持久化到全局应用程序状态时,使用 selectedExpense 状态。相比之下,expense Subject 类可以用来发出一个事件,该事件只被在事件发出时订阅的观察者接收。在添加了 expense Subject 类和 selectedExpense 状态之后,我们将继续使用私有的 fetchExpenseById 方法:

private fetchExpenseById(id: number, select = false) {
  this.expensesApi.getById(id).subscribe({
    next: (expense) => { select ? this.selectedExpense.next(expense) : this.expense.next(expense) },
    error: (err) => { console.log(‹err ==>›, err) }
  })
}

fetchExpenseById 方法有 idselect 参数。id 参数是必需的,而 select 属性是可选的,默认值为 false。该方法首先通过 API 调用来通过 ID 获取费用。当 API 响应费用时,我们将使用 expense Subject 发出一个新值,或者使用 BehaviorSubject 类发出一个值并设置 selectedExpense 状态。根据您应用程序的需求,您还可以将获取的费用添加到 expenses 状态中,但对我们这个演示应用程序来说,这不是必需的。

现在,为了完成通过 id 获取费用的逻辑,我们需要实现公共的 getExpenseselectExpense 方法:

getExpense(id: number): void {
  const expense = this.currentExpenses.find(expense => expense.id === id);
  expense ? this.expense.next(expense) : this.fetchExpenseById(id);
}
selectExpense(id: number): void {
  const expense = this.currentExpenses.find(expense => expense.id === id);
  expense ? this.selectedExpense.next(expense) : this.fetchExpenseById(id, true);
}

如您所见,getExpenseselectExpense 方法非常相似。两种方法都接收 id 作为参数,并检查提供的 id 参数是否可以在当前的 expenses 状态中找到。

当在当前状态中找到费用时,会在 expense Subject 类或 selectedExpense BehaviorSubject 类上调用 next() 方法。当在当前的 expenses 状态中没有找到费用时,会调用 fetchExpenseById 方法从后端获取费用;在这种情况下,fetchExpenseById 方法将调用 expense SubjectselectedExpense BehaviorSubject 类。既然我们已经添加了获取或选择费用的响应式方法,让我们添加 updateExpense 方法。

更新费用

update 方法将接收 expense 作为函数参数。接下来,它将使用 expensesApi 发起 PUT 请求来更新后端中的请求。在 API 成功响应后,该方法将更新 expenses 状态:

updateExpense(expense: ExpenseModel): void {
  this.expensesApi.put(expense).subscribe({
    next: (expense) => {
      this.expenses.next(this.currentExpenses.map(exp => exp.id === expense.id ? expense : exp));
    },
    error: (err) => { console.log(‹err ==>›, err) }})
}

如您所见,我们发起 API 请求并在 expenses BehaviorSubject 上使用 next() 方法来更新 expenses 状态。作为 next() 方法的参数,我们使用 currentExpenses 获取器并使用 map() 函数来替换更新的费用。

现在我们已经添加了添加、更新、删除和获取费用的方法,让我们通过添加一些额外的状态和重置状态的方法来完成这个存储。

扩展 ExpensesStore

我们将首先添加一个额外的状态来管理是否显示价格,包括或排除增值税。我们可以通过创建一个新的 BehaviorSubject 类和一个调整 BehaviorSubject 类值的方法来实现这一点:

private inclVat = new BehaviorSubject<boolean>(false);
inclVat$ = this.inclVat.asObservable();
adjustVat(): void {
  this.inclVat.next(!this.inclVat.value);
}

如您所见,增值税状态只是一个简单的布尔值,表示我们是否显示包含或不含增值税的价格。

最后,我们需要一些逻辑来重置我们的应用程序状态并清除所选产品状态。我们将为resetState创建两个不同的方法来将所有状态重置为默认值。我们将使用clearExpenseSelection方法来清除selectedExpense状态:

clearExpenseSelection(): void {
  this.selectedExpense.next(null);
}
resetState(): void {
  this.expenses.next([]);
  this.selectedExpense.next(null);
  this.inclVat.next(false);
}

这是我们费用存储的最后一部分。您创建了一个简单而有效的状态管理解决方案来处理费用的全局应用程序状态。您这样做使用了 RxJS 的SubjectBehaviorSubject类。现在,ExpensesStore可以成为您应用程序中所有费用数据的单一事实来源。

如果一个组件需要某些费用数据的当前状态,它将来自这个ExpensesStore。当您的应用程序增长,并且您有除了费用之外的其他实体具有状态,例如用户、报告或设置时,每个实体都将有一个存储文件来管理该实体的状态。

现在您已经使用 RxJS 创建了一个状态管理解决方案,我们将开始构建门面服务,并通过门面将视图层与存储连接起来。

使用门面服务连接您的状态管理和视图层

现在您已经有一个状态管理解决方案,是时候将其连接到您应用程序的视图层了。正如本书中多次提到的,最佳方法是为此创建一个门面服务。这个门面提供了一层额外的抽象,为您的视图层提供了一个简单的接口来与应用程序状态交互。图 8.1展示了门面服务以及数据如何从您的状态通过门面流入组件:

图 8.1:使用门面、组件和状态的数据流

图 8.1:使用门面、组件和状态的数据流

如您所见,您的组件向门面服务发出一个简单的请求,门面将从您的不同状态服务中收集数据,并以组件所需格式将其发送回组件。这确保了您的组件只有一个依赖项,而门面将托管所有其他必要的依赖项以检索您组件所需的数据。

创建门面服务

首先,在您的expenses data-access库的lib文件夹内创建一个facades文件夹。新的facades文件夹将位于与store文件夹相同的文件夹中。

在新的facades文件夹内,您必须创建一个名为expenses.facade.ts的文件,并包含一个名为ExpensesFacade的可注入类。您可以使用 Nx 生成器创建一个服务并重命名它,或者手动创建门面。此外,在index.ts文件中添加一个导出,以便您可以在库外部使用门面。

当您完成时,您应该在expenses.facade.ts文件中有以下内容:

@Injectable({ providedIn: 'root' })
export class ExpensesFacade {}

创建外观接口

接下来,在 expenses.facade.ts 文件旁边创建一个名为 expensesFacade.interface.ts 的文件。在这个接口中,我们将声明外观的蓝图。只要你的外观实现了这个接口,你就可以在不接触组件层的情况下切换状态实现。如果你更改了接口,你也需要调整组件层。

在接口文件中,声明以下接口:

export interface IExpensesFacade {
  expenseSelector$: Observable<ExpenseModel>;
  selectedExpenseSelector$: Observable<ExpenseModel>;
  inclVatSelector$: Observable<boolean>;
  addExpense(expense: ExpenseModel): void;
  adjustVat(): void;
  clearExpenseSelection(): void;
  deleteExpense(id: number): void;
  fetchExpenses(): void;
  getExpense(id: number): void;
  getExpenses(id: number): Observable<ExpensesViewModel>;
  resetExpenseState(): void;
  selectExpense(id: number): void;
  updateExpense(expense: ExpenseModel): void;
}

在定义了接口之后,我们可以开始实现外观服务。首先实现接口:

export class ExpensesFacade implements IExpensesFacade {…}

现在,你想要在外观服务内部注入 ExpensesStore

protected readonly expensesStore = inject(ExpensesStore);

现在我们已经注入了存储库,我们将添加一个获取费用的方法。

将外观与存储库连接

让我们在外观中添加一个简单的方法,该方法简单地调用存储库中的 fetch 方法:

fetchExpenses() {
  this.expensesStore.fetchExpenses();
}

接下来,我们将创建一个获取已获取费用的方法。但在我们这样做之前,我们将创建一个新的接口,称为 ExpensesViewModel

export interface ExpensesViewModel {
  total: number;
  inclVat: boolean;
  expenses: ExpenseModel[];
}

你也可以稍微调整 ExpenseModel 并将 amountExclVat 属性重命名为 value。如果你使用 VS Code,你可以选择属性并按 F2 键来重命名它。当你使用 F2 键重命名时,属性将在每个实例中重命名(除了 HTML 模板之外)。

现在你已经创建了 ExpensesViewModel 并调整了 ExpenseModel,让我们在外观内部创建 getExpenses 方法:

getExpenses(): Observable<ExpensesViewModel> {
  return combineLatest([this.expensesStore.expenses$, this.expensesStore.inclVat$]).pipe(
    distinctUntilChanged(),
    map(([expenses, inclVat]) => ({
      expenses: structuredClone(expenses).map(expense => {
        expense.amount.value = inclVat ? expense.amount.value * (1 + expense.amount.vatPercentage / 100) : expense.amount.value;
        return expense;
      }),
      inclVat,
      total: expenses.reduce((acc, expense) => {
        return acc + (inclVat ? (expense.amount.value * (1 + expense.amount.vatPercentage / 100)) : expense.amount.value);
      }, 0),
    }))
  );
}

如你所见,这个方法中有很多事情在进行。这是使用外观服务有益的原因之一。

在大型应用程序中,你需要在多个组件中使用这个 ExpensesViewModel 的可能性很高。你不需要在多个组件类中定义这块逻辑,你可以在外观内部定义它,在组件层内部,你可以使用简单的函数调用,保持你的组件简单和干净。此外,当你需要调整逻辑时,你只需要在这个单一位置进行调整,而不是在多个组件类中。现在,为了更好地理解我们在函数内部做了什么,让我们逐行分解:

  1. 我们首先命名了方法为 getExpenses 并指定它将返回一个 ExpensesViewModel 可观察对象。

  2. getExpenses() 方法内部,我们使用 combineLatest() 方法返回了一个可观察对象。

  3. combineLatest() 内部,我们将存储库中的 expenses$inclVat$ 可观察对象组合起来,并应用了 RxJS 的 pipe() 函数到 combineLatest()

  4. pipe() 函数内部,我们应用了两个操作符,从 distinctUntilChanged() 操作符开始,这样我们只有在值发生变化时才发出新的值。

  5. 接下来,我们使用了 map() 操作符将两个可观察对象流映射到 ExpensesViewModel

  6. 根据 inclVat$ 可观察对象的状态,我们返回费用值属性和总属性,包括或排除增值税。

现在你已经在外观内部创建了 fetch-getExpenses 方法,让我们调整费用概览页面。

调整支出概览页面

在页面组件内部,首先注入外观服务:

protected readonly expensesFacade = inject(ExpensesFacade);

在注入外观后,你可以在页面组件的ngOnInit()方法中获取支出:

ngOnInit() { this.expensesFacade.fetchExpenses() }

接下来,你可以清理组件。在第七章中,我们使用模拟数据为expenses创建了一个信号;在本节中,我们将使用外观内部getExpenses方法接收到的支出。首先,像这样重新分配expenses属性:

expenses = this.expensesFacade.getExpenses();

在重新分配expenses属性后,由于你不再拥有expenses信号,你将在支出概览页面的组件和模板文件中遇到一些错误。继续移除totalInclVat计算信号;你还可以移除组件中的信号效果,并在onAddExpense方法内部清除逻辑。

接下来,我们需要对 HTML 模板做一些调整。

首先在 HTML 表格周围添加一个if-else块:

@if(expenses | async; as expensesVm) {……} @else {Loading… }

if块内部,你将使用带有async管道的expenses属性,以便从外观中检索expenses并使用这些值在模板中。

在添加if块后,你需要调整 HTML 模板内部的for块,并将expenses信号切换为你从外观中检索到的expenses属性:

@for (expense of expensesVm.expenses; track expense.id){…}

调整for块后,你需要调整表格行以正确反映新的模型结构并改进 UI。通过将值四舍五入到两位小数,然后添加currency管道和百分比(%)符号来完成此操作:

<td>{{ expense.amount.value.toFixed(2) | currency }}</td>
<td>{{ expense.amount.vatPercentage }}%</td>

最后,你需要将模板中使用的totalInclVat计算信号切换为expensesVm上的total属性:

<td>Total: {{expensesVm.total}}</td>

在这里,我们将文本调整为total,因为我们现在显示包括或排除增值税的总金额。在做出这些调整后,你应该再次在表格中看到总金额和支出,但现在使用 RxJS 和全局状态而不是带有模拟expenses的信号。

接下来,你想要一个可以切换增值税的选项,以便在增值税状态改变时自动更新显示的支出和总金额。

首先在组件服务内部添加一个新方法:

adjustVat() { this.expensesStore.adjustVat() }

如你所见,这只是一个调用存储中adjustVat方法的简单方法调用。这将改变存储中inclVat BehaviorSubject类。这反过来将触发我们在外观内部的getExpenses方法中使用的combineLatest()方法。

因此,当你更改增值税状态时,通过getExpenses方法检索到的ExpensesViewModel将自动更新并显示总金额和支出金额,包括或排除增值税,具体取决于状态。

一旦你添加了调整增值税的方法,你还需要在组件内部检索inclVat状态。你可以简单地创建一个属性并使用存储中的inclVat$可观察对象来分配它:

inclVatSelector$ = this.expensesStore.inclVat$;

在添加了调整和检索增值税状态的方法和属性后,让我们在费用概述页面的 HTML 模板中添加一个切换来调整增值税状态:

<div class="vatToggle">
  <span>Incl. VAT:</span>
  <label class=»switch»>
    <input (click)=»expensesFacade.adjustVat()" type="checkbox"
      [checked]=»expensesFacade.inclVatSelector$ | async">
    <span class=»slider round»></span>
  </label>
</div>

我在inclVatSelector$旁边添加了增值税切换,并结合了async管道来设置增值税切换的checked属性。

我们还向切换按钮的input值添加了一个click事件,以便在门面中调用adjustVat方法。如果你点击切换按钮,你将看到表格中的费用金额和表格摘要中的总金额根据增值税状态的变化而包含或排除增值税金额。

如您可能已经注意到的,这是一个非常响应式的方法,因为所有内容都会在状态变化时自动做出反应。代码也非常高效,因为更新是以非阻塞方式执行的,允许所有代码继续运行。

现在我们已经实现了getExpenses方法和增值税状态,让我们完成门面服务的开发。

完成门面服务的开发

对于存储公开的所有其他方法,你可以在门面服务中添加简单的方法来调用它们,类似于我们处理fetchExpensesadjustVat方法的方式。

对于存储中的selectedExpenseexpense属性,你需要在门面服务中添加一个选择器属性。因为我们还将映射由selectedExpenseexpense发出的费用,所以我们将映射行为抽象到一个新的函数中,以便我们可以重用它:

private mapExpense(expense: ExpenseModel, inclVat: boolean) {
  const expenseClone = structuredClone(expense) as ExpenseModel;
  expenseClone.amount.value = inclVat ? expenseClone.amount.value * (1 + expenseClone.amount.vatPercentage / 100) : expenseClone.amount.value;
  return expenseClone;
}

接下来,你可以像这样调整getExpenses方法内部的费用映射:

expenses: expenses.map(expense => this.mapExpense(expense, inclVat)),

最后,我们将为selectedExpenseexpense添加选择器属性,从expenseSelector$开始:

expenseSelector$ = this.expensesStore.expense$.pipe(withLatestFrom(this.expensesStore.inclVat$), map(([expense, inclVat]) => this.mapExpense(expense, inclVat)));

如您所见,对于expenseSelector$,我们使用了withLatestFrom()运算符而没有使用combineLatest()。我们这样做是因为expenseSelector$将只使用Subject类而不是BehaviorSubject作为事件发出值。这里没有状态,我们不希望选择器在增值税切换变化时发出新值。我们只想在expense Subject类发出值时做出反应,并且当这种情况发生时,使用当前inclVat$ Observable 的值来映射费用。

selectedExpense的选择器属性将使用combineLatest()函数将selectedExpense$ Observable 和inclVat$ Observable 结合起来,如下所示:

selectedExpenseSelector$ = combineLatest([this.expensesStore.selectedExpense$, this.expensesStore.inclVat$]).pipe(filter(([expense]) => !!expense), map(([expense, inclVat]) => this.mapExpense(expense as ExpenseModel, inclVat)));

对于selectedExpenseSelector$,我们使用了combineLatest()函数,因为选定的费用是状态性的,并且持续存在于我们的存储中。当我们可以更改增值税时,我们可以在视图中使用选定的费用,因此我们希望它在增值税状态变化时做出反应,并更新视图中的金额。因为我们希望selectedExpense对增值税状态也是响应式的,所以我们使用了combineLatest()运算符,它在组合的任何一个 Observables 发出新值时都会触发。

这就是使用 RxJS 实现状态管理解决方案的最后一部分。这种状态管理方法通常用于较小的 Angular 应用程序中,其中状态在许多不同的组件和服务中不被使用。该解决方案提供了良好的响应性,并且易于构建和理解。

现在,让我们学习如何将这个状态管理解决方案转换为使用信号(Signals)而不是 RxJS。使用信号将简化你的外观服务(facade service)和组件层。它还允许 Angular 进行更好的变更检测。如果你需要组合许多数据流并应用定制逻辑,RxJS 方法将更适合你的应用程序。

话虽如此,对于简单的状态和数据流,使用信号(Signals)要简单得多。即使你需要组合一些数据流而不需要过多控制这个过程,信号方法也将最适合你。如果你发现自己只使用combineLatest()withLatestFrom()以及一些基本操作符,如map()filter(),那么信号将是你的状态管理方式。

使用信号处理全局应用程序状态

为了将你的状态管理解决方案转换为使用信号而不是 RxJS,你必须将ExpensesStore中的BehaviorSubject类更改为信号。你仍然想要确保状态仅在存储库中设置时才发出新值;你不想能够在存储库外部设置状态。

为了实现这一点,我们将创建一个私有的WritableSignal和一个公共的只读Signal。你可以使用以下语法将所有BehaviorSubject类更改为信号:

private expensesState = signal<ExpenseModel[]>([]);
expenses = this.expensesState as Signal<ExpenseModel[]>;

在这里,我们使用signal()函数声明了一个私有信号(Signal)。以这种方式声明信号将创建WritableSignal。在下一行,我们创建了一个公共属性,并将其分配给WritableSignal,但使用as关键字将其转换为Signal类型;这里的Signal类型是只读的。在调整所有BehaviorSubject类之后,你需要更改在存储库内部对它们的引用。

首先移除currentExpenses获取器,并将所有this.current Expenses实例更改为以下内容:

this.expenses()

接下来,在adjustVat()函数内部,将!this.incluVat.value更改为以下内容:

!this.inclVat()

最后,你需要调整所有使用next()方法在某个BehaviorSubject类上的实例。

下面是一个如何转换resetState()函数的示例:

resetState(): void {
  this.expensesState.set([]);
  this.selectedExpenseState.set(null);
  this.inclVatState.set(false);
}

现在,将所有其他next()方法实例更改为Subject类和set()方法。这就是我们为ExpensesStore需要做的所有事情;你现在拥有使用信号而不是 RxJS BehaviorSubject类的状态管理。在调整状态后,我们需要调整ExpensesFacade,使其能够与信号而不是观察者(Observables)一起工作。

通常来说,外观服务的一个优点是它是一个抽象层,在改变状态管理解决方案时我们不需要触及组件层。但在这个情况下,我们需要调整外观服务和组件层;这是因为我们将要改变外观服务的接口。

理论上,我们可以保持接口不变,并在服务中将信号转换回可观察对象,这样就可以不触及组件层。然而,我们想要充分利用这些信号的力量,并在我们的组件中实现它们,以便 Angular 可以执行更好的变更检测,我们也可以使我们的模板同步。为了实现这一点,我们需要从我们的外观服务返回信号而不是可观察对象,改变外观服务的接口。

我们将通过改变接口来开始改变外观。将接口内部的 getExpenses 方法替换为 expenses 属性,并像这样调整 selectedExpenseSelector$inclVatSelector$ 属性:

selectedExpense: Signal<ExpenseModel | null>;
inclVat: Signal<boolean>;
expenses: Signal<ExpensesViewModel>

在接口中做出上述调整后,你就可以开始在外观服务内部实现接口。为了在接口中实现更改,删除 getExpenses 方法。而不是 getExpenses 方法,你必须创建一个计算信号,它返回与 getExpenses 方法相同的价值:

expenses = computed<ExpensesViewModel>(() => {
  const inclVat = this.expensesStore.inclVat();
  return {
    expenses: this.expensesStore.expenses().map(expense => this.mapExpense(expense, inclVat)),
    inclVat,
    total: this.expensesStore.expenses().reduce((acc, expense) => {
      return acc + (inclVat ? (expense.amount.value * (1 + expense.amount.vatPercentage / 100)) : expense.amount.value);
    }, 0),
  }
});

如你所见,计算信号与 getExpenses 方法非常相似。主要区别是我们不再需要 combineLatest()map() 操作符。现在我们可以在计算信号中使用 inclVatexpenses 信号。

当两个信号中的任何一个接收到新值时,计算信号将自动计算一个新的值。计算信号可以看作是信号领域的 combineLatest()withLatestFrom() 的等价物将是在计算信号中使用信号并使用 untracked() 函数包装信号,正如我们在 第七章 中讨论的那样。

在添加了计算信号之后,我们需要在外观服务内部实现 inclVatselectedExpense 信号。这很简单——你只需定义属性,并用从 ExpensesStore 获取的信号分配给它:

inclVat = this.expensesStore.inclVat;
selectedExpense = this.expensesStore.selectedExpense;

在这里,我们通过从存储中获取的信号来分配属性;我们不是通过添加函数括号 () 来调用信号。我们不添加这些函数括号是因为我们想在组件层中使用实际的信号,而不是 Signal 值。如果你在这里调用信号并检索组件层内的值,更新行为将不会按预期工作,并且当你的状态改变时视图不会更新。

最后要做的事情是调整 ExpensesOverviewPageComponent 及其模板。在组件类内部,你可以调整 expenses 属性,并用外观服务中的 expenses Subject 类代替 getExpenses() 函数分配给它:

expenses = this. expensesFacade.expenses;

现在,在 HTML 模板内部,你需要将 inclVatSelector$ 改为 inclVat(),移除 async 管道,并将带有 async 管道的 expenses 改为不带 async 管道的 expenses()

[checked]="expensesFacade.inclVat()"
@if(expenses(); as expensesVm) { …… }

在前面的更改中,你已经调整了组件类和 HTML 模板以使用 Signals 而不是 Observables。正如你所见,使用 Signal 方法稍微简单一些,并且需要更少的代码行。它还使你的 HTML 模板同步,并帮助 Angular 进行更好的变更检测,从而提高性能。

另一方面,你对数据流的控制较少,在数据流到达你的应用程序逻辑之前修改流并不容易。与 RxJS 相比,当你想要组合不同的数据流时,Signals 也提供了较少的控制,因此根据你的需求,你可以决定是否使用 Signals 或 RxJS。

你还可以创建一个混合解决方案,将 Observables 转换为 Signals,这样你就可以兼得两者之优。在这种情况下,你可以使用你需要的 RxJS 操作符,并且仍然可以在组件类和 HTML 模板中将值作为 Signals 消费。

通过这样,你已经学会了如何使用 RxJS 和 Signals 创建状态管理解决方案。你创建了一个外观服务作为额外的抽象层,并学习了在与外观服务一起工作时,何时需要更改组件层,何时只需要更改状态管理层。

我们创建的两个状态管理解决方案对于具有相对简单全局状态的小型应用程序都表现良好。RxJS 方法得到了广泛实现,随着 Signals 的普及,我想 Signal 方法也将得到广泛实现。但是,当你有一个较大的应用程序,其中状态在许多组件和服务中使用时,你将遇到我们当前实现的问题。在下一节中,你将了解这些问题以及如何解决它们。

使用 RxJS 或 Signals 进行全局状态管理的问题

虽然我们当前的状态管理解决方案被用于许多应用程序并且对我们的当前应用程序表现良好,但存在一个巨大的问题:我们当前的全局状态管理解决方案不是不可变的。

你不能从存储外部修改你的 BehaviorSubject 类或 Signals,因此从这个意义上说,它是不可变的。此外,当使用原始值作为状态时,状态本身也是不可变的。然而,当你使用引用对象作为 BehaviorSubject 类或 Signals 的值时,状态本身并不是不可变的。

当你使用数组或对象作为你的状态,并通过 BehaviorSubjectSignal 检索状态时,你可能会无意中修改状态值。当你在一个组件或服务类中调整检索到的状态对象时,BehaviorSubjectSignal 的值也会被修改!

这也是我们在 mapExpenses() 函数内部使用 structuredClone() 函数的原因。如果你移除 structuredClone() 并在视图中切换增值税几次,你会注意到金额持续增加,而不是添加和移除增值税。这是因为我们每次在门面服务内部调整对象时,都会在 SignalBehaviorSubject 内部修改对象。

下次我们检索状态时,它仍然具有调整后的值,而不是我们期望的真实状态。依赖开发者始终在修改对象时进行克隆是危险的,这不是你想要的方式。

允许你的状态在商店外部被修改,且没有在 BehaviorSubjectSignal 上调用 next()set() 方法,这为意外的状态变化打开了大门,导致状态损坏。当你的状态不是你所期望的那样时,你可能会向用户显示错误的数据,并在你的代码中执行非预期的操作。

对于那些状态在多处不常被使用的小型应用来说,这可能是一个可管理的问题,但当你的应用增长,状态在多个地方被使用,并且经常在本地修改检索到的状态时,问题会迅速显现。

要有一个真正不可变、响应式且可以处理任何应用状态(无论它变得多大)的状态管理系统,你的最佳选择是选择一个专注于状态管理的优秀库。Angular 社区中的一些流行选择如下:

  • NgRx

  • NgXs

  • RxAngular

  • Angular Query

所有这些库都有它们的优缺点。我个人的最爱是 RxAngular、NgXs 和 NgRx。NgRx 是社区中最常用的状态管理解决方案,它提供了基于 Observable 和 Signal 的状态管理支持。RxAngular 正在获得越来越多的关注,它以非常直观的方式管理状态,几乎不需要样板代码;它还允许你放弃 ZoneJS,提高你应用的性能。

在下一节中,我们将把我们的状态管理解决方案转换为 NgRx 状态管理解决方案。我选择 NgRx 是因为它是最常用的解决方案,但我建议你调查一些其他解决方案。

使用 NgRx 处理全局应用状态

当您在开发企业软件或具有广泛或复杂状态管理的应用程序时,您应该使用经过实战检验的状态管理解决方案,该解决方案提供真正的不可变性、单向数据流以及良好的工具来执行副作用并安全地修改状态。最佳做法是使用专注于状态管理的经过实战检验的库。

在 Angular 社区中,最常用的状态管理库是 NgRx;它拥有庞大的社区和您可能需要的所有工具来处理最复杂的状态。NgRx 实现了 Redux 模式,并包含四个主要构建块:actions、reducers、selectors 和 effects。

在本节中,我们将修改我们自定义的状态管理解决方案,使其使用 NgRx。我们将保留上一节中创建的存储文件作为参考,并在新文件中构建 NgRx 状态管理。

在生产环境中,您应该删除旧的未使用存储文件。在门面服务中,我们将简单地用 NgRx 实现替换当前实现,这次我们不会调整 IExpensesFacade 接口,这意味着我们不需要更改我们的组件层。让我们回顾一下实现 NgRx 状态管理的逐步过程。

安装 @ngrx/store 和 @ngrx/effects 包

要开始实现 NgRx 状态管理,您需要通过在您的 Nx monorepo 根目录中运行以下 npm 命令来安装一些包:

npm install @ngrx/store --save
npm i @ngrx/effects

在安装了 @ngrx/store@ngrx/effects 包之后,您需要创建一些文件夹和文件。有一个 Nx 生成器可以为您创建 NgRx 存储的初始设置,但我们将手动设置一切,以便您更好地理解一切是如何工作的,以及在使用 NgRx 时需要什么。

首先,在 expenses 数据访问库的 lib 文件夹内创建一个名为 state 的文件夹(位于 stores 文件夹旁边)。在新建的 state 文件夹内,创建另一个名为 expenses 的文件夹。现在,在新建的 expenses 文件夹内,创建以下五个文件:

  • expenses.actions.ts

  • expenses.reducers.ts

  • expenses.selectors.ts

  • expenses.effects.ts

  • index.ts

当您完成文件夹和文件的创建后,您可以在 expenses.actions.ts 文件内添加一些动作。

定义您的第一个 NgRx 动作

createAction() 函数,这是 @ngrx/store 包向您暴露的。

您必须向 createAction() 函数提供动作的描述,并且可选地提供一个 props() 函数来定义您必须提供给动作以执行动作的属性。

或者,您可以使用 createActionGroup() 函数来创建多个事件并将它们组合成一个单独的常量。我们不会使用 createActionGroup() 函数,但您始终可以在官方 NgRx 文档中阅读有关它的信息:ngrx.io/docs

我们将从一项简单的任务开始:定义一个从 API 获取费用的动作。你不需要提供任何参数来获取费用,因此该动作将只包含一个描述。NgRx 动作的描述通常使用以下命名约定:

[Unique State Name] Description of the action

expenses.actions.ts 文件内,定义获取费用的动作,如下所示:

export const fetchExpenses = createAction(`[Expenses] Fetch Expenses`);

通常,当定义包含 API 请求的 NgRx 动作时,你也会定义一个成功和失败的动作。所以,继续定义一个在获取费用成功或失败时的动作:

export const fetchExpensesSuccess = createAction(`[Expenses] Fetch Expenses Success`, props<{ expenses: ExpenseModel[] }>());
export const fetchExpensesFailed = createAction(`[Expenses] Fetch Expenses Failed`);

在这里,我们声明了两个动作;它们都接收了一个描述,而 fetchExpensesSuccess 动作还接收了 props() 函数。在箭头括号内,我们定义了 props() 函数的类型——在这种情况下,一个包含 expenses 属性的 ExpenseModel 数组的对象。fetchExpensesSuccess 动作需要 expenses 作为 props(),因为我们将使用 fetchExpensesSuccess 动作来更新状态,以包含从 API 请求中检索到的费用。

现在你已经添加了 fetchExpensesfetchExpensesSuccessfetch ExpensesFailed 动作,让我们更新 state/expenses 文件夹内的 index.ts 文件,通过定义我们的费用动作的导出:

export * as ExpenseActions from './expenses.actions';

在将导出添加到 index.ts 文件后,我们可以继续下一个难题。下一步是创建一个 NgRx 效果,该效果将向 API 发送请求以获取费用,并相应地分发成功或失败的动作。

创建你的第一个 NgRx 效果

你将创建你的 expenses.effects.ts 文件。效果允许你在动作分发时执行副作用。效果通常用于执行数据获取、分发其他事件或更新本地存储等任务。副作用将一些逻辑从组件中隔离出来,使组件类尽可能简单。

你将创建的第一个效果是 fetchExpeses$ 效果。每当 fetchExpenses 动作被分发时,此效果将会运行。然后,该效果将向 API 发送请求以获取费用,并将 API 调用的结果映射到一个新分发的动作——fetchExpensesSuccessfetchExpensesFailed 动作。

要开始,在 expenses.effects.ts 文件内创建一个名为 ExpensesEffects 的可注入类:

@Injectable({ providedIn: 'root' })
export class ExpensesEffects {}

在创建 ExpensesEffects 类之后,你需要在 ExpensesEffects 类中注入来自 @ngrx/effectsActions 类和 ExpensesHttpService

private readonly actions = inject(Actions);
private readonly expensesApi = inject(ExpensesHttpService);

接下来,使用 @ngrx/effects 提供的 createEffect() 函数创建你的第一个效果:

fetchExpeses$ = createEffect(() =>
  this.actions.pipe(
    ofType(ExpenseActions.fetchExpenses.type),
    switchMap(() => this.expensesApi.get().pipe(
      map((expenses: ExpenseModel[]) => ExpenseActions.fetchExpensesSuccess({ expenses })),
      catchError(() => of(ExpenseActions.fetchExpensesFailed()))
    ))
  )
);

在前面的代码片段中,你创建了第一个名为 fetchExpenses$ 的效果。正如你所见,那里有很多事情在进行,所以让我们逐行分析。

我们首先定义了一个名为 fetchExpenses$ 的属性,并将其分配给 createEffect() 函数。在 createEffect() 函数中,我们定义了一个返回 this.actions.pipe() 方法的 callback 函数。this.actions 实例指的是我们在前面的代码块中注入的 Actions 类。Actions 类发出我们派发的动作,并扩展了 Observable 类,这意味着你可以在类上使用 RxJS 的 pipe() 函数。

pipe() 函数的链式动作中,我们定义了一些操作符,从 ofType() 操作符开始。ofType() 操作符是一个过滤器操作符,它通过动作类型过滤动作。在 ofType() 操作符的函数括号内,你定义了动作的类型。在我们的例子中,我们向它提供了 fetchExpenses 动作的类型。在这里,ExpenseAction 用于导出和导入我们的动作,fetchExpenses 是我们赋予动作的属性名,而 type 是一个属性,它暴露在我们使用 createAction() 函数创建的所有动作上。

每当派发 fetchExpenses 动作时,我们将继续在效果函数的 pipe() 函数中的下一个操作符。下一个操作符是 switchMap() 操作符,它用于平铺由 HTTP 请求获取费用所创建的附加 Observable 流。

switchMap() 操作符的回调中,我们进行了 HTTP 请求,并将一个额外的 pipe() 函数添加到 HTTP 请求中。在 HTTP 请求的 pipe() 函数中,我们使用了 map() 操作符将成功的 HTTP 响应映射到 fetchExpensesSuccess 动作,并将从 API 响应中检索到的费用提供给 fetchExpensesSuccess 动作。如果 API 请求失败,我们使用 catchError 操作符将其映射到 fetchExpensesFailed 动作。

createEffect() 函数将自动派发返回的动作;这就是为什么我们不需要显式调用 dispatch() 函数,只需返回一个包含我们想要派发动作的 Observable 即可。在我们的例子中,这是 fetchExpensesSuccessfetchExpensesFailed 动作。

最后,你需要将 index.ts 文件中的效果导出,该文件位于 state/expenses 文件夹内:

export * from './expenses.effects';

现在我们已经定义了动作并创建了一个处理 fetchExpenses 动作并派发 fetchExpensesSuccessfetchExpensesFailed 动作的效果,让我们通过创建我们的状态和还原函数来覆盖我们 NgRx 状态的下一个构建块。

创建你的初始状态和第一个还原函数

现在你已经创建了一些动作和第一个效果,你需要一个状态来执行这些动作,并在 expenses.reducer.ts 文件中,你将定义你的初始状态对象和还原器,以便在派发动作时调整状态。

首先,在 expenses.interface.ts 文件中为你的状态对象创建一个新的接口:

export interface ExpensesState {
  expenses: ExpenseModel[];
  selectedExpense: ExpenseModel | null;
  isLoading: boolean;
  inclVat: boolean;
  error: string | null;
}

在创建接口之后,你可以在expenses.reducer.ts文件中创建你的初始状态对象:

export const initialExpensesState: Readonly<ExpensesState> = {
  expenses: [],
  selectedExpense: null,
  isLoading: false,
  inclVat: false,
  error: null
};

在定义了接口和初始状态对象之后,你可以使用createReducer()函数创建 reducer。createReducer()函数接受你的初始状态作为参数,并根据分发动作来减少你的状态。

首先,我们需要定义 reducer 函数并给它提供初始状态:

export const expensesReducer = createReducer<ExpensesState>(initialExpensesState);

在前面的代码片段中,我们创建了一个名为expensesReducer的属性,并将其分配给createReducer()函数。在箭头括号内,我们提供了 reducer 将修改的类型;在我们的例子中,这是ExpensesState接口。在函数括号内,我们提供了初始状态对象,initialExpensesState

接下来,你需要在createReducer()函数内部添加函数,以便在分发动作时更新状态,从fetchExpenses动作开始。为了更新状态,你必须定义一个on()函数,并给on()函数提供它需要响应的动作的引用,以及一个callback函数来修改状态:

createReducer<ExpensesState>(
  initialExpensesState,
  on(ExpenseActions.fetchExpenses, (state) => ({
    ...state,
    isLoading: true
  }))
)

在这里,我们在createReducer()函数内部初始状态对象下面添加了一个on()函数。我们给on()函数提供了ExpenseActions.fetchExpenses,以便在分发fetchExpenses动作时做出反应。

在动作引用之后,我们声明了一个callback函数来修改状态。在callback函数的函数括号内,你可以定义一个参数,它将填充当前状态对象供你使用;按照惯例,将此参数命名为state

最后,我们通过将当前状态扩展到对象中并设置我们想要更改的状态属性来返回一个新的状态对象。在fetchExpenses动作的情况下,我们只想将isLoading状态属性设置为true

接下来,我们可以在fetchExpenses动作的reducer函数下面添加fetchExpensesSuccessfetch ExpensesFailed动作的 reducer 函数:

on(ExpenseActions.fetchExpensesSuccess, (state, { expenses }) => ({
  ...state,
  isLoading: false,
  expenses,
  error: null
})),
on(ExpenseActions.fetchExpensesFailed, (state) => ({
  ...state,
  isLoading: false,
  error: ‹Failed to fetch expenses!›
})),

在这里,我们声明了两个额外的on()函数,并给它们提供了fetchExpensesSuccessfetchExpensesFailed动作。在fetchExpensesSuccess动作 reducer 的callback函数的函数括号内,我们使用了解构来从分发动作中提取expenses对象。你可能还记得,你定义了fetchExpensesSuccess动作,以便将 API 请求获取的支出作为参数。

接下来,在callback函数内部,我们更新了状态中的expenses属性,将isLoading设置为false,并将error设置为null。如果我们成功获取expenses属性,将不会向用户显示任何错误。

对于fetchExpensesFailed,我们在分发动作时没有提供参数,所以我们只提供状态对象到回调中,就像我们在fetchExpenses动作 reducer 中所做的那样。在fetchExpensesFailedreducer 的回调中,我们将isLoading设置为false并设置一个错误消息。

这样,您已经创建了初始状态,并为您定义的每个动作创建了一个reducer函数。当fetchExpenses动作被分发时,您使用reducer函数将isLoading状态设置为true。当您完成获取后,并且fetchExpensesSuccessfetchExpensesFailed动作被分发时,您使用reducer函数将isLoading状态设置为false,并相应地更新expenseserror状态。您可以使用isLoading状态来显示加载指示器,使用error状态来显示错误消息,以及使用expenses来显示您的费用列表。

现在,在expensesReducer下面,您需要为expenses状态定义一个唯一的键:

export const expensesFeatureKey = 'expenses';

作为最后一步,您需要在index.ts文件内添加 reducer 文件:

export * from './expenses.reducer';

在导出index.ts文件内部之后,您的 reducer 文件就准备好了。在继续到 NgRx 状态管理的最后一个构建块——选择器之前,我们将我们的 reducer 添加到expenses-registration应用的ApplicationConfig对象中。在app.config.ts文件中,在providers数组内添加以下内容:

provideStore(),
provideState({ name: expensesFeatureKey, reducer: expensesReducer }),

在前面的代码中,我们在providers数组内添加了provideStore()函数和provideState()函数。在provideState()函数内部,我们添加了一个包含名称和reducer属性的对象。名称接收我们在 reducer 文件内部提供的唯一键,而reducer属性接收expensesReducer函数。

现在您已经创建了 reducer 并在ApplicationConfig对象中添加了配置,现在是时候继续我们的 NgRx 状态的最后部分:选择器。

定义 NgRx 选择器

expenses状态:

export const selectExpensesState = createFeatureSelector<ExpensesState>(expensesFeatureKey);

在这里,我们使用了一个createFeatureSelector()函数,并向它提供了我们在expenses.reducer.ts文件内部声明的键。接下来,我们可以使用createSelector()函数定义额外的选择器,以检索expenses状态的具体部分:

export const selectExpenses = createSelector(selectExpensesState, (state) => state.expenses);
export const selectError = createSelector(selectExpensesState, (state) => state.error);
export const selectIsLoading = createSelector(selectExpensesState, (state) => state.isLoading);

在前面的代码片段中,我们声明了三个额外的选择器——一个用于检索expenses状态,一个用于检索error状态,还有一个用于检索isLoading状态。为了完成选择器,让我们在index.ts文件内部导出文件:

export * as ExpenseSelectors from './expenses.selectors';

在添加此export之后,还需要从您的state文件夹中导出index.ts文件;这个文件可以在data-access库的index.ts文件中找到:

export * from './lib/state/expenses/index';

现在我们已经将 NgRx 状态管理系统的所有部分都设置好了,是时候调整外观服务了。

调整外观服务以使用 NgRx 状态管理

我们将调整门面服务中的fetchExpenses方法和expenses信号。我们尚未为所有其他属性创建动作、效果、还原器和选择器。为了转换门面服务,我们需要首先注入Store类,该类由@ngrx/store包暴露给你:

protected readonly store = inject(Store);

在注入Store类后,我们可以调整门面服务中的fetchExpenses函数。只需在fetchExpenses函数内部移除this.expensesStore.fetchExpenses()并分发fetchExpenses动作:

this.store.dispatch(ExpenseActions.fetchExpenses());

这里,你使用了Store类,并在其上调用dispatch()函数来分发一个动作。调整fetchExpenses()方法后,是时候调整expenses计算信号了。

在这个计算信号内部,我们使用来自存储的expenses Subject类。我们需要将其更改为基于你的 NgRx 状态的expenses信号。

为了调整expenses计算信号,你需要创建一个新的属性来从 NgRx 状态中检索expenses状态并将其转换为信号。

我们可以通过在Store类上使用selectExpenses选择器并调用select()方法来从 NgRx 状态中检索expenses。使用Store类上的select()方法和我们的选择器将返回expenses状态作为 Observable,因此我们需要使用toSignal()函数将其转换为信号:

expensesSignal = toSignal(this.store.select(ExpenseSelectors.selectExpenses), { initialValue: [] });

现在我们已经从 NgRx 状态中获取了expenses状态,并在门面服务中作为一个信号,我们可以调整expenses计算信号,使其使用 NgRx 状态的expenses而不是存储中的expenses。只需将计算信号内部的this.expensesStore.expenses()实例替换为this.expensesSignal()即可。

通过这样,你已经更改了所有需要更改的内容,并且通过 NgRx 动作和状态来获取和检索expenses状态。在继续之前,让我们添加一个额外的 NgRx 状态,以便你可以理解 NgRx 状态管理中正在发生的一切。

添加额外的动作、效果、还原器和选择器

为了更好地掌握我们构建的 NgRx 状态管理,让我们通过添加额外的动作、效果、还原器和选择器来扩展它。

我们将首先添加一个动作来调整inclVat状态,就像我们之前做的那样,通过添加一个动作。因为inclVat状态只涉及状态变化而没有 HTTP 请求,所以你只需要一个动作来调整inclVat状态,不需要成功和失败的动作,因为你没有进行可能成功或失败的 HTTP 请求。调整inclVat状态的动作也不需要参数,因为我们只是将状态更改为它当前不是的状态。

你可以简单地创建一个动作并为其提供一个描述:

export const adjustVat = createAction(`[Expenses] Adjust incl vat`);

对于inclVat状态更改不需要效果,因为你没有执行 HTTP 请求或需要分发额外的动作。然而,你确实需要在expensesReducer内部添加一个新的还原器函数来调整状态对象。

expensesReducercreateReducer()函数内部,添加一个额外的on()函数来改变inclVat状态,当adjustVat动作被分发时:

on(ExpenseActions.adjustVat, (state) => ({
  ...state,
  inclVat: !state.inclVat
})),

如你所见,在分发adjustVat动作后,我们将inclVat状态更改为它目前不是的状态。在添加reducer函数之后,你需要添加一个选择器来从状态对象中检索inclVat属性:

export const selectInclVat = createSelector(selectExpensesState, (state) => state.inclVat);

现在,唯一剩下要做的事情是调整外观服务,并使用 NgRx 状态中的inclVat属性而不是expenses.store.ts中的信号。

要调整外观服务,首先添加一个inclVat属性,并使用toSignal()函数将selectInclVat选择器转换为信号:

inclVat = toSignal(this.store.select(ExpenseSelectors.selectInclVat), { initialValue: false });

在添加了inclVat属性之后,你只需在expenses计算信号内部将this.expensesStore.inclVat()更改为this.inclVat()即可。

最后,你需要调整外观服务中的adjustVat()函数。移除函数中的当前内容,并用分发adjustVat动作来替换它:

this.store.dispatch(ExpenseActions.adjustVat());

在添加了前面的代码之后,你已经做出了所有必要的更改,现在你正在使用 NgRx 状态中的inclVat属性而不是expenses.store.ts中的信号。现在,你只需要添加剩余的动作、效果、还原器和选择器,这样你就可以完全从外观服务中移除存储,并使用 NgRx 状态来做所有事情。

作为练习,你可以尝试根据我们为费用列表所做的工作,自己添加额外的动作、效果、还原器和选择器。在添加了额外的动作、效果、还原器和选择器之后,你应该能够完全调整费用外观,并完全移除存储实现。如果你遇到了困难或者只是想复制代码,你可以从本书的 GitHub 仓库中获取:github.com/PacktPublishing/Effective-Angular

在本节中,你探索了 NgRx,并学习了如何使用它来管理你应用程序的状态。我们讨论了默认的 NgRx 实现来管理状态。请注意,该库还有更多解决方案和包可以提供,但这超出了本书的范围。

NgRx 提供的一些其他功能包括signalStoresignalState,这两个解决方案你可以使用它们来管理你的状态,使用 NgRx 和信号而不必使用toSignal()转换 Observables,这是我们在这个部分所做的工作。NgRx 库中有有用的 RxJS 操作符。我们只使用了ofType()操作符,但 NgRx 还提供了更多实用操作符,例如concatLatestFrom()tapResponse()

NgRx 还提供了管理组件状态和在路由变更时分发访问状态动作的解决方案。我强烈建议你自己探索 NgRx 和其他状态管理库。

摘要

在本章中,你学到了很多,并将我们在 第七章 中学到的所有内容结合起来。你学习了状态管理是什么以及为什么你需要一个好的状态管理解决方案。你还了解了不可变性、单向数据流和副作用。在理论学习之后,你开始使用 RxJS 的 BehaviorSubjectSubject 类构建状态管理解决方案。

当你使用 RxJS 构建完状态管理解决方案后,你创建了一个门面服务,该服务将你的组件层连接到应用程序的数据访问和状态管理层。为了结束你的自定义状态管理解决方案,你将 RxJS 的状态实现转换为 Signals 实现,进一步简化了你的组件层和门面服务。

最后,你了解了使用 RxJS 和 Signals 作为你的状态管理解决方案的不足,并用 NgRx 实现替换了它们,该实现使用动作、效果、还原器和选择器。

在下一章中,你将学习如何提高你的 Angular 应用程序的性能和安全性。

第三部分:使用自动化测试、性能、安全性和可访问性为生产做准备

在最后一部分,你将学习如何提高你的 Angular 应用程序的性能,并使它们对每个人来说更加安全和易于访问。从性能开始,你将深入了解 Angular 的变更检测机制,学习 Angular 如何检测变更以及你可以采取哪些行动来减少变更检测周期数。当你详细了解变更检测的工作原理后,你将学习如何防止其他因素影响你的 Angular 应用程序的性能。然后,你将探讨在开发 Angular 应用程序时的一些常见安全风险以及如何减轻它们。此外,你将深入研究可访问性,使用 Transloco 使你的应用程序内容可翻译,并学习如何开发适用于来自不同地区和能力的用户的可访问应用程序。此外,你将学习如何使用 Jest 编写和运行单元测试,以及使用 Cypress 进行端到端测试,这让你在部署更改时更有信心,而不会破坏任何东西。最后,你将进行一些最后的改进,学习如何分析和优化你的包大小,并自动化你的部署流程。

本部分包括以下章节:

  • 第九章增强 Angular 应用程序的性能和安全性

  • 第十章Angular 应用程序的国际化和本地化以及可访问性

  • 第十一章测试 Angular 应用程序

  • 第十二章, 部署 Angular 应用程序

第九章:提升 Angular 应用程序的性能和安全

在本章中,你将学习如何提高你的 Angular 应用程序的性能和安全。你将深入研究 Angular 的变更检测机制,以便你知道如何减少 Angular 在浏览器中需要检查变更和重新渲染的组件数量。接下来,你将了解你可以采取哪些措施来优化 Angular 应用程序的页面加载时间和运行时性能。一旦你知道如何提升 Angular 应用程序的性能,你将学习有关安全性的知识。你将了解在构建 Angular 应用程序时可能遇到的风险,以及如何减轻这些风险,以便为你的最终用户提供安全的应用程序。

本章将涵盖以下主题:

  • 理解 Angular 变更检测

  • 提升 Angular 应用程序的性能

  • 构建安全的 Angular 应用程序

理解 Angular 变更检测

对于小型应用程序,性能通常不是瓶颈。然而,当应用程序增长并且你开始添加和组合更多组件时,你的应用程序可能会变慢,损害用户体验并降低用户留存率。你的应用程序变慢的一个原因是,如果你在开发时没有采取措施帮助 Angular 执行更好的变更检测,Angular 将检查越来越多的组件以查找变更。因此,为了构建性能良好的 Angular 应用程序,你需要了解变更检测机制是如何工作的,这样你就可以减少框架需要检查变更和重新渲染的组件数量。

为了更好地理解问题,你必须首先了解 Angular 如何执行变更检测以及问题从何开始。

假设你有一个简单的组件,具有标题属性和changeTitle()函数,如下所示:

title = 'Some title';
changeTitle(newTitle) { this.title = newTitle }

如果你调用changeTitle()函数,Angular 可以在更改标题后保持一切同步。在调用栈中,Angular 将首先调用changeTitle()函数,随后所有后续函数都将由于调用changeTitle()函数而被调用。然后,在幕后,Angular 将调用一个tick()函数来运行变更检测。变更检测将运行整个组件树,因为你可能在一个或多个组件内部更改了服务中使用的值。这种场景将按预期工作;尽管 Angular 必须检查整个组件树,但它将保持应用程序状态和视图的同步。

现在,假设你在更新标题属性之前运行一些异步代码;问题将始于这个场景。Angular 会检测到 changeTitle() 被调用并运行变更检测。由于调用栈的工作方式,Angular 不会在调用后台运行变更检测的函数之前等待异步操作完成。因此,Angular 将在更新标题属性之前运行变更检测,导致应用程序损坏,因为仍然显示旧值。

重要提示

实际上,异步更改不会破坏代码和视图之间的同步,因为 Angular 使用 Zone.js 来解决这个问题!

现在你已经知道异步更改可能导致未检测到的更改。接下来,让我们了解 Zone.js 以及 Angular 如何使用它来处理这个问题,以便它可以成功执行同步和异步更改的变更检测。

Zone.js 和 Angular

Zone.JS 库通过猴子补丁(即动态更新运行时行为)浏览器 API,并允许你挂钩到浏览器事件的生存周期。这意味着你可以在浏览器事件发生前后运行代码。使用 Zone.js,你可以创建一个 Zone,在 Zone 内部代码执行之前和所有 Zone 内部代码完成之后(包括异步事件)运行代码。

为了演示这一点,这里有一个这样的 Zone 的简单示例:

const zone = Zone.current.fork({
  onInvokeTask: (delegate, current, target, task, applyThis, applyArgs) => {
    console.log(‹Before zone.run code is executed');
    delegate.invokeTask(target, task, applyThis, applyArgs);
    console.log('After zone.run code is executed');
  }
});
zone.run(() => {
  setTimeout(() => {
    console.log(‹Hello from inside the zone!›);
  }, 1000);
});

在前面的代码中,创建了一个 Zone,并在 Zone 内部执行异步代码——在我们的例子中是一个 setTimeout 函数。前面的代码将首先记录在 delegate.InvokeTask() 方法之前声明的消息。接下来,它将运行在 zone.run() 回调函数内部声明的代码;这可以是同步和异步代码。最后,当回调函数内部的代码完成时,将记录在 delegate.InvokeTask() 方法之后声明的消息。

在幕后,Angular 使用与我们的 Zone 示例类似的方法创建了一个围绕整个应用程序的 Zone,称为 onMicrotaskEmpty,当队列中没有更多微任务时,它会发出一个值。Angular 使用这个 onMicrotaskEmpty 可观察对象来确定 NgZone 内部的所有同步和异步代码何时完成,Angular 可以安全地运行变更检测而不会错过已更改的值。

图 9.1 中,你可以看到 Angular 创建的 NgZone 如何围绕整个组件树,允许 Angular 安全地监控异步更改:

图 9.1:NgZone 内部的组件树

图 9.1:NgZone 内部的组件树

在运行变更检测时,Angular 将检查组件树中的所有组件,如果任何绑定发生变化(绑定是绑定到 HTML 模板的值),则更新和重新渲染组件。

现在您知道了 Angular 如何在所有同步和异步任务完成后使用 Zone.js 触发变化检测,以及 Angular 在变化检测运行时检查整个组件树。让我们学习为什么 Angular 检查整个组件树,以及您如何在变化检测运行时减少 Angular 必须检查和重新渲染的组件数量。

提高变化检测效率

Angular 将组件标记为 OnPush 变化检测策略,如下面的代码所示:

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush
})

当使用 OnPush 变化检测策略时,Angular 只会对标记为脏的组件执行变化检测,这显著减少了必须检查和重新渲染的组件数量。有几个因素会将组件标记为脏:

  • 组件内部处理的浏览器事件(悬停、点击、键入等)

  • 改变的组件输入值

  • 组件输出发射

当组件被标记为脏时,Angular 也会将组件的所有祖先标记为脏。在 图 9**.2 中,您可以可视化地看到这一点,以更好地理解概念:

图 9.2:脏组件树

图 9.2:脏组件树

现在,使用 OnPush 变化检测策略且未标记为脏的组件在 Angular 运行变化检测时不会被检查更改,这减少了框架必须检查的组件数量。Angular 也会跳过使用 OnPush 且未标记为脏的组件的所有子组件。

图 9**.3 展示了使用 OnPush 策略的变化检测机制:

图 9.3:使用 OnPush 策略的变化检测

图 9.3:使用 OnPush 策略的变化检测

如您在 图 9**.3 中所见。Angular 不会检查是否需要使用 OnPush 变化检测策略刷新非脏组件的所有子组件的绑定。这也说明了为什么在使用 OnPush 时,所有祖先组件都必须标记为脏。Angular 从顶部向下检查是否需要刷新绑定,从根组件开始。因此,如果您在组件树底部的组件上点击,Angular 会从根组件开始,逐层向下遍历组件树。如果点击的组件的父组件使用 OnPush 变化检测策略,并且该组件未标记为脏,Angular 将跳过其子组件。结果,Angular 不会检查您点击的组件,导致代码和视图不匹配,因为与点击相关的更改将不会被处理。由于上述原因,当组件使用 OnPush 变化检测时,Angular 必须将所有父组件标记为脏。

对于OnPush变更检测,另一个有趣的案例是 Observables。Observables 是 Angular 框架中处理异步事件和数据流的主要工具,但接收新值的 Observables 不会将组件标记为脏。因此,当使用OnPush变更检测策略时,如果 Observables 接收新值,组件将不会更新。为了解决这个问题,你可以使用async管道,因为async管道会自动标记组件为检查,并像常规事件一样处理更新,标记组件为脏并在之后运行变更检测。或者,你可以使用ChangeDetectorRef并手动调用markForCheck()detectChanges()方法,如下所示:

cd = inject(ChangeDetectorRef)
this.cd.markForCheck();
this.cd.detectChanges();

markForCheck()方法将在下一个变更检测周期中将组件标记为检查,而detectChanges()方法将立即将组件标记为脏并触发该特定组件的变更检测。

然而,在使用detectChanges()方法时你必须小心,因为它也可能导致性能问题。detectChanges()方法将在单个浏览器任务中运行整个变更检测,直到该任务完成才会释放主线程。例如,当你需要在屏幕上显示一个大型数组并且必须频繁检测该数组的变更时,这会给浏览器带来大量工作,从而减慢你的 Angular 应用程序。

现在你已经更好地理解了OnPush变更检测的工作原理以及如何标记组件为脏或手动运行变更检测,让我们学习一下 Angular 变更检测机制是如何处理信号的。

Angular 变更检测和信号

在 Angular 17 中,信号作为开发者预览版发布,随之变更检测机制也得到了升级。当在模板中使用信号时,Angular 会注册一个效果,该效果监听模板中使用的信号。当信号值发生变化时,效果会运行并将组件标记为 Angular 变更检测需要检查的组件。

当组件被标记为检查,因为信号值发生变化时,变更检测周期的工作方式将有所不同。首先,信号值发生变化的组件将收到一个RefreshView标志。接下来,它将遍历组件树并标记所有祖先组件为HAS_CHILD_VIEWS_TO_REFRESH。它不会将祖先组件标记为脏。现在,当变更检测运行时,Angular 将执行所谓的全局+局部glo-cal)变更检测。

当运行全局变更检测时,组件树将自顶向下进行检查,就像通常一样。但是,当 Angular 遇到带有 HAS_CHILD_VIEWS_TO_REFRESH 标志的非脏 OnPush 组件时,它将跳过 OnPush 组件,但会继续向下遍历组件树以查找带有 RefreshView 标志的组件。因此,只有带有 RefreshView 标志的组件将被更新和重新渲染;所有使用 OnPush 变更检测策略的父组件将不会被检查或重新渲染,这进一步提高了 Angular 变更检测机制的效率。

你现在知道了 Angular 变更检测的工作原理以及如何使用 OnPush 变更检测策略来使变更检测过程更高效。然后,你学习了在使用 OnPush 变更检测为组件处理可观察对象的方法。你还知道如何使用 markForCheck()detectChanges() 函数手动标记组件以进行检查或运行变更检测。最后,你看到了如何通过结合使用信号和 OnPush 变更检测策略以及触发全局变更检测来进一步提高变更检测的效率。所有这些更改都将显著提高应用程序的性能,尤其是在应用程序增长并且你有大型且复杂的组件树时。

在下一节中,我们将探讨其他方法来提高您的 Angular 应用程序的性能。

提高 Angular 应用程序的性能

使用尽可能多的组件在 OnPush 变更检测策略上理解 Angular 的变更检测工作,并使用信号来进一步改进变更检测,这是构建高性能应用程序的良好第一步。然而,当开发高性能应用程序时,框架还有更多可以提供的内容。

在本节中,我们将探讨可用于提高 Angular 应用程序性能的内置工具和技巧,以确保快速页面加载和良好的运行时性能。我们将首先探索用于提高性能的第一个内置工具 runOutsideAngular() 方法。

理解和使用 runOutsideAngular() 方法

在 Angular 应用程序中,优化性能有时需要执行特定任务在 Angular 区域之外。在前一节中,你学习了关于 Zone.js 的内容,Angular 如何使用它来创建 NgZone,以及它与变更检测和应用程序的更新行为之间的关系。runOutsideAngular() 方法提供了一种在 Angular 的变更检测机制之外运行特定代码的方式,这可以提高应用程序的响应性和效率。

通过使用runOutsideAngular()在 Angular 的 Zone 外执行任务,您可以防止不必要的变更检测周期被触发。这可以导致更平滑的用户交互,并减少与 Angular 的变更检测机制相关的开销。在 Angular Zone 外执行的任务不会被 Angular 的变更检测周期自动检测,从而提高应用程序的整体性能。

runOutsideAngular()方法由 Angular 的 NgZone 服务提供。runOutsideAngular()方法可以在 NgZone 外运行重计算函数。一些重计算函数的例子包括复杂的数学计算、排序大型数组以及处理大型数据集。您可能希望在 NgZone 外运行的其他场景如下:

  • 运行第三方库中的代码:在 Angular Zone 外运行与初始化、配置或与第三方库交互相关的代码,可以防止 Angular 执行不必要的变更检测,从而提高性能并避免潜在的副作用。

  • 处理 WebSocket 通信或长轮询请求:这涉及到频繁更新应用程序状态,而不触发用户发起的操作。

  • 涉及低级 DOM 操作或 canvas 绘图操作的动画或渲染优化:在 Angular Zone 外运行相关代码可以通过绕过 Angular 的变更检测并允许对渲染更新有更直接的控制来提高性能。

通过战略性地使用runOutsideAngular(),您可以提高 Angular 应用程序的性能和响应速度,尤其是在处理计算密集型任务或与外部库交互时。然而,平衡性能优化与保持应用程序的完整性和功能至关重要。当在runOutsideAngular()内运行任务时,变更检测将不会检测到这些任务,因此您可能会向用户显示错误的数据。对此的一个良好对策是在runOutsideAngular()方法内运行重计算,然后通过使用run()方法再次在 NgZone 内将值分配给组件属性,如下面的代码所示:

@Component({……})
export class ExampleComponent {
  protected readonly ngZone = inject(NgZone);
  performTask(): void {
    this.ngZone.runOutsideAngular(() => {
      console.log(‹Task performed outside Angular Zone›);
      // Run inside the runOutsideAngular method again
      this.ngZone.run(() => {
        console.log(‹Running inside NgZone again›);
      });
    });
  }
}

在前面的代码中,您可以看到如何使用runOutsideAngular()run()方法。您注入 NgZone 并调用 Angular 提供的服务上的方法。在每个方法的回调中,您可以在 NgZone 内或外执行任何逻辑。

现在您已经了解了如何使用runOutsideAngular()在 NgZone 外运行代码以提高应用程序的性能,让我们继续了解 Angular 提供的下一个工具,以开发更高效的应用程序:NgOptimizedImage指令。

理解和使用 NgOptimizedImage 指令

在构建高性能应用程序时,优化图像是另一个关键方面。您的图像加载时间对网站的最大内容渲染时间(Largest Contentful PaintLCP)有很大影响,这是三个核心 Web Vital 指标之一[其他两个核心 Web Vital 指标是首次输入延迟FID)和累积布局偏移CLS)]。LCP 表示网页主要内容的加载速度,具体测量从用户触发页面加载到浏览器窗口可见区域内显示最大图像或文本块之间的持续时间。由于图像的加载时间通常比文本内容长,因此图像的加载和显示方式在应用程序的 LCP 中起着至关重要的作用。

在 Angular 框架中,您可以通过使用 NgOptimizedImage 指令来改进图像的加载方式。NgOptimizedImage 指令专注于优先加载 LCP 图像。

默认情况下,此指令为非优先图像启用懒加载,节省带宽并提高初始页面加载时间。此外,NgOptimizedImage 在文档头部生成一个 preconnect 链接标签,优化资源获取策略。NgOptimizedImage 自动在 img 标签上设置 fetchpriority 属性,强调 LCP 图像的加载优先级。此外,该指令简化了生成 srcset 属性的过程。通过使用 srcset 属性,浏览器请求适合用户视口的图像大小,因此不会浪费时间和资源下载过大的图像。

除了优先加载 LCP 图像外,NgOptimizedImage 还确保应用一系列图像最佳实践:

  • 图像 CDN 利用:该指令鼓励使用图像内容分发网络(CDN)的 URL,以促进图像优化并在全球网络中高效传输。

  • 如果 NgOptimizedImage 设置错误或未设置尺寸,将导致警告。通过设置宽度和高度属性,您可以减轻布局偏移,提高您的 CLS,并确保正确的渲染。

  • NgOptimizedImage 会提醒开发者注意渲染图像中可能出现的视觉扭曲。

既然您已经知道了为什么需要 NgOptimizedImage 指令,让我们看看如何使用它。NgOptimizedImage 指令是独立的,因此您首先需要将 NgOptimizedImage 指令直接导入必要的 NgModule 或独立组件。接下来,您可以通过将 img 标签上的 src 属性替换为 ngSrc 来使用 NgOptimizedImage

<img ngSrc="dog.jpg">

如前所述,您还需要设置宽度和高度属性:

<img NgOptimizedImage directive, but there are some additional options you can add. Let’s start by exploring the priority attribute. When you mark an image with priority, the following optimizations are applied for you:

*   `fetchpriority=high`
*   `loading=eager`

When you use server-side rendering, it automatically generates a preload link element.
You can mark an image with priority as follows:


 All LCP images should be marked as priority. If you don’t mark an LCP image as priority during development, Angular will log an error.
Besides the `priority` attribute, another useful attribute used with the `NgOptimizedImage` directive is the `fill` attribute, like so:

当你想要图像填充包含元素时,使用ngSrc="dog.jpg"fill属性。当你想将图像用作背景图像,或者当你不知道图像的确切大小,但想将其适应一个你知道其相对于屏幕大小的大小时,可以使用填充属性。当使用填充属性时,你不需要设置宽度和高度属性,因为 Angular 会在大小解决后为你设置它们。

要控制图像如何填充容器,你可以使用object-fit CSS 属性。

更多信息

除了priorityfill属性之外,当使用第三方服务处理你的图片时,NgOptimizedImage指令还有更多酷炫的功能,例如低分辨率占位符和自定义图片加载器。这些功能超出了本书的范围,但如果你有兴趣,可以在官方 Angular 文档中阅读有关它们的内容:angular.io/guide/image-directive

现在你已经了解了NgOptimizedImage以及如何使用它来优化你的图像并提高应用程序的 LCP,让我们深入了解下一个性能优化步骤:在 HTML 模板中使用trackBytrack函数进行循环。

理解和使用 trackBy 和 track 函数

在 Angular 应用程序中,渲染大量列表或数据集合有时会导致性能问题,因为频繁的 DOM 操作。为了优化你的 Angular 应用程序的性能,了解并利用像trackBytrack函数这样的工具至关重要。

trackBy函数是 Angular 提供的一个功能,它通过在*ngFor指令渲染列表时提高性能。track函数是 Angular 控制流语法的对应物。trackBy函数是可选的,而track函数在使用控制流语法时是必需的。

默认情况下,Angular 使用对象标识符来跟踪*ngFor指令提供的数据的变化。然而,这种方法可能导致 DOM 元素的不必要重渲染,尤其是在处理动态数据时。tracktrackBy函数允许 Angular 通过为每个项目提供一个唯一标识符来高效地跟踪集合中的变化。这导致 DOM 操作更少,显著提高了渲染性能,尤其是在处理大量数据集时。

当你使用*ngFor指令时,你需要将trackBy属性分配给一个函数,并在你的组件类内部声明相应的函数。该函数应该返回你想要用来跟踪你正在渲染的列表中项目的唯一标识符:

<div *ngFor="let item of items; *ngFor directive and defining the trackBy property. The trackBy property is assigned with a function named trackById. This trackById function has to be declared inside the component class, like this:

trackById(index: number, item: Item) { return item.id }


 In the preceding example, you use the `id` property from the objects you are rendering with the `*ngFor` directive as the unique identifier (this assumes the objects have an `id` property, otherwise you return another unique property). It’s important to note that the `trackBy` function should only be used when the items in the collection have a unique identifier. Using a non-unique identifier or omitting the `trackBy` function altogether can lead to unexpected behavior and performance issues.
When using the control flow syntax to output a list inside your HTML template, the syntax is a bit simplified. Instead of a `trackBy` function, you now use the `track` function and directly provide it with the unique property to check instead of creating a function that returns the unique property, like so:

@for (item of items; track item.id) { … }


 Now that you know why you need to use `trackBy` and `track` functions when rendering lists in your HTML templates, let’s explore web workers, the next performance optimization that Angular has at its disposal.
Understanding and using web workers in Angular
**Web workers** allow you to execute CPU-intensive tasks within a separate thread running in the background, thereby making the primary thread free to update the user interface and run the main threat without any hiccups. Whether it involves intricate tasks such as producing **computer-aided design** (**CAD**) drawings or conducting complex geometric computations, applications can leverage web workers to enhance overall performance significantly.
You add a web worker to your application with an Nx generator. Open the **NX console**, click on **generate**, search for web worker, and select the **@nx/angular – web worker** generator. Next, you need to give your web worker a name and select a project to add the web worker to. If you work without Nx, you can run the following CLI command:

ng generate web-worker


 Running the Nx generator or Angular CLI command will configure your project to use web workers if it isn’t configured already. It will also generate a file with your web workers. If you named your web worker `heavy-duty`, the generated file will be named `heavy-duty.worker.ts`; when using the Angular CLI, the name of the file will equal the location you provided in the CLI command.
Inside the generated worker file, you will find the initial scaffolded code you need for your web worker. When using Nx, you’ll find the following code in the generated file:

addEventListener('message', ({ data }) => {

const response = worker response to ${data}

postMessage(response);

});

if (typeof Worker !== 'undefined') {

const worker = new Worker(new URL('./heavy-duty.worker', import.meta.url));

worker.onmessage = ({ data }) => {

console.log(页面收到消息 ${data});

};

worker.postMessage('hello');

} else { // 环境回退 }


 The `addEventListener` function will stay in the worker file, and the rest of the code must be located in the component or service where you want to use the web worker. By moving everything but the `addEventListner` function, you can send messages from the component or service to the web worker. As you can see, in the code that must be moved, there is a fallback for environments where the web worker doesn’t work. This is because when using server-side rendering, web workers do not work and you need to have a fallback.
To work with the web worker, you need to send messages to and from the web worker to perform the logic you need to perform. For example, let’s say you want to use the web worker when a component is initialized. To achieve this, you add the following code inside the component where you want to use the web worker:

@Component({……})

export class FooComponent {

heavyDutyResult;

heavyDutyInput = {……};

constructor() { this.runWebWorker() }

runWebWorker () {

if (typeof Worker !== 'undefined') {

const worker = new Worker(new URL('./heavy-duty.worker', import.meta.url));

worker.onmessage = ({ data }) => {

this.heavyDutyResult = data;

};

worker.postMessage(this.heavyDutyInput);

} else { // 回退 }

}

}


 As you can see in the preceding code, you use `worker.postMessage` to send a message to the web worker. This is received inside the event listener of the web worker. When the `postMessage()` function is called in the web worker, it will be received in the `worker.onmessage()` callback function inside the component. Now, you only need to update the web worker file to perform the heavy-duty logic:

addEventListener('message', ({ data }) => {

const response = heavyDutyFunction(data);

postMessage(response);

});


 As you can see in the preceding code, we perform some logic – in this example, an imaginary `heavyDutyFunction()` – and send the response back to the component using the `postMessage()` function. Now the circle is complete. You can send some data from the component to the web worker and the web worker will receive this data, perform the heavy-duty logic with the data, and returns the `response` constant to the component class.
Now you know how to use a web worker to create multithreading and run resource-intensive code without blocking your main threat. To wrap up the section, I will mention some other methods you can use to improve the performance of your Angular applications:

*   **Lazy loading**: Lazy loading routes help to only load sections of your app that the user actually reaches. We already showcased this in *Chapter 2*, but it’s worth mentioning as a performance optimalization.
*   `preloadingStrategy` on your routes, you can also pre-load routes you anticipate the user will navigate.
*   `Record` classes, for example. For API requests, I can recommend using `ts-cachable`.
*   **Using pure pipes**: We already explained the usage of pipes and what pure pipes are in *Chapter 3*, but they are worth mentioning as a performance optimalization.
*   `canMatch` route guard combined with lazy-loaded routes prevents you from loading modules and components the user is not allowed to access.
*   **Using RxJS effectively**: Running code asynchronously doesn’t block your threat and can help to improve the performance of your application.
*   `ng add @angular/ssr` command, you can enable server-side rendering, greatly improving the performance of your application. We will not cover server-side rendering in further detail, but as of Angular 17, you can also include page hydration when using server-side rendering, further enhancing the performance.
*   **Virtual scrolling**: Virtual scrolling is a feature in the Angular Material CDK that enables you to effectively render large lists. The virtual scroll will ensure that only items within the viewport are rendered.

You now know how to improve the performance of your Angular applications using `OnPush` and Signals, run code outside the NgZone or create multithreading using web workers, optimize images using the `NgOptimizedImage` directive, and render lists in a performant way by utilizing the `trackBy` and `track` functions. You also learned about other tools and tips to further enhance the performance of your Angular applications. Next, we will learn how you can improve the security of your Angular applications.
Building secure Angular applications
In a world where hacks and exploits are more frequent than ever, you are also responsible for developing secure applications. In this section, we’ll delve into the various security risks that Angular applications may face and explore strategies to mitigate them effectively.
When it comes to securing frontend applications, you want to ensure that the users can’t reach parts of your application they are not intended to go to and that they can’t perform malicious actions that will compromise your application. We will first look at the first scenario and ensure that users can’t reach sections of your applications they are not intended to reach.
Setting up route guards
**Route guards** are used to guard specific routes within your Angular application. They prevent unauthorized users from accessing certain parts of your application. For example, most parts of your application should only be accessible to users who are logged in; other routes might be restricted based on user roles or other factors. Within Angular, there are four different types of route guards:

*   **canActivate**: Determines whether the user can activate a specific route.
*   **canActivateChild**: Determines whether the user can activate the child routes of a specific route.
*   **canDeactivate**: Determines whether a user can deactivate a specific route.
*   `canActivate` is that if the `canMatch` guard fails, the module or standalone component related to the route is not loaded at all. Using `canMatch` offers some performance benefits when combined with lazy-loaded routes.

Since Angular 15, route guards have been implemented using a functional approach; in earlier versions, a class-based approach was used. The class-based approach is currently deprecated, so we will only cover the functional approach. You can declare each guard type you want to use in your route configuration, like this:

{

path: '…',

loadComponent: () => import('……'),

canMatch: [],

canActivate: [],

}


 As you can see, you define the guards in the route configuration object. Each guard type is assigned an array containing the guard function that it should resolve before the user can access the route. Each guard function returns a Boolean: `true` if the guard passes and the user can access the route, or `false` if the guard fails and the user can’t access the route.
In its simplest form, you can define the guard function directly inside the array assigned to the guard type property:

canMatch: [() => inject(UserService).loggedIn],


 In the preceding example, we `inject` a service and check whether the user is logged in (we did not create the service in this book; this is just an example). If the `loggedIn` property is `true`, the user can access the route. If the `loggedIn` property is `false`, the user can’t access the route.
In some scenarios, you might need access to route properties or the current component. If this is the case, you create a function that implements the `CanActivateFn`, `CanActivateChildFn`, `CanDeactivateFn`, and `CanMatchFn` type aliases. When using these type aliases, Angular provides the function with some function parameters you can use inside the guard logic:

*   `ActivatedRouteSnapshot` and state of type `RouterStateSnapshot`.
*   `CanActivateFn` type alias.
*   `currentRoute` of type `ActivatedRouteSnapshot`, `currentState` of type `RouterStateSnapshot`, and `nextState` of type `RouterStateSnapshot`.
*   `route` of type `Route` and `segments` of type `UrlSegment[]`.

You use the type aliases by defining a function that resolves in a `Boolean`. You type the function with the type alias and include the function parameters inside the function brackets. Here is an example implementing the `CanMatchFn` type alias:

export const hasRouteSegments: CanMatchFn = (route: Route, segments: UrlSegment[]) => {

return inject(UserService).loggedIn && segments.length > 1;

};


 In the preceding example, we check whether the user is logged in and whether there is more than one route segment. To use this guard, you add it to the array of the `canMatch` property inside the route configuration:

canMatch: [hasRouteSegments]


 You can also directly implement the type alias inside the array without defining the function elsewhere:

canDeactivate: [(component: UserComponent) => !component.hasUnsavedChanges]


 Now you know how to define functional route guards and prevent unauthorized users from accessing routes they aren’t allowed to access.
Although this already makes your application more secure, there are other risks when building Angular applications whereby users can perform malicious activities. So, let’s outline some attack surfaces and learn how you can mitigate them.
Angular attack surfaces and how to mitigate them
Before delving into +Angular-specific security measures, it’s essential to understand the common threats that web applications face. These threats include **cross-site scripting** (**XSS**), **cross-site request forgery** (**CSRF** or **XSRF**), injection attacks, and HTTP-level vulnerabilities such as **cross-site script** **inclusion** (**XSSI**).
Angular has some built-in tools to reduce the security risks of these attacks for you and there are some preventive measures you can take yourself when developing your Angular application. Let’s start with the most prevalent risk when developing frontend applications: XSS attacks.
Mitigating XSS attacks
In simple terms, you block XSS attacks by preventing malicious code from entering the `<script>` tag into the DOM. Other HTML elements that allow code exaction and can be used by attackers include the `<img>` and `<a>` tags. An attacker can use an XSS attack to hijack user sessions, steal sensitive data, or deface websites.
Angular takes a proactive approach to security, treating all values as untrusted by default. This means that when values are inserted into the DOM via template binding or interpolation, Angular automatically sanitizes and escapes untrusted values. This approach significantly reduces the risk of XSS attacks, a prevalent security vulnerability. Even though Angular proactively sanitizes and escapes untrusted values, there are still some actions you can take to make your applications even safer and protect them from security vulnerabilities.
Values inserted into the DOM via template binding or interpolation are automatically sanitized and escaped if the values are not trusted. On the other hand, Angular trusts HTML templates by default, because of which you should treat HTML templates as executable code. Never directly concatenate user input and template syntax because this would enable an attacker to inject harmful code into your application. Here is an example of what you should avoid:

{{ data }}
+ userInput

 One way to reduce the template risks is by using the default **ahead-of-time** (**AOT**) template compiler when creating production builds. Because the AOT compiler is the default, you don’t have to do anything unless you change the default compile settings.
Other possible attack surfaces for an XSS attack are `style`, `innerHTML`, `href`, and `src` bindings where the bound value is provided by the user:

...

 Attackers can use unsafe binding to inject harmful code or URLs into your application. Besides unsafe bindings, you also should avoid direct interaction with the DOM. If you bind an unsafe value, Angular will recognize it in most cases and sanitize it by removing the unsafe value. It’s good to be aware of this because it can lead to broken functionality in your application. Also, some attackers might be able to circumvent the sanitation, so be careful when using unsafe binding options. If you want to bind a URL, script, or other value that Angular will sanitize and you know the value is safe, you can bypass the sanitation using the `DomSanitizer` service provided by Angular.
If you want to bypass sanitation, you start by injecting the `DomSanitizer` service:

protected readonly sanitizer = inject(DomSanitizer);


 Next, you can use the bypass methods exposed by the service to bypass sanitation:

this.trustedUrl = this.sanitizer.bypassSecurityTrustUrl(this.dangerousUrl);


 The `DomSanitizer` service exposes five different options to bypass sanitation:

*   `bypassSecurityTrustHtml`
*   `bypassSecurityTrustScript`
*   `bypassSecurityTrustStyle`
*   `bypassSecurityTrustUrl`
*   `bypassSecurityTrustResourceUrl`

Depending on what value you are bypassing, you use the corresponding `bypassSecurity` method, so to bypass the sanitation of a piece of HTML, you would use the `bypassSecurityTrustHtml` method.
Besides binding unsafe values, another possible attack surface is direct manipulation of the DOM. The built-in browser DOM APIs don’t protect you from security vulnerabilities unless `Trusted Types` are configured. For example, elements accessed through `ElementRef` instances, the browser document, and many third-party APIs contain unsafe methods. You should avoid interacting with the DOM directly and instead use the `Renderer2` service when you need to manipulate DOM nodes.
Lastly, you can configure a **Content Security Policy** (**CSP**) to prevent XSS attacks. A CSP can be enabled on the web server and falls out of scope for this book.
You now know what XSS attacks are, what Angular does to prevent them, and what measures you can take to prevent them. Next, you will learn what vulnerabilities there are when making HTTP requests and what you can do in your Angular applications to prevent them.
Mitigating HTTP-related security risks
Ensuring robust security measures against HTTP-related risks is paramount to safeguarding your application and its users. Two significant threats to consider are CSRF (or XSRF) and XSSI. In this section, we will dive deeper into CSRF and XSSI and explain what they are, how they can affect your applications and users, and what measures you can take to prevent CSRF and XSSI exploits.
While CSRF and XSSI predominantly have to be mitigated on the server side, Angular does provide some tools to make the integration with the client side a bit easier. We will start by explaining what CSRF is and what you need to do on the client side to prevent it.
What CSRF/XSRF attacks are and how to prevent them
Imagine you’re logged into your online banking account in one tab of your browser. Now, if you visit a malicious website in another tab, that site can secretly make requests to your banking website without your knowledge. These requests could transfer money, change your password, or perform any action that your banking website allows – all without your consent.
CSRF/XSRF attacks can have serious consequences. They can lead to unauthorized transactions, data manipulation, and even account takeovers. Since the attacker doesn’t need to know your login credentials, these attacks can bypass traditional authentication mechanisms.
To protect against CSRF/XSRF attacks, websites typically use techniques such as CSRF tokens. These tokens are unique identifiers generated by the server and sent to the frontend. The frontend includes these random tokens with each request so the server can verify the token, ensuring that the request originated from a legitimate source and not from a malicious website. Commonly, the token is sent to the frontend using a cookie flagged with `SameSite`. If the cookie also includes the `httpOnly` flag, you don’t have to do anything on the frontend and everything will be handled on the backend, but this isn’t always the case; often, you must include the token in the request headers.
Using a CSRF token is an effective measure because all browsers have the same-origin policy. The same-origin policy ensures that only the code of the website where a cookie is set can read the cookie. The same-origin policy also ensures that a custom request header can be set by the code of the application making the request. That means that malicious code from the website the attacker tricked you into using cannot read the cookie or set the headers for your request. Only the code of your own application can do this.
If the cookie with the CSFR token is not an `httpOnly` cookie and the client is required to add the cookie in the request header, you can create an HTTP interceptor for this purpose. Here is an example of how the interceptor could be implemented:

export const MockInterceptor: HttpInterceptorFn = (

req: HttpRequest,

next: HttpHandlerFn,

) => {

const csrfToken = inject(AuthService).getCsrfToken();

const csrfReq = req.clone({

setHeaders: {

'X-XSRF-TOKEN': csrfToken,

},

});

return next(csrfReq);

};


 Besides adding a CSRF token, there isn’t anything you can do on the frontend to protect your application from CSRF attacks. If you need to add the token, it depends on how the server side implements the cookie, so consult with the backend team about this topic.
Now that you know what CSRF/XSRF attacks are, let’s learn about XSSI attacks.
What XSSI attacks are and how to prevent them
XSSI attacks occur when an attacker injects malicious scripts into a web page from an external domain. These scripts are executed in the context of the victim’s session, potentially compromising sensitive information and performing unauthorized actions. XSSI attacks can lead to data theft, session hijacking, and unauthorized manipulation of user interactions.
XSSI attacks are also known as the `<script>` tag, malicious actors can execute unauthorized requests and retrieve sensitive information from the targeted JSON API.
The success of this exploit hinges on the JSON data being executable as JavaScript. To prevent XSSI attacks, servers can adopt a preventive measure by prefixing all JSON responses, rendering them non-executable. Conventionally, this is achieved by appending the widely recognized `)]}',\``n` string.
The `HttpClient` of the Angular framework is equipped to handle this security measure seamlessly. It detects and removes the `)]}',\n` string from incoming responses automatically before proceeding with further parsing, thus fortifying the application against potential exploits. Because Angular automatically detects the `)]}',\n` string and removes it for you, you don’t have to do anything for XSSI prevention in the frontend, but it’s always good to be aware of the attack and how it actually can be prevented. If your backend team uses a different prevention measure, align with it to see whether you need to do anything in the frontend.
Summary
In this chapter, you learned about performance and security. You took a deep dive into Angular change detection, giving you a better understanding of how Angular detects changes and how you can reduce the number of components and bindings that Angular has to check when performing change detection.
You also learned about other measures you can take to ensure your Angular applications remain performant. You learned how to run code outside of the Angular zone, you learned about the `NgOptimizedImage` directive, you learned about the `trackBy` and `track` functions, and you’ve created your own web worker to run code in a separate threat. Furthermore, you learned that you can use lazy loading, `canMatch`, server-side rendering, and other tools provided by the Angular framework to enhance application performance even more.
After taking a deep dive into Angular application performance, you learned how you can develop secure frontend applications using the Angular framework. You learned how to prevent users from accessing pages they aren’t intended to reach. You also learned about common exploits, what measures Angular takes to prevent these attacks, and what steps you can take to make your application even more secure.
In the next chapter, you will learn how to make your applications more accessible and tailored to the users visiting them. You will learn about translatable content, using the correct formatting and symbols for each user, and making your website accessible to people of all abilities.


第十章:Angular 应用的国际化、本地化和无障碍性

在开发应用时,你通常针对来自许多不同国家的用户;因此,将国际化本地化添加到您的 Angular 应用中至关重要。国际化是指使您的应用可翻译的过程,这样讲不同语言的人就可以无任何问题使用您的应用。通过本地化,您将网站内容定制到特定位置。例如,来自美国的用户期望看到美元符号,而来自欧盟的用户在货币值前使用欧元符号。除了来自不同国家的用户外,您的应用也将被不同能力的人使用。一些用户可能无法像其他用户那样使用键盘或阅读屏幕。在一个越来越依赖应用的世界里,以确保所有能力用户都能使用它们的方式开发您的应用非常重要。

在本章中,您将学习如何开发可以服务于尽可能多人的 Angular 应用。首先,我们将深入 Nx 单仓库中实现国际化。在使您的内容可翻译后,您将了解并实现本地化,以便根据用户正确显示日期、货币和其他值。最后,我们将深入研究开发可访问的前端应用的主题,这些应用可以被所有能力用户使用。

本章将涵盖以下主题:

  • 在 Angular 应用中添加可翻译内容

  • Angular 应用的本地化

  • 使您的 Angular 应用对所有人可访问

在 Angular 应用中添加可翻译内容

在本节中,我们将深入研究国际化,通常称为i18n(i18n 是国际化的缩写,其中“i”和“n”是单词的首尾字母,18 代表“i”和“n”之间字母的数量)。国际化是为讲不同语言的人开发可用的应用。简单来说,当您实现 i18n 时,您使您的应用内容可翻译。大约世界上 75%的人根本不说英语,所以如果您只以英语(或仅使用另一种语言)显示您的应用内容,您就会错过很多潜在用户。

Angular 框架内置了支持可翻译内容的功能,但我们将不会使用内置的 i18n 解决方案来支持可翻译内容。第一个原因是内置解决方案在翻译文件中使用了 XML 内容。大多数开发者不喜欢 XML,而 JSON 是一个更易读和更灵活的替代品。我们不使用内置 i18n 解决方案进行可翻译内容的第二个原因是它主要关注编译时翻译。在编译时翻译中,翻译键在编译过程中被替换;因此,您必须为每个您想要支持的语言创建和部署特定的构建。还有一些对运行时翻译的支持,这意味着翻译可以在应用程序部署和运行时更改,但文档很少,并且该解决方案很少被使用,因为社区开发了更好的选项并进行了维护。

在 Angular 应用程序中支持可翻译内容的两个最常用的 i18n 库是 Translocongx-translate。我们将使用 Transloco,因为它对独立组件的支持更好,维护更活跃,文档更完善,支持服务器端渲染,并且整体上具有更多的配置选项和功能。我们将逐步将 Transloco 实施到 Nx monorepo 中的 费用登记 应用 中。

您仍然可以访问此网站深入了解 Transloco 的文档:jsverse.github.io/transloco/

在您的 Nx monorepo 中安装 Transloco

在您能够支持 HTML 模板和 TypeScript 文件中的可翻译内容之前,您需要在您的 Nx monorepo 中安装 Transloco 并将其添加到您想要使用的应用中。因此,让我们从开始安装 NPM 包,使用以下命令:

npm i @ngneat/transloco

在安装包后,您需要将 Transloco 添加到您的项目中,在 business-tools-monorepo\apps\finance\expenses-registration 文件夹路径下打开终端,并在终端中运行以下命令:

nx g @ngneat/transloco:ng-add

当您运行此命令时,您将在终端内被问及两个问题:

  • 使用 en 添加英语,使用 nl 添加荷兰语作为我的默认语言。您可以提供自己的语言,但请添加 en 以确保英语,因为我们在书中会使用它。如果您愿意,您总是可以扩展这些语言。

  • 您是否在使用服务器端渲染?:False–这是默认设置,所以您只需按 Enter 即可)。

在回答完问题后,命令将添加必要的配置以将 Transloco 添加到 费用登记应用。您也可以手动添加配置,但使用终端命令更快,并确保一切配置都添加正确。

现在,让我们更仔细地查看 Transloco 为您生成的文件,这些文件已被添加到 Nx monorepo 和 费用登记应用 中。

我们将要查看的第一个文件是 transloco-loader.ts。在这个文件中,Transloco 创建了一个 Angular 服务,该服务负责根据提供的语言加载翻译文件。以下代码是在 transloco-loader.ts 文件中为你生成的:

@Injectable({ providedIn: 'root' })
export class TranslocoHttpLoader implements TranslocoLoader {
    private http = inject(HttpClient);
    getTranslation(lang: string) {
        return this.http.get<Translation>(`/assets/i18n/${lang}.json`);
    }
}

如前所述的代码所示,TranslocoHttpLoader 服务确实从你的 assets 文件夹中获取了一个 JSON 文件。获取到的 JSON 文件将包含特定语言的翻译键和值。你需要为 getTranslation() 函数提供一个与你的翻译文件命名相对应的语言键。例如,如果你在 i18n 文件夹中有一个包含你的英语翻译的 en.json 文件,你将使用 en 作为参数调用 getTranslation() 函数以获取英语翻译。

个人的观点,我喜欢确保 getTranslation() 函数的函数参数有更好的类型,并且只允许特定的字符串值而不是任何字符串。在我的情况下,我有 ennl 作为我的语言文件,所以我将调整函数参数为这个:

lang: 'en' | 'nl'

你可以调整函数参数类型以匹配你支持的语言。

重要提示

需要注意的是,当你部署你的 Angular 应用程序,并且 Transloco 无法加载你的语言文件时,可能是因为你需要提供一个相对路径,但一般来说,这种情况并不常见。

现在我们来看看如何使用带有相对路径的 getTranslation() 方法:

getTranslation(langPath:'en' | 'nl') {
  return this.http.get(`./assets/i18n/${langPath}.json`);
}

在前面的代码中,你可以找到一个相对路径的示例,但你只需要提供一个相对路径,如果 Transloco 无法使用常规设置加载你的翻译。

现在你已经了解了 transloco-loader.ts 文件及其用途,让我们看看 Transloco 还为你创建了什么。

对于你在终端中运行命令时提供的每种语言,Transloco 都为你创建了一个翻译文件。在你的 assets 文件夹中,创建了一个 i18n 文件夹,在该 i18n 文件夹中,你可以找到为你提供的每种语言的 JSON 文件。所以,在我的情况下,我提供了 ennl,因此 Transloco 为我创建了 en.jsonnl.json 文件。你将添加你的翻译键和值到这些 JSON 文件中。默认情况下,这些文件包含一个空的 JSON 对象。在生成空翻译文件后,Transloco 调整了 app.config.ts 文件中的 ApplicationConfig 对象。

在你的 ApplicationConfig 中,Transloco 在提供者数组中添加了 provideHttpClient()provideTransloco() 配置:

provideHttpClient(),
provideTransloco({
  config: {
    availableLangs: [‹en›, ‹nl›],
    defaultLang: ‹en›,
    // Remove this option if your application doesn›t support changing language in runtime.
    reRenderOnLangChange: true,
    prodMode: !isDevMode(),
  },
  loader: TranslocoHttpLoader
}),

provideTransloco() 配置函数接收一个自己的配置对象,用于配置 Transloco 实例。默认情况下,Transloco 的配置定义了翻译加载器、默认语言、可用语言、是否处于生产模式以及是否支持在应用程序中运行时更改语言。此外,您还可以添加其他配置,例如备用语言、Transloco 在使用备用语言之前需要尝试加载语言文件多少次,或者是否记录丢失的翻译键。

删除生成的 provideHttpClient() 函数

Transloco 还在您的 providers 数组内部添加了 provideHttpClient() 函数。因为您已经在 ApplicationConfig 对象的 providers 数组中有一个 provideHttpClient() 配置,所以删除 Transloco 添加的此配置非常重要。如果您不删除 Transloco 添加的 provideHttpClient(),它将覆盖您自己的 provideHttpClient() 配置,该配置还包含您的模拟数据拦截器配置。

由于模拟数据拦截器,您还需要调整一件事。转到 mock.interceptor.ts 文件并调整第一个 if 检查。我们希望在不修改请求的情况下返回请求(就像我们在生产中已经做的那样)或如果请求 URL 以 .json 结尾:

if (!isDevMode() || req.url.endsWith('.json')) return next(req);

如果您现在检查浏览器控制台的“网络”标签页,您会看到有一个请求被发送并成功获取了 en.json 文件。

现在您已经知道了 Transloco 生成了什么并添加到您的应用程序中,让我们继续学习如何使用 Transloco 来支持多语言内容。

使用 Transloco 翻译内容

要使用 Transloco 翻译值,您在翻译文件中创建键值对。您在每个翻译文件中使用相同的键,并为其提供给定语言文件的正确翻译值。键都是小写,单词之间用下划线分隔。例如,如果我想翻译 Expenses Overview,我会在 en.json 翻译文件中添加以下内容:

{
  "expenses_overview": "Expenses Overview"
}

在其他翻译文件中,您添加相同的 expenses_overview 键,并为其分配特定语言的翻译值。例如,在我的 nl.json 文件中,我会添加相同的键并带有荷兰语的翻译:

{
  "expenses_overview": "Uitgavenoverzicht"
}

现在您已经知道了如何向翻译文件添加翻译键和值,让我们看看如何在您的应用程序中使用这些键值对来翻译内容。您可以在 HTML 模板和 TypeScript 文件中翻译值。我们将首先查看 HTML 翻译,之后您将学习如何在 TypeScript 文件中翻译值。

在 HTML 模板中翻译值

在 HTML 模板中,你可以使用结构指令属性指令翻译管道来翻译值。建议使用结构指令。管道和指令都会创建订阅以观察用户何时更改选定的语言。当使用结构指令时,你只为模板创建一个订阅。相比之下,管道和属性指令每次在模板中使用时都会创建一个订阅,这可能在模板中多次发生。此外,结构指令会缓存翻译,所以如果你在模板中多次使用相同的翻译键,结构指令可以直接从缓存中返回它,从而提高你的性能和内存使用。

因为结构指令是推荐的方法,所以在查看管道和属性指令之前,让我们首先学习如何使用它。因为我们使用的是独立组件,所以在我们可以使用它之前,我们需要将其导入到组件的imports数组中:

imports: [……, TranslocoDirective],

导入指令后,将其添加到支出概述页面。当使用结构指令时,建议将整个模板包裹在一个ng-container元素中,并在ng-container元素上添加结构指令:

<ng-container ng-container element and add the directive on ng-container, you can use the t variable from the directive throughout the template to translate values. Let’s replace Expenses Overview text inside the h1 tag with the translatable value using the t variable from the directive and the expenses_overview translation key:

`

{{ t variable from the directive as a function and provide it with the translation key you want to display. In the browser, you’ll see t function provided by the *transloco structural directive. But there are also many scenarios where you need to translate something and need dynamic values inside the translated value.

例如,假设你想将h1标签内的标题从Expenses Overview更改为Expenses overview for <user name>。在这种情况下,你需要一种方法将用户名提供给可翻译的值,以便将其插入正确的位置。你也不能仅仅追加用户名,因为你的可翻译值在每种语言中可能不同,或者你的动态值需要在翻译值的中间某个位置插入。幸运的是,有一个简单的解决方案——你可以在你的翻译值中添加参数,并在使用与翻译值关联的翻译键时提供这些参数的值。

要实现这一点,首先在翻译文件中添加另一个(或更改现有的)翻译键:

"expenses_overview_for_user": "Expenses overview for {{user}}"

正如你所见,我们通过使用双大括号在我们的翻译值中添加了一个参数。现在,在 HTML 模板中,你可以像这样为用户翻译参数提供一个值:

<h1>{{ t('expenses_overview_for_user', { user: user.fullName }) }}</h1>

如您所见,您可以将一个对象传递给t函数作为第二个参数。t函数的第一个参数是翻译键,第二个参数是可选的,可以用来为翻译参数提供值。在我们的例子中,我们使用一个具有fullName属性的user对象来向翻译参数提供用户名(我们当前组件中没有这个用户对象;这只是一个例子,您可以使用任何属性或静态值来提供用户翻译参数)。除了参数和简单的翻译键之外,您还可以在翻译文件中分组翻译键。

要分组翻译键,创建一个分组键,例如expenses_overview_page,然后在分组键下添加嵌套键,如下所示:

{
  "expenses_overview_page": {
    "title": "Expenses Overview",
    "incl_vat": "Incl. VAT"
  }
}

尤其当您的翻译文件变得更大时,这有助于快速定位特定的翻译。使用这种方法,您可能会得到一些重复的翻译值。尽管如此,改进的可维护性比几个重复的翻译值更有意义,但您的做法取决于您和您的团队。当使用分组方法时,您在 HTML 模板中使用以下语法:

<h1>{{ t('expenses_overview_page.title') }}</h1>

如您在前面的示例中所见,您首先定义组名,然后是一个点,然后是翻译键。正如您所想象的那样,在每个翻译键前加上组名可能会变得冗余,因此为了简化,您可以在*transloco指令中定义此组名:

*transloco="let t; read: 'expenses_overview_page'"

当您在指令中定义组名时,您只需在t函数中使用内部翻译键:

<h1>{{ t('title') }}</h1>

您现在知道如何声明分组或常规的翻译键值对,以及如何在 HTML 模板中使用*transloco结构指令来翻译它们。或者,您可以使用transloco属性指令或管道来完成您的翻译;然而,如前所述,属性指令是推荐的,因为它在您的 HTML 模板中创建更少的订阅,并且具有缓存功能。

为了提供一个全面的概述,以下是一个使用管道包括翻译参数的示例:

<h1>{{ 'expenses_overview_for_user' | transloco: {user: user.fullname} }}</h1>

最后,您可以使用属性指令:

<h1 transloco=" expenses_overview_for_user " transloco pipe to translate translation keys. In the last example, we showed how you can use the transloco attribute directive combined with the translocoParams attribute directive to translate translation keys with parameters. But, as mentioned earlier, using the structural directive is the preferred approach, as it improves your performance and creates fewer subscriptions.
Now you know about all the options to translate values inside your HTML templates using Transloco. Next, you’ll learn about translating values programmatically inside your TypeScript files.
Translating values programmatically in your TypeScript files
Sometimes, you might encounter a situation where you must translate values inside your component classes or services. To translate values programmatically inside your TypeScript files, you can use the `TranslocoService` class. There is only one caveat: you need to know the translation file is loaded before you can translate translation keys inside your TypeScript files.
So, let’s start by creating a `TranslationService` class in the *expenses-registration application*. Inside the `TranslationService`, we will make the `TranslocoService` class publicly available and create a signal from the `events$` observable using the `toSignal()` function:

@Injectable({ providedIn: 'root' })

export class TranslationService {

translocoService = inject(TranslocoService);

translationsLoaded = toSignal(this.translocoService.events$.pipe(filter(event => event.type === 'translationLoadSuccess'), map(event => !!event)));

}


 We injected the `TranslocoService` inside `TranslationService`. Alternatively, you can inject the `TranslocoService` into each component or service you need to, but I prefer to expose it in the `TranslationService`, where we also declare the `translationsLoaded` signal and possible other configurations and methods. The `translationsLoaded` signal you created will return `true` when the `events$` observable from the `TranslocoService` returns an event of type `translationLoadSuccess`. You can use this signal in combination with a signal effect to translate values inside your TypeScript files safely.
For example, if you want to translate the `expenses_overview_page.title` key inside the `ExpensesOverviewPageComponent`, you start by injecting the `TranslationService` service you created:

protected readonly translationService = inject(TranslationService);


 After injecting the service, you can create an effect based on the `translationsLoaded` signal and perform your translations safely inside the effect:

translationEventsEffect = effect(() => {

if (this.translationService.translationsLoaded()) {

// 在此处执行您的翻译

}

});


 As you can see in the preceding code, we declare an `effect` and check if the `translationsLoaded` signal from the service returns a `true` value; if it does, you can perform your translations safely. If you know the translation file is already loaded, for example, because the user is logged in and you fetch the translation file before the user is logged in, you don’t need the effect and can directly get the translation. You translate a value by calling the `translate()` function on the `TranslocoService` and providing the function with the translation key you want to translate:

this.translationService.translocoService.translate('expenses_overview_page.title')


 Using the `translate()` function will return the translated value synchronously, so you can directly use or assign it however you need to. When you need to translate a key-value pair that includes translation parameters, you use the following syntax:

this.translationService.translocoService.translate(' expenses_overview_for_user', {user: user.fullname})


 Alternatively to the `translate()` function, you can also use the `selectTranslate()` function. The `selectTranslate()` function is asynchronous and will fetch the translation file (unless it’s already loaded) and return the translation as an Observable. When using the `selectTranslate()` function, you don’t have to make sure the translation file is already loaded, but you do need to manage the subscription. I like to use the first approach, as you don’t have to create and manage subscriptions each time you want to translate a value inside your component classes, but here is an example of how you can get the translation value for a key using the `selectTranslate()` function:

this.translationService.translocoService.selectTranslate('expenses_overview_page.title').subscribe((title) => {

console.log(‹==>›, title);

});


 You now know how to translate values synchronously and asynchronously in your TypeScript files. You also know how to translate values in the HTML template. The last thing to learn is how to change your Transloco instance’s configurations at runtime. After all, you want to give the user the ability to change the language.
Changing the Transloco configurations at runtime
The last step in making your content translatable for your users is adding the option to change the language. First, you need something so the user can select a language; I will add a select box to the navbar for this purpose. In the HTML file of the `NavbarComponent`, I’ve added the following HTML:

<select #selectList (change)=»languageChange.emit(selectList.value)»>


 After adding the select box in the HTML file, in the `component` class, I’ve added an input for the `languages` array and an output to output the selected value (I also converted the `navbarItems` input to a signal input):

export class NavbarComponent {

navbarItems = input([], { transform: addHome });

languages = input<string[]>([]);

@Output() languageChange = new EventEmitter();

}


 After updating the `NavbarComponent`, you need to provide an array of languages to the `NavbarComponent` inside the HTML template of the `AppComponent` of your `expenses-registration` application. To achieve this, we’re first going to add an additional method in our `LanguageService` to retrieve the available languages as a string array:

getLanguages() {

return this.translocoService.getAvailableLangs() as string[];

}


 After adding the method that returns the available languages, we will use this method inside the HTML template of our `AppComponent` and use it to set the `language` input for the `NavbarComponent`:

<bt-libs-navbar …… [languages]="translationService.getLanguages()" />


 After supplying the `languages` input with the available languages, you’ll see the options inside the select box. Next, you need to add the `languageChange` output event on the HTML tag of the `NavbarComponent` and use the output value to set a new active language:

<bt-libs-navbar …… (languageChange)="translationService.translocoService.setActiveLang($event)" />


 Now, if you change the language in the select box inside your navbar, you’ll notice that the translatable text in your application has been changed to the selected language. That is all you need to know about adding translations in your Angular applications using Transloco. The library has other translation options, such as setting the default language, setting the fallback language, or subscribing to language changes to perform logic reactively when the active language changes.
If you want, you can dive deeper on your own by visiting t[he documentation on the official web](https://jsverse.github.io/transloco/)site: [`jsverse.github.io/transloco/`](https://jsverse.github.io/transloco/).
You now know how to support internationalization for your Angular websites using Transloco. We did all the necessary setup for Transloco and created an additional `TranslationService`. Next, we created translation files with key-value pairs used to declare all your translations. You learned how to use the Transloco directives and pipe to translate values inside your HTML templates and the `TranslocoService` to translate values and change the active language inside your TypeScript files. Now that you know how to add i18n support to your Angular applications, let’s learn about localization.
Localization for Angular applications
Where internationalization ensures your application content can be consumed in different languages, localization ensures that your application uses the correct formats and symbols for the user’s location. For example, dates are formatted differently for users in the USA and in the EU. You want to ensure that you show the correct formats for each user so your application can be used without confusion by as many people as possible. Where internationalization is commonly referred to as i18n, localization is commonly referred to as **l10n**.
Just as with i18n, Angular has its own localization packages, but the implementation of the framework isn’t widely used and requires some additional work to use at runtime. Just as we did with i18n, we can use Transloco for l10n. Transloco provides a dedicated package for l10n containing a date, currency, and decimal pipe. To start using the Transloco l10n pipes, start by installing the NPM package with the following command:

npm i @ngneat/transloco-locale


 After installing the package in your Nx monorepo, you need to provide configurations in the `ApplicationConfig` object of the applications you want to use the l10n package. We will add it to the *expenses-registration application* by adding the following provider inside the providers array of the `ApplicationConfig` object:

provideTranslocoLocale({

langToLocaleMapping: { en: ‹en-US›, nl: ‹nl-NL› }

})


 As you can see, you need to add the `provideTranslocoLocale()` provider function inside the providers array and supply the function with a parameter to configure the language to localization mapping. In the example, we map the `en` language to the `en-US` localization and the `nl` language to the `nl-NL` localization. By using the `langToLocaleMapping` configuration, the localization will automatically change when you set a new active language. If you don’t use the `langToLocaleMapping` setting and want your localization settings separated, you need to set the active location using the `TranslocoService`:

this.translocoService.setLocale('en-US');


 Besides the `langToLocaleMapping` configuration, you can provide the following configurations to the `provideTranslocoLocale()` provider function:

export interface TranslocoLocaleConfig {

defaultLocale?: Locale;

defaultCurrency?: Currency;

localeConfig?: LocaleConfig;

langToLocaleMapping?: LangToLocaleMapping;

localeToCurrencyMapping?: LocaleToCurrencyMapping;

}


 After configuring the `provideTranslocoLocale()` provider function, you can start using the Transloco i10n pipes. Let’s start with localizing currencies.
Localizing currencies using the translocoCurrency pipe
When localizing currency values inside your applications, you want to display the correct currency symbol and make sure your numbers are formatted correctly depending on the user. To localize currency values with Transloco, you import the pipe into your component (if you’re using standalone components) and change the Angular currency pipe inside your templates for the `translocoCurrency` pipe. So go ahead and change the currency pipe in your `expenses-overview-page.component.html` file and replace it with the transloco pipe:

{{ expense.amount.value.toFixed(2) | en to nl inside the navbar (在生产环境中,后端也应返回不同的数据,因为货币之间的汇率,或者 API 可以提供汇率,以便您可以在前端执行转换)。

您可以为 translocoCurrency 管道提供一些额外的选项来进一步配置显示的值:

  • display: 此选项控制您想要显示的货币单位。您可以选择的选项有 code、symbol、narrowSymbol 和 name。

  • numberFormatOptions: 这是一个对象,用于控制数字的格式。您可以为该对象提供 Intl.NumberFormatOptions 属性(Intl.NumberFormatOptions 是原生的 JavaScript 格式选项)。

  • currencyCode: 使用此选项,您可以指定管道应使用的货币符号。

  • locale: 使用此选项,您可以提供区域设置选项,例如 en-US 或 nl-NL。

以下是一个包含参数的 translocoCurrency 管道的示例:

translocoCurrency: 'narrowSymbol' : {minimumFractionDigits: 2 } : 'EUR' : 'nl-NL'

配置选项是可选的,并将覆盖您在 provideTranslocoLocale() 函数内部配置的任何默认设置。

现在您已经了解了货币管道,探索 Transloco 提供的其他本地化管道。

使用 translocoDate 管道本地化日期

在本地化日期时,您想要确保日期格式正确,这取决于用户选择的语言设置(或如果它们是分开的,本地化设置)。例如,欧盟和美国的日期格式不同。例如,在美国,日期以月份开始。在欧盟,它们以日期开始。因此,如果您想为美国用户显示 2024 年 1 月 20 日,这将表示为 1/20/2024,而对于欧盟用户,20-01-2024 将是传统的格式。此外,对于讲英语的用户,月份总是大写,而对于某些欧盟语言来说则不是这样。

为了提供最佳的用户体验,您的应用程序中的日期应正确格式化。translocoDate 管道提供了一种简单的方法来实现这一点。只需将管道导入到您的组件中(如果您正在使用独立组件)。类似于货币管道,您可以用 translocoDate 管道替换原生的 Angular 日期管道,并且当用户更改语言(或本地化)设置时,您的日期将自动响应。为了演示这一点,您可以在 expenses-overview-page.component.html 文件中更改您使用的日期管道:

{{ expense.date | translocoDate pipe, the date format changes when you change the language settings inside the navbar. Additionally, you can provide the translocoDate pipe with some parameters to further configure how formats are displayed or to overwrite the default settings for specific instances in your application. The translocoDate pipe can take the following parameters:

*   `options`: This is an object containing the following properties:
    *   `dateStyle`: Controls how to format the date; you can use full, long, medium, and short formats.
    *   `timeStyle`: Controls how to format the time of the date. You can provide it with full, long, medium, and short.
    *   `fractionalSecondDigits`: Controls the number of fractional seconds to show; you can set it to 0, 1, 2, or 3.
    *   `dayPeriod`: Controls how to show the period of the day (at night, midday, etc.). The options are long, short, and narrow.
    *   `hour12`: Indicates whether you are using 12 or 24 hours. You can provide the `hour12` property with a `true` or `false` value.
    *   `weekday`: Controls how weeks are formatted; the options are long, short, and narrow.
    *   `era`: Controls how the era is formatted; the options are long, short, and narrow.
    *   `year`: Controls how to format the year value; the options are numeric and 2-digit.
    *   `month`: Controls how to format the month value; the options are numeric, 2-digit, long, short, and narrow.
    *   `day`: Controls how to format the day value; the options are numeric and 2-digit.
    *   `hour`: Controls how to format the hour value; the options are numeric and 2-digit.
    *   `minute`: Controls how to format the minute value; the options are numeric and 2-digit.
    *   `second`: Controls how to format the second value; the options are numeric and 2-digit.
    *   `timezone`: Controls how to display the time zone; the options are full, long, medium, and short.
*   `locale`: With this option, you can provide a locale option, such as en-US or nl-NL.

All the additional parameters you can supply to the `translocoDate` pipe are optional; if you don’t supply any additional parameters, the pipe will display dates with the numeric day, month, and year by default. Now that you know how to use the `translocoDate` pipe and what configuration options it has, let’s move on to the last Transloco pipe we will cover: the decimal pipe.
Localizing numbers using the translocoDecimal pipe
Just like currency and date values, numbers are subject to localization. For example, in the USA, large numbers are separated by a comma and decimal numbers are separated from the integer values by a dot; for EU users, this is reversed. This localization issue can be solved in your applications by using the `translocoDecimal` pipe. It will format numbers correctly depending on the language (or localization) settings configured by the user:

{{ 1234567890 | en-US, 大数以逗号分隔。如果您将其作为小数,小数值将以点分隔:

<!--567,890.15  en-US -->
<span> {{ 567890.15 | translocoDecimal }}</span>

就像 translocoDatetranslocoCurrency 管道一样,您为 translocoDecimal 管道有一些额外的配置选项。translocoDecimal 管道可以接受以下参数:

  • numberFormatOptions: 这是一个包含 Intl.NumberFormatOptions 格式化属性的对象(Intl.NumberFormatOptions 是一个原生的 JavaScript 格式化对象)

  • locale: 使用此选项,您可以提供区域设置选项,例如 en-US 或 nl-NL

您现在已经学会了如何使用 Transloco 库的不同本地化管道,以及您可以提供哪些配置选项来控制输出。在下一节中,您将学习如何使您的 Angular 应用程序对所有人可访问。

使您的 Angular 应用程序对所有人可访问

在一个越来越依赖网络应用的世界里,确保每个人都能使用您的应用程序至关重要。确保您的 Angular 应用程序对有运动或视觉障碍的人可访问是确保所有能力用户都能有效与之互动的关键。可访问性,通常缩写为 a11y,涉及设计和开发您的应用程序,使其能够被具有不同需求的人使用,包括那些有残疾的人。

作为一名开发者,我经常忘记并不是每个人都能像我能一样使用键盘或看到屏幕。不仅包括永久性残疾的人,还包括那些暂时无法使用他们的手或视力的人也应该能够继续与你的应用程序互动。在一些国家,法律甚至强制要求你的应用程序实施特定的可访问性标准。一个常用的可访问性标准是Web 内容可访问性指南 2.2(WCAG 2.2)。WCAG 2.2 有 13 条准则需要遵循:

  1. 文本替代:为非文本内容提供文本替代方案,使其对无法看到图像或听到音频的用户可访问。

  2. 基于时间的媒体:为基于时间的媒体提供替代方案,如音频和视频,以确保无法听到或看到多媒体内容的用户能够访问。

  3. 适应性:创建可以在不同方式下呈现而不丢失信息或结构的内容,确保依赖于各种辅助技术的用户能够访问。

  4. 可区分性:确保足够的对比度,提供音频内容的替代方案,并避免可能阻碍访问的干扰,使用户更容易看到和听到内容。

  5. 键盘可访问:确保所有功能都可通过键盘界面访问,确保无法使用鼠标的用户能够访问。

  6. 充足时间:为用户提供足够的时间阅读和使用内容,确保那些可能需要更多时间与网页互动的用户能够访问。

  7. 癫痫和身体反应:不要设计可能导致癫痫发作或身体反应的内容,确保对患有光敏感性癫痫或其他状况的用户可访问。

  8. 可导航性:使网页可导航且可预测,确保依赖于导航辅助工具或具有认知障碍的用户能够访问。

  9. 输入模式:确保与不同的输入模式兼容,如触摸、语音和手势,确保那些影响他们与网页内容互动的残疾用户能够访问。

  10. 设备独立性:确保与各种设备、平台和辅助技术兼容,确保使用不同设备访问网络的用户能够访问。

  11. 可读性:使文本内容易于阅读和理解,确保认知障碍或使用屏幕阅读器的用户能够访问。

  12. 可预测性:使网页以可预测的方式运行,确保依赖于一致导航和交互模式的用户能够访问。

  13. 输入辅助:帮助用户避免和纠正错误,确保信息输入或导航网页表单可能存在困难的用户能够访问。

现在你已经了解了 WCAG 2.2 的 13 条准则,让我们来看看在你的 Angular 应用程序中你可以做些什么来遵守这些准则。

如何使 Angular 应用程序可访问

遵循可访问性指南的最简单方法是使用一个 UI 库,该库将这些关注点从你的手中拿走。一些好的 Angular UI 库包括 Angular Material、PrimeNG 和 Ng Zorro。然而,并非所有应用程序代码都可以使用 UI 库开发,有时你的雇主会开发自己的 UI 组件;在这些情况下,你需要自己应用 WCAG。为了确保你遵循 WCAG,你需要做的第一件事是使用语义化 HTML。

语义化 HTML 是指使用适当的 HTML 元素来可视化屏幕上的元素。通常,开发者喜欢使用过多的<div><span>元素;这些元素并没有告诉屏幕阅读器任何关于它们用途的信息。尽量使用如<label><button><input><header><footer><section><article><form>等 HTML 元素。当使用语义化 HTML 元素时,屏幕阅读器可以更好地向无法看到页面的用户解释页面内容。除了实现语义化 HTML 之外,你还应该确保用户可以使用键盘导航页面。

使用tabindex属性

你可以通过添加tabindex属性来确保 HTML 元素可以通过键盘上的 tab 键聚焦。这种默认聚焦行为适用于以下元素:具有href属性的<a><area><button><iframe><input><object><select><textarea><SVG>和为<details>元素提供摘要的<summary>元素。开发者不需要手动添加tabindex属性到这些元素,除非他们想要改变它们的默认聚焦行为。

例如,设置负的tabindex值将移除元素从焦点导航顺序,从而使其无法聚焦。然而,在修改可聚焦元素的默认行为时,必须谨慎行事,以确保直观且易于访问的用户体验。

使用tabindex属性,你可以使 HTML 元素可键盘聚焦,防止元素被键盘聚焦,并确定聚焦顺序。如前所述,当你为tabindex属性提供一个负整数时,HTML 元素在使用键盘时将不可聚焦。如果你为tabindex属性提供一个0,则元素保持默认的 tab 顺序(从上到下,根据 HTML 元素的顺序)。最后,你可以提供正整数。具有正整数的元素将在默认聚焦顺序之前聚焦,从tabindex 1开始,到最高的tabindex结束。当没有更多的具有正整数的tabindex属性时,下一个聚焦的元素是第一个具有默认tabindex属性的 HTML 元素:

<button type="button">1st default focus</button>
<button type="button">2nd default focus</button>
<button type="button" tabindex="100">Second focused</button>
<div tabindex="0">3rd default focus</div>
<button type="button" tabindex. The first element that will be focused is the button on the bottom with tabindex 1; after that, the button with tabindex 100 will be focused, and after that, elements will be focused from top to bottom (skipping the two elements we already focused because they have a positive tabindex attribute).
Now that you know how to control the keyboard focus of HTML elements and when to use the `tabindex` attribute, let’s learn about ARIA attributes.
Adding ARIA attributes
**Accessible Rich Internet Applications** (**ARIA**) attributes are used to provide or add semantic meaning to HTML elements. In Angular, you can provide static or dynamic values for your ARIA attributes. Dynamic attributes might be useful if you want your ARIA attributes to be translatable or when they change for specific HTML elements based on some user interaction:

<button [attr.aria-label]="dynamicValue">…</button>

<button [attr.aria-label]="'translationkey' | translate">…</button>

<button aria-label="static value">…</button>


 In the preceding code, you see the three options:

*   First, we provide a dynamic value to the `aria-label`. In the first example, `dynamicValue` is a component property that can have different values over time.
*   Then, you see how you can use a translated value for the `aria-label`.
*   Lastly, we provided a static value for the `aria-label`.

There are many different ARIA attributes; let us take a look at the few most commonly used ones.
The aria-label
The `aria-label` attribute is by far the most used ARAI attribute. The `aria-label` attribute can be used to provide a text explanation for visual elements that don’t have any text or to provide a more descriptive explanation of an element that does include text. For example, if you have a button HTML element that is styled as a hamburger menu, the button will not include any text. Without the `aria-label` attribute, a screen reader can’t make anything of this, so you need to provide the `aria-label` attribute with an explanation:

<button aria-label 属性,并为其提供了文本“汉堡菜单”,这样屏幕阅读器可以清楚地解释该元素的内容。或者,你也可以使用 aria-label 属性为包含文本的元素提供更好的解释:

<button Submit text. By default, a screen reader will read the text of the HTML element, but only reading out Submit might not give the user enough information about what they are submitting, especially if you can’t see the screen. Adding an aria-label attribute can provide a better explanation for the screen reader to read aloud to the user.
The role attribute
Another important and commonly used ARIA attribute is the `role` attribute. The `role` attribute in HTML defines an element’s specific role or purpose within the web page or application. It can be added to various HTML elements to convey their intended purpose to assistive technologies, such as screen readers, which rely on this information to provide a meaningful experience to users with disabilities.
The `role` attribute can be added for most HTML elements, including but not limited to the following:

*   Semantic elements such as `<nav>`, `<article>`, `<section>`, `<header>`, `<footer>`, and `<main>`.
*   Form elements such as `<input>`, `<button>`, `<select>`, and `<textarea>`.
*   Interactive elements such as `<a>` (anchor links), `<button>`, and `<option>` (within `<select>`).

The following is an example of the `role` attribute:

<a href="#" 标签,指定 HTML 元素的 role 为按钮。通常,标签被用作链接,但在这个情况下,我们表明它被用作按钮。

更多信息

除了rolearia-label属性之外,还有许多其他的 ARIA 属性,例如aria-hiddenaria-checkedaria-disabledaria-readonly

你可以在以下 URL 找到所有 ARIA 属性的完整列表和详细解释:

developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes

现在你已经了解了不同的无障碍性指南以及如何将它们应用到你的 Angular 应用程序中。你学习了为什么为你的页面使用语义化的 HTML 结构很重要,这样屏幕阅读器等辅助工具可以更好地导航和理解你的组件和页面。你学习了如何使用 Tab 键使元素可聚焦,以及如何控制页面中不同元素的聚焦顺序。最后,你学习了 ARIA 属性以及它们如何被用来为辅助技术,如屏幕阅读器,提供额外信息。

摘要

在本章中,你学习了如何让你的应用程序对讲不同语言或位于不同地区的人更加无障碍。你了解了 Transloco 库以及如何用它来实现本地化和国际化。你创建了语言文件以提供翻译键值对,并在你的 HTML 模板中实现了翻译。你学习了如何使用结构指令、属性指令、管道和TranslocoService来翻译值。在了解了可翻译内容之后,你学习了如何为来自不同地区的人格式化值。你学习了translocoCurrencytranslocoDatetranslocoDecimal管道。你看到了如何为本地化配置你的应用程序,以及如何在代码中特定实例中覆盖默认设置。在国际化并本地化你的网站之后,你学习了无障碍性。你熟悉了 WCAG,并了解了如何在 Angular 应用程序中确保它们得到实施。

在下一章中,你将学习如何为你的 Angular 应用程序编写单元测试和端到端测试。






第十一章:测试 Angular 应用程序

为您的应用程序编写自动化测试与编写应用程序代码一样重要。许多开发者不喜欢编写测试或者完全跳过它们,因为他们觉得这太耗时了,但随着您的应用程序和工作空间的增长,拥有自动化测试变得越来越关键。当您在一个庞大的应用程序上工作时,您的更改可能会影响整个应用程序中的许多方面。小的更改可能会影响许多事物,当您在一个被许多应用程序使用的库中进行更改时,这一点尤为明显。您经常会发现自己处于这样的场景中:做出更改,甚至不知道您的更改会影响哪些应用程序的表面。因为您不想破坏功能,所以您不想每次更改时都手动处理或手动测试整个工作空间;您需要能够为您测试所有受影响代码的自动化测试。自动化测试将帮助您以不同的方式看待您的代码;它们可以帮助您编写更好、更坚固的代码。自动化测试还会在早期阶段捕捉到错误,并且应该让您有信心在所有测试成功通过后安全地将代码更改发布到生产环境。

本章将深入探讨不同类型的自动化测试以及它们在您的 Angular 应用程序中的目的。接下来,您将更深入地探讨单元测试的主题,并通过为我们的 Nx monorepo 编写单元测试来获得一些实践经验。最后,您将了解更多关于端到端测试的知识,并获得一些使用 Cypress 编写端到端测试的经验。到本章结束时,您将了解为什么您需要自动化测试以及如何为您的 Angular 应用程序编写它们。

本章将涵盖以下主题:

  • 不同类型的应用程序测试

  • 使用 Jest 对 Angular 应用程序进行单元测试

  • 使用 Cypress 对 Angular 应用程序进行端到端测试

不同类型的应用程序测试

在软件在日益重要的世界中扮演越来越重要角色,而我们构建的应用程序变得越来越复杂的情况下,自动化测试正变得越来越关键。公司不断寻求改进他们的应用程序,以给用户提供更好的体验。为了实现这一点,许多公司追求持续交付他们的软件,这意味着他们的更新可以在任何给定时间自动发布到生产环境。为了确保您可以在不破坏生产环境中的事物的情况下安全地发布更新,您需要能够在您的构建管道中运行的自动化测试,在将更改发布到您的测试、验收和生产环境之前自动测试您的软件。

随着应用程序变得更加复杂,手动测试所有更改变得过于耗时,并且由于您的代码更改而未测试到的风险显著增加。手动测试也慢得多,重复且无聊。耗时、繁琐和重复的任务往往会被跳过,导致错误。所有这些人工劳动对于企业来说也是一笔相当大的开销,因此拥有一个健全的自动化测试系统是必需的。

除了加快测试过程并减少错误率外,自动化测试还应让您有信心,合并的任何代码都不会破坏现有的应用程序代码。如果您曾在没有良好测试套件的环境中合并过大型代码更改,您就知道这是一次多么令人紧张的经历,并且您从未真正有信心您的更改没有破坏任何东西。如果有测试,它们会捕获错误并帮助您以不同的方式思考您的代码实现,但能够有信心地发布更改是我们编写自动化测试时试图实现的真实目标。

当涉及到为您的 Angular 应用程序进行自动化测试时,您可以将其分为四大主要类型:

  • 单元测试

  • 端到端测试

  • 组件测试

  • 集成测试

让我们了解这四种测试类型,它们在 Angular 应用程序中的使用方式以及它们之间的区别。

理解单元测试

软件测试的基本方面之一是单元测试。简单来说,单元测试验证代码的小单元,通常是单个函数、属性或方法。单元测试用于测试属性在不同场景下的更新行为和函数的实现。给定一个特定的输入,您期望函数返回一个特定的值并更新某些属性。单元测试在与其他应用程序代码隔离的情况下运行,因此您可以在不受应用程序代码其他部分影响的情况下测试代码的小单元;这样,您可以根据其实现快速确定函数是否按预期工作。

开发应用程序的一种常见且流行的技术是测试驱动开发TDD)。简单来说,当您使用 TDD 来开发应用程序时,您首先编写测试场景,然后编写代码实现。以这种方式开发代码允许您从另一个角度审视您的代码实现。编写测试有助于您以不同的方式看待代码实现。尽管如此,当您首先编写所有想要覆盖的可能测试场景,然后编写代码实现之后,这会改变您的视角。

在 Angular 应用程序中,单元测试通常使用 JestKarma 等框架来实现,通常测试特定的 Angular 组件、服务、管道或指令。这些测试对于验证每个代码单元是否按预期行为,遵守其定义的规范和需求至关重要。通过隔离每个代码单元,开发者可以在开发早期识别并解决错误和问题,从而促进更健壮和稳定的应用程序。

在 Angular 应用程序中,单元测试的主要目的是让开发者对其代码实现有信心,确保属性得到更新,函数按预期工作。通过在不同场景下彻底测试单个代码单元,开发者可以确保每一行代码都按预期工作,即使代码库随着时间的推移而演变和变化。这种信心对于开发者能够有信心地对应用程序进行更改和改进至关重要,同时确保现有功能保持完整。

单元测试的一个特点是它们运行速度快,允许开发者在开发过程中多次运行它们,这使得早期识别意外的副作用和错误变得容易。单元测试还旨在覆盖代码库的特定百分比。通常,公司喜欢测试 80% 到 100% 的代码行、函数和分支(或路径);大多数单元测试框架可以强制执行这些阈值,所以如果你没有足够的测试覆盖率,就不能合并代码。

总结来说,单元测试用于测试如函数、方法和属性等小的代码单元。通过单元测试,你可以在不同的场景下测试代码实现,以增强你对代码在给定特定输入时按预期行为的信心。通常,你试图实现 80% 到 100% 的代码测试覆盖率;因此,与其他测试类型(端到端、组件和集成)相比,你的单元测试将拥有最多的测试用例。单元测试运行速度快,很少因为环境问题而失败,因为它们是在隔离状态下运行的。

既然你对单元测试有了很好的了解,知道它们为什么有用,那么让我们深入了解下一类测试:端到端测试。

理解端到端测试

端到端e2e)测试是 Angular 应用程序测试策略的一个关键部分。它们提供了一个全面的方法来验证应用程序从用户角度的行为和功能。与专注于测试独立代码单元的单元测试不同,端到端测试模拟了与应用程序的真实用户交互,跨越多个组件和服务,以确保应用程序作为一个整体正确运行。

在 Angular 应用程序的背景下,端到端测试通常使用CypressPlaywrightProtractor等框架来实现。这些框架提供了有助于自动化浏览器交互的有用工具,允许它们模拟用户操作,例如点击按钮、输入文本和在不同页面之间导航。通过自动化这些交互,开发者可以彻底测试应用程序的用户界面和工作流程,识别和解决在实际使用中可能出现的问题。

端到端测试的主要目标是验证应用程序从用户的角度来看是否按预期工作,包括应用程序的功能性和非功能性方面。端到端测试在真实浏览器中(你也可以在不打开浏览器的情况下运行它们)呈现应用程序(或应用程序的特定库或模块),访问特定的 URL,并以用户的方式与之交互。通过端到端测试,你可以测试组件是否正确渲染,以及诸如表单提交、数据检索和显示、模型和错误处理等功能是否按预期工作。通过端到端测试应用程序,开发者可以确保多个组件和服务能够无缝协作,提供一致的用户体验。

端到端测试的主要优势之一是它们能够检测在单独测试代码单元时可能缺失的问题。通过在测试期间对整个应用程序堆栈进行操作,包括前端用户界面、外部依赖项以及(可选的)后端服务,端到端测试可以揭示与数据流、组件间的通信以及与第三方服务的互操作性相关的问题。这种全面的测试方法有助于开发者识别和解决应用程序中可能存在的瓶颈和故障点,从而产生更健壮和可靠的应用软件产品。

然而,尽管端到端测试提供了许多好处,但它们也带来了一些挑战和开发者必须解决的问题。端到端测试的设置比单元测试更具挑战性,并且由于测试环境中的问题,端到端测试更容易失败。Nx 已经为我们处理了大部分设置工作,这使得开始我们的端到端测试变得更加容易。

使用端到端测试,你也没有简单的方法来检测代码覆盖率,因此它们需要更多的规划和协调,以确保测试了应用程序中的所有内容,并处理了不同的场景和用例。

此外,由于端到端测试依赖于浏览器自动化和模拟真实用户行为的需求,因此它们的执行可能比单元测试更耗时和资源密集。因此,开发者必须在端到端测试覆盖的深度和范围与测试执行时间和资源等实际约束之间取得平衡。

尽管存在这些挑战,端到端测试(e2e tests)在确保 Angular 应用程序的整体质量和可靠性方面发挥着至关重要的作用,它补充了其他测试技术,如单元测试和集成测试。通过彻底测试应用程序的每个环节,开发者可以对其行为和功能充满信心,在开发早期阶段识别并解决问题,最终向客户交付高质量的用户体验。

总结来说,端到端测试旨在从用户的角度测试你的应用程序,并在真实浏览器中与之交互。它们确保你的应用程序(或特定的库或模块)作为一个整体工作,并按预期对用户交互做出响应。虽然端到端测试在编写和执行方面比单元测试更耗时,但它们提供了用户可以按预期与你的应用程序交互的保证,从而与最终用户建立更强的联系。

现在你已经了解了端到端测试是什么以及它们与单元测试的不同之处,我将简要解释组件测试和集成测试。

理解组件测试

组件测试与单元测试和端到端测试相比是一个相对较新的概念。在现代前端框架如 Angular 中,我们使用 组件 来开发应用程序。组件可以是简单的组件,如按钮,也可以是更复杂的组件,如表格或表单。

使用组件测试,例如 Cypress 这样的框架提供了一种测试基于组件的应用程序的新方法。而不是访问 URL 并运行整个应用程序,组件测试会挂载单个组件并在隔离状态下测试这些组件。组件测试类似于端到端测试的单元测试。你仍然挂载组件并在浏览器中显示它,以便像用户一样与之交互,但你是在与应用程序的其他部分隔离的情况下进行测试。在隔离状态下测试组件允许你从用户交互的角度测试组件,而不必担心应用程序的其他部分。

需要记住的一点是,即使所有组件测试都通过,这也并不意味着你的应用程序按预期工作。组件可以独立工作,但在组合或需要与其他应用程序中的组件交互时可能会失败。与端到端测试相比,组件测试不需要执行整个系统,因此它们可以更快地运行,并且很少因为测试环境的问题而失败。

如果你和你的团队想要实施组件测试,这取决于你;这些测试可以帮助减少你需要编写的端到端测试的数量。我更喜欢编写更多的端到端测试而不是组件测试。组件测试仍然需要被采纳为行业标准;大多数公司只需要单元测试、端到端测试和集成测试。

总结来说,组件测试从用户的角度测试单个组件。组件测试确保组件在隔离状态下可以工作,但并不确保组件在应用程序的整个上下文中也能工作。组件测试比端到端测试更容易设置,因为它们不需要运行整个应用程序。你可以将组件测试视为单元测试和端到端测试之间的混合体。

现在你已经知道了组件测试是什么以及它们与端到端测试的不同,我们将通过解释集成测试来结束关于不同测试类型的这一部分。

理解集成测试

集成测试用于测试软件的不同模块和元素是否可以集成而不会出错。它们通常是你在将更改发布到生产之前进行的最终测试阶段。因此,单元测试专注于在隔离状态下测试单个代码单元,端到端测试模拟并测试特定应用程序库和模块的用户交互,而集成测试用于测试应用程序内各种模块和元素之间的交互。

集成测试可以在应用程序的不同集成级别中使用和编写。例如,你可以编写与单元测试相当的功能测试。然而,你测试的不是孤立组件或服务的代码实现,而是测试你的功能实现是否按预期工作,对于一组协同工作的组件或服务。你也可以从用户的角度编写集成测试,类似于端到端测试。当你从用户的角度编写集成测试时,你可以测试你的前端是否与 API 协同工作,或者你的部署应用程序是否由多个 Angular 应用程序组成。你还可以测试当所有内容都部署时,不同的应用程序是否可以协同工作。

当你从用户的角度创建集成测试时,你通常会在一个与生产环境相似的环境中运行测试。你使用实际部署的应用程序和真实的 API 以及数据进行测试。通过在部署的系统上进行测试,你可以测试你的软件的所有元素是否按预期协同工作,没有任何边界。

现在你已经了解了单元测试、端到端测试、组件测试和集成测试,是时候动手编写一些测试了。我们将跳过集成测试,因为我们没有大型系统或包含不同集成元素的部署版本。我们将从使用 Jest 测试框架为我们的 Angular 应用程序编写和运行单元测试开始。编写完单元测试后,我们将使用 Cypress 测试框架编写端到端测试来结束本章。

使用 Jest 对 Angular 应用程序进行单元测试

当你将 Angular 项目添加到你的 Nx 单一代码仓库中时,应用程序默认设置为使用 Jest 作为测试运行器。Jest 是一个常用的测试框架,通常用于编写和运行基于 JavaScript 和 TypeScript 的应用程序的自动化单元测试。本节将为你提供使用 Jest 为你的 Angular 应用程序编写单元测试的实践经验。在你开始编写测试之前,让我们扩展 Nx 提供的默认配置,以使你的测试体验更佳。

设置覆盖率阈值

在 Jest 配置中,你首先想要添加的是单元测试应该覆盖的最小行、函数和分支的覆盖率阈值。常用的百分比是 80%,但你可以将覆盖率百分比设置为你和你的团队认为足够的任何值,以确保新的更改不会破坏现有代码。你可以在 Nx 单一代码仓库根目录下的 jest.preset.js 文件中添加测试覆盖率的全局配置。此外,你还可以在每个项目的根目录下的 jest.config.ts 文件中为每个项目设置特定的配置。我将在 Nx 单一代码仓库根目录下的 jest.preset.js 中添加以下配置:

coverageThreshold: {
  global: {
      lines: 80,
      functions: 80,
      branches: 80
  },
},
collectCoverage : true,
coverageReporters: [
  "cobertura",
  "lcov",
  "text",
]

上述配置确保所有分支、函数和行至少有 80% 的测试覆盖率。该配置还告诉 Jest 收集覆盖率结果并向你提供一个基于文本的覆盖率报告。在覆盖率报告中,你可以看到你的代码覆盖了多少,哪些行、函数和分支缺失,以及它们各自的页面行号。

现在你已经配置了测试覆盖率报告,是时候为 Transloco 添加一个测试模块了。

添加额外的配置

测试模块使得导入正确的配置来测试使用 Transloco 的组件变得容易。在你的 expenses-registration 项目根目录下,你可以创建一个 transloco-testing.module.ts 文件,并添加以下内容:

import { TranslocoTestingModule, TranslocoTestingOptions } from '@ngneat/transloco';
import en from '../assets/i18n/en.json';
import nl from '../assets/i18n/nl.json';
export function getTranslocoModule(options: TranslocoTestingOptions = {}) {
  return TranslocoTestingModule.forRoot({
    langs: { en, nl },
    translocoConfig: {
      availableLangs: ['en', 'nl'],
      defaultLang: 'en',
    },
    preloadLangs: true,
    ...options
  });
}

在前面的示例中,我们创建了一个 getTranslocoModule() 函数。这个函数将在我们的单元测试文件中使用,为测试设置添加必要的 Transloco 配置。它只是一个返回 TranslocoTestingModule 类的函数,该类由 Transloco 库提供。在文件顶部,我们导入包含我们翻译的两个 JSON 文件。如果你想无障碍地导入这两个 JSON 文件,你需要在你的 tsconfig.base.json 文件中添加以下配置:

"resolveJsonModule": true,
"esModuleInterop": true,

在添加上述配置和包含 getTranslocoModule() 函数的 transloco-testing.module.ts 文件之后,我们几乎完成了对 Nx 提供的默认 Jest 设置的补充。最后,我们需要更新 jest.config.ts 文件中的 transformIgnorePatterns 配置,如下所示:

transformIgnorePatterns: ['node_modules/?!(.*\\.mjs$|@ngneat)'],

当你更改 transformIgnorePatterns 配置时,你确保 Jest 不会开始抱怨 node_modules 文件夹中缺少导入和包。在你的 Nx monorepo 中,每个 jest.config.ts 文件中已经存在 transformIgnorePatterns 配置,但在许多情况下,你需要调整它们,否则你的测试可能会因为 node_modules 文件夹中的内容而失败。

这就是我们将进行的所有额外设置。你总是可以根据需要添加额外的配置。你可以在他们的官方文档中找到所有额外的 Jest 配置:jestjs.io/docs/configuration

现在你已经添加了测试 Transloco 所需的配置并获得了测试覆盖率报告,让我们开始编写和运行我们的 Angular 应用程序的单元测试。

编写和运行单元测试

你将单元测试写在为创建的资源创建的 .spec.ts 文件中。这些 .spec.ts 文件包含默认生成的单元测试。

让我们以 expenses-registration Angular 应用程序为例。当你生成应用程序时,Nx 为你生成了一个 AppComponent 类和一个 app.component.spec.ts 文件,其中包含为 AppComponent 生成的默认单元测试。此外,我们在 expenses-registration 应用程序 中创建了 ExpensesOverviewPageComponentExpensesApprovalPageComponent;对于这两个组件,Nx 也生成了 .spec.ts 文件。让我们从这些文件开始。

修复生成的 spec 文件

如果你现在在这些 spec 文件中运行测试,它们将会失败。测试会失败,因为我们自生成以来还没有修改过 spec 文件,但我们确实调整了组件类。所以在我们尝试运行测试之前,让我们逐个修复 spec 文件。我们还将编写一些新的测试并解释 Nx 为我们生成了什么。从 app.component.spec.ts 文件开始,让我们看看 Nx 在 spec 文件中生成了什么:

describe('AppComponent', () => {
  beforeEach(async () => { …… });
  it(‹should render title›, () => { …… });
  it(`should have as title ‹finance-expenses-registration›`, () => { …… });
});

Nx 为你生成了前面的代码。正如你所见,有 describe()beforeEach() 和两个 it() 函数。所有这些函数在其各自的回调函数中都有一些额外的代码,但我们现在将忽略这些代码。让我们首先解释 describe()beforeEach()it() 函数的用途:

  • describe(): describe() 函数用于将多个测试组合在一起并描述我们正在测试的元素。你向 describe() 函数提供两个参数:一个字符串,描述我们正在为哪些内容编写测试——在我们的例子中是 AppComponent——以及一个回调函数,我们将在这里编写具体的测试用例。

  • beforeEach(): beforeEach() 函数用于在每个测试之前执行特定的步骤,通常包括配置,如设置 TestBed、创建我们正在测试的组件、服务、管道或指令,以及我们在每个测试运行之前想要进行的任何其他配置。

  • it()it()函数定义了每个测试用例。一个it()函数接受两个参数:一个包含测试用例描述的字符串和一个包含测试逻辑的回调函数。

现在你已经知道了describe()beforeEach()it()函数是什么,让我们用反映当前应用组件状态的代码替换app.component.spec.ts文件中生成的代码。

定义我们的测试用例

在我们开始调整 spec 文件中的代码之前,让我们首先明确我们想要测试的内容。

如果你查看你的AppComponent类,你会找到两个属性:一个translationService和一个navItems属性。此外,在你的应用组件的 HTML 模板中,你会找到一个带有一些输入和当选择的语言改变时的输出的导航栏组件。正如我们之前提到的,在编写单元测试时,你想要单独测试单个代码单元——在这种情况下,我们的应用组件。那么,与应用组件相关的功能有哪些?

  • 定义组件类属性。

  • 在导航栏的translationService属性上调用setActiveLanguage方法以触发languageChange事件。

上述提到的点是与应用组件相关的唯一组件逻辑;检查导航栏输入是否被正确处理以及导航栏是否正确渲染的逻辑是与导航栏相关的,应该在导航栏组件的 spec 文件中进行测试。如果我们要在应用组件的 spec 文件中检查这些内容,我们就会在测试导航栏和应用组件是否正确集成。出于同样的原因,我们不会检查在调用方法后TranslationService是否实际上调整了活动语言。这将测试应用组件和TranslationService之间的集成。从应用组件的角度来看,我们只对应用组件是否实际上调用了该函数感兴趣。现在我们知道了我们将要测试的内容,让我们定义我们将在 spec 文件中创建的it()语句:

  • 它们应该创建组件并使用预期值设置组件属性。

  • languageChange事件被触发时,它们应该调用setActiveLanguage方法。

既然我们已经定义了app.component.spec.ts文件中即将定义的it()语句,让我们一步一步地调整文件,以便我们能够成功测试这些语句。

调整 spec 文件中的代码

我们定义了我们想要编写的测试用例,并了解了 spec 文件中的三个主要函数。现在,让我们编写我们的测试用例,并学习如何配置测试模块,为测试用例进行断言,并实际运行测试。

describe()函数

我们将首先移除describe()函数内部的所有代码,这样我们就可以从头开始。describe()函数本身可以保持它为您生成的样子。在移除生成的代码后,首先在describe()函数内部定义三个属性:

let component: AppComponent;
let fixture: ComponentFixture<AppComponent>;
const mockTranslationService = {
  setActiveLanguage: jest.fn(),
  getLanguages: jest.fn().mockReturnValue([]),
};

如您在前面的代码片段中所见,我们在describe()函数内部添加了componentfixturemockTranslationService属性。component变量将保存我们AppComponent类的实例,fixture将是一个包含测试框架的元素,可以用来调试和与组件(类、原生元素、元素引用、生命周期方法等)交互,而mockTranslationService将作为我们在组件内部使用的TranslationService注入值的替代。我们使用这个TranslationService的模拟版本来简化我们在 spec 文件内部需要做的设置。因为我们不想测试组件和TranslationService之间的集成,我们希望单独测试组件。在定义了这三个属性之后,就是时候添加beforeEach()函数了。

beforeEach()函数

beforeEach()函数将添加在我们刚才添加的三个属性下面,并用于在每次测试之前配置TestBed和分配我们的componentfixture属性。让我们简单地定义beforeEach()方法本身:

beforeEach(async () => {});

现在在beforeEach()函数的回调内部,我们首先使用TestBed.configureTestingModule()方法配置测试模块。测试模块需要创建我们的组件所需的一切:

await TestBed.configureTestingModule({
  imports: [AppComponent, RouterTestingModule, getTranslocoModule()],
  providers: [{
      provide: TranslationService,
      useValue: mockTranslationService,
  }]
}).compileComponents();

如您在先前的代码中看到的那样,我们需要导入三个类并定义一个提供者来配置测试模块。您需要导入AppComponent类,因为AppComponent是一个独立组件,RouterTestingModule因为我们在组件模板内部使用了RouterOutlet,以及使用我们在本章的添加额外配置部分定义的getTranslocoModule()函数导入TranslocoTestingModule。除了导入之外,您还需要为TranslationService创建一个提供者,以便在测试期间组件使用mockTranslationService。在configureTestingModule()方法的末尾,您需要调用compileComponents()方法,这样 Jest 就会编译我们在测试模块配置内部定义的所有内容。

在配置好TestBed测试模块之后,我们将分配componentfixture属性。fixture属性将通过TestBed.createComponent()方法分配。在TestBed上调用createComponent()函数将冻结当前的TestBed类,这意味着您不能再调用TestBed配置方法了。它还将返回一个测试框架,可以用来与测试用例中创建的组件交互:

fixture = TestBed.createComponent(AppComponent);

在分配fixture之后,您使用fixturecomponentInstance属性来分配component变量。这个componentInstance属性是一个包含您正在测试的组件的所有属性和函数的对象——在我们的例子中是AppComponent

component = fixture.componentInstance;

最后,您需要在fixture上调用detectChanges()方法,以便为创建的应用组件运行变更检测:

fixture.detectChanges();

现在您已经定义了beforeEach()函数并配置了TestBed,我们可以开始我们的第一个it()函数并定义第一个测试用例。

第一个it()函数和测试用例

您可以在beforeEach()函数下方定义您的it()函数。在我们的例子中,第一个测试用例应该测试组件是否成功创建,以及navItemstranslationService属性是否正确分配:

it('should create the component and set the component properties with the expected values', () => {
  expect(component).toBeDefined();
  expect(component.navItems).toEqual([{ label: 'expenses approval', route: '/expenses-approval' }]);
  expect(component[‹translationService›]).toEqual(mockTranslationService);
});

如您在前面的代码中看到的,我们首先使用it()函数,并向该函数提供一个描述。然后,在回调函数中,我们使用expect()函数结合断言方法来评估我们想要测试的内容。您向expect()方法提供想要测试的值,并期望它为或不是某个值。在我们的例子中,我们首先期望组件属性(在beforeEach()函数内部没有分配组件实例)被定义。接下来,我们期望组件的navItems属性等于我们在组件类内部为navItems属性定义的对象。最后,我们期望translationService等于mockTranslationService

现在您已经定义了beforeEach()函数,配置了TestBed,在beforeEach()函数内部创建了组件,并编写了您的第一个测试用例。您可以在app.component.spec.ts文件中运行测试。您通过在 Nx 单仓库的根目录中运行以下命令来运行单元测试:

npx nx run <project-name>:test

在前面的终端命令中,您需要将<project-name>占位符替换为您想要运行测试的实际项目名称。您可以在每个应用程序或库的 Nx 单仓库中的project.json文件中找到项目名称。要为特定文件运行单元测试,请在命令末尾添加–test-file标志。例如,要为我们的app.component.spec.ts文件运行单元测试,您运行以下命令:

npx nx run finance-expenses-registration:test --test-file=app.component.spec.ts

上述命令将在app.component.spec.ts文件中运行测试。在运行测试后,您会注意到您的测试用例失败,并显示以下错误消息:NavbarComponent和 Jest 不支持信号输入(截至编写时)。

作为一种解决方案,你可以创建一个简化的NavbarComponent副本,用于单元测试在其模板中使用NavbarComponent的组件。这样的副本通常被称为AppComponent的功能,而不是将其与NavbarComponent集成。你可以在common-components库中的navbar文件夹内创建 navbar 占位组件,通过添加一个包含以下内容的navbar.component.stub.ts文件来实现:

@Component({
  selector: "bt-libs-navbar",
  standalone: true,
  template: ‹›,
})
export class StubNavbarComponent {
  @Input() navbarItems = [];
  @Input() languages = [];
  @Output() languageChange = new EventEmitter();
}

创建占位组件后,在common-components库的index.ts文件中导出它,这样你就可以在 spec 文件中访问占位组件。现在,在app.component.spec.ts文件的beforeEach()函数中,你可以确保在测试期间 app 组件使用的是 stub navbar 组件而不是常规的 navbar 组件。你可以通过使用TestBed.overrideComponent()方法更改 navbar 组件的导入为 stub navbar 组件来实现这一点。你需要简单地移除NavbarComponent的导入并添加StubNavbarComponent的导入:

TestBed.overrideComponent(AppComponent, {
  add: {
    imports: [StubNavbarComponent],
  },
  remove: {
    imports: [NavbarComponent],
  },
});

如前述代码所示,我们从AppComponent中移除了NavbarComponent的导入,并添加了StubNavbarComponent。在调用TestBed.createComponent()方法并冻结TestBed之前覆盖组件导入是很重要的;否则,你的覆盖将不会包含在TestBed中。

在这种情况下,使用占位组件和服务可能会有所帮助,例如,Jest 仍然需要添加对特定功能的支持。此外,占位确保你正在进行单元测试,而不是集成测试,专注于代码的独立单元。如果你想对 navbar 进行单元测试,例如,你应该在 navbar 组件的 spec 文件中进行,而不是在 app 组件的 spec 文件中进行。此外,使用占位组件可以简化你在beforeEach()方法中需要的设置,以确保 Jest 可以创建你想要进行单元测试的组件或服务。

假设你在添加了 stub navbar 组件后重新运行 app 组件的单元测试。在这种情况下,你会看到我们在 spec 文件中定义的测试用例是通过的,这意味着测试成功创建了componentInstance。然而,测试运行失败,因为我们没有达到配置的 80%覆盖率要求。如果你查看终端中的覆盖率报告,你会看到你的app.component.tsapp.component.html文件的覆盖率是 100%,但你的translation.service.ts文件的覆盖率是 0%,使得总测试覆盖率低于所需的 80%。

那么,为什么测试覆盖率包括translation.service.ts,你应该关心吗?translation.service.ts文件被包含在你的覆盖率报告中,因为默认情况下,Jest(和其他测试运行器)将包括你测试类中导入和使用的所有文件——在这个例子中,是AppComponent类。你应该关心吗?你需要修复覆盖率百分比中的不足吗?

答案取决于你如何运行测试。如果你正在为单个文件运行测试,就像我们现在这样,你不必关心,只需关注与你要测试的单元相关的文件——在我们的例子中,是应用组件。毕竟,你想要编写浅层单元测试,只测试单个代码单元,所以如果你对与你要测试的单元相关的文件有 80%或更高的测试覆盖率,那就很好!然而,如果你通过省略–test-file标志来为整个项目运行单元测试,你应该关心覆盖率百分比。对于你的整个项目,你应该有足够的覆盖率。在这个例子中,与translation.service.ts文件相关的代码应该在translation.service.spec.ts文件中进行测试。如果你为整个项目运行单元测试,并在translation.service.spec.ts文件中覆盖了translation.service.ts文件的逻辑,你的覆盖率报告中就不会有缺口,测试运行将成功。现在我们已经澄清了这一点,让我们为app.component.spec.ts添加第二个it()函数。

第二个it()函数

尽管我们对与我们的应用组件相关的文件实现了 100%的测试覆盖率,但我们的测试可能无法给我们提供所需的对一切按预期工作的信心。我们没有测试当导航栏发出languageChange事件时,应用组件类是否调用setActiveLanguage方法,因此让我们为这个功能添加一个测试。你可以添加以下代码来测试当应用组件接收到languageChange事件时是否调用setActiveLanguage方法:

it('should call the setActiveLanguage method when the languageChange event is emitted', () => {
  const setActiveLanguage = jest.spyOn(component['translationService'], 'setActiveLanguage');
  const navbarElement = fixture.debugElement.query(By.directive(StubNavbarComponent));
  navbarElement.triggerEventHandler('languageChange', 'nl');
  expect(setActiveLanguage).toHaveBeenCalledWith('nl');
});

在前面的代码中,发生了很多事情,所以让我们仔细检查每一行。

首先,我们定义it()函数,并为其提供测试用例的描述。在回调函数内部,我们首先创建一个间谍元素。

translationService属性的setActiveLanguage方法被调用。通过使用jest.spyOn()函数创建间谍对象。在jest.spyOn()函数内部,首先提供包含你想要监视的函数的对象,然后,作为一个字符串,提供你想要监视的函数名。

创建间谍对象后,我们使用fixturedebugElement来访问 HTML 模板中的StubNavbarComponent,并将其保存在名为navBarElement的常量中。接下来,我们在navBarElement上使用triggerEventHandler方法来触发languageChange事件,并提供nl作为事件数据。

触发languageChange事件后,我们期望setActiveLanguage()方法使用nl参数被调用。我们通过向expect()函数提供setActiveLanguage间谍对象,并在expect()函数上调用toHaveBeenCalledWith('nl')断言方法来检查这是否正确。

在将第二个测试用例添加到app.component.spec.ts文件后,你可以再次运行测试,你会注意到两个测试用例都成功了。

总结一下,您了解到describe()函数用于分组测试用例,beforeEach()函数用于配置TestBed并在每个测试用例运行之前定义值。it()函数用于定义测试用例,在it()函数内部,您使用expect()函数结合断言方法来执行测试语句。您可以通过创建和使用间谍对象来验证函数是否被调用。在编写单元测试时,您应该编写浅层单元测试,专注于单个代码单元,而不是测试不同组件和服务的集成。您可以通过创建存根组件和服务来编写浅层单元测试,并防止因不支持的功能而产生问题,这些存根是用于单元测试中的简化副本。

现在您对单元测试有了更好的理解,并且已经创建了您的第一个测试,我们将修复expenses-registration组件的额外 spec 文件,以便我们可以成功运行应用测试。此外,您还将学习如何在 Nx monorepo 中运行多个项目的单元测试。

为支出登记应用添加额外的单元测试

现在,我们将开始编写一些额外的单元测试,以便您可以在不指定–test-file标志的情况下成功运行整个应用的单元测试。首先,我们将移除expenses-approval-page.component.spec.ts文件,因为我们还没有在支出审批组件中添加任何代码。在移除expenses-approval-page.component.spec.ts之后,我们将调整expenses-overview-page.component.spec.ts中的测试。我们对ExpensesOverviewPageComponent类进行了相当多的调整,因此修复相关的 spec 文件将比修复AppComponent的 spec 文件要费更多功夫。

让我们从以下命令开始运行单元测试,看看会出现什么情况:

npx nx run finance-expenses-registration:test --test-file=expenses-overview-page.component.spec.ts

测试运行失败可能并不令人意外。让我们逐个修复 spec 文件中的问题,从 spec 文件中导入ExpensesOverviewPageComponent开始。

调整其他 spec 文件中的代码

由于我们将ExpensesOverviewPageComponent的导出更改为默认导出,我们还需要调整 spec 文件中的导入:

import ExpensesOverviewPageComponent from './expenses-overview-page.component';

在更改import语句后,您需要调整 spec 文件,以便TestBed能够成功创建expenses-overview组件。就像 app 组件一样,expenses-overview组件使用TranslationService,因此我们将为这个服务创建一个模拟对象(或者,您也可以为它创建一个存根服务,并在 app 组件的 spec 文件中使用它):

const mockTranslationService = {
  translocoService: { translate: jest.fn() },
  translationsLoaded: signal(false) as WritableSignal<boolean>,
};

如前述代码所示,此规格文件的mockTranslationService与为应用程序组件的规格文件创建的mockTranslationService类不同。模拟对象的不同是因为我们只在我们即将测试的组件中包含我们需要的部分在模拟对象中;在这种情况下,expenses-overview组件只使用服务的translocoServicetranslationsLoaded属性。除了mockTranslationService之外,我们还需要为ExpensesFacade提供一个存根。您可以从本书的 GitHub 仓库中复制StubExpensesFacadeexpenses.facade.stub.ts文件位于 finance data-access库中的常规expenses.facade.ts文件旁边。在创建了我们的单元测试所需的模拟和存根对象之后,我们可以创建beforeEach()函数并设置TestBed

beforeEach(async () => {
  await TestBed.configureTestingModule({
    imports: [ExpensesOverviewPageComponent, getTranslocoModule()],
    providers: [
      { provide: ExpensesFacade, useClass: StubExpensesFacade },
      { provide: TranslationService, useValue: mockTranslationService, },
      provideTranslocoLocale({
        langToLocaleMapping: { en: 'en-US', nl: 'nl-NL' }
      })
    ]
  }).compileComponents();
  fixture = TestBed.createComponent(ExpensesOverviewPageComponent);
  component = fixture.componentInstance;
  fixture.detectChanges();
});

如前述代码所示,我们使用getTranslocoModule()函数导入了ExpensesOverviewPageComponentTranslocoTestingModule。在测试模块的导入之后,我们添加了一些需要配置测试模块的提供者。我们使用模拟和存根值提供ExpensesFacadeTranslationService,并提供了TranslocoLocale配置,因为我们使用本地化管道在expenses-overview页面中。在配置了测试模块的导入和提供者之后,我们创建了fixturecomponent属性,并在fixture上调用detectChanges()。现在我们已经配置了在TestBed内部创建组件所需的所有内容,让我们移除所有it()函数并编写我们自己的测试用例。

编写测试用例

我们将创建以下测试用例:

  • 测试应该正确创建组件并初始化属性。

  • 它应该在init时获取费用。

  • 如果已加载翻译,它应该翻译标题。

  • 如果调用onSummaryChange,它应该更改summaryBtnText

  • 当调用onAddExpense时,它应该在费用外观上使用正确的值调用addExpense

现在我们已经定义了我们的测试用例,让我们逐个创建它们:

it('should create the component and initialize the properties correctly', () => {
  expect(component).toBeTruthy();
  expect(component[‹expensesFacade›]).toBeInstanceOf(StubExpensesFacade);
  expect(component[‹translationService›]).toEqual(mockTranslationService);
  expect(component.translationEventsEffect).toBeDefined();
  expect(component.expenses()).toEqual(component[‹expensesFacade›].expenses());
  expect(component.showAddExpenseModal()).toBeFalsy();
  expect(component.showSummary()).toBeFalsy();
  expect(component.summaryBtnText()).toEqual('Show summary');
});

如前述代码所示,这个测试非常直接;我们只是简单地检查组件变量是否已定义,以及每个组件属性是否已用我们期望的值初始化。如您可能已注意到的,我们在这里使用了一些新的断言方法,例如toBeFalsy()toBeInstanceOf()。这些断言方法可以用来检查在布尔上下文中一个值是否为假,以及一个对象是否是特定类的实例。除了toBeFalsy()toBeInstanceOf()之外,在这个测试中我们没有做任何新的操作,所以让我们继续到下一个测试用例:

it('should fetch expenses on init', () => {
  const fetchExpenses = jest.spyOn(component['expensesFacade'], 'fetchExpenses');
  component.ngOnInit();
  expect(fetchExpenses).toHaveBeenCalled();
});

在先前的测试中,我们创建了一个间谍对象来监视ExpensesFacadefetchExpenses()函数。之后,我们调用我们正在测试的组件的ngOnInit()方法——在这种情况下,是expenses-overview页面组件——并在测试的末尾使用expect()函数来检查fetchExpenses()函数是否被调用。如您所见,调用我们正在测试的组件中声明的方法非常简单;您只需使用component变量并调用您想要运行的方法。现在我们已经覆盖了这个测试,让我们继续进行下一个测试用例:

it('should translate title if translations are loaded', fakeAsync(() => {
  const translateSpy = jest.spyOn(component['translationService'].translocoService, 'translate');
  expect(component[‹translationService›].translationsLoaded()).toBeFalsy();
  expect(translateSpy).not.toHaveBeenCalled();
  mockTranslationService.translationsLoaded.set(true);
  tick();
  expect(translateSpy).toHaveBeenCalledWith(‹expenses_overview_page.title›);
}));

在先前的测试中,发生了一些更多的事情,我们使用了一些新技术。让我们更详细地探索我们在这里做了什么。因为在这个测试中我们正在测试信号效果,而信号效果是异步的,所以我们把it()函数的回调包裹在fakeAsync()函数内部。在fakeAsync()函数内部,时间是同步的。你可以通过调用flushMicroTasks()手动执行微任务,并通过tick()函数模拟时间的流逝。在使用fakeAsync()函数之后,我们首先定义一个间谍对象。然后,我们检查translationsLoaded信号是否有假值,以及我们用于信号效果的translate()函数没有被调用。接下来,我们将translationsLoaded信号值设置为true。这应该会再次触发信号效果,这次我们应该到达使用translate()函数的部分。因为信号效果是异步的,我们首先调用tick()函数来模拟时间的流逝,然后检查我们的间谍对象是否被带有正确翻译键的调用。

现在我们已经解释了在上一个测试用例中使用的fakeAsync()tick()函数,让我们继续并添加下一个测试用例:

it('should change the summaryBtnText if onSummaryChange is called', () => {
  expect(component.showSummary()).toBeFalsy();
  expect(component.summaryBtnText()).toEqual('Show summary');
  component.onSummaryChange();
  expect(component.showSummary()).toBeTruthy();
  expect(component.summaryBtnText()).toEqual('Hide summary');
});

如您在前面的代码中所见,这是一个简单的测试。我们首先检查showSummary信号是否为假,以及summaryBtnText计算信号返回onSummaryChange()函数,并检查showSummary信号和summaryBtnText计算信号是否调整正确。在添加前面的测试之后,我们只需要将一个测试用例添加到我们的规范文件中:

it('should call addExpense on the expenses facade with the correct values when onAddExpense is called', () => {
  const addExpense = jest.spyOn(component['expensesFacade'], 'addExpense');
  const expenseToAdd = { description: 'test', amount: { value: 50, vatPercentage: 20 }, date: '2019-01-04', tags: ['printer'], id: 999 };
  component.onAddExpense(expenseToAdd);
  expect(addExpense).toHaveBeenCalledWith(expenseToAdd);
  expect(component.expenses().expenses).toContainEqual(expenseToAdd);
});

在前面的测试中,我们首先创建了一个 spy 对象来监视ExpensesFacadeaddExpense()函数。在创建 spy 对象后,我们创建了一个expense对象以提供给onAddExpense()方法。在创建expense后,我们调用onAddExpense()方法并向其提供expenseToAdd属性。在调用onAddExpense()方法后,门面中的addExpense()函数应该以expenseToAdd属性作为函数参数被调用。我们使用toHaveBeenCalledWith()断言方法验证addExpense()函数是否以正确的参数被调用。最后,我们使用toContainEqual()断言方法来检查expense是否被添加到expenses-overview页面组件的expenses信号中。

在添加最后一个测试用例后,你可以使用以下命令再次运行测试:

npx nx run finance-expenses-registration:test --test-file=expenses-overview-page.component.spec.ts

在再次运行测试后,你会发现所有测试都通过了,并且与expenses-overview组件相关的文件覆盖率达到了 100%。你可以编写一些额外的测试来测试模板,但这也将在下一节中我们将编写的端到端测试中得到覆盖。现在你已经知道了如何运行单个 spec 文件的单元测试,让我们来看看如何在 Nx 单仓库中运行一个或多个项目的单元测试。

运行一个或多个项目的单元测试

我们逐个运行了 spec 文件的单元测试,所以现在让我们运行整个finance-expenses-registration项目的单元测试。当你为一个整个项目运行单元测试时,它将在该 Nx 项目中找到的所有 spec 文件中运行测试。请注意,这不会包括你在项目中使用的任何库项目。例如,要运行finance-expenses-registration项目中的所有 spec 文件的测试,你使用以下终端命令:

npx nx run finance-expenses-registration:test

当你运行前面的命令时,你会注意到测试运行失败,因为我们没有达到 80%的覆盖率阈值。这是因为我们没有测试translation.service.ts文件。作为一个练习,你可以自己创建TranslationService的 spec 文件;或者,你也可以降低覆盖率阈值。

除了为单个项目运行单元测试外,你还可以使用run-many命令同时运行多个项目的单元测试。当使用不带任何额外参数的run-many命令时,你将为在您的整个 Nx 单仓库中找到的项目运行单元测试:

nx run-many -t test

此外,你可以在终端命令的末尾添加特定的项目名称,以仅运行特定项目的单元测试:

nx run-many -t test -p proj1 proj2

你也可以使用--exclude标志运行所有项目的单元测试,并排除特定项目:

nx run-many -t test --exclude excluded-app

最后,你可以使用 affected 终端命令。affected 命令可以用来运行受你更改影响的所有项目的单元测试。Nx 会查看其缓存并检查自上次运行单元测试以来哪些项目已更改。在最新缓存的测试运行之后所做的更改受影响的项目将在使用 affected 命令时运行。当你在构建管道上运行单元测试并希望在每次合并代码时测试你的代码时,affected 命令特别有用:

nx affected -t test

总结一下,你可以为单个 spec 文件、一个或多个 Nx 项目,或受你更改影响的项目运行测试。单元测试旨在测试隔离的单元,并应让你有信心你的更改不会破坏现有的代码实现。单元测试由三个主要部分组成,即 describe()beforeEach()it() 函数,并在你的测试用例中使用 expect() 函数进行断言。

现在你已经知道了如何编写和运行单元测试,是时候更深入地探讨 e2e 测试的话题了。

使用 Cypress 对 Angular 应用程序进行端到端测试

当你使用 Nx CLI 或 Nx 控制台创建应用程序时,为你创建了两个项目:一个常规应用程序(在我们的例子中是一个 Angular 应用程序)和一个配置为使用 Cypress 测试框架测试生成应用程序项目的 e2e 项目。例如,当我们创建了 expenses-registration 项目时,Nx 也创建了一个 expenses-registration-e2e 项目。expenses-registration-e2e 项目的文件夹位于 expenses-registration 项目文件夹旁边。

在我们开始编写自己的 e2e 测试之前,让我们看看 Nx 在 expenses-registration-e2e 文件夹内为我们生成了什么。当你打开 expenses-registration-e2e 文件夹时,你会找到一些文件夹和四个文件。.eslintrc.jsoncypress.config.tsproject.jsontsconfig.json 文件都是用来配置 Cypress 和 e2e 项目的。我们想在 tsconfig.json 文件中调整一个小东西;你可以不修改其他文件。在这个 tsconfig.json 文件中,你会找到一个 include 数组;在这个 include 数组中,添加以下字符串:

"src/**/*.cy.ts"

除了 Nx 创建的配置之外,你还需要在 Nx monorepo 根目录下的 .eslintrc.json 文件中添加一个小东西。在你的 Nx monorepo 根目录下的 .eslintrc.json 文件中,你会找到一个 project 数组;在这个数组中,添加以下值:

"apps/*/*/tsconfig.json"

如果没有上述两个配置的添加,你将遇到一些 ESLint 解析错误。添加了额外的配置后,让我们看看 Nx 在 expenses-registration-e2e 项目中又生成了什么。

你会在 expenses-registration-e2e 文件夹内看到一个 cypress 文件夹和一个 src 文件夹。cypress 文件夹可以忽略;在 src 文件夹内,你会找到 e2efixturessupport 文件夹,它们具有以下用途:

  • e2e:在e2e文件夹中,您将添加包含您的端到端测试的文件。Nx 已经在这个文件夹中为您生成了一个app.cy.ts文件。如您所见,文件名以cy.ts结尾。这是包含您的 Cypress 端到端测试的文件的命名约定。文件名末尾的cy是 Cypress 的简称。

  • fixtures:在fixture文件夹中,您可以添加包含您在端到端测试中想要使用的模拟数据的 JSON 文件。使用固定数据在您想要为端到端测试使用特定数据时很有用。此外,您在端到端测试期间通常不会有 API 或模拟服务可以使用或想要使用。在端到端测试中添加模拟服务或 API 通常需要在您的本地环境中以及您想要运行端到端测试的管道中进行大量的额外设置。除了额外的设置外,使用真实的 API 或与您的开发环境相同的模拟服务可能会导致您的端到端测试更加不稳定。

  • support:在support文件夹中,您将找到编写和运行您的端到端测试所需的一切。您放在support文件夹中的某些东西包括一个包含您在端到端项目中使用的所有导入的文件,一个包含在您的.cy.ts文件中使用的页面对象的文件,额外的设置文件或包含自定义 Cypress 命令的文件。

现在您已经了解了 Nx 为您生成的以及您端到端项目中文件和文件夹的用途,让我们开始为expenses-registration项目编写和运行端到端测试。

编写您的第一个端到端测试

首先,删除app.cy.ts文件,并用expenses-registration.cy.ts文件替换它。在这个expenses-registration.cy.ts文件中,我们将编写将要测试*expenses-registration 应用程序*的端到端测试。就像我们处理单元测试一样,我们定义一个describe()函数。您使用describe()函数来分组多个端到端测试,类似于我们用于单元测试的describe()函数。describe()函数接受两个参数:一个描述和一个回调函数:

describe('finance-expenses-registration', () => {});

现在,在describe()函数的回调函数内部,我们将添加一个beforeEach()函数。在beforeEach()函数内部,您可以定义在每次端到端测试之前要执行的步骤。在beforeEach()函数内部定义的一些常见步骤包括访问应用程序的 URL、设置拦截器、以用户身份登录以及关闭 cookie 同意消息。在我们的例子中,我们只访问应用程序的 URL,稍后,我们将创建一个拦截器来演示您如何使用固定数据提供模拟数据:

beforeEach(() => {
  cy.visit('');
});

在前面的代码中,我们定义了beforeEach()函数,并在回调函数内部使用cy对象上的visit()方法访问了我们的应用程序的基本 URL。cy对象是由 Cypress 框架提供的全局辅助对象,用于各种操作,例如访问页面、访问页面和窗口对象、响应事件、设置拦截器和等待请求。在前面的示例中,我们使用了visit()方法。我们向其提供了一个空字符串作为函数参数,以指示 Cypress 应访问我们应用程序的基本 URL。

在定义了beforeEach()函数并访问了应用程序的基本 URL 之后,让我们添加我们的第一个简单的端到端(e2e)测试。与单元测试一样,您的测试用例是通过it()函数定义的。与单元测试类似,您的it()函数接收一个描述和一个回调函数。在回调函数内部,您编写测试用例的代码。让我们从简单开始,编写一个端到端测试来检查当访问基本 URL 时,应用程序是否重定向到expenses-overview路由:

it('should redirect to the expenses-overview page when we load the root application route', () => {
  cy.url().should(‹equal', 'http://localhost:4200/expenses-overview');
});

如前述代码所示,我们描述了测试用例,然后在it()函数的回调函数内部编写了我们的测试用例逻辑。对于先前的测试用例,我们只需要一行逻辑。beforeEach()函数将在基本 URL 上打开应用程序。当我们打开基本 URL 上的应用程序时,我们应该被重定向到expenses-overview路由,因此当我们到达it()函数时,应用程序应该被重定向到expenses-overview路由。在这个测试用例中,您只需断言当前 URL 是否等于http://localhost:4200/expenses-overview。在您的 Cypress 测试中,您通常获取一个页面或窗口元素,并与它们交互或断言文本、CSS 类或属性;在这种情况下,您获取浏览器 URL 并断言 URL 是否等于您期望的文本。

cy对象默认暴露了大多数窗口对象;如果您想从应用程序的 HTML 结构中访问元素,您可以使用cy对象上的.get()方法。在我们的示例中,我们感兴趣的是 URL,它位于window对象的location.href属性中。cy对象默认通过.URL()方法暴露了location.href属性。

在您获取要断言的元素之后,您可以通过链式.should()断言方法来执行您想要的断言。.should()方法接受两个参数:一个断言类型和一个用于执行断言的值。在我们的情况下,我们向.should()方法提供了equal断言类型,并提供了http://localhost:4200/expenses-overview值来检查我们提供的值是否等于我们想要断言的元素——在这个例子中,是 URL。

您可以在官方 Cypress 文档中找到所有断言类型的列表:docs.cypress.io/guides/references/assertions.

现在我们已经编写了第一个端到端测试并解释了所有工作的原理,让我们运行端到端测试并看看测试是否会成功。您可以通过在您的 Nx 单一代码库的根目录下运行以下终端命令来开始您的端到端测试:

nx e2e <project-name>

在前面的命令中,您需要将 <项目名称> 替换为 project.json 文件中找到的名称。就像单元测试一样,您也可以使用 run-many 命令运行多个项目的端到端测试,或者使用 affected 命令运行受影响的项目。

当运行上述命令之一时,端到端测试将以无头模式运行,这意味着不会打开浏览器来执行您的端到端测试。如果您想在构建管道或没有浏览器访问权限的其他环境中运行测试,无头模式运行端到端测试是理想的。

在开发过程中看到 Cypress 在真实浏览器中执行测试是非常好的。当 Cypress 在真实浏览器中执行测试时,您可以更好地理解测试失败的原因。有一个友好的用户界面,让您可以轻松地找到失败的测试,并导航到它们失败的具体步骤。要在一个真实浏览器中运行端到端测试,您可以使用以下命令:

nx e2e <project-name> --watch

因此,让我们将 <项目名称> 占位符更改为 finance-expenses-registration-e2e 并运行我们创建的测试:

nx e2e finance-expenses-registration-e2e --watch

图 11.1 所示,当您运行前面的终端命令时,Cypress UI 将启动并提示您选择一个浏览器:

图 11.1:Cypress UI 启动屏幕

图 11.1:Cypress UI 启动屏幕

选择位于您的 e2e 项目的 e2e 文件夹中的 .cy.ts 文件;在我们的例子中,我们只有一个文件,即 expenses-registration.cy.ts 文件。当您点击文件名时,Cypress 将为该特定文件运行端到端测试。在 图 11.2 中,您可以查看 expenses-registration.cy.ts 文件的测试运行情况:

图 11.2:Cypress UI 测试运行屏幕

图 11.2:Cypress UI 测试运行屏幕

图 11.2 所示,在左侧,您可以查看正在执行的测试以及它们是否通过或失败,而在右侧,您可以查看应用程序以及 Cypress 在应用程序内部执行的操作。

总结一下,Nx 使用 Nx cli 或 Nx 控制台为每个生成的应用程序创建一个端到端项目。你在e2e文件夹内创建.cy.ts文件来定义你的测试用例。测试用例使用describe()函数和beforeEach()函数分组,后者可以用来在每次端到端测试之前执行逻辑。测试用例本身使用it()函数定义,并在你的it()函数的回调中定义测试逻辑。你可以使用cy对象获取元素,并使用.should()方法结合断言类型和值来断言。当你定义测试时,你可以在真实浏览器中执行它们,或者如果你想在无法访问浏览器的环境中运行它们,可以选择无头模式。

现在你已经学习了端到端测试的基础,创建了你的第一个测试,并使用 Cypress UI 运行你的测试,让我们添加一些额外的端到端测试来了解在端到端测试中常用的额外概念和模式。

定义端到端测试的页面对象

在端到端测试中,页面对象模式是一个常见的模式。当使用页面对象模式时,你将页面元素的选取从实际测试中抽象出来,从而使得测试更加可读和易于维护。为了演示页面对象模式,我们首先创建一个不使用页面对象模式的新的端到端测试,然后通过使用页面对象模式来调整这个新的测试。新的测试用例将检查显示摘要按钮是否默认显示,以及当我们点击按钮时,摘要是否会显示。此外,测试还会检查按钮文本是否更改为隐藏摘要,以及如果我们再次点击按钮,摘要是否会消失。

要创建这个测试用例,让我们首先定义it()函数,并为测试用例提供描述:

it('should toggle the summary and adjust the button text', () => {});

现在已经定义了it()函数,并提供了合适的描述,我们需要在it()函数的回调中添加测试逻辑。首先,我们需要获取用于切换摘要的按钮。如前所述,你可以使用cy.get()从你的应用程序中获取元素。你向.get()方法提供一个查询选择器;这些选择器与 jQuery 选择器工作方式相同:

cy.get('business-tools-monorepo-expenses-overview-page > div > div > div > button:nth-child(2)');

在前面的代码片段中,你可以看到我们使用了cy.get()并提供查询选择器来获取显示摘要按钮。如果你不熟悉 jQuery 选择器,你可以通过 Chrome 浏览器的DevTools复制选择器。简单地检查 HTML 页面,找到你想要在 Cypress 测试中使用的元素,在DevTools中右键点击该元素,选择复制|复制选择器。在图 11.3中,你可以看到你可以复制选择器的位置:

图 11.3:DevTools 复制选择器

图 11.3:DevTools 复制选择器

使用DevTools复制的选取器始终从 HTML 文档的根开始,可以通过删除选取器的前缀来简化。现在你知道了如何选择元素以便在测试中使用,让我们编写测试逻辑的其余部分:

it('should toggle the summary and adjust the button text', () => {
  const button = () => cy.get('business-tools-monorepo-expenses-overview-page > div > div > div > button:nth-child(2)');
  const summary = () => cy.get('table > tr.summary > td');
  button().should(‹contain›, ‹Show summary›);
  summary().should(‹not.exist');
  button().click();
  button().should(‹contain›, ‹Hide summary›);
  summary().should(‹exist›);
  button().click();
  button().should(‹contain›, ‹Show summary›);
  summary().should(‹not.exist');
});

在前面的代码中,我们首先定义了两个常量,一个用于button,另一个用于summary。正如你所见,我们使用了一个返回cy.get()方法的函数。直接将cy.get()的返回值赋给变量是一种反模式,因为在测试过程中你可以修改返回值。通常,当你获取按钮时,你希望获取一个未经修改的按钮,所以我们创建了一个返回cy.get()函数调用的函数,并将其赋值给我们的变量。

在定义了两个常量之后,我们检查按钮是否包含显示摘要文本,以及摘要元素是否尚未存在。之后,我们点击按钮,检查按钮文本是否更改为隐藏摘要,以及摘要元素是否存在。最后,我们再次点击按钮,看看是否一切都被切换回初始状态。

如果你现在运行你的 e2e 测试,你会发现测试成功。虽然这个测试没有问题,但我们还可以做一些事情来稍微清理一下。首先,我们可以通过在想要选择的 HTML 元素上添加data-test-id属性来简化选取器。data-test-id属性是一个简单的 HTML 属性,通常添加到你想要在 e2e 测试中使用的元素上。所以,让我们在expenses-overview-page.component.html文件中的按钮和摘要元素上添加这个属性:

<button data-test-id attribute to the button and provided it with a value of show-summary-btn. Next, we will do the same for the element where we show the summary:

通过使用data-test-id属性,你可以简化 e2e 测试内部使用的选取器。而不是使用用于选择按钮和摘要元素的长选取器,你可以使用以下语法:

cy.get('[data-test-id="show-summary-btn"]');
cy.get('[data-test-id="summary"]')

正如你所见,这大大简化了 HTML 元素的选取器。除了通过引入data-test-id属性简化选取器外,你还可以创建一个函数来检查按钮文本,并检查是否存在摘要元素。你不需要在测试内部重复三次。

现在我们已经定义了测试用例的逻辑,是时候改进它并将一些逻辑移动到页面对象文件中。首先,从支持文件夹中删除app.po.ts文件,并添加一个new expenses-overview.po.ts文件。正如你可能已经猜到的,.po.ts.page-object.ts的简称。在expenses-overview.po.ts文件中,你将定义所有用于expenses-overview页面 e2e 测试所需的元素逻辑。通过将元素选择抽象到页面对象文件中,你可以轻松地重用它们,使你的 e2e 测试更小、更容易阅读、编写和维护。目前,我们只有两个元素可以移动到页面对象文件中——摘要按钮和摘要元素:

export const showHideSummaryBtn = () => cy.get('[data-test-id="show-summary-btn"]');
export const summaryValue = () => cy.get('[data-test-id="summary"]');

在前面的代码片段中,您可以看到我们将测试用例内部定义的两个常量移动到了页面对象文件中,并导出它们以便在端到端测试中使用。我们还给这两个常量起了更具有描述性的名字。现在,在expenses-registration.cy.ts文件中,导入这两个常量,并调整端到端测试以使用导入的常量。如果您现在需要在另一个端到端测试中使用按钮或摘要类型,您可以直接使用页面对象文件中定义的,而不是重新定义获取元素的逻辑。

在页面对象文件中,我们还可以添加一个函数来检查切换摘要按钮文本和摘要本身的可见性:

export function summaryIsShwon(isShown: boolean) {
  showHideSummaryBtn().should('contain', isShown ? 'Hide summary' : 'Show summary');
  summaryValue().should(isShown ? 'exist' : 'not.exist');
}

在添加上述函数后,让我们更新端到端测试以使用该函数,并查看添加页面对象文件后的最终结果:

it('should toggle the summary and adjust the button text', () => {
  summaryIsShwon(false);
  showHideSummaryBtn().click();
  summaryIsShwon(true);
  showHideSummaryBtn().click();
  summaryIsShwon(false);
});

如您在前面的代码中看到的,现在我们使用了页面对象模式,测试变得更加容易理解,并且需要的代码行数更少;除此之外,代码在新的测试用例中更容易重用。

总结一下,您可以使用cy.get()方法和与 jQuery 选择器相同的选择器来选择元素。为了简化您的选择器,您可以使用data-test-id属性,并且通过使用页面对象模式,您可以将元素选择逻辑从测试用例中抽象出来,使测试更容易阅读、编写和维护。现在您已经更好地掌握了如何选择元素以及页面对象模式如何帮助您编写更好的端到端测试,让我们学习如何在端到端测试中拦截请求并使用固定数据中的模拟数据。

在端到端测试中使用固定数据

固定数据用于为您的端到端测试提供特定的模拟数据。使用模拟数据为您的端到端测试确保您有稳定的数据来运行测试。通常,您需要在没有访问 API 或模拟服务的环境中运行端到端测试;在这种情况下,您可以使用固定数据中定义的数据。另一个常见场景是您在测试或验收环境中运行端到端测试,而这些环境中的数据并不总是稳定的,可能会随时间变化,导致测试失败。因此,根据您的环境,固定数据可以为您提供额外的稳定性,确保测试不会因为数据而失败,而只会因为您实际上在应用程序代码中破坏了某些内容而失败。

让我们先以生产模式运行端到端测试,以展示为什么您需要固定数据。如果我们提供应用的生产构建版本,我们的mock.interceptor.ts文件将不会返回模拟数据。您可以使用以下终端命令以应用的生产构建版本运行端到端测试:

nx e2e finance-expenses-registration-e2e --watch --configuration=production

在运行前面的命令后,你会注意到在端到端测试期间应用程序没有数据可以显示。对于我们当前的测试用例,这没有问题,但当你有更多测试时,这很可能会导致一些测试失败。我们不必依赖于mock.interceptor.ts,可以使用固定装置在端到端测试期间提供数据。

要使用固定装置中的数据,你首先需要在fixtures文件夹内添加一个包含模拟数据的文件。我们将使用与我们的模拟拦截器相同的模拟数据,所以首先将assets/api文件夹中的expenses.json文件从你的费用注册应用程序复制到端到端项目的fixtures文件夹。

在复制expenses.json文件后,你需要调整expenses-registration.cy.ts文件中的beforeEach()函数。在beforeEach()函数内部,你需要设置一个拦截器来拦截我们获取费用的 API 请求,并向其提供一个来自你的固定装置的文件:

beforeEach(() => {
  cy.intercept('GET', '**/api/expenses', { fixture: 'expenses.json' }).as('getExpenses');
  cy.visit('');
  cy.wait('@getExpenses');
});

在前面的代码中,你可以看到我们使用cy.intercept()方法设置了拦截器。cy.intercept()方法首先接受一个字符串来定义你想要拦截的 API 请求类型;在我们的情况下,我们想要拦截一个GET请求。接下来,你需要提供你想要拦截的 API URL,最后,你需要提供一个对象,其中包含一个固定属性,分配给你想要用作拦截请求响应的固定文件。在cy.intercept()方法的末尾,我们链式调用.as()方法,并为其提供一个拦截器的别名;在这种情况下,我们使用了getExpenses

在设置拦截器之后,我们定义了cy.visit()方法来访问应用页面,就像我们之前做的那样。在cy.visit()方法之后,我们定义了cy.wait()方法,表示 Cypress 必须等待我们设置的拦截器。然后,向cy.wait()方法提供以@符号为前缀的拦截器别名。

前面的步骤涉及在端到端测试中使用固定文件进行模拟数据所需的所有内容。如果你需要为多个测试文件设置相同的拦截器和访问相同的页面,你可以将逻辑抽象成一个函数,并在beforeEach()回调内部调用该函数,这样你就不必多次重复。

你可以通过在生产环境中运行以下命令来测试拦截器和固定装置是否工作:

nx e2e finance-expenses-registration-e2e --watch --configuration=production

在运行前面的命令后,你会注意到在运行端到端测试时应用程序再次显示数据。

总结一下,你了解到固定装置可以在端到端测试期间提供模拟数据。使用模拟数据可以为你的测试提供额外的稳定性,并帮助你在一个无法访问 API 或模拟服务的环境中运行测试。你通过在测试的beforeEach()函数中设置拦截器并提供固定文件来使用固定装置。

摘要

在本章中,你学习了自动化应用程序测试。你了解到单元测试用于独立测试小的代码单元,以确保代码实现按预期工作。端到端测试从用户的角度测试应用程序,并检查是否正确显示了值,以及用户交互是否在应用程序视图中正确处理和渲染。组件测试是一个相对较新的概念,与端到端测试类似,但不同的是,组件测试不是编译和测试整个应用程序,而是从用户的角度测试单个组件。最后,集成测试用于检查软件的不同模块和元素如何集成在一起。集成测试可以在多个级别上实现,例如,检查在组合多个组件和服务时代码实现是否仍然工作,或者当将多个 Angular 应用程序和后端 API 作为单一产品提供给客户时,应用程序是否仍然工作。

在了解了不同类型的测试之后,你创建了属于自己的单元测试和端到端测试。你学习了describe()beforeEach()its()函数,以及它们如何用于端到端和单元测试。你学习了如何在端到端测试中使用模拟数据,以及在单元测试中模拟组件和服务。在单元测试中,使用expect()函数断言值,而在端到端测试中,你使用cy.should()方法。

最后,你学习了不同的终端命令来运行单个文件、单个项目、多个项目或受你更改影响的项目上的测试。在本书的下一章和最后一章中,你将添加最后的修饰,并学习部署 Angular 应用程序到你的 Nx 单仓库所需采取的不同步骤。


第十二章:部署 Angular 应用程序

在本章的最后部分,你将把我们所创建的演示应用程序部署到 GitHub Pages。你将学习如何在你的 Nx 单一代码库中检查和构建 Angular 应用程序和库。我们将探讨 Angular 和 Nx 在构建 Angular 应用程序时为你创建的内容,以及你部署到托管平台上的内容。当你知道如何构建你的 Angular 应用程序后,我们将检查应用程序的构建并分析构建中的不同包,以确定我们可以从哪里进行一些改进来减小应用程序包的大小。在分析应用程序包之后,我们首先将在本地托管我们的应用程序的生产构建。接下来,我们将深入研究 GitHub Pages 和 GitHub Actions 以创建自动部署流程。GitHub Pages 是一个静态网站托管服务,允许你免费托管你的静态网站和应用程序。GitHub Actions 用于设置持续集成和持续交付CI/CD)流程来自动化你的代码检查、测试和部署过程。你创建的 CI/CD 流程将在你将代码合并到主 GitHub 分支时自动在 Nx 单一代码库中检查、测试和部署你的 Angular 应用程序。

本章将涵盖以下主题:

  • 在你的 Nx 单一代码库中构建和检查 Angular 应用程序

  • 分析构建输出

  • 自动部署 Angular 应用程序

在你的 Nx 单一代码库中构建和检查 Angular 应用程序

本节将探讨在 Nx 单一代码库中构建和检查 Angular 应用程序的步骤。我们首先学习如何在你的 Nx 单一代码库中检查项目。接下来,我们将探索不同的代码检查过程配置以及如何应用它们。一旦你知道在创建构建之前如何以及为什么要检查你的项目,我们将学习如何在 Nx 单一代码库中构建项目。

此外,我们还将探讨 Nx 如何通过利用高级缓存和并行任务执行来优化构建时间来增强构建过程。Nx 的增量构建显著减少了大型和复杂项目的构建时间,确保只有应用程序的必要部分被重新构建。增量构建和 Nx 缓存将加速你的 CI/CD 管道和整体部署过程。让我们从开始学习如何在你的 Nx 单一代码库中检查项目代码检查。

检查 Nx 项目

代码检查是软件开发过程中的关键步骤。它涉及分析你的代码以查找潜在的错误、强制执行编码标准和维护代码质量。代码检查有助于在开发周期早期捕捉问题,降低出现错误的可能性,并确保你的代码库遵循最佳实践。

代码风格检查可以提高您代码的可维护性和可读性,并通过提供一致的编码风格来增强团队内部的协作。我们已经在第一章中讨论了代码风格检查。根据您的配置,您将在开发过程中通过 ESLint 获得关于代码风格检查错误的提示。然而,在代码合并或部署之前始终运行代码风格检查过程是很重要的。在 CI/CD 管道中运行代码风格检查过程确保不符合您配置的指南的代码不会被合并。通过将代码风格检查集成到您的管道中,您可以确保项目健壮、可靠且适用于生产,从而实现更高效和更无错误的构建。

与所有其他任务(如测试和构建)一样,您可以使用 Nx 控制台或通过运行终端命令来进行代码风格检查。

使用 Nx 控制台进行代码风格检查

如果您想使用 Nx 控制台对项目进行代码风格检查,请按照以下步骤操作:

  1. 在 VS Code 左侧点击 Nx 标志。

  2. 项目 选项卡下找到您想要进行代码风格检查的项目。

  3. 将鼠标悬停在 lint 上。

  4. 点击播放按钮:

图 12.1:Nx 控制台代码风格检查

图 12.1:Nx 控制台代码风格检查

图 12.1 展示了 Nx 控制台的视觉表示以及如何找到 lint 任务。当您点击 lint 任务的播放按钮时,VS Code 内部将打开一个终端,您可以在其中看到代码风格检查的结果。在 finance-expenses-registration 应用程序 的例子中,代码风格检查过程将无任何错误或警告通过您的代码风格检查规则。对于 Nx Cloud 将会有一个警告,但可以忽略,因为我们没有使用 Nx Cloud。当使用 Nx 控制台时,您只能单独对项目进行代码风格检查。或者,您也可以使用终端命令通过 Nx 控制台对项目进行代码风格检查。

使用终端命令进行代码风格检查

您可以通过在 Nx 单个项目仓库的根目录下运行以下终端命令来对单个项目进行代码风格检查:

nx run <project name>:lint

在前面的终端命令中,您需要将 <project name> 替换为您想要进行代码风格检查的项目名称。这个项目名称可以在您想要目标项目的 project.json 文件中找到。因此,要对 finance-expenses-registration 应用程序 进行代码风格检查,您需要运行以下命令:

nx run finance-expenses-registration:lint

由于 Nx 缓存,第二次运行代码风格检查过程要快得多。在终端中,您会发现 Nx 从缓存中读取输出而不是为 1 个任务中的 1 个运行命令,这表明 Nx 从缓存中获取了代码风格检查的输出,因为它检测到自上次代码风格检查以来项目没有发生变化。当对多个项目运行代码风格检查时,Nx 缓存可以显著减少代码风格检查完成所需的时间,大大提高您的开发过程和体验。

对多个项目进行代码风格检查

在前面的示例中,我们看到了如何为单个项目运行 linting,但在大多数情况下,尤其是在您的 CI/CD 管道中,您希望 lint 多个项目。当您在 CI 管道(在每次代码合并之前运行的管道)中运行 linting 时,您通常希望 lint 受您即将合并的更改影响的项目;或者,当运行部署您的系统的 CD 管道时,您通常会在您的 monorepo 中的所有项目或与部署相关的所有项目中运行 linting。

要运行受您更改影响的所有项目的 linting,您可以使用nx affected命令。终端命令与我们在为受影响的项目运行测试时使用的affected命令类似;您只需更改目标作业从testlint

nx affected -t lint –base=main

如您在前面的命令中看到的,您使用affected关键字,添加-t标志,并定义您想要运行的目标任务。在这个场景中,它是lint。在定义任务之后,我们还需要配置–base标志,并为其提供要与之比较更改的基分支;通常,这将是你主分支。您不必手动定义–base标志,也可以在您的nx.json文件中配置defaultBase属性。要配置您的默认基分支,请在您的nx.json文件的根目录下添加以下内容:

"affected": {
  "defaultBase": "main"
}

在添加上述代码之后,您可以在不提供–base标志的情况下运行affected命令。目前,如果您运行affected命令,您会注意到 monorepo 中的所有项目都被 linted。所有项目都被 linted,因为 Nx 没有为您的defaultBase属性缓存受影响的 lint。除了所有项目都被 linted 之外,您还会注意到 linting 过程中有 4 个项目中的 4 个失败了。

修复 linting 错误

在我们继续之前,让我们修复 linting 错误,以便所有 linted 项目都能成功。

--fix标志

您可以手动逐个查看终端输出中的所有错误并逐一修复它们,但大多数 linting 错误很容易修复,Nx 的 linting 过程提供了一种为您解决所有简单 linting 错误的方法。只需将–-fix标志附加到您的终端命令中,Nx 将自动修复它可以修复的问题。要使用--fix标志获得最佳结果,您需要为每个项目单独运行 linting 过程。所以,让我们取有 linting 错误的项目,并逐个运行它们,命令中附加了–-fix标志:

nx run workspace-generators-plugin:lint --fix
nx run shared-ui-common-components:lint --fix
nx run shared-data-access-generic-http:lint --fix
nx run shared-util-custom-decorators:lint --fix

在运行上述命令之后,所有的 linting 错误都已被修复。现在您可以重新运行受影响的lint命令,所有项目的 linting 都将成功。

修复选项

如您所想象,为每个有 linting 错误的工程运行 linting 命令可能会变得繁琐,尤其是在您的 monorepo 增长时。另外,您可以在各个项目的project.json文件内配置fix选项。在project.json文件中,您会找到一个lint部分,在这个部分内,您可以添加fix选项,如下所示:

"lint": {
  ……
  "options": {
    "fix": true
  }
}

在将前面的配置添加到每个项目的project.json文件后,您只需运行lint affected命令,Nx 将为每个项目修复其可以修复的 linting 问题。

其他配置

除了--fix标志和配置之外,还有其他有用的配置,例如在 linting 过程失败之前允许最大警告数,将您的 linting 结果输出到文件,或者即使存在 linting 错误也传递 linting 过程。您可以在以下 URL 的官方 Nx 文档中找到所有配置:nx.dev/nx-api/eslint/executors/lint

运行 linting 命令时针对多个项目

在查看剩余的配置选项后,我将简要解释如何在运行 linting 命令时同时针对多个项目。您可以使用run-many命令来完成此操作,类似于我们在第十一章中用单个命令测试多个项目时所做的那样。您使用run-many命令结合-p标志。run-many命令允许您为许多项目运行任务,而-p标志将允许您指定特定的项目:

nx run-many -t lint -p project1 project2

在前面的例子中,您为project1project2执行了 linting。另外,您可以通过以下命令为具有特定标签的项目运行 linting 过程:

nx run-many -t lint --projects=tag:type:ui

如您在前面的命令中所见,我们不是定义项目名称,而是定义了我们想要针对的标签。

现在您已经知道了如何 lint 特定的项目以及如何配置 linting 过程,让我们深入下一个主题:在 Nx monorepo 内部构建您的项目。

构建您的 Angular 库和应用程序

要部署您的 Angular 项目,您需要创建一个应用程序构建。当您创建应用程序构建时,构建过程会创建一个代码包,您的运行时可以执行和运行它。在 Angular 应用程序的情况下,浏览器将是解释和运行应用程序代码的运行时。因此,要部署我们创建的演示应用程序,您首先需要使用nx build命令创建一个应用程序构建。

创建应用程序构建的幕后

在运行我们的*expenses-registration 应用程序的nx build命令之前,让我们详细检查运行build命令时会发生什么:

  1. 您即将构建的项目中的 project.json 文件。这与标准 Angular CLI 类似,但支持单仓库中的多个项目,Nx 分析 project.json 文件而不是常规 Angular 项目中的 angular.json 文件。

  2. project.json 文件。如果您为构建目标配置了文件替换,Nx 将在继续构建过程之前替换文件。

  3. ngc,将 TypeScript 代码转换为 JavaScript,处理 Angular 装饰器和模板。

  4. 预编译(AOT):与 Angular CLI 类似,Nx 对生产构建执行 AOT 编译(除非配置不同),将 Angular 模板和组件转换为浏览器可以执行的效率高的 JavaScript 代码,而无需在渲染之前编译代码。

  5. 捆绑阶段

    • 模块解析:Nx 使用配置的构建工具进行模块解析和创建依赖图,从入口点(s)开始。

    • 摇树优化:Nx 执行摇树优化以删除未使用的代码,减小最终捆绑包的大小。

    • 代码拆分:Nx 将应用程序的不同部分拆分为多个 JavaScript 捆绑包,这些捆绑包可以被浏览器懒加载。

    • 资产优化:Nx 优化 CSS、HTML、图像和其他资产,类似于 Angular CLI。

  6. 压缩 和压缩

    • JavaScript 压缩:Nx 压缩 JavaScript 代码以减小文件大小。

    • 压缩:Nx 进一步混淆 JavaScript 代码。

  7. 哈希和缓存失效

    • 文件哈希:Nx 将哈希值附加到生成的捆绑包和资产的文件名上,以实现缓存失效。
  8. index.html 文件,包括对哈希过的 JavaScript 和 CSS 捆绑的引用。

  9. dist 文件夹或某些其他配置的输出路径。

前面的步骤与使用 Angular CLI 时的常规构建流程相似。然而,Nx 有一些额外的功能,例如受影响构建、run-many 命令、增量构建和缓存,这使得在单仓库内为多个项目创建应用程序构建更加容易,并加快了构建时间。现在您已经更好地了解了构建项目时发生的情况,让我们为费用注册应用程序创建一个应用程序构建。

重要通知!

我们制作的 费用注册应用程序 只是一个简单的演示应用程序,并不真正适用于生产目的。在您实际部署应用程序之前,还有很多改进可以做出,需要添加页面,以及需要清理的代码。为了演示目的,我们将把应用程序部署到 GitHub Pages,但这并不是为了其他任何目的而使用。

运行构建命令

您可以在终端或 Nx 控制台中运行 费用注册应用程序build 命令。如图 图 12.2 所示,您可以在 Nx 控制台中找到 build 命令,它在 lint 命令之上:

图 12.2:Nx 控制台构建

图 12.2:Nx 控制台构建

要运行build命令,在 Nx 控制台中点击build关键字旁边的播放按钮。Nx 将生产构建配置为默认配置;如果你想针对另一个构建配置,你可以在 Nx 控制台中展开build以查看所有构建配置。然后,你可以通过点击你想要运行的构建配置名称旁边的播放按钮来运行特定的构建配置。对于每个项目,Nx 都会在project.json文件中为你创建一个生产和开发构建配置。当需要时,你可以创建额外的构建配置;例如,当你有多个预发布环境,如测试和验收,这些环境需要从生产或开发构建中有所偏差时。

Nx 控制台是代码编辑器的扩展,因此它不可用于你的 CI/CD 管道中。由于 Nx 控制台不可用,你需要在管道中使用 Nx CLI 来创建应用程序构建并执行其他任务,例如构建和测试你的应用程序。nx build命令的语法与之前使用的testlint命令类似;你只需将构建目标从linttest更改为build

nx build finance-expenses-registration

如上述命令所示,我们输入nx来指定 Nx CLI,然后是我们要运行的任务,最后添加我们想要针对的项目名称。Nx 将使用默认的构建配置,除非你更改它。如果你想针对不同的构建配置,你可以在build命令中添加–-configuration标志:

--configuration=development

通过在 CLI 命令的末尾添加前面的标志,你将使用development构建配置而不是生产配置。或者,你也可以使用以下语法:

nx run finance-expenses-registration:build:production

上述命令为finance-expenses-registration项目运行了生产构建。正如我们通过linttest命令所看到的,你也可以使用affectedrun-many来构建多个项目或受你更改影响的项目。现在,你已经知道了如何使用 Nx 控制台和 Nx CLI 运行build命令,那么就继续为finance-expenses-registration项目运行生产构建吧。

修复失败的构建

在为finance-expenses-registration项目运行生产构建后,你会注意到 Nx 试图构建finance-expenses-registration项目以及它所依赖的所有项目——在我们的案例中,是三个其他库(shared-ui-common-componentsfinance-data-access-expensesshared-util-form-validator)——在我们的 Nx 单仓库中。你还会注意到,四个构建中有三个失败了。这三个失败的构建是三个库,它们都有相同的错误:'updateBuildableProjectDepsInPackageJson'schema中未找到。

为了修复你的构建,你需要在你的库的project.json文件中移除以下配置:

"updateBuildableProjectDepsInPackageJson": true

在你的project.json文件中移除前面的配置后,你可以再次运行finance-expenses-registration项目的build命令,现在你的构建过程将成功。构建完成后,你会在 Nx 单体仓库的根目录下注意到已创建一个dist文件夹。finance-expenses-registration项目及其所有依赖的可构建项目的构建输出都位于这个dist文件夹内。finance-expenses-registration项目的构建输出可以在以下路径找到:dist/apps/finance/expenses-registration

当你部署你的应用程序时,你需要将dist/apps/finance/expenses-registration目录内的输出上传到你的托管服务。在 Angular 应用程序的情况下,你可以使用诸如 GitHub Pages、Azure Blob Storage、Amazon 简单存储服务 (S3) 或你偏好的任何其他静态网站托管服务。如果你正在使用 Angular Universal 和服务器端渲染 (SSR),你需要一个不同的托管服务,例如 Azure App Service,但在这本书中我们不会涉及这一点。

在本章的最后部分,我们将使用 GitHub Actions 自动部署和托管我们的演示应用程序到 GitHub Pages,但现在,让我们看看如何使用http-server在我们的机器上本地托管我们的生产构建。

本地托管生产构建

为了托管我们的生产构建,我们需要一个可以托管我们的静态文件的服务器。市面上有许多可以做到这一点的服务,但我们将使用http-server。首先,你需要使用以下命令在你的机器上安装http-server

npm install http-server -g

安装http-server后,你可以使用它来本地托管你的 Angular 应用程序的生产构建。如果你没有诸如测试和验收之类的预发布环境,使用像http-server这样的工具可以在部署到生产环境之前测试你的构建是否按预期工作;然而,在专业设置中,我总是推荐使用一个测试和验收环境,在那里你可以在一个类似于生产环境的环境中测试你的应用程序。

要使用http-server在本地托管你的应用程序,你需要在 Nx 单体仓库的根目录下运行以下终端命令:

http-server dist\apps\finance\expenses-registration

运行前面的命令后,将创建一个服务器,托管dist\apps\finance\expenses-registration文件夹内的静态文件。在终端中,你会找到两个以你的私有 IP 地址开头并以8080端口结尾的 URL。你可以在浏览器中访问这两个 URL 中的任何一个来查看你的应用程序。当你通过浏览器访问应用程序时,你会在终端中看到你的应用程序发出的请求。你可能注意到api/expenses请求失败;导致 API 请求失败的原因是我们没有运行中的 API,并且在生产构建中禁用了模拟 API 拦截器。

由于我们禁用了模拟数据拦截器并且我们没有运行中的 API,所以当你用浏览器访问它时,你不会在应用程序中看到任何数据。为了测试目的,你可以通过在 mock.interceptor.ts 文件中移除以下内容来为你的生产构建启用模拟拦截器:

!isDevMode() ||

移除前面的代码后,模拟拦截器也将适用于你的生产构建。创建一个新的应用程序构建,使用 http-server 提供服务,然后在浏览器中重新访问应用程序(你可能需要通过打开开发者工具,右键单击浏览器中的重新加载符号,并选择 api/expenses 请求成功来清除浏览器缓存)。别忘了在 mock.interceptor.ts 文件中撤销更改,因为这只是为了测试目的;在实际的生产应用程序中,你希望使用真实的 API。

因此,总结一下,你学习了如何在 Nx 单一代码库内部对项目进行代码检查,以及如何通过使用 –-fix 标志自动修复基本的代码检查错误。你学习了当你为 Angular 应用程序创建应用程序构建时幕后发生了什么。你还学习了如何为特定的构建配置运行 build 命令以及在哪里可以找到应用程序构建的输出。最后,你使用 http-server npm 包在本地托管了构建输出。在下一节中,你将学习如何分析应用程序构建,以便你可以轻松地识别哪些构建部分可以工作以减小你的包大小。

分析你的构建输出

在你的构建输出中,你会找到不同的 JavaScript 文件。其中一些文件是你的应用程序包,浏览器将加载这些包来为最终用户渲染应用程序。这些包的大小直接影响到你应用程序的性能。JavaScript 对浏览器来说加载较慢,因此你的包越大,浏览器下载文件、在屏幕上渲染内容以及使网页响应用户交互所需的时间就越长。

为了减小你的包大小,你需要一种有效的方法来分析你的包。如果你为 finance-expenses-registration 项目运行 build 命令,你会在终端看到一个小的报告

终端内的构建报告包括创建的包列表以及每个包的大小。虽然这个列表给你一些关于包大小的指示,但你看不到包由什么组成以及你可以在哪里进行一些改进以减小包的大小。

有一个名为 npm 的工具包,使用以下命令:

npm i -g webpack-bundle-analyzer

在全局安装包之后,你需要在 Nx 单一代码库内部将其安装为开发依赖。你通过在 Nx 单一代码库的根目录下运行以下命令来将 Webpack Bundle Analyzer 工具作为开发依赖项添加:

npm i webpack-bundle-analyzer -save-dev

在运行前面的 npm 命令之后,您就可以开始使用 Webpack Bundle Analyzer 了。第一步是为您的应用程序创建一个新的生产构建,并在 build 命令中包含一个 –-stats-json 标志:

nx build finance-expenses-registration --stats-json

通过添加 –-stats-json 标志,Webpack Bundle Analyzer 将在构建输出中创建一个 stats.json 文件。stats.json 文件位于 dist\apps\finance\expenses-registration 文件夹内,与您的应用程序构建的其他部分相邻。要检查 stats.json 文件,您可以在您的 Nx 单一代码库的根目录下运行以下命令:

webpack-bundle-analyzer dist/apps/finance/expenses-registration/stats.json

运行前面的命令将为您提供应用程序包的视觉和详细概述。概述将在浏览器中打开,应该看起来像这样:

图 12.3:Webpack Bundle Analyzer

图 12.3:Webpack Bundle Analyzer

图 12.3 所示,Webpack Bundle Analyzer 工具为您提供了关于您的包的详细且易于阅读的概述以及每个包中包含的内容。您可以轻松地识别每个包中的大型部分,以便您可以查看可以改进的地方。由于我们的应用程序很简单,所以没有太多可以调整的,但例如,您可以看到 ngrx-storetransloco 包是我们添加到项目中最大的元素之一。如果您的包大小变得过大,您可以尝试寻找替换您导入的大型 npm 包,如果可用,仅导入小的子模块,或者将代码和其他资源移动到懒加载模块中,以便浏览器首先渲染页面并懒加载额外的资源。

如您在 图 12.3 中所见,在 Webpack Bundle Analyzer 的侧边栏中,您可以控制屏幕上显示哪些包;这使得关注构建输出中的特定包变得更容易。此外,您还可以以三种不同的格式查看包大小 – StatParsedGzipped 格式:

  • Stat 是最大的;这表示您的包在没有任何压缩或优化情况下的原始大小。

  • Parsed 的大小与您的终端构建输出报告中所显示的大小相同。这是在应用了一些优化和压缩之后您包的大小。

  • 如果您看到 Content-Encoding: Gzip 标头,则知道您的托管服务已为您启用了 Gzipping;如果该标头未包含在内,您需要自己添加 Gzipping。如何添加 Gzipping 本身超出了本书的范围。GitHub Pages 会为您压缩 Angular 应用程序。

总结一下,你现在知道如何有效地使用 Webpack Bundle Analyzer 分析你的应用程序包。如果包太大,你可以尝试将代码移动到懒加载模块中,并寻找替换你导入的大型 npm 包。你还了解到,对内容进行 Gzipping 可以显著减小包的大小,并且大多数现代托管服务为你处理 Gzipping 和必要的配置。在本书的下一节和最后一节中,我们将使用 GitHub Actions 创建一个 CI/CD 流程,该流程将自动将我们的演示应用程序部署到 GitHub Pages。

自动部署 Angular 应用程序

在本节的最后部分,你将学习如何自动部署 Angular 应用程序到你的 Nx 单一代码仓库中的 GitHub Pages。你将设置一个使用 GitHub Actions 的 CI/CD 流水线,每当你在 GitHub 的主分支上合并代码时,它就会部署我们创建的演示应用程序。我们将创建一个包含所有必要部署步骤的 .yml 文件。在 .yml 文件内部,我们将使用 YAML 语言,这是一种常用于创建各种 DevOps 工具和程序配置文件的通用语言。

创建访问令牌

在开始创建 .yml 文件之前,你需要在你的 GitHub 账户中创建一个访问令牌。GitHub 需要知道你已认证,可以部署应用程序到 GitHub Pages,然后才能执行部署。你向 GitHub 提供必要的认证,在 .yml 文件中的部署步骤提供访问令牌。因此,你需要做的第一件事是在你的 GitHub 账户中创建一个访问令牌。按照以下步骤操作:

  1. 首先,点击 GitHub 中的你的账户图标并选择 Settings

  2. 在个人设置页面,你需要向下滚动并选择屏幕左侧的 Develop settings

  3. 现在,再次点击屏幕左侧的 Personal access tokens,然后在下拉菜单中选择 Tokens (classic)

  4. 接下来,你需要点击 Generate new token 按钮,并在下拉菜单中选择 Generate new token (classic)

    在遵循 步骤 14 之后,你应该会看到 图 12.4 所示的页面:

图 12.4:GitHub 个人访问令牌

图 12.4:GitHub 个人访问令牌

  1. 现在,在 GH_PAGES 下,在 GH_PAGES 名称下。

  2. 对于 Expiration 字段,选择 No expiration。在实际的生产环境中,你可能出于安全原因想要一个过期日期,但这意味着你需要根据设定的过期间隔更新你的令牌。

  3. gist 下。

  4. 在选择 Select scopes 下的复选框后,你可以向下滚动并选择 Generate token。点击 Generate token 后,你会看到你的个人访问令牌的密钥;它看起来可能类似于这个:

    ghp_vnjAH0PRGRcIO6UQGO2GavdOUVUDPJ1Jrv77
    

    将访问令牌的密钥复制到你的系统中并安全存储;你将在自动化部署配置的下一步中需要它。

  5. 在复制并保存您的密钥后,转到存储您在本书学习过程中创建的代码的 GitHub 仓库。

  6. 接下来,您需要通过点击导航项中的设置来导航到仓库设置,如图图 12.5所示:

图 12.5:GitHub 仓库设置

图 12.5:GitHub 仓库设置

  1. 现在,在屏幕左侧的安全部分下,点击密钥和变量,然后在下拉菜单中点击动作

    这将带您到动作密钥和变量页面,如图图 12.6所示:

图 12.6:动作密钥和变量

图 12.6:动作密钥和变量

  1. 动作密钥和变量页面,您需要点击新建仓库密钥按钮。

  2. 接下来,您将被要求提供名称和密钥。在GH_PAGES内部,并在密钥字段中,粘贴从个人访问令牌中保存的密钥。

  3. 在输入必要的值后,点击添加密钥按钮以保存您的仓库密钥。

如果操作成功,你现在应该在动作密钥部分看到Repository secrets页面上的GH_PAGES密钥。

创建一个.yml 文件

现在您已经创建了一个个人访问令牌和一个仓库密钥,我们可以继续创建一个.yml文件。

要创建一个.yml文件,请按照以下步骤操作:

  1. 首先点击仓库导航中的动作

  2. 接下来,点击自行设置工作流程

    这将带您到一个页面,如图图 12.7所示:

图 12.7:main.yml

图 12.7:main.yml

图 12.7所示,GitHub 在.github/workflows文件夹内为您创建了一个空的main.yml文件。您可以直接在 GitHub 中编辑main.yml文件,或者提交文件并在 VS Code 中编辑它。现在,我们将简单地编辑 GitHub 中的文件,并开始添加必要的步骤以部署我们的演示应用程序到 GitHub Pages。

添加触发器

您首先需要在.yml文件中添加一个触发器。在我们的例子中,我们希望我们的.yml文件在向 GitHub 仓库的主分支推送更改时被触发。您可以通过将以下内容添加到.yml文件中来添加此触发器:

# Add trigger
name: Build and Deploy Script
on:
  push:
    branches:
      - main

上述代码片段以注释开始;接下来,我们为我们声明的步骤命名,然后使用on:关键字来表示我们正在定义一个触发器,并在其下方定义触发器的配置——在本例中为主分支的推送。.yml文件中的缩进很重要,所以请确保每行比上一行多一个制表符。

定义要运行的作业和相关步骤

在定义触发器后,您需要定义要运行的作业以及作业的步骤。

在我们的案例中,我们只有一个工作,它是一个由多个步骤组成的build工作。首先定义jobsbuild关键字,然后定义构建工作将在其上运行的虚拟机的操作系统:

# Jobs to run
jobs:
  build:
    runs-on: ubuntu-latest

在构建工作的runs-on配置下面,我们将定义执行构建工作所需的步骤。您需要执行的第一步是检出 GitHub 仓库:

    # Checkout repository
    - name: Checkout Repository
      uses: actions/checkout@v4
      with:
        fetch-depth: 0

如您在前面的代码片段中所见,此步骤的缩进从与runs-on配置相同的位置开始。我们首先定义一个注释;接下来,我们定义步骤的名称和步骤使用的操作。最后,我们为步骤提供额外的配置 – 在这种情况下,fetch-depth: 0,这表示获取 GitHub 仓库中所有分支和标签的历史记录。

在获取 GitHub 仓库之后,您需要使用以下步骤在 Ubuntu 机器上安装 NodeJS:

    # Install NodeJS
    - name: Adding Node.js
      uses: actions/setup-node@v2
      with:
        node-version: 20.1.0

在安装 NodeJS 之后,您需要安装node_modules。为了加快构建工作,我们将使用pnpm而不是npm。只需将以下步骤添加到您的.yml文件中,以安装 Nx monorepo 的node_modules

    # Setup pnpm
    - uses: pnpm/action-setup@v2
      with:
        version: 8.14.1
    - run: pnpm install --frozen-lockfile

我们流程中的下一步将是检查所有受影响的项目:

    # Lint affected projects
    - name: Lint affected
      run: pnpm nx run-many -t lint --base=main --no-cloud

如您所见,我们在之前未见过的新命令中添加了一个新标志。我们添加了–-no-cloud标志来表示我们不在使用 Nx Cloud;否则,您的管道将尝试连接 Nx Cloud 而失败。接下来,我们将为我们的应用程序添加一个额外的单元测试步骤:

    # Unit testing finance-expenses-registration
    - name: Unit test finance-expenses-registration
      run: pnpm nx run finance-expenses-registration:test –no-cloud

为了简化,我们将跳过finance-expenses-registration项目。如果您已经在 monorepo 中修复了所有单元测试,您可以将.yml文件更改为运行所有受影响项目的测试。在运行linttest命令后,我们将使用以下步骤构建finance-expenses-registration项目:

    # Build finance-expenses-registration application
    - name: Build Angular App
      run: pnpm nx build finance-expenses-registration --no-cloud --base-href /Effective-Angular/

如您可能已经注意到的,我们在build命令中添加了–-base-href标志。我们需要添加一个基本的href属性,因为 GitHub 会将您的应用程序部署到<GitHub account name>.github.io/<repository name>。如果您不使用Effective-Angular作为您的仓库名称,您需要更改–-base-href标志的值为您自己的仓库名称。

在构建仓库之后,您需要在虚拟机上初始化 Git,以便您可以将其部署到 GitHub Pages。只需将以下步骤添加到您的.yml文件中:

    # Setup Git on the VM
    - name: Set up Git
      run: |
        git config --global user.email "youremail@gmail.com"
        git config --global user.name "your-git-username"

在添加前面的步骤之后,我们可以移动到构建工作内的最后一步,即我们的应用程序部署到 GitHub Pages。

将应用程序部署到 GitHub Pages

我们将使用一个名为angular-cli-ghpagesnpm包来部署应用程序。将以下步骤添加到您的.yml文件中:

    # Deploy the finance-expenses-registration to GitHub pages
    - name: Deploy to gh pages
      run: |
        npx angular-cli-ghpages --dir=dist/apps/finance/expenses-registration
      env:
        CI: true
        GH_TOKEN: ${{ secrets.GH_PAGES }}

如前述代码所示,我们使用angular-cli-ghpages运行终端命令来部署应用,并需要提供我们之前创建的GH_PAGES令牌,这样 GitHub 就知道我们有必要的授权来进行部署。添加部署步骤后,你已经添加了自动将你的演示应用部署到 GitHub Pages 所需的所有步骤。

你现在可以点击主分支上的.yml文件,.yml文件将立即被触发。你可以通过导航回你的仓库的操作选项卡来查看这一点,在那里你应该看到你的运行中的工作流程。不幸的是,当前的工作流程失败了。

工作流程修复失败

GitHub Actions 工作流程由于多个原因失败,所以让我们逐一修复问题。首先,你需要在你的 Nx 单仓库中安装angular-cli-ghpages包。只需运行以下命令在你的单仓库中安装该包:

npm i angular-cli-ghpages

接下来,我们在 GitHub Actions 工作流程中使用pnpm而不是npm。因此,我们需要将pnpm-lock.yaml文件提交到我们的 GitHub 仓库。要生成此pnpm-lock.yaml文件,首先使用以下npm命令全局安装pnpm

npm install -g pnpm

安装pnpm后,你可以开始使用pnpm来替代你通常运行的npm命令。我建议使用pnpm,因为它比npm快得多。现在,要生成你的pnpm-lock.yaml文件,只需运行以下命令:

pnpm install

上述命令类似于npm install命令,只是使用pnpm而不是npm。运行pnpm install命令后,你可以提交你的更改并将它们与 GitHub 上的主分支合并以再次触发工作流程。如图 12.8所示,在你的仓库的操作选项卡下,你现在应该看到两个工作流程运行,第二个运行应该成功:

图 12.8:GitHub Actions 工作流程运行

图 12.8:GitHub Actions 工作流程运行

现在你已经成功构建,但仍需处理部署步骤。前往你的仓库设置,然后在左侧点击页面并添加如图图 12.9所示的配置:

图 12.9:构建和部署

图 12.9:构建和部署

图 12.9所示,配置分支为gh-pages后,部署过程将自动开始,几分钟后,你可以在本 GitHub 页面的顶部找到你应用的 URL。每次你的工作流程成功运行时,现在部署将自动完成。当你访问部署的 URL 上的演示应用时,你只会看到你的导航栏,因为应用部署在<GitHub 账户名>.github.io/<仓库名> URL 上,我们需要在获取应用内部的语言文件时考虑到仓库名,因此还需要进行最后的修复。

前往 finance-expenses-registration 项目的 app.config.ts 文件,并在你的 providers 数组顶部添加以下提供者:

{ provide: APP_BASE_HREF, useValue: isDevMode() ? '' : '/Effective-Angular/' },

接下来,前往你的 transloco-loader.ts 文件,并调整它以包含以下内容:

@Injectable({ providedIn: 'root' })
export class TranslocoHttpLoader implements TranslocoLoader {
  private http = inject(HttpClient);
  protected readonly baseHref = inject(APP_BASE_HREF);
  getTranslation(lang: 'en' | 'nl') {
    return this.http.get<Translation>(`${this.baseHref}assets/i18n/${lang}.json`);
  }
}

正如你所见,我们已经将 APP_BASE_HREF 注入器注入到文件中,并将 baseHref 属性包含在用于获取翻译的 URL 中。你现在可以推送更改,并将它们与你的主分支合并以再次触发工作流程。在你的工作流程运行成功后,你可以点击 部署,如图 图 12.10 所示。

图 12.10:部署

图 12.10:部署

当你点击 部署,如图 图 12.10 所示,你将被导航到一个页面,你可以看到你应用程序的所有部署。在同一个页面上,你还可以找到你部署应用程序的 URL 以及它最后一次更新的时间:

图 12.11:部署列表

图 12.11:部署列表

当你的新部署准备就绪时,如图 图 12.11 所示,你可以在浏览器中再次导航到你的部署应用程序,现在你应该看到一个包含表格和翻译值的工作应用程序。由于模拟拦截器在你的生产构建中被禁用,表格中没有数据,也没有你的应用程序可以连接的 API。你现在一切就绪,每次你将更改推送到你的主 GitHub 仓库时,你的应用程序都会自动进行代码检查、测试、构建和部署。

如前所述,这只是一个演示应用程序,还有很多改进的空间。所以,如果你想的话,你可以添加更多页面,为表格和其他你想要的功能创建独立的库组件,并将应用程序创建和转换为真正适合生产目的的东西。你现在拥有了所有使用 Nx 和 Angular 框架开发健壮和可扩展应用程序所需的构建块。

摘要

我们已经到达了这本书的结尾,首先,我想感谢你阅读这本书,并祝贺你完成它!在阅读这本书的过程中,你学到了很多,也做了很多。你创建了自己的 Nx monorepo,可以扩展到数百个 Angular 应用程序和库。你知道如何根据个人需求配置 Nx monorepo,并在你的 monorepo 中添加第一个库和应用程序。在创建 monorepo 并添加项目后,你了解了内置和自定义 Nx 生成器以及如何使用它们来生成样板代码并在你的 monorepo 中引入一致性。

接下来,我们转向 Angular,学习了框架的最新发展,以及如何利用强大的功能,如组件通信、Angular 路由和依赖注入DI)。在探索框架最新和最强大的功能之后,你学习了管道、指令和动画。你看到了如何创建自定义管道和指令,学习了指令组合,并创建了可重用的动画,使你的应用程序对最终用户更具吸引力。

此外,我们深入研究了 Angular 的forms模块,学习了模板驱动、响应式和动态表单。在众多内容中,你学习了表单验证、表单构建器、错误处理以及创建动态表单和表单字段。完成 Angular 表单的学习后,我们回归到理论知识,探索了在 Angular 应用程序中常用的不同最佳实践、约定和设计模式。你学习了某些设计模式和最佳实践,为这本书的下一部分做好了准备,该部分专注于响应式编程和状态管理。你还学习了如何利用 RxJS 和 Signals 创建响应式代码。你了解了可观察流、可管道操作符以及处理嵌套数据流。我们将代码从 RxJS 转换为 Signals,以展示两者之间的差异,并解释了何时使用哪个工具以达到预期的结果。我们还探讨了结合 RxJS 和 Signals 以获得两者的最佳效果。在完成 RxJS 和 Signals 的基础知识后,我们使用这两个工具创建了一个自定义状态管理解决方案,并通过一个外观服务将其与我们的应用程序组件层连接起来。为了完成我们的状态管理之旅,我们将我们的自定义状态管理解决方案转换为使用 NgRx 的实现,这是 Angular 生态系统中最强大和最受欢迎的状态管理解决方案。

当我们完成了响应式编程和状态管理的学习后,我们继续阅读这本书的最后一部分。你学习了如何开发适用于全球各地、各种能力人群的应用程序。你使用了transloco为你的应用程序添加本地化和国际化,使得不同国家和语言的人们可以使用你应用并选择他们偏好的格式和语言。接下来,你使用 Jest 和 Cypress 创建了单元测试和端到端测试。

最后,我们通过创建一个使用 GitHub Actions 自动部署流程来将您的应用程序部署到 GitHub Pages 的方法完成了这本书。完成这本书后,您将拥有使用 Nx 和最新的 Angular 技术开发稳健和可扩展的 Angular 应用程序所需的所有知识。您可以通过添加更多页面、将表格抽象为其自己的组件、创建用于费用登记的功能组件,使得页面只需声明一个组件,添加新的功能,如分组费用、规划费用、上传收据等,来继续使用我们创建的演示应用程序。您还可以在 Nx monorepo 中创建多个应用程序,或者从您自己的项目从头开始。无论您想开发什么,现在您都有工具和知识来有效地使用 Angular 开发任何规模的应用程序。

posted @ 2025-09-05 09:24  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报