Angular-设计模式和最佳实践-全-

Angular 设计模式和最佳实践(全)

原文:zh.annas-archive.org/md5/45ce755fc65c79b26ac858559b9855ab

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

自 2009 年以来,Angular 框架一直在帮助开发团队,它拥有强大的结构,几乎包含了构建 Web 应用程序所需的一切。Angular 以其“包含电池”的哲学,提供了状态管理、路由管理和依赖注入等机制,以及其他工具,以帮助您为用户提供最令人难以置信的体验。

本书旨在帮助您导航这个令人难以置信的功能列表,并学习如何为您和您的团队编排这些功能,以充分利用 Angular 及其整个生态系统。

我们将发现框架中存在哪些类型的模式,以及我们可以从这些模式中学到什么教训,并将其应用到我们的应用程序中。

我们还将根据其文档和 Angular 生态系统周围的社区建议,探索 Angular 开发和架构的最佳实践。

Angular 被不同规模和行业的公司广泛使用。赞助这个开源框架的公司,谷歌,拥有数千个使用 Angular 的内部应用程序,保证了极高的稳定性,这也是使用它的最大原因之一。

对于掌握 Angular 的开发人员和能够组织和充分利用 Angular 的架构师,需求量很大,目前框架处于最佳状态,社区称之为 Angular 文艺复兴。

本书面向的对象

本书面向有 Angular 或任何其他 Web 框架经验的开发人员和架构师,他们希望深入了解 Angular 能提供的最佳功能。

本书的主要目标受众是以下人群:

  • 已经使用 Angular 的开发人员,希望提高他们在完成任务时的生产率

  • 技术领导者,希望将最佳实践带到他们的团队中,以提高交付的质量和生产率

  • 希望探索 Angular 为应用程序提供可能性的软件架构师,并因此设计出弹性和安全的系统

本书涵盖的内容

第一章正确开始项目,强化了 Angular 的基础知识、其原则以及如何配置项目和开发环境以尽可能提高生产效率。

第二章组织您的应用程序,探讨了组织 Angular 项目的最佳实践以及如何通过懒加载 Angular 模块来优化应用程序的性能。

第三章Angular 的 TypeScript 模式,深入框架的基础语言 TypeScript,并帮助您理解为什么 Angular 团队选择了它,以及我们如何将其应用到我们的项目中。

第四章组件和页面,与框架的基本元素组件一起工作,以及我们如何构建项目以创建简洁高效的应用程序。

第五章, Angular 服务和单例模式,分析了 Angular 服务,以将业务逻辑与表示逻辑分离,并讨论了与后端通信的最佳实践。

第六章, 处理用户输入:表单,我们将研究用户与我们的应用程序交互的主要方式,即通过表单,以及我们如何创建响应式且易于维护的表单。

第七章, 路由和路由器,我们将与 Angular 的路由机制一起工作,以及如何以安全和优化的方式管理我们应用程序的路由。

第八章, 改进后端集成:拦截器模式,我们将在这里应用拦截器设计模式,以处理与后端通信的常见任务,例如令牌管理和用户通知。

第九章, 探索 RxJS 的反应性,深入探讨了 RxJS 库,以及我们如何充分利用它来管理项目中的信息流和交互。

第十章, 设计用于测试:最佳实践,讨论了自动化测试以及如何为这个过程准备我们的项目,以及使用 Jasmine 和 Karma 库进行单元测试,以及使用开源工具 Cypress 进行端到端测试。

第十一章, 使用 Angular Elements 的微前端,探讨了微前端架构,并讨论了何时使用它以及如何在 Angular 中使用 Angular Elements 库来实现它。

第十二章, 打包一切:部署最佳实践,探讨了构建和部署我们的 Angular 应用程序到云环境中的最佳实践。我们将通过一个示例项目来探索微软 Azure 云。

第十三章, Angular 的复兴,探讨了如何跟上 Angular 的持续发展,并查看令人惊叹的功能,如 Angular Signals、独立组件和通过 defer 指令的懒加载组件。

为了充分利用本书

您需要具备基本的 HTML、CSS 和 JavaScript 知识以及如何工作一个 Web 应用程序。

本书涵盖的软件/硬件 操作系统要求
Angular 16 和 17 Windows、macOS 或 Linux
TypeScript 5.2
RxJS 7
Azure

下载示例代码文件

您可以从 GitHub(.)下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。

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

使用的约定

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

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“在这个测试用例中,我们不需要担心登录,因为beforeEach函数执行了这个功能,我们直接在表上工作。”

代码块设置如下:

describe('My First Test', () => {
  it('Visits the initial project page', () => {
    cy.visit('/')
    cy.contains('app is running!')
  })
})

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

<button
  type="submit"
  class="w-full rounded bg-blue-500 px-4 py-2 text-white"
  [disabled]="loginForm.invalid"
  [class.opacity-50]="loginForm.invalid"
  data-cy="submit"
>
  Login
</button>

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

ng test

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“选择所需的浏览器,然后点击开始 E2E 测试,我们将获得测试执行界面。”

小贴士或重要注意事项

看起来是这样的。

联系我们

我们欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请通过客户关怀@packtpub.com给我们发邮件,并在邮件主题中提及书名。

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

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

如果您想成为一名作者:如果您在某个领域有专业知识,并且对撰写或参与一本书籍感兴趣,请访问 authors.packtpub.com。

分享您的想法

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

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

下载本书的免费 PDF 副本

感谢您购买本书!

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

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

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

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

优惠不会就此停止,您还可以获得独家折扣、时事通讯和每日免费内容的每日邮箱访问权限。

按照以下简单步骤获取优惠:

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

packt.link/free-ebook/9781837631971

  1. 提交您的购买证明

  2. 就这些了!我们将直接将您的免费 PDF 和其他福利发送到您的邮箱

第一部分:巩固基础

在本部分中,您将更深入地了解 Angular 框架的基本原理及其基本概念,例如为什么使用 Angular,如何组织项目并设置高效的开发环境。此外,您还将学习组件创建和与后端通信方面的最佳实践。

本部分包含以下章节:

  • 第一章**,以严谨的方式启动项目

  • 第二章**,组织您的应用程序

  • 第三章**,Angular 的 TypeScript 模式

  • 第四章**,组件和页面

  • 第五章**,Angular 服务和单例模式

第一章:正确开始项目

Angular 是一个以“一应俱全”作为开发理念的框架。这意味着您需要的所有前端应用程序资源在创建新项目时就已经全部可用。

在本章中,您将了解为什么为您的 Web 应用程序选择 Angular,它的主要特性和设计是什么,以及为什么公司,尤其是最大的公司,选择 Angular 作为开发单页应用程序的主要框架。

您将探索构成框架的技术,并在需要特定情况下的可能替代方案时,充分利用这些技术。您还将使用最佳工具设置您的办公空间,以帮助您和您的团队提高生产力。

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

  • 为什么选择 Angular?

  • 生态系统中有哪些技术?

  • 配置您的开发环境

  • 开始一个 Angular 项目

  • 使用 Angular 命令行界面 (CLI) 提高您的生产力

到本章结束时,您将为在项目中使用 Angular 提供论据,并在您的开发工作区中更加高效。

技术要求

要遵循本章中的说明,您需要以下内容:

本章的代码文件可在 此处 获取。

为什么选择 Angular?

在特定项目中选择要使用的技术对其成功至关重要。作为项目开发者或架构师,您必须通过选择最适合这项工作的工具来帮助您的团队完成这项任务。

Angular 框架是构建单页应用程序最常用的工具之一,与 React 和 Vue 一起。在选择适合这项工作的正确工具时,您需要回答“为什么”。

以下是一些选择 Angular 的论据。

一应俱全

Angular 是一个有观点的框架,这意味着 Angular 开发团队已经为每个可能出现在 Web 应用程序中的挑战做出了几个工具和解决方案的选择。这样,您和您的团队就不必研究应该使用哪个路由引擎或状态管理库;所有这些都已经包含并为您项目配置好了。

此功能还简化了您团队中新开发者的入职流程。遵循文档中提出的指南和使用最佳实践,Angular 项目通常具有相同的结构和开发方法。了解 Angular 后,您可以快速定位到任何正在进行的项目。

Google 支持

Angular 是由谷歌的 Angular 团队创建和维护的。尽管像 Vue.js 和 Svelte 这样的优秀框架仅由其社区维护,但有一个大型科技公司支持框架,这为技术选择带来了安全性,尤其是对于大型公司来说。

此外,Angular 被用于超过 300 个内部应用程序和谷歌产品中,这意味着稳定性和质量,因为,在发布框架的新版本之前,它已经在所有这些应用程序中得到验证。

自从版本 13 以来,Angular 团队一直努力通过发布路线图(angular.io/guide/roadmap)来增加社区内的透明度,详细说明所有正在进行中的改进以及未来框架的预期,让您放心,它将在未来几年内得到支持。

社区

技术的生命力取决于支持它的社区,Angular 拥有一个庞大的社区。聚会、播客、活动、文章和视频——Angular 社区拥有许多资源来帮助开发者。

构成这个社区的人们还对 Angular 做出了重要的贡献,即提供反馈、创建和修复 Angular 的问题。由于它是一个开源项目,每个人都受邀评估和贡献代码。

Angular 团队还通过请求评论RFCs)的方式向社区寻求帮助,以做出重大的框架决策。

此外,社区还创建了众多库,扩展了框架的可能性,例如 NgRx(ngrx.io/)用于高级状态管理和 Transloco(ngneat.github.io/transloco/)以支持国际化等。

工具

与其竞争对手相比,Angular 的一个不同之处在于从一开始就专注于工具和开发者体验。Angular CLI 工具是一个强大的生产力工具,我们将在本章中探讨它,它不仅用于项目的简单创建和设置。

从测试的角度来看,Angular 已经配备了 Karma 作为测试运行器和 Jasmine 作为配置工具。Angular 的工具已经使用 webpack 配置了项目构建,并且已经有一个开发服务器。

该工具也是可扩展的,允许社区为配置和更新他们的库创建常规。

基于这些论点,您将能够根据您的项目选择 Angular;现在让我们看看构成框架生态系统的技术有哪些。

生态系统中有哪些技术?

当 Angular 团队为不断增长的 Web 应用程序开发复杂性寻找解决方案时,决定将最佳的工具和库联合在一个有见地的包中,并尽可能多地使用默认配置。

然后,我们有以下库构成了 Angular 的核心。

TypeScript

TypeScript 是 JavaScript 语言的超集,它向语言添加了类型检查和其他功能,确保了更好的开发体验和安全性,适用于 Web 开发。

它自 Angular 的第一个版本以来就存在,是框架的基石,它使得依赖注入、类型化表单和 Angular 的工具等功能成为可能。

TypeScript 目前是 Node.js 后端开发的首选工具,并且受到 React 和 Vue.js 等其他框架社区的鼓励。

RXJS

RXJS 是一个在 JavaScript 语言中实现响应式范式的库 (www.reactivemanifesto.org/)。

自 Angular 的第一个版本以来,响应性一直是框架想要实现的核心主题,因此它使用 RXJS 库来帮助实现这一目标。

HTTP 请求、路由、表单以及其他 Angular 元素使用可观察对象及其操作符的概念,为 Angular 开发者提供工具,以创建更流畅和动态的应用程序,同时减少样板代码。

RXJS 还提供了在前端应用程序中进行状态管理的方法,无需使用更复杂的模式,如 Redux。

Karma 和 Jasmine

质量在任何应用程序中都应该是首要任务,这对于前端应用程序尤为重要,因为对于用户来说,它就是 那个 应用程序。

质量的一个证明方式是通过测试,考虑到这一点,Angular 默认已经包含了 JasmineKarma 工具组合。

Jasmine 是一个用于对 JavaScript 和 TypeScript 应用程序进行单元测试的框架,它提供了多个断言和测试组装功能。

Karma 是测试运行器,即执行单元测试设置的运行环境,它借助 Jasmine 来执行。这个环境在其配置文件中配置,在浏览器中运行,与客户的日常生活相比,使测试更加真实。

由于测试执行的性能,社区中许多人将这两个工具切换到 Jest 框架,这是完全可以接受的,甚至 Angular CLI 还提供了便利;然而,应该注意的是,这个工具不在浏览器中运行,这确实提高了测试执行的性能,但可能会隐藏一些只有通过在浏览器中进行测试才能提供的特定性。

Webpack

在应用程序开发完成后,需要创建捆绑包以发送到生产环境,Webpack 就是 Angular 团队选择的这个任务的工具。

Webpack 是一个非常强大且通用的打包器,正是由于它,框架能够实现一些有趣的优化,如摇树优化和捆绑的懒加载。

然而,Webpack 的配置复杂,考虑到这一点,Angular 团队已经设置并创建了一些抽象,以便对工具进行微调,例如 angular.json 文件。

我们理解框架的各个组成部分以及它们如何与提供丰富和流畅的用户界面相关联。现在,我们将设置我们的开发环境。

配置您的开发环境

拥有正确工具的井然有序的环境是通往卓越和高效的第一步;现在,让我们在您的开发空间中设置此环境。

按照技术要求部分中的说明安装 Node.js 后,以下工具及其插件将帮助您在您的开发流程中。

VS Code

VS Code (code.visualstudio.com/) 目前是大多数开发者的默认工具,尤其是对于前端项目。

还有其他非常好的工具,例如 WebStorm (www.jetbrains.com/webstorm),但 VS Code,特别是针对 Angular 项目的插件,可以极大地提高生产力和人体工程学。

要安装此处列出的插件,在代码编辑器中点击扩展或使用快捷键 Ctrl + Shift + X(Windows)或 Cmd + Shift + X(macOS)。

以下是为开发 Angular 应用程序推荐的 VS Code 插件。

Git 扩展包

Git 扩展包 (marketplace.visualstudio.com/items?itemName=donjayamanne.git-extension-pack) 并非专门用于开发 Angular 应用程序,但它对任何类型的工作都很有用。

Git 是版本控制的默认工具,VS Code 对其有原生支持。这一套插件进一步增强了这种支持,增加了在编辑器中读取先前提交中注释和更改的能力、支持多个项目,以及更好地查看您的存储库历史和日志。

Angular 语言服务

Angular 语言服务 (marketplace.visualstudio.com/items?itemName=Angular.ng-template) 扩展由 Angular 团队维护,并从代码编辑器开始添加了对框架大多数功能的支持。

通过将此扩展添加到您的编辑器中,它将具有以下功能:

  • 在 HTML 模板文件中进行自动完成,让您无需查阅 TypeScript 文件即可使用组件方法

  • 检查 HTML 模板文件和 TypeScript 文件中可能的编译错误

  • 快速在 HTML 和 TypeScript 模板之间导航,让您能够查阅方法和对象的定义

此扩展也适用于其他 IDE,如 WebStorm 和 Eclipse。

Prettier

Prettier (marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) 是一个 JavaScript 工具,用于解决代码格式化问题。尽管可以进行一些自定义,但它对格式化设置有明确的观点。

除了 TypeScript,Prettier 还格式化 HTML、CSS、JSON 和 JavaScript 文件,这使得此扩展对于使用 Node.js 进行后端开发也非常有用。

为了在整个团队中标准化格式,您可以将 Prettier 作为项目包安装,并在项目的 CI/CD 轨道上运行它,我们将在 第十二章打包一切 – 部署最佳实践 中看到。

ESLint

在创建应用程序时,强烈建议使用代码检查器以确保良好的语言实践并避免开发初期的错误。

在过去,用于检查 TypeScript 项目的默认工具是 TSLint,但该项目已被 ESLint (https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) 吸收,这使得您能够验证 JavaScript 和 TypeScript 项目。

使用此扩展,在您键入项目代码的同时,验证会迅速发生。ESLint 可以作为包安装到您的 Angular 项目中,从而在项目的 CI/CD 传输带上执行此验证,我们将在 第十二章打包一切 – 部署最佳实践 中看到。

EditorConfig

EditorConfig (marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig) 插件不仅为 VS Code,还为任何支持此格式的 IDE 提供创建默认配置文件的功能。

此插件对标准化您的项目和团队中的事物非常有用——例如,每个 Tab 键表示的空格数,或者您的项目是否将使用单引号或双引号来表示字符串。

要使用它,只需在项目的根目录下创建或拥有一个名为 .editorconfig 的文件,VS Code 将尊重文件中描述的设置。

VS Code 设置

VS Code 除了扩展之外,还有一些原生设置可以帮助您在日常工作中。通过访问 文件 菜单,我们可以激活自动保存标志,这样您就不必总是担心按 Ctrl + S(尽管这个习惯已经在我们的脑海中根深蒂固...)。

另一个有趣的设置是 禅模式,其中所有窗口和菜单都被隐藏,这样您就可以专注于代码。要激活它,请转到 视图 | 外观 | 禅模式,或使用键盘快捷键 Ctrl + K + Z(Windows/Linux 系统)和 Cmd + K + Z(macOS)。

为了在编辑时提高代码的可读性,一个有趣的设置是 括号着色,它将为您的代码中的每个括号和括号赋予不同的颜色。

要启用此设置,请使用快捷键 Ctrl + Shift + P(Windows/Linux)或 Cmd + Shift + P(macOS)打开 配置 文件,并输入 打开用户 设置 (JSON)

在文件中添加以下元素:

{
  "editor.bracketPairColorization.enabled": true,
  "editor.guides.bracketPairs": true
}

VS Code 还具有 内联提示 功能,它显示参数类型和返回方法的详细信息,以及您正在阅读的代码行上的其他有用信息。

要在设置菜单中配置它,查找内联提示并激活它,如果它尚未配置。对于您的 Angular 应用程序的开发,您还可以通过选择TypeScript来执行特定的配置。

您也可以通过直接使用以下元素配置 settings.json 文件来启用此功能:

{
  "typescript.inlayHints.parameterNames.enabled": "all",
  "typescript.inlayHints.functionLikeReturnTypes.enabled": true,
  "typescript.inlayHints.parameterTypes.enabled": true,
  "typescript.inlayHints.propertyDeclarationTypes.enabled": true,
  "typescript.inlayHints.variableTypes.enabled": true,
  "editor.inlayHints.enabled": "on"
}

Fira Code 字体和连字符

并非每个开发者都会注意到的一个重要细节是他们代码编辑器中使用的字体类型。一个令人困惑的字体可能会使阅读代码变得困难,并使您的眼睛感到疲劳。

理想的选择是使用等宽字体,即字符占据相同水平空间的字体。

如下图中所示,一个非常受欢迎的字体是 ==>==>

图 1.1 – 带有字体连字符的符号示例

图 1.1 – 带有字体连字符的符号示例

在您的操作系统上安装字体后,要启用 VS Code 中字体的连字符,按照上一节中的说明访问 configuration 文件,并添加以下元素:

{
  "editor.fontFamily": "Fira Code",
  "editor.fontLigatures": true,
}

在项目中标准化扩展和设置

为什么选择 Angular? 部分中,我们了解到选择这个框架进行项目开发的一个优点是它为开发团队提供标准化。

您还可以标准化您的 VS Code 设置,并将它们记录在您的 Git 仓库中,这样不仅您,我们的团队也能获得这种生产力飞跃。

要完成这个任务,在你的仓库中创建一个名为 .vscode 的文件夹,并在该文件夹内创建两个文件。extensions.json 文件将包含项目推荐的所有扩展。在这个例子中,我们将使用我们之前看到的扩展:

{
  "recommendations": [
    "dbaeumer.vscode-eslint",
    "esbenp.prettier-vscode",
    "Angular.ng-template",
    "donjayamanne.git-extension-pack",
    "editorconfig.editorconfig"
  ]
 }

让我们再创建一个 settings.json 文件,它允许您将 VS Code 设置添加到您的工作区。这些设置优先于用户设置和 VS Code 的默认设置。

此文件将包含之前建议的设置:

{
  "editor.bracketPairColorization.enabled": true,
  "editor.guides.bracketPairs": true
  "editor.fontFamily": "Fira Code",
  "editor.fontLigatures": true,
  "typescript.inlayHints.parameterNames.enabled": "all",
  "typescript.inlayHints.functionLikeReturnTypes.enabled": true,
  "typescript.inlayHints.parameterTypes.enabled": true,
  "typescript.inlayHints.propertyDeclarationTypes.enabled": true,
  "typescript.inlayHints.variableTypes.enabled": true,
  "editor.inlayHints.enabled": "on"
}

通过在您的仓库中同步这些文件,当您的团队成员下载项目并首次打开 VS Code 时,将显示以下消息:

图 1.2 – VS Code 推荐扩展的提示

图 1.2 – VS Code 推荐扩展的提示

一旦确认,文件中配置的所有扩展都将安装到团队成员的 VS Code 开发环境中,从而自动化标准化团队工作环境的任务。

Angular DevTools

从 Angular 框架中缺失的一个工具是钻入浏览器中的应用程序的方式。多年来,像 Chrome 和 Firefox 这样的浏览器已经极大地改善了所有类型网站的开发者体验。

在此背景下,Angular 团队从版本 12 开始为 Chrome 和 Firefox 创建了 Angular DevTools 扩展。

要安装它,您需要前往浏览器(Chrome 或 Firefox)的扩展程序商店并点击 安装

安装后,访问使用 Angular 构建的网站,以及为开发设置的构建,开发者工具中会出现 Angular 选项卡:

图 1.3 – Angular DevTools Chrome 扩展示例

图 1.3 – Angular DevTools Chrome 扩展示例

此工具允许您浏览应用程序的结构,定位屏幕上组件的代码,并对您的应用程序进行性能分析以检测可能的问题。

现在,您已经拥有了开发 Angular 应用程序的生产力开发环境,我们准备开始我们的应用程序。

开始一个 Angular 项目

我们已经安装并配置了我们的工具,现在我们将开始我们的 Angular 应用程序。首先,我们将安装 Angular CLI,它将负责创建和构建我们的应用程序。在您的终端中,输入以下命令:

npm install -g @angular/cli@16

安装 CLI 后,使用以下命令来确认安装:

ng version

以下图应在您的终端中显示(Angular 版本可能更新):

图 1.4 – Angular CLI 提示确认您已正确安装工具

图 1.4 – Angular CLI 提示确认您已正确安装工具

如果 ng 命令不被识别,请重新启动终端。此 ng 命令是 CLI 调用,并将在本章和其他章节中使用。

让我们使用 ng new 命令开始我们的项目。Angular CLI 将要求您定义一些项目:

  1. 第一项是项目的名称;对于此示例,输入 angular-start

  2. 第二个提示是您是否想配置项目的路由,我们将输入 Yes。此请求将告诉 CLI 创建路由的基本文件,这对于大多数应用程序是推荐的;一个例外可能是您想要创建的 Angular 库。

  3. 下一个提示将告诉您项目将使用哪种 CSS 格式。Angular 默认支持常规 CSS 以及 SCSS、Sass 和 Less 工具。对于本书中的此和其他示例,我们将使用 CSS

  4. 确认 Angular CLI 将创建项目的整个初始结构,并使用 npm i 命令安装依赖项,为开发启动做好准备,如下例所示。

图 1.5 – 由 angular-cli 生成的文件提示

图 1.5 – 由 angular-cli 生成的文件提示

要验证项目是否成功安装,在您的操作系统终端中输入以下命令:

ng serve

此命令将启动开发 Web 服务器并加载示例项目页面,如图 图 1**.6 所示:

图 1.6 – 创建项目时由 angular-cli 生成的示例页面

图 1.6 – 创建项目时由 angular-cli 生成的示例页面

ng new命令还有其他选项,可以用于项目中特定的需求。它们列在官方文档(angular.io/cli/new)中,以下是一些可能有趣的选项:

  • 参数 '—package-manager': 使用此参数,可以选择其他 node 包管理器,例如https://yarnpkg.com/)。

  • 参数 '--skip-install': 使用此参数,CLI 不会执行包安装步骤,这对于为你的团队创建自动化工具可能很有用。

  • 参数 '--strict': 此参数默认设置为true,但重要的是要提及它,因为它将项目配置为strict模式,该模式配置 TypeScript 和 Angular 机制以改进类型和模板验证。有关更多详细信息,请参阅第三章Angular 的 TypeScript 模式

项目结构

Angular CLI 使用 Angular 团队推荐的结构创建项目,并默认配置所有文件。为了深化我们对框架的了解,我们需要了解主要文件、它们的函数以及可用的自定义化,如下所示:

  • src: 这是你的项目所在的文件夹,包括所有组件、模块和服务。

  • assets: 包含你在项目中需要的静态文件,例如图片和图标。在构建过程中,默认情况下,它将从此文件夹导出文件,而不会对生产构建进行任何更改。

  • index.html: 这是应用程序的初始文件。在构建过程中,此文件将被使用,除非有非常具体的需求,否则建议不要修改它。标题信息必须使用 Angular 功能进行更改,而不是直接在此文件中更改。

  • main.ts: 这是将在应用程序中加载的第一个 JavaScript 文件。除非你的项目有非常具体的需求需要更改它,否则你不应该更改它。

  • styles.css: 这是一个可以包含应用程序全局 CSS 的文件,即所有组件都可以读取的 CSS,因为 Angular 默认将每个组件的 CSS 隔离。当你的项目使用 Material(material.angular.io/)等设计系统时,通常需要修改此文件。

  • .editorconfig: 如本章的VS Code部分所述,此文件以及解释和配置 IDE 的扩展,允许在代码约定中实现标准化,例如使用双引号或单引号以及使用制表符或缩进空格。

  • angular.json: 这是 Angular 应用程序最重要的配置文件。在其中,您可以自定义项目构建的方式,并定义包大小的预算(更多详情请参阅第十二章打包一切 – 部署最佳实践),以及其他设置。

  • package.jsonpackage-lock.json:这些文件指的是项目npm包的依赖项,也是创建将在 Angular 应用程序 CI/CD 管道中使用的npm脚本的地点(更多详情请参阅第十二章打包一切 – 部署最佳实践)。

截至 Angular 版本 15,CLI 默认隐藏 Karma 配置文件和环境变量文件(enviroment.ts),理由是简化项目结构。仍然可以创建这些文件以微调应用程序构建、测试和环境过程(更多详情请参阅第八章改进后端集成:拦截器模式)。

我们使用angular-cli工具创建了我们的项目,但这个工具可以为我们提供更多帮助,正如我们接下来将要学习的。

使用 Angular CLI 提高您的生产力

我们学习了如何创建具有所有选项的项目,但 Angular CLI 远不止是一个项目创建工具。它是 Angular 应用程序生产力和工作流程中非常重要的工具。所有可用选项都使用以下命令描述:

ng --help

我们将详细介绍一些最有趣的选择,在接下来的章节中,我们将继续使用这些工具,鉴于这个工具的实用性。

ng add

此命令的功能是将 Angular 库添加到您的项目中。您可能会想,“npm install 不是做同样的事情吗?”您是对的。然而,当您需要将 Angular Material 作为库安装时,安装依赖项只是第一步。

许多库,如 Angular Material 本身,需要配置angular.json文件和创建一些其他lib文件,以及其他任务。ng add命令允许库创建者自动化这些步骤并简化他们的工作流程。

为了在创建的项目中举例说明这一点,我们将使用以下命令:

ng add @angular/material

执行上述命令后,库将进行一些提示(格式与ng new命令中看到的相同),最后,它将使用库配置我们的项目,如图图 1.7所示。

图 1.7 – 使用 angular-cli 安装 Angular Material

图 1.7 – 使用 angular-cli 安装 Angular Material

ng update

在我们项目的开发过程中,更新某个版本的耗时往往比添加一个新库要长。ng update 命令使这项任务变得几乎微不足道,在我们更新应用程序的 Angular 版本时,它是最大的盟友之一。

在 Angular 更新网站(update.angular.io/)上,Angular 团队详细说明了如何更新旧版本的项目。较大的和更复杂的项目可能有它们的怪癖(通常在网站上描述),但所有应用程序都从以下命令开始(在这种情况下,版本 15):

ng update @angular/core@15 @angular/cli@15

Angular CLI 将负责更新包,甚至可能进行破坏自动化的更改;通常,这已经足够完全更新您的应用程序。

这个命令,就像 ng add 一样,也适用于由其作者配置过的库,并且可以从这种自动化中受益。

ng serve

这个命令被每一位 Angular 开发者使用(这是您创建项目后应该做的第一件事),其功能是上传开发 Web 服务器。

此命令最有趣和最有生产力的特性之一是热重载功能;也就是说,每当项目文件更新时,服务器都会重新启动,让您能够实时在界面上看到其修改。

对于此命令的一个生产力提示是使用以下open参数:

ng serve --open

使用此参数,一旦 Angular 加载您的应用程序,CLI 就会打开操作系统默认的浏览器,并显示您正在工作的应用程序。

ng build

ng build 命令旨在准备您的应用程序包以便由您选择的生成 Web 服务器执行。

它执行一系列优化以确保交付尽可能小的应用程序包。

这导致了性能提升,因为客户端下载较小的包更快,这在互联网速度慢的环境中尤为重要。

我们将在第十二章中更详细地讨论此命令,打包一切 – 部署最佳实践

ng deploy

ng deploy 命令允许您将应用程序完全部署到云服务提供商,如微软 Azure。

此命令与您想要使用的提供商的 Angular 库协同工作,因此为了使其工作,您需要安装它。

我们将在第十二章中更详细地讨论此命令,打包一切 – 部署最佳实践

ng generate

ng generate 命令具有生成您应用程序可以使用的几乎所有类型的 Angular 组件的功能。此功能在您的流程中提高了生产力,因为它生成了所有必要的文件。

让我们在示例项目中使用以下命令生成我们的about页面:

ng generate component about

我们可以在我们的项目文件夹中分析,Angular CLI 创建了渲染组件所需的 TypeScript、HTML 和 CSS 文件。

然而,它还为此组件生成了单元测试文件,并更新了模块以供其使用。所有这些文件都已经包含了组件开发所需的最小样板代码。

除了生成几乎所有标准的 Angular 组件外,此命令还可以被希望提供这种开发体验的外部库使用,如下面的 Angular Material 示例所示:

ng generate @angular/material:navigation home

在本书的几乎每一章中,我们都会使用此命令来生成我们将要研究的组件,以及它们的最佳实践和模式。

摘要

在本章中,我们介绍了 Angular 的特性和哲学,以及如何以最高效的方式启动一个项目。我们学习了构成其生态系统的技术,以及如何使用最佳的 VS Code 扩展和设置来配置其桌面环境。最后,我们学习了如何使用 Angular CLI 启动一个项目,以及这个强大工具能为我们提供哪些其他功能。

现在,你将能够争论为什么要在你团队的项目中使用 Angular,你将能够帮助它设置一个高效的工作环境。你还将能够使用 Angular CLI 来创建和维护你的项目。

在下一章中,我们将学习如何组织 Angular 应用程序的组件。

第二章:组织您的应用程序

一个混乱的项目是一个等待破坏用户体验的虫窝。除了质量外,从开始就组织良好的项目将为您的团队带来生产力,在 Angular 的情况下,还有可能提高您应用程序的性能。

在本章中,您将了解 Angular 模块的功能,这些模块与 JavaScript 模块之间的区别,以及如何以最佳方式在项目中使用它们。

您将了解单模块应用的反模式以及如何避免它,以及为什么要避免它。您还将使用 Angular 模块通过 SharedModule 模式优化对应用程序中常用组件的导入。最后,您将了解如何使用懒加载来优化您应用程序的性能。

在本章中,我们将介绍以下主题:

  • 使用 Angular 模块组织应用程序

  • 第一个模块:AppModule

  • 避免反模式:单模块应用

  • 优化常用模块的使用:SharedModule 模式

  • 提高您应用程序的大小:懒加载

到本章结束时,您将能够将您的 Angular 应用程序组织成功能化和优化的模块。

技术要求

要遵循本章中的说明,您需要以下内容:

本章的代码文件可在 github.com/PacktPublishing/Angular-Design-Patterns-and-Best-Practices/tree/main/ch2 获取。

使用 Angular 模块组织应用程序

使用框架组织应用程序组件的基础是 Angular 模块,在文档和社区中更广为人知的是名称 NgModules

Angular 模块是一个带有 @NgModule 装饰器的 TypeScript 类,其中包含元数据,如下例所示:

import { NgModule } from '@angular/core';
@NgModule({
 declarations: [SimulationComponent],
 providers:[],
 imports: [
   CommonModule,
   SharedModule,
   MatCardModule,
   MatButtonModule,
   MatSelectModule,
   MatRadioModule,ReactiveFormsModule,
 ],
 exports: [SimulationComponent],
})
export class SimulationModule {}

让我们在以下子节中详细说明这些元数据类型。

声明

此元数据包含一个由组件、指令和管道组成的数组,这些组件必须只属于一个模块,否则 Angular 编译器将抛出错误,如图 图 2**.1 所示:

图 2.1 – 在多个模块中声明组件时的错误信息

图 2.1 – 在多个模块中声明组件时的错误信息

提供者

在这个属性中,我们可以使用 Angular 的依赖注入系统注册我们想要注入的类,通常用于服务(将在 第五章Angular 服务和 单例模式 中详细介绍)。

导入

在这个元数据中,我们通知模块我们想要导入并使用它们的组件和服务。例如,如果我们想使用 Angular 的 HTTP 请求服务,我们必须在这里声明 HttpClientModule 模块。

重要的是要知道,在这里,我们不应该导入组件或服务,而只导入 Ngmodules。

exports

默认情况下,declarations 属性中的所有项都是私有的。这意味着如果一个模块包含了 StateSelectorComponent 组件和另一个模块,例如,导入该模块以使用此组件将导致以下错误发生:

图 2.2 – 使用未正确导出的组件时的错误信息

图 2.2 – 使用未正确导出的组件时的错误信息

为了让 Angular 知道该组件可以被使用,必须在 exports 元数据中声明它。

imports 元数据不同,在这里,你可以声明组件、管道、指令和其他模块(如我们将在 优化常用模块的使用 – SharedModule 模式 部分中看到的)。

现在我们已经知道了如何声明一个模块,让我们来研究创建 Angular 项目时生成的模块。

第一个模块 – AppModule

Angular 中的模块对于框架来说非常重要,因此当您启动一个项目时,它会自动创建一个名为 AppModule 的模块。

此模块包含我们在上一节中研究的所有参数(declarationsprovidersimportsexports),以及一个额外的参数:bootstrap。此模块包含将被注入到应用程序的 index.html 文件中的第一个组件,并将成为您 Angular 应用程序组件树的根。

您可能想知道这是哪个 index.html 文件和哪个树。

如我们在 第一章 中所述,正确开始项目,Angular 是一个框架,index.html 文件实际上是网络服务器向其用户提供的唯一 页面

由 Angular 引擎渲染的所有接口(称为 index.html 文件,第一个组件在 bootstrap 元数据中描述)遵循逻辑树类型的数据结构,此树的根是第一个组件。

Angular 和 JavaScript 模块之间的区别是什么?

几乎所有编程语言都为开发者提供了一种方式,可以在一个或多个文件中组织函数、类和变量,从而提高可维护性和关注点的分离。

在 JavaScript 中,在其创建和几个提议之后,语言模块的概念得到了巩固。解释这个概念的最佳方式是使用示例来演示。首先,我们创建一个 sum.mjs 文件 – 一个接收两个数字并返回它们的和的 sum 函数。这里重要的是,我们使用 export 关键字来指示我们想在源文件之外的作用域中使用它:

export function sum(numberA,numberB){
  return numberA + numberB;
}

index.mjs文件中,我们将使用创建的函数,为此,我们在文件的第一行进行声明。使用保留字import,我们指明哪个函数以及它来自哪个文件:

import {sum} from './sum.mjs';
const numberA = 5;
const numberB = 10;
console.log(sum(numberA,numberB));

你可能想知道为什么使用.mjs扩展名。这是因为,在示例中,我们正在使用 Node.js 来执行,这种类型的模块——ECMAScript 模块ESM),因为 JavaScript 语言的官方名称是 ECMAScript——是在版本 14 中引入的。

Angular,以及所有其他 SPA 框架,在其开发中使用 JavaScript 模块,我们可以在任何 Angular 组件或服务中注意到我们导出类并使用 ESM 导入:

import { Component } from '@angular/core';
@Component({
  selector: 'app-home',
  templateUrl: './home.component.html',
  styleUrls: ['./home.component.css']
})
export class HomeComponent {

在前面的代码片段中,我们正在从@angular/core库中导入Component装饰器,并将HomeComponent类导出以在其他项目部分中使用。

模块类型

现在我们已经理解和加强了 Angular 框架中模块的概念,让我们将应用程序分割并更好地利用这个特性。组织应用程序模块没有固定的规则,但 Angular 团队和社区建议根据具有共同特性的功能分组来分离模块。

基于这个想法,我们可以有以下类型的 Angular 模块:

  • 业务领域模块

  • 组件模块

业务领域模块

一个应用程序将服务于一个或多个用户工作流程。此类模块旨在根据组成它们的接口的亲和力对这些流程进行分组。例如,在资源管理应用程序中,我们可以有会计模块和库存模块。

ch2文件夹中可用的应用程序中,有一个我们将用于本章和其他章节以将我们的知识付诸实践的talktalk应用程序。在项目文件夹中,让我们使用以下命令创建home模块:

ng g m home

在此命令中,我们使用 Angular CLI 的缩写ngg代表m代表home

让我们创建一个表示应用程序主页的Page组件,由于我们正在使用 Angular material,我们将使用 Angular CLI 来生成一个带有侧菜单的页面,使用以下命令:

ng generate @angular/material:navigation home/home

除了创建组件外,Angular CLI 还编辑了home.module.ts文件,将其添加到declarations属性中。按照以下示例更改此文件:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HomeComponent } from './home/home.component';
import { LayoutModule } from '@angular/cdk/layout';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatButtonModule } from '@angular/material/button';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
@NgModule({
 declarations: [HomeComponent],
 imports: [
   CommonModule,
   LayoutModule,
   MatToolbarModule,
   MatButtonModule,
   MatSidenavModule,
   MatIconModule,
   MatListModule,
 ],
 exports: [HomeComponent],
})
export class HomeModule {}

在此模块中,我们将导出HomeComponent组件以在应用程序的路由中使用。在app.module.ts文件中,按以下方式导入模块:

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { HomeModule } from './home/home.module';
@NgModule({
 declarations: [
   AppComponent
 ],
 imports: [
   BrowserModule,
   AppRoutingModule,
   BrowserAnimationsModule,
   HomeModule
 ],
 providers: [],
 bootstrap: [AppComponent]
})
export class AppModule { }

NgModule元数据的import属性中,我们可以更改app-routing.module.ts文件中的路由:

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { HomeComponent } from './home/home/home.component';
const routes: Routes = [
  { path: '', pathMatch: 'full', redirectTo: 'home' },
  {
    path: 'home',
    component: HomeComponent
  },
];
@NgModule({
 imports: [RouterModule.forRoot(routes)],
 exports: [RouterModule],
})
export class AppRoutingModule {}

routes组件也是NgModule,然而,它专门用于组织路由,并且只从 Angular 导入和导出RouterModule。在这里,在routes数组中,我们为HomeComponent创建方向。

运行ng serve --o命令,我们得到应用程序的主页:

图 2.3 – talktalk 示例应用程序菜单页面

图 2.3 – talktalk 示例应用程序菜单页面

组件模块

这个模块的目的是将那些将被业务域组件和其他组件重用的指令组件和管道分组。即使使用像 Angular Material 这样的组件库,你的系统也需要根据你的业务域规则创建自定义组件。

这种类型的组件在 declaration 属性中声明了组件、指令和管道,并在 exports 属性中导出,如下面的示例所示:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { StatesSelectorComponent } from './states-selector/states-selector.component';
import { MatSelectModule } from '@angular/material/select';
@NgModule({
 declarations: [StatesSelectorComponent],
 imports: [CommonModule, MatSelectModule],
 exports: [StatesSelectorComponent],
})
export class ComponentsModule {}

将项目分解为业务域模块和组件将组织你的代码并提高其可维护性。让我们分析 Angular 应用程序中的一种常见反模式。

避免反模式 – 单模块应用

当我们开始使用 Angular 进行学习和开发时,通常不太关注应用模块的组织和使用。正如我们在本章开头所学的,NgModules 对 Angular 来说是如此基础,以至于当我们开始一个项目时,Angular CLI 就会为该项目创建第一个模块,即 AppModule

理论上,仅此模块对于你的应用程序运行是必要的。从那里,我们可以声明所有组件和指令,并导入项目可能需要的所有库,如下面的示例所示:

import { NgModule } from '@angular/core';
. . .
@NgModule({
 declarations: [
   AppComponent,
   StatesSelectorComponent,
   HomeComponent,
   SimulationComponent
 ],
 imports: [
   BrowserModule,AppRoutingModule,
   BrowserAnimationsModule,HttpClientModule,
   ReactiveFormsModule,LayoutModule,
   MatToolbarModule,MatButtonModule,
   MatSidenavModule,MatIconModule,
   MatListModule,
 ],
 bootstrap: [AppComponent]
})
export class AppModule { }

这种方法存在一些问题,是一种我们称之为单模块应用的反模式。

我们在这里遇到的问题如下:

  • 无序的文件夹结构:随着项目的增长,团队将很快不知道哪些组件属于项目的哪个区域。随着项目的增长,这个文件将变得更大、更混乱。

  • 包大小和构建时间:Angular 有几个构建和打包优化,这些优化依赖于应用模块的定义。如果我们只在一个模块中,这些优化效果并不明显。

  • 组件可维护性和更新问题:随着这个文件的增长,团队将难以弃用不再使用的组件或更新那些 Angular CLI 无法自动更新的组件。

解决这种反模式的方法是应用我们在本章中学到的知识:将模块分为业务域(或功能)和组件模块。

我们可以使用 NgModel 来减少在应用程序中重复导入常用组件,正如我们将在下一节关于 SharedModule 模式的部分中所看到的。

优化常用模块的使用 – SharedModule 模式

如果我们观察 Angular 项目,我们将看到模块使用模式,例如 HttpModule,如下面的示例所示:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HomeComponent } from './home/home.component';
import { LayoutModule } from '@angular/cdk/layout';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatButtonModule } from '@angular/material/button';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
@NgModule({
 declarations: [HomeComponent],
 imports: [
   CommonModule,
   LayoutModule,
   MatToolbarModule,
   MatButtonModule,
   MatSidenavModule,
   MatIconModule,
   MatListModule,
 ],
 exports: [HomeComponent],
})
export class HomeModule {}

为了避免代码重复并使新团队成员更容易上手,别忘了将一个重要的模块添加到项目中;我们可以创建 SharedModule 来集中管理 Angular 项目的公共依赖。

让我们在我们的项目中使用 Angular CLI 来实现这一点:

ng generate module shared

在新创建的文件中,我们将放置 Angular Material 的依赖项:

import { NgModule } from '@angular/core';
...
@NgModule({
 imports: [
   CommonModule,
   LayoutModule,
   MatToolbarModule,
   MatButtonModule,
   MatSidenavModule,
   MatIconModule,
   MatListModule,
 ],
 exports: [
   CommonModule,
   LayoutModule,
   MatToolbarModule,
   MatButtonModule,
   MatSidenavModule,
   MatIconModule,
   MatListModule,
 ]
})
export class SharedModule { }

在本模块中,我们正在导入 Angular Material 的依赖项并导出相同的依赖项,而不声明任何组件、指令或管道。

home.module.ts文件中,我们可以重构以使用SharedModule

import { NgModule } from '@angular/core';
import { HomeComponent } from './home/home.component';
import { SharedModule } from '../shared/shared.module';
@NgModule({
 declarations: [HomeComponent],
 imports: [
   SharedModule
 ],
 exports: [HomeComponent],
})
export class HomeModule {}

注意使用SharedModule后,文件变得多么简洁且易于阅读。

重要

SharedModule中存在的模块必须是您项目中大多数模块共有的模块,因为这会增加模块包的大小。如果模块需要一些特定的依赖项,您必须在那个依赖项中声明它,而不是在SharedModule中。

在下一个主题中,我们将看到一个将提高用户体验的功能,该功能基于将应用程序组织成模块。

提高应用程序的大小 – 懒加载

将模块从您的 Angular 应用程序中分离出来的好策略将提高团队的生产力并改善代码组织。但另一个将影响用户质量的优势是使用模块的懒加载技术。

如果我们使用ng build命令运行示例应用程序的构建过程,我们可以看到以下消息:

图 2.4 – 示例应用程序包大小

图 2.4 – 示例应用程序包大小

我们应用程序的初始包(main.ts文件)的大小为 94.73 kB,这可能看起来很小,但考虑到我们的应用程序功能较少,这是一个相当大的大小。

随着项目功能的增加,这种初始包的增长趋势会相当明显,这会损害用户的体验,因为他们最初需要下载一个更大的文件。这个问题在互联网不太好的环境中尤其明显,例如 3G 网络。

为了减小这个文件并相应地提高用户体验,理想的情况是拥有更小的包,并且这些包只在必要时加载 – 也就是说,以懒加载的方式。

我们将重构我们的项目,我们已经采取的第一步是将功能分离到功能模块中(在避免反模式 – 单模块应用部分,我们解释了不分离应用程序模块的危险,毫无疑问,包的大小对用户影响最大)。

现在,让我们为Home模块创建一个路由文件。由于该模块已经存在,让我们在home.module.ts文件所在的同一文件夹中手动创建home-routing.module.ts文件。

在此文件中,我们将添加以下代码:

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { HomeComponent } from './home/home.component';
const routes: Routes = [
  {
    path: '',
    component: HomeComponent,
  },
];
@NgModule({
 imports: [RouterModule.forChild(routes)],
 exports: [RouterModule],
})
export class HomeRoutingModule {}

此路由文件与应用程序的主要路由文件类似,不同之处在于@NgModule的导入使用forChild方法而不是forRoot。这是因为此模块是主路由的子路由。

另一个需要注意的重要细节是,为HomeComponent组件选择的路径是空的。我们可以解释这一点,因为定义/home路由以及如何表示/home组件的主要路由文件已经定义。

home.module.ts文件中,让我们将其更改为导入路由文件:

import { NgModule } from '@angular/core';
import { HomeComponent } from './home/home.component';
import { SharedModule } from '../shared/shared.module';
import { HomeRoutingModule } from './home-routing.module';
@NgModule({
 declarations: [HomeComponent],
 imports: [
   SharedModule,HomeRoutingModule
 ]
})
export class HomeModule {}

在这个文件中,我们还移除了HomeComponent组件的导出,因为Home模块的路由文件会加载它。

在项目的主要路由文件app-routing.module.ts中,让我们按照以下方式重构它:

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
const routes: Routes = [
  { path: '', pathMatch: 'full', redirectTo: 'home' },
  {
    path: 'home',
    loadChildren: () =>
      import('./home/home.module').then((file) => file.HomeModule),
  },
];
@NgModule({
 imports: [RouterModule.forRoot(routes)],
 exports: [RouterModule],
})
export class AppRoutingModule {}

在此代码中,最重要的部分是loadChildren属性。这是我们配置懒加载的地方,因为我们向 Angular 的路由机制传递一个返回import承诺的函数。

注意,import函数不是一个 Angular 函数,而是一个标准的 JavaScript 函数,它允许动态加载代码。Angular 的路由引擎使用这个语言特性来实现这一功能。

最后,在主模块AppModule中,让我们移除HomeModule的导入:

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
@NgModule({
 declarations: [
   AppComponent
 ],
 imports: [
   BrowserModule,
   AppRoutingModule,
   BrowserAnimationsModule
 ],
 providers: [],
 bootstrap: [AppComponent]
})
export class AppModule { }

使用ng serve命令运行我们的应用程序时,我们没有注意到任何差异。然而,当执行ng build命令时,我们可以注意到以下诊断:

图 2.5 – 使用懒加载重构后的应用程序包大小

图 2.5 – 使用懒加载重构后的应用程序包大小

Angular 构建过程已将Home模块分离成其自己的包,并将main.ts包变得更小。这种差异可能看起来很小,但请注意,这样我们的应用程序可以扩展并增加复杂性,而初始包将保持较小或增长很少。

新功能仍然存在并被应用程序加载,但初始加载将更快,并且这些新功能将仅在用户访问他们想要的路径时按需下载,从而提供非常积极的流畅性和响应性。

摘要

在本章中,我们详细研究了 Angular 模块以及我们如何利用它们来组织应用程序的性能。我们学习了 Angular 模块与 JavaScript 模块之间的区别,并看到了模块定义的每个属性以及我们可以在项目中创建的类型。最后,我们学习了如何避免单模块应用程序的反模式以及如何创建SharedModule

我们重申了我们的示例应用,使用分批懒加载,这证明了良好的模块组织反映了我们用户的表现力和流畅性。现在,你可以以这种方式组织你的应用程序,使其能够扩展并增加复杂性和功能,而不会损害项目的可维护性。

在下一章中,我们将学习如何有效地使用 TypeScript 来为我们的 Angular 项目提高生产效率。

第三章:TypeScript Patterns for Angular

自框架的第 2 版以来,Angular 无论是内部开发还是用于构建应用程序的用户,都基于 TypeScript 进行开发。

当时这是一个有争议的决定,因为这个由微软创建的 JavaScript 超集是新的。如今,大多数 Web 框架,如 React、Vue.js 和 Svelte,都支持 TypeScript,一些 Web 框架甚至积极推荐使用 TypeScript 作为编程语言。

本章中,我们将研究使用 TypeScript 与 Angular 以及其他技术的最佳实践和模式;这些技术可以应用于 Node.js 后端开发,甚至其他 Web 框架,如 React 和 Vue.js。

我们将学习如何更好地声明我们的应用程序的方法和函数,以及如何利用 TypeScript 的类型推断机制来使我们的类更加简洁。

本章将涵盖以下主题:

  • 创建类和类型

  • 创建方法和函数

  • 减少冗余:类型推断

  • 验证类型:类型守卫

  • 使用 any 类型的更好替代方案

到本章结束时,你将能够更好地在你的项目中应用 TypeScript 资源,提高代码质量和团队的生产力。

技术要求

要遵循本章的说明,你需要以下内容:

本章的代码文件可在 github.com/PacktPublishing/Angular-Design-Patterns-and-Best-Practices/tree/main/ch3 找到。

创建类和类型

使用 Angular 进行应用程序开发的基础是面向对象编程,因此深入了解如何创建类和实例化对象对我们来说非常重要。使用 TypeScript 而不是纯 JavaScript,我们在类型工具箱中又多了一个强大的元素。

通过类型化变量和对象,TypeScript 编译器能够执行检查和警告,防止在开发过程中由于这个过程不存在而可能发生的运行时错误。

请记住,在将 TypeScript 代码转换为 JavaScript(这是一个转换过程)之后,发送到客户端浏览器的代码是纯 JavaScript,包括一些优化;也就是说,用 TypeScript 编写的代码在性能上并不逊色于直接用 JavaScript 编写的代码。

为了从基础知识开始,让我们来探索原始和基本类型。

原始和基本类型

尽管 JavaScript 不是强类型语言,但它有三个称为原始类型:

  • boolean:表示两个二进制值 falsetrue

  • string:表示一组字符,如单词

  • number:表示数值

对于这些原始类型中的每一个,TypeScript 已经有一个表示它们的内置数据类型,分别是BooleanStringNumber

重要

TypeScript 中原始类型的第一个字母是大写的,以区分它们与原始 JavaScript 类型的区别。如果您想使用typeof函数在运行时检查类型,请使用原始类型的小写名称。

要声明这些类型的变量,只需在变量声明前使用:符号,如下面的示例所示:

export function primitive_example() {
  let name: string;
  let age: number;
  let isAlive: boolean;
  name = "Mario";
  age = 9;
  isAlive = true;
  console.log(`Name:${name} Age:${age} is alive:${isAlive ? "yes" : "no"}`);
}

在前面的示例中,我们将nameageisAlive变量分别声明为stringnumberboolean。请注意,我们可以在 TypeScript 中使用 JavaScript 类型名称,因为 TypeScript 允许这些原始类型使用两种形式。

在 JavaScript 中,使用数组数据结构非常常见。这种结构允许我们存储和操作应用程序中的值列表。TypeScript 有一个名为Array的类型,其中不仅可以创建具有该类型的变量,还可以指明数组将包含哪种类型的值:

export function array_example() {
  let names: Array<string>;
  let surnames: string[];
  names = ["Mario", "Gabriel", "Lucy"];
  surnames = ["Camillo", "Smith"];
  names.forEach((name) => console.log(`Name:${name}`));
  surnames.forEach((surname) => console.log(`Surname:${surname}`));
}

在这个函数中,我们使用Array类型声明names数组,并声明它是一个字符串列表,因为我们是在方括号内通知它的。在surnames数组声明中,我们进行相同的声明,但使用 TypeScript 的语法糖,在string类型后面使用[]。这种声明方式具有相同的效果;它只是更简洁。

在示例的末尾,我们使用ArrayforEach方法来打印数组的元素。最后,另一个广泛使用的类型是any类型。此类型告诉 TypeScript 编译器不要对此类型执行任何类型检查,并且其内容可以在代码的任何地方更改类型,如下面的示例所示:

export function any_example(){
  let information:any;
  information = 'Mario';
  console.log(`Name: ${information}`);
  information = 7;
  console.log(`Age: ${information}`);
}

information变量被声明为any,然后我们将Mario字符串放入其中。随后,我们用值5重新定义了变量。

默认情况下,在 TypeScript 中,每个没有声明类型的变量,或者在其声明中定义了值的变量,都是any类型。

这种语言规则允许,例如,一个包含 JavaScript 代码的项目可以通过最初将所有变量声明为any类型来逐步转换为 TypeScript。any类型的另一个用途是当您的代码需要 JavaScript 的灵活性以处理一些更通用的算法类型时。

然而,建议 Angular 开发者避免使用any,因为它部分地禁用了 TypeScript 在您的代码中执行的检查,而没有充分利用其功能。

如果您需要any类型的灵活性,而又不想牺牲类型检查和 TypeScript 推断,本章将展示一些替代方案。

在我们了解基本类型的基础上,现在让我们创建更复杂的数据类型。我们将要探索的第一个是 。面向对象编程的一个基本元素,类代表一个模型,可以是真实的,如人或车辆,也可以是抽象的,如网页上的文本框。

在课程中,我们创建了对象,这些对象是我们系统将用来执行业务规则的基本元素,如下面的示例所示:

class Person {
  name: string;
  age: number;
  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
  }
}
export function basic_class() {
  let client: Person = new Person("Mario", 7);
  console.log(`Name:${client.name} Age:${client.age}`);
}

首先,我们通过键入属性声明 Person 类,具有 nameage 属性,然后我们为该类创建一个名为 constructor 的方法。这个方法很特殊,因为它定义了从该类实例化对象时的规则。

basic_class 函数中,我们使用 new 关键字实例化了一个名为 client 的对象,它是 Person 类型的。为了检索这个实例化对象的属性,我们使用 client.nameclient.age 的表示法。

TypeScript 中类的声明和使用几乎与 JavaScript 相同,只是对类的属性进行类型化。

纯 JavaScript 中的相同示例如下:

class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }
}
function basic_class() {
  let client = new Person("Mario", 7);
  console.log(`Name:${client.name} Age:${client.age}`);
}

注意,声明类和从它实例化对象的过程在 TypeScript 中变化很小。然而,正如我们将在下面的代码块中看到的,TypeScript 为我们在项目中使用类提供了更多的资源。

除了属性之外,类还定义了方法,这些是对象可以执行的功能。在我们正在工作的示例中,我们现在将添加一个方法:

class Person {
  name: string;
  age: number;
  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
  }
  toString(){
    return `Name:${this.name} Age:${this.age}`;
  }
}

toString 方法返回一个表示对象的 string,因此它使用保留的 JavaScript 关键字 this 访问对象实例的属性。

面向对象编程中有一个称为 属性封装 的概念。这包括定义哪些属性可供实例化给定对象的函数访问。

这个概念对于正确使用某些设计模式非常重要,但在 JavaScript 中并不完整存在。每个类属性都是公开的,但在 TypeScript 中,它通过编译器实现和验证,如下面的示例所示:

class Person {
  name: string;
  age: number;
  private id:number;
  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
    this.id =Math.floor(Math.random() * 1000);
  }
  toString(){
    return `Name:${this.name} Age:${this.age} ID: ${this.id}`;
  }
}

在这里,我们创建了一个名为 id 的属性,该属性在对象实例化时生成,我们使用保留字 private 来表示它不应从类外部访问。请注意,在类方法中,此属性可以正常访问。

让我们尝试从外部强制访问,如下面的示例所示,看看会发生什么:

export function basic_class() {
  let client: Person = new Person("Mario", 7);
  console.log(client.toString());
  client.id = 100;
}

在这个函数中,我们实例化了一个 Person 类的 client 对象,然后我们尝试修改 id 属性。当尝试运行代码时,TypeScript 将显示以下错误:

图 3.1 – 访问私有属性时的错误信息

图 3.1 – 访问私有属性时的错误信息

另一个面向对象编程的概念是继承。它定义了类之间的 is a 关系,例如,一个客户是 一个人

在实践中,它使一个类具有扩展类的所有属性和方法,如下面的示例所示:

class Client extends Person {
  address: string;
  constructor(name: string, age: number, address: string) {
    super(name, age);
    this.address = address;
  }
  toString(): string {
    return `${super.toString()} Address: ${this.address}`;
  }
}

在这里,我们正在创建一个 Client 类,它从 Person 类扩展而来。我们添加一个名为 address 的属性并创建构造函数。由于它是一个从 Person 继承而来的类,因此必须调用 super 方法,这是我们访问原始类的方法和属性的方式。

当使用继承时,我们可以选择性地重写原始类的方法,就像我们重写 toString 方法一样。这个概念在 JavaScript 中也存在,但 TypeScript 会检查构造函数和方法重写的规则,在编译时进行检查,这增加了我们对开发的信心。

接口

在 TypeScript 中,我们还有一种方式来指定对象的结构,称为 接口。以下示例演示了其用法:

export interface Animal {
  species: string;
  kingdom: string;
  class: string;
 }

要声明一个接口,我们使用保留字 interface 并像之前看到的那样将其属性声明为一个类。

要使用 interface,我们可以这样做:

import { Animal } from "./animals";
export function basic_interface() {
  let chicken: Animal = {
    kingdom: "Animalia",
    species: "Gallus",
    class: "birds",
  };
  console.log(
    `kingdom:${chicken.kingdom} species:${chicken.species} class:${chicken.class}`
  );
}

注意,为了使用一个类,我们只需输入变量并声明其值,而不使用保留字 new。这是因为接口不是 JavaScript 元素,它仅由 TypeScript 编译器用于检查对象是否包含定义的属性。

为了证明接口不存在,如果我们编译 interface 文件,TypeScript 将生成一个空文件!

我们还可以使用接口为类创建合约,如果类需要某些方法和属性的话。让我们看看以下示例:

export interface Animal {
  species: string;
  kingdom: string;
  class: string;
}
export interface DoSound {
  doASound: () => string;
}
export class Duck implements DoSound {
  public doASound(){
    return 'quack';
  }
}
export class Dog implements DoSound {
  public doASound(){
    return 'bark';
  }
}

要定义一个类遵循 DoSound 合约,我们使用保留字 implements。TypeScript 然后要求定义一个名为 doASound 的方法,并且该方法返回一个字符串。

接口这一特性促进了面向对象语言非常重要的能力——多态性的使用。让我们看看以下示例:

export function animalDoSound() {
  let duck = new Duck();
  let dog = new Dog();
  makeSound(duck);
  makeSound(dog);
}
function makeSound(animal: DoSound) {
  console.log(`The animal make this sound:${animal.doASound()}`);
}

我们创建一个 makeSound 函数,它接收一个实现了 DoSound 合约的动物。该函数不关心动物的类型或其属性;它只需要遵循 DoSound 接口合约,因为它将调用其方法之一。

Angular 大量使用 TypeScript 接口的这一特性,正如我们可以在组件声明中看到的那样:

export class SimulationComponent implements OnInit {

当我们通知 Angular 组件实现了 OnInit 接口时,它将在组件生命周期的开始执行所需的 ngOnInit 方法(我们将在 第四章组件页面)中更详细地研究这一点)。

类型别名

本章我们将看到的最后一种类型化变量的方式是最简单的一种,即创建类型别名。与接口一样,类型别名仅存在于 TypeScript 中,我们可以在以下示例中使用它们:

type Machine = {
  id: number;
  description: string;
  energyOutput: number;
};
export function basic_type() {
  let car: Machine = {
    id: 123,
    description: "Car",
    energyOutput: 1000,
  };
  console.log(
    `ID:${car.id} Description:${car.description} Energy Output:${car.energyOutput} `
 );
}

在此代码中,我们创建了一个Machine类型,描述了我们想要表示的对象,并在basic_type函数中,我们使用该类型实例化了一个变量。

注意,我们使用这个变量的属性就像之前的例子一样。这展示了 TypeScript 在保持 JavaScript 的灵活性同时,为开发者提供了更多可能性。

类型别名的常用特性之一是从其他类型创建一个类型。其中最常见的是类型的联合,正如我们可以在以下代码中看到:

type ID = string | number;
type Machine = {
  id: ID;
  description: string;
   energyOutput: number;
};

这里,我们正在创建一个名为id的类型,它可以是一个stringnumber。为此,我们使用|符号,这与 JavaScript 中用来表示条件OR的符号相同。

这个特性对于使用更高级的技术非常重要,例如guard类型,我们将在本章中看到。

何时使用类、接口或类型

在所有这些创建类型化对象的方式中,你可能会想知道在哪些情况下我们应该使用哪一种。基于每种形式的特性,我们可以对每种使用进行分类:

  • 类型别名:创建的最简单形式,推荐用于类型化输入参数和函数返回值。

  • implements关键字。

  • :面向对象的基础,也存在于 JavaScript 中。当我们需要具有方法和属性的对象时,我们应该使用它。在 Angular 中,所有组件和服务最终都是通过类创建的对象。

记住,在 TypeScript 中,你可以创建一个表现得像接口的alias类型,以及将接口作为函数的参数和返回值,但这里的建议是针对每种情况使用最佳实践,并解释它们通常如何在 Angular 应用程序中使用。

现在我们已经很好地理解了创建更复杂变量作为对象的不同方式,让我们来了解如何使用 TypeScript 创建函数和方法。

创建方法和函数

TypeScript 用来改善 Angular 应用程序开发中开发者体验的最好方法之一是通过能够类型化参数和函数及方法。

对于创建库和框架的开发者以及使用这些软件组件的开发者来说,了解函数期望什么以及预期的返回值可以让我们减少阅读和查找文档的时间,特别是我们系统可能遇到的运行时错误。

要对函数的参数和返回值进行类型化,让我们考虑以下示例:

interface invoiceItem {
  product: string;
  quantity: number;
  price: number;
}
type Invoice = Array<invoiceItem>;
function getTotalInvoice(invoice: Invoice): number {
  let invoiceTotal = invoice.reduce(
    (total, item) => total + item.quantity * item.price,
    0
  );
  return invoiceTotal;
}
export function invoiceExample() {
  let example: Invoice = [
    { product: "banana", price: 1.5, quantity: 3 },
    { product: "apple", price: 0.5, quantity: 5 },
    { product: "pinaple", price: 3, quantity: 12 },
  ];
  console.log(`Invoice Total:${getTotalInvoice(example)}`);
}

在这个例子中,我们首先定义一个表示发票项目的接口,然后创建一个表示发票的类型,在这个简化中,它是一个项目的数组。

这展示了我们如何使用接口和类型更好地表达我们的 TypeScript 代码。不久之后,我们创建了一个返回发票总价值的函数;作为输入参数,我们接收一个具有发票类型的值,函数的返回值将是 number

最后,我们创建了一个示例函数来使用 getTotalInvoice 函数。在这里,除了类型检查之外,如果我们使用具有 TypeScript 支持的编辑器,如 VS Code,我们将获得基本的文档和自动完成功能,如下面的截图所示:

图 3.2 – 由 TypeScript 生成并由 VS Code 可视化的文档

图 3.2 – 由 TypeScript 生成并由 VS Code 可视化的文档

除了原始类型和对象之外,函数还必须准备好处理 null 数据或未定义变量。在下一节中,我们将探讨如何实现这一点。

处理 null 值

在 TypeScript 中,默认情况下,所有函数和方法参数都是必需的,并且由编译器进行检查。

如果任何参数是可选的,我们可以在它所代表的类型中定义它,如下面的示例所示:

function applyDiscount(
  invoice: Invoice,
  discountValue: number,
  productOfDiscount?: string
) {
  discountValue = discountValue / 100;
  let newInvoice = invoice.map((item) => {
    if (productOfDiscount === undefined || item.product === productOfDiscount) {
      item.price = item.price - item.price * discountValue;
    }
    return item;
  });
  return newInvoice;
  }

在应用折扣到发票的函数中,我们创建了一个可选参数,允许函数用户确定要应用折扣的产品。如果该参数未定义,折扣将应用于整个发票。

要定义一个可选参数,我们使用 ? 字符。在 TypeScript 中,可选参数必须是函数中最后定义的。如果我们更改函数参数的位置,编译器将抛出以下错误:

error TS1016: A required parameter cannot follow an optional parameter.

此外,TypeScript 允许您为参数定义一个默认值:

function applyDiscount(
  invoice: Invoice,
  discountValue = 10,
  productOfDiscount?: string
)

在参数声明中赋值时,如果函数用户没有使用该参数,将对发票项目应用 10%的折扣。

我们已经看到了如何使用 TypeScript 来类型化函数参数和返回值。现在让我们讨论类型推断以及我们如何使用它来减少代码的冗余。

减少冗余 – 类型推断

在本章中,我们看到了 TypeScript 的最佳功能,这些功能有助于我们开发 Angular 项目。我们输入所有变量,并依赖 TypeScript 编译器来避免在用户运行时可能发生的错误。

现在我们来探索 TypeScript 强大的推断机制。通过它,TypeScript 通过内容识别变量的类型,而不需要您显式地定义类型。让我们观察以下示例:

export function primitive_example() {
  let name = "Mario";
  let age = 9;
  let isAlive = true;
  console.log(`Name:${name} Age:${age} is alive:${isAlive ? "yes" : "no"}`);
}

这个例子与 原始和基本类型 中的例子相同,但我们直接在变量中告知值。这种声明变量的方式与显式方法具有相同的效果。如果您更改变量的值为另一种类型,TypeScript 将执行如下示例中的验证:

TSError: ⨯ Unable to compile TypeScript:
src/basic_types/primitive.ts:6:3 - error TS2322: Type 'number' is not assignable to type 'string'.

TypeScript 也可以推断复杂类型,如数组和函数返回值。在这里的一个好做法是使用推断能力来编写更少的代码,并且只从接口中类型化对象,例如。

验证类型 – 类型守卫

现在我们已经了解了 TypeScript 的推断机制,我们可以理解其中另一个特性,类型守卫。让我们在以下示例中考虑这些内容:

function getDiscount(value: string | number) {
  if (typeof value === "number") {
    return value;
  } else {
    return parseInt(value);
  }
}

在这个函数中,我们可以接收一个可以是原始类型 stringnumber 的值。

由于它们是原始类型,我们可以使用 typeof 函数来定义变量是否为数值型;否则,它是一个 string,我们必须将其转换为数值型。

TypeScript 转译器可以解释这个条件语句的上下文,并在其中将值视为 numberstring,包括在 VS Code 的自动完成中。

图 3.3 – TypeScript 中的条件部分,识别变量为数字

图 3.3 – TypeScript 中的条件部分,识别变量为数字

图中的 VS Code 插件在后台运行转译器,并识别出 if 语句内部的变量只能是一个 number

图 3.4 – TypeScript 中的条件 else 部分,识别变量为字符串

图 3.4 – TypeScript 中的条件 else 部分,识别变量为字符串

由于它们是原始类型,我们可以使用 typeof 函数来定义变量是否为数值型;否则,它是一个 string,我们必须将其转换为数值型。

对于更复杂的数据类型,如对象,使用 typeof 函数的这种守卫是不可能的,因为它总是会识别变量为 object 类型。然而,我们可以创建自己的自定义类型守卫函数:

interface Person {
  socialSecurityNumber: number;
  name: string;
}
interface Company {
  corporateNumber: number;
  name: string;
}
type Client = Person | Company;
function isPerson(client: Client): client is Person {
  return (client as Person).socialSecurityNumber !== undefined;
}
function getID(client: Client) {
  if (isPerson(client)) {
    return client.socialSecurityNumber;
  } else {
    return client.corporateNumber;
  }
}

在这里,我们有两个接口,PersonCompany,我们创建了一个名为 Client 的类型。对于遵循接口的每种对象类型,我们都有一个 id 类型,对于个人是 socialSecurityNumber,对于公司是 corporateNumber

为了执行 guard 类型,我们创建了 isPerson 函数。关于这个函数的不同之处在于,我们将 client is Person 表达式放在了函数返回值的定义中。

在其中,我们定义了将对象视为人的规则,并在 getID 函数中使用它。我们不仅有一个在运行时检查对象的函数,而且以这种方式,TypeScript 转译器在编译时检查操作是否报告了错误。

使用 better alternative 到 any 类型

在 TypeScript 应用程序的开发过程中,我们可能会遇到不知道将要接收哪种类型参数的情况,例如 API 的返回值。

通过创建表示数据的接口可以定义传输的内容,(更多详情,见 第五章**,Angular 服务和单例模式)。由于纯文本在互联网上传输,因此无法保证这一点。

在这些情况下,我们可以使用 any 类型,这可以防止 TypeScript 进行类型检查。

在这个例子中,我们可以看到 any 的使用:

interface Products {
  id: number;
   description: string;
}
type ListOfProducts = Array<Products>;
const exampleList: ListOfProducts = [
  { id: 1, description: "banana" },
  { id: 2, description: "apple" },
  { id: 3, description: "pear" },
];
function getProductById(id: any) {
  return exampleList.find((product) => product.id === id);
}

在前面的代码示例中,我们创建了一个表示产品的接口和一个表示产品列表的类型。然后我们创建了一个接收 any 类型 id 的函数,并在 Array 中搜索,从产品列表中返回一个项。

在这些简单的例子中,我们可以假设没有错误,但让我们创建一个将使用此片段的函数,看看会发生什么:

export function getProductTest() {
  const id = '2';
  const item = getProductById(id);
  if (item !== undefined) {
    console.log(`ID:${item.id} Description:${item.description}`);
  } else {
    console.log("No product found");
  }
}

在这个例子中,项目未找到是因为我们传递的变量是一个 string。这可能发生在我们传递给函数的数据来自 API 或外部调用,并且数据格式不正确。

运行代码,我们得到以下结果:

图 3.5 – 由于 id 变量类型错误,函数返回“未找到产品”

图 3.5 – 由于 id 变量类型错误,函数返回“未找到产品”

当我们使用 any 类型时,我们放弃了类型检查的优势,这种类型的错误可能会出现在我们的应用程序中。但如何在不失去 TypeScript 类型检查的情况下拥有 any 类型的灵活性?

在这些情况下,我们使用 unknown 类型。这种类型与 any 类型具有相同的灵活性,但有一个细节:TypeScript 强制你在使用变量之前执行类型守卫。

让我们重构我们的示例函数:

function getProductById(id: unknown) {
  if (typeof id === 'string'){
    id = parseInt(id);
  } else if (typeof id !== 'number'){
    return
  }
  return exampleList.find((product) => product.id === id);
}

在这里,我们声明 id 将是 unknown 类型,紧接着我们在该变量中创建一个 guard 类型,处理变量可能是数字的可能情况。

any 类型仍将在你的应用程序中使用,但考虑使用 unknown 类型以确保当你不确定谁会调用你的函数时,可以正确处理类型。

摘要

在这一章中,我们看到了如何使用 TypeScript 以更少的努力创建更高质量的代码,从而提高我们的生产力。我们学习了基本 TypeScript 类型,如 numberstringArray

我们还研究了创建类、接口和类型别名,以及我们如何选择和混合这些结构类型,使我们的代码更简洁、更易于维护。

最后,我们学习了 TypeScript 的类型推断机制以及我们如何使用类型守卫的概念来进一步改进类型检查机制。通过这些概念,我们还熟悉了 unknown 类型,它为 any 类型提供了一个更好的替代方案。

在下一章中,我们将学习 Angular 项目接口的基础知识,即组件。

第四章:组件和页面

Angular 应用程序的主要构建块是 组件。正是通过使用它们,我们组装用户界面并定义体验的流程。在 Angular 架构中,组件将应用程序组织成可重用的部分,使其易于维护和扩展。

在本章中,我们将探讨组件之间的通信,并使用组件组合来组装我们的页面,避免创建单体界面的反模式。

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

  • 创建组件

  • 组件之间的通信 – 输入和输出

  • 最佳实践 – 使用 TrackBy 属性

  • 分离责任 – 智能组件和展示组件

  • 子组件之间的通信 – 使用 @Output

到本章结束时,你将能够创建可重用且易于维护的组件和页面,从而简化项目开发并提高你和你团队的生产力。

技术要求

要遵循本章中的说明,你需要以下内容:

本章的代码文件可在 github.com/PacktPublishing/Angular-Design-Patterns-and-Best-Practices/tree/main/ch4 找到。

创建组件

使用 Angular 创建的每个接口都是框架架构中的一个组件;因此,从理论上讲,我们可以在单个组件中拥有我们的整个应用程序。

正如我们在 第二章 中所研究的,组织你的应用程序,最好将你的应用程序分成模块,并且通过组件,我们通过将我们的接口分成不同的组件并使用不同的组件来组合它们,来使用相同的推理,以最大化重用性和可维护性。

在本章中,我们将通过以下健身日记应用程序来展示这一点,如图所示 – 为了专注于 Angular,我们不会使用 Angular Material,只使用 HTML、CSS(在这种情况下,Tailwind CSS)和 TypeScript。

图 4.1 – 健身日记应用程序 UI

图 4.1 – 健身日记应用程序 UI

在这个初始示例中,我们创建了一个仅包含 HTML 模板、CSS 和 TypeScript 文件的组件。以下是页面的顶部内容:

<div class="min-h-screen bg-gray-200">
  <header class="bg-blue-500 py-4 text-white">
    <div class="mx-auto max-w-6xl px-4">
      <h1 class="text-2xl font-bold">Workout diary</h1>
    </div>
  </header>

使用良好的 HTML 语义实践,让我们创建一个 main 部分:

 <main class="mx-auto mt-8 max-w-6xl px-4">
   <section class="mb-8">
     <h2 class="mb-4 text-xl font-bold">List of entries</h2>
     <ul class="rounded border shadow">
       <li class="mb-4 border-b bg-white p-4">
         <span class="font-bold">Date:</span> 2023-03-20<br />
         <span class="font-bold">Exercise:</span> Bench press<br />
         <span class="font-bold">Sets:</span> 3<br />
         <span class="font-bold">Reps:</span> 10
       </li>
         <!-- more entries here -->
     </ul>
   </section>
   <button
     class="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
   >
     Add new entry
   </button>
 </main>
</div>

我们可以看到,在前面的示例中,界面已经设计和样式化,但它不是功能性的,因为日记条目固定在 HTML 中,在我们的应用程序中,用户应该能够添加他们想要的任意数量的条目。

我们可以识别出,这个日记条目的这部分可能是一个页面可以使用的组件,所以让我们创建一个名为entry的组件。正如我们在第一章,“正确开始项目”中学到的,我们将使用 Angular CLI 在所需的模块中创建这个新组件:

ng g c diary/entry-item

使用这个命令,Angular CLI 将创建一个包含以下四个文件的新文件夹,并更新diary模块以包含新组件。

  • entry-item.component.css: 此文件将包含组件的样式表。Angular 成功地解决了 Web 应用的一个大痛点,即每个组件的 CSS 作用域。有了这个特性,我们可以在不担心是否会影响到应用程序的 CSS(即使使用相同的属性或选择器名称)的情况下指定组件的样式。

  • entry-item.component.html: 此文件包含组件的 HTML 模板,尽管扩展名似乎表明我们只能使用 HTML 标签,但在模板文件中,我们可以使用 Angular 指令,正如我们将在本章学习的。

  • entry-item.component.spec.ts: 此文件包含组件的单元测试,我们将在第十章“为测试而设计:最佳实践”中详细说明。

  • entry-item.component.ts: 这是代表组件本身的 TypeScript 文件。所有其他文件都是可选的,这使得你可以只使用这个文件来创建一个组件,尽管这并不是在 Angular 项目中广泛应用的实践,并且仅推荐用于非常小的组件。

entry-item.component.ts文件中,Angular CLI 创建了以下结构:

import { Component } from '@angular/core';
@Component({
 selector: 'app-entry-item',
 templateUrl: './entry-item.component.html',
 styleUrls: ['./entry-item.component.css']
})
export class EntryItemComponent {
}

通过这个示例,我们强化了组件是一个 TypeScript 类的定义,并通过使用@Component装饰器,我们向 Angular 指示了组装组件的部分在哪里。

主要属性如下:

  • selector: 这是一个可选属性,定义了如果组件在另一个组件的模板中使用时,其选择器将是什么。代表页面的组件不需要定义选择器,因为它们是从路由中实例化的。Angular CLI 根据在angular.json文件的prefix属性中定义的应用程序前缀以及你在ng g命令中定义的名称来建议选择器。

  • templateUrl: 这定义了包含组件模板的 HTML 文件的路径。或者,我们可以使用template属性来定义一个包含所有组件 HTML 的字符串。

  • styleUrls: 这定义了包含组件样式的 CSS 文件的路径。这个属性的细节是它是一个数组,因此可以链接多个 CSS 文件到组件。或者,我们可以使用style属性来定义一个包含组件 CSS 的字符串。

entry-item.component.html文件中,我们将放置代表我们健身房日记中练习列表中一个条目的片段:

<div class="mb-4 border-b bg-white p-4">
  <span class="font-bold">Date:</span> 2023-03-20<br />
  <span class="font-bold">Exercise:</span> Bench press<br />
  <span class="font-bold">Sets:</span> 3<br />
  <span class="font-bold">Reps:</span> 10
</div>

在这里,我们有了一个条目的表示,区别在于我们使用 <div> 元素而不是 <li>,因为我们希望我们的组件尽可能可重用——它可能不一定在列表和 <ul> 元素中使用。

让我们把我们的组件用起来。在 diary.component 组件中,让我们按照以下方式重构 diary.component.html 文件:

<div class="min-h-screen bg-gray-200">
  <header class="bg-blue-500 py-4 text-white">
    <div class="mx-auto max-w-6xl px-4">
      <h1 class="text-2xl font-bold">Workout diary</h1>
    </div>
  </header>
  <main class="mx-auto mt-8 max-w-6xl px-4">
    <section class="mb-8">
      <h2 class="mb-4 text-xl font-bold">List of entries</h2>
      <ul class="rounded border shadow">
        <li>
          <app-entry-item />
        </li>
        <li>
          <app-entry-item />
        </li>
        <!-- more entries here -->
      </ul>
   </section>
   <button
     class="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
   >
     Add new entry
   </button>
 </main>
</div>

使用 app-entry-item 选择器,我们在页面上使用了我们的新组件。从 Angular 的第 15 版开始,我们可以为组件使用自闭合标签,所以我们在这里使用了 <app-entry-item />,但如果你更喜欢以前的方式,<app-entry-item> <app-entry-item> 仍然有效。

运行我们的项目,我们可以看到它仍然在正常工作。然而,两个条目中的数据是相同的。我们现在需要一种在组件之间传递信息的方法,我们将在下一节中看到如何做到这一点。

组件之间的通信 – 输入和输出

在我们的健身房日记应用程序中,我们现在需要一个锻炼列表页面组件 DiaryComponent 与列表项组件 EntryItemComponent 进行通信。

实现这种通信的最简单方式是使用 Angular 的属性绑定概念。尽管名字听起来复杂,但实际上,我们通过在组件对象的属性上添加 @Input 注解来标注,这样 Angular 就会在组件上创建一个自定义的 HTML 属性。

让我们看看这个概念在实际中的应用;首先,让我们创建一个接口来表示我们日记中的一个条目:

ng g interface diary/interfaces/exercise-set

使用前面的命令,我们创建了文件,并且作为一个有组织的实践,我们创建了一个文件夹来存储模块的接口。在生成的文件中,我们将定义我们想要通信的对象:

export interface ExerciseSet {
  id?: string;
  date: Date;
  exercise: string;
  sets: number;
  reps: number;
}
export type ExerciseSetList = Array<ExerciseSet>;

我们创建了一个接口来定义对象,并定义了一个类型来定义一系列锻炼,这提高了我们实现的可读性。

现在,在 entry-item.component.ts 文件中,让我们添加新的属性:

import { Component, Input } from '@angular/core';
import { ExerciseSet } from '../interfaces/exercise-set';
@Component({
 selector: 'app-entry-item',
 templateUrl: './entry-item.component.html',
 styleUrls: ['./entry-item.component.css']
})
export class EntryItemComponent {
  @Input('exercise-set') exerciseSet!:ExerciseSet;
}

在这里,我们创建了一个名为 exerciseSet 的属性,其类型为 ExerciseSet,这是我们刚刚定义的。我们在类型定义中使用 ! 符号,因为我们将在运行时定义其值。

@Input 注解接收 exercise-set 字符串作为参数。有了这个参数,我们定义了在模板中使用的自定义 HTML 属性的名称。这个参数是可选的;如果不使用它,属性名称将与属性名称相同。在这里,它将是 exerciseSet

现在,让我们更改我们的模板以使用这个属性:

<div class="mb-4 border-b bg-white p-4">
  <span class="font-bold">Date:</span> {{ exerciseSet.date | date }}<br />
  <span class="font-bold">Exercise:</span> {{ exerciseSet.exercise }}<br />
  <span class="font-bold">Sets:</span> {{ exerciseSet.sets }}<br />
  <span class="font-bold">Reps:</span> {{ exerciseSet.reps }}
</div>

要在模板中使用组件的属性,我们使用 {{ }} 语法。在这里,我们可以看到启用 Angular 语言服务扩展的 VS Code 的一个优点,因为我们可以在 HTML 模板中进行类型检查,避免例如拼写错误等问题。

在这个例子中需要强调的是 Date 属性。在这里,我们使用了一个名为 pipe 的 Angular 功能,它允许格式化模板元素。在这种情况下,我们正在格式化日期。

现在,让我们在 diary.component.ts 文件中配置一个锻炼列表:

import { Component } from '@angular/core';
import { ExerciseSetList } from '../interfaces/exercise-set';
@Component({
 templateUrl: './diary.component.html',
 styleUrls: ['./diary.component.css'],
})
export class DiaryComponent {
  exerciseList: ExerciseSetList = [
    { id: '1', date: new Date(), exercise: 'Deadlift', reps: 15, sets: 3 },
    { id: '2', date: new Date(), exercise: 'Squat', reps: 15, sets: 3 },
    { id: '3', date: new Date(), exercise: 'Barbell row', reps: 15, sets: 3 },
 ];
}

对于这个例子,我们创建一个名为 exerciseListExample 的属性,并用来自 ExerciseSet 接口的对象填充它。现在,让我们在 diary.component.html 文件中更改列表模板:

. . .
  <section class="mb-8">
    <h2 class="mb-4 text-xl font-bold">List of entries</h2>
      <ul class="rounded border shadow">
        <li *ngFor="let item of exerciseList">
          <app-entry-item [exercise-set]="item" />
        </li>
      </ul>
  </section>
. . .

在模板中,我们使用 ngFor 指令,它具有遍历列表并渲染我们在模板中想要定义的元素的功能。对于每个列表项,我们将创建一个新的 app-entry-item 组件,现在我们想要将它分配给它。

要做到这一点,我们使用 [exercise-set] 属性来传递 ngFor 提供的项目。当我们运行我们的项目时,我们会看到以下图所示的列表:

图 4.2 – 重构后的健身房日记应用程序 UI

图 4.2 – 重构后的健身房日记应用程序 UI

通过这种方式,我们了解了如何从一个组件传递信息到另一个组件,但我们可以通过引入良好的性能实践,即 TrackBy 属性来改进这个项目。

最佳实践 – 使用 TrackBy 属性

*ngIf 指令之后,ngFor 指令很可能是你将在你的 Angular 项目中最常使用的指令。尽管简单,但这个指令可以隐藏前端可能发生的性能和感知问题,这些问题将影响你的用户。

为了演示这一点,让我们添加一个新的列表按钮,模拟来自后端的一个列表更新。

diary.component.ts 文件中,添加以下方法:

 newList() {
   this.exerciseList = [
     { id: '1', date: new Date(), exercise: 'Deadlift', reps: 15, sets: 3 },
     { id: '2', date: new Date(), exercise: 'Squat', reps: 15, sets: 3 },
     { id: '3', date: new Date(), exercise: 'Barbell row', reps: 15, sets: 3 },
     { id: '4', date: new Date(), exercise: 'Leg Press', reps: 15, sets: 3 },
   ];
 }

此方法用这个新数组替换数组,该数组包含相同的元素,但多了一个项目。

让我们在列表模板中添加按钮:

<br>
<br>
  <button
    class="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
    (click)="newList()"
   >
     Server Sync
  </button>

当我们点击 服务器同步 按钮时,整个项目列表都会被渲染,尽管新的列表与原始列表除了新增一个项目外完全相同。

图 4.3 – Chrome 开发者工具

图 4.3 – Chrome 开发者工具

对于少量项目,这可能不一定是问题,但对于较长的列表,这种不必要的渲染可能会冒犯用户对我们应用程序性能的感知。

为了改进这类情况,ngFor 指令有 TrackBy 选项。让我们重构我们的代码来展示这个选项;首先,让我们为练习列表组件创建一个方法:

 itemTrackBy(index: number, item: ExerciseSet) {
   return item.id;
 }

这种方法告诉 Angular 如何识别它将遍历 *ngFor 指令的集合中的单个元素。把它想象成集合的 主键

在组件的模板中,让我们更改 ngFor 配置:

<section class="mb-8">
  <h2 class="mb-4 text-xl font-bold">List of entries</h2>
  <ul class="rounded border shadow">
    <li *ngFor="let item of exerciseList; index as i; trackBy: itemTrackBy">
      <app-entry-item [exercise-set]="item" />
    </li>
  </ul>
</section>

在这里,我们告诉 ngFor 根据对象的 id 属性进行渲染。再次在浏览器中使用 Chrome DevTools 运行它,我们看到现在只有具有 id 属性的项目在页面上被渲染。

TrackBy 属性除了避免不必要的渲染外,还有以下优点:

  • 在从集合中添加和删除项目时启用动画

  • 当集合发生变化时,保留任何与 DOM 特定的 UI 状态,例如焦点和文本选择。

现在我们已经了解了ngFor属性的使用,让我们研究如何构建我们组件和页面的架构。

分离责任 – 智能和展示组件

单页应用(SPA)的信息流可能相当复杂,如果你在设计之初没有考虑这种流程,它可能会随着时间的推移影响你项目的生产力和质量。

简单为佳;因此,在 Angular 应用中以及一般单页应用中,一个非常常见的模式是使用智能组件和展示组件来组合接口。在文献和社区中,你也会找到这个模式被称为智能愚笨组件或容器展示组件。

智能组件具有 UI 业务规则;这是我们注入将与后端通信的服务的地方,以及与展示组件的接口将被组合的地方。

展示组件是一个仅用于展示智能组件通过输入传递的数据的组件。展示组件反过来可以包含一个或多个展示类型的组件。

为了说明这个模式,我们将使用以下图表:

图 4.4 – 智能和展示组件

图 4.4 – 智能和展示组件

注意,我们有一个真相来源,即智能组件,通信只在一个方向上发生,这就是我们所说的单向数据流。这个模式的目的是将组件内的所有状态隔离开来,从而简化状态管理。

让我们重构我们的项目以适应这个设计模式。让我们使用 Angular CLI 创建一个新的展示组件:

ng g c diary/list-entries

在这个新组件中,我们将把渲染日记条目列表的部分移动到模板中。在list-entries.component.html文件中,添加以下代码:

<section class="mb-8">
  <h2 class="mb-4 text-xl font-bold">List of entries</h2>
  <ul class="rounded border shadow">
    <li *ngFor="let item of exerciseList; index as i; trackBy: itemTrackBy">
      <app-entry-item [exercise-set]="item" />
    </li>
  </ul>
</section>

将要显示的列表将直接从DiaryComponent组件中准备好,因此,在list-entries.component.ts文件中,我们将添加以下代码:

import { Component, Input } from '@angular/core';
import { ExerciseSet, ExerciseSetList } from '../interfaces/exercise-set';
@Component({
 selector: 'app-list-entries',
 templateUrl: './list-entries.component.html',
 styleUrls: ['./list-entries.component.css'],
})
export class ListEntriesComponent {
  @Input() exerciseList!: ExerciseSetList;
  itemTrackBy(index: number, item: ExerciseSet) {
    return item.id;
 }
}

在这里,我们将itemTrackBy函数移动到组件中,因为这将是其显示列表的功能,并且我们包含带有@Input装饰器的exerciseList属性。在这个例子中,我们没有指定任何参数,所以模板属性的名称将与exerciseList类的属性名称相同。

让我们在diary.component.html文件中更改Diary模板以使用我们创建的新展示组件:

<main class="mx-auto mt-8 max-w-6xl px-4">
  <app-list-entries [exerciseList]="exerciseList" />
  <button
    class="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
  >
    Add new entry
  </button>
  <br />
  <br />
  <button
    class="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
    (click)="newList()"
  >
     erver Sync
  </button>
 </main>

DiaryComponent智能组件只是将列表传递给ListEntriesComponent展示组件,该组件通过调用EntryItemComponent展示组件来遍历列表。在这种结构下,只有DiaryComponent组件需要担心练习列表,遵循 SOLID 的单一职责概念。

我们已经学习了如何构建我们的页面和组件,但子组件如何与父组件通信呢?让我们接下来学习 Angular 组件的输出属性。

子组件的通信 - 使用@Output

我们学习了父组件,无论是智能组件还是展示组件,可以通过使用带有@Input装饰器的属性与子组件通信。

然而,当我们需要相反的情况时,子组件将一些信息传递给父组件。正如我们在上一节中看到的,业务规则处理理想情况下应该在智能组件中完成。对于这种类型的通信,我们使用@Output装饰器标记属性。

让我们为我们的日记创建一个添加条目的按钮。我们将在第六章中看到表单的使用,处理用户输入:表单,但在这里我们想要关注组件之间的交互。

使用 Angular CLI,我们将使用以下命令创建新组件:

ng g c diary/new-item-button

在新组件的模板中,让我们将日记按钮模板移动到组件中:

<button
  class="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
>
  Add new entry
</button>

new-item-button.component.ts文件中,我们将添加一个新的属性:

import { Component, EventEmitter, Output } from '@angular/core';
import { ExerciseSet } from '../interfaces/exercise-set';
@Component({
 selector: 'app-new-item-button',
 templateUrl: './new-item-button.component.html',
 styleUrls: ['./new-item-button.component.css'],
})
export class NewItemButtonComponent {
  @Output() newExerciseEvent = new EventEmitter<ExerciseSet>();
  addNewExercise() {
    const id = Date.now().toString();
    const date = new Date();
    const reps = 10;
    const sets = 4;
    const exercise = 'Leg Press';
    const newExerciseSet: ExerciseSet = { id, date, reps, sets, exercise };
    this.newExerciseEvent.emit(newExerciseSet);
  }
}

在这里,我们首先创建newExerciseEvent属性,并添加@Output装饰器来定义它将在组件的模板中作为一个属性存在。

在这里,与@Input属性有所不同;在这种情况下,我们已经在变量中分配了一个EventEmitter类的对象。这个 Angular 类旨在在发生某些操作时发出事件。

这是因为,与@Input不同,其值在组件结构化和渲染时分配,@Output通信可以在任何时间发生,取决于用户的操作。

EventEmitter类利用 TypeScript 的类型检查功能,使我们能够确定我们将要向父组件发出的对象类型。

addNewExercise方法中,我们创建一个ExerciseSet类型的对象,并使用EventEmitter类的emit方法将此对象传递给父组件。

回到模板 - 让我们在按钮的click动作中添加方法调用:

<button
  class="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
  (click)="addNewExercise()"
>
  Add new entry
</button>

现在,让我们重构DiaryComponent以使用新的按钮:

. . .
<main class="mx-auto mt-8 max-w-6xl px-4">
  <app-list-entries [exerciseList]="exerciseList" />
  <app-new-item-button (newExerciseEvent)="addExercise($event)" />
  <br />
  <br />
  <button
    class="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
    (click)="newList()"
  >
    Server Sync
  </button>
 </main>
. . .

在模板中,我们使用app-new-item-button组件将addExercise函数传递给newExerciseEvent属性。

在这里,我们可以强调,@Output属性的绑定必须使用括号——( )——并且这个$event参数代表子组件将要发出的对象。如果你在 VS Code 中突出显示此参数,我们可以验证它属于ExerciseSet类型。

最后,让我们在组件中创建addExercise方法:

. . .
addExercise(newSet: ExerciseSet) {
   this.exerciseList.push(newSet);
 }
. . .

我们的方法接收发出的值并将其添加到exercises数组中。运行我们的项目,我们可以看到项目已成功添加。

在这个例子中,我们可以看到设计模式中智能和展示组件的整个流程的实际应用。当点击Diary智能组件时,它会从NewItemButtonComponent展示组件接收新的练习。

通过更新列表,列表会自动传递到ListEntriesComponent组件,该组件在屏幕上渲染列表。现在我们将实现列表中练习项的动作——我们将看到如何发出这些项的事件以及如何识别这些元素。

从嵌套组件传播事件

我们将在日记中添加删除列表项和增加重复次数的选项。首先,让我们向列表项模板中添加按钮。在entry-item.component.html文件中,我们将编辑模板:

<div class="mb-4 flex items-center justify-between border-b bg-white p-4">
  <div>
    <span class="font-bold">Date:</span> {{ exerciseSet.date | date }}<br />
    <span class="font-bold">Exercise:</span> {{ exerciseSet.exercise }}<br />
    <span class="font-bold">Sets:</span> {{ exerciseSet.sets }}<br />
    <span class="font-bold">Reps:</span> {{ exerciseSet.reps }}
  </div>
  <div class="flex items-center">
    <button
      class="mr-2 rounded bg-red-500 py-2 px-4 font-bold text-white hover:bg-red-700"
    >
      Delete
    </button>
    <button
      class="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
    >
      New Rep
    </button>
  </div>
</div>

这里的挑战是确保正确识别列表中每个项目的动作,以便正确应用——也就是说,处理列表的Diary智能组件将找到相应的项目并更改它。

为了做到这一点,我们将应用 Angular 输出功能到项目组件:

 @Output() newRepEvent = new EventEmitter<ExerciseSet>();
 @Output() deleteEvent = new EventEmitter<string>();
 delete() {
   this.deleteEvent.emit(this.exerciseSet.id);
 }
 newRep() {
   const reps = ++this.exerciseSet.reps;
   const newItem: ExerciseSet = {
     ...this.exerciseSet,
     reps,
   };
   this.newRepEvent.emit(newItem);
 }

我们创建两个输出,每个输出对应我们想要发出的不同事件,并且我们输入它们,因为我们需要不同的动作。

然后我们创建delete方法,该方法将发出我们想要删除的项目id值,以及newRep方法,我们将使用它为将要执行的运动项目添加重复次数并发出该项目。

我们将回到模板,将方法与创建的按钮关联:

   <button
     class="mr-2 rounded bg-red-500 py-2 px-4 font-bold text-white hover:bg-red-700"
     (click)="delete()"
   >
     Delete
   </button>
   <button
     class="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
     (click)="newRep()"
   >
     New Rep
   </button>

现在,让我们更改list-entries.component展示组件以创建输出,这里为了简单起见,它将具有与项目输出相同的名称:

export class ListEntriesComponent {
 @Input() exerciseList!: ExerciseSetList;
 @Output() newRepEvent = new EventEmitter<ExerciseSet>();
 @Output() deleteEvent = new EventEmitter<string>();
. . .
}

为了传播项目事件,我们将更改列表模板:

  <li *ngFor="let item of exerciseList; index as i; trackBy: itemTrackBy">
    <app-entry-item
      [exercise-set]="item"
      (deleteEvent)="deleteEvent.emit($event)"
      (newRepEvent)="newRepEvent.emit($event)"
    />
  </li>

我们可以看到我们只使用输出的emit方法发出项目事件。

最后,我们将重构DiaryComponent智能组件以响应项目事件。首先,让我们看看模板:

<main class="mx-auto mt-8 max-w-6xl px-4">
  <app-list-entries
    [exerciseList]="exerciseList"
    (deleteEvent)="deleteItem($event)"
    (newRepEvent)="newRep($event)"
  />
 . . .
 </main>

如前例所示,我们使用括号将其与一个方法关联,该方法将处理事件并使用$event变量接收该方法参数发出的元素。

我们现在将通过创建两个新方法来重构组件——一个用于删除日记条目,另一个用于为练习创建新的重复项:

. . .
deleteItem(id: string) {
  this.exerciseList = this.exerciseList.filter((item) => item.id !== id);
}
 newRep(exerciseSet: ExerciseSet) {
   const id = exerciseSet.id;
   const i = this.exerciseList.findIndex((item) => item.id === id);
   if (i >= 0) {
     this.exerciseList[i] = { ...exerciseSet };
   }
 }
. . .

我们使用 TypeScript 数组方法来模拟删除和更改项目数组。我们可以看到,由于 Angular 的事件发射机制,该方法已经自动接收删除项或 id。

我们在这里利用智能和展示组件模式,以适应稍微复杂一些的需求。

摘要

在本章中,我们研究了负责渲染我们项目界面的元素,即组件。我们看到了如何以细粒度创建和组织组件,从而使我们的项目更具可维护性。

我们还研究了如何使用@Input@Output属性在组件之间进行通信,利用 Angular 提供的促进这种通信的能力。

我们看到了使用TrackBy在模板中通过ngFor指令迭代列表的良好实践,这特别提高了具有许多项目的列表的性能。

最后,我们研究了智能和展示组件的设计模式,这是一种组织组件及其交互的方式,以便通过单向信息流简化这种编排。

在下一章中,我们将研究 Angular 中负责业务规则和与后端交互的元素——服务。

第五章:Angular 服务和单例模式

静态网页和单页应用之间的一大区别是用户浏览器中的处理能力和交互,给人一种在设备上安装了应用程序的感觉。在 Angular 框架中,进行这种处理和交互的元素,不仅与后端,而且与用户,是服务

这个元素对 Angular 来说非常重要,以至于团队创建了一个依赖管理系统,它允许以简化的方式在组件中创建、组合和使用服务。

在本章中,我们将探讨这个元素,了解它使用的模式以及在你的项目中应遵循的最佳实践。

在这里,我们将涵盖以下主题:

  • 创建服务

  • 理解依赖注入模式

  • 使用服务在组件之间进行通信

  • 消费 REST API

到本章结束时,你将能够创建可重用和可维护的服务,同时了解将提高你生产力的实践。

技术要求

要遵循本章的说明,你需要以下内容:

本章的代码文件可在github.com/PacktPublishing/Angular-Design-Patterns-and-Best-Practices/tree/main/ch5找到。

创建服务

Angular 中的服务是 TypeScript 类,旨在实现我们接口的业务逻辑。在前端项目中,业务逻辑可能是一个有争议的问题,因为理想情况下,所有逻辑和处理都应该在后台进行,这是正确的。

这里我们使用的是业务规则;这些规则是通用的行为,不依赖于视觉组件,可以在其他组件中重用。

前端业务规则的例子可能如下所示:

  • 应用状态控制

  • 与后端的通信

  • 使用固定规则(如电话号码中的数字数量)进行信息验证

我们将把这个概念付诸实践,并在我们的健身房日记应用程序中创建第一个服务。在命令行中,我们将使用 Angular CLI:

ng generate service diary/services/ExerciseSets

与组件不同,我们可以看到 Angular CLI 创建的元素仅由一个 TypeScript 文件(及其相应的单元测试文件)组成。

在这个文件中,我们将看到 Angular CLI 生成的样板代码:

import { Injectable } from '@angular/core';
@Injectable({
  providedIn: 'root'
})
export class ExerciseSetsService {
  constructor() { }
}

在这里,我们有一个名为ExerciseSetsService的 TypeScript 类,它有一个名为@Injectable的装饰器。正是这个装饰器定义了 Angular 中的服务;我们将在本章后面了解更多关于它的细节。

让我们重构我们的项目,并将日记的初始系列设置放在这个服务中。

首先,我们将创建获取初始列表并在后端刷新它的方法:

private setList?: ExerciseSetList;
getInitialList(): ExerciseSetList {
  this.setList = [
    { id: 1, date: new Date(), exercise: 'Deadlift', reps: 15, sets: 3 },
    { id: 2, date: new Date(), exercise: 'Squat', reps: 15, sets: 3 },
    { id: 3, date: new Date(), exercise: 'Barbell row', reps: 15, sets: 3 },
  ];
  return this.setList;
}
refreshList(): ExerciseSetList {
  this.setList = [
    { id: 1, date: new Date(), exercise: 'Deadlift', reps: 15, sets: 3 },
    { id: 2, date: new Date(), exercise: 'Squat', reps: 15, sets: 3 },
    { id: 3, date: new Date(), exercise: 'Barbell row', reps: 15, sets: 3 },
    { id: 4, date: new Date(), exercise: 'Leg Press', reps: 15, sets: 3 },
  ];
  return this.setList;
}

在服务中,我们将日记组件的初始化和刷新操作移动到服务中,使用 getInitialListrefreshList 方法。

当我们看到与后端的通信时,这些方法将得到改进,但在这里,我们已经在将管理练习列表的业务规则从渲染用户界面的组件中解耦,创建了一个特定的服务。

现在让我们考虑向练习列表添加项的方法:

addNewItem(item: ExerciseSet): ExerciseSetList {
  if (this.setList) {
    this.setList = [...this.setList, item];
  } else {
    this.setList = [item];
  }
  return this.setList;
}

服务的 setList 属性可以是 null,因此在这里我们使用 TypeScript 类型守卫概念(更多详情见 第三章Angular 的 TypeScript 模式)来操作数组。在这里,我们也使用不可变性的概念,在添加新元素后返回一个新的数组。

DiaryComponent 组件中,我们将使用我们创建的服务:

export class DiaryComponent {
  constructor(private exerciseSetsService: ExerciseSetsService) {}
  exerciseList = this.exerciseSetsService.getInitialList();
  newList() {
    this.exerciseList = this.exerciseSetsService.refreshList();
  }
  addExercise(newSet: ExerciseSet) {
    this.exerciseList = this.exerciseSetsService.addNewItem(newSet);
  }
}

在组件中,我们首先可以观察到的是类构造函数的使用,声明了一个类型为 ExerciseSetsService 的私有属性 exerciseSetsService。通过这个声明,我们实例化了一个对象,并重构了我们的组件,用服务方法替换了列表的初始化和刷新操作。

从现在起,组件不再关心如何获取和管理练习列表;这是服务的责任,如果需要,我们现在可以在其他组件中使用这个服务。在这段代码中,你可能想知道为什么我们使用了 ExerciseSetsService 服务,如果我们没有实例化该类的对象。

这里,Angular 有一个很好的特性,即依赖注入机制,我们将在下一节深入探讨这个话题。

理解依赖注入模式

在面向对象的软件开发中,优先考虑组合而非继承是一个好的实践,这意味着一个类应该由其他类(最好是接口)组成。

在我们之前的例子中,我们可以看到 service 类包含了 DiaryComponent 组件。另一种使用此服务的方法如下:

. . .
export class DiaryComponent {
  private exerciseSetsService: ExerciseSetsService;
  exerciseList: ExerciseSetList;
  constructor() {
    this.exerciseSetsService = new ExerciseSetsService();
    this.exerciseList = this.exerciseSetsService.getInitialList();
  }
. . .
}

在这里,我们修改我们的代码,明确地将服务类对象的创建留在了组件的构造函数方法中。再次运行我们的代码,我们可以看到界面保持不变。

这种方法虽然功能齐全,但存在一些问题,例如以下内容:

  • 组件和服务之间的高耦合,这意味着如果我们需要更改服务的实现,例如构建单元测试,我们可能会遇到问题。

  • 如果服务依赖于另一个类,正如我们将要在 Angular 的 HTTP 请求服务 HttpClient 类中看到的那样,我们将在我们的组件中实现这个依赖,从而增加其复杂性。

为了简化开发并解决我们所描述的问题,Angular 有一个依赖注入机制。这个特性允许我们仅通过在构造函数中声明所需的对象来组合一个类。

Angular 利用 TypeScript,将使用在此声明中定义的类型来组装我们所需的类的依赖树,并创建所需的对象。

让我们回到我们的代码,分析这个机制是如何工作的:

. . .
export class DiaryComponent {
  constructor(private exerciseSetsService: ExerciseSetsService) {}
  exerciseList = this.exerciseSetsService.getInitialList();
. . .
}

在代码中,我们在构造函数中声明了我们类的依赖,创建了exerciseSetsService属性。有了这个,我们就可以在它的声明中初始化exerciseList属性。

第十章为测试而设计:最佳实践中,我们将替换测试运行时中此服务的实现。所有这一切都得益于 Angular 的依赖注入功能。

从 Angular 的 14 版开始,我们有了一个依赖注入的替代方案,我们将在下一节中看到。

使用 inject()函数

inject()函数允许你以更简单的方式使用相同的依赖注入功能。

让我们重构我们的组件代码:

import { Component, inject } from '@angular/core';
import { ExerciseSet } from '../interfaces/exercise-set';
import { ExerciseSetsService } from '../services/exercise-sets.service';
. . .
export class DiaryComponent {
  private exerciseSetsService = inject(ExerciseSetsService);
  exerciseList = this.exerciseSetsService.getInitialList();
. . .
}

在这里,我们移除了依赖注入的构造函数声明,并直接声明了exerciseSetsService服务。对于对象的创建,我们使用inject函数。

需要注意的是,我们使用的是@angular/core模块中的inject函数,而不是@angular/core/testing模块中存在的函数,后者将用于其他目的。

这种方法,除了更简单、更清晰(服务是通过函数注入的)之外,如果需要为特定组件使用继承,还可以简化开发。记住,良好的实践建议我们应优先选择组合而非继承,但在库中,这个特性可能很有趣。

关于inject函数的一个需要注意的点是其只能在组件的构造阶段使用,即在方法的属性声明或类的构造方法中。

在其他上下文中的任何使用都将生成以下编译错误:

inject() must be called from an injection context
such as a constructor, a factory function, a field initializer,
or a function used with `runInInjectionContext`.

现在,让我们深入探讨 Angular 服务的另一个方面,即单例设计模式的使用,以及我们如何利用这种能力在组件之间进行通信。

使用服务进行组件间的通信

关于 Angular 服务,我们必须理解的一个特点是,默认情况下,由依赖注入机制实例化的每个服务都有相同的引用;也就是说,不会创建新的对象,而是重用。

这是因为依赖注入机制实现了单例设计模式来创建和传递对象。单例模式是一种创建型设计模式,允许创建在系统中具有全局访问权限的对象。

这个特性对于服务很重要,因为服务处理可重用的业务规则,我们可以在组件之间使用相同的实例,而无需重建整个对象。此外,我们可以利用这个特性,将服务用作组件之间通信的替代方案。

让我们修改我们的健身房日记,使ListEntriesComponent组件通过服务而不是@Input接收初始列表:

export class ListEntriesComponent {
  private exerciseSetsService = inject(ExerciseSetsService);
  exerciseList = this.exerciseSetsService.getInitialList();
  itemTrackBy(index: number, item: ExerciseSet) {
    return item.id;
  }
}

DiaryComponent组件中,我们将从输入中删除列表:

<main class="mx-auto mt-8 max-w-6xl px-4">
  <app-list-entries />
  <app-new-item-button (newExerciseEvent)="addExercise($event)" />
  <br />
  <br />
  <button
    class="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
    (click)="newList()"
  >
    Server Sync
  </button>
</main>

再次运行它,我们可以看到列表继续出现。这是因为两个组件中使用的服务实例是相同的。然而,这种通信形式需要我们使用 RxJS 通过日记屏幕上的按钮来更新值。我们将在第九章中更深入地探讨这个主题,使用 RxJS 探索反应性

我们看到,默认情况下,服务是单例的,但在 Angular 中,如果需要解决应用程序中的某些边缘情况,可以更改此配置以用于其他服务。

当我们创建一个服务时,它有一个@Injectable装饰器,就像我们的例子一样:

@Injectable({
  providedIn: 'root',
})
export class ExerciseSetsService {

provideIn元数据决定了服务的范围。值'root'表示每个应用程序都将有一个唯一的服务实例;这就是为什么默认情况下,Angular 服务是单例的。

要更改此行为,让我们首先回到ListEntriesComponent组件以接收@Input

export class ListEntriesComponent {
  @Input() exerciseList!: ExerciseSetList;
  itemTrackBy(index: number, item: ExerciseSet) {
    return item.id;
  }
}

让我们回到DiaryComponent组件中通知属性:

<main class="mx-auto mt-8 max-w-6xl px-4">
  <app-list-entries [exerciseList]="exerciseList" />
  <app-new-item-button (newExerciseEvent)="addExercise($event)" />
  <br />
  <br />
  <button
    class="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
    (click)="newList()"
  >
    Server Sync
  </button>
</main>

ExerciseSetsService服务中,我们将删除provideIn元数据:

@Injectable()
export class ExerciseSetsService {

如果我们现在运行我们的应用程序,将发生以下错误:

ERROR Error: Uncaught (in promise): NullInjectorError: R3InjectorError(DiaryModule)[ExerciseSetsService -> ExerciseSetsService -> ExerciseSetsService -> ExerciseSetsService]: NullInjectorError: No provider for ExerciseSetsService!

这个错误发生在我们通知 Angular 服务不应该在应用程序范围内实例化时。为了解决这个问题,让我们直接在DiaryComponent组件中声明对服务的使用:

@Component({
  templateUrl: './diary.component.html',
  styleUrls: ['./diary.component.css'],
  providers: [ExerciseSetsService],
})
export class DiaryComponent {

因此,我们的系统再次工作,并且组件有自己的服务实例。

然而,这种技术必须在特定情况下使用,其中组件必须使用它自己的服务实例;建议在服务中保留provideIn

现在,让我们开始使用 Angular 探索我们的应用程序与后端之间的通信。

REST API 消费

毫无疑问,Angular 服务的主要用途之一是与应用程序的后端通信,使用表示状态传输REST)协议。

让我们通过准备我们的项目以使用其后端来实际了解这个功能。

首先,让我们通过访问gym-diary-backend文件夹并在您的命令行提示符中使用以下命令来本地上传后端:

npm start

我们可以保留这个命令运行,并现在可以创建用于消费 API 的服务。

为了执行这种消费,Angular 有一个专门的服务——HttpClient。要使用它,我们首先将其模块导入到app.module.ts文件中:

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppRoutingModule } from './app-routing.module';
import { HttpClientModule } from '@angular/common/http';
import { AppComponent } from './app.component';
@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, AppRoutingModule, HttpClientModule],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}

我们项目的后端 API 返回一些 JSON,包含当天的练习列表。作为良好的实践,我们应该创建一个界面来简化我们在前端应用程序中输入和操作结果。在exercise-set.ts文件中,我们将添加以下接口:

export interface ExerciseSetListAPI {
  hasNext: boolean;
  items: ExerciseSetList;
}

现在我们可以重构我们的ExerciseSetsService服务以使用HttpClient

export class ExerciseSetsService {
  private httpClient = inject(HttpClient);
  private url = 'http://localhost:3000/diary';
  getInitialList(): Observable<ExerciseSetListAPI> {
    return this.httpClient.get<ExerciseSetListAPI>(this.url);
  }
  refreshList(): Observable<ExerciseSetListAPI> {
    return this.httpClient.get<ExerciseSetListAPI>(this.url);
  }
}

首先,我们使用inject函数将HttpClient服务注入到我们的类中。然后我们创建url变量来包含该服务将用于其方法的端点。

最后,我们将getInitialListrefreshList方法重构为消费项目的 API。最初,它们有相同的实现,但我们将在整个书中改进这段代码。

进行了一个重要的更改,使得该方法不返回练习列表,而是一个包含练习列表的 Observable。这是因为涉及消费 REST API 的操作是异步的,通过使用 RxJS 及其 Observables,Angular 处理这种异步性。我们将在第九章中更深入地探讨这个主题,使用 RxJS 探索反应性

使用HttpClient服务消费GET 类型API,我们声明由ExerciseSetListAPI类型表示的返回类型和服务的get方法,将我们要消费的端点的 URL 作为参数传递。

现在我们添加其他方法以完善我们的服务:

addNewItem(item: ExerciseSet): Observable<ExerciseSet> {
  return this.httpClient.post<ExerciseSet>(this.url, item);
 }
updateItem(id: string, item: ExerciseSet): Observable<ExerciseSet> {
  return this.httpClient.put<ExerciseSet>(`${this.url}/${id}`, item);
  deleteItem(id: string): Observable<boolean> {
    return this.httpClient.delete<boolean>(`${this.url}/${id}`);
  }
}

对于包含一个新的集合,我们使用服务中的POST方法,该方法使用同名的动词调用 API。我们始终传递 URL,在这种情况下,请求正文将是新的练习集合。

要更新集合,我们使用带有正文的PUT方法,并使用字符串插值传递 API 合同中要求的id值。最后,为了删除,我们使用DELETE方法,并使用插值传递我们想要删除的元素的id值。

让我们调整我们的DiaryComponent组件以消费重构后的服务。我们的挑战是如何处理通过 HTTP 请求消费 REST API 的异步性。

首先,让我们调整练习列表的初始化:

@Component({
  templateUrl: './diary.component.html',
  styleUrls: ['./diary.component.css'],
})
export class DiaryComponent implements OnInit {
  private exerciseSetsService = inject(ExerciseSetsService);
  exerciseList!: ExerciseSetList;
  ngOnInit(): void {
    this.exerciseSetsService
      .getInitialList()
      .subscribe((dataApi) => (this.exerciseList = dataApi.items));
  }
}

DiaryComponent类中,我们将实现OnInit接口并创建onInit方法。这是 Angular 组件的生命周期事件之一,这意味着 Angular 将在构建和渲染界面时在某个时刻调用它。

onInit方法在构建组件后、渲染组件之前被调用。我们需要实现这个方法,因为练习列表的填充将异步发生。在onInit方法中实现这个初始化将确保当 Angular 开始渲染屏幕时数据已经存在。

在这个方法中,我们正在使用该服务,但由于它现在返回一个 Observable,我们需要调用 subscribe 方法,并在其中实现列表的初始化。由于我们正在使用智能和展示组件架构,我们可以在 DiaryComponent 智能组件中实现按钮方法如下:

newList() {
  this.exerciseSetsService
    .refreshList()
    .subscribe((dataApi) => (this.exerciseList = dataApi.items));
}
addExercise(newSet: ExerciseSet) {
  this.exerciseSetsService
    .addNewItem(newSet)
    .subscribe((_) => this.newList());
}
deleteItem(id: string) {
  this.exerciseSetsService.deleteItem(id).subscribe(() => {
    this.exerciseList = this.exerciseList.filter(
      (exerciseSet) => exerciseSet.id !== id
    );
  });
}
newRep(updateSet: ExerciseSet) {
  const id = updateSet.id ?? '';
  this.exerciseSetsService
    .updateItem(id, updateSet)
    .subscribe();
}

newList 方法中,我们将它重构为通过 refreshList 方法获取列表元素。

addExercisedeleteItemnewRep 方法中,我们将之前的逻辑重构为使用 exerciseSetsService 服务。

摘要

在本章中,我们学习了 Angular 服务以及如何以简单和可重用的方式从我们的应用程序中正确隔离业务规则,以及 Angular 服务如何使用单例模式进行内存和性能优化。

我们与 Angular 的依赖注入机制进行了合作并研究,注意到能够组织和重用组件和其他服务之间的服务是多么重要。我们还学习了如何使用 inject 函数作为 Angular 服务的替代,以通过 Angular 的构造函数进行依赖注入。

最后,我们与服务的其中一个主要用途——与后端通信——进行了合作,并在本章中,我们开始探索将我们的前端应用程序与后端集成的过程。

在下一章中,我们将研究使用表单的最佳实践,这是用户将信息输入到我们系统中的主要方式。

第二部分:利用 Angular 的功能

在本部分中,你将使用 Angular 的更高级功能,并了解你如何使用此框架的常见任务。你将了解表单的最佳实践,如何正确使用 Angular 的路由机制,以及最后如何使用拦截器设计模式和 RxJS 库优化 API 消费。

本部分包含以下章节:

  • 第六章**,处理用户输入:表单

  • 第七章**,路由和路由器

  • 第八章**,改进后端集成:拦截器模式

  • 第九章**,使用 RXJS 探索响应性

第六章:处理用户输入:表单

自从 Web 应用程序的早期以来,在<form>标签的概念被用来创建、组织和将表单发送到后端之前。

在常见的应用程序中,例如银行系统和健康应用程序,我们使用表单来组织用户需要在我们的系统中执行的操作。由于 Web 应用程序中这样一个常见的元素,Angular 这样的框架,其哲学是“内置电池”,自然为开发者提供了这一功能。

在本章中,我们将深入探讨 Angular 中的以下表单功能:

  • 模板驱动表单

  • 响应式表单

  • 数据验证

  • 自定义验证

  • 打字响应式表单

到本章结束时,您将能够为您的用户创建可维护且流畅的表单,同时通过此类任务提高您的生产力。

技术要求

要遵循本章中的说明,您需要以下内容:

本章的代码文件可在github.com/PacktPublishing/Angular-Design-Patterns-and-Best-Practices/tree/main/ch6找到。

在学习本章内容时,请记住使用npm start命令运行位于gym-diary-backend文件夹中的应用程序的后端。

模板驱动表单

Angular 有两种不同的方式处理表单:模板驱动响应式。首先,让我们探索模板驱动表单。正如其名所示,我们最大限度地利用 HTML 模板的能力来创建和管理与表单关联的数据模型。

我们将演进我们的健身日记应用程序,以更好地说明这一概念。在以下命令行中,我们使用 Angular CLI 创建新的页面组件:

ng g c diary/new-entry-form-template

要访问新的分配表单,我们将重构日记页面组件,使添加新条目按钮将用户带到我们创建的组件。

让我们在DiaryModule模块中添加对负责管理应用程序路由的框架模块的导入:

. . .
import { RouterModule } from '@angular/router';
@NgModule({
 declarations: [
   DiaryComponent,
   EntryItemComponent,
   ListEntriesComponent,
   NewItemButtonComponent,
   NewEntryFormTemplateComponent,
 ],
 imports: [CommonModule, DiaryRoutingModule, RouterModule],
})
export class DiaryModule {}

导入RouterModule模块后,我们将能够使用 Angular 的路由服务。有关路由的更多详细信息,请参阅第七章路由和路由器。我们将在DiaryRoutingModule模块中添加新组件到路由:

. . .
import { NewEntryFormTemplateComponent } from './new-entry-form-template/new-entry-form-template.component';
const routes: Routes = [
  {
    path: '',
    component: DiaryComponent,
  },
  {
    path: 'new-template',
    component: NewEntryFormTemplateComponent,
  },
];
@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule],
})
export class DiaryRoutingModule {}

为了能够比较两种表单创建方法,我们将为将要创建的每个示例组件创建一个路由。在这里,URL /home/new-template将引导我们到模板驱动表单路由。

现在,我们将重构DiaryComponent以修改添加新条目按钮的行为:

. . .
import { Router } from '@angular/router';
@Component({
  templateUrl: './diary.component.html',
  styleUrls: ['./diary.component.css'],
})
export class DiaryComponent implements OnInit {
  private exerciseSetsService = inject(ExerciseSetsService);
  private router = inject(Router)
. . .
  addExercise(newSet: ExerciseSet) {
    this.router.navigate(['/home/new-template'])
  }
. . .
}

首先,我们需要注入 Angular 的路由服务。我们将addExercise方法改为使用该服务,并使用navigate方法导航到页面。

我们可以继续在 new-entry-form-template.component.html 文件中的表单 HTML 模板,并仅放置表单的元素:

<div class="flex h-screen items-center justify-center bg-gray-200">
  <form class="mx-auto max-w-sm rounded bg-gray-200 p-4">
      . . .
      <input
        type="date"
        id="date"
         name="date"
     />
. . .
      <input
        type="text"
        id="exercise"
        name="exercise"
      />
. . .
      <input
        type="number"
        id="sets"
        name="sets"
      />
  </div>
  <input
    type="number"
    id="reps"
    name="reps"
  />
   </div>
   <div class="flex items-center justify-center">
     <button
       type="submit"
     >
     Add Entry
     </button>
...

Angular 使用 HTML 最佳实践,因此我们现在将在 HTML <form> 标签下创建表单字段。在输入字段中,我们尊重 HTML 语义,并创建与客户端所需信息类型正确的 <input> 字段。

让我们使用 ng serve 命令运行我们的应用程序。通过点击 新条目 按钮,我们将能够注意到我们的日记条目添加表单。

图 6.1 – 健身日记表单 UI

图 6.1 – 健身日记表单 UI

这里,我们有表单的结构和模板。现在,我们将准备让 Angular 通过模板中的用户输入来管理字段的状态。要使用模板驱动表单,我们需要将 FormModule 模块导入到我们的功能模块 DiaryModule 中:

import { FormsModule } from '@angular/forms';
@NgModule({
  declarations: [
    DiaryComponent,
    EntryItemComponent,
    ListEntriesComponent,
    NewItemButtonComponent,
    NewEntryFormTemplateComponent,
  ],
  imports: [CommonModule, DiaryRoutingModule, RouterModule, FormsModule],
})
export class DiaryModule {}

在我们的表单模板中,我们将添加创建和链接表单信息到其数据模型的指令:

. . .
<form
  (ngSubmit)="newEntry()"
  class="mx-auto max-w-sm rounded bg-gray-200 p-4">
    <div class="mb-4">
      . . .
      <input type="date" id="date" name="date"
      . . .
        [(ngModel)]="entry.date"
      />
    </div>
    <div class="mb-4">
      . . .
      <input type="text" id="exercise" name="exercise"
[(ngModel)]="entry.exercise"
      . . . />
    </div>
    <div class="mb-4">
. . .
      <input type="number" id="sets" name="sets"  [(ngModel)]="entry.sets"
. . ./>
    </div>
    <div class="mb-4">
. . .
      <input type="number" id="reps" name="reps" [(ngModel)]="entry.reps"
 . . ./>
. . .
</form>
</div>
ngSubmit parameter to state which method will be called by Angular when the user submits the form. Then, we link the HTML input elements with the data model that will represent the form. We do this through the [(ngModel)] directive.
`ngModel` is an object managed by the `FormModule` module that represents the form’s data model. The use of square brackets and parentheses signals to Angular that we are performing a two-way data binding on the property.
This means that the `ngModel` property will both receive the `form` property and emit events. Finally, for development and debugging purposes, we are placing the content of the entry object in the footer and formatting it with the JSON pipe.
Let’s finish the form by changing the component’s TypeScript file:

export class NewEntryFormTemplateComponent {

private exerciseSetsService = inject(ExerciseSetsService);

private router = inject(Router);

entry: ExerciseSet = { date: new Date(), exercise: '', reps: 0, sets: 0 };

newEntry() {

const newEntry = { ...this.entry };

this.exerciseSetsService

.addNewItem(newEntry)

.subscribe((entry) => this.router.navigate(['/home']));

}

}


 First, we inject the `ExerciseSetsService` service for the backend communication and the router service because we want to return to the diary as soon as the user creates a new entry.
Soon after we create the entry object that represents the form’s data model, it is important that we start it with an empty object because Angular makes the binding as soon as the form is loaded. Finally, we create the `newEntry` method, which will send the form data to the backend through the `ExerciseSetsService` service.
For more details about Angular services, see *Chapter 5*, *Angular Services and the Singleton Pattern*. If we run our project and fill in the data, we can see that we are back to the diary screen with the new entry in it.
Notice that at no point did we need to interact with the entry object, as Angular’s form template engine took care of that for us! This type of form can be used for simpler situations, but now we will see the way recommended by the Angular team to create all types of forms: reactive forms!
Reactive forms
Reactive forms use a declarative and explicit approach to creating and manipulating form data. Let’s put this concept into practice by creating a new form for our project.
First, on the command line, let’s use the Angular CLI to generate the new component:

ng g c diary/new-entry-form-reactive


 In the same way as we did with the template-driven form, let’s add this new component to the `DiaryRoutingModule` routing module:

import { NewEntryFormReactiveComponent } from './new-entry-form-reactive/new-entry-form-reactive.component';

const routes: Routes = [

{

path: '',

component: DiaryComponent,

},

{

path: 'new-template',

component: NewEntryFormTemplateComponent,

},

{

path: 'new-reactive',

component: NewEntryFormReactiveComponent,

},

];


 In the `DiaryModule` module, we need to add the `ReactiveFormsModule` module responsible for all the functionality that Angular makes available to us for this type of form:

@NgModule({

declarations: [

. . .

],

imports: [

. . .

ReactiveFormsModule,

],

})


 To finalize the component’s route, let’s change the main screen of our application, replacing the route that the **New Entry** button will call:

addExercise(newSet: ExerciseSet) {

this.router.navigate(['/home/new-reactive']);

}


 We will now start creating the reactive form. First, let’s configure the component elements in the `new-entry-form-reactive.component.ts` TypeScript file:

export class NewEntryFormReactiveComponent implements OnInit {

public entryForm!: FormGroup;

private formBuilder = inject(FormBuilder);

ngOnInit() {

this.entryForm = this.formBuilder.group({

date: [''],

exercise: [''],

sets: [''],

reps: [''],

});

}

}


 Note that the first attribute is `entryForm` of type `FormGroup`. It will represent our form—not just the data model, but the whole form—as validations, field structure, and so on.
Then, we inject the `FormBuilder` service responsible for assembling the `entryForm` object. Note the name of the service that Angular uses from the `Builder` design pattern, which has the objective of creating complex objects, such as a reactive form.
To initialize the `entryForm` attribute, we’ll use the `onInit` component lifecycle hook. Here, we’ll use the `group` method to define the form’s data model. This method receives the object, and each attribute receives an array that contains the characteristics of that attribute in the form. The first element of the array is the initial value of the attribute.
In the component’s template, we will create the structure of the form, which, in relation to the template-driven form example, is very similar:

[formGroup]="entryForm"

<input

type="date"

id="date"

name="date"

formControlName="date"

/>

<input

type="text"

id="exercise"

name="exercise"

formControlName="exercise"

/>

<input

type="number"

id="sets"

name="sets"

formControlName="sets"

/>

<input

type="number"

id="reps"

name="reps"

formControlName="reps"

/>

将 formGroup 属性与之前创建的对象关联。要将每个模板字段关联到 FormGroup 属性,我们使用 formControlName 元素。

为了调试数据模型,我们也在使用 JSON 管道,但请注意,为了获取用户填写的数据模型,我们使用 entryForm 对象的 value 属性。最后,我们将使用项目的 API 功能和记录输入来完善表单。

下一步是更改组件:

export class NewEntryFormReactiveComponent implements OnInit {
  . . .
  private exerciseSetsService = inject(ExerciseSetsService);
  private router = inject(Router);
  . . .
  newEntry() {
    const newEntry = { ...this.entryForm.value };
    this.exerciseSetsService
      .addNewItem(newEntry)
      .subscribe((entry) => this.router.navigate(['/home']));
  }
}

在这里,我们注入了 ExerciseSetsService API 的消费者服务和 Angular 路由服务路由。

newEntry 方法中,就像前面的例子一样,我们捕获用户输入的数据。然而,在响应式表单中,它位于 value 属性中,我们通过服务将此属性发送到 API。

运行项目后,我们可以看到界面工作得像为模板驱动表单编写的对应界面一样。

图 6.2 – 使用响应式表单的健身房日记表单 UI

图 6.2 – 使用响应式表单的健身房日记表单 UI

你可能想知道,使用响应式表单的优势是什么?为什么 Angular 社区和团队推荐使用它?接下来,我们将看到如何使用表单的内置验证以及如何将它们集成到我们的响应式表单中。

数据验证

一个好的用户体验实践是在用户离开填写字段时立即验证用户在表单中输入的信息。这可以最小化用户的挫败感,同时提高将发送到后端的信息。

使用响应式表单,我们可以使用 Angular 团队创建的实用类来添加在表单中常用到的验证。让我们改进我们的项目,首先在 NewEntryFormReactiveComponent 组件中:

. . .
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
. . .
export class NewEntryFormReactiveComponent implements OnInit {
. . .
 ngOnInit() {
   this.entryForm = this.formBuilder.group({
     date: ['', Validators.required],
     exercise: ['', Validators.required],
     sets: ['', [Validators.required, Validators.min(0)]],
     reps: ['', [Validators.required, Validators.min(0)]],
   });
 }
newEntry() {
   if (this.entryForm.valid) {
     const newEntry = { ...this.entryForm.value };
     this.exerciseSetsService
       .addNewItem(newEntry)
       .subscribe((entry) => this.router.navigate(['/home']));
   }
 }
}

在前面的例子中,我们正在从 Angular 导入 Validators 包,该包将为我们的报告的基本验证提供 utility 类。在创建响应式表单对象的 ngOnInit 方法中,验证位于定义表单字段的数组中的第二个位置。

我们在表单的所有字段中使用必填验证,并在 setsreps 字段中添加另一个验证以确保数字是正数。要添加多个验证,我们可以添加另一个包含验证的数组。

我们对组件所做的另一个更改是,现在它在开始与后端交互之前检查表单是否有效。我们通过检查对象的 valid 属性来完成此操作。Angular 会自动根据用户输入更新此字段。

在模板文件中,让我们为用户添加错误信息:

  <div
    *ngIf="entryForm.get('date')?.invalid && entryForm.get('date')?.touched"
    class="mt-1 text-red-500"
  >
    Date is required.
  </div>
  <div
    *ngIf="
      entryForm.get('exercise')?.invalid &&
      entryForm.get('exercise')?.touched
      "
    class="mt-1 text-red-500"
  >
    Exercise is required.
  </div>
   . . .
  <div
    *ngIf="entryForm.get('sets')?.invalid && entryForm.get('sets')?.touched"
    class="mt-1 text-red-500"
  >
    Sets is required and must be a positive number.
  </div>
  <div
    *ngIf="entryForm.get('reps')?.invalid && entryForm.get('reps')?.touched"
    class="mt-1 text-red-500"
  >
    Reps is required and must be a positive number.
  </div>
  <button
    type="submit"
    [disabled]="entryForm.invalid"
    [class.opacity-50]="entryForm.invalid"
  >
    Add Entry
  </button>

要在模板中显示验证,我们使用包含我们想要的消息的 div 元素。为了决定消息是否显示,我们使用 ngIf 指令,检查字段的状况。

为了做到这一点,我们首先使用 GET 方法获取字段并检查以下两个属性:

  • invalid 属性检查字段是否根据组件中配置的规则无效。

  • touched属性检查用户是否访问了字段。建议在界面加载时不要显示所有验证。

除了每个字段的验证之外,为了提高可用性,我们通过在表单无效时禁用提交按钮并应用 CSS 来使其对用户清晰可见。

运行项目,我们可以看到验证访问了所有字段,而没有任何字段被填写。

图 6.3 – 健身日记表单 UI 验证

图 6.3 – 健身日记表单 UI 验证

我们已经学习了如何使用 Angular 的实用类进行验证,所以让我们探索如何创建我们自己的自定义验证。

自定义验证

我们可以扩展验证的使用,并创建可以接收参数的自定义函数,以最大化在项目中的重用。为了说明这一点,让我们创建一个自定义验证来评估重复次数或组数是否分别是 2 和 3 的倍数。

让我们创建一个名为custom-validation.ts的新文件,并添加以下函数:

import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function multipleValidator(multiple: number): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const isNotMultiple = control.value % multiple !== 0;
    return isNotMultiple ? { isNotMultiple: { value: control.value } } : null;
  };
}

为了让 Angular 识别表单验证函数,它必须返回一个具有ValidatorFn接口中描述的签名的函数。这个签名定义了它将接收AbstractControl,并且必须返回一个类型为ValidationErrors的对象,允许模板解释新的验证类型。

在这里,我们使用control.value获取输入值,如果它不是 3 的倍数,我们将返回error对象。否则,我们将返回null,这将向 Angular 指示值是正确的。

要使用这个函数,我们将按照以下方式重构我们的表单组件:

. . .
ngOnInit() {
  this.entryForm = this.formBuilder.group({
    date: ['', Validators.required],
    exercise: ['', Validators.required],
    sets: [
      '',
      [Validators.required, Validators.min(0), multipleValidator(2)],
    ],
    reps: [
      '',
      [Validators.required, Validators.min(0), multipleValidator(3)],
    ],
  });
}
. . .

要使用我们的自定义函数,我们需要从新创建的文件中导入它,并在构建表单对象时将其用于验证数组中,就像标准 Angular 验证一样。

最后,让我们更改表单模板以添加错误信息:

. . .
    <div
      *ngIf="
        entryForm.get('sets')?.errors?.['isNotMultiple'] &&
        entryForm.get('sets')?.touched
      "
      class="mt-1 text-red-500"
    >
      sets is required and must be multiple of 2.
    </div>
. . .
    <div
      *ngIf="
        entryForm.get('reps')?.errors?.['isNotMultiple'] &&
        entryForm.get('reps')?.touched
      "
      class="mt-1 text-red-500"
    >
      Reps is required and must be multiple of 3.
    </div>
. . .

我们包括新的div元素,但为了特别验证输入的倍数错误,我们使用error属性,并在其中使用我们自定义函数的新isNotMultiple属性。

我们使用这个参数是因为它在运行时定义的,Angular 将在编译时警告它不存在。

运行我们的项目,我们可以看到新的验证:

图 6.4 – 健身日记表单 UI 自定义验证

图 6.4 – 健身日记表单 UI 自定义验证

除了验证之外,从 Angular 14 版本开始,响应式表单可以更好地进行类型化,以确保在项目开发中提高生产力和安全性。我们将在下一节中介绍这个功能。

类型化响应式表单

在我们的项目中,如果我们查看对象和值的类型,我们可以看到它们都是any类型。虽然功能性强,但通过更好地使用 TypeScript 的类型检查,我们可以改善这种开发体验。

让我们按照以下方式重构组件中的代码:

export class NewEntryFormReactiveComponent {
  private formBuilder = inject(FormBuilder);
  private exerciseSetsService = inject(ExerciseSetsService);
  private router = inject(Router);
  public entryForm = this.formBuilder.group({
    date: [new Date(), Validators.required],
    exercise: ['', Validators.required],
    sets: [0, [Validators.required, Validators.min(0), multipleValidator(2)]],
    reps: [0, [Validators.required, Validators.min(0), multipleValidator(3)]],
  });
  newEntry() {
    if (this.entryForm.valid) {
      const newEntry = { ...this.entryForm.value };
      this.exerciseSetsService
        .addNewItem(newEntry)
        .subscribe((entry) => this.router.navigate(['/home']));
    }
  }
}

我们将表单对象的创建移动到了组件的构造函数中,并使用 API 将接受的类型初始化字段。使用 Visual Studio Code 的 IntelliSense,我们可以看到 Angular 推断出类型,现在我们有一个非常接近 ExerciseSet 类型的对象。

然而,随着这个更改,addNewItem 方法抛出了一个错误,这实际上是个好事,因为它意味着我们现在正在使用 TypeScript 的类型检查来发现那些只能在运行时出现的潜在错误。为了解决这个问题,我们首先需要将服务修改为接收一个可以包含 ExerciseSet 的一些属性的对象。

在服务中更改 addNewItem 方法:

addNewItem(item: Partial<ExerciseSet>): Observable<ExerciseSet> {
  return this.httpClient.post<ExerciseSet>(this.url, item);
}

在这里,我们使用 TypeScript 的 Partial 类型来告知函数它可以接收一个包含部分接口属性的对象。回到我们的组件中,我们可以看到它仍然有一个错误。这是因为它可以接收表单属性中的 null 值。

为了解决这个问题,让我们将 FormBuilder 服务更改为 NonNullableFormBuilder 类型,如下所示:

export class NewEntryFormReactiveComponent {
. . .
  private formBuilder = inject(NonNullableFormBuilder);
. . .
}

通过这个更改,Angular 本身执行了这个验证。唯一的要求是所有表单字段都已初始化,这在我们这里已经完成了。

这样,我们的响应式表单就正常工作了,现在我们可以更有效地使用 TypeScript 的类型检查了!

摘要

在本章中,我们探讨了 Angular 表单以及如何使用它们来提升我们的用户体验和团队的生产力。我们学习了如何使用模板表单来满足更简单的需求,并探讨了 Angular 如何使用 ngModel 对象在 HTML 和数据模型之间执行绑定。

我们还使用响应式表单,这为创建和操作表单提供了许多可能性。关于响应式表单,我们研究了如何对字段应用验证以及如何创建我们自己的自定义验证函数。最后,我们重构了我们的响应式表单,使用带类型的表单来利用 TypeScript 类型检查。

在下一章中,我们将探讨 Angular 的路由机制以及它为我们的应用带来的可能性。


第七章:路由和路由器

一个 index.html 页面,并且从那里,所有 Web 应用程序的内容都使用 JavaScript 渲染。

然而,从用户的角度来看,他们正在与登录屏幕、主页和购买表单等不同界面(或页面)进行交互。技术上,它们都在 index.html 页面上渲染,但对于用户来说,它们是不同的体验。

负责这种客户端在单页应用(SPA)中与界面交互流程的机制是路由引擎。Angular 框架自带此功能,在本章中,我们将详细探讨它。

本章我们将涵盖以下主题:

  • 路由和导航

  • 定义错误页面和标题

  • 动态路由 – 通配符和参数

  • 保护路由 – 守卫

  • 优化体验 – 解析

到本章结束时,您将能够使用 Angular 的路由机制创建改进用户体验的导航流程。

技术要求

要遵循本章的说明,您需要以下内容:

本章的代码文件可在 github.com/PacktPublishing/Angular-Design-Patterns-and-Best-Practices/tree/main/ch7 找到。

在遵循本章内容时,请记得使用 npm start 命令运行位于 gym-diary-backend 文件夹中的应用程序的后端。

路由和导航

让我们通过创建一个具有简化菜单的用户界面主页来改进我们的项目,从而探索我们可以使用 Angular 路由实现的可能。在命令行中,我们将使用 Angular CLI 创建一个新的模块和组件页面:

ng g m home --routing

在前面的代码片段中,我们首先创建了一个新的模块,并通过使用 --routing 参数,我们指示 Angular CLI 创建模块及其路由文件。以下命令创建了我们在工作的组件:

ng g c home

更多关于 Angular CLI 和模块的详细信息,您可以参考 第二章组织 您的应用程序

首先,让我们在我们刚刚创建的组件的 HTML 文件中创建模板:

<div class="flex h-screen">
  <aside class="w-1/6 bg-blue-500 text-white">
    <nav class="mt-8">
      <ul class="flex flex-col items-center space-y-4">
        <li>
          <a class="flex items-center space-x-2 text-white">
            <span>Diary</span>
          </a>
        </li>
        <li>
          <a class="flex items-center space-x-2 text-white">
            <span>New Entry</span>
          </a>
        </li>
      </ul>
    </nav>
  </aside>
  <main class="flex-1 bg-gray-200 p-4">
    <router-outlet></router-outlet>
  </main>
</div>

在这个模板示例中,我们使用 <aside><main> HTML 元素来创建菜单和将要投影所选页面的区域。为此,我们使用 <router-outlet> 指令来指示 Angular 正确的区域。

要使主页成为主页面,我们需要修改我们的应用程序在 app-routing.module.ts 文件中的主要路由模块:

. . .
const routes: Routes = [
  { path: '', pathMatch: 'full', redirectTo: 'home' },
  {
    path: 'home',
    loadChildren: () =>
      import('./home/home.module').then((file) => file.HomeModule),
  },
];
. . .
export class AppRoutingModule {}

routes 数组是 Angular 路由机制的主要元素。我们在其中定义对象,这些对象对应于用户将能够访问的路由。在这个例子中,我们定义了应用程序的根路由("/")将通过 redirectTo 属性重定向用户到 home 路由。

在这里,我们应该使用 pathMatch 属性,并设置为 "full" 值。这是因为它决定了 Angular 路由引擎是否会匹配第一个与模式匹配的路由(默认行为,即 "prefix"),或者是否会匹配整个路由。

在第二个对象中,我们正在定义 home 路由并懒加载 Home 模块。有关懒加载的更多详细信息,您可以参考 第二章组织 您的应用程序

当运行我们的应用程序时,我们有菜单和显示我们的锻炼日记页面的区域。

要在主页上包含锻炼日记,我们需要修改 HomeRoutingModule 模块:

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomeComponent } from './home.component';
const routes: Routes = [
  {
    path: '',
    component: HomeComponent,
    children: [
      {
        path: 'diary',
        loadChildren: () =>
          import('../diary/diary.module').then((file) => file.DiaryModule),
      },
      {
        path: '',
        redirectTo: 'diary',
        pathMatch: 'full',
      },
    ],
  },
];
@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule],
})
export class HomeRoutingModule {}

在这个路由文件中,类似于上一个文件,我们定义了主路由将导向 HomeComponent 组件。然而,在这里,我们希望路由和模块在组件的 router outlet 中渲染,而不是 AppModule

在这里,children 属性发挥作用,我们将定义该模块的嵌套路由。由于我们想使用 DiaryComponent,我们正在对该模块进行懒加载。这遵循了 Angular 在应用程序中分离功能模块的最佳实践。

现在,当我们再次运行应用程序时,我们又有了日记页面。

图 7.1 – 带有日记的健身房日记主页

图 7.1 – 带有日记的健身房日记主页

为了结束这个环节,让我们在 Home 模板中添加新的锻炼条目链接。进行以下修改:

<li>
  <a routerLink="./diary" class="flex items-center space-x-2 text-white">
    <span>Diary</span>
  </a>
</li>
<li>
  <a routerLink="./diary/new-reactive" class="flex items-center space-x-2 text-white">
    <span>New Entry</span>
  </a>
</li>

我们正在使用 Angular 的 routerLink 指令在模板中创建链接,指定它应该导航到的 URL。

一个需要注意的重要细节是,我们正在使用项目的相对路径来创建链接,使用 ./。因为条目表单路由位于日记模块中,Angular 解释为该模块已经被加载,并允许链接,无需在 HomeRoutingModule 组件中声明额外的声明。

在下一节中,让我们探讨如何处理用户输入一个不存在的日期的场景。

定义错误页面和标题

在我们的当前项目中,如果用户输入一个没有映射路由的路径,他们将面临一个空白屏幕。这不是一个好的 用户体验UX)实践;理想情况下,我们需要通过显示错误页面来处理这个错误,以便将其重定向到正确的页面。

首先,让我们使用 Angular CLI 创建组件:

ng generate component ErrorPage

在这里,我们直接在 AppModule 中创建组件,因为我们想将这种处理应用于整个系统,而不是特定的功能模块。

让我们为这个组件创建一个带有错误信息的模板:

<div class="flex h-screen flex-col items-center justify-center">
  <h1 class="mb-4 text-6xl font-bold text-red-500">Oops!</h1>
  <h2 class="mb-2 text-3xl font-bold text-gray-800">Looks like you're lost!</h2>
  <p class="mb-6 text-gray-600">
    We couldn't find the page you're looking for.
  </p>
  <p class="text-gray-600">
    But don't worry! Go back to the Gym Diary and continue your progress!
  </p>
  <a
    routerLink="/home"
    class="mt-4 rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-600"
  >
    Go back to the Gym Diary
  </a>
</div>

注意,我们有一个链接到主页的号召性用语,让用户返回主页。

下一步是更新AppRoutingModule路由文件:

. . .
import { ErrorPageComponent } from './error-page/error-page.component';
const routes: Routes = [
  { path: '', pathMatch: 'full', redirectTo: 'home' },
  {
    path: 'home',
    loadChildren: () =>
      import('./home/home.module').then((file) => file.HomeModule),
  },
  { path: 'error', component: ErrorPageComponent },
  { path: '**', redirectTo: '/error' },
];
. . .

到这一点,Angular 将完成其工作。只需定义错误页面路由并在数组中创建另一个条目,我们就已经定义了'**'路径并将其重定向到错误路由。

当我们运行项目时,如果用户输入了错误的页面,将会显示以下信息:

图 7.2 - 错误路由错误页面

图 7.2 - 错误路由错误页面

我们还可以在我们的应用程序中改进的另一个点是浏览器标签页中的页面标题。

为了做到这一点,我们还可以再次使用 Angular 的路由机制。在DiaryRoutingModule中,我们需要更改以下代码片段:

. . .
const routes: Routes = [
  {
    path: '',
    component: DiaryComponent,
    title: 'Diary',
  },
  {
    path: 'new-template',
    component: NewEntryFormTemplateComponent,
  },
  {
    path: 'new-reactive',
    component: NewEntryFormReactiveComponent,
    title: 'Entry Form',
  },
];
. . .

要更改标题,我们只需在路由定义中通知title属性。另一种可能的方法(但更长)是使用 Angular 的Title服务。

让我们在NewEntryFormTemplateComponent组件中举例说明:

import { Title } from '@angular/platform-browser';
. . .
export class NewEntryFormTemplateComponent implements OnInit {
. . .
  private titleService = inject(Title);
. . .
  ngOnInit(): void {
    this.titleService.setTitle('Template Form');
  }
. . .
}

注入Title服务后,我们在OnInit生命周期钩子中使用它。虽然路由方法更简单、更直观,但如果标题可以动态更改,则可以使用Title服务。

我们将在下一节学习如何从一个路由传递信息到另一个路由。

动态路由 - 通配符和参数

我们希望更改新重复按钮的功能,使其不再是向条目添加重复,而是用户实际上可以编辑条目,打开填写了数据的表单。

首先,让我们向ExerciseSetsService服务中添加一个新方法:

export class ExerciseSetsService {
 . . .
  updateItem(id: string, item: Partial<ExerciseSet>): Observable<ExerciseSet> {
    return this.httpClient.put<ExerciseSet>(`${this.url}/${id}`, item);
  }
  getItem(id: string): Observable<ExerciseSet> {
    return this.httpClient.get<ExerciseSet>(`${this.url}/${id}`);
  }
}

除了通过获取特定项创建新方法外,我们还准备了update方法来接受ExerciseSet对象的Partial

编辑日记条目的表单将与添加新条目时的表单相同,不同之处在于它将被填写,并将调用update方法。因此,让我们重用NewEntryFormReactiveComponent组件来完成这项工作。

我们将首先编辑DiaryRoutingModule路由文件:

const routes: Routes = [
. . .
. . .
  {
    path: 'entry',
    component: NewEntryFormReactiveComponent,
    title: 'Entry Form',
  },
  {
    path: 'entry/:id',
    component: NewEntryFormReactiveComponent,
    title: 'Edit Entry',
  },
];

route数组中,我们将新表单的路由更改为entry并创建entry/:id路由。

此路由指向相同的组件,但请注意:id告诉 Angular 这是一个动态路由——也就是说,它将接收一个变量值,必须指向该路由。

随着这一变化,我们需要重构我们应用程序的一些部分。在HomeComponent菜单中,让我们调整应用程序路由:

<li>
  <a
    routerLink="./diary/entry"
    class="flex items-center space-x-2 text-white"
  >
    <span>New Entry</span>
  </a>
</li>

我们还需要调整日记和输入组件以调用新路由而不是增加重复次数。在EntryItemComponent组件中,我们将调整组件的方法和Output实例:

export class EntryItemComponent {
  @Input('exercise-set') exerciseSet!: ExerciseSet;
  @Output() editEvent = new EventEmitter<ExerciseSet>();
  @Output() deleteEvent = new EventEmitter<string>();
  delete() {
    this.deleteEvent.emit(this.exerciseSet.id);
  }
  editEntry() {
    this.editEvent.emit(this.exerciseSet);
  }
}

在这里,我们移除了处理并仅发出事件。在模板中,我们将调整 HTML 内容:

. . .
<button
  class="rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700"
  (click)="editEntry()"
>
  Edit
</button>
. . .

我们还将调整 ListEntriesComponent 组件以正确传播 editEvent

export class ListEntriesComponent {
  @Input() exerciseList!: ExerciseSetList;
  @Output() editEvent = new EventEmitter<ExerciseSet>();
  @Output() deleteEvent = new EventEmitter<string>();
. . .
}
<app-entry-item
  [exercise-set]="item"
  (deleteEvent)="deleteEvent.emit($event)"
  (editEvent)="editEvent.emit($event)"
/>

我们将对日记进行一些小的更改以反映新路由。我们首先在模板中这样做:

<app-list-entries
  [exerciseList]="exerciseList"
  (deleteEvent)="deleteItem($event)"
  (editEvent)="editEntry($event)"
/>

在组件中,我们将更改 newRep 方法,除了名称更改外,它还将重定向到新路由:

addExercise(newSet: ExerciseSet) {
  this.router.navigate(['/home/diary/entry']);
}
deleteItem(id: string) {
  this.exerciseSetsService.deleteItem(id).subscribe();
}
editEntry(updateSet: ExerciseSet) {
  const id = updateSet.id ?? '';
  this.router.navigate([`/home/diary/entry/${id}`]);
}

为了重定向到新路由,我们正在进行字符串插值以包含由列表项输出发出的 id。最后,让我们将注意力集中在表单上。在 NewEntryFormReactiveComponent 组件中,让我们调整模板中的 button 标签:

<button
  type="submit"
  [disabled]="entryForm.invalid"
  [class.opacity-50]="entryForm.invalid"
  class="rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700"
>
  Add Entry
</button>

NewEntryFormReactiveComponent 组件中,我们将对其进行调整,使其现在成为创建和编辑条目的表单:

. . .
export class NewEntryFormReactiveComponent implements OnInit {
. . .
  private route = inject(ActivatedRoute);
  private entryId?: string | null;
. . .
  ngOnInit(): void {
    this.entryId = this.route.snapshot.paramMap.get('id');
    if (this.entryId) {
      this.exerciseSetsService
        .getItem(this.entryId)
        .subscribe((entry) => this.updateForm(entry));
    }
  }
  updateForm(entry: ExerciseSet): void {
    let { id: _, ...entryForm } = entry;
    this.entryForm.setValue(entryForm);
  }
. . .
}

在示例中,我们使用 OnInit 生命周期钩子根据被调用的路由配置表单。为此,Angular 有一个名为 ActivatedRoute 的服务。

ngOnInit 方法中,我们捕获调用我们应用程序的路由参数,如果组件接收到 ID,它将从后端获取条目并根据返回值更新表单。

这里的一个细节是,我们正在使用解构赋值来从对象中移除 id 字段,因为它在表单的数据模型中不存在。

在相同的组件中,我们需要更改日记条目的记录:

newEntry() {
  if (this.entryForm.valid) {
    const newEntry = { ...this.entryForm.value };
    if (this.entryId) {
      this.exerciseSetsService
        .updateItem(this.entryId, newEntry)
        .subscribe((entry) => this.router.navigate(['/home']));
    } else {
      this.exerciseSetsService
        .addNewItem(newEntry)
        .subscribe((entry) => this.router.navigate(['/home']));
    }
  }
}

newEntry 方法中,如果组件通过路由接收到了对象的 id,它将表现为编辑并调用 exerciseSetsService 服务的相应方法。

当我们运行项目时,我们现在有了输入编辑表单。

图 7.3 – 健身日记编辑条目表单

图 7.3 – 健身日记编辑条目表单

从 Angular 的第 16 版开始,我们在路由参数的使用上有了改进。除了 ActivatedRoute 服务外,我们还可以直接将页面组件的输入映射到我们应用程序的路由变量中。

让我们重构我们的示例,首先更改主路由模块 AppRoutingModule

. . .
@NgModule({
  imports: [
    RouterModule.forRoot(routes, {
      bindToComponentInputs: true,
    }),
  ],
  exports: [RouterModule],
})
export class AppRoutingModule {}

要使用此资源,我们需要在应用程序路由的一般配置中添加 bindToComponentInputs 属性。

在我们的表单页面中,我们将进行如下重构:

export class NewEntryFormReactiveComponent implements OnInit {
  @Input('id') entryId?: string;
. . .
  ngOnInit(): void {
    if (this.entryId) {
      this.exerciseSetsService
        .getItem(this.entryId)
        .subscribe((entry) => this.updateForm(entry));
    }
  }
. . .
}

我们为 entryId 属性创建 Input 并定义路由的通配符变量将是 id。我们这样做是为了防止需要重构组件的其余部分,但我们也可以将属性名称更改为 id,如下例所示:

 @Input() id?: string;

这里重要的是 Angular 自动将来自路由的信息绑定到属性中,从而进一步简化通过 URL 将参数传递到组件的过程。

在下一节中,我们将通过学习路由守卫来了解如何保护路由免受错误访问。

保护路由 – 守卫

到目前为止,我们已经看到了如何通过路由获取数据来确定page组件的行为。然而,Angular 创建的路由非常灵活,还允许您通过基于业务规则的条件资源来塑造客户的旅程。

为了说明这个功能,我们将创建一个具有简化认证机制的登录屏幕。为了创建组件,我们将使用 Angular CLI。

在您操作系统的命令提示符下,使用以下命令:

ng g m login --routing
ng g c login
ng g s login/auth

第一个命令创建了一个带有routes文件的Login模块。第二个命令创建了login页面组件,最后,我们有了将管理后端认证交互的服务。

Login模块中,我们将配置新模块的依赖项:

. . .
@NgModule({
  declarations: [
    LoginComponent
  ],
  imports: [
    CommonModule,
    LoginRoutingModule,
    ReactiveFormsModule
  ]
})
export class LoginModule { }

接下来,让我们将新模块添加到AppRoutingModule

const routes: Routes = [
  { path: '', pathMatch: 'full', redirectTo: 'home' },
  {
    path: 'home',
    loadChildren: () =>
      import('./home/home.module').then((file) => file.HomeModule),
  },
  {
    path: 'login',
    loadChildren: () =>
      import('./login/login.module').then((file) => file.LoginModule),
  },
  { path: 'error', component: ErrorPageComponent },
  { path: '**', redirectTo: '/error' },
];

LoginRoutingModule模块中,我们将配置我们创建的组件:

const routes: Routes = [
  { path: '', component: LoginComponent },
];
@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule]
})
export class LoginRoutingModule { }

为了简化处理认证服务的请求和响应有效负载,让我们使用新的类型创建一个接口:

export interface LoginForm {
  username: string;
  password: string;
}
export interface Token {
  access_token: string;
}

LoginForm接口对应我们要发送的数据,而Token接口是 API 返回的,基本上是应用将发送给客户端的 JWT 访问令牌。

创建了接口后,让我们创建一个将协调与后端交互的服务:

export class AuthService {
  private httpClient = inject(HttpClient);
  private url = 'http://localhost:3000/auth/login';
  private token?: Token;
  login(loginForm: Partial<LoginForm>): Observable<Token> {
    return this.httpClient
      .post<Token>(this.url, loginForm)
      .pipe(tap((token) => (this.token = token)));
  }
  get isLogged() {
    return this.token ? true : false;
  }
  logout() {
    this.token = undefined;
  }
}

在这个服务中,我们使用HttpClient服务向后端发送请求(更多详情,请参阅第五章Angular 服务和单例模式)。我们使用 RxJS 的 tap 操作符,以便在请求成功后立即将令牌保存到service变量中。

正是通过这个变量,我们创建了isLogged属性,这对于控制路由非常重要。创建了服务后,我们可以开发Login页面模板:

<div class="flex justify-center items-center h-screen bg-blue-500">
  <div class="bg-blue-200 rounded shadow p-6">
    <h2 class="text-2xl font-bold text-gray-800 mb-6">Login</h2>
    <form class="space-y-4"
    [formGroup]="loginForm"
    (ngSubmit)="login()"
    >
    <div>
      <label for="username" class="text-gray-700">Username</label>
      <input type="text" id="username" class="block w-full rounded border-gray-300 p-2 focus:border-blue-500 focus:outline-none" formControlName="username">
    </div>
    <div>
      <label for="password" class="text-gray-700">Password</label>
      <input type="password" id="password" class="block w-full rounded border-gray-300 p-2 focus:border-blue-500 focus:outline-none" formControlName="password">
    </div>
    <div>
      <button
        type="submit"
        class="bg-blue-500 text-white rounded px-4 py-2 w-full"
        [disabled]="loginForm.invalid"
        [class.opacity-50]="loginForm.invalid"
        >Login</button>
    </div>
    </form>
  </div>
</div>

在创建Login页面时,一个重要点是正确使用 HTML input字段类型以正确处理 UX 和可访问性。

完成模板后,让我们开发组件:

export class LoginComponent {
  private formBuilder = inject(NonNullableFormBuilder);
  private loginService = inject(AuthService);
  private router = inject(Router);
  public loginForm = this.formBuilder.group({
    username: ['', [Validators.required]],
    password: ['', [Validators.required]],
  });
  login() {
    const loginValue = { ...this.loginForm.value };
    this.loginService.login(loginValue).subscribe({
      next: (_) => {
        this.router.navigate(['/home']);
      },
      error: (e) => alert('User not Found'),
    });
  }
}

在这个例子中,我们正在创建响应式表单,并在login方法中使用AuthService服务。运行项目后,在url /login,我们将有我们的登录屏幕。要使用该屏幕,我们有用户名mario和密码1234

图 7.4 – 登录页面

图 7.4 – 登录页面

要创建注销处理,我们将在HomeComponent组件菜单中创建一个链接,并在其中创建logout方法,将其重定向到登录页面:

<li>
  <a
    (click)="logout()"
    class="flex items-center space-x-2 text-white"
  >
    <span>Logout</span>
  </a>
</li>
export class HomeComponent {
  private authService = inject(AuthService);
  private router = inject(Router);
  logout() {
    this.authService.logout();
    this.router.navigate(['./login']);
  }
}

页面创建后,现在我们需要一种方式来确保只有用户登录时才能访问日记。对于这种类型的路由检查,我们应该使用 Angular 的路由****守卫功能。

要创建它,我们可以依靠 Angular CLI 的帮助;在命令行中,使用以下命令:

ng g guard login/auth

将会显示一个选择列表;选择CanActivate。在新文件中,让我们创建以下函数:

export const authGuard: CanActivateFn = (route, state) => {
  const authService = inject(AuthService);
  const router = inject(Router);
  if (authService.isLogged) {
    return true;
  } else {
    return router.parseUrl('/login');
  }
};

从版本 14 开始,创建路由守卫的推荐方式是通过函数而不是类。

我们正在创建一个具有CanActivateFn接口的authGuard函数,这是一个期望返回布尔值或UrlTree类对象的函数,用于将用户重定向到指定的路由。

在函数中,我们首先注入AuthServiceRouter服务;注意在这个上下文中inject函数是强制性的,因为在函数中我们没有构造函数来注入服务。

配置好服务后,我们做一个评估isLogged服务属性的if语句。如果用户已登录,我们返回true,允许导航到该路由。否则,我们返回一个包含登录页面路由的UrlTree类的对象。

要使用守卫,让我们改变DiaryRoutingModule

const routes: Routes = [
  {
    path: '',
    component: DiaryComponent,
    title: 'Diary',
    canActivate: [authGuard],
  },
  {
    path: 'new-template',
    component: NewEntryFormTemplateComponent,
  },
  {
    path: 'entry',
    component: NewEntryFormReactiveComponent,
    title: 'Entry Form',
  },
  {
    path: 'entry/:id',
    component: NewEntryFormReactiveComponent,
    title: 'Edit Entry',
  },
];

通过使用canActivate属性,我们可以传递一个或多个路由守卫。

运行应用程序后,我们可以看到我们被导向登录页面。但如果我们直接调用/home/diary/entry路由,我们会发现它并没有被保护。这是因为我们只在/diary路由上设置了guard

为了解决这个问题,我们可以在所有路由上设置canActivate属性,但更有效的方法是将路由的类型改为CanActivateChild

回到route函数,让我们改变它的类型:

export const authGuard: CanActivateChildFn = (route, state) => {
. . .
};

我们现在需要重构DiaryRoutingModule

const routes: Routes = [
  {
    path: '',
    children: [
      {
        path: '',
        component: DiaryComponent,
        title: 'Diary',
      },
      {
        path: 'new-template',
        component: NewEntryFormTemplateComponent,
      },
      {
        path: 'entry',
        component: NewEntryFormReactiveComponent,
        title: 'Entry Form',
      },
      {
        path: 'entry/:id',
        component: NewEntryFormReactiveComponent,
        title: 'Edit Entry',
      },
    ],
    canActivateChild: [authGuard],
  },
];

这里,我们使用了一个无组件的路由模式;基本上,我们创建了一个没有组件的路由,并将所有路由作为它的子路由。

然后,我们使用canActivateChild属性来调用路由的守卫,这样我们就不需要在这个模块中重复所有路由。

路由守卫功能可以为您的应用程序做更多的事情,而不仅仅是流程控制;我们可以提高它的感知性能,就像我们将在下一节中看到的那样。

优化体验 – 解析

性能是影响用户体验和满意度最大的变量之一;因此,最佳性能应该是网络开发者的一个持续目标。

感知感知是我们想要赢得的游戏,在 Angular 生态系统中我们有丰富的选择。我们可以在页面渲染之前加载页面所需的信息,为此我们将使用 Resolveroute 保存资源。

与我们之前研究的守卫不同,它的目的是返回由路由导向的页面所需的信息。

我们将使用 Angular CLI 创建这个守卫。在您的命令提示符中,使用以下命令:

ng g resolver diary/diary

在新创建的文件中,让我们改变 Angular CLI 生成的函数:

export const diaryResolver: ResolveFn<ExerciseSetListAPI> = (route, state) => {
  const exerciseSetsService = inject(ExerciseSetsService);
  return exerciseSetsService.getInitialList();
};

函数注入了ExerciseSetsService服务,并返回getInitialList方法返回的 Observable。

我们将使用这个新解析器配置DiaryRoutingModule

{
  path: '',
  component: DiaryComponent,
  title: 'Diary',
  resolve: { diaryApi: diaryResolver },
},

我们使用resolve属性,就像配置路由指南一样,不同之处在于我们将一个对象与函数关联起来,这对于组件消耗由它生成数据将非常重要。

DiaryComponent组件中,我们将对该组件进行重构,使其从解析器中获取数据,而不是直接从服务中获取信息:

. . .
private route = inject(ActivatedRoute);
. . .
  ngOnInit(): void {
    this.route.data.subscribe(({ diaryApi }) => {
      this.exerciseList = diaryApi.items;
    });
  }
. . .

组件现在正在消耗路由的data属性。它返回一个包含diaryApi属性的对象的可观察对象——这是我们之前在routes模块中配置的。

当我们再次运行我们的项目时,我们会看到屏幕的行为在外部没有改变;然而,在内部,我们在组件加载之前从健身房日记中获取信息。在我们这个例子中的这种变化可能不易察觉,但在一个更大、更复杂的应用中,这可能是你和你的团队所寻找的差异。

重要的是要记住,这不会加快对后端请求的速度。它将花费与之前一样的时间,但你的用户可能会感受到的性能可能会受到影响。

我们将对加载日记条目编辑页面进行相同的处理;在同一个resolve文件中,我们将创建一个新的函数:

export const entryResolver: ResolveFn<ExerciseSet> = (route, state) => {
  const entryId = route.paramMap.get('id')!;
  const exerciseSetsService = inject(ExerciseSetsService);
  return exerciseSetsService.getItem(entryId);
};

函数注入了服务,但这次我们使用route参数来提取条目的id以加载它。这个参数由 Angular 提供,以便你可以从你将配置解析器的路由中提取任何属性。

route模块中,我们将resolve函数添加到编辑路由:

{
  path: 'entry/:id',
  component: NewEntryFormReactiveComponent,
  title: 'Edit Entry',
  resolve: { entry: entryResolver },
},

现在,我们需要重构组件以使用路由守卫信息:

  private route = inject(ActivatedRoute);
. . .
  ngOnInit(): void {
    if (this.entryId) {
      this.route.data.subscribe(({ entry }) => {
        this.updateForm(entry);
      });
    }
  }

就像我们在日记页面中所做的那样,这里我们用路由的消耗来替换服务的消耗。

摘要

在本章中,我们与路由及其资源一起工作,以引导和组织我们应用中的用户流程。我们了解了 Angular 框架中的路由器概念,并为用户使用了不存在路由的情况创建了一个错误页面。我们通过重用表单创建了编辑日记条目页面,并利用动态路由功能学习了如何捕获页面设置所需的路由数据。

最后,我们了解了路由守卫功能,创建了简化的登录流程,并看到了如何通过在页面加载之前使用守卫解析功能来加载后端信息来优化用户体验。

在下一章中,我们将学习如何使用资源通过拦截器设计模式来简化我们对后端的请求。

第八章:改进后端集成:拦截器模式

在一个 Service 中。然而,许多辅助任务对所有与后端的通信都是通用的,例如头部处理、身份验证和加载。

我们可以按服务逐个执行这个辅助任务,但除了这是一个低效的活动外,团队可能由于新成员的疏忽或无知而无法对请求实施一些控制。

为了简化与后端通信的辅助任务开发,Angular 框架实现了拦截器设计模式,我们将在本章中探讨这一模式。在此,我们将涵盖以下主题:

  • 使用拦截器将令牌附加到请求

  • 更改请求路由

  • 创建一个加载器

  • 通知成功

  • 测量请求的性能

到本章结束时,你将能够创建能够隐式执行后端通信所需任务的拦截器。

技术要求

要遵循本章的说明,你需要以下内容:

本章的代码文件可在 github.com/PacktPublishing/Angular-Design-Patterns-and-Best-Practices/tree/main/ch8 找到。

在阅读本章时,请记住使用 npm start 命令运行位于 gym-diary-backend 文件夹中的应用程序的后端。

使用拦截器将令牌附加到请求

到目前为止,我们的后端没有任何形式的身份验证控制,这在现实世界中不会发生(或者至少不应该发生)。后端被修改以执行身份验证,但这也反映在前端,因为如果我们尝试登录,就会发生以下错误:

ERROR Error: Uncaught (in promise): HttpErrorResponse:
{"headers":{"normalizedNames":{},"lazyUpdate":null},"status":401,"statusText":"Unauthorized","url":"http://localhost:3000/diary","ok":false,"name":"HttpErrorResponse","message":"Http failure response for http://localhost:3000/diary: 401 Unauthorized","error":{"message":"Unauthorized","statusCode":401}}

这个错误意味着我们的请求被服务器拒绝,因为它没有得到授权。这是因为我们的服务器实现了一种非常常见的安全形式,即在每次请求中都要求提供授权令牌。

这个令牌是在用户登录应用程序时创建的,并且它必须在 HTTP 请求的头部传递。

我们首先通过更改 AuthService 服务来解决这个问题:

export class AuthService {
  private httpClient = inject(HttpClient);
  private url = 'http://localhost:3000/auth/login';
  #token?: Token;
  login(loginForm: Partial<LoginForm>): Observable<Token> {
    return this.httpClient
      .post<Token>(this.url, loginForm)
      .pipe(tap((token) => (this.#token = token)));
  }
  get isLogged() {
    return this.#token ? true : false;
  }
  logout() {
    this.#token = undefined;
  }
  get token() {
    return this.#token?.access_token;
  }
}

首先,我们更改 token 属性的访问模式。我们使用 # 符号,这是在标准 JavaScript 中声明 private 属性的方式。我们希望令牌只能被其他 component 读取,但永远不会被覆盖,使用令牌可以确保即使消费者类强制修改也能实现这一点。

我们将类名更改为新的属性名,并在最后创建一个 token() 访问器方法来返回服务存储的令牌。

我们将重构ExerciseSetsService服务,以便在返回日记条目的请求中发送令牌:

. . .
private authService = inject(AuthService);
private url = 'http://localhost:3000/diary';
getInitialList(): Observable<ExerciseSetListAPI> {
  const headers = new HttpHeaders({
    Authorization: `Bearer ${this.authService.token}`,
  });
  return this.httpClient.get<ExerciseSetListAPI>(this.url, { headers });
}
. . .

在这里,我们使用 Angular 的辅助类HttpHeaders创建一个头,通过Authorization属性传递令牌。然后,我们将此头传递给 Angular 的HttpClient服务的get方法。

当我们再次运行应用程序时,它再次工作(mario,和1234):

图 8.1 – 健身日记主页

图 8.1 – 健身日记主页

这种方法存在一个问题,因为我们需要为所有服务的方法复制此操作,并且随着应用程序的增长,我们需要记住执行此令牌处理。

一个好的软件架构应该考虑随着项目的增长,新团队成员的不同背景甚至新团队的创建。因此,我们系统的此类横向要求必须以更智能的方式处理。

进入Angular 拦截器,这是一个特定类型的服务,用于处理 HTTP 请求流程。该组件基于同名的设计模式,旨在改变处理周期。

让我们通过以下图表来说明此模式:

图 8.2 – 拦截器设计模式

图 8.2 – 拦截器设计模式

在此图表中,我们有发出 HTTP 请求到后端的 Angular 应用程序;在拦截器模式中,我们有一个位于请求中间的 Angular 服务,可以更改请求和后端的返回。

我们将重构我们的前一个解决方案,以看到此模式在实际中的应用。我们将通过从Authorization头中删除处理来清理ExerciseSetsService服务:

export class ExerciseSetsService {
  private httpClient = inject(HttpClient);
  private url = 'http://localhost:3000/diary';
  getInitialList(): Observable<ExerciseSetListAPI> {
    return this.httpClient.get<ExerciseSetListAPI>(this.url);
  }
 . . .
}

为了创建拦截器,我们将使用 Angular CLI 为 Angular 创建整个服务的模板:

ng g interceptor login/auth

创建了AuthInterceptor服务后,让我们创建我们的逻辑来附加Authorization头:

@Injectable()
export class AuthInterceptor implements HttpInterceptor {
  private authService = inject(AuthService);
  intercept(
    request: HttpRequest<unknown>,
    next: HttpHandler
  ): Observable<HttpEvent<unknown>> {
    const token = this.authService.token;
    if (request.url.includes('auth')) {
      return next.handle(request);
    }
    if (token) {
      const reqAuth = request.clone({
        headers: request.headers.set(`Authorization`, `Bearer ${token}`),
      });
      return next.handle(reqAuth);
    }
    return next.handle(request);
  }
}

我们首先可以注意到,拦截器是一个常见的 Angular 服务,因此它具有@Injectable注解;有关 Angular 服务的更多详细信息,请参阅第五章**, Angular 服务和单例模式

此服务实现了HttpInterceptor接口,要求类必须具有inject方法。此方法接收我们想要处理的请求,并期望返回一个可观察对象。此签名表明了拦截器的特征,因为此类始终位于发出请求的组件和后端之间的流程中间。

因此,服务从流程中接收信息,并必须返回由可观察对象表示的流程。在我们的案例中,我们使用AuthService服务来获取令牌。服务不能将令牌附加到登录端点,因为那里我们将获取令牌,所以我们通过分析请求使用的 URL 来创建一个if语句。

如果我们有令牌,我们克隆请求,但这次,我们使用令牌来设置头信息。我们需要使用clone方法来获取新对象的原因是请求对象是不可变的——也就是说,它不能被更改;我们需要创建一个新的,与旧的完全相同,但这次,我们添加了头信息。

最后,流程被返回,但这次,带有新的请求对象。为了配置拦截器,我们需要更改AppModule模块:

@NgModule({
  declarations: [AppComponent, ErrorPageComponent],
  imports: [BrowserModule, AppRoutingModule, HttpClientModule],
  providers: [
    { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
  ],
  bootstrap: [AppComponent],
})
export class AppModule {}

我们将AuthInterceptor服务包含在HTTP_INTERCEPTORS令牌中。这告诉框架每当组件使用 Angular 的HttpClient服务时调用该服务。multi属性通知框架我们可以有多个拦截器,因为默认情况下,Angular 只添加一个。

再次运行应用程序,我们可以看到它现在正在工作,新增的是所有资源都在附加头信息,但隐式地,不需要更改每个HttpClient调用。

让我们进一步探讨这个功能,通过我们项目中的一个非常常见的任务,即在 API 调用中的 URL 路由。

更改请求路由

在我们到目前为止的项目中,我们有两个服务会向后端发送请求。如果我们分析它们,我们会看到它们都直接指向后端 URL。这不是一个好的做法,因为随着项目的规模和复杂性的增长,指向错误的 URL 可能会导致错误。除了需要更改主机之外,我们还需要更改许多文件。

处理这个问题的方法有很多,但在这个问题中一个非常有用的工具是 Angular 拦截器。让我们从 Angular CLI 开始,我们将创建新的拦截器:

ng g interceptor shared/host

使用生成的文件,让我们创建intercept函数:

@Injectable()
export class HostInterceptor implements HttpInterceptor {
  intercept(
    request: HttpRequest<unknown>,
    next: HttpHandler
  ): Observable<HttpEvent<unknown>> {
    const url = 'http://localhost:3000';
    const resource = request.url;
    if (request.url.includes('http')) {
      return next.handle(request);
    }
    const urlsReq = request.clone({
      url: `${url}/${resource}`,
    });
    return next.handle(urlsReq);
  }
}

在这个函数中,我们有后端的 URL,在resource变量中,我们接收我们想要拦截和修改的原始请求 URL。我们使用if语句是因为我们想要避免错误,以防某些服务需要直接调用另一个 API。

最后,我们创建一个新的请求对象(这次,URL 已更改)并将这个新对象传递给请求流程。为了让这个拦截器被 Angular 触发,我们需要将其添加到AppModule模块的providers数组中:

@NgModule({
  declarations: [AppComponent, ErrorPageComponent],
  imports: [BrowserModule, AppRoutingModule, HttpClientModule],
  providers: [
    { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
    { provide: HTTP_INTERCEPTORS, useClass: HostInterceptor, multi: true },
  ],
  bootstrap: [AppComponent],
})
export class AppModule {}

我们将重构我们的服务,使其只关注它们需要的特性,从ExerciseSetsService服务开始:

export class ExerciseSetsService {
  private httpClient = inject(HttpClient);
  private url = 'diary';
 . . .
}

接下来,我们使用Authentication服务:

export class AuthService {
  private httpClient = inject(HttpClient);
  private url = 'auth/login';
. . .
}

我们可以看到,如果我们需要新的服务或更改 URL,HTTP 请求就不需要重构,因为我们创建了一个拦截器来处理这个问题。

接下来,我们将学习如何让用户在请求耗时过长时获得更好的体验。

创建一个加载器

在前端项目中,性能不仅关乎拥有更快的请求,还关乎提高用户对应用程序的感知。没有任何反馈信号的空白屏幕会向用户传达页面没有加载,他们的互联网有问题,或其他任何负面感知。

正因如此,我们始终需要向用户发出信号,表明他们期望的操作正在执行。展示这一点的其中一种方式是加载指示器,这正是我们在这个会话中将要做的。在我们的操作系统命令行中,我们将使用 Angular CLI:

 ng generate component loading-overlay
 ng generate service loading-overlay/load
 ng generate interceptor loading-overlay/load

通过这样,我们创建了overlay组件,控制加载状态的服务,以及根据 HTTP 请求控制加载开始和结束的拦截器。

让我们在LoadingOverlayComponent组件的 HTML 模板中创建加载覆盖屏幕:

<div class="fixed inset-0 flex items-center justify-center bg-gray-800 bg-opacity-75 z-50">
  <div class="text-white text-xl">
    Loading...
  </div>
</div>

我们将实现LoadService服务,它将维护和控制加载状态:

@Injectable({
  providedIn: 'root',
})
export class LoadService {
  #showLoader = false;
  showLoader() {
    this.#showLoader = true;
  }
  hideLoader() {
    this.#showLoader = false;
  }
  get isLoading() {
    return this.#showLoader;
  }
}

我们创建了两个方法来开启和关闭加载状态,以及一个属性来公开此状态。

在加载拦截器中,我们将实现以下功能:

@Injectable()
export class LoadInterceptor implements HttpInterceptor {
  private loadService = inject(LoadService);
  intercept(
    request: HttpRequest<unknown>,
    next: HttpHandler
  ): Observable<HttpEvent<unknown>> {
    if (request.headers.get('X-LOADING') === 'false') {
      return next.handle(request);
    }
    this.loadService.showLoader();
    return next
      .handle(request)
      .pipe(finalize(() => this.loadService.hideLoader()));
  }
}

intercept方法首先开启加载状态,并返回未修改的请求。

然而,在请求流程中,我们放置了 RxJs 的finalize操作符,它具有在可观察者到达完成状态时执行函数的特征 – 在这里,关闭加载状态。有关 RxJS 的更多详细信息,请参阅第九章使用 RxJS 探索反应性

要激活拦截器,我们将将其添加到AppModule

@NgModule({
  declarations: [AppComponent, ErrorPageComponent, LoadingOverlayComponent],
  imports: [BrowserModule, AppRoutingModule, HttpClientModule],
  providers: [
    { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
    { provide: HTTP_INTERCEPTORS, useClass: HostInterceptor, multi: true },
    { provide: HTTP_INTERCEPTORS, useClass: LoadInterceptor, multi: true },
  ],
  bootstrap: [AppComponent],
})
export class AppModule {}

我们希望覆盖层在整个应用程序中执行,因此我们将overlay组件包含在AppComponent组件中:

export class AppComponent {
  loadService = inject(LoadService);
  title = 'gym-diary';
}

我们只需要注入LoadService服务,因为那里我们将拥有加载状态。

最后,让我们将overlay组件放置在 HTML 模板中:

<app-loading-overlay *ngIf="loadService.isLoading"></app-loading-overlay>
<router-outlet></router-outlet>

运行我们的应用程序,因为我们是在机器上运行带有后端的程序,我们可能不会注意到加载屏幕。然而,对于这些情况,我们可以使用 Chrome 的一个功能来模拟慢速 3G 网络。

打开Chrome 开发者工具,在网络标签页中,使用如下所示的节流选项:

图 8.3 – 模拟慢速 3G 网络以注意加载屏幕

图 8.3 – 模拟慢速 3G 网络以注意加载屏幕

在下一节中,我们将学习如何通知用户后端请求的成功。

通知成功

除了通知用户系统正在寻找他们所需的信息的加载屏幕外,在处理完一个项目后通知用户也同样重要。我们可以直接从服务或组件中处理此通知,但也可以通过拦截器以通用和隐式的方式实现它。

我们将重构我们的应用程序以添加这种处理。但首先,让我们安装一个库,在屏幕上使用动画显示toaster组件。在我们的操作系统命令行中,我们将在前端项目的main文件夹中使用以下命令:

 npm install ngx-toastr

为了使包正常工作,我们需要通过编辑angular.json文件将我们的 CSS 添加到项目中:

. . .
  "build": {
    . . .
      "assets": [
      "src/favicon.ico",
      "src/assets"
      ],
      "styles": ["src/styles.css", "node_modules/ngx-toastr/toastr.css"],
    . . .
  },

为了使 toaster 动画工作,我们需要更改AppModule模块:

imports: [
  BrowserAnimationsModule,
  AppRoutingModule,
  HttpClientModule,
  ToastrModule.forRoot(),
],

在我们应用程序的main模块中,我们正在添加来自库的ToastrModule模块,并将BrowserModule更改为BrowserAnimationsModule,这为库添加了 Angular 动画服务。

配置了新包后,我们可以使用 Angular CLI 创建新的拦截器:

ng interceptor notification/notification

创建拦截器后,我们将更改通知的处理文件:

. . .
import { ToastrService } from 'ngx-toastr';
@Injectable()
export class NotificationInterceptor implements HttpInterceptor {
  private toaster = inject(ToastrService);
  intercept(
    request: HttpRequest<unknown>,
    next: HttpHandler
  ): Observable<HttpEvent<unknown>> {
    return next.handle(request).pipe(
      tap((event: HttpEvent<any>) => {
        if (event instanceof HttpResponse && event.status === 201) {
          this.toaster.success('Item Created!');
        }
      })
    );
  }
}

正如在创建加载器部分中,我们正在利用请求被视为流的事实,使用 RxJS 及其可观察对象来验证请求的特征。我们使用tap操作符,该操作符旨在对请求执行副作用而不改变它。

此操作符将执行一个匿名函数,该函数将检查 HTTP 事件,这带我们到一个有趣的观点。由于我们对请求的返回感兴趣,我们只选择类型为HttpResponse的事件,事件代码为201-Created

在开发拦截器时,我们必须记住它在请求和响应时被调用,因此使用条件执行我们需要的操作是很重要的。

我们需要配置的最后一点是主要的AppModule模块:

 providers: [
. . .
   {
     provide: HTTP_INTERCEPTORS,
     useClass: NotificationInterceptor,
     multi: true,
   },
. . .
 ]

运行我们的项目并创建一个条目,我们注意到配置的消息在屏幕上显示为 toast。

图 8.4 – 成功通知

图 8.4 – 成功通知

拦截器的一个用途是测量我们的应用程序的性能和稳定性,我们将在下一节中了解。

测量请求的性能

作为一支开发团队,我们必须始终寻求为用户提供最佳体验,除了开发高质量的产品外,我们还必须允许应用程序在生产过程中被监控以维持质量。

市面上有几种工具可供选择,其中许多需要一定程度的仪器来准确测量用户体验。我们将开发一个更简单的遥测示例,但它可以应用于你们团队使用的监控工具。

使用 Angular CLI,我们将创建一个新的拦截器:

ng g interceptor telemetry/telemetry

在由 Angular CLI 生成的文件中,我们将开发我们的拦截器:

@Injectable()
export class TelemetryInterceptor implements HttpInterceptor {
  intercept(
    request: HttpRequest<unknown>,
    next: HttpHandler
  ): Observable<HttpEvent<unknown>> {
    if (request.headers.get('X-TELEMETRY') !== 'true') {
      return next.handle(request);
    }
    const started = Date.now();
    return next.handle(request).pipe(
      finalize(() => {
        const elapsed = Date.now() - started;
        const message = `${request.method} "${request.urlWithParams}" in ${elapsed} ms.`;
        console.log(message);
      })
    );
  }
}

为了说明自定义拦截器的能力,我们同意只有当请求带有名为X-TELEMETRY的自定义头时,才会使用遥测,并在函数的开始处进行此验证。

就像在加载器示例中做的那样,我们使用了finalize运算符以简化的方式测量请求的性能,并在console.log中展示。你可以在这里放置你的遥测提供者调用,甚至你的自定义后端。

为了举例说明,我们使用console.log来展示信息。就像在其他部分一样,我们需要在主AppModule模块中配置拦截器:

. . .
providers: [
. . .
   {
     provide: HTTP_INTERCEPTORS,
     useClass: TelemetryInterceptor,
     multi: true,
   },
 ],
. . .

最后,在ExerciseSetsService服务中,我们将发送定制的头以执行此请求的遥测:

. . .
getInitialList(): Observable<ExerciseSetListAPI> {
  const headers = new HttpHeaders().set('X-TELEMETRY', 'true');
  return this.httpClient.get<ExerciseSetListAPI>(this.url, { headers });
}
. . .

头部传递是配置拦截器以根据不同情况表现不同的方式。

运行我们的项目,我们可以在浏览器日志中看到消息:

GET "http://localhost:3000/diary" in 5 ms. telemetry.interceptor.ts:25:16

通过这次开发,配置了头的 HTTP 请求将在console.log中记录。你可以用集成到遥测服务的拦截器替换这个拦截器,从而提高你应用程序的监控能力。

摘要

在本章中,我们探讨了 Angular 中的拦截器功能以及这个功能可以为我们的团队带来的可能性。我们学习了如何在不改变我们项目中所有服务的情况下将身份验证令牌附加到请求中。我们还致力于更改请求的 URL,使我们的项目对执行环境更加灵活。

我们还通过创建一个加载器来改善用户的体验,以防他们的网络速度慢,并在他们的健身房日记中注册新条目时在屏幕上通知他们。最后,我们使用自定义头创建了一个简单的遥测示例,以便团队能够选择哪些请求具有遥测能力。

在下一章中,我们将探索 RxJS,这是 Angular 工具包中最强大的库。

第九章:使用 RxJS 探索反应性

在一个网络应用程序中,最具挑战性的任务之一是处理网络的异步性。应用程序无法预测诸如对后端请求、更改路由和简单的用户交互等事件何时会发生。在这些情况下,命令式编程更复杂且容易出错。

构成 Angular 生态系统的 RxJS 库旨在通过声明性和响应式编程使控制异步流程变得更加简单。

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

  • 可观察对象和操作符

  • 处理数据 – 转换操作符

  • 另一种订阅方式 – 异步管道

  • 连接信息流 – 高阶操作符

  • 优化数据消费 – 过滤操作符

  • 如何选择正确的操作符

到本章结束时,你将通过将它们与后端请求集成来为你的用户提供更好的体验。

技术要求

要遵循本章的说明,你需要以下内容:

本章的代码文件可在以下位置找到。

在本章中,请记住使用 npm start 命令运行位于 gym-diary-backend 文件夹中的应用程序的后端。

可观察对象和操作符

到目前为止,我们使用可观察对象作为通过 subscribe 方法捕获来自后端 API 的数据的方式,但让我们退一步,问一下什么是可观察对象,以及为什么我们不直接使用 JavaScript 承诺。

让我们使用表格来组织我们的解释:

同步 函数 Iterator
异步 Promise Observable

表 9.1 – 根据需求的对象类型

当我们需要执行同步处理并期望返回值时,我们使用函数。如果我们需要一个同步值的集合,我们使用 Iterator 类型的对象。当我们需要函数的返回值但其处理是异步的时候,我们使用承诺。

但对于异步处理,我们能够使用什么来不返回一个值,而是一个可以随时间作为事件分发的值集合呢?对这个需求的答案是 可观察对象!使用这种数据结构,我们可以捕获一系列事件并在声明性上使我们的应用程序对这些事件做出反应。

关于使用承诺进行 HTTP 请求的使用,我们可以使用它们,但使用承诺执行冗长且复杂的任务可以改用可观察对象和 RxJS 来完成。我们可以这样说,承诺能做的,可观察对象也能做,反之亦然,这会变得复杂。

在 Angular 中,大多数异步事件都是通过可观察对象映射和控制的。除了 HTTP 请求、用户输入、应用程序之间的路由交换,甚至组件的生命周期都是通过可观察对象控制的,因为它们是随时间发生的事件。

我们可以将这些事件视为信息流,RxJS 和可观察的概念可以操纵这些流,并使我们的应用程序能够对其做出反应。操纵此流的主要资源是 RxJS 操作符,这些是接收并返回数据到该流的函数。

在下一节中,我们将从将转换流数据的操作符开始。

处理数据 – 转换操作符

在我们的DiaryComponent应用程序组件中,它渲染日记条目的列表,我们可以注意到我们的组件需要知道从 API 返回的值的详细信息,在这种情况下,详细信息是在一个名为item的属性中返回的。

让我们重构服务,使其仅返回组件需要的已格式化的内容,抽象 API 的结构。

ExerciseSetsService服务中,我们将重构以下方法:

import { Observable, map } from 'rxjs';
. . .
export class ExerciseSetsService {
. . .
  getInitialList(): Observable<ExerciseSetList> {
    const headers = new HttpHeaders().set('X-TELEMETRY', 'true');
    return this.httpClient
      .get<ExerciseSetListAPI>(this.url, { headers })
      .pipe(map((api) => api?.items));
  }
  refreshList(): Observable<ExerciseSetList> {
    return this.httpClient
      .get<ExerciseSetListAPI>(this.url)
      .pipe(map((api) => api?.items));
  }
. . .
}

在服务的getInitialListrefreshList方法中,我们调用Observable对象的pipe方法。这是理解 RxJS 的基本方法,因为通过它,我们可以定义哪些操作符将在可观察对象封装的信息流中起作用。

pipe方法也返回一个可观察对象,当组件调用subscribe方法时,其结果将通过所有操作符并传递结果。针对我们的需求,我们使用map操作符,它接收可观察对象正在处理的数据,并返回将被next操作符或最终由订阅该组件使用的其他数据。

在这种情况下,操作符接收一个ExerciseSetListAPI类型的对象,我们将返回其中包含的项元素到组件,该组件是ExerciseSetList类型。通过这个更改,VS Code 以及 Angular 的 Language Server(有关如何配置的更多详细信息,请参阅第一章正确开始项目)将在diary.resolver.ts文件中指出错误。我们将按以下方式更正它:

export const diaryResolver: ResolveFn<ExerciseSetList> = (route, state) => {
  const exerciseSetsService = inject(ExerciseSetsService);
  return exerciseSetsService.getInitialList();
};

由于服务现在返回的是期刊条目而不是整个 API 返回的结构,我们更改了函数返回的类型。请注意,RxJS 使用 TypeScript 来提高开发者的体验。

DiaryRoutingModule模块中,让我们重构我们修复的解析器的使用:

const routes: Routes = [
  {
    path: '',
    children: [
      {
        path: '',
        component: DiaryComponent,
        title: 'Diary',
        resolve: { exerciseList: diaryResolver },
      },
. . .
  },
];

重要的是尽可能清晰地命名项目变量;在这种情况下,我们将route属性更改为exerciseList。为了完成这个任务,我们需要重构DiaryComponent组件:

export class DiaryComponent implements OnInit {
 . . .
 ngOnInit(): void {
   this.route.data.subscribe(({ exerciseList }) => {
     this.exerciseList = exerciseList;
   });
 }
 newList() {
   this.exerciseSetsService
     .refreshList()
     .subscribe((exerciseList) => (this.exerciseList = exerciseList));
 }
. . .
}

使用服务中的 map 操作符,现在,在组件中,我们只传递练习列表,因此组件不需要知道实现和 API 的细节。

在下一节中,我们将看到另一种订阅方式。

另一种订阅方式 – 异步管道

为了展示 RxJS 在 Angular 应用中的多功能性,我们将执行在后台添加搜索练习的任务到日记条目包含表单中。

按照 Angular 应用的良好实践,我们将创建一个表示练习的接口。从操作系统的命令行,我们将使用 Angular CLI:

ng g interface diary/interfaces/exercise

在由 Angular CLI 生成的文件中,我们定义了 API 返回的结构:

export interface Exercise {
  id?: string;
  description: string;
}
export type ExerciseList = Array<Exercise>;
export interface ExerciseListAPI {
  hasNext: boolean;
  items: ExerciseList;
}

我们使用接口来定义 API 的返回值,并使用类型来定义练习列表。下一步是创建一个将获取这些信息的服务,再次使用 Angular CLI:

ng g service diary/services/exercises

使用由 Angular CLI 创建的服务结构,我们将完成服务的逻辑:

export class ExercisesService {
  private httpClient = inject(HttpClient);
  private url = 'exercises';
  getExercises(filter?: string): Observable<ExerciseList> {
    const headers = new HttpHeaders().set('X-LOADING', 'false');
    filter = filter ? `?filter=${filter}` : '';
    return this.httpClient
      .get<ExerciseListAPI>(`${this.url}${filter}`, { headers })
      .pipe(map((api) => api?.items));
  }
}

在服务中,我们使用 HttpClient Angular 服务,因为我们将要查询一个 API,并且我们在请求中添加了 X-LOADING 头部,值为 false,因为我们在这里不希望加载屏幕搜索练习。

如果组件传递了一个过滤器,我们将添加 get URL。最后,我们使用之前章节中看到的 map 操作符,因为我们不希望组件担心了解 API 的结构。

服务创建后,我们可以更改 NewEntryFormReactiveComponent 表单:

export class NewEntryFormReactiveComponent implements OnInit {
. . .
  private exerciseService = inject(ExercisesService);
  public showSuggestions: boolean = false;
  public exercises$ = this.exerciseService.getExercises();
  selectExercise(suggestion: string) {
    this.entryForm.get('exercise')?.setValue(suggestion);
    this.toggleSuggestions(false);
  }
  toggleSuggestions(turnOn: boolean) {
    this.showSuggestions = turnOn;
  }
}

在这里,我们首先注入我们创建的服务,并创建一个属性来控制是否显示练习列表。

exercises$ 属性将包含服务将返回的可观察对象。你可能已经注意到这里的 $ 符号。在变量和属性中使用这个后缀是社区惯例。这不是强制性的,但你经常会在使用 RxJS 的代码库中看到这个符号。

我们还创建了两个方法,当用户从列表中选择一个练习时将被触发。让我们更改表单模板:

. . .
    <input
      type="text"
      id="exercise"
      name="exercise"
      class="w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow"
      formControlName="exercise"
      (focus)="toggleSuggestions(true)"
    />
    <ul
      class="absolute z-10 mt-2 w-auto rounded border border-gray-300 bg-white"
      *ngIf="showSuggestions"
    >
      <li
        *ngFor="let suggestion of exercises$ | async"
        class="cursor-pointer px-3 py-2 hover:bg-blue-500 hover:text-white"
        (click)="selectExercise(suggestion.description)"
      >
        {{ suggestion.description }}
      </li>
    </ul>
. . .

exercise 字段中,我们添加了一个由 ul HTML 元素组成的列表,并且这个列表将通过 showSuggestions 属性来展示。字段的焦点事件将触发这个变量,点击元素将调用 selectExercise 方法。

这段代码的注意力将集中在以下指令上:

*ngFor="let suggestion of exercises$ | async"

使用 *ngFor 指令,我们想要遍历一个列表,但在这里,我们没有列表,而是一个可观察对象。这是怎么做到的?

这是异步管道的责任!这个管道在模板中执行的操作是在可观察对象上执行订阅,获取其结果,即练习列表,并将 *ngFor 指令提供给迭代。

注意,我们之所以得到了这样简洁的代码,是因为在服务中,我们使用map操作符来准备返回的观察者,使其正好满足组件的需求。异步管道提供的另一个优点是框架控制了观察者的生命周期;也就是说,当组件被销毁时,Angular 会自动触发unsubscribe方法。

我们在本书中尚未进行这种处理,因为 HTTP 请求生成的观察者在请求完成后不会打开,但在这里我们将使用观察者来处理其他可能使观察者流仍然打开的情况。

控制我们使用的观察者的生命周期非常重要;否则,我们可能会生成由内存泄漏引起的错误和性能下降。使用异步管道,这种订阅管理是由 Angular 本身完成的!

在下一节中,我们将使用 RxJS 和异步管道连接不同的流。

连接信息流 - 高阶操作符

正如我们在本章开头所看到的,除了 HTTP 请求之外,还有许多用途可以使用观察者。在我们的任务中,我们将举例说明这种用途。在响应式表单中,用户在字段中输入被视为一个观察者。

在我们的例子中,让我们改变NewEntryFormReactiveComponent组件:

ngOnInit(): void {
  this.entryForm.valueChanges.subscribe((model) => console.log(model));
  . . .
}

运行我们的应用程序,我们可以在浏览器控制台中看到,在任意表单字段中输入会触发由subscribe方法捕获的事件。

知道我们可以对用户输入事件做出反应,我们如何将此事件连接到 API 中的练习信息搜索?我们使用一个操作符!

在我们的组件中,我们将重构代码:

public exercises$ = this.entryForm.valueChanges.pipe(
  switchMap((model) => this.exerciseService.getExercises(model?.exercise))
);

我们从组件中移除ngOnInit方法的订阅,并将exercises$观察者的赋值放入其中。然而,如果我们这样做,TypeScript 和 Angular 类型验证会显示一个错误,因为模板正在等待一个列表来执行迭代。

进入switchMap操作符。我们用表单中输入的练习请求流替换第一个事件流,将表单模型的exercise字段作为exerciseService服务的过滤器。

结果是,exercises$观察者继续接收一系列练习。执行我们的项目,我们会注意到我们有一个列表,当我们在字段中填写时,会进行搜索请求,如图所示。

图 9.1 – 练习选择

图 9.1 – 练习选择

switchMap操作符是一个高阶观察者,因为它接受一个观察者作为输入并返回一个观察者作为输出。这与map操作符形成对比,后者接受一个观察者作为输入并返回一个值作为输出。

使用命令,我们有了search字段,但如果查看浏览器中的网络标签页,我们可以看到每次我们键入一个字母都会触发一个请求。我们可以在不影响用户体验的情况下改进应用程序的数据消耗,我们将在下一节中这样做。

优化数据消耗 – 过滤操作符

我们创建类型提示类型search字段的任务已经完成,但我们可以从消耗 HTTP 请求的角度使这个功能更高效。在这里,在我们的情况下,如果用户只键入一个字母,我们已经开始搜索信息,但一个字母仍然会导致一个非常开放的列表。

对于我们的应用程序来说,从用户键入的第三个字母开始查找练习会更有趣,我们可以为此行为进行以下修改:

public exercises$ = this.entryForm.valueChanges.pipe(
  map((model) => model?.exercise ?? ''),
  filter((exercise) => exercise.length >= 3),
  switchMap((exercise) => this.exerciseService.getExercises(exercise))
);

在这里,我们开始使用 RxJS 最灵活的特性之一,即链式操作符以执行特定操作。我们始终需要记住操作符的顺序非常重要,一个操作符的输出是下一个操作符的输入:

  1. 我们使用已知的map操作符来从form模型中提取仅exercise字段,并将数据视为字段值未定义。

  2. filter操作符与 JavaScript 中Array对象的同名方法类似。它接收exercise字符串,并验证其长度必须大于或等于三个才能进入下一个操作符。

  3. 最后,我们运行switchMap高阶操作符,将表单键入的可观察值切换到服务的 HTTP 请求可观察值。

我们还可以使用另一个操作符,为可观察值的流开始添加一个等待时间,如下例所示:

const DEBOUNCE_TIME = 300;
. . .
public exercises$ = this.entryForm.valueChanges.pipe(
  debounceTime(DEBOUNCE_TIME),
  map((model) => model?.exercise ?? ''),
  filter((exercise) => exercise.length >= 3),
  switchMap((exercise) => this.exerciseService.getExercises(exercise))
);
. . .

我们添加了debounceTime操作符来创建流开始的延迟时间,以毫秒为单位,并使用常量来使代码更清晰,这是良好的实践。

让我们使用一个新的操作符添加最后一个优化:

public exercises$ = this.entryForm.valueChanges.pipe(
  debounceTime(DEBOUNCE_TIME),
  map((model) => model?.exercise ?? ''),
  filter((exercise) => exercise.length >= 3),
  distinctUntilChanged(),
  switchMap((exercise) => this.exerciseService.getExercises(exercise))
);

distinctUntilChanged操作符检查流的数据,这里指exercise,是否从一个迭代到另一个迭代发生了变化,并且只有当值不同时才会触发下一个操作符,从而节省更多不必要的后端调用。

我们已经了解了一些操作符,但库中超过 80 个。在下一节中,我们将学习如何浏览库的文档。

如何选择正确的操作符

RxJS 库有大量的操作符可以帮助简化你的代码并处理异步和性能的边缘情况。

你不需要记住所有的操作符,我们之前看到的那些将帮助你处理最常见的情况。

图书馆文档有一个决策树页面,我们将学习如何导航该页面。

进入网站(rxjs.dev/operator-decision-tree),在这里,我们将导航到一个我们已经研究过的操作符,以展示这个工具的使用。

图 9.2 – 操作符决策树

图 9.2 – 操作符决策树

让我们回到我们的表单示例。我们需要从用户的输入中获取练习信息——让我们假设我们不知道选择哪个操作符。

我们已经有一个可观察对象,它是 Angular 表单中的valueChanges事件,所以第一个屏幕上,我们将选择我有一个现有的 可观察对象 选项。

我们对 API 的请求由一个可观察对象表示,所以在下个屏幕上,我们将选择我想为每个 启动一个新的可观察对象选项。

由于我们希望为用户输入的每个字母都发出新的请求,我们希望将一个流更改为另一个流,所以下一个屏幕上,我们将选择当新 值到达时 取消之前的嵌套可观察对象

练习搜索取决于 Angular 表单中的值,所以最终页面上,我们将选择嵌套可观察对象为每个 计算

确认选择后,决策树指示这种情况的正确操作符是我们正在使用的switchMap操作符!

在 RxJS 文档中,我们还需要理解的是宝石图。为此,让我们以我们在本章中研究的另一个操作符为例,即这里的map操作符:https://rxjs.dev/api/index/function/map。

除了文本说明外,我们还有以下图示:

图 9.3 – Map 操作符的宝石图(来源:https://rxjs.dev/api/index/function/map,MIT 许可)

图 9.3 – Map 操作符的宝石图(来源:https://rxjs.dev/api/index/function/map,MIT 许可)

正如我们在本章开头所学的,RxJS 在信息流上工作,其中操作符具有处理信息的功能。

说明这个流程的图使用箭头表示时间的流逝,用宝石表示值。

因此,在这份文档中,我们看到map操作符接受每个发出的值,并根据一个函数,产生一个由其转换的值流。

这些值一旦发出就依次交换,因此,在图中,我们可以看到宝石的位置相同。

这种理解对于理解库中其他更复杂的操作符是基本的。

摘要

在本章中,我们探讨了 RxJS 库及其基本元素,可观察对象。

我们学习了什么是可观察对象以及它与承诺或函数的区别。有了这些知识,我们重构了我们的项目,使用map操作符处理数据,抽象出将消费服务的组件的实现细节。我们还了解了 Angular 的异步管道以及它是如何简化对可观察对象的订阅管理的,将这项任务留给了框架本身来管理。

最后,我们使用 RxJS 创建了一个 typeahead 搜索字段,根据用户的输入事件搜索练习,使用操作符来优化前端发出的 HTTP 调用。在下一章中,我们将探讨我们可以在 Angular 应用程序中进行的自动化测试的可能性。

第三部分:架构和部署

在本部分中,你将学习如何构建你的 Angular 项目的架构以满足用户面临的挑战和需求。我们将探讨使用框架使用的库进行自动化测试的最佳实践,并安装 Cypress 进行端到端测试。我们将了解微前端架构以及如何使用 Angular 实现它。我们将使用 Azure 云服务执行构建和部署我们的示例应用程序,并最终了解如何更新 Angular 应用程序以及从版本 17 开始使用 Angular Signals 等特性。

本部分包含以下章节:

  • 第十章**,为测试而设计:最佳实践

  • 第十一章**,使用 Angular Elements 的微前端

  • 第十二章**,打包一切:部署的最佳实践

  • 第十三章**,Angular 的文艺复兴

第十章:测试设计:最佳实践

在软件项目中,无论是前端还是后端项目,最佳实践之一就是进行测试。毕竟,如果你和你的团队不严格测试你的系统,那么不可避免地测试系统并发现潜在错误的人将是用户,我们不想看到这种情况发生。

因此,Angular 团队自框架的第一版以来就关注于创建和集成自动化测试工具,这并不奇怪。

我们可以通过默认情况下 Angular CLI 总是与组件一起生成测试文件这一事实来注意到这一点,就好像在说,“嘿,朋友,别忘了单元测试!”

在本章中,我们将通过以下内容来探讨这个主题:

  • 需要测试的内容

  • 服务测试

  • 理解 TestBed

  • 组件测试

  • 使用 Cypress 进行端到端测试

在本章结束时,你将能够为你的组件和服务创建测试,从而提高你交付的质量和团队的工作效率。

技术要求

要遵循本章的说明,你需要以下内容:

本章的代码文件可在 github.com/PacktPublishing/Angular-Design-Patterns-and-Best-Practices/tree/main/ch10 找到。

在学习本章的过程中,请记住使用 npm start 命令运行位于 gym-diary-backend 文件夹中的应用程序的后端。

需要测试的内容

在一个软件项目中,我们可以进行多种类型的测试以确保产品的质量。在这个领域,使用金字塔结构对测试进行分类是非常常见的。

图 10.1 – 测试金字塔

图 10.1 – 测试金字塔

在金字塔的底部,我们有单元测试,其目标是验证软件项目中最小元素的质量,例如函数或类的成员方法。由于它们的范围狭窄和原子性,它们可以快速由工具执行,并且理想情况下应该构成应用程序测试的大多数。

在中间层,我们有集成测试,这些测试专注于验证项目组件之间的交互,例如,可以通过 HTTP 请求测试一个 API。因为这些测试使用更多元素并需要一定的环境要求,所以它们的性能较低,执行成本较高,这也是为什么与单元测试相比,我们看到的数量较少的原因。

在金字塔的顶端,我们有 端到端测试E2E 测试),这些测试从用户的角度验证系统,模拟他们的动作和行为。这些测试需要一个几乎完整的环境,包括数据库和服务器。此外,它们运行较慢,因此与之前的测试相比,它们的数量更少。

最后,我们有手动和探索性测试,这些是由质量分析师执行的测试。理想情况下,这些测试将作为创建端到端测试的基础,主要针对新功能。由于它们是由人类运行的,因此成本最高,但它们对于发现新功能中的新错误是最好的。

需要强调的是,没有哪个测试比另一个更好或更重要。在这里,我们有根据一段时间内测试执行量进行的分类。你和你的团队必须根据项目可用的能力和资源来确定哪些测试需要优先考虑。这些类型的测试可以应用于任何类型的软件项目,但你可能想知道我们如何将这个概念应用到 Angular 项目中。

手动测试的概念可以不使用工具来应用,因为我们需要的只是一个质量分析师和一个具有完整环境的应用程序,即后端服务对我们的应用程序做出响应。

端到端测试是由模拟用户行为的特定工具执行的。截至版本 14,Angular 已经有一个内置工具叫做 Protractor,但 Angular 团队不再推荐它,因为现在有更多现代、更快的替代品。在本章中,我们将使用 Cypress 来实现这一目的。

最后,单元测试是对我们的服务和组件的方法进行的,以验证其行为。

在 Angular 工具箱中,我们有两个用于创建和运行这些测试的工具:Jasmine 和 Karma。这些工具在我们启动新项目时默认安装。

Jasmine 是一个具有多个检查功能的测试框架,除了提供使用称为 spy 的元素在运行时更改方法或类功能的能力外。对于单元测试的执行,我们使用 Karma 工具,它具有在浏览器中运行测试的特征,这使得团队能够分析应用程序在不同类型环境中的行为。尽管现在很少见,但我们可能有一些依赖于它所运行的浏览器的错误。

要使用这两个工具,我们不需要对我们的项目进行任何配置;我们只需要在我们的操作系统命令行中执行以下命令:

ng test

一旦我们执行前面的命令,就会得到一个编译错误。这是因为,在此之前,Angular 只编译了我们的组件文件,而忽略了测试文件,因为它们不会在最终版本中部署给用户。

运行测试时,我们注意到我们在diary.resolver.spec.ts测试文件中有一个错误,所以让我们进行修正:

describe('diaryResolver', () => {
  const executeResolver: ResolveFn<ExerciseSetList> = (...resolverParameters) =>
     TestBed.runInInjectionContext(() => diaryResolver(...resolverParameters));
  beforeEach(() => {
    TestBed.configureTestingModule({});
  });
  it('should be created', () => {
    expect(executeResolver).toBeTruthy();
  });
});

由 Angular CLI 生成的测试包含所有 Jasmine 的样板代码。在 describe 函数中,我们定义我们的测试用例,这是一个我们将创建的测试组。

这个函数的第一个参数是一个字符串,表示测试用例的名称,它甚至会在报告中识别它。

在第二个参数中,我们有准备和测试的函数。在这里,我们进行了一个修正,因为我们想要测试的解析器返回的是 ExerciseSetList 类型的对象,而不是之前作为布尔值返回。

在下一行,我们有 TestBed 类,这是 Angular 测试中最基本元素。

这个框架类具有为测试运行准备 Angular 执行环境的函数。我们将在以下章节中看到它在不同情况下的使用。

beforeEach 函数的目的是在执行测试之前执行一些常见操作。

最后,it 函数是我们创建测试的地方。在一个 describe 函数内部,我们可以有多个 it 类型的函数。

如果我们再次运行 Karma,浏览器将打开,我们可以跟踪测试的执行过程:

图 10.2 – Karma 执行测试

图 10.2 – Karma 执行测试

由于我们在创建应用程序元素时,这个项目已经有了测试,因此我们有一些损坏的测试将在下一节中修复,但现在对我们来说重要的是要了解如何运行我们应用程序的单元测试。

在下一节中,我们将学习如何为我们的项目服务创建测试。

服务测试

正如我们在 第五章 中详细研究的,Angular 服务和单例模式,Angular 应用程序中作为业务规则存储库工作的服务。因此,对我们来说,为这些服务开发单元测试至关重要。在本节中,我们将专注于 ExerciseSetsService 服务,以展示我们在项目中使用的 Angular 单元测试技术。让我们开始吧。

exercise-sets.service.spec.ts 测试文件中,让我们首先修复由 Angular CLI 自动创建但运行不正确的测试:

import { TestBed } from '@angular/core/testing';
import { ExerciseSetsService } from './exercise-sets.service';
import { HttpClientTestingModule } from '@angular/common/http/testing';
  fdescribe('ExerciseSetsService', () => {
    let service: ExerciseSetsService;
    let httpMock: HttpTestingController;
  beforeEach(() => {
    TestBed.configureTestingModule({ imports: [HttpClientTestingModule] });
    service = TestBed.inject(ExerciseSetsService);
    httpMock = TestBed.inject(HttpTestingController);
  });
  it('should be created', () => {
    expect(service).toBeTruthy();
  });
});

由于我们想要进行服务测试,因此在此阶段,我们将 describe 函数替换为 fdescribe 函数,这样 Karma 测试运行器将只执行这个测试用例。fdescribe 功能也可以用于隔离特定的测试,在这种情况下,将 it 函数替换为 fit 函数。为了修复 Angular 编译器识别出的错误,我们在 TestBed 组件中导入 'HttpClientTestingModule' 模块。

我们需要了解 Karma、Jasmine 和 Angular 如何协同工作以运行测试。在it函数中定义每个测试案例之前,Angular 为测试设置一个隔离的环境。最初,这个环境几乎没有模块配置,就像您的真实应用程序一样,TestBed组件开始发挥作用,在那里我们配置测试运行所需的最小必要依赖项。

在这个服务中,因为它依赖于HttpClient来执行 HTTP 请求,所以我们需要导入HttpClientModule模块来拥有这个依赖项。您可能会想,“但在这里您正在使用HttpClientTestingModule。这是正确的吗?”正如我们将在下面的代码中看到的那样,我们不仅想要使用HttpClient,我们还需要模拟 HTTP 调用,为了使这项任务更容易,Angular 团队已经为这种类型的测试准备了一个特定的模块。

在我们的基本“应该被创建”测试案例就绪后,让我们测试类的这些方法:

it('should use the method getInitialList to return the list of entries', fakeAsync(() => {
  const fakeBody: ExerciseSetListAPI = {
    hasNext: false,
    items: [
      {
        id: '1',
        date: new Date(),
        exercise: 'Deadlift',
        reps: 15,
        sets: 4,
      },
    ],
  };
  service.getInitialList().subscribe((response) => {
    expect(response).toEqual(fakeBody.items);
  });
  const request = httpMock.expectOne((req) => {
    return req.method === 'GET';
  });
  request.flush(fakeBody);
  tick();
}));

如您从前面的代码中可以看到,此服务旨在处理与健身日记条目相关的请求。在初始方法getInitialList中,我们的目标是验证服务是否准确使用GET方法向后端发起 HTTP 请求。通过在第一个参数中使用it函数创建一个新的案例,我们放置了一个在测试执行期间将重要的测试案例描述。与“应该被创建”的测试案例不同,测试函数包含在 Angular 团队创建的fakeAsync函数中,以方便测试异步方法,如 HTTP 请求。在函数内部,我们开始组装我们的测试。在这里,我们需要定义单元测试的结构看起来是什么样子。

单元测试由三个部分组成:

  • 测试设置,其中我们准备所有测试所需的元素

  • 执行要执行的方法

  • 测试断言,其中我们比较执行结果与预期的返回值

在这个测试案例中,部分设置是在beforeEach函数中完成的,但请注意,相反,我们必须放置所有将要执行的测试案例的共同设置,以避免测试案例中的减速。在所讨论的测试中,我们定义了一个来自服务器的模拟返回值,因为单元测试必须独立于后端服务执行。在执行阶段,我们使用getInitialList方法调用服务。

我们调用服务返回的可观察对象的subscribe方法,并在其中,我们做出预期的返回值等于fakeBody对象的item元素的断言。在这里,断言阶段可能很棘手,因为为了检查这个可观察对象的返回值,我们需要模拟项目的后端处理。

进入 Angular 的 HttpTestingController 服务,我们可以用它来模拟后端服务的响应。在这里,我们还创建了一个断言来确保我们的方法正在使用 GET HTTP 动词调用 API。为了模拟 HTTP 请求,我们使用服务的 flush 方法以及我们想要发送的内容——在这种情况下,是 fakebody 对象。但我们需要记住,HTTP 操作是异步的,所以我们使用 fakeAsync 函数上下文中的 tick 函数来模拟异步执行所需的时间。

我们将为同一服务创建一个测试来模拟创建新条目的过程:

it('should use the method addNewItem to add a new Entry', fakeAsync(() => {
  const fakeBody: ExerciseSet = {
    id: '1',
    date: new Date(),
    exercise: 'Deadlift',
    reps: 15,
    sets: 4,
  };
  service.addNewItem(fakeBody).subscribe((response) => {
    expect(response).toEqual(fakeBody);
  });
  const request = httpMock.expectOne((req) => {
    return req.method === 'POST';
  });
  request.flush(fakeBody);
  tick();
}));

我们首先定义将在 Karma 中出现的新测试,然后我们在 fakeAsync 函数的上下文中创建测试函数。

在测试设置中,我们定义了一个名为 fakeBody 的对象,其中包含我们想要发送的有效负载,并进行断言。在我们要测试的方法的执行阶段,我们调用 addNewItem 方法,并在 subscribe 函数内放置断言。我们执行 POST 动词的断言,最后,我们使用 flushtick 函数模拟请求。

为了结束这次会议,让我们将 fdescribe 函数切换到 describe 函数。在 ExercisesServiceAuthInterceptorAuthService 服务的测试文件中,让我们进行以下更改:

beforeEach(() => {
  TestBed.configureTestingModule({ imports: [HttpClientTestingModule] });
  . . .
});

正如我们在本节中看到的,我们需要通知 Angular 测试的依赖项,即在 TestBed 组件的配置中声明 HttpClientTestingModule

我们仍然需要纠正 NotificationInterceptor 服务的测试,该服务使用外部库作为依赖项。我们将按照以下方式重构 notification.interceptor.spec.ts 文件:

describe('NotificationInterceptor', () => {
  beforeEach(() =>
    TestBed.configureTestingModule({
      providers: [
        NotificationInterceptor,
        {
          provide: ToastrService,
          useValue: jasmine.createSpyObj('ToastrService', ['success']),
        },
      ],
    })
  );
. . .
});

在我们需要在测试中模拟的一般依赖项的情况下,我们可以在 TestBed 类定义中的 providers 属性中定义该服务。但不是提供原始的 ToastrService 类,我们声明一个对象,并使用 useValue 属性告诉 Angular 为测试提供哪个类。

在这里,我们可以创建一个具有相同原始方法但更好的类,但更好的是,我们正在使用 Jasmine 测试框架的一个特性,即间谍对象。通过它们,我们可以为我们的测试模拟整个类,从而成功地模拟单元测试依赖项。

在下一节中,我们将修复所有测试并了解 Angular 的 TestBed 组件是如何工作的。

修复测试和了解 TestBed

为了更好地理解 TestBed 的使用,我们将通过向测试文件添加依赖项来修复我们项目的其余测试。我们将从 app.component.spec.ts 文件开始,并按照以下方式进行修复:

describe('AppComponent', () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
     declarations: [AppComponent],
     imports: [RouterTestingModule],
   }).compileComponents();
  });
  it('should create the app', () => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.componentInstance;
    expect(app).toBeTruthy();
  });
});

在这个测试中,我们清理了 Angular CLI 在项目启动时已经创建的测试用例。它有router-outlet组件,因此我们需要模拟 Angular 的路由服务。像HttpClient服务一样,Angular 团队也为测试准备了一个特定的模块,因此我们在这里导入RouterTestingModule模块。

接下来,我们将修改login.component.spec.ts文件中的测试:

beforeEach(() => {
  TestBed.configureTestingModule({
    declarations: [LoginComponent],
    imports: [ReactiveFormsModule],
    providers: [
      AuthService,
      {
        provide: AuthService,
        useValue: jasmine.createSpyObj('AuthService', ['login']),
      },
    ],
  });
  fixture = TestBed.createComponent(LoginComponent);
  component = fixture.componentInstance;
  fixture.detectChanges();
});

由于Login组件依赖于ReactiveFormsModule模块,我们还需要将其导入到我们的测试中。此外,该组件使用了AuthService服务,并且为了我们的模拟目的,我们使用了前面演示的useValue属性。在单元测试中,集中关注组件本身至关重要,我们通过模拟其依赖项来实现这一点。

下一个需要调整的测试是针对home.component.spec.ts文件:

beforeEach(() => {
  TestBed.configureTestingModule({
    declarations: [HomeComponent],
    imports: [RouterTestingModule],
    providers: [
      AuthService,
      {
        provide: AuthService,
        useValue: jasmine.createSpyObj('AuthService', ['logout']),
      },
    ],
  });
  fixture = TestBed.createComponent(HomeComponent);
  component = fixture.componentInstance;
  fixture.detectChanges();
});

当测试Home组件时,我们需要包含'RouterTestingModule'依赖项,因为我们正在使用路由服务,并且由于应用程序的注销操作,我们正在模拟'AuthService'服务。

接下来,让我们修复new-entry-form-template.component.spec.ts文件的测试:

beforeEach(() => {
  TestBed.configureTestingModule({
    declarations: [NewEntryFormTemplateComponent],
    imports: [FormsModule],
    providers: [
      ExerciseSetsService,
      {
        provide: ExerciseSetsService,
        useValue: jasmine.createSpyObj('ExerciseSetsService', ['addNewItem']),
      },
    ],
  });
  fixture = TestBed.createComponent(NewEntryFormTemplateComponent);
  component = fixture.componentInstance;
  fixture.detectChanges();
});

这个页面使用了模板驱动的表单技术,因此在测试运行时,我们通过导入它来包含'FormsModule'模块。因为它只使用了'ExerciseSetsService'服务,所以我们用 Jasmine 框架帮助模拟它。

我们将开始测试new-entry-form-reactive.component.spec页面:

beforeEach(() => {
  TestBed.configureTestingModule({
    declarations: [NewEntryFormReactiveComponent],
    imports: [ReactiveFormsModule, RouterTestingModule],
    providers: [
      ExerciseSetsService,
      {
        provide: ExerciseSetsService,
        useValue: jasmine.createSpyObj('ExerciseSetsService', [
          'addNewItem',
          'updateItem',
        ]),
      },
      ExercisesService,
      {
        provide: ExercisesService,
        useValue: jasmine.createSpyObj('ExercisesService', ['getExercises']),
      },
    ],
  });
  fixture = TestBed.createComponent(NewEntryFormReactiveComponent);
  component = fixture.componentInstance;
  fixture.detectChanges();
});

第九章《使用 RxJS 探索响应性》中,我们将搜索练习纳入了表单,因此在这个测试用例中,我们需要导入'ReactiveFormsModule''RouterTestingModule'模块。此外,我们还需要模拟'ExerciseSetsService''ExercisesService'服务。

使用这个测试集,让我们转到最后一个组件diary.component.spec.ts

describe('DiaryComponent', () => {
. . .
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [
        DiaryComponent,
        ListEntriesComponent,
        NewItemButtonComponent,
      ],
      imports: [RouterTestingModule],
      providers: [
        ExerciseSetsService,
        {
          provide: ExerciseSetsService,
          useValue: jasmine.createSpyObj('ExerciseSetsService', [
            'deleteItem'
          ]),
        },
      ],
    }).compileComponents();
    fixture = TestBed.createComponent(DiaryComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });
});

这个组件,作为我们建议架构中的智能组件,需要在测试中声明它所组成的组件。在这里,它们是DiaryComponentListEntriesComponentNewItemButtonComponent。最后,我们将RouterTestingModule模块导入到测试设置中,并模拟了ExerciseSetsService服务,从而纠正了我们项目中所有的测试。

为了理解TestBed是如何工作的,让我们为我们的组件创建一个测试用例。

组件测试

Angular 组件单元测试不仅检查逻辑,还评估将在屏幕上呈现的值。

如果你的应用程序遵循 Angular 团队推荐的组件架构(更多详情见第四章《组件和页面》),你可能不会在组件中有太多业务逻辑,而是将其委托给服务。

为了举例说明,在本节中,我们将为DiaryComponent组件的一些方法创建测试。

我们将为健身房日记条目删除操作创建测试用例,并检查服务是否调用了delete方法:

describe('DiaryComponent', () => {
  . . .
  let exerciseSetsService: ExerciseSetsService;
  beforeEach(async () => {
  await TestBed.configureTestingModule({
   . . .
  }).compileComponents();
   . . .
    exerciseSetsService = TestBed.inject(ExerciseSetsService);
  });
  it('should call delete method when the button delete is clicked', fakeAsync(() => {
    exerciseSetsService.deleteItem = jasmine.createSpy().and.returnValue(of());
    component.deleteItem('1');
    tick();
    expect(exerciseSetsService.deleteItem).toHaveBeenCalledOnceWith('1');
  }));
});

在前面的代码块中,我们正在测试DiaryComponent组件,因此我们使用TestBed模拟它所依赖的服务。但在这个测试中,我们需要这个服务的引用,为此,我们声明一个名为exerciseSetsService的变量。使用TestBed.inject方法,我们将值分配给这个变量。

在测试设置中,我们需要使用createSpy函数来分配服务的deleteItem方法,因为 Jasmine 框架生成的模拟没有服务的完整实现,因此不会返回组件期望的可观察对象。

在执行阶段,我们调用组件的deleteItem方法。

由于此操作是异步的,我们使用tick函数来模拟时间的流逝。

在断言阶段,我们检查exerciseSetsService服务方法是否被调用一次,并且使用了预期的参数。

接下来,让我们测试editEntry方法:

import { Location } from '@angular/common';
describe('DiaryComponent', () => {
  let location: Location;
  beforeEach(async () => {
    await TestBed.configureTestingModule({
. . .
      imports: [
        RouterTestingModule.withRoutes([
          {
            path: 'home/diary/entry/:id',
            component: NewEntryFormReactiveComponent, },
        ]),
      ]
     }).compileComponents();
    location = TestBed.inject(Location);
  });
  it('should direct to diary entry edit route', fakeAsync(() => {
    const set: ExerciseSet = { date: new Date(), exercise: 'test', reps: 6, sets: 6, id: '1' };
    component.editEntry(set);
    tick();
    expect(location.path()).toBe('/home/diary/entry/1');
  }));
});

为了执行路由的断言,我们将使用一个Location类型的对象——这就是为什么我们在测试开始时声明它并使用TestBed组件来分配它。请注意,我们想要的是@angular/common库对象,而不是浏览器的默认Location对象。此外,在TestBed中,我们需要声明一个路由,因为我们处于单元测试的上下文中,Angular 不知道可用的路由。

在测试用例中,我们首先创建一个虚拟的ExerciseSet对象并调用editEntry方法。再次使用tick函数来模拟时间的流逝。最后,在断言中,我们验证路径是否正确。请注意,在这里,我们不需要为路由器创建任何模拟,因为RouterTestingModule模块为我们创建了它。

在下一节中,我们将探索使用 Cypress 框架的端到端(E2E)测试。

Cypress 的端到端(E2E)测试

E2E测试旨在从用户的角度评估系统,模拟在字段中输入、点击按钮、执行断言和评估屏幕上的消息等操作,就像用户评估操作是否成功一样。

在 Angular 生态系统中,过去有一个名为Protractor的工具来帮助进行此类测试,但 Angular 团队已经停止了它,转而支持其他更专注的开源工具。

在这些新工具中,我们将使用最受欢迎的一个,称为 Cypress。

Cypress 框架是一个旨在帮助开发者创建和运行测试金字塔中所有类型测试的工具,从单元测试到端到端测试。

让我们在我们的项目中看看它的实际应用。为此,我们需要安装和配置它。按照以下步骤安装和配置 Cypress:

  1. 我们将使用 Angular CLI 来安装和配置 Cypress。在你的操作系统命令行中运行以下命令:

    angular.json with the settings it needs.
    
  2. 要运行此工具,请在操作系统提示符中运行以下命令:

    ng serve command and open the tool’s interface.
    

图 10.3 – Cypress 执行测试

图 10.3 – Cypress 执行测试

  1. 选择所需的浏览器并点击 开始端到端测试,我们将获得测试执行界面。

注意,我们已经有了一个名为 spec.cy.ts 的文件。它是 Cypress 生成的,以示例化测试脚本的创建。让我们回到 Visual Studio Code 并检查此文件:

describe('My First Test', () => {
  it('Visits the initial project page', () => {
    cy.visit('/')
    cy.contains('app is running!')
  })
})

与 Angular 不同,Cypress 使用 Mocha (mochajs.org/) 作为测试框架。然而,在实践中,正如前一个示例所示,它与 Jasmine 框架非常相似。

我们有 describe 函数来创建测试套件,以及 it 函数来创建测试用例。这里的区别在于 cy 对象,它代表浏览器界面,并且通过这个对象,我们可以从用户的角度执行操作并评估页面状态。在这里,我们使用 visit 方法访问初始端点,并使用 contains 方法评估文本应用是否运行出现在页面上。我们将删除此文件,因为我们将要为我们的应用程序创建脚本。

在前一个文件所在的同一文件夹中,我们将创建 login.cy.ts 文件并添加以下代码:

describe('Login Page:', () => {
  it('should login to the diary with the correct credentials.', () => {
    cy.visit('/');
    cy.get('#username').type('mario');
    cy.get('#password').type('1234');
    cy.get(':nth-child(3) > .w-full').click();
    cy.contains('Workout diary');
  });
});

在这个测试中,我们使用了 get 方法通过 CSS 查询获取页面元素,以便对其执行操作。首先,我们获取 usernamepassword 字段,并使用 type 方法模拟用户在这些字段中输入。然后我们定位到 click 方法来模拟鼠标点击动作。

为了断言测试,我们使用了 contains 方法来评估日记屏幕是否显示。

创建此脚本棘手的部分是需要进行 CSS 查询以获取所需的元素。但在此阶段,Cypress 为我们提供了大量帮助。

通过运行测试,我们可以看到屏幕顶部有一个目标图标。通过点击它并选择我们想要的元素,Cypress 将生成必要的命令,以便我们可以将其复制并粘贴到脚本中。

图 10.4 – Cypress 帮助进行 CSS 查询

图 10.4 – Cypress 帮助进行 CSS 查询

然而,在这个脚本中,在选择按钮时存在问题,而且查询对于阅读测试脚本的其他人来说不够清晰。如果团队需要更改布局,测试可能会不当地中断。

为了避免这个错误,让我们更改 login 组件模板:

<button
  type="submit"
  class="w-full rounded bg-blue-500 px-4 py-2 text-white"
  [disabled]="loginForm.invalid"
  [class.opacity-50]="loginForm.invalid"
  data-cy="submit"
>
  Login
</button>

使用这个自定义 HTML 元素,我们可以使用带有 data-cy 属性标记的元素进行测试:

describe('Login Page:', () => {
  it('should login to the diary with the correct credentials.', () => {
    cy.visit('/');
    cy.get('#username').type('mario');
    cy.get('#password').type('1234');
    cy.get('[data-cy="submit"]').click();
    cy.contains('Workout diary');
  });
});

我们将之前的 CSS 查询替换为一个更简单的查询,该查询不依赖于布局元素。在您的项目模板中使用此良好实践,以方便端到端测试,并使测试更不容易出错。

我们将为新的日记条目表单创建一个端到端测试,但首先,让我们将最佳实践应用到我们将在这个测试中使用的模板上。在Home组件模板中,我们将按照以下方式重构:

<li>
  <a
    routerLink="./diary"
    class="flex items-center space-x-2 text-white"
    data-cy="home-menu"
  >
    <span>Diary</span>
  </a>
</li>
<li>
  <a
    routerLink="./diary/entry"
    class="flex items-center space-x-2 text-white"
    data-cy="new-entry-menu"
  >
    <span>New Entry</span>
  </a>
</li>
<li>
  <a
    (click)="logout()"
    class="flex items-center space-x-2 text-white"
    data-cy="logout-menu"
  >
    <span>Logout</span>
  </a>
</li>

在模板中,我们在菜单项中添加data-cy HTML 元素。请注意,由于测试是从用户的角度进行的,我们需要模拟他们如何到达表单。

new-entry-form-reactive.component.html模板中,我们将按如下方式更改提交按钮:

<button
  type="submit"
  [disabled]="entryForm.invalid"
  [class.opacity-50]="entryForm.invalid"
  class="rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700"
  data-cy="submit"
>
  Confirm
</button>

与登录屏幕一样,我们使用data-cy元素标记按钮,以方便端到端测试的开发。

在我们的应用程序更适合测试后,我们将在工作区的cypress/e2e文件夹中创建new-entry-form.cy.ts文件,并添加以下代码:

describe('New Entry Form:', () => {
  beforeEach(() => {
    cy.visit('/');
    cy.get('#username').type('mario');
    cy.get('#password').type('1234');
    cy.get('[data-cy="submit"]').click();
  });
  it('Should register a new entry in the workout diary', () => {
    cy.get('[data-cy="new-entry-menu"]').click();
    cy.contains('Date');
    cy.get('#date').type('2023-08-08');
    cy.get('#exercise').type('Front Squat');
    cy.get('#sets').type('4');
    cy.get('#reps').type('6');
    cy.get('[data-cy="submit"]').click();
    cy.contains('Item Created!');
  });
});

与 Jasmine 一样,Mocha.js 框架也有beforeEach函数,但在这里,我们不是使用TestBed设置环境,而是使用该函数执行登录,因为我们在模拟用户的地方进行每个测试时都需要这个动作。

在表单测试用例中,由于我们已登录,我们点击输入表单的菜单并检查是否有日期标签。从那时起,我们用数据填写表单字段并点击按钮。在断言阶段,我们检查屏幕上是否出现项目已创建的消息。

有一个需要注意的事项是,我们从未告诉脚本等待后端响应的时间有多长,这可能会变化。这是因为 Cypress 框架为我们做了这项工作,并使这个等待过程对我们来说变得透明。

我们将创建一个测试用例来评估表单验证:

it('should validate field information and show the validation message', () => {
    cy.get('[data-cy="new-entry-menu"]').click();
    cy.contains('Date');
    cy.get('#date').type('2023-08-08');
    cy.get('#exercise').type('Front Squat');
    cy.get('#sets').type('3');
    cy.get('#reps').type('6');
    cy.contains('Sets is required and must be a positive number.');
    cy.contains('sets is required and must be multiple of 2.');
  });

在这个测试用例中,我们不需要担心登录,因为beforeEach函数执行这个功能,我们直接在表单上工作。我们填写字段,但这次,用不正确的信息。在断言阶段,我们使用contains方法检查验证消息是否正确出现。

通过这样,你已经了解了在 Angular 应用程序中使用 Cypress 和端到端测试,所以让我们总结一下本章我们看到了什么。

概述

在本章中,我们学习了如何在 Angular 项目中执行测试。我们研究了有哪些类型的测试,它们的重要性,以及如何在我们的日常生活中应用它们。我们通过首先为服务创建测试并查看如何为单元测试隔离依赖项来工作在我们的项目上。此外,我们还探讨了使用HttpClientTestingModule模块测试 HTTP 请求。我们了解了TestBed组件及其为每个单元测试运行设置环境的重要任务。我们还研究了组件测试以及如何断言使用路由的组件。最后,我们使用 Cypress 工具探索了端到端测试,该工具简化了从客户端视角模拟我们应用程序行为的脚本的创建。

在下一章中,我们将使用 Angular 框架来探讨微前端的概念。

第十一章:使用 Angular Elements 的微前端

随着应用程序的增长和复杂化,仅一个团队不足以维持增长速度,需要新的人来处理出现的新应用程序部分。在这个时候,你的项目架构需要进化,一个可能的方法是将你的应用程序分解成几个项目,这些项目作为一个整体集成。这种做法起源于后端服务领域,在前端领域以微前端的名字出现。在本章中,我们将学习如何在 Angular 项目中应用这一原则。

本章将涵盖以下主题:

  • 微前端 – 概念和应用

  • 在微前端中切割你的应用程序

  • 使用独立组件创建微前端应用程序

  • 准备一个将被基础应用程序加载的页面

  • 动态加载微前端

到本章结束时,你将能够评估何时需要使用微前端,如何组织你的 Angular 项目,以及如何将其整合成一个统一的应用程序。

技术要求

要遵循本章中的说明,你需要以下内容:

本章的代码文件可在github.com/PacktPublishing/Angular-Design-Patterns-and-Best-Practices/tree/main/ch11找到。

在开始阅读本章之前,请记住使用npm start命令运行gym-diary-backend文件夹中的应用程序的后端。

微前端 – 概念和应用

2014 年,Martin Fowler 和 James Lewis 的一篇文章(martinfowler.com/articles/microservices.html)通过正式化微服务的概念,震撼了开发界。该文章专注于后端服务的开发,将一个大的系统(称为单体)分解成专注于业务单一方面的独立小服务,无疑是系统架构的一个里程碑。

不久之后,这个概念被应用于前端领域,其中一篇主要文章由 Cam Jackson 撰写(martinfowler.com/articles/micro-frontends.html)。微前端的基本思想与其兄弟概念微服务相同,即把一个大的前端项目(单体)分解成专注于业务某一方面的独立小项目。然而,关注点当然不同。在微服务中,我们担心的是数据库和通信协议,而在前端,我们需要关注的是数据包大小、可访问性和用户体验。

让我们先分析一下你是否需要为你的项目使用这种类型的架构。

何时使用微前端

在系统架构中有一个大但非常真实的陈词滥调,那就是没有银弹——也就是说,没有一种适合所有问题的万能解决方案——微前端也无法摆脱这个陈词滥调。这种架构的主要优势,在技术方面之前,是其组织方面的优势。

当我们使用微前端时,我们正在分离一个专注于业务某一方面的独立部分,这部分将由专注于该方面的团队处理。通过这种方式,你的项目可以跨不同团队扩展,这些团队处理特定主题,并将它们整合成用户的体验。每个团队在这个项目的交付周期中都有自主权,与构建、部署和测试独立。独立性可以达到一个水平,即团队可以与不同的 Angular 版本甚至不同的框架(如 React 和 Vue)一起工作,尽管这并不高度推荐,我们将在下一节讨论这一点。

何时不使用微前端项目

另一个软件工程陈词滥调是没有免费的午餐,选择使用微前端有其成本和挑战。

第一个挑战是前端性能问题。正如我们在第一章,“正确开始项目”中看到的,在单页应用SPA)中,用户的浏览器下载包含 Angular 框架代码的应用程序包,以及你的团队生产的代码。在这之后,浏览器解释这个包并为用户渲染页面。这个整个过程必须尽可能快和高效,因为在这个过程中,用户无法与屏幕交互,这会导致挫败感。

现在想象这个过程发生在你系统的每一个部分,因为为了保证版本和甚至框架的独立性,每个微前端都携带其特定版本的框架引擎。有像 webpack 的模块联邦([webpack.js.org/concepts/module-federation/](https://webpack.js.org/concepts/module-federation/))这样的技术和工具,但你和你的团队必须评估这个挑战。

我们还必须关注的另一个问题是关于用户体验和屏幕上组件的设计,因为对于它们来说,界面之间的组件必须本质上相同,以保证他们在体验中的连贯性。

通过实施设计系统——即你公司组件的单个设计指南,最好有一个支持它的库——可以克服这个挑战。设计系统的一个例子是谷歌的 Material Design。

既然我们已经对微前端有了基本的了解,让我们继续到下一节,我们将探讨如何将我们的应用程序拆分为微前端。

将你的应用程序拆分为微前端

为了最大限度地从微前端架构中获得收益并最小化前一部分定义的风险,我们需要创建尽可能独立且对团队组织有意义的微服务。

最常见的项目组织类型是功能垂直化 – 也就是说,对于一个项目,你可能有一个完整的用户旅程,例如产品购买屏幕,另一个项目用于产品注册,另一个用于应用的管理模块。

图 11.1 – 微前端划分

图 11.1 – 微前端划分

此图使用 Angular 应用程序展示了划分的概念。在每一个项目中,我们都有用户体验的所有组件。

你可能会想,“我能否使用 Angular 模块实现这种相同的分离?”答案是肯定的,你可以。如果一个团队负责你公司组织的所有模块,或者团队可以组织成只有一个项目,那么你可以(甚至应该)这样做。

我们需要记住,将你的项目划分为微前端的原因是为了满足项目的一个组织需求,并且团队希望拥有部署和开发的独立性。

在心中牢记基本概念后,我们将展示如何在我们的健身日记项目中实现它们。

使用独立组件创建微前端应用程序

为了在我们的健身日记中展示微前端架构的使用,我们将创建一个表单来定义用户的新练习。让我们创建另一个 Angular 项目,模拟一个专门负责这个功能的团队。在你的操作系统命令行中,使用以下命令:

ng new gym_exercises --skip-git --standalone --routing false --style css

我们在第一章,“正确开始项目”,学习了ng new命令,但在这里我们使用了一些之前没有见过的参数。我们使用skip-git参数是因为,在这个例子中,我们是在同一个 Git 项目中创建的(该项目中已经包含了gym-diarygym-backend项目)。routing参数设置为false,因为我们的项目将在日记应用的路由中加载,style参数设置为CSS,这样 Angular CLI 就不需要询问我们的项目将有什么类型的样式。

这个命令中最大的区别是standalone参数,它将我们的项目参数化,默认创建所有组件为独立组件。但你可能想知道什么是standalone组件。从 Angular 的 15 版本开始创建,这个特性允许你创建一个组件而不使用 Angular 模块(NgModule)。尽管模块非常重要,正如我们在第二章中看到的,组织你的应用程序,但在某些情况下它们并不非常有用,并且会使项目变得不必要地复杂。一个很好的例子是范围有限的小项目,例如这个微前端,我们将不会有多个路由或懒加载。

在我们开始创建练习表单之前,让我们添加并配置 Tailwind CSS 框架,因为我们希望有一个与我们的主应用程序兼容的样式。在创建的项目文件夹中,从你的操作系统的命令行运行以下命令:

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init

此命令将向项目添加开发依赖项,并在 Tailwind CSS 框架中创建配置文件。

tailwind.config.js文件中,进行以下更改:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./src/**/*.{html,ts}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

在此文件中,我们正在告诉 Angular 将 Tailwind CSS 框架应用于src文件夹中的所有 HTML 文件。

最后,将以下代码行添加到app.component.css文件中:

@tailwind base;
@tailwind components;
@tailwind utilities;

使用这些 CSS 变量,组件将能够访问tailwindcss类。

然后,我们将创建一个服务,该服务将负责与我们的后端练习 API 交互。在命令行中,我们将使用以下命令:

ng g service service/Exercises
ng g interface exercise

注意我们架构的一个细节:我们已经在主项目中有一个查询练习 API 的服务,但在这里我们不能重用它,因为它们是独立的项目,并且某些代码重复是这个架构的成本。

按照最佳实践,我们将创建我们的 API 如下:

Export interface Exercise {
  id?: string;
  description: string;
}
export type ExerciseList = Array<Exercise>;
export interface ExerciseListAPI {
  hasNext: boolean;
  items: ExerciseList;
};

在这里,我们正在重新创建表示 API 数据的类型。有关 TypeScript 接口的更多详细信息,你可以查阅第三章TypeScript 模式 for Angular

在创建的服务中,我们将添加与后端的交互:

@Injectable({
  providedIn: 'root',
})
export class ExercisesService {
  private httpClient = inject(HttpClient);
  private url = 'http://localhost:3000/exercises';
  getExercises(): Observable<ExerciseList> {
    return this.httpClient
      .get<ExerciseListAPI>(`${this.url}`)
      .pipe(map((api) => api?.items));
  }
  addExercises(exercises: Partial<Exercise>): Observable<Exercise> {
    return this.httpClient.post<Exercise>(this.url, exercises);
  }
}

在服务中,我们正在通过 HTTP 请求查询练习并添加新的练习。有关 Angular 服务的更多详细信息,你可以查阅第五章Angular 服务和 Singleton 模式,以及第九章使用 RxJS 探索响应性

然而,我们遇到了一个错误,因为我们没有导入HttpClientModule模块。但如果没有模块在独立组件中,我们如何导入它呢?

在没有模块的项目中,导入发生在组件本身中;对于服务,我们有app.config.ts文件,我们将将其添加到其中:

import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
  providers: [provideHttpClient()],
};

注意,我们导入的是provideHttpClient提供者而不是模块。这是因为这个提供者是由 Angular 团队创建的,用于处理这些独立应用程序的情况。

在应用程序的主要组件中,我们将按照以下方式编写其行为:

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule],
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
})
export class AppComponent {
  private formBuilder = inject(NonNullableFormBuilder);
  private exerciseService = inject(ExercisesService);
  exerciseList$ = this.exerciseService.getExercises();
  public entryForm = this.formBuilder.group({
    description: ['', Validators.required],
  });
  newExercise() {
    if (this.entryForm.valid) {
      const newExercise = { ...this.entryForm.value };
      this.exerciseService
        .addExercises(newExercise)
        .subscribe(
          (_) => (this.exerciseList$ = this.exerciseService.getExercises())
        );
    }
  }
}

让我们先强调@Component装饰器元数据中的组件配置。standalone属性表示该组件可以直接使用,而无需在任何模块中声明。在imports属性中,我们声明其依赖项,这些依赖项是CommonModule,它是任何 Angular 组件的基础,以及ReactiveFormsModule,因为我们将会开发一个响应式表单(更多关于表单的详情,请参阅第六章处理用户输入:表单)。在组件中,我们注入NonNullableFormBuilderExercisesService,并将初始列表分配给exerciseList$属性。我们使用formBuilder服务创建表单对象,并最终创建负责提交按钮的newExercise方法。

由于我们将有相同的表单中的练习列表,在subscribe方法中,我们再次将exerciseList$属性分配给刷新列表。

为了完成组件,让我们创建其模板如下:

<div class="bg-gray-100 flex justify-center items-center min-h-screen">
  <div class="max-w-md w-full p-6 bg-white rounded-lg shadow-md">
    <h1 class="text-2xl font-bold mb-4">Exercise List</h1>
    <div class="max-h-40 overflow-y-auto mb-4">
      <ul>
        <li class="mb-2" *ngFor="let exercise of exerciseList$ | async">
          {{ exercise.description }}
        </li>
      </ul>
    </div>
  </div>
</div>

在第一部分,我们有练习列表,这里我们使用 Angular 的async管道来订阅和搜索列表(更多详情,请参阅第九章使用 RxJS 探索反应性)。

在相同的模板文件中,我们将添加表单:

<h2 class="text-xl font-semibold mt-6 mb-2">Add Exercise</h2>
  <form [formGroup]="entryForm" (ngSubmit)="newExercise()" class="space-y-2">
    <div class="mb-4">
      <label for="description" class="mb-2 block font-bold text-gray-700">Description:</label>
      <input type="text" id="description" name="description" class="w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow" formControlName="description"/>
      <div *ngIf="entryForm.get('exercise')?.invalid && entryForm.get('exercise')?.touched" class="mt-1 text-red-500">
        Exercise is required.
      </div>
    </div>
    <div class="flex items-center justify-center">
      <button type="submit" [disabled]="entryForm.invalid" [class.opacity-50]="entryForm.invalid" class="rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700" >
        Confirm
      </button>
    </div>
  </form>

我们仅使用描述字段创建了一个响应式表单,并添加了简单的验证。

通过使用ng serve命令运行我们的应用程序,我们将拥有以下界面:

图 11.2 – 练习表单

图 11.2 – 练习表单

当我们的微前端项目准备就绪后,我们可以准备它以便被我们的主应用程序消费。

准备一个页面以便由基础应用程序加载

当我们的微前端项目准备就绪后,我们需要准备它以便被另一个应用程序消费。共享微前端有多种方式,从最简单(且已过时)的通过使用 iframe,到更现代但复杂的解决方案,如模块联邦。

在本节中,我们将使用市场上广泛使用的方法,即使用 Web Components。Web Components 是一个旨在将不同框架创建的组件标准化为可以在它们之间消费的模型的规范。换句话说,通过遵循此规范创建一个 Angular 组件,React 或 Vue 创建的应用程序可以消费此组件。尽管 Web Components 并非专为微前端项目而创建,但我们可以看到其定义完美地符合我们的需求。

就像 Angular 框架中的几乎所有内容一样,为了创建这种类型的组件,我们不需要手动操作,因为 Angular 团队为此创建了一个工具:Angular elements。一个 Angular 元素组件是一个通用组件,但被转换为 Web Components 标准,不仅打包我们的代码,还打包 Angular 渲染引擎,使其框架无关。

让我们在操作系统的命令行上使用以下命令将其添加到我们的gym_exercises项目中:

npm i @angular/elements

使用前面的命令,我们将angular/elements依赖项添加到我们的项目中,并且为了使用它,我们将对angular.json文件进行修改:

{
  "type": "anyComponentStyle",
  "maximumWarning": "50kb",
  "maximumError": "50kb"
}

由 Angular elements 生成的组件将封装 Tailwind CSS 框架,因此我们需要稍微增加组件大小预算,以避免在构建项目时出现错误。

我们必须进行的下一个更改是项目的main.ts文件:

import {
  bootstrapApplication,
  createApplication,
} from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
import { createCustomElement } from '@angular/elements';
(async () => {
  const app = await createApplication(appConfig);
  const element = createCustomElement(AppComponent, {
    injector: app.injector,
  });
  customElements.define('exercise-form', element);
})();

此文件负责配置 Angular 项目的初始化,我们通常不会更改它,因为我们想要标准的 SPA 构建和执行行为。然而,在这里,我们需要将其更改以通知 Angular,此项目的结果是 Angular elements 包生成的 Web 组件。在这里,我们正在配置项目,以便应用程序生成一个标签名为exercise-form的 Web 组件。

现在,我们需要修改index.html文件来理解这个新标签,这样我们就可以渲染我们的微前端进行测试:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>GymExercises</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>
    <exercise-form></exercise-form>
  </body>
</html>

在这里,我们将默认的<app-root>Angular 组件替换为 Web Components 的<exercise-form>标签。我们的主要应用程序将是我们的微前端 JavaScript,但修改index.html将允许你和你的团队在不加载主项目的情况下维护微前端。

我们现在面临一个挑战,尽管我们创建了一个 Web 组件,但项目构建却在三个文件和哈希中创建它,如果我们的应用程序不是微前端,这是正确的,但就我们而言,我们希望所有代码都在一个文件中,并且没有哈希。我们可以手动完成这项工作,但社区有一个包可以自动化这种处理:ngx-build-plus包。

让我们借助 Angular CLI 将其添加到命令行:

ng add ngx-build-plus

为了提供这个微前端,我们将使用http-server包,并在命令行上使用npm添加它:

npm i http-server

最后,让我们创建一些npm脚本来使运行mfe更加容易。在package.json文件中,我们将进行以下修改:

"scripts": {
  "ng": "ng",
  "start": "ng serve",
  "build": "ng build --single-bundle  --bundle-styles  --keep-styles  --output-hashing=none",
  "serve-mfe": "http-server dist/gym_exercises",
}

build脚本中,我们指定了运行它的意图,从而生成一个单独的文件(--single-bundle)。我们还指示它保留并封装 CSS(--bundle-styles --keep-styles),同时确保生成的文件名不包含任何类型的哈希(--output-hashing=none)。

serve-mfe脚本使用http-server服务发布包含编译后的微前端的dist文件夹的内容。

让我们使用以下命令运行我们的项目并检查我们创建的微前端:

npm run build
npm run serve-mfe

通过访问http://127.0.0.1:8080,我们可以看到我们的微前端应用程序正在成功生成。

我们的微前端准备就绪,可以供消费,在下一节中,我们将在主应用程序中消费它。

动态加载微前端

让我们准备我们的主应用程序健身日记以消费我们之前准备的微前端。为此,让我们首先在应用程序中创建一个新的模块。在命令行中,我们将使用以下 Angular CLI 命令:

ng g m exercise --routing
ng g c exercise/exercise

使用前面的命令,我们创建了一个包含生成的路由文件和负责加载mfe的组件的模块。

让我们调整exercise-routing.module.ts文件以指向组件:

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ExerciseComponent } from './exercise/exercise.component';
const routes: Routes = [
  {
    path: '',
    component: ExerciseComponent,
    title: 'Exercise Registry',
  },
];
@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule],
})
export class ExerciseRoutingModule {}

routes数组中,我们定义了一个基础路由,用于练习注册组件,因为它将通过懒加载加载。

接下来,我们将重构home-routing.module.ts文件如下:

. . .
const routes: Routes = [
  {
    path: '',
    component: HomeComponent,
    children: [
      {
        path: 'diary',
        loadChildren: () =>
          import('../diary/diary.module').then((file) => file.DiaryModule),
      },
      {
        path: 'exercise',
        loadChildren: () =>
          import('../exercise/exercise.module').then(
            (file) => file.ExerciseModule
          ),
      },
      {
        path: '',
        redirectTo: 'diary',
        pathMatch: 'full',
      },
    ],
  },
];
. . .

我们的HomePage模块包含菜单,在本节中,我们正在将新模块添加到界面的正确区域。

要完成添加此新模块,让我们更改home.component.html文件:

. . .
  <li>
    <a
      routerLink="./exercise"
      class="flex items-center space-x-2 text-white"
    >
      <span>Exercise Registry</span>
    </a>
  </li>
. . .

home模板中添加了新的菜单项后,我们现在有任务将其他项目中生成的微前端包含到我们的界面中。

为了做到这一点,我们有一个名为@angular-extensions的社区包,它允许我们通过指令简单地加载我们的微前端,正如我们稍后将会看到的。但首先,让我们使用以下命令在我们的项目中安装这个依赖项:

npm i @angular-extensions/elements

安装完成后,我们可以更改ExerciseModule模块:

import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ExerciseRoutingModule } from './exercise-routing.module';
import { ExerciseComponent } from './exercise/exercise.component';
import { LazyElementsModule } from '@angular-extensions/elements';
@NgModule({
  declarations: [ExerciseComponent],
  imports: [CommonModule, LazyElementsModule, ExerciseRoutingModule],
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class ExerciseModule {}

在此文件中,我们首先添加了一个名为LazyElementsModule的库模块,以便访问我们将在组件中使用的指令。此外,我们在元数据中有一个新的属性schemas。在其中,我们通过CUSTOM_ELEMENTS_SCHEMA令牌通知 Angular,此模块将接收来自项目外部的元素。默认情况下,Angular 会检查模板中使用的标签是否存在于项目中或在 HTML 标准中,例如input标签。

由于我们在这里将导入由我们的微前端定义的exercise-form标签,此属性将防止 Angular 在项目编译时执行此检查。

exercise.component.ts文件中,我们将添加一个新属性:

import { Component } from '@angular/core';
@Component({
  selector: 'app-exercise',
  templateUrl: './exercise.component.html',
  styleUrls: ['./exercise.component.css'],
})
export class ExerciseComponent {
  elementUrl = 'http://localhost:8080/main.js';
}

在这里,我们定义了微前端主文件将被提供的服务地址。

最后,让我们更改组件模板:

<exercise-form *axLazyElement="elementUrl"> </exercise-form >

在这里,我们声明了新的exercise-form元素,为了加载它,我们使用axLazyElement指令分配微前端地址。

要运行我们的项目,请确保微前端正在使用npm run serve-mfe命令提供服务。一切配置完成后,我们可以看到我们工作的结果:

img/B19562_11_3.jpg

图 11.3 – 动态加载到主应用程序中的练习表单

摘要

在本章中,我们探讨了微前端架构以及如何将其应用于 Angular 项目。

我们了解了架构的概念、其优势及其权衡。我们探讨了选择这种架构的主要原因是其与每个团队组织结构的灵活性,因为几个团队可以独立地工作在前端项目的不同部分。

我们还学习了如何理想地将我们的应用程序划分为微前端。

在所有这些概念的基础上,我们通过创建一个小型应用程序并利用 Angular 的独立组件功能,以及准备使用 Angular 元素库加载它,来应用我们的项目。

最后,我们在主应用程序中借助@angular-extensions/elements库实现了动态加载。

在下一章中,我们将探讨部署 Angular 应用程序的最佳实践。

第十二章:打包一切 – 部署最佳实践

在架构、开发和测试完您的应用程序后,是时候将其部署给用户了。

在本章中,我们将学习生成生产包的最佳实践以及如何使用自动化工具在项目此阶段最大化团队的生产力和效率。

本章将涵盖以下主题:

  • 部署后端

  • 区分环境

  • 准备生产包

  • 挂载带有 Nginx 的 Docker 镜像

  • 将页面部署到 Azure 静态 Web 应用

到本章结束时,您将能够使用 Angular CLI 生成针对生产优化的包,并使用 CI/CD 工具自动化团队的过程。

技术要求

要遵循本章中的说明,您需要以下内容:

本章的代码文件可在 github.com/PacktPublishing/Angular-Design-Patterns-and-Best-Practices/tree/main/ch12 找到。

部署后端

在为我们的健身房日记项目准备生产之前,我们首先将后端上传到云服务,以便我们的页面能够访问数据。

我们为这本书选择了 Azure 服务,但本章中的概念也可以应用于其他云服务,例如 AWS (aws.amazon.com) 和 GCP (cloud.google.com)。

此示例的后端不使用数据库,并使用 NestJS 框架(nestjs.com/)构建,该框架实际上有一个完全受 Angular 启发的架构,但用于后端!此框架允许您通过 Azure 添加云部署功能。为了准备您的后端进行部署,在您的操作系统命令行中,在project文件夹(/gym-diary-backend)中,运行以下命令:

npm install @schematics/angular
nest add @nestjs/azure-func-http

第一个命令安装 Angular Schematic 包,该包将被用于构建应用程序。

nest add命令与 Angular 的ng add命令具有相同的功能,在这里,除了安装部署到 Azure 的依赖项外,它还配置并创建了执行此任务所需的必要文件。

在安装了技术要求部分提供的工具后,我们首先需要创建一个 Azure Functions 项目。为此,让我们转到 Azure 门户中的函数应用菜单选项:

图 12.1 – 函数应用菜单选项

图 12.1 – 函数应用菜单选项

Azure 有几种运行后端服务的方式,其中最简单的一种是通过 Azure Functions。使用它,我们可以在不需要配置服务器的情况下上传我们的服务,因为提供商将负责这些细节。

然后,我们需要进行一些基本配置。为此,我们将点击+ 创建。完成后,我们将看到以下屏幕:

图 12.2 – Azure Functions 服务配置

图 12.2 – Azure Functions 服务配置

订阅字段中,您需要选择您的 Azure 订阅。在资源组字段中,您可以选择您已经拥有的组;如果您没有,您可以创建一个新的组并输入其名称。函数应用名称字段很重要,因为它将最初是您端点的地址。您可以选择购买一个特定的 URL 或将此 API 放在 Azure API 网关(https://azure.microsoft.com/en-us/products/api-management)后面,尽管在我们的示例中这不是必需的。我们将直接从代码部署,所以将您想部署代码还是容器镜像?设置为代码。项目的运行时堆栈应设置为NodeJS,版本18 LTS。对于项目区域,选择离您较近的区域,或东 US,这是默认选项。最后,操作系统应设置为Linux托管选项和计划选项应设置为消费(无服务器),因为我们在此情况下不需要任何更具体的功能。

图 12.3 – 托管选项和计划

图 12.3 – 托管选项和计划

一旦我们填写了所有必要的信息,点击审查 + 创建。在下一个屏幕上,确认您的信息并执行创建:

图 12.4 – Azure Functions 服务创建

图 12.4 – Azure Functions 服务创建

要将我们的后端发布到创建的服务中,我们将使用 VS 插件。打开后端项目,左键单击,并选择部署到函数应用…,如图所示:

图 12.5 – VSCode 发布 Azure 函数扩展

图 12.5 – VSCode 发布 Azure 函数扩展

扩展程序将获取您账户中创建的服务列表,因此请从AZURE面板中选择我们创建的那个。

图 12.6 – VSCode AZURE 面板

图 12.6 – VSCode AZURE 面板

发布后,Azure 服务将指向一个包含您服务的公共 URL。使用 /exercise 端点在浏览器中访问它,以检查服务是否处于活动状态。

发布的 URL 返回应类似于以下列表:

{"items":id":"30","description":"Plank"},{"id":"29","description":"Dumbbell Bench Press"},{"id":"28","description":"Seated Leg Curl"},{"id":"27","description":"Cable Curl"},{"id":"26","description":"Glute Bridge"},{"id":"25","description":"Skull Crusher"},{"id":"24","description":"Arnold Press"},{"id":"23","description":"Inverted Row"},{"id":"22","description":"Chest Fly"},{"id":"21","description":"Hanging Leg Raise"},{"id":"20","description":"Side Lateral Raise"},{"id":"19","description":"Front Squat"},{"id":"18","description":"Seated Row"},{"id":"17","description":"Romanian Deadlift"},{"id":"16","description":"Bicep Curl"},{"id":"15","description":"Calf Raise"},{"id":"14","description":"Tricep Dip"},{"id":"13","description":"Push-up"},{"id":"12","description":"Leg Curl"},{"id":"11","description":"Incline Bench Press"},{"id":"10","description":"Hammer Curl"}, {"id":"9","description":"Lunges"},{"id":"8","description":"Dumbbell Curl"},{"id":"7","description":"Pull-up"},{"id":"6","description":"Shoulder Press"},{"id":"5","description":"Bench Press"},{"id":"4","description":"Leg Press"},{"id":"3","description":"Barbell 
Row"},{"id":"2","description":"Squat"},{"id":"1","description":"Deadlift"}],"hasNext":false}

我们必须做的最后一个配置是配置服务的 CORS,以使我们的本地应用程序能够连接到云服务。在 Azure 控制台中,单击创建的服务,然后点击*

图 12.7 – CORS 配置

图 12.7 – CORS 配置

在我们的后端服务上线后,我们将专注于如何在下一节中从我们的应用程序中访问它。一个重要点是始终记得在 Azure 中关闭服务,以避免在通过这本书的示例在您的 Azure 账户中产生不必要的费用。

区分环境

完成后端部署的任务后,我们需要更改我们的前端项目以向我们的云基础设施发送请求。但在这里,出现了一个问题。我们希望在生产环境中访问我们发布的后端,但团队需要继续本地访问 API 以更实际的方式开发新功能。我们如何才能两者兼得?

这个问题的答案,再次,是由 Angular 团队想出来的,即为每个开发环境创建配置文件。

在 Angular 的第 14 版之前,这些文件在创建项目(ng new 命令)时已经是标准配置。然而,为了简化新项目并降低学习曲线,这些文件在新项目中已被移除。

但我们不必担心,因为要添加它们,我们可以使用 Angular CLI。在命令行中,使用以下命令:

ng generate environments

执行上述命令后,Angular CLI 创建了environments文件夹,并在其中我们有environment.development.tsenvironment.ts文件。

这些 TypeScript 文件只有一个对象,而这个对象就是我们放置所有需要区分生产环境和开发环境的设置的地点。我们首先将environment.development.ts文件修改如下:

export const environment = {
  production: false,
  apiUrl: 'http://localhost:3000'
};

在这些对象中,我们声明一个标志来指示这是一个开发环境的配置以及我们本地后端服务的 URL。现在我们将environment.ts文件修改如下:

export const environment = {
  production: true,
  apiUrl: 'https://gymdiaryangularboook.azurewebsites.net/api',
};

这里,我们做的是同样的事情,但指示我们的应用程序的生产环境。后端地址将是上一节中创建的那个。

要使用这些文件,我们必须导入它们,并重构HostInterceptor服务以使用它:

. . .
import { environment } from 'src/environments/environment';
@Injectable()
export class HostInterceptor implements HttpInterceptor {
  intercept(
    request: HttpRequest<unknown>,
    next: HttpHandler
  ): Observable<HttpEvent<unknown>> {
    const url = environment.apiUrl;
. . .
}

在我们的拦截器服务中,该服务负责将 URL 添加到我们的请求中(更多详情,见第八章改进后端集成:拦截器模式),我们使用environment对象属性来确定 URL。

这里需要注意的一个点是,我们必须导入environment.ts文件,因为 Angular 在生成构建时会进行更改。

为了清楚地表明我们处于哪个环境,我们将按照以下方式更改AppComponent组件:

. . .
import { environment } from 'src/environments/environment';
import { ToastrService } from 'ngx-toastr';
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
})
export class AppComponent implements OnInit {
  loadService = inject(LoadService);
  toaster = inject(ToastrService);
  title = 'gym-diary';
  ngOnInit(): void {
    if (environment.production) {
      this.toaster.info('Production Build!');
    } else {
      this.toaster.info('Development Build!');
    }
  }
}

在这个更改中,我们使用 toaster 服务(更多详情,请参阅第八章改进后端集成:拦截器模式)来指示,当用户进入页面时,他们处于哪个环境。

让我们使用ng serve命令运行我们的应用程序,我们将得到以下结果:

图 12.8 – 开发模式下的应用

图 12.8 – 开发模式下的应用

如果我们登录到我们的应用程序,我们可以通过查看网络标签中的开发者工具,看到应用程序正在向我们的本地后端发送请求。要作为生产构建运行我们的 Angular 项目,我们可以使用以下命令:

ng serve --configuration production

当访问我们的应用程序时,我们可以在屏幕上的消息中看到请求是针对我们云服务中发布的服务发出的:

图 12.9 – 生产模式下的应用

图 12.9 – 生产模式下的应用

由于我们的服务已为多个环境做好准备,我们现在可以看看如何在下一节中更好地为部署做准备。

准备生产包

在生产环境中运行的前端应用程序的环境需求与我们在书中看到的开发环境不同。

在我们开发时,我们寻求编译速度、强大的调试和性能分析工具来分析我们的代码,以及生成样板代码等功能。

即使在我们的本地机器上处理成本更高,需要更多空间来生成用于调试的仪器化包,以及需要更大的网络消耗来下载开发工具,但所有这些对于团队的生产力来说都是重要的,Angular 框架通过一个强大的生态系统提供了这些功能。

当我们谈论在生产环境中运行的客户端网络代码时,目标几乎相反。我们希望我们的代码尽可能小和优化,以便以最高效的方式下载和执行。

带着这个目标,Angular 框架有一个强大且简单的构建工具,用于生成生产包。

要运行它,我们需要在我们的 project 文件夹中使用以下命令:

ng build

此命令将在我们项目的 dist 文件夹中创建我们将要运行的生产包。

但为了深化我们对 Angular 框架的了解,让我们了解这个构建过程的基础是什么。答案是 angular.json 文件。让我们分析一些构建的重要属性:

"configurations": {
  "production": {
    "budgets": [
      {
        "type": "initial",
        "maximumWarning": "500kb",
        "maximumError": "1mb"
      },
      {
        "type": "anyComponentStyle",
        "maximumWarning": "2kb",
        "maximumError": "4kb"
      }
    ],
    "outputHashing": "all"
  },
  . . .
  "defaultConfiguration": "production"
}

configurations 属性中,我们有我们项目中可以拥有的环境类型的定义。最初,Angular CLI 创建了两个配置:生产模式和开发模式。

在生产配置中,我们有 budgets 属性,它确定我们的包必须具有的最大大小,除了定义单元组件必须具有的最大大小。

如果您的项目超过这个大小,Angular 可能会在生产控制台中显示警告,甚至可能不构建您的项目。

这很重要,因为我们需要生成尽可能小的文件,因为这会导致用户对我们性能的感知更高,尤其是如果他们正在使用 3G 网络上的设备。

减少文件大小的一种方法是通过使用 Angular 的懒加载功能(有关此功能的更多详细信息,请参阅第二章组织 您的应用程序)。

outputHashing 属性确保应用程序生成的文件名称被添加到哈希中。

这很重要,因为大多数公共云和 内容分发网络CDNs)根据文件名缓存应用程序。当我们生成我们应用程序的新版本时,我们希望这个缓存被无效化,以便将新版本提供给我们的用户。

最后,defaultConfiguration 属性确定如果没有传递参数,ng build 命令将执行其中指示的配置,在这种情况下,是生产模式。

这些配置可以根据项目需求进行扩展和创建新配置。在我们的案例中,我们将保留默认配置。

在生产配置中运行构建时,Angular 执行以下过程:

  • 提前编译(AOT):Angular 除了 TypeScript 文件外,还编译模板和 CSS 文件。

  • 生产模式:应用程序有一些针对在生产环境中运行的验证进行了优化。

  • 打包:它将所有组件文件、模板、服务和库打包到按模块分隔的文件中。

  • 压缩:从 TypeScript 生成的文件中,它连接并消除空白和注释,以生成尽可能小的文件。

  • 丑化:它重新编写生成的代码,包括变量、函数名称和小的、神秘的模块,使其难以逆向工程用户浏览器接收到的前端代码。

  • 死代码消除:也称为 摇树,这是不包括在代码中未引用且不需要在生产包中存在的组件的过程。

所有这些过程都是通过 ng build 命令以及创建项目时设置的配置来完成的。重要的是要注意,这个过程会随着 Angular 每个新版本的发布而改进,这也是始终使用最新版本保持项目更新的另一个原因。

在下一节中,我们将创建一个由 Nginx 网络服务器构建和运行的 Docker 镜像。

挂载带有 Nginx 的 Docker 镜像

到目前为止,我们一直在使用 Angular 包中包含的网络服务器来在本地运行我们的应用程序。虽然非常胜任,但它纯粹关注开发者的体验,并且没有生产环境所需的表现力和可扩展性能力。

为了这个目的,我们使用生产级别的网络服务器。其中最受欢迎的是 Nginx(发音为 Engine X)。

要配置它,我们需要在项目的根目录下创建一个名为 nginx.default.conf 的文件,并将其添加以下内容:

server {
  listen 80;
  sendfile on;
  default_type application/octet-stream;
  gzip on;
  gzip_http_version 1.1;
  gzip_disable      "MSIE [1-6]\.";
  gzip_min_length   1100;
  gzip_vary         on;
  gzip_proxied      expired no-cache no-store private auth;
  gzip_types        text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript;
  gzip_comp_level   9;
  root /usr/share/nginx/html;
  location / {
    try_files $uri $uri/ /index.html =404;
  }
}

在这个配置文件中,前三个属性(listensendfiledefault_type)旨在配置暴露的端口并准备服务器发送我们的项目包文件。

gzip 开头的属性配置了使用本地压缩数据 gzip 交付文件,从而进一步减少发送到用户浏览器的文件。

文件的最后部分决定了要服务的第一个页面。因为我们处于 index.html

使用这个配置,我们可以运行 Nginx,但不是在本地机器上原生安装它,而是使用 Docker 来运行它。

Docker 是当今现代系统中广泛使用的工具,旨在将应用程序的环境隔离开来。换句话说,通过配置一个文件,我们可以为我们的应用程序创建一个环境,它可以在我们的本地机器上运行,也可以在具有相同依赖项和版本的云服务提供商上运行。

让我们通过首先在项目的根目录下创建一个名为 .dockerignore 的文件并添加以下内容来举例说明其使用:

node_modules

.gitignore 文件为例,我们确保 node_modules 文件夹不会被复制到镜像中。请记住,这个镜像以及从中运行的(在 Docker 生态系统中称为容器)就像是一台新机器,我们只会复制应用程序运行所需的内容。

下一步是创建 dockerfile 文件,并将其以下代码添加到其中:

FROM node:18-alpine as build
COPY package.json package-lock.json ./
RUN npm ci && mkdir /gym-app && mv ./node_modules ./gym-app/
WORKDIR /gym-app
COPY . .
RUN npm run build
FROM nginx:1.25-alpine
COPY nginx.default.conf /etc/nginx/conf.d/default.conf
RUN rm -rf /usr/share/nginx/html/*
COPY --from=build /gym-app/dist/gym-diary /usr/share/nginx/html
CMD ["nginx", "-g", "daemon off;"]

在这个文件中,我们使用多阶段构建技术来创建我们的镜像。首先,我们构建应用程序,然后使用这个构建的结果来创建最终的镜像。这样,我们的镜像变得更小,更优化。

我们在这里称之为 build 的第一个阶段基于 node:18-alpine 镜像,这是一个包含 Alpine Linux 发行版和 Node.js 18 版本的精简镜像。

然后,复制 package.jsonpackage-lock.json 文件,并运行 npm ci 命令来安装包。

然后,使用 COPY . . 命令,复制所有项目代码(除了 node_module 文件夹)。

在这个阶段的最后,我们的应用程序包是通过 npm run build 命令生成的。

下一个阶段,即生产阶段,基于 nginx:1.25-alpine 镜像,因为运行 Web 服务器,我们只需要安装一个像 Nginx 这样的 Linux 发行版。

下一个任务是复制 Nginx 安装的配置文件,删除工具附带示例文件,并将上一阶段生成的文件复制到这一阶段。

这行 ["nginx", "-g", "daemon off;"] 运行 Nginx 并使其准备好交付我们的应用程序。

要挂载镜像,在 VSCode 中右键单击 dockerfile 文件,并选择 构建 镜像 选项。

要在本地运行 Docker 容器,请使用以下命令:

docker run -p 8080:80 gymdiary

通过访问 http://localhost:8080 URL,我们可以在生产模式下运行我们的应用程序。将我们的项目放在网络上的另一种方式是使用 Azure Static Web Apps。我们将在下一节中处理这个问题。

部署页面到 Azure Static Web Apps

使用我们创建的 Docker 镜像,我们可以在提供容器服务的任何云服务提供商上运行我们的项目。然而,还有其他方法来部署我们的 Angular 项目。

这些替代方案之一是 Azure Static Web Apps,这是一项专注于网页设计的专业服务,并允许与 GitHub 自动集成。让我们在我们的项目中实际看看。

第一个要求是您的项目位于 GitHub 上,如下截图所示:

图 12.10 – 前端项目的 GitHub 存储库

图 12.10 – 前端项目的 GitHub 存储库

如果您已复制项目存储库,请将 gym-diary 文件夹放置在您的 GitHub 项目中。

要配置 Azure 服务,请转到账户门户并搜索 Static Web Apps

点击 创建静态 Web App 按钮,服务表单将呈现给您。

在第一部分,我们有以下字段:

  • 订阅:选择您的 Azure 订阅。

  • 资源组:为该服务创建或定义一个组。在 Azure 中,每个资源都必须链接到一个资源组。

  • 名称:为您的前端项目提供一个名称。

  • 计划类型:选择您环境的层级。资源越多,成本越高,但在这个例子中,我们只会使用免费计划。

图 12.11 – Azure Static Web App 创建

图 12.11 – Azure Static Web App 创建

  • :在这个字段中,我们确定我们的项目是在 GitHub 上还是在 Azure 存储库中。

  • 组织:您想要从中选择存储库的 GitHub 用户或组织的名称。重要的是您的用户具有高访问权限,例如维护者或管理员。

  • 存储库:Azure 将列出您在所选组织中有访问权限的所有存储库。

  • 分支:您想要部署的存储库分支。

图 12.12 – 部署详情配置

图 12.12 – 部署详情配置

在第二部分,我们使用 Angular 为我们的项目配置了特定的设置:

  • 构建预设:Azure 服务支持多种前端技术。在这种情况下,我们将选择 Angular

  • /.

  • API 位置:如果您想指向在 Azure 中部署的后端服务,这是一个可选字段。在这里,在这个例子中,我们将将其留空。

  • dist/gym-diary/.

图 12.13 – 预设设置

图 12.13 – 预设设置

完成后,点击 审查和创建,然后在下一屏幕上确认操作。Azure 将开始处理,一旦准备好,它将显示创建的服务仪表板:

图 12.14 – 创建的服务仪表板

图 12.14 – 创建的服务仪表板

URL 字段中,您将看到 Azure 为我们的项目创建的 URL。选择它,一旦部署状态为 Ready,我们的系统就会立即呈现。因此,我们的项目已经在云端运行。您可以配置其他设置,例如添加您自己的 URL,但请记住,某些设置在免费计划中不可用。

这个功能最有趣的地方是它在我们仓库中实现了一个 GitHub Action:

图 12.15 – GitHub Action

图 12.15 – GitHub Action

什么是 GitHub Action?这是一个 GitHub 功能,允许创建和执行脚本来自动化任务,例如,在我们的例子中,部署到 Azure 服务。

使用我们的配置,Azure 向导在我们的 GitHub 仓库中创建并运行了脚本。

奖励是,我们生成的脚本配置为在每次向仓库推送时执行和部署,更新我们在云端部署的应用程序。

摘要

在本章中,我们探讨了将我们的应用程序部署到生产环境时 Angular 的技术和功能。

我们首先将后端上传到云端,在那里它将为我们的前端应用程序可用。

然后,我们通过使用 Angular 的 environment.ts 文件功能来区分开发环境和生产环境来调整我们的应用程序。

我们探讨了 ng build 命令和 Angular 为我们执行的所有任务,以使我们的应用程序尽可能精简,以便为我们的用户提供更快的速度。

我们学习了 Docker 以及我们如何将我们的 Angular 应用程序打包以在 Nginx 等网络服务器上运行,而不管我们的应用程序运行在哪种类型的机器上。

最后,我们了解了一种使用 Azure 静态 Web 应用服务将应用程序部署到云端的另一种方法,并看到它是如何通过创建 GitHub Action 脚本来自动化此过程的。

在下一章中,我们将探索最新的 Angular 创新,包括 Angular Signals。

第十三章:Angular 的文艺复兴

我们的应用程序需要不断地进化,为了满足这一需求,Angular 框架及其生态系统也在不断进化。

在本章中,我们将了解 Angular 的最新功能。虽然其中许多功能仍处于开发者预览阶段,但对我们来说,了解这个令人难以置信的框架的未来前景非常重要。

本章我们将涵盖以下主题:

  • 使用 Angular CLI 更新你的项目

  • 使用一种新的创建模板的方式——控制流

  • 使用 defer 命令改进用户体验

  • 在页面之间创建过渡——视图事务

  • 简化应用程序状态——Angular 信号

到本章结束时,你将学会如何跟上框架的未来版本,以及如何更新你的项目。

技术要求

要遵循本章的说明,你需要以下内容:

本章的代码文件可在 github.com/PacktPublishing/Angular-Design-Patterns-and-Best-Practices/tree/main/ch13 获取。

在本章的学习过程中,请记住使用 npm start 命令运行 gym-diary-backend 文件夹中的应用程序的后端。

使用 Angular CLI 更新你的项目

Angular 框架在不断地进化,带来新的功能和优化,但为了帮助社区和开发者保持有序,并确保他们的应用程序保持最新,Angular 团队使用语义化版本号来编号他们的发布。

语义化版本号由三个部分组成,每个部分具有以下表示形式:

  • 大版本:每当框架发生变化时,这个数字都会增加,这反过来又要求我们在应用程序中做出一些更改,以便它能够继续工作,也称为破坏性变更

  • 小版本:当新版本有我们可以使用的新功能时,这个数字会增加,但如果我们不使用它,我们不需要更改我们的应用程序

  • 补丁版本:当框架有修正时,我们不需要更改我们的代码;这通常用于有安全修正的版本

在本书中,我们使用的是 Angular 的 16.2.0 版本,下一个版本将是 17.0.0,它将带来新的功能,同时也可能有一些破坏性变更。虽然使用了“破坏性变更”这个术语,但我们应注意的是,Angular 团队对这些变更越来越谨慎,目前,它们只影响非常特定的案例,而绝大多数应用程序不会受到影响。

除了严格的版本控制之外,Angular 团队还注意每隔六个月发布主要版本,这使得团队能够规划应用程序的更新。你可能会问,我是否应该总是更新我的应用程序的 Angular 版本?答案是肯定的,以下是一些原因:

  • 每个新版本都会为框架带来内部改进,这可以改善渲染引擎,使你的应用程序更快,构建时间和包大小更小,更优化。

  • 新功能为你提供了更多可能性,以创造更好的用户体验

  • 它提供安全更新和框架漏洞修复

需要强调的是,Angular 团队致力于在当前版本之前为最多两个主要版本提供修正(长期支持),这意味着使用旧版本的 Angular 可能会使你的应用程序容易受到新的安全漏洞的攻击。然而,更新应用程序的 Angular 版本的任务并不复杂,因为 Angular CLI 有助于自动化整个过程。让我们将我们的项目更新到 Angular 的第 17 版,以使用本章中的新功能。

在你的操作系统命令行中,在 gym-diary 项目文件夹中,使用以下命令:

ng update @angular/core@17 @angular/cli@17

使用此命令,Angular CLI 将从 package.json 文件更新所有 Angular 包。此外,它将分析你的所有代码,寻找由于破坏性更改而需要更改的情况。如果可能,它将为你更新代码。如果不可能,它将指示应进行何种类型的更正,但这种情况仅发生在非常特定的角落案例中。

更新完成后,为确保应用程序在处理过程中继续工作,我们可以运行单元测试和端到端测试。有关测试的更多详细信息,请参阅第十章,“为测试而设计:最佳实践”。

使用我们更新的项目,我们可以探索 HTML 模板的新的语法,我们将在下一节中看到。

使用创建模板的新方法——控制流

自框架的第二个版本以来,HTML 模板语法相对稳定,没有太多变化。通过使用自定义属性,我们可以在组件中评估条件并遍历列表和其他形式的流程控制,以创建可视化逻辑。*ngIf*ngFor*ngSwitch 指令用于提高开发者体验,内部生成 HTML 中的元素。你可以在第四章、“组件”和“页面”中了解更多信息。

从版本 17 开始,Angular 团队引入了 HTML 中的新形式的控制流。这个版本的语法处于开发者预览状态,这意味着它对生产是稳定的,但未来版本可能会有所变化。让我们重构我们的代码以使用这种语法,并看看实际中的差异。

app.component.html 文件中,我们将更改以下内容:

@if (loadService.isLoading) {
  <app-loading-overlay />
}
<router-outlet></router-outlet>

在这里,我们可以注意到新的控制流结构中的第一个新结构,即 if。使用 HTML 模板中的命令并带有 @ 符号,我们应用了与 TypeScript 中的条件语句相同的条件语句,评估函数或变量是否为真或假。

语法的新颖之处在于,我们现在有了 @else 指令,它简化了条件语句的链式操作,无需使用 ng-template 指令来完成此目的。

我们将按照以下方式重构 list-entries.component.html 文件:

<section class="mb-8">
  <h2 class="mb-4 text-xl font-bold">List of entries</h2>
  <ul class="rounded border shadow">
    @for (item of exerciseList; track item.id) {
    <li>
      <app-entry-item
        [exercise-set]="item"
        (deleteEvent)="deleteEvent.emit($event)"
        (editEvent)="editEvent.emit($event)"
      />
    </li>
    } @empty {
      <div>
        No Items!
      </div>
    }
  </ul>
</section>

在这个例子中,我们使用 @for 指令来替换 *ngFor 指令。我们提供了将要接收列表迭代的变量名称,在这个例子中是 item,以及列表本身,在这个组件中命名为 exerciseList

第四章 组件和页面 中,我们学习了使用 *ngFor 指令的 trackBy 属性来提高列表渲染性能的良好实践。现在,这种良好实践在新 @for 语法中是强制性的,并且在这种情况下,它甚至更简单,因为我们只需简单地传递 Angular 应该检查的属性。

新的元素是 @empty 指令,它指示如果相关的列表为空时应该显示什么。

新的 @for 指令,除了提高开发体验外,根据 Angular 团队的说法,在渲染列表时比之前的解决方案快 90%,这是因为控制语句不仅仅是指令的糖语法;模板引擎已经被重新设计,指令操作 Angular 的内部 DOM 渲染元素。

最后,让我们按照以下方式重构 new-entry-form-reactive.component.html 文件:

. . .
@if (entryForm.get('date')?.invalid && entryForm.get('date')?.touched) {
    <div class="mt-1 text-red-500">Date is required.</div>
    }
. . .
@if (showSuggestions) {
    <ul
      class="absolute z-10 mt-2 w-auto rounded border border-gray-300 bg-white"
    >
      @for (suggestion of exercises$ | async; track suggestion.id) {
      <li
        class="cursor-pointer px-3 py-2 hover:bg-blue-500 hover:text-white"
        (click)="selectExercise(suggestion.description)"
      >
        {{ suggestion.description }}
      </li>
      }
    </ul>
    } @if (entryForm.get('exercise')?.invalid &&
    entryForm.get('exercise')?.touched) {
    <div class="mt-1 text-red-500">Exercise is required.</div>
    }
. . .
@if (entryForm.get('reps')?.invalid && entryForm.get('reps')?.touched) {
    <div class="mt-1 text-red-500">
      Reps is required and must be a positive number.
    </div>
    }@else if ( entryForm.get('reps')?.errors?.['isNotMultiple'] &&
    entryForm.get('reps')?.touched) {
    <div class="mt-1 text-red-500">
      Reps is required and must be multiple of 3.
    </div>
    }

在这个文件中,我们正在用 @if 语句替换评估表单错误的条件语句。对于我们用来渲染练习列表的 @for 指令,我们可以注意到,使用 async 管道与 *ngFor 指令非常相似,并且我们添加了 track 以进一步提高列表的渲染。最后,我们使用 @else if 命令来链式操作两个条件语句。

我们可以注意到,我们不需要进行任何额外的配置来使用流程控制语法,因为这项功能与之前的机制完全兼容,它们可以在同一个项目中,甚至在同一个文件中共存。

Angular 团队甚至在 Angular CLI 中创建了一个迁移命令,如下所示:

ng g @angular/core:control-flow

在下一节中,我们将看到这种模板重构为我们应用程序提供的新可能性,即在 HTML 模板中懒加载组件的选项。

使用 defer 命令改进用户体验

新的 HTML 模板流程控制语法的背后主要意图是提供一个新基础,用于在框架的模板中构建新的可能性。通过这种语法,第一个新的功能是 defer 指令,它使得直接从 HTML 模板中懒加载组件成为可能。

我们在第二章中学习了组织你的应用程序,最佳实践是将你的应用程序分为功能模块,并配置 Angular 以懒加载这些模块。这意味着只有当用户访问某个路由时,模块及其组件才会被加载,从而实现更小的包和更好的应用程序性能,特别是如果你的用户没有良好的互联网连接(如 3G)。

defer命令具有相同的目的,但它不是为模块工作,而是为standalone组件工作。我们在第十一章中学习了使用 Angular Elements 的微前端中的standalone组件。

我们将开始重构,将练习列表组件转换为standalone组件。在diary.component.ts文件中,进行以下更改:

@Component({
  standalone: true,
  templateUrl: './diary.component.html',
  styleUrls: ['./diary.component.css'],
  imports: [ListEntriesComponent, NewItemButtonComponent],
})

在前面的代码中,我们包括了将standalone属性设置为true,并直接使用imports属性添加了它所依赖的组件。

我们将在EntryItemComponent组件中执行相同的步骤:

@Component({
  selector: 'app-entry-item',
  standalone: true,
  templateUrl: './entry-item.component.html',
  styleUrls: ['./entry-item.component.css'],
  imports: [DatePipe],
})

在此组件中,除了standalone属性外,我们还需要添加依赖项,以便日期管道能够工作。需要注意的是,standalone组件需要在imports属性中声明式地包含其依赖项,因为它没有链接到任何 Angular 模块。

为了懒加载模板,我们还将NewItemButtonComponent组件转换为standalone组件:

@Component({
  selector: 'app-new-item-button',
  templateUrl: './new-item-button.component.html',
  styleUrls: ['./new-item-button.component.css'],
  standalone: true,
})

最后一个要转换为standalone组件的是ListEntriesComponent,更改如下:

@Component({
  selector: 'app-list-entries',
  standalone: true,
  templateUrl: './list-entries.component.html',
  styleUrls: ['./list-entries.component.css'],
  imports: [EntryItemComponent],
})

在这个例子中,我们将EntryItemComponent依赖项添加到了import属性中。

重要提示

单元测试也进行了调整,以考虑TestBed定义中的组件依赖项,并且你可以在这个章节的 GitHub 仓库中找到测试代码。

最后的调整必须在DiaryModule模块中进行:

@NgModule({
  declarations: [
    NewEntryFormTemplateComponent,
    NewEntryFormReactiveComponent,
  ],
  imports: [
    CommonModule,
    DiaryRoutingModule,
    RouterModule,
    FormsModule,
    ReactiveFormsModule,
  ],
})
export class DiaryModule {}

由于我们将动态加载已转换为standalone的组件,我们必须从模块的declarations属性中删除这些组件。

在此准备之后,我们可以在diary.component.html文件中使用defer命令:

@defer {
  <app-list-entries
    [exerciseList]="exerciseList"
    (deleteEvent)="deleteItem($event)"
    (editEvent)="editEntry($event)"
  />
}

要使用defer命令,我们必须创建一个包含我们想要懒加载的组件的块。

如果我们运行我们的应用程序并分析网络选项卡,我们会注意到当屏幕渲染时,会加载特定的包:

图 12.1 – 懒加载包

图 12.1 – 懒加载包

我们可以看到,效果类似于路由模块的懒加载,但defer有其他有趣的选择。让我们通过更改我们的代码来实际看看:

. . .
@defer (on hover(trigger)){
  <app-list-entries
    [exerciseList]="exerciseList"
    (deleteEvent)="deleteItem($event)"
    (editEvent)="editEntry($event)"
  />
}
. . .
  <button
    #trigger
    class="rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700"
    (click)="newList()"
  >
    Server Sync
  </button>
. . .

通过on hover(trigger)条件,当我们将鼠标悬停在defer命令上时,列表会被加载,这为微调用户体验打开了一系列机会。defer命令有以下条件:

  • on immediate: 组件将在屏幕渲染的瞬间被加载。

  • on idle: 组件将在浏览器requestIdleCallback API 的第一次调用时被加载。这个 API 允许浏览器中的非阻塞处理,并且是defer命令的默认行为。

  • on hover(target): 我们可以定义另一个界面组件,当用户悬停在组件上时将发生加载。

  • on timer(time): 允许我们在界面渲染后以毫秒为单位定义组件将被加载的时间。

  • on viewport(target): 当目标组件位于浏览器视口中时,子组件将被加载。这种行为非常适合在用户滚动到页面底部后加载位于其后的组件。

  • on interaction(target): 它与on hover有类似的行为,但它将由某些交互触发,例如点击。

  • when (condition): 允许我们通过布尔属性或返回布尔值的函数强制控制组件的加载。

补充defer命令,我们还有其他可以使用的命令。回到我们的代码,我们将按以下方式更改它:

@defer {
  <app-list-entries
    [exerciseList]="exerciseList"
    (deleteEvent)="deleteItem($event)"
    (editEvent)="editEntry($event)"
  />
  } @loading {
  <div>Loading</div>
  } @placeholder {
  <div>PlaceHolder</div>
  } @error {
  <div>Error</div>
  }

这些补充命令具有以下功能:

  • @loading: 在加载defer块的组件时,显示该块的内容

  • @placeholder: 在defer块的组件未开始加载时显示该块的内容,例如,如果用户没有悬停在指定的目标上

  • @error: 如果在加载defer块的组件时发生错误,将显示该块的内容

在我们的模板中,使用这个defer命令有许多可能性,我们应该探索它们以提高用户体验。但请注意,我们不应该懒加载屏幕上的所有组件。我们需要在大型组件或对我们正在构建的页面不是必需的组件上使用defer命令。

在下一节中,我们将探讨如何改善我们应用程序中路由之间转换的体验。

创建页面之间的转换 – 视图事务

作为前端开发者,我们需要关注我们应用程序的技术性能。一些小的 UI 细节,例如我们在第八章“改进后端集成:拦截器模式”中创建的加载屏幕,可以提高用户对我们应用程序性能的认识。这些 UI 细节之一是我们应用程序页面之间的转换。我们可以在从一个路由到另一个路由的干燥加载之间创建一个平滑转换的动画,使用户体验更加愉快。

在 Angular 版本 17 之前,可以使用我们之前在书中使用的标准 Angular 动画包来创建此动画,在 第八章 中创建的 toaster 动画,改进后端集成:拦截器模式。创建此动画的方法是特定于 Angular 的,并且对于专注于 CSS 的设计师来说并不简单。

截至 Angular 版本 17,对 app-routing.module.ts 文件有支持:

@NgModule({
  imports: [
    RouterModule.forRoot(routes, {
      bindToComponentInputs: true,
      enableViewTransitions: true,
    }),
  ],
  exports: [RouterModule],
})
export class AppRoutingModule {}

我们正在配置 Angular,以便路由机制将使用带有 enableViewTransitions 属性编写的 CSS 视图过渡。仅通过这一变化,我们就可以在我们的应用程序中注意到页面之间的过渡具有令人愉悦的淡入和淡出动画。这个默认动画是由 Angular 团队创建的,旨在让开发者的生活更轻松。但我们可以用一点 CSS 来自定义这个动画。在 styles.css 文件中,我们将创建以下类:

@keyframes slide-right {
  from {
    transform: translateX(40px);
  }
}
@keyframes slide-left {
  to {
    transform: translateX(-40px);
  }
}
@keyframes fade-in {
  from {
    opacity: 0;
  }
}
@keyframes fade-out {
  to {
    opacity: 0;
  }
}

对于 CSS 动画,我们需要定义一个初始状态和最终状态,我们希望元素处于该状态,在这种情况下,整个屏幕。在我们的示例中,我们定义了一个状态,其中屏幕在 slide-right 关键帧中向右移动,在 slide-left 关键帧中向左移动。最后,我们定义了淡入和淡出效果的关键帧。

注意,当我们定义过渡动画时,我们完全替换了 Angular 的默认过渡动画,因此我们在这里定义了淡入和淡出关键帧。

要设置动画,让我们将以下内容添加到 styles.css 文件中:

::view-transition-old(root) {
  animation: 100ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
  400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-left;
}
::view-transition-new(root) {
  animation: 250ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in,
  400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-right;
}

视图过渡 API 在 CSS 中创建伪元素,其中我们定义了旧页面的退出动画(::view-transition-old)和新页面的进入动画(::view-transition-old)。在这种情况下,我们定义旧屏幕将淡出并向左移动,而新页面将淡入并从右侧滑入。

重要提示

视图过渡 API 于 2023 年创建,并正在逐渐被浏览器采用。请访问 caniuse.com/ 检查您的用户将使用的浏览器是否支持此 API。

在下一节中,我们将探讨 Angular 信号以及我们如何使用它来简化应用程序中的状态控制。

简化应用程序状态 - Angular 信号

控制前端应用程序的状态是开发者的一个重大挑战,因为从本质上讲,界面是动态的,需要响应用户的各种操作。Angular 以其“包含堆栈”的哲学,已经拥有了适合这项任务的工具,我们在第五章、Angular 服务和单例模式第九章使用 RxJS 探索响应性中研究了如何使用这些工具。然而,尽管有效,Angular 社区和团队认识到,对于新开发者以及前端项目中简单的响应性情况来说,它们可能有点复杂。为了填补这一空白,Angular 团队从版本 17 开始,向框架中引入了一个新元素,称为信号。

根据 Angular 文档,信号是一个围绕值的包装器,当该值发生变化时,它会通知消费者。您可以将其与电子表格中的一个单元格相关联的类比。它可以包含一个值,我们可以在其他单元格中创建公式,使用其值来创建其他值。

在重构我们的应用程序之前,让我们用一个更简单的例子来说明这一点:

let a = signal<number>(2);
let b = signal<number>(3);
let sum = computed(() => a() + b());
console.log(sum());

要创建一个信号,我们使用 signal 函数,其中我们定义它将存储的值的类型,并声明它的初始值。信号可以是可写的或只读的;在这种情况下,变量 ab 是可写的。变量 c 也是一个信号,但类型特定,称为计算信号。计算类型在我们的电子表格类比中,是一个包含公式的单元格,您可以通过读取其他单元格的值来确定其值。最后,我们通过简单地像调用函数一样调用信号来读取信号值。此代码片段的结果是值 5

我们现在将更改示例:

let a = signal<number>(2);
let b = signal<number>(3);
let sum = computed(() => a() + b());
console.log(sum());
a.set(9);
console.log(sum());

在这次更改中,我们正在使用 set 方法更新信号 a 的值。当读取 sum 信号时,我们可以注意到值已更新为 12。请注意,计算会实时反应,就像在电子表格中一样。

更新可写信号的值的另一种方法是使用 update 方法:

let a = signal<number>(2);
let b = signal<number>(3);
let sum = computed(() => a() + b());
console.log(sum());
a.set(9);
console.log(sum());
b.update((oldValue) => oldValue * 2);
console.log(sum());

update 方法允许您根据信号中包含的最后一个值来更新信号。

尽管简单,signal 允许许多可能性,因为它可以包含任何类型的值,从原始的数值、字符串和布尔值到复杂对象。

我们将重构我们的项目以使用信号,从 LoadService 服务开始:

export class LoadService {
  isLoading = signal<Boolean>(false);
  showLoader() {
    this.isLoading.set(true);
  }
  hideLoader() {
    this.isLoading.set(false);
  }
}

在这里,我们正在将 isLoading 属性替换为 isLoading 信号,简化了服务。我们将更改 AppComponent 组件模板如下:

@if (loadService.isLoading()) {
  <app-loading-overlay />
}
<router-outlet></router-outlet>

要读取信号的内容,我们就像调用一个函数一样调用它。通常,在模板中调用函数不是一种好做法,因为它会导致不必要的处理。然而,信号被创建和优化为在模板中读取,所以在这种情况下,没有问题。

下一个任务将是重构日记条目列表,以便我们不再管理列表,而是将一切留给ExerciseSetsService服务。我们将首先按照以下方式更改ExerciseSetsService服务:

export class ExerciseSetsService {
. . .
  exerciseList = signal<ExerciseSetList>([] as ExerciseSetList);
  getInitialList() {
    const headers = new HttpHeaders().set('X-TELEMETRY', 'true');
    this.httpClient
      .get<ExerciseSetListAPI>(this.url, { headers })
      .pipe(map((api) => api?.items))
      .subscribe((list) => this.exerciseList.set(list));
  }
  deleteItem(id: string) {
    this.httpClient.delete<boolean>(`${this.url}/${id}`).subscribe(() => {
    this.exerciseList.update((list) =>
      list.filter((exerciseSet) => exerciseSet.id !== id)
    );
    });
  }
. . .
}

在前面的代码块中,我们通过声明它包含ExerciseSetList并使用空列表初始化它来创建exerciseList信号。然后,我们将getInitialList方法更改为根据 API 返回值更新exerciseList信号。我们还更改了delete方法,在删除日记条目后更新信号。

由于我们正在更改函数的行为,我们还需要排除diaryResolver函数,因为现在服务将管理 API 中的查询,而组件将消费创建的信号。

ListEntriesComponent组件中,我们将重构以消费我们创建的信号列表:

export class ListEntriesComponent {
  @Output() editEvent = new EventEmitter<ExerciseSet>();
  @Output() deleteEvent = new EventEmitter<string>();
  private exerciseSetsService = inject(ExerciseSetsService);
  exerciseList = this.exerciseSetsService.exerciseList;
}

在前面的代码块中,我们将组件的input替换为ExerciseSetsService服务,并从中接收exerciseList信号。

我们将按照以下方式更改ListEntriesComponent组件模板:

<section class="mb-8">
  <h2 class="mb-4 text-xl font-bold">List of entries</h2>
  <ul class="rounded border shadow">
  @for (item of exerciseList(); track item.id) {
    <li>
      <app-entry-item
        [exercise-set]="item"
        (deleteEvent)="deleteEvent.emit($event)"
        (editEvent)="editEvent.emit($event)"
      />
    </li>
    } @empty {
      <div>
        No Items!
      </div>
    }
  </ul>
</section>

@for命令已准备好读取信号的内容,包括检查其中包含的值的类型。

要完成此重构,我们将更改'DiaryComponent'组件的模板:

<app-list-entries
  (deleteEvent)="deleteItem($event)"
  (editEvent)="editEntry($event)"
/>

我们已从app-list-entries组件中删除了练习列表,因为它将自行管理状态。

在更改模板后,我们可以更改DiaryComponent组件:

ngOnInit(): void {
  this.exerciseSetsService.getInitialList();
}
deleteItem(id: string) {
  this.exerciseSetsService.deleteItem(id);
}

由于状态现在由ExerciseSetsService服务管理,我们通过仅调用服务的方法来简化组件,无需管理可观察对象的订阅。

通过信号管理状态,我们可以在该屏幕上添加一个新功能。假设我们需要在日记中通知总训练量,即执行的总练习量。

要获取此信息并对诸如条目的删除或包含等事件做出反应,我们可以使用 Angular Signals!

DiaryComponent组件中,我们将进行以下更改:

volume = computed<number>(() =>
  this.exerciseSetsService
    .exerciseList()
    .reduce(
      (volume, exerciseSet) => volume + exerciseSet.reps * exerciseSet.sets,
      0
    )
);

我们创建了一个新的计算信号,称为'volume',并在其中根据'exerciseList'信号中的值进行计算。

要使用这个新信号,让我们更改模板:

<header class="bg-blue-500 py-4 text-white">
  <div class="mx-auto max-w-6xl px-4">
    <h1 class="text-2xl font-bold">Workout diary - Total Volume: {{volume()}} </h1>
  </div>
</header>

我们通过在模板中直接调用信号来消费volume信号。通过运行我们的项目,我们可以注意到这个volume信号会响应我们在练习列表中做出的更改。

图 12.2 – 懒加载包

图 12.2 – 懒加载包

信号是 Angular 团队将不断改进的元素,这将使我们能够更好地控制应用程序的反应性。我们需要注意的一个重要点是,信号不会取代 RxJS;实际上,它们是互补的,因为我们仍然需要可观察者来控制异步流程和更复杂的流程,正如我们在第九章,“使用 RxJS 探索反应性”中学习的那样。

摘要

在本章中,我们探讨了 Angular 框架未来可以为我们提供的可能性。我们学习了如何将我们的项目更新到 Angular 的新版本,这是一个持续的活动,因为框架仍在不断发展。我们了解了 Angular 版本的工作原理以及持续更新我们的项目的重要性,从安全、性能和新功能的角度来看。然后,我们将我们的应用程序更改为使用新的模板表达式,这不仅简化了操作,而且在某些情况下,还可以提高我们应用程序的性能。随着模板表达式的这一改进,我们研究了defer表达式,它允许在模板中延迟加载组件,为我们提供了优化具有复杂组件的界面的新选项。我们还学习了如何使用视图事务 API 来改善用户在页面变化之间的动画体验。最后,我们探索了 Angular 信号,并使用这个补充 RxJs 的新元素简化了我们的应用程序的状态管理。Angular 是一个不断进化的框架,正如我们的用户不断要求新功能一样。在本章中,我们学习了如何保持 Angular 的更新。

posted @ 2025-09-05 09:26  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报