Angular-学习指南第五版-全-

Angular 学习指南第五版(全)

原文:zh.annas-archive.org/md5/0c949428ebde6e02b5e977a141696e15

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

随着 Angular 继续作为 JavaScript 框架中的佼佼者,越来越多的开发者正在寻找开始使用这个极其灵活且安全的框架的最佳方法。"学习 Angular",现在已出到第五版,将向你展示如何使用 Angular 通过最新的网络技术实现跨平台高性能,与现代化网络标准的广泛集成,以及集成开发环境IDEs)的集成。

这本书特别适用于那些刚开始接触 Angular 的人,它将帮助你掌握开始开发 Angular 应用程序所需的框架的基本结构。你将学习如何通过利用 Angular 的命令行界面CLI)来开发应用程序,编写单元测试,根据 Material Design 指南来设计你的应用程序风格,最后,为生产环境构建它们。

更新至 Angular 19,这一新版本涵盖了众多新特性和实践,旨在解决当前前端网络开发挑战。你将发现有关信号和优化的新专门章节,以及更多关于 Angular 中的错误处理和调试的内容,还有新的实际案例。到本书结束时,你不仅能够从头开始使用 TypeScript 创建 Angular 应用程序,还能通过最佳实践提升你的编码技能。

本书面向对象

这本书面向希望开始前端开发的网络开发者,以及希望扩展他们对 JavaScript 框架知识的前端开发者。你需要有 JavaScript 的先验知识,对命令行有基本了解,并且需要熟悉使用 IDE 来开始学习这本书。

本书涵盖内容

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

在这一章中,我们通过安装 Angular CLI 来设置开发环境,并学习如何使用 schematics(命令)来自动化诸如代码生成和应用构建等任务。我们使用 Angular CLI 创建了一个新的简单应用程序并构建了它。我们还学习了 Visual Studio Code 中可用的最有用的 Angular 工具。

第二章TypeScript 简介

在这一章中,我们学习 TypeScript 是什么,它是创建 Angular 应用程序时使用的语言,以及最基本的构建块是什么,例如类型和类。我们还将探讨一些可用的高级类型和语言的最新功能。

第三章使用组件构建用户界面

在这一章中,我们学习组件如何与其模板连接,并使用装饰器来配置它。我们探讨组件之间如何通过使用输入和输出绑定将数据从一个组件传递到另一个组件来相互通信,并了解检测组件中变化的不同策略。我们还学习如何在组件生命周期中执行自定义逻辑。

第四章使用管道和指令丰富应用程序

在本章中,我们查看 Angular 的内置管道,并构建我们自己的自定义管道。我们学习如何创建指令并通过一个演示其使用的 Angular 应用程序来利用它们。

第五章使用服务管理复杂任务

在本章中,我们学习依赖注入机制的工作原理,如何在组件中创建和使用服务,以及如何在 Angular 应用程序中创建提供者。

第六章Angular 中的响应式模式

在本章中,我们学习响应式编程是什么,以及我们如何通过 RxJS 库在 Angular 应用程序上下文中使用可观察对象。我们还游览了在 Angular 应用程序中使用的所有常见 RxJS 操作符。

第七章使用信号跟踪应用程序状态

在本章中,我们学习 Signals API 的基本概念及其使用理由。我们探索如何使用信号跟踪 Angular 应用程序的状态。我们还查看信号与 RxJS 的互操作性以及它们如何在示例应用程序中良好协作。

第八章通过 HTTP 与数据服务通信

在本章中,我们学习如何与远程后端 API 交互,并在 Angular 中执行 CRUD 操作。我们还探讨如何设置额外的 HTTP 请求头,并在发送请求之前或请求完成后拦截它。

第九章使用路由导航应用程序

在本章中,我们学习如何使用 Angular 路由激活 Angular 应用程序的不同部分。我们了解如何通过 URL 传递参数,以及如何将应用程序分解为可以懒加载的子路由。然后我们学习如何保护我们的组件,以及如何在组件初始化之前准备数据。

第十章使用表单收集用户数据

在本章中,我们学习如何使用 Angular 表单将 HTML 表单集成到应用程序中,以及如何使用 FormGroup 和 FormControl 设置它们。我们跟踪用户在表单中的交互并验证输入字段。

第十一章处理应用程序错误

在本章中,我们学习如何处理 Angular 应用程序中的不同类型错误,以及来自框架本身的错误。

第十二章Angular Material 简介

在本章中,我们学习如何使用名为 Angular Material 的库将 Google 材料设计指南集成到 Angular 应用程序中,该库由 Angular 团队开发。我们查看库的一些核心组件及其使用方法。我们讨论库捆绑的主题以及如何安装它们。

第十三章单元测试 Angular 应用程序

在本章中,我们学习如何测试 Angular 艺术品并在测试中覆盖它们,测试的不同部分是什么,以及组件的哪些部分应该被测试。

第十四章将应用程序部署到生产环境

在本章中,我们学习如何使用 Angular CLI 构建和部署 Angular 应用程序。我们查看如何在构建过程中传递环境变量,以及如何在部署前执行构建优化。

第十五章优化应用程序性能

在本章中,我们学习什么是核心 Web 指标CWV)以及它们如何影响 Angular 应用程序的性能。我们探讨了提高 CWV 指标的三种不同方法:如何在服务器端渲染应用程序,如何从水合中受益,以及如何优化我们的图像。

为了充分利用本书

您需要在您的计算机上安装 Angular 19 版本,最好是最新版本。所有代码示例都已在 Windows 上的 Angular 19.0.0 上测试过,但它们也应该适用于 Angular 19 的任何未来版本。

我们建议您亲自输入本书的代码,或者通过 GitHub 仓库(下一节中的链接)获取代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。

下载示例代码文件

您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. www.packtpub.com登录或注册。

  2. 选择支持标签页。

  3. 点击代码下载与勘误

  4. 搜索框中输入本书的名称,并遵循屏幕上的说明。

文件下载完成后,请确保使用最新版本的以下软件解压缩或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Learning-Angular-Fifth-Edition。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/gbp/9781835087480

使用的约定

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

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和社交媒体名称。例如;“将下载的WebStorm-10*.dmg磁盘映像文件作为系统中的另一个磁盘挂载。”

代码块设置如下:

[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0) 

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

[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
**exten => s,102,Voicemail(b100)**
exten => i,1,Voicemail(s0) 

任何命令行输入或输出都如下所示:

# cp /usr/src/asterisk-addons/configs/cdr_mysql.conf.sample
     /etc/asterisk/cdr_mysql.conf 

粗体:表示新术语、重要单词或您在屏幕上看到的单词,例如在菜单或对话框中。例如:“从管理面板中选择系统信息。”

警告或重要注意事项如下所示。

技巧和窍门如下所示。

联系我们

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

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

勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您能向我们报告。请访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,我们将非常感激您能提供位置地址或网站名称。请通过链接与我们联系copyright@packtpub.com

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

评论

一旦您阅读并使用过这本书,为什么不在您购买它的网站上留下评论呢?潜在的读者可以查看并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

有关 Packt 的更多信息,请访问packtpub.com

分享您的想法

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

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

下载此书的免费 PDF 副本

感谢您购买此书!

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

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

不要担心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

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

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

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

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

img

packt.link/free-ebook/9781835087480

  1. 提交您的购买证明。

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

第一章:构建您的第一个 Angular 应用程序

在过去十年中,网络开发经历了巨大的增长。出现了框架、库和工具,使开发者能够构建出色的网络应用程序。Angular 通过创建一个专注于应用性能、开发人体工程学和现代网络技术的框架,开辟了道路。

在开发 Angular 应用程序之前,我们需要学习一些基本但至关重要的东西,以便在 Angular 框架中获得良好的体验。我们应该知道的首要事情之一是 Angular 是什么以及为什么我们应该用它来进行网络开发。我们还将在本章中游览 Angular 的历史,以了解框架是如何演变的。

另一个重要但有时具有挑战性的入门主题是设置我们的开发环境。这必须在项目开始时完成,并且尽早正确设置可以减少随着应用程序的增长而产生的摩擦。因此,本章的大部分内容都致力于 Angular CLI,这是 Angular 团队开发的一个工具,它为 Angular 应用程序提供脚手架和自动化任务,消除配置样板,使开发者能够专注于编码过程。我们将使用 Angular CLI 从头开始创建我们的第一个应用程序,了解 Angular 应用程序的解剖结构,并一窥 Angular 内部的工作原理。

在没有开发工具(如集成开发环境 IDE)的帮助下进行 Angular 项目的开发可能会很痛苦。我们最喜欢的代码编辑器可以提供敏捷的开发工作流程,包括运行时编译、静态类型检查、内省、代码补全以及调试和构建我们的应用程序的视觉辅助。在本章中,我们将突出介绍 Angular 生态系统中最受欢迎的一些工具,例如 Angular DevToolsVisual Studio Code ( VSCode )。

总结,以下是本章我们将探讨的主要主题:

  • 什么是 Angular?

  • 为什么选择 Angular?

  • 设置 Angular CLI 工作空间

  • Angular 应用程序的结构

  • Angular 工具

技术要求

什么是 Angular?

Angular 是用 TypeScript 语言编写的网络框架,包括 CLI、语言服务、调试工具和丰富的第一方库集合。

包含在 Angular 框架中提供的开箱即用的库被称为第一方库。

Angular 使开发者能够使用 TypeScript 构建可扩展的 Web 应用程序,TypeScript 是 JavaScript 的严格超集,我们将在第二章TypeScript 简介中学习它。

官方的 Angular 文档可以在angular.dev找到。

官方的 Angular 文档是 Angular 开发的最新资源。在用 Angular 开发时,优先使用它而不是其他外部资源。

Google 创建了 Angular。第一个版本,1.0,于 2012 年发布,被称为AngularJS。AngularJS 是一个 JavaScript 框架,用其构建的 Web 应用程序是用 JavaScript 编写的。

在 2016 年,Angular 团队决定在 AngularJS 中实施一次革命性的变革。他们与微软的 TypeScript 团队合作,将 TypeScript 语言引入到框架中。框架的下一个版本,2.0,是用 TypeScript 编写的,并重新命名为Angular,其标志与 AngularJS 不同。

2022 年,Angular 进入了一个名为Angular 复兴的新的进化进步时代。在那个时期,框架通过引入专注于增强开发者体验DX)和优化应用程序性能的重大创新,在 Web 开发中获得了动力,例如:

  • 一种简单现代的方法来编写 Angular 应用程序

  • 改进的响应式模式以有效地管理应用程序状态

  • 集成服务器端渲染SSR)技术以提高性能

在 Angular 复兴时代的一个重大里程碑是Angular 17,当时 Angular 团队决定用新的标志和颜色重新命名框架,反映了最近的变化,并为未来的进步设定了愿景。

在这本书中,我们将介绍Angular 19,Angular 框架的最新主要稳定版本。AngularJS 在 2022 年走到了生命的尽头,它不再由 Angular 团队支持和维护。

Angular 基于最现代的 Web 标准,支持所有长期有效的浏览器。您可以在angular.dev/reference/versions#browser-support找到有关每个浏览器特定版本支持的更多详细信息。

在下一节中,我们将学习选择 Angular 进行 Web 开发的益处。

为什么选择 Angular?

Angular 框架的力量基于以下特性的结合:

  • 框架的主要支柱:

    • 跨平台

    • 令人难以置信的工具

    • 易于上手

  • 全球范围内 Angular 的使用:

    • 一个令人惊叹的社区

    • 与 Google 产品进行了实战测试

在接下来的章节中,我们将更详细地考察每个特性。

跨平台

Angular 应用程序可以在不同的平台上运行:Web、服务器、桌面和移动。Angular 只能在 Web 上本地运行,因为它是一个 Web 框架;然而,它是开源的,并拥有令人难以置信的工具支持,使得框架可以通过以下工具在剩余的三个平台上运行:

  • Angular SSR:在服务器端渲染 Angular 应用程序

  • Angular 服务工作者:使 Angular 应用程序可以作为渐进式网络应用程序PWAs)运行,能够在桌面和原生移动环境中执行

  • Ionic/NativeScript:允许我们使用 Angular 构建移动应用程序

框架的下一个支柱描述了 Angular 生态系统中的工具。

工具

Angular 团队构建了两个出色的工具,使 Angular 开发变得简单且有趣:

  • Angular CLI:一个命令行界面,允许我们处理 Angular 项目,从搭建到测试和部署

  • Angular DevTools:一个浏览器扩展,使我们能够从浏览器中检查和配置 Angular 应用程序

Angular CLI 是处理 Angular 应用程序的既定解决方案。它允许开发者专注于编写应用程序代码,消除了配置任务(如搭建、构建、测试和部署 Angular 应用程序)的样板代码。

入门

使用 Angular 开发非常简单且容易入门,因为当我们安装 Angular 时,我们也会获得一套丰富的第一方库,包括:

  • 一个 Angular HTTP 客户端用于通过 HTTP 与外部资源通信

  • 使用 Angular 创建 HTML 表单以收集用户输入和数据

  • 一个 Angular 路由器用于执行应用程序内的导航

当我们使用 Angular CLI 创建新的 Angular 应用程序时,这些库默认安装。然而,只有当我们明确将它们导入到我们的项目中时,它们才会在我们的应用程序中使用。

Angular 在全球的使用情况

许多公司使用 Angular 来构建他们的网站和 Web 应用程序。网站 www.madewithangular.com 包含了这些公司的详尽列表,其中包括一些流行的公司。

此外,Angular 被谷歌和全球数百万开发者用于成千上万个项目中。Angular 已经在谷歌内部使用的事实是框架可靠性的关键因素。Angular 的每个新版本在公开发布之前都会在这些项目中经过彻底测试。测试过程帮助 Angular 团队尽早发现错误,并向整个开发者社区交付高质量的框架。

Angular 得到了一个繁荣的开发者社区的支持。开发者可以访问全球许多可用的社区,无论是线上还是线下,以获得关于 Angular 框架的帮助和指导。另一方面,社区通过分享对新特性的反馈、测试新想法和报告问题来帮助 Angular 框架进步。一些最受欢迎的在线社区包括:

  • Tech Stack Nation:世界上友好的 Angular 学习小组,将热衷于提高构建出色 Angular 应用程序信心的 Angular 开发者聚集在一起。Tech Stack Nation 是一个 Angular 开发者可以协作、从彼此的专业知识中学习并推动 Angular 所能实现边界的社区。您可以通过techstacknation.com加入 Tech Stack Nation。

  • Angular Community Discord:Angular 的官方 Discord 服务器,将令人难以置信的 Angular 社区聚集在一起。每个人都可以通过点击按钮加入社区。这是连接 Angular 团队成员、Google 开发者专家GDEs)、库作者、聚会小组以及任何对学习框架感兴趣的人的中心位置。您可以通过discord.gg/angular加入 Angular Community Discord 服务器。

  • Angular.love:一个由House of Angular支持的 Angular 爱好者社区平台,通过知识共享活动促进 Angular 开发者成长。它最初是一个博客,专家在这里发布关于 Angular 新闻、特性和最佳实践的文章。现在,Angular.love 还组织线下和线上的聚会,经常邀请 GDEs 参加。您可以通过angular.love加入 Angular.love。

现在我们已经了解了 Angular 是什么以及为什么有人会选择它进行 Web 开发,我们将学习如何使用它来构建优秀的 Web 应用程序。

设置 Angular CLI 工作区

使用 Angular 设置项目可能会很棘手。您需要知道要导入哪些库,并确保文件以正确的顺序处理,这使我们来到了脚手架(scaffolding)这一主题。脚手架是一个自动化任务的工具,例如从头开始生成项目,随着复杂性的增加和每小时都关系到产生商业价值而不是花费在解决配置问题上,它变得必要。

创建 Angular CLI 的主要动机是帮助开发者专注于应用程序构建,消除配置模板。本质上,通过一个简单的命令,您应该能够初始化应用程序、添加新工件、运行测试、更新应用程序以及创建生产级别的捆绑包。Angular CLI 使用称为schematics的特殊命令来实现所有这些。

前提条件

在我们开始之前,我们必须确保我们的开发环境包括对 Angular 开发工作流程至关重要的软件工具。

Node.js

Node.js 是建立在 Chrome v8 JavaScript 引擎之上的 JavaScript 运行时。Angular 需要一个活跃或维护的长期支持LTS)版本。如果您已经安装了它,您可以在命令行上运行node -v来检查您正在运行哪个版本。

如果您需要处理使用不同 Node.js 版本的应用程序或由于权限限制而无法安装运行时,请使用nvm,这是一个为 Node.js 设计的版本管理器,旨在按用户安装。您可以在github.com/nvm-sh/nvm上了解更多信息。

npm

npm 是 Node.js 默认包含的软件包管理器。您可以在命令行中运行npm -v来查看它。Angular 应用程序由各种库组成,称为,它们存在于一个称为npm 注册处的中心位置。npm 客户端从 npm 注册处下载并安装运行应用程序所需的库到您的本地计算机。

Git

Git 是一个客户端,允许我们连接到分布式版本控制系统,如 GitHub、Bitbucket 和 GitLab。从 Angular CLI 的角度来看,它是可选的。如果您想将 Angular 项目上传到 Git 仓库,您可能需要这样做,那么您应该安装它。

安装 Angular CLI

Angular CLI 是 Angular 生态系统的一部分,可以从 npm 包注册处下载。由于它用于创建 Angular 项目,我们必须在系统中全局安装它。打开一个终端窗口并运行以下命令:

npm install -g @angular/cli 

您可能在某些 Windows 系统上需要提升权限,因此您应该以管理员身份运行您的终端。在 Linux/macOS 系统上运行前面的命令时,请添加sudo关键字作为前缀以具有管理权限执行。

我们用来安装 Angular CLI 的命令使用了npm客户端,后面跟着一系列运行时参数:

  • installi:表示安装一个包

  • -g--global:表示包将在系统上全局安装

  • @angular/cli:要安装的包的名称

Angular CLI 遵循与 Angular 框架相同的版本,本书中为 19 版本。前面的命令将安装 Angular CLI 的最新稳定版本。您可以通过在命令行中运行ng versionng v来检查您已安装的版本。如果您安装后版本不是 19,可以运行以下命令:

npm install -g @angular/cli@19 

前面的命令将检索并安装 Angular CLI 19 的最新版本。

CLI 命令

Angular CLI 是一个命令行界面工具,在开发过程中自动化特定任务,例如提供、构建、打包、更新和测试 Angular 项目。正如其名所示,它使用命令行来调用ng可执行文件,并使用以下语法运行命令:

ng [command] [options] 

这里,[command] 是要执行的命令的名称,而 [options] 表示可以传递给每个命令的附加参数。要查看所有可用的命令,你可以运行以下命令:

ng help 

一些命令可以使用别名而不是名称来调用。在这本书中,我们涵盖了最常见的命令(每个命令的别名显示在括号内):

  • new (n): 从头创建一个新的 Angular CLI 工作空间

  • build (b): 编译一个 Angular 应用程序,并在预定义的文件夹中输出生成的文件

  • generate (g): 创建构成 Angular 应用程序的新文件

  • serve (dev): 使用预配置的 web 服务器构建 Angular 应用程序并提供服务

  • test (t): 运行 Angular 应用程序的单元测试

  • add : 在 Angular 应用程序中安装 Angular 库

  • update : 将 Angular 应用程序更新到最新的 Angular 版本

你可以在 angular.dev/cli 找到更多 Angular CLI 命令。

更新 Angular 应用程序是上述列表中最关键的任务之一。它通过将我们的 Angular 应用程序升级到最新版本来帮助我们保持最新。

尽量保持你的 Angular 项目更新,因为每个新的 Angular 版本都包含了许多令人兴奋的新功能、性能改进和错误修复。

此外,你还可以使用 Angular 升级指南,其中包含有关更新应用程序的提示和分步说明,请访问 angular.dev/update-guide

创建新项目

现在我们已经准备好了我们的开发环境,我们可以开始创建我们的第一个 Angular 应用程序。我们将使用 Angular CLI 的 ng new 命令,并将我们想要创建的应用程序的名称作为选项传递:

  1. 打开一个终端窗口,导航到你选择的文件夹,并运行命令 ng new my-app。创建一个新的 Angular 应用程序是一个简单的过程。Angular CLI 将询问我们想要创建的应用程序的详细信息,以便它能够尽可能好地构建 Angular 项目。

  2. 初始时,它将询问我们是否想要启用 Angular 分析:

    Would you like to share pseudonymous usage data about this project with the Angular Team at Google under Google's Privacy Policy at https://policies.google.com/privacy. For more details and how to change this setting, see https://angular.dev/cli/analytics. (y/N) 
    

Angular CLI 将在创建第一个 Angular 项目时询问这个问题一次,并将其全局应用于我们的系统。然而,我们可以在特定的 Angular 工作空间中稍后更改设置。

  1. 下一个问题与我们应用程序的样式有关:

    Which stylesheet format would you like to use? 
    

使用 CSS 来样式化 Angular 应用程序是很常见的。然而,我们可以使用预处理程序如 SCSSLess 来增加我们的开发工作流程的价值。在这本书中,我们直接使用 CSS,所以接受默认选择 CSS 并按 Enter

  1. 最后,Angular CLI 将提示我们是否想要在我们的应用程序中启用 SSR 和 静态站点生成 ( SSG ):

    Do you want to enable Server-Side Rendering (SSR) and Static Site Generation (SSG/Prerendering)? (y/N) 
    

SSR 和 SSG 与提高 Angular 应用程序的启动和加载性能有关。我们将在第十五章 优化应用程序性能 中了解更多关于它们的内容。现在,通过按 Enter 键接受默认选择 No

该过程可能需要一些时间,具体取决于您的互联网连接。在此期间,Angular CLI 将下载并安装所有必要的包,并为您的 Angular 应用程序创建默认文件。完成后,它将创建一个名为 my-app 的文件夹。该文件夹代表一个 Angular CLI 工作区,其中包含一个名为 my-app 的 Angular 应用程序,位于根级别。

工作区包含各种文件夹和配置文件,Angular CLI 需要这些文件来构建和测试 Angular 应用程序:

  • .vscode:包含 VSCode 配置文件

  • node_modules:包含开发并运行 Angular 应用程序所需的已安装 npm 包

  • public:包含字体、图像和图标等静态资产

  • src:包含应用程序的源文件

  • .editorconfig:定义默认编辑器的编码风格

  • .gitignore:指定 Git 应该不跟踪的文件和文件夹

  • angular.json:Angular CLI 工作区的主要配置文件

  • package.jsonpackage-lock.json:提供 npm 包的定义,包括它们的精确版本,这些版本对于开发、测试和运行 Angular 应用程序是必需的

  • README.md:由 Angular CLI 自动生成的 README 文件

  • tsconfig.app.json:针对 Angular 应用程序特定的 TypeScript 配置

  • tsconfig.json:针对 Angular CLI 工作区的 TypeScript 配置

  • tsconfig.spec.json:针对 Angular 应用程序单元测试的 TypeScript 配置

作为开发者,我们只应该关心编写实现我们应用程序功能的源代码。然而,了解应用程序是如何编排和配置的基本知识有助于我们更好地理解其机制,并在必要时进行干预。

导航到新创建的文件夹,并使用以下命令启动您的应用程序:

ng serve 

请记住,任何 Angular CLI 命令都必须在 Angular CLI 工作区文件夹内运行。

Angular CLI 编译 Angular 项目并启动一个监视项目文件变化的 Web 服务器。这样,每当您更改应用程序代码时,Web 服务器都会重新构建项目以反映新的更改。

编译成功完成后,您可以通过打开浏览器并导航到 http://localhost:4200 来预览应用程序:

包含文本、屏幕截图、字体样式的图像,自动生成的描述

图 1.1:Angular 应用程序登录页面

恭喜!您已经创建了您的第一个 Angular CLI 工作空间。Angular CLI 创建了一个示例网页,我们可以将其用作构建项目的参考。在下一节中,我们将探索应用程序的主要部分,并学习如何修改此页面。

Angular 应用程序的结构

我们将勇敢地迈出第一步来检查我们的 Angular 应用程序。Angular CLI 已经为我们搭建了项目并完成了大部分繁重的工作。我们所需做的就是启动我们最喜欢的 IDE 并开始与 Angular 项目一起工作。本书中我们将使用 VSCode,但请随意选择您熟悉的任何编辑器:

  1. 打开 VSCode 并从主菜单中选择 文件 | 打开文件夹…

  2. 导航到 my-app 文件夹并选择它。VSCode 将加载相关的 Angular CLI 工作空间。

  3. 展开文件夹 src

当我们开发 Angular 应用程序时,我们可能会与 src 文件夹进行交互。这是我们编写应用程序代码和测试的地方。它包含以下内容:

  • app:应用程序的所有 Angular 相关文件。在开发过程中,您大部分时间都会与此文件夹进行交互。

  • index.html:Angular 应用程序的主 HTML 页面。

  • main.ts:Angular 应用程序的主入口点。

  • styles.css:适用于 Angular 应用程序的全局 CSS 样式。此文件的扩展名取决于创建应用程序时选择的样式表格式。

app 文件夹包含我们为应用程序编写的实际源代码。开发者大部分时间都在这个文件夹内工作。由 Angular CLI 自动创建的 Angular 应用程序包含以下文件:

  • app.component.css:包含针对示例页面的特定 CSS 样式。此文件的扩展名取决于创建应用程序时选择的样式表格式。

  • app.component.html:包含示例页面的 HTML 内容。

  • app.component.spec.ts:包含示例页面的单元测试。

  • app.component.ts:定义示例页面的展示逻辑

  • app.config.ts:定义 Angular 应用程序的配置。

  • app.routes.ts:定义 Angular 应用程序的路由配置。

文件名扩展名 .ts 指的是 TypeScript 文件。

在以下章节中,我们将学习 Angular 如何编排一些文件以显示应用程序的示例页面。

组件

app.component 开头的文件名构成一个Angular 组件。在 Angular 中,组件通过编排展示逻辑与页面 HTML 内容(称为模板)的交互来控制网页的一部分。

每个 Angular 应用程序都有一个主要的 HTML 文件,名为 index.html,它位于 src 文件夹中,并包含以下 <body> HTML 元素:

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

<app-root> 标签用于识别应用程序的主要组件,并作为显示其 HTML 内容的容器。它指示 Angular 在该标签内渲染主要组件的模板。我们将在 第三章使用组件构建用户界面 中学习它是如何工作的。

当 Angular CLI 构建一个 Angular 应用程序时,它会解析 index.html 文件并识别 <body> 元素内的 HTML 标签。Angular 应用程序始终在 <body> 元素内渲染,并包含一个组件树。当 Angular CLI 发现一个不是已知 HTML 元素的标签,例如 <app-root>,它就会开始搜索应用程序树中的组件。但它是如何知道从哪里开始的呢?

引导启动

Angular 应用程序的启动方法称为 引导启动,它在 src 文件夹内的 main.ts 文件中定义:

import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent, appConfig)
  .catch((err) => console.error(err)); 

引导启动文件的 主要任务是定义在应用程序启动时将加载的组件。它调用 bootstrapApplication 方法,传递 AppComponent 作为参数以指定应用程序的起始组件。它还传递 appConfig 对象作为第二个参数以指定在应用程序启动时将使用的配置。应用程序配置在 app.config.ts 文件中描述:

import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
  providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes)]
}; 

appConfig 对象包含一个 providers 属性,用于定义在 Angular 应用程序中提供的所有服务。我们将在 第五章使用服务管理复杂任务 中了解更多关于服务的内容。

默认情况下,新的 Angular CLI 应用程序提供了路由服务。路由与使用浏览器 URL 在不同组件之间进行的应用内导航相关。它通过 provideRouter 方法激活,传递一个 routes 对象,称为 路由配置,作为参数。应用程序的路由配置在 app.routes.ts 文件中定义:

import { Routes } from '@angular/router';
export const routes: Routes = []; 

我们的应用程序还没有路由配置,如空的 routes 数组所示。我们将在 第九章使用路由导航应用程序 中学习如何设置路由并对其进行配置。

模板语法

现在我们已经简要概述了我们的示例应用程序,是时候开始与源代码进行交互了:

  1. 在终端窗口中运行以下命令以启动应用程序(如果尚未运行):

    ng serve 
    

如果你正在使用 VSCode,最好使用其集成的终端,它可以通过主菜单中的 Terminal | New Terminal 选项访问。

  1. 使用浏览器打开应用程序 http://localhost:4200,注意 Angular 标志下方的文本,显示为 Hello, my-app。单词 my-app,与应用程序名称相对应,来自主组件 TypeScript 文件中声明的变量。打开 app.component.ts 文件并找到 title 变量:

    import { Component } from '@angular/core';
    import { RouterOutlet } from '@angular/router';
    @Component({
      selector: 'app-root',
      imports: [RouterOutlet],
      templateUrl: './app.component.html',
      styleUrl: './app.component.css'
    })
    export class AppComponent {
      **title = 'my-app';**
    } 
    

title 变量是一个 组件属性,它在组件模板中使用。

  1. 打开 app.component.html 文件并跳转到第 228 行:

    <h1>Hello, {{ title }}</h1> 
    

title 属性被双大括号语法包围,称为 插值,它是 Angular 模板语法的组成部分。简而言之,插值将 title 属性的值转换为文本并在页面上打印出来。

Angular 使用特定的模板语法来扩展和丰富应用程序模板中的标准 HTML 语法。我们将在 第三章 中学习更多关于 Angular 模板语法的知识,使用组件构建用户界面

  1. AppComponent 类中 title 属性的值更改为 World,保存更改,等待应用程序重新加载,并在浏览器中检查输出:

包含文本、字体、图形、标志的图像,自动生成的描述

图 1.2:着陆页标题

恭喜!您已成功与您应用程序的源代码进行了交互。

到目前为止,您应该已经对 Angular 的工作原理以及 Angular 应用程序的基本组成部分有了基本的了解。作为读者,您到目前为止已经吸收了大量的信息。然而,您将在接下来的章节中有机会更熟悉组件。目前,重点是让您能够快速上手,通过提供像 Angular CLI 这样强大的工具,并展示如何仅通过几个步骤就能在屏幕上显示应用程序。

Angular 工具

Angular 框架在开发者中受欢迎的原因之一是丰富的可用工具生态系统。Angular 社区已经构建了惊人的工具来完成和自动化各种任务,例如调试、检查和编写 Angular 应用程序:

  • Angular DevTools

  • VSCode 调试器

  • VSCode 配置文件

我们将在接下来的章节中学习如何使用每个工具,首先是 Angular DevTools。

Angular DevTools

Angular DevTools 是由 Angular 团队创建和维护的浏览器扩展。它允许我们在浏览器中直接检查和配置 Angular 应用程序。它目前支持 Google Chrome 和 Mozilla Firefox,可以从以下浏览器商店下载:

要打开扩展,请打开浏览器开发者工具并选择 Angular 选项卡。它包含三个附加选项卡:

  • 组件:显示 Angular 应用程序的组件树

  • 分析器:允许我们分析和检查 Angular 应用程序

  • 注入器树:显示 Angular 应用程序提供的服务

在本章中,我们将探讨如何使用组件标签页。我们将在第三章使用组件构建用户界面中学习如何使用分析器标签页,以及在第五章使用服务管理复杂任务中使用注入器树标签页。

组件标签页允许我们预览 Angular 应用的组件和指令,并与它们交互。如果我们从树表示中选择一个组件,我们可以查看其属性和元数据:

包含文本、屏幕截图、字体、行号的自动生成的描述

图 1.3:组件预览

组件标签页,我们还可以在 DOM 中查找相应的 HTML 元素或导航到组件或指令的实际源代码。点击< >按钮将带我们到当前组件的 TypeScript 文件:

img

图 1.4:TypeScript 源文件

双击组件标签页的树表示中的选择器将带我们导航到主页的 DOM 并突出显示单个 HTML 元素:

包含文本、屏幕截图、字体、行号的自动生成的描述

图 1.5:主页 DOM

最后,组件树最有用的功能之一是我们可以更改组件属性的值并检查组件模板的行为:

包含文本、屏幕截图、字体、行号的自动生成的描述

图 1.6:更改组件状态

在前面的图像中,您可以看到当我们将title属性的值更改为Angular World时,更改也反映在组件模板中。

VSCode 调试器

我们可以使用标准 Web 应用调试技术或 VSCode 提供的工具来调试 Angular 应用。

console对象是调试中最常用的 Web API。它是一种快速打印数据并在浏览器控制台中检查值的方法。要检查 Angular 组件中对象的值,我们可以使用debuglog方法,将我们要检查的对象作为参数传递。然而,这被认为是一种过时的方法,包含许多console.log方法的代码库难以阅读。另一种方法是使用 VSCode 调试菜单中的源代码内的断点

VSCode 是由微软支持的开放源代码代码编辑器。它在 Angular 社区中非常受欢迎,主要是因为它对 TypeScript 的强大支持。TypeScript 在很大程度上是由微软推动的项目,因此,其流行的编辑器被构思为内置对该语言的支持是有意义的。它包含丰富的实用功能,包括语法、错误突出显示、自动构建和调试。

VSCode 内置了一个调试工具,它使用断点来调试 Angular 应用程序。我们可以在 VSCode 中的源代码内添加断点,并检查 Angular 应用程序的状态。当 Angular 应用程序运行并遇到断点时,它会暂停并等待。在这段时间内,我们可以调查并检查当前执行上下文中涉及的几个值。

让我们看看如何向我们的示例应用程序添加断点:

  1. 打开app.component.ts文件,并在第 11 行的左侧添加断点。红色圆点表示断点:

包含文本的图片,屏幕截图,字体,编号  自动生成的描述

图 1.7:添加断点

  1. 在 VSCode 左侧侧边栏中点击运行和调试按钮。

  2. 点击播放按钮,使用ng serve命令启动应用程序:

图片

图 1.8:运行和调试菜单

VSCode 将构建我们的应用程序,打开默认的网页浏览器,并在编辑器内设置断点:

图片

图 1.9:遇到断点

我们现在可以检查组件的各个方面,并使用调试工具栏中的按钮来控制调试会话。

VSCode 的另一个强大功能是VSCode 配置文件,它帮助开发者根据他们的开发需求自定义 VSCode。

VSCode 配置文件

VSCode 配置文件允许我们自定义 VSCode 编辑器的以下方面:

  • 设置:VSCode 的配置设置

  • 键盘快捷键:使用键盘执行 VSCode 命令的快捷方式

  • 代码片段:可重用的模板代码片段

  • 任务:直接从 VSCode 自动化执行脚本和工具的任务

  • 扩展:使我们能够在 VSCode 中添加新功能的工具,例如语言、调试器和代码检查器

配置文件也可以共享,这有助于我们在团队中保持一致的开发设置和工作流程。VSCode 包含一组内置配置文件,包括一个用于 Angular 的配置文件,我们可以根据我们的开发需求进一步自定义。要安装 Angular 配置文件:

  1. 在 VSCode 左侧侧边栏底部点击代表齿轮的管理按钮,并选择配置文件选项。

  2. 点击新建配置文件按钮的箭头,并选择从模板 | Angular选项。

  3. 如果您想为您的配置文件选择自定义图标,请点击齿轮按钮。

  4. 点击创建按钮来创建您的配置文件。

VSCode 将在成功创建配置文件后自动应用新配置。

在以下章节中,我们将探索 VSCode Angular 配置文件中的某些扩展。

Angular 语言服务

Angular 语言服务扩展由 Angular 团队开发并维护,它提供了 Angular 模板中的代码补全、导航和错误检测。它为 VSCode 增加了以下功能:

  • 代码补全

  • 跳转到定义

  • 快速信息

  • 诊断消息

为了一窥其强大的功能,让我们看看代码补全功能。假设我们想在主组件的模板中显示一个名为 description 的新属性。我们可以通过以下步骤来设置:

  1. app.component.ts 文件中定义新属性:

    export class AppComponent {
      title = 'my-app';
      **description = 'Hello World';**
    } 
    
  2. 打开 app.component.html 文件,并使用 Angular 插值语法在模板中添加属性名称。Angular 语言服务会自动找到它并为我们建议:

图片

图 1.10:Angular 语言服务

description 属性是一个 公共 属性。当我们使用公共属性和方法时,可以省略关键字 public。代码补全不适用于私有属性和方法。如果属性被声明为 private,那么 Angular 语言服务和模板将无法识别它。

您可能已经注意到,当您输入时,HTML 元素下方立即出现了一条红线。Angular 语言服务直到您正确输入并给出了适当的指示,才识别出该属性,并显示了对这种识别不足的适当指示。如果您将鼠标悬停在红色指示上,它会显示关于出错原因的完整信息消息:

包含文本、字体、行、屏幕截图的图片,自动生成的描述

图 1.11:模板中的错误处理

前面的信息消息来自诊断消息功能。Angular 语言服务根据使用情况支持各种消息。随着您与 Angular 的工作越来越多,您将遇到更多此类消息。

Material Icon Theme

VSCode 内置了一套图标,用于在项目中显示不同类型的文件。Material Icon Theme 扩展提供了符合 Google 的 Material Design 指南的额外图标;该集合的子集针对基于 Angular 的工件:

包含文本、屏幕截图、字体的图片,自动生成的描述

图 1.12:Material Icon Theme

使用此扩展,您可以轻松地识别项目中的 Angular 文件类型,如组件,并提高开发者的生产力,尤其是在文件众多的大型项目中。

EditorConfig

VSCode 编辑器设置,如缩进或间距,可以在用户或项目级别设置。EditorConfig 可以使用 .editorconfig 配置文件覆盖这些设置,该文件位于 Angular CLI 项目的根目录中:

# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false 

您可以在该文件中定义独特的设置,以确保团队中编码风格的一致性。

摘要

就这样!您进入 Angular 世界的旅程才刚刚开始。让我们回顾一下您迄今为止所学的特性。我们学习了 Angular 是什么,回顾了该框架的简要历史,并探讨了使用它进行 Web 开发的优势。

我们看到了如何设置我们的开发工作空间并找到将 TypeScript 带入游戏所需的工具。我们介绍了 Angular CLI 工具,这是 Angular 的瑞士军刀,它可以自动化特定的开发任务。我们使用了一些最常用的命令来搭建我们的第一个 Angular 应用程序。我们还检查了应用程序的结构,并学习了如何与之交互。

我们的第一款应用程序让我们对 Angular 如何在网页上渲染我们的应用程序有了基本的了解。我们开始了我们的旅程,从 Angular 应用程序的主 HTML 文件开始。我们看到了 Angular 如何解析该文件并开始搜索组件树以加载主组件。我们学习了 Angular 启动过程以及它是如何用于加载应用程序配置的。

最后,我们遇到了一些最重要的 Angular 工具,这些工具可以赋予你作为软件开发者的力量。我们探讨了如何使用 Angular DevTools 检查 Angular 应用程序和 VSCode Debugger 进行调试。我们还检查了 VSCode Profiles 以及它如何帮助我们保持团队内的一致开发环境。

在下一章中,你将学习 TypeScript 语言的一些基础知识。本章将涵盖通过引入类型和语言本身可以解决的问题。TypeScript 作为 JavaScript 的超集,包含了许多强大的概念,并且与 Angular 框架配合得很好,正如你即将发现的那样。

加入我们的 Discord

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/LearningAngular5e

img

第二章:TypeScript 简介

正如我们在上一章中学到的,当我们构建我们的第一个 Angular 应用程序时,Angular 项目的代码是用 TypeScript 编写的。使用 TypeScript 编写代码并利用其静态类型给我们带来了与其他脚本语言相比的显著优势。本章不是对 TypeScript 语言的全面概述。相反,我们将关注对本书有用的核心元素。很快我们就会看到,对这些机制有扎实的知识对于理解 Angular 中的依赖注入工作至关重要。

在本章中,我们将涵盖以下主要主题:

  • JavaScript 基础知识

  • 什么是 TypeScript?

  • 开始使用 TypeScript

我们将首先通过回顾与 TypeScript 相关的一些基本特性来刷新我们对 JavaScript 的知识,例如函数和类。然后,我们将研究 TypeScript 的背景及其创建的原理。我们还将学习如何编写和执行 TypeScript 代码。我们将强调类型系统,这是 TypeScript 的主要优势,并学习如何使用它来创建基本类型和接口。

技术要求

JavaScript 基础知识

JavaScript 是一种包含许多用于构建 Web 应用的特性的编程语言。在本节中,我们将回顾并刷新我们对一些最基本特性的知识,因为它们与 TypeScript 和 Angular 开发直接相关。TypeScript 是 JavaScript 的语法定义超集,这意味着它添加了诸如类型、接口和泛型等特性。我们将更详细地查看以下 JavaScript 特性:

  • 变量声明

  • 函数参数

  • 箭头函数

  • 可选链

  • 空值合并

  • 模块

你可以通过以下方式运行本节中的所有代码示例:

  • 在浏览器控制台窗口中输入代码

  • 在 JavaScript 文件中键入代码,并使用 Node.js 来执行它

如果你熟悉这些特性,你可以直接跳转到 什么是 TypeScript? 部分。

变量声明

传统上,JavaScript 开发者使用关键字 var 来声明对象、变量和其他实体。原因是该语言的老式语义只有函数作用域,其中变量在其上下文中是唯一的:

function myFunc() {
  var x = 0;
} 

在前面的函数中,其体内不能声明其他变量作为 x。如果你声明了一个,那么你实际上是在重新定义它。然而,在某些情况下,作用域不适用,例如在循环中:

var x = 20;
for (var x = 0; x < 10; x++) {
} 

在前面的代码片段中,循环外部的 x 变量不会影响循环内部的 x 变量,因为它们有不同的作用域。为了克服作用域限制,JavaScript 引入了 let 关键字:

function myFunc() {
  let x = 0;
  x = 10;
} 

let 关键字允许我们在代码中多次更改变量的引用。

在 JavaScript 中定义变量的另一种方式是 const 关键字,它表示变量不应该改变。随着代码库的增长,可能会发生错误,这可能会造成损失。const 关键字可以防止这类错误。考虑以下代码片段:

const price = 100;
price = 50; 

如果我们尝试执行它,将会抛出以下错误信息:

TypeError: Assignment to constant variable. 

前面的错误只会在顶层出现。如果你声明对象为常量,比如这样,你需要注意这一点:

const product = { price: 100 };
product.price = 50; 

声明 product 变量为常量不会阻止整个对象被编辑,而是阻止其引用被编辑。因此,前面的代码是有效的。如果我们尝试更改变量的引用,我们将得到与之前相同的错误:

const product = { price: 100 };
**product = { price: 50 };** 

当我们确信一个对象的属性在其生命周期内不会改变时,最好使用 const 关键字,因为它可以防止对象意外更改。

当我们想要合并变量时,我们可以使用 扩展参数 语法。扩展参数使用省略号()来展开变量的值:

const category = 'Computing';
const categories = ['Gaming', 'Multimedia'];
const productCategories = [...categories, category]; 

在前面的代码片段中,我们将 categories 数组与 category 项目合并,以创建一个新的数组。categories 数组仍然包含两个项目,而新的数组包含三个。当前的行为称为 不可变性,这意味着不更改变量,而是创建一个新的变量,该变量来自原始变量。

如果一个对象的属性可以被更改,或者其属性是一个其属性可以被更改的对象,那么这个对象就不是不可变的。

我们还可以在对象上使用扩展参数:

const product = {
  name: 'Keyboard',
  price: 75
};
const newProduct = {
  ...product,
  price: 100,
  category: 'Computing'
}; 

在前面的代码片段中,我们没有更改原始的 product 对象,而是创建了两个对象之间的合并。newProduct 对象的值将是:

{
  name: 'Keyboard',
  price: 100,
  category: 'Computing'
} 

newProduct 对象从 product 对象中获取属性,在其之上添加新的值,并替换现有的值。

函数参数

JavaScript 中的函数是我们用来分析输入、消化信息并对数据进行必要转换的处理机。它们使用参数来提供数据以转换我们应用程序的状态或返回一个将被用来塑造应用程序的业务逻辑或用户交互性的输出。

我们可以声明一个函数以接受默认参数,这样函数在执行时如果没有明确传递,就会假定一个默认值:

function addtoCart(productId, quantity = 1) {
  const product = {
    id: productId,
    qty: quantity
  };
} 

如果在调用函数时没有为 quantity 参数传递值,我们将得到一个 qty 设置为 1product 对象。

默认参数必须在函数签名中的所有 必需 参数之后定义。

在定义函数时,JavaScript 的灵活性具有一个显著的优势,即接受一个无限的非声明参数数组,称为剩余参数。本质上,我们可以在参数列表的末尾定义一个额外的参数,该参数由省略号()前缀:

function addProduct(name, ...categories) {
  const product = {
    name,
    categories: categories.join(',')
  };
} 

在前面的函数中,我们使用 join 方法从 categories 参数创建一个以逗号分隔的字符串。我们调用函数时分别传递每个参数:

addProduct('Keyboard', 'Computing', 'Peripherals'); 

当我们不知道将传递多少参数时,剩余参数非常有用。name 属性也是使用 JavaScript 语言的一个有用特性设置的。我们不是在 product 对象中显式设置属性,而是直接使用属性名。以下代码片段与 addProduct 函数的初始声明等效:

function addProduct(name, ...categories) {
  const product = {
    name`:` **name**,
    categories: categories.join(',')
  };
} 

当参数名称与对象的属性名称匹配时,可以使用简写语法来分配属性值。

箭头函数

在 JavaScript 中,我们可以以另一种方式创建函数,称为箭头函数。箭头函数的目的是简化通用函数语法,并提供一种安全处理函数作用域的方法,这在传统上是由 this 对象处理的。考虑以下示例,它根据产品的价格计算产品折扣:

const discount = (price) => {
  return (price / 100) * 10 ; 
}; 

上述代码没有 function 关键字,函数体由箭头(=>)定义。箭头函数可以通过以下最佳实践进一步简化:

  • 当函数签名只有一个参数时,可以省略函数参数中的括号。

  • 如果函数体只有一个语句,则可以省略花括号和 return 关键字。

结果函数将看起来更简单,更容易阅读:

const discount = price => (price / 100) * 10; 

现在我们来解释箭头函数是如何与作用域处理相关的。this 对象的值可以指向不同的上下文,这取决于我们在哪里执行函数。当我们在一个回调函数内部使用它时,我们会失去上层上下文,这通常会导致我们使用诸如将其值赋给外部变量之类的约定。考虑以下函数,它使用原生的 setTimeout 函数记录产品名称:

function createProduct(name) {
  this.name = name;
  this.getName = function() {
    setTimeout(function() {
      console.log('Product name is:', this.name);
    });
  }
} 

使用以下代码片段执行 getName 函数,并观察控制台输出:

const product = new createProduct('Monitor');
product.getName(); 

上述代码片段不会按预期打印出 Monitor 产品的名称,因为我们在 setTimeout 回调内部评估函数时修改了 this 对象的作用域。为了修复它,将 setTimeout 函数转换为使用箭头函数:

setTimeout(() => {
  console.log('Product name is:', this.name);
}); 

我们的代码现在更简单,我们可以安全地使用函数作用域。

可选链

可选链是一个强大的功能,可以帮助我们重构和简化代码。简而言之,它可以在某个语句中某个值未被提供的情况下,引导我们的代码忽略该语句的执行。让我们通过一个示例来看看可选链:

const getOrder = () => {
  return {
    product: {
      name: 'Keyboard'
    }
  };
}; 

在前面的代码片段中,我们定义了一个getOrder函数,它返回特定订单的产品。接下来,让我们获取product属性的值,确保在读取之前存在order

const order = getOrder();
if (order !== undefined) {
  const product = order.product;
} 

之前的代码片段是在我们的对象已被修改的情况下采取的预防措施。如果我们不检查对象并且它已经变为undefined,JavaScript 将抛出一个错误。然而,我们可以使用可选链来改进之前的语句:

const order = getOrder();
**const product = order?.product;** 

order对象后面的字符?确保只有当对象有值时才会访问product属性。可选链在更复杂的场景中也同样适用,例如:

const name = order?.product?.name; 

在前面的代码片段中,我们也检查了product对象是否有值,然后再访问其name属性。

空值合并

空值合并与在变量未设置时提供默认值相关。考虑以下示例,它只在qty变量存在时才将值赋给quantity变量:

const quantity = qty ? qty : 1; 

之前的语句被称为三元运算符,它的工作方式类似于条件语句。如果qty变量没有值,quantity变量将被初始化为默认值1。我们可以使用空值合并来重写之前的表达式:

const quantity = qty ?? 1; 

空值合并有助于使我们的代码更易读且更简洁。

JavaScript 类允许我们组织我们的应用程序代码并创建每个类的实例。一个类可以有属性成员、构造函数、方法和属性访问器。以下代码片段展示了类的外观:

class User {
  firstName = '';
  lastName = '';
  #isActive = false;
  constructor(firstName, lastName, isActive = true) {
    this.firstName = firstName;
    this.lastName = lastName;
    this.#isActive = isActive;
  }
  getFullname() {
    return `${this.firstName} ${this.lastName}`;
  }

  get active() {
    return this.#isActive;
  }
} 

类语句封装了几个元素,我们可以将其分解:

  • 成员User类包含firstNamelastName#isActive成员。类成员只能从类本身内部访问。User类的实例将只能访问公共属性firstNamelastName#isActive属性将不可用,因为它是一个私有属性,如属性名前的#字符所示。

  • 构造函数:当创建类的实例时执行constructor。它通常用于使用签名中提供的参数初始化类成员。我们还可以为参数如isActive参数提供默认值。

  • 方法:方法代表一个函数,可以返回一个值,例如getFullname方法,它构建用户的完整名称。它也可以定义为私有,类似于类成员。

  • 属性访问器:属性访问器是通过在方法前加上set关键字使其可写,加上get关键字使其可读,然后跟我们要公开的属性名来定义的。active方法是一个属性访问器,它返回#isActive成员的值。

类还可以扩展其他类的成员和功能。我们可以通过在类定义中添加 extends 关键字并跟随后要继承的类来使一个类继承另一个类:

class Customer extends User {
  taxNumber = '';

  constructor(firstName, lastName) {
    super(firstName, lastName);
  }
} 

在前面的代码片段中,Customer 类扩展了 User 类,这暴露了 firstNamelastName 属性。Customer 类的任何实例都可以默认使用这些属性。我们还可以通过添加具有相同名称的方法来覆盖 User 类的方法。constructor 方法需要调用 super 方法,它指向 User 类的 constructor

模块

随着我们的应用程序规模和增长,将会有一个时刻我们需要更好地组织代码,并使其可持续和可重用。模块是实现这些任务的好方法,因此让我们看看它们是如何工作的,以及我们如何在应用程序中实现它们。

在前面的章节中,我们学习了如何使用类。将两个类放在同一个文件中是不可扩展的,维护它也不会容易。想象一下,你必须处理多少代码才能在其中一个类中做简单的更改。模块允许我们将应用程序代码分离成单个文件,强制执行 单一职责模式SRP)。每个文件都是一个不同的模块,关注特定的功能或功能。

将模块拆分为多个文件的良好迹象是当模块开始占据不同的领域时。例如,产品模块不能包含客户逻辑。

让我们重构上一节中描述的代码,以便 UserCustomer 类属于不同的模块:

  1. 打开 VSCode 并创建一个名为 user.js 的新 JavaScript 文件。

  2. User 类的内容输入进去,并在类定义中添加 export 关键字。export 关键字使模块对其他模块可用,并形成模块的公共 API。

  3. 创建一个名为 customer.js 的新 JavaScript 文件,并将 Customer 类的内容添加进去。Customer 类不能识别 User 类,因为它们在不同的文件中。

  4. 通过在文件顶部添加以下语句将 User 类导入到 customer.js 文件中:

    import { User } from './user'; 
    

我们使用 import 关键字和模块文件的无扩展名相对路径来导入 User 类。如果一个模块导出多个工件,我们将它们放在大括号内,用逗号分隔,例如:

import { User, UserPreferences } from './user'; 

探索模块结束了我们对 JavaScript 基础知识的旅程。在下一节中,我们将学习 TypeScript 以及它是如何帮助我们构建 Web 应用程序的。

什么是 TypeScript?

由于早期 JavaScript 版本的限制,将小型网络应用程序转换为厚重的单体客户端是不可能的。简而言之,大规模 JavaScript 应用程序在规模和复杂性增加后,就面临着严重的可维护性和可扩展性问题。随着新库和模块需要无缝集成到我们的应用程序中,这个问题变得更加相关。缺乏适当的互操作性机制导致了繁琐的解决方案。

为了克服这些困难,微软构建了一个 JavaScript 超集,这将有助于使用静态类型检查、更好的工具和代码分析来构建具有较低错误足迹的企业应用程序。TypeScript 1.0 于 2014 年推出。它领先于 JavaScript,实现了相同的功能,并为构建大规模应用程序提供了一个稳定的环境。它通过类型注解引入了可选的静态类型,从而确保了编译时的类型检查并在开发过程中早期捕捉到错误。它对声明文件的支持还使开发者能够描述他们模块的接口,以便其他开发者能够更好地将它们集成到他们的代码工作流程和工具中。

您可以通过 www.typescriptlang.org 访问官方 TypeScript 网站。它包含了广泛的语言文档和一个游乐场,我们可以快速通过教程熟悉语言。它包含了一些现成的代码示例,涵盖了语言的一些最常见特性。

作为 JavaScript 的超集,在您的下一个项目中采用 TypeScript 的一大优势是低门槛。如果您了解 JavaScript,那么您基本上已经准备好了,因为 TypeScript 中的所有附加功能都是可选的。您可以挑选并引入其中任何一项来实现您的目标。总的来说,使用 TypeScript 在您的下一个项目中有很多坚实的论据,并且这些论据都适用于 Angular。

下面是一些优势的简要概述:

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

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

  • 使用类型确保了您应用程序的一致性。结合前两点,从长远来看,整体代码错误足迹得到了最小化。

  • 接口确保了您的库在其他系统和代码库中的平稳无缝集成。

  • 不同 IDE 上的语言支持令人惊叹,您可以免费享受代码高亮、实时类型检查和自动编译等特性。

  • 语法对来自其他基于 OOP 的背景(如 Java、C# 和 C++)的开发者来说很熟悉。

在下一节中,我们将学习如何开发和执行 TypeScript 应用程序。在 Angular 应用程序中,我们不需要手动执行 TypeScript 代码,因为 Angular CLI 会自动处理它;然而,了解其底层工作原理是很好的。

开始使用 TypeScript

TypeScript 语言是一个可以从 npm 注册表安装的 npm 包,使用以下命令进行安装:

npm install -g typescript 

在上述命令中,我们选择在系统中全局安装 TypeScript,这样我们就可以在开发环境的任何路径中使用它。让我们通过一个简单的示例来看看如何使用 TypeScript:

  1. 打开 VSCode 并从主菜单选项中选择 文件 | 新建文件…

  2. 新建文件… 对话框中输入 app.ts 并按 Enter 键。

包含文本的图片,屏幕截图,字体,行描述由自动创建

图 2.1:新建文件… 对话框

正如我们已经学到的,TypeScript 文件具有 .ts 扩展名。

  1. 选择你想要创建新文件的路径。VSCode 将在编辑器中打开该文件。

  2. 将以下代码片段输入到 app.ts 文件中:

    const title = 'Hello TypeScript!'; 
    

尽管我们已经创建了一个 TypeScript 文件,但上述代码片段是有效的 JavaScript 代码。回想一下,TypeScript 是 JavaScript 的超集,它通过其类型系统提供语法糖。然而,使用 TypeScript 编写纯 JavaScript 代码并不会给我们带来任何明显的优势。

  1. 打开一个终端窗口并运行以下命令以将 TypeScript 文件编译成 JavaScript:

    tsc app.ts 
    

上述命令启动了一个名为 编译 的过程,由 tsc 可执行文件执行,它是 TypeScript 语言的核心理念。我们需要将 TypeScript 代码编译成 JavaScript,因为浏览器目前不支持 TypeScript。

Angular 使用一个编译器,该编译器在底层使用 TypeScript 编译器来构建 Angular 应用程序。

TypeScript 编译器支持额外的配置选项,我们可以通过终端窗口或配置文件将它们传递给 tsc 可执行文件。完整的编译器选项列表可以在 www.typescriptlang.org/docs/handbook/compiler-options.html 找到。

  1. 编译过程将在 TypeScript 文件所在的同一文件夹中创建一个 app.js 文件。新文件将包含以下代码:

    var title = 'Hello TypeScript!'; 
    

由于我们尚未使用任何特定的 TypeScript 功能,上述代码片段几乎与原始代码相同,除了变量声明。

  1. 编译过程将 const 关键字替换为 var 关键字,因为 TypeScript 编译器默认使用旧的 JavaScript 版本。我们可以通过在 tsc 命令中指定 target 来更改这一点:

    tsc app.ts --target es2022 
    

在前面的命令中,我们指定了 es2022,这代表了撰写时的 JavaScript 语言的最新版本。我们将在本书中构建的 Angular 应用程序默认也针对相同的 JavaScript 版本。

  1. 由于我们将在本章的其余部分使用最新的 JavaScript 版本,让我们使用 TypeScript 配置文件来定义 target 选项。在当前文件夹中创建一个名为 tsconfig.json 的文件,并添加以下内容:

    {
      "compilerOptions": {
        "target": "ES2022"
      }
    } 
    

你可以在 www.typescriptlang.org/tsconfig 找到 TypeScript 配置文件的更多选项。

在终端窗口中运行 tsc 命令以验证输出 JavaScript 文件保持不变。

当我们不带选项运行 tsc 命令时,它将使用配置文件中的选项编译当前文件夹中的所有 TypeScript 文件。

我们迄今为止编写的 TypeScript 代码没有使用 TypeScript 特定功能。在下一节中,我们将学习如何使用类型系统,这是 TypeScript 语言最强大和最基本的功能。

类型

使用 TypeScript 或任何其他编程语言意味着处理数据,这些数据可以代表不同类型的内容,称为 类型。类型用于表示数据可以是文本、整数值或这些值类型的数组等。

类型在编译过程中消失,并且不包括在最终的 JavaScript 代码中。

你可能已经在 JavaScript 中遇到了类型,因为我们总是隐式地使用它们。在 JavaScript 中,任何给定的变量都可能假设(或返回,在函数的情况下)任何值。有时,这会导致我们的代码中出现错误和异常,因为代码返回的类型与我们期望返回的类型之间发生类型冲突。然而,静态类型化我们的变量为我们和我们的 IDE 提供了一个很好的了解,在每个代码实例中我们应该找到什么类型的数据。这成为了一种在代码执行之前在编译时帮助我们调试应用程序的无价方式。

字符串

最广泛使用的原始类型之一是 string,它用文本填充变量:

const product: string = 'Keyboard'; 

类型是通过在变量旁边添加冒号和类型名称来定义的。

布尔

boolean 类型定义了一个变量,它可以具有 truefalse 的值:

const isActive: boolean = true; 

boolean 变量的结果是条件语句的满足。

数字

number 类型可能是除 stringboolean 之外最广泛使用的原始数据类型:

const price: number = 100; 

我们可以使用 number 类型来定义浮点数和十六进制、十进制、二进制和八进制字面量。

数组

数组类型定义了一个只包含特定类型的项的列表。现在,我们可以通过这种类型轻松避免由错误引起的异常,例如在列表中分配错误的成员类型。我们可以使用方括号语法或Array关键字来定义数组:

const categories: string[] = ['Computing', 'Multimedia'];
const categories: Array<string> = ['Computing', 'Multimedia']; 

在应用程序开发期间,与团队就语法达成一致并坚持使用它是明智的。

如果我们尝试向categories数组添加一个类型不是string的新项,TypeScript 将抛出一个错误,确保我们的类型化成员保持一致,我们的代码无错误。

any

在所有前面的情况下,类型是可选的,因为 TypeScript 足够智能,可以从变量的值中推断出数据类型,具有一定的准确性。

让类型系统推断类型非常重要,而不是手动进行类型化。类型系统永远不会出错,但开发者可能会出错。

然而,如果不可能,类型系统将自动将动态的any类型分配给弱类型数据,代价是降低类型检查到最低限度。此外,当从任何给定点的信息中难以推断数据类型时,我们可以在代码中手动添加any类型。any类型包括所有其他现有类型,因此我们可以用它可以对任何数据值进行类型化,并在以后将其赋值:

let order: any;
function setOrderNo() {
  order = '0001';
} 

TypeScript 包含另一种类型,类似于any类型,称为unknownunknown类型的变量可以具有任何类型的值。主要区别在于,除非我们首先进行类型检查,否则 TypeScript 不会让我们对未知值执行任意操作,例如调用方法。

然而,权力越大,责任越大。如果我们绕过静态类型检查的便利性,我们就会打开在通过我们的应用程序传输数据时出现类型错误的门。确保我们应用程序中的类型安全取决于我们。

自定义类型

在 TypeScript 中,如果你需要,可以通过以下方式使用type关键字来自定义类型:

type Categories = 'computing' | 'multimedia'; 

然后,我们可以创建一个特定类型的变量,如下所示:

const category: Categories = 'computing'; 

前面的代码作为computing是允许的值之一,并且按预期工作。自定义类型是添加有限数量允许值的类型的绝佳方式。

当我们想要从一个对象创建一个自定义类型时,我们可以使用keyof运算符。keyof运算符使我们能够遍历对象的属性并将它们提取到一个新类型中:

type Category = {
  computing: string;
  multimedia: string;
};
type CategoryType = keyof Category; 

在前面的代码片段中,CategoryType产生的结果与Categories类型相同。我们将学习如何在第四章中动态地使用keyof运算符遍历对象属性,即使用管道和指令丰富应用程序

TypeScript 的类型系统主要用于用类型注解 JavaScript 代码。它通过提供智能感应和防止开发早期出现的错误来提高开发者体验。在下一节中,我们将学习更多关于在函数中添加类型注解的内容。

函数

TypeScript 中的函数与普通 JavaScript 函数没有太大区别,除了像 TypeScript 中的其他一切一样,它们可以用静态类型进行注解。因此,它们通过在它们的签名中提供编译器期望的信息以及它们旨在返回的数据类型(如果有的话)来改进编译器。

以下示例展示了如何在 TypeScript 中注解普通函数:

function getProduct(): string {
  return 'Keyboard';
} 

在前面的代码片段中,我们通过在函数声明中添加 string 类型来注解了函数的返回值。我们也可以在函数参数中添加类型,例如:

function getFullname(firstName: string, lastName: string): string {
  return `${this.firstName} ${this.lastName}`;
} 

在前面的代码片段中,我们注解了在函数签名中声明的参数,这是有意义的,因为编译器将想要检查提供的数据是否具有正确的类型。

如前文所述,TypeScript 编译器足够智能,能够在没有提供注解的情况下推断类型。在上述两个函数中,我们可以省略类型,因为编译器可以从提供的参数和返回语句中推断出它。

当一个函数不返回类型时,我们可以使用 void 类型来注解它:

function printFullname(firstName: string, lastName: string): void {
  console.log(`${this.firstName} ${this.lastName}`);
} 

我们已经学习了如何在 JavaScript 函数中使用默认和剩余参数。TypeScript 通过引入可选参数扩展了函数的功能。通过在参数名称后添加字符 ? 来定义可选参数:

function addtoCart(productId: number, quantity?: number) {
  const product = {
    id: productId,
    qty: quantity ?? 1
  };
} 

在前面的函数中,我们将 quantity 定义为可选参数。我们还使用了空值合并运算符来设置 product 对象的 qty 属性,如果未传递 quantity

我们可以通过仅传递 productId 参数或两者都传递来调用 addToCart 函数。

可选参数应该放在函数签名中的最后。

我们已经学习了如何使用 JavaScript 类来帮助我们组织应用程序代码。在下一节中,我们将看到如何在 TypeScript 中使用它们来进一步改进我们的应用程序。

考虑我们在 user.js 文件中定义的 User 类:

export class User {
  firstName = '';
  lastName = '';
  #isActive = false;
  constructor(firstName, lastName, isActive = true) {
    this.firstName = firstName;
    this.lastName = lastName;
    this.#isActive = isActive;
  }
  getFullname() {
    return `${this.firstName} ${this.lastName}`;
  }

  get active() {
    return this.#isActive;
  }
} 

我们将采取简单、小步骤来在整个类中添加类型:

  1. 将文件重命名为 user.ts 以将其转换为 TypeScript。

  2. 为所有类属性添加以下类型:

    firstName: **string** = '';
    lastName: **string** = '';
    **private isActive: boolean** = false; 
    

在前面的代码片段中,我们还使用了 private 修饰符来定义 isActive 属性为私有。

  1. 通过为参数添加类型来修改 constructor

    constructor(firstName`:` **string**, lastName`:` **string**, isActive`:` **boolean** = true) {
      this.firstName = firstName;
      this.lastName = lastName;
      this**.isActive** = isActive;
    } 
    

或者,我们可以省略类属性,让 constructor 通过将参数声明为 private 自动创建它们:

constructor(private firstName: string, private lastName: string, private isActive: boolean = true) {}

  1. 最后,在 active 属性访问器和 getFullname 方法中添加类型:

    getFullname()**: string** {
      return `${this.firstName} ${this.lastName}`;
    }
    get active()**: boolean** {
      return this**.isActive****;**
    } 
    

将 JavaScript 类转换为 TypeScript 并添加类型是利用 TypeScript 中类型功能的重要步骤。

TypeScript 与类相关的一个伟大特性是 instanceOf 关键字。它允许我们检查类实例的类型,并按照相关类提供正确的属性。让我们通过在 customer.js 文件中定义的 Customer 类来探索它:

  1. 将文件重命名为 customer.ts 以将其转换为 TypeScript。

  2. 按如下方式重写 Customer 类以添加类型:

    class Customer extends User {
      taxNumber**: number;**
      constructor(firstName**: string**, lastName**: string**) {
        super(firstName, lastName);
      }
    } 
    
  3. 创建一个可以在 UserCustomer 类型之间切换的对象:

    const account: User | Customer = undefined; 
    
  4. 我们现在可以使用 instanceOf 关键字根据底层类访问 account 对象的不同属性:

    if (account instanceof Customer) {
      const taxNo = account.taxNumber;
    } else {
      const name = account.getFullname();
    } 
    

TypeScript 足够智能,能够理解在 else 语句中的 account 对象没有 taxNumber 属性,因为它属于 User 类型。即使我们错误地尝试访问它,VSCode 也会抛出一个错误:

包含文本的图像,屏幕截图,字体,行描述由自动创建

图 2.2:属性访问错误

TypeScript 类帮助我们编写结构良好的代码,可以被实例化,包含业务逻辑,并在我们的应用程序中提供静态类型。随着应用程序的扩展和更多类的创建,我们需要找到确保代码一致性和规则遵守的方法。正如我们将在下一节中学习的,解决一致性和类型验证的最佳方法之一是创建 接口

接口

接口是一种代码契约,它定义了特定的模式。任何实现接口的类和函数等工件都应该遵守此模式。当我们想要对由工厂生成的类强制执行严格的类型检查,或者当我们定义函数签名以确保在有效负载中找到特定类型的属性时,接口是有益的。

接口在编译过程中消失,并且不会包含在最终的 JavaScript 代码中。

在下面的代码片段中,我们定义了一个用于管理产品的接口:

interface Product {
  name: string;
  price: number;
  getCategories: () => string[];
} 

当处理来自后端 API 或其他来源的数据时,接口是推荐的方法。

一个接口可以包含属性和方法。在上面的代码片段中,Product 接口包含了 nameprice 属性。它还定义了 getCategories 方法。一个类可以通过在类声明中添加 implements 关键字和接口名称来使用接口:

class Keyboard implements Product {
  name: string = 'Keyboard';
  price: number = 20;
  getCategories(): string[] {
    return ['Computing', 'Peripherals'];
  }
} 

在前面的代码片段中,Keyboard 类必须实现 Product 接口的所有成员;否则,TypeScript 会抛出一个错误。如果我们不想实现接口成员,我们可以使用 ? 字符将其定义为可选:

interface Product {
  name: string;
  price: number;
  getCategories: () => string[];
  **description?: string;**
} 

我们还可以使用接口来改变变量的类型,从一种类型转换为另一种类型,这被称为类型转换。当处理动态数据或 TypeScript 无法自动推断变量类型时,类型转换非常有用。在以下代码中,我们指示 TypeScript 将product对象视为Product类型:

const product = {
  name: 'Keyboard',
  price: 20
} as Product; 

然而,类型转换应该谨慎使用。在先前的代码片段中,我们故意省略了添加getCategories方法,但 TypeScript 没有抛出错误。当我们使用类型转换时,我们告诉 TypeScript 一个变量假装是特定类型。

建议尽可能避免类型转换,并明确定义类型。

接口可以与泛型结合使用,无论数据类型如何,都可以提供通用的代码行为,正如我们将在下一节中学习的那样。

泛型

当我们想在其他 TypeScript 元素(如方法)中使用动态类型时,会使用泛型。

假设我们想要创建一个用于在浏览器本地存储中保存Product对象的函数:

function save(data: Product) {
  localStorage.setItem('Product', JSON.stringify(data));
} 

在先前的代码中,我们明确地将data参数定义为Product。如果我们还想保存Keyboard对象,我们应该按以下方式修改save方法:

function save(data: Product **| Keyboard**) {
  localStorage.setItem('Product', JSON.stringify(data));
} 

然而,如果我们未来想要添加其他类型,先前的这种方法并不容易扩展。相反,我们可以使用泛型来让save方法的消费者决定传递的数据类型:

function save<T>(data: T) {
  localStorage.setItem('Product', JSON.stringify(data));
} 

在先前的例子中,T的类型直到我们使用该方法时才会被评估。我们使用T作为定义泛型的约定,但你也可以使用其他字母。我们可以按照以下方式为Product对象执行save方法:

save<Product>({
  name: 'Microphone',
  price: 45,
  getCategories: () => ['Peripherals', 'Multimedia']
}); 

正如你所见,其类型根据你如何调用它而变化。它还确保你传递了正确的数据类型。假设先前的方法以这种方式被调用:

save<Product>('Microphone'); 

我们指定T应该是Product,但我们坚持将其值作为字符串传递。编译器清楚地指出这是不正确的。如果我们想在save方法中使用更多泛型,我们可以使用不同的字母,例如:

function save<T, **P**>(data: T, **obj: P**) {
  localStorage.setItem('Product', JSON.stringify(data));
} 

泛型通常用于集合中,因为它们的行为相似,无论类型如何。然而,它们也可以用于其他构造,如方法。其理念是泛型应该表明你即将以不允许的方式混合类型。

如果你有一个与许多不同数据类型相关的典型行为,泛型是非常强大的。你可能不会编写自定义泛型,至少最初不会,但了解正在发生的事情是好的。

在下一节中,我们将探讨一些与接口相关的实用类型,这些类型将有助于我们在 Angular 开发过程中。

实用类型

实用类型是帮助我们从现有类型派生出新类型的类型。

当我们想要从一个所有属性都是可选的接口中创建对象时,会使用Partial类型。在下面的代码片段中,我们使用Product接口声明了一个产品的简化版本:

const mic: Partial<Product> = {
  name: 'Microphone',
  price: 67
}; 

在前面的代码片段中,我们可以看到mic对象不包含getCategories方法。或者,我们可以使用Pick类型,它允许我们从接口属性的一个子集中创建对象:

type Microphone = Pick<Product, 'name' | 'price'>;
const microphone: Microphone = {
  name: 'Microphone',
  price: 67
}; 

一些语言,如 C#,在定义键值对对象或字典时有一个保留类型,正如其名。在 TypeScript 中,如果我们想定义这样的类型,我们可以使用Record类型:

interface Order {
  products: Record<string, number>;
} 

前面的代码片段将产品名称定义为string类型,将数量定义为number类型。

你可以在www.typescriptlang.org/docs/handbook/utility-types.html找到更多实用类型。

摘要

虽然阅读起来很长,但这个 TypeScript 入门介绍对于理解 Angular 中许多最精彩部分的逻辑是必要的。它使我们能够介绍语言语法并解释其作为构建 Angular 框架首选语法的成功原因。

我们回顾了类型架构以及在设计具有各种参数签名替代方案的功能时如何创建高级业务逻辑。我们还发现了如何使用强大的箭头函数绕过与作用域相关的问题。通过探索 Angular 应用程序中最常用的某些功能,我们增强了我们对 TypeScript 的了解。

可能本章最相关的部分是我们对类、方法、属性和访问器的概述,以及我们如何通过接口处理继承和更好的应用程序设计。

在掌握了所有这些知识之后,我们可以开始通过构建 Angular 应用程序来学习如何应用它们。在下一章中,我们将学习如何使用 Angular 组件来创建可组合的用户界面,以维护我们的应用程序代码并使其更具可扩展性。

第三章:使用组件构建用户界面

到目前为止,我们有机会从宏观的角度了解 Angular 框架。我们学习了如何使用 Angular CLI 创建新的 Angular 应用程序,以及如何使用模板语法与 Angular 组件进行交互。我们还探讨了 TypeScript,这将帮助我们理解如何编写 Angular 代码。我们已经拥有了探索 Angular 带来的更多可能性的所有工具,包括创建交互式组件以及它们如何相互通信。

在本章中,我们将学习以下概念:

  • 创建我们的第一个组件

  • 与模板交互

  • 组件间通信

  • 封装 CSS 样式

  • 决定变更检测策略

  • 介绍组件生命周期

技术要求

本章包含各种代码示例,以引导您了解 Angular 组件。您可以在以下 GitHub 仓库的 ch03 文件夹中找到相关源代码:

www.github.com/PacktPublishing/Learning-Angular-Fifth-Edition

创建我们的第一个组件

组件是 Angular 应用程序的基本构建块。它们控制不同的网页部分,称为 视图,例如产品列表或订单结账表单。它们负责 Angular 应用程序的展示逻辑,并且它们组织在一个可以相互交互的组件分层树中:

包含图标  自动生成的描述

图 3.1:组件架构

Angular 应用程序的架构基于 Angular 组件。每个 Angular 组件都可以与组件树中的一个或多个组件进行通信和交互。如图 3.1 所示,一个组件可以同时是某些子组件的父组件,也是另一个父组件的子组件。

在本节中,我们将探讨以下关于 Angular 组件的主题:

  • Angular 组件的结构

  • 使用 Angular CLI 创建组件

我们将从调查 Angular 组件的内部结构开始我们的旅程。

Angular 组件的结构

正如我们在 第一章构建您的第一个 Angular 应用程序 中所学,一个典型的 Angular 应用程序至少包含一个主组件,该组件由多个文件组成。组件的 TypeScript 类定义在 app.component.ts 文件中:

import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
  selector: 'app-root',
  imports: [RouterOutlet],
  templateUrl: './app.component.html',
  styleUrl: './app.component.css'
})
export class AppComponent {
  title = 'World';
} 

@Component 是一个 Angular 装饰器,用于定义 Angular 组件的属性。Angular 装饰器是一个接受包含元数据对象作为参数的方法。元数据用于使用以下属性将 TypeScript 类配置为 Angular 组件:

  • selector:一个 CSS 选择器,指示 Angular 在 HTML 模板中找到相应标签的位置加载组件。Angular CLI 默认添加 app 前缀,但您可以在创建 Angular 项目时使用 --prefix 选项来自定义它。

  • imports:定义组件需要正确加载的 Angular 艺术品列表,例如其他 Angular 组件。Angular CLI 默认在主应用程序组件中添加 RouterOutlet。当我们在 Angular 应用程序中需要路由功能时,会使用 RouterOutlet。我们将在 第九章 中学习如何配置路由,即 使用路由在应用程序中导航

  • templateUrl:定义包含组件 HTML 模板的外部 HTML 文件的路径。或者,您可以使用 template 属性在行内提供模板。

  • styleUrl:定义包含组件 CSS 样式的外部 CSS 样式表文件的路径。或者,您可以使用 styles 属性在行内提供样式。

    在使用较旧版本的 Angular 构建的应用程序中,您可能会注意到 @Component 装饰器中缺少 imports 属性。这是因为此类组件依赖于 Angular 模块来提供必要的功能。

    然而,从 Angular v16 版本开始,引入了 standalone 属性作为 Angular 模块的一个替代方案。在 Angular v19 版本中,独立组件 现在成为默认选项,并在整个项目结构中强制执行。这种转变意味着使用 Angular v19 创建的应用程序将默认使用独立组件中的 imports 数组,这标志着与早期版本基于模块的架构有显著的不同。

现在,我们已经探讨了 Angular 组件的结构,我们将学习如何使用 Angular CLI 并自行创建组件。

使用 Angular CLI 创建组件

除了主应用程序组件外,我们还可以创建其他 Angular 组件,为应用程序提供特定的功能。

您需要有一个 Angular 应用程序才能跟随本章的其余部分。一个选择是运行您在 第一章 中学到的 ng new 命令来创建一个新的 Angular 应用程序。或者,您可以从本章 技术要求 部分提到的 GitHub 仓库中获取源代码。

要在 Angular 应用程序中创建一个新的组件,我们使用 Angular CLI 的 ng generate 命令,并将组件名称作为参数传递。请在当前 Angular CLI 工作区的根目录中运行以下命令:

ng generate component product-list 

上述命令为组件创建了一个名为 product-list 的专用文件夹,其中包含所有必要的文件:

  • product-list.component.css 文件,目前还没有包含任何 CSS 样式。

  • 包含显示静态文本的段落元素的 product-list.component.html 文件:

    <p>product-list works!</p> 
    
  • 包含一个单元测试的 product-list.component.spec.ts 文件,该测试检查组件是否可以成功创建:

    import { ComponentFixture, TestBed } from '@angular/core/testing';
    import { ProductListComponent } from './product-list.component';
    describe('ProductListComponent', () => {
      let component: ProductListComponent;
      let fixture: ComponentFixture<ProductListComponent>;
      beforeEach(async () => {
        await TestBed.configureTestingModule({
          imports: [ProductListComponent]
        })
        .compileComponents();
    
        fixture = TestBed.createComponent(ProductListComponent);
        component = fixture.componentInstance;
        fixture.detectChanges();
      });
      it('should create', () => {
        expect(component).toBeTruthy();
      });
    }); 
    

我们将在 第十三章 中学习更多关于单元测试及其语法的知识,即 单元测试 Angular 应用程序

  • 包含我们组件展示逻辑的 product-list.component.ts 文件:

    import { Component } from '@angular/core';
    @Component({
      selector: 'app-product-list',
      imports: [],
      templateUrl: './product-list.component.html',
      styleUrl: './product-list.component.css'
    })
    export class ProductListComponent {
    } 
    

在本节中,我们专注于 Angular 组件的 TypeScript 类,但它们是如何与它们的 HTML 模板交互的呢?

在以下部分,我们将学习如何在页面上显示 Angular 组件的 HTML 模板。我们还将了解如何使用 Angular 模板语法在组件的 TypeScript 类和其 HTML 模板之间进行交互。

与模板交互

正如我们所学的,使用 Angular CLI 创建 Angular 组件涉及生成一系列配套文件。其中之一是包含在页面中显示的 HTML 内容的组件模板。在本节中,我们将通过以下主题探索如何显示和与模板交互:

  • 加载组件模板

  • 显示组件类中的数据

  • 组件样式化

  • 从模板获取数据

我们将在组件模板中开始我们的旅程,探索如何在网页上渲染组件。

加载组件模板

我们了解到 Angular 使用 selector 属性在 HTML 模板中加载组件。典型的 Angular 应用程序在应用程序启动时加载主组件的模板。我们在 第一章 中看到的 <app-root> 标签是主应用程序组件的 selector

要加载我们创建的组件,例如产品列表组件,我们必须在 HTML 模板中添加其 selector。对于此场景,我们将在主应用程序组件的模板中加载它:

  1. 打开 app.component.html 文件并将 app.component.css 文件中 <style> 标签的内容移动过来。

将所有 CSS 样式放在一个单独的文件中,这样做更易于维护,并且被认为是最佳实践。

  1. 通过在具有 content 类的 <div> 标签内添加 <app-product-list> 标签来修改 app.component.html 文件:

    <div class="content">
      **<app-product-list></app-product-list>**
    </div> 
    

我们还可以使用自闭合标签,类似于 <input><img> HTML 元素,将产品列表组件添加为 <app-product-list />

  1. 在终端窗口中运行 ng serve 命令以启动 Angular 应用程序。该命令将失败,并显示以下错误:

    [ERROR] NG8001: 'app-product-list' is not a known element 
    

此错误是由于主应用程序组件尚未识别产品列表组件。

  1. 打开 app.component.ts 文件并导入 ProductListComponent 类:

    import { Component } from '@angular/core';
    import { RouterOutlet } from '@angular/router';
    **import { ProductListComponent } from './product-list/product-list.component';**
    @Component({
      selector: 'app-root',
      imports: [RouterOutlet, **ProductListComponent**],
      templateUrl: './app.component.html',
      styleUrl: './app.component.css'
    })
    export class AppComponent {
      title = 'World';
    } 
    

应用程序构建成功后,导航到 http://localhost:4200 预览它。网页显示产品列表组件模板中的静态文本。

在接下来的章节中,我们将看到如何使用 Angular 模板语法,并通过 TypeScript 类与模板交互。我们将开始探索如何显示在组件 TypeScript 类中定义的动态数据。

从组件类显示数据

我们已经遇到了插值来显示从组件类到模板的属性值作为文本:

<h1>Hello, {{ title }}</h1> 

Angular 将title组件属性转换为文本并在屏幕上显示。

执行插值的另一种方法是绑定title属性到<h1>HTML 元素的innerText属性,这种方法称为属性绑定

<h1 [innerText]="title"></h1> 

在前面的代码片段中,我们绑定到元素的 DOM 属性,而不是其 HTML 属性,乍一看是这样的。方括号内的属性称为目标属性,是我们想要绑定到其中的 DOM 元素的属性。右侧的变量称为模板表达式,对应于组件的title属性。

当我们打开一个网页时,浏览器解析页面的 HTML 内容并将其转换为树结构,即 DOM。页面上的每个 HTML 元素都转换为称为节点的对象,它代表 DOM 的一部分。节点定义了一组属性和方法,代表对象 API。innerText是这样一个属性,用于设置 HTML 元素内的文本。

为了更好地理解 Angular 模板机制的工作原理,我们首先需要了解 Angular 如何与属性和属性交互。它定义 HTML 属性以初始化 DOM 属性,然后使用数据绑定直接与属性交互。

要设置 HTML 元素的属性,我们使用属性绑定后的attr.语法,后跟属性名称。例如,要设置 HTML 元素的aria-label无障碍属性,我们会写如下:

<p [attr.aria-label]="myText"></p> 

在前面的代码片段中,myText是 Angular 组件的一个属性。记住,属性绑定与 Angular 组件的属性交互。因此,如果我们想直接将innerText属性的值设置到 HTML 中,我们会写上单引号包围的文本值:

<h1 [innerText]="'My title'"></h1> 

在这种情况下,传递给innerText属性的值是静态文本,而不是组件属性。

在 Angular 框架中,属性绑定将组件 TypeScript 类中的属性值绑定到模板中。正如我们接下来将要看到的,控制流语法适合协调这些值如何在模板中显示。

控制数据表示

Angular 框架最新版本中引入的新控制流语法允许我们操作数据在组件模板中的表示方式。它提供了一套内置块,为 Angular 模板语法添加了以下功能:

  • 条件显示数据

  • 遍历数据

  • 切换模板

在接下来的章节中,我们将探索先前的功能,从基于条件语句显示组件数据开始。

有条件地显示数据

@if块根据评估表达式来添加或删除 DOM 中的 HTML 元素。如果表达式评估为true,则元素被插入到 DOM 中。否则,元素将从 DOM 中删除。我们将通过一个示例来说明@if块的使用:

  1. 运行以下命令来为产品创建一个接口:

    ng generate interface product 
    
  2. 打开product.ts文件并添加以下属性:

    export interface Product {
      **id: number;**
      **title: string;**
    } 
    

Product接口定义了Product对象的结构。

  1. 打开app.component.css文件并将包含h1p选择器的 CSS 样式从product-list.component.css文件中移动出来。

  2. 打开product-list.component.ts文件并创建一个空的products数组:

    import { Component } from '@angular/core';
    **import { Product } from '../product';**
    @Component({
      selector: 'app-product-list',
      imports: [],
      templateUrl: './product-list.component.html',
      styleUrl: './product-list.component.css'
    })
    export class ProductListComponent {
      **products: Product[] = [];**
    } 
    

products数组将用于存储Product对象列表。

  1. 打开product-list.component.html文件并用以下片段替换其内容:

    @if (products.length > 0) {
      <h1>Products ({{products.length}})</h1>
    } 
    

在先前的 HTML 模板中,当products数组不为空时,<h1>元素会在屏幕上渲染。否则,它将被完全删除。

  1. @if块的行为类似于 JavaScript 的if语句。因此,我们可以在组件模板中添加一个@else部分,以便在没有产品时执行自定义逻辑:

    @if (products.length > 0) {
      <h1>Products ({{products.length}})</h1>
    } **@else {**
    **<p>No products found!</p>**
    **}** 
    

    如果我们有一个额外的条件想要评估,我们可以使用@else if部分:

    @if (products.length > 0) {
      <h1>Products ({{products.length}})</h1>
    } **@else if (products.length === 100) {**
    **<span>**
    **Click <a>Load More</a> to see more products**
    **</span>**
    `}` @else {
      <p>No products found!</p>
    } 
    
  2. 运行ng serve命令来预览到目前为止的应用程序:

包含字体、字体样式、文本、书法的图片 自动生成的描述

图 3.2:应用程序输出

在使用较旧版本的 Angular 构建的应用程序中,由于控制流语法不可用,你可能注意到使用了*ngIf语法来显示条件数据:

<h1 *ngIf="products.length > 0">
  Products ({{products.length}})
</h1> 

*ngIf是一个与@if块具有相同行为的Angular 指令。我们将在下一章学习如何创建自定义 Angular 指令。

然而,强烈建议使用@if块,以下是一些原因:

  • 使得模板更加易于阅读

  • 语法更接近 JavaScript,更容易记住

  • 它内置在框架中并且立即可用,这导致包的大小更小

你可以在angular.dev/guide/directives#adding-or-removing-an-element-with-ngif找到更多关于*ngIf的信息。

我们构建的应用程序没有显示任何数据,因为products数组是空的。在接下来的部分,我们将学习如何在产品列表组件中添加和显示产品数据。

遍历数据

@for 块允许我们遍历一组项目并为每个项目渲染一个模板,其中我们可以定义方便的占位符来插值项目数据。每个渲染的模板都限定在放置循环指令的外部上下文中,这样我们就可以访问其他绑定。我们可以将 @for 块视为 JavaScript for 循环,但用于 HTML 模板。

我们可以使用 @for 块在组件中显示产品列表,如下所示:

  1. 打开 app.component.css 文件,并将 product-list.component.css 文件中包含 .pill-group.pill.pill:hover 选择器的 CSS 样式移动到 app.component.css 文件中。

  2. 修改 product-list.component.ts 文件中 ProductListComponent 类的 products 数组,使其包含以下数据:

    export class ProductListComponent {
      products: Product[] = [
        **{ id: 1, title: 'Keyboard' },**
        **{ id: 2, title: 'Microphone' },**
        **{ id: 3, title: 'Web camera' },**
        **{ id: 4, title: 'Tablet' }**
      ];
    } 
    
  3. 打开 product-list.component.html 文件,并在 @if 块之后添加以下片段:

    <ul class="pill-group">
      @for (product of products; track product.id) {
        <li class="pill">{{product.title}}</li>
      }
    </ul> 
    

在前面的代码中,我们使用 @for 块并将从 products 数组中获取的每个元素转换为名为 模板输入变量product 变量。我们通过使用 Angular 插值语法绑定其 title 属性来在 HTML 中引用模板变量。

@for 块的执行过程中,数据可能会改变,HTML 元素可能会被添加、移动或删除,整个列表甚至可能被替换。Angular 必须通过连接迭代的数组和相应的 DOM 元素来同步数据更改与 DOM 树。这是一个可能变得非常缓慢和昂贵的进程,并可能导致性能不佳。为此,Angular 依赖于 track 属性,该属性跟踪数据更改。在我们的例子中,track 属性定义了 product 变量的属性名称,该属性将用于跟踪 products 数组中的每个项目。

  1. 运行 ng serve 命令以预览应用程序:

包含文本、屏幕截图、字体、标志的图像,自动生成的描述

图 3.3:产品列表

  1. @for 块支持添加一个 @empty 部分,当项目数组为空时执行。我们可以通过删除 @if 块的 @else 部分并添加以下 @empty 部分来重构我们的代码:

    @if (products.length > 0) {
      <h1>Products ({{products.length}})</h1>
    }
    <ul class="pill-group">
      @for (product of products; track product.id) {
        <li class="pill">{{product.title}}</li>
      } @empty {
        <p>No products found!</p>
      }
    </ul> 
    

@for 块可以观察底层集合的变化,并在集合中的项目添加、删除或重新排序时添加、删除或排序渲染的模板。还可以跟踪其他有用的属性。我们可以使用以下语法使用 @for 块的扩展版本:

@for (product of products; track product.id; **let variable=property**) {} 

variable 是一个模板输入变量,我们可以在模板中稍后引用。property 可以有以下值:

  • $count : 表示数组中元素的数量

  • $index : 表示数组中元素的索引

  • $first / $last : 表示当前元素是否是数组中的第一个或最后一个

  • $even / $odd : 表示数组中元素的索引是偶数还是奇数

我们可以直接使用前面的属性,或者通过声明一个别名来使用,如下面的示例所示。

在下面的代码片段中,Angular 将 $index 属性的值赋给 i 输入变量。该 i 变量随后在模板中使用,以显示每个产品作为编号列表:

@for (product of products; track product.id; let i = $index) {
  <li class="pill">{{i+1}}. {{product.title}}</li>
} 

当不确定应该从对象数据中选择哪一个时,在 track 变量中使用 $index 属性。此外,当你没有在对象中任何独特的属性,并且你不会通过删除、添加或移动元素来修改列表的顺序时,建议使用它。

在使用较旧版本的 Angular 构建的应用程序中,你可能注意到底下的集合迭代语法:

<ul class="pill-group">
  <li class="pill" *ngFor="let product of products">
    {{product.title}}
  </li>
</ul> 

*ngFor 是一个与 @for 块类似工作的 Angular 指令。然而,强烈建议出于与上一节中提到的 @if 块相同的原因使用 @for

你可以在 angular.dev/guide/directives#listing-items-with-ngfor 找到更多关于 *ngFor 的信息。

我们将在下一节中介绍控制流语法的最后一个块,即 @switch 块。

模板切换

@switch 块在组件模板的部分之间切换,并根据定义的值显示每个部分。

你可以将 @switch 想象成 JavaScript 的 switch 语句。它由以下部分组成:

  • @switch:定义在应用该块时要检查的属性

  • @case:根据在 @switch 块中定义的属性的值,向 DOM 树中添加或删除模板

  • @default:如果 @switch 块中定义的属性的值不满足任何 @case 语句,则向 DOM 树中添加模板

我们将通过根据产品标题显示不同的表情符号来学习如何使用 @switch 块。打开 product-list.component.html 文件,并修改 @for 块,使其包含以下 @switch 块:

<ul class="pill-group">
  @for (product of products; track product.id) {
    <li class="pill">
      **@switch (product.title) {**
**@case ('Keyboard') {** **![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-04.png)** **}**
**@case ('Microphone') {** `![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-05.png)` **}**
**@default {** `![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-06.png)` **}**
**}**
      {{product.title}}
    </li>
  } @empty {
    <p>No products found!</p>
  }
</ul> 

@switch 块评估每个产品的 title 属性。当它找到一个匹配项时,它激活相应的 @case 部分。如果 title 属性的值与任何 @case 部分不匹配,则激活 @default 部分。

在使用较旧版本的 Angular 构建的应用程序中,你可能注意到底下的模板切换语法:

<div [ngSwitch]="product.title">
  <p *ngSwitchCase="'Keyboard'">![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-041.png)</p>
  <p *ngSwitchCase="'Microphone'">![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-05.png)</p>
  <p *ngSwitchDefault>![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-06.png)</p>
</div> 

[ngSwitch] 是一个具有与 @switch 块相同行为的 Angular 指令。然而,强烈建议出于与上一节中提到的 @if 块相同的原因使用 @switch

你可以在 angular.dev/guide/directives#switching-cases-with-ngswitch 找到更多关于 [ngSwitch] 的信息。

控制流语法的简洁性和改进的人体工程学使得在 Angular 框架中引入@defer块成为可能。@defer块通过异步加载组件模板的部分来帮助增强用户体验和提高应用程序性能。我们将在第十五章优化应用程序性能中了解更多内容。

在本节中,我们学习了如何利用控制流语法并协调如何在组件模板上显示数据。

如果您想在已经使用旧指令方法的现有应用程序中使用此语法,可以执行 Angular CLI 迁移,详情请参阅angular.dev/reference/migrations/control-flow

正如我们将在下一节中学习的,Angular 框架中的属性绑定在 Angular 模板中应用 CSS 样式和类。

组件样式化

在 Web 应用程序中,可以使用 HTML 元素的classstyle属性,或同时使用两者来应用样式:

<p class="star"></p> 
<p style="color: greenyellow"></p> 

Angular 框架提供两种类型的属性绑定:

  • 类绑定

  • 样式绑定

让我们在下一节开始我们的组件样式之旅,使用类绑定。

类绑定

我们可以使用以下语法将单个类应用到 HTML 元素上:

<p [class.star]="isLiked"></p> 

在前面的代码片段中,当isLiked表达式为true时,star类将被添加到段落元素中。否则,它将从元素中移除。如果我们想同时应用多个 CSS 类,可以使用以下语法:

<p [class]="currentClasses"></p> 

currentClasses变量是一个组件属性。在类绑定中使用的表达式的值可以是以下之一:

  • 类名由空格分隔的字符串,例如'star active'

  • 一个对象,键是类名,值是每个键的布尔条件。当键的值(带有其名称)评估为true时,将向元素添加类。否则,将从元素中移除该类:

    currentClasses = { 
      star: true, 
      active: false 
    }; 
    

我们可以使用样式绑定直接设置元素的样式,而不是使用 CSS 类来样式化我们的元素。

样式绑定

与类绑定类似,我们可以使用样式绑定同时应用单个或多个样式。可以使用以下语法将单个样式设置为 HTML 元素:

<p [style.color]="'greenyellow'"></p> 

在前面的代码片段中,段落元素将具有greenyellow颜色。一些样式可以在绑定中进一步展开,例如段落元素的width,我们可以定义它以及度量单位:

<p [style.width.px]="100"></p> 

段落元素将长100像素。如果我们需要同时切换多个样式,可以使用对象语法:

<p [style]="currentStyles"></p> 

currentStyles变量是一个组件属性。在样式绑定中使用的表达式的值可以是以下之一:

  • 一个由分号分隔的样式字符串,例如'color: greenyellow; width: 100px'

  • 一个对象,其键是样式的名称,值是实际的样式值:

    currentStyles = { 
      color: 'greenyellow', 
      width: '100px' 
    }; 
    

类和样式绑定是 Angular 提供的强大功能,无需额外配置。结合我们可以在 @Component 装饰器中定义的 CSS 样式配置,它为 Angular 组件的样式化提供了无限的机会。同样引人注目的功能是能够从模板读取数据到组件类,我们将在下一节中探讨这一点。

从模板获取数据

在上一节中,我们学习了如何使用属性绑定来显示来自组件类的数据。现实场景通常涉及组件之间的双向数据流。要从模板获取数据返回到组件类,我们使用一种称为事件绑定的技术。我们将通过在从列表中选择产品时通知组件类来学习如何使用事件绑定:

  1. 打开 product-list.component.ts 文件并添加一个 selectedProduct 属性:

    selectedProduct: Product | undefined; 
    
  2. 打开 product-list.component.html 文件,并使用插值语法来显示所选产品(如果存在):

    @if (selectedProduct) {
      <p>You selected:
        <strong>{{selectedProduct.title}}</strong>
      </p>
    } 
    
  3. <li> 标签中添加一个 click 事件绑定,将 selectedProduct 设置为 @for 块的当前 product 变量:

    @for (product of products; track product.id) {
      <li class="pill" **(click)="selectedProduct = product"**>
        @switch (product.title) {
          @case ('Keyboard') { ![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-041.png) }
          @case ('Microphone') { ![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-08.png) }
          @default { ![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-09.png) }
        }
        {{product.title}}
      </li>
    } 
    
  4. 运行 ng serve 以启动应用程序并从列表中选择一个产品:

包含文本、屏幕截图、字体、标志的自动创建的描述

图 3.4:产品选择

事件绑定监听目标 HTML 元素上的 DOM 事件,并通过与组件类的成员交互来响应这些事件。括号内的事件称为目标事件,是我们当前正在监听的事件。右侧的表达式称为模板语句,它与组件类交互。Angular 的事件绑定支持在 developer.mozilla.org/docs/Web/Events 找到的所有原生 DOM 事件。

组件模板与其相应的 TypeScript 类的交互总结在下述图中:

包含文本、屏幕截图、字体、行号的自动创建的描述

图 3.5:组件模板交互

我们用于与组件模板和类交互的相同原则也可以用于组件之间的通信。

组件间通信

Angular 组件公开一个公共 API,允许它们与其他组件进行通信。此 API 包含输入属性,我们使用这些属性向组件提供数据。它还公开了我们可以绑定事件监听器的输出属性,从而及时获取有关组件状态变化的详细信息。

在本节中,我们将通过快速简单的示例学习 Angular 如何通过组件注入和提取数据解决问题。

使用输入绑定传递数据

当前应用程序在同一组件中显示产品列表和所选产品详情。为了学习如何在不同的组件之间传递数据,我们将创建一个新的 Angular 组件,该组件将显示所选产品的详情。表示特定产品详情的数据将从产品列表组件动态传递。

我们将首先创建和配置组件以显示产品详情:

  1. 运行以下 Angular CLI 命令以创建新的 Angular 组件:

    ng generate component product-detail 
    
  2. 打开product-detail.component.ts文件并相应地修改import语句:

    import { Component, **input** } from '@angular/core';
    **import { Product } from '../product';** 
    

input函数是信号 API 的一部分,当我们想要将数据从一个组件向下传递到另一个组件时使用。

我们将在第七章中了解更多关于信号 API 的内容,即使用信号跟踪应用程序状态。

  1. ProductDetailComponent类中定义一个product属性,使用input函数:

    export class ProductDetailComponent {
      **product = input<Product>();**
    } 
    

在 Angular 的旧版本中,我们使用@Input装饰器在组件之间传递数据。您可以在angular.dev/guide/components/inputs了解更多信息。

  1. 打开product-detail.component.html文件并添加以下内容:

    @if (product()) {
      <p>You selected:
        <strong>{{product()!.title}}</strong>
      </p>
    } 
    

在前面的代码片段中,我们使用@if块检查product输入属性是否已设置,然后再显示其title

  1. 打开product-list.component.ts文件并导入ProductDetailComponent类:

    import { Component } from '@angular/core';
    import { Product } from '../product';
    **import { ProductDetailComponent } from '../product-detail/product-detail.component';**
    @Component({
      selector: 'app-product-list',
      imports: [**ProductDetailComponent**],
      templateUrl: './product-list.component.html',
      styleUrl: './product-list.component.css'
    }) 
    
  2. 最后,将product-list.component.html文件中的最后一个@if块替换为以下代码片段:

    <app-product-detail [product]="selectedProduct"></app-product-detail> 
    

在前面的代码片段中,我们使用属性绑定将selectedProduct属性的值绑定到产品详情组件的product输入属性。这种方法称为输入绑定

如果我们运行应用程序并点击列表中的产品,我们会看到产品选择仍然按预期工作。

产品详情组件模板中的@if块意味着product输入属性是必需的;否则,它不会显示其title。Angular 在构建时不知道产品列表组件是否为product输入绑定传递了值。如果我们想在编译时强制执行此规则,我们可以相应地定义一个必需的输入属性:

product = input.required<Product>(); 

根据前面的代码片段,如果产品列表组件没有为product输入属性传递值,Angular 编译器将抛出以下错误:

[ERROR] NG8008: Required input 'product' from component ProductDetailComponent must be specified. 

就这样!我们已经成功地将数据从一个组件传递到另一个组件。在下一节中,我们将学习如何在组件中监听事件并对其做出响应。

使用输出绑定监听事件

我们了解到,当我们需要在组件之间传递数据时,会使用输入绑定。这种方法适用于有两个组件的场景,一个作为父组件,另一个作为子组件。如果我们想从子组件向父组件进行通信呢?我们如何通知父组件关于子组件中的特定操作?

考虑一个场景,其中产品详情组件应该有一个按钮,可以将当前产品添加到购物车中。购物车将是产品列表组件的一个属性。产品详情组件如何通知产品列表组件按钮已被点击?让我们看看我们如何在应用程序中实现这个功能:

  1. 打开 product-detail.component.ts 文件,并从 @angular/core npm 包中导入 output 函数:

    import { Component, input, **output** } from '@angular/core'; 
    

当我们想要创建从组件 向上 触发的事件时,会使用 output 函数。

  1. ProductDetailComponent 类中定义一个新的组件属性,该属性使用 output 函数:

    added = output(); 
    

在 Angular 的旧版本中,我们使用 @Output 装饰器在组件之间触发事件。您可以在 angular.dev/guide/components/outputs 上了解更多信息。

  1. 在同一个 TypeScript 类中,创建以下方法:

    addToCart() {
      this.added.emit();
    } 
    

addToCart 方法在之前步骤中创建的 added 输出事件上调用 emit 方法。emit 方法触发一个事件,并通知任何当前监听该事件的组件。

  1. 现在,在组件模板中添加一个 <button> 元素,并将其 click 事件绑定到 addToCart 方法:

    @if (product()) {
      <p>You selected:
        <strong>{{product()!.title}}</strong>
      </p>
      **<button (click)="addToCart()">Add to cart</button>**
    } 
    
  2. 打开 product-detail.component.css 文件,并添加以下将应用于 <button> 元素的 CSS 样式:

    button {
      display: flex;
      align-items: center;
      --button-accent: var(--bright-blue);
      background: color-mix(in srgb, var(--button-accent) 65%, transparent);
      color: white;
      padding-inline: 0.75rem;
      padding-block: 0.375rem;
      border-radius: 0.5rem;
      border: 0;
      transition: background 0.3s ease;
      font-family: var(--inter-font);
      font-size: 0.875rem;
      font-style: normal;
      font-weight: 500;
      line-height: 1.4rem;
      letter-spacing: -0.00875rem;
      cursor: pointer;
    }
    button:hover {
      background: color-mix(in srgb, var(--button-accent) 50%, transparent);
    } 
    
  3. 我们几乎完成了!现在,我们需要在产品列表组件中设置绑定,以便两个组件可以通信。打开 product-list.component.ts 文件,并创建以下方法:

    onAdded() {
      alert(`${this.selectedProduct?.title} added to the cart!`);
    } 
    

在前面的代码片段中,我们使用浏览器的原生 alert 方法向用户显示一个对话框。

  1. 最后,按照以下方式修改 product-list.component.html 文件中的 <app-product-detail> 标签:

    <app-product-detail
      [product]="selectedProduct"
      **(added)="onAdded()"**
    ></app-product-detail> 
    

在前面的代码片段中,我们使用事件绑定将 onAdded 方法绑定到产品详情组件的 added 输出属性。这种方法被称为 输出绑定

如果我们从列表中选择一个产品并点击 添加到购物车 按钮,对话框将显示如下消息:

已将网络摄像头添加到购物车!

您可以在以下图中看到我们讨论的组件通信机制的概述:

包含文本的图像,屏幕截图,字体,行号,自动生成的描述

图 3.6:组件间通信

产品详情组件的输出事件除了向父组件发出事件之外,没有做更多或更少的事情。然而,我们可以通过emit方法使用它来传递任意数据,正如我们将在下一节中学习的那样。

通过自定义事件发送数据

输出事件的emit方法可以接受任何数据以传递给父组件。最好定义可以传递的数据类型以强制进行静态类型检查。

目前,产品列表组件已经知道选定的产品。让我们假设产品列表组件只能在用户点击添加到购物车按钮后实现这一点:

  1. 打开product-detail.component.ts文件,并使用泛型声明将传递到产品列表组件的数据类型:

    added = output**<Product>**(); 
    
  2. 修改addToCart方法,以便emit方法传递当前选定的产品:

    addToCart() {
      this.added.emit(**this.product()!**);
    } 
    
  3. 打开product-list.component.html文件,并在onAdded方法中传递$event变量:

    <app-product-detail
      [product]="selectedProduct"
      (added)="onAdded(**$event**)"
    ></app-product-detail> 
    

$event对象是 Angular 中的一个保留关键字,它包含来自输出绑定的事件发射器的有效负载数据,在我们的例子中是一个Product对象。

  1. 打开product-list.component.ts文件,并相应地更改onAdded方法的签名:

    onAdded(**product: Product**) {
      alert(`${**product**.title} added to the cart!`);
    } 
    

正如我们所见,输出事件绑定是一种通知父组件组件状态变化或发送任何数据的好方法。

除了使用输入和输出绑定与组件进行通信外,我们还可以直接使用局部模板引用变量访问它们的属性和方法。

模板中的局部引用变量

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

我们已经学习了组件如何通过监听使用输出绑定的发出事件或通过输入绑定传递数据来进行通信。但如果我们能够深入检查组件,或者至少是其公开的属性和方法,并且无需通过输入和输出绑定来访问它们怎么办?在组件上设置局部引用为访问其公共 API 打开了大门。

组件的公共 API 由 TypeScript 类的所有public成员组成。

我们可以在product-list.component.html文件中为产品详情组件声明一个模板引用变量,如下所示:

<app-product-detail
  **#productDetail**
  [product]="selectedProduct"
  (added)="onAdded()"
></app-product-detail> 

从那一刻起,我们可以直接访问组件成员,甚至可以在模板的其他位置绑定它们,例如显示产品标题:

<span>{{productDetail.product()!.title}}</span> 

这样,我们就不需要依赖于输入和输出属性,并且可以操作这些属性的值。

当我们使用无法控制子组件添加输入或输出绑定属性库时,局部引用变量方法特别有用。

我们主要解释了组件类如何与其模板或其他组件交互,但几乎没有关注它们的样式。我们将在下一部分更详细地探讨这一点。

封装 CSS 样式

我们可以在组件内部定义 CSS 样式,以更好地封装我们的代码并使其更具可重用性。在创建我们的第一个组件部分,我们学习了如何使用styleUrl属性通过外部 CSS 文件定义组件的 CSS 样式,或者通过 TypeScript 组件文件中的styles属性在文件内部定义 CSS 样式。

CSS 特定性的常规规则同时适用于这两种方式:developer.mozilla.org/docs/Web/CSS/Specificity

感谢作用域样式的存在,在支持阴影 DOM的浏览器上,CSS 管理和特定性变得非常简单。CSS 样式应用于组件内的元素,但不会超出它们的边界。

你可以在developer.mozilla.org/docs/Web/API/Web_components/Using_shadow_DOM上找到更多关于阴影 DOM 的详细信息。

此外,Angular 将样式表嵌入网页的<head>元素中,以便它们可以影响我们应用的其他元素。我们可以设置不同级别的视图封装来防止这种情况发生。

视图封装是 Angular 需要在组件内部管理 CSS 作用域的方式。我们可以通过设置@Component装饰器的encapsulation属性来改变它,以下是一些ViewEncapsulation枚举值:

  • Emulated:通过在指向组件的特定选择器下沙盒 CSS 规则来模拟阴影 DOM 中的本地作用域。此选项被首选以确保组件样式不会泄漏到组件外部,并且不受其他外部样式的影响。它是 Angular CLI 项目的默认行为。

  • Native:使用渲染器的本地阴影 DOM 封装机制,仅在支持阴影 DOM 的浏览器上工作。

  • None:不提供模板或样式封装。样式按添加到文档<head>元素中的顺序注入。如果未涉及阴影 DOM 启用浏览器,则这是唯一选项。

我们将通过一个示例来探索EmulatedNone选项,因为它们具有扩展的支持:

  1. 打开product-detail.component.html文件,并将@if块的内容包裹在一个<div>元素中:

    @if (product()) {
      `<div>`
        <p>You selected:
          <strong>{{product()!.title}}</strong>
        </p>
        <button (click)="addToCart()">Add to cart</button>
      `</div>`
    } 
    
  2. 打开product-detail.component.css文件,并添加一个 CSS 样式来改变<div>元素的边框:

    div {
      padding-inline: 0.75rem;
      padding-block: 0.375rem;
      border: 2px dashed;
    } 
    
  3. 使用ng serve命令运行应用程序,并注意当你选择一个产品时,产品详情组件周围有一个虚线边框:

包含文本、字体、屏幕截图、描述的图片

图 3.7:产品详情

样式不会影响app.component.html文件中的<div>元素,因为默认的封装作用域将所有 CSS 样式定义到特定的组件中。

如果我们没有明确指定,默认视图封装是Emulated

  1. 打开product-detail.component.ts文件,并将组件封装设置为ViewEncapsulation.None

    import { Component, input, output, **ViewEncapsulation** } from '@angular/core';
    import { Product } from '../product';
    @Component({
      selector: 'app-product-detail',
      imports: [],
      templateUrl: './product-detail.component.html',
      styleUrl: './product-detail.component.css',
      **encapsulation: ViewEncapsulation.None**
    }) 
    

应用程序输出应如下所示:

包含文本、屏幕截图、字体、编号的图片

图 3.8:无视图封装

在前面的图像中,CSS 样式泄漏到组件树中,影响了主应用程序组件的<div>元素。

视图封装可以在我们为组件设置样式时解决许多问题。然而,应该谨慎使用,因为我们已经了解到,CSS 样式可能会泄漏到应用程序的某些部分并产生不受欢迎的效果。

变更检测策略是@Component装饰器的另一个非常强大的属性。让我们接下来看看这个。

决定变更检测策略

变更检测是 Angular 内部用来检测组件属性变化并将其反映到视图中的机制。它在特定事件上触发,例如当用户点击按钮、异步请求完成或执行setTimeoutsetInterval方法时。Angular 使用称为monkey patching的过程,通过使用名为Zone.js的库来覆盖这些事件的默认行为来修改这些事件。

每个组件都有一个变更检测器,通过比较属性的当前值和上一个值来检测其属性是否发生变化。如果有差异,它将更改应用到组件模板中。在产品详情组件中,当product输入属性由于我们之前提到的事件而发生变化时,变更检测机制会为此组件运行并相应地更新模板。

然而,有些情况下我们不希望这种行为,例如渲染大量数据的组件。在这种情况下,默认的变更检测机制是不够的,因为它可能会在应用程序中引入性能瓶颈。我们可以使用@Component装饰器的changeDetection属性作为替代,它决定了组件将遵循的变更检测策略。

我们将通过使用 Angular DevTools 分析我们的 Angular 应用程序来学习如何使用变更检测机制:

  1. 打开 product-detail.component.ts 文件,创建一个返回当前产品标题的 getter 属性:

    get productTitle() {
      return this.product()!.title;
    } 
    
  2. 打开 product-detail.component.html 文件,并将 <strong> 标签内的 product.title 表达式替换为 productTitle

    @if (product()) {
      <p>You selected:
        <strong>{{**productTitle**}}</strong>
      </p>
      <button (click)="addToCart()">Add to cart</button>
    } 
    
  3. 使用 ng serve 命令运行应用程序,并在 http://localhost:4200 预览它。

  4. 启动 Angular DevTools,选择 Profiler 选项卡,并点击 开始录制 按钮以开始分析 Angular 应用程序。

  5. 从产品列表中点击 键盘 产品,并在柱状图中选择第一个条形来审查变更检测:

包含文本、屏幕截图、字体、编号的图像,自动生成的描述

图 3.9:变更检测柱状图

在前面的图像中,我们可以看到变更检测触发了应用程序组件树中的每个组件。

  1. 点击 添加到购物车 按钮,并在柱状图中选择第二个条形:

包含文本、线条、字体、屏幕截图的图像,自动生成的描述

图 3.10:变更检测柱状图

即使没有更改其属性,Angular 也在产品详情组件中执行了变更检测。

  1. 通过将 product-detail.component.ts 文件的 @Component 装饰器的 changeDetection 属性设置为 ChangeDetectionStrategy.OnPush 来修改它:

    import { **ChangeDetectionStrategy**, Component, input, output } from '@angular/core';
    import { Product } from '../product';
    @Component({
      selector: 'app-product-detail',
      imports: [],
      templateUrl: './product-detail.component.html',
      styleUrl: './product-detail.component.css',
      **changeDetection: ChangeDetectionStrategy.OnPush**
    }) 
    
  2. 重复步骤 4 到 6,并观察变更检测柱状图的第二个条形输出:

包含文本、屏幕截图、字体、编号的图像,自动生成的描述

图 3.11:变更检测柱状图

这次没有为产品详情组件运行变更检测。

  1. 从列表中点击 麦克风 产品,并观察柱状图中的新条形:

包含文本、线条、字体、屏幕截图的图像,自动生成的描述

图 3.12:变更检测柱状图

这次运行了变更检测,因为我们更改了 product 输入属性的引用。如果我们只是使用 OnPush 变更检测策略更改了一个属性,变更检测机制就不会被触发。您可以在 angular.dev/best-practices/skipping-subtrees 了解更多变更检测场景。

变更检测策略是一种机制,允许我们修改组件检测其数据变化的方式,这在大型应用程序中可以显著提高性能。它结束了配置组件的旅程,但 Angular 框架并未止步于此。正如我们将在下一节中学习的,我们可以在组件生命周期的特定时间点进行挂钩。

介绍组件生命周期

生命周期事件是钩子,允许我们在组件生命周期的特定阶段跳入并应用自定义逻辑。它们的使用是可选的,但如果你了解如何使用它们,可能会很有价值。

一些钩子被认为是最佳实践,而其他钩子有助于调试和理解 Angular 应用程序中发生的事情。钩子有一个定义我们需要实现的方法的接口。Angular 框架确保钩子被调用,前提是我们已在组件中实现了该方法。

在组件中定义接口不是强制性的,但被认为是良好的实践。Angular 只关心我们是否实现了实际的方法,而不关心我们是否定义了接口。

Angular 组件最基本的生命周期钩子包括:

  • ngOnInit:当组件初始化时调用此方法

  • ngOnDestroy:当组件被销毁时调用此方法

  • ngOnChanges:当组件中输入绑定属性的值发生变化时调用此方法

  • ngAfterViewInit:当 Angular 初始化当前组件及其子组件的视图时调用此方法

所有这些生命周期钩子都可在 Angular 框架的 @angular/core npm 包中找到。

所有支持的生命周期钩子的完整列表可在官方 Angular 文档中找到,网址为 angular.dev/guide/components/lifecycle

我们将在接下来的章节中通过示例逐一探索这些方法。让我们从最基本的组件生命周期事件 ngOnInit 开始。

执行组件初始化

ngOnInit 生命周期钩子是在组件初始化期间调用的一个方法。在这个阶段,所有输入绑定和数据绑定属性都已适当地设置,我们可以安全地使用它们。使用组件的 constructor 来访问它们可能很有吸引力,但那时的值尚未设置。我们将通过以下示例学习如何使用 ngOnInit 生命周期钩子:

  1. 打开 product-detail.component.ts 文件,并添加一个 constructor 方法,用于在浏览器控制台中记录 product 属性的值:

    constructor() {
      console.log('Product:', this.product());
    } 
    
  2. @angular/core npm 包中导入 OnInit 接口:

    import { Component, input, **OnInit**, output } from '@angular/core'; 
    
  3. OnInit 接口添加到 ProductDetailComponent 类实现的接口列表中:

    export class ProductDetailComponent **implements OnInit** 
    
  4. ProductDetailComponent 类中添加以下方法,以记录与步骤 1 相同的信息:

    ngOnInit(): void {
      console.log('Product:', this.product());
    } 
    
  5. 打开 product-list.component.ts 文件,并为 selectedProduct 属性设置一个初始值:

    selectedProduct: Product | undefined = **this.products[0];** 
    
  6. 使用 ng serve 命令运行应用程序,并检查浏览器控制台输出:

包含文本的图片,屏幕截图,字体,自动生成的描述

图 3.13:控制台输出

constructor 的第一条消息包含一个 undefined 值,但在第二条消息中,product 属性的值显示正确。

构造函数应该相对为空,并且除了设置初始变量之外不应包含其他逻辑。在构造函数中添加业务逻辑会使它在测试场景中难以模拟。

ngOnInit钩子的另一个良好用途是在我们需要使用来自外部源的数据初始化组件时,例如 Angular 服务,正如我们将在第五章“使用服务管理复杂任务”中学习到的那样。

Angular 框架为组件生命周期的所有阶段提供了钩子,从初始化到销毁。

清理组件资源

我们用于挂钩组件销毁事件的接口是ngOnDestroy生命周期钩子。我们需要导入OnDestroy接口并实现ngOnDestroy方法,然后才能开始使用它:

import { Component, input, **OnDestroy**, output } from '@angular/core';
import { Product } from '../product';
@Component({
  selector: 'app-product-detail',
  imports: [],
  templateUrl: './product-detail.component.html',
  styleUrl: './product-detail.component.css'
})
export class ProductDetailComponent **implements OnDestroy** {
  product = input<Product>();
  added = output();
  addToCart() {
    this.added.emit();
  }
  **ngOnDestroy(): void {**

**}**
} 

在前面的代码片段中,我们已经添加了OnDestroy接口并实现了其ngOnDestroy方法。然后我们可以在ngOnDestroy方法中添加任何自定义逻辑,以便在组件销毁时运行代码。

当组件因以下原因从网页的 DOM 树中移除时,它将被销毁:

  • 使用控制流语法中的@if

  • 使用 Angular 路由离开组件,我们将在第九章“使用路由导航应用程序”中学习到这一点,导航通过应用程序的配置

我们通常在ngOnDestroy方法中执行组件资源的清理,例如以下操作:

  • 重置计时器和间隔

  • 从可观察流中取消订阅,我们将在第六章“Angular 中的响应式模式”中学习到这一点。

ngOnDestroy生命周期钩子的另一种方法是使用内置的 Angular 服务,例如DestroyRef

import { Component, **DestroyRef**, input, output } from '@angular/core';
import { Product } from '../product';
@Component({
  selector: 'app-product-detail',
  imports: [],
  templateUrl: './product-detail.component.html',
  styleUrl: './product-detail.component.css'
})
export class ProductDetailComponent {
  product = input<Product>();
  added = output();
  **constructor(destroyRef: DestroyRef) {**
**destroyRef.onDestroy(() => {**
**});**
**}**
  addToCart() {
    this.added.emit();
  }
} 

正如我们将在第五章“使用服务管理复杂任务”中学习到的那样,使用constructor是将 Angular 服务注入到其他 Angular 组件的一种方法。在这种情况下,destroyRef服务公开了onDestroy方法,它接受一个回调函数作为参数。当组件销毁时,将调用回调函数。

我们已经学习了如何使用输入绑定将数据传递到组件。Angular 框架提供了ngOnChanges生命周期钩子,我们可以使用它来检查此类绑定的值何时已更改。

检测输入绑定更改

当 Angular 检测到输入数据绑定的值已更改时,会调用ngOnChanges生命周期钩子。我们将在产品详情组件中使用它来了解当我们从列表中选择不同的产品时,它的行为如何:

  1. product-detail.component.ts文件中导入OnChangesSimpleChanges接口:

    import {
      Component,
      input,
      **OnChanges**,
      output,
      **SimpleChanges**
    } from '@angular/core'; 
    
  2. 修改ProductDetailComponent类的定义,使其实现OnChanges接口:

    export class ProductDetailComponent **implements OnChanges** 
    
  3. 实现定义在OnChanges接口中的ngOnChanges方法。它接受一个SimpleChanges类型的对象作为参数,该对象包含每个更改的输入属性的键。每个键指向另一个对象,该对象具有currentValuepreviousValue属性,分别表示输入属性的新旧值:

    ngOnChanges(changes: SimpleChanges): void {
      const product = changes['product']; 
      const oldValue = product.previousValue; 
      const newValue = product.currentValue; 
      console.log('Old value', oldValue);
      console.log('New value', newValue);
    } 
    

前面的代码片段跟踪product输入属性的变化,并在浏览器控制台窗口中记录旧值和新值。

  1. 要检查应用程序,运行ng serve命令,从列表中选择一个产品,并注意控制台中的输出。你应该得到以下类似的内容:

包含文本、屏幕截图、字体、行号的图像,自动生成的描述

图 3.14:控制台输出

在前面的图像中,前两行表明产品值从undefined更改为undefined。这是产品详情组件初始化的实际时间,product属性还没有值。OnChanges生命周期事件在值首次设置后触发,并在所有通过绑定机制发生的后续更改中触发。

  1. 为了消除不必要的日志消息,我们可以使用isFirstChange方法检查product属性是否是第一次被更改:

    ngOnChanges(changes: SimpleChanges): void {
      const product = changes['product']; 
      **if (!product.isFirstChange()) {**
        const oldValue = product.previousValue; 
        const newValue = product.currentValue; 
        console.log('Old value', oldValue);
        console.log('New value', newValue);
      `}`
    } 
    

如果我们刷新浏览器,我们可以在控制台窗口中看到正确的消息。

ngOnChanges生命周期钩子是检测输入属性值变化的好方法。随着信号 API 的出现,我们有了更好的方法来检测和响应这些变化,正如我们将在第七章使用信号跟踪应用程序状态中学习的那样。然而,对于 Angular 的旧版本,钩子仍然是首选的解决方案。

我们将要探索的 Angular 组件的最后一个生命周期事件是ngAfterViewInit钩子。

访问子组件

当 Angular 组件的ngAfterViewInit生命周期钩子被调用时:

  • 组件的 HTML 模板已经初始化

  • 所有子组件的 HTML 模板已经初始化

我们可以通过产品列表和产品详情组件来探索ngAfterViewInit事件是如何工作的:

  1. 打开product-list.component.ts文件,并从@angular/core npm 包中导入AfterViewInitviewChild组件:

    import { **AfterViewInit**, Component, **viewChild** } from '@angular/core'; 
    
  2. ProductListComponent类中创建以下属性:

    productDetail = viewChild(ProductDetailComponent); 
    

我们已经学习了如何使用局部引用变量从 HTML 模板中查询组件类。或者,我们也可以使用viewChild函数从父组件类中查询子组件。

在 Angular 的旧版本中,我们使用@ViewChild装饰器来查询子组件。你可以了解更多信息在angular.dev/guide/components/queries

viewChild函数接受我们想要查询的组件类型作为参数。

  1. 修改ProductListComponent类的定义,使其实现AfterViewInit接口:

    export class ProductListComponent **implements AfterViewInit** 
    
  2. AfterViewInit接口实现了ngAfterViewInit方法,我们可以使用它来访问productDetail属性:

    ngAfterViewInit(): void {
      console.log(this.productDetail()!.product());
    } 
    

当我们查询productDetail属性时,我们得到ProductDetail-Component类的实例。然后我们可以访问其公共 API 的任何成员,例如product属性。

运行前面的代码将显示product属性的undefined值,因为我们没有在产品详情组件初始化时设置初始值。

ngAfterViewInit生命周期事件标志着我们通过 Angular 组件生命周期的旅程结束。组件生命周期钩子是框架的一个有用特性,您将在开发 Angular 应用程序时大量使用它们。

摘要

在本章中,我们探讨了 Angular 组件。我们看到了它们的结构以及如何创建它们,并讨论了如何将组件的 HTML 模板隔离在外部文件中,以简化其未来的可维护性。我们还看到了如何将任何我们想要绑定到组件的样式表也进行相同的处理,以防我们不希望将组件样式内联捆绑。我们还学习了如何使用 Angular 模板语法并与组件模板交互。同样,我们了解了组件如何通过属性和事件绑定双向通信。

我们探讨了 Angular 中创建强大 API 的选项,以便我们可以在组件之间提供高水平的互操作性,通过分配静态值或管理绑定来配置它们的属性。我们还看到了一个组件如何作为宿主组件为另一个子组件服务,在其模板中实例化前者的自定义元素,并为我们的应用程序中更大的组件树奠定基础。输出参数通过将我们的组件转换为事件发射器,为我们提供了所需的交互层,从而使它们能够与任何可能最终托管它们的父组件充分通信。

模板引用为我们铺平了道路,使我们能够在自定义元素中创建引用,我们可以使用它们作为访问器,从模板中以声明方式访问它们的属性和方法。Angular 中处理 CSS 视图封装的内置功能概述让我们对如何从每个组件的 shadow DOM 的 CSS 作用域中受益有了更深入的了解。最后,我们学习了在 Angular 应用程序中变更检测的重要性以及如何自定义它以进一步提高其性能。

我们还研究了组件生命周期,并学习了如何使用内置的 Angular 生命周期钩子执行自定义逻辑。关于 Angular 中的模板管理,我们还有更多要学习的内容,主要涉及您在 Angular 之旅中将要使用的两个概念:指令和管道,这些内容将在下一章中介绍。

第四章:使用管道和指令丰富应用程序

在上一章中,我们构建了几个组件,利用输入和输出属性在屏幕上渲染数据。在本章中,我们将利用这些知识,通过使用 Angular 管道指令将我们的组件提升到下一个层次。管道允许我们消化和转换我们在模板中绑定的信息。指令使更雄心勃勃的功能成为可能,例如操作 DOM 或改变 HTML 元素的外观和行为。

在本章中,我们将学习以下概念:

  • 使用管道操作数据

  • 构建管道

  • 构建指令

技术要求

本章包含代码示例,引导您了解 Angular 管道和指令。您可以在以下 GitHub 仓库的ch04文件夹中找到相关源代码:

www.github.com/PacktPublishing/Learning-Angular-Fifth-Edition

使用管道操作数据

管道允许我们在视图级别转换表达式的结果。它们以数据为输入,将其转换为所需的格式,并在模板中显示输出。

管道的语法由我们想要转换的表达式后面的管道名称组成,由管道符号(|)分隔:

expression | pipe 

任何参数都添加在管道名称之后,并由冒号分隔:

expression | pipe:param 

管道可以在 Angular 模板中使用插值和属性绑定,并且可以相互链接。

Angular 已经内置了广泛的自定义管道类型:

  • uppercase / lowercase : 将字符串转换为大写或小写字母。

  • percent : 将数字格式化为百分比。

  • date : 以特定的日期格式格式化日期或字符串。默认情况下,管道显示的日期根据用户的机器的本地设置。然而,我们可以传递 Angular 已经内置的额外格式作为参数。

  • currency : 将数字格式化为本地货币。我们可以覆盖本地设置并更改货币符号,通过将货币代码作为参数传递给管道。

  • json : 将对象作为输入,并以 JSON 格式输出,将单引号替换为双引号。json管道的主要用途是调试。这是一种查看复杂对象内容并优雅地打印到屏幕上的绝佳方式。

  • keyvalue : 将对象转换为键值对集合,其中每个项目的key代表对象的属性,而value是其实际值。

  • slice : 从集合或字符串中减去一个子集(切片)。它接受一个起始索引作为参数,其中它将开始切片输入数据,以及可选的结束索引。当指定结束索引时,该索引处的项目不包括在结果数组中。如果省略结束索引,则默认为数据的最后一个索引。

slice 管道转换不可变数据。转换后的列表始终是原始数据的副本,即使它返回所有项目。

  • async:当我们管理组件类异步处理的数据时使用,我们需要确保我们的视图能够及时反映变化。我们将在 第八章 中学习更多关于这个管道的内容,通过 HTTP 与数据服务通信,我们将使用它来异步获取和显示数据。

您需要我们创建在 第三章使用组件构建用户界面 中创建的 Angular 应用程序的源代码,以跟随本章的其余部分。

我们将更详细地介绍 lowercasecurrencykeyvalue 管道,但我们鼓励您在 angular.dev/api 的 API 参考中探索其余部分:

  1. 打开 product-detail.component.ts 文件并导入 CommonModule 类:

    **import { CommonModule } from '@angular/common';**
    import { Component, input, output } from '@angular/core';
    import { Product } from '../product';
    @Component({
      selector: 'app-product-detail',
      imports: [**CommonModule**],
      templateUrl: './product-detail.component.html',
      styleUrl: './product-detail.component.css'
    }) 
    

CommonModule 类导出 Angular 内置管道。Angular 组件在使用组件模板中的内置管道之前必须导入 CommonModule

  1. 打开 product.ts 文件,并将以下字段添加到 Product 接口中,以描述产品的附加属性:

    export interface Product {
      id: number;
      title: string;
      **price: number;**
    **categories: Record<number, string>;**
    } 
    

categories 属性是一个对象,其中键代表类别 ID,值代表类别描述。

  1. 打开 product-list.component.ts 文件并修改 products 数组以设置新属性的值:

    products: Product[] = [
      { 
        id: 1,
        title: 'Keyboard',
        **price: 100,**
    **categories: {**
    **1: 'Computing',**
    **2: 'Peripherals'**
    **}**
      },
      {
        id: 2,
        title: 'Microphone',
        **price: 35,**
    **categories: { 3: 'Multimedia' }**
      },
      {
        id: 3,
        title: 'Web camera',
        **price: 79,**
    **categories: {**
    **1: 'Computing',**
    **3: 'Multimedia'**
    **}**
      },
      {
        id: 4,
        title: 'Tablet',
        **price: 500,**
    **categories: { 4: 'Entertainment' }**
      }
    ]; 
    
  2. 打开 product-detail.component.html 文件并添加一个段落元素以显示所选产品的欧元价格:

    @if (product()) {
      <p>You selected:
        <strong>{{product()!.title}}</strong>
      </p>
      **<p>{{product()!.price | currency:'EUR'}}</p>**
      <button (click)="addToCart()">Add to cart</button>
    } 
    
  3. 运行 ng serve 以启动应用程序并从产品列表中选择 麦克风

包含文本、字体、屏幕截图、徽标的图片,自动生成的描述

图 4.1:产品详情

在前面的图像中,产品价格以货币格式显示。

  1. 在产品价格下方添加以下代码片段以显示产品类别:

    <div class="pill-group">
      @for (cat of product()!.categories | keyvalue; track cat.key) {
        <p class="pill">{{cat.value | lowercase}}</p>
      }
    </div> 
    

在前面的代码片段中,我们使用了 @for 块来遍历 product 变量的 categories 属性。由于 categories 属性不是一个可迭代的对象,所以我们使用了 keyvalue 管道将其转换为包含 keyvalue 属性的数组。key 属性代表类别 ID,这是一个我们可以与 track 变量一起使用的唯一标识符。value 属性存储类别描述。

此外,我们还使用了 lowercase 管道将类别描述转换为小写文本。

  1. 将以下 CSS 样式添加到 product-detail.component.css 文件中:

    .pill-group {
      display: flex;
      flex-direction: row;
      align-items: start;
      flex-wrap: wrap;
      gap: 1.25rem;
    }
    .pill {
      display: flex;
      align-items: center;
      --pill-accent: var(--gray-900);
      background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
      color: var(--pill-accent);
      padding-inline: 0.75rem;
      padding-block: 0.375rem;
      border-radius: 2.75rem;
      border: 0;
      transition: background 0.3s ease;
      font-family: var(--inter-font);
      font-size: 0.875rem;
      font-style: normal;
      font-weight: 500;
      line-height: 1.4rem;
      letter-spacing: -0.00875rem;
      text-decoration: none;
    } 
    
  2. 在运行应用程序时,从列表中选择 网络摄像头 产品:

包含文本、屏幕截图、字体、徽标的图片,自动生成的描述

图 4.2:包含类别的产品详情

使用CommonModule的替代方案,我们可以从@angular/common npm 包中单独导入每个管道类:

import { **CurrencyPipe, KeyValuePipe, LowerCasePipe** } from '@angular/common';
import { Component, input, output } from '@angular/core';
import { Product } from '../product';
@Component({
  selector: 'app-product-detail',
  imports: [**KeyValuePipe, CurrencyPipe, LowerCasePipe**],
  templateUrl: './product-detail.component.html',
  styleUrl: './product-detail.component.css'
}) 

在最终的product-detail.component.html文件中,我们多次使用product()!片段来读取product属性的值。或者,我们可以使用@let语法创建一个别名,如下所示:

@let selectedProduct = product()!; 

@let关键字类似于 JavaScript 中的let关键字,用于声明仅在组件模板中可用的变量。在前面的代码片段中,我们声明了selectedProduct变量,它可以在 HTML 代码的其余部分中使用,如下所示:

@if (**selectedProduct**) {
  <p>You selected:
    <strong>{{**selectedProduct**.title}}</strong>
  </p>
  <p>{{**selectedProduct**.price | currency:'EUR'}}</p>
  <div class="pill-group">
    @for (cat of **selectedProduct**.categories | keyvalue; track cat.key) {
      <p class="pill">{{cat.value | lowercase}}</p>
    }
  </div>  
  <button (click)="addToCart()">Add to cart</button>
} 

@let关键字帮助我们处理在模板中使用复杂表达式的情况,例如:

  • 三元运算符

  • 嵌套对象属性

  • 异步管道

内置管道对于大多数用例来说已经足够了,但在其他情况下,我们必须对数据进行复杂的转换。Angular 框架提供了一个机制来创建独特的自定义管道,正如我们将在下一节中看到的。

构建管道

我们已经看到了管道是什么以及它们在 Angular 生态系统中的用途。接下来,我们将深入了解如何构建一个管道来为数据绑定提供自定义转换。在下一节中,我们将创建一个管道,按标题对产品列表进行排序。

使用管道排序数据

要创建一个新的管道,我们使用 Angular CLI 的ng generate命令,并传递其名称作为参数:

ng generate pipe sort 

上述命令将在我们运行ng generate命令的文件夹内生成sort管道的所有必要文件。管道的 TypeScript 类定义在sort.pipe.ts文件中:

import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
  name: 'sort'
})
export class SortPipe implements PipeTransform {
  transform(value: unknown, ...args: unknown[]): unknown {
    return null;
  }
} 

@Pipe是 Angular 装饰器,用于定义 Angular 管道的name

管道的 TypeScript 类实现了PipeTransform接口的transform方法,并接受两个参数:

  • value:我们想要转换的输入数据

  • args:我们可以提供给转换方法的可选参数列表,每个参数由冒号分隔

Angular CLI 通过为我们搭建一个空的transform方法来帮助我们。我们现在需要修改它以满足我们的业务需求。该管道将在Product对象列表上操作,因此我们需要对提供的类型进行必要的调整:

  1. 添加以下语句以导入Product接口:

    import { Product } from './product'; 
    
  2. value参数的类型更改为Product[],因为我们想对Product对象列表进行排序。

  3. 将方法类型更改为Product[],因为排序后的列表将只包含Product对象,并修改它以便默认返回一个空数组。

结果的sort.pipe.ts文件现在应该看起来像以下这样:

import { Pipe, PipeTransform } from '@angular/core';
import { Product } from './product';
@Pipe({
  name: 'sort'
})
export class SortPipe implements PipeTransform {
  transform(value: Product[], ...args: unknown[]): Product[] {
    return [];
  }
} 

我们现在可以开始实现我们方法的排序算法。我们将使用原生的sort方法,它默认按字母顺序排序项。我们将提供一个自定义比较函数给sort方法,以覆盖默认功能并执行我们想要实现的排序逻辑:

transform(value: Product[], ...args: unknown[]): Product[] {
  **if (value) {**
    **return value.sort((a: Product, b: Product) => {**
      **if (a.title < b.title) {**
        **return -1;**
      **} else if (b.title < a.title) {**
        **return 1;**
      **}**
      **return 0;**
    **});**
  **}**
  return [];
} 

值得注意的是,transform方法在继续排序过程之前首先检查是否存在输入数据。否则,它返回一个空数组。这减轻了集合异步设置或消费管道的组件根本未设置集合的情况。

关于sort方法的更多信息,请参阅developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/sort

就这样!我们已经成功创建了我们的第一个管道。我们需要从我们的组件模板中调用它以查看其效果:

  1. 打开product-list.component.ts文件并导入SortPipe类:

    import { Component } from '@angular/core';
    import { Product } from '../product';
    import { ProductDetailComponent } from '../product-detail/product-detail.component';
    **import { SortPipe } from '../sort.pipe';**
    @Component({
      selector: 'app-product-list',
      imports: [ProductDetailComponent, **SortPipe**],
      templateUrl: './product-list.component.html',
      styleUrl: './product-list.component.css'
    }) 
    
  2. 打开product-list.component.html文件并在@for块中添加管道:

    <ul class="pill-group">
      @for (product of products **| sort**; track product.id) {
        <li class="pill" (click)="selectedProduct = product">
          @switch (product.title) {
            @case ('Keyboard') { ![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-041.png) }
            @case ('Microphone') { ![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-05.png) }
            @default { ![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-09.png) }
          }
          {{product.title}}
        </li>
      } @empty {
        <p>No products found!</p>
      }
    </ul> 
    
  3. 如果我们使用ng serve命令运行应用程序,我们会注意到产品列表现在按标题字母顺序排序:

包含文本、屏幕截图、字体、标志的图像,自动生成的描述

图 4.3:按标题字母顺序排序的产品列表

排序管道只能按title对产品数据进行排序。在下一节中,我们将学习如何配置管道,使其可以按其他产品属性进行排序。

向管道传递参数

如我们在使用管道操作数据部分所学,我们可以使用冒号传递管道的额外参数。我们在管道的transform方法中使用args参数来获取由冒号分隔的每个参数的值。我们了解到 Angular CLI 默认创建args参数并使用扩展运算符在方法中展开其值:

transform(value: Product[], **...args: unknown[]**): Product[] {
    if (value) {
      return value.sort((a: Product, b: Product) => {
        if (a.title < b.title) {
          return -1;
        } else if (b.title < a.title) {
          return 1;
        }
        return 0;
      });
    }  
    return [];
  } 

目前,transform方法只能与产品的title属性一起工作。我们可以利用args参数使其动态化,并允许管道的消费者定义他们想要排序数据的属性,例如产品价格:

  1. args参数中移除扩展运算符,因为我们每次将传递产品的一个属性并更改其类型,如下所示:

    transform(value: Product[], **args: keyof Product**): Product[] {
        if (value) {
          return value.sort((a: Product, b: Product) => {
            if (a.title < b.title) {
              return -1;
            } else if (b.title < a.title) {
              return 1;
            }
            return 0;
          });
        }  
        return [];
      } 
    

在前面的方法中,我们使用 TypeScript 的keyof类型运算符来定义args参数可以是Product对象中的任何属性。

  1. if语句内部将title属性替换为args参数:

    if (value) {
      return value.sort((a: Product, b: Product) => {
        if (a**[args]****[args]** < b) {
          return -1;
        } else if (b**[args]****[args]** < a) {
          return 1;
        }
        return 0;
      });
    } 
    

注意,在前面的代码片段中,我们使用方括号语法而不是之前的点语法来访问ab对象。

  1. 在方法签名中修改args参数,以便它默认使用title属性,如果管道的消费者没有在管道中传递任何参数:

    transform(value: Product[], args: keyof Product **= 'title'**) 
    

前面的行为确保产品列表组件在管道使用上无需任何更改即可正常工作。

  1. 运行ng serve命令并验证产品列表最初是否按标题排序。

  2. 打开product-list.component.html文件并将price属性作为管道参数传递:

    @for (product of products | sort**:'price'**; track product.id) {
      <li class="pill" (click)="selectedProduct = product">
        @switch (product.title) {
          @case ('Keyboard') {![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-041.png) }
          @case ('Microphone') { ![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-05.png) }
          @default { ![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-09.png) }
        }
        {{product.title}}
      </li>
    } 
    
  3. 保存文件并等待应用程序重新加载。你应该会看到产品列表现在是按价格排序的:

img

图 4.4:按价格排序的产品列表

@Pipe装饰器包含另一个我们可以设置的显著属性,它与管道在 Angular 框架的变更检测机制中的反应方式直接相关。

使用管道进行变更检测

管道分为两类:不纯。默认情况下,所有管道都被视为纯的,除非我们在@Pipe装饰器中明确将pure属性设置为false

@Pipe({
  name: 'sort',
  **pure: false**
}) 

当输入变量的引用发生变化时,Angular 会执行纯管道。例如,如果ProductListComponent类中的products数组被分配给新值,管道将正确反映这种变化。然而,如果我们使用原生的Array.push方法向数组中添加新产品,管道将不会触发,因为数组的对象引用没有改变。

另一个例子是当我们创建了一个仅操作单个对象的纯管道。同样地,如果值的引用发生变化,管道将正确执行。如果对象的一个属性发生变化,管道无法检测到这种变化。

然而,有一个警告——不纯的管道会在每次变更检测周期触发时调用transform方法。所以,这可能会对性能不利。作为替代,你可以不设置pure属性,并尝试缓存值或使用 reducer 和不可变数据以更好的方式解决这个问题,如下所示:

this.products= [ 
  ...this.products,
  {
    id: 5,
    title: 'Headphones',
    price: 55,
    categories: { 3: 'Multimedia' }
  }
]; 

在前面的代码片段中,我们使用了扩展参数语法来创建一个新引用的products数组,通过向现有数组的引用中添加一个新项目来实现。

与纯管道不同,我们可以使用计算信号,它由于以下原因更有效且更直观:

  • 我们可以在组件类中访问信号值,与管道不同,在模板中只能读取它们的值。

  • 计算信号是一个简单的普通函数,所以我们不需要像在管道中那样使用 TypeScript 类。

我们将在第七章使用信号跟踪应用程序状态中了解更多关于信号的内容。

创建自定义管道允许我们根据我们的需求以特定方式转换我们的数据。如果我们还想转换模板元素,我们必须创建自定义指令。

构建指令

Angular 指令是扩展标准 HTML 元素行为或外观的 HTML 属性。当我们将指令应用于 HTML 元素或 Angular 组件时,我们可以添加自定义行为或改变其外观。有三种类型的指令:

  • 组件:组件是包含相关 HTML 模板的指令。

  • 结构指令:这些指令向 DOM 添加或删除元素。

  • 属性指令:这些指令可以修改 DOM 元素的外观或定义自定义行为。我们在上一章中遇到了属性指令在类和样式绑定中的应用。

如果一个指令附加了模板,那么它就变成了一个组件。换句话说,组件是有视图的 Angular 指令。这个规则在决定是否为您的需求创建组件或指令时很有用。如果您需要一个模板,请创建一个组件;否则,将其制作为一个指令。

自定义指令允许我们将高级行为附加到 DOM 中的元素或修改它们的外观。在接下来的章节中,我们将探讨如何创建属性指令。

显示动态数据

属性指令通常用于改变 HTML 元素的外观。我们可能都遇到过想要在我们的应用程序中添加版权信息的情况。理想情况下,我们希望将此信息用于应用程序的各个部分,如仪表板或联系页面。信息的内容也应该是动态的。年份或年份范围(这取决于你如何使用它)应根据当前日期动态更新。我们的第一个想法可能是创建一个组件,但将其改为指令怎么样?这样,我们就可以将指令附加到任何我们想要的元素上,而不必担心特定的模板。那么,让我们开始吧!

我们将使用 Angular CLI 的ng generate命令,将指令的名称作为参数传递:

ng generate directive copyright 

上述命令将在我们运行ng generate命令的文件夹内生成copyright指令的所有必要文件。指令的 TypeScript 类定义在copyright.directive.ts文件中:

import { Directive } from '@angular/core';
@Directive({
  selector: '[appCopyright]'
})
export class CopyrightDirective {
  constructor() { }
} 

@Directive是 Angular 装饰器,用于定义 Angular 指令的属性。它使用selector属性将 TypeScript 类配置为 Angular 指令。它是一个 CSS 选择器,指示 Angular 在 HTML 模板中找到对应属性的位置加载指令。Angular CLI 默认添加app前缀,但您可以在创建 Angular 项目时使用--prefix选项来自定义它。

当我们在 HTML 模板中使用选择器时,我们不需要添加方括号。

让我们使用新创建的指令将版权信息添加到我们的应用程序中:

  1. 打开styles.css文件并添加以下 CSS 样式:

    .copyright {
      font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
        Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
        "Segoe UI Symbol";
        width: 100%;
        min-height: 100%;
        display: flex;
        justify-content: center;
        align-items: center;
        padding: 1rem;
        box-sizing: inherit;
        position: relative;
    } 
    

在前面的代码片段中,我们在全局 CSS 样式表中添加了我们的版权指令的 CSS 样式。与组件不同,指令没有伴随的 CSS 文件供我们使用。

  1. 打开copyright.directive.ts文件,并从@angular/core npm 包中导入ElementRef类:

    import { Directive, **ElementRef** } from '@angular/core'; 
    
  2. 按照以下方式修改指令的constructor

    constructor(el: **ElementRef**) {
      **const currentYear = new Date().getFullYear();**
      **const targetEl: HTMLElement = el.nativeElement;**
      **targetEl.classList.add('copyright');**
      **targetEl.textContent = `Copyright ©${currentYear} All Rights****Reserved`;** 
    } 
    

在前面的代码片段中,我们使用了 ElementRef 类来访问和操作指令附加的底层 HTML 元素。nativeElement 属性包含实际的本地 HTML 元素。我们还使用 classList 属性的 add 方法添加了 copyright 类。最后,我们通过修改 textContent 属性来更改元素的文本。

ElementRef 是一个内置的 Angular 服务。要在组件或指令中使用服务,我们需要将其注入到 constructor 中,正如我们将在第五章 使用服务管理复杂任务 中学习的那样。

  1. 打开 app.component.ts 文件并导入 CopyrightDirective 类:

    import { Component } from '@angular/core';
    import { RouterOutlet } from '@angular/router';
    import { ProductListComponent } from './product-list/product-list.component';
    **import { CopyrightDirective } from './copyright.directive';**
    @Component({
      selector: 'app-root',
      imports: [
        RouterOutlet,
        ProductListComponent,
        **CopyrightDirective**
      ],
      templateUrl: './app.component.html',
      styleUrl: './app.component.css'
    }) 
    
  2. 打开 app.component.html 文件并添加一个 <footer> 元素以显示版权信息:

    <main class="main">
      <div class="content">
        <app-product-list></app-product-list>
      </div>
    </main>
    **<footer appCopyright></footer>**
    <router-outlet /> 
    
  3. 使用 ng serve 命令运行应用程序并观察应用程序输出:

包含文本、屏幕截图、字体、标志的图片,自动生成的描述

图 4.5:应用程序的输出

在创建指令时,考虑可重用功能非常重要,这些功能不一定与特定功能相关。我们探讨的主题是受版权保护的信息,但我们可以相对容易地构建其他功能,例如工具提示和可折叠或无限滚动功能。在下一节中,我们将构建另一个属性指令,进一步探索可用的选项。

属性绑定和响应事件

属性指令也关注 HTML 元素的行为。它们可以扩展元素的功能并添加新功能。Angular 框架提供了两个有用的装饰器,我们可以在我们的指令中使用它们来增强 HTML 元素的功能:

  • @HostBinding:将值绑定到本地宿主元素的属性。

  • @HostListener:绑定到本地宿主元素的事件。

本地宿主元素是我们指令执行动作的元素。

原生的 <input> HTML 元素可以支持不同的输入类型,包括简单的文本、单选按钮和数值。当我们使用后者时,输入会添加两个箭头,一个向上,一个向下,以控制其值。正是输入元素的这一特性使其看起来不完整。如果我们输入一个非数值字符,输入仍然会渲染它。

我们将创建一个属性指令,该指令将拒绝通过键盘输入的非数值:

  1. 运行以下 Angular CLI 命令以创建一个名为 numeric 的新指令:

    ng generate directive numeric 
    
  2. 打开 numeric.directive.ts 文件并导入我们将要使用的两个装饰器:

    import { Directive, **HostBinding, HostListener** } from '@angular/core'; 
    
  3. 使用 @HostBinding 装饰器定义一个 currentClass 属性,该属性将绑定到 <input> 元素的 class 属性:

    @HostBinding('class') currentClass = ''; 
    
  4. 使用 @HostListener 装饰器定义一个 onKeyPress 方法,该方法将绑定到 <input> 元素的 keypress 本地事件:

    @HostListener('keypress', ['$event']) onKeyPress(event: KeyboardEvent) {
      const charCode = event.key.charCodeAt(0);
      if (charCode > 31 && (charCode < 48 || charCode > 57)) {
        this.currentClass = 'invalid';
        event.preventDefault();
      } else {
        this.currentClass = 'valid';
      }
    } 
    
  5. 打开styles.css文件,并添加以下 CSS 样式,当组件使用指令时将应用这些样式:

    input.valid {
      border: solid green;
    }
    input.invalid {
      border: solid red;
    } 
    

onKeyPress方法包含了我们的指令在底层如何工作的逻辑。

当用户在<input>元素内按下键时,Angular 知道调用onKeyPress方法,因为我们已经使用@HostListener装饰器注册了它。@HostListener装饰器接受事件名称和参数列表作为参数。在我们的情况下,我们传递了keypress事件名称和$event参数。$event是触发事件的当前对象,它是KeyboardEvent类型,包含用户输入的按键。

每当用户按下键时,我们从$event对象中提取它,使用charCodeAt方法将其转换为 Unicode 字符,并检查它是否为非数字代码。如果字符是非数字的,我们调用$event对象的preventDefault方法来取消用户操作并将<input>元素回滚到其之前的状态。同时,如果键是数字的,我们将相应的类设置为valid,如果不是,则设置为invalid

我们可以在<input>标签中如下应用指令:

<input appNumeric /> 

我们将在第十章使用表单收集用户数据中看到指令的实际应用。同时,如果你想亲自尝试,记得在使用它之前在你的组件中导入NumericDirective类。

摘要

现在我们已经到达这个阶段,可以说你已经遇到了几乎所有的 Angular 构建 Angular 组件的元素,这些元素确实是所有 Angular 应用程序的轮子和引擎。在接下来的章节中,我们将看到我们如何更好地设计我们的应用程序架构,管理组件树中的依赖注入,消费数据服务,并利用新的 Angular 路由器在需要时显示和隐藏组件。

现在,准备好迎接新的挑战——在下一章中,我们将发现如何使用数据服务来管理组件中的复杂任务。

第五章:使用服务管理复杂任务

在我们的旅程中,我们已经达到了一个阶段,可以通过在组件树中嵌套其他组件来成功开发更复杂的应用程序。然而,将所有业务逻辑捆绑到一个组件中并不是一个好的做法。随着应用程序的发展,它可能会很快变得难以维护。

在本章中,我们将探讨 Angular 的依赖管理机制如何带来优势以克服这些问题。我们将学习如何使用 Angular 依赖注入DI)机制以最小的努力和最佳结果在应用程序中声明和消费我们的依赖项。到本章结束时,您将能够创建一个正确结构的 Angular 应用程序,使用服务强制执行 关注点分离SoC)模式。

我们将介绍与 Angular 服务相关的以下概念:

  • 介绍 Angular 依赖注入

  • 创建我们的第一个 Angular 服务

  • 在应用程序中提供依赖项

  • 在组件树中注入服务

  • 在注入器层次结构中覆盖提供者

技术要求

本章包含各种代码示例,以向您介绍 Angular 服务的概念。您可以在以下 GitHub 仓库的 ch05 文件夹中找到相关的源代码:

www.github.com/PacktPublishing/Learning-Angular-Fifth-Edition

介绍 Angular 依赖注入

依赖注入(DI)是一种应用设计模式,我们也在其他语言中遇到过,例如 C# 和 Java。随着我们的应用程序增长和演变,每个代码实体将内部需要其他对象的实例,这通常被称为依赖项。将这些依赖项传递给消费者代码实体称为注入,这也涉及到另一个称为注入器的代码实体。注入器负责实例化和启动所需的依赖项,以便在注入到消费者时可以使用。消费者对其依赖项的实例化方式一无所知,只知道它们实现的接口以使用它们。

Angular 包含一个顶级的 DI 机制,用于向任何 Angular 应用程序的 Angular 实体公开所需的依赖项。在深入探讨这个主题之前,让我们看看 Angular 中的 DI 试图解决的问题。

第三章使用组件构建用户界面 中,我们学习了如何使用 @for 块显示对象列表。我们使用了一个静态的 Product 对象列表,这些对象在 product-list.component.ts 文件中声明,如下所示:

products: Product[] = [
  { 
    id: 1,
    title: 'Keyboard',
    price: 100,
    categories: {
      1: 'Computing',
      2: 'Peripherals'
    }
  },
  {
    id: 2,
    title: 'Microphone',
    price: 35,
    categories: { 3: 'Multimedia' }
  },
  {
    id: 3,
    title: 'Web camera',
    price: 79,
    categories: {
      1: 'Computing',
      3: 'Multimedia'
    }
  },
  {
    id: 4,
    title: 'Tablet',
    price: 500,
    categories: { 4: 'Entertainment' }
  }
]; 

这种先前的方法有两个主要缺点:

  • 在现实世界的应用中,我们很少处理静态数据。它通常来自后端 API 或其他外部来源。

  • 产品列表与组件紧密耦合。Angular 组件负责展示逻辑,不应关心如何获取数据。它们只需要在 HTML 模板中显示数据。因此,它们应该将业务逻辑委托给服务来处理此类任务。

在接下来的章节中,我们将学习如何使用 Angular 服务避免这些障碍。

为了跟随本章的其余部分,您需要我们创建的 Angular 应用程序的源代码,即 第四章使用管道和指令丰富应用程序

我们将创建一个 Angular 服务,该服务将返回产品列表。因此,我们将有效地将业务逻辑任务从组件中委托出去。记住:组件只应关注展示逻辑

创建我们的第一个 Angular 服务

要创建一个新的 Angular 服务,我们使用 Angular CLI 的 ng generate 命令,同时传递服务的名称作为参数:

ng generate service products 

运行前面的命令将创建 products 服务,它包括 products.service.ts 文件及其伴随的单元测试文件 products.service.spec.ts

我们通常根据服务所表示的功能来命名服务。每个服务都有一个业务上下文或领域,在其中它运行。当它开始跨越不同上下文之间的边界时,这表明你应该将其拆分为不同的服务。产品服务应该关注产品。同样,订单应该由单独的订单服务管理。

Angular 服务是一个带有 @Injectable 装饰器的 TypeScript 类。装饰器将类标识为 Angular 服务,它可以注入到其他 Angular 实体中,如组件、指令,甚至是其他服务。它接受一个对象作为参数,该对象有一个名为 providedIn 的单个属性,它定义了哪个注入器提供该服务。

默认情况下,Angular 服务通过注入器注册——即 Angular 应用程序的 root 注入器,如 products.service.ts 文件中定义的那样:

import { Injectable } from '@angular/core';
@Injectable({
  providedIn: 'root'
})
export class ProductsService {
  constructor() { }
} 

我们的服务不包含任何实现。让我们添加一些逻辑,以便我们的组件可以使用它:

  1. 添加以下语句以导入 Product 接口:

    import { Product } from './product'; 
    
  2. ProductsService 类中创建以下方法:

    getProducts(): Product[] {
      return [
        { 
          id: 1,
          title: 'Keyboard',
          price: 100,
          categories: {
            1: 'Computing',
            2: 'Peripherals'
          }
        },
        {
          id: 2,
          title: 'Microphone',
          price: 35,
          categories: { 3: 'Multimedia' }
        },
        {
          id: 3,
          title: 'Web camera',
          price: 79,
          categories: {
            1: 'Computing',
            3: 'Multimedia'
          }
        },
        {
          id: 4,
          title: 'Tablet',
          price: 500,
          categories: { 4: 'Entertainment' }
        }
      ];
    } 
    

在接下来的章节中,我们将学习如何在我们的应用程序中使用该服务。

在构造函数中注入服务

在 Angular 组件中使用服务最常见的方式是通过其 constructor :

  1. 打开 product-list.component.ts 文件,并修改 products 属性,使其初始化为空数组:

    products: Product[] = []; 
    
  2. 添加以下语句以导入 ProductsService 类:

    import { ProductsService } from '../products.service'; 
    
  3. 创建一个名为 productService 的组件属性,并为其指定类型 ProductsService :

    private productService: ProductsService; 
    
  4. 使用 new 关键字在组件的 constructor 中实例化属性:

    constructor() {
      this.productService = new ProductsService();
    } 
    
  5. @angular/core npm 包中导入 OnInit 接口:

    import { Component, OnInit } from '@angular/core'; 
    
  6. OnInit接口添加到ProductListComponent类的实现接口列表中:

    export class ProductListComponent implements OnInit 
    
  7. 添加以下ngOnInit方法,该方法调用productService属性的getProducts方法,并将返回值赋给products属性:

    ngOnInit(): void {
      this.products = this.productService.getProducts();
    } 
    

使用ng serve命令运行应用程序以验证产品列表是否仍然正确显示在页面上:

包含文本、屏幕截图、字体、设计的图像自动生成的描述

图 5.1:产品列表

太棒了!我们已经成功地将组件与服务连接起来,应用程序看起来很棒。嗯,这似乎是情况,但实际上并非如此。实际的实现中存在一些问题。如果ProductsService类必须更改,比如为了适应另一个依赖项,ProductListComponent也应该更改其constructor的实现。因此,很明显,产品列表组件与ProductsService的实现紧密耦合。它阻止我们在需要时更改、覆盖或整洁地测试服务。它还意味着每次渲染产品列表组件时都会创建一个新的ProductsService对象,这在某些场景中可能不是期望的,例如当我们期望使用实际的单例服务时。

依赖注入系统通过提出几种模式来尝试解决这些问题,其中构造函数注入模式是由 Angular 强制执行的。我们可以移除productService组件属性,并将服务直接注入到constructor中。生成的ProductListComponent类如下:

export class ProductListComponent implements OnInit {
  products: Product[] = [];  
  selectedProduct: Product | undefined;
  constructor(private productService: ProductsService) {}

  onAdded() {
    alert(`${this.selectedProduct?.title} added to the cart!`);
  }
  ngOnInit(): void {
    this.products = this.productService.getProducts();
  }
} 

考虑将注入的服务声明为readonly,以提供更稳定的代码并防止服务重新赋值。在前面的代码片段中,constructor可以被重写为constructor(private readonly productService: ProductsService) {}

组件不需要知道如何实例化服务。另一方面,它期望在实例化之前就有一个这样的依赖项可用,以便可以通过其constructor注入。这种方法更容易测试,因为它允许我们覆盖它或模拟它。

然而,在 Angular 应用程序中注入服务的方式不仅仅是使用constructor,正如我们将在下一节中学习的。

注入关键字

Angular 框架包含一个内置的inject方法,我们可以使用它来注入服务而不使用constructor。有一些情况下我们希望使用inject方法:

  • constructor包含许多注入的服务,这使得我们的代码难以阅读。

  • 在与 Angular 路由器或 HTTP 客户端一起使用纯函数时,不能使用constructor,正如我们将在下一章中学习的。

让我们看看我们如何重构产品列表组件以使用inject方法:

  1. 打开product-list.component.ts文件,并从@angular/corenpm 包中导入inject方法:

    import { Component, OnInit, **inject** } from '@angular/core'; 
    
  2. ProductListComponent类中声明以下属性:

    private productService = inject(ProductsService); 
    
  3. ProductListComponent类中删除constructor

如果我们运行ng serve命令,应用程序应该仍然按预期工作。产品列表应该像上一节中显示的那样显示。

我们将在第八章通过 HTTP 与数据服务通信和第九章使用路由导航应用程序中探索inject方法的更多用例。

与构造函数方法相比,inject方法提供了更精确的类型,强制执行强类型 Angular 应用程序。

Angular CLI 提供了一个可以运行的图表,我们可以用它来迁移到新的inject方法。有关如何运行图表的更多详细信息,请参阅angular.dev/reference/migrations/inject-function

在这本书中,我们根据应用程序代码的执行上下文,同时使用注入方法和构造函数方法。

正如我们所学的,当我们创建一个新的 Angular 服务时,Angular CLI 默认将此服务注册到应用程序的根注入器。在下一节中,我们将了解 DI 机制的内部原理以及根注入器的工作方式。

在应用程序中提供依赖项

Angular 框架提供了一个 DI 机制,用于在 Angular 实体(如组件、指令、管道和服务)中提供依赖项。Angular DI 基于一个注入器层次结构,其中在顶部是 Angular 应用程序的根注入器。

Angular 中的注入器可以检查 Angular 实体(如构造函数)中的依赖项,并为每个依赖项返回其类型的实例,这样我们就可以直接在我们的 Angular 类实现中使用它。注入器维护一个 Angular 应用程序需要的所有依赖项的列表。当组件或其他实体想要使用依赖项时,注入器首先检查是否已经创建了该依赖项的实例。如果没有,它将创建一个新的实例,将其返回给组件,并保留一个副本以供进一步使用。下次请求相同的依赖项时,它将返回之前创建的副本。但是,注入器如何知道 Angular 应用程序需要哪些依赖项呢?

当我们创建一个 Angular 服务时,我们使用 @Injectable 装饰器的 providedIn 属性来定义它如何提供给应用程序。也就是说,我们为这个服务创建一个 提供者。提供者是一个包含创建特定服务的指南的 配方。在应用程序启动期间,框架负责使用服务提供者的提供者配置注入器,以便它知道如何在请求时创建一个。使用 CLI 创建的 Angular 服务默认情况下通过根注入器进行配置。根注入器创建单例服务,这些服务通过应用程序全局可用。

第一章构建你的第一个 Angular 应用程序 中,我们了解到在 app.config.ts 文件中定义的应用程序配置对象有一个 providers 属性,我们可以在这里注册应用程序服务。我们可以从 products.service.ts 文件的 @Injectable 装饰器中移除 providedIn 属性,并将其直接添加到该数组中。以这种方式注册服务与使用 providedIn: 'root' 配置服务相同。它们之间的主要区别是 providedIn 语法是 可树摇的

树摇(Tree shaking)是寻找应用中未使用的依赖项并将它们从最终包中移除的过程。在 Angular 的上下文中,Angular 编译器可以检测并删除未使用的 Angular 服务,从而生成更小的包。

当你通过应用程序配置对象提供服务时,Angular 编译器无法确定该服务是否在应用程序的某个地方被使用。因此,它预先将服务包含在最终包中。因此,在应用程序配置的 providers 数组上使用 @Injectable 装饰器是首选的。

你应该始终在根注入器中注册单例服务。

根注入器并不是 Angular 应用程序中唯一的注入器。组件也有它们自己的注入器。Angular 注入器也是分层的。每当 Angular 组件在其 constructor 中定义一个令牌时,注入器会在注册提供者的池中搜索与该令牌匹配的类型。如果没有找到匹配项,它将搜索委托给父组件的提供者,并继续向上冒泡组件注入器树,直到达到根注入器。如果没有找到匹配项,Angular 将抛出异常。

让我们使用 Angular DevTools 探索产品列表组件的注入器层次结构:

  1. 使用 ng serve 命令运行应用程序,并在 http://localhost:4200 预览它。

  2. 启动 Angular DevTools 并选择 组件 选项卡。

  3. 从组件树中选择 app-product-list 组件:

包含文本、屏幕截图、字体、编号的图像,自动生成的描述

图 5.2:组件选项卡

在前面的图像中,注入的服务部分包含了注入到组件中的服务。

  1. 点击ProductsService标签旁边的向下箭头,你会看到以下图表:

包含文本的图片,屏幕截图,线条,字体  自动生成的描述

图 5.3:产品列表注入器层次结构

Angular DevTools 中的注入器层次结构图是水平方向的。在这里,我们将其垂直显示以提高可读性。

上述图表描述了产品列表组件的注入器层次结构。它包含两个在 Angular 应用程序中常见的注入器层次结构类型:环境元素注入器。

环境注入器是通过应用程序配置对象中的providedIn属性和providers数组进行配置的。在我们的例子中,我们看到RootStandalone[_AppComponent]注入器,因为产品服务是通过providedIn属性从根注入器提供的。

Angular 为每个组件创建一个元素注入器,该注入器可以从@Component装饰器的providers数组中进行配置,正如我们将在下一节中看到的。在我们的例子中,我们看到AppComponentProductListComponent注入器,因为这些组件直接与产品列表相关。

您可以通过选择 Angular DevTools 的注入器树选项卡来对应用程序的每个类型的注入器层次结构进行更详细的分析。您还可以在angular.dev/guide/di/hierarchical-dependency-injection#types-of-injector-hierarchies了解更多关于不同类型的注入器信息。

组件创建注入器,因此它们立即可用于其子组件。我们将在下一节中详细介绍这一点。

在组件树中注入服务

如前所述,Angular 使用元素注入器通过@Component装饰器的providers属性在组件中提供服务。注册到元素注入器的服务可以有两个用途:

  • 它可以与其子组件共享

  • 它可以在提供服务的组件每次渲染时创建服务的多个副本

在接下来的章节中,我们将学习如何应用每种方法。

通过组件共享依赖项

通过组件提供的服务可以在父组件的子组件之间共享,并且它立即可用于注入到它们的构造函数中。子组件会重用与父组件相同的服务的实例。让我们通过一个例子来更好地理解这一点:

  1. 创建一个名为favorites的新 Angular 组件:

    ng generate component favorites 
    
  2. 打开favorites.component.ts文件并相应地修改import语句:

    import { Component, **OnInit** } from '@angular/core';
    **import { Product } from '../product';**
    **import { ProductsService } from '../products.service';** 
    
  3. 修改FavoritesComponent类以使用ProductsService类并在products组件属性中获取产品列表:

    export class FavoritesComponent implements OnInit {
      products: Product[] = [];
      constructor(private productService: ProductsService) {}
      ngOnInit(): void {
        this.products = this.productService.getProducts();
      }
    } 
    
  4. 打开 favorites.component.html 文件,将其内容替换为以下 HTML 代码:

     <ul class="pill-group">
      @for (product of products | slice:1:3; track product.id) {
        <li class="pill">
          ⭐ {{product.title}}
        </li>
      }
    </ul> 
    

在前面的代码片段中,我们遍历 products 数组,并使用 slice 管道仅显示两个产品。

  1. 修改 favorites.component.ts 文件,使其导入 CommonModule 类,这是 slice 管道所必需的:

    **import { CommonModule } from '@angular/common';**
    import { Component, OnInit } from '@angular/core';
    import { Product } from '../product';
    import { ProductsService } from '../products.service';
    @Component({
      selector: 'app-favorites',
      imports: [**CommonModule**],
      templateUrl: './favorites.component.html',
      styleUrl: './favorites.component.css'
    }) 
    
  2. 打开 favorites.component.css 文件,为我们的收藏产品添加一些 CSS 样式:

    .pill-group {
      display: flex;
      flex-direction: column;
      align-items: start;
      flex-wrap: wrap;
      gap: 1.25rem;
    }
    .pill {
      display: flex;
      align-items: center;
      --pill-accent: var(--hot-red);
      background: color-mix(in srgb, var(--hot-red) 5%, transparent);
      color: var(--pill-accent);
      padding-inline: 0.75rem;
      padding-block: 0.375rem;
      border-radius: 2.75rem;
      border: 0;
      transition: background 0.3s ease;
      font-family: var(--inter-font);
      font-size: 0.875rem;
      font-style: normal;
      font-weight: 500;
      line-height: 1.4rem;
      letter-spacing: -0.00875rem;
      text-decoration: none;
    } 
    
  3. 打开 product-list.component.ts 文件,导入 FavoritesComponent 类,并将 ProductsService 类添加到 @Component 装饰器的 providers 数组中:

    import { Component, OnInit } from '@angular/core';
    import { Product } from '../product';
    import { ProductDetailComponent } from '../product-detail/product-detail.component';
    import { SortPipe } from '../sort.pipe';
    import { ProductsService } from '../products.service';
    **import { FavoritesComponent } from '../favorites/favorites.component';**
    
    @Component({
      selector: 'app-product-list',
      imports: [ProductDetailComponent, SortPipe, **FavoritesComponent**],
      templateUrl: './product-list.component.html',
      styleUrl: './product-list.component.css',
      **providers: [ProductsService]**
    }) 
    
  4. 打开 products.service.ts 文件,从 @Injectable 装饰器中移除 providedIn 属性,因为产品列表组件的元素注入器将提供它。

  5. 最后,打开 product-list.component.html 文件,并添加以下 HTML 片段以显示收藏组件的内容:

    <h1>Favorites</h1>
    <app-favorites></app-favorites> 
    

当使用 ng serve 运行应用程序时,你应该看到以下输出:

包含文本、屏幕截图、字体、设计的图像 自动生成的描述

图 5.4:带有收藏夹的产品列表

让我们更详细地解释一下上一个示例中我们做了什么。我们在 FavoritesComponent 中注入了 ProductsService,但没有通过其注入器提供它。那么组件是如何知道如何创建 ProductsService 类的实例并使用它的呢?它并不知道。当我们将收藏组件添加到 ProductListComponent 模板中时,我们使其成为该组件的直接子组件,从而使其能够访问所有提供的服务。简而言之,FavoritesComponent 可以直接使用 ProductsService,因为它已经通过其父组件 ProductListComponent 的元素注入器提供。

因此,即使 ProductsService 最初是在环境根注入器中注册的,我们也可以将其注册到 ProductListComponent 的元素注入器中。在下一节中,我们将探讨如何实现这种行为。

根和组件注入器

我们已经了解到,当我们使用 Angular CLI 创建 Angular 服务时,服务默认由应用程序的根注入器提供。通过组件的元素注入器提供服务时,这有何不同?

使用应用程序根注入器提供的服务在整个应用程序中可用。当组件想要使用此类服务时,它只需要注入它,无需更多操作。现在,如果组件通过其注入器提供相同的服务,它将获得一个与根注入器完全不同的服务实例。这种技术被称为 服务作用域限制,因为我们限制了服务的范围到组件树的一个特定部分:

描述自动生成的表格

图 5.5:服务作用域限制

上一张图显示,ProductsService可以通过两个注入器提供:应用程序根注入器和产品列表组件的元素注入器。FavoritesComponent类注入ProductsService以使用它。正如我们之前看到的,FavoritesComponentProductListComponent的子组件。

根据注入器层次结构,它首先会询问其父组件ProductListComponent是否提供该服务。ProductListComponent类确实提供了ProductsService,因此它会创建该服务的新实例并将其返回给FavoritesComponent

现在,考虑我们应用程序中的另一个组件CmpA,它想使用ProductsService。由于它不是ProductListComponent的子组件,也不包含任何提供所需服务的父组件,它最终会到达应用程序根注入器。提供ProductsService的根注入器会检查是否已经为该服务创建了一个实例。如果没有,它会创建一个新的实例,称为productService,并将其返回给CmpA。它还会将productService保留在本地服务池中,以供以后使用。

假设另一个组件CmpB想使用ProductsService并请求应用程序根注入器。根注入器知道当CmpA请求它时已经创建了productService实例,并立即将其返回给CmpB组件。

将具有多个实例的组件进行沙盒化

当我们通过元素注入器提供服务并将其注入到组件的constructor中时,每次组件在页面上渲染时都会创建一个新的实例。这在我们需要为每个组件拥有一个本地缓存服务的情况下很有用。我们将通过将我们的 Angular 应用程序转换为产品列表使用 Angular 服务显示每个产品的快速预览来探索这个场景:

  1. 运行以下命令以创建一个新的 Angular 组件用于产品视图:

    ng generate component product-view 
    
  2. 打开product-view.component.ts文件,声明一个名为id的输入属性,这样我们就可以传递我们想要显示的产品的一个唯一标识符:

    import { Component, **input** } from '@angular/core';
    @Component({
      selector: 'app-product-view',
      imports: [],
      templateUrl: './product-view.component.html',
      styleUrl: './product-view.component.css'
    })
    export class ProductViewComponent {
      **id = input<number>();**
    } 
    
  3. product-view文件夹内运行以下 Angular CLI 命令以创建一个将专门用于产品视图组件的 Angular 服务:

    ng generate service product-view 
    
  4. 打开product-view.service.ts文件,从@Injectable装饰器中移除providedIn属性,因为我们将在产品视图组件中稍后提供它。

  5. ProductsService注入到ProductViewService类的constructor中:

    import { Injectable } from '@angular/core';
    **import { ProductsService } from '../products.service';**
    @Injectable()
    export class ProductViewService {
      constructor(**private productService: ProductsService**) { }
    } 
    

上述技术被称为服务中的服务,因为我们在一个 Angular 服务中注入了另一个服务。

  1. 创建一个名为getProduct的方法,该方法接受一个id属性作为参数。该方法将调用ProductsService类的getProducts方法并根据id搜索产品列表。如果找到产品,它将将其保存在一个名为product的本地变量中:

    import { Injectable } from '@angular/core';
    import { ProductsService } from '../products.service';
    **import { Product } from '../product';**
    @Injectable()
    export class ProductViewService {
      **private product: Product | undefined;**
      constructor(private productService: ProductsService) { }
      **getProduct(id: number): Product | undefined {**
    **const products = this.productService.getProducts();**
    **if (!this.product) {**
    **this.product = products.find(product => product.id === id)**
    **}**
    **return this.product;**
    **}**  
    } 
    

我们已经为与产品视图组件一起工作创建了必要的 Angular 元素。我们现在需要做的就是将它们连接起来,并将它们连接到产品列表:

  1. ProductViewComponentconstructor 中注入 ProductViewService 并实现 ngOnInit 方法:

    import { Component, input, **OnInit** } from '@angular/core';
    **import { ProductViewService } from './product-view.service';**
    @Component({
      selector: 'app-product-view',
      imports: [],
      templateUrl: './product-view.component.html',
      styleUrl: './product-view.component.css',
      **providers: [ProductViewService]**
    })
    export class ProductViewComponent **implements OnInit** {
      id = input<number>();
      **constructor(private productViewService: ProductViewService) {}**
    **ngOnInit(): void {**
    **}**
    } 
    
  2. 创建一个组件属性以保存我们将从 ProductViewService 类获取的产品:

    import { Component, input, OnInit } from '@angular/core';
    import { ProductViewService } from './product-view.service';
    **import { Product } from '../product';**
    @Component({
      selector: 'app-product-view',
      imports: [],
      templateUrl: './product-view.component.html',
      styleUrl: './product-view.component.css',
      providers: [ProductViewService]
    })
    export class ProductViewComponent implements OnInit {
      id = input<number>();
      **product: Product | undefined;**
    
      constructor(private productViewService: ProductViewService) {}
      ngOnInit(): void {
      }
    } 
    
  3. 修改 ngOnInit 方法,使其调用 ProductViewService 类的 getProduct 方法,如下所示:

    ngOnInit(): void {
      **this.product = this.productViewService.getProduct(this.id()!);**
    } 
    

在前面的代码片段中,我们将 id 组件属性作为参数传递给 getProduct 方法,并将返回的值赋给 product 属性。

  1. 打开 product-view.component.html 文件,并用以下 HTML 模板替换其内容:

    @switch (product?.title) {
      @case ('Keyboard') { ![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-041.png) }
      @case ('Microphone') { ![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-05.png) }
      @default { ![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-06.png) }
    }
    {{product?.title}} 
    
  2. 打开 product-list.component.ts 文件并导入 ProductViewComponent 类:

    import { Component, OnInit } from '@angular/core';
    import { Product } from '../product';
    import { ProductDetailComponent } from '../product-detail/product-detail.component';
    import { SortPipe } from '../sort.pipe';
    import { ProductsService } from '../products.service';
    **import { ProductViewComponent } from '../product-view/product-view.component';**
    @Component({
      selector: 'app-product-list',
      imports: [ProductDetailComponent, SortPipe, **ProductViewComponent**],
      templateUrl: './product-list.component.html',
      styleUrl: './product-list.component.css'
    }) 
    
  3. 最后,打开 product-list.component.html 文件,并修改 @for 块以使用产品视图组件:

    <ul class="pill-group">
      @for (product of products | sort; track product.id) {
        <li class="pill" (click)="selectedProduct = product">
          **<app-product-view [id]="product.id"></app-product-view>**
        </li>
      } @empty {
        <p>No products found!</p>
      }
    </ul> 
    

如果我们使用 ng serve 命令运行我们的应用程序,我们会看到产品列表仍然正确显示。

每个渲染的产品视图组件都会为其目的创建一个专门的沙盒 ProductViewService 实例。任何其他组件都不能共享该实例或被提供它的组件更改。

尝试在 ProductListComponent 中提供 ProductViewService 而不是 ProductViewComponent;你会看到只有一个产品被渲染多次:

包含文本、屏幕截图、字体、标志的图片,自动生成的描述

图 5.6:产品列表

在这种情况下,只有一个服务实例在子组件之间共享。为什么是这样?回想一下 ProductViewService 类中 getProduct 方法的业务逻辑:

getProduct(id: number): Product | undefined {
  const products = this.productService.getProducts();
  if (!this.product) {
    this.product = products.find(product => product.id === id)
  }
  return this.product;
} 

在前面的方法中,当我们在 ProductListComponent 内部提供服务时,product 属性被初始化设置。由于我们只有一个服务实例,该属性的值将在我们多次渲染产品视图组件时保持不变。

我们已经学习了如何将依赖项注入到组件层次结构中,以及如何通过在组件树中冒泡请求来执行提供者查找。然而,如果我们想限制这样的注入或查找操作呢?我们将在下一节中看到如何做到这一点。

限制提供者查找

我们只能将依赖项查找限制在下一级。为此,我们需要将这些依赖项参数应用 @Host 装饰器,我们想要限制其提供者查找:

import { CommonModule } from '@angular/common';
import { Component, **Host**, OnInit } from '@angular/core';
import { Product } from '../product';
import { ProductsService } from '../products.service';
@Component({
  selector: 'app-favorites',
  imports: [CommonModule],
  templateUrl: './favorites.component.html',
  styleUrl: './favorites.component.css'
})
export class FavoritesComponent implements OnInit {
  products: Product[] = [];
  constructor(**@Host()** private productService: ProductsService) {}
  ngOnInit(): void {
    this.products = this.productService.getProducts();
  }
} 

在前面的示例中,FavoritesComponent 的元素注入器将在其提供者中查找 ProductsService 类。如果它不提供该服务,它将不会冒泡注入器层次结构;相反,它将停止并在浏览器的控制台窗口中抛出异常:

错误:NG0201:在 NodeInjector 中找不到 _ProductsService 提供者

我们可以配置注入器,使其在用 @Optional 装饰器装饰服务时不会抛出错误:

import { CommonModule } from '@angular/common';
import { Component, Host, OnInit, **Optional** } from '@angular/core';
import { Product } from '../product';
import { ProductsService } from '../products.service';
@Component({
  selector: 'app-favorites',
  imports: [CommonModule],
  templateUrl: './favorites.component.html',
  styleUrl: './favorites.component.css'
})
export class FavoritesComponent implements OnInit {
  products: Product[] = [];
  constructor(**@Optional()** @Host() private productService: ProductsService) {}
  ngOnInit(): void {
    this.products = this.productService.getProducts();
  }
} 

然而,使用 @Optional 装饰器并不能解决实际问题。前面的代码片段仍然会抛出错误,与之前的错误不同,因为我们仍然使用了限制在注入器层次结构中搜索 ProductsService 类的 @Host 装饰器。我们需要重构 ngOnInit 生命周期钩子事件,以确保它不会找到服务实例。

@Host@Optional 装饰器定义了注入器搜索依赖项的级别。还有另外两个装饰器,称为 @Self@SkipSelf。当使用 @Self 装饰器时,注入器在当前组件的注入器中查找依赖项。相反,@SkipSelf 装饰器指示注入器跳过本地注入器,并在注入器层次结构中进一步搜索。

@Host@Self 装饰器的工作方式类似。有关何时使用每个装饰器的更多信息,请参阅angular.dev/guide/di/hierarchical-dependency-injection#selfangular.dev/guide/di/hierarchical-dependency-injection#host

到目前为止,我们已经学习了 Angular DI 框架如何使用类作为依赖项令牌来确定所需类型,并从注入器层次结构中的任何提供者返回它。然而,在某些情况下,我们可能需要覆盖类的实例或提供不是实际类的类型,例如原始类型。

覆盖注入器层次结构中的提供者

我们已经在 通过组件共享依赖项 部分学习了如何使用 @Component 装饰器的 providers 数组:

providers: [ProductsService] 

前面的语法称为 类提供者 语法,是下面显示的 提供对象字面量 语法的简写:

providers: [
  `{` **provide: ProductsService, useClass: ProductsService** `}`
] 

前面的语法使用了一个具有以下属性的对象:

  • provide:这是用于配置注入器的令牌。它是消费者将其注入构造函数的实际类。

  • useClass:这是注入器将提供给消费者的实际实现。属性名将根据提供的实现类型而有所不同。类型可以是类、值或工厂函数。在这种情况下,我们使用 useClass,因为我们提供了一个类。

让我们看看一些示例,以了解如何使用提供对象字面量语法。

覆盖服务实现

我们已经了解到,组件可以与其子组件共享其依赖项。考虑FavoritesComponent,我们在其模板中使用slice管道显示收藏产品的列表。如果它需要通过ProductsService的裁剪版本来获取数据,而不是直接从ProductListComponent的服务实例中获取数据怎么办?我们可以创建一个新的服务,该服务扩展了ProductsService类,并使用原生的Array.slice方法过滤数据。让我们创建新的服务并学习如何使用它:

  1. 运行以下命令以生成服务:

    ng generate service favorites 
    
  2. 打开favorites.service.ts文件并添加以下import语句:

    import { Product } from './product';
    import { ProductsService } from './products.service'; 
    
  3. 在类定义中使用extends关键字来指示ProductsServiceFavoritesService的基类:

    export class FavoritesService **extends ProductsService** {
      constructor() { }
    } 
    
  4. 修改constructor以调用super方法并在基类constructor中执行任何业务逻辑:

    constructor() {
      **super();**
    } 
    
  5. 创建以下服务方法,使用slice方法从列表中返回前两个产品:

    override getProducts(): Product[] {
      return super.getProducts().slice(1, 3);
    } 
    

之前的方法使用override关键字标记,以表明该方法的实现替换了基类中对应的方法。

  1. 打开favorites.component.ts文件,并添加以下import语句:

    import { FavoritesService } from '../favorites.service'; 
    
  2. FavoritesService类添加到@Component装饰器的providers数组中,如下所示:

    @Component({
      selector: 'app-favorites',
      imports: [],
      templateUrl: './favorites.component.html',
      styleUrl: './favorites.component.css',
      **providers: [**
    **{ provide: ProductsService, useClass: FavoritesService }**
    **]**
    }) 
    

在前面的代码片段中,我们从imports数组中移除了CommonModule,因为我们不再需要slice管道。

  1. 最后,打开favorites.component.html文件并从@for块中删除slice管道。

如果我们使用ng serve命令运行应用程序,我们将看到收藏部分仍然正确显示:

包含文本、字体、标志、图形的图片,自动生成的描述

图 5.7:收藏产品列表

前面的输出假设您已经将收藏组件导入并添加到产品列表组件中。

useClass属性本质上覆盖了ProductsService类在收藏组件中的初始实现。或者,我们可以更进一步,使用一个函数返回我们需要的特定对象实例,正如我们将在下一节中学习的那样。

条件提供服务

在上一节的示例中,我们使用了useClass语法来替换注入的ProductsService类的实现。或者,我们可以创建一个工厂函数,根据条件决定返回FavoritesService类或ProductsService类的实例。该函数将位于一个简单的 TypeScript 文件中,命名为favorites.ts

import { FavoritesService } from './favorites.service';
import { ProductsService } from './products.service';
export function favoritesFactory(isFavorite: boolean) {
  return () => {
    if (isFavorite) {
      return new FavoritesService();
    }
    return new ProductsService();
  };
} 

然后,我们可以修改favorites.component.ts文件中的providers数组,如下所示:

import { CommonModule } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { Product } from '../product';
import { ProductsService } from '../products.service';
**import { favoritesFactory } from '../favorites';**
@Component({
  selector: 'app-favorites',
  imports: [CommonModule],
  templateUrl: './favorites.component.html',
  styleUrl: './favorites.component.css',
  **providers: [**
**{ provide: ProductsService, useFactory: favoritesFactory(true) }**
**]**
}) 

值得注意的是,如果其中一个服务注入了其他依赖项,之前的语法就不够用了。例如,如果FavoritesService类依赖于ProductViewService类,我们就会将其添加到提供对象字面量语法的deps属性中:

providers: [
  {
    provide: ProductsService,
    useFactory: favoritesFactory(true),
    **deps: [ProductViewService]**
  }
] 

然后,我们可以在favorites.ts文件的工厂函数中使用它,如下所示:

export function favoritesFactory(isFavorite: boolean) {
  return (**productViewService: ProductViewService**) => {
    if (isFavorite) {
      return new FavoritesService();
    }
    return new ProductsService();
  };
} 

我们已经学习了如何为 Angular 服务提供一个替代类实现。如果我们想提供的依赖项不是一个类,而是一个字符串或对象怎么办?我们可以使用useValue语法来完成这个任务。

在 Angular 服务中转换对象

在实际应用中,通常会将应用程序设置保存在一个常量对象中。我们如何使用useValue语法在我们的组件中提供这些设置呢?我们将通过为我们的应用程序创建设置,例如版本号和标题,来了解更多:

  1. 在 Angular CLI 工作区的src\app文件夹中创建一个app.settings.ts文件,并添加以下内容:

    export interface AppSettings {
      title: string;
      version: string;
    }
    export const appSettings: AppSettings = {
      title: 'My e-shop',
      version: '1.0'
    }; 
    

你可能会认为我们可以将这些设置作为{ provide: AppSettings, useValue: appSettings }提供,但这样会抛出一个错误,因为AppSettings是一个接口,不是一个类。接口是 TypeScript 中的语法糖,在编译过程中会被丢弃。相反,我们应该提供一个InjectionToken对象。

  1. @angular/core npm 包中导入InjectionToken类的以下语句添加到其中:

    import { InjectionToken } from '@angular/core'; 
    
  2. 声明以下使用InjectionToken类型的常量变量:

    export const APP_SETTINGS = new InjectionToken<AppSettings>('app.settings'); 
    
  3. 打开app.component.ts文件,并按以下方式修改import语句:

    import { Component, **inject** } from '@angular/core';
    import { RouterOutlet } from '@angular/router';
    import { ProductListComponent } from './product-list/product-list.component';
    import { CopyrightDirective } from './copyright.directive';
    **import { APP_SETTINGS, appSettings } from './app.settings';** 
    
  4. @Component装饰器的providers数组中添加应用程序设置令牌:

    @Component({
      selector: 'app-root',
      imports: [
        RouterOutlet,
        ProductListComponent,
        CopyrightDirective
      ],
      templateUrl: './app.component.html',
      styleUrl: './app.component.css',
      **providers: [**
    **{ provide: APP_SETTINGS, useValue: appSettings }**
    **]**
    }) 
    

    useValue语法在测试 Angular 应用程序时特别有用。当我们学习第十三章“单元测试 Angular 应用程序”时,我们将广泛使用它。

  5. AppComponent类中添加以下属性:

    settings = inject(APP_SETTINGS); 
    
  6. 打开app.component.html文件,并修改<footer>标签以包含应用程序版本:

    <footer appCopyright> `-` **v{{ settings.version }}**</footer> 
    
  7. 使用ng serve命令运行应用程序,并观察应用程序输出的页脚:

版权所有©2024 保留所有权利 - v1.0

注意,尽管AppSettings接口在注入过程中没有发挥重要作用,但我们仍需要它来为配置对象提供类型定义。

Angular DI(依赖注入)是一个强大且稳健的机制,它允许我们高效地管理应用程序的依赖项。Angular 团队投入了大量精力使其易于使用,并减轻了开发者的负担。正如我们所见,组合方式众多,我们将如何使用它们取决于具体的使用场景。

摘要

Angular DI 实现是 Angular 框架的骨架。Angular 组件基于 Angular DI 将复杂任务委托给 Angular 服务。

在本章中,我们学习了 Angular DI 是什么以及如何通过创建 Angular 服务来利用它。我们探讨了将 Angular 服务注入组件的不同方法。我们看到了如何在组件之间共享服务,在组件中隔离服务,以及如何通过组件树定义依赖访问。

最后,我们探讨了如何通过替换服务实现或将现有对象转换为服务来覆盖 Angular 服务。

在下一章中,我们将学习响应式编程是什么以及我们如何在 Angular 应用程序上下文中使用可观察对象。

第六章:Angular 中的响应式模式

处理异步信息是开发者日常生活中的常见任务。响应式编程是一种范式,它帮助我们通过数据流来消费、消化和转换异步信息。RxJS是一个 JavaScript 库,它提供了使用可观察对象来操作数据流的方法。

Angular 提供了一套无与伦比的工具集,帮助我们处理异步数据。可观察流是这套工具集的前沿,为开发者创建 Angular 应用提供了丰富的功能。Angular 框架的核心对 RxJS 的依赖性较小。其他 Angular 包,如路由器和 HTTP 客户端,与可观察对象的耦合性更紧密。然而,在撰写本文时,Angular 团队目前正在调查如何使上述包减少对可观察对象的依赖。

在本章中,我们将学习以下概念:

  • 处理异步信息的策略

  • Angular 中的响应式编程

  • RxJS 库

  • 订阅可观察对象

  • 从可观察对象中退订

技术要求

本章包含各种代码示例,引导您了解可观察对象和 RxJS。您可以在以下 GitHub 仓库的 ch06 文件夹中找到相关源代码:

www.github.com/PacktPublishing/Learning-Angular-Fifth-Edition

处理异步信息的策略

我们以不同的形式异步管理数据,例如从后端 API 消费数据,这是我们日常开发工作流程中的典型操作,或者从本地文件系统中读取内容。我们总是通过 HTTP 消费数据,例如通过向认证服务发送凭证来验证用户。我们还在获取我们最喜欢的社交网络应用中的最新帖子时使用 HTTP。

现代移动设备引入了一种独特的消费远程服务的方式。它们将请求和响应消费推迟到移动连接可用时。响应性和可用性已经成为一大问题。

尽管现在的互联网连接速度很快,但在提供此类信息时,响应时间总是存在的。因此,正如我们将在本节中看到的,我们为最终用户透明地处理应用程序的状态设置了机制。

从回调地狱转向承诺

有时我们可能需要在应用程序中构建一些功能,这些功能在时间经过后异步地改变其状态。在这些情况下,我们必须引入代码模式,例如回调模式,来处理应用程序状态的这种延迟变化。

在回调中,触发异步操作的功能接受另一个函数作为参数。当异步操作完成后,该函数将被执行。

您需要我们在第五章使用服务管理复杂任务中创建的 Angular 应用程序的源代码,才能跟随本章的其余部分。在您获取代码后,我们建议您为简单起见采取以下行动:

  • 删除favorites文件夹

  • 删除favorites.service.ts及其单元测试文件

  • 删除favorite.ts文件

  • 删除numeric.directive.ts文件及其单元测试文件

  • 删除product-view文件夹

让我们通过一个示例来看看如何使用回调:

  1. 打开app.component.html文件,并添加一个<header>HTML 元素来显示title组件属性,使用插值表达式:

    **<header>{{ title }}</header>**
    <main class="main">
      <div class="content">
        <app-product-list></app-product-list>
      </div>
    </main>
    <footer appCopyright> - v{{ settings.version }}</footer>
    <router-outlet /> 
    
  2. 打开app.component.ts文件并创建以下属性:

    private setTitle = () => {
      this.title = this.settings.title;
    } 
    

setTitle属性用于根据应用程序设置中的title属性更改title组件属性。它返回一个箭头函数,因为我们将其用作另一个方法的回调。

  1. 接下来,创建一个名为changeTitle的方法,该方法在两秒后调用另一个方法,按照惯例命名为callback

    private changeTitle(callback: Function) {
      setTimeout(() => {
        callback();
      }, 2000);
    } 
    
  2. 添加一个constructor来调用changeTitle方法,并将setTitle属性作为参数传递:

    constructor() {
      this.changeTitle(this.setTitle);
    } 
    

在前面的代码片段中,我们使用setTitle属性而没有括号,因为我们使用回调时传递的是函数签名,而不是实际的函数调用。

如果我们使用ng serve命令运行 Angular 应用程序,我们会看到title属性在两秒后改变。我们刚才描述的模式的问题在于,随着我们引入更多的嵌套回调,代码可能会变得混乱和繁琐。

考虑以下场景,我们需要钻入文件夹层次结构以访问设备上的照片:

getRootFolder(folder => { 
  getAssetsFolder(folder, assets => {
    getPhotos(assets, photos => {}); 
  });   
}); 

在我们能够进行下一个调用之前,我们必须依赖于之前的异步调用及其返回的数据。我们必须在执行另一个带有回调的方法的方法内部执行一个方法。代码很快就会变得复杂且难以阅读,导致一种称为回调地狱的情况。

我们可以使用promises来避免回调地狱。Promises 通过遵循更整洁、更稳定的接口,引入了一种新的异步数据管理方式。不同的异步操作可以在同一级别上串联,甚至可以从其他函数中拆分并返回。

为了更好地理解 promises 的工作原理,让我们重构先前的回调示例:

  1. AppComponent类中创建一个名为onComplete的新方法,该方法返回一个Promise对象。Promise 可以是已解决拒绝的。resolve参数表示 promise 已成功完成,并可选择返回一个结果:

    private onComplete() {
      return new Promise<void>(resolve => {
      });
    } 
    
  2. 在 promise 中引入两秒的超时,以便在这段时间过后解决:

    private onComplete() {
      return new Promise<void>(resolve => {
        **setTimeout(() => {**
          **resolve();**
        **}, 2000);**
      });
    } 
    
  3. 现在,将constructor中的changeTitle调用替换为基于 promise 的方法。要执行返回 promise 的方法,我们调用该方法,并用then方法链式调用:

    constructor() {
      **this.onComplete().then(this.setTitle);**
    } 
    

如果我们重新运行 Angular 应用程序,我们不应该注意到任何显著的差异。承诺的真实价值在于它为我们代码带来的简洁性和可读性。我们现在可以相应地重构先前的文件夹层次结构示例:

getRootFolder()
  .then(getAssetsFolder)
  .then(getPhotos); 

上一段代码中then方法的链式调用展示了我们如何将一个异步调用紧接另一个异步调用。每个先前的异步调用都会将其结果传递给即将到来的异步方法。

承诺很有吸引力,但有时我们可能需要生成一个响应输出,它遵循更复杂的消化过程,甚至取消整个过程。我们不能用承诺实现这种行为,因为它们在实例化时就会触发。换句话说,承诺不是懒加载的。另一方面,在异步操作已经触发但尚未完成的情况下,取消该操作的可能性在特定场景中可能非常有用。承诺允许我们解决或拒绝异步操作,但有时我们可能想在达到那个点之前取消一切。

此外,承诺作为一次性操作。一旦它们被解决,除非我们从零开始运行一切,否则我们无法期望收到任何进一步的信息或状态变化通知。总结承诺的限制:

  • 它们不能被取消

  • 它们立即执行

  • 它们是单次操作;没有简单的方法可以重试它们

  • 它们只响应一个值

让我们用一个例子来说明一些限制:

  1. onComplete方法中将setTimeout替换为setInterval

    private onComplete() {
      return new Promise<void>(resolve => {
        **setInterval**(() => {
          resolve();
        }, 2000);
      });
    } 
    

承诺现在将每两秒解析一次。

  1. setTitle属性修改为在组件的title属性中附加当前的timestamp

    private setTitle = () => {
      **const timestamp = new Date();**
      this.title = **`${this.settings.title} (${timestamp})`;**
    } 
    
  2. 运行 Angular 应用程序,你会注意到时间戳在两秒后只设置了一次,之后再也没有改变。承诺自行解决,整个异步事件在那个时刻终止。

我们可能需要一个更主动的异步数据处理实现来修复前面的行为,这就是可观察对象出现的地方。

简而言之,可观察对象

可观察对象是一个对象,它维护一个依赖者列表,称为观察者,并通过异步方式发出事件来通知它们关于状态和数据的变化。为了做到这一点,可观察对象实现了所有必要的机制来产生和发出这些事件。它可以随时触发和取消,无论它是否已经发出了预期的数据。

观察者必须订阅一个可观察对象以接收通知并响应状态变化。这种模式被称为观察者模式,它允许并发操作和更高级的逻辑。这些观察者,也称为订阅者,会持续监听可观察对象中发生的事情,直到它被销毁。我们可以在实际示例中更清晰地看到这一切:

  1. rxjs npm 包中导入Observable对象:

    import { Observable } from 'rxjs'; 
    
  2. 创建一个名为 title$ 的组件属性,该属性创建一个 Observable 对象。可观察对象的构造函数接受一个 observer 对象作为参数。observer 是一个箭头函数,包含当有人使用可观察对象时将执行的业务逻辑。每隔两秒调用 observernext 方法以指示数据或应用程序状态的变化:

    title$ = new Observable(observer => {
      setInterval(() => {
        observer.next();
      }, 2000);
    }); 
    

当我们定义一个可观察变量时,我们倾向于在变量名后附加 $ 符号。这是我们遵循的一种约定,以便在我们的代码中高效且快速地识别可观察对象。

  1. 修改 constructor 组件以使用新创建的 title$ 属性:

    constructor() {
      **this.title$.subscribe**(this.setTitle);
    } 
    

我们使用 subscribe 方法注册到 title$ 可观察对象,并接收任何变化的通知。如果我们不调用此方法,setTitle 方法将永远不会执行。

除非有订阅者订阅它,否则可观察对象不会做任何事情。

如果你运行应用程序,你会注意到时间戳每两秒变化一次。恭喜!你已经进入了可观察对象和反应式编程的世界!

可观察对象返回一个事件流,我们的订阅者会收到这些事件的即时通知,以便他们可以相应地采取行动。它们不执行异步操作并终止(尽管我们可以配置它们这样做),而是启动一个持续的流,我们可以订阅它。

然而,这还不算完。这个流可以在到达订阅它的观察者之前组合许多操作。就像我们可以使用 mapfilter 等方法来操作数组并对其进行转换一样,我们也可以对由可观察对象发出的事件流做同样的事情。这是一个被称为反应式编程的模式,Angular 充分利用这种范式来处理异步信息。

Angular 中的反应式编程

观察者模式是反应式编程的核心。反应脚本最基本实现包括几个我们需要熟悉的概念:

  • 可观察对象

  • 观察者

  • 时间线

  • 一系列事件

  • 一组可组合的操作符

这可能听起来令人畏惧,但实际上并非如此。这里的重大挑战是改变我们的思维方式,学习如何进行反应式思考,这是本节的主要目标。

反应式编程涉及对事件流的可观察对象应用异步订阅和转换。

让我们通过一个更具描述性的例子来解释。想象一下一个交互设备,比如键盘。它有用户可以按下的键。每个按键都会触发一个特定的键盘事件,例如 keyUpkeyUp 事件具有广泛的元数据,包括但不限于用户在特定时刻按下的特定键的数字代码。随着用户继续按键,将触发更多的 keyUp 事件,并通过一个想象的时间线传递。时间线是一个连续的数据流,其中 keyUp 事件可以在任何时间发生;毕竟,用户决定何时按下这些键。

回想一下上一节中关于可观察对象的例子。那段代码可以通知观察者每两秒发出另一个值。我们知道定时器间隔触发的频率。在 keyUp 事件的情况下,我们不知道,因为它们不受我们的控制。让我们通过在我们的应用程序中实现一个键记录器来进一步解释:

  1. 创建一个名为 key-logger 的新 Angular 组件:

    ng generate component key-logger 
    
  2. 打开 key-logger.component.html 文件,并用以下 HTML 模板替换其内容:

    <input type="text" #keyContainer />
    You pressed: {{keys}} 
    

在前面的模板中,我们添加了一个 <input> HTML 元素,并附加了 keyContainer 模板引用变量。

模板引用变量可以添加到任何 HTML 元素中,而不仅仅是组件。

我们还显示了一个 keys 属性,表示用户按下的所有键盘键。

  1. 打开 key-logger.component.ts 文件,并从 @angular/core npm 包导入 OnInitviewChildElementRef 工具:

    import { Component, **ElementRef, OnInit, viewChild** } from '@angular/core'; 
    
  2. KeyLoggerComponent 类中创建以下属性:

    input = viewChild<ElementRef>('keyContainer');
    keys = ''; 
    

input 属性用于使用 keyContainer 模板引用变量查询 <input> HTML 元素。

  1. 添加以下 import 语句以从 rxjs npm 包导入 fromEvent 工具:

    import { fromEvent } from 'rxjs'; 
    

RxJS 库拥有各种有用的工具,称为 操作符,我们可以与可观察对象一起使用。fromEvent 操作符从一个原生 HTML 元素的 DOM 事件创建一个可观察对象。

  1. 实现 ngOnInit 方法,从 OnInit 接口监听 <input> 元素中的 keyup 事件,并将按下的键保存在 keys 属性中:

    export class KeyLoggerComponent **implements OnInit** {
      input = viewChild<ElementRef>('keyContainer');
      keys = '';
      **ngOnInit(): void {**
        **const logger$ = fromEvent<KeyboardEvent>(this.input()!.nativeElement, 'keyup');**
        **logger$.subscribe(evt => this.keys += evt.key);**
      **}**
    **}** 
    

注意,我们通过模板引用变量的 nativeElement 属性访问原生 HTML 输入元素。使用 viewChild 函数查询的结果是一个 ElementRef 对象,它是实际 HTML 元素的包装器。

  1. 打开 app.component.ts 文件,并从 @angular/core npm 包导入 KeyLoggerComponent 类:

    import { Component, inject } from '@angular/core';
    import { RouterOutlet } from '@angular/router';
    import { ProductListComponent } from './product-list/product-list.component';
    import { CopyrightDirective } from './copyright.directive';
    import { APP_SETTINGS, appSettings } from './app.settings';
    import { Observable } from 'rxjs';
    **import { KeyLoggerComponent } from './key-logger/key-logger.component';**
    @Component({
      selector: 'app-root',
      imports: [
        RouterOutlet,
        ProductListComponent,
        CopyrightDirective,
        **KeyLoggerComponent**
      ],
      templateUrl: './app.component.html',
      styleUrl: './app.component.css',
      providers: [
        { provide: APP_SETTINGS, useValue: appSettings }
      ]
    }) 
    
  2. 打开 app.component.html 文件,并在模板中添加 <app-key-logger> 选择器:

    <header>{{ title }}</header>
    <main class="main">
      <div class="content">
        <app-product-list></app-product-list>
      </div>
    </main>
    <footer appCopyright> - v{{ settings.version }}</footer>
    <router-outlet />
    **<app-key-logger></app-key-logger>** 
    

使用 ng serve 命令运行应用程序,并开始按键以验证我们刚刚创建的键记录器的使用情况:

img

图 6.1:键记录器输出

可观察对象的一个基本方面是使用操作符并将可观察对象链接在一起,从而实现 丰富的组合。当我们想要使用它们时,可观察对象操作符看起来像数组方法。例如,用于可观察对象的 map 操作符与数组的 map 方法用法相似。在下一节中,我们将学习 RxJS 库,它提供了这些操作符,并通过示例了解其中的一些。

RxJS 库

如前所述,Angular 依赖于 RxJS,这是 ReactiveX 库的 JavaScript 版本,它允许我们从各种场景中创建可观察对象,包括以下内容:

  • 交互事件

  • Promises

  • 回调函数

  • 事件

响应式编程并不旨在取代异步模式,如承诺或回调。相反,它也可以利用它们来创建可观察序列。

RxJS 内置了对各种可组合操作符的支持,用于转换、过滤和组合结果事件流。它的 API 为观察者提供了方便的方法来订阅这些流,以便我们的组件能够相应地响应状态变化或输入交互。以下小节中,我们将看到一些这些操作符的实际应用。

创建可观察对象

我们已经学习了如何使用fromEvent操作符从 DOM 事件创建可观察对象。与可观察对象创建相关的另外两个流行的操作符是offrom操作符。

of操作符用于从数字等值创建可观察对象:

const values = of(1, 2, 3);
values.subscribe(value => console.log(value)); 

之前的代码片段按顺序在浏览器控制台窗口中打印了数字123

from操作符用于将数组转换为可观察对象:

const values = from([1, 2, 3]);
values.subscribe(value => console.log(value)); 

from操作符在将承诺或回调转换为可观察对象时也非常有用。我们可以在AppComponent类的constructor中将onComplete方法包装如下:

constructor() {
  const complete$ = from(this.onComplete());
  complete$.subscribe(this.setTitle);
} 

如果你在一个现有的应用程序中使用承诺,from操作符是迁移到可观察对象的一个极好方式!

除了创建可观察对象外,RxJS 库还包含了一些实用的操作符,用于操作和转换从可观察对象发出的数据。

转换可观察对象

我们已经在第四章使用管道和指令丰富应用中学习了如何创建只包含数字的指令。现在,我们将使用 RxJS 操作符在我们的键记录器组件中完成相同的事情:

  1. 打开key-logger.component.ts文件,并从rxjs npm 包中导入tap操作符:

    import { fromEvent, **tap** } from 'rxjs'; 
    
  2. 按如下方式重构ngOnInit方法:

    ngOnInit(): void {
      const logger$ = fromEvent<KeyboardEvent>(this.input()!.nativeElement, 'keyup');
      logger$**.pipe(**
        **tap(evt => this.keys += evt.key)**
      **).subscribe();**
    } 
    

pipe操作符将用逗号分隔的多个操作符链接和组合起来。我们可以将其视为一个配方,定义了应用于可观察对象的操作符。其中之一是tap操作符,当我们想要对数据执行某些操作而不修改它时使用。

  1. 我们想要排除logger$可观察对象发出的非数字值。我们已经从evt属性中获取了实际按下的键,但它返回的是字母数字值。列出所有非数字值并手动排除它们将不会很高效。相反,我们将使用map操作符来获取键的实际 Unicode 值。它的工作方式与数组的map方法类似,因为它返回一个包含修改后初始数据的可观察对象。从rxjs npm 包中导入map操作符:

    import { fromEvent, tap, **map** } from 'rxjs'; 
    
  2. ngOnInit方法中tap操作符上方添加以下代码片段:

    map(evt => evt.key.charCodeAt(0)) 
    
  3. 我们现在可以添加filter操作符,它的工作方式与数组的filter方法类似,用于排除非数字值。从rxjs npm 包中导入filter操作符:

    import { fromEvent, tap, map, **filter** } from 'rxjs'; 
    
  4. ngOnInit方法中map运算符之后添加以下代码片段:

    filter(code => (code > 31 && (code < 48 || code > 57)) === false) 
    
  5. 当前可观察对象发出的是 Unicode 字符码。我们必须将它们转换回键盘字符以便在 HTML 模板中显示。重构tap运算符以适应这一变化:

    tap(**digit** => this.keys += **String.fromCharCode(digit)**) 
    

作为最后的润色,我们将在组件中添加一个输入绑定,以有条件地切换仅数字功能:

  1. @angular/core npm 包的import语句中添加input函数:

    import { Component, ElementRef, OnInit, viewChild, **input** } from '@angular/core'; 
    
  2. KeyLoggerComponent类中添加一个numeric输入属性:

    numeric = input(false); 
    
  3. 重构ngOnInit方法中的filter运算符,使其考虑numeric属性:

    filter(code => **{**
      **if (this.numeric()) {**
        **return (code > 31 && (code < 48 || code > 57)) === false;**
      **}**
      **return true;**
    **}**) 
    

只有当numeric输入属性为true时,logger$可观察对象才会过滤非数字值。

  1. ngOnInit方法最终应如下所示:

    ngOnInit(): void {
      const logger$ = fromEvent<KeyboardEvent>(this.input()!.nativeElement, 'keyup');
      logger$.pipe(
        map(evt => evt.key.charCodeAt(0)),
        filter(code => {
          if (this.numeric()) {
            return (code > 31 && (code < 48 || code > 57)) === false;
          }
          return true;
        }),
        tap(digit => this.keys += String.fromCharCode(digit))
      ).subscribe();
    } 
    
  2. 打开app.component.html文件,并在<app-key-logger>选择器中添加对numeric属性的绑定:

    <app-key-logger **[numeric]="true"**></app-key-logger> 
    
  3. 使用ng serve命令运行应用程序,并在输入框中输入Angular 19

包含文本、字体、屏幕截图、描述的图像

图 6.2:数字键盘记录器

我们已经看到了 RxJS 运算符如何操作返回原始数据类型(如数字、字符串和数组)的可观察对象。在下一节中,我们将学习如何在我们的电子商务应用程序中使用可观察对象。

订阅可观察对象

我们已经了解到观察者需要订阅一个可观察对象以获取发出的数据。在我们的例子中,观察者将是产品列表组件,而可观察对象将位于products.service.ts文件中。因此,我们首先需要将ProductsService类转换为使用可观察对象而不是普通数组,以便组件可以订阅以获取数据:

  1. 打开products.service.ts文件,并添加以下import语句:

    import { Observable, of } from 'rxjs'; 
    
  2. getProducts方法中使用的商品数据提取到单独的服务属性中,以增强代码可读性:

    private products: Product[] = [
      { 
        id: 1,
        title: 'Keyboard',
        price: 100,
        categories: {
          1: 'Computing',
          2: 'Peripherals'
        }
      },
      {
        id: 2,
        title: 'Microphone',
        price: 35,
        categories: { 3: 'Multimedia' }
      },
      {
        id: 3,
        title: 'Web camera',
        price: 79,
        categories: {
          1: 'Computing',
          3: 'Multimedia'
        }
      },
      {
        id: 4,
        title: 'Tablet',
        price: 500,
        categories: { 4: 'Entertainment' }
      }
    ]; 
    
  3. 修改getProducts方法,使其返回products属性作为可观察对象:

    getProducts(): **Observable<Product[]**> {
      return **of(this.products);**
    } 
    

在前面的代码片段中,我们使用of运算符从products数组创建一个新的可观察对象。

ProductsService类现在使用可观察对象发出产品数据。我们必须修改组件以订阅并获取这些数据:

  1. 打开product-list.component.ts文件,并在ProductListComponent类中创建一个getProducts方法:

    private getProducts() {
      this.productService.getProducts().subscribe(products => {
        this.products = products;
      });
    } 
    

在前面的方法中,我们订阅了ProductsService类的getProducts方法,因为它返回一个可观察对象而不是一个普通数组。products数组在subscribe方法中返回,在那里我们将products组件属性设置为从可观察对象发出的数组。

  1. 修改ngOnInit方法,使其调用新创建的getProducts方法:

    ngOnInit(): void {
      **this.getProducts();**
    } 
    

我们本可以将getProducts方法的主体直接放在ngOnInit方法中。我们没有这样做,因为组件生命周期事件方法应该尽可能清晰简洁。始终尝试将它们的逻辑提取到单独的方法中以提高清晰度。

使用ng serve命令运行应用程序,你应该能够成功地在页面上显示产品列表:

包含文本、屏幕截图、字体、标志的图像,自动生成的描述图 6.3:产品列表

如前一张图所示,我们已经达到了与第五章使用服务管理复杂任务相同的结果,即显示产品列表,但使用了可观察对象。这可能一开始并不明显,但我们已经为使用基于可观察对象的 Angular HTTP 客户端奠定了基础。在第八章通过 HTTP 与数据服务通信中,我们将更详细地探讨 HTTP 客户端。

当我们订阅可观察对象时,如果我们没有及时清理它们,我们容易受到潜在内存泄漏的影响。在下一节中,我们将了解不同的方法来完成这项任务。

从可观察对象中取消订阅

当我们订阅一个可观察对象时,我们创建一个观察者来监听数据流中的变化。在订阅保持活跃的同时,观察者会持续监视该流。当订阅活跃时,它会在浏览器中保留内存并消耗一定资源。如果我们没有告诉观察者在某个时刻取消订阅并清理任何资源,对可观察对象的订阅可能会可能地导致内存泄漏。

观察者通常需要在创建订阅的 Angular 组件必须被销毁时取消订阅。

用于从可观察对象中取消订阅的最知名的技术如下:

  • 手动取消可观察对象的订阅

  • 在组件模板中使用async管道

让我们在以下小节中看到这两种技术的实际应用。

销毁组件

组件有我们可以用来挂钩并执行自定义逻辑的生命周期事件,正如我们在第三章使用组件构建用户界面中学到的。其中之一是ngOnDestroy事件,它在组件被销毁且不再存在时被调用。

回想一下我们之前在示例中使用的ProductListComponentProductViewComponent,它们在组件初始化时订阅了ProductsServiceProductViewService的相应方法。当组件被销毁时,订阅的引用仍然保持活跃,这可能会导致不可预测的行为。我们需要在组件销毁时手动取消订阅以正确清理任何资源:

  1. 打开product-list.component.ts文件并添加以下import语句:

    import { Subscription } from 'rxjs'; 
    
  2. ProductListComponent类中创建以下属性:

    private productsSub: Subscription | undefined; 
    
  3. getProducts方法中将productsSub属性分配给订阅结果:

    private getProducts() {
      **this.productsSub** = this.productService.getProducts().subscribe(products => {
        this.products = products;
      });
    } 
    
  4. @angular/core npm 包中导入OnDestroy生命周期钩子:

    import { Component, OnInit, **OnDestroy** } from '@angular/core'; 
    
  5. OnDestroy添加到ProductListComponent类的实现接口列表中:

    export class ProductListComponent implements OnInit, **OnDestroy** 
    
  6. 按照以下方式实现ngOnDestroy方法:

    ngOnDestroy(): void {
      this.productsSub?.unsubscribe();
    } 
    

unsubscribe方法从订阅的活跃监听器中移除观察者,并清理任何预留的资源。

只为了取消一个订阅就需要这么多的样板代码,如果我们有很多订阅,这可能会很快变得难以阅读和维护。

或者,我们可以使用一种特定类型的操作符,称为takeUntilDestroyed , 它在@angular/core/rxjs-interop包中可用。我们将在产品列表组件中探索使用此操作符取消订阅观察者的方式:

  1. 打开product-list.component.ts文件,并按照以下方式导入injectDestroyReftakeUntilDestroyed实体:

    import { Component, **DestroyRef, inject**, OnInit } from '@angular/core';
    **import { takeUntilDestroyed } from '@angular/core/rxjs-interop';** 
    

takeUntilDestroyed实体是一个操作符,当组件被销毁时,它会取消订阅观察者。

  1. 声明以下属性以注入DestroyRef服务:

    private destroyRef = inject(DestroyRef); 
    
  2. 按照以下方式修改getProducts方法:

    private getProducts() {
      this.productService.getProducts()**.pipe(**
        **takeUntilDestroyed(this.destroyRef)**
      **)**.subscribe(products => {
        this.products = products;
      });
    } 
    

在前面的方法中,我们使用pipe操作符将takeUntilDestroyed操作符与ProductsService类的getProducts方法的订阅链接起来。takeUntilDestroyed操作符接受DestroyRef服务的参数。

  1. 删除与ngOnDestroy方法相关的任何代码。

就这样!我们已经将我们的订阅转换为更声明性和易读的形式。然而,可维护性的问题仍然存在。我们的组件现在正在手动取消它们的观察者订阅。我们可以使用一个专门的 Angular 管道,即async管道,来解决这个问题,它允许我们用更少的代码自动取消订阅。

使用异步管道

async管道是 Angular 内置的管道,与观察者一起使用,其作用有两方面。它帮助我们编写更少的代码,并使我们免于设置和取消订阅。它自动订阅观察者,并在组件销毁时取消订阅。我们将使用它来简化产品列表组件的代码:

  1. 打开product-list.component.ts文件,并添加以下import语句:

    import { AsyncPipe } from '@angular/common';
    import { Observable } from 'rxjs'; 
    
  2. AsyncPipe类添加到@Component装饰器的imports数组中:

    @Component({
      selector: 'app-product-list',
      imports: [ProductDetailComponent, SortPipe, AsyncPipe],
      templateUrl: './product-list.component.html',
      styleUrl: './product-list.component.css'
    }) 
    
  3. products组件属性转换为观察者:

    products$: Observable<Product[]> | undefined; 
    
  4. ProductsService类的getProducts方法分配给products$组件属性:

    private getProducts() {
      **this.products$ = this.productService.getProducts();**
    } 
    

getProducts方法的主体现在已经被缩减到一行,并且变得更加易读。

  1. 打开product-list.component.html文件,并在文件开头添加以下片段:

    @let products = (products$ | async)!; 
    

在前面的代码片段中,我们使用 async 管道订阅了 products$ 观察者,并使用 @let 关键字创建了一个模板变量。这个模板变量的名称与之前相应的组件属性相同,因此我们不需要进一步更改组件模板。

就这样!我们不再需要手动订阅或取消订阅观察者了!async 管道会为我们处理一切。

我们了解到观察者会对应用程序事件做出反应,并异步地向注册的观察者发出值。我们可以将观察者想象为围绕发出值的包装对象。Angular 通过提供类似的包装器,该包装器同步工作并响应应用程序状态变化,从而丰富了 Web 应用的响应式领域。

摘要

要详细涵盖 Angular 中所有我们可以用响应式编程做到的伟大事情,需要不止一个章节。好消息是,我们已经涵盖了进行基本 Angular 开发所需的所有工具和类。

我们学习了什么是响应式编程以及如何在 Angular 中使用它。我们看到了如何应用响应式技术,如观察者,来与数据流交互。我们探讨了 RxJS 库以及如何使用一些操作符来操作观察者。我们学习了在 Angular 组件中订阅和取消订阅观察者的不同方法。

剩下的就留给你的想象力了,所以请尽情发挥,将所有这些知识应用到你的 Angular 应用程序中。可能性是无限的,你有从承诺和观察者到响应式模式的各种策略。你可以利用响应式模式的惊人功能,为你的 Angular 应用程序构建令人惊叹的响应式体验。

正如我们已经强调的,天空才是极限。然而,我们仍然有一条漫长而激动人心的道路要走。在下一章中,我们将探索信号,这是 Angular 框架中内置的另一种响应式模式。我们将学习如何使用 Angular 信号来处理 Angular 应用程序的状态。

第七章:使用信号跟踪应用程序状态

Angular 通过信号赋予开发者使用其应用程序内内置反应性的能力。Angular 信号是反应式编程的同步方法,它有效地提高了应用程序性能并管理应用程序状态。

我们在之前的章节中遇到了信号,当时我们使用input方法在组件之间交换数据,使用viewChild方法查询子组件。信号 API 可以在 Angular 应用程序的不同部分使用,因此,其使用分散在这本书的各个章节中。

在本章中,我们将涵盖以下主题:

  • 理解信号

  • 读取和写入信号

  • 计算信号

  • 与 RxJS 协作

技术要求

本章包含各种代码示例,以向您介绍 Angular 信号的概念。您可以在以下 GitHub 仓库的ch07文件夹中找到相关源代码:

www.github.com/PacktPublishing/Learning-Angular-Fifth-Edition

理解信号

如我们在第三章使用组件构建用户界面中学习的那样,Zone.js在 Angular 应用程序的性能中发挥着重要作用。当应用程序内部发生特定事件时,它会触发 Angular 的变更检测机制。框架在每次检测周期中检查每个应用程序组件,并评估其绑定,从而降低应用程序的性能。

基于 Zone.js 的变更检测的原理是基于 Angular 无法知道应用程序内部何时或何地发生了变化。不可避免地,Angular 开发者试图通过以下技术来限制变更检测周期:

  • 使用OnPush变更检测策略配置组件

  • 使用ChangeDetectorRef服务手动与变更检测机制交互

信号通过根据应用程序需求简化并增强先前的技术来改善开发者与 Angular 变更检测机制的交互。

Angular 信号提供了基于反应性的更稳健和更人性化的变更检测周期管理。它们监视应用程序状态的变化,并允许框架通过仅在受变化影响的区域触发变更检测来做出反应。

信号是 Angular 框架的一个创新特性,它将通过引入未来的无 Zone 应用程序基于信号的组件来进一步提高应用程序的性能。

信号还充当值的容器,变更检测机制必须检查这些值。当值发生变化时,信号会通知框架该变化。框架负责触发变更检测并更新任何信号消费者。信号值可以通过可写信号直接更改,或者通过只读或计算信号间接更改。

在下一节中,我们将学习可写信号的工作方式。

读取和写入信号

可写信号由@angular/core npm 包中的signal类型指示。

您需要我们在第六章Angular 中的响应式模式中创建的 Angular 应用程序的源代码,以便跟随本章的其余部分。在您获取代码后,我们建议您为了简化,删除key-logger文件夹。

让我们开始学习如何在信号中写入一个值:

  1. 打开app.component.ts文件,并从@angular/core npm 包中导入signal实体:

    import { Component, inject, **signal** } from '@angular/core'; 
    
  2. AppComponent类中声明以下属性为signal并初始化它:

    currentDate = signal(new Date()); 
    
  3. setTitle属性中的timestamp变量替换为以下代码片段:

    this.currentDate.set(new Date()); 
    

在上述代码片段中,我们使用set方法在信号中写入新值。该方法通知 Angular 框架值已更改,并且它必须运行变更检测机制。

  1. title属性修改为使用currentDate信号的值:

    this.title = `${this.settings.title} (${**this.currentDate()**})`; 
    

在上述代码片段中,我们调用currentDate获取器方法来读取信号的值。

当应用程序的速度和性能很重要时,信号是一个很好的选择,例如:

  • 一个包含小部件和实时数据的仪表板页面,必须定期更新,例如股票交易应用程序。

  • 需要显示来自大型或复杂对象的属性组件,例如以下内容:

    const order = {
      no: '1',
      date: new Date(),
      products: [
        { 
          id: 1,
          title: 'Keyboard',
          price: 100
        },
        {
          id: 2,
          title: 'Microphone',
          price: 35
        }
      ],
      customerCode: '0002',
      isCompleted: false
    }; 
    

在这种情况下,我们可以在不涉及整个对象进入变更检测周期的情况下,从信号中提取我们想要的属性,例如:

const orderDetails = signal({
  no: '1',
  customerCode: '0002',
  isCompleted: false
}); 

与信号类似的方法,也可以触发变更检测的是update方法。当我们要根据信号当前值设置新的信号值时使用它:

this.currentDate.update(d => {
  return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0);
}); 

上述代码片段将获取currentDate信号在d变量中的值,并使用它来返回一个新的Date对象。

在下一节中,我们将探讨计算信号在 Angular 应用程序中的行为。

计算信号

计算信号或只读信号依赖于其他信号,无论是可写还是计算。计算信号的值不能直接使用setupdate方法更改,它只能在其他信号的值更改时间接更改。

让我们看看它是如何工作的:

  1. 打开app.component.ts文件,并从@angular/core npm 包中导入computedSignal实体:

    import {
      Component,
      inject,
      **Signal**,
      **computed**,
      signal 
    } from '@angular/core'; 
    
  2. title组件属性的类型更改为Signal

    title: **Signal<string> = signal('');** 
    

Signal类型表示该信号是一个计算信号。

  1. setTitle方法中删除title赋值,并将其添加到constructor中,如下所示:

    constructor() {
      **this****.title$.****subscribe****(****this****.setTitle);**
      **this****.****title =** **computed****(() => {**
        **return****`****${****this.settings.title}****(****${****this****.currentDate()}****)`****;**
      **});**
    } 
    

在上述代码片段中,我们使用computed函数设置title信号的值。title信号的值取决于currentDate信号。当currentDate信号的值更改时,它每 2 秒更新一次。

  1. 打开 app.component.html 文件并按以下方式修改 <header> HTML 元素:

    <header>{{ **title()** }}</header> 
    
  2. 使用 ng serve 运行应用程序并验证标题是否正确更新。

相比之前的计算,计算信号在更复杂的计算方面具有优异的性能,原因如下:

  • 计算函数在模板中首次读取信号值时执行

  • 只有当派生信号发生变化时,才会计算新的信号值

  • 计算信号使用缓存机制来记忆值并在不重新计算的情况下返回它们

尽管信号是 Angular 的现代反应式方法,但与 RxJS 相比,它们在 Angular 生态系统中相对较新。在下一节中,我们将学习它们如何在 Angular 应用程序中与 RxJS 协作。

与 RxJS 协作

信号和 RxJS 为 Angular 应用程序赋予了反应式能力。这些库可以相互补充,在利用 Angular 框架优势的同时提供反应性。信号并非旨在取代 RxJS,而是为开发者提供了一种具有以下额外特性的替代反应式方法:

  • 精细粒度反应性

  • 强制性编程

  • 改进了变更检测机制的用法

然而,Angular 框架的核心部分仍然使用 RxJS 和可观察者,例如 HTTP 客户端和路由器。此外,许多开发者更喜欢 RxJS 库提供的开箱即用的声明式方法。

在撰写本文时,Angular 团队目前正在调查和实验,以便在可预见的未来将 RxJS 作为 Angular 应用程序的可选组件。他们还在努力将内置 API,如 HTTP 客户端和路由器,转换为信号。

Angular 信号提供了与 RxJS 和可观察者协作的内置 API。信号 API 提供了一个可以将可观察者转换为信号的功能:

  1. 打开 product-list.component.ts 文件并导入 injecttoSignal 依赖项:

    import { Component, **inject** } from '@angular/core';
    **import** **{ toSignal }** **from****'@angular/core/rxjs-interop'****;** 
    

@angular/core/rxjs-interop npm 包包括处理信号和可观察者协作的所有实用方法。toSignal 函数可以将可观察者转换为信号。

rxjs-interop 包还包含将信号转换为可观察者的实用方法。您可以在 Lamis Chebbi(Packt Publishing)的《使用 RxJS 和 Angular 信号的反应式模式》中了解更多信息。

  1. ProductListComponent 类中创建以下信号:

    products = toSignal(inject(ProductsService).getProducts(), {
      initialValue: []
    }); 
    

toSignal 函数中,我们传递两个参数:我们想要转换的可观察者和可选的初始值。在这种情况下,我们传递 ProductService 类的 getProducts 方法,该方法返回一个可观察者,我们还设置了信号的初始值为空数组。

  1. 打开 product-list.component.html 文件并按以下方式修改其内容:

    @if (products().length > 0) {
      <h1>Products ({{products().length}})</h1>
    }
    <ul class="pill-group">
      @for (product of products() | sort; track product.id) {
        <li class="pill" (click)="selectedProduct = product">
          @switch (product.title) {
            @case ('Keyboard') { ![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-041.png) }
            @case ('Microphone') { ![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-05.png) }
            @default { ![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-09.png) }
          }
          {{product.title}}
        </li>
      } @empty {
        <p>No products found!</p>
      }
    </ul>
    <app-product-detail
      [product]="selectedProduct"
      (added)="onAdded()"
    ></app-product-detail> 
    

在前面的模板中,我们移除了顶部的@if块,并将products属性转换成了一个信号。我们不需要async管道,因为信号会自动订阅到一个可观察对象。

  1. 为了进一步清理我们的组件,我们可以移除任何与async管道和可观察对象相关的代码,因为它们不再需要。结果product-list.component.ts文件应该是以下内容:

    import { Component, inject } from '@angular/core';
    import { toSignal } from '@angular/core/rxjs-interop';
    import { Product } from '../product';
    import { ProductDetailComponent } from '../product-detail/product-detail.component';
    import { SortPipe } from '../sort.pipe';
    import { ProductsService } from '../products.service';
    @Component({
      selector: 'app-product-list',
      imports: [ProductDetailComponent, SortPipe],
      templateUrl: './product-list.component.html',
      styleUrl: './product-list.component.css'
    })
    export class ProductListComponent {
      selectedProduct: Product | undefined;
      products = toSignal(inject(ProductsService).getProducts(), {
        initialValue: []
      });  
    
      onAdded() {
        alert(`${this.selectedProduct?.title} added to the cart!`);
      }
    } 
    
  2. 使用ng serve运行应用程序,并观察应用程序输出显示产品列表。

前面的代码片段看起来简单多了。Angular 信号不仅提高了我们应用程序的性能,还改善了开发者的体验和人体工程学。

摘要

在本章中,我们探讨了信号,这是 Angular 中一个新的响应式模式,用于管理应用程序状态。我们学习了其原理以及它们与 Zone.js 的比较。我们还探讨了如何将值读入和写入信号。我们还学习了如何创建依赖于其他信号值的计算信号。

在下一章中,我们将学习如何使用 Angular HTTP 客户端并从远程端点获取数据。

第八章:通过 HTTP 与数据服务通信

对于企业级 Angular 应用程序的一个真实场景是连接到远程服务和 API 以交换数据。Angular HTTP 客户端提供了与 HTTP 服务通信的现成支持。Angular 应用程序与 HTTP 客户端的交互基于 RxJS 可观察流,为开发者提供了一套丰富的数据访问能力。

通过 HTTP 连接到 API 有许多方法。在这本书中,我们只会触及表面。然而,本章涵盖的见解将让你能够迅速将 Angular 应用程序连接到 HTTP 服务,而如何使用它们的创意则完全取决于你。

在本章中,我们将探讨以下概念:

  • 通过 HTTP 进行数据通信

  • 介绍 Angular HTTP 客户端

  • 设置后端 API

  • 在 Angular 中处理 CRUD 数据

  • 使用 HTTP 进行身份验证和授权

技术要求

本章包含各种代码示例,以向您介绍 Angular HTTP 客户端的概念。您可以在以下 GitHub 仓库的ch08文件夹中找到相关源代码:

www.github.com/PacktPublishing/Learning-Angular-Fifth-Edition

通过 HTTP 进行数据通信

在我们深入描述 Angular HTTP 客户端及其如何与服务器通信之前,让我们首先谈谈原生的 HTTP 实现。目前,如果我们想使用 JavaScript 通过 HTTP 与服务器通信,我们可以使用 JavaScript 原生的fetch API。它包含所有连接到服务器和交换数据所需的方法。

你可以在以下代码中看到一个如何获取数据的示例:

fetch(url)
  .then(response => {
    return response.ok ? response.text() : '';
  })
  .then(result => {
    if (result) {
      console.log(result);
    } else {
      console.error('An error has occurred');
    }
  }); 

尽管fetch API 基于 promise,但如果发生错误,它返回的 promise 不会被拒绝。相反,当response对象中没有ok属性时,请求将不成功。

如果对远程 URL 的请求完成,我们可以使用response对象的text()方法在一个新的 promise 中返回响应文本。最后,在第二个then回调中,我们将响应文本或特定的错误消息显示到浏览器控制台。

要了解更多关于fetch API 的信息,请查看官方文档developer.mozilla.org/docs/Web/API/fetch

我们已经了解到,可观察者是管理异步操作时的灵活工具。你可能想知道我们如何将此模式应用于从 HTTP 服务获取信息时。到目前为止,你将习惯于向 AJAX 服务提交异步请求,然后将响应传递给回调或 promise。现在,我们将通过返回一个可观察者来处理调用。该可观察者将在流的环境中作为事件发出服务器响应,这可以通过 RxJS 运算符进行过滤,以更好地处理响应。

让我们将之前的 fetch API 示例转换为可观察对象。我们使用 Observable 类将 fetch 调用包装在一个可观察的流中,并用适当的 observer 对象方法替换 console 方法:

**const request$ = new Observable(observer => {**
  fetch(url)
    .then(response => {
      return response.ok ? response.text() : '';
    })
    .then(result => {
      if (result) {
        **observer.next(result);**
        **observer.complete();**
      } else {
        **observer.error('An error has occurred');**
      }
    });
**});** 

在前面的代码片段中,我们使用了以下 observer 方法:

  • next:当数据到达时,这个方法将响应数据返回给订阅者

  • complete:这个方法通知订阅者流中不会有其他数据可用

  • error:这个方法会通知订阅者发生了错误

就这样!我们现在已经构建了一个自定义的 HTTP 客户端。当然,这并不算什么。我们的自定义 HTTP 客户端只处理一个 GET 操作来从远程端点获取数据。我们并没有处理 HTTP 协议的许多其他操作,如 POSTPUTDELETE。然而,意识到 Angular 中 HTTP 客户端为我们所做的大量工作是非常重要的。另一个重要的教训是如何轻松地将异步 API 转换为与我们的其他异步概念很好地配合的可观察 API。因此,让我们继续探讨 Angular 中 HTTP 服务的实现。

介绍 Angular HTTP 客户端

Angular 框架的 HTTP 客户端是一个独立的 Angular 库,位于 @angular/common npm 包下的 http 命名空间中。Angular CLI 在创建新的 Angular 项目时默认安装此包。

您需要我们在 第六章Angular 中的响应式模式 中创建的 Angular 应用程序的源代码,以便跟随本章的其余部分。在您获取代码后,我们建议您为了简化,删除 key-logger 文件夹。

要开始使用 Angular HTTP 客户端,我们需要在 app.config.ts 文件中导入 provideHttpClient 方法:

**import { provideHttpClient } from '@angular/common/http';**
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideRouter(routes),
    **provideHttpClient()**
  ]
}; 

假设我们想在用旧版本的 Angular 构建的应用程序中使用 HTTP 客户端。在这种情况下,我们需要从 @angular/common/http 命名空间中导入一个名为 HttpClientModule 的 Angular 模块到我们应用程序的某个模块中。

provideHttpClient 方法暴露了我们可以用来处理异步 HTTP 通信的各种 Angular 服务。最基本的是 HttpClient 服务,它提供了一个强大的 API,并抽象了通过以下 HTTP 方法处理异步连接所需的所有操作:

  • get:这个方法执行一个 GET 操作来获取数据

  • post:这个方法执行一个 POST 操作来添加新数据

  • put / patch:这个方法执行一个 PUT / PATCH 操作来更新现有数据

  • delete:这个方法执行一个 DELETE 操作来删除现有数据

之前的 HTTP 方法构成了 创建、读取、更新、删除CRUD)应用程序的主要操作。Angular HTTP 客户端的早期方法都返回一个可观察的数据流。Angular 组件可以使用 RxJS 库订阅这些方法并与远程 API 交互。

Angular 团队目前正在调查和实验,看看他们是否可以使 RxJS 的使用在框架中成为可选的。在这种情况下,我们可能会看到一个基于信号的 HTTP 实现。在本章的其余部分,我们将坚持使用观察者,因为 Angular HTTP 客户端不支持开箱即用的信号。

在下一节中,我们将探讨如何使用这些方法并与远程 API 通信。

设置后端 API

Web CRUD 应用程序通常连接到服务器,并使用 HTTP 后端 API 对数据进行操作。它获取现有数据,更新它,创建新数据或删除它。

在现实世界的场景中,你很可能会通过 HTTP 与真实的后端 API 服务交互。在本书中,我们将使用一个名为 Fake Store API 的假 API。

官方的 Fake Store API 文档可以在 fakestoreapi.com 找到。

Fake Store API 是一个在线可用的后端 REST API,当你需要为电子商务或网店 Web 应用程序生成假数据时可以使用它。它可以管理以 JSON 格式存在的产品、购物车和用户。它公开以下主要端点:

  • products:这管理一组产品项

  • cart:这管理用户的购物车

  • user:这管理应用程序用户集合

  • login:这处理用户认证

    在本章中,我们将仅与产品和登录端点一起工作。然而,我们将在本书的后面部分重新访问购物车端点。

所有修改数据的操作都不会在数据库中物理持久化。然而,它们返回操作是否成功的指示。所有获取数据的操作返回一个预定义的项目集合。

在 Angular 中处理 CRUD 数据

CRUD 应用程序在 Angular 世界中广泛使用。你几乎找不到不遵循此模式的 Web 应用程序。Angular 通过提供 HttpClient 服务来很好地支持此类应用程序。在本节中,我们将通过与 Fake Store API 的产品端点交互来探索 Angular HTTP 客户端。

通过 HTTP 获取数据

ProductListComponent 类使用 ProductsService 类来获取和显示产品数据。数据目前硬编码在 ProductsService 类的 products 属性中。在本节中,我们将修改我们的 Angular 应用程序以使用 Fake Store API 的实时数据:

  1. 打开 app.component.ts 文件,从 @Component 装饰器中移除 providers 属性。我们将直接通过应用程序配置文件提供 APP_SETTINGS

  2. 在这一点上,我们也可以移除 title 属性、title$ 可观察对象、setTitle 属性和组件类的 constructor

    export class AppComponent {
      settings = inject(APP_SETTINGS);
    } 
    
  3. 打开 app.component.html 文件,修改 <header> HTML 元素,使其直接使用 settings 对象:

    <header>{{ **settings.title** }}</header> 
    
  4. 打开 app.config.ts 文件,并按如下方式添加 APP_SETTINGS 提供者:

    import { provideHttpClient } from '@angular/common/http';
    import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
    import { provideRouter } from '@angular/router';
    import { routes } from './app.routes';
    **import { APP_SETTINGS, appSettings } from './app.settings';**
    export const appConfig: ApplicationConfig = {
      providers: [
        provideZoneChangeDetection({ eventCoalescing: true }),
        provideRouter(routes),
        provideHttpClient(),
        **{ provide: APP_SETTINGS, useValue: appSettings }**
      ]
    }; 
    

我们从应用程序配置文件中提供 APP_SETTINGS,因为我们希望它在应用程序中全局可访问。

  1. 打开 app.settings.ts 文件,并在 AppSettings 接口中添加一个新属性,代表 Fake Store API 的 URL:

    import { InjectionToken } from '@angular/core';
    export interface AppSettings {
      title: string;
      version: string;
      **apiUrl: string;**
    }
    export const appSettings: AppSettings = {
      title: 'My e-shop',
      version: '1.0',
      **apiUrl: 'https://fakestoreapi.com'**
    };
    export const APP_SETTINGS = new InjectionToken<AppSettings>('app.settings'); 
    

    后端 API 的 URL 也可以添加到环境文件中,我们将在 第十四章将应用程序部署到生产环境 中学习这一点。

  2. 打开 products.service.ts 文件,并相应地修改 import 语句:

    **import { HttpClient } from '@angular/common/http';**
    import { Injectable, **inject** } from '@angular/core';
    import { Product } from './product';
    import { Observable, of } from 'rxjs';
    **import { APP_SETTINGS } from './app.settings';** 
    
  3. ProductsService 类中创建以下属性,代表 API 产品端点:

    private productsUrl = inject(APP_SETTINGS).apiUrl + '/products'; 
    
  4. 修改 constructor 以注入 HttpClient 服务:

    constructor(**private http: HttpClient**) { } 
    
  5. 修改 getProducts 方法,使其使用 HttpClient 服务获取产品列表:

    getProducts(): Observable<Product[]> {
      return **this.http.get<Product[]>(this.productsUrl);**
    } 
    

在前面的方法中,我们使用 HttpClient 类的 get 方法,并将 API 的产品端点作为参数传递。我们还在 get 方法中定义 Product 为泛型类型,以指示 API 的响应包含 Product 对象的列表。

  1. products 属性转换为空数组:

    private products: Product[] = **[]**; 
    

我们将在 通过 HTTP 修改数据 部分稍后用于本地缓存。

  1. 打开 product-list.component.html 文件,并修改 @if 块,使其检查 products 模板变量是否存在:

    @if (**products**) {
      <h1>Products ({{products.length}})</h1>
    } 
    

我们需要检查变量是否存在,因为数据现在是从 Fake Store API 获取的,变量获得值之前会有网络延迟。

如果我们使用 ng serve 命令运行应用程序,我们应该看到来自 API 的扩展产品列表,类似于以下内容:

img

图 8.1:Fake Store API 的产品列表

产品端点支持传递一个请求参数来限制 API 返回的结果。如 fakestoreapi.com/docs#p-limit 所示,我们可以使用名为 limit 的查询参数来完成此任务。让我们看看如何在 Angular HTTP 客户端中传递查询参数:

  1. 打开 products.service.ts 文件,从 @angular/common/http 命名空间导入 HttpParams 类:

    import { HttpClient, **HttpParams** } from '@angular/common/http'; 
    

HttpParams 类用于在 HTTP 请求中传递查询参数。

  1. getProducts 方法内部创建以下变量:

    const options = new HttpParams().set('limit', 10); 
    

HttpParams 类是不可变的。以下操作不会工作,因为每个操作都会返回一个新的实例:

`const options = new HttpParams();`
`options.set('limit', 10);` 

HttpParams 类的 set 方法创建一个新的查询参数。如果我们想传递额外的参数,我们应该链式调用更多的 set 方法,例如:

const options = new HttpParams()
  .set('limit', 10)
  .set('page', 1); 
  1. 我们使用 get 方法的第二个参数通过 params 属性传递查询参数:

    return this.http.get<Product[]>(this.productsUrl, **{**
      **params: options**
    **}**); 
    
  2. 保存您的更改,等待应用程序重新加载,并观察应用程序的输出:

包含文本的图片,屏幕截图,字体,自动生成的描述

图 8.2:产品列表

在前面的列表中,所有产品都显示相同的标签图标,这是根据 product-list.component.html 文件中的 @switch 块的默认设置:

<li class="pill" (click)="selectedProduct = product">
  **@switch (product.title) {**
    **@case ('Keyboard') {** **![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-041.png)** **}**
    **@case ('Microphone') {** **![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-05.png)** **}**
    **@default {** **![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-09.png)** **}**
  **}**
  **{{product.title}}**
</li> 

@switch 块依赖于产品 title 属性。我们将将其改为基于来自 API 产品端点的 category 属性:

  1. 打开 product.ts 文件并将 categories 属性替换为以下属性:

    category: string; 
    
  2. 打开 product-list.component.html 文件并按如下方式修改 @switch 块:

    @switch (product.category) {
      @case ('electronics') { ![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-041.png) }
      @case ('jewelery') { ![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-10.png) }
      @default { ![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-09.png) }
    } 
    
  3. 我们还需要修改 product-detail.component.html 文件,因为我们已经在步骤 1 中替换了 categories 属性:

    @if (product()) {
      <p>You selected:
        <strong>{{product()!.title}}</strong>
      </p>
      <p>{{product()!.price | currency:'EUR'}}</p>
      <div class="pill-group">
        **<p class="pill">{{ product()!.category }}</p>**
      </div>  
      <button (click)="addToCart()">Add to cart</button>
    } 
    
  4. 保存你的更改,等待应用程序重新加载,并观察应用程序的输出:

img

图 8.3:带有类别的产品列表

如果你点击列表中的产品,你会注意到产品详情显示正确:

包含文本的图片,屏幕截图,字体,自动生成的描述

图 8.4:产品详情

产品详情组件继续按预期工作,因为我们从产品列表传递所选产品作为输入属性:

<app-product-detail
  **[product]="selectedProduct"**
  (added)="onAdded()"
></app-product-detail> 

我们将改变之前的行为,直接通过 HTTP GET 请求从 API 获取产品详情。Fake Store API 包含一个端点方法,我们可以使用它根据产品的 ID 获取特定产品的详情:

  1. 打开 products.service.ts 文件并创建一个新的 getProduct 方法,该方法接受产品 id 作为参数,并根据该 id 初始化对 API 的 GET 请求:

    getProduct(id: number): Observable<Product> {
      return this.http.get<Product>(`${this.productsUrl}/${id}`);
    } 
    

前面的方法使用 HttpClient 服务的 get 方法。它接受产品端点 URL 后跟产品 id 作为参数。

  1. 打开 product-detail.component.ts 文件并按如下方式修改 import 语句:

    import { CommonModule } from '@angular/common';
    import {
      Component,
      input,
      output,
      **OnChanges**
    } from '@angular/core';
    import { Product } from '../product';
    **import { Observable } from 'rxjs';**
    **import { ProductsService } from '../products.service';** 
    
  2. ProductDetailComponent 类中添加以下属性:

    id = input<number>(); 
    

id 组件属性将用于从列表传递所选产品的 ID。

  1. product 输入属性替换为以下可观察对象:

    product$: Observable<Product> | undefined; 
    

product$ 属性将用于从服务中调用 getProduct 方法。

  1. ProductDetailComponent 类中添加一个 constructor 并注入 ProductsService

    constructor(private productService: ProductsService) { } 
    
  2. 在实现接口列表中添加 OnChanges

    export class ProductDetailComponent **implements OnChanges** 
    
  3. 实现以下 ngOnChanges 方法:

    ngOnChanges(): void {
      this.product$ = this.productService.getProduct(this.id()!);
    } 
    

在前面的方法中,每次使用输入绑定传递新的 id 时,我们都将 ProductsService 中的 getProduct 方法的值分配给 product$ 组件属性。

  1. 打开 product-detail.component.html 文件并修改其内容,使其使用 product$ 可观察对象:

    **@let product = (product$ | async);**
    @if (**product**) {
      <p>You selected:
        <strong>{{**product**.title}}</strong>
      </p>
      <p>{{**product**.price | currency:'EUR'}}</p>
      <div class="pill-group">
        <p class="pill">{{ **product**.category }}</p>
      </div>  
      <button (click)="addToCart()">Add to cart</button>
    } 
    
  2. 最后,打开 product-list.component.html 文件并绑定 selectedProduct 属性的 id<app-product-detail> 组件的 id 输入绑定:

    <app-product-detail
      **[id]="selectedProduct?.id"**
      (added)="onAdded()"
    ></app-product-detail> 
    

如果我们使用ng serve命令运行应用程序并从列表中选择一个产品,我们将验证产品详情是否正确显示。

我们已经学习了如何从后端 API 获取项目列表和单个项目,并涵盖了 CRUD 操作的读取部分。在下一节中,我们将涵盖 CRUD 操作的其余部分,这些部分主要涉及修改数据。

通过 HTTP 修改数据

在 CRUD 应用程序中修改数据通常指的是添加新数据以及更新或删除现有数据。为了演示如何使用 HTTP 客户端在 Angular 应用程序中实现此类功能,我们将对我们的应用程序进行以下更改:

  • 创建一个 Angular 组件以添加新产品

  • 修改产品详情组件以更改现有产品的价格

  • 在产品详情组件中添加一个按钮以删除现有产品

我们已经提到,在 Fake Store API 中,没有 HTTP 操作会物理持久化数据,因此我们需要为我们的产品数据实现一个本地缓存机制,并在产品服务中直接与之交互:

  1. 打开products.service.ts文件并导入map RxJS 运算符:

    import { Observable, **map**, of } from 'rxjs'; 
    
  2. 如下修改getProducts方法:

    getProducts(): Observable<Product[]> {
      const options = new HttpParams().set('limit', 10);
      return this.http.get<Product[]>(this.productsUrl, {
        params: options
      })**.pipe(map(products => {**
        **this.products = products;**
        **return products;**
      **}));**
    } 
    

前面的方法使用 API 中的数据填充products数组,并将产品数据作为可观察对象返回。

  1. 修改getProduct方法,使其使用products数组返回产品对象,而不是使用 Fake Store API:

    getProduct(id: number): Observable<Product> {
      **const product = this.products.find(p => p.id === id);**
      **return of(product!);**
    } 
    

我们现在已经有了产品服务,可以开始构建添加新产品的组件。

添加新产品

要通过我们的应用程序添加新产品,我们需要将其详细信息发送到 Fake Store API:

  1. 打开products.service.ts文件并添加以下方法:

    addProduct(newProduct: Partial<Product>): Observable<Product> {
      return this.http.post<Product>(this.productsUrl, newProduct).pipe(
        map(product => {
          this.products.push(product);
          return product;
        })
      );
    } 
    

在前面的代码片段中,我们使用了HttpClient类的post方法,并传递了 API 的产品端点以及一个新产品对象作为参数。

我们将新产品定义为Partial,因为新产品没有 ID。

post方法中定义的泛型类型表示从 API 返回的产品是一个Product对象。我们还将在本地缓存中添加新产品并返回它。

  1. 运行以下 Angular CLI 命令以创建一个新的组件:

    ng generate component product-create 
    
  2. 打开product-create.component.ts文件并添加以下import语句:

    import { ProductsService } from '../products.service'; 
    
  3. 创建一个constructor并注入ProductsService类:

    constructor(private productsService: ProductsService) {} 
    
  4. 将以下方法添加到组件类中:

    createProduct(title: string, price: string, category: string) {
      this.productsService.addProduct({
        title,
        price: Number(price),
        category
      }).subscribe();
    } 
    

在与 Angular HTTP 客户端交互时,我们不需要取消订阅,因为框架会自动为我们完成。

前面的方法接受产品详情作为参数并调用ProductsService类的addProduct方法。我们使用原生的Number函数将价格值转换为数字,因为它将从模板中作为字符串传递。

  1. 打开product-create.component.html文件并将内容替换为以下 HTML 模板:

    <h1>Add new product</h1>
    <div>
      <label for="title">Title</label>
      <input id="title" #title />
    </div>
    <div>
      <label for="price">Price</label>
      <input id="price" #price type="number" />
    </div>
    <div>
      <label for="category">Category</label>
      <select id="category" #category>
        <option>Select a category</option>
        <option value="electronics">Electronics</option>
        <option value="jewelery">Jewelery</option>
        <option>Other</option>
      </select>
    </div>
    <div>
      <button (click)="createProduct(title.value, price.value, category.value)">Create</button>
    </div> 
    

在前面的模板中,我们将 createProduct 方法绑定到 Create 按钮的 click 事件,并使用相应的模板引用变量传递 <input><select> HTML 元素的值。

  1. 打开全局 styles.css 文件并添加以下 CSS 样式:

    input {
      border-radius: 4px;
      padding: 8px;
      margin-bottom: 16px;
      border: 1px solid #BDBDBD;
    } 
    

此外,将按钮相关的样式从 product-detail.component.css 文件移动到全局 CSS 样式文件中。

  1. 打开 product-create.component.css 文件并添加以下 CSS 样式以给我们的新组件一个良好的外观和感觉:

    input {
      width: 200px;
    }
    select {
      border-radius: 4px;
      padding: 8px;
      margin-bottom: 16px;
      border: 1px solid #BDBDBD;
      width: 220px;
    }
    
    label {
      margin-bottom: 4px;
      display: block;
    } 
    
  2. 打开 product-list.component.ts 文件并导入 ProductCreateComponent 类:

    import { AsyncPipe } from '@angular/common';
    import { Component, OnInit } from '@angular/core';
    import { Observable } from 'rxjs';
    import { Product } from '../product';
    import { ProductDetailComponent } from '../product-detail/product-detail.component';
    import { SortPipe } from '../sort.pipe';
    import { ProductsService } from '../products.service';
    **import { ProductCreateComponent } from '../product-create/product-create.component';**
    @Component({
      selector: 'app-product-list',
      imports: [
        ProductDetailComponent,
        SortPipe,
        AsyncPipe,
        **ProductCreateComponent**
      ],
      templateUrl: './product-list.component.html',
      styleUrl: './product-list.component.css'
    }) 
    
  3. 最后,打开 product-list.component.html 文件并在模板末尾添加以下片段:

    <app-product-create></app-product-create> 
    

如果我们现在使用 ng serve 命令运行我们的 Angular 应用程序,我们应该在页面末尾看到添加新产品的组件:

包含文本、屏幕截图、字体、编号的自动生成的描述

图 8.5:创建产品

为了实验,尝试通过填写其详细信息,点击 创建 按钮,并验证新产品是否已添加到列表中。

我们将在应用程序中添加的下一个功能是通过更改现有产品的价格来修改数据。

更新产品价格

在电子商务应用程序中,产品的价格可能在某个时候需要更改。我们需要提供一个方法,让我们的用户可以通过我们的应用程序更新该价格:

  1. 打开 products.service.ts 文件并添加一个用于更新产品的新的方法:

    updateProduct(id: number, price: number): Observable<Product> {
      return this.http.patch<Product>(`${this.productsUrl}/${id}`, {
        price
      }).pipe(
        map(product => {
          const index = this.products.findIndex(p => p.id === id);
          this.products[index].price = price;
          return product;
        })
      );
    } 
    

在前面的方法中,我们使用 HttpClient 类的 patch 方法将我们要修改的产品详情发送到 API。我们还更新了所选产品的本地产品缓存中的价格,并返回它。

或者,我们也可以使用 HTTP 客户端的 put 方法。当我们只想更新对象的一个子集时,应该使用 patch 方法,而 put 方法会与所有对象属性交互。在这种情况下,我们不想更新产品标题,因此使用 patch 方法。这两种方法都接受 API 端点和我们要更新的对象作为参数。

  1. 将以下方法添加到 ProductDetailComponent 类中:

    changePrice(product: Product, price: string) {
      this.productService.updateProduct(product.id, Number(price)).subscribe();
    } 
    

前面的方法接受一个现有的 product 和其新的 price 作为参数,并调用 ProductsService 类的 updateProduct 方法。

  1. 打开 product-detail.component.html 文件并在价格段落元素之后添加一个 <input> 和一个 <button> 元素:

    @let product = (product$ | async);
    @if (product) {
      <p>You selected:
        <strong>{{product.title}}</strong>
      </p>
      <p>{{product.price | currency:'EUR'}}</p>
      **<input placeholder="New price" #price type="number" />**
      **<button**
        **class="secondary"**
        **(click)="changePrice(product, price.value)">**
          **Change**
      **</button>**
      <div class="pill-group">
        <p class="pill">{{ product.category }}</p>
      </div>  
      <button (click)="addToCart()">Add to cart</button>
    } 
    

<input> 元素用于输入产品的新的价格并定义 price 模板引用变量。<button> 元素的 click 事件绑定到 changePrice 方法,该方法传递当前的 product 对象和 price 变量的值。

  1. 最后,打开 product-detail.component.css 文件并添加以下 CSS 样式:

    button.secondary {
      display: inline;
      margin-left: 5px;
      --button-accent: var(--vivid-pink);
    } 
    
  2. 运行 ng serve 命令以启动 Angular 应用程序,并从列表中选择一个产品。产品详情应如下所示:

包含文本的图像,屏幕截图,字体  自动生成的描述

图 8.6:产品详情

  1. 新价格 输入框中输入一个价格并点击 更改 按钮。现有价格应更新以反映更改,例如:

包含文本的图像,屏幕截图,字体  自动生成的描述

图 8.7:更改价格后的产品详情

我们现在可以通过更改价格来修改产品。

请记住,来自 Fake Store API 的产品更改不会物理持久化。如果你更改价格并刷新浏览器,它将恢复初始价格。

我们 CRUD 应用程序的下一步和最后一步将是删除一个现有的产品。

删除产品

在电子商务应用程序中删除产品并不常见。然而,我们需要为此提供功能,以防用户输入错误或不正确的数据,之后想要删除它。在我们的应用程序中,删除现有产品将通过产品详情组件来完成:

  1. 打开 products.service.ts 文件,并从 rxjs 包中导入 tap 操作符:

    import { Observable, map, of, **tap** } from 'rxjs'; 
    
  2. 将以下方法添加到 ProductsService 类中:

    deleteProduct(id: number): Observable<void> {
      return this.http.delete<void>(`${this.productsUrl}/${id}`).pipe(
        tap(() => {
          const index = this.products.findIndex(p => p.id === id);
          this.products.splice(index, 1);
        })
      );
    } 
    

在前面的方法中,我们使用 HttpClient 类的 delete 方法,将产品端点和要删除的 API 产品 id 传递给方法。我们还在使用 products 数组的 splice 方法从本地缓存中删除产品。

该方法的返回类型设置为 Observable<void>,因为我们目前对 HTTP 请求的结果不感兴趣。我们只需要知道它是否成功。我们还使用了 tap RxJS 操作符,因为我们没有改变可观察返回的值。

  1. 打开 product-detail.component.ts 文件,并在 ProductDetailComponent 类中创建一个新的输出属性:

    deleted = output(); 
    

前面的属性将通知 ProductListComponent 已删除所选产品。

  1. 创建以下方法,该方法调用 ProductsService 类的 deleteProduct 方法并触发 deleted 输出事件:

    remove(product: Product) {
      this.productService.deleteProduct(product.id).subscribe(() => {
        this.deleted.emit();
      });
    } 
    
  2. 打开 product-detail.component.html 文件,创建一个 <button> 元素,并将其 click 事件绑定到 deleted 输出事件的 emit 方法:

    @let product = (product$ | async);
    @if (product) {
      <p>You selected:
        <strong>{{product.title}}</strong>
      </p>
      <p>{{product.price | currency:'EUR'}}</p>
      <input placeholder="New price" #price type="number" />
      <button
        class="secondary"
        (click)="changePrice(product, price.value)">
          Change
      </button>
      <div class="pill-group">
        <p class="pill">{{ product.category }}</p>
      </div>  
      **<div class="button-group">** 
        **<button (click)="addToCart()">Add to cart</button>**
        **<button class="delete" (click)="remove(product)">Delete</button>**
      **</div>**
    } 
    

在前面的代码片段中,我们将两个按钮分组在一个 <div> HTML 元素中,以便它们并排显示。

  1. product-detail.component.css 文件中添加新按钮和按钮组的适当样式:

    button.delete {
      display: inline;
      margin-left: 5px;
      --button-accent: var(--hot-red);
    }
    .button-group {
      display: flex;
      flex-direction: row;
      align-items: start;
      flex-wrap: wrap;
    } 
    
  2. 打开 product-list.component.html 文件,并为 <app-product-detail> 组件的 deleted 事件添加绑定:

    <app-product-detail
      [id]="selectedProduct?.id"
      (added)="onAdded()"
      **(deleted)="selectedProduct = undefined"**
    ></app-product-detail> 
    

如果我们使用 ng serve 命令运行应用程序并从列表中选择一个产品,我们应该看到以下内容:

包含文本的图片,屏幕截图,字体,自动生成的描述

图 8.8:产品详情

产品详情组件现在有一个 删除 按钮,当点击时,它会删除产品并将其从列表中移除。

请记住,来自 Fake Store API 的产品更改不会物理持久化。如果你删除一个产品并刷新浏览器,该产品将再次出现在列表中。

我们迄今为止构建的电子商务应用有一个 添加到购物车 按钮可以用来将产品添加到购物车。按钮目前还没有做什么,但我们将将在下一章中实现完整的购物车功能。根据 Fake Store API 的文档,购物车仅对认证用户可用,因此我们必须确保在我们的应用中 添加到购物车 按钮仅对它们可用。

在 Angular 企业应用中,产品管理功能也必须保护免受未经授权的用户访问。在这种情况下,我们将实现一个更细粒度的授权方案,使用户角色仅允许管理员更改和添加产品。我们不会实现此功能,但鼓励您进行实验。

在下一节中,我们将学习 Angular 中的认证和授权。

使用 HTTP 进行认证和授权

Fake Store API 提供了一个用于用户认证的端点。它包含一个接受用户名和密码作为参数的登录方法,并返回一个认证令牌。我们将在我们的应用中使用认证令牌来区分已登录用户和访客。

来自用户端点 fakestoreapi.com/users 的预定义池提供了用户名和密码。

在本节中,我们将探讨以下认证和授权主题:

  • 使用后端 API 进行认证

  • 授权用户访问某些功能

  • 使用拦截器授权 HTTP 请求

让我们从使用 Fake Store API 进行认证的主题开始。

使用后端 API 进行认证

在 Angular 的实际应用中,我们通常创建一个 Angular 组件,允许用户登录和注销应用。Angular 服务将与 API 通信并处理所有认证任务。

让我们从创建认证服务开始:

  1. 运行以下命令以创建一个新的 Angular 服务:

    ng generate service auth 
    
  2. 打开 auth.service.ts 文件并按如下方式修改 import 语句:

    import { Injectable, **computed, inject, signal** } from '@angular/core';
    **import { HttpClient } from '@angular/common/http';**
    **import { Observable, tap } from 'rxjs';**
    **import { APP_SETTINGS } from './app.settings';** 
    
  3. AuthService 类中创建以下属性:

    private accessToken = signal('');
    private authUrl = inject(APP_SETTINGS).apiUrl + '/auth';
    isLoggedIn = computed(() => this.accessToken() !== ''); 
    

在前面的代码片段中,accessToken 信号将存储来自 API 的认证令牌,而 isLoggedIn 信号指示用户是否已登录。用户的登录状态取决于 accessToken 属性是否有值。

信号不仅可以在 Angular 组件中使用,也可以在服务内部使用。

authUrl 属性指向 Fake Store API 的认证端点 URL。

  1. constructor 中注入 HttpClient 类:

    constructor(**private http: HttpClient**) { } 
    
  2. 创建一个 login 方法以允许用户登录到 Fake Store API:

    login(username: string, password: string): Observable<string> {
      return this.http.post<string>(this.authUrl + '/login', {
        username, password
      }).pipe(tap(token => this.accessToken.set(token)));
    } 
    

前面的方法使用 API 的登录端点发起 POST 请求,并在请求体中传递 usernamepassword。从 POST 请求返回的可观察对象传递给 tap 操作符,该操作符更新 accessToken 信号。

  1. 创建一个 logout 方法来重置 accessToken 信号:

    logout() {
      this.accessToken.set('');
    } 
    

我们已经在 Angular 应用程序中设置了用户认证的业务逻辑。在下一节中,我们将学习如何使用它来控制应用程序中的授权。

授权用户访问

首先,我们将创建一个认证组件,允许我们的用户登录和登出应用程序:

  1. 运行以下命令以创建一个新的 Angular 组件:

    ng generate component auth 
    
  2. 打开 auth.component.ts 文件并添加以下 import 语句:

    import { AuthService } from '../auth.service'; 
    
  3. 在组件的 constructor 中注入 AuthService

    constructor(public authService: AuthService) {} 
    

在前面的代码片段中,我们使用 public 访问修饰符注入 AuthService,因为我们希望它可以从组件的模板中访问。

  1. AuthComponent 类中创建以下方法:

    login() {
      this.authService.login('david_r', '3478*#54').subscribe();
    }
    logout() {
      this.authService.logout();
    } 
    

在前面的代码片段中,login 方法使用用户端点的预定义凭据。

  1. 打开 auth.component.html 文件,并用以下 HTML 模板替换其内容:

    @if (!authService.isLoggedIn()) {
      <button (click)="login()">Login</button>
    } @else {
      <button (click)="logout()">Logout</button>
    } 
    

上述模板包含两个 <button> HTML 元素,用于登录/登出。每个按钮根据 AuthService 类的 isLoggedIn 信号值有条件地显示。

我们现在可以利用产品详情组件中的 isLoggedIn 信号来切换 添加到购物车 按钮的可见性:

  1. 打开 product-detail.component.ts 文件并添加以下 import 语句:

    import { AuthService } from '../auth.service'; 
    
  2. ProductDetailComponent 类的 constructor 中注入 AuthService

    constructor(private productService: ProductsService, **public authService: AuthService**) { } 
    
  3. 打开 product-detail.component.html 文件,并使用 @if 块有条件地显示 添加到购物车 按钮:

    **@if (authService.isLoggedIn()) {** 
      <button (click)="addToCart()">Add to cart</button>
    **}** 
    
  4. 打开 app.component.ts 文件并导入 AuthComponent 类:

    import { Component, inject } from '@angular/core';
    import { RouterOutlet } from '@angular/router';
    import { ProductListComponent } from './product-list/product-list.component';
    import { CopyrightDirective } from './copyright.directive';
    import { APP_SETTINGS } from './app.settings';
    **import { AuthComponent } from './auth/auth.component';**
    @Component({
      selector: 'app-root',
      imports: [
        RouterOutlet,
        ProductListComponent,
        CopyrightDirective,
        **AuthComponent**
      ],
      templateUrl: './app.component.html',
      styleUrl: './app.component.css'
    }) 
    
  5. 打开 app.component.html 文件并在 <header> HTML 元素内添加 <app-auth> 标签:

    <header>
      {{ settings.title }}
      **<app-auth></app-auth>**
    </header> 
    

要尝试应用程序中的认证功能,请按照以下步骤操作:

  1. 运行 ng serve 命令以启动应用程序并导航到 http://localhost:4200

  2. 从列表中选择一个产品并验证 添加到购物车 按钮不可见。

  3. 点击页面左上角的 登录 按钮。成功登录到 Fake Store API 后,文本应更改为 登出,并且 添加到购物车 按钮应出现。

恭喜!您已将基本认证和授权模式添加到您的 Angular 应用程序中。

在企业应用程序中,在向后端 API 通信时执行授权是常见的。后端 API 通常要求在每次请求中通过头部传递认证令牌。在下一节中,我们将学习如何使用HTTP 头部

授权 HTTP 请求

Fake Store API 在与端点通信时不需要授权。然而,假设我们正在与一个期望所有 HTTP 请求都包含使用 HTTP 头部传递的认证令牌的后端 API 一起工作。在 Web 应用程序中,一个常见的模式是在Authorization头部中包含令牌。我们可以通过从@angular/common/http命名空间导入HttpHeaders类并在相应的方法中修改来在 Angular 应用程序中使用 HTTP 头部。以下是如何看起来getProducts方法的示例:

getProducts(): Observable<Product[]> {
  const options = {
    **params: new HttpParams().set('limit', 10),**
    **headers: new HttpHeaders({ Authorization: 'myToken' })**
  **};**
  return this.http.get<Product[]>(this.productsUrl, **options**).pipe(map(products => {
    this.products = products;
    return products;
  }));
} 

为了简化,我们正在使用硬编码的值作为认证令牌。在实际场景中,我们可能从浏览器的本地存储或其他方式中获取它。

所有HttpClient方法都接受一个可选的对象作为参数,用于将额外的选项传递给 HTTP 请求,包括 HTTP 头部。要设置头部,我们使用options对象的headers属性,并创建一个HttpHeaders类的新实例作为值。HttpHeaders对象是一个键值对,它定义了自定义 HTTP 头部。

现在,假设我们需要在ProductsService类的所有方法中传递认证令牌,会发生什么情况。我们应该逐个进入它们并重复编写相同的代码。我们的代码可能会很快变得杂乱无章,难以测试。幸运的是,Angular HTTP 客户端有另一个我们可以用来帮助这种情况的功能,称为拦截器

HTTP 拦截器是一个 Angular 服务,它拦截通过 Angular HTTP 客户端传递的 HTTP 请求和响应。它可以在以下场景中使用:

  • 当我们想要在每次请求中传递自定义 HTTP 头部,例如认证令牌

  • 当我们想要在等待服务器响应时显示加载指示器

  • 当我们想要为每次 HTTP 通信提供一个日志记录机制

在我们的情况下,我们可以为每个 HTTP 请求创建一个传递认证令牌的拦截器:

  1. 运行以下命令以创建一个新的拦截器:

    ng generate interceptor auth 
    
  2. 打开app.config.ts文件并从@angular/common/http命名空间导入withInterceptors函数:

    import { provideHttpClient, **withInterceptors** } from '@angular/common/http'; 
    

withInterceptors函数用于将拦截器注册到 HTTP 客户端。

  1. 使用以下语句导入我们在上一步中创建的拦截器:

    import { authInterceptor } from './auth.interceptor'; 
    
  2. 修改provideHttpClient方法以注册authInterceptor

    export const appConfig: ApplicationConfig = {
      providers: [
        provideZoneChangeDetection({ eventCoalescing: true }),
        provideRouter(routes),
        provideHttpClient(**withInterceptors([authInterceptor])**),
        { provide: APP_SETTINGS, useValue: appSettings }
      ]
    }; 
    

withInterceptors函数接受一个已注册的拦截器列表,它们的顺序很重要。在下面的图中,你可以看到拦截器如何根据它们的顺序处理 HTTP 请求和响应:

包含文本、屏幕截图、图表、字体样式的自动生成的描述

图 8.9:Angular 拦截器的执行顺序

默认情况下,在将请求发送到服务器之前的最后一个拦截器是一个名为HttpBackend的内置 Angular 服务。

  1. 打开auth.interceptor.ts文件,并按照以下方式修改authInterceptor函数的箭头函数:

    export const authInterceptor: HttpInterceptorFn = (req, next) => {
      **const authReq = req.clone({**
        **setHeaders: { Authorization: 'myToken' }**
      **});**
      return next(**authReq**);
    }; 
    

箭头函数接受以下参数:req,表示当前请求,以及next,它是链中的下一个拦截器。在上面的代码片段中,我们使用clone方法修改现有的请求,因为默认情况下 HTTP 请求是不可变的。同样,由于 HTTP 头部的不可变性质,我们使用setHeaders方法来更新它们。最后,我们使用handle方法将请求委托给下一个拦截器。

拦截器可以使用inject方法从 Angular DI 机制获取它们可能需要的依赖项。例如,如果我们想在拦截器内部使用AuthService类,我们可以按照以下方式修改它:

**import { inject } from '@angular/core';**
import { HttpInterceptorFn } from '@angular/common/http';
**import { AuthService } from './auth.service';**
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  **const authService = inject(AuthService);**
  const authReq = req.clone({
    setHeaders: { Authorization: 'myToken' }
  });
  return next(authReq);
}; 

在使用较旧版本的 Angular 框架构建的应用程序中,您可能会注意到拦截器是 TypeScript 类而不是纯函数。为了将拦截器注册到 HTTP 客户端,我们需要在模块的providers数组中添加以下provide对象字面量,它还提供了HttpClientModule

{
  provide: HTTP_INTERCEPTORS,
  useClass: AuthInterceptor,
  multi: true
} 

在前面的代码片段中,HTTP_INTERCEPTORS是一个可以多次提供的注入令牌,如multi属性所示。

Angular 拦截器有许多用途,其中授权是最基本的之一。在 HTTP 请求期间传递认证令牌是企业级 Web 应用程序中的常见场景。

摘要

企业级 Web 应用程序几乎每天都需要与后端 API 交换信息。Angular 框架使应用程序能够通过 Angular HTTP 客户端使用 HTTP 与 API 进行通信。在本章中,我们探讨了 Angular HTTP 客户端的基本部分。

我们学习了如何摆脱传统的fetchAPI,并使用可观察对象通过 HTTP 进行通信。我们使用 Fake Store API 作为后端,探讨了 CRUD 应用程序的基本部分。我们研究了如何在 Angular 应用程序中实现认证和授权。最后,我们学习了 Angular 拦截器是什么以及如何使用它们来授权 HTTP 调用。

现在我们知道了如何在组件中从后端 API 获取数据,我们可以进一步改善我们应用程序的用户体验。在下一章中,我们将学习如何使用 Angular 路由通过导航来加载我们的组件。

第九章:使用路由在应用程序中导航

在前面的章节中,我们很好地分离了关注点,并为 Angular 应用程序添加了不同的抽象层,以增加其可维护性。然而,我们几乎没有关注应用程序的用户体验(UX)。

我们的用户界面过于臃肿,组件散布在单个屏幕上。我们必须为用户提供更好的导航体验,以及一种直观地更改应用程序视图的逻辑方式。现在是时候引入路由并将不同兴趣区域分割成页面,通过链接和 URL 网格连接起来。

那么,我们如何在 Angular 应用程序的组件之间部署导航方案呢?我们使用 Angular 路由器并为我们的组件创建自定义链接以进行响应。

本章包含以下部分:

  • 介绍 Angular 路由器

  • 配置主要路由

  • 组织应用程序路由

  • 向路由传递参数

  • 使用高级功能增强导航

技术要求

本章包含各种代码示例,以指导您了解 Angular 框架中的路由。您可以在以下 GitHub 仓库的 ch09 文件夹中找到相关源代码:

www.github.com/PacktPublishing/Learning-Angular-Fifth-Edition

介绍 Angular 路由器

在传统的 Web 应用程序中,当我们想要从一个视图切换到另一个视图时,我们需要从服务器请求一个新的页面。浏览器会为视图创建一个 URL 并将其发送到服务器。然后,浏览器会在客户端收到响应后立即重新加载页面。这是一个导致往返时间延迟并给我们的应用程序带来糟糕用户体验的过程:

包含文本、线条、屏幕截图、编号  自动生成的描述

图 9.1:传统 Web 应用程序中的路由

使用 Angular 等 JavaScript 框架的现代 Web 应用程序采用不同的方法。它们在客户端处理视图或组件之间的变化,而不打扰服务器。它们在启动时仅与服务器联系一次以获取主 HTML 文件。客户端的路由器拦截并处理任何随后的 URL 变化。这些应用程序被称为 单页应用程序(SPA),因为它们不会导致页面完全重新加载:

包含文本、并行、图表、线条  自动生成的描述

图 9.2:SPA 架构

Angular 框架提供了 @angular/router npm 包,我们可以使用它来在 Angular 应用程序的不同组件之间进行导航。

在 Angular 应用程序中添加路由涉及以下步骤:

  1. 指定 Angular 应用程序的基本路径

  2. 使用 @angular/router npm 包中的适当指令或服务

  3. 配置 Angular 应用程序的不同路由

  4. 决定在导航时渲染组件的位置

在以下章节中,我们将在深入了解实际示例之前学习 Angular 路由的基础知识。

指定基本路径

正如我们已经看到的,当应用程序内的 URL 发生变化时,现代和传统 Web 应用程序的反应不同。每个浏览器的架构在这个行为中起着至关重要的作用。旧浏览器在 URL 发生变化时向服务器发起新的请求。现代浏览器,也称为 evergreen 浏览器,可以在不向服务器发送请求的情况下,通过使用称为 pushState 的技术在不同视图中导航时更改 URL 和浏览器历史记录。

HTML5 pushState 允许在应用程序内导航而不会导致页面完全重新加载,并且所有现代浏览器都支持。

Angular 应用程序必须在 index.html 文件中设置 <base> HTML 标签以启用 pushState 路由:

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

href 属性通知浏览器在加载应用程序资源时应遵循的路径。Angular CLI 在创建新应用程序时会自动添加此标签,并将 href 值设置为应用程序根目录,/。如果你的应用程序位于根目录之外的文件夹中,你应该将其命名为该文件夹的名称。

在 Angular 应用程序中启用路由

新的 Angular 应用程序默认启用 Angular 路由器,如 app.config.ts 文件中的 provideRouter 方法所示:

import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
**import { provideRouter } from '@angular/router';**
**import { routes } from './app.routes';**
export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    **provideRouter(routes)**
  ]
}; 

在使用较旧版本的 Angular 框架构建的应用程序中,通过在主应用程序模块中导入 RouterModule 类并使用其 forRoot 方法来定义路由配置来启用路由器。

provideRouter 方法使我们能够使用与路由相关的 Angular 艺术品集:

  • 执行常见路由任务(如导航)的服务

  • 我们可以在组件中使用以丰富其导航逻辑的指令

它接受一个参数,即应用程序的路由配置,默认情况下在 app.routes.ts 文件中定义。

配置路由器

app.routes.ts 文件包含一个 Routes 对象列表,指定应用程序中存在哪些路由以及哪些组件应该对特定路由做出响应。它看起来如下所示:

const routes: Routes = [ 
  { path: 'products', component: ProductListComponent }, 
  { path: '**', component: PageNotFoundComponent } 
]; 

在使用较旧版本的 Angular 框架构建的应用程序中,你可能注意到路由配置是在专门的 app-routing.module.ts 文件中定义的。

每个路由定义对象包含一个 path 属性,它是路由的 URL 路径,以及一个 component 属性,它定义了当应用程序导航到该路径时将加载哪个组件。

path 属性的值不应包含前导 /

在 Angular 应用程序中的导航可以通过手动更改浏览器 URL 或使用应用程序内的链接进行。在第一种情况下,浏览器将导致应用程序重新加载,而在第二种情况下,它将指示路由器在运行时进行导航。在我们的案例中,当浏览器 URL 包含products路径时,路由器将在页面上渲染产品列表组件。相反,当应用程序通过代码导航到products时,路由器遵循相同的程序并更新浏览器 URL。

如果用户尝试导航到不匹配任何路由的 URL,Angular 将激活一种自定义类型的路由,称为通配符后备路由。通配符路由的path属性有两个星号,匹配任何 URL。此组件的属性通常是特定于应用程序的PageNotFoundComponent或应用程序的主要组件。

组件渲染

主应用程序组件的模板包含<router-outlet>元素,这是 Angular 路由器的主要指令之一。它位于app.component.html文件中,用作通过路由激活的组件的占位符。这些组件作为<router-outlet>元素的兄弟元素进行渲染。

我们已经介绍了基础知识并提供了最小化的路由设置。在下一节中,我们将查看一个更实际的示例并扩展我们对路由的知识。

配置主路由

当我们开始设计具有路由的 Angular 应用程序的架构时,最容易想到的是其主要功能,例如用户可以点击以访问的菜单链接。产品和购物车是我们目前正在构建的电子商务应用程序的基本功能。添加链接并将它们配置为激活 Angular 应用程序的特定功能是应用程序路由配置的一部分。

您需要我们创建在第八章通过 HTTP 与数据服务通信中创建的 Angular 应用程序的源代码,以便跟随本章的其余部分。在您获取代码后,我们建议您为简化起见采取以下行动:

  • 删除auth.interceptor.ts及其单元测试文件。在 Fake Store API 的实际调用中不需要身份验证。

  • 修改app.config.ts文件,以便provideHttpClient方法不使用拦截器。

要设置我们应用程序的路由配置,我们需要遵循以下步骤:

  1. 运行以下命令以创建一个新的 Angular 组件用于购物车:

    ng generate component cart 
    
  2. 打开app.routes.ts文件并添加以下import语句:

    import { CartComponent } from './cart/cart.component';
    import { ProductListComponent } from './product-list/product-list.component'; 
    
  3. routes变量中添加两个路由定义对象:

    export const routes: Routes = [
      **{ path: 'products', component: ProductListComponent },**
      **{ path: 'cart', component: CartComponent }**
    ]; 
    

在前面的代码片段中,products路由将激活ProductListComponent,而cart路由将激活CartComponent

  1. 打开app.component.html文件,并按照以下方式修改<header> HTML 元素:

    <header>
      <h2>{{ settings.title }}</h2>
      <span class="spacer"></span>
      <div class="menu-links">
        <a routerLink="/products">Products</a>
        <a routerLink="/cart">My Cart</a>
      </div>
      <app-auth></app-auth>
    </header> 
    

在前面的模板中,我们应用了 routerLink 指令到锚点 HTML 元素,并分配我们想要导航的路由路径。请注意,路径应该以 / 开头,而不是路由定义对象中的 path 属性。

路径如何开始取决于我们是否想在应用程序中使用绝对路由或相对路由,我们将在本章后面学习。

  1. <router-outlet> HTML 元素移动到具有 content 类选择器的 <div> 元素内部,并删除 <app-product-list> 组件:

    <main class="main">
      <div class="content">
        <router-outlet />
      </div>
    </main> 
    
  2. 打开 app.component.ts 文件,删除对 ProductListComponent 类的任何引用,并导入 RouterLink 类:

    import { Component, inject } from '@angular/core';
    import { **RouterLink**, RouterOutlet } from '@angular/router';
    import { CopyrightDirective } from './copyright.directive';
    import { APP_SETTINGS } from './app.settings';
    import { AuthComponent } from './auth/auth.component';
    @Component({
      selector: 'app-root',
      imports: [
        RouterOutlet,
        **RouterLink**,
        CopyrightDirective,
        AuthComponent
      ],
      templateUrl: './app.component.html',
      styleUrl: './app.component.css'
    }) 
    
  3. 打开 app.component.css 文件,并用以下样式替换与 .social-links 选择器相关的所有 CSS 样式:

    header {
      display: flex;
      flex-direction: row;
      gap: 0.73rem;
      justify-content: end;
      margin-top: 1.5rem;
    }
    .menu-links {
      display: flex;
      align-items: center;
      gap: 0.73rem;
    }
    .menu-links a {
      transition: fill 0.3s ease;
      color: var(--gray-400);
    }
    .menu-links a:hover {
      color: var(--gray-900);
    } 
    
  4. 最后,打开全局的 styles.css 文件,并添加以下 CSS 样式:

    a {
      text-decoration: none;
    }
    .spacer {
      flex: 1 1 auto;
    } 
    

我们现在可以预览我们的 Angular 应用程序了:

  1. 运行 ng serve 命令并导航到 http://localhost:4200。最初,应用程序页面仅显示应用程序标题和版权信息。

  2. 点击 Products 链接。应用程序应该显示产品列表,并更新浏览器 URL 以匹配 /products 路径。

  3. 现在导航到根路径 http://localhost:4200,并在浏览器 URL 的末尾追加 /cart 路径。应用程序应该用购物车组件替换产品列表视图:

购物车功能正常!

Angular 中的路由是双向的。它使我们能够使用应用程序内的链接或浏览器地址栏导航到 Angular 组件。

恭喜!您的 Angular 应用程序现在支持应用程序内导航。

我们对 Angular 路由的探索还只是触及了皮毛。在接下来的章节中,我们将有许多特性要研究。现在,让我们尝试将我们的组件拆分成更多路由,以便我们能够轻松管理。

组织应用程序路由

我们的应用程序显示产品列表以及产品详情和产品创建组件。我们需要组织路由配置,以便不同的路由激活每个组件。

在本节中,我们将为产品创建组件添加一个新的路由。在后面的 向路由传递参数 部分,我们将为产品详情组件添加一个单独的路由。

让我们开始创建产品组件:

  1. 打开 app.routes.ts 文件,并添加以下 import 语句:

    import { ProductCreateComponent } from './product-create/product-create.component'; 
    
  2. routes 变量中添加以下路由定义对象:

    { path: 'products/new', component: ProductCreateComponent } 
    
  3. 打开 product-list.component.ts 文件,并删除对 ProductCreateComponent 类的任何引用。

  4. 打开 product-list.component.html 文件,并删除 <app-product-create> 元素。

  5. 运行 ng serve 命令以启动应用程序,点击 Products 链接,并验证产品创建表单没有显示。

目前,产品创建组件只能通过浏览器 URL 访问,我们无法通过应用程序 UI 访问它。在下一节中,我们将学习如何完成这项任务并强制导航到路由。

强制导航到路由

产品创建组件只能通过在浏览器地址栏中输入地址http://localhost:4200/products/new来激活。让我们在产品列表中添加一个按钮,以便我们也可以从 UI 导航:

  1. 打开product-list.component.html文件并修改第二个@if块如下:

下面的<path>元素可能难以手动输入。或者,您可以在书的 GitHub 仓库中的ch09文件夹中找到代码并从那里复制。

@if (products) {
  **<div class="caption">**
    **<h1>Products ({{products.length}})</h1>**
    **<a routerLink="new">**
      **<svg**
        **width="24"**
        **height="24"**

        **fill-rule="evenodd"**
        **clip-rule="evenodd">**
        **<path d="M11.5 0c6.347 0 11.5 5.153 11.5 11.5s-5.153 11.5-11.5 11.5-11.5-5.153-11.5-11.5 5.153-11.5 11.5-11.5zm0 1c5.795 0 10.5 4.705 10.5 10.5s-4.705 10.5-10.5 10.5-10.5-4.705-10.5-10.5 4.705-10.5 10.5-10.5zm.5 10h6v1h-6v6h-1v-6h-6v-1h6v-6h1v6z"/>**
      **</svg>**
    **</a>**
  **</div>**
} 

在前面的代码片段中,我们添加了一个锚点元素,它将带我们导航到产品创建组件,如routerLink指令的值所示。

routerLink指令的值是new,而不是像人们预期的那样是/products/new。前面的行为是因为按钮位于产品列表组件中,该组件已经被路由的products部分激活。

Angular 路由器可以通过所有激活的路由来合成目标路由,但如果您不想从根开始,可以在路由前添加一个/

  1. 打开product-list.component.css文件并添加以下 CSS 样式:

    .caption {
      display: flex;
      align-items: center;
      gap: 1.25rem;
    }
    path {
      transition: fill 0.3s ease;
      fill: var(--gray-400);
    }
    a:hover svg path {
      fill: var(--gray-900);
    } 
    
  2. 打开product-list.component.ts文件并添加以下import语句:

    import { RouterLink } from '@angular/router'; 
    
  3. @Component装饰器的imports数组中添加RouterLink类:

    @Component({
      selector: 'app-product-list',
      imports: [
        ProductDetailComponent,
        SortPipe,
        AsyncPipe,
        **RouterLink**
      ],
      templateUrl: './product-list.component.html',
      styleUrl: './product-list.component.css'
    }) 
    
  4. 打开product-create.component.css文件并添加以下 CSS 样式:

    :host {
      width: 400px;
    } 
    

在前面的样式中,:host选择器针对产品创建组件的主元素。

  1. 运行ng serve命令以启动应用程序并导航到http://localhost:4200/products

包含文本的图像,屏幕截图,字体,自动生成的描述

图 9.3:产品列表

  1. 点击带有加号的按钮。应用程序将您重定向到/products/new路由并激活产品创建组件:

包含文本的图像,屏幕截图,字体,编号,自动生成的描述

图 9.4:产品创建表单

尽管产品创建组件仍然可用,但我们的更改在应用程序的 UX 中引入了一个缺陷。当创建新产品时,用户没有视觉指示,因为产品列表属于不同的路由。我们必须修改创建按钮的逻辑,以便在成功创建产品后将其重定向到产品列表:

  1. 打开product-create.component.ts文件并添加以下import语句:

    import { Router } from '@angular/router'; 
    
  2. ProductCreateComponent类的constructor中注入Router服务:

    constructor(private productsService: ProductsService, **private router: Router**) {} 
    
  3. 按照以下方式修改createProduct方法:

    createProduct(title: string, price: string, category: string) {
      this.productsService.addProduct({
        title,
        price: Number(price),
        category
      }).subscribe(**() => this.router.navigate(['/products'])**);
    } 
    

在前面的方法中,我们调用 Router 服务的 navigate 方法来导航到应用程序的 /products 路径。

我们使用 / 字符是因为我们默认使用绝对路由。

它接受一个包含我们想要导航的目的地路由路径的链接参数数组

  1. 打开 products.service.ts 文件,并修改 getProducts 方法,使其在没有本地产品数据时使用 Fake Store API:

    getProducts(): Observable<Product[]> {
      **if (this.products.length === 0) {**
        const options = new HttpParams().set('limit', 10);
        return this.http.get<Product[]>(this.productsUrl, {
          params: options
        }).pipe(map(products => {
          this.products = products;
          return products;
        }));
      **}**
      **return of(this.products);**
    } 
    

如果我们不进行前面的更改,产品列表组件将始终从 Fake Store API 返回数据。

我们的应用程序现在在用户创建新产品时将用户重定向到产品列表,以便他们可以在列表中看到它。

到目前为止,我们已经配置了应用程序的路由,以便根据给定的路径激活组件。然而,在我们的应用程序中,以下情况下不会显示任何组件:

  • 当我们导航到应用程序的根路径时

  • 当我们尝试导航到一个不存在的路由时

在下一节中,我们将学习如何使用 Angular 路由器提供的内置路由路径,并改进应用程序的用户体验。

使用内置路由路径

当我们想要定义一个在导航到根路径时将被加载的组件时,我们创建一个路由定义对象,并将 path 属性设置为空字符串。具有空字符串 path 的路由称为 Angular 应用程序的默认路由

在我们的案例中,我们希望默认路由显示产品列表组件。打开 app.routes.ts 文件,并在 routes 变量的末尾添加以下路由:

{ path: '', redirectTo: 'products', pathMatch: 'full' } 

在前面的代码片段中,我们告诉路由器当应用程序导航到默认路由时重定向到 products 路径。pathMatch 属性告诉路由器如何将 URL 匹配到根路径属性。在这种情况下,只有当 URL 匹配根路径(即空字符串)时,路由器才会重定向到 products 路径。

如果我们运行应用程序,我们会注意到当浏览器 URL 指向我们的应用程序根路径时,我们会重定向到 products 路径,并且产品列表会显示在屏幕上。

我们在所有其他路由之后添加了默认路由,因为路由的顺序很重要。路由器采用“首次匹配即赢”的策略选择路由。更具体的路由应该定义在不太具体的路由之前。

我们在“介绍 Angular 路由器”部分遇到了未知路由的概念。我们简要地看到了如何设置一个通配符路由,当我们的应用程序尝试导航到一个不存在的路由时,显示 PageNotFoundComponent。在实际应用中,创建这样的组件很常见,尤其是如果你想向用户显示额外的信息,比如他们可以采取的下一步行动。在我们的案例中,这比较简单,我们将重定向到 products 路由。

打开 app.routes.ts 文件,并在 routes 变量的末尾添加以下路由:

{ path: '**', redirectTo: 'products' } 

通配符路由必须是路由列表中的最后一个条目,因为应用程序只有在没有匹配的路由时才应该到达它。

如果我们使用ng serve命令运行我们的应用程序并导航到一个未知路径,我们的应用程序将显示产品列表。

到目前为止,我们一直依赖于浏览器地址栏来指示在任何给定时间哪个路由是激活的。正如我们将在下一节中学习的,我们可以使用 CSS 样式来改进用户体验。

路由链接样式

应用程序标题包含产品我的购物车链接。当我们导航到每个链接时,不清楚哪个路由已被激活。Angular 路由器导出了routerLinkActive指令,我们可以用它来改变当对应路由激活时链接的样式。它的工作方式与我们在第三章中学习的类绑定类似,使用组件结构化用户界面。它接受一个类名列表或一个当链接激活时添加并当它变为非激活时移除的类。

让我们看看如何在我们的应用程序中使用它:

  1. 打开app.component.css文件并添加以下 CSS 样式:

    .menu-links a.active {
      color: var(--electric-violet);
    } 
    
  2. 打开app.component.ts文件并从@angular/router npm 包中导入RouterLinkActive类:

    import { RouterLink, **RouterLinkActive**, RouterOutlet } from '@angular/router'; 
    
  3. @Component装饰器的imports数组中添加RouterLinkActive类:

    @Component({
      selector: 'app-root',
      imports: [
        RouterOutlet,
        RouterLink,
        **RouterLinkActive**,
        CopyrightDirective,
        AuthComponent
      ],
      templateUrl: './app.component.html',
      styleUrl: './app.component.css'
    }) 
    
  4. 打开app.component.html文件并将routerLinkActive指令添加到两个链接中:

    <div class="menu-links">
      <a routerLink="/products" **routerLinkActive="active"**>Products</a>
      <a routerLink="/cart" **routerLinkActive="active"**>My Cart</a>
    </div> 
    

现在,当我们点击标题中的应用程序链接时,其颜色会改变以表示链接是激活的。

我们已经学习了如何使用路由和激活不需要任何参数的组件。然而,产品详情组件接受产品 ID 作为参数。在下一节中,我们将学习如何使用动态路由参数激活组件。

向路由传递参数

企业级 Web 应用中常见的场景是有一个项目列表,当你点击其中一个时,页面会改变当前视图并显示所选项目的详细信息。之前的方法类似于主从浏览功能,其中主页面上的每个生成的 URL 都包含在详情页上加载每个项目所需的标识符。

我们可以用两个路由来表示之前的场景,一个路由导航到不同的组件。一个组件是项目列表,另一个是项目详情。因此,我们需要找到一种方法从一条路由创建和传递动态的项目特定数据到另一条路由。

我们在这里面临双重挑战:在运行时创建带有动态参数的 URL 以及解析这些参数的值。没问题:Angular 路由器支持我们,我们将通过一个真实示例来了解这一点。

使用路由参数构建详情页

在我们的应用程序中,产品列表当前显示产品列表。当我们点击产品时,产品详情会显示在列表下方。我们需要重构之前的流程,以便负责显示产品详情的组件在列表的不同页面上渲染。我们将使用 Angular 路由在从列表中点击产品时将用户重定向到新页面。

产品列表组件当前通过输入绑定传递所选产品 ID。我们将使用 Angular 路由将产品 ID 作为路由参数传递:

  1. 打开 app.routes.ts 文件并添加以下 import 语句:

    import { ProductDetailComponent } from './product-detail/product-detail.component'; 
    
  2. routes 变量中 products/new 路由之后添加以下路由定义:

    { path: 'products/:id', component: ProductDetailComponent } 
    

冒号字符表示在新的路由定义对象中将 id 作为路由参数。如果一个路由有多个参数,我们用 / 分隔它们。正如我们稍后将要学习的,当我们在组件中消费其值时,参数名称很重要。

  1. 打开 product-list.component.html 文件并为产品标题添加一个锚点元素,使其使用新的路由定义:

    <ul class="pill-group">
      @for (product of products | sort; track product.id) {
        <li class="pill" (click)="selectedProduct = product">
          @switch (product.category) {
            @case ('electronics') { ![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-041.png) }
            @case ('jewelery') { ![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-101.png) }
            @default { ![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/lrn-ng-5e/img/Icon-09.png) }
          }
          **<a [routerLink]="[product.id]">{{product.title}}</a>**
        </li>
      } @empty {
        <p>No products found!</p>
      }
    </ul> 
    

在前面的代码片段中,routerLink 指令使用属性绑定来设置其值在链接参数数组中。我们将产品模板引用变量的 id 作为参数传递到数组中。

我们不需要在链接参数数组的值前加 /products 前缀,因为该路由已经激活了产品列表。

  1. 移除 <app-product-detail> 组件和 <li> 标签上的 click 事件绑定。

我们可以重构 product-list.component.ts 文件,并移除任何使用 selectedProduct 属性和 ProductDetailComponent 类的代码。产品列表不需要在本地状态中保留所选产品,因为我们选择产品后会离开列表。

我们现在可以通过修改产品详情组件来使其与路由一起工作:

  1. 打开 product-detail.component.css 文件,添加一个 CSS 样式来设置宿主元素的宽度:

    :host {
      width: 450px;
    } 
    
  2. 打开 product-detail.component.ts 文件,并按如下方式修改 import 语句:

    import { CommonModule } from '@angular/common';
    import { Component, input, OnInit } from '@angular/core';
    import { ActivatedRoute, Router } from '@angular/router';
    import { Product } from '../product';
    import { Observable, switchMap } from 'rxjs';
    import { ProductsService } from '../products.service';
    import { AuthService } from '../auth.service'; 
    

Angular 路由导出 ActivatedRoute 服务,我们可以使用它来检索有关当前激活路由的信息,包括任何参数。

  1. 修改 constructor 组件以注入 ActivatedRouteRouter 服务:

    constructor(
      private productService: ProductsService,
      public authService: AuthService,
      **private route: ActivatedRoute,**
      **private router: Router**
    ) { } 
    
  2. 修改 ProductDetailComponent 类实现的接口列表:

    export class ProductDetailComponent implements OnInit 
    
  3. 创建以下 ngOnInit 方法:

    ngOnInit(): void {
      this.product$ = this.route.paramMap.pipe(
        switchMap(params => {
          return this.productService.getProduct(Number(params.get('id')));
        })
      );
    } 
    

ActivatedRoute 服务包含 paramMap 可观察对象,我们可以使用它来订阅并获取路由参数值。当我们需要从一个可观察对象中获取值、完成它并将值传递给另一个可观察对象时,我们使用 switchMap RxJS 操作符。在这种情况下,我们使用它将 paramMap 可观察对象中的 id 参数传递到 ProductsService 类的 getProduct 方法。

  1. 修改 changePriceremove 方法,以便在每次操作完成后应用程序将重定向到产品列表:

    changePrice(product: Product, price: string) {
      this.productService.updateProduct(product.id, Number(price)).subscribe(() => {
        **this.router.navigate(['/products']);**
      **}**);
    }
    remove(product: Product) {
      this.productService.deleteProduct(product.id).subscribe(**() => {**
    **this.router.navigate(['/products']);**
    **}**);
    } 
    
  2. 移除 ngOnChanges 方法,因为组件及其绑定每次激活路由时都会初始化。

  3. 移除输出事件发射器,因为产品列表组件不再是父组件。保留 id 输入属性不变,因为我们将在本章后面使用它。

  4. 目前将 addToCart 方法留空。我们将在第十章 使用表单收集用户数据 中使用它。

还值得注意的是:

  1. paramMap 可观察者返回一个 ParamMap 类型的对象。我们可以使用 ParamMap 对象的 get 方法来传递我们在路由配置中定义的参数名称,并访问其值。

  2. 我们将 id 参数的值转换为数字,因为路由参数值始终是字符串。

如果我们使用 ng serve 命令运行应用程序并从列表中点击一个产品,应用程序将导航到产品详情组件:

包含文本、屏幕截图、字体、设计的图像自动生成的描述

图 9.5:产品详情页面

如果你刷新浏览器,应用程序将不会显示产品,因为 ProductsService 类的 getProduct 方法只与产品数据的缓存版本一起工作。你必须再次转到产品列表并选择一个产品,因为本地缓存已被重置。请注意,这种行为基于电子商务应用程序的当前实现,并不依赖于 Angular 路由器架构。

在前面的例子中,我们使用了 paramMap 属性来获取路由参数作为可观察者。因此,理想情况下,我们的组件可以在其生命周期内通知新值。但是,每次我们想要从列表中选择不同的产品时,组件和 paramMap 可观察者的订阅都会被销毁。

或者,我们可以在组件在连续导航中保持渲染在屏幕上时立即重用组件的实例,从而避免使用可观察者。我们可以通过使用子路由来实现这种行为,正如我们将在下一节中学习的。

使用子路由重用组件

当我们想要一个提供其他组件路由的着陆页组件时,子路由是一个完美的解决方案。该组件应包含一个 <router-outlet> 元素,其中将加载子路由。

假设我们想定义我们的 Angular 应用程序的布局如下:

形状描述,中等置信度自动生成

图 9.6:主-详细布局

上一图中的场景要求产品列表组件包含一个 <router-outlet> 元素,以便在激活相关路由时渲染产品详情组件。

产品详情组件将在产品列表组件的<router-outlet>中渲染,而不是在主应用程序组件的<router-outlet>中。

当我们从一种产品导航到另一种产品时,产品详情组件不会被销毁。相反,它保持在 DOM 树中,并且它的ngOnInit方法只在第一次选择产品时被调用。当我们从列表中选择新产品时,paramMap可观察对象会发出新产品的id。新产品使用ProductsService类获取,组件模板被刷新以反映新的变化。

应用程序的路线配置,在这种情况下,如下所示:

export const routes: Routes = [
  **{**
    **path: 'products',**
    **component: ProductListComponent,**
    **children: [**
      **{ path: 'new', component: ProductCreateComponent },**
      **{ path: ':id', component: ProductDetailComponent },**
    **]**
  **}**,
  { path: 'cart', component: CartComponent },
  { path: '', redirectTo: 'products', pathMatch: 'full' },
  { path: '**', redirectTo: 'products' }
]; 

在前面的代码片段中,我们使用路由定义对象的children属性来定义包含路由定义对象列表的子路由。

注意,我们还从子路由的path属性中移除了单词products,因为父路由会附加它。

父路由也可以通过使用路由定义对象的providers属性为其子路由提供服务。在路由中提供服务在我们想要限制对路由配置子集的访问时非常有用。如果我们只想将ProductsService类限制在产品相关组件中,我们应该做以下操作:

{
  path: 'products',
  component: ProductListComponent,
  children: [
    { path: 'new', component: ProductCreateComponent },
    { path: ':id', component: ProductDetailComponent },
  ],
  **providers: [ProductsService]**
} 

当在路由定义对象中提供服务时,Angular 会创建一个独立的注入器,它是根注入器的直接子节点。假设该服务也在根注入器中提供,并且假设购物车组件使用它。在这种情况下,由产品相关组件之一创建的实例将与购物车组件的实例不同。

我们已经学习了如何在 Angular 路由中使用paramMap可观察对象。在下一节中,我们将讨论使用快照的替代方法。

拍摄路由参数快照

当我们从列表中选择产品时,产品列表组件会被从 DOM 树中移除,产品详情组件被添加。要选择不同的产品,我们需要点击产品链接或浏览器的后退按钮。因此,产品详情组件在 DOM 中被产品列表组件替换。所以,我们处于在任何时候屏幕上只显示一个组件的情况。

当产品详情组件被销毁时,它的ngOnInit方法和对paramMap可观察对象的订阅也会被销毁。因此,我们在此点使用可观察对象没有好处。作为替代,我们可以使用ActivatedRoute服务的snapshot属性来获取路由参数的值,如下所示:

ngOnInit(): void {
  const id = this.route.snapshot.params['id'];
  this.product$ = this.productService.getProduct(id);
} 

snapshot属性表示路由参数的当前值,这恰好也是初始值。它包含params属性,这是一个我们可以访问的路由参数键值对的对象。

如果你确定你的组件不会被重用,请使用快照方法。

到目前为止,我们已经处理了以 products/:id 形式的路由参数。我们使用这些参数导航到需要该参数的组件。在我们的例子中,产品详情组件需要 id 参数来获取特定产品的详细信息。然而,当我们需要它为可选时,还有另一种类型的路由参数,我们将在下一节中学习。

使用查询参数过滤数据

第八章通过 HTTP 与数据服务通信 中,我们学习了如何使用 HttpParams 类将查询参数传递给请求。Angular 路由也支持通过应用程序的 URL 传递查询参数。

products.service.ts 文件中的 getProducts 方法使用 HTTP 查询参数来限制从 Fake Store API 返回的产品结果:

getProducts(): Observable<Product[]> {
  if (this.products.length === 0) {
    const options = new HttpParams().set('limit', 10);
    return this.http.get<Product[]>(this.productsUrl, {
      params: options
    }).pipe(map(products => {
      this.products = products;
      return products;
    }));
  }
  return of(this.products);
} 

它使用硬编码的值来设置 limit 查询参数。我们将修改应用程序,使产品列表组件动态传递 limit 值:

  1. 打开 products.service.ts 文件并修改 getProducts 方法,使 limit 作为参数传递:

    getProducts(**limit?: number**): Observable<Product[]> {
      if (this.products.length === 0) {
        const options = new HttpParams().set('limit', **limit ||** 10);
        return this.http.get<Product[]>(this.productsUrl, {
          params: options
        }).pipe(map(products => {
          this.products = products;
          return products;
        }));
      }
      return of(this.products);
    } 
    

在前面的方法中,如果 limit 值为 假值,我们将默认值 10 传递给查询参数。

在布尔上下文中,假值评估为 False,可以是 nullundefined0False。您可以在 developer.mozilla.org/docs/Glossary/Falsy 上了解更多信息。

  1. 打开 product-list.component.ts 文件并导入 ActivatedRoute 服务和 switchMap RxJS 操作符:

    import { RouterLink, **ActivatedRoute** } from '@angular/router';
    import { Observable, **switchMap** } from 'rxjs'; 
    
  2. ProductListComponent 类的 constructor 中注入 ActivatedRoute 服务:

    constructor(private productService: ProductsService, **private route: ActivatedRoute**) {} 
    
  3. ActivatedRoute 服务包含一个 queryParamMap 可观察对象,我们可以订阅它以获取查询参数值。它返回一个 ParamMap 对象,类似于我们之前看到的 paramMap 可观察对象,我们可以查询它以获取参数值。修改 getProducts 方法以使用 queryParamMap 可观察对象:

    private getProducts() {
      this.products$ = **this.route.queryParamMap.pipe(**
        **switchMap(params => {**
          **return this.productService.getProducts(Number(params.get('limit')));**
        **})**
      **);**
    } 
    

在前面的代码片段中,我们使用 switchMap RxJS 操作符将 limit 参数从 queryParamMap 可观察对象管道到 ProductsService 类的 getProducts 方法作为数字。

  1. 运行 ng serve 命令以启动应用程序并导航到 http://localhost:4200?limit=5 。你应该看到 5 个产品的列表:

包含文本的图像,屏幕截图,字体,自动生成的描述

图 9.7:过滤后的产品列表

尝试对 limit 参数的不同值进行实验,并观察输出。

路由中的查询参数功能强大,可用于各种用例,例如过滤和排序数据。它们也可以在与基于快照的路由一起工作时使用。

在下一节中,我们将探索一种新的创新方法,使用组件输入属性传递路由参数。

将输入属性绑定到路由

我们已经在 第三章 中学习了,使用组件构建用户界面,我们使用输入和输出绑定在组件之间进行交互。输入绑定还可以在导航到组件时传递路由参数。我们将通过产品详情组件的示例来了解:

  1. 默认情况下,Angular 路由器没有启用带有路由参数的输入绑定。我们必须从应用程序配置文件中激活它。打开 app.config.ts 文件,并从 @angular/router npm 包中导入 withComponentInputBinding 函数:

    import { provideRouter, **withComponentInputBinding** } from '@angular/router'; 
    
  2. 将前面的函数作为 provideRouter 方法的第二个参数传递:

    provideRouter(routes, **withComponentInputBinding()**), 
    
  3. 现在,打开 product-detail.component.ts 文件,将 id 组件属性的类型更改为 string

    id = input<**string**>(); 
    

我们必须更改属性类型,因为路由参数是以字符串形式传递的。

  1. 修改 ngOnInit 方法以使用 id 参数获取产品:

    ngOnInit(): void {
      **this.product$ = this.productService.getProduct(Number(this.id()!));**
    } 
    
  2. 运行 ng serve 命令并验证在从列表中选择产品时是否显示产品详情。

将路由参数绑定到组件输入属性有以下优点:

  • TypeScript 组件类更简单,因为我们没有使用异步调用与可观察对象

  • 我们可以使用路由访问与输入和输出绑定一起工作的现有组件

输入绑定与通过路由激活的组件一起工作。如果我们想从另一个组件访问任何路由参数,我们必须使用 ActivatedRoute 服务。

现在我们已经学习了在导航期间传递参数的所有不同方式,我们已经涵盖了开始使用路由构建 Angular 应用程序所需的所有基本信息。在以下章节中,我们将关注增强 Angular 应用程序中应用程序导航用户体验的高级实践。

使用高级功能增强导航

到目前为止,我们已经介绍了使用路由和查询参数的基本路由。然而,Angular 路由器功能强大,能够做更多的事情,例如以下内容:

  • 控制对路由的访问

  • 防止从路由导航离开

  • 预取数据以改善应用程序 UX

  • 懒加载路由以加快响应时间

在以下章节中,我们将更详细地了解所有这些技术。

控制路由访问

当我们想要控制对特定路由的访问时,我们使用 守卫。要创建守卫,我们使用 Angular CLI 的 ng generate 命令,传递 guard 和其名称作为参数:

ng generate guard auth 

当我们执行前面的命令时,Angular CLI 会询问我们想要创建哪种类型的守卫。根据它们提供的功能,我们可以创建多种类型的守卫:

  • CanActivate : 控制是否可以激活路由

  • CanActivateChild : 控制子路由是否可以激活

  • CanDeactivate : 控制是否可以停用路由

停用发生在我们从路由导航离开时。

  • CanMatch:控制是否可以访问任何路由

选择CanActivate并按Enter。Angular CLI 创建以下auth.guard.ts文件:

import { CanActivateFn } from '@angular/router';
export const authGuard: CanActivateFn = (route, state) => {
  return true;
}; 

我们创建的守卫是一个CanActivateFn类型的函数,它接受两个参数:

  • route:指示将要激活的路由

  • state:包含成功导航时的路由器状态

CanActivateFn函数可以同步或异步地返回一个布尔值。在后一种情况下,路由器将等待可观察对象或承诺解析完成后再继续。如果异步事件没有完成,导航将不会继续。它还可以返回一个UrlTree对象,这将导致新的导航到定义的路由。

我们的守卫立即返回true,允许自由访问路由。让我们添加自定义逻辑来根据用户是否登录来控制访问:

  1. 按照以下方式修改import语句:

    **import { inject } from '@angular/core';**
    import { CanActivateFn, **Router** } from '@angular/router';
    **import { AuthService } from './auth.service';** 
    
  2. 将箭头函数的主体替换为以下代码片段:

    const authService = inject(AuthService);
    const router = inject(Router);
    if (authService.isLoggedIn()) {
      return true;
    }
    return router.parseUrl('/'); 
    

在前面的代码片段中,我们使用inject方法将AuthServiceRouter服务注入到函数中。然后我们检查isLoggedIn信号值的真假。如果它是true,我们允许应用程序导航到请求的路由。否则,我们使用Router服务的parseUrl方法导航到 Angular 应用程序的根路径。

parseUrl方法返回一个UrlTree对象,它实际上取消了之前的导航并将用户重定向到参数中传入的 URL。建议使用它而不是navigate方法,因为navigate方法可能会引入意外的行为,并可能导致复杂的导航问题。

  1. 打开app.routes.ts文件并添加以下import语句:

    import { authGuard } from './auth.guard'; 
    
  2. cart路由的canActivate数组中添加authGuard函数:

    {
      path: 'cart',
      component: CartComponent,
      **canActivate: [authGuard]**
    } 
    

canActivate属性是一个数组,因为多个守卫可以控制路由激活。数组中守卫的顺序很重要。如果数组中的任何一个守卫未能通过,Angular 将阻止访问该路由。

只有经过身份验证的用户现在才能访问购物车。如果你使用ng serve命令运行应用程序并点击我的购物车链接,你会注意到没有任何反应。

当你尝试从产品列表访问购物车时,你总是停留在同一页面上。这是因为由于身份验证守卫导致的重定向在你已经在重定向的路由中时没有任何效果。

与路由激活相关的另一种守卫类型是CanDeactivate守卫。在下一节中,我们将学习如何使用它来防止用户离开路由。

防止从路由导航离开

控制路由是否可以被退出的守卫是一个CanDeactivateFn类型的函数。我们将通过实现一个守卫来学习如何使用它,当用户从购物车组件导航离开时,它会通知用户购物车中有待处理的产品:

  1. 运行以下命令以生成一个新的守卫:

    ng generate guard checkout 
    
  2. 从列表中选择CanDeactivate类型并按Enter键。

  3. 打开checkout.guard.ts文件并添加以下import语句:

    import { CartComponent } from './cart/cart.component'; 
    
  4. CanDeactivateFn的泛型更改为CartComponent并移除箭头函数的参数。

在现实世界的场景中,我们可能需要在泛型中添加更多组件来创建一个泛型守卫。

  1. 将箭头函数的主体替换为以下代码片段:

    const confirmation = confirm(
      'You have pending items in your cart. Do you want to continue?'
    );
    return confirmation; 
    

在前面的代码片段中,我们使用全局window对象的confirm方法在离开购物车组件之前显示一个确认对话框。应用程序执行将等待直到确认对话框被关闭,作为用户交互。

  1. 打开app.routes.ts文件并添加以下import语句:

    import { checkoutGuard } from './checkout.guard'; 
    
  2. 路由定义对象包含一个类似于canActivatecanDeactivate数组。将checkoutGuard函数添加到cart路由的canDeactivate数组中:

    {
      path: 'cart',
      component: CartComponent,
      canActivate: [authGuard],
      **canDeactivate: [checkoutGuard]**
    } 
    

canDeactivate属性是一个数组,因为多个守卫可以控制路由的停用。数组中守卫的顺序很重要。如果其中一个守卫未能通过,Angular 将阻止用户离开该路由。

对于这样一个简单的场景,我们可以在内联中编写checkoutGuard函数的逻辑,以避免创建checkout.guard.ts文件:

{
  path: 'cart',
  component: CartComponent,
  canActivate: [authGuard],
  canDeactivate: [**() => confirm('You have pending items in your cart. Do you want to continue?')**]
} 

使用ng serve命令运行应用程序,并在登录后点击我的购物车链接。如果你然后点击产品链接或按浏览器的后退按钮,你应该会看到一个包含以下信息的对话框:

你的购物车中有待处理的项目。你想继续吗?

如果你点击取消按钮,导航将被取消,应用程序将保持当前状态。如果你点击确定按钮,你将被重定向到产品列表。

预取路由数据

你可能已经注意到,当你第一次导航到应用程序的根路径时,产品列表的显示会有延迟。这是合理的,因为我们正在向后端 API 发出 HTTP 请求。然而,产品列表组件当时已经初始化了。

如果组件在初始化期间与数据交互的逻辑导致这种行为,可能会导致不希望的效果。为了解决这个问题,我们可以使用解析器来预取产品列表并在数据可用时加载组件。

在激活路由之前处理可能的错误时,解析器非常有用。如果 API 请求失败,导航到错误页面比显示空白页面更合适。

要创建一个解析器,我们使用 Angular CLI 的ng generate命令,传递单词resolver及其名称作为参数:

ng generate resolver products 

前面的命令创建以下products.resolver.ts文件:

import { ResolveFn } from '@angular/router';
export const productsResolver: ResolveFn<boolean> = (route, state) => {
  return true;
}; 

我们创建的解析器是一个类型为ResolveFn的函数,它接受两个参数:

  • route:指示将要激活的路由

  • state:包含激活路由的状态

ResolveFn函数可以返回一个可观察对象或 promise。路由器将在可观察对象或 promise 解析之前等待,如果异步事件未完成,则导航不会继续。

目前,我们的解析器返回一个布尔值。让我们添加自定义逻辑,使其返回产品数组:

  1. 添加以下import语句:

    import { inject } from '@angular/core';
    import { Product } from './product';
    import { ProductsService } from './products.service'; 
    
  2. 修改productsResolver函数,使其返回产品数组:

    export const productsResolver: ResolveFn<**Product[]**> = (route, state) => {
      return **[]**;
    }; 
    
  3. 使用inject方法在函数体中注入ProductsService

    const productService = inject(ProductsService); 
    
  4. 使用queryParamMap属性从当前路由获取limit参数值:

    const limit = Number(route.queryParamMap.get('limit')); 
    
  5. return语句替换为以下内容:

    return productService.getProducts(limit); 
    
  6. 生成的函数应如下所示:

    export const productsResolver: ResolveFn<Product[]> = (route, state) => {
      const productService = inject(ProductsService);
      const limit = Number(route.queryParamMap.get('limit'));
    
      return productService.getProducts(limit);
    }; 
    

现在我们已经创建了解析器,我们可以将其与产品列表组件连接起来:

  1. 打开app.routes.ts文件并添加以下import语句:

    import { productsResolver } from './products.resolver'; 
    
  2. 将以下resolve属性添加到products路由:

    {
      path: 'products',
      component: ProductListComponent,
      **resolve: {**
        **products: productsResolver**
      **}**
    } 
    

resolve属性是一个包含唯一名称作为键和解析函数作为值的对象。键名称很重要,因为我们将使用它在我们的组件中访问解析数据。

  1. 打开product-list.component.ts文件并从rxjsnpm 包中导入of运算符:

    import { Observable, switchMap, of } from 'rxjs'; 
    
  2. 修改getProducts方法,使其订阅ActivatedRoute服务的data属性:

    private getProducts() {
      this.products$ = **this.route.data.pipe(**
        **switchMap(data => of(data['products']))**
      **);**
    } 
    

在前面的代码片段中,data可观察对象发出一个对象,其值存在于products键中。请注意,我们使用switchMap运算符在新的可观察对象中返回产品。

在这一点上,我们也可以删除对ProductsService类的任何引用,因为它不再需要了。

  1. 运行ng serve命令以启动应用程序并验证在导航到http://localhost:4200时是否显示产品列表。

当路由组件中存在复杂的初始化逻辑时,Angular 解析器可以提高应用程序性能。提高应用程序性能的另一种方法是按需加载组件或子路由,我们将在下一节中学习。

懒加载应用程序的部分

在某个时候,我们的应用程序可能会增长,我们放入其中的数据量也可能增加。应用程序可能需要很长时间才能启动,或者某些部分可能需要很长时间才能加载。为了克服这些问题,我们可以使用称为懒加载的技术。

懒加载意味着我们最初不会加载某些应用程序部分,例如 Angular 组件或路由。在 Angular 应用程序中,懒加载有许多优点:

  • 组件和路由可以根据用户请求进行加载

  • 访问您应用程序特定区域的用户可以从这项技术中显著受益

  • 我们可以在懒加载区域添加更多功能,而不会影响整体应用程序包的大小

要了解 Angular 中的懒加载是如何工作的,我们将创建一个新的组件来显示当前用户的个人资料。

一个好的做法是懒加载那些不常使用的应用程序部分,例如当前登录用户的个人资料。

让我们开始吧:

  1. 运行以下命令创建 Angular 组件:

    ng generate component user 
    
  2. src\app文件夹中创建一个名为user.routes.ts的文件,并添加以下内容:

    import { UserComponent } from './user/user.component';
    export default [
      { path: '', component: UserComponent }
    ]; 
    

在前面的代码片段中,我们将path属性设置为空字符串以默认激活路由。我们还使用default关键字来利用懒加载中的默认导出功能。

  1. 打开app.routes.ts文件并在routes变量中添加以下路由定义:

    { path: 'user', loadChildren: () => import('./user.routes') } 
    

路由定义对象的loadChildren属性用于懒加载 Angular 路由。它返回一个使用动态import语句懒加载路由文件的箭头函数。import函数接受我们想要导入的路由文件的相对路径。

  1. app.component.html文件的<header>元素中添加一个新的锚点元素,链接到新创建的路由:

    <div class="menu-links">
      <a routerLink="/products" routerLinkActive="active">Products</a>
      <a routerLink="/cart" routerLinkActive="active">My Cart</a>
      **<a routerLink="/user" routerLinkActive="active">My Profile</a>**
    </div> 
    
  2. 运行命令ng serve并观察控制台窗口中的输出。它应该类似于以下内容:

    Initial chunk files | Names         |  Raw size
    polyfills.js        | polyfills     |  82.71 kB | 
    main.js             | main          |  47.22 kB | 
    styles.css          | styles        |   1.14 kB | 
                        | Initial total | 131.07 kB
    Lazy chunk files    | Names         |  Raw size
    chunk-D3RURZVV.js   | user-routes   |   1.26 kB | 
    Application bundle generation complete. [1.234 seconds] 
    

在前面的输出中,我们可以看到 Angular CLI 除了创建了应用的初始块文件外,还创建了一个名为user-routes的懒加载块文件。

  1. 使用您的浏览器导航到http://localhost:4200并打开开发者工具。

  2. 点击我的个人资料链接并检查网络请求标签页:

包含文本的图片,屏幕截图,字体,编号  自动生成的描述

图 9.8:懒加载路由

应用程序向块文件发起新的请求,这是用户路由的包。Angular 框架为每个懒加载的组件创建一个新的包,并且不将其包含在主应用程序包中。

如果您离开导航并再次点击我的个人资料链接,您会注意到应用程序不会发出新的请求来加载包文件。一旦请求懒加载的路由,它就会被保留在内存中,并可用于后续请求。

懒加载不仅适用于路由,也适用于组件。我们可以通过修改user路由来懒加载用户组件而不是整个路由,如下所示:

{
  path: 'user',
  loadComponent: () => import('./user/user.component').then(c => c.UserComponent),
} 

在前面的代码片段中,我们使用loadComponent属性动态导入user.component.ts文件。import函数返回一个 promise,我们通过then方法将其链接以加载UserComponent类。

用户路由目前对所有用户都可用,即使未经过身份验证。在下一节中,我们将学习如何使用守卫来保护它们。

保护懒加载路由

我们可以像在正常路由上一样控制对懒加载路由的无授权访问。然而,我们的守卫需要支持一个名为CanMatchFn的函数类型。

我们将扩展我们的认证守卫以用于懒加载的路由:

  1. 打开 auth.guard.ts 文件,并从 @angular/router npm 包中导入 CanMatchFn 类型:

    import { CanActivateFn, **CanMatchFn**, Router } from '@angular/router'; 
    
  2. 修改 authGuard 函数的签名如下:

    export const authGuard: CanActivateFn | **CanMatchFn = ()** => {
      const authService = inject(AuthService);
      const router = inject(Router);
      if (authService.isLoggedIn()) {
        return true;
      }
      return router.parseUrl('/');
    }; 
    
  3. 打开 app.routes.ts 文件,并在 user 路由的 canMatch 数组中添加 authGuard 函数:

    {
      path: 'user',
      loadChildren: () => import('./user.routes'),
      **canMatch: [authGuard]**
    } 
    

    canMatch 属性是一个数组,因为多个守卫可以控制路由匹配。数组中守卫的顺序很重要。如果其中一个守卫无法与路由匹配,Angular 将阻止访问该路由。

如果我们现在运行应用程序并点击 我的资料 链接,我们会注意到除非我们已认证,否则无法导航到相应的组件。

当应用程序性能至关重要时,懒加载是一种首选的技术。Angular 还引入了一个更高效的功能,即延迟加载 Angular 应用程序的部分,称为 可延迟视图。可延迟视图让开发者能够更精细地控制应用的一部分将在何种条件下被加载。我们将在第十五章,优化应用性能 中探讨可延迟视图。

摘要

我们现在已经揭开了 Angular 路由的威力,希望您已经享受了这次深入了解这个库的旅程。Angular 路由中闪耀的一点是,我们可以用这样简单但强大的实现覆盖大量的选项和场景。

我们已经学习了设置路由和处理不同类型参数的基础知识。我们还了解了更多高级功能,例如子路由。此外,我们还学习了如何保护我们的路由免受未经授权的访问。最后,我们展示了路由的全部威力以及如何通过懒加载和预加载来提高响应时间。

在下一章中,我们将增强我们的应用组件,以展示 Angular 中 Web 表单背后的机制以及最佳策略,以使用表单控件捕获用户输入。

第十章:使用表单收集用户数据

网络应用程序使用表单从用户那里收集输入数据。用例多种多样,从允许用户登录、填写支付信息、预订航班,甚至执行搜索。表单数据可以稍后保存在本地存储中或通过后端 API 发送到服务器。

在本章中,我们将介绍以下关于表单的主题:

  • 介绍网络表单

  • 构建模板驱动表单

  • 构建响应式表单

  • 使用表单构建器

  • 在表单中验证输入

  • 操作表单状态

技术要求

本章包含各种代码示例,引导你创建和管理 Angular 中的表单。你可以在以下 GitHub 仓库的ch10文件夹中找到相关源代码:

www.github.com/PacktPublishing/Learning-Angular-Fifth-Edition

介绍网络表单

表单通常具有以下特性,这些特性可以增强网络应用程序的用户体验:

  • 定义不同类型的输入字段

  • 设置不同类型的验证并向用户显示验证错误

  • 如果表单处于错误状态,支持不同的数据处理策略

Angular 框架提供了两种处理表单的方法:模板驱动响应式。两种方法都没有被认为是更好的;你必须选择最适合你场景的方法。两种方法之间的主要区别在于它们管理数据的方式:

  • 模板驱动表单:这些表单易于设置并添加到 Angular 应用程序中。它们仅通过组件模板来创建元素和配置验证规则;因此,它们不易于测试。它们还依赖于框架的变更检测机制。

  • 响应式表单:在扩展和测试时更为稳健。它们在组件类中操作,以管理输入控件和设置验证规则。它们还使用中间表单模型来操作数据,保持其不可变性质。如果你广泛使用响应式编程技术,或者你的 Angular 应用程序包含许多表单,那么这项技术适合你。

网络应用程序中的表单由一个包含用于输入数据的 HTML 元素(如<input><select>元素)以及用于与数据交互的<button>元素的<form>HTML 元素组成。表单可以本地检索和保存数据,或将其发送到服务器进行进一步处理。以下是一个用于在 Web 应用程序中登录用户的简单表单示例:

<form> 
  <div>
    <input type="text" name="username" placeholder="Username" /> 
  </div> 
  <div>
    <input type="password" name="password" placeholder="Password" /> 
  </div> 
  <button type="submit">Login</button> 
</form> 

前面的表单有两个<input>元素:一个用于输入用户名,另一个用于输入密码。password字段的类型设置为password,这样在输入时输入控件的内容是不可见的。<button>元素的类型设置为submit,这样表单可以通过用户点击按钮或按下任何输入控件上的Enter键来收集数据。

如果我们想要重置表单数据,可以添加另一个具有 reset 类型的按钮。

注意,一个 HTML 元素必须位于 <form> 元素内部,才能成为其一部分。以下截图显示了在页面上渲染的表单外观:

图形用户界面,文本,应用程序  自动生成的描述

图 10.1:登录表单

通过使用提供如输入控件中的自动完成或提示用户保存敏感数据等功能的表单,Web 应用程序可以显著提升用户体验。现在我们已经了解了 Web 表单的外观,让我们学习所有这些如何在 Angular 框架中结合在一起。

构建模板驱动的表单

模板驱动的表单是两种不同的将表单集成到 Angular 的方式之一。在需要为我们的 Angular 应用程序创建小型和简单表单的情况下,这些功能可能非常强大。

我们在 第三章使用组件构建用户界面 中学习了数据绑定,以及我们如何使用不同类型从 Angular 组件中读取数据并将其写入。在这种情况下,绑定可以是单向的或双向的,称为 单向绑定。在模板驱动的表单中,我们可以结合两种方式,创建一个可以同时读取和写入数据的 双向绑定。模板驱动的表单提供了 ngModel 指令,我们可以在我们的组件中使用它来获得这种行为。要了解更多关于模板驱动的表单,我们将把产品详情组件的更改价格功能转换为与 Angular 表单一起工作。

为了跟随本章的其余部分,你需要我们创建在 第九章使用路由导航应用程序 中的 Angular 应用程序的源代码。

让我们开始吧:

  1. 打开 product-detail.component.ts 文件,并添加以下 import 语句:

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

我们使用来自 @angular/forms npm 包的 FormsModule 类将模板驱动的表单添加到 Angular 应用程序中。

  1. @Component 装饰器的 imports 数组中添加 FormsModule

    @Component({
      selector: 'app-product-detail',
      imports: [CommonModule, **FormsModule**],
      templateUrl: './product-detail.component.html',
      styleUrl: './product-detail.component.css'
    }) 
    
  2. 打开 product-detail.component.html 文件,并按如下方式修改 <input> 元素:

    <input placeholder="New price" type="number" name="price" [(ngModel)]="product.price" /> 
    

在前面的代码片段中,我们将 product 模板变量的 price 属性绑定到 <input> 元素的 ngModel 指令。name 属性是必需的,这样 Angular 可以在内部创建一个唯一的表单控件来区分它。

ngModel 指令的语法被称为 香蕉盒,我们通过以下两个步骤创建它。首先,我们通过括号 ()ngModel 包围起来制作出 香蕉。然后,我们通过方括号 [()] 将它放入 盒子 中。

  1. 修改 <button> 元素如下:

    <button class="secondary" type="submit">Change</button> 
    

在前面的代码片段中,我们从 <button> 元素中移除了 click 事件,因为提交表单将更新价格。我们还添加了 submit 类型来表示表单提交可以通过用户点击按钮来实现。

  1. <input><button> 元素包裹在以下 <form> 元素中:

    **<form (ngSubmit)="changePrice(product)">**
      <input placeholder="New price" type="number" name="price" [(ngModel)]="product.price" />
      <button class="secondary" type="submit">Change</button>
    **</form>** 
    

在前面的代码片段中,我们将 changePrice 方法绑定到表单的 ngSubmit 事件。如果我们在输入框内按下 Enter 键或点击按钮,绑定将触发方法执行。ngSubmit 事件是 Angular FormsModule 的一部分,它挂钩于 HTML 表单的本地 submit 事件。

  1. 打开 product-detail.component.ts 文件,并按如下方式修改 changePrice 方法:

    changePrice(product: Product) {
      this.productService.updateProduct(
        product.id,
        product.price
      ).subscribe(() => this.router.navigate(['/products']));
    } 
    
  2. 使用 ng serve 命令运行应用程序,并从列表中选择一个产品。

  3. 你会注意到当前产品价格已经显示在输入框中。尝试更改价格,你会注意到当你键入时,产品的当前价格也在变化:

img

图 10.2:双向绑定

前面图像中所示的应用程序行为是双向绑定和 ngModel 的魔法所在。

当 AngularJS 在 2010 年推出时,双向绑定是其最大的卖点。在当时使用纯 JavaScript 和 jQuery 实现该行为是复杂的。

当我们在输入框中键入时,ngModel 指令会更新产品价格的价值。新价格会直接反映在模板中,因为我们使用了 Angular 插值语法来显示其值。

在我们的案例中,在输入新价格的同时更新当前产品价格是一种糟糕的用户体验。用户应该能够始终查看产品的当前价格。我们将修改产品详情组件,以便正确显示价格:

  1. 打开 product-detail.component.ts 文件,并在 ProductDetailComponent 类中创建一个 price 属性:

    price: number | undefined; 
    
  2. changePrice 方法修改为使用 price 组件属性:

    changePrice(product: Product) {
      this.productService.updateProduct(
        product.id,
        **this.price!**
      ).subscribe(() => this.router.navigate(['/products']));
    } 
    
  3. 打开 product-detail.component.html 文件,并将 <input> 元素中的绑定替换为使用新的组件属性:

    <input placeholder="New price" type="number" name="price" [(ngModel)]="**price**" /> 
    

如果我们运行应用程序并尝试在 新价格 输入框中输入新价格,我们会注意到显示的当前价格没有变化。更改价格的功能也像以前一样正常工作。

我们已经看到,当创建小型和简单的表单时,模板驱动的表单非常有用。在下一节中,我们将更深入地探讨 Angular 框架提供的另一种方法:响应式表单。

构建响应式表单

如其名所示,响应式表单能够动态地提供对网页表单的访问。它们是考虑到响应性而构建的,其中输入控件及其值可以通过可观察流进行操作。它们还保持表单数据的不可变状态,这使得它们更容易测试,因为我们有信心可以明确且一致地修改表单状态。

响应式表单采用程序化方法来创建表单元素和设置验证规则,通过在组件类中设置一切来实现。在此方法中涉及的 Angular 关键类如下:

  • FormControl:表示单个表单控件,例如 <input> 元素。

  • FormGroup:表示一组表单控件。<form> 元素是响应式表单层次结构中最顶层的 FormGroup

  • FormArray:表示一组表单控件,就像 FormGroup 一样,但可以在运行时进行修改。例如,我们可以根据需要动态添加或删除 FormControl 对象。

前面的类都来自 @angular/forms npm 包,并包含可用于以下场景的属性:

  • 根据表单或控件的状态渲染不同的 UI

  • 检查我们是否与表单或控件进行了交互

我们将通过 Angular 应用程序中的示例来探索每个表单类。在下一节中,我们将使用产品创建组件在我们的应用程序中介绍响应式表单:

与响应式表单交互

我们构建的 Angular 应用程序包含一个用于添加新产品的组件。该组件使用模板引用变量来收集输入数据。我们将使用 Angular 表单 API 通过响应式表单来完成相同任务:

  1. 打开 product-create.component.ts 文件并添加以下 import 语句:

    import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; 
    
  2. @Component 装饰器的 imports 数组中添加 ReactiveFormsModule 类:

    @Component({
      selector: 'app-product-create',
      imports: [**ReactiveFormsModule**],
      templateUrl: './product-create.component.html',
      styleUrl: './product-create.component.css'
    }) 
    

Angular 表单库提供了 ReactiveFormsModule 类,用于在 Angular 应用程序中创建响应式表单。

  1. ProductCreateComponent 类中定义以下 productForm 属性:

    productForm = new FormGroup({
      title: new FormControl('', { nonNullable: true }),
      price: new FormControl<number | undefined>(undefined, { nonNullable: true }),
      category: new FormControl('', { nonNullable: true })
    }); 
    

FormGroup 构造函数接受一个包含表单控件键值对的对象。键是唯一的控件名称,值是 FormControl 实例。FormControl 构造函数接受控件在第一个参数中的默认值。对于 titlecategory 控件,我们传递一个空字符串,这样我们就不设置任何初始值。对于应该接受数字作为值的 price 控件,我们将其初始值设置为 undefined。传递给 FormControl 的第二个参数是一个对象,它将 nonNullable 属性设置为指示控件不接受空值。

  1. 在我们创建了表单组和其控件之后,我们需要将它们与模板中相应的 HTML 元素关联起来。打开 product-create.component.html 文件,并在 <input><select><button> HTML 元素周围添加以下 <form> 元素:

    **<form [formGroup]="productForm">**
      <div>
        <label for="title">Title</label>
        <input id="title" #title />
      </div>
      <div>
        <label for="price">Price</label>
        <input id="price" #price type="number" />
      </div>
      <div>
        <label for="category">Category</label>
        <select id="category" #category>
          <option>Select a category</option>
          <option value="electronics">Electronics</option>
          <option value="jewelery">Jewelery</option>
          <option>Other</option>
        </select>
      </div>
      <div>
        <button (click)="createProduct(title.value, price.value, category.value)">Create</button>
      </div>
    **</form>** 
    

在前面的模板中,我们使用从 ReactiveFormsModule 类导出的 formGroup 指令将 FormGroup 实例连接到 <form> 元素。

  1. ReactiveFormsModule 类还导出了 formControlName 指令,我们使用它将 FormControl 实例连接到 HTML 元素。按照以下方式修改表单 HTML 元素:

    <div>
      <label for="title">Title</label>
      <input id="title" **formControlName="title"** />
    </div>
    <div>
      <label for="price">Price</label>
      <input id="price" **formControlName="price"** type="number" />
    </div>
    <div>
      <label for="category">Category</label>
      <select id="category" **formControlName="category"**>
        <option>Select a category</option>
        <option value="electronics">Electronics</option>
        <option value="jewelery">Jewelery</option>
        <option>Other</option>
      </select>
    </div> 
    

在前面的代码片段中,我们将formControlName指令的值设置为相应的FormControl实例的名称。我们还删除了模板引用变量,因为我们可以直接从FormGroup实例获取它们的值。

  1. 根据需要在product-create.component.ts文件中修改createProduct方法:

    createProduct() {
      this.productsService.addProduct(this.productForm.value).subscribe(() => {
        this.router.navigate(['/products']);
      });
    } 
    

在前面的方法中,我们使用FormGroup类的value属性来获取表单值。

注意,value属性不包括表单禁用字段的值。相反,我们可以使用getRawValue方法来返回所有字段的值。

在这种情况下,我们可以使用表单值,因为表单模型与Product接口相同。

如果它不同,我们可以使用FormGroup类的controls属性来单独获取表单控件值,如下所示:

createProduct() {
  this.productsService.addProduct({
    **title: this.productForm.controls.title.value,**
 **price: this.productForm.controls.price.value,**
 **category: this.productForm.controls.category.value**
  }).subscribe(() => {
    this.router.navigate(['/products']);
  });
} 

FormControl类包含一个value属性,它返回表单控件的值。

  1. product-create.component.html文件中修改<form>元素,以便在表单提交时创建新产品:

    <form [formGroup]="productForm" **(ngSubmit)="createProduct()"**>
      <div>
        <label for="title">Title</label>
        <input id="title" formControlName="title" />
      </div>
      <div>
        <label for="price">Price</label>
        <input id="price" formControlName="price" type="number" />
      </div>
      <div>
        <label for="category">Category</label>
        <select id="category" formControlName="category">
          <option>Select a category</option>
          <option value="electronics">Electronics</option>
          <option value="jewelery">Jewelery</option>
          <option>Other</option>
        </select>
      </div>
      <div>
        <button **type="submit"**>Create</button>
      </div>
    </form> 
    
  2. 打开全局styles.css文件并添加以下 CSS 样式:

    label {
      margin-bottom: 4px;
      display: block;
    } 
    

我们希望前面的样式在全局范围内可用,因为我们将在本章后面的购物车组件中使用它们。

  1. 打开product-create.component.css文件并删除<label>标签的样式。

如果我们运行应用程序,我们会看到添加新产品的功能仍然按预期工作。

我们了解到FormGroup类将一组表单控件分组。表单控件可以是一个单独的表单控件或另一个表单组,正如我们将在下一节中看到的。

创建嵌套表单层次结构

产品创建组件由一个包含三个表单控件的单一表单组组成。在企业应用程序中,某些用例需要更高级的表单,这些表单涉及创建嵌套的表单组层次结构。考虑以下表单,它用于添加新产品及其附加详细信息:

包含文本的图片,屏幕截图,字体,编号  自动生成的描述

图 10.3:带有附加信息的新产品表单

前面的表单可能看起来像一个单一的表单组,但如果我们深入查看组件类,我们会看到productForm由两个FormGroup实例组成,一个嵌套在另一个内部:

productForm = new FormGroup({
  title: new FormControl('', { nonNullable: true }),
  price: new FormControl<number | undefined>(undefined, { nonNullable: true }),
  category: new FormControl('', { nonNullable: true }),
  **extra: new FormGroup({**
    **image: new FormControl(''),**
    **description: new FormControl('')**
  **})**
}); 

productForm属性是父表单组,而extra是其子项。一个父表单组可以有它需要的任意多个子表单组。如果我们查看组件模板,我们会看到子表单组与父表单组定义不同:

<form [formGroup]="productForm" (ngSubmit)="createProduct()">
  <div>
    <label for="title">Title</label>
    <input id="title" formControlName="title" />
  </div>
  <div>
    <label for="price">Price</label>
    <input id="price" formControlName="price" type="number" />
  </div>
  <div>
    <label for="category">Category</label>
    <select id="category" formControlName="category">
      <option>Select a category</option>
      <option value="electronics">Electronics</option>
      <option value="jewelery">Jewelery</option>
      <option>Other</option>
    </select>
  </div>
  <h2>Additional details</h2>
  **<form formGroupName="extra">**
    **<div>**
      **<label for="descr">Description</label>**
      **<input id="descr" formControlName="description" />**
    **</div>**
    **<div>**
      **<label for="photo">Photo URL</label>**
      **<input id="photo" formControlName="image" />**
    **</div>**
  **</form>**
  <div>
    <button type="submit">Create</button>
  </div>
</form> 

在前面的 HTML 模板中,我们使用formGroupName指令将内部表单元素绑定到extra属性。

你可能期望直接将其绑定到 productForm.extra 属性,但 Angular 非常聪明,因为它理解 extraproductForm 的子表单组。它能推断出这个信息,因为与 extra 相关的表单元素位于绑定到 productForm 属性的表单元素内部。

在嵌套表单层次结构中,子表单组的值与其父表单共享。在我们的例子中,extra 表单组的值将包含在 productForm 组中,从而保持一致的表单模型。

我们已经涵盖了 FormGroupFormControl 类。在下一节中,我们将学习如何使用 FormArray 类与动态表单进行交互。

动态修改表单

考虑以下场景:我们在我们的电子商务应用的购物车中添加了一些产品,并想在结账前更新它们的数量。

目前,我们的应用程序没有购物车的任何功能,因此我们现在将添加一个:

  1. 运行以下命令以创建 Cart 接口:

    ng generate interface Cart 
    
  2. 打开 cart.ts 文件,并按如下方式修改 Cart 接口:

    export interface Cart {
     **id: number;**
      **products: { productId :number }[];**
    } 
    

在前面的代码片段中,products 属性将包含属于当前购物车的产品 ID。

  1. 通过运行以下 Angular CLI 命令创建一个新的服务来管理购物车:

    ng generate service cart 
    
  2. 打开 cart.service.ts 文件,并按如下方式修改 import 语句:

    import { Injectable, **inject** } from '@angular/core';
    **import { HttpClient } from '@angular/common/http';**
    **import { Observable, defer, map } from 'rxjs';**
    **import { Cart } from './cart';**
    **import { APP_SETTINGS } from './app.settings';** 
    
  3. CartService 类中创建以下属性:

    cart: Cart | undefined;
    private cartUrl = inject(APP_SETTINGS).apiUrl + '/carts'; 
    

cartUrl 属性用于 Fake Store API 的购物车端点,而 cart 属性用于保存用户购物车的本地缓存。

  1. constructor 中注入 HttpClient 服务:

    constructor(**private http: HttpClient**) { } 
    
  2. 添加以下方法以将产品添加到购物车:

    addProduct(id: number): Observable<Cart> {
      const cartProduct = { productId: id, quantity: 1 };
    
      return defer(() =>
        !this.cart
        ? this.http.post<Cart>(this.cartUrl, { products: [cartProduct] })
        : this.http.put<Cart>(`${this.cartUrl}/${this.cart.id}`, {
          products: [
            ...this.cart.products,
            cartProduct
          ]
        })
      ).pipe(map(cart => this.cart = cart));
    } 
    

在前一种方法中,我们使用了一个名为 defer 的新 RxJS 操作符。defer 操作符在观察者中充当 if/else 语句的作用。

如果 cart 属性尚未初始化,这意味着我们的购物车目前为空,我们将向 API 发起一个 POST 请求,并将 cartProduct 变量作为参数传递。否则,我们将发起一个包含 cartProduct 以及购物车中现有产品的 PATCH 请求。

我们已经完成了服务的设置,使其能够与 Fake Store API 进行通信。现在,我们需要将服务与相应的组件连接起来:

  1. 打开 product-detail.component.ts 文件,并添加以下 import 语句:

    import { CartService } from '../cart.service'; 
    
  2. ProductDetailComponent 类中注入 CartService

    constructor(
      private productService: ProductsService,
      public authService: AuthService,
      private route: ActivatedRoute,
      private router: Router,
      **private cartService: CartService**
    ) { } 
    
  3. 修改 addToCart 方法,使其调用 CartService 类的 addProduct 方法:

    addToCart(**id: number**) {
      **this.cartService.addProduct(id).subscribe();**
    } 
    
  4. 最后,打开 product-detail.component.html 文件,并修改 Add to cart 按钮的 click 事件:

    <button (click)="addToCart(**product.id**)">Add to cart</button> 
    

我们已经实现了存储用户想要购买的产品的基本功能。现在,我们必须修改购物车组件以显示购物车项目:

  1. 打开 cart.component.ts 文件,并按如下方式修改 import 语句:

    import { Component, **OnInit** } from '@angular/core';
    **import {**
      **FormArray,**
      **FormControl,**
      **FormGroup,**
      **ReactiveFormsModule**
    **} from '@angular/forms';**
    **import { Product } from '../product';**
    **import { CartService } from '../cart.service';**
    **import { ProductsService } from '../products.service';** 
    
  2. @Component装饰器的imports数组中添加ReactiveFormsModule类:

    @Component({
      selector: 'app-cart',
      imports: [**ReactiveFormsModule**],
      templateUrl: './cart.component.html',
      styleUrl: './cart.component.css'
    }) 
    
  3. OnInit接口添加到CartComponent类的实现接口列表中:

    export class CartComponent **implements OnInit** 
    
  4. 在 TypeScript 类中创建以下属性:

    cartForm = new FormGroup({
      products: new FormArray<FormControl<number>>([])
    });
    products: Product[] = []; 
    

在前面的代码片段中,我们创建了一个包含products属性的FormGroup对象。我们将products属性的值设置为FormArray类的实例。FormArray类的构造函数接受一个参数,该参数是一个具有number类型的FormControl实例列表。目前这个列表是空的,因为购物车中没有产品。FormGroup实例外的products属性将用于查找原因,以显示购物车中每个产品的标题。

  1. 添加一个constructor来注入以下服务:

    constructor(
      private cartService: CartService,
      private productsService: ProductsService
    ) {} 
    
  2. 创建以下方法以从购物车获取产品:

    private getProducts() {
      this.productsService.getProducts().subscribe(products => {
        this.cartService.cart?.products.forEach(item => {
          const product = products.find(p => p.id === item.productId);
          if (product) {
            this.products.push(product);
          }
        });
      });
    } 
    

在前面的方法中,我们最初订阅了ProductsService类的getProducts方法以获取可用产品。然后,对于购物车中的每个产品,我们提取productId属性并检查它是否在购物车中存在。如果找到产品,我们就将其添加到products组件属性中。

  1. 创建另一个方法来构建我们的表单:

    private buildForm() {
      this.products.forEach(() => {
        this.cartForm.controls.products.push(
          new FormControl(1, { nonNullable: true })
        );
      });
    } 
    

在前面的方法中,我们遍历products属性并为products表单数组中的每个产品添加一个FormControl实例。我们将每个表单控件的值设置为1,以表示购物车默认包含每种产品的一个项目。

  1. 创建以下ngOnInit方法,该方法结合了步骤 6步骤 7

    ngOnInit(): void {
      this.getProducts();
      this.buildForm();
    } 
    
  2. 打开cart.component.html文件,并用以下内容替换其 HTML 模板:

    <div [formGroup]="cartForm">
      <div formArrayName="products">
        @for(product of cartForm.controls.products.controls; track $index) {
          <label>{{products[$index].title}}</label>
          <input [formControlName]="$index" type="number" />
        }
      </div>
    </div> 
    

在前面的模板中,我们使用@for块遍历products表单数组的controls属性并为每个创建一个<input>元素。我们使用@for块的$index关键字通过formControlName绑定动态地为每个表单控件提供一个名称。我们还添加了一个<label>标签,用于显示products组件属性中的产品标题。产品标题是通过使用数组中当前产品的$index获取的。

  1. 最后,打开cart.component.css文件并添加以下 CSS 样式:

    :host {
      width: 500px;
    }
    input {
      width: 50px;
    } 
    

要查看购物车组件的实际效果,请使用ng serve命令运行应用程序并将一些产品添加到购物车中。

不要忘记先登录,因为将产品添加到购物车的功能仅对认证用户可用。

在将一些产品添加到购物车后,点击我的购物车链接以查看您的购物车。它应该看起来像以下这样:

包含文本的图片、屏幕截图、字体、图表 自动生成的描述

图 10.4:购物车

由于我们已经为管理购物车建立了业务逻辑,我们也可以更新上一章中创建的结账守卫:

  1. 打开checkout.guard.ts文件并添加以下import语句:

    import { inject } from '@angular/core';
    import { CartService } from './cart.service'; 
    
  2. 使用以下语句在checkoutGuard函数中注入CartService类:

    const cartService = inject(CartService); 
    
  3. 修改checkoutGuard箭头函数的剩余部分,以便仅在购物车不为空时显示确认对话框:

    **if (cartService.cart) {**
      const confirmation = confirm(
        'You have pending items in your cart. Do you want to continue?'
      );
      return confirmation;
    **}**
    **return true;** 
    

使用FormArray,我们已经完成了对 Angular 表单最基本构建块的探索。我们学习了如何使用 Angular 表单类创建结构化 Web 表单并收集用户输入。在下一节中,我们将学习如何使用FormBuilder服务构建 Angular 表单。

使用表单构建器

使用表单类构建 Angular 表单可能会在复杂场景中变得重复和繁琐。Angular 框架提供了FormBuilder,这是一个内置的 Angular 表单服务,包含用于构建表单的辅助方法。让我们看看我们如何使用它来构建创建新产品的表单:

  1. 打开product-create.component.ts文件并导入OnInitFormBuilder组件:

    import { Component, **OnInit** } from '@angular/core';
    import { FormControl, FormGroup, ReactiveFormsModule, **FormBuilder** } from '@angular/forms'; 
    
  2. OnInit添加到ProductCreateComponent类实现的接口列表中:

    export class ProductCreateComponent **implements OnInit** 
    
  3. constructor中注入FormBuilder类:

    constructor(
      private productsService: ProductsService,
      private router: Router,
      **private builder: FormBuilder**
    ) {} 
    
  4. 按照以下方式修改productForm属性:

    productForm: FormGroup<{
      title: FormControl<string>,
      price: FormControl<number | undefined>,
      category: FormControl<string>
    }> | undefined; 
    

在前面的代码片段中,我们只定义了表单的结构,因为它现在将使用FormBuilder服务创建。

  1. 创建以下方法来构建表单:

    private buildForm() {
      this.productForm = this.builder.nonNullable.group({
        title: [''],
        price: this.builder.nonNullable.control<number | undefined>(undefined),
        category: ['']
      });
    } 
    

在前面的方法中,我们使用FormBuilder类的nonNullable属性创建一个不能为空的表单组。group方法用于组合表单控件。titlecategory表单控件使用空字符串作为默认值创建。price表单控件采用与其他不同的方法,因为我们不能因为 TypeScript 语言限制而分配undefined作为默认值。在这种情况下,我们使用nonNullable属性的control方法来定义表单控件。

  1. ngOnInit生命周期钩子添加到执行buildForm方法:

    ngOnInit(): void {
      this.buildForm();
    } 
    
  2. createProduct方法中访问productForm属性时添加非空断言运算符:

    createProduct() {
      this.productsService.addProduct(this.**productForm!**.value).subscribe(() => {
        this.router.navigate(['/products']);
      });
    } 
    
  3. 打开product-create.component.html文件,并在<form>HTML 元素中也添加非空断言运算符:

    <form [formGroup]="**productForm!**" (ngSubmit)="createProduct()">
      <div>
        <label for="title">Title</label>
        <input id="title" formControlName="title" />
      </div>
      <div>
        <label for="price">Price</label>
        <input id="price" formControlName="price" type="number" />
      </div>
      <div>
        <label for="category">Category</label>
        <select id="category" formControlName="category">
          <option>Select a category</option>
          <option value="electronics">Electronics</option>
          <option value="jewelery">Jewelery</option>
          <option>Other</option>
        </select>
      </div>
      <div>
        <button type="submit">Create</button>
      </div>
    </form> 
    

使用FormBuilder服务创建 Angular 表单时,我们不需要显式处理FormGroupFormControl数据类型,尽管底层正在创建这些类型。

使用ng serve命令运行应用程序,并验证新产品创建过程是否正确工作。尝试在不输入任何表单控件值的情况下点击创建按钮,并观察产品列表中的情况。应用程序创建了一个标题为空的产物。这是我们应在实际场景中避免的情况。我们应该意识到表单控件的状态并相应地采取行动。

本章其余部分的示例代码在处理响应式表单时没有使用FormBuilder服务。

在下一节中,我们将调查我们可以检查的不同属性,以获取表单状态并向用户提供反馈。

在表单中验证输入

一个 Angular 表单应该验证输入并提供视觉反馈以增强用户体验并指导用户成功完成表单。我们将探讨以下在 Angular 应用程序中验证表单的方法:

  • 使用 CSS 的全局验证

  • 组件类中的验证

  • 组件模板中的验证

  • 构建自定义验证器

在下一节中,我们将学习如何在 Angular 应用程序中使用 CSS 样式全局应用验证规则。

使用 CSS 的全局验证

Angular 框架在表单和模板驱动或响应式表单中自动设置以下 CSS 类,我们可以使用它们来提供用户反馈:

  • ng-untouched : 表示我们尚未与表单交互

  • ng-touched : 表示我们已与表单交互

  • ng-dirty : 表示我们已经向表单设置了一个值

  • ng-pristine : 表示我们尚未修改表单

此外,Angular 还会在表单控制的 HTML 元素上添加以下类:

  • ng-valid : 表示表单的值有效

  • ng-invalid : 表示表单的值无效

Angular 根据其状态在表单及其控件中设置上述 CSS 类。表单状态是根据其控件的状态评估的。例如,如果至少有一个表单控件无效,Angular 将设置ng-invalid CSS 类到表单和相应的控件。

在嵌套表单层次结构的情况下,子表单组的状态会冒泡到层次结构中,并与父表单共享。

我们可以使用内置的 CSS 类和仅使用 CSS 来样式化 Angular 表单。例如,为了在第一次与控件交互时在输入控件中显示浅蓝色高亮边框,我们应该添加以下样式:

input.ng-touched {
  border: 3px solid lightblue;
} 

我们还可以根据应用程序的需要组合 CSS 类:

  1. 打开全局的styles.css文件并按如下方式修改input.valid样式:

    input.valid, **input.ng-dirty.ng-valid** {
      border: solid green;
    } 
    

上述样式将在用户输入有效值时显示绿色边框。

  1. 根据需要修改input.invalid样式:

    input.invalid, **input.ng-dirty.ng-invalid** {
      border: solid red;
    } 
    

上述样式将在用户输入无效值时显示红色边框。

  1. 打开product-create.component.html文件并在<input>表单控件中添加required属性:

    <div>
      <label for="title">Title</label>
      <input id="title" formControlName="title" **required** />
    </div>
    <div>
      <label for="price">Price</label>
      <input id="price" formControlName="price" type="number" **required** />
    </div> 
    
  2. 使用ng serve命令运行应用程序并导航到http://localhost:4200/products/new

  3. 标题字段中输入一些文本并点击输入控件之外。注意它有一个绿色边框。

  4. 标题字段中删除文本并点击输入控件之外。现在边框应该变成红色。

我们学习了如何在模板中使用 CSS 样式定义验证规则。在下一节中,我们将学习如何在模板驱动的表单中定义它们,并使用适当的消息提供视觉反馈。

模板驱动的表单验证

在上一节中,我们了解到 Angular 在验证 Angular 表单时添加了一系列内置的 CSS 类。每个类在相应的表单模型中都有一个对应的布尔属性,无论是在模板驱动的表单还是响应式表单中:

  • untouched:表示我们尚未与表单交互

  • touched:表示我们已与表单交互

  • dirty:表示我们已经为表单设置了一个值

  • pristine:表示我们尚未修改表单

  • valid:表示表单的值有效

  • invalid:表示表单的值无效

我们可以利用前面的类来通知用户当前的表单状态。首先,让我们调查产品详情组件中更改价格过程的行为:

  1. 运行ng serve命令以启动应用程序并导航到http://localhost:4200

  2. 从列表中选择一个产品。

  3. 新价格输入框中输入一个0的值并点击更改按钮。

  4. 从列表中选择相同的产品并观察输出:

包含文本的图片,屏幕截图,字体描述自动创建

图 10.5:产品详情

组件的展示逻辑未能检测到用户可以为产品价格输入0。产品应该始终有一个价格。

产品详情组件需要验证价格值的输入,如果发现输入无效,则禁用更改按钮,并向用户显示一条信息消息。

处理验证是个人偏好或业务规范的问题。在这种情况下,我们决定通过禁用按钮并显示适当的消息来展示一种常见的验证方法。

模板驱动的验证是在组件模板中执行的。打开product-detail.component.html文件并执行以下步骤:

  1. 创建priceCtrl模板引用变量并将其绑定到ngModel属性:

    <input
      placeholder="New price"
      type="number"
      name="price"
      **#priceCtrl="ngModel"**
      [(ngModel)]="price" /> 
    

ngModel属性使我们能够访问底层表单控件模型。

  1. requiredmin验证属性添加到 HTML 元素:

    <input
      placeholder="New price"
      type="number"
      name="price"
      **required min="1"** 
      #priceCtrl="ngModel"
      [(ngModel)]="price" /> 
    

min验证属性只能与<input> HTML 元素中的number类型一起使用。它用于在数字控件使用箭头时定义最小值。

  1. 在表单的<button>元素下方添加以下<span>HTML 元素:

    @if (priceCtrl.dirty && (priceCtrl.invalid || priceCtrl.hasError('min'))) {
      <span class="help-text">Please enter a valid price</span>
    } 
    

当我们输入一个价格值然后留空或输入零时,将显示前面的 HTML 元素。我们使用表单控件模型的hasError方法来检查min验证是否抛出错误。

所有验证属性都可以使用 hasError 方法进行检查。控件的有效性状态是根据我们附加到 HTML 元素的所有验证属性的状态来评估的。

  1. <form> HTML 元素中添加一个 priceForm 模板引用变量,并将其绑定到 ngForm 属性:

    <form (ngSubmit)="changePrice(product)" **#priceForm="ngForm"**>
      <input
        placeholder="New price"
        type="number"
        name="price"
        required min="1"
        #priceCtrl="ngModel"
        [(ngModel)]="price" />
      <button class="secondary" type="submit">Change</button>
      @if (priceCtrl.dirty && (priceCtrl.invalid || priceCtrl.hasError('min'))) {
        <span class="help-text">Please enter a valid price</span>
      }    
    </form> 
    

ngForm 属性为我们提供了访问底层表单模型的权限。

  1. <button> HTML 元素的 disabled 属性绑定到表单模型的 invalid 状态:

    <button
      class="secondary"
      type="submit"
      **[disabled]="priceForm.invalid">**
      Change
    </button> 
    

    在前面的模板中,由于表单只有一个控件,我们可以直接绑定到 priceCtrl.invalid 状态。出于演示目的,我们选择表单。

  2. 打开 styles.css 文件,并为 <span> 标签和 disabled 按钮添加以下 CSS 样式:

    .help-text {
      display: flex;
      color: var(--hot-red);
      font-size: 0.875rem;
    }
    button:disabled {
      background-color: lightgrey;
      cursor: not-allowed;
    } 
    

为了验证验证是否按预期工作,执行以下步骤:

  1. 运行 ng serve 命令以启动应用程序,并从列表中选择一个产品。

  2. 新价格 输入框中输入 0 并观察输出:

包含文本的图片,屏幕截图,字体,编号  自动生成的描述

图 10.6:验证错误

  1. 输入一个有效值,并验证错误消息是否消失,以及 更改 按钮是否被启用。

  2. 新价格 输入框中留空,并验证错误消息是否再次显示,以及 更改 按钮是否被禁用。

现在我们已经学习了如何在模板驱动表单中完成验证,让我们看看如何验证响应式表单中的输入数据。

响应式表单的验证

模板驱动表单完全依赖于组件模板来执行验证。在响应式表单中,真相之源是我们组件 TypeScript 类中驻留的表单模型。我们在构建 FormGroup 实例时程序化地定义响应式表单中的验证规则。

为了演示响应式表单中的验证,我们将在产品创建组件中添加验证规则:

  1. 打开 product-create.component.ts 文件,并从 @angular/forms npm 包中导入 Validators 类:

    import {
      FormControl,
      FormGroup,
      ReactiveFormsModule,
      **Validators**
    } from '@angular/forms'; 
    
  2. 修改 productForm 属性的声明,以便 titleprice 表单控件在 FormControl 实例中传递一个 validators 属性:

    productForm = new FormGroup({
      title: new FormControl('', {
        nonNullable: true,
        **validators: Validators.required**
      }),
      price: new FormControl<number | undefined>(undefined, {
        nonNullable: true,
        **validators: [Validators.required, Validators.min(1)]**
      }),
      category: new FormControl('', { nonNullable: true })
    }); 
    

Validators 类包含每个可用验证规则的静态字段。它包含几乎与模板驱动表单中可用的相同验证规则。我们可以通过将它们添加到数组中,如 price 表单控件中的 validators 属性所示,来组合多个验证器。

当我们使用 FormControl 类添加验证器时,我们可以从 HTML 模板中删除相应的 HTML 属性。然而,出于可访问性的目的,建议保留它,以便屏幕阅读器应用程序可以使用它。

  1. 打开 product-create.component.html 文件,并使用 productForm 属性的 invalid 属性来禁用 创建 按钮:

    <button type="submit" **[disabled]="productForm.invalid"**>Create</button> 
    
  2. 在每个<input>表单控件中添加一个<span> HTML 元素,以在控件被触摸且required验证抛出错误时显示错误消息:

    <div>
      <label for="title">Title</label>
      <input id="title" formControlName="title" required />
      **@if (productForm.controls.title.touched && productForm.controls.title.invalid) {**
        **<span class="help-text">Title is required</span>**
      **}**
    </div>
    <div>
      <label for="price">Price</label>
      <input id="price" formControlName="price" type="number" required />
      **@if (productForm.controls.price.touched && productForm.controls.price.invalid) {**
    **<span class="help-text">Price is required</span>**
    **}**
    </div> 
    

在前面的代码片段中,我们使用productForm属性的controls属性来访问单个表单控件模型并获取它们的状态。

  1. 根据验证规则显示不同的消息会很方便。例如,当price控件的min验证抛出错误时,我们可以显示一个更具体的消息。我们可以使用前面章节中看到的hasError方法来显示这样的消息:

    <div>
      <label for="price">Price</label>
      <input id="price" formControlName="price" type="number" required />
      **@if (productForm.controls.price.touched && productForm.controls.price.hasError('required')) {**
        **<span class="help-text">Price is required</span>**
      **}**
      **@if (productForm.controls.price.touched && productForm.controls.price.hasError('min')) {**
        **<span class="help-text">Price should be greater than 0</span>**
      **}**
    </div> 
    

Angular 框架提供了一套内置验证器,我们已经在我们的表单中学习了如何使用它们。在下一节中,我们将学习如何为模板驱动和响应式表单创建自定义验证器以满足特定的业务需求。

构建自定义验证器

内置验证器可能无法涵盖我们在 Angular 应用程序中可能遇到的所有场景;然而,编写自定义验证器并在 Angular 表单中使用它是很容易的。在我们的例子中,我们将构建一个验证器来检查产品的价格不能超过指定的阈值。

我们可以使用内置的max验证器来完成同样的任务。然而,我们将为了学习目的构建验证器函数。

当我们想要使用自定义代码验证表单或控件时,会使用自定义验证器。例如,为了与 API 通信以验证值,或者执行复杂的计算以验证值。

  1. src\app文件夹中创建一个名为price-maximum.validator.ts的文件,并添加以下内容:

    import { ValidatorFn, AbstractControl, ValidationErrors } from '@angular/forms';
    export function priceMaximumValidator(price: number): ValidatorFn {
      return (control: AbstractControl): ValidationErrors | null => {
        const isMax = control.value <= price;
        return isMax ? null : { priceMaximum: true };
      };
    } 
    

表单验证器是一个返回包含指定错误或null值的ValidationErrors对象的函数。它接受将被应用到的表单控件作为参数。在前面的代码片段中,如果控件值大于通过导出函数的price参数传递的特定阈值,它将返回一个验证错误对象。否则,它返回null

验证错误对象的键指定了验证器错误的描述性名称。这是一个我们可以稍后通过控件的hasError方法进行检查的名称,以找出它是否有任何错误。验证错误对象的值可以是任何任意值,我们可以将其传递到错误消息中。

  1. 打开product-create.component.ts文件,并添加以下import语句:

    import { priceMaximumValidator } from '../price-maximum.validator'; 
    
  2. price表单控件的validators数组中添加验证器,并将阈值设置为1000

    price: new FormControl<number | undefined>(undefined, {
      nonNullable: true,
      validators: [
        Validators.required,
        Validators.min(1),
        **priceMaximumValidator(1000)**
      ]
    }) 
    
  3. product-create.component.html文件中为价格表单控件添加一个新的<span> HTML 元素:

    <div>
      <label for="price">Price</label>
      <input id="price" formControlName="price" type="number" required />
      @if (productForm.controls.price.touched && productForm.controls.price.hasError('required')) {
        <span class="help-text">Price is required</span>
      }
      @if (productForm.controls.price.touched && productForm.controls.price.hasError('min')) {
        <span class="help-text">Price should be greater than 0</span>
      }
      **@if (productForm.controls.price.touched && productForm.controls.price.hasError('priceMaximum')) {**
        **<span class="help-text">Price must be smaller or equal to 1000</span>**
      }
    </div> 
    
  4. 运行ng serve命令以启动应用程序并导航到http://localhost:4200/products/new

  5. 价格字段中输入1200的值,点击输入框外部,并观察输出结果:

包含文本的图像,屏幕截图,字体,编号  自动生成的描述

图 10.7:响应式表单中的验证

要在模板驱动的表单中使用价格最大值验证器,我们必须遵循不同的方法,该方法涉及创建 Angular 指令:

  1. 运行以下命令创建 Angular 指令:

    ng generate directive price-maximum 
    

前面的指令将作为我们已创建的priceMaximumValidator函数的包装器。

  1. 打开price-maximum.directive.ts文件并按如下方式修改import语句:

    import { Directive, **input, numberAttribute** } from '@angular/core';
    **import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator****} from '@angular/forms';** 
    **import { priceMaximumValidator } from './price-maximum.validator';** 
    
  2. @Directive装饰器中添加NG_VALIDATORS提供者:

    @Directive({
      selector: '[appPriceMaximum]',
      **providers: [**
        **{**
          **provide: NG_VALIDATORS,**
          **useExisting: PriceMaximumDirective,**
          **multi: true**
        **}**
      **]**
    }) 
    

NG_VALIDATORS令牌是 Angular 表单的内置令牌,它帮助我们注册 Angular 指令作为表单验证器。在上面的代码片段中,我们使用提供者配置中的multi属性,因为我们可以使用NG_VALIDATORS令牌注册多个指令。

  1. PriceMaximumDirective类的实现接口中添加Validator接口:

    export class PriceMaximumDirective **implements Validator** 
    
  2. 添加以下输入属性,该属性将用于传递最大阈值值:

    appPriceMaximum = input(undefined, {
      alias: 'threshold',
      transform: numberAttribute
    }); 
    

在前面的属性中,我们将包含两个属性配置对象作为input函数的参数。alias属性定义了我们将用于绑定的输入属性名称。transform属性用于将输入属性值转换为不同类型。numberAttribute是 Angular 框架的内置函数,它将输入属性值转换为数字。

Angular 还包含booleanAttribute函数,该函数将输入属性值解析为布尔值。

  1. 按如下方式实现Validator接口的validate方法:

    validate(control: AbstractControl): ValidationErrors | null {
      return this.appPriceMaximum
        ? priceMaximumValidator(this.appPriceMaximum()!)(control)
        : null;
    } 
    

validate方法的签名与priceMaximumValidator函数返回的函数相同。它检查appPriceMaximum输入属性,并根据情况将值委托给priceMaximumValidator函数。

我们将在产品详情组件中使用我们创建的新指令:

  1. 打开product-detail.component.ts文件并添加以下import语句:

    import { PriceMaximumDirective } from '../price-maximum.directive'; 
    
  2. @Component装饰器的imports数组中添加PriceMaximumDirective类:

    @Component({
      selector: 'app-product-detail',
      imports: [
        CommonModule,
        FormsModule,
        **PriceMaximumDirective**
      ],
      templateUrl: './product-detail.component.html',
      styleUrl: './product-detail.component.css'
    }) 
    
  3. 打开product-detail.component.html文件并在<input>HTML 元素中添加新的验证器:

    <input
      placeholder="New price"
      type="number"
      name="price"
      required min="1"
      **appPriceMaximum threshold="500"**
      #priceCtrl="ngModel"
      [(ngModel)]="price" /> 
    
  4. 添加一个新的<span>HTML 元素,当验证器抛出错误时显示不同的消息:

    @if (priceCtrl.dirty && priceCtrl.hasError('priceMaximum')) {
      <span class="help-text">Price must be smaller or equal to 500</span>
    } 
    
  5. 运行ng serve命令以启动应用程序并从列表中选择一个产品。

  6. 新价格输入框中输入值600并观察输出:

包含文本、屏幕截图、字体样式的自动生成的描述

图 10.8:模板驱动表单中的验证

Angular 自定义验证可以同步或异步工作。在本节中,我们学习了如何使用前者。异步验证是一个高级主题,我们不会在本书中涉及。然而,你可以在angular.dev/guide/forms/form-validation#creating-asynchronous-validators了解更多信息。

在下一节中,我们将探讨操作 Angular 表单的状态。

操作表单状态

Angular 表单的状态在模板驱动和响应式表单之间有所不同。在前者中,状态是一个普通对象,而在后者中,它保存在表单模型中。在本节中,我们将学习以下概念:

  • 更新表单状态

  • 对状态变化做出反应

我们将首先探讨如何更改表单状态。

更新表单状态

在模板驱动的表单中处理表单状态相对简单。我们必须与绑定到表单控件ngModel指令的组件属性进行交互。

在响应式表单中,我们可以使用FormControl实例的value属性或FormGroup类的以下方法来更改整个表单中的值:

  • setValue:替换表单中所有控件的值

  • patchValue:更新表单中特定控件的值

setValue方法接受一个对象作为参数,该对象包含所有表单控件的键值对。如果我们想以编程方式在产品创建组件中填写产品的详细信息,以下代码片段可以作为示例:

this.productForm.setValue({
  title: 'TV monitor',
  price: 600,
  category: 'electronics'
}); 

在前面的代码片段中,传递给setValue方法的对象中的每个键都必须与每个表单控件的名称匹配。如果我们省略一个,Angular 将抛出错误。

如果我们想填写一些产品的详细信息,可以使用patchValue方法:

this.productForm.patchValue({
  title: 'TV monitor',
  category: 'electronics'
}); 

FormGroup类的setValuepatchValue方法帮助我们设置表单中的数据。

表单的另一个有趣方面是,当这些值发生变化时,我们可以收到通知,正如我们将在下一节中看到的那样。

对状态变化做出反应

在使用 Angular 表单时,一个常见的场景是我们希望在表单控件的值变化时触发副作用。副作用可以是以下任何一种:

  • 要更改表单控件的值

  • 为了发起一个 HTTP 请求来过滤表单控件的值

  • 为了启用/禁用组件模板的某些部分

在模板驱动的表单中,我们可以使用ngModel指令的扩展版本来在值变化时得到通知。ngModel指令包含以下可绑定属性:

  • ngModel:一个输入属性,用于将值传递到控件

  • ngModelChange:当控件值变化时得到通知的输出属性

我们可以在产品详情组件的<input>HTML 元素中以下方式编写ngModel绑定:

<input
  placeholder="New price"
  type="number"
  name="price"
  required min="1"
  appPriceMaximum threshold="500"
  #priceCtrl="ngModel"
  **[ngModel]="price"**
  **(ngModelChange)="price = $event"** /> 

在前面的代码片段中,我们使用属性绑定设置了ngModel输入属性的值,并使用事件绑定设置了price组件属性的值。Angular 会自动触发ngModelChange事件,并将新值包含在$event属性中。当价格表单控件的值发生变化时,我们可以使用ngModelChange事件在我们的组件中进行任何副作用。

在响应式表单中,我们使用基于可观察的 API 来响应状态变化。FormGroupFormControl类包含valueChanges可观察流,我们可以使用它来订阅并在表单或控件的值发生变化时接收通知。

我们将使用它来在类别更改时重置产品创建组件中price表单控件的值:

  1. 打开product-create.component.ts文件并从@angular/core npm 包中导入OnInit实体:

    import { Component, **OnInit** } from '@angular/core'; 
    
  2. OnInit接口添加到ProductCreateComponent类实现的接口列表中:

    export class ProductCreateComponent **implements OnInit** 
    
  3. 创建以下ngOnInit方法以订阅category表单控件的valueChanges属性:

    ngOnInit(): void {
      this.productForm.controls.category.valueChanges.subscribe(() => {
        this.productForm.controls.price.reset();
      });
    } 
    

在前面的方法中,我们通过使用FormControl类的reset方法来重置price表单控件的值。

FormControl类的valueChanges属性是一个标准的可观察流。当组件被销毁时,不要忘记取消订阅。

当然,我们还可以使用valueChanges可观察流做更多的事情;例如,我们可以通过将其发送到后端 API 来检查产品标题是否已被预留。然而,希望前面的例子已经传达了如何利用表单的响应性特性并相应地做出反应。

摘要

在本章中,我们了解到 Angular 提供了两种不同的创建表单的方法——模板驱动和响应式——并且没有一种方法比另一种更好。我们探讨了如何构建每种类型的表单以及如何对输入数据进行验证,并涵盖了自定义验证以实现额外的验证场景。我们还学习了如何更新表单的状态以及当状态中的值发生变化时如何做出反应。

在下一章中,我们将探讨处理应用程序错误的各种方法。错误处理是 Angular 应用程序的一个非常重要的特性,并且可能具有不同的来源和原因,正如我们将看到的。

第十一章:处理应用程序错误

应用程序错误是网络应用程序生命周期的一个组成部分。它们可能发生在运行时或开发应用程序期间。运行时错误的可能原因包括失败的 HTTP 请求或不完整的 HTML 表单。网络应用程序必须处理运行时错误并减轻不良影响,以确保流畅的用户体验。

开发错误通常发生在我们没有根据其语义正确使用编程语言或框架的情况下。在这种情况下,错误可能会覆盖编译器并在运行时在应用程序中暴露出来。通过遵循最佳实践和推荐的编码技术可以减轻开发错误。

在本章中,我们将学习如何在 Angular 应用程序中处理不同类型的错误,并理解来自框架本身的错误。我们将更详细地探讨以下概念:

  • 处理运行时错误

  • 揭秘框架错误

技术要求

本章中描述的代码示例可以在以下 GitHub 仓库的 ch11 文件夹中找到:

www.github.com/PacktPublishing/Learning-Angular-Fifth-Edition

处理运行时错误

在 Angular 应用程序中,最常见的运行时错误来自于与 HTTP API 的交互。输入错误的登录凭证或以错误格式发送数据可能导致 HTTP 错误。Angular 应用程序可以通过以下方式处理 HTTP 错误:

  • 在执行特定 HTTP 请求期间显式处理

  • 在应用程序的全局错误处理器中全局处理

  • 使用 HTTP 拦截器集中处理

在以下部分,我们将探讨如何处理特定 HTTP 请求中的 HTTP 错误。

捕获 HTTP 请求错误

处理 HTTP 请求中的错误通常需要手动检查错误响应对象中返回的信息。RxJS 提供了 catchError 操作符来简化这个过程。它可以在使用 pipe 操作符发起 HTTP 请求时捕获潜在的错误。

要跟随本章的其余部分,您需要我们在第十章 使用表单收集用户数据 中创建的 Angular 应用程序的源代码。

让我们看看我们如何使用 catchError 操作符来捕获在应用程序中获取产品列表时的 HTTP 错误:

  1. 打开 products.service.ts 文件,并从 rxjs npm 包中导入 catchErrorthrowError 操作符:

    import { Observable, map, of, tap, **catchError, throwError** } from 'rxjs'; 
    
  2. @angular/common/http 命名空间导入 HttpErrorResponse 接口:

    import { HttpClient, HttpParams, **HttpErrorResponse** } from '@angular/common/http'; 
    
  3. 修改 getProducts 方法:

    getProducts(limit?: number): Observable<Product[]> {
      if (this.products.length === 0) {
        const options = new HttpParams().set('limit', limit || 10);
        return this.http.get<Product[]>(this.productsUrl, {
          params: options
        }).pipe(
          map(products => {
            this.products = products;
            return products;
          }),
          **catchError((error: HttpErrorResponse) => {**
            **console.error(error);**
            **return throwError(() => error);**
          **})**
        );
      }
      return of(this.products);
    } 
    

catchError 操作符的签名包含从服务器返回的实际 HttpErrorResponse 对象。在捕获错误后,我们使用 throwError 操作符,它将错误重新抛出为一个可观察对象。

或者,我们可以使用标准 Web API 方法中的throw关键字来抛出错误。然而,throwError方法通常过于强大。请相应地使用它。

这样,我们确保应用程序执行将继续并完成,而不会造成潜在的内存泄漏。

在实际场景中,我们可能会创建一个辅助方法来在一个更稳固的跟踪系统中记录错误,并根据错误的原因返回一些有意义的信息:

  1. 在同一文件products.service.ts中,从@angular/common/http命名空间导入HttpStatusCode枚举:

    import { HttpClient, HttpParams, HttpErrorResponse, **HttpStatusCode** } from '@angular/common/http'; 
    

HttpStatusCode是一个枚举,包含所有 HTTP 响应状态码的列表。

  1. ProductsService类中创建以下方法:

    private handleError(error: HttpErrorResponse) {
      let message = '';
      switch(error.status) {
        case HttpStatusCode.InternalServerError:
          message = 'Server error';
          break;
        case HttpStatusCode.BadRequest:
          message = 'Request error';
          break;
        default:
          message = 'Unknown error';
      }
    
      console.error(message, error.error);
    
      return throwError(() => error);
    } 
    

上述方法根据错误状态在浏览器控制台中记录不同的消息。它使用switch语句来区分内部服务器错误和错误请求。对于其他任何错误,它回退到default语句,在控制台中记录一个通用的消息。

  1. 重构getProducts方法以使用handleError方法来捕获错误:

    getProducts(limit?: number): Observable<Product[]> {
      if (this.products.length === 0) {
        const options = new HttpParams().set('limit', limit || 10);
        return this.http.get<Product[]>(this.productsUrl, {
          params: options
        }).pipe(
          map(products => {
            this.products = products;
            return products;
          }),
          catchError(**this.handleError**)
        );
      }
      return of(this.products);
    } 
    

当前handleError方法仅管理来自 HTTP 响应的 HTTP 错误。然而,在 Angular 应用程序中,其他错误也可能发生,例如由于网络错误而未到达服务器的请求或在 RxJS 操作符中抛出的异常。为了处理上述任何错误,我们应该在handleError方法中添加一个新的case语句:

private handleError(error: HttpErrorResponse) {
  let message = '';
  switch(error.status) {
    **case 0:**
      **message = 'Client error';**
      **break;**
    case HttpStatusCode.InternalServerError:
      message = 'Server error';
      break;
    case HttpStatusCode.BadRequest:
      message = 'Request error';
      break;
    default:
      message = 'Unknown error';
  }

  console.error(message, error.error);

  return throwError(() => error);
} 

在前面的代码片段中,状态为0的错误表示它是在应用程序客户端发生的错误。

在 HTTP 请求中处理错误时,可以结合一个机制,在处理错误之前重试特定的 HTTP 调用特定次数。对于几乎所有事情,RxJS 都有一个操作符,甚至有一个用于重试 HTTP 请求的操作符。它接受重试次数,即特定请求必须执行直到成功完成:

getProducts(limit?: number): Observable<Product[]> {
  if (this.products.length === 0) {
    const options = new HttpParams().set('limit', limit || 10);
    return this.http.get<Product[]>(this.productsUrl, {
      params: options
    }).pipe(
      map(products => {
        this.products = products;
        return products;
      }),
      **retry(2)**,
      catchError(this.handleError)
    );
  }
  return of(this.products);
} 

我们了解到我们使用catchError RxJS 操作符来捕获错误。我们处理它的方式取决于场景。在我们的情况下,我们在服务中为所有 HTTP 调用创建了一个handleError方法。在实际场景中,我们会在应用程序的其他 Angular 服务中遵循相同的错误处理方法。为每个服务创建一个方法可能不方便,并且扩展性不好。

或者,我们可以利用 Angular 提供的全局错误处理器来在中央位置处理错误。我们将在下一节学习如何创建全局错误处理器。

创建全局错误处理器

Angular 框架提供了ErrorHandler类来处理 Angular 应用程序中的全局错误。ErrorHandler类的默认实现将在浏览器控制台窗口中打印错误消息。

要为我们自己的应用程序创建一个自定义错误处理器,我们需要对 ErrorHandler 类进行子类化,并提供我们定制的错误记录实现:

  1. 在 Angular CLI 工作区的 src\app 文件夹中创建一个名为 app-error-handler.ts 的文件。

  2. 打开文件并添加以下 import 语句:

    import { HttpErrorResponse, HttpStatusCode } from '@angular/common/http';
    import { ErrorHandler, Injectable } from '@angular/core'; 
    
  3. 创建一个实现 ErrorHandler 接口的 TypeScript 类:

    @Injectable()
    export class AppErrorHandler implements ErrorHandler {} 
    

AppErrorHandler 类必须使用 @Injectable() 装饰器进行装饰,因为我们将在应用程序配置文件中稍后提供它。

  1. 按照以下方式实现 ErrorHandler 接口的 handleError 方法:

    handleError(error: any): void {
      const err = error.rejection || error;
      let message = '';
    
      if (err instanceof HttpErrorResponse) {
        switch(err.status) {
          case 0:
            message = 'Client error';
            break;
          case HttpStatusCode.InternalServerError:
            message = 'Server error';
            break;
          case HttpStatusCode.BadRequest:
            message = 'Request error';
            break;
          default:
            message = 'Unknown error';
        }
      } else {
        message = 'Application error';
      }
      console.error(message, err);
    } 
    

在前面的方法中,我们检查 error 对象是否包含一个 rejection 属性。来自负责 Angular 中变更检测的 Zone.js 库的错误,封装了实际的错误在该属性中。

在从 err 变量中提取错误后,我们使用 HttpErrorResponse 类型检查它是否是 HTTP 错误。这个检查最终会捕获使用 throwError RxJS 操作符的任何 HTTP 调用的错误。所有其他错误都被视为客户端发生的应用错误。

  1. 打开 app.config.ts 文件,并从 @angular/core npm 包中导入 ErrorHandler 类:

    import { ApplicationConfig, **ErrorHandler**, provideZoneChangeDetection } from '@angular/core'; 
    
  2. app-error-handler.ts 文件中导入我们创建的自定义错误处理器:

    import { AppErrorHandler } from './app-error-handler'; 
    
  3. 通过将其添加到 appConfig 变量的 providers 数组中,将 AppErrorHandler 类注册为应用程序的全局错误处理器:

    export const appConfig: ApplicationConfig = {
      providers: [
        provideZoneChangeDetection({ eventCoalescing: true }),
        provideRouter(routes),
        provideHttpClient(),
        { provide: APP_SETTINGS, useValue: appSettings },
        **{ provide: ErrorHandler, useClass: AppErrorHandler }**
      ]
    }; 
    

要调查全局应用错误处理器的行为,请执行以下步骤:

  1. 运行 ng serve 命令以启动应用程序。

  2. 断开您的计算机与互联网的连接。

  3. 导航到 http://localhost:4200

  4. 打开浏览器开发者工具并检查控制台窗口的输出:

img

图 11.1:应用错误

在一个网络企业应用中最常见的 HTTP 错误之一是 401 未授权 的响应错误。我们将在下一节学习如何处理这个特定的错误。

响应 401 未授权错误

在 Angular 应用程序中,401 未授权错误可能发生在以下情况:

  • 用户在登录应用程序时没有提供正确的凭据

  • 用户登录应用程序时提供的身份验证令牌已过期

处理 401 未授权错误的好地方是在负责身份验证的 HTTP 拦截器内部。在 第八章通过 HTTP 与数据服务通信 中,我们学习了如何创建一个身份验证拦截器,以便将授权令牌传递给每个 HTTP 请求。为了处理 401 未授权错误,auth.interceptor.ts 文件可以修改如下:

import { HttpErrorResponse, HttpInterceptorFn, HttpStatusCode } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';
import { catchError, EMPTY, throwError } from 'rxjs';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const authService = inject(AuthService);
  const authReq = req.clone({
    setHeaders: { Authorization: 'myToken' }
  });
  return next(authReq).pipe(
    catchError((error: HttpErrorResponse) => {
      if (error.status === HttpStatusCode.Unauthorized) {
        authService.logout();
        return EMPTY;
      } else {
        return throwError(() => error);
      }
    })
  );
}; 

当发生 401 未授权错误时,拦截器将调用AuthService类的logout方法并返回一个EMPTY可观察对象以停止发出数据。它将使用throwError运算符将错误冒泡到全局错误处理器中的所有其他错误。正如我们之前看到的,全局错误处理器将检查返回的错误并根据状态码采取行动。

正如我们在上一节中创建的全局错误处理器中看到的,一些错误与 HTTP 客户端的交互无关。有一些应用程序错误是在客户端发生的,我们将在下一节中学习如何理解它们。

揭秘框架错误

在 Angular 应用程序中,客户端发生的应用程序错误可能有多种原因。其中之一是我们源代码与 Angular 框架的交互。开发者在构建应用程序时喜欢尝试新事物和方法。有时,一切都会顺利进行,但有时可能会在应用程序中引起错误。

Angular 框架提供了一个机制,以以下格式报告一些常见错误:

NGWXYZ: {Error message}.<Link> 

让我们分析前面的错误格式:

  • NG:表示这是一个 Angular 错误,用于区分来自 TypeScript 和浏览器的其他错误

  • W:一个一位数,表示错误的类型。0 代表运行时错误,而 1 到 9 的所有其他数字代表编译器错误

  • X:一个一位数,表示框架运行区域类别,例如变更检测、依赖注入和模板

  • YZ:一个两位数代码,用于索引特定错误

  • {错误消息}:实际的错误消息

  • <链接>:指向 Angular 文档的链接,提供有关指定错误的更多信息

符合上述格式的错误消息将在浏览器控制台发生时显示。让我们通过使用ExpressionChangedAfterChecked错误(Angular 应用程序中最著名的错误)来查看一个错误示例:

  1. 打开app.component.ts文件,从@angular/corenpm 包中导入AfterViewInit实体:

    import { **AfterViewInit**, Component, inject } from '@angular/core'; 
    
  2. AfterViewInit添加到实现接口的列表中:

    export class AppComponent **implements AfterViewInit** 
    
  3. AppComponent类中创建以下title属性:

    title = ''; 
    
  4. 实现ngAfterViewInit方法并在方法体内更改title属性:

    ngAfterViewInit(): void {
      this.title = this.settings.title;
    } 
    
  5. 打开app.component.html文件,将title属性绑定到<h2>HTML 元素:

     <h2>{{ **title** }}</h2> 
    
  6. 运行ng serve命令并导航到http://localhost:4200

初始时,一切看起来都工作正常。title属性的值在页面上正确显示。

  1. 打开浏览器开发者工具并检查控制台窗口:

    Application error RuntimeError: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: ''. Current value: 'My e-shop'. Expression location: _AppComponent component. Find more at https://angular.dev/errors/NG0100 
    

前面的消息表明更改title属性的值导致了错误。

  1. 点击angular.dev/errors/NG0100链接将带我们转到 Angular 文档中适当的错误指南,以获取更多信息。错误指南解释了具体的错误,并描述了如何在我们的应用程序代码中修复问题。

当我们理解了源自 Angular 框架的错误信息时,我们可以轻松地修复它们。

摘要

在运行时或开发过程中处理错误对于每个 Angular 应用程序至关重要。在本章中,我们学习了如何在 Angular 应用程序运行时处理错误,例如 HTTP 或客户端错误。我们还学习了如何理解和修复由 Angular 框架抛出的应用程序错误。

在下一章中,我们将学习如何在 Angular Material 的帮助下美化我们的应用程序,使其看起来更美观。Angular Material 拥有许多组件和样式,这些组件和样式已经准备好供你在项目中使用。所以,让我们给你的 Angular 项目带来应有的关爱。

第十二章:Angular Material 简介

在开发 Web 应用程序时,您必须决定如何创建您的用户界面UI)。它理想上应使用适当的对比色,具有一致的外观和感觉,响应式,并在不同的设备和浏览器上运行良好。简而言之,关于 UI 和 UX 有很多事情要考虑。许多开发者认为创建 UI/UX 是一项艰巨的任务,并转向 UI 框架来承担大部分繁重的工作。有些框架比其他框架使用得更多,即BootstrapTailwind CSS。然而,基于 Google 的Material Design技术的Angular Material框架已经获得了流行。在本章中,我们将解释 Material Design 是什么,以及 Angular Material 如何使用它为 Angular 框架提供一个组件 UI 库。我们还将通过在我们的 e-shop 应用程序中应用它们来学习使用各种 Angular Material 组件。

在本章中,我们将进行以下操作:

  • 介绍 Material Design

  • 介绍 Angular Material

  • 集成 UI 组件

技术要求

本章包含各种代码示例,以向您介绍 Angular Material 的概念。您可以在以下 GitHub 仓库的ch12文件夹中找到相关的源代码:

www.github.com/PacktPublishing/Learning-Angular-Fifth-Edition

介绍 Material Design

Material Design 是由 Google 开发的一种设计语言,其目标是:

  • 开发一个单一的基础系统,允许在各个平台和设备尺寸上提供统一的使用体验。

  • 移动原则是基本的,但触摸、语音、鼠标和键盘都是一等输入方法。

设计语言的目的在于让用户处理 UI 和用户交互在各个设备上的外观和感觉。Material Design 基于以下三个主要原则:

  • 材料是隐喻:它受到物理世界中的不同纹理和介质(如纸张和墨水)的启发。

  • 粗体、图形化和有意图的:它受到不同的印刷设计方法(如排版、网格和颜色)的指导,为用户提供沉浸式的体验。

  • 运动赋予意义:通过创建动画和交互来重新组织环境,元素在屏幕上显示。

Material Design 背后有许多理论,如果您想深入了解,可以查阅相关的适当文档。您可以在官方文档网站上找到更多信息:material.io

如果你不是设计师,设计语言本身可能并不那么有趣。在下一节中,我们将学习 Angular 开发者如何通过 Angular Material 库从 Material Design 中受益。

介绍 Angular Material

Angular Material 库是为了实现 Angular 框架的 Material Design 而开发的。它基于以下概念:

  • 从零开始构建应用:目的是让作为应用开发者的您能够迅速上手。设置所需的工作量应尽可能小。

  • 快速且一致:性能一直是重点,Angular Material 保证在所有主要浏览器上都能良好工作。

  • 通用性:许多主题应该很容易自定义,并且还有对本地化和国际化的强大支持。

  • 针对 Angular 优化:Angular 团队构建了它的事实意味着对 Angular 的支持是一个重要优先事项。

该库分为以下主要部分:

  • 组件:许多 UI 组件,如不同类型的输入、按钮、布局、导航、模态以及其他展示表格数据的方式,都已准备好以帮助您成功。

  • 主题:该库附带预安装的主题,但如果您想创建自己的主题,也可以参考material.angular.io/guide/theming中的主题指南。

Angular Material 库的每个部分和组件都封装了开箱即用的 Web 无障碍最佳实践。

Angular Material 库的核心是Angular CDK,它是一组实现与任何展示风格无关的类似交互模式的工具集合。Angular Material 组件的行为是使用 Angular CDK 设计的。Angular CDK 如此抽象,以至于您可以用它来创建自定义组件。如果您是 UI 库的作者,您应该认真考虑它。

我们已经涵盖了关于 Angular Material 的所有基本理论,所以让我们在以下部分通过将其与 Angular 应用集成来将其付诸实践。

安装 Angular Material

Angular Material 库是一个 npm 包。为了安装它,我们需要手动执行npm install命令并将几个 Angular 工件导入到我们的 Angular 应用中。Angular 团队通过创建必要的 schematics 来自动化这些交互,以便使用 Angular CLI 安装它。

您需要我们在第十一章处理应用错误中创建的 Angular 应用的源代码,以跟随本章的其余部分。

我们可以使用 Angular CLI 的ng add命令将 Angular Material 安装到我们的电子商务应用中:

  1. 在当前 Angular CLI 工作区中运行以下命令:

    ng add @angular/material 
    

Angular CLI 将找到 Angular Material 库的最新稳定版本,并提示我们下载它。

在这本书中,我们使用 Angular Material 19,它与 Angular 19 兼容。如果提示的版本不同,您应该运行命令ng add @angular/material@19将最新的 Angular Material 19 安装到您的系统中。

  1. 下载完成后,它将询问我们是否想为我们的 Angular 应用使用预构建的主题或自定义主题:

    Choose a prebuilt theme name, or "custom" for a custom theme: (Use arrow keys) 
    

通过按Enter键接受默认值Azure/Blue

  1. 选择主题后,Angular CLI 将询问我们是否想在应用程序中设置全局排版样式。排版指的是文本在我们应用程序中的排列方式:

    Set up global Angular Material typography styles? (y/N) 
    

我们希望尽可能保持应用程序的简单性,因此通过按 Enter 键接受默认值,No

Angular Material 排版基于 Material 设计指南,并使用 Roboto Google 字体进行样式设计。

  1. 接下来的问题是关于动画。动画不是严格必需的,但我们希望当点击按钮或打开模态对话框时,我们的应用程序能够显示一个漂亮的动画:

    Include the Angular animations module? (Use arrow keys) 
    

通过按 Enter 键接受默认值,Include and enable animations

Angular CLI 将开始安装和配置 Angular Material 到我们的应用程序中。它将构建和导入所有必要的组件,以便我们立即开始使用 Angular Material:

  • angular.json : 它在 Angular CLI 工作区的配置文件中添加了主题样式表文件:

    "styles": [
      **"@angular/material/prebuilt-themes/azure-blue.css",**
      "src/styles.css"
    ] 
    
  • package.json : 它添加了 @angular/cdk@angular/material npm 包。

  • index.html : 它在主 HTML 文件中添加了 Roboto 字体和 Material 图标的样式表文件。

  • styles.css : 它为 <html><body> 标签添加必要的全局 CSS 样式:

    html, body { height: 100%; }
    body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } 
    
  • app.config.ts : 它在应用程序配置文件中启用动画:

    import { provideHttpClient } from '@angular/common/http';
    import { ApplicationConfig, ErrorHandler, provideZoneChangeDetection } from '@angular/core';
    import { provideRouter } from '@angular/router';
    import { routes } from './app.routes';
    import { APP_SETTINGS, appSettings } from './app.settings';
    import { AppErrorHandler } from './app-error-handler';
    **import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';**
    export const appConfig: ApplicationConfig = {
      providers: [
        provideZoneChangeDetection({ eventCoalescing: true }),
        provideRouter(routes),
        provideHttpClient(),
        { provide: APP_SETTINGS, useValue: appSettings },
        { provide: ErrorHandler, useClass: AppErrorHandler },
        **provideAnimationsAsync()**
      ]
    }; 
    

在过程完成后,我们可以开始将 Angular Material 库中的 UI 组件添加到我们的应用程序中。

添加 UI 组件

按钮组件是 Angular Material 库中最常用的组件之一。例如,我们将学习如何轻松地将按钮组件添加到我们的电子商务应用程序中。在我们可以在 Angular 应用程序中使用它之前,我们必须删除我们迄今为止使用的所有原生 <button> 标签的 CSS 样式:

  1. 打开 styles.css 文件,并删除 buttonbutton:hoverbutton:disabled CSS 样式。

  2. 打开 product-detail.component.css 文件,并从 button.secondarybutton.delete 样式中删除 --button-accent 变量。

  3. 完全删除 .button-group CSS 样式。

  4. button.delete 样式中添加一个 color

    button.delete {
      display: inline;
      margin-left: 5px;
      **color: brown;**
    } 
    

要开始使用 Angular Material 库中的 UI 组件,我们必须导入其相应的 Angular 组件。让我们通过在 Angular 应用程序的认证组件中添加按钮组件来查看这是如何完成的:

  1. 打开 auth.component.ts 文件,并添加以下 import 语句以使用 Angular Material 按钮:

    import { MatButton } from '@angular/material/button'; 
    

我们不直接从 @angular/material 包中导入,因为每个组件都有一个专门的命名空间。按钮组件可以在 @angular/material/button 命名空间中找到。

Angular Material 组件也可以通过导入它们各自的模块来使用,例如 MatButtonModule 用于按钮。然而,我们建议直接导入组件,因为这有助于我们保持与现代 Angular 模式的一致性。但是,我们将看到一些功能需要导入太多的组件。在这些情况下,直接导入模块是可以接受的。

  1. @Component 装饰器的 imports 数组中添加 MatButton 类:

    @Component({
      selector: 'app-auth',
      imports: [**MatButton**],
      templateUrl: './auth.component.html',
      styleUrl: './auth.component.css'
    }) 
    
  2. 打开 auth.component.html 文件,并在 <button> HTML 元素中添加 mat-button 指令:

    @if (!authService.isLoggedIn()) {
      <button **mat-button** (click)="login()">Login</button>
    } @else {
      <button **mat-button** (click)="logout()">Logout</button>
    } 
    

在前面的模板中,mat-button 指令本质上修改了 <button> 元素,使其看起来并表现得像一个 Material Design 按钮。

如果我们运行 ng serve 命令并导航到 http://localhost:4200,我们会注意到按钮的样式与之前不同。它看起来更像是一个链接,这是 Material 按钮的默认外观。在下一节中,我们将学习关于主题化和按钮组件的变化。

主题化 UI 组件

Angular Material 库自带四个内置主题:

  • Azure/蓝色

  • Rose/红色

  • Magenta/紫色

  • 青色/橙色

当我们将 Angular Material 添加到 Angular 应用程序中时,我们可以选择应用前面提到的哪个主题。我们总是可以通过修改 angular.json 配置文件中包含的 CSS 样式表文件来更改它。以下是一个示例:

"styles": [
  **"/@angular/material/prebuilt-themes/azure-blue.css",**
  "src/styles.css"
] 

正如我们在前面的章节中看到的,按钮组件被显示为一个链接。当我们将鼠标悬停在按钮上时,mat-button 指令会显示背景颜色。要永久设置背景颜色,我们必须使用 mat-flat-button 指令,如下所示:

@if (!authService.isLoggedIn()) {
  <button **mat-flat-button** (click)="login()">
    Login
  </button>
} @else {
  <button **mat-flat-button** (click)="logout()">
    Logout
  </button>
} 

现在我们已经知道了如何在 Angular 应用程序中与按钮组件交互,让我们学习一些它的变化:

  1. 打开 product-create.component.ts 文件,并添加以下 import 语句:

    import { MatButton } from '@angular/material/button'; 
    
  2. @Component 装饰器的 imports 数组中添加 MatButton 类:

    @Component({
      selector: 'app-product-create',
      imports: [ReactiveFormsModule, **MatButton**],
      templateUrl: './product-create.component.html',
      styleUrl: './product-create.component.css'
    }) 
    
  3. 打开 product-create.component.html 文件,并在 <button> HTML 元素中添加 mat-raised-button 指令:

    <button
      **mat-raised-button**
      type="submit"
      [disabled]="productForm.invalid">
      Create
    </button> 
    

mat-raised-button 指令将为按钮元素添加阴影:

包含文本、标志、字体、设计 自动生成的描述

图 12.1:提升按钮

  1. 打开 product-detail.component.ts 文件并重复 步骤 1步骤 2

  2. 打开 product-detail.component.html 文件,并在 Change 按钮中添加 mat-stroked-button 指令:

    <button
      **mat-stroked-button**
      class="secondary"
      type="submit"
      [disabled]="priceForm.invalid">
      Change
    </button> 
    

mat-stroked-button 指令在按钮元素周围添加一个边框:

包含字体、标志、图形、白色 自动生成的描述

图 12.2:描边按钮

  1. 移除具有 button-group 类的 <div> HTML 元素,并在两个 <button> HTML 元素中添加 mat-raised-button 指令:

    @if (authService.isLoggedIn()) {  
      <button
        **mat-raised-button**
        (click)="addToCart(product.id)">
        Add to cart
      </button>
    }
    <button
      **mat-raised-button**
      class="delete"
      (click)="remove(product)">
      Delete
    </button> 
    

当我们运行应用程序时,两个按钮将如下所示:

包含文本、字体、标志、白色 描述由系统自动生成

图 12.3:产品详情操作按钮

  1. 打开product-list.component.ts文件,并添加以下import语句:

    import { MatMiniFabButton } from '@angular/material/button';
    import { MatIcon } from '@angular/material/icon'; 
    
  2. @Component装饰器的imports数组中添加前面导入的类:

    @Component({
      selector: 'app-product-list',
      imports: [
        SortPipe,
        AsyncPipe,
        RouterLink,
        **MatMiniFabButton,**
        **MatIcon**
      ],
      templateUrl: './product-list.component.html',
      styleUrl: './product-list.component.css'
    }) 
    
  3. 打开product-list.component.html文件,并用以下 HTML 片段替换导航到产品创建组件的锚元素:

    <button mat-mini-fab routerLink="new">
      <mat-icon>add</mat-icon>
    </button> 
    

mat-mini-fab指令显示一个带圆角的方形按钮和一个由<mat-icon>HTML 元素指示的图标。<mat-icon>元素的文本对应于 Material Design 图标集合中的add图标名称:

包含符号、标志 描述由系统自动生成

图 12.4:FAB 按钮

Angular Material 的主题非常广泛,我们可以使用现有的 CSS 变量来创建自定义主题,但这本书的范围不包括这个话题。

为了继续我们的 Angular Material 风格之旅,我们将在下一节学习如何集成各种 UI 组件。

集成 UI 组件

Angular Material 包含许多组织在类别中的 UI 组件,可以在material.angular.io/components/categories找到。在本章中,我们将探索前面集合的子集,可以归纳为以下类别:

  • 表单控件:这些可以在 Angular 表单内部使用,例如自动完成、输入和下拉列表。

  • 导航:这些提供导航功能,例如页眉和页脚。

  • 布局:这些定义了数据如何表示,例如卡片或表格。

  • 弹出窗口和覆盖层:这些是显示信息并可以阻止用户交互的覆盖窗口,直到以任何方式关闭。

在接下来的章节中,我们将更详细地探讨每个类别。

表单控件

在第十章使用表单收集用户数据中,我们了解到表单控件是关于以不同方式收集输入数据并采取进一步行动的,例如通过 HTTP 将数据发送到后端 API。

在 Angular Material 库中有很多不同类型的表单控件,具体如下:

  • 自动完成:允许用户在输入字段中开始输入,并在输入时提供建议。这有助于缩小输入可以接受的值。

  • 复选框:一个经典的复选框,表示已选中或未选中的状态。

  • 日期选择器:允许用户在日历中选择一个日期。

  • 输入:一个经典的输入控件,在输入时增强了有意义的动画。

  • 单选按钮:一个经典的单选按钮,在编辑时增强了动画和过渡,以创造更好的用户体验。

  • 选择:一个下拉控件,提示用户从列表中选择一个或多个项目。

  • 滑块:允许用户通过拉动滑块按钮向右或向左增加或减少一个值。

  • 滑动开关:用户可以滑动来设置开或关的开关。

  • 芯片:一个显示、选择和过滤项目的列表。

在以下章节中,我们将更详细地检查这些表单控件中的几个。让我们从输入组件开始。

输入

输入组件通常附加到<input> HTML 元素上。我们还可以添加在输入字段中显示错误的能力。

在我们可以在我们的 Angular 应用程序中使用输入组件之前,我们必须移除我们迄今为止使用的所有原生<input>标签的 CSS 样式:

  1. 打开styles.css文件,移除任何引用input标签的 CSS 样式。

  2. product-create.component.csscart.component.css文件中移除input CSS 样式。

要了解如何使用输入组件,我们将将其集成到我们的应用程序组件中:

  1. 打开product-create.component.ts文件,并添加以下import语句:

    import { MatInput } from '@angular/material/input';
    import { MatFormField, MatError, MatLabel } from '@angular/material/form-field'; 
    
  2. @Component装饰器的imports数组中添加前面导入的类:

    @Component({
      selector: 'app-product-create',
      imports: [
        ReactiveFormsModule,
        MatButton,
        **MatInput,**
        **MatFormField,**
        **MatError,**
        **MatLabel**
      ],
      templateUrl: './product-create.component.html',
      styleUrl: './product-create.component.css'
    }) 
    
  3. 打开product-create.component.html文件,并按以下方式替换<input> HTML 元素的<div>标签:

    <mat-form-field>
      <mat-label>Title</mat-label>
      <input formControlName="title" matInput required />
      <mat-error>Title is required</mat-error>
    </mat-form-field>
    <mat-form-field>
      <mat-label>Price</mat-label>
      <input formControlName="price" matInput type="number" required />
      @if (productForm.controls.price.touched && productForm.controls.price.hasError('required')) {
        <mat-error>Price is required</mat-error>
      }
      @if (productForm.controls.price.touched && productForm.controls.price.hasError('min')) {
        <mat-error>Price should be greater than 0</mat-error>
      }
      @if (productForm.controls.price.touched && productForm.controls.price.hasError('priceMaximum')) {
        <mat-error>Price must be smaller or equal to 1000</mat-error>
      }
    </mat-form-field> 
    

在前面的 HTML 代码片段中,我们使用matInput指令来指示<input> HTML 元素是 Angular Material 输入组件。在 Angular Material 中,表单控件必须被包含在<mat-form-field>元素中。

我们已将所有<label> HTML 元素替换为<mat-label>元素。一个<mat-label> HTML 元素是一个针对特定 Angular Material 表单控件的标签。

当 Angular 触发验证错误时,<mat-error>元素会在表单控件中显示错误消息。当表单控件的状态无效时,它默认显示。在其他所有情况下,我们可以使用@if块来控制<mat-error>元素何时显示。

  1. 打开全局styles.css文件,并添加以下 CSS 样式:

    mat-form-field {
      width: 100%;
    } 
    

在前面的代码片段中,我们配置了mat-form-field元素以占用所有可用宽度。

  1. 运行ng serve命令以启动应用程序,并导航到http://localhost:4200/products/new。关注输入字段的显示:

包含文本、屏幕截图、矩形、自动生成的描述

图 12.5:输入组件

在前面的图中,每个表单控件的标签后面都跟着一个星号。星号是表示表单控件必须具有值的常见指示。Angular Material 会自动添加它,因为它识别了<input> HTML 元素上的required属性。

  1. 打开cart.component.ts文件,重复步骤 1 和 2,但不要包含MatError类。

  2. 打开cart.component.html文件,并按以下方式修改@for块的内容:

    @for(product of cartForm.controls.products.controls; track $index) {
      **<mat-form-field>**
        **<mat-label>{{products[$index].title}}</mat-label>**
        **<input**
          **[formControlName]="$index"**
          **placeholder="{{products[$index].title}}"**
          **type="number"**
          **matInput />**
      **</mat-form-field>**
    } 
    

我们应用程序中包含<input> HTML 元素的其余组件是产品详情组件。产品详情组件是 Angular Material 输入的特殊情况,因为我们必须将其与更改产品价格的按钮组合在一起:

  1. 打开product-detail.component.ts文件,并按照以下方式修改从 Angular Material npm 包的import语句:

    import { MatButton, **MatIconButton** } from '@angular/material/button';
    **import { MatInput } from '@angular/material/input';**
    **import { MatFormField, MatError, MatSuffix } from '@angular/material/form-field';**
    **import { MatIcon } from '@angular/material/icon';** 
    
  2. @Component装饰器的imports数组中添加前面导入的类:

    @Component({
      selector: 'app-product-detail',
      imports: [
        CommonModule,
        FormsModule,
        PriceMaximumDirective,
        MatButton,
        **MatInput,**
        **MatFormField,**
        **MatError,**
        **MatIcon,**
        **MatSuffix,**
        **MatIconButton**
      ],
      templateUrl: './product-detail.component.html',
      styleUrl: './product-detail.component.css'
    }) 
    
  3. 打开product-detail.component.html文件,并按照以下方式修改<form> HTML 元素:

    <form (ngSubmit)="changePrice(product)" #priceForm="ngForm">
      **<mat-form-field>**
        <input
          placeholder="New price"
          type="number"
          name="price"
          required min="1"
          appPriceMaximum threshold="500"
          **matInput**
          #priceCtrl="ngModel"
          [(ngModel)]="price" />
        <button
          **mat-icon-button**
          **matSuffix**
          type="submit"
          [disabled]="priceForm.invalid">
          **<mat-icon>edit</mat-icon>**
        </button>    
        @if (priceCtrl.dirty && (priceCtrl.invalid || priceCtrl.hasError('min'))) {
          **<mat-error>****</mat-error>**Please enter a valid price
        }
        @if (priceCtrl.dirty && priceCtrl.hasError('priceMaximum')) {
          **<mat-error>****</mat-error>**Price must be smaller or equal to 500
        }
      **</mat-form-field>**
    </form> 
    

在前面的片段中,我们修改了更改价格的按钮,使其显示铅笔图标,并且它与<input> HTML 元素对齐。

mat-icon-button指令表示按钮将没有任何文本。相反,它将显示由<mat-icon> HTML 元素定义的图标。matSuffix指令将按钮放置在<input> HTML 元素的行内和末尾。

  1. 在浏览器中导航到产品列表并选择一个产品。更改产品价格输入应该是以下内容:

包含文本的图像,矩形,屏幕截图,字体  自动生成的描述

图 12.6:带有行内按钮的输入组件

在下一节中,我们将学习如何使用 Angular Material 选择组件在产品创建组件中选择一个类别。

选择

选择组件的工作方式与原生的<select> HTML 元素类似。它显示一个下拉元素,其中包含用户可以选择的选项列表。

我们将在产品创建组件中添加一个来选择新产品的类别:

  1. 打开product-create.component.ts文件并添加以下import语句:

    import { MatSelect, MatOption } from '@angular/material/select'; 
    
  2. @Component装饰器的imports数组中添加前面导入的类:

    @Component({
      selector: 'app-product-create',
      imports: [
        ReactiveFormsModule,
        MatButton,
        MatInput,
        MatFormField,
        MatError,
        MatLabel,
        **MatSelect,**
        **MatOption**
      ],
      templateUrl: './product-create.component.html',
      styleUrl: './product-create.component.css'
    }) 
    
  3. 打开product-create.component.html文件,并用以下 HTML 片段替换包围<select>元素的<div> HTML 元素:

    <mat-form-field>
      <mat-label>Category</mat-label>
      <mat-select formControlName="category">
        <mat-option value="electronics">Electronics</mat-option>
        <mat-option value="jewelery">Jewelery</mat-option>
        <mat-option>Other</mat-option>
      </mat-select>
    </mat-form-field> 
    

在前面的片段中,我们将<select><option> HTML 元素分别替换为<mat-select><mat-option>元素。

  1. 导航到http://localhost:4200/products/new并点击类别下拉列表:

包含文本的图像,屏幕截图,字体  自动生成的描述

图 12.7:选择组件

产品详情组件将产品类别显示为具有特定 CSS 类的段落元素。在下一节中,我们将学习如何使用 Angular Material 芯片组件表示产品类别。

芯片

芯片组件通常用于按特定属性分组显示信息。它还可以提供数据过滤和选择功能。我们可以在我们的应用程序中使用芯片来在产品详情组件中显示类别。

我们的产品只有一个类别,但如果我们的产品有额外的类别分配,那么芯片会更有意义。

让我们开始吧:

  1. 打开product-detail.component.ts文件,添加以下import语句:

    import { MatChipSet, MatChip } from '@angular/material/chips'; 
    
  2. @Component装饰器的imports数组中添加前面导入的类:

    @Component({
      selector: 'app-product-detail',
      imports: [
        CommonModule,
        FormsModule,
        PriceMaximumDirective,
        MatButton,
        MatInput,
        MatFormField,
        MatError,
        MatIcon,
        MatSuffix,
        MatIconButton,
        **MatChipSet,**
        **MatChip**
      ],
      templateUrl: './product-detail.component.html',
      styleUrl: './product-detail.component.css'
    }) 
    
  3. 打开product-detail.component.html文件,将包含pill-group类的<div> HTML 元素替换为以下内容:

    <mat-chip-set>
      <mat-chip>{{ product.category }}</mat-chip>
    </mat-chip-set> 
    

<mat-chip> HTML 元素表示一个芯片组件。芯片必须始终使用容器元素封装。芯片容器最简单的形式是<mat-chip-set>元素。

  1. 打开product-detail.component.css文件,添加以下 CSS 样式:

    mat-chip-set {
      margin-bottom: 1.375rem;
    } 
    
  2. 运行ng serve命令以启动应用程序,并从列表中选择一个产品。例如,类别应该看起来像以下这样:

包含字体、文本、白色、标志的图片,自动生成的描述

图 12.8:芯片组件

芯片组件完成了我们对 Angular Material 表单控件的探索。在下一节中,我们将通过为应用程序的导航布局添加样式来获得实际操作经验。

导航

在 Angular 应用程序中导航有不同的方式,例如点击链接或菜单项。Angular Material 为此类交互提供了以下组件:

  • 菜单:一个弹出列表,您可以从预定义的选项集中进行选择。

  • 侧边栏:一个作为菜单固定在页面左侧或右侧的组件。它可以作为覆盖在应用程序上的叠加层,同时变暗应用程序内容。

  • 工具栏:一个标准工具栏,允许用户访问常用操作。

在本节中,我们将演示如何使用工具栏组件。我们将把主应用程序组件的<header><footer> HTML 元素转换为 Angular Material 工具栏。

要创建工具栏,我们将按照以下步骤进行:

  1. 打开app.component.ts文件,添加以下import语句:

    import { MatToolbarRow, MatToolbar } from '@angular/material/toolbar';
    import { MatButton } from '@angular/material/button'; 
    
  2. @Component装饰器的imports数组中添加前面导入的类,并删除RouterLinkActive类:

    @Component({
      selector: 'app-root',
      imports: [
        RouterOutlet,
        RouterLink,
        CopyrightDirective,
        AuthComponent,
        MatToolbarRow,
        MatToolbar,
        MatButton
      ],
      templateUrl: './app.component.html',
      styleUrl: './app.component.css'
    }) 
    
  3. 打开app.component.html文件,按照以下方式修改<header> HTML 元素:

    <header>
      **<mat-toolbar>**
        **<mat-toolbar-row>**
          <h2>{{ settings.title }}</h2>
          <span class="spacer"></span>
          **<button mat-button routerLink="/products">Products</button>**
          **<button mat-button routerLink="/cart">My Cart</button>**
          **<button mat-button routerLink="/user">My Profile</button>**
          <app-auth></app-auth>
        **</mat-toolbar-row>**
      **</mat-toolbar>**
    </header> 
    

在前面的模板中,我们在<mat-toolbar>元素内添加了主应用程序链接和身份验证组件。工具栏组件由一个由<mat-toolbar-row> HTML 元素表示的单行组成。

  1. 打开app.component.css文件,删除header标签和menu-links的 CSS 样式。

  2. 如果我们使用ng serve命令运行应用程序,我们将在页面顶部看到我们应用程序的新工具栏:

img

图 12.9:应用程序标题

  1. 现在,修改<footer> HTML 元素,将其转换为 Angular Material 工具栏组件:

    <footer>
      **<mat-toolbar>**
        **<mat-toolbar-row>**
          **<span appCopyright> - v{{ settings.version }}</span>** 
        **</mat-toolbar-row>**
      **</mat-toolbar>**
    </footer> 
    
  2. 保存更改,等待应用程序刷新,并观察应用程序底部的工具栏:

img

图 12.10:应用页脚

工具栏组件是完全可定制的,我们可以根据应用程序的需求进行调整。我们可以添加图标,甚至创建多行的工具栏。现在你已经了解了创建简单工具栏的基础,你可以探索更多的可能性。

在下一节中,我们将学习如何在应用程序内部以不同的方式布局内容。

布局

当我们提到布局时,我们讨论如何在模板中放置内容。Angular Material 为我们提供了不同的组件来完成这个目的:

  • 列表:将内容以项目列表的形式可视化。它可以添加链接、图标,甚至多行内容。

  • 网格列表:帮助我们以块的形式排列内容。我们只需要定义列数;组件将填充视觉空间。

  • 卡片:包装内容并添加阴影。我们还可以为它定义一个标题。

  • 标签页:将内容分割成不同的标签页。

  • 步骤条:将内容分割成类似向导的步骤。

  • 展开面板:允许我们将内容以类似列表的方式放置,并为每个项目添加标题。项目一次只能展开一个。

  • 表格:以行和列的表格格式表示数据。

在这本书中,我们将介绍卡片和表格组件。

卡片

我们将学习如何将列表中的每个产品显示为卡片:

  1. 打开product.ts文件,并在Product接口中添加一个image属性:

    export interface Product {
      id: number;
      title: string;
      price: number;
      category: string;
      **image: string;**
    } 
    

image属性是一个指向 Fake Store API 中产品图片文件的 URL。

  1. 打开product-list.component.ts文件,并添加以下import语句:

    import { MatCardModule } from '@angular/material/card'; 
    
  2. @Component装饰器的imports数组中添加MatCardModule类:

    @Component({
      selector: 'app-product-list',
      imports: [
        SortPipe,
        AsyncPipe,
        RouterLink,
        MatMiniFabButton,
        MatIcon,
        **MatCardModule**
      ],
      templateUrl: './product-list.component.html',
      styleUrl: './product-list.component.css'
    }) 
    

Angular Material 卡片组件由许多其他组件和指令组成。我们选择导入整个 Angular 模块,因为单独导入它们不太方便。

  1. 打开product-list.component.html文件,将无序列表元素替换为以下 HTML 片段:

    @for (product of products | sort; track product.id) {
      <mat-card [routerLink]="[product.id]">
        <mat-card-header>
          <mat-card-title-group>
            <mat-card-title>{{ product.title }}</mat-card-title>
            <mat-card-subtitle>{{ product.category }}</mat-card-subtitle>
            <img mat-card-sm-image [src]="product.image" />
          </mat-card-title-group>
        </mat-card-header>
      </mat-card>
    } @empty {
      <p>No products found!</p>
    } 
    

Angular Material 卡片组件由一个标题组成,由<mat-card-header>HTML 元素表示。标题组件包含一个<mat-card-title-group>HTML 元素,该元素将卡片标题、副标题和图像排列成一个单独的部分。由<mat-card-title>HTML 元素表示的卡片标题显示产品标题。由<mat-card-subtitle>HTML 元素表示的卡片副标题显示产品类别。最后,通过将mat-card-sm-image指令附加到<img>HTML 元素上,显示产品图片。指令中的sm关键字表示我们想要渲染图像的小尺寸。

Angular Material 还支持mdlg,分别代表中等和大型尺寸。

  1. 打开product-list.component.css文件,并添加以下 CSS 样式:

    mat-card {
      margin: 1.375rem;
      cursor: pointer;
    } 
    
  2. 使用ng serve命令运行应用程序,并导航到http://localhost:4200

包含文本的图像,屏幕截图,自动生成的描述

图 12.11:产品列表卡片表示

您可以通过导航到material.angular.io/components/card/overview来探索更多卡片组件的选项。

在下一节中,我们将学习如何将产品列表切换到表格视图。

数据表

Angular Material 库中的表格组件使我们能够以列和行的形式显示我们的数据。要创建表格,我们必须从@angular/material/table命名空间导入MatTableModule类。

Angular Material 数据表由许多其他组件和指令组成。我们选择导入整个 Angular 模块,因为单独导入它们将不方便。

让我们开始吧:

  1. 打开product-list.component.ts文件,并导入CurrencyPipeMatTableModule组件:

    import { AsyncPipe, **CurrencyPipe** } from '@angular/common';
    **import { MatTableModule } from '@angular/material/table';** 
    
  2. 将之前导入的类添加到@Component装饰器的imports数组中:

    @Component({
      selector: 'app-product-list',
      imports: [
        SortPipe,
        AsyncPipe,
        **CurrencyPipe**,
        RouterLink,
        MatMiniFabButton,
        MatIcon,
        MatCardModule,
        **MatTableModule**
      ],
      templateUrl: './product-list.component.html',
      styleUrl: './product-list.component.css'
    }) 
    
  3. ProductListComponent类中创建以下属性来定义表格列名:

    columnNames = ['title', 'price']; 
    

每个列的名称都与Product接口中的一个属性匹配。

  1. 打开product-list.component.html文件,在@for块之后添加以下代码片段:

    <table mat-table [dataSource]="products"></table> 
    

Angular Material 表格是一个带有mat-table指令的标准<table> HTML 元素。

mat-table指令的dataSource属性定义了我们想在表格上显示的数据。它可以是指任何可枚举的数据,例如数组。在我们的例子中,我们将其绑定到products模板引用变量。

  1. 为我们想要显示的每个列添加一个<ng-container>元素:

    <table mat-table [dataSource]="products">
      **<ng-container matColumnDef="title">**
        **<th mat-header-cell *matHeaderCellDef>Title</th>**
        **<td mat-cell *matCellDef="let product">**
          **<a [routerLink]="[product.id]">{{ product.title }}</a>**
        **</td>**
      **</ng-container>**
      **<ng-container matColumnDef="price">**
        **<th mat-header-cell *matHeaderCellDef>Price</th>**
        **<td mat-cell *matCellDef="let product">{{ product.price |****currency }}</td>** 
      **</ng-container>**
    </table> 
    

    <ng-container>元素是一个具有独特用途的元素,它将具有相似功能的元素分组在一起。它不会干扰子元素的样式,也不会在屏幕上渲染。

<ng-container>元素使用matColumnDef指令设置特定列的名称。

matColumnDef指令的值必须与columnNames组件属性的值匹配;否则,应用程序将抛出一个错误,表明它找不到定义的列的名称。

它包含一个带有mat-header-cell指令的<th> HTML 元素,表示单元格的标题,以及一个带有mat-cell指令的<td> HTML 元素,用于单元格的数据。<td> HTML 元素使用matCellDef指令创建一个用于当前行数据的本地模板变量,我们可以在以后使用它。

  1. <ng-container>元素之后添加以下代码片段:

    <tr mat-header-row *matHeaderRowDef="columnNames"></tr>
    <tr mat-row *matRowDef="let row; columns: columnNames;"></tr> 
    

在前面的代码片段中,我们定义了表格的标题行,显示列名和包含数据的实际行。

如果我们运行应用程序,输出应该是以下内容:

包含文本的图像,屏幕截图,编号,字体,自动生成的描述

图 12.12:表格组件

产品列表组件同时显示数据的卡片表示和表格表示。我们将使用 Angular Material 的按钮切换组件来区分它们。按钮切换组件根据特定条件切换按钮的开启或关闭:

  1. 打开product-list.component.ts文件并添加以下import语句:

    import { MatButtonToggle, MatButtonToggleGroup } from '@angular/material/button-toggle'; 
    
  2. @Component装饰器的imports数组中添加前面导入的类:

    @Component({
      selector: 'app-product-list',
      imports: [
        SortPipe,
        AsyncPipe,
        CurrencyPipe,
        RouterLink,
        MatMiniFabButton,
        MatIcon,
        MatCardModule,
        MatTableModule,
        **MatButtonToggle,**
        **MatButtonToggleGroup**
      ],
      templateUrl: './product-list.component.html',
      styleUrl: './product-list.component.css'
    }) 
    
  3. 打开product-list.component.html文件,并在具有caption类的<div>HTML 元素内添加以下 HTML 片段:

    <span class="spacer"></span>
    <mat-button-toggle-group #group="matButtonToggleGroup">
      <mat-button-toggle value="card" checked>
        <mat-icon>list</mat-icon>
      </mat-button-toggle>
      <mat-button-toggle value="table">
        <mat-icon>grid_on</mat-icon>
      </mat-button-toggle>
    </mat-button-toggle-group> 
    

在前面的代码片段中,我们使用<mat-button-toggle-group>元素创建两个并排的切换按钮。按钮切换组的实例被分配给group模板引用变量,这样我们可以在以后访问它。

我们使用<mat-button-toggle>元素声明切换按钮,并设置适当的value。当点击任一按钮时,将设置value属性。我们还为每个切换按钮添加了一个图标,以增强用户体验,当用户与产品列表交互时。

  1. 在具有caption类的<div>HTML 元素之后创建一个新的@if块,并将@for块移动到其中:

    @if (group.value === 'card') {
      @for (product of products | sort; track product.id) {
        <mat-card [routerLink]="[product.id]">
          <mat-card-header>
            <mat-card-title-group>
              <mat-card-title>{{ product.title }}</mat-card-title>
              <mat-card-subtitle>{{ product.category }}</mat-card-subtitle>
              <img mat-card-sm-image [src]="product.image" />
            </mat-card-title-group>
          </mat-card-header>
        </mat-card>
      } @empty {
        <p>No products found!</p>
      }
    } 
    

根据前面的代码片段,当按钮切换组的value属性设置为card时,将显示产品的卡片表示。

  1. 添加以下@else块,并将数据表组件移动到其中,以便在点击第二个切换按钮时以表格格式显示产品列表:

    @else {
      <table mat-table [dataSource]="products">
        <ng-container matColumnDef="title">
          <th mat-header-cell *matHeaderCellDef>Title</th>
          <td mat-cell *matCellDef="let product">
            <a [routerLink]="[product.id]">{{ product.title }}</a>
          </td>
        </ng-container>
        <ng-container matColumnDef="price">
          <th mat-header-cell *matHeaderCellDef>Price</th>
          <td mat-cell *matCellDef="let product">{{ product.price | currency }}</td>
        </ng-container>
        <tr mat-header-row *matHeaderRowDef="columnNames"></tr>
        <tr mat-row *matRowDef="let row; columns: columnNames;"></tr>
      </table>
    } 
    
  2. 运行ng serve命令以启动应用程序并验证卡片表示最初显示。

包含文本的图像,屏幕截图,自动生成的描述

图 12.13:产品列表

  1. 点击第二个切换按钮并验证产品现在以表格格式显示。

在本节中,我们学习了如何以表格格式显示产品列表。我们还使用了切换按钮在卡片视图和表格视图之间切换。

在以下部分,我们将学习如何使用弹出窗口和覆盖层向用户提供额外信息。

弹出窗口和覆盖层

在 Web 应用程序中,有不同方式来吸引用户的注意力。其中一种是在页面内容上显示弹出对话框,并提示用户相应地采取行动。另一种方式是在页面的不同部分显示信息作为通知。

Angular Material 提供三个不同的组件来处理此类情况:

  • 对话框:一个模态弹出对话框,它显示在页面内容之上。

  • 徽章:一个小圆形指示,用于更新 UI 元素的状态。

  • Snackbar:在页面底部显示的信息消息,短暂可见。其目的是通知用户操作的结果,例如保存表单。

我们将学习如何在我们的电子商务应用程序中使用前面的组件,从如何创建一个简单的模态对话框开始。

创建确认对话框

对话框组件功能强大,可以轻松地进行自定义和配置。它是一个普通的 Angular 组件,带有自定义指令,强制其表现出对话框的行为。为了探索 Angular Material 对话框的功能,我们将在结账守卫中使用确认对话框来通知用户他们购物车中剩余的商品:

  1. 运行以下 Angular CLI 命令以创建一个新的 Angular 组件:

    ng generate component checkout 
    

上述命令将创建一个 Angular 组件,该组件将托管我们的对话框。

  1. 打开 checkout.component.ts 文件并添加以下 import 语句:

    import { MatButton } from '@angular/material/button';
    import { MatDialogModule } from '@angular/material/dialog'; 
    

Angular Material 对话框组件由许多其他组件和指令组成。我们选择导入整个 Angular 模块,因为单独导入它们将不太方便。

  1. @Component 装饰器的 imports 数组中添加前面导入的类:

    @Component({
      selector: 'app-checkout',
      imports: [**MatButton, MatDialogModule**],
      templateUrl: './checkout.component.html',
      styleUrl: './checkout.component.css'
    }) 
    
  2. 打开 checkout.component.html 文件并将其内容替换为以下 HTML 模板:

    <h1 mat-dialog-title>Cart Checkout</h1>
    <mat-dialog-content>
      <span>You have pending items in your cart. Do you want to continue?</span>
    </mat-dialog-content>
    <mat-dialog-actions>
      <button mat-raised-button>Yes</button>
      <button mat-button>No</button>
    </mat-dialog-actions> 
    

组件模板包含属于 Angular Material 对话框组件的各种指令和元素。mat-dialog-title 指令定义对话框的标题,<mat-dialog-content> 是实际内容。<mat-dialog-actions> 元素定义对话框可以执行的操作,通常包含按钮元素。

  1. 对话框必须被触发才能在页面上显示。打开 checkout.guard.ts 文件并添加以下 import 语句:

    import { MatDialog } from '@angular/material/dialog';
    import { CheckoutComponent } from './checkout/checkout.component'; 
    
  2. checkoutGuard 函数的主体中注入 MatDialog 服务:

    const dialog = inject(MatDialog); 
    
  3. 按如下方式修改 confirmation 变量的赋值:

    if (cartService.cart) {
      const confirmation = **dialog.open(CheckoutComponent).afterClosed();**
      return confirmation;
    } 
    

在前面的代码片段中,我们使用 MatDialog 服务来显示结账组件。MatDialog 服务接受一个参数,该参数是表示对话框的组件类类型。

MatDialog 服务的 open 方法返回一个 afterClosed 可观察属性,它将在对话框关闭时通知我们。该可观察对象会发出从对话框发送回的任何值。

在本章的后面部分,我们将学习如何从对话框组件返回一个布尔值,该值与 CanDeactivateFn 函数返回的类型相匹配。

我们现在可以通过执行以下步骤来验证对话框组件是否按预期工作:

  1. 使用 ng serve 命令运行应用程序并导航到 http://localhost:4200

  2. 登录应用程序。

  3. 从列表中选择一个产品并将其添加到购物车。

  4. 重复上述步骤以向购物车添加更多产品。

  5. 导航到购物车,然后点击浏览器的后退按钮或任何应用程序链接以离开购物车。屏幕上将会显示以下对话框:

包含文本的图像,屏幕截图,字体  自动生成的描述

图 12.14:结账对话框组件

我们可以通过在对话框中显示我们添加到购物车中的项目数量来进一步改善应用程序的 UX。在下一节中,我们将学习如何在对话框中传递数据并显示购物车项目数量。

配置对话框

在实际场景中,你可能会需要创建一个可重用的组件来在 Angular 项目中显示对话框。该组件最终可能成为 Angular 库中的一个包。因此,你应该配置对话框组件以动态接受数据。

在当前的 Angular 项目中,我们希望显示我们添加到购物车中的产品数量:

  1. 打开 checkout.component.ts 文件,并按如下方式修改 import 语句:

    import { Component, **inject** } from '@angular/core';
    import { MatButton } from '@angular/material/button';
    import { MatDialogModule, **MAT_DIALOG_DATA** } from '@angular/material/dialog'; 
    
  2. 以以下方式在 CheckoutComponent 类中注入 MAT_DIALOG_DATA

    export class CheckoutComponent {
      **data = inject(MAT_DIALOG_DATA);**
    } 
    

MAT_DIALOG_DATA 是一个注入令牌,它使我们能够将任意数据传递给对话框组件。当调用其 open 方法时,data 变量将包含我们传递给对话框的任何数据。

  1. 打开 checkout.component.html 文件,并将 data 属性添加到 <span> HTML 元素的内部文本:

    <span>
      You have **{{ data }}** pending items in your cart.
      Do you want to continue?
    </span> 
    
  2. 打开 checkout.guard.ts 文件,并在对话框配置对象中设置 data 属性,这是 open 方法的第二个参数:

    const confirmation = dialog.open(
      CheckoutComponent,
      **{ data: cartService.cart.products.length }**
    ).afterClosed(); 
    
  3. 如果我们在运行应用程序时尝试离开购物车页面,我们将得到一个类似于以下对话框:

包含文本的图像,屏幕截图,字体  自动生成的描述

图 12.15:带有自定义数据的结账对话框组件

对话框组件的按钮目前还没有做任何特定的事情。在下一节中,我们将学习如何配置它们并将数据返回给守卫。

从对话框获取数据

Angular Material 对话框模块公开了 mat-dialog-close 指令,我们可以使用它来配置哪个按钮将关闭对话框。打开 checkout.component.html 文件,并将 mat-dialog-close 指令添加到两个按钮:

<mat-dialog-actions>
  <button mat-raised-button **mat-dialog-close**>Yes</button>
  <button mat-button **[mat-dialog-close]="false"**>No</button>
   </mat-dialog-actions> 

在前面的代码片段中,我们以两种方式使用 mat-dialog-close 指令:

  • 如果在 Yes 按钮中不传递值,对话框将默认返回 true,允许守卫从购物车页面导航离开。

  • No 按钮的属性绑定中,我们传递 false 作为值以取消从守卫处的导航。

执行以下步骤以验证对话框行为是否正确:

  1. 运行 ng serve 命令以启动应用程序并导航到 http://localhost:4200

  2. 登录到应用程序。

  3. 从列表中选择一个产品并将其添加到购物车。

  4. 单击My Cart链接以导航到购物车。

  5. 在结账对话框中选择No,然后单击Products链接,并验证应用程序是否停留在购物车页面上。

  6. 再次单击Products链接,在对话框中选择Yes,你应该会导航到产品列表。

对话框是 Angular Material 的一个优秀功能,可以为您的应用程序提供强大的功能。在下一节中,我们将探讨徽章和 Snackbar 组件,以便在产品添加到购物车时通知用户。

显示用户通知

Angular Material 库强制执行提高应用程序 UX 的模式和行为。应用程序 UX 的一个方面是,在特定操作后向用户提供通知。Angular Material 为我们提供了在这种情况下可以使用的徽章和 Snackbar 组件。

应用徽章

徽章组件是一个位于另一个元素顶部的圆形,通常显示一个数字。我们将学习如何通过在My Cart应用程序链接中显示购物车项目数量来应用徽章:

  1. 打开app.component.ts文件并添加以下import语句:

    import { MatBadge } from '@angular/material/badge';
    import { CartService } from './cart.service'; 
    

MatBadge类导出徽章组件。CartService类将为我们提供购物车中的项目数量。

  1. @Component装饰器的imports数组中添加MatBadge类:

    @Component({
      selector: 'app-root',
      imports: [
        RouterOutlet,
        RouterLink,
        CopyrightDirective,
        AuthComponent,
        MatToolbarRow,
        MatToolbar,
        MatButton,
        **MatBadge**
      ],
      templateUrl: './app.component.html',
      styleUrl: './app.component.css'
    }) 
    
  2. AppComponent类中注入CartService类:

    cartService = inject(CartService); 
    
  3. 打开app.component.html文件并将matBadge指令添加到My Cart按钮:

    <button
      mat-button
      routerLink="/cart"
      **[matBadge]="cartService.cart?.products?.length">**
      My Cart
    </button> 
    

在前面的代码片段中,matBadge指令指示徽章中显示的数字。在这种情况下,我们将其绑定到当前购物车中存在的products数组的length

  1. 打开app.component.css文件并添加以下 CSS 样式:

    button {
      margin: 5px;
    } 
    

上述样式将为每个应用程序链接周围添加空间,以便按钮不会与徽章组件重叠。

  1. 运行ng serve命令以启动应用程序并向购物车添加一些产品。注意,当产品添加到购物车时,徽章图标会更新其值;以下是一个示例:

包含文本、字体、屏幕截图、徽标的图片,自动生成的描述

图 12.16:徽章组件

应用 Snackbar

当我们与 CRUD 应用程序一起工作时,另一个好的 UX 模式是在操作完成后显示通知。我们可以通过在产品添加到购物车时显示通知来应用这种模式。我们将使用 Angular Material 的 Snackbar 组件来显示通知:

  1. 打开product-detail.component.ts文件并添加以下import语句:

    import { MatSnackBarModule, MatSnackBar } from '@angular/material/snack-bar'; 
    

Snackbar 不是一个像我们之前看到的所有 Angular Material 组件的实际 Angular 组件。它是一个名为MatSnackBar的 Angular 服务,可以通过将MatSnackBarModule类导入我们的组件来使用。

  1. @Component装饰器的imports数组中添加MatSnackBarModule类:

    @Component({
      selector: 'app-product-detail',
      imports: [
        CommonModule,
        FormsModule,
        PriceMaximumDirective,
        MatButton,
        MatInput,
        MatFormField,
        MatError,
        MatIcon,
        MatSuffix,
        MatIconButton,
        MatChipSet,
        MatChip,
        **MatSnackBarModule**
      ],
      templateUrl: './product-detail.component.html',
      styleUrl: './product-detail.component.css'
    }) 
    
  2. MatSnackBar服务注入到ProductDetailComponent类的constructor中:

    constructor(
      private productService: ProductsService,
      public authService: AuthService,
      private route: ActivatedRoute,
      private router: Router,
      private cartService: CartService,
      **private snackbar: MatSnackBar**
    ) { } 
    
  3. 修改addToCart方法,当产品添加到购物车时显示一个通知栏:

    addToCart(id: number) {
      this.cartService.addProduct(id).subscribe(() => {
        **this.snackbar.open('Product added to cart!', undefined, {**
          **duration: 1000**
        **});**
      **}**);
    } 
    

在前面的方法中,我们使用MatSnackBar服务的open方法来显示一个通知栏。open方法接受三个参数:我们想要显示的消息,当通知栏被关闭时我们想要采取的操作,以及一个配置对象。配置对象使我们能够设置各种选项,例如通知栏在毫秒数内的可见duration

我们没有传递操作参数,因为我们不希望在通知栏被关闭时做出反应。

  1. 运行ng serve命令以启动应用程序并从列表中选择一个产品。

  2. 确保您已登录并点击添加到购物车按钮。以下通知信息将在页面底部显示:

包含文本、字体、屏幕截图、矩形框的图片,自动生成的描述

图 12.17:Snackbar 组件

可以通过配置选项更改通知栏的位置。更多信息请参阅material.angular.io/components/snack-bar/overview

在本节中,我们学习了如何使用弹出模型和通知覆盖来增强应用程序的用户体验,并为用户提供一个出色的工作流程。

摘要

在本章中,我们探讨了 Material Design 系统的基本知识。我们主要关注 Angular Material,这是为 Angular 设计的 Material Design 实现,以及它由不同的组件组成。我们查看了一个关于如何安装它、设置它以及使用其核心组件和主题的动手说明。

希望您已经阅读了这一章,并发现您现在已经掌握了 Material Design 的一般知识,特别是 Angular Material,并且可以确定它是否适合您的下一个 Angular 应用程序。

网络应用程序必须是可测试的,以确保它们的功能性和符合应用程序要求。在下一章中,我们将学习如何在 Angular 应用程序中应用不同的测试技术。

第十三章:单元测试 Angular 应用

在前面的章节中,我们讨论了从零开始构建 Angular 企业应用的许多方面。但我们是怎样确保应用在未来可以轻松维护的呢?一旦我们的应用开始扩展,我们必须减轻 bug 的影响,一个全面的自动化测试层就可以成为我们的生命线。

测试,特别是单元测试,旨在在项目开发过程中由开发者执行。现在我们的框架知识已经成熟,我们将简要介绍本章中 Angular 应用单元测试的所有复杂性,包括测试工具的使用。

为了简单起见,本章中的示例与我们在整本书中构建的电子商务应用无关。

更详细地说,我们将学习以下内容:

  • 为什么我们需要单元测试?

  • 单元测试的结构

  • 在 Angular 中引入单元测试

  • 测试组件

  • 测试服务

  • 测试管道

  • 测试指令

  • 测试表单

  • 测试路由器

技术要求

本章包含各种代码示例,以向您介绍 Angular 中单元测试的概念。您可以在以下 GitHub 仓库的ch13文件夹中找到相关源代码:

www.github.com/PacktPublishing/Learning-Angular-Fifth-Edition .

为什么我们需要单元测试?

在本节中,我们将学习单元测试是什么以及为什么它们在 Web 开发中很有用。

如果你熟悉单元测试和测试驱动开发,你可以跳到下一节。

单元测试是高效和敏捷开发流程的工程哲学的一部分。它们在代码开发之前为应用程序代码添加了一层自动化测试。核心概念是,一段代码及其测试都是由负责该代码的开发者构建的。首先,我们针对我们想要交付的功能设计测试,检查其输出和行为是否准确。由于该功能尚未实现,测试将失败,因此开发者的工作是构建功能以通过测试。

单元测试相当有争议。虽然测试驱动开发有助于确保代码质量并随着时间的推移进行维护,但并非每个人都在他们的日常开发工作流程中执行单元测试。

在我们开发代码的同时构建测试有时会感觉像是一种负担,尤其是在测试结果变得比要测试的功能更大的情况下。然而,支持测试的论据超过了反对它的论据:

  • 构建测试有助于更好的代码设计。我们的代码必须符合测试要求,而不是相反。如果我们试图测试现有的代码并在某个点上受阻,那么代码可能设计得不好,需要重新思考。另一方面,构建可测试的功能可以帮助早期发现副作用。

  • 对经过测试的代码进行重构是防止在后期引入错误的生命线。开发应当随着时间而演进,每次重构时引入错误的几率很高。单元测试是确保我们能够尽早捕捉到错误的一种极好方式,无论是引入新功能还是更新现有功能。

  • 构建测试是记录我们代码的极好方式。当有人不熟悉代码库而接管开发任务时,它成为了一种无价资源。

这些只是其中的一小部分论点,但你在网上可以找到无数关于测试代码好处的资源。如果你还没有确信,不妨试一试;否则,让我们继续我们的旅程,看看单元测试的整体形式。

单元测试的结构

测试一段代码有许多不同的方法。在本章中,我们将探讨单元测试的结构——它由哪些单独的部分组成。

要测试任何代码,我们需要一个用于编写测试的框架和一个用于运行测试的运行器。在本节中,我们将专注于测试框架。测试框架应提供用于构建包含一个或多个测试规格的测试套件的实用函数。因此,单元测试涉及以下概念:

  • 测试套件:一个为许多测试创建逻辑分组的套件。例如,一个套件可以包含特定功能的全部测试。

  • 测试规格:实际的单元测试。

在本章中,我们将使用Jasmine,这是一个流行的测试框架,也是 Angular CLI 项目中默认使用的框架。以下是 Jasmine 中的单元测试示例:

describe('Calculator', () => {
  it('should add two numbers', () => {
    expect(1+1).toBe(2);
  });
}); 

describe 方法定义了测试套件,并接受一个名称和一个箭头函数作为参数。箭头函数是测试套件的主体,包含多个单元测试。it 方法定义了一个单独的单元测试。它接受一个名称和一个箭头函数作为参数。

每个测试规格验证套件名称中描述的功能的特定功能,并在其主体中声明一个或多个期望。每个期望都包含一个值,称为期望值,它使用匹配器函数实际值进行比较。该函数检查期望值和实际值是否相应匹配,这被称为断言。测试框架根据这种断言的结果通过或失败规格。在先前的例子中,1+1将返回实际值,该值应与在toBe匹配器函数中声明的期望值2相匹配。

Jasmine 框架根据用户特定的需求包含各种匹配器函数,正如我们在本章后面将要看到的。

假设前面的代码包含另一个必须测试的数学运算。将这两个操作组合在 Calculator 测试套件下进行测试是有意义的,如下所示:

describe('Calculator', () => {
  it('should add two numbers', () => {
    expect(1+1).toBe(2);
  });
  **it('should subtract two numbers', () => {**
    **expect(1-1).toBe(0);**
  **});**
}); 

到目前为止,我们已经了解了测试套件以及如何使用它们根据其功能分组测试。此外,我们还学习了如何调用我们想要测试的代码并确认它做了它应该做的事情。然而,单元测试中涉及的概念更多,值得了解,即设置拆卸功能。

设置功能是在你开始运行测试之前准备你的代码。这是一种通过调用代码并检查断言来保持代码清洁的方法。拆卸功能是相反的。它负责拆卸我们最初设置的内容,涉及诸如清理资源等活动。让我们通过一个代码示例来看看这在实际中是什么样子:

describe('Calculator', () => {
  let total: number;
  **beforeEach(() => total = 1);**
  it('should add two numbers', () => {
    total = total + 1;
    expect(total).toBe(2);
  });
  it('should subtract two numbers', () => {
    total = total - 1;
    expect(total).toBe(0);
  });
  **afterEach(() => total = 0);**
}); 

beforeEach 方法用于设置功能,并在每个单元测试之前运行。在这个例子中,我们在每个测试之前将 total 变量的值设置为 1afterEach 方法用于运行拆卸逻辑。在每个测试之后,我们将 total 变量的值重置为 0

显然,测试只需要关注调用应用程序代码并断言结果,这使得测试更简洁;然而,在实际应用中,测试往往需要更多的设置。最重要的是,beforeEach 方法通常使添加新测试变得更容易,这是非常好的。我们希望代码经过良好的测试;编写和维护这样的代码越容易,对我们软件的好处就越大。

现在我们已经了解了单元测试的基础,让我们看看我们如何在 Angular 框架的上下文中实现它们。

在 Angular 中引入单元测试

在上一节中,我们熟悉了单元测试及其一般概念,如测试套件、测试规范和断言。现在是时候带着这些知识去探索 Angular 的单元测试了。然而,在我们开始为 Angular 编写测试之前,让我们看看 Angular 框架和 Angular CLI 为我们提供的工具:

  • Jasmine:我们已经了解到,这是一个测试框架。

  • Karma:运行我们的单元测试的测试运行器。

  • Angular 测试工具:一组辅助方法,帮助我们设置单元测试并在 Angular 框架的上下文中编写断言。

当我们使用 Angular CLI 时,我们不需要对 Angular 应用程序中的 Jasmine 和 Karma 进行任何配置。当我们创建一个新的 Angular CLI 项目时,单元测试默认情况下就可以工作。大多数时候,我们将与 Angular 测试工具交互。

Angular 测试实用工具帮助我们创建一个测试环境,使得为我们的 Angular 艺术品编写测试变得容易。它由 TestBed 类和 @angular/core/testing 命名空间中的各种辅助方法组成。随着本章的深入,我们将了解这些是什么以及它们如何帮助我们测试各种艺术品。现在,让我们先看看最常用的概念,以便在稍后更详细地探讨时,您能熟悉它们:

  • TestBed:一个创建测试模块的类。当我们测试它时,我们将 Angular 艺术品附加到这个测试模块上。TestBed 类包含我们用来设置测试模块所需的 configureTestingModule 方法。

  • ComponentFixture:一个围绕 Angular 组件实例的包装类。它允许我们与组件及其相应的 HTML 元素进行交互。

  • DebugElement:组件 DOM 元素的包装。它是一个跨平台操作抽象,使得我们的测试是平台无关的。

现在我们已经了解了我们的测试环境和使用的框架和库,我们可以开始编写我们的第一个 Angular 单元测试。

本章中描述的所有示例都是在新的 Angular CLI 项目中创建的。

我们将从 Angular 最基本的构建块——组件开始,开始这段伟大的旅程。

测试组件

你可能已经注意到,每次我们使用 Angular CLI 构建新的 Angular 应用程序或生成 Angular 艺术品时,它都会为我们创建一些测试文件。

Angular CLI 中的测试文件文件名中包含单词 spec。测试文件的文件名与其所测试的 Angular 艺术品相同,后跟后缀 .spec.ts。例如,Angular 应用程序主组件的测试文件是 app.component.spec.ts,它位于组件文件相同的路径中。

我们应该将 Angular 艺术品及其相应的测试视为一件事。当我们更改艺术品的逻辑时,我们可能需要修改单元测试。将单元测试文件与其 Angular 艺术品放在一起,使我们更容易记住和编辑它们。这也有助于我们在需要重构代码时,例如移动艺术品(不要忘记移动单元测试)。

当我们构建一个新的 Angular 应用程序时,Angular CLI 会自动为主组件 AppComponent 创建一个测试。在文件的开头,有一个用于设置的 beforeEach 语句:

beforeEach(async () => {
  await TestBed.configureTestingModule({
    imports: [AppComponent],
  }).compileComponents();
}); 

它使用 TestBed 类的 configureTestingModule 方法,并将一个对象作为参数传递。

我们可以指定一个包含我们想要测试的组件的 imports 数组。此外,我们可以使用 teardown 属性定义拆解选项。

teardown 属性包含一个 ModuleTeardownOptions 类型的对象,可以设置以下属性:

  • destroyAfterEach:它在每个测试中创建模块的新实例,以消除由于 HTML 元素清理不完整而引起的错误。

  • rethrowErrors:它在模块销毁时抛出任何发生的错误。

最后,我们调用 compileComponents 方法来编译我们的组件的 TypeScript 类和 HTML 模板。

第一个单元测试验证我们是否可以使用 createComponent 方法创建 AppComponent 的新实例:

it('should create the app', () => {
  const fixture = TestBed.createComponent(AppComponent);
  const app = fixture.componentInstance;
  expect(app).toBeTruthy();
}); 

createComponent 方法的结果是 AppComponent 类型的 ComponentFixture 实例,我们可以使用 componentInstance 属性获取组件实例。我们还使用 toBeTruthy 匹配器函数来检查生成的实例是否有效。

一旦我们能够访问组件实例,我们就可以查询其任何公共属性和方法:

it(`should have the 'my-app' title`, () => {
  const fixture = TestBed.createComponent(AppComponent);
  const app = fixture.componentInstance;
  expect(app.title).toEqual('my-app');
}); 

在前面的测试中,我们使用另一个匹配器函数 toEqual 检查 title 组件属性是否设置为 my-app

在新的 Angular 应用程序中,title 组件属性的值将是你在创建应用程序时通过 ng new 命令传递的名称。

正如我们所学的,组件由 TypeScript 类和模板文件组成。因此,仅从类角度测试,如前面的测试,是不够的。我们还应该测试类是否正确与 DOM 交互:

it('should render title', () => {
  const fixture = TestBed.createComponent(AppComponent);
  fixture.detectChanges();
  const compiled = fixture.nativeElement as HTMLElement;
  expect(compiled.querySelector('h1')?.textContent).toContain('Hello, my-app');
}); 

许多开发者更喜欢类测试而不是 DOM 测试,并依赖于 端到端E2E)测试,这种测试较慢且性能较差。端到端测试通常验证应用程序与后端 API 的集成,并且容易出错。因此,在 Angular 应用程序中执行 DOM 单元测试是推荐的。

在前面的测试中,我们创建了一个组件并调用了 ComponentFixturedetectChanges 方法。detectChanges 方法触发了 Angular 的变更检测机制,强制数据绑定更新。它在第一次调用时执行组件的 ngOnInit 生命周期事件,在后续调用中执行 ngOnChanges,这样我们就可以使用 nativeElement 属性查询组件的 DOM 元素。在这个例子中,我们检查与 title 属性对应的 HTML 元素的 textContent

运行测试时,我们使用 Angular CLI 的 ng test 命令。它将启动 Karma 测试运行器,获取所有单元测试文件,执行它们,并打开浏览器以显示每个测试的结果。Angular CLI 默认使用 Google Chrome 浏览器。输出将类似于以下内容:

包含文本、字体、屏幕截图、白色 描述由系统自动生成

图 13.1:测试执行输出

在前面的图中,我们可以在页面顶部看到每个测试的结果。我们还可以看到 Karma 如何通过套件视觉分组每个测试。在我们的例子中,唯一的测试套件是 AppComponent

现在,让我们让我们的一个测试失败。打开app.component.ts文件,将title属性的值更改为my-new-app,然后保存文件。Karma 将重新执行我们的测试,并在页面上显示结果:

包含文本、屏幕截图、字体、编号的图片,自动生成的描述

图 13.2:测试失败

Karma 以监视模式运行,因此我们不需要每次更改时都执行 Angular CLI 测试命令。

有时,在浏览器中阅读测试输出并不太方便。作为替代,我们可以检查我们用来运行ng test命令的控制台窗口,其中包含测试结果的裁剪版本:

Executed 3 of 3 SUCCESS (0.117 secs / 0.044 secs)
TOTAL: 3 SUCCESS 

通过查看 Angular CLI 为我们自动创建的AppComponent测试,我们已经获得了相当多的见解。在下一节中,我们将查看一个更高级的场景,用于测试具有依赖项的组件。

带依赖项的测试

在现实世界的场景中,组件通常不像主组件那样简单。它们几乎肯定依赖于一个或多个服务。它们也可能在其模板中包含其他子组件。

我们有不同方式处理这种情况下的测试。有一点很清楚:如果我们正在测试组件,我们不应该测试服务或其子组件。因此,当我们设置此类测试时,依赖项不应是真实类。在单元测试中,处理这个问题有不同方式;没有解决方案是绝对优于其他解决方案的:

  • 存根:一种指示依赖项注入器注入我们提供的存根而不是真实类的指令。

  • 间谍:一种注入实际依赖项但附加一个间谍到我们在组件中调用的方法的方法。然后我们可以返回模拟数据或让方法调用通过。

    当依赖项复杂时,使用存根而不是间谍更可取。一些服务会注入其他服务,因此在测试中使用真实依赖项需要您补偿其他依赖项。当我们要测试的组件在其模板中包含子组件时,这也是首选方法。

无论采用何种方法,我们都确保测试不会执行意外的操作,例如访问文件系统或尝试通过 HTTP 进行通信;我们正在完全隔离的情况下测试组件。

用存根替换依赖项

用存根替换依赖项意味着我们完全用假的依赖项替换了真实的依赖项。

我们可以通过以下方式创建一个假的依赖项:

  • 创建一个包含真实依赖项属性和方法的常量变量或类。

  • 为依赖项的实际类创建一个模拟定义。

这些方法并没有太大的区别。在本节中,我们将查看第一个方法,因为它在 Angular 开发中最为常见。您可以按照自己的节奏探索第二个方法。

考虑以下stub.component.ts组件文件:

import { Component, OnInit } from '@angular/core';
import { StubService } from '../stub.service';
@Component({
  selector: 'app-stub',
  template: '<span>{{ msg }}</span>'
})
export class StubComponent implements OnInit {
  msg = '';
  constructor(private stubService: StubService) {}
  ngOnInit(): void {
    this.msg = this.stubService.isBusy
      ? this.stubService.name + ' is on mission'
      : this.stubService.name + ' is available';
  }
} 

它注入了StubService,该服务包含两个公共属性。在测试中为该服务提供占位符相当直接,如下面的示例所示:

const serviceStub: Partial<StubService> = {
  name: 'Boothstomper'
}; 

我们将服务声明为Partial,因为我们只想最初设置name属性。现在我们可以使用对象字面量语法将占位符服务注入到我们的测试模块中:

await TestBed.configureTestingModule({
  imports: [StubComponent],
  **providers: [**
    **{ provide: StubService, useValue: serviceStub }**
  **]**
})
.compileComponents(); 

msg组件属性依赖于isBusy服务属性的值。因此,我们需要在测试套件中获取对服务的引用,并在每个测试中为该属性提供不同的值。我们可以使用TestBed类的inject方法获取注入的StubService实例:

describe('status', () => {
  let service: StubService;
  beforeEach(() => {
    service = TestBed.inject(StubService);
  })
}); 

我们将真实的StubService作为参数传递给inject方法,而不是我们创建的占位符版本。修改占位符的值不会影响注入的服务,因为我们的组件使用的是真实服务的实例。inject方法请求应用程序的根注入器提供所需的服务。如果服务是从组件注入器提供的,我们就需要使用fixture.debugElement.injector.get(StubService)从组件注入器获取它。

现在我们可以编写测试来检查msg组件属性在数据绑定期间是否表现正确:

describe('status', () => {
  let service: StubService;
  **let msgDisplay: HTMLElement;**
  beforeEach(() => {
    service = TestBed.inject(StubService);
    **msgDisplay = fixture.nativeElement.****querySelector****(****'span'****);**
  })
  **it('should be on a mission', () => {**
**service.isBusy = true;**
**fixture.detectChanges();**
**expect(msgDisplay.textContent).toContain('is on mission');**
**});**
  **it('should be available', () => {**
    **service.isBusy = false;**
    **fixture.detectChanges();**
    **expect(msgDisplay.textContent).toContain('is available');**
  **});**
}); 

我们已经从beforeEach语句中移除了fixture.detectChanges行,因为我们希望在测试中单独触发变更检测。

占位依赖项并不总是可行的,尤其是在根注入器不提供它的情况下。服务可以在组件注入器级别提供。使用我们之前查看的过程提供占位符没有任何效果。为了应对这种情况,我们可以使用TestBed类的overrideComponent方法:

await TestBed.configureTestingModule({
  imports: [StubComponent],
  providers: [
    { provide: StubService, useValue: serviceStub }
  ]
})
**.overrideComponent(StubComponent, {**
  **set: {**
    **providers: [**
      **{ provide: StubService, useValue: serviceStub }**
    **]**
  **}**
**})**
.compileComponents(); 

overrideComponent方法接受两个参数:提供服务的组件类型和一个覆盖元数据对象。元数据对象包含set属性,它为组件提供服务。

假设我们想要测试的组件在其模板中包含一个子组件,例如:

@Component({
  selector: 'app-stub',
  template: `
    <span>{{ msg }}</span>
    **<app-child></app-child>**
  `
}) 

在前面的例子中,当我们测试StubComponent时,我们还需要在配置测试模块时导入<app-child>组件的 TypeScript 类:

await TestBed.configureTestingModule({
  imports: [StubComponent],
  providers: [
    { provide: StubService, useValue: serviceStub }
  ],
  **imports: [ChildComponent]**
}) 

ChildComponent类可能还有其他依赖。为这些依赖提供占位符不可行,因为这不是被测试组件的责任。相反,我们可以为组件创建一个占位符 TypeScript 类,并在配置测试模块时导入它:

@Component({ selector: 'app-child', template: '' })
class ChildStubComponent {} 

在前面的代码片段中,我们在组件的template属性中传递了一个空数组,因为我们对子组件的内部实现不感兴趣。

如果子组件包含在测试父组件时使用的属性和方法,我们还需要在ChildStubComponent中定义它们。

或者,为了提供一个组件的存根,我们可以在配置测试模块时传递来自 @angular/core npm 包的 NO_ERRORS_SCHEMA

await TestBed.configureTestingModule({
  imports: [StubComponent],
  providers: [
    { provide: StubService, useValue: serviceStub },
  ],
  **schemas: [NO_ERRORS_SCHEMA]**
}) 

前面的代码片段指示 Angular 忽略任何未导入到测试模块中的组件。

存根依赖非常简单,但并不总是可能的,正如我们将在下一节中看到的。

监视依赖方法

使用存根不是在单元测试中隔离逻辑的唯一方法。我们不必替换整个依赖——只需替换组件使用的部分。替换某些部分意味着我们指出依赖中的特定方法,并将间谍分配给它们。间谍可以回答你想要的内容,但你也可以看到它被调用了多少次以及使用了什么参数。因此,间谍为你提供了更多关于发生情况的信息。

在依赖中设置间谍有两种方式:

  • 注入实际依赖并监视其方法。

  • 使用 Jasmine 的 createSpyObj 方法来创建一个假的依赖实例。然后我们可以像对待真实实例一样监视这个依赖的方法。

第一种情况在 Angular 开发中最为常见。让我们看看如何设置它。考虑以下 spy.component.ts 文件,它使用了 Angular 框架的 Title 服务:

import { Component, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
@Component({
  selector: 'app-spy',
  template: '{{ caption }}'
})
export class SpyComponent implements OnInit {
  caption = '';
  constructor(private title: Title) {}
  ngOnInit(): void {
    this.title.setTitle('My Angular app');
    this.caption = this.title.getTitle();
  }
} 

Title 服务与 Angular 应用程序中主 HTML 文档的标题进行交互。

我们对 Title 服务没有控制权,因为它已经内置到框架中。它可能包含我们不知道的依赖。监视其方法是我们在测试中使用它的最简单、最安全的方式。我们通过 providers 数组将其注入到测试模块中,然后在我们的测试中使用它,例如:

it('should set the title', () => {
  const title = TestBed.inject(Title);
  const spy = spyOn(title, 'setTitle');
  component.ngOnInit();
  expect(spy).toHaveBeenCalledWith('My Angular app');
}); 

我们使用 Jasmine 的 spyOn 方法,它接受两个参数:要监视的对象及其特定的方法。我们在调用 ngOnInit 组件方法之前使用它来在触发变更检测机制之前附加间谍。expect 语句验证 setTitle 方法是否以正确的参数被调用。

我们的组件还使用 getTitle 方法来获取文档标题。我们可以直接监视该方法并返回模拟数据:

  1. 首先,我们需要将 Title 服务定义为间谍对象,并通过传递两个参数——服务的名称和组件当前使用的方程序表来初始化它:

    const titleSpy = jasmine.createSpyObj('Title', [
      'getTitle', 'setTitle'
    ]); 
    
  2. 然后,我们将间谍附加到 getTitle 方法,并使用 Jasmine 的 returnValue 方法返回一个自定义标题:

    titleSpy.getTitle.and.returnValue('My title'); 
    
  3. 最后,我们在测试模块的 providers 数组中添加 titleSpy 变量:

    await TestBed.configureTestingModule({
      imports: [SpyComponent],
      **providers: [**
        **{ provide: Title, useValue: titleSpy }**
      **]**
    })
    .compileComponents(); 
    

结果测试应该看起来像以下这样:

it('should get the title', async () => {
  const titleSpy = jasmine.createSpyObj('Title', [
    'getTitle', 'setTitle'
  ]);    
  titleSpy.getTitle.and.returnValue('My title');
  await TestBed.configureTestingModule({
    imports: [SpyComponent],
    providers: [
      { provide: Title, useValue: titleSpy }
    ]
  })
  .compileComponents();    

  const fixture = TestBed.createComponent(SpyComponent);
  fixture.detectChanges();

  expect(fixture.nativeElement.textContent).toContain('My title');
}); 

很少有服务表现得很好且直接,例如 Title 服务,因为它们是同步的。大多数时候,它们是异步的,可以返回可观察对象或承诺。在下一节中,我们将学习如何测试异步依赖。

测试异步服务

Angular 测试工具提供了两个工具来处理异步测试场景:

  • waitForAsync:这是一种异步的单元测试服务的方法。它与ComponentFixture类的whenStable方法结合使用。

  • fakeAsync:这是一种同步的单元测试服务的方法。它通常与tick函数结合使用。

这两种方法提供大致相同的功能;它们只是在我们的使用方式上有所不同。让我们通过查看一个示例来看看我们如何使用每种方法。

考虑以下async.component.ts文件:

import { AsyncPipe } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { AsyncService } from '../async.service';
@Component({
  selector: 'app-async',
  imports: [AsyncPipe],
  template: `
    @for(item of items$ | async; track item) {
      <p>{{ item }}</p>
    }
  `
})
export class AsyncComponent implements OnInit {
  items$: Observable<string[]> | undefined;
  constructor(private asyncService: AsyncService) {}
  ngOnInit(): void {
    this.items$ = this.asyncService.getItems();
  }
} 

它从async.service.ts文件中注入AsyncService,并在ngOnInit方法中调用其getItems方法。正如我们所见,getItems方法返回一个字符串的可观察对象。它还引入了轻微的延迟,以便使场景看起来是异步的:

getItems(): Observable<string[]> {
  return of(items).pipe(delay(500));
} 

单元测试会查询组件的原生元素,并检查items$可观察对象的价值是否正确显示:

it('should get data with waitForAsync', waitForAsync(async() => {
  fixture.detectChanges();
  await fixture.whenStable();
  fixture.detectChanges();

  const itemDisplay: HTMLElement[] = fixture.nativeElement.querySelectorAll('p');
  expect(itemDisplay.length).toBe(2);
})); 

我们将测试的主体包裹在waitForAsync方法内,并调用detectChanges方法来触发变更检测。此外,我们调用whenStable方法,它返回一个立即解决的承诺,当items$可观察对象完成时。当承诺解决时,我们再次调用detectChanges方法来触发数据绑定并相应地查询 DOM。

当我们想要测试包含模板驱动的表单的组件时,也会使用whenStable方法。此方法的异步特性使得在 Angular 应用程序中使用响应式表单更为可取。

另一种同步的方法是使用fakeAsync方法,并按照以下方式编写相同的单元测试:

it('should get items with fakeAsync', fakeAsync(() => {
  fixture.detectChanges();
  tick(500);
  fixture.detectChanges();

  const itemDisplay: HTMLElement[] = fixture.nativeElement.querySelectorAll('p');
  expect(itemDisplay.length).toBe(2);
})); 

在前面的代码片段中,我们将测试的主体包裹在fakeAsync方法内,并用tick函数替换了whenStable方法。tick函数将时间推进500毫秒,这是我们引入AsyncServicegetItems方法中的虚拟延迟。

使用异步服务测试组件有时可能变得非常困难。然而,每种描述的方法都可以显著帮助我们完成这项任务。然而,组件不仅关于服务,还包括输入和输出绑定。在接下来的部分,我们将学习如何测试组件的公共 API。

输入和输出测试

到目前为止,我们已经学习了如何测试具有简单属性的组件以及处理同步和异步依赖。但组件的内容远不止于此。正如我们在第三章使用组件构建用户界面中学到的,组件有一个由输入和输出组成的公共 API,这些也应该被测试。

由于我们想要测试组件的公共 API,因此测试它从另一个组件托管时的交互是有意义的。测试这样的组件可以通过两种方式完成:

  • 我们可以验证我们的输入绑定是否设置正确。

  • 我们可以验证我们的输出绑定是否正确触发,以及它发出的内容是否被接收。

假设我们有一个包含输入和输出绑定的 bindings.component.ts 文件:

import { Component, input, output } from '@angular/core';
@Component({
  selector: 'app-bindings',
  template: `
    <p>{{ title() }}</p>
    <button (click)="liked.emit()">Like!</button>
  `
})
export class BindingsComponent {
  title = input('');
  liked = output();
} 

在我们开始编写测试之前,我们应该在 bindings.component.spec.ts 文件中创建一个测试宿主组件,该组件将使用要测试的组件:

@Component({
  imports: [BindingsComponent],
  template: `
    <app-bindings [title]="testTitle" (liked)="isFavorite = true"></app-bindings>
  `
})
export class TestHostComponent {
  testTitle = 'My title';
  isFavorite = false;
} 

在设置阶段,请注意 ComponentFixtureTestHostComponent 类型:

describe('BindingsComponent', () => {
  let component: **TestHostComponent**;
  let fixture: ComponentFixture<**TestHostComponent**>;
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [**TestHostComponent**]
    })
    .compileComponents();

    fixture = TestBed.createComponent(**TestHostComponent**);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });
  it('should create', () => {
    expect(component).toBeTruthy();
  });
}); 

我们的单元测试将验证 BindingsComponentTestHostComponent 交互时的行为。

第一个测试检查输入绑定是否已正确应用于 title 属性:

it('should display the title', () => {
  const titleDisplay: HTMLElement = fixture.nativeElement.querySelector('p');
  expect(titleDisplay.textContent).toEqual(component.testTitle);
}); 

第二个测试验证 isFavorite 属性是否正确地与 liked 输出事件连接:

it('should emit the liked event', () => {
  const button: HTMLButtonElement = fixture.nativeElement.querySelector('button');
  button.click();
  expect(component.isFavorite).toBeTrue();
}); 

在上一个测试中,我们使用 ComponentFixture 类的 nativeElement 属性查询 DOM 中的 <button> 元素。然后,我们点击它以触发输出事件。或者,我们也可以使用 debugElement 属性找到按钮,并使用其 triggerEventHandler 方法来点击它:

it('should emit the liked event using debugElement', () => {
  const buttonDe = fixture.debugElement.query(By.css('button'));
  buttonDe.triggerEventHandler('click');
  expect(component.isFavorite).toBeTrue();
}); 

在前面的测试中,我们使用了 query 方法,该方法接受一个 谓词 函数作为参数。谓词使用 By 类的 CSS 方法通过 CSS 选择器定位元素。

正如我们在 Angular 中引入单元测试 部分所学,debugElement 是框架无关的。如果你确定你的测试只会在浏览器中运行,你应该选择 nativeElement 属性。

triggerEventHandler 方法接受我们想要触发的事件名称作为参数;在这种情况下,是 click 事件。

如果我们只测试了 BindingsComponent,就可以避免很多代码,这仍然会是有效的。但我们就会错过将其作为真实场景进行测试的机会。组件的公共 API 旨在被其他组件使用,因此我们应该以这种方式进行测试。

目前,我们在 BindingsComponent 的模板中使用的按钮是一个原生的 HTML <button> 元素。如果按钮是 Angular Material 按钮组件,我们可以使用另一种与之交互的方法,这是下一节的主题。

使用组件 harness 进行测试

Angular CDK 库,Angular Material 的核心,包含一组实用工具,允许测试通过公共测试 API 与组件交互。Angular CDK 测试实用工具使我们能够通过组件 harness 访问 Angular Material 组件,而无需依赖它们的内部实现。

使用 harness 测试 Angular 组件的过程包括以下部分:

  • @angular/cdk/testing:包含与组件 harness 交互的基础设施的 npm 包。

  • 测试环境:组件 harness 测试将被加载的环境。Angular CDK 包含一个用于 Karma 单元测试的内置测试环境。它还提供了一套丰富的工具,允许开发者创建自定义测试环境。

  • 组件 harness:一个类,它为开发者提供了访问浏览器 DOM 中组件实例的权限。

要学习如何使用组件 harness,我们将BindingsComponent中的<button>元素转换为 Angular Material 按钮:

import { Component, input, output } from '@angular/core';
**import { MatButton } from '@angular/material/button';**
@Component({
  selector: 'app-bindings',
  imports: [**MatButton**],
  template: `
    <p>{{ title() }}</p>
    <button **mat-button** (click)="liked.emit()">Like!</button>
  `
}) 

前面的代码片段假设你已经将 Angular Material 库添加到你正在工作的项目中。

要开始使用 Angular CDK 的组件 harness,我们需要从@angular/cdk/testing命名空间导入以下元素:

import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatButtonHarness } from '@angular/material/button/testing'; 

在前面的代码片段中,我们添加了以下类:

  • TestbedHarnessEnvironment:表示使用 Karma 运行单元测试的测试环境。

  • MatButtonHarness:Angular Material 按钮组件的组件 harness。Angular Material 库中的几乎所有组件都有一个相应的组件 harness,我们可以使用它。

    如果你是一个组件库的作者,Angular CDK 提供了创建 UI 组件 harness 所需的所有必要工具。

在我们导入所有必要的元素之后,我们可以编写我们的测试:

it('should emit the liked event using harness', async () => {
  const loader = TestbedHarnessEnvironment.loader(fixture);
  const buttonHarness = await loader.getHarness(MatButtonHarness);
  await buttonHarness.click();
  expect(component.isFavorite).toBeTrue();
}); 

在前面的测试中,测试环境的loader方法接受当前组件的ComponentFixture实例作为参数,并返回一个HarnessLoader对象。Angular CDK harness 提供的抽象是基于它操作组件固定层(component fixture),这是一个在真实 DOM 元素之上的抽象层。

我们在测试的体内包围一个async函数,因为组件 harness 是基于 promise 的。我们使用 harness loader 的getHarness方法来加载按钮组件的特定 harness。最后,我们调用按钮组件 harness 的click方法来触发按钮点击事件。

我们不需要调用detectChanges方法,因为 Angular CDK 组件 harness 会自动触发变更检测。

组件 harness 是 Angular CDK 的一个强大工具,确保我们在测试期间以抽象和安全的方式与组件交互。

我们已经讨论了许多测试具有依赖项的组件的方法。现在,是时候学习如何测试依赖项本身了。

测试服务

正如我们在第五章使用服务管理复杂任务中学到的,一个服务可以注入其他服务。测试独立服务相当直接:我们从注入器获取一个实例,然后开始查询其公共属性和方法。

我们只对测试服务的公共 API 感兴趣,这是组件和其他元素使用的接口。私有属性和方法在测试中没有价值,因为它们代表了服务的内部实现。

在服务中,我们可以执行两种不同的测试:

  • 测试同步和异步操作,例如返回一个简单数组的方法或返回一个可观察对象的方法

  • 测试具有依赖项的服务,例如执行 HTTP 请求的方法

在以下部分,我们将更详细地介绍每个部分。

测试同步/异步方法

当我们使用 Angular CLI 创建 Angular 服务时,它也会创建一个相应的测试文件。考虑以下 async.service.spec.ts 文件,这是我们之前使用的 AsyncService 的测试文件:

import { TestBed } from '@angular/core/testing';
import { AsyncService } from './async.service';
describe('AsyncService', () => {
  let service: AsyncService;
  beforeEach(() => {
    TestBed.configureTestingModule({});
    service = TestBed.inject(AsyncService);
  });
  it('should be created', () => {
    expect(service).toBeTruthy();
  });
}); 

AsyncService 不依赖于任何东西。它还由 Angular 应用的根注入器提供,因此它将一个空对象传递给 configureTestingModule 方法。我们可以使用 TestBed 类的 inject 方法获取我们测试的服务的实例。

我们可以编写的第一个测试相当直接,因为它调用 setItems 方法并检查其结果:

it('should set items', () => {
  const result = service.setItems('Camera');
  expect(result.length).toBe(3);
}); 

与前一个案例中同步方法的测试相比,编写同步方法的测试通常相对容易;然而,当我们想要测试如下的异步方法时,情况就不同了。

第二个测试有点棘手,因为它涉及到一个可观察对象。我们需要订阅 getItems 方法,并在可观察对象完成时立即检查其值:

it('should get items', (done: DoneFn) => {
  service.getItems().subscribe(items => {
    expect(items.length).toBe(2);
    done();
  });
}); 

Karma 测试运行器不知道可观察对象何时完成,因此我们提供了 done 方法来表示可观察对象已完成,我们现在可以断言 expect 语句。

测试具有依赖关系的服务

测试具有依赖关系的服务与测试具有依赖关系的组件类似。我们在 测试组件 部分看到的每个方法都可以类似地应用;然而,当我们测试注入 HttpClient 服务的服务时,我们遵循不同的方法。

考虑以下使用 HTTP 客户端的 deps.service.ts 文件:

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
@Injectable({
  providedIn: 'root'
})
export class DepsService {
  constructor(private http: HttpClient) { }
  getItems() {
    return this.http.get('http://some.url');
  }
  addItem(item: string) {
    return this.http.post('http://some.url', { name: item });
  }
} 

Angular 测试工具提供了两个用于在单元测试中模拟 HTTP 请求的工件:provideHttpClientTesting 函数,它为测试提供了一个 HTTP 客户端,以及 HttpTestingController,它模拟 HttpClient 服务。我们可以从 @angular/common/http/testing 命名空间导入这两个:

import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
**import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';**
import { DepsService } from './deps.service';
describe('DepsService', () => {
  let service: DepsService;
  **let httpTestingController: HttpTestingController;**
  beforeEach(() => {
    TestBed.configureTestingModule({
      **providers: [**
        provideHttpClient(),
        **provideHttpClientTesting()**
      **]**
    });
    service = TestBed.inject(DepsService);
    **httpTestingController = TestBed.inject(HttpTestingController);**
  });
}); 

我们的测试不应该发出真实的 HTTP 请求。它们只需要验证它将以正确的选项发出。以下是对 getItems 方法进行验证的第一个测试:

it('should get items', () => {
  service.getItems().subscribe();
  const req = httpTestingController.expectOne('http://some.url');
  expect(req.request.method).toBe('GET');
}); 

在前面的测试中,我们使用 HttpTestingControllerexpectOne 方法创建了一个假请求,该方法接受一个 URL 作为参数。expectOne 方法创建一个模拟请求对象,并断言只向特定的 URL 发出了一个请求。在我们创建我们的请求后,我们可以验证其方法是 GET

在测试 addItem 方法时,我们遵循类似的方法,但我们需要确保请求体包含正确的数据:

it('should add an item', () => {
  service.addItem('Camera').subscribe();
  const req = httpTestingController.expectOne('http://some.url');
  expect(req.request.method).toBe('POST');
  expect(req.request.body).toEqual({
    name: 'Camera'
  });
}); 

在每个测试之后,我们使用 afterEach 块内的 verify 方法确保没有未匹配的请求挂起:

afterEach(() => {
  httpTestingController.verify();
}); 

在以下部分,我们继续我们的测试之旅,通过学习如何测试一个管道来继续探索测试世界。

测试管道

正如我们在 第四章使用管道和指令丰富应用程序 中所学,管道是一个实现了 PipeTransform 接口的 TypeScript 类。它公开了一个 transform 方法,这个方法通常是同步的,这意味着它很容易测试。

考虑包含将逗号分隔的字符串转换为列表的管道的 list.pipe.ts 文件:

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'list'
})
export class ListPipe implements PipeTransform {
  transform(value: string): string[] {
    return value.split(',');
  }
} 

编写测试很简单。我们唯一需要做的是实例化 ListPipe 类的一个实例,并使用一些模拟数据验证 transform 方法的输出结果:

it('should return an array', () => {
  const pipe = new ListPipe();
  expect(pipe.transform('A,B,C')).toEqual(['A', 'B', 'C']);
}); 

在测试管道时,不涉及 Angular 测试工具。我们创建管道类的实例,然后可以开始调用 transform 方法。

Angular 指令是我们可能不会经常创建的工件,因为框架提供的内置集合已经足够多了;然而,如果我们创建了自定义指令,我们也应该测试它们。在下一节中,我们将学习如何完成这项工作。

测试指令

指令在整体形状上通常非常直接,它们是没有视图的组件。指令通常与组件一起工作的事实,让我们对测试它们时如何进行有了很好的了解。

考虑我们创建在 第五章使用管道和指令丰富应用程序 中的 copyright.directive.ts 文件:

import { Directive, ElementRef } from '@angular/core';
@Directive({
  selector: '[appCopyright]'
})
export class CopyrightDirective {
  constructor(el: ElementRef) {
    const currentYear = new Date().getFullYear();
    const targetEl: HTMLElement = el.nativeElement;
    targetEl.classList.add('copyright');
    targetEl.textContent = `Copyright ©${currentYear} All Rights Reserved`;
  }  
} 

指令通常与组件一起使用,因此在使用组件的同时对其进行单元测试是有意义的。让我们创建一个测试宿主组件并将其添加到测试模块的 imports 数组中:

@Component({
  imports: [CopyrightDirective],
  template: '<span appCopyright></span>'
})
class TestHostComponent { } 

我们现在可以编写测试来检查 <span> 元素是否包含 copyright 类,并在其 textContent 属性中显示当前年份:

describe('CopyrightDirective', () => {
  let container: HTMLElement;
  beforeEach(() => {
    const fixture = TestBed.configureTestingModule({
      imports: [TestHostComponent]
    })
    .createComponent(TestHostComponent);
    container = fixture.nativeElement.querySelector('span');
  });
  it('should have copyright class', () => {
    expect(container.classList).toContain('copyright');
  });
  it('should display copyright details', () => {
    expect(container.textContent).toContain(new Date().getFullYear().toString());
  });
}); 

这就是测试指令可以有多简单。关键要点是,你需要一个组件来放置指令,并且你隐式地使用组件来测试指令。

在下一节中,我们将学习如何测试响应式表单。

测试表单

正如我们在 第十章使用表单收集用户数据 中所见,表单对于 Angular 应用程序至关重要。Angular 应用程序没有至少一个简单表单的情况很少见,例如搜索表单。在本章中,我们将专注于响应式表单,因为它们比模板驱动的表单更容易测试。

考虑以下 search.component.ts 文件:

import { Component } from '@angular/core';
import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
@Component({
  selector: 'app-search',
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="searchForm" (ngSubmit)="search()">
      <input type="text" formControlName="searchText">
      <button type="submit" [disabled]="searchForm.invalid">Search</button>
    </form>
  `
})
export class SearchComponent {
  searchForm = new FormGroup({
    searchText: new FormControl('', Validators.required)
  });
  search() {
    if(this.searchForm.valid) {
      console.log('You searched for: ' + this.searchForm.controls.searchText.value);
    }
  }
} 

在前面的组件中,我们可以编写单元测试来验证以下内容:

  • searchText 表单控件的值可以正确设置

  • 当表单无效时,Search 按钮被禁用

  • 当表单有效,并且用户点击 Search 按钮时,会调用 console.log 方法

要测试响应式表单,我们首先需要将 ReactiveFormsModule 导入到测试模块中:

await TestBed.configureTestingModule({
  imports: [SearchComponent, **ReactiveFormsModule**]
})
.compileComponents(); 

对于第一个测试,我们需要断言当我们向输入控件中输入内容时,值是否传播到 searchText 表单控件:

it('should set the searchText', () => {
  const input: HTMLInputElement = fixture.nativeElement.querySelector('input');
  input.value = 'Angular';
  input.dispatchEvent(new CustomEvent('input'));
  expect(component.searchForm.controls.searchText.value).toBe('Angular');
}); 

在前面的测试中,我们使用 nativeElement 属性的 querySelector 方法找到 <input> HTML 元素并设置其值。但仅此不足以使值传播到表单控件。Angular 框架将不知道 <input> HTML 元素的值是否已更改,直到我们触发该元素的 input DOM 事件。我们正在使用 dispatchEvent 方法来触发事件,该方法接受一个参数,该参数指向 CustomEvent 类的实例。

现在我们确信 searchText 表单控件已正确连接,我们可以使用它来编写剩余的测试:

it('should disable search button', () => {
  const button: HTMLButtonElement = fixture.nativeElement.querySelector('button');
  component.searchForm.controls.searchText.setValue('');
  expect(button.disabled).toBeTrue();
});
it('should log to the console', () => {
  const button: HTMLButtonElement = fixture.nativeElement.querySelector('button');
  const spy = spyOn(console, 'log');
  component.searchForm.controls.searchText.setValue('Angular');
  fixture.detectChanges();
  button.click();
  expect(spy).toHaveBeenCalledWith('You searched for: Angular');
}); 

注意,在第二个测试中,我们设置了 searchText 表单控件的值,然后调用按钮的 detectChanges 方法以启用它。点击按钮将触发表单的 submit 事件,我们最终可以断言测试的期望。

在表单有多个控件的情况下,在测试中查询它们并不方便。作为替代,我们可以创建一个 Page 对象,该对象负责查询 HTML 元素并监视服务:

class Page {
  get searchText() { return this.query<HTMLInputElement>('input'); }
  get submitButton() { return this.query<HTMLButtonElement>('button'); }
  private query<T>(selector: string): T {
    return fixture.nativeElement.querySelector(selector);
  }
} 

然后,我们可以在 beforeEach 语句中创建 Page 对象的实例,并在测试中访问其属性和方法。

正如我们所见,响应式表单很容易测试,因为表单模型是唯一的真相来源。在下一节中,我们将学习如何测试使用路由的 Angular 应用程序的某些部分。

测试路由器

测试与 Angular 路由交互的代码可能很容易成为一个单独的章节。在本节中,我们将关注以下路由概念:

  • 路由和路由组件

  • 守卫

  • 解析器

让我们首先看看如何测试路由和路由组件。

路由和路由组件

路由组件是在我们导航到特定应用程序路由时被激活的组件。考虑以下 app.routes.ts 文件:

import { Routes } from '@angular/router';
import { RoutedComponent } from './routed/routed.component';
export const routes: Routes = [
  { path: 'routed', component: RoutedComponent }
]; 

RoutedComponent 类定义在下面的 routed.component.ts 文件中:

import { Component } from '@angular/core';
@Component({
  selector: 'app-routed',
  template: '<span>{{ title }}</span>'
})
export class RoutedComponent {
  title = 'My routed component';
} 

前面的组件将 title 组件属性的值绑定到一个 <span> HTML 元素上。我们将编写的测试将断言绑定是否正确工作。

Angular 路由测试基于我们在 测试组件 部分学习到的组件 harness 方法。它公开了 RouterTestingHarness 类,该类包含在测试中处理路由组件的各种实用方法:

import { RouterTestingHarness } from '@angular/router/testing'; 

在我们开始测试路由组件之前,我们必须在测试模块中注册应用程序的路由配置:

beforeEach(async () => {
  await TestBed.configureTestingModule({
    **providers: [provideRouter(routes)]**
  })
  .compileComponents();

  fixture = TestBed.createComponent(RoutedComponent);
  component = fixture.componentInstance;
  fixture.detectChanges();
}); 

在前面的设置过程中,我们提供了与应用程序路由配置相同的 app.config.ts 文件。

我们已经了解到,我们可以从 ComponentFixture 类查询原生 HTML 元素的 DOM。当使用路由器加载组件时,我们使用 RouterTestingHarness 类的 routeNativeElement 属性:

it('should display a span element', async () => {
  const harness = await RouterTestingHarness.create();
  await harness.navigateByUrl('/routed');
  expect(harness.routeNativeElement?.querySelector('span')?.textContent).toBe('My routed component');
}); 

前面的测试被分为以下步骤:

  1. 我们使用RouterTestingHarnesscreate方法为我们的组件创建一个新的路由测试工具。

  2. 我们使用navigateByUrl方法导航到已注册的路由路径。根据应用程序的路由配置,/routedURL 将激活测试中的组件。

  3. 我们使用routeNativeElement属性的标准化查询方法来验证<span>HTML 元素是否显示了正确的文本。

RouterTestingHarness类还包含routeDebugElement属性,它跨平台的工作方式类似于ComponentFixture类的debugElement属性。

路由组件是一个用于在 Angular 应用程序中导航到另一个组件的组件。它通常涉及调用Router服务的navigate方法,如下所示:

import { Component } from '@angular/core';
**import { Router } from '@angular/router';**
@Component({
  selector: 'app-routed',
  template: '<span>{{ title }}</span>'
})
export class RoutedComponent {
  title = 'My routed component';
  **constructor(private router: Router) {}**
  **goBack() {**
    **this.router.navigate(['/']);**
  **}**
} 

根据前面的代码片段,我们的测试应该验证当我们调用goBack方法时,路由器将导航到根路径:

it('should navigate to the root path', () => {
  component.goBack();
  expect(TestBed.inject(Router).url).toBe('/');
}); 

在前面的测试中,我们使用TestBed类的inject方法获取Router服务的引用。然后我们访问url属性以验证导航过程是否正确完成。

在以下部分,我们将学习如何测试路由守卫。

守卫

我们在第九章使用路由导航应用程序中了解到,路由守卫是普通的函数。

考虑以下检查用户认证状态的守卫:

import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = () => {
  const authService = inject(AuthService);
  const router = inject(Router);
  if (authService.isLoggedIn) {
    return true;
  }
  return router.parseUrl('/');
}; 

在前面的守卫中,我们检查了以下AuthService类的isLoggedIn属性:

import { Injectable } from '@angular/core';
@Injectable({
  providedIn: 'root'
})
export class AuthService {
  isLoggedIn = false;
} 

我们决定保持AuthService类简单,并专注于认证守卫的逻辑。

如果isLoggedIn属性为true,守卫也返回true。否则,它执行Router服务的parseUrl方法,将用户重定向到根路径。

Angular CLI 为守卫创建了以下单元测试:

import { TestBed } from '@angular/core/testing';
import { CanActivateFn } from '@angular/router';
import { authGuard } from './auth.guard';
describe('authGuard', () => {
  const executeGuard: CanActivateFn = (...guardParameters) => 
      TestBed.runInInjectionContext(() => authGuard(...guardParameters));
  beforeEach(() => {
    TestBed.configureTestingModule({});
  });
  it('should be created', () => {
    expect(executeGuard).toBeTruthy();
  });
}); 

在前面的代码片段中,executeGuard变量封装了authGuard函数的创建。它使用TestBed类的runInInjectionContext方法,通过inject方法允许注入所需的服务。

要创建验证认证守卫使用的单元测试,我们必须执行以下步骤:

  1. 按照以下方式修改@angular/routernpm 包的import语句:

    import {
      **ActivatedRouteSnapshot**,
      CanActivateFn,
      **Router,**
      **RouterStateSnapshot**
    } from '@angular/router'; 
    
  2. 添加以下import语句:

    import { AuthService } from './auth.service'; 
    
  3. 创建以下与注入服务对应的变量:

    let authService: AuthService;
    let routerSpy: jasmine.SpyObj<Router>; 
    
  4. 在测试套件的beforeEach语句中初始化前面的变量:

    beforeEach(() => {
      **routerSpy = jasmine.createSpyObj('Router', ['parseUrl']);**
      TestBed.configureTestingModule({
        **providers: [**
          **{ provide: Router, useValue: routerSpy }**
        **]**
      });
      **authService = TestBed.inject(AuthService);**
    }); 
    

在前面的代码片段中,我们使用createSpyObj方法为Router服务创建一个间谍对象,并将其提供给测试模块。此外,我们使用TestBed类的inject方法获取实际AuthService类的实例,因为它是一个没有依赖的简单服务。

  1. 第一个单元测试应该断言当用户认证时,守卫执行返回true

    it('should return true', () => {
      authService.isLoggedIn = true;
      expect(executeGuard({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot)).toBeTrue();
    }); 
    

我们为ActivatedRouteSnapshotRouterStateSnapshot参数传递一个空对象,因为它们在守卫中是不必要的。

  1. 第二个单元测试应验证守卫执行导致重定向到根路径:

    it('should redirect', () => {
      authService.isLoggedIn = false;
      executeGuard({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot);
      expect(routerSpy.parseUrl).toHaveBeenCalledWith('/');
    }); 
    

在下一节中,我们将学习如何测试守卫解析器。

解析器

路由解析器是特定类型的普通函数,类似于守卫。在测试解析器时最常见的情况是验证返回的数据是否正确。

考虑以下解析器,它返回一个项目列表:

import { ResolveFn } from '@angular/router';
import { AsyncService } from './async.service';
import { inject } from '@angular/core';
export const itemsResolver: ResolveFn<string[]> = () => {
  const asyncService = inject(AsyncService);
  return asyncService.getItems();
}; 

解析器使用了我们之前看到的AsyncService,它通过getItems方法返回一个包含项目的可观察对象。

Angular CLI 在搭建解析器时会最初创建以下单元测试文件:

import { TestBed } from '@angular/core/testing';
import { ResolveFn } from '@angular/router';
import { itemsResolver } from './items.resolver';
describe('itemsResolver', () => {
  const executeResolver: ResolveFn<boolean> = (...resolverParameters) => 
      TestBed.runInInjectionContext(() => itemsResolver(...resolverParameters));
  beforeEach(() => {
    TestBed.configureTestingModule({});
  });
  it('should be created', () => {
    expect(executeResolver).toBeTruthy();
  });
}); 

在前面的代码片段中,executeResolver变量封装了itemsResolver函数的创建,类似于它对守卫的处理。它还使用了TestBed类的runInInjectionContext方法来允许注入所需的服务。

我们的解析器逻辑非常简单,因此我们必须编写一个单独的单元测试:

  1. 修改@angular/router npm 包的import语句如下:

    import {
      **ActivatedRouteSnapshot**,
      ResolveFn,
      **RouterStateSnapshot**
    } from '@angular/router'; 
    
  2. 添加以下import语句:

    import { Observable } from 'rxjs'; 
    
  3. executeResolver变量的类型更改为ResolveFn<string[]>,以便与itemsResolver函数的签名匹配:

    const executeResolver: ResolveFn<**string[]**> = (...resolverParameters) => 
        TestBed.runInInjectionContext(() => itemsResolver(...resolverParameters)); 
    
  4. 编写以下单元测试:

    it('should return items', () => {
      (executeResolver({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot) as Observable<string[]>).subscribe(items => {
        expect(items).toEqual(['Microphone', 'Keyboard']);
      })
    }); 
    

为了验证解析器返回的数据是否正确,我们必须订阅executeResolver函数。

在本节中,我们学习了如何对 Angular 路由器的一些重要功能进行单元测试。

摘要

我们的测试之旅即将结束,这是一段漫长但令人兴奋的旅程。在本章中,我们看到了在 Angular 应用程序中引入单元测试的重要性,单元测试的基本形状以及为我们的测试设置 Jasmine 的过程。

我们还学习了如何为我们的组件、指令、管道和服务编写健壮的测试。我们还讨论了如何测试 Angular 响应式表单和路由器。

本单元测试章节几乎完成了构建完整 Angular 应用程序的拼图。只剩下最后一块,这很重要,因为网络应用程序最终注定要面向网络。因此,在下一章中,我们将学习如何为 Angular 应用程序生成生产构建并将其部署以与世界分享!

第十四章:将应用程序推向生产

一个 Web 应用程序通常应该在 Web 上运行,并且任何人从任何地方都可以访问。它需要两个基本要素:一个托管应用程序的 Web 服务器和一个生产构建,以便将其部署到该服务器。在本章中,我们将关注食谱的第二部分。

简而言之,一个 Web 应用的生产构建是应用程序代码的优化版本,它更小、更快、性能更优。主要来说,这是一个将所有代码文件应用于优化技术,并将它们转换成一个单包文件的过程。

在前面的章节中,我们了解了构建 Angular 应用程序所涉及到的许多部分。我们只需要最后一块拼图,将所有部分连接起来,使我们的应用程序可供任何人使用,那就是构建它并将其部署到 web 服务器上。

在本章中,我们将学习以下概念:

  • 构建 Angular 应用程序

  • 限制应用程序包的大小

  • 优化应用程序包

  • 部署 Angular 应用程序

技术要求

本章包含各种代码示例,以指导您了解将应用程序推向生产的概念。

您可以在以下 GitHub 仓库的 ch14 文件夹中找到相关的源代码:

www.github.com/PacktPublishing/Learning-Angular-Fifth-Edition

构建 Angular 应用程序

要构建 Angular 应用程序,我们使用以下 Angular CLI 命令:

ng build 

构建过程启动 Angular 编译器,它主要收集我们应用程序代码中的所有 TypeScript 和 HTML 文件,并将它们转换为 JavaScript。CSS 样式表文件(如 SCSS)被转换为纯 CSS 文件。构建过程确保我们的应用程序在浏览器中的快速和最优渲染。

一个 Angular 应用程序包含各种 TypeScript 文件,这些文件在运行时通常不使用,例如单元测试或工具辅助程序。编译器通过读取 tsconfig.app.json 文件的 files 属性来确定哪些文件需要收集到构建过程中:

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/app",
    "types": []
  },
  **"files": [**
    **"src/main.ts"**
  **],**
  "include": [
    "src/**/*.d.ts"
  ]
} 

src/main.ts 文件是应用程序的主要入口点,它帮助 Angular 遍历我们应用程序需要的所有组件、服务和其它 Angular 元素。

ng build 命令的输出如下所示:

Initial chunk files   | Names         |  Raw size | Estimated transfer size
main-N4USDVTP.js      | main          | 206.91 kB |                55.87 kB
polyfills-SCHOHYNV.js | polyfills     |  34.52 kB |                11.29 kB
styles-5INURTSO.css   | styles        |   0 bytes |                 0 bytes
                      | Initial total | 241.44 kB |                67.16 kB 

此输出显示了从构建 Angular 应用程序生成的 JavaScript 和 CSS 文件,即:

  • main:我们所编写的实际应用程序代码

  • polyfills:为旧浏览器提供的功能 polyfills

  • styles:我们应用程序的全局 CSS 样式

Angular 编译器将前面的文件输出到 dist\appName\browser 文件夹中,其中 appName 是应用程序名称。它还包含以下文件:

  • favicon.ico:Angular 应用程序的图标

  • index.html:Angular 应用程序的主要 HTML 文件

Angular CLI 的 ng build 命令可以在两种模式下运行:开发和生产。默认情况下,它以生产模式运行。要将其以开发模式运行,我们应该运行以下 Angular CLI 命令:

ng build --configuration=development 

前面的命令将产生如下所示的输出:

Initial chunk files | Names         | Raw size
main.js             | main          |  1.25 MB | 
polyfills.js        | polyfills     | 90.23 kB | 
styles.css          | styles        | 95 bytes | 
                    | Initial total |  1.35 MB 

在前面的输出中,您可能会注意到 Initial chunk files 的名称不包含哈希数字,这与生产构建的情况不同。在生产模式下,Angular CLI 对应用程序代码执行各种优化技术,例如图像优化和即时编译AOT),以确保最终输出适合托管在 Web 服务器和生产环境中。添加到每个文件的哈希数字确保在部署应用程序的新版本时,浏览器的缓存会快速失效它们。

当我们在开发模式下运行 Angular CLI 的 ng build 命令时,我们使用了 --configuration 选项。--configuration 选项允许我们在不同的环境中运行 Angular 应用程序。我们将在下一节学习如何定义 Angular 环境。

为不同环境构建

一个组织可能希望为需要不同变量(如后端 API 端点和应用程序本地设置)的多个环境构建 Angular 应用程序。一个常见的用例是在部署到生产之前测试应用程序的预发布环境。

Angular CLI 允许我们为每个环境定义不同的配置,并使用每个配置构建我们的应用程序。我们可以使用以下语法在执行 ng build 命令时传递配置名称作为参数:

ng build --configuration=name 

我们还可以在其他 Angular CLI 命令中传递配置,例如 ng serveng test

我们可以使用以下 Angular CLI 命令来开始使用环境:

ng generate environments 

此命令将在 Angular 项目中创建一个 src\environments 文件夹,其中包含以下文件:

  • environment.ts:应用程序的默认环境,在生产期间使用

  • environment.development.ts:开发期间使用的应用程序环境

它还会在 Angular 项目的 angular.json 配置文件中添加一个 fileReplacements 部分:

"development": {
  "optimization": false,
  "extractLicenses": false,
  "sourceMap": true,
  **"fileReplacements": [**
    **{**
      **"replace": "src/environments/environment.ts",**
      **"with": "src/environments/environment.development.ts"**
    **}**
  **]**
} 

在前面的代码片段中,fileReplacements 属性定义了在执行 build 命令时的 development 环境中将替换默认环境文件的环境文件。如果我们运行 ng build --configuration=development 命令,Angular CLI 将将应用程序包中的 environment.ts 文件替换为 environment.development.ts 文件。

每个环境文件导出一个 environment 对象,其中我们可以定义额外的应用程序属性,例如后端 API 的 URL:

export const environment = {
  **apiUrl: 'https://my-default-url'**
}; 

所有环境文件中必须定义导出对象的相同属性。

我们需要导入默认环境才能在 Angular 应用程序中访问环境属性。例如,要使用主应用程序组件中的apiUrl属性,我们应该这样做:

import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
**import { environment } from '../environments/environment';**
@Component({
  selector: 'app-root',
  imports: [RouterOutlet],
  templateUrl: './app.component.html',
  styleUrl: './app.component.css'
})
export class AppComponent {
  title = 'my-app';
  **apiUrl = environment.apiUrl;**
} 

并非 Angular 应用程序中的所有库都可以像大多数 Angular 第一方库那样导入为 JavaScript 模块。在下一节中,我们将学习如何导入需要全局window对象的库。

为窗口对象构建

Angular 应用程序可能使用像jQuery这样的库,它必须附加到window对象上。其他库,如Bootstrap,包含字体、图标和 CSS 文件,这些文件必须包含在应用程序包中。

在所有这些情况下,我们需要告诉 Angular CLI 它们的存在,以便它可以将其包含在最终包中。

angular.json配置文件包含一个在build配置中的options对象,我们可以使用它来定义此类文件:

"options": {
  "outputPath": "dist/my-app",
  "index": "src/index.html",
  "browser": "src/main.ts",
  "polyfills": [
    "zone.js"
  ],
  "tsConfig": "tsconfig.app.json",
  "assets": [
    {
      "glob": "**/*",
      "input": "public"
    }
  ],
  "styles": [
    "src/styles.css"
  ],
  "scripts": []
} 

options对象包含以下属性,我们可以使用它们:

  • assets:包含来自public文件夹的静态文件,如图标、字体和翻译。

  • styles:包含外部 CSS 样式表文件。默认情况下,应用程序的全局 CSS 样式表文件被包含在内。

  • scripts:包含外部 JavaScript 文件。

随着我们向 Angular 应用程序添加越来越多的功能,最终包的大小在某些时候会变得更大。在下一节中,我们将学习如何使用预算来减轻这种影响。

限制应用程序包的大小

作为开发者,我们总是希望为最终用户提供具有酷炫功能的令人印象深刻的程序。因此,我们最终会向我们的 Angular 应用程序添加越来越多的功能——有时根据规格,有时为了向用户提供额外的价值。然而,向 Angular 应用程序添加新功能会导致其大小增加,这可能在某些时候是不可接受的。为了克服这个问题,我们可以使用预算

预算是我们可以在angular.json配置文件中定义的阈值,我们可以确保应用程序的大小不超过这些阈值。要设置预算,我们可以使用build命令中的production配置的budgets属性:

"budgets": [
  {
    "type": "initial",
    "maximumWarning": "500kB",
    "maximumError": "1MB"
  },
  {
    "type": "anyComponentStyle",
    "maximumWarning": "4kB",
    "maximumError": "8kB"
  }
] 

Angular CLI 在创建新的 Angular CLI 项目时定义了前面的默认预算。

我们可以为不同类型定义预算,例如整个 Angular 应用程序或其某些部分。预算的阈值可以定义为字节、千字节、兆字节或其百分比。当达到或超过阈值定义的值时,Angular CLI 会显示警告或抛出错误。

为了更好地理解它,让我们描述前面的默认示例:

  • 当 Angular 应用程序的大小超过500 KB 时,会显示警告,当超过1 MB 时,会显示错误。

  • 当任何组件样式的尺寸超过4 KB 时,会显示警告,当超过8 KB 时,会显示错误。

要查看在配置 Angular 应用程序的预算时可以定义的所有可用选项,请查看官方文档网站上的指南:angular.dev/tools/cli/build/#configuring-size-budgets

当我们希望提供一种警报机制以应对 Angular 应用程序显著增长时,预算是非常有用的。然而,它们只是信息预防和措施的一个级别。在下一节中,我们将学习如何最小化我们的包大小。

优化应用程序包

正如在构建 Angular 应用程序部分中学到的,当我们构建 Angular 应用程序时,Angular CLI 会执行优化技术。在应用程序代码中执行的优化过程包括现代 Web 技术和工具,如下所示:

  • 压缩:将多行源文件转换为单行,删除空白和注释。这是一个使浏览器能够更快地解析它们的进程。

  • 混淆:将属性和方法重命名为非人类可读的形式,以便它们难以理解和用于恶意目的。

  • 打包:将应用程序的所有源文件连接成一个单一的文件,称为包。

  • 摇树优化:删除未使用的文件和 Angular 组件和服务等 Angular 工件,从而减小包的大小。

  • 字体优化:在应用程序的主要 HTML 文件中内联外部字体文件,而不会阻塞渲染请求。它目前支持 Google Fonts 和 Adobe Fonts,并需要互联网连接来下载它们。

  • 构建缓存:缓存之前的构建状态,并在我们运行相同的构建时恢复它,从而减少构建应用程序所需的时间。

如果在所有先前的优化技术之后,Angular 应用程序的最终包仍然很大,我们可以使用一个名为source-map-explorer的外部工具来调查原因。也许我们重复导入了 JavaScript 库或包含了一个未使用的文件。该工具分析我们的应用程序包,并以可视化的形式显示我们使用的所有 Angular 工件和库。要开始使用它,请按照以下步骤操作:

  1. 从终端安装source-map-explorer npm 包:

    npm install source-map-explorer --save-dev 
    
  2. 构建您的 Angular 应用程序并启用源映射:

    ng build --source-map 
    
  3. package.json文件中添加以下脚本:

    "scripts": {
      "ng": "ng",
      "start": "ng serve",
      "build": "ng build",
      "watch": "ng build --watch --configuration development",
      "test": "ng test",
      **"analyze": "source-map-explorer"**
    } 
    
  4. main包文件上运行以下命令:

    npm run analyze dist/my-app/browser/main*.js 
    

它将在浏览器中打开应用程序包的视觉表示:

img

图 14.1:源映射探索器输出

我们可以与之交互并检查它,以了解为什么我们的包仍然太大。一些可能的原因如下:

  • 包中包含了一个库

  • 包含了一个无法摇树优化的库,但当前并未使用

在我们构建 Angular 应用程序之后的最后一步是将它部署到 Web 服务器,正如我们将在下一节中学习的那样。

部署 Angular 应用

如果你已经有一个想要用于 Angular 应用的网络服务器,你可以将输出文件夹的内容复制到该服务器的一个路径。如果你想将其部署到根目录以外的其他文件夹,你可以按照以下方式更改主 HTML 文件中 <base> 标签的 href 属性:

  • ng build 命令中传递 --base-href 选项:

    ng build --base-href=/mypath/ 
    
  • angular.json 配置文件的 build 命令中设置 baseHref 属性:

    "options": {
      "outputPath": "dist/my-app",
      "index": "src/index.html",
      "browser": "src/main.ts",
      **"baseHref": "/mypath/"**,
      "polyfills": [
        "zone.js"
      ],
      "tsConfig": "tsconfig.app.json",
      "assets": [
        {
          "glob": "**/*",
          "input": "public"
        }
      ],
      "styles": [
        "src/styles.css"
      ],
      "scripts": []
    } 
    

如果你不想将其部署到自定义服务器,你可以使用 Angular CLI 工具将应用部署到支持的托管提供商,具体信息可以在angular.dev/tools/cli/deployment#automatic-deployment-with-the-cli 找到。

摘要

Angular 应用的部署是最简单且最重要的部分,因为它最终使你的优秀应用对最终用户可用。毕竟,Web 应用都是关于向最终用户提供体验的。

在本章中,我们学习了如何构建 Angular 应用并将其准备好用于生产。我们还探讨了优化最终包的不同方法,并学习了如何将 Angular 应用部署到自定义服务器,手动和自动地部署到其他托管提供商。

在下一章,也就是本书的最后一章,我们将学习如何提高 Angular 应用的性能。

第十五章:优化应用程序性能

作为开发人员和技术专业人士,我们在构建和部署 Angular 应用程序中发挥着至关重要的作用,确保它们的持续性能并提供卓越的用户体验。我们的努力对于应用程序的成功至关重要。

网络应用程序的行为以及它在运行时的表现是监控和优化的关键考虑因素。如果我们的应用程序开始退化,我们应该监控和衡量应用程序的性能。用于识别网络应用程序问题的最流行指标之一是核心网页关键指标CWV)。

确定退化的原因后,我们可以应用各种优化技术。Angular 框架提供了各种工具来优化 Angular 应用程序,包括服务器端渲染SSR)、图像优化和延迟视图加载。如果我们事先知道应用程序将具有高性能,那么在开发早期使用上述任何工具也是高度推荐的。

在本章中,我们将探讨以下关于优化的 Angular 概念:

  • 介绍核心网页关键指标

  • 渲染 SSR 应用程序

  • 优化图像加载

  • 延迟组件

  • 预渲染 SSG 应用程序

技术要求

本章包含各种代码示例,以指导您了解优化 Angular 应用程序的概念。您可以在以下 GitHub 仓库的ch15文件夹中找到相关源代码:

github.com/PacktPublishing/Learning-Angular-Fifth-Edition

介绍核心网页关键指标

CWV 是一组指标,帮助我们衡量网络应用程序的性能。它是网页关键指标Web Vitals)的一部分,这是由 Google 领导的一项倡议,旨在统一各种用于衡量网页性能的指南和工具。每个指标都关注用户体验的特定方面,包括网页的加载、交互性和视觉稳定性:

  • 最大内容绘制LCP):这通过计算页面上的最大元素渲染所需的时间来衡量网页的加载速度。快速的 LCP 值表示页面能够快速对用户可用。

  • 交互到下一次绘制INP):这通过计算对用户交互做出响应并提供视觉反馈所需的时间来衡量网页的响应性。低 INP 值表示页面能够快速响应用户。

  • 累积布局偏移CLS):这通过计算不想要的布局偏移发生的频率来衡量网页 UI 的稳定性。布局偏移通常发生在由于动态或异步加载而导致 HTML 元素在 DOM 中移动时。低 CLS 值表示页面在视觉上是稳定的。

    Web Vitals 包含额外的指标,通过测量更广泛或更专业的用户体验领域(如首次内容渲染FCP)和首次字节时间TTFB))来补充现有的 CWV 集。

每个 CWV 指标的值都落入以下类别:

  • 良好(绿色)

  • 需要改进(橙色)

  • (红色)

您可以在web.dev/articles/vitals#core-web-vitals了解更多关于 CWV 类别及其阈值的信息。

我们可以通过以下方式测量 CWV:

  • 在生产环境中:当 Web 应用在生产环境中运行时,我们可以使用PageSpeed InsightsChrome 用户体验报告等工具。

  • 以编程方式在 JavaScript 中:我们可以使用标准 Web API 或第三方库,如web-vitals

  • 在实验室中:在开发过程中构建 Web 应用时,我们可以使用Chrome DevToolsLighthouse等工具。

在本章中,我们将学习如何使用 Chrome DevTools 来测量我们电商应用的性能:

  1. 第十二章Angular Material 简介中的源代码复制到一个新文件夹中。

  2. 在新文件夹内运行以下命令以安装包依赖项:

    npm install 
    
  3. 运行以下命令以启动 Angular 应用:

    ng serve 
    
  4. 打开Google Chrome并导航到http://localhost:4200

  5. 切换开发者工具并选择Lighthouse标签。Lighthouse 是一个用于测量网页各种性能方面的工具,包括 CWV。Google Chrome 内置了 Lighthouse 版本,我们可以用它来基准测试我们的应用:

包含文本、屏幕截图、软件、计算机图标的图片,自动生成的描述

图 15.1:Lighthouse 标签页

在前一张显示的屏幕上,我们可以通过选择各种选项(包括设备类别部分)来生成 Lighthouse 性能报告。设备部分允许我们指定我们想要测量应用的环境。类别部分允许我们评估与 CWV 相关的不同指标,包括性能

  1. 设备部分选择桌面选项,在类别部分仅检查性能选项,然后点击分析页面加载按钮:

img

图 15.2:Lighthouse 报告

在前一张图片中,我们可以看到 CWV 指标的单个得分和总体性能得分。

总体性能评分是一个估计值,可能会根据您计算机的能力或任何已安装的浏览器扩展程序而有所不同。最好在隐身或私人模式下运行基准测试,以模拟更接近真实世界场景的环境。

在以下章节中,我们将探讨通过应用 Angular 最佳实践来提高性能得分的方法。我们将从 SSR 开始。

渲染 SSR 应用

SSR 是一种在 Web 开发中提高应用程序性能和安全的技巧,以下是一些方式:

  • 它通过在服务器上渲染应用程序并消除发送给客户端的初始 HTML 内容来提高加载性能。服务器将初始 HTML 发送给客户端,客户端可以在等待 JavaScript 内容下载的同时解析和加载。

  • 它通过使应用程序可被发现和可由网络爬虫索引来提高 搜索引擎优化SEO )。SEO 在第三方应用程序(如社交媒体平台)中共享时提供有意义的内。

  • 它通过提高与加载速度和 UI 稳定性相关的 CWV 指标(如 LCP、FCP 和 CLS)来提高性能。

  • 它通过向 Angular 应用程序添加 CSP nonces 来提高安全性。

正如我们在 第一章 中所看到的,构建您的第一个 Angular 应用程序,当我们使用 Angular CLI 创建新应用程序时,它会提示我们启用 SSR:

Do you want to enable Server-Side Rendering (SSR) and Static Site Generation (SSG/Prerendering)? (y/N) 

在我们的案例中,我们已经使用 Angular CLI 创建了一个 Angular 应用程序。要在现有的 Angular 应用程序中添加 SSR,请在 Angular CLI 工作区内的终端窗口中运行以下命令:

ng add @angular/ssr 

上述命令将询问我们以下问题:

Would you like to use the Server Routing and App Engine APIs (Developer Preview) for this server application? (y/N) 

通过按 Enter 键接受默认值 No,Angular CLI 将提示我们安装 @angular/ssr npm 包。

开发者预览 中的功能意味着它尚未准备好用于生产,但您可以在开发环境中对其进行测试。

安装完成后,Angular CLI 将创建以下文件:

  • main.server.ts:这是在服务器上使用特定配置启动应用程序的文件。

  • app.config.server.ts:这包含在服务器上渲染的应用程序的配置。它导出一个 config 变量,其中包含客户端和服务器应用程序配置文件的合并版本。

  • server.ts:它配置并启动一个 Node.js Express 服务器,在服务器上渲染 Angular 应用程序。它使用 @angular/ssr 包中的 CommonEngine 类来启动 Angular 应用程序。

此外,该命令将在 Angular CLI 工作区中进行以下修改:

  • 它将在 angular.json 文件的 build 部分中添加必要的选项,以在 SSR 和 SSG 中运行 Angular 应用程序。

  • 它将在 tsconfig.app.json 文件的 filestypes 属性中添加必要的条目,以便 TypeScript 编译器可以识别为服务器创建的文件。

  • 它将在 package.json 文件中添加必要的脚本和依赖项。

  • src\app\app.config.ts 文件中添加 provideClientHydration 以启用 Angular 应用程序中的 ** hydration **。Hydration 是将服务器端渲染的应用程序恢复到客户端的过程。我们将在本章后面了解更多关于 hydration 的内容。

现在我们已经在我们的应用程序中安装了 Angular SSR,让我们看看如何使用它:

  1. 打开 app.config.ts 文件,并按如下方式修改 @angular/common/http 命名空间的 import 语句:

    import { provideHttpClient, **withFetch** } from '@angular/common/http'; 
    

withFetch 方法用于配置 Angular HTTP 客户端,使其使用原生的 fetch API 进行请求。

强烈建议为使用 SSR 的应用程序启用 fetch,以获得更好的性能和兼容性。

  1. provideHttpClient 方法中将 withFetch 方法作为参数传递:

    provideHttpClient(**withFetch()**) 
    
  2. 运行以下命令以构建 Angular 应用程序:

    ng build 
    

前一个命令在 dist\my-app 文件夹内生成 browserserver 打包,并预渲染静态路由。我们将在 预渲染 SSG 应用程序 部分了解更多关于预渲染的内容。

  1. 运行以下命令以运行 SSR 应用程序:

    npm run serve:ssr:my-app 
    

前一个命令将在本地端口 4000 启动 Express 服务器并服务 SSR 应用程序。

  1. 打开 Google Chrome 并导航到 http://localhost:4000。你应该在网页上看到电子商务应用程序。

  2. 重复上一节中我们学习的过程,使用 Lighthouse 运行性能基准测试。整体得分和 CWV 指标应该有显著提升:

包含文本、屏幕截图、软件、字体的自动生成描述

图 15.3:Lighthouse 报告(SSR)

通过在我们的 Angular 应用程序中安装 SSR,我们的应用程序性能提高了超过 20%!正如我们将在本章后面学到的那样,我们可以应用各种 Angular 技巧来进一步提高性能。

当我们需要从服务器获取数据并在网站上静态显示时,Angular SSR 是一个很好的选择。然而,在某些情况下,SSR 并没有好处,例如当应用程序基于数据输入并且有很多用户输入时。

在下一节中,我们将学习如何覆盖 SSR 或完全跳过 Angular 应用程序的部分。

在 Angular 应用程序中覆盖 SSR

湿化是 Angular SSR 应用程序默认启用的一个重要功能。它通过有效地处理客户端上 DOM 的创建来提高应用程序的整体性能。客户端可以重用服务器端渲染应用程序的 DOM 结构,而不是从头创建它并强制 UI 闪烁,这会影响 CWV 指标,如 LCP 和 CLS。以下情况下, hydration 过程将失败:

  • 当我们尝试通过原生浏览器 API(如 windowdocument)直接或使用第三方库来操作 DOM 时

  • 当我们的组件模板没有有效的 HTML 语法时

我们可以通过应用以下最佳实践来克服上述问题:

  • 在与 DOM 交互之前,使用 Angular API 检测我们的应用程序正在运行的平台。

  • 跳过特定 Angular 组件的 hydration

让我们通过一个示例来看看如何同时使用这两个功能:

  1. 运行上一节中显示的 Angular 应用程序的 SSR 版本。

  2. 注意在应用程序页脚中显示的文本:

- v1.0

版权信息显示不正确。

  1. 打开copyright.directive.ts文件并关注constructor代码:

    constructor(el: ElementRef) {
      const currentYear = new Date().getFullYear();
      const targetEl: HTMLElement = el.nativeElement;
      targetEl.classList.add('copyright');
      targetEl.textContent = `Copyright ©${currentYear} All Rights Reserved`;
    } 
    

前面的代码使用nativeElement属性通过添加 CSS 类和设置 HTML 元素的textContent来操作 DOM。然而,正如之前提到的,这段代码破坏了我们的应用程序,因为服务器上没有 DOM。让我们来修复它!

  1. 打开app.component.html文件,并在<footer>HTML 标签的<mat-toolbar>元素上添加ngSkipHydration属性:

    <footer>
      <mat-toolbar **ngSkipHydration**>
        <mat-toolbar-row>
          <span appCopyright> - v{{ settings.version }}</span> 
        </mat-toolbar-row>
      </mat-toolbar>
    </footer> 
    

    ngSkipHydration是一个 HTML 属性,而不是 Angular 指令。它只能在其他 Angular 组件中使用,不能用于原生 HTML 元素。如果我们将其添加到<footer>标签中,则不会起作用。

在前面的代码片段中,<mat-toolbar>组件及其子组件将不会被水合。这实际上意味着当 SSR 版本的应用程序准备就绪时,Angular 将从头开始创建它们。

  1. 再次运行步骤 1,并观察应用程序页脚中的输出:

版权所有 ©2024 保留所有权利 - v1.0

跳过水合应该被视为一种权宜之计。我们暂时在无法启用水合的情况下使用它。建议重构你的代码,以便你的应用程序能够从水合功能中受益。

另一种更好方法是重构我们的代码,使其有条件地执行客户端代码:

  1. 按照以下方式修改copyright.directive.ts文件中的import语句:

    **import { isPlatformBrowser } from '@angular/common';**
    import { Directive, ElementRef, **inject, OnInit, PLATFORM_ID** } from '@angular/core'; 
    

PLATFORM_ID是一个InjectionToken,它指示我们的应用程序当前正在运行的平台类型。isPlatformBrowser函数检查给定的平台 ID 是否为浏览器。

OnInit接口添加到CopyrightDirective类的实现接口列表中:

export class CopyrightDirective **implements OnInit** 
  1. 添加以下类属性:

    private platform = inject(PLATFORM_ID);
    private el = inject(ElementRef); 
    
  2. 删除constructor并添加以下ngOnInit方法:

    ngOnInit(): void {
      if (isPlatformBrowser(this.platform)) {
        const currentYear = new Date().getFullYear();
        const targetEl: HTMLElement = this.el.nativeElement;
        targetEl.classList.add('copyright');
        targetEl.textContent = `Copyright ©${currentYear} All Rights Reserved ${targetEl.textContent}`;
      }
    } 
    

isPlatformBrowser函数接受平台 ID 作为参数。

Angular 还提供了isPlatformServer函数,它是isPlatformBrowser函数的对立面,用于检查当前平台是否为服务器。

  1. 以服务器端模式构建和运行应用程序,以验证版权信息是否仍然可见。

总结来说,建议你在整个应用程序中使用 Angular SSR,并对必须在浏览器上运行的代码部分进行重构。这将使你能够享受到服务器端渲染应用程序的所有好处。

在前面的章节中,我们展示了将 SSR 添加到 Angular 应用程序中可以显著提高其整体性能评分。正如我们将在下一节中学习的,通过应用优化技术到产品图片,我们可以做得更好。

优化图片加载

产品列表是我们应用程序的着陆组件,它会在列表中显示每个产品的图像。在 Angular 应用程序中图像的加载方式可能会影响 CWV 指标,如 LCP 和 CLS。我们的应用程序目前以从 Fake Store API 收到的形式加载图像。然而,我们可以在加载图像时使用特定的 Angular 工具来强制执行最佳实践。

Angular 框架为我们提供了 NgOptimizedImage 指令,我们可以将其附加到 <img> HTML 元素上:

  1. 打开 product-list.component.ts 文件,并从 @angular/common npm 包中导入 NgOptimizedImage 类:

    import { AsyncPipe, CurrencyPipe, **NgOptimizedImage** } from '@angular/common'; 
    
  2. @Component 装饰器的 imports 数组中添加 NgOptimizedImage 类:

    @Component({
      selector: 'app-product-list',
      imports: [
        SortPipe,
        AsyncPipe,
        CurrencyPipe,
        RouterLink,
        MatMiniFabButton,
        MatIcon,
        MatCardModule,
        MatTableModule,
        MatButtonToggle,
        MatButtonToggleGroup,
        **NgOptimizedImage**
      ],
      templateUrl: './product-list.component.html',
      styleUrl: './product-list.component.css'
    }) 
    
  3. 打开 product-list.component.html 文件,并将 src 属性的绑定替换为 ngSrc 指令:

    <mat-card-title-group>
      <mat-card-title>{{ product.title }}</mat-card-title>
      <mat-card-subtitle>{{ product.category }}</mat-card-subtitle>
      <img mat-card-sm-image [**ngSrc**]="product.image" />
    </mat-card-title-group> 
    

ngSrc 指令在加载图像时不足以防止布局偏移。我们还必须通过定义 widthheightfill 属性来设置图像大小。在这种情况下,我们将使用后者,因为并非所有产品的图像大小都相同:

<img mat-card-sm-image [ngSrc]="product.image" **fill** /> 
  1. 打开 product-list.component.css 文件,并添加以下 CSS 样式以将图像定位在容器的右上角:

    img {
      object-fit: contain;
      object-position: right 5px top 0;
    } 
    
  2. 运行以下命令以启动应用程序:

    ng serve 
    
  3. 导航到 http://localhost:4200 并验证产品列表是否正确显示。

使用 NgOptimizedImage 指令获得的好处不会立即在 UI 中显现。该指令在后台工作,并自动通过以下方式提高 CWV 的 LCP 指标:

  • <img> HTML 元素上设置获取优先级

  • 懒加载图像

  • 在 SSR 的情况下设置 preconnect 链接标签和预加载提示

  • 为响应式图像生成 srcset 属性

此外,它还帮助开发者遵循有关图像加载的最佳实践,例如:

  • 如果事先知道图像大小,则设置图像大小

  • 通过 CDN 加载图像

  • 在控制台窗口中显示针对不同指标的适当警告

NgOptimizedImage 指令包含许多其他我们可以启用的功能,以实现强大的性能改进,例如设置图像加载器、使用占位符以及定义优先加载的图像。更多信息请参阅 angular.dev/guide/image-optimization

我们已经学习了各种提高应用程序性能的工具。其中最有效的工具之一是可延迟视图,我们将在下一节中学习。

延迟组件

介绍新的控制流语法使 Angular 能够在框架中集成新的原语,从而提高了 Angular 应用程序的用户体验、开发体验和性能。其中一个这样的原语是可延迟视图,它允许懒加载 Angular 组件及其依赖项。

介绍可延迟视图

我们已经学习了如何使用 Angular 路由根据特定路由懒加载组件。可延迟视图提供了一个新的 API,补充了前面的 API。结合懒加载路由可以保证开发高性能和强大的 Web 应用程序。可延迟视图允许我们根据事件或组件状态懒加载组件,并具有以下特性:

  • 它们易于使用,并且易于理解封装的代码

  • 我们以声明式的方式定义它们

  • 它们最小化了初始应用程序加载和最终包的大小,提高了 CWV 指标,如 LCP 和 TTFB

每个可延迟视图都被分割成单独的块,类似于由懒加载路由生成的单个块文件。它们由以下 HTML 块组成:

  • @defer:指示将要加载的 HTML 内容。

  • @placeholder:指示在 @defer 块开始加载之前显示的 HTML 内容。它在应用程序通过慢速网络加载或我们想要避免 UI 闪烁时特别有用。

  • @loading:指示在 @defer 块加载时可见的 HTML 内容。

  • @error:指示在 @defer 块加载时发生错误时显示的 HTML 内容。

我们将在下一节学习如何使用每个块。

使用可延迟块

我们将通过创建一个组件来在我们的电子商务应用中集成可延迟视图,该组件将显示来自 Fake Store API 的特色产品,而这些产品目前不在产品列表中。让我们开始:

  1. 运行以下命令来创建新的组件:

    ng generate component featured 
    
  2. 打开 products.service.ts 文件,并添加以下方法,该方法从 Fake Store API 获取 ID 为 20 的特定产品:

    getFeatured(): Observable<Product> {
      return this.http.get<Product>(this.productsUrl + '/20');
    } 
    
  3. 打开 featured.component.ts 文件,并按照以下方式修改 import 语句:

    import { Component, **OnInit** } from '@angular/core';
    **import { CommonModule } from '@angular/common';**
    **import { MatButton } from '@angular/material/button';**
    **import { MatCardModule } from '@angular/material/card';**
    **import { Observable } from 'rxjs';**
    **import { Product } from '../product';**
    **import { ProductsService } from '../products.service';** 
    
  4. 按照以下方式修改 @Component 装饰器的 imports 数组:

    @Component({
      selector: 'app-featured',
      imports: [**CommonModule, MatButton, MatCardModule**],
      templateUrl: './featured.component.html',
      styleUrl: './featured.component.css'
    }) 
    
  5. 按照以下方式修改 FeaturedComponent 类:

    export class FeaturedComponent implements **OnInit** {
      **product$: Observable<Product> | undefined;**
    
      **constructor(private productService: ProductsService) {}**
      **ngOnInit() {**
        **this.product$ = this.productService.getFeatured();**
      **}**
    } 
    

在前面的 TypeScript 类中,我们已经声明了 product$ 可观察对象,并将其分配给 ProductsService 类中 getFeatured 方法的返回值。

  1. 打开 featured.component.html 文件,并用以下 HTML 代码替换其内容:

    @if (product$ | async; as product) {
      <mat-card>
        <mat-card-header>
          <mat-card-title>MEGA DEAL</mat-card-title>
          <mat-card-subtitle>{{ product.title }}</mat-card-subtitle>
        </mat-card-header>
        <img mat-card-image [src]="product.image" />
        <mat-card-actions>
          <button mat-flat-button color="primary">Buy now</button>
        </mat-card-actions>
      </mat-card>
    } 
    

在前面的代码片段中,我们使用 async 管道在 @if 块内部订阅 product$ 可观察对象。该块的 HTML 内容以 Angular Material 卡组件的形式显示产品详情。

  1. 打开 featured.component.css 文件,并为卡片和按钮组件添加以下 CSS 样式:

    mat-card {
      max-width: 350px;
    }
    button {
      width: 100%;
    } 
    

新的 Angular 组件已经就位。我们必须将其添加到应用程序的主组件中,并使用 @defer 块来加载它:

  1. 打开 app.component.ts 文件,并添加以下 import 语句:

    import { FeaturedComponent } from './featured/featured.component'; 
    
  2. @Component 装饰器的 imports 数组中添加 FeaturedComponent 类:

    @Component({
      selector: 'app-root',
      imports: [
        RouterOutlet,
        RouterLink,
        CopyrightDirective,
        AuthComponent,
        MatToolbarRow,
        MatToolbar,
        MatButton,
        MatBadge,
        **FeaturedComponent**
      ],
      templateUrl: './app.component.html',
      styleUrl: './app.component.css'
    }) 
    
  3. 打开 app.component.html 文件,并在 <main> HTML 标签内添加 <app-featured> 组件:

    <main class="main">
      <div class="content">
        <router-outlet />
      </div>
      **@defer() {**
        **<app-featured />**
      **}**
    </main> 
    

在前面的代码片段中,我们使用@defer块通过自闭合标签语法声明了<app-featured>组件。

  1. 运行ng serve命令以启动应用程序,并在终端窗口中观察懒加载文件部分:

    Lazy chunk files     | Names              |  Raw size
    chunk-OP24QI45.mjs   | featured-component |   2.88 kB | 
    chunk-4T4L5V7V.mjs   | user-routes        |   1.19 kB | 
    

特色组件的源代码被分割成一个块文件。

  1. 导航到http://localhost:4200并观察产品列表右侧的新组件:

图片

图 15.4:特色产品

尝试重新加载浏览器,您将注意到在加载特色产品时会出现 UI 闪烁。我们将使用@placeholder块在特色组件开始加载之前显示轮廓图像:

  1. 技术要求部分中描述的 GitHub 仓库的public文件夹中的placeholder.png图像复制到您的工作空间相应文件夹中。

  2. @defer块之后添加一个@placeholder块,如下所示:

    @defer() {
      <app-featured />
    } **@placeholder(minimum 1s) {**
      **<img src="img/placeholder.png" />**
    **}** 
    

@placeholder块接受一个可选参数,定义占位符将可见的最短时间。在这种情况下,我们定义的最短时间为1秒。

  1. 使用ng serve命令运行应用程序,并验证在内容加载前,以下占位图是否可见 1 秒钟:

包含矩形、屏幕截图、自动生成的描述

图 15.5:占位图

另一种方法是在@loading块中使用,并在特色组件加载时显示一个加载指示器,例如一个旋转器:

  1. 打开app.component.ts文件并添加以下import语句:

    import { MatProgressSpinner } from '@angular/material/progress-spinner'; 
    

MatProgressSpinner类是 Angular Material 库中的一个旋转器组件。

  1. @Component装饰器的imports数组中添加MatProgressSpinner类:

    @Component({
      selector: 'app-root',
      imports: [
        RouterOutlet,
        RouterLink,
        CopyrightDirective,
        AuthComponent,
        MatToolbarRow,
        MatToolbar,
        MatButton,
        MatBadge,
        FeaturedComponent,
        **MatProgressSpinner**
      ],
      templateUrl: './app.component.html',
      styleUrl: './app.component.css'
    }) 
    
  2. app.component.html文件中添加@loading块,如下所示:

    @defer() {
      <app-featured />
    } **@loading(minimum 1s) {**
      **<mat-spinner ngSkipHydration></mat-spinner>**
    **}** 
    

@loading块接受与@placeholder块相同的可选参数。在这种情况下,我们至少显示旋转器组件1秒。

我们添加了ngSkipHydration属性,因为旋转器组件与浏览器 DOM 交互,不能进行水合。

  1. 如果我们使用ng serve命令运行应用程序,我们应该在特色组件加载时看到一个旋转指示器,持续 1 秒钟。

可延迟视图中的@error块与@placeholder@loading块的工作方式类似。当加载@defer块内容时发生错误,其中的 HTML 内容将可见:

@defer() {
  <app-featured />
} @placeholder(minimum 1s) {
  <img src="img/placeholder.png" />
} **@error() {**
  **<span>An error occurred while loading the featured product</span>**
**}** 

正如我们所见,@defer块的内容在所属组件渲染时立即开始加载。然而,可延迟视图 API 为我们提供了便于控制块何时加载的工具,我们将在下一节中看到。

@defer块中加载模式

使用触发器预取机制,我们可以控制@defer块何时以及如何加载:

  • 触发器定义了块的内容何时开始加载

  • Prefetch 定义了 Angular 是否会在需要时预先获取内容,以便它们在需要时可用

我们可以在@defer块内部使用on关键字和触发器的名称来定义一个触发器作为可选参数:

@defer(**on viewport**) {
  <app-featured />
} @placeholder(minimum 1s) {
  <img src="img/placeholder.png" />
} @error() {
  <span>An error occurred while loading the featured product</span>
} 

Angular 框架包含以下内置触发器:

  • viewport:这将触发当内容进入浏览器视口时,即当前可见的浏览器部分。

你可以在developer.mozilla.org/docs/Glossary/Viewport上了解更多关于视口的信息。

  • interaction:这将触发当用户与内容交互时。

  • hover:这将触发当用户将鼠标悬停在内容覆盖的区域上时。

  • idle:这将触发当浏览器进入一个空闲状态时,这是可延迟视图的默认行为。浏览器空闲状态由原生的requestIdleCallback API 触发。

你可以在developer.mozilla.org/docs/Web/API/Window/requestIdleCallback上了解更多关于空闲状态的信息。

  • immediate:这将触发当客户端渲染页面时。

不使用块与使用immediate触发器之间的区别在于,我们可以从可延迟视图的代码拆分功能中受益,并向客户端交付更少的 JavaScript。

  • timer:这将触发在指定持续时间后执行块。持续时间是timer函数的必需参数:

    @defer(**on timer(2s)**) {
      <app-featured />
    } 
    
  • 在前面的代码片段中,将在2秒后开始加载特色组件。

通过组合触发器,我们可以实现更好的加载粒度:

@defer(**on timer(2s); on idle**) {
  <app-featured />
} 

在前面的代码片段中,将在浏览器处于idle状态或2秒后加载特色组件。

除了内置触发器之外,我们可以使用when关键字自己创建自定义触发器。when关键字后面跟着一个评估为布尔值的表达式:

@defer(**when isActive === true**) {
  <app-featured />
} 

在前面的代码片段中,当isActive组件属性为true时,将加载特色组件。

可延迟视图中的触发器是强大且人性化的工具,可以在速度和性能上带来惊人的结果。当与预取结合使用时,它们可以在 Angular 应用程序中实现巨大的性能提升。预取允许我们指定可以预取可延迟视图以备需要时使用的条件。预取支持所有可延迟视图的内置触发器:

@defer(on timer(2s); **prefetch on idle**) {
  <app-featured />
} 

在前面的代码片段中,当浏览器处于idle状态时,将预取内容,并在2秒后加载。它还可以使用when关键字定义预取内容的时间,或创建自定义触发器。

触发和预取允许我们创建加载可延迟视图的复杂场景。可延迟视图 API 提供的灵活性使其成为开发高度复杂和性能卓越的 Angular 应用程序的有用工具。

不应将可延迟视图用于必须立即渲染的内容。

在下一节中,我们将结束使用 Angular SSG 优化应用程序性能的旅程。

预渲染 SSG 应用程序

SSG 或构建时预渲染是创建 Angular 应用程序的静态生成 HTML 文件的过程。当我们使用 ng build Angular CLI 命令构建 Angular SSR 应用程序时,它默认发生。

SSG 应用程序的主要好处是它不需要在每个请求之间在服务器和客户端之间进行往返。相反,每个页面都作为静态内容提供,消除了 TTFB CWV 指标所衡量的应用程序加载时间。

渲染 SSR 应用程序 部分,Angular CLI 构建命令的输出包括以下信息:

Prerendered 4 static routes. 

让我们看看 SSG 的工作原理以及前面的输出意味着什么:

  1. 运行以下命令来构建 Angular 应用程序:

    ng build 
    
  2. ng build 命令将创建 dist\my-app\browser 文件夹。

前面的文件夹不应与构建非 SSR Angular 应用程序时生成的 browser 文件夹混淆。

  1. 导航到 dist\my-app 文件夹并打开 prerendered-routes.json 文件:

    {
      "routes": [
        "/cart",
        "/products",
        "/products/new",
        "/user"
      ]
    } 
    

它列出了 Angular SSG 预渲染的应用程序路由。它还在 browser 文件夹内为每个路由创建了一个文件夹和 index.html 文件。

  1. 打开 products\index.html 文件,你会看到 Angular 已经添加了所有 CSS 和 HTML 文件,并且它甚至已经渲染了从 Fake Store API 获取的产品数据。

  2. 要预览 SSG 的工作方式,运行 ng serve 命令以启动应用程序,并导航到 http://localhost:4200/products。产品列表会立即加载,无需等待应用程序从 Fake Store API 获取数据。

ng serve 命令提供我们应用程序的 SSG 版本,因为它在底层执行 ng build 命令。要禁用 SSG,打开 angular.json 文件,并在 build 部分将 prerender 属性设置为 false

在 Angular SSR 应用程序中,SSG 默认启用,可以显著提高它们的加载时间和运行时性能。对于性能较差的低端设备尤其有用。

摘要

在本章中,我们学习了不同的方法来优化和改进 Angular 应用程序的性能。我们介绍了 CWV 的概念以及它如何影响 Web 应用程序。我们探讨了如何使用 Angular 应用程序中的 SSR 和激活来测量和改进 CWV 指标。我们还研究了性能优化的不同方面,例如 NgOptimizedImage 指令和可延迟视图。最后,我们概述了 Angular 应用程序中的 SSG。

我们的 Angular 框架之旅以本章结束。然而,我们能做的事情的可能性是无限的。Angular 框架在每次发布中都更新了新功能,为网络开发者提供了强大的日常开发工具。我们很高兴您能加入我们,并希望这本书能帮助您拓宽对使用如此优秀工具所能实现的事情的看法!

加入我们的 Discord

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/LearningAngular5e

img

img

packt.com

订阅我们的在线数字图书馆,全面访问超过 7,000 本书籍和视频,以及领先的行业工具,帮助您规划个人发展并推进您的职业生涯。更多信息,请访问我们的网站。

为什么订阅?

  • 使用来自 4,000 多名行业专业人士的实用电子书和视频,节省学习时间,多花时间编码

  • 使用专为您量身定制的技能计划提高您的学习效果

  • 每月免费获得一本电子书或视频

  • 完全可搜索,便于轻松访问关键信息

  • 复制粘贴、打印和收藏内容

www.packt.com ,您还可以阅读一系列免费的技术文章,注册各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。

你可能还会喜欢的其他书籍

如果你喜欢这本书,你可能对 Packt 的这些其他书籍也感兴趣:

img

有效的 Angular

Roberto Heckers

ISBN: 978-1-80512-553-2

  • 创建可以处理数百个 Angular 应用程序的 Nx 单仓库

  • 使用独立的 API、注入函数、控制流和信号在 Angular 中降低复杂性

  • 使用信号、RxJS 和 NgRx 有效地管理应用程序状态

  • 使用投影、TemplateRef 和延迟块构建动态组件

  • 使用 Cypress 和 Jest 在 Angular 中执行端到端和单元测试

  • 优化 Angular 性能,防止不良做法,并自动化部署

img

使用 RxJS 和 Angular 信号构建响应式模式

Lamis Chebbi

ISBN: 978-1-83508-770-1

  • 掌握 RxJS 核心概念,如 Observables、subjects 和 operators

  • 在响应式模式中使用宝石图

  • 深入研究流式处理,包括转换和组合它们

  • 使用 RxJS 和最佳实践了解内存泄漏问题,并避免它们

  • 使用 Angular 信号和 RxJS 构建响应式模式

  • 探索针对 RxJS 应用程序的不同测试策略

  • 发现 RxJS 中的多播及其如何解决复杂问题

  • 使用 RxJS 和 Angular 的最新功能构建完整的响应式 Angular 应用程序

img[(https://www.packtpub.com/en-us/product/angular-for-enterprise-applications-9781805125037)]

《企业级应用 Angular,第三版》

杜古汉·乌卢卡

ISBN: 978-1-80512-712-3

  • 架构和领导企业级项目的最佳实践

  • 交付 Web 应用的极简主义、价值优先方法

  • Angular 中独立组件、服务、提供者、模块、懒加载和指令是如何工作的

  • 使用 Signals 或 RxJS 管理你的应用数据响应性

  • 使用 NgRx 为你的 Angular 应用进行状态管理

  • 使用 Angular 生态系统构建和交付企业级应用

  • 自动化测试和 CI/CD 以交付高质量应用

  • 认证和授权

  • 使用 REST 和 GraphQL 构建基于角色的访问控制

Packt 正在寻找像你这样的作者

如果你对成为 Packt 的作者感兴趣,请访问 authors.packtpub.com 并今天申请。我们已与成千上万的开发者和技术专业人士合作,就像你一样,帮助他们将见解分享给全球技术社区。你可以提交一个一般性申请,申请我们正在招募作者的特定热门话题,或者提交你自己的想法。

分享你的想法

现在你已经完成了 《学习 Angular,第五版》,我们很乐意听听你的想法!如果你在亚马逊购买了这本书,请点击此处直接进入该书的亚马逊评论页面并分享你的反馈或在该网站上留下评论。

你的评论对我们和整个技术社区都很重要,并将帮助我们确保我们提供高质量的内容。

posted @ 2025-09-05 09:26  绝不原创的飞龙  阅读(7)  评论(0)    收藏  举报