Angular-企业级应用第三版-全-

Angular 企业级应用第三版(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎来到 Angular 企业开发的奇妙世界!独立项目、信号和控制流语法为框架注入了新鲜血液。本书出版时,Angular 17.1 已发布,新增功能将基于信号的组件更接近现实,使 Angular 生态系统始终保持活力。如果这一趋势持续下去,到 Angular 20 时,框架将比以往任何时候都更容易使用,并能够创建任何规模的可信和高性能的应用程序。本书的新版将内容重点放在企业架构上,并继续探索掌握复杂业务需求的高级和可扩展的 Angular 解决方案。

与前版类似,本书经过精心设计,旨在为你提供不可或缺的知识和实用的示例,以便你能够设计、构建和交付健壮的 Angular 应用程序。重点仍然是采用高效和简约的方法——最大限度地发挥 Angular 本身的能力,避免不必要的依赖。这导致代码更加流畅,随着 Angular 快速发展的步伐,维护起来也更加简单。

你从本书中学到的基本概念始终保持活力,即使工具和技术不断演变。以路由为首要架构、Angular 组件、响应式编程模型、强大的路由系统以及直观的模板语法都经受了时间的考验。它们将为你提供一个坚实的基石,随着框架的变化和新版本的出现。

本版对关键主题进行了广泛扩展,包括变化检测、状态管理、解耦组件、模块化设计、路由编排以及与后端系统的集成。你还将学习到企业开发中至关重要的实战技能,如用户认证、数据验证、优化最佳实践和 CI/CD 管道。

这本权威指南分享了构建满足任何业务需求的网络系统的宝贵经验。多年来,代码和内容都经过了行业领先专家的审查和改进,本版也不例外。通过这些专家的集体经验,你可以学习到可操作的食谱、内部技巧和展示专业技术的示例应用程序。

访问配套网站AngularForEnterprise.com,加入社区并保持最新动态。

本书面向对象

这本书是为经验丰富的开发者准备的。如果你是 Angular 的新手,请从angular.dev/tutorials上的优秀教程开始,熟悉基础知识,然后回到这本书。作为一名 Angular 开发者,你将深化对框架的理解,并接触到设计和部署 Angular 应用到生产环境的整个范围。你将学习易于理解和教授他人的 Angular 模式。作为一名自由职业者,你将掌握有效的工具和技术,以安全、自信和可靠地交付你的 Angular 应用。作为一名企业开发者,你将学习编写具有可扩展架构的 Angular 应用程序的模式和实践,并利用流行的企业工具。

本书涵盖的内容

第一章Angular 的架构和概念,介绍了 Angular 作为使用 TypeScript、RxJS 和 NgRx 构建复杂、高性能 Web 应用的成熟平台。它介绍了诸如响应式编程、Flux 模式、独立组件、使用 Signals 的细粒度响应性以及保持 Angular 更新的重要性等关键概念。

第二章表单、Observables、Signals 和 Subjects,涵盖了创建搜索功能、使用表单、启用组件之间的交互、避免内存泄漏、比较命令式和响应式编程、链式调用 API、使用 Signals 提高性能,以及构建一个小型天气应用程序来展示基本的 Angular 概念。

第三章构建企业应用架构,涵盖了在企业 Angular 项目中作为技术负责人或架构师取得成功时的最佳实践和考虑因素,包括成功运行项目的要素、为什么 Angular 适合企业需求、性能优化工具和技术,如 80-20 规则和路由第一架构,以及使用看板进行敏捷规划。

第四章创建以路由为第一线的业务应用,涵盖了使用 Angular CLI 生成项目脚手架和组件,实现品牌和图标,使用 DevTools 调试路由,以及路由第一架构的核心原则——早期定义角色、懒加载、骨架导航、围绕数据实体设计、完成高级 UX 设计、实现无状态和松耦合组件、区分控件和组件,以及使用 TypeScript/ECMAScript 最大化代码重用。

第五章设计身份验证和授权,涵盖了使用 TypeScript 实现基于令牌的身份验证,使用 JWT 进行安全数据处理,构建具有面向对象原则(如继承和抽象类)的可扩展服务,缓存和 HTTP 拦截器的基本原理以保留登录状态,以及用于测试的内存身份验证服务。关键主题是构建安全的身份验证和授权服务,并将 SOLID 原则应用于使其可扩展。

第六章实现基于角色的导航,涵盖了设计条件导航体验,创建用于警报的可重用 UI 服务,使用路由守卫来控制访问,强调服务器端安全性,根据环境动态提供不同的身份验证提供者,以及使用 Firebase 实现身份验证。

第七章与 REST 和 GraphQL API 一起工作,涵盖了使用 MEAN 栈的全栈架构 – 使用 TypeScript 构建 Node.js 服务器,使用 Docker 进行容器化,使用 Docker Compose 进行基础设施即代码,CI/CD 验证,使用 OpenAPI 设计 REST API 和 Apollo 的 GraphQL,在 Express 中实现 JWT 身份验证和 RBAC 中间件,以及使用 HttpClient 和 Apollo 在 Angular 中构建自定义身份验证提供者。关键主题是全栈开发、API 设计、RBAC 和端到端身份验证。

第八章食谱 – 可重用性、表单和缓存,涵盖了在 Angular 中构建可重用表单、指令和用户控件,包括多步骤响应式表单、通过继承和抽象移除样板代码、动态表单元素如日期选择器、自动完成和表单数组、具有输入掩码和自定义组件的交互式控件、通过ControlValueAccessor实现的无缝集成,以及通过提取部分来线性扩展表单复杂性 – 以及布局技术如网格列表。关键主题是可重用性、动态性和交互式表单构建块。

第九章食谱 – 主/详细、数据表和 NgRx,通过使用路由优先架构和食谱来实现业务线应用程序,完成了对主要 Angular 应用程序设计考虑的覆盖,包括编辑用户、解析路由数据、重用组件、构建主/详细视图和数据表、使用 NgRx 或 SignalStore 实现状态管理,比较状态管理选项如 NgRx Data、ComponentStore、Signals、Akita 和 Elf,添加预加载动画和全局加载指示器,通过重构应用程序以使用 SignalStore 来预览 Angular 的基于信号的未来。

第十章使用 CI/CD 发布到生产环境,涵盖了实现持续集成/持续交付管道,强调自动化测试以实现企业中的快速交付,配置 CircleCI 进行 CI,使用 GitHub flow 通过主干开发强制执行质量门,部署到 Vercel 和 Firebase,使用 Docker 和 NPM 脚本的基础设施即代码技术,容器化和部署到 Google Cloud Run,门控 CI 工作流程,使用工作流程和 orbs 的 CircleCI 编排,代码覆盖率指标,以及自动化部署以实现持续交付 – 允许快速迭代和共享应用程序构建。

附录 A设置开发环境,通过使用 CLI 工具自动化和确保 Windows 和 macOS 之间的一致性来设置高效的 Angular 开发环境,创建初始 Angular 项目,优化 VS Code 配置,实施自动 linting 和修复以执行编码标准并捕获错误,通过脚本记录团队规范,以及标准化环境和编码风格如何提高团队生产力和故障排除。

为了充分利用本书

  • 按照附录 A,设置开发环境中涵盖的脚本设置您的系统以进行网络开发。

  • 如果你刚开始接触 Angular,请完成angular.dev/tutorials上的教程。

  • 遵循每章开头的技术要求部分以及章节内的信息框。

  • 在 GitHub 上查看最新的代码示例github.com/duluca

  • angularforenterprise.com注册配套网站以完成自我评估。

  • 对于初学者、新接触 Angular 的开发者或经验不足的开发者:

    1. 按照出版顺序阅读本书,并在每一章的内容旁边编码你的解决方案。

    2. 熟悉全栈网络开发有助于,但不是先决条件。

下载示例代码文件

你可以在 GitHub 上获取示例代码文件的最新版本。本书直接支持以下四个项目:

  1. 网络开发环境设置脚本github.com/duluca/web-dev-environment-setup

  2. 本地天气应用github.com/duluca/local-weather-app

  3. LemonMartgithub.com/duluca/lemon-mart

  4. LemonMart 服务器github.com/duluca/lemon-mart-server

在每一章中,你可以找到访问特定章节代码示例的特定说明,这些示例被称为阶段。在演示持续集成和部署配置时,使用 Git 分支和 GitHub 拉取请求来演示特定的配置元素。

注意,GitHub 上的代码可能与本书内容不同,因为 Angular 在不断发展。

下载彩色图像

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

使用的约定

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

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

代码块设置如下:

{
  "name": "local-weather-app",
  "version": "0.0.0",
  "license": "MIT",
  ...
} 

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

 "scripts": {
    "ng": "ng",
    "start": "ng serve",
    **"build"**: **"ng build"**,
    **"test"**: **"ng test"**,
    "lint": "ng lint",
    "e2e": "ng e2e"
  }, 

任何跨平台或 macOS 特定的命令行输入或输出如下所示:

$ brew tap caskroom/cask 

Windows 特定的命令行输入或输出如下所示:

PS> Set-ExecutionPolicy AllSigned; iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1')) 

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

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

技巧和窍门如下所示。

联系我们

我们读者的反馈总是受欢迎的。

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

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

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

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

分享您的想法

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

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

下载这本书的免费 PDF 副本

感谢您购买这本书!

你喜欢在旅途中阅读,但无法随身携带你的印刷书籍吗?

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

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

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

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

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

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

二维码描述自动生成

packt.link/free-ebook/9781805127123

  1. 提交您的购买证明

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

第一章:Angular 的架构和概念

Angular 是一个流行的单页应用(SPA)框架,用于构建 Web 应用。它通常在企业应用开发中更受欢迎,因为它是一个有观点、包含电池的框架,支持使用 TypeScript 进行类型检查,以及像依赖注入(DI)这样的概念,允许大型团队通过这些概念构建可扩展的解决方案。相比之下,React 是一个灵活且无观点的库,而不是一个完整的框架,需要开发者从社区中选择他们喜欢的风味来构建功能齐全的应用。

React 无疑是两者中更受欢迎的选择。数据不会说谎。React 更容易的学习曲线和看似小巧简单的起点吸引了众多开发者的注意。你无疑在网上遇到了许多“Angular vs React”的文章,这些文章增加了混乱。这些文章通常过于肤浅,经常包含关于 Angular 的误导性信息,并且缺乏对 Angular 光明的未来的洞察。

本章旨在帮助你更深入地理解为什么 Angular 存在,你可以利用哪些模式和范式来解决复杂问题,以及,在本书的后期,在扩展你的解决方案时需要避免的陷阱。花时间阅读这些材料很重要,因为每一次旅程都是从选择开始的。你今天的选择的真实故事只能在项目进行几年后才能写出来,那时切换技术已经太晚且成本高昂。

本章涵盖了以下主题:

  • 两个 Angular

  • Web 框架的简要历史

  • Angular 及其背后的哲学

  • 组件架构

  • 响应式编程

  • 模块化架构

  • 独立架构

  • Angular 路由器

  • 状态管理

  • React.js 架构

  • Angular 的未来

第二章表单、可观察者、信号和主题,涵盖了 Angular 的基本概念和构建块。第三章构建企业应用,涵盖了交付大型应用的技术、架构和工具问题。通过第四章创建以路由器为第一线的业务应用,我们深入探讨创建适用于企业的可扩展 Angular 应用。

每一章都介绍新的概念,并逐步构建在最佳实践之上,同时涵盖使用流行的开源工具的最优工作方法。在这个过程中,提示和信息框提供了额外的背景和历史,编号步骤和项目符号,描述了你需要采取的行动。

本书提供的代码示例是使用 Angular 17 开发的。自第二版以来,JavaScript 和 Angular 生态系统发生了重大变化。转向 Angular 的 Ivy 引擎意味着一些第三方工具停止工作。ESLint 取代了 TSLint。Karma 和 Jasmine 已经过时,被 Jest 或更现代的 Vitest 所取代。在用ES 模块ESM)替换commonjs模块方面取得了重大进展。这些变化的总体影响是,第二版的大部分辅助工具都无法修复。作为经验教训,现在的示例项目使用了最少的工具,以便在安装尽可能少的 npm 包的情况下实现最佳可能的开发体验。本书的核心示例有意避免了第三方库,最初是为 Angular 5 编写的,并经受了时间的考验。本书采用 Angular Evergreen 的口号,并鼓励逐步、主动和及时地升级你的依赖项,以保持项目和团队的健康。

本书由配套网站AngularForEnterprise.com支持。访问该网站获取最新新闻和更新。

JavaScript、TypeScript 和 Angular 的世界不断变化。为了保持读者的连贯性,我发布了一系列开源项目,以支持本书的内容:

计算机屏幕截图  自动生成的描述

图 1.1:支持本书的代码

上面的图表显示了构成支持本书的技术内容的各个组成部分。每个组件将在接下来的章节中详细介绍。代码示例包含逐章快照和代码的最终状态。本书的样本代码的最新版本可在以下链接的 GitHub 仓库中找到:

你可以在补充阅读材料《保持 Angular 和工具常青》中了解更多关于更新 Angular 的信息,该材料可在angularforenterprise.com/evergreen找到。

现在你已经熟悉了本书的结构和辅助内容,在我们深入探讨网络的漫长历史之前,让我们首先区分 Angular 的两大主要架构以及推动 2016 年框架大幅重写的潜在主题。

两个 Angular

在其原始版本 Angular.js(即 1.x),开创了单页应用(SPA)时代,这是一种欺骗浏览器认为单个 index.html 包含一个包含多个页面的交互式应用的技术。Angular.js 还普及了双向绑定在网页开发中的概念,它自动将视图更新以匹配 ViewModel 的状态。为了实现这一功能,Angular.js 使用 变更检测 来跟踪浏览器中的 文档对象模型DOM)元素和应用程序的 ViewModel 状态。

变更检测依赖于一个复杂的渲染循环来检测用户交互和其他事件,以确定应用程序是否需要响应变化。每当涉及渲染循环时,如在游戏中,性能可以测量为每秒帧数,即 每秒帧数FPS)。缓慢的变更检测过程会导致低 FPS 数,转化为不流畅的 用户体验UX)。随着对更多交互性和复杂网络应用的需求增加,很明显,Angular.js 的内部架构无法通过提高以保持一致的 FPS 输出来进行改进。然而,用户体验和性能只是体验故事的一方面。随着应用程序变得更加复杂,需要更好的工具来支持优秀的 开发者体验DevEx)——有时称为 DevXDX),这对于开发者的福祉至关重要。

Angular 2 重写,现在简称为 Angular,旨在解决问题的两个方面。在 React、Angular 和 Vue 等框架和库出现之前,我们遭受着未管理的复杂性和“每周 JavaScript 框架”综合症的困扰。这些框架通过承诺解决所有问题、带来通用可重用网络组件以及使学习、开发和扩展网络应用变得更加容易而取得成功——至少在一段时间内,某些框架在不同时期表现得比其他框架更好。随着对越来越复杂的网络体验的需求增加,以及解决这些问题的工具变得越来越复杂,早期单页应用(SPA)所面临的相同问题正在回归。要掌握 Angular 或任何其他现代框架,了解网络开发的过去、现在和未来至关重要。网络发展的青少年历史教会了我们几个重要的教训。首先,变化是不可避免的,其次,开发者的幸福是一种宝贵的商品,它可以使整个公司兴衰。

正如你所见,Angular 的发展深受性能、用户体验(UX)和开发者体验(DevEx)等因素的影响。但这并非仅影响 Angular 的独特问题。让我们将时钟倒退至过去二十五年左右,回顾一下网络开发的历史,以便你能将现代框架如 Angular、React 和 Vue 放置在正确的背景中进行理解。

网络框架简史

理解为什么我们最初使用像 Angular、React 或 Vue 这样的框架至关重要,这样才能真正体会到它们带来的价值。随着网络的不断发展,你可能会发现,在某些情况下,框架不再是必需的,应该被舍弃;而在其他情况下,框架对你的业务至关重要,必须保留。随着 JavaScript 在浏览器中的普及和强大,Web 框架应运而生。在 2004 年,异步 JavaScript 和 XMLAJAX)技术变得非常流行,用于创建不需要依赖完整页面刷新来创建动态体验的网站,利用了标准化的 Web 技术,如 HTML、JavaScript/ECMAScript 和 CSS。浏览器供应商应该按照万维网联盟W3C)的定义来实现这些技术。

Internet ExplorerIE)是当时大多数互联网用户依赖的浏览器。微软利用其市场主导地位来推广专有技术和 API,以确保 IE 作为首选浏览器的优势。当 Mozilla 的 Firefox 挑战 IE 的主导地位,随后是 Google 的 Chrome 浏览器出现时,事情开始变得有趣。随着这两个浏览器成功获得显著的市场份额,Web 开发领域变得混乱。新的浏览器版本以惊人的速度出现。竞争的厂商和技术利益导致了 Web 标准实现的分歧。

这种碎片化环境使得开发者难以在网络上提供一致的用户体验。各种标准的实现质量、版本和名称的差异创造了一个巨大的挑战:成功编写能够一致操作浏览器 DOM 的代码。即使是浏览器 API 和功能的最微小差异也足以破坏一个网站。

jQuery 时代

在 2006 年,jQuery 被开发出来以平滑 API 和浏览器能力之间的差异。因此,你不必反复编写代码来检查浏览器版本,你可以使用 jQuery,一切就绪。它隐藏了供应商特定实现的复杂性,并在功能缺失时优雅地填补了空白。在近十年里,jQuery 成为了 Web 开发框架。没有使用 jQuery 就无法编写交互式网站的想法是不可想象的。

然而,为了创建充满活力的用户体验,仅使用 jQuery 是不够的。原生 Web 应用程序在浏览器中运行所有代码,这需要快速的计算机来运行动态解释的 JavaScript 并使用复杂的对象图来渲染网页。在 2000 年代,许多用户在相对较慢的计算机上运行过时的浏览器,因此用户体验并不理想。

结合 AJAX,jQuery 使任何 Web 开发者都能够创建在任何浏览器上运行的交互式和动态网站,而无需运行昂贵的服务器硬件和软件。为了对客户端和服务器上运行的代码的架构细微差别有一个坚实的理解,可以考虑传统的三层软件架构。每个层级由三个主要层组成,如下面的图所示:

图片

图 1.2:三层软件架构

表示层包含与用户界面UI)相关的代码。这主要是运行在客户端的代码,被称为胖客户端。然而,表示逻辑可以驻留在服务器上。在这些情况下,客户端变成瘦客户端。业务层包含业务逻辑,通常位于服务器端。无序的实现可能导致业务逻辑跨越所有三层。这意味着一个错误或逻辑的变化需要在许多位置实现。实际上,没有人能够定位到这种逻辑的所有发生位置,只能部分修复代码。这当然会导致更复杂的错误产生。持久层包含与数据存储相关的代码。

为了编写易于维护且无错误的代码,我们的整体设计目标是追求架构组件之间的低耦合和高内聚。低耦合意味着这些层之间的代码片段不应相互依赖,并且应该是可独立替换的。高内聚意味着相关的代码片段,如特定业务逻辑领域的代码,应保持在一起。例如,当构建一个用于管理餐厅的应用程序时,预订系统的代码应在一起,而不是分散在其他系统,如库存跟踪或用户管理。

使用 jQuery 和 AJAX,编写 Web 的胖客户端成为可能,这使得编写难以维护的代码变得比以往任何时候都容易。现代 Web 应用程序比基本的三层应用程序有更多的移动部件。下面的图显示了适合表示层、业务层和持久层的额外层:

图片

图 1.3:现代 Web 架构

您可以在扩展的架构图中观察到现代 Web 开发的必要组件,该图包括一个通常在表示层和业务层之间转换和传输数据的 API 层。除了操作环境中的代码之外,工具和最佳实践层定义并强制执行用于开发软件的模式。最后,测试层定义了一系列自动化测试,以确保代码的正确性,这在当今的迭代和快速发展的开发周期中至关重要。

虽然有很多人希望用厚客户端民主化 Web 开发,主要消耗客户端计算资源,但工具还没有准备好执行适当的架构实践并交付可维护的软件。这意味着企业继续投资于服务器端渲染技术。

服务器端 MVC 时代

在 2000 年代后期,许多企业仍然依赖于服务器端渲染的网页。服务器动态创建渲染页面所需的全部 HTML、CSS 和数据。浏览器充当了一个被美化的查看器,用于显示结果。以下是一个展示 ASP.NET MVC 堆栈中服务器端渲染的 Web 应用程序示例架构概述的图表:

图 1.4:服务器端渲染的 MVC 架构

模型-视图-控制器MVC)是一种典型的代码模式,其中模型包含数据操作逻辑,控制器包含业务逻辑,视图包含展示逻辑。在 ASP.NET MVC 的情况下,控制器和模型使用 C#编写,视图使用 HTML、JavaScript 和 C#的模板版本创建。结果是浏览器接收 HTML、JavaScript 和所需数据,通过 jQuery 和 AJAX 魔法,网页看起来是交互式的。服务器端渲染和 MVC 模式至今仍然流行并在使用中。有一些合理的利基用途,例如Facebook.com。Facebook 服务于从非常慢到非常快的数十亿设备。没有服务器端渲染,Facebook 无法保证其用户群的一致 UX。

服务器端渲染和 MVC 的组合是一个复杂的执行模式;在展示和业务逻辑混合方面有很多机会。为了确保组件的低耦合,工程团队中的每一位成员都必须非常经验丰富。高比例资深开发者的团队很难找到,这还是一种轻描淡写。

更进一步的是,C#(或任何其他服务器端语言)不能在浏览器中本地运行。因此,从事服务器端渲染应用程序的开发者必须同样擅长使用前端和后端技术。在这样实现中,缺乏经验的开发者很容易无意中将展示和业务逻辑混合在一起。当这种情况发生时,原本运行良好的系统的必然 UI 现代化变得不可能。换句话说,要更换厨房中的水槽,你必须翻新整个厨房。由于架构不足,组织每十年都要花费数百万美元来编写和重写相同的应用程序。

丰富客户端时代

在 2000 年代,使用 Java Applets、Flash 或 Silverlight 等技术可以构建与服务器 API 解耦的丰富 Web 应用程序。然而,这些技术依赖于浏览器插件,需要单独安装。通常,这些插件已经过时,创建了关键的安全漏洞,并且在移动计算机上消耗了过多的电力。在 2008 年 iPhone 革命之后,很明显,即使 Android 操作系统做出了最好的努力,这样的插件也无法在手机上运行。此外,苹果公司 CEO 史蒂夫·乔布斯对这种不优雅的解决方案的厌恶标志着在浏览器中对这种技术支持结束的开始。

在 2010 年代初,像 Backbone 和 AngularJS 这样的框架开始出现,展示了如何以原生感觉和速度构建丰富的 Web 应用程序,并且似乎以成本效益的方式做到这一点。以下图表展示了一个模型-视图-视图模型MVVM)客户端和一个表示状态转换REST)API。当我们通过 API 将客户端从服务器解耦时,我们可以在架构上强制实施将表示和业务逻辑分开实现。从理论上讲,这种 RESTful Web 服务模式将允许我们尽可能多地替换厨房用具,而无需重新装修整个厨房。

图片

图 1.5:富客户端解耦 MVVM 架构

上述 MVVM 架构与服务器端 MVC 架构相比,箱子的数量几乎翻了一番。这意味着我们需要编写两倍多的代码吗?是的,也不完全是。是的,我们需要编写更多的代码来维护一个有纪律的架构;然而,随着时间的推移,我们将因为解决方案的整体可维护性而编写更少的代码。围绕表示逻辑的架构确实变得更加复杂。客户端和服务器必须实现它们的表示/API、业务和持久化层。

不幸的是,许多利用 Backbone 和 AngularJS 等框架的早期开发工作因为未能正确实现客户端架构而失败。

这些早期的开发工作也受到了设计不良的 RESTful Web API 的困扰。大多数 API 没有对其 URI 进行版本控制,这使得在支持现有客户端的同时引入新功能变得非常困难。此外,API 经常返回复杂的数据模型,将它们的内部关系数据模型暴露给 Web 应用程序。这种设计缺陷在用 HTML 编写的看似无关的组件/视图和用 SQL 创建的模型之间创建了一个紧密的耦合。如果你不实现额外的代码层来转换或映射数据结构,那么你将在层之间创建一个无意和不受控制的耦合。随着时间的推移,处理这种耦合变得非常昂贵,在大多数情况下需要重大的重写。

今天,我们在将数据模型发送到客户端之前,使用 API 层将其扁平化,以避免出现此类问题。像 GraphQL 这样的新技术更进一步,通过暴露一个定义良好的数据模型,并允许消费者查询所需的确切数据。使用 GraphQL,HTTP 请求的数量和通过网络传输的数据量是最优的,而无需开发者创建许多专门的 API。

Backbone 和 AngularJS 证明了在浏览器中本地运行 Web 应用程序是可行的。当时所有的 SPA 框架都依赖于 jQuery 进行 DOM 操作。同时,网络标准持续发展,支持新标准的永续浏览器变得司空见惯。然而,变化是永恒的,网络技术的发展使得第一代 SPA 框架的优雅演进变得不可持续,正如我在“两个 Angular”部分所暗示的。

新一代 Web 框架需要解决许多问题;它们需要强制实施良好的架构,设计用于与网络标准同步发展,并且要稳定且可扩展以满足企业需求,而不会崩溃。此外,这些新框架需要获得开发者的认可,因为开发者已经厌倦了生态系统中太多的快速变化。记住,不快乐的开发者不会创造成功的业务。实现这些目标需要与过去彻底决裂,因此 Angular 和 React 作为平台以不同的方式解决了过去的问题。正如你将在接下来的章节中发现的那样,Angular 提供了构建可扩展企业级应用程序的最佳工具和架构。

Angular 及其背后的哲学

Angular 是由 Google 和一群开发者维护的开源项目。新的 Angular 平台与您可能使用过的传统框架大相径庭。在与微软合作的过程中,Google 将 TypeScript 设为 Angular 的默认语言。TypeScript 是 JavaScript 的超集,它使开发者能够针对旧版浏览器,如 Internet Explorer 11,同时允许他们编写在现代浏览器(如 Chrome、Firefox 和 Edge)中运行的现代 JavaScript 代码。Angular 1.x 范围内的传统版本,称为 AngularJS,是一个单体 JavaScript SPA 框架。现代版本,Angular 2+,是一个能够针对浏览器、混合移动框架、桌面应用程序和服务器端渲染视图的平台。

在上一代中,升级到 AngularJS 的新版本是风险和成本高昂的,因为即使是微小的更新也引入了新的编码模式和实验性功能。每次更新都会引入弃用或重构 API 表面,需要重写大量代码。此外,更新以不确定的间隔交付,使得团队无法计划资源以升级到新版本。发布方法最终导致了一个不可预测、不断演变的框架,似乎没有任何指导之手来推动代码库向前发展。如果你使用 AngularJS,你很可能会卡在特定版本上,因为你的代码库的特定架构使得迁移到新版本非常困难。2018 年,Angular 团队发布了 AngularJS 的最后一个主要更新,版本为 1.7。这次发布标志着旧框架的终结,其生命周期的结束定于 2022 年 1 月。

确定性发布

Angular 在各个方面都优于 AngularJS。该平台遵循semver.org/中定义的semver,其中次要版本增量表示新功能添加和下一个主要版本的潜在弃用通知,但没有破坏性更改。此外,谷歌的 Angular 团队承诺了一个确定性的发布计划,每 6 个月发布一个主要版本。在此 6 个月的开发窗口之后,从 Angular 4 开始,所有主要版本都收到 LTS 支持,包括额外的 12 个月的错误修复和安全补丁。从发布到生命周期的结束,每个主要版本都会收到 18 个月的更新。请参考以下图表,了解 Angular 的预期发布和支持计划:

图 1.6:活跃支持版本

这对你意味着什么?你可以确信,即使你对 Angular 代码没有任何改动,你的 Angular 代码大约在 24 个月内都将得到支持和向后兼容。例如,如果你在 2023 年 11 月使用版本 17 编写了一个 Angular 应用,并且你没有使用任何弃用功能,你的代码将与 Angular 18 运行时兼容,并且支持到 2025 年 5 月。要将你的 Angular 17 代码升级到 Angular 19,你必须确保你没有使用 Angular 18 中收到弃用通知的任何弃用 API。

在实践中,大多数弃用都是微小的,并且重构起来非常直接。除非你使用针对高度专业用户体验的低级 API,否则更新你的代码库所需的时间和精力应该是微不足道的。然而,这是谷歌做出的承诺,而不是合同。Angular 团队有重大动力确保向后兼容性,因为谷歌在组织内部运行着大约 1,000 多个 Angular 应用,任何时候只有一个版本的 Angular 处于活跃状态。因此,在你阅读这段文字的时候,谷歌的所有 1,000 多个应用都将运行在 Angular 的最新版本上。

首要升级

您可能认为谷歌有无限的资源来定期更新数千个应用程序。像任何组织一样,谷歌的资源也是有限的。为每个应用程序分配一个专门的团队来维护将过于昂贵。因此,Angular 团队必须通过自动化测试确保兼容性,并尽可能使未来通过主要版本变得无痛。在 Angular 6 中引入了ng update,使更新过程成为一流的体验。

Angular 团队通过自动化 CLI 工具不断改进其发布流程,使升级过时的功能成为一个主要自动化的、合理的努力。法国航空和荷兰皇家航空展示了这种策略的好处,将他们的升级时间从 Angular 2 的 30 天减少到 Angular 7 的 1 天。

可预测且得到良好支持的升级过程对开发者和组织来说是个好消息。您不必永远停留在 Angular 的旧版本上,您可以计划和分配必要的资源,以保持您的应用程序不断向未来迈进,而无需昂贵的重写。正如我在 2017 年的博客文章《Angular 4 的最佳新功能》中写道,bit.ly/NgBestFeature,信息很明确:

对于开发者和管理者:Angular 将长期存在,因此您应该投资时间、注意力和金钱来学习它——即使您目前对某些其他框架情有独钟。

对于决策者(CIOs、CTOs 等):计划在接下来的 6 个月内开始您的 Angular 迁移。这将是一项您可以向有商业头脑的人解释的投资,您的投资将在未来多年内带来回报,远远超过初始 LTS 窗口到期,并且有优雅的升级路径到 Angular vNext 以及更远。

那么为什么谷歌(Angular)和微软(TypeScript 和 Visual Studio Code)会免费提供这样的技术?有多个原因:

  • 一个复杂的框架使得开发 Web 应用程序变得容易,这展示了技术实力,可以保留和吸引开发者人才。

  • 一个开源框架使得数百万开发者可以大规模地验证和调试新想法和工具。

  • 允许开发者创造出色的 Web 体验,为谷歌和微软带来更多商业机会。

我在这里看不到任何恶意意图,并欢迎开放、成熟和高品质的工具,如果需要,我可以对其进行调整以适应我的意愿。不必为专有技术的支持合同付费是一个受欢迎的额外好处。

警惕 - 在网上寻找 Angular 帮助可能会很棘手。您需要区分 AngularJS 或 Angular,它们可能被称为 Angular2,但也要意识到,关于版本 13 或以下的一些建议可能不适用于 14+,因为渲染引擎已更改为 Ivy。我总是建议在学习时阅读官方文档。Angular 的文档在angular.dev。这不应与angularjs.org混淆,后者是关于遗留的 AngularJS 框架或已退役的angular.io网站。

要查看即将发布的 Angular 版本的最新更新,请查看官方发布时间表angular.dev/reference/releases

可维护性

您的时间宝贵,您的幸福至关重要,因此您必须仔细选择投入时间的科技。考虑到这一点,我们必须回答为什么 Angular 是您应该学习超过 React、Vue 或其他技术的工具。Angular 是一个伟大的框架,适合开始学习。该框架和工具可以帮助您快速起步并持续成功,拥有充满活力的社区和高质量的 UI 库,您可以使用它们来交付卓越的 Web 应用程序。React 和 Vue 是具有其优势和劣势的伟大库。每个工具都有其位置和目的。

在某些情况下,React 是项目的正确选择,而在其他情况下,Vue 才是正确的选择。在某种程度上精通其他 Web 框架只能帮助您进一步理解 Angular,并使您成为一名更好的开发者。在 2012 年,当我意识到解耦前端和后端关注点的重要性时,Backbone 和 AngularJS 这样的 SPA(单页应用程序)完全吸引了我的注意力。服务器端渲染的模板几乎无法维护,并且是许多昂贵的软件系统重写的根本原因。如果您关心创建可维护的软件,您必须遵守首要指令:保持 API 背后的业务逻辑与在 UI 中实现的展示逻辑解耦。

Angular 完美符合帕累托原则或 80-20 法则。它已成为一个成熟且不断发展的平台,让您可以用 20%的努力完成 80%的任务。如前所述,每个主要版本都支持 18 个月,创造了一个学习的连续性,保持最新,并弃用旧功能。从全栈开发者的角度来看,这种连续性是无价的,因为您的技能和培训将在未来许多年里保持相关和新鲜。

Angular 的哲学是在配置和惯例之间倾向于配置。尽管基于惯例的框架在外观上可能看起来很优雅,但它们让新来者难以掌握框架。基于配置的框架旨在通过显式的配置和钩子来揭示其内部工作原理,你可以在这些钩子中附加你的自定义行为到框架中。本质上,AngularJS 有很多魔法,这些魔法可能会让人困惑、不可预测,并且难以调试,而 Angular 则试图避免这些魔法。

配置优于惯例导致代码冗长。冗长是好事。简洁的代码是可维护性的敌人,只对原始作者有利。正如 Andy Hunt 和 David Thomas 在 《实用程序员》 中所说:

记住,你(以及你之后的人)将多次阅读代码,但只编写几次。

此外,Andy Hunt 的 设计法则 规定:

如果你不能轻易地移除每一部分,那么设计就是失败的。

详尽、解耦、一致和封装的代码是确保你的代码面向未来的关键。通过其各种机制,Angular 使这些概念得到适当的执行。它消除了 AngularJS 中许多自定义惯例,如 ng-click,并引入了一种更自然的语言,它建立在现有的 HTML 元素和属性之上。因此,ng-click 变成了 (click),它扩展了 HTML 而不是取代它。

接下来,我们将回顾 Angular 的持续更新心态和响应式编程范式,这是 Angular 初始哲学的最新扩展。

Angular Evergreen

当你在学习 Angular 时,你不仅仅是在学习 Angular 的一个特定版本,而是在学习一个不断发展的平台。自从最初的草案以来,我设计这本书时就是为了淡化你所使用的 Angular 的特定版本。Angular 团队支持这一理念。多年来,我与 Angular 团队以及社区中的思想领袖进行了许多对话,并聆听了许多演讲。因此,你可以依赖 Angular 作为成熟的 Web 开发平台。Angular 经常会进行更新,并且非常注重向后兼容性。此外,任何因新版本而变得不兼容的代码,都可以通过自动化工具的帮助或通过在 angular.dev/update 上定位 Angular 更新指南 来获得明确的指导来更新你的代码,这样你就永远不会感到困惑或在网上寻找答案。Angular 团队致力于确保你——开发者——拥有最佳的 Web 开发体验。

为了将这个理念清晰地传达给开发者,我和几位同事一起开发并发布了一个名为 Angular Evergreen 的 Visual Studio Code 扩展,如下面的图像所示:

图 1.7:Angular Evergreen VS Code 扩展

此扩展检测您当前的 Angular 版本,并将其与 Angular 的最新和下一个版本进行比较。标记为“下一个”的版本是为早期采用者和测试您的代码与即将推出的 Angular 版本的兼容性而设计的。不要在生产部署中使用标记为“下一个”的版本。

您可以在Angular Evergreen 扩展的网站上找到更多信息、功能请求和错误报告。

Angular 的一个关键组件是 TypeScript,它使平台能够保持始终如一。TypeScript 允许高效地实现新功能,同时支持旧浏览器,因此您的代码可以触及尽可能广泛的受众。

TypeScript

Angular 使用 TypeScript 编码。微软的安德斯·海尔斯伯格创建了 TypeScript,以解决在大型企业规模上应用 JavaScript 的几个主要问题。

安德斯·海尔斯伯格是 Turbo Pascal 和 C# 的创造者,以及 Delphi 的首席架构师。安德斯设计了 C# 以成为一个对开发者友好的语言,它建立在 C 和 C++ 的熟悉语法之上。因此,C# 成为微软流行的 .NET 框架背后的语言。TypeScript 与 Turbo Pascal 和 C# 以及它们的理念有着相似的血统,这使它们取得了巨大的成功。

JavaScript 是一种动态解释语言,浏览器在运行时解析和理解您编写的代码。像 Java 或 C# 这样的静态类型语言有一个额外的编译步骤,编译器可以在编译时捕获编程和逻辑错误。在编译时与运行时检测和修复错误要便宜得多。TypeScript 通过引入类型和泛型将静态类型语言的优点带到了 JavaScript。然而,TypeScript 并不是传统意义上的编译器。它是一个转译器。编译器使用 C/C++ 将代码编译成机器语言,或者使用 Java 或 C# 的中间语言IL)。然而,转译器将代码从一种方言转换成另一种方言。因此,当 TypeScript 代码被构建、编译或转译时,结果是纯 JavaScript。

JavaScript 的官方名称是 ECMAScript。该语言的特性和语法由 ECMA 技术委员会 39,简称 TC39 维护。

转译还有另一个显著的好处。将 TypeScript 转换为 JavaScript 的相同工具也可以用来将 JavaScript 重新编写为旧语法,以便旧浏览器可以解析和执行。在 1999 年到 2009 年间,JavaScript 语言没有看到任何新特性。由于各种技术和政治原因,ECMAScript 放弃了版本 4。浏览器供应商在他们的浏览器中实施新的 JavaScript 特性时遇到了困难,从 ES5 的引入开始,然后是 ES2015(也称为 ES6)。

因此,用户对这些新特性的采用率一直很低。然而,这些新特性意味着开发者可以更高效地编写代码。这产生了一个被称为 JavaScript 特性差距的差距,如下面的图形所示:

图片

图 1.8:JavaScript 特性差距

JavaScript 特性差距正在缩小,因为 TC39 承诺每年更新 JavaScript。因此,TypeScript 代表了 JavaScript 的过去、现在和未来。你可以使用 JavaScript 的未来特性,同时仍然能够针对过去的浏览器,以最大化你能够触及的受众。到 2023 年,这个差距比以往任何时候都要小,ES2022 是一种成熟的语言,得到了每个主要浏览器的广泛支持。

现在,让我们来了解一下 Angular 的底层架构。

组件架构

Angular 遵循 MV* 模式,这是 MVC 和 MVVM 模式的混合体。之前,我们讨论了 MVC 模式。从高层次来看,这两种模式的架构相对相似,如下面的图所示:

图片

图 1.9:MV* 架构

这里的新概念是 ViewModel,它代表连接你的视图到你的模型或服务的粘合代码。在 Angular 中,这种粘合被称为绑定。与像 Backbone 或 React 这样的 MVC 框架必须调用 render 方法来处理它们的 HTML 模板不同,在 Angular 中,这个过程对开发者来说是无缝且透明的。绑定是区分 MVC 应用程序和 MVVM 应用程序的关键。

Angular 应用程序中最基本的单元是一个组件。组件将一个 TypeScript 编写的 JavaScript 类和一个 Angular 模板(由 HTML、CSS 和 TypeScript 编写)结合成一个元素。通过绑定,类和模板就像拼图一样契合在一起,以便它们可以相互通信,如下面的图所示:

图片

图 1.10:组件解剖结构

类是面向对象编程(OOP)的结构。如果你投入时间深入研究 OOP 范式,你会大大提高你对 Angular 的工作原理的理解。OOP 范式允许在组件中注入依赖服务,这样你就可以进行 HTTP 调用或触发显示给用户的 toast 消息,而无需将这种逻辑拉入你的组件或复制你的代码。依赖注入使得开发者能够轻松地使用许多相互依赖的服务,而无需担心这些对象从内存中实例化、初始化或销毁的顺序。

Angular 模板允许通过指令、管道、用户控件和其他组件进行类似的代码重用。这些是封装高度交互式最终用户代码的代码片段。这种交互式代码通常很复杂且错综复杂,必须将其与业务逻辑或展示逻辑隔离,以保持代码的可维护性。

Angular 17 引入了一种新的 控制流语法(处于预览状态),它用 @if 替换了 *ngIf,用 @for 替换了 *ngFor,用 @switch 替换了 *ngSwitch,并引入了 @empty@defer、上下文变量和条件语句。新的语法使得模板更容易阅读,并避免了在独立项目中将遗留指令导入到每个组件中。本书将专门使用控制流语法。

您可以使用 npx ng generate @angular/core:control-flow 命令将现有的模板转换为新的语法。

Angular 应用程序可以通过两种不同的方式创建:

  • NgModule 项目

  • 独立项目

截至 Angular 17,默认的方式是将您的应用程序作为独立项目启动。这种方法有很多好处,如以下 Angular 路由 部分进一步解释。有许多新的术语需要学习,但模块作为一个概念并不会消失。只是它们不再需要了。

无论您的应用程序是从 bootstrapApplication 还是 bootstrapModule 开始,在应用程序的根级别,Angular 组件、服务、指令、管道和用户控件都提供给 bootstrapApplication 函数或组织在模块下。根级别的配置渲染您的第一个组件,注入任何服务,并准备它可能需要的任何依赖项。在独立应用程序中,您可以懒加载单个组件。

您还可以引入 功能模块 来懒加载服务组和组件。所有这些功能都有助于初始应用程序快速加载,提高首次内容绘制时间,因为框架不需要在浏览器中同时下载和加载所有网络应用程序组件。例如,向没有管理员权限的用户发送管理仪表板的代码是无用的。

能够创建独立组件使我们能够放弃人为的模块。以前,您被迫将共享组件放在共享模块中,这导致在减少应用程序大小方面效率低下,因为开发者不一定想为每个共享组件创建一个模块。例如,LocalCast Weather 应用程序是一个简单的应用程序,它不受益于模块的概念,但 LemonMart 应用程序通过在不同模块中实现不同的业务功能,自然地反映了模块化架构。关于这一点,本章的 模块化架构 部分将进行更多介绍。

独立组件不应与 Angular 元素混淆,Angular 元素是网络标准的实现,自定义元素,也称为 Web Components。以这种方式实现组件将需要将 Angular 框架的大小减少到只有几 KB,而当前框架的大小约为 150 KB。如果这成功了,您将能够在任何网络应用程序中使用您开发的 Angular 组件。这是一件令人兴奋的事情,但也是一个艰巨的任务。您可以在 angular.dev/guide/elements 上了解更多关于 Angular 元素的信息。

Angular 大量使用 RxJS 库,它将响应式开发模式引入 Angular,而不是更传统的命令式开发模式。

响应式编程

Angular 支持多种编程风格。Angular 内部编程风格的多样性是它对具有不同背景的程序员友好的一部分原因。无论你是来自面向对象编程的背景,还是坚定地相信函数式编程,你都可以使用 Angular 构建可行的应用程序。在第二章表单、观察者、信号和主题中,你将开始利用响应式编程概念构建 LocalCast 天气应用程序。

作为程序员,你很可能习惯于命令式编程。命令式编程是指你作为程序员编写顺序代码,描述必须按你定义的顺序执行的所有操作以及应用程序的状态,这取决于正确设置的一组变量以正确运行。你编写循环、条件语句并调用函数;你触发事件并期望它们被处理。命令式和顺序逻辑是你习惯的编码方式。

响应式编程是函数式编程的一个子集。在函数式编程中,你不能依赖于之前设置的变量。你写的每个函数都必须独立存在,接收它自己的输入集,并返回一个结果,而不受外部函数或类的状态的影响。函数式编程很好地支持测试驱动开发TDD),因为每个函数都是一个可以独立测试的单元。因此,你写的每个函数都变得可组合。所以你可以混合、匹配和组合你写的任何函数,构建一系列调用,以产生你预期的结果。

响应式编程给函数式编程增添了一个转折。你不再处理纯逻辑,而是一个异步数据流,你可以使用一组可组合的函数将其塑造成你需要的任何形状。所以当你订阅响应式流中的事件时,你正在将你的编码范式从响应式编程转变为命令式编程。

在本书后面的部分,当实现 LocalCast 天气应用程序时,你将在CurrentWeatherComponentCitySearchComponent中利用subscribe的实际操作。

考虑以下例子,由 Mike Pearson 在他的演示文稿思考响应式:最难中恰如其分地提出,提供从水龙头获取热水的指令,以帮助理解命令式编程和响应式编程之间的差异:

一个黑色屏幕上带有白色圆圈,描述自动生成

图 1.11:命令式与响应式方法

如你所见,在使用命令式编程时,你必须定义代码执行的每一步。总共有六个步骤。每个步骤都依赖于前一个步骤,这意味着你必须考虑环境的状态以确保操作成功。在这样的环境中,很容易忘记一个步骤,并且很难测试每个步骤的正确性。在函数式响应式编程中,你与异步数据流一起工作,这导致了一个无状态的流程,它很容易与其他动作组合。总共有两个步骤,但步骤 2不需要任何新的逻辑。它只是断开步骤 1中的代码。

RxJS 是允许你在响应式范式下实现代码的库。

Angular 16 在开发者预览中引入了信号,作为一种新的范式,以在 Angular 中实现细粒度的响应性。在第二章表单、可观察者、信号和主题中,你将在你的 Angular 应用程序中实现信号。请参考本章后面的Angular 的未来部分以获取更多信息。

RxJS

RxJS代表响应式扩展,这是一个模块化库,它使得响应式编程成为可能。它是一种异步编程范式,允许通过转换、过滤和控制函数来操作数据流。你可以将响应式编程视为基于事件编程的进化。

响应式数据流

在事件驱动编程中,你会定义一个事件处理程序并将其附加到事件源。更具体地说,如果你有一个保存按钮,它暴露了一个onClick事件,你会实现一个confirmSave函数,当触发时,会显示一个弹出窗口询问用户“你确定吗?”。查看以下图表以了解此过程的可视化:

图片

图 1.12:事件驱动实现

简而言之,你会有一个事件在每次用户操作时触发一次。如果用户多次点击保存按钮,这个模式会乐意渲染与点击次数一样多的弹出窗口,这并没有太多意义。

发布-订阅pub/sub)模式是另一种类型的事件驱动编程。在这种情况下,我们可以为给定事件的结果编写多个处理程序,以便它们可以同时作用于。假设你的应用程序刚刚接收了一些更新数据。发布者会遍历其订阅者列表,并将更新数据传递给每个订阅者。

参考以下图表了解更新数据事件如何触发多个函数:

  • updateCache函数使用新数据更新你的本地缓存

  • fetchDetails函数从服务器检索有关数据的更多详细信息

  • showToastMessage函数通知用户应用程序刚刚接收到了新数据

图片

图 1.13:发布/订阅模式实现

所有这些事件都可以异步发生;然而,fetchDetailsshowToastMessage 函数将接收到比它们所需更多的数据,尝试以不同的方式组合这些事件来修改应用程序的行为可能会变得复杂。

在响应式编程中,一切都是被视为流。一个流将包含随时间发生的事件,这些事件可能包含一些或没有数据。以下图表可视化了一个场景,其中你的应用程序正在监听用户的鼠标点击。未经控制的用户点击流是没有意义的。你通过应用 throttle 函数来控制这个流,这样你每 250 毫秒只得到更新。如果你订阅这个新的事件流,每 250 毫秒,你将收到一个点击事件的列表。你可能试图从每个点击事件中提取一些数据,但在这个情况下,你只对发生的点击事件数量感兴趣。使用 map 函数,我们可以将原始事件数据塑造成所有点击的总和。

在数据流进一步向下时,我们可能只对监听两个或更多点击的事件感兴趣,因此我们可以使用 filter 函数仅对本质上为双击事件进行操作。每次我们的过滤器事件触发时,都意味着用户有意进行双击,你可以通过弹出警告来利用这个信息。

流的真正力量来自于你可以选择在任何时候对事件进行操作,当它通过各种控制、转换和过滤器函数时。你可以选择使用 @for 和 Angular 的 async 管道在 HTML 列表中显示点击数据,以便用户可以监控每 250 毫秒被捕获的点击数据类型。

图片

图 1.14:响应式数据流实现

现在,让我们考虑一些更高级的 Angular 架构模式。

模块化架构

如前文在 组件架构部分 中提到的,如果你创建一个 NgModule 项目,Angular 组件、服务和依赖项将组织到模块中。Angular 应用程序通过其根模块进行引导,如下面的图表所示:

图片

图 1.15:显示主要架构元素的 Angular 引导过程

根模块可以导入其他模块,声明组件,并提供服务。随着你的应用程序增长,你必须创建包含其组件和服务的子模块。以这种方式组织你的应用程序允许你实现懒加载,这样你可以控制哪些应用程序的部分被发送到浏览器以及何时发送。当你向应用程序添加更多功能时,你会从其他库(如 Angular Material 或 NgRx)导入模块。你实现路由器以在组件之间启用丰富的导航体验,允许你的路由配置编排组件的创建。

第四章创建一个 Router-First 业务应用,介绍了 router-first 架构,我鼓励你通过提前创建所有路由来开始开发你的应用程序。

在 Angular 中,服务默认以单例形式提供给模块。你会很快习惯这种行为。然而,你必须记住,如果你在多个模块中提供相同的服务,每个模块都有其提供的服务的独立实例。在认证服务的情况下,我们希望在整个应用程序中只有一个实例,你必须小心只在该根模块级别提供认证服务的实例。任何在应用程序根级别提供的服务、组件或模块都将在功能模块中可用。

独立架构

如果你创建一个独立项目,你的依赖项将在根级别的 bootstrapApplication 函数中提供。第一方和第三方库被更新为暴露提供者函数而不是模块。这些提供者函数本质上是可摇树的,这意味着框架可以在最终包中删除未使用的它们。提供者函数可以使用“with”函数进行自定义,其中名为 withFeature() 的函数可以启用某个特定功能。

在独立项目和通常使用独立组件的情况下,我们必须明确导入它们使用的、不包括在提供者中的功能。这意味着管道、指令(包括像 *ngIf 这样的基本指令——当然,除非你使用 @if),以及子组件必须被提供。这可能会比 NgModule 项目更冗长和限制性,但长期的好处超过了短期的不便。我们向框架提供关于我们项目的更多信息,框架就能更好地优化我们的代码并提高性能。

你可以使用以下命令将现有的 NgModule 项目迁移到独立项目:

$ npx ng g @angular/core:standalone 

警惕——这并不是一个万无一失或完全自动化的过程。更多关于此的信息请参阅 angular.dev/reference/migrations/standalone

路由器是你在 Angular 中必须掌握的下一个最强大的技术。

Angular 路由器

Angular 路由器,包含在 @angular/router 包中,是构建类似常规网站且易于导航的 SPAs 的核心和关键部分,它使用浏览器控件或缩放或微缩控件进行操作。

Angular 路由器具有高级功能,如懒加载、路由出口、辅助路由、智能活动链接跟踪,以及能够以 href 的形式表达,这使能够利用无状态数据驱动组件的灵活的 Router-first 应用架构,使用 RxJS BehaviorSubject 或一个 signal

如果一个类(在 Angular 中是一个组件或服务)在执行其任何行为(通过函数或属性获取器/设置器)时不依赖于实例变量,则该类是无状态的。当类用于管理数据访问时,它是数据驱动的。一个无状态的数据驱动组件可以持有数据对象的引用并允许访问它们(包括通过函数进行的突变),但不会在变量中存储任何账簿或状态信息。

大型团队可以在单个代码库上协同工作,每个团队负责一个模块的开发,同时避免相互干扰,并实现轻松的持续集成。谷歌拥有数亿行代码,之所以选择在单个代码库上工作,有一个非常好的原因:事后的集成成本非常高。

小型团队可以即时混搭他们的 UI 布局以快速响应变化,而无需重新架构他们的代码。很容易低估由于布局或导航的后期变化而浪费的时间。对于大型团队来说,这些变化更容易吸收,但对于小型团队来说则成本高昂。

考虑以下图示;首先,根据引导配置,应用程序将要么是一个独立的,要么是NgModule项目。无论如何,你将在应用程序的根目录中定义一个rootRouter;组件amasterdetail;服务;管道;指令;以及其他模块将被提供。当用户首次导航到你的应用程序时,所有这些组件都将被浏览器解析和预加载。

系统图  自动生成的描述

图 1.16:Angular 架构

如果你打算实现一个懒加载的路由/b,你需要创建一个名为b的功能模块,它将拥有其childRouter;组件/b/a/b/b;服务;管道;指令;以及其他为它提供的模块。在转换过程中,Angular 会将这些组件打包到一个单独的文件或包中,并且这个包只有在用户导航到/b下的路径时才会被下载、解析和加载。

在一个独立项目中,你可以懒加载由三角形表示的其他独立组件。你可以在路由配置文件中组织组件。/c/a/c/b 组件将能够访问根级别的提供者。你可以在路由配置文件中为特定组件提供一个 环境注入器。实际上,这只有在你想只为该组件或具有特定范围(例如,仅由该组件使用的状态)提供服务时才有用。与 NgModule 应用程序相比,你必须在每个组件中细粒度地声明你正在使用的模块。然而,与 NgModule 应用程序不同,任何未由组件使用的根级别提供者都是可摇的。这两个属性的组合导致应用程序包体积小,并且由于每个模块都可以单独懒加载,每个包的大小也会更小,从而带来更好的整体性能。

让我们更详细地研究懒加载。

懒加载

连接 /b/...rootRouter 的虚线展示了懒加载的工作原理。懒加载允许开发者快速实现亚秒级的首次有意义的绘制。通过延迟加载额外的模块,我们可以将发送到浏览器的包大小保持在最小。模块的大小会负面影响下载和加载速度,因为浏览器需要做的工作越多,用户看到应用第一个屏幕的时间就越长。通过定义懒加载的模块,每个模块都作为单独的文件打包,可以单独下载和按需加载。

Angular 路由器提供了智能的活跃链接跟踪,这导致了卓越的开发者和用户体验,使得实现高亮功能以指示用户当前标签或当前活动应用的某个部分变得非常容易。辅助路由最大化了组件的重用,并有助于轻松完成复杂的状态转换。使用辅助路由,你可以仅使用单个外部模板渲染多个主视图和详细视图。你还可以控制路由在浏览器 URL 栏中的显示方式,并在模板中使用 routerLink 和在组件类中使用 Router.navigate 来组合路由,处理复杂场景。

第四章创建以路由为第一线的业务应用,我介绍了实现路由基础,高级食谱在第 第八章食谱 – 可重用性、表单和缓存 中介绍。

除了路由之外,如果你想要构建复杂的 Angular 应用程序,掌握状态管理是另一个至关重要的概念。

状态管理

每个 Angular 组件和服务都有一个 EcmaScript 类作为后盾。当实例化时,类在内存中成为对象。当你与对象一起工作时,如果你在对象属性中存储值,你就在你的 Angular 应用程序中引入了状态。如果未管理,状态就会成为你应用程序成功和可维护性的重大负担。

我既喜欢后端也喜欢前端的无状态设计。从我的角度来看,状态是邪恶的,你应该仔细注意不要将状态引入到你的代码中。早些时候,我们讨论了 Angular 中的服务默认是单例的。这是一个引入状态到应用程序的糟糕机会。你必须避免在服务中存储信息。在 第四章创建以路由为第一线的业务应用程序 中,我向你介绍了 readonly BehaviorSubject,它作为应用程序的数据锚点。在这种情况下,我们将这些锚点存储在服务中以在组件之间共享,以同步数据。数据锚点是数据的引用而不是副本。服务不存储任何元数据或进行任何账目管理。

在 Angular 组件中,类是一个 ViewModel,充当你的代码和模板之间的粘合代码。与服务相比,组件的生存周期相对较短,在这种情况下使用对象属性是可以的。

然而,除了设计之外,还有特定的用例需要引入强大的机制来维护应用程序状态中的复杂数据模型。渐进式 Web 应用程序PWA)和移动应用程序就是其中之一,在这些情况下,连接性无法保证。在这些情况下,能够保存和恢复应用程序的整个状态是必须的,以提供出色的用户体验。

Angular 的 NgRx 库利用 Flux 模式为你的应用程序提供复杂的状态管理。在 第二章表单、可观察对象、信号和主题第九章食谱 – 主/详细信息、数据表和 NgRx 中,我提供了使用 NgRx 的各种功能的替代实现,以展示与更轻量级方法之间的实现差异。

Flux 模式

Flux 是 Facebook 创建的应用架构,旨在协助构建客户端 Web 应用程序。Flux 模式定义了一系列组件,这些组件通过触发/处理动作和从存储中读取值的视图函数来管理存储应用程序状态。使用 Flux 模式,你将应用程序的状态保存在存储中,存储的访问只能通过定义良好且解耦的函数进行,从而实现良好的架构扩展性,因为解耦的函数在独立情况下易于推理和编写单元测试。

考虑以下图表,以了解这些组件之间信息流的流向:

图片

图 1.17:NgRx 数据流

NgRx 使用 RxJS 在 Angular 中实现了 Flux 模式。

NgRx

NgRx 库将基于 RxJS 的类似 Redux(一个流行的 React.js 库)的响应式状态管理引入 Angular。使用 NgRx 进行状态管理允许开发者编写原子、自包含和可组合的代码片段,创建动作、减少器和选择器。这种响应式编程允许在状态变化中隔离副作用,并且与 React.js 的一般编码模式感觉非常自然。NgRx 在已经复杂和高级的工具(如 RxJS)之上创建了一个抽象层。

使用 NgRx 有许多优秀的理由,例如如果你在应用程序中处理 3+ 个输入流。在这种情况下,处理这么多事件的额外开销使得引入新的编码范式到你的项目中变得值得。然而,大多数应用程序只有两个输入流:REST API 和用户输入。对于可能需要持久化和恢复复杂状态信息(或具有类似需求的利基企业应用程序)的离线优先 渐进式 Web 应用程序PWAs),NgRx 可能是有意义的。

这里是 NgRx 的架构概述:

图片

图 1.18:NgRx 架构概述

将图的最顶部视为可观察的动作流,动作可以像圆圈所示那样被分发和执行。效果和组件可以分发动作。减少器和效果可以对这些动作进行操作,要么将值存储在存储中,要么触发与服务器的交互。组件利用选择器从存储中读取值。

由于我对最小工具的态度积极,并且没有明确的必要性需要 NgRx 之外的利基受众,我不建议将 NgRx 作为默认选择。RxJS/BehaviorSubject 强大且足够强大,可以解锁复杂和可扩展的模式,帮助你构建出色的 Angular 应用程序,正如在 第九章食谱 - 主/详细,数据表和 NgRx 章节中所展示的那样。

你可以在 ngrx.io 上了解更多关于 NgRx 的信息。

NgRx 组件存储

NgRx 组件存储,包名为 @ngrx/component-store,是一个旨在通过针对本地/组件状态来简化状态管理的库。它是一种替代基于服务的响应式推送主题的方法。对于组件状态仅由组件自身或一小组组件更改的场景,你可以通过使用这个库来提高你代码的可测试性、复杂性和性能。

与像 NgRx 这样的全局状态解决方案相比,NgRx 组件存储由于其有限的范围,可以在其关联视图从组件树中分离时自动清除自己。与单例服务不同,你可以有多个组件存储的实例,为不同的组件提供不同的状态。此外,组件存储的概念模型很简单。只需要掌握 select、updater 和 effect 概念,所有这些都在一个受限的范围内操作。因此,对于那些正在构建独立的 Angular 应用或寻求特定组件存储的人来说,NgRx 组件存储提供了一种可持续且易于测试的方法。

你可以在ngrx.io/guide/component-store了解更多关于 NgRx 组件存储的信息。

React.js 架构

与 Angular 相比,React.js 全面实现了 Flux 模式。以下是一个以路由为中心的 React 应用程序视图,其中组件/容器和提供者以严格的树状方式表示。

图片

图 1.19:React.js 架构概述

在 React 的最初版本中,即使是最基本的功能,也需要费力地将值在组件的继承树中传递上/下。后来,引入了react-redux,因此每个组件都可以直接将值读写到存储中,而无需遍历树。

这个基本的概述应该能让你对 Angular 和 React 之间的重要架构差异有一个感觉。然而,请记住,就像 Angular 一样,React 的社区、模式和最佳实践也在不断演变,并且随着时间的推移而变得更好。

如果你追求简洁,可以看看 Vue。它真的很简单:vuejs.org

你可以在reactjs.org了解更多关于 React 的信息。

Angular 的未来

Angular 最大的好处之一是你每 6 个月可以期待一次重大发布。然而,随着定期发布的节奏,每次重大发布都要推出有意义的、引人注目的更新所带来的压力也随之而来。我们可能要怪谷歌创造了这种压力。如果你不是持续产出,你就不在行了。这有一个不幸的副作用,即新功能可能会以预览或未完成的状态发布。虽然可以说,提前发布即将推出的功能可以让开发者社区收集反馈,但并不能保证不会引入性能回归。

如果你的团队没有消费掉定期发布的每一份 Angular 新闻,你可能会错过这些细微差别,并将代码部署到生产环境中,这可能会对你的业务产生负面影响,甚至可能影响收入。例如,一些用户已经注意到 Angular 16 中的性能回归,Angular 团队知道这一点并在 Angular 17 中修复了它,但这种立场使采用新版本的企业的业务处于风险之中。

野心勃勃的 Angular Elements 功能最好地体现了这一方面的另一个方面。大约在 Angular 9 发布时,宣布了对 Angular 的 Web 组件支持引起了很大的关注。承诺是你可以使用你喜欢的框架创建通用可重用组件。团队强调了将 Angular 的精简版与组件一起发布的巨大挑战——将框架大小从 150 KB 减少到只有几个 KB。团队并没有专注于最终确定这一功能,尽管取得了巨大的增量进展,但团队发现这项任务过于艰巨。因此,团队转向了不同的想法来解决这个问题。但即使是这些新想法也被匆忙推出,处于预览状态,例如,Angular signals 增加了生产软件中未完成工作的堆积。在 Angular 17 中,signals 部分退出预览,并有可能通过实现基于信号的组件来改变未来 Angular 应用程序构建的方式。与 RxJS 的泄漏订阅概念相比,signals 不易泄漏内存。Signals 还可以与 async/await 调用一起工作,避免了使用 RxJS 进行许多不自然的响应式编程。所有这些功能的稳定交付可能要到 Angular 19。

查找有关即将推出和预览中的功能的最新信息,请访问angular.dev/roadmap

一个大型 Angular 应用程序会遭受与 Angular.js 类似的性能问题,只是关于“大型”的定义已经发生了显著变化。这里的主要问题是,无法解决这些性能问题,至少不是没有进行重大工程投资,否则你将不得不深入 Angular 渲染引擎的内部。

此外,到 2023 年,通过利用 ES2022 功能,可以使用纯 JavaScript 构建响应式和交互式 Web 应用程序。Angular signals 通过用原生 JavaScript 替换 Zone.js 来暴露这些 ES2022 功能,从而实现细粒度响应性。这意味着只有需要更新的 DOM 部分才会被更新,显著减少了渲染时间。这是我在第三章构建企业应用程序架构中进一步探讨的主题。结合这些变化,结果是一个更优化的变更检测周期,从而实现更平滑的 FPS。

每次 Angular 的发布都旨在提高现代浏览器的首次交互时间TTI)。在过去,这意味着改进包大小,引入模块的懒加载,而现在则是单个组件。Angular 现在支持服务器端渲染SSR)的非破坏性水合。这意味着服务器可以计算视图的 DOM 并将其传输到客户端,客户端可以更新显示给用户的 DOM,而无需完全替换它。

Angular 也正在从 Jasmine 转向 Jest。Jasmine 一直是一个出色的单元测试框架。然而,在 Web 应用程序环境中使其工作总是需要大量的配置和额外的工具,如 Karma 来执行测试和获取覆盖率报告。Jest 包含了所有这些功能。目前的支持是实验性的,还不清楚 Vitest 是否会比 Jest 更好。Angular 正在从 webpack 转向 esbuild,其速度比 webpack 快约 40 倍。同样,它目前仅作为(开发者)预览版提供。

如您所见,Angular 中一些最激动人心的事情发生在预览功能中。事实是,团队正在全力以赴,为他们的项目交付功能,并尽力跟上所有最新的变化。不断更新依赖项已经足够困难;框架在心理模型上的重大变化,加上性能问题,可能会失去开发者和企业的信心。信任难以建立,却容易失去。

事实上,Angular 团队正在做出色的工作,该框架正在做出必要的改变以适应不断增长的需求。谷歌强制要求他们拥有的 2,000 多个 Angular 项目都必须使用相同的 Angular 版本。这意味着每个新的 Angular 更新都经过了良好的测试,并且没有向后不兼容的惊喜。

Angular 仍然是一个令人兴奋、敏捷且功能强大的框架。我的目的是让您了解潜在的地雷在哪里。我希望您和我一样对现代网络开发的状态以及它解锁的未来可能性感到兴奋。系好您的安全带,多萝西,因为堪萨斯州即将消失。

摘要

总结来说,网络技术已经发展到可以创建丰富、快速和本地的网络应用的程度,这些应用可以在今天部署的大多数桌面和移动浏览器上良好运行。Angular 已经成为一个成熟且稳定的平台,应用了从过去学到的经验。它使开发者能够使用 TypeScript、RxJS 和 NgRx 启用的模式(来自面向对象编程、响应式编程、Flux 模式和独立组件)等技术开发可维护、交互式和快速的应用程序,以及 NgRx 组件存储。

Angular 旨在以持续更新的方式使用,因此始终保持 Angular 的最新状态是个好主意。访问 AngularForEnterprise.com 获取最新更新和新闻。

Angular 被设计成从头到尾都是响应式的;因此,你必须调整你的编程风格以适应这种模式。有了信号,Angular 甚至获得了细粒度的响应性。然而,表示层响应性并不等同于响应式编程。当基于信号的组件在 Angular 19 左右出现时,Angular 将不再需要响应式编程来实现响应式的表示层。在第九章食谱 – 主/详细,数据表和 NgRx中,我提供了一个使用信号和 NgRx SignalStore 的几乎可观察和无订阅的应用程序示例,以展示 Angular 17 所能实现的可能性。在此之前,官方文档应该是你的圣经,可在angular.dev找到。

在下一章中,我们将回顾 LocalCast 天气应用作为一个独立应用;你将学习如何使用响应式表单捕获用户输入,保持组件解耦,使用BehaviorSubject在它们之间启用数据交换,以及 NgRx 组件存储和 Angular 信号与这些概念的区别。在接下来的章节中,你将学习关于创建可扩展应用程序的高级架构模式,以及你的 Angular 前端如何在最小 MEAN 的全栈 TypeScript 应用程序的上下文中工作。本书最后介绍了 DevOps 和持续集成技术,以发布你的应用程序。

进一步阅读

提问

尽可能好地回答以下问题,以确保你已理解本章的关键概念,无需使用 Google 搜索。你知道你是否回答了所有问题吗?访问 angularforenterprise.com/self-assessment 获取更多信息:

  1. 独立项目与 NgModule 项目之间有什么区别?

  2. Angular Evergreen 的概念是什么?

  3. 使用双击示例实现反应式流,使用 RxJS 实现以下步骤:使用 fromEvent 函数监听 HTML 目标的点击事件。使用 throttleTimeasyncSchedulerbufferfilter 操作符确定鼠标是否在 250 毫秒内双击。如果检测到双击,则在浏览器中显示一个警告。提示:使用 stackblitz.com 或实现你的代码并使用 rxjs.dev/ 获取帮助。

  4. NgRx 是什么,它在 Angular 应用中扮演什么角色?

  5. 在 Angular 中,模块、组件和服务之间有什么区别?

加入我们的 Discord 社区

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

packt.link/AngularEnterpise3e

二维码

第二章:表单、可观察对象、信号和主题

在本章中,我们将使用 Angular 和来自 OpenWeatherMap.org 的第三方 Web API,开发一个简单的天气应用 LocalCast Weather。该项目的源代码可在 GitHub 上找到,网址为 github.com/duluca/local-weather-app,包括在 projects 文件夹中的各个开发阶段。

如果您之前从未使用过 Angular,并且需要了解 Angular 的基础知识,我建议您查看 Angular.dev 上的 什么是 Angular? angular.dev/overview,并学习 Angular 教程 angular.dev/tutorials/learn-angular

感觉勇敢吗?只需在您的终端中输入以下内容:

$ npm create @angular 

LocalCast Weather 是一个简单的应用,展示了构成 Angular 应用程序的基本元素,例如组件、独立组件、模块、提供者、管道、服务、RxJS、单元测试、使用 Cypress 的端到端测试、环境变量、Angular Material 以及利用 CircleCI 的 持续集成持续交付CI/CD) 管道。

我已经在 GitHub 上为这个项目创建了一个看板板。您可以通过以下链接访问它,以了解更多关于项目的背景信息:

github.com/users/duluca/projects/1.

看板板是一个很好的方式来记录您构建应用的计划。我在 第三章构建企业应用架构 中提到了制定路线图和创建项目状态信息发射器的必要性。

信息发射器是一种物理或虚拟显示,易于可见或访问,传达有关项目或过程的关键信息。它通常包括指标、进度图表或状态指示器,旨在提供一目了然的认识,而无需观众寻求信息。信息发射器的目标是促进透明度,促进团队成员之间的沟通,并使利益相关者能够在不打扰团队工作流程的情况下获取更新。

作为额外奖励,我在我的仓库 github.com/duluca/local-weather-app/wiki 上创建了一个基本的维基页面。请注意,您无法在 README.md 或维基页面上上传图片。为了克服这一限制,您可以创建一个新的问题,在评论中上传一张图片,并将图片的 URL 复制粘贴到 README.md 或维基页面上以嵌入图片。在示例维基中,我使用了这种方法将线框设计嵌入到页面中。

书中示例项目的源代码被分为 阶段 以捕捉开发的各种状态快照。在本章中,我们从 stage5 开始应用开发,并演进到 stage6。在 stage5 中,应用已经完善,但它只能拉取一个城市的天气信息,该信息被硬编码到应用中。因此,它不是一个非常有用的应用。

计算机屏幕截图  自动生成的描述

图 2.1:LocalCast 天气应用,如 projects/stage5 中的项目

您将继承一个尚未交互的项目。为了构建交互式应用,我们需要能够处理用户输入。在您的应用程序中启用用户输入可以打开创建优秀用户体验的可能性。

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

  • 优秀的用户体验应驱动实现

  • 响应式表单与模板驱动表单的比较

  • 组件与可观察对象和 RxJS/BehaviorSubject 的交互

  • 管理订阅和内存泄漏

  • 在响应式范式中进行编码

  • 链接 API 调用

  • 使用 Angular 信号

  • 使用 ChatGPT 生成应用

技术要求

书籍的示例代码的最新版本可在 GitHub 上找到,链接将在稍后提供。该存储库包含代码的最终和完成状态。您可以在本章末尾通过查找 projects 文件夹下的章节末尾代码快照来验证您的进度。

对于 第二章

  1. 克隆 github.com/duluca/local-weather-app 存储库。

  2. 在根文件夹中执行 npm install 以安装依赖项。

  3. 项目的初始状态反映在:

    projects/stage5 
    
  4. 项目的最终状态反映在:

    projects/stage6 
    
  5. 将阶段名称添加到任何 ng 命令中,以仅对该阶段执行操作:

    npx ng build stage6 
    

注意,存储库根目录下的 dist/stage6 文件夹将包含编译结果。

请注意,书中提供的源代码和 GitHub 上的版本可能不同。这些项目周围的生态系统一直在不断发展。由于 Angular CLI 生成新代码的方式的变化、错误修复、库的新版本以及多种技术的并行实现,存在许多难以计数的差异。如果您发现错误或有问题,请在 GitHub 上创建问题或提交拉取请求。

到本章结束时,您应该能够熟练地利用可观察对象和信号构建提供优秀用户体验的应用。作为额外奖励,我将简要介绍您如何利用 生成式 AIGenAI)工具,如 ChatGPT (chat.openai.com/) 来构建快速原型。但首先,让我们回到用户体验,因为无论您如何奔跑、爬行或扩展城市城墙,如果您的用户体验做得好,您的应用将受到喜爱;但如果您没有达到目标,您的应用将一文不值。

优秀的用户体验应驱动实现

创建一个易于使用且丰富的用户体验UX)应该是你的主要目标。你不应该仅仅因为最容易实现而选择一个设计。然而,通常你会发现一个伟大的 UX,在应用的前端实现起来很简单,但在后端却要困难得多。考虑一下 google.com 的登录页面:

谷歌搜索截图,描述由中等置信度自动生成

图 2.2:谷歌的登录页面

在这种情况下,谷歌搜索只是一个简单的输入字段,带有两个按钮。容易构建,对吧?这个简单的输入字段解锁了世界上一些最复杂和最先进的软件技术,这些技术背后是全球基础设施中的定制数据中心和人工智能AI)。这是一种欺骗性的简单且疯狂强大的与用户互动的方式。你可以通过利用现代网络 API,如GeoLocation来增强用户输入,并为从用户输入中推导出新的含义添加关键上下文。因此,当用户输入巴黎时,你不必猜测他们是指法国的巴黎,还是指德克萨斯州的巴黎,或者你是否应该显示摄氏度或华氏度的当前温度。使用LocalStorage,你可以缓存用户凭据并记住用户偏好,以在您的应用中启用深色模式。

在这本书中,我们不会实现一个由人工智能驱动的超级应用,但我们将允许用户通过城市名称或邮政编码(在美国通常称为“邮编”)来搜索他们的城市。一旦你意识到实现看似简单的邮政编码搜索可能有多么复杂,你可能会对精心设计的网络应用产生新的赞赏。

为了实现用户体验目标,我们需要构建一个以输入字段为中心的用户界面。为此,我们需要利用 Angular 表单和验证消息来创建具有“边打边搜”功能的引人入胜的搜索体验。

在幕后,RxJS/BehaviorSubject信号使我们能够构建解耦的组件,它们可以相互通信,并且一个响应式数据流允许我们在不增加我们应用复杂性的情况下合并来自多个网络 API 的数据。此外,你将了解 Angular 信号,并了解它与 RxJS 的区别。

接下来,让我们看看如何使用表单实现输入字段。表单是我们捕获用户输入的主要机制。在 Angular 中,有两种类型的表单:响应式模板驱动。我们需要涵盖这两种技术,以便你熟悉 Angular 中表单的工作方式。

响应式表单与模板驱动表单

现在,我们将实现搜索栏在应用程序的主屏幕上。下一个用户故事是显示当前位置的预报信息,这可能意味着固有的地理定位功能。然而,正如你可能注意到的,地理定位是一个单独的任务。挑战在于,使用原生平台功能,如地理定位,你永远不能保证收到实际的位置信息。这可能是由于移动设备上的信号丢失问题,或者用户可能简单地拒绝分享他们的位置信息。

首先,我们必须提供一个良好的基线用户体验,然后才实现诸如地理定位等增值功能。在 阶段 5,项目状态在看板(Kanban board)上表示,如下快照所示:

聊天截图,描述自动生成,中等置信度

图 2.3:GitHub 项目看板

我们将实现添加城市搜索功能卡片(它捕获了一个用户故事),如图进行中列所示。作为这个故事的一部分,我们将实现一个即搜即显的功能,并在服务无法检索预期数据时向用户提供反馈。

初始时,实现一个类型搜索机制可能看起来很直观;然而,OpenWeatherMap API 并不提供这样的端点。相反,它们提供批量数据下载,这些下载成本高昂,且数据量在兆字节级别。

我们需要实现我们的应用程序服务器以暴露这样的端点,以便我们的应用程序在尽可能少用数据的情况下有效地查询。

OpenWeatherMap 的免费端点确实提出了一个有趣的挑战,即两位数的国家代码可能伴随城市名称或邮政编码,以获得最准确的结果。这是一个为用户提供反馈机制的绝佳机会,如果针对特定查询返回多个结果。

我们希望应用程序的每一次迭代都是一个可能发布的增量,并避免在任何给定时间内做太多事情。

在开始编写故事之前,将故事分解成技术任务是件好事。以下是本故事的分解任务:

  1. 添加一个 Angular 表单控件,以便我们可以捕获用户输入事件。

  2. 使用在material.angular.io/components/input上记录的 Angular Material 输入来提高输入字段的用户体验。

  3. 将搜索栏作为一个单独的组件创建,以强制执行关注点的分离和解耦的组件架构。

  4. weather.service.ts 中扩展现有端点以接受邮政编码,并使国家代码在可选,以便使最终用户与我们的应用程序交互更加直观。

  5. 限制请求频率,这样我们就不需要在每次按键时查询 API,而是在用户仍然能够立即获得反馈而不需要点击单独按钮的间隔内进行查询。

让我们在接下来的几节中处理这些任务。

添加 Angular 响应式表单

你可能会想知道为什么我们要添加 Angular 表单,因为我们只有一个输入字段,而不是一个包含多个输入字段的表单。作为一个一般规则,每次添加输入字段时,都应该将其包裹在 <form> 标签中。Forms 模块包含 FormControl,它允许你编写输入字段背后的代码以响应用户输入并提供适当的数据或验证或消息。

Angular 中有两种类型的表单:

  • 模板驱动表单:这些表单类似于你可能熟悉的 AngularJS 中的情况,其中表单逻辑主要在 HTML 模板内部。我不是这种方法的粉丝,因为它更难测试这些行为,而且庞大的 HTML 模板难以快速维护。

  • 响应式表单:响应式表单的行为由控制器中的 TypeScript 代码驱动。这意味着你的验证逻辑可以进行单元测试,并且更好的是,可以在你的应用程序中重用。响应式表单是核心技术,在未来将使 Angular Material 团队能够编写自动化工具,这些工具可以根据 TypeScript 接口自动生成输入表单。

angular.dev/guide/forms/reactive-forms 了解更多关于响应式表单的信息。

在 Angular 中,依赖项被封装在框架提供的模块中。用户创建的模块不再是强制性的,并且我们的代码示例配置为一个独立的应用程序。对于下一节中定义的组件,你必须导入 FormsModuleReactiveFormsModule 才能在模板中使用这些功能。

在纯响应式表单实现中,你只需要 ReactiveFormsModule。请注意,FormsModule 支持模板驱动表单和其他你可能只想声明 FormControl 而不是 FormGroup 的场景。这是我们实现此应用程序输入字段的这种方式。FormGroup 将在下一节中定义。

注意,响应式表单允许你在响应式范式中进行编码,这在使用可观察者时是一个净正面。接下来,让我们向我们的应用程序添加一个城市搜索组件。

添加和验证组件

我们将使用 Angular Material 表单和输入模块创建一个 citySearch 组件:

  1. 创建新的 citySearch 组件:

    $ npx ng g c citySearch 
    
  2. 从上一节导入表单依赖项和材料依赖项,MatFormFieldModuleMatInputModule

    **src/app/city-search/city-search.****component****.****ts**
    import { FormsModule, ReactiveFormsModule } from '@angular/forms'
    import { MatButtonModule } from '@angular/material/button'
    import { MatFormFieldModule } from '@angular/material/form-field'
    import { MatIconModule } from '@angular/material/icon'
    import { MatInputModule } from '@angular/material/input'
    ...
    @Component({
      ...
      standalone: true,
      imports: [
        FormsModule,
        ReactiveFormsModule,
        MatFormFieldModule,
        MatInputModule,
      ],
    })
    export class CitySearchComponent
    ... 
    

    我们添加 MatFormFieldModule 是因为每个输入字段都应该包裹在 <mat-form-field> 标签中,以充分利用 Angular Material 的功能。

    在高层次上,<form> 封装了针对键盘、屏幕阅读器和浏览器扩展用户的众多默认行为;<mat-form-field> 使双向数据绑定变得简单,这是一种应该适度使用的技巧,同时也允许优雅地显示标签、验证和错误消息。

  3. 创建一个基本模板,替换现有内容:

    **src/app/city-search/city-search.component.html**
    <form>
      <mat-form-field appearance="outline">
        <mat-label>City Name or Postal Code</mat-label>
        <mat-icon matPrefix>search</mat-icon>
        <input matInput aria-label="City or Zip" [formControl]="search">
      </mat-form-field>
    </form> 
    
  4. 声明一个名为 search 的属性,并将其实例化为 FormControl 的一个实例:

    **src/app/city-search/city-search.****component****.****ts**
    import { FormControl } from '@angular/forms'
    ...
    export class CitySearchComponent implements 
      OnInit { 
    
      search = new FormControl()
      ... 
    

    响应式表单有三个级别的控制:

    • FormControl 是与输入字段一对一关系的最基本元素。

    • FormArray 表示表示对象集合的重复输入字段。

    • FormGroup 在你向表单添加更多输入字段时,将单个 FormControlFormArray 对象注册为对象。

    最后,FormBuilder 对象用于更轻松地编排和维护 FormGroup 对象的操作。FormBuilderFormGroup 首次在第六章“实现基于角色的导航”中使用,所有控件,包括 FormArray,在第八章“食谱 - 可重用性、表单和缓存”中进行了深入介绍。

  5. app.component.ts 中导入 CitySearchComponent,然后在包含应用标语和包含 mat-card 的行的中间添加 <app-city-search> 作为新的 div

    **src/app/app.****component****.****ts**
    template: `
      ...
        </div>
        **<div fxLayoutAlign="center">**
          **<app-city-search></app-city-search>**
        **</div>**
        <div fxLayout="row">
      ...
    `,
      standalone: true,
      imports: [
        FlexModule,
        CitySearchComponent,
        ...
      ],
    })
    export class AppComponent { 
    
  6. 从你的终端启动你的应用:

    $ npm start 
    
  7. 通过在浏览器中查看应用来测试组件的集成,如下所示:计算机屏幕截图  描述由中等置信度自动生成

    图 2.4:带有搜索字段的 LocalCast 天气应用

如果没有发生错误,我们可以开始添加 FormControl 元素并将它们连接到搜索端点。

向天气服务添加搜索选项

到目前为止,我们通过传递参数使用城市名称和国家代码来获取天气。通过允许用户输入邮政编码,我们必须使我们的服务在接受这两种类型的输入时更加灵活。

OpenWeatherMap 的 API 接受 URI 参数,因此我们可以使用 TypeScript 的 联合类型类型守卫 重构现有的 getCurrentWeather 函数。

这意味着我们可以在保留类型检查的同时提供不同的参数:

  1. weather.service.ts 中重构 getCurrentWeather 函数以处理 zipcity 输入:

    **src/app/weather/weather.****service****.****ts**
      getCurrentWeather(
        search: string | number, 
        country?: string
      ): Observable<ICurrentWeather> { 
        let uriParams = new HttpParams()
        if (typeof search === 'string') { 
          uriParams = uriParams.set('q',
            country ? `${search},${country}` : search
          )
        } else {
          uriParams = uriParams.set('zip', 'search')
        }
    
        uriParams = uriParams.set('appid', environment.appId)
        return this.httpClient
          .get<ICurrentWeatherData>(
            `${environment.baseUrl}api.openweathermap.org/data/2.5/
              weather`,
            { params: uriParams } 
        )
        .pipe(map(
          data => this.transformToICurrentWeather(data)))
      } 
    

    我们将 city 参数重命名为 search,因为它可以是城市名称或邮政编码。然后我们允许其类型为 stringnumber,并且根据运行时类型,我们将使用 qzip。我们还使 country 可选,并且仅在它存在时将其附加到查询中。

    getCurrentWeather 现在已经嵌入业务逻辑,因此是单元测试的良好目标。遵循 SOLID 原则中的单一职责原则,我们将重构 HTTP 调用到其自己的函数 getCurrentWeatherHelper

    如果你对 SOLID 原则不熟悉,我在第三章“构建企业应用”的敏捷工程最佳实践部分进行了介绍。如果你想现在了解更多关于它的信息,请参阅维基百科条目:en.wikipedia.org/wiki/SOLID

  2. 将 HTTP 调用重构为 getCurrentWeatherHelper

    在下一个示例中,请注意使用反引号字符 ` 而不是单引号字符 ',这利用了模板字面量的功能,允许在 JavaScript 中嵌入表达式:

    **src/app/weather/weather.****service****.****ts**
      getCurrentWeather(
        search: string | number, 
        country?: string
      ): Observable<ICurrentWeather> { 
        let uriParams = new HttpParams()
        if (typeof search === 'string') { 
          uriParams = uriParams.set('q',
            country ? `${search},${country}` : search
          )
        } else {
          uriParams = uriParams.set('zip', 'search')
        }
    
        **return****this****.****getCurrentWeatherHelper****(uriParams)**
      }
      private getCurrentWeatherHelper(uriParams: HttpParams):
        Observable<ICurrentWeather> { 
        uriParams = uriParams.set('appid', environment.appId)
        return this.httpClient
          .get<ICurrentWeatherData>(
            `${environment.baseUrl}api.openweathermap.org/data/2.5/
              weather`,
            { params: uriParams } 
          )
          .pipe(map(
            data => this.transformToICurrentWeather(data)))
        } 
    

    作为积极的副作用,getCurrentWeatherHelper 遵循 SOLID 的开闭原则。毕竟,它可以通过提供不同的 uriParams 来扩展函数的行为,并且由于它不需要经常更改,因此它是封闭的。

    为了演示这一点,让我们实现一个新的函数来通过纬度和经度获取当前天气。

  3. 实现 getCurrentWeatherByCoords

    **src/app/weather/weather.****service****.****ts**
    getCurrentWeatherByCoords(coords: Coordinates): Observable<ICurrentWeather> {
      const uriParams = new HttpParams()
          .set('lat', coords.latitude.toString())
          .set('lon', coords.longitude.toString())
      return this.getCurrentWeatherHelper(uriParams)
    } 
    

    如您所见,getCurrentWeatherHelper 的功能是可扩展的,而无需修改其代码。

  4. 确保您更新 IWeatherService 以包含之前所做的更改:

    **src/app/weather/weather.****service****.****ts**
    export interface IWeatherService {
      getCurrentWeather(
        search: string | number, country?: string
      ): Observable<ICurrentWeather>
      getCurrentWeatherByCoords(coords: Coordinates): Observable<ICurrentWeather>
    } 
    

由于遵循 SOLID 设计原则,我们使得对流程控制逻辑进行健壮的单元测试更加容易,并最终编写出对错误更具有弹性和更易于维护的代码。

实现搜索

现在,让我们将新的服务方法连接到输入字段:

  1. citySearch 更新为注入 weatherService 并订阅输入变化:

    **src/app/city-search/city-search.****component****.****ts**
    import { WeatherService } from '../weather/weather.service'
    ...
    export class CitySearchComponent implements OnInit { 
      search = new FormControl()
    
      constructor(**private****weatherService****:** **WeatherService**) {}
      ...
      ngOnInit(): void { 
        **this****.****search****.****valueChanges****.****subscribe****()**
      } 
    

    目前我们正在将所有输入视为 string。用户输入可以是城市和邮编、城市和国家代码,或者邮编和国家代码,由逗号分隔。虽然城市或邮编是必需的,但国家代码是可选的。我们可以使用 String.split 函数来解析任何潜在的逗号分隔输入,然后使用 String.trim 从字符串的开始和结束处去除任何空白。然后我们通过迭代它们确保我们修剪字符串的所有部分。

    我们然后使用三元运算符 ?: 处理可选参数,只有当它存在时才传递值,否则将其留为未定义。

  2. 实现搜索处理程序:

    **src/app/city-search/city-search.****component****.****ts**
    this.search.valueChanges
      .subscribe(
        (searchValue: string) => { 
        if (searchValue) {
          const userInput = searchValue.split(',').map(s => s.trim())
          this.weatherService.getCurrentWeather(
            userInput[0],
            userInput.length > 1 ? userInput[1] : undefined
          ).subscribe(data => (console.log(data)))
      }
    }) 
    
  3. 在输入字段下方为用户添加提示,告知他们关于可选的国家功能:

    **src/app/city-search/city-search.component.html**
    ...
      <mat-form-field appearance="outline">
        ...
        **<****mat-hint****>****Specify country code like 'Paris, US'****</****mat-hint****>**
      </mat-form-field>
    ... 
    

在这一点上,订阅处理程序将调用服务器并将输出记录到控制台。

使用 Chrome DevTools 观察这个工作原理。注意 search 函数运行的频率,并且我们没有处理服务错误。

使用节流/防抖限制用户输入

我们目前在每个按键敲击时都会向服务器提交一个请求。这不是理想的行为,因为它可能导致糟糕的用户体验和电池寿命消耗,从而在客户端和服务器端都造成网络请求浪费和性能问题。用户会犯拼写错误;他们可能会改变他们输入的内容,而且信息输入的前几个字符很少能产生有用的结果。

我们仍然可以监听每个按键敲击,但不必对每个敲击做出反应。通过利用 throttle/debounce,我们可以限制生成的事件数量到一个预定的间隔,并保持按搜索功能。

注意,throttledebounce 并不是功能等效的,它们的行为会因框架而异。除了节流之外,我们还期望捕获用户最后输入的内容。在 lodash 框架中,throttle 函数满足这一要求,而在 RxJS 中,debounce 函数满足这一要求。

使用 RxJS/debounceTime 很容易将节流注入到可观察流中。使用 pipe 实现 debounceTime

**src/app/city-search/city-search.****component****.****ts**
import { debounceTime } from 'rxjs/operators'
  this.search.valueChanges
    .pipe(debounceTime(1000))
    .subscribe(...) 

debounceTime 最多每秒运行一次搜索,但用户停止输入后也会运行另一个搜索。相比之下,RxJS/throttleTime 只会在每秒的第二次运行搜索,并且不一定捕获用户可能输入的最后几个字符。

RxJS 还提供了 throttledebounce 函数,你可以使用这些函数来实现自定义逻辑,以限制非基于时间的输入。

由于这是一个基于时间和事件的函数,断点调试不可行。你可以在 Chrome Dev Tools | Network 选项卡中监控网络调用,但要更实时地了解你的搜索处理程序被调用的频率,可以添加一个 console.log 语句。

在代码中检查时,不要使用带有活动 console.log 语句。这些调试语句使得实际代码难以阅读,从而增加了维护成本。即使调试语句被注释掉了,也不要检查它们。

输入验证和错误消息

FormControl 非常可定制。它允许你设置默认的初始值,添加验证器,或者监听 blurchangesubmit 事件,如下所示:

**example**
new FormControl('Bethesda', { updateOn: 'submit' }) 

我们不会用值初始化 FormControl,但我们需要实现一个验证器来禁止单字符输入:

  1. @angular/forms 中导入 Validators

    **src/app/city-search/city-search.****component****.****ts**
    import { FormControl, Validators } from '@angular/forms' 
    
  2. 修改 FormControl 以添加最小长度验证器:

    **src/app/city-search/city-search.****component****.****ts**
    search = new FormControl('', [Validators.minLength(2)]) 
    
  3. 修改模板以在提示文本下方显示验证错误消息:

    **src/app/city-search/city-search.component.html**
    ...
    <form **style****=****"margin-bottom: 32px"**>
      <mat-form-field appearance="outline">
        ...
        @if (search.invalid) {
          **<****mat-error****>**
            **Type more than one character to search**
          **</****mat-error****>**
        }
      </mat-form-field>
    </form>
    ... 
    

    注意添加了一些额外的边距,以便为长错误消息腾出空间。

    如果你正在处理不同类型的错误,模板中的 hasError 语法可能会变得重复。你可能想实现一个更可扩展的解决方案,可以通过代码进行定制,如下所示:

    **example**
    @if (search.invalid) {
      <mat-error>
        {{getErrorMessage()}}
      </mat-error>
    }
    getErrorMessage() {
      return this.search.hasError('minLength') ?
        'Type more than one character to search' : '';
    } 
    
  4. 修改 search 函数,使其在输入无效时不执行搜索,替换现有 if 语句中的条件:

    **src/app/city-search/city-search.****component****.****ts**
    this.search.valueChanges
      .pipe(debounceTime(1000))
      .subscribe((search Value: string) => {
        if (**!****this****.****search****.****invalid**) {
          ... 
    

我们可以调用 this.search.invalid 来利用验证引擎进行更健壮的检查,而不是简单地检查 searchValue 是否已定义且不是空字符串。

目前,我们已经完成了 search 功能的实现。接下来,让我们探讨一个假设场景,看看模板驱动的表单实现会是什么样子。

带有双向绑定的模板驱动表单

与响应式表单相比,另一种选择是模板驱动表单。如果你熟悉 AngularJS 中的 ng-model,你会发现新的 ngModel 指令是与它兼容的 API 替代品。

在幕后,ngModel实现了FormControl,它会自动附加到FormGroupngModel可以在<form>级别或单个<input>级别使用。你可以在angular.dev/api/forms/NgModel了解更多关于ngModel的信息。

在 GitHub 上 LocalCast Weather 应用程序仓库的stage6示例代码中,我已在app.component.ts中包含了一个名为<app-city-search-tpldriven>的模板驱动组件,它被渲染在<div class="example">下。你可以尝试这个组件,看看替代的模板实现是什么样的:

**projects/stage6/src/app/city-search-tpldriven/city-search-tpldriven.component.html**
  ...
    <input matInput aria-label="City or Zip"
      [(ngModel)]="model.search"
      (ngModelChange)="doSearch($event)" minlength="2" 
      name="search" #search="ngModel">
  ...
    @if(search.invalid) {
      <mat-error>
        Type more than one character to search
      </mat-error>
    }
  ... 

注意使用ngModel时使用的[()]“香蕉箱”双向绑定语法。

组件之间的差异是这样实现的:

**projects/stage6/src/app/city-search-tpldriven/city-search-tpldriven.****component****.****ts**
import { WeatherService } from '../weather/weather.service'
export class CitySearchTpldrivenComponent { 
  model = {
    search: '',
  }
  constructor(private weatherService: WeatherService) {}

  doSearch(searchValue) {
    const userInput = searchValue.split(',').map(s => s.trim())
    this.weatherService
      .getCurrentWeather(userInput[0], userInput.length > 1 ?
        userInput[1] : undefined
      )
      .subscribe(data => console.log(data))
  }
} 

如你所见,大部分逻辑都是在模板中实现的;因此,你需要保持对模板和控制器的一个活跃的心理模型。任何对事件处理程序和验证逻辑的更改都需要你在两个文件之间来回切换。

此外,我们失去了输入限制和防止在输入无效时调用服务的能力。虽然仍然可以实施这些功能,但它们需要复杂的解决方案,并且无法很好地适应新的 Angular 语法和概念。

总体来说,我不推荐使用模板驱动的表单。可能有一些情况下使用“香蕉箱”语法非常方便。然而,这为其他团队成员复制相同的模式在应用程序中设定了一个不好的先例。

使用 BehaviorSubject 与组件进行交互

为了更新当前的天气信息,我们需要citySearch组件与currentWeather组件进行交互。在 Angular 中,有四种主要的技术来实现组件间的交互:

  • 全局事件

  • 父组件监听从子组件冒泡上来的信息

  • 在一个基于相似数据流的模块中,兄弟、父或子组件

  • 父组件向子组件传递信息

让我们在接下来的章节中详细探讨它们。

全局事件

这种技术在编程的早期就已经被利用了。在 JavaScript 中,你可能通过全局函数代理或 jQuery 的事件系统实现了这一点。在 AngularJS 中,你可能创建了一个服务并在其中存储变量。

在 Angular 中,你仍然可以创建根级别的服务,在其中存储值,使用 Angular 的EventEmitter类(它旨在用于指令),或者使用RxJS/Subscription为自己创建一个花哨的消息总线。

作为一种模式,全局事件容易受到滥用,并且它们不仅不能帮助维护解耦的应用架构,反而随着时间的推移导致全局状态。全局状态或控制器级别的本地状态,其中函数在任意给定的类中读写变量,是编写可维护和可单元测试的软件的头号敌人。

最终,如果您在一个服务中存储所有应用程序数据或路由所有事件以启用组件交互,那么您只是在发明一个更好的捕鼠器。随着时间的推移,单个服务会变得庞大而复杂。这会导致不可预见的错误、由于无意中修改了无关数据而产生的副作用、由于先前视图中的数据无法丢弃而持续增加的内存使用,以及由于存储了应用程序中无关组件的数据而导致的低内聚。过度使用服务是一种应该不惜一切代价避免的反模式。在后面的章节中,您会发现,本质上,我们仍然会使用服务来启用组件交互;然而,我想指出,在允许解耦的灵活架构和全球或集中式解耦方法之间存在着一条细线,后者扩展性不佳。

使用事件发射器的子父关系

您的子组件应该完全不知道其父组件。这是创建可重用组件的关键。

我们可以通过利用AppComponent作为父元素,让AppComponent控制器协调数据来实现CitySearchComponentCurrentWeatherComponent之间的通信。

立即提交您的代码! 在接下来的两节中,您将进行代码更改,这些更改您将需要丢弃。

让我们看看这个实现将如何呈现:

  1. CitySearchComponent通过一个@Output属性暴露EventEmitter

    **src/app/city-search/city-search.****component****.****ts**
    import { Component, OnInit, Output, EventEmitter } from '@angular/core'
    export class CitySearchComponent implements OnInit {
      @Output() searchEvent = new EventEmitter<string>()
      ...
      this.search.valueChanges
        .pipe(debounceTime(1000))
        .subscribe((search Value: string) => {
          if (!this.search.invalid) {
            this.searchEvent.emit(searchValue)
          }
        })
      ...
    } 
    
  2. AppComponent消费这些数据并调用weatherService,设置currentWeather变量:

    **src/app/app.****component****.****ts**
    import { WeatherService } from './weather/weather.service'
    import { ICurrentWeather } from './interfaces'
    ...
    template: `
      ...
        <app-city-search (searchEvent)="doSearch($event)">
        </app-city-search>
      ...
    `,
    export class AppComponent { 
      currentWeather: ICurrentWeather 
      constructor(private weatherService: WeatherService) { }
      doSearch(searchValue) {
        const userInput = searchValue.split(',').map(
          s => s.trim()) 
        this.weatherService
          .getCurrentWeather(
            userInput[0], userInput.length > 1 ?
            userInput[1] : undefined
        )
        .subscribe(data => this.currentWeather = data)
      }
    } 
    

注意,我们使用括号语法绑定到searchEvent$event变量自动捕获事件输出并将其传递到doSearch方法。

我们成功地将信息冒泡到父组件,但我们也必须能够将其传递到CurrentWeatherComponent

使用输入绑定的父子关系

根据定义,父组件将知道它正在与哪些子组件一起工作。由于currentWeather属性绑定到CurrentWeatherComponent上的current属性,结果会向下传递以供显示。这是通过创建一个@Input属性来实现的:

**src/app/current-weather/current-weather.****component****.****ts**
import { Component, **Input** } from '@angular/core'
...
export class CurrentWeatherComponent { 
  **@Input****()** current: ICurrentWeather
  ...
} 

注意,CurrentWeatherComponentngOnInit函数现在已不再必要,可以删除。

然后,您可以更新AppComponent以将数据绑定到当前天气:

**src/app/app.****component****.****ts**
template: `
  ...
  <app-current-weather **[current]="currentWeather"**>
  </app-current-weather>
  ...
` 

到目前为止,您的代码应该可以正常工作!尝试搜索一个城市。如果CurrentWeatherComponent更新,那么就成功了!

事件发射器到输入绑定的方法适用于您正在创建紧密耦合的组件或用户控件,并且子组件没有消费任何外部数据的情况。一个很好的示例可能是向CurrentWeatherComponent添加预测信息,如下所示:

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

图 2.5:天气预报线框图

每周每一天都可以实现为一个使用@for重复的组件,并且对于CurrentWeatherComponent来说,检索并将此信息绑定到其子组件是完全合理的:

**example**
@for (dailyForecast of forecastArray; track dailyForecast) {
  <app-mini-forecast [forecast]="dailyForecast">
  </app-mini-forecast>
} 

通常,如果你正在处理数据驱动的组件,父-子或子-父通信模式会导致一个不灵活的架构,这使得重用或重新排列你的组件变得困难。一个紧密耦合的好例子是我们将WeatherService导入到app.component.ts中。请注意,AppComponent不应该知道WeatherService;它的唯一任务是布局几个组件。鉴于不断变化的企业需求和设计,这是一个需要记住的重要教训。

在继续之前,丢弃两个部分的更改。我们将实现一个替代解决方案。

接下来,我们将介绍一种更好的方法,让两个组件在不引入与subject额外耦合的情况下相互交互。

subject的兄弟交互

组件交互的主要原因是为了发送或接收由用户提供或从服务器接收的数据更新。在 Angular 中,你的服务暴露RxJS/Observable端点,这些是组件可以订阅的数据流。RxJS/Observer作为RxJS/Observable事件的消费者,补充了RxJS/ObservableRxJS/Subject将这两种功能结合在一个易于使用的包中。

你可以用subject来本质上描述属于特定数据集的流,例如正在显示的当前天气数据:

**example**
import { Subject } from 'rxjs'
...
export class WeatherService implements IWeatherService { 
  currentWeather$: Subject<ICurrentWeather>
  ...
} 

currentWeather$仍然是一个数据流,并不简单地代表一个数据点。你可以使用subscribe来订阅currentWeather$数据的变化,或者你可以使用next来发布对其的变化,如下所示:

**example**
currentWeather$.subscribe(data => (this.current = data))
  currentWeather$.next(newData) 

注意currentWeather$属性的命名约定,它以$结尾。这是可观察属性的命名约定。

Subject的默认行为非常类似于通用的发布/订阅机制,例如 jQuery 事件。然而,在一个异步世界中,组件以不可预测的方式加载或卸载,使用默认的Subject并不是非常有用。

存在三种高级的subject变体:

  • ReplaySubject会记住并缓存数据流中发生的数据点,以便订阅者可以在任何给定时间回放旧事件。

  • BehaviorSubject在监听新数据点时只记住最后一个数据点。

  • AsyncSubject用于一次性事件,这些事件预计不会再次发生。

ReplaySubject 可能会对你的应用程序的内存和性能产生严重影响,因此应谨慎使用。在 CurrentWeatherComponent 的情况下,我们只对显示接收到的最新天气数据感兴趣,但通过用户输入或其他事件,我们愿意接收新数据以保持 CurrentWeatherComponent 的更新。BehaviorSubject 将是满足这些需求适当的机制:

  1. currentWeather$ 作为只读属性添加到 IWeatherService

    **src/app/weather/weather.****service****.****ts**
    import { BehaviorSubject, Observable } from 'rxjs'
    export interface IWeatherService {
      **readonly****currentWeather$****:** **BehaviorSubject****<****ICurrentWeather****>**
      ...
    } 
    

    currentWeather$ 被声明为只读,因为其 BehaviorSubject 不应重新分配。它是我们的数据锚点或引用,而不是数据本身的副本。任何对值的更新都应该通过在属性上调用 next 函数来发送。

  2. WeatherService 中定义 BehaviorSubject 并设置默认值:

    **src/app/weather/weather.****service****.****ts**
    ...
    export class WeatherService implements IWeatherService {
      readonly currentWeather$ =
        new BehaviorSubject<ICurrentWeather>({ 
        city: '--',
        country: '--',
        date: Date.now(),
        image: '',
        temperature: 0,
        description: '',
      })
    ...
    } 
    
  3. 添加一个名为 updateCurrentWeather 的新函数,该函数将触发 getCurrentWeather 并更新 currentWeather$ 的值:

    **src/app/weather/weather.****service****.****ts**
    ...
    updateCurrentWeather(search: string | number, 
      country?: string): void { 
      this.getCurrentWeather(search, country)
        .subscribe(weather => 
          this.currentWeather$.next(weather)
        )
    }
    ... 
    
  4. 使用新函数更新 IWeatherService 以使其如下所示:

    **src/app/weather/weather.****service****.****ts**
    ...
    export interface IWeatherService {
      readonly currentWeather$: BehaviorSubject<ICurrentWeather> 
      getCurrentWeather(city: string | number, country?: string):
        Observable<ICurrentWeather>
      getCurrentWeatherByCoords(coords: Coordinates):
        Observable<ICurrentWeather>
        **updateCurrentWeather****(**
          **search****:** **string** **|** **number****, country?:** **string **    **):** **void**
    } 
    
  5. 更新 CurrentWeatherComponent 以订阅新的 BehaviorSubject

    **src/app/current-weather/current-weather.****component****.****ts**
    ...
      ngOnInit() { 
        this.weatherService.currentWeather$
          .subscribe(data => (this.current = data))
      }
    ... 
    
  6. CitySearchComponent 中,更新 getCurrentWeather 函数调用以利用新的 updateCurrentWeather 函数:

    **src/app/city-search/city-search.****component****.****ts**
    ...
      this.weatherService.**updateCurrentWeather**( 
        userInput[0],
        userInput.length > 1 ? userInput[1] : undefined
      )
    ... 
    
  7. 在浏览器中测试你的应用程序;它应该如下所示:天气预报屏幕截图  描述由中等置信度自动生成

    图 2.6:土耳其布尔萨的天气信息

当你输入一个新城市时,组件应该更新以包含该城市的当前天气信息。我们可以将 添加城市搜索功能... 任务移动到 完成 列,如图所示在我们的看板板上:

计算机屏幕截图  描述由中等置信度自动生成

图 2.7:GitHub 项目看板板状态

我们有一个功能齐全的应用程序。然而,我们在处理 currentWeather$ 订阅的方式中引入了内存泄漏。在下一节中,我们将回顾内存泄漏是如何发生的,以及如何通过使用 firsttakeUntilDestroyed 操作来完全避免它们。

管理订阅

订阅是读取数据流中值以供应用程序逻辑使用的一种便捷方式。如果未管理,它们可以在你的应用程序中创建内存泄漏。一个泄漏的应用程序将消耗越来越多的 RAM,最终导致浏览器标签页无响应,导致对你的应用产生负面印象,甚至更糟的是,可能的数据丢失,这可能会让最终用户感到沮丧。

内存泄漏的源头可能不明显。在 CurrentWeatherComponent 中,我们注入 WeatherSevice 以访问 BehaviorSubjectcurrentWeather$ 值。如果我们管理不当 currentWeather$ 订阅,我们可能会在组件或服务中产生泄漏。

服务的生命周期

默认情况下,Angular 服务是自动注册到根提供者共享实例服务或单例。这意味着一旦在内存中创建,只要它们所属的应用程序或功能模块仍然在内存中,它们就会被保留。请参见以下共享实例服务的示例:

@Injectable({
  **providedIn****: 'root'**
})
export class WeatherService implements IWeatherService
... 

从实际的角度来看,这意味着你应用程序中的大多数服务将存在于应用程序的生命周期内。然而,组件的生命周期可能要短得多,或者可能会有多个相同组件的实例被反复创建。

此外,还有一些用例需要组件自己的实例或服务的副本(例如,缓存表单输入的值或同时显示不同城市的天气)。要创建多个实例服务,请参见下面的示例:

@Injectable()
export class WeatherService implements IWeatherService
... 

然后,您将为该服务提供一个组件提供者

@Component({
  selector: 'app-current-weather',
  standalone: true,
  **providers****: [****WeatherService****]**
})
export class CurrentWeatherComponent {
... 

在这种情况下,当组件被销毁时,服务也会被销毁。但这并不是防止内存泄漏的保护措施。如果我们没有仔细管理长生命期和短生命期对象之间的交互,我们可能会在对象之间留下悬挂引用,从而导致内存泄漏。

内存泄漏的揭露

当我们订阅currentWeather$时,我们将其附加到一个事件处理器上,以便CurrentWeatherComponent可以响应推送到BehaviorSubject的值变化。当组件需要被销毁时,这会带来问题。

在像 JavaScript 这样的管理语言中,内存由垃圾回收器(GC)管理,而不是像在 C 或 C++这样的非管理语言中那样手动分配和释放内存。在非常高的层面上,GC 定期扫描堆栈中其他对象未引用的对象。

如果发现某个对象被取消引用,那么它占用的堆栈空间可以被释放。然而,如果一个未使用的对象仍然引用另一个仍在使用的对象,它就不能被垃圾回收。GC 不是魔法,不能读我们的心思。当一个对象未使用且无法被解除分配时,只要你的应用程序在运行,该对象占用的内存就永远不能用于其他目的。这被认为是内存泄漏。

我的同事 Brendon Caulkins 提供了一个有用的类比:

想象浏览器的内存空间就像一个停车场;每次我们分配一个值或创建一个订阅时,我们就在这个停车场停车。如果我们不小心丢弃了一辆车,我们仍然会留下停车位被占用;其他人无法使用它。如果浏览器中的所有应用程序都这样做,或者我们反复这样做,你可以想象停车场会很快填满,我们永远无法运行我们的应用程序。

接下来,让我们看看我们如何确保我们不把车留在停车场里。

取消订阅

订阅或事件处理器会创建对其他对象的引用,例如从短生命周期的组件到长生命周期的服务。诚然,在我们的情况下,CurrentWeatherComponent也是一个单例,但如果我们向应用程序添加更多功能,在页面之间导航或同时显示多个城市的天气,这可能会改变。如果我们不取消currentWeather$的订阅,那么CurrentWeatherComponent的任何实例都会卡在内存中。我们在ngOnInit中订阅,所以我们必须在ngOnDestroy中取消订阅。ngOnDestroy是在 Angular 确定框架不再使用组件时被调用的。

让我们看看以下示例代码中如何取消订阅订阅的例子:

**example**
import { ..., OnDestroy } from '@angular/core'
import { ..., Subscription } from 'rxjs'
export class CurrentWeatherComponent implements OnInit, OnDestroy { 
  currentWeatherSubscription: Subscription
  ...
  ngOnInit() { 
    this.currentWeatherSubscription =
      this.weatherService.currentWeather$
        .subscribe((data) => (this.current = data))
  }
  ngOnDestroy(): void {
    this.currentWeatherSubscription.unsubscribe()
  }
... 

首先,我们需要为组件实现OnDestroy接口。然后,我们将ngOnInit更新为将订阅存储在名为currentWeatherSubscription的属性中。最后,在ngOnDestroy中,我们可以调用unsubscribe方法。

如果我们的组件被销毁,它将不再导致内存泄漏。然而,如果我们在一个组件中有多个订阅,这会导致大量的繁琐编码。

注意,在CitySearchComponent中,我们订阅了FormControl对象的valueChanges事件。我们不需要管理此事件的订阅,因为FormControl是我们组件的子对象。当父组件从所有对象中取消引用时,所有其子对象都可以安全地被 GC 收集。

订阅数据流中的值本身可以被认为是一种反模式,因为您将编程模型从响应式切换到命令式。但当然,我们必须至少订阅一次以激活数据流。在下一节中,我们将介绍如何确保在订阅时不会泄漏内存。

使用 first 或 takeUntilDestroyed 进行订阅

默认情况下,可观察流不会结束。鉴于 RxJS 在每次 Angular 操作中都根深蒂固,这很少是期望的结果。在订阅资源时,我们可以应用两种常见的策略,以确保流可以可预测地完成,并且不会导致内存泄漏。

第一种策略,嗯,就是第一种方法。观察WeatherService中的updateCurrentWeather方法:

**src/app/weather/weather.****service****.****ts**
import { map, switchMap, first } from 'rxjs/operators' 
export class WeatherService implements IWeatherService{
  ...
    updateCurrentWeather(searchText: string, country?: string): void {
      this.getCurrentWeather(searchText, country)
        .pipe(first())
        .subscribe((weather) => this.currentWeather$.next(weather))
    }  
  ... 

在上面的例子中,我们的目的是获取当前天气并将其显示出来——并且只在每个请求中这样做一次。通过将一个first()调用管道输入到可观察流中,我们指示 RxJS 在接收到一个结果后完成流。这样,当正在 GC(垃圾回收)使用此流的资源时,相关的 RxJS 对象不会引起泄漏。

第二种策略是takeUntilDestroyed。对于将更新多次的组件,first()策略没有意义。例如CurrentWeatherComponent可以在用户输入新的搜索文本后更新,因此我们希望在组件存在期间接收更新。请看以下示例:

**src/app/current-weather/current-weather.****component****.****ts**
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
export class CurrentWeatherComponent implements OnInit {
  private destroyRef = inject(DestroyRef);
  ...
  ngOnInit(): void {
    this.weatherService
      .getCurrentWeather('Bethesda', 'US')
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe((data) => (this.current = data))
  }
  ... 

takeUntilDestroyed只能在注入器上下文中使用,即构造函数中。当在生命周期钩子调用(如ngOnInit)中使用它时,我们必须注入DestroyRef并将其传递到函数中。它会自动注册自己,因此当组件被销毁时,它会完成流。这样,组件可以在需要时接收消息,但没有任何内存泄漏的风险。

通过应用这些方法与subscribe方法一起,我们不必依赖于难以追踪的取消订阅方法,并且我们可以通过快速搜索单词subscribe来轻松验证它们的实现。

最好的部分是完全没有。接下来,让我们看看我们如何在不订阅的情况下消费一个可观察组件。

在响应式范式下编码

如在第一章Angular 的架构和概念中所述,我们应仅订阅可观察流以激活它。如果我们将subscribe函数视为事件处理器,我们就以命令式的方式实现我们的代码。

在你的代码库中看到除了空的subscribe()调用以外的任何内容都应该被视为一个红旗,因为它偏离了响应式范式。

在响应式编程中,当你在一个响应式流中订阅一个事件时,你将你的编码范式从响应式编程转换为命令式编程。在我们的应用程序中有两个地方我们进行了订阅,一个在CurrentWeatherComponent中,另一个在CitySearchComponent中。

让我们从修复CurrentWeatherComponent开始,这样我们就不会混淆范式。

使用异步管道绑定到可观察值

从一开始设计,Angular 就被设计成一个异步框架。你可以通过保持响应式范式来充分利用 Angular。一开始这样做可能会感觉不自然,但 Angular 提供了你需要的所有工具,让你无需切换到命令式编程就能将应用程序的当前状态反映给用户。

你可以在模板中使用async管道来反映可观察值的当前值。让我们更新CurrentWeatherComponent以使用async管道:

  1. 首先,将current: ICurrentWeather替换为一个可观察属性:

    current$: Observable<ICurrentWeather> 
    
  2. 在构造函数中,将weatherService.currentWeather$分配给current$

    **src/app/current-weather/current-weather.****component****.****ts**
    import { Observable } from 'rxjs'
    export class CurrentWeatherComponent {
      current$: Observable<ICurrentWeather>
      constructor(private weatherService: WeatherService) {
        this.current$ = this.weatherService.currentWeather$
      }
      ... 
    
  3. 删除所有与SubSinkngOnInitngOnDestroy相关的代码。

  4. 更新模板以便你可以绑定到current$

    **src/app/current-weather/current-weather.****component****.****html**
    @if (current$ | async; as current) {
      <div> ... </div>
    } 
    

    async管道会自动订阅current$的当前值,并将其作为current变量提供给模板以进行命令式使用。这种方法的优点是async管道隐式地管理订阅,因此你不必担心取消订阅。

  5. 删除不再需要的@else { <div>no data</div> }块,因为BehaviorSubject总是初始化的。

到目前为止,响应式范式已经使我们能够简化并清理我们的代码。

异步管道允许您使用简单的if-else逻辑显示加载消息。要显示在您的可观察对象解析时的消息,请参阅以下技术:

`example`
@if (current$ | async; as current) {
  <div>{{current}}</div>
} @else {
  <div>Loading…</div>
} 

接下来,让我们进一步改进我们的代码。

捕获可观察的数据流

当触发search函数时,CitySearchComponentsubscribe语句中实现了一个回调。这导致了一种命令式编程风格和思维模式。切换编程范式时的危险在于,您可以通过更容易地存储状态或创建错误来无意中引入代码库中的副作用。

让我们将CitySearchComponent重构为响应式函数式编程风格,如下例所示:

**src/app/city-search/city-search.****component****.****ts**
import { debounceTime, **filter, tap** } from 'rxjs/operators'
import { **takeUntilDestroyed** } from '@angular/core/rxjs-interop'
export class CitySearchComponent {
  search = new FormControl('', 
    [**Validators****.****required**, Validators.minLength(2)])
  constructor(private weatherService: WeatherService) { 
    **this****.****search****.****valueChanges**
      **.****pipe****(**
        **takeUntilDestroyed****(),**
        **filter****(****() =>****this****.****search****.****valid****),**
        **debounceTime****(****1000****),**
        **tap****(****(****searchValue****:** **string****) =>****this****.****doSearch****(searchValue))**
        **takeUntilDestroyed()**
      **).****subscribe****()**
}
doSearch(searchValue: string) {
  const userInput = searchValue.split(',').map(s => s.trim())
  const searchText = userInput[0]
  const country = userInput.length > 1 ? userInput[1] : undefined
  this.weatherService.updateCurrentWeather(searchText, country)
}
} 

在前面的代码中,我们移除了OnInit实现并实现了我们的响应式过滤逻辑。tap操作符只有在this.search有效时才会被触发。

constructor仅应在处理类上下文内的属性和事件时使用。在这种情况下,搜索在定义时初始化,并且valueChanges只能由用户与组件的交互触发。因此,在constructor中设置订阅逻辑是可行的。

然而,如果您在模板中引用任何属性、@Input变量或注册外部服务调用,则必须使用ngOnInit。否则,您将遇到渲染错误或不可预测的行为。这是因为模板属性,包括@Input变量,直到ngOnInit被调用才可用。此外,外部服务调用可能在组件初始化之前返回响应,导致检测错误。

简而言之,99%的时间您应该使用ngOnInit

此外,doSearch在函数式上下文中被调用,这使得在函数内部引用任何其他类属性变得非常困难。这减少了类状态影响我们函数结果的可能性。因此,doSearch是一个可组合的且可单元测试的函数,而在先前的实现中,以直接方式单元测试ngOnInit将非常具有挑战性。

注意,必须在对valueChanges调用subscribe()来激活可观察数据流。否则,不会触发任何事件。

我们不需要实现ngOnInit的事实反映了我们代码的真实异步性,它与应用程序的生命周期或状态无关。然而,您应该坚持使用ngOnInit作为一般最佳实践。

我们的重构完成后,应用程序应该与之前相同,但具有更少的样板代码。现在,让我们看看如何增强我们的应用程序以处理来自任何国家的邮政编码。

链接 API 调用

目前,我们的应用程序只能处理来自美国的 5 位数字邮政或邮编。例如,邮编 22201 可以通过简单的条件 typeof search === 'string' 与城市名称区分开来。然而,邮编在不同国家之间差异很大,英国就是一个很好的例子,其邮编如 EC2R 6AB。即使我们对每个国家的邮编格式有完美的理解,我们仍然不能确保用户没有输入一个轻微错误的邮编。今天的复杂用户期望网络应用程序对这类错误具有弹性。然而,作为网络开发者,我们不能期望手动编写一个通用的邮编验证服务。相反,在我们向 OpenWeatherMap API 发送请求之前,我们需要利用外部服务。让我们探索如何串联依赖彼此的连续 API 调用。

在本书的第一版出版后,我收到了一些热情的读者反馈,他们对示例应用程序只能支持美国邮编表示失望。我已经实现了这个功能,因为它展示了简单的请求如何引入您应用程序中的未计划复杂性。作为额外的好处,应用程序现在可以在全球范围内使用。

让我们添加一个新项目,支持国际邮编,并将其移动到进行中

聊天截图,描述自动生成,置信度中等

图 2.8:添加国际邮编故事

实现邮编服务

为了正确理解用户输入的是有效的邮编还是城市名称,我们必须依赖 geonames.org 提供的第三方 API 调用。让我们看看我们如何将二级 API 调用注入到我们应用程序的搜索逻辑中。

您需要在 geonames.org 上注册一个免费账户。之后,将用户名作为新参数存储在 environment.tsenvironment.prod.ts 文件中。

您可以在 www.geonames.org/postal-codes 交互式地实验邮政编码 API。

我们需要实现一个符合以下接口的服务:

interface IPostalCodeService {
  resolvePostalCode(postalCode: string): Observable<IPostalCode>
} 

在您最初设计应用程序时,声明服务接口是一种有用的实践。您和您的团队成员可以专注于提供正确的交互模型,而不会被实现细节所困扰。一旦您的接口定义完成,您就可以快速创建功能原型,并放置应用程序的行走骨架版本。原型化的函数有助于验证设计选择并鼓励组件之间的早期集成。一旦实施,团队成员就不再需要猜测他们是否在正确的位置编码。您应该始终导出接口,这样您就可以使用类型信息编写单元测试,创建测试替身或模拟。

接口是实践测试驱动开发TDD)的关键。

现在按照以下示例实现 PostalCodeService

你可以通过执行 npx ng generate service postalCode --project=local-weather-app --no-flat 来生成服务。

**src/app/postal-code/postal-code.****service****.****ts**
import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { Observable } from 'rxjs'
import { defaultIfEmpty, flatMap } from 'rxjs/operators'
import { environment } from '../../environments/environment'
export interface IPostalCode {
  countryCode: string
  postalCode: string
  placeName: string
  lng: number
  lat: number
}
export interface IPostalCodeData { 
  postalCodes: [IPostalCode]
}
export interface IPostalCodeService {
  resolvePostalCode(postalCode: string): Observable<IPostalCode>
}
@Injectable({
  providedIn: 'root',
}) 
export class PostalCodeService implements IPostalCodeService {
  constructor(private httpClient: HttpClient) {}
  resolvePostalCode(postalCode: string): Observable<IPostalCode> {
    const uriParams = new HttpParams()
      .set('maxRows', '1')
      .set('username', environment.username)
      .set('postalcode', postalCode)
    return this.httpClient
      .get<IPostalCodeData>(
        `${environment.baseUrl}${environment.geonamesApi}.geonames.org/
          postalCodeSearchJSON`,
      { params: uriParams }
    )
    .pipe(
      flatMap(data => data.postalCodes), 
      defaultIfEmpty(null)
    )
  }
} 

注意新的环境变量 environment.geonamesApi。在 environment.ts 中将其值设置为 api,在 environment.prod.ts 中设置为 secure,这样 HTTPS 调用就可以正确工作,避免混合内容错误,如第十章 使用 CI/CD 发布到生产 中所述。

在前面的代码段中,我们实现了一个 resolvePostalCode 函数,该函数调用一个配置为接收 API 返回的第一个有效结果的 API。然后结果被展平并传递给订阅者。使用 defaultIfEmpty,我们确保如果没有从 API 收到结果,将提供一个 null 值。如果调用成功,我们将得到 IpostalCode 中定义的所有信息,这使得可以使用坐标调用 getCurrentWeatherByCoords

使用 switchMap 进行可观察的序列化

让我们更新天气服务,使其能够调用 postalCode 服务以确定用户输入是否为有效的邮政编码:

  1. 首先,更新接口,这样我们只处理字符串:

    **src/app/weather/weather.****service****.****ts**
    …
    export interface IWeatherService {
    ...
      getCurrentWeather(search: **string**, country?: string): 
        Observable<ICurrentWeather>
      updateCurrentWeather(search: **string**, country?: string)
    } 
    
  2. PostalCodeService 注入到天气服务作为私有属性:

    **src/app/weather/weather.****service****.****ts**
    import {
      PostalCodeService
    } from '../postal-code/postal-code.service'
    ...
    constructor(
      private httpClient: HttpClient,
      private postalCodeService: PostalCodeService
    ) {} 
    
  3. 更新 updateCurrentWeather 方法的签名。

  4. 更新 getCurrentWeather 以尝试将 searchText 解析为邮政编码:

    **src/app/weather/weather.****service****.****ts**
    import { map, **switchMap** } from 'rxjs/operators'
    ... 
    getCurrentWeather(
      searchText: string,
      country?: string
    ): Observable<ICurrentWeather> {
      return this.postalCodeService.
        resolvePostalCode(searchText)
        .pipe(
          switchMap((postalCode) => {
            if (postalCode) {
              return this.getCurrentWeatherByCoords({
                latitude: postalCode.lat,
                longitude: postalCode.lng,
              } as Coordinates)
            } else {
              const uriParams = new HttpParams().set(
                'q',
                country ? `${searchText},${country}` : searchText
              )
              return this.getCurrentWeatherHelper(uriParams)
            }
         })
       )
    } 
    

如果你在将纬度和经度传递给 getCurrentWeatherByCoords 时遇到 TypeScript 问题,你可能需要使用 as 操作符来强制转换对象。因此,你的代码看起来会是这样:

 return this.getCurrentWeatherByCoords({
   latitude: postalCode.lat,
   longitude: postalCode.lng,
 } as Coordinates) 

在前面的代码段中,我们的第一次调用是到 postalCode 服务。然后我们使用 switchMap 对数据流上发布的邮政编码做出反应。在 switchMap 内部,我们可以观察 postalCode 是否为 null,并做出适当的后续调用,要么通过坐标获取当前天气,要么通过城市名称获取。

现在,LocalCast 天气应该可以与全球邮政编码一起工作,如下面的截图所示:

天气预报截图,描述自动生成,置信度中等

图 2.9:带有全球邮政编码的 LocalCast 天气

我们已经完成了国际邮政编码支持的实施。将其移动到你的看板上的 完成 列:

计算机截图,描述自动生成,置信度中等

图 2.10:国际邮政编码支持完成

随着我们完成 LocalCast 天气应用程序的实现,仍有改进的空间。最初,由于显示的破折号和空字段,应用程序看起来损坏。至少有两种不同的方法来处理这个问题。第一种是在没有数据显示时,在 AppComponent 级别隐藏整个组件。为了使这可行,我们必须将 WeatherService 注入到 AppComponent 中,这最终导致了一个灵活性较低的解决方案。另一种方法是增强 CurrentWeatherComponent,使其能够更好地处理缺失的数据。

通过实现地理位置功能,您可以在启动应用程序时获取用户的当前位置的天气。您还可以利用 window.localStorage 在应用程序初始启动时存储最后显示的城市或从 window.geolocation 获取的最后位置。

我们完成了 LocalCast 天气应用程序的本地化,直到 第九章食谱 – 主/详细页面,数据表和 NgRx,在那里我展示了像 NgRx 这样的状态存储与使用 RxJS/BehaviorSubject 相比的情况。

使用 Angular 信号

信号是一种反应原语,它跟踪其值随时间的变化。Angular 信号实现了这种原语,以细粒度同步应用程序状态与 DOM。通过关注状态中的细粒度变化以及仅相关的 DOM 节点,可以显著减少变更检测操作的数量和严重程度。如 第一章Angular 的架构和概念 中所述,变更检测是 Angular 框架执行的最昂贵的操作之一。随着应用程序复杂性的增加,变更检测操作可能被迫遍历或更新 DOM 树的更大部分。随着应用程序中交互元素的数量的增加,变更检测事件发生的频率也会更高。应用程序的复杂性与事件频率的结合可能会引入重大的性能问题,导致应用程序渲染缓慢或断断续续。通常,对于这类问题没有快速的解决方案。因此,了解信号的工作原理并在您的应用程序中实现它们以避免昂贵的性能问题是至关重要的。

截至本次发布,Angular 信号处于预览阶段。这意味着功能集的功能和性能特征可能会发生变化。有关最新信息,请参阅以下指南:angular.dev/guide/signals

Angular 信号提供了一些简单的函数来与之交互:

  • signal:一个值的包装器。它就像类中的值获取器或设置器一样工作,在概念上类似于 BehaviorSubject 的工作方式:

    const mySignal = signal('Hello')
    console.log(mySignal()) // outputs: Hello (only once)
    mySignal.set('Goodbye') // updates the value. Update and mutate methods have subtle differences in setting a new value.
    // To display the new value, you must call console.log again. 
    
  • computed:一个计算信号。它利用一个或多个信号来修改结果:

    const someSignal = computed(() => `${mySignal()}, World`)
    console.log(someSignal()) // outputs: Hello, World. If needed, it lazily updates when mySignal is set to a new value. 
    
  • effect:当信号发生变化时触发的事件:

    effect(() => {
      console.log(`A robot says: ${someSignal}`) 
    })  
    // console.log will be called any time mySignal changes. 
    

信号是一个新的基础概念,它们改变了我们对可观察对象、数据绑定和组件之间状态同步的看法。它们性能出色,性质上具有手术性,而且最好,它们是内存安全的。这里没有订阅需要担心。

让我们从使用信号的一个简单示例开始。

实现深色模式

为了让我们的应用程序在技术人员眼中看起来很酷,我们必须为它实现一个深色模式。让我们使用信号来实现这个功能,并进一步通过在 localStorage 中记住用户的选择来更进一步:

**src/app/app.****component****.****ts**
const darkClassName = 'dark-theme'
@Component({
  selector: 'app-root',
  standalone: true,
  imports: [...],
  template: `
    <mat-toolbar color="primary">
      <span data-testid="title">LocalCast Weather</span>
      <div fxFlex></div>
      <mat-icon>brightness_5</mat-icon>
      <mat-slide-toggle
        color="warn"
        data-testid="darkmode-toggle"
        [checked]="toggleState()"
        (change)="toggleState.set($event.checked)"></mat-slide-toggle>
      <mat-icon>bedtime</mat-icon>
    </mat-toolbar>
    <div fxLayoutAlign="center">
      <div class="mat-caption vertical-margin">
        Your city, your forecast, right now!
      </div>
    </div>
    <div fxLayoutAlign="center">
      <app-city-search></app-city-search>
    </div>
    <div fxLayout="row">
      <div fxFlex></div>
      <mat-card appearance="outlined" fxFlex="300px">
        <mat-card-header>
          <mat-card-title>
            <div class="mat-headline-5">Current Weather</div>
          </mat-card-title>
        </mat-card-header>
        <mat-card-content>
          <app-current-weather></app-current-weather>
        </mat-card-content>
      </mat-card>
      <div fxFlex></div>
    </div>
  `,
})
export class AppComponent {
  readonly toggleState = signal(localStorage.getItem(darkClassName) === 'true')
  constructor() {
    effect(() => {
      localStorage.setItem(darkClassName, this.toggleState().toString())
      document.documentElement.classList.toggle(
        darkClassName, this.toggleState()
      )
    })
  }
} 

如果这是生产代码,我不会使用这样简短的代码行:

document.documentElement.classList.toggle(darkClassName, this. toggleState()) 

在这里,我希望将代码行数保持在最低,并且 DOM API 提供的切换函数包含了使这正确工作的逻辑。这一行应该重构以符合单一责任原则。

观察名为 toggleStatereadonly 属性。这是我们信号。它包含一个布尔值。我们可以通过从 localStorage 读取值来初始化它;如果它不存在,它将默认为 false

在工具栏中,我们定义 mat-slide-toggle 并将其 checked 状态分配给 toggleState()。这会将信号值绑定到组件上。通过分配 (change)="toggleState.set($event.checked)",我们确保当用户翻转开关时,其值将被写回到信号中。

最后,我们实现 effect 方法来响应信号值的变化。在构造函数中,我们可以在 effect 函数内定义我们想要的操作行为。首先,我们使用 toggleState 的当前值更新 localStorage,其次,我们将 dark-theme 类设置在 DOM 上以切换深色模式状态。

我们利用 Angular Material 内置的深色主题功能来定义一个深色主题,并将其附加到名为 dark-theme 的 CSS 类。请参阅 styles.scss 以了解如何配置。

我们本可以实现这一功能至少六种不同的方式,但信号确实提供了一种非常经济的方式来实现。

我们可以在此基础上构建,并在整个应用程序中替换 BehaviorSubject[ngModel] 的使用。这样做极大地简化了我们的 Angular 应用程序的工作方式,同时减少了包的大小和复杂性。

用信号替换 BehaviorSubject

现在,让我们看看使用信号而不是 BehaviorSubject 的样子。实现一个信号意味着我们必须改变值检索和显示的端到端管道。信号是一个同步管道,而 RxJS 是异步的。

你可能会想,异步不是比同步更好吗?是的,但并不是当同步代码可以以非阻塞方式运行时。异步是昂贵的,而且由于底层所利用的基本技术,信号要便宜得多,速度快得多。这得益于 JavaScript 中现在内置的出色功能。参见 Justin Schroeder 的www.arrow-js.com作为例子。某些类型和规模的项目不再需要像 Angular、React 或 Vue 这样的完整框架。

我们需要更新WeatherServiceCitySearchComponentCurrentWeatherComponent

  1. 首先将WeatherService中的currentWeather$替换为currentWeatherSignal

    **src/app/weather/weather.****service****.****ts**
    import { signal } from '@angular/core'
    export class WeatherService implements IWeatherService{
      ...
      readonly currentWeatherSignal = signal(defaultWeather)  
      ... 
    
  2. 实现一个新的getCurrentWeatherAsPromise函数,将可观察对象转换为Promise,以及一个新的updateCurrentWeatherSignal函数,以等待调用结果并将结果分配给信号:

    **src/app/weather/weather.****service****.****ts**
    **import** **{ ..., firstValueFrom }** **from****'****rxjs'**
      getCurrentWeatherAsPromise(
        searchText: string,
        country?: string
      ): Promise<ICurrentWeather> {
        return firstValueFrom(
          this.getCurrentWeather(searchText, country)
        )
      }
      async updateCurrentWeatherSignal(searchText: string,
        country?: string): Promise<void> {
        this.currentWeatherSignal.set(
          await this.getCurrentWeatherAsPromise(
            searchText, country
          )
        )
      } 
    

    注意,我们使用firstValueFrom以确保流按预期完成。

  3. 接下来,在CurrentWeatherComponent中将current$属性替换为currentSignal

    **src/app/current-weather/current-weather.****component****.****ts**
    export class CurrentWeatherComponent {
      readonly currentSignal: WritableSignal<ICurrentWeather>
      constructor(private weatherService: WeatherService) {
        this.currentSignal = this.weatherService.currentWeatherSignal
    ... 
    
  4. 更新模板以使用信号:

    **src/app/current-weather/current-weather.****component****.****html**
    @if (currentSignal(); as current) {
      ...
    } 
    
  5. 最后,更新CitySearchComponent以触发新的服务调用:

    **src/app/city-search/city-search.****component****.****ts**
    export class CitySearchComponent {
      ...
      this.weatherService.updateCurrentWeatherSignal(
        searchText, country
      )
      ... 
    

我们已经将应用转换为使用信号在组件之间进行通信。信号比BehaviorSubject简单,但大多数时候,额外的功能并没有被使用。信号是内存安全的、轻量级的,并且通过利用计算信号(如本章前面讨论的 RxJS 合并技术)允许新颖的应用。

最终,RxJS 和信号是互补的技术。例如,我们不会考虑在 RxJS 之外替换搜索输入中的防抖逻辑。Angular 还提供了toSignalfromSignal互操作性函数;然而,我警告不要混合范式。为了充分利用信号,始终优先考虑端到端的重构,正如本节所涵盖的。

在如此多的选项、范式、技术和陷阱中,你可能想知道是否可以使用 AI 生成此代码。我正是这样做的。请继续阅读。

使用 ChatGPT 生成应用

让我们看看如果让 ChatGPT 生成一个天气应用,我们会得到什么结果。在 2023 年 8 月,我让 ChatGPT 使用 GPT-4 和CodeInterpreter插件生成一个天气应用。我给了它以下提示:

编写一个 Angular 应用,使用 Angular Material 显示来自 openweathermap.org API 的实时天气数据,用户输入可以接受城市名、国家或邮政编码作为输入。

在进行几处小的修正后,这是我得到的结果:

计算机屏幕截图 自动生成描述

图 2.11:ChatGPT 天气应用 – 2023 年 8 月

ChatGPT 为我创建了一个非常简单直接的应用程序,其中包含一个使用双向绑定的输入字段的天气显示组件。服务调用在“获取天气”按钮触发的专用天气服务中正确实现。为了达到与我们构建的LocalCast应用程序相似的结果,我们必须提供一个包含更多技术细节的提示。非技术人员不知道要求具体的实现细节,而开发者可能觉得迭代开发他们的解决方案更容易。尽管如此,结果仍然令人印象深刻。

四个月后,我对上面段落中的前提产生了疑问。

如果开发者可以接受提供一两个额外的提示会怎样?

在 2023 年 12 月,我使用 GPT-4,没有使用任何插件,向 ChatGPT 提供了上述相同的提示,并在它生成代码后,我提供了额外的提示:

你能重写 weather.component.html 并将其样式设计得在桌面和移动设备上看起来都像专业设计吗?

然后,我得到了一个看起来好得多的结果!

计算机屏幕截图,自动生成描述

图 2.12:ChatGPT 天气应用初始版本 – 2023 年 12 月

然而,这个输出并不像我设计的。当然,ChatGPT 不知道我的设计是什么,而且详细地用文字描述它也太繁琐了。然后我想起了我有一个为 2018 年第一版创建的天气应用程序的手绘原型。

包含文本、手写、绘图、图表的图片,自动生成描述

图 2.13:LocalCast 的手绘线框图

是的,我确实使用了尺子!

在 2023 年 8 月,ChatGPT 无法看到,但从那时起,它已经获得了计算机视觉。我上传了原型,并说,“根据这个原型重新设计 UI。”记住,我的原型有三个屏幕,并且有难以阅读的手写文字。

带有 Sidenav 的初始结果

图 2.14:ChatGPT 天气应用第二版 – 2023 年 12 月

我震惊的是它注意到了SideNav并将其使用适当的 Material 组件和FlexLayout媒体查询来使其响应式地整合 – 就算我的手写被误解了。

我更新了生成的 UI 代码,使其具有交互性,并将其作为一个名为chat-get-dec23的项目包含在仓库中。这是结果:

带有 Sidenav 的初始结果

图 2.15:ChatGPT 天气应用最终版本 – 2023 年 12 月

这已经超出了令人印象深刻。到这本书的下一版出版时,这一章可能只有几页长,充满了提示,强调了在绘制原型时使用尺子的关键需求。

摘要

在本章中,你学习了如何使用MatInput、验证器、响应式表单和数据流驱动的处理程序创建搜索即输入的功能。你意识到了双向绑定和模板驱动的表单。你还学习了不同的策略来启用组件间的交互和数据共享。你深入了解了内存泄漏是如何产生的,以及管理你的订阅的重要性。

你现在可以区分命令式和响应式编程范式,并理解在可能的情况下坚持使用响应式编程的重要性。最后,你学习了如何通过链式调用多个 API 来实现复杂的功能。你了解了信号原语以及如何使用它来构建更简单、性能更优的应用程序。

LocalCast Weather 是一个简单的应用程序,我们用它来介绍 Angular 的基本概念。正如你所见,Angular 非常适合构建这样小型且动态的应用程序,同时向最终用户交付最少的框架代码。你应该考虑利用 Angular 进行快速且简单的项目,这对于构建更大的应用程序来说总是一个很好的实践。你还了解到,你可以使用像 ChatGPT 这样的 GenAI 工具,在开始一个新项目时给自己一个快速启动。

在下一章中,我们将深入探讨在企业应用程序中构建 Web 应用程序的考虑因素,并了解所有怪物隐藏的地方。我们将介绍如何使用以路由器为首要方法的设计和架构,构建具有一流身份验证和授权、用户体验以及覆盖大多数 LOB 应用程序需求的众多菜谱的可扩展 Angular 应用程序的业务线LOB)应用程序。

练习

在完成支持国际邮编功能后,我们是否在这里切换了编程范式?我们的实现是命令式、响应式,还是两者的组合?如果我们的实现不是完全响应式的,你将如何以响应式的方式实现这个函数?我将把这个作为读者的练习。

在继续之前,不要忘记执行npm testnpm run e2enpm run test:a11y。将修复单元测试和端到端测试的任务留给读者作为练习。

访问 GitHub 查看我为本章实现的单元测试,链接为github.com/duluca/local-weather-app/tree/master/projects/stage6

问题

尽可能好地回答以下问题,以确保你已理解本章的关键概念,而无需搜索任何内容。你知道你是否回答了所有问题吗?访问angularforenterprise.com/self-assessment获取更多信息:

  1. async管道是什么?

  2. 解释响应式和命令式编程有何不同,以及我们应该优先选择哪种技术。

  3. BehaviorSubject 的好处是什么,它用于什么?

  4. 什么是内存泄漏,为什么应该避免它们?

  5. 管理订阅的最佳方法是什么?

  6. Angular signals 与 RxJS streams 有何不同?

  7. 你可以使用 Angular Signals 的哪些方法来简化你的应用程序?

第三章:架构企业应用

第二章表单、Observables、Signals 和 Subjects 中,我们使用 LocalCast Weather 应用来展示 Angular 的各种特性,以便学习和实验,并告知我们这些特性是否适合更复杂的企业应用。构建企业应用与构建它们的团队一样重要,也与构建它们的技术一样重要。对未经证实的技术的过度热情和快速推广肯定会让你项目中的技术“黑洞”效应。如果你不熟悉“黑洞”,它们是由于地下材料溶解而发生的自然现象。在某个时候,通常突然,地面会塌陷,产生毁灭性的影响,露出地下的巨大空洞。

在这本书中,我尽力只包括经过验证、成熟且得到良好支持的技伎和实践。LocalCast Weather 包含了 Angular 的所有前沿特性,如根级别的独立项目、独立组件、信号和控制流语法。通过独立组件,我们摒弃了共享模块的概念,有助于解决循环依赖问题和由于共享模块过于臃肿而产生的冗余。然而,也存在一些挫折。独立项目需要库来支持提供者而不是模块;当缺少提供者时,将库应用于具有功能模块的项目变得繁琐且充满猜测。

控制流语法在预览中产生易于阅读的模板,并消除了导入结构指令的需要,从而减少了样板代码。同时,Angular 信号在部分预览中,直到基于信号的组件推出之前,不会带来重大价值。无论如何,Angular 信号借鉴了后面将要介绍的新的 JavaScript 基本类型,在 Minimalist JavaScript solutions 部分中,你应该了解并学会在 Angular 之外使用它们。在深入具体细节之前,让我们先考虑更大的图景。

在企业中构建的最常见类型的应用是业务线LOB)应用。LOB 应用是软件开发世界的基石。正如维基百科所定义的,LOB 是一个通用术语,指的是服务于特定客户交易或业务需求的产品或一系列相关产品。LOB 应用提供了展示各种功能和特性的绝佳机会,而无需涉及大型企业应用通常需要解决的扭曲或专业场景。

帕累托原则,也称为 80-20 规则,表明我们可以用 20% 的总体努力完成 80% 的目标。我们将把 80-20 规则应用于我们的 LOB 应用设计和架构。鉴于 LOB 应用覆盖的常见用例,它们非常适合 80-20 学习体验。只需 20% 的努力,你就可以了解 80% 你需要知道的内容,以便为用户提供高质量体验。

LOB 应用有一个奇特的特点。如果你构建了一个半有用的应用,其需求会无控制地增长,你很快就会成为你成功的受害者。平衡项目的架构需求具有挑战性;你想要避免潜在的毁灭性低工程,同时,在另一方面,避免为那些永远不会需要它的应用进行昂贵的过度工程。

在本章中,我将向你介绍一些要素,这些要素将帮助你在你企业中取得成功,作为架构师你应该考虑的事项,在构建高性能应用时应考虑的各种工具,如何使用路由优先架构设计大型应用,以及 80-20 设计解决方案来解决交付现代 Web 应用时增量迭代所面临的挑战。

如你在第一章Angular 的架构和概念中阅读的那样,软件架构不会保持静态。通过使用编码 kata、概念验证应用和参考项目来实践,使用新想法进行实验是至关重要的,这有助于你更好地创建更灵活的架构。

在本书的本章和剩余章节中,我们将设置一个具有丰富功能的新应用,它可以满足具有可扩展架构和工程最佳实践的 LOB 应用的需求。我们将遵循路由优先架构,这是一种依赖于可重用组件来创建名为LemonMart的杂货店 LOB 应用的设计模式。我们将讨论围绕主要数据实体进行设计,以及在实现各种条件导航元素之前完成应用的高级原型的重要性,这些元素在设计阶段可能会发生重大变化。

在本章中,你将学习以下内容:

  • 成为一个技术领导或架构师

  • 在你的企业中取得成功的要素

  • 使用 GitHub 项目进行看板规划

  • 构建高性能应用的工具

  • 将 80-20 法则应用于软件开发

  • 学习如何构建路由优先应用

让我们从你必须掌握的项目非技术方面开始,这样你就可以为团队的成功奠定基础。

成为一个技术领导或架构师

我将交替使用技术领导架构师这两个术语。根据你所在组织的规模,这可能是两个不同的角色,也可能是一个。但无论你担任哪个具体角色,在这些角色中,确保你项目成功以及最重要的是确保团队成员的幸福,都取决于你。

理解业务影响

你的第一个任务应该是理解你项目的业务影响。以下是一些需要提出的问题:

  • 这个项目的成功对业务有多重要?

  • 失败的后果是什么?

  • 失败意味着什么?

  • 哪些功能将带来最大的价值?

  • 你必须操作哪些参数?

  • 什么可以协商,什么不可以?

你对这些问题的回答将显著影响你的项目应该如何运作。如果企业的生存或声誉受到威胁,你必须放大确保企业生存或声誉不受损害的项目方面。如果某些功能集将带来最大价值,那么集中所有注意力在该部分获得高性能和高品质的用户体验,并将其他部分简化为初始发布。如果某些人员或技术被指定,权衡这些因素与其他因素。确保你选择的首席技术官CTO)选择的技术适合公司生死存亡的项目。不要进行不必要的斗争。

设定成功的参数

作为一项基本原则,利用团队成员的优势和激情是明智的。如果资源或时间紧张,这一点尤为重要。如果人们也在他们热爱的技术中投入时间,他们将会更努力地投资于你的项目。但不要以失败为代价——回到我在本章开头关于过度热衷于使用新技术的那一点。

为你的团队设定明确的成功参数非常重要。这只有在你自己清楚业务影响的情况下才可能实现。你和你的团队成员都在一条旅程上。你们都在一条道路上行走。偏离道路意味着危险,无论是对于个人团队成员还是项目的成功。这取决于你如何调整这条道路的宽窄。如果你让它太窄,你的团队成员会因为缺乏自由或过于严格的审查而窒息和沮丧。相反,如果你让它太宽,你将会有比实际工作更多的实验。你必须根据团队和项目的需求,在道路上设置适量的护栏。

弹性领导

你的领导风格不能是静态的。它必须是弹性的。所有团队和项目都有生命周期。它们可能经历生存、学习或自我组织的阶段。根据截止日期或外部因素,团队可能被迫从一个状态转变为另一个状态。

查看 Roy Osherove 在www.elasticleadership.com上关于弹性领导的优秀内容。

了解你的团队处于何种状态,并相应地调整你的领导风格。生存模式需要独裁者或保护者风格的领导者,你将深度参与所有团队活动,并指导个别任务和行动。你的目标应该是将处于这种状态的团队转移到学习模式。这就是你成为教练的时候,你不再那么参与日常活动,而是专注于团队成员应该学习什么以及他们应该如何成长。下一步将是处于自我组织模式的团队。这些团队具有弹性,他们可以自我成长,并且可以接受一般方向和战略并执行它们。在这个阶段,你需要成为一个促进者或仆人式领导者。如果你对一个成熟的团队发号施令,你很快就会失去这个团队。

谷歌的DevOps 研究和评估DORA)计划包含 30 个涵盖技术、流程和文化主题的能力,你的团队和组织可以掌握这些能力,成为一个高绩效团队。该研究项目自 2014 年起运行,考虑了来自各行各业和政府的 33,000 名专业人士的意见。了解更多关于该计划的信息,请访问dora.dev

如果你不懂这些概念,你可能会让你的团队成员处于他们将会筋疲力尽的情况,加班工作且士气低落,这可能会影响家庭生活,造成压力和高流失率。在这种情况下,你可能需要照照镜子,因为有这么多人站在那里。所有因素都会导致一个不利于健康组织运行的恶性循环。正如俗话所说,这不是短跑,而是马拉松。它不仅关乎达到终点,也关乎旅程。不要让你的团队失败。

成功项目的要素

你理解你项目的商业影响,并且你有一个渴望开始的团队;现在怎么办?当然,你必须有一个计划。正如德国陆军元帅莫尔特克所说(或者至少是现代对它的诠释),“没有计划能在与敌人的第一次接触中存活。”

五十年后,温斯顿·丘吉尔和德怀特·D·艾森豪威尔补充说,“计划的重要性很小[或者毫无价值],但规划是必不可少的[或者不可或缺的]。”

敏捷软件开发

在过去十年中,敏捷软件开发管理已经接管了世界,用 Scrum 取代了瀑布式项目执行,用看板板取代了甘特图,每两周进行一次迭代和增量交付,而不是多年开发周期、长周期和昂贵的质量保证QA)和支持周期。统计数据显示,敏捷项目比瀑布项目有更高的成功率。这从直觉上是有道理的,因为迭代和增量交付确保成功尽可能快地发生。

2001 年发布的位于agilemanifesto.org的敏捷宣言,强调了成为敏捷的关键要素:

个人和交互而非流程和工具

工作软件而非全面文档

客户协作而非合同谈判

针对变化而非遵循计划

大多数人在加入 Scrum 团队时接触到敏捷,因此大多数人混淆了敏捷和 Scrum。Scrum 是一个管理框架。像冲刺、规划会议、回顾和其他可选元素,如用户故事和故事点,与敏捷无关。执行 Scrum 并滥用所有可能的敏捷价值观是可能的。

在德克萨斯州奥斯汀的交付:敏捷 2018会议上,我听了 Ron Jeffries(2001 年敏捷宣言的签署者)和 Chet Hendrickson(一个幽默感极强的人,正确地批评我在 MacBook 的盖子上贴满了各种 JavaScript 贴纸)关于敏捷开发的演讲。他们聚焦于敏捷宣言的“工作软件”部分,以强调一个重要观点。无论多少流程、管理监督或会议都不会导致软件交付。正如我在 2018 年的演讲《交付或从未发生》中恰如其分地所说:

“这不是他们的责任[经理们]

这是我们[开发者]的责任。”

这不是关于创建漂亮的图表,对虚构的架构进行无休止的迭代。这也不是关于交付故事点、勾选框和将卡片从一个列移动到另一个列。简而言之,不要成为流程猴子。专注于交付可工作的代码。

敏捷工程最佳实践

实现这一目标的最佳方式是通过交付和遵守敏捷工程最佳实践:

  1. 测试驱动开发TDD):在编写实际代码之前编写测试,以确保代码满足要求。

  2. 持续集成CI):频繁地对代码提交运行自动构建和测试,以尽早发现问题。

  3. 持续交付CD):确保每个产品迭代都处于可发布状态。

  4. 结对编程:团队成员在共同完成任务时相互学习。

  5. 重构:作为日常工作的一部分,持续改进代码库。

  6. 简单且演进式设计:设计应尽可能简单,以满足当前需求,避免过度设计,并且足够灵活或开放,以便随着时间的推移而演进。

  7. 行为驱动开发BDD):使用基于软件预期行为的测试,辅以具体示例,采用类似于 Gherkin(GivenWhenThen格式)的语法,以便技术和非技术人员可以协作。

  8. 频繁发布:频繁以小批量交付软件。

  9. 高内聚低耦合:模块或组件专注于单一任务,同时这些模块或组件之间相互依赖最小。

  10. 遵循 SOLID 原则:

    1. 单一职责原则:函数或类只应负责一项任务。

    2. 开放/封闭原则:代码应该对扩展开放(即,可扩展)但对修改封闭(即,无需重写算法即可扩展)。

    3. Liskov 替换原则:从超类/基类继承不应该导致意外行为。

    4. 接口隔离原则:暴露所需的最小接口。

    5. 依赖倒置原则:模块/组件应该依赖于抽象而不是具体实现。

  11. DRY 原则:不要重复自己。应用 DRY 原则有助于使代码更容易维护。如果你需要更改某些内容,你只需在一个地方更改,而不是多个地方。然而,过度应用 DRY 有时可能会使代码过于复杂,因此需要判断以找到正确的平衡。该原则是关于合理减少重复,而不是完全消除重复。

工程卓越和工艺

遵循这些原则需要工程卓越和工艺心态。你的团队成员需要关心他们的工作,为他们的代码感到自豪,并对他们产生的结果感到满意。

将需求和设计转换为工作代码并不容易。以迭代和增量方式完成它需要深入了解技术、工具和业务需求。然而,同时编写代码而不过度设计,同时保持其灵活性不仅是可能的,而且应该是每个工程师的目标。

哦,选择一个组件库并坚持下去,但也避免供应商锁定。祝你好运!

企业中的 Angular

Angular 并不适合用于每个项目。然而,由于框架支持的高级概念,如依赖注入DI)、原生 TypeScript 支持、模块化架构、强大的打包、测试、可访问性工具,以及对定期发布新版本的承诺,Angular 在企业中被优先考虑。这些工具的结合有助于应用从小型应用扩展到真正的大规模应用,拥有 1,000+视图。

对于内容管理系统CMS)风格的使用案例和简单应用,你应该考虑使用 Analog。Analog 是一个用于使用 Angular 构建应用和网站的元框架:analogjs.org。Analog 预配置了服务器端渲染SSR)、静态站点生成SSG)、Vite(vitejs.dev)工具、基于约定的路由和 API 路由,以提供无烦恼、充满激情的开发体验。

事实上,AngularForEnterprise.com就是使用 Analog 创建的。

多种编程范式支持

Angular 允许不同的编程风格。您可以选择命令式和响应式编程,利用面向对象的概念,或者完全忽略它们,拥有内联和紧凑的单文件组件,或者使用多达四个文件来包含相同的代码。这种多样性在支持多样化的开发者群体时是一种优势,但也可能因为允许同一项目内存在多种实现风格而成为一种弱点。通过建立关于使用哪种编程风格和范式的团队规范来控制这一点是很重要的。像 ESLint 这样的代码风格工具可以帮助保持代码外观的一致性,但在检测或防止代码中的范式转变方面并不有效。

社区支持

Angular 拥有一个伟大且积极参与的社区,其中充满了聪明、尊重和积极的人,他们共同构建围绕 Angular 的工具并在技术社区中传播 Angular。谷歌也通过 Google Developer Expert 计划支持 Angular,我有幸成为其中的一员,Angular 团队会与活跃的社区成员会面,给他们提供早期访问,听取他们的反馈,并鼓励合作。这意味着当您的企业需要支持时,有一个值得信赖的社区个人群体,您可以从中获取建议。

如第一章中“引言”和“Angular 的架构和概念”部分所讨论的,随着 Web 应用程序规模的扩大,性能问题开始出现在应用程序的渲染中,工具难以支持项目中的贡献者数量,架构在处理复杂性和多样化的需求时变得不足。关于架构的更多内容,我们将在本章后面讨论 80-20 解决方案和路由优先架构。

您可以在 angularforenterprise.com/enterprise-tools 找到并贡献一个社区整理的、用于构建企业应用程序的 Angular 工具列表。

接下来,让我们深入了解可以支持构建具有良好 UX 和 DevEx 的高性能应用程序的工具。

高性能应用程序的工具和技术

在本节中,我们将涵盖与构建高性能应用程序相关的话题。虽然其中一些工具是针对 Angular 的,但总体观点和建议适用于所有 Web 应用程序。这意味着一个复杂的大型应用程序仍然能够:

  • 达到平滑的 60 FPS 渲染目标

  • 在 340 毫秒内响应用户点击

  • 为开发者提供快速的反馈循环

  • 尽可能快地运行构建和自动化测试

首先,让我们谈谈运行时的性能。

运行时性能

糟糕的性能对业务有真实的影响。2008 年,有报道称延迟增加 100 毫秒使亚马逊的销售额减少了 1%。2006 年,谷歌观察到生成搜索页面额外增加 0.5 秒导致流量下降了 20%。在亚马逊和谷歌这样的规模下,这些数字令人震惊,因为毫秒可以转化为数百万美元。这可能意味着在您的组织规模下,数千或数十万美元。考虑到随时间推移的性能趋势,这些较小的数字仍然可以累积到数百万。

浏览器是一个复杂的运行环境,我们用来开发 Web 应用的框架通过牺牲丰富的特性和易于维护的代码来模糊运行时。这种模糊化在满足原生浏览器相关优化要求的基础上,引入了特定于框架代码的性能问题。到了 2024 年,我们使用的框架已经变得极其复杂。

让我们了解我们可以使用哪些指标来理解 Web 应用的运行时性能:

  • 首次内容绘制FCP):衡量从页面开始到首次显示可视内容的时间

  • 交互时间TTI):衡量页面加载的响应性,并有助于识别页面看起来是交互式的但实际上不是的情况

  • 延迟:用户操作与其响应之间的延迟

  • 帧率下降:当帧无法快速生成以平滑渲染 UI 时发生的情况

我们可以使用浏览器 DevTools 中的分析工具来发现这些问题。下面是一个由 Michael Hladky 创建的火焰图(通常称为火焰图)的注释示例,它突出了 Angular 应用引导过程的各个阶段:

图片

图 3.1:Angular 引导阶段的火焰图。版权所有 Michael Hladky

图表的 x 轴显示时间,y 轴显示函数调用堆栈。一个宽条表示一个执行时间较长或被频繁调用的函数。一个高的堆栈包括一个深层次的函数调用链。您可以使用这些视觉线索放大图表并调查性能问题。

如果您不熟悉使用 Chrome 的分析功能,请参阅developer.chrome.com/docs/devtools/performance/reference

性能瓶颈的根本原因

什么在拖慢我们的进度?在应用的引导过程中或加载新页面时,会发生几件事情:

  1. 下载 JavaScript:这包括应用、框架和第三方库代码。大小越大,速度越慢。

  2. 执行 JavaScript:代码需要解压缩并加载到内存中,然后由 JavaScript 引擎进行即时执行;DOM 元素和框架钩子需要构建。

  3. 应用代码的初始化:框架必须计算应用程序的状态(视觉和数据),将事件监听器连接到 DOM,并渲染。

  4. 变更检测:框架必须遍历组件树以确定 UI 是否应该因为状态变化而更新。

按顺序执行这些步骤,假设您的应用程序或 API 代码没有重大问题,是大型网络应用程序性能问题的根本原因。在当前的单页应用(SPA)框架状态下,这些问题是无法避免的,或者解决起来非常昂贵。

一个无法避免的问题的例子就是在屏幕上渲染过多的交互式组件。解决这个问题的一个非常昂贵的方案可能是用自定义代码修补每一个组件,以绕过框架的正常生命周期,并强制它以只能对您正在构建的应用程序有益的方式行事。

最简主义的 JavaScript 解决方案

ECMAScript 2022,被所有主流浏览器广泛支持,具有内置的基本功能,使我们能够从头开始构建性能良好且响应式的网络应用程序。仅用 1-2 kb 的“框架”代码,我们就可以极大地提高开发体验,并创建现代且快速的网站。

ArrowJS

在 2023 年弗罗里达州华盛顿特区的 Frontrunners 2023 会议上,我参加了一场由 Justin Schroeder 关于他的前端库 ArrowJS(无框架的响应性)的演讲。更多信息可以在www.arrow-js.com找到。在他的演讲中,Justin 介绍了使他的库成为可能的基本原理:

  • 弱映射:弱映射是一个键值对的集合,其中键是具有任意值的对象。键是弱引用的,这意味着如果没有其他引用存在,它们可以被垃圾回收。通常,映射本身会作为引用而保留在内存中。

  • 代理:代理是一个包装另一个对象或函数的对象,允许您拦截并定义基本操作的自定义行为。它们是您对象的中介。信号在幕后使用代理。它们在表单验证中也很有用。

  • 集合:集合是一组值,其中每个值必须是唯一的,这意味着相同的值不能出现多次。它们对于跟踪依赖项很有用。

  • 标签模板字面量:这些允许使用函数解析模板字面量,实现自定义字符串插值和处理。它们是字符串的中介。它们用于将模板 HTML 代码作为函数解析很有用。

当所有这些概念放在一起时,观察您如何在 ArrowJS 中复制一个待办事项列表应用程序:

import { html, reactive } from '@arrow-js/core'
const data = reactive({
  items: [
    { id: 17, task: 'Check email' },
    { id: 21, task: 'Get groceries' },
    { id: 44, task: 'Make dinner' },
  ]
})
function addItem(e) {
  e.preventDefault()
  const input = document.getElementById('new-item')
  data.items.push({
    id: Math.random(),
    task: input.value,
  })
  input.value = ''
}
html`
<ul>
  ${() => data.items.map(
      item => html`<li>${item.task}</li>`.key(item.id)
    )}
</ul>
<form @submit="${addItem}">
  <input type="text" id="new-item">
  <button>Add</button>
</form>` 

这段代码不需要任何编译或进一步处理。下一个解决方案,然而,在幕后利用类似的技术,但同时也解决了所有大问题。

Qwik.js

Angular 的创始人 Miško Hevery,以及行业老将 Adam Bradley(jQuery Mobile、Ionic、Stencil.js)和天才程序员 Manu Almeida,共同创建了 Qwik.js,作为对 Angular、React 和 Vue 等流行 SPA 框架中固有的基本问题的回应。你可以在这个框架的网站上查看 qwik.builder.io

想要冒险吗?执行:

$ npm create qwik@latest 

Qwik.js 是从头开始构建的,以强制执行响应式编程范式,利用信号原语和可恢复性作为内置功能,确保任何大小或规模的 app 总能在 1 秒内完成全页加载,并且整体性能快 5-10 倍。所有这些都在一个大小约为 1 kb 的库中实现。

Qwik 随带丰富的用户控件、Vite 开发服务器、Vitest 和 Playwright 进行测试、可扩展的样式和服务器端渲染,并且不需要激活。要了解激活和可恢复性之间的区别,请参阅以下图形:

图片

图 3.2:激活与可恢复性。改编自 Miško Hevery

在顶部,你可以看到加载传统 SPA 的激活步骤。随着应用程序的增长,每个单独的框所需的时间会更长。然而,一个可恢复的应用程序作为准备好渲染的 HTML 有效载荷下载,其状态嵌入其中,将复杂度从 O(图片) 降低到 O(1)。

如果你觉得 图片 令人畏惧,别担心。这只是一个玩笑。

在初始加载后,Qwik 仅预取所需的 JavaScript 量以启用交互性。这可能意味着一次下载一个函数。Qwik 在懒加载代码方面也非常积极。因此,只有用户与之交互的元素才会活跃,从而保持性能问题的控制。自然地,有一些智能的预取算法在后台工作,以确保这是一个无缝的体验。

如果你想要了解更多关于 Qwik 的信息,请查看我在 YouTube 上的演讲 www.youtube.com/watch?v=QDqp_qTa4Ww&t=40s 以及幻灯片 slides.com/doguhanuluca/intro-to-qwik

接下来,让我们看看我们可以使用哪些工具来解决 Angular 中的这些问题。

Angular 性能解决方案

有两类问题在发挥作用,一类是关于应用程序的初始加载,另一类是通过保持变更检测来确保平滑渲染。

让我们从 服务器端渲染SSR) 开始。

服务器端渲染和激活

Angular 服务器端渲染使得渲染 SPA 成为可能,否则这是一个在浏览器中执行的客户端技术。服务器可以预先烘焙视图的初始状态,作为浏览器不需要花费太多时间解释的简单 HTML 和 JavaScript。因此,下载的内容渲染非常快,解决了由于下载和执行大量 JavaScript 而造成的延迟。这在移动设备或处理能力和带宽有限的旧设备中尤为重要。服务器可以不受用户硬件限制地持续执行此任务。

SSR 的另一个好处是便于网络爬虫,从而在你的网站上实现搜索引擎优化SEO)。

当用户正在凝视你的着陆页——由于此时它主要是静态的,他们只能看,不能触摸——Angular 使用web workers来加载应用程序的其余部分。

将 SSR 集成到项目中的最合理方式是在一开始就进行,这样你可以逐步解决和测试配置问题。

要创建一个新的具有 SSR 的项目,请执行:

$ npm create @angular -- --ssr 

$ npx @angular/cli new app_name --ssr 

早上醒来心情不错吗?尝试将 SSR 添加到你的现有项目中:

$ npx ng add @angular/ssr 

在所有资产加载完毕,你的 SPA 准备好完全交互时,应用必须从静态状态过渡到动态状态。这种过渡可能会很突然。正如我们在介绍 Qwik 时学到的,水合对于无缝地从静态状态过渡到动态状态是至关重要的。

如果你正在使用FlexLayout,请使用FlexLayoutServerModule在 SSR 配置中启用框架。更多详细信息可以在github.com/angular/flex-layout/wiki/Using-SSR-with-Flex-Layout找到。

现在轮到客户端水合登场。你可以在AppComponent中使用其提供者来启用客户端水合:

import {provideClientHydration} from '@angular/platform-browser';
// ...
@NgModule({
  // ...
**providers****: [** **provideClientHydration****() ],** **// add this line**
  bootstrap: [ AppComponent ]
})
export class AppModule {
  // ...
} 

使用客户端水合,你的 SPA 可以重用已经渲染的服务器端 DOM 结构、应用程序状态、下载的数据和其他过程,以实现平滑的过渡。

你可以在angular.dev/guide/ssr了解更多关于使用 Angular SSR 的信息,以及在angular.dev/guide/hydration了解更多关于 Angular 水合的信息。

应用外壳

从概念上讲,应用外壳类似于 SSR,但不是服务器渲染你的页面,你可以在构建时预先渲染一个路由或页面。这样,你可以在着陆页或懒加载大型功能模块时创建一个静态的着陆体验,然后当准备好时,Angular 将过渡到交互状态。

预渲染在概念上和机械上更容易处理。然而,你可能需要简化着陆页的某些方面,以便它们能够与预渲染一起工作。

你是否即将错过项目的交付截止日期,但仍需要解决加载问题的解决方案?尝试添加一个应用外壳:

$ npx ng generate app-shell 

正如俗话所说,试试看,但效果可能因人而异。了解更多关于应用壳的信息,请访问angular.dev/ecosystem/service-workers/app-shell

应用壳在离线应用中也很有用。接下来,让我们看看服务工作者如何帮助提高性能。

服务工作者

你还可以利用服务工作者让你的 Web 应用感觉像原生应用。服务工作者使渐进式 Web 应用PWA)功能成为可能,如离线支持、推送通知和 Web 应用的背景数据同步。

在后台线程中运行这些任务可以释放主线程来执行与渲染和用户交互相关的任务。这是提高你应用性能的显而易见的方法。更多关于服务工作者信息,请参阅angular.dev/ecosystem/service-workers

你知道你想要这么做。今天添加 PWA 支持:

$ npx ng add @angular/pwa –project <project-name> 

本书的技术审稿人 Jurgen Van de Moere 还推荐了 Google 的 Workbox,这是一套用于复杂场景的生产就绪服务工作者库和工具,在企业中经常使用。

了解更多关于 Workbox 的信息,请访问developer.chrome.com/docs/workbox

啊,终于到了剥洋葱核心的时候了,变更检测。

RxAngular

Michael Hladky 及其团队创建了 RxAngular 以克服大型 Angular 应用中的性能问题。RxAngular“提供了一套全面的工具集,用于处理完全响应式的 Angular 应用,主要关注运行时性能和模板渲染。”

以下图表详细说明了 Angular 变更检测事件的周期:

图 3.3:Angular 中的默认变更检测。改编自 Michael Hladky

让我一步步地解释:

  1. 首先,用户交互触发一个事件。

  2. 然后,Angular 将元素标记为需要进行脏检查。

  3. Zone.js 用于在 Angular 应用状态和 DOM 之间进行转换。

  4. Angular 引擎“滴答”作响,触发挂起任务的执行。

  5. 变更检测算法避免了不脏的分支。

  6. 算法识别必须重新渲染以响应在步骤 1中触发的事件的最高级别组件。

Angular 的默认变更检测过程是一个计算成本高昂的过程,可能会强制重新渲染应用中比必要更大的部分。Angular 还提供了一个OnPush变更检测策略。

一个图表的图表  自动生成的描述

图 3.4:Angular 中的 OnPush 变更检测。改编自 Michael Hladky

OnPush 禁用自动变更检测,直到手动重新激活。我们只能使用此策略来激活用户交互的组件子树,限制 Angular 必须执行变更检测的数量和范围。你可以在 angular.dev/best-practices/skipping-subtrees 上了解更多信息。

最后,使用 RxAngular 的指令和管道,如 RxLetRxForRxIf,我们可以欺骗 Angular 引擎,只有在与这些指令关联的可观察对象发生变化时才检测变更。

一个图表的图表  自动生成的描述

图 3.5:使用 RxAngular 指令进行变更检测。改编自 Michael Hladky

如上图所示,我们可以确保只更新需要更改的元素,而无需经过昂贵的变更检测过程。如果你已经有一个大型 Angular 应用程序,重写不是一个选择,RxAngular 可能是你解决性能问题的唯一选择。

在 Angular DC 上,Michael 的演讲深入探讨了 Angular 的性能:www.youtube.com/watch?v=HTU4WYWGTIk

www.rx-angular.io 上了解更多关于 RxAngular 的信息。

Angular 信号

Angular 信号可以提供与 图 3.5 中在 RxAngular 部分所示相同的细粒度变更检测。正如你可以想象的那样,这有可能解决 Angular 最严重的性能和扩展问题。然而,截至 Angular 17,信号仍然处于部分预览状态,基于信号的组件预计将在 Angular 19 左右交付并变得稳定。当这种情况发生时,我们仍然需要重写我们应用程序的很大一部分以利用性能优势。

基于纯信号的应用程序不需要使用重 RxJS 来启用响应性。我预测其使用将很少,因为大多数服务和 API 调用都可以转换为启用 async/await 的基于 promise 的调用。这将使 Angular 非常容易学习。

第九章使用 NgRx/SignalStore 重写 Angular 应用程序 部分,配方 – 主/详细,数据表和 NgRx,我展示了使用 Angular 17 可以用信号走多远。

接下来,让我们谈谈大型应用程序可能产生的构建问题。

构建性能

与运行时性能问题一样,代码库的大小可以在 DevEx 的质量中发挥重要作用。当项目中有更多代码时,构建代码和运行测试会变慢。这延长了开发者的反馈周期。缓慢的反馈周期会导致开发过程缓慢,错误更多,交付的质量特性更少。

虽然像 ArrowJS 或 Qwik.js 这样的极简解决方案通过要求使用最先进的工具来处理这个问题,但考虑到现有的 SPA 框架有一个庞大的安装基础和复杂的需求,它们并不能轻易做到这一点。但这并不意味着没有优秀的解决方案。

下文所述的工具可以加快你的开发过程并提高 DevEx。

Nx

Nx是一个下一代构建系统,具有一流的 monorepo 支持和强大的集成。Nx 允许你将应用程序代码分解成库,并仅对需要重新构建的应用程序部分使用构建缓存。因此,小的更改不需要触发完整的构建,而是进行一个短暂的 30 秒构建,并且只重新运行受影响的测试。这一点的好处是缓存可以在远程服务器和开发机器之间共享。

Nx 还提供了一种有见地的架构,这对于非常大的团队和企业来说是非常受欢迎的。Nx 还自动更新依赖项,这对于所有现代 Web 项目来说是一个关键且耗时的工作。

你可以通过运行以下命令创建一个新的 Nx 应用程序:

$ npx create-nx-workspace@latest 

或者,你可以通过运行以下命令将现有的应用程序迁移到 Nx:

$ npx nx@latest init 

你可以在nx.dev/上了解更多关于 Nx 的信息。

esbuild

esbuild是一个针对 Web 的极快打包器。它的运行速度比 webpack 5 快 40 倍,而 Angular 目前依赖于它来打包 SPA,这极大地影响了构建时间。

截至 Angular 17,基于 esbuild 的ES 模块(ESM)构建系统是默认的构建器。你可以在angular.dev/tools/cli/esbuild上了解更多。

基于 webpack 的遗留构建系统仍然被认为是稳定且完全受支持的。

要迁移到新的构建系统,请遵循angular.dev/tools/cli/esbuild#using-the-browser-esbuild-builder中的说明。

新的基于 esbuild 的构建系统还使下一代前端工具 Vite 成为可能。更多关于 Vite 的信息可以在vitejs.dev/上找到。

你可以在esbuild.github.io/上了解更多关于 esbuild 的信息。

测试自动化

Karma 和 Jasmine 工具正显示出它们的年代感。Karma 从未考虑过无头单元测试。Angular 的原始端到端(e2e)测试工具 Protractor 已经被弃用并替换为 Cypress。这在第四章创建以路由为首要业务应用中有详细说明。Cypress 易于使用,是 Protractor 的绝佳替代品。

让我们来看看几个 Karma 和 Jasmine 的替代方案,以实现更快的单元测试:

  • Jest(jestjs.io)是一个几乎可以无缝替换 Jasmine 的内置测试运行器。我在利用 Jest 与我的 CLI 工具方面有很好的体验;然而,Jest 从未被构建为支持 ES 模块。这导致在使用 CommonJS 模块和 ES 模块时出现重大兼容性问题,这在任何现有应用程序中通常是情况。问题如此模糊且严重,以至于我不得不放弃升级书中示例代码到 Jest 的努力。

    截至出版时,Angular 支持预览中的 Jest。然而,我不建议你使用它。

  • 由 Vite 驱动的 Vitest(vitest.dev/)是一个闪电般的单元测试框架,代表了可接受的未来状态。然而,为了无缝利用 Vitest,你还应该使用 Vite 附带基于 esbuild 的构建配置。

  • Cypress(www.cypress.io/)通常以其端到端测试能力而闻名。然而,你也可以使用 Cypress 编写组件测试。一旦你为 Angular 项目配置了 Cypress,每次你生成一个新组件时,也会添加一个新的 Cypress 组件测试文件。Cypress 对组件测试的黑盒方法使得编写新测试变得更容易,但它们不能被称为单元测试。

一旦(如果)所有这些预览中的六分之一或更多的技术毕业到 Angular 生产状态,这个强大的 SPA 框架将准备好迎接未来。与此同时,不要低估像 Qwik.js 这样的替代方案,对于极端性能敏感的应用程序来说。

现在你已经知道怪物藏在哪里了,你准备好着手执行你的项目了。但是等等,不要急于开始编码。有一个计划在位是必不可少的。

使用看板和 GitHub 项目进行规划

在上路之前有一个路线图对于到达目的地至关重要。同样,在开始编码之前制定一个粗略的行动计划对于确保项目成功至关重要。尽早制定计划可以让你的同事或客户了解你打算完成什么。然而,任何初始计划都注定会随着时间的推移而改变。

敏捷软件开发旨在随着时间的推移适应不断变化的需求和功能。看板(Kanban)和 Scrum 是两种最受欢迎的项目管理方法,你可以使用它们来管理你的项目。每种方法都有一个待办事项的概念,以及列出计划、进行中和已完成的工作。包含优先级任务列表的待办事项,确立了关于接下来需要做什么的共享理解。列出每个任务状态的列表充当信息发射器,利益相关者可以在不打扰你工作流程的情况下获取更新。无论你是为自己还是为他人开发应用程序,保持一个活跃的待办事项并跟踪任务进度都能带来回报,并保持对你要实现的目标的关注。

你可以利用 GitHub 项目作为看板。在企业中,你可以使用票务系统或工具来维护待办事项列表,实施 Scrum 方法,并显示看板。在 GitHub 中,问题代表你的待办事项。你可以利用内置的项目标签来定义代表发布或迭代的任务范围,以建立看板。GitHub 项目直接集成到你的 GitHub 仓库的问题中,并通过标签跟踪问题的状态。这样,你就可以继续使用你选择的工具与你的仓库交互,同时轻松地发射信息。

信息发射器是一种在敏捷项目管理与软件开发中使用的动态工具,用于在高度可见的区域展示关键项目信息。它旨在促进透明度和便利的被动沟通,确保团队成员能够持续且轻松地了解项目的关键方面,如进度、目标、截止日期和潜在问题。通常放置在公共工作区,信息发射器可能以大型白板或数字显示屏的形式出现,展示易于理解的图表、图形或看板。其主要功能是保持信息的时效性和相关性,提供项目状态的实时快照。通过这样做,它增强了协作,有助于快速识别瓶颈,并允许及时做出决策。因此,信息发射器既是团队互动的焦点,也是促进更投入、更了解团队动态的催化剂。

在下一节中,你将设置一个项目以实现这一目标。

设置 GitHub 项目

让我们设置一个 GitHub 项目:

  1. 在浏览器中导航到你的 GitHub 仓库。

  2. 切换到项目标签。

  3. 选择新建项目计算机屏幕截图  自动生成的描述

    图 3.6:在 GitHub 中创建新项目

  4. 项目名称框中提供名称。

  5. 选择名为团队待办事项的项目模板。

  6. 点击创建

我们刚刚创建了一个看板,这是一种轻量级的工作组织方法,你可能选择它而不是其他如 Scrum 的方法。观察你的看板,它应该如下所示:

计算机屏幕截图  自动生成的描述

图 3.7:你的项目看板

画板默认配置包括以下列:

  • 新建:用于添加新问题

  • 待办事项:要工作的优先级任务列表

  • 就绪:符合就绪定义且可以立即工作的任务

  • 进行中:正在进行的任务;使用三点菜单,你可以设置项目数量限制,强制执行工作在进度中WIP)限制

  • 待审阅:处于代码审查、质量测试和产品负责人PO)批准中的工作

  • 完成:符合完成定义的工作

就绪定义定义了“知道何时将待办事项项细化到足以开始工作的标准”和完成定义定义了“知道何时将待办事项项完成的标准”。定义这些标准作为团队规范的一部分是至关重要的。要了解更多关于成功运行看板项目的信息,请查看我的同事 Nicole Spence-Goon 的文章:www.excella.com/insights/successful-agile-project-with-transient-teams

如果您在您的存储库中已有 GitHub 问题或拉取请求,您可以使用+ 添加项目按钮单独或批量将它们添加到您的项目中。您还可以将来自不同存储库的问题组合在一起。

自定义看板板

您的板默认配置为看板板,带有所有必要的列。默认情况下,在您的项目中创建的问题和打开的拉取请求PR)将自动添加到板上。

您可以通过打开以下截图中的三明治菜单标记为1工作流程屏幕来完成此操作:

计算机截图 自动生成描述

图 3.8:看板板工作流程

2号显示了默认工作流程列表。您可以选择一个,并使用标记为3编辑按钮来更改自动行为。

GitHub 项目还有一个里程碑的概念。您可以为问题或 PR 创建里程碑,并设置冲刺或发布,通过三明治菜单旁边的洞察屏幕跟踪完成百分比或其他统计数据,该屏幕标记为数字1

您还可以通过点击+ 新视图按钮添加路线图以获得不同的视图。GitHub 项目的最强大功能是与其开发者工作流程的集成。请参阅以下 PR:

计算机截图 自动生成描述

图 3.9:与项目集成的拉取请求

1号显示 PR 位于LemonMart项目的进行中列。开发者可以获取可快速查看的信息,并在 PR 内操作项目文件。在2号中,我们可以看到 PR 被分配给第 3 版里程碑,其中我们可以看到一个显示完成百分比的进度条。最后,3号显示了 PR 可以解决的一个或多个问题。

为您的应用创建优先级待办事项

与您的团队一起,创建一个待办事项列表来跟踪您实现应用程序设计时的进度。在创建问题时,您应该专注于交付具有用户价值的功能性迭代。

避免创建纯粹的技术任务。您必须克服以实现这些结果的技术障碍对您的用户或客户没有兴趣。错误报告是可以的,因为它们会影响您的用户。然而,技术任务成为您试图解决的函数问题的一部分,因此请在此背景下解决它们。这样,当您的组织中的业务领导向您提出要求时,您总能准备好将正在进行的工作转化为交付的价值。

当您将项目添加到待办事项列表时,请确保对其进行优先排序。首先要工作的项目将排在最上面,并按重要性顺序下降。新项目进入列,并且仅在梳理会议中添加到待办事项列表,以便对其必要性和优先级进行适当的评估。有了优先排序的待办事项列表,您就掌握了路线图,您的团队也准备好开始工作了。

最终,GitHub 项目提供了一个易于使用的图形用户界面,以便非技术人员可以轻松地与 GitHub 问题进行交互。通过允许非技术人员参与 GitHub 上的开发过程,您可以解锁 GitHub 成为您整个项目单一信息源的好处。关于功能和问题的疑问、答案和讨论都被跟踪为 GitHub 问题的一部分,而不是在电子邮件中丢失。您还可以在 GitHub 上存储类似维基的文档。因此,通过在 GitHub 上集中所有项目相关的信息、数据、对话和工件,您可以极大地简化需要持续维护且成本高昂的多个系统之间的复杂交互。GitHub 为私有仓库和本地企业安装提供了非常合理的费用。如果您坚持开源,就像我们在本章中所做的那样,所有这些工具都是免费的。

在制定路线图后,让我们探讨如何优先处理工作和执行我们应用的设计和架构的哲学。

80-20 解决方案

无论我们是在家中开发应用、出于激情的项目,还是在办公室为工作开发应用,我们都必须牢记我们的目的:提供价值。如果我们不能通过我们的激情项目提供价值,我们不会感到满足或快乐。如果我们无法在工作场所提供价值,我们可能得不到报酬。

提供现代网络应用是困难的。我们需要克服许多挑战才能取得成功:

  • 迭代和增量交付

  • 具有可扩展性

  • 服务数十个屏幕和输入类型

  • 可用性

  • 可访问性

  • 管理团队

  • 梳理优先排序的待办事项列表

  • 确保验收标准清晰、简洁、具体

如果您曾经领导过一个项目或尝试过独立实施和交付一个项目,您会意识到在任何特定项目中,时间和资源永远都不够用来覆盖广泛的利益相关者、团队和技术需求。记住帕累托原则,也称为 80-20 法则,意味着我们可以用总体努力的 20%完成 80%的目标。

如果我们将 80-20 法则应用于我们的工作,我们可以最大化我们的产出、质量和幸福感。业务线应用是我们行业的基石。应用 80-20 法则,我们可以推断出我们中的大多数人将通过提供此类应用来赚取大部分收入。因此,我们应该将我们的工程开销保持在最低,并减少我们项目的交付风险。通过限制生产代码中的实验,我们为团队成员创造了一个可预测的环境,并且只引入我们在概念验证或小型应用中已有机会审查过的变化。

我们的 80-20 策略,结合纪律,可以帮助我们在同一时间以更多功能和更好的质量交付相同的项目。将你的职业生涯视为一场马拉松而不是一系列冲刺,你可以发现自己处于能够项目接项目地交付高质量解决方案的位置,而不会感到疲惫不堪。

理解业务线应用

根据维基百科,业务线应用是一套“被认为对企业运营至关重要的关键计算机应用。” 业务线应用是大多数开发者最终会开发的应用,即使我们可能认为我们开发的是小型应用或大型企业应用。考虑以下插图,它展示了我们可能开发的应用类型,并放置在一个相对于其大小和范围的轴上:

图 3.10:四种类型应用的大小和范围

从我的角度来看,当我们开始开发软件时,我们会考虑四种类型的应用:

  • 小型应用

  • 业务线应用

  • 大型企业应用

  • 亿级用户规模的应用

亿级用户规模的应用是完全的利基实现,很少需要与大多数现有应用相匹配的需求。因此,我们必须将这些应用归类为异常值。

小型应用从小开始。在架构上,它们很可能是最初设计不足的。随着你向小型应用添加功能和团队成员,在某个时候,你将遇到麻烦。随着你的团队规模和功能集的增长,或者应用的整体复杂性增加,应用的架构需求呈指数增长。

一旦你超过了你的架构可以承受的复杂性拐点,你将面临代价高昂的重构努力以回到正轨。请看以下图表说明这一概念:

图 3.11:小型应用的架构之旅

特性线以下的区域代表设计不足,这会给你的项目带来风险。特性线以上的区域显示了支持所需功能所需的工程开销。相比之下,大型企业应用从大规模的过度设计努力开始,如下面的图所示:

图 3.12:大型企业应用的架构之旅

随着时间的推移和系统整体复杂性的增加,大型企业应用也可能面临类似的拐点,原始架构可能变得不足。通过谨慎规划和管理工作,你可以避免麻烦并保护所做的重要初始投资。这样的大型企业应用需要数百名开发者,多个级别的经理和架构师才能成功执行。像亿级用户规模的应用一样,这些应用也可能有特定的架构需求。在我们开发的小型应用和大型企业应用之间,存在着 LOB 应用。

图片

图 3.13:软件演化的动态性质

如前图所示,小型应用可以成长并转变为 LOB 应用,而大型企业应用由于用户忽略了他们从未需要的功能,却保留应用以单一目的作为 LOB 应用,可能会变得利用率低下。在任何情况下,尽管我们尽了最大努力,但我们最终为解决我们正在解决的问题提供了一个低效的解决方案。我们都没有水晶球来预见未来,在不可预测的商业环境中,规划和工程只能为我们做这么多;我们需要依靠 80-20 法则来制定一个灵活变通、但足以满足大多数商业需求的架构。

在后续章节中介绍的“路由优先架构”旨在保持最佳架构开销,以避免在交付所有必需功能时的昂贵重工程或后期冲刺。让我们看看如何做到这一点。

我在 2018 年这本书的第一版中提出了“路由优先架构”这个术语。从那时起,我在十几个会议上讨论过它,并在数百万美元的项目中成功实施了它。以下是我在 2018 年 6 月 Angular DC 聚会上的第一次演讲:www.youtube.com/watch?v=XKuFNiV-TWg

严谨和平衡的方法

我们已经涵盖了软件开发中的“什么”,但在我们能够到达“如何”之前,我们必须考虑“为什么”、“何时”、“何地”和“谁”。当我们为学习或激情项目开发应用时,我们通常会低估我们的项目。如果你的激情项目意外地一夜成名,维护或继续添加功能到你的应用将变得昂贵。在这种情况下,你将不得不承担持续维护成本或面临应用程序的重写。

当我们为工作开发应用时,我们往往更加保守,可能会过度设计我们的解决方案。然而,如果你只为工作编码,你可能会在面向生产的代码上进行实验。在与其他团队成员的代码库中实验是危险的。你可能会引入一个新模式,而你的团队不了解你选择的结果。你也可能不太了解你引入的技术的中长期风险或收益。

草率实验也可能对你的团队成员产生严重的负面影响。在一个由资深和经验丰富的软件工程师组成的团队中,你可能在移动车辆中进行实验。然而,我们团队中可能会有不同背景和学习风格的成员。我们中的一些人有计算机科学学位,一些人是独行侠,还有一些人过度依赖 Stack Overflow。我们中的一些人在支持职业发展的公司工作,但也有一些人在不会给我们一天时间学习新东西的地方工作。因此,在实验时,我们必须考虑我们的环境;否则,我们可能会让我们的同事加班或感到无助和沮丧。

采用有纪律和平衡的方法,我们可以减少交付的 bug 数量,避免昂贵的返工,并与一个朝着同一方向前进的团队一起工作。我们还需要正确的架构、工具和模式/实践来成功交付。总之,我们的方法必须考虑以下方面:

  • 我们应用的大小

  • 我们开发应用的原因

  • 开发者的技能水平

  • 迭代和增量交付

  • 特性不断向前流动

  • 云架构、运营成本和网络安全

理想情况下,我们需要保持最佳工程开销。我们的架构应该支持我们的短期需求,同时具有可扩展性,这样我们就可以在短期或长期需求改变时转向不同的方向,而无需重写大量代码。考虑以下图表,与上一节中关于小型和大型企业应用的图表形成对比:

图 3.14:LOB 应用的理想架构之旅

路由优先架构旨在帮助你找到代码库的工程开销、特性交付和灵活性之间的平衡。然而,你负责管理事物的纪律方面。

Shu Ha Ri 是一个可以帮助你将纪律带入工作的概念。它是一种思维方式,指导你首先掌握基础知识,而不必担心底层理论,然后掌握理论,最后能够将你掌握的内容适应到你的需求中。然而,如果你跳过了步骤 1 或 2,你可能会发现自己错误地适应了错误的事物。

在涵盖了什么为什么何时何地之后,让我们在下一节中跳入如何

路由优先架构

路由优先架构是一种方法:

  • 强制高级思维

  • 在开始编码之前确保对特性达成共识

  • 规划你的代码库/团队的增长

  • 引入小的工程开销

实施路由优先架构有七个步骤:

  1. 制定路线图和范围(第四章)。

  2. 考虑懒加载进行设计(第四章)。

  3. 实施步行骨架导航体验(第四章)。

  4. 实现无状态、数据驱动的架构(第 4-5 章)。

  5. 强制实施解耦组件架构(第 6-9 章)。

  6. 区分用户控件和组件(第八章)。

  7. 利用 TypeScript 和 ES 特性最大化代码重用(第 5-9 章)。

如前所述,每个步骤将在本章节和接下来的章节中更详细地介绍。在我们从高层次概述这些步骤之前,让我们首先介绍 Angular 中的功能模块,这是重要的基本技术概念。

功能模块

第一章Angular 的架构和概念中,我们以高层次概述了 Angular 的架构,并介绍了懒加载和路由的概念。功能模块是实现懒加载的关键组件。有两种类型的模块:根模块和功能模块。模块是通过NgModule类实现的。NgModule包含渲染组件和注入服务所需的所有必要元数据。在独立组件引入之前,没有模块的组件无法做很多事情。但现在,一个独立组件可以导入其依赖的模块、组件和提供者,而无需模块。

一个 Angular 应用由位于应用程序根部的NgModule定义。这被称为根模块。从 Angular 17 开始,独立项目默认启用。因此,而不是根模块,会创建一个AppConfig对象,它在引导过程中扮演与根模块相似的角色。根模块渲染在index.html文件中的<app-root>元素中显示的内容。在以下图中定位根模块,它与AppConfig可互换:

包含文本、图表、计划、行描述自动生成

图 3.15:Angular 的主要架构组件

NgModule可以包含许多其他的NgModules。一个 Angular 应用只能有一个根模块或者没有。这意味着你实现的NgModule是一个功能模块。在先前的图中,你可以看到你可以将一组组件(Cmp)和服务(Svc)组织到功能模块中。将功能分组到模块中允许我们将代码组织成块,这些块可以与应用程序的初始包分离。

根模块和功能模块的这个概念代表了一种父子关系,这个概念扩展到其他功能和框架。例如,注意先前的图中将根路由注入到根模块中。根路由可以有子路由。子路由可以配置为加载功能模块。同样,NgRx 有根模块和功能模块级别的存储来组织应用程序的状态数据。

在本书中提到的任何关于子模块、子模块或功能模块的提及,都指的是同一件事:一个不是根模块的模块。

功能模块和子路由允许将应用程序的主要组件之间的关注点分离。两个团队可以在不相互干扰的情况下分别工作在不同的模块上。这种分离意味着任何功能模块所需的依赖都必须明确添加到该模块的导入、声明或提供者中。对于独立组件,这些导入和提供者必须添加到每个组件中。这可能会显得重复且令人烦恼,但这是必要的恶行。

如同在第二章形式、可观察量、信号和主题中所述,服务默认是单例的——每个模块一个实例。然而,你可以从@Injectable注解中移除providedIn: 'root'属性,并在不同的注入上下文中使用该服务。你可以在功能模块或组件级别提供服务的副本。当你这样做时,请注意,如果在同一继承链中的多个上下文中提供相同的服务,例如在根模块和一个功能模块或组件中,你最终会有多个可能被注入的服务实例,这会破坏依赖注入系统的预期。

如你所见,在大多数情况下,模块引入了一个复杂且通常不必要的抽象层。这是 Angular 现在默认生成独立项目的主要原因。

现在,让我们从高层次上回顾一下路由优先架构的七个步骤。

制定路线图和范围

在项目早期就制定路线图和确定范围对于确保高级架构的正确性至关重要。创建待办事项列表、线框图、原型和交互式原型将帮助你确定路线图,在踏上道路之前捕捉到具体的愿景。重要的是要记住,只有在必要时才使用工具。如果一张纸和一支铅笔就能解决问题,就不要从 Photoshop 开始。如果利益相关者和团队成员理解正在开发的内容,就能够在迭代和增量交付解决方案。然而,不要陷入完美的陷阱。在基本要素到位并获得一致同意之后,再进行微调和家具调整。

记录你创建的每个工件。在第四章创建路由优先的业务线应用程序中,我们介绍了如何利用 GitHub 维基来存储你的工件。

在本章的后面部分,我们将介绍如何制定路线图和定义范围的技术,这些技术是在使用看板和 GitHub 项目进行规划部分中介绍的路线图构建技术的基础上。

考虑懒加载进行设计

您的第一幅画非常重要!根据 2018 年 Google Analytics 团队的数据,当加载时间超过 3 秒时,53%的移动用户放弃了网站。在同一时期,大多数网站都是在移动设备上被消费的——在美国约占 70%,在中国约占 90%。UI 库和静态资产可以显著增加您应用程序的大小。由于大多数内容都是在移动设备上被消费的,因此推迟非关键资产的加载非常重要。

我们通过将 Angular 应用程序的部分划分为功能模块来推迟资产的加载。这样,Angular 可以只加载渲染当前屏幕所需的资产,并在需要时动态下载进一步的资源。您可以通过定义应用程序可能使用的各种用户角色来开始考虑如何将应用程序划分为功能模块。用户角色通常表示用户的职能,如经理或数据录入专员。从技术角度来说,它们可以被视为特定用户角色可以执行的一组操作。毕竟,数据录入专员永远不会看到经理可以看到的大多数屏幕,那么为什么要把这些资产提供给这些用户并减慢他们的体验呢?

将您的应用程序按业务功能划分的更明确策略是。您可以将每个工作职能视为应用程序的一个子模块。遵循低耦合和高内聚的原则,您会希望将相关的功能分组到可以懒加载的模块中。这样,当用户在业务功能内执行任务时,他们不会因为延迟而受到干扰。然而,当用户从一个业务功能切换到另一个业务功能时,我们可以加载新的模块,轻微的延迟是可以接受的。

您可以设置预加载策略,在用户与您的应用程序交互时在后台急切地加载所有模块。这将消除任何过渡延迟。

更多信息,请参阅angular.dev/guide/ngmodules/lazy-loading#preloading

懒加载对于创建可扩展的应用程序架构至关重要,它允许您提供高质量和高效的产品。懒加载是我们将作为一个基本设计目标解决的问题。在事后实现懒加载可能会很昂贵。

也可能对单个独立组件进行懒加载。独立组件不需要 Angular 应用程序所需的全部引导。

您可以在angular.io/guide/standalone-components#lazy-loading-a-standalone-component详细了解这些组件。

在本章的后面部分,您将学习如何使用功能模块实现懒加载。

实现一个行走骨架

配置懒加载可能很棘手,因此早期确定走廊骨架导航体验至关重要。实现应用的点击版本将帮助您在早期收集用户反馈。这样,您将能够快速解决基本的工作流程和集成问题。此外,您将能够确立当前开发工作的具体范围。开发人员和利益相关者都将能够更好地可视化产品的外观。

走廊骨架也为多个团队协作奠定了基础。多个人可以同时开始开发不同的功能模块或组件,而不必担心这些拼图碎片如何最终拼合在一起。到本章结束时,您将完成实现示例应用 LemonMart 的走廊骨架。

实现无状态、数据驱动的设计

如同在第五章设计身份验证和授权中强调的,在全栈架构中实现无状态设计对于构建可维护的应用至关重要。正如第一章Angular 的架构和概念以及随后的第九章食谱 – 主/详细信息,数据表和 NgRx中所述,Flux 模式与 NgRx 使得为您的应用实现不可变状态成为可能。然而,Flux 模式对于大多数应用来说可能过于复杂。NgRx 本身利用了 RxJS 中的许多核心技术。

我们将使用 RxJS 和响应式编程范式来实现应用的最小化、无状态和数据驱动模式。识别用户将与之交互的主要数据实体,例如发票或人员,将帮助您避免过度设计您的应用。围绕主要数据实体进行设计将有助于早期确定 API 设计,并帮助定义您将用于实现无状态、数据驱动设计的BehaviorSubject数据锚点。该设计反过来将确保解耦的组件架构,正如第二章表单、可观察对象、信号和主题中详细描述的。

通过定义可观察的数据锚点,您可以确保各个组件之间的数据保持同步。我们可以通过编写函数式响应式代码,利用 RxJS 特性,并在组件中不存储状态来实现不可变数据流。

我们将在第五章设计身份验证和授权中介绍如何为您的应用设计数据模型,并在接下来的章节中继续使用这些模型。

强制执行解耦的组件架构

如同在第一章Angular 的架构和概念中讨论的,解耦架构中的组件对于确保代码库的可维护性至关重要。您可以通过利用@Input@Output绑定以及路由编排来在 Angular 中解耦组件。

绑定可以帮助你维护一个简单的组件层次结构,并避免在静态设计更有效的情况下使用动态模板,例如创建多页表单。

路由出口和辅助路径允许你使用路由组合视图。解析器可以通过消耗路由参数来帮助加载数据。身份验证守卫可以帮助控制对各种模块和组件的访问。使用路由链接,你可以以不可变和可预测的方式动态定制用户将看到的元素,就像我们在上一步中设计和开发数据锚点的方式一样。

如果你确保每个组件都负责加载其数据,那么你可以通过 URL 组合组件。然而,过度使用路由可能会成为一种反模式。如果一个父组件在逻辑上拥有一个子组件,那么将它们解耦的努力将是徒劳的。

第二章表单、Observables、Signals 和 Subjects中,你学习了如何使用BehaviorSubjectSignals启用组件交互。在第八章食谱 - 可重用性、表单和缓存中,你将学习如何实现@Input@Output绑定,在接下来的章节中,你将学习如何实现路由功能。

区分用户控件和组件

另一个重要的观点是区分用户控件和组件。用户控件就像是一个定制的日期输入或星级评分器。它通常是高度交互性和动态的代码,高度耦合、复杂且复杂。这些控件可能使用很少使用的 Angular API,如NgZoneRenderer2ViewContainerRefDynamicComponentLoaders。这些高度专业化和具体的 API 超出了本书的范围。

组件更像是带有字段的表单,可能包含简单的日期输入或星级评分器。由于表单封装了业务功能,其代码必须易于阅读和理解。你的代码应坚持 Angular 的基本原则,以便它稳定且易于维护,就像本书中的大多数代码一样。

通过区分用户控件和组件,你可以在决定想要创建哪种可重用代码时做出更好的决策。创建可重用代码是昂贵的。如果你创建了正确的可重用代码,你可以节省时间和资源。如果你创建了错误的可重用代码,那么你可能会浪费大量的时间和资源。

原型设计允许你早期识别可重用元素。用户控件将帮助将用户交互代码与业务逻辑分开。精心设计的组件重用将使你能够封装特定领域的功能并在以后共享它。

识别封装你希望为你的应用程序创建的独特行为的自包含用户控件是很重要的。用户控件可能会作为指令或具有数据绑定属性和紧密耦合的控制器逻辑和模板的组件来创建。

相反,组件利用路由生命周期事件来解析参数并在数据上执行 CRUD 操作。尽早识别这些组件的复用将创建出更多灵活的组件,这些组件可以在多个上下文中复用(由路由器编排),从而最大化代码复用。

我们将在第八章 配方 – 可复用性、表单和缓存 中介绍创建可复用组件和用户控件。

使用 TypeScript 和 ES 最大化代码复用

在考虑 Angular、RxJS 以及你使用的所有库提供的功能之前,记住你所使用的语言的底层特性是至关重要的。有数十年的软件工程基础,你可以利用这些基础来编写可读性和可维护的代码。

首先最重要的是 DRY 原则,即不要重复自己。所以,不要复制粘贴代码。不要只是更改一个或两个变量。积极重构你的代码,使你的函数无状态和可复用。简而言之:不要重复自己,不要重复自己,不要重复自己。

利用面向对象设计。将行为移动到类中;如果 person 类有一个 fullName 属性,不要在十几个不同的地方重新实现组装全名的逻辑。在 person 类中只实现一次。

这意味着你需要熟悉数据填充,即在新的实例化类中注入 JSON 对象和使用 toJSON 进行序列化。重要的是不要过度使用面向对象编程。你应该通过避免在类参数中存储状态来保持无状态和函数式。

通过利用泛型、继承和抽象类,你可以释放面向对象设计的力量。我们将在本书的后面部分介绍这些技术的具体示例。

TypeScript 为 JavaScript 引入了接口的概念。接口主要是一个静态类型语言的保留概念。接口代表了一个对象可以做什么的抽象概念,而不指定实现细节。此外,接口可以用来记录数据的形状。例如,你可以编写第三方 API 的部分接口来记录你感兴趣消费的字段。当其他开发者阅读你的代码时,他们可以理解他们消费的数据结构,而无需阅读另一个网站上的文档。

接口还允许你以定义良好的方式改变数据形状。因此,你可以编写一个转换函数来将外部数据的形状转换为内部数据。TypeScript 将捕获你可能犯的任何错误。进一步来说,你也可以使用接口来简化数据。如果你接收到的数据具有多实体关系结构,你可以简化关系,将数据设计从你的 UI 代码中解耦。

不要过度扁平化你的数据。对于常见对象,数组和简单形状是合适的,例如一个具有firstmiddlelastprefixsuffix属性的name对象,或者常用的特定领域对象。

您还应该避免在代码中使用字符串字面量。在比较 'apples' !== 'Oranges' 的业务逻辑中编写代码会导致难以维护的代码。您应该在 TypeScript 中利用 enums,这样您的代码就不会受到程序员拼写错误或变化业务需求的影响。因此,'oranges' === Fruit.Oranges

除了 TypeScript 和 ECMAScript,Angular 还提供了有助于重用逻辑的功能。Angular 验证器、管道、路由解析器和路由守卫允许你在组件和模板之间共享代码。

摘要

本章向您介绍了成为一名技术领导或架构师所需具备的条件。您学习了成功运行项目的要素。我们探讨了为什么 Angular 对于企业来说是一个很好的选择。然后我们深入探讨了构建高性能解决方案时需要考虑的各种工具和功能。您学习了如何创建看板,并熟悉了 80-20 规则和路由优先的架构方法来处理复杂项目。

在下一章中,您将创建一个更为复杂的LOB应用程序,使用路由优先的方法设计和构建具有一流身份验证和授权、用户体验以及覆盖大量可能出现在 LOB 应用程序中的需求的可扩展 Angular 应用程序。

进一步阅读

问题

尽可能地回答以下问题,以确保你已理解本章的关键概念,而无需使用谷歌搜索。你知道你是否回答了所有问题吗?访问 angularforenterprise.com/self-assessment 获取更多信息:

  1. 作为技术负责人或架构师,为了成功,你应该做哪三件事?

  2. 成功项目的要素有哪些?

  3. 为什么你应该在企业中使用 Angular?

  4. 为企业构建 Angular 应用时最重要的考虑因素是什么?

  5. 网络应用程序性能问题是由什么引起的?

  6. 我们如何解决大型网络应用程序中的性能问题?

  7. 什么是 LOB 应用程序?

  8. 帕累托原理是什么?

  9. 路由优先架构的主要目标是什么?

加入我们的 Discord 社区

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

packt.link/AngularEnterpise3e

第四章:创建以路由优先的业务线应用

如你在第三章“架构企业应用”中阅读到的,业务线LOB)应用是软件开发世界的基石。

在本书的此部分和后续章节中,我们将设置一个具有丰富功能的新应用,以满足具有可扩展架构和工程最佳实践的业务线应用的需求。我们将遵循路由优先的设计模式,依靠可重用组件来创建名为 LemonMart 的杂货店业务线应用。我们将讨论围绕主要数据实体进行设计的重要性,以及在实现各种条件导航元素之前完成应用程序的高级原型设计的重要性,这些元素在设计阶段可能会发生重大变化。

本项目的源代码可在 GitHub 上找到,地址为github.com/duluca/lemon-mart,包括在Projects文件夹中的各个开发阶段。该项目由 Jasmine 单元测试和 Cypress 端到端测试支持,使用环境变量、Angular Material,以及利用 CircleCI 的持续集成和持续交付CI/CD)管道。你可以在第十章“使用 CI/CD 发布到生产环境”中找到更多关于 CI/CD 的信息。

LemonMart 是一个独立的 Angular 仓库。对于企业或全栈开发,你可能会问,为什么它没有配置为单仓库?在第五章“设计身份验证和授权”中,我们将介绍如何使用 Git 的子模块功能创建单仓库。为了在大型 Angular 应用上工作提供更具有意见和更人性化的方法,我强烈建议你考虑 Nx。它智能的构建系统本身就可以节省数小时的构建时间。请在nx.dev上查看。然而,对这个工具的深入探讨超出了本书的范围。

想要冒险吗?运行以下命令以创建你的 Nx 工作空间:

$ npx create-nx-workspace 

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

  • 创建 LemonMart

  • 生成具有路由功能的模块

  • 品牌化、定制和 Material 图标

  • 带有懒加载的功能模块

  • 创建行走骨架

  • 常见测试模块

  • 围绕主要数据实体进行设计

  • 高级用户体验设计

在第五章到第九章中,我们将逐步完善 LemonMart 以展示上述概念。

技术要求

书籍的示例代码的最新版本可在以下链接的 GitHub 仓库中找到。该仓库包含代码的最终和完成状态。你可以在本章末尾通过查找projects文件夹下的章节末尾代码快照来验证你的进度。

对于第四章:

  1. 克隆仓库:github.com/duluca/lemon-mart

  2. 在根目录下执行npm install以安装依赖项。

  3. 项目的最终状态反映在:

    projects/stage7 
    
  4. 将阶段名称添加到任何ng命令中,使其仅对该阶段生效:

    npx ng build stage7 
    

注意,存储库根目录下的dist/stage7文件夹将包含编译结果。

请注意,书中提供的源代码和 GitHub 上的版本可能不同。这些项目周围的生态系统是不断演变的。在 Angular CLI 生成新代码的方式、错误修复、库的新版本或多种技术的并行实现之间,存在许多难以计数的差异。如果您发现错误或有疑问,请创建问题,或在 GitHub 上提交拉取请求。

在补充指南保持 Angular 和工具常青中了解更多关于更新 Angular 的信息,该指南位于angularforenterprise.com/evergreen

接下来,让我们首先创建 LemonMart^™,这是一个功能齐全的 LOB 应用程序,您可以用作启动下一个专业项目的模板。LemonMart 是一个强大且现实的项目,可以支持功能增长和不同的后端实现,并且它自带完整且可配置的认证和授权解决方案。

自 2018 年推出以来,LemonMart 已为超过 32,500 名开发者提供了超过 257,000 个柠檬。真香!

您可以随时从 GitHub 克隆完成的项目,www.github.com/duluca/lemon-mart, whenever needed。让我们直接开始吧。

创建 LemonMart

LemonMart 将是一个中等规模的业务线应用程序,拥有超过 90 个代码文件。我们将从创建一个新的 Angular 应用程序开始,其中已配置路由和 Angular Material。

假设您已安装了附录 A 中提到的所有必需软件,即设置您的开发环境。如果没有,请根据您的操作系统执行以下命令来配置您的环境。

在 Windows PowerShell 中执行:

PS> Install-Script -Name setup-windows-dev-env
PS> setup-windows-dev-env.ps1 

在 macOS 终端中执行:

$> bash <(wget -O - https://git.io/JvHi1) 

如需更多信息,请参阅github.com/duluca/web-dev-environment-setup

创建一个路由优先的应用程序

我们将创建 LemonMart 作为一个独立项目,这意味着不需要根模块来启动应用程序,并且应用程序内创建的所有组件都将配置为独立组件。我们将使用懒加载功能模块实现模块化架构,并选择性地使用懒加载的独立组件来共享功能模块中的组件。采用路由优先的方法,我们希望在应用程序早期启用路由:

  1. 您可以通过执行此命令创建一个新的应用程序,其中已配置路由:

    $ npm create @angular
    (Enter project name)
    (select SCSS)
    (respond no to SSR) 
    
  2. 为我们创建了一个新的app.routes.ts文件:

    **src/app/app.****routes****.****ts**
    import { Routes } from '@angular/router'
    export const routes: Routes = [] 
    

    我们将在路由数组内部定义routes

  3. 注意,routesapp.config.ts中提供,如下所示:

    **src/app/app.****config****.****ts**
    import { ApplicationConfig } from '@angular/core';
    import { provideRouter } from '@angular/router';
    import { routes } from './app.routes';
    export const appConfig: ApplicationConfig = {
      providers: [provideRouter(**routes**) ]
    }; 
    
  4. 最终,ApplicationConfigmain.ts中被bootstrapApplication消费,从而启动应用程序的引导过程:

    **src/main.****ts**
    import { bootstrapApplication } from '@angular/platform-browser';
    import { appConfig } from './app/app.config';
    import { AppComponent } from './app/app.component';
    bootstrapApplication(AppComponent, **appConfig**)
      .catch((err) => console.error(err)); 
    
  5. 通过运行npm start来执行您的项目。

配置 Angular 和 VS Code

使用mrm,一个帮助保持项目配置文件同步的命令行工具,应用以下配置步骤到您的项目中:

以下脚本不需要您使用 VS Code。如果您希望使用 WebStorm 等其他 IDE,配置的npm脚本同样可以正常运行。

您可以在mrm.js.org/docs/getting-started了解更多关于mrm的信息。

  1. 应用 Angular VS Code 配置:

    npx mrm angular-vscode 
    
  2. 应用 Docker 配置的npm脚本:

    npx mrm npm-docker 
    
  3. 实现一个名为build:prodnpm脚本来在生产模式下构建您的应用程序:

    **"scripts"****: {**
     **...,**
    **"build:prod"****:** **"ng build --configuration production"****,**
    **}** 
    

    默认情况下,Angular 将在生产模式下构建您的代码。但是,这种行为可以在angular.json中更改。因此,我更喜欢明确请求生产构建,以便将代码发布,以避免错误。

    这些设置不断调整以适应扩展、插件、Angular 和 VS Code 不断变化的格局。或者,您可以使用 VS Code 的 Angular Evergreen 扩展一键运行配置命令。

    注意,如果前面的配置脚本执行失败,以下npm脚本也将失败。在这种情况下,您有两个选择:撤销您的更改并忽略这些脚本,或者手动实现这些脚本,如前几章所述(或如 GitHub 上所示)。

  4. 执行npm run style:fix

  5. 执行npm run lint:fix

  6. 执行npm start

请参考附录 A设置您的开发环境,以获取更多配置细节。

关于mrm任务的更多信息,请参阅:

配置 Angular Material 和样式

几年前,将重置或规范化 CSS 样式表应用到主题项目中以解决浏览器处理布局或间距的差异是一个必要的实践。然而,当前浏览器对 CSS 规范的遵循更加严格,因此传统的重置样式表可能过于冗余。以下,我使用具有重置参数如body { margin: 0 }html, body { height: 100% }styles.scss实现。

如果您想查看规范化样式表的现代版本,我推荐github.com/sindresorhus/modern-normalize。它易于设置,并在导入到styles.scss时无缝工作。

我们还需要设置 Angular Material 并配置一个要使用的主题:

  1. 安装 Angular Material:

    $ npx ng add @angular/material
    (select Custom, No to global typography, Yes to browser animations)
    $ npm i @ngbracket/ngx-layout 
    

    注意,由于这是一个独立项目,我们将在每个需要它的单个组件中导入所需的 Material 模块和FlexModule。当@ngbracket/ngx-layout包实现根级提供者时,将不再需要手动添加FlexModule

  2. 如以下代码所示,将常见的 CSS 追加到styles.scss中:

    **src****/styles****.scss**
    …
    html, body { height: 100%; }
    body { margin: 0; font-family: Roboto, 'Helvetica Neue',
      sans-serif; }
    .top-pad { margin-top: 16px; }
    .h-pad { margin: 0 16px; }
    .v-pad { margin: 16px 0; }
    .left-pad { margin-left: 8px; }
    .flex-spacer { flex: 1 1 auto; } 
    
  3. index.html中更新您应用程序的标题。

我们将在本章的后面部分为应用程序应用自定义品牌。接下来,让我们开始设计我们的业务应用程序。

设计 LemonMart

在数据库到前端的过程中,同时避免过度设计,构建一个基本的路线图来遵循是很重要的。这个初始设计阶段对于项目的长期健康和成功至关重要,其中必须打破团队之间存在的任何隔阂,并且所有团队成员都必须对整体技术愿景有清晰的理解。这比说起来容易做起来难,关于这个主题已经写了很多本书。

在工程领域,对于问题没有绝对正确的答案,因此记住没有人能够拥有所有答案或清晰的愿景是很重要的。在文化中创造一个安全的空间,允许开放讨论和实验,对于技术和非技术领导者来说至关重要。作为一个团队能够共同面对这种不确定性所带来的谦逊和同理心,与任何单个团队成员的技术能力一样重要。每个团队成员都必须感到自在,将他们的自我放下,因为我们的共同目标是在开发周期中不断增长和演变应用程序以适应不断变化的需求。如果你知道你成功了,那么你创建的软件的各个部分将很容易被任何人替换。

因此,让我们先制定一个路线图,并确定我们应用程序的范围。为此,我们将定义用户角色,然后构建一个网站图,以形成一个关于我们的应用程序可能如何工作的愿景。

识别用户角色

我们设计的第一步将是思考谁在使用这个应用程序以及为什么。

我们设想了 LemonMart 的四种用户状态或角色:

  • 认证用户:任何认证用户都可以访问他们的个人资料

  • 收银员,其唯一职责是结账客户

  • 店员,其唯一职责是执行与库存相关的功能

  • 经理,可以执行收银员和店员可以执行的所有操作,还可以访问管理功能

考虑到这一点,我们可以开始为我们的应用程序创建一个高级设计。

使用网站图来识别高级模块

按照以下图像所示,开发您应用程序的高级网站图:

登录页面截图,描述自动生成,中等置信度

图 4.1:用户登录页面

我使用了 MockFlow.com 的 SiteMap 工具来创建显示的网站图:sitemap.mockflow.com

初步检查后,有三个高级模块被确定为懒加载候选者:

  • 销售点POS

  • 库存

  • 经理

收银员只能访问POS模块和组件。店员只能访问库存模块,该模块将包括库存录入产品类别管理组件的附加屏幕:

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

图 4.2:库存页面

最后,经理将能够通过经理模块访问所有三个模块,包括用户管理和收据查找组件:

计算机屏幕截图,描述自动生成,中等置信度

图 4.3:经理页面

为所有三个模块启用懒加载将带来巨大好处;由于收银员和店员永远不会使用属于其他用户角色的组件,因此没有必要将这些字节发送到他们的设备。随着经理模块获得更多高级报告功能或新角色被添加到应用程序中,POS模块将不会受到其他情况下不断增长的应用程序带宽和内存影响的干扰。

这意味着在相同硬件上支持调用更少,并且性能保持一致的时间更长。

生成具有路由功能的模块

现在我们已经将高级组件定义为经理库存POS,我们可以将它们定义为模块。这些模块将不同于您为路由和 Angular Material 创建的模块。我们可以在应用程序模块上创建用户配置文件作为组件;然而,请注意,用户配置文件将仅用于已认证的用户,因此定义一个仅针对一般已认证用户的第四个模块是有意义的。这样,您将确保应用程序的第一个负载尽可能小。此外,我们还将创建一个home组件来包含我们应用程序的着陆体验,这样我们就可以将实现细节排除在app.component之外:

  1. 通过指定名称和路由能力生成managerinventoryposuser功能模块:

    $ npx ng g m manager --routing
    $ npx ng g m inventory --routing
    $ npx ng g m pos --routing
    $ npx ng g m user --routing 
    

    注意简化的命令结构,其中ng generate module manager变为ng g m manager,同样,--module变为-m

  2. 确认您没有 CLI 错误。

    注意,在 Windows 上使用npx可能会抛出错误,例如Path must be a string. Received undefined。这个错误似乎不会影响命令的成功执行,因此始终检查 CLI 工具生成的输出是至关重要的。

  3. 确认已创建文件夹和文件:

    /src/app
    │   app.component.scss
    │   app.component.html
    │   app.component.spec.ts
    │   app.component.ts
    │   app.config.ts
    │   app.routes.ts
    ├───inventory
    │      inventory-routing.module.ts
    │      inventory.module.ts
    ├───manager
    │      manager-routing.module.ts
    │      manager.module.ts
    ├───pos
    │      pos-routing.module.ts
    │      pos.module.ts
    └───user
    │      user-routing.module.ts
    │      user.module.ts 
    

让我们检查 ManagerModule 的配置。记住,功能模块由 @NgModule 注解装饰。在配置有根 NgModule 的 Angular 应用中,你会注意到它实现了 bootstrap 属性,而功能模块没有实现此属性。下面是生成的代码:

**src/app/manager/manager.****module****.****ts**
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { ManagerRoutingModule } from './manager-routing.module'
@NgModule({
  imports: [CommonModule, ManagerRoutingModule],
  declarations: [],
})
export class ManagerModule {} 

由于我们指定了 --routing 选项,已创建并导入到 ManagerModule 中的 routing 模块:

**src/app/manager/manager-routing.****module****.****ts**
import { NgModule } from '@angular/core'
import { Routes, RouterModule } from '@angular/router'
const routes: Routes = []
@NgModule({
  imports: [RouterModule.forChild(routes)], 
  exports: [RouterModule],
})
export class ManagerRoutingModule {} 

注意,RouterModule 正在使用 forChild 进行配置,而不是可选的 forRoot 方法,该方法配置在 AppRouting 模块或 ApplicationConfig 中的路由提供者。通过指定上下文,我们允许路由器理解不同模块上下文中定义的路由之间的正确关系。例如,在 ManagerRoutingModule 中定义的所有子路由都将由路由段 /manager 预先添加。

在继续之前,务必执行 stylelint fix 命令:

$ npm run style:fix && npm run lint:fix 

现在,让我们设计 LemonMart 的着陆页将如何看起来和工作。

设计主页路由

将以下模拟作为 LemonMart 的着陆体验考虑:

登录框的截图,描述由低置信度自动生成

图 4.4:LemonMart 着陆体验

与 LocalCast 天气应用不同,我们不希望在 AppComponent 中有太多的布局标记。AppComponent 是你整个应用的根元素;因此,它应该只包含将在你的应用中持续出现的元素。在以下注释模拟中,标记为 1 的工具栏将在整个应用中保持不变。

标记为 2 的区域将包含 home 组件,该组件本身将包含一个标记为 3 的登录用户控件:

计算机登录的截图,描述由中等置信度自动生成

图 4.5:LemonMart 布局结构

在 Angular 中,将默认或着陆组件作为单独的元素创建是最佳实践。这有助于减少必须加载和在每个页面上逻辑执行的代码量,但它也使得在利用路由器时具有更灵活的架构。

使用内联模板和样式生成 home 组件:

$ npx ng g c home --inline-template --inline-style 

注意,具有内联模板和样式的组件也称为 单文件组件SFC

现在,你已准备好配置路由器。

设置默认路由

让我们开始设置 LemonMart 的简单路由。我们需要设置 / 路由(也称为空路由)和 /home 路由以显示 HomeComponent。我们还需要一个通配符路由来捕获所有未定义的路由并显示一个 PageNotFoundComponent,这也必须被创建:

**src/app/app.****routes****.****ts**
...
**import** **{** **HomeComponent** **}** **from****'./home/home.component'**
**import** **{** 
**PageNotFoundComponent**
**}** **from****'./page-not-found/page-not-found.component'**
const routes: Routes = [
  **{** **path****:** **''****,** **redirectTo****:** **'home'****,** **pathMatch****:** **'full'** **},**
  **{** **path****:** **'home'****,** **component****:** **HomeComponent** **},**
  **{** **path****:** **'**'****,** **component****:** **PageNotFoundComponent** **},**
]
... 

让我们逐步整理上述路由配置:

  1. 定义 'home' 的路径,并通过设置 component 属性将路由器指向渲染 HomeComponent

  2. 将应用程序的默认路径''设置为重定向到'/home'。通过设置pathMatch属性,我们始终确保这个特定的home路由实例将被渲染为着陆体验;否则,在其默认前缀设置中,pathMatch将考虑空路径为所有路由的前缀,导致无限重定向循环。

  3. 创建一个具有内联模板的pageNotFound组件。

  4. PageNotFoundComponent配置为最后一个条目的通配符路由。

通过将通配符路由配置为最后一个条目,我们处理任何未通过优雅匹配的路由,将其重定向到PageNotFoundComponent。通配符路径必须是数组中的最后一个属性;否则,定义在后面的路由将不会被考虑。

当用户到达PageNotFoundComponent时,我们希望他们能够使用routerLink指令返回到HomeComponent

PageNotFoundComponent中,替换内联模板,使用routerLink链接回home

**src/app/page-not-found/page-not-found.****component****.****ts**
...
  template: `
    **<p>**
      **This page doesn't exist. Go back to**
      **<a routerLink="/home">home</a>.**
    **</p>**
  `,
... 

这种导航也可以通过<a href>标签实现;然而,在更动态和复杂的导航场景中,你将失去诸如自动活动链接跟踪或动态链接生成等特性。

Angular 引导过程将确保AppComponent位于index.html中的<app-root>元素内。然而,我们必须手动定义我们希望HomeComponent渲染的位置,以最终完成路由配置。

路由出口

AppComponent被认为是app-routes.ts中定义的根路由的根元素,这允许我们在根元素内部定义出口,以动态加载我们希望使用<router-outlet>元素加载的任何内容:

  1. AppComponent配置为使用内联模板和样式,删除htmlscss文件中任何现有的内容。

  2. 为你的应用程序添加工具栏。

  3. 将应用程序的名称作为按钮链接添加,以便在点击时将用户带到主页。

  4. 在组件中导入RouterLinkRouterOutletMatToolbarModule

  5. 为内容添加<router-outlet>以进行渲染:

    **src/app/app.****component****.****ts**
    ...
    template: `
      <mat-toolbar color="primary">
        <a mat-button routerLink="/home"><h1>LemonMart</h1></a>
      </mat-toolbar>
      <router-outlet></router-outlet>
    `, 
    

现在,home的内容将渲染在<router-outlet>内。

品牌、定制和 Material 图标

为了构建一个吸引人且直观的工具栏,我们必须向应用程序引入一些图标和品牌,以便用户可以在熟悉图标的帮助下轻松地导航应用程序。

品牌

在品牌方面,你应该确保你的 Web 应用有一个自定义的色彩调色板,并且与桌面和移动浏览器功能集成,以突出你的应用名称和图标。

色彩调色板

使用位于m2.material.io/design/color/the-color-system.html#tools-for-picking-colorsMaterial Color工具选择一个色彩调色板。对于 LemonMart,我选择了以下值:

  1. 主颜色- #2E7D32

    $lemon-mart-primary: mat.define-palette(mat.$green-palette, 800); 
    
  2. 次要颜色- #C6FF00

    $lemon-mart-accent: mat.define-palette(mat.$lime-palette, A400); 
    

    您可以在styles.scss中实现您的主题,或者创建一个单独的主题文件。如果打算进一步自定义单个组件,则单独的文件很有用。

  3. 添加一个名为lemonmart-theme.scss的文件

  4. 将与主题相关的 CSS 从styles.scss移动到新文件。主题相关内容将在以下行之上:

    **styles****.scss**
    ...
    /* You can add global styles to this file and also import other style files */
    ... 
    
  5. styles.scss更新为在文件的第一行包含新主题:

    **styles****.scss**
    @use 'lemonmart-theme';
    ... 
    
  6. 使用所选的色彩调色板配置您的自定义主题。

您还可以从 GitHub 获取与 LemonMart 相关的资源,网址为github.com/duluca/lemon-mart

对于 LocalCast 天气应用,我们替换了favicon.ico文件,以在浏览器中为我们的应用打上品牌。虽然这在 10 年前就足够了,但今天的设备种类繁多,每个平台都可以更好地利用优化后的资源来代表您的 Web 应用在其操作系统中的表现。接下来,让我们实现一个更健壮的 favicon。

实现浏览器清单和图标

您必须确保浏览器在浏览器标签中显示正确的标题文本和图标。此外,应创建一个实现各种移动操作系统特定图标的清单文件,以便如果用户将您的网站固定,将显示一个类似其他手机应用图标的图标。这将确保如果用户在移动设备的首页上收藏或固定您的 Web 应用,他们将获得一个看起来像原生应用图标的图标:

  1. 从设计师或类似www.flaticon.com的网站创建或获取您网站标志的 SVG 版本。

  2. 在这个例子中,我将使用尤里卡柠檬的相似图像:包含黄色、香蕉的图片 自动生成的描述

    图 4.6:LemonMart 的标志性标志

    当使用在线找到的图片时,请注意适用的版权。在这种情况下,我已购买许可证以能够发布这个柠檬标志,但您可以在以下 URL 获取自己的副本,前提是您向图片的作者提供所需的归属:www.flaticon.com/free-icon/lemon_605070

  3. 使用工具如realfavicongenerator.net生成favicon.ico和清单文件。

  4. 调整 iOS、Android、Windows 和 macOS Safari 的设置以符合您的喜好。

  5. 在生成器中,务必设置一个版本号,因为 favicon 可能会因缓存而出名;一个随机的版本号将确保用户总是获得最新版本。

  6. 下载并解压缩生成的favicons.zip文件到您的src文件夹。

  7. 编辑angular.json文件以将新资源包含到您的应用中:

    **angular.json**
    "apps": [
      {
      ...
        "assets": [
          "src/assets",
          "src/favicon.ico",
          "src/android-chrome-192x192.png",
          "src/favicon-16x16.png",
          "src/mstile-310x150.png",
          "src/android-chrome-512x512.png",
          "src/favicon-32x32.png",
          "src/mstile-310x310.png",
          "src/apple-touch-icon.png",
          "src/manifest.json",
          "src/mstile-70x70.png",
          "src/browserconfig.xml",
          "src/mstile-144x144.png",
          "src/safari-pinned-tab.svg",
          "src/mstile-150x150.png"
        ] 
    
  8. index.html<head>部分插入生成的代码:

    **src/index.html**
    <link rel="apple-touch-icon" sizes="180x180"
      href="/apple-touch- icon.png?v=rMlKOnvxlK">
    <link rel="icon" type="image/png" sizes="32x32"
      href="/favicon-32x32.png?v=rMlKOnvxlK">
    <link rel="icon" type="image/png" sizes="16x16"
      href="/favicon-16x16.png?v=rMlKOnvxlK">
    <link rel="manifest" href="/manifest.json?v=rMlKOnvxlK">
    <link rel="mask-icon"
      href="/safari-pinned-tab.svg?v=rMlKOnvxlK" color="#b3ad2d">
    <link rel="shortcut icon" href="/favicon.ico?v=rMlKOnvxlK">
    <meta name="theme-color" content="#ffffff"> 
    

    在 favicon 声明和 CSS 样式导入之间放置生成的代码。顺序很重要。浏览器自上而下加载数据。您希望应用程序的图标在用户等待下载 CSS 文件之前被解析。

  9. 确保您的新 favicon 正确显示。

一旦您的基本品牌工作完成,请考虑是否希望通过主题化建立更独特的视觉和感觉。

自定义主题

您可以通过利用以下工具列表中的工具以及我发现的某些其他工具来进一步自定义 Material 的外观和感觉,以实现您应用独特的体验:m2.material.io/resources

  • Material Design 主题调色板生成器将在 mcg.mbitson.com 生成定义您自定义调色板的必要代码,以创建真正独特的主题。

  • 颜色混合器有助于找到两种颜色之间的中间点,这在定义颜色样本之间的颜色时很有用,位于 meyerweb.com/eric/tools/color-blend

    在 2021 年,Google 宣布了 Material 3,也称为 Material You,这是一个动态主题系统,它适应用户在操作系统级别颜色使用方面的偏好。到 2023 年,Angular Material 仍然基于 Material 2。Angular 团队在 Angular 15 中过渡到新的 Web 端 Material 设计组件MDC)风格组件,并在 Angular 17 中弃用旧样式。MDC 风格组件支持可调整的密度,因此更加动态。在此里程碑之后,Angular 团队计划解决 Material You 的实现问题。

    您可以关注此线程以获取更新:github.com/angular/components/issues/22738

material.io 上有大量关于 Material 设计深入哲学的信息,包括关于色彩系统等内容,如 material.io/design/color/the-color-system.html,它深入探讨了为您的品牌选择正确的调色板以及其他主题,例如为您的应用创建深色主题。

区分您的品牌与其他应用或竞争对手非常重要。创建高质量的定制主题将是一个耗时的过程;然而,通过给用户留下深刻的第一印象所带来的好处是相当可观的。

接下来,我们将向您展示如何将自定义图标添加到您的 Angular 应用中。

自定义图标

现在,让我们将您自定义的品牌添加到您的 Angular 应用中。您需要用于创建 favicon 的 svg 图标:

  1. 将图片放置在 src/assets/img/icons 目录下,命名为 lemon.svg

  2. app.config.ts 中,添加 provideHttpClient() 作为提供者,以便可以通过 HTTP 请求 .svg 文件。

  3. 更新 AppComponent 以注册新的 .svg 文件作为图标:

    **src/app/app.****component****.****ts**
    **import** **{** **MatIconRegistry** **}** **from****'@angular/material/icon'**
    **import** **{** **DomSanitizer** **}** **from****'@angular/platform-browser'**
    ...
    export class AppComponent {
      **constructor****(**
        **iconRegistry****:** **MatIconRegistry****,** 
        **sanitizer****:** **DomSanitizer**
      **) {** 
        **iconRegistry.****addSvgIcon****(**
          **'lemon'****,**
          **sanitizer.****bypassSecurityTrustResourceUrl****(**
            **'assets/img/icons/lemon.svg'**
          **)** 
        **)**
      } 
    } 
    

    注意,从 URL 资源添加 svg 图标在 服务器端渲染SSR)配置中不起作用。相反,您可以将 svg 图标作为 TypeScript 文件导入中的 const 字符串添加,并按以下方式注册:

    import { LEMON_ICON } from './svg.icons'iconRegistry.
    addSvgIconLiteral('lemon', sanitizer. bypassSecurityTrustHtml(LEMON_ICON)) 
    
  4. 导入 MatIconModule

  5. 按照在material.angular.io/components/toolbar文档中找到的MatToolbar模式,将图标添加到工具栏:

    **src/app/app.****component****.****ts**
    template: ` 
      <mat-toolbar color="primary"> 
        **<mat-icon svgIcon="lemon"></mat-icon>** 
        <a mat-button routerLink="/home"><h1>LemonMart</h1></a> 
      </mat-toolbar> 
      <router-outlet></router-outlet>
    `, 
    

现在,让我们添加菜单、用户个人资料和注销的剩余图标。

材料图标

Angular Material 与 Material Design 图标字体无缝配合,自动作为网页字体导入到您的应用中,位于index.html。您可以自行托管字体;然而,如果您选择这条路,如果用户的浏览器已经从访问另一个网站时缓存了字体,那么您将无法获得好处,这可能会在下载 42-56 KB 文件的过程中节省速度和延迟。完整的图标列表可以在fonts.google.com/icons找到。

现在让我们更新工具栏并添加一些图标,设置主页并使用最小模板添加一个假登录按钮:

  1. 确保材料图标的<link>标签已添加到index.html

    **src/index.html**
    <head>
      ...
      <link href="https://fonts.googleapis.com icon?family=Material+Icons"
        rel="stylesheet">
    </head> 
    

    google.github.io/material-design-icons/#getting-icons自托管部分可以找到自托管说明。

    一旦配置完成,使用材料图标就变得简单。

  2. AppComponent上,更新工具栏,将菜单按钮放置在标题的左侧。

  3. 添加fxFlex指令,以便剩余的图标右对齐。

  4. 导入FlexModuleMatButtonModule

  5. 添加用户个人资料和注销图标:

    **src/app/app.****component****.****ts**
    template: `
      <mat-toolbar color="primary">
        **<button mat-icon-button>**
          **<mat-icon>menu</mat-icon>**
        **</button>**
        <mat-icon svgIcon="lemon"></mat-icon>
        <a mat-button routerLink="/home"><h1>LemonMart</h1></a>
        **<span class="flex-spacer"></span>**
        **<button mat-icon-button>**
          **<mat-icon>account_circle</mat-icon>**
        **</button>**
        **<button mat-icon-button>**
          **<mat-icon>lock_open</mat-icon>**
        **</button>**
      </mat-toolbar>
      <router-outlet></router-outlet>
    `, 
    
  6. HomeComponent上,添加一个用于登录体验的最小模板,替换任何现有内容。别忘了导入FlexModuleMatButtonModule

    **src/app/home/home.****component****.****ts**
      styles: [`
        div[fxLayout] {margin-top: 32px;}
      `],
      template: `
        <div fxLayout="column" fxLayoutAlign="center center">
          <span class="mat-display-2">Hello, Limoncu!</span>
          <button mat-raised-button color="primary">Login</button>
        </div>
      ` 
    

您的应用应该看起来与这张截图相似:

计算机截图 自动生成描述

图 4.7:LemonMart 的最小登录界面

由于用户的认证状态,在实现和显示/隐藏菜单、个人资料和注销图标方面还有一些工作要做。我们将在第七章使用 REST 和 GraphQL API中介绍这个功能。

要调试路由,获取您的路由的可视化,并将 Angular 紧密集成到 Chrome 的调试功能中,请使用从 Chrome Web Store(也兼容 Microsoft Edge)或 Firefox 插件angular.dev/tools/devtools提供的 Angular DevTools。

现在您已经为您的应用设置了基本的路由,我们可以继续设置带有子组件的懒加载模块。如果您不熟悉 Angular 的故障排除和调试,请在继续之前查阅angular.dev/tools/devtools

带有懒加载的功能模块

资源加载有两种方式:急切加载或懒加载。当浏览器加载你的应用的index.html时,它从上到下开始处理。首先处理<head>元素,然后是<body>。例如,我们在应用的<head>中定义的 CSS 资源将在我们的 Angular 应用在 HTML 文件的<body>中定义为<script>之前下载,因为我们的 Angular 应用被定义为 HTML 文件的<body>中的<script>

当你使用ng build命令时,Angular 利用 webpack 模块打包器将所有 JavaScript、HTML 和 CSS 组合成最小化和优化的 JavaScript 包。

如果你不在 Angular 中使用懒加载,你的应用的所有内容都将被急切加载。用户将看不到你的应用的第一屏,直到所有屏幕都下载并加载完成。

懒加载允许 Angular 构建过程与 webpack 协同工作,将你的 Web 应用分割成不同的 JavaScript 文件,称为 chunks。我们可以通过将应用程序的部分分离到功能模块中来实现这种 chunking。功能模块及其依赖项可以捆绑到单独的 chunks 中。请记住,根模块及其依赖项将始终在第一个下载的 chunk 中。因此,通过 chunking 我们的应用程序的 JavaScript 包大小,我们保持初始 chunk 的大小最小。有了最小化的第一个 chunk,无论你的应用如何增长,首次有意义的绘制时间保持不变。否则,随着你向应用添加更多功能和功能,你的应用将需要更长的时间来下载和渲染。懒加载对于实现可扩展的应用程序架构至关重要。

考虑以下图形以确定哪些路由是急切加载的,哪些是懒加载的:

系统图,描述自动生成

图 4.8:Angular 路由急切加载与懒加载

黑色三角形是独立组件,而黑色圆圈是依赖于模块的组件。rootRouter定义了三条路由:abc/master/detail代表命名路由出口,这在第九章食谱 – 主/详情、数据表和 NgRx中有详细说明。路由a是应用的默认路由。路由ac用实线连接到rootRouter,而路由b则使用虚线连接。在这种情况下,路由b被配置为懒加载路由。这意味着路由b将动态加载一个包含childRouter的功能模块BModulechildRouter可以定义任意数量的组件,甚至可以重用其他地方已经重用的路由名称。在这种情况下,b定义了两个额外的路由:/b/a/b/b

考虑rootRouter的示例路由定义:

**rootRouter example**
const routes: Routes = [
  { path: '', redirectTo: '/a', pathMatch: 'full' },
  {
    path: 'a',
    component: AComponent,
    children: [
      { path: '', component: MasterComponent, outlet: 'master' },
      { path: '', component: DetailComponent, outlet: 'detail' },
    ],
  },
  {
    path: 'b',
    loadChildren: 
      () => import('./b/b.module')
        .then((module) => module.BModule), 
    canLoad: [AuthGuard],
  },
  { path: 'c', loadChildren: () => import('./c/routes').then(mod => mod.C_ROUTES)},},
  { path: '**', component: PageNotFoundComponent },
] 

注意,路由/b/a/b/b/c/a/c/b的定义在rootRouter中不存在。请参阅childRouter的示例路由定义:

**/b childRouter example**
const routes: Routes = [
  { path: '', redirectTo: '/b/a', pathMatch: 'full' },
  { path: 'a', component: BAComponent },
  { path: 'b', component: BBComponent },
]
**/c route config example**
const routes: Routes = [
  { path: '', redirectTo: '/c/a', pathMatch: 'full' },
  { path: 'a', component: CAComponent },
  { path: 'b', component: CBComponent },
] 

如您所见,childRouter 中定义的路由与 rootRouter 中定义的路由是独立的。子路由存在于一个层次结构中,其中 /b 是父路径。要导航到 BAComponent,您必须使用路径 /b/a,要导航到 CAComponent,则使用 /c/a

给定此示例配置,rootRouter 中定义的每个组件及其依赖项都将包含在我们应用的第一个块中,因此会预先加载。第一个块将包括组件 AMasterDetailPageNotFound。第二个块将包含组件 BABB。这个第二个块将在用户导航到以 /b 开头的路径之前不会下载或加载;因此,它是懒加载的。在独立配置中,这种分块可以在组件级别上更细致。

在我们添加跨不同模块使用的共享组件时,我在 第八章食谱 – 可重用性、表单和缓存 中介绍了如何处理懒加载独立组件。

您可以在 angular.io/guide/standalone-components#lazy-loading-a-standalone-component 上了解更多详细信息。

我们现在将介绍如何设置具有组件和路由的功能模块。我们还将使用 Angular DevTools 来观察我们各种路由配置的效果。

使用组件和路由配置功能模块

管理模块需要一个着陆页,如图所示:

计算机的截图  描述由中等置信度自动生成

图 4.9:管理员的仪表板

让我们从创建 ManagerModule 的主屏幕开始:

  1. 创建 ManagerHome 组件:

    $ npx ng g c manager/managerHome manager -s -t 
    

    要在 manager 文件夹下创建新组件,我们必须在组件名称前加上 manager/ 前缀。由于这是一个另一个着陆页,它不太可能复杂到需要单独的 HTML 和 CSS 文件。您可以使用 --inline-style(别名 -s)和/或 --inline-template(别名 -t)来避免创建额外的文件。

  2. 确认您的文件夹结构如下:

    **/src**
    **├───app**
    **│ │**
    **│ ├───manager**
    **│ │ │ manager-routing.module.ts**
    **│ │ │ manager.module.ts**
    **│ │ │**
    **│ │ └───manager-home**
    **│ │ │ │ manager-home.component.spec.ts**
    **│ │ │ │ manager-home.component.ts** 
    
  3. manager-routing.module.ts 中配置 ManagerHome 组件的路由,类似于我们在 app.route.ts 中配置 Home 组件的方式:

    **src/app/manager/manager-routing.****module****.****ts**
    import { 
      ManagerHomeComponent 
    } from './manager-home/manager-home.component' 
    const routes: Routes = [ 
      { path: '', redirectTo: 'home', pathMatch: 'full' }, 
      { path: 'home', component: ManagerHomeComponent }, 
    ] 
    

注意,http://localhost:4200/manager 还没有解析到组件,因为我们的 Angular 应用不知道 ManagerModule 的存在。在独立项目中预先加载模块根本就没有意义;我们只会考虑功能模块的懒加载。

接下来,让我们实现 ManagerModule 的懒加载,以便 Angular 可以导航到它。

懒加载

懒加载代码可能看起来像是黑魔法(即误解)代码。为了从不同的模块加载路由,我们知道我们不能简单地导入它们;否则,它们将被预先加载。答案在于使用 loadChildren 属性配置路由,并使用内联 import 语句通知路由器如何在 app.routes.ts 中加载功能模块:

  1. app.routes.ts 中,使用 loadChildren 属性实现或更新 'manager' 路径:

    **src/app/app.****routes****.****ts**
    import { Routes } from '@angular/router'
    import { HomeComponent } from './home/home.component'
    import { PageNotFoundComponent } from './page-not-found/page-not-found.component'
    
      const routes: Routes = [ 
        ... 
        { 
          path: 'manager', 
          loadChildren: 
            () => import('./manager/manager.module')
              . then(m=> m.ManagerModule), 
        },
        ... 
      ] 
    ... 
    

    懒加载是通过一种巧妙的技巧实现的,避免了在文件级别使用 import 语句。将一个函数委托设置到 loadChildren 属性,该属性包含一个内联 import 语句,定义了功能模块文件的位置,例如 ./manager/manager.module,允许我们以类型安全的方式引用 ManagerModule 而无需完全加载它。内联 import 语句可以在构建过程中被解释,以创建一个单独的 JavaScript 块,只有在需要时才能下载。ManagerModule 作为功能模块是自给自足的;它管理所有子依赖项和路由。

  2. 考虑到 manager 现在是它们的根路由,更新 manager-routing.module.ts 路由:

    **src/app/manager/manager-routing.module.ts**
    const routes: Routes = [
      { path: '', redirectTo: 'home', pathMatch: 'full' },
      { path: 'home', component: ManagerHomeComponent },
    ] 
    

    我们现在可以将 ManagerHomeComponent 的路由更新为更有意义的 'home' 路径。这个路径不会与 app.routes.ts 中的路径冲突,因为在当前上下文中,'home' 解析为 'manager/home',同样地,当 path 为空时,URL 将看起来像 http://localhost:4200/manager

  3. 重新启动 ng servenpm start 命令,以便 Angular 可以正确地分块应用程序。

  4. 导航到 http://localhost:4200/manager

  5. 通过观察 CLI 输出是否包含一个新的 Lazy Chunk Files 部分,以确认懒加载是否正常工作:

    Lazy Chunk Files            | Names          |  Raw Size |
    src_app_manager_module_ts.js| manager-module | 358.75 kB | 
    

我们已成功设置了一个具有懒加载的功能模块。接下来,让我们为 LemonMart 实现行走骨架。

创建行走骨架

使用本章早期为 LemonMart 创建的网站图,我们需要为应用程序创建行走骨架导航体验。为了创建这种体验,我们必须创建一些按钮来链接所有模块和组件。我们将按模块逐一进行。

在我们开始之前,更新 HomeComponent 上的 Login 按钮以使用 routerLink 属性导航到 'manager' 路径,并重命名该按钮:

**src/app/home/home.****component****.****ts**
  ...
  <button mat-raised-button color="primary" routerLink="/manager">
    Login as Manager
  </button>
  ... 

现在,我们可以通过点击 Login 按钮导航到 ManagerHome 组件。

管理模块

由于我们已为 ManagerModule 启用了懒加载,让我们继续完成其余的导航元素。

在当前设置中,ManagerHomeComponentAppComponent 模板中定义的 <router-outlet> 中渲染,因此当用户从 HomeComponent 导航到 ManagerHomeComponent 时,AppComponent 中实现的工具栏将保持原位。参见以下 管理仪表板 的模拟图:

登录表单的特写  描述由低置信度自动生成

图 4.10:应用范围和功能模块工具栏

应用范围工具栏无论我们导航到哪里都保持不变。想象一下,我们可以为ManagerModule中持续存在的功能模块实现一个类似的工具栏。因此,导航的用户管理收据查找按钮将始终可见。这允许我们在模块之间创建一致的 UX 来导航子页面。

要实现一个次要工具栏,我们需要复制AppComponentHomeComponent之间的父子关系,其中父元素实现工具栏和<router-outlet>,以便子元素可以渲染在那里:

  1. 首先创建基本的manager组件:

    $ npx ng g c manager/manager --flat -s -t 
    

    --flat选项跳过目录创建,并将组件直接放置在manager文件夹下,就像位于app文件夹下的AppComponent一样。

  2. ManagerComponent中实现一个带有activeLink跟踪的导航工具栏:

    **src/app/manager/manager.****component****.****ts**
    styles: `
      div[fxLayout] {
        margin-top: 32px;
      }
      .active-link {
        font-weight: bold;
        border-bottom: 2px solid #005005;
      }
    `,
      template: `
        <mat-toolbar color="accent" fxLayoutGap="8px">
          <a mat-button routerLink="home" routerLinkActive="active-link">
            Manager's Dashboard
          </a>
          <a mat-button routerLink="users" routerLinkActive="active-link">
            User Management
          </a>
          <a mat-button routerLink="receipts"
            routerLinkActive="active- link">
            Receipt Lookup
          </a>
        </mat-toolbar>
        <router-outlet></router-outlet> 
      `, 
    

    在独立项目中,每个新组件都是作为一个独立组件创建的。这意味着每个组件都必须导入它自己的依赖项。别忘了在模板中逐个导入每个使用的功能。

  3. 创建子页面的组件:

    $ npx ng g c manager/userManagement
    $ npx ng g c manager/receiptLookup 
    
  4. 创建父子路由。我们知道我们需要以下路由才能导航到我们的子页面,如下所示:

    **example**
    { path: '', redirectTo: 'home', pathMatch: 'full' },
    { path: 'home', component: ManagerHomeComponent },
    { path: 'users', component: UserManagementComponent },
    { path: 'receipts', component: ReceiptLookupComponent }, 
    

要针对在ManagerComponent中定义的<router-outlet>,我们需要首先创建一个父路由,然后指定子页面的路由:

**src/app/manager/manager-routing.****module****.****ts**
...
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import {
  ManagerHomeComponent
} from './manager-home/manager-home.component'
import {
  ManagerComponent
} from './manager.component'
import {
  ReceiptLookupComponent
} from './receipt-lookup/receipt-lookup.component'
import {
  UserManagementComponent
} from './user-management/user-management.component' 
const routes: Routes = [
  {
    path: '', 
    component: ManagerComponent, 
    children: [
      { path: '', redirectTo: 'home', pathMatch: 'full' },
      { path: 'home', component: ManagerHomeComponent },
      { path: 'users', component: UserManagementComponent },
      { path: 'receipts', component: ReceiptLookupComponent },
    ],
  },
] 

现在,你应该能够导航到应用中。当你点击登录为管理员按钮时,你将被带到这里显示的页面。可点击的目标被突出显示:

计算机屏幕截图  自动生成的描述

图 4.11:带有所有路由链接高亮显示的管理员仪表板

如果你点击LemonMart,你将被带到主页。如果你点击管理员的仪表板用户管理收据查找,你将被导航到相应的子页面,而活动链接将在工具栏上加粗并下划线。

用户模块

用户登录后,可以通过侧边导航菜单访问他们的个人资料并查看在 LemonMart 应用中可以访问的操作列表。在第六章实现基于角色的导航中,当我们实现身份验证和授权时,我们将从服务器接收用户的角色。根据用户的角色,我们可以自动导航或限制用户可以看到的选项。我们将在这个模块中实现这些组件,以便它们只在用户登录时加载。为了完成行走骨架,我们将忽略与身份验证相关的关注点:

  1. 创建必要的组件:

    $ npx ng g c user/profile
    $ npx ng g c user/logout -t -s
    $ npx ng g c user/navigationMenu -t -s 
    
  2. 实现路由。

    从在app.routes.ts中实现懒加载开始:

    **src/app/app.****routes****.****ts**
    ... 
    { 
      path: 'user', 
      loadChildren: 
        () => import('./user/user.module')
          .then(m => m.UserModule), 
    }, 
    

    如前所述,确保PageNotFoundComponent路由始终是app.routes.ts中的最后一个路由——因为它有一个通配符匹配器,它将覆盖其后的路由定义。

    现在在user-routing.module.ts中实现子路由:

    **src/app/user/user-routing.****module****.****ts**
    ...
    const routes: Routes = [
      { path: 'profile', component: ProfileComponent },
      { path: 'logout', component: LogoutComponent },
    ] 
    

    我们正在为NavigationMenuComponent实现路由,因为它将被直接用作 HTML 元素。此外,由于UserModule没有登录页面,因此没有定义默认路径。

  3. AppComponent中连接userlogout图标:

    **src/app/app.****component****.****ts**
    ... 
    <mat-toolbar> 
      ... 
      <button 
        **mat-mini-fab routerLink=****"/user/profile"**
        **matTooltip=****"Profile"** **aria-label=****"User Profile"**
      > 
        <mat-icon>account_circle</mat-icon> 
      </button>
      <button
         **mat-mini-fab routerLink=****"/user/logout"** 
         **matTooltip=****"Logout"** **aria-label=****"Logout"**
      > 
        <mat-icon>lock_open</mat-icon> 
      </button> 
    </mat-toolbar> 
    

    图标按钮可能难以理解,因此添加工具提示是个好主意。为了让工具提示工作,从mat-icon-button指令切换到mat-mini-fab指令,并确保按需导入MatTooltipModule。此外,确保为仅图标按钮添加aria-label,以便依赖屏幕阅读器的残障用户仍然可以导航您的 Web 应用程序。

  4. 确保应用程序正常工作。

    您会注意到两个按钮彼此之间太近,如下所示:

    A green and white flag  Description automatically generated with low confidence

    图 4.12:带有图标的工具栏

  5. 您可以通过在<mat-toolbar>中添加fxLayoutGap="8px"来解决图标布局问题;然而,现在柠檬标志与应用程序名称的距离太远,如下所示:img/B20960_04_13.png

    图 4.13:带有填充图标的工具栏

  6. 通过合并图标和按钮可以修复标志布局问题:

    **src/app/app.****component****.****ts**
    ...
    <mat-toolbar>
      ...
      <a mat-icon-button routerLink="/home">
        <mat-icon svgIcon="lemon"></mat-icon>
        LemonMart
      </a>
      ...
    </mat-toolbar> 
    

    如以下截图所示,分组解决了布局问题:

    img/B20960_04_14.png

    图 4.14:带有分组和填充元素的工具栏

  7. 另一个替代方案是将文本包裹在<span>标签中;然而,在这种情况下,您需要添加一些填充以保持外观:

    <span class="left-pad" data-testid="title">LemonMart</span> 
    

从用户体验的角度来看,这更令人满意;现在,用户可以通过点击柠檬返回主页。

POS 和库存模块

我们的行走骨架扮演管理者的角色。为了能够访问我们即将创建的所有组件,我们需要使管理者能够访问 POS 和库存模块。

用两个新按钮更新ManagerComponent

**src/app/manager/manager.****component****.****ts**
<mat-toolbar color="accent" **fxLayoutGap=****"8px"**> 
  ... 
  **<span** **class****=****"flex-spacer"****><****/span>** 
  **<button** 
    **mat-mini-fab routerLink="/i****nventory****"** 
    **matTooltip="****Inventory****" aria-label="****Inventory****"**
  **>** 
    **<mat-icon>list</mat-icon>** 
  **</button>** 
  **<button** 
    **mat-mini-fab routerLink="****/pos****"** 
    **matTooltip="****POS****" aria-label="****POS****"**
  **>** 
    **<mat-icon>shopping_cart</mat-icon>** 
  **</button>** 
</mat-toolbar> 

注意,这些路由链接将使我们离开ManagerModule的领域,因此管理特定的二级工具栏消失是正常的。

现在,将取决于您来实现最后两个剩余的模块。对于这两个新模块,我提供了高级步骤,并指导您参考先前的模块,您可以在其中为新模块建模。如果您遇到困难,请参考 GitHub 项目github.com/duluca/lemon-mart中的projects/stage7文件夹。

PosModule

PosModuleUserModule非常相似,除了PosModule是默认路径。PosComponent将是默认组件。这可能是一个具有一些子组件的复杂组件,因此不要使用内联模板或样式:

  1. 创建PosComponent

  2. PosComponent注册为默认路径。

  3. PosModule配置懒加载。

  4. 确保应用程序正常工作。

现在让我们实现InventoryModule

InventoryModule

InventoryModuleManagerModule非常相似,如下所示:

计算机屏幕截图  描述由中等置信度自动生成

图 4.15:库存仪表板原型

  1. 创建一个基本的Inventory组件。

  2. 注册MaterialModule

  3. 创建Inventory HomeStock EntryProductsCategories组件。

  4. inventory-routing.module.ts中配置父子路由。

  5. InventoryModule配置懒加载。

  6. InventoryComponent中实现一个用于内部InventoryModule导航的二级工具栏。

  7. 确保应用程序按如下所示工作:

计算机屏幕截图  描述由低置信度自动生成

图 4.16:LemonMart 库存仪表板

现在应用程序的行走骨架已经完成,检查 CLI 输出以确保所有预期的模块或组件都被懒加载是很重要的。

在继续之前,确保解决任何测试错误。确保npm testnpm run e2e执行时没有错误。

通用测试模块

现在我们有很多模块要处理,为每个spec文件单独配置导入和提供者变得繁琐。为此,创建一个通用测试模块来包含一个通用的配置,你可以在整个项目中重用它。

首先,创建一个新的.ts文件:

  1. 创建common/common.testing.ts

  2. 用常见的测试提供者、模拟和模块填充它。

我提供了一个commonTestingModules数组:

**src/app/common/common.****testing****.****ts**
import {
  HttpClientTestingModule
} from '@angular/common/http/testing'
import { ReactiveFormsModule } from '@angular/forms'
import {
  NoopAnimationsModule
} from '@angular/platform-browser/animations'
import { RouterTestingModule } from '@angular/router/testing'
import {
  MatIconTestingModule
} from '@angular/material/icon/testing'
export const commonTestingProviders = [
  // Intentionally left blank! Used in later chapters.
]
export const commonTestingModules = [
  ReactiveFormsModule,
  NoopAnimationsModule,
  HttpClientTestingModule,
  RouterTestingModule,
  MatIconTestingModule,
] as unknown[] 

现在让我们看看这个共享配置文件的示例用法:

**src/app/app.****component****.****spec****.****ts**
...
describe('AppComponent', () => {
  beforeEach(waitForAsync(() => {
    TestBed.configureTestingModule({
      imports: [...commonTestingModules, AppComponent],
      providers: [],
    }).compileComponents()
  }))
... 

虽然commonTestingModules很方便,但随着你的应用程序增长,它将通过导入不必要的模块开始减慢测试运行。独立组件在很大程度上有助于缓解这个问题,因为它们会带来自己的导入。注意不要过度使用这个便利模块。

停!你确保了所有单元测试都通过了吗?为了确保你的测试总是通过,在 CircleCI 中实现一个 CI 管道,如第十章使用 CI/CD 发布到生产中所示。

当你的测试运行起来后,LemonMart 的行走骨架就完成了。现在,让我们展望未来,开始思考我们可能会处理哪些类型的数据实体。

围绕主要数据实体进行设计

路由优先架构的第四步是实现无状态、数据驱动的架构。为了实现这一点,围绕主要数据组件组织你的 API 非常有帮助。这大致符合你在 Angular 应用程序中消费数据的方式。我们将从定义我们的主要数据组件开始,创建一个粗略的数据实体关系图ERD)。在第五章设计身份验证和授权中,我们将回顾使用 Swagger.io 和 Express.js 进行 REST 以及 Apollo 进行 GraphQL 的用户数据实体的 API 设计和实现。

定义实体

让我们先看看你希望存储哪些类型的实体以及这些实体之间可能如何相互关联。

这里是 LemonMart 的一个示例设计,使用draw.io创建:

计算机截图,描述自动生成,中等置信度

图 4.17:LemonMart 的 ERD

目前,无论您的实体存储在 SQL 还是 NoSQL 数据库中,这并不重要。我的建议是坚持你所知道的,但如果你是从头开始的,NoSQL 数据库如 MongoDB 将提供最大的灵活性,因为你的实现和要求不断发展。

通常,您需要为每个实体提供 CRUD API。考虑到这些数据元素,我们还可以想象围绕这些 CRUD API 的用户界面。让我们接下来做这件事。

高级用户体验设计

模拟图对于确定整个应用中需要哪些组件和用户控件非常重要。任何将在组件间使用的用户控件或组件必须在根级别定义,其他控件必须在其自己的模块中定义。

在本章早期,我们确定了子模块并为他们设计了着陆页以完成行走骨架。现在我们已经定义了主要的数据组件,我们可以完成应用其余部分的模拟。在设计高级别屏幕时,请记住以下几点:

  • 用户能否以尽可能少的导航完成他们角色所需的一般任务?

  • 用户能否通过屏幕上的可见元素轻松访问应用的所有信息和功能?

  • 用户能否轻松搜索他们所需的数据?

  • 一旦用户找到感兴趣的记录,他们能否轻松地深入到详细记录或查看相关记录?

  • 那个弹出警告是否必要?你知道用户不会阅读它,对吧?

记住,没有一种正确的方式来设计任何用户体验,这就是为什么在设计屏幕时,你应该始终考虑模块化和可重用性。

创建一个工件 wiki

如本章前面所述,记录你创建的每个工件非常重要。Wiki 提供了一种创建可协作更新或编辑的活文档的方式。虽然 Slack、Teams、电子邮件和白板提供了良好的协作机会,但它们的短暂性质仍有待改进。

因此,当你生成各种设计工件,如模拟图或设计决策时,请确保将它们发布在所有团队成员都能访问的 wiki 上:

  1. 在 GitHub 上,切换到Wiki标签。

您可以查看我的示例 wiki,网址为github.com/duluca/lemon-mart/wiki,如下所示:

计算机截图,描述自动生成,中等置信度

图 4.18:GitHub.com LemonMart wiki

  1. 在创建 wiki 页面时,确保您与其他任何可用的文档交叉链接,例如Readme

  2. 注意,GitHub 在Pages下显示 wiki 的子页面。

  3. 然而,一个额外的总结是有帮助的,例如设计工件部分,因为有些人可能会错过右侧的导航元素。

  4. 随着你完成原型,将它们发布在维基百科上。

您可以在这里看到维基百科的摘要视图:

计算机的截图  描述由系统自动生成,中等置信度

图 4.19:LemonMart 原型的摘要视图

现在您的工件已集中在一个地方,所有团队成员都可以访问。他们可以添加、编辑、更新或整理内容。这样,您的维基百科就变成了您团队所需信息的实用、活生生的文档,而不是您感觉被迫创建的文档。如果你曾经发现自己处于那种情况,请举手!

接下来,将您的原型集成到您的应用中,以便您可以收集利益相关者的早期反馈并测试您应用程序的流程。

在您的应用中利用原型

将原型放置在可步行骨骼应用中,以便测试人员可以更好地设想尚未开发的功能。在这里查看这个想法的示例:

计算机的截图  描述由系统自动生成

图 4.20:在 UI 中使用原型来验证应用流程

这在设计和实现您的身份验证和授权工作流程时也会很有帮助。随着原型的完成,我们需要在第五章设计身份验证和授权中实现后端,然后我们才能继续在第六章实现基于角色的导航中实现 LemonMart 的身份验证和授权工作流程。

摘要

在本章中,您学会了如何有效地使用 Angular CLI 创建主要的 Angular 组件和脚手架。您创建了您应用的标志,利用自定义和内置的 Material 图标。

您学会了如何使用 Angular DevTools 调试复杂的路由配置。最后,您开始构建以路由器为优先的应用程序,早期定义用户角色,考虑到懒加载进行设计,并在早期确定可步行骨骼导航体验。我们讨论了围绕主要数据实体进行设计。我们还介绍了完成并记录整个应用的高级 UX 设计的重要性,以便我们可以正确设计出色的条件导航体验。

回顾一下,要实现以路由器为优先的实现方式,你需要做以下这些:

  1. 制定路线图和范围。

  2. 考虑到懒加载进行设计。

  3. 实现一个可步行骨骼导航体验。

  4. 实现无状态、数据驱动的架构。

  5. 强制实施解耦的组件架构。

  6. 区分用户控件和组件。

  7. 使用 TypeScript 和 ES6 最大化代码重用。

在本章中,你执行了步骤 1-3;在接下来的章节中,你将执行步骤 4-7。在第五章设计身份验证和授权中,你将看到使用最小 MEAN 栈的完整栈实现。在第六章基于角色的导航实现第七章与 REST 和 GraphQL API 一起工作中,我们将深入探讨面向对象设计、继承和抽象,以及深入考虑安全性和设计条件导航体验。第八章食谱 – 可重用性、表单和缓存第九章食谱 – 主/详细信息、数据表和 NgRx将通过坚持解耦组件架构,明智地选择创建用户控件和组件,以及通过使用各种 TypeScript、RxJS 和 Angular 编码技术最大化代码重用,将所有内容结合起来。

练习

到目前为止,我们还没有实现懒加载组件。作为一个挑战,按照angular.io/guide/standalone-components中的文档更新app.route.ts,以便PageNotFoundComponent可以懒加载。更新完成后,验证 CLI 输出是否正确显示了新的分块文件,并且打开 DevTools 的网络选项卡,以监视在导航应用程序时下载的分块。

进一步阅读

问题

尽可能地回答以下问题,以确保你已理解本章的关键概念,而无需进行任何谷歌搜索。你知道你是否答对了所有问题吗?访问angularforenterprise.com/self-assessment获取更多信息:

  1. 根模块和功能模块之间有什么区别?

  2. 懒加载有什么好处?

  3. 独立组件与模块有何不同?

  4. 为什么我们要创建应用程序的行走骨架?

  5. 围绕主要数据实体进行设计有什么好处?

  6. 为什么我们应该为我们的项目创建维基?

第五章:设计认证和授权

设计一个高质量且不会让最终用户感到沮丧的 认证授权 系统是一个难以解决的问题。认证是验证用户身份的行为,授权指定用户必须拥有的访问资源的权限。这两个过程,简称 auth,必须无缝协同工作,以满足具有不同角色、需求和职能的用户的需求。

在今天的网络中,用户对任何通过浏览器遇到的认证系统都有很高的基线期望,因此这是您应用程序中一个重要的部分,需要第一次就做对。用户应该始终知道他们可以在您的应用程序中做什么以及不能做什么。如果有错误、失败或错误,用户应该被告知它们发生的原因。随着您的应用程序的增长,很容易错过错误条件可能被触发的机制。您的实现应该易于扩展或维护。否则,您应用程序的基本骨架将需要大量的维护。在本章中,我们将探讨创建出色的认证用户体验和实现坚实基础体验的挑战。

在本章中,我们将围绕上一章定义的用户实体实现基于令牌的认证方案。为了实现健壮且易于维护的实现,我们将深入探讨面向对象编程(OOP),包括抽象、继承和工厂,同时实现一个缓存服务、一个 UI 服务和一个内存中的模拟认证服务,用于测试和教育目的。

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

  • 设计认证工作流程

  • TypeScript 的安全数据处理运算符

  • 实现数据实体

  • 利用面向对象编程(OOP)概念的可重用服务

  • 创建认证服务

  • 使用 localStorage 的缓存服务

  • 一个内存中的认证服务

  • 登出

  • 一个 HTTP 拦截器

技术要求

书籍的示例代码的最新版本可以在以下链接的 GitHub 仓库中找到。github.com/duluca/lemon-mart。该仓库包含代码的最终和完成状态。您可以在本章末尾通过查找 projects 文件夹下的章节末尾代码快照来验证您的进度。

对于 第五章

  1. 克隆 github.com/duluca/lemon-mart 仓库。

  2. 在根目录下执行 npm install 以安装依赖项。

  3. 项目的初始状态反映在:

    projects/stage7 
    
  4. 项目的最终状态反映在:

    projects/stage8 
    
  5. 将阶段名称添加到任何 ng 命令中,使其仅对该阶段生效:

    npx ng build stage8 
    

    注意,存储库根目录下的 dist/stage8 文件夹将包含编译结果。

请注意,书中提供的源代码和 GitHub 上的版本可能不同。围绕这些项目的生态系统一直在不断演变。由于 Angular CLI 生成新代码的方式的变化、错误修复、库的新版本以及多种技术的并行实现,存在许多难以预料的差异。如果您发现错误或有疑问,请创建一个 GitHub 上的问题或提交一个 pull request。

让我们先了解一下基于令牌的认证工作流程是如何工作的。

设计认证工作流程

一个精心设计的认证工作流程是无状态的,因此没有会话过期的概念。用户可以从他们想要的任何设备和标签页中与您的无状态 REST API 交互,同时或分时进行。JSON Web Token(JWT)实现了基于声明的分布式认证,可以使用消息认证码(MAC)进行数字签名或信息保护以及/或加密。这意味着一旦用户的身份得到验证(即在登录表单上的密码挑战),他们就会收到一个编码的声明的票据或令牌,然后可以使用它来向系统发出未来的请求,而无需重新验证用户的身份。

服务器可以独立验证这个声明的有效性,并处理请求,而无需事先知道是否与该用户交互过。因此,我们不需要存储有关用户的会话信息,这使得我们的解决方案是无状态的,易于扩展。每个令牌在预定义的期限后会过期,由于它们的分布式特性,它们不能远程或单独撤销;然而,我们可以通过插入自定义账户和用户角色状态检查来增强实时安全性,以确保认证用户有权访问服务器端资源。

JWT 实现了位于tools.ietf.org/html/rfc7519互联网工程任务组(IETF)行业标准 RFC 7519。

一个好的授权工作流程允许根据用户的角色进行条件导航,这样用户就会被自动带到最佳的着陆页面;不适合他们角色的路由和 UI 元素不应显示,如果他们不小心尝试访问受限路径,应阻止他们这样做。您必须记住,任何客户端基于角色的导航仅仅是一种便利,并不用于安全。

这意味着每个发送到服务器的调用都应该包含必要的头信息,包括安全的令牌,以便服务器可以重新认证用户并独立验证其角色。只有在这种情况下,他们才能被允许检索受保护的数据。由于客户端认证的本质,它不能被信任。所有认证逻辑都必须在服务器端实现。安全地实现密码重置屏幕可能特别具有挑战性,因为它们可以在您的 Web 应用程序内部触发或通过嵌入到电子邮件/通知中的链接触发。当交互模式增加时,攻击面也随之增长。因此,我建议使用服务器端渲染来构建重置屏幕,以便用户和服务器都可以验证预期的用户正在与系统交互。如果您在客户端实现此功能,您必须确保服务器生成一个时间有限的、一次性的令牌,以便与新的密码一起传递,这样您可以合理地确信请求是合法的。接下来,让我们深入了解如何生成安全的令牌。

JWT 生命周期

JWTs 补充了无状态 REST API 架构,通过加密令牌机制,使得客户端请求的认证和授权变得方便、分布式且高性能。基于令牌的认证方案有三个主要组成部分:

  • 客户端:捕获登录信息并隐藏不允许的操作,以提供良好的用户体验。

  • 服务器端:验证每个请求是否已认证并且具有适当的授权。

  • 认证服务:生成和验证加密令牌,并独立验证用户请求的认证状态,这些请求来自数据存储。

一个安全的系统假定客户端(应用程序和浏览器)、系统(服务器和服务)以及数据库之间发送/接收的数据都使用 传输层安全性TLS)进行加密,这本质上是一个 安全套接字层SSL)的新版本。您的 REST API 必须使用正确配置的 SSL 证书托管,通过 HTTPS 提供所有 API 调用,以确保用户凭证在客户端和服务器之间不会被暴露。同样,任何数据库或第三方服务调用也应通过 TLS 进行。这确保了传输中数据的安全性。

在静止状态(数据存储在数据库中时),应使用安全的单向哈希算法和良好的盐值实践来存储密码。

所有的哈希和盐值讨论让你想起了早餐吗?不幸的是,它们是密码学相关的术语。如果你想了解更多,可以查看这篇文章:crackstation.net/hashing-security.htm

对于敏感用户信息,如 个人身份信息PII),应使用安全的双向加密算法在静止状态下加密,与密码不同。密码是经过散列的,因此我们验证用户提供的密码是否与系统所知的密码相同。对于 PII,我们必须能够解密数据以将其显示给用户。然而,由于数据在静止状态下加密,如果数据库被破坏,那么被黑客窃取的数据将毫无价值。

采取分层的安全方法至关重要,因为攻击者需要完成同时破坏你安全所有层的不太可能的事情,以对你的业务造成实质性伤害。

有趣的事实:当你听到来自大型公司的重大数据泄露事件时,其根本原因往往是缺乏对传输中或静止状态安全性的适当实施。有时,这是因为持续加密/解密数据计算成本过高,因此工程师依赖于防火墙的保护。在这种情况下,一旦外围被突破,正如他们所说,狐狸就进入了鸡舍。

考虑以下序列图,它突出了基于 JWT 的认证生命周期:

图片

图 5.1:基于 JWT 的认证生命周期

初始时,用户通过提供用户名和密码进行登录。一旦验证通过,用户的认证状态和角色将被加密在一个带有过期日期和时间的 JWT 中,并将其发送回浏览器。

我们的应用程序(Angular 或其他)可以安全地将此令牌缓存到本地或会话存储中,这样用户就不必在每次请求时都强制登录。这样,我们就不必采取像在 cookies 中存储用户凭据这样的不安全做法,以提供良好的用户体验。

我们的技术审稿人 Jurgen Van de Moere 指出,cookies 并不一定是不可靠的。

请参阅 www.youtube.com/watch?v=9ZOpUtQ_4Uk 由 Philippe De Ryck 撰写的视频,解释在特定情况下 cookies 可以是一个有效的机制来存储 JWT 令牌。

当你在本章后面实现自己的认证服务时,你会更好地理解 JWT 生命周期。在接下来的几节中,我们将围绕 用户 数据实体设计一个功能齐全的认证工作流程,如下所示:

图片

图 5.2:用户实体

描述的 用户 实体与我们最初的实体模型略有不同。实体模型反映了数据在数据库中的存储方式。

实体是用户记录的扁平化(或简化)表示。即使是一个扁平化的实体也包含复杂对象,如 姓名,具有首字母、中间名和姓氏等属性。此外,并非所有属性都是必需的。此外,在与认证系统和其他 API 交互时,我们可能会收到不完整、错误或恶意构造的数据,因此我们的代码必须有效地处理 nullundefined 变量。

接下来,让我们看看如何利用 TypeScript 运算符有效地处理意外数据。

TypeScript 的安全数据处理运算符

JavaScript 是一种动态类型语言。在运行时,执行我们代码的 JavaScript 引擎,如 Chrome 的 V8,不知道我们使用的变量的类型。因此,引擎必须推断类型。我们可以有基本类型,如 booleannumberarraystring,或者我们可以有复杂类型,这本质上是一个 JSON 对象。此外,变量可以是 nullundefined。从广义上讲,undefined 表示尚未声明或初始化的某物,而 null 表示已声明变量值的故意缺失。

在强类型语言中,undefined 的概念不存在。基本类型有默认值,如 number 是零或 string 是空字符串。然而,复杂类型可以是 nullnull 引用意味着变量已定义,但后面没有值。

null 引用的发明者 Tony Hoare 称其为他的“十亿美元的错误”。

TypeScript 将强类型语言的观念引入 JavaScript,因此它必须在两个世界之间架起桥梁。因此,TypeScript 定义了 nullundefinedanynever 等类型,以理解 JavaScript 的类型语义。我在 进一步阅读 部分添加了相关 TypeScript 文档的链接,以便更深入地了解 TypeScript 类型。

如 TypeScript 文档所述,TypeScript 将 nullundefined 区分开来,以匹配 JavaScript 的语义。例如,联合类型 string | nullstring | undefinedstring | undefined | null 是不同的类型。

另一个细微差别是:使用 ===== 来检查一个值是否等于 null。使用双等号运算符,检查 foo != null 表示 foo 已定义且不是 null。然而,使用三等号运算符,foo !== null 表示 foo 不是 null 但可能是 undefined。然而,这两个运算符并没有考虑变量的真值,这包括空字符串的情况。

这些细微差别对编写代码的方式有很大影响,尤其是在使用 --strict 选项创建 Angular 应用程序时应用严格的 TypeScript 规则。重要的是要记住,TypeScript 是一个编译时工具,而不是运行时工具。在运行时,我们仍在处理动态类型语言的现实。仅仅因为我们声明了一个类型是字符串,并不意味着我们会收到一个字符串。

接下来,让我们看看如何处理与处理意外值相关的问题。

nullundefined 检查

当与其他库一起工作或处理来自应用程序外部发送或接收的信息时,你必须处理接收到的变量可能是 nullundefined 的现实。

在您的应用程序之外意味着处理用户输入,从 cookie 或 localStorage 读取,从路由器获取 URL 参数,或通过 HTTP 进行 API 调用,仅举几个例子。

在我们的代码中,我们主要关注变量的真值。这意味着变量已被定义,不为空,如果它是一个基本类型,它具有非默认值。给定一个 string,我们可以通过简单的 if 语句来检查 string 是否为真值:

**example**
const foo: string = undefined
if(foo) {
  console.log('truthy')
} else {
  console.log('falsy')
} 

如果 foonullundefined 或一个空字符串,变量将被视为假值。对于某些情况,我们可以使用条件或三元运算符而不是 if-else

条件或三元运算符

条件或三元运算符具有 ?: 语法。在问号的左侧,运算符接受一个条件表达式。在冒号的右侧,我们提供真值和假值的输出:conditional ? true-outcome : false-outcome。条件或三元运算符是表示 if-else 条件的紧凑方式,并且可以非常有助于提高代码库的可读性。这个运算符不是 if-else 块的替代品,但在使用 if-else 条件的输出时非常有用。

考虑以下示例:

**example**
const foo: string = undefined
let result = ''
if(foo) {
  result = 'truthy'
} else {
  result = 'falsy'
}
console.log(result) 

可以将前面的 if-else 块重写为:

**example**
const foo: string = undefined
console.log(foo ? 'truthy' : 'falsy') 

在这种情况下,条件或三元运算符使代码更加紧凑且易于理解。另一个常见场景是返回一个默认值,其中变量为假值。

接下来,我们考虑空合并运算符。

空合并运算符

空合并运算符是 ||。这个运算符在条件表达式的真值与条件表达式本身相同时,可以避免重复。

考虑以下示例,如果 foo 被定义,我们希望使用 foo 的值,但如果它是 undefined,我们需要一个默认值 'bar'

**example**
const foo: string = undefined
console.log(foo ? foo : 'bar') 

如您所见,foo 被重复了两次。我们可以通过使用空合并运算符来避免重复:

**example**
const foo: string = undefined
console.log(foo || 'bar') 

因此,如果 fooundefinednull 或一个空字符串,将输出 bar。否则,将使用 foo 的值。但在某些情况下,我们只需要在值是 undefinednull 时使用默认值。

让我们来看看空合并运算符。

空合并运算符

空合并运算符是 ??。这个运算符与空合并运算符类似,但有一个关键的区别。当处理来自 API 或用户输入的数据时,检查变量的真值可能不足以确定一个空字符串是否为有效值。正如我们在本节前面所讨论的,检查 nullundefined 并不像看起来那么简单。然而,我们知道通过使用双等号运算符,我们可以确保 foo 被定义且不为空:

**example**
const foo: string = undefined
console.log(foo != null ? foo : 'bar') 

在前面的例子中,如果foo是一个空字符串或另一个值,我们将得到foo输出的值。如果是nullundefined,我们将得到'bar'。通过使用空值合并运算符,我们可以以更紧凑的方式完成这项工作:

**example**
const foo: string = undefined
console.log(foo ?? 'bar') 

前面的代码将产生与上一个例子相同的结果。然而,当处理复杂对象时,我们需要考虑它们的属性是否是nullundefined。为此,我们将考虑使用可选链运算符。

可选链

可选链运算符是?。它类似于 Angular 的安全导航运算符。可选链确保在尝试访问子属性或调用函数之前,变量或属性已被定义且不是null。因此,foo?.bar?.callMe()这个语句在没有抛出错误的情况下执行,即使foobarnullundefined

考虑一下user实体,它有一个name对象,包含firstmiddlelast属性。让我们看看如何使用空值合并运算符安全地为中间名提供一个空字符串的默认值:

**example**
const user = {
  name: {
    first: 'Doguhan',
    middle: null,
    last: 'Uluca'
  } 
}
console.log((user && user.name && user.name.middle) ?? '') 

如你所见,在访问子属性之前,我们需要检查父对象是否是truthy。如果middlenull,则输出一个空字符串。可选链使这项任务变得更简单:

**example**
console.log(user?.name?.middle ?? '') 

通过结合使用可选链和空值合并运算符,我们可以消除重复,并交付出健壮的代码,能够有效地处理 JavaScript 动态运行时的现实。

因此,在设计你的代码时,你必须决定是否在你的逻辑中引入 null 的概念,或者使用像空字符串这样的默认值。在下一节中,当我们实现用户实体时,你将看到这些选择是如何发挥作用的。到目前为止,我们只使用了接口来定义我们数据的形式。接下来,让我们构建用户实体,利用面向对象编程的概念,如类、枚举和抽象来实现它,以及一个认证服务。

让我们从简单开始,看看这些模式如何在 JavaScript 类和 TypeScript 基础知识的环境中实现。

实现数据实体和接口

在本节中,我将演示你如何在你的代码设计中使用类来定义和封装你的模型的行为,例如User类。在本章的后面部分,你将看到使用抽象基类的类继承的例子,这允许我们标准化我们的实现,并以干净、易于维护的方式重用基本功能。

我必须指出,面向对象编程(OOP)具有非常实用的模式,这些模式可以提高你代码的质量;然而,如果你过度使用它,那么你将开始失去 JavaScript 动态、灵活和功能性的好处。

有时候,你只需要一个文件中的几个函数,你会在整本书中看到这样的例子。

展示类价值的一个好方法就是标准化创建默认User对象的过程。我们需要这样做,因为BehaviorSubject对象需要用默认对象初始化。最好在一个地方完成这个操作,而不是在多个地方复制粘贴相同的实现。让User对象拥有这个功能而不是由 Angular 服务创建默认User对象是非常有意义的。所以,让我们实现一个User类来实现这个目标。

类、接口和枚举

如前所述,我们只使用接口来表示数据。我们仍然希望在传递数据到各个组件和服务时继续使用接口。接口非常适合描述实现具有哪些属性或函数,但它们对这些属性或函数的行为没有任何暗示。

在 ES2015(ES6)中,JavaScript 获得了对类的原生支持,这是面向对象编程范式的一个关键概念。类是行为的实际实现。与文件中只包含函数集合相比,类可以正确地封装行为。然后可以使用 new 关键字将类实例化为对象。

TypeScript 采用了 ES2015(及以后)的类实现,并引入了必要的概念,如抽象类、私有、受保护和公共属性,以及接口,以便能够实现面向对象编程模式。

我们将首先定义所需数据实体的枚举和接口,利用 TypeScript 的两大最佳特性。

接口帮助我们实践 SOLID 设计原则中的依赖倒置原则:依赖于抽象,而不是具体实现。这意味着在组件或服务之间,传递对象的接口(一个实例化的类)而不是对象本身会更好。这就是为什么我们定义的每个类都将实现一个接口。此外,接口通常是你在新项目中开始编码的第一件事,使用它们来实现你的原型和 API 集成。

枚举有助于确保另一个重要规则:永远不要使用字符串字面量。枚举功能强大且出色。

让我们直接进入并定义所需的接口和枚举:

  1. src/app/auth/auth.enum.ts位置定义用户角色为enum

    **src/app/auth/auth.****enum****.****ts**
    export enum Role {
      None = 'none',
      Clerk = 'clerk',
      Cashier = 'cashier',
      Manager = 'manager',
    } 
    
  2. src/app/user/user文件夹下创建一个user.ts文件。

  3. user.ts文件中定义一个名为IUser的新接口:

    **src/app/user/user/user.****ts**
    import { Role } from '../../auth/auth.enum'
    export interface IUser {
      _id: string
      email: string
      name: IName
      picture: string
      role: Role | string
      userStatus: boolean
      dateOfBirth: Date | null | string
      level: number
      address: {
        line1: string
        line2?: string
        city: string
        state: string
        zip: string
      }
      phones: IPhone[]
    } 
    

    注意,接口上定义的每个复杂属性也可以表示为string。在传输过程中,所有对象都使用JSON.stringify()转换为字符串。不包含任何类型信息。我们还利用接口在内存中表示Class对象,这些对象可以具有复杂类型。因此,我们的接口属性必须使用联合类型反映这两种情况。例如,role可以是Role类型或string。同样,dateOfBirth可以是Datestring

    我们将address定义为内联类型,因为我们在这个类之外不使用地址的概念。相比之下,我们将IName定义为其自己的接口,因为在第八章“食谱 - 可重用性、表单和缓存”中,我们将实现一个单独的组件来处理名称。我们还定义了一个单独的接口来处理电话,因为它们被表示为数组。在开发表单时,我们需要能够在模板代码中引用数组的单个元素,例如IPhone

    通常,在接口名称前加上大写的I,以便于识别。不用担心;在 Android 手机上使用IPhone接口没有兼容性问题!

  4. user.ts中定义INameIPhone接口,并实现PhoneType枚举:

    **src/app/user/user/user.****ts**
    export interface IName {
      first: string
      middle?: string
      last: string
    }
    export enum PhoneType {
      None = 'none',
      Mobile = 'mobile',
      Home = 'home',
      Work = 'work',
    }
    export interface IPhone {
      type: PhoneType
      digits: string
      id: number
    } 
    

    注意,在PhoneType枚举中,我们明确地定义了string值。默认情况下,enum值在键入时会转换为字符串,这可能导致数据库中存储的值与开发者选择拼写变量名的方式不同步,从而导致问题。通过明确和全部小写的值,我们降低了出现错误的风险。

  5. 接下来,定义实现IUser接口的User类:

    **src/app/user/user/user.****ts**
    export class User implements IUser {
      constructor(
        // tslint:disable-next-line: variable-name
        public _id = '',
        public email = '',
        public name = { first: '', middle: '', last: '' } as IName,
        public picture = '',
        public role = Role.None,
        public dateOfBirth: Date | null = null,
        public userStatus = false,
        public level = 0,
        public address = {
          line1: '',
          city: '',
          state: '',
          zip: '',
        },
        public phones: IPhone[] = []
      ) {}
      static Build(user: IUser) {
        if (!user) {
          return new User()
        }
        return new User(
          user._id,
          user.email,
          user.name,
          user.picture,
          user.role as Role,
          typeof user.dateOfBirth === 'string'
            ? new Date(user.dateOfBirth)
            : user.dateOfBirth, 
          user.userStatus,
          user.level,
          user.address,
          user.phones
        )
      }
    } 
    

    注意,通过在构造函数中将所有属性定义为public属性并赋予默认值,我们一举两得;否则,我们需要分别定义属性并单独初始化它们。这样,我们实现了简洁的实现。

    使用静态的Build函数,我们可以快速用从服务器接收到的数据填充对象。我们还可以实现toJSON()函数来定制对象在发送到服务器前的序列化行为。但在那之前,让我们添加一个计算属性。

    我们可以在模板或通知消息中使用计算属性方便地显示由多个部分组成的值。一个很好的例子是从name对象中提取全名作为User类中的一个属性。

    用于组装全名的计算属性封装了组合首名、中名和姓氏的逻辑,这样你就不必在多个地方重写这个逻辑,遵循 DRY 原则!

  6. User类中实现fullName属性的 getter:

    **src/app/user/user/user.****ts**
    export class User implements IUser {
      ...
      **public****get****fullName****():** **string** **{**
        **if** **(!****this****.****name****) {**
          **return****''**
        **}**
        **if** **(****this****.****name****.****middle****) {**
          **return**
            **`****${****this****.name.first}****${****this****.name.middle}****${****this****.name.last}****`**
        **}**
        **return****`****${****this****.name.first}****${****this****.name.last}****`**
      **}**
    } 
    
  7. fullName添加到IUser中作为一个可选的readonly属性:

    **src/app/user/user/user.****ts**
    export interface IUser {
      ...
      readonly fullName?: string
    } 
    

    你现在可以通过IUser接口使用fullName属性。

  8. 实现序列化函数:

    **src/app/user/user/user.****ts**
    export class User implements IUser {
      ...
    
    **toJSON****():** **object** **{**
        **const** **serialized =** **Object****.****assign****(****this****)**
        **delete** **serialized.****_id**
        **delete** **serialized.****fullName**
        **return** **serialized**
      **}**
    } 
    

注意,在序列化对象时,我们删除了_idfullName字段。这些是我们不希望存储在数据库中的值。fullName字段是一个计算属性,因此不需要存储。_id通常在GETPUT调用中作为参数传递,以定位记录。这避免了可能导致的错误,这些错误可能会导致覆盖现有对象的id字段。

现在我们已经实现了User data实体,接下来让我们实现认证服务。

利用面向对象概念的可重用服务

与 RxJS 所支持的响应式编程风格相比,OOP(面向对象编程)是一种命令式编程风格。类是 OOP 的基础,而使用 RxJS 的观察者(observables)在响应式编程中扮演着同样的角色。

我鼓励你熟悉 OOP 术语。请参阅进一步阅读部分,了解一些有用的资源。你应该熟悉:

  • 类与对象

  • 组合(接口)

  • 封装(私有、受保护、公共属性,以及属性获取器和设置器)

  • 多态(继承、抽象类和方法重写)

如你所知,Angular 使用 OOP 模式来实现组件和服务。例如,接口实现了生命周期钩子,如OnInit。我们的目标是设计一个灵活的认证服务,它可以实现多个认证提供者。在第六章实现基于角色的导航中,我们将实现一个内存提供者和一个 Google Firebase 提供者。在第七章与 REST 和 GraphQL API 交互中,我们将实现两个自定义提供者以与我们的后端交互,并了解基于角色的访问控制RBAC)是如何实现的。

通过声明一个抽象基类,我们可以描述我们应用程序的常见登录和注销行为,因此当我们实现另一个认证提供者时,我们不需要重新设计我们的应用程序。

此外,我们可以声明抽象函数,我们的基类的实现者必须实现这些函数,以强制我们的设计。任何实现基类的类都将获得基类中实现代码的好处,因此我们不需要在两个不同的地方重复相同的逻辑。

以下类图反映了我们抽象的AuthService的架构和继承层次结构:

图片

图 5.3:AuthService 继承结构

AuthService实现了IAuthService接口,如下所示:

export interface IAuthService {
  readonly authStatus$: BehaviorSubject<IAuthStatus>
  readonly currentUser$: BehaviorSubject<IUser>
  login(email: string, password: string): Observable<void>
  logout(clearToken?: boolean): void
  getToken(): string
} 

接口反映了服务公开的属性。服务提供认证状态作为authStatus$观察者,当前用户作为currentUser$,并提供三个函数,loginlogoutgetToken

AuthService需要从另一个名为CacheService的服务中获取缓存功能。我们不是通过继承来整合缓存功能,而是将其注入到基类中。由于AuthService是一个抽象类,它不能独立使用,因此我们将实现三个认证提供者,即图示下方的InMemoryAuthServiceFirebaseAuthServiceCustomAuthService

组合优于继承,因此你必须确保你正确地使用了继承。继承描述了一个 is-a 关系,而组合描述了一个 has-a 关系。在这种情况下,我们使用了正确的继承和组合的混合,因为FirebaseAuthServiceAuthService,而AuthService有一个CacheService

注意,所有三个认证服务都实现了所有抽象函数。此外,FirebaseAuthService 覆盖了基类的 logout 函数以实现其自己的行为。所有三个类都继承自同一个抽象类并公开相同的公共接口。所有三个都将执行相同的认证工作流程,针对不同的认证服务器。

内存中的认证服务不与服务器通信。此服务仅用于演示目的。它实现了假的 JWT 编码,因此我们可以演示 JWT 生命周期的工作方式。

让我们从创建认证服务开始。

创建一个认证服务

我们将首先创建抽象的认证服务和内存中的服务:

  1. 添加一个认证服务:

    $ npx ng g s auth --flat false
    $ npx ng g s auth/inMemoryAuth --skip-tests 
    
  2. in-memory-auth.service.ts 重命名为 auth.in-memory.service.ts,以便在文件资源管理器中将不同的认证提供者视觉上分组在一起。

  3. 移除 auth.service.ts 中的 @Injectable() 装饰器,但保留在 auth.in-memory.service.ts 上。

  4. 确保在 app.module.ts 中提供了 authService,并且使用 InMemoryAuthService 而不是抽象类:

    **src/app/app.****module****.****ts**
    **import** **{** **AuthService** **}** **from****'./auth/auth.service'**
    **import** **{** **InMemoryAuthService** **}** **from****'./auth/auth.in-memory.service'**
    ...
      providers: [
        **{**
          **provide****:** **AuthService****,**
          **useClass****:** **InMemoryAuthService**
        **},**
        ...
    ] 
    

为服务创建一个单独的文件夹,可以组织与认证相关的各种组件,例如用户角色的 enum 定义。此外,我们还将能够将 authService 模拟器添加到同一个文件夹中,以进行自动化测试。

实现一个抽象的认证服务

现在,让我们构建一个抽象的认证服务,该服务将协调登录和注销,同时封装管理 JWT、认证状态和有关当前用户的信息的逻辑。通过利用抽象类,我们应该能够针对任何认证提供者实现自己的认证服务,而无需修改应用程序的内部行为。

我们将要演示的抽象认证服务可以实现丰富和复杂的流程。这是一个可以无缝集成到您的应用程序中的解决方案,无需修改内部逻辑。因此,它是一个复杂的解决方案。

此认证服务将使我们能够演示使用电子邮件和密码进行登录、缓存以及基于认证状态和用户角色的条件导航概念:

  1. 首先安装一个 JWT 解码库,以及为了模拟认证的 JWT 编码库:

    $ npm install jwt-decode 
    $ npm install -D @types/jwt-decode 
    
  2. 实现一个 IAuthStatus 接口以存储解码后的用户信息,一个辅助接口,以及默认安全的 defaultAuthStatus

    **src/app/auth/auth.****service****.****ts**
    import { Role } from './auth.enum'
    ...
    export interface IAuthStatus {
      isAuthenticated: boolean
      userRole: Role
      userId: string
    }
    export interface IServerAuthResponse {
      accessToken: string
    }
    export const defaultAuthStatus: IAuthStatus = {
      isAuthenticated: false,
      userRole: Role.None,
      userId: '',
    }
    ... 
    

    IAuthStatus 是一个接口,它代表了从认证服务接收到的典型 JWT 的结构。它包含有关用户及其角色的最小信息。认证状态对象可以附加到每个 API 调用的头部,以验证用户的身份。认证状态可以可选地缓存在 localStorage 中以记住用户的登录状态;否则,他们每次刷新页面时都需要重新输入密码。

    在前面的实现中,我们假设默认角色为 None,如 Role 枚举中定义。通过默认不给用户分配任何角色,我们遵循最小权限访问模型。用户正确的角色将在他们使用从 auth API 收到的信息成功登录后设置。

  3. auth.service.ts 中定义 IAuthService 接口:

    **src/app/auth/auth.service.ts**
    export interface IAuthService {
      readonly authStatus$: BehaviorSubject<IAuthStatus>
      readonly currentUser$: BehaviorSubject<IUser>
      login(email: string, password: string): Observable<void>
      logout(clearToken?: boolean): void
      getToken(): string
    } 
    
  4. AuthService 实现为 abstract 类,如下所示:

    export abstract class AuthService 
    
  5. 使用 VS Code 的快速修复功能实现接口 IAuthService

    **src/app/auth/auth.****service****.****ts**
    export abstract class AuthService **implements****IAuthService** {
      authStatus$: BehaviorSubject<IAuthStatus>
      currentUser$: BehaviorSubject<IUser>
    
      **constructor****() {}**
    
      login(email: string, password: string): Observable<void> {
        throw new Error('Method not implemented.')
      }
      logout(clearToken?: boolean): void {
        throw new Error('Method not implemented.')
      }
      getToken(): string {
        throw new Error('Method not implemented.')
      }
    } 
    
  6. authStatus$currentUser$ 属性实现为 readonly 并用它们的默认值初始化我们的数据锚点:

    **src/app/auth/auth.****service****.****ts**
    import { IUser, **User** } from '../user/user/user'
    ...
    export abstract class AuthService implements IAuthService {
      **readonly** authStatus$ = 
        **new****BehaviorSubject****<****IAuthStatus****>(defaultAuthStatus)** 
      **readonly** currentUser$ = 
        **new****BehaviorSubject****<****IUser****>(****new****User****())**
      ...
    } 
    

注意,我们移除了属性的类型定义。相反,我们让 TypeScript 从初始化中推断类型。

你必须始终将你的数据锚点声明为 readonly,这样你就不会意外地通过将数据锚点重新初始化为新的 BehaviorSubject 来覆盖数据流,这样做会使任何先前的订阅者成为孤儿,导致内存泄漏,这会有许多意想不到的后果。

所有实现 IAuthService 的实现者必须能够登录用户,转换从服务器返回的令牌,以便我们可以读取和存储它,支持访问当前用户和认证状态,并提供一种注销用户的方式。我们已经成功添加了公共方法的函数,并为我们的数据锚点实现了默认值,为我们的应用程序的其他部分创建了钩子。但到目前为止,我们只定义了我们的服务可以做什么,而没有定义它是如何做到的。

总是细节决定成败,难点在于“如何”。抽象函数可以帮助我们在应用程序的服务中完成工作流程的实现,同时将必须实现外部 API 的服务部分留空。

抽象函数

实现抽象类的认证服务应该能够支持任何类型的认证提供者和任何类型的令牌转换,同时能够修改行为,如用户检索逻辑。我们必须能够实现登录、注销、令牌和认证状态管理,而不需要实现对特定服务的调用。

通过定义抽象函数,我们可以声明一系列必须实现一组给定输入和输出的方法——一个没有实现的签名。然后我们可以使用这些抽象函数来编排我们的认证工作流程的实现。

开放/封闭原则推动了我们的设计目标。AuthService 将通过其能够扩展以与任何基于令牌的认证提供者一起工作的能力而开放,但它对修改是封闭的。一旦我们完成了 AuthService 的实现,我们就不会需要修改其代码来添加额外的认证提供者。

现在,我们需要定义我们的认证提供者必须实现的抽象函数,如本章前面 图 5.3 所示:

  • authProvider(email, password): Observable<IServerAuthResponse> 可以通过提供者登录并返回标准化的 IServerAuthResponse

  • transformJwtToken(token): IAuthStatus 可以将提供者返回的令牌标准化为 IAuthStatus 接口

  • getCurrentUser(): Observable<User> 可以检索已登录用户的用户资料

然后,我们可以在 loginlogoutgetToken 方法中使用这些函数来实现身份验证工作流程:

  1. 将派生类应该实现的抽象方法定义为受保护的属性,以便在派生类中可访问,但不是公开的:

    **src/app/auth/auth.****service****.****ts**
    ...
    **export****abstract****class****AuthService****implements****IAuthService** **{**
         **protected****abstract****authProvider****(**
           **email****:** **string****,**
           **password****:** **string**
         **):** **Observable****<****IServerAuthResponse****>**
         **protected****abstract****transformJwtToken****(****token****:** **unknown****):**
           **IAuthStatus**
         **protected****abstract****getCurrentUser****():** **Observable****<****User****>**
         ...
    } 
    

    利用这些模拟的方法,我们现在可以实现一个登录方法来登录用户并检索当前登录用户,更新 authStatus$currentUser$ 数据流。

  2. 在我们继续之前,实现一个 transformError 函数来处理不同类型的错误,如 HttpErrorResponsestring,并将它们提供在可观察的流中。在 src/app/common 下的新文件 common.ts 中创建 transformError 函数:

    **src/app/common/common.****ts**
    import { HttpErrorResponse } from '@angular/common/http'
    import { throwError } from 'rxjs'
    export function transformError(error: HttpErrorResponse | string) {
      let errorMessage = 'An unknown error has occurred'
      if (typeof error === 'string') {
        errorMessage = error
      } else if (error.error instanceof ErrorEvent) {
        errorMessage = `Error! ${error.error.message}`
      } else if (error.status) {
        errorMessage = 
          `Request failed with ${error.status} ${error.statusText}`
      } else if (error instanceof Error) {
        errorMessage = error.message
      }
      return throwError(errorMessage)
    } 
    
  3. auth.service.ts 中实现 login 方法:

    **src/app/auth/auth.****service****.****ts**
    import * **as** decode from 'jwt-decode'
    import { transformError } from '../common/common'
    ...
      login(email: string, password: string): Observable<void> {
        const loginResponse$ = this.authProvider(email, password)
          .pipe(
            map((value) => {
              const token = decode(value.accessToken)
              return this.transformJwtToken(token)
            }),
            tap((status) => this.authStatus$.next(status)),
            filter((status: IAuthStatus) => status.isAuthenticated),
            flatMap(() => this.getCurrentUser()),
            map(user => this.currentUser$.next(user)),
            catchError(transformError)
          )
        loginResponse$.subscribe({
          error: err => {
            this.logout()
            return throwError(err)
          },
        })
        return loginResponse$
      } 
    

    login 方法通过调用 authProvider 并传入 emailpassword 信息来封装正确的操作顺序,然后解码接收到的 JWT,进行转换,并更新 authStatus$。接着,只有当 status.isAuthenticatedtrue 时,才会调用 getCurrentUser()。之后,更新 currentUser$,最后,我们使用自定义的 transformError 函数来捕获任何错误。

    我们通过在它上面调用 subscribe 来激活可观察的流。在出现错误的情况下,我们调用 logout() 以保持应用程序的正确状态,并通过使用 throwError 重新抛出错误,将错误冒泡到 login 的消费者。

    现在,需要实现相应的 logout 函数。在登录尝试失败的情况下,或者在检测到未经授权的访问尝试时,都会触发退出。我们可以通过使用路由器身份验证守卫来检测未经授权的访问尝试,当用户在应用程序中导航时,这是本章后面将要讨论的一个主题。

  4. 实现退出方法:

    **src/app/auth/auth.****service****.****ts**
      ...
      logout(clearToken?: boolean): void {
        setTimeout(() => this.authStatus$.next(defaultAuthStatus), 0)
      } 
    

我们通过在 authStatus$ 流中推送 defaultAuthStatus 作为下一个值来注销。注意 setTimeout 的使用,它允许我们在应用程序的核心元素同时更改状态时避免时序问题。

考虑一下 login 方法是如何遵循开放/封闭原则的。该方法通过抽象函数 authProvidertransformJwtTokengetCurrentUser 对扩展开放。通过在派生类中实现这些函数,我们可以外部提供不同的身份验证提供者,而不需要修改 login 方法。因此,方法的实现保持对修改的封闭,从而遵循开放/封闭原则。

创建抽象类真正的价值在于能够以可扩展的方式封装通用功能。

目前您可以忽略getToken函数,因为我们还没有缓存我们的 JWT。没有缓存,用户每次刷新页面时都需要登录。让我们接下来实现缓存。

使用 localStorage 的缓存服务

我们必须能够缓存已登录用户的认证状态。如前所述,否则,每次刷新页面时,用户都必须通过登录流程。我们需要更新AuthService以持久化认证状态。

存储数据主要有三种方式:

  • cookie

  • localStorage

  • sessionStorage

虽然 cookies 有其用例,但不应该用来存储安全数据,因为它们可以被恶意行为者嗅探或窃取。此外,cookies 只能存储 4KB 的数据,并且可以设置过期时间。

localStoragesessionStorage相似。它们是受保护和隔离的浏览器端存储,允许为您的应用程序存储更多的数据。与 cookies 不同,您不能为存储在任一存储中的值设置过期日期和时间。存储在任一存储中的值在页面重新加载和恢复时仍然存在,这使得它们比 cookies 更适合缓存信息。

localStoragesessionStorage之间的主要区别在于值如何在浏览器标签页之间持久化。使用sessionStorage,存储的值在浏览器标签页或窗口关闭时被删除。然而,localStorage在重启后仍然存在。在大多数情况下,用户登录的缓存可以从几分钟到一个月或更长时间,具体取决于您的业务,因此依赖于用户是否关闭浏览器窗口并不是很有用。通过这个过程排除,我更喜欢localStorage,因为它具有隔离性和长期存储能力。

JWT 可以被加密并包含过期时间的戳。从理论上讲,这抵消了 cookies 和localStorage的弱点。如果正确实现,任一选项都应安全用于 JWT,但localStorage仍然更受欢迎。

让我们先实现一个缓存服务,它可以为我们应用程序提供一个集中的缓存方法。然后我们可以从这个服务中派生出我们的认证信息缓存:

  1. 首先创建一个抽象的cacheService,它封装了缓存的方法:

    **src/app/common/cache.****service****.****ts**
    @Injectable({ providedIn: 'root' })
    export class CacheService {
      protected getItem<T>(key: string): T | null {
        const data = localStorage.getItem(key)
        if (data != null) {
          try {
            return JSON.parse(data)
          } catch (error) {
            console.error('Parsing error:', error)
            return null
          }     
        }
        return null
      }
      protected setItem(key: string, data: object | string) {
        if (typeof data === 'string') {
          localStorage.setItem(key, data)
        }
        localStorage.setItem(key, JSON.stringify(data))
      }
      protected removeItem(key: string) {
        localStorage.removeItem(key)
      }
      protected clear() {
        localStorage.clear()
      }
    } 
    

    这个缓存服务类可以为任何服务提供缓存能力。虽然它创建了一个可以注入到另一个服务中的集中式缓存方法,但它并不是一个集中式值存储。您永远不应该用它来同步状态,这样我们就可以避免在服务和组件之间引入副作用和耦合。

  2. 更新AuthService以注入CacheService,这将使我们能够在下一节中实现 JWT 的缓存:

    **src/app/auth/auth.****service****.****ts**
    ...
    export abstract class AuthService implements IAuthService { 
      protected readonly cache = inject(CacheService)
      ... 
    } 
    

让我们通过一个示例来了解如何通过缓存authStatus对象的值来使用基类的功能:

**example**
authStatus$ = new BehaviorSubject<IAuthStatus>(
  this.getItem('authStatus') ?? defaultAuthStatus
)
constructor() {
  this.authStatus$.pipe(
    tap(authStatus => this.cache.setItem('authStatus', authStatus))
  )
} 

示例中展示的技术利用了 RxJS 的可观察流,在authStatus$的值发生变化时更新缓存。你可以使用这种模式持久化任何类型的数据,而不会让你的业务逻辑被缓存代码所杂乱。在这种情况下,我们不需要更新login函数来调用setItem,因为它已经调用了this.authStatus.next,我们只需接入数据流。这有助于保持无状态并避免副作用,通过解耦函数来实现。

注意,我们还在初始化BehaviorSubject时使用了getItem函数。使用空值合并运算符,我们只有在缓存的数据不是undefinednull时才使用它。否则,我们提供默认值。

你可以在setItemgetItem函数中实现自己的自定义缓存过期方案,或者利用第三方创建的服务。

然而,为了额外的安全层,我们不会缓存authStatus对象。相反,我们只会缓存编码后的 JWT,它只包含足够的信息,以便我们可以验证发送到服务器的请求。

在第七章的“实现 JWT 身份验证”部分,我们在“与 REST 和 GraphQL API 一起工作”中讨论了您应该如何加密和验证 JWT 令牌的有效性,以避免基于令牌的攻击。

理解基于令牌的认证工作方式对于避免泄露敏感信息至关重要。回顾本章早些时候的 JWT 生命周期,以加深你的理解。

接下来,让我们缓存令牌。

缓存 JWT

让我们更新身份验证服务,使其能够缓存令牌:

  1. 更新AuthService以能够设置、获取和清除令牌,如下所示:

    **src/app/auth/auth.****service****.****ts**
    ...
      protected setToken(jwt: string) {
        this.cache.setItem('jwt', jwt)
      }
      getToken(): string {
        return this.cache.getItem('jwt') ?? ''
      }
      protected clearToken() {
        this.cache.removeItem('jwt')
      } 
    
  2. login期间调用clearTokensetToken,在logout期间调用clearToken,如下所示:

    **src/app/auth/auth.****service****.****ts**
    ...
      login(email: string, password: string): Observable<void> {
        **this****.****clearToken****()**
        const loginResponse$ = this.authProvider(email, password)
          .pipe(
            map(value => {
              **this****.****setToken****(value.****accessToken****)**
              const token = decode(value.accessToken)
              return this.transformJwtToken(token)
            }),
            tap((status) => this.authStatus$.next(status)),
            ...
      }
      logout(clearToken?: boolean) {
        **if** **(clearToken) {**
          **this****.****clearToken****()**
        **}**
        setTimeout(() => this.authStatus$.next(defaultAuthStatus), 0)
      } 
    

每个后续请求都将包含在请求头中的 JWT。你应该确保每个 API 都检查并验证收到的令牌。例如,如果用户想要访问他们的个人资料,AuthService将验证令牌以检查用户是否经过身份验证;然而,还需要进一步的数据库调用来检查用户是否有权查看数据。这确保了对用户访问系统的独立确认,并防止了对未过期令牌的滥用。

如果一个经过身份验证的用户调用了一个他们没有适当授权的 API(比如说,如果一名职员想要获取所有用户的列表),那么AuthService将返回一个falsy状态,客户端将收到一个403 禁止的响应,这将被显示为用户的一个错误消息。

用户可以使用过期的令牌进行请求;当这种情况发生时,会向客户端发送一个401 未授权的响应。作为一个好的用户体验实践,我们应该自动提示用户重新登录,并允许他们在不丢失任何数据的情况下继续他们的工作流程。

总结来说,真正的安全性是通过强大的服务器端实现来实现的。任何客户端实现主要是为了在良好的安全实践周围提供良好的用户体验。

内存中的身份验证服务

现在,让我们实现一个具体版本的身份验证服务,我们可以使用它:

  1. 首先安装一个 JWT 解码库,以及为了模拟身份验证,一个 JWT 编码库:

    $ npm install fake-jwt-sign 
    
  2. 扩展 AuthService 抽象类:

    **src/app/auth/auth.****in****-memory.****service****.****ts**
    import { AuthService } from './auth.service'
    @Injectable({ providedIn: 'root' })
    export class InMemoryAuthService **extends****AuthService** {
      constructor() {
        super()
        **console****.****warn****(**
          **'You're using the InMemoryAuthService. Do not use this service in production.'**
        **)**
      }
      …
    } 
    
  3. 实现一个模拟的 authProvider 函数,模拟身份验证过程,包括动态创建一个模拟 JWT:

    **src/app/auth/auth.****in****-memory.****service****.****ts**
      import { sign } from 'fake-jwt-sign'//For InMemoryAuthService only
    ...
      protected authProvider(
        email: string,
        password: string
      ): Observable<IServerAuthResponse> {
        email = email.toLowerCase()
        if (!email.endsWith('@test.com')) {
          return throwError(
            'Failed to login! Email needs to end with @test.com.'
          )
        }
        const authStatus = {
          isAuthenticated: true,
          userId: this.defaultUser._id,
          userRole: email.includes('cashier')
            ? Role.Cashier
            : email.includes('clerk')
            ? Role.Clerk
            : email.includes('manager')
            ? Role.Manager
            : Role.None,
        } as IAuthStatus
        this.defaultUser.role = authStatus.userRole
        const authResponse = {
          accessToken: sign(authStatus, 'secret', {
            expiresIn: '1h',
            algorithm: 'none',
          }),
        } as IServerAuthResponse
        return of(authResponse)
      }
    ... 
    

    authProvider 在服务中实现了原本应该在服务器端实现的方法,这样我们就可以方便地在微调身份验证工作流程的同时实验代码。提供者使用临时的 fake-jwt-sign 库创建并签名 JWT,这样我也可以演示如何处理一个正确形成的 JWT。

    不要将 fake-jwt-sign 依赖项打包到你的 Angular 应用程序中,因为它意味着是服务器端代码。

    相比之下,一个真实的身份验证提供者将包括一个发送到服务器的 POST 调用。请参阅以下示例代码:

    **example**
    private exampleAuthProvider(
      email: string,
      password: string
    ): Observable<IServerAuthResponse> { return this.httpClient.post<IServerAuthResponse>(
        `${environment.baseUrl}/v1/login`, 
        { email: email, password: password }
      )
    } 
    

    这相当直接,因为困难的工作已经在服务器端完成。这个调用也可以发送给第三方身份验证提供者,我将在本章后面的 Firebase 身份验证食谱中介绍。

    注意,URL 路径中的 API 版本 v1 是在服务中定义的,而不是作为 baseUrl 的一部分。这是因为每个 API 可以独立更改版本。登录可能长时间保持为 v1,而其他 API 可能升级到 v2v3 等等。

  4. 实现 transformJwtToken 将是微不足道的,因为登录函数为我们提供了一个符合 IAuthStatus 的令牌:

    **src/app/auth/auth.****in****-memory.****service****.****ts**
    protected transformJwtToken(token: IAuthStatus): 
      IauthStatus {
        return token
      } 
    
  5. 最后,实现 getCurrentUser,它应该返回一些默认用户:

    **src/app/auth/auth.****in****-memory.****service****.****ts**
    protected getCurrentUser(): Observable<User> {
      return of(this.defaultUser)
    } 
    

    接下来,将 defaultUser 作为私有属性提供给类;以下是我创建的一个示例。

  6. InMemoryAuthService 类中添加一个私有的 defaultUser 属性:

    **src/app/auth/auth.****in****-memory.****service****.****ts**
    import { PhoneType, User } from '../user/user/user'
    ...
    private defaultUser = User.Build({
      _id: '5da01751da27cc462d265913',
      email: 'duluca@gmail.com',
      name: { first: 'Doguhan', last: 'Uluca' },
      picture: 'https://secure.gravatar.com/
        avatar/7cbaa9afb5ca78d97f3c689f8ce6c985',
      role: Role.Manager,
      dateOfBirth: new Date(1980, 1, 1),
      userStatus: true,
      address: {
        line1: '101 Sesame St.',
        city: 'Bethesda',
        state: 'Maryland',
        zip: '20810',
      },
      level: 2,
      phones: [
        {
          id: 0,
          type: PhoneType.Mobile,
          digits: '5555550717',
        },
      ],
    }) 
    

恭喜!你已经实现了一个具体但仍然模拟的身份验证服务。现在你已经有了一个内存中的身份验证服务,确保运行你的 Angular 应用程序并且没有错误出现。

让我们通过实现一个简单的登录和注销功能来测试我们的身份验证服务,这些功能可以通过 UI 访问。

简单登录

在我们实现一个功能齐全的 login 组件之前,让我们将预制的登录行为连接到 HomeComponent 中的“登录为管理员”按钮。在我们深入了解交付丰富 UI 组件的细节之前,我们可以测试我们的身份验证服务的功能。

我们的目标是模拟登录为管理员。为了实现这一点,我们需要硬编码一个电子邮件地址和密码进行登录,并在登录成功后,保持导航到 /manager 路由的功能。

注意,在 GitHub 上,本节代码示例位于projects/stage8文件夹结构下的home.component.simple.ts文件中。另一个文件仅用于参考目的,因为本章后面的代码将对此部分进行重大更改。忽略文件名差异,因为它不会影响本节编码。

让我们实现一个简单的登录机制:

  1. HomeComponent中实现一个使用AuthServicelogin函数:

    **src/app/home/home.****component****.****ts**
    import { AuthService } from '../auth/auth.service'
    export class HomeComponent implements OnInit {
      constructor(private authService: AuthService) {}
      ngOnInit(): void {}
      login() {
        this.authService.login('manager@test.com', '12345678')
      }
    } 
    
  2. 更新模板以删除routerLink,而不是调用login函数:

    **src/app/home/home.****component****.****ts**
    template: `
        <div fxLayout="column" fxLayoutAlign="center center">
          <span class="mat-display-2">Hello, Limoncu!</span>
          <button mat-raised-button color="primary" **(click)="login()"**>
            Login as Manager
          </button>
        </div>
      `, 
    

    在成功登录后,我们需要导航到/manager路由。我们可以通过监听AuthService公开的authStatus$currentUser$可观察对象来验证我们是否成功登录。如果authStatus$.isAuthenticatedtruecurrentUser$._id是一个非空字符串,则表示有效的登录。我们可以通过使用 RxJS 的combineLatest运算符来监听这两个可观察对象。在有效的登录条件下,然后我们可以使用filter运算符来响应式地导航到/manager路由。

  3. 更新login()函数以实现登录条件,并在成功后导航到/manager路由:

    **src/app/home/home.****component****.****ts**
    constructor(
      private authService: AuthService,
      **private****router****:** **Router**
    ) {}
    
    login() {
      this.authService.login('manager@test.com', '12345678')
    **combineLatest****([**
      **this****.****authService****.****authStatus$****,** **this****.****authService****.****currentUser$**
    **])** 
      **.****pipe****(**
        **filter****(****(****[authStatus, user]****) =>**
          **authStatus.****isAuthenticated** **&& user?.****_id** **!==** **''**
        **),**
        **tap****(****(****[authStatus, user]****) =>** **{**
          **this****.****router****.****navigate****([****'/manager'****])**
        **})**
      **)**
      **.****subscribe****()**
    } 
    

    注意,我们在最后订阅了combineLatest运算符,这在激活可观察流中是至关重要的。否则,除非其他组件订阅了流,我们的登录操作将保持休眠状态。你只需要激活一次流。

  4. 现在,测试新的login功能。验证 JWT 是否已创建并存储在localStorage中,如这里所示:![img/B20960_05_04.png]

    图 5.4:DevTools 显示应用程序 | 本地存储

你可以在应用程序选项卡下查看本地存储。确保你的应用程序 URL 被突出显示。在步骤 3中,你可以看到我们有一个名为jwt的键,它包含一个看起来有效的令牌。

注意步骤 4步骤 5,突出显示两个警告,分别建议我们不要在生产代码中使用InMemoryAuthServicefake-jwt-sign包。

使用断点进行调试并逐步执行代码,以更具体地了解HomeComponentInMemoryAuthServiceAuthService如何协同工作以登录用户。

当你刷新页面时,请注意你仍然处于登录状态,因为我们正在本地存储中缓存令牌。

由于我们正在缓存登录状态,我们必须实现一个登出体验来完成认证工作流程。

登出

应用程序工具栏上的登出按钮已经连接到我们之前创建的logout组件。让我们更新这个组件,以便在导航到时能够登出用户:

  1. 实现登录组件:

    **src/app/user/logout/logout.****component****.****ts**
    import { Component, OnInit } from '@angular/core' 
    import { Router } from '@angular/router'
    import { AuthService } from '../../auth/auth.service'
    @Component({
      selector: 'app-logout', 
      template: `<p>Logging out...</p>`,
    })
    export class LogoutComponent implements OnInit { 
      constructor(private router: Router, private authService: AuthService) {}
      ngOnInit() { 
        this.authService.logout(true)
        this.router.navigate(['/'])
      }
    } 
    

    注意,我们通过将true传递给logout函数来显式清除 JWT。在调用logout之后,我们将用户导航回主页。

  2. 测试logout按钮。

  3. 验证在登出后本地存储已被清除。

我们已经实现了稳固的登录和注销功能。然而,我们还没有完成我们认证流程的基础。

接下来,我们需要考虑我们的 JWT 的过期状态。

恢复 JWT 会话

如果你每次访问网站时都必须登录 Gmail 或 Amazon,那么用户体验将不会很好。这就是为什么我们缓存 JWT,但如果你永远保持登录状态,用户体验同样糟糕。JWT 有一个过期日期策略,提供商可以选择几分钟甚至几个月来允许你的令牌有效,这取决于安全需求。内存服务创建的令牌在一小时内过期,所以如果用户在那一时间段内刷新浏览器窗口,我们应该尊重有效的令牌,并允许用户继续使用应用程序,而无需要求他们重新登录。

另一方面,如果令牌已过期,我们应该自动将用户导航到登录屏幕,以实现流畅的用户体验。

让我们开始吧:

  1. 更新 AuthService 类以实现一个名为 hasExpiredToken 的函数来检查令牌是否过期,以及一个名为 getAuthStatusFromToken 的辅助函数来解码令牌,如下所示:

    **src/app/auth/auth.****service****.****ts**
    ...
      protected hasExpiredToken(): boolean {
        const jwt = this.getToken()
        if (jwt) {
          const payload = decode(jwt) as any
          return Date.now() >= payload.exp * 1000
        }
        return true
      }
      protected getAuthStatusFromToken(): IAuthStatus {
        return this.transformJwtToken(decode(this.getToken()))
      } 
    

    保持你的代码 DRY!将 login() 函数更新为使用 getAuthStatusFromToken()

  2. 更新 AuthService 的构造函数以检查令牌的状态:

    **src/app/auth/auth.****service****.****ts**
    ...
    constructor() {
      super()
      if (this.hasExpiredToken()) {
        this.logout(true)
      } else {
        this.authStatus$.next(this.getAuthStatusFromToken())
      }
    } 
    

    如果令牌已过期,我们注销用户并从 localStorage 中清除令牌。否则,我们解码令牌并将认证状态推送到数据流中。

    在这里需要考虑的一个特殊情况是,在恢复会话时也要触发当前用户的重新加载。我们可以通过实现一个新的管道来实现这一点,如果激活,则重新加载当前用户。

  3. 首先,让我们将 login() 函数中现有的用户更新逻辑重构为一个名为 getAndUpdateUserIfAuthenticated 的私有属性,以便我们可以重用它:

    **src/app/auth/auth.****service****.****ts**
    ...
    export abstract class AuthService implements IAuthService {
      **private** **getAndUpdateUserIfAuthenticated =** **pipe****(**
        **filter****(****(****status****:** **IAuthStatus****) =>** **status.****isAuthenticated****),**
        **flatMap****(****() =>****this****.****getCurrentUser****()),**
        **map****(****(****user****:** **IUser****) =>****this****.****currentUser$****.****next****(user)),**
        **catchError****(transformError)**
      **)**
      ...
      login(email: string, password: string): Observable<void> {
        this.clearToken()
        const loginResponse$ = this.authProvider(email, password)
          .pipe(
            map((value) => {
              this.setToken(value.accessToken)
              const token = decode(value.accessToken)
              return this.transformJwtToken(token)
            }),
            tap((status) => this.authStatus$.next(status)),
            **this****.****getAndUpdateUserIfAuthenticated**
          )
        ...
      }
      ...
    } 
    
  4. AuthService 中,定义一个名为 resumeCurrentUser$ 的可观察属性,作为 authStatus$ 的分支,并使用 getAndUpdateUserIfAuthenticated 逻辑:

    **src/app/auth/auth.****service****.****ts**
    ...
      protected readonly resumeCurrentUser$ = this.authStatus$.pipe(
        this.getAndUpdateUserIfAuthenticated 
      ) 
    

    一旦 resumeCurrentUser$ 被激活且 status.isAuthenticatedtrue,则 this.getCurrentUser() 将被调用,并且 currentUser$ 将被更新。

  5. 更新 AuthService 的构造函数,如果令牌未过期则激活管道:

    **src/app/auth/auth.****service****.****ts**
    ...
    constructor() {
      if (this.hasExpiredToken()) {
        this.logout(true)
      } else {
        this.authStatus$.next(this.getAuthStatusFromToken())
        // To load user on browser refresh,
        // resume pipeline must activate on the next cycle
        // Which allows for all services to constructed properly
        setTimeout(() => this.resumeCurrentUser$.subscribe(), 0)
      }
    } 
    

使用前面的技术,我们可以检索最新的用户配置文件数据,而无需处理缓存问题。

要实验令牌过期,我建议在 InMemoryAuthService 中创建一个更快过期的令牌。

如缓存部分之前所演示的,可以使用this.cache.setItem和缓存中的配置文件数据在首次启动时缓存用户配置文件数据。这将提供更快的用户体验,并覆盖用户可能离线的情况。应用启动后,您可以异步获取新鲜的用户数据,并在新数据到来时更新currentUser$。您需要添加额外的缓存并调整getCurrentUser()逻辑以使此功能正常工作。哦,您还需要进行大量的测试!创建高质量的认证体验需要大量的测试。

恭喜!我们已经完成了健壮的认证工作流程的实现!接下来,我们需要将认证与 Angular 的 HTTP 客户端集成,以便将令牌附加到每个请求的 HTTP 头中。

一个 HTTP 拦截器

实现一个 HTTP 拦截器,将 JWT 注入到发送到 API 的每个请求的头部中,并通过提示用户重新登录来优雅地处理认证失败:

  1. auth下创建一个AuthHttpInterceptor

    **src/app/auth/auth.****http****.****interceptor****.****ts**
    import { HttpHandlerFn, HttpRequest } from '@angular/common/http'
    import { inject } from '@angular/core'
    import { Router } from '@angular/router'
    import { throwError } from 'rxjs'
    import { catchError } from 'rxjs/operators'
    import { environment } from 'src/environments/environment'
    import { UiService } from '../common/ui.service'
    import { AuthService } from './auth.service'
    export function AuthHttpInterceptor(
      req: HttpRequest<unknown>, next: HttpHandlerFn
    ) {
      const authService = inject(AuthService)
      const router = inject(Router)
      const uiService = inject(UiService)
      const jwt = authService.getToken()
      const baseUrl = environment.baseUrl
      if (req.url.startsWith(baseUrl)) {
        const authRequest = req.clone({
          setHeaders: {
            authorization: `Bearer ${jwt}`
          }
        })
        return next(authRequest).pipe(
          catchError((err) => {
            uiService.showToast(err.error.message)
            if (err.status === 401) {
              router.navigate(['/login'], {
                queryParams: {
                  redirectUrl: router.routerState.snapshot.url
                },
              })
            }
            return throwError(() => err)
          })
        )
      } else {
        return next(req)
      }
    } 
    

    注意到AuthService被用来检索令牌,并且在401错误后为login组件设置了redirectUrl

    注意到 if 语句if (req.url.startsWith(baseUrl))过滤掉了所有不是发送到我们 API 的出站请求。这样,我们就不会将 JWT 令牌泄露给外部服务。

  2. 更新app.config.ts以提供拦截器:

    **src/app/app.****config****.****ts**
    export const appConfig: ApplicationConfig = {
      providers: [
        provideAnimations(),
        provideHttpClient(
          **withInterceptors****([****AuthHttpInterceptor****])**
        ),
        ... 
    
  3. 确保拦截器将令牌添加到请求中。为此,打开Chrome DevTools | 网络标签,登录,然后刷新页面:img/B20960_05_05.png

    图 5.5:lemon.svg 的请求头

步骤 4中,您现在可以观察到拦截器的实际操作。对lemon.svg文件的请求在请求头中包含了承载令牌。

现在我们已经实现了认证机制,让我们利用我们编写的所有支持代码,包括动态 UI 组件和条件导航系统,在下一章中创建基于角色的用户体验。

摘要

您现在应该对 JWT 的工作原理、如何使用 TypeScript 进行安全数据处理以及如何构建可扩展的服务有一个稳固的理解。在本章中,我们定义了一个User对象,我们可以从它中提取或将其序列化为 JSON 对象,应用面向对象类设计和 TypeScript 运算符进行安全数据处理。

我们利用面向对象设计原则,使用继承和抽象类来实现一个基本的认证服务,该服务演示了开放/封闭原则。

我们涵盖了基于令牌的认证和 JWT 的基础知识,这样您就不会泄露任何关键用户信息。您学习了缓存和 HTTP 拦截器是必要的,这样用户就不必在每次请求时输入他们的登录信息。在此之后,我们实现了一个内存中的认证服务,它不需要任何外部依赖,这对于测试来说非常好。

在第六章 实现基于角色的导航 中,我们将构建一个动态 UI,使用路由和身份验证守卫,弹性布局媒体查询,Material 组件和服务工厂,以响应应用程序的身份验证状态。我们还将实现 Firebase 身份验证提供者,以便您可以在 Google Firebase 上托管您的应用程序。在第七章 与 REST 和 GraphQL API 一起工作 中,我们将使用两个自定义身份验证提供者将所有内容整合在一起,这些提供者可以针对 LemonMart 服务器进行身份验证,使用 Minimal MEAN 堆栈。

进一步阅读

问题

尽可能地回答以下问题,以确保您已经理解了本章的关键概念,而无需进行任何谷歌搜索。你知道你是否答对了所有问题吗?访问 angularforenterprise.com/self-assessment 获取更多信息:

  1. 在传输中和静止状态下的安全性是什么?

  2. 身份验证和授权之间的区别是什么?

  3. 解释继承和多态。

  4. 抽象类是什么?

  5. 抽象方法是什么?

  6. 解释 AuthService 如何遵循开放/封闭原则。

  7. JWT 如何验证您的身份?

  8. RxJS 的 combineLatestmerge 操作符之间的区别是什么?

  9. 路由守卫是什么?

  10. 服务工厂允许您做什么?

加入我们的 Discord 社区

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

packt.link/AngularEnterpise3e

二维码

第六章:实现基于角色的导航

第五章设计授权和认证中,我们讨论了设计一个有效的认证和授权系统虽然具有挑战性但对于用户满意度至关重要。用户对网络认证系统有很高的期望,任何错误都应该明确传达。随着应用程序的增长,它们的认证核心应该易于维护和扩展,以确保无缝的用户体验。

在本章中,我们将讨论创建出色的认证用户体验和实现坚实基础体验的挑战。我们将继续采用以路由器为起点的方法来设计单页应用(SPAs),通过实现 LemonMart 的认证体验。在第四章创建以路由器为起点的业务应用中,我们定义了用户角色,完成了所有主要路由的构建,并完成了 LemonMart 的粗略导航体验。这意味着我们已经为实施基于角色的条件导航体验做好了充分准备,该体验能够捕捉无缝认证体验的细微差别。我们将使用 Google Firebase 认证服务作为认证提供者来补充这一点,您可以在实际应用中利用它。

在本章中,您将了解以下主题:

  • 动态 UI 组件和导航

  • 使用守卫实现基于角色的路由

  • 一个 Firebase 认证配方

  • 使用工厂提供服务

技术要求

书中示例代码的最新版本可在以下链接存储库中找到。该存储库包含代码的最终和完成状态。您可以在本章结束时通过查找projects文件夹下的章节结束代码快照来验证您的进度。

对于第六章

  1. 克隆github.com/duluca/lemon-mart存储库。

  2. 在根目录中执行npm install以安装依赖项。

  3. 您将继续从上一章的stage8构建:

    projects/stage7 
    
  4. 项目的最终状态反映在:

    projects/stage8 
    
  5. 将舞台名称添加到任何ng命令中,使其仅在该阶段生效:

    npx ng build stage8 
    

注意,存储库根目录中的dist/stage8文件夹将包含编译结果。

请注意,书中提供的源代码和 GitHub 上的版本可能不同。这些项目周围的生态系统一直在不断发展。由于 Angular CLI 生成新代码的方式的变化、错误修复、库的新版本以及多种技术的并行实现,存在许多难以预料的差异。如果您发现错误或有疑问,请在 GitHub 上创建问题或提交拉取请求。

在内存中的认证提供者就位后,让我们利用我们为动态 UI 组件和基于角色的条件导航系统编写的所有支持代码。

动态 UI 组件和导航

AuthService 提供异步的认证状态和用户信息,包括用户的姓名和角色。我们可以使用所有这些信息来创建一个友好且个性化的用户体验。在下一节中,我们将实现 LoginComponent,以便用户可以输入他们的用户名和密码信息并尝试登录。

实现登录组件

LoginComponent 利用我们创建的 AuthService 并使用响应式表单实现验证错误。

记住,在 app.config.ts 中,我们使用 InMemoryAuthService 类提供了 AuthService。因此,在运行时,当 AuthService 注入到 LoginComponent 中时,将使用内存服务。

LoginComponent 应该设计成可以独立于任何其他组件渲染,因为在路由事件期间,如果我们发现用户没有正确认证或授权,我们将导航他们到这个组件。我们可以捕获这个原始 URL 作为 redirectUrl,这样一旦用户成功登录,我们就可以将他们导航回该 URL。

让我们开始:

  1. 在应用程序的根目录下创建一个名为 login 的新组件,并使用内联样式。

  2. 让我们先实现到 LoginComponent 的路由:

    **src/app/app-routing.modules.ts**
    ...
      { path: 'login', component: LoginComponent },
      { path: 'login/:redirectUrl', component: LoginComponent },
    ... 
    

    记住,'**' 路径必须是最后一个定义的。

  3. 使用与我们在 HomeComponent 中实现的类似 login 逻辑,使用一些样式实现 LoginComponent

    不要忘记为即将进行的步骤将所需的依赖模块导入到你的 Angular 应用程序中。这有意留作练习,让你找到并导入缺失的模块。

    **src/app/login/login.****component****.****ts**
    …
    import { AuthService } from '../auth/auth.service'
    import { Role } from '../auth/role.enum'
    @Component({
      selector: 'app-login',
      templateUrl: 'login.component.html',
      styles: `
          .error { color: red; }
          div[fxLayout] { margin-top: 32px; }
        `, 
      standalone: true,
      imports: [
        FlexModule,
        MatCardModule,
        ReactiveFormsModule,
        MatIconModule,
        MatFormFieldModule,
        MatInputModule,
        FieldErrorDirective,
        MatButtonModule,
        MatExpansionModule,
        MatGridListModule,
      ],
    })
    export class LoginComponent implements OnInit { 
      private readonly formBuilder = inject(FormBuilder)
      private readonly authService = inject(AuthService)
      private readonly router = inject(Router)
      private readonly route = inject(ActivatedRoute)
      loginForm: FormGroup
      loginError = ''  
      get redirectUrl() {
        return this.route.snapshot
                   .queryParamMap.get('redirectUrl') || ''
      }
    
      ngOnInit() {
        this.authService.logout()
        this.buildLoginForm()
      }
      buildLoginForm() {
        this.loginForm = this.formBuilder.group({
          email: ['', [Validators.required, Validators.email]],
          password: ['', [
            Validators.required,
            Validators.minLength(8),
            Validators.maxLength(50),
          ]],
        })
      }
      async login(submittedForm: FormGroup) {
        this.authService
          .login(
            submittedForm.value.email,
            submittedForm.value.password
          )
          .pipe(catchError(err => (this.loginError = err)))
        combineLatest([
          this.authService.authStatus$,
          this.authService.currentUser$,
        ])
          .pipe(
            filter(
              ([authStatus, user]) =>
                authStatus.isAuthenticated && user?._id !== ''
            ),
            first(),
            tap(([authStatus, user]) => {
              this.router.navigate([this.redirectUrl || '/manager'])
            })
          )
          .subscribe()
      } 
    } 
    

    我们使用 first 操作符来管理订阅。我们确保在调用 ngOnInit 时我们已注销。我们以标准方式构建响应式表单。最后,login 方法调用 this.authService.login 来启动登录过程。

    我们使用 combineLatest 同时监听 authStatus$currentUser$ 数据流。每当每个流中发生更改时,我们的管道都会执行。我们过滤掉不成功的登录尝试。作为成功登录尝试的结果,我们利用路由将认证用户导航到其个人资料。在服务器通过服务发送错误的情况下,我们将该错误分配给 loginError

  4. 这里是一个登录表单的实现,用于捕获和验证用户的 emailpassword,并在出现任何服务器错误时显示它们:

    不要忘记在 app.modules.ts 中导入 ReactiveFormsModule

    **src/app/login/login.component.html**
    <div fxLayout="row" fxLayoutAlign="center">
      <mat-card appearance="outlined" fxFlex="400px">
        <mat-card-header>
          <mat-card-title>
            <div class="mat-headline-5">Hello, Limoncu!</div>
          </mat-card-title>
        </mat-card-header>
        <mat-card-content>
          <form [formGroup]="loginForm" (ngSubmit)="login(loginForm)"
                     fxLayout="column">
            <div fxLayout="row" fxLayoutAlign="start center"
                    fxLayoutGap="10px">
              <mat-icon>email</mat-icon>
              <mat-form-field fxFlex>
                <input
                  matInput
                  placeholder="E-mail"
                  aria-label="E-mail"
                  formControlName="email"
                  #email />
                <mat-error [input]="email" [group]="loginForm"
                                    appFieldError="invalid">
                </mat-error>
              </mat-form-field>
            </div>
            <div fxLayout="row" fxLayoutAlign="start center"
                    fxLayoutGap="10px">
              <mat-icon matPrefix>vpn_key</mat-icon>
              <mat-form-field fxFlex>
                <input
                  matInput
                  placeholder="Password"
                  aria-label="Password"
                  type="password"
                  formControlName="password"
                  #password />
                <mat-hint>Minimum 8 characters</mat-hint>
                <mat-error
                  [input]="password"
                  [group]="loginForm"
                  [appFieldError]=
                    "['required', 'minlength', 'maxlength']">
                </mat-error>
              </mat-form-field>
            </div>
            <div fxLayout="row" class="margin-top">
              @if (loginError) {
                <div class="mat-caption error">
                  {{ loginError }}
                </div>
              }           
              <div class="flex-spacer"></div>
              <button
                mat-raised-button
                type="submit"
                color="primary"
                [disabled]="loginForm.invalid">
                Login
              </button>
            </div>
          </form>
        </mat-card-content>
      </mat-card>
    </div> 
    

    登录 按钮在电子邮件和密码满足客户端验证规则之前是禁用的。此外,<mat-form-field> 一次只会显示一个 mat-error,除非你为更多错误创建更多空间,所以请确保将错误条件按正确顺序放置。

    完成实现 LoginComponent 后,你可以更新主屏幕以有条件地显示或隐藏我们创建的新组件。

  5. 更新HomeComponent以清理我们之前添加的代码,以便在用户访问应用程序的主页时显示LoginComponent

    **src/app/home/home.****component****.****ts**
      ...
      template: `
        @if (displayLogin) {
          <app-login></app-login>
        } @else {
          <span class="mat-display-3">
            You get a lemon, you get a lemon, you get a lemon...
          </span>
        }
      `,
    }) 
    export class HomeComponent {
      displayLogin = true
      constructor() {
      }
    } 
    

您的应用程序应类似于以下截图:

图 6.1:LemonMart 登录界面

根据用户的认证状态,我们还需要做一些工作来实现和显示/隐藏sidenav菜单、个人资料和注销图标。

条件导航

条件导航对于创建无烦恼的用户体验是必要的。通过选择性地显示用户可以访问的元素并隐藏他们无法访问的元素,我们使用户能够自信地导航应用程序。

让我们从隐藏用户登录应用程序后的LoginComponent开始:

  1. HomeComponent中,将AuthService注入构造函数作为public变量:

    **src/app/home/home.****component****.****simple****.****ts**
    ...
    import { AuthService } from '../auth/auth.service'
    ...
    export class HomeComponent { 
      constructor(public authService: AuthService) {}
    } 
    
  2. 删除局部变量displayLogin,因为我们可以直接在模板中使用async管道访问认证状态。

  3. 使用控制流语法和async管道,实现一个新的模板,如下所示:

    **src/app/home/home.****component****.****ts**
    ...
      template: `
        **@if ((authService.authStatus$ | async)?.isAuthenticated) {**
          <div>      
            <div class="mat-display-4">
              This is LemonMart! The place where
            </div>
            <div class="mat-display-4">
              You get a lemon, you get a lemon, you get a lemon...
            </div>
            <div class="mat-display-4">
              Everybody gets a lemon.
            </div>
          </div>
        **} @else {**
          <app-login></app-login>
        **}**
      `,
       standalone: true,
       imports: [LoginComponent, AsyncPipe], 
    

    使用async管道可以避免像Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked这样的错误。每当您看到这个错误时,请停止使用局部变量,而应使用async管道。这是反应式操作的正确做法!

  4. AppComponent中,我们将通过注入AuthService作为public变量来遵循类似的模式:

    **src/app/app.****component****.****ts**
    import { Component, OnInit } from '@angular/core'
    import { AuthService } from './auth/auth.service'
    ...
    export class AppComponent implements OnInit { 
      constructor(..., **public****authService****:** **AuthService**) {
      }
      ngOnInit(): void {}
      ...
    } 
    
  5. 在模板中更新mat-toolbar,以便我们使用async管道监控authStatus$currentUser$

    **@if** **({**
      **status****: authService.****authStatus$** **|** **async****,**
      **user****: authService.****currentUser$** **|** **async**
    **};** **as** **auth;) {** 
       <mat-toolbar ... 
    
  6. 使用@if来隐藏所有针对已登录用户的按钮:

    **src/app/app.****component****.****ts**
    @if (auth?.status?.isAuthenticated) {
      <button ... > 
    

    现在,当用户注销时,您的工具栏应该看起来很干净,没有按钮,如图所示:

    图 6.2:用户登录前的 LemonMart 工具栏

  7. 如果用户有图片,我们还可以在profile按钮中替换通用的account_circle图标:

    **src/app/app.****component****.****ts**
    **import** **{** **NgOptimizedImage** **}** **from****'****@angular/common'**
    styles: `
      .image-cropper {
        border-radius: 50%;
      }
    `,
    template: `
      ...
      @if (auth?.status?.isAuthenticated) {
        <button mat-mini-fab routerLink="/user/profile" 
         matTooltip="Profile" aria-label="User Profile">
        @if (auth?.user?.picture) {
          <img alt="Profile picture" class="image-cropper" 
               **[ngSrc]="auth?.user?.picture ?? ''"** 
               width="40px" height="40px" fill />
        }
        @if (!auth?.user?.picture) {
          <mat-icon>account_circle</mat-icon>
        }
      </button>
    }
    ...
    `
    standalone: true,
      imports: [
        FlexModule,
        RouterLink,
        NavigationMenuComponent,
        RouterOutlet,
        AsyncPipe,
        MatIconModule,
        MatToolbarModule,
        MatButtonModule,
        MatSidenavModule,
        NgOptimizedImage,
      ], 
    

注意在img标签中使用ngSrc属性,这会激活NgOptimizedImage指令。此指令使得采用性能最佳实践来加载图片变得容易。它具有丰富的功能,可以优先或延迟加载某些图片,以帮助在快速首次内容绘制FCP)场景中,允许使用 CDN,并强制使用widthheight属性以防止在图片加载时可能发生的布局偏移。

angular.dev/guide/image-optimization了解更多关于NgOptimizedImage的信息。

我们现在有一个高度功能化的工具栏,它能够响应应用程序的认证状态,并且还可以显示属于已登录用户的信息。

表单的常见验证

在我们继续之前,我们需要重构 LoginComponent 的验证。随着我们在 第八章 中实现更多表单,食谱 - 可重用性、表单和缓存,你会发现反复在模板或响应式表单中键入表单验证非常快就会变得繁琐。响应式表单的魅力之一是它们由代码驱动,因此我们可以轻松地将验证提取到一个共享类中,并进行单元测试和重用,如下所示:

  1. common 文件夹下创建一个名为 validations.ts 的文件。

  2. 实现电子邮件和密码验证:

    **src/app/common/validations.****ts**
    import { Validators } from '@angular/forms'
    export const EmailValidation = [
      Validators.required, Validators.email
    ]
    export const PasswordValidation = [
      Validators.required,
      Validators.minLength(8),
      Validators.maxLength(50),
    ] 
    

    根据您的密码验证需求,您可以使用 Validations.pattern() 函数配合 RegEx 模式来强制执行密码复杂度规则,或者利用 OWASP 的 npmowasp-password-strength-test 来启用密码短语,以及设置更灵活的密码要求。请参阅 进一步阅读 部分的 OWASP 认证通用指南链接。

  3. 使用新的验证更新 LoginComponent

    **src/app/login/login.****component****.****ts**
    import {
      EmailValidation, PasswordValidation
    } from '../common/validations'
    ...
    this.loginForm = this.formBuilder.group({
      email: ['', EmailValidation],
      password: ['', PasswordValidation],
    }) 
    

接下来,让我们将一些常见的 UI 行为封装到一个 Angular 服务中。

使用环境提供者的 UI 服务

当我们开始处理复杂的流程,如认证流程时,能够以编程方式向用户显示 toast 通知非常重要。在其他情况下,我们可能希望在执行具有更侵入性弹出通知的破坏性操作之前请求确认。

无论您使用什么组件库,重复编写相同的样板代码来显示快速通知都会变得很繁琐。UI 服务可以整洁地封装一个默认实现,该实现可以自定义。

在 UI 服务中,我们将实现 showToastshowDialog 函数,这些函数可以触发通知或提示用户做出决定,使我们能够在实现业务逻辑的代码中使用它们。

让我们开始吧:

  1. common 下创建一个名为 ui 的新服务。

  2. 使用 MatSnackBar 实现一个 showToast 函数:

    查阅 material.angular.io 上的 MatSnackBar 文档。

    由于此服务可以被任何服务、组件或功能模块使用,我们无法在模块上下文中声明此服务。由于我们的项目是一个独立项目,我们因此需要实现一个 环境提供者,以便我们可以在 app.config.ts 中定义的应用程序上下文中提供该服务。

    **src/app/common/ui.****service****.****ts**
    @Injectable({
      providedIn: 'root',
    })
    export class UiService {
      constructor(
        private snackBar: MatSnackBar,
        private dialog: MatDialog
      ) {}
      showToast(
        message: string,
        action = 'Close',
        config?: MatSnackBarConfig
    ) {
        this.snackBar.open(
          message,
          action,
          config || {
            duration: 7000,
          }
        )
      }
    } 
    

    对于使用 MatDialogshowDialog 函数,我们必须实现一个基本的 dialog 组件。

    查阅 material.angular.io 上的 MatDialog 文档。

  3. common 文件夹下添加一个名为 simpleDialog 的新组件,包含内联模板和样式,跳过测试,并保持扁平的文件夹结构:

    **app/common/simple-dialog.****component****.****ts**
    import { Component, Inject } from '@angular/core'
    import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'
    @Component({
      // prettier-ignore
      template: `
        <h2 mat-dialog-title>{{ data.title }}</h2>
        <mat-dialog-content>
          <p>{{ data.content }}</p>
        </mat-dialog-content>
        <mat-dialog-actions>
          <span class="flex-spacer"></span>
          @if (data.cancelText) {
            <button mat-button mat-dialog-close>
              {{ data.cancelText }}
            </button>
          }
          <button mat-button mat-button-raised color="primary"
            [mat-dialog-close]="true" cdkFocusInitial>
            {{ data.okText }}
          </button>
        </mat-dialog-actions>
      `,
      standalone: true,
      imports: [MatDialogModule, MatButtonModule],
    })
    export class SimpleDialogComponent {
      constructor(
        public dialogRef: MatDialogRef<SimpleDialogComponent, boolean>,
        @Inject(MAT_DIALOG_DATA)
        public data: {
          title: string;
          content: string;
          okText: string;
          cancelText: string
        }
      ) {}
    } 
    

    SimpleDialogComponent 不应具有 selector: 'app-simple-dialog' 这样的应用程序选择器,因为我们只计划与 UiService 一起使用它。如果自动生成,请从您的组件中删除此属性。

  4. 现在,使用MatDialog实现一个showDialog函数来显示SimpleDialogComponent

    **app/common/ui.****service****.****ts**
    ...
    showDialog(
      title: string,
      content: string,
      okText = 'OK',
      cancelText?: string,
      customConfig?: MatDialogConfig
    ): Observable<boolean> {
      const dialogRef = this.dialog.open(
        SimpleDialogComponent,
        customConfig || {
          width: '300px',
          data: { title, content, okText, cancelText },
        }
      )
      return dialogRef.afterClosed()
    } 
    

    ShowDialog返回一个Observable<boolean>,因此你可以根据用户所做的选择实现后续操作。点击确定将返回true,点击取消将返回false

    SimpleDialogComponent中,使用@Inject,我们可以使用showDialog发送的所有变量来自定义对话框的内容。

  5. UiService的底部添加一个名为provideUiService的环境提供者:

    **app/common/ui.****service****.****ts**
    import { importProvidersFrom, makeEnvironmentProviders } from '@angular/core'
    export function provideUiService() {
      return makeEnvironmentProviders([
        importProvidersFrom(MatDialogModule, MatSnackBarModule),
      ])
    } 
    

    makeEnvironmentProviders允许我们将Service的依赖项封装在一个对象中。这样,我们不会将这些依赖项暴露给使用服务的组件。这有助于我们强制执行解耦架构。

  6. app.config.ts中,将provideUiService()添加到providers数组中:

    **src/app/app.****config****.****ts**
    export const appConfig: ApplicationConfig = {
      providers: [
        ...
        provideUiService()
      ]
    } 
    
  7. 更新LoginComponent中的login()函数,在登录后显示一个吐司消息:

    **src/app/login/login.****component****.****ts**
    import { UiService } from '../common/ui.service'
    ...
      **private****readonly** **uiService =** **inject****(****UiService****)** 
      ...
      async login(submittedForm: FormGroup) {
        ...
        tap(([authStatus, user]) => {
          **this****.****uiService****.****showToast****(**
            **`Welcome** **${user.fullName}****! Role:** **${user.role}****`**
          )
          ...
        })
     ... 
    

    现在,当用户登录后,将显示一个吐司消息,如图所示:

    图 6.3:Material snackBar

    snackBar将根据浏览器的大小占据整个屏幕宽度或部分宽度。

  8. 尝试显示一个对话框代替:

    **src/app/login/login.****component****.****ts**
    this.uiService.showDialog(
      `Welcome ${user.fullName}!`, `Role: ${user.role}`
    ) 
    

现在你已经验证了showToastshowDialog都工作正常,你更喜欢哪一个?

我在选择吐司消息或对话框时的经验法则是,除非用户即将采取不可逆的操作,否则你应该选择吐司消息而不是对话框,这样就不会打断用户的操作流程。

接下来,让我们实现一个全局侧导航体验,作为我们已有的基于工具栏导航的替代方案,以便用户可以轻松地在模块之间切换。

侧导航

为了提升用户体验,启用以移动端优先的工作流程并提供直观的导航机制,使用户能够快速访问所需的功能至关重要。侧导航栏(SideNav)对移动端和桌面用户都同样适用。在移动屏幕上,可以通过三横线(汉堡)菜单激活,在大屏幕上可以锁定打开。为了进一步优化体验,我们应该只显示用户有权查看的链接。我们可以通过根据用户的当前角色利用AuthenticationService来实现这一点。我们将按照以下方式实现侧导航模拟图:

图 6.4:侧导航模拟图

让我们将侧导航的代码作为一个单独的组件来实现,这样更容易维护:

  1. 在应用程序的根目录中创建一个带有内联模板和样式的NavigationMenu组件。

    侧导航在用户登录后技术上不是必需的。然而,为了能够从工具栏启动侧导航菜单,我们需要能够从 AppComponent 触发它。由于这个组件将是简单的,我们将急切地加载它。为了实现懒加载,Angular 确实有一个动态组件加载模式,但这将产生较高的实现开销,只有在节省了数百万字节的情况下才有意义。

    SideNav 将从工具栏触发,并附带一个 <mat-sidenav-container> 父容器,该容器本身托管 SideNav 以及应用程序的内容。因此,我们必须通过将 <router-outlet> 放置在 <mat-sidenav-content> 内部来渲染所有应用程序内容。

  2. AppComponent 中,定义一些样式以确保网络应用程序将扩展以填充整个页面,并在桌面和移动场景中保持适当的可滚动性:

    **src/app/app.****component****.****ts**
    styles: `
        .app-container {
          display: flex;
          flex-direction: column;
          position: absolute;
          top: 0;
          bottom: 0;
          left: 0;
          right: 0;
        }
        .app-is-mobile .app-toolbar {
          position: fixed;
          z-index: 2;
        }
        .app-sidenav-container {
          flex: 1;
        }
        .app-is-mobile .app-sidenav-container {
          flex: 1 0 auto;
        }
        mat-sidenav {
          width: 200px;
        }
        .image-cropper {
          border-radius: 50%;
        }
      `, 
    
  3. AppComponent 中注入 Angular Flex Layout 的 MediaObserver 服务。同时实现 OnInit,注入 DestroyRef,并添加一个名为 opened 的布尔属性:

    **src/app/app.****component****.****ts**
    import { MediaObserver } from '@ngbracket/ngx-layout '
    export class AppComponent implements OnInit {
      **private** **destroyRef =** **inject****(****DestroyRef****)**
      **opened****:** **boolean**
      constructor(
        ...
        **public****media****:** **MediaObserver**
      ) {
      ...
      }
      ngOnInit(): void {
        throw new Error('Method not implemented.')
      }
    } 
    

    为了自动确定侧导航的打开/关闭状态,我们需要监控媒体观察器和认证状态。当用户登录时,我们希望显示侧导航,当用户注销时隐藏它。我们可以通过将 opened 赋值为 authStatus$.isAuthenticated 的值来实现这一点。然而,如果我们只考虑 isAuthenticated,并且用户在移动设备上,我们将创建一个不太理想的用户体验。通过监控媒体观察器的 mediaValue,我们可以检查屏幕尺寸是否设置为超小或 xs;如果是这样,我们可以保持侧导航关闭。

  4. 更新 ngOnInit 以实现动态侧导航的打开/关闭逻辑:

    **src/app/app.****component****.****ts**
      ngOnInit() {
        combineLatest([
          this.media.asObservable(),
          this.authService.authStatus$,
        ])
          .pipe(
            tap(([mediaValue, authStatus]) => {
              if (!authStatus?.isAuthenticated) {
                this.opened = false
              } else {
                if (mediaValue[0].mqAlias === 'xs') {
                  this.opened = false
                } else {
                  this.opened = true
                }
              }
            }),
            takeUntilDestroyed(this.destroyRef)
          )
          .subscribe()
      } 
    

    通过监控媒体和 authStatus$ 流,我们可以考虑未经认证的场景,即使有足够的屏幕空间,侧导航也不应该打开。我们还使用 takeUntilDestroyed 以便清理我们的资源。

  5. 更新模板,以实现响应式的 SideNav,在移动场景中滑过内容,在桌面场景中将内容推到一边:

    **src/app/app.****component****.****ts**
    ...
    // prettier-ignore
    template: `
      **<div class="app-container">**
          @if (
            {
              status: authService.authStatus$ | async,
              user: authService.currentUser$ | async
            };
            as auth;
          ) {
            <mat-toolbar color="primary" fxLayoutGap="8px" 
             **class="app-toolbar"** 
             **[class.app-is-mobile]="media.isActive('xs')"**
              >
              @if (auth?.status?.isAuthenticated) {
                <button mat-icon-button 
                  **(click)="sidenav.toggle()">**
                  <mat-icon>menu</mat-icon>
                </button>
              }
        ...
      </mat-toolbar>
      <mat-sidenav-container class="app-sidenav-container">
        <mat-sidenav #sidenav
          [mode]="media.isActive('xs') ? 'over' : 'side'"
          [fixedInViewport]="media.isActive('xs')"
          fixedTopGap="56" [(opened)]="opened"
        >
          <app-navigation-menu></app-navigation-menu>
        </mat-sidenav>
        <mat-sidenav-content>
          <router-outlet></router-outlet>
        </mat-sidenav-content>
      </mat-sidenav-container>
      </div>
    `, 
    

    上述模板利用了 @ngbracket/ngx-layout 中的媒体观察器,这是已弃用的 Angular Flex Layout 库的社区克隆版。我们之前注入 ngx-layout 是为了实现响应式布局。

    您可以在模板上方使用 // prettier-ignore 指令来防止 Prettier 将您的模板拆分成太多行,这在某些条件下(如本例)可能会损害可读性。

    我们将在 NavigationMenuComponent 中实现导航链接。随着时间的推移,我们应用程序中的链接数量可能会增加,并受到各种基于角色的业务规则的影响。因此,如果我们将这些链接实现到 app.component.ts 中,我们可能会使该文件变得过大。此外,我们不想让 app.component.ts 频繁更改,因为那里的更改可能会影响整个应用程序。将链接实现为单独的组件是一种良好的实践。

  6. NavigationMenuComponent 中实现导航链接:

    **src/app/navigation-menu/navigation-menu.****component****.****ts**
    ...
      styles: `
          .active-link {
            font-weight: bold;
            border-left: 3px solid green;
          }
          .mat-mdc-subheader {        font-weight: bold;      }
      `,
      template: `
        <mat-nav-list>
          <h3 matSubheader>Manager</h3>
          <a mat-list-item
            routerLinkActive="active-link"
            routerLink="/manager/users">
              Users
          </a>
          <a mat-list-item
            routerLinkActive="active-link"
            routerLink="/manager/receipts">
              Receipts
          </a>
          <h3 matSubheader>Inventory</h3>
          <a mat-list-item
            routerLinkActive="active-link"
            routerLink="/inventory/stockEntry">
              Stock Entry
          </a>
          <a mat-list-item
            routerLinkActive="active-link"
            routerLink="/inventory/products">
              Products
          </a>
          <a mat-list-item
            routerLinkActive="active-link"
            routerLink="/inventory/categories">
              Categories
          </a>
          <h3 matSubheader>Clerk</h3>
          <a mat-list-item
            routerLinkActive="active-link"
            routerLink="/pos">
              POS
          </a>
        </mat-nav-list>
      `,
      standalone: true,
      imports: [MatListModule, RouterLinkActive, RouterLink],
    ... 
    

    <mat-nav-list> 在功能上等同于 <mat-list>,因此你可以使用 MatList 的文档进行布局。在此观察经理库存职员的子标题:

    图 6.5:桌面上的经理仪表板显示收据查找

    routerLinkActive="active-link" 突出了选中的收据路由,如图中所示。

    Angular Router 跟踪应用程序中的导航状态。根据哪个链接是活动的,它会自动分配适当的 CSS,以便将其突出显示为活动链接。

    你可以在 angular.dev/guide/routing/router-reference 上了解更多关于路由的信息。

    此外,你可以看到在移动设备上的外观和行为差异如下:

    图 6.6:移动设备上的经理仪表板显示收据查找

接下来,让我们实现基于角色的路由。

基于角色的路由使用守卫

这是您应用程序最基本且最重要的部分。通过懒加载,我们确保只加载最基本数量的资源,以便用户能够登录。

一旦用户登录,他们应该根据用户角色被路由到适当的登录屏幕,这样他们就不会猜测如何使用应用程序。例如,收银员只需要访问销售点(POS)屏幕,以便他们可以结账客户。在这种情况下,收银员可以自动被路由到该屏幕。

下面的 POS 屏幕是一个原型:

图 6.7:POS 屏幕原型

通过更新 LoginComponent 确保用户登录后能够被路由到适当的页面。

在名为 homeRoutePerRole 的函数中更新每个角色的路由 login 逻辑:

**app/src/login/login.****component****.****ts**
async login(submittedForm: FormGroup) {
  ...
    this.router.navigate([
      this.redirectUrl ||
      **this****.****homeRoutePerRole****(user.****role****as****Role****)**
    **])**
  **...**
**}**
**private****homeRoutePerRole****(****role****:** **Role****) {**
  **switch** **(role) {**
    **case****Role****.****Cashier****:**
      **return****'/pos'**
    **case****Role****.****Clerk****:**
      **return****'/inventory'**
    **case****Role****.****Manager****:**
      **return****'/manager'**
    **default****:**
      **return****'/user/profile'**
  **}**
**}** 

同样,职员和经理将被路由到他们的登录屏幕以访问他们完成任务所需的功能,如前所述。由于我们已经实现了默认的管理员角色,相应的登录体验将自动启动。

在下一节中,你将了解路由守卫,它有助于检查用户身份验证,甚至可以在表单渲染之前加载数据。这在防止用户意外访问他们不应访问的路由以及阻止有意尝试突破这些限制方面至关重要。

路由守卫

路由守卫使逻辑的进一步解耦和重用成为可能,并提供了对组件生命周期的更多控制。

这里是您最可能使用的四个主要守卫:

  • canActivatecanActivateChild:用于检查路由的认证访问

  • canDeactivate:用于在离开路由之前请求权限

  • Resolve:允许从路由参数中预取数据

  • CanLoad:允许在加载功能模块资源之前执行自定义逻辑

请参阅以下部分以了解如何利用canActivatecanLoadResolve守卫将在第八章食谱 – 可重用性、表单和缓存中介绍。

身份验证守卫

身份验证守卫通过允许或禁止在模块加载之前或在进行任何不适当的数据请求之前意外导航到功能模块或组件,从而提供良好的用户体验。例如,当管理员登录时,他们将被自动路由到/manager/home路径。浏览器将缓存此 URL,因此文书人员意外导航到相同的 URL 是完全可能的。Angular 不知道特定路由是否对用户可访问。如果没有authGuard,它将愉快地渲染管理员的首页并触发将失败的服务器请求。

不论您的前端实现多么健壮,您实现的每个 REST 或 GraphQL API 都应该在服务器端使用基于角色的访问控制(RBAC)进行适当的保护。

让我们更新路由器,以便在没有经过身份验证的用户的情况下无法激活ProfileComponent,并且ManagerModule只有在管理员使用authGuard登录时才会加载:

  1. 实现一个功能性的AuthGuard

    **src/app/auth/auth.****guard****.****ts**
    export const authGuard = (route?: ActivatedRouteSnapshot) => {
      const authService = inject(AuthService)
      const router = inject(Router)
      const uiService = inject(UiService)
      return checkLogin(authService, router, uiService, route)
    } 
    

    注意,所有依赖项都是通过注入函数内联注入的,这允许在@Injectable类的构造函数之外进行依赖注入,在这种情况下是一个函数。

    function checkLogin(
      authService: AuthService,
      router: Router,
      uiService: UiService,
      route?: ActivatedRouteSnapshot
    ): Observable<boolean> {
      return authService.authStatus$.pipe(
        map((authStatus) => {
          const roleMatch = checkRoleMatch(authStatus.userRole, route)
          const allowLogin = authStatus.isAuthenticated && roleMatch
          if (!allowLogin) {
            showAlert(uiService, authStatus.isAuthenticated, roleMatch)
            router.navigate(['login'], {
              queryParams: {
                redirectUrl: router?.getCurrentNavigation()?
                             .initialUrl.toString(),
              },
            })
          }
          return allowLogin
        }),
        take(1) // the observable must complete for the guard to work
      )
    }
    function checkRoleMatch(role: Role, route?: ActivatedRouteSnapshot) {
      if (!route?.data?.['expectedRole']) {
        return true
      }
      return role === route.data['expectedRole']
    }
    function showAlert(
      uiService: UiService,
      isAuth: boolean,
      roleMatch: boolean
    ) {
      if (!isAuth) {
        uiService.showToast('You must login to continue')
      }
      if (!roleMatch) {
        uiService.showToast(
          'You do not have the permissions to view this resource'
        )
      }
    } 
    
  2. 使用canLoad守卫防止加载懒加载的模块,例如manager模块:

    **src/app/app.****routes****.****ts**
    **import** **{ authGuard }** **from****'./auth/auth.guard'**
    ...
    {
      path: 'manager',
      loadChildren: () => import('./manager/manager.module')
        .then((m) => m.ManagerModule), 
      **canLoad****: [authGuard],**
      **data****: {** **expectedRole****:** **Role****.****Manager** **},**
    },
    ... 
    

    在这种情况下,当ManagerModule加载时,authGuard将在canLoad事件期间被调用,checkLogin函数将验证用户的身份验证状态。如果守卫返回false,则模块将不会加载。

    我们可以更进一步,在路由定义中提供额外的元数据,如expectedRole,它将通过canActivate事件传递给checkLogin函数。如果用户已通过身份验证,但他们的角色不匹配Role.Manager,则authGuard将再次返回false,模块将不会加载。

  3. 使用canActivate守卫防止激活单个组件,例如用户的profile

    **src/app/user/user-routing.****module****.****ts**
    ...
    { 
      path: 'profile', component: ProfileComponent, 
      **canActivate****: [authGuard]** 
    },
    ... 
    

    user-routing.module.ts的情况下,authGuardcanActivate事件期间被调用,checkLogin函数控制此路由可以导航的位置。由于用户正在查看自己的个人资料,因此在这里不需要检查用户的角色。

  4. 使用具有 expectedRole 属性的 canActivatecanActivateChild 来防止其他用户激活组件,例如 ManagerHomeComponent

    **src/app/mananger/manager-routing.****module****.****ts**
    ...
      {
        path: 'home',
        component: ManagerHomeComponent,
        **canActivate****: [authGuard],**
        **data****: {** **expectedRole****:** **Role****.****Manager** **},**
      },
      {
        path: 'users',
        component: UserManagementComponent,
        **canActivate****: [authGuard],**
        **data****: {** **expectedRole****:** **Role****.****Manager** **},**
      },
      {
        path: 'receipts',
        component: ReceiptLookupComponent,
        **canActivate****: [authGuard],**
        **data****: {** **expectedRole****:** **Role****.****Manager** **},**
      },
    ... 
    

ManagerModule 中,我们可以验证用户是否可以访问特定的路由。我们再次定义一些元数据,如 expectedRole,如果角色不匹配 Role.Manager,则 authGuard 将返回 false,从而阻止导航。

接下来,我们将回顾一些实现单元测试以隔离依赖的技术。

认证服务模拟和通用测试提供者

我们需要使用 common.testing.ts 中的 commonTestingProviders 函数提供 AuthServiceUiService 等服务的模拟版本,使用与在 第四章 中提到的 commonTestingModules 相似的模式。这样,我们就不会反复模拟相同的对象。

让我们使用来自 angular-unit-test-helperautoSpyObj 函数创建间谍对象,并回顾一些不那么明显的更改,以使我们的测试通过:

  1. common.testing.ts 中更新 commonTestingProviders

    **src/app/common/common.****testing****.****ts**
    import { autoSpyObj } from 'angular-unit-test-helper'
    export const commonTestingProviders: any[] = [
      { provide: AuthService, useValue: autoSpyObj(AuthService) },
      { provide: UiService, useValue: autoSpyObj(UiService) }, 
    ] 
    
  2. 观察在 app.component.spec.ts 中为 MediaObserver 提供的测试双例,并将其更新为使用 commonTestingModules

    **src/app/app.****component****.****spec****.****ts**
    ...
      TestBed.configureTestingModule({
        **imports****: [...commonTestingModules],**
        providers: [
          **{** **provide****:** **MediaObserver****,** **useClass****:** **MediaObserverFake** **},**
    ... 
    

    注意我们如何使用扩展语法 ... 在另一个数组中展开 commonTestingModules。这样,当你需要向数组中添加更多项时,只需在旁边添加一个 common 和另一个元素就非常方便。

    不要将扩展语法 与本书中用于表示代码片段中周围代码存在的省略号 混淆。

  3. 更新 LoginComponentspec 文件以利用 commonTestingModulescommonTestingProviders

    **src/app/login/login.****component****.****spec****.****ts**
    ...
      TestBed.configureTestingModule({
        **imports****: [... commonTestingModules],**
        **providers****: [... commonTestingProviders],**
        declarations: [LoginComponent],
      }).compileComponents() 
    
  4. 然后,将此技术应用于所有依赖于 AuthServiceUiServicespec 文件。

  5. 值得注意的是,对于服务,例如在 auth.service.spec.ts 中,你想使用测试双例。由于 AuthService 是被测试的类,请确保它按以下方式配置:

    **src/app/auth/auth.****service****.****spec****.****ts**
    ...
    TestBed.configureTestingModule({
      **imports****: [****HttpClientTestingModule****],**
      **providers****: [****AuthService****,** 
      **{** **provide****:** **UiService****,** **useValue****:** **autoSpyObj****(****UiService****) }],**
    }) 
    
  6. 使用类似考虑更新 ui.service.spec.ts

记住,直到所有测试通过,不要继续前进!

Firebase 认证配方

我们可以利用当前的认证设置并将其与真实的认证服务集成。对于本节,你需要一个免费的 Google 和 Firebase 账户。Firebase 是 Google 的综合移动开发平台:firebase.google.com。你可以创建一个免费账户来托管你的应用程序并利用 Firebase 认证系统。

Firebase 控制台,位于 console.firebase.google.com,允许你管理用户并发送密码重置电子邮件,而无需为你的应用程序实现后端。稍后,你可以利用 Firebase 函数以无服务器的方式实现 API。

首先,使用 Firebase 控制台将你的项目添加到 Firebase:

蓝色屏幕的截图 自动生成的描述

图 6.8:Firebase 控制台

  1. 点击添加项目

  2. 提供您的项目名称。

  3. 为您的项目启用 Google Analytics。

在尝试此操作之前创建一个 Google Analytics 账户可能会有所帮助,但它仍然应该可以工作。一旦您的项目创建完成,您应该看到您的项目仪表板:

计算机的截图 自动生成的描述

图 6.9:Firebase 项目概览

在左侧,标记为1的地方,您可以看到可以添加到项目中的工具和服务菜单。在顶部,标记为2的地方,您可以快速在项目之间切换。在这样做之前,您需要向项目中添加一个应用程序。

创建 Firebase 应用程序

您的项目可以包含您应用程序的多个分发版本,如 Web、iOS 和 Android 版本。在本章中,我们只对添加 Web 应用程序感兴趣。

让我们开始吧:

  1. 在您的项目仪表板上,点击 Web 应用程序按钮以添加应用程序,这在图 6.9中标记为3

  2. 提供一个应用程序昵称。

  3. 选择设置Firebase 托管的选项。

  4. 通过点击注册应用按钮继续。

  5. 跳过添加 Firebase SDK部分。

  6. 按照说明安装 Firebase CLI:

    $ npm install -g firebase-tools 
    
  7. 登录:

    $ firebase login 
    

确保您的当前目录是您的项目根文件夹。

  1. 初始化您的项目:

    $ firebase init 
    
  2. 选择托管选项。不用担心,您稍后可以添加更多功能。

  3. 选择您创建的项目作为默认项目,即lemon-mart-007

  4. 回答“检测到当前目录中存在现有的 Angular 代码库,我们应该使用这个吗?”时说“是”。

    这将创建两个新的文件:firebase.json.firebaserc

  5. 为生产构建您的项目:

    $ npx ng build --prod 
    

    或者

    $ npm run build:prod 
    
  6. 现在,您可以通过执行以下命令来部署您的 Angular 应用程序:

    $ firebase deploy 
    

您的网站应该在类似lemon-mart-007.firebaseapp.com的 URL 上可用,如终端中所示。

.firebase文件夹添加到.gitignore中,这样您就不会提交您的缓存文件。其他两个文件,firebase.json.firebaserc,可以安全提交。

可选地,使用 Firebase 控制台将您拥有的自定义域名连接到账户。

配置 Firebase 身份验证

现在,让我们配置身份验证。

在 Firebase 控制台中:

  1. 展开构建菜单,并从侧边导航中选择身份验证登录页面的截图 自动生成的描述

    图 6.10:Firebase 身份验证页面

  2. 添加一个登录方法;选择电子邮件/密码作为提供者。

  3. 启用它。

  4. 不要启用电子邮件链接。

  5. 保存您的配置。

您现在可以看到用户管理控制台:

计算机的截图 自动生成的描述

图 6.11:Firebase 用户管理控制台

它的操作简单直观,所以我将把它作为练习留给你。

将 Firebase 认证提供者添加到 Angular 中

让我们从添加 Angular Fire 开始,这是 Angular 的官方 Firebase 库到我们的应用程序中:

$ npx ng add @angular/fire 

按照 Angular Fire 的快速入门指南完成设置库与您的 Angular 项目的配置,您可以在 GitHub 上的README文件中找到链接:github.com/angular/angularfire

  1. 确保 Firebase 模块按照文档在app.config.ts中提供。

  2. 将您的 Firebase config对象复制到所有的environment.ts文件中。

    注意,environment.ts中提供的任何信息都是公开信息。因此,当您将 Firebase API 密钥放在此文件中时,它将是公开可用的。有很小的可能性,其他开发者可能会滥用您的 API 密钥并增加您的账单。为了保护自己免受此类攻击,请查看 Paachu 的这篇博客文章:即使 API 密钥公开可用,如何保护您的 Firebase 项目,链接为medium.com/@impaachu/how-to-secure-your-firebase-project-even-when-your-api-key-is-publicly-available-a462a2a58843

  3. 创建一个新的FirebaseAuthService

    $ npx ng g s auth/firebaseAuth --lintFix 
    
  4. 重命名服务文件auth.firebase.service.ts

  5. 一定要删除{ providedIn: 'root' }

  6. 通过扩展抽象认证服务实现 Firebase 认证:

    **src/app/auth/auth.****firebase****.****service****.****ts**
    import { inject, Injectable } from '@angular/core'
    import {
      Auth as FireAuth,
      signInWithEmailAndPassword,
      signOut,
      User as FireUser,
    } from '@angular/fire/auth'
    import { Observable, of, Subject } from 'rxjs'
    import { IUser, User } from '../user/user/user'
    import { Role } from './auth.enum'
    import {
      AuthService,
      defaultAuthStatus,
      IAuthStatus,
      IServerAuthResponse,
    } from './auth.service'
    interface IJwtToken {
      email: string
      iat: number
      exp: number
      sub: string
    }
    @Injectable()
    export class FirebaseAuthService extends AuthService {
      private afAuth: FireAuth = inject(FireAuth)
      constructor() {
        super()
      }
      protected authProvider(
        email: string,
        password: string
      ): Observable<IServerAuthResponse> {
        const serverResponse$ = new Subject<IServerAuthResponse>()
        signInWithEmailAndPassword(this.afAuth, email, password).then(
          (res) => {
            const firebaseUser: FireUser | null = res.user
            firebaseUser?.getIdToken().then(
              (token) => serverResponse$.next({
                accessToken: token
              } as IServerAuthResponse),
              (err) => serverResponse$.error(err)
            )
          },
          (err) => serverResponse$.error(err)
        )
        return serverResponse$
      }
      protected transformJwtToken(token: IJwtToken): IAuthStatus {
        if (!token) {
          return defaultAuthStatus
        }
        return {
          isAuthenticated: token.email ? true : false,
          userId: token.sub,
          userRole: Role.None,
        }
      }
      protected getCurrentUser(): Observable<User> {
        return of(this.transformFirebaseUser(this.afAuth.currentUser))
      }
      private transformFirebaseUser(firebaseUser: FireUser | null): User {
        if (!firebaseUser) {
          return new User()
        }
        return User.Build({
          name: {
            first: firebaseUser?.displayName?.split(' ')[0] ||
                     'Firebase',
            last: firebaseUser?.displayName?.split(' ')[1] || 'User',
          },
          picture: firebaseUser.photoURL,
          email: firebaseUser.email,
          _id: firebaseUser.uid,
          role: Role.None,
        } as IUser)
      }
      override async logout() {
        if (this.afAuth) {
          await signOut(this.afAuth)
        }
        this.clearToken()
        this.authStatus$.next(defaultAuthStatus)
      }
    } 
    

    如您所见,我们只需实现我们已建立的认证代码与 Firebase 认证方法之间的差异。我们不需要复制任何代码,并且需要将 Firebase 的 user 对象转换为我们应用程序的内部用户对象。

    注意,在transformFirebaseUser中,我们设置role: Role.None,因为 Firebase 认证默认不实现用户角色的概念。为了使 Firebase 集成完全功能,您需要实现 Firebase 函数和 Firestore 数据库,以便您可以存储丰富的用户配置文件并在其上执行 CRUD 操作。在这种情况下,在认证后,您将再次调用以检索角色信息。在第七章与 REST 和 GraphQL API 一起工作中,我们介绍了如何在您的自定义 API 中实现这一点。

  7. 要使用 Firebase 认证而不是内存认证,更新app.config.ts中的AuthService提供者:

    **src/app/app.****config****.****ts**
      {
        provide: AuthService,
        useClass: **FirebaseAuthService**,
      }, 
    

    完成步骤后,从 Firebase 认证控制台添加新用户,您应该能够使用真实认证进行登录。

    总是确保在互联网上传输任何类型的个人身份信息PII)或敏感信息(如密码)时使用 HTTPS。否则,您的信息将被记录在第三方服务器上或被恶意行为者捕获。

  8. 再次提醒,在继续之前,务必更新您的单元测试:

    src/app/auth/auth.firebase.service.spec.ts
    import {
      HttpClientTestingModule
    } from '@angular/common/http/testing'
    import { inject, TestBed } from '@angular/core/testing'
    import { Auth as FireAuth } from '@angular/fire/auth'
    import { UiService } from '../common/ui.service'
    import { FirebaseAuthService } from './auth.firebase.service'
    const angularFireStub = {
      user: jasmine.createSpyObj('user', ['subscribe']),
      auth: jasmine.createSpyObj('auth',
                ['signInWithEmailAndPassword', 'signOut']),
    }
    describe('AuthService', () => {
      beforeEach(() => {
        TestBed.configureTestingModule({
          imports: [HttpClientTestingModule],
          providers: [
            FirebaseAuthService,
            UiService,
            { provide: FireAuth, useValue: angularFireStub },
          ],
        })
      })
      it('should be created', inject(
        [FirebaseAuthService],
        (service: FirebaseAuthService) => {
          expect(service).toBeTruthy()
        }
      ))
    }) 
    

停止!在部署真实认证方法之前,从你的项目中移除fake-jwt-sign包。

恭喜!你的应用程序已与 Firebase 集成!接下来,让我们来了解服务工厂,这可以帮助你动态切换抽象类的提供者。

使用工厂提供服务

你可以在加载时动态选择提供商,因此,你不需要更改代码来在认证方法之间切换,而是可以通过参数化环境变量来让不同类型的构建使用不同的认证方法。这在编写针对你的应用程序的自动化 UI 测试时特别有用,因为在实际环境中处理真实的认证可能很困难,甚至不可能。

首先,我们将在environment.ts中创建一个enum来帮助我们定义选项,然后我们将使用该enum在我们应用程序的引导过程中选择认证提供者。

让我们开始吧:

  1. 创建一个名为AuthMode的新enum

    **src/app/auth/auth.****enum****.****ts**
    export enum AuthMode {
      InMemory = 'In Memory',
      CustomServer = 'Custom Server',
      CustomGraphQL = 'Custom GraphQL',
      Firebase = 'Firebase',
    } 
    
  2. environment.ts中添加一个authMode属性:

    **src/environments/environment.****ts**
    ...
      authMode: AuthMode.**InMemory**,
    ...
    **src/environments/environment.****prod****.****ts**
    ...
      authMode: AuthMode.**Firebase**,
    ... 
    
  3. auth/auth.factory.ts的新文件中创建一个authFactory函数:

    **src/app/auth/auth.****factory****.****ts**
    import { environment } from '../../environments/environment'
    import { AuthMode } from './auth.enum'
    import { FirebaseAuthService } from './auth.firebase.service'
    import { InMemoryAuthService } from './auth.in-memory.service'
    export function authFactory() {
      switch (environment.authMode) {
        case AuthMode.InMemory:
          return new InMemoryAuthService()
        case AuthMode.Firebase:
          return new FirebaseAuthService()
        case AuthMode.CustomServer:
          throw new Error('Not yet implemented')
        case AuthMode.CustomGraphQL:
          throw new Error('Not yet implemented')
      }
    } 
    

    注意,工厂必须导入任何依赖的服务,如上所示。

  4. app.config.ts中的AuthService提供者更新为使用工厂:

    **src/app/app.****config****.****ts**
      providers: [
        {
          provide: AuthService,
          **useFactory****: authFactory**
        }, 
    

注意,你可以从app.config.ts中移除InMemoryAuthServiceFirebaseAuthService的导入。

使用此配置,每次你在开发配置中构建应用程序时,你将使用内存中的认证服务,而生产(prod)构建将使用 Firebase 认证服务。

摘要

你现在应该熟悉了如何创建高质量的认证体验。在本章中,我们设计了一个很好的条件导航体验,你可以通过将基本元素复制到你的项目中并实现自己的认证提供者来在你的应用程序中使用。我们创建了一个可重用的 UI 服务,这样你就可以方便地在应用程序的流程控制逻辑中显示警告。

我们介绍了路由守卫,以防止用户误入未经授权使用的屏幕,并重申了你的应用程序的真实安全性应该在服务器端实现的观点。你看到了如何使用工厂在不同的环境中动态提供不同的认证提供者。

最后,我们使用 Firebase 实现了真实的认证提供者。在第七章与 REST 和 GraphQL API 一起工作中,我们将回顾 LemonMart 服务器,这是一个使用 REST 和 GraphQL API 的最小 MEAN 堆栈的全栈实现。我们将通过学习如何实现自定义认证提供者和为 REST 和 GraphQL 端点实现 RBAC 来完成我们的认证之旅。

进一步阅读

问题

尽可能地回答以下问题,以确保你理解了本章的关键概念,而无需使用谷歌搜索。你知道你是否正确回答了所有问题吗?请访问angularforenterprise.com/self-assessment获取更多信息:

  1. RxJS 的combineLatestmerge操作符之间有什么区别?

  2. 在 Angular 路由守卫的上下文中,解释canActivatecanLoad之间的区别。

  3. 动态 UI 渲染如何提高基于角色的导航系统中的用户体验?

  4. 使用类似 Firebase Authentication 这样的服务进行 Web 应用程序的用户管理有哪些好处和潜在缺点?

  5. 描述一个场景,其中服务工厂在 Angular 应用程序中特别有用。

第七章:与 REST 和 GraphQL API 一起工作

第一章Angular 的架构和概念中,我向您介绍了网络应用存在的更广泛的架构,而在第三章构建企业应用中,我们讨论了可能影响您应用成功的一系列性能瓶颈。然而,您的网络应用的表现只能与您的全栈架构的表现相匹配。如果您正在使用不充分的 API 设计或缓慢的数据库,您将花费时间实施临时解决方案,而不是解决问题的根本原因。当我们摆脱最小化思维并开始修补漏洞时,我们就在构建一个可能崩溃或维护成本极高的脆弱塔楼的道路上。简而言之,全栈架构中做出的选择可以深刻影响网络应用的成功。您和您的团队根本无法忽视 API 的设计方式。通常,实现新功能或修复性能问题的正确方法是通过重新设计 API 端点。使用MongoDBExpressAngularNode.jsMEAN栈是一套围绕类似技术构建的流行技术集合,这些技术应该有助于网络开发者的采用。我对 MEAN 栈的看法是“最小化 MEAN”,它优先考虑易用性、健康和有效性,这些都是构建出色的DevEx的主要成分。

在过去两章中,我们为我们的应用设计并实现了一个基于角色的访问控制RBAC)机制。在第五章设计身份验证和授权中,我们深入探讨了安全考虑因素,介绍了 JWT 身份验证的工作原理,学习了如何使用 TypeScript 安全地处理数据,并利用面向对象编程(OOP)的设计,通过继承和抽象来构建一个可扩展的认证服务。在第六章实现基于角色的导航中,我们使用我们的认证服务设计了一个条件导航体验,并实现了针对自定义 API 和 Google Firebase 的认证提供者。

在本章中,我将向您介绍 LemonMart 服务器,该服务器实现了 JWT 身份验证、REST 和 GraphQL API。我们将使用这些 API 在 Angular 中实现两个自定义认证提供者。这将允许您对第八章食谱 – 可重用性、表单和缓存第九章食谱 – 主/详细、数据表和 NgRx中将要介绍的食谱进行身份验证调用。

本章涵盖了大量的内容。它旨在作为 GitHub 仓库的路线图。GitHub 仓库。我涵盖了架构、设计和实现的主要组件。我强调了一些重要的代码片段来解释解决方案是如何组合在一起的,但避免深入到实现细节。更重要的是,你需要理解我们为什么要实现各种组件,而不是对实现细节有深刻的掌握。对于本章,我建议你阅读并理解服务器代码,而不是试图自己重新创建它。

我们首先介绍全栈架构、LemonMart 服务器的 monorepo 设计以及如何使用 Docker Compose 运行具有 Web 应用、服务器和数据库的三层应用程序。然后,我们将回顾 REST 和 GraphQL API 的设计、实现和文档。对于 REST,我们将利用OpenAPI规范和SwaggerUI。对于 GraphQL,我们将利用GraphQL schemasApollo Studio。这两个 API 都将使用 Express.js 和 TypeScript 实现。然后,我们将介绍使用 DocumentTS 库实现 MongoDB 的对象文档映射器(ODM)以存储具有登录凭证的用户。最后,我们将实现基于令牌的身份验证功能来保护我们的 API 和 Angular 中的相应身份验证提供者。

在本章中,你将学习以下内容:

  • 全栈架构

  • 与 monorepos 一起工作

  • 设计 API

  • 使用 Express.js 实现 API

  • 使用 DocumentTS 的 MongoDB ODM

  • 实现 JWT 身份验证

  • 自定义服务器身份验证提供者

技术要求

书籍示例代码的最新版本可以在 GitHub 上的以下链接仓库中找到。链接仓库。该仓库包含代码的最终和完成状态。本章需要 Docker Desktop 和 Postman 应用程序。

确保你在开发环境中启动lemon-mart-server并且lemon-mart能够与之通信至关重要。请参考此处或 GitHub 上的README中的说明来启动你的服务器。

对于第七章的服务端实现:

  • 使用--recurse-submodules选项克隆lemon-mart-server仓库:

    git clone --recurse-submodules https://github.com/duluca/lemon-mart-server 
    
  • 在 VS Code 终端中,执行cd web-app; git checkout master以确保从github.com/duluca/lemon-mart克隆的子模块位于 master 分支。

    在后面的Git 子模块部分,你可以配置web-app文件夹以从你的lemon-mart服务器拉取。

  • root文件夹中执行npm install以安装依赖项。

    注意,在根目录中运行npm install命令会触发一个脚本,该脚本还会在serverweb-app文件夹下安装依赖项。

  • 在根目录中执行npm run init:env以配置.env文件中的环境变量。

    此命令将在根目录和 server 文件夹下创建两个 .env 文件,以包含您的私有配置信息。初始文件基于 example.env 文件生成。您可以在以后修改这些文件并设置自己的安全密钥。

  • 在根目录中执行 npm run build 以构建服务器和网页应用。

    注意,网页应用使用名为 --configuration=lemon-mart-server 的新配置构建,该配置使用 src/environments/environment.lemon-mart-server.ts

  • 执行 docker compose up --build 以运行服务器、网页应用和 MongoDB 数据库的容器化版本。

    注意,网页应用使用名为 nginx.Dockerfile 的新文件进行容器化。

  • 导航到 http://localhost:8080 查看网页应用。

    要登录,单击填写按钮以使用默认的演示凭据填写电子邮件和密码字段。

  • 导航到 http://localhost:3000 查看服务器着陆页面:手机截图  自动生成的描述

    图 7.1:LemonMart 服务器着陆页面

  • 导航到 http://localhost:3000/api-docs 查看交互式 API 文档。

  • 您可以使用 npm run start:database 仅启动数据库,并在 server 文件夹中使用 npm start 进行调试。

  • 您可以使用 npm run start:backend 仅启动数据库和服务器,并在 web-app 文件夹中使用 npm start 进行调试。

对于第七章中的客户端实现:

  • 克隆仓库:github.com/duluca/lemon-mart

  • 在根目录中执行 npm install 以安装依赖项。

  • 项目的初始状态反映在:

    projects/stage8 
    
  • 项目的最终状态反映在:

    projects/stage10 
    
  • 将阶段名称添加到任何 ng 命令中,以仅对该阶段执行操作:

    npx ng build stage10 
    

注意,仓库根目录下的 dist/stage10 文件夹将包含编译结果。

请注意,书中提供的源代码和 GitHub 上的版本可能不同。这些项目周围的生态系统不断演变。由于 Angular CLI 生成新代码的方式、错误修复、库的新版本以及多种技术的并行实现,存在许多难以计数的变体。如果您发现错误或有疑问,请在 GitHub 上创建问题或提交拉取请求。

当您的 LemonMart 服务器启动并运行时,我们准备探索 MEAN 栈的架构。到本节结束时,您应该有自己的 LemonMart 版本与服务器通信。

全栈架构

全栈指的是使应用程序工作的整个软件堆栈,从数据库到服务器、API 以及利用它们的 Web 和/或移动应用程序。传说中的全栈开发者无所不知,可以轻松地在职业的各个垂直领域操作。在所有与软件相关的事物上专长并被认为是每个给定主题的专家几乎是不可能的。然而,要被认为是某个主题的专家,你也必须对相关主题有深入的了解。在了解一个新主题时,保持你的工具和语言一致非常有帮助,这样你就可以在没有额外噪音的情况下吸收新信息。

因此,我选择向你介绍 MEAN 堆栈,而不是使用 Java 的 Spring Boot 或使用 C#的 ASP.NET。通过坚持熟悉的工具和语言,如 TypeScript、VS Code、npm、GitHub、Jasmine/Jest、Docker 和 CircleCI,你可以更好地理解全栈实现是如何结合在一起的,并成为一个更好的 Web 开发者。

最小化 MEAN

为你的项目选择理想的堆栈是困难的。首先,你的技术架构应该足够满足业务需求。例如,如果你试图使用 Node.js 交付一个人工智能项目,你很可能会使用错误的堆栈。我们的重点将是交付 Web 应用程序,但除此之外,我们还有其他参数需要考虑,包括以下内容:

  • 易用性

  • 幸福

  • 效率

如果你的开发团队将长期从事你的应用程序开发,考虑兼容性以外的因素非常重要。如果你的代码库易于使用,让你的开发者保持愉快,或者让他们觉得自己是项目的有效贡献者,你的堆栈、工具选择和编码风格可以产生重大影响。

一个良好配置的堆栈对于优秀的 DevEx 至关重要。这可能是干燥的煎饼堆和美味的小份煎饼之间的区别,适量的黄油和糖浆。

通过引入过多的库和依赖项,你可以减慢你的进度,使你的代码难以维护,并发现自己陷入引入更多库以解决其他库问题的反馈循环。赢得这场游戏的唯一方法就是简单地不参与。

如果你花时间学习如何使用几个基本的库,你可以成为一个更有效的开发者。本质上,你可以用更少的资源做更多的事情。我的建议是:

  • 在编写任何一行代码之前思考,并应用 80-20 规则。

  • 等待库和工具成熟,跳过测试版。

  • 快速通过减少对新包和工具的贪婪,掌握基础知识。

在 YouTube 上观看我 2017 年 Ng 会议的演讲,标题为用更少的 JavaScript 做更多的事情,链接为www.youtube.com/watch?v=Sd1aM8181kc

这种极简主义思维是最小化 MEAN 的设计哲学。您可以在 GitHub 上查看参考实现:github.com/duluca/minimal-mean。请参考以下图表以了解整体架构:

图 7.2:最小化 MEAN 软件栈和工具

让我们回顾一下架构的组件:

  • Angular: 您应该知道这个。Angular 是表示层。Angular 构建的输出是一组静态文件,可以使用最小化的 Docker 容器duluca/minimal-nginx-web-serverduluca/minimal-node-web-server托管。

  • Express.js: 这是我们的 API 层。Express 是一个快速、无偏见、极简的 Node.js 网络框架。Express 拥有庞大的插件生态系统,几乎可以满足每一个需求。NestJS 建立在 Express 之上,是成熟团队的不错替代品。在最小化 MEAN 中,我们利用了一些 Express 中间件:

    • cors: 配置跨源资源共享设置

    • compression: 压缩通过网络发送的数据包以降低带宽使用

    • morgan: 记录 HTTP 请求

    • express.static: 用于提供public文件夹内容的函数

    • graphql: 用于托管 GraphQL 端点

您可以在expressjs.com/了解更多关于 Express.js 的信息

  • Node.js: 这是服务器运行时;Express 在 Node 上运行,因此业务层将在 Node 上实现。Node 是一个轻量级且高效的 JavaScript 运行时,它使用事件驱动的、非阻塞的 I/O 模型,适用于高性能和实时应用。您可以通过使用 TypeScript 开发应用程序来提高 Node 应用程序的可靠性。

    Node 可以在任何地方运行,从冰箱到智能手表。请参阅 Frank Rosner 的博客文章,深入了解非阻塞 I/O 主题:blog.codecentric.de/en/2019/04/explain-non-blocking-i-o-like-im-five/

  • MongoDB: 这是持久化层。MongoDB 是一个具有动态 JSON 类似模式的文档型数据库。有关 MongoDB 的更多信息,请参阅www.mongodb.com/

MEAN 堆栈更受欢迎,因为它利用了使用基于 JSON 的数据库的主要好处,这意味着你不需要将数据从一种格式转换到另一种格式,因为它跨越了你的堆栈层——在处理.NET、Java 和 SQL 服务器时这是一个主要痛点。你可以仅使用 JSON 来检索、显示、编辑和更新数据。此外,Node 的 MongoDB 原生驱动程序成熟、性能良好且功能强大。我开发了一个名为document-ts的库,旨在通过引入易于编码的丰富文档对象来简化与 MongoDB 的交互。DocumentTS 是一个非常薄的基于 TypeScript 的 MongoDB 助手,具有可选的丰富 ODM 便利功能。更多关于 DocumentTS 的信息请参阅github.com/duluca/document-ts

Minimal MEAN 利用了我们用于 Angular 开发的相同工具和语言,这使得开发者可以在前端和后端开发之间进行最小化的上下文切换。

NestJS

Minimal MEAN 有意坚持基本原理,这样你可以更多地了解底层技术。虽然我使用 Minimal MEAN 为具有不同技能水平的大型团队交付了生产系统,但这种基础的开发体验可能并不合适。在这种情况下,你可能考虑 NestJS,这是一个用于实现全栈 Node.js 应用的流行框架。NestJS 具有丰富的功能集,其架构和编码风格类似于 Angular。

想要冒险吗?通过执行以下命令创建一个 NestJS 应用:

$ npx @nestjs/cli new your-app-name --strict 

Nest 建立在 Express 之上,并提供了构建可扩展后端解决方案的语法糖和概念。该框架大量借鉴了 Angular 的思想来实现依赖注入、守卫、拦截器、管道、模块和提供者。内置的资源生成器可以生成实体类、CRUD创建检索更新删除)控制器、数据传输对象DTOs)和服务。

例如:

$ npx nest g resource users 

在创建资源时,你可以选择创建 REST、GraphQL、微服务或 WebSocket 端点:

? What transport layer do you use?
> REST API 
  GraphQL (code first)
  GraphQL (schema first)
  Microservice (non-HTTP)
  WebSockets 

Nest 支持 OpenAPI 用于 REST 文档,GraphQL 也支持 GraphQL 的 schema-first 和 code-first 开发。对于具有如此多功能的库,Nest 的显式微服务支持是受欢迎的,快速启动时间和小框架大小对于操作至关重要。所有这些功能都由详细的文档在docs.nestjs.com/中支持。

向 Kamil Mysliwiec 和 Mark Pieszak 致敬,他们创建了一个伟大的工具,并在 NestJS 周围培养了一个充满活力的社区。如果你需要,可以在trilon.io/寻求咨询服务。

如果你访问文档网站,可能会被提供的众多选项所淹没。这就是为什么我在你用最少的 MEAN 掌握了基础知识之后,推荐使用功能丰富的库的原因。

你可以在nestjs.com/了解更多关于 NestJS 的信息。

接下来,让我们了解 monorepo、它们的优点和缺点。我将分享如何在 monorepo 中结合 Nx、Nest 和 Angular,然后介绍 LemonMart 服务器如何使用 Git 子模块创建 monorepo。

在 VS Code 中使用多根工作区

monorepo单体仓库)是一种软件开发策略,用于在单个仓库中托管多个项目的代码。这允许统一版本控制、简化依赖关系管理,以及更容易地在项目之间共享代码。在 monorepo 中,开发者可以在同一个 IDE 窗口中跳转项目,并更容易地在项目之间引用代码,例如在前端和后端之间共享 TypeScript 接口,确保数据对象每次都保持一致。

你可以使用 VS Code 中的多根工作区在同一个 IDE 窗口中启用对多个项目的访问,你可以在资源管理器窗口中添加多个项目进行显示。然而,monorepo 在源代码控制级别将项目组合在一起,允许我们在 CI 服务器上一起构建它们。有关多根工作区的更多信息,请参阅code.visualstudio.com/docs/editor/multi-root-workspaces

能够访问多个项目的代码使得提交原子更改成为可能,这意味着跨项目所做的更改可以合并为一个单独的提交。这通过将可能需要在多个仓库、部署和系统中协调的更改集中在一个地方,带来明显的优势。所有围绕维护代码质量和标准的过程也变得简化。只有一个Pull RequestPR)需要审查,一个部署需要验证,以及一组需要执行的检查。

那么为什么每个项目都不是 monorepo 呢?在大型的应用程序中,项目中的文件过多可能成为一个重大问题。它要求每个开发者都拥有顶级的硬件和 CI/CD 服务器,以便在昂贵的、高性能的硬件上运行。此外,自动部署这样的项目可能成为一个非常复杂的任务。最后,新加入团队的新成员可能会感到不知所措。

虽然 monorepos 至少可以追溯到 2000 年代初,但对于大多数公司来说,除了全球顶尖的科技公司外,它们并不实用。2019 年,当谷歌发布了开源的 Bazel 构建工具,该工具基于 2015 年的内部项目 Blaze 时,这个想法对于小规模项目来说变得可行。在 JavaScript、TypeScript 和 Web 应用程序开发领域,由前谷歌员工开发的 Nx 已经崭露头角。在管理、构建和发布包方面,Lerna 是 Nx 的近亲。

Nx monorepo

如同在第三章中提到的,构建企业级应用架构,Nx 是一个下一代构建系统,具有一流的单一代码仓库支持和强大的集成功能。Nx 提供了一种有见地的架构,这对于大型团队和企业来说是非常受欢迎的。Nx 还提供云服务,它将利用分布式缓存和并行化来优化构建,而无需你的团队投资复杂的底层基础设施工作。

你可以通过执行以下命令来设置一个新的 Nx 工作空间:

$ npx create-nx-workspace@latest 

或者,你可以在项目文件夹中执行以下命令来迁移现有项目:

$ npx nx@latest init 

默认情况下,这将为你提供一个包含一个应用的单一代码仓库配置。你可以使用 Nx 生成器添加可以在组件和其他模块之间共享的库。通过将代码分离到不同的库中,同时参与项目工作的多个人不太可能遇到合并冲突。然而,如果你遵循先路由架构并在功能模块之间划分职责,你也能得到类似的结果。更多内容请参阅nx.dev/getting-started

问题是,这值得吗?许多专家将其用作标准工具;然而,在我追求简约的过程中,我不喜欢在刀战中带来坦克。引入这样复杂的技术对团队来说是有成本的。采用这样的工具需要克服陡峭的学习曲线。

当你在 JavaScript、TypeScript、Git、Nx、Angular、库、Node、npm 和其他服务器端技术之上层层叠加时,导航这些工具所需的认知负荷会急剧增加。此外,这些工具中的每一个都需要专业知识来正确配置、维护和随着时间的推移进行升级。

在现代硬件上(至少不是被企业级慢速一切以便我们可以额外确保你没有病毒软件搞砸的硬件),拥有数百个组件的 Angular 应用构建速度足够快。随着 esbuild 和 Vite 的采用,这应该会进一步改善。Nx 的分布式缓存和集中式依赖管理功能可能会对你产生决定性影响。在开始一个新项目之前,务必仔细评估你的需求;自动运行时,很容易低估或高估你的需求。

我要明确一点。如果你正在处理数千个组件,那么 Nx 是必需的。

大多数 Angular 单一代码仓库只包含前端代码。要在现有的 Angular 工作空间中使用 NestJS 配置一个全栈单一代码仓库,请安装 Nest 脚本并在 Nx 工作空间内生成一个新项目:

$ npm i -D @nrwl/nest
$ npx nx g @nrwl/nest:application apps/your-api 

你可以在此处了解更多信息www.thisdot.co/blog/nx-workspace-with-angular-and-nest/

接下来,让我们看看 LemonMart 服务器的单一代码仓库是如何配置的。

Git 子模块

Git 子模块帮助您在多个仓库之间共享代码,同时保持提交的分离。前端开发者可能选择仅使用前端仓库进行工作,而全栈开发者将更喜欢访问所有代码。Git 子模块还为现有项目的合并提供了一个方便的方法。

观察一下lemon-mart-server项目的整体结构,您将拥有三个主要文件夹,如图所示:

lemon-mart-server
├───bin
├───web-app (snapshot of lemon-mart)
├───server
│   package.json
│   README.md 

bin文件夹包含辅助脚本或工具,web-app文件夹代表您的前端,而server包含后端源代码。在我们的案例中,web-app文件夹是lemon-mart项目。我们不是复制粘贴现有项目的代码,而是利用 Git 子模块将两个仓库链接在一起。package.json文件包含帮助初始化、更新和清理 Git 子模块的脚本,如modules:update用于获取 web 应用的最新版本。

我建议您在从 GitHub 克隆的lemon-mart-server版本上执行以下操作。否则,您将需要创建一个新的项目并执行npm init -y以开始操作。

要使用您的项目初始化 web-app 文件夹:

  1. webAppGitUrl更新为您自己的项目的 URL。

  2. 执行webapp:clean以删除现有的web-app文件夹。

  3. 最后,执行webapp:init命令以初始化web-app文件夹中的项目:

    $ npm run webapp:init 
    

在继续前进时,执行modules:update命令以更新子模块中的代码。在另一个环境中克隆仓库后,要拉取子模块,请执行npm modules:init。如果您需要重置环境并重新启动,请执行webapp:clean以清理 Git 的缓存并删除文件夹。

注意,您可以在您的仓库中拥有多个子模块。modules:update命令将更新所有子模块。

您的 Web 应用程序代码现在可在名为web-app的文件夹中找到。此外,您应该能够在 VS Code 的源代码控制面板下看到这两个项目,如图所示:

图片

图 7.3:VS Code 源代码控制提供者

使用 VS Code 的源代码控制,您可以对任一仓库独立执行 Git 操作。

如果您的子模块变得混乱,只需cd到子模块目录,执行git pull,然后git checkout main以恢复主分支。使用此技术,您可以从项目中的任何分支检出并提交 PR。

现在子模块已经准备好了,让我们看看服务器项目是如何配置的,这样我们就可以配置我们的 CI 服务器。

CircleCI 配置

使用 Git 子模块的一个好处是我们可以验证我们的前端和后端是否在同一个 CI 管道中工作。config.yml文件实现了两个作业,这是这里显示的工作流程的一部分:

**.circleci/config.yml**
...
workflows:
  build-and-test-compose:
    jobs:
      - build_server
      - build_webapp 

管道检出代码,使用 audit-ci 验证我们使用的包的安全性,安装依赖项,检查样式和 linting 错误,运行测试,并检查代码覆盖率水平。

测试命令隐式构建服务器代码,这些代码存储在 dist 文件夹下。在最后一步,我们将 dist 文件夹移动到工作区,以便我们可以在以后阶段使用它。

CI 管道将并行构建服务器和 Web 应用程序,如果主分支上的作业成功,可以选择运行 deploy 作业。关于 CI/CD 的更多细节可以在 第十章,使用 CI/CD 发布到生产 中找到。

接下来,让我们看看 RESTful 和 GraphQL API 之间的区别。

设计 API

第三章,构建企业应用程序架构 中,我讨论了无状态、数据驱动设计作为 Router-first 架构的一部分的重要性。作为这一目标的一部分,我强调识别应用程序将围绕其操作的主要数据实体作为一项重要活动。API 设计通过围绕主要数据实体进行设计也能带来极大的好处。

在全栈开发中,尽早确定 API 设计非常重要。如果前端和后端团队能够就主要数据实体及其形状达成一致,那么两个团队就可以就一个合同达成一致,去构建他们各自的软件组件。在 Router-first 架构中,我强调了利用 TypeScript 接口快速构建应用程序架构的重要性。后端团队也可以进行类似的活动。

一点早期的设计工作和协议确保了这些组件之间的集成可以非常早地建立,并且通过 CI/CD 管道,我们可以确保它不会分解。

CI 对于成功至关重要。最臭名昭著的案例之一是,团队直到太晚才整合关键系统,那就是 2013 年 HealthCare.gov 的灾难性发布。尽管有 300 人参与其中,并且在这个项目上花费了 3 亿美元,但它失败了。总共花费了 17 亿美元来拯救该项目并使其成功。美国政府可以承担这样的费用。您的企业可能不会这么宽容。

在设计您的 API 时,还有一些进一步的考虑因素,如果前端和后端开发者紧密合作以实现共同的设计目标,那么项目成功的几率将大大提高。

列出以下高级目标:

  • 最小化客户端和服务器之间传输的数据。

  • 坚持使用成熟的设计模式(例如,分页 API 设计)。

  • 设计以减少客户端上的业务逻辑实现。

  • 围绕主要数据实体进行设计。

  • 在跨越边界时简化数据结构。

  • 不要暴露数据库密钥或外键关系。

  • 从一开始就版本化端点。

你应该旨在实现 API 表面背后的所有业务逻辑。前端应仅包含展示逻辑。任何由前端实现的if语句也应由后端验证。

如在第一章Angular 的架构和概念中讨论的那样,在后台和前端实现无状态设计至关重要。每个请求都应该利用非阻塞 I/O 方法,并且不依赖于现有的会话。这是在云平台上无缝扩展你的 Web 应用程序代码的关键。会话因其扩展和占用大量内存而臭名昭著。

无论何时你在实施一个项目,限制,如果可能的话,消除实验是非常重要的。这在全栈项目中尤其如此。一旦你的应用程序上线,API 设计中的失误可能会产生深远的影响,并且难以纠正。概念验证是实验和验证想法以及新技术理想的地方。它们的一个显著特点是它们的可丢弃性。

接下来,让我们来讨论围绕主要数据实体设计 REST 和 GraphQL API。在这种情况下,我们将回顾围绕用户和认证的 API 实现。在两种情况下,我们将依赖 API 规范语言。对于 REST,我们将使用 OpenAPI 规范,对于 GraphQL,我们将使用模式规范,以记录设计,以便我们可以具体地向团队成员传达 API 的意图。稍后,这些规范将成为交互式工具,反映我们 API 的能力。

REST API

REST表示状态转移)通常用于创建利用 HTTP 方法(动词)如GETPOSTPUTDELETE的无状态、可靠的 Web 应用程序。REST API 定义良好且静态。像任何公开 API 一样,一旦发布,就很难,如果不是不可能的,改变它们的接口。总是可以扩展,但很难针对新兴用例进行优化,例如需要以不同方式使用 API 的移动或专用应用程序。这通常会导致 API 表面的巨大扩展,因为团队实施特定的 API 来满足新的需求。如果有多个独立的代码库需要访问相同的数据,这可能会导致可维护性挑战。

从前端开发者的角度来看,使用他们没有编写过的 API 可能是一种令人困惑的经历。大多数公开 API 和发布 API 的企业通常通过发布高质量的文档和示例来解决这个问题。这需要时间和金钱。然而,在企业环境中,一个快速发展的团队无法等待这样的文档被手动创建。

进入 OpenAPI,也称为 Swagger。OpenAPI 规范可以记录 API 名称、路由、输入和返回参数类型、编码、身份验证、请求头和预期的 HTTP 状态码。这种详细程度为 API 应如何使用留下了很少的解释空间,减少了摩擦和有缺陷的代码——所有这些都是避免后期集成挑战的关键因素。

OpenAPI 规范可以用 YAML 或 JSON 格式定义。使用此规范文件,您可以为您 API 渲染一个交互式用户界面。安装 Swagger Viewer VS Code 扩展,并在 server 文件夹下预览 swagger.yaml 文件:

此外,还有 OpenAPI (Swagger) 编辑器扩展,这是一个功能丰富的替代品。在发布时,此扩展不支持 OpenAPI 版本 3.1.0。

计算机屏幕截图  描述自动生成

图 7.4:Swagger.yaml 预览

使用 Swagger UI 视图,您可以在实现后尝试命令并对其服务器环境执行它们。

OpenAPI 规范

我们使用 OpenAPI 规范版本 openapi: 3.1.0。OpenAPI 规范可以记录关于您的服务器、API 的各种组件(如安全方案、响应、数据模式、输入参数)以及定义您的 HTTP 端点的路径的元数据。

让我们回顾一下位于 server 文件夹下的 swagger.yaml 文件的主要组件:

  1. YAML 文件以一般信息和目标服务器开始:

    **server/swagger.yaml**
    openapi: 3.1.0
    info:
      title: lemon-mart-server
      description: LemonMart API
      version: "3.0.0**"**
    **servers**:
      - url: http://localhost:3000
        description: Local environment
      - url: https://mystagingserver.com
        description: Staging environment
      - url: https://myprodserver.com
        description: Production environment 
    
  2. components 下,我们定义常见的 securitySchemes 和响应,这些定义了我们打算实施的认证方案以及我们的错误消息响应的外观:

    **server/swagger.yaml**
    **...**
    **components:**
      **securitySchemes:**
        bearerAuth:
          type: http
          scheme: bearer
          bearerFormat: JWT
      **responses:**
        UnauthorizedError:
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServerMessage"
              type: string 
    

    注意 $ref 的使用,它可以重复使用重复的元素。您可以看到在这里定义了 ServerMessage

  3. components 下,我们定义共享的数据 schemas,这些声明了我们作为输入接受的或返回给客户端的数据实体:

    **server/swagger.yaml**
    ...
      **schemas:**
        ServerMessage:
          type: object
          properties:
            message:
              type: string
        Role:
          type: string
          enum: [none, clerk, cashier, manager]
        ... 
    
  4. components 下,我们定义共享的 parameters,这使得重用如分页端点等常见模式变得容易:

    **server/swagger.yaml**
    ...
      **parameters:**
        filterParam:
          in: query
          name: filter
          required: false
          schema:
            type: string
          description: Search text to filter the result set by
    ... 
    
  5. paths 下,我们定义 REST 端点,例如 /login 路径的 post 端点:

    **server/swagger.yaml**
    ...
    **paths:**
      /v1/login:
        post:
          description: |
            Generates a JWT, given the correct credentials.
          requestBody:
            required: true
            content:
              application/json:
                schema:
                  type: object
                  properties:
                    email:
                      type: string
                    password:
                      type: string
                  required:
                    - email
                    - password
          responses:
            '200': # Response
              description: OK
              content:
                application/json:
                  schema:
                    type: object
                    properties:
                      accessToken:
                        type: string
                    description: JWT token that contains userId as subject, email and role as data payload.
            '401':
              $ref: '#/components/responses/UnauthorizedError' 
    

    注意 requestBody 定义了类型为 string 的必需输入变量。在 responses 下,我们可以定义对请求的 200 成功响应和 401 不成功响应的外观。在前者的情况下,我们返回 accessToken,而在后者的情况下,我们返回在 步骤 2 中定义的 UnauthorizedError

  6. paths 下,我们定义剩余的路径:

    **server/swagger.yaml**
    ...
    **paths:**
      /v1/auth/me:
        get: ...
      /v2/users:
        get: ...
        post: ...
      /v2/users/{id}:
        get: ...
        put: ... 
    

OpenAPI 规范功能强大,允许您定义复杂的用户如何与您的 API 交互的要求。OpenAPI 规范可在 spec.openapis.org/oas/latest.html 找到。在开发自己的 API 定义时,这是一个无价的资源。

我们的总体目标是集成此交互式文档与我们的 Express.js API。现在,让我们看看您如何实现这样的 API。

OpenAPI 规范与 Express

使用 Express 配置 Swagger 是一个手动过程。但这是一件好事。强迫自己手动记录端点有积极的影响。通过放慢速度,你将有机会从 API 消费者的角度考虑你的实现。这种视角将帮助你解决开发过程中端点可能存在的潜在问题,避免令人烦恼的,如果不是昂贵的,返工。

让我们看看如何将 OpenAPI 规范直接嵌入到代码中的示例:

**server/src/v1/routes/authRouter.****ts**
/**
 * @openapi
 * /v1/auth/me:
 *   get:
 *     description: Gets the `User` object of the logged in user
 *     responses:
 *       '200':
 *         description: OK
 *         content:
 *           application/json:
 *             schema:
 *               $ref: '#/components/schemas/User'
 *       '401':
 *         $ref: '#/components/responses/UnauthorizedError'
 */
router.get('/me', authenticate(), async (_req: Request, res: Response) => {
  if (res.locals.currentUser) {
    return res.send(res.locals.currentUser)
  }
  return res.status(401).send({ message: AuthenticationRequiredMessage })
}) 

在本例中,我们使用以 /** 开头的 JSDoc 文档语法,然后在 @openapi 标识符之后直接定义 OpenAPI 规范的相关部分。我们仍然可以引用其他地方定义的组件,如通过 $ref 语句引用 UserUnauthorizedError 对象所示。

将规范集成到代码旁边的最大好处是,开发者确切地知道服务器应该如何响应 /me GET 请求。如果存在用户,我们返回一个 User 对象;如果没有,我们抛出一个符合 UnauthorizedError 对象形状的 401 错误。使用一些自动化工具,我们仍然可以生成之前提到的相同的交互式 Swagger UI,因此测试人员和开发者可以直接从 Web 界面发现或测试 API。

随着 API 实现的演变,这种设置使开发者能够轻松地保持规范更新。通过使其变得容易,我们激励所有相关人员都有保持 Swagger UI 运作的动力,因为所有团队成员都从中受益。通过创建一个良性循环,我们实现了活文档的理想。通常,随着其变得过时,初始设计变得无用,但相反,我们可以有一个自动化和交互式的解决方案,提供持续的价值。

我们将使用两个辅助库来帮助我们集成内联规范到代码库中:

  • swagger-jsdoc:这允许我们通过在 JSDoc 注释块中使用 @openapi 标识符,在相关代码上方实现 OpenAPI 规范,输出一个 swagger.json 文件。

  • swagger-ui-express:这个库消耗 swagger.json 文件以显示交互式的 Swagger UI Web 界面。

让我们探索 Swagger 如何配置与 Express.js 一起工作:

  1. TypeScript 的依赖和类型信息如下所示:

    $ npm i swagger-jsdoc swagger-ui-express
    $ npm i -D @types/swagger-jsdoc @types/swagger-ui-express 
    
  2. 让我们探索 docs-config.ts 文件,它配置了基本的 OpenAPI 定义:

    **server/src/docs-config.****ts**
    import * as swaggerJsdoc from 'swagger-jsdoc'
    import { Options } from 'swagger-jsdoc'
    import * as packageJson from '../package.json'
    const options: Options = {
      swaggerDefinition: {
        openapi: '3.1.0',
        components: {},
        info: {
          title: packageJson.name,
          version: packageJson.version,
          description: packageJson.description,
        },
        servers: [
          {
            url: 'http://localhost:3000',
            description: 'Local environment',
          },
          {
            url: 'https://mystagingserver.com',
            description: 'Staging environment',
          },
          {
            url: 'https://myprodserver.com',
            description: 'Production environment',
          },
        ],
      },
      apis: [
        '**/models/*.js', 
        '**/v1/routes/*.js', 
        '**/v2/routes/*. js'
      ],
    }
    export const specs = swaggerJsdoc(options) 
    

    修改 servers 属性以包含你的测试、预发布或生产环境的位置。这允许 API 的消费者使用 Web 界面测试 API,而无需额外的工具。请注意,apis 属性通知代码文件 swaggerJsdoc 在构建 swagger.json 文件时应解析的文件。这个程序在服务器引导过程中运行,这就是为什么我们引用了转译的 .js 文件而不是 .ts 文件。

  3. app.ts 中引导 swagger 配置:

    **server/src/app.****ts**
    import * as swaggerUi from 'swagger-ui-express'
    import { specs } from './docs-config'
    const app = express()
    app.use(cors())
    ...
    **app.****use****(****'/api-docs'****, swaggerUi.****serve****, swaggerUi.****setup****(specs))**
    **app.****get****(****'/swagger'****,** **function** **(****_req, res****) {**
     **res.****json****(specs)**
    **})**
    ...
    export default app 
    

规范包含swagger.json文件的内容,然后传递给swaggerUi。然后,使用server中间件,我们可以配置swaggerUi/api-docs上托管 Web 界面。我们还可以从端点提供 JSON 文件,以便在其他工具中使用,如上所示。

即使在将规范文件与代码集成之后,开发者也必须手动确保规范和代码的一致性。这个过程可以自动化,包括生成基于 TypeScript 的 API 处理程序以防止编码错误。

可以在openapi.tools/找到由社区驱动的 OpenAPI 高质量和现代工具列表。

现在你已经了解了我们如何设计 REST API 并在其周围创建活文档,现在是时候学习 GraphQL 了,它将这些想法融入其核心设计。

GraphQL API

GraphQL图查询语言),由 Facebook 发明,是一种现代的 API 查询语言,它提供了一种比传统 REST API 更灵活、更健壮、更高效的替代方案。在 GraphQL 中,你不需要使用 HTTP 动词,而是编写一个查询来获取数据,一个突变来 POST、PUT 或 DELETE 数据,以及订阅以 WebSocket 风格推送数据。与 REST 不同,REST 为每个资源暴露一组固定的端点,而 GraphQL 允许客户端请求他们确切需要的数据,不多也不少。这意味着客户端可以根据他们的需求来塑造响应,从而减少过度获取和不足获取的问题。我们不再需要设计完美的 API 表面来获得最佳结果。

在全栈开发领域,正如在设计 API部分中提到的,围绕主要数据实体进行设计的重要性不容忽视。GraphQL 在这方面表现出色。其类型系统确保 API 围绕这些主要数据实体进行塑造,为前端和后端团队提供了一个清晰的合同。这个类型系统,定义在 GraphQL 模式中,作为合同,指定可以获取的数据类型和可用的操作集。

对于前端开发者来说,深入探索 GraphQL API 可以是一种令人耳目一新的体验。GraphQL 的反思性意味着可以查询其自身的模式以获取详细信息。

这种自文档特性确保开发者始终拥有最新的参考,消除了需要单独手动维护文档的需求。这对于企业环境中的敏捷团队特别有益,在这些环境中,等待文档并不总是可行的。

进入 GraphQL Playground 或 GraphiQL 交互式环境,开发者可以实时测试和探索 GraphQL 查询。这些工具与 OpenAPI 的 Swagger UI 类似,提供即时反馈,使开发者能够理解 API 的结构、类型和操作。这种动手方法降低了学习曲线,并促进了开发者对 API 功能的更深入理解。

接下来,让我们探索如何围绕主要数据实体设计 GraphQL API,确保它们与我们在 Router-first 架构和其他最佳实践中概述的原则保持一致。

GraphQL 模式

GraphQL 模式是任何 GraphQL API 的核心,作为客户端和服务器之间的合约。它通过定义类型和类型之间的关系来描述 API 的结构和能力。这些类型模拟了 API 操作的主要数据实体。

让我们从探索位于 server/graphql 下的 graphql.schema 文件开始:

  1. 使用 type 关键字,我们可以定义数据对象:

    **server/graphql/graphql.schema**
    type User {
      address: Address
      dateOfBirth: String
      email: String!
      id: ID!
      level: Float
      name: Name!
      phones: [Phone]
      picture: String
      role: Role!
      userStatus: Boolean!
      fullName: String
    } 
    

    这个 User 类型具有标量字段,如 idemail 字段,代表原始值类型如 IDStringIntFloatBoolean。感叹号 ! 表示这些字段是必需的。我们还可以定义类型之间的关系,例如 NamePhone。方括号 [] 表示 phonesPhone 对象的数组。

  2. 我们还可以定义枚举并像标量类型一样使用它们:

    **server****/****graphql****/****graphql.schema**
    enum Role {
      None
      Clerk
      Cashier
      Manager
    } 
    
  3. 使用保留类型 Query,我们可以定义如何检索数据:

    **server****/****graphql****/****graphql.schema**
    type Query {
      # Gets a `User` object by id
      # Equivalent to GET /v2/users/{id}
      user(id: ID!): User 
    } 
    

    我们可以定义可接受的参数和返回类型。

  4. 使用保留类型 Mutation,我们可以定义如何修改状态:

    **server/graphql/graphql.schema**
    type Mutation {
      # Generates a JWT, given correct credentials.
      # Equivalent to POST /v1/auth/login
      login(email: String!, password: String!): JWT
      # Create a new `User`
      # Equivalent to POST /v2/users
      createUser(userInput: UserInput!): User
    } 
    

    我们可以定义一个登录或 createUser 方法。注意,createUser 接受一个输入对象,如果我们想传递整个对象作为参数,则该对象是必需的。

  5. 输入对象使用 input 关键字声明:

    **server/graphql/graphql.schema**
    input UserInput {
      address: AddressInput
      dateOfBirth: String
      email: String!
      level: Float
      name: NameInput!
      phones: [PhoneInput]
      picture: String
      role: Role!
      userStatus: Boolean!
    } 
    

注意,任何相关对象也必须使用输入声明。输出类型和输入数据不能混合。

如您可能已经注意到的,我们还可以使用 # 符号或可选的三重引号 """ 语法添加描述来记录我们的 API。

模式使用 GraphQL 模式定义语言SDL)定义。您可以在 graphql.org/ 访问 SDL 规范。它是任何构建良好定义的 GraphQL API 的人的必备资源。

总体而言,该模式在客户端和服务器之间提供了一个严格的合约。它明确提供了可用的数据形状和能力。前端和后端团队可以针对此合约并行构建功能,并且像 GraphQL Playground 这样的工具使得模式交互式。

我们将使用 Apollo GraphQL 库来帮助在我们的 Express 服务器中以编程方式构建模式。

Apollo 与 Express

Apollo GraphQL 是一套全面且广泛采用的工具和服务套件,旨在帮助开发者轻松构建、管理和扩展 GraphQL 应用程序。由 Meteor 开发组开发,Apollo 由于其强大的功能和开发者友好的方法,已成为许多开发者进行 GraphQL 开发的同义词。以下是 Apollo GraphQL 的概述:

  • Apollo Client:一个先进的 GraphQL 客户端,用于管理本地和远程数据。它可以无缝集成到任何 JavaScript 前端框架中,如 React、Vue 或 Angular。Apollo Client 提供了缓存、乐观 UI 更新和实时订阅等功能,使得获取、缓存和修改应用程序数据变得更加容易。

  • Apollo Server:一个由社区驱动的开源 GraphQL 服务器,可以与任何 GraphQL 模式一起工作。Apollo Server 提供性能跟踪和错误跟踪,并支持模式拼接,允许将多个 GraphQL API 合并成一个统一的 API。

  • Apollo Client 开发者工具:提供丰富的浏览器内开发体验的浏览器扩展。开发者可以查看他们的 GraphQL 存储,检查活动查询,并使用内置的 GraphiQL IDE 与他们的 GraphQL 服务器进行交互。

Apollo 作为其云服务的一部分提供了更高级的开发工具,即 Apollo Studio。Apollo Federation 允许组织将他们的单体 GraphQL API 划分为更小、更易于维护的微服务。它提供了一种将多个 GraphQL 服务组合成单个数据图的方法。Apollo Link 允许开发者创建可链式的“链接”来处理日志记录、请求重试甚至离线缓存等任务。

从本质上讲,Apollo GraphQL 提供了一种全面的 GraphQL 开发方法,为初学者和高级用户提供工具和服务。无论您是构建小型应用程序还是扩展大型企业系统,Apollo 的工具都能确保出色的开发体验。

GraphQL 模式和 GraphQL 库是不可分割的,因此我们不需要采取额外步骤来配置模式定义以与代码库一起工作,就像我们与 OpenAPI 一起做的那样。

要从 GraphQL 模式生成类型,请遵循 www.apollographql.com/docs/apollo-server/workflow/generate-types/ 提供的指南。

接下来,让我们看看如何使用 Express.js 配置您的模式和 Apollo:

  1. 安装 Apollo 服务器:

    $ @apollo/server 
    
  2. 打开 api.graphql.ts 文件,该文件配置了 Apollo 服务器:

    **server/src/graphql/api.****graphql****.****ts**
    ...
    import { resolvers } from './resolvers'
    const typeDefs = readFileSync('./src/graphql/schema.graphql', 
    ...
    export async function useGraphQL(app: Express) {
      const server = new ApolloServer<AuthContext>({
        typeDefs,
        resolvers,
      })
      await server.start()
      ...
      )
    } 
    
  3. 使用 node:fs,我们将模式文件读取到 typeDefs 对象中,并将其传递给一个新的 ApolloServer 实例,同时传递一个对解析器的引用。最后,我们调用 server.start() 并导出 useGraphQL 函数。

  4. index.ts 中启动 Apollo 服务器:

    **server/src/index.****ts**
    import app from './app'
    ...
    **async****function****start****() {**
      **...**
      **Instance** **= http.****createServer****(app)**
      **await****useGraphQL****(app)**
            ...
          }
          start() 
    

index.ts 中,在我们创建由 app 变量定义的 Express 服务器实例之后,我们调用 useGraphQL 函数来启动它。这种配置允许我们同时实现 REST 和 GraphQL API。如以下所示,GraphQL API 和交互式探索工具可以通过 /graphql 访问:

计算机截图  描述自动生成

图 7.5:GraphQL 探索器

现在您已经了解了 REST 和 GraphQL API 之间的区别以及我们如何使用 Express.js 等效地配置它们,让我们看一下服务器的整体架构。

使用 Express.js 实现 API

让我们回顾一下我们后端的结构和文件结构,以便我们了解服务器是如何启动的,API 端点的路由是如何配置的,公共资源是如何提供的,以及服务是如何配置的。

查看我们的 Express 服务器文件结构:

server/src
├── api.ts
├── app.ts
├── config.ts
├── docs-config.ts
├── graphql
│   ├── api.graphql.ts
│   ├── helpers.ts
│   └── resolvers.ts
├── index.ts
├── models
│   ├── enums.ts
│   ├── phone.ts
│   └── user.ts
├── public
├── services
│   ├── authService.ts
│   └── userService.ts
├── v1
│   ├── index.ts
│   └── routes
│       └── authRouter.ts
└── v2
    ├── index.ts
    └── routes
        └── userRouter.ts 

接下来,我们将通过查看组件图来回顾这些文件的目的和交互,从而获得架构和依赖树的概览:

图 7.6:Express 服务器架构

index.ts 包含一个 start 函数,该函数启动应用程序,利用四个主要助手:

  • config.ts:管理环境变量和设置。

  • app.ts:配置 Express.js 并定义所有 API 路径,然后路由实现路径并利用包含业务逻辑的服务。服务使用模型,如 user.ts,来访问数据库。

  • api.graphql.ts:配置 GraphQL,解析器实现查询,并使用相同的解析器和突变器利用相同的服务来访问数据库。

  • document-ts:建立与数据库的连接,配置它,并在启动期间利用 user.ts 配置种子用户。

您可以看到,图顶部的组件负责启动和配置任务,包括配置 API 路径,这代表了 API 层。业务层应该包含应用程序的大部分业务逻辑,而数据访问则在 持久层 处理。

参考以下 index.ts 的实现,它展示了所有主要组件按顺序的简化版本:

**server/src/index.****ts**
...
export let server: http.Server
async function start() {
  await document.connect(config.MongoUri, config.IsProd)
  server = http.createServer(app)
  await useGraphQL(app)
  server.listen(config.Port, async () => {
    console.log(`Server listening on port ${config.Port}...`)
  })
}
start() 

注意,显示的最后一行代码 start() 是触发服务器初始化的功能调用。

现在,让我们来调查一下 Express 服务器是如何设置的。

服务器启动

app.ts 配置 Express.js,包括提供静态资源、路由和版本控制。Express.js 利用中间件函数与库或您的代码集成。中间件是在对 Express 服务器请求的生命周期中执行的函数。中间件函数可以访问请求和响应对象以及应用程序请求-响应周期中的下一个中间件函数。这种访问允许它们执行任何代码,进行更改,结束请求-响应周期,并调用堆栈中的下一个中间件。在下面的代码中,cors、logger 和 compression 是库函数,在章节的后面,我们将介绍自定义认证中间件的实现:

**server/src/app.****ts**
import api from './api'
const app = express()
app.use(cors())
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
app.use(logger('dev'))
app.use(compression())
app.use('/', express.static(path.join(__dirname, '../public'), { redirect: false }))
app.use(api)
export default app 

在前面的代码中,请注意,使用use()方法配置 Express 非常简单。首先,我们配置cors,然后是express解析器、loggercompression

接下来,使用express.static函数,我们在根路由/上提供public文件夹,这样我们就可以显示有关我们服务器的一些有用信息,如本章开头所示的图 7.1

最后,我们配置路由器,该路由器在api.ts中定义。

REST 路由和版本控制

api.ts配置 Express 路由器。请参考以下实现:

**server/src/api.****ts**
import { Router } from 'express'
import api_v1 from './v1'
import api_v2 from './v2'
const api = Router()
// Configure all routes here
api.use('/v1', api_v1)
api.use('/v2', api_v2)
export default api 

在这种情况下,我们有v1v2两个子路由。始终对实现的 API 进行版本控制是至关重要的。一旦 API 公开,简单地淘汰一个 API 以支持新版本可能会变得很棘手,有时甚至不可能。即使是微小的代码更改或 API 的细微差异也可能导致客户端崩溃。你必须仔细注意,只对你的 API 进行向后兼容的更改。

在某个时候,你可能需要完全重写端点以满足新的要求、性能和业务需求,此时,你可以简单地实现端点的v2版本,同时保持v1实现不变。这允许你以你需要的速度进行创新,同时保持你的应用程序的旧版消费者功能正常。

简而言之,你应该为创建的每个 API 进行版本控制。通过这样做,你迫使你的消费者对你的 API 的 HTTP 调用进行版本控制。随着时间的推移,你可以在不同的版本下过渡、复制和退役 API。消费者随后可以选择调用对他们有用的 API 版本。

配置路由很简单。让我们看看v2的配置,如下所示:

**server/src/v2/index.****ts**
import { Router } from 'express'
import userRouter from './routes/userRouter'
const router = Router()
// Configure all v2 routers here
router.use('/users?', userRouter)
export default router 

/users?结尾的问号意味着/user/users都将针对userRouter中实现的操作工作。这是一种避免拼写错误的同时允许开发者选择对操作有意义的复数形式的好方法。

userRouter中,你可以实现 GET、POST、PUT 和 DELETE 操作。请参考以下实现:

**server/src/v2/routes/userRouter.****ts**
const router = Router()
router.get('/', async (req: Request, res: Response) => {})
router.post('/', async (req: Request, res: Response) => {})
router.get('/:userId', async (req: Request, res: Response) => {})
router.put('/:userId', async (req: Request, res: Response) => {})
export default router 

在前面的代码中,你可以观察到路由参数的使用。你可以通过请求对象,如req.params.userId,来消费路由参数。

注意,示例代码中的所有路由都被标记为async,因为它们都会进行数据库调用,我们将等待这些调用。如果你的路由是同步的,那么你不需要async关键字。

接下来,让我们调查 GraphQL 解析器。

GraphQL 解析器

GraphQL 解析器在resolvers.ts中实现。GraphQL 服务器对查询进行广度优先遍历,并递归调用解析器以生成响应。

让我详细说明一下——当一个 GraphQL 服务器接收到一个查询时,它会逐层处理请求,从顶层字段开始,水平地穿过结构,就像在树的每一层移动之前先进行搜索,这被称为广度优先遍历。对于它遇到的每个字段,服务器将调用一个特定的函数,称为解析器,用于获取该字段的数据。如果一个字段复杂且包含嵌套子字段,该字段的解析器将依次调用其他解析器来获取每个子字段的数据。这个过程会重复进行,根据需要进入查询的层次结构,直到检索到查询的所有数据,并将其组装成与原始查询布局相匹配的结构化响应。

参考以下实现:

**server/src/graphql/resolvers.****ts**
export const resolvers = {
  Query: {
    me: () => ...,
    user: () => ...,
    users: () => ...,
  },
  Mutation: {
    login: () => ...,
    },
    createUser: () => ...,
    updateUser: () => ...,
  }, 
 a resolver function for each query and mutation implemented in the scheme. Each resolver takes in four arguments (parent, args, contextValue, info): parent can be used to access a parent resolver, args contains any input arguments passed in, contextValue stores session data useful for auth, and info contains metadata about the query itself. Next, let’s look at the type resolvers:
**server/src/graphql/resolvers.****ts**
  User: {
    id: (obj: User) => obj._id.toString(),
    role: (obj: User) => EnumValues.getNameFromValue(Role, obj.role),
    phones: (obj: User) => (obj.phones ? wrapAsArray(obj.phones) : []),
    dateOfBirth: (obj: User) => obj.dateOfBirth?.toISOString(),
  },
  Phone: {
    type: (obj: { type: string }) =>
      EnumValues.getNameFromValue(PhoneType, obj.type),
  },
  Users: {
    data: (obj: Users) => (obj.data ? wrapAsArray(obj.data) : []),
  },
} 

对于非标量类型、数组或枚举,我们可能需要提供转换,以便 GraphQL 可以适当地解包从数据库检索到的数据。好处是我们只需要为需要此类操作的对象的特定属性提供解析器。

解析器可能看起来很简单,但它们可以满足非常复杂的需求,例如,一个简单的客户端请求可能涉及多次调用服务和数据库,并将结果汇总成一个高效的响应,以便客户端可以显示它。

解析器的原子性质意味着我们只需要实现一次。接下来,让我们探索如何配置服务。

服务

我们不希望在表示我们的 API 层的路由器文件中实现我们的业务逻辑。API 层应主要包含转换数据和调用业务逻辑层。

您可以使用 Node.js 和 TypeScript 功能来实现服务。不需要复杂的依赖注入。示例应用程序实现了两个服务 - authServiceuserService

例如,在userService.ts中,您可以实现一个名为createNewUser的函数:

**server/src/services/userService.****ts**
import { IUser, User } from '../models/user'
export async function createNewUser(userData: IUser):
  Promise<User | boolean> {
  // create user
} 

createNewUser接受userData,其形状为IUser,当它完成用户创建后,返回一个User实例。然后我们可以在我们的路由器中如下使用此函数:

**server/src/v2/routes/userRouter.****ts**
import { createNewUser } from '../../services/userService'
router.post('/', async (req: Request, res: Response) => {
  const userData = req.body as IUser
  const success = await createNewUser(userData)
  if (success instanceof User) {
    res.send(success)
  } else {
    res.status(400).send({ message: 'Failed to create user.' })
  }
}) 

我们可以等待createNewUser的结果,如果成功,将创建的对象作为对 POST 请求的响应返回。

注意,尽管我们将req.body转换为IUser,但这只是一个开发时的便利功能。在运行时,消费者可能将任意数量的属性传递到主体中。粗心处理请求参数是您的代码可能被恶意利用的主要方式之一。

恭喜!现在,您已经很好地理解了我们的 Express 服务器是如何工作的。接下来,让我们看看如何连接到 MongoDB。

MongoDB ODM with DocumentTS

DocumentTS 充当一个ODM,实现了一层模型,以实现与数据库对象的丰富和可定制的交互。ODM 是关系数据库中对象关系映射器(ORM)的文档数据库等价物。想想 Hibernate 或 Entity Framework。如果您不熟悉这些概念,我建议您在继续之前进行进一步的研究。

要开始,您可以查看以下文章,MongoDB ORMs,ODMs 和库,在www.mongodb.com/developer/products/mongodb/mongodb-orms-odms-libraries

在其核心,DocumentTS 利用 MongoDB 的 Node.js 驱动程序。MongoDB 的制作者实现了这个驱动程序。它保证了最佳性能和与新 MongoDB 版本的功能一致性,而第三方库通常在支持新功能方面落后。通过使用database.getDbInstance方法,您可以直接访问原生驱动程序。否则,您将通过您实现的模型访问 Mongo。参考以下图表以获取概述:

图片

图 7.7:DocumentTS 概述

您可以在mongodb.github.io/node-mongodb-native/上了解更多关于 MongoDB 的 Node.js 驱动程序的信息。

有关 DocumentTS 的工作方式和配置细节的更多详细信息,请参阅 GitHub 上的项目 Wiki github.com/duluca/document-ts/wiki。Wiki 涵盖了连接到数据库、定义实现IDocument的模型以及配置数据的序列化和反序列化。模型允许包含计算属性,如fullName,在客户端响应中,同时排除如密码等字段。密码也被防止以明文形式保存到数据库中。

概述继续通过演示如何创建索引和使用聚合查询数据库。它为电子邮件创建了一个唯一索引,因此不能注册重复的电子邮件。一个加权文本索引有助于过滤查询结果。DocumentTS 旨在在原生 MongoDB 驱动程序之上提供一个方便且可选的层,以帮助构建完全异步的 Web 应用程序。开发者直接接触到 MongoDB 驱动程序,因此他们学习如何与数据库而不是仅仅与库一起工作。

让我们看看如何使用新的用户模型来获取数据。

实现 JWT 认证

第五章设计认证和授权中,我们讨论了实现基于 JWT 的认证机制。在 LemonMart 中,您实现了一个基础认证服务,它可以扩展为自定义认证服务。

我们将利用三个包来实现我们的实现:

  • jsonwebtoken: 用于创建和编码 JWT

  • bcryptjs: 用于在数据库中保存用户密码之前对其进行哈希和加盐,因此我们永远不会以明文形式存储用户的密码

  • uuid:一个生成的全局唯一标识符,当需要将用户的密码重置为随机值时非常有用

散列函数是一种一致可重复的单向加密方法,这意味着每次提供相同的输入时都会得到相同的输出,即使您有权访问散列值,也无法轻易地找出它存储的信息。然而,我们可以通过散列用户的输入并将其与存储的密码散列值进行比较,来验证用户是否输入了正确的密码。

认证服务在存储用户密码之前对其进行散列,并在登录时比较散列密码。createJwt函数在成功登录后生成 JWT 访问令牌。认证中间件解码 JWT 并将用户加载到响应流中,以便认证端点可以访问。

注意代码中不正确的电子邮件/密码消息的模糊性。这样做是为了防止恶意行为者利用认证系统。

对于密码散列,User模型的setPassword方法使用 bcrypt 的genSalthash函数。comparePassword方法将存储的散列密码与用户输入的散列密码进行比较。这确保密码永远不会以纯文本形式存储。

登录 API 端点通过电子邮件查找用户,调用comparePassword来验证密码,成功后调用createJwt生成包含电子邮件、角色等用户详情的已签名 JWT,并将 JWT 作为accessToken返回给客户端:

// Example of hashing and salting password
user.setPassword = async (password) => {
  const salt = await bcrypt.genSalt(10);
  return await bcrypt.hash(password, salt); 
} 

认证中间件解码 JWT,通过编码的id查找用户,并将用户注入到res.locals.currentUser中。像/me这样的认证端点可以方便地访问用户信息。它还通过检查如requiredRole之类的选项来处理基于角色的访问:

// Example of JWT-based login
router.post('/login', async (req, res) => {
  const user = await User.findByEmail(req.body.email);
  if (user && user.comparePassword(req.body.password)) {
    const accessToken = createJwt(user);
    return res.send({accessToken});
  }
  return res.status(401).send('Invalid credentials');
}) 

当通过电子邮件检索用户时,请记住电子邮件是不区分大小写的,因此您应该始终将输入转换为小写。您可以通过验证电子邮件并删除任何空白、脚本标签或甚至恶意 Unicode 字符来进一步改进此实现。考虑使用express-validatorexpress-sanitizer等库。

认证中间件

authenticate函数是我们可以在 API 实现中使用的中间件,以确保只有具有适当权限的认证用户可以访问端点。请记住,真正的安全性是在您的后端实现中实现的,而这个authenticate函数是您的守门人。

authenticate接受一个可空的options对象,使用requiredRole属性验证当前用户的角色,因此如果 API 配置如下,则只有经理可以访问该 API:

authenticate(**{** **requiredRole****:** **Role****.****Manager** **}**) 

在某些情况下,我们希望用户能够更新自己的记录,同时也允许经理更新其他所有人的记录。在这种情况下,我们利用permitIfSelf属性,如下所示:

authenticate({
    requiredRole: Role.Manager,
    **permitIfSelf****: {**
      **idGetter****:** **(****req: Request****) =>** **req.****body****.****_id****,**
      **requiredRoleCanOverride****:** **true****,**
    **},**
  }), 

在这种情况下,如果更新记录的 _id 与当前用户 _id 匹配,则用户可以更新自己的记录。由于 requiredRoleCanOverride 设置为 true,经理可以更新任何记录。如果设置为 false,则不允许这样做。通过混合和匹配这些属性,你可以覆盖大多数门控需求。

注意,idGetter 是一个函数委托,这样你就可以指定在 authenticate 中间件执行时 _id 属性应该如何访问。

查看以下简化版 authenticate 中间件及其使用的示例实现:

完整实现可以在 server/src/services/auth.service.ts 中找到。

// Authenticate middleware
function authenticate(options) {
  return async (req, res, next) => {
    const user = await decodeAndFindUser(req.headers.authorization);
    if (user) {
      // Check role if required
      if (options.requiredRole && user.role !== options.requiredRole) {
        return res.status(403).send("Forbidden");
      }

      // Attach user to response 
      res.locals.user = user;

      return next();
    } else {
      return res.status(401).send('Unauthenticated');
    }
  }
}
// Usage in RESTful route
router.get('/me', authenticate(), (req, res) 
  => res.send(res.locals.user)
) 

authenticate 方法作为 Express.js 中间件实现。它可以读取请求头中的授权令牌,验证提供的 JWT 的有效性,加载当前用户,并将其注入到响应流中,以便认证的 API 端点可以方便地访问当前用户的信息。这在上面的 me API 中显示。如果成功,中间件调用 next() 函数将控制权交还给 Express。如果失败,则无法调用 API。

注意,authenticateHelper 返回有用的错误消息,所以如果用户尝试执行他们无权执行的操作,他们不会感到困惑。

在 GraphQL 中,认证和授权是分开处理的。在 Express.js 层级上,我们将 authenticate 中间件应用于 /graphql 路由。然而,为了使探索、内省和登录函数正常工作,我们必须对规则进行例外处理。请参阅下面的代码,它实现了这种逻辑:

// GraphQL authentication
app.use('/graphql', authenticate({ 
    authOverridingOperations: ['Login'] 
  })
)
// Usage in GraphQL resolver
me: (parent, args, contextValue) => authorize(contextValue), 

查看 server/src/graphql/resolvers.ts 以了解认证实现的完整示例。

authOverridingOperations 属性通知 authenticate 允许调用内省和 Login 函数。现在对其他 GraphQL 函数的所有调用都将使用在解析器中可用的认证上下文进行认证。在解析器中,我们可以使用 authorize 方法(位于 server/src/graphql/helpers.ts)来检查请求者是否可以查看他们试图访问的资源。contextValue 存储会话上下文,类似于 Express 中的 res.local

接下来,让我们实现两个自定义认证提供者,一个用于 REST,另一个用于 GraphQL。

自定义服务器认证提供者

现在你已经理解了我们服务器中的认证实现,我们可以在 LemonMart 中实现一个自定义认证提供者,如第六章实现基于角色的导航所述:

你必须在你的 Angular 应用中实现这个自定义认证提供者。

本节的代码示例位于 lemon-mart-appappweb-app 文件夹中的 projects/stage10 文件夹。

  1. 首先,在 environment.ts 中创建一个 baseUrl 变量,以便我们可以连接到你的服务器。

  2. environment.tsenvironment.prod.ts 中实现一个 baseUrl 变量。

  3. 此外,选择 authModeAuthMode.CustomServer

    **web-app/src/environments/environment.****ts**
    **web-app/src/environments/environment.****prod****.****ts**
    export const environment = {
      ...
      baseUrl: 'http://localhost:3000',
      authMode: AuthMode.CustomServer, 
    
  4. 安装一个辅助库以编程方式访问 TypeScript 枚举值:

    $ npm i ts-enum-util 
    
  5. 如下所示,使用 HttpClient 实现基于 RESTful 的自定义身份验证提供者:

    **web-app/src/app/auth/auth.****custom****.****service****.****ts**
    import { $enum } from 'ts-enum-util'
    ...
    @Injectable(@Injectable({ providedIn: 'root' }))
    export class CustomAuthService extends AuthService {
      private httpClient: HttpClient = inject(HttpClient)
      protected authProvider(
        email: string,
        password: string
      ): Observable<IServerAuthResponse> {
        return this.httpClient.post<IServerAuthResponse>
          (`${environment.baseUrl}/v1/auth/login`, {
            email,
            password,
          })
          .pipe(first())
      }
      protected transformJwtToken(token: IJwtToken): IAuthStatus {
        return {
          isAuthenticated: token.email ? true : false,
          userId: token.sub,
          userRole: $enum(Role)
            .asValueOrDefault(token.role, Role.None),
          userEmail: token.email,
          userPicture: token.picture,
        } as IAuthStatus
      }
      protected getCurrentUser(): Observable<User> {
        return this.httpClient
          .get<IUser>(`${environment.baseUrl}/v1/auth/me`)
            .pipe(
            first(),
            map((user) => User.Build(user)),
              catchError(transformError)
            )
      }
    } 
    
  6. authProvider 方法调用我们的 /v1/auth/login 方法,而 getCurrentUser 调用 /v1/auth/me 来检索当前用户。

    确保对 login 方法的调用始终发生在 HTTPS 上。否则,你将在开放的互联网上发送用户凭据。这很容易让公共 Wi-Fi 网络上的窃听者窃取用户凭据。

  7. 如下所示,使用 Apollo Client 实现基于 GraphQL 的自定义身份验证提供者:

    **web-app/src/app/auth/auth.****graphql****.****custom****.****service****.****ts**
    import { GET_ME, LOGIN } from './auth.graphql.queries'
    ...
    @Injectable({ providedIn: 'root' })
    export class CustomGraphQLAuthService extends AuthService {
      private apollo: Apollo = inject(Apollo)
      protected authProvider(
        email: string,
        password: string
      ): Observable<IServerAuthResponse> {
        return this.apollo
          .mutate<{ login: IServerAuthResponse }>({
            mutation: LOGIN,
            variables: {
              email,
              password,
            },
          })
          .pipe(
            first(),
            map((result) => 
              result.data?.login as IServerAuthResponse
            )
          )
        }
      protected transformJwtToken(token: IJwtToken): IAuthStatus {
        return {
          isAuthenticated: token.email ? true : false,
          userId: token.sub,
          userRole: $enum(Role).asValueOrDefault(
            token.role,
            Role.None
          ),
          userEmail: token.email,
          userPicture: token.picture,
        } as IAuthStatus
      }
      protected getCurrentUser(): Observable<User> {
        return this.apollo
          .watchQuery<{ me: IUser }>({
            query: GET_ME,
          })
          .valueChanges.pipe(
            first(),
            map((result) => User.Build(result.data.me))
          )
      }
    } 
    

    注意,LOGIN 变异和 Me 查询是在 auth.graphql.queries.ts 中实现的。否则,它们会占用太多空间,使得服务代码难以阅读。

  8. 更新 authFactory 以返回 AuthMode.CustomServer 选项的新提供者:

    **web-app/src/app/auth/auth.****factory****.****ts**
    export function authFactory() {
      ...
      case AuthMode.CustomServer:
        return new CustomAuthService()
      case AuthMode.CustomGraphQL:
        return new CustomGraphQLAuthService()
    } 
    
  9. 启动你的 Web 应用程序以确保一切正常工作。

恭喜!你现在已经掌握了代码在整个软件栈中的工作方式,从数据库到前端和后端。

摘要

在本章中,我们介绍了全栈架构。你学习了如何构建最小化的 MEAN 栈。你现在知道如何为全栈应用程序创建 monorepo 并配置 TypeScript 的 Node.js 服务器。你学习了 monorepos、容器化 Node.js 服务器以及使用 Docker Compose 声明性地定义基础设施。通过使用 Docker Compose 与 CircleCI,我们看到了如何在 CI 环境中验证你的基础设施。

你学习了如何使用 Apollo 和 OpenAPI 以及 GraphQL 设计 RESTful API,设置 Express.js 应用程序,并配置它以便为你的 API 生成交互式文档。你了解了使用 DocumentTS 与 MongoDB 一起使用的优势。

然后,你实现了一个基于 JWT 的身份验证服务,使用 authenticate 中间件来保护 API 端点并允许 RBAC。最后,你在 Angular 中实现了两个自定义身份验证提供者。对于 REST,我们使用了 HttpClient,而对于 GraphQL,我们使用了 Apollo Client。

接下来的两章将探索 Angular 的配方来创建表单和数据表。在第八章 配方 – 可重用性、表单和缓存 和第九章 配方 – 主/详细信息、数据表和 NgRx 中,我们将通过坚持解耦组件架构、明智地选择创建用户控件和组件以及最大化代码重用,结合各种 TypeScript、RxJS、NgRx 和 Angular 编码技术来整合一切。

在本书的剩余部分,你将需要确保你的 LemonMart 服务器和 MongoDB 实例运行正常,以便在实现表单和表格时验证它们的功能是否正确。

练习

你使用authenticate中间件来保护你的端点。你配置了 Postman 发送有效的令牌,以便你可以与受保护的端点通信。作为一个练习,尝试移除authenticate中间件,并使用有效的令牌和无令牌调用相同的端点。重新添加中间件,然后再次尝试相同的事情。观察服务器返回的不同响应。

进一步阅读

问题

回答以下问题以确保你已理解本章的关键概念,无需使用谷歌搜索。你知道你是否答对了所有问题吗?访问angularforenterprise.com/self-assessment获取更多信息:

  1. 构成优秀开发者体验的主要组件有哪些?

  2. .env文件有什么用途?

  3. authenticate中间件的目的是什么?

  4. Docker Compose 与使用 Dockerfile 有何不同?

  5. ODM 是什么?它与 ORM 有何不同?

  6. 中间件是什么?

  7. OpenAPI 规范有哪些用途?

  8. 你会如何重构userRouter.ts中的/v2/users/{id} PUT端点的代码,以便代码可重用?

  9. REST 和 GraphQL 之间有哪些主要区别?

  10. OpenAPI 和 GraphQL 模式之间有哪些相似之处?

加入我们的 Discord 社区

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

packt.link/AngularEnterpise3e

二维码

第八章:菜谱 – 可重用性、表单和缓存

在接下来的两个章节中,我们将完成 LemonMart 的大部分实现,并完善我们对路由优先方法的覆盖。在本章中,我将通过创建一个可重用可路由的组件来支持数据绑定来强化解耦组件架构的概念。我们将使用Angular 指令来减少样板代码,并利用类、接口、枚举、验证器和管道,通过 TypeScript 和 ES 功能最大化代码重用。

此外,我们还将创建一个多步骤表单,它在架构上具有良好的扩展性,并支持响应式设计。然后,我们将通过引入一个柠檬评分器和一个封装了名称对象的可重用表单部分来区分用户控件和组件。

本章涵盖了大量的内容。它以菜谱格式组织,因此你可以在处理项目时快速参考特定的实现。我将涵盖实现架构、设计和主要组件。我将突出显示重要的代码片段来解释解决方案是如何组合在一起的。利用你迄今为止所学到的知识,我期望你填写常规实现和配置细节。然而,如果你遇到困难,你始终可以参考 GitHub 项目。

在本章中,你将学习以下主题:

  • 使用缓存实现 CRUD 服务

  • 多步骤响应式表单

  • 使用指令重用重复模板行为

  • 计算属性和 DatePicker

  • 自动完成支持

  • 动态表单数组

  • 创建共享组件

  • 审查和保存表单数据

  • 具有可重用部分的可扩展表单架构

  • 输入掩码

  • 使用ControlValueAccessor的自定义控件

  • 使用网格列表布局

  • 恢复缓存数据

技术要求

书籍的示例代码的最新版本可在以下步骤中链接的 GitHub 仓库中找到。该仓库包含代码的最终和完成状态。你可以在本章末尾通过查看projects文件夹下的代码末尾快照来验证你的进度。

对于第八章

确保服务端lemon-mart-server正在运行。请参阅第七章与 REST 和 GraphQL API 一起工作

  1. github.com/duluca/lemon-mart克隆仓库。

  2. 在根目录中执行npm install以安装依赖项。

  3. 项目的初始状态反映在:

    projects/stage10 
    
  4. 项目的最终状态反映在:

    projects/stage11 
    
  5. 将阶段名称添加到任何ng命令中,以仅对该阶段执行操作:

    npx ng build stage11 
    

注意,仓库根目录下的dist/stage11文件夹将包含编译结果。

请注意,书中提供的源代码和 GitHub 上的版本可能会有所不同。这些项目周围的生态系统一直在不断发展。在 Angular CLI 生成新代码的方式、错误修复、库的新版本或多种技术的并行实现之间,有很多变化是无法预料的。如果您发现错误或有疑问,请创建一个 issue 或在 GitHub 上提交一个 pull request。

让我们从实现一个用户服务来检索数据并构建一个用于显示和编辑个人资料信息的表单开始。稍后,我们将重构这个表单,以抽象出其可重用部分。

实现具有缓存的 CRUD 服务

我们需要一个能够对用户执行 CRUD 操作的服务,以便我们可以实现用户资料。然而,该服务必须足够健壮,以承受常见的错误。毕竟,当用户无意中丢失他们输入的数据时,用户体验非常糟糕。表单数据可能会因为超出用户控制的情况而重置,比如网络或验证错误,或者用户错误,比如不小心点击了后退或刷新按钮。我们将创建一个利用我们在 第五章 中构建的 CacheService 的用户服务,在服务器处理数据的同时,将用户数据保存在 localStorage 中。该服务将实现以下接口,并且,像往常一样,引用抽象的 IUser 接口而不是具体的用户实现:

export interface IUserService {
  getUser(id: string): Observable<IUser>
  updateUser(id: string, user: IUser): Observable<IUser>
  getUsers(
    pageSize: number,
    searchText: string,
    pagesToSkip: number
  ): Observable<IUsers>
} 

在创建服务之前,启动 lemon-mart-server 并将您的应用程序的 AuthMode 设置为 CustomServer

您可以使用 npm run start:backend 来启动数据库和服务器。

在本节中,我们将实现 getUserupdateUser 函数。我们将在 第九章 中实现 getUsers食谱 – 主/详细信息、数据表和 NgRx,以支持数据表分页。

首先创建用户服务:

  1. src/app/user/user 下创建一个 UserService

  2. 从前面的代码片段中声明 IUserService 接口,不包括 getUsers 函数。

  3. 确保 UserService 类实现了 IUserService

  4. CacheServiceHttpClientAuthService 注入,如下所示:

    **src/app/user/user/user.****service****.****ts**
    export interface IUserService {
      getUser(id: string): Observable<Iuser>
      updateUser(id: string, user: Iuser): Observable<Iuser>
    }
    @Injectable({
      providedIn: 'root',
    })
    export class UserService implements IUserService {
      private readonly cache = inject(CacheService)
      private readonly httpClient = inject(HttpClient)
      private readonly authService = inject(AuthService)
      getUser(id: string): Observable<IUser> {
        throw new Error('Method not implemented.')
      }
      updateUser(id: string, user: IUser): Observable<IUser> {
        throw new Error('Method not implemented.')
      }
    } 
    
  5. 实现如下的 getUser 函数:

    **src/app/user/user/user.****service****.****ts**
    getUser(id: string | null): Observable<IUser> {
      if (id === null) {
        return throwError('User id is not set')
      }
      return this.httpClient.get<IUser>(
        `${environment.baseUrl}/v2/user/${id}`
      )
    } 
    

我们提供了一个可以加载任何用户个人资料信息的 getUser 函数。请注意,此函数的安全性由服务器实现中的 authenticate 中间件提供。请求者可以获取他们的个人资料,或者需要是管理员。我们将在本章后面使用 getUser 与 resolve 守卫一起使用。

更新缓存

实现 updateUser,它接受一个实现 IUser 接口的对象,以便可以将数据发送到 PUT 端点:

**src/app/user/user/user.****service****.****ts**
  updateUser(id: string, user: IUser): Observable<IUser> {
    if (id === '') {
      return throwError('User id is not set')
    }
    // cache user data in case of errors
    this.cache.setItem('draft-user', Object.assign(user, { _id: id }))
    const updateResponse$ = this.httpClient
      .put<IUser>(`${environment.baseUrl}/v2/user/${id}`, user)
      .pipe(map(User.Build), catchError(transformError))
    updateResponse$.subscribe(
      (res) => {
        this.authService.currentUser$.next(res)
        this.cache.removeItem('draft-user')
      },
      (err) => throwError(err)
    )
    return updateResponse$
  } 

注意缓存服务是如何使用 setItem 来保存用户输入的数据,如果 put 调用失败。当调用成功时,我们使用 removeItem 删除缓存数据。此外,注意我们是如何使用 map(User.Build) 将从服务器返回的用户作为 User 对象进行填充,这调用的是 class User 的构造函数。

填充是用于从数据库或网络请求中填充对象数据的常用术语。例如,我们在组件之间传递或从服务器接收的 User JSON 对象符合 IUser 接口,但不是 class User 类型。我们使用 toJSON 方法将对象序列化为 JSON。当我们填充并从 JSON 实例化一个新对象时,我们会反转并反序列化数据。

需要强调的是,在传递数据时,你应该始终坚持使用接口,而不是像 User 这样的具体实现。这是 SOLID 原则中的 D(依赖倒置原则)。引用像 User 这样的具体实现会带来很多风险,因为它们经常变化,而像 IUser 这样的抽象则很少变化。毕竟,你不会直接将灯泡焊接在墙上的电线中。相反,你会将灯泡焊接在插头上,然后使用插头获取所需的电力。

代码完成后,UserService 现在可以用于基本的 CRUD 操作。

多步骤响应式表单

总体而言,表单与你的应用程序的其他部分不同,它们需要特殊的架构考虑。我不建议使用动态模板或启用路由的组件过度设计你的表单解决方案。根据定义,表单的不同部分是紧密耦合的。从可维护性和易于实现的角度来看,创建一个巨大的组件比使用上述一些策略和过度设计更好。

我们将实现一个多步骤输入表单,在单个组件中捕获用户配置文件信息。我将在本章的“可重用表单部分和可扩展性”部分介绍我推荐的将表单拆分为多个组件的技术。

由于表单的实现在这部分和章节后面的部分之间发生了巨大变化,你可以在 GitHub 上的 projects/stage11/src/app/user/profile/profile.initial.component.tsprojects/stage11/src/app/user/profile/profile.initial.component.html 找到初始版本的代码。

我们还将使用媒体查询使这个多步骤表单对移动设备做出响应:

  1. 让我们先添加一些辅助数据,这将帮助我们显示带有选项的输入表单:

    **src/app/user/profile/data.****ts**
    export interface IUSState {
      code: string
      name: string
    }
    export function USStateFilter(value: string): IUSState[] {
      return USStates.filter((state) => {
        return (
          (state.code.length === 2 && 
           state.code.toLowerCase() === value.toLowerCase()) ||
           state.name.toLowerCase().indexOf(value.toLowerCase()) === 0
        )
      })
    }
    const USStates = [
      { code: 'AK', name: 'Alaska' },
      { code: 'AL', name: 'Alabama' },
      ...
      { code: 'WY', name: 'Wyoming' },
    ] 
    
  2. common/validations.ts 中添加新的验证规则:

    **src/app/common/validations.****ts**
    ...
    export const OptionalTextValidation = [
      Validators.minLength(2), 
      Validators.maxLength(50)
    ]
    export const RequiredTextValidation = 
      OptionalTextValidation.concat([Validators.required])
    export const OneCharValidation = [
      Validators.minLength(1), 
      Validators.maxLength(1)
    ] 
    export const USAZipCodeValidation = [ 
      Validators.required, 
      Validators.pattern(/^\d{5}(?:[-\s]\d{4})?$/),
    ]
    export const USAPhoneNumberValidation = [ 
      Validators.required, 
      Validators.pattern(/^\D?(\d{3})\D?\D?(\d{3})\D?(\d{4})$/), 
    ] 
    
  3. 现在,按照以下方式实现 profile.component.ts

    **src/app/user/profile/profile.****component****.****ts**
    import { Role } from '../../auth/auth.enum' 
    import { $enum } from 'ts-enum-util'
    import { IName, IPhone, IUser, PhoneType } from '../user/user'
    ...
    @Component({ 
      selector: 'app-profile', 
      templateUrl: './profile.component.html', 
      styleUrls: ['./profile.component.css'],
    })
    export class ProfileComponent implements OnInit { 
      Role = Role 
      PhoneType = PhoneType 
      PhoneTypes = $enum(PhoneType).getKeys() 
      formGroup: FormGroup 
      states$: Observable<IUSState[]> 
      userError = '' 
      currentUserId: string 
      constructor( 
        private formBuilder: FormBuilder, 
        private uiService: UiService, 
        private userService: UserService, 
        private authService: AuthService
      ) {}
      private destroyRef = inject(DestroyRef)
      ngOnInit() { 
        this.buildForm() 
        this.authService.currentUser$.pipe(
          filter((user) => user !== null), 
          tap((user) => { 
            this.currentUserId = user._id 
            this.buildForm(user) 
          }),
          takeUntilDestroyed(this.destroyRef) 
        ).subscribe()
      }
      private get currentUserRole() { 
        return this.authService.authStatus$.value.userRole 
      }
      buildForm(user?: IUser) {}
      ...
    } 
    

在加载时,我们从authService请求当前用户,但这可能需要一些时间,所以我们首先使用this.buildForm()作为第一条语句构建一个空表单。我们还将在currentUserId属性中存储用户的 ID,我们将在实现save功能时需要它。

注意,我们过滤掉nullundefined用户,以避免尝试在无效状态下构建表单。

上述实现中,如果从 API 获取authService.currentUser$时,会引入一个 UX 问题。如果 API 需要超过半秒钟(实际上,340 毫秒)来返回数据,表单上会出现明显的新信息弹出。这将覆盖用户可能已经输入的任何文本。

为了防止这种情况,我们可以在收到信息后禁用和重新启用表单。然而,该组件并不知道信息来自何处;它只是订阅了authService.currentUser$,它可能永远不会返回值。即使我们能够可靠地判断我们正在从 API 接收数据,我们也必须在每个组件中实现一个定制的解决方案。

使用HttpInterceptor,我们可以全局检测 API 调用何时被触发和完成;我们可以暴露一个signal,让组件可以单独订阅以显示加载指示器,或者我们可以利用UiService来启动全局加载指示器,在从服务器获取数据时阻塞 UI。在第九章食谱 – 主/详情,数据表和 NgRx中,我介绍了如何实现全局加载指示器。

全局加载指示器是 80-20 的终极解决方案。然而,您可能会发现,在具有数十个组件持续检索数据的大型应用程序中,全局加载指示器可能不可行。复杂的 UI 需要昂贵的 UX 解决方案。在这种情况下,您确实需要实现一个组件级别的加载指示器。这在第九章的数据表分页部分进行了演示,食谱 – 主/详情,数据表和 NgRx

在本章的后面部分,我们将实现一个基于路由提供的userId来加载用户的解析守卫,以提高该组件的可重用性。

表单控件和表单组

如您所忆,FormControl对象是表单的最基本部分,通常代表单个输入字段。我们可以使用FormGroup将一组相关的FormControl对象组合在一起,例如一个人的名字的单独的姓、名和姓。FormGroup对象也可以将FormControlFormGroupFormArray对象的混合组合在一起。后者允许我们拥有动态重复的元素。FormArray将在本章的动态表单数组部分进行介绍。

我们的表单有许多输入字段,因此我们将使用由this.formBuilder.group创建的FormGroup来存放我们的各种FormControl对象。此外,子FormGroup对象将允许我们保持数据结构的正确形状。

由于表单的实现在这部分和章节后面的部分之间有显著变化,你可以在 GitHub 上找到初始版本的代码,地址为projects/stage11/src/app/user/profile/profile.initial.component.tsprojects/stage11/src/app/user/profile/profile.initial.component.html

开始构建buildForm函数,如下所示:

**src/app/user/profile/profile.****component****.****ts**
...
  buildForm(user?: IUser) { 
    this.formGroup =
    this.formBuilder.group({
      email: [
        {
          value: user?.email || '',
          disabled: this.currentUserRole !== Role.Manager,
        },
        EmailValidation,
      ],
      name: this.formBuilder.group({
        first: [user?.name?.first || '', RequiredTextValidation],
        middle: [user?.name?.middle || '', OneCharValidation],
        last: [user?.name?.last || '', RequiredTextValidation],
      }),
      role: [
        {
          value: user?.role || '',
          disabled: this.currentUserRole !== Role.Manager,
        },
        [Validators.required],
      ],
      dateOfBirth: [user?.dateOfBirth || '', Validators.required], 
      address: this.formBuilder.group({
        line1: [user?.address?.line1 || '', RequiredTextValidation],
        line2: [user?.address?.line2 || '', OptionalTextValidation],
        city: [user?.address?.city || '', RequiredTextValidation],
        state: [user?.address?.state || '', RequiredTextValidation],
        zip: [user?.address?.zip || '', USAZipCodeValidation],
      }),
    })
  } 

buildForm可以接受一个IUser来预填充表单。否则,所有字段都设置为它们的默认值。formGroup属性本身是顶级FormGroup。根据需要,向其中添加各种FormControls,例如email,并附加必要的验证器。

注意nameaddress是它们自己的FormGroup对象。这种父子关系确保了表单数据序列化到 JSON 时的正确结构,以适应IUser的结构,这样我们的应用程序和服务器端代码就可以利用它。

你将通过遵循本章提供的示例代码独立完成formGroup的实现。在接下来的几节中,我将逐段审查代码,解释某些关键功能。

步进器和响应式布局

Angular Material 的步进器自带MatStepperModule。步进器允许将表单输入分成多个步骤,这样用户就不会同时处理数十个输入字段而感到不知所措。用户仍然可以跟踪他们在过程中的位置,作为副作用,作为开发者,我们可以将我们的<form>实现拆分,并逐步实施验证规则,或者创建可选的工作流程,其中某些步骤可以跳过或为必填项。与所有 Material 用户控件一样,步进器已经设计为具有响应式 UX。在接下来的几节中,我们将实现三个步骤,涵盖过程中的不同表单输入技术:

  1. 账户信息:

    • 输入验证

    • 响应式布局与媒体查询

    • 计算属性

    • DatePicker

  2. 联系信息:

    • 自动完成支持

    • 动态表单数组

  3. 复习:

    • 只读视图

    • 保存和清除数据

让我们先添加 Angular material 依赖项:

  1. profile.component.ts中导入以下 Material 模块:

    MatAutocompleteModule,
    MatButtonModule,
    MatDatepickerModule,
    MatFormFieldModule,
    MatIconModule,
    MatInputModule,
    MatListModule,
    MatNativeDateModule,
    MatOptionModule,
    MatRadioModule,
    MatSelectModule,
    MatStepperModule,
    MatToolbarModule, 
    
  2. 导入其他支持模块:

    FlexModule,
    ReactiveFormsModule,
    ... 
    
  3. 实现包含第一步的横向步进器表单:

    **src/app/user/profile/profile.component.html**
    <mat-toolbar color="accent">
    <h5>User Profile</h5>
    </mat-toolbar>
    <mat-horizontal-stepper #stepper="matHorizontalStepper">
      <mat-step [stepControl]="formGroup">
        <form [formGroup]="formGroup">
          <ng-template matStepLabel>Account Information</ng-template>
          <div class="stepContent">
            ...
          </div>
        </form>
      </mat-step>
    </mat-horizontal-stepper> 
    
  4. 现在,开始实现上一步骤中省略号的Account Information步骤的name行:

    **src/app/user/profile/profile.component.html**
    <div fxLayout="row" fxLayout.lt-sm="column"   
         [formGroup]="formGroup.get('name')" fxLayoutGap="10px">
      <mat-form-field appearance="outline" fxFlex="40%">
        <input matInput placeholder="First Name"
               aria-label="First Name" formControlName="first">
          @if (formGroup.get('name.first')?.hasError('required'))
          {
             <mat-error>First Name is required</mat-error>
          }
          @if (formGroup.get('name.first')?.hasError('minLength'))
          {
            <mat-error>Must be at least 2 characters</mat-error>
          }
          @if (formGroup.get('name.first')?.hasError('maxLength'))
          {
            <mat-error>Can't exceed 50 characters</mat-error>
          }
      </mat-form-field>
      <mat-form-field appearance="outline" fxFlex="20%">
        <input matInput placeholder="MI" 
          aria-label="Middle Initial" formControlName="middle">
          @if (formGroup.get('name.middle')?.hasError('invalid'))
          {
            <mat-error>Only initial</mat-error>
          }
      </mat-form-field>
      <mat-form-field appearance="outline" fxFlex="40%">
        <input matInput placeholder="Last Name" 
          aria-label="Last Name" formControlName="last">
          @if (formGroup.get('name.last')?.hasError('required'))
          {
            <mat-error>Last Name is required</mat-error>
          }
          @if (formGroup.get('name.last')?.hasError('minLength'))
          {
            <mat-error>Must be at least 2 characters</mat-error>
          }
          @if (formGroup.get('name.last')?.hasError('maxLength'))
          {
            <mat-error>Can't exceed 50 characters</mat-error>
          }  
      </mat-form-field>
    </div> 
    
  5. 仔细理解到目前为止的步进器和表单配置工作原理。你应该能看到第一行渲染,从lemon-mart-server获取数据:

    图 8.1:多步表单 – 第一步

注意,将fxLayout.lt-sm="column"添加到具有fxLayout="row"的行中,可以为表单启用响应式布局,如下所示:

图 8.2:移动端多步表单

在我们继续介绍如何实现出生日期字段之前,让我们通过实现错误消息来重新评估我们的策略。

使用指令重用重复的模板行为

在上一节中,我们为 name 对象的每个字段部分的每个验证错误都实现了一个 mat-error 元素。对于三个字段,这会迅速增加到七个元素。在 第五章设计身份验证和授权 中,我们实现了 common/validations.ts 以重用验证规则。我们可以使用属性指令重用我们在 mat-error 中实现的行为,或者任何其他 div

属性指令

第一章Angular 的架构和概念 中,我提到 Angular 组件代表 Angular 应用中最基本的单元。通过组件,我们定义可以重用模板和一些 TypeScript 代码所表示的功能和特性的自定义 HTML 元素。相反,指令增强了现有元素或组件的功能。从某种意义上说,组件是一个增强基本 HTML 功能的超指令。

基于这个观点,我们可以定义三种类型的指令:

  • 组件

  • 结构性指令

  • 属性指令

基本上,组件是带有模板的指令;这是你将最常使用的指令类型。结构性指令通过添加或删除元素来修改 DOM,*ngIf*ngFor 是典型的例子。

截至 Angular 17,你可以使用 @-语法来实现控制流和可延迟视图,分别用 @if@for 替代 *ngIf*ngFor。下面是一个示例代码片段:

@if (user.isHuman) {
  <human-profile [data]="user" />
} @else if (user.isRobot) {
  <!-- robot users are rare, so load their profiles lazily -->
  @defer {
    <robot-profile [data]="user" />
  }
} @else {
  <p>The profile is unknown!
} 

最后,属性指令允许你定义可以添加到 HTML 元素或组件中的新属性,添加新的行为。

让我们实现一个可以封装字段级错误行为的属性指令。

字段错误属性指令

想象一下使用指令来减少显示字段错误时的重复元素。以下是一个使用姓名字段作为示例的例子:

**example**
<mat-form-field appearance="outline" fxFlex="40%">
  <mat-label>First Name</mat-label>
  <input matInput aria-label="First Name" formControlName="first" #name />
  <mat-error **[****input****]=****"name"** **[****group****]=****"formGroup.get('name')"**
    **[****appFieldError****]=****"ErrorSets.RequiredText"****>**
  </mat-error>
</mat-form-field> 

我们有 Material 表单字段的常规布局结构,但只有一个 mat-error 元素。mat-error 上有三个新属性:

  • input 绑定到被标记为 #name 的 HTML 输入元素,使用模板引用变量,这样我们就可以监听输入元素的 blur 事件并读取 placeholderaria-labelformControlName 属性。

  • group 绑定到包含表单控件的父 FormGroup 对象,因此通过使用输入的 formControlName 属性,我们可以检索 formControl 对象,同时避免额外的代码。

  • appFieldError 绑定到一个数组,该数组包含必须与 formControl 对象(如 requiredminlengthmaxlengthinvalid)进行校验的验证错误。

使用前面的信息,我们可以创建一个指令,可以在 mat-error 元素内渲染一行或多行错误信息,有效地复制我们在上一节中使用的方法。

让我们继续创建一个名为 FieldErrorDirective 的属性指令:

  1. src/app/user-controls下创建FieldErrorDirective

  2. 将指令的选择器定义为名为appFieldError的可绑定属性:

    **src/app/user-controls/field-error/field-error.****directive****.****ts**
    @Directive({
      selector: **'[appFieldError]'**,
    }) 
    
  3. 在指令之外,定义两个新类型ValidationErrorValidationErrorTuple,它们定义我们将要处理的错误条件类型以及允许我们将自定义错误消息附加到错误类型上的结构:

    **src/app/user-controls/field-error/field-error.****directive****.****ts**
    export type ValidationError = 
       'required' | 'minlength' | 'maxlength' | 'invalid'
    export type ValidationErrorTuple = {
      error: ValidationError;
      message: string
    } 
    
  4. 就像我们分组验证一样,让我们定义两组常见的错误条件,这样我们就不必一遍又一遍地输入它们:

    **src/app/user-controls/field-error/field-error.****directive****.****ts**
    export const ErrorSets: { [key: string]: ValidationError[] } = {
      OptionalText: ['minlength', 'maxlength'],
      RequiredText: ['minlength', 'maxlength', 'required'],
    } 
    
  5. 接下来,让我们定义指令的@Input目标:

    **src/app/user-controls/field-error/field-error.****directive****.****ts**
    export class FieldErrorDirective implements OnDestroy, OnChanges {
      @Input() appFieldError:
        | ValidationError
        | ValidationError[]
        | ValidationErrorTuple
        | ValidationErrorTuple[]
      @Input() input: HTMLInputElement | undefined
      @Input() group: FormGroup
      @Input() fieldControl: AbstractControl | null
      @Input() fieldLabel: string | undefined 
    

    注意,我们已经讨论了前三个属性的目的。fieldControlfieldLabel是可选属性。如果指定了inputgroup,则可选属性可以自动填充。由于它们是类级变量,如果用户想要覆盖指令的默认行为,则公开它们是有意义的。这是一个容易的胜利,可以创建灵活且可重用的控件。

  6. constructor中导入元素引用,这可以在稍后由renderErrors函数用于在mat-error元素的内部 HTML 中显示错误:

    **src/app/user-controls/field-error/field-error.****directive****.****ts**
      private readonly nativeElement: HTMLElement
      constructor(private el: ElementRef) {
        this.nativeElement = this.el.nativeElement
      }
      renderErrors(errors: string) {
        this.nativeElement.innerText = errors
      } 
    
  7. 实现一个函数,该函数可以根据错误类型返回预定义的错误消息:

    **src/app/user-controls/field-error/field-error.****directive****.****ts**
      getStandardErrorMessage(error: ValidationError): string {
        const label = this.fieldLabel || 'Input'
        switch (error) {
          case 'required':
            return `${label} is required`
          case 'minlength':
            return `${label} must be at least ${
              this.fieldControl?.getError(error)?.requiredLength ?? 2
            } characters`
          case 'maxlength':
            return `${label} can\'t exceed ${
              this.fieldControl?.getError(error)?.requiredLength ?? 50
            } characters`
          case 'invalid':
            return `A valid ${label} is required`
        }
      } 
    

    注意,我们可以从fieldControl动态提取所需的minlengthmaxlength,这大大减少了我们需要生成的自定义消息的数量。

  8. 实现一个算法,该算法可以使用getStandardErrorMessage方法遍历appFieldError中的所有元素和需要显示的错误:

    **src/app/user-controls/field-error/field-error.****directive****.****ts**
    updateErrorMessage() {
        const errorsToDisplay: string[] = []
        const errors = Array.isArray(this.appFieldError)
          ? this.appFieldError
          : [this.appFieldError]
        errors.forEach(
          (error: ValidationError 
                | { error: ValidationError; message: string }) => {
            const errorCode = 
              typeof error === 'object' ? error.error : error 
            const message =
              typeof error === 'object'
                ? () => error.message
                : () => this.getStandardErrorMessage(errorCode)
            const errorChecker =
              errorCode === 'invalid'
                ? () => this.fieldControl?.invalid
                : () => this.fieldControl?.hasError(errorCode)
            if (errorChecker()) {
              errorsToDisplay.push(message())
            }
          }
        )
        this.renderErrors(errorsToDisplay.join('<br>'))
      } 
    

    最终,我们可以使用renderErrors方法显示错误信息。

    注意函数委托的使用,这是一种允许函数被传递并用作变量的技术。由于此代码每分钟将执行数百次,因此避免不必要的调用很重要。函数委托有助于更好地组织我们的代码,并在绝对必要时才延迟其逻辑的执行。这种编码模式允许使用记忆技术进一步提高性能。有关更多详细信息,请参阅进一步阅读部分。

  9. 现在,初始化fieldControl属性,它代表一个formControl。我们将监听控制的valueChanges事件,如果验证状态无效,则执行我们的自定义updateErrorMessage逻辑来显示错误信息:

    **src/app/user-controls/field-error/field-error.****directive****.****ts**
    private controlSubscription: Subscription | undefined
    ngOnDestroy(): void {
      this.unsubscribe()
    }
    unsubscribe(): void {
      this.controlSubscription?.unsubscribe()
    }
    initFieldControl() {
        if (this.input && this.group) {
          const controlName = this.input.
            getAttribute('formControlName') ?? ''
          this.fieldControl =
            this.fieldControl || this.group.get(controlName)
          if (!this.fieldControl) {
            throw new Error( 
              `[appFieldError] couldn't bind to control ${controlName}`
            )
          }
          this.unsubscribe()
          this.controlSubscription = this.fieldControl?.valueChanges
            .pipe(
              filter(() => this.fieldControl?.status === 'INVALID'),
              tap(() => this.updateErrorMessage())
            )
            .subscribe()
        }
      } 
    

    注意,由于我们正在订阅valueChanges,我们也必须取消订阅。我们使用ngOnDestroy取消订阅一次,然后在再次订阅之前再次取消订阅。这是因为initFieldControl可能会被多次调用。如果我们不清除之前的订阅,将导致内存泄漏和相关性能问题。

    此外,如果我们无法绑定到fieldControl,我们会抛出一个错误信息,这通常指向一个编码错误。

  10. 最后,我们使用 ngOnChanges 事件来配置所有主要属性,该事件在 @Input 属性更新时触发。这确保了,在表单元素可能动态添加或删除的情况下,我们始终考虑最新的值。我们调用 initFieldControl 来开始监听值的变化,实现一个触发 updateErrorMessage()onblur 事件处理器,并为 HTML 输入元素分配 fieldLabel 的值:

    **src/app/user-controls/field-error/field-error.****directive****.****ts**
      ngOnChanges(changes: SimpleChanges): void {
        this.initFieldControl()
        if (changes.input.firstChange) {
          if (this.input) {
            this.input.onblur = () => this.updateErrorMessage()
            this.fieldLabel =
              this.fieldLabel ||
              this.input.placeholder ||
              this.input.getAttribute('aria-label') ||
              ''
          } else {
            throw new Error(
              `appFieldError.[input] couldn't bind to any input element`
            )
          }
        }
      } 
    

注意,如果我们无法绑定到 HTML input 元素,这通常意味着开发者忘记正确连接这些元素。在这种情况下,我们抛出一个新的 Error 对象,这在控制台中生成一个有用的堆栈跟踪,以便你可以确定模板中错误发生的位置。

这完成了指令的实现。现在,我们需要将指令打包到一个名为 field-error.module.ts 的模块中:

**src/app/user-controls/field-error/field-error.****directive****.****ts**
  @NgModule({
  imports: [CommonModule, ReactiveFormsModule],
  declarations: [FieldErrorDirective],
  exports: [FieldErrorDirective],
})
export class FieldErrorModule {} 

现在,继续在我们的现有表单中使用这个指令:

  1. app.module.tsuser.module.ts 中导入该模块。

  2. 使用新指令更新 profile.component.html

  3. 使用新指令更新 login.component.html

确保在 component 类中将 ErrorSets 定义为一个公共属性变量,以便你可以在模板中使用它。

测试你的表单,以确保我们的验证消息按预期显示,并且没有控制台错误。

恭喜!你已经学会了如何使用指令将新行为注入到其他元素和组件中。通过这样做,我们可以避免大量的重复代码,并在我们的应用程序中标准化错误消息。

在继续之前,通过查看 GitHub 上的实现来完成表单的实现。你可以在 projects/stage11/src/app/user/profile/profile.initial.component.html 中找到表单模板的代码,以及在 projects/stage11/src/app/user/profile/profile.initial.component.ts 中找到 component 类。

不要包含 app-lemon-raterapp-view-user 元素,并从电话号码中移除 mask 属性,我们将在本章后面实现它。

在这里,你可以看到在 LemonMart 上显示的用户资料:

图片

图 8.3:基本完成的资料组件

接下来,我们将回顾 profile 组件,看看 出生日期 字段是如何工作的。

计算属性和日期选择器

我们可以根据用户输入显示计算属性。例如,为了显示一个人的年龄,基于他们的出生日期,引入计算年龄的类属性,并如下显示:

**src/app/user/profile/profile.****component****.****ts**
now = new Date()
get dateOfBirth() {
  return this.formGroup.get('dateOfBirth')?.value || this.now
}
get age() {
  return this.now.getFullYear() - this.dateOfBirth.getFullYear()
} 

age属性获取器的实现并不是最有效率的选项。为了计算年龄,我们调用this.nowthis.dateOfBirthgetFullYear()函数。作为一个在模板中引用的属性,Angular 的变更检测算法将每秒调用age多达 60 次,与其他屏幕上的元素混合,这可能导致严重的性能问题。你可以通过创建一个纯自定义管道来解决这个问题,这样 Angular 只会在其依赖值之一发生变化时检查age属性。

你可以在angular.dev/guide/pipes/change-detection了解更多关于纯管道的信息。

另一个选项是使用计算信号。与计算属性类似,计算信号是只读信号,其值来自其他信号。

我们可以将上面的代码重写如下:

 now = new Date()
  dateOfBirth = 
    signal(
     this.formGroup.get('dateOfBirth')?.value || this.now
    )
  age = computed(() => 
    this.now.getFullYear() - this.dateOfBirth().getFullYear()) 

我们将dateOfBirth创建为一个信号,将age创建为一个计算信号。使用这种设置,只有当dateOfBirth发生变化时,age才会更新。正如你所看到的,实现很简单,Angular 的变更检测算法将默认做正确的事情。

除了一个小问题!由于缺少基于信号的组件和必需的FormGroup支持,我们无法直接在响应式表单中使用dateOfBirthage

这有助于你理解对于 Angular 来说,信号变化有多大。更多内容请参阅angular.dev/guide/signals#computed-signals

要验证过去一百年内的日期,实现一个minDate类属性:

**src/app/user/profile/profile.****component****.****ts**
  minDate = new Date(
    this.now.getFullYear() - 100,
    this.now.getMonth(),
    this.now.getDate()
  ) 

模板中计算属性的用法如下:

**src/app/user/profile/profile.component.html**
<mat-form-field appearance="outline" fxFlex="50%">
  <mat-label>Date of Birth</mat-label>
  <input matInput aria-label="Date of Birth" formControlName="dateOfBirth"
    **[****min****]=****"minDate"** **[****max****]=****"now"** [matDatepicker]="dateOfBirthPicker" #dob />
  @if (formGroup.get('dateOfBirth')?.value) {
    <mat-hint> {{ age }} year(s) old </mat-hint>
  }
  <mat-datepicker-toggle matSuffix [for]="dateOfBirthPicker">
  </mat-datepicker-toggle>
  <mat-datepicker #dateOfBirthPicker></mat-datepicker>
  <mat-error [input]="dob" [group]="formGroup"
    [appFieldError]=
      "{error: 'invalid', message: 'Date must be within the last 100 years'}">
  </mat-error>
</mat-form-field> 

参考前面代码片段中突出显示的[min][max]属性,以应用一百年的日期范围。

DatePicker的实际使用效果如下:

图 8.4:使用 DatePicker 选择日期

注意,4 月 26 日之后的日期变灰。选择日期后,计算出的年龄将显示如下:

图 8.5:计算年龄属性

现在,让我们继续下一步,联系信息,看看我们如何能够方便地显示和输入地址字段的状态部分。

类型提示支持

buildForm中,我们为address.state设置一个监听器,以支持类型提示过滤下拉体验:

**src/app/user/profile/profile.****component****.****ts**
const state = this.formGroup.get('address.state')
if (state != null) {
  this.states$ = state.valueChanges.pipe(
    startWith(''),
    map((value) => USStateFilter(value))
  )
} 

在模板中,实现mat-autocomplete,通过async管道绑定到过滤后的状态数组:

**src/app/user/profile/profile.component.html**
...
<mat-form-field appearance="outline" fxFlex="30%">
  <mat-label>State</mat-label>
  <input type="text" aria-label="State" matInput formControlName="state"
    [matAutocomplete]="stateAuto" #state />
  <mat-autocomplete #stateAuto="matAutocomplete">
    @for (state of states$ | async; track state) {
      <mat-option [value]="state.name">
        {{ state.name }}
      </mat-option>
    }
  </mat-autocomplete>
  <mat-error [input]="state" [group]="formGroup.get('address')"
    appFieldError="required">
  </mat-error>
</mat-form-field> 
... 

当用户输入字符V时,它看起来是这样的:

图 8.6:具有类型提示支持的下拉菜单

在下一节中,让我们启用多个电话号码的输入。

动态表单数组

注意,phones 属性是一个数组,可能允许有多个输入。我们可以通过使用 this.formBuilder.array 函数构建 FormArray 来实现这一点。我们还定义了几个辅助函数,以使构建 FormArray 更加容易:

  • buildPhoneFormControl 有助于构建单个条目的 FormGroup 对象。

  • buildPhoneArray 会创建所需数量的 FormGroup 对象,或者如果表单为空,则创建一个空条目。

  • addPhoneFormArray 添加一个新的空 FormGroup 对象。

  • get phonesArray() 是一个方便的属性,用于从表单中获取 phones 控件。

让我们看看实现是如何结合在一起的:

**src/app/user/profile/profile.****component****.****ts**
...
phones: this.formBuilder.array(this.buildPhoneArray(user?.phones || [])),
...
  private buildPhoneArray(phones: IPhone[]) {
    const groups = []
    if (phones?.length === 0) {
      groups.push(this.buildPhoneFormControl(1))
    } else {
      phones.forEach((p) => {
        groups.push(
          this.buildPhoneFormControl(p.id, p.type, p.digits)
        )
      })
    }
    return groups
      } 
  private buildPhoneFormControl(
    id: number, type?: string, phoneNumber?: string
  ) {
    return this.formBuilder.group({
      id: [id],
      type: [type || '', Validators.required],
      digits: [phoneNumber || '', USAPhoneNumberValidation],
  })
} 
... 

buildPhoneArray 支持使用单个电话输入初始化表单,或者通过填充现有数据来实现,与 buildPhoneFormControl 一起工作。当用户点击 Add 按钮创建新行时,后者非常有用:

**src/app/user/profile/profile.****component****.****ts**
...
addPhone() { this.phonesArray.push(
this.buildPhoneFormControl(
  this.formGroup.get('phones').value.length + 1)
)
}
get phonesArray(): FormArray {
  return this.formGroup.get('phones') as FormArray
}
... 

phonesArray 属性的获取器是一个常见的模式,它使得访问某些表单属性变得更加容易。然而,在这种情况下,它也是必要的,因为 get('phones') 必须转换为 FormArray 类型,这样我们才能在模板上访问其 length 属性:

**src/app/user/profile/profile.component.html**
...
<mat-list formArrayName="phones">
  <h2 mat-subheader>Phone Number(s)
    <button mat-button (click)="addPhone()">
      <mat-icon>add</mat-icon>
      Add Phone
    </button>
  </h2>
  @for (position of phonesArray.controls; track position; let i = $index) 
  {
    <mat-list-item [formGroupName]="i"> 
      <mat-form-field appearance="outline" fxFlex="100px">
        <mat-label>Type</mat-label>
        <mat-select formControlName="type">
          @for (type of PhoneTypes; track type) {
            <mat-option [value]="convertTypeToPhoneType(type)">
              {{ type }}
            </mat-option>
          }      
       </mat-select>
    </mat-form-field>
    <mat-form-field appearance="outline" fxFlex fxFlexOffset="10px">
      <mat-label>Number</mat-label>
      <input matInput type="text" formControlName="digits"
        aria-label="Phone number" prefix="+1" />
        @if (phonesArray.controls[i].invalid && 
             phonesArray.controls[i].touched) {
          <mat-error>A valid phone number is required</mat-error>
        }     
    </mat-form-field>
    <button fxFlex="33px" mat-icon-button
      (click)="**phonesArray.removeAt(i)**"> 
      <mat-icon>delete</mat-icon>
    </button>
  </mat-list-item>
  }
</mat-list> 
... 

注意突出显示的 convertTypeToPhoneType 函数,它将 string 转换为 enum PhoneType

在前面的代码块中,还突出了 remove 函数是如何在模板中内联实现的,这使得它更容易阅读和维护。

让我们看看动态数组应该如何工作:

图 8.7:使用 FormArray 的多个输入

现在我们已经完成了数据输入,我们可以继续到步骤器的最后一步 Review。然而,如前所述,Review 步骤使用 <app-view-user> 指令来显示其数据。让我们首先构建这个视图。

创建共享组件

这里是 <app-view-user> 指令的最小实现,这是 Review 步骤的先决条件。

user 文件夹结构下创建一个新的 viewUser 组件,如下所示:

**src/app/user/view-user/view-user.****component****.****ts**
import { AsyncPipe, DatePipe } from '@angular/common'
import {
  Component, inject, Input, OnChanges, SimpleChanges
} from '@angular/core'
import { MatButtonModule } from '@angular/material/button'
import { MatCardModule } from '@angular/material/card'
import { MatIconModule } from '@angular/material/icon'
import { Router } from '@angular/router'
import { IUser, User } from '../user/user'
@Component({
  selector: 'app-view-user',
  template: `
    @if (currentUser) {
      <div>
        <mat-card appearance="outlined">
          <mat-card-header>
            <div mat-card-avatar>
              <mat-icon>account_circle</mat-icon>
            </div>
            <mat-card-title>{{ currentUser.fullName }}</mat-card-title>
            <mat-card-subtitle>{{ currentUser.role }}</mat-card-subtitle>
          </mat-card-header>
          <mat-card-content>
            <p><span class="mat-input bold">E-mail</span></p>
            <p>{{ currentUser.email }}</p>
            <p><span class="mat-input bold">Date of Birth</span></p>
            <p>{{ currentUser.dateOfBirth | date: 'mediumDate' }}</p>
          </mat-card-content>
          @if (editMode) {
            <mat-card-actions>
              <button mat-button mat-raised-button (click)="editUser(currentUser._id)">
                Edit
              </button>
            </mat-card-actions>
          }
        </mat-card>
      </div>
    }
  `,
  styles: `
    .bold {
      font-weight: bold;
    }
`,
  standalone: true,
  imports: [MatCardModule, MatIconModule, MatButtonModule, AsyncPipe, DatePipe],
})
export class ViewUserComponent implements OnChanges {
  private readonly router = inject(Router)
  @Input() user!: IUser
  currentUser = new User()
  get editMode() {
    return !this.user
  }
  ngOnChanges(changes: SimpleChanges): void {
    this.currentUser = User.Build(changes['user'].currentValue)
  }
  editUser(id: string) {
    this.router.navigate(['/user/profile', id])
  }
} 

前面的组件使用 @Input 输入绑定来从外部组件获取符合 IUser 接口的用户数据。我们实现了 ngOnChanges 生命周期钩子,它在绑定数据更改时触发。在这个事件中,我们使用 User.Build 将存储在 user 属性中的简单 JSON 对象作为 User 类的实例进行填充。

然后,我们将 User 对象分配给属性 this.currentUser。即使我们想直接绑定到用户属性,也是不可能的,因为像 fullName 这样的计算属性只能在数据被注入到 User 类的实例中时才能工作。Angular 17.1 在开发者预览中引入了基于信号的输入。我们可以定义用户为 user = input<IUser>() 并利用效果和计算信号来简化我们的实现。在我们的代码当前状态下,由于我们绑定的属性数量众多,我们承受着沉重的变更检测惩罚。然而,在基于信号的组件中则不会有这样的惩罚。我期待着在基于信号的组件发布时重构这个组件。

现在,我们准备完成多步骤表单。

审查和保存表单数据

在多步骤表单的最后一步,用户应该能够审查并保存表单数据。作为一个好的实践,成功的 POST 请求将返回保存的数据,并将其返回到浏览器。然后我们可以用从服务器接收到的信息重新加载表单:

**src/app/user/profile/profile.****component****.****ts**
...
async save(form: FormGroup) {
    this.userService
      .updateUser(this.currentUserId, form.value)
      .pipe(first())
      .subscribe({
        next: (res: IUser) => {
          this.patchUser(res)
          **this****.****formGroup****.****patchValue****(res)**
          this.uiService.showToast('Updated user')
        },
        error: (err: string) => (this.userError = err),
      })
  }
... 

注意,updateUser 返回用户的保存值。数据库可能返回与之前不同的 user 版本,因此我们使用 formGroup.patchValue 来更新表单背后的数据。表单会自动更新以反映任何更改。

如果在保存数据时出现错误,它们将被设置为 userError 以在表单上显示。在保存数据之前,我们使用可重用的 app-view-user 组件以紧凑的格式展示数据,我们可以将表单数据绑定到该组件:

**src/app/user/profile/profile.component.html**
...
<mat-step [stepControl]="formGroup">
  <form [formGroup]="formGroup" (ngSubmit)="save(formGroup)">
    <ng-template matStepLabel>Review</ng-template>
    <div class="stepContent">
      Review and update your user profile.
      **<****app-view-user** **[****user****]=****"formGroup.getRawValue()"****></****app-view-user****>**
    </div>
    <div fxLayout="row" class="margin-top">
      <button mat-button matStepperPrevious>Back</button>
      <div class="flex-spacer"></div>
      @if (userError) {
        <div class="mat-caption error">{{ **userError** }}</div>
      } 
      <button mat-button color="warn" (click)="**stepper.reset()**">
        Reset
      </button>
      <button mat-raised-button matStepperNext color="primary" 
        type="submit" [disabled]="formGroup.invalid">
        Update
      </button>
    </div>
  </form>
</mat-step>
... 

注意,我们使用 formGroup.getRawValue() 来提取表单数据的 JSON。看看我们是如何将 userError 绑定到显示错误消息的。此外,重置按钮使用 stepper.reset(),可以方便地重置所有用户输入。

这就是最终产品应该呈现的样子:

图 8.8

图 8.8:审查步骤

现在用户资料输入已完成,我们离最终目标——创建一个主/详细视图还有一半的路要走,在这个视图中,经理可以点击用户并查看其资料详情。我们还有更多的代码要添加,并且在过程中,我们陷入了添加大量样板代码以加载组件所需数据的模式。

接下来,让我们重构我们的表单,使其代码可重用和可扩展,这样即使我们的表单有数十个字段,代码仍然是可维护的,并且我们不会引入指数级成本增加来做出更改。

具有可重用部分的可扩展表单架构

如在 多步骤响应式表单 部分的介绍中提到的,表单是紧密耦合的怪物,可能会变得很大,使用错误的架构模式来扩展你的实现,在实现新功能或维护现有功能时可能会引起重大问题。

为了展示如何将你的表单拆分成多个部分,我们将重构它,提取以下截图中的突出部分,即名称FormGroup,作为一个单独的组件。完成这一点的技术与你想要将表单的每一步放入单独组件时使用的技术相同:

图片

图 8.9:用户个人资料名称部分被突出显示

通过使FormGroup名称可重用,你还将了解如何将你构建到FormGroup中的业务逻辑在其他表单中重用。我们将把FormGroup逻辑提取到一个名为NameInputComponent的新组件中。在这个过程中,我们也有机会将一些可重用的表单功能提取到BaseFormComponent作为一个抽象类。

这里将会有几个组件协同工作,包括ProfileComponentViewUserComponentNameInputComponent。我们需要这三个组件中的所有值在用户输入时保持最新。

ProfileComponent将拥有主表单,我们需要将任何子表单注册到这个主表单上。一旦完成注册,你之前学到的所有表单验证技术仍然适用。

这是一种关键的方法,可以使你的表单能够在许多组件之间扩展,并且继续易于操作,同时不会引入不必要的验证开销。因此,回顾这些对象之间的不同交互对于巩固你对它们行为异步和解耦性质的理解是有用的:

图片

图 8.10:表单组件交互

在本节中,我们将结合你在本书中学到的许多概念。利用前面的图来理解各种表单组件之间的交互。

在前面的图中,粗体属性表示数据绑定。下划线函数元素表示事件注册。箭头显示了组件之间的连接点。

工作流程从ProfileComponent的实例化开始。组件的OnInit事件开始构建formGroup对象,同时异步加载可能需要修补到表单中的任何潜在initialData。参考前面的图来查看从服务或缓存中进入的initialData的视觉表示。

NameInputComponentProfileComponent表单中作为<app-name-input>使用。为了将initialDataNameInputComponent同步,我们使用async管道绑定一个nameInitialData$主题,因为initialData是异步到达的。

NameInputComponent实现了OnChanges生命周期钩子,所以每当nameInitialData$更新时,其值就会被修补到NameInputComponent表单中。

ProfileComponent一样,NameInputComponent也实现了OnInit事件来构建其formGroup对象。由于这是一个异步事件,NameInputComponent需要公开一个formReady事件,ProfileComponent可以订阅它。一旦formGroup对象就绪,我们发出事件,ProfileComponent上的registerForm函数触发。registerFormNameInputComponentformGroup对象作为子元素添加到父formGroup上。

ViewUserComponentProfileComponent表单中作为<app-view-user>使用。当父表单中的值发生变化时,我们需要<app-view-user>保持最新状态。我们绑定到ViewUserComponent上的user属性,该组件实现了OnChanges以接收更新。每次更新时,User对象都会从IUser对象中恢复,因此计算字段如fullName可以继续工作。然后,更新的User被分配给currentUser,绑定到模板上。

我们将首先构建BaseFormComponent,然后NameInputComponentProfileComponent将实现它。

抽象表单组件

我们可以通过实现一个基抽象类来共享通用功能并标准化实现所有实现表单的组件。一个抽象类不能单独实例化,因为它这样做没有意义,因为它将没有模板,使其单独使用变得无用。

注意,BaseFormComponent只是一个class,而不是 Angular 组件。

BaseFormComponent将标准化以下内容:

  • @Input initialDatadisable作为绑定目标

  • @Output formReady事件

  • formGroup,以及要在模板的buildForm函数中使用的FormGroup来构建formGroup

在前面的假设下,基类可以提供一些通用功能:

  • patchUpdatedData可以在不重建的情况下更新formGroup中的数据(部分或全部)。

  • registerFormderegisterForm可以注册或注销子表单。

  • deregisterAllForms可以自动注销任何已注册的子表单。

  • hasChanged可以根据由ngOnChange事件处理器提供的SimpleChange对象确定initialData是否已更改。

  • patchUpdatedDataIfChanged利用hasChanged并使用patchUpdatedDatainitialDataformGroup已初始化的情况下更新数据,仅当initialData有更新时。

src/common下创建一个新的类,BaseFormComponent,如下所示:

**src/app/common/base-form.****class****.****ts**
import { EventEmitter, Input, Output, SimpleChange, SimpleChanges } 
   from '@angular/core'
import { AbstractControl, FormGroup } from '@angular/forms'
export abstract class BaseFormComponent<TFormData extends object> {
  @Input() initialData: TFormData
  @Input() disable: boolean
  @Output() formReady: EventEmitter<AbstractControl>
  formGroup: FormGroup
  private registeredForms: string[] = []
  constructor() {
    this.formReady = new EventEmitter<AbstractControl>(true)
  }
  abstract buildForm(initialData?: TFormData): FormGroup
  patchUpdatedData(data: object) {
    this.formGroup.patchValue(data, { onlySelf: false })
  }
  patchUpdatedDataIfChanged(changes: SimpleChanges) {
    if (this.formGroup && this.hasChanged(changes.initialData)) {
      this.patchUpdatedData(this.initialData)
    }
  }
  emitFormReady(control: AbstractControl | null = null) {
    this.formReady.emit(control || this.formGroup)
  }
  registerForm(name: string, control: AbstractControl) {
    this.formGroup.setControl(name, control)
    this.registeredForms.push(name)
  }
  deregisterForm(name: string) {
    if (this.formGroup.contains(name)) {
      this.formGroup.removeControl(name)
    }
  }
  protected deregisterAllForms() {
    this.registeredForms.forEach(() => this.deregisterForm(name))
  }
  protected hasChanged(change: SimpleChange): boolean {
    return change?.previousValue !== change?.currentValue
  }
} 

让我们使用BaseFormComponent来实现NameInputComponent

实现可重用的表单部分

首先在profile组件代码和模板文件中识别名称FormGroup

  1. 以下是对FormGroup名称的实现:

    **src/app/user/profile/profile.****component****.****ts**
    ...
    name: this.formBuilder.group({
      first: [user?.name?.first || '', RequiredTextValidation],
      middle: [user?.name?.middle || '', OneCharValidation],
      last: [user?.name?.last || '', RequiredTextValidation],
    }),
    ... 
    

注意,当我们将这些验证规则移动到新的组件时,我们仍然希望它们在确定父表单的整体验证状态时仍然有效。我们通过使用上一节中实现的registerForm函数来实现这一点。一旦我们的新FormGroup与现有的一个注册,它们的工作方式与重构之前相同。

  1. 接下来是FormGroup模板:

    **src/app/user/profile/profile.component.html**
    ...
    <div fxLayout="row" fxLayout.lt-sm="column"
      [formGroup]="formGroup.get('name')" fxLayoutGap="10px">
      <mat-form-field appearance="outline" fxFlex="40%">
        <mat-label>First Name</mat-label>
        <input matInput aria-label="First Name" 
               formControlName="first" #name />
        ...
    </div>
    ... 
    

你将把大部分代码移动到新的组件中。

  1. user文件夹下创建一个新的NameInputComponent

  2. BaseFormComponent扩展类。

  3. constructor中注入FormBuilder

对于具有小型或有限功能的功能组件,我更喜欢使用内联模板和样式来创建它们,这样更容易从一处更改代码。

**src/app/user/name-input/name-input.****component****.****ts**
export class NameInputComponent extends BaseFormComponent<IName> {
  constructor(private formBuilder: FormBuilder) {
    super()
  }
  buildForm(initialData?: IName): FormGroup {
    throw new Error("Method not implemented.");
  }
  ...
} 

记住,基类已经实现了formGroupinitialDatadisableformReady属性,因此你不需要重新定义它们。

注意,我们必须实现buildForm函数,因为它被定义为抽象的。这是强制开发者之间统一标准的好方法。此外,注意实现类可以通过使用override关键字重新定义函数来覆盖任何由基类提供的函数。当我们在重构ProfileComponent时,你会看到这个功能的具体实现。

  1. 实现函数buildForm

  2. ProfileComponent中的formGroupname属性设置为null

    **src/app/user/name-input/name-input.****component****.****ts**
    export class NameInputComponent implements OnInit {
      ...
      buildForm(initialData?: IName): FormGroup {
        const name = initialData
        return this.formBuilder.group({
          first: [name?.first : '', RequiredTextValidation],
          middle: [name?.middle : '', OneCharValidation], 
          last: [name?.last : '', RequiredTextValidation],
        })
      } 
    
  3. 通过将ProfileComponent中的内容迁移过来来实现模板:

    **src/app/user/name-input/name-input.****component****.****ts**
    template: `
        <form [formGroup]="formGroup">
          <div fxLayout="row" fxLayout.lt-sm="column"
            fxLayoutGap="10px">
            ...
          </div>
        </form>
      `, 
    
  4. 实现事件处理器ngOnInit

    **src/app/user/name-input/name-input.****component****.****ts**
    ngOnInit() {
      this.formGroup = this.buildForm(this.initialData)
      this.formReady.emit(this.formGroup)
    } 
    

在每个BaseFormComponent实现中正确实现ngOnInit事件处理器至关重要。前面的示例是任何你可能实现的child组件的标准行为。

注意,ProfileComponent中的实现将会有所不同。

  1. 实现事件处理器ngOnChanges,利用基类的patchUpdatedDataIfChanged行为:

    **src/app/user/name-input/name-input.****component****.****ts**
    ngOnChanges(changes: SimpleChanges) {
      this.disable ?
        this.formGroup?.disable() : this.formGroup?.enable()
      this.patchUpdatedDataIfChanged(changes)
    } 
    

注意,在patchUpdatedDataIfChanged中,将onlySelf设置为false将导致父表单也更新。如果你想要优化此行为,可以覆盖该函数。

现在,你已经有一个完全实现的NameInputComponent,可以将其集成到ProfileComponent中。

为了验证你未来的ProfileComponent代码,请参考projects/stage11/src/app/user/profile/profile.component.tsprojects/stage11/src/app/user/profile/profile.component.html

在开始使用NameInputComponent之前,执行以下重构。

  1. ProfileComponent重构为扩展BaseFormComponent,并根据需要符合其默认值。

  2. 定义一个只读的nameInitialData$属性,其类型为BehaviorSubject<IName>,并用空字符串初始化它。

  3. ProfileComponent中的内容替换为新的<app-name-input>组件:

    **src/app/user/profile/profile.component.html**
    <mat-horizontal-stepper #stepper="matHorizontalStepper">
      <mat-step [stepControl]="formGroup">
        <form [formGroup]="formGroup">
          <ng-template matStepLabel>Account Information</ng-template>
            <div class="stepContent">
              **<****app-name-input** **[****initialData****]=****"nameInitialData$ | async"**
                **(****formReady****)=****"registerForm('name', $event)"****>**
              </app-name-input>
            </div>
            ...
          </ng-template>
        </form>
      </mat-step>
      ...
    </mat-horizontal-stepper> 
    

注意,这里使用了基础表单组件函数registerForm

  1. 确保你的ngOnInit被正确实现:

注意,在更新的ProfileComponent中存在一些额外的重构,例如以下片段中看到的patchUser函数。当您更新组件时,不要错过这些更新。

**src/app/user/profile/profile.****component****.****ts**
ngOnInit() {
  this.formGroup = this.buildForm()
  this.authService.currentUser$
    .pipe(
      filter((user) => user != null),
      tap((user) => this.patchUser(user)),
      takeUntilDestroyed(this.destroyRef)
    )
    .subscribe()
} 

initialData更新时,重要的是要使用pathUpdatedData以及nameInitialData$更新当前表单的数据。

  1. 确保正确实现了ngOnDestroy

    **src/app/user/profile/profile.****component****.****ts**
      ngOnDestroy() {
        this.deregisterAllForms()
      } 
    

您可以利用基类功能来自动注销所有子表单。

接下来,让我们学习如何通过屏蔽用户输入来提高我们的数据质量。

输入屏蔽

屏蔽用户输入是一种输入 UX 工具,也是一种数据质量工具。我是ngx-mask库的粉丝,它使得在 Angular 中实现输入屏蔽变得容易。我们将通过更新电话号码输入字段来演示输入屏蔽,确保用户输入有效的电话号码,如图所示:

图片

图 8.11:带有输入屏蔽的电话号码字段

按如下方式设置您的输入屏蔽:

  1. 使用npm i ngx-mask通过 npm 安装库。

  2. 要么在app.config.ts中使用环境提供者provideEnvironmentNgxMask(),要么在您的功能模块user.module.ts中使用provideNgxMask()

  3. profile.component.html中导入NgxMaskDirective

  4. 按如下方式更新ProfileComponent中的number字段:

    **src/app/user/profile/profile.component.html**
    <mat-form-field appearance="outline" fxFlex fxFlexOffset="10px">
      <mat-label>Number</mat-label>
      <input matInput type="text" formControlName="number"
        prefix="+1" **mask****=****"(000) 000-0000"** **[****showMaskTyped****]=****"true"** />
        @if (phonesArray.controls[i].invalid && 
             phonesArray.controls[i].touched) {
          <mat-error>A valid phone number is required</mat-error>
        } 
    </mat-form-field> 
    

简单来说,您可以在 GitHub 上了解更多关于该模块及其功能的信息:github.com/JsDaddy/ngx-mask

使用 ControlValueAccessor 的自定义控件

到目前为止,我们已经学习了使用 Angular Material 提供的标准表单控件和输入控件来使用表单。然而,您也可以创建自定义用户控件。如果您实现了ControlValueAccessor接口,那么您的自定义控件将与表单和ControlValueAccessor接口的验证引擎很好地协同工作。

我们将创建以下截图所示的定制评分控件,并将其放置在ProfileComponent的第一步控件上:

图片

图 8.12:柠檬评分用户控件

用户控件本质上是高度可重用、紧密耦合且定制的组件,以实现丰富的用户交互。让我们来实现一个。

实现自定义评分控件

Lemon Rater 将在用户与控件实时交互时动态突出显示所选柠檬的数量。因此,创建高质量的定制控件是一项昂贵的任务。然而,在定义您的品牌和/或构成 UX 核心的应用程序元素上投入精力是完全值得的。

Lemon Rater 是 Jennifer Wadella 在github.com/tehfedaykin/galaxy-rating-app找到的 Galaxy 评分应用示例的修改版。我强烈推荐观看 Jennifer 在 Ng-Conf 2019 上关于ControlValueAccessor的演讲,链接在进一步阅读部分。

按照以下方式设置您的自定义评分控件:

  1. user-controls文件夹下生成一个新的组件名为LemonRater

  2. LemonRater中实现ControlValueAccess接口:

    **src/app/user-controls/lemon-rater/lemon-rater.****component****.****ts**
    export class LemonRaterComponent implements ControlValueAccessor {
      disabled = false
      private internalValue: number
      get value() {
        return this.internalValue
      }
      onChanged: any = () => {}
      onTouched: any = () => {}
      writeValue(obj: any): void {
        this.internalValue = obj
      }
      registerOnChange(fn: any): void {
        this.onChanged = fn
      }
      registerOnTouched(fn: any): void {
        this.onTouched = fn
      }
      setDisabledState?(isDisabled: boolean): void {
        this.disabled = isDisabled
      }
    } 
    
  3. 添加具有multi属性设置为trueNG_VALUE_ACCESSOR提供者。这将使我们的组件注册到表单的更改事件中,因此当用户与评分器交互时,表单值可以更新:

    **src/app/user-controls/lemon-rater/lemon-rater.****component****.****ts**
    @Component({
      selector: 'app-lemon-rater',   
      templateUrl: 'lemon-rater.component.html',
      styleUrls: ['lemon-rater.component.css'],
      providers: [
        {
          provide: NG_VALUE_ACCESSOR,
          useExisting: forwardRef(() => LemonRaterComponent),
          multi: true,
        },
      ],  
      standalone: true,
      imports: [NgClass], 
    

    forwardRef允许我们引用尚未定义的组件。更多内容请参阅angular.dev/api/core/forwardRef

  4. 使用函数实现一个自定义评分方案,允许我们根据用户输入设置所选评分:

    **src/app/user-controls/lemon-rater/lemon-rater.****component****.****ts**
    export class LemonRaterComponent implements ControlValueAccessor { 
      @ViewChild('displayText', { static: false }) displayTextRef!: ElementRef
      disabled = false
      private internalValue!: number
      get value() {
        return this.internalValue
      }
    
      ratings = Object.freeze([
        {
          value: 1,
          text: 'no zest',
        },
        {
          value: 2,
          text: 'neither a lemon or a lime ',
        },
        {
          value: 3,
          text: 'a true lemon',
        },
      ])
      ...
      setRating(lemon: any) {
        if (!this.disabled) {
          this.internalValue = lemon.value
          this.ratingText = lemon.text
          this.onChanged(lemon.value)
          this.onTouched()
        }
      }
      setDisplayText() {
        this.setSelectedText(this.internalValue)
      }
      private setSelectedText(value: number) {
        this.displayTextRef.nativeElement.textContent = 
          this.getSelectedText(value)
      }
      private getSelectedText(value: number) {
        let text = ''
        if (value) {
          text = this.ratings
            .find((i) => i.value === value)?.text || ''
        }
        return text
      }
    } 
    

    注意,通过使用@ViewChild,我们正在获取名为#displayText的 HTML 元素(在以下模板中突出显示)。使用setSelectText,我们替换元素的textContent

  5. 实现模板,参考svg标签内容的示例代码:

    **src/app/user-controls/lemon-rater/lemon-rater.component.html**
    **<****i** **#****displayText****></****i****>**
    <div class="lemons" [ngClass]="{'disabled': disabled}"> 
      @for (lemon of ratings; track lemon) { 
        <svg width="24px" height="24px" viewBox="0 0 513 513"
             [attr.title]="lemon.text" class="lemon rating"
             [ngClass]="{'selected': lemon.value <= value}"
             (mouseover)=
               "displayText.textContent = !disabled ? lemon.text : ''"
             (mouseout)="setDisplayText()"
             (click)="setRating(lemon)"
         >
         ...
         </svg>
      }
    </div> 
    

    模板中最重要的三个属性是mouseovermouseoutclickmouseover属性显示用户当前悬停的评分文本,mouseout将显示文本重置为所选值,click调用我们实现的setRating方法来记录用户的选择。然而,控件可以通过突出显示用户悬停在评分或选择它时柠檬的数量来提供更丰富的用户交互。我们将通过一些 CSS 魔法来实现这一点。

  6. 实现用户控件的 CSS:

    **src/app/user-controls/lemon-rater/lemon-rater.component.css**
    .lemons {
      cursor: pointer;
    }
    .lemons:hover .lemon #fill-area {
      fill: #ffe200 !important;
    }
    .lemons.disabled:hover {
      cursor: not-allowed;
    }
    .lemons.disabled:hover .lemon #fill-area {
      fill: #d8d8d8 !important;
    }
    .lemons .lemon {
      float: left; margin: 0px 5px;
    }
    .lemons .lemon #fill-area {
      fill: #d8d8d8;
    }
    .lemons .lemon:hover~.lemon #fill-area {
      fill: #d8d8d8 !important;
    }
    .lemons .lemon.selected #fill-area {
      fill: #ffe200 !important;
    }
    .lemons .dad.heart #ada { 
      fill: #6a0dad !important;
    } 
    

最有趣的部分是.lemons .lemon:hover~.lemon #fill-area。注意操作符~,或通用兄弟组合器,选择一系列元素,这样当用户悬停在它们上面时,会有一个动态数量的柠檬被突出显示。

#fill-area指的是在柠檬.svg内部定义的<path>,允许动态调整柠檬的颜色。我不得不手动将此 ID 字段注入到.svg文件中。

现在,让我们看看您如何在表单中使用这个新的用户控件。

在表单中使用自定义控件

我们将在profile组件中使用柠檬评分器来捕捉员工的柠檬等级。

Limoncu 是土耳其语中指种植或出售柠檬的人,也是 LemonMart 的专有员工参与度和绩效测量系统。

让我们集成柠檬评分器:

  1. 首先在profile.component.ts中导入LemonRaterComponent

  2. 确保在buildForm中初始化等级表单控件:

    **src/app/user/profile/profile.****component****.****ts**
    buildForm(initialData?: IUser): FormGroup {
      ...
      level: [user?.level || 0, Validators.required], 
      ...
    } 
    
  3. 将柠檬评分器作为第一个mat-step中的最后一个元素插入到form元素内:

    **src/app/user/profile/profile.component.html**
    <div fxLayout="row" fxLayout.lt-sm="column" class="margin-top" fxLayoutGap="10px">
      <mat-label class="mat-body-1">Select the Limoncu level:
        <app-lemon-rater formControlName="level">
        </app-lemon-rater>
      </mat-label>
    </div> 
    

我们通过实现formControlName像其他控件一样与自定义控件集成。

恭喜!您应该有一个集成了您表单的工作自定义控件。

使用网格列表布局

Flex Layout 库非常适合使用 CSS Flexbox 布局内容。Angular Material 提供了另一种布局内容的方法,即使用 CSS Grid 及其网格列表功能。演示这种功能的一个好方法是在LoginComponent中实现一个有用的假登录信息列表,如下所示:

图 8.13:带有网格列表的登录助手

按照以下方式实现你的列表:

  1. 首先定义一个roles属性,它是一个包含所有角色的数组:

    **src/app/login/login.****component****.****ts**
    roles = Object.keys(Role) 
    
  2. login.component.ts中导入MatExpansionModuleMatGridListModule

  3. 在现有的mat-card-content下方实现一个新的mat-card-content

    **src/app/login/login.component.html**
    <div fxLayout="row" fxLayoutAlign="center">
      <mat-card fxFlex="400px">
        <mat-card-header>
          <mat-card-title>
            <div class="mat-headline">Hello, Limoncu!</div>
          </mat-card-title>
        </mat-card-header>
        <mat-card-content>
          ...
        </mat-card-content>
        **<****mat-card-content****>**
        **</****mat-card-content****>**
      </mat-card>
    </div> 
    
  4. 在新的mat-card-content内部,放入一个标签来显示认证模式:

    **src/app/login/login.component.html**
    <div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="10px">
      <span>Authentication Mode: </span><i>{{ authMode }}</i>
    </div> 
    
  5. 在标签下方实现一个展开列表:

    **src/app/login/login.component.html**
    <mat-accordion>
      <mat-expansion-panel>
        <mat-expansion-panel-header>
            <mat-panel-title>
              Fake Login Info
            </mat-panel-title>
        </mat-expansion-panel-header>
        ...
      </mat-expansion-panel>
    </mat-accordion> 
    
  6. mat-expansion-panel-header之后,在上一步中用省略号标记的区域,实现一个包含角色和电子邮件地址的表格,以及一些关于密码长度的提示文本,使用mat-grid-list,如下面的代码块所示:

    **src/app/login/login.component.html**
    <mat-grid-list cols="3" rowHeight="48px" role="list">
      <mat-grid-tile [**colspan**]="3" role="listitem"
      style="background: pink">
        Use any 8 character string as password
      </mat-grid-tile>
      <mat-grid-tile>
        <mat-grid-tile-header>Role</mat-grid-tile-header>
      </mat-grid-tile>
      <mat-grid-tile [colspan]="2">
        <mat-grid-tile-header>E-mail</mat-grid-tile-header>
      </mat-grid-tile>
      @for (role of roles; track role; let oddRow = $odd) {
        <div>
          <mat-grid-tile
            role="listitem"
            [style.background]="oddRow ? 'lightGray' : 'white'">
            {{ role }}
          </mat-grid-tile>
          <mat-grid-tile
            [colspan]="2"
            role="listitem"
            [style.background]="oddRow ? 'lightGray' : 'white'">
            <div fxFlex fxLayoutAlign="end center">
              @if (role.toLowerCase() === 'none') {
                <div>Any &#64;test.com email</div>
              } @else {
                {{ role.toLowerCase() }}&#64;test.com
              }
              <button
                mat-button
                (click)="
                  this.loginForm.patchValue({
                    email: role.toLowerCase() + '@test.com',
                    password: 'whatever'
                  })
                ">
                Fill
              </button>
            </div>
          </mat-grid-tile>
        </div>
      }
    </mat-grid-list> 
    

我们使用colspan来控制每行和每个单元格的宽度。我们利用fxLayoutAlign电子邮件列的内容右对齐。我们使用@if; @else控制流运算符来选择性地显示内容。最后,一个填充按钮帮助我们用假登录信息填充登录表单。

在你的应用程序中,你可以使用展开面板来向用户传达密码复杂性的要求。

你可以在material.angular.io/components/expansion了解更多关于展开面板的信息,以及在material.angular.io/components/grid-list/overview了解更多关于网格列表的信息。

恢复缓存数据

在本章的开始,当在UserService中实现updateUser方法时,我们缓存了user对象,以防任何可能清除用户提供的数据的错误:

**src/app/user/user/user.****service****.****ts**
updateUser(id: string, user: IUser): Observable<IUser> {
  ...
  This.cache.setItem('draft-user', user)
  ...
} 

考虑一个场景,当用户尝试保存数据时,他们可能暂时离线。在这种情况下,我们的updateUser函数将保存数据。

让我们看看如何在加载用户资料时在ProfileComponent中恢复这些数据:

  1. 首先向ProfileComponent类中添加名为loadFromCacheclearCache的函数:

    **src/app/user/profile.****component****.****ts**
    private loadFromCache(): Observable<User | null> {
      let user = null
      try {
        const draftUser = this.cache.getItem('draft-user')
        if (draftUser != null) {
          user = User.Build(JSON.parse(draftUser))
        }
        if (user) {
          this.uiService.showToast('Loaded data from cache')
        }
      } catch (err) {
        this.clearCache()
      }
      return of(user)
    } 
    clearCache() {
      this.cache.removeItem('draft-user')
    } 
    

    加载数据后,我们使用JSON.parse将数据解析为 JSON 对象,然后使用User.BuildUser对象填充。

  2. 更新模板以调用clearCache函数,以便当用户重置表单时,我们也清除缓存:

    **src/app/user/profile.component.html**
    <button mat-button color="warn"
        (click)="stepper.reset(); **clearCache()**">
      Reset
    </button> 
    
  3. 更新ngOnInit以有条件地从缓存或authService的最新currentUser$加载数据:

    **src/app/user/profile.****component****.****ts**
    ngOnInit() {
      this.formGroup = this.buildForm()
      combineLatest([
            this.loadFromCache(),
            this.authService.currentUser$,
          ])
            .pipe(
            takeUntilDestroyed(this.destroyRef),
              filter(
                ([cachedUser, me]) => 
                  cachedUser != null || me != null
              ),
              tap(
                ([cachedUser, me]) => 
                  this.patchUser(cachedUser || me)
              )
            )
            .subscribe()
    } 
    

我们利用combineLatest运算符将loadFromCachecurrentUser$的输出组合起来。我们检查其中一个流返回一个非空值。如果存在缓存用户,它将先于从currentUser$接收到的值。

你可以通过将浏览器网络状态设置为离线来测试你的缓存,如下所示:

图片

图 8.14:离线网络状态

将浏览器网络状态设置为离线,方法如下:

  1. 在 Chrome DevTools 中,导航到网络标签页。

  2. 在前一张截图标记为2的下拉菜单中选择离线

  3. 更新表单数据,例如名称,然后点击更新

  4. 你会在表单底部看到错误信息发生未知错误

  5. 你会在网络标签页中看到你的 PUT 请求失败。

  6. 现在,刷新你的浏览器窗口,观察你输入的新名称仍然存在。

参考以下截图,它显示了从缓存加载数据后你收到的吐司通知:

图片

图 8.15:从缓存加载数据

实现一个优秀的缓存用户体验极具挑战性。我提供了一个基本的方法来展示什么是可能的。然而,许多边缘情况可能会影响你的应用程序中缓存的工作方式。

在我的情况下,缓存固执地存在,直到我们成功将数据保存到服务器。这可能会让一些用户感到沮丧。

恭喜!你已经成功实现了一个复杂的表单来从你的用户那里捕获数据!

练习

通过更新UserService和多步骤的ProfileComponent表单来练习 Angular 中的新概念,如信号和@defer

  • 更新UserService及其相关组件,使用signal而不是BehaviorSubject

  • 使用@defer来延迟条件视图的渲染。

  • LoginComponent中实现一个展开面板,以向用户传达密码复杂性的要求。

摘要

本章介绍了 LemonMart 的表单、指令和用户控制相关功能。我们使用数据绑定创建了可重用的组件,这些组件可以嵌入到另一个组件中。我们展示了你可以使用 PUT 向服务器发送数据并缓存用户输入的数据。我们还创建了一个多步骤输入表单,它可以适应屏幕大小的变化。我们通过利用可重用表单部分、基类表单以存放公共功能以及属性指令来封装字段级别的错误行为和消息,从我们的组件中移除了样板代码。

我们使用日期选择器、自动完成支持和表单数组创建了动态表单元素。我们通过输入掩码和柠檬评分器实现了交互式控件。使用ControlValueAccessor接口,我们将柠檬评分器无缝集成到我们的表单中。我们展示了我们可以通过提取名称作为其表单部分来线性扩展表单的大小和复杂性。此外,我们还介绍了使用网格列表构建布局。

在下一章中,我们将进一步增强我们的组件,使用路由器来编排它们。我们还将实现主/详细视图和数据表,并探索 NgRx 作为 RxJS/BehaviorSubject 的替代方案。

进一步阅读

问题

尽可能好地回答以下问题,以确保你已理解本章的关键概念,而无需使用 Google。你知道你是否答对了所有问题吗?请访问 angularforenterprise.com/self-assessment 获取更多信息:

  1. 组件和用户控件之间的区别是什么?

  2. 属性指令是什么?

  3. @-语法的含义是什么?

  4. ControlValueAccessor 接口的目的是什么?

  5. 序列化、反序列化和活化是什么?

  6. 在表单上修补值意味着什么?

  7. 如何将两个独立的 FormGroup 对象相互关联?

第九章:食谱 – 主/详细信息,数据表,和 NgRx

在本章中,我们通过实现 LemonMart 中的前三个在业务应用程序中最常用的功能(主/详细信息视图,数据表和状态管理)来完成以路由器为首要的架构实现。我将使用 LemonMart 和 LemonMart Server 展示具有服务器端分页的数据表,突出前端和后端的集成。

我们将利用路由编排概念来编排我们的组件如何加载数据或渲染。然后,我们将使用解析守卫在导航到组件之前减少加载数据时的样板代码。我们将使用辅助路由通过路由配置来布局组件,并在多个上下文中重用相同的组件。

然后,我们将使用 LocalCast 天气应用程序深入探讨 NgRx,并使用 LemonMart 探索 NgRx Signal Store,这样你就可以熟悉 Angular 中更高级的应用程序架构概念。到本章结束时,我们将触及 Angular 和 Angular Material 提供的主要功能 – 如果你愿意,就是好的部分。

本章涵盖了大量的内容。它以食谱格式组织,因此你可以在项目工作时快速参考特定的实现。我涵盖了实现架构、设计和主要组件,并突出显示重要的代码片段来解释解决方案是如何组合在一起的。利用你所学到的知识,我期望读者能够填补常规实现和配置细节。然而,如果你遇到困难,可以始终参考 GitHub 仓库。

在本章中,你将学习以下主题:

  • 使用解析守卫加载数据

  • 使用绑定和路由数据重用组件

  • 使用辅助路由的主/详细信息视图

  • 带分页的数据表

  • NgRx 存储 和 影响

  • NgRx 生态系统

  • 实现全局旋转器

  • 使用 Angular CLI 配置服务器代理

技术要求

书籍的样本代码的最新版本可以在以下列表中链接的 GitHub 仓库中找到。该仓库包含代码的最终和完成状态。你可以在本章结束时通过查找projects文件夹下的代码章节末尾快照来验证你的进度。

对于第九章

确保lemon-mart-server正在运行。请参阅第七章与 REST 和 GraphQL API 一起工作

  1. 克隆以下仓库:github.com/duluca/local-weather-appgithub.com/duluca/lemon-mart

  2. 在根目录下执行npm install以安装依赖。

  3. 项目的初始状态反映在:

    projects/stage11 
    
  4. 项目的最终状态反映在:

    projects/stage12 
    
  5. 将阶段名称添加到任何ng命令中,以仅对该阶段执行操作:

    npx ng build stage12 
    

注意,存储库根目录中的dist/stage12文件夹将包含编译结果。

第八章食谱 - 可重用性、表单和缓存中,我们创建了一个带有editUser函数的ViewUserComponent。在章节后面实现系统中的主/详细视图时,我们需要这个功能,其中经理可以看到系统中的所有用户并编辑他们。在启用editUser功能之前,我们需要确保ViewUserComponent组件和ProfileComponent组件可以加载任何用户,给定他们的 ID。

在接下来的几节中,我们将学习关于解析保护器的内容,以简化我们的代码并减少样板代码的数量。让我们首先实现一个我们可以用于两个组件的解析保护器。

使用解析保护器加载数据

解析保护器是一种不同类型的路由保护器,如第六章基于角色的导航实现中提到的。解析保护器可以通过从route参数中读取记录 ID,异步加载数据,并在组件激活和初始化时准备好数据来为组件加载数据。

解析保护器的主要优势包括加载逻辑的可重用性、减少了样板代码,以及减少了依赖性,因为组件可以在不导入任何服务的情况下接收所需的数据:

  1. user/user下创建一个新的user.resolve.ts类:

    **src/app/user/user/user.****resolve****.****ts**
    import { inject } from '@angular/core'
    import { ActivatedRouteSnapshot, ResolveFn } from '@angular/router'
    import { catchError, map } from 'rxjs/operators'
    import { transformError } from '../../common/common'
    import { User } from './user'
    import { UserService } from './user.service'
    export const userResolver: ResolveFn<User> = (route: ActivatedRouteSnapshot) => {
      return inject(UserService)
        .getUser(route.paramMap.get('userId'))
        .pipe(map(User.Build), catchError(transformError))
    } 
    

    UserService中的updateUser方法类似,我们使用map(User.Build)来填充user对象,使其在组件从route快照加载数据时准备好使用,正如我们接下来将看到的。

  2. 修改user-routing.module.ts以添加一个新的路径profile/:userId,带有路由解析器和canActivate authGuard

    **src/app/user/user-routing.****module****.****ts**
    ...
    {
        path: 'profile/:userId',
        component: ProfileComponent,
        resolve: {
          user: userResolver,
        },
        canActivate: [authGuard],
      },
      ... 
    

    当与身份验证保护器结合使用时,resolve函数只有在保护器成功后才会执行。

  3. 更新profile组件,如果存在,则从route加载数据:

    **src/app/user/profile/profile.****component****.****ts**
    ...
      constructor(
        ...
        **private****route****:** **ActivatedRoute**
      ) {
        super()
      }
      **private****readonly** **destroyRef =** **inject****(****DestroyRef****)**
    
      ngOnInit() {
        this.formGroup = this.buildForm()
        if (**this****.****route****.****snapshot****.****data****[**'**user**'**]**) {
          **this****.****patchUser****(****this****.****route****.****snapshot****.****data****[**'**user**'**]**)
        } else {
           combineLatest(
            [this.loadFromCache(), 
             this.authService.currentUser$]
           )
          .pipe(
            takeUntilDestroyed(this.destroyRef),
            filter(
              ([cachedUser, me]) => 
                cachedUser != null || me != null
            ),
            tap(
              ([cachedUser, me]) => 
               this.patchUser(cachedUser || me)
            )
          )
          .subscribe()
        }
      } 
    

我们首先检查用户是否存在于route快照中。如果是,我们调用patchUser来加载此用户。否则,我们回退到我们的条件缓存加载逻辑。

注意,patchUser方法还设置了currentUserIdnameInitialDate$可观察对象,并调用patchUpdateData基类来更新表单数据。

您可以通过导航到带有您用户 ID 的配置文件来验证解析器是否正常工作。使用出厂设置,此 URL 将类似于http://localhost:4200/user/profile/5da01751da27cc462d265913

使用绑定和路由数据重用组件

现在,让我们重构viewUser组件,以便我们可以在多个上下文中重用它。根据创建的模拟图,应用程序中显示用户信息的地方有两个。

第一个地方是我们之前章节中实现的用户资料审查步骤。第二个地方是在/manager/users路由的用户管理屏幕上,如下所示:

计算机屏幕截图,描述自动生成

图 9.1:经理用户管理模拟图

为了最大化代码重用,我们必须确保我们的共享 ViewUser 组件可以在两种上下文中使用。

在第一个用例中,我们将当前用户绑定到多步输入表单的 Review 步骤。在第二个用例中,组件需要使用 resolve 守卫加载数据,因此我们不需要实现额外的逻辑来实现我们的目标:

  1. 更新 viewUser 组件以注入 RouterActivatedRoute。在 ngOnInit 中,我们需要从路由中设置 currentUser 并订阅未来的路由更改事件,以使用辅助函数 assignUserFromRoute 更新用户,并在 ngOnDestroy 中取消订阅事件:

    **src/app/user/view-user/view-user.****component****.****ts**
    ...
    export class ViewUserComponent 
      implements OnInit, OnChanges, OnDestroy {
      private readonly route = inject(ActivatedRoute)
      private readonly router = inject(Router)
      private routerEventsSubscription?: Subscription
      ...
      ngOnInit() {
        // assignment on initial render
        this.assignUserFromRoute()
        this.routerEventsSubscription = 
          this.router.events.subscribe((event) => {
          // assignment on subsequent renders
          if (event instanceof NavigationEnd) {
            this.assignUserFromRoute()
          }
        })
      }
      private assignUserFromRoute() {
        if (this.route.snapshot.data['user']) {
          this.currentUser = this.route.snapshot.data['user']
        }
      }
      ngOnDestroy(): void {
        this.routerEventsSubscription?.unsubscribe()
      }
      ...
    }} 
    

ngOnInit 只会在组件在另一个组件内部初始化或在路由器上下文中加载时触发一次。如果已经解析了路由的任何数据,我们将更新 currentUser。当用户想要查看另一个用户时,将发生一个新的导航事件,带有不同的用户 ID。由于 Angular 会重用组件,我们必须订阅路由事件以对后续的用户更改做出反应。在这种情况下,如果发生 NavigationEnd 事件,并且已解析了用户数据,我们将再次更新 currentUser

我们现在有三个独立的事件来更新和处理数据。在父组件上下文中,ngOnChanges 处理 @Input 值的更新,如果 this.user 已经绑定,则更新 currentUser。我们上面添加的代码处理了第一次导航和后续导航事件中的剩余两个情况。

由于 LemonMart 是作为一个独立的应用程序自举的,并且 viewUser 是一个独立的组件,因此我们可以在多个懒加载的模块中使用这个组件,而无需额外的编排。

如果你没有使用独立组件,你必须在这个组件内部包装一个 SharedComponentsModule,并在你的懒加载模块中导入该模块。你可以在项目的 GitHub 历史记录中找到一个示例实现。

在关键组件就绪后,让我们开始实现 master/detail 视图。

使用辅助路由的 master/detail 视图

路由器优先架构的真正力量在辅助路由中得以实现,我们可以通过路由配置单独影响组件的布局,从而允许我们混合现有的组件到不同的布局中。辅助路由是相互独立的路由,它们可以在标记中定义的命名出口中渲染内容,例如 <router-outlet name="master"><router-outlet name="detail">。此外,辅助路由可以有它们的参数、浏览器历史、子路由和嵌套辅助路由。

在下面的示例中,我们将实现一个基本的 master/detail 视图,使用辅助路由:

  1. 实现一个简单的组件,其中定义了两个命名的出口:

    **src/app/manager/user-management/user-management.****component****.****ts**
      template: `
        <div class="h-pad">
          <router-outlet name="master"></router-outlet>
          <div style="min-height: 10px"></div>
          <router-outlet name="detail"></router-outlet>
        </div>
      ` 
    
  2. manager 下添加一个新的 userTable 组件。

  3. 更新 manager-routing.module.ts 以定义辅助路由:

    **src/app/manager/manager-routing.****module****.****ts**
      ...
        {
          path: 'users',
          component: UserManagementComponent,
          children: [
            { 
              path: '', component: UserTableComponent, 
               outlet: 'master' 
            },
            {
              path: 'user',
              component: ViewUserComponent,
              outlet: 'detail',
              resolve: {
                user: userResolver,
              },
            },
          ],
          canActivate: [authGuard],
          canActivateChild: [authGuard],
          data: {
            expectedRole: Role.Manager,
          },
        },
    ... 
    

    这意味着当用户导航到/manager/users时,他们将看到UserTableComponent,因为它使用的是默认路径。

  4. manager.module.ts中提供UserResolve,因为viewUser依赖于它。

  5. userTable中实现一个临时按钮:

    **src/app/manager/user-table/user-table.component.html**
    <button
      mat-icon-button
      [routerLink]="[
        '../users',
        { outlets: { detail: ['user', { userId: row._id }] } }
      ]"
      [skipLocationChange]="true">
      <mat-icon>visibility</mat-icon>
    </button> 
    

    skipLocationChange指令在不将新记录推入历史记录的情况下进行导航。因此,如果用户查看多个记录并点击后退按钮,他们将被带回到上一个屏幕,而不是必须滚动查看他们首先查看的记录。

    想象一下,一个用户点击了一个类似于之前定义的查看详情按钮——然后,ViewUserComponent将为用户渲染带有给定userId的组件。在下一张截图中,你可以看到在下一节实现数据表后,查看详情按钮将看起来如何:

    手机截图  自动生成的描述

    图 9.2:查看详情按钮

    你可以尽可能多地组合,并为主视图和详情视图定义替代组件,从而允许动态布局的无限可能性。然而,设置routerLink可能是一个令人沮丧的经历。根据具体条件,你必须提供或不需要提供链接中的所有或部分出口。

    例如,在先前的场景中,考虑以下替代实现,其中主出口被明确定义:

    ['../users', { 
       outlets: { 
         master: [''], detail: ['user', {userId: row.id}] 
       } 
    }], 
    

    路由器将无法正确解析此路由,并且会静默失败加载。如果它是master: [],则可以正常工作。这取决于空路由上的模式匹配方式;虽然这在框架代码中逻辑上是合理的,但对于使用 API 的开发者来说并不直观。

    现在我们已经完成了ViewUserComponent的 resolve guard 实现,你可以使用 Chrome DevTools 来查看正确加载的数据。

    在调试之前,确保我们在第七章使用 REST 和 GraphQL API中创建的lemon-mart-server正在运行。

  6. Chrome DevTools中,在this.currentUser分配后立即设置一个断点,如图所示:计算机截图  自动生成的描述

    图 9.3:Dev Tools 调试 ViewUserComponent

你将观察到this.currentUser被正确设置,而无需在ngOnInit函数内部加载数据的任何样板代码,这显示了 resolve guard 的真正好处。"ViewUserComponent"是详情视图;现在,让我们实现主视图,作为一个带有分页的数据表。

带有分页的数据表

我们已经创建了脚手架来布局我们的主/详细视图。在主出口处,我们将有一个用户分页数据表,所以让我们实现 UserTableComponent,它将包含一个名为 dataSourceMatTableDataSource 属性。我们需要能够使用标准分页控件(如 pageSizepagesToSkip)批量获取用户数据,并使用用户提供的搜索文本进一步缩小选择范围。

让我们从向 UserService 添加必要的功能开始:

  1. 实现一个新的 IUsers 接口来描述分页数据的结构:

    **src/app/user/user/user.****service****.****ts**
    ...
    export interface IUsers {
      data: IUser[]
      total: number
    } 
    
  2. 使用 getUsers 函数更新 UserService 的接口:

    **src/app/user/user/user.****service****.****ts**
    ...
    export interface IUserService {
      getUser(id: string): Observable<IUser>
      updateUser(id: string, user: IUser): Observable<IUser>
      **getUsers****(****pageSize****:** **number****,** **searchText****:** **string****,** 
        **pagesToSkip****:** **number****):** **Observable****<****IUsers****>**
    }
    export class UserService implements IUserService {
    ... 
    
  3. getUsers 添加到 UserService

    **src/app/user/user/user.****service****.****ts**
    ...
    getUsers(
        pageSize: number,
        searchText = '',
        pagesToSkip = 0,
        sortColumn = '',
        sortDirection: '' | 'asc' | 'desc' = 'asc'
      ): Observable<IUsers> {
        const recordsToSkip = pageSize * pagesToSkip
        if (sortColumn) {
          sortColumn =
            sortDirection === 'desc' ? `-${sortColumn}` : sortColumn
        }
        return this.httpClient.get<IUsers>(
          `${environment.baseUrl}/v2/users`, { 
            params: {
              filter: searchText,
              skip: recordsToSkip.toString(),
              limit: pageSize.toString(),
              sortKey: sortColumn,
            },
          })
        }
    ... 
    

    注意,sort 方向由关键字 asc(升序)和 desc(降序)表示。在升序排序列时,我们将列名作为参数传递给服务器。为了降序排序列,我们在列名前加上负号。

  4. 使用分页、排序和过滤设置 UserTable

    **src/app/manager/user-table/user-table.****component****.****ts**
    ...
    @Component({
      selector: 'app-user-table',
      templateUrl: './user-table.component.html',
      styleUrls: ['./user-table.component.css'],
    })
    export class UserTableComponent implements AfterViewInit {
      @ViewChild(MatPaginator) paginator!: MatPaginator
      @ViewChild(MatSort) sort!: MatSort
      private skipLoading = false
      private readonly userService = inject(UserService)
      private readonly router = inject(Router)
      private readonly activatedRoute = inject(ActivatedRoute)
      private readonly destroyRef = inject(DestroyRef)
      readonly refresh$ = new Subject<void>()
      readonly demoViewDetailsColumn = signal(false)
      items$!: Observable<Iuser[]>
      displayedColumns = computed(() => [
        'name',
        'email',
        'role',
        ...(this.demoViewDetailsColumn() ? ['_id'] : []),
      ])
      isLoading = true
      resultsLength = 0
      hasError = false
      errorText = ''
      selectedRow?: Iuser
      search = new FormControl<string>('', OptionalTextValidation)
      resetPage(stayOnPage = false) {
        if (!stayOnPage) {
          this.paginator.firstPage()
        }
        **// this.outletCloser.closeOutlet('detail')**
        this.router.navigate([
          '../users',
          { outlets: { detail: null } }
        ], {
          skipLocationChange: true,
          relativeTo: this.activatedRoute,
        })
        this.selectedRow = undefined
      }
      showDetail(userId: string) {
        this.router.navigate([
          '../users',
          { outlets: { detail: ['user', { userId: userId }] }
        }],
          {
            skipLocationChange: true,
            relativeTo: this.activatedRoute,
          }
        )
      }  
      ngAfterViewInit() {
        this.sort.sortChange
          .pipe(
            tap(() => this.resetPage()),
            takeUntilDestroyed(this.destroyRef)
          )
          .subscribe()
        this.paginator.page
          .pipe(
            tap(() => this.resetPage(true)),
            takeUntilDestroyed(this.destroyRef)
          )
          .subscribe()
        if (this.skipLoading) {
          return
        }     
        setTimeout(() => {
          **this****.****items$** **=** **merge****(**
            **this****.****refresh$****,**
            **this****.****sort****.****sortChange****,**
            **this****.****paginator****.****page****,**
            **this****.****search****.****valueChanges****.****pipe****(**
              **debounceTime****(****1000****),**
              **tap****(****() =>****this****.****resetPage****())**
            **)**
          **).****pipe****(**
            **startWith****({}),**
            **switchMap****(****() =>** **{**
              **this****.****isLoading** **=** **true**
              **return****this****.****userService****.****getUsers****(**
                **this****.****paginator****.****pageSize****,**
                **this****.****search****.****value****as****string****,**
                **this****.****paginator****.****pageIndex****,**
                **this****.****sort****.****active****,**
                **this****.****sort****.****direction**
              **)**
            **}),**
            **map****(****(****results****: { total:** **number****; data: IUser[] }****) =>** **{**
              **this****.****isLoading** **=** **false**
              **this****.****hasError** **=** **false**
              **this****.****resultsLength** **= results.****total**
              **return** **results.****data**
            **}),**
            **catchError****(****(****err****) =>** **{**
              **this****.****isLoading** **=** **false**
              **this****.****hasError** **=** **true**
              **this****.****errorText** **= err**
              **return****of****([])**
            **}),**
            **takeUntilDestroyed****(****this****.****destroyRef****),**
          **)**
        **})**
      }
    } 
    

    我们定义并初始化各种属性以支持加载分页数据。items$ 存储定义在数据表上显示的数据的可观察流。displayedColumns,一个计算信号,定义了表格的列。为了动态显示或隐藏列,我们可以使用一个信号定义一个切换器,例如 demoViewDetailsColumn。由于这个信号在计算信号中被引用,当它更新时,计算信号也会更新,这将在表格上得到反映。paginatorsort 提供分页和排序首选项,.search 提供我们用于通过文本过滤结果的文本。

    resetPage 帮助将分页重置到第一页并隐藏详细视图。这在搜索、分页或排序事件之后很有用,否则将显示随机记录的详细视图。

    showDetail 使用路由器在名为 detail 的出口处显示所选记录的详细视图。在本节稍后,我们将介绍在模板中实现相同链接的版本。我故意包含了这两个选项,这样你可以看到它们是如何实现的。

    我故意在代码库中以下代码被注释掉:

    // this.outletCloser.closeOutlet('detail') 
    

    我发现,在某些情况下,路由器可能无法优雅地关闭出口。位于 common 文件夹中的 OutletCloserService 可以从任何上下文中无麻烦地关闭任何出口。

    关于安德鲁·斯科特原始版本的引用,请参阅 stackblitz.com/edit/close-outlet-from-anywhere

    魔法发生在ngAfterViewInit中。我们首先订阅sortpaginator变化事件,以便我们可以正确地重置表格。接下来,我们使用setTimeout调用内的merge方法,如前一个片段中突出显示的,来监听影响需要显示的数据的分页、排序和筛选属性的变化。如果某个属性发生变化,整个管道就会被触发。

    为什么setTimeout是必要的?因为我们使用从模板中提取的 paginator 和 sort 的引用,我们必须使用ngAfterViewInit生命周期钩子。然而,在这个时候,Angular 已经为 Material 数据表组件设置了dataSource属性。如果我们使用merge操作符重新分配它,我们将得到 NG0100 ExpressionChangedAfterItHasBeenCheckedError。使用setTimeout将重新分配推入下一个变化检测周期,从而避免错误。

    这与我们在AuthService中实现登录例程的方式类似。该管道包含对this.userService.getUsers的调用,该调用将根据传入的分页、排序和筛选偏好检索用户。然后结果被管道传输到this.items$ Observable,数据表通过async管道订阅它以显示数据。

    没有必要订阅this.items$,因为 Material 数据表已经内部订阅了它。如果你订阅,每次对服务器的调用都将执行两次。

    然而,你必须注意将takeUntilDestroyed调用放在管道中的最后一个元素。否则,你可能会在调用之后泄漏合并后的订阅。

    cartant.medium.com/rxjs-avoiding-takeuntil-leaks-fb5182d047ef了解更多相关信息。

  5. 导入以下模块:

    **src/app/manager/user-table/user-table.****component****.****ts**
    imports: [
      AsyncPipe,
      FlexModule,
      FormsModule,
      MatButtonModule,
      MatFormFieldModule,
      MatIconModule,
      MatInputModule,
      MatPaginatorModule,
      MatProgressSpinnerModule,
      MatSlideToggleModule,
      MatSortModule,
      MatTableModule,
      MatToolbarModule,
      ReactiveFormsModule,
      RouterLink,
    ], 
    
  6. 实现userTable的 CSS:

    **src****/app/manager/user-****table****/user-****table****.component.scss**
    .loading-shade {
      position: absolute;
      top: 0;
      left: 0;
      bottom: 56px;
      right: 0;
      background: rgba(0, 0, 0, 0.15);
      z-index: 1;
      display: flex;
      align-items: center;
      justify-content: center;
    }
    .filter-row {
      min-height: 64px;
      padding: 8px 24px 0;
    }
    .full-width {
      width: 100%;
    }
    .mat-mdc-paginator {
      background: transparent;
    }
    /* row selection styles */
    .mat-mdc-row .mat-mdc-cell {
      border-bottom: 1px solid transparent;
      border-top: 1px solid transparent;
      cursor: pointer;
    }
    .mat-mdc-row:hover .mat-mdc-cell {
      border-color: currentColor;
      background-color: #efefef;
    }
    .selected {
      font-weight: 500;
      background-color: #efefef;
    } 
    

    在注释/* 行选择样式 */下面的样式有助于在点击单个行时辅助材料涟漪效果。

  7. 最后,实现userTable模板:

    **src/app/manager/user-table/user-table.component.html**
    <div fxLayout="row" fxLayoutAlign="end">
      <mat-slide-toggle
        [checked]="demoViewDetailsColumn()"
        (change)="demoViewDetailsColumn.set($event.checked)">
        Demo 'View Details' Column
      </mat-slide-toggle>
    </div>
    <div class="filter-row">
      <form style="margin-bottom: 32px">
        <div fxLayout="row">
          <mat-form-field class="full-width">
            <mat-icon matPrefix>search</mat-icon>
            <input matInput placeholder="Search" 
                   aria-label="Search" [formControl]="search" />
            <mat-hint>Search by e-mail or name</mat-hint>
            @if (search.invalid) {
              <mat-error>
                Type more than one character to search
              </mat-error>
            }
          </mat-form-field>
        </div>
      </form>
    </div>
    <div class="mat-elevation-z8">
      **@if (isLoading) {**
        **<****div****class****=****"loading-shade"****>**
          **<****mat-spinner****></****mat-spinner****>**
        **</****div****>**
      **}**
      @if (hasError) {
        <div class="error">
          {{ errorText }}
        </div>
      }
      <mat-table
        class="full-width"
        **[****dataSource****]=****"items$"**
        matSort
        matSortActive="name"
        matSortDirection="asc"
        matSortDisableClear>
        <ng-container matColumnDef="name">
          <mat-header-cell *matHeaderCellDef mat-sort-header>
            Name
          </mat-header-cell>
          <mat-cell *matCellDef="let row">
            {{ row.fullName }}
          </mat-cell>
        </ng-container>
        <ng-container matColumnDef="email">
          <mat-header-cell *matHeaderCellDef mat-sort-header>
            E-mail
          </mat-header-cell>
          <mat-cell *matCellDef="let row">
            {{ row.email }}
          </mat-cell>
        </ng-container>
        <ng-container matColumnDef="role">
          <mat-header-cell *matHeaderCellDef mat-sort-header>
            Role
          </mat-header-cell>
          <mat-cell *matCellDef="let row">
            {{ row.role }}
          </mat-cell>
        </ng-container>
        <ng-container matColumnDef="_id">
          <mat-header-cell *matHeaderCellDef>
            View Details
          </mat-header-cell>
          <mat-cell *matCellDef="let row" 
                    style="margin-right: 8px">
            **<****button**
              **mat-icon-button**
              **[****routerLink****]=****"[**
                **'../users',**
                **{** 
                  **outlets: { detail: ['user', { userId: row._id }]** 
                **}** 
              **}]"**
              **[****skipLocationChange****]=****"true"****>**
              <mat-icon>visibility</mat-icon>
            </button>
          </mat-cell>
        </ng-container>
        <mat-header-row *matHeaderRowDef="displayedColumns()">
        </mat-header-row>
        <mat-row
          **matRipple**
          **(****click****)=****"selectedRow = row;** 
            **demoViewDetailsColumn() ? 'noop' : showDetail(row._id)"**
          [class.selected]="selectedRow === row"
          *matRowDef="let row; columns: displayedColumns()">
        </mat-row>
      </mat-table>
      <mat-toolbar>
        <mat-toolbar-row>
          <button mat-icon-button **(****click****)=****"refresh$.next()**">
            <mat-icon title="Refresh">refresh</mat-icon>
          </button>
          <span class="flex-spacer"></span>
          <mat-paginator [pageSizeOptions]="[5, 10, 25, 100]" 
                         [length]="resultsLength">
          </mat-paginator>
        </mat-toolbar-row>
      </mat-toolbar>
    </div> 
    

    注意loading-shade样式的实现,它在加载数据时在表格上放置一个旋转器。这是一个本地化旋转器的示例。在实现 NgRx/SignalState 的全局旋转器部分,我将介绍我们如何实现全局版本。大多数非常大的应用程序将需要一个本地化旋转器,以避免全局旋转器造成的过度全屏中断。

    我们将items$绑定到dataSource以激活 Observable。下面,带有[routerLink]="['../users', { outlets: { detail: ['user', { userId: row._id }] } }]"mat-icon-button使用上下文行变量来分配一个 URL,该 URL 将在detail出口中显示ViewUserComponentskipLocationChange确保浏览器中的 URL 不会更新为出口信息。

    注意,在routerLink中使用相对 URL'../users',如上所示,允许UserTableComponent从管理功能模块的上下文中解耦。这样,组件可以在其他上下文中重用,例如/owner/users/ceo/users,而不是硬编码为/manager/users

    在延迟加载的模块和命名出口中设置路由器可能会出错。

    您可以通过修改app.config.ts中的根提供者来启用路由器的调试模式,如下所示添加withDebugTracing函数:

    provideRouter(routes, withDebugTracing()), 
    

    进一步来说,matRipple指令在行被点击时启用 Material Design 涟漪效果。紧接着,我们实现点击处理程序。默认情况下,点击行将使用showDetail函数显示详细视图;否则,用户将在最右侧列的视图按钮上点击。

    最后,观察刷新按钮的点击,这会导致refresh$可观察对象更新。这将由我们在组件中实现的合并管道捕获。

    仅放置主视图,表格如下所示(确保您已更新到 Angular 的最新版本):

    图 9.4:用户表

    如果您点击行,ViewUserComponent将使用showDetails函数在详细出口中渲染,如下所示:

    计算机截图 自动生成描述

    图 9.5:行点击的主/详细视图

    注意行是如何被突出显示以表示选择的。如果您在右上角翻转演示“查看详情”列选项,您将取消隐藏查看详情列。

    如果您点击查看图标,ViewUserComponent将使用模板中的routerLink在详细出口中渲染,如下所示:

    图 9.6:主/详细视图图标点击

    在上一章中,我们实现了编辑按钮,由右上角的铅笔图标表示,将userId传递给UserProfile以编辑和更新数据。

  8. 点击编辑按钮,将被带到ProfileComponent,编辑用户记录,并验证您是否可以更新其他用户的记录。

  9. 确认您可以在数据表中查看更新的用户记录。

这本书中 LemonMart 的数据表分页演示完成了主要功能。在继续之前,请确保所有测试都已通过。

对于单元测试,我导入NameInputComponentViewUserComponent的具体实现,而不是使用angular-unit-test-helper中的createComponentMock函数。这是因为createComponentMock不足以将数据绑定到子组件。在进一步阅读部分,我包括了一篇由 Aiko Klostermann 撰写的博客文章,该文章涵盖了使用@Input()属性测试 Angular 组件。

实现的重任完成后,我们现在可以探索替代的架构、工具和库,以更好地理解针对各种需求构建 Angular 应用的最好方式。接下来,让我们探索 NgRx。

NgRx 存储和效果

第一章Angular 的架构和概念所述,NgRx 库基于 RxJS 将响应式状态管理引入 Angular。使用 NgRx 进行状态管理允许开发者编写原子化、自包含和可组合的代码片段,创建动作、还原器和选择器。这种响应式编程将副作用隔离在状态变化中。NgRx 是 RxJS 之上的抽象层,以适应Flux 模式

NgRx 有四个主要元素:

  • 存储:状态信息持久化的中心位置。您在存储中实现一个还原器以存储状态转换,并实现一个选择器以从存储中读取数据。这些都是原子化和可组合的代码片段。

    视图(或用户界面)通过使用选择器显示存储中的数据。

  • 动作:在整个应用中发生的独特事件。

    动作从视图触发,目的是将它们派发到存储中。

  • 派发器:这是一个向存储发送动作的方法。

    存储中的还原器监听派发的动作。

  • 效果:这是动作和派发器的组合。效果通常用于不是从视图中触发的动作。

让我们回顾以下 Flux 模式图,现在突出显示了一个效果

系统图,自动生成描述

图 9.7:通量模式图

让我们通过一个具体的例子来演示 NgRx 是如何工作的。为了保持简单,我们将利用 LocalCast 天气应用。

实现 LocalCast 天气应用的 NgRx

我们将在 LocalCast 天气应用中实现 NgRx 以执行搜索功能。考虑以下架构图:

天气预报图,自动生成描述

图 9.8:LocalCast 天气架构

我们将使用 NgRx 存储和效果库来实现我们的实现。NgRx 存储动作在图中以浅灰色表示,包括WeatherLoaded还原器和应用状态。在顶部,动作表示为各种数据对象流,要么派发动作,要么对派发的动作进行操作,使我们能够实现第一章Angular 的架构和概念中引入的Flux 模式。NgRx 效果库通过在其模型中隔离副作用来扩展 Flux 模式,而不会在存储中留下临时数据。

图 9.8中以深灰色表示的效果工作流程从步骤 1开始:

  1. CitySearchComponent派发search动作。

  2. search动作出现在@ngrx/action可观察流(或数据流)中。

  3. CurrentWeatherEffectssearch动作进行操作以执行搜索。

  4. WeatherService 执行搜索以从 OpenWeather API 获取当前天气信息。

存储动作,以浅灰色表示,以 step A(大写 A)开头:

  1. CurrentWeatherEffects 分派 weatherLoaded 动作。

  2. weatherLoaded 动作出现在 Observable 数据流上,标记为 @ngrx/action 流。

  3. weatherLoaded 约束对 weatherLoaded 动作进行操作。

  4. weatherLoaded 约束将天气信息转换为要存储的新状态。

  5. 新状态是一个持久化的 search 状态,是 appStore 状态的一部分。

注意,有一个包含子 search 状态的父级 appStore 状态。我故意保留了这种设置,以展示父级状态如何随着你在存储库中添加不同类型的数据元素而扩展。

最后,一个视图(一个 Angular 组件)从存储中读取,以 step a(小写 a)开始:

  1. CurrentWeather 组件使用 async 管道订阅 selectCurrentWeather 选择器。

  2. selectCurrentWeather 选择器监听 appStore 状态中 store.search.current 属性的变化。

  3. appStore 状态检索持久化的数据。

使用 NgRx 选择器就像编写查询来读取存储在数据库中的数据。在这种情况下,数据库是存储库。

使用 NgRx,当用户搜索城市时,检索、持久化和在 CurrentWeatherComponent 上显示该信息的动作将自动通过单个可组合和不可变元素发生。

比较 BehaviorSubject 和 NgRx

我们将实现 NgRx 与 BehaviorSubject 一起,以便你可以看到实现相同功能的差异。为此,我们需要一个滑动开关来在两种策略之间切换:

本节使用 local-weather-app 仓库。你可以在 projects/stage12 文件夹下找到本章的代码示例。

注意,位于 src 文件夹下的主应用程序使用按钮切换在 SignalsBehaviorSubjectNgRx 之间切换。

  1. 首先,在 CitySearchComponent 上实现一个 <mat-slide-toggle> 元素,如下面的截图所示:天气预报截图 自动生成的描述

    图 9.9:LocalCast 天气滑动切换

    确保字段由组件上的名为 useNgRx 的属性支持。

  2. 重构 doSearch 方法,将 BehaviorSubject 代码提取到名为 behaviorSubjectBasedSearch 的单独函数中。

  3. 创建一个名为 ngRxBasedSearch 的函数原型:

    **src/app/city-search/city-search.****component****.****ts**
    doSearch(searchValue: string) {
      const userInput = searchValue.split(',').map((s) => s.trim())
      const searchText = userInput[0]
      const country = userInput.length > 1 ? userInput[1] : undefined
      **if** **(****this****.****useNgRx****) {**
        **this****.****ngRxBasedSearch****(searchText, country)**
      **}** **else** **{**
        **this****.****behaviorSubjectBasedSearch****(searchText, country)**
      **}**
    } 
    

我们将从你刚刚创建的 ngRxBasedSearch 函数中分派一个动作。

设置 NgRx

你可以使用以下命令添加 NgRx Store 包:

$ npx ng add @ngrx/store 

这将创建一个包含 index.ts 文件的 reducers 文件夹。现在添加 NgRx 的 effects 包:

$ npx ng add @ngrx/effects --minimal 

我们在这里使用 --minimal 选项以避免创建不必要的样板代码。

接下来,安装 NgRx 规范库,这样你就可以利用生成器为你创建样板代码:

$ npm i -D @ngrx/schematics 

由于 NgRx 的高度解耦特性,实现 NgRx 可能会令人困惑,这可能需要了解库的内部工作原理。

projects/stage12下的示例项目配置了@ngrx/store-devtools进行调试。

如果你希望在运行时能够console.log NgRx 动作进行调试或监控,可以使用 NgRx 文档中描述的 MetaReducer,ngrx.io/guide/store/metareducers

定义 NgRx 动作

在我们能够实现影响或还原器之前,我们首先需要定义我们的应用程序将能够执行的动作。对于 LocalCast Weather,有两种类型的动作:

  • search:获取正在搜索的城市或邮编的当前天气

  • weatherLoaded:表示已获取新的当前天气信息

通过运行以下命令创建名为search的动作:

$ npx ng generate @ngrx/schematics:action search --group --creators 

在提示时选择默认选项。

--group选项将动作分组在名为action的文件夹下。--creators选项使用创建函数来实现动作和还原器,这是一种更熟悉且直接的方式来实现这些组件。

现在,让我们使用createAction函数实现这两个动作,提供名称和预期的输入参数列表:

**src/app/action/search.****actions****.****ts**
import { createAction, props, union } from '@ngrx/store'
import { ICurrentWeather } from '../interfaces'
export const SearchActions = {
  search: createAction(
    '[Search] Search',
    props<{ searchText: string; country?: string }>()
  ),
  weatherLoaded: createAction( 
    '[Search] CurrentWeather loaded',
    props<{ current: ICurrentWeather }>()
  ),
}
const all = union(SearchActions)
export type SearchActions = typeof all 

搜索操作名为'[Search] Search',输入参数包括searchText和一个可选的country参数。weatherLoaded操作遵循类似的模式。在文件末尾,我们创建了一个动作的联合类型,这样我们就可以将它们分组在单个父类型下,以便在应用程序的其余部分中使用。

注意,动作名称前缀为[Search]。这个约定有助于开发者在调试期间视觉上分组相关的动作。

现在我们已经定义了动作,我们可以实现影响来处理搜索动作并分发一个weatherLoaded动作。

实现 NgRx 影响

如前所述,影响允许我们更改存储的状态,而无需存储导致更改的事件数据。例如,我们希望我们的状态只有天气数据,而不是搜索文本本身。影响允许我们一步完成此操作,而不是强迫我们使用中间存储来存储searchText,以及一个更复杂的链式事件来将其转换为天气数据。

否则,我们可能需要在中间实现一个还原器。我们首先需要将此值存储在 NgRx 存储中,然后从服务中检索它,最后分发一个weatherLoaded动作。影响将使从服务中检索数据变得更加简单。

现在,让我们将CurrentWeatherEffects添加到我们的应用程序中:

$ npx ng generate @ngrx/schematics:effect currentWeather --module=app.module.ts --root --group --creators 

在提示时选择默认选项。

你将在effects文件夹下创建一个新的current-weather.effects.ts文件。

再次强调,--group用于将影响分组在同名文件夹下。--root将影响注册到app.module.ts中,我们使用带有--creators选项的创建函数。

CurrentWeatherEffects文件中,首先实现一个私有的doSearch方法:

**src/app/effects/current-weather.****effects****.****ts**
private doSearch(action: { searchText: string; country?: string }) {
  return this.weatherService.getCurrentWeather(
    action.searchText,
    action.country
  ).pipe(
    map((weather) =>
      SearchActions.weatherLoaded({ current: weather })
    ),
    catchError(() => EMPTY)
  )
} 

注意,我们选择忽略由EMPTY函数抛出的错误。您可以使用类似于为 LemonMart 实现的UiService将这些错误暴露给用户。

这个函数接受一个带有搜索参数的操作,调用getCurrentWeather,并在收到响应后,派发weatherLoaded操作,传递当前天气属性。

现在,让我们创建效果本身,这样我们就可以触发doSearch函数:

**src/app/effects/current-weather.****effects****.****ts**
getCurrentWeather$ = createEffect(() =>
  this.actions$.pipe(
    ofType(SearchActions.search), 
    exhaustMap((action) => this.doSearch(action))
  )
) 

这是我们利用 Observable 操作流this.actions$并监听SearchAction.search类型操作的地方。然后我们使用exhaustMap操作符来注册发射的事件。

由于其独特的性质,exhaustMap不会允许在doSearch函数完成派发weatherLoaded操作之前处理另一个搜索操作。

RxJS 操作符对操作的影响

在前面的例子中,我使用了exhaustMap操作符。这并不一定是这个用例的正确 RxJS 操作符,switchMap才是。我选择exhaustMap的明确目的是为了限制对免费资源生成的 API 调用数量,这样可以积极限制请求的速率。

让我们探索我们可以选择的四个 RxJS 操作符:

  1. mergeMap:允许并行处理多个操作,适用于每个操作的效果是独立且不需要同步的场景。

  2. concatMap:按顺序处理操作,只有在前一个操作完成之后才开始下一个操作,确保操作按它们被派发的顺序处理,这对于在状态更新中保持一致性很有用。

  3. switchMap:在接收到新操作时,取消之前的操作并切换到新的操作,这对于搜索栏输入等用例非常合适,在这些用例中,只有最新的操作(例如,用户输入)是相关的。

  4. exhaustMap:如果已有操作正在处理,则忽略新操作,这使得它对于避免重复或冲突请求很有用,例如,多次提交相同的表单。

使用exhaustMap,如果doSearch函数在创建操作之前快速创建,则尚未处理的操作将被丢弃。所以,如果创建了操作abcde,但doSearchcd创建之间完成,那么操作bce将永远不会被处理,但操作d将会被处理。对于bce的 API 调用永远不会发生。只有为d发出的weatherLoaded操作。虽然我们避免了为用户永远不会看到的成果进行不必要的 API 调用,但最终状态可能会让用户感到困惑。

使用mergeMap,所有搜索操作都是并行处理的,进行 API 调用,并派发weatherLoaded操作。所以,如果快速创建了操作abcde,用户可能会看到所有操作的闪烁结果,但最后显示的是e

使用concatMap,动作按顺序处理。考虑到动作abcde,对于b的 API 调用不会在为a分发了weatherLoaded动作并渲染结果之后进行。这将为每个动作发生,直到显示e的天气。

使用switchMap,每个动作都会进行 API 调用。然而,只有最后一个动作会被分发,所以用户只会看到最后一个动作显示。

因此,从 UX 的角度来看,switchMap在功能上是正确的实现。您还可以在数据处理时实现加载指示器或禁用用户输入,以防止昂贵的 API 调用。

最终,根据您的用例和 UX 需求,考虑使用不同的 RxJS 操作符。并非所有分发的动作都会导致需要渲染的屏幕。如果您想保留所有数据输入,您可以在服务工作者后台线程中处理动作,并更新应用中的通知面板或徽章计数器。

实现 reducers

当触发weatherLoaded动作时,我们需要一种方法来摄取当前天气信息并将其存储在我们的appStore状态中。Reducer 将帮助我们处理特定动作,创建一个隔离且不可变的管道以可预测地存储我们的数据。

让我们创建一个searchreducer:

$ npx ng generate @ngrx/schematics:reducer search 
    --reducers=reducers/index.ts --group --creators 

使用默认选项。在这里,我们使用--group来保持文件在reducers文件夹下组织,并使用--creators来利用创建 NgRx 代码的创建者风格。我们还使用--reducers指定我们的父appStore状态的位置在reducers/index.ts,以便我们的新 reducer 可以与之注册。

您可能会注意到reducers.index.ts已更新以注册新的search.reducer.ts。让我们一步一步实现它。

search状态中,我们将存储当前天气,因此实现接口以反映这一点:

**src/app/reducers/search.****reducer****.****ts**
export interface State {
  current: ICurrentWeather
} 

现在让我们指定initialState。这类似于我们需要定义signalBehaviorSubject的默认值。重构WeatherService以导出const defaultWeather: ICurrentWeather对象,您可以使用它来初始化BehaviorSubjectinitialState

**src/app/reducers/search.****reducer****.****ts**
export const initialState: 
  State = { 
      current: defaultWeather,
  } 

最后,使用on操作符实现searchReducer来处理weatherLoaded动作:

**src/app/reducers/search.****reducer****.****ts**
const searchReducer = createReducer(
  initialState,
  on(SearchActions.weatherLoaded, (state, action) => {
    return {
      ...state,
      current: action.current,
    }
  })
) 

我们注册了weatherLoaded动作,解包存储的数据,并将其传递到search状态。

当然,这是一个非常简单的例子。然而,很容易想象一个更复杂的场景,我们可能需要扁平化或处理接收到的数据并将其以易于消费的方式存储。以不可变的方式隔离这种逻辑是利用像 NgRx 这样的库的关键价值主张。

使用选择器在 Store 中注册

我们需要CurrentWeatherComponent注册到appStore状态以获取更新的当前天气数据。

首先,依赖注入appStore状态并注册选择器以从State对象中提取当前天气:

**src/app/current-weather/current-weather.****component****.****ts**
**import** ***** **as** **appStore** **from****'../reducers'**
export class CurrentWeatherComponent {
  current$: Observable<ICurrentWeather>
  constructor(**private****store****:** **Store****<appStore.****State****>**) {
    this.current$ =
      **this****.****store****.****pipe****(****select****(****(****state****:** **State****) =>** **state.****search****.****current****))**
  } 
  ...
} 

我们监听通过存储流动的状态变化事件。使用select函数,我们可以实现内联选择以获取所需的数据。

我们可以稍作重构,通过使用createSelectorreducers/index.ts上创建selectCurrentWeather属性来使我们的选择器可重用:

**src/app/reducers/index.****ts**
export const selectCurrentWeather = createSelector(
  (state: State) => state.search.current,
  current => current
) 

随着 TypeScript 接口和 NgRx 选择器的数量增加,您应该将它们拆分为单独的文件,并更好地组织您的代码。

此外,由于我们希望保持BehaviorSubject的持续操作,我们可以在CurrentWeatherComponent中实现一个merge运算符来监听WeatherService更新和appStore状态更新:

**src/app/current-weather/current-weather.****component****.****ts**
import * as appStore from '../reducers'
  constructor(
    private weatherService: WeatherService,
    private store: Store<appStore.State>
  ) {
    this.current$ = merge(
      **this****.****store****.****pipe****(****select****(appStore.****selectCurrentWeather****)),**
      this.weatherService.currentWeather$
    )
  } 

现在我们能够监听存储更新,让我们实现最终的拼图碎片:分发搜索动作。

分发存储动作

我们需要分发搜索动作,以便我们的搜索效果可以获取当前天气数据并更新存储。在本章的早期,您在CitySearchComponent中实现了一个名为ngRxBasedSearch的存根函数。

让我们实现ngRxBasedSearch

**src/app/city-search/city-search.****component****.****ts**
ngRxBasedSearch(searchText: string, country?: string) {
  this.store.dispatch(SearchActions.search({ searchText, country }))
} 

不要忘记将appState存储注入到组件中!

就这样!现在您应该能够运行您的代码并测试是否一切正常。

如您所见,NgRx 带来了许多复杂的技巧来创建使数据转换不可变、定义良好和可预测的方法。然而,这伴随着相当大的实现开销。

使用您的最佳判断来决定您是否需要在您的 Angular 应用程序中使用 Flux 模式。前端应用程序代码可以通过实现返回平面数据对象的 RESTful API 而变得简单得多,复杂的数据操作由服务器端处理,从而减少,如果不是消除,对像 NgRx 这样的工具的需求。

单元测试 reducer 和选择器

您可以在search.reducer.spec.ts中对weatherLoadedreducer 和selectCurrentWeather选择器实现单元测试:

**src/app/reducers/search.****reducer****.****spec****.****ts**
import { SearchActions } from '../actions/search.actions'
import { defaultWeather } from '../weather/weather.service'
import { fakeWeather } from '../weather/weather.service.fake'
import { selectCurrentWeather } from './index'
import { initialState, reducer } from './search.reducer'
describe('Search Reducer', () => {
  describe('weatherLoaded', () => {
    it('should return current weather', () => {
      const action = SearchActions.weatherLoaded({ current: fakeWeather })
      const result = reducer(initialState, action)
      expect(result).toEqual({ current: fakeWeather })
    })
  })
})
describe('Search Selectors', () => { 
  it('should selectCurrentWeather', () => {
    const expectedWeather = defaultWeather
    expect(selectCurrentWeather({
      search: { current: defaultWeather }
    })).toEqual(
      expectedWeather
    )
  })
}) 

这些单元测试很简单,将确保在存储中不会发生对数据结构的意外更改。

使用 MockStore 单元测试组件

您需要更新CurrentWeatherComponent的测试,以便我们可以将模拟的Store注入到组件中以测试current$属性的值。

让我们看看需要添加到spec文件中的 delta,以配置模拟存储:

**src/app/current-weather/current-weather.****component****.****spec****.****ts**
import { MockStore, provideMockStore } from '@ngrx/store/testing'
describe('CurrentWeatherComponent', () => {
  ...
  let store: MockStore<{ search: { current: ICurrentWeather } }>
  const initialState = { search: { current: defaultWeather } }
  beforeEach(async(() => {
    ...
    TestBed.configureTestingModule({
      imports: [AppMaterialModule],
      providers: [
        ...
        **provideMockStore****({ initialState }),**
      ],
    }).compileComponents()
    ...
    **store =** **TestBed****.****inject****(****Store****)** **as****any**
  }))
...
}) 

我们现在可以更新'should get currentWeather from weatherService'测试,以查看CurrentWeatherComponent是否与模拟存储一起工作:

**src/app/current-weather/current-weather.****component****.****spec****.****ts**
it('should get currentWeather from weatherService', (done) => {
  // Arrange
  store.setState({ search: { current: fakeWeather } })
  weatherServiceMock.currentWeather$.next(fakeWeather)
  // Act
  fixture.detectChanges() // triggers ngOnInit()
  // Assert
  expect(component.current$).toBeDefined()
  component.current$.subscribe(current => { 
    expect(current.city).toEqual('Bethesda')
    expect(current.temperature).toEqual(280.32)
    // Assert on DOM
    const debugEl = fixture.debugElement
    const titleEl: HTMLElement =
      debugEl.query(By.css('.mat-title')).nativeElement
    expect(titleEl.textContent).toContain('Bethesda')
    done()
  })
}) 

模拟存储允许我们设置存储的当前状态,这反过来又允许选择器在构造函数中调用以触发并获取提供的虚假天气数据。

TestBed 并非在 Angular 中编写单元测试的硬性要求,关于这个话题在 angular.dev/guide/testing 有很好的介绍。我的同事和第二版的审稿人布伦登·考林斯为这一章贡献了一个无床的 spec 文件,名为 current-weather.component.nobed.spec.ts。他提到在运行测试时,由于减少了导入和维护,性能有显著提升,但需要更高水平和专业知识来实现测试。如果你在一个大型项目中,考虑跳过 TestBed

GitHub 上的示例代码位于 projects/stage12 文件夹下。

继续更新你剩余的测试,直到它们全部通过后再继续。

NgRx 生态系统

现在你对 NgRx 的理解已经超越了理论层面,让我们来检查生态系统中的不同可用选项。

这里有一些社区中流行的选项,包括 NgRx 的兄弟包:

  • NgRx/Data,一个简化实体管理的 NgRx 入门

  • NgRx/ComponentStore,NgRx/Store 的组件范围版本,减少了样板代码

  • NgRx/SignalStore,Angular 中下一代状态管理

  • Akita,为 JS 应用量身定制的响应式状态管理解决方案

  • Elf,一个具有神奇力量的响应式存储

让我们探索这些选项。

NgRx/Data

如果 NgRx 是基于配置的框架,那么 NgRx Data 就是 NgRx 的基于约定的兄弟。NgRx Data 自动创建存储、效果、动作、还原器、分发和选择器。如果你的应用程序的大部分动作是 CRUD创建检索更新删除)操作,那么 NgRx Data 可以用更少的代码实现与 NgRx 相同的结果。

@ngrx/data@ngrx/entity 库协同工作。它们共同提供了一套丰富的功能,包括事务性数据管理。

NgRx Data 可能是你和你的团队更好地了解 Flux 模式的一个很好的入门,它允许轻松地过渡到完整的 NgRx 框架。不幸的是,NgRx Data 已不再推荐用于新项目。

截至 17 版本,NgRx Data 正式进入维护模式,不推荐用于新项目或添加到现有项目中。

你可以在 ngrx.io/guide/data 上了解更多相关信息。

你可以通过执行以下命令将 NgRx Data 添加到你的项目中:

$ npx ng add @ngrx/store –minimal
$ npx ng add @ngrx/effects –minimal
$ npx ng add @ngrx/entity
$ npx ng add @ngrx/data 

那么,你应在你的下一个应用中实现 NgRx Data 吗?这取决于情况,但鉴于其维护模式的状态,可能不太适合。由于这个库是 NgRx 之上的抽象层,如果你没有很好地理解 NgRx 的内部结构,你可能会感到迷茫和受限。然而,这个库在减少实体数据管理和 CRUD 操作的样板代码方面有很大的潜力。

如果你正在应用程序中执行大量 CRUD 操作,你可能可以节省时间,但请注意将实现范围限制在需要它的区域。正如 NgRx 文档所强调的,NgRx Data 缺乏许多功能齐全的实体管理系统功能,如深度实体克隆、服务器端查询、关系、键生成和非规范化服务器响应。

对于一个功能齐全的实体管理库,可以考虑 BreezeJS www.getbreezenow.com/breezejs。然而,请注意 Breeze 并不遵循 NgRx 所采用的响应式、不可变和 Redux 原则。

接下来,让我们调查 ComponentStore 在 Flux 模式更不具挑战性和更专注的应用。

NgRx/ComponentStore

NgRx ComponentStore 提供了一种轻量级、响应式的状态管理解决方案,非常适合组件或模块内的本地状态。

它旨在在不需要全局存储的情况下管理本地状态,保持关注点的清晰分离,并使组件简单易维护。这种方法对于具有许多本地状态和交互的复杂组件尤其有用,因为它允许基于推的服务管理此状态,支持可重用性和独立实例。

你可以使用ComponentStore实现分页数据表的 dataSource,类似于使用 Elf 的方式。查看 Pierre Bouillon 这篇出色的两篇博客文章:dev.to/this-is-angular/handling-pagination-with-ngrx-component-stores-1j1p

相比之下,NgRx Store 管理全局共享状态,对需要可扩展性、多个效果和 DevTools 集成的较大应用程序有益。虽然 ComponentStore 的可扩展性较低,并且有许多更新器和效果,但它确保了类型安全、性能和易于测试,从而允许更封装和组件特定的状态管理。

ComponentStoreStore之间的选择取决于应用程序的大小、组件依赖、状态持久性和业务需求等因素。

ngrx.io/guide/component-store了解更多关于 ComponentStore 的信息。

你可以通过执行以下操作将 ComponentStore 添加到你的项目中:

$ npx ng add @ngrx/component-store 

简而言之,ComponentStore是本书中提到的“带有 Subject 的服务”方法的替代方案。然而,随着 Angular 架构向信号转变,你可能想要跳过ComponentStore并实现SignalStore

NgRx/Signals

在第二章“表单、可观察者、信号和主题”的“使用 Angular 信号”部分,我向您介绍了信号。NgRx/Signals 是一个自包含的库,它提供了一个反应式状态管理解决方案,并附带了一套用于处理 Angular 信号的实用工具。它旨在简单易用,为开发者提供了一个直观的 API。其轻量级特性确保了应用程序负载最小,同时保持高性能。

该库推崇声明式编程,培养简洁的代码。它促进了自主组件的构建,这些组件易于集成,促进了可扩展和灵活的应用程序。此外,它强制执行类型安全,在开发周期的早期阶段减少错误。

该库包括以下内容:

  • SignalStore 是一个强大的状态管理系统,它从 NgRx/Store 和 NgRx/ComponentStore 中汲取了最佳之处。

  • SignalState 是一个简化的实用工具,用于在 Angular 组件和服务中管理状态,它取代了服务中任何需要自行管理的信号属性。

  • rxMethod 提供了可选的使用方式来与 Observables 交互。这对于与现有代码交互非常有用。

  • withEntities 是一个实体管理插件,提供了一种高效的方法来促进 CRUD 操作,以管理实体。

我们将在接下来的章节中深入探讨 SignalState 和 SignalStore。

您可以在 ngrx.io/guide/signals 上了解更多关于 NgRx Signals 的信息。

您可以通过执行以下命令将 SignalStore 添加到您的项目中:

$ npx ng add @ngrx/signals 

让我们通过一些流行的非 NgRx 选项,如 Akita 和 Elf,来结束我们的状态管理生态系统之旅。

Akita

Akita 是一个将 Flux、Redux 和 RxJS 概念结合到可观察数据存储模型中的状态管理解决方案,它倾向于不可变性和流式数据。它强调简单性,减少了样板代码,由于其适中的学习曲线,使得所有级别的开发者都能轻松上手。Akita 采用面向对象原则,对于那些熟悉面向对象编程的人来说,它使代码更加直观,并强制执行一致的结构来指导和标准化团队的开发实践。

Akita 围绕 RxJS 的 BehaviorSubject 构建,并为状态管理提供了专门的类,如 StoreQueryEntityStore。与 NgRx 类似,Akita 将状态变化暴露为 RxJS Observables,并使用更新方法进行状态突变,从而实现面向对象的状态管理风格。

如果您正在寻找一个具有内置实体管理、状态历史记录插件、服务器端分页、更多面向对象而非函数式,以及总体上更少的样板代码的简单解决方案,那么尝试 Akita 是值得的。

您可以在 opensource.salesforce.com/akita/ 上了解更多关于 Akita 的信息。

Elf

Elf 是众多选项中最神奇的一个。它是一个针对 Angular 的新状态管理库,旨在通过最小化 API 简化反应性和状态突变,专注于人体工程学和易用性。它使用现代 RxJS 模式进行状态管理,使您能够对状态变化和反应性进行精细控制。Elf 设计得轻量级且直观,为更全面的 NgRx 套件提供了一个更简单的替代方案。

Elf 是模块化的,完全可摇树(tree-shakable),并提供了一级支持以下功能:

  • 请求缓存,以防止冗余的 API 调用。

  • 实体,如 NgRx/Data 或 Akita。

  • 状态持久化,适用于离线优先的应用程序。

  • 状态历史,便于实现撤销/重做功能。

  • 高级分页,以优化分页数据的获取和缓存。

  • Elf 集成了构建具有状态管理和声明式 Web 应用的功能和最佳实践,并使其变得简单。虽然可以使用 NgRx/ComponentStore 实现像分页支持这样的功能,但内置的分页缓存支持令人印象深刻。此外,Elf 还有一个插件,可以同步浏览器标签页之间的状态,从而实现真正的高级状态管理。

  • 考虑到 Elf 内置的众多高质量功能,它是一个突出的解决方案,可能是你下一个项目的正确选择。你可以在 ngneat.github.io/elf/ 上了解更多关于 Elf 的信息。

我们已经涵盖了 NgRx 生态系统的细微差别。让我们学习如何使用 Angular 配置代理,以处理期望以特定方式访问服务器端数据的基于约定的状态管理库。

使用 Angular CLI 配置服务器代理

一些状态管理库,尤其是基于约定的实体存储库如 NgRx Data,对访问服务器端数据做出了假设。在 NgRx Data 的情况下,库希望通过与你 Angular 应用在同一端口上运行的 /api 路径来访问 REST API。我们必须利用 Angular CLI 的代理功能来实现这一目标,在开发过程中完成。

通常,HTTP 请求会发送到我们的 web 服务器,并且我们的 API 服务器应该有相同的 URL。然而,在开发过程中,我们通常在 http://localhost 的两个不同端口上托管这两个应用程序。某些库,包括 NgRx Data,要求 HTTP 调用在同一个端口上。这为创建无摩擦的开发体验带来了挑战。因此,Angular CLI 随带了一个代理功能,你可以用它将 /api 路径指向 localhost 上的不同端点。这样,你可以使用一个端口来服务你的 web 应用和 API 请求:

  1. src 下创建一个 proxy.conf.json 文件,如下所示:

如果你正在使用 lemon-mart-server 单一仓库,这将位于 web-app/src

**proxy.conf.json**
{
  "/api": {
    "target": "http://localhost:3000",
    "secure": false,
    "pathRewrite": {
       "^/api": ""
    }
  }
} 
  1. 使用 angular.json 注册代理:

    **angular.json**
    ...
    "serve": {
      "builder": "@angular-devkit/build-angular:dev-server",
      "options": {
        "browserTarget": "lemon-mart:build",
        "proxyConfig": "proxy.conf.json"
      },
      ...
    } 
    

现在当您运行npm startng serve时启动的服务可以重写对/api路由的任何调用 URL 为http://localhost:3000。这是lemon-mart-server默认运行的端口。

如果您的 API 在不同的端口上运行,请使用正确的端口号和子路由。

使用 NgRx/SignalState 实现全局加载指示器

在第八章的“多步骤响应式表单”部分,以及本章前面的“数据表格与分页”部分,我讨论了本地化加载指示器和全局加载指示器之间的区别。全局加载指示器是解决 UI 元素在数据加载时未准备好交互而产生的 UX 问题的 80-20 解决方案。然而,这将在具有多个屏幕组件或后台服务工作者加载数据的大型应用程序中引起过多的全屏中断。在这种情况下,大多数组件将需要本地加载指示器。

考虑到这一点,让我们追求 80-20 解决方案。我们可以使用HttpInterceptor来检测应用程序内是否进行了 API 调用。这允许我们显示或隐藏全局加载指示器。然而,如果有多个并发调用,我们必须跟踪这一点,否则全局加载指示器可能会行为异常。使用 NgRx/SignalState,我们可以跟踪调用次数,而无需在服务中引入本地状态。

NgRx/SignalState

SignalState 是@ngrx/signals提供的一个轻量级实用工具,用于以简洁和极简的方式在 Angular 组件和服务中管理基于信号的状态。它用于在组件类、服务或独立函数中直接创建和操作状态的小部分。您可以提供一个对象属性的深层嵌套信号。

SignalState 应在组件或服务中使用来管理简单的状态。该库提供了以下函数:

  • signalState是一个实用函数,它接受存储的初始状态并定义状态的结构。

  • patchState更新存储的值。

ngrx.io/guide/signals/signal-state中了解更多关于 NgRx SignalState 的信息。

我们首先将signalStatecomputed signalshowLoaderhideLoader函数添加到UiService中:

  1. 按照以下方式修改UiService

    **src/app/common/ui.****service****.****ts**
    @Injectable({ providedIn: 'root' })
    export class UiService {
      ...
      private readonly loadState = **signalState**({ 
        count: 0, 
        isLoading: false 
      })
      isLoading = **computed**(() => this.loadState.isLoading())
      showLoader() {
        if (this.loadState.count() === 0) {
          **patchState**(this.loadState, () => ({ isLoading: true }))
        }
        **patchState**(this.loadState, (state) => ({ 
          count: state.count++ 
        }))}
      hideLoader() {
        **patchState**(this.loadState, (state) => ({ 
          count: state.count—
        }))
        if (this.loadState.count() === 0) {
          **patchState**(this.loadState, () => ({ isLoading: false }))
        }
      }  
      ...
    } 
    

    我们首先定义一个私有的signalState并初始化countisLoading属性。状态应该始终封装在使用它的边界内,以避免不可控的副作用。正如我们在下一节中将要讨论的,SignalStore 是管理副作用的一个更健壮的解决方案。然而,我们希望isLoading是公开可用的,以便 UI 组件可以将其绑定以隐藏或显示加载指示器。因此,我们实现了一个computed信号,它作为一个选择器以只读方式返回isLoading的当前值。

    patchState 是一个实用函数,它提供了一种类型安全的方式来对状态片段进行不可变更新。我们使用它来更新 countisLoading 的值,每当调用 showhide 函数时。

  2. 接下来,在 src/common 下实现 LoadingHttpInterceptor 以调用 showhide 方法:

    **src/common/loading.****http****.****interceptor****.****ts**
    export function LoadingHttpInterceptor(
      req: HttpRequest<unknown>, next: HttpHandlerFn) {
      const uiService = inject(UiService)
      uiService.showLoader()
      return next(req).pipe(finalize(() => 
                            uiService.hideLoader()))
    } 
    

    我们注入 UiService 并调用 showLoader 以将计数加一。然后设置最终操作符,以便 API 调用完成后调用 hideLoader 以将计数减一。因此,每当调用加载器函数且计数等于零时,我们知道我们需要显示或隐藏旋转器。

    不要忘记在 app.config.ts 中提供新的拦截器。

  3. 现在,在 common 下创建一个 LoadingOverlayComponent 并使用 isLoading 来显示或隐藏一个旋转器:

    **src/common/loading-overlay.****component****.****ts**
    @Component({
      selector: 'app-loading-overlay',
      template: `
        **@if (uiService.isLoading()) {**
          <div class="overlay">
            <div class="center">
              <img alt="loading" class="spinner"
                        src="img/lemon.svg" />
            </div>
          </div>
        **}**
      `,
      styles: `
        .overlay {
          position: fixed;
          width: 100%;
          height: 100%;
          left: 0;
          top: 0;
          background-color: rgba(255, 255, 255, 0.65);
          z-index: 9999;
        }
        .spinner {
          display: block;
          width: 48px;
          height: 48px;
          animation-name: spin;
          animation-duration: 1.00s;
          animation-iteration-count: infinite;
          animation-timing-function: ease-in-out;
        }
        @keyframes spin {
          0% { transform: rotate(0deg); }
          100% { transform: rotate(360deg); }
        }
        .center {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
          }
      `,
      standalone: true,
      **encapsulation****:** **ViewEncapsulation****.****ShadowDom**,
    })
    export class LoadingOverlayComponent {
      readonly uiService = inject(UiService)
    } 
    

    我们注入并使用来自 UiService 的计算 isLoading 信号。通过 @if 流程控制,旋转器将根据 isLoading 是否设置为 true 或 false 来显示或隐藏。

    Angular 中的 ViewEncapsulation.ShadowDom 使用允许组件样式在 Shadow DOM 内封装。默认情况下,Angular 使用模拟模式将样式范围限定在组件内。然而,Shadow DOM 封装为动态 CSS 功能提供了更稳健的支持。

  4. 最后,更新 app.component.ts 以导入并将在模板顶部放置新组件:

    **src/app/app.****component****.****ts**
    template: `
        **<app-loading-overlay></app-loading-overlay>**
        <div class="app-container">
        ... 
    
  5. 尝试一下。每当进行 API 调用时,一个辉煌的柠檬旋转器将接管屏幕。柠檬登录截图 自动生成描述

    图 9.10:LemonMart 的柠檬旋转器

  6. 这解决了第八章,食谱 - 可重用性、表单和缓存中提到的用户资料表单中的数据弹出问题。

    你的 API 调用是否太快以至于无法欣赏旋转器?

    lemon-mart-server 中,你可以添加两秒的延迟:

    `server/src/v1/routes/authRouter.ts`
    router.get('/me', authenticate(), async (_req, res) => {
      `await setTimeout(2000)`
    ... 
    

    或者,你可以在浏览器 DevTools 的 网络 选项卡中将 无节流 下拉菜单更改为 Fast 3GSlow 3G

使用 HTML 和 CSS 预加载屏幕

如果你遵循上一节的提示,并将浏览器网络速度减慢到 Slow 3G 并禁用缓存,你会注意到任何东西在屏幕上显示都需要很长时间。在第三章,构建企业级应用中,我介绍了如何实现 服务器端渲染SSR)来克服此类问题。然而,这并不总是可行的,或者可能过于复杂。使用简单的 HTML 和 CSS,我们可以实现一个简单的方法来展示一个吸引人且动态的加载屏幕,以娱乐在慢速网络下盯着你的应用的用户。

让我们从添加 LemonMart 的 CSS 开始:

  1. src/assets/styles 下创建 spinner.css。从 LemonMart 的 GitHub 仓库复制内容至 github.com/duluca/lemon-mart/blob/main/src/assets/styles/spinner.css

  2. 更新 index.html 以导入样式表,并将必要的 HTML 放在 <app-root> 元素内部:

    **src/index.html** 
    <head>
      ...
      **<****link****href****=****"assets/styles/spinner.css"****rel****=****"stylesheet"** **/>**
    </head>
    <body class="mat-typography mat-app-background">
        <app-root>
          **<****div****class****=****"spinner-background"****>**
            **<****div****class****=****"spinner-container"****>**
              **<****svg****class****=****"spinner"****width****=****"****65px"****height****=****"65px"**
                **viewBox****=****"0 0 66 66"****>**
                **<****circle****class****=****"path"****fill****=****"none"****stroke-width****=****"6"**
                  **stroke-linecap****=****"round"****cx****=****"33"****cy****=****"33"****r****=****"30"****>**
                **</****circle****>**
              **</****svg****>**
              **<****h2****class****=****"animate-text"****>****Loading****</****h2****>**
            **</****div****>**
          **</****div****>**
        </app-root>
    </body> 
    

    当 Angular 启动时,<app-root> 的内容将被您的应用程序内容替换。

    注意,这个预加载屏幕被设计成最小化,应该会立即出现在屏幕上。然而,您会发现它仍然可能需要长达 6 秒钟才能显示。这是因为 Angular 优先加载全局的 styles.scss 文件。如果您使用 Angular Material,这将增加 165 KB 的内容,在 慢速 3G 网络中几乎需要 6 秒钟才能加载。然而,在 50 秒的总加载时间背景下,这仍然要好得多。

  3. 重新启动您的 Angular 应用程序,您应该会看到预加载屏幕:

计算机屏幕截图  描述自动生成

图 9.11:慢速网络上的预加载屏幕

现在您已经了解了如何使用 NgRx/SignalState 处理状态切片,让我们接下来深入了解出色的 NgRx/SignalStore 库。事实上,它如此之好,以至于它激发了我重写 LocalCast 天气应用程序,使其几乎完全依赖于 Observable 和 RxJS 操作符,并且没有订阅调用或异步管道。

使用 NgRx/SignalStore 重写 Angular 应用程序

使用 Observables,最好的订阅是你不需要做的。在这本书中,我们使用了 async pipetake(1)takeUntilDestroyedngOnDestroy 中的 unsubscribe 来尝试管理它们。这本书的示例代码在六年的时间里经过了各种实践者和专家的多次审查。每次审查都突出了 RxJS 代码中的一些疏忽或错误。

书的第三版提供了一个 99%无错误的实现。由于 RxJS 生态系统的疯狂复杂性,我永远无法声称 100%。

我引以为傲的是不选择容易的道路。我尽力为您提供真实和完整的示例,而不仅仅是计数器和待办事项列表。然而,与现实生活中发生的事情相比,这些项目仍然是非常受控制和规模较小的。你很少有时间回头重新评估整个项目。错误随着时间的推移而累积。这是使用 RxJS 的工作中的一个令人悲伤的现实。它在所做的事情上很棒,但 95%以上的代码并不需要这个工具带来的灵活性和反应性。大多数代码都是关于从 API 中检索一些数据一次并显示出来。由 async/await 驱动的承诺的信号使这种代码的编写变得简单。

RxJS 和信号辅助工具

几个重要的函数将帮助您从 Observables 和 RxJS 转移:

  • JavaScript 承诺是一种允许异步操作的结构,提供了一种处理最终成功值或失败原因的方法。

  • JavaScript async/await 是 JavaScript 中的语法糖,它允许您以同步的方式编写异步代码,建立在承诺之上。

  • RxJS InteroptoSignal 创建一个跟踪 Observable 值的信号,类似于模板中的异步管道,但更灵活。类似于异步管道,toSignal 也为我们管理订阅,因此不需要使用 subscribetakeUntil 或取消订阅。还有一个 toObservable,这在过渡期间非常有用。

  • ChangeDetectionStrategy.OnPush 是一个策略,告诉 Angular 仅当组件的输入属性更改时才运行变更检测,通过减少检查次数来提高性能。你需要在你的组件中将 changeDetection 属性设置为这个值,直到基于 Signal 的组件到来。

  • lastValueFrom 是一个实用函数,它将 Observable 转换为承诺,该承诺解析为 Observable 发出的最后一个值。此操作符还为我们管理订阅。还有 firstValueFrom,但你可能不需要它。这个对话将有必要进行,直到 Angular 为 HttpClientRouterFormControl 等模块实现基于承诺的 API。

    RxJS 互操作功能目前处于开发者预览阶段。

    你可以在 angular.dev/guide/signals/rxjs-interop 上了解更多相关信息。

状态管理仍然是管理大型应用程序复杂性的关键。让我们看看 NgRx SignalStore 如何帮助过渡。

NgRx/SignalStore

SignalStore 是一个围绕声明式编程构建的完整功能状态管理解决方案,确保代码干净简洁。SignalStore 用于管理具有复杂状态的较大存储,而 SignalState 则是为在单个组件或服务中包含简单状态而设计的。

更多关于 NgRx SignalStore 的信息,请参阅 ngrx.io/guide/signals/signal-store

SignalStore 可以在根级别或组件级别提供。该库提供了以下函数:

  • signalStore 是一个用于管理应用程序中更大和更复杂状态片段的实用函数。

  • withState 接收存储的初始状态并定义状态的结构。

  • withComputed 从存储中现有的状态片段推导出计算属性。

  • withMethods 包含自定义函数(存储方法),这些函数公开暴露以通过一个定义良好的 API 操作存储。withMethods 可以使用 patchState 和注入的服务来更新存储。

  • withHooks 在存储创建或销毁时被调用,允许获取数据以初始化存储或更新状态。

  • withEntities 是一个扩展,用于简化实体管理中的 CRUD 操作。它类似于 @ngrx/entity,但并不相同。

    更多关于 NgRx SignalStore 实体的信息,请参阅 ngrx.io/guide/signals/signal-store/entity-management

    此外,还可以在 ngrx.io/guide/signals/signal-store/custom-store-features 查看带有自定义存储功能的先进用例。

让我们看看如何将 SignalStore 应用于 LocalCast 天气。下面的图表是本章前面 实现 NgRx for LocalCast 天气 部分中的图表的重现。

一个图表的图表  描述自动生成

图 9.12:LocalCast 天气架构

初步检查时,SignalStore 似乎比 NgRx Store 的实现更简单。这是因为信号的内生反应性已经内置到 Angular 中。你必须记住这条看不见的线索,它使得这个实现背后的魔法得以工作。

工作流程,如 图 9.12 中用深灰色表示,从 步骤 1 开始:

  1. CitySearchComponent 触发 doSearch 方法,该方法反过来调用 store.updateWeather

  2. withMethods 激活 updateWeather 函数,该函数注入了对 WeatherService 的引用,并从中调用 getCurrentWeather

  3. updateWeather 等待 getCurrentWeather 的结果,该结果从 OpenWeather 获取当前天气信息,并使用 patchState 更新 store.current

  4. CurrentWeatherComponent 绑定到 store.current,因此当值更新时,模板会自动更新。

现在我们已经理解了 SignalStore 的概念运作方式,让我们来浏览一下新的代码库。

重构 RxJS 和 NgRx 代码

我们将回顾重构后的 LocalCast 天气应用,以检查代码是如何使用信号和 SignalStore 重写以变得更加简单和简洁。

本节源代码位于 local-weather-app 仓库的 projects/signal-store 目录下 github.com/duluca/local-weather-app/tree/main/projects/signal-store

你可以通过执行以下命令来运行项目:

$ npx ng serve --project signal-store 

使用以下命令运行 Cypress 测试:

$ npx ng run signal-store:cypress-run --spec "cypress/e2e/app.cy.ts,cypress/e2e/simple-search.cy.ts" 

NgRx Store 到 SignalStore

让我们从 projects/signal-store/src/app/store 下的 Store 实现开始:

**projects/signal-store/src/app/store/weather.****store****.****ts**
export const WeatherStore = signalStore(
  {
    providedIn: 'root',
  },
  withState({
    current: defaultWeather,
  }),
  withMethods((store, weatherService = inject(WeatherService)) => ({
    async updateWeather(searchText: string, country?: string) {
      patchState(store, {
        current: await weatherService.getCurrentWeather(
          searchText,
          country
        ),
      })
    },
  }))
) 

withState 定义并初始化了存储。withMethods 实现了 updateWeather 函数,该函数封装了更新当前天气的行为。这个函数原本在 WeatherService 中,但现在已经被移动到存储中。可以说,对于 NgRx Store 的实现来说,这应该是最佳做法;然而,由于整体架构更加简单,更容易看出任何潜在的副作用最好在存储中实现。

从 Observables 到 Signals 的服务

我们必须更新 API 调用来返回 Promise 而不是 Observable。我首先更新了 PostalCodeService

**projects/signal-store/src/app/postal-code/postal-code.****service****.****ts**
export class PostalCodeService implements IPostalCodeService {
  private readonly httpClient = inject(HttpClient)
  resolvePostalCode(postalCode: string): Promise<IPostalCode> {
    const uriParams = new HttpParams()
      .set('maxRows', '1')
      .set('username', environment.username)
      .set('postalcode', postalCode)
    const httpCall$ = this.httpClient.get<IPostalCodeData>(
      `${environment.baseUrl}${environment.geonamesApi}.geonames.org/postalCodeSearchJSON`,
      { params: uriParams }
    )
    return lastValueFrom(httpCall$).then((data) =>
      data.postalCodes?.length > 0 ? 
        data.postalCodes[0] : defaultPostalCode
    )
  }
} 

resolvePostalCode现在返回Promise<IPostalCode>。我们将httpClient.get返回的 Observable 存储为本地变量httpCall$,然后通过lastValueFrom进行包装。在这个过程中,我们还移除了实现mergeMapdefaultIfEmpty以清理接收数据的管道。我们必须在then函数中实现类似的功能。如前所述,then的行为类似于一个tap函数。

接下来,让我们看看WeatherService

**projects/signal-store/src/app/weather/weather.****service****.****ts**
export class WeatherService implements IWeatherService {
  private readonly httpClient = inject(HttpClient)
  private readonly postalCodeService = inject(PostalCodeService)
  async getCurrentWeather(
    searchText: string, country?: string): Promise<ICurrentWeather> {
    const postalCode = await   
       this.postalCodeService.resolvePostalCode(searchText)
    if (postalCode && postalCode !== defaultPostalCode) {
      return this.getCurrentWeatherByCoords({
        latitude: postalCode.lat,
        longitude: postalCode.lng,
      })
    } else {
      const uriParams = new HttpParams().set(
        'q',
        country ? `${searchText},${country}` : searchText
      )
      return this.getCurrentWeatherHelper(uriParams)
    }
  }
  private getCurrentWeatherHelper(
    uriParams: HttpParams): Promise<ICurrentWeather> {
    uriParams = uriParams.set('appid', environment.appId)
    const httpCall$ = this.httpClient.get<ICurrentWeatherData>(
      `${environment.baseUrl}api.openweathermap.org/data/2.5/weather`,
      { params: uriParams }
    )
    return lastValueFrom(httpCall$).then(
           (data) => this.transformToICurrentWeather(data))
  } 

getCurrentWeather现在是一个异步函数,用于等待postalCodeService.resolvePostalCode的结果。处理resolvePostalCode响应的逻辑现在是一个简单的 if-else 语句,嵌套较少。getCurrentWeatherHelper已经被重构,类似于我们重构resolvePostalCode的方式。

最重要的是,不再有BehaviorSubjectsignal或任何保留在服务中用于更新currentWeather值的代码。

从 Observables 到 Signals 的组件

更新服务后,我们现在可以完成重构并更新组件以使用信号。

让我们从CitySearchComponent开始:

**projects/signal-store/src/app/city-search/city-search.****component****.****ts**
@Component({
  selector: 'app-city-search',
  ...
  **changeDetection****:** **ChangeDetectionStrategy****.****OnPush****,**
})
export class CitySearchComponent {
  private readonly store = inject(WeatherStore)
  search = new FormControl(
    '', 
    [Validators.required, Validators.minLength(2)]
  )
  readonly searchSignal = **toSignal**(
    this.search.valueChanges.pipe(
      filter(() => this.search.valid),
      debounceTime(1000)
    )
  )
  constructor() {
    effect(() => {
      this.doSearch(this.searchSignal())
    })
  }
  doSearch(searchValue?: string | null) {
    if (typeof searchValue !== 'string') return
    const userInput = searchValue.split(',').map((s) => s.trim())
    const searchText = userInput[0]
    const country = userInput.length > 1 ? userInput[1] : undefined
    this.store.updateWeather(searchText, country)
  }
} 

我们首先将changeDetection策略设置为OnPush。接下来,我们将search.valueChanges包装在一个toSignal函数中,以将 Observable 转换为信号。这是应用程序中剩下的唯一管道。主要原因是因为debounceTime操作符。更多信息请见提示框。然后我们使用effect函数来响应推送到searchSignal的变化,这会触发doSearch,进而调用store.updateWeather。如我们之前所述,store.updateWeather最终会更新store.current信号。值得注意的是,我们不再从这个组件中引用WeatherService,并且不需要对模板进行任何更改。

过滤和去抖动是 LocalCast Weather 中剩下的唯一 RxJS 操作符。目前还没有信号的操作符。你可以查看 Stack Overflow 上的这个答案,了解去抖动信号函数可能的工作方式stackoverflow.com/a/76597576/178620。然而,正如作者 An Nguyen 所指出的,这是一段复杂的代码,目前最好使用经过良好测试的库。

接下来,让我们看看CurrentWeatherComponent

**projects/signal-store/src/app/current-weather/**
**current-weather.****component****.****ts**
@Component({
  selector: 'app-current-weather',
  ...
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CurrentWeatherComponent {
  readonly store = inject(WeatherStore)  
  ...
} 

最显著的变化发生在CurrentWeatherComponent内部。我们设置了changeDetection策略,但现在已经只需要注入WeatherStore。就是这样:

**projects/signal-store/src/app/current-weather/**
**current-weather.component.html**
<**div** **fxLayout**=**"row"**>
  <div fxFlex="66%" class="mat-headline-6 no-margin" data-testid="city">
    {{ store.current().city }},
    {{ store.current().country }}
  </div>
  ... 

在模板中,我们可以移除 null 保护,因为信号总是初始化的。然后我们只需绑定到store.current()信号。当然,这需要对模板进行相当繁琐的重构。我们必须更新所有对current的引用,使用store.current()。这可以通过引入一个名为current的局部变量并使用effect来监听store.current()的更新来避免。然而,使用这种配置,你将无法获得signalOnPush提供的细粒度变更检测的好处。

我预计当基于信号的组件到来时,我们将能够编写类似于异步管道工作的代码:

@if (store.current() as current) 

这将极大地帮助避免令人烦恼的模板重写。

组件更新完成后,应用重构就完成了。在应用代码周围还有一些细微的变化。你会注意到导入和提供者大大减少。

在我看来,仅使用信号的代码更容易理解和维护;远优于 RxJS 的替代方案。我希望你喜欢这个对 Angular 未来的预览。

摘要

在本章中,我们使用路由优先架构以及我们的食谱,完成了对所有主要 Angular 应用设计考虑的回顾,从而轻松实现业务线应用。我们回顾了如何编辑现有用户,利用 resolve 守卫来加载数据,以及在不同的上下文中激活和重用组件。

我们使用辅助路由实现了主/详细视图,并展示了如何构建具有分页的数据表。然后我们学习了如何使用 local-weather-app 实现 NgRx/Store 和 NgRx/SignalStore。我们涵盖了 NgRx 生态系统中的可用选项,包括 NgRx/Data、NgRx/ComponentStore、Akita 和 Elf,以及这些选项之间的差异,以便你可以在项目中做出明智的决定。

我们还实现了一个预加载动画,以便在慢速连接时你的应用看起来响应灵敏。我们还实现了应用内的全局旋转器,以处理数据弹出相关的用户体验问题。最后,我们通过参观使用 SignalStore 和开发者预览功能的 local-weather-app 的全面重构,一瞥 Angular 的基于信号的未来。

采用路由优先的设计、架构和实现方法,我们以对目标成果的高层次理解来处理应用的设计。通过展示路由出口的使用和在同一组件中重用不同上下文,我们见证了路由编排的力量。通过早期识别代码重用机会,我们优化了实现策略,提前实现可重用组件,避免了过度工程化解决方案的风险。

在下一章中,我们将学习使用 Docker 进行容器化以及将你的应用到云端部署。Docker 允许强大的工作流程,可以极大地提高开发体验,同时允许你将服务器配置作为代码实现,为开发者最喜欢的借口敲响了最后的丧钟:“但是在我的机器上它运行正常!”

练习

  1. lemon-mart 中更新 UserTableComponent 和相关服务,以利用 Elf 实体和分页功能,实现请求的优化处理。

  2. 按照以下指南操作:ngneat.github.io/elf/docs/features/pagination

  3. 使用 NgRx/SignalStore 重写你的 Angular 应用,使其几乎成为可观察的,并且没有 RxJS 操作符,无需任何订阅调用或异步管道。

  4. 如果你认为这只是一个在书末添加的滑稽练习,请在我的 GitHub 个人资料上给我留言,链接为github.com/duluca

进一步阅读

问题

尽可能地回答以下问题,以确保你已理解本章的关键概念,而无需查阅任何资料。你知道你是否答对了所有问题吗?访问 angularforenterprise.com/self-assessment 获取更多信息:

  1. 解析守卫是什么?

  2. 路由编排的好处是什么?

  3. 辅助路由是什么?

  4. NgRx 与使用 RxJS/Subject 有何不同?

  5. NgRx 数据的价值是什么?

  6. UserTableComponent 中,为什么我们使用 readonly isLoadingResults$: BehaviorSubject<Boolean> 而不是简单的 Boolean 来驱动加载指示器?

加入我们的 Discord 社区

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

packt.link/AngularEnterpise3e

第十章:使用 CI/CD 发布到生产环境

发布它,否则它从未发生!如果你不发布你的代码,你创造的价值为零。这种将你的工作发布的动力在许多行业中都很普遍。然而,将一份工作交付给他人或将其公之于众可能会令人恐惧。在软件工程中,交付任何事物都是困难的;将事物部署到生产环境则更加困难。

查看我的 2018 年演讲,发布它,否则它从未发生:Docker、Heroku 和 CircleCI 的力量bit.ly/ship-it-or-it-never-happened

我们生活在一个快速行动和破坏事物的时代。然而,这个说法的后半部分在企业中很少适用。你可以生活在边缘,采用 YOLO 生活方式,但这并不符合良好的商业逻辑。

图片

图 10.1:一个工具的创意 CLI 选项

在企业项目中,代码在合并之前必须通过多个质量关卡。在本章中,我们将介绍利用 GitHub 流和 CircleCI 的 持续集成CI)管道,这些管道有助于团队实现频繁、可靠、高质量和灵活的发布。

如果我们有一套可以快速验证我们代码正确性的自动化测试,那么频繁且可靠的发布才成为可能。自动化测试对于确保你的更改不会引入回归至关重要。因此,我们将讨论编写单元测试和 Cypress 的 端到端e2e)测试的重要性。

为了快速推进而不破坏事物,我们需要通过 DevOps 最佳实践,如 基础设施即代码IaC)来实现 持续部署CD),这样我们就可以更频繁地验证我们运行中的代码的正确性。

在本章中,我们将介绍使用 CLI 工具部署 Angular 应用,然后讨论基于 Docker 的方法来实现 IaC,该方法可以在大多数 CI 服务和云提供商上运行,允许你从任何 CI 环境到任何云提供商实现可重复的构建和部署。使用灵活的工具,你可以避免在单一服务上过度专业化,并保持你的配置管理技能在不同 CI 服务中相关。

本书使用 CircleCI 作为 CI 服务器。其他值得注意的 CI 服务器包括 Jenkins、Azure DevOps 以及 GitLab 和 GitHub 内置的机制。

本章涵盖:

  • 自动化测试

  • 持续集成

  • 部署到云端

  • DevOps

  • 使用 Docker 容器化 Web 应用

  • 在云端使用容器

  • 持续部署

  • 代码覆盖率报告

技术要求

为了跟随本章内容,以下软件是必需的:

  • Docker Desktop 社区版 4+

  • Docker 引擎 CE 版本 24+

  • CircleCI 账户

  • Vercel 账户

  • Firebase 账户

  • Coveralls 账户

书中示例代码的最新版本可以在以下链接的 GitHub 仓库中找到:链接列表。该仓库包含代码的最终和完成状态。按照以下说明进行操作,以了解如何在阅读本章各节时验证您的进度。

对于第十章

  1. 克隆以下仓库:github.com/duluca/local-weather-appgithub.com/duluca/lemon-mart

  2. 在根目录下执行npm install以安装依赖项。

package.json包含构建脚本。

注意到.circleci文件夹包含额外的 YAML 文件。

对于local-weather-app

  1. .circleci/config.stage4.yml代表一个简单的 CI 管道。

  2. .circleci/config.stage9.yml添加了 CD,目标部署到 Vercel。

  3. .circleci/config.yml显示了一个具有并行构建和自动化 Cypress 测试的高级管道。

对于lemon-mart

  1. .circleci/config.stage9.yml添加了 CD,目标部署到 Vercel。

  2. .circleci/config.docker-integration.yml演示了使用多阶段 Dockerfile 和 AWS ECS Fargate 部署的容器内容器设置。

  3. .circleci/config.yml显示了一个具有并行构建和自动化 Cypress 测试的高级管道。

请注意,书中提供的源代码和 GitHub 上的版本可能会不同。云服务一直在不断发展和变化。如果您发现错误或有疑问,请创建 GitHub 上的问题或提交拉取请求。

首先,让我们了解为什么自动化测试对于通过 CI/CD 管道交付高质量解决方案至关重要。

自动化测试

作为开发者,我们将来自各种来源的代码集成到我们的解决方案中。这可能是来自充满咖啡、漫长且疲惫的编码会话,StackOverflow 上的复制粘贴答案,博客文章的片段,npm 包,或者像 Angular 这样的主要库。我们被期望在估算的时间内交付高质量的结果。在这些条件下,错误不可避免地会出现在我们的代码中。当截止日期、雄心壮志或不幸的架构决策与常规的编码节奏相交时,事情只会变得更糟。

自动化测试确保我们编写的代码是正确的,并且保持正确。我们依赖于 CI/CD 管道进行可重复的过程,这些过程不易受到人为错误的影响,但管道的质量取决于我们编写的自动化测试的质量。

Angular 有两个主要的测试类别,单元测试和端到端测试。单元测试旨在快速创建和执行,而端到端测试则较慢且成本更高。然而,存在一个问题:Angular 的单元测试并不是真正的单元测试。

要了解原因,我们需要深入了解单元测试基础知识,以便您熟悉测试驱动开发的好处,并涵盖像 FIRST 和 SOLID 这样的原则。

单元测试

单元测试对于确保你的应用程序的行为不会无意中随时间改变至关重要。单元测试将使你和你的团队能够继续对你的应用程序进行更改,而不会引入先前已验证的功能的改变。开发者编写单元测试,其中每个测试的范围仅限于测试待测试函数FUT)或待测试类CUT)中的代码。单元测试应该很多,自动化,并且快速。你应该与原始代码一起编写单元测试。如果它们与实现分离,即使是一两天,你也会开始忘记代码的细节。正因为如此,你可能会忘记为潜在的边缘情况编写测试。

单元测试应遵循 FIRST 原则:

  • 快速

  • 隔离

  • 可重复

  • 自我验证

  • 及时

单元测试应该是快速的,只需毫秒即可运行,这样我们就可以在几分钟内运行数千个测试。为了实现快速测试,单元测试应该是隔离的。它不应该与数据库通信,通过网络进行请求,或与 DOM 交互。隔离的测试将是可重复的,这样每次测试运行都会返回相同的结果。可预测性意味着我们可以断言测试的正确性,而不依赖于任何外部环境,这使得我们的测试可以自我验证。如前所述,你应该及时编写单元测试;否则,你会失去编写单元测试的好处。

如果你的测试只关注单个 FUT/CUT,那么你可以坚持 FIRST 原则。但其他类、服务或我们必须传递给 FUT/CUT 的参数怎么办?单元测试可以通过利用测试替代表示 FUT/CUT 的行为。测试替代表示允许我们控制外部依赖,所以你可能会向你的组件注入一个假的或模拟的HttpService。使用测试替代表示,我们可以控制外部依赖的影响,并创建快速且可重复的测试。

多少测试才算足够?你应该至少有与生产代码一样多的测试代码。如果没有,那么你离编写足够的测试还远着呢。

请参阅进一步阅读部分,了解 20 多年前的文献。

单元测试不是你可以创建的唯一类型的测试,但它们是你应该创建最多的类型。考虑你可以创建的三种测试类型:单元、集成和 UI。

正如我们所说的,单元测试一次只关注一个 FUT/CUT。集成测试测试各种组件的集成,以便它们可以包括数据库调用、网络请求以及与 DOM 的交互。由于它们的性质,集成测试运行速度较慢,并且必须经常维护。运行时间和维护的增加意味着随着时间的推移,集成测试比单元测试更昂贵。UI 测试就像用户在使用应用程序一样测试应用程序,填写字段、点击按钮并观察预期的结果。

你可能会想象这些测试是最慢且最脆弱的测试类型。应用程序的 UI 经常变化,使用 UI 测试创建可重复的测试非常困难。

我们可以利用集成和 UI 测试来创建验收测试。验收测试是为了自动化我们的功能接受而编写的。Angular 的 e2e 测试是创建验收测试的一种方式。

我们可以用 Mike Cohn 的测试金字塔来可视化自动化测试的三大类别的优缺点,如下所示:

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

图 10.2:Mike Cohn 的测试金字塔

测试金字塔有效地总结了在考虑速度和成本的情况下,我们应该为我们的应用程序创建的每种类型的测试的相对数量。

根据单元测试的描述和预期,你现在可以开始理解为什么Angular 单元测试实际上并不是真正的单元测试。正如我在第一章“组件架构”部分中解释的,“Angular 的架构和概念”,一个 Angular 组件由一个组件类和一个模板组成。要真正测试一个组件,我们必须与 DOM 交互。这就是为什么 Angular 测试必须利用 TestBed 来执行。此外,依赖注入的配置和模拟可能会非常繁琐。最终,与真正的单元测试相比,TestBed 比较慢且脆弱。

考虑使用 Spectator 来简化你的 Angular 测试。Spectator 旨在帮助你摆脱所有样板式的繁琐工作,专注于可读性和流畅的单元测试。

更多信息请参阅 github.com/ngneat/spectator

独立组件配置起来要容易得多,因为它们定义了自己的依赖关系,我们不再需要向 TestBed 引入模块。

这对 Angular 意味着什么?在这个现实情况下,对组件进行单元测试是浪费时间。你应该将所有业务逻辑提取到服务和函数中,并彻底测试它们。希望 Angular 单元测试设置的将来更新能改变这种情况。

Cypress 组件测试是集成测试单个组件功能的好方法。按照下一节的说明设置 Cypress 以适应你的项目。

关于组件测试的更多信息请参阅 docs.cypress.io/guides/component-testing/angular/overview

从 Angular 17.1 开始,你可以用现代的Web Test Runner替换 Karma 测试运行器。你可以按照以下说明进行设置:

$ npm i -D @web/test-runner 

使用以下命令更新 angular.json

"test": {
  "builder": "@angular-devkit/build-angular:web-test-runner"
} 

你可以阅读更多内容 modern-web.dev/docs/test-runner/overview

要测试 UI 和模板逻辑,Angular e2e 测试将提供最佳性价比,现在你可以将 Cypress 配置为应用程序的默认 e2e 提供者。

Cypress 端到端测试

当单元测试专注于隔离 CUT 时,端到端测试则是关于集成测试。我强烈建议你通过执行以下命令将 Cypress 配置为你的端到端提供者:

$ npx ng add @cypress/schematic 

你可以在www.npmjs.com/package/@cypress/schematic了解更多关于 Cypress 集成的信息。

Cypress 允许你轻松创建现代 Web 应用程序的测试,可视化调试它们,并在 CI 构建中自动运行它们。你可以在www.cypress.io/了解更多关于 Cypress 的信息。

端到端测试允许你从用户在浏览器中与应用程序交互的角度编写自动化验收测试AAT)。Cypress 测试易于创建和运行。通过在 HTML 组件上使用data-testid属性,你可以使它们更稳定。

你可以在cypress文件夹下找到local-weather-applemon-mart的示例 Cypress 测试。

你可以通过执行以下命令在开发中运行测试:

$ npx ng e2e 

对于 CI,你可以使用以下命令:

$ npx ng run local-weather-app:cypress-run
$ npx ng run lemon-mart:cypress-run 

local-weather-app中,查看cypress/e2e/app.cy.ts文件:

**cypress/e2e/app.****cy****.****ts**
import '../support/commands'
describe('LocalCast Weather', () => {
  beforeEach(() => {
    cy.visit('/')
  })
  it('has the correct title', () => {
    cy.byTestId('title').should('have.text', 'LocalCast Weather')
  })
}) 

cypress/support/commands.ts 文件中,我实现了一个名为byTestId的辅助函数,该函数可以找到具有给定名称的data-testid属性的 HTML 元素。在这种情况下,cy.byTestId("title")将找到以下元素:

**src/app/app.****component****.****ts**
<span data-testid="title">LocalCast Weather</span> 

即使元素在页面上移动,测试也能正常工作。使用测试 ID 可以轻松方便地编写更可靠的测试。

构建健壮且可维护的端到端代码需要一些额外的概念,如页面对象。你可以在docs.cypress.io/guides/end-to-end-testing/protractor-to-cypress#Using-Page-Objects和其他最佳实践docs.cypress.io/guides/references/best-practices中了解更多关于页面对象的信息。

接下来,让我们设置 CI 以确保在将应用程序部署到生产之前,我们的测试总是运行。

持续集成

在将代码推送到生产之前,你应该启用 CI。这个简单的工具帮助我们确保在每次更改代码时执行自动化任务,包括测试执行,从而确保我们不将损坏的代码推送到生产。

CircleCI

CircleCI使入门变得容易,提供免费层和针对初学者和专业人士的优秀文档。如果你有独特的企业需求,CircleCI 可以部署在企业内部,在防火墙后面,或在云中作为私有部署。

CircleCI 为免费设置提供了预配置的构建环境,但它也可以使用 Docker 容器运行构建,使其成为一个可以扩展到用户技能和需求解决方案:

  1. circleci.com/创建一个 CircleCI 账户。

  2. 导航到项目以添加新项目。

  3. 搜索local-weather-app并点击设置项目

  4. 按照屏幕提示创建一个示例 .yml 文件。Hello World 或 Node.js 都可以,但您最终会替换内容。

    本节使用 local-weather-app 仓库。本节的 config.yml 文件命名为 .circleci/config.stage4.yml

  5. 将以下 .yml 内容复制到文件中:

    **.circleci/config.yml**
    version: 2.1
    orbs:
      browser-tools: circleci/browser-tools@1
      cypress: cypress-io/cypress@3
    commands:
      install:
        description: 'Install project dependencies'
        steps:
          - checkout
          - restore_cache:
              keys:
                - node_modules-{{ checksum "package-lock.json" }}
          - run: npm install
          - save_cache:
              key: node_modules-{{ checksum "package-lock.json" }}
              paths:
                - node_modules
      lint:
        description: 'Check for code style and linting errors'
        steps:
          - run: npm run style
          - run: npm run lint
      build_and_test:
        description: 'Builds and tests Angular project'
        steps:
          - run: npx ng build --configuration production
          - attach_workspace:
              at: ~/
          - browser-tools/install-chrome
          - browser-tools/install-chromedriver
          - run: npx ng test --watch=false --code-coverage
      store:
        description: 'Stores build_and_test artifacts'
        steps:
          - store_test_results:
              path: ./test_results
          - store_artifacts:
              path: ./coverage
    jobs:
      run_build_and_test:
        docker:
          - image: cimg/node:lts-browsers
        working_directory: ~/repo
        steps:
          - install
          - cypress/install
          - lint
          - build_and_test
          - run: npx ng run local-weather-app:cypress-run
          - store
    workflows:
      build-and-test:
        jobs:
          - run_build_and_test 
    
  6. 选择提交运行

  7. CircleCI 应该在新的分支上运行。

如果一切顺利,您应该有一个通过,绿色的构建。如果不顺利,您将看到一个失败的,红色的构建。

当您有一个绿色构建时,您可以使用 CircleCI 来强制执行每次代码推送时自动管道的执行。GitHub 流程允许我们控制代码如何流入我们的仓库。

GitHub 流

我们开发软件的主要原因是为了交付价值。在自动化我们交付软件的方式时,我们正在创建一个价值交付流。交付有缺陷的软件很容易;然而,为了可靠地交付价值,代码库的每个更改都应该通过一系列的检查和平衡。

使用控制门,我们可以强制执行标准,使我们的质量控制流程对每个团队成员都是可重复的,并且能够隔离更改。如果出现问题或工作不符合您的标准,您可以轻松地丢弃提议的更改并重新开始。

GitHub 流程是定义价值交付流和实施控制门的关键部分。正如 GitHub 所说,“GitHub 流是一个轻量级、基于分支的工作流程,支持定期进行部署的团队和项目。”

GitHub 流程包括六个步骤,如下所示,这是来自 GitHub 的以下图形:

图片

图 10.3:GitHub 流程图

  1. 分支 – 总是在新分支中添加用于修复错误或添加新功能的代码

  2. 提交 – 对您的分支进行多次提交

  3. 创建拉取请求 – 向团队成员发出工作准备就绪的信号,并在拉取请求中查看 CI 结果

  4. 讨论和审查 – 请求对您的代码更改进行审查,处理一般性或行级评论,并进行必要的修改

  5. 部署 – 可选地在测试服务器或预发布服务器上测试您的代码,并具有回滚到主分支的能力

  6. 合并 – 将您的更改应用到主分支

您可以使用 GitHub 流程来确保只有高质量的代码最终进入主分支。坚实的基础为其他团队成员在做出更改时提供了成功的基础。您必须限制对主分支的推送访问,以强制执行 GitHub 流程。

让我们为主分支启用分支保护:

  1. 导航到您项目的 GitHub 设置标签页。

  2. 从左侧导航窗格中选择分支

  3. 如果主分支存在规则,请点击编辑添加规则按钮。

  4. 将分支名称模式输入为main

  5. 启用以下选项:

    1. 在合并前要求拉取请求。

    2. 至少需要一位同事的批准。

    3. 撤销过时的拉取请求批准。

    4. 在合并之前需要确保状态检查通过。

    5. 在合并之前需要确保分支是最新的。

    6. 需要线性历史记录。

  6. 搜索CircleCI并选择必须成功的 CI 作业,在本例中为:run_build_and_test

  7. 保存你的更改后,你应该能在分支页面上看到你的新规则,如下所示:计算机屏幕截图  自动生成的描述

    图 10.4:GitHub 分支

你现在无法直接向主分支提交代码。要提交代码,你首先需要从主分支创建一个分支,将你的更改提交到新分支,并在准备好时使用新分支创建一个拉取请求。如果你不熟悉git命令,可以使用 GitHub Desktop 来帮助你进行这些操作。请参考 GitHub Desktop 中的便捷分支菜单:

图 10.5:GitHub Desktop 分支菜单

创建拉取请求后,你现在可以观察针对你的分支运行的检查。现在我们已经配置了 CircleCI,如果一切顺利,你应该能够合并拉取请求,如下所示:

图 10.6:GitHub.com 状态检查通过

当检查失败时,你必须修复任何问题后才能合并新的代码。此外,如果你在处理你的分支时团队成员合并到了主分支,你可能会遇到合并冲突。在这种情况下,你可以使用 GitHub Desktop 的从主分支更新功能来更新你的分支,以赶上最新的主分支。

观察以下图像中失败的拉取请求的状态:

图 10.7:GitHub.com 状态检查失败

注意,我还有一个额外的检查,DeepScan,它会对我的代码库运行额外的测试。你可以在deepscan.io上注册你的仓库。在章节的后面部分,我将演示如何使用 Coveralls 强制执行单元测试覆盖率。

更多信息,请参阅guides.github.com/introduction/flow

现在我们已经确保了我们的自动化检查正在被强制执行,我们可以合理地确信我们不会推送一个损坏的应用到生产环境。接下来,让我们学习如何将我们的应用部署到云端。

部署到云端

如果从编码角度来看交付到生产环境有困难,那么从基础设施角度来看正确地完成它将非常复杂。在 Azure、AWS 和 Google Cloud 的完整版本中部署解决方案是复杂的。为了快速得到结果,我们可以利用可以在几分钟内为我们 Angular 应用的dist文件夹提供服务的云服务。

其中一项服务是 Vercel,另一项是 Firebase,可以利用ng deploy命令。

Vercel

Vercel,vercel.com,是一个多云服务,它使您能够从 CLI 直接进行应用的实时全球部署。Vercel 与静态文件、Node.js、PHP、Go 应用以及您愿意为其编写自定义构建器的任何自定义软件栈协同工作,使其非常简单。Vercel 有一个免费层,您可以使用它快速部署 Angular 应用的dist文件夹。

vercel包安装到您的项目中,并运行login命令:

$ npm i -D vercel
$ npx vercel login 

按照屏幕上的提示完成登录过程。现在,让我们配置publish脚本。

部署静态文件

在您构建 Angular 项目后,构建输出将驻留在dist文件夹中。这个文件夹中的文件被认为是静态文件;Web 服务器需要做的只是将这些文件未修改地传递给客户端浏览器,然后浏览器动态执行您的代码。

这意味着任何 Web 服务器都可以提供您的 Angular 项目。然而,vercel使得这一过程变得极其简单且免费。

本节使用local-weather-app仓库。

让我们开始使用 Vercel 的静态文件托管能力部署您的 Angular 应用:

  1. package.json中添加两个新的脚本,如下所示:

    **package.json**
    ...
    "prevercel:publish": "npm run build:prod",
    "vercel:publish": 
      "vercel deploy --prod dist/local-weather-app --yes" 
    
  2. 执行npm run vercel:publish

  3. 按照屏幕上的命令接受首次使用设置。

    在终端窗口中,观察 Angular 项目首先构建然后上传到vercel

    $ npm run vercel:publish 
    ...
    Build at: 2023-11-07T03:51:23.229Z - Hash: d6b1388088df7136 - Time: 5403ms
    ...
    ![](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/ng-entp-app-3e/img/B20960_10_001.png)  Production: https://local-weather-hhk9xi3wz-duluca.vercel.app [1s] to clipboard] [4s] 
    
  4. 按照屏幕上显示的 URL 查看,您的应用已成功部署。

完成了!恭喜,您的 Angular 应用已上线互联网!

Firebase

Firebase,firebase.google.com/,是一个帮助您构建和增长用户喜爱的应用和游戏的平台。它由谷歌支持,并被全球数百万家企业所信赖。

在第六章“实现基于角色的导航”的 Firebase 身份验证配方部分,您为 LemonMart 创建了一个 Firebase 应用,并使用firebase deploy部署了您的应用。我们将利用您创建的账户,使用新的ng deploy命令将 LemonMart 部署到 Firebase 托管。

ng deploy

ng deploy是一个新的 CLI 命令,它帮助云服务提供与 Angular 的无缝集成,因此您可以轻松地将应用部署到云端。

现在,让我们使用@angular/fire配置 LemonMart 项目。

本节使用lemon-mart仓库。

执行以下命令,并确保选择ng deploy -- hosting

$ npx ng add @angular/fire
![](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/ng-entp-app-3e/img/B20960_10_002.png) ng deploy -- hosting 

这将在angular.json中创建一个deploy配置,并添加或更新现有的 Firebase 配置文件。

对于 LemonMart,我修改了angular.json,使其能够自动使用 Firebase 身份验证模式构建:

**angular.json**
...
"deploy": {
  "builder": "@angular/fire:deploy",
  "options": {
    "version": 2,
    "browserTarget": "lemon-mart:build:firebase"
  }
} 

Firebase 配置定义在配置选项下,并使用environment.firebase.ts构建应用。

我还修改了 firebase.json 以部署 dist/lemon-mart,因为该存储库被配置为多项目 Angular 应用。然而,您不需要修改自动生成的文件。

执行以下命令进行部署:

$ npx ng deploy
...
Build at: 2023-11-07T04:27:36.842Z - Hash: ad999f95a270f4e6 - Time: 14141ms
=== Deploying to 'lemon-mart-007'...
...
![](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/ng-entp-app-3e/img/B20960_10_003.png)  Deploy complete!
Project Console: https://console.firebase.google.com/project/lemon-mart-007/overview
Hosting URL: https://lemon-mart-007.web.app 

按照屏幕上的 URL 查看,以确认您的应用已成功部署。

ng deploy 提供了比使用特定平台 CLI 命令更好的集成和更简单的选项。使用 CLI 命令很棒,但一条在某一台机器上工作的命令很容易在另一台机器上失败。IaC(基础设施即代码)是高性能企业团队能够快速交付高质量代码的唯一途径。然而,要达到这一点,我们首先需要了解 DevOps 是什么。

DevOps

DevOps 是开发和运维的结合。对于开发来说,像 Git 这样的代码仓库跟踪每个代码更改是众所周知的事实。在运维方面,长期以来一直存在各种技术来跟踪环境的更改,包括脚本和各种旨在自动化操作系统和服务器预配的工具。

您有多少次听说过这样的话,“在我的机器上它运行正常”?开发者经常用这句话作为笑话。然而,软件在测试服务器上运行得很好,但由于配置的细微差异,最终在生产服务器上遇到问题的情形也屡见不鲜。

之前,我们讨论了 GitHub flow 如何使我们能够创建价值交付流。我们在做出更改之前总是从主分支创建分支,强制更改通过我们的 CI 管道,一旦我们合理确信代码可以工作,就将其合并回主分支。请参阅以下图表:

黑色背景的点和线  自动生成的描述

图 10.8:分支和合并

记住,您的主分支应该始终可部署,并且您应该经常将您的工作与主分支合并。

Docker 允许我们通过名为 Dockerfile 的特殊文件以声明式的方式定义我们的代码所依赖的软件和特定配置参数。同样,CircleCI 允许我们在 config.yml 文件中定义我们的 CI 环境配置。通过将配置存储在文件中,我们可以将文件与代码一起检查。我们可以使用 Git 跟踪更改,并强制它们通过我们的 CI 管道进行验证。

通过将我们的基础设施定义存储在代码中,我们实现了 IaC(基础设施即代码),我们还实现了可重复的集成,因此无论我们在什么环境中运行我们的基础设施,我们都应该能够通过一条命令启动我们的全栈应用。

您可能还记得,在 第一章Angular 的架构和概念 中,我们介绍了 TypeScript 如何覆盖 JavaScript 功能差距。像 TypeScript 一样,Docker 覆盖了配置差距,如下面的图表所示:

图表的图表  自动生成的描述

图 10.9:覆盖配置差距

通过使用 Docker,我们可以合理地确信,在测试期间在我们机器上工作的代码,在发布时将以相同的方式工作。

总结来说,DevOps 将运维与开发更紧密地结合在一起,在这里进行更改和解决问题成本更低。因此,DevOps 主要是开发者的责任,但也是一种思维方式,运维团队必须愿意支持。让我们更深入地了解 Docker。

使用 Docker 容器化 Web 应用

Docker,可在docker.io找到,是一个用于开发、发布和运行应用程序的开源平台。Docker 结合了一个轻量级的容器虚拟化平台以及帮助管理和部署应用程序的工作流程和工具。虚拟机VMs)和 Docker 容器之间最明显的区别是,VMs 通常大小为数十个吉字节,需要数吉字节内存,而容器在磁盘和内存大小需求方面仅占用兆字节。此外,Docker 平台抽象化了主机操作系统OS)级别的配置设置,因此成功运行应用程序所需的所有配置都编码在人类可读的格式中。

Dockerfile 的解剖结构

Dockerfile 由四个主要部分组成:

  • FROM – 这里我们可以从 Docker 的最小“scratch”镜像或现有的镜像中继承

  • SETUP – 这里我们根据需求配置软件依赖项

  • COPY – 这里我们将构建好的代码复制到操作环境中

  • CMD – 这里我们指定启动操作环境的命令

引导(Bootstrap)指的是一组初始指令,描述了程序如何加载或启动。

考虑以下 Dockerfile 解剖结构的可视化:

计算机程序截图 自动生成描述

图 10.10:Dockerfile 的解剖结构

以下代码展示了 Dockerfile 的具体表示:

**Dockerfile**
FROM duluca/minimal-nginx-web-server:1-alpine
COPY /dist/local-weather-app /var/www
CMD 'nginx' 

你可以将脚本中的FROMCOPYCMD部分映射到可视化中。我们使用FROM命令从duluca/minimal-nginx-web-server镜像中继承。然后,我们使用COPY(或者,也可以使用ADD)命令将我们的应用编译结果从我们的开发机器或构建环境复制到镜像中。最后,我们指示容器使用CMD(或者,也可以使用ENTRYPOINT)命令执行 nginx web 服务器。

注意,前面的Dockerfile没有独立的SETUP部分。SETUP不映射到实际的Dockerfile命令,但代表了一组你可以执行的命令来设置你的容器。在这种情况下,所有必要的设置都由基础镜像完成,因此没有额外的命令要运行。

常见的 Dockerfile 命令有 FROM, COPY, ADD, RUN, CMD, ENTRYPOINT, ENV, 和 EXPOSE。关于完整的 Dockerfile 参考,请参阅 docs.docker.com/engine/reference/builder/

Dockerfile 描述了一个新的容器,它从名为 duluca/minimal-nginx-web-server 的容器继承。这是一个我在 Docker Hub 上发布的容器,它从 nginx:alpine 镜像继承,而 nginx:alpine 镜像本身又从 alpine 镜像继承。alpine 镜像是一个最小化的 Linux 操作环境,大小仅为 5 MB。alpine 镜像本身又从 scratch 镜像继承,而 scratch 镜像是一个空镜像。以下图表展示了继承层次结构:

计算机屏幕截图  自动生成的描述

图 10.11:Docker 继承

Dockerfile 然后将开发环境中的 dist 文件夹内容复制到容器的 www 文件夹中,如下所示:

计算机屏幕截图  自动生成的描述

图 10.12:将代码复制到容器化的 Web 服务器

在这种情况下,父镜像配置了一个 nginx 服务器作为 Web 服务器来服务 www 文件夹内的内容。到这一点,我们的源代码可以从互联网上访问,但生活在安全环境的多层中。即使我们的应用程序存在某种漏洞,攻击者也很难伤害我们正在运行的系统。以下图表展示了 Docker 提供的安全层:

软件开发图表  自动生成的描述

图 10.13:Docker 安全

总结来说,在基础层,我们的宿主操作系统,例如 Windows 或 macOS,运行 Docker 运行时,这将在下一节中安装。Docker 运行时可以运行自包含的 Docker 镜像,这些镜像由上述 Dockerfile 定义。duluca/minimal-nginx-web-server基于轻量级的 Linux 操作系统 Alpine。Alpine 是 Linux 的一个完全精简的版本,不包含任何 GUI、驱动程序,甚至大多数您可能从 Linux 系统中期望的 CLI 工具。因此,该操作系统的大小仅为约 5 MB。然后,我们从nginx镜像继承,该镜像安装了网络服务器,其自身大小约为几 MB。最后,我们的自定义nginx配置覆盖了默认镜像,结果生成一个微小的约 7 MB 镜像。nginx服务器被配置为服务/var/www文件夹的内容。在Dockerfile中,我们仅复制我们开发环境中/dist文件夹的内容,并将其放置到/var/www文件夹中。我们稍后将构建并执行此镜像,该镜像将运行包含我们dist文件夹输出的nginx网络服务器。我已经发布了一个类似的镜像,名为duluca/minimal-node-web-server,其大小约为 15 MB。

duluca/minimal-node-web-server可以更直接地使用,尤其是如果您不熟悉nginx。它依赖于一个Express.js服务器来服务静态内容。大多数云提供商都提供了使用 Node 和 Express 的具体示例,这可以帮助您缩小任何错误的范围。此外,duluca/minimal-node-web-server内置了 HTTPS 重定向支持。您可能会花费大量时间尝试设置一个nginx代理来完成相同的事情,而您只需在您的Dockerfile中设置ENFORCE_HTTPS环境变量即可。请参阅以下示例 Dockerfile:

**Dockerfile**
FROM duluca/minimal-node-web-server:lts-alpine
WORKDIR /usr/src/app
COPY dist/local-weather-app public
ENTRYPOINT [ "npm", "start" ]
ENV ENFORCE_HTTPS=xProto 

您可以在https://github.com/duluca/minimal-node-web-server了解更多关于minimal-node-web-server提供的选项。

正如我们现在所看到的,Docker 的美丽之处在于您可以直接导航到hub.docker.com,搜索duluca/minimal-nginx-web-serverduluca/minimal-node-web-server,阅读其Dockerfile,并追溯其起源,直至原始的基础镜像,这是网络服务器的基础。我鼓励您以这种方式检查您使用的每个 Docker 镜像,以了解它究竟为您带来了哪些需求。

您可能会觉得它过于强大,或者它具有您从未知晓的功能,这些功能可以使您的生活变得更加轻松。

注意,父镜像应该拉取 duluca/minimal-nginx-web-server 的特定标签,即 1-alpine。同样,duluca/minimal-node-web-serverlts-alpine 拉取。这些常绿基础包始终包含 nginx 的最新版本 1 和 Alpine 或 Node 的 LTS 版本。在 Docker Hub 上,我设置了管道,当发布新的基础镜像时自动更新这两个镜像。因此,每次您拉取这些镜像时,您都会获得最新的错误修复和安全补丁。

拥有一个常绿依赖树可以减轻您作为开发者的负担,不必寻找 Docker 镜像的最新版本。或者,如果您指定了版本号,您的镜像将不会受到任何潜在的重大更改的影响。然而,记住在新的构建后测试您的镜像比永远不更新镜像、可能部署受损害的软件要好。毕竟,网络是不断变化的,不会因为您更新镜像而放慢速度。

就像 npm 包一样,Docker 可以带来极大的便利和价值,但您必须注意了解您正在使用的工具。

安装 Docker

要能够构建和运行容器,您必须首先在您的计算机上安装 Docker 执行环境。您可以在 www.docker.com/products/docker-desktop/ 下载 Docker Desktop。按照屏幕上的说明完成安装。

设置 npm 脚本 for Docker

现在,让我们为您的 Angular 应用程序配置一些 Docker 脚本,您可以使用这些脚本来自动化容器的构建、测试和发布。我开发了一套名为 npm 脚本 for Docker 的脚本,这些脚本在 Windows 10 和 macOS 上运行。您可以通过执行以下命令获取这些脚本的最新版本,并在您的项目中自动配置它们。

现在在 local-weather-applemon-mart 两个项目中运行以下命令!

安装 npm 脚本 for Docker 任务:

$ npx mrm npm-docker 

执行 mrm 脚本后,我们就准备好使用本地天气应用程序作为示例,深入挖掘配置设置。

构建 Docker Hub 上的镜像并发布

接下来,让我们确保您的项目配置正确,这样我们就可以将其容器化,构建可执行镜像,并将其发布到 Docker Hub,这样我们就可以在任何构建环境中访问它。

本节使用 local-weather-app 仓库。

您需要采取以下步骤:

  1. hub.docker.com/ 上注册 Docker Hub 账户。

  2. 为您的应用程序创建一个公共(免费)仓库。

  3. package.json 中,添加或更新 config 属性,使用以下配置属性:

    **package.json**
      ...
      "config": {
        "imageRepo": "[namespace]/[repository]",
        "imageName": "custom_app_name",
        "imagePort": "0000",
        "internalContainerPort": "3000"
      },
      ... 
    

    namespace 将是你的 Docker Hub 用户名。你将在创建时定义你的仓库名称。一个示例的 repository 变量应类似于 duluca/localcast-weather。镜像名称用于在使用 Docker 命令(如 docker ps)时方便识别你的容器。我将我的命名为 localcast-weatherimagePort 属性将定义用于从容器内部暴露应用程序的端口。由于我们使用端口 4200 进行开发,请选择不同的端口,例如 8080internalContainerPort 定义了你的 Web 服务器映射到的端口。这通常对于 Node 服务器是端口 3000,对于 nginx 服务器是端口 80。请参考你使用的基容器的文档。

  4. 让我们回顾一下之前由 mrm 任务添加到 package.json 中的 Docker 脚本。以下部分展示了添加的脚本的注释版本,解释了每个条目。

    注意,使用 npm 脚本时,prepost 关键字用于在执行给定脚本之前或之后执行辅助脚本。脚本被有意地分成更小的部分,以便更容易阅读和维护。

    • build 脚本如下:

      **package.json**
      ...
        "scripts": {
          ...
          "predocker:build": "npm run build",
          "docker:build": "cross-conf-env docker image build --platform linux/amd64,linux/arm64 . -t $npm_package_config_imageRepo:$npm_package_version",
          "postdocker:build": "npm run docker:tag",
          ... 
      

    注意,以下 cross-conf-env 命令确保脚本在 macOS、Linux 和 Windows 环境中都能同样良好地执行。

    • npm run docker:build 将在 pre 脚本中构建你的 Angular 应用程序,然后使用 docker image build 命令构建 Docker 镜像,并在 post 脚本中使用版本号标记镜像。

    在我的项目中,pre 命令以 prod 模式构建我的 Angular 应用程序,并运行测试以确保我有一个优化后的构建,没有失败的测试。

    我的 pre 命令如下:

    "predocker:build": "npm run build:prod && npm test -- --watch=false" 
    
    • tag 脚本如下:

      **package.json**
          ...
          "docker:tag": " cross-conf-env docker image tag $npm_package_config_imageRepo:$npm_package_version $npm_package_config_imageRepo:latest",
          ... 
      
    • npm run docker:tag 将使用 package.json 中的 version 属性和最新标签标记已构建的 Docker 镜像。

    • stop 脚本如下:

      **package.json**
          ...
          "docker:stop": "cross-conf-env docker stop $npm_package_config_imageName || true",
          ... 
      
    • npm run docker:stop 如果镜像当前正在运行,则会停止它,这样 run 脚本就可以无错误地执行。

    • run 脚本如下:

      **package.json**
          ...
          "docker:run": "run-s -c docker:stop docker:runHelper",
          "docker:runHelper": "cross-conf-env docker run -e NODE_ENV=local --rm --name $npm_package_config_imageName -d -p $npm_package_config_imagePort:$npm_package_config_internalContainerPort $npm_package_config_imageRepo",
          ... 
      

    注意,run-srun-p 命令是 npm-run-all 包的一部分,用于同步或并行化 npm 脚本的执行。

    • npm run docker:run 如果镜像已经在运行,则会停止它,然后使用 docker run 命令运行新构建的镜像版本。请注意,imagePort 属性用作 Docker 镜像的外部端口,它映射到 Node.js 服务器监听的内部端口 3000

    • publish 脚本如下:

      **package.json**
          ...
          "predocker:publish": "echo Attention! Ensure `docker login` is correct.",
          "docker:publish": "cross-conf-env docker image push $npm_package_config_imageRepo:$npm_package_version",
          "postdocker:publish": "cross-conf-env docker image push $npm_package_config_imageRepo:latest",
          ... 
      

    注意,docker:tag 在容器上添加了最新版本标签。当我们推送最新标签时,两个标签会同时推送。

    • npm run docker:publish 将使用 docker image push 命令将构建的镜像发布到配置的仓库,在这种情况下是 Docker Hub。

    • 首先,发布带有版本号的镜像,然后在 post 中标记一个 latesttaillogs 脚本如下:

      **package.json**
          ...
          "docker:taillogs": "cross-conf-env docker logs -f $npm_package_config_imageName",
          ... 
      
    • npm run docker:taillogs 将使用 docker log -f 命令显示正在运行的 Docker 实例的内部控制台日志,这是一个用于调试您的 Docker 实例的有用工具。

    • open 脚本如下:

      **package.json**
          ...
          "docker:open": "sleep 2 && cross-conf-env open-cli http://localhost:$npm_package_config_imagePort",
          ... 
      
    • npm run docker:open 将等待 2 秒,以考虑延迟,然后使用 imagePort 属性启动浏览器,并使用正确的 URL 打开您的应用程序。

    • debug 脚本如下:

      **package.json**
          ...
          "predocker:debug": "run-s docker:build docker:run",
          "docker:debug": "run-s -cs docker:open:win docker:open:mac docker:taillogs"
        },
      ... 
      
    • npm run docker:debug 将构建您的镜像并在 pre 中运行其实例,打开浏览器,然后开始显示容器的内部日志。

  5. 自定义预构建脚本以在生产模式下构建您的 Angular 应用程序,并在构建镜像之前执行单元测试:

    **package.json**
        "build": "ng build",
        "build:prod": "ng build --prod",
        "predocker:build": "npm run build:prod && npm test -- --watch=false", 
    

    注意,ng build 使用了 --prod 参数,这实现了两个目的:通过即时编译AOT)优化应用程序的大小,使其显著减小,从而提高运行时性能,并使用 src/environments/environment.prod.ts 中定义的配置项。

  6. 更新 src/environments/environment.prod.ts 以便看起来您正在使用自己的 appIdOpenWeather

    export const environment = {   
      production: true,
      appId: '01ff1xxxxxxxxxxxxxxxxxxxxx',
      username: 'localcast',
      baseUrl: 'https://',
      geonamesApi: 'secure',
    } 
    

    我们正在修改 npm test 的执行方式,因此测试只会运行一次,并且工具会停止执行。提供 --watch=false 选项来实现此行为,而不是开发友好的默认连续执行行为。

  7. 在项目根目录下创建一个名为 Dockerfile 的无扩展名的新文件。

  8. 实现或替换 Dockerfile 的内容,如下所示:

    **Dockerfile**
    FROM duluca/minimal-node-web-server:lts-alpine 
    WORKDIR /usr/src/app
    COPY dist/local-weather-app public 
    

    请务必检查您的 dist 文件夹的内容,以确保您正在复制正确的文件夹,该文件夹在其根目录中包含 index.html 文件。

  9. 执行 npm run predocker:build 并在终端中查看它是否无错误运行,以确保您的应用程序更改已成功。

  10. 执行 npm run docker:build 并在终端中查看它是否无错误运行,以确保您的镜像构建成功。

    • 虽然您可以单独运行提供的任何脚本,但您实际上只需要记住其中的两个脚本即可:

    • npm run docker:debug 将测试、构建、标记、运行、跟踪并在新的浏览器窗口中启动您的容器化应用程序进行测试。

    • npm run docker:publish 将您刚刚构建和测试的镜像发布到在线 Docker 仓库。

  11. 在您的终端中执行 docker:debug

    $ npm run docker:debug 
    
    • 成功的 docker:debug 运行应该导致一个新的聚焦浏览器窗口打开,其中包含您的应用程序和终端中正在跟踪的服务器日志,如下所示:

      Current Environment: local.
      Server listening on port 3000 inside the container
      Attention: To access server, use http://localhost:EXTERNAL_PORT
      EXTERNAL_PORT is specified with 'docker run -p EXTERNAL_PORT:3000'. See 'package.json->imagePort' for the default port.      
      GET / 304 2.194 ms - -
      GET /runtime-es2015.js 304 0.371 ms - -
      GET /polyfills-es2015.js 304 0.359 ms - -
      GET /styles-es2015.js 304 0.839 ms - -
      GET /vendor-es2015.js 304 0.789 ms - -
      GET /main-es2015.js 304 0.331 ms - - 
      

    您应该始终运行 docker ps 来检查您的镜像是否正在运行,它上次更新是什么时候,以及它是否与任何声称相同端口的现有镜像冲突。

  12. 在您的终端中执行 docker:publish

    $ npm run docker:publish 
    
    • 您应该在终端窗口中观察到如下成功的运行:

      The push refers to a repository [docker.io/duluca/localcast- weather]
      60f66aaaaa50: Pushed
      ...
      latest: digest: sha256:b680970d76769cf12cc48f37391d8a542fe226b66d9a6f8a7ac81ad77be4 f58b size: 2827 
      

随着时间的推移,您的本地 Docker 缓存可能会显著增长;例如,在我的笔记本电脑上,两年内已达到大约 40 GB。您可以使用docker image prunedocker container prune命令来减小缓存大小。有关更详细的信息,请参阅docs.docker.com/config/pruning中的文档。

通过定义Dockerfile并编写使用它的脚本,我们在代码库中创建了活生生的文档。我们实现了 DevOps 并关闭了配置差距。

确保像处理local-weather-app一样将lemon-mart容器化,并通过执行npm run docker:debug来验证您的工作。

VS Code 提供了一种视觉方式来与 npm 脚本交互。让我们接下来看看 VS Code 的 npm 脚本支持。

VS Code 中的 npm 脚本

VS Code 默认提供对 npm 脚本的支持。要启用 npm 脚本探索器,请打开 VS Code 设置并确保存在"npm.enableScriptExplorer": true属性。一旦这样做,您将在资源管理器窗格中看到一个可展开的标题NPM 脚本,如下面的截图所示,用箭头突出显示:

计算机的截图  自动生成的描述

图 10.14:VS Code 中的 NPM 脚本

您可以点击任何脚本以启动package.json中包含该脚本的行,或者右键单击并选择运行来执行该脚本。

让我们看看与 Docker 交互的更简单的方法。

VS Code 中的 Docker 扩展

与 Docker 镜像和容器交互的另一种方式是通过 VS Code。如果您已按照第二章中建议的,安装了来自 Microsoft 的ms-azuretools.vscode-docker Docker 扩展,您可以通过 VS Code 左侧导航菜单中的 Docker 标志来识别该扩展,如下面的截图所示,用白色圆圈标出:

计算机的截图  自动生成的描述

图 10.15:VS Code 中的 Docker 扩展

让我们浏览一下扩展提供的一些功能。请参考前面的截图和以下列表中的编号步骤,以获得快速说明:

  1. 镜像包含您系统上所有容器快照的列表。

  2. 右键单击 Docker 镜像将创建一个上下文菜单,以运行各种操作,如运行推送标记

  3. 容器列出了您系统上所有可执行的 Docker 容器,您可以启动、停止或附加到它们。

  4. 注册表显示您配置的连接到的注册表,例如 Docker Hub 或AWS Elastic Container RegistryAWS ECR)。

虽然该扩展使与 Docker 的交互变得更容易,但Docker 的 npm 脚本(您使用mrm任务配置的)自动化了许多与构建、标记和测试镜像相关的任务。它们都是跨平台的,在 CI 环境中也能同样良好地工作。

npm run docker:debug 脚本自动化了许多任务,以验证您有一个良好的镜像构建!

现在让我们学习如何将我们的容器部署到云中,并最终实现持续交付(CD)。

云中的容器工作

使用 Docker 的一个优点是我们可以在任何数量的操作系统环境中部署它,从个人电脑到服务器和云服务提供商。无论如何,我们期望我们的容器在所有环境中都能正常工作。

在本章的早期,我提到与像 Azure、AWS 和 Google Cloud 这样的全面云服务提供商合作是复杂的。为了在云中部署您的容器,您可能需要使用这些提供商之一。不时地,会出现提供简单无缝容器托管的提供商,但多年来,这些选项已经消失了。

云服务提供了从托管到非托管解决方案的广泛方法来运行容器。托管和非托管之间的关键区别在于用户和云服务提供商之间共享的控制和责任水平。这被称为共享责任模型。在托管配置中,您将更多的基础设施控制和责任让给云服务提供商。这随之带来了更高的安全性和通常更便宜的云账单。

图片

图 10.16:共享责任模型

上面的图示展示了在云中运行容器的共享责任模型。在非托管模式下,云服务提供商为您提供了访问虚拟机的权限。云服务提供商管理和维护物理机器,对用户完全抽象。现在,您需要配置操作系统OS)并确保其安全。在操作系统之上,您可以设置容器运行时或涉及多个虚拟机的集群。

阅读关于 ECS Fargate 威胁模型的黄开智的优秀文章,链接为sysdig.com/blog/ecs-fargate-threat-modeling

容器集群是一组用于运行容器化工作负载的主机。像 Kubernetes 这样的容器编排软件自动化并管理集群基础设施中的容器。这允许您根据负载变化扩展容器实例。集群还提供了丰富的功能,如服务发现。您可以在kubernetes.io了解更多关于 Kubernetes 的信息。

在托管模式下,云服务提供商为您提供了具有类似集群功能的容器运行时环境,包括对 Kubernetes 的支持。云服务提供商确保运行时安全,用户只需负责容器。这些服务提供的是无服务器的。在这个领域,每个云服务提供商都提供各种服务,这些服务在功能上存在显著差异。

这里有一些流行的选项列表:

  • AWS Fargate是一个无服务器计算引擎,可以在不管理服务器或集群的情况下运行容器。与弹性容器服务ECS)和弹性 Kubernetes 服务EKS)集成。

  • Google Cloud Run是一个完全管理的无服务器平台,用于无状态容器化应用程序。自动扩展并按请求计费。

  • Azure 容器实例可以在不采用高级编排服务的情况下运行单个容器。按秒计费。

  • Amazon ECS提供在弹性计算云(EC2)实例集群上高度可扩展的 Docker 容器管理。与其他 AWS 服务紧密集成。AWS EKS 是 AWS ECS 的 Kubernetes 版本。

  • Google Kubernetes EngineGKE)是运行在 Google Cloud 上的托管 Kubernetes 环境。利用原生 Google Cloud 集成轻松部署容器。

  • Azure Kubernetes ServiceAKS)是托管在 Azure 上的完全管理的 Kubernetes 集群服务。具有自动扩展功能的容器化部署简化流程。

无服务器选项(AWS Fargate、Google Cloud Run、Azure 容器实例)的主要优势是它们完全托管、自动扩展,并且具有按使用付费的定价模式。然而,与预配置的 Kubernetes 相比,它们的定制性有限。预配置的 Kubernetes 选项(Amazon ECS、Google GKE、Azure AKS)提供更多控制、定制以及运行有状态应用程序的能力。然而,它们需要手动扩展和管理基础设施。

总体而言,无服务器选项更容易使用但灵活性较低,而预配置的 Kubernetes 提供更多定制,但需要 DevOps 知识来管理基础设施。您的选择取决于工作负载是否适合无服务器约束以及您更倾向于多少控制与多少简单。

到目前为止,您已经将代码部署到云端,掌握了与 Docker 容器一起工作的基础知识,并了解了在云端部署容器的细微差别。持续部署CD)是我座右铭的体现:“发布它,否则它从未发生”。接下来,我们将介绍如何将部署集成到您的 CI 管道中,以实现 CD。

npm run docker:debug脚本自动化了许多任务,以验证您是否有一个良好的镜像构建!

持续部署

CD 是这样一个理念:成功通过您的管道的代码更改可以自动部署到目标环境。尽管存在持续部署到生产环境的例子,但大多数企业更喜欢将目标环境定位在开发环境中。采用门控方法将更改通过开发环境的各个阶段,包括测试、预发布,最终到生产。CircleCI 可以通过审批工作流程促进门控部署,这在本节后面将进行介绍。

在 CircleCI 中,您需要实现一个部署作业来部署您的镜像。您可以在该作业中部署到许多目标,例如 Google Cloud Run、Docker Hub、Heroku、Azure 或 AWS ECS。与这些目标的集成将涉及多个步骤。从高层次来看,这些步骤如下:

  1. 为您的目标环境配置一个 orb,它提供了部署您的软件所需的 CLI 工具。

  2. 将针对目标环境的登录凭证或访问密钥作为 CircleCI 环境变量存储。

  3. 如果不使用特定平台的build命令,则在 CI 管道中构建容器。然后使用docker push将生成的 Docker 镜像提交到目标平台的注册表。

  4. 执行特定平台的deploy命令,指示目标运行刚刚推送的 Docker 镜像。

通过使用基于 Docker 的工作流程,我们在系统和目标环境方面实现了极大的灵活性。以下图表通过突出我们可用的可能选择排列来阐述这一点:

结构图  自动生成的描述

图 10.17:n-to-n 部署

如您所见,在容器化的世界中,可能性是无限的。我们将使用本章后面添加的与部署相关的 npm 脚本来在我们的 CI 管道中实现部署作业。

在基于 Docker 的工作流程之外,您可以使用专门设计的 CLI 工具快速部署您的应用程序。接下来,让我们看看如何使用 CircleCI 将应用程序部署到 Vercel。

使用 CircleCI 将应用程序部署到 Vercel

之前,我们配置了 LocalCast Weather 应用程序使用 CircleCI 进行构建。我们可以增强我们的 CI 管道,以使用构建输出,并可选择将其部署到 Vercel。

本节使用local-weather-app仓库。本节的config.yml文件名为.circleci/config.stage9.yml

让我们更新config.yml文件,添加一个名为deploy的新作业。在即将到来的工作流程部分,我们将使用此作业在批准时部署管道:

  1. 从您的 Vercel 账户创建一个令牌。

  2. 在您的 CircleCI 项目中添加一个名为VERCEL_TOKEN的环境变量,并将您的 Vercel 令牌作为值存储。

  3. config.yml中,更新build作业以包含新步骤,并添加一个名为deploy的新作业:

    **.circleci/config.yml**
    ...
    jobs:
      build_and_test:
        ...       
        - run:
            name: Move compiled app to workspace
            command: |
              set -exu
              mkdir -p /tmp/workspace/dist
              mv dist/local-weather-app /tmp/workspace/dist/
        - persist_to_workspace:
            root: /tmp/workspace
            paths:
              - dist/local-weather-app
      deploy:
        docker:
          - image: circleci/node:lts
        working_directory: ~/repo
        steps:
          - attach_workspace:
              at: /tmp/workspace
          - run: npx vercel deploy --token $VERCEL_TOKEN --prod /tmp/
                 workspace/dist/<< parameters.project >> --yes 
    

    build作业中,构建完成后,我们添加两个新步骤。首先,我们将dist文件夹中的编译应用程序移动到工作区,并持久化该工作区,以便我们可以在另一个作业中使用它。在名为deploy的新作业中,我们附加工作区并使用npx运行vercel命令来部署dist文件夹。这是一个简单的过程。

    注意,$VERCEL_TOKEN是我们存储在 CircleCI 项目中的环境变量。

  4. 实现一个简单的 CircleCI 工作流程,以持续部署build作业的结果:

    **.circleci/config.yml**
    ...
    workflows:
      version: 2
      build-test-and-deploy:
        jobs:
          - build_and_test
          - deploy:
             requires:
               - build_and_test 
    

    注意,deploy作业在执行之前会等待build作业完成。

  5. 通过检查测试结果来确保您的 CI 管道成功执行:计算机屏幕截图  自动生成的描述

    图 10.18:本地天气应用的 Vercel 成功部署

大多数云服务提供商的 CLI 命令需要在您的管道中安装才能使用。由于 Vercel 有一个 npm 包,这很容易做到。AWS、Google Cloud 或 Microsoft Azure 的 CLI 工具需要使用brewchoco等工具安装。CircleCI 提供 orbs,这些是可重用的代码片段,有助于自动化重复过程,加快项目设置,并便于与第三方工具集成。

您可以在circleci.com/orbs上了解更多关于 orbs 的信息。

查看 Orb 注册表以获取有关如何使用这些 orbs 的更多信息circleci.com/orbs/registry

CD 命令在开发和测试环境中效果很好。然而,通常希望有门控部署,即部署在到达生产环境之前必须有人批准。接下来,让我们看看如何使用 CircleCI 来实现这一点。

门控 CI 工作流程

在 CircleCI 中,您可以定义一个工作流程来控制作业的执行方式和时间。考虑以下配置,给定作业builddeploy

**.circleci/config.yml**
workflows:
  version: 2
  build-and-deploy:
    jobs:
      - build
      - hold:
          type: approval
          requires:
            - build
      - deploy:
          requires:
            - hold 

首先,执行build作业。然后,我们引入一个名为hold的特殊作业,其类型为approval,它要求build作业成功完成。一旦这样,管道就会被挂起。如果或当决策者批准hold,则deploy步骤可以执行。参考以下屏幕截图以查看hold的外观:

计算机屏幕截图  自动生成的描述

图 10.19:管道中的中断

build and test steps are broken out into two separate jobs:
workflows:
  version: 2
    build-test-and-approval-deploy:
      jobs:
      - build 
      - test
      - hold:
         type: approval
         requires:
           - build
           - test
         filters:
           branches:
             only: main
      - deploy:
        requires:
          - hold 

在这种情况下,buildtest作业是并行执行的。如果我们在一个分支上,管道就会在这里停止。一旦分支与main合并,管道就会被挂起,决策者有选择部署构建或不部署的选项。这种类型的分支过滤确保只有合并到main的代码可以被部署,这与 GitHub 流程一致。

接下来,我们将介绍如何将代码覆盖率报告与我们的 CI 运行集成,以更好地了解我们的测试覆盖率,并可选地使未达到一定阈值的构建失败。

代码覆盖率报告

代码覆盖率报告是了解您的 Angular 项目单元测试覆盖量和趋势的好方法。

要生成您应用的报告,请从您的项目文件夹中执行以下命令:

$ npx ng test --watch=false --code-coverage 

生成的报告将作为一个名为coverage的文件夹下的 HTML 文件创建;执行以下命令以在浏览器中查看它:

$ npx http-server -c-1 -o -p 9875 ./coverage 

您可能需要为ng test命令指定--project。同样,覆盖率报告可能生成在coverage下的子文件夹中。您可以选择文件夹来查看它。

这是istanbul为 LemonMart 生成的文件夹级样本覆盖率报告:

电子表格截图,自动生成描述

图 10.20:LemonMart 的 Istanbul 代码覆盖率报告

您可以针对特定文件夹(如src/app/auth)进行深入分析,并获得文件级报告,如下所示:

数据截图,自动生成描述

图 10.21:src/app/auth 的 Istanbul 代码覆盖率报告

您可以进一步深入到特定文件(如cache.service.ts)的行级覆盖率,如下所示:

计算机截图,自动生成描述

图 10.22:cache.service.ts 的 Istanbul 代码覆盖率报告

在前面的截图中,您可以看到行51217182122没有被任何测试覆盖。I图标表示if路径没有被采取。我们可以通过实现单元测试来增加代码覆盖率,这些单元测试将测试CacheService中包含的函数。作为一个练习,您应该尝试至少用一个新的单元测试覆盖这些函数中的一个,并观察代码覆盖率报告的变化。

CI 中的代码覆盖率

理想情况下,您的 CI 服务器配置应该在每次测试运行时生成和托管代码覆盖率报告。然后您可以使用代码覆盖率作为另一个代码质量关卡,防止降低整体代码覆盖率百分比的代码被合并。这是一种加强测试驱动开发TDD)思维的好方法。

您可以使用 Coveralls 等服务(位于coveralls.io)来实现代码覆盖率检查,这些服务可以直接在 GitHub 拉取请求中嵌入您的代码覆盖率级别。

让我们为 LemonMart 配置 Coveralls:

lemon-mart仓库中,本节config.yml文件的名称为.circleci/config.stage9.yml

  1. 在您的 CircleCI 账户设置中,在安全部分确保您允许执行未经认证/未签名的插件。

  2. coveralls.io/注册您的 GitHub 项目。

  3. 复制仓库令牌并将其存储为 CircleCI 中的环境变量,变量名为COVERALLS_REPO_TOKEN

  4. 在进行任何代码更改之前创建一个新的分支。

  5. 使用以下方式更新.circleci/config.yml文件,添加 Coveralls 插件:

    **.circleci/config.yml**
    version: 2.1
    orbs:
    **coveralls:****coveralls/coveralls@2** 
    
  6. 更新build作业以存储代码覆盖率结果并将其上传到 Coveralls:

    **.circleci/config.yml**
    jobs:
      build_and_test:
        ...
          - run: npm test -- --watch=false --code-coverage
          - store_test_results:
              path: ./test_results
          **-****store_artifacts:**
              **path:****./coverage**
          **-****coveralls/upload**
          - run:
              name: Tar & Gzip compiled app
              command: tar zcf dist.tar.gz dist/lemon-mart
          - store_artifacts:
              path: dist.tar.gz 
    

    store_test_resultsstore_artifacts将存储测试结果和代码覆盖率数据,以便在其他作业或插件中进行分析。CircleCI 可以在其 Web UI 中显示 XML 格式的覆盖率报告。The coveralls/upload command上传我们刚刚存储的用于分析的代码覆盖率数据。

    注意,插件会自动为您配置 Coveralls 账户,因此coveralls/upload命令可以上传您的代码覆盖率结果。

  7. 将您的更改提交到分支并发布。

  8. 使用分支在 GitHub 上创建一个拉取请求。

  9. 在拉取请求上,验证你能否看到 Coveralls 正在报告你的项目代码覆盖率,如下所示:计算机错误的截图  自动生成的描述

    图 10.23:Coveralls 报告代码覆盖率

  10. 将拉取请求合并到主分支。

恭喜!现在,你可以修改你的分支保护规则,要求在合并请求合并到主分支之前,代码覆盖率必须高于一定百分比。

你可以在coveralls.io设置覆盖率阈值。

LemonMart 项目在github.com/duluca/lemon-mart中实现了一个功能齐全的config.yml文件。此文件还实现了Cypress.io,覆盖了 Angular 的默认 e2e 工具。Cypress orb 可以记录测试结果,并允许你从 CircleCI 管道中查看它们。

利用本章所学,你可以将 LocalCast Weather 的deploy脚本集成到 LemonMart 中,并实施门控部署工作流程。

良好的工程实践和 DevOps 实践对于交付成功项目和建立令人满意的职业生涯至关重要。感谢阅读这本书!

摘要

在本章中,你学习了如何使用 CI/CD 管道创建价值交付流。我们讨论了在企业环境中,自动化单元测试对于快速交付高质量代码的重要性。你使用 CircleCI 配置了一个 CI 管道。你学习了使用 GitHub flow 进行基于主干的开发以及实施质量门。你将一个 Web 应用程序部署到 Vercel,并利用ng deploy进行 Firebase 部署。

接下来,我们介绍了使用 Docker 和 npm 脚本的 DevOps 和 IaC 技术。你将你的 Web 应用程序容器化,学习了在云中与容器一起工作的方法,并学习了如何实施门控 CI 工作流程。此外,你还熟悉了 orbs、工作流程和代码覆盖率工具。

我们利用 CircleCI 作为基于云的 CI 服务,并强调你可以将构建结果部署到所有主要的云托管提供商。你看到了如何实现 CD。我们通过 CircleCI 演示了如何通过 Vercel 进行示例部署,展示了如何实现持续部署。

通过一个健壮的 CI/CD 管道,你可以与客户和团队成员共享每个应用程序构建,并快速向最终用户交付错误修复或新功能。

恭喜!你已经完成了你的企业级应用程序的 Angular之旅。我希望你学到了一些新东西。学习和成长的最佳方式是通过教学和分享,所以我希望你能将这本书作为参考资料保留,并与朋友、家人和同事分享。

如果你已经读到这儿,请发给我一张你与(电子)书的照片或关于内容对你影响的笔记,我将与你分享一个专属的数字徽章以表彰你的成就。

继续您的旅程,请访问 AngularForEnterprise.com

练习

  1. 将 CircleCI 和 Coveralls 徽章添加到您的代码仓库的 README.md 文件中。

  2. 实现 Cypress 进行端到端测试,并使用 Cypress orb 在您的 CircleCI 管道中运行它。

  3. 实现 LemonMart 应用的 Vercel 部署和条件工作流程。生成的 config.yml 文件位于 lemon-mart 仓库中,命名为 .circleci/config.stage9.yml

进一步阅读

问题

尽可能地回答以下问题,以确保您已经理解了本章的关键概念,而无需使用 Google 搜索。你知道你是否回答了所有正确的问题吗?请访问 angularforenterprise.com/self-assessment 获取更多信息:

  1. 测试金字塔是什么?

  2. 固定装置和匹配器是什么?

  3. 模拟、间谍和存根之间的区别是什么?

  4. 在生产模式下构建 Angular 的好处是什么?

  5. GitHub 流是如何工作的?

  6. 为什么我们应该保护主分支?

  7. 解释 Docker 镜像和 Docker 容器之间的区别。

  8. 为什么你更喜欢在云中使用托管容器运行时而不是非托管运行时?

  9. CD 管道的目的是什么?

  10. CD 的好处是什么?

  11. 我们如何填补配置差距?

  12. CircleCI orb 做什么?

  13. 使用多阶段 Dockerfile 的好处是什么?

  14. 代码覆盖率报告如何帮助维护您应用程序的质量?

附录 A

设置您的开发环境

在您和团队成员之间共享一致的开发环境非常重要。一致性有助于避免许多与 IT 相关的问题,包括持续维护、许可和升级成本。此外,您还希望确保整个团队拥有相同的开发体验。这样,如果团队成员遇到配置问题,其他团队成员可以协助解决问题。为新团队成员创建一个无烦恼且高效的入职体验也是至关重要的。

简单且文档齐全的入职流程确保新团队成员可以快速变得高效并融入团队。在持续的过程中,保持一致且最小化的开发环境对团队来说仍然是一个挑战。自动化可以帮助在开发机器上提供和维护一致的环境。

推荐的操作系统是 Windows 10 或 Windows 11 Pro,并启用 PowerShell v7+ 和开发者模式,或者 macOS Ventura 或 Sonoma,并安装了终端和 XCode 开发者工具。建议使用基于 Chromium 的 Google Chrome 或 Microsoft Edge 作为网络浏览器,因为它们提供了几乎相同的开发者体验。

本书建议的大多数软件都是跨平台工具,因此它们也适用于 Linux 系统。然而,您在使用这些系统时的体验可能会有所不同。

本附录涵盖:

  • 推荐的 Web 开发工具

  • 命令行包管理器

  • 为 Windows 和 macOS 安装自动化

  • 使用 Angular CLI 进行项目设置

  • 优化 VS Code 以支持 Angular

让我们从回顾推荐的 Web 开发工具以及如何通过自动化脚本来快速安装这些工具开始。在附录的后面部分,我们将介绍如何创建这样的脚本。

推荐的 Web 开发工具

本节涵盖了推荐用于开发 Web 应用程序的工具,如下所示:

工具 描述 URL
Git 版本控制系统 git-scm.com
GitHub Desktop 运行 Git 命令并与 GitHub 交互的图形用户界面GUI desktop.github.com
Node.js 跨平台 JavaScript 运行时环境 nodejs.org
Visual Studio Code 跨平台集成开发环境IDE code.visualstudio.com
Docker Desktop 轻量级容器虚拟化平台 www.docker.com/products/docker-desktop

表 A.1:Web 开发所需的工具

要自动安装本书所需的全部 Web 开发工具,请在您的操作系统上执行以下命令以配置您的环境。

在 Windows PowerShell 中执行:

PS> Install-Script -Name setup-windows-dev-env
PS> setup-windows-dev-env.ps1 

在 macOS 终端中执行:

$> bash <(wget -O - https://git.io/JvHi1) 

更多信息,请参阅 github.com/duluca/web-dev-environment-setup

macOS 脚本安装了 Node 版本 20,这是发布时的 长期支持LTS)版本。为了确保您始终安装了最新的 LTS 版本,脚本安装了 n,一个 Node 版本管理器。使用 n,您可以轻松地在不同的 Node 版本之间切换。

一旦您安装了您的 IDE,您就可以开始开发了。在下一节中,您将了解基于 CLI 的包管理器。您会发现使用 CLI 工具比处理单个安装程序要好。自动化 CLI 工具要容易得多,使得设置和维护任务可重复且快速。

CLI 包管理器

通过 图形用户界面GUI)安装软件速度慢且难以自动化。作为一名全栈开发者,无论是 Windows 还是 Mac 用户,您必须依赖 命令行界面CLI)包管理器来高效地安装和配置您依赖的软件。

任何可以用 CLI 命令表达的内容也可以自动化。

安装 Windows 的 Chocolatey

Chocolatey 是一个基于 CLI 的 Windows 包管理器,可用于自动化软件安装。要在 Windows 上安装 Chocolatey,您需要运行提升的命令壳:

  1. 打开 开始 菜单。

  2. PowerShell 中开始输入。

  3. 您应该看到 Windows PowerShell 桌面应用 作为搜索结果。

  4. 右键单击 Windows PowerShell 并选择 以管理员身份运行

  5. 这会触发一个 用户账户控制UAC)警告;选择 以继续。

  6. PowerShell 中执行 chocolatey.org/install 中的安装命令以安装 Chocolatey 包管理器:

    PS> Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net. ServicePointManager]::SecurityProtocol = [System.Net.ServicePointM anager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net. WebClient).DownloadString('https://chocolatey.org/install.ps1')) 
    
  7. 通过执行 choco 验证您的 Chocolatey 安装。

  8. 您应该看到以下截图所示的类似输出:计算机的截图  自动生成的描述

    图 A.1:Chocolatey 成功安装

所有后续的 Chocolatey 命令也必须从提升的命令壳中执行。或者,您可以在不需要提升命令壳的非管理员设置中安装 Chocolatey。然而,这会导致一个非标准且不太安全的开发环境,并且通过此工具安装的某些应用程序可能仍然需要提升权限。

WinGet 是由 Microsoft 提供的 Windows 包管理器。您可以在 github.com/microsoft/winget-cli/ 或 Microsoft Store 中了解更多关于 WinGet 的信息。

我更喜欢 Chocolatey 而不是 WinGet,因为当脚本遇到意外的配置时,它提供了更好的弹性。有关 Chocolatey 的更多信息,请参阅 chocolatey.org/install

安装 macOS 的 Homebrew

Homebrew 是一个基于 CLI 的 macOS 软件包管理器,可用于自动化软件安装。要在 macOS 上安装 Homebrew,您需要运行一个命令行:

  1. 使用 + Space 启动 Spotlight 搜索

  2. 输入 终端

  3. 在终端中执行以下命令以安装 Homebrew 软件包管理器:

    $ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" 
    
  4. 通过执行 brew 验证您的 Homebrew 安装。

  5. 您应该看到以下类似的输出!

    图 A.2:Homebrew 成功安装

  6. 要启用对额外软件的访问,请执行以下命令:

    $ brew tap caskroom/cask 
    

Homebrew Cask 扩展了 Homebrew,将它的优雅、简洁和速度带到了 GUI macOS 应用程序的安装和管理中,如 Visual Studio Code 和 Google Chrome。更多关于它的信息请查看 github.com/Homebrew/homebrew-cask。在 macOS 上,如果您在安装与 chown’ing /usr/local 相关的 brew 软件包时遇到权限问题,您需要执行 sudo chown -R $(whoami)

$(brew --prefix)/* 命令。此命令将用户级别的所有权重新分配给 brew 软件包,这比广泛的超级用户 /su 级别访问更安全。

更多信息,请查看 brew.sh/

安装 Windows 和 macOS 的自动化

记住,任何可以用 CLI 命令表达的内容都可以自动化。在整个设置过程中,我们确保每个正在使用的工具都已设置好,并且可以通过 CLI 命令验证其功能。这意味着我们可以轻松地创建一个 PowerShell 或 Bash 脚本来将这些命令串联起来,并简化设置和验证新环境的过程。

让我们实现一些基本但有效的脚本,以帮助设置您的开发环境。

PowerShell 脚本

对于基于 Windows 的开发环境,您需要创建一个 PowerShell 脚本:

  1. 创建一个名为 setup-windows-dev-env.ps1 的文件。

  2. 在文件中插入以下文本,该文本也可在 github.com/duluca/web-dev-environment-setup 找到:

    setup-windows-dev-env.ps1
    # This script is intentionally kept simple to demonstrate basic automation techniques.
    Write-Output "You must run this script in an elevated command shell, using 'Run as Administator'"
    $title = "Setup Web Development Environment"
    $message = "Select the appropriate option to continue (Absolutely NO WARRANTIES or GUARANTEES are provided):"
    $yes = New-Object System.Management.Automation.Host.ChoiceDescription "&Install Software using Chocolatey", `
      "Setup development environment."
    $no = New-Object System.Management.Automation.Host.ChoiceDescription "&Exit", `
      "Do not execute script."
    $options = [System.Management.Automation.Host.ChoiceDescription[]]($yes, $no)
    $result = $host.ui.PromptForChoice($title, $message, $options, 1)
    switch ($result) {
      0 {
        Write-Output "Installing chocolatey"
        Set-ExecutionPolicy Bypass -Scope Process -Force; Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))
        Write-Output "Refreshing environment variables. If rest of the script fails, restart elevated shell and rerun script."
        $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
        Write-Output "Assuming chocolatey is already installed"
        Write-Output "Installing Git & GitHub Desktop"
        choco.exe upgrade git github-desktop -y
        Write-Output "Installing NodeJS and NVS"
        choco.exe upgrade nodejs-lts nvs -y
        Write-Output "Installing Docker"
        choco.exe upgrade docker-cli docker-for-windows -y
        Write-Output "Installing AWS"
        choco.exe upgrade awscli -y
        Write-Output "Installing VS Code"
        choco.exe upgrade VisualStudioCode -y
        RefreshEnv.cmd
        Write-Output "Results:"
        Write-Output "Verify installation of GitHub Desktop and VS Code manually."
        $awsVersion = aws.exe --version
        Write-Output "aws: $awsVersion"
        $dockerVersion = docker.exe --version
        Write-Output "docker: $dockerVersion"
        $gitVersion = git.exe --version
        Write-Output "git: $gitVersion"
        $nodeVersion = node.exe -v
        Write-Output "Node: $nodeVersion"
        $npmVersion = npm.cmd -v
        Write-Output "npm: $npmVersion"
      }
      1 { "Aborted." }
    } 
    
  3. 要执行脚本,请运行:

    PS> Set-ExecutionPolicy Unrestricted; .\setup-windows-dev-env.ps1 
    

或者,您可以直接从 PowerShell Gallery 安装并执行脚本,PowerShell Gallery 的网址为 www.powershellgallery.com,通过执行以下命令:

PS> Install-Script -Name setup-windows-dev-env PS> setup-windows-dev-env.ps1 

通过执行此脚本,您已成功在 Windows 上设置开发环境。

如果您有兴趣将您自己的脚本发布到 PowerShell Gallery 或对提高 PowerShell 技能感兴趣,我建议您从 github.com/PowerShell/PowerShell 安装 PowerShell Core,这是一个跨平台的 PowerShell 版本。

现在,让我们调查您如何在 Mac 上实现类似的设置。

Bash 脚本

对于基于 Mac 的开发环境,您需要创建一个 Bash 脚本:

  1. 创建一个名为 setup-mac-dev-env.sh 的文件。

  2. 运行chmod a+x setup-mac-dev-env.sh以使文件可执行。

  3. 将以下文本(也可在github.com/duluca/web-dev-environment-setup找到)插入到setup-mac-dev-env.sh文件中:

     #!/bin/bash
    
          # In order to be able to execute this script, run 'chmod a+x setup-mac-dev-env.sh' to make the file executable
    
          echo "Execute Installation Script"
          read -r -p "Absolutely NO WARRANTIES or GUARANTEES are provided. Are you sure you want to continue? [y/N] " response
          if [[ "$response" =~ ^([yY][eE][sS]|[yY])+$ ]]
          then
              echo "Installing brew"
              /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
    
              echo "Installing git"
              brew install git
              brew upgrade git
    
              echo "Installing GitHub Desktop"
              brew install github
              brew upgrade github
    
              echo "Installing NodeJS"
              brew install node@20
              brew upgrade node@20
    
              echo "Installing Docker"
              brew install docker
              brew upgrade docker
    
              echo "Installing AWS"
              brew install awscli
              brew upgrade awscli
    
              echo "Installing VS Code"
              brew install visual-studio-code
              brew upgrade visual-studio-code
    
              echo "Results:"
              echo "Verify installation of AWS, Docker, GitHub Desktop and VS Code manually."
              gitVersion=$(git --version)
              echo "git: $gitVersion"
              nodeVersion=$(node -v)
              echo "Node: $nodeVersion"
              npmVersion=$(npm -v)
              echo "npm: $npmVersion"
    
              echo "Ensuring Node LTS is installed"
              npm i -g n@latest
              n lts
          else
              echo "Aborted."
          fi 
    
  4. 要执行脚本,请运行:

    $ ./setup-mac-dev-env.sh 
    

通过执行此脚本,你已经在 Mac 上成功设置了你的开发环境。以下是一个更复杂的安装和验证例程的示例,你可以在尝试安装之前检查是否已经安装了特定的程序,如brewnode

echo "Checking if brew is installed" which -s brew
if [[ $? != 0 ]] ; then echo "Installing brew"
/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/ Homebrew/install/master/install)" < /dev/null
else
echo "Found brew"
fi
echo "Checking for Node version ${NODE_VERSION}" node -v | grep ${NODE_VERSION}
if [[ $? != 0 ]] ; then
echo "Installing Node version ${NODE_VERSION}" brew install nodejs
else
echo "Found Node version ${NODE_VERSION}"
fi 

到现在为止,你应该对自动化执行你的脚本的样子有了很好的了解。然而,残酷的现实是,这些脚本并不代表一个高度能干或具有弹性的解决方案。它们不能远程执行或管理,也不能快速从错误中恢复或从机器启动周期中生存下来。此外,你的 IT 需求可能超出了这里涵盖的范围。

如果你正在处理大型团队并且经常有员工流动,自动化工具可以非常有用。相反,如果你是单独工作或者是一个更小、更稳定的团队的一部分,这样的工具可能就是过度配置了。我鼓励你探索像 Puppet、Chef、Ansible 和 Vagrant 这样的工具,以帮助你决定哪个最适合你的需求,或者确定一个简单的脚本是否足够。

使用 Angular CLI 进行项目设置

Angular CLI 工具ng是 Angular 官方项目,旨在确保新创建的 Angular 应用程序具有统一的架构,遵循社区经过时间检验的最佳实践。这意味着你将来遇到的任何 Angular 应用程序都应该具有相同的一般形状。

Nx 工具在企业级应用开发领域非常流行,用于构建、生成和维护 Angular 应用程序。你可以在nx.dev了解更多关于 Nx 的信息。

设置你的开发目录

设置一个专门的dev目录可以救命。因为这个目录下的所有数据都使用 GitHub 进行备份,所以你可以安全地配置你的防病毒软件、云同步或备份软件来忽略它。这可以显著减少 CPU、磁盘和网络的使用率。作为一个全栈开发者,你很可能会进行很多多任务处理,所以避免不必要的活动对性能、电力和数据消耗的净正面影响很大,尤其是如果你的开发环境是资源匮乏的笔记本电脑,或者你希望在移动时尽可能延长电池寿命。

在 Windows 中,直接在c:\驱动器下创建一个dev文件夹非常重要。早期的 Windows 版本,或者说 NTFS,无法处理超过 260 个字符的文件路径。一开始这看起来可能足够,但当你在一个已经非常深的目录结构中安装npm包时,node_modules文件夹结构可能会变得足够深,以至于很容易达到这个限制。

使用 npm 3+,引入了一种新的、更扁平的包安装策略,这有助于解决与 npm 相关的问题,但尽可能接近根文件夹对任何工具都非常有帮助。

使用以下命令创建你的 dev 文件夹。对于 Windows:

PS> mkdir c:\dev
PS> cd c:\dev 

对于 macOS:

$ mkdir ~/dev
$ cd ~/dev 

在基于 Unix 的操作系统上,~(发音为 tilde)是当前用户主目录的快捷方式,该目录位于 /Users/你的用户名 下。

现在开发目录已经准备好了,让我们开始生成你的 Angular 应用程序。

生成你的 Angular 应用程序

我们将使用 npm create 命令来生成你的 Angular 应用程序,该命令使用 Angular CLI 生成代码。Angular CLI 不仅仅局限于初始代码生成;你将频繁地使用它来创建新的组件、指令、管道、服务、模块等。Angular CLI 还在开发过程中提供实时重新加载功能,以便你可以快速看到你更改的结果。Angular CLI 还可以测试、检查和构建用于生产发布的代码优化版本。此外,随着新的 Angular 版本的发布,Angular CLI 帮助你通过自动重写部分代码以保持与潜在破坏性更改的兼容性来升级你的代码。

安装 Angular CLI

angular.dev/tools/cli 上的文档指导你如何将 @angular/cli 作为全局 npm 包进行安装。然而,我建议不要这样做。随着时间的推移,随着 Angular CLI 的升级,保持全局和在项目中的版本同步成为一个持续的烦恼。如果它们不一致,工具往往会无休止地抱怨。此外,如果你在多个项目上工作,随着时间的推移,你将拥有 Angular CLI 的不同版本。结果,你的命令可能不会返回你预期的结果,或者你的团队成员得到的结果。

下一个部分中详细介绍的策略为你的 Angular 项目的初始配置增加了一些复杂性。然而,如果你几个月或甚至一年后必须返回项目,你将完全弥补这种痛苦。在这种情况下,你可以使用你在项目上最后使用的工具版本,而不是可能需要升级而你不愿意执行的未来版本。在下一个部分中,你将使用这个最佳实践来初始化你的 Angular 应用程序。

初始化你的 Angular 应用程序

初始化你的应用程序的主要方式是使用 Angular CLI。让我们为开发初始化应用程序:

  1. 在你的 dev 文件夹下,执行以下命令:

    npm create @angular 
    

    Angular 使用独立项目配置创建所有新的项目。

  2. 按照屏幕上的说明,并将你的应用程序命名为 my-test-app;否则,通过按 Enter 键接受默认选项。

  3. 在你的终端上,你应该会看到一个成功消息。

你的项目文件夹 my-test-app 已初始化为 Git 仓库,并使用初始文件和文件夹结构进行了脚手架搭建。

@angular/cli的别名是ng。如果你要全局安装 Angular CLI,你会执行ng new my-test-app,但我们没有这样做。要使用 Angular CLI,你必须以npx @angular/cli的方式运行它。然而,我们只是在你应用的node_modules/.bin目录下安装了 Angular CLI,即my-test-app目录。这意味着你可以在apps目录中使用与你的项目关联的版本运行ng命令,例如npx ng generate component my-new-component,并继续高效地工作。

优化 VS Code 以适应 Angular

优化你的集成开发环境(IDE)以获得良好的开发体验至关重要。如果你利用本节中我提供的自动化工具,你可以快速配置你的 IDE 和 Angular 项目,使用数十个可以良好协同工作的设置。

自动配置你的项目

要快速配置你的 Angular 应用程序和 VS Code 工作区,请运行以下命令:

  1. 应用 Angular VS Code 配置:

    npx mrm angular-vscode 
    
  2. 应用 Docker 配置的npm脚本:

    npx mrm npm-docker 
    

    这些设置不断调整以适应扩展、插件、Angular 和 VS Code 不断变化的格局。始终确保通过重新运行install命令来安装任务的最新版本。

  3. 执行npm run style:fix

  4. 执行npm run lint:fix

有关mrm任务的更多信息,请参阅:

你可以将你的配置与 GitHub 上的示例项目进行验证。然而,请注意,配置组件将应用于存储库的根目录,而不是projects文件夹下。

恭喜你 – 你已经完成了开发环境的设置!

摘要

在本附录中,你掌握了使用基于命令行界面(CLI)的包管理器来加速和自动化为你和你的同事设置开发环境的方法。你还创建了你的第一个 Angular 项目,并使用 Visual Studio Code 对其配置进行了优化。然后,你实现了自动化的样式检查器和修复器,以强制执行团队中的编码标准和样式。你实现的检查器和修复器将自动捕获潜在的编码错误和维护性问题。

你创建的自动化脚本将你的团队规范编码化,并为新成员和现有成员记录下来。通过减少不同开发者环境之间的差异,你的团队能够更有效地克服任何个人配置问题,并专注于手头的任务。通过共同理解一个共同的环境,团队中的任何个人都不需要承担帮助解决其他人的问题的负担。同样的想法也适用于你的代码文件的结构和风格。

当团队成员查看另一位团队成员的代码时,其风格看起来完全相同,这使得调试和解决问题更加容易。因此,你的团队会更加高效。通过利用更复杂和健壮的工具,中大型组织可以在其 IT 预算中实现相当大的节省。

进一步阅读

维什瓦什·帕拉梅什瓦拉帕的《自动化本地开发者机器的设置》一文是使用 Vagrant 的绝佳起点,可以在www.vagrantup.com找到。你可以在Red-gate.com/simple-talk/sysadmin/general/automating-setup-local-developer-machine找到这篇文章。

其他工具包括位于www.chef.io的 Chef 和位于puppet.com的 Puppet。一些开发者更喜欢在www.docker.com的 Docker 容器内进行编码。这样做是为了隔离不同版本的 SDK。特定的开发工具不能局限于特定的文件夹,必须全局或操作系统范围内安装,这使得同时处理多个项目变得困难。如果你能避免这种设置,我建议你这样做。我预计随着 CPU 核心数量的增加和虚拟化技术获得更好的硬件加速,IDEs 将在未来自动化此类任务。

问题

尽可能好地回答以下问题,以确保你已经理解了本章的关键概念。你知道你是否已经正确回答了所有问题?请访问angularforenterprise.com/self-assessment获取更多信息:

  1. 与 GUI 相比,使用 CLI 工具的动机是什么?

  2. 对于你的特定操作系统,建议使用哪个包管理器?

  3. 使用包管理器的一些好处是什么?

  4. 保持你的开发团队成员的开发环境尽可能相似有什么好处?

加入我们的 Discord 社区

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

packt.link/AngularEnterpise3e

二维码

posted @ 2025-09-05 09:24  绝不原创的飞龙  阅读(42)  评论(0)    收藏  举报