Angular-企业就绪的-Web-应用-全-

Angular 企业就绪的 Web 应用(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎来到精彩的网络开发世界!本书旨在教你 Angular 平台的基础知识,并为你提供有用的食谱和实用的代码示例,以便你可以创建丰富且可扩展的业务线应用程序。本书强调一种极简主义方法,通过最大化使用内置库,避免引入额外的第三方依赖项来实现预期结果。由于这种方法,你的代码将更容易维护和升级,因为 Angular 的新版本发布频率很高。你可以继续使用本书作为学习资源,因为书中包含的基本概念、技术和示例在未来一段时间内仍将保持相关性,尽管会有一些细微的修改。书中推荐的工具和服务已更新至大约 2020 年的最新版本,然而,工具和服务不断演变、变化,有时甚至完全消失。如果发生这种情况,请随时联系我寻求替代方案。

本书还将致力于在你心中培养敏捷和 DevOps 思维,以便你自信地创建可靠和灵活的解决方案。无论你认为自己是一位为小企业开发软件的自由职业者、全栈开发者、企业开发者还是网络开发者,你需要了解的设计、架构、开发、维护、交付和部署网络应用程序的知识,以及你需要应用的最佳实践和模式,变化并不大。如果你向用户群体交付应用程序,从某种意义上说,你是一位全栈开发者,因为你必须了解许多服务器技术。实际上,如果你掌握了如何使用 TypeScript 交付 Angular 应用程序,那么在本书后面的具体实现中,你将能够轻松地使用 Node.js、Express.js 和 TypeScript 编写自己的 RESTful API。

根据某些定义,全栈开发者需要了解从餐饮到国际版权法的一切,以便在今天的互联网上成功创建和运营应用程序。如果你是一位企业家,从某种意义上说,这是真的。然而,在这本书中,你的烹饪技能和法律学位并不适用。本书假设你已经知道如何使用网络开发基础知识,并且熟悉使用你选择的技术的 RESTful API,如果不是这样,别担心,只需遵循动手的逐步指导,你将能够迅速创建你的第一个具有 API 功能的 Angular 应用程序。

本书面向的对象

本书适合初学者和经验丰富的开发者,他们希望学习 Angular 或一般的 Web 开发。如果您是 Angular 开发者,您将接触到设计和部署 Angular 应用程序到生产环境的整个范围。您将了解易于理解和教授他人的 Angular 模式。如果您是自由职业者,您将掌握有效的工具和技术,以安全、自信和可靠的方式交付您的 Angular 应用程序。如果您是企业开发者,您将学习编写具有可扩展架构的 Angular 应用程序的模式和实践。

本书涵盖了以下内容

第一章Angular 及其概念简介,向读者介绍了 Angular 和 Web 开发的世界。

第二章设置您的开发环境,介绍了一种可脚本化的方法来设置您的环境。

第三章创建基本 Angular 应用程序,介绍了软件开发的看板方法,使用易于使用的设计工具来传达想法,并涵盖 Angular 基础知识,利用 CLI 工具最大化您的效果。

第四章自动化测试、持续集成和发布到生产环境,涵盖了单元测试、持续集成和快速云部署。

第五章使用 Material 提供高质量 UX,向您介绍 Angular Material,并解释如何使用它来构建外观出色的应用程序。

第六章表单、Observables 和 Subjects,教您如何舒适地使用 Angular 表单和 RxJS 进行响应式编程。

第七章创建以路由器为首要的业务应用,专注于路由器优先架构,这是一种针对中大型业务应用设计和开发的七步方法。

第八章设计身份验证和授权,深入探讨了 Angular 和 RESTful 应用中的身份验证和授权相关模式。

第九章使用 Docker 进行 DevOps,深入探讨了使用 Docker 进行容器化,以实现跨不同生态系统的可重复的开发和运营工作流程。

第十章RESTful API 与全栈实现,将引导您了解如何实现一个支持业务应用的现实世界 MEAN 栈应用程序。

第十一章食谱 - 可重用性、路由和缓存,包含围绕捕获和操作用户数据的食谱,这些数据对于业务应用通常需要。

第十二章食谱 - 主/详情、数据表和 NgRx,包含围绕展示业务应用中常见用户数据的食谱,以及使用 NgRx 在 Angular 中实现 Flux 模式的介绍。

第十三章在 AWS 上构建高可用云基础设施,不仅涉及应用程序功能,还涉及在 AWS 上配置高可用云基础设施。

第十四章Google Analytics 和高级云操作,探讨了拥有、运营和优化您的云基础设施,以及使用 Google Analytics 来捕捉用户行为的细微差别。

附录 A调试 Angular,涵盖了如何使用 Chrome DevTools 处理常见的 Angular 错误和断点调试。

附录 BAngular 速查表,是 Angular CLI 命令、主要 Angular 组件和常见 RxJS 操作符的快速参考。

附录 C保持 Angular 和工具始终如一,包含了有关如何保持您的开发环境、Angular 及其依赖项更新的详细信息。您可以通过static.packt-cdn.com/downloads/9781838648800_Appendix_C_Keeping_Angular_and_Tools_Evergreen.pdf阅读此附录。您还可以通过expertlysimple.io/stay-evergreen阅读此附录。

附录 D自我评估答案,包含了每章末尾的测试题答案。您可以通过static.packt-cdn.com/downloads/9781838648800_Appendix_D_Self-Assessment_Answers.pdf阅读此附录。您还可以通过expertlysimple.io/angular-self-assessment/阅读此附录。

为了充分利用这本书

  • 遵循每章和每节开头的说明。

  • 在 GitHub 上检查最新的代码示例。

  • 熟悉全栈 Web 开发会有所帮助,但这不是先决条件。

  • 如果您是初学者,请按照出版顺序阅读本书,并在每个章节的内容旁边编写您的解决方案。

  • 您可以从任何章节开始阅读,只要您从 GitHub 克隆了前一章节的实现,并理解了第二章设置您的开发环境中涵盖的假设。

下载示例代码文件

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

  1. Web 开发环境设置脚本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 拉取请求来演示特定的配置元素。

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

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

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

  2. 选择支持标签。

  3. 点击代码下载与勘误

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

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

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

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

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表的彩色 PDF 文件。您可以从以下链接下载:www.packtpub.com/sites/default/files/downloads/9781838648800_ColorImages.pdf

使用的约定

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

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。例如;“将下载的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')) 

粗体:表示新术语、重要单词或您在屏幕上看到的单词,例如在菜单或对话框中,这些单词在文本中也会以这种方式出现。例如:“浏览器供应商应按照万维网联盟W3C)定义的技术来实现这些技术。”

警告或重要提示如下所示。

技巧和窍门如下所示。

联系我们

我们欢迎读者的反馈。

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

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

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

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

评论

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

如需了解 Packt 的更多信息,请访问packtpub.com

第一章:Angular 及其概念的介绍

最初,有 HTML,然后是 DHTML。技术人员发明了新的技术,如 Java、JavaScript、PHP 等,以在浏览器上提供交互式体验。编程的圣杯是编写一次程序,在任何地方运行。一瞬间,单页应用SPAs)的时代诞生了。SPAs 欺骗浏览器认为单个index.html可以容纳包含许多页面的整个应用程序。Backbone.js、Knockout.js 和 Angular.js 都来了又去。所有人都在未管理的复杂性和每周 JavaScript 框架综合症中挣扎,寻找救世主。然后出现了 React、Angular 和 Vue。它们承诺解决所有问题,带来通用可重用 Web 组件,并使学习、开发和扩展 Web 应用变得更加容易。而且,它们确实做到了!有的比其他的好。网络发展的青少年历史教会了我们几个基本教训。首先,变化是不可避免的,其次,开发者的幸福是一种宝贵的商品,它可以成就或摧毁整个公司。

本章涵盖:

  • Web 框架的历史

  • Angular 及其背后的哲学

  • 反应式开发范式

  • 高级 Angular 特性,包括状态管理

  • 主要 Angular 版本和特性

这第一章旨在为你提供本书其余部分的理论和历史背景。在阅读本书的其余部分时,请随时将其用作参考。第二章设置您的开发环境,介绍了如何配置您的开发环境以获得出色的开发体验。在第三章创建一个基本的 Angular 应用中,你开始实现您的第一个 Angular 应用程序。如果您已经熟悉 Angular,您可以从第七章创建一个以路由为第一线的业务应用开始,深入创建适合企业的可扩展应用程序。

本书中的每一章都向您介绍新的概念,同时强化最佳实践,并涵盖使用广泛使用的开源工具的最佳工作方式。在这个过程中,提示和信息框涵盖了关于 Web 和现代 JavaScript 开发基础知识可能存在的任何知识差距。在您阅读内容时,请注意编号步骤或项目符号,因为它们描述了您需要采取的行动。如果您跳过某个部分或章节,您可能会错过配置或技术上的微妙变化,这可能会在以后让您感到困惑。

本书提供的代码示例是使用 Angular 9 开发的,该版本计划在 2021 年 8 月之前提供长期支持LTS)。您可能是在 Angular 9 之后的新版本发布后阅读这本书的。但是,请放心。本书采用了 Angular 的 evergreen 口号,即始终将 Angular 的版本与最新版本保持同步。通过坚持平台基础并避免不必要的第三方库,可以保持更新。本书的示例项目最初是为 Angular 5 编写的,随着时间的推移,通过遵循积极的增量 Angular 升级计划,没有进行重大重写而进行了更新。我预计这些项目在未来几年中只需进行少量修改即可生存。这种可靠性是对 Angular 团队所做优秀兼容性工作的证明。

JavaScript、TypeScript 和 Angular 的世界一直在不断变化。书中代码示例与您使用工具生成的代码之间存在一些差异是正常的。因此,本书推荐的大多数最佳实践和配置项都是使用我创建的工具应用的,以便它们可以更新。以下是支持本书内容的库、扩展和开源项目集合的高级概述:

图 1.1:支持本书开发的代码

上述图表是为了让您快速了解一些动态部分。每个组件将在接下来的章节中详细介绍。本书示例代码的最新版本可在 GitHub 上找到,位于以下链接的仓库中。这些仓库包含代码的最终和完成状态。为了使您在章节结束时更容易验证进度,每个仓库中的projects文件夹包含反映代码当前状态的逐章快照:

  • 对于第二章第六章,以及第十二章,LocalCast Weather: github.com/duluca/local-weather-app

  • 对于第七章第十四章,Lemon Mart: github.com/duluca/lemon-mart

  • 对于第十章,Lemon Mart Server: github.com/duluca/lemon-mart-server

    您可以在附录 C、“保持 Angular 和工具 evergreen”中了解更多关于更新 Angular 的信息。您可以从static.packt-cdn.com/downloads/9781838648800_Appendix_C_Keeping_Angular_and_Tools_Evergreen.pdfexpertlysimple.io/stay-evergreen在线找到此附录。

让我们回顾一下过去 20 年左右的 Web 开发历史,这样您就可以了解 Angular 是如何产生并演变的。

Web 框架简史

考虑到我们最初为什么要使用像 Angular、React 或 Vue 这样的框架,这是非常重要的。随着 JavaScript 在浏览器中变得更加流行和强大,网络框架开始兴起。在 2004 年,异步 JavaScript 和 XML(AJAX)技术在创建不需要依赖完整页面刷新来创建动态体验的网站中变得非常流行,这些体验利用了标准化的网络技术,如 HTML、JavaScript/ECMAScript 和 CSS。浏览器供应商应该按照万维网联盟(W3C)的定义来实现这些技术。

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

这种碎片化为开发者创造了一个不可持续的环境,让他们在网络上提供一致的用户体验。各种标准的实现质量、版本和名称的差异创造了一个巨大的挑战,即成功编写能够一致操作浏览器文档对象模型(DOM)的代码。即使是浏览器 API 和功能的最微小差异也足以破坏一个网站。

在 2006 年,jQuery 被开发出来以平滑浏览器 API 和功能之间的差异。因此,你不必反复编写代码来检查浏览器版本,你可以使用 jQuery,然后就可以顺利开始了。它隐藏了所有特定供应商实现的复杂性,并在缺少功能时优雅地填补了空白。在接下来的 5 到 6 年里,jQuery 成为了网络开发框架。没有使用 jQuery 编写交互式网站是不可想象的。

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

传统上,软件架构被描述为三个主要层,如下面的图所示:

图片

图 1.2:三层软件架构

表示层包含与用户界面(UI)相关的代码,业务层包含业务逻辑,持久层包含与数据存储相关的代码。我们的整体设计目标是追求架构组件之间的低耦合和高内聚。低耦合意味着这些层之间的代码片段不应相互依赖,并且应该是可以独立替换的。高内聚意味着相互关联的代码片段,如特定业务逻辑领域的代码,应该保持在一起。例如,当构建一个用于管理餐厅的应用程序时,预订系统的代码应该在一起,而不是分散在其他系统如库存跟踪或用户管理中。现代网络开发比基本的三层应用有更多的组成部分。下面的图显示了围绕表示层、业务层和持久层的附加层:

图 1.3:现代网络架构

在前面的图中,你可以看到一个扩展的架构图,它包括了现代网络开发的基本组件,这些组件包括一个通常在表示层和业务层之间转换数据的 API 层,一个定义了用于开发软件的各种方法的工具和最佳实践层,以及一个在当今迭代和快速发展的开发周期中至关重要的自动化测试层。

在 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 架构

观察前一个图中的框的数量几乎翻倍。仅仅因为我们把客户端和服务器分离开来,并不意味着我们最终简化了架构。实际上,围绕展示逻辑的架构变得更加复杂。客户端和服务器都必须实现它们的展示/API、业务和持久化层。

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

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

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

Backbone 和 AngularJS 证明了在浏览器中创建原生运行的 Web 应用程序是可行的。当时所有的 SPA 框架都依赖于 jQuery 进行 DOM 操作。同时,Web 标准也在不断演进,支持新标准的永续浏览器开始变得普遍。然而,变化是永恒的,Web 技术的演进使得第一代 SPA 框架的优雅演进变得不可持续。

新一代的 Web 框架需要解决许多问题;它们需要强制实施良好的架构;设计为与 Web 标准一起演进;并且稳定且可扩展以满足企业需求,而不会崩溃。此外,这些新框架需要获得开发者的认可,因为开发者对生态系统中的太多快速变化感到疲惫。记住,不快乐的开发者不会创造成功的业务。实现这些目标需要与过去彻底决裂,因此 Angular 和 React 作为平台以不同的方式解决了过去的问题。

Angular 简介

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

升级到新的 AngularJS 是有风险且昂贵的,因为即使是微小的更新也引入了新的编码模式和实验性功能。每次更新都引入了弃用或旧功能的重构,这需要重写大量代码。此外,更新以不确定的间隔交付,使得团队无法规划资源以升级到新版本。发布方法最终导致了一个不可预测、不断演变的框架,似乎没有任何指导之手来推动代码库的发展。如果您使用了 AngularJS,您可能被困在特定版本上,因为您的代码库的特定架构使得迁移到新版本非常困难。2018 年,Angular 团队发布了 AngularJS 的最后一个主要更新,版本号为 1.7。这次发布标志着旧框架的终结的开始,预计在 2021 年 7 月停止使用。

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

图片

图 1.6:Angular 的预期发布和支持计划

那么,这对你们意味着什么呢?你可以确信,即使你不对它做出任何更改,你的 Angular 代码在约 24 个月的时间内也将得到支持和向后兼容。所以,如果你在 2020 年 2 月编写了一个版本 9 的 Angular 应用,你的代码与 Angular 10 具有运行时兼容性,并将得到支持直到 2021 年 10 月。要将你的 Angular 9 代码升级到 Angular 11,你需要确保你没有使用 Angular 10 中收到弃用通知的任何弃用 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 将会持续存在,因此你应该投入你的时间、注意力和金钱来学习它——即使你现在可能对其他框架情有独钟。

对于决策者(CIO、CTO 等等):计划在接下来的 6 个月内开始过渡到 Angular。这将是一项你可以向商业人士解释的投资,你的投资将在未来多年带来回报,即使是在初始 LTS 窗口到期后,也有优雅的升级路径到 Angular vNext 以及更远。

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

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

  • 开源框架能够以规模的方式验证和调试新想法和工具,拥有数百万的开发者。

  • 允许开发者更快地创建出色的 Web 体验,最终为 Google 和 Microsoft 带来更多商业机会。

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

小心,在网上寻找 Angular 帮助可能会很棘手。你会注意到,有时 Angular 被称为 Angular 2 或 Angular 4。有时,Angular 和 AngularJS 都被称为 AngularJS。这是不正确的。Angular 的文档位于 angular.io 上。如果你访问了 angularjs.org,你将阅读关于遗留的 AngularJS 框架的内容。

要了解即将发布的 Angular 版本的最新更新,请查看官方发布计划 angular.io/guide/releases

Angular 的理念

你的时间是宝贵的,你的幸福至关重要,因此你必须谨慎选择投入时间的科技。考虑到这一点,我们需要回答为什么学习 Angular,而不是 React、Vue 或其他框架?Angular 是一个很好的框架开始学习。该框架和工具可以帮助你快速起步,并凭借充满活力的社区和高质量的 UI 库继续成功,这些库可以帮助你交付卓越的 Web 应用程序。React 和 Vue 也是很好的框架,各有其优势和劣势。每个工具都有其位置和目的。

在某些情况下,React 是项目的正确选择,而在其他情况下,Vue 才是正确的选择。无论如何,对其他 Web 框架的熟练掌握只能有助于进一步加深你对 Angular 的理解,并使你成为一个更好的开发者。像 Backbone 和 AngularJS 这样的单页应用(SPAs)在 2012 年引起了我的极大关注,当时我意识到解耦前端和后端关注点的重要性。服务器端渲染的模板几乎无法维护,并且是许多昂贵的软件系统重写的根本原因。如果你关心创建可维护的软件,那么你必须遵守首要指令;将业务逻辑实现保持在 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 持续更新

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

为了将这个想法突出给开发者,我和几位同事一起开发和发布了名为 Angular Evergreen 的 Visual Studio Code 扩展。

图片

图 1.7:Angular Evergreen VS Code 扩展

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

在 Angular Evergreen 扩展中查找更多信息、功能请求和错误报告,请访问AngularEvergreen.com

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

TypeScript

Angular 使用 TypeScript 进行编码。TypeScript 是由微软的 Anders Hejlsberg 创建的,旨在解决在企业规模上应用 JavaScript 时遇到的一些主要问题。

Anders Hejlsberg 是 Turbo Pascal 和 C#的创造者,同时也是 Delphi 的首席架构师。Anders 设计了 C#,使其成为一个开发者友好的语言,它建立在 C 和 C++熟悉的语法之上。因此,C#成为了微软流行的.NET Framework 背后的语言。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。从 ES5 的引入开始,然后是 ES2015(也称为 ES6),浏览器供应商一直在努力在其浏览器中实现新的 JavaScript 特性。因此,这些新特性的用户采用率一直很低。然而,这些新特性意味着开发者可以更高效地编写代码。这创造了一个被称为 JavaScript 特性差距的差距,如下面的图形所示:

图片

图 1.8:JavaScript 特性差距

JavaScript 特性差距是一个滑动差距,因为 TC39 已经承诺每年更新 JavaScript。因此,TypeScript 代表了 JavaScript 的过去、现在和未来。你可以使用今天的 JavaScript 的未来特性,同时仍然能够针对旧版浏览器,以最大化你能够触及的受众。

现在,让我们来探讨 Angular 的底层架构。

基本 Angular 架构

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

图片

图 1.9:MV*架构

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

Angular 应用的最基本单元是一个组件。组件是由 TypeScript 编写的 JavaScript 类和由 HTML、CSS 和 TypeScript 编写的 Angular 模板的组合。通过绑定,类和模板就像拼图一样相互匹配,从而可以相互通信,如下面的图所示:

图 1.10:组件的解剖结构

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

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

所有 Angular 组件、服务、指令、管道和用户控件都组织在模块下。每个 Angular 应用都通过一个根模块启动,该模块渲染你的第一个组件,并注入任何所需的服务,准备依赖项。你可以引入子模块以启用诸如懒加载等能力,这样你就不必一次性将你的 Web 应用的所有组件交付给浏览器。例如,没有管理员权限的用户发送管理员仪表板的代码是没有用的。

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

响应式开发范式

Angular 支持多种编程风格。多种编程风格是它对具有不同背景的程序员友好的一大原因。无论你是来自面向对象编程的背景,还是你是一个坚定的函数式编程信徒,你都可以使用 Angular 构建可行的应用。在第三章创建基本的 Angular 应用中,你将开始利用响应式编程概念来构建 LocalCast 天气应用。

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

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

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

在本书的后期,当实现 LocalCast 天气应用时,你将在CurrentWeatherCitySearch组件中利用subscribe的实际操作。

考虑以下例子,由迈克·皮尔逊在他的演示中恰如其分地提出

思考反应式编程:最难的部分,提供从水龙头获取热水的指令,以帮助理解命令式编程和反应式编程之间的区别:

从水龙头获取热水的指令
0
1
2
3
4
5
6

如您所见,在命令式编程中,您必须定义代码执行的每一步。每一步都依赖于前一步,这意味着您必须考虑环境的状态以确保操作成功。在这样的环境中,很容易忘记一个步骤,而且很难测试每个单独步骤的正确性。在函数式反应式编程中,您与异步数据流一起工作,从而产生一个无状态的流程,易于与其他操作组合。

RxJS 是使您能够在响应式范式下实现代码的库。

RxJS

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

响应式数据流

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

图片

图 1.11:事件驱动实现

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

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

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

  • 一个updateCache函数使用新数据更新您的本地缓存

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

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

图片

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

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

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

在流进一步下游的地方,我们可能只对监听包含两个或更多点击的事件感兴趣,因此我们可以使用 filter 函数只对本质上是一个双击事件进行操作。每次我们的过滤事件触发时,都意味着用户意图进行双击,你可以通过弹出警告来对此信息采取行动。

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

图片

图 1.13:响应式数据流实现

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

高级 Angular 架构

如前所述,在 基本 Angular 架构 部分,Angular 组件、服务和依赖项被组织到模块中。Angular 应用程序通过其根模块启动,如下面的图所示:

图片

图 1.14:显示主要架构元素的 Angular 启动过程

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

第七章创建以路由器为第一线的业务应用程序,介绍了以路由器为第一线的架构,我鼓励你通过提前创建所有路由来开始你的应用程序开发。

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

除了模块之外,路由器是你在 Angular 中必须掌握的下一个最强大的技术。

Angular 路由器

@angular/router 包中提供的 Angular Router 是构建单页应用SPAs)的核心和关键部分,这些应用的行为和表现就像易于使用浏览器控件或缩放或微缩控件导航的常规网站。

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

大型团队可以在单个代码库上工作,每个团队负责一个模块的开发,而不会相互干扰,同时实现易于的持续集成。谷歌拥有数亿行代码,之所以在单个代码库上工作,是因为事后集成成本非常高。

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

考虑以下图表,其中 app.ts 包含模块。它有一个 rootRouter;组件 amasterdetailcservicespipes;以及为其提供和声明的 directives。当用户首次导航到您的应用时,所有这些组件都将被浏览器解析和急切加载。

图片

图 1.15:Angular 架构

如果您要实现一个懒加载的路由 /b,您需要创建一个名为 b 的功能模块,它将有自己的 childRouter;组件 defservicespipes;以及为其提供和声明的 directives。在编译时,Angular 将将这些组件打包到单独的文件或包中,并且这个包只有在用户导航到 /b 下的路径时才会被下载、解析和加载。

让我们更详细地了解一下懒加载。

懒加载

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

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

第七章创建以路由为第一线的业务应用程序中,我介绍了实现路由基础,而高级食谱则在第十一章食谱 – 可重用性、路由和缓存中介绍。

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

状态管理

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

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

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

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

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

Flux 模式

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

考虑以下图表来理解这些组件之间的信息流:

图 1.16

图 1.16: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.17:NgRx 架构概述

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

考虑到我对于最小化工具的积极态度,以及对 NgRx 在之前提到的利基受众之外没有明确必要性的看法,我不建议将 NgRx 作为默认选择。RxJS/BehaviorSubjects 功能强大且足够,可以解锁复杂和可扩展的模式,帮助你构建出色的 Angular 应用程序,正如在通向第十二章食谱 – 主/详细、数据表和 NgRx的章节中所展示的那样。

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

React.js 架构

与 Angular 不同,React.js 作为一个整体实现了 Flux 模式。以下是一个以路由为中心的 React 应用程序视图,其中组件/容器和提供者以严格的树状结构表示。

图 1.18:React.js 架构概述

在 React 的早期版本中,为了使最基本的功能正常工作,必须费力地将值在组件的继承树中上下传递。后来,引入了 react-redux,因此每个组件都可以直接读取/写入值到存储中,而无需遍历整个树。

这个基本的概述应该能让你对 Angular 和 React 之间的重要架构差异有一个大致的了解。然而,请记住,就像 Angular 一样,React、其社区、模式和最佳实践都在不断发展和完善。

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

重要的 Angular 特性

特定的 Angular 版本引入了值得注意的变更,以推进平台的哲学思想,使其更加无缝和全面。我建议查看这些开创性发布所引入的独特变更。

Angular 6

本书中的大部分内容、模式和最佳实践都与 Angular 4 及以上版本兼容。然而,Angular 6 是 Angular 的一个开创性发布,为平台带来了许多底层改进,以及整个生态系统在稳定性和凝聚力方面的提升。开发体验得到了极大的改善,额外的 CLI 工具使得更新包版本和加快构建时间变得更容易,从而改善了代码-构建-视图反馈周期。在 Angular 6 中,所有平台工具的版本都同步到 6.0,这使得理解生态系统变得更加容易。在下表中,你可以看到这如何使工具兼容性沟通变得更加容易:

之前 v6 版本
CLI 1.7 6.0
Angular 5.2.10 6.0
Material 5.2.4 6.0

Angular CLI 6.0 带来了许多重大新功能,例如 ng updateng add 命令;ng update 使得更新你的 Angular 版本、npm 依赖、RxJS 和 Angular Material 变得更加容易,包括一些确定性的代码重写能力,以将名称更改应用于 API 或函数。关于更新 Angular 版本的内容在 附录 C保持 Angular 和工具常青 中进行了深入探讨。您可以从 static.packt-cdn.com/downloads/9781838648800_Appendix_C_Keeping_Angular_and_Tools_Evergreen.pdfexpertlysimple.io/stay-evergreen 在线找到这个附录。ng add 将方案支持引入到 Angular CLI 中。有了方案,你可以编写自定义代码来向 Angular 应用添加新功能,包括添加任何依赖项、样板配置代码或脚手架。一个很好的例子是通过执行 ng add @angular/material 来将 Angular Material 添加到你的项目中。关于将 Angular Material 添加到你的项目的内容在 第五章使用 Material 提供高质量 UX 中进行了深入探讨。一个独立的 Material 更新工具旨在使 Angular Material 更新不那么痛苦,可以在 Github.com/angular/material-update-tool 找到,但请期待这个功能将合并到 ng update 中。进一步的方案可以为自己的 CLI 带来自己的 generate 命令,使你的生活更轻松,随着时间的推移,你的代码库更加一致。此外,webpack 的第 4 版被配置为将你的 Angular 应用程序构建成具有范围托管的小模块,缩短了应用程序的首次绘制时间。

Angular 6 的主要主题是底层的性能改进和自定义元素支持。第 6 版在基础包大小方面比 v5 版本提高了 12%,达到 65 KB,从快速的 3G 连接到光纤连接,将加载时间提高了惊人的 21-40%。随着你的应用程序的增长,Angular 利用更好的摇树技术进一步修剪最终交付物中的未使用代码。速度是 Angular 6 的一个 UX 特性。这是通过更好的 Angular 组件开发工具包CDK)、Angular Material、动画和 i18n 的支持来实现的。Angular Universal 允许服务器端辅助快速启动时间,Angular PWA 支持利用本地平台功能,如缓存和离线存储,因此,在后续访问中,你的应用程序保持快速。RxJS 6 的支持允许可摇动的管道命令,更频繁地减少包大小,并修复了我在 第六章表单、可观察对象和主题 中警告你的 throttle 的行为,以及众多错误修复和性能改进。TypeScript 2.7 带来了更好的 JavaScript 包类型导入支持,以及在构建时捕获编码错误的更高级功能。

Angular Material 6 添加了新的用户控件,如树形控件和徽章,同时通过一系列错误修复、功能完整性和现有组件的主题化,使库变得更加稳定。Angular Flex Layout 6 引入了 polyfills,使 Internet Explorer 11 能够支持 CSS Flexbox。这使得使用 Material 和 Flex Layout 的 Angular 应用程序完全兼容最后一个主要遗留浏览器技术,尽管它在 2018 年 1 月与 Windows 8.1 一起停止了主流支持,并被 Microsoft Edge 取代了 18 次。Angular 6 本身可以通过 polyfills 配置为向下兼容到 IE9。这对于必须支持此类遗留浏览器并且仍然能够使用现代技术构建解决方案的开发者来说是个好消息。

一些令人兴奋的新辅助工具也被发布,以支持高频、高性能或大型企业用例。Angular 生态系统欢迎 NgRx 库的加入,它基于 RxJS 将 Redux-like 的响应式状态管理引入 Angular。由前 Angular 团队成员开发的 Nx CLI 工具为 Angular 带来了有观点的开发环境设置,适合需要确保环境一致性的顾问和大型组织。本书遵循类似的模式,旨在教育您建立一致的建筑和设计模式,以便应用于您的应用程序。Google 的 Bazel 构建工具支持增量构建,因此您的应用程序中未更改的部分不需要重新构建,大大提高了大型项目的构建时间,并允许 Angular 应用程序之间共享库的打包。

Angular 8

如本书前言中所述,本书旨在与任何新的 Angular 版本有效。这是 Angular 团队所倡导的理念,他们希望淡化您当前使用的 Angular 特定版本,而不是专注于并投资于持续保持 Angular 每个小版本和主要版本的更新。Angular 团队投入了大量的精力和努力,以确保您编写的尽可能多的代码在 Angular 的性能和功能集随时间改进后仍然兼容。任何破坏性变更都由自动化工具支持,帮助您重写代码的部分,或者计划弃用,给您充足的时间逐步淘汰不受支持的代码。

Angular 7 为 TypeScript、RxJS 和 Node 带来了性能、可访问性和依赖性更新,以及 Angular Material 控件的重大更新和扩展;Angular 8 延续了这一趋势。Angular 8 引入了差异加载和对于 evergreen 浏览器的最小 polyfills 支持,节省了发送到客户端的负载的 7-20%。

Angular 9

Angular 9 及其随后的 9.1 更新通过提供 Ivy 渲染引擎和 TypeScript 3.8 支持,为框架带来了迄今为止最重要的更新。这次更新解决了大量技术债务,带来了 100 个错误修复和功能,并大大扩展了框架的自动化测试覆盖率。Ivy 渲染引擎使得应用程序的包大小更小,加载速度更快。此外,Angular 9.1 带来了 40% 的更快构建时间,40-50% 的改进单元测试运行时间,以及更好的调试能力,具有更简单的堆栈跟踪和模板绑定。TypeScript 3.8 带来了新的语法优势,如可选链和空值合并运算符,这使得在 Angular 的严格模式下处理 null 或 undefined 值变得更加容易。

Ivy 渲染引擎的全部优势将在未来的更新中体现出来。Ivy 将允许创建小巧且精简的 Angular 应用程序。在 Ivy 之前,描述 Angular 组件所需的元数据存储在模块中。使用 Ivy,组件实现了局部性原则,因此可以自我描述。这允许 Ivy 惰性加载单个组件和创建独立组件。想象一下,一个 Angular 库可以通过单次函数调用渲染组件,并且其大小只有几千字节。这种小型化使得使用自定义元素(Custom Elements)实现 Angular Elements 成为可能,这是 Web Components 规范的一部分。

在版本 6 中引入的 Angular Elements 允许你编写一个 Angular 组件,并在任何其他使用任何 Web 技术的 Web 应用程序中重用该组件,本质上是在声明你自己的自定义 HTML 元素。这些自定义元素与任何基于 HTML 的工具链兼容,包括其他 Web 应用程序库或框架。为了使这一切工作,整个 Angular 框架需要与你的新自定义元素一起打包。在 Angular 6 中,这并不可行,因为这意味着每次创建新的用户控件时,至少需要附加 65 KB。

到 2020 年初,Chrome、Edge 和 Firefox 原生支持自定义元素,这与 2018 年初的现状相比是一个重大变化。Angular 9 默认启用 Ivy 渲染引擎,并且 Angular 的未来更新应将基本包的大小降至 2.7 KB,因此基于 Angular 的自定义元素的大规模使用很快将成为现实。在 2020 年,所有主要浏览器都原生支持自定义元素,只剩下 Safari 是最后一个实现该标准的浏览器。

在对新的 Web 技术过于兴奋之前,请始终检查 caniuse.com,以确保你确实能够在必须支持的浏览器中使用该功能。

Angular.io 利用自定义元素来展示该技术的可行性。该文档网站每月吸引超过 100 万独立访客,因此随着其成熟,它应该有助于解决一些问题。自定义元素非常适合在静态内容旁边托管交互式代码示例。例如,在 2018 年初,Angular.io 开始使用 StackBlitz.io 来托管交互式代码示例。

StackBlitz.io 是一个惊人的工具,一个直接在浏览器中的丰富 IDE,因此你可以实验不同的想法或运行 GitHub 仓库,而无需在本地拉取或执行任何代码。

其他重要更新包括对 JavaScript 包的按需加载,以改善加载时间和现代浏览器的首次交互时间(TTI)。Angular Router 添加了向后兼容性,使得对遗留的 AngularJS 项目进行分阶段升级成为可能。

谷歌要求他们拥有的 2000 多个 Angular 项目都必须使用 Angular 的同一版本。这意味着 Angular 的每个新更新都经过了良好的测试,并且没有向后兼容性的惊喜。

在版本 9 中打下了所有基础后,我们可以期待 Angular 10 将带来更加敏捷和强大的框架。我希望你对 Angular 和它解锁的未来可能性和我一样兴奋。系好你的安全带,多萝西,因为堪萨斯州即将离你而去。

摘要

总结来说,网络技术已经发展到可以创建丰富、快速和本地的网络应用程序,这些应用程序可以在今天部署的大多数桌面和移动浏览器上良好运行。Angular 已经发展到成为一个成熟和稳定的平台,应用了从过去学到的经验教训。它使开发者能够采用复杂的方法论,从而创建可维护、交互式和快速的应用程序。像 TypeScript、RxJS 和 NgRx 这样的技术使得面向对象编程、响应式编程和 Flux 模式中的模式成为可能。

Angular 从头到尾都是设计成响应式的,因此你必须调整你的编程风格以适应这种模式。此外,Angular 意在以持续更新的方式被消费,因此始终保持 Angular 的最新状态是个好主意。

在 Angular 应用中利用承诺(promises),而不是观察者(observables)和异步管道(async pipe),等同于忽视了 Angular 团队和社区中的思想领袖所传达的所有建议和文档。很容易因为从自我帮助网站或以实验心态撰写的博客文章中汲取的浅显或极端脱离上下文的建议而陷入不良的实践和习惯。官方文档应该是你的圣经,可以在 angular.io/docs 找到。

在下一章中,你将配置你的开发环境,以优化 macOS 和 Windows 操作系统上的出色且一致的 Angular 开发体验。在接下来的章节中,你将学习如何创建一个基本的 Angular 应用,将其部署到互联网上,然后了解如何创建可扩展的应用程序的高级架构模式,学习如何使用 Minimal MEAN 创建全栈 TypeScript 应用程序,并利用高级 DevOps 和持续集成技术。本书通过介绍 Amazon Web Services 和 Google Analytics 来结束。

进一步阅读

  • 设计模式:可重用面向对象软件的元素,Erich Gamma、Richard Helm、Ralph Johnson、John Vlissides,1994,Addison Wesley,ISBN 0-201-63361-2。

  • 人类 JavaScript,Henrik Joreteg,2013,read.humanjavascript.com

  • TypeScript x MS Build 2017 的新功能,Anders Hejlsberg,2017,www.youtube.com/watch?v=0sMZJ02rs2c

  • 实用程序员,20 周年纪念版,David Thomas 和 Andrew Hunt,2019,Addison Wesley,ISBN 978-0135957059。

  • 反应式思维:最困难的部分,Mike Pearson,2019,www.youtube.com/watch?v=-4cwkHNguXE

  • 使用 RxJS 进行数据组合,Deborah Kurata,2019,www.youtube.com/watch?v=Z76QlSpYcck

  • Flux 模式深入概述,Facebook,2019,facebook.github.io/flux/docs/in-depth-overview

问题

尽可能好地回答以下问题,以确保你已理解本章的关键概念,而无需使用 Google。你需要帮助回答这些问题吗?请参阅 附录 D自我评估答案,在线位于 static.packt-cdn.com/downloads/9781838648800_Appendix_D_Self-Assessment_Answers.pdf 或访问 expertlysimple.io/angular-self-assessment

  1. Angular Evergreen 的概念是什么?

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

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

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

第二章:设置您的开发环境

本章展示了您和您的团队成员如何创建一个一致的开发生态环境,以便整个团队都能拥有相同的优秀 Web 开发体验——这一点在本书的序言中得到了强调。对于初学者来说,创建正确的发展环境可能很困难,这对于无烦恼的开发体验至关重要。对于经验丰富的开发者和团队来说,实现一致且最小化的开发环境仍然是一个挑战。一旦实现,这样的开发环境有助于避免许多 IT 相关问题,包括持续维护、许可和升级成本。

安装 GitHub Desktop、Node.js、Angular CLI 和 Docker 的说明对于从初学者到经验丰富的团队都是一份有用的参考,其中包括如何自动化以及确保开发环境配置正确和一致的策略。

如果您已经设置了一个强大的开发环境,可以自由跳过这一章;然而,请注意,本章中声明的某些环境假设可能会导致后续章节中的某些说明对您不起作用。如果您遇到问题或需要帮助同事、学生或朋友设置开发环境,请将此章节作为参考。设置您开发环境的自动化安装脚本可以在 github.com/duluca/web-dev-environment-setup 找到。

为了充分利用本书,您应该熟悉 JavaScript ES2015+、前端开发基础和 RESTful API。

推荐的操作系统是 Windows 10 Pro v1903+ 配合 PowerShell v7+,或者 macOS Sierra v10.15+ 配合 Terminal(Bash 或 Oh My Zsh)。本书中建议的大多数软件也适用于 Linux 系统,但您的体验可能会根据您的特定配置而有所不同。

开发者在开发 Web 应用程序时通常使用 Google Chrome 80+。然而,您也可以使用基于 Chromium 的 Microsoft Edge 浏览器 80+。您绝对应该从 github.com/PowerShell/PowerShell/releases 安装跨平台的 PowerShell,这将为您提供访问链式操作符 &&|| 的权限。此外,您可以从 Microsoft Store 获取新的 Windows Terminal,以获得在 Windows 上更优越的命令行体验。

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

  • 使用 CLI 包管理器 Chocolatey 和 Homebrew 安装和更新软件

  • 使用那些包管理器来安装 GitHub、Node.js 和其他必需的程序

  • 使用脚本通过 PowerShell 或 Bash 自动化安装

  • 使用 Angular CLI 生成 Angular 应用程序

  • 使用自动化工具实现一致且跨平台的开发生态环境

让我们从了解您可以使用来安装开发工具的基于 CLI 的包管理器开始。在下一节中,您将看到使用 CLI 工具是一种优于处理单个安装程序的方法。自动化 CLI 工具要容易得多,这使得设置和维护任务可重复且快速。

CLI 包管理器

通过 图形用户界面GUI)安装软件速度慢,且难以自动化。作为一名全栈开发者,无论您是 Windows 还是 Mac 用户,都必须依赖 命令行界面CLI)包管理器来高效地安装和配置您所依赖的软件。

记住,任何可以用 CLI 命令表达的内容也可以自动化。

为 Windows 安装 Chocolatey

Chocolatey 是一个基于 CLI 的 Windows 包管理器,可用于自动化软件安装。要在 Windows 上安装 Chocolatey,您需要运行提升的命令行 shell:

  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.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1')) 
    
  7. 通过执行 choco 验证您的 Chocolatey 安装

  8. 您应该看到以下截图所示的类似输出:

图 2.1:Chocolatey 成功安装

所有后续的 Chocolatey 命令都必须从提升的命令行 shell 中执行。或者,您可以在不需要提升的命令行 shell 的非管理员设置中安装 Chocolatey。然而,这会导致一个非标准且不太安全的开发环境,并且通过该工具安装的某些应用程序可能仍然需要提升权限。

Scoop 是 Chocolatey 的替代品,它提供了更类似 Unix 的体验。如果您更喜欢 Unix 风格的工具和命令,您可以在 scoop.sh/ 或通过执行以下命令来安装 Scoop:

$ iwr -useb get.scoop.sh | iex 

有关 Chocolatey 的更多信息,请参阅 chocolatey.org/install

为 macOS 安装 Homebrew

Homebrew 是一个基于 CLI 的 macOS 包管理器,可用于自动化软件安装。要在 macOS 上安装 Homebrew,您需要运行一个命令行 shell:

  1. 使用 + Space 启动 Spotlight 搜索

  2. 输入 terminal

  3. 在终端中执行以下命令以安装 Homebrew 包管理器:

    $ /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" 
    
  4. 通过执行 brew 验证您的 Homebrew 安装

  5. 您应该看到以下类似的输出:

图 2.2:Homebrew 成功安装

  1. 要启用对额外软件的访问,请执行以下命令:

    $ brew tap caskroom/cask 
    

在 macOS 上,如果您在安装 brew 包时遇到与 chown /usr/local相关的权限问题,您需要执行sudo chown -R $(whoami) $(brew --prefix)/*命令。此命令将用户级别的所有权重新分配给 brew 包,这比广泛的超级用户/su级别访问更安全。

更多信息,请查看brew.sh/

安装开发工具

在本节中,您将安装所有开始开发网络应用所需的开发工具。Git 和 GitHub Desktop 在您的机器上建立源代码仓库,并允许您将代码与远程仓库同步。Node.js 是适用于您的 PC 的 JavaScript 运行时,它附带Node 包管理器npm。Npm 管理第三方源代码,包括 Angular。Visual Studio Code 是一个集成开发环境IDE

要自动安装本书所需的全部网络开发工具,请在您的操作系统上执行以下命令以配置您的环境。

在 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

一旦您安装了您的集成开发环境(IDE),您就可以开始开发了。本节还包含了安装 Docker(一个轻量级容器化平台)和配置各种云服务的说明。这些工具将在后面的章节中变得相关。如果您想更快地开始 Angular 之旅,现在可以跳过它们。

Git 和 GitHub Desktop

本节旨在建立一个适合最广泛受众的最佳实践 Git 配置。为了最大限度地利用本节和本书后续章节,我假设您已经满足了以下先决条件:

  • 理解源代码管理和 Git 实际上是什么

  • GitHub.com上创建的免费账户

为什么使用 GitHub?

如果您是 Git 用户,那么您很可能也使用在线仓库,例如 GitHub、Bitbucket 或 GitLab。每个仓库都为开源项目提供免费层,并配以功能强大的网站,包括您可以选择付费的本地企业选项。GitHub 在 2016 年托管了 38+百万个仓库,是迄今为止最受欢迎的在线仓库。社区普遍认为,GitHub 是一个始终在线的基准工具。

随着时间的推移,GitHub 添加了许多丰富的功能,使其从单纯的仓库转变为在线平台。在本书的整个过程中,我会参考 GitHub 的功能和特性,以便您可以利用其能力来改变您开发、维护和发布软件的方式。

为什么使用 GitHub Desktop?

Git CLI 工具确实很强大,只要你坚持使用它,就没有问题。然而,作为全栈开发者,我们担心各种问题。在匆忙完成任务时,你可能会因为遵循错误或不完整的建议而毁掉自己,有时甚至可能毁掉团队的一天。

以下截图展示了 Stack Overflow 上此类建议的示例(stackoverflow.com/questions/1125968/force-git-to-overwrite-local-files-on-pull):

图 2.3:不要盲目执行的命令示例

如果你执行上述命令,请准备好丢失未提交的本地更改。不幸的是,新手用户往往倾向于遵循最直接和最简单的指示,这可能导致丢失工作。如果你认为你之前的提交是安全的,请三思!在 Git 方面,如果你能想到的,它就可以通过 CLI 实现。

幸运的是,有了 GitHub,你可以保护分支并实施 GitHub 工作流程,这包括分支、提交、合并、更新和提交拉取请求。这些保护和工作流程有助于防止有害的 Git 命令造成不可逆的更改,并确保团队保持高效的生产力。当存在合并冲突时,通过 CLI 执行所有这些操作可能会变得复杂和繁琐。

注意,Git 附带了一个名为 Git Bash 的 CLI 工具,这是一个基于 Unix 的 shell,你可以用它来执行git和其他命令。Bash 在 Linux 和 macOS 计算机上很容易获得。Windows 10 正在快速提高其终端支持,包括Windows Subsystem for LinuxWSL)和 PowerShell 中的 Unix 命令别名,因此 Windows 上使用 Git Bash 的需求正在迅速消失。如果你想了解更多关于 Git Bash 的信息,请查看 Atlassian 网站上的教程,网址为www.atlassian.com/git/tutorials/git-bash

为了更深入地了解 Git 和 GitHub 的益处和弊端,您可以阅读我 2016 年关于这个主题的文章,网址为Bit.ly/InDepthGitHub

安装 Git 和 GitHub Desktop

GitHub Desktop 提供了一个易于使用的 GUI,可以在 Windows 和 macOS 上以一致的方式执行 GitHub 工作流程。当欢迎新成员或初级团队成员加入,或者你不是代码库的频繁贡献者时,一致性非常有价值。我们建议您安装 GitHub Desktop 2.2+。

  1. 执行安装命令:

    对于 Windows:

    PS> choco install git github-desktop -y 
    

    对于 macOS:

    $ brew install git && brew cask install github 
    
  2. 通过执行git --version来验证您的 Git 安装,并观察返回的版本号。

    在安装新的 CLI 工具后,你需要重新启动你的终端。然而,你可以通过刷新或源环境变量来避免重新启动终端,节省一些时间。在 Windows 上,执行 refreshenv;在 macOS 上,执行 source ~/.bashrcsource ~/.zshrc

  3. 通过启动应用程序来验证你的 GitHub Desktop 安装。

  4. 在 GitHub Desktop 上登录 github.com/

  5. 一旦你创建了仓库,你可以通过在终端中执行以下命令来启动应用程序:

    $ github path/to/repo 
    
  6. 如果你已经在正确的文件夹中,你可以输入以下命令代替:

    $ github . 
    

对于 Windows 系统,在 GitHub Desktop 启动时,如果你卡在登录屏幕,请关闭应用程序,以管理员身份重新启动它,完成设置,然后你可以正常使用,无需再次以管理员身份启动。更多信息请参考:desktop.github.com/

接下来,我们将介绍几种策略,通过正确注册你的 GitHub 凭据,使 Git 的使用体验更加顺畅。

使用你的 GitHub 凭据在 Git 中

当你在 GitHub 上与你的仓库交互时,git 命令由你使用的工具(如 IDE)利用来推送或拉取内容。为了有一个顺畅的 Git 使用体验,正确地将你的 GitHub 凭据注册到 Git 中是一个好主意。

完成此任务有三种主要策略:

  1. 配置 SSH – 这是最佳且最安全的方式与任何远程计算机系统交互,因为不会交换密码。你可以通过 GitHub 的最新指南来配置 SSH:help.github.com/articles/connecting-to-github-with-ssh

  2. 在 Git 中缓存你的 GitHub 密码 – 有时候,你使用的工具可能不支持 SSH,因此你可能需要缓存密码。你可以通过执行以下命令来完成:

    对于 Windows 系统:

    PS> git config --global credential.helper wincred 
    

    对于 macOS 系统:

    $ git credential-osxkeychain
    $ git config --global credential.helper osxkeychain 
    

    如需进一步指导,请参考 GitHub 指南:help.github.com/articles/caching-your-github-password-in-git

  3. 创建个人访问令牌 – 从安全角度来看,这是一种介于 SSH 和密码使用之间的策略,因为 SSH 密钥和令牌可以从 GitHub 任何时间撤销,但一旦你的密码泄露或受损,你可能会失去对一切的控制。

如果你正在使用两步验证,这是绝对应该的,那么你不需要缓存密码,而是需要在 github.com/settings/tokens 创建一个个人访问令牌,并使用令牌代替密码。在 第三章创建基本的 Angular 应用程序 中,我们介绍了如何设置令牌以与 Visual Studio Code 配合使用,这是本书首选的 IDE。

查看 TJ Holowaychuk 的 git-extras 工具,它可以在 github.com/tj/git-extras 提供仓库摘要、更改日志填充、作者提交百分比等关于你的仓库的更多信息。

Node.js

本节旨在建立一个最佳实践 JavaScript 开发环境。我假设你对现代 JavaScript 生态系统和工具有所了解。至少,确保你熟悉以下资源:

Node.js 是在任何地方运行的 JavaScript。它是一个开源项目,旨在在服务器上运行 JavaScript,基于 Google Chrome 的 V8 JavaScript 引擎构建。到 2015 年底,Node.js 稳定下来,并宣布了面向企业的 18 个月 LTS 循环,这为平台带来了可预测性和稳定性,同时与更频繁更新的但更具实验性的最新分支相匹配。

Node 也附带 npm(Node 包管理器),截至 2018 年,npm 是世界上最大的 JavaScript 包库。

要更深入地了解 Node 的历史,请阅读我在 Bit.ly/NodeJSHistory(http://Bit.ly/NodeJSHistory)上发表的两篇文章。

你可能听说过 Yarn,以及它如何比 npm 更快或更好。截至 npm 5,它随 Node 8 一起打包,npm 在功能丰富性、易用性以及性能方面与 Yarn 相当,Yarn 由 Facebook 发布,Facebook 还创建了 React。必须注意的是,Yarn 依赖于 npm 存储库,所以无论你使用哪个工具,你都能访问到相同的包库。

现有的 Node.js 安装

如果你之前安装过 Node.js,在用 choco 或 brew 安装 Node.js 的新版本时,请务必仔细阅读命令输出。你的包管理器可能会返回注意事项或额外的说明,以便你成功完成安装。

很可能你的系统或文件夹权限在过去已经被手动编辑过,这可能会干扰 Node 的无烦恼操作。如果以下命令不能解决你的问题,请作为最后的手段使用 Node 网站的 GUI 安装程序。

要查看你的全局安装包列表,请执行 npm list -g --depth=0。要卸载全局包,请执行 npm uninstall -g package-name。我建议你卸载所有全局安装的包,并从下一节提供的建议重新开始。

无论怎样,你必须注意卸载之前使用npm -g安装的所有全局工具。随着每个主要 Node 版本的发布,你的工具和 Node 之间的本地绑定可能会被取消。此外,全局工具会迅速过时,而特定项目的工具会迅速失去同步。因此,现在全局安装工具已经成为一种反模式,已被更好的技术所取代,这些技术将在下一节和第三章创建基本的 Angular 应用中介绍。

安装 Node.js

本书假设你正在使用 Node 12.13 或更高版本。Node 的奇数版本并不打算长期使用。8.x.x、10.x.x、12.x.x 等版本是可以的,但无论如何都要避免 9.x.x、11.x.x 等版本,因为它们是实验性的。

  1. 执行安装命令:

    对于 Windows:

    PS> choco install nodejs-lts -y 
    

    对于 macOS:

    $ brew install node@10 
    
  2. 通过执行node -v来验证 Node 的安装

  3. 通过执行npm -v来验证 npm

    注意,在 Windows 上,你绝对不应该使用 npm install -g npm来升级 npm 版本,正如在附录 C保持 Angular 和工具始终如一中强调的那样。你可以从static.packt-cdn.com/downloads/9781838648800_Appendix_C_Keeping_Angular_and_Tools_Evergreen.pdfexpertlysimple.io/stay-evergreen在线找到这个附录。强烈建议你使用 npm-windows-upgrade npm 包。

对于这本书,请确保你已经安装了 npm v.6.12+。现在,让我们来了解一下你可能想要全局安装的一些实用的 npm 包。

全局 npm 包

npm 仓库包含许多有用且成熟的 CLI 命令,这些命令通常是跨平台的。以下是我经常依赖并选择全局安装的命令,出于性能考虑:

  • npx:通过按需下载最新版本或项目特定的本地node_modules文件夹来执行 CLI 工具。npx随 npm 5+一起提供,允许你运行经常更新而不需要全局安装的代码生成器。

  • rimraf:Unix 命令rm -rf在 Windows 上同样适用。它对于删除node_modules文件夹非常有用,尤其是在 Windows 由于嵌套文件夹结构无法执行删除操作时。

  • npm-check-updates:分析你的项目文件夹,并报告哪些包有新版本或没有,如果你愿意,可以选择更新所有这些包。简称ncu

  • n:一个简单易用的工具,可以快速在 Node 的不同版本之间切换,无需记住具体的版本号,适用于 macOS/Linux。对于 Windows,你可以使用 choco 包nvsnnvs都在附录 C保持 Angular 和工具始终如一中介绍。

  • http-server:一个简单、零配置的命令行 HTTP 服务器,是本地测试静态 HTML/CSS 页面或 Angular 或 React 项目的 dist 文件夹的绝佳方式。

  • npm-windows-upgrade:在 Windows 上升级 npm 所必需。

  • npkill:轻松查找并删除旧的、庞大的 node_modules 文件夹,并回收数 GB 的磁盘空间。

    您可以通过执行 ncu -g 使用 npm-check-updates 来保持所有全局包的最新状态。

如果您在 macOS 上安装全局包时遇到 EACCES 权限错误,请参阅 npm 的指南,链接为 docs.npmjs.com/getting-started/fixing-npm-permissions

Visual Studio Code

Visual Studio CodeVS Code)是市面上最好的代码编辑器/IDE 之一,由微软开发和维护。它是免费的且跨平台。值得注意的是,VS Code 具有代码编辑器的闪电般性能——想想 NotePad++ 或 Sublime Text——但具有昂贵 IDE 的功能集和便利性——想想 Visual Studio 或 WebStorm。对于 JavaScript 开发,这种速度至关重要,并且对于经常在不同项目之间切换的开发者来说,这是一个巨大的生活质量提升。VS Code 集成了终端、易于使用的扩展系统、透明的设置、出色的搜索和替换功能,以及在我看来,目前最好的 Node.js 调试器。

这本书不需要您使用 VS Code。如果您希望使用 WebStorm 等其他 IDE,您也可以这样做。WebStorm 是一款付费产品,开箱即用即可提供出色的开发体验,而 VS Code 则需要大量的自定义。本书提供了自动化脚本来配置 VS Code,以实现最佳的 Angular 开发体验。

您可以在 https://www.jetbrains.com/webstorm 上找到更多关于 WebStorm 的信息。

安装 Visual Studio Code

对于 Angular 开发,本书利用 VS Code v1.42+。我强烈建议您也使用 VS Code 的最新版本。

  1. 执行安装命令:

    对于 Windows:

    PS> choco install VisualStudioCode -y 
    

    对于 macOS:

    $ brew cask install visual-studio-code 
    

    VS Code 最好的特性之一是您还可以从 CLI 启动它。如果您在一个希望编辑的文件夹中,只需执行 code . 或特定的文件,通过执行 code ~/.bashrccode readme.md

  2. 通过启动 VS Code 验证安装

  3. 导航到一个文件夹并执行 code

  4. 这将打开一个新的 VS Code 窗口,资源管理器显示当前文件夹的内容

更多信息,请参阅 code.visualstudio.com

安装 VS Code 后,您就可以开始开发了。如果您想快速开始 Angular 的冒险,请跳转到 Angular CLI 部分,并在需要 Docker 和各种云服务的工具时回到本节。

Docker

Docker 是一个轻量级的容器虚拟化平台,具有帮助管理和部署应用程序的工作流程和工具。

安装 Docker

要能够构建和运行容器,您必须首先在您的计算机上安装 Docker 执行环境。

Windows 对 Docker 的支持可能具有挑战性。您必须拥有一台支持虚拟化扩展的 CPU 的 PC,这在笔记本电脑上并不保证。您还必须拥有已启用 Hyper-V 的 Windows Pro 版本。另一方面,Windows Server 对 Docker 有原生支持,这是微软对行业采用 Docker 和容器化所展示的前所未有的支持。

  1. 通过执行以下命令安装 Docker:

    对于 Windows:

    PS> choco install docker docker-for-windows -y 
    

    对于 macOS:

    $ brew install docker 
    
  2. 执行 docker -v 以验证安装。

云服务

在本书中,我们将使用各种云服务提供商来执行您将要构建的应用程序的部署。每个服务都附带一个 CLI 工具,该工具可以方便地从您的终端或云中的 持续集成CI)环境中部署您的应用程序。

Vercel Now

Vercel Now 是一个用于静态网站和无服务器函数的云平台。通过简单的 CLI 命令,您可以即时托管网站和部署网络服务。本书利用了一个免费层的 Vercel Now 账户。

  1. vercel.com 创建一个 Vercel Now 账户。

  2. 通过执行以下命令安装 CLI 工具:

    $ npm i -g now 
    
  3. 通过执行以下命令验证安装:

    $ now login 
    
  4. 按照说明完成登录过程。您应该会看到一个类似于以下的消息:

    > We sent an email to xxxxx@gmail.com. Please follow the steps provided inside it and make sure the security code matches Classical Slow Worm
    √ Email confirmed
    > Congratulations! You are now logged in. In order to deploy something, run `now` 
    

如需更多信息,请参阅vercel.com

Google Firebase

Firebase 是谷歌专为托管具有身份验证、推送通知、云函数、数据库、机器学习和分析支持的手机和网页应用而定制的云平台。本书利用了一个免费层的 Firebase 账户。

  1. firebase.google.com/ 创建一个 Firebase 账户。

  2. 通过执行以下命令安装 CLI 工具:

    $ npm i -g firebase-tools 
    
  3. 通过执行以下命令验证安装:

    $ firebase login 
    
  4. 按照说明完成登录过程。您应该会看到一个类似于以下的消息:

    Waiting for authentication...
    +  Success! Logged in as xxxxxx@gmail.com 
    

如需更多信息,请参阅 firebase.google.com/

Google Cloud

Google Cloud 是谷歌为企业和组织提供的世界级云基础设施。本书利用 Google Cloud Run 进行管理的容器部署到云中。在您首次注册时,您可能会收到免费额度来使用 Google Cloud。然而,这是一个可选的练习,因为如果您忘记拆除您的部署,使用此服务可能会产生费用。

  1. cloud.google.com/ 创建一个 Google Cloud 账户。

  2. 执行安装命令:

    对于 Windows:

    PS> choco install gcloudsdk -y 
    

    如果您在 choco 中安装 gcloudsdk 时遇到问题,那么请尝试之前章节中提到的 scoop。执行以下命令:

    $ scoop bucket add extras
    $ scoop install gcloud 
    

    对于 macOS:

    $ brew install google-cloud-sdk 
    
  3. 通过执行 gcloud --version 来验证安装。

  4. 通过执行 gcloud init 来完成设置。

更多信息,请参阅 cloud.google.com/run/

亚马逊网络服务

亚马逊网络服务AWS)是由亚马逊提供的全球部署的云基础设施。AWS 是企业和政府广泛使用的工具,对于 IT 专业人员来说,它是一项有利可图的业务。第十三章,“AWS 上的高可用云基础设施”,深入探讨了如何使用 AWS 并执行基于容器的可扩展部署。

  1. 执行安装命令:

    对于 Windows:

    PS> choco upgrade awscli -y 
    

    对于 macOS:

    $ brew install awscli
    $ brew upgrade awscli 
    

    注意,在 choco 和 brew 上运行升级命令可以确保如果它们之前已安装到您的环境中,您将拥有任何给定工具的最新版本。

  2. 通过执行 aws --version 验证安装

更多信息,请参阅 aws.amazon.com/

设置 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 Administrator'"
    $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 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 AWS, Docker, GitHub Desktop and VS Code manually."
        $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 技能感兴趣,我建议您安装 PowerShell Core,这是 PowerShell 的多平台版本。请从 github.com/PowerShell/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
    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"
    
        /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
    
        echo "Installing git"
        brew install git
        brew upgrade git
        echo "Installing GitHub Desktop"
        brew cask install github
        brew cask upgrade github
        echo "Installing NodeJS"
        brew install node@12
        brew upgrade node@12
        echo "Installing Docker"
        brew cask install docker
        brew cask upgrade docker
        echo "Installing AWS"
        brew install awscli
        brew upgrade awscli
        echo "Installing VS Code"
        brew cask install visual-studio-code
        brew cask 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"
    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 应用程序都应该具有相同的一般形状。

设置您的开发目录

设置一个专门的 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/your-user-name 下。

现在您的开发目录已准备就绪,让我们开始生成您的 Angular 应用程序。

生成您的 Angular 应用程序

Angular CLI 是一个官方的 Angular 项目,旨在确保新创建的 Angular 应用程序具有统一的架构,遵循社区经过时间检验的最佳实践。这意味着您今后遇到的任何 Angular 应用程序都应该具有相同的一般形状。Angular CLI 超出了初始代码生成的范畴;您会频繁地使用它来创建新的组件、指令、管道、服务、模块等。Angular CLI 还在开发过程中提供帮助,具有实时重新加载功能,以便您可以快速看到您更改的结果。Angular CLI 还可以测试、检查代码风格并构建用于生产发布的优化版本。此外,随着新的 Angular 版本的发布,Angular CLI 帮助您通过自动重写代码的部分来升级您的代码,以确保它与潜在的破坏性更改保持兼容。

安装 Angular CLI

angular.io/guide/quickstart 上的文档指导您如何将 @angular/cli 作为全局 npm 包进行安装。请不要这样做。随着时间的推移,当 Angular CLI 升级时,始终需要保持全局版本和项目版本同步,这会成为一个持续的烦恼。如果您不这样做,工具会不断地抱怨。此外,如果您正在处理多个项目,随着时间的推移,您将拥有不同版本的 Angular CLI。结果,您的命令可能不会返回您期望的结果,或者与团队成员得到的结果不同。

下一个部分中详细说明的策略使得您的 Angular 项目的初始配置比必要的要复杂一些;然而,如果您几个月或一年后需要返回项目,您将能够弥补这种痛苦。在这种情况下,您可以使用在项目上最后使用的工具版本,而不是可能需要升级的某个未来的版本。在下一个部分中,您将使用这一最佳实践来初始化您的 Angular 应用程序。

初始化您的 Angular 应用

初始化您的应用的主要方式是使用 Angular CLI。让我们使用 npx 初始化开发中的应用程序,npx 已经在您安装最新版本的 Node LTS 时安装到您的系统上,从 PowerShell/Terminal 运行:

  1. 在您的 dev 文件夹下,执行 npx @angular/cli new local-weather-app

  2. 当被问及“您是否希望添加 Angular 路由?”时,选择

  3. 当被问及“您希望使用哪种样式表格式?”时,选择CSS

  4. 在您的终端上,您应该看到一条类似于以下的成功消息:

    $ npx @angular/cli new local-weather-app
    ...
    CREATE local-weather-app/src/environments/environment.ts (662 bytes)
    CREATE local-weather-app/src/app/app-routing.module.ts (245 bytes) CREATE local-weather-app/src/app/app.module.ts (393 bytes)
    CREATE local-weather-app/src/app/app.component.html (1152 bytes) CREATE local-weather-app/src/app/app.component.spec.ts (1086 bytes) CREATE local-weather-app/src/app/app.component.ts (207 bytes) CREATE local-weather-app/src/app/app.component.css (0 bytes)
    CREATE local-weather-app/e2e/protractor.conf.js (752 bytes) CREATE local-weather-app/e2e/tsconfig.e2e.json (213 bytes) CREATE local-weather-app/e2e/src/app.e2e-spec.ts (632 bytes) CREATE local-weather-app/e2e/src/app.po.ts (251 bytes)
    added 1076 packages from 1026 contributors and audited 42608 packages in 62.832s
    found 0 vulnerabilities Successfully initialized git.
    Project 'local-weather-app' successfully created. 
    

您的项目文件夹——local-weather-app——已初始化为 Git 仓库,并使用初始文件和文件夹结构进行了搭建,其结构应如下所示:

local-weather-app
├── .editorconfig
├── .git
├── .gitignore
├── angular.json
├── e2e
│ ├── protractor.conf.js
│ ├── src
│ │ ├── app.e2e-spec.ts
│ │ └── app.po.ts
│ └── tsconfig.e2e.json
├── package.json
├── README.md
├── src
│ ├── app
│ │ ├── app-routing.module.ts
│ │ ├── app.component.css
│ │ ├── app.component.html
│ │ ├── app.component.spec.ts
│ │ ├── app.component.ts
│ │ └── app.module.ts
│ ├── assets
│ │ └── .gitkeep
│ ├── browserslist
│ ├── environments
│ │ ├── environment.prod.ts
│ │ └── environment.ts
│ ├── favicon.ico
│ ├── index.html
│ ├── karma.conf.js
│ ├── main.ts
│ ├── polyfills.ts
│ ├── styles.css
│ ├── test.ts
│ ├── tsconfig.app.json
│ ├── tsconfig.spec.json
│ └── tslint.json
├── tsconfig.json
└── tslint.json 

@angular/cli 的别名是 ng。如果你全局安装 Angular CLI,你会执行 ng new local-weather-app,但我们没有这样做。因此,记住,从现在开始,你将执行 ng 命令,但这次是在 local-weather-app 目录下。Angular CLI 的最新版本已经安装到 node_modules/.bin 目录下,这样你就可以运行 ng 命令,如 npx ng generate component my-new-component,并继续高效地工作。

如果你使用的是 macOS,你可以通过实现 shell 自动回退来进一步改善你的开发体验,这可以消除必须使用 npx 命令的必要性。如果发现未知命令,npx 将接管请求。如果该软件包已经在本地 node_modules/.bin 下存在,npx 将将你的请求传递给正确的二进制文件。因此,你可以像全局安装一样运行 ng g c my-new-component 这样的命令。有关如何设置此功能的说明,请参阅 npx 的自述文件,链接为 npmjs.com/package/npx#shell-auto-fallback

使用 GitHub Desktop 发布 Git 仓库

GitHub Desktop 允许你在应用程序内直接创建新的仓库:

  1. 打开 GitHub for Desktop

  2. 文件 | 添加本地仓库...

  3. 通过点击 选择... 定位 local-weather-app 文件夹

  4. 点击 添加仓库

  5. 注意,Angular CLI 已经在 历史 选项卡中为你创建了第一个提交

  6. 最后,点击 发布仓库,在以下截图中被标记为 6

图 2.4:GitHub Desktop

检查和更新 package.json

package.json 是你应该始终保持警觉的单个最重要的配置文件。你的项目脚本、运行时和开发依赖都存储在这个文件中。

  1. 打开 package.json 并定位名称和版本属性:

    **package.json**
    {
      "name": "local-weather-app", 
      "version": "0.0.0",
      "license": "MIT",
    ... 
    
  2. 将你的应用程序重命名为你想要的任何名称;我使用了 localcast-weather

  3. 将你的版本号设置为 1.0.0

    npm 使用语义化版本控制(semver),其中版本号数字代表主版本号、次版本号和修订号的增量。Semver 对于任何发布的 API,版本号从 1.0.0 开始,尽管它并不阻止 0.x.x 的版本控制。作为网络应用程序的作者,你的应用程序的版本控制对你来说没有实际影响,除了内部工具、团队或公司沟通目的之外。然而,你的依赖项的版本控制对你的应用程序的可靠性至关重要。总之,修订版本号应该只是修复错误。次版本号添加功能而不破坏现有功能,而主版本号的增量可以自由地进行不兼容的 API 更改。但是,任何更新都可能对你的应用程序的测试行为构成风险。因此,package-lock.json 文件存储了你的应用程序的整个依赖项树,以便其他开发人员或 CI 服务器可以复制你的应用程序的确切状态。更多信息,请访问:semver.org/.

    在以下代码块中,请注意,scripts 属性包含一系列有用的启动脚本,你可以在此基础上进行扩展。starttest 命令是 npm 的默认命令,因此可以通过 npm startnpm test 执行。然而,其他命令是自定义命令,必须以 run 关键字开头。例如,为了构建你的应用程序,你必须使用 npm run build

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

    在 npx 引入之前,如果你想在全局安装之外使用 Angular CLI,你必须使用 npm run ng -- g c my-new-component 来运行它。双横线是必需的,以便让 npm 知道命令行工具名称的结束和选项的开始。例如,为了在除了默认的 4200 之外的其他端口上启动你的 Angular 应用程序,你需要运行 npm start -- --port 5000

  4. 更新你的 package.json 文件,以便从不太常用的端口 5000 运行你的开发版本应用程序,作为新的默认行为:

    **package.json**
    ...
      "start": "ng serve --port 5000",
    ... 
    

    在依赖项属性下,你可以观察到你的运行时依赖项。这些是与你的代码一起打包并发送到客户端浏览器的库。将此列表保持最小是至关重要的:

    **package.json**
    ...
      "dependencies": { 
        "@angular/animations": "~9.0.0",
        "@angular/common": "~9.0.0",
        "@angular/compiler": "~9.0.0",
        "@angular/core": "~9.0.0",
        "@angular/forms": "~9.0.0",
        "@angular/platform-browser": "~9.0.0",
        "@angular/platform-browser-dynamic": "~9.0.0",
        "@angular/router": "~9.0.0",
        "rxjs": "~6.5.3",
        "tslib": "¹.10.0",
        "zone.js": "~0.10.2"
      },
    ... 
    

在前面的示例中,所有 Angular 组件都在同一版本上。随着你安装额外的 Angular 组件或升级单个组件,建议保持所有 Angular 包在同一版本上。这特别容易做到,因为 npm 不再需要 --save 选项来永久更新包版本。例如,只需执行 npm install @angular/router 就足以更新 package.json 中的版本。这是一个积极的整体变化,因为你在 package.json 中看到的内容与实际安装的内容相匹配。然而,你必须小心,因为 npm 还会自动更新 package-lock.json,这会将你的,可能是无意中做出的,更改传播给你的团队成员。

你的开发依赖存储在 devDependencies 属性下。当你为你的项目安装新工具时,你必须注意在命令中附加 --save-dev,以确保你的依赖被正确分类。开发依赖只在开发期间使用,不会发送到客户端浏览器。你应该熟悉每一个这样的包及其特定的用途。如果你对我们继续展示的包不熟悉,你了解它们最佳的资源是 www.npmjs.com/

**package.json**
...
  "devDependencies": {
    "@angular-devkit/build-angular": "~0.900.0",
    "@angular/cli": "~9.0.0",
    "@angular/compiler-cli": "~9.0.0",
    "@angular/language-service": "~9.0.0",
    "@types/node": "¹².11.1",
    "@types/jasmine": "~3.4.0",
    "@types/jasminewd2": "~2.0.3",
    "codelyzer": "⁵.1.2",
    "jasmine-core": "~3.5.0",
    "jasmine-spec-reporter": "~4.2.1",
    "karma": "~4.3.0",
    "karma-chrome-launcher": "~3.1.0",
    "karma-coverage-istanbul-reporter": "~2.1.0",
    "karma-jasmine": "~2.0.1",
    "karma-jasmine-html-reporter": "¹.4.2",
    "protractor": "~5.4.2",
    "ts-node": "~8.3.0",
    "tslint": "~5.18.0",
    "typescript": "~3.6.4"
  }
... 

在版本号前面的字符在 semver 中有特定的含义:

  • 波浪号 ~ 在版本号的三个数字都定义的情况下启用波浪号范围,允许自动应用补丁版本升级

  • 上箭头字符 ^ 启用箭头范围,允许自动应用小版本升级

  • 任何字符的缺失都表示 npm 将在你的机器上安装该库的确切版本

你可能会注意到,不允许自动进行主要版本升级。一般来说,更新包可能会有风险。为了确保没有任何包在没有你明确知道的情况下更新,你可以通过使用 npm 的 --save-exact 选项来安装精确版本的包。让我们通过安装我发布的一个名为 dev-norms 的 npm 包来实验这种行为,这是一个 CLI 工具,可以生成一个包含合理默认规范的 markdown 文件,以便你的团队进行讨论,如下所示:

  1. local-weather-app 目录下,执行 npm install dev-norms --save-dev --save-exact。注意,"dev-norms": "1.7.0" 或类似的版本已添加到 package.json 中,并且 package-lock.json 会自动更新以反映相应的更改。

  2. 工具安装后,执行 npx dev-norms create。已创建一个名为 dev-norms.md 的文件,其中包含之前提到的开发者规范。

  3. 将你的更改保存到 package.json

与过时的包一起工作有其风险。在 npm 6 中,引入了 npm audit 命令,以便让你了解你在使用的包中发现的任何漏洞。在 npm install 期间,如果你收到任何漏洞通知,你可以执行 npm audit 来了解任何潜在风险的详细信息。

在下一节中,你将提交你已对 Git 做出的更改。

使用 VS Code 提交代码

要将你的更改提交到 Git 并然后将提交同步到 GitHub,你可以使用 VS Code:

  1. 切换到标记为 1源代码控制 选项卡:

    图 2.5:Visual Studio Code 源代码控制选项卡

  2. 在标记为 2 的框中输入提交信息

  3. 点击标记为 3 的勾号图标,以提交你的更改

  4. 最后,通过点击标记为 4 的刷新图标,将你的更改与 GitHub 仓库同步

如果你启用了两步验证,你应该这样做,GitHub 可能会要求你的凭据。在这种情况下,你需要创建一个个人访问令牌。按照以下说明操作以完成此操作:

  1. 前往页面 github.com/settings/tokens

  2. 生成一个新的令牌并将其复制

  3. 尝试在 VS Code 中重新同步你的更改

  4. 忽略 GitHub 身份验证窗口,该窗口会显示 VS Code 的凭据输入栏

  5. 输入你的 GitHub 用户名,而不是你的电子邮件

  6. 将令牌粘贴为你的密码

  7. 同步应该成功,后续同步不应提示密码

在本章前面的 Git 和 Github Desktop 部分查看更广泛的讨论,了解你可以使用的各种方法将你的 Git 客户端连接到 GitHub。

从现在开始,你可以在 VS Code 中执行大多数 Git 操作。

运行你的 Angular 应用程序

运行你的 Angular 应用程序以检查它是否工作。在开发过程中,你可以通过ng serve命令执行npm start;此操作将代码编译、打包并在 localhost 上提供,启用实时重新加载:

  1. 执行npm start

  2. 导航到http://localhost:5000

  3. 你应该看到一个渲染的页面,类似于这个!img/B14094_02_06.png

    图 2.6:默认 Angular CLI 登录页面

  4. 通过在集成终端中按Ctrl + C来停止你的应用程序

恭喜!你准备好开始开发你的 Web 应用程序了。如果在设置过程中遇到任何问题,请查看下一节,了解如何验证你的代码与 GitHub 上的示例项目。

验证你的代码

书中示例代码的最新版本在 GitHub 上,链接如下。该存储库包含代码的最终和完成状态。你可以在章节结束时通过查找projects文件夹下的章节结束代码快照来验证你的进度。

对于第二章

  1. 克隆 repo github.com/duluca/local-weather-app

  2. 在根目录下执行npm install以安装依赖项

  3. 本章的代码示例位于子文件夹中:

    projects/ch2 
    
  4. 要运行本章的 Angular 应用程序,请执行:

    npx ng serve ch2 
    
  5. 要运行本章的 Angular 单元测试,请执行:

    npx ng test ch2 --watch=false 
    

注意,书中或 GitHub 上的源代码可能并不总是与 Angular CLI 生成的代码相匹配。由于生态系统不断演变,书中代码与 GitHub 上代码之间的实现也可能存在细微差异。随着时间的推移,示例代码发生变化是自然的。在 GitHub 上,你可能会找到更正、修复以支持库的新版本,或者为读者观察而并排实现多种技术的示例。读者只需实现书中推荐的理想解决方案即可。如果你发现错误或有疑问,请为所有读者创建一个 issue 或提交一个 pull request 到 GitHub。

在下一节中,我将介绍如何优化 VS Code 以获得最佳的 Angular 开发体验。

优化 VS Code 以适用于 Angular

优化您的 IDE 以获得出色的开发体验至关重要。如果您利用本节中提供的自动化工具,您可以快速配置您的 IDE 和 Angular 项目,并使用数十个协同工作的设置。

自动配置您的项目

要快速应用即将在章节中涵盖的配置步骤,请运行以下命令:

  1. 安装 Angular VS Code 任务:

    npm i -g mrm-task-angular-vscode 
    
  2. 应用 Angular VS Code 配置:

    npx mrm angular-vscode 
    
  3. 为 Docker 任务安装 npm 脚本:

    npm i -g mrm-task-npm-docker 
    
  4. 应用 Docker 的 npm 脚本配置:

    npx mrm npm-docker 
    

    这些设置不断调整以适应扩展、插件、Angular 和 VS Code 的不断变化的环境。请始终确保通过重新运行安装命令来安装任务的最新版本。

  5. 执行 npm run style:fix

  6. 执行 npm run lint:fix

有关 mrm 任务更多信息,请参阅:

注意,mrm-task-npm-aws 为 AWS ECS 设置 npm 脚本,这在 第十三章AWS 上的高可用云基础设施 中使用。

您可以将配置与 GitHub 上的示例项目进行验证。请注意,配置组件将在仓库的根目录下应用,而不是在 projects 文件夹下。

下面的三个部分涵盖了之前自动应用的设置。如果您有问题,请随时跳过并查阅。

VS Code 自动保存

持续保存文件可能会变得繁琐。您可以通过以下步骤启用自动保存:

  1. 打开 VS Code

  2. 文件 | 自动保存 下切换设置

您可以通过启动 首选项 来进一步自定义 VS Code 的许多行为。在 Windows 上,启动 首选项 的快捷键是 Ctrl + ,,在 macOS 上是 ![ + ,`]。

IDE 设置

您可以通过在项目目录的根目录下创建一个 .vscode 文件夹并将一个 settings.json 文件放入其中来与同事共享此类设置。如果您将此文件提交到仓库,每个人都将共享相同的 IDE 体验。不幸的是,个人无法通过本地首选项覆盖这些设置,因此请确保共享设置最小化,并作为团队规范达成一致。

这里是我用于获得最佳、电池寿命意识的 Angular 开发体验的定制设置:

**.vscode/settings.json**
{
  "debug.openExplorerOnEnd": true,
  "editor.tabSize": 2,
  "editor.rulers": [90],
  "editor.autoIndent": "full",
  "editor.cursorBlinking": "solid",
  "editor.formatOnType": false,       // Adjust the intensity of
  "editor.formatOnPaste": false,         auto-formatting to taste
  "editor.formatOnSave": true,
  "editor.minimap.enabled": false,
  "editor.codeActionsOnSave": {
    "source.organizeImports": false,
    "source.fixAll.tslint": true,
  },
  "explorer.openEditors.visible": 0,
  "files.trimTrailingWhitespace": true,
  "files.autoSave": "onFocusChange",
  "git.confirmSync": false,
  "git.enableSmartCommit": true,
  "npm.enableScriptExplorer": true,
  "typescript.tsdk": "node_modules/typescript/lib",
  "workbench.iconTheme": "material-icon-theme",     // Requires 
                                                  Material Icon 
                                                Theme Extension 
  "auto-close-tag.SublimeText3Mode": true,          // Requires Auto 
                                              Close Tag Extension 
  "html.autoClosingTags": false, 
  "peacock.affectActivityBar": true,               // Requires Peacock 
  "peacock.affectStatusBar": true,                           Extension
  "peacock.affectTitleBar": false,
  "workbench.colorCustomizations": {
    "activityBar.background": "#d04649",
    "activityBar.activeBorder": "#37cb34",
    "activityBar.foreground": "#e7e7e7",
    "activityBar.inactiveForeground": "#e7e7e799",
    "activityBarBadge.background": "#37cb34",
    "activityBarBadge.foreground": "#15202b",
    "statusBar.background": "#b52e31",
    "statusBarItem.hoverBackground": "#d04649",
    "statusBar.foreground": "#e7e7e7"
  },
  "peacock.color": "#b52e31",
  "gitlens.menus": {                              // Requires GitLens 
    "editorGroup": false                               Extension 
  }, 
  "ng-evergreen.upgradeChannel": "Latest"            // Requires Angular 
                                                  Evergreen Extension 
} 

在后面的部分中,当我们添加强制执行我们编码风格的工具时,请注意不要引入相互重叠或相互矛盾的新设置。

IDE 扩展

为了在 VS Code 和 Angular 之间获得神奇的开发体验,你应该安装由 John Papa 创建和精选的 Angular Essentials 扩展包。John Papa 是 Angular 社区中领先的倡导者和思想领袖之一。他不断不懈地寻求最佳的开发体验,以便你作为开发者更加高效和快乐。要了解更多关于 Angular Essentials 的信息,请参阅这篇博客文章johnpapa.net/rec-ng-extensions和 GitHub 仓库github.com/johnpapa/vscode-angular-essentials

我强烈建议你关注 Twitter 上的 John Papa,用户名是 @john_papa

与设置类似,你也可以通过 JSON 文件共享推荐的扩展。这些是我用于 Angular 开发的扩展:

**.vscode/extensions.json**
{
  "recommendations":[
    "johnpapa.angular-essentials",
    "PKief.material-icon-theme",
    "formulahendry.auto-close-tag",
    "ms-azuretools.vscode-docker",
    "eamodio.gitlens",
    "WallabyJs.quokka-vscode",
    "amatiasq.sort-imports",
    "DSKWRK.vscode-generate-getter-setter",
    "esbenp.prettier-vscode",
    "HookyQR.beautify",
    "expertly-simple.ng-evergreen",
    "msjsdiag.debugger-for-edge"
  ]
} 

VS Code 也推荐了一些扩展供你安装。我警告你,不要安装太多扩展,因为这些扩展会明显降低 VS Code 的启动性能和最佳运行状态。

VS Code 生态系统是一个不断演变、动态且丰富的生态系统。因此,某些扩展或设置可能会消失、停止工作或存在错误。如果你遇到任何问题或只是好奇,你可以在 GitHub 上找到我首选的 VS Code 配置文件的最新版本,链接为bit.ly/ngCodeSettings

脚本代码风格和 linting

你可以在 VS Code 和 Angular CLI 中自定义代码风格强制执行和代码生成行为。自动化强制执行代码风格和 linting 规则的最关键目标是让开发者达成共识。如果团队无法就遵循哪种风格达成一致,那么抛硬币也比没有协议要好。开发团队应该专注于代码质量,让自动化工具去关心代码的缩进、括号的定位和括号之间的空格。在大团队中,任何风格上的偏差都可能导致合并冲突带来重大麻烦。强烈建议你实施强制执行标准的机制。

我更喜欢 StandardJS 的 JavaScript 设置,它将编写代码的最小化方法与高可读性相结合。这意味着制表符为两个空格,不使用分号。除了减少按键次数外,StandardJS 还占用更少的横向空间,这在你的 IDE 只能利用屏幕一半的情况下,另一半被浏览器占用时尤其有价值。你可以在standardjs.com/上了解更多关于 StandardJS 的信息。

使用默认设置,你的代码看起来像:

import { AppComponent } from "./app.component"; 

使用 StandardJS 设置,你的代码看起来像:

import { AppComponent } from './app.component' 

如果你不喜欢这种风格,那也行。虽然接下来我会分享我的首选设置,但你可以随意调整到自己喜欢的样子。我们实施的强制执行规则机制保持不变。

要应用和强制执行代码风格规则,我们使用一些提供 CLI 工具和 VS Code 扩展的工具:

  • Prettier – 用于格式化 .ts 文件

  • ImportSort/SortImports – 用于组织 TypeScript 导入语句

  • Beautify – 用于格式化 .html 文件,

  • TSLint – 作为静态代码分析工具,用于检查代码可读性、可维护性和功能错误

我们的目标是最终拥有四个脚本:

  1. style – 检查我们的代码是否遵循样式规则

  2. style:fix – 自动根据样式规则格式化代码文件

  3. lint – 检查我们的代码是否有任何代码风格错误

  4. lint:fix – 自动修复可自动修复的代码风格错误

样式和代码风格命令将由我们的 CI 服务器使用,以确保每位团队成员都遵循相同的编码标准。style:fixlint:fix 命令将帮助开发者以尽可能少的努力遵守编码标准。

这些工具不断更新。这些工具的行为可能会随时间而变化,所以请密切关注,并且不要犹豫,尝试添加/删除这些工具以实现适合您的配置。

在我们设置依赖项和配置文件之前,请确保已安装 IDE 扩展部分中推荐的所有扩展。

配置工具

您可以通过以下步骤开始进行配置更改:

  1. 确保已安装 Prettier – 代码格式化器TSLintsort-importsBeautify 扩展(已包含在前一节的 extensions.json 中)

  2. 通过执行以下命令安装 CLI 工具:

     npm i -D prettier tslint-config-prettier tslint-plugin-prettier
      npm i -D js-beautify
      npm i -D import-sort import-sort-cli import-sort-parser-typescript import-sort-style-module
      npm i -D tslint tslint-etc 
    

    使用 npm,您可以使用 i 作为 install 的别名,而不是更冗长的 --save-dev 选项。然而,如果您将 -D 错误地输入为 -d,您最终会将包保存为生产依赖项。

  3. 通过在文件末尾添加 importSort 属性来编辑 package.json

    **package.json**
    ...
      "importSort": {
        ".ts, .tsx": {
          "parser": "typescript",
          "style": "module",
          "options": {}
        }
      }
    ... 
    
  4. 更新 tslint.json 规则以与 Prettier 和 tslint-etc 集成:

    **tslint.json**
    **{**
     **"extends": [**
     **"tslint:recommended",**
     **"tslint-config-prettier",**
     **"tslint-plugin-prettier",**
     **"tslint-etc"**
     **],**
     **"rules": {**
     **"prettier": true,**
     **"no-unused-declaration": true,**
    **...**
    "quotemark": [true, "single", "avoid-escape"],
    ...
    "semicolon": [ true, "never"],
    ...
    "max-line-length": [ true,90],
    ...
    } 
    
  5. 在项目的根目录下添加一个新文件,命名为 .jsbeautifyrc

    **.jsbeautifyrc**
    {
      "indent_size": 2,
      "wrap_line_length": 90,
      "language": {
        "html": [
          "html"
        ]
      }
    } 
    
  6. 在项目的根目录下添加一个新文件,命名为 .prettierrc

    **. prettierrc**
    {
      "tabWidth": 2,
      "useTabs": false,
      "printWidth": 90,
      "semi": false,
      "singleQuote": true,
      "trailingComma": "es5",
      "jsxBracketSameLine": true
    } 
    
  7. 在项目的根目录下添加一个新文件,命名为 .prettierignore。注意,此文件没有花括号:

    **. prettierignore**
    **/*.html 
    

现在我们已经配置好了所有必要的工具来实现我们的样式和代码风格脚本。

实现样式检查器和修复器

让我们实现 npm 脚本以用于样式和 style:fix 命令。Npm 脚本是一种很好的方式来记录您的团队需要在不同平台和 CI 服务器上执行 CLI 脚本。

现在,让我们添加第一个脚本:

  1. 编辑 package.json 中的脚本属性,添加 stylestyle:fix 命令:

    **package.json**
    ...
      "scripts": {
        "style:fix": "import-sort --write \"**/{src,tests,e2e}/*.ts\" && prettier --write \"**/{src,tests,e2e}/*.{*css,ts}\" && js-beautify \"src/**/*.html\"",
        "style": "import-sort -l \"**/{src,tests,e2e}/*.ts\" && prettier --check \"**/{src,tests,e2e}/*.{*css,ts}\"",  ...
      }
    ... 
    
  2. 执行 npm run style 来查看不遵循样式规则的文件

  3. 执行 npm run style:fix 来更新所有文件到新样式

  4. 在 GitHub Desktop 中观察所有文件更改

  5. 提交您的更改

当你在 Angular 中使用内联模板时,内联的 HTML 部分由 Prettier 而不是 Beautify 格式化。在这些情况的大多数中,你的代码看起来会很好,但如果你的 HTML 元素有太多属性,你的代码将以非常冗长的方式格式化。为了防止这种情况发生,你可以选择相关的 HTML 代码,并在 VS Code 中运行 美化选择 命令。如果你在模板属性上方添加 // prettier-ignore,Prettier 将停止干扰你的美化 HTML。

现在,让我们配置我们的 linting 脚本。

实现一个 lint 检查器和修复器

package.json 中已经存在一个 lint 命令。我们用我们自己的命令覆盖现有的 lint 命令并实现一个额外的 lint:fix 命令。

添加新的脚本:

  1. 编辑 package.json 脚本属性以替换 lint 并添加 lint:fix 命令:

    **package.json**
    ...
      "scripts": {
      ...
        "lint": "tslint --config tslint.json --project . -e \"**/{test,polyfills}.ts\"",
        "lint:fix": "tslint --config tslint.json --fix --project . -e \"**/{test,polyfills}.ts\"",  ...
      }
    ... 
    

    注意,与样式脚本不同,我们排除了 test.tspolyfills.ts 的 lint 检查。这些文件带有 linting 错误;它们不太可能经常被编辑,并且由于它们对我们的代码质量没有影响,我们可以安全地忽略它们。

  2. 执行 npm run lint 以查看有 linting 错误的文件

  3. 执行 npm run lint:fix 以修复任何可自动修复的错误

  4. 如果还有进一步的错误,请按 Ctrl/cmd + click 点击文件并手动修复错误

  5. 在 GitHub Desktop 中观察所有文件更改

  6. 提交你的更改

  7. 不要忘记将你的更改推送到你的仓库!

有时,当你使用 Angular CLI 输入新代码或生成新组件时,可能会遇到双引号或分号被红色波浪线划下,以表示存在问题。我们已经配置了 VS Code 在保存文件时自动格式化文件,这会在窗口失去焦点时自动发生。当自动格式化被触发时,文件会更新,与格式化相关的错误会消失。

当我们在 第四章 中介绍 CI(持续集成)时,自动化测试、CI 和发布到生产,我们将运行我们的样式和 lint 检查器作为我们管道的一部分。

接下来,配置 ng 工具以在终端中获得自动完成功能。

配置 Angular CLI 自动完成

当使用 Angular CLI 时,你可以在终端中获得自动完成体验。执行适用于你的 *nix 环境的相应命令:

  • 对于 bash shell:

    $ ng completion --bash >> ~/.bashrc
    $ source ~/.bashrc 
    
  • 对于 zsh shell:

    $ ng completion --zsh >> ~/.zshrc
    $ source ~/.zshrc 
    
  • 对于使用 Git bash shell 的 Windows 用户:

    $ ng completion --bash >> ~/.bash_profile
    $ source ~/.bash_profile 
    

接下来,让我们了解 VS Code 自动修复器。

VS Code 自动修复器

有时,一个黄色灯泡图标会出现在代码行旁边。这可能是由于你输入了一些违反 tslint.json 中定义的规则的代码。如果你点击灯泡,你会看到一个标记为 修复 的操作。你可以利用这些自动修复器让 VS Code 自动修复你的代码。下面的截图显示了 不必要的分号 问题的示例:

图 B14094_02_07

图 2.7:VS Code 自动修复器

恭喜你 – 你已经完成了开发环境的设置!

摘要

在本章中,你掌握了使用基于 CLI 的包管理器在 Windows 和 macOS 上的使用方法,以加快并自动化你和你同事的开发环境设置。你还创建了你的第一个 Angular 项目,并使用 Visual Studio Code 对其配置进行了优化,以用于开发。然后,你实现了自动化的样式检查器和修复器,以强制执行团队中的编码标准和样式。你实现的 lint 检查器和修复器将自动捕获潜在的编码错误和维护性问题。

你创建的自动化脚本将规范你的团队规范,并为新成员和现有成员提供文档。通过减少一个开发者的环境与另一个开发者环境之间的差异,你的团队可以更有效地克服任何个人配置问题,并专注于手头的任务执行。有了对共同环境的集体理解,团队中的任何个人都不必承担帮助解决其他人的问题的负担。同样的想法也适用于你的代码文件的结构和样式。

当团队成员查看另一位团队成员的代码时,它们在风格上看起来完全相同,这使得调试和解决问题变得更加容易。因此,你的团队将更加高效。通过利用更复杂和有弹性的工具,中大型组织可以在其 IT 预算中实现相当大的节省。

在下一章中,你将学习更多关于 Angular 平台的知识,利用 GitHub 项目和使用 GitHub 问题进行 Kanban,学习 Angular 基础知识以构建具有全栈架构的简单 Web 应用,并了解使用 RxJS 的响应式编程。

进一步阅读

关于通过 Vishwas Parameshwarappa 撰写的自动化本地开发者机器设置的文章是一个很好的起点,使用 Vagrant,位于www.vagrantup.com。你可以在这里找到文章Red-gate.com/simple-talk/sysadmin/general/automating-setup-local-developer-machine

其他工具包括位于www.chef.io/的 Chef 和位于puppet.com的 Puppet。一些开发者更喜欢在编码时使用 Docker 容器,位于www.docker.com。这样做是为了隔离不同的 SDK 版本。特定的开发工具不能限定在给定的文件夹中,必须全局或跨操作系统安装,这使得同时处理多个项目变得非常困难。如果你能避免这种设置,我建议远离这种设置。在未来,我预计随着 CPU 核心数量的增加和虚拟化技术的硬件加速,这类任务将由 IDE 自动化。

我们将在本书稍后使用 Docker,但我们将用它来隔离我们的生产软件依赖项,使其与周围元素(如我们的本地开发环境或云服务器)隔离开。

问题

尽可能地回答以下问题,以确保您已经理解了本章的关键概念,而无需使用 Google。您需要帮助回答这些问题吗?请参阅附录 D自我评估答案,在线位于static.packt-cdn.com/downloads/9781838648800_Appendix_D_Self-Assessment_Answers.pdf或访问expertlysimple.io/angular-self-assessment

  1. 与 GUI 相比,使用 CLI 工具的动机是什么?

  2. 对于您的特定操作系统,建议使用哪个包管理器?

  3. 使用包管理器有哪些好处?

  4. 尽可能使您的开发团队成员的开发环境彼此相似有哪些好处?

第三章:创建基本的 Angular 应用

在本章中,我们将使用 Angular 和第三方 Web API 以及迭代开发方法设计并构建一个简单的本地天气应用。我们将专注于首先交付价值,同时了解 Angular、TypeScript、Visual Studio (VS) Code、响应式编程和 RxJS 的细微差别和最佳使用方式。在我们开始编码之前,我们需要构建一个功能路线图,创建我们打算构建的应用的 mock-up,并绘制我们应用的高级架构图。

您将了解 Angular 基础知识以构建一个简单的 Web 应用,并熟悉新的 Angular 平台和全栈架构。

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

  • 使用 GitHub 项目规划您的路线图

  • 使用看板板来启用协作和无缝的信息辐射

  • 使用 Angular 组件和 TypeScript 接口制作一个新的 UI 元素来显示当前天气信息

  • 使用 Angular 服务和 HttpClientOpenWeatherMap API 获取数据

  • 利用可观察流使用 RxJS 转换数据

  • Angular 中的空值保护

书中样本代码的最新版本可在以下链接的 GitHub 仓库中找到。该仓库包含代码的最终和完成状态。您可以在本章末尾通过查找 projects 文件夹下的章节末尾代码快照来验证您的进度。

对于 第三章

  1. 克隆仓库 github.com/duluca/local-weather-app

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

  3. 本章的代码示例位于子文件夹:

    projects/ch3 
    
  4. 要运行本章的 Angular 应用,请执行:

    npx ng serve ch3 
    

注意,书中或 GitHub 上的源代码可能并不总是与 Angular CLI 生成的代码相匹配。由于生态系统不断演变,书中代码和 GitHub 上的代码在实现上可能也存在细微差异。样本代码随时间变化是自然的。在 GitHub 上,您可能会找到更正、修复以支持库的新版本,或者为读者观察而并排实现多种技术的示例。读者只需实现书中推荐的理想解决方案即可。如果您发现错误或有疑问,请创建一个 issue 或在 GitHub 上提交一个 pull request,以惠及所有读者。

您可以在 附录 C 中了解更多关于更新 Angular 的信息,即 保持 Angular 和工具始终如一。您可以从 static.packt-cdn.com/downloads/9781838648800_Appendix_C_Keeping_Angular_and_Tools_Evergreen.pdfexpertlysimple.io/stay-evergreen 在线找到此附录。

让我们首先制定一个高级计划,以便在您开始编码之前了解要实现的内容。

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

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

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

在实现本地天气应用时,我们将利用 GitHub 项目作为看板。在企业中,你可以使用票据系统或能够保持待办事项、实施敏捷方法学和显示看板工具。在 GitHub 中,问题代表你的待办事项。你可以利用内置的项目标签页来定义一个代表发布或迭代的范围,从而建立看板。GitHub 项目直接集成到你的 GitHub 仓库的问题中,并通过标签跟踪问题的状态。这样,你就可以继续使用你选择的工具与你的仓库交互,并且毫不费力地传播信息。在下一节中,你将设置一个项目来实现这一目标。

设置 GitHub 项目

让我们设置一个 GitHub 项目:

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

  2. 切换到项目标签页。

  3. 点击创建新项目,如下面的截图所示图片

    图 3.1:在 GitHub 中创建新项目

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

  5. 选择一个项目模板,例如自动化看板

  6. 在本书的后面部分,我们将为你的 GitHub 项目启用 GitHub 流程。使用 GitHub 流程,你的仓库更改将通过拉取请求PR)进行处理。在未来,你可能想选择带有审查的自动化看板模板,该模板自动跟踪 PR 的状态,传播更多关于软件开发过程内部运作的详细信息。

  7. 点击创建项目

观察你的看板,它应该如下所示:

图片

图 3.2:你的项目看板

如果您的仓库中已有现有问题,您可能会被提示将卡片添加到您的看板。现在您可以安全地忽略它,稍后再用 + 添加卡片 按钮返回。您还会看到几个 待办 卡片。您可以自由地审查并删除这些卡片以清理您的看板。

如果您想跟踪每个发布或冲刺,可以为每个创建一个新的项目。创建新项目有助于跟踪特定发布或冲刺的完成百分比,但这会引入额外的管理开销。

接下来,我们将配置项目为看板板而不是 GitHub 项目,这是一种轻量级的方法,可以组织您的工作,您可能会选择它而不是其他方法,如 Scrum。

配置看板板

看板不定义您工作的正式迭代或发布。如果您想有一个低开销的过程,只与一个项目工作,可以通过向项目中引入待办事项列来实现。

现在,让我们添加一个待办事项列:

  1. 点击 + 添加列

  2. 列名称 中输入 Backlog

  3. 对于 预设 选择 待办

  4. 当以下条件满足时移动问题到这里 下,选择 新添加的,如图所示:社交媒体帖子的截图 自动生成的描述

    图 3.3:选择“新添加”的位置

  5. 点击 创建列

  6. 将列拖动到最左侧的位置。

使用此设置,新问题被添加到 待办事项 中,允许您手动维护您打算在 待办 列表中工作的项目。

为本地天气应用程序创建待办事项列表

让我们创建一个待办事项列表,以便在实现应用程序设计的过程中跟踪您的进度。在创建问题时,您应该专注于交付具有用户价值的功能性迭代。

您必须克服的技术障碍对您的用户或客户没有兴趣。

以下是我们在首次发布中计划构建的功能:

  • 显示当天的当前位置天气信息

  • 显示当前位置的预报信息

  • 添加城市搜索功能,以便用户可以看到其他城市的天气信息

  • 添加一个偏好设置面板以存储用户的默认城市

  • 使用 Angular Material 改进应用程序的 UX

让我们再添加一些我们不会在本书中实现的功能,以此展示待办事项如何捕捉您的想法:

  • 添加身份验证,以便用户可以从任何浏览器检索数据

  • 添加 HTML5 地理位置支持

  • 使用 localStorage 缓存用户偏好设置

您可以自由地向待办事项添加您能想到的其他功能。

首先,将前面的功能作为 GitHub 上的问题创建。确保将每个新问题分配给本章早期创建的项目。一旦创建,将前面定义的功能移动到待办列。当您开始处理一个任务时,将卡片移动到进行中列,当它完成时,将其移动到完成列。以下是我们计划开始工作的第一个功能——显示当前日期当前位置的天气信息的板子样子:

图片

图 3.4:GitHub 上板子初始状态的一张快照

注意,我还添加了一个创建应用原型的问题,并将其移动到完成状态,这将在下一节中介绍。此外,GitHub 可能会在您打开和关闭它们时自动将卡片从一个状态移动到另一个状态。

最终,GitHub 项目提供了一个易于使用的图形用户界面,以便非技术人员可以轻松地与 GitHub 问题进行交互。通过允许非技术人员参与 GitHub 上的开发过程,您可以解锁 GitHub 成为您整个项目单一信息源的好处。关于功能和问题的疑问、答案和讨论都被跟踪为 GitHub 问题的组成部分,而不是在电子邮件中丢失。您还可以在 GitHub 上存储类似维基的文档。因此,通过在 GitHub 上集中所有项目相关的信息、数据、对话和工件,您极大地简化了需要持续维护且成本高昂的多个系统的复杂交互。对于私有仓库和本地企业安装,GitHub 的成本非常合理。如果您坚持开源,就像我们在本章中所做的那样,所有这些工具都是免费的。

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

在有了路线图之后,您现在可以开始创建您应用程序的原型了。

线框设计

现在有很多优秀的工具可以制作看起来粗糙的模型,用丰富的功能来展示你的想法。如果你有一个专门的 UX 设计师,这些工具对于创建准原型来说非常棒。然而,作为一个全栈开发者,我发现最好的工具还是笔和纸。这样,你不必学习另一个工具YAT),而且这比完全没有设计要好得多。将事物放在纸上可以避免未来昂贵的编码错误,如果你能在用户验证你的线框设计之前就进行验证,那就更好了。我的应用叫做 LocalCast Weather,但请发挥创意,选择你自己的名字。看看,这是你的天气应用的线框设计:

图片

图 3.5:LocalCast 的手绘线框图。(提示:我确实使用了尺子!)

线框不需要太花哨。我建议从手绘设计开始,这非常快,而且能有效地传达粗略的轮廓。

现在有很多优秀的线框工具。我在这本书中建议并使用其中几个,然而,在你项目的最初几天,每一小时都很重要。

当然,这种粗略的设计可能永远不会超出你团队的边界,但请知道,没有什么能比将你的想法写在纸上或白板上更快地获得即时反馈和协作。

高级架构

无论你的项目大小如何,开始时有一个可以扩展的坚实基础架构至关重要。大多数时候,你无法提前准确预测项目的大小。坚持在第一章Angular 及其概念简介中讨论的架构基础,可以导致一个不会过于繁重的架构,这样你可以快速执行一个简单的应用想法。关键是从一开始就确保适当的解耦。

在我看来,解耦有两种类型。一种叫做软解耦,这里指的是通过“绅士协议”来避免混合关注点,并尽量不破坏代码库。这可以应用到你所编写的代码,甚至到基础设施级别的交互。如果你让你的前端代码与后端代码保持相同的代码结构,并且如果你的 REST 服务器提供前端应用服务,那么你只是在实践软解耦。

你应该练习严格的解耦,这意味着前端代码位于一个独立的仓库中,永远不会直接调用数据库,并且完全托管在其自己的 Web 服务器上。这样,你可以确保在任何时候,你的 REST API 或前端代码都是完全可替换的,并且独立于其他代码。练习严格的解耦也有货币和安全方面的好处。你前端应用程序的托管和扩展需求肯定与后端不同,因此你可以相应地优化你的主机环境并节省资金。如果你只允许来自你的前端服务器的调用访问你的 REST API,你将大大提高你的安全性。考虑以下我们 LocalCast 天气应用的高级架构图:

图片

图 3.6:LocalCast 高级架构

高级架构显示,我们的 Angular Web 应用完全解耦于任何后端。它托管在其自己的 Web 服务器上,可以与 Web API(如OpenWeatherMap)通信,或者可选地与后端基础设施配对,以解锁 Web API 单独无法提供的丰富和定制化功能,例如存储每个用户的偏好或用我们自己的数据集补充 OpenWeatherMap API 的数据集。

无论你的后端技术是什么,我建议你的前端始终位于其仓库中,并使用不依赖于你的 API 服务器的 Web 服务器进行托管。

在第十章“RESTful API 和全栈实现”中,你将深入了解如何将 MongoDB、Express、Angular 和 Node 组成的 MEAN 栈应用在实践中结合起来。

现在我们已经有了我们的功能、线框设计和高级架构,我们可以开始实现我们的应用。

使用组件和接口制作 UI 元素

在第二章“设置你的开发环境”中,你应该已经创建了一个 Angular 应用。我们将以此作为起点。如果你还没有这样做,请回到第二章“设置你的开发环境”,并创建你的项目。

在本节中,你将利用 Angular 组件、接口和服务以解耦、一致和封装的方式构建当前天气功能。

默认情况下,Angular 应用的着陆页位于app.component.html。因此,首先通过基本的 HTML 编辑AppComponent的模板,为应用布局初始着陆体验。

我们现在开始开发功能 1:显示当前日期的当前位置天气信息,这样你就可以将 GitHub 项目中的卡片移动到进行中列。

  1. 删除模板文件app.component.html中任何现有的代码

  2. 添加一个h1标签作为标题,然后是我们的应用标语作为div标签,以及我们可能想要显示当前天气的占位符,如下面的代码块所示:

    **src/app/app.component.html**
    <div style="text-align:center">
      <h1>
        LocalCast Weather
      </h1>
      <div>Your city, your forecast, right now!</div>
      <h2>Current Weather</h2>
      <div>current weather</div>
    </div> 
    
  3. component类中移除未使用的title属性,使其为空

    **src/app/app.component.ts**
    import { Component } from '@angular/core'
    @Component({
      selector: 'app-root',
      templateUrl: './app.component.html',
      styleUrls: ['./app.component.css'],
    })
    export class AppComponent {} 
    
  4. 在终端中执行npm start

  5. 在你的浏览器中导航到http://localhost:5000

现在,你应该能够在浏览器中实时观察你所做的更改。

注意,你应该使用 VS Code 中的集成终端来运行命令,这样你就不必在不同的窗口之间跳转。在 Windows 上使用[CTRL] + `` ,在 Mac 上使用`[^] + `` ` 来打开终端。如果你不熟悉,`是一个反引号,通常与~(波浪号)在同一键上。

添加 Angular 组件

我们需要显示当前的天气信息,其中<div>current weather</div>所在的位置。为了实现这一点,我们需要构建一个负责显示天气数据的组件。

创建独立组件的原因是编码在模型-视图-视图模型MVVM)设计模式中的架构最佳实践。你可能之前听说过模型-视图-控制器MVC)模式。大约在 2005-2015 年间编写的绝大多数基于 Web 的代码都是按照 MVC 模式编写的。MVVM 模式与 MVC 模式在有意义的方式上有所不同,正如我在 2013 年 DevPro 上的文章中解释的那样:

MVVM 的有效实现内在地强制执行适当的关注点分离。业务逻辑与表示逻辑明显分离。因此,当开发视图时,它将保持开发状态,因为修复一个视图功能中的错误不会影响其他视图。另一方面,如果你有效地使用视觉继承并创建可重用的用户控件,修复一个地方中的错误可以修复整个应用程序中的问题。

Angular 提供了 MVVM 的实用实现:

视图模型优雅地封装了任何表示逻辑,并通过充当模型的专用版本来允许更简单的视图代码。视图和视图模型之间的关系简单明了,允许更自然地将 UI 行为封装在可重用的用户控件中。

你可以在bit.ly/MVVMvsMVC上阅读更多关于架构细微差别和插图的信息。

接下来,你使用 Angular CLI 的ng generate命令创建你的第一个 Angular 组件,该组件包括视图和视图模型:

  1. 在终端中执行npx ng generate component current-weather

    确保你是在local-weather-app文件夹下执行ng命令,而不是在初始化项目的父文件夹下。此外,请注意npx ng generate component current-weather可以重写为ng g c current-weather。本书将使用简写格式并期望你在必要时添加npx

  2. 观察在app文件夹中创建的新文件:

    **src/app**
    ├── app.component.css
    ├── app.component.html
    ├── app.component.spec.ts
    ├── app.component.ts
    ├── app.module.ts
    ├── current-weather
      ├── current-weather.component.css
      ├── current-weather.component.html
      ├── current-weather.component.spec.ts
      └── current-weather.component.ts 
    

    生成的组件有四个部分:

    • current-weather.component.css包含任何特定于组件的 CSS,这是一个可选文件。

    • current-weather.component.html 包含定义组件外观和绑定渲染的 HTML 模板,可以被认为是 View,结合任何使用的 CSS 样式。

    • current-weather.component.spec.ts 包含基于 Jasmine 的单元测试,你可以扩展这些测试来测试你的组件功能。

    • current-weather.component.ts 包含在类定义之上的 @Component 装饰器,并且是连接 CSS、HTML 和 JavaScript 代码的粘合剂。这个类本身可以被视为 ViewModel,从服务中获取数据并执行任何必要的转换,以便为 View 提供合理的绑定,如下所示:

    **src/app/current-weather/current-weather.component.ts**
    import { Component, OnInit } from '@angular/core'
    @Component({
      selector: 'app-current-weather',
      templateUrl: './current-weather.component.html',
      styleUrls: ['./current-weather.component.css'],
    })
    export class CurrentWeatherComponent implements OnInit {
      constructor() {}
      ngOnInit() {}
    } 
    

    如果你计划编写的组件很简单,你可以使用内联样式和内联模板来简化你的代码结构。如果我们使用内联模板和样式重写上面的组件,它将看起来像以下示例:

    **example**
    import { Component, OnInit } from '@angular/core'
    @Component({
      selector: 'app-current-weather', 
      template: `
      <p>
        current-weather works!
      </p>
      `,
      styles: []
    })
    export class CurrentWeatherComponent implements OnInit {
      constructor() {}
      ngOnInit() {}
    } 
    

    然而,我们不会内联这个模板。所以,保持你的生成代码不变。

    注意,模板被反引号字符 ` 包围,而不是单引号字符。反引号字符定义了一个模板字面量,允许定义换行而不必使用加号运算符连接字符串。你可以在 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals 上了解更多关于模板字面量的信息。

    当你执行 generate 命令时,除了创建组件外,该命令还将在应用程序的根模块 app.module.ts 中添加你创建的新组件,从而避免了将组件连接在一起的繁琐任务:

    **src/app/app.module.ts**
    ...
    import { 
      CurrentWeatherComponent 
    } from './current-weather/ current-weather.component'
    ...
    @NgModule({
    declarations: [
        AppComponent, 
        CurrentWeatherComponent
      ],
    ... 
    

    Angular 的引导过程确实有些复杂。这正是 Angular CLI 存在的主要原因。index.html 包含一个名为 <app-root> 的元素。当 Angular 开始执行时,它首先加载 main.ts,该文件配置了框架以供浏览器使用并加载应用程序模块。然后应用程序模块加载所有其依赖项,并在上述 <app-root> 元素内渲染。在 第七章创建以路由为第一线的业务应用 中,当我们构建业务应用时,我们创建功能模块以利用 Angular 的可伸缩性功能。

    现在,我们需要在初始的 AppComponent 模板中显示我们的新组件,以便最终用户可以看到。

  3. 通过将 <div>current weather</div> 替换为 <app-current-weather></app-current-weather> 来将 CurrentWeatherComponent 添加到 AppComponent

    **src/app/app.component.html**
    <div style="text-align:center">
      <h1>
        LocalCast Weather
      </h1>
      <div>Your city, your forecast, right now!</div>
      <h2>Current Weather</h2>
      <app-current-weather></app-current-weather>
    </div> 
    
  4. 如果一切正常,你应该会看到以下内容:

图 3.7:本地天气应用的初始渲染

注意浏览器窗口标签页中的图标和名称。作为网络开发的标准,在index.html文件中,更新<title>标签和favicon.ico文件以包含应用程序的名称和图标,以自定义浏览器标签页信息。如果你的 favicon 没有更新,请将href属性附加一个唯一的版本号,例如href="favicon.ico?v=2"。结果,你的应用程序将开始看起来像一个真正的网络应用程序,而不是 CLI 生成的启动项目。

既然你已经看到了 Angular 组件的实际应用,让我们来了解一下其背后的基础知识。

揭秘 Angular 组件

如同在第一章Angular 及其概念简介中所述,Angular 组件是以 ES2015 类的方式实现的,这使得我们能够利用面向对象编程(OOP)的概念。类在强类型语言中是传统存在的,因此 JavaScript 作为动态类型语言实现类是非常出色的。类允许我们将功能和行为封装在自包含的单元(对象)中。我们可以用非常通用和抽象的方式定义行为,并实现继承层次结构以共享和将行为转化为不同的实现。

考虑到下面的CurrentWeatherComponent类,我可以强调一些类的优点:

@Component(...)
export class CurrentWeatherComponent implements OnInit {
  constructor() {}
  ngOnInit() {}
} 

与函数不同,你无法直接在类中使用代码。它必须使用 new 关键字实例化为一个对象。这意味着我们可以有任意给定类的多个实例,每个对象都可以保持其内部状态。在这种情况下,Angular 在幕后为我们实例化组件。类的constructor在其实例化时执行。你可以在构造函数中放置任何初始化其他类或变量的代码。然而,你不应该在构造函数中进行 HTTP 调用或尝试访问 DOM 元素。这就是OnInit生命周期钩子发挥作用的地方。

当 Angular 初始化CurrentWeatherComponent为对象时,它也在遍历整个模块、组件、服务和其它依赖关系的图,以确保所有相互依赖的代码都被加载到内存中。在此期间,Angular 还不能保证 HTTP 或 DOM 访问的可用性。所有类实例化完成后,Angular 会遍历带有@Component装饰器的类,实现OnInit接口,并在我们的类中调用ngOnInit函数。这就是为什么我们需要将需要在组件首次加载时进行 HTTP 或 DOM 访问的任何代码放入ngOnInit中。

类可以有属性、变量和函数。从 Angular 模板中,你可以访问表达式中的任何属性、变量或函数。表达式的语法看起来像{{ expression }}[target]="expression"(event)="expression"*ngIf="expression"

现在你已经很好地理解了代码,或者模板背后的视图模型(ViewModel)是如何实例化的,以及你如何从模板中访问这段代码。在下一节中,我们将构建一个接口,这是一个定义对象形状的合约。

使用接口定义你的模型

现在你的视图(View)和视图模型(ViewModel)已经就绪,你需要定义你的模型。如果你回顾一下设计,你会看到该组件需要显示:

  • 城市和国家

  • 当前日期

  • 当前图像

  • 当前温度

  • 当前天气描述

首先,你需要创建一个表示这种数据结构的接口。我们创建接口而不是类,因为接口是一种不包含任何实现的抽象。在创建触摸点或在不同组件之间传递数据时,如果我们依赖于抽象定义而不是可能实现不可预测的自定义行为的对象,我们可以确保一个解耦的设计,这可能导致错误。

首先创建接口:

  1. 在终端中执行npx ng generate interface ICurrentWeather

  2. 观察一个新创建的名为icurrent-weather.ts的文件,其中包含一个空接口定义,如下所示:

    **src/app/icurrent-weather.ts**
    export interface ICurrentWeather {
    } 
    

    这不是一个理想的环境,因为我们可能会向我们的应用程序添加许多接口,跟踪各种接口可能会变得繁琐。随着时间的推移,当你将这些接口的具体实现作为类添加时,将类及其接口放在它们的文件中是有意义的。

    为什么不直接将接口命名为CurrentWeather呢?这是因为,稍后我们可能会创建一个类来实现CurrentWeather的一些有趣行为。接口建立了一个合约,为任何实现或扩展该接口的类或接口上的可用属性建立列表。始终意识到你是在使用类还是接口非常重要。如果你遵循最佳实践,始终以大写I开始你的接口名称,你将始终意识到你正在传递的对象类型。因此,接口被命名为ICurrentWeather

  3. icurrent-weather.ts重命名为interfaces.ts

  4. 此外,按照以下方式实现接口:

    **src/app/interfaces.ts**
    export interface ICurrentWeather {
      city: string
      country: string 
      date: Date 
      image: string
      temperature: number 
      description: string
    } 
    

    这个接口及其最终作为类的具体表示是 MVVM 中的模型。到目前为止,我已经强调了 Angular 的各个部分如何符合 MVVM 模式;接下来,我将按照它们的实际名称来引用这些部分。

    现在,我们可以将接口导入组件中,并开始在CurrentWeatherComponent的模板中设置绑定。

  5. 导入ICurrentWeather

  6. 切换回templateUrlstyleUrls

  7. 定义一个名为current的局部变量,其类型为ICurrentWeather

    **src/app/current-weather/current-weather.component.ts**
    import { Component, OnInit } from '@angular/core' 
    import { ICurrentWeather } from '../interfaces'
    @Component({
      selector: 'app-current-weather',
      templateUrl: './current-weather.component.html',
      styleUrls: ['./current-weather.component.css'],
    })
    export class CurrentWeatherComponent implements OnInit {
      current: ICurrentWeather
      constructor() {}
      ngOnInit() {}
    } 
    

    如果你只输入current:ICurrentWeather,你可以使用 VS Code 中的自动修复功能来自动插入import语句。

    在构造函数中,你需要暂时用占位符数据填充current属性以测试你的绑定。

  8. 将模拟数据实现为一个 JSON 对象,并使用as运算符声明其遵循ICurrentWeather

    **src/app/current-weather/current-weather.component.ts**
    ...
    constructor() { 
      this.current = {
        city: 'Bethesda', 
        country: 'US', 
        date: new Date(),
        image: 'assets/img/sunny.svg', 
        temperature: 72,
        description: 'sunny',
      } as ICurrentWeather
    }
    ... 
    

    src/assets文件夹中,创建一个名为img的子文件夹,并将你选择的图片放置在那里以供你的模拟数据引用。

    你可能会忘记你创建的界面中的确切属性。你可以通过按住Ctrl并用鼠标悬停在界面名称上快速查看它们,如图所示:

    图片

    图 3.8:Ctrl + 悬停在界面上

    现在,更新模板以将绑定与基本的 HTML 布局连接起来。

  9. 开始实现模板:

    **src/app/current-weather/current-weather.component.html**
    <div>
      ...
    </div> 
    
  10. 在父div内部,定义另一个div来使用绑定显示城市和国家信息:

    <div>
      <span>{{current.city}}, {{current.country}}</span>
      ...
    </div> 
    

    注意,在span内部,你可以使用静态文本来定位两个属性。在这种情况下,citycountry由逗号分隔,后面跟着一个空格。

  11. citycountry下方,使用绑定和DatePipe定义属性显示格式来显示date

    <span>{{current.date | date:'fullDate'}}</span> 
    

    要更改current.date的显示格式,我们使用了上面的DatePipe,传入'fullDate'作为格式选项。在 Angular 中,可以使用各种内置和自定义的|操作符来更改数据的显示外观,而实际上并不改变底层数据。这是一个非常强大、方便且灵活的系统,可以共享这样的用户界面逻辑,而无需编写重复的模板代码。

    在前面的例子中,如果我们想以更紧凑的形式表示当前日期,我们可以传入'shortDate'。有关DatePipe的各种选项的更多信息,请参阅angular.io/api/common/DatePipe文档。

  12. 定义另一个div来显示温度信息,使用DecimalPipe格式化值,并将当前天气的图像绑定到一个img标签:

    <div>
      <img [src]='current.image'>
      <span>{{current.temperature | number:'1.0-0'}}˚F</span>
    </div> 
    

    我们使用方括号语法将图像属性绑定到img标签的src属性。接下来,我们使用DecimalPipe格式化current.temperature,以确保不显示小数值。文档在angular.io/api/common/DecimalPipe

    注意,你可以使用各自的 HTML 代码来渲染˚C 和˚F:&#8451;用于˚C,&#8457;用于˚F。

  13. 创建一个最终的div来显示描述属性:

    <div>
      {{current.description}}
    </div> 
    
  14. 你的最终模板应该如下所示:

    **src/app/current-weather/current-weather.component.html**
    <div>
      <div>
        <span>{{current.city}}, {{current.country}}</span>
        <span>{{current.date | date:'fullDate'}}</span>
      </div>
      <div>
        <img [src]='current.image'>
        <span>{{current.temperature | number:'1.0-0'}}˚F</span>
      </div>
      <div>
        {{current.description}}
      </div>
    </div> 
    
  15. 如果一切正常工作,你的应用应该看起来与这个截图相似:

图片

图 3.9:连接了模拟数据的 App

恭喜你 – 你已经成功连接了你的第一个组件!

现在让我们更新应用,以便可以从 Web API 中拉取实时天气数据。

使用 Angular 服务和 HttpClient 检索数据

现在您需要将您的 CurrentWeather 组件连接到 OpenWeatherMap API 以获取实时天气数据。然而,我们不想直接将此代码插入到我们的组件中。如果我们这样做,如果 API 发生变化,我们就必须更新组件。现在想象一个拥有数十或数百个视图的应用程序,想象这将如何创建一个重大的可维护性挑战。

相反,我们将利用一个 Angular 服务,一个单例类,它可以为我们组件提供当前的天气信息,并抽象出数据源。这种抽象将 UI 与 Web API 解耦。利用这种关注点的分离,在未来,我们可以增强我们的服务以从多个 API 或本地缓存中获取天气信息,而无需更改 UI 代码。

在接下来的章节中,我们将介绍以下步骤以实现这一目标:

  1. 创建一个新的 Angular 服务

  2. 导入 HttpClientModule 并将其注入到服务中

  3. 发现 OpenWeatherMap API

  4. 创建一个符合 API 形状的新的接口

  5. 编写一个 get 请求

  6. 将新服务注入到 CurrentWeather 组件中

  7. CurrentWeather 组件的 ngOnInit 函数中调用服务

  8. 最后,使用 RxJS 函数将 API 数据映射到本地的 ICurrentWeather 类型,以便您的组件可以消费它

创建一个新的 Angular 服务

任何超出组件边界之外的代码都应该存在于服务中;这包括组件间的通信(除非存在父子关系)、任何类型的 API 调用,以及任何从 cookie 或浏览器的 localStorage 中缓存或检索数据的代码。这是一个关键的建筑模式,可以确保您的应用程序在长期内可维护。我在我的 DevPro MVVM 文章中扩展了这个想法,链接为 www.itprotoday.com/microsoft-visualstudio/mvvm-and-net-great-combo-web-application-development

要创建一个 Angular 服务,请使用 Angular CLI:

  1. 在终端中,执行 npx ng g s weather --flat false

  2. 观察新创建的 weather 文件夹:

    **src/app**
    ...
    └── weather
    ├── weather.service.spec.ts
    └── weather.service.ts 
    

CLI 生成的服务有两个部分:

  • weather.service.spec.ts 包含基于 Jasmine 的单元测试,您可以扩展以测试服务功能。

  • weather.service.ts 包含在类定义之上的 @Injectable 装饰器,这使得可以将此服务注入到其他组件中,利用 Angular 的提供者系统。这确保了我们的服务是单例的,意味着它只实例化一次,无论它在其他地方被注入多少次。

服务生成如下所示:

**src/app/weather/weather.service.ts**
import { Injectable } from '@angular/core'
@Injectable({
  providedIn: 'root',
})
export class WeatherService {
  constructor() {}
} 

注意,providedIn 属性确保根模块在 app.module.ts 中提供天气服务。

接下来,让我们看看 Angular 中的依赖注入机制,它允许服务、组件或模块之间使用其他服务、组件或模块,而无需开发者管理共享对象的实例化。

依赖注入

要进行 API 调用,你需要利用 Angular 中的HttpClient模块。官方文档(angular.io/guide/http)简洁地解释了此模块的优点:

"使用 HttpClient,@angular/common/http 为 Angular 应用程序提供了一个简化的 HTTP 功能 API,它建立在浏览器暴露的 XMLHttpRequest 接口之上。HttpClient 的其他优点包括测试支持、请求和响应对象的强类型、请求和响应拦截器支持,以及基于 Observables 的 API 提供的更好的错误处理。"

让我们从将HttpClientModule导入我们的应用程序开始,这样我们就可以将模块提供的HttpClient注入到WeatherService中:

  1. HttpClientModule添加到app.module.ts中,如下所示:

    **src/app/app.module.ts**
    ... 
    import { HttpClientModule } from '@angular/common/http' 
    ... 
    @NgModule({ 
      ... 
      imports: [..., HttpClientModule]
      ...
    }) 
    
  2. HttpClient注入到WeatherService中,如下所示:

    **src/app/weather/weather.service.ts**
    import { HttpClient } from '@angular/common/http'
    import { Injectable } from '@angular/core'
    @Injectable()
    export class WeatherService {
      constructor(private httpClient: HttpClient) {}
    } 
    

现在,httpClient已准备好在你的服务中使用。

发现 OpenWeatherMap API

由于httpClient是强类型的,我们需要创建一个新的接口,该接口符合我们将要调用的 API 的形状。为了能够做到这一点,你需要熟悉当前天气数据 API:

  1. 通过导航到openweathermap.org/current阅读文档:

    图 3.10:OpenWeatherMap 当前天气数据 API 文档

    你需要使用名为按城市名称的 API,它允许你通过提供城市名称作为参数来获取当前天气数据,这样我们的网络请求看起来如下所示:

    api.openweathermap.org/data/2.5/weather?q={city name},{country code} 
    
  2. 在文档页面上,点击API 调用示例下的链接,你将看到如下所示的示例响应:

    **http://samples.openweathermap.org/data/2.5/weather?q=London,uk&appid=b1b15e88fa797225412429c1c50c122a1**
    {
      "coord": { 
        "lon": -0.13,
        "lat": 51.51
      },
      "weather": [
        {
          "id": 300,
          "main": "Drizzle",
          "description": "light intensity drizzle",
          "icon": "09d"
        }
      ],
      "base": "stations",
      "main": {
        "temp": 280.32,
        "pressure": 1012,
        "humidity": 81,
        "temp_min": 279.15,
        "temp_max": 281.15
      },
      "visibility": 10000,
      "wind": {
        "speed": 4.1,
        "deg": 80
      },
      "clouds": {
      "all": 90
      },
      "dt": 1485789600,
      "sys": {
        "type": 1,
        "id": 5091,
        "message": 0.0103,
        "country": "GB",
        "sunrise": 1485762037,
        "sunset": 1485794875
      },
      "id": 2643743,
      "name": "London",
      "cod": 200
    } 
    

    给定你已创建的现有ICurrentWeather接口,此响应包含的信息比你需要的多。你需要编写一个新的接口,该接口符合此响应的形状,但仅指定你打算使用的数据部分。此接口仅存在于WeatherService中,我们不会将其导出,因为应用程序的其他部分不需要了解此类型。

  3. weather.service.ts中的import@Injectable语句之间创建一个名为ICurrentWeatherData的新接口

  4. 新接口应该如下所示:

    **src/app/weather/weather.service.ts**
    interface ICurrentWeatherData { 
      weather: [{
        description: string,
        icon: string
      }],
      main: {
        temp: number
      },
      sys: {
        country: string
      },
      dt: number,
      name: string
    } 
    

使用ICurrentWeatherData接口,我们通过向接口添加具有不同结构的子对象来定义新的匿名类型。这些对象中的每一个都可以单独提取出来并定义为自己的命名接口。特别注意的是,weather是一个具有descriptionicon属性的匿名类型的数组。

接下来,让我们学习如何将环境变量引入到你的 Angular 应用程序中,以便你的应用程序的测试和产品版本可以依赖不同的值。

存储环境变量

容易被忽略的是,前几节中的示例 URL——http://samples.openweathermap.org/data/2.5/weather?q=London,uk&appid=b1b15e88fa797225412429c1c50c122a1——包含一个必需的appid参数。你必须将此密钥存储在你的 Angular 应用程序中。你可以将其存储在天气服务中,但现实中,应用程序需要能够在从开发到测试、预生产和生产环境迁移时针对不同的资源集。默认情况下,Angular 提供了两个环境:一个是prod,另一个是默认环境。

在你继续之前,你需要注册一个免费的OpenWeatherMap账户并获取你的appid。你可以在openweathermap.org/appid上阅读有关appid的文档以获取更多信息。

  1. 复制你的appid,它是一串由字符和数字组成的长字符串

  2. environment.ts中存储你的appid

  3. 为后续使用配置baseUrl

    **src/environments/environment.ts**
    export const environment = {
      production: false,
      appId: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
      baseUrl: 'http://',
    } 
    

在代码中,我们使用驼峰式appId来保持我们的编码风格一致。

由于 URL 参数不区分大小写,appIdappid都可以使用。

接下来,让我们实现一个 HTTP GET 来获取当前的天气数据。

实现 HTTP GET 操作

现在,我们可以在WeatherService类中实现 GET 调用:

  1. WeatherService类中添加一个名为getCurrentWeather的新函数

  2. 导入environment对象

  3. 实现 HTTP GET 函数

  4. 返回 HTTP 调用的结果:

    **src/app/weather/weather.service.ts**
    import { HttpClient } from '@angular/common/http'
    import { environment } from '../../environments/environment'
    ...
    export class WeatherService {
      constructor(
        private httpClient: HttpClient
      ) { }
    getCurrentWeather(city: string, country: string) { 
      return this.httpClient
        .get<ICurrentWeatherData>(
        `${environment.baseUrl}api.openweathermap.org/data/2.5/weather?` +
            `q=${city},${country}&appid=${environment.appId}`
        )
      }
    } 
    

    注意 ES2015 的字符串插值功能的使用。你不需要像environment.baseUrl + 'api.openweathermap.org/data/2.5/weather?q=' + city + ',' + country + '&appid=' + environment.appId那样通过将变量一个接一个地附加来构建你的字符串,你可以使用反引号语法来包裹 ``your string`` ``。在反引号内部,你可以使用换行符,并通过使用${dollarbracket}语法直接在字符串流中嵌入变量。然而,当你代码中引入换行符时,它被解释为一个字面换行符\n。为了在代码中分割字符串,你可以添加一个反斜杠`,但这样你的代码的下一行就不能有缩进。正如前面的代码示例所示,直接连接多个模板会更简单。

    使用长而复杂的字符串是一个容易出错的流程。相反,我们可以使用HttpParams对象来程序化地构建 URL。

  5. 利用HttpParams简化 URL:

    **src/app/weather/weather.service.ts**
    import { HttpClient, HttpParams } from '@angular/common/http'
    import { environment } from '../../environments/environment'
    ...
    export class WeatherService {
      constructor(private httpClient: HttpClient) { }
      getCurrentWeather(city: string, country: string) {
        const uriParams = new HttpParams()
          .set('q', `${city},${country}`)
          .set('appid', environment.appId)
        return this.httpClient
          .get<ICurrentWeatherData>(
        `${environment.baseUrl}api.openweathermap.org/data/2.5/weather`,
            { params: uriParams }
          )
      }
    } 
    

现在,让我们连接这些点,以便我们可以通过利用天气服务从CurrentWeather组件中获取当前的天气数据。

从组件中检索服务数据

要能在CurrentWeather组件中使用getCurrentWeather函数,你需要将该服务注入到组件中:

  1. WeatherService注入到CurrentWeatherComponent类的构造函数中

  2. 在构造函数中移除创建模拟数据的现有代码:

    **src/app/current-weather/current-weather.component.ts**
    constructor(private weatherService: WeatherService) { } 
    

    注意使用 TypeScript 泛型与get函数的尖号语法,如<TypeName>。使用泛型是一个开发时提高生活质量的特性。通过向函数提供类型信息,输入和/或返回变量的类型会在你编写代码时显示,并在开发和编译时进行验证。

  3. ngOnInit函数内部调用getCurrentWeather函数:

    **src/app/current-weather/current-weather.component.ts**
    ngOnInit() { 
      this.weatherService.getCurrentWeather('Bethesda', 'US')
        .subscribe((data) => this.current = data)
    } 
    

提醒一下:不要期望这段代码立即就能工作,因为data的类型是ICurrentWeatherData,而current的类型是ICurrentWeather。你可以观察错误,它应该会提示"error TS2322: 类型 'Observable<ICurrentWeatherData>' 无法分配给类型 'Observable<ICurrentWeather>'。"让我们看看下一部分的内容。

Angular 组件拥有丰富的生命周期钩子,允许你在组件渲染、刷新或销毁时注入自定义行为。ngOnInit()是你将最常使用的生命周期钩子。它只调用一次,当组件首次实例化或访问时。这就是你想要执行服务调用的地方。要深入了解组件生命周期钩子,请查看angular.io/guide/lifecycle-hooks中的文档。

注意,你传递给subscribe的匿名函数是一个 ES2015 箭头函数。如果你不熟悉箭头函数,一开始可能会感到困惑。箭头函数非常优雅且简单。考虑以下箭头函数:

(data) => { this.current = data } 

你可以简单地将其重写为:

function(data) { this.current = data } 

有一个特殊条件——当你编写一个转换数据的箭头函数时,例如:

(data) => { data.main.temp } 

此函数有效地将ICurrentWeatherData作为输入,并返回temp属性。返回语句是隐式的。如果你将其重写为一个普通函数,它看起来如下所示:

function(data) { return data.main.temp } 

CurrentWeather组件加载时,ngOnInit会触发一次,调用getCurrentWeather函数,该函数返回一个类型为Observable<ICurrentWeatherData>的对象。

可观察对象是 RxJS 中最基本的构建块,它代表了一个事件发射器,它会在一段时间内发射任何接收到的数据,其类型如官方文档中所述的ICurrentWeatherData

可观察对象本身是良性的,除非被监听,否则不会通过网络发送请求。你可以在reactivex.io/rxjs/class/es6/Observable.js~Observable.html了解更多关于可观察对象的信息。

通过在 Observable 上调用 .subscribe,你实际上是在将一个监听器附加到发射器上。你在 subscribe 方法中实现了一个匿名函数,该函数在接收到新数据并发出事件时执行。匿名函数接受一个数据对象作为参数,在这种情况下,具体的实现将数据块分配给名为 current 的局部变量。每当 current 更新时,你之前实现的模板绑定会拉入新数据并在视图中渲染它。即使 ngOnInit 只执行一次,对 Observable 的订阅仍然存在。因此,每当有新数据时,current 变量就会更新,视图会重新渲染以显示最新数据。

当前错误的根本原因是正在发出的数据类型为 ICurrentWeatherData;然而,我们的组件只能理解按照 ICurrentWeather 接口描述的形状的数据。在下一节中,你需要更深入地了解 RxJS,以了解如何最好地完成这项任务。

注意,VS Code 和 CLI 有时会出现停止工作的情况。如前所述,当你编码时,npm start 命令正在 VS Code 的集成终端中运行。Angular CLI 与 Angular 语言服务插件结合使用,持续监视代码更改并将你的 TypeScript 代码转换为 JavaScript,以便你可以在浏览器中使用实时重新加载来观察你的更改。好事是,当你犯编码错误时,除了 VS Code 中的红色下划线外,你还会在终端或浏览器中看到一些红色文本,因为转换失败了。在大多数情况下,当你纠正错误时,红色下划线会消失,Angular CLI 会自动重新转换你的代码,一切都会正常工作。然而,在特定场景中,请注意 VS Code 无法在 IDE 中识别类型更改,因此你将不会获得自动完成帮助,或者 CLI 工具可能会因为显示 webpack: Failed to compile 的消息而卡住。

你有两种主要策略来从这种状态中恢复:

  • 点击终端并按 Ctrl + C 停止运行 CLI 任务,然后通过执行 npm start 重新启动。

  • 如果这不起作用,请使用 Alt + F4(Windows)或 ![](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/ng-entp-rdy-webapp/img/B14094_02_0012.png) + Q(macOS)退出 VS Code,并重新启动它。鉴于 Angular 和 VS Code 的月度发布周期,我坚信随着时间的推移,工具将只会变得更好。

让我们通过转换数据形状来解决类型不匹配问题。

使用 RxJS 转换数据

我们将使用 RxJS 的响应式管道(或数据流)来重塑来自外部 API 的数据结构,以适应我们 Angular 应用中预期的数据形状。如果我们不这样做,那么我们的代码将因类型不匹配错误而失败。

参考第一章,Angular 及其概念简介,以深入了解 RxJS 和响应式编程。

实现响应式转换

为了避免未来出现从你的服务中返回不期望的数据类型等错误,你需要更新 getCurrentWeather 函数,将其返回类型定义为 Observable<ICurrentWeather> 并导入 Observable 类型,如下所示:

**src/app/weather/weather.service.ts**
import { Observable } from 'rxjs'
import { ICurrentWeather } from '../interfaces'
...
export class WeatherService {
  ...
  getCurrentWeather(city: string, country: string): 
    Observable<ICurrentWeather> {
  }
  ...
} 

现在,VS Code 告诉你,类型 Observable<ICurrentWeatherData> 不能赋值给类型 Observable<ICurrentWeather>:

  1. 编写一个名为 transformToICurrentWeather 的转换函数,该函数可以将 ICurrentWeatherData 转换为 ICurrentWeather

  2. 此外,编写一个名为 convertKelvinToFahrenheit 的辅助函数,该函数可以将 API 提供的开尔文温度转换为华氏温度:

    **src/app/weather/weather.service.ts**
    export class WeatherService {
    ...
      private transformToICurrentWeather(data: ICurrentWeatherData): ICurrentWeather {
        return {
          city: data.name,
          country: data.sys.country,
          date: data.dt * 1000,
          image:
    `http://openweathermap.org/img/w/${data.weather[0].icon}.png`, 
          temperature: this.convertKelvinToFahrenheit(data.main.temp), 
          description: data.weather[0].description,
        }
      }
      private convertKelvinToFahrenheit(kelvin: number): number
      { 
        return kelvin * 9 / 5 - 459.67
      }
    } 
    

    注意,你需要在当前阶段将图标属性转换为图像 URL。在服务中这样做有助于保持封装;在视图模板中将图标值绑定到 URL 上会破坏关注点分离SoC)原则。如果你希望创建真正模块化、可重用和可维护的组件,你必须保持警惕并严格执行 SoC。有关天气图标和如何形成 URL 的详细说明,包括所有可用的图标,可以在openweathermap.org/weather-conditions找到。

    另一方面,可以说开尔文到华氏的转换是视图关注点,但我们已经在服务中实现了它。这个论点站得住脚,特别是考虑到我们有一个计划中的功能,可以切换摄氏度和华氏度。反论点是,目前我们只需要显示华氏温度,并且将单位转换为天气服务的一部分工作。这个论点也有道理。最终的实现是编写一个自定义的 Angular 管道并将其应用于模板。管道可以轻松地与计划中的切换按钮绑定。

    然而,目前我们只需要显示华氏温度,我会选择过度设计解决方案。

  3. ICurrentWeather.date 更改为 number 类型

    在编写转换函数时,请注意 API 以数字形式返回日期。这个数字代表自 Unix 纪元(时间戳)以来的秒数,即 1970 年 1 月 1 日 00:00:00 UTC。然而,ICurrentWeather期望一个Date对象。通过将其传递给Date对象的构造函数,如new Date(data.dt),转换时间戳是很容易的。这是可以的,但也是不必要的,因为 Angular 的DatePipe可以直接处理时间戳。为了追求极致的简洁性和最大限度地利用我们使用的框架的功能,我们将ICurrentWeather更新为使用数字。如果你正在转换大量数据,这种方法还有性能和内存上的好处,但在这里这个顾虑不适用。有一个注意事项——JavaScript 的时间戳是以毫秒为单位的,但服务器值是以秒为单位的,所以在转换过程中仍然需要进行简单的乘法。

  4. 在其他import语句下方导入 RxJS 的map操作符:

    **src/app/weather/weather.service.ts**
    import { map } from 'rxjs/operators' 
    

    不得不手动导入map操作符可能看起来有些奇怪。RxJS 是一个功能强大的框架,具有广泛的 API 表面。一个Observable本身就有超过 200 个方法附加到它上。默认包含所有这些方法会导致开发时间问题,因为有很多函数可供选择,同时也对最终交付物的尺寸产生负面影响,包括应用性能和内存使用。你必须单独添加你打算使用的每个操作符。

  5. 通过pipehttpClient.get方法返回的数据流应用map函数

  6. 将数据对象传递给transformToICurrentWeather函数:

    **src/app/weather/weather.service.ts**
    ...
    return this.httpClient
      .get<ICurrentWeatherData>(
        `${environment.baseUrl}api.openweathermap.org/data/2.5/weather`,
        { params: uriParams }
      )
      .pipe(map(data => this.transformToICurrentWeather(data)))
    ... 
    

    现在,数据可以在流通过时进行转换,确保OpenWeatherMap当前天气 API 数据处于正确的形状,以便CurrentWeather组件可以消费它。

  7. 确保你的应用编译成功

  8. 在浏览器中检查结果:图 3.11

    图 3.11:显示来自 OpenWeatherMap 的实时数据

    你应该看到你的应用能够从OpenWeatherMap拉取实时数据,并且能够正确地将服务器数据转换为预期的格式。

    你已经完成了功能 1 的开发:显示当前日期的当前位置天气信息。提交你的代码!

  9. 最后,我们可以将这个任务移动到完成列:图 3.12

    图 3.12:GitHub 项目看板状态

干得好!你现在熟悉了 Angular 的基本架构。你通过利用 RxJS 开始以响应式范式编写代码。

现在让我们通过防范 null 或 undefined 值来提高我们应用的健壮性,这些值可能会破坏你的应用程序代码。

Angular 中的 null 防护

在 JavaScript 中,undefinednull 值是一个持续存在的问题,必须在每个步骤中积极处理。这在与外部 API 和其他库打交道时尤其关键。如果我们不处理 undefinednull 值,那么你的应用程序可能会呈现渲染不良的视图、控制台错误、业务逻辑问题,甚至可能导致整个应用程序崩溃。

在 Angular 中,有多种策略可以防止空值:

  • 属性初始化

  • 安全导航操作符 ?.

  • 使用 *ngIf 进行空值保护

您可以使用这些策略中的一个或多个。然而,在接下来的几节中,我将演示为什么 *ngIf 策略是最佳选择。

为了模拟从服务器获取空响应的场景,请先在 CurrentWeatherComponentngOnInit 中注释掉 getCurrentWeather 调用:

**src/app/current-weather/current-weather.component.ts**
ngOnInit(): void {
    // this.weatherService
    //   .getCurrentWeather('Bethesda', 'US')
    //   .subscribe(data => (this.current = data))
} 

让我们从实现属性初始化策略来防止空值开始。

属性初始化

在静态类型语言(如 Java)中,你被灌输的是适当的变量初始化/实例化是错误无操作的关键。因此,让我们在 CurrentWeatherComponent 中通过用默认值初始化 current 来尝试一下:

**src/app/current-weather/current-weather.component.ts**
constructor(private weatherService: WeatherService) { 
  this.current = {
    city: '',
    country: '', 
    date: 0, 
    image: '',
    temperature: 0, 
    description: '',
  }
} 

这些更改的结果将控制台错误的数量从两个减少到零。然而,应用程序本身仍然没有达到可展示的状态,如下所示:

图片 B14094_03_13

图 3.13:属性初始化的结果

为了使这个视图对用户友好,我们必须在模板上的每个属性上编写默认值。因此,通过初始化来解决空值保护问题,我们创建了一个默认值处理问题。初始化和默认值处理都是对开发者来说的 O(n) 规模的任务。在最理想的情况下,这种策略的实现令人烦恼,在最糟糕的情况下,效率极低且容易出错,每个属性至少需要 O(2n) 的努力。

接下来,让我们了解 Angular 的安全导航操作符,当处理我们无法控制哪些属性可能为 null 或 undefined 的外部对象时,这个操作符非常有用。

安全导航操作符

Angular 实现了安全导航操作符 ?.,以防止意外遍历未定义的对象。因此,我们不必编写初始化代码并处理模板值,我们只需更新模板。

从构造函数中删除属性初始化代码,而是按照以下方式更新模板:

**src/app/current-weather/current-weather.component.html**
<div>
  <div>
    <span>{{current?.city}}, {{current?.country}}</span>
    <span>{{current?.date | date:'fullDate'}}</span>
  </div>
  <div>
    <img [src]='current?.image'>
    <span>{{current?.temperature}}℉</span>
  </div>
  <div>
    {{current?.description}}
  </div>
</div> 

这次,我们不必创建默认值,而是让 Angular 处理显示未定义绑定。应用程序本身处于某种更好的状态。不再显示令人困惑的数据;然而,它仍然没有达到可展示的状态,如下所示:

图片 B14094_03_14

图片 3.14:使用安全导航操作符的结果

你可能可以想象出安全导航操作符在更复杂场景中可能很有用的方式。然而,当大规模部署时,这种类型的编码仍然至少需要O(n)级别的努力来实现。

当向用户展示数据时,我们不想展示空值。清理 UI 的最简单方法就是利用ngIf指令来隐藏整个div

使用*ngIf 进行空值保护

理想策略是使用*ngIf,这是一个结构化指令,意味着 Angular 会在遇到假值时停止遍历 DOM 树元素。

CurrentWeather组件中,我们可以在尝试渲染模板之前轻松检查current变量是否为 null 或 undefined:

  1. 撤销上一节中安全导航操作符的实现

  2. 使用*ngIf更新最顶层的div元素,以检查current是否是一个对象,如下所示:

    **src/app/current-weather/current-weather.component.html**
    <div *ngIf="!current">
      no data
    </div>
    <div *ngIf="current">
    ...
    </div> 
    

    现在观察控制台日志,没有错误被报告。你应该始终确保你的 Angular 应用程序报告零控制台错误。如果你在控制台日志中仍然看到错误,请确保你已经正确地将OpenWeather URL 恢复到其正确状态,或者终止并重新启动你的npm start进程。我强烈建议你在继续之前解决任何控制台错误。

  3. 注意到 UI 现在会显示没有数据!图片

    图 3.15:使用*ngIf 进行空值保护的结果

  4. CurrentWeatherComponentngOnInit中重新启用getCurrentWeather调用:

    **src/app/current-weather/current-weather.component.ts**
    ngOnInit(): void {
      this.weatherService
          .getCurrentWeather('Bethesda', 'US')
          .subscribe(data => (this.current = data))
    } 
    
  5. 提交你的更改。

使用空值保护,你可以确保你的 UI 始终看起来专业。

摘要

恭喜!在本章中,你创建了一个具有灵活架构的 Angular 应用程序,同时避免了过度设计。这是可能的,因为我们首先制定了一个路线图,并在一个对同事可见的看板中将其编码化。我们专注于实现我们首先放入进行中的第一个功能,并始终没有偏离计划。

你学习了如何通过积极声明函数的输入和返回类型以及使用泛型函数来避免编码错误。你使用了日期和小数管道来确保数据以所需的方式格式化,同时将格式化相关的问题主要保留在模板中,这种逻辑属于模板。

最后,你使用了接口在组件和服务之间进行通信,而没有将外部数据结构泄露给内部组件。通过结合应用所有这些技术,Angular、RxJS 和 TypeScript 允许我们这样做,你确保了适当的关注点分离和封装。因此,CurrentWeather组件现在真正是可重用和可组合的;这不是一件容易的事情。

如果你没有发布,那就从未发生过。在下一章中,我们将通过排除应用错误、确保自动化单元和端到端测试通过,以及使用 Docker 容器化 Angular 应用,为生产发布准备这个 Angular 应用,以便可以在网络上发布。

进一步阅读

问题

尽可能地回答以下问题,以确保你在不使用 Google 的情况下理解了本章的关键概念。你需要帮助回答这些问题吗?请参阅附录 D自我评估答案,在线位于static.packt-cdn.com/downloads/9781838648800_Appendix_D_Self-Assessment_Answers.pdf或访问expertlysimple.io/angular-self-assessment

  1. 我介绍了看板的概念。它是什么,看板在我们的软件应用开发中扮演什么角色?

  2. 我们使用 Angular CLI 工具创建 Local Weather 应用后,生成了哪些不同的 Angular 组件,以及每个组件的功能和角色是什么?

  3. 在 Angular 中绑定数据的不同方式有哪些?

  4. 为什么我们需要在 Angular 中使用服务?

  5. 在 RxJS 中,可观察者是什么?

  6. 如果你的模板背后的数据是假的,最容易的方式展示一个干净的 UI 是什么?

第四章:自动化测试、持续集成和发布到生产环境

发布它,否则它从未发生过!在第三章中,您在创建基本 Angular 应用时创建了一个可以检索当前天气数据的应用程序。您在这个过程中创造了一定的价值;然而,如果您不将您的应用放到网络上,您最终创造的价值为零。将您的作品发布出去的这种动机在许多行业中都很普遍。然而,将作品交付给他人或公开接受审查可能会令人恐惧。在软件工程中,交付任何东西都是困难的;将东西交付到生产环境则更加困难。本章将帮助您实现持续集成CI)管道。持续集成管道将帮助您实现频繁、可靠、高质量和灵活的发布。

只有在我们有一套可以快速验证我们代码正确性的自动化测试时,才能实现频繁和可靠的发布。我们在上一章创建的应用程序有失败的单元测试和端到端e2e)测试。我们需要修复这些单元测试,然后通过利用 GitHub 工作流和 CircleCI 确保它们不再中断。然后我们将介绍如何将您的 Angular 应用交付到网络上。在第九章使用 Docker 进行 DevOps中,我们将介绍持续交付CD)管道,它也可以自动化您的交付。

查看我的 2018 年演讲,发布它,否则它从未发生过:Docker、Heroku 和 CircleCI 的力量,链接为bit.ly/ship-it-or-it-never-happened

本章涵盖以下内容:

  • 使用测试替身进行单元测试

  • 使用 Jasmine 进行 Angular 单元测试

  • Angular 端到端测试

  • GitHub 工作流

  • 生产就绪

  • 使用 CircleCI 进行持续集成

  • 使用 Vercel Now 在网络上部署应用

本书样本代码的最新版本可在以下链接的 GitHub 仓库中找到。该仓库包含代码的最终和完整状态。您可以通过查看projects文件夹下的章节末尾代码快照来验证本章的进度。

对于第四章

  1. 克隆以下仓库 github.com/duluca/local-weather-app

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

  3. 本章的代码示例位于以下子文件夹中:

    projects/ch4 
    
  4. 要运行本章的 Angular 应用,请执行以下命令:

    npx ng serve ch4 
    
  5. 要运行本章的 Angular 单元测试,请执行以下命令:

    npx ng test ch4 --watch=false 
    
  6. 要运行本章的 Angular 端到端测试,请执行以下命令:

    npx ng e2e ch4 
    
  7. 要构建本章的生产就绪 Angular 应用,请执行以下命令:

    npx ng build ch4 --prod 
    

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

注意,书中或 GitHub 上的源代码可能并不总是与 Angular CLI 生成的代码相匹配。由于生态系统不断演变,书中代码与 GitHub 上代码之间的实现也可能存在细微差异。随着时间的推移,示例代码发生变化是自然的。此外,在 GitHub 上,您可能会找到更正、支持库新版本的修复,或者为读者观察而并排实现多种技术的示例。您只需实现书中推荐的理想解决方案即可。如果您发现错误或有疑问,请为所有读者创建 GitHub 上的问题或提交拉取请求。

你可以在附录 C保持 Angular 和工具常青中了解更多关于更新 Angular 的内容。您可以从static.packt-cdn.com/downloads/9781838648800_Appendix_C_Keeping_Angular_and_Tools_Evergreen.pdfexpertlysimple.io/stay-evergreen在线找到这个附录。

在本章中,您需要在 CircleCI 和 Vercel Now 上注册账户。但在我们可以部署我们的应用程序之前,我们需要确保我们已自动部署测试来确保我们的应用程序的质量随时间保持。首先,我们将深入探讨单元测试基础,让您熟悉测试驱动开发的益处,并介绍如 FIRST 和 SOLID 等原则。

单元测试

单元测试对于确保您的应用程序的行为不会无意中随时间改变至关重要。单元测试将使您和您的团队能够继续对应用程序进行更改,而不会引入先前已验证的功能的变化。开发者编写单元测试,其中每个测试的范围仅限于测试待测试函数FUT)或待测试类CUT)中的代码。Angular 组件和服务都是类;然而,也鼓励您开发可重用的函数。单元测试应该数量充足、自动化且快速。您应该在原始代码旁边编写单元测试。如果它们与实现分离,即使是一两天,您也可能开始忘记代码的细节。正因为如此,您可能会忘记为潜在的边缘情况编写测试。

单元测试应遵循FIRST 原则

  • Fast

  • Isolated

  • Repeatable

  • Self-verifying

  • Timely

单元测试应该快速,只需毫秒即可运行,这样我们就可以在几分钟内运行数千个测试。为了实现快速测试,单元测试应该是隔离的。它不应该与数据库通信、通过网络发送请求或与 DOM 交互。隔离的测试将是可重复的,因此每次测试运行都会返回相同的结果。可预测性意味着我们可以断言测试的正确性,而不依赖于任何外部环境,这使得我们的测试可以自我验证。如前所述,你应该及时编写单元测试;否则,你会失去编写单元测试的好处。

如果你的测试只关注单个 FUT/CUT,那么你就可以坚持 FIRST 原则。但其他类、服务或我们必须传递给 FUT/CUT 的参数怎么办?单元测试可以通过利用测试双倍来隔离 FUT/CUT 的行为。测试双倍允许我们控制外部依赖,因此,而不是向你的组件注入HttpService,你可以注入一个假的或模拟的HttpService。使用测试双倍,我们可以控制外部依赖的影响,并创建快速且可重复的测试。

多少测试才算足够?你应该至少有与生产代码一样多的测试代码。如果没有,那么你离编写足够的测试还差得远。

单元测试并不是你可以创建的唯一类型的测试,但它们是你应该创建最多的一种。让我们考虑你可以创建的三个主要测试类别:单元、集成和 UI。

正如我们所说的,单元测试一次只关注一个 FUT/CUT。集成测试测试各种组件的集成,以便它们可以包括数据库调用、网络请求以及与 DOM 的交互。由于它们的性质,集成测试运行速度较慢,并且需要频繁维护。运行时间和维护的增加意味着随着时间的推移,集成测试比单元测试更昂贵。UI 测试模拟用户使用应用程序,填写字段、点击按钮并观察预期的结果。

你可能会想象这些测试是最慢且最脆弱的测试类型。应用程序的 UI 经常变化,使用 UI 测试创建可重复的测试非常困难。

我们可以利用集成和 UI 测试的混合来创建验收测试。验收测试是为了自动化我们交付的功能的业务验收而编写的。Angular 的端到端测试是一种创建验收测试的方法。

我们可以用迈克·科恩的测试金字塔可视化三种主要自动化测试类别的优缺点,如下所示:

图片

图 4.1:迈克·科恩的测试金字塔

测试金字塔有效地总结了在考虑速度和成本的情况下,我们应该为我们的应用程序创建的每种类型的测试的相对数量。

在实现方面,单元测试由三个部分组成:

  • 安排 – 设置

  • 行动 - 运行你想要测试的东西

  • 断言 - 验证结果

在安排步骤中,我们设置测试双、预期结果和任何其他必需的依赖项。在行动步骤中,我们执行我们正在测试的代码行。最后,在断言阶段,我们验证行动步骤的结果是否与安排步骤中定义的预期结果相匹配。我们将在下一节中看到安排、行动和断言在实际中的工作方式。

让我们来看看在 Angular 中单元测试意味着什么。

Angular 单元测试

Angular 中单元测试的定义与我们之前定义的单元测试的严格定义略有不同。Angular CLI 使用 Jasmine 框架为我们自动生成单元测试。然而,这些所谓的单元测试包括 DOM 交互,因为它们渲染组件的视图。

第一章Angular 及其概念简介 中考虑 Angular 组件的架构:

图 4.2:组件的解剖结构

由于 Angular 使用绑定,组件类及其模板是不可分割的,实际上代表了一个单元。我们仍然可以通过测试单个函数来编写纯单元测试,但除此之外,组件及其模板被认为是测试的最小单元。

随着应用程序的增长,您可能会发现 Angular 单元测试运行缓慢,因为它们渲染视图并解析依赖关系树。有各种方法可以解决这个问题,包括测试运行的并行化、选择不使用 Angular TestBed 以及更积极地使用测试双。

如您所注意到的,我们可以将服务注入到组件中或在我们的模板中使用其他组件。我们将利用 Jasmine 提供的测试双机制来隔离我们的组件,使其不受此类外部依赖的影响。

让我们来看看 Jasmine 是什么。

Jasmine

Jasmine 是一个用于浏览器和 Node.js 测试的行为驱动测试框架。Jasmine 还支持 Ruby 和 Python。Jasmine 是一个包含电池的框架。它支持基本的单元测试需求,如测试固定装置、断言、模拟、间谍和报告器。

Jasmine 测试文件的命名约定是在文件名后使用spec.ts,例如fileUnderTest.spec.ts。Jasmine 测试组织在describe块中,这些块可以按层级分组,以反映文件、类或属于单个函数的多个测试的结构。单个测试用例或规格用it块表示。以下示例显示了一个名为converters.ts的文件导出一个将摄氏度转换为华氏度的函数:

**Sample Jasmine Test**
describe('Converters', () => {
  describe('convertCtoF', () => {
    it('should convert 0c to 32f', () => {
      ...
    })
  })
}) 

规格以这种方式组织,当它们执行时,它们读起来像一句话。在这种情况下,结果将是 Converters convertCtoF 应将 0c 转换为 32f

想了解更多关于 Jasmine 的信息,请访问 jasmine.github.io.

接下来,让我们探讨 Jasmine 和大多数其他测试框架的主要功能类别——固定装置和匹配器——这些功能帮助你使用行动、安排和断言结构编写连贯的单元测试。

固定装置

如前所述,单元测试有三个部分:安排、行动和断言。单元测试的安排部分可能是重复的,因为多个测试案例通常需要相同的设置。Jasmine 提供固定装置来帮助减少你的代码中的重复。

以下是四个固定装置:

  • beforeAll() – 在describe中的所有规格之前运行

  • afterAll() – 在每个测试固定装置之后运行所有describe中的规格

  • beforeEach() – 在describe中的每个规格之前运行

  • afterEach() – 在describe中的每个规格之后运行

固定装置在指定其describe块的作用域内,在某个特定或一组特定规格之前和之后执行。

匹配器

在单元测试的断言部分,我们需要让 Jasmine 知道一个规格是通过了还是失败了。我们可以通过编写一个断言来实现这一点。有两种断言类型:

  • fail('message') – 这会明确地使一个规格失败

  • expect() – 给定一个匹配器,动态断言预期的结果是否与实际结果匹配

expect断言需要匹配器来确定测试的结果。expect和匹配器的组合旨在读起来像一句话。以下是一些你可能使用的常见匹配器:

**Jasmine Matchers**
expect(expected).toBe(actual)
                .toEqual(actual)
                .toBeDefined()
                .toBeFalsy()
                .toThrow(exception)
                .nothing() 

关于 Jasmine 匹配器的完整范围,请参阅jasmine.github.io/api/edge/matchers.html

存在着具有更丰富功能的其他库,例如 Jest、Mocha 或 testdouble.js。然而,当开始使用像 Angular 这样的新框架时,保持你的工具集最小化是很重要的。坚持默认设置是一个好主意。

此外,Jasmine 还提供了间谍(spies),通过spyOn函数支持存根(stubbing)和模拟(mocking)。我们将在本章的后面更详细地介绍这些测试替身。

自动生成单元测试的解剖结构

默认情况下,Angular 配置为可以使用 Jasmine 编写单元测试。Karma 是测试运行器,它可以持续监控代码的变化,并自动重新运行你的单元测试。

Angular 的默认配置利用了TestBed,这是一个特定于 Angular 的组件,它简化了模块的提供、依赖注入、模拟、触发 Angular 生命周期事件(如ngOnInit)以及执行模板逻辑。

如前所述,当你利用TestBed时,在术语的最严格定义中,不可能将这些测试称为单元测试。这是因为,默认情况下,TestBed注入了你的依赖项的实际实例。这意味着当你执行测试时,你也在执行服务或其他组件中的代码,而你应该只测试当前正在测试的服务或组件中的代码。我们利用测试替身来帮助我们编写隔离和可重复的单元测试。

第三章创建一个基本的 Angular 应用中,Angular CLI 在你创建新的组件和服务时创建了单元测试文件,例如current-weather.component.spec.tsweather.service.spec.ts。请查看以下 spec 文件,并观察should create测试。框架断言任何CurrentWeatherComponent类型的组件不应为 null 或 undefined,而应该是真值:

**src/app/current-weather/current-weather.component.spec.ts**
describe('CurrentWeatherComponent', () => {
  let component: CurrentWeatherComponent
  let fixture: ComponentFixture<CurrentWeatherComponent>
  beforeEach(
    async(() => {
      TestBed.configureTestingModule({
        declarations: [CurrentWeatherComponent],
      }).compileComponents()
    })
  )
  beforeEach(() => {
    fixture = TestBed.createComponent(CurrentWeatherComponent)
    component = fixture.componentInstance
    fixture.detectChanges()
  })
  it('should create', () => {
    expect(component).toBeTruthy()
  })
}) 

WeatherService spec 包含一个类似的测试。然而,你会注意到每种类型的测试设置略有不同:

**src/app/weather/weather.service.spec.ts**
describe('WeatherService', () => { 
  let service: WeatherService

  beforeEach(() => {
    TestBed.configureTestingModule({})
    service = TestBed.inject(WeatherService);
  })
  it('should be created', () => {
      expect(service).toBeTruthy()
    })
  )
}) 

WeatherService spec 的beforeEach函数中,CUT 被注入到TestBed中。另一方面,CurrentWeatherComponent spec 有两个beforeEach函数。第一个beforeEach函数异步声明和编译组件的依赖模块,而第二个beforeEach函数创建测试固定装置并开始监听组件的变化,一旦编译完成,就准备好运行测试。

接下来,让我们执行我们的单元测试,看看有多少通过或失败。

单元测试执行

Angular CLI 使用 Jasmine 单元测试库来定义单元测试,并使用 Karma 测试运行器来执行它们。最好的是,这些测试工具已经配置好可以直接运行。你可以使用以下命令执行单元测试:

$ npm test 

测试是由 Karma 测试运行器在一个新的 Chrome 浏览器窗口中运行的。Karma 的主要好处是它带来了类似于 Angular CLI 在开发应用程序时使用 webpack 实现的实时重新加载功能。

在执行npm test命令的初始执行后,你很可能会遇到编译错误,因为我们实现应用程序代码时没有更新相应的单元测试代码。

在开发过程中,遇到许多错误是正常的。所以,不要沮丧!参见附录 A调试 Angular,了解如何使用 Chrome/Edge Dev Tools 和 VS Code 来调试错误。

让我们看看如何解决这些错误。

编译错误

在开发应用程序代码时,更新你的单元测试代码是很重要的。不这样做通常会导致编译错误。

记住,当你构建 Angular 应用时,测试代码不会被构建。你必须执行npm test来构建和运行你的测试代码。

当你执行测试时,你应该会看到一个类似于以下错误消息:

ERROR in src/app/app.component.spec.ts:21:16 - error TS2339:
Property 'title' does not exist on type 'AppComponent'.
21     expect(app.title).toEqual('local-weather-app') 

我们需要纠正的第一个测试位于app.component.spec.ts中,名为'should have as title "local-weather-app"'。我们在上一章中从AppComponent中删除了title属性,因为我们没有使用它。所以,我们不再需要这个单元测试。

  1. 删除should have as title 'local-weather-app'单元测试。

    如前所述,Jasmine 结合了describeit函数中提供的文本。因此,这个测试被称为'AppComponent should have as title 'local-weather-app''。这是一个方便的约定,可以快速定位测试。当你编写新的测试时,维护你规格的可读描述取决于你。

    第二个要修复的测试位于AppComponent下,名称为should render title。我们现在将“LocalCast Weather”作为标题渲染,所以让我们更改测试。

  2. 更新should render title测试,如下所示:

    **src/app/app.component.spec.ts**
    it('should render title', () => {
      ... 
      expect(compiled.querySelector('h1').textContent)
        .toContain('LocalCast Weather') 
    }) 
    
  3. 提交你的代码更改。

我们已经修复了单元测试中的逻辑问题。它们现在应该可以无编译错误地执行。然而,你应该预期它们都会失败,因为我们还没有配置 Angular 的TestBed

测试结果

你应该在终端上观察到最后一条信息是TOTAL: 2 FAILED, 2 SUCCESS。这是正常的,因为我们根本没有关注这些测试,所以让我们修复它们。

图 4.3:Karma Runner 显示 Jasmine 单元测试结果

将 Karma Runner 窗口与 VS Code 并排打开,这样你可以立即看到你更改的结果。

现在我们来配置 TestBed。

配置 TestBed

TestBed 有三个主要功能,可以帮助你创建可单元测试的组件:

  • 声明 – 构建组件类及其模板逻辑,以方便测试

  • 提供者 – 提供没有模板逻辑和需要注入的依赖项的组件类

  • 导入 – 导入支持模块以能够渲染模板逻辑或其他平台功能

TestBed 不是在 Angular 中编写单元测试的强制要求,这是一个在angular.io/guide/testing中很好地介绍的话题。我的同事和本书的审稿人 Brendon Caulkins 为第十二章配方 – 主/详细,数据表和 NgRx代码示例贡献了一个无床的 spec 文件,名为current-weather.component.nobed.spec.ts。他引用了在运行测试时性能显著提高,导入更少,维护更少,但需要更高水平的关注和专业知识来实现测试。如果你在一个大型项目中,你应该认真考虑跳过 TestBed。

你可以在 GitHub 上找到示例代码,链接为github.com/duluca/local-weather-app/tree/master/projects/ch12

让我们逐一介绍这些功能,同时修复手头的测试,以确保它们可以成功运行。

声明

声明使我们能够提供渲染待测试组件所需的所有组件。通常,你只需声明待测试的组件。因此,app.component.spec.ts声明了AppComponent,而current-weather.component.spec.ts声明了CurrentWeatherComponent等等。

注意,我们在AppComponent的模板中使用了<app-current-weather>;然而,这并不意味着我们还需要在app.component.spec.ts中声明CurrentWeatherComponent。Angular 的旧版本TestBed要求将子组件作为父组件单元测试的一部分进行声明,这导致了创建单元测试时的显著开销。在声明中包含多个组件会产生副作用,需要注入所有已声明组件的所有依赖项,而不仅仅是待测试组件的依赖项。这意味着将无关的依赖项添加到我们的“单元”测试中,使它们变成了集成测试。

在这种情况下,CurrentWeatherComponentAppComponent的硬编码依赖项。可以通过两种方式进一步解耦这两个组件:一种方式是使用ng-container动态注入组件,另一种方式是利用 Angular Router 和router-outletrouter-outlet策略是我们构建大多数多屏 Angular 应用的方式,我将在后面的章节中介绍。使用ng-container正确解耦组件的任务留给读者作为练习。

你可以尝试在app.component.spec.ts中声明CurrentWeatherComponent

**src/app/app.component.spec.ts**
...
TestBed.configureTestingModule({
  declarations: [AppComponent, CurrentWeatherComponent],
}).compileComponents()
... 

注意,这样做会在AppComponent测试中引入与HttpClient相关的错误,尽管AppComponent本身没有导入WeatherService。实际上,CurrentWeatherComponent导入了WeatherService,而WeatherService本身又导入了HttpClient。你可以看到依赖项的复杂性是如何迅速失控的。Angular 单元测试配置为不需要声明子组件,但请注意,单元测试框架正在抛出一个关于未知元素的警告:

WARN: ''app-current-weather' is not a known element 

在编程中,警告几乎和错误一样严重。不解决警告注定会在将来造成麻烦。当我们后面讨论模拟时,我们将介绍如何正确解决这个问题。

在继续之前,请确保撤销你的更改。

目前,你不需要为父组件测试声明子组件,这使得最初通过单元测试变得更容易。在某些情况下,你必须声明依赖组件,例如当你创建自定义控件并需要测试你的控件是否在组件的上下文中正常工作时。创建自定义控件的例子包括在第十一章的配方 - 可重用性、路由和缓存中。

在下一节中,我们将探讨提供者,它们帮助我们注入依赖项的真实和模拟实现,这样我们就可以避免测试像WeatherService这样的依赖项,而只测试“单元”。

提供者

提供者允许我们在不使用模板逻辑或注入到待测试组件中的服务的情况下提供组件。你会注意到我们的CurrentWeatherComponent测试没有通过,出现了一个错误,抱怨缺少HttpClient的提供者:

CurrentWeatherComponent > should create
NullInjectorError: R3InjectorError(DynamicTestModule)[WeatherService -> HttpClient -> HttpClient]: 
  NullInjectorError: No provider for HttpClient! 

这是因为注入到 CurrentWeatherComponent 中的 WeatherService 需要一个 HttpClient 的提供者。然而,CurrentWeatherComponent 并不知道 HttpClient。它只知道 WeatherService。你可能猜到我们可能并不是严格地进行单元测试,而是实际上在进行集成测试,你会是对的。

然而,让我们继续并将在 current-weather.component.spec.ts 中添加 WeatherService 的提供者。在 current-weather.component.spec.ts 中的声明中提供 WeatherService,如下所示:

**src/app/current-weather/current-weather.component.spec.ts**
...
beforeEach(async(() => { 
  TestBed.configureTestingModule({ 
    declarations: [...], 
    providers: [WeatherService], 
  })
  ...
})
... 

在这个例子中,我们提供了 WeatherService 的实际实现,但这并没有解决当前的问题。WeatherService 的实现仍然依赖于 HttpClient,错误仍然存在。

在继续之前,请确保撤销您的更改。

提供者允许我们提供依赖项的替代实现,如该依赖项的伪造或模拟。

如果我们定义一个名为 FakeWeatherServiceWeatherService 伪造实现,我们可以通过以下 useClass 方式提供伪造而不是实际实现:

providers: [{ provide: WeatherService, useClass: FakeWeatherService }] 

伪造实现将打破对 HttpClient 的依赖,并解决我们的问题。我将在下一节关于测试替身的部分中介绍如何实现伪造。

或者,如果我们为 WeatherService 创建一个名为 mockWeatherService 的模拟,我们可以通过以下方式提供模拟的 useValue

providers: [{ provide: WeatherService, useValue: mockWeatherService }] 

使用模拟,我们甚至不需要实现伪造类并确保我们只测试正在测试的组件。下一节关于测试替身的部分将详细介绍模拟。

现在我们已经很好地理解了提供者能为我们做什么以及不能做什么,让我们看看导入如何完善 TestBed

导入

导入有助于引入代码,这些代码可以促进视图或其他依赖项的渲染到测试中。目前,测试仍然失败,因为 WeatherService 本身依赖于 HttpClient,因此我们需要提供 HttpClient。如果我们这样做,那么我们的单元测试将尝试通过 HTTP 进行调用。我们不希望我们的测试依赖于其他服务,因为这违反了本章前面提到的 FIRST 原则。因此,我们不应该提供实际的 HttpClient

Angular 为 HttpClient 提供了一个名为 HttpClientTestingModule 的测试替身。为了利用它,您必须导入它,这将自动为您提供测试替身。

current-weather.component.spec.ts 导入 HttpClientTestingModule:

**src/app/current-weather/current-weather.component.spec.ts**
import { HttpClientTestingModule } from '@angular/common/http/testing'
...
  describe(' CurrentWeatherComponent', () => { 
    beforeEach(() => { 
      TestBed.configureTestingModule({ 
        imports: [HttpClientTestingModule], 
        ...
      })
    ...
  })
... 

HttpClientTestingModule 类似,还有一个 RouterTestingModuleNoopAnimationsModule,它们是真实服务的模拟版本,因此单元测试可以仅关注测试您编写的组件或服务代码。在后面的章节中,我们还将介绍如何编写您自己的模拟。

呼吸!现在,所有您的单元测试都应该通过。如您所见,CurrentWeatherComponent 测试不是我们的单元测试,因为它们正在使用实际的 WeatherService,而 WeatherService 本身依赖于 HttpClient

现在,让我们看看测试替身如何帮助我们编写符合 FIRST 原则的单元测试。

测试替身

应该只对 CUT(Cut,即代码单元测试中的“代码单元”)中的代码进行测试。在 CurrentWeatherComponent 的情况下,我们需要确保服务代码不被执行。因此,你应该永远不要提供服务的实际实现。

我们需要了解两种测试替身类型:

  • 模拟

  • 模拟、存根或间谍

通常,对模拟进行推理更容易,所以我们将从这里开始。一旦你对单元测试感到舒适,并且你的现有测试集处于正常工作状态,我强烈建议切换到仅使用模拟,这将使你的测试更加健壮、高效和易于维护。

模拟

模拟是一个现有类的替代、简化实现。它就像一个模拟服务,其中不进行任何实际的 HTTP 调用,但你的服务返回预制的响应。在单元测试期间,模拟被实例化并像真实类一样使用。在前一节中,我们使用了 HttpClientTestingModule,这是一个模拟的 HttpClient。我们的自定义服务是 WeatherService,因此我们必须提供我们的测试替身实现。

我们通过创建服务的模拟来创建测试替身。由于 WeatherService 的模拟在测试多个组件时使用,你的实现应该在一个单独的文件中。为了保持代码库的可维护性和可发现性,每个文件一个类是一个很好的经验法则。将类保存在单独的文件中可以防止你犯一些编程错误,比如在两个类之间错误地创建或共享全局状态或独立函数,从而在过程中保持代码解耦。

我们还需要确保实际实现和测试替身的 API 随时间不会不同步。我们可以通过为服务创建一个接口来实现这一点。

  1. IWeatherService 添加到 weather.service.ts 中,如下所示:

    **src/app/weather/weather.service.ts**
    export interface IWeatherService {
      getCurrentWeather(
      city: string, 
      country: string
      ): Observable<ICurrentWeather> 
    } 
    
  2. 更新 WeatherService 以实现新的接口:

    **src/app/weather/weather.service.ts**
    export class WeatherService implements IWeatherService 
    
  3. 创建一个新的文件 weather/weather.service.fake.ts

  4. weather.service.fake.ts 中实现一个基本的模拟,如下所示:

    **src/app/weather/weather.service.fake.ts**
    import { Observable, of } from 'rxjs'
    import { IWeatherService } from './weather.service'
    import { ICurrentWeather } from '../interfaces'
    export const fakeWeather: ICurrentWeather = {
      city: 'Bethesda',
      country: 'US',
      date: 1485789600,
      image: '',
      temperature: 280.32,
      description: 'light intensity drizzle',
    }
    export class WeatherServiceFake implements IWeatherService {
      public getCurrentWeather(
        city: string,
        country: string): Observable<ICurrentWeather> { 
          return of(fakeWeather)
      }
    } 
    

    我们正在利用现有的 ICurrentWeather 接口,我们的模拟数据已经正确地塑造了它,但我们必须将其转换为 Observable。这可以通过使用 of 来轻松实现,它根据提供的参数创建一个可观察的序列。

    现在,你已经准备好为 AppComponentCurrentWeatherComponent 提供模拟了。

  5. 更新 current-weather.component.spec.ts 中的提供者,以使用 WeatherServiceFake,这样就会使用模拟而不是实际的服务:

    **src/app/current-weather/current-weather.component.spec.ts**
      ...
      beforeEach( 
        async(() => {
          TestBed.configureTestingModule({
            ...
            providers: [{
              provide: WeatherService, useClass: WeatherServiceFake
            }],
            ... 
    

    注意,这个替代实现是在一个名为 current-weather.component.fake.spec 的不同文件中提供的,它是 GitHub 上 projects/ch4 子文件夹的一部分。

  6. 从导入中删除 HttpClientTestingModule,因为它不再需要

    随着你的服务和组件变得越来越复杂,很容易提供一个不完整或不充分的测试替身。你可能会看到诸如 NetworkError: Failed to execute 'send' on 'XMLHttpRequest'Can't resolve all parameters[object ErrorEvent] thrown 这样的错误。在后者的情况下,点击 Karma 中的 调试 按钮以发现视图错误详情,这可能看起来像 超时 - jasmine 指定的时间超出了异步回调。单元测试设计为以毫秒为单位运行,所以实际上触碰到默认的 5 秒超时是不可能的。问题几乎总是与测试设置或配置有关。

  7. 验证所有测试是否通过

使用假对象,我们能够在一定程度上减少测试复杂性并提高隔离性。我们可以通过模拟、存根和间谍做得更好。

模拟、存根和间谍

模拟、存根或间谍不包含任何实现。模拟在单元测试文件中配置,以对特定的函数调用响应一系列响应,这些响应可以根据测试轻松地变化。

声明 部分中较早的时候,我们讨论了在 app.component.spec.ts 中声明 CurrentWeatherComponent 以解决未知元素警告的必要性。如果我们声明真实的 CurrentWeatherComponent,那么 AppComponent 的测试配置就会变得过于复杂,因为我们需要解决子组件的依赖关系树,包括 WeatherServiceHttpClient。此外,仅仅为了提供假天气数据就创建一个整个假服务是过度设计,并且不是一个灵活的解决方案。如果我们想根据不同的输入测试不同的服务响应怎么办?我们就必须开始在假服务中引入逻辑,然后不知不觉中,你就在处理 WeatherService 的两个独立实现。

创建一个假对象的替代方案是创建一个空对象,它冒充真实对象但没有任何实现。这些对象被称为模拟。我们将在下面利用两种不同的技术来创建模拟组件和模拟服务。

模拟组件

如果我们在 app.component.spec.ts 中提供一个 CurrentWeatherComponent,我们可以解决未知元素的警告,并且不需要担心 CurrentWeatherComponent 所依赖的所有组件和服务。

如果你手动实现它,一个模拟组件看起来像这样:

@Component({
  selector: 'app-current-weather',
  template: ``,
})
class MockCurrentWeatherComponent {} 

然而,这可能会很快变得繁琐,这就是为什么我发布了一个单元测试辅助库,名为 angular-unit-test-helper,以使模拟组件更容易。使用这个库,你只需用这个函数调用替换声明中的组件:

createComponentMock('CurrentWeatherComponent') 

让我们更新 app.component.spec.ts 以使用模拟组件:

  1. 执行 npm i -D angular-unit-test-helper

  2. 使用模拟组件更新 AppComponent

    **src/app/app.component.spec.ts**
    import { createComponentMock } from 'angular-unit-test-helper'
      TestBed.configureTestingModule({
        declarations: [ ...,
          createComponentMock('CurrentWeatherComponent')
        ],
        ...
      }) 
    
  3. 完全删除 providers 属性

  4. 清理未使用的导入

注意,单元测试文件保持简洁,警告已解决。angular-unit-test-helper 推断 CurrentWeatherComponent 代表一个 HTML 标签,如 <app-current-weather>,并在浏览器的窗口对象中提供它。然后 createComponentMock 函数通过分配选择器 'app-current-weather' 和一个空模板来正确装饰空的 CurrentWeatherComponent 类。然后 TestBed 能够解析 <app-current-weather> 为这个模拟组件。createComponentMock 还允许你根据需要提供自定义选择器或假模板。这是一个可扩展的解决方案,减少了超过一半的导入,并遵循 FIRST 原则。

模拟的概念扩展到我们可以定义的所有类型的对象,包括 Angular 服务。通过模拟服务,我们不必担心可能注入到该服务中的任何依赖项。

让我们看看如何模拟一个服务。

模拟服务

让我们为 CurrentWeatherComponent 编写两个新的单元测试,以展示模拟服务而不是实现其假值的优点。模拟允许我们创建一个空对象,并给我们提供只提供可能需要的测试函数的选项。然后我们可以根据每个测试来模拟这些函数的返回值或监视它们以查看我们的代码是否调用了它们。监视特别有用,如果相关的函数没有返回值。我们需要在我们的规范安排部分设置我们的间谍。

  1. 让我们从创建一个 WeatherService 间谍对象开始,使用 jasmine.createSpyObj,如下所示:

    **src/app/current-weather/current-weather.component.spec.ts**
    import {
      ComponentFixture,
      TestBed,
      async
    } from '@angular/core/testing'
    import { injectSpy } from 'angular-unit-test-helper'
    import { WeatherService } from '../weather/weather.service'
    import {
      CurrentWeatherComponent
    } from './current-weather.component'
    describe('CurrentWeatherComponent', () => {
      ...
      let weatherServiceMock: jasmine.SpyObj<WeatherService>
      beforeEach(async(() => {
        const weatherServiceSpy =
          jasmine.createSpyObj(
            'WeatherService',
            ['getCurrentWeather']
          )
    
        TestBed.configureTestingModule({ ... })
      }) 
    
  2. 使用 useValueweatherServiceSpy 作为 WeatherService 的值。

  3. 最后,从 TestBed 获取注入的实例并将其分配给 weatherServiceMock,使用 angular-unit-test-helper 中的 injectSpy 方法,如下所示:

    **src/app/current-weather/current-weather.component.spec.ts**
        beforeEach(async(() => { 
          ...
          TestBed.configureTestingModule({
          ...,
           providers: [{
             provide: WeatherService, useValue: weatherServiceSpy
           }]
        }).compileComponents()
        weatherServiceMock = injectSpy(WeatherService)
    } 
    

注意,injectSpyTestBed.inject(WeatherService) 的简写,作为任何。

在前面的例子中,我们有一个模拟的 WeatherService 版本,其中声明它有一个名为 getCurrentWeather 的函数。然而,请注意,你现在得到了一个错误:

TypeError: Cannot read property 'subscribe' of undefined 

这是因为 getCurrentWeather 不会返回一个可观察对象。使用 weatherServiceMock,我们可以监视 getCurrentWeather 是否被调用,也可以根据测试来模拟其返回值。

为了操纵 getCurrentWeather 的返回值,我们需要更新 should create 测试以反映安排、执行和断言结构。为此,我们需要将 fixture.detectChanges() 从第二个 beforeEach 中移除,这样我们就可以控制其执行顺序,使其在安排部分之后执行。

**src/app****/current-weather/current-weather.component.spec.ts**
  import { of } from 'rxjs'
  ...
  beforeEach(() => {
    fixture = TestBed.createComponent(CurrentWeatherComponent)
    component = fixture.componentInstance
  })
  it('should create', () => {
    // Arrange
    weatherServiceMock.getCurrentWeather.and.returnValue(of())
    // Act
    fixture.detectChanges() // triggers ngOnInit
    // Assert
    expect(component).toBeTruthy()
  }) 

在安排部分,我们配置getCurrentWeather应使用RxJS\of函数返回一个空的 Observable。在行为部分,我们触发 TestBed 的detectChanges函数,这会触发生命周期事件,如ngOnInit。由于我们正在测试的代码位于ngOnInit中,这是正确执行的操作。最后,在断言部分,我们确认组件已成功创建。

在接下来的测试中,我们可以验证getCurrentWeather函数确实被调用了一次:

**src/app/current-weather/current-weather.component.spec.ts**
  it('should get currentWeather from weatherService', () => {
    // Arrange
    weatherServiceMock.getCurrentWeather.and.returnValue(of())
    // Act
    fixture.detectChanges() // triggers ngOnInit()
    // Assert
    expect(weatherServiceMock.getCurrentWeather)
      .toHaveBeenCalledTimes(1)
  }) 

最后,我们可以测试返回的值是否正确分配在组件类中,并且也正确渲染在模板上:

**src/app/current-weather/current-weather.component.spec.ts**
import { By } from '@angular/platform-browser'
import { fakeWeather } from '../weather/weather.service.fake'
...
  it('should eagerly load currentWeather in Bethesda from weatherService', () => {
    // Arrange
    weatherServiceMock.getCurrentWeather
      .and.returnValue(of(fakeWeather))
    // Act
    fixture.detectChanges() // triggers ngOnInit()
    // Assert
    expect(component.current).toBeDefined()
    expect(component.current.city).toEqual('Bethesda')
    expect(component.current.temperature).toEqual(280.32)
    // Assert on DOM
    const debugEl = fixture.debugElement
    const titleEl: HTMLElement = debugEl.query(By.css('span'))
      .nativeElement
    expect(titleEl.textContent).toContain('Bethesda')
  }) 

在前面的例子中,您可以看到我们提供了一个名为fakeWeather的模拟对象,其中城市名称为 Bethesda。然后我们能够断言当前属性具有正确的city,并且具有class=mat-title<div>元素包含文本 Bethesda。

您现在应该有七个通过测试:

TOTAL: 7 SUCCESS 

通过使用模拟(mocks)、存根(stubs)和间谍(spies),我们可以快速测试外部依赖项可以返回和不能返回的多种可能性,并且我们可以通过观察 DOM 来验证组件或服务类中驻留的代码的断言。

要了解更多关于模拟、存根和间谍的信息,请参阅jasmine.github.io。此外,我发现 Dave Ceddia 的 Jasmine 2 Spy Cheat Sheet 非常有用,位于daveceddia.com/jasmine-2-spy-cheat-sheet

通常,您的单元测试应该最多断言一两个事情。为了达到足够的单元测试覆盖率,您应该专注于测试包含业务逻辑的函数的正确性:通常在您看到ifswitch语句的地方。

要编写可单元测试的代码,请确保遵循 SOLID 原则中的单一责任原则和开放/封闭原则。

查看我的同事 Brendan Sawyer 创建的ng-tester库,位于www.npmjs.com/package/ng-tester。它为您的 Angular 组件创建具有angular-unit-test-helper的规范文件,以帮助进行模拟。此外,该库展示了如何模拟依赖项并在不使用TestBed的情况下创建测试。

您可以通过命令npm install -D ng-tester安装库,并使用命令npx ng generate ng-tester:unit创建单元测试。

除了单元测试之外,Angular CLI 还会为您的应用程序生成和配置端到端测试。接下来,让我们了解端到端测试。

Angular 端到端测试

虽然单元测试侧重于隔离 CUT,但 e2e 测试是关于集成测试。Angular CLI 利用 Protractor 和 WebDriver,以便您可以从用户与浏览器中交互的角度编写 自动化验收测试(AAT)。作为一个经验法则,您应该始终编写比 AATs 多一个数量级的单元测试,因为您的应用程序经常变化,因此与单元测试相比,AATs 的脆弱性和维护成本要高得多。

如果“web driver”这个词听起来很熟悉,那是因为它是经典 Selenium WebDriver 的一种演变。在 2017 年 3 月 30 日,WebDriver 被提议作为 W3C 的官方网络标准。您可以在 www.w3.org/TR/webdriver 上了解更多信息。如果您熟悉 Selenium,您应该会感到很自在,因为许多模式和做法几乎相同。

CLI 为初始的 AppComponent 提供端到端测试,具体取决于您应用程序的复杂性和功能集。您需要遵循提供的模式来更好地组织您的测试。在 e2e 文件夹下,每个组件都会生成两个文件:

**e2e/src/app.e2e-spec.ts**
import { browser, logging } from 'protractor'
import { AppPage } from './app.po'
describe('workspace-project App', () => {
  let page: AppPage
  beforeEach(() => {
    page = new AppPage()
  })
  it('should display welcome message', () => {
    page.navigateTo()
    expect(page.getTitleText())
      .toEqual('local-weather-app app is running!')
  })
  afterEach(async () => {
    // Assert that there are no errors emitted from the browser
    const logs = await browser
      .manage()
      .logs()
      .get(logging.Type.BROWSER)
    expect(logs).not.toContain(
      jasmine.objectContaining({
        level: logging.Level.SEVERE,
      } as logging.Entry)
    )
  })
}) 

app.e2e-spec.ts 使用 Jasmine 编写并实现了验收测试。规范依赖于页面对象(po)文件,该文件定义在 spec 文件旁边:

**e2e/src/app.po.ts**
import { browser, by, element } from 'protractor'
export class AppPage {
  navigateTo(): Promise<unknown> {
    return browser.get(browser.baseUrl) as Promise<unknown>
  }
  getTitleText(): Promise<string> {
    return element(by.css('app-root div h1'))
      .getText() as Promise<string>
  }
} 

考虑以下图表,它以视觉方式表示 e2e 测试架构:

图 4.4:e2e 测试的架构

'should display welcome message' 测试的目标是验证 app.component.html 是否显示了正确的文本。页面对象文件 app.po.ts 封装了 WebDriver 实现,使用 getTitleText 函数检索消息。最后,测试在 app.e2e-spec.ts 文件中以 Jasmine 测试的形式编写。AATs 是最脆弱的测试类型。在 HTML 和规范文件之间有一个页面对象层,这使得测试易于维护且易于阅读。通过在这一级别分离关注点,您可以将 AATs 的脆弱性限制在一个位置。通过利用类继承,您可以构建一个健壮的页面对象集合,随着时间的推移更容易维护。

e2e 测试执行

您可以使用以下命令在终端中执行 e2e 测试;确保 npm test 进程没有运行:

$ npm run e2e 

注意,测试执行与单元测试不同。虽然您可以使用 Karma 配置监视器来持续执行单元测试,但由于 e2e 测试的用户驱动和有状态性质,尝试对 e2e 测试进行类似的配置并不是一个好的做法。运行一次测试并停止测试工具确保每次运行都有一个干净的状态。

e2e 页面对象和规范

执行 e2e 测试后,您应该看到类似于以下错误消息:

**************************************************
*                    Failures                    *
**************************************************
1) web-app App should display welcome message
  - Failed: No element found using locator: By(css selector, app-root .content span)
Executed 1 of 1 spec (1 FAILED) in 0.659 sec. 

测试失败是因为我们在 app.component.html 中的 HTML 结构以及页面对象的 getTitleText 方法中进行了重大修改,导致该方法不再正确。

  1. 首先,通过纠正 getTitleText 以获取正确的文本:

    e2e/src/app.po.ts
      getTitleText(): Promise<string> {
        return element(by.css('app-root div h1')).
          getText() as Promise<string>
      } 
    

    注意,错误信息现在说:

    - Expected 'LocalCast Weather' to equal 'local-weather-app app is running!'. 
    
  2. 更新 spec 以期望正确的标题如下:

    e2e/src/app.e2e-spec.ts
      it('should display welcome message', () => {
        page.navigateTo()
        expect(page.getTitleText()).toEqual('LocalCast Weather')
      }) 
    
  3. 重新运行测试;现在它们应该通过了:

    Jasmine started
    web-app App
    √ should display welcome message
    Executed 1 of 1 spec SUCCESS in 0.676 sec. 
    
  4. 提交您的代码更改。

我们的单元测试和端到端测试现在正在工作。

对于自动化验收测试,还有更强大的工具,如 cypress.iogithub.com/bigtestjs。考虑使用这些工具而不是 Angular 的 e2e 测试。

您可以在 第七章创建以路由为第一线的业务应用 中找到 LemonMart 项目的 Cypress 示例实现,该章节位于 github.com/duluca/lemon-mart

执行 npm run cypress:run 以查看 Cypress 的实际效果。Cypress 可以记录和重放测试运行,以便轻松调试;它是您下一个企业项目的强大工具。

从现在开始,确保您的测试保持正常工作状态。

接下来,我们需要为生产部署准备我们的应用程序,这意味着以生产模式构建应用程序并设置适当的环境变量。

生产准备就绪

当您运行 npm start 时,Angular 以调试模式构建,这可以加快构建时间,启用断点调试和实时重新加载。这也意味着一个小型应用捆绑包的大小会膨胀到超过 7 MB。在慢速 3G 连接上,7 MB 的捆绑包大小会导致超过两分钟的加载时间,而我们的应用只需几秒钟即可加载。此外,在调试模式下,我们使用的是为本地开发而设计的环境变量。然而,在生产环境中,我们需要使用不同的设置,以便我们的应用程序可以在托管环境中正确运行。

让我们先实现一个 npm 脚本来帮助我们以生产模式构建。

构建生产版本

Angular 随带一个强大的构建工具,可以通过从调试构建中删除冗余、未使用和不高效代码以及预编译代码部分来优化捆绑包的大小,以便浏览器可以更快地解释它。因此,7 MB 的捆绑包可以缩小到 700 KB,即使在慢速 3G 连接上也能在 7 秒内加载完成。

默认情况下,ng build 命令以调试模式构建您的代码。通过向其中添加 --prod 选项,我们可以启用 prod 模式。

  1. package.json 中添加一个名为 build:prod 的新脚本,如下所示:

    **package.json**
    "scripts": {
      ...
      "build:prod": "ng build --prod"
    } 
    
  2. 通过执行以下命令测试脚本:

    $ npm run build:prod 
    

这是高效交付 Angular 应用的关键配置。

在启用生产模式之前,不要发布 Angular 应用程序。

接下来,让我们设置生产环境的环境变量。

设置环境变量

第三章创建基本的 Angular 应用 中,我们使用存储在 src/environment/environment.ts 文件中的环境变量配置了 OpenWeatherMap API 的 URL。我们需要更新我们的变量以用于生产,因为我们的 Angular 应用程序所在的环境正在发生变化。在本地或测试环境中工作的设置不一定适用于托管环境。

将以下更改应用到 environment.prod.ts 文件中:

  1. production 设置为 true

  2. 如有必要,提供生产 appId 变量

  3. baseUrl 更新为 https:

    **src/environments/environment.prod.ts**
    export const environment = {
      production: true,
      appId: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
      baseUrl: 'https://',
    } 
    

production 设置为 true 允许应用程序代码检查应用程序的模式以调整其行为。此外,我们将 baseUrl 从 HTTP 更改为 HTTPS,因为我们的应用程序是通过 HTTPS 托管的。浏览器不允许提供混合内容,这会削弱 HTTPS 提供的整体安全优势。如果我们不切换到 HTTPS,那么我们对 OpenWeatherMap API 的调用将失败。

你可以在 developers.google.com/web/fundamentals/security/prevent-mixed-content/what-is-mixed-content 上了解更多关于混合内容的信息。

接下来,让我们设置 CI 以确保在将应用程序部署到生产之前,我们的测试总是通过。

持续集成

在将代码推送到生产之前,你应该启用 CI。这个基本设置有助于确保即使我们更改代码,我们的应用程序代码也能继续工作,因为它通过自动化执行我们的单元测试来实现。

CircleCI

CircleCI 为初学者和专业人士 alike 提供了免费层和优秀的文档,使其易于上手。如果你有独特的企业需求,CircleCI 可以在企业内部署,位于企业防火墙之后,或作为云中的私有部署。

CircleCI 为免费设置的虚拟配置预置了构建环境,但它也可以使用 Docker 容器运行构建,使其成为一个可以扩展到用户技能和需求解决方案,如在第九章 使用 Docker 的 DevOps 中所述:

  1. circleci.com/ 创建 CircleCI 账户。

  2. 使用 GitHub 注册:

    图 4.5:CircleCI 注册页面

  3. 添加一个新项目:

    图 4.6:CircleCI 项目页面

    在下一屏,你可以选择 LinuxmacOS 构建环境。macOS 构建环境适合构建 iOS 或 macOS 应用。然而,这些环境没有免费层;只有具有 1x 并行性的 Linux 实例是免费的。

  4. 搜索 local-weather-app 并点击 Set Up Project

  5. 选择 Linux

  6. Language 设置为 Node,它提供了一个样本 .yml 文件。

    本节使用 local-weather-app 仓库。本节的 config.yml 文件命名为 .circleci/config.ch4.yml。您还可以在 CircleCI 上找到本章中执行 yml 文件的拉取请求:github.com/duluca/local-weatherapp/pull/52 使用 branch build_ch4。请注意,此分支在 config.ymlDockerfile 中进行了修改配置,以使用 local-weather-app 中的 projects/ch4 代码。

  7. 在您的源代码中,创建一个名为 .circleci 的文件夹,并添加一个名为 config.yml 的文件:

    **.circleci/config.yml**
    version: 2.1
    jobs:
      build:
        docker:
          - image: circleci/node:lts-browsers
        working_directory: ~/repo
        steps:
          - checkout
          - restore_cache:
              keys:
                - v1-dependencies-{{ checksum "package-lock.json" }}
          - run: npm ci
          # force update the webdriver
          - run: cd ./node_modules/protractor && npm i webdrivermanager@latest
          # because we use "npm ci" to install NPM dependencies
          # we cache "~/.npm" folder
          - save_cache:
              key: v1-dependencies-{{ checksum "package-lock.json" }}
              paths:
                - ~/.npm
          - run: npm run style
          - run: npm run lint
          - run: npm run build:prod
          - run: npm run test:coverage -- --watch=false
          - run: npm run e2e
          - run:
              name: Tar & Gzip compiled app
              command: tar zcf dist.tar.gz dist/local-weather-app
          - store_artifacts:
              path: dist.tar.gz
    workflows:
      version: 2
      build-and-test:
        jobs:
          - build 
    
  8. 将您的更改同步到 Github。

  9. 在 CircleCI 上,点击 开始构建 以注册您的项目。

如果一切顺利,您应该有一个通过,绿色 的构建。如果不顺利,您会看到一个失败的,红色 的构建。以下截图显示了一个失败的构建,#97,以及随后的成功构建,#98

图 4.7:CircleCI 上的绿色构建

现在您有了绿色构建,可以利用 CircleCI 在每次代码推送时强制执行您的自动化管道的执行。GitHub 流允许我们控制代码如何流入我们的仓库。

GitHub 流

我们开发软件的主要原因是为了提供价值。在自动化软件交付方式的过程中,我们正在创建一个价值交付流。交付有缺陷的软件很容易;然而,为了可靠地提供价值,每次对代码库的更改都应该通过一系列的检查和平衡流程。

通过控制门,我们可以强制执行标准,使我们的质量控制流程对每个团队成员都是可重复的,并且能够隔离更改。如果出现问题或工作不符合您的标准,您可以轻松地丢弃提议的更改并重新开始。

GitHub 流程是定义价值交付流和实施控制门的关键部分。正如 GitHub 所说,"GitHub 流是一个轻量级的基于分支的工作流程,支持定期部署的团队和项目。"

GitHub 流程包括 6 个步骤,如下所示,来自 GitHub 的以下图形:

图 4.8:GitHub 流图

  1. 分支 – 总是在新分支中添加用于修复错误或新功能的代码

  2. 提交 – 对您的分支进行多次提交

  3. 创建拉取请求 – 向团队成员发出您的工作准备就绪的信号,并在拉取请求中查看 CI 结果

  4. 讨论和审查 – 请求对您的代码更改进行审查,处理一般性或行级评论,并进行必要的修改

  5. 部署 – 可选地在测试服务器或生产环境中测试您的代码,并具有回滚到主分支的能力

  6. 合并 – 将您的更改应用到主分支

使用 GitHub 流,您可以确保只有高质量的代码最终进入主分支。坚实的基础为其他团队成员在开始他们的更改时设定了成功的基础。为了强制执行 GitHub 流,您需要限制对主分支的推送访问。

让我们为 master 分支启用分支保护:

  1. 导航到您项目的 GitHub 设置标签页

  2. 从左侧导航面板中选择分支

  3. 点击添加规则按钮

  4. 按照以下图像配置您的规则!图片

    图 4.9:GitHub 分支保护规则

  5. 保存您的更改后,您应该在 分支 页面上看到您的新规则,如下所示:

图片

图 4.10:GitHub 分支

您不再能够直接向 master 分支提交代码。要提交代码,您首先需要从 master 创建一个分支,将更改提交到新分支,然后准备好后,使用新分支创建拉取请求。如果您不熟悉 git 命令,可以使用 GitHub Desktop 来协助您进行这些操作。请参阅 GitHub Desktop 中的实用 分支 菜单:

图片

图 4.11:GitHub Desktop 分支菜单

创建拉取请求后,您现在可以观察对您的分支运行的检查。现在我们已经配置了 CircleCI,如果一切顺利,您应该能够合并拉取请求,如下所示:

图片

图 4.12:GitHub.com 状态检查通过

当检查失败时,您必须修复任何问题后才能合并新代码。此外,如果团队成员在您正在工作分支的同时合并到 master,您可能会遇到合并冲突。在这种情况下,您可以使用 GitHub Desktop 的 从 master 更新 功能来使您的分支与最新的 master 分支保持同步。

观察以下图像中失败的拉取请求的状态:

图片

图 4.13:GitHub.com 状态检查失败

注意,我还有一个额外的检查,DeepScan,它会对我的代码库运行额外的测试。您可以在 deepscan.io 上注册您的仓库。在 第九章使用 Docker 的 DevOps 中,我演示了如何使用 Coveralls 强制执行单元测试代码覆盖率。

更多信息,请参阅 guides.github.com/introduction/flow

现在我们已经确保了我们的自动化检查正在执行,我们可以合理地确信我们不会将损坏的应用程序推送到生产环境。接下来,让我们学习如何将我们的应用程序部署到云端。

部署到云端

如果从编码的角度来看,将东西部署到生产环境很困难,那么从基础设施的角度来看,正确地做到这一点则非常复杂。在 第十三章AWS 上的高可用云基础设施 中,我介绍了如何为您的应用程序配置世界级的 AWS 弹性容器服务ECS)基础设施,但这在您需要快速展示一个想法或不需要高度可配置的解决方案时不会有所帮助。这时就出现了 Vercel Now。

Vercel Now

Vercel Now,vercel.com,是一个多云服务,它允许您直接从命令行实时全球部署应用程序。Vercel Now 支持静态文件、Node.js、PHP、Go 应用程序,以及您愿意为其编写自定义构建器的任何自定义软件堆栈,这使得与它一起工作变得相当简单。目前处于版本 2 的 Vercel Now 提供了一个免费层,您可以使用它来非常快速地部署 Angular 应用程序的dist文件夹。在第九章使用 Docker 的 DevOps中,我展示了您如何部署 Angular 应用程序的容器化版本。

请参考第二章设置您的开发环境,以获取安装 Vercel Now 的说明。

使用now工具,我们已准备好将我们的应用程序部署到网络上。

发布静态文件

在构建 Angular 项目后,构建输出位于dist文件夹中。这个文件夹中的文件被认为是静态文件;所有网络服务器需要做的就是将这些文件未修改地发送到客户端浏览器,然后浏览器动态执行您的代码。

这意味着任何网络服务器都能够提供您的 Angular 项目。然而,now使这一过程变得极其简单且免费。

让我们开始使用now的静态文件托管功能部署您的 Angular 应用程序。

  1. package.json中添加两个新的脚本,如下所示:

    **package.json**
    ...
    "scripts": {
      ...
      "prenow:publish": "npm run build:prod",
      "now:publish": "now --platform-version 2 dist/local-weather-app"
    } 
    

    要从github.com/duluca/local-weather-app部署第四章特定的代码,您需要执行now --platform-version 2 dist/ch4。接受 CLI 提示的默认选项。在我的情况下,应用程序部署到了ch4-dun.now.sh/

  2. 执行npm run now:publish

  3. 接受 CLI 提示的默认选项。

    在终端窗口中,注意 Angular 项目首先构建然后上传到now

    $ npm run now:publish
    > localcast-weather@9.0.0 prenow:publish C:\dev\local-weather-app
    > npm run build:prod
    > localcast-weather@9.0.0 build:prod C:\dev\local-weather-app
    > ng build --prod
    Generating ES5 bundles for differential loading...
    ES5 bundle generation complete.
    chunk {2} polyfills-es2015.ca64e4516afbb1b890d5.js (polyfills) 35.6 kB [initial] [rendered]
    chunk {3} polyfills-es5.1d087d4db6b105875851.js (polyfills-es5) 128 kB [initial] [rendered]
    chunk {1} main-es2015.941dc398feac35a1a67d.js (main) 485 kB [initial] [rendered]
    chunk {1} main-es5.941dc398feac35a1a67d.js (main) 577 kB [initial] [rendered]chunk {0} runtime-es2015.0811dcefd377500b5b1a.js (runtime) 1.45 kB [entry] [rendered]
    chunk {0} runtime-es5.0811dcefd377500b5b1a.js (runtime) 1.45 kB [entry] [rendered]
    chunk {4} styles.1938720bb6985e81892f.css (styles) 62 kB [initial] [rendered]Date: 2020-03-24T00:14:52.939Z - Hash: 4d78a666345c6761dc95 - Time: 14719ms  
    > localcast-weather@9.0.0 now:publish C:\dev\local-weather-app
    > now --platform-version 2 --prod dist/local-weather-app
    > UPDATE AVAILABLE Run `npm i now@latest` to install Now CLI 17.1.1
    > Changelog: https://github.com/zeit/now/releases/tag/now@17.1.1
    Now CLI 17.0.4
    ? Set up and deploy "C:\dev\local-weather-app\dist\local-weather-app"? [Y/n] y
    ? Which scope do you want to deploy to? Doguhan Uluca
    ? Found project "duluca/local-weather-app". Link to it? [Y/n] y
    ![](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/ng-entp-rdy-webapp/img/B14094_04_15.png)  Linked to duluca/local-weather-app (created .now and added it to .gitigre)
    ![](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/ng-entp-rdy-webapp/img/B14094_04_16.png)  Inspect: https://zeit.co/duluca/local-weather-app/jy2k1szdi [2s]       
    ![](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/ng-entp-rdy-webapp/img/B14094_04_17.png)  Production: https://local-weather-app.duluca.now.sh [copied to clipboard] [4s] 
    
  4. 按屏幕上显示的 URL 查看,您的应用程序已成功部署,在我的情况下,local-weather-app.duluca.now.sh

    注意关于缺少now.json文件的警告。当我们运行命令时,我们使用选项--platform-version 2指定我们的平台版本为 2,因此配置文件不是必需的。然而,如果您希望自定义部署的任何方面,例如使用自定义域名、选择地理位置或使用扩展选项,您应该配置此文件。有关如何充分利用now的更多信息,请参阅vercel.com/docs

如果您的部署成功,您应该看到您的应用程序显示了美国贝塞斯达的当前天气:

图 4.14:成功部署

完成了!恭喜,您的 Angular 应用程序已上线!

摘要

在本章中,你了解了单元测试的重要性,并掌握了 Angular 单元和端到端测试的配置和设置。你学习了如何配置 Angular 的 TestBed 以及如何使用测试替身编写单元测试。你为生产部署配置了你的 Angular 应用。通过使用 CI 管道和 GitHub 流创建价值交付流,你确保了应用程序的质量。最后,你成功地将一个网络应用程序部署到云端。

现在,你知道了构建一个可靠、有弹性且容器化的生产就绪 Angular 应用程序需要哪些条件,它允许灵活的部署策略。在下一章中,我们将介绍如何将 Angular Material 添加到你的项目中,让你的本地天气预报应用看起来很棒。在这个过程中,你将了解用户控件或 UI 组件库可能对你的应用程序产生的负面影响,包括基本 Material 组件;Angular Flex Layout;无障碍性;排版;主题;以及如何更新 Angular Material。

进一步阅读

问题

尽可能地回答以下问题,以确保你已理解本章的关键概念,无需使用 Google。你需要帮助回答这些问题吗?请参阅 附录 D自我评估答案,在线位于 static.packt-cdn.com/downloads/9781838648800_Appendix_D_Self-Assessment_Answers.pdf 或访问 expertlysimple.io/angular-self-assessment

  1. 测试金字塔是什么?

  2. 固定装置和匹配器是什么?

  3. 模拟、间谍和存根之间有什么区别?

  4. 在生产模式下构建 Angular 的好处是什么?

  5. GitHub 流是如何工作的?

  6. 为什么我们应该保护主分支?

第五章:使用 Material 提供高质量 UX

第四章,“自动化测试、CI 和发布到生产”中,我们提到了交付高质量应用程序的需求。目前,应用程序的外观和感觉非常糟糕,只适合 20 世纪 90 年代末创建的网站。用户或客户对你产品或工作的第一印象非常重要,因此我们必须能够创建一个外观出色且在移动和桌面浏览器上提供出色用户体验的应用程序。

作为全栈开发者,专注于应用程序的打磨是件困难的事情。当应用程序的功能集迅速增长时,这种情况往往会变得更糟。编写支持视图的出色模块化代码固然有趣,但随后又匆忙地使用 CSS 技巧和内联样式来改善应用程序的外观和感觉,这并不愉快。

在与 Angular 紧密协调下开发的 Angular Material 非常出色。如果你学会了如何有效地利用 Angular Material,你创建的功能将从一开始就看起来和运行得很好,无论你是在开发小型还是大型应用程序。

Angular Material 将使你成为一个更有效的 Web 开发者,因为它提供了一系列你可以利用的用户控件,你不必担心浏览器兼容性。作为额外的奖励,编写自定义 CSS 将变得罕见。

虽然本章介绍了如何创建一个吸引人的用户界面(UI),并利用 Angular Material 实现开箱即用的用户体验(UX),但了解不应该做什么也同样重要。有一个名为 User Interface 的网站,展示了 UI/UX 的糟糕实践,网址为userinyerface.com

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

  • 区分 Angular Material 作为 UI/UX 库的特点

  • 如何配置 Angular Material

  • 使用 Angular Flex Layout 进行响应式设计

  • 使用 Angular Material 提升 UX

  • 通过命令行界面(CLI)工具强制执行可访问性合规性

  • 构建交互式原型

书籍的样本代码的最新版本可在以下 GitHub 仓库链接中找到。该仓库包含代码的最终和完成状态。你可以在本章结束时通过查找projects文件夹下的代码章节快照来验证你的进度。

对于第五章

  1. 克隆以下仓库github.com/duluca/local-weather-app

  2. 在根目录下执行npm install以安装依赖项

  3. 本章的代码示例位于以下子文件夹中:

    projects/ch5 
    
  4. 要运行本章的 Angular 应用程序,请执行:

    npx ng serve ch5 
    
  5. 要运行本章的 Angular 单元测试,请执行:

    npx ng test ch5 --watch=false 
    
  6. 要运行本章的 Angular e2e 测试,请执行:

    npx ng e2e ch5 
    
  7. 要构建本章的生产就绪型 Angular 应用程序,请执行:

    npx ng build ch5 --prod 
    

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

请注意,书中或 GitHub 上的源代码可能并不总是与 Angular CLI 生成的代码相匹配。由于生态系统不断演变,书中代码与 GitHub 上代码之间的实现也可能存在细微差异。随着时间的推移,示例代码发生变化是自然的。在 GitHub 上,您可能会找到更正、修复以支持库的新版本,或者观察多种技术并行的实现。您只需实现书中推荐的理想解决方案即可。如果您发现错误或有问题,请创建一个 issue 或提交一个 pull request 到 GitHub,以供所有读者受益。

让我们先了解是什么让 Angular Material 成为 UI/UX 库的一个优秀选择。

Angular Material

Angular Material 项目的目标是提供一组有用且具有标准设置的高质量 UI 组件。该库实现了谷歌的 Material Design 规范,该规范在谷歌的移动应用、网络属性和 Android 操作系统中无处不在。Material Design 具有特定的数字和方盒式外观和感觉,但它不仅仅是一个像 Bootstrap 一样的 CSS 库。考虑这里使用 Bootstrap 编写的登录体验:

图片

图 5.1:Bootstrap 登录体验

注意,输入字段及其标签位于单独的行上,复选框是一个小目标,错误信息以临时吐司通知的形式显示,而提交按钮则静静地坐在角落里。现在,考虑以下 Angular Material 示例:

图片

图 5.2:Angular Material 登录体验

输入字段及其标签最初是合并的,以紧凑的形态因素吸引用户的注意力。复选框触感友好,提交按钮默认拉伸以占用可用空间,从而提供更响应式的用户体验。一旦用户点击一个字段,标签就会折叠到输入字段的左上角,如下所示:

图片

图 5.3:Angular Material 动画和错误

此外,验证错误信息以行内形式显示,与标签的颜色变化相结合,保持用户的注意力在输入字段上。

Material Design 帮助您设计具有自己品牌和样式的模块化 UI,同时定义动画,使用户在使用您的应用程序时拥有更好的用户体验。人类大脑无意识地跟踪对象及其位置。任何有助于过渡或对人类输入产生的变化做出反应的动画都能减少用户的认知负荷,因此使用户能够专注于处理内容,而不是试图弄清楚您特定应用程序的怪癖。

模块化 UI 设计和流畅运动的结合创造了一个出色的用户体验。看看 Angular Material 如何实现一个简单的按钮:

图片

图 5.4:Angular Material 按钮动画

在前面的屏幕截图中,请注意按钮上的点击动画是从用户实际点击的位置开始的。虽然这种效果很微妙,但它创造了一种连续的运动,从而对用户的行为产生适当的屏幕反应。当按钮在移动设备上使用时,这种效果更为明显,从而带来更自然的人机交互。大多数用户无法明确表达出什么使得直观的用户体验真正直观,而这些微妙但至关重要的设计经验和设计中的提示,可以帮助你在为用户设计这种体验方面取得巨大进步。

Angular Material 还旨在成为 Angular 高质量 UI 组件的参考实现。如果您打算开发自己的自定义控件,Angular Material 的源代码应该是您首要的资源。术语“高质量”经常被使用,而且真正重要的是要量化它的含义。Angular Material 团队在他们的网站上表达得很好:

我们所说的“高质量”是什么意思?

国际化和可访问性,确保所有用户都能使用它们。直观的 API 不会让开发者感到困惑,并且能够在各种用例中按预期工作,而不会出现错误。行为经过单元和集成测试的充分测试。在 Material 设计规范范围内可定制。性能成本降至最低。代码整洁且文档完善,可作为 Angular 开发者的示例。支持浏览器和屏幕阅读器。

Angular Material 支持所有主流浏览器的最新两个版本:Chrome(包括 Android)、Firefox、Safari(包括 iOS)和 IE11/Edge。

构建网络应用程序,尤其是那些也兼容移动设备的,是非常困难的。有很多细微之处你必须注意。Angular Material 抽象掉了这些细微之处,包括支持所有主流浏览器,这样你就可以专注于创建你的应用程序。Angular Material 不是一种时尚,也不应该被轻视。如果使用得当,你可以大大提高你的生产力和工作的感知质量。

并非总是可以在项目中使用 Angular Material。我建议使用 PrimeNG,可在 www.primefaces.org/primeng 找到,或者使用 Clarity,可在 vmware.github.io/clarity 找到,作为可以满足您大部分甚至所有用户控件需求的组件工具包。这里要避免的一件事是从不同的来源拉取数十个用户控件,最终得到一个包含数百个怪癖和错误的混乱库,这些怪癖和错误需要学习、维护或绕过。

在使用 UI 组件时,最显著的挑战之一是它们可以添加到您的应用程序包大小中的大量内容。接下来,让我们看看如何使用一致的组件库来帮助保持您应用程序的性能处于最佳状态,并为您的应用程序配置 Angular Material。

Angular Material 设置和性能

默认情况下,Angular Material 配置为优化最终交付物的包大小。在 Angular JS 和 Angular Material 1.x 中,整个依赖库都会被加载。然而,现在使用 Angular Material,我们能够指定我们打算使用的组件,从而实现显著的性能提升。

在以下表格中,您可以看到典型 Angular 1.x + Angular Material 1.x 应用程序与 Angular 6 + Material 6 应用程序在高速低延迟光纤连接下的性能特性改进:

光纤网络 Angular 6 + Material 6 Angular 1.5 + Material 1.1.5 % 差异
首次绘制(DOMContentLoaded)* 0.61 秒 1.69 秒** 大约 2.8 倍更快
JS 包大小* 113 KB 1,425 KB 12.6 倍更小

为了公平比较,结果中未包含图像或其他媒体内容

平均值:较差的基础设施导致渲染时间从 0.9 秒到 2.5 秒不等

在高速低延迟的理想条件下,Angular 6 + Material 6 应用程序在 1 秒内加载完成。然而,当我们切换到更常见的普通速度和高延迟的快速 3G 移动网络时,差异变得更加明显,如下表所示:

快速 3G 移动网络 Angular 6 + Material 6 Angular 1.5 + Material 1.1.5 % 差异
首次绘制* 1.94 秒 11.02 秒 5.7 倍更快
JS 包大小* 113 KB 1,425 KB 12.6 倍更小

为了公平比较,结果中未包含图像或其他媒体内容

尽管应用程序的大小差异保持一致,但您可以看到,由移动网络引入的额外延迟导致传统 Angular 应用程序的运行速度急剧下降,达到了不可接受的水平。

将所有组件添加到 Material 中会导致大约~1.3 MB 的额外负载需要发送给用户。正如您从之前的比较中看到的,这必须不惜一切代价避免。为了提供尽可能小的应用程序,这在移动和销售相关场景中至关重要,因为每次加载时间增加 100 毫秒都会影响用户保留率,您可以单独加载和包含模块。Webpack 的 tree-shaking 过程将模块分割成不同的文件,从而减少初始下载大小。

作为现实世界的例子,当您完成 LocalCast Weather 应用程序的最终版本构建后,您的应用程序包大小将约为 800 KB,在快速 3G 连接上使用 Angular 9 + Material 9 时,首次绘制时间仅为 2 秒多。一个功能齐全的多页面应用程序,利用懒加载,仅加载大约~300 KB 的依赖项,同时保持首次绘制时间低于 2 秒。

注意,示例应用程序包含可以裁剪掉的示例代码,这使得应用程序的体积更小。这证明了 Angular 生态系统如何提供丰富和优化的用户体验。

接下来,让我们设置 Angular Material。

安装 Angular Material

你有几种方法可以为你的 Angular 应用配置 Angular Material:

  • 使用 Angular CLI 自动安装

  • 使用 npm 手动安装

让我们开始这个任务,并使用 Angular Material 提升天气应用的 UX。将 提升应用的 UX 任务移动到我们 GitHub 项目的 进行中 状态。在这里,你可以看到我的看板状态:

图片

图 5.5:GitHub 项目看板

自动安装

从 Angular 6 开始,你可以自动将 Angular Material 添加到你的项目中,从而在过程中节省大量时间:

  1. 执行 add 命令,如下所示:

    $ npx ng add @angular/material 
    
  2. 选择名为 indigo-pink 的预构建主题

  3. 当你得到提示 “Set up global Angular Material typography styles?” 时,输入 “no

  4. 当你得到提示 “Set up browser animations for Angular Material?” 时,输入 “yes

  5. 输出应类似于以下示例:

    Installing packages for tooling via npm.
    Installed packages for tooling via npm.
    ? Choose a prebuilt theme name, or "custom" for a custom theme: Indigo/Pink        [ Preview: https://material.angular.io?theme=indigo-pink ]
    ? Set up global Angular Material typography styles? No
    ? Set up browser animations for Angular Material? Yes
    UPDATE package.json (1348 bytes)
    √ Packages installed successfully.
    UPDATE src/app/app.module.ts (423 bytes)
    UPDATE angular.json (3740 bytes)
    UPDATE src/index.html (487 bytes)
    UPDATE src/styles.css (181 bytes) 
    

    注意,index.html 文件已被修改,以添加图标库和默认字体,如下所示:

    **src/index.html**
    <head>
      ...
      <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500&display=swap" rel="stylesheet">
      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
    </head> 
    

    angular.json 文件已被更新以设置默认主题:

    **angular.json**
    ...
    "styles": [
      "./node_modules/@angular/material/prebuilt-themes/indigo-pink.css",
      "src/styles.css"
    ],
    ... 
    

    styles.css 已被更新为默认的全局 CSS 样式:

    **src/styles.css**
    html, 
    body { 
      height: 100%; 
    }
    body { 
      margin: 0; 
      font-family: Roboto, "Helvetica Neue", sans-serif; 
    } 
    

    还请注意,app.module.ts 已被更新以导入 BrowserAnimationsModule,如下所示:

    **src/app/app.module.ts**
    import { BrowserAnimationsModule } from '@angular/platform- browser/animations';
    @NgModule({ 
      declarations: [
        AppComponent
      ],
      imports: [
        ...
        BrowserAnimationsModule
      ], 
    
  6. 启动你的应用并确保它正常工作:

    $ npm start 
    

到此为止,你已经完成了。你的应用应该已经配置了 Angular Material。你现在可以跳转到 导入模块 部分,看看如何以稳健的方式导入 Material 模块。

我强烈建议快速浏览所有手动安装和配置步骤。知道的越多越好!

仍然重要的是要理解构成 Angular Material 的所有各种组件,或者你可能不喜欢自动化的东西;在接下来的章节中,我们将介绍手动安装和配置步骤。

手动安装

我们将首先安装所有必需的库。截至 Angular 5,Angular Material 的主版本应与你的 Angular 安装版本相匹配,并且从 Angular 6 开始,版本应同步:

  1. 在终端中执行 npm install @angular/material @angular/cdk

  2. 观察到 package.json 版本:

    **package.json**
     "dependencies": { 
        "@angular/cdk": "9.0.0",
        "@angular/material": "9.0.0", 
        ... 
    

在这种情况下,所有库都有相同的主版本和次版本。如果你的主版本和次版本不匹配,你可以重新运行 npm install 命令来安装特定版本,或者选择通过将包的服务器版本附加到 install 命令来升级你的 Angular 版本:

$ npm install @angular/material@9.0.0 @angular/cdk@9.0.0 

如果你在一个类似 Bash 的 shell 中工作,你可以通过使用方括号语法来节省一些输入,避免重复命令的部分,形式为 npm install @angular/{material,cdk}@9.0.0

如果你需要更新你的 Angular 版本,请参考附录 C 中的更新 Angular部分,保持 Angular 和工具始终如一。您可以从static.packt-cdn.com/downloads/9781838648800_Appendix_C_Keeping_Angular_and_Tools_Evergreen.pdfexpertlysimple.io/stay-evergreen在线找到此附录。

理解 Material 的组件

让我们看看我们到底在安装什么:

  • @angular/material是官方的 Material 库。

  • @angular/cdk是一个同级依赖项,除非你打算构建自己的组件,否则你不会直接使用它。

  • @angular/animations为某些 Material 模块启用了一些动画。可以省略以保持应用大小最小。你可以使用NoopAnimationsModule来禁用需要此依赖项的模块中的动画。结果,你将失去 Angular Material 的一些 UX 优势。

手动配置 Angular Material

现在依赖项已安装,让我们在 Angular 应用中配置 Angular Material。请注意,如果你使用ng add @angular/material安装 Angular Material,其中一些工作将为你完成。

导入模块

我们将首先创建一个单独的模块文件来存放所有的 Material 模块导入:

  1. 在终端中执行以下命令以生成material.module.ts

    $ npx ng g m material --flat -m app 
    

    注意使用--flat标志,它表示不应为material.module.ts创建额外的目录。另外,注意-m--module的别名,这样我们的新模块就会自动导入到app.module.ts中。

  2. 观察新创建的文件material.module.ts并移除CommonModule

    **src/app/material.module.ts**
    import { NgModule } from '@angular/core' 
    @NgModule({
      imports: [], 
      declarations: [],
    })
    export class MaterialModule {} 
    
  3. 确保模块已被导入到app.module.ts中:

    **src/app/app.module.ts**
    import { MaterialModule } from './material.module'
    ...
    @NgModule({
      ...
      imports: [..., MaterialModule],
    } 
    
  4. 添加动画和手势支持(如果尚未自动添加):

    **src/app/app.module.ts**
    import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
    @NgModule({
      ...
      imports: [..., MaterialModule, BrowserAnimationsModule],
    } 
    
  5. 修改material.module.ts以导入和导出MatButtonMatToolbarMatIcon的基本组件:

    **src/app/material.module.ts**
    import { NgModule } from '@angular/core'
    import { MatButtonModule } from '@angular/material/button'
    import { MatIconModule } from '@angular/material/icon'
    import { MatToolbarModule } from '@angular/material/toolbar'
    @NgModule({
      imports: [
        MatButtonModule, MatToolbarModule, MatIconModule
      ], 
      exports: [
        MatButtonModule, MatToolbarModule, MatIconModule
      ],
    })
    export class MaterialModule {} 
    

    importsexports数组有时会变得很长且重复。如果你在其中一个数组中遗漏了一个元素,你可能会花费数小时来追踪错误。考虑实现一个单一的数组作为常量,你可以将其分配给importsexports属性以获得更可靠的配置。感谢 Brendon Caulkins 提供的提示。

  6. 优化你的代码,将你的模块存储在数组中,并重复使用它来导入和导出:

    **src/app/material.module.ts**
    ...
    const modules = 
      [MatButtonModule, MatToolbarModule, MatIconModule]
    @NgModule({
      declarations: [],
      imports: modules,
      exports: modules,
    })
    export class MaterialModule {} 
    

现在 Material 已被导入到应用中;现在让我们配置一个主题并向我们的应用添加必要的 CSS。

导入主题

为了使用 Material 组件,需要一个基本主题。我们在安装 Angular Material 时已经选择了一个默认主题。我们可以在angular.json中定义或更改默认主题:

**angular.json**
...
"styles": [
  {
    "input":
      "node_modules/@angular/material/prebuilt-themes/indigo-pink.css"
  },
  "src/styles.css"
],
... 

从这里选择一个新的选项:

  • deeppurple-amber.css

  • indigo-pink.css

  • pink-bluegrey.css

  • purple-green.css

更新angular.json以使用新的 Material 主题。

你也可以创建自己的主题,这在本章的自定义主题部分有介绍。更多信息,请访问material.angular.io/guide/theming

注意,在styles.css中实现的任何 CSS 都将全局可用。话虽如此,请不要在此文件中包含特定视图的 CSS。每个组件都有自己的 CSS 文件用于此目的。

添加 Material 图标字体

你可以通过将 Material Icon 网络字体添加到你的应用程序中,来获取一个很好的默认图标集。这个库的大小为 48 KB,是一个非常轻量级的库。

为了支持图标,请在index.html中导入字体:

**src/index.html**
<head>
  ...
  <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head> 

material.io/resources/icons上发现和搜索图标。

为了获取更丰富的图标集,请访问MaterialDesignIcons.com。这个图标集包含了 Material 图标的基础集合,以及一个丰富的第三方图标集,其中包含来自社交媒体网站的有用图像,涵盖了众多丰富的操作,覆盖了广泛的领域。这个字体大小为 118 KB。

我们为 Angular Material 配置了 UI/UX 库。我们还需要一个布局库,以便在页面上放置组件时使生活更加轻松。

接下来,让我们了解不同的布局技术,从 Bootstrap 到 Flexbox CSS,以及为什么 Angular Flex Layout 是管理布局的绝佳工具。在为我们的应用程序配置 Angular Flex Layout 之后,我们将准备好在我们的应用程序中实现 Material UI 组件。

Angular Flex Layout

在你能够有效使用 Material 之前,你必须了解其布局引擎。如果你已经从事了一段时间的 Web 开发,你可能已经遇到过 Bootstrap 的 12 列布局系统。我发现这非常令人烦恼,因为它在我的大脑中遇到了一个数学障碍,我的大脑习惯于将事物分成 100%的部分。Bootstrap 还要求严格遵循div列和行的层次结构,这必须从你的顶级 HTML 精确管理到最底层。这可能会使开发体验非常令人沮丧。

在下面的屏幕截图中,你可以看到 Bootstrap 的 12 列方案看起来如何:

图 5.6:Bootstrap 的 12 列布局方案

Bootstrap 的定制网格布局系统在当时是革命性的,但随后 CSS3 Flexbox 出现了。结合媒体查询,这两种技术允许创建响应式 UI。然而,有效地利用这些技术是非常费力的。截至 Angular v4.1,Angular 团队引入了其 Flex Layout 系统,它只需简单设置即可工作。

GitHub 上的 Angular Flex Layout 文档很好地解释了以下内容:

Angular Flex Layout 使用 FlexBox CSS 和 mediaQuery 提供了一个复杂的布局 API。此模块为 Angular(v4.1 及以上)开发者提供了使用自定义 Layout API、mediaQuery 可观察对象和注入 DOM flexbox-2016 CSS 样式的组件布局功能。

Angular 的出色实现使得使用 Flexbox 变得非常容易。正如文档进一步解释的那样:

布局引擎智能地自动化了将适当的 FlexBox CSS 应用于浏览器视图层次结构的过程。这种自动化还解决了使用传统的、仅 CSS 的手动应用 FlexBox CSS 时遇到的大多数复杂性和解决方案。

该库功能强大,可以适应您能想象到的任何类型的网格布局,包括与您期望的所有 CSS 功能集成,例如calc()函数。在下一幅插图,您可以看到如何使用 CSS Flexbox 描述列:

图 5.7:Angular Flex Layout 方案

好消息是,Angular Flex Layout 与 Angular Material 没有任何耦合,可以独立使用。这是一个非常重要的解耦,解决了使用 AngularJS 与 Material v1 时的一个主要痛点,即 Material 的版本更新经常会引起布局中的错误。

更多详情,请查看github.com/angular/flex-layout/wiki

您会注意到@angular/flex-layout安装时带有 beta 标签。这个状态已经持续了很长时间。由于库无法覆盖回 Internet Explorer 11 的所有边缘情况,这阻止了它退出 beta。然而,在持续更新的浏览器中,我发现该库的行为是可靠和一致的。此外,CSS Grid 有取代 CSS Flexbox 的趋势,因此,该库使用的底层技术可能会改变。我的愿望是,这个库作为布局引擎下方的抽象层。

响应式布局

所有您设计和构建的用户界面(UI)都应该是以移动端优先的 UI。这不仅仅是为了服务于手机浏览器,还包括笔记本电脑用户可能在一侧并排使用您的应用程序的情况。正确实现以移动端优先的设计有很多细微之处。

以下是Mozilla Holy Grail 布局,它展示了“动态改变不同屏幕分辨率的布局”的能力,同时优化移动设备上的显示内容。

您可以在mzl.la/2vvxj25了解更多关于 Flexbox 基本概念的信息。

这表示了 UI 在大屏幕上的外观:

图 5.8:大屏幕上的 Mozilla Holy Grail 布局

如下所示,相同的布局在小屏幕上表示:

图 5.9:小屏幕上的 Mozilla Holy Grail 布局

Mozilla 的参考实现需要 85 行代码来完成这种响应式 UI。Angular Flex Layout 只需一半的代码就能完成同样的任务。

安装 Angular Flex Layout

让我们安装并将 Angular Flex Layout 添加到我们的项目中:

  1. 在终端中执行 npm i @angular/flex-layout

    要解决依赖错误,执行 npm i @angular/flex-layout@nextnpm i @angular/flex-layout --force,如 附录 C 中所述,保持 Angular 和工具始终如一。您可以从 static.packt-cdn.com/downloads/9781838648800_Appendix_C_Keeping_Angular_and_Tools_Evergreen.pdfexpertlysimple.io/stay-evergreen 在线找到此附录。

  2. 更新 app.module.ts,如下所示:

    **src/app.module.ts**
    import { FlexLayoutModule } from '@angular/flex-layout'
    imports: [..., **FlexLayoutModule** ], 
    

安装 Flex Layout 后,让我们来了解一下库的基本工作原理。

布局基础

Bootstrap 和 CSS Flexbox 与 Angular Flex Layout 不同。如果你学习 Angular Flex Layout,你会发现你将使用更少的布局代码,因为 Angular Material 大多数时候会自动做正确的事情,但一旦你意识到一旦离开 Angular Flex Layout 的保护壳,你需要编写更多的代码才能使事物工作,你可能会感到失望。然而,你的技能仍然可以迁移,因为概念大多相同。

让我们在以下章节中回顾 Flex Layout API。

如果你是 CSS 或 Flexbox 的初学者,一些使用的缩写可能没有意义。我建议你尝试在文档中提供的实时演示应用程序中实验,以更好地了解库在更直观的层面的功能。更多信息及访问实时演示的链接请访问 github.com/angular/flex-layout/wiki/Declarative-API-Overview

Flex Layout API 用于 DOM 容器

这些指令可以用于 DOM 容器,如 <div><span>,以操纵它们的布局方向、对齐或元素之间的间隙。

考虑以下示例:

<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="15px">...</div> 

div 被布局为行,因此多个 div 将会堆叠在一起,而不是列布局,它们会并排渲染。

div 在其父容器内水平左对齐且垂直居中。

div 与其周围元素之间有 15-px 的间隙。

考虑以下图表来在空间上映射 fxLayout 术语:

css3-flexbox-model

图 5.10:Angular Flex Layout 术语的空间映射

选项的完整列表如下表所示:

HTML API 允许的值
fxLayout <direction> | <direction> <wrap>使用:row | column | row-reverse | column-reverse
fxLayoutAlign <main-axis> <cross-axis> main-axis: start
fxLayoutGap %

DOM 元素的 Flex 布局 API

这些指令影响 DOM 元素在其容器内的行为。

考虑以下示例:

<div fxLayout="column">
  <input fxFlex />
</div> 

input 元素会扩展以填充父 div 提供的所有可用空间。如果 fxFlex 被设置为 fxFlex="50%",它将只填充可用空间的一半。在这种情况下,可以使用 fxFlexAlign 属性在 div 内部左对齐、右对齐或居中对齐元素。

以下表格展示了完整的选项列表:

HTML API 允许的值
fxFlex ""
fxFlexOrder int
fxFlexOffset %
fxFlexAlign start
fxFlexFill none

任何元素的 Flex 布局 API

以下指令可以应用于任何 HTML 元素,以显示、隐藏或更改这些元素的样式和外观。

考虑以下示例:

<div fxShow fxHide.lt-sm></div> 

默认设置为 truefxShow 将显示 div 元素。除非 lt-sm 条件变为 true,这发生在浏览器窗口缩小到 small 的阈值以下。Small 被定义为 468 像素的像素值。因此,如果浏览器窗口的宽度缩小到 467 像素或更少,fxHide 将隐藏 div 元素。

以下表格展示了完整的选项列表:

HTML API 允许的值
fxHide TRUE
fxShow TRUE
ngClass @extends ngClass core
ngStyle @extends ngStyle core

本节涵盖了静态布局的基础知识。您可以在github.com/angular/flex-layout/wiki/Declarative-API-Overview了解更多关于静态 API 的信息。我们将在第十一章“食谱 - 可重用性、路由和缓存”中介绍响应式 API。您可以在github.com/angular/flex-layout/wiki/Responsive-API了解更多关于响应式 API 的信息。

现在我们已经配置了布局引擎,并且你对它的工作原理有了基本的了解,我们可以开始构建我们应用的屏幕。

使用 Material 组件

现在我们已经安装了所有各种依赖项,我们可以开始修改我们的 Angular 应用以添加 Material 组件。我们将添加一个工具栏和一个 Material Design 卡元素,并介绍可访问性和排版问题,同时涵盖基本的布局技术。

Angular Material 规划图

自从 Angular 6 和脚图引入以来,像 Material 这样的库可以提供自己的代码生成器。在出版时,Angular Material 随带三个基本的生成器,用于创建具有侧导航、仪表板布局或数据表的 Angular 组件。你可以在 material.angular.io/guide/schematics 上了解更多关于生成器脚图的信息。

例如,你可以通过执行以下命令创建一个侧导航布局:

$ ng generate @angular/material:material-nav --name=side-nav
CREATE src/app/side-nav/side-nav.component.css (110 bytes) CREATE src/app/side-nav/side-nav.component.html (945 bytes) CREATE src/app/side-nav/side-nav.component.spec.ts (619 bytes) CREATE src/app/side-nav/side-nav.component.ts (489 bytes) UPDATE src/app/app.module.ts (882 bytes) 

此命令更新 app.module.ts,直接将该文件中的 Material 模块导入,破坏了之前建议的 material.module.ts 模式。此外,一个新的 SideNavComponent 作为单独的组件添加到应用中,但如第八章 设计身份验证和授权 中的 侧导航 节所述,这种导航体验需要在应用的根目录中实现。

简而言之,Angular Material Schematics 使得向你的 Angular 应用添加各种 Material 模块和组件变得更加简单;然而,正如提供的,这些脚图并不适合创建一个灵活、可扩展且结构良好的代码库,这正是本书追求的目标。

目前,我建议将这些脚图用于快速原型设计或实验目的。

现在,让我们开始手动向我们的 LocalCast Weather 应用添加一些组件。

使用 Material 工具栏修改登录页面

在我们开始对 app.component.ts 进行进一步修改之前,让我们将组件切换到使用内联模板和内联样式,这样我们就不必在文件之间来回切换,因为这是一个相对简单的组件:

  1. app.component.ts 更新为使用内联模板。将 app.component.html 的内容剪切并粘贴到 app.component.ts 中,并移除如下所示的 styleUrls 属性:

    **src/app/app.component.ts**
    import { Component } from '@angular/core'
    @Component({
      selector: 'app-root', 
     **template: `**
        <div style="text-align:center">
          <h1>
            LocalCast Weather
          </h1>
          <div>Your city, your forecast, right now!</div>
          <h2>Current Weather</h2>
          <app-current-weather></app-current-weather>
        </div>
      **`,**
    })
    export class AppComponent {} 
    
  2. 删除文件 app.component.htmlapp.component.css

  3. 让我们从实现一个全局工具栏开始改进我们的应用。观察 app.component.ts 中的 h1 标签:

    **src/app/app.component.ts**
    <h1>
      LocalCast Weather
    </h1> 
    
  4. 更新 h1 标签为 mat-toolbar:

    **src/app/app.component.ts**
    **<mat-toolbar>**
      <span>LocalCast Weather</span>
    **</mat-toolbar>** 
    
  5. mat-toolbar 更新为更具吸引力的颜色:

    **src/app/app.component.ts**
    <mat-toolbar **color="primary"**> 
    

注意,如果你的应用未能按照前面章节 导入模块 中所述导入 MatToolbarModule,则应用将无法编译。

注意,Material 会添加以下全局样式:

**src/styles.css**
body {
  margin: 0;
} 

0 外边距提供了原生应用的感觉,其中工具栏触及浏览器的边缘。这在大屏幕和小屏幕格式上都很好用。当你将汉堡菜单或帮助按钮等可点击元素放置在工具栏的左侧或右侧时,你会避免用户点击空白区域的可能性。这就是为什么 Material 按钮实际上比视觉上表示的点击区域更大。这为制作无烦恼的用户体验做出了很大的贡献。

类似地,如果你正在构建一个信息密集型的应用程序,请注意,你的内容将延伸到应用程序的边缘,这使得内容更难以阅读,这不是一个理想的结果。在这些情况下,你应该在内容区域包裹一个div,并使用 CSS 应用适当的边距,如下所示:

**example**
.content-margin { 
  margin-left: 8px; 
  margin-right: 8px;
} 

在下一个屏幕截图中,你可以看到边缘到边缘的工具栏,并应用了主要颜色:

图 5.11:改进后的工具栏的 LocalCast 天气

现在我们已经配置了工具栏,让我们继续制作天气信息的容器。

材料卡片

材料卡片是一个很好的容器,可以表示当前的天气信息。卡片元素周围有一个阴影,将内容与其周围环境区分开来:

  1. material.module中导入MatCardModule

    **src/app/material.module.ts**
    import { MatCardModule } from '@angular/material/card'
    ...
    const modules = […, MatCardModule] 
    
  2. AppComponent的模板中,将<app-current-weather>包围在<mat-card>中:

    **src/app/app.component.ts**
    ...
      template: `
      ...
      <div style="text-align:center">
        <mat-toolbar color="primary">
          <span>LocalCast Weather</span>
        </mat-toolbar>
        <div>Your city, your forecast, right now!</div>
        **<mat-card>**
          <h2>Current Weather</h2>
          <app-current-weather></app-current-weather>
        **</mat-card>**
      </div>
      ...
      `,
    ... 
    
  3. 观察屏幕底部附近的几乎无法区分的卡片元素及其阴影:

    图 5.12:LocalCast 天气与难以区分的卡片元素

    为了更好地布局屏幕,我们需要切换到 Flex 布局引擎。我们将从移除组件模板中的训练轮开始。

  4. 从最外层的<div>元素中移除style="text-align:center"

  5. 使用以下 HTML 将<mat-card>包围起来,其中<mat-card>的内容替换了代码中间的省略号:

    要在页面上居中一个元素,我们需要创建一个行,为居中元素分配一个宽度,并在两侧创建两个额外的列,这些列可以伸缩以填充空余空间,如下所示:

    **src/app/app.component.ts**
    ...
    <div fxLayout="row">
      <div fxFlex></div>
      <div fxFlex="300px">
          **...**
      </div>
      <div fxFlex></div>
    </div>
    ... 
    
  6. 注意到mat-card元素被正确地居中,如下所示:

图 5.13:LocalCast 天气与居中的卡片

通过阅读卡片文档并查看 Material 文档站点material.angular.io/components/card/overview上的示例,你会注意到mat-card提供了容纳标题和内容的元素。我们将在接下来的部分中实现这一点。

material.angular.io上,你可以通过点击括号图标查看任何示例的源代码,或者通过点击箭头图标在StackBlitz.io上启动一个工作示例。

卡片标题和内容

现在,让我们使用mat-card-headermat-card-content来实现mat-card的标题和内容元素,如下所示:

**src/app/app.component.ts**
...
<mat-toolbar color="primary">
  <span>LocalCast Weather</span>
</mat-toolbar>
<div>Your city, your forecast, right now!</div>
<div fxLayout="row">
  <div fxFlex></div>
  <div fxFlex="300px">
    <mat-card>
      **<mat-card-header>**
 **<mat-card-title>Current Weather</mat-card-title>**
 **</mat-card-header>**
 **<mat-card-content>**
 **<app-current-weather></app-current-weather>**
 **</mat-card-content>**
    </mat-card>
  </div>
  <div fxFlex></div>
</div>
... 

所有 Material 元素都原生支持 Flex 布局引擎。这允许我们优化我们的 HTML,将<div fxFlex="300px"><mat-card>合并并简化代码:

**src/app/app.component.ts**
...
<div fxLayout="row">
  <div fxFlex></div>
  **<mat-card fxFlex="300px">**
    ...
  </mat-card>
  <div fxFlex></div>
</div>
... 

这对复杂 UI 的可维护性有巨大的积极影响。

不要忘记:使用 Material,少即是多。

在我们应用mat-card-headermat-card-content之后,你可以看到这个结果:

图 5.14:LocalCast 天气卡片,带有标题和内容

注意,卡片内的字体现在与材料的 Roboto 字体相匹配。然而,当前天气不再像以前那样引人注目。如果你在mat-card-title内的h2标签中添加回来,当前天气在视觉上看起来会更大;然而,字体不会与你的应用程序的其他部分匹配。要解决这个问题,你必须了解材料的排印功能。

材料排印

材料文档恰如其分地表述如下:

排印是排列文本以使其在显示时易于阅读、可读且吸引人的方式。

材料提供了一种不同的排印级别,它具有不同的font-sizeline-heightfont-weight特性,你可以将这些特性应用于任何 HTML 元素,而不仅仅是那些开箱即用的组件。

下表列出了你可以用来应用材料排印的 CSS 类:

考虑以下示例:

<div class="mat-display-4">Hello, Material world!</div> 

display-4排印通过在div前添加"mat-"来应用于div

查看下表以获取完整的排印样式列表:

类名 用途
display-4, display-3, display-2, 和 display-1 大型一次性标题,通常位于页面顶部(例如,英雄标题)
h1, headline <h1>标签对应的章节标题
h2, title <h2>标签对应的章节标题
h3, subheading-2 <h3>标签对应的章节标题
h4, subheading-1 <h4>标签对应的章节标题
body-1 基础正文文本
body-2 更粗的正文文本
Caption 较小的正文和提示文本
Button 按钮

你可以在material.angular.io/guide/typography了解更多关于材料排印的信息。

应用排印

应用排印的方式有多种。一种方式是利用mat-typography类并使用相应的 HTML 标签,例如<h2>

**example**
<mat-card-header class="mat-typography">
<mat-card-title><h2>Current Weather</h2></mat-card-title>
</mat-card-header> 

另一种方式是将特定的排印直接应用于一个元素上,例如class="mat-title"

**example**
<mat-card-title>
  <div class="mat-title">Current Weather</div>
</mat-card-title> 

注意,class="mat-title"可以应用于divspan或具有相同结果的h2

作为一条经验法则,通常实施更具体和本地化的选项会更好,这里就是第二种实现。

在接下来的章节中实现材料排印时,我们需要确保卡片标题在屏幕上的其他元素中脱颖而出。在这种情况下,我更喜欢mat-headline排印的外观来实现这一目标,因此你的实现应该看起来像:

**src/app/app.component.ts**
<mat-card-title>
  <div **class="mat-headline"**>Current Weather</div>
</mat-card-title> 

接下来,让我们看看如何对屏幕上的其他元素进行对齐。

弹性布局对齐

我们可以使用fxLayoutAlign来居中对齐应用程序的标语,并使用mat-caption排印给它一种低调的外观:

  1. 使用fxLayoutAlign居中对齐包含标语标签的div

    **src/app/app.component.ts**
    **<div fxLayoutAlign="center">**
      <div>
        Your city, your forecast, right now!
      </div>
    **</div>** 
    
  2. mat-caption排印应用于标语:

    **src/app/app.component.ts**
    <div **class="mat-caption"**>
      Your city, your forecast, right now!
    </div> 
    
  3. 观察以下结果,如图所示:

图 5.15:带有居中标语标签的 LocalCast 天气

接下来,我们需要对齐和样式化元素以匹配设计。

Flex 布局

为了使 UI 看起来像设计,还需要做更多的工作。观察以下当前天气卡的以下设计:

图 5.16:当前天气的 Lo-fi 设计

为了设计布局,我们将利用 Angular Flex。

你将编辑 current-weather.component.html,它使用 <div><span> 标签来建立分别位于单独行或同一行的元素。随着切换到 Angular Flex,我们需要将所有元素切换到 <div>,并使用 fxLayout 指定行和列。

实施布局脚手架

我们需要首先实现初步的脚手架。考虑模板的当前状态:

**src/app/current-weather/current-weather.component.html**
 **1**  <div *ngIf="!current">
 **2**    no data
 **3**  </div>
 **4**  <div *ngIf="current">
 **5**    <div>
 **6**      <span>{{current.city}}, {{current.country}}</span>
 **7**      <span>{{current.date | date:'fullDate'}}</span>
 **8**    </div>
 **9**    <div>
**10**      <img [src]='current.image'>
**11**      <span>{{current.temperature | number:'1.0-0'}}˚F</span>
**12**    </div>
**13**    <div>
**14**      {{current.description}}
**15**    </div>
**16**  </div> 

让我们一步一步地通过文件并更新它。首先,让我们进行结构更改以支持 Flex 布局:

  1. 在第 6、7 和 11 行,将 <span> 元素更新为 <div> 元素。

  2. 在第 10 行,将 <img> 元素包裹在一个 <div> 元素中。

  3. 在第 5 和 9 行,向具有多个子元素的父 <div> 元素添加 fxLayout="row" 属性。

接下来,将 fxFlex 属性应用到 div 元素上,以确定元素应占用多少水平空间:

  1. 在第 6 行,城市和国家列应占据屏幕的大约 ²⁄³,因此向 <div> 元素添加 fxFlex="66%"

  2. 在第 7 行,向 <div> 元素添加 fxFlex 以确保它填满剩余的水平空间。

  3. 在第 10 行,向包围 <img> 元素的新 <div> 元素添加 fxFlex="66%"

  4. 在第 11 行,向 <div> 元素添加 fxFlex

模板的最终状态应如下所示:

**src/app/current-weather/current-weather.component.html**
 **5**    <div fxLayout="row">
 **6**      **<div fxFlex="66%">**{{current.city}}, ...**</div>**
 **7**      **<div fxFlex>**{{current.date | date:'fullDate'}}**</div>**
 **8**    </div>
 **9**    <div **fxLayout="row"**>
**10**      **<div fxFlex="66%">**<img [src]='current.image'>**</div>**
**11**      **<div fxFlex>**{{current.temperature | number:'1.0-0'}}˚F**</div>**
**12**    </div>
**13**    <div>
**14**      {{current.description}}
**15**    </div> 

你可以在添加 Angular Flex 属性时更加详细;然而,你写的代码越多,你需要维护的也就越多,这会使未来的更改更加困难。例如,在第 13 行,<div> 元素不需要 fxLayout="row",因为 <div> 隐式地得到一个新行。同样,在第 7 和 11 行,右侧列不需要显式的 fxFlex 属性,因为左侧元素会自动压缩它。但是,我们将保留那些 fxFlex 属性。

从网格放置的角度来看,所有元素现在都位于正确的 单元格 中,如图所示:

图 5.17:带有布局脚手架的 LocalCast 天气

实现响应式设计后,接下来让我们处理主要元素的对齐。

使用 CSS 对齐元素

现在,我们需要对齐和样式化每个单元格以匹配设计。为此,我们依赖于 CSS 而不是 fxLayoutAlign。日期和温度需要右对齐,而描述需要居中:

  1. 要使日期和温度右对齐,请在 current-weather.component.css 中创建一个新的 CSS 类 .right

    **src/app/current-weather/current-weather.component.css**
    .right {
      text-align: right
    } 
    
  2. 在第 7 和 11 行的 <div> 元素上添加 class="right"

  3. 以与本章早期中心对齐应用标语相同的方式,将描述的 <div> 元素居中。使用具有 fxLayoutAlign="center" 属性的周围 div

  4. 观察到元素对齐正确,如下所示:

图 5.18:LocalCast 天气正确对齐

在对主要元素进行对齐后,让我们为每个元素应用第一层样式以匹配设计。

单独设置元素样式

完成元素样式通常是前端开发中最耗时的部分。我建议先进行多次迭代,以最小的努力实现设计的一个足够接近的版本,然后让你的客户或团队决定是否值得投入额外资源来进一步润色设计:

  1. 添加一个新的 CSS 属性:

    **src/app/current-weather/current-weather.component.css**
    .no-margin { 
      margin-bottom: 0
    } 
    
  2. 对于城市名称,添加 class="mat-title no-margin"

  3. 对于日期,将 class="right" 修改为添加 "mat-h3 no-margin"

  4. 将日期的显示格式从 'fullDate' 更改为 'EEEE MMM d' 以匹配设计。

  5. <img> 修改为添加 style="zoom: 175%"

  6. 对于温度,将 class="right" 修改为添加 "mat-display-3 no-margin"

  7. 对于描述,添加 class="mat-caption"

    这是模板的最终状态:

    **src/app/current-weather/current-weather.component.html**
    <div *ngIf="!current">
      no data
    </div>
    <div *ngIf="current">
      <div fxLayout="row">
        <div fxFlex="66%" class="mat-title no-margin">
          {{current.city}}, {{current.country}}
        </div>
        <div fxFlex class="right mat-h3 no-margin">
          {{current.date | date:'EEEE MMM d'}}
        </div>
      </div>
      <div fxLayout="row">
        <div fxFlex="66%">
          <img style="zoom: 175%" [src]='current.image'>
        </div>
        <div fxFlex class="right mat-display-3 no-margin">
        {{current.temperature | number:'1.0-0'}}˚F
      </div>
    </div>
      <div fxLayoutAlign="center">
        <div class="mat-caption">
          {{current.description}}
        </div>
      </div>
    </div> 
    
  8. 观察到代码的样式输出发生了变化,如图所示:

    图 5.19:LocalCast 天气样式

我们已经完成了设计的第一层样式添加。接下来,让我们微调元素之间的间距和对齐。

微调样式

标语可以从一些顶部和底部边距中受益。这是我们可能会在整个应用中使用的常见 CSS,所以让我们将其放入 styles.css

  1. 在全局 styles.css 中实现 vertical-margin

    **src/styles.css**
    .vertical-margin { 
      margin-top: 16px; 
      margin-bottom: 16px;
    } 
    
  2. app.component.ts 中,为应用的标语应用 vertical-margin

    **src/app/app.component.ts**
    <div class="mat-caption **vertical-margin**">
      Your city, your forecast, right now!
    </div> 
    
  3. current-weather.component.html 中,图片和温度没有居中,所以将这些元素的外围 div 添加 fxLayoutAlign="center center"

    **src/app/current-weather/current-weather.component.html**
    <div fxLayout="row" **fxLayoutAlign="center center**">
      ...
    </div> 
    
  4. 观察你应用最终布局,它应该看起来像这样:

    图 5.20:LocalCast 天气最终布局

最后,让我们通过紧缩我们的设计来增加一些视觉亮点,比如修复日期和月份之间缺失的换行符,并添加一些锦上添花的特性。

调整以匹配设计

这是你可能会花费大量时间的地方。如果我们遵循 80-20 原则,像素级的调整通常会是最后的 20%,而这需要 80% 的时间来完成。让我们比较前一个图中的实现和以下图中的原始设计,以及弥合差距需要做什么:

图 5.21:LocalCast 天气原始设计

日期需要进一步定制。在我们的实现中缺少数字序数th;为了完成这个任务,我们需要引入第三方库,如moment,或者实现我们自己的解决方案并将其绑定到模板上的日期旁边:

  1. CurrentWeatherComponent中实现一个getOrdinal函数:

    **src/app/current-weather/current-weather.component.ts**
    export class CurrentWeatherComponent implements OnInit {
      ...
      getOrdinal(date: number) {
        const n = new Date(date).getDate()
        return n > 0
          ? ['th', 'st', 'nd', 'rd'][(n > 3 && n < 21) || 
                                      n % 10 > 3 ? 0 : n % 10]
          : ''
      }
      ...
    } 
    
  2. 在模板中,更新current.date以向其添加一个序数:

    **src/app/current-weather/current-weather.component.html**
    <div fxFlex class="right mat-h3 no-margin">
      {{current.date | 
        date:'EEEE MMM d'}}**{{getOrdinal(current.date)}}**
    </div> 
    

    注意,getOrdinal的实现归结为一个复杂的一行代码,可读性差,且难以维护。如果这些函数对你的业务逻辑至关重要,应该对它们进行大量的单元测试。

    接下来,让我们修复星期和月份之间缺失的换行符。在某些日子,比如 3 月 23 日(星期一),星期一和三月将位于第一行,而 23 日(星期一)则单独位于第二行。然而,在 3 月 24 日(星期二),这个问题并不存在,三月和 24 日(星期二)都位于同一行。在发布时,Angular 不支持日期模板中的新行断开;理想情况下,我们应该能够指定日期格式为“EEEE\nMMM d”,以确保行断开始终一致。然而,我们可以对问题进行一些低效的代码处理,并强制执行我们想要的操作。

  3. 将当前日期分成两部分,并用换行标签<br>分隔它们,然后从外部的div中移除类:

    **src/app/current-weather/current-weather.component.html**
    <div fxFlex class="mat-h3 no-margin">
      {{current.date | date:'EEEE'}}<br>
      {{current.date | date:'MMM d'}}{{getOrdinal(current.date)}}
    </div> 
    

    不要为了布局目的使用<br>。在这个有限的例子中,这是可以接受的,因为我们正在将内容拆分在divp标签内。

    现在,让我们添加一些视觉亮点,当显示温度单位时。为了实现这一点,温度实现需要使用<span>元素将数字与单位分开,并用<p>元素包围,这样就可以在单位上应用上标样式,例如<span class="unit">``˚``F</span>,其中unit是一个 CSS 类,使其内容看起来像上标元素。

  4. 实现一个unit CSS 类:

    **src/app/current-weather/current-weather.component.css**
    .unit {
    vertical-align: super;
    } 
    
  5. 将图像的弹性设置为 55%,将温度和单位用p标签包裹,并在p标签上应用mat-display-3。然后,在温度单位周围实现一个span,并用p标签应用unitmat-display-1类:

    **src/app/current-weather/current-weather.component.html**
    <div fxFlex=**"55%"**>
      <img style="zoom: 175%" [src]='current.image'>
    </div>
    <div fxFlex class="right no-margin">
      **<p class="mat-display-3">**
        {{current.temperature | number:'1.0-0'}}
        **<span class="mat-display-1 unit">**˚F**</span>**
      **</p>**
    </div> 
    

你通常需要通过调整前一行上的fxFlex值来实验预测图像应该有多少空间。如果它占用了太多空间,温度就会溢出到下一行。你的设置还可以受到浏览器窗口大小的影响。60%似乎效果不错,但当我编写这个示例时,当前天气是 55˚F,所以出于完全诗意的理由,我决定选择 55%。在这里查看我们应用的精炼版本:

图片

图 5.22:调整后的 LocalCast 天气

和往常一样,您可以进一步调整边距和填充来进一步自定义设计。然而,任何与库的偏差都可能在后续的维护中产生后果。除非您真正围绕显示天气数据来构建业务,否则您应该将任何进一步的优化推迟到项目末尾,如果时间允许,并且如果经验是任何指导,您可能不会进行这种优化。

使用两个负边距下边距的技巧,您可以获得一个相当接近原始设计的样式,但在这里我不会包括这些技巧,将其作为读者在 GitHub 仓库中发现的练习。这样的技巧有时是必要的恶,但通常它们指向设计和实现现实之间的脱节。调整部分之前的解决方案是最佳点,在那里 Angular Material 最为繁荣。超出这一点,您可能是在浪费时间。我提前浪费了我的时间,以下是我的结果:

图片

图 5.23:调整和技巧后的 LocalCast 天气

现在我们已经完成了布局和设计,让我们来看看如何使用 Angular Material 创建一个自定义主题。

自定义主题

正如我们之前讨论的,Material 随带一些默认主题,包括 deeppurple-amberindigo-pinkpink-blue-greypurple-green。然而,您的公司或产品可能有它自己的配色方案。为此,您可以创建一个自定义主题来改变您应用程序的外观。

为了创建一个新的主题,您必须实现一个新的 SCSS 文件:

  1. angular.json 中移除您默认主题的所有定义。

  2. 重新运行命令 npx ng add @angular/material

  3. 这次选择 Custom 作为主题。

  4. 运行命令后,请确保您的 index.htmlstyles.css 文件没有被修改。如果被修改了,请撤销更改。

  5. 这将在 src 目录下创建一个名为 custom-theme.scss 的新文件。将其重命名为 localcast-theme.scss,如下所示:

    **src/localcast-theme.scss**
    // Custom Theming for Angular Material
    // For more information: https://material.angular.io/guide/theming
    @import '~@angular/material/theming';
    // Plus imports for other components in your app.
    // Include the common styles for Angular Material.
    // We include this here so that you only have to
    // load a single css file for Angular Material in your app.
    // Be sure that you only ever include this mixin once!
    @include mat-core();
    // Define the palettes for your theme using
    // the Material Design palettes available in palette.scss
    // (imported above). For each palette, you can optionally
    // specify a default, lighter, and darker hue.
    // Available color palettes: https://material.io/design/color/
    $local-weather-app-primary: mat-palette($mat-indigo);
    $local-weather-app-accent: mat-palette(
      $mat-pink,
      A200, A100, A400
    );
    // The warn palette is optional (defaults to red).
    $local-weather-app-warn: mat-palette($mat-red);
    // Create the theme object (a Sass map containing
    // all of the palettes).
    $local-weather-app-theme: mat-light-theme(
      $local-weather-app-primary,
      $local-weather-app-accent,
      $local-weather-app-warn
    );
    // Custom Theming for Angular Material
    // For more information: https://material.angular.io/guide/theming
    @import '~@angular/material/theming';
    // Plus imports for other components in your app.
    // Include the common styles for Angular Material.
    // We include this here so that you only have to
    // load a single css file for Angular Material in your app.
    // Be sure that you only ever include this mixin once!
    @include mat-core();
    // Define the palettes for your theme using
    // the Material Design palettes available in palette.scss
    // (imported above). For each palette, you can optionally
    // specify a default, lighter, and darker hue.
    // Available color palettes: https://material.io/design/color/
    $local-weather-app-primary: mat-palette($mat-indigo);
    $local-weather-app-accent: mat-palette($mat-pink, A200, A100, A400);
    // The warn palette is optional (defaults to red).
    $local-weather-app-warn: mat-palette($mat-red);
    // Create the theme object (a Sass map containing
    // all of the palettes).
    $local-weather-app-theme: mat-light-theme(
      $local-weather-app-primary,
      $local-weather-app-accent,
      $local-weather-app-warn
    );
    // Include theme styles for core and each component used in
    // your app. Alternatively, you can import and @include the
    // theme mixins for each component that you are using.
    @include angular-material-theme($local-weather-app-theme); 
    

    您可以在 material.angular.io/guide/theming 找到 Material 主题指南,获取更详细的信息。

    注意,mat-core() 应该只包含在您的应用程序中一次;否则,您将在应用程序中引入不必要的和重复的 CSS 负载。

    mat-core() 包含将自定义颜色注入 Material 所必需的 SCSS 函数,例如 mat-palettemat-light-thememat-dark-theme

    至少,我们必须定义一个新的主色调和一个强调色。然而,定义新的颜色并不是一个简单的过程。Material 需要通过 mat-palette 定义调色板,这需要一个复杂的颜色对象来初始化,不能简单地通过如 #BFB900 这样的十六进制值来覆盖。

    要选择您的颜色,您可以使用位于 material.io/resources/color 的 Material Design 颜色工具。以下是工具的截图:

    图片

    图 5.24:Material.io 色彩工具

  6. 使用Material 调色板选择主色辅助色

    • 我的主色选择是带有色调值500的红色。

    • 我的次要选择是带有色调值A400的靛蓝色。

  7. 通过浏览页面左侧的六个预建屏幕,观察您的选择如何应用于 Material Design 应用。

  8. 评估您选择的可访问性影响,如图所示:图片

    图 5.25:Material.io 色彩工具的可访问性选项卡

    工具警告我们,当在主色上使用白色文本时,我们的选择会导致文本难以辨认。您应该注意避免在主色上显示白色文本,或者更改您的选择。

    如果您想创建自己的调色板,那么mat-palette的界面看起来是这样的:

    mat-palette($base-palette, $default: 500, $lighter: 100, $darker: 700) 
    
  9. 使用默认色调定义主色和辅助色的mat-palette对象:

    **src/localcast-theme.scss**
    $local-weather-app-primary: mat-palette($mat-red, 500);
    $local-weather-app-accent: mat-palette($mat-indigo, A400); 
    

即使您的主题在 SCSS 中,您也可以继续在应用程序的其他部分使用 CSS。Angular CLI 支持编译 SCSS 和 CSS。如果您想更改默认行为,您可以通过将angular.json文件中的defaults.styleExt属性从 CSS 更改为 SCSS 来完全切换到 SCSS。

您还可以选择删除styles.css并将其内容合并到localcast-theme.scss中,或者通过简单地将其重命名为styles.scssstyles.css转换为 SASS 文件。如果您这样做,别忘了更新angular.json

恭喜!您的应用程序现在应该具有您自己的商标色彩方案:

图片

图 5.26:使用自定义主题的 LocalCast 天气

将您的代码推送到 GitHub 并检查您的 CircleCI 流水线。

使用 Material 进行单元测试

一旦您提交代码,您会注意到由于测试失败,您的流水线现在失败了。为了保持您的单元测试运行,您需要将MaterialModule导入到任何使用 Angular Material 的组件的spec文件中:

***.component.spec.ts**
...
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      ...
      imports: [..., MaterialModule],
    }).compileComponents()
  })
) 

您还需要更新任何搜索特定 HTML 元素的测试,包括端到端测试。

例如,由于应用程序的标题 LocalCast Weather 不再位于h1标签中,您必须更新spec文件以在span元素中查找它:

**src/app/app.component.spec.ts**
expect(compiled.querySelector('**span**').textContent).toContain('LocalCast Weather') 

另一个例子是在CurrentWeather组件中,city周围的元素不再是span,因此您可以使用mat-title CSS 类:

**src/app/current-weather/current-weather.component.spec.ts**
import { By } from '@angular/platform-browser'
// Assert on DOM
const debugEl = fixture.debugElement
const titleEl: HTMLElement =
  debugEl.query(By.css('**.mat-title**')).nativeElement
expect(titleEl.textContent).toContain('Bethesda') 

类似地,在端到端测试中,您需要更新您的页面对象函数以从正确的位置检索文本:

**src/e2e/app.po.ts**
getParagraphText() {
  return element(by.css('app-root mat-toolbar span'))
    .getText() as Promise<string>
} 

一旦您的测试通过,再次将您的代码推送到 GitHub。当您的 CircleCI 流水线成功时,使用 Vercel Now 发布您的应用程序。记住,如果您不发布它,它就从未发生过!

我们现在可以将 UX 任务移动到完成列:

图片

图 5.27:GitHub 项目看板板状态

第七章创建一个以路由器为第一线的业务应用 中,你将了解更多高级的工具,以便进一步自定义你的 Material 主题的外观和感觉,从而创建一个真正独特的体验,适合你所代表的品牌。

可访问性

了解你的应用中可能存在的潜在可访问性问题非常重要。你可以通过访问 A11Y 项目网站 a11yproject.com 来熟悉可访问性方面的考虑。Material 本身也提供了额外的工具来帮助你提高可访问性;你可以在 material.angular.io/cdk/a11y/overview 上了解更多信息。

利用这样的 Material 功能可能感觉不必要;然而,在设计你的应用时,你必须考虑响应性、样式、间距和可访问性方面的考虑。Material 团队投入了大量努力,以确保你的代码在大多数情况下都能正确运行,并且能够为最大可能的用户群体提供高质量的 UX。这可能包括视觉障碍者或以键盘为主的用户,他们必须依赖专门的软件或键盘功能,如标签,来导航你的应用。利用 Material 元素为这些用户提供关键的元数据,使他们能够导航你的应用。

Material 声称支持以下屏幕阅读软件:

  • 在 Windows 上的 Internet Explorer/Firefox/Chrome 中使用 NVDA 和 JAWS

  • 在 iOS 上的 Safari 和 macOS X 上的 Safari/Chrome 中使用 VoiceOver

  • 在 Android 上的 Chrome 中使用 TalkBack

除了 Material 之外,你可能需要或希望支持特定的可访问性标准,如基于美国的第 508 条或 W3C 定义的 Web 内容可访问性指南WCAG)。声称对这样的标准提供官方支持需要昂贵的认证和合格的测试人员来确保合规性。

考虑 pa11y,这是一个自动化可访问性测试的命令行工具。由于它是一个 CLI 工具,你可以轻松地将它集成到你的 CI 管道中。能够在开发周期的早期自动捕捉到可访问性问题,可以显著降低在应用中实现可访问性功能的成本。

A11yaccessibility 的缩写,因为在单词 accessibility 中,字母 ay 之间有 11 个字符。你可以在 a11yproject.com/ 上了解更多关于为什么支持可访问性很重要。

你可以在 pa11y.org/ 上了解更多关于 pa11y 的信息。接下来,让我们在我们的项目中配置 pa11y CLI 工具。

配置自动 pa11y 测试

pa11y 是一个可以从命令行执行的自动化可访问性工具,你可以用它来检查你的 Web 应用是否符合各种可访问性规则集,如第 508 条或 WCAG 2 AAA。你可以配置 pa11y 在你的项目本地或 CI 服务器上运行。在两种情况下,你必须对已部署的应用版本进行测试。

让我们从为本地运行配置 pa11y 开始:

  1. 使用以下命令安装 pa11y 和 pa11y-ci 包:

    npm i -D pa11y pa11y-ci http-server 
    
  2. 添加npm脚本以执行 pa11y 进行本地运行,检查第五百零八部分合规性问题:

    **package.json**
    ...
    "scripts": {
      ...
      "test:a11y": "pa11y --standard Section508 http://localhost:5000"
    } 
    
  3. 通过执行npm start确保应用正在运行。

  4. 在一个新的终端窗口中,执行npm run test:a11y。输出应该如下所示:

    Welcome to Pa11y
     > Running Pa11y on URL http://localhost:5000
    Results for URL: http://localhost:5000/
     • Error: Img element missing an alt attribute. Use the alt attribute to specify a short text alternative.
       ├── Section508.A.Img.MissingAlt
       ├── html > body > app-root > div:nth-child(4) > mat-card > mat-card-content > app-current-weather > div > div:nth-child(2) > div:nth-child(1) > img
       └── <img _ngcontent-pbr-c132="" style="margin-bottom:32px; zoom:175%" src="">        
    1 Errors 
    

    注意,我们有一个错误。错误信息表明,在app-current-weather下,我们在mat-card-content内部显示的图片缺少一个alt属性。观察以下导致错误的代码行:

    **src/app/current-weather/current-weather.component.html**
    ...
    <img style="zoom: 175%" [src]="current.image" /> 
    

    上述代码指的是我们从 OpenWeatherMap API 获取的图片。一个视觉障碍用户,依赖屏幕阅读器,如果没有alt属性,将无法确定图片的用途。由于这是一个动态图片,一个静态的alt属性,如当前天气图标,将不利于我们的用户。然而,将当前天气描述值绑定为属性是合适的。我们可以像下面这样修复可访问性问题:

    **src/app/current-weather/current-weather.component.html**
    ...
    <img style="zoom: 175%" [src]="current.image" 
     [alt]="current.description" /> 
    
  5. 重新运行 pa11y 以确认问题已修复。

现在,依赖屏幕阅读器的用户可以快速了解页面上的图片反映了当前天气。在这种情况下,我们已经在页面上有了描述。这是一个非常重要的问题,需要修复,因为避免在我们的页面上出现整个用户群体都无法解读的神秘元素至关重要。

现在,让我们为我们的 CI 管道配置 pa11y。

  1. 在项目的根目录下创建一个.pa11yci配置文件:

    **.pa11yci**
    {
      "default": {
        "timeout": 1000,
        "page": {
          "viewport": {
            "width": 320,
            "height": 480
          }
        }
      },
      "urls": [
        "https://localcast-weather.duluca.now.sh/"
      ]
    } 
    
  2. 添加npm脚本以执行 pa11y 进行本地运行,检查第五百零八部分合规性问题:

    **package.json**
    ...
    "scripts": {
      ...
      "test:a11y:ci": "pa11y-ci"
    } 
    

现在,我们可以将命令npm run test:a11y:ci添加到.circleci/config.yml中。然而,如您所注意到的,我们将对已经部署的应用版本进行测试。为了克服这个挑战,您必须创建一个替代的now:publish命令,将我们的分支部署到不同的 URL,更新.pa11yci以检查新的 URL,并在您的管道中执行部署。由于这里涉及的所有操作都是 CLI 命令,您可以按顺序执行它们。我将这个作为练习留给用户来完成。

CircleCI 的更多高级用法在第九章使用 Docker 的 DevOps中有所介绍。接下来,我们将讲解如何构建一个交互式原型,以便在开发早期发现 UI/UX 问题,从而降低开发成本。

构建交互式原型

外观很重要。无论您是在开发团队中工作还是作为自由职业者,您的同事、老板或客户都会更加重视一个精心准备好的演示。在第三章创建基本的 Angular 应用中,我提到了全栈开发者面临的时间和信息管理挑战。我们必须选择一个可以以最少的劳动量实现最佳结果的工具。这通常意味着走付费工具的道路,但 UI/UX 设计工具很少是免费或便宜的。

一个原型工具将帮助你创建一个更好、更专业的应用原型。无论你选择什么工具,它也应该支持你选择的 UI 框架,在这种情况下,是 Material。

如果一张图片值一千个字,那么你应用的交互式原型就值一千行代码。一个交互式原型将帮助你在你写第一行代码之前检验想法,并节省你大量的代码编写。

MockFlow WireframePro

我选择了 MockFlow WireframePro,可在mockflow.com获取,作为一个易于使用、功能强大的在线工具,它支持 Material Design UI 元素,并允许你创建多个页面,然后可以将它们链接在一起,以创建一个工作应用的错觉。

最重要的是,在发布时,MockFlow 允许永久免费使用一个完整功能集和能力的项目。这将给你一个真正检验工具有用性的机会,而不受人工限制或总是比你预期的更快过去的试用期。

Balsamiq(可在balsamiq.com获取)是一个更知名的线框工具;然而,它不提供任何免费使用。如果你在寻找一个没有月费的工具,我强烈推荐 Balsamiq 的桌面应用程序 Mockups,它有一个一次性购买成本。

构建原型

我们首先添加一个新任务来创建一个交互式原型,并在任务结束时,我将把所有工件附加到这个任务上,以便它们存储在 GitHub 上,所有团队成员都可以访问,并且也可以从维基页面上链接,以实现持久的文档。

让我们把这项新任务拖到进行中列,并查看我们从 Waffle.io 的看板状态:

图片

图 5.28:当前看板板状态

WireframePro 作为一个拖放式设计界面非常直观,所以我不将深入探讨工具的工作原理,但我将突出一些技巧:

  1. 创建你的项目

  2. 选择一个组件包,无论是手绘 UI还是Material Design

  3. 按照以下截图所示,将每个屏幕作为新页面添加:

图片

图 5.29:MockFlow.com WireFrame Pro

我建议坚持使用手绘 UI 的外观和感觉,因为这为你的观众设定了正确的期望。如果你在与客户的第一次会面中展示一个非常高质量的样稿,你的第一次演示将会是保守的。你最多只能满足期望,最坏的情况是令观众感到失望。

主屏幕

这是刚刚创建的主屏幕的新原型:

图片

图 5.30:LocalCast 天气主屏幕线框

你会注意到一些差异,例如应用工具栏与浏览器栏合并,以及重复元素的故意模糊。我做出了这些选择,以减少我需要在每个屏幕上花费的设计时间。我只是简单地使用了水平和垂直线对象来创建网格。

搜索结果

搜索屏幕同样故意保持模糊,以避免维护任何详细的信息。令人惊讶的是,你的观众更有可能关注你的测试数据,而不是关注设计元素。

通过模糊处理,我们故意将观众的注意力集中在重要的事情上。以下是搜索屏幕的原型图:

图片

图 5.31:LocalCast 天气搜索屏幕线框图

设置面板

设置面板是一个独立的屏幕,它将主页面的元素复制过来,并应用了 85%的不透明度以创建类似模型的体验。设置面板本身只是一个带有黑色边框和纯白色背景的矩形。

看看下面的原型图:

图片

图 5.32:LocalCast 天气设置线框图

添加交互性

能够点击原型并了解导航工作流程是一种不可或缺的工具,可以让你在早期获得用户反馈。这将为你和你的客户节省大量的挫败感、时间和金钱。

要将元素链接在一起,请按照以下步骤操作:

  1. 选择一个可点击的元素,例如主页面的齿轮图标

  2. 链接子标题下,点击选择页面

  3. 在弹出窗口中,选择设置

  4. 点击创建链接,如图所示:

图片

图 5.33:在 Wireframe Pro 中添加链接

现在,当你点击齿轮图标时,工具将显示设置页面,这将创建侧边栏实际上在同一页面上显示的效果。要返回主页,你可以将齿轮图标和侧边栏外的部分链接回该页面,以便用户可以来回导航。

导出功能原型

一旦你的原型完成,你可以将其导出为各种格式:

  1. 项目菜单下,选择导出线框按钮,如图所示图片

    图 5.34:Wireframe Pro 的导出线框菜单选项

  2. 现在选择你的文件格式,如下所示图片

    图 5.35:Wireframe Pro 中的文件格式

    我更喜欢 HTML 格式,因为它具有灵活性;然而,你的工作流程和需求可能会有所不同。

  3. 如果你选择了 HTML,你将下载一个包含所有资产的 ZIP 压缩包。

  4. 解压包并使用浏览器导航到它;你应该会得到一个交互式的线框图版本,如图所示图片

    图 5.36:Wireframe Pro 中的交互式线框图

交互元素在截图中被突出显示为黄色(打印为浅灰色),并由前一个截图中的粗箭头指出。你可以通过屏幕左下角的 Reveal Links 选项启用或禁用此行为。

现在将所有资产添加到 GitHub 问题的评论中,包括 ZIP 压缩包,我们就完成了。

你还可以使用 第四章 中讨论的 Vercel Now 发布你的原型 HTML 项目。

摘要

在本章中,你学习了 Angular Material 是什么,如何使用 Angular Flex Layout 引擎,UI 库对性能的影响,以及如何将特定的 Angular Material 组件应用到你的应用程序中。你意识到了过度优化的 UI 设计中 CSS 微调的陷阱,以及如何为你的应用程序添加自定义主题。

我们还介绍了如何提高你应用程序的可访问性,并在实现之前验证你的设计。

在下一章中,我们将更新天气应用程序以响应用户输入的响应式表单,并保持我们的组件解耦,同时使用 BehaviorSubject 在它们之间启用数据交换。在下一章之后,我们将完成天气应用程序,并将我们的重点转移到构建更大的业务线应用程序。

查看 附录 C保持 Angular 和工具常青,了解如何升级 Angular Material。你可以从 static.packt-cdn.com/downloads/9781838648800_Appendix_C_Keeping_Angular_and_Tools_Evergreen.pdfexpertlysimple.io/stay-evergreen 在线找到此附录。

进一步阅读

  • 《疯狂简单:推动苹果成功的执着,肯·塞格尔》,2013

  • 《Material Design,谷歌》,2020,位于 material.io

  • Pa11y,Team Pa11y,2020,位于 pa11y.org

练习

通过实现替代的 Now 部署在你的 CI 流程中实施 pa11y,以便你可以测试你分支中的更改。

问题

尽可能好地回答以下问题,以确保你在没有使用 Google 的情况下理解了本章的关键概念。你需要帮助回答这些问题吗?请参阅 附录 D自我评估答案,在线位于 static.packt-cdn.com/downloads/9781838648800_Appendix_D_Self-Assessment_Answers.pdf 或访问 expertlysimple.io/angular-self-assessment

  1. 使用 Angular Material 的好处是什么?

  2. Angular Flex Layout 依赖于哪种底层 CSS 技术?

  3. 为什么测试可访问性很重要?

  4. 为什么你应该构建交互式原型?

第六章:表单、Observables 和 Subjects

到目前为止,你已经一直在构建构成 Angular 应用程序的基本元素,例如模块、组件、管道、服务、RxJS、单元测试和环境变量,甚至更进一步,学习如何使用 Docker 交付你的网络应用,并用 Angular Material 给它一个光鲜的外观。

到目前为止,我们的应用还不是交互式的。它只能为单个城市获取天气信息。因此,它不是一个非常有用的应用。为了构建一个交互式应用,我们需要能够处理用户输入。在你的应用程序中启用用户输入可以打开创建出色用户体验的可能性。考虑一下 google.com 的登录页面:

图片

图 6.1:谷歌的登录页面

在这个背景下,除了一个简单的带有两个按钮的输入字段之外,谷歌搜索是什么?那个简单的输入字段解锁了世界上一些最复杂和最先进的软件技术。这是一种欺骗性的简单且疯狂强大的与用户互动的方式。你可以通过利用现代网络功能,如GeoLocation来增强用户输入,并从用户输入中获得新的意义。因此,当用户输入巴黎时,你不必猜测他们是指法国的巴黎,还是德克萨斯州的巴黎,或者你是否应该显示摄氏度或华氏度的当前温度。使用LocalStorage,你可以缓存用户凭据并记住用户偏好,这样你就可以在你的应用中启用暗黑模式。

到本章结束时,我们不会实现谷歌、地理位置或暗黑模式,而是将允许用户通过城市名称或邮政编码(在美国通常被称为“邮编”)来搜索他们的城市。一旦你意识到实现看似简单的按邮政编码搜索功能可能会多么复杂,你可能会对精心设计的网络应用产生新的敬意。

要构建一个由输入字段驱动的 UX,我们需要利用 Angular 表单和验证消息,这样我们就可以创建具有搜索即输入功能的引人入胜的搜索体验。在幕后,RxJS/BehaviorSubject 使我们能够构建解耦的组件,它们可以相互通信,而响应式数据流允许我们合并来自多个网络 API 的数据,而不会增加我们应用程序的复杂性。

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

  • 模板驱动和响应式表单,包括双向绑定和输入字段验证

  • 组件之间的交互

  • Observables 和 RxJS/BehaviorSubject

  • 管理订阅,以及如何处理内存泄漏和取消订阅

  • 使用 async pipe 处理多个 API 调用,包括链式多个 API 调用

本书样本代码的最新版本可在 GitHub 上找到,链接如下。该存储库包含代码的最终和完成状态。你可以在本章末尾通过查找projects文件夹下的代码末尾快照来验证你的进度。

对于第六章

  1. 克隆仓库github.com/duluca/local-weather-app

  2. 在根目录下执行npm install以安装依赖项

  3. 本章的代码示例位于子文件夹中:

    projects/ch6 
    
  4. 要运行本章的 Angular 应用,请执行:

    npx ng serve ch6 
    
  5. 要运行本章的 Angular 单元测试,请执行:

    npx ng test ch6 --watch=false 
    
  6. 要运行本章的 Angular 端到端测试,请执行:

    npx ng e2e ch6 
    
  7. 要构建本章的生产级 Angular 应用,请执行:

    npx ng build ch6 --prod 
    

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

请注意,书中或 GitHub 上的源代码可能并不总是与 Angular CLI 生成的代码匹配。书中和 GitHub 上的实现之间也可能存在细微的差异,因为生态系统是不断演变的。随着时间的推移,示例代码发生变化是自然的。在 GitHub 上,你可能会找到更正、修复以支持库的新版本,或者为读者观察而并排实现多种技术的示例。读者只需实现书中推荐的理想解决方案即可。如果你发现错误或有疑问,请创建一个 issue 或提交一个 pull request 到 GitHub,以供所有读者受益。

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

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

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

首先最重要的是,我们必须提供一个良好的基线用户体验,并在之后仅实现增值功能,如地理位置。相反,让我们将添加城市搜索功能 ...移动到进行中,如图我们在看板上的所示:

图 6.2: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.io/guide/reactive-forms 上了解更多关于响应式表单的信息。

让我们首先将 FormsModuleReactiveFormsModule 导入到我们的应用程序中:

**src/app/app.module.ts**
...
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
...
@NgModule({
  ...
  imports: [
    ...
    **FormsModule,** 
 **ReactiveFormsModule,**
  ] 

注意,在纯响应式表单实现中,你只需要ReactiveFormsModuleFormsModule支持模板驱动表单和其他场景,在这些场景中,你可能只想声明一个没有FormGroupFormControl。这就是我们实现应用程序输入字段的办法。FormGroup将在下一节中定义。

此外,响应式表单还允许你以响应式范式编写代码,这是一个净正面的结果。接下来,让我们将城市搜索组件添加到我们的应用程序中。

添加和验证组件

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

  1. MatFormFieldModuleMatInputModule添加到material.module.ts中,使其在应用程序中可用:

    **src/app/material.module.ts**
    import { MatFormFieldModule } from '@angular/material/form-field'
    import { MatInputModule } from '@angular/material/input'
    const modules = [..., **MatFormFieldModule, MatInputModule**] 
    

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

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

  2. 创建新的citySearch组件:

    $ npx ng g c citySearch --module=app.module 
    

    由于我们添加了material.module.ts文件,ng无法猜测应该将citySearch添加到哪个功能模块中,导致出现“多个模块匹配”等错误。因此,我们需要使用--module选项提供我们希望citySearch添加到的模块。使用--skip-import选项跳过将组件导入任何模块。

  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-city-search添加到app.component.ts中,作为包含应用程序标语和包含mat-card的行的中间新的div

    **src/app/app.component.ts**
    template: `
      ...
        </div>
     **<div fxLayoutAlign="center">**
     **<app-city-search></app-city-search>**
     **</div>**
        <div fxLayout="row">
      ...
    `, 
    
  6. 通过在浏览器中查看应用程序来测试组件的集成,如图所示:

    图 6.3:带有搜索字段的 LocalCast 天气应用程序

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

向天气服务添加搜索选项

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

OpenWeatherMap 的 API 接受 URI 参数,因此我们可以使用 TypeScript 联合类型和类型守卫重构现有的getCurrentWeather函数(在第三章创建一个基本的 Angular 应用中介绍)。这意味着我们可以提供不同的参数,同时保留类型检查:

  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

  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遵循开闭原则。毕竟,它可以通过提供不同的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从字符串的开始和结束处去除任何空白字符。然后,我们通过使用Array.map遍历它们来确保去除字符串的所有部分。

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

  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 限制用户输入

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

我们仍然可以监听每个按键,但不必对每个按键做出反应。通过利用 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 语句的做法并不好。如 第三章 中所述,创建基本的 Angular 应用程序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">
        ...
        **<mat-error *ngIf="search.invalid">**
     **Type more than one character to search**
     **</mat-error>**
      </mat-form-field>
    </form>
    ... 
    

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

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

    **example**
    <mat-error *ngIf="search.invalid">
      {{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**) {
          ... 
    

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

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

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

反应式表单的替代方案是模板驱动表单。如果您熟悉 AngularJS 中的 ng-model,您会发现新的 ngModel 指令是它的 API 兼容替代品。

在幕后,ngModel 实现了一个可以自动附加到 FormGroupFormControlngModel 可以在 <form> 级别或单个 <input> 级别使用。您可以在 angular.io/api/forms/NgModel 上了解更多关于 ngModel 的信息。

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

**projects/ch6/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">
  ...
    <mat-error *ngIf="search.invalid">
      Type more than one character to search
    </mat-error>
  ... 

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

组件中的差异如下实现:

**projects/ch6/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进行组件交互

要更新当前天气信息,我们需要city-search组件与current-weather组件交互。在 Angular 中,有四种主要技术可以启用组件交互:

  • 全局事件

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

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

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

全局事件

这是一种从编程早期就一直在使用的技巧。在 JavaScript 中,你可能通过全局函数委托或 jQuery 的事件系统实现了这一点。在 AngularJS 中,你可能创建了一个服务并在其中存储了值。

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

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

最终,如果你在一个服务中存储所有应用程序数据或路由所有事件以实现组件交互,你只是在发明一个更好的捕鼠器。这是一个应该不惜一切代价避免的反模式。在后面的章节中,你会发现,本质上,我们仍然会使用服务来实现组件交互;然而,我想指出,在允许解耦的灵活架构和全局或集中式解耦方法之间存在着一条细线,后者扩展性不佳。

基于事件发射器的亲子关系

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

我们可以通过利用AppComponent作为父元素,让app模块控制器协调数据来实现城市搜索组件和当前天气组件之间的通信。

现在提交你的代码!在接下来的两个部分中,你将需要丢弃所做的代码更改。

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

  1. city-search组件通过@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. app组件消费它并调用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 方法。

我们已成功将信息冒泡到父组件,但我们还必须能够将其传递到 current-weather 组件。

输入绑定下的父子关系

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

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

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

然后,您可以更新 app 组件以将数据绑定到 current 天气:

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

到目前为止,您的代码应该可以正常工作!尝试搜索一个城市。如果 current-weather 组件更新,则表示成功!

事件发射器和输入绑定方法适用于创建紧密耦合的组件或用户控件,且没有外部数据被消费的情况。一个很好的例子是将预报信息添加到 current-weather 组件中,如下所示:

\192.168.0.200\All_Books\2020\Working_Titles\14094_Angular 8 for Enterprise-Ready Web Applications\BookDrafts\Graphics\Chapter 6\B14094_06_03.png

图 6.4:天气预报线框图

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

**example**
<app-mini-forecast
  *ngFor="let dailyForecast of forecastArray
  [forecast]="dailyForecast"
>
</app-mini-forecast> 

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

在继续之前,请丢弃您在前两节中做出的更改。我们将实现一个替代方案。

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

与主题的兄弟交互

组件交互的主要原因是为了发送或接收数据更新,这些数据可能是由用户提供的,也可能是由服务器接收的。在 Angular 中,你的服务公开 RxJS.Observable 端点,这些是组件可以订阅的数据流。RxJS.Observer 作为 RxJS.Observable 事件发射器的消费者,与 RxJS.Observable 相辅相成。RxJS.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 的默认行为非常类似于通用的 pub/sub 机制,例如 jQuery 事件。然而,在一个组件以不可预测的方式加载或卸载的异步世界中,使用默认的 Subject 并不是非常有用。

主题有三种高级变体:

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

  • BehaviorSubject 只记住最后一个数据点,同时继续监听新的数据点。

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

ReplaySubject 可能会对您的应用程序的内存和性能产生严重影响,因此应谨慎使用。在 current-weather 的情况下,我们只对显示接收到的最新天气数据感兴趣,但通过用户输入或其他事件,我们愿意接收新数据,以便保持 current-weather 组件的更新。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. 更新 current-weather 组件以订阅新的 BehaviorSubject

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

    **src/app/city-search/city-search.component.ts**
    ...
      this.weatherService.**updateCurrentWeather**( 
        userInput[0],
        userInput.length > 1 ? userInput[1] : undefined
      )
    ... 
    
  7. 在浏览器中测试您的应用程序;它应该如下所示:

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

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

图 6.6:GitHub 项目看板板状态

我们有一个功能性的应用程序。然而,我们引入了内存泄漏,所以让我们在下一节中修复它。

管理订阅

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

current-weather组件中,我们注入weatherSevice以便我们可以访问BehaviorSubjectcurrentWeather$组件。在 Angular 中,服务是单例的,这意味着当它们首次在内存中创建时,只要它们所属的模块在内存中,它们就会被保留。从实际的角度来看,这意味着您应用程序中的大多数服务将存在于应用程序的生命周期内。然而,组件的生命周期可能要短得多,并且可能反复创建相同组件的多个实例。如果我们不仔细管理长生命期和短生命期对象之间的交互,我们可能会在对象之间产生悬挂引用,从而导致内存泄漏。

内存泄漏的揭露

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

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

如果发现一个对象被取消引用,那么它占用的栈空间可以被释放。然而,如果一个未使用的对象仍然引用另一个仍在使用的对象,它就不能被垃圾回收。垃圾回收器并不是神奇的,不能读取我们的思想。当一个对象未使用且无法释放时,该对象占用的内存将无法在您的应用程序运行期间用于其他目的,这被认为是内存泄漏。

我的同事布伦登·考林斯提供了一个有用的类比:

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

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

取消订阅

订阅或事件处理器会创建对其他对象的引用,例如从短生命周期的组件到长生命周期的服务。诚然,在我们的案例中,current-weather组件也是一个单例,但如果我们给应用添加更多功能,比如页面间的导航或同时显示多个城市的天气,这可能会改变。如果我们不取消对currentWeather$的订阅,那么任何current-weather的实例都会卡在内存中。我们在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方法。

如果我们的组件被销毁,将不会导致内存泄漏。然而,如果给定组件中有多个订阅,这可能会导致大量的编码工作。

注意在city-search中,我们订阅了FormControl对象的valueChanges事件。我们不需要管理这个事件的订阅,因为FormControl是我们组件的一个子对象。当父组件从所有对象中解除引用时,所有子对象都可以安全地被 GC(垃圾回收器)收集。

现在我们来看一种更好地管理多个订阅的方法。

使用 SubSink 取消订阅

由 Ward Bell 发布的 SubSink 是一个简单的库,可以跟踪给定类中的所有订阅,无论是组件还是服务。

将 SubSink 包添加到你的 Angular 项目中:

$ npm i subsink 

接下来,更新current-weather以使用 SubSink,替换currentWeatherSubscription

**src/app/current-weather/current-weather.component.ts**
import { ..., OnDestroy } from '@angular/core'
import { SubSink } from 'subsink'
export class CurrentWeatherComponent implements OnInit, OnDestroy {
  private subscriptions = new SubSink()
  ...
  ngOnInit(): void {
    this.subscriptions.add(
      this.weatherService.currentWeather$
        .subscribe((data) => (this.current = data))
    )
  }
  ngOnDestroy(): void {
 this.subscriptions.unsubscribe()
  }
... 

在前面的代码示例中,我们实例化了一个私有的subscriptions对象,它将作为汇入点来包含所有的订阅。然后,在ngOnInit中,我们简单地将currentWeather$的订阅添加到汇入点。在ngOnDestroy中,我们调用汇入点的unsubscribe方法,而不是单个订阅。

这是一个可扩展的方法,因为我们必须编写的样板代码量保持一致,你可以在不额外编码的情况下向汇入点添加许多订阅。

订阅数据流中的值本身可以被认为是一种反模式,因为它将你的编程模型从响应式切换到命令式。此外,你还可以避免一开始就需要管理订阅。我们将在下一节中讨论这个话题。

实现响应式风格

如同在第一章Angular 及其概念简介中所述,我们应当只为激活它而订阅一个可观察的流。如果我们把subscribe函数当作一个事件处理器,那么我们就是在命令式地实现代码。

在你的代码库中看到除空 .subscribe() 调用之外的内容,应被视为放弃响应式编程的迹象。

在响应式编程中,当你在一个响应式流中订阅一个事件时,你就是在将你的编程范式从响应式编程切换到命令式编程。在我们的应用程序中有两个地方我们进行了订阅,一次在 current-weather 中,另一次在 city-search 组件中。

让我们先从修复 current-weather 开始,这样我们就不会回到命令式编程。

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

从一开始,Angular 就被设计成一个异步框架。通过保持在响应式编程领域,你可以最大限度地发挥 Angular 的作用。这样做可能一开始感觉不自然,但 Angular 提供了你需要的所有工具,以便将应用程序的当前状态反映给用户,而无需切换到命令式编程。

你可以在模板中使用 async 管道来反映可观察对象当前值。让我们更新 current-weather 组件以使用 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**
    <div *ngIf="current$ | async as current">
    ...
    </div> 
    

    async 管道自动订阅 current$ 的当前值,并将其作为变量 current 以命令式方式提供给模板使用。这种方法的美妙之处在于,async 管道隐式管理订阅,因此你不必担心取消订阅。

  5. 移除 <div *ngIf="!current">。这不再需要,因为 BehaviorSubject 总是初始化的。

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

异步管道允许你实现 if-else 逻辑。如果你想在你的可观察对象解析时显示一个加载消息,你可以通过以下技术来实现:

**example**
<div *ngIf="current$ | async as current;
  else loading"
>
  ...
</div>
<ng-template #loading>
  Loading...
</ng-template> 

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

捕获可观察流

city-search 组件在触发 search 函数时,在 subscribe 语句中实现了一个回调。这导致了一种命令式编程风格和思维模式。切换编程范式的一个危险是,你可能会无意中在你的代码中引入副作用,这使得引入错误或状态到你的应用程序中变得更加容易。

让我们将 city-search.component.ts 重构为响应式函数式编程风格,如下例所示:

**src/app/city-search/city-search.component.ts**
import { debounceTime, **filter, tap** } from 'rxjs/operators'
export class CitySearchComponent {
  search = new FormControl('', 
    [**Validators.required**, Validators.minLength(2)])
  constructor(private weatherService: WeatherService) { 
    **this.search.valueChanges**
 **.pipe(**
 **debounceTime(1000),**
 **filter(() => !this.search.invalid),**
 **tap((searchValue: string) => this.doSearch(searchValue))**
 **)**
 **.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 有效时才会被触发。此外,doSearch 在函数式上下文中被调用,这使得在函数内部引用任何其他类属性变得非常困难。

这减少了类的状态影响我们函数结果的可能性。因此,doSearch是一个可组合的、可单元测试的函数,而在之前的实现中,以简单的方式单元测试ngOnInit将非常具有挑战性。

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

我们没有实现ngOnInit的事实反映了我们代码的真实异步性,它与应用程序的生命周期或状态无关。

在我们的重构完成后,应用应该与之前一样运行,但具有更少的样板代码。现在,让我们看看如何增强我们的应用,使其能够处理来自任何国家的邮政编码。

多次 API 调用

目前,我们的应用只能处理来自美国的 5 位数字邮政编码。例如,邮政编码22201可以通过简单的条件typeof search === 'string'与城市名称区分开来。然而,邮政编码在不同国家之间差异很大,英国就是一个很好的例子,其邮政编码如EC2R 6AB。即使我们对地球上每个国家的邮政编码格式有完美的理解,我们仍然不能确保用户没有输入一个轻微错误的邮政编码。今天的用户期望网络应用对这类错误具有弹性。

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

让我们在待办事项中添加一个新的项目,支持国际邮政编码,并将其移动到进行中

图 6.7:添加国际邮政编码故事

实现邮政编码服务

要正确理解用户输入的是有效的邮政编码还是城市名称,我们必须依赖由geonames.org提供的第三方 API 调用。让我们看看如何将一个次要的 API 调用注入到我们应用的搜索逻辑中。

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

您可以在本页面上尝试邮政编码 API:www.geonames.org/postal-codes

首先,实现一个PostalCodeService,如下所示:

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

**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 调用就可以正确工作,避免混合内容错误,如第四章中所述的自动化测试、持续集成和发布到生产

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

链接 API 调用

让我们更新天气服务,使其能够调用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 天气应该可以与全球邮政编码一起工作,如下面的截图所示:

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

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

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

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

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

我们完成了本地天气应用的开发,直到第十二章食谱 — 主/详情,数据表和 NgRx,在那里我展示了 NgRx 与使用 RxJS/BehaviorSubject 的比较。

摘要

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

您现在能够更好地区分命令式和响应式编程风格,以及在可能的情况下坚持使用响应式编程的重要性。最后,您学习了如何通过链式调用多个 API 调用来实现复杂的功能。

LocalCast Weather 是一个简单的应用,我们用它来介绍 Angular 的基本概念。正如您所看到的,Angular 构建这样的小型和动态应用非常出色,同时向最终用户交付的框架代码量最少。您应该考虑在快速和简单的项目中使用 Angular,这在构建大型应用时也是一个很好的实践。在下一章中,您将创建一个更复杂的业务线LOB)应用,使用以路由为首要方法的架构和设计可扩展的 Angular 应用,包括一流的认证和授权、用户体验以及覆盖大多数您可能在 LOB 应用中找到的需求的众多食谱。

练习

在完成支持国际邮政编码的功能后,我们是否在这里切换了编程范式?我们的实现是命令式、响应式,还是两者的结合?如果我们的实现不是完全响应式的,您会如何实现这个功能以响应式的方式?我将这个问题留作读者的练习。

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

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

问题

尽可能地回答以下问题,以确保你已理解本章的关键概念,无需使用 Google。你需要帮助回答这些问题吗?请参阅附录 D自我评估答案,可在static.packt-cdn.com/downloads/9781838648800_Appendix_D_Self-Assessment_Answers.pdf在线查看,或访问expertlysimple.io/angular-self-assessment

  1. async管道是什么?

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

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

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

第七章:创建以路由器为第一线的业务应用

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

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

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

在本章中,我将向你介绍路由器优先架构,80-20 设计解决方案,以增量迭代的方式解决交付现代 Web 应用所面临的挑战。

如你在第一章Angular 及其概念简介中阅读的那样,软件架构不会保持静止。通过使用编码练习、概念验证应用和参考项目来实验新想法,对于提高创建更灵活架构的能力是至关重要的。

在本章和本书剩余的章节中,我们将设置一个具有丰富功能的新应用,它可以满足具有可扩展架构和工程最佳实践的业务应用需求。这将帮助你从小规模开始,并在有需求时能够快速扩展你的解决方案。我们将遵循路由器优先的设计模式,依靠可重用组件来创建一个名为 LemonMart 的杂货店业务应用。我们将讨论围绕主要数据实体进行设计的理念,以及在开始实现各种条件导航元素之前完成高级原型的重要性,这些元素在设计阶段可能会发生重大变化。

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

  • 将 80-20 解决方案应用于软件开发

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

  • 开始创建你将在本书剩余部分扩展的 LemonMart 应用

  • 创建有效的品牌,以及定制和材料图标

  • 使用懒加载实现亚秒级首次绘制

  • 创建一个行走骨架

  • 使用公共测试模块减少重复

  • 围绕主要数据实体进行设计

  • 认识到高级用户体验设计的重要性

本书样本代码的最新版本可在以下链接的 GitHub 存储库中找到。该存储库包含代码的最终和完成状态。您可以在本章结束时通过查找 projects 文件夹下的代码章节快照来验证您的进度。

第七章

  1. 克隆存储库 github.com/duluca/lemon-mart

  2. 在根目录下执行 npm install 以安装依赖项

  3. 该章节的代码示例位于子文件夹:

    projects/ch7 
    
  4. 要运行本章的 Angular 应用程序,请执行以下操作:

    npx ng serve ch7 
    
  5. 要运行本章的 Angular 单元测试,请执行以下操作:

    npx ng test ch7 --watch=false 
    
  6. 要运行本章的 Angular e2e 测试,请执行以下操作:

    npx ng e2e ch7 
    
  7. 要为本章构建一个生产就绪的 Angular 应用程序,请执行以下操作:

    npx ng build ch7 --prod 
    

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

注意,书中或 GitHub 上的源代码可能并不总是与 Angular CLI 生成的代码相匹配。由于生态系统不断演变,书中代码与 GitHub 上的代码在实现上可能存在细微差异。随着时间的推移,样本代码发生变化是自然的。

此外,在 GitHub 上,您可以期待找到更正、修复以支持库的新版本,或者观察多种技术的并行实现。您只需实现书中推荐的理想解决方案。如果您发现错误或有疑问,请为所有读者创建一个 GitHub 上的问题或提交一个拉取请求。

您可以在 附录 C 中了解更多关于更新 Angular 的信息,即 保持 Angular 和工具常青。您可以从 static.packt-cdn.com/downloads/9781838648800_Appendix_C_Keeping_Angular_and_Tools_Evergreen.pdfexpertlysimple.io/stay-evergreen 在线找到此附录。

让我们从了解我们应用程序设计和架构背后的哲学开始。

80-20 解决方案

无论我们是在家开发应用程序、出于激情项目,还是在办公室工作,我们都必须保持清醒的头脑:我们的目的是提供价值。如果我们不能通过我们的激情项目提供价值,那么我们不会感到满足或快乐。如果我们无法在工作场所提供价值,我们可能得不到报酬。

提供现代网络应用程序是困难的。我们需要克服许多挑战才能成功:

  • 逐步迭代交付

  • 具有可扩展性

  • 支持数十种屏幕和输入类型

  • 易于使用

  • 易于访问

  • 管理一个团队

  • 整理优先级较高的待办事项列表

  • 确保验收标准清晰、简洁、具体

如果你曾经领导过一个项目或者尝试过独立实施并交付一个项目,你就会意识到在任何特定项目中,时间和资源总是不足以覆盖广泛的利益相关者、团队和技术需求。记住帕累托法则,也称为 80-20 法则,它意味着我们可以用总体努力的 20%完成 80%的目标。

如果我们将 80-20 法则应用于我们的工作,我们可以最大化我们的产出、质量和幸福感。业务线应用是我们行业的核心。应用 80-20 法则,我们可以推断出我们中的大多数人很可能通过交付这样的应用来赚取大部分收入。因此,我们应该将我们的工程开销保持在最低,并减少项目的交付风险。通过限制生产代码中的实验,我们为团队成员创造了一个可预测的环境,并且只引入我们在概念验证或小型应用中有机会审查过的变化。

我们的 80-20 策略,结合纪律,可以帮助我们在相同的时间内交付相同的项目,拥有更多功能和更好的质量。将我们的职业生涯视为马拉松而不是一系列冲刺,你可以发现自己处于一个不断交付高质量解决方案、项目接项目的位置,而不会感到疲惫不堪。

理解业务线应用

根据维基百科,业务线应用是一组“被认为对运营企业至关重要的关键计算机应用。”业务线应用是大多数开发者最终会开发的应用,即使我们可能认为我们开发的是小型应用或大型企业应用。考虑以下插图,它展示了我们可能开发的应用类型,这些应用根据其规模和范围放置在轴上:

图 7.1:四种类型应用的大小和范围相对关系

从我的角度来看,当我们开始开发软件时,我们会考虑四种类型的应用:

  • 小型应用

  • 业务线应用

  • 大型企业应用

  • 亿级用户规模的应用

亿级用户规模的应用是完全的细分市场实现,很少需要与大量现有应用的需求相匹配。因此,我们必须将这些应用归类为异常值。

小型应用从小开始。在架构上,它们很可能是最初设计不足的。随着你向小型应用添加功能和团队成员,在某个时候,你将会遇到麻烦。随着你的团队规模和功能集的增长,或者应用的总体复杂性增加,应用的架构需求呈指数增长。

一旦你超过了你的架构能够承受的复杂性的拐点,你就只剩下了一个昂贵的重构努力来回到正轨。参见以下图表,说明了这个概念:

图 7.2:小型应用的架构之旅

特性线下的区域代表过度简化,这会给你的项目带来风险。特性线上的区域显示了支持所需功能所需的工程开销。相比之下,大型企业应用从大规模的过度工程努力开始,如下面的图所示:

图片

图 7.3:大型企业应用的架构之旅

随着时间的推移和系统整体复杂性的增加,大型企业应用也可能面临一个类似的转折点,此时原始架构可能变得不足。通过仔细规划和管理工作,你可以避免麻烦并保护所做出的重大初始投资。这类大型企业应用需要数百名开发者,以及多个级别的经理和架构师来成功执行。与亿级用户规模的应用类似,这些应用也可能有特定的架构需求。在我们开发的小型应用和大型企业应用之间,存在着 LOB 应用。

图片

图 7.4:软件演变的动态性

如前图所示,小型应用可以增长并转变为 LOB 应用,而大型企业应用可能会因为用户忽视他们从未需要的功能而未被充分利用,但仍然作为 LOB 应用服务于单一目的。在任何情况下,尽管我们尽了最大努力,但我们最终可能为解决我们的问题提供了一个低效的解决方案。我们都没有水晶球来预见未来,规划和工程在不可预测的商业环境中只能为我们做这么多;我们需要依靠 80-20 规则来制定一个灵活且能够满足大多数商业需求的架构。

以路由器为首要架构的目标是保持最佳架构开销,以便在匆忙交付所有必需功能时,可以避免昂贵的重新工程或后期冲刺。让我们看看如何做到这一点。

严谨和平衡的方法

我们已经涵盖了软件开发中的“是什么”,但我们必须考虑“为什么”、“何时”、“何地”和“谁”,然后我们才能到达“如何”。当我们为学习或激情项目开发应用时,我们通常会过度简化我们的项目。如果你的激情项目意外地一夜成名,那么维护或继续添加应用功能可能会变得成本高昂。在这种情况下,你可能会面临一个选择,即承担持续维护的成本,或者重写你的应用程序。

当我们为工作开发应用时,我们往往更加保守,我们可能会过度工程我们的解决方案。然而,如果你只为工作编码,那么你可能会在生产代码中进行实验。在与其他团队成员共享的代码库中进行实验是危险的。你可能会引入一种新的模式,而你的团队可能不了解你选择的结果。你也不太可能意识到你引入的技术在中等或长期风险或收益。

草率实验也可能对您的团队成员产生严重的负面影响。在一个由资深和经验丰富的软件工程师组成的团队中,您可能在移动车辆中进行实验。然而,我们团队中可能有着不同背景和学习风格的成员。我们中的一些人有计算机科学学位,一些人是独行侠,一些人对 Stack Overflow 过于依赖。我们中的一些人在支持职业发展的公司工作,但一些人在不会给您一天时间学习新东西的地方工作。因此,当我们进行实验时,我们必须考虑我们的环境;否则,我们可能会让我们的同事加班或感到无助和沮丧。

采用有纪律和平衡的方法,我们可以减少交付的缺陷数量,避免昂贵的返工,并与所有朝同一方向前进的人一起工作。我们还需要正确的架构、工具和模式/实践来成功交付。总之,我们的方法必须考虑:

  • 我们应用的大小

  • 我们开发应用的原因

  • 开发者的技能水平

  • 迭代和增量交付

  • 特性的持续向前流动

  • 所有云相关事物

理想情况下,我们需要保持最佳工程开销。我们的架构应该支持我们的短期需求,同时具有可扩展性,这样我们就可以在长期或中期需求发生变化时,无需重写大量代码,而在不同方向上进行调整。考虑以下图表,与上一节中关于小型和大型企业应用的图表进行对比:

图 7.5:LOB 应用的理想架构之旅

以路由器优先的架构旨在帮助您在工程开销、功能交付和代码库的灵活性之间找到平衡。然而,你必须自己带来纪律。

或 Shu Ha Ri 是一个可以帮助您在工作中建立纪律的概念。这是一种思维方式,指导您首先掌握基础知识,而不必担心底层理论,然后掌握理论,最后能够根据您的需求调整您所掌握的内容。然而,如果您跳过步骤 1 或 2,您可能会发现自己以错误的方式调整了错误的内容。

在讨论了什么为什么何时何地之后,让我们在下一节中跳入如何

路由器优先架构

路由器优先架构是一种方法:

  • 强制进行高级思维

  • 确保在开始编码之前就功能达成共识

  • 规划您的代码库/团队的增长

  • 引入小的工程开销

实施路由器优先架构的七个步骤:

  1. 制定路线图和范围(第七章

  2. 考虑懒加载进行设计(第七章

  3. 实施步行骨架导航体验(第七章

  4. 实现无状态、数据驱动的架构(第七章第十章

  5. 强制采用解耦的组件架构(第八章第十一章第十二章

  6. 区分用户控件和组件(第十一章

  7. 利用 TypeScript 和 ES 功能最大化代码复用(第八章第十章第十一章第十二章

如前所述,每个步骤将在本章和后续章节中更详细地介绍。在我们从高层次概述这些步骤之前,让我们首先介绍 Angular 中的功能模块,这是一个重要的基本技术概念。

功能模块

第一章Angular 及其概念简介中,我们以高层次介绍了 Angular 的架构,并介绍了懒加载和路由的概念。功能模块是实现懒加载的关键组件。有两种类型的模块:根模块和功能模块。模块通过NgModule类实现。NgModule包含渲染组件和注入服务所需的所有必要元数据。没有模块的组件几乎不起作用。

一个 Angular 应用程序由一个位于应用程序根部的NgModule定义。这被称为根模块。根模块负责渲染index.html文件中的<app-root>元素中显示的内容。在以下图中定位根模块:

图 7.6:Angular 的主要架构组件

一个 NgModule 可以包含许多其他 NgModules。Angular 应用程序只有一个根模块,因此根据定义,每个其他 NgModule 都成为功能模块。在前面的图中,你可以看到可以将一组组件(Cmp)和服务(Svc)组织到功能模块中。将功能分组到模块中允许我们将代码组织成块,这些块可以与应用程序的初始负载分离。

根模块和功能模块的概念代表了父/子关系,这是一个扩展到其他功能和框架的概念。例如,注意前面的图中将根路由注入到根模块中。根路由可以有子路由。子路由可以配置为加载功能模块。同样,NgRx 有根模块和功能模块级别的存储来组织应用程序的状态数据。

就本书而言,任何提及的子模块、子模块或功能模块都指的是同一件事:不是根模块的模块。

功能模块和子路由允许将应用程序的主要组件之间的关注点分离。两个团队可以同时工作在不同的模块上,而不会相互干扰。这种分离意味着任何功能模块所需的依赖项都必须明确添加到该模块的导入、声明或提供者中。当在模块之间共享代码时,这可能会显得重复且令人烦恼,但这是必要的恶行。

在 Angular 中,默认情况下,服务是单例的——每个模块一个实例。在将已导入根模块的服务导入功能模块之前,考虑这是否真的是期望的行为。在根模块中提供的服务可以在功能模块中导入,而无需再次提供。在根模块和功能模块中提供服务会导致内存中有多个该服务的实例,这打破了默认情况下服务是单例的预期。在第八章设计身份验证和授权中,当我们实现AuthService时,你将看到这一行为的具体实现。

随着 Angular 9 中 Ivy 渲染引擎的引入,创建自描述组件的道路已经铺平。自描述组件不需要 NgModule 就能发挥作用。随着 Angular 未来版本的推出,将能够实现无需整个仪式(即:样板代码)的简单应用。

现在,让我们从高层次上概述一下路由优先架构的七个步骤。

制定路线图和确定范围

在项目早期就制定路线图和确定项目范围对于确保高级架构的正确性至关重要。创建待办事项列表、线框图、原型和交互式原型将帮助你确定路线图,在开始之前捕捉到具体愿景。重要的是要记住,只有在必要时才携带工具。不要一开始就使用 Photoshop,一张纸和一支铅笔就足够了。如果利益相关者和团队成员了解正在开发的内容,那么就有可能迭代和逐步交付你的解决方案。然而,不要陷入完美的陷阱。在基本要素到位并获得一致同意之后,再进行微调和家具调整。

记录你创建的每一个工件。在本章的后面部分,我们将介绍如何利用 GitHub Wikis 来存储你的工件。

在本章的后面部分,我们将介绍如何制定路线图和定义范围的技术,这些技术是在第三章创建基本的 Angular 应用中介绍的路线图构建技术的基础上进行的。

以懒加载为设计理念

首次绘制非常重要!根据 Angular 团队在 2018 年收集的 Google Analytics 数据,53%的移动用户在加载时间超过 3 秒时放弃了网站。在同一时间段内,大多数网站都是在移动设备上被消费的,在美国约为 70%以上,在中国约为 90%以上。正如我们在第五章使用 Material 提供高质量 UX中所述,UI 库和静态资产可以显著增加你的应用程序的大小。鉴于大多数内容都是在移动设备上被消费的,延迟加载非关键资产非常重要。

我们通过将我们的 Angular 应用程序的部分划分为功能模块来延迟加载资源。这样,Angular 只能加载渲染当前屏幕所需的资源,并在需要时动态下载进一步的资源。将你的应用程序划分为功能模块的一个好方法是通过定义应用程序可能使用的各种用户角色。用户角色通常表示用户的职能,例如经理或数据录入专员。从技术角度来说,它们可以被视为一组特定类别的用户被允许执行的操作。毕竟,数据录入专员永远不会看到经理可以看到的大多数屏幕,那么为什么要把这些资源提供给这些用户并减慢他们的体验呢?

懒加载对于创建可扩展的应用程序架构至关重要,它允许你交付高质量和高效的产品。懒加载是一个低垂的果实,我们将将其作为基本设计目标来处理。在事后实现懒加载可能会很昂贵。

从 Angular 9 开始,可以懒加载单个组件。Angular 9 的 Ivy 渲染引擎使得组件具有自描述性和独立性。那些不需要 Angular 应用程序所需的所有引导的组件有可能彻底改变并简化我们设计应用程序的方式。然而,目前还无法以这种方式设计应用程序。期待 Angular 的未来版本引入公共 API,使其能够轻松使用新功能,减少早期精心设计功能模块的需求。

在本章的后面部分,你将学习如何使用功能模块实现懒加载。

实现一个行走骨架

配置懒加载可能很棘手,这就是为什么在早期就确定行走骨架导航体验至关重要。实现一个可点击的应用程序版本将帮助你尽早收集用户反馈。这样,你将能够快速解决基本的工作流程和集成问题。此外,你将能够确立当前开发工作的具体范围。开发人员和利益相关者都将能够更好地可视化最终产品的外观。

行走骨架也为多个团队协同工作奠定了基础。多个人可以同时开始开发不同的功能模块或组件,而不必担心这些拼图碎片将来如何拼合在一起。到本章结束时,你将完成实现示例应用程序 LemonMart 的行走骨架。

实现无状态、数据驱动的架构

如第十章“RESTful API 和全栈实现”中强调的那样,在全栈架构中实现无状态设计对于构建可维护的应用程序至关重要。如第一章“Angular 简介及其概念”和第十二章“食谱 - 主/详细、数据表和 NgRx”中所述,Flux 模式和 NgRx 使得为应用程序实现不可变状态成为可能。然而,Flux 模式对于大多数应用程序来说可能过于复杂。NgRx 本身利用了 RxJS 中许多核心技术。

我们将使用 RxJS 和响应式编程范式来实现一个最小化、无状态和以数据驱动的模式来构建我们的应用程序。识别用户将要操作的主要数据实体,例如发票或人员,将帮助您避免过度设计应用程序。围绕主要数据实体进行设计将在早期就指导 API 设计,并帮助定义您将用于实现无状态、数据驱动设计的 BehaviorSubject 数据锚点。这种设计反过来将确保一个解耦的组件架构,如第六章“表单、Observables 和 Subjects”中详细所述。

通过定义可观察的数据锚点,您可以确保各个组件之间的数据保持同步。通过编写功能响应式代码,利用 RxJS 功能,并在组件中不存储状态,我们可以实现不可变的数据流。

我们将在第十章“RESTful API 和全栈实现”中介绍如何设计应用程序的数据模型,并在接下来的章节中继续使用这些模型。

强制实施解耦的组件架构

如我们在第一章“Angular 简介及其概念”中讨论的那样,解耦架构中的组件对于确保代码库的可维护性至关重要。在 Angular 中,您可以通过利用 @Input@Output 绑定以及路由编排来实现组件的解耦。

绑定将帮助您维护一个简单的组件层次结构,并在静态设计更有效的情况下避免使用动态模板,例如创建多页表单。

路由出口和辅助路径允许您使用路由来组合视图。解析器可以通过消耗路由参数来帮助加载数据。身份验证守卫可以帮助控制对各种模块和组件的访问。使用路由链接,您可以以动态和可预测的方式定制用户将看到的元素,类似于我们在上一步中设计和开发数据锚点的方式。

如果您确保每个组件都负责加载自己的数据,那么您可以通过 URL 来组合组件。然而,过度使用路由本身可能成为一种反模式。如果一个父组件在逻辑上拥有一个子组件,那么解耦它们的努力将是徒劳的。

在第六章“表单、可观察对象和主题”中,你学习了如何使用 BehaviorSubject 启用组件交互。在第十一章“食谱 - 可重用性、路由和缓存”中,你将学习如何实现 @Input@Output 绑定,在接下来的章节中,你将学习如何实现路由功能。

区分用户控件和组件。

另一个重要的观点是区分用户控件和组件。用户控件就像自定义日期输入或自定义星级评分器。它通常是高度交互性和动态的代码,最终会变成高度耦合、复杂和复杂的代码。这样的控件可能会使用到之前没有人听说过的 Angular 特性,这些特性很可能在本书中没有涉及。

组件更像是带有字段的表单,可能包含简单的日期输入或星级评分器。因为表单封装了业务功能,所以其代码必须易于阅读和理解。你的代码应遵循 Angular 基础,这样代码就稳定且易于维护,就像本书中展示的大多数代码一样。

通过区分用户控件和组件,你可以在决定想要创建哪种可重用代码时做出更好的决策。创建可重用代码是有成本的。如果你创建了正确的可重用代码,你可以节省时间和资源。如果你创建了错误的可重用代码,那么你可能会浪费大量的时间和资源。

原型设计允许你早期识别可重用元素。用户控件将帮助将用户交互代码与业务逻辑分离。精心设计的组件重用将使你能够封装特定领域的行怍,并在以后共享。

识别封装你希望为你的应用程序创建的独特行为的自包含用户控件是很重要的。用户控件很可能会以指令或具有数据绑定属性和紧密耦合的控制器逻辑和模板的组件的形式创建。

另一方面,组件利用路由生命周期事件来解析参数并在数据上执行 CRUD 操作。早期识别这些组件重用将导致创建更多灵活的组件,这些组件可以在多个上下文中重用(由路由器编排),从而最大化代码重用。

我们将在第十一章“食谱 - 可重用性、路由和缓存”中介绍如何创建可重用组件和用户控件。

使用 TypeScript 和 ES 最大化代码重用。

在考虑 Angular、RxJS 以及你使用的所有库提供的功能之前,记住你所使用语言的底层特性是至关重要的。你可以利用数十年的软件工程基础来编写可读性和可维护性强的代码。

首先最重要的是 DRY 原则,即不要重复自己。所以,不要复制粘贴代码。不要只是更改一个或两个变量。积极重构你的代码,使你的函数无状态和可重用。简而言之:不要重复自己,不要重复自己,不要重复自己。

利用面向对象设计。将行为移动到类中;如果你的person对象有一个fullName属性,不要在十几个不同的地方重新实现相同的逻辑,而是在person类中实现一次。这意味着你需要熟悉水合(也称为将 JSON 对象注入新实例化的类)以及使用toJSON进行序列化。重要的是不要过度使用面向对象编程。你应该通过避免在类参数中存储状态,保持无状态和函数式。

通过利用泛型、继承和抽象类,你可以真正发挥面向对象设计的威力。

TypeScript 将接口的概念引入 JavaScript。接口主要是一个静态类型语言的保留概念。接口代表了一个对象可以做什么的抽象概念,而不指定任何实现细节。此外,接口可以用来记录数据的形状。例如,你可以编写第三方 API 的部分接口来记录你感兴趣消费的字段。当其他开发者阅读你的代码时,他们可以本能地理解他们消费的数据结构,而无需阅读另一个网站上的文档。

接口还允许你以定义良好的方式改变数据结构。因此,你可以编写一个转换函数来将外部数据结构转换为内部数据结构。TypeScript 会捕获你可能犯的任何错误。进一步来说,你也可以使用接口来简化数据。如果你接收到的数据具有多实体关系结构,你可以简化这种关系,从而将数据设计从你的 UI 代码中解耦。

不要过度简化你的数据。对于常见的对象,如姓名对象或常用领域特定对象,数组和简单形状都是可以的。

你还应该避免在代码中使用字符串字面量。在比较 'apples' !== 'Oranges' 的业务逻辑中编写代码会导致难以维护的代码。你应该在 TypeScript 中利用枚举(enums),这样你的代码就不会受到程序员拼写错误或业务需求变化的影响。所以 'oranges' === Fruit.Organes

除了 TypeScript 和 ECMAScript 之外,Angular 还提供了有用的函数,供你重用逻辑。Angular 验证器、管道、路由解析器和路由守卫都允许你在组件和模板之间共享代码。

以下章节将演示上述概念:

  • 第八章设计身份验证和授权

  • 第十章RESTful API 和全栈实现

  • 第十一章食谱 – 可重用性、路由和缓存

  • 第十二章食谱 – 主/详细、数据表和 NgRx

接下来,让我们先创建 LemonMart™,这是一个功能齐全的业务线应用,你可以将其用作启动下一个专业项目的模板。LemonMart 是一个强大且现实的项目,可以支持功能增长和不同的后端实现,并且它自带完整且可配置的认证和授权解决方案。

自从 LemonMart 上市以来,它已经为超过 14,000 名开发者提供了超过 160,000 个柠檬。真美味!图片

你可以随时从 GitHub 克隆完成的项目,www.github.com/duluca/lemon-mart,无论何时你需要它。让我们直接进入正题。

创建 LemonMart

LemonMart 将是一个中等规模的业务线应用,拥有超过 90 个代码文件。我们将从创建一个新的 Angular 应用程序开始,从一开始就配置了路由和 Angular Material。

假设你已经安装了 第二章 中提到的所有必需的软件,设置你的开发环境。如果没有,请根据你的操作系统执行以下命令来配置你的环境。

在 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

创建一个路由优先的应用

使用路由优先的方法,我们希望在应用早期就启用路由:

  1. 你可以通过执行以下命令创建一个新应用,其中已经配置了路由。

    确保没有全局安装 @angular/cli,否则你可能会遇到错误:

    $ npx @angular/cli new lemon-mart --routing --strict
    (Select CSS as the stylesheet format ) 
    

    从 Angular 9 开始,你可以使用 --strict 来启用 TypeScript 功能,如 noImplicitAnynoImplicitReturnsnoFallthroughCasesInSwitchstrictNullChecks。这些选项将减少编码错误的机会,但会导致代码更加冗长。在我看来,这是一个好事,并且这个选项非常推荐用于生产环境的应用。

  2. 为我们创建了一个新的 AppRoutingModule 文件:

    **src/app/app-routing.modules.ts**
    import { NgModule } from '@angular/core';
    import { Routes, RouterModule } from '@angular/router'; 
    const routes: Routes = [];
    @NgModule({
      imports: [RouterModule.forRoot(routes)],
      exports: [RouterModule],
    })
    export class AppRoutingModule { } 
    

    我们将在路由数组内部定义路由。请注意,路由数组被传递进来以配置为应用的根路由;默认的根路由是 /

    当配置你的 RouterModule 时,你可以传递额外的选项来定制 Router 的默认行为,例如当你尝试加载一个你已经处于其中的路由时。通常情况下,如果你尝试导航到的路由与当前路由相同,路由器不会采取任何行动。然而,如果你想让路由器刷新页面,你可以定制路由器的默认行为,例如使用 RouterModule.forRoot(routes, { onSameUrlNavigation: 'reload' })。启用此设置后,如果你导航到与当前相同的 URL,你将强制重新加载当前组件。

  3. 最后,AppRoutingModuleAppModule 注册,如下所示:

    **src/app/app.module.ts**
    ...
    import { AppRoutingModule } from './app-routing.module';
    @NgModule({ 
      ... 
      imports: [ AppRoutingModule, ... ], 
      ...
    }) 
    

配置 Angular 和 VS Code

要快速应用第 2-6 章中涵盖的配置步骤,请运行以下命令:

以下脚本不需要你使用 VS Code。如果你希望使用其他 IDE,如 WebStorm,配置的 npm 脚本同样可以很好地运行。

  1. 安装 Angular VS Code 任务:

    npm i -g mrm-task-angular-vscode 
    
  2. 应用 Angular VS Code 配置:

    npx mrm angular-vscode 
    
  3. 安装 Docker 任务的 npm 脚本:

    npm i -g mrm-task-npm-docker 
    
  4. 应用 Docker 配置的 npm 脚本:

    npx mrm npm-docker 
    
  5. 实现一个名为 build:prod 的 npm 脚本来在生产模式下构建你的应用。

    "scripts": {
      ...,
      "build:prod": "ng build --prod",
    } 
    

这些设置不断调整以适应扩展、插件、Angular 和 VS Code 的不断变化。始终确保通过重新运行 install 命令来安装任务的最新版本。或者,你可以使用 VS Code 的 Angular Evergreen 扩展来一键运行配置命令。

注意,如果前面的配置脚本执行失败,那么以下 npm 脚本也会失败。在这种情况下,你有两个选择:撤销你的更改并忽略这些脚本,或者手动实现这些脚本,如前几章所述(或如 GitHub 上所示)。

  1. 执行 npm run style:fix

  2. 执行 npm run lint:fix

  3. 执行 npm start 并确保你正在运行在 http://localhost:5000,而不是默认端口 4200

参考第二章,设置你的开发环境,以获取更多配置细节。

你可以选择使用 mrm-task-npm-aws 为 AWS ECS 设置 npm 脚本,这在第十三章,AWS 上的高可用云基础设施中有所介绍。

关于 mrm 任务更多信息,请参考:

配置材料和样式。

我们还需要设置 Angular Material 并配置一个要使用的主题,如第五章,使用 Material 提供高质量 UX 中所述:

  1. 安装 Angular Material:

    $ npx ng add @angular/material
    (select Custom, No to global typography, Yes to browser animations)
    $ npm i @angular/flex-layout 
    $ npx ng g m material --flat -m app 
    
  2. material.module.ts 中定义一个 const modules 数组并导出 MatButtonModuleMatToolbarModuleMatIconModule,移除 CommonModule

  3. app.modules.ts 中导入 FlexLayoutModule 以激活 Angular Flex Layout。

  4. 按照以下代码将常用 CSS 添加到 styles.css 中:

    **src/styles.css**
    html,
    body { 
      height: 100%; 
    }
    body { 
      margin: 0;
      font-family: Roboto, "Helvetica Neue", sans-serif; 
    }
    .margin-top { 
      margin-top: 16px;
    }
    .horizontal-padding { 
      margin-left: 16px;
      margin-right: 16px;
    }
    .flex-spacer {
      flex: 1 1 auto;
    } 
    
  5. index.html 中更新你的应用标题。

参考第五章,使用 Material 提供高质量 UX,以获取更多配置细节。

我们将在本章的后面部分应用自定义品牌到应用中。接下来,让我们开始设计我们的业务线应用。

设计 LemonMart。

在数据库到前端的过程中,构建一个基本的路线图来遵循,同时避免过度设计,这一点非常重要。这个初始设计阶段对你的项目的长期健康和成功至关重要,其中必须打破团队之间现有的隔阂,并确保所有团队成员都充分理解整体技术愿景。这说起来容易做起来难,关于这个话题已经有许多书籍被撰写。

在工程领域,没有一种正确答案可以解决所有问题,因此记住没有人能够拥有所有答案,也没有人能够拥有清晰明确的愿景,这一点非常重要。在文化中,技术和非技术领导者创建一个安全的空间,提供开放讨论和实验的机会,这一点非常重要。作为一个团队,能够共同面对这种不确定性所带来的谦逊和同理心,与任何单个团队成员的技术能力一样重要。每个团队成员都必须舒适地放下他们的自我,因为我们的共同目标是在开发周期中不断增长和演变一个能够满足不断变化需求的应用程序。如果你知道你成功了,那么你创建的软件的各个部分将很容易被任何人替换。

因此,让我们首先开发一个路线图,并确定我们应用程序的范围。为此,我们将定义用户角色,然后构建一个网站图,以形成一个关于我们的应用程序可能如何工作的愿景。

确定用户角色

我们设计的第一步将是思考谁在使用这个应用程序以及为什么。

我们设想了 LemonMart 的四种用户状态或角色:

  • 认证;任何认证用户都将能够访问其个人资料

  • 收银员,其唯一角色是结账客户

  • 会计,其唯一角色是执行与库存相关的功能

  • 经理,可以执行收银员和会计可以执行的所有操作,同时还可以访问管理功能

考虑到这一点,我们可以开始为我们的应用程序创建一个高级设计。

使用网站图确定高级模块

开发一个高级网站图,如下所示:

图 7.7:用户登录页面

我使用了 MockFlow.com 的网站图工具创建了显示的网站图:sitemap.mockflow.com

在初步检查后,有三个高级模块被认为是懒加载的候选者:

  1. 销售点(POS

  2. 库存

  3. 经理

收银员只能访问POS模块和组件。会计只能访问库存模块,该模块将包括库存录入产品类别管理组件的附加屏幕:

图 7.8:库存页面

最后,经理将能够通过经理模块访问所有三个模块,包括用户管理和收据查找组件:

图 7.9:经理页面

启用所有三个模块的懒加载将带来巨大的好处;由于收银员和店员永远不会使用属于其他用户角色的组件,没有必要将这些字节发送到他们的设备上。这意味着随着Manager模块获得更多高级报告功能,或者新角色被添加到应用程序中,POS模块将不会受到其他增长应用程序带宽和内存影响的冲击。

这意味着更少的支持电话,以及更长时间在同一硬件上的一致性能。

生成具有路由功能的模块

现在我们已经将高级组件定义为ManagerInventoryPOS,我们可以将它们定义为模块。这些模块将不同于您迄今为止创建的模块,因为它们用于路由和 Angular Material。我们可以在应用模块上创建用户配置文件作为组件;然而,请注意,用户配置文件将仅用于已认证的用户,因此定义一个仅针对一般认证用户的第四个模块是有意义的。这样,您将确保您的应用的第一批数据尽可能最小化。此外,我们将创建一个主页组件来包含我们应用的着陆体验,这样我们就可以将实现细节从app.component中分离出来:

  1. 生成managerinventoryposuser模块,指定它们的目标模块和路由功能:

    $ npx ng g m manager -m app --routing
    $ npx ng g m inventory -m app --routing
    $ npx ng g m pos -m app --routing
    $ npx ng g m user -m app --routing 
    

    如同在第二章设置您的开发环境中讨论的那样,如果您已经配置了npx以自动识别ng作为命令,那么您可以节省一些按键,因为您不必每次都附加npx到您的命令中。不要全局安装@angular/cli

    注意简化的命令结构,其中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-routing.module.ts
    │   app.component.css
    │   app.component.html
    │   app.component.spec.ts
    │   app.component.ts
    │   app.module.ts
    │   material.module.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 
    
  4. 检查ManagerModule是如何连接的。

功能模块实现了一个类似于app.module@NgModule。最大的区别是功能模块不实现bootstrap属性,这是您的根模块初始化 Angular 应用所必需的:

**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 {} 

由于我们指定了-m选项,模块已经被导入到app.module中:

**src/app/app.module.ts**
...
import { ManagerModule } from './manager/manager.module'
...
@NgModule({ 
  ... 
  imports: [..., ManagerModule], 
  ...
}) 

此外,因为我们还指定了--routing选项,一个路由模块已经被创建并导入到ManagerModule中:

**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 模块中是案例。这样,路由器可以正确理解不同模块上下文中定义的路由之间的关系,并可以在本例中将 /manager 前缀正确地添加到所有子路由。

CLI 不尊重您的 tslint.json 设置。如果您已正确配置了 VS Code 环境并使用 Prettier,则您的代码样式首选项将在您处理每个文件时应用,或在运行 prettier 命令时全局应用。

在继续之前,请确保运行您的 style:fixlint:fix 命令。现在,让我们设计 LemonMart 的着陆页的外观和工作方式。

设计主页路由

将以下模拟图视为 LemonMart 的着陆体验:

图 7.10:LemonMart 着陆体验

LocalCastWeather 应用不同,我们不希望所有这些标记都放在 App 组件中。App 组件是您整个应用的根元素;因此,它应该只包含将在您的应用中持续出现的元素。在以下注释的模拟图中,标记为 1 的工具栏将在整个应用中保持不变。

标记为 2 的区域将包含 home 组件,它本身将包含一个登录用户控件,标记为 3

图 7.11:LemonMart 布局结构

在 Angular 中,将默认或着陆组件作为单独的元素创建是最佳实践。这有助于减少必须加载和执行的代码量以及逻辑,但这也使得在利用路由器时具有更灵活的架构。

使用内联模板和样式生成 home 组件:

$ npx ng g c home -m app --inline-template --inline-style 

注意,具有内联模板和样式的组件也被称为 单文件组件SFC

现在,您已经准备好配置路由器。

设置默认路由

让我们开始设置 LemonMart 的简单路由。我们需要设置 / 路由(也称为空路由)和 /home 路由以显示 HomeComponent。我们还需要一个通配符路由来捕获所有未定义的路由并显示 PageNotFoundComponent,这也需要创建:

  1. 配置 home 路由:

    **src/app/app-routing.module.ts**
    ...
    **import { HomeComponent } from './home/home.component'**
    const routes: Routes = [
     **{ path: '', redirectTo: '/home', pathMatch: 'full' },**
     **{ path: 'home', component: HomeComponent },**
    ]
    ... 
    

    我们首先为 'home' 定义一个路径,并通过设置 component 属性通知路由器渲染 HomeComponent。然后,我们将应用的默认路径 '' 设置为重定向到 '/home'。通过设置 pathMatch 属性,我们始终确保这个非常具体的 home 路由实例将被渲染为着陆体验。

  2. 创建一个具有内联模板的 pageNotFound 组件

  3. PageNotFoundComponent 配置通配符路由:

    **src/app/app-routing.module.ts**
    **import {** 
     **PageNotFoundComponent** 
    **} from './page-not-found/page-not-found.component'** 
    ...
    const routes: Routes = [
      ...
        **{ path: '**', component: PageNotFoundComponent },**
      ]
    ... 
    

这样,任何未匹配的路由都将被重定向到 PageNotFoundComponent

当用户到达 PageNotFoundComponent 时,我们希望他们使用 routerLink 指令重定向到 HomeComponent

  1. 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-routing.module 中定义的根路由的根元素,这允许我们在该根元素内定义出口,以便使用 <router-outlet> 元素动态加载我们希望的内容:

  1. 配置 AppComponent 使用内联模板和样式,删除 html 和 css 文件中任何现有的内容

  2. 为您的应用程序添加工具栏

  3. 将您应用程序的名称作为按钮链接添加,以便点击时将用户带到主页

  4. 为内容渲染添加 <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 图标

为了构建一个吸引人且直观的工具栏,我们必须向应用程序引入一些图标和品牌,以便用户可以在熟悉图标的帮助下轻松地浏览应用程序。

品牌化

在品牌化方面,您应该确保您的网络应用程序有一个自定义的色彩调色板,并与桌面和移动浏览器功能集成,以突出显示您的应用程序名称和图标。

色彩调色板

使用第五章使用 Material 提供高质量 UX中讨论的 Material Color 工具选择一个色彩调色板。这是我为 LemonMart 选择的一个:

material.io/resources/color/#!/?view.left=0&view.right=0&primary.color=2E7D32&secondary.color=C6FF00

  1. custom-theme.scss 重命名为 lemonmart-theme.scss

  2. 使用新的主题文件名更新 angular.json

    **angular.json**
    "apps": [ {
      ... 
      "styles": [ 
        "src/lemonmart-theme.scss", 
        "src/styles.css" 
      ],
      ...
    }] 
    
  3. 使用所选的色彩调色板配置您的自定义主题

您还可以从 GitHub 获取 LemonMart 相关资产:github.com/duluca/lemon-mart

对于本地天气应用程序,我们替换了 favicon.ico 文件以在浏览器中品牌化我们的应用程序。虽然这在十年前就足够了,但今天的设备种类繁多,每个平台都可以利用优化的资产来更好地在其操作系统中代表您的网络应用程序。接下来,让我们实现一个更健壮的 favicon。

实现浏览器清单和图标

您需要确保浏览器在浏览器标签中显示正确的标题文本和图标。此外,还应创建一个实现各种移动操作系统的特定图标的 manifest 文件,这样如果用户将您的网站固定,就会显示类似于手机上其他应用图标的期望图标。这将确保如果用户在移动设备的首页上收藏或固定您的网络应用,他们将获得原生外观的应用图标:

  1. 从设计师或类似www.flaticon.com的网站上创建或获取您网站标志的 SVG 版本

  2. 在这个例子中,我将使用 Eureka 柠檬的相似图像:Eureka 柠檬的相似图像

    图 7.12:LemonMart 的标志性标志

    当使用您在网上找到的图片时,请注意适用的版权。在这种情况下,我已购买许可证以能够发布这个柠檬标志,但您可以在以下 URL 获取您自己的副本,前提是您向图片的作者提供所需的归属:www.flaticon.com/free-icon/lemon_605070

  3. 使用诸如realfavicongenerator.net之类的工具生成favicon.ico和 manifest 文件

  4. 调整 iOS、Android、Windows Metro 和 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 声明之后、样式导入之前放置前面的 HTML。顺序很重要。浏览器从上到下加载数据。您希望应用程序的图标在用户等待下载 CSS 文件之前被解析。

  9. 确保您的新 favicon 显示正确

一旦您完成基本品牌工作,请考虑是否想通过主题化建立更独特的视觉和感觉

自定义主题

您可以通过利用material.io/tools上列出的工具以及我发现的某些其他工具来进一步自定义 Material 的外观和感觉,以实现您应用独特的体验,这些工具如下列所示:

  • Material Theme Editor(自 2020 年 3 月已停用)是适用于 macOS 上流行的设计应用 Sketch 的插件,用于创建比皮肤更深层次的主题。一个替代工具尚未宣布,但您可以在以下链接的博客文章中找到您可以继续使用的资源:material.io/tools/theme-editor

  • Material Theme Builder 是构建在 Glitch.com 上工作的自定义主题的替代方案,网址为material-theme-builder.glitch.me

  • 材料设计主题调色板生成器将在mcg.mbitson.com生成定义您自定义颜色调色板的必要代码,以创建真正独特的主题。

  • 颜色混合器有助于在两种颜色之间找到中间点,这在定义颜色样本之间的中间颜色时很有用,位于meyerweb.com/eric/tools/color-blend

material.io上有很多关于 Material 设计背后深入哲学的信息,包括关于色彩系统material.io/design/color/the-color-system.html的精彩部分,深入探讨了为您的品牌选择正确的调色板以及其他主题,例如为您的应用创建深色主题。

区分您的品牌与其他应用或竞争对手非常重要。创建高质量的定制主题将是一个耗时的过程;然而,给用户留下深刻第一印象的好处是相当可观的。

接下来,我们将向您展示如何将自定义图标添加到您的 Angular 应用中。

自定义图标

现在,让我们在您的 Angular 应用中添加您的自定义品牌。您需要用于创建 favicon 的 svg 图标:

  1. 将图片放在src/assets/img/icons下,命名为lemon.svg

  2. app.module.ts中,将HttpClientModule导入到AppComponent,以便可以通过 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'**
     **)** 
     **)**
      } 
    } 
    
  4. 将图标添加到工具栏:

    **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 文件的过程中节省速度和延迟。完整的图标列表可以在material.io/icons/找到。

现在,让我们更新工具栏并设置主页,使用最小模板创建一个假登录按钮:

  1. 确保 Material 图标<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自托管部分找到。

    一旦配置完成,使用 Material 图标就变得简单。

  2. AppComponent上,更新工具栏以将菜单按钮放置在标题左侧。

  3. 添加fxFlex指令,以便剩余的图标右对齐。

  4. 添加用户资料和注销图标:

    **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>
    `, 
    
  5. HomeComponent中,添加一个用于登录体验的最小模板,替换任何现有内容:

    **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>
      ` 
    

你的应用应该看起来与这张截图相似:

图片

图 7.13:LemonMart 的最小化登录

在实现和显示/隐藏菜单、个人资料和注销图标方面,根据用户的认证状态,还有一些工作要做。我们将在第八章设计认证和授权中介绍这一功能。

为了调试路由,获取你的路由的可视化,并使用 Angular Augury 紧密集成 Chrome 调试功能,请参阅附录 A调试 Angular

现在你已经为你的应用设置了基本的路由,我们可以继续设置带有子组件的 lazy 加载模块。如果你不熟悉 Angular 的故障排除和调试,请在继续之前参考附录 A调试 Angular

带有 lazy 加载的功能模块

资源有两种加载方式:eager 或 lazy。当浏览器加载你的应用的index.html时,它从上到下开始处理。首先处理<head>元素,然后是<body>。例如,我们在应用的<head>中定义的 CSS 资源将在应用渲染之前下载,因为我们的 Angular 应用在 HTML 文件的<body>中定义为<script>

当你使用命令ng build时,Angular 利用 webpack 模块打包器将所有 JavaScript、HTML 和 CSS 组合成最小化和优化的 JavaScript 包。

如果你不在 Angular 中使用 lazy 加载,你的应用的所有内容都将被 eager 加载。用户将不会看到你的应用的第一屏,直到所有屏幕都下载并加载完成。

Lazy 加载允许 Angular 构建过程与 webpack 协同工作,将你的 Web 应用分离成不同的 JavaScript 文件,称为 chunks。我们可以通过将应用的部分功能分离成功能模块来启用这种 chunking。功能模块及其依赖可以被捆绑到单独的 chunks 中。记住,根模块及其依赖将始终在下载的第一个 chunk 中。因此,通过 chunking 我们应用的 JavaScript 包大小,我们保持初始 chunk 的大小最小。有了最小化的初始 chunk,无论你的应用如何增长,首次有意义的绘制时间保持不变。否则,随着你向应用添加更多功能和功能,你的应用将需要更长的时间来下载和渲染。Lazy 加载对于实现可扩展的应用架构至关重要。

考虑以下图形以确定哪些路由是 eager 加载的,哪些是 lazy 加载的:

图片

图 7.14:Angular 路由的 eager 加载与 lazy 加载

rootRouter 定义了三条路由:abc/master/detail 代表了命名的路由出口,这在 第十二章食谱 – 主/详情、数据表和 NgRx 中有详细说明。路由 a 是应用的默认路由。路由 ac 用实线连接到 rootRouter,而路由 b 则使用虚线连接。在这种情况下,路由 b 被配置为懒加载路由。这意味着路由 b 将动态加载一个名为 BModule 的功能模块,该模块包含其 childRouter。这个 childRouter 可以定义任意数量的组件,甚至可以复用其他地方已经复用的路由名称。在这种情况下,b 定义了三条额外的路由:def

考虑 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', component: CComponent },
  { path: '**', component: PageNotFoundComponent },
] 

注意,路由 def 的定义在 rootRouter 中不存在。请参阅 childRouter 的示例路由定义:

**childRouter example**
const routes: Routes = [
  { path: '', redirectTo: '/b/d', pathMatch: 'full' },
  { path: 'd', component: DComponent },
  { path: 'e', component: EComponent },
  { path: 'f', component: FComponent },
] 

如您所见,childRouter 中定义的路由与 rootRouter 中定义的路由是独立的。子路由存在于一个层次结构中,其中 /b 是父路径。要导航到 DComponent,您必须使用路径 /b/d,而要导航到 CComponent,您只需使用 /c 即可。

给定这个示例配置,rootRouter 中定义的每个组件及其依赖项都会在我们的应用的第一块中,因此会预先加载。第一个块将包括组件 AMasterDetailCPageNotFound。第二个块将包含组件 DEF。这个第二个块将不会在用户导航到以 /b 开头的路径之前下载或加载;因此,它是懒加载的。

在书中,我只介绍了已建立的方法,即懒加载功能模块。请查看 John Papa 的博客文章,关于在 johnpapa.net/angular-9-lazy-loading-components/ 创建懒加载组件。

我们现在将介绍如何设置带有组件和路由的功能模块。我们还将使用 Augury 来观察我们各种路由配置的效果。

配置带有组件和路由的功能模块

管理模块需要一个着陆页,如图所示:

图 7.15:管理员的仪表板

让我们从创建 ManagerModule 的主屏幕开始:

  1. 创建 ManagerHome 组件:

    $ npx ng g c manager/managerHome -m manager -s -t 
    

    为了在 manager 文件夹下创建新的组件,我们必须在组件名称前加上 manager/ 前缀。此外,我们指定该组件应该用 ManagerModule 导入和声明。由于这是一个另一个着陆页,它可能不会复杂到需要单独的 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 配置 ManagerHome 组件的路由,类似于我们如何使用 app-route.module 配置 Home 组件:

    **src/app/manager/manager-routing.module.ts**
    import { 
      ManagerHomeComponent 
    } from './manager-home/manager-home.component' 
    const routes: Routes = [ 
      { path: '', redirectTo: '/manager/home', pathMatch: 'full' }, 
      { path: 'home', component: ManagerHomeComponent }, 
    ] 
    

注意,http://localhost:5000/manager 实际上还没有解析到组件,因为我们的 Angular 应用不知道 ManagerModule 的存在。让我们首先尝试强制、急切加载的方法来导入 ManagerModule 并将管理路由注册到我们的应用中。

急切加载

让我们先急切加载 ManagerModule,这样我们可以看到在根模块中导入和注册路由并不会导致可扩展的解决方案:

  1. app.module.ts 中导入 ManagerModule

    **src/app/app.module.ts**
    import { ManagerModule } from './manager/manager.module' 
    ...
    @NgModule({ 
      imports: [..., ManagerModule],
      ...
    }) 
    

    你会注意到 http://localhost:5000/manager 仍然没有渲染其 home 组件。

  2. 使用 Augury 调试路由状态,如图所示:图片

    图 7.16:急切加载的路由树

    注意,在发布时,Augury 对 Ivy 渲染引擎的支持并不好。为了可靠地查看 路由树 选项卡,你需要禁用 Ivy。你可以在项目的 tsconfig.app.json 文件中添加以下设置:

     "angularCompilerOptions": {
        "enableIvy": false
      } 
    

    你需要重新启动你的 Angular 应用并重新加载 Augury 以使更改生效。然而,得到一个漂亮的图表并不值得意外地将你的应用与 Ivy 禁用一起发布。请小心处理这个问题!

  3. 看起来 /manager 路径已正确注册并指向正确的组件,ManagerHomeComponent。这里的问题是,在 app-routing.module 中配置的 rootRouter 并不知道 /manager 路径,所以 ** 路径优先级更高,导致渲染了 PageNotFoundComponent

  4. app-routing.module.ts 中实现 'manager' 路径,并将其分配给 ManagerHomeComponent,这样我们就可以看到会发生什么:

    **src/app/app-routing.module.ts**
    **import {** 
     **ManagerHomeComponent** 
    **} from './manager/manager-home/ manager-home.component'** 
    ...
    const routes: Routes = [
      ...
      **{ path: 'manager', component: ManagerHomeComponent },**
      { path: '**', component: PageNotFoundComponent },
    ] 
    

    图片

    图 7.17:重复路径注册导致的 Manager 主页渲染

    如上图所示,http://localhost:5000/manager 正确渲染,显示 manager-home 正常工作!然而,当你通过 Augury 调试路由状态时,请注意 ManagerHomeComponent 被注册了两次。这是因为 rootRouterchildRouter 的注册都被选中了。为了避免这个问题,我们不得不在 rootRouter 中集中创建所有路径,并且不使用子路由。

    将所有路径集中到 rootRouter 中并不具有良好的可扩展性,因为它强迫所有开发者维护一个单独的主文件来导入和配置每个模块。这很容易导致合并冲突和团队成员之间的挫败感。随着文件变大,引入错误的可能性会呈指数级增加,同一个路由可能会无意中注册多次。

    可以设计一个解决方案,将模块分割成多个文件。你可以在 ManagerModule 中实现一个新的路由数组,并将其导入到 rootRouter 中。让我们修复重复注册的问题。

  5. manager.module.ts 中,从导入数组中移除 ManagerRoutingModule

  6. manager.module.ts 中实现一个 Routes 数组,并为组件 ManagerHomeComponent 设置一个空路径,如下所示:

    **src/app/manager/manager.module.ts**
    import { Routes } from '@angular/router'
    export const managerModuleRoutes: Routes = [
      { path: '', component: ManagerHomeComponent }
    ] 
    
  7. app-routing.module.ts 中,导入你刚刚创建的数组,并将其分配给 'manager' 路径的 children 属性:

    **src/app/app-routing.module.ts**
    import { **managerModuleRoutes** } from './manager/manager.module'
    ...
    { path: 'manager', children: **managerModuleRoutes** }, 
    

    不要忘记移除 component 属性和 ManagerHomeModule 的导入。

让我们在 Augury 再次检查 Router 树,看看是否解决了重复注册的问题:

图片

图 7.18:带有子路由的 Router 树

提供的解决方案是有效的。因为没有重复注册,因为我们停止在 manager-routing.module.ts 中使用 childRouters。此外,我们通过不在 ManagerModule 之外导入 ManagerHomeComponent 来保持关注点的分离,从而得到一个更可扩展的解决方案。然而,随着应用的成长,我们仍然必须使用 app.module.ts 注册所有模块。因此,功能模块仍然以可能不可预测的方式紧密耦合到根模块。此外,这段代码不能被分割,因为功能模块直接在 app.module.ts 中导入,所以 TypeScript 编译器将其视为一个必需的依赖项。

接下来,让我们将我们的配置转换成懒加载配置。

懒加载

现在你已经了解了模块的急切加载是如何工作的,你将能够更好地理解我们即将编写的代码,否则它可能看起来像是黑魔法,而魔法(也称为误解)代码总是导致意大利面式架构。

我们现在将把急切加载的解决方案演变成懒加载。为了从不同的模块加载路由,我们知道我们不能简单地导入它们,否则它们将被急切加载。答案在于使用 loadChildren 属性配置一个路由,并使用内联导入语句通知路由器如何在 app-routing.module.ts 中加载功能模块:

  1. 确保你打算懒加载的任何模块都没有在 app.module.ts 中导入,因此从 imports 中移除 ManagerModule

  2. ManagerModule 中移除添加的 Routes 数组。

  3. ManagerRoutingModule 添加回 ManagerModule 中的 imports

  4. app-routing.module.ts 中,实现或更新 'manager' 路径的 loadChildren 属性:

    **src/app/app-routing.module.ts**
    import { NgModule } from '@angular/core'
    import { RouterModule, 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), 
        }, 
        { path: '**', component: PageNotFoundComponent }, 
      ] 
    ... 
    

    通过一个巧妙的技巧实现了懒加载,避免了在文件级别使用导入语句。将一个函数委托设置到 loadChildren 属性,该属性包含一个内联导入语句,定义了功能模块文件的路径,例如 ./manager/manager.module,这样我们就可以以类型安全的方式引用 ManagerModule,而无需实际完全加载它。内联导入语句可以在构建过程中被解释,以创建一个单独的 JavaScript 块,只有在需要时才下载。ManagerModule 因此表现得像它自己的 Angular 应用,并管理所有子依赖项和路由。

  5. 更新manager-routing.module路由,考虑到manager现在是它们的根路由:

    **src/app/manager/manager-routing.module.ts**
    const routes: Routes = [
      { path: '', redirectTo: '/manager/home', pathMatch: 'full' },
      { path: 'home', component: ManagerHomeComponent },
    ] 
    

    我们现在可以更新ManagerHomeComponent的路由到一个更有意义的'home'路径。这个路径不会与app-routing.module中找到的路径冲突,因为在这个上下文中,'home'解析为'manager/home',同样地,当path为空时,URL 将看起来像http://localhost:5000/manager

  6. 重新启动ng servenpm start命令,以便 Angular 可以正确地分割应用。

  7. 导航到http://localhost:5000/manager

  8. 通过查看 Augury 来确认懒加载是否正常工作,如下所示:图片

    图 7.19:具有懒加载的路由树

ManagerHomeComponent的根节点现在命名为manager [Lazy]

我们已经成功设置了一个具有懒加载的功能模块。接下来,让我们为 LemonMart 实现行走骨架。

完成行走骨架

使用我们在本章早期为 LemonMart 创建的网站图,我们需要完成应用的行走骨架导航体验。为了创建这种体验,我们需要创建一些按钮来链接所有模块和组件。我们将按模块逐一进行。

在我们开始之前,更新HomeComponent上的登录按钮,使用routerLink属性将其导航到'manager'路径,并重命名按钮:

**src/app/home/home.component.ts**
  ...
  <button mat-raised-button color="primary" **routerLink="/manager"**>
    **Login as Manager**
  </button>
  ... 

现在,我们可以通过点击登录按钮来导航到ManagerHome组件。

管理器模块

由于我们已经在ManagerModule中启用了懒加载,让我们继续完成其余的导航元素。

在当前设置中,ManagerHomeComponentAppComponent的模板中定义的<router-outlet>中渲染,所以当用户从HomeComponent导航到ManagerHomeComponent时,AppComponent中实现的工具栏仍然在位。参见以下管理器仪表板的模拟图:

图片

图 7.20:全局和功能模块工具栏

无论我们导航到哪里,全局工具栏都保持不变。你可以想象我们可以为功能模块实现一个类似的工具栏,它在ManagerModule中持续存在。因此,导航按钮用户管理收据查找将始终可见。这允许我们在模块之间导航子页面时创建一致的 UX。

为了实现一个次要的工具栏,我们需要复制AppComponentHomeComponent之间的父子关系,其中父组件实现工具栏和一个<router-outlet>,以便子元素可以在这里渲染:

  1. 首先创建基本的manager组件:

    $ npx ng g c manager/manager -m manager --flat -s -t 
    

    使用--flat选项将跳过目录创建,并将组件直接放置在manager文件夹下,就像app.component直接位于app文件夹下一样。

  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"> 
      <a mat-button 
        routerLink="/manager/home" 
        routerLinkActive="active-link"
       >
          Manager's Dashboard
      </a> 
      <a mat-button 
        routerLink="/manager/users" 
        routerLinkActive="active-link"
      >
        User Management
      </a> 
      <a mat-button 
         routerLink="/manager/receipts" 
         routerLinkActive="active-link"
       >
         Receipt Lookup
       </a> 
    </mat-toolbar> 
    <router-outlet></router-outlet>
    ` 
    

    必须注意,功能模块不会自动访问在父模块中创建的服务或组件。这是一个重要的默认行为,以保持解耦架构。然而,在某些情况下,共享一定量的代码是有益的。在这种情况下,需要重新导入mat-toolbar。由于MatToolbarModule已经在src/app/material.module.ts中加载,我们只需将此模块导入到manager.module.ts中,这样就不会有性能或内存的惩罚。

  3. 确保在ManagerModule中声明了ManagerComponent并导入了MaterialModule

    **src/app/manager/manager.module.ts**
    import { MaterialModule } from '../material.module' 
    import { ManagerComponent } from './manager.component'
    ...
    declarations: [..., **ManagerComponent**],
    imports: [..., **MaterialModule**], 
    
  4. 创建子页面的组件:

    $ npx ng g c manager/userManagement -m manager
    $ npx ng g c manager/receiptLookup -m manager 
    
  5. 创建父子路由。我们知道我们需要以下路由来导航到我们的子页面,如下所示:

    **example**
    { path: '', redirectTo: '/manager/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: '/manager/home', pathMatch: 'full' },
      { path: 'home', component: ManagerHomeComponent },
      { path: 'users', component: UserManagementComponent },
      { path: 'receipts', component: ReceiptLookupComponent },
    ],
  },
] 

现在,你应该能够导航到应用中。当你点击登录为经理按钮时,你将被带到这里显示的页面。可点击的目标被突出显示,如下所示:

图片

图 7.21:所有路由链接高亮的经理仪表板

如果你点击LemonMart,你将被带到主页。如果你点击经理仪表板用户管理收据查找,你将被导航到相应的子页面,而活动链接将在工具栏上加粗并下划线。

用户模块

登录后,用户将能够访问他们的个人资料,并通过侧边导航菜单查看他们在 LemonMart 应用中可以访问的操作列表。在第八章设计身份验证和授权中,当我们实现身份验证和授权时,我们将从服务器接收用户的角色。根据用户的角色,我们将能够自动导航或限制用户可以看到的选项。我们将在这个模块中实现这些组件,以便它们只在用户登录后加载。为了完成行走骨架,我们将忽略与身份验证相关的问题:

  1. 创建必要的组件:

    $ npx ng g c user/profile -m user
    $ npx ng g c user/logout -m user -t -s
    $ npx ng g c user/navigationMenu -m user -t -s 
    
  2. 实现路由。

    从在app-routing.module.ts中实现懒加载开始:

    **src/app/app-routing.module.ts**
    ... 
    { 
      path: 'user', 
      loadChildren: 
        () => import('./user/user.module')
          .then(m => m.UserModule), 
    }, 
    

    确保在app-routing.module中,PageNotFoundComponent路由总是最后一个路由。

    现在在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中,连接用户和注销图标:

    **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指令,并确保在material.module.ts中导入MatTooltipModule。此外,确保为仅图标按钮添加aria-label,以便依赖屏幕阅读器的残疾用户仍然可以导航你的 Web 应用。

  4. 确保应用正常运行。

    你会注意到两个按钮距离太近,如下所示:

    图片

    图 7.22:带有图标的工具栏

  5. 你可以通过向<mat-toolbar>添加fxLayoutGap="8px"来解决图标布局问题;然而,现在柠檬标志与应用名称的距离太远,如下所示:

图片

图 7.23:带有填充图标的工具栏

  1. 通过合并图标和按钮可以解决标志布局问题:

    **src/app/app.component.ts**
    ...
    <mat-toolbar>
      ...
      <a mat-icon-button routerLink="/home">
        <mat-icon svgIcon="lemon"></mat-icon>
        <span class="mat-h2">LemonMart</span>
      </a>
      ...
    </mat-toolbar> 
    

如以下截图所示,分组解决了布局问题:

图片

图 7.24:带有分组和填充元素的工具栏

从用户体验角度来看,这也是更受欢迎的;现在用户可以通过点击柠檬回到主页。

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="/inventory"** 
 **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/ch7文件夹。

POS 模块

PosModuleUserModule非常相似,除了PosModule是一个默认路径。PosComponent将是默认组件。这可能会是一个复杂的组件,包含一些子组件,因此不要使用内联模板或样式:

  1. 创建PosComponent

  2. PosComponent注册为默认路径

  3. PosModule配置懒加载

  4. 确保应用正常运行

现在我们来实现库存模块。

库存模块

InventoryModuleManagerModule非常相似,如下所示:

图片

图 7.25:库存仪表板原型

  1. 创建一个基本的Inventory组件

  2. 注册MaterialModule

  3. 创建库存主页库存录入产品分类组件

  4. inventory-routing.module.ts中配置父子路由

  5. InventoryModule配置懒加载

  6. InventoryComponent中实现内部InventoryModule导航的辅助工具栏

  7. 确保应用如所示正常运行:

图片

图 7.26:LemonMart 库存仪表板

现在应用的行走骨架已经完成,检查路由树以确保正确配置了懒加载并且模块没有意外地被急切加载是很重要的。

检查路由树

导航到应用的基路由,并使用 Augury 检查路由树,如图所示:

图片

图 7.27:具有懒加载的路由树

除了最初所需的组件之外,所有其他组件都应该用[Lazy]属性标记。如果出于某种原因,路由没有用[Lazy]标记,那么它们很可能是错误地被导入到app.module.ts或其他组件中。

在你的路由树中,你可能注意到ProfileComponentLogoutComponent是急切加载的,而UserModule被正确标记为[Lazy]。即使通过工具和代码库进行多次视觉检查,也可能让你寻找罪魁祸首。然而,如果你全局搜索UserModule,你会很快发现它被导入到app.module.ts中。在运行 CLI 命令时,你的模块可能会意外地重新导入到app.module.ts中,所以请注意这一点!

为了安全起见,检查你的app.module.ts文件,并确保删除任何非根级别的模块或组件的导入语句。你的文件应该看起来像以下这样:

**src/app/app.module.ts**
import { HttpClientModule } from '@angular/common/http'
import { NgModule } from '@angular/core'
import { FlexLayoutModule } from '@angular/flex-layout'
import { BrowserModule } from '@angular/platform-browser'
import { 
  BrowserAnimationsModule 
} from '@angular/platform-browser/ animations'
import { AppRoutingModule } from './app-routing.module'
import { AppComponent } from './app.component'
import { HomeComponent } from './home/home.component'
import { MaterialModule } from './material.module'
import { 
  PageNotFoundComponent 
} from './page-not-found/page-not-found.component' 
@NgModule({
  declarations: [AppComponent, HomeComponent, PageNotFoundComponent],
  imports: [
    BrowserModule,
    AppRoutingModule,
    BrowserAnimationsModule,
    MaterialModule,
    HttpClientModule,
    FlexLayoutModule,
  ],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {} 

如果你使用 Augury 调试路由时禁用了 Ivy,现在是时候重新启用它了。

预期读者在继续之前解决任何测试错误。确保npm testnpm run e2e执行时没有错误。

公共测试模块

现在我们有很多模块要处理,为每个 spec 文件单独配置导入和提供者变得很繁琐。为此,我建议创建一个公共测试模块来包含通用的配置,你可以在各个地方重用它。

首先创建一个新的.ts文件

  1. 创建common/common.testing.ts

  2. 用常见的测试提供者、模拟和模块填充它。

    我已经提供了ObservableMediaMatIconRegistryDomSanitizer的模拟实现,以及commonTestingProviderscommonTestingModules的数组:

    **src/app/common/common.testing.ts**
    import { 
      HttpClientTestingModule 
    } from '@angular/common/http/ testing'
    import { SecurityContext } from '@angular/core'
    import { MediaChange } from '@angular/flex-layout'
    import { ReactiveFormsModule } from '@angular/forms'
    import { 
      SafeResourceUrl, 
      SafeValue 
    } from '@angular/platform-browser' 
    import { 
      NoopAnimationsModule 
    } from '@angular/platform-browser/animations' 
    import { RouterTestingModule } from '@angular/router/testing'
    import { Observable, Subscription, of } from 'rxjs'
    import { MaterialModule } from '../material.module'
    const FAKE_SVGS = {
      lemon: '<svg><path id="lemon" name="lemon"></path></svg>',
    }
    export class MediaObserverFake {
      isActive(query: string): boolean {
        return false
      }
      asObservable(): Observable<MediaChange> {
        return of({} as MediaChange)
      }
      subscribe(
        next?: (value: MediaChange) => void,
        error?: (error: any) => void,
        complete?: () => void
      ): Subscription {
        return new Subscription()
      }
    }
    export class MatIconRegistryFake {
      // tslint:disable-next-line: variable-name
      _document = document
      addSvgIcon(iconName: string, url: SafeResourceUrl): this {
        // this.addSvgIcon('lemon', 'lemon.svg')
        return this
      }
      getNamedSvgIcon(name: string, namespace: string = ''): Observable<SVGElement> {
        return of(this._svgElementFromString(FAKE_SVGS.lemon))
      }
      private _svgElementFromString(str: string): SVGElement {
        const div = (this._document || document)
          . createElement('DIV') 
        div.innerHTML = str
        const svg = div.querySelector('svg') as SVGElement
        if (!svg) {
          throw Error('<svg> tag not found')
        }
        return svg
      }
    }
    export class DomSanitizerFake {
      bypassSecurityTrustResourceUrl(url: string): SafeResourceUrl {
        return {} as SafeResourceUrl
      }
      sanitize(
        context: SecurityContext, 
        value: SafeValue | string | null): 
          string | null 
      { 
        return value?.toString() || null
      }
    }
    export const commonTestingProviders: any[] = [
      // Intentionally Left Blank!!!
    ]
    export const commonTestingModules: any[] = [
      ReactiveFormsModule,
      MaterialModule,
      NoopAnimationsModule,
      HttpClientTestingModule,
      RouterTestingModule,
    ] 
    

现在让我们看看这个共享配置文件的一个示例用法:

**src/app/app.component.spec.ts**
import { MediaObserver } from '@angular/flex-layout'
import { MatIconRegistry } from '@angular/material/icon'
import { DomSanitizer } from '@angular/platform-browser'
...
import {
  DomSanitizerFake,
  MatIconRegistryFake,
  MediaObserverFake,
  commonTestingModules,
} from './common/common.testing'
...
  TestBed.configureTestingModule({
    imports: commonTestingModules,
    providers: commonTestingProviders.concat([
      { provide: MediaObserver, useClass: MediaObserverFake },
      { provide: MatIconRegistry, useClass: MatIconRegistryFake },
      { provide: DomSanitizer, useClass: DomSanitizerFake },
    ]),
    declarations: [AppComponent],
... 

大多数其他模块只需要导入commonTestingModules

停!你确保了所有单元测试都通过了吗?为了确保你的测试始终通过,在 CircleCI 中实现一个 CI 管道,如第四章中所示,自动化测试、CI 和发布到生产

当你的测试运行起来后,LemonMart 的行走骨架就完成了。现在,让我们展望未来,开始思考我们可能会处理哪些数据实体。

围绕主要数据实体进行设计

路由优先架构的第四步是实现无状态、数据驱动的设计。为了实现这一点,围绕主要数据组件组织你的 API 非常有帮助。这大致符合你在 Angular 应用程序的各种组件中消耗数据的方式。我们将从定义我们的主要数据组件开始,创建一个粗略的数据实体关系图ERD)。在第十章RESTful API 和全栈实现中,我们将使用 Swagger.io 和 Express.js 设计和实现用户数据实体的 API。

定义实体

让我们先尝试确定你希望存储哪些类型的实体以及这些实体之间可能如何相互关联。

这里是 LemonMart 的一个示例设计,使用draw.io创建的:

图 7.28:LemonMart 的 ERD

在这个时刻,无论你的实体存储在 SQL 还是 NoSQL 数据库中,这并不重要。我的建议是坚持你所知道的,但如果你是从头开始的,那么像 MongoDB 这样的 NoSQL 数据库将提供最大的灵活性,因为你的实现和需求会不断演变。

一般而言,你将为每个实体需要 CRUD API。考虑到这些数据元素,我们也可以想象围绕这些 CRUD API 的一些用户界面。让我们接下来做这件事。

高级用户体验设计

原型对于确定整个应用程序中需要哪些组件和用户控件非常重要。任何将在组件间使用的用户控件或组件都需要在根级别定义,其他则在其自己的模块中定义。

在本章前面,我们确定了子模块并为他们设计了着陆页以完成行走骨架。现在我们已经定义了主要数据组件,我们可以为应用程序的其余部分完成原型设计。在设计高级屏幕时,请记住以下几点:

  • 用户能否以尽可能少的导航完成他们角色所需的基本任务?

  • 用户能否通过屏幕上的可见元素轻松访问应用程序的所有信息和功能?

  • 用户能否轻松搜索他们所需的数据?

  • 一旦用户找到了感兴趣的记录,他们能否轻松地深入到详细记录或查看相关记录?

  • 那个弹出警告真的有必要吗?你知道用户不会阅读它,对吧?

请记住,没有一种设计用户体验的正确方法,这就是为什么在设计屏幕时,你应该始终考虑模块化和可重用性。

创建工件维基

如本章前面所述,记录你创建的每个工件是很重要的。维基提供了一个创建可协作更新或编辑的活文档的方法。虽然 Slack、Teams、电子邮件和白板提供了良好的协作机会,但它们的短暂性质还有很多需要改进的地方。

因此,当你生成各种设计工件,如原型或设计决策时,请确保将它们发布在所有团队成员都能访问的维基上:

  1. 在 GitHub 上,切换到Wiki标签页

  2. 你可以查看我的示例 Wiki,github.com/duluca/lemon-mart/wiki,如图所示:

    图 7.29:GitHub.com LemonMart wiki

  3. 在创建 Wiki 页面时,确保你在任何其他可用的文档之间进行交叉链接,例如Readme

  4. 注意,GitHub 在Pages下显示 Wiki 的子页面

  5. 然而,一个额外的概览也很有帮助,例如设计工件部分,因为有些人可能会错过右侧的导航元素

  6. 随着你完成原型,将它们发布在 Wiki 上

你可以在这里看到 Wiki 的概览视图:

图 7.30:LemonMart 原型的概览视图

现在你的工件集中在一个地方,所有团队成员都可以访问。他们可以添加、编辑、更新或整理内容。这样,你的 Wiki 就变成了团队所需信息的有用、活生生的文档,而不是你感觉被迫创建的文档。如果你曾经发现自己处于那种情况,请举手!

接下来,将你的原型整合到你的应用中,这样你可以收集利益相关者的早期反馈并测试你应用流程。

在你的应用中利用原型

将原型放在可步行骨架应用中,以便测试人员可以更好地设想尚未开发的功能。在这里可以看到这个想法的一个示例:

图 7.31:在 UI 中使用原型验证应用流程

这在设计和实施你的身份验证和授权流程时也会很有帮助。原型完成之后,我们现在可以继续在第八章设计身份验证和授权中实施 LemonMart 的身份验证和授权流程。

摘要

在本章中,你掌握了如何有效地使用 Angular CLI 创建主要的 Angular 组件和脚手架。你熟悉了 80-20 规则。你创建了你的应用品牌,利用自定义和内置的 Material 图标。你学习了如何使用 Augury 调试复杂的路由配置。最后,你开始构建以路由为首要考虑的应用,早期定义用户角色,考虑到懒加载进行设计,并在早期确定一个可步行骨架的导航体验。我们讨论了围绕主要数据实体进行设计。我们还涵盖了完成并记录我们整个应用的高级 UX 设计的重要性,这样我们就可以正确地设计一个出色的条件导航体验。

回顾一下,为了实现以路由为首要考虑的实施,你需要做以下这些:

  1. 制定路线图和范围

  2. 考虑到懒加载进行设计

  3. 实现一个可步行骨架的导航体验

  4. 实现无状态、数据驱动的设计

  5. 强制实现解耦的组件架构

  6. 区分用户控件和组件

  7. 使用 TypeScript 和 ES6 最大化代码重用

在本章中,你执行了步骤 1-3;在接下来的四个章节中,你将执行步骤 4-7。在第八章设计身份验证和授权中,我们将深入探讨面向对象设计、继承和抽象,以及深入考虑安全性和设计条件导航体验。在第十章RESTful API 和全栈实现中,你将看到使用最小 MEAN 栈的具体全栈实现。第十一章食谱 – 可重用性、路由和缓存,以及第十二章食谱 – 主/详细信息、数据表和 NgRx,我们将通过坚持解耦组件架构、明智地选择创建用户控件和组件,以及使用各种 TypeScript、RxJS 和 Angular 编码技术最大化代码重用来整合一切。

进一步阅读

问题

尽可能好地回答以下问题,以确保你已理解本章的关键概念,而无需使用 Google。你需要帮助回答这些问题吗?请参阅附录 D自我评估答案,在线位于static.packt-cdn.com/downloads/9781838648800_Appendix_D_Self-Assessment_Answers.pdf或访问expertlysimple.io/angular-self-assessment.

  1. 什么是帕累托原则?

  2. 路由优先架构的主要目标是什么?

  3. 根模块和功能模块之间有什么区别?

  4. 懒加载有哪些好处?

  5. 为什么需要创建应用程序的行走骨架?

第八章:设计身份验证和授权

设计一个高质量的身份验证和授权系统,同时不令最终用户感到沮丧是一个难以解决的问题。身份验证是验证用户身份的行为,授权指定用户必须拥有的权限才能访问资源。这两个过程,简称 auth,必须无缝协同工作,以满足具有不同角色、需求和职能的用户的需求。

在今天的网络中,用户对通过浏览器遇到的任何身份验证系统都有很高的基本期望水平,因此这是你应用中一个非常重要的部分,必须第一次就绝对正确。用户应该始终了解他们在你的应用中可以做什么和不能做什么。如果有错误、失败或错误,用户应该清楚地了解它们发生的原因。随着你的应用增长,很容易错过所有可能触发错误条件的方式。你的实现应该易于扩展或维护,否则你应用的基本骨架将需要大量的维护。在本章中,我们将探讨创建出色的身份验证用户体验的各种挑战,并实现一个坚实的基础体验。

我们将继续采用路由优先的方法来设计单页应用(SPA),通过实现 LemonMart 的身份验证体验。在第七章创建路由优先的业务应用中,我们定义了用户角色,完成了所有主要路由的构建,并完成了 LemonMart 的初步导航体验。这意味着我们已经为实施基于角色的条件导航体验做好了充分准备,这种体验能够捕捉无缝身份验证体验的细微差别。

在本章中,我们将围绕我们在上一章中定义的用户实体实现一个基于令牌的身份验证方案。为了实现一个健壮且易于维护的实现,我们将深入探讨面向对象编程(OOP),包括抽象、继承和工厂,同时实现缓存服务、UI 服务以及两种不同的身份验证方案:用于教育目的的内存中模拟身份验证服务和可用于实际应用的 Google Firebase 身份验证服务。

在本章中,你将学习以下主题:

  • 设计身份验证工作流程

  • TypeScript 操作符用于安全数据处理

  • 利用面向对象编程(OOP)概念的可重用服务

  • 动态 UI 组件和导航

  • 使用守卫进行基于角色的路由

  • Firebase 身份验证配方

  • 使用工厂提供服务

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

对于第八章

  1. 克隆仓库 github.com/duluca/lemon-mart

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

  3. 本章的代码示例位于子文件夹

    projects/ch8 
    
  4. 要运行本章的 Angular 应用程序,请执行

    npx ng serve ch8 
    
  5. 要运行本章的 Angular 单元测试,请执行

    npx ng test ch8 --watch=false 
    
  6. 要运行本章的 Angular 端到端测试,请执行

    npx ng e2e ch8 
    
  7. 要构建本章的生产就绪 Angular 应用程序,请执行

    npx ng build ch8 --prod 
    

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

请注意,书中或 GitHub 上的源代码可能并不总是与 Angular CLI 生成的代码匹配。由于生态系统不断演变,书中代码与 GitHub 上代码之间的实现也可能存在细微差异。随着时间的推移,示例代码发生变化是自然的。此外,在 GitHub 上,您可能会找到更正、支持库新版本的修复或多种技术的并排实现,供您观察。您只需实现书中推荐的理想解决方案即可。如果您发现错误或有问题,请创建一个 issue 或提交一个 pull request 到 GitHub,以供所有读者受益。

让我们从了解基于令牌的认证工作流程如何运作开始。

设计认证工作流程

一个设计良好的认证工作流程是无状态的,因此没有会话过期的概念。用户可以自由地从他们想要的任何设备和标签页与您的无状态 REST API 交互,同时或随着时间的推移。JSON Web TokenJWT)实现了基于声明的分布式认证,可以数字签名或使用消息认证码MAC)进行保护/加密。这意味着一旦用户的身份得到认证(即在登录表单上的密码挑战),他们就会收到一个编码的声明票据或令牌,然后可以使用它来向系统发出未来的请求,而无需重新认证用户身份。

服务器可以独立验证此声明的有效性并处理请求,而无需了解之前是否与该用户交互的任何先验知识。因此,我们不需要存储关于用户的会话信息,这使得我们的解决方案无状态且易于扩展。每个令牌在预定义的期限后都会过期,由于它们的分布式特性,它们不能远程或单独撤销;然而,我们可以通过插入自定义账户和用户角色状态检查来增强实时安全性,以确保认证用户有权访问服务器端资源。

JWT 实现了互联网工程任务组IETF)行业标准 RFC 7519,可在tools.ietf.org/html/rfc7519找到。

一个好的授权工作流程可以根据用户的角色进行条件导航,这样用户就可以自动被带到最优的着陆页面;不会显示不适合他们角色的路径或元素,如果他们不小心尝试访问受限路径,将阻止他们这样做。你必须记住,任何客户端基于角色的导航仅仅是一种便利,并不用于安全。这意味着对服务器的每个调用都应该包含必要的头信息,包括安全令牌,以便服务器可以重新验证用户并独立验证其角色。只有这样,他们才能被允许检索受保护的数据。客户端认证不可信,这就是为什么密码重置屏幕必须使用服务器端渲染技术来构建,以便用户和服务器都可以验证预期的用户正在与系统交互。

JWT 生命周期

JWTs 通过加密令牌机制补充无状态 REST API 架构,允许方便、分布式和高性能地认证和授权客户端发送的请求。基于令牌的认证方案有三个主要组成部分:

  • 客户端: 捕获登录信息并隐藏不允许的操作以提供良好的用户体验

  • 服务器端: 验证每个请求都经过身份验证并且具有适当的授权

  • 认证服务: 生成和验证加密令牌,并独立验证用户请求的认证状态

一个安全的系统假设客户端(应用程序和浏览器)、系统(服务器和服务)和数据库之间发送/接收的数据使用传输层安全性TLS)进行加密,这本质上是一个安全套接字层SSL)的新版本。这意味着你的 REST API 必须使用正确配置的 SSL 证书托管,通过 HTTPS 提供所有 API 调用,以确保用户凭据在客户端和服务器之间永远不会暴露。同样,任何数据库或第三方服务调用都应该通过 TLS 进行。这确保了传输中数据的安全性。

在静止状态(数据坐在数据库中时),应该使用安全的单向哈希算法和良好的加盐实践来存储密码。

所有的哈希和加盐的讨论让你想起了早餐吗?不幸的是,它们是密码学相关的术语。如果你有兴趣了解更多,请查看这篇文章:crackstation.net/hashing-security.htm

对于敏感的用户信息,如个人身份信息PII),应使用安全的双向加密算法在静止状态下进行加密,与密码不同。密码是经过散列的,因此我们验证用户提供的密码是否与系统所知的密码相同。对于 PII,我们必须能够解密数据,以便我们可以将其显示给用户。但由于数据在静止状态下加密,如果数据库被破坏,那么被黑客窃取的数据就毫无价值。

采取分层的安全方法至关重要,因为攻击者需要同时破坏你安全系统的所有层,这是一个不太可能完成的任务,以对你的业务造成实质性伤害。

有趣的事实:当你听到来自大型公司的重大数据泄露事件时,大多数情况下,根本原因是对传输中或静止状态下的安全措施实施不当。有时这是因为持续加密/解密数据计算成本过高,因此工程师依赖于防火墙的保护。在这种情况下,一旦外围被突破,正如他们所说,狐狸就进入了鸡舍。

考虑以下序列图,它突出了基于 JWT 的认证生命周期:

图 8.1:基于 JWT 的认证生命周期

初始时,用户通过提供用户名和密码进行登录。一旦验证通过,用户的认证状态和角色将被加密在一个带有过期日期和时间的 JWT 中,并将其发送回浏览器。

我们的应用程序(Angular 或其他)可以安全地将此令牌缓存到本地或会话存储中,这样用户就不必在每次请求时都强制登录。这样,我们就不会采取像在 cookie 中存储用户凭据这样的不安全做法,以提供良好的用户体验。

当你在本章后面实现自己的认证服务时,你会对 JWT 生命周期有更深入的理解。在接下来的几节中,我们将围绕用户数据实体设计一个功能齐全的认证工作流程,如下所示:

图 8.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 为了匹配 JavaScript 语义,对 nullundefined 处理不同。例如,联合类型 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 调用等。

在我们的代码中,我们主要关心变量的真值。这意味着变量已被定义,不是null,如果它是一个基本类型,它有一个非默认值。给定一个string,我们可以通过简单的if语句检查string是否为真值:

**example**
const foo: string = undefined
if(foo) {
  console.log('truthy')
} else {
  console.log('falsy')
} 

如果foonullundefined或空字符串,变量将被评估为falsy。在特定情况下,你可能想使用条件运算符或三元运算符而不是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') 

在这种情况下,条件运算符或三元运算符使代码更加紧凑,并且一眼就能理解。另一个常见场景是当变量是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被定义且不是null

**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 的安全导航运算符,这在第三章创建一个基本的 Angular 应用中已经介绍过。可选链确保在尝试访问子属性或调用函数之前,变量或属性已被定义且不是null。因此,即使foobarnullundefinedfoo?.bar?.callMe()语句也会执行而不会抛出错误。

考虑用户实体,它有一个name对象,包含firstmiddlelast属性。让我们看看使用空值合并运算符为中间名提供一个空字符串默认值需要什么:

**example**
const user = {
  name: {
    first: 'Doguhan',
    middle: null,
    last: 'Uluca'
  } 
}
console.log((user && user.name && user.name.middle) ?? '') 

如你所见,在访问子属性之前,我们需要检查父对象是否为真值。如果middlenull,则输出一个空字符串。可选链使这项任务变得简单:

**example**
console.log(user?.name?.middle ?? '') 

使用可选链和空值合并运算符一起,我们可以消除重复,并交付出健壮的代码,能够有效地处理 JavaScript 动态运行时的现实情况。

因此,在设计代码时,你必须决定是否在你的逻辑中引入空值的概念,或者使用默认值,如空字符串。在下一节中,当我们实现用户实体时,你会看到这些选择是如何体现的。到目前为止,我们只使用了接口来定义我们数据的结构。接下来,让我们构建用户实体,利用面向对象编程(OOP)的概念,如类、枚举和抽象来实现它,以及一个认证服务。

利用 OOP 概念的可重用服务

如前所述,我们只使用接口来表示数据。我们仍然希望在传递数据到各种组件和服务时继续使用接口。接口非常适合描述实现具有哪些属性或函数,但它们对这些属性或函数的行为没有任何暗示。

在 ES2015(ES6)中,JavaScript 获得了对类的本地支持,这是面向对象范式的一个关键概念。类是行为的实际实现。与文件中只包含函数集合相比,类可以正确地封装行为。然后可以使用new关键字将类实例化为对象。

TypeScript 采用了 ES2015(及以后)的类实现,并引入了必要的概念,如抽象类、私有、受保护和公共属性,以及接口,使得实现 OOP 模式成为可能。

面向对象编程(OOP)是一种命令式编程风格,与 RxJS 启用的响应式编程风格相比。类是 OOP 的基石,而可观察者(observables)在 RxJS 中为响应式编程提供了同样的作用。

我鼓励你熟悉 OOP 术语。请参阅进一步阅读部分,了解一些有用的资源。你应该熟悉:

  1. 类与对象

  2. 组合(接口)

  3. 封装(私有、受保护和公共属性,以及属性获取器和设置器)

  4. 多态(继承、抽象类和方法重写)

如你所知,Angular 使用面向对象编程(OOP)模式来实现组件和服务。例如,接口用于实现生命周期钩子,如OnInit。让我们看看这些模式如何在 JavaScript 类中实现。

JavaScript 类

在本节中,我将演示如何在你自己的代码设计中使用类来定义和封装模型的行为,例如User类。在本章的后面部分,你将看到使用抽象基类的类继承示例,这允许我们标准化我们的实现,并以干净、易于维护的方式重用基本功能。

我必须指出,OOP 具有非常有用的模式,可以提高你代码的质量;然而,如果你过度使用它,你将开始失去 JavaScript 动态、灵活和功能性的好处。

有时候,你只需要一个文件中的一系列函数,你将在整本书中看到这些例子。

展示类价值的一个好方法是将默认User对象的创建标准化。我们需要这样做,因为BehaviorSubject对象需要用默认对象初始化。最好在一个地方完成这个操作,而不是在多个地方复制粘贴相同的实现。让User对象拥有这个功能而不是由 Angular 服务创建默认User对象是非常有意义的。所以,让我们实现一个User类来实现这个目标。

让我们从定义我们的接口和枚举开始:

  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是规范做法,以便于识别。不用担心,使用IPhone接口在 Android 手机上没有兼容性问题!

  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. 最后,定义User类,它实现了IUser接口:

    **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()
        }
        if (typeof user.dateOfBirth === 'string') {
          user.dateOfBirth = new Date(user.dateOfBirth)
        }
        return new User(
          user._id,
          user.email,
          user.name,
          user.picture,
          user.role as Role,
          user.dateOfBirth,
          user.userStatus,
          user.level,
          user.address,
          user.phones
        )
      }
    } 
    

    注意,通过在构造函数中将所有属性定义为public属性并赋予默认值,我们一举两得;否则,我们需要分别定义属性并初始化它们。这样,我们实现了简洁的实现方式。

    使用一个静态的Build函数,我们可以快速用从服务器接收到的数据填充对象。我们还可以实现toJSON()函数来定制对象在发送到服务器之前的序列化行为。但在那之前,让我们添加一个计算属性。

    我们可以在模板或吐司消息中使用计算属性方便地显示由多个部分组成的值。一个很好的例子是从name对象中提取完整名称作为User类中的一个属性。

    一个用于组装完整名称的计算属性封装了合并姓氏、中间名和姓氏的逻辑,这样你就不必在多个地方重写这个逻辑,遵循 DRY 原则!

  6. User类中实现一个fullName属性获取器:

    **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属性定义为IUserreadonly和可选属性:

    **src/app/user/user/user.ts**
    export interface IUser {
      ...
      readonly fullName?: string
    } 
    

    你现在可以通过IUser接口使用fullName属性。

  8. 实现一个toJSON函数:

    **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实体,接下来让我们实现认证服务。

抽象和继承

我们的目标是设计一个灵活的认证服务,可以实现多个认证提供者。在本章中,我们将实现内存提供者和 Google Firebase 提供者。在第十章RESTful API 和全栈实现中,我们将实现一个自定义提供者以与我们的后端交互。

通过声明一个抽象基类,我们可以描述我们应用程序的常见登录和注销行为,因此当我们实现另一个认证提供者时,我们不需要重新设计我们的应用程序。

此外,我们可以声明抽象函数,我们的基类的实现者必须实现这些函数,强制我们的设计。任何实现基类的类都将获得基类中实现代码的好处,因此我们不需要在两个不同的地方重复相同的逻辑。

以下类图反映了我们的抽象AuthService的架构和继承层次结构:

图片

图 8.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,如图中底部所示。

注意,所有三个认证服务都实现了所有抽象函数。此外,FirebaseAuthService覆盖了基类的logout函数以实现其自己的行为。所有三个类从同一个抽象类继承,并公开相同的公共接口。所有三个将执行相同的认证工作流程,针对不同的认证服务器。

内存中的认证服务不与服务器通信。此服务仅用于演示目的。它实现了假的 JWT 编码,因此我们可以演示 JWT 生命周期的工作方式。

让我们先创建认证服务。

创建认证服务

我们将首先创建抽象认证服务和内存服务:

  1. 添加一个认证服务:

    $ npx ng g s auth --flat false --lintFix 
    $ npx ng g s auth/inMemoryAuth --lintFix --skipTests 
    
  2. in-memory-auth.service.ts重命名为auth.inmemory.service.ts,以便在文件资源管理器中将不同的认证提供者视觉上分组在一起。

  3. auth.service.tsauth.inmemory.service.ts@Injectable()装饰器中移除配置对象{ providedIn: 'root' }

  4. 确保在app.module.ts中提供authService,但实际上使用的是InMemoryAuthService而不是抽象类:

    **src/app/app.module.ts**
    **import { AuthService } from './auth/auth.service'**
    **import { InMemoryAuthService } from './auth/auth.inmemory.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**
    @Injectable()
    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'
    ...
    @Injectable()
    export abstract class AuthService implements IAuthService {
      **readonly** authStatus$ = 
        **new BehaviorSubject<IAuthStatus>(defaultAuthStatus)** 
      **readonly** currentUser$ = 
        **new BehaviorSubject<IUser>(new User())**
      ...
    } 
    

注意,我们移除了属性的类型定义。相反,我们让 TypeScript 从初始化中推断类型。

你必须始终将你的数据锚点声明为 readonly,这样你就不会意外地通过将数据锚点重新初始化为新的 BehaviorSubject 来覆盖数据流。这样做会导致任何先前的订阅者成为孤儿,导致内存泄漏,并产生许多意想不到的后果。

所有实现 IAuthService 的类都需要能够登录用户,将我们从服务器获取的令牌进行转换,以便我们可以读取和存储它,支持访问当前用户和认证状态,并提供一种注销用户的方式。我们已经成功地将公共方法的功能放入其中,并为我们的数据锚点实现了默认值,以创建其余应用程序使用的钩子。但到目前为止,我们只定义了我们的服务可以做什么,而没有定义它是如何做到的。

总是如此,魔鬼在于细节,难点在于“如何”。抽象函数可以帮助我们在应用程序中的服务内完成工作流程的实现,同时将必须实现外部 API 的部分服务留空。

抽象函数

实现抽象类的认证服务应该能够支持任何类型的认证提供者和任何类型的令牌转换,同时能够修改如用户检索逻辑等行为。我们必须能够实现登录、注销、令牌和认证状态管理,而不需要实现对特定服务的调用。

通过定义抽象函数,我们可以声明一系列必须实现给定输入和输出的方法——一个没有实现的签名。然后我们可以使用这些抽象函数来编排我们的认证工作流程的实现。

我们在这里的设计目标是受 Open/Closed 原则驱动的。AuthService 将通过其扩展能力来工作与任何基于令牌的认证提供者,但对其修改是封闭的。一旦我们完成了 AuthService 的实现,我们就无需修改其代码来添加额外的认证提供者。

现在我们需要定义我们的认证提供者必须实现的抽象函数,如本章前面 图 8.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>**
         ...
    } 
    

    利用这些已编写的函数,我们现在可以实现一个 login 方法,执行登录并检索当前登录用户,确保更新 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方法通过调用带有emailpassword信息的authProvider来封装正确的操作顺序,然后解码接收到的 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)
      } 
    

我们通过将defaultAuthStatus作为authStatus$流中的下一个值来注销。注意setTimeout的使用,它允许我们在应用程序的核心元素同时更改状态时避免时序问题。

考虑到login方法如何遵循开放/封闭原则。该方法通过抽象函数authProvidertransformJwtTokengetCurrentUser进行扩展。通过在派生类中实现这些函数,我们保持了外部提供不同认证提供者的能力,而无需修改login方法。因此,方法的实现保持对修改的封闭,从而遵循开放/封闭原则。

创建抽象类的真正价值在于能够以可扩展的方式封装常见功能。

目前您可以忽略getToken函数,因为我们还没有缓存 JWT。在没有缓存的情况下,用户每次刷新页面时都必须登录。让我们接下来实现缓存。

使用 localStorage 的抽象缓存服务

我们必须能够缓存已登录用户的认证状态。如前所述,否则,每次页面刷新时,用户都必须通过登录流程。我们需要更新AuthService以持久化认证状态。

存储数据主要有三种方式:

  • cookie

  • localStorage

  • sessionStorage

应当避免使用 Cookies 来存储安全数据,因为它们可以被恶意行为者嗅探或窃取。此外,Cookies 只能存储 4 KB 的数据,并且可以被设置为过期。

localStoragesessionStorage彼此相似。它们是受保护和隔离的浏览器端存储,允许为您的应用程序存储更多的数据。与 cookies 不同,您不能为存储在任一存储中的值设置过期日期和时间。存储在任一存储中的值在页面重新加载和恢复时仍然存在,这使得它们比 cookies 更适合缓存信息。

localStoragesessionStorage之间的主要区别在于,当浏览器窗口关闭时,值会被移除。在大多数情况下,用户登录的缓存时间从几分钟到一个月或更长时间,具体取决于您的业务,因此依赖于用户是否关闭浏览器窗口并不是很有用。通过这个过程排除,我更喜欢localStorage,因为它提供了隔离性和长期存储能力。

JWT 可以被加密并包含一个过期时间戳。从理论上讲,这抵消了 cookies 和localStorage的弱点。如果正确实现,任一选项都应安全用于 JWT,但localStorage仍然更受欢迎。

让我们从实现一个可以抽象我们缓存方法的缓存服务开始。然后我们可以从这个服务派生出缓存我们的认证信息:

  1. 首先创建一个抽象的cacheService,它封装了缓存方法:

    **src/app/auth/cache.service.ts**
    export abstract class CacheService {
      protected getItem<T>(key: string): T | null {
        const data = localStorage.getItem(key)
        if (data != null) {
          return JSON.parse(data)
        }
        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 
      **extends CacheService** implements IAuthService { 
      constructor() {
        **super()**
      }
      ... 
    } 
    

注意,我们必须使用super方法从派生类的构造函数中调用基类的构造函数。

让我们通过一个例子来了解如何通过缓存authStatus对象来使用基类的功能:

**example**
authStatus$ = new BehaviorSubject<IAuthStatus>(
  this.getItem('authStatus') ?? defaultAuthStatus
)
constructor() {
  super()
  this.authStatus$.pipe(
    tap(authStatus => this.setItem('authStatus', authStatus))
  )
} 

示例中展示的技术利用 RxJS 可观察流在authStatus$的值变化时更新缓存。您可以使用这种模式持久化任何类型的数据,而无需在业务逻辑中散布缓存代码。在这种情况下,我们不需要更新login函数来调用setItem,因为它已经调用了this.authStatus.next,我们只需接入数据流即可。这有助于保持无状态并避免副作用,通过解耦函数来实现。

注意,我们还在BehaviorSubject中使用getItem函数进行初始化。使用空值合并运算符,我们只在缓存的数据不是undefinednull时使用它。否则,我们提供默认值。

您可以在setItemgetItem函数中实现自己的自定义缓存过期方案,或者利用第三方创建的服务。

然而,为了额外的安全层,我们不会缓存authStatus对象。相反,我们只缓存编码后的 JWT,它只包含足够的信息,以便我们可以认证发送到服务器的请求。理解基于令牌的认证工作方式对于避免泄露有损的秘密非常重要。回顾本章早些时候的 JWT 生命周期,以提高你的理解。

接下来,让我们缓存令牌。

缓存 JWT

让我们更新认证服务,使其能够缓存令牌。

  1. 更新AuthService以能够设置、获取和清除令牌,如下所示:

    **src/app/auth/auth.service.ts**
    ...
      protected setToken(jwt: string) {
        this.setItem('jwt', jwt)
      }
      getToken(): string {
        return this.getItem('jwt') ?? ''
      }
      protected clearToken() {
        this.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.inmemory.service.ts**
    import { AuthService } from './auth.service'
    @Injectable()
    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.inmemory.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。

    不要将 Angular 应用程序与fake-jwt-sign依赖项一起发货,因为它意味着是服务器端代码。

    相比之下,一个真实的认证提供者会包括一个发送到服务器的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.inmemory.service.ts**
    protected transformJwtToken(token: IAuthStatus): 
      IAuthStatus {
        return token
      } 
    
  5. 最后,实现getCurrentUser,它应该返回一个默认用户:

    **src/app/auth/auth.inmemory.service.ts**
    protected getCurrentUser(): Observable<User> {
      return of(this.defaultUser)
    } 
    

    接下来,将defaultUser作为私有属性提供给类;以下是我创建的一个示例。

  6. InMemoryAuthService类添加一个私有的defaultUser属性:

    **src/app/auth/auth.inmemory.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 应用程序,并确保没有错误。

让我们通过实现一个简单的登录和注销功能来测试我们的认证服务,该功能可以通过用户界面访问。

简单登录

在我们实现一个功能齐全的login组件之前,让我们将预制的登录行为连接到HomeComponent中的登录为管理员**按钮。在深入了解交付丰富 UI 组件的细节之前,我们可以测试我们的认证服务的功能。

我们的目标是模拟登录为管理员。为了实现这一点,我们需要硬编码一个电子邮件地址和密码来登录,并在成功登录后保持导航到/manager路由的功能。

注意,在 GitHub 上,本节代码示例位于projects/ch8文件夹结构下的home.component.simple.ts文件中。该备用文件仅用于参考目的,因为本章后面的代码将发生重大变化。忽略文件名差异,因为它不会影响本节代码的编写。

让我们实现一个简单的登录机制:

  1. HomeComponent中实现一个login函数,该函数使用AuthService

    **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. 现在测试一下新的登录功能。验证 JWT 是否已创建并存储在localStorage中,如Chrome DevTools|应用程序选项卡中所示img/B14094_08_04.png

    图 8.4:DevTools 显示应用程序本地存储

您可以在应用程序选项卡下查看本地存储。确保您的应用程序的 URL 被突出显示。在第 3 步中,您可以看到我们有一个名为jwt的有效令牌。

注意步骤 4 和 5 中突出的两个警告,它们建议我们不要在生产代码中使用InMemoryAuthServicefake-jwt-sign包。

使用断点进行调试,并逐步执行代码,以更具体地了解HomeComponentInMemoryAuthServiceAuthService如何协同工作以登录用户。

当您刷新页面时,请注意您仍然处于登录状态,因为我们已经在本地存储中缓存了令牌。

由于我们正在缓存登录状态,因此我们还需要实现一个注销体验来完成认证工作流程。

注销

应用程序工具栏上的注销按钮已经连接到我们之前创建的注销组件。让我们更新这个组件,以便在导航到时能够注销用户:

  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传递给注销函数来显式清除 JWT。在调用注销后,我们将用户导航回主页。

  2. 测试一下注销按钮。

  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**
    ...
      @Injectable()
    export abstract class AuthService extends CacheService 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() {
      super()
      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.setItem和首次启动时的缓存配置文件数据,可以缓存用户配置文件数据。这将提供更快的用户体验,并覆盖用户可能离线的情况。在应用程序启动后,您可以异步获取新鲜的用户数据,并在新数据到来时更新currentUser$。您需要添加额外的缓存并调整getCurrentUser()逻辑以使此功能正常工作。哦,您还需要大量的测试!创建高质量的认证体验需要大量的测试。

恭喜,我们已经完成了健壮的认证工作流的实现!接下来,我们需要将认证与 Angular 的 HTTP 客户端集成,以便我们可以将令牌附加到每个请求的 HTTP 头部。

HTTP 拦截器

实现一个 HTTP 拦截器,将 JWT 注入到发送给用户的每个请求的头部,并通过要求用户重新登录来优雅地处理认证失败:

  1. auth下创建一个AuthHttpInterceptor

    **src/app/auth/auth-http-interceptor.ts**
    import {
      HttpEvent,
      HttpHandler,
      HttpInterceptor,
      HttpRequest,
    } from '@angular/common/http'
    import { Injectable } from '@angular/core'
    import { Router } from '@angular/router'
    import { Observable, throwError } from 'rxjs'
    import { catchError } from 'rxjs/operators'
    import { AuthService } from './auth.service'
    @Injectable()
    export class AuthHttpInterceptor implements HttpInterceptor {
      constructor(private authService: AuthService, private router: Router) {}
      intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        const jwt = this.authService.getToken()
        const authRequest = req.clone({ setHeaders: { authorization: `Bearer ${jwt}` } })
        return next.handle(authRequest).pipe(
          catchError((err, caught) => {
            if (err.status === 401) {
              this.router.navigate(
                ['/login'], { queryParams: {
                  redirectUrl: this.router.routerState.snapshot.url},}
              )
            }
            return throwError(err)
          })
        )
      }
    } 
    

    注意,AuthService被用来检索令牌,并且在401错误后为login组件设置了redirectUrl

  2. 更新app.module.ts以提供拦截器:

    **src/app/app.module.ts**
      providers: [
        ...
        {
          provide: HTTP_INTERCEPTORS,
          useClass: AuthHttpInterceptor,
          multi: true,
        },
      ], 
    
  3. 确保拦截器正在将令牌添加到请求中。为此,打开Chrome DevTools | 网络标签,登录,然后刷新页面:

    图 8.5:lemon.svg 的请求头

在步骤4中,你现在可以观察拦截器的动作。对lemon.svg文件的请求在请求头中包含 bearer 令牌。

现在我们已经设置了认证机制,让我们利用我们编写的所有支持代码,包括动态 UI 组件和基于角色的 UX 的条件导航系统。

动态 UI 组件和导航

AuthService提供异步的认证状态和用户信息,包括用户的名字和角色。我们可以使用所有这些信息为用户创建一个友好且个性化的体验。在本节中,我们将实现LoginComponent,以便用户可以输入他们的用户名和密码信息并尝试登录。

实现登录组件

login组件利用我们刚刚创建的AuthService并使用响应式表单实现验证错误。

记住,在app.module.ts中,我们使用InMemoryAuthService类提供了AuthService。因此,在运行时,当AuthService被注入到login组件中时,将使用内存服务。

login组件应该设计成独立于任何其他组件渲染,因为在路由事件期间,如果我们发现用户没有正确认证或授权,我们将导航他们到这个组件。我们可以捕获这个原始 URL 作为redirectUrl,这样一旦用户成功登录,我们就可以将他们导航回它。

让我们开始:

  1. 安装SubSink包。

  2. 在应用程序的根目录中创建一个名为login的新组件,并使用内联样式。

  3. 让我们从实现导航到login组件的路由开始:

    **src/app/app-routing.modules.ts**
    ...
      { path: 'login', component: LoginComponent },
      { path: 'login/:redirectUrl', component: LoginComponent },
    ... 
    

    记住,'**'路径必须是最后定义的。

  4. 使用与我们在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;
          }
        `,
      ],
    })
    export class LoginComponent implements OnInit { 
      private subs = new SubSink()
      loginForm: FormGroup
      loginError = ''
      redirectUrl: string
      constructor(
        private formBuilder: FormBuilder,
        private authService: AuthService,
        private router: Router,
        private route: ActivatedRoute
      ) {
        this.subs.sink = route.paramMap.subscribe(
          params => (this.redirectUrl = 
            params.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)))
        this.subs.sink = combineLatest([
          this.authService.authStatus$,
          this.authService.currentUser$,
        ])
          .pipe(
            filter(
              ([authStatus, user]) =>
                authStatus.isAuthenticated && user?._id !== ''
            ),
            tap(([authStatus, user]) => {
              this.router.navigate([this.redirectUrl || '/manager'])
            })
          )
          .subscribe()
      } 
    } 
    

    我们使用SubSink来管理我们的订阅。我们确保在调用ngOnInit时注销。我们以标准方式构建响应式表单。最后,login方法调用this.authService.login以启动登录过程。

    我们使用combineLatest同时监听authStatus$currentUser$数据流。每当每个流中发生更改时,我们的管道都会执行。我们过滤掉不成功的登录尝试。成功的登录尝试的结果,我们利用路由将认证用户导航到其个人资料。如果服务从服务器发送错误,我们将该错误分配给loginError

  5. 这里是一个用于捕获和验证用户emailpassword的登录表单的实现,如果存在任何服务器错误,将显示它们:

    不要忘记在app.modules.ts中导入ReactiveFormsModule

    **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>
          <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">
                <mat-error *ngIf="loginForm.get('email')?.hasError('required')">
                  E-mail is required
                </mat-error>
                <mat-error *ngIf="loginForm.get('email')?.hasError('email')">
                  E-mail is not valid
                </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">
                <mat-hint>Minimum 8 characters</mat-hint>
                <mat-error *ngIf="loginForm.get('password')?.hasError('required')">
                  Password is required
                </mat-error>
                <mat-error *ngIf="loginForm.get('password')?.hasError('minlength')">
                  Password is at least 8 characters long
                </mat-error>
                <mat-error *ngIf="loginForm.get('password')?.hasError('maxlength')">
                  Password cannot be longer than 50 characters
                </mat-error>
              </mat-form-field>
            </div>
            <div fxLayout="row" class="margin-top">
              <div *ngIf="loginError" 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,除非您为更多错误创建更多空间,所以请确保将错误条件按正确顺序放置。

    实现完login组件后,现在可以更新主屏幕以有条件地显示或隐藏我们创建的新组件。

  6. 更新HomeComponent以清理我们之前添加的代码,这样我们就可以在用户登录应用程序的主页时显示LoginComponent

    **src/app/home/home.component.ts**
      ...
      template: `
        <div *ngIf="displayLogin">
          <app-login></app-login>
        </div>
        <div *ngIf="!displayLogin">
          <span class="mat-display-3">You get a lemon, you get a lemon, you get a lemon...</span>
        </div>
      `,
    }) 
    export class HomeComponent {
      displayLogin = true
      constructor() {
      }
    } 
    

您的应用程序应该看起来与这个截图相似:

图 8.6:LemonMart 登录界面

根据用户的认证状态,在实现和显示/隐藏sidenav菜单、个人资料和注销图标方面还有一些工作要做。

条件导航

条件导航对于创建无烦恼的用户体验是必要的。通过选择性地显示用户可以访问的元素并隐藏他们无法访问的元素,我们使用户能够自信地导航应用程序。

让我们从在用户登录应用程序后隐藏login组件开始:

  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. 使用ngIf; else语法和async管道实现一个新的模板,如下所示:

    **src/app/home/home.component.ts**
    ...
      template: `
        <div *ngIf= 
    "(authService.authStatus$ | async)?.isAuthenticated; else doLogin">
          <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>
        <ng-template #doLogin>
          <app-login></app-login>
        </ng-template>  
      `, 
    

    使用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$

     <mat-toolbar ...
            ***ngIf="{**
     **status: authService.authStatus$ | async,**
     **user: authService.currentUser$ | async**
     **} as auth;"**> 
    
  6. 使用*ngIf隐藏所有针对已登录用户的按钮:

    **src/app/app.component.ts**
    <button ***ngIf="auth?.status?.isAuthenticated"** ... > 
    

    现在,当用户注销时,您的工具栏应该看起来非常干净,没有任何按钮,如下所示:

    图 8.7:用户登录前的 LemonMart 工具栏

  7. 如果用户有图片,我们还可以将profile按钮中的通用account_circle图标替换掉:

    **src/app/app.component.ts**
    styles: [
    `
      .image-cropper {
        width: 40px;
        height: 40px;
        position: relative;
        overflow: hidden;
        border-radius: 50%;
        margin-top: -8px;
      }
    `],
    template: `
      ...
      <button
        *ngIf="auth?.status?.isAuthenticated"
        mat-mini-fab
        routerLink="/user/profile"
        matTooltip="Profile"
        aria-label="User Profile"
      >
        <img *ngIf="auth?.user?.picture" class="image-cropper"
          [src]="auth?.user?.picture" />
        <mat-icon *ngIf="!auth?.user?.picture">account_circle</mat-icon>
      </button> 
    

我们现在有一个高度功能化的工具栏,它可以响应应用程序的认证状态,并且能够显示属于已登录用户的信息。

表单的常见验证

在我们继续之前,我们需要重构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 npm 包owasp-password-strength-test来启用密码短语,以及设置更灵活的密码要求。请参阅进一步阅读部分中 OWASP 认证通用指南的链接。

  1. 使用新的验证更新login组件:

    **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 服务中,我们将实现一个showToast和一个showDialog函数,可以触发通知或提示用户做出决定,这样我们就可以在实现业务逻辑的代码中使用它。

让我们开始吧:

  1. common目录下创建一个名为ui的新服务。

  2. 使用MatSnackBar实现showToast函数:

    查看关于MatSnackBar的文档,链接为material.angular.io

    不要忘记更新app.module.tsmaterial.module.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组件。

    查看关于MatDialog的文档,链接为material.angular.io

  3. app.module.ts提供的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>
          <button mat-button mat-dialog-close *ngIf="data.cancelText">
            {{ data.cancelText }}
          </button>
          <button mat-button mat-button-raised color="primary" [mat-dialog-close]="true"
            cdkFocusInitial>
            {{ data.okText }}
          </button>
        </mat-dialog-actions>
      `
    })
    export class SimpleDialogComponent {
      constructor(
        public dialogRef: MatDialogRef<SimpleDialogComponent, boolean>,
        @Inject(MAT_DIALOG_DATA) public data: any
      ) {}
    } 
    

    注意,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>,因此你可以根据用户所做的选择实现后续操作。点击 OK 将返回 true,而 Cancel 将返回 false

    SimpleDialogComponent 中,使用 @Inject,我们可以使用 showDialog 发送的变量来自定义对话框的内容。

  5. app.module.ts 中,将 SimpleDialogComponent 声明为 entry 组件:

    **src/app/app.module.ts**
    @NgModule({
      ...
      bootstrap: [AppComponent],
      **entryComponents: [SimpleDialogComponent],**
    })
    Export class AppModule {} 
    

    注意,在 Ivy 渲染引擎中,entryComponents 应该是不必要的,并且在 Angular 9 中已被弃用。然而,在发布时,仍然需要将此组件声明为 entry 组件。

  6. 更新 LoginComponent 中的 login() 函数,在登录后显示一个 toast 消息:

    **src/app/login/login.component.ts**
    import { UiService } from '../common/ui.service'
    ...
    constructor(... , **private uiService: UiService**)
      ...
      async login(submittedForm: FormGroup) {
        ...
        tap(([authStatus, user]) => {
          **this.uiService.showToast(**
     **`Welcome ${user.fullName}! Role: ${user.role}`**
     **)**
          ...
        })
     ... 
    

    现在,用户登录后会出现一个提示消息,如下所示:

    图 8.8:Material snackbar

    snackBar 将根据浏览器的大小,占据整个屏幕宽度或部分宽度。

  7. 尝试显示一个对话框代替:

    **src/app/login/login.component.ts**
    this.uiService.showDialog(
      `Welcome ${user.fullName}!`, `Role: ${user.role}`
    ) 
    

现在你已经验证了 showToastshowDialog 都能正常工作,你更喜欢哪一个?我的经验法则是,除非用户即将采取不可逆的操作,否则你应该选择 toast 消息而不是对话框,这样就不会打断用户的操作流程。

接下来,让我们实现一个全局的侧导航体验,作为我们已有的基于工具栏导航的替代方案,这样用户可以轻松地在模块之间切换。

侧导航

启用以移动端优先的工作流程,并提供一个简单的导航机制,以便快速跳转到所需的功能。使用身份验证服务,根据用户的当前角色,仅显示他们可以访问的功能链接。我们将按照以下方式实现侧导航模拟:

图 8.9:侧导航模拟图

让我们实现一个独立的组件来作为侧导航的代码,这样更容易维护:

  1. 在应用程序的根目录下创建一个名为 NavigationMenu 的组件,带有内联模板和样式。

    侧导航在技术上不是必需的,直到用户登录后。然而,为了能够从 AppComponent 中触发侧导航菜单,我们需要能够从那里触发它。由于这个组件将是简单的,我们将急切地加载它。为了实现这一点,Angular 确实有一个动态组件加载模式,它具有很高的实现开销,只有在节省了数百千字节的情况下才有意义。

    SideNav 将从工具栏触发,并附带一个 <mat-sidenav-container> 父容器,该容器本身托管 SideNav 以及应用程序的内容。因此,我们需要通过将 <router-outlet> 放置在 <mat-sidenav-content> 中来渲染所有应用程序内容。

  2. AppComponent中定义一些样式,以确保 Web 应用将扩展以填充整个页面,并在桌面和移动场景中保持适当的可滚动性:

    **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 {
          width: 40px;
          height: 40px;
          position: relative;
          overflow: hidden;
          border-radius: 50%;
          margin-top: -8px;
        }
      `,
    ], 
    
  3. AppComponent中注入 Angular Flex Layout 的MediaObserver服务。同时实现OnInitOnDestroy,初始化SubSink,并添加一个名为opened的布尔属性:

    **src/app/app.component.ts**
    import { MediaObserver } from '@angular/flex-layout'
    export class AppComponent implements **OnInit, OnDestroy** {
      **private subs = new SubSink()**
     **opened: boolean**
      constructor(
        ...
     **public media: MediaObserver**
      ) {
      ...
      }
      ngOnDestroy() {
        this.subs.unsubscribe()
      }
      ngOnInit(): void {
        throw new Error('Method not implemented.')
      }
    } 
    

    为了自动确定侧导航的打开/关闭状态,我们需要监控媒体观察器和认证状态。当用户登录时,我们希望显示侧导航,当用户注销时隐藏它。我们可以通过将opened设置为authStatus$.isAuthenticated的值来实现这一点。然而,如果我们只考虑isAuthenticated,并且用户在移动设备上,我们将创建一个不太理想的用户体验。通过监控媒体观察器的mediaValue,我们可以检查屏幕尺寸是否设置为超小,或xs;如果是这样,我们可以保持侧导航关闭。

  4. 更新ngOnInit以实现动态侧导航打开/关闭逻辑:

    **src/app/app.component.ts**
      ngOnInit() {
        this.subs.sink = 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
                }
              }
            })
          )
          .subscribe()
      } 
    

    通过监控媒体和authStatus$流,我们可以考虑未经认证的场景,即使有足够的屏幕空间,侧导航也不应该打开。

  5. 使用响应式的SideNav更新模板,在移动场景中滑过内容,在桌面场景中将内容推到一边:

    **src/app/app.component.ts**
    ...
    // prettier-ignore
    template: `
      **<div class="app-container">**
      <mat-toolbar color="primary" fxLayoutGap="8px"
        **class="app-toolbar"**
        **[class.app-is-mobile]="media.isActive('xs')"**
        *ngIf="{
          status: authService.authStatus$ | async,
          user: authService.currentUser$ | async
        } as auth;"
      >
        <button *ngIf="auth?.status?.isAuthenticated"
          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>**
    `, 
    

    上述模板利用了之前注入的 Angular Flex 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;
          }
        `,
      ],
      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>
      `,
    ... 
    

<mat-nav-list>在功能上等同于<mat-list>,因此你可以使用MatList的文档来布局。在此观察经理库存职员subheaders

图 8.10:显示桌面设备上收据查找的经理仪表板

routerLinkActive="active-link"突出显示所选的收据路由,如前述截图所示。

此外,你可以看到在移动设备上的外观和行为差异如下:

图 8.11:显示移动设备上收据查找的经理仪表板

接下来,让我们实现基于角色的路由。

使用守卫实现基于角色的路由

这是您应用程序最基本和最重要的部分。通过懒加载,我们确保只加载最少的资源以使用户能够登录。

一旦用户登录,他们应该根据用户角色被路由到适当的登录屏幕,这样他们就不会猜测如何使用应用程序。例如,收银员只需要访问销售点(POS)来结账客户,所以他们可以自动被路由到该屏幕。

以下是一个 POS 屏幕的模拟图:

图 8.12:POS 屏幕模拟图

通过更新LoginComponent确保用户在登录后能够被路由到适当的页面。

更新login逻辑,在名为homeRoutePerRole的函数中按角色路由:

**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'**
 **}**
**}** 

同样,收银员和管理员会被路由到他们的登录屏幕以访问他们完成任务所需的功能,如前所述。由于我们已经实现了默认的管理员角色,相应的登录体验将自动启动。硬币的另一面是有意或无意尝试访问用户不应访问的路由。在下一节中,你将了解可以帮助检查认证甚至在表单渲染之前加载所需数据的路由守卫。

路由守卫

路由守卫使逻辑的进一步解耦和重用成为可能,并提供了对组件生命周期的更多控制。

这里是您最可能使用的四个主要守卫:

  1. CanActivateCanActivateChild:用于检查路由的认证访问

  2. CanDeactivate:用于在离开路由之前请求权限

  3. Resolve:允许从路由参数预取数据

  4. CanLoad: 允许在加载功能模块资源之前执行自定义逻辑

参考以下章节以了解如何利用CanActivateCanLoadResolve守卫将在第十一章配方 - 可重用性、路由和缓存中介绍。

认证守卫

认证守卫通过允许或禁止在模块加载之前或在进行任何不适当的数据请求之前意外导航到功能模块或组件,从而实现良好的用户体验。例如,当管理员登录时,他们会被自动路由到/manager/home路径。浏览器将缓存此 URL,因此收银员意外导航到相同的 URL 是完全可能的。Angular 不知道特定的路由是否对用户可访问,如果没有AuthGuard,它将愉快地渲染管理员的首页并触发会导致失败的服务器请求。

无论您的前端实现多么健壮,您实现的每个 REST API 都应该在服务器端得到适当的保护。

让我们更新路由器,以便在没有经过认证的用户激活ProfileComponent之前,ManagerModule不会加载,除非管理员使用AuthGuard登录:

  1. 实现一个 AuthGuard 服务:

    **src/app/auth/auth-guard.service.ts**
    import { Injectable } from '@angular/core'
    import {
      ActivatedRouteSnapshot,
      CanActivate,
      CanActivateChild,
      CanLoad,
      Route,
      Router,
      RouterStateSnapshot,
    } from '@angular/router'
    import { Observable } from 'rxjs'
    import { map, take } from 'rxjs/operators'
    import { UiService } from '../common/ui.service'
    import { Role } from './auth.enum'
    import { AuthService } from './auth.service'
    @Injectable({
      providedIn: 'root',
    })
    export class AuthGuard implements CanActivate, CanActivateChild, CanLoad {
      constructor(
        protected authService: AuthService,
        protected router: Router,
        private uiService: UiService
      ) {}
      canLoad(route: Route):
        boolean | Observable<boolean> | Promise<boolean> {
          return this.checkLogin()
      }
      canActivate(
        route: ActivatedRouteSnapshot,
        state: RouterStateSnapshot
      ): boolean | Observable<boolean> | Promise<boolean> {
        return this.checkLogin(route)
      }
      canActivateChild(
        childRoute: ActivatedRouteSnapshot,
        state: RouterStateSnapshot
      ): boolean | Observable<boolean> | Promise<boolean> {
        return this.checkLogin(childRoute)
      }
      protected checkLogin(route?: ActivatedRouteSnapshot):
        Observable<boolean> {
        return this.authService.authStatus$.pipe(
          map((authStatus) => {
            const roleMatch = this.checkRoleMatch(
              authStatus.userRole, route
            )
            const allowLogin = authStatus.isAuthenticated && roleMatch
            if (!allowLogin) {
              this.showAlert(authStatus.isAuthenticated, roleMatch)
              this.router.navigate(['login'], {
                queryParams: {
                  redirectUrl: this.getResolvedUrl(route),
                },
              })
            }
            return allowLogin
          }),
          take(1) // complete the observable for the guard to work
        )
      }
      private checkRoleMatch(
        role: Role,
        route?: ActivatedRouteSnapshot
      ) {
        if (!route?.data?.expectedRole) {
          return true
        }
        return role === route.data.expectedRole
      }
      private showAlert(isAuth: boolean, roleMatch: boolean) {
        if (!isAuth) {
          this.uiService.showToast('You must login to continue')
        }
        if (!roleMatch) {
          this.uiService.showToast(
            'You do not have the permissions to view this resource'
          )
        }
      }
      getResolvedUrl(route?: ActivatedRouteSnapshot): string {
        if (!route) {
          return ''
        }
        return route.pathFromRoot
          .map((r) => r.url.map((segment) => segment.toString())
          .join('/'))
          .join('/')
          .replace('//', '/')
      }
    } 
    
  2. 使用 CanLoad 守卫来防止加载懒加载的模块,例如管理员的模块:

    **src/app/app-routing.module.ts**
    ...
    {
      path: 'manager',
      loadChildren: () => import('./manager/manager.module')
        .then((m) => m.ManagerModule), 
      **canLoad: [AuthGuard],**
    },
    ... 
    

    在这种情况下,当 ManagerModule 被加载时,AuthGuard 将在 canLoad 事件期间被激活,checkLogin 函数将验证用户的认证状态。如果守卫返回 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,它将通过 canActivate 事件传递给 checkLogin 函数。如果用户已认证但角色不匹配 Role.ManagerAuthGuard 将返回 false 并阻止导航。

接下来,我们将介绍一些使测试通过的技术。

身份验证服务模拟和通用测试提供者

我们需要在 common.testing.ts 中使用 commonTestingProviders 函数提供类似 commonTestingModules 中提到的模式的服务模拟,例如 AuthServiceUiService。这样,我们就不必反复模拟相同的对象。

让我们使用来自 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: commonTestingProviders.concat([
          **{ provide: MediaObserver, useClass: MediaObserverFake },**
    ... 
    

    看看 commonTestingProviders 数组是如何与针对 app.component.ts 的特定模拟进行连接的;我们的新模拟应自动应用。

  3. 更新 LoginComponent 的规范文件以利用 commonTestingModulescommonTestingProviders

    **src/app/login/login.component.spec.ts**
    ...
      TestBed.configureTestingModule({
        **imports: commonTestingModules,**
     **providers: commonTestingProviders,**
        declarations: [LoginComponent],
      }).compileComponents() 
    
  4. 将此技术应用于所有依赖于 AuthServiceUiService 的规范文件。

  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:

图 8.13:Firebase 控制台

  1. 点击 添加项目

  2. 提供你的项目名称

  3. 为你的项目启用 Google Analytics

在尝试此操作之前创建一个 Google Analytics 账户可能会有所帮助,但它仍然可以工作。一旦你的项目创建完成,你应该能看到你的项目仪表板:

图 8.14:Firebase 项目概览

在左侧,标记为步骤 1,你可以看到一个菜单,其中包含你可以添加到项目中的工具和服务。在顶部,标记为步骤 2,你可以快速在项目之间切换。首先,你需要向你的项目添加一个应用程序。

添加一个应用程序

你的项目可以包含多个应用程序的发行版本,如网页、iOS 和 Android 版本。在本章中,我们只对添加一个网页应用程序感兴趣。

让我们开始吧:

  1. 在你的项目仪表板上,点击网页应用程序按钮以添加一个应用程序,这在 图 8.14 中的步骤 3 有标记

  2. 提供一个应用程序昵称

  3. 选择设置 Firebase Hosting 的选项

  4. 通过点击 注册应用程序 按钮继续

  5. 跳过 添加 Firebase SDK 部分

  6. 按照说明安装 Firebase CLI:

    $ npm install -g firebase-tools 
    
  7. 登录:

    $ firebase login 
    

    确保你的当前目录是项目根文件夹。

  8. 初始化你的项目:

    $ firebase init 
    
  9. 选择 托管 选项;不用担心,你以后可以添加更多功能

  10. 选择你创建的项目作为默认项目,即 lemon-mart-007

  11. 对于公共目录,输入 dist/lemon-mart 或你在 angular.json 文件中定义的 outputPath

  12. 以将其配置为单页应用程序。

    这将创建两个新的文件:firebase.json.firebaserc

  13. 为生产构建你的项目:

    $ npx ng build --prod 
    

    或者

    $ npm run build:prod 
    
  14. 现在,你可以通过执行以下命令来部署你的 Angular 应用程序:

    $ firebase deploy 
    

你的网站应该可以在类似 lemon-mart-007.firebaseapp.com 的 URL 上访问,如终端所示。

.firebase 文件夹添加到 .gitignore 中,这样你就不需要提交你的缓存文件。其他两个文件,firebase.json.firebaserc,可以安全提交。

可选地,使用 Firebase 控制台将你拥有的自定义域名连接到账户。

配置身份验证

现在,让我们配置身份验证。

在 Firebase 控制台中:

  1. 从侧边导航中选择身份验证图片

    图 8.15:Firebase 身份验证页面

  2. 选择电子邮件/密码作为提供者

  3. 启用它

  4. 不要启用电子邮件链接

  5. 保存你的配置

你现在可以看到用户管理控制台:

图片

图 8.16:Firebase 用户管理控制台

它的操作相当直接且直观,所以我将把它留给你作为练习。

实现 Firebase 身份验证

让我们从向我们的应用程序添加 Angular Fire 开始,这是 Angular 的官方 Firebase 库:

$ npx ng add @angular/fire 

按照 Angular Fire 的快速入门指南完成设置库与你的 Angular 项目的配置,你可以从 GitHub 上的 readme 文件中找到链接,链接为github.com/angular/angularfire2

  1. 确保 Firebase 模块按照文档要求在app.module.ts中提供。

  2. 确保你的 Firebase 配置对象在所有的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 { Injectable } from '@angular/core'
    import { AngularFireAuth } from '@angular/fire/auth'
    import { User as FirebaseUser } from 'firebase'
    import { Observable, Subject } from 'rxjs'
    import { map } from 'rxjs/operators'
    import { IUser, User } from '../user/user/user'
    import { Role } from './auth.enum'
    import {
      AuthService,
      IAuthStatus,
      IServerAuthResponse,
      defaultAuthStatus,
    } from './auth.service'
    interface IJwtToken {
      email: string
      iat: number
      exp: number
      sub: string
    }
    @Injectable()
    export class FirebaseAuthService extends AuthService {
      constructor(private afAuth: AngularFireAuth) {
        super()
      }
      protected authProvider(
        email: string,
        password: string
      ): Observable<IServerAuthResponse> {
        const serverResponse$ = new Subject<IServerAuthResponse>()
        this.afAuth.signInWithEmailAndPassword(email, password).then(
          (res) => {
            const firebaseUser: FirebaseUser | 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 this.afAuth.user.pipe(map(this.transformFirebaseUser))
      }
      private transformFirebaseUser(firebaseUser: FirebaseUser): 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)
      }
      logout() {
        if (this.afAuth) {
          this.afAuth.signOut()
        }
        this.clearToken()
        this.authStatus$.next(defaultAuthStatus)
      }
    } 
    

    如你所见,我们只需要实现我们已建立的认证代码和 Firebase 认证方法之间的差异。我们不需要复制任何代码,甚至将 Firebase 的user对象转换成了我们应用程序的内部用户对象。

  7. 要使用 Firebase 身份验证而不是内存身份验证,更新app.module.ts中的AuthService提供者:

    **src/app/app.module.ts**
      {
        provide: AuthService,
        useClass: **FirebaseAuthService**,
      }, 
    

    完成步骤后,从 Firebase 身份验证控制台添加新用户,你应该能够使用真实身份验证登录。

    总是要确保在互联网上传输任何类型的个人身份信息PII)或敏感信息(如密码)时使用 HTTPS。否则,你的信息可能会被第三方服务器记录或被恶意行为者捕获。

  8. 再次确保在继续之前更新你的单元测试:

    **src/app/auth/auth.firebase.service.spec.ts**
    import { AngularFireAuth } from '@angular/fire/auth'
    import { UiService } from '../common/ui.service'
    import { FirebaseAuthService } from './auth.firebase.service'
    TestBed.configureTestingModule({
      imports: [**HttpClientTestingModule**],
      providers: [
        **FirebaseAuthService,**
     **{ provide: UiService, useValue: autoSpyObj(UiService) },**
     **{ provide: AngularFireAuth,** 
     **useValue: autoSpyObj(AngularFireAuth)** 
     **},**
      ],
    }) 
    

停止!在部署真实身份验证方法之前,从你的项目中移除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',
      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**
    export function authFactory(afAuth: AngularFireAuth) {
      switch (environment.authMode) {
        case AuthMode.InMemory:
          return new InMemoryAuthService()
        case AuthMode.Firebase:
          return new FirebaseAuthService(afAuth)
        case AuthMode.CustomServer:
          throw new Error('Not yet implemented')
      }
    } 
    

    注意,工厂必须导入任何依赖的服务。

  4. app.module.ts中更新AuthService提供者以使用工厂:

    **src/app/app.module.ts**
      providers: [
        {
          provide: AuthService,
          **useFactory: authFactory,**
     **deps: [AngularFireAuth],**
        }, 
    

注意,你可以从AppModule中移除InMemoryAuthServiceFirebaseAuthService的导入。

在此配置就绪后,无论何时构建你的应用程序用于本地开发,你都将使用内存中的认证服务,而生产(或 prod)构建将使用 Firebase 认证服务。

摘要

现在,你应该熟悉了如何创建高质量的认证体验。在本章中,我们定义了一个用户对象,我们可以从中填充或序列化为 JSON 对象,应用面向对象类设计和 TypeScript 运算符以安全地处理数据。

我们利用面向对象设计原则,使用继承和抽象类来实现一个基类认证服务,展示了开放/封闭原则。

我们已经涵盖了基于令牌的认证和 JWTs 的基础知识,这样你就不会泄露任何关键用户信息。你了解到缓存和 HTTP 拦截器是必要的,这样用户就不必在每次请求时输入他们的登录信息。在此之后,我们实现了两个不同的认证提供者,一个是内存中的,另一个是 Firebase。

然后,我们设计了一个很好的条件导航体验,你可以通过将基本元素复制到你的项目中并实现你自己的认证提供者来在你的应用程序中使用。我们创建了一个可重用的 UI 服务,这样你就可以方便地将警报注入到应用程序的流程控制逻辑中。

最后,我们涵盖了路由守卫,以防止用户误入未经授权使用的屏幕,并重申了你的应用程序的真实安全性应该在实际服务器端实现。你看到了如何使用工厂为不同的环境动态提供不同的认证提供者。

在下一章中,我们将稍微转换一下方向,学习使用 Docker 进行容器化。Docker 允许强大的工作流程,可以极大地提高开发体验,同时允许你将服务器配置作为代码实现,为开发者最喜欢的借口敲响最后的丧钟:当他们软件出错时:“但是在我的机器上它运行正常!”

进一步阅读

问题

尽可能地回答以下问题,以确保你已理解本章的关键概念,而无需使用 Google 搜索。你需要帮助回答这些问题吗?请参阅附录 D自我评估答案,在线访问static.packt-cdn.com/downloads/9781838648800_Appendix_D_Self-Assessment_Answers.pdf或访问expertlysimple.io/angular-self-assessment

  1. 在途和静止状态的安全是什么?

  2. 认证和授权之间有什么区别?

  3. 解释继承和多态。

  4. 抽象类是什么?

  5. 抽象方法是什么?

  6. 解释AuthService如何遵循开放/封闭原则。

  7. JWT 如何验证你的身份?

  8. RxJS 的combineLatestmerge操作符之间有什么区别?

  9. 路由守卫是什么?

  10. 服务工厂允许你做什么?

第九章:使用 Docker 进行 DevOps

第八章结束,即设计身份验证和授权,我们已经拥有了一个相当复杂的应用程序。在第四章,即自动化测试、持续集成和发布到生产环境,我强调了确保我们创建的每个代码推送都通过测试、遵循编码标准,并且是一个团队成员可以运行测试的可执行工件的重要性。到第七章结束,即创建以路由器为第一线的业务应用程序,你应该已经复制了我们为 LemonMart 的本地天气应用程序实现的相同 CircleCI 设置。如果没有,在我们开始为我们的业务线应用程序LOB)构建更复杂的功能之前,请先完成这项工作。

我们生活在一个快速行动并打破事物的时代。然而,这个陈述的后半部分在企业中很少适用。你可以选择生活在边缘并采用 YOLO 生活方式,但这并不符合商业逻辑。

图片

图 9.1:一个工具的创意命令行界面选项

持续集成CI)对于确保通过在每次代码推送时构建和执行测试来交付高质量的产品至关重要。设置 CI 环境可能耗时且需要使用工具的专业知识。在第四章,即自动化测试、持续集成和发布到生产环境,我们实现了 GitHub 流程与 CircleCI 的集成。然而,我们手动部署了我们的应用程序。为了快速行动而不破坏事物,我们需要使用 DevOps 最佳实践,如基础设施即代码IaC)来实现持续部署CD),这样我们就可以更频繁地验证我们运行中的代码的正确性。

在本章中,我们将介绍一种基于 Docker 的方法来实现 IaC,它可以在大多数 CI 服务和云服务提供商上运行,允许你从任何 CI 环境到任何云服务提供商实现可重复的构建和部署。使用灵活的工具,你将避免在单一服务上过度专业化,并保持你的配置管理技能在不同 CI 服务中相关。

本书利用 CircleCI 作为持续集成服务器。其他值得注意的持续集成服务器包括 Jenkins、Azure DevOps 以及 GitLab 和 GitHub 内置的机制。

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

  • DevOps 和 IaC

  • 使用 Docker 容器化 Web 应用程序

  • 使用 Google Cloud Run 部署容器化应用程序

  • 将代码部署到多个云服务提供商

  • 高级持续集成

  • 代码覆盖率报告

遵循本章内容所需的软件包括:

  • Docker Desktop Community 版本 2+

  • Docker Engine CE 版本 18+

  • 一个 Google Cloud Engine 账户

  • 一个 Coveralls 账户

书中示例代码的最新版本可在 GitHub 上找到,链接如下列表中的存储库。该存储库包含代码的最终和完成版本。每个部分都包含信息框,以帮助您找到 GitHub 上的正确文件名或分支,以便您可以使用它们来验证您的进度。

对于基于 local-weather-app第九章 示例,请执行以下操作:

  1. github.com/duluca/local-weather-app 处克隆仓库。

  2. 在根目录下执行 npm install 以安装依赖项。

  3. 使用 .circleci/config.ch9.yml 来验证您的 config.yml 实现。

  4. 要运行 CircleCI Vercel Now 配置,请执行

    git checkout deploy_Vercelnow 
    

    请参阅 github.com/duluca/local-weather-app/pull/50 的拉取请求。

  5. 要运行 CircleCI GCloud 配置,请执行

    git checkout deploy_cloudrun 
    

    请参阅 github.com/duluca/local-weather-app/pull/51 的拉取请求。

注意,这两个分支都利用修改后的代码来使用来自 local-weather-app 仓库的 projects/ch6 代码。

对于基于 lemon-mart第九章 示例,请执行以下操作:

  1. github.com/duluca/lemon-mart 处克隆仓库。

  2. 使用 .circleci/config.ch9.ymlconfig.docker-integration.yml 来验证您的 config.yml 实现。

  3. 在根目录下执行 npm install 以安装依赖项。

  4. 要运行 CircleCI Docker 集成配置,请执行

    git checkout docker-integration 
    

    请参阅 github.com/duluca/lemon-mart/pull/25 的拉取请求。

注意,docker-integration 分支略有修改,以使用来自 lemon-mart 仓库的 projects/ch8 文件夹中的代码。

请注意,由于生态系统不断演变,书中代码与 GitHub 上的代码之间可能存在细微的差异。随着时间的推移,示例代码发生变化是自然的。在 GitHub 上,您可能会找到更正、修复以支持库的新版本,或者为读者观察而并排实现多种技术的示例。读者只需实现书中推荐的理想解决方案即可。如果您发现错误或有疑问,请创建问题或提交 GitHub 上的拉取请求,以惠及所有读者。

您可以在 附录 C 中了解更多关于更新 Angular 的信息,即 保持 Angular 和工具始终如一。您可以从 static.packt-cdn.com/downloads/9781838648800_Appendix_C_Keeping_Angular_and_Tools_Evergreen.pdfexpertlysimple.io/stay-evergreen 在线找到此附录。

让我们先来了解一下 DevOps 是什么。

DevOps

DevOps 是开发和运维的结合。在开发中,代码仓库如 Git 跟踪每个代码更改是众所周知的事实。在运维中,长期以来一直存在各种技术来跟踪环境更改,包括脚本和各种旨在自动化操作系统和服务器配置的自动化工具。

然而,你听过多少次“在我的机器上它工作得很好”的说法?开发者经常用这句话作为玩笑。尽管如此,软件在测试服务器上运行得很好,但由于配置的细微差异,最终在生产服务器上遇到问题的情形也经常发生。

第四章自动化测试、持续集成和发布到生产中,我们讨论了 GitHub 流程如何使我们能够创建一个价值交付流。我们在做出任何更改之前总是从主分支分叉。强制更改通过我们的 CI 管道,一旦我们合理确信我们的代码可以工作,我们就可以合并回主分支。请参见以下图表:

图片

图 9.2:分叉和合并

记住,你的主分支应该始终是可部署的,你应该经常将你的工作合并到主分支。

Docker 允许我们通过一个名为Dockerfile的特殊文件以声明方式定义我们的代码所依赖的软件和特定的配置参数。同样,CircleCI 允许我们通过一个名为config.yml的文件定义我们的 CI 环境配置。通过将配置存储在文件中,我们能够与代码一起检查这些文件。我们可以使用 Git 跟踪更改,并强制它们通过我们的 CI 管道进行验证。通过将基础设施的定义存储在代码中,我们实现了基础设施即代码(IaC)。通过 IaC,我们还实现了可重复的集成,因此无论我们在什么环境中运行我们的基础设施,我们都应该能够通过一条命令启动我们的全栈应用。

你可能还记得,在第一章Angular 介绍及其概念中,我们介绍了 TypeScript 如何覆盖 JavaScript 功能差距。与 TypeScript 类似,Docker 覆盖了配置差距,如下所示:

图片

图 9.3:覆盖配置差距

通过使用 Docker,我们可以合理确信,在测试期间在我们机器上工作的代码,在发布时将以完全相同的方式运行。

总结来说,通过 DevOps,我们将运维与开发更紧密地结合在一起,在开发中更改和解决问题成本更低。因此,DevOps 主要是开发者的责任,但它也是一种思维方式,运维团队必须愿意支持。让我们更深入地了解 Docker。

使用 Docker 容器化 Web 应用

Docker,可在 docker.io 找到,是一个用于开发、运输和运行应用程序的开源平台。Docker 结合了一个轻量级的容器虚拟化平台以及帮助管理和部署应用程序的工作流程和工具。虚拟机VMs)和 Docker 容器之间最明显的区别是,VMs 通常大小为数十个吉字节,需要数吉字节内存,而容器在磁盘和内存大小需求方面仅占用兆字节。此外,Docker 平台抽象化了主机 操作系统OS)级别的配置设置,因此成功运行应用程序所需的所有配置都编码在可读格式中。

Dockerfile 的结构

一个 Dockerfile 由四个主要部分组成:

  • FROM – 我们可以继承 Docker 的最小 "scratch" 镜像或现有的镜像

  • SETUP – 我们根据需求配置软件依赖的地方

  • COPY – 我们将构建的代码复制到操作环境中的地方

  • CMD – 我们指定启动操作环境的命令的地方

引导程序指的是一组初始指令,描述了程序如何加载或启动。

考虑以下 Dockerfile 结构的可视化:

图 9.4:Dockerfile 的结构

以下代码展示了 Dockerfile 的具体表示:

**Dockerfile**
FROM duluca/minimal-nginx-web-server:1-alpine
COPY /dist/local-weather-app /var/www
CMD 'nginx' 

你可以将脚本中的 FROM, COPY, 和 CMD 部分映射到可视化中。我们使用 FROM 命令从 duluca/minimal-nginx-web-server 镜像继承。然后,我们使用 COPY(或,作为替代,ADD)命令将我们的应用程序的编译结果从我们的开发机器或构建环境复制到镜像中。最后,我们指示容器使用 CMD(或,作为替代,ENTRYPOINT)命令执行 nginx 网络服务器。

注意,前面的 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镜像继承,该镜像本身又从alpine镜像继承。alpine镜像是一个最小的 Linux 操作系统环境,大小仅为 5MB。alpine镜像本身从scratch继承,scratch是一个空镜像。请参见以下图表中展示的继承层次结构:

图 9.5:Docker 继承

然后Dockerfile将开发环境中的dist文件夹内容复制到容器的www文件夹中,如下所示:

图 9.6:将代码复制到容器化的 Web 服务器中

在这种情况下,父镜像配置了 nginx 服务器作为 Web 服务器来服务www文件夹内的内容。此时,我们的源代码可以从互联网上访问,但生活在多层安全环境中。即使我们的应用程序存在某种漏洞,攻击者也很难伤害我们正在运行的系统。以下图表展示了 Docker 提供的多层安全:

图 9.7:Docker 安全

总结来说,在基础层,我们有运行 Docker 运行时的宿主操作系统,例如 Windows 或 macOS,它将在下一节中安装。Docker 运行时能够运行自包含的 Docker 镜像,这些镜像由上述Dockerfile定义。duluca/minimal-nginx-web-server基于轻量级的 Linux 操作系统 Alpine。Alpine 是 Linux 的一个完全精简版本,不带任何 GUI、驱动程序,甚至没有大多数 Linux 系统可能期望的 sCLI 工具。因此,该操作系统的大小仅为约 5MB。然后我们从 nginx 镜像继承,该镜像安装了 Web 服务器,其自身大小约为几 MB。最后,我们的自定义 nginx 配置覆盖在默认镜像之上,结果是一个小巧的约 7MB 镜像。nginx 服务器配置为服务/var/www文件夹的内容。在Dockerfile中,我们仅复制开发环境中/dist文件夹的内容并将其放置到/var/www文件夹中。我们稍后将构建并执行此镜像,该镜像将运行包含我们dist文件夹输出的 Nginx Web 服务器。我已经发布了一个类似的镜像,名为duluca/minimal-node-web-server,大小约为 15MB。

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 

你可以在 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 和 Alpine 或 Node 的 LTS 版本的最新发布版本。我已经设置了管道,当发布新的基础镜像时,会自动更新这两个镜像。所以,无论何时你拉取这些镜像,你都会得到最新的错误修复和安全补丁。

拥有一个常绿依赖树可以减轻你作为开发者寻找最新 Docker 镜像版本的负担。或者,如果你指定了版本号,你的镜像将不会受到任何潜在的重大更改的影响。然而,记住在新构建后测试你的镜像,比永远不更新你的镜像并可能部署受损害的软件要好。毕竟,网络是不断变化的,不会因为你更新镜像而减速。

就像 npm 包一样,Docker 可以带来极大的便利和价值,但你必须小心,理解你正在使用的工具。

第十三章AWS 上的高可用云基础设施 中,我们将利用基于 Nginx 的低内存占用 Docker 镜像 duluca/minimal-nginx-web-server。如果你熟悉配置 nginx,这是理想的选择。

安装 Docker

为了能够构建和运行容器,你必须在你的电脑上首先安装 Docker 执行环境。请参考 第二章设置你的开发环境,了解安装 Docker 的说明。

设置 npm scripts for Docker

现在,让我们配置一些 Docker 脚本,您可以使用这些脚本来自动化 Angular 应用程序的构建、测试和发布。我开发了一套名为 npm scripts for Docker 的脚本,这些脚本在 Windows 10 和 macOS 上运行。您可以通过执行以下代码获取这些脚本的最新版本,并在项目中自动配置它们:

现在,在 local-weather-applemon-mart 项目上运行以下命令!

  1. 安装 Docker 任务的 npm 脚本:

    $ npm i -g mrm-task-npm-docker 
    
  2. 应用 Docker 配置的 npm 脚本:

    $ npx mrm npm-docker 
    

执行 mrm 脚本后,我们就可以使用 Local Weather 应用程序作为示例,深入查看配置设置。

构建并发布镜像到 Docker Hub

接下来,让我们确保您的项目配置正确,这样我们就可以将其容器化,构建可执行镜像,并将其发布到 Docker Hub,从而允许我们从任何构建环境中访问它。我们将使用我们在 第六章表单、Observables 和 Subjects 中最后更新的 Local Weather 应用程序来完成本节:

本节使用 local-weather-app 仓库。

  1. hub.docker.com/ 上注册 Docker Hub 账户。

  2. 为您的应用程序创建一个公共(免费)仓库。

    在本章的后面部分,我们使用 Google Cloud 的容器注册库作为私有仓库。此外,在 第十三章AWS 上的高可用云基础设施 中,我介绍了如何使用 AWS Elastic Container ServiceAWS ECS)设置私有容器仓库。

  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 属性将定义应使用哪个端口从容器内部公开您的应用程序。由于我们使用端口 5000 进行开发,请选择不同的端口,例如 8080internalContainerPort 定义了您的 Web 服务器映射到的端口。对于 Node 服务器,这通常是端口 3000,而对于 Nginx 服务器,则是 80。请参阅您所使用的基容器的文档。

  4. 让我们回顾一下之前由 mrm 任务添加到 package.json 中的 Docker 脚本。以下是一个注释版本的脚本,解释了每个功能。

    注意,使用 npm 脚本时,prepost 关键字分别用于在执行给定脚本之前或之后执行辅助脚本。脚本被有意拆分成更小的部分,以便更容易阅读和维护。

    build 脚本如下:

    注意以下 cross-conf-env 命令确保脚本在 macOS、Linux 和 Windows 环境中都能同样良好地执行。

    **package.json**
    ...
      "scripts": {
        ...
        "predocker:build": "npm run build",
        "docker:build": "cross-conf-env docker image build . -t $npm_package_config_imageRepo:$npm_package_version",
        "postdocker:build": "npm run docker:tag",
        ... 
    

    npm run docker:build 将在 pre 脚本中构建你的 Angular 应用程序,然后使用 docker image build 命令构建 Docker 镜像,并在 post 脚本中使用版本号标记镜像:

    在我的项目中,pre 命令以生产模式构建我的 Angular 应用程序,并运行测试以确保我有一个优化后的构建,没有失败的测试。

    我的预命令看起来像:

    "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.jsonversion 属性的版本号和最新标签标记已构建的 Docker 镜像。

    stop 脚本如下:

    **package.json**
        ...
        "docker:stop": "cross-conf-env docker stop $npm_package_config_imageName || true",
        ... 
    

    npm run docker:stop 如果镜像正在运行,将停止它,这样 run 脚本就可以无错误地执行。

    run 脚本如下:

    注意 run-srun-p 命令是 npm-run-all 包的一部分,用于同步或并行化 npm 脚本的执行。

    **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",
        ... 
    

    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",
        ... 
    

    npm run docker:publish 将使用 docker image push 命令将构建好的镜像发布到配置的仓库,在这个例子中是 Docker Hub。

    首先,发布带有版本号的镜像,然后是带有 latest 标签的镜像。taillogs 脚本如下:

    **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 参数,这实现了两个目的:通过 Ahead-of-TimeAOT) 编译将应用程序的大小优化得显著更小,从而提高运行时性能,并且使用 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 验证你的工作。

你可能会发现通过 CLI 交互 npm 脚本有些令人困惑。接下来,让我们看看 VS Code 的 npm 脚本支持。

VS Code 中的 NPM 脚本

VS Code 默认提供对 npm 脚本的支持。为了启用 npm 脚本探索器,打开 VS Code 设置并确保存在 "npm.enableScriptExplorer": true 属性。一旦这样做,你将在 探索器 面板中看到一个可展开的标题名为 NPM SCRIPTS,如下面的截图所示,箭头已突出显示:

img/B14094_09_08.png

图 9.8:VS Code 中的 NPM 脚本

您可以单击任何脚本以启动package.json中包含脚本的行,或者右键单击并选择运行来执行脚本。

让我们看看与 Docker 交互的一种更简单的方法。

VS Code 中的 Docker 扩展

与 Docker 镜像和容器交互的另一种方式是通过 VS Code。如果您已按照第二章设置开发环境中建议的,安装了来自 Microsoft 的ms-azuretools.vscode-docker Docker 扩展,您可以通过 VS Code 左侧导航菜单上的 Docker 标志识别该扩展,如下面的截图所示,用白色圆圈标出:

图 9.9:VS Code 中的 Docker 扩展

让我们来看看扩展提供的功能之一。参考前面的截图和以下列表中的编号步骤,以获得快速解释:

  1. 镜像包含您系统上所有容器快照的列表。

  2. 右键单击 Docker 镜像会弹出一个上下文菜单,可以运行各种操作,如运行推送标记****。

  3. 容器列出了您系统上所有可执行的 Docker 容器,您可以启动、停止或附加到它们。

  4. 注册表显示了您配置连接到的注册表,例如 Docker Hub 或AWS 弹性容器注册表AWS ECR)。

虽然扩展使与 Docker 的交互变得更容易,但Docker 的 npm 脚本(您使用mrm任务配置的)自动化了大量与构建、标记和测试镜像相关的任务。它们都是跨平台的,在 CI 环境中也能同样良好地工作。

npm run docker:debug脚本自动化了大量任务,以验证您有一个良好的镜像构建!

现在让我们看看如何将我们的容器部署到云,并随后实现持续交付(CD)。

将 Dockerfile 部署到云

使用 Docker 的一个优点是我们可以在任何数量的操作系统环境中部署它,从个人 PC 到服务器和云提供商。在任何情况下,我们都期望我们的容器以相同的方式运行。让我们将 LocalCast 天气应用部署到 Google Cloud Run。

Google Cloud Run

Google Cloud Run 允许您部署任意 Docker 容器,并在 Google Cloud Platform 上执行它们,而无需任何繁重的开销。完全管理的实例提供一些免费时间;然而,这里没有永久免费的版本。请注意您可能产生的任何费用。请参阅cloud.google.com/run/pricing?hl=en_US%20for%20pricing

参考第二章,设置开发环境,了解如何安装 glcoud。

本节使用local-weather-app仓库。

让我们配置 glcoud,以便我们可以部署一个Dockerfile

  1. 更新您的Dockerfile以覆盖ENTRYPOINT命令:

    **Dockerfile**
    FROM duluca/minimal-node-web-server:lts-alpine
    WORKDIR /usr/src/app
    COPY dist/local-weather-app public
    ENTRYPOINT [ "npm", "start" ] 
    

    minimal-node-web-server 中的 ENTRYPOINT 命令运行一个名为 dumb-init 的进程,以强制你的 Node 进程的进程 ID 随机化。然而,gcloud 无法执行此命令,这就是我们为什么要覆盖它的原因。

  2. 创建一个新的 gcloud 项目:

    $ gcloud projects create localcast-weather 
    

    记得使用你自己的项目名称!

  3. 导航到 console.cloud.google.com/

  4. 定位到你的新项目,并从侧边栏中选择计费选项,如图下截图所示:

    图 9.10:计费选项

  5. 按照说明设置计费账户。

    如果你看到它,免费增值账户选项也将有效。否则,你可以选择利用免费试用优惠。然而,设置一个预算警报是个好主意,这样你就可以在每月超过一定金额时收到通知。更多信息请见 cloud.google.com/billing/docs/how-to/modify-project

  6. 创建一个 .gcloudignore 文件,忽略除 Dockerfiledist 文件夹之外的所有内容:

    **.gcloudignore**
    /*
    !Dockerfile
    !dist/ 
    
  7. 在云端添加一个新的 npm 脚本来构建你的 Dockerfile

    **package.json**
      scripts: {
        "gcloud:build": "gcloud builds submit --tag gcr.io/localcast-weather/localcast-weather --project localcast-weather",
      } 
    

    记得使用你自己的项目名称!

  8. 添加另一个 npm 脚本来部署你发布的容器:

    **package.json**
      scripts: {
        "gcloud:deploy": "gcloud run deploy --image gcr.io/localcast-weather/localcast-weather --platform managed --project localcast-weather --region us-east1"
      } 
    

    注意,你应该提供离你地理位置最近的区域,以获得最佳体验。

  9. 按照以下方式构建你的 Dockerfile

    $ npm run gcloud:build 
    

    在运行此命令之前,记得为 prod 构建你的应用程序。你 dist 文件夹中的任何内容都将被部署。

    注意,在首次运行时,你将需要回答问题以配置你的账户以供初始使用。正确选择你的账户和项目名称,否则,请选择默认选项。build 命令在首次运行时可能会失败。有时需要多次运行,gcloud 才能预热并成功构建你的容器。

  10. 一旦你的容器发布,使用以下命令部署它:

    $ npm run gcloud:deploy 
    

成功的部署看起来如下所示:

图 9.11:成功的部署

恭喜,你已经在 Google Cloud 上部署了你的容器。你应该能够通过终端输出的 URL 访问你的应用程序。

如往常一样,考虑将 CLI 命令作为 npm 脚本添加到你的项目中,这样你可以维护你脚本的活文档。这些脚本还将允许你利用 npm 中的预脚本和后脚本,从而自动化构建你的应用程序、容器和标记过程。因此,下次你需要部署时,你只需要运行一个命令。我鼓励读者从我们之前设置的 npm 脚本 Docker 工具中寻找灵感,以创建你自己的 gcloud 脚本集。

有关更多信息和一些示例项目,请参阅 cloud.google.com/run/docs/quickstarts/prebuilt-deploycloud.google.com/run/docs/quickstarts/build-and-deploy

使用 Cloud Run 配置 Docker

在上一节中,我们将我们的 Dockerfiledist 文件夹提交给 gcloud,以便它为我们构建容器。这是一个方便的选项,可以避免一些额外的配置步骤。然而,您仍然可以利用基于 Docker 的工作流程来构建和发布您的容器。

让我们使用 gcloud 配置 Docker:

  1. 设置您的默认区域:

    $ gcloud config set run/region us-east1 
    
  2. 使用 gcloud 容器注册库配置 Docker:

    $ gcloud auth configure-docker 
    
  3. 使用 gcloud 主机名标记您已构建的容器:

    $ docker tag duluca/localcast-weather:latest gcr.io/localcast-weather/localcast-weather:latest 
    

    有关如何标记镜像的详细说明,请参阅 cloud.google.com/container-registry/docs/pushing-and-pulling

  4. 使用 Docker 将容器发布到 gcloud:

    $ docker push gcr.io/localcast-weather/localcast-weather:latest 
    
  5. 执行 deploy 命令:

    $ gcloud run deploy --image gcr.io/localcast-weather/localcast-weather --platform managed --project localcast-weather 
    

    在初始部署期间,此命令可能看起来卡住了。大约 15 分钟后再试一次。

  6. 按照屏幕上的说明完成您的部署。

  7. 按照屏幕上显示的 URL 检查您的应用程序是否已成功部署。

上述步骤演示了一种与我们在 第十三章AWS 上的高可用云基础设施 中部署到 AWS ECS 时所利用的技术类似的部署技术。

有关更多信息,请参阅 cloud.google.com/sdk/gcloud/reference/run/deploy。在接下来的几节中,我们将切换回 LemonMart。

Cloud Run 故障排除

为了故障排除您的 glcoud 命令,您可以使用 Google Cloud Platform 控制台 console.cloud.google.com/

在 Cloud Run 菜单下,您可以跟踪您正在运行的容器。如果在部署过程中发生错误,您可能想检查日志以查看容器创建的消息。参考以下截图,它显示了 localcast-weather 部署的日志:

图 9.12:Cloud Run 日志

要了解更多关于 Cloud Run 故障排除的信息,请参阅 cloud.google.com/run/docs/troubleshooting

恭喜!您已经掌握了在本地开发环境中使用 Docker 容器以及将它们推送到云中的多个注册库和运行时环境的基本技能。

持续部署

CD 是指代码更改成功通过您的管道后可以自动部署到目标环境。尽管有持续部署到生产环境的例子,但大多数企业更喜欢将目标设置为开发环境。采用门控方法将更改通过开发的各个阶段,包括测试、预发布和最终的生产。CircleCI 可以通过审批工作流程促进门控部署,这一点将在本节后面介绍。

在 CircleCI 中,为了部署您的镜像,我们需要实现一个deploy作业。在这个作业中,您可以部署到多个目标,例如 Google Cloud Run、Docker Hub、Heroku、Azure 或 AWS ECS。与这些目标的集成将涉及多个步骤。从高层次来看,这些步骤如下:

  1. 为您的目标环境配置一个 orb,它提供了部署您的软件所需的 CLI 工具。

  2. 将针对目标环境的特定登录凭证或访问密钥存储为 CircleCI 环境变量。

  3. 如果不是使用特定平台的build命令,则在 CI 管道中构建容器。然后使用docker push将生成的 Docker 镜像提交到目标平台的 Docker 注册库。

  4. 执行特定平台的deploy命令,指示目标运行刚刚推送的 Docker 镜像。

通过使用基于 Docker 的工作流程,我们在系统和目标环境方面实现了极大的灵活性。以下图表通过突出我们可用的可能选择排列来阐述这一点:

图 9.13

图 9.13:n-to-n 部署

如您所见,在容器化的世界中,可能性是无限的。我将在本章后面演示如何使用容器和 CI 将应用程序部署到 Google Cloud Run。在基于 Docker 的工作流程之外,您可以使用专门构建的 CLI 工具快速部署您的应用程序。接下来,让我们看看如何使用 CircleCI 将应用程序部署到 Vercel Now。

使用 CircleCI 将应用程序部署到 Vercel Now

第四章,“自动化测试、CI 和发布到生产”中,我们配置了 LocalCast Weather 应用程序使用 CircleCI 进行构建。我们可以增强我们的 CI 管道,以使用构建输出,并可选择将其部署到 Vercel Now。

注意,ZEIT Now 在 2020 年更名为 Vercel Now。

本节使用local-weather-app仓库。本节的config.yml文件命名为.circleci/config.ch9.yml。您还可以在github.com/duluca/local-weather-app/pull/50找到执行本章中.yml文件的拉取请求,使用branch deploy_Vercelnow

注意,这个分支在config.ymlDockerfile中有一个修改过的配置,以使用来自local-weather-appprojects/ch6代码。

让我们更新config.yml文件,添加一个名为deploy的新作业。在即将到来的工作流程部分,我们将使用此作业在批准时部署管道:

  1. 从您的 Vercel Now 账户创建一个令牌。

  2. 在您的 CircleCI 项目中添加一个名为 NOW_TOKEN 的环境变量,并将您的 Vercel Now 令牌作为其值存储。

  3. config.yml 中,更新 build 作业的新步骤,并添加一个名为 deploy 的新作业:

    **.circleci/config.yml**
    ...
    jobs:
      build:
        ...       
        - 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 now --token $NOW_TOKEN --platform-version 2 --prod /tmp/workspace/dist/local-weather-app --confirm 
    

    build 作业中,构建完成后,我们添加两个新步骤。首先,我们将位于 dist 文件夹中的编译后的应用程序移动到工作区,并持久化该工作区,以便我们可以在另一个作业中使用它。在名为 deploy 的新作业中,我们附加工作区并使用 npx 运行 now 命令来部署 dist 文件夹。这是一个简单的过程。

    注意 $NOW_TOKEN 是我们在 CircleCI 项目中存储的环境变量。

  4. 实现一个简单的 CircleCI 工作流程,以持续部署 build 作业的结果:

    **.circleci/config.yml**
    ...
    workflows:
      version: 2
      build-test-and-deploy:
        jobs:
          - build
          - deploy:
             requires:
               - build 
    

    注意,deploy 作业在执行之前会等待 build 作业完成。

  5. 通过检查测试结果来确保您的 CI 管道成功执行:

图片

图 9.14:在 deploy_Vercelnow 分支上成功部署 local-weather-app 的 Vercel Now

大多数云服务提供商的 CLI 命令都需要安装到您的管道中才能正常工作。由于 Vercel Now 有 npm 包,这很容易做到。AWS、Google Cloud 或 Microsoft Azure 的 CLI 工具需要使用 brewchoco 等工具安装。在 CI 环境中手动执行此操作很繁琐。接下来,我们将介绍 orbs,它有助于解决这个问题。

使用 orbs 将应用程序部署到 GCloud

Orbs 包含一组配置元素,用于封装 CircleCI 项目之间的可共享行为。CircleCI 提供由 CLI 工具维护者开发的 orbs。这些 orbs 使您能够轻松地将 CLI 工具添加到您的管道中,而无需手动设置,配置简单。

要使用 orbs,您的 config.yml 版本号必须设置为 2.1,并且在您的 CircleCI 安全设置中,您必须选择允许未认证 orbs 的选项。

以下是一些您可以在项目中使用的 orbs:

  • circleci/aws-clicircleci/aws-ecr 为您提供 AWS CLI 工具,并帮助您与 AWS 弹性容器服务AWS ECS)交互,执行诸如将容器部署到 AWS ECR 等任务。

  • circleci/aws-ecs 简化了您的 CircleCI 配置,以便将容器部署到 AWS ECS。

  • circleci/gcp-clicircleci/gcp-gcr 为您提供 GCloud CLI 工具和访问 Google 容器注册库GCR)的权限。

  • circleci/gcp-cloud-run 简化了您的 CircleCI 配置,以便将容器部署到 Cloud Run。

  • circleci/azure-clicircleci/azure-acr 为您提供 Azure CLI 工具和访问 Azure 容器注册库ACR)的权限。

查看 Orb 注册表以获取有关如何使用这些 orbs 的更多信息:circleci.com/orbs/registry

现在,让我们配置circleci/gcp-cloud-run orb 与 Local Weather 应用,这样我们就可以持续将我们的应用部署到 GCloud,而无需在 CI 服务器上手动安装和配置 gcloud CLI 工具。

local-weather-app仓库中,你可以找到一个从这一步开始在 CircleCI 上执行 Cloud Run 配置的 pull request,链接为github.com/duluca/local-weather-app/pull/51,使用的是deploy_cloudrun分支。

注意,这个分支在config.ymlDockerfile中有修改过的配置,以使用local-weather-app中的projects/ch6代码。

首先,配置你的 CircleCI 和 GCloud 账户,以便你可以从 CI 服务器部署。这与从你的开发机器部署明显不同,因为 gcloud CLI 工具会自动为你设置必要的认证配置。在这里,你必须手动完成:

  1. 在你的 CircleCI 账户设置中,在安全部分确保你允许执行未经认证/未签名的 orb。

  2. 在 CircleCI 项目设置中,添加一个名为GOOGLE_PROJECT_ID的环境变量。

    如果你和我使用的是相同的项目 ID,那么这个应该是localcast-weather

  3. 为你的项目现有的服务账户创建一个 GCloud 服务账户密钥。

    创建服务账户密钥将生成一个 JSON 文件。不要将此文件提交到你的代码仓库。不要通过不安全的通信渠道(如电子邮件或短信)共享其内容。泄露此文件的内容意味着任何第三方都可以通过密钥权限访问你的 GCloud 资源。

  4. 将 JSON 文件的内容复制到 CircleCI 环境变量GCLOUD_SERVICE_KEY中。

  5. 添加另一个名为GOOGLE_COMPUTE_ZONE的环境变量,并将其设置为你的首选区域。

    我使用了us-east1

  6. 更新你的config.yml文件,添加一个名为circleci/gcp-cloud-run的 orb:

    **.circleci/config.yml**
    **version: 2.1**
    orbs:
      cloudrun: circleci/gcp-cloud-run@1.0.2
      ... 
    
  7. 接下来,实现一个名为deploy_cloudrun的新作业,利用 orb 功能来初始化、构建、部署和测试我们的部署:

    **.circleci/config.yml**
    ...
    deploy_cloudrun:
      docker:
        - image: 'cimg/base:stable'
      working_directory: ~/repo
      steps:
        - attach_workspace:
            at: /tmp/workspace
        - checkout
        - run:
            name: Copy built app to dist folder
            command: cp -avR /tmp/workspace/dist/ .
        - cloudrun/init
        - cloudrun/build:
           tag: 'gcr.io/${GOOGLE_PROJECT_ID}/test-${CIRCLE_SHA1}'
           source: ~/repo
        - cloudrun/deploy:
            image: 'gcr.io/${GOOGLE_PROJECT_ID}/test-${CIRCLE_SHA1}'
            platform: managed
            region: us-east1
            service-name: localcast-weather
            unauthenticated: true
        - run:
            command: >
              GCP_API_RESULTS=$(curl -s "$GCP_DEPLOY_ENDPOINT")
              if ! echo "$GCP_API_RESULTS" | grep -nwo "LocalCast Weather"; then
                echo "Result is unexpected"
                echo 'Result: '
                curl -s "$GCP_DEPLOY_ENDPOINT"
                exit 1;
              fi
            name: Test managed deployed service. 
    

    我们首先从build作业中加载dist文件夹。然后运行cloudrun/init,以便初始化 CLI 工具。使用cloudrun/build,我们构建项目根目录下的Dockerfile,构建结果自动存储在 GCR 中。然后,cloudrun/deploy部署我们刚刚构建的镜像,使我们的代码上线。在最后一个命令中,使用curl工具检索我们网站的index.html文件,并检查它是否已正确部署,通过搜索 LocalCast Weather 字符串来验证。

  8. 更新你的工作流程以持续部署到 gcloud:

    **.circleci/config.yml**
    ...
    workflows:
     version: 2
      build-test-and-deploy:
        jobs:
          - build
          - deploy_cloudrun:
              requires:
                - build 
    

    注意,你可以有多个同时部署到多个目标的deploy作业。

  9. 通过检查测试结果来确保你的 CI 管道执行成功:

图片

图 9.15:在 deploy_cloudrun 分支上成功部署 local-weather-app 到 gcloud

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 的外观:

图片

图 9.16:管道中的暂停

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: master
      - deploy:
        requires:
          - hold 

在这种情况下,buildtest 作业是并行执行的。如果我们在一个分支上,这就是管道停止的地方。一旦分支与 master 合并,管道就会被暂停,决策者有选择部署特定构建或不部署的选项。这种分支过滤类型确保只有合并到 master 的代码才能部署,这与 GitHub 流相一致。

接下来,我们将深入了解如何自定义 Docker 以适应您的流程和环境。

高级持续集成

第四章自动化测试、持续集成和发布到生产 中,我们介绍了利用默认功能的基本 CircleCI 工作流程。除了单元测试执行的基本自动化之外,CI 的另一个目标是通过每次代码推送都构建、测试和生成应用程序的可部署工件,以实现一致和可重复的环境。在推送一些代码之前,开发者应该对构建通过有一个合理的预期;因此,创建一个可靠的 CI 环境至关重要,该环境可以自动化开发者也可以在本地机器上运行的命令。为了实现这一目标,我们将构建一个自定义的构建管道,该管道可以在任何操作系统上运行,无需配置或任何行为变化。

本节使用 lemon-mart 仓库。请确保您的项目已按本章前面所述正确配置,通过执行 npm run docker:debug

容器化构建环境

为了确保在各种操作系统平台、开发机器和 CI 环境之间保持一致的构建环境,您可能需要将您的构建环境容器化。请注意,目前至少有六种常见的 CI 工具正在使用中。学习每个工具的细节几乎是一项几乎不可能完成的任务。

将构建环境容器化是一个高级概念,它超越了当前 CI 工具所期望的功能。然而,容器化是一种很好的方法,可以标准化超过 90% 的构建基础设施,并且可以在几乎任何 CI 环境中执行。采用这种方法,你学到的技能和创建的构建配置将更有价值,因为你的知识和你创建的工具都变得可转移和可重复使用。

有许多策略可以将构建环境容器化,具有不同粒度和性能期望。为了本书的目的,我们将关注可重用性和易用性。我们不会创建一个复杂且相互依赖的 Docker 镜像集,这可能允许更高效的失败和恢复路径,而是将重点放在一个简单直接的流程上。Docker 的新版本有一个名为多阶段构建的出色功能,它允许你以易于阅读的方式定义多镜像过程,并维护一个单一的 Dockerfile

在过程结束时,你可以提取一个优化的容器镜像作为我们的交付物,从而简化了之前过程中使用的镜像的复杂性。

作为提醒,你的单个 Dockerfile 可能看起来像以下示例:

**Dockerfile**
FROM duluca/minimal-node-web-server:lts-alpine 
WORKDIR /usr/src/app
COPY dist/lemon-mart public 

多阶段 Dockerfile

多阶段构建通过在单个 Dockerfile 中使用多个 FROM 语句来实现,其中每个阶段都可以执行一个任务,并将其实例内的任何资源提供给其他阶段。在构建环境中,我们可以将各种与构建相关的任务作为它们自己的阶段来实现,然后将最终结果,如 Angular 构建的 dist 文件夹,复制到包含 Web 服务器的最终镜像中。在这种情况下,我们将实现三个阶段的镜像:

  • 构建器:用于构建你的 Angular 应用的生产版本

  • 测试器:用于对无头 Chrome 实例运行单元和端到端测试

  • Web 服务器:仅包含优化后的生产代码的最终结果

多阶段构建需要 Docker 版本 17.05 或更高。要了解更多关于多阶段构建的信息,请阅读docs.docker.com/develop/develop-images/multistage-build上的文档。

如以下图所示,构建器将构建应用程序,测试器将执行测试:

图 9.17:多阶段 Dockerfile

最终镜像将使用构建步骤的结果来构建。

首先,在项目的根目录下创建一个新文件来实现多阶段配置,命名为 integration.Dockerfile

构建器

第一个阶段是 builder。我们需要一个轻量级的构建环境,以确保跨所有方面的构建一致性。为此,我创建了一个基于 Alpine 的 Node 构建环境,其中包括 npm、bash 和 Git 工具。这个最小容器被称为 duluca/minimal-node-build-env,基于 node-alpine,可以在 Docker Hub 上找到 hub.docker.com/r/duluca/minimal-node-build-env。这个镜像比 node 小大约 10 倍。

Docker 镜像的大小对构建时间有实际影响,因为 CI 服务器或您的团队成员将花费额外的时间来拉取更大的镜像。请选择最适合您需求的 环境。

让我们使用合适的基镜像创建一个构建器:

  1. 确保在 package.json 中有 build:prod 命令:

    **package.json**
    "scripts": {
      "build:prod": "ng build --prod",
    } 
    
  2. 继承自基于 Node.js 的构建环境,例如 node:lts-alpineduluca/minimal-node-build-env:lts-alpine

  3. 在新的 Dockerfile 中实现特定环境的构建脚本,命名为 integration.Dockerfile,如下所示:

    **integration.Dockerfile**
    FROM duluca/minimal-node-build-env:lts-alpine as builder
    ENV BUILDER_SRC_DIR=/usr/src
    # setup source code directory and copy source code
    WORKDIR $BUILDER_SRC_DIR
    COPY . .
    # install dependencies and build
    RUN npm ci
    RUN npm run style
    RUN npm run lint
    RUN npm run build:prod 
    

CI 环境将从 GitHub 检出您的源代码并将其放置在当前目录中。因此,使用点符号从 当前工作目录CWD)复制源代码应该可以工作,就像在您的本地开发环境中一样。如果您遇到问题,请参考您的 CI 提供商的文档。

接下来,让我们看看您如何调试您的 Docker 构建。

调试构建环境

根据您的特定需求,您对 Dockerfile 中构建器部分的初始设置可能很令人沮丧。为了测试新命令或调试错误,您可能需要直接与构建环境交互。

要在构建环境中交互式实验和/或调试,请执行以下命令:

$ docker run -it duluca/minimal-node-build-env:lts-alpine /bin/bash 

您可以在将它们烘焙到 Dockerfile 之前,在这个临时环境中测试或调试命令。

Tester

第二个阶段是 tester。默认情况下,Angular CLI 生成的测试需求是针对开发环境的。这在 CI 环境中不起作用;我们必须配置 Angular 以针对无头浏览器运行,该浏览器可以在没有 GPU 辅助的情况下执行,并且还需要一个容器化环境来执行测试。

Angular 测试工具在 第四章自动化测试、CI 和发布到生产 中有所介绍。

为 Angular 配置无头浏览器

Protractor 测试工具官方支持在无头模式下运行 Chrome。为了在 CI 环境中执行 Angular 测试,您需要配置测试运行器 Karma,使其与无头 Chrome 实例一起运行:

  1. 更新 karma.conf.js 以包括新的无头浏览器选项:

    **Karma.conf.js**
    ...
        browsers: ['Chrome', 'ChromiumHeadless', 'ChromiumNoSandbox'],
        customLaunchers: {
          ChromiumHeadless: {
            base: 'Chrome',
            flags: [
              '--headless',
              '--disable-gpu',
              // Without a remote debugging port, Google Chrome exits immediately.
              '--remote-debugging-port=9222',
              ],
            debug: true,
          },
          ChromiumNoSandbox: {
            base: 'ChromiumHeadless',
            flags: ['--no-sandbox', '--disable-translate', '--disable- extensions']
          },
        }, 
    

    ChromiumNoSandbox 自定义启动器封装了良好默认设置所需的所有配置元素。

  2. 更新 protractor 配置以在无头模式下运行:

    **e2e/protractor.conf.js**
    ...
      capabilities: { 
        browserName: 'chrome',
        chromeOptions: {
          args: [
            '--headless',
            '--disable-gpu',
            '--no-sandbox',
            '--disable-translate',
            '--disable-extensions',
            '--window-size=800,600',
          ],
        },
      },
    ... 
    

    为了测试你的应用程序在响应式场景下的表现,你可以使用前面提到的--window-size选项来更改浏览器设置。

  3. 更新package.json中的脚本,以在生产构建场景中选择新的浏览器选项:

    **package.json**
    "scripts": {
      ...
      "test": "ng test lemon-mart --browsers Chrome",
      "test:prod": "npm test -- --browsers ChromiumNoSandbox   --  watch=false"
    ...
    } 
    

    注意,test:prod不包括npm run e2e。e2e 测试是执行时间较长的集成测试,所以在将它们作为关键构建流程的一部分时要三思。e2e 测试不会在下一节中提到的轻量级测试环境中运行,因为它们需要更多的资源和时间来执行。

现在,让我们定义容器化的测试环境。

配置我们的测试环境

为了创建一个轻量级的测试环境,我们将利用基于 Alpine 的 Chromium 浏览器安装:

  1. 继承自duluca/minimal-node-chromium:lts-alpine

  2. 将以下配置追加到integration.Dockerfile中:

    **integration.Dockerfile**
    ...
    FROM duluca/minimal-node-chromium:lts-alpine as tester
    ENV BUILDER_SRC_DIR=/usr/src
    ENV TESTER_SRC_DIR=/usr/src
    WORKDIR $TESTER_SRC_DIR
    COPY --from=builder $BUILDER_SRC_DIR .
    # force update the webdriver, so it runs with latest version of Chrome
    RUN cd ./node_modules/protractor && npm i webdriver-manager@latest
    WORKDIR $TESTER_SRC_DIR
    RUN npm run test:prod 
    

前面的脚本将从builder阶段复制生产构建,并以可预测的方式执行你的测试脚本。

Web 服务器

第三个也是最后一个阶段生成将成为你的 Web 服务器的容器。一旦这个阶段完成,前面的阶段将被丢弃,最终结果将是一个优化后的小于 10 MB 的容器:

  1. 在文件末尾追加以下FROM语句来构建 Web 服务器,但这次,从builder复制生产就绪代码,如下代码片段所示:

    **integration.Dockerfile**
    ...
    FROM duluca/minimal-nginx-web-server:1-alpine as webserver
    ENV BUILDER_SRC_DIR=/usr/src
    COPY --from=builder $BUILDER_SRC_DIR/dist/lemon-mart /var/www
    CMD 'nginx' 
    
  2. 构建和测试你的多阶段Dockerfile

    $ docker build -f integration.Dockerfile . 
    

    根据你的操作系统,你可能会看到终端错误。只要 Docker 镜像最终成功构建,你就可以安全地忽略这些错误。为了参考,当我们稍后在 CircleCI 上构建此镜像时,CI 服务器上没有记录任何错误。

  3. 将你的脚本保存为一个新的 npm 脚本,命名为build:integration,如下所示:

    **package.json**
    "scripts": {
    ...
      "build:integration": "cross-conf-env docker image build -f integration.Dockerfile . -t $npm_package_config_imageRepo:latest",
    ...
    } 
    

伟大的工作!你已经定义了一个自定义的构建和测试环境。让我们如下可视化我们的努力结果:

图 9.18:多阶段构建环境结果

通过利用多阶段Dockerfile,我们可以定义一个定制的构建环境,并在过程结束时只传输必要的字节。在先前的例子中,我们避免了将 250+ MB 的开发依赖项传输到我们的生产服务器,并且只交付了一个 7 MB 的容器,它具有最小的内存占用。

现在,让我们在 CircleCI 上执行这个容器化流水线。

CircleCI 容器内容器

第四章自动化测试、持续集成和发布到生产中,我们创建了一个相对简单的 CircleCI 文件。稍后,我们也将为这个项目重复相同的配置,但现在,我们将使用一个容器内的容器设置,利用我们刚刚创建的多阶段Dockerfile

lemon-mart 仓库中,本节使用的 config.yml 文件名为 .circleci/config.docker-integration.yml。您还可以在 CircleCI 上找到从本章执行 .yml 文件的拉取请求,使用 docker-integration 分支,链接为 github.com/duluca/lemon-mart/pull/25

注意,此构建使用修改后的 integration.Dockerfile 来使用来自 lemon-martprojects/ch8 代码。

在您的源代码中,创建一个名为 .circleci 的文件夹,并添加一个名为 config.yml 的文件:

**.circleci/config.yml**
version: 2.1
jobs:
  build:
    docker:
      - image: circleci/node:lts
    working_directory: ~/repo
    steps:
      - checkout
      - setup_remote_docker
      - run:
          name: Execute Pipeline (Build Source -> Test -> Build Web Server)
          command: |
            docker build -f integration.Dockerfile . -t lemon-mart:$CIRCLE_BRANCH
            mkdir -p docker-cache
            docker save lemon-mart:$CIRCLE_BRANCH | gzip > docker-cache/built-image.tar.gz
      - save_cache:
          key: built-image-{{ .BuildNum }}
          paths:
            - docker-cache
      - store_artifacts:
          path: docker-cache/built-image.tar.gz
          destination: built-image.tar.gz
workflows:
  version: 2
  build-and-deploy:
    jobs:
      - build 

在先前的 config.yml 文件中,定义了一个名为 build-and-deploy 的工作流程,其中包含一个名为 build 的作业。该作业使用 CircleCI 预构建的 circleci/node:lts 图像。

build 任务有五个步骤:

  1. checkout 从 GitHub 检出源代码。

  2. setup_remote_docker 通知 CircleCI 设置 Docker-within-Docker 环境,这样我们就可以在我们的流水线中运行容器。

  3. run 执行 docker build -f integration.Dockerfile . 命令以启动我们的自定义构建过程,将基于 Alpine 的图像缓存,并用 $CIRCLE_BRANCH. 标记它。

  4. save_cache 保存我们在缓存中创建的图像,以便在下一步中消费。

  5. store_artifacts 从缓存中读取创建的图像,并将其作为构建工件发布,可以从 Web 界面下载或由另一个作业用于将其部署到云环境中。

在您将更改同步到 GitHub 后,如果一切顺利,您将有一个通过 绿色 构建的结果。如图所示,此构建是成功的:

图片

图 9.19:使用 lemon-mart docker-integration 分支在 CircleCI 上进行的绿色构建

注意,压缩并 gzip 的图像文件大小为 9.2 MB,其中包括我们的 Web 应用程序,以及大约 7 MB 的基础镜像大小。

目前,CI 服务器正在运行并执行我们的三步流水线。正如您在前面的屏幕截图中所见,构建正在生成一个名为 built-image.tar.gz 的压缩文件,其中包含 Web 服务器图像。您可以从 Artifacts 选项卡下载此文件。然而,我们并没有将生成的图像部署到服务器上。

您现在已经充分掌握了使用 CircleCI 的技能。我们将在第十三章,AWS 上的高可用云基础设施中重新访问这个多阶段 Dockerfile 以在 AWS 上执行部署。

接下来,让我们看看您如何从您的 Angular 应用程序中获取代码覆盖率报告,并在 CircleCI 中记录结果。

代码覆盖率报告

了解您的 Angular 项目的单元测试覆盖量和趋势的一个好方法是查看代码覆盖率报告。

为了为您的应用程序生成报告,请从您的 project 文件夹中执行以下命令:

$ npx ng test --browsers ChromiumNoSandbox --watch=false --code-coverage 

生成的报告将作为一个名为 coverage 的文件夹下的 HTML 文件创建;执行以下命令以在浏览器中查看它:

$ npx http-server -c-1 -o -p 9875 ./coverage 

在您的项目中安装 http-server 作为开发依赖项。

这是 istanbul/nyc 为 LemonMart 生成的文件夹级样本覆盖率报告:

图片

图 9.20:LemonMart 的 Istanbul 代码覆盖率报告

您可以针对特定文件夹,例如 src/app/auth,进行深入分析,并获取文件级报告,如下所示:

图片

图 9.21:src/app/auth 的 Istanbul 代码覆盖率报告

您可以进一步深入以获取特定文件的行级覆盖率,例如 cache.service.ts,如下所示:

图片

图 9.22:cache.service.ts 的 Istanbul 代码覆盖率报告

在前面的屏幕截图中,您可以看到行 51217-1821-22 没有被任何测试覆盖。I 图标表示 if 路径没有被采取。我们可以通过实现单元测试来增加我们的代码覆盖率,这些单元测试将测试 CacheService 中包含的函数。作为练习,读者应尝试至少用一个新的单元测试覆盖这些函数之一,并观察代码覆盖率报告的变化。

CI 中的代码覆盖率

理想情况下,您的 CI 服务器配置应在每次测试运行时生成和托管代码覆盖率报告。然后,您可以使用代码覆盖率作为另一个代码质量关卡,防止合并拉取请求,如果新代码降低了整体代码覆盖率百分比。这是一种加强 测试驱动开发TDD)思维的好方法。

您可以使用 Coveralls 等服务(位于 coveralls.io/)来实现代码覆盖率检查,这些服务可以直接在 GitHub 拉取请求中嵌入您的代码覆盖率级别。

让我们为 LemonMart 配置 Coveralls:

lemon-mart 仓库中,本节 config.yml 文件命名为 .circleci/config.ch9.yml

  1. 在您的 CircleCI 账户设置中,在安全部分确保您允许执行未经认证/未签名的 orb。

  2. coveralls.io/ 注册您的 GitHub 项目。

  3. 复制仓库令牌并将其存储为 CircleCI 中的环境变量,名称为 COVERALLS_REPO_TOKEN

  4. 在进行任何代码更改之前,请创建一个新的分支。

  5. 更新 karma.conf.js 以使其在 coverage 文件夹下存储代码覆盖率结果:

    **karma.conf.js**
    ...
        coverageIstanbulReporter: {
          dir: require('path').join(__dirname, **'coverage'**),
          reports: ['html', 'lcovonly'],
          fixWebpackSourcePaths: true,
        },
    ... 
    
  6. 使用 Coveralls orb 更新 .circleci/config.yml 文件,如下所示:

    **.circleci/config.yml**
    version: 2.1
    orbs:
      **coveralls: coveralls/coveralls@1.0.4** 
    
  7. 更新 build 作业以存储代码覆盖率结果并将其上传到 Coveralls:

    **.circleci/config.yml**
    jobs:
      build:
        ...
          - run: npm test -- --watch=false --code-coverage
          - run: npm run e2e
          - 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 
    

    注意,orb 会自动为您配置 Coveralls 账户,因此 coveralls/upload 命令可以上传您的代码覆盖率结果。

  8. 将您的更改提交到分支并发布。

  9. 使用分支在 GitHub 上创建拉取请求。

  10. 在拉取请求上,验证您是否可以看到 Coveralls 正在报告您的项目代码覆盖率,如图所示:图片

    图 9.23:Coveralls 报告代码覆盖率

  11. 将拉取请求合并到您的 master 分支。

恭喜!现在,你可以修改你的分支保护规则,要求在合并到主分支之前,代码覆盖率必须高于一定百分比。

LemonMart 项目在 github.com/duluca/lemon-mart 中实现了一个功能齐全的 config.yml 文件。此文件还在 CircleCI 中实现了 Cypress.io,与 Angular 的 e2e 工具相比,这是一个更加健壮的解决方案。Cypress orb 可以记录测试结果,并允许你在 CircleCI 管道中查看它们。

利用本章所学,你可以将 LocalCast Weather 的 deploy 脚本集成到 LemonMart 中,并实现门控部署工作流程。

摘要

在本章中,你学习了 DevOps 和 Docker。你将你的 Web 应用程序容器化,使用 CLI 工具将容器部署到 Google Cloud Run,并学习了如何实现门控 CI 工作流程。你利用高级 CI 技术构建了一个基于多阶段 Dockerfile 的 CI 环境。你还熟悉了 orbs、工作流程和代码覆盖率工具。

我们利用 CircleCI 作为基于云的 CI 服务,并强调了你可以将构建结果部署到所有主要的云托管提供商。你已经看到了如何实现 CD。我们介绍了通过 CircleCI 到 Vercel Now 和 Google Cloud Run 的示例部署,让你能够实现自动部署。

通过一个健壮的 CI/CD 管道,你可以与客户和团队成员分享你应用的每个迭代,并快速将错误修复或新功能交付给最终用户。

练习

  1. 将 CircleCI 和 Coveralls 徽章添加到你的代码仓库中的 README.md 文件。

  2. 为端到端测试实现 Cypress,并在你的 CircleCI 管道中使用 Cypress orb 运行它。

  3. 实现 Lemon Mart 应用程序的 Vercel Now 部署和条件工作流程。你可以在 lemon-mart 仓库中找到生成的 config.yml 文件,命名为 .circleci/config.ch9.yml

进一步阅读

问题

尽可能地回答以下问题,以确保你在不使用谷歌搜索的情况下理解了本章的关键概念。你需要帮助回答这些问题吗?请参阅附录 D自我评估答案,在线访问static.packt-cdn.com/downloads/9781838648800_Appendix_D_Self-Assessment_Answers.pdf或访问expertlysimple.io/angular-self-assessment

  1. 解释 Docker 镜像和 Docker 容器之间的区别。

  2. CD 管道的目的是什么?

  3. CD 的好处是什么?

  4. 我们如何覆盖配置差距?

  5. CircleCI orb 的功能是什么?

  6. 使用多阶段Dockerfile的好处是什么?

  7. 代码覆盖率报告如何帮助维护你应用程序的质量?

第十章:RESTful API 和全栈实现

第一章Angular 简介及其概念 中,我向你介绍了网络应用程序存在的更广泛的架构。在全栈架构中做出的选择可能会深刻影响你网络应用程序的成功。你绝对不能忽视你交互的 API 是如何设计的。在本章中,我们将介绍如何使用 Node、Express 和 Mongo 为你的前端实现后端。结合 Angular,这个软件堆栈被称为 MEAN 堆栈。

我对 MEAN 堆栈的看法是“最小化 MEAN”,它优先考虑易用性、幸福感和效率,这是优秀 开发者体验DX)的主要成分。为了保持主题一致,我们将实现 LemonMart 服务器。这个服务器将完善来自 第八章设计认证和授权 的 JWT 认证。此外,服务器还将支持我在 第十一章食谱 – 可重用性、路由和缓存第十二章食谱 – 主/详细、数据表和 NgRx 中将要介绍的功能。

本章涵盖了大量的内容。它旨在作为 GitHub 仓库 (github.com/duluca/lemon-mart-server) 的路线图。我涵盖了实现架构、设计和主要组件。我强调了一些重要的代码片段来解释解决方案是如何组合在一起的。然而,与前面的章节不同,你不能仅仅依靠文本中提供的代码示例来完成你的实现。对于本书的目的,理解我们为什么要实现各种功能比掌握实现细节更为重要。因此,对于这一章,我建议你阅读并理解服务器代码,而不是试图自己重新创建它。

你需要在章节的末尾采取行动,在你的 Angular 应用中实现一个自定义认证提供者,以对 lemon-mart-server 进行认证,并利用 Postman 生成测试数据,这在后面的章节中将会很有用。

我们首先介绍全栈架构,涵盖 lemon-mart-server 的单仓库设计以及如何使用 Docker Compose 运行一个包含 Web 应用、服务器和数据库的三层应用程序。然后,我们介绍 RESTful API 设计和文档,利用 OpenAPI 规范通过 Swagger.io 和使用 Express.js 的实现。然后,我们介绍使用我的 DocumentTS 库实现 MongoDB 的 对象文档映射器ODM),用于存储带有登录凭证的用户。我们实现了一个基于令牌的认证功能,并使用它来保护我们的 API。最后,我们利用 Postman 使用我们开发的 API 在数据库中生成测试数据。

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

  • 全栈架构

  • Docker Compose

  • RESTful API

  • MongoDB ODM with DocumentTS

  • 实现 JWT 认证

  • 使用 Postman 生成用户

书籍示例代码的最新版本可以在以下链接的 GitHub 仓库中找到。该仓库包含代码的最终和完成状态。本章需要 Docker 和 Postman 应用程序。

在您的开发环境中运行lemon-mart-server并使其与lemon-mart通信是至关重要的。请参阅此处或 GitHub 上的README中的说明以启动您的服务器。

对于本章的情况:

  1. 使用--recurse-submodules选项克隆lemon-mart-server仓库:git clone --recurse-submodules github.com/duluca/lemon-mart-server

  2. 在 VS Code 终端中,执行cd web-app; git checkout master以确保来自github.com/duluca/lemon-mart的子模块位于 master 分支。

    在后面的Git 子模块部分,您可以配置web-app文件夹以从您的 lemon-mart 服务器拉取。

  3. 在根目录下执行npm install以安装依赖项。

    注意,在根目录下运行npm install命令会触发一个脚本,该脚本还会在serverweb-app文件夹下安装依赖项。

  4. 在根目录下执行npm run init:env以配置.env文件中的环境变量。

    此命令将在根目录和server文件夹下创建两个.env文件,用于包含您的私有配置信息。初始文件基于example.env文件生成。您可以在以后修改这些文件并设置自己的安全密钥。

  5. 在根目录下执行npm run build以构建服务器和 Web 应用。

    注意,该 Web 应用是使用名为--configuration=lemon-mart-server的新配置构建的,它使用src/environments/environment.lemon-mart-server.ts

  6. 执行docker-compose up --build以运行服务器、Web 应用和 MongoDB 数据库的容器化版本。

    注意,Web 应用使用名为nginx.Dockerfile的新文件进行容器化。

  7. 导航到http://localhost:8080以查看 Web 应用。

    登录时,单击填写按钮以使用默认的演示凭据填充电子邮件和密码字段。

  8. 导航到http://localhost:3000以查看服务器登录页面。

  9. 导航到http://localhost:3000/api-docs以查看交互式 API 文档。

  10. 您可以使用npm run start:database仅启动数据库,并在server文件夹下使用npm start进行调试。

  11. 您可以使用npm run start:backend仅启动数据库和服务器,并在web-app文件夹下使用npm start进行调试。

对于本章中基于lemon-mart的示例:

  1. 克隆仓库:github.com/duluca/lemon-mart

  2. 在根目录下执行npm install以安装依赖项。

  3. 此章节的代码示例位于子文件夹下:

    projects/ch10 
    
  4. 要为此章节运行 Angular 应用程序,请执行以下命令:

    npx ng serve ch10 
    
  5. 要为此章节运行 Angular 单元测试,请执行以下命令:

    npx ng test ch10 --watch=false 
    
  6. 要为此章节运行 Angular e2e 测试,请执行以下命令:

    npx ng e2e ch10 
    
  7. 要为此章节构建一个生产就绪的 Angular 应用程序,请执行以下命令:

    npx ng build ch10 --prod 
    

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

注意,书中或 GitHub 上的源代码可能并不总是与 Angular CLI 生成的代码匹配。由于生态系统不断演变,书中代码与 GitHub 上代码之间的实现也可能存在细微差异。随着时间的推移,示例代码发生变化是自然的。此外,在 GitHub 上,你可能会找到更正、修复以支持库的新版本,或者为读者观察而并排实现多种技术的示例。读者只需实现书中推荐的理想解决方案即可。如果你发现错误或有疑问,请为所有读者创建一个 GitHub 问题或提交一个 pull request。

你可以在附录 C“保持 Angular 和工具常青”中了解更多关于更新 Angular 的信息。你可以在网上从static.packt-cdn.com/downloads/9781838648800_Appendix_C_Keeping_Angular_and_Tools_Evergreen.pdfexpertlysimple.io/stay-evergreen找到这个附录。

当你的 LemonMart 服务器运行起来后,我们就可以探索 MEAN 栈的架构了。到本节结束时,你应该有自己的 LemonMart 版本与服务器通信。

全栈架构

全栈指的是使应用程序工作的整个软件栈,从数据库到服务器、API 以及利用它们的 Web 和/或移动应用程序。传说中的全栈开发者无所不知,可以轻松地在职业的各个垂直领域操作。在所有与软件相关的事物中专业化,并被认为是每个给定主题的专家,几乎是不可能的。然而,要被认为是某个主题的专家,你也必须对相关主题有深入的了解。在学习新主题时,保持你的工具和语言一致非常有帮助,这样你就可以在没有额外噪音的情况下吸收新信息。因此,我选择用 Java 或 C#使用 ASP.NET 介绍你使用 MEAN 栈而不是 Spring Boot。通过坚持使用熟悉的工具和语言,如 TypeScript、VS Code、npm、GitHub、Jasmine、Docker 和 CircleCI,你可以更好地理解全栈实现是如何结合在一起的,并因此成为一个更好的 Web 开发者。

最小化 MEAN

为你的项目选择正确的-Stack™ 是困难的。首先,你的技术架构应该足够满足业务需求。例如,如果你试图使用 Node.js 实现人工智能项目,你很可能会选择错误的堆栈。我们的重点是交付网络应用,但除此之外,我们还有其他参数要考虑,包括以下内容:

  • 易用性

  • 幸福

  • 效率

如果你的开发团队将长时间在你的应用程序上工作,那么考虑兼容性以外的因素非常重要。你的堆栈、工具选择和编码风格如果使代码库易于使用,让你的开发者感到快乐,或者让他们觉得自己是项目的有效贡献者,那么这些都会产生重大影响。

一个配置良好的堆栈对于优秀的开发体验至关重要。这可能是干燥的煎饼堆和带有适量黄油和糖浆的精美小堆之间的区别。

通过引入过多的库和依赖项,你可以减慢你的进度,使你的代码难以维护,并发现自己陷入引入更多库以解决其他库问题的反馈循环。赢得这场游戏的唯一方法就是简单地不玩。

如果你花时间学习如何使用几个基本的库,你可以成为一个更有效的开发者。本质上,你可以用更少的资源做更多的事情。我的建议是:

  • 在编写代码之前先思考,并应用 80-20 规则。

  • 等待库和工具成熟,跳过测试版

  • 通过减少对新包和工具的贪婪,掌握基础知识来实现快速

    在 YouTube 上观看我 2017 年 Ng 会议的演讲,标题为 用更少的 JavaScript 做更多的事情,链接为 www.youtube.com/watch?v=Sd1aM8181kc

这种简约思维是 minimal MEAN 的设计哲学。你可以在 GitHub 上查看参考实现 github.com/duluca/minimal-mean。参考以下图表了解整体架构:

图 10.1:Minimal MEAN 软件堆栈和工具

让我们逐个介绍架构的组件。

Angular

Angular 是表现层。Angular 是一个能够和可靠的开发平台。它被广泛理解,拥有一个伟大的社区。在考虑其他选项之前,你绝对应该花时间掌握 Angular 的基础知识。

如 Angular Material、Angular Evergreen 和 angular-unit-test-helper 这样的库可以帮助你以最小的努力提供最佳和外观出色的解决方案。

你可以使用最小的 Docker 容器 duluca/minimal-nginx-web-serverduluca/minimal-node-web-server 来容器化你的 Angular(或任何其他网络应用)。

Express

Express.js 将成为我们的 API 层。Express 是一个快速、无偏见且极简的 Node.js 网络框架。Express 拥有庞大的插件生态系统,几乎可以满足所有需求。在 Minimal MEAN 中,我们只利用了两个包:

  • cors: 配置跨源资源共享设置

  • morgan: 用于记录 HTTP 请求

此外,我们使用 express 解析器解析传入的 HTTP 请求中的req.body,并使用express.static函数来提供public文件夹的内容。

你可以在expressjs.com/了解更多关于 Express.js 的信息。

Node

Express.js 运行在 Node.js 上。我们将使用 Node 实现业务层。Node 是一个轻量级且高效的 JavaScript 运行时,使用事件驱动、非阻塞 I/O 模型,使其适用于高性能和实时应用。Node 可以在任何地方运行,从冰箱到智能手表。你可以通过使用 TypeScript 开发应用程序来提高 Node 应用程序的可靠性。

有关非阻塞 I/O 的更深入解释,请参阅 Frank Rosner 的博客文章blog.codecentric.de/en/2019/04/explain-non-blocking-i-o-like-im-five/

在本章的后面部分,你将学习如何使用 TypeScript 配置 Node 项目。

Mongo

MongoDB 代表持久层。MongoDB 是一个面向文档的数据库,具有动态的类似 JSON 的架构。使用基于 JSON 的数据库的主要好处是你不需要将数据从一种格式转换为另一种格式。你可以仅使用 JSON 检索、显示、编辑和更新数据。

此外,Node 的 MongoDB 原生驱动程序成熟、性能良好且功能强大。我开发了一个名为document-ts的库,旨在通过引入易于编码的丰富文档对象来简化与 MongoDB 的交互。DocumentTS 是一个非常薄的基于 TypeScript 的 MongoDB 辅助工具,具有可选的丰富 ODM 便利功能。

你可以在www.mongodb.com/了解更多关于 MongoDB 的信息,以及 DocumentTS 库的github.com/duluca/document-ts

工具

支持你开发的技术工具与你的软件栈选择一样重要。Minimal MEAN 利用以下工具:

  • VS Code: 优秀的扩展支持,轻量级、快速且跨平台

  • TypeScript: 快速且易于使用的转译器,具有使用 tslint 的出色 lint 支持

  • Npm: 多平台脚本和依赖管理,拥有丰富的包生态系统

  • GitHub: 灵活、免费且支持良好的 Git 托管服务。GitHub 流程与 CI 服务器协同,实现门控代码检查

  • Docker: 一种轻量级虚拟化技术,封装了你的环境配置和设置

  • 持续集成(CI): 确保代码交付质量的关键

  • Jasmine:包含电池的单元测试框架,与 nyc/istanbul.js 一起工作以提供代码覆盖率指标

注意,我们使用的工具和选择的语言与用于 Angular 开发的工具和语言相同。这使得开发者能够在前端和后端开发之间进行最小化的上下文切换。

现在我们已经涵盖了交付最小 MEAN 堆栈应用的所有主要组件和工具,让我们首先创建一个 Git 仓库,用于存放我们的前端和后端代码。

配置 monorepo

您可以通过创建包含您前端和后端代码的 monorepo 来优化您的开发体验。monorepo 允许开发者能够在同一 IDE 窗口内跳转项目。开发者可以更容易地在项目之间引用代码,例如在前端和后端之间共享 TypeScript 接口,从而确保数据对象每次都保持一致。CI 服务器可以一次性构建所有项目,以确保全栈应用的所有组件都处于正常工作状态。

注意,monorepo 与 VS Code 中的多根工作区不同,在多根工作区中,您可以将多个项目添加到同一 IDE 窗口中。monorepo 在源代码控制级别组合项目。有关多根工作区的更多信息,请参阅code.visualstudio.com/docs/editor/multi-root-workspaces

让我们快速浏览一下代码库。

Monorepo 结构

lemon-mart-server项目中,您将拥有三个主要文件夹,如下所示:

lemon-mart-server
├───bin
├───web-app (default Angular setup)
├───server
│   ├───src
│   │   ├───models
│   │   ├───public
│   │   ├───services
│   │   ├───v1
│   │   │   └───routes
│   │   └───v2
│   │       └───routes
│   └───tests
|   package.json
|   README.md 

bin文件夹包含辅助脚本或工具,web-app文件夹代表您的前端,而server包含后端源代码。在我们的案例中,web-app文件夹是lemon-mart项目。我们不是复制粘贴现有项目的代码,而是利用 Git 子模块将两个仓库链接在一起。

Git 子模块

Git 子模块帮助您在多个仓库之间共享代码,同时保持提交的分离。前端开发者可能选择仅使用前端仓库进行工作,而全栈开发者将更喜欢访问所有代码。Git 子模块还为现有项目的组合提供了一个方便的方法。

首先,让我们看看您如何将您自己的lemon-mart项目作为lemon-mart-server的子模块添加,利用位于我们 monorepo 根目录package.json文件中的脚本:

我建议您在从 GitHub 克隆的lemon-mart-server版本上执行此操作。否则,您将需要创建一个新的项目并执行npm init -y以开始操作。

  1. 观察以下package.json脚本,这些脚本有助于初始化、更新和清理 Git 子模块:

    **package.json**
      "config": {
      ...
        "webAppGitUrl": "https://github.com/duluca/lemon-mart.git"
      },
      "scripts": {
        "webapp:clean": "cross-conf-env rimraf web-app && git rm -r --cached web-app",
        "webapp:init": "cross-conf-env git submodule add $npm_package_config_webAppGitUrl web-app",
        "postwebapp:init": "git submodule status web-app",
        "modules:init": "git submodule update --init --recursive",
        "modules:update": "git submodule update --recursive --remote"
      }, 
    
  2. webAppGitUrl更新为您自己的项目的 URL。

  3. 执行webapp:clean以删除现有的web-app文件夹。

  4. 最后,执行 webapp:init 命令以在 web-app 文件夹中初始化你的项目:

    $ npm run webapp:init 
    

接下来,执行 modules:update 命令以更新子模块中的代码。在另一个环境中克隆仓库后,要拉取子模块,请执行 npm modules:init。如果你需要重置环境并重新启动,请执行 webapp:clean 以清理 Git 缓存并删除文件夹。

注意,你的仓库中可以有多个子模块。modules:update 命令将更新所有子模块。

你的 Web 应用程序代码现在位于名为 web-app 的文件夹中。此外,你应该能够在 VS Code 的 源代码管理 面板中看到这两个项目,如图所示:

图 10.2:VS Code 源代码管理提供者

使用 VS Code 的源代码管理,你可以独立地对任一仓库执行 Git 操作。

如果你的子模块出现混乱,只需在子模块目录中执行 cd 并运行 git pull,然后执行 git checkout master 以恢复主分支。使用此技术,你可以从项目中的任何分支检出并提交 PR。

现在我们已经准备好了子模块,让我们看看服务器项目是如何配置的。

使用 TypeScript 配置 Node 项目

要使用 TypeScript 创建新的 Node.js 应用程序,请执行以下步骤:

以下步骤仅适用于你正在创建新的服务器项目。我建议你使用从 GitHub 克隆的 lemon-mart-server 项目中提供的现有一个。

  1. 创建子文件夹 server

    $ mkdir server 
    
  2. 将当前目录更改为 server 文件夹:

    $ cd server 
    
  3. 初始化 npm 以在 server 文件夹中设置 package.json

    $ npm init -y 
    

    注意,顶级 package.json 将用于与全栈项目相关的脚本。server/package.json 将包含后端项目的脚本和依赖项。

  4. 使用 mrm-task-typescript-vscode 配置你的仓库:

    $ npm i -g mrm-task-typescript-vscode
    $ npx mrm typescript-vscode 
    

mrm 任务配置 VS Code 以获得优化的 TypeScript 开发体验,类似于我们在 第二章设置开发环境 中使用 mrm-task-angular-vscode 所做的那样。

命令执行完毕后,project 文件夹将如所示出现:

server
│   .gitignore
│   .nycrc
│   .prettierignore
│   .prettierrc
│   example.env
│   jasmine.json
│   package-lock.json
│   package.json
│   pull_request_template.md
│   tsconfig.json
│   tsconfig.src.json
│   tslint.json
│
├───.vscode
│       extensions.json
│       launch.json
│       settings.json
│
├───src
│       index.ts
│
└───tests
│       index.spec.ts
│       tsconfig.spec.json 

此任务配置以下内容:

  • 常用 npm 脚本包:cross-conf-env (www.npmjs.com/package/cross-conf-env)、npm-run-all (www.npmjs.com/package/npm-run-all)、dev-norms (www.npmjs.com/package/dev-norms) 和 rimraf (www.npmjs.com/package/rimraf)

  • Npm 脚本用于样式、代码检查、构建和测试:

    • stylelint:检查代码样式和代码检查错误是否符合规范。它们主要用于 CI 服务器使用。

    • style:fixlint:fix: 将代码样式和检查规则应用到代码中。并非所有检查错误都可以自动修复。您需要手动解决每个错误。

    • build: 将代码转换为 dist 文件夹。

    • start: 在 Node.js 中运行转换后的代码。

    prepublishOnlyprepare 脚本仅在您开发 npm 包时相关。在这种情况下,您还应该实现一个 .npmignore 文件,该文件排除了 srctests 文件夹。

  • ImportSort: 维护 import 语句的顺序:

    • 将设置添加到 package.json

    • 支持的 npm 包已安装:import-sort、import-sort-cli、import-sort-parser-typescript 和 import-sort-style-module

  • 使用 TypeScript 和 tslint:

    • tsconfig.json: 常见的 TypeScript 设置

    • tsconfig.src.json: 适用于 src 文件夹下源代码的特定设置

    • tslint.json: 检查规则

  • 自动格式化我们代码样式的 Prettier 插件:

    • .prettierrc: Prettier 设置

    • .prettierignore: 忽略的文件

  • Jasmine 和 nyc 用于单元测试和代码覆盖率:

    • jasmine.json: 测试设置。

    • .nycrc: 代码覆盖率设置。

    • tests 文件夹:包含 spec.ts 文件,其中包含您的测试和 tsconfig.spec.json,它配置了更宽松的设置,使快速编写测试变得更容易。

    • package.json 中:创建测试脚本以使用 build:test 构建测试并使用 npm test 执行它们。test:ci 命令旨在用于 CI 服务器,而 test:nyc 提供代码覆盖率报告。

  • example.env:用于记录在您的私有 .env 文件中存在的必需环境变量

    • .env 已添加到 .gitignore
  • PR 模板:一个请求开发者提供额外信息的拉取请求模板

  • VS Code 扩展、设置和调试配置分别在三份文件中:

    • .vscode/extensions.json

    • .vscode/settings.json

    • .vscode/launch.json

一旦您熟悉了项目引入的更改,请验证您的项目是否处于正常工作状态。

通过执行测试来验证项目:

$ npm test 

在运行 test 命令之前,执行 npm run build && npm run build:test 以将我们的 TypeScript 代码转换为 JavaScript。输出放置在 dist 文件夹中,如下所示:

server
│
├───dist
│       index.js
│       index.js.map 

注意,在您的文件系统中,.js.js.map 文件与每个 .ts 文件一起创建。在 .vscode/settings.json 中,我们配置了 files.exclude 属性,以在 IDE 中隐藏这些文件,这样它们就不会在开发期间分散开发者的注意力。此外,在 .gitignore 中,我们也忽略了 .js.js.map 文件,这样它们就不会被提交到我们的仓库中。

现在我们有了基本的单仓库,我们可以配置我们的持续集成服务器。

CircleCI 配置

使用 Git 子模块的一个好处是我们可以验证我们的前端和后端是否在相同的管道中工作。我们将实现两个任务:

  1. build_server

  2. build_webapp

这些任务将遵循此处显示的工作流程:

**.circleci/config.yml**
...
workflows:
  version: 2
  build-and-test-compose:
    jobs:
      - build_server
      - build_webapp 

CI 管道将同时构建服务器和 Web 应用程序,如果主分支上的作业成功,可以选择运行deploy作业。有关如何在 GitHub 上的config.yml文件中实现build_webapp作业的说明,请参阅第九章使用 Docker 的 DevOps,该作业与您在第九章中实现的类似,但包括一些细微的差异,以处理与子模块和文件夹结构变化的工作。构建服务器的管道与 Web 应用程序的管道不太相似,如下所示:

**.circleci/config.yml**
version: 2.1
orbs:
  coveralls: coveralls/coveralls@1.0.4
jobs:
  build_server:
    docker:
      - image: circleci/node:lts
    working_directory: ~/repo/server
    steps:
      - checkout:
          path: ~/repo
      - restore_cache:
          keys:
            - web-modules-{{ checksum "package-lock.json" }}
      # check npm dependencies for security risks - 'npm audit' to fix
      - run: npx audit-ci --high --report-type full
      - run: npm ci
      - save_cache:
          key: web-modules-{{ checksum "package-lock.json" }}
          paths:
            - ~/.npm
      - run: npm run style
      - run: npm run lint
      # run tests and store test results
      - run: npm run pretest
      - run: npm run test:ci
      - store_test_results:
          path: ./test_results
      # run code coverage and store coverage report
      - run: npm run test:nyc
      - store_artifacts:
          path: ./coverage
      - coveralls/upload
      - run:
          name: Move compiled app to workspace
          command: |
            set -exu
            mkdir -p /tmp/workspace/server
            mv dist /tmp/workspace/server
      - persist_to_workspace:
          root: /tmp/workspace
          paths:
            - server 

管道会检出代码,使用audit-ci验证我们使用的软件包的安全性,安装依赖项,检查样式和 linting 错误,运行测试,并检查代码覆盖率水平。

测试命令隐式构建服务器代码,该代码存储在dist文件夹下。在最后一步,我们将dist文件夹移动到工作区,以便我们可以在以后使用它。

接下来,让我们看看我们如何将应用程序的所有层组合在一起,并使用 Docker Compose 运行它。

Docker Compose

由于我们有一个三层架构,我们需要一种方便的方式来设置全栈应用程序的基础设施。您可以创建脚本来单独启动各种 Docker 容器,但有一个专门用于运行多容器应用程序的工具,称为 Docker Compose。Compose 使用名为docker-compose.yml的 YAML 文件格式,因此您可以声明性地定义应用程序的配置。Compose 允许您遵循基础设施即代码的原则。Compose 还将使我们能够方便地启动数据库实例,而无需在我们的开发环境中安装永久性和始终开启的数据库解决方案。

您可以使用 Compose 在云服务上部署应用程序,调整您正在运行的容器实例数量,甚至可以在您的 CI 服务器上运行应用程序的集成测试。在本节后面的内容中,我们将介绍如何在 CircleCI 上运行 Docker Compose。

考虑以下应用程序的架构以及每一层的通信端口:

图 10.3:Lemon Mart 三层架构

使用 Docker Compose,我们能够精确地描述这里显示的架构。您可以在docs.docker.com/compose/了解更多关于 Compose 的信息。

接下来,让我们为 Lemon Mart 实现一个更高效的 Web 服务器。

使用 Nginx 作为 Web 服务器

我们的 Web 应用程序已经在第九章使用 Docker 的 DevOps中进行了容器化。对于这个练习,我们将使用基于 nginx 的容器。

web-app的根目录下添加一个名为nginx.Dockerfile的新 Dockerfile。这个镜像将比我们已有的基于 Node 的镜像小,因为我们使用 nginx 作为 Web 服务器:

**web-app/nginx.Dockerfile**
FROM duluca/minimal-nginx-web-server:1-alpine
COPY dist/lemon-mart /var/www
CMD 'nginx' 

现在,让我们将我们的服务器容器化。

容器化服务器

到目前为止,我们主要使用预配置的 Docker 镜像来部署我们的 Web 应用程序。以下是基于 Node.js 的服务器更详细实现的示例:

如果你需要,可以参考第九章使用 Docker 的 DevOps中的使用 Docker 容器化应用程序部分,作为 Docker 的复习。

  1. 让我们先定义Dockerfile

    **server/Dockerfile**
    FROM node:lts-alpine
    RUN apk add --update --no-progress make python bash
    ENV NPM_CONFIG_LOGLEVEL error
    ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.2/dumb-init_1.2.2_amd64 /usr/local/bin/dumb-init
    RUN chmod +x /usr/local/bin/dumb-init
    RUN mkdir -p /usr/src/app
    RUN chown node: /usr/src/app
    USER node
    WORKDIR /usr/src/app
    COPY package*.json ./
    RUN NODE_ENV=production
    RUN npm install --only=production
    ENV HOST "0.0.0.0"
    ENV PORT 3000
    EXPOSE 3000
    ADD dist dist
    ENTRYPOINT ["dumb-init", "--"]
    CMD ["node", "dist/src/index"] 
    

    注意,我们将dist文件夹添加到我们的服务器中,然后使用 nodes 运行它。

    你可以通过查看github.com/duluca/minimal-node-web-server上类似配置的minimal-node-web-server仓库中的README.md来了解更多关于我们服务器容器配置的信息。

    现在,设置跨环境的npm 脚本用于 Docker,它适用于 Windows 10 和 macOS 上的我们的服务器。

  2. 安装 Docker 任务的 npm 脚本:

    $ npm i -g mrm-task-npm-docker 
    
  3. 应用 Docker 配置的 npm 脚本,确保在server文件夹中执行命令:

    $ npx mrm npm-docker 
    
  4. 使用配置参数配置你的package.json

    **server/package.json**
      "config": {
        "imageRepo": "duluca/lemon-mart-server",
        "imageName": "lemon-mart-server",
        "imagePort": "3000",
        "internalContainerPort": "3000"
      } 
    

在构建 Docker 容器之前,请确保构建你的应用程序。

使用 DotEnv 配置环境变量

DotEnv 文件广泛支持,方便地将密钥存储在未提交到代码仓库的.env文件中。Docker 和 Compose 原生支持.env文件。

首先,让我们了解单一代码库核心的环境变量:

  1. 参考项目根目录下的example.env文件:

    **example.env**
    # Root database admin credentials
    MONGO_INITDB_ROOT_USERNAME=admin
    MONGO_INITDB_ROOT_PASSWORD=anAdminPasswordThatIsNotThis
    # Your application's database connection information. 
    # Corresponds to MONGO_URI on server-example.env
    MONGODB_APPLICATION_DATABASE=lemon-mart
    MONGODB_APPLICATION_USER=john.smith
    MONGODB_APPLICATION_PASS=g00fy
    # Needed for AWS deployments
    AWS_ACCESS_KEY_ID=xxxxxx
    AWS_SECRET_ACCESS_KEY=xxxxxx
    # See server-example.env for server environment variables 
    

    不要在example.env中存储任何真实密钥。将它们存储在.env文件中。example.env文件用于记录项目所需的环境变量。在这种情况下,我已经在我的example.env文件中填充了示例值,以便读者可以在不配置所有这些参数的情况下运行示例。

  2. 通过执行以下命令确保init-dev-env已安装在项目根目录中:

    $ npm i -D init-dev-env 
    
  3. npm run init:env脚本使用init-dev-env包根据example.env文件生成.env文件:

    lemon-mart-server中,服务器的example.env文件存在于两个地方。首先在项目根目录下作为server-example.env,其次在server/example.env下。这样做是为了增加示例配置设置的可见性。

    $ npx init-dev-env generate-dot-env example.env -f && 
    init-dev-env generate-dot-env server-example.env --source=. --target=server -f 
    
  4. 第二个.env文件是为服务器生成的,如下所示:

    **server/.env**
    # MongoDB connection string as defined in example.env
    MONGO_URI=mongodb://john.smith:g00fy@localhost:27017/lemon-mart
    # Secret used to generate a secure JWT
    JWT_SECRET=aSecureStringThatIsNotThis
    # DEMO User Login Credentials
    DEMO_EMAIL=duluca@gmail.com
    DEMO_PASSWORD=l0l1pop!!
    DEMO_USERID=5da01751da27cc462d265913 
    

注意,此文件包含连接到 MongoDB 的连接字符串、我们将用于加密 JWT 的密钥以及一个种子用户,以便我们可以登录到应用程序。通常,你不会为你的种子用户配置密码或用户 ID。这些只在这里以支持可重复的演示代码。

现在,我们准备好定义 Compose 的 YAML 文件。

定义 Docker-Compose YAML

让我们在单一代码库的根目录中定义一个docker-compose.yml文件,以反映我们的架构:

**docker-compose.yml**
version: '3.7'
services:
  web-app:
    container_name: web
    build:
      context: ./web-app
      dockerfile: nginx.Dockerfile
    ports:
      - '8080:80'
    links:
      - server
    depends_on:
      - server
  server:
    container_name: lemon-mart-server
    build: server
    env_file: ./server/.env
    environment:
      - MONGO_URI=mongodb://john.smith:g00fy@lemondb:27017/lemon-mart
    ports:
      - '3000:3000'
    links:
      - database
    depends_on:
      - database
  database:
    container_name: lemondb
    image: duluca/minimal-mongo:4.2.2
    restart: always
    env_file: .env
    ports:
      - '27017:27017'
    volumes:
      - 'dbdata:/data/db'
volumes:
  dbdata: 

在顶部,我们使用基于 nginx 的容器构建 web-app 服务。build 属性会自动为我们构建 Dockerfile。我们在端口 8080 上公开 web-app 并将其链接到 server 服务。links 属性创建了一个隔离的 Docker 网络,以确保我们的容器可以相互通信。通过使用 depends_on 属性,我们确保在启动 web-app 之前启动服务器。

server 也使用 build 属性来自动构建 Dockerfile。它还使用 env_file 属性从 server 文件夹下的 .env 文件加载环境变量。使用 environment 属性,我们覆盖了 MONGO_URI 变量,以便使用数据库容器的内部 Docker 网络名称。服务器既 linksdepends_on 数据库,该数据库被命名为 lemondb

database 服务从 Docker Hub 拉取 duluca/minimal-mongo 镜像。使用 restart 属性,我们确保数据库在崩溃时将自动重启。我们使用 .env 文件中的设置参数来配置和密码保护数据库。使用 volumes 属性,我们将数据库的存储目录挂载到本地目录,以便你的数据可以在容器重启后持续存在。

在云环境中,你可以将你的数据库卷挂载到云提供商的持久化解决方案上,包括 AWS 弹性文件系统EFS)或 Azure 文件存储。

此外,我们定义了一个名为 dbdata 的 Docker 卷用于数据存储。

有时,你的数据库可能无法正常工作。这可能发生在你升级容器、使用不同的容器或在不同项目中使用相同的卷时。在这种情况下,你可以通过执行以下命令来重置你的 Docker 设置的状态:

 $ docker image prune
  $ docker container prune
  $ docker volume prune 

或者

 $ docker system prune --volumes **(this will delete everything)** 

要运行你的基础设施,你将执行 docker-compose up 命令。你也可以使用 -d 选项以分离模式运行你的基础设施。你可以使用 down 命令停止它,并通过 rm 命令删除它创建的容器。

在你能够运行你的基础设施之前,你需要构建你的应用程序,这将在下一节中介绍。

编排 Compose 启动

运行 docker-compose up 是启动你的基础设施的一种方便简单的方式。然而,在构建容器之前,你需要先构建你的代码。这是一个容易忽视的简单步骤。请参考以下 npm 脚本,你可以使用它们来编排你的基础设施启动:

**package.json**
scripts: {
  "build": "npm run build --prefix ./server && npm run build --prefix ./web-app -- --configuration=lemon-mart-server",
  "test": "npm test --prefix ./server && npm test --prefix ./web-app -- --watch=false",
  "prestart": "npm run build && docker-compose build",
  "start": "docker-compose up",
  "stop": "docker-compose down",
  "clean": "docker-compose rm",
  "clean:all": "docker system prune --volumes",
  "start:backend": "docker-compose -f docker-compose.backend.yml up --build",
  "start:database": "docker-compose -f docker-compose.database.yml up --build", 

我们实现了一个build脚本,该脚本运行服务器和 web 应用的build命令。一个test脚本可以执行相同的操作来执行测试。我们还实现了一个npm start命令,它可以自动运行build命令并运行compose up。作为额外的好处,我们还实现了start:backendstart:database脚本,可以运行不同的docker-compose文件来仅启动服务器或数据库。你可以通过删除主docker-compose.yml文件中的不必要的部分来创建这些文件。有关示例,请参阅 GitHub 仓库。

在服务器上编码时,我通常执行npm run start:database来启动数据库,并在单独的终端窗口中,从server文件夹使用npm start启动服务器。这样,我可以并排看到两个系统生成的日志。

执行npm start以验证你的docker-compose配置是否正常工作。按Ctrl + C停止基础设施。

在 CircleCI 上组合

你可以在 CircleCI 上执行你的 Compose 基础设施以验证配置的正确性并运行快速集成测试。请参阅以下更新的工作流程:

**.circleci/config.yml**
workflows:
  version: 2
  build-and-test-compose:
    jobs:
      - build_server
      - build_webapp
      - test_compose:
          requires:
            - build_server
            - build_webapp 

我们确保在运行名为test_compose的新任务之前,serverweb-app都已构建,该任务检查代码、初始化子模块并复制两个构建的dist文件夹,如下所示:

**.circleci/config.yml**
  test_compose:
    docker:
      - image: circleci/node:lts-browsers
    working_directory: ~/repo
    steps:
      - setup_remote_docker
      - attach_workspace:
          at: /tmp/workspace
      - checkout:
          path: ~/repo
      - run: npm run modules:init
      - run:
          name: Copy built server to server/dist folder
          command: cp -avR /tmp/workspace/server/dist/ ./server
      - run:
          name: Copy built web-app to web-app/dist folder
          command: cp -avR /tmp/workspace/dist/ ./web-app
      - run:
          name: Restore .env files
          command: |
            set +H
            echo -e $PROJECT_DOT_ENV > .env
            echo -e $SERVER_DOT_ENV > server/.env
      - run:
          name: Compose up
          command: |
            set -x
            docker-compose up -d
      - run:
          name: Verify web app
          command: |
            set -x
            docker run --network container:web jwilder/dockerize -wait http://localhost:80
            docker run --network container:web appropriate/curl http://localhost:80
      - run:
          name: Verify db login with api
          command: |
            set -x
            docker run --network container:lemon-mart-server jwilder/dockerize -wait http://localhost:3000
            docker run --network container:lemon-mart-server appropriate/curl \
              -H "accept: application/json" -H "Content-Type: application/json" \
              -d "$LOGIN_JSON" http://localhost:3000/v1/auth/login 

在复制dist文件后,该任务随后放置来自 CircleCI 环境变量的.env文件。然后,我们运行docker-compose up来启动我们的服务器。接下来,我们通过运行一个curl命令来检索其index.html文件来测试web-app。在等待服务器通过dockerize -wait变得可用后,我们运行curl。同样,我们通过使用我们的演示用户登录来测试我们的 API 服务器和数据库的集成。

恭喜!现在,你对我们的全栈架构在高级别是如何拼接的有了相当好的理解。在本章的后半部分,我们将介绍 API 是如何实现的,它是如何与数据库集成的,以及我们将看到 JWT 身份验证是如何与 API 和数据库协同工作的。

让我们继续深入探讨 API 设计。

RESTful API

在全栈开发中,尽早确定 API 设计非常重要。API 设计本身与你的数据合约的外观密切相关。你可以创建 RESTful 端点或使用下一代 GraphQL 技术。在设计你的 API 时,前端和后端开发者应紧密合作以实现共同的设计目标。以下是一些高级目标:

  • 最小化客户端和服务器之间传输的数据

  • 遵循已建立的设计模式(换句话说,数据分页)

  • 设计以减少客户端中的业务逻辑

  • 扁平化数据结构

  • 不要暴露数据库键或关系

  • 从一开始就对端点进行版本控制

  • 围绕主要数据实体进行设计

你应该旨在在你的 RESTful API 中实现业务逻辑。理想情况下,你的前端不应该包含比展示逻辑更多的内容。任何由前端实现的 if 语句也应该在你的后端得到验证。

如同在 第一章Angular 简介及其概念 中讨论的那样,在后台和前端实现无状态设计至关重要。每个请求都应使用非阻塞 I/O 方法,并且不应依赖于任何现有会话。这是使用云托管提供商无限扩展你的 Web 应用程序的关键。

无论你在实施项目时,都应限制,如果可能的话,消除实验。这在全栈项目中尤其如此。一旦你的应用程序上线,API 设计中的失误的下游影响可能是深远的,并且无法纠正。

接下来,让我们看看如何围绕主要数据实体设计 API。在这种情况下,我们将回顾围绕用户(包括身份验证)的 API 实现。首先,我们将探索如何使用 Swagger 定义一个端点,这样我们就可以具体地向团队成员传达我们设计的意图。

记住,本章只涵盖了概念上重要的代码片段。虽然你可以选择从头开始实现这段代码,但理解其工作原理并不需要这样做。如果你选择从头开始实现,请参考 github.com/duluca/lemon-mart-server 上的完整源代码,以跟进并填补你实现中的空白。

在以后,Swagger 将成为文档工具,反映我们 API 的能力。

使用 Swagger 进行 API 设计

Swagger 将允许你设计和记录你的 Web API。对于团队来说,它可以作为前端和后端开发者之间出色的沟通工具,从而减少很多摩擦。此外,尽早定义你的 API 表面,可以让实现开始而不必担心后期集成挑战。

随着我们继续前进,我们将实现一个用户 API,以展示 Swagger 的工作原理。

我强烈推荐安装 Swagger Viewer VS Code 扩展,它允许我们在不运行任何额外工具的情况下预览 YAML 文件。

让我们从探索单一代码库根目录下的 swagger.yaml 文件开始:

  1. 在 VS Code 中打开 swagger.yaml

  2. 安装名为 Swagger Preview 的 VS Code 扩展。

  3. Ctrl + Shift + P,或者点击 ++P,以打开命令面板并运行 预览 Swagger

  4. 看看预览,如图所示:

    图 10.4:Swagger.yaml 预览

使用 Swagger UI 视图,你将能够尝试命令并在你的服务器环境中执行它们。

定义 Swagger YAML 文件

我们将使用 Swagger 规范版本 openapi: 3.0.1,它实现了 OpenAPI 标准。让我们在这里回顾 swagger.yaml 文件的主要组件:

有关 Swagger 文件定义的更多信息,请参阅swagger.io/specification/

  1. YAML 文件以一般信息和目标服务器开始:

    **swagger.yaml**
    openapi: 3.0.1
    **info**:
      title: LemonMart
      description: LemonMart API
      version: "2.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和响应,这些定义了我们打算实施的认证方案以及我们的错误消息响应的形状:

    **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,它声明了要么作为输入接收要么返回给客户端的数据实体:

    **swagger.yaml**
    ...
     **schemas:**
        ServerMessage:
          type: object
          properties:
            message:
              type: string
        Role:
          type: string
          enum: [none, clerk, cashier, manager]
        ... 
    
  4. components下,我们添加共享parameters,这使得重用常见的模式,如分页端点变得容易:

    **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端点:

    **swagger.yaml**
    ...
    **paths:**
      /v1/login:
        post:
          description: |
            Generates a JWT, given 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,而在后者的情况下,我们返回一个UnauthorizedError,如第二步中定义的那样。

  6. paths下,我们继续添加以下路径:

    **swagger.yaml**
    ...
    **paths:**
      /v1/auth/me:
      get: ...
     /v2/users:
        get: ...
        post: ...
     /v2/users/{id}:
        get: ...
        put: ... 
    

OpenAPI 规范功能强大,允许您定义复杂的用户如何与您的 API 交互的要求。在swagger.io/docs/specification上的规范文档在开发您自己的 API 定义时是一个无价资源。

预览 Swagger 文件

您可以免费在swaggerhub.com验证您的 Swagger 文件。在您注册免费账户后,创建一个新的项目并定义您的 YAML 文件。SwaggerHub 将突出显示您所犯的错误。它还会为您提供网页预览,这与您在 Swagger Preview VS Code 扩展中获得的预览相同。

参考以下截图,查看有效的 Swagger YAML 定义在 SwaggerHub 上的样子:

图片

图 10.5:SwaggerHub 上的有效 Swagger YAML 定义

我们的目的是将这种交互式文档与我们的 Express.js API 集成。

现在,让我们看看您如何实现这样的 API。

使用 Express.js 实现 API

在我们开始实现我们的 API 之前,让我们分节回顾我们后端的目标文件结构,以便我们了解服务器是如何启动的,API 端点的路由是如何配置的,公共资源是如何提供的,以及服务是如何配置的。Minimal MEAN 故意坚持基本原理,这样您就可以更多地了解底层技术。虽然我已经使用 Minimal MEAN 实现了生产系统,但您可能不会像我一样享受这种骨架式开发体验。在这种情况下,您可以考虑 Nest.js,这是一个用于实现全栈 Node.js 应用程序的热门框架。Nest.js 具有丰富的功能集,其架构和编码风格与 Angular 非常相似。我建议在您掌握了 MEAN 栈的基础之后使用此类库。

向 Kamil Mysliwiec 和 Mark Pieszak 表示祝贺,他们创建了一个出色的工具,并在 Nest.js 周围营造了一个充满活力的社区。您可以在 nestjs.com/ 上了解更多关于 Nest.js 的信息,并在 trilon.io/ 获取咨询服务。

现在,让我们回顾一下我们的 Express 服务器的文件结构:

**server/src**
│   api.ts
│   app.ts
│   config.ts
│   docs-config.ts
│   index.ts
│   
├───models
│       enums.ts
│       phone.ts
│       user.ts
│       
├───public
│       favicon.ico
│       index.html
│       
├───services
│       authService.ts
│       userService.ts
│       
├───v1
│   │   index.ts
│   │   
│   └───routes
│           authRouter.ts
│           
└───v2
    │   index.ts
    │   
    └───routes
            userRouter.ts 

通过查看组件图,我们可以回顾这些文件的目的和它们之间的交互,从而获得架构和依赖树的概览:

图 10.6:Express 服务器架构

index.ts 包含一个 start 函数,该函数利用三个主要助手启动应用程序:

  1. config.ts:管理环境变量和设置。

  2. app.ts:配置 Express.js,定义所有 API 路径,然后路由实现路径并利用包含业务逻辑的服务。服务使用模型,如 user.ts,来访问数据库。

  3. document-ts:建立与数据库的连接并对其进行配置,并在启动时利用 user.ts 配置种子用户。

您可以看到,图中顶部的组件负责启动和配置任务,包括配置 API 路径,这代表了 API 层。服务层应该包含应用程序的大部分业务逻辑,而持久性则在 模型 层处理。

参考以下 index.ts 的实现,其中不包含任何数据库功能:

**server/src/index.ts**
import * as http from 'http'
import app from './app'
import * as config from './config'
export let Instance: http.Server
async function start() {
  console.log('Starting server: ')
  console.log(`isProd: ${config.IsProd}`)
  console.log(`port: ${config.Port}`)
  Instance = http.createServer(app)
  Instance.listen(config.Port, async () => {
    console.log(`Server listening on port ${config.Port}...`)
  })
}
start() 

注意,显示的最后一行代码 start() 是触发服务器初始化的函数调用。

现在,让我们看看 Express 服务器是如何设置的。

服务器启动

App.ts 配置 Express.js,同时提供静态资源服务、路由和版本控制。Express.js 通过中间件函数与库或您自己的代码集成,例如一个认证方法:

**server/src/app.ts**
import * as path from 'path'
import * as cors from 'cors'
import * as express from 'express'
import * as logger from 'morgan'
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('/', express.static(path.join(__dirname, '../public'), { redirect: false }))
app.use(api)
export default app 

在前面的代码中,请注意配置 Express 使用 use() 方法非常简单。首先,我们配置 cors,然后是 express 解析器和 logger

接下来,使用express.static函数,我们在根路由/上提供public文件夹,这样我们就可以显示有关我们服务器的一些有用信息,如下所示:

图 10.7:LemonMart 服务器登录页面

我们将在下一节中介绍如何配置上面提到的/api-docs端点。

最后,我们配置路由器,它在api.ts中定义。

路由和版本控制

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,因为它们都将进行数据库调用,我们将使用await。如果您的路由是同步的,那么您不需要async关键字。

接下来,让我们看看服务。

服务

我们不希望在表示我们的 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 服务器的骨架结构,让我们看看如何配置 Swagger,以便您可以用它作为实现的指南并为您 API 创建活页文档。

配置 Swagger 与 Express

配置 Swagger 与 Express 是一个手动过程。强迫自己手动记录端点有一个很好的副作用。通过放慢速度,您将有机会从消费者和实现者的角度考虑您的实现。这种视角将帮助您在开发过程中解决端点可能存在的潜在问题,从而避免昂贵的返工。

将 Swagger 集成到您的服务器中的主要好处是,您将获得本章前面提到的相同的交互式 Swagger UI,因此您的测试人员和开发者可以直接从网络界面发现或测试您的 API。

我们将使用两个辅助库来帮助我们集成 Swagger 到我们的服务器中:

  • swagger-jsdoc:它允许您通过在 JSDoc 注释块中使用 @swagger 标识符在相关代码上实现 OpenAPI 规范,生成 swagger.json 文件作为输出。

  • swagger-ui-express:它消费 swagger.json 文件以显示交互式 Swagger UI 网络界面。

让我们来看看 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.0.1',
        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 消费者使用网络界面测试 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))**
    ...
    export default app 
    

规范包含 swagger.json 文件的内容,然后传递给 swaggerUi。然后,使用服务器中间件,我们可以配置 swaggerUi/api-docs 上托管网络界面。

您已经拥有了从本章开始就需要用于完成应用程序实现的 OpenAPI 定义。有关更多信息,请参考完整的源代码 github.com/duluca/lemon-mart-server

恭喜!现在您已经很好地理解了我们的 Express 服务器是如何工作的。接下来,让我们看看如何连接到 MongoDB。

MongoDB ODM 与 DocumentTS

DocumentTS 作为 ODM,通过实现模型层来启用与数据库对象的丰富和可定制的交互。ODM 是文档数据库中与关系数据库中的 对象关系映射器ORM)相对应的。想想 Hibernate 或 Entity Framework。如果您不熟悉这些概念,我建议在继续之前进行进一步的研究。

在其核心,DocumentTS 利用 MongoDB 的 Node.js 驱动程序。该驱动程序由 MongoDB 的制作者实现。它保证提供最佳性能并与新 MongoDB 版本保持功能一致性,而第三方库通常在支持新功能方面落后。使用 database.getDbInstance 方法,您可以直接访问原生驱动程序。否则,您将通过您实现的模型访问 Mongo。请参考以下图表以获取概述:

图 10.8:DocumentTS 概述

您可以在 mongodb.github.io/node-mongodb-native/ 上了解更多关于 MongoDB Node.js 驱动程序的信息。

关于 DocumentTS

DocumentTS 提供了三个主要功能:

  • connect():一个 MongoDB 异步连接工具

  • DocumentIDocument:一个基类和接口,帮助您定义自己的模型

  • CollectionFactory:定义集合,组织索引,并在集合实现中聚合查询

以下是 DocumentTS 集合提供的便利功能:

  • get collection 返回原生 MongoDB 集合,因此您可以直接操作它:

    get collection(): ICollectionProvider<TDocument> 
    
  • aggregate 允许您运行 MongoDB 聚合管道:

    aggregate(pipeline: object[]): AggregationCursor<TDocument> 
    
  • findOnefindOneAndUpdate 简化了常用数据库功能的操作,自动填充返回的模型:

    async findOne(
      filter: FilterQuery<TDocument>, 
      options?: FindOneOptions
    ): Promise<TDocument | null> 
    async findOneAndUpdate(
      filter: FilterQuery<TDocument>,
      update: TDocument | UpdateQuery<TDocument>,
      options?: FindOneAndReplaceOption
     ): Promise<TDocument | null> 
    
  • findWithPagination 是 DocumentTS 中迄今为止最好的功能,允许您过滤、排序和分页大量数据。此功能旨在与数据表一起使用,因此您指定可搜索属性,关闭填充,并使用调试功能来微调您的查询:

    async findWithPagination<TReturnType extends IDbRecord>(
      queryParams: Partial<IQueryParameters> & object,
      aggregationCursorFunc?: Func<AggregationCursor<TReturnType>>,
      query?: string | object,
      searchableProperties?: string[],
      hydrate = true,
      debugQuery = false
    ): Promise<IPaginationResult<TReturnType>> 
    

DocumentTS 致力于成为可靠、可选且易于使用的工具。DocumentTS 直接将开发者暴露于原生 Node.js 驱动程序,因此您学习如何与 MongoDB 交互,而不是使用某个库。开发者可以选择利用库的便利功能,包括以下内容:

  • 通过简单的接口定义您自己的模型。

  • 选择您想要自动填充的字段,例如子对象或相关对象。

  • 每次请求时序列化计算字段。

  • 保护某些字段(如密码)免于序列化,以防止它们意外地通过网络发送。

由于 DocumentTS 是可选的,因此开发人员可以按自己的时间表过渡到新功能。如果性能成为关注点,您可以轻松切换到原生 MongoDB 调用以获得最佳性能。使用 DocumentTS,您将花费更多时间阅读 MongoDB 文档,而不是 DocumentTS 文档。

Mongoose 是一个用于与 MongoDB 交互的流行库。然而,它是一个围绕 MongoDB 的包装器,需要全面采用。此外,该库抽象了原生驱动程序,因此它对生态系统中的更改和更新非常敏感。您可以在mongoosejs.com/上了解更多关于 Mongoose 的信息。

使用以下命令安装 MongoDB 依赖项和 TypeScript 类型信息:

$ npm i mongodb document-ts
$ npm i -D @types/mongodb 

接下来,让我们看看如何连接到数据库。

连接到数据库

在编写完全异步的 Web 应用程序时,确保数据库连接存在可能是一个挑战。connect()函数使得连接到 MongoDB 实例变得简单,并且可以从多个同时启动的线程中安全地调用。

让我们先配置一下环境变量:

  1. 记住MONGO_URI连接字符串位于server/.env中:

    **server/.env**
    MONGO_URI=mongodb://john.smith:g00fy@localhost:27017/lemon-mart 
    

    为了更新用户名、密码和数据库名称,您需要编辑顶级.env文件中的以下变量:

    **.env**
    MONGODB_APPLICATION_DATABASE=lemon-mart
    MONGODB_APPLICATION_USER=john.smith
    MONGODB_APPLICATION_PASS=g00fy 
    

    记住,.env更改只有在您重新启动服务器后才会生效。

  2. 让我们看看document-ts如何与index.ts集成:

    **server/src/index.ts**
    ...
    import * as document from 'document-ts'
    import { UserCollection } from './models/user'
    ...
    async function start() {
      ...
      console.log(`mongoUri: ${config.MongoUri}`)
      try {
        **await document.connect(config.MongoUri, config.IsProd)**
        console.log('Connected to database!')
      } catch (ex) {
        console.log(`Couldn't connect to a database: ${ex}`)
      }
    ...
      Instance.listen(config.Port, async () => {
        console.log(`Server listening on port ${config.Port}...`)
        **await createIndexes()**
        console.log('Done.')
      })
    }
    async function createIndexes() {
      console.log('Create indexes...')
      **await UserCollection.createIndexes()**
    }
    start() 
    

我们尝试使用try/catch块连接到数据库。一旦 Express 服务器启动并运行,我们调用createIndexes,它反过来调用UserCollection上具有相同名称的函数。除了性能考虑之外,MongoDB 索引对于使字段可搜索是必要的。

具有 IDocument 的模型

您可以实施一个类似于 LemonMart 中的IUser接口。然而,这个接口将扩展 DocumentTS 中定义的IDocument

  1. 这是IUser接口:

    **server/src/models/user.ts**
    export interface IUser extends IDocument {
      email: string
      name: IName
      picture: string
      role: Role
      userStatus: boolean
      dateOfBirth: Date
      level: number
      address: {
        line1: string
        line2?: string
        city: string
        state: string
        zip: string
      }
      phones?: IPhone[]
    } 
    

    DocumentTS 提供的接口和基类旨在帮助您以一致的方式开发业务逻辑和数据库查询。我鼓励您通过Ctrl + 点击它们来探索基类和接口,以便您可以看到它们背后的源代码。

  2. 现在,这里是一个扩展Document<T>并实现 Swagger 文档的User类:

    **server/src/models/user.ts**
    import { v4 as uuid } from 'uuid'
    /**
     * @swagger
     * components:
     *   schemas:
     *     Name:
     *       type: object
     *       …
     *     User:
     *       type: object 
     *       …
     */
    export class User extends Document<IUser> implements IUser {
      static collectionName = 'users'
      private password: string
      public email: string
      public name: IName
      public picture: string
      public role: Role
      public dateOfBirth: Date
      public userStatus: boolean
      public level: number
      public address: {
        line1: string
        city: string
        state: string
        zip: string
      }
      public phones?: IPhone[]
      constructor(user?: Partial<IUser>) {
        super(User.collectionName, user)
      }
      fillData(data?: Partial<IUser>) {
        if (data) {
          Object.assign(this, data)
        }
        if (this.phones) {
          this.phones = this.hydrateInterfaceArray(
            Phone, Phone.Build, this.phones
          )
        }
      }
      getCalculatedPropertiesToInclude(): string[] {
        return ['fullName']
      }
      getPropertiesToExclude(): string[] {
        return ['password']
      }
      public get fullName(): string {
        if (this.name.middle) {
          return `${this.name.first} ${this.name.middle} ${this.name.last}`
        }
        return `${this.name.first} ${this.name.last}`
      }
      async create(id?: string, password?: string, upsert = false) {
        if (id) {
          this._id = new ObjectID(id)
        }
        if (!password) {
          password = uuid()
        }
        this.password = await this.setPassword(password)
        await this.save({ upsert })
      }
      hasSameId(id: ObjectID): boolean {
        return this._id.toHexString() === id.toHexString()
      }
    } 
    

    注意属性getCalculatedPropertiesToIncludegetPropertiesToExclude。这些属性定义了字段是否应该由客户端序列化或允许写入数据库。

    数据的序列化和反序列化是将数据转换为可以存储或传输的格式的概念。请参阅进一步阅读部分,以获取有关序列化和 JSON 数据格式的文章链接。

    fullName是一个计算属性,因此我们不希望将此值写入数据库。然而,fullName对客户端很有用。另一方面,password属性永远不应该传回客户端,但显然我们需要能够将其保存到数据库中,以便进行密码比较和更改。在保存时,我们传递一个{ upsert }对象来指示数据库即使在提供部分信息的情况下也要更新记录。

    记得提供完整的 Swagger 定义。

  3. 最后,让我们回顾一下实现CollectionFactory<T>UserCollectionFactory

    **server/src/models/user.ts**
    class UserCollectionFactory extends CollectionFactory<User> {
      constructor(docType: typeof User) {
        super(User.collectionName, docType, ['name.first', 'name.last', 'email'])
      }
      async createIndexes() {
        await this.collection().createIndexes([
          {
            key: {
              email: 1,
            },
            unique: true,
          },
          {
            key: {
              'name.first': 'text',
              'name.last': 'text',
              email: 'text',
            },
            weights: {
              'name.last': 4,
              'name.first': 2,
              email: 1,
            },
            name: 'TextIndex',
          },
        ])
      }
    userSearchQuery(
        searchText: string
      ): AggregationCursor<{ _id: ObjectID; email: string }> {
        const aggregateQuery = [
          {
            $match: {
              $text: { $search: searchText },
            },
          },
          {
            $project: {
              email: 1,
            },
          },
        ]
        if (searchText === undefined || searchText === '') {
          delete (aggregateQuery[0] as any).$match.$text
        }
        return this.collection().aggregate(aggregateQuery)
      }
    }
    export let UserCollection = new UserCollectionFactory(User) 
    

在这里,我们创建一个唯一索引,这样具有相同电子邮件地址的另一个用户将无法注册。我们还创建了一个加权索引,这有助于编写过滤查询。我们在连接到数据库后立即在index.ts中应用这些索引。

userSearchQuery是一个有点牵强的例子,用于演示 MongoDB 中的聚合查询。使用 MongoDB 的聚合功能可以执行更复杂和高效的查询。你可以在docs.mongodb.com/manual/aggregation上了解更多关于聚合的信息。

在文件底部,我们实例化一个UserCollection并将其导出,以便在应用程序的任何地方引用:

**server/src/models/user.ts**
**export** let UserCollection = new UserCollectionFactory(User) 

注意,UserCollectionFactory没有被导出,因为它只在user.ts文件中需要。

让我们看看如何使用新的用户模型来获取数据。

实现 JWT 身份验证

在第八章“设计身份验证和授权”中,我们讨论了如何实现基于 JWT 的身份验证机制。在 LemonMart 中,你实现了一个基础认证服务,它可以扩展为自定义认证服务。

我们将利用三个包来实现我们的功能:

  • jsonwebtoken:用于创建和编码 JWT

  • bcryptjs:用于在将用户的密码保存到数据库之前对其进行散列和加盐,所以我们永远不会以明文形式存储用户的密码

  • uuid:生成一个全局唯一标识符,当需要将用户的密码重置为随机值时很有用

哈希函数是一个一致可重复的单向加密方法,这意味着每次提供相同的输入时都会得到相同的输出,即使你有访问散列值的能力,也无法轻易地找出它存储的信息。然而,我们可以通过散列用户的输入并将其与存储的密码散列值进行比较,来比较用户是否输入了正确的密码。

  1. 让我们看看 JWT 身份验证相关的依赖和 TypeScript 类型信息:

    $ npm i bcryptjs jsonwebtoken uuid
    $ npm i -D @types/bcryptjs @types/jsonwebtoken @types/uuid 
    
  2. 观察具有密码散列功能的User模型:

    **server/src/models/user.ts**
    import * as bcrypt from 'bcryptjs'
      async create(id?: string, password?: string, upsert = false) {
          ...
          this.password = await this.setPassword(password)
          await this.save({ upsert })
        }
      async resetPassword(newPassword: string) {
        this.password = await this.setPassword(newPassword)
        await this.save()
      }
      private setPassword(newPassword: string): Promise<string> {
        return new Promise<string>((resolve, reject) => {
          bcrypt.genSalt(10, (err, salt) => {
            if (err) {
              return reject(err)
            }
            bcrypt.hash(newPassword, salt, (hashError, hash) => {
              if (hashError) {
                return reject(hashError)
              }
              resolve(hash)
            })
          })
        })
      }
      comparePassword(password: string): Promise<boolean> {
        const user = this
        return new Promise((resolve, reject) => {
          bcrypt.compare(password, user.password, (err, isMatch) => {
            if (err) {
              return reject(err)
            }
            resolve(isMatch)
          })
        })
      } 
    

使用 setPassword 方法,你可以对用户提供的密码进行散列,并将其安全地保存到数据库中。稍后,我们将使用 comparePassword 函数将用户提供的值与散列密码进行比较。我们从不存储用户提供的值,因此系统永远不会重新生成用户的密码,这使得它是一个安全的实现。

登录 API

以下是在 lemon-mart-serverauthServicelogin 方法的实现:

**server/src/services/authService.ts**
import * as jwt from 'jsonwebtoken'
import { JwtSecret } from '../config'
export const IncorrectEmailPasswordMessage = 'Incorrect email and/or password'
export const AuthenticationRequiredMessage = 'Request has not been authenticated'
export function createJwt(user: IUser): Promise<string> {
  return new Promise<string>((resolve, reject) => {
    const payload = {
      email: user.email,
      role: user.role,
      picture: user.picture,
    }
    jwt.sign(
      payload,
      JwtSecret(),
      {
        subject: user._id.toHexString(),
        expiresIn: '1d',
      },
      (err: Error, encoded: string) => {
        if (err) {
          reject(err.message)
        }
        resolve(encoded)
      }
    )
  })
} 

以下代码示例实现了一个 createJwt 函数来为每个用户创建 JWT。我们还为身份验证失败定义了预定义的响应。注意不正确的电子邮件/密码消息的模糊性,这意味着恶意行为者不能利用系统来利用身份验证系统。

让我们在 /v1/auth/login 上实现登录 API:

**server/src/v1/routes/authRouter.ts**
import { Request, Response, Router } from 'express'
import { UserCollection } from '../../models/user'
import {
  AuthenticationRequiredMessage,
  IncorrectEmailPasswordMessage,
  authenticate,
  createJwt,
} from '../../services/authService'
const router = Router()
/**
 * @swagger
 * /v1/auth/login:
 *   post:
 * …
 */
router.post('/login', async (req: Request, res: Response) => {
  const userEmail = req.body.email?.toLowerCase()
  const user = await UserCollection.findOne({ email: userEmail })
  if (user && (await user.comparePassword(req.body.password))) {
    return res.send({ accessToken: await createJwt(user) })
  }
  return res.status(401).send({
    message: IncorrectEmailPasswordMessage
  })
}) 

注意,当通过电子邮件检索用户时,请记住电子邮件是不区分大小写的。因此,你应该始终将输入转换为小写。你可以通过验证电子邮件、删除任何空白字符、脚本标签或甚至恶意 Unicode 字符来进一步改进此实现。考虑使用 express-validatorexpress-sanitizer 等库。

login 方法利用 user.comparePassword 函数来确认提供的密码的正确性。然后 createJwt 函数创建要返回给客户端的 accessToken

身份验证中间件

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 属性。

请参阅以下 authenticateauthenticateHelper 的实现:

**server/src/services/authService.ts**
import { NextFunction, Request, Response } from 'express'
import { ObjectID } from 'mongodb'
import { IUser, UserCollection } from '../models/user'
interface IJwtPayload {
  email: string
  role: string
  picture: string
  iat: number
  exp: number
  sub: string
}
export function authenticate(options?: {
  requiredRole?: Role
  permitIfSelf?: {
    idGetter: (req: Request) => string
    requiredRoleCanOverride: boolean
  }
}) {
  return async (req: Request, res: Response, next: NextFunction) => {
    try {
      res.locals.currentUser =
        await authenticateHelper(
          req.headers.authorization, {
            requiredRole: options?.requiredRole,
            permitIfSelf: options?.permitIfSelf
              ? {
                  id: options?.permitIfSelf.idGetter(req),
                  requiredRoleCanOverride: 
                    options?.permitIfSelf.requiredRoleCanOverride,
                }
             : undefined,
          }
        )
      return next()
    } catch (ex) {
      return res.status(401).send({ message: ex.message })
    }
  }
}
export async function authenticateHelper(
  authorizationHeader?: string,
  options?: {
    requiredRole?: Role
    permitIfSelf?: {
      id: string
      requiredRoleCanOverride: boolean
    }
  }
): Promise<User> {
  if (!authorizationHeader) {
    throw new Error('Request is missing authorization header')
  }
  const payload = jwt.verify(
    sanitizeToken(authorizationHeader),
    JwtSecret()
  ) as IJwtPayload
  const currentUser = await UserCollection.findOne({
    _id: new ObjectID(payload?.sub),
  })
  if (!currentUser) {
    throw new Error("User doesn't exist")
  }
  if (
    options?.permitIfSelf &&
    !currentUser._id.equals(options.permitIfSelf.id) &&
    !options.permitIfSelf.requiredRoleCanOverride
  ) {
    throw new Error(`You can only edit your own records`)
  }
  if (
    options?.requiredRole && 
    currentUser.role !== options.requiredRole
  ) {
    throw new Error(`You must have role: ${options.requiredRole}`)
  }
  return currentUser
} 
function sanitizeToken(authorization: string | undefined) {
  const authString = authorization || ''
  const authParts = authString.split(' ')
  return authParts.length === 2 ? authParts[1] : authParts[0]
} 

authenticate 方法作为 Express.js 中间件实现。它可以读取请求头中的授权令牌,验证提供的 JWT 的有效性,加载当前用户,并将其注入到响应流中,以便认证的 API 端点可以方便地访问当前用户的信息。这将通过 me API 进行演示。如果成功,中间件调用 next() 函数将控制权交还给 Express。如果失败,则无法调用 API。

注意,authenticateHelper 返回有用的错误消息,所以如果用户尝试执行他们无权执行的操作,他们不会感到困惑。

考虑 me API 的实现,它通过 /v1/auth/me 将当前登录用户返回给客户端,如下所示:

**server/src/v1/routes/authRouter.ts**
/**
 * @swagger
 * /v1/auth/me:
 *   get:
 *     ...
 */
// tslint:disable-next-line: variable-name
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 })
  }
) 

注意,/v1/auth/me 方法使用 authenticate 中间件,并简单地返回加载到响应流中的用户。

自定义服务器身份验证提供者

现在我们已经在服务器中实现了功能性的身份验证实现,我们可以在 LemonMart 中实现自定义身份验证提供者,如第八章 设计身份验证和授权 中所述:

你必须在你的 Angular 应用程序中实现这个自定义身份验证提供者。

本节代码示例位于 lemon-mart 仓库的 projects/ch10 文件夹中。请注意,该示例也位于 web-app 文件夹下。

  1. environment.tsenvironment.prod.ts 中实现一个 baseUrl 变量。

  2. 还选择 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, 
    
  3. 安装一个辅助库以编程方式访问 TypeScript 枚举值:

    $ npm i ts-enum-util 
    
  4. 按照如下所示实现自定义身份验证提供者:

    **web-app/src/app/auth/auth.custom.service.ts**
    import { $enum } from 'ts-enum-util'
    interface IJwtToken {
      email: string
      role: string
      picture: string
      iat: number
      exp: number
      sub: string
    }
    @Injectable()
    export class CustomAuthService extends AuthService {
      constructor(private httpClient: HttpClient) {
        super()
      }
      protected authProvider(
        email: string,
        password: string
      ): Observable<IServerAuthResponse> {
        return this.httpClient.post<IServerAuthResponse>(
          `${environment.baseUrl}/v1/auth/login`,
          {
            email,
            password,
          }
        )
      }
      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(map(User.Build, catchError(transformError)))
      }
    } 
    

    authProvider 方法调用我们的 /v1/auth/login 方法,getCurrentUser 调用 /v1/auth/me 以检索当前用户。

    确保对 login 方法的调用始终发生在 HTTPS 上,否则你将在开放的互联网上发送用户凭据。这对于在公共 Wi-Fi 网络上监听者窃取用户凭据来说是一个很好的机会。

  5. 更新 authFactory 以返回 AuthMode.CustomServer 选项的新提供者:

    **web-app/src/app/auth/auth.factory.ts**
    export function authFactory(
      afAuth: **AngularFireAuth,**
      **httpClient**: HttpClient
    ) {
      ...
      case AuthMode.CustomServer:
        return new CustomAuthService(**httpClient**)
    } 
    
  6. app.modules.ts 中,更新 AuthService 提供者的 deps 属性以将 HttpClient 注入到 authFactory

    **web-app/src/app/app.module.ts**
    ...
      {
        provide: AuthService,
        useFactory: authFactory,
        deps: [AngularFireAuth, **HttpClient**],
      },
    ... 
    
  7. 启动你的 Web 应用程序以确保一切正常工作。

接下来,让我们实现获取用户端点,以便我们的身份验证提供者可以获取当前用户。

通过 ID 获取用户

让我们在 userRouter 中实现通过 ID 获取用户的 GET API 端点,在 /v2/users/{id}

**server/src/v2/routes/userRouter.ts**
import { ObjectID } from 'mongodb'
import { authenticate } from '../../services/authService'
import { IUser, User, UserCollection } from '../../models/user'
/**
 * @swagger
 * /v2/users/{id}:
 *   get: …
 */
router.get(
  '/:userId',
  authenticate({
    requiredRole: Role.Manager,
    permitIfSelf: {
      idGetter: (req: Request) => req.body._id,
      requiredRoleCanOverride: true,
    },
  }),
  async (req: Request, res: Response) => {
    const user = await UserCollection
      .findOne({ _id: new ObjectID(req.params.userId) })
    if (!user) {
      res.status(404).send({ message: 'User not found.' })
    } else {
      res.send(user)
    }
  }
) 

在前面的代码示例中,我们通过用户 ID 查询数据库以找到我们正在寻找的记录。我们导入 UserCollection 并调用 findOne 方法以获取一个 User 对象。请注意,我们没有利用 userService。由于我们只检索单个记录并立即发送结果,额外的抽象层是繁琐的。然而,如果你开始向检索用户的操作中添加任何业务逻辑,那么请重构代码以利用 userService

我们使用authenticate中间件来保护端点,允许用户检索他们的记录,管理员可以检索任何记录。

使用 Postman 生成用户

在本章前面,我们介绍了如何在Express.js部分的实现 API子部分的服务子部分中创建一个 POST 方法来创建新用户。使用这个 POST 端点和 Postman API 客户端,我们可以快速为测试目的生成用户记录。

你必须按照以下说明在lemon-mart-server中生成测试数据,这在后面的章节中将是必需的。

让我们安装和配置 Postman。

前往www.getpostman.com下载并安装 Postman。

配置 Postman 进行认证调用

首先,我们需要配置 Postman,以便我们可以访问我们的认证端点:

使用docker-compose upnpm run start:backend启动你的服务器和数据库。记住,首先确保你能够执行 GitHub 上提供的示例服务器github.com/duluca/lemon-mart-server。启动你自己的服务器版本是次要目标。

  1. 创建一个名为LemonMart的新集合。

  2. 添加一个 URL 为http://localhost:3000/v1/auth/login的 POST 请求。

  3. 在头部设置键值对,Content-Type: application/json

  4. 在正文部分,提供我们定义在顶级.env文件中的演示用户登录的电子邮件和密码:

    http://localhost:3000/v1/auth/login - Body
    {
        "email": "duluca@gmail.com",
        "password": "l0l1pop!!"
    } 
    
  5. 点击发送以登录。

  6. 复制accessToken,如下所示图 B14094_10_09

    图 10.9:设置 Postman

  7. 点击右上角的设置图标来管理环境。

  8. 添加一个名为 LemonMart Server 的新环境。

  9. 创建一个名为token的变量。

  10. accessToken值粘贴为当前值(不带括号)。

  11. 点击添加/更新

从现在起,当你添加 Postman 中的新请求时,你必须提供令牌变量作为授权头,如下所示:

图 B14094_10_10

图 10.10:在 Postman 中提供令牌

当使用 Postman 时,始终确保在右上角的下拉菜单中选择了正确的环境。

  1. 切换到授权选项卡。

  2. 选择Bearer Token作为类型。

  3. 将令牌变量作为{{token}}提供。

当你发送你的请求时,你应该看到结果。请注意,当你的令牌过期时,你需要重复此过程。

Postman 自动化

使用 Postman,我们可以自动化请求的执行。为了在我们的系统中创建示例用户,我们可以利用这个功能:

  1. http://localhost:3000/v2/user创建一个新的名为创建用户的 POST 请求。

  2. 授权选项卡中设置token

  3. 正文选项卡中,提供一个模板化的 JSON 对象,如下所示:

    {
      "email": "{{email}}",
      "name": {
        "first": "{{first}}",
        "last": "{{last}}"
      },
      "picture": "https://en.wikipedia.org/wiki/Bugs_Bunny#/media/File:Bugs_Bunny.svg",
      "role": "clerk",
      "userStatus": true,
      "dateOfBirth": "1940-07-27",
      "address": {
        "line1": "123 Acme St",
        "city": "LooneyVille",
        "state": "Virginia",
        "zip": "22201"
      },
      "phones": [
        {
          "type": "mobile",
          "digits": "5551234567"
        }
      ]
    } 
    

    在本例中,我仅对电子邮件和姓名字段进行模板化。你可以对所有属性进行模板化。

  4. 实现一个 Postman Pre-request Script,它在发送请求之前执行任意逻辑。该脚本将定义一个人员数组,并在请求执行时逐个设置当前环境变量:

    关于预请求脚本的更多信息,请查看 learning.postman.com/docs/postman/scripts/pre-request-scripts/

  5. 切换到 Pre-request Script 选项卡并实现脚本:

    var people = pm.environment.get('people')
    if (!people) {
      people = [
        {email: 'efg@gmail.com', first: 'Ali', last: 'Smith'},
        {email: 'veli@gmail.com', first: 'Veli', last: 'Tepeli'},
        {email: 'thunderdome@hotmail.com', first: 'Justin', last: 'Thunderclaps'},
        {email: 'jt23@hotmail.com', first: 'Tim', last: 'John'},
        {email: 'apple@smith.com', first: 'Obladi', last: 'Oblada'},
        {email: 'jones.smith@icloud.com', first: 'Smith', last: 'Jones'},
        {email: 'bugs@bunnylove.com', first: 'Bugs', last: 'Bunny'},
      ]
    }
    var person = people.shift()
    pm.environment.set('email', person.email)
    pm.environment.set('first', person.first)
    pm.environment.set('last', person.last)
    pm.environment.set('people', people) 
    

    pm 是一个全局变量,代表 PostMan。

    在第一行,我们从环境中获取 people 数组。在第一次请求期间,它将不存在,这允许我们使用测试数据初始化数组。接下来,我们移动到下一个记录,并设置我们在模板请求体中使用的单个变量。然后,我们将当前数组的当前状态保存回环境,这样,在下次执行时,我们可以移动到下一个记录,直到我们用完记录。

  6. Tests 选项卡中实现一个 test 脚本:

    var people = pm.environment.get('people')
    if (people && people.length > 0) {
      postman.setNextRequest('Create Users')
    } else {
      postman.setNextRequest(null)
    } 
    
  7. 确保保存您的请求。

    在这里,我们定义一个 test 脚本,该脚本将一直执行,直到 people.length 达到零。在每次迭代中,我们调用 Create Users 请求。当没有剩下的人时,我们调用 null 来终止测试。

    如您所想象,您可以将多个请求和多个环境变量组合起来执行复杂的测试。

  8. 现在,使用屏幕左上角的 Runner 执行脚本:图片

    图 10.11:Postman UI 左上角的运行器按钮

  9. 在继续操作之前,请更新您的 login 令牌。

  10. 按照以下配置设置运行器:图片

    图 10.12:集合运行器配置

  11. 选择 LemonMart 集合。

    选择包含 token 变量的 LemonMart Server 环境。

    只选择 Create Users 请求。

    点击 Run LemonMart 来执行。

如果您的运行成功,您应该看到以下输出:

图片

图 10.13:集合运行器结果

如果您使用 Studio 3T 作为 MongoDB 探索器,您可以看到所有记录都已创建,或者您可以使用 Postman 检查它们,当我们实现 /v2/users 端点时。

注意,由于我们有一个唯一的电子邮件索引,您的下一次运行部分成功。对于已创建的记录的 POST 请求将返回 400 Bad Request

您可以在 studio3t.com/ 上了解更多关于 Studio 3T 的信息。

添加用户

我们已经在本章前面的 Services 部分介绍了如何创建 POST 请求。现在,让我们看看您如何更新现有的用户记录:

**server/src/v2/routes/userRouter.ts**
/**
 * @swagger
 * /v2/users/{id}:
 *   put:
 */
router.put(
  '/:userId',
  authenticate({
    requiredRole: Role.Manager,
    permitIfSelf: {
      idGetter: (req: Request) => req.body._id,
      requiredRoleCanOverride: true,
    },
  }),
  async (req: Request, res: Response) => {
    const userData = req.body as User
    delete userData._id
    await UserCollection.findOneAndUpdate(
      { _id: new ObjectID(req.params.userId) },
      {
        $set: userData,
      }
    )
    const user = await UserCollection
      .findOne({ _id: new ObjectID(req.params.userId) })
    if (!user) {
      res.status(404).send({ message: 'User not found.' })
    } else {
      res.send(user)
    }
  }
) 

我们从请求体中设置 userData。然后我们 delete 请求体中的 _id 属性,因为 URL 参数是信息的权威来源。此外,这还可以防止用户的 ID 被意外更改成不同的值。

然后,我们利用findOneAndUpdate方法定位并更新记录。我们使用 ID 查询记录。通过使用 MongoDB 的$set运算符来更新记录。

最后,我们从数据库中加载保存的记录并将其返回给客户端。

POST 和 PUT 方法应始终响应记录的更新状态。

对于我们最后的实现部分,让我们回顾一下可以支持分页数据表的 API 端点。

使用 DocumentTS 进行分页和过滤

到目前为止,DocumentTS 最有用的功能是findWithPagination,如关于 DocumentTS部分所述。让我们利用findWithPagination来实现/v2/users端点,它可以返回所有用户:

**server/src/v2/routes/userRouter.ts**
/**
 * @swagger
 * components:
 *   parameters:
 *     filterParam: …
 *     skipParam: …
 *     limitParam: …
 *     sortKeyParam: …
 */
/**
 * @swagger
 * /v2/users:
 *   get:
 */
router.get(
  '/',
  authenticate({ requiredRole: Role.Manager }),
  async (req: Request, res: Response) => {
    const query: Partial<IQueryParameters> = {
      filter: req.query.filter,
      limit: req.query.limit,
      skip: req.query.skip,
      sortKeyOrList: req.query.sortKey,
      projectionKeyOrList: ['email', 'role', '_id', 'name'],
    }
    const users = await UserCollection.findWithPagination<User>(query)
    res.send(users)
  }
) 

我们使用req.query对象作为局部变量复制 URL 中的所有参数。我们定义了一个名为projectionKeyOrList的附加属性,以限制可以返回给客户端的记录属性。在这种情况下,仅返回emailrole_idname属性。这最小化了通过网络发送的数据量。

最后,我们只需将新的query对象传递给findWithPagination函数,并将结果返回给客户端。

您可以在 Postman 中创建一个新的请求来验证您的新端点的正确功能,如下面的截图所示:

图片

图 10.14:使用 Postman 调用获取用户

第十二章食谱 - 主/详细,数据表和 NgRx中,我们将实现一个利用过滤、排序和数据限制功能的分页数据表。

恭喜!您现在掌握了代码在整个软件栈中如何工作的知识,从数据库到前端和后端。

摘要

在本章中,我们介绍了全栈架构。您学习了如何构建最小化的 MEAN 栈。您现在知道如何为全栈应用程序创建 monorepo,并使用 TypeScript 配置 Node.js 服务器。您将 Node.js 服务器容器化,并使用 Docker Compose 声明性地定义了您的基础设施。使用 Docker Compose 与 CircleCI,您在 CI 环境中验证了您的基础设施。

您使用 Swagger 和 OpenAPI 规范设计了 RESTful API,设置了 Express.js 应用程序,并配置了它,以便您可以将 Swagger 定义作为 API 的文档进行集成。您使用 DocumentTS ODM 配置 MongoDB,以便您可以轻松连接和查询文档。您定义了一个具有密码散列功能的用户模型。

然后您实现了基于 JWT 的认证服务。您实现了一个authenticate中间件来保护 API 端点并允许基于角色的访问。您学习了如何使用 Postman 与 RESTful API 交互。使用 Postman 的自动化功能,您生成了测试数据。最后,您实现了认证功能的 RESTful API 和用户的 CRUD 操作。

在接下来的两个章节中,我们将介绍 Angular 食谱来创建表单和数据表。当你实现它们时,你将希望 Lemon Mart 服务器运行起来,以验证表单和表格的正确功能。

练习

你使用authenticate中间件来保护你的端点。你已配置 Postman 发送有效的令牌,以便你可以与受保护的端点通信。作为练习,尝试移除authenticate中间件,并使用和没有有效令牌的方式调用相同的端点。重新添加中间件,再次尝试相同的事情。观察你从服务器收到的不同响应。

进一步阅读

问题

尽可能好地回答以下问题,以确保你在不使用 Google 的情况下理解了本章的关键概念。你需要帮助回答这些问题吗?请参阅附录 D自我评估答案,在线位于static.packt-cdn.com/downloads/9781838648800_Appendix_D_Self-Assessment_Answers.pdf或访问expertlysimple.io/angular-self-assessment

  1. 构成优秀开发者体验的主要组件有哪些?

  2. .env文件是什么?

  3. authenticate中间件的作用是什么?

  4. Docker Compose 与使用Dockerfile有何不同?

  5. 什么是 ODM?它与 ORM 有何不同?

  6. 中间件是什么?

  7. Swagger 的用途是什么?

  8. 你会如何重构userRouter.ts/v2/users/{id} PUT端点的代码,以便代码可重用?

第十一章:配方 – 可重用性、路由和缓存

在接下来的两章中,我们将完成 LemonMart 的主要实现,并完善我们对路由优先方法的覆盖。在本章中,我将通过创建一个可重用且可路由的组件,同时支持数据绑定,来强化解耦组件架构的概念。我们使用 Angular 指令来减少样板代码,并利用类、接口、枚举、验证器和管道,通过 TypeScript 和 ES 特性最大化代码重用。

此外,我们还将创建一个在架构上可扩展且支持响应式设计的多步骤表单。然后,我们将通过引入柠檬评分器和封装名称对象的可重用表单部分来区分用户控件和组件。

确保在实现本章中提到的配方时,你的 lemon-mart-server 正在运行。有关更多信息,请参阅 第十章RESTful API 和全栈实现

本章内容丰富。它以配方格式组织,因此当你正在处理项目时,可以快速参考特定的实现。我将涵盖实现的结构、设计和主要组件。我将突出显示重要的代码片段,以解释解决方案是如何组合在一起的。利用你迄今为止所学到的知识,我期望读者能够填写常规实现和配置细节。然而,如果你遇到困难,始终可以参考 GitHub 项目。

在本章中,你将学习以下主题:

  • 使用缓存服务响应的 HTTP PUT 请求

  • 多步骤响应式表单

  • 使用指令重用重复模板行为

  • 可扩展的表单架构,具有可重用表单部分

  • 输入掩码

  • 使用 ControlValueAccessor 的自定义控件

  • 使用网格列表布局

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

为了准备本章内容,请执行以下操作:

  1. 克隆 github.com/duluca/lemon-mart 上的存储库。

  2. 在根目录下执行 npm install 以安装依赖项。

  3. 本章的代码示例位于以下子文件夹下:

    projects/ch11 
    
  4. 要运行本章的 Angular 应用程序,请执行以下命令:

    npx ng serve ch11 
    
  5. 要运行本章的 Angular 单元测试,请执行以下命令:

    npx ng test ch11 --watch=false 
    
  6. 要运行本章的 Angular e2e 测试,请执行以下命令:

    npx ng e2e ch11 
    
  7. 要构建本章的生产就绪 Angular 应用程序,请执行以下命令:

    npx ng build ch11 --prod 
    

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

请注意,书中或 GitHub 上的源代码可能并不总是与 Angular CLI 生成的代码相匹配。由于生态系统不断演变,书中代码与 GitHub 上代码之间的实现也可能存在细微差异。随着时间的推移,示例代码发生变化是自然的。在 GitHub 上,您可能会找到更正、支持库新版本的修复或多种技术的并排实现,供读者观察。读者只需实现书中推荐的理想解决方案即可。如果您发现错误或有问题,请创建一个 issue 或提交一个 pull request 到 GitHub,以惠及所有读者。

你可以在附录 C保持 Angular 和工具常青中了解更多关于更新 Angular 的信息。您可以从static.packt-cdn.com/downloads/9781838648800_Appendix_C_Keeping_Angular_and_Tools_Evergreen.pdfexpertlysimple.io/stay-evergreen在线找到此附录。

让我们从实现一个用户服务来检索数据开始,这样我们就可以构建一个表单来显示和编辑个人资料信息。稍后,我们将重构此表单以抽象出其可重用部分。

使用 GET 实现用户服务

为了实现用户个人资料,我们需要一个可以执行IUser上的 CRUD 操作的服务。我们将创建一个实现以下接口的用户服务:

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

在本节中,我们将实现getUserupdateUser函数。我们将在第十二章食谱 – 主/详细信息,数据表和 NgRx中实现getUsers,以支持数据表分页。

首先创建用户服务:

  1. src/app/user/user下创建一个UserService

  2. 从前面的片段中声明IUserService接口,不包括getUsers函数。

  3. 使用CacheService扩展UserService类并实现IUserService

  4. 如下所示在构造函数中注入HttpClient

    **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 extends CacheService implements IUserService {
      constructor() {
        super()
      }
      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函数,可以加载任何用户的个人资料信息。请注意,此函数的安全性由服务器实现中的认证中间件提供。请求者可以获取自己的个人资料,或者他们需要是管理员。我们将在本章后面使用getUser与解析守卫。

实现带有缓存的 PUT

实现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.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.removeItem('draft-user')
      },
      (err) => throwError(err)
    )
    return updateResponse$
  } 

注意使用缓存服务中的 setItem 来保存用户输入的数据,以防 put 调用失败。当调用成功时,我们使用 removeItem 删除缓存数据。同时注意我们如何使用 map(User.Build) 将来自服务器的用户作为 User 对象进行润滑,这调用 class User 的构造函数。

“Hydrate”是一个常用术语,指的是用数据库或网络请求中的数据填充一个对象。例如,我们在组件之间传递或从服务器接收的 User JSON 对象符合 IUser 接口,但它不是 class User 类型。我们使用 toJSON 方法将对象序列化为 JSON。当我们从 JSON 润滑并实例化一个新对象时,我们执行相反的操作并反序列化数据。

需要强调的是,在传递数据时,你应该始终坚持使用接口,而不是像 User 这样的具体实现。这是 SOLID 原则中的 D(依赖倒置原则)。依赖于具体实现会带来很多风险,因为它们经常变化,而像 IUser 这样的抽象很少会变化。毕竟,你不会直接将灯泡焊接在墙上的电线中。相反,你首先将灯泡焊接在插头上,然后使用插头获取所需的电力。

完成此代码后,UserService 现在可以用于基本的 CRUD 操作。

多步骤响应式表单

总体来说,表单与你的应用程序的其他部分不同,它们需要特殊的架构考虑。我不建议过度设计你的表单解决方案,使用动态模板或启用路由的组件。从可维护性和易于实施的角度来看,创建一个巨大的组件比使用上述一些策略和过度设计更好。

我们将实现一个多步骤输入表单,在单个组件中捕获用户配置文件信息。我将在本章的“可重用表单部分和可扩展性”部分介绍我推荐的将表单拆分为多个组件的技术。

由于表单的实现在这部分和本章后面的内容中变化很大,你可以在 GitHub 上找到初始版本的代码,地址为 projects/ch11/src/app/user/profile/profile.initial.component.tsprojects/ch11/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
    ) {}
    ngOnInit() { 
      this.buildForm()
      this.authService.currentUser$
              .pipe(
                 filter((user) => user !== null),
                 tap((user) => { 
                  this.currentUserId = user._id
                  this.buildForm(user)
                })
               )
              .subscribe()
    }
      private get currentUserRole() {
        return this.authService.authStatus$.value.userRole
      }
    buildForm(user?: IUser) {}
    ...
    } 
    

在加载时,我们从authService请求当前用户,但这可能需要一些时间,所以我们首先使用this.buildForm()作为第一条语句构建一个空表单。我们还将用户的 ID 存储在currentUserId属性中,稍后当我们实现save功能时将需要它。

注意,我们过滤掉了nullundefined的用户。

在本章的后面部分,我们将实现一个解析守卫,根据路由上提供的userId加载用户,以提高该组件的可重用性。

表单控件和表单组

如您所忆,FormControl对象是表单的最基本部分,通常代表单个输入字段。我们可以使用FormGroup将一组相关的FormControl对象组合在一起,例如一个人的名字的各个部分(首、中、姓)。FormGroup对象还可以将FormControlFormGroupFormArray对象组合在一起,后者允许我们拥有动态重复的元素。FormArray将在本章的“动态表单数组”部分进行介绍。

我们的形式有很多输入字段,因此我们将使用由this.formBuilder.group创建的FormGroup来容纳我们的各种FormControl对象。此外,子FormGroup对象将允许我们保持数据结构的正确形状。

由于表单的实现在这部分和本章后面的部分之间发生了巨大变化,您可以在 GitHub 上找到初始版本的代码,位于projects/ch11/src/app/user/profile/profile.initial.component.tsprojects/ch11/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. 账户信息

    • 输入验证

    • 响应式布局与媒体查询

    • 计算属性

    • 日期选择器

  2. 联系信息

    • 自动完成支持

    • 动态表单数组

  3. 复习

    • 只读视图

    • 保存和清除数据

让我们为一些新的材料模块准备 UserModule

随着我们开始添加子材料模块,将我们的根 material.module.ts 文件重命名为 app-material.modules.ts,以符合 app-routing.module.ts 的命名方式。从现在开始,我将使用后者的约定。

  1. src/app/material.modules.ts 文件重命名为 app-material.module.ts,然后将 MaterialModule 类重命名为 AppMaterialModule

  2. 创建一个包含以下材料模块的 user-material.module.ts 文件:

    MatAutocompleteModule,
    MatDatepickerModule,
    MatDividerModule,
    MatLineModule,
    MatNativeDateModule,
    MatRadioModule,
    MatSelectModule,
    MatStepperModule, 
    
  3. 确保 user.module.ts 正确导入以下内容:

    • 新的 user-material.module

    • 基线 app-material.module

    • 所需的 ReactiveFormsModuleFlexLayoutModule

  4. 实现一个包含第一步的横向步进器表单:

    由于本节和本章后面的表单实现变化很大,你可以在 GitHub 上的 projects/ch11/src/app/user/profile/profile.initial.component.tsprojects/ch11/src/app/user/profile/profile.initial.component.html 找到初始版本的代码。

    **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> 
    
  5. 现在,开始实现 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">
        <mat-error 
          *ngIf="formGroup.get('name.first')?.hasError('required')">
          First Name is required
        </mat-error>
        <mat-error 
          *ngIf="formGroup.get('name.first')?.hasError('minLength')">
          Must be at least 2 characters
        </mat-error>
        <mat-error 
          *ngIf="formGroup.get('name.first')?.hasError('maxLength')">
          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">
        <mat-error *ngIf="formGroup.get('name.middle')?.invalid">
          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">
        <mat-error   
           *ngIf="formGroup.get('name.last')?.hasError('required')">
           Last Name is required
        </mat-error>
        <mat-error 
           *ngIf="formGroup.get('name.last')?.hasError('minLength')">
           Must be at least 2 characters
        </mat-error>
        <mat-error 
          *ngIf="formGroup.get('name.last')?.hasError('maxLength')">
          Can't exceed 50 characters
        </mat-error>
      </mat-form-field>
    </div> 
    
  6. 请注意理解到目前为止步进器和表单配置是如何工作的。你应该能看到第一行渲染,从 lemon-mart-server 拉取数据:

    图 11.1:多步骤表单 – 第 1 步

注意,将 fxLayout.lt-sm="column" 添加到具有 fxLayout="row" 的行中,可以启用表单的响应式布局,如下所示:

图 11.2:移动端的多步骤表单

在我们继续介绍如何实现 出生日期 字段之前,让我们通过实现错误消息来重新评估我们的策略。

使用指令重用重复模板行为

在上一节中,我们为 name 对象的每个字段部分的每个验证错误实现了一个 mat-error 元素。对于三个字段,这会迅速增加到七个元素。在 第八章设计身份验证和授权 中,我们实现了 common/validations.ts 以重用验证规则。我们可以使用属性指令重用我们在 mat-error 中实现的行为,或者任何其他 div,使用属性指令。

属性指令

第一章Angular 及其概念简介 中,我提到 Angular 组件代表 Angular 应用程序的最基本单元。通过组件,我们定义自己的 HTML 元素,这些元素可以重用模板和一些 TypeScript 代码所表示的功能和特性。另一方面,指令增强了现有元素或组件的功能。在某种程度上,组件是一个超级指令,它增强了基本的 HTML 功能。

考虑到这个视图,我们可以定义三种类型的指令:

  • 组件

  • 结构指令

  • 属性指令

基本上,组件是带有模板的指令,这是你将最常使用的指令类型。结构指令通过添加或删除元素来修改 DOM,*ngIf*ngFor 是典型的例子。最后,属性指令允许你定义可以添加到 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> 

我们有一个标准布局结构用于材料表单字段,但只有一个 mat-error 元素。mat-error 上有三个新属性:

  • input 通过模板引用变量绑定到标记为 #name 的 HTML 输入元素,这样我们就可以访问输入元素的模糊事件,并能够读取 placeholderaria-labelformControlName 属性。

  • group 绑定到包含表单控件的父表单组对象,因此我们可以使用输入的 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. 在指令外部,定义一个新的类型名为ValidationError,它定义了我们将要处理的错误条件类型:

    **src/app/user-controls/field-error/field-error.directive.ts**
    export type ValidationError = 
       'required' | 'minlength' | 'maxlength' | 'invalid' 
    
  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[]
        | { error: ValidationError; message: string }
        | { error: ValidationError; message: string }[]
      @Input() input: HTMLInputElement | undefined
      @Input() group: FormGroup
      @Input() fieldControl: AbstractControl | null
      @Input() fieldLabel: string | undefined 
    

    注意,我们已经讨论了前三个属性的目的。fieldControlfieldLabel是可选属性。如果指定了inputgroup,可选属性可以自动填充。由于它们是类级别的变量,因此公开它们是有意义的,以防用户想要覆盖指令的默认行为。这有助于创建灵活且可重用的控件。

  6. 在构造函数中导入元素引用,这可以在稍后由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.innerHTML = 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以开始监听值变化,实现一个onblur事件处理器,该处理器触发updateErrorMessage()为 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/ch11/src/app/user/profile/profile.initial.component.html找到表单模板的代码,在projects/ch11/src/app/user/profile/profile.initial.component.ts找到component类。

不要包含app-lemon-raterapp-view-user元素,并从电话号码中移除mask属性,我们将在本章后面实现它。

在这里,你可以看到用户资料在 LemonMart 上的显示方式:

图 11.3:基本完成的配置文件组件

接下来,让我们继续查看profile组件,看看出生日期字段是如何工作的。

计算属性和 DatePicker

我们可以根据用户输入显示基于计算属性的值。例如,为了显示一个人的年龄,基于他们的出生日期,引入计算年龄的类属性,并如下显示它:

**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()
} 

要验证过去一百年内的日期,实现一个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 />
  <mat-hint *ngIf="formGroup.get('dateOfBirth')?.value">
    {{ 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的实际效果如下所示:

图 11.4:使用 DatePicker 选择日期

注意,2020 年 4 月 26 日之后的日期将以灰色显示。选择日期后,计算出的年龄将如下显示:

图 11.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">
    <mat-option *ngFor="let state of (states$ | async)" [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 字符时,它看起来是这样的:

图片

图 11.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 一起工作。当用户点击 添加 按钮创建新行时,后者非常有用:

**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>
  <mat-list-item style="margin-top: 36px;"
    *ngFor="let position of phonesArray.controls; let i = index"
      [formGroupName]="i">
    <mat-form-field appearance="outline" fxFlex="100px">
      <mat-label>Type</mat-label>
      <mat-select formControlName="type">
        <mat-option *ngFor="let type of PhoneTypes"
             [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" />
      <mat-error
        *ngIf="phonesArray.controls[i].invalid &&  
               phonesArray.controls[i].touched">
        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 函数是如何在模板中内联实现的,这使得它更容易阅读和维护。

让我们看看动态数组应该如何工作:

图片

图 11.7:使用 FormArray 的多个输入

现在我们已经完成了数据输入,我们可以继续到步骤器的最后一步,审查。然而,如前所述,审查步骤使用 <app-view-user> 指令来显示其数据。让我们首先构建这个视图。

创建共享组件

这里是 <app-view-user> 指令的最小实现,它是 审查 步骤的先决条件。

user 模块下创建一个新的 viewUser 组件,如下所示:

**src/app/user/view-user/view-user.component.ts**
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'
import { Router } from '@angular/router'
import { BehaviorSubject } from 'rxjs'
import { IUser, User } from '../user/user'
@Component({
  selector: 'app-view-user',
  template: `
    <div *ngIf="currentUser$ | async as currentUser">
      <mat-card>
        <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>
        <mat-card-actions *ngIf="editMode">
          <button mat-button mat-raised-button
                  (click)="editUser(currentUser._id)">
            Edit
          </button>
        </mat-card-actions>
      </mat-card>
    </div>
  `,
  styles: [
    `
      .bold {
        font-weight: bold;
      }
    `,
  ],
})
export class ViewUserComponent implements OnChanges {
  @Input() user: IUser
  readonly currentUser$ = new BehaviorSubject(new User())
  get editMode() {
    return !this.user
  }
  constructor(private router: Router) {}
  ngOnChanges(changes: SimpleChanges): void {
    this.currentUser$.next(User.Build(changes.user.currentValue))
  }
  editUser(id: string) {
    this.router.navigate(['/user/profile', id])
  }
} 

前面的组件使用 @Input 输入绑定从外部组件获取用户数据,符合 IUser 接口。我们实现了 ngOnChanges 事件,该事件在绑定数据更改时触发。在这个事件中,我们使用 User.Build 将存储在 user 属性中的简单 JSON 对象作为 User 类的实例进行填充。

然后,我们定义一个只读的BehaviorSubject,命名为this.currentUser$,这样我们就可以使用下一个函数异步地将其更新。这种灵活性将在我们稍后使该组件在多个上下文中可重用时派上用场。即使我们想这样做,我们也不能直接绑定到user,因为像fullName这样的计算属性只有在数据被注入到User类的实例中时才会工作。

现在,我们准备好完成多步骤表单。

查看并保存表单数据

在多步骤表单的最后一步,用户应该能够查看并保存表单数据。作为一个好的实践,成功的POST请求将返回保存的数据回浏览器。然后我们可以用从服务器返回的信息重新加载表单:

**src/app/user/profile/profile.component.ts**
...
  async save(form: FormGroup) {
    this.subs.add(
      this.userService
        .updateUser(this.currentUserId, form.value)
        .subscribe(
          (res: IUser) => {
            **this.formGroup.patchValue(res)**
            this.uiService.showToast('Updated user')
          },
          (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>
      <div *ngIf="userError" 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(),它可以方便地重置所有用户输入。

最终产品应该看起来是这样的:

图片

图 11.8:查看步骤

现在用户配置文件输入已完成,我们离最终目标——创建一个主/详细视图还有一半的路要走,在这个视图中,经理可以点击用户并查看他们的配置文件详情。我们还有更多的代码要添加,并且在过程中,我们陷入了添加大量样板代码来加载组件所需数据的模式。

接下来,让我们重构我们的表单,使其代码可重用和可扩展,即使我们的表单有数十个字段,代码仍然是可维护的,我们不会引入指数级成本增加来做出更改。

使用可重用表单部分扩展架构

如在多步骤响应式表单部分的介绍中提到的,表单是紧密耦合的怪物,可能会变得很大,使用错误的架构模式来扩展你的实现可能会在实现新功能或维护现有功能时引起重大问题。

为了展示你如何将表单拆分成多个部分,我们将重构表单,提取以下截图中的突出显示部分,即名字表单组,作为一个单独的组件。完成这一点的技术与你想要将表单的每个步骤放入单独组件时使用的技术相同:

图片

图 11.9:用户配置文件的名字部分被突出显示

通过使名称表单组可重用,你还将了解如何将你构建到该表单组中的业务逻辑在其他表单中重用。我们将名称表单组逻辑提取到一个名为 NameInputComponent 的新组件中。在这个过程中,我们也有机会将一些可重用表单功能提取到 BaseFormComponent 作为 抽象类

这里将会有几个组件协同工作,包括 ProfileComponentViewUserComponentNameInputComponent。我们需要这三个组件中的所有值在用户输入时都保持最新。

ProfileComponent 将拥有主表单,我们需要在其中注册任何子表单。一旦我们这样做,你之前学到的所有表单验证技术仍然适用。

这是让你的表单能够在许多组件之间扩展并继续易于使用的关键方式,同时不会引入不必要的验证开销。因此,回顾这些对象之间的不同交互,有助于巩固你对它们异步和解耦行为性质的理解:

图片

图 11.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$,该对象通过 async 绑定到模板。

我们将首先构建一个 BaseFormComponent,然后 NameInputComponentProfileComponent 将实现它。

基础表单组件作为一个抽象类

通过实现一个基抽象类,我们可以共享通用功能并标准化实现所有实现表单的组件。抽象类不能单独实例化,因为它本身没有模板,单独使用是没有意义的。

注意,BaseFormComponent 只是一个 class,而不是 Angular 组件。

BaseFormComponent 将标准化以下内容:

  • @Input initialData,并禁用为绑定目标

  • @Output formReady 事件

  • formGroup,在模板的 buildForm 函数中使用的 FormGroup 以构建 formGroup

在前面的假设下,基类可以提供一些通用功能:

  • patchUpdatedData,可以在不重建的情况下更新 formGroup 中的数据(部分或全部)。

  • registerFormderegisterForm 可以注册或注销子表单。

  • deregisterAllForms 可以自动注销任何已注册的子表单。

  • hasChanged 可以确定在 ngOnChange 事件处理器提供的 SimpleChange 对象的情况下,initialData 是否已更改。

  • patchUpdatedDataIfChanged 利用 hasChanged 并使用 patchUpdatedData 来更新数据,前提是 initialDataformGroup 已经初始化,并且有更新。

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 组件代码和模板文件中开始识别名称表单组:

  1. 以下为名称表单组实现:

    **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 与现有的一个注册,它们的工作方式与重构前完全相同。

  2. 接下来是名称表单组的模板:

    **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>
    ... 
    

    你将把大部分代码移动到新组件中。

  3. user 文件夹下创建一个新的 NameInputComponent

  4. BaseFormComponent 扩展类。

  5. 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函数,因为它被定义为抽象的。这是强制开发人员遵守标准的好方法。此外,注意任何基函数都可以通过简单地重新定义函数被实现类覆盖。您将在重构ProfileComponent时看到这一点。

  6. 实现函数buildForm

  7. ProfileComponentformGroupname属性设置为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],
        })
      } 
    
  8. 通过将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>
      `, 
    
  9. 实现事件处理程序ngOnInit

    **src/app/user/name-input/name-input.component.ts**
    ngOnInit() {
      this.formGroup = this.buildForm(this.initialData)
      if (this.disable) {
        this.formGroup.disable()
      }
      this.formReady.emit(this.formGroup)
    } 
    

    在每个BaseFormComponent的实现中,正确实现ngOnInit事件处理程序至关重要。前例是任何您可能实现的child组件的相当标准的操作。

    注意,ProfileComponent中的实现将略有不同。

  10. 实现事件处理程序ngOnChanges,利用基类的patchUpdatedDataIfChanged行为:

    **src/app/user/name-input/name-input.component.ts**
    ngOnChanges(changes: SimpleChanges) {
      this.patchUpdatedDataIfChanged(changes)
    } 
    

    注意,在patchUpdatedDataIfChanged函数中,将onlySelf设置为false会导致父表单也会更新。如果您想优化这种行为,您可以重写该函数。

    现在您已经有一个完全实现的NameInputComponent,可以将其集成到ProfileComponent中。

    为了验证您未来的ProfileComponent代码,请参考projects/ch11/src/app/user/profile/profile.component.tsprojects/ch11/src/app/user/profile/profile.component.html

    在您开始使用NameInputComponent之前,执行以下重构:

  11. ProfileComponent重构为扩展BaseFormComponent,并根据需要符合其默认值。

  12. 定义一个只读的nameInitialData$属性,其类型为BehaviorSubject<IName>,并用空字符串初始化它。

  13. 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

  14. 确保您的ngOnInit被正确实现。

    注意,更新的ProfileComponent中还有一些额外的重构,例如以下片段中看到的patchUser函数。当您更新组件时,不要错过这些更新。

    **src/app/user/profile/profile.component.ts**
    ngOnInit() {
      this.formGroup = this.buildForm()
      this.subs.sink = this.authService.currentUser$
        .pipe(
          filter((user) => user != null),
          tap((user) => this.patchUser(user))
        )
        .subscribe()
    } 
    

    initialData更新时,重要的是要使用pathUpdatedData以及nameInitialData$更新当前表单的数据。

  15. 确保正确实现了ngOnDestroy

    **src/app/user/profile/profile.component.ts**
      ngOnDestroy() {
        this.subs.unsubscribe()
        this.deregisterAllForms()
      } 
    

总是要记得取消订阅,您可以使用SubSink包轻松地这样做。您还可以利用基类功能来自动注销所有子表单。

接下来,让我们了解如何对用户输入进行屏蔽以提高数据质量。

输入掩码

掩码用户输入是一种输入用户体验工具,同时也是数据质量工具。我是ngx-mask库的粉丝,它使得在 Angular 中实现输入掩码变得非常简单。我们将通过更新电话号码输入字段来演示输入掩码,以确保用户输入有效的电话号码,如下面的截图所示:

图片

图 11.11:带有输入掩码的电话号码字段

按以下方式设置您的输入掩码:

  1. 使用npm i ngx-mask通过 npm 安装库。

  2. 导入forRoot模块:

    **src/app/app.module.ts**
    export const options: Partial<IConfig> | (() => Partial<IConfig>) = {
      showMaskTyped: true,
    }
    @NgModule({
      imports: [
        ...
        **NgxMaskModule.forRoot(options),**
      ]
    }) 
    
  3. user功能模块中导入模块:

    **src/app/user/user.module.ts**
    @NgModule({
      imports: [
        ...
        NgxMaskModule.forChild(),
      ]
    }) 
    
  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"** />
      <mat-error *ngIf="this.phonesArray.controls[i].invalid">
        A valid phone number is required
      </mat-error>
    </mat-form-field> 
    

简单就是这样。您可以在 GitHub 上了解更多关于模块及其功能的信息:github.com/JsDaddy/ngx-mask

带有ControlValueAccessor的自定义控件

到目前为止,我们已经学习了使用 Angular Material 提供的标准表单控件和输入控件来使用表单。然而,您也可以创建自定义用户控件。如果您实现了ControlValueAccessor接口,那么您的自定义控件将与表单和ControlValueAccessor接口的验证引擎很好地协同工作。

我们将创建以下截图所示的定制评分控件,并将其放置在ProfileComponent的第一步中:

图片

图 11.12:柠檬评分器用户控件

用户控件本质上是高度可重用、紧密耦合且定制的组件,用于实现丰富的用户交互。让我们来实现一个。

实现自定义评分控件

柠檬评分器将根据用户与控件实时交互时选择的柠檬数量动态突出显示。因此,创建高质量的定制控件是一项耗时的任务。

Lemon Rater 是 Jennifer Wadella 在github.com/tehfedaykin/galaxy-rating-app找到的 Galaxy 评分应用示例的修改版本。我强烈推荐您观看 Jennifer 在 Ng-Conf 2019 上关于ControlValueAccessor的演讲,链接在进一步阅读部分。

按以下方式设置您的自定义评分控件:

  1. user-controls文件夹下创建一个名为LemonRater的新组件。

  2. 在同一文件夹中创建一个LemonRaterModule

  3. 声明并导出组件。

  4. 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
      }
    } 
    
  5. NG_VALUE_ACCESSOR提供者与multi属性设置为true。这将注册我们的组件到表单的更改事件,以便在用户与评分器交互时更新表单值:

    **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,
        },
      ], 
    
  6. 实现一个自定义评分方案,该方案包含一个函数,允许根据用户输入设置所选评分:

    **src/app/user-controls/lemon-rater/lemon-rater.component.ts**
    export class LemonRaterComponent implements ControlValueAccessor { 
      @ViewChild('displayText', { static: false }) displayTextRef: ElementRef
    
      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

  7. 实现模板,参考svg标签内容的示例代码:

    **src/app/user-controls/lemon-rater/lemon-rater.component.html**
    **<i #displayText></i>** 
    <div class="lemons" [ngClass]="{'disabled': disabled}"> 
      <ng-container *ngFor="let lemon of ratings"> 
        <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>
      </ng-container>
    </div> 
    

    模板中最重要的三个属性是mouseovermouseoutclickmouseover显示用户当前悬停的评分文本,mouseout将显示文本重置为所选值,click调用我们实现的setRating方法来记录用户的选择。然而,控件可以通过突出显示用户悬停在评分或选择它时柠檬的数量来提供更丰富的用户交互。我们将通过一些 CSS 魔法来实现这一点。

  8. 实现用户控件的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 等级。

Limoncu,在土耳其语中意味着种植或出售柠檬的人,是 Lemon Mart 的专有员工参与度和绩效测量系统。

让我们集成柠檬评分器:

  1. 首先在UserModule中导入LemonRaterModule

  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与任何其他控件一样的方式,简单地与自定义控件集成。

恭喜!你应该有一个与你的表单集成的可工作的自定义控件。

使用网格列表布局

Angular Flex Layout 库非常适合使用 CSS Flexbox 布局内容。Angular Material 通过使用 CSS Grid 及其网格列表功能提供另一种布局内容的机制。演示此功能的一个好方法是在LoginComponent中实现一个用于伪造登录信息的帮助列表,如下所示:

img/B14094_11_13.png

图 11.13:带有网格列表的登录助手

按照以下方式实现你的列表:

  1. 首先定义一个roles属性,它是一个包含所有角色的数组:

    **src/app/login/login.component.ts** 
    roles = Object.keys(Role) 
    
  2. MatExpansionModuleMatGridListModule导入到AppMaterialModule中:

  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>
      <div *ngFor="let role of roles; odd as oddRow">
        <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"**>
            <div
              ***ngIf**="role.toLowerCase() === 'none'**; else otherRoles"**
            >
              Any @test.com email
            </div>
            <ng-template **#otherRoles**>
              {{role.toLowerCase()}}@test.com
            </ng-template>
            <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电子邮件列的内容右对齐。我们使用*ngIf; 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.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 = localStorage.getItem('draft-user')
        if (draftUser != null) {
          user = User.Build(JSON.parse(draftUser))
        }
        if (user) {
          this.uiService.showToast('Loaded data from cache')
        }
      } catch (err) {
        localStorage.removeItem('draft-user')
      }
      return of(user)
    } 
    clearCache() {
      localStorage.removeItem('draft-user')
    } 
    

    在加载数据后,我们使用JSON.parse将数据解析为 JSON 对象,然后使用User.Build来填充User对象。

  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()
      this.subs.sink = combineLatest([
            this.loadFromCache(),
            this.authService.currentUser$,
          ])
            .pipe(
              filter(
                ([cachedUser, me]) => 
                  cachedUser != null || me != null
              ),
              tap(
                ([cachedUser, me]) => 
                  this.patchUser(cachedUser || me)
              )
            )
            .subscribe()
    } 
    

我们利用combineLatest运算符将loadFromCachecurrentUser$的输出合并。我们检查是否有流返回非空值。如果存在缓存的用户,它将优先于从currentUser$接收到的值。

你可以通过将浏览器的网络状态设置为离线来测试你的缓存,如下所示:

图 11.14:离线网络状态

将浏览器的网络状态设置为离线,如下所示:

  1. 在 Chrome DevTools 中,导航到网络选项卡。

  2. 在前面的截图标记为2的下拉菜单中选择离线

  3. 修改你的表单,例如名称,然后点击更新

  4. 你会在表单底部看到发生未知错误的错误信息。

  5. 网络选项卡中,你会看到你的 PUT 请求失败了。

  6. 现在,刷新你的浏览器窗口,观察你输入的新名称仍然存在。

参考以下截图,它显示了从缓存加载数据后你收到的吐司通知:

图 11.15:从缓存加载数据

在缓存周围实现一个优秀的用户体验非常具有挑战性。我提供了一个基本的方法来展示什么是可能的。然而,有许多边缘情况可能会影响你的应用程序中缓存的工作方式。

在我的情况下,缓存固执地存在,直到我们成功将数据保存到服务器。这可能会让一些用户感到沮丧。

恭喜!您已成功实现了一个复杂的表单来捕获用户数据!

练习

进一步增强login组件,以添加AuthMode.CustomServer的登录助手。

摘要

在本章中,我们涵盖了 LemonMart 的表单、指令和用户控制相关功能。我们创建了可重用的组件,可以使用数据绑定嵌入到另一个组件中。我们展示了您可以使用 PUT 向服务器发送数据并缓存用户输入的数据。我们还创建了一个响应屏幕尺寸变化的分步输入表单。通过利用可重用表单部分、基类表单以容纳常用功能以及属性指令来封装字段级错误行为和消息,我们消除了组件中的样板代码。

我们使用日期选择器、自动完成支持和表单数组创建了动态表单元素。我们实现了具有输入掩码和柠檬评分器的交互式控件。通过使用ControlValueAccessor接口,我们将柠檬评分器无缝集成到我们的表单中。我们展示了我们可以通过提取名称作为其自己的表单部分来线性扩展表单的大小和复杂性。此外,我们还介绍了使用网格列表构建布局。

在下一章中,我们将进一步增强我们的组件,以便我们可以使用路由器来编排它们。我们还将实现主/详细视图和数据表,并探索 NgRx 作为使用 RxJS/BehaviorSubject 的替代方案。

进一步阅读

问题

尽可能地回答以下问题,以确保你在不使用谷歌搜索的情况下理解了本章的关键概念。你需要帮助回答这些问题吗?请参阅附录 D自我评估答案,在线访问static.packt-cdn.com/downloads/9781838648800_Appendix_D_Self-Assessment_Answers.pdf或访问expertlysimple.io/angular-self-assessment

  1. 组件和用户控件之间的区别是什么?

  2. 属性指令是什么?

  3. ControlValueAccessor接口的目的是什么?

  4. 序列化、反序列化和活化是什么?

  5. 在表单上修补值意味着什么?

  6. 你如何将两个独立的FormGroup对象相互关联?

第十二章:食谱 – 主/详细,数据表和 NgRx

本章,我们在 LemonMart 上通过实现商业应用中最常用的两个功能:主/详细视图和数据表,完成了路由优先架构的实现。我通过 LemonMart 和 LemonMart Server 的服务器端分页演示了数据表,突出了前端和后端的集成。

确保在实现本章概述的食谱时,你的lemon-mart-server正在运行。有关更多信息,请参阅第十章RESTful API 和全栈实现

我们利用路由编排的概念来编排组件如何加载数据或渲染。我们使用解析守卫在导航到组件之前加载数据时减少样板代码。我们使用辅助路由通过路由配置来布局组件。我们在多个上下文中复用相同的组件。

然后,我们使用 LocalCast 天气应用程序深入探讨 NgRx,并使用 LemonMart 探索 NgRx 数据,这样你就可以熟悉 Angular 中更高级的应用程序架构概念。到本章结束时,我们将触及 Angular 和 Angular Material 提供的大多数主要功能。

本章涵盖了大量的内容。它以食谱格式组织,因此当你正在处理项目时,可以快速参考特定的实现。我涵盖了实现架构、设计和主要组件。我突出显示重要的代码片段来解释解决方案是如何组合在一起的。利用你迄今为止所学的内容,我期望读者能够填写常规实现和配置细节。然而,如果你遇到困难,你始终可以参考 GitHub 仓库。

本章,你将学习以下主题:

  • 使用解析守卫加载数据

  • 带有路由数据的可复用组件

  • 使用辅助路由的主/详细视图

  • 带有分页的数据表

  • NgRx Store 和 Effects

  • NgRx 数据库

书籍样本代码的最新版本可在以下列表中链接的 GitHub 仓库找到。该仓库包含代码的最终和完成状态。你可以在本章末尾通过查找projects文件夹下的代码快照来验证你的进度。

为了为本章的基于lemon-mart的示例做好准备,请执行以下操作:

  1. github.com/duluca/lemon-mart克隆仓库

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

  3. 本章的代码示例位于以下子文件夹中:

    projects/ch12 
    
  4. 要运行本章的 Angular 应用程序,请执行以下命令:

    npx ng serve ch12 
    
  5. 要运行本章的 Angular 单元测试,请执行以下命令:

    npx ng test ch12 --watch=false 
    
  6. 要运行本章的 Angular e2e 测试,请执行以下命令:

    npx ng e2e ch12 
    
  7. 要构建本章的生产就绪 Angular 应用程序,请执行以下命令:

    npx ng build ch12 --prod 
    

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

为了准备本章基于 local-weather-app 的示例,请执行以下步骤:

  1. 克隆 github.com/duluca/local-weather-app 上的 repo

  2. 在根目录下执行 npm install 以安装依赖项

  3. 本章的代码示例位于以下子文件夹中:

    projects/ch12 
    
  4. 要运行本章的 Angular 应用,请执行以下命令:

    npx ng serve ch12 
    
  5. 要运行本章的 Angular 单元测试,请执行以下命令:

    npx ng test ch12 --watch=false 
    
  6. 要运行本章的 Angular 端到端测试,请执行以下命令:

    npx ng e2e ch12 
    
  7. 要构建本章的生产就绪 Angular 应用,请执行以下命令:

    npx ng build ch12 --prod 
    

记住,存储库根目录下的 dist/ch12 文件夹将包含编译结果。

请注意,书中或 GitHub 上的源代码可能并不总是与 Angular CLI 生成的代码匹配。由于生态系统不断演变,书中代码与 GitHub 上代码之间的实现可能也存在细微差异。随着时间的推移,示例代码发生变化是自然的。在 GitHub 上,您可能会找到更正、修复以支持库的新版本或为读者观察而并排实现多种技术的示例。读者只需实现书中推荐的理想解决方案即可。如果您发现错误或有疑问,请创建一个 GitHub 问题或提交一个拉取请求,以惠及所有读者。

您可以在 附录 C 中了解更多关于更新 Angular 的信息,即 保持 Angular 和工具常青。您可以从 static.packt-cdn.com/downloads/9781838648800_Appendix_C_Keeping_Angular_and_Tools_Evergreen.pdfexpertlysimple.io/stay-evergreen 在线找到此附录。

在下一节中,我们将学习解析守卫,以便我们可以简化代码并减少样板代码的数量。

编辑现有用户

第十一章 中,我们在 食谱 – 可重用性、路由和缓存 中创建了一个具有 editUser 函数的 ViewUserComponent。在章节后面实现系统中的主/详细视图时,我们需要这个功能,其中经理可以看到系统中的所有用户并具有编辑他们的能力。在我们能够启用 editUser 功能之前,我们需要确保 ViewUserComponent 组件和 ProfileComponent 组件可以加载任何给定其 ID 的用户。

让我们从实现一个可以用于两个组件的解析守卫开始。

使用解析守卫加载数据

如同在第八章中提到的,resolve guard 是一种路由守卫。resolve guard 可以通过读取route参数中的记录 ID 来为组件加载数据,异步加载数据,并在组件激活和初始化时准备好数据。

Resolve guard 的主要优势包括加载逻辑的可重用性、减少了样板代码,以及减少了依赖性,因为组件可以在不导入任何服务的情况下接收所需的数据:

  1. user/user下创建一个新的user.resolve.ts类:

    **src/app/user/user/user.resolve.ts**
    import { Injectable } from '@angular/core'
    import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
    import { catchError, map } from 'rxjs/operators'
    import { transformError } from '../../common/common'
    import { IUser, User } from './user'
    import { UserService } from './user.service'
    @Injectable()
    export class UserResolve implements Resolve<IUser> { constructor(private userService: UserService) {}
      resolve(route: ActivatedRouteSnapshot) {
        return this.userService
          .getUser(route.paramMap.get('userId'))
          .pipe(map(User.Build), catchError(transformError))
      }
    } 
    

    注意,与UserService中的updateUser方法类似,我们使用map(User.Build)来填充user对象,以便在组件从route快照加载数据时可以使用,正如我们接下来将要看到的。

  2. user.module.ts中提供解析器。

    接下来,让我们配置routerProfileComponent,以便能够加载现有用户。

  3. 修改user-routing.module.ts以添加一个新的路径,profile/:userId,带有路由解析器和canActivate AuthGuard

    **src/app/user/user-routing.module.ts**
    ...
    {
        path: 'profile/:userId',
        component: ProfileComponent,
        resolve: {
          user: UserResolve,
        },
        canActivate: [AuthGuard],
      },
      ... 
    

    记得在user.module.ts中提供UserResolveAuthGuard

  4. profile组件更新为从route加载数据(如果存在):

    **src/app/user/profile/profile.component.ts**
    ...
      constructor(
        ...
        **private route: ActivatedRoute**
      ) {
        super()
      }
      ngOnInit() {
        this.formGroup = this.buildForm()
        if (**this.route.snapshot.data.user**) {
          **this.patchUser(this.route.snapshot.data.user)**
        } else {
          this.subs.sink = combineLatest(
            [this.loadFromCache(), 
             this.authService.currentUser$]
           )
          .pipe(
            filter(
              ([cachedUser, me]) => 
                cachedUser != null || me != null
            ),
            tap(
              ([cachedUser, me]) => 
               this.patchUser(cachedUser || me)
            )
          )
          .subscribe()
        }
      } 
    

我们首先检查route快照中是否存在用户。如果存在,我们调用patchUser来加载此用户。否则,我们回退到我们的条件缓存加载逻辑。

注意,patchUser方法还设置了currentUserIdnameInitialDate$可观察对象,并调用patchUpdateData基类来更新表单数据。

您可以通过导航到具有您用户 ID 的配置文件来验证解析器是否工作。使用默认设置,此 URL 将类似于http://localhost:5000/user/profile/5da01751da27cc462d265913

重新使用具有绑定和路由数据的组件

现在,让我们重构viewUser组件,以便我们可以在多个上下文中重用它。根据创建的 mock-ups,用户信息在应用程序中的两个地方显示。

第一个地方是我们之前章节中实现的用户配置文件的Review步骤。第二个地方是在/manager/users路由上的用户管理屏幕,如下所示:

图 12.1:用户管理 mock-up

图 12.1:经理用户管理 mock-up

为了最大化代码重用,我们需要确保我们的共享ViewUser组件可以在两种上下文中使用。

对于多步输入表单的Review步骤,我们只需将当前用户绑定到它。在第二个用例中,组件需要使用 resolve guard 来加载自己的数据,因此我们不需要实现额外的逻辑来实现我们的目标:

  1. 更新viewUser组件以注入ActivatedRoute对象,并在ngOnInit()中将currentUser$从路由设置:

    **src/app/user/view-user/view-user.component.ts**
    ...
    import { ActivatedRoute } from '@angular/router'
    export class ViewUserComponent implements OnChanges, OnInit {
      ...
      constructor(
        private route: ActivatedRoute, private router: Router
        ) {} 
      ngOnInit() {
        if (this.route.snapshot.data.user) { 
          this.currentUser$.next(this.route.snapshot.data.user)
        }
      }
      ...
    } 
    

    ngOnInit仅在组件首次初始化或被路由到时触发一次。在这种情况下,如果已解析路由的任何数据,则它将通过next()函数推送到this.currentUser$

    我们现在有两个独立的事件来更新数据;一个用于ngOnChanges,它处理对@Input值的更新,并将其推送到BehaviorSubject currentUser$,如果this.user已被绑定。

    为了能够在多个懒加载的模块中使用此组件,我们必须将其包裹在其自己的模块中:

  2. src/app下创建一个新的shared-components.module.ts

    **src/app/shared-components.module.ts**
    import { CommonModule } from '@angular/common'
    import { NgModule } from '@angular/core'
    import { FlexLayoutModule } from '@angular/flex-layout'
    import { ReactiveFormsModule } from '@angular/forms'
    import { AppMaterialModule } from './app-material.module'
    import { 
      ViewUserComponent 
    } from './user/view-user/view-user.component'
    @NgModule({
      imports: [
        CommonModule,
        ReactiveFormsModule,
        FlexLayoutModule,
        AppMaterialModule,
      ],
      declarations: [ViewUserComponent],
      exports: [ViewUserComponent],
    })
    export class SharedComponentsModule {} 
    

    确保将SharedComponentsModule模块导入到每个你打算使用ViewUserComponent的功能模块中。在我们的例子中,这些将是UserModuleManagerModule

  3. User模块声明中移除ViewUserComponent

  4. 类似地,在SharedComponentsModule中声明并导出NameInputComponent,然后清理其其他声明

  5. SharedComponentsModule中导入必要的模块以支持ViewUserComponentNameInputComponent,例如FieldErrorModule

我们现在已经拥有了开始实现主/详细视图的关键部分。让我们继续下一步。

主/详细视图辅助路由

路由器首先架构的真正力量在于使用辅助路由的实现,我们可以通过路由配置单独影响组件的布局,允许我们进行丰富的场景,其中我们可以将现有组件重新组合到不同的布局中。辅助路由是彼此独立的路由,它们可以在标记中定义的命名出口中渲染内容,例如<router-outlet name="master"><router-outlet name="detail">。此外,辅助路由可以有自己的参数、浏览器历史记录、子路由和嵌套辅助路由。

在以下示例中,我们将使用辅助路由实现基本的主/详细视图:

  1. 实现一个具有两个命名出口的简单组件:

    **src/app/manager/user-management/user-management.component.ts**
      template: `
        <div class="horizontal-padding">
          <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: UserResolve,
              },
            },
          ],
          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**
    <a mat-button mat-icon-button [routerLink]="['/manager/users', 
        { outlets: { detail: ['user', { userId: row._id}] } }]"
        skipLocationChange>
      <mat-icon>visibility</mat-icon>
    </a> 
    

    skipLocationChange指令在导航时不会将新记录推入历史记录。因此,如果用户查看多个记录并点击后退按钮,他们将被带回到上一个屏幕,而不是必须先滚动查看他们查看的记录。

    想象一下,如果用户点击一个类似于之前定义的查看详情按钮,那么ViewUserComponent将为具有给定userId的用户渲染。在下一张截图中,你可以看到在下一节实现数据表后,查看详情按钮将看起来是什么样子:

    图片

    图 12.2:查看详情按钮

    您可以为主视图和详情视图定义任意多的组合和替代组件,从而实现动态布局的无限可能性。然而,设置routerLink可能会让人感到沮丧。根据具体条件,您可能需要提供或不需要提供链接中的所有或部分出口。例如,对于前面的场景,如果链接是['/manager/users', { outlets: { master: [''], detail: ['user', {userId: row.id}] } }],则路由将静默失败加载。预计这些怪癖将在未来的 Angular 版本中得到解决。

    现在我们已经完成了ViewUserComponent的解析保护器的实现,您可以使用 Chrome DevTools 查看正确加载的数据。

    在调试之前,请确保运行我们在第十章“RESTful APIs 和全栈实现”中创建的lemon-mart-server

  6. 在 Chrome DevTools 中,在this.currentUser被分配后立即设置一个断点,如图所示:图片

    图 12.3:Dev Tools 调试 ViewUserComponent

您将观察到this.currentUser被正确设置,而无需在ngOnInit函数内部加载数据的任何样板代码,这显示了解析保护器的真正好处。"ViewUserComponent"是详情视图;现在让我们实现主视图,作为一个具有分页的数据表。

带分页的数据表

我们已经创建了主/详情视图的框架。在主出口中,我们将有一个用户的分页数据表,因此让我们实现UserTableComponent,它将包含一个名为dataSourceMatTableDataSource属性。我们需要能够使用标准的分页控件,如pageSizepagesToSkip来批量获取用户数据,并且能够通过用户提供的searchText进一步缩小选择范围。

让我们从向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 extends CacheService 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,
            },
          })
        }
    ... 
    

    注意,排序方向由关键字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 OnDestroy, AfterViewInit {
      displayedColumns = ['name', 'email', 'role', '_id']
      items$: Observable<IUser[]>
      resultsLength = 0
      hasError = false
      errorText = ''
      private skipLoading = false
      private subs = new SubSink()
      readonly isLoadingResults$ = new BehaviorSubject(true)
      loading$: Observable<boolean>
      refresh$ = new Subject()
      search = new FormControl('', OptionalTextValidation)
      @ViewChild(MatPaginator, { static: false })
        paginator: MatPaginator 
      @ViewChild(MatSort, { static: false }) sort: MatSort
      constructor(
        private userService: UserService
      ) {
        this.loading$ = this.isLoadingResults$
      }
      getUsers(
        pageSize: number,
        searchText: string,
        pagesToSkip: number,
        sortColumn: string,
        sortDirection: SortDirection
      ): Observable<IUsers> {
        return this.userService.getUsers(
          pageSize,
          searchText,
          pagesToSkip,
          sortColumn,
          sortDirection
        )
      }
      ngOnDestroy(): void {
        this.subs.unsubscribe()
      }
      ngAfterViewInit() {
        this.subs.sink = this.sort.sortChange
          .subscribe(() => this.paginator.firstPage()) 
        if (this.skipLoading) {
          return
        }
        **this.items$ = merge(**
     **this.refresh$,**
     **this.sort.sortChange,**
     **this.paginator.page,**
     **this.search.valueChanges.pipe(debounceTime(1000))**
     **).pipe(**
     **startWith({}),**
     **switchMap(() => {**
     **this.isLoadingResults$.next(true)**
     **return this.getUsers(**
     **this.paginator.pageSize,**
     **this.search.value,**
     **this.paginator.pageIndex,**
     **this.sort.active,**
     **this.sort.direction**
     **)**
     **}),**
     **map((results: { total: number; data: IUser[] }) => {**
     **this.isLoadingResults$.next(false)**
     **this.hasError = false**
     **this.resultsLength = results.total**
     **return results.data**
     **}),**
     **catchError((err) => {**
     **this.isLoadingResults$.next(false)**
     **this.hasError = true**
     **this.errorText = err**
     **return of([])**
     **})**
     **)**
     **this.items$.subscribe()**
      }
    } 
    

    我们定义并初始化各种属性以支持加载分页数据。"items$"存储用户记录,"displayedColumns"定义了我们打算显示的数据列,"paginator"和"sort"提供分页和排序偏好,而"search"提供了我们用于过滤结果的文本。

    in pagination, sorting, and filter properties. If one property changes, the whole pipeline is triggered. This is similar to how we implemented the login routine in AuthService. The pipeline contains a call to this.userService.getUsers, which will retrieve users based on the pagination, sorting, and filter preferences passed in. Results are then piped into the this.items$ observable, which the data table subscribes to with an async pipe, so it can display the data.
    
  5. 创建包含以下 Material 模块的ManagerMaterialModule

    **src/app/manager/manager-material.module.ts**
        MatTableModule,
        MatSortModule,
        MatPaginatorModule,
        MatProgressSpinnerModule,
        MatSlideToggleModule, 
    
  6. 确保正确导入manager.module.ts中的以下内容:

    • 新的ManageMaterialModule

    • 基线AppMaterialModule

    • 以下必需模块:FormsModuleReactiveFormsModuleFlexLayoutModule

  7. 实现对userTable的 CSS:

    **src/app/manager/user-table/user-table.component.css**
    .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-paginator {
      background: transparent;
    } 
    
  8. 最后,实现userTable模板:

    **src/app/manager/user-table/user-table.component.html**
    <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>
            <mat-error *ngIf="search.invalid">
              Type more than one character to search
            </mat-error>
          </mat-form-field>
        </div>
      </form>
    </div>
    <div class="mat-elevation-z8">
      <div class="loading-shade" *ngIf="loading$ | async as loading">
        <mat-spinner *ngIf="loading"></mat-spinner>
        <div class="error" *ngIf="hasError">
          {{ errorText }}
        </div>
      </div>
      <table mat-table class="full-width" [dataSource]="items$ | async" matSort
        matSortActive="name" matSortDirection="asc" matSortDisableClear>
        <ng-container matColumnDef="name">
          <th mat-header-cell *matHeaderCellDef mat-sort-header> Name </th>
          <td mat-cell *matCellDef="let row">
            {{ row.fullName }}
          </td>
        </ng-container>
        <ng-container matColumnDef="email">
          <th mat-header-cell *matHeaderCellDef mat-sort-header> E-mail </th>
          <td mat-cell *matCellDef="let row"> {{ row.email }} </td>
        </ng-container>
        <ng-container matColumnDef="role">
          <th mat-header-cell *matHeaderCellDef mat-sort-header> Role </th>
          <td mat-cell *matCellDef="let row"> {{ row.role }} </td>
        </ng-container>
        <ng-container matColumnDef="_id">
          <th mat-header-cell *matHeaderCellDef>View Details
          </th>
          <td mat-cell *matCellDef="let row" style="margin-right: 8px">
            <a mat-button mat-icon-button [routerLink]="[
                '/manager/users',
                { outlets: { detail: ['user', { userId: row._id }] } }
              ]" skipLocationChange>
              <mat-icon>visibility</mat-icon>
            </a>
          </td>
        </ng-container>
        <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
        <tr mat-row *matRowDef="let row; columns: displayedColumns"> </tr>
      </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> 
    

    仅使用主视图,表格如下截图所示(确保你已经更新到 Angular 的最新版本!):

    图片

    图 12.4:用户表

    如果你点击查看图标,ViewUserComponent将在详情出口中渲染,如下所示:

    图片

    图 12.5:主/详情视图

    在上一章中,我们实现了编辑按钮,将userId传递给UserProfile,以便可以编辑和更新数据。

  9. 点击编辑按钮,将被带到ProfileComponent,编辑用户记录,并验证你是否可以更新其他用户的记录

  10. 确认你可以在数据表中查看更新的用户记录

这个带有分页的数据表演示完成了本书中 LemonMart 的主要功能。现在,在我们继续之前,让我们确保所有测试都通过。

更新单元测试

让我们回顾一下ProfileComponentUserTableComponent的一些单元测试,看看我们如何利用不同的技术来测试组件:

  1. 观察单元测试文件ProfileComponent,并识别使用authServiceMock对象为组件提供初始数据:

    **src/app/user/profile/profile.component.spec.ts**
    describe('ProfileComponent', () => {
      let component: ProfileComponent
      let fixture: ComponentFixture<ProfileComponent>
      let authServiceMock: jasmine.SpyObj<AuthService>
      beforeEach(async(() => {
        const authServiceSpy = autoSpyObj(
          AuthService,
          ['currentUser$', 'authStatus$'],
          ObservablePropertyStrategy.BehaviorSubject
        )
        TestBed.configureTestingModule({
          providers: commonTestingProviders.concat({
            provide: AuthService,
            useValue: authServiceSpy,
          }),
          imports: commonTestingModules.concat([
            UserMaterialModule,
            FieldErrorModule,
            LemonRaterModule,
          ]),
          declarations: [ProfileComponent, NameInputComponent, ViewUserComponent],
        }).compileComponents()
        authServiceMock = injectSpy(AuthService)
        fixture = TestBed.createComponent(ProfileComponent)
        component = fixture.debugElement.componentInstance
      }))
      it('should create', () => {
        authServiceMock.currentUser$.next(new User())
        authServiceMock.authStatus$.next(defaultAuthStatus)
        fixture.detectChanges()
        expect(component).toBeTruthy()
      })
    }) 
    

    注意,我并不是使用angular-unit-test-helper中的createComponentMock函数来导入NameInputComponentViewUserComponent,而是导入它们的实际实现。这是因为createComponentMock还不够复杂,无法处理将数据绑定到子组件。在进一步阅读部分,我包括了一篇由 Aiko Klostermann 撰写的博客文章,该文章涵盖了使用@Input()属性测试 Angular 组件。

  2. 打开UserTableComponent的规范文件:

    在修复其提供者和导入之后,你会注意到UserTableComponent抛出了ExpressionChangedAfterItHasBeenCheckedError错误。这是因为组件初始化逻辑需要dataSource被定义。如果未定义,则无法创建组件。然而,我们可以在第二个beforeEach方法中轻松修改组件属性,该方法在TestBed将真实、模拟或伪造的依赖项注入到组件类之后执行。以下片段中突出显示的更改用于测试数据设置:

    **src/app/manager/user-table/user-table.component.spec.ts**
    ...
    beforeEach(() => {
        fixture = TestBed.createComponent(UserTableComponent)
        component = fixture.componentInstance
        **component.items$ = of([new User()])**
     **Object.assign(component, { skipLoading: true })**
        fixture.detectChanges()
    })
    ... 
    

    到现在为止,你可能已经注意到,仅仅通过更新一些我们的核心配置文件,例如commonTestingProviderscommonTestingModules,一些测试通过了,其余的测试可以通过应用我们在整本书中使用的各种模式来解决。例如,user-management.component.spec.ts使用了我们创建的通用测试模块和提供者:

    **src/app/manager/user-management/user-management.component.spec.ts**
    providers: commonTestingProviders,
    imports: commonTestingModules.concat([ManagerMaterialModule]), 
    

    当你在模拟提供者时,请记住正在测试的模块、组件、服务或类,并注意只模拟依赖项。

    ViewUserComponent 是一个特殊情况,我们无法使用我们常见的测试模块和提供者,否则我们最终会创建一个循环依赖。在这种情况下,手动指定需要导入的模块。

  3. 修复单元测试配置,以确保所有测试都通过且不生成警告。

完成实现的重任后,我们现在可以探索替代架构、工具和库,以更好地理解为各种需求构建 Angular 应用程序的最佳方式。接下来,让我们探索 NgRx。

NgRx Store 和 Effects

第一章 中所述,Angular 及其概念简介,NgRx 库基于 RxJS 将响应式状态管理引入 Angular。使用 NgRx 进行状态管理允许开发者编写原子性、自包含和可组合的代码片段,创建动作、reducer 和选择器。这种响应式编程允许在状态变化中隔离副作用。本质上,NgRx 是 RxJS 之上的一个抽象层,以适应 Flux 模式。

NgRx 有四个主要元素:

  • Store:状态信息持久化的中心位置。您在 store 中实现一个 reducer 以存储状态转换,并实现一个选择器以从 store 中读取数据。这些都是原子性和可组合的代码片段。

    一个视图(或用户界面)通过使用选择器显示 store 中的数据。

  • 动作:在整个应用程序中发生的独特事件。

    动作从视图触发,目的是将它们分发给 store。

  • 分发器:这是一种将动作发送到 store 的方法。

    Store 上的 reducer 监听已分发的动作。

  • 效果:这是动作和分发器的组合。效果通常用于不是从视图触发的动作。

让我们重新审视以下 Flux 模式图,现在它突出显示了一个效果

图 12.6:Flux 模式图

让我们通过一个具体的例子来演示 NgRx 的工作原理。为了使其简单,我们将利用 LocalCast 天气应用。

为 LocalCast 天气实现 NgRx

我们将在 LocalCast 天气应用中实现 NgRx 以执行搜索功能。考虑以下架构图:

图 12.7:LocalCast 天气架构

为了实现我们的实现,我们将同时使用 NgRx store 和 effects 库。NgRx store 动作在图中以浅灰色显示,包括 WeatherLoaded reducer 和应用状态。在顶部,动作被表示为一系列各种数据对象,这些对象要么分发动作,要么对已分发的动作进行操作,使我们能够实现 Flux 模式。NgRx effects 库通过在其自己的模型中隔离副作用,而不在 store 中散布临时数据,从而扩展了 Flux 模式。

以深灰色表示的效果工作流程从步骤 1开始:

  1. CitySearchComponent 分发 search 动作

  2. search 动作出现在 @ngrx/action 可观察流(或数据流)中

  3. CurrentWeatherEffectssearch 动作进行操作以执行搜索

  4. WeatherService 执行搜索以从OpenWeather API检索当前天气信息

以浅灰色表示的存储动作从步骤 A开始:

  1. CurrentWeatherEffects 分发 weatherLoaded 动作

  2. weatherLoaded 动作出现在数据流中

  3. weatherLoaded 减法器对 weatherLoaded 动作进行操作

  4. weatherLoaded 减法器将天气信息转换为要存储的新状态

  5. 新状态是持久化的 search 状态,是 appStore 状态的一部分

注意,存在一个父级 appStore 状态,其中包含一个子 search 状态。我故意保留这种设置来演示当您向存储中添加不同类型的数据元素时,父级状态是如何扩展的。

最后,一个视图从存储中读取,从步骤 a开始:

  1. CurrentWeather 组件使用 async 管道订阅 selectCurrentWeather 选择器

  2. selectCurrentWeather 选择器监听 appStore 状态中 store.search.current 属性的变化

  3. appStore 状态检索持久化的数据

使用 NgRx,当用户搜索城市时,检索、持久化和在 CurrentWeatherComponent 上显示该信息的动作会通过单个可组合和不可变元素自动发生。

比较 BehaviorSubject 和 NgRx

我们将同时实现 NgRx 和 BehaviorSubjects,这样您可以看到同一功能的实现差异。为此,我们需要一个滑动切换来在两种策略之间切换:

本节使用 local-weather-app 仓库。您可以在 projects/ch12 文件夹下找到本章的代码示例。

  1. 从在 CitySearchComponent 上实现 <mat-slide-toggle> 元素开始,如下面的截图所示:

    图 12.8:LocalCast Weather 滑动切换

    确保该字段由组件上的 useNgRx 属性支持。

  2. doSearch 方法重构为提取名为 behaviorSubjectBasedSearchBehaviorSubject 代码作为其自己的函数

  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/ch12下的示例项目配置了@ngrx/store-devtools进行调试。

如果你希望在运行时进行调试或仪表化,并能够console.log NgRx 动作,请参阅附录 A,调试 Angular

定义 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,以及一个更为复杂的链式事件来将其转换为天气数据。

否则,我们不得不在中间实现一个还原器,首先将此值存储在存储中,然后稍后从服务中检索它并分发一个weatherLoaded动作。效果将使数据检索变得简单。

现在我们将CurrentWeatherEffects添加到我们的应用程序中:

$ npx ng generate @ngrx/schematics:effect currentWeather --module=app.module.ts --root --group --creators 

按提示选择默认选项。

你将在effects文件夹下有一个新的current-weather.effects.ts文件。

再次强调,--group用于将效果分组在同名文件夹下。--rootapp.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))
  )
) 

这是我们接入可观察的动作流this.actions$并监听SearchAction.search类型动作的地方。然后我们使用exhaustMap操作符来注册发射的事件。由于其独特的性质,exhaustMap不会允许在doSearch函数完成其weatherLoaded动作的派发之前处理另一个搜索动作。

对所有不同的 RxJS 操作符感到困惑,担心你永远不会记住它们?请参阅附录 BAngular Cheat Sheet,以获取快速参考。

实现 reducers

weatherLoaded动作触发后,我们需要一种方法来摄取当前的天气信息并将其存储在我们的appStore状态中。reducers 将帮助我们处理特定动作,创建一个隔离且不可变的管道,以可预测的方式存储我们的数据。

让我们创建一个搜索 reducer:

$ 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。这类似于我们需要定义BehaviorSubject的默认值。重构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
) 

此外,由于我们希望保持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 来简化,复杂的数据处理在服务器端进行。

单元测试 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$属性的值。

让我们看看需要添加到规范文件中的 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.io/guide/testing 中得到良好阐述的话题。我的同事和本书的审稿人 Brendon Caulkins 为本章提供了一个无床的规范文件,名为 current-weather.component.nobed.spec.ts。他提到,在运行测试时,由于导入较少和维护较少,测试性能显著提高,但需要更高水平的关注和专业知识来实现测试。如果你在一个大型项目中,你应该认真考虑跳过 TestBed

你可以在 GitHub 的 projects/ch12 文件夹下找到示例代码。

继续更新你剩余的测试,直到它们全部通过后再继续。

NgRx Data

如果 NgRx 是一个基于配置的框架,那么 NgRx Data 就是 NgRx 的基于约定的兄弟。NgRx Data 自动创建存储、效果、动作、还原器、分发和选择器。如果你的大多数应用程序动作是 CRUD创建检索更新删除)操作,那么 NgRx Data 可以用更少的代码实现与 NgRx 相同的结果。

NgRx Data 可能是你和你的团队更好地了解 Flux 模式的入门,然后你可以继续学习 NgRx 本身。

@ngrx/data@ngrx/entity 库协同工作。它们一起提供了一套丰富的功能,包括事务性数据管理。更多关于它的信息请参阅 ngrx.io/guide/data

对于这个示例,我们将切换回 LemonMart 项目。

通过执行以下命令将 NgRx Data 添加到你的项目中:

$ npx ng add @ngrx/store --minimal
$ npx ng add @ngrx/effects --minimal
$ npx ng add @ngrx/entity
$ npx ng add @ngrx/data 

projects/ch12 下的示例项目配置了 @ngrx/store-devtools 用于调试。

如果你希望能够在运行时进行调试或仪表化,并使用 console.log NgRx 动作,请参阅 附录 A调试 Angular

在 LemonMart 中实现 NgRx/Data

在 LemonMart 中,我们有一个很好的用例来使用 @ngrx/data 库,特别是 User 类和 UserService。它巧妙地表示了一个可以支持 CRUD 操作的实体。通过一些修改和最少的努力,你就可以看到库的实际应用。

本节使用 lemon-mart 仓库。你可以在 projects/ch12 文件夹下找到本章的代码示例。

  1. 让我们从在 entity-metadata.ts 中定义 User 实体开始:

    **src/app/entity-metadata.ts**
    import { EntityMetadataMap } from '@ngrx/data'
    const entityMetadata: EntityMetadataMap = {
      User: {},
    }
    export const entityConfig = {
      entityMetadata,
    } 
    
  2. 确保将 entityConfig 对象注册到 EntityDataModule

    **src/app/app.module.ts**
    imports: [
      ...
      StoreModule.forRoot({}),
      EffectsModule.forRoot([]),
      EntityDataModule.forRoot(entityConfig),
    ] 
    
  3. 创建一个 User 实体服务:

    **src/app/user/user/user.entity.service.ts**
    import { Injectable } from '@angular/core' 
    import {
      EntityCollectionServiceBase,
      EntityCollectionServiceElementsFactory,
    } from '@ngrx/data'
    import { User } from './user'
    @Injectable({ providedIn: 'root' })
    export class UserEntityService
      extends EntityCollectionServiceBase<User> {
      constructor(
        serviceElementsFactory: EntityCollectionServiceElementsFactory
      ) {
        super('User', serviceElementsFactory)
      }
    } 
    

现在,你已经拥有了所有基本元素,可以将实体服务与组件集成。从某种意义上说,设置 NgRx Data 如此简单。然而,我们需要对其进行一些定制以适应我们现有的 REST API 结构,这将在下一节中详细说明。如果你遵循 NgRx Data 期望的 API 实现模式,那么不需要进行任何更改。

NgRx Data 想要通过 /api 路径访问 REST API,该路径托管在与你的 Angular 应用程序相同的端口上。为了在开发期间完成此操作,我们需要利用 Angular CLI 的代理功能。

在 Angular CLI 中配置代理

通常,发送到我们的 web 服务器和 API 服务器的 HTTP 请求应该有完全相同的 URL。然而,在开发过程中,我们通常在 http://localhost 的两个不同端口上托管两个应用程序。某些库,包括 NgRx Data,要求 HTTP 调用在同一个端口上。这为创建无摩擦的开发体验带来了挑战。因此,Angular CLI 随带了一个代理功能,你可以将 /api 路径指向 localhost 上的不同端点。这样,你可以使用一个端口来提供你的 web 应用程序和 API 请求。

  1. src 下创建一个 proxy.conf.json 文件,如下所示:

    如果你正在 lemon-mart-server monorepo 中工作,这将是在 web-app/src

    **proxy.conf.json**
    {
      "/api": {
        "target": "http://localhost:3000",
        "secure": false,
        "pathRewrite": {
           "^/api": ""
        }
      }
    } 
    
  2. 使用 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 路径的任何调用,使用 http://localhost:3000。这是默认运行 lemon-mart-server 的端口。

如果你的 API 在不同的端口上运行,那么请使用正确的端口号和子路由。

接下来,让我们使用 UserEntityService

使用实体服务

我们将更新用户管理主视图,因此我们可以选择使用 BehaviorSubject 或我们刚刚创建的 UserEntityService

  1. 首先,在 user-table.component.ts 中实现一个切换开关,类似于我们在本章前面为 LocalCast Weather 和 NgRx 所做的那样!图片

    图 12.9:具有 NgRx 滑动切换的 UserTableComponent

  2. 将新服务注入到 UserTableComponent 中,并将其加载可观察对象与组件上现有的可观察对象合并:

    **src/app/manager/user-table/user-table.component.ts**
    useNgRxData = true
    readonly isLoadingResults$ = new BehaviorSubject(true) loading$: Observable<boolean>
    constructor(
      private userService: UserService,
      **private userEntityService: UserEntityService**
    **) {**
      this.loading$ = merge(
        **this.userEntityService.loading$,** 
        this.isLoadingResults$
      )
    } 
    

    由于 EntityDataModule 在我们应用程序的根目录 app.module.ts 中注册,因此我们还需要在 app.module.ts 中提供 UserService,这样我们就可以在 UserEntityService 中使用它。尽管 UserEntityServiceUserModule 中提供,但 NgRx Data 中的操作顺序不适合与功能模块正确工作。这可能在某个时候得到修复。

  3. 你可以向组件中添加 CRUD 方法,如下面的代码所示。然而,我们将专注于仅更新 getUsers 函数,因此不需要添加其他方法:

    **src/app/manager/user-table/user-table.component.ts**
    **getUsers() {**
     **return this.userEntityService.getAll().pipe(**
     **map((value) => {**
     **return { total: value.length, data: value }**
     **})**
     **)**
    **}**
    add(user: User) { 
      this.userEntityService.add(user)
    }
    delete(user: User) { 
      this.userEntityService.delete(user._id)
    }
    update(user: User) { 
      this.userEntityService.update(user)
    } 
    
  4. ngAfterViewInit 中,重构对 this.userService.getUsers 的调用,使其从名为 getUsers 的方法中调用

  5. 然后实现一个对 this.userEntityService.getAll() 的条件调用,并映射出返回值以适应 IUsers 接口:

    **src/app/manager/user-table/user-table.component.ts**
    ...
      getUsers(pageSize: number, searchText = '', pagesToSkip = 0)
        : Observable<IUsers> {
          if (this.useNgRxData) {
            return this.userEntityService.getAll().pipe(   
              map((value) => {
                return { total: value.length, data: value }
              })
            )
          } else {
            return this.userService.getUsers(
              pageSize,
              searchText,
              pagesToSkip,
              sortColumn,
              sortDirection
            )
          } 
    

现在,您的组件可以通过切换滑动开关并输入一些新的搜索文本来尝试从任一来源获取数据。然而,我们的端点没有以 NgRx Data 期望的形状提供数据,因此我们需要自定义实体服务来克服这个问题。

自定义实体服务

您可以在多个位置自定义 NgRx Data 的行为。我们感兴趣的是覆盖getAll()函数的行为,以便我们接收到的数据得到适当的初始化,并且可以从项目的对象中提取数据。

对于这个例子,我们不会尝试使用 NgRx Data 恢复完整的分页功能。为了保持简单,我们只关注将数据数组获取到数据表中。

更新用户实体服务以注入UserService并实现一个使用它的getAll函数:

**src/app/user/user/user.entity.service.ts**
...
getAll(options?: EntityActionOptions): Observable<User[]> {
  return this.userService
    .getUsers(10)
    .pipe(map((users) => users.data.map(User.Build)))
}
... 

如您所见,我们正在遍历项目的对象,并使用我们的构建函数初始化对象,从而将Observable<IUsers>扁平化和转换为Observable<User[]>

实施此更改后,您应该能够看到数据如下流入用户表:

图片

图 12.10:NgRx Data 的用户表

注意,所有七个用户同时显示,如图中放大截图所示,分页功能没有正常工作。然而,这个实现足以展示 NgRx Data 带来的好处。

那么,您应该在您的下一个应用中实现 NgRx Data 吗?这取决于。由于该库是 NgRx 之上的抽象层,如果您没有很好地理解 NgRx 的内部结构,您可能会感到迷茫和受限。然而,该库在减少实体数据管理和 CRUD 操作方面的样板代码方面有很大的潜力。如果您在应用中执行大量的 CRUD 操作,您可能会节省时间,但请务必只将实现范围限制在需要它的区域。无论如何,您都应该关注这个伟大库的演变。

摘要

在本章中,我们使用路由优先架构和我们的食谱完成了对所有主要 Angular 应用设计考虑因素的回顾,从而轻松实现了一个业务应用。我们回顾了如何编辑现有用户,利用 resolve guard 加载用户数据,以及在不同的上下文中初始化和重用组件。

我们使用辅助路由实现了主/详细视图,并展示了如何构建具有分页的数据表。然后我们学习了 NgRx 和@ngrx/data库以及它们对我们代码库的影响,使用了local-weather-applemon-mart项目。

总体来说,通过使用路由优先的设计、架构和实现方法,我们以良好的高级理解来应对我们的应用程序设计,以实现我们想要达到的目标。通过早期识别代码复用机会,我们能够优化我们的实现策略,提前实现可重用组件,而不会冒着过度工程化解决方案的风险。

在下一章中,我们将在 AWS 上设置一个高可用性基础设施来托管 LemonMart。我们将更新项目以添加新脚本,以实现无停机时间的蓝绿部署。最后,在最后一章中,我们将更新 LemonMart 以使用 Google Analytics,并讨论高级云运维问题。

进一步阅读

问题

尽可能地回答以下问题,以确保你已理解本章的关键概念,无需使用 Google。你需要帮助回答这些问题吗?请参阅 附录 D自我评估答案,可在static.packt-cdn.com/downloads/9781838648800_Appendix_D_Self-Assessment_Answers.pdf在线找到,或访问expertlysimple.io/angular-self-assessment

  1. 什么是解析守卫?

  2. 路由编排有什么好处?

  3. 什么是辅助路由?

  4. NgRx 与使用 RxJS/Subject 有何不同?

  5. NgRx 数据的价值是什么?

  6. UserTableComponent 中,为什么我们使用 readonly isLoadingResults$: BehaviorSubject<Boolean> 而不是简单的布尔值来驱动加载指示器?

第十三章:AWS 上的高可用云基础设施

互联网是一个充满敌意的环境。有好的和坏的参与者。坏参与者可能会试图在您的安全中找到漏洞,或者试图通过 分布式拒绝服务DDoS)攻击使您的网站崩溃。如果您幸运的话,好的参与者会喜欢您的网站,并且不会停止使用它。他们会对您的网站提出改进建议,但他们也可能遇到错误,并且由于高流量,您的网站可能会因为过于热情而变得缓慢。在互联网上的实际部署需要大量的专业知识才能正确完成。作为一名全栈开发者,您只能了解硬件、软件和网络的一些细微差别。幸运的是,随着云服务提供商的出现,许多这种专业知识已经转化为软件配置,而复杂的硬件和网络问题则由提供商处理。

云服务提供商的最佳特性之一是云的可扩展性,这指的是您的服务器会自动扩展以应对意外的流量高峰,并在流量恢复正常水平时缩减规模以节省成本。亚马逊网络服务AWS)不仅超越了基本的云可扩展性,还引入了高可用性和容错概念,允许实现弹性本地和全球部署。我选择向您介绍 AWS,是因为其庞大的功能,这些功能远远超出了我在本书中将要涉及的内容。使用 Route 53,您可以获得免费的 DDoS 保护;使用 API Gateway,您可以创建 API 密钥;使用 AWS Lambda,您每月只需花费几美元就可以处理数百万笔交易;使用 CloudFront,您可以在世界各大城市的秘密边缘位置缓存您的内容。此外,蓝绿部署允许您实现软件的无中断部署。

总体而言,您将在本章中学到的工具和技术适用于任何云服务提供商,并且正迅速成为任何全栈开发者的关键知识。我们将讨论以下主题:

  • 创建和保护 AWS 账户

  • 合理配置基础设施,包括简单的负载测试以优化实例

  • 配置和部署到 AWS

    • ECS Fargate 脚本化蓝绿部署

    • 账单

本书示例代码的最新版本可在以下 GitHub 仓库链接找到。该仓库包含代码的最终和完整状态。每个部分都包含信息框,以帮助您找到 GitHub 上正确的文件名或分支,以便您可以使用它来验证您的进度。

第十三章 的示例代码移除了之前章节中所有可选和替代实现,并且仅启用与 lemon-mart-server 的身份验证。这样,读者可以参考 lemon-mart 项目的干净实现。

基于 lemon-mart第十三章 示例:

  1. 克隆github.com/duluca/lemon-mart上的仓库。

  2. 使用 config.docker-integration.yml 验证您的 config.yml 实现。

  3. 在根目录上执行 npm install 以安装依赖项。

  4. 要运行 CircleCI Docker 集成配置,请执行 git checkout deploy_aws。请参阅github.com/duluca/lemon-mart/pull/27的拉取请求。

  5. 本章的代码示例位于子文件夹:

    projects/ch13 
    
  6. 要运行本章的 Angular 应用程序,请执行:

    npx ng serve ch13 
    
  7. 要运行本章的 Angular 单元测试,请执行:

    npx ng test ch13 --watch=false 
    
  8. 要运行本章的 Angular e2e 测试,请执行:

    npx ng e2e ch13 
    
  9. 要为本章构建一个生产就绪的 Angular 应用程序,请执行:

    npx ng build ch13 --prod 
    

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

请注意,书中或 GitHub 上的源代码可能并不总是与 Angular CLI 生成的代码相匹配。由于生态系统不断演变,书中代码与 GitHub 上的代码在实现上也可能存在细微差异。随着时间的推移,示例代码发生变化是自然的。在 GitHub 上,您可能会找到更正、修复以支持库的新版本,或者观察多种技术并行的实现。您只需实现书中推荐的理想解决方案即可。如果您发现错误或有疑问,请创建问题或提交 GitHub 上的拉取请求,以惠及所有读者。

您可以在附录 C中了解更多关于更新 Angular 的信息,保持 Angular 和工具始终如一。您可以从static.packt-cdn.com/downloads/9781838648800_Appendix_C_Keeping_Angular_and_Tools_Evergreen.pdfexpertlysimple.io/stay-evergreen在线找到此附录。

AWS 是一个非常流行的服务,AWS 账户更是黑客攻击的热门目标。让我们从创建一个安全的 AWS 账户开始。

创建安全的 AWS 账户

账户访问和控制在任何云服务中都是至关重要的,包括 AWS。在初始账户创建后,您将拥有您的根凭据,即您的电子邮件和密码组合。

让我们从创建 AWS 账户开始:

  1. 首先导航到console.aws.amazon.com

  2. 如果您还没有,请创建一个新账户。

  3. 如果你刚接触 AWS,你可以在注册页面这里获得各种服务的 12 个月免费层访问权限,如图所示:

    图 13.1:AWS 账户注册

    您的 AWS 账单与您的根凭据相关联。如果遭到破坏,在您恢复访问之前,您的账户可能会遭受大量损失。

  4. 确保您已在您的根凭据上启用了双因素认证。

    为了增加另一层安全性,从现在开始,您需要停止使用根凭证登录您的 AWS 账户。您可以使用 AWS 身份和访问管理IAM)模块创建用户账户。如果这些账户被泄露,与您的根账户不同,您可以轻松快速地删除或替换它们。

  5. 导航到 IAM 模块

  6. 创建一个新的具有全局管理员权限的用户账户。

  7. 使用这些凭证登录 AWS 控制台。

  8. 您还应该为这些凭证启用双因素认证。

  9. 一个安全的账户设置如下所示,每个状态都报告为绿色:img/B14091_13_02.png

    图 13.2:安全设置后的 AWS IAM 模块

与用户账户合作的主要好处是程序性访问。对于每个用户账户,您都可以创建一个公共访问 ID 和私有访问密钥对。当您与第三方合作,例如托管持续集成服务、自己的应用程序代码或 CLI 工具时,您使用程序性访问密钥来连接到您的 AWS 资源。当不可避免地访问密钥泄露时,禁用旧密钥并创建新密钥既快速又方便。

此外,用户账户访问可以通过非常细粒度的权限进行严格控制。您还可以创建具有一组权限的角色,并进一步控制 AWS 服务与某些外部服务之间的通信。

在创建用户账户和角色时,始终采取最小权限原则。当与不熟悉 AWS 的客户、承包商或同事合作时,这可能是一项令人沮丧的练习;然而,这是一项值得做的练习。

您的安全性和可靠性取决于您最薄弱的环节,因此您必须计划失败,并且最重要的是,定期练习恢复计划。

保护机密

密码和私钥泄露的发生频率可能比你想象的要高。您的密钥可能在未加密的公共 Wi-Fi 网络上被泄露;您可能不小心将它们检查到代码仓库中,或者使用像电子邮件这样极其不安全的通信方式。

然而,意外代码检查入是最大的问题,因为大多数初级开发者没有意识到在源代码控制系统中删除不是一种选择。

作为开发者,有一些值得注意的最佳实践需要遵循,以保护您的机密:

  1. 在公共 Wi-Fi 上始终使用 VPN 服务,例如tunnelbear.com

  2. 利用位于您用户主目录下的 .aws/credentials 文件来创建配置文件并存储访问密钥。

  3. 作为团队规范,在项目的根目录下创建一个.env文件,并将其添加到.gitignore中,以存储 CI 服务器可能后来注入的任何机密。

  4. 在推送之前始终审查提交。

  5. 考虑注册一个可以监控您的代码库以查找机密的服务的账户,例如 GitGurdian 在gitguardian.com/,这对于开源项目是免费的。

注意,GitGuardian 将 Firebase 和 OpenWeather API 密钥标记为泄露。这是一个误报,因为所涉及的密钥是公钥,必须发布以使你的应用程序正确运行。

每次都遵循这些惯例将使你养成一个良好的习惯,即永远不要将秘密检查到代码库中。在下一节中,我们将深入探讨云环境中的资源考虑因素。

适当规模的基础设施

优化你的基础设施的目的是在保护你公司收入的同时,最大限度地减少运营基础设施的成本。你的目标应该是确保用户不会遇到高延迟,否则称为不良性能,更糟糕的是,未满足或丢弃的请求,同时让你的企业成为一个可持续的事业。

网络应用程序性能的三个支柱如下:

  1. CPU 利用率

  2. 内存使用

  3. 网络带宽

我故意将磁盘访问排除在关键考虑指标之外,因为只有特定的工作负载在应用程序服务器或数据存储上执行时才会受到影响。只要应用程序资源由内容分发网络CDN)提供,磁盘访问很少会影响服务网络应用程序的性能。尽管如此,仍然要注意任何意外的失控磁盘访问,例如频繁创建临时和日志文件。例如,Docker 可能会输出日志,这些日志很容易填满驱动器。

在理想情况下,CPU、内存和网络带宽的使用应该均匀地利用,大约在可用容量的 60-80%左右。如果你遇到由于各种其他因素(如磁盘 I/O、缓慢的第三方服务或低效的代码)引起的性能问题,很可能会出现你的某个指标达到或接近最大容量,而其他两个则处于闲置或严重未充分利用的状态。这是一个机会,可以使用更多的 CPU、内存或带宽来补偿性能问题,并均匀利用可用资源。

目标是 60-80%的利用率背后的原因是,为了给一个新的实例(服务器或容器)的配置和准备就绪以服务用户留出一些时间。在你预定义的阈值被超过后,当一个新的实例正在配置时,你可以继续服务越来越多的用户,从而最小化未满足的请求。

在整本书中,我一直在劝阻过度工程化或完美解决方案。在当今复杂的 IT 环境中,几乎不可能预测你将在哪里遇到性能瓶颈。你的工程可能非常容易地花费超过$100,000 的工程时数,而你的问题的解决方案可能只需要几百美元的新硬件,无论是网络交换机、固态驱动器、CPU 还是更多的内存。

如果您的 CPU 过于繁忙,您可能想在代码中引入更多的账务逻辑,通过索引、哈希表或字典,您可以在内存中缓存它们以加快逻辑的后续或中间步骤。例如,如果您不断运行数组查找操作以定位记录的特定属性,您可以在该记录上执行操作,将记录的 ID 和/或属性保存到您在内存中保持的哈希表中,从而将您的运行时成本从 O(n) 降低到 O(1)

在前面的示例之后,您可能会使用过多的内存,特别是使用哈希表。在这种情况下,您可能希望更积极地卸载或转移缓存到较慢但更丰富的数据存储中,例如使用您的备用网络带宽的 Redis 实例。

如果您的网络利用率过高,您可能想调查使用带有过期链接的 CDN、客户端缓存、限制请求以及为滥用配额的客户设置 API 访问限制,或者优化您的实例,使其网络容量与其 CPU 或内存容量不成比例地更多。

优化实例

在前面的示例中,我展示了使用我的 duluca/minimal-node-web-server Docker 镜像来托管我们的 Angular 应用。尽管 Node.js 是一个非常轻量级的服务器,但它并不是为了仅仅作为 Web 服务器而优化的。此外,Node.js 具有单线程执行环境,这使得它不适合同时为许多并发用户提供服务静态内容。

您可以通过执行 docker stats 来观察 Docker 镜像正在使用的资源:

$ docker stats
CONTAINER ID  CPU %  MEM USAGE / LIMIT  MEM %  NET I/O  BLOCK I/O    PIDS
27d431e289c9  0.00%  1.797MiB / 1.9GiB  0.09%  13.7kB / 285kB  0B / 0B  2 

这里是 Node 和基于 NGINX 的服务器在空闲时系统资源利用率的比较结果:

服务器 镜像大小 内存使用
duluca/minimal-nginx-web-server 16.8 MB 1.8 MB
duluca/minimal-node-web-server 71.8 MB 37.0 MB

然而,空闲时的值只能讲述故事的一部分。为了更好地理解,我们必须进行简单的负载测试,以查看负载下的内存和 CPU 利用率。

简单负载测试

为了更好地理解我们服务器的性能特性,让我们对它们施加一些负载并对其进行压力测试:

  1. 使用 docker run 启动您的容器:

    $ docker run --name <imageName> -d -p 8080:<internal_port>
    <imageRepo> 
    

    如果您使用 npm 脚本来运行 Docker,请执行以下命令以启动您的容器:

    $ npm run docker:debug 
    
  2. 执行以下 bash 脚本来启动负载测试:

    $ curl -L http://bit.ly/load-test-bash | bash -s 100 "http://localhost:8080" 
    

    此脚本将每秒向服务器发送 100 个请求,直到您终止它。

  3. 执行 docker stats 来观察性能特性。

这里是 CPU 和内存利用率的总体观察:

CPU 利用率统计 最大内存
duluca/minimal-nginx-web-server 2% 15% 60% 2.4 MB
duluca/minimal-node-web-server 20% 45% 130% 75 MB

如您所见,两个服务器在提供相同内容时存在显著的性能差异。请注意,这种基于每秒请求数的测试对于比较分析很有用,但不一定反映实际使用情况。

很明显,我们的 NGINX 服务器将为我们提供最佳性价比。有了最优解决方案,让我们在 AWS 上部署应用程序。

部署到 AWS ECS Fargate

AWS 弹性容器服务ECS)Fargate 是一种成本效益高且易于配置的云部署容器的方法。

ECS 由四个主要部分组成:

  1. 一个容器仓库,弹性容器注册库ECR),您可以在其中发布您的 Docker 镜像。

  2. 服务、任务和任务定义,其中您定义作为服务运行的容器作为任务定义的运行时参数和端口映射。

  3. 一个集群,一组 EC2 实例,其中可以部署和扩展任务。

  4. Fargate,一种管理集群服务,它抽象化了 EC2 实例、负载均衡器和安全组的问题。

在 AWS 控制台右上角,务必选择离您的用户最近的区域。对我来说,这是 us-east-1 区域。

我们的目标是创建一个高度可用的蓝绿部署,这意味着在服务器故障或甚至在部署期间,我们的应用程序至少有一个实例将处于运行状态。这些概念在第十四章Google Analytics 和高级云操作可扩展环境中的每用户成本部分中进行了详细探讨。

配置 ECS Fargate

您可以在 AWS服务菜单下访问 ECS 功能,选择弹性容器服务链接。

如果这是您第一次登录,您必须完成一个教程,其中您将被强制创建一个示例应用程序。我建议您完成教程,并在之后删除您的示例应用程序。为了删除服务,您需要将您服务的任务数量更新为 0。此外,删除默认集群以避免任何意外费用。

创建 Fargate 集群

让我们先配置一个 Fargate 集群,它作为配置其他 AWS 服务时的锚点。我们的集群最终将运行一个集群服务,我们将在接下来的章节中逐步构建。

AWS Fargate 是实施云中可扩展容器编排解决方案的一个很好的选择。近年来,Kubernetes 作为首选解决方案已经变得非常流行。Kubernetes 是 AWS ECS 的开源替代品,它为容器编排提供了更丰富的功能,适用于本地、云和云混合部署。虽然 AWS 确实提供了 Amazon Elastic Container Service for Kubernetes (Amazon EKS),但与纯 Kubernetes 相比,RedHat 的开源 OpenShift 平台更容易使用,并且自带电池(即无需额外配置)。

让我们创建集群:

  1. 导航到弹性容器服务

  2. 点击集群 | 创建集群

  3. 选择仅网络...由 AWS Fargate 提供模板。

  4. 点击下一步,您将看到创建集群步骤,如图所示:图片

    图 13.3:AWS ECS 创建集群

  5. 集群名称输入为fargate-cluster

  6. 创建一个VPC以隔离您的资源与其他 AWS 资源。

  7. 点击创建集群以完成设置。

您将看到您的操作摘要,如下所示:

图片

图 13.4:AWS ECS Fargate 集群

现在您已经在自己的虚拟专用云VPC)中创建了一个集群,您可以在弹性容器服务 | 集群下查看它。

创建容器仓库

接下来,我们需要设置一个仓库,我们可以在此处发布我们在本地或 CI 环境中构建的容器镜像:

本节假设您已根据第九章使用 Docker 的 DevOps中详细说明的设置 Docker 和 npm 脚本。您可以通过执行npm i -g mrm-task-npm-docker并使用npx mrm npm-docker应用这些脚本来获取这些脚本的最新版本。

  1. 导航到弹性容器服务

  2. 点击仓库 | 创建仓库

  3. 将仓库名称输入为lemon-mart

  4. 复制屏幕上生成的仓库 URI

  5. 将 URI 粘贴到您的应用程序的package.json文件中作为新的imageRepo变量:

    **package.json**
    ...
    "config": {
      "imageRepo": "000000000000.dkr.ecr.us-east-1.amazonaws.com/lemon-mart",
      ...
    } 
    
  6. 点击创建仓库

  7. 点击下一步,然后点击完成以完成设置。

在摘要屏幕上,您将获得有关如何使用 Docker 与您的仓库一起使用的进一步说明。在本章的后面部分,我们将介绍将为我们处理这些任务的脚本:

图片

图 13.5:AWS ECS 仓库

您可以在弹性容器服务 | 仓库下查看您的新仓库。

我们将在即将到来的AWS 的 npm 脚本部分中介绍如何发布您的镜像。

让我们继续设置 ECS。

创建任务定义

在我们的仓库中定义了容器目标后,我们可以定义一个任务定义,它包含运行我们的容器所需的所有元数据,例如端口映射、保留 CPU 和内存分配:

  1. 导航到弹性容器服务

  2. 点击任务定义 | 创建新的任务定义

  3. 选择Fargate启动类型兼容性。

  4. 任务定义名称输入为lemon-mart-task

  5. 选择任务角色为无(您可以在稍后添加一个以启用对其他 AWS 服务的访问)。

  6. 任务内存输入为0.5 GB

  7. 任务 CPU输入为0.25 CPU

  8. 点击添加容器

    • 容器名称输入为lemon-mart

    • 对于镜像,粘贴之前生成的镜像仓库 URI,但向其追加:latest标签,以便始终从仓库中拉取最新镜像,例如000000000000.dkr.ecr.us-east-1.amazonaws.com/lemon-mart:latest

    • 为 NGINX 设置软限制128 MB,或为 Node.js 设置256 MB

    • 端口映射下,指定容器端口为 NGINX 的80或 Node.js 的3000

  9. 接受剩余的默认设置。

  10. 点击添加;这是在创建之前您的任务定义将看起来如何:img/B14091_13_06.png

    图 13.6:AWS ECS 任务定义

  11. 点击创建以完成设置。

弹性容器服务 | 任务定义下查看您的新任务定义

注意,默认设置将启用 AWS CloudWatch 日志记录,这是一种您可以事后访问容器实例控制台日志的方式。在这个例子中,将创建一个名为/ecs/lemon-mart-task的 CloudWatch 日志组。

云监控 | 日志下查看您的新日志组。

如果您添加的容器需要持久化数据,任务定义允许您定义一个卷并将文件夹挂载到您的 Docker 容器中。我发布了一个关于如何配置 AWS 弹性文件系统(EFS)与您的 ECS 容器的指南,请参阅bit.ly/mount-aws-efs-ecs-container

创建一个弹性负载均衡器

在高可用性部署中,您希望运行两个容器实例,正如我们刚才创建的任务定义所定义的,跨越两个不同的可用区(AZs)。对于这种动态扩展和缩减,我们需要配置一个应用程序负载均衡器(ALB)来处理请求路由和排空:

  1. 在另一个选项卡上,导航到EC2 | 负载均衡器 | 创建负载均衡器

  2. 创建一个应用程序负载均衡器

  3. 名称设置为lemon-mart-alb

    为了在监听器下支持 SSL 流量,您可以在端口443上添加一个新的 HTTPS 监听器。通过 AWS 服务和向导,可以方便地实现 SSL 设置。在 ALB 配置过程中,AWS 提供了链接到这些向导的选项,以创建您的证书。然而,这是一个复杂的过程,并且可能因您现有的域名托管和 SSL 证书设置而有所不同。在这本书中,我将跳过与 SSL 相关的配置。您可以在我在bit.ly/setupAWSECSCluster发布的指南中找到与 SSL 相关的步骤。

  4. 可用区下,选择为您的fargate-cluster创建的VPC

  5. 选择列出的所有 AZs。

  6. 展开标签并添加一个键/值对,以便能够识别 ALB,例如"App": "LemonMart"

  7. 点击下一步:配置安全设置

    如果您添加了 HTTPS 监听器,您将看到配置证书的选项。

    如果配置证书,请点击从 ACM 选择证书(AWS 证书管理器)并选择默认 ELB 安全策略

    如果您从未创建过证书,请点击从 ACM 请求新的证书链接来创建一个。如果您之前创建过证书,请转到证书管理器创建一个新的。然后,刷新并选择您的证书。

  8. 点击下一步:配置安全组

  9. 创建一个新的集群特定安全组lemon-mart-sg,仅允许端口80入站或如果使用 HTTPS 则允许端口443入站。

    在下一节创建集群服务时,请确保此处创建的安全组是在服务创建期间选择的。否则,您的 ALB 将无法连接到您的实例。

  10. 点击下一步:配置路由

  11. 为新的目标组命名为lemon-mart-target-group

  12. 将协议类型从instance更改为ip

  13. 健康检查下,如果通过 HTTP 提供服务,请保留默认路由/

    健康检查对于扩展和部署操作至关重要。这是 AWS 可以用来检查实例是否成功创建的机制。

    如果部署 API 和/或重定向所有 HTTP 调用到 HTTPS,请确保您的应用程序定义了一个自定义路由,该路由不会被重定向到 HTTPS。在 HTTP 服务器上,GET /healthCheck返回一个简单的 200 消息,表示I'm healthy并验证这不会重定向到 HTTPS。否则,您将经历很多痛苦和苦难,试图找出问题所在,因为所有健康检查都会失败,部署也会莫名其妙地失败。duluca/minimal-node-web-server提供 HTTPS 重定向,以及一个开箱即用的 HTTP-only /healthCheck端点。使用duluca/minimal-nginx-web-server,您需要提供自己的配置。

  14. 点击下一步:注册目标

  15. 不要注册任何目标IP 范围。ECS Fargate 会神奇地为您管理这些。如果您自己这样做,您将配置一个半损坏的基础设施。

  16. 点击下一步:审查;您的 ALB 设置应类似于图中所示![img/B14091_13_07.png]

    图 13.7:AWS 应用程序负载均衡器设置

  17. 点击创建以完成设置。

在下一节创建集群服务时,您将使用lemon-mart-alb

创建集群服务

现在,我们将通过在集群中使用任务定义和创建的 ALB 来整合所有内容:

  1. 导航到弹性容器服务

  2. 点击集群 | fargate-cluster

  3. 服务选项卡下,点击创建

  4. 选择启动类型Fargate

  5. 选择您之前创建的任务定义。

    注意,任务定义是版本化的,例如lemon-mart-task:1。如果您要更改任务定义,AWS 将创建lemon-mart-task:2。您需要使用这个新版本更新服务,以便您的更改生效。

  6. 服务名称输入为lemon-mart-service

  7. 对于任务数量,选择2

  8. 对于最小健康百分比,选择50

  9. 对于最大百分比,选择200

    保持滚动更新部署类型,因为我们将实现自己的蓝绿部署策略。

  10. 点击下一步 步骤

    为了在部署期间实现高可用性,将最小健康百分比设置为100。Fargate 定价基于每秒的使用量,因此在部署应用程序时,您将为额外的实例支付额外费用,而旧的实例正在被取消配置。

  11. 配置网络下,选择与您之前集群相同的VPC

  12. 选择所有可用的子网;对于高可用性,至少应该有两个。

  13. 选择您在上一节中创建的安全组,命名为lemon-mart-sg。(如果您看不到它,请刷新页面。)

  14. 选择负载均衡器类型为应用程序负载均衡器

  15. 选择lemon-mart-alb选项。

  16. 通过点击添加到负载均衡器按钮,将容器端口(例如803000)添加到 ALB。

  17. 选择您已经定义的生产监听端口

  18. 选择您已经定义的目标组lemon-mart-target-group

  19. 取消选择启用服务发现集成

  20. 点击下一步

  21. 如果您希望实例在达到一定容量限制时自动扩展和缩小,请设置自动扩展

    我建议在服务初始设置期间跳过自动扩展的设置,以便更容易地调试任何潜在的配置问题。您可以在稍后回来设置它。自动任务扩展策略依赖于警报,例如 CPU 利用率。在第十四章Google Analytics 和高级云操作可扩展环境中的每用户成本部分,您可以了解如何计算您的最佳目标服务器利用率并根据这个数字设置警报。

  22. 点击下一步并审查您的更改,如图所示:

    图 13.8:AWS Fargate 集群服务设置

  23. 最后,点击创建服务以完成设置。

弹性容器服务 | 集群 | fargate-cluster | lemon-mart-service下观察您的新服务。在您将镜像发布到容器仓库之前,您的 AWS 服务将无法部署实例,因为健康检查将不断失败。发布镜像后,您需要确保在服务的事件标签页中没有错误。

AWS 是一个复杂的野兽,有了 Fargate,您可以避免很多复杂性。然而,如果您有兴趣使用自己的 EC2 实例设置自己的 ECS 集群,您可以通过 1-3 年的预留实例获得显著的折扣。我有一个 75+步的设置指南可供参考,链接为bit.ly/setupAWSECSCluster

我们手动执行了许多步骤来创建我们的集群。AWS CloudFormation 通过提供可定制的配置模板来解决这个问题,您可以根据自己的需求进行定制,或者从头开始编写自己的模板。如果您想认真对待 AWS,这种代码即基础设施的设置绝对是您应该采取的方式。

对于生产部署,确保您的配置由 CloudFormation 模板定义,这样它就可以在部署相关的错误发生时轻松重新部署。不是如果,而是当部署相关的错误发生时。

配置 DNS

要将域名或子域名连接到您的应用程序,您必须配置您的 DNS 以指向 ALB。AWS 提供 Route 53 服务来管理您的域名。

Route 53 使动态地将域名或子域名分配给 ALB 变得容易:

  1. 导航到Route 53 | 托管区域

  2. 如果您已经注册了域名,请选择它;否则,使用创建托管区域注册它。

    注意,您需要将域名的名称服务器重新分配到 AWS 以使此操作生效。

  3. 点击创建记录集

  4. 输入名称lemonmart

  5. 别名设置为yes

  6. 从负载均衡器列表中选择lemon-mart-alb

  7. 点击创建以完成设置:img/B14091_13_09.png

    图 13.9:Route 53 – 创建记录集

现在,您的网站将可以通过您刚刚定义的子域名访问,例如,lemonmart.angularforenterprise.com

如果您不使用 Route 53,请不要慌张。在您的域名提供商网站上,编辑Zone文件以创建指向 ALB DNS 地址的 A 记录,然后您就完成了。

获取 ALB DNS 名称

为了获取您的负载均衡器的 DNS 地址,执行以下步骤:

  1. 导航到EC2 | 负载均衡器

  2. 选择lemon-mart-alb

  3. 描述选项卡中,注意 DNS 名称;考虑以下示例:

    DNS name:
    lemon-mart-alb-1871778644.us-east-1.elb.amazonaws.com (A Record) 
    

现在我们已经配置了 AWS ECS Fargate,让我们准备我们的 Angular 应用程序以便部署到 AWS。

添加 npm 脚本用于 AWS

就像 Docker 的 npm 脚本一样,我开发了一套名为npm 脚本用于 AWS的脚本,这些脚本在 Windows 10 和 macOS 上运行。这些脚本将允许您以壮观、无停机、蓝绿方式上传和发布您的 Docker 镜像。您可以通过执行以下步骤获取这些脚本的最新版本并在项目中自动配置它们:

我们正在lemon-mart项目中配置这些设置。

  1. 安装 AWS ECS 任务的 npm 脚本:

    npm i -g mrm-task-npm-aws 
    
  2. 应用 npm 脚本用于 Docker 的配置:

    npx mrm npm-aws 
    

现在,让我们配置脚本:

  1. 确保在您的项目中设置了mrm-task-npm-docker脚本。

  2. 创建一个.env文件并设置AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY

    **.env**
    AWS_ACCESS_KEY_ID=your_own_key_id
    AWS_SECRET_ACCESS_KEY=your_own_secret_key 
    
  3. 确保您的.env文件已包含在.gitignore文件中,以保护您的机密信息。

  4. 安装或升级到最新的 AWS CLI:

    • 在 macOS 上,brew install awscli

    • 在 Windows 上,choco install awscli

  5. 使用您的凭证登录 AWS CLI:

    1. 运行aws configure

    2. 您需要从配置 IAM 账户时获取您的访问密钥 ID秘密访问密钥

    3. 默认区域名称设置为us-east-1

  6. 更新package.json以添加一个新的config属性,包含以下配置属性:

    **package.json**
      ...
      "config": {
        ...
        "awsRegion": "us-east-1",
        "awsEcsCluster": "fargate-cluster",
        "awsService": "lemon-mart-service"
      },
    ... 
    

    确保您已从配置 npm 脚本用于 Docker 时更新package.json,以便imageRepo属性具有您的新 ECS 存储库的地址。

  7. 确保已将 AWS 脚本添加到package.json中,如图所示:

    **package.json**
    ...
    "scripts": {
      ...
      "aws:login:win": "cross-conf-env 
         aws ecr get-login --no-include-email --region 
         $npm_package_config_awsRegion > 
         dockerLogin.cmd && call dockerLogin.cmd && 
         del dockerLogin.cmd",
      "aws:login:mac": "eval $(aws ecr get-login 
         --no-include-email --region $npm_package_config_awsRegion)",
        "aws:login": "run-p -cs aws:login:win aws:login:mac", 
    } 
    

通过执行aws --version来检查您的 AWS CLI 版本。根据您的版本,您的aws:login可能需要不同。前面的脚本显示了 AWS CLI v1 的登录脚本。如果您有 v2,您的登录命令将类似于以下脚本:

在 macOS / Linux 上

aws ecr get-login-password --region $npm_package_config_awsRegion | docker login --username AWS --password-stdin $npm_package_config_imageRepo

在 Windows 上

(Get-ECRLoginCommand).Password | docker login --username AWS --password-stdin $npm_package_config_imageRepo:latest

npm run aws:login调用平台特定的命令,自动执行通常需要多步操作的动作,从 AWS CLI 工具获取docker login命令,如下所示:

**example**
$ npm run aws:login 
docker login -u AWS -p eyJwYXl...3ODk1fQ== https://00000000000.dkr.ecr.us-east-1.amazonaws.com
$ docker login -u AWS -p eyJwYXl...3ODk1fQ== https://00000000000.dkr.ecr.us-east-1.amazonaws.com
WARNING! Using --password via the CLI is insecure. Use --password-stdin.
Login Succeeded 

您首先执行aws ecr get-login,然后复制粘贴生成的docker login命令并执行它,以便您的本地 Docker 实例指向 AWS ECR。现在让我们看看我们如何部署构建的容器:

**package.json**
...
"scripts": {
...
"aws:deploy": "cross-conf-env docker run 
   --env-file ./.env silintl/ecs-deploy
   -c $npm_package_config_awsEcsCluster 
   -n $npm_package_config_awsService 
   -i $npm_package_config_imageRepo:latest 
   -r $npm_package_config_awsRegion --timeout 1000", 
}
...
example
$ docker image build . -f nginx.Dockerfile 
   -t 000000000.dkr.ecr.us-east-1.amazonaws.com/lemon-mart:latest
$ npm run docker:publish
$ npm run aws:deploy
Using image name: 0000000.dkr.ecr.us-east-1.amazonaws.com/lemon-mart:latest
Current task definition: arn:aws:ecs:us-east-1: 0000000:task-definition/lemon-mart-task:7
New task definition: arn:aws:ecs:us-east-1: 0000000:task-definition/lemon-mart-task:8
Service updated successfully, new task definition running.
Waiting for service deployment to complete...
Service deployment successful. 

我们首先构建我们的 Web 应用的 NGINX 版本 Docker 镜像,因为我们正在 ECS 上监听端口80。然后,将容器发布到 ECR,最后执行npm run aws:deploy,它使用运行蓝色/绿色部署的silintl/ecs-deploy Docker 容器。

使用 ECS 命令如何进行蓝色/绿色部署的详细信息超出了本书的范围。要查看更多使用原生 AWS ECS 命令的示例,请参阅github.com/aws-samples/ecs-blue-green-deploymentaws-samples存储库。

我们可以将我们的命令组合在一起,作为一个单独的release命令来执行,如下所示:

**package.json**
...
"scripts": {
  ...
  "aws:release": "run-s -cs aws:login docker:publish aws:deploy"
}
... 

最后,npm run aws:release简单地按照正确的顺序从 npm 脚本中运行aws:logindocker:publishaws:deploy命令。

发布

您的项目配置为在 AWS 上部署。您主要需要使用我们创建的两个命令来构建和发布镜像:

  1. 执行docker:debug来测试、构建、标记、运行、跟踪并在浏览器中启动您的应用程序以测试镜像:

    $ npm run docker:debug 
    
  2. 执行aws:release以配置 Docker 登录 AWS,发布最新的镜像构建,并在 ECS 上发布:

    $ npm run aws:release 
    

    注意,当连续运行多个命令,其中一个命令以状态1退出时,npm 将其视为失败。然而,这并不一定意味着您的操作失败。始终滚动查看终端输出,以查看是否抛出了任何真实错误。

  3. 验证您的任务在服务级别上是否运行和运行:img/B14091_13_10.png

    图 13.10:AWS ECS 服务

    确保运行计数和期望计数相同。不匹配或部署时间非常长通常意味着您的新容器上的健康检查失败。查看事件选项卡以获取更多信息。您的容器可能无法启动,或者您可能监听的是错误的端口。

  4. 验证您的实例在任务级别上是否运行:img/B14091_13_11.png

    图 13.11:AWS ECS 任务实例

    注意 公共 IP 地址并导航到它;例如,http://54.164.92.137,你应该能看到你的应用程序或 LemonMart 正在运行。

  5. 验证在 DNS 层面上 负载均衡器 设置是否正确。

  6. 导航到 ALB DNS 地址,例如 lemon-mart-alb-681490029.us-east-1.elb.amazonaws.com,并确认应用程序渲染如下:图片

    图 13.12:LemonMart 在 AWS Fargate 上运行

Et voilà!你的网站应该已经上线并运行。

在随后的版本中,在你第一次之后,你将能够观察到蓝绿部署的实际操作,如下所示:

图片

图 13.13:AWS 服务在蓝绿部署期间

有两个任务正在运行,同时还有两个新的任务正在配置。当新的任务正在验证时,运行计数将上升到四个任务。在新任务验证并通过旧任务的连接被释放后,运行计数将回到两个。

你可以通过配置 CircleCI 并使用安装了 awscli 工具的容器以及运行 AWS 的 npm 脚本来自动化你的部署。使用这种技术,你可以实现持续部署到预发布环境或持续交付到生产环境。

使用 CircleCI 部署到 AWS

第九章使用 Docker 的 DevOps 中,我们基于多阶段 Dockerfile 实现了一个 CircleCI 管道,这产生了一个 tar 和 gzip 压缩的 Docker 镜像。我们还介绍了如何使用 CircleCI 实现部署步骤。利用本章所学,我们可以结合两种策略,以便使用 CircleCI 部署到 AWS。

对于 AWS 部署,你可以使用 aws-cli orb 和一个 deploy 作业。deploy 作业将包含从缓存中恢复构建的 Docker 镜像、登录 AWS 并将镜像推送到你的 AWS ECS 容器仓库的步骤。

lemon-mart 仓库中,本节使用的 config.yml 文件名为 .circleci/config.docker-integration.yml。你还可以在 CircleCI 上找到本章的 YML 文件对应的 pull request,网址为 github.com/duluca/lemon-mart/pull/27,使用分支 deploy_aws

为了推送容器,我们通过运行 npm run aws:deploy 进行部署。让我们在 config.yml 文件中添加一个新的 deploy 作业。

CircleCI 账户设置组织设置 下,添加一个名为 aws 的新 上下文。将 AWS_ACCOUNT_IDAWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYAWS_DEFAULT_REGION 环境变量作为上下文的一部分设置。

在这里查看配置更改:

**.circleci/config.yml**
version: 2.1
orbs:
  **aws-cli: circleci/aws-cli@1.0.0**
...
jobs:
  ...
  **deploy:**
 **executor: aws-cli/default**
    working_directory: ~/repo
    steps:
      - attach_workspace:
          at: /tmp/workspace
      - checkout
      - setup_remote_docker
      - aws-cli/setup
      - run: npm ci
      - run:
          name: Restore .env files
          command: |
            set +H
            DOT_ENV=AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID\\nAWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY
            echo -e $DOT_ENV > .env
      - run:
          name: Sign Docker into AWS ECR
          command: |
            aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/lemon-mart
      - run:
          name: Push it to ECR
          command: |
            docker load < /tmp/workspace/built-image.tar.gz
            ECR_URI=$AWS_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/lemon-mart
            docker image tag lemon-mart:$CIRCLE_BRANCH $ECR_URI:$CIRCLE_BRANCH
            docker image tag $ECR_URI:$CIRCLE_BRANCH $ECR_URI:latest
            docker image push $ECR_URI:$CIRCLE_BRANCH
            docker image push $ECR_URI:latest      
      - run:
          name: Deploy
          **command: npm run aws:deploy** 

我们使用 aws-cli/setup 作业配置 aws-cli orb。然后执行 npm ci,这样我们就可以稍后运行我们的 npm 脚本。我们使用 CircleCI 环境变量恢复 .env 文件。我们使用 AWS ECR 登录信息配置 Docker,这样我们就可以将其容器推送到它。我们将前一步骤的 built-image.tar.gz 存储起来,并使用 docker load 命令加载它。我们给镜像打标签并推送到 ECR。最后,我们执行 npm run aws:deploy,这触发了我们的蓝绿部署。

最后但同样重要的是,我们更新 workflows 以包括 deploy 作业并配置我们之前定义的 aws context

**.circleci/config.yml**
...
workflows:
  version: 2
  build-and-deploy:
    jobs:
      - build
      - deploy:
          **context: aws**
          requires:
            - build 

在 CircleCI 中正确配置上下文至关重要。如果配置错误,您可能会发现自己陷入一个卡夫卡式的错误迷宫、糟糕的文档和痛苦的境地。不要说我没有警告过你。

请在此处查看成功部署的截图:

图 13.14:成功部署到 AWS Fargate 集群的 CircleCI

注意,部署步骤需要近 7 分钟。这是因为蓝绿部署确保我们的新部署是健康的,然后它会从现有容器中移除连接到新容器,默认设置下这需要 5 分钟。如果新部署不健康,部署步骤将在 10 分钟后超时并失败。

恭喜!现在我们可以以无停机、蓝绿的方式持续部署到 AWS。这一切都很棒,但一个基本的高度可用配置的成本是多少呢?

为了避免收费,请删除 lemon-mart-service。为此,您需要首先将您服务的任务数量更新为 0。此外,删除为您创建的默认集群,以避免任何未预见的费用。

让我们在下一节中检查费用。

AWS 账单

我在 AWS Fargate 上高度可用的 LemonMart 部署每月大约花费 45 美元。以下是详细费用分解:

描述 费用
亚马逊简单存储服务 (S3) $0.01
AWS 数据传输 $0.02
亚马逊云监控 $0.00
亚马逊 EC2 容器服务 (ECS Fargate) $27.35
亚马逊弹性计算云 (EC2 负载均衡器实例) $16.21
亚马逊 EC2 容器注册库 (ECR) $0.01
亚马逊 Route 53 $0.50
总计 $44.10

注意,账单非常详细,但它确实准确地分解了我们最终使用的所有 AWS 服务。主要成本是运行我们的 Web 服务器在 EC2 容器服务ECS)上的两个实例,以及在 弹性计算云EC2)上运行负载均衡器。客观地说,每月 45 美元可能看起来很多,用于托管一个 Web 应用程序。如果您愿意设置自己的集群并使用专用的 EC2 服务器,您可以通过 1 年或 3 年的分期付款来节省高达 50% 的成本。在 Heroku 上具有两个实例的类似、高度可用的部署起价为每月 50 美元,您还可以获得其他丰富的功能。同样,Vercel Now 上的两个实例将花费每月 30 美元。请注意,Heroku 和 Vercel Now 都不提供对物理上不同的可用区的访问。另一方面,Digital Ocean 允许您在不同的数据中心中部署服务器;然而,您必须自己编写基础设施代码。对于每月 15 美元,您可以在三个服务器上设置自己的高度可用集群,并能够在其上托管多个网站。

摘要

在本章中,您学习了在正确保护您的 AWS 账户时需要注意的细微差别和各种安全考虑。我们讨论了调整基础设施规模的概念。您以隔离的方式进行了简单的负载测试,以找出两个 Web 服务器之间性能的相对差异。拥有优化的 Web 服务器后,您配置了 AWS ECS Fargate 集群,以实现高度可用的云基础设施。使用 npm 脚本进行 AWS,您学习了如何编写可重复且可靠的零停机蓝/绿部署脚本。最后,您了解了在 AWS 和其他云服务提供商(如 Heroku、Vercel Now 和 Digital Ocean)上运行您的基础设施的基本成本。

在下一章和最后一章中,我们将完成对全栈 Web 开发者在将应用程序部署到网络上时应了解的主题范围的覆盖。我们将向 LemonMart 添加 Google Analytics 以衡量用户行为,利用高级负载测试来了解部署良好配置的可扩展基础设施的财务影响,并使用自定义分析事件来衡量重要应用程序功能的实际使用情况。

练习

使用 LemonMart 的 docker-compse.yml 文件部署其服务器基础设施到 AWS ECS。作为额外奖励,配置 AWS ECS 以使用 AWS 弹性文件系统EFS)持久化您的 MongoDB 数据:

  1. docs.aws.amazon.com/AmazonECS/latest/developerguide/ECS_CLI_installation.html 安装 ECS CLI。

  2. mrm-task-npm-aws 脚本添加到 lemon-mart-server 的根目录。

  3. 在版本 3.0 中创建一个新的 docker-compose.aws.yml 文件,并将其更新为引用您已发布的容器版本。

  4. 使用 npm run aws:publish:compose 命令部署您的应用程序。

你可以使用 Minimal MEAN 项目和相关的 GitHub gists 作为指南,在 github.com/duluca/minimal-mean#continuous-integration-and-hosting

进一步阅读

问题

尽可能好地回答以下问题,以确保你已理解本章的关键概念,而无需使用 Google。你需要帮助回答这些问题吗?请参阅 附录 D自我评估答案,在线位于 static.packt-cdn.com/downloads/9781838648800_Appendix_D_Self-Assessment_Answers.pdf 或访问 expertlysimple.io/angular-self-assessment

  1. 适当规模的基础设施有哪些好处?

  2. 使用 AWS ECS Fargate 相比 AWS ECS 的好处是什么?

  3. 你记得关闭你的 AWS 基础设施以避免额外收费吗?

  4. 什么是蓝/绿部署?

第十四章:Google Analytics 和高级云操作

你已经设计、开发和部署了一个世界级的网络应用程序;然而,这只是你应用程序故事的开端。网络是一个不断演变、充满活力、生机勃勃的环境,需要关注才能继续作为企业取得成功。在第十三章AWS 上的高可用云基础设施中,我们讨论了云基础设施的基本概念和拥有成本。

在本章中,我们将深入了解如何真正理解用户实际使用我们的应用程序,我们将使用 Google Analytics 来获取这些信息。然后,我们将利用这些信息创建逼真的负载测试来模拟实际用户行为,以了解我们服务器单个实例的真实容量。了解单个服务器的容量后,我们可以微调我们的基础设施扩展方式,以减少浪费并讨论各种扩展策略的影响。

最后,我们将讨论高级分析概念,例如自定义事件,以获得对用户行为的更精细理解和跟踪。

在本章中,你将学习以下主题:

  • 收集分析

  • 预算和扩展

  • 高级负载测试以预测容量

  • 可靠的云扩展

  • 使用自定义分析事件测量实际使用情况

在本章中,你将设置 Google Analytics、Google Tag Manager 和 OctoPerf 账户。

本书样本代码的最新版本可以在 GitHub 上的以下存储库链接中找到。该存储库包含代码的最终和完成状态。你可以在本章结束时通过查找projects文件夹下的代码末尾快照来验证你的进度。

第十四章的情况下:

  1. 克隆仓库:github.com/duluca/lemon-mart

    在根目录下执行npm install以安装依赖项。

  2. 本章的代码示例可以在以下子文件夹中找到:

    projects/ch14 
    
  3. 要运行本章的 Angular 应用程序,请执行以下命令:

    npx ng serve ch14 
    
  4. 要运行本章的 Angular 单元测试,请执行以下命令:

    npx ng test ch14 --watch=false 
    
  5. 要运行本章的 Angular e2e 测试,请执行以下命令:

    npx ng e2e ch14 
    
  6. 要构建本章的生产就绪 Angular 应用程序,请执行以下命令:

    npx ng build ch14 --prod 
    

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

    请注意,书中或 GitHub 上的源代码可能并不总是与 Angular CLI 生成的代码相匹配。由于生态系统不断演变,书中代码与 GitHub 上代码之间的实现可能也存在细微差异。随着时间的推移,示例代码发生变化是自然的。在 GitHub 上,您可能会找到更正、修复以支持库的新版本,或者为读者提供观察的多种技术的并排实现。读者只需实现书中推荐的理想解决方案即可。如果您发现错误或有问题,请创建一个 GitHub 上的问题或提交一个 pull request,以惠及所有读者。

    您可以在 附录 C 中了解更多关于更新 Angular 的信息,保持 Angular 和工具常青。您可以从 static.packt-cdn.com/downloads/9781838648800_Appendix_C_Keeping_Angular_and_Tools_Evergreen.pdfexpertlysimple.io/stay-evergreen 在线找到此附录。

让我们从了解网络分析的基本知识开始。

收集分析数据

现在我们网站已经上线并运行,我们需要开始收集指标以了解其使用情况。指标对于运营网络应用程序至关重要。

Google Analytics 有许多方面。以下是主要三个方面:

  1. 获取,衡量访客如何到达您的网站

  2. 行为,衡量访客如何与您的网站互动

  3. 转化,衡量访客如何在您的网站上完成各种目标

这是我的网站 thejavascriptpromise.com/ 上的 行为 | 概览 页面的一个查看:

图 14.1:Google Analytics 行为概览

thejavascriptpromise.com/ 是一个简单的单页 HTML 网站,因此指标相当简单。让我们回顾一下屏幕上的各种指标:

  1. 页面浏览量显示访客数量。

  2. 独立页面浏览量显示独立访客数量。

  3. 平均页面停留时间显示每位用户在网站上花费的时间。

  4. 跳出率显示用户在没有导航到子页面或以任何方式与网站互动(如点击带有自定义事件的链接或按钮)的情况下离开网站。

  5. %退出率表示用户在查看特定页面或一组页面后离开网站的频率。

在 2017 年,网站大约有 1,090 位独立访客,平均每位访客在网站上花费约 2.5 分钟,即 157 秒。鉴于这是一个单页网站,跳出率和%退出率在任何有意义的层面上都不适用。后来,我们使用独立访客数量来计算每位用户的成本。

作为比较,书中提到的 LemonMart 应用在 2018 年 4 月至 2020 年 4 月之间共服务了 162,396 个柠檬:

图 14.2:LemonMart 行为概览

除了页面浏览量之外,Google Analytics 还可以捕获特定事件,例如点击按钮触发服务器请求。这些事件可以在 事件 | 概览 页面上查看,如下所示:

图 14.3:Google Analytics 事件概览

也可以在服务器端捕获指标,但这将提供随时间推移的请求统计。您将需要额外的代码和状态管理来跟踪特定用户的行为,以便您可以计算随时间推移的用户统计。通过在客户端使用 Google Analytics 实现此类跟踪,您可以获得对用户来源、所做行为、是否成功以及何时离开您的应用程序的更详细理解,而无需在您的后端添加不必要的代码复杂性和基础设施负载。

将 Google Tag Manager 添加到您的 Angular 应用程序中

让我们在您的 Angular 应用程序中开始捕获分析数据。Google 正在逐步淘汰与 Google Analytics 一起提供的旧版 ga.jsanalytics.js 产品,用其新的、更灵活的全局站点标签 gtag.js 取代这些产品,该标签与 Google Tag Manager 一起提供。这绝不是 Google Analytics 的终结;相反,它代表了对一个更容易配置和管理分析工具的转变。全局站点标签可以通过 Google Tag Manager 远程配置和管理。标签是发送到客户端的 JavaScript 跟踪代码片段,它们可以启用对新的指标的跟踪以及与多个分析工具的集成,而无需更改已部署的代码。您仍然可以使用 Google Analytics 来分析和查看您的分析数据。Google Tag Manager 的另一个主要优点是它具有版本控制功能,这意味着您可以在各种条件下触发不同类型的标签进行实验,而无需担心对您的分析配置造成不可逆的损害。

设置 Google Tag Manager

让我们从为您的应用程序设置一个 Google Tag Manager 账户开始:

  1. tagmanager.google.com/ 登录 Google Tag Manager。

  2. 按照以下步骤添加一个带有 Web 容器的新的账户:

    图 14.4:Google Tag Manager

  3. 按照网站上的说明,将生成的脚本粘贴到 index.html 文件的顶部或接近顶部的 <head><body> 部分:

    **src/index.html**
    <head>
    <!-- Google Tag Manager -->
    <script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0], j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
    })(window,document,'script','dataLayer','GTM-56D4F6K');</script>
    <!-- End Google Tag Manager -->
    ...
    </head>
    <body>
    <!-- Google Tag Manager (noscript) -->
    <noscript><iframe src="img/ns.html?id=GTM-56D4F6K" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
    <!-- End Google Tag Manager (noscript) -->
    <app-root></app-root>
    </body> 
    

    注意,如果用户在其浏览器中禁用了 JavaScript 执行,则 <noscript> 标签才会执行。这样,我们可以收集此类用户的指标,而不是对他们的存在视而不见。

  4. 提交并发布您的标签管理器容器。

  5. 您应该看到您的标签管理器的初始设置已完成,如下截图所示:

    图 14.5:已发布的标签

  6. 验证您的 Angular 应用程序运行时没有错误。

注意,如果你没有发布你的标签管理器容器,你将在开发控制台或网络标签加载gtm.js时看到404错误。

设置 Google Analytics

现在,让我们通过 Google Analytics 生成一个跟踪 ID。这是你应用的通用唯一标识符,用于关联你的分析数据:

  1. analytics.google.com登录 Google Analytics。

  2. 打开管理控制台,使用屏幕左下角的齿轮图标,如图所示:

    图 14.6:Google Analytics 管理控制台

  3. 创建一个新的分析账户。

  4. 使用前一张截图中的步骤作为指南,执行以下步骤:

    1. 添加一个新的属性LemonMart

    2. 将属性配置为你的偏好。

    3. 点击跟踪代码

    4. 复制以UA-xxxxxxxxxx-1开头的跟踪 ID

    5. 忽略提供的gtag.js代码。

拥有你的跟踪 ID 后,我们可以配置 Google Tag Manager,使其能够收集分析数据。

在标签管理器中配置 Google Analytics 标签

现在,让我们将我们的 Google Analytics ID 连接到 Google Tag Manager:

  1. tagmanager.google.com,打开工作区标签。

  2. 点击添加新标签

  3. 将其命名为Google Analytics

  4. 点击标签配置并选择通用分析

  5. Google Analytics 设置下添加一个新变量。

  6. 将上一节复制的跟踪 ID 粘贴。

  7. 点击触发器并添加所有页面触发器。

  8. 点击保存,如图所示:

    图 14.7:创建 Google Analytics 标签

  9. 提交并发布你的更改,并观察带有单个标签的版本摘要,如图所示:

    图 14.8:显示一个标签的版本摘要

  10. 现在刷新你的 Angular 应用,你将在/home路由上。

  11. 在一个私有窗口中,打开你的 Angular 应用的新实例并导航到/manager/home路由。

  12. analytics.google.com/,打开实时 | 概览面板,如图所示:

    图 14.9:Google Analytics 实时概览

  13. 注意,两个活跃用户正在被跟踪。

  14. 顶部活跃页面下,你应该能看到用户所在的页面。

通过利用 Google Tag Manager 和 Google Analytics 一起,我们能够在不更改 Angular 应用内部任何代码的情况下完成页面跟踪。

搜索引擎优化SEO)是分析的重要组成部分。为了更好地理解爬虫如何感知你的 Angular 网站,请使用位于www.google.com/webmasters/tools的 Google Search Console 仪表板来识别优化。此外,考虑使用 Angular Universal 在服务器端渲染某些动态内容,以便爬虫可以索引你的动态数据源,并为你的网站带来更多流量。

预算和扩展

在第十三章的AWS 计费部分,AWS 上的高可用云基础设施中,我们介绍了运营一个 Web 服务器的月度成本,从每月 5 美元到每月 45 美元不等,从单服务器实例场景到高可用基础设施。对于大多数需求,预算讨论将从这一月度数字开始和结束。你可以执行如高级负载测试部分所建议的负载测试,以预测每服务器的用户容量,并大致了解你可能需要多少服务器。在一个有数十个服务器全天候运行的动态扩展云环境中,这是一种过于简单化的预算计算方法。

如果你运营的是一个规模较大的网站,事情往往会变得复杂。你将需要在不同的技术栈上运行多个服务器,服务于不同的目的。很难衡量或证明为看似过剩的容量或不必要的高性能服务器预留多少预算。无论如何,你需要能够根据你服务的用户数量来传达你基础设施的效率,并确保你的基础设施经过精细调整,这样你就不会因为一个无响应的应用程序而失去用户,或者因为使用了比你需要的更多容量而过度付费。

因此,我们将采取以用户为中心的方法,将我们的 IT 基础设施成本转换为每用户成本指标,这样业务和营销部门都能理解。

在下一节中,我们将探讨计算你基础设施的每用户成本意味着什么,以及当云扩展介入时,这些计算如何变化。我们将以我的一个网站为例。

计算每用户成本

我们将利用 Google Analytics 中的行为指标,目标是计算给定时间段的每用户成本:

每用户成本的计算方法如下:

图片

使用之前从thejavascriptpromise.com/获取的数据,让我们将数据输入公式来计算每用户成本/月

这个网站部署在 DigitalOcean 上的 Ubuntu 服务器上,因此每月的基础设施成本(包括每周备份)为每月 6 美元。从 Google Analytics 中,我们知道 2017 年有 1,090 位独立访客:

图片

在 2017 年,我每个用户支付了 7 美分。这笔钱花得值吗?每月 6 美元,我并不介意。在 2017 年,thejavascriptpromise.com/ 部署在一个传统的服务器设置上,作为一个静态网站,无法扩展或缩减。这些条件使得使用独立访客指标和找到每个用户的成本变得非常直接。正是这种简单的计算方式,也导致了基础设施的低效。如果我要在相同的基础设施上服务 100 万用户,我的年度成本将达到 70,000 美元。如果我能通过谷歌广告为每 1000 个用户赚取 100 美元,我的网站每年将赚取 100,000 美元。在扣除税费、开发费用以及我们不合理的主机费用后,运营可能会亏损。

如果你想要利用云扩展,即根据当前用户需求动态扩展或缩减实例,那么上述公式很快就会变得毫无用处,因为你必须考虑配置时间和目标服务器利用率。

配置时间是你的云提供商从零开始启动新服务器所需的时间。目标服务器利用率是给定服务器的最大使用指标,其中必须发出扩展警报,以便在当前服务器达到最大容量之前,新服务器已经准备好。为了计算这些变量,我们必须对我们的服务器执行一系列负载测试。

页面浏览量是确定单页应用(如 Angular)中用户行为的过于简单的方法,在这些应用中,页面浏览量不一定与请求相关,或者反之亦然。如果我们仅仅基于页面浏览量执行负载测试,我们就无法得到一个关于你的平台在负载下可能如何表现的现实模拟。

用户行为,或者说用户实际如何使用你的应用,可以极大地影响你的性能预测,并使预算数字大幅波动。你可以使用谷歌分析自定义事件来捕捉由你的平台提供各种类型请求的复杂动作集。在本章的后面部分,我们将探讨如何在“测量实际使用”部分测量实际使用情况。

初始时,你将没有上述任何指标,而且任何你可能拥有的指标,在你对软件或硬件栈进行任何有意义的更改时都会被无效化。因此,定期执行负载测试以模拟真实用户负载是至关重要的。

高级负载测试

为了能够预测容量,我们需要运行负载测试。在第十三章,“AWS 上的高可用云基础设施”中,我讨论了一种简单的负载测试技术,即向服务器发送大量网络请求。在相对比较场景中,这对于测试原始功率来说效果不错。然而,实际用户在浏览你的网站时会产生数十个不同间隔的请求,这导致了对后端服务器的各种 API 调用。

我们必须能够模拟虚拟用户,并在我们的服务器上释放大量用户以找到服务器的瓶颈。OctoPerf 是一个易于使用的服务,用于执行此类负载测试,位于 octoperf.com。OctoPerf 提供了一个免费层,允许在无限次测试运行中使用两个负载生成器进行 50 个并发用户/测试。

OctoPerf 是一个理想的工具,可以帮助我们快速开始高级测试功能。让我们创建一个账户,看看它能为我们做什么:

  1. 创建一个 OctoPerf 账户。

  2. 登录并添加一个新的 LemonMart 项目,如图所示:图片

    图 14.10:在 OctoPerf 中添加项目

    OctoPerf 允许您创建具有不同使用特性的多个虚拟用户。由于它是一个基于 URL 的设置,任何基于点击的用户操作也可以通过直接调用应用程序服务器 URL 并传递测试参数来模拟。

  3. 创建两个虚拟用户,一个作为 经理,导航到基于经理的页面,另一个作为 POS 用户,坚持使用 POS 功能。

  4. 点击 创建场景图片

    图 14.11:POS 用户场景

  5. 将场景命名为 晚间高峰

  6. 您可以添加 经理POS 用户 类型的混合,如图所示:图片

    图 14.12:晚间高峰场景

  7. 点击 启动 50 VUs 按钮以开始负载测试。

    您可以观察实时达到的 用户数每秒点击数,如下面的屏幕截图所示:

    图片

    图 14.13:晚间高峰负载测试进行中

  8. ECS 服务指标还给我们提供了一个关于实时利用率的总体概念,如下面的屏幕截图所示:图片

    图 14.14:ECS 实时指标

  9. 分析负载测试结果。

您可以通过点击 ECS 服务指标 中的 CPUUtilization 链接或通过导航到 CloudWatch | 指标 部分来从 ECS 获取更准确的结果,如下所示:

图片

图 14.15:AWS CloudWatch 指标

如前图所示,在 10 分钟的持续用户负载为 50 的情况下,CPU 利用率相当稳定,大约为 1.3%。在此期间,没有请求错误,如 OctoPerf 的统计摘要所示:

图片

图 14.16:OctoPerf 统计摘要

理想情况下,我们会测量每秒最大用户数,直到错误开始产生。然而,鉴于只有 50 个虚拟用户和我们已经拥有的信息,我们可以预测在 100% 利用率下可以处理多少用户:

图片

我们的负载测试结果显示,我们的基础设施可以处理每秒 3,846 个用户。鉴于这一信息,我们可以在下一节计算可扩展环境中的每用户成本。然而,性能和可靠性是相辅相成的。你选择如何架构你的基础设施也将为预算提供重要信息,因为所需的可靠性水平将决定你必须始终保留的最小实例数量。

可靠的云扩展

可靠性可以用你组织的恢复点目标(RPO)和恢复时间目标(RTO)来表示。RPO 代表你愿意丢失多少数据,而 RTO 代表在发生故障的情况下,你可以多快重建你的基础设施。

假设你运营一个电子商务网站。在每周工作日的中午左右,你达到销售高峰。每次用户将商品添加到购物车时,你都会在服务器端缓存中存储这些商品,以便用户可以在家后继续购物。此外,你每分钟处理数百笔交易。生意兴隆,你的基础设施正在扩展,一切都很顺利。与此同时,一只饥饿的老鼠或过度充电的闪电云决定攻击你的数据中心。最初,一个看似无害的电源单元出现故障,但没关系,因为附近的电源单元可以填补空缺。然而,这是午餐高峰期;数据中心上的其他网站也面临着高流量。结果,几个电源单元过热并发生故障。没有足够的电源单元来填补空缺,因此,电源单元一个接一个地过热并开始故障,引发了一系列连锁反应,最终导致整个数据中心崩溃。与此同时,一些用户刚刚点击了加入购物车,其他人点击了支付按钮,还有一些人正准备到达你的网站。如果你的 RPO 是 1 小时,意味着你每小时持久化一次购物车缓存,那么你将不得不与那些夜间购物者失去宝贵的数据和潜在的销售。如果你的 RTO 是 1 小时,那么你需要 1 小时的时间才能让你的网站重新上线并运行,你可以放心,那些刚刚点击购买按钮或到达无响应网站的大部分客户那天不会在你的网站上购买。

一个周全的 RPO(恢复点目标)和 RTO(恢复时间目标)是关键的商业需求,但它们还必须与正确的基础设施相匹配,这样才能以成本效益的方式实现你的目标。AWS 由全球二十多个地区组成,每个地区至少包含它们的可用区(AZ)。每个 AZ 是一个物理上分离的基础设施,不会受到另一个 AZ 故障的影响。

在 AWS 上,高可用配置意味着您的应用程序至少在两个可用区(AZ)上运行,因此如果服务器实例失败,或者整个数据中心失败,您已经在物理上分开的数据中心中有一个实例已经运行,能够无缝地接收传入的请求。

容错架构意味着您的应用程序部署在多个区域。即使整个区域因自然灾害、DDoS 攻击或软件更新不当而完全中断,您的基础设施仍然屹立不倒,并且能够响应用户请求。您的数据通过多层安全措施和备份的备份进行保护。

AWS 提供优质的服务,包括Shield服务,用于保护您的网站免受 DDoS 攻击,Pilot Light服务以最低的基础设施在另一个区域保持等待休眠状态,如果需要,可以扩展到全容量,同时保持运营成本较低,以及Glacier服务,以经济实惠的方式存储大量数据长达数年。

高可用配置始终需要在多 AZ 设置中至少有两个实例。对于容错设置,您需要在至少两个区域中拥有两个高可用配置。大多数 AWS 云服务,如用于数据存储的 DynamoDB 或用于缓存的 Redis,默认都是高可用的,包括无服务器技术如 Lambda。Lambda 按使用量收费,并且可以以成本效益的方式扩展以满足任何需求。如果您可以将重计算任务移动到 Lambda,您可以在过程中显著降低服务器利用率和扩展需求。在规划您的基础设施时,您应该考虑所有这些变量,以设置满足您需求的正确可扩展环境。

可扩展环境中的每用户成本

在可扩展环境中,您不能指望 100%的利用率。部署新服务器需要时间。利用率达到 100%的服务器无法及时处理额外的传入请求,这会导致用户看到请求丢失或错误请求。因此,相关的服务器必须在达到 100%利用率之前发送触发信号,以确保不会丢失任何请求。在本章早期,我建议在扩展之前设定 60-80%的目标利用率。确切的数字将很大程度上取决于您选择的特定软件和硬件堆栈。

根据您自定义的利用率目标,我们可以计算出您的基础设施平均每个实例预期要服务的用户数量。利用这些信息,您可以计算出更准确的每用户成本,这应该能够根据您的具体需求正确调整您的 IT 预算。过度支出和不足支出同样糟糕。您可能正在放弃比可接受水平更多的增长、安全、数据、可靠性和弹性。

在下一节中,我们将介绍如何计算最佳目标服务器利用率指标,以便您可以计算更准确的每用户成本。然后,我们将探讨在预先计划的时间框架和软件部署过程中可能发生的扩展。

计算目标服务器利用率

首先,计算您自定义的服务器利用率目标,这是您的服务器经历增加流量并触发新服务器配置的时间点,以便原始服务器不会达到 100%的利用率并丢弃请求。考虑以下公式:

让我们通过一个具体的例子来展示公式的应用:

  1. 对您的实例进行负载测试以确定每个实例的用户容量:

    负载测试结果:每秒 3,846 用户。

    每秒请求数和每秒用户数并不相同,因为一个用户可能需要多次请求来完成一个操作,并且可能每秒执行多个请求。需要像 OctoPerf 这样的高级负载测试工具来执行真实且多样化的工作负载,并测量用户容量相对于请求容量的情况。

  2. 测量实例配置速度,从创建/冷启动到首次满足请求:

    测量的实例配置速度:60 秒。

    为了测量这个速度,您可以放下秒表。根据您的具体设置,AWS 在 ECS 服务事件标签页、CloudWatch 和 CloudTrail 中提供事件和应用日志,以关联足够的信息来找出何时请求了新实例以及实例准备就绪以处理请求所需的时间。

    例如,在ECS 服务事件标签页中,以目标注册事件作为开始时间。一旦任务已启动,点击任务 ID 以查看创建时间。使用任务 ID,检查 CloudWatch 中的任务日志以查看任务首次处理 Web 请求的时间作为结束时间,然后计算持续时间。

  3. 测量第 95 百分位数的用户增长率,排除已知的容量增加:

    第 95 百分位数用户增长率:每秒 10 用户。

    第 95 百分位数是一个常用的指标来计算整体网络使用情况。这意味着 95%的时间内,使用量将低于所声明的数量,这使得它是一个很好的数字用于规划,正如 Barb Dijker 在她的文章《这个 95 百分位数是什么意思?》中所解释的,该文章可在www2.arnes.si/~gljsentvid10/pct.html找到。

    如果您没有先前的指标,最初定义用户增长率将最多是一个有根据的猜测。然而,一旦您开始收集数据,您就可以更新您的假设。此外,不可能运营一个能够以经济有效的方式应对任何可想象到的异常值的基础设施,而不会丢弃请求。根据您的指标,您应该有意识地做出业务决策,确定应该忽略多少百分比的异常值作为可接受的业务风险。

  4. 让我们将数字代入公式:

图片

自定义目标利用率率,向下取整,将是 84%。将你的扩展触发器设置为 84%将避免实例过度配置,同时防止用户请求丢失。

在考虑到这个自定义目标利用率的情况下,让我们更新包含扩展的每个用户成本公式:

图片

因此,如果我们的基础设施每月为 150 个用户提供服务,成本为 100 美元,在 100%的利用率下,你计算出的每个用户的成本将是 0.67 美元/用户/月。如果你考虑扩展,成本将如下所示:

图片

不丢失请求的扩展将比原始的 0.67 美元/用户/月多 16%,达到 0.79 美元/用户/月。然而,重要的是要记住,你的基础设施并不总是如此高效。在较低的利用率目标下,或者当这些目标与扩展触发器配置不当时,成本可以轻易地翻倍、三倍或四倍于原始成本。这里的最终目标是找到最佳平衡点,这意味着你将为每个用户支付正确的金额。

没有规定的每个用户成本你应该追求。然而,如果你提供的服务在扣除所有其他运营成本和利润率后,每个用户每月收费 5 美元,并且你仍有剩余预算并且你的用户抱怨性能不佳,那么你可能支出不足。然而,如果你正在侵蚀你的利润率,或者更糟糕的是,仅仅收支平衡,那么你可能支出过多,或者你可能需要重新考虑你的商业模式。

有几个其他因素会影响你的每个用户成本,包括我们稍后将讨论的蓝绿部署。你还可以通过利用预定配置来提高扩展的效率。

预定配置

动态扩展和收缩然后再次扩展是定义云计算的关键。然而,目前可用的算法仍然需要一些规划,如果你知道一年中的某些日子、周或月将需要不寻常的高资源容量。面对突然涌入的新流量,你的基础设施将尝试动态扩展,但如果流量的增长速度是对数级的,即使优化了服务器利用率目标也无济于事。服务器将频繁达到并运行在 100%的利用率,导致请求丢失或错误。为了防止这种情况发生,你应该在可预测的高需求期间主动提供额外的容量。

蓝绿部署

在第十三章“AWS 上的高可用云基础设施”中,你配置了无停机时间的蓝绿部署。蓝绿部署是可靠的代码部署,确保你的网站持续在线,同时最大限度地降低不良部署的风险。

假设您有一个高可用性部署,这意味着在任何给定时间都有两个实例处于活动状态。在蓝/绿部署期间,将配置两个额外的实例。一旦这些额外的实例准备好满足请求,它们的健康状态将使用您预定义的健康指标来确定。

如果发现您的新实例是健康的,这意味着它们处于正常工作状态。将有一段时间,比如说 5 分钟,在这段时间内,原始实例中的连接会被耗尽并重新路由到新实例。在这段时间内,原始实例将被取消配置。

如果发现新实例不健康,则这些新实例将被取消配置,导致部署失败。然而,服务将保持不间断的可用性,因为原始实例将保持完整,在整个过程中继续为用户提供服务。

使用指标修订估计

压力测试和预测用户增长率可以帮助您了解系统在生产环境中的行为。收集更细粒度的指标和数据对于修订您的估计并确定更准确的 IT 预算至关重要。

测量实际使用

如我们之前讨论的,仅跟踪页面浏览量并不能反映用户向服务器发送的请求数量。使用 Google Tag Manager 和 Google Analytics,您可以轻松跟踪页面浏览量以外的更多内容。

到出版时为止,以下是一些您可以在各个类别中配置的默认事件。随着时间的推移,此列表将不断增长:

  • 页面浏览:用于跟踪用户在页面资源加载和页面完全渲染时是否停留:

    • 页面浏览;在首次机会触发

    • DOM 就绪;当 DOM 结构加载完成时

    • 窗口加载完成;当所有元素都加载完成时

  • 点击:用于跟踪用户与页面的点击交互:

    • 所有元素

    • 仅链接

  • 用户参与度:跟踪用户行为:

    • 元素可见性;元素是否已被显示

    • 表单提交;是否提交了表单

    • 滚动深度;他们滚动了页面多远

    • YouTube 视频;如果他们播放了嵌入的 YouTube 视频

  • 其他事件跟踪:

    • 自定义事件;由程序员定义以跟踪单个或多个步骤的事件,例如用户完成结账流程的步骤

    • 历史更改;用户是否在浏览器的历史记录中导航回

    • JavaScript 错误;是否生成了 JavaScript 错误

    • 计时器;用于触发或延迟基于时间的分析事件

大多数这些事件不需要任何额外的编码来实现,因此我们将实现一个自定义事件来演示您如何通过自定义编码捕获任何单个事件或一系列事件。通过一系列事件捕获工作流程可以揭示您应该将开发精力集中在哪些方面。

关于 Google Tag Manager 事件、触发器或技巧和窍门,我建议您查看 Simo Ahava 的博客www.simoahava.com/以获取更多信息。

创建自定义事件

在此示例中,我们将捕获客户成功结账并完成销售的事件。我们将实现两个事件,一个用于结账发起,另一个用于交易成功完成:

  1. 登录您的 Google Tag Manager 工区tagmanager.google.com

  2. 触发器菜单下,单击新建,如图所示:

    图 14.17:标签管理器工区

  3. 为您的触发器命名。

  4. 单击空触发器卡以选择事件类型。

  5. 选择自定义事件

  6. 创建一个名为checkoutCompleted的自定义事件,如图所示:

    图 14.18:自定义结账事件

    通过选择某些自定义事件选项,您可以限制或控制特定事件的收集,即仅在特定页面或域名上,例如在lemonmart.com上。在以下截图中,您可以看到一个自定义规则,该规则会过滤掉在lemonmart.com上未发生的任何结账事件,以排除开发或测试数据:

    图 14.19:某些自定义事件

  7. 保存您的新事件。

  8. 为名为Checkout Initiated的事件重复此过程。

  9. 添加两个新的 Google Analytics 事件标签,如下截图所示:

    图 14.20:新的自定义事件标签

  10. 配置事件并将其与您创建的相关触发器附加到它,如下截图所示:

    图 14.21:触发器设置

  11. 提交并发布您的工区。

我们现在已准备好在我们的分析环境中接收自定义事件。

在 Angular 中添加自定义事件

现在,让我们编辑 Angular 代码以触发事件:

  1. 考虑带有结账按钮的 POS 模板:

    **src/app/pos/pos/pos.component.html**
    <p>
      <img
        src=”https://user-images.githubusercontent.com/822159/36186684-9f05fef8-110e-11e8-991f-fae6ca60fe5d.png” />
    </p>
    <p>
      <button mat-icon-button (click)=”checkout(currentTransaction)”>
        <mat-icon>shopping_cart</mat-icon> Checkout Customer
      </button>
    </p> 
    

    以下图中的圆形结账按钮位于左下角:

    图 14.22:带有结账按钮的 POS 页面

    可选地,您可以直接在模板中添加一个onclick事件处理器,例如在结账按钮上onclick="dataLayer.push({'event': 'checkoutInitiated'})"。这会将checkoutInitiated事件推送到由gtm.js提供的dataLayer对象。

  2. 定义一个ITransaction接口:

    **src/app/pos/transaction/transaction.ts**
    ...
    export interface ITransaction {
      paymentType: TransactionType
      paymentAmount: number
      transactionId?: string
    }
    ... 
    
  3. 定义一个TransactionType枚举:

    **src/app/pos/transaction/transaction.enum.ts**
    ...
    export enum TransactionType {
      Cash,
      Credit,
      LemonCoin,
    }
    ... 
    
  4. 实现一个具有processTransaction函数的TransactionService

    **src/app/pos/transaction/transaction.service.ts**
    ...
    @Injectable({
      providedIn: ‘root’,
    })
    export class TransactionService {
      constructor() {}
      processTransaction(transaction: ITransaction)
          : Observable<string> {
        return new
          BehaviorSubject<string>(‘5a6352c6810c19729de860ea’)
          .asObservable()
      }
    }
    ... 
    

    ‘5a6352c6810c19729de860ea’是一个表示交易 ID 的随机字符串。

    PosComponent中声明一个用于推送dataLayer事件的接口:

    **src/app/pos/pos/pos.component.ts**
    ...
    interface IEvent {
    event: ‘checkoutCompleted’ | ‘checkoutInitiated’
    }
    declare let dataLayer: IEvent[]
    ... 
    

    导入依赖项并初始化currentTransaction

    **src/app/pos/pos/pos.component.ts**
    ...
    export class PosComponent implements OnInit, OnDestroy {
      private subs = new SubSink()
      currentTransaction: ITransaction
      constructor(
        private transactionService: TransactionService,
        private uiService: UiService
      ) {}
       ngOnInit() {
        this.currentTransaction = {
          paymentAmount: 25.78,
          paymentType: TransactionType.Credit,
        } as ITransaction
      }
      ngOnDestroy() {
        this.subs.unsubscribe()
      }
    ... 
    

    在发起服务调用之前创建checkout函数以调用checkoutInitiated

    使用setTimeout模拟假交易并在超时结束时调用checkoutCompleted事件:

    **src/app/pos/pos/pos.component.ts**
    export class PosComponent implements OnInit {
    ...
      checkout(transaction: ITransaction) {
        this.uiService.showToast(‘Checkout initiated’)
        dataLayer.push({
          event: ‘checkoutInitiated’,
        })
        this.subs.sink = this.transactionService
          .processTransaction(transaction)
          .pipe(
            filter((tx) => tx != null || tx !== undefined),
            tap((transactionId) => {
              this.uiService.showToast(‘Checkout completed’)
              dataLayer.push({
                event: ‘checkoutCompleted’,
              })
            })
          )
          .subscribe()
    } 
    

为了防止在分析收集过程中丢失任何数据,请考虑覆盖失败情况,例如添加多个checkoutFailed事件,以涵盖各种失败情况。

现在,我们已经准备好看到分析的实际应用:

运行您的应用。

在 POS 页面上,点击结账按钮。

在 Google Analytics 中,观察实时 | 事件标签以查看事件发生的情况。

5-10 分钟后,事件也会在行为 | 事件标签下显示,如下所示:

图 14.23:Google Analytics 顶级事件

使用自定义事件,您可以跟踪您网站上发生的各种细微的用户行为。通过收集checkoutInitiatedcheckoutCompleted事件,您可以计算有多少发起的结账被完成,从而计算转化率。在销售点系统中,这个比率应该非常高;否则,这可能意味着您可能存在系统性的问题。

高级分析事件

在结账开始时,可以收集与每个事件相关的附加元数据,例如支付金额或类型,或者当结账完成时的transactionId

要使用这些更高级的功能,我建议您查看angulartics2,它可以在www.npmjs.com/package/angulartics2找到。angulartics2是一个对供应商无关的 Angular 分析库,可以使用流行的供应商,如 Google Tag Manager、Google Analytics、Adobe、Facebook、百度等,如工具主页上所强调的,如下所示:

图 14.24:Angulartics2

angulartics2与 Angular 路由和 UI 路由集成,能够在每个路由的基础上实现自定义规则和异常。该库使得实现自定义事件和通过数据绑定进行元数据跟踪变得容易。

查看以下示例:

<div angulartics2On="click" angularticsEvent="DownloadClick"
  angularticsCategory="{{ song.name }}"
  [angularticsProperties]="{label: 'Fall Campaign'}">
</div> 

我们可以跟踪一个名为DownloadClickclick事件,该事件将附加一个category和一个label,以便在 Google Analytics 中进行丰富的事件跟踪。

在掌握高级分析之后,您可以使用实际的使用数据来指导您如何改进或托管您的应用。这个主题结束了一个旅程,这个旅程始于本书开头创建铅笔草图,涵盖了全栈网络开发者必须熟悉的各种工具、技术和技术,以便在今天的网络中取得成功。我们深入研究了 Angular、Angular Material、Docker 和自动化,以使您成为最富有生产力的开发者,交付最高质量的网络应用,同时在这个过程中处理大量的复杂性。祝您好运!

摘要

在本章中,你完善了你对开发 Web 应用程序的知识。你学习了如何使用 Google Tag Manager 和 Google Analytics 来捕获你的 Angular 应用程序的页面浏览量。使用高级指标,我们讨论了你可以如何计算每用户的 Infrastructure 成本。然后我们探讨了高可用性和扩展对预算的影响的细微差别。我们涵盖了复杂用户工作流程的负载测试,以估计任何给定服务器可以同时托管多少用户。使用这些信息,我们计算了目标服务器利用率以微调你的扩展设置。

我们的所有预发布计算基本上都是估计和有根据的猜测。我们讨论了你可以用来衡量你应用程序实际使用的各种指标和自定义事件。当你的应用程序上线并开始收集这些指标时,你可以更新你的计算以更好地理解你基础设施的可行性和成本效益。

恭喜!你已经完成了你的旅程。我希望你玩得开心!请随意使用这本书作为参考,包括附录。

关注我的 Twitter @duluca 并关注 expertlysimple.io 以获取更新。

进一步阅读

  • Google Analytics 和 Google Tag Manager 博客,由 Simo Ahava 提供:www.simoahava.com

问题

尽可能好地回答以下问题,以确保你已理解本章的关键概念,而无需使用 Google。你需要帮助回答这些问题吗?请参阅 附录 D自我评估答案,在线位于 static.packt-cdn.com/downloads/9781838648800_Appendix_D_Self-Assessment_Answers.pdf 或访问 expertlysimple.io/angular-self-assessment

  1. 负载测试的好处是什么?

  2. 关于可靠的云扩展,有哪些考虑因素?

  3. 测量用户行为的价值是什么?

附录 A

Angular 调试

"一个表述清晰的问题已经解决了一半。" 20 世纪初通用汽车公司研究部门负责人查尔斯·凯特林(Charles Kettering)曾说过,为了有效地找到解决问题的方法,你必须首先能够很好地解释它。换句话说,你必须首先投入时间去理解问题是什么,当你做到了这一点,你就已经解决了一半的问题。

有效的调试对于理解为什么或如何你的软件失败至关重要。与使用console.log相比,有更好的方法来调试你的 JavaScript 代码。本附录将介绍各种工具和技术,以介绍断点调试和可以帮助你更好地理解应用程序状态的浏览器扩展。

在本附录中,我们涵盖了以下内容:

  • 最有用的快捷键

  • 浏览器中的错误排查

  • Karma、Jasmine 和单元测试错误

  • 使用开发者工具进行调试

  • 使用 VS Code 进行调试

  • 使用 Angular Augury 进行调试

  • 使用 Redux DevTools 进行调试

  • RxJS 调试

让我们从学习一个将使你更加高效的生产力快捷键开始。

最有用的快捷键

在不熟悉或大型代码库中找到自己的位置可能会很困难,令人困惑,甚至令人烦恼。有一个键盘快捷键可以解决这个问题,这个快捷键在多个工具中共享,如 VS Code 和 Chrome/Edge 开发者工具(dev tools)。

要在 VS Code 或开发者工具中的面板中搜索并打开文件,请使用以下快捷键:

在 macOS 上:图片 + P

在 Windows 上:Ctrl + P

你会很快发现,这将是你最常使用的快捷键。

浏览器中的错误排查

在本节中,你将故意引入一个容易犯的错误,这样你就可以熟悉在开发应用程序时可能发生的真实错误,并深入了解使你成为有效开发者的工具。

请参考第四章自动化测试、持续集成和发布到生产,以及 LocalCast 天气应用,以更好地理解以下代码示例。

LocalCast 天气应用的最新版本可以在 GitHub 上找到:github.com/duluca/local-weather-app

让我们假设我们在从OpenWeatherMap.org的 API 文档页面复制粘贴 URL 时犯了一个无辜的错误,并且忘记在前面添加http://。这是一个容易犯的错误:

**src/app/weather/weather.service.ts**
...
return this.httpClient
  .get<ICurrentWeatherData>(
`api.openweathermap.org/data/2.5/weather?q=${city},${country}&appid=${environment.appId}`
  ).pipe(map(data => this.transformToICurrentWeather(data)))
... 

你的应用将成功编译,但当你检查浏览器中的结果时,你不会看到任何天气数据。实际上,看起来CurrentWeather组件根本就没有渲染,正如你在以下图片中可以看到的:

图片

图 1:CurrentWeather 无法渲染

为了找出原因,你需要调试你的 Angular 应用。

利用浏览器开发者工具

作为一名开发者,我使用 Edge 或 Google Chrome 浏览器,因为它们具有跨平台和一致的开发者工具,以及有用的扩展。

作为最佳实践,我使用 VS Code 和浏览器并排编码,同时开发工具也在浏览器中打开。有多个很好的理由来实践并排开发:

  • 快速反馈循环:使用实时重新加载,你可以非常快地看到你更改的最终结果

  • 笔记本电脑:现在很多开发者都在笔记本电脑上进行大部分的开发工作,第二块显示器是一种奢侈

  • 关注响应式设计:由于我的工作空间有限,我始终关注移动优先开发,在之后修复桌面布局问题

  • 关注网络活动:使我能够快速看到任何 API 调用错误,并确保请求的数据量符合我的预期

  • 关注控制台错误:使我能够快速反应并解决当引入新错误时

  • 禁用缓存:这样你知道你总是得到所有的更改,而不是与浏览器的缓存作斗争

观察并排开发的样貌:

图片

图 2:运行实时重新加载的并排开发

最终,你应该做最适合你的事情。在使用并排设置的情况下,我经常发现自己需要打开和关闭 VS Code 的资源管理器,并根据具体任务调整开发工具窗格的大小。要切换 VS Code 的资源管理器,请点击前一张截图中圈出的资源管理器图标。

就像你可以使用npm start进行带有实时重新加载的并排开发一样,你也可以使用npm test获得相同类型的快速反馈循环,用于单元测试:

图片

图 3:单元测试并行的开发

使用并排单元测试设置,你可以成为开发单元测试的高效者。

优化开发工具

为了使带有实时重新加载的并排开发工作得很好,你需要优化默认的开发工具体验:

图片

图 4:优化后的 Chrome 开发者工具

观察前一张截图,你会注意到许多设置和信息发射器被突出显示:

  1. 默认打开网络选项卡,以便你可以看到网络流量流动。

  2. 通过点击 按钮打开开发工具设置。

  3. 点击右侧图标,以便开发工具停靠在 Chrome 的右侧。这种布局提供了更多的垂直空间,因此你可以一次性看到更多的网络流量和控制台事件。作为额外的好处,左侧大致呈现移动设备的尺寸和形状。

  4. 开启大请求行并关闭概览,以便看到每个请求的更多 URL 和参数,并获得更多垂直空间。

  5. 选择禁用缓存选项,这将强制在开发工具打开时刷新页面时重新加载每个资源。这可以防止奇怪的缓存错误破坏你的日子。

  6. 你主要会关注查看对各种 API 的 XHR 调用,因此点击XHR以过滤结果。

  7. 注意,你可以在右上角查看控制台错误的数量为12。理想情况下,控制台错误的数量应该始终为 0。

  8. 注意,请求行中的顶部条目表明存在状态码为404 Not Found的错误。

  9. 由于我们正在调试 Angular 应用程序,已经加载了Augury扩展。这个工具将在本章后面更详细地介绍,使用更复杂的 LemonMart 应用程序。

在你的优化开发工具环境中,你现在可以有效地调试和解决之前的应用程序错误。

故障排除网络问题

在这个阶段,应用程序存在三个可见问题:

  • 组件详情未显示

  • 存在许多控制台错误

  • API 调用返回404 Not Found错误

首先检查任何网络错误,因为网络错误通常会导致连锁反应:

  1. 网络选项卡中点击失败的 URL

  2. 在 URL 右侧打开的详细信息面板中,点击预览选项卡

  3. 你应该看到这个:

    Cannot GET /api.openweathermap.org/data/2.5/weather 
    

    仅通过观察这个错误信息,你可能会错过忘记添加http://前缀到 URL 的事实。这个错误很微妙,绝对不是显而易见的。

  4. 将鼠标悬停在 URL 上,观察完整的 URL,如图所示:完整 URL 截图

    图 5:检查网络错误

如你所见,现在错误非常明显。在这个视图中,我们可以看到完整的 URL,并且很明显,weather.service.ts中定义的 URL 不是完全限定的,因此 Angular 正在尝试从其父服务器(localhost:5000)加载资源,而不是通过网络到正确的服务器。

调查控制台错误

在修复这个问题之前,了解失败的 API 调用的连锁反应是值得的:

  1. 观察控制台错误:控制台错误截图

    图 6:开发者工具控制台错误上下文

    这里值得注意的第一个元素是名为ERROR CONTEXT的对象,它有一个名为DebugContext_的属性。DebugContext_包含错误发生时 Angular 应用程序当前状态的详细快照。DebugContext_中包含的信息比 AngularJS 生成的几乎无用的错误信息多得多。

    值为(...)的属性是属性获取器,你必须点击它们来加载它们的详细信息。例如,如果你点击componentRenderElement的省略号,它将被app-current-weather元素填充。你可以展开元素来检查组件的运行时条件。

  2. 现在滚动到控制台顶部

  3. 观察第一个错误:

    ERROR TypeError: Cannot read property 'city' of undefined 
    

你可能之前遇到过TypeError。这种错误是由于尝试访问未定义的对象属性而引起的。在这种情况下,CurrentWeatherComponent.current因为没有 HTTP 调用失败而没有分配给一个对象。由于current未初始化,模板盲目地尝试绑定到其属性,如{{current.city}},我们得到一条消息说属性'city'的未定义无法读取。这就是可能在你应用程序中产生许多不可预测副作用的那种连锁反应。你必须积极编码以防止这种情况发生。

Karma、Jasmine 和单元测试错误

当使用ng test命令运行测试时,你可能会遇到一些高级错误,这些错误可能会掩盖实际潜在错误的根本原因。

解决错误的通用方法应该是从内到外,首先解决子组件的问题,最后留下父组件和根组件。

网络错误

网络错误可能由多种潜在问题引起:

NetworkError: Failed to execute 'send' on 'XMLHttpRequest': Failed to load 'ng:///DynamicTestModule/AppComponent.ngfactory.js'. 

从内到外工作,你应该实现服务的测试双倍,并向适当的组件提供伪造品,如前一小节所述。然而,即使在父组件中正确提供了伪造品,你也可能仍然会遇到错误。请参阅处理通用错误事件的章节,以揭示潜在问题。

通用错误事件

错误事件是隐藏潜在原因的通用错误:

[object ErrorEvent] thrown 

为了暴露通用错误的根本原因,实现一个新的test:debug脚本:

  1. 如下所示,在package.json中实现test:debug

    **package.json**
    ...
      "scripts": {
      ...
      "test:debug": "ng test --source-map",
      ...
    } 
    
  2. 执行npm run test:debug

  3. 现在,Karma 运行器可能会揭示根本问题

  4. 如果需要,根据堆栈跟踪找到可能引起问题的子组件

    如果这种策略不起作用,你可能可以通过断点调试你的单元测试来获取更多关于错误原因的信息。

使用开发工具进行调试

关于是否将console.log输出到控制台;这是一个问题。记录在案,让我声明,console.log语句永远不会被提交到你的仓库。一般来说,它们是浪费时间,因为它们需要编辑、构建和运行代码来带来价值,更不用说清理代码的成本了。

调试的首选方法是断点调试,这是一种暂停代码执行的方法,在代码运行时检查和操作其状态。你可以有条件地设置断点,逐行遍历你的代码,甚至可以在控制台中执行语句来尝试新想法。

Angular 9 和 Ivy 带来了许多调试改进,使得调试异步代码和模板成为可能。此外,Angular 9 生成的堆栈跟踪在确定错误根本原因方面要远比以前更有用。

有一些特殊用例,其中console.log语句可能很有用。这些主要是并行操作且依赖于及时用户交互的异步工作流程。在这些情况下,控制台日志可以帮助你更好地理解事件流和各个组件之间的交互。你可以在本章后面的调试 RxJS部分看到这一点。

对于常见情况,我们应该坚持使用断点调试。使用开发者工具,我们可以观察属性在被设置时的状态,并且能够即时更改它们的值,以强制代码在if-elseswitch语句中执行分支逻辑。

假设HomeComponent上存在一些基本逻辑,它根据从AuthService检索到的isAuthenticated值设置一个displayLogin boolean,如下所示:

**src/app/home/home.component.ts**
...
import { AuthService } from '../auth.service'
...
export class HomeComponent implements OnInit {
  displayLogin = true
  constructor(private authService: AuthService) {}
  ngOnInit() {
    this.displayLogin = !this.authService.isAuthenticated()
  }
} 

现在观察displayLogin的值和isAuthenticated函数在被设置时的状态,然后观察displayLogin值的改变:

  1. 切换到开发者工具中的标签

  2. 使用最有用的快捷键,Ctrl + P图片 + P,搜索HomeComponent

  3. ngOnInit函数内的第一行设置一个断点

  4. 刷新页面

  5. 你会看到你的断点被触发,如这里用蓝色突出显示的图片

    图 7:Chrome 开发者工具断点调试

  6. 悬停在this.displayLogin上并观察其值被设置为true

  7. 如果你悬停在this.authService.isAuthenticated()上,你将无法观察到其值

    当你的断点被触发时,你可以在控制台中访问当前的状态作用域,这意味着你可以执行函数并观察其值。

  8. 在控制台执行isAuthenticated()

    > this.authService.isAuthenticated() true 
    

    你会观察到它返回true,这正是this.displayLogin被设置的值。你仍然可以在控制台中强制改变displayLogin的值。

  9. displayLogin设置为false

    > this.displayLogin = false false 
    

如果你观察displayLogin的值,无论是通过悬停在其上还是从控件中检索它,你会发现该值被设置为false

利用断点调试的基础知识,你可以调试复杂场景而无需更改源代码。你也可以调试模板以及复杂的回调,使用 RxJS 语句。

使用 Visual Studio Code 进行调试

你也可以直接在 Visual Studio Code 中调试你的 Angular 应用程序、Karma 和 Protractor 测试。首先,你需要配置调试器以与 Chrome 调试环境一起工作,如图所示:

图片

图 8:VS Code 调试设置

  1. 点击调试面板

  2. 展开无配置下拉菜单并点击添加配置...

  3. 选择环境复选框中,选择Chrome

    这将在.vscode/launch.json文件中创建一个默认配置。我们将修改此文件以添加三个单独的配置。

  4. launch.json的内容替换为以下配置:

    **.vscode/launch.json**
    {
      "version": "0.2.0",
      "configurations": [
        {
          "name": "Debug npm start with Chrome",
          "type": "chrome",
          "request": "launch",
          "url": "http://localhost:5000/#",
          "webRoot": "${workspaceRoot}",
          "runtimeArgs": [
            "--remote-debugging-port=9222"
          ],
          "sourceMaps": true,
          "preLaunchTask": "npm: start"
        },
        {
          "name": "Debug npm start with Edge",
          "type": "edge",
          "request": "launch",
          "version": "dev",
          "url": "http://localhost:5000/#",
          "webRoot": "${workspaceRoot}",
          "sourceMaps": true,
          "preLaunchTask": "npm: start"
        },
        {
          "name": "Debug npm test with Chrome",
          "type": "chrome",
          "request": "launch",
          "url": "http://localhost:9876/debug.html",
          "webRoot": "${workspaceRoot}",
          "runtimeArgs": [
            "--remote-debugging-port=9222"
          ],
          "sourceMaps": true,
          "preLaunchTask": "npm: test"
        },
        {
          "name": "Debug npm test with Edge",
          "type": "edge",
          "request": "launch",
          "version": "dev",
          "url": "http://localhost:9876/debug.html",
          "webRoot": "${workspaceRoot}",
          "sourceMaps": true,
          "preLaunchTask": "npm: test"
        },
        {
          "name": "npm run e2e",
          "type": "node",
          "request": "launch",
          "program": "${workspaceRoot}/node_modules/protractor/bin/protractor",
          "protocol": "inspector",
          "args": [
            "${workspaceRoot}/protractor.conf.js"
          ]
        }
      ]
    } 
    

    注意,我们还为微软基于 Chromium 的新 Edge 浏览器添加了调试器。

  5. 在开始调试器之前,执行相关的 CLI 命令,如npm startnpm testnpm run e2e

  6. 调试页面,在调试下拉菜单中,选择npm start并点击绿色的播放图标

  7. 观察到一个 Chrome 实例已启动

  8. .ts文件上设置断点

  9. 在应用程序中执行操作以触发断点

  10. 如果一切顺利,Chrome 将报告代码已在 Visual Studio Code 中暂停

    更多信息,请参阅 GitHub 上 VS Code 菜谱的 Angular CLI 部分github.com/Microsoft/vscode-recipes

使用 Angular Augury 进行调试

Augury 是一个用于调试和性能分析的 Chrome DevTools 扩展,它是一个专为帮助开发者可视化导航组件树、检查路由状态以及通过源映射在生成的 JavaScript 代码和开发者编写的 TypeScript 代码之间启用断点调试而设计的工具。

注意,Augury 与 Angular 9 的 Ivy 渲染引擎不完全兼容。为了某些功能(如路由树和 NgModules)能够工作,您需要暂时在项目中禁用 Ivy。

您可以通过更新项目根目录下的tsconfig.app.json来关闭 Ivy,向其中添加以下属性:

"angularCompileOptions": {
  "enableIvy": false
} 

您可以从augury.angular.io下载 Augury。一旦安装,当您为 Angular 应用程序打开 Chrome DevTools 时,您会注意到一个名为 Augury 的新选项卡,如图所示:

图 9:Chrome DevTools Augury

Augury 提供了有用的关键信息,以了解您的 Angular 应用程序在运行时的行为:

  1. 当前 Angular 版本列在下面,在这种情况下,版本为9.1.7

  2. 组件树显示了在应用程序中渲染的所有 Angular 组件

  3. 路由树显示了应用程序中配置的所有路由

  4. NgModules显示了应用程序的AppModule和功能模块

组件树

组件树选项卡显示了所有应用程序组件之间的关系以及它们如何相互交互:

  1. 按如下方式选择特定的组件,例如HomeComponent

    图 10:Augury 组件树

    右侧的属性选项卡将显示一个名为查看源代码的链接,您可以使用它来调试您的组件。进一步向下,您将能够观察到组件属性的状态,例如displayLogin boolean,包括注入到组件中的服务和它们的状态。

    您可以通过双击值来更改任何属性的值。

    例如,如果您想将displayLogin的值更改为false,只需双击包含true值的蓝色框,并输入false。您将能够在 Angular 应用程序中观察到您更改的效果。

    为了观察 HomeComponent 的运行时组件层次结构,你可以观察 注入器图

  2. 点击如图所示的 注入器图 选项卡:

图 11:Augury 注入器图

此视图显示了所选组件是如何被渲染的。在这种情况下,我们可以观察到 HomeComponent 是在 AppComponent 中被渲染的。这种可视化在追踪不熟悉的代码库中特定组件的实现或存在深层组件树的情况下非常有帮助。

路由树

路由树 选项卡将显示路由的当前状态。这可以是一个非常有助于可视化路由和组件之间关系的工具,如图所示:

图 12:Augury 路由树

上述路由树展示了具有主-详细视图的深度嵌套路由结构。你可以通过点击圆形节点来查看渲染给定组件所需的绝对路径和参数。

如你所见,对于 PersonDetailsComponent,确定渲染主-详细视图的这一部分所需的确切参数集可能会变得复杂。

NgModules

NgModules 选项卡显示了 AppModule 和任何其他当前已加载到内存中的功能模块:

  1. 启动应用程序的 /home 路由并在地址栏中按回车键,以便 Augury 注册导航事件

  2. 观察如图所示的 NgModules 选项卡:

    图 13:Augury NgModules

    你会注意到所有根级模块,包括 AppModule,都已经加载。然而,由于我们的应用程序具有懒加载架构,我们还没有加载任何功能模块。

  3. 导航到 ManagerModule 中的一个页面并在地址栏中按回车键

  4. 然后,导航到 UserModule 中的一个页面并在地址栏中按回车键

  5. 最后,导航回 /home 路由并在地址栏中按回车键

  6. 观察如图所示的 NgModules 选项卡:

    图 14:Augury 包含三个模块的 NgModules

  7. 现在,你会注意到 ManagerModuleUserModule 以及所有相关的模块已经被加载到内存中。

NgModules 是一个重要的工具,可以可视化你的设计和架构的影响。

使用 Redux DevTools 进行调试

有两种主要的策略用于调试和从 NgRx 获取仪表化。

  1. 实现一个控制台日志记录器进行调试

  2. 配置 Store DevTools 以进行丰富的仪表化

让我们从简单的调试解决方案开始。

实现 NgRx 控制台日志记录器

app.module 中,StoreModule 被配置为将一个 MetaReducer 注入到你的配置中。元减法器能够监听在动作-减法器管道中发生的所有事件,从而赋予你预处理动作的能力。我们可以使用这个钩子来实现一个简单的日志记录器。

  1. reducers/index.ts 中实现一个名为 logger 的函数:

    **src/app/reducers/index.ts**
    export function logger(reducer: ActionReducer<AppState>): ActionReducer<AppState> {
      return (state, action) => {
        const result = reducer(state, action)
        console.groupCollapsed(action.type)
        console.log('prev state', state)
        console.log('action', action)
        console.log('next state', result)
        console.groupEnd()
        return result
      }
    } 
    
  2. 使用 metaReducers 配置 logger,并且仅在非生产模式下:

    **src/app/reducers/index.ts**
    export const metaReducers: MetaReducer<AppState>[] =
    !environment.production
      ? [logger]
      : [] 
    

现在尝试一下,你应该能在你的控制台中观察到 NgRx,如下所示:

图 15:带有 NgRx 日志的控制台视图

配置 NgRx Store DevTools

NgRx Store Devtools 包也可以在开发过程中协助我们的调试工作,或者在生成生产构建时提供仪表化。通过运行以下命令添加该包:

$ npx ng add @ngrx/store-devtools 

你会注意到该包会自动在app.module中添加生产仪表化规则,以便只捕获最后 25 个事件。这是为了避免性能问题。

一旦安装,为了利用生成的仪表化并能够调试 NgRx,你将需要安装 Chrome 或 Firefox 的 Redux DevTools 扩展,可以在github.com/zalmoxisus/redux-devtools-extensionextension.remotedev.io找到。

一旦启动你的应用程序,激活扩展,你应该能看到 Redux DevTools 随着时间的推移正在捕获详细的仪表化,如下所示:

图 16:Redux DevTools

Redux DevTools 为你提供了回放事件和查看状态变化的能力。这在上一个截图的右下象限中得到了演示。你可以观察到当前城市为布尔萨,其前一个值为贝塞斯达

调试 RxJS

调试 RxJS 管道内部发生情况的主要策略有两个:

  1. 深入事件流并记录特定点的流事件数据

  2. 在开发者工具中执行断点调试

让我们从使用tap操作符开始。

深入 RxJS 事件流

第六章表单、可观察对象和主题中,我们介绍了 RxJS 的tap操作符,作为将用户输入从搜索输入的更改事件流中引导出来的方式,并最终调用我们的doSearch函数。当 RxJS 流似乎没有按预期行为时,你可以结合tap操作符和console.log来记录每个事件的详细数据,这样你就可以随着时间的推移看到它。由于tap根据操作顺序中的位置捕获流中的数据,一旦添加到流中,你就可以简单地使用 VS Code 的行移动键盘快捷键来移动它并测试流程。

要移动代码行上下,在 Windows 上使用Alt + Alt + ,在 macOS 上使用![](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/ng-entp-rdy-webapp/img/B14094_A2_231.png) + ![](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/ng-entp-rdy-webapp/img/B14094_A2_231.png) +

CitySearchComponent中的以下tap将记录来自输入的每个更改事件:

this.search.valueChanges 
  .pipe( 
 **tap(console.log),**
    debounceTime(1000), 
    filter(() => !this.search.invalid), 
    tap((searchValue: string) => this.doSearch(searchValue)) 
  ).subscribe() 

图 17:RxJS 记录每个事件

如果我们将tap向下移动一行,那么我们不会得到每个用户输入,而只会得到防抖事件:

this.search.valueChanges 
  .pipe( 
    debounceTime(1000), 
    **tap(console.log),** 
    filter(() => !this.search.invalid), 
    tap((searchValue: string) => this.doSearch(searchValue)) 
  ).subscribe() 

图 18:RxJS 仅记录防抖事件

再向下移动一行,我们看到经过防抖和过滤后的事件:

this.search.valueChanges 
  .pipe( 
    debounceTime(1000), 
    filter(() => !this.search.invalid), 
    **tap(console.log),** 
    tap((searchValue: string) => this.doSearch(searchValue)) 
  ).subscribe() 

图 19:RxJS 忽略无效输入

注意,由于一个字符无效,过滤器捕获了这个事件并没有让它通过,因此我们在控制台中看不到任何数据。

虽然在控制台中可能会非常混乱,但你也可以同时记录很多东西,使用回调而不是仅仅传递console.log可调用函数:

this.search.valueChanges 
  .pipe( 
    debounceTime(1000), 
    tap(debouncedData => 
      console.log('debounced: ', debouncedData)
    ), 
    filter(() => !this.search.invalid), 
    tap(debouncedAndFilteredData => 
      console.log(
        'debounced + filtered: ', 
        debouncedAndFilteredData
      )
    ), 
    tap((searchValue: string) => this.doSearch(searchValue)) 
  ).subscribe() 

图 20:RxJS 记录多个事件

接下来,让我们看看如何利用断点调试。

断点调试 RxJS 事件流

参考本章前面的使用 DevTools 进行调试部分,了解更多关于断点调试的信息。在调试 RxJS 时,重要的是要理解调试器中蓝色光标的作用。

当一行代码有多个可以暂停执行的位置时,这些位置会用方形光标表示。这些光标可以是开启的(深色,实心)或关闭的(浅色,透明),以指示你希望在代码行的哪个位置让浏览器停止,如下面的截图所示:

图 21:RxJS 断点调试

光标可以用来在浏览器中停止流,在执行工作的回调函数内部,以检查数据或甚至操作它。在上面的例子中,我已经将断点调试器设置为在过滤器函数内部中断,这样我就可以检查搜索输入字段的当前状态。

通过实验调试来了解更多相关信息。

进一步的建议

如果你仍然遇到困难,不要沮丧。即使是最好的人有时也会在小事上花费无数小时。然而,如果你需要,你可以寻求帮助。Angular 在网上有一个丰富且支持性强的社区。

你寻求帮助的方式非常重要。首先使用stackblitz.com/复现你的问题。一半的时间,你会在复现问题的过程中找到你的问题。接下来,在stackoverflow.com上提问。如果你能提供 StackBlitz 链接到你的问题,你的问题很可能会得到快速解答。如果最终发现可能是框架本身的问题,那么在正确的存储库(换句话说,Angular、CLI、Material、NgRx、RxJS 等)上创建一个 GitHub 问题,详细说明你的问题。如果你在框架中发现了 bug,那么你就正式为开源项目的发展做出了贡献:

否则,你也可以使用 Twitter 并搜索@angular#angular标签以寻求帮助。我个人是 Google 的大粉丝。我的哲学是,别人可能遇到了和我一样的问题,当我说这句话的时候——一个良好的 Google 查询就是问题解决了一半。

附录 B

Angular 快速参考

这里有一份快速参考表,帮助你熟悉常见的 Angular 语法和 CLI 命令。花点时间复习并熟悉新的 Angular 语法、主要组件、CLI 框架和常见管道。

如果你之前使用过 AngularJS,你会发现这个列表特别有用,因为你需要学习一些旧的语法。

如果你刚开始使用 Angular 或者不是 CLI 命令的粉丝,可以查看 Nx Console 在 nx.dev/angular/cli/console,这是一个出色的桌面应用程序,也是 Visual Studio Code 的扩展,它可以为你编写 CLI 参数。话虽如此,我仍然建议你首先熟悉 CLI 命令,并强迫自己使用它们一段时间,这样你就能更好地理解 Nx Console 的工作原理。

绑定,或数据绑定,指的是代码中的变量与在 HTML 模板或其他组件中显示或输入的值之间自动的单一或双向连接:

类型 语法 数据方向
插值属性属性类样式 {{expression}} [target]="expression" bind-target="expression" 单向,从数据源到视图目标
事件 (target)="statement" on-target="statement" 单向,从视图目标到数据源
双向 [(target)]="expression" bindon-target="expression" 双向

内置指令

指令封装了可以应用于 HTML 元素或其他组件的编码行为:

名称 语法 目的
结构指令 *ngIf``*ngFor``*ngSwitch 控制 HTML 的结构布局以及元素是否被添加或从 DOM 中移除
属性指令 [class] [style] [(model)] 监听并修改其他 HTML 元素、属性、属性和组件的行为,例如 CSS 类、HTML 样式和 HTML 表单元素

常见管道

管道(在 AngularJS 中称为过滤器)修改了数据绑定值在 HTML 模板中的显示方式:

名称 目的 用法
异步 管理对可观察对象的订阅,并提供对模板中变量的同步访问 someVariable$ &#124; async as someVariable
日期 根据区域规则格式化日期 {{date_value &#124; date[:format]}}
文本转换 将文本转换为大写、小写或标题格式 {{value &#124; uppercase}}``{{value &#124; lowercase}}``{{value &#124; titlecase }}
小数 根据区域规则格式化数字 {{number &#124; number[:digitInfo]}}
百分比 根据区域规则格式化数字为百分比 {{number &#124; percent[:digitInfo]}}
货币 根据区域规则使用货币代码和符号格式化数字为货币 {{number &#124; currency [:currencyCode [:symbolDisplay [:digitInfo]]]}}

启动命令,主要组件和 CLI 框架

启动命令帮助生成新项目或添加依赖。Angular CLI 命令通过自动生成样板代码轻松创建主要组件。要查看完整命令列表,请访问 github.com/angular/angular-cli/wiki

启动命令

这里是最基本的命令,您可能会随着时间的推移记住并频繁使用。请记住,不要像在 第三章创建基本的 Angular 应用程序 中所述那样全局安装 @angular/cli

名称 用途 CLI 命令
新建 创建一个新的 Angular 应用程序,包含初始化的 Git 仓库、package.json,已配置路由并启用 Ivy。从父文件夹运行。 npx @angular/cli new project-name --routing
更新 更新 Angular、RxJS 和 Angular Material 依赖项。如有必要,重写代码以保持兼容性。 npx ng update
安装 Angular Material 安装并配置 Angular Material 依赖项。 npx ng add @angular/material

主要组件模板

在您的日常工作流程中使用以下命令添加新组件、服务和其他主要组件到您的 Angular 应用程序中。这些命令将为您节省大量时间并帮助您避免简单的配置错误:

名称 用途 CLI 命令
模块 创建一个新的 @NgModule 类。使用 -- routing 为子模块添加路由。可选地,使用 --module 将新模块导入父模块。 ng g module my-module ng g m my-module
组件 创建一个新的 @Component 类。使用 -- module 指定父模块。可选地,使用 --flat 跳过目录创建,-t 用于内联模板,-s 用于内联样式。 ng g component my-component ng g c my-component
指令 创建一个新的 @Directive 类。可选地,使用 --module 将指令作用域限定为指定的子模块。 ng g directive my-directive ng g d my-directive
管道 创建一个新的 @Pipe 类。可选地,使用 --module 将管道作用域限定为指定的子模块。 ng g pipe my-pipe ng g p my-pipe
服务 创建一个新的 @Injectable 类。使用 --module 为指定的子模块提供服务。服务不会自动导入到模块中。可选地使用 --flat false 在目录下创建服务。 ng g service my-service ng g s my-service
守卫 创建一个新的 @Injectable 类,该类实现了路由生命周期钩子 CanActivate。使用 --module 为指定的子模块提供守卫。守卫不会自动导入到模块中。 ng g guard my-guard ng g g my-guard

为了正确地在一个自定义模块(如my-module)下搭建一些之前列出的组件,你可以在你打算生成的名称之前添加模块名称,例如,ng g c my-module/my-new-component。Angular CLI 将正确连接并将新组件放置在my-module文件夹下。

TypeScript 搭建

如果你不太熟悉 TypeScript 语法,这些 TypeScript 特定的搭建将帮助你创建类、接口和枚举,这样你就可以利用面向对象编程原则来减少代码重复,并在类中而不是在组件中实现代码行为,如计算属性:

名称 用途 CLI 命令
创建一个基本类 ng g class my-class
接口 创建一个基本接口 ng g interface my-interface
枚举 创建一个基本枚举 ng g enum my-enum

常见的 RxJS 函数/操作符

为了成为一名有效的 Angular 开发者,你需要成为 RxJS 的大师。以下是一些最常见和有用的 RxJS 操作符,供快速参考:

函数

名称 用途
pipe 接受一个或多个可观察对象作为输入,并生成一个可观察对象作为输出,允许你构建自定义数据流。
subscribe 必须激活一个可观察对象。从subscribe操作中提取可观察数据流的值是一种反模式。可以使用异步管道或tap函数来检查或使用当前值。
unsubscribe 释放资源并取消可观察对象的执行。不取消订阅可能导致性能问题和内存泄漏。使用异步管道或SubSink库来管理订阅。

操作符

名称 用途
of 将提供的值转换为可观察序列。对于将同步代码集成到可观察数据流中非常有用。
from 从数组、可迭代对象或承诺创建一个可观察对象。
map 允许你遍历可观察对象发出的每个值。
merge 创建一个输出可观察对象,同时并发地发出所有给定输入可观察对象的所有值。对于基于多个可观察对象触发操作非常有用。
combineLatest 将多个可观察对象中的值与每个可观察对象的最新值组合。当与merge操作符一起使用时非常有用。
filter 过滤数据流中的值。用于忽略 null 值或仅在满足某些条件时执行管道的其余部分。
concat 顺序地从一个或多个可观察对象中发出值。对于同步多个操作非常有用。类似concatMap的变体也可以扁平化可观察对象,这对于遍历集合的值非常有用。
take 给定一个计数,在消耗指定次数后自动完成可观察对象。
catchError 捕获可观察对象上的错误,通过返回一个新的可观察对象或抛出一个错误来处理。
scan 使用累加器函数,它可以增量地处理数据。也就是说,随着数字的增加,可以得到一个运行的小计。这对于需要更新的长时间运行的操作非常有用。

感谢 Jan-Niklas Wortmann 对本节的审阅。在 Twitter 上关注他:@niklas_wortmann

进一步阅读

posted @ 2025-09-05 09:26  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报