Angular-高效指南-全-
Angular 高效指南(全)
原文:
zh.annas-archive.org/md5/e6800eecdc28872497904cb5b86b5615译者:飞龙
前言
欢迎阅读《高效 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 版本。
在任何地方、任何设备上阅读。直接从您喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件
按照以下简单步骤获取好处:
- 扫描二维码或访问以下链接

packt.link/free-ebook/978-1-80512-553-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 Code(VS 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:开发速度
现在你已经理解了我们所说的可扩展前端应用程序的含义以及为什么良好的架构对于实现它们至关重要,我们将深入了解一些扩展前端应用程序的方法,并了解它们的优缺点。
前端应用程序的扩展方法
当涉及到软件架构时,考虑你正在构建的内容、它能够发展到什么程度、所处的环境以及谁将负责它,这是至关重要的。根据这些参数,你将希望创建一个足够灵活的架构,以便在不过度设计的情况下增长和适应,避免使事情比必要的更复杂和耗时。
例如,如果你正在为一家小型家族企业构建一个简单的网站,你不需要复杂的架构和设计模式;这会使事情比必要的更复杂和耗时。网站的需求可能保持大致相同,代码库将保持小而易于管理。但是,当你构建由多个应用程序组成的商业软件时,这些应用程序的需求和效用将发生很大变化,你需要确保软件为此做好准备。
在本节中,你将了解不同的架构选择,以确保你不会过度或不足设计你的前端应用程序。你将了解在 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 模式
现在你已经了解了 MVC 模式的内容以及如何在你的 Angular 应用中实现它,让我们来学习下一个常见的架构模式:六边形架构。
Angular 应用中的六边形架构模式
与 MVC 和其他一些架构模式,如分层架构、MVVC 和 MVP 相比,六边形架构相对较新。六边形架构是在 2005 年提出的,而人们直到最近几年才开始在 Angular 应用中实施它。它之所以受到欢迎,是因为领域驱动开发(DDD)成为了一个热门话题,六边形架构非常适合与 DDD 结合使用。六边形架构的主要原则是通过端口和适配器将核心应用程序逻辑与 UI 和数据实现分离。正因为如此,这种架构也通常被称为端口和适配器架构。但是,我听到你在想,端口和适配器是什么?
简而言之,端口是接口(或抽象类),它们将您的核心逻辑与 UI 和代码实现分开。这些接口规定了 UI 和代码实现如何与您的应用程序核心通信。适配器是通过端口连接到您的应用程序核心的 UI 和代码实现。在六边形架构中,端口和适配器有两种类型,UI 和数据相关端口和适配器——换句话说,主要和次要适配器和端口。这一概念在 图 1**.3 中得到了说明:

图 1.3:六边形架构模式
在实现六边形架构时,您将为应用程序中的每个领域都有一组端口和适配器。我喜欢在端口接口和适配器之间使用外观服务,以在 UI、实现和应用程序核心之间提供更多抽象。当使用外观服务时,外观可以被视为端口本身。只需确保外观实现了一个接口,这样就可以为与核心和适配器的通信定义一组固定的规则。
因为端口定义了与您的应用程序核心通信的固定规则集,所以当业务需求发生变化时,您可以轻松地更改实现。您可以在不触及业务逻辑或数据实现的情况下交换 UI 组件,并且可以在不触及视图或应用程序核心的情况下更改数据持久化或检索方式。您需要做的唯一事情是确保您的新实现可以连接到端口使用的相同接口,以便连接一切。这种方法提供了出色的灵活性和松散耦合的系统。
为了澄清问题,我想回顾一下 图 1**.3 并将其翻译成 Angular 应用。我们将从左到右进行。在最左边,我们有主要适配器。用户面对的或触发的所有内容都被视为主要适配器:组件、指令、解析器、守卫和事件监听器。向右一步,我们将找到主要端口。这些常规 TypeScript 接口(或外观服务)规定了 UI 层如何与应用程序核心通信。我们的应用程序核心位于中间,在那里我们访问状态管理并在 Angular 服务中定义业务和应用程序逻辑。在应用程序核心的右侧,我们有我们的次要端口。这些端口规定了应用程序代码如何与 HTTP 服务、状态管理、内存持久化、事件调度器和其他数据或 API 相关逻辑通信。与主要端口一样,次要端口也是常规 TypeScript 接口(或外观服务)。在最右边,我们有我们的次要适配器。次要适配器实现了我们的 HTTP 服务、本地存储持久化、状态管理和事件调度器。
现在你已经了解了六边形架构是什么以及你如何在 Angular 应用中实现它,让我们来看看我们将讨论的第三种和最后一种架构模式:分层架构。
Angular 应用中的分层架构模式
如其名所示,分层架构模式使用不同的层来分离关注点。对于应用的每个部分,你应在架构中创建一个层,该层位于另一个层之上。当你将分层架构模式应用于你的 Angular 应用时,你应该至少有三个(主要)层:核心层、抽象层和表示层。在这些顶级层中,你可以有额外的子层或同级元素。如果你的应用架构需要更多层,你可以根据需要添加。
分层架构最重要的地方是每个层只能与自身上下层的层通信;链中的其余层都是禁止的。另一个基本特点是事件和动作向上流动,数据向下流动。用户在表示层触发事件或执行操作。该层通知抽象层操作和相应的变化。抽象层将这些动作和变化发送到核心层,在那里执行业务逻辑并持久化数据变化。当核心层执行了应用逻辑并持久化了变化后,数据将从核心层通过抽象层流向表示层,在那里更新用户视图:

图 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 build 或 ng serve 时,我们的整个应用程序以及它所依赖的所有库都需要被编译以完成构建或提供服务。随着应用程序的增长,这可能会变得耗时。结果是 CI 构建缓慢,开发者在每次想要启动或测试应用程序时都需要等待应用程序编译。Nx 通过增量构建和计算缓存帮助解决这些问题。
使用计算缓存,Nx 将检查自上次您运行命令以来是否有任何更改。如果没有更改或构建计算等于之前的缓存运行,Nx 不会重新运行命令,而是从其缓存系统中获取结果。首先,它将查看本地缓存,如果您设置了 Nx 云的远程缓存,它也会检查是否可以在远程缓存中找到相同的计算哈希。如果 Nx 找不到相同的计算哈希,它将运行命令并将结果的哈希存储在 Nx 缓存中。
除了计算缓存外,Nx 通过增量构建帮助加快我们的构建和编译时间。在使用增量构建时,我们只构建自上次构建以来已更改的项目。在常规场景中,我们会构建应用程序及其所使用的所有库。随着应用程序的增长和依赖多个库,每次构建应用程序时重建所有内容都可能变得耗时且成本高昂。要使用增量构建,您的库必须是可构建的,这样 Nx 才能缓存库,并且只有在它们自上次构建以来已更改时才构建它们。当您构建较小的应用程序时,您可能不希望使用具有计算缓存的库,因为使库可构建也有一些开销。当您在 Nx 工作区中创建库时,您可以选择是否希望它为标准、可构建或可发布的。我们将在 结构化 Angular 应用程序和库 部分更深入地探讨这个话题。
使用 Nx 在 monorepo 中有效地运行任务
在单个 Angular 项目中使用 Angular CLI 运行任务,如 ng build、ng test 和 ng 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
现在,假设我们只想构建、检查和测试 testApp 和 testApp2。为此,我们可以运行以下命令:
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 扩展后,您必须全局安装 Nx 和 Cypress NPM 包。为此,您可以在您选择的终端中运行以下命令。我喜欢使用集成的 VS Code 终端:
npm i -g nx cypress
安装这些 NPM 包后,我们可以创建我们的 Nx 工作区。找到您想创建 Nx monorepo 的文件夹,并在该位置打开终端。要创建 Nx 工作区,请运行以下命令:
npx create-nx-workspace
当您运行此命令时,您将收到几个问题:
- 
您想在何处创建工作区?( business-tools-monorepo。)
- 
您想使用哪个堆栈?(Angular:使用现代工具配置 Angular 应用程序。) 
- 
独立项目或集成 monorepo?(集成 monorepo:Nx 创建一个包含多个项目的 monorepo。) 
- 
应用程序名称( Invoicing)。
- 
您想使用哪个打包器?( esbuild。)
- 
默认样式表格式( SASS(.scss))。
- 
您想启用 服务器端渲染(SSR)和 静态站点生成(SSG)吗?(否。) 
- 
用于端到端测试的测试运行器(Cypress)。 
- 
设置具有缓存、分发和测试去抖动的 CI(跳过)。 
- 
您想使用远程缓存以加快构建速度吗?(是。) 
回答这些问题后,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 插件开始:
- 
要使用插件生成器,通过运行以下命令安装 NPM 包: npm i @nx/plugin
- 
现在,在 VS Code 的左侧,点击 Nx 图标以打开 Nx 控制台。 
- 
进入生成 & 运行目标选项卡,点击生成。这将在 VS Code 顶部打开一个带有搜索栏的下拉菜单。 
- 
在这个搜索栏中,输入 plugin并选择@nx/plugin - plugin Create a Nx plugin选项。这将在 VS Code 中打开一个新窗口,你可以在这个窗口中生成你的插件。
- 
默认情况下,生成器会要求你填写两个字段,一个名称和一个导入路径,以及一个目录,其中名称是必需的。 
- 
将你的插件命名为 workspace-generators-plugin。对于导入路径,输入@business-tools/workspace-generators-plugin,对于目录,输入libs。
- 
点击显示 所有选项。 
- 
在projectNameAndRootFormat下,选择派生。 
- 
然后,点击右上角的生成按钮。 
这将在你的单仓库根目录下的libs文件夹中生成你的插件。
接下来,我们将创建我们的自定义生成器:
- 
首先,在 Nx 控制台中再次点击生成。 
- 
在搜索栏中,输入 generator并选择@nx/plugin - generator。
- 
现在,在新打开的窗口中,给生成器起一个名字,例如 generate-angular-library。
- 
对于 libs\workspace-generators-plugin。
- 
点击显示 所有选项。 
- 
在projectNameAndRootFormat下,选择派生。 
- 
然后,再次点击右上角的生成按钮。 
当此过程完成后,你将在workspace-generators-plugin的src文件夹中找到一个generators文件夹。在这个generators文件夹中,你会找到你的自定义生成器,其名称为generate-angular-library。在你的自定义生成器中有一系列文件,但在我们开始探索它们之前,让我们看看我们将用这个自定义生成器覆盖的内容:
- 
返回你的 Nx 控制台并再次点击生成。 
- 
这次,搜索 library并选择选项 @nx/angular - library。
- 
当你检查这个库的新生成器窗口时,你会找到七个选项来填写;如果你点击显示所有选项,你将有 30 个选项可以填写。并不是所有开发者都知道在这里选择什么,如果我们把它留给开发者,那么在我们创建 monorepo 内的库时,我们会得到太多的变化。现在,让我们关闭生成 Angular 库的窗口,并开始用我们的自定义生成器来覆盖它。 
当我们覆盖一个现有的生成器时,我们对三个不同的文件感兴趣:generator.ts、schema.json和schema.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.json和schema.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`, ‹›);
一旦你更新并保存了所有内容,你就可以测试生成器。
- 
在 VS Code 中打开 Nx 控制台。在顶部,你会找到一个名为 projects的部分。
- 
在这个部分下方,你会找到你的workspace-generators-plugin项目。如果你展开它,你会看到三个选项:构建、lint和测试。 
- 
当你悬停在构建选项上时,你会看到一个播放按钮。请点击这个按钮来构建workspace-generators-plugin项目。 
- 
当插件构建成功后,重启 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:文件夹结构
在进入下一章之前,让我们清理我们的单一代码库并添加一些占位符项目。首先,删除我们之前创建的发票应用程序及其相应的端到端项目。您可以通过右键单击项目并选择“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 的原始值有string、number、bigint、boolean、symbol、null和undefined。非原始值是对象。
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。
按照以下步骤生成必要的组件:
- 
在 pages/expenses-overview-page。
- 
选择 独立 复选框。 
- 
点击 显示所有选项。 
- 
在 变更检测 选择框中,选择 OnPush。 
在右上角,点击 生成。
完成这些步骤后,重复相同的步骤来创建第二个组件。你只需将名称更改为 pages/expenses-approval-page。
现在,让我们使用以下命令来运行 finance-expenses-registration 应用程序:
nx serve finance-expenses-registration
你还可以使用 Nx 控制台来运行你的应用程序。只需在 项目 选项卡下选择应用程序,并在悬停在 运行 上后点击 播放 按钮。
当你在 http://localhost:4200/ 打开应用程序时,你会看到一个空白屏幕。这是因为你只在 app.component.html 文件中有一个路由出口,它显示当前路由,而我们还没有为我们的应用程序配置任何路由。
你的应用程序正在运行,并且有两个组件可以路由,所以让我们为你的应用程序配置一些路由。
配置 Angular 应用程序中的路由
在RouterModule之前,我们通过在forRoot或forChild方法中提供路由来配置它。因为我们使用的是最新的 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 事件(如 click 和 mouseleave)的工作方式相同:
<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:层次依赖创建
现在你已经知道如何使用 @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:层次依赖创建
了解 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 标签中添加变量来访问 index、first、last、odd 和 even 等值。如果我们 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
除了 index、first、last、odd 和 even 属性外,*ngFor 还有一些其他属性可以用来提高其性能。
默认情况下,当你使用 *ngFor 渲染某些内容并且列表中发生变化时,Angular 会重新渲染整个列表。正如你可以想象的那样,这会对你性能产生负面影响。你可以添加 trackBy 函数来改善这一点。当你使用 trackBy 函数时,Angular 会通过 index 或 ID 来识别每个项目。通过这样做,它只会重新渲染发生变化的内容。建议你尽可能多地使用 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;对于结构化指令,你必须注入TemplateRef和ViewContainerRef。当你将结构化指令添加到 HTML 元素时,Angular 会将其转换为嵌入式模板,使用类似于这样的ng-template标签:
<ng-template [ ngIf ]="condition">
  <div>Shown when condition is true</div>
</ng-template>
Angular 创建的嵌入式模板是使用TemplateRef访问的。嵌入式模板在没有结构化指令使用ViewContainerRef将其添加到视图容器之前不会渲染。ViewContainerRef为你提供了访问定义指令宿主元素的视图。
让我们从向你的指令类添加TemplateRef和ViewContainerRef开始。你可以通过构造函数注入或使用inject函数来注入TemplateRef和ViewContainerRef;我将像这样使用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 模板中,如果你想为 TypeDirective 的 btLibsUiType 输入提供一个值,你可以使用以下语法:
<bt-libs-button [style]="'secondary'">XX</bt-libs-button>
在指令内部使用指令组合与在组件中相同。假设我们有一个 backgroundColorDirective 和 textColorDirective;我们可以在 backgroundColorDirective 的 hostDirectives 数组中声明 textColorDirective。现在,当你使用 backgroundColorDirective 时,这两个指令都将应用,并且两个指令的输入都将暴露出来,前提是你已经在 backgroundColorDirective 的 hostDirectives 数组中定义了 textColorDirective 的输入。
当使用指令组合时,你需要使用独立的指令。否则,它将不会工作。此外,每次创建一个组件时,hostDirectives 数组中声明的所有指令都会创建一个新的实例。因为每个指令实例都是为宿主组件的每个实例创建的,所以在使用指令组合时必须小心。当你将太多指令放入常用组件内部时,你的内存使用量将会激增,并会负面影响你应用程序的性能。
在本节中,你学习了属性指令、结构指令、指令选择器和指令组合。现在我们将继续学习本章的下一部分,开始了解如何使用 Angular 管道来转换值。
使用 Angular 管道转换值
在 Angular 中,管道用于转换值。Angular 提供了许多有用的内置管道,并允许你创建自己的管道。让我们首先列出最强大和最常用的内置管道,并简要说明它们的使用目的:
- 
AsyncPipe:AsyncPipe用于处理模板中的异步值。它自动订阅并自动取消订阅,以防止内存泄漏。建议尽可能多地使用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作为其类型。
当你的新库生成后,按照以下步骤生成自定义管道:
- 
关闭并重新打开 VSCode,以确保你的新库包含在 Nx 图示中。 
- 
右键单击此位置的文件夹: libs\shared\util\common-pipes\src\lib,并选择Nx 生成。
- 
输入 pipe并点击@nx/angular – pipe。
- 
在名称字段中输入 multiply。
- 
点击显示 所有选项。 
- 
选择独立复选框。 
- 
在右上角点击生成。 
- 
当组件生成后,将以下内容添加到库中的 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 文件中创建FormGroup、FormControl和FormArray实例来显式定义表单模型。响应式表单的显式特性允许实现更复杂的逻辑、易于测试以及更好的表单控件和模型的重用性。在响应式表单中,验证规则也通过Validator类或自定义验证器显式定义,从而允许更复杂的验证。
响应式表单提供了对表单状态的精细控制,允许你通过程序方式设置、获取和操作值。一旦创建了一个表单及其表单控件,就不能直接修改其值。这使得响应式表单不可变。不可变的表单提供了一个更可靠的数据模型,这反过来又导致错误更少。
由于你直接使用表单 API 在你的 TypeScript 文件中定义表单模型,因此为响应式表单创建的表单模型可以很容易地复用和测试。在编写测试时,响应式表单非常简单,因为你可以直接在测试中使用表单 API,就像你在组件类中做的那样。
响应式表单在视图(我们所说的视图是指浏览器中显示的 HTML 模板)和数据模型之间具有同步的数据流。因此,Angular 可以精确地知道何时在响应式表单上运行变更检测,从而提高性能。
与模板驱动的表单相比,响应式表单虽然需要更多的初始设置,但在处理大型、复杂的表单时更为合适,因为在这种情况下,额外的控制、测试以及表单控件和模型的可重用性至关重要。
模板驱动的表单和响应式表单之间的主要差异
下表概述了模板驱动的表单和响应式表单之间的差异:
| 模板驱动的表单 | 响应式表单 | |
|---|---|---|
| 表单、模型和验证创建 | 隐式使用 HTML 模板中的指令 | 显式使用 TypeScript 文件中的类 | 
| 设置和创建表单 | 易于设置和简单 | 需要更多的初始设置,可能感觉更复杂 | 
| 数据模型 | 非结构化和可变 | 结构化和不可变 | 
| 数据流 | 异步 | 同步 | 
| 与信号兼容 | 好 | 不好 | 
| 测试性 | 单元测试困难 | 使用表单 API 容易测试 | 
| 表单的可重用性和动态创建 | 更难重用或动态构建 | 容易重用和动态构建 | 
表 4.1:模板驱动表单和响应式表单之间的关键区别
现在你已经了解了 Angular 模板驱动和响应式表单的关键特性,让我们深入探讨并学习如何创建这两种类型的表单。
构建模板驱动表单
在本节中,我们将构建一个模板驱动表单。你将学习如何将数据绑定到输入字段,分组表单字段,并在模板驱动表单中执行内置和自定义验证规则。你还将了解模板驱动表单在幕后是如何工作的,以更好地理解模板驱动表单。
到本节结束时,你将能够构建健壮的模板驱动表单,并为我们的演示应用程序创建一个模板驱动表单来添加费用。
创建带有表单组件的表单库
在我们开始创建表单之前,我们需要一个新的库。我们将使用我们在第一章中制作的自定义 Nx 生成器来生成新的库。
你可以就如何分离表单库进行辩论。你可以创建一个包含特定领域所有表单的库,为特定领域的每个应用程序创建一个表单库,或者为每个表单创建一个新的库。
使用每个表单的单个库是使用 Nx 缓存和增量构建系统的最佳方式,但它也在开发和维护方面带来了一些额外的开销。如果你的组织有很多在多个应用程序之间重用的表单,将它们拆分到单独的库中可能值得额外的设置,因为它将加快你的构建和管道。
在我们的示例中,我将创建一个专门用于 expenses-registration 应用程序 的表单库,以便该库将包含此特定应用程序的所有表单:
- 
运行自定义 Nx 生成器以创建库。将其命名为 expenses-registration-forms。
- 
选择域为finance,类型为ui。然后,点击生成。 
- 
一旦生成了库,重新启动 VS Code,以便 Nx 规范与你的新库更新。 
- 
我们将使用 Nx 生成器为我们的模板驱动表单创建一个组件。将组件命名为 add-expense。
- 
选择为项目创建的新库,勾选独立复选框,点击显示所有选项,并为changeDetection选项选择OnPush。 
- 
在右上角点击生成。 
- 
一旦生成了组件,将其导出到库的 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 的属性赋值时,表单也会更新,就这样,您已经创建了一个具有双向数据绑定的表单。
ngModel 和 ngForm 指令还有一些其他有趣的配置,非常有用。让我们首先检查 ngModelOptions。
其他表单字段选项
您可以使用 ngModelOptions 来配置模板驱动表单中的表单控件实例。ngModelOptions 指令可以用来定义 name 属性,控制更新行为,或者将 ngModel 实例标记为独立。
您可以通过将指令添加到声明 ngModel 指令的输入字段中来添加 ngModelOptions:
[ngModelOptions]="{name: 'description', updateOn: 'blur', standalone: false}"
让我们更深入地了解您可以在 ngModelOptions 指令上设置的属性。
使用名称属性
当您设置 ngModelOptions 的 name 属性时,您可以移除 input 字段上的 name 属性,因为使用 ngModelOptions 中的 name 属性与提供 name 属性相同。
使用 updateOn 属性
接下来,我们有 updateOn 属性,它控制表单控件的更新行为,可以取三个值——change、blur 或 submit:
- 
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 元素。
例如,我们可以将金额、不含增值税和增值税百分比字段分组。
我们可以这样操作:
- 
将金额和增值税百分比字段的 HTML 包装在 fieldset标签内,并在该fieldset标签上声明ngModelGroup指令。
- 
为您的 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 文档中查看验证器,并添加你想要使用的验证规则指令。
我简要地提一下模式验证器,因为这是一个特殊的验证器。模式验证器可以用于许多用例,因为它接受一个正则表达式,并检查输入字段中的值是否与正则表达式模式匹配。其他验证器用于单一目的,例如检查最大输入值或字段是否有值。
现在你已经知道了如何添加内置的验证规则,让我们来看看我们如何根据表单和表单控件的状态和有效性来样式化表单字段。
根据控制状态值对表单和表单字段进行样式设置
为了为你的应用程序用户提供良好的用户体验,提供有关表单及其字段状态的可视反馈非常重要。做到这一点的最佳方式是利用你的表单及其表单控件的控制状态。
表单以及其FormGroup和FormControl实例,都由 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:

 
                    
                     
                    
                 
                    
                
 
                
            
         
         浙公网安备 33010602011771号
浙公网安备 33010602011771号