Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (2024)

原文:zh.annas-archive.org/md5/87CFF2637ACB075A16B30B5AA7A68992

译者:飞龙

协议:CC BY-NC-SA 4.0

欢迎!如果你想学习并精通 Angular 开发,你来对地方了。这本书旨在向你灌输敏捷和 DevOps 的思维,让你能够自信地创建可靠和灵活的解决方案。无论你是自由职业者为小型企业开发软件,全栈开发人员,企业开发人员还是网页开发人员,你需要了解如何设计、架构、开发、维护、交付和部署 Web 应用程序,以及需要应用的最佳实践和模式并没有太大的差异。如果你要向用户群交付应用程序,从某种意义上说,你是一个全栈开发人员,因为你必须了解许多服务器技术。事实上,如果你掌握了如何使用 TypeScript 交付 Angular 应用程序,那么使用 Node.js、Express.js 和 TypeScript 编写自己的 RESTful API 对你来说并不困难,但这超出了本书的范围。

根据某些定义,全栈开发人员需要了解从满足国际版权法到成功在当今的网络上创建和运营应用程序的一切。从某种意义上说,如果你是一名企业家,这是正确的。然而,在这本书中,你的烹饪技能和法律学位并不适用。这本书假设你已经知道如何使用你选择的技术栈编写 RESTful API,如果不知道,不要担心!你仍然可以受益并了解如何使用 RESTful API 工作。

这本书既适合初学者又适合有经验的开发人员,他们想学习 Angular 或者网页开发。如果你是 Angular 开发人员,你将接触到设计和部署 Angular 应用程序到生产环境的整个过程。你将学习易于理解并能够教给他人的 Angular 模式。如果你是自由职业者,你将掌握交付 Angular 应用程序的有效工具和技术,以安全、自信和可靠的方式。如果你是企业开发人员,你将学习编写具有可扩展架构的 Angular 应用程序的模式和实践。

  1. 你应该已经熟悉全栈 Web 开发

  2. 按照出版顺序跟随本书,在每一章的内容旁边编写你的解决方案。

您可以从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-6-for-Enterprise-Ready-Web-Applications

该书的代码包也托管在作者的 GitHub 存储库中,网址为github.com/duluca/local-weather-appgithub.com/duluca/lemon-mart

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

本书中使用了许多文本约定。

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'))

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这是一个例子:“启动开始菜单。”

警告或重要说明会出现在这样的样式中。提示和技巧会出现在这样的样式中。

让我们从质疑整本书的前提开始,即 Angular 本身。为什么学习 Angular,而不是 React、Vue 或其他框架?首先,我不会反对学习任何新工具的论点。我相信每个工具都有其存在的场所和目的。熟练掌握 React 或 Vue 只会进一步加深您对 Angular 的理解。自 2012 年以来,像 Backbone 或 Angular 这样的单页面应用SPA)框架就吸引了我的全部注意力,当时我意识到服务器端渲染的模板是不可能维护的,并且会导致软件系统的非常昂贵的重写。如果您打算创建可维护的软件,必须遵守的首要指令是将 API 和业务逻辑与用户界面UI)解耦。

问题是,为什么要精通 Angular?我发现 Angular 完美地符合帕累托原则。它已经成为一个成熟且不断发展的平台,使您能够用 20%的努力完成 80%的任务。此外,从版本 4 开始,在长期支持LTS)直到 2018 年 10 月,每个主要版本都受到 18 个月的支持,创造了一个持续学习、保持最新和淘汰旧功能的过程。从全栈开发人员的角度来看,这种连续性是非常宝贵的,因为您的技能和培训将在未来多年内保持有用和新鲜。

这第一章将帮助您和您的团队成员创建一致的开发环境。对于初学者来说,创建正确的开发环境可能很困难,这对于无挫折的开发体验至关重要。对于经验丰富的开发人员和团队来说,实现一致和最小的开发环境仍然是一个挑战。一旦实现,这样的开发环境有助于避免许多与 IT 相关的问题,包括持续维护、许可和升级成本。

安装 GitHub 桌面版、Node.js、Angular CLI 和 Docker 的说明将成为从初学者到经验丰富的团队的良好参考,以及自动化和确保开发环境的正确和一致配置的策略。

如果您已经设置了强大的开发环境,可以跳过本章;但是,请注意,本章中声明的一些环境假设可能会导致后续章节中的一些指令对您不起作用。如果遇到问题或需要帮助同事、学生或朋友设置他们的开发环境,请返回本章作为参考。

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

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

  • Windows 10 上的 Chocolatey

  • macOS X 上的 Homebrew

  • 使用脚本来自动化安装:

  • Windows 10 上的 Powershell

  • macOS X 上的 Bash

  • 实现一致且跨平台的开发环境

您应该熟悉这些内容:

  • JavaScript ES2015+

  • 前端开发基础知识

  • RESTful API

支持的操作系统如下:

  • Windows 10 Pro v1703+与 PowerShell v5.1+

  • macOS Sierra v10.12.6+与终端(Bash 或 Oh My Zsh)

  • 大多数建议的软件也适用于 Linux 系统,但您的体验可能会有所不同。

建议的跨平台软件如下:

  • Node 8.10+(除非非 LTS 版本)

  • npm 5.7.1+

  • GitHub Desktop 1.0.0+

  • Visual Studio Code v1.16.0+

  • Google Chrome 64+

通过图形用户界面GUI)安装软件是缓慢且难以自动化的。作为全栈开发人员,无论您是 Windows 用户还是 Mac 用户,您都必须依赖命令行界面CLI)包管理器来高效地安装和配置您将依赖的软件。请记住,任何可以表示为 CLI 命令的东西也可以被自动化。

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

  1. 启动开始菜单

  2. 开始在PowerShell中输入

  3. 您应该看到 Windows PowerShell 桌面应用程序作为搜索结果

  4. 右键单击 Windows PowerShell 并选择以管理员身份运行

  5. 这将触发用户账户控制(UAC)警告;选择“是”继续

  6. 在 PowerShell 中执行以下命令来安装 Chocolatey 包管理器:

PS> Set-ExecutionPolicy AllSigned; iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))
  1. 通过执行choco来验证您的 Chocolatey 安装

  2. 您应该看到类似的输出,如下面的屏幕截图所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (1)成功安装 Chocolatey 所有后续的 Chocolatey 命令也必须从提升的命令行中执行。或者,也可以在不需要提升的命令行中安装 Chocolatey。但是,这将导致非标准和不太安全的开发环境,并且通过该工具安装的某些应用程序可能仍然需要提升。

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

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

  1. 使用⌘ + Space 启动 Spotlight 搜索

  2. 在“终端”中输入

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

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

  2. 您应该看到类似的输出,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (2)成功安装 Homebrew

  1. 要启用对其他软件的访问,请执行以下命令:
$ brew tap caskroom/cask

有关更多信息,请访问:brew.sh/

本节旨在建立一个最佳实践的 Git 配置,适用于尽可能广泛的受众。为了充分利用本节和本书后续章节,假定读者已满足以下先决条件:

  • 对源代码管理和 Git 的理解

  • GitHub.com上创建一个免费帐户

如果您是 Git 用户,很可能您也使用在线存储库,如 GitHub、Bitbucket 或 GitLab。每个存储库都有一个免费的开源项目层,配有功能各异的强大网站,包括您可以付费使用的本地企业选项。GitHub 在 2016 年托管了 3800 多万个存储库,是目前最受欢迎的在线存储库。GitHub 被广泛认为是一个基本的实用工具,永远不会被社区下线。

随着时间的推移,GitHub 添加了许多丰富的功能,使其从一个简单的存储库变成了一个在线平台。在本书中,我将引用 GitHub 的功能和功能,以便您可以利用其能力来改变您开发、维护和发布软件的方式。

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

请参见来自 StackOverflow 的以下建议的截图(stackoverflow.com/questions/1125968/force-git-to-overwrite-local-files-on-pull):

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (3)

如果你执行上述命令,请准备好失去未提交的本地更改。不幸的是,新手用户倾向于遵循最简单和最直接的指令,可能导致丢失工作。如果你认为你过去的提交是安全的,再想想!当涉及到 Git 时,如果你能想象到,通过 CLI 都可以做到。

幸运的是,通过 GitHub,你可以保护分支并实施 GitHub 工作流程,其中包括分支、提交、合并、更新和提交拉取请求。这些保护和工作流程有助于防止有害的 Git 命令造成不可逆转的更改,并实现一定程度的质量控制,使你的团队保持高效。通过 CLI 执行所有这些操作,特别是在存在合并冲突时,可能会变得复杂和繁琐。

要更深入地了解 Git 和 GitHub 的优势和缺陷,你可以阅读我 2016 年关于这个主题的文章:Bit.ly/InDepthGitHub

GitHub 桌面提供了一个易于使用的 GUI 来执行 GitHub 工作流程,这种方式在 Windows 和 macOS 上是一致的。当新人或初级团队成员加入时,或者如果你不经常贡献代码,一致性是非常有价值的。

  1. 执行安装命令:

对于 Windows:

**PS> choco install git github-desktop -y** 

对于 macOS:

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

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

  1. 通过启动应用程序来验证你的 GitHub 桌面安装

  2. 在 GitHub 桌面上登录github.com/

  3. 创建了存储库后,您可以通过在终端中执行以下命令来启动应用程序:

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

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

本节旨在建立最佳实践的 JavaScript 开发环境。为了充分利用本书,假定您已满足以下先决条件:

Node.js 是可以在任何地方运行的 JavaScript。它是一个开源项目,旨在在服务器上运行 JavaScript,构建在谷歌 Chrome 的 V8 JavaScript 引擎上。2015 年底,Node.js 稳定下来,并宣布了企业友好的 18 个月 LTS 周期,为平台带来了可预测性和稳定性,配合更频繁更新但更实验性的 Latest 分支。Node 还附带了 npm,Node 包管理器,截至 2018 年,npm 是世界上最大的 JavaScript 包存储库。

要更详细地了解 Node 的历史,请阅读我在 Node 上的两部分文章:Bit.ly/NodeJSHistory

您可能听说过 yarn 以及它比 npm 更快或更好。截至 npm 5,它与 Node 8 捆绑在一起,npm 更加功能丰富,更易于使用,并且在性能方面与 yarn 不相上下。Yarn 由 Facebook 发布,该公司还创建了 React JavaScript UI 库。必须指出的是 yarn 依赖于 npm 存储库,因此无论您使用哪种工具,都可以访问相同的包库。

如果您之前安装过 Node.js,在使用 choco 或 brew 安装新版本 Node 时,请确保仔细阅读命令输出。您的软件包管理器可能会返回警告或额外的指令,因此您可以成功完成安装。

您的系统或文件夹权限很可能在过去被手动编辑过,这可能会影响 Node 的无障碍操作。如果以下命令无法解决您的问题,请作为最后的手段使用 Node 网站上的 GUI 安装程序。

无论如何,您必须小心卸载之前使用npm -g安装的所有全局工具。随着每个主要的 Node 版本,您的工具与 Node 之间的本地绑定可能会失效。此外,全局工具很快就会过时,项目特定的工具也很快就会不同步。因此,全局安装工具现在是一种反模式,已被更好的技术所取代,这些技术在下一节和第二章的 Angular CLI 部分中有介绍,创建一个本地天气 Web 应用

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

本书假定您正在使用 Node 8.4 或更高版本。Node 的奇数版本不适合长期使用。6.x.x、8.x.x、10.x.x 等是可以的,但是要尽量避免 7.x.x、9.x.x 等。

  1. 执行安装命令:

对于 Windows:

PS> choco install nodejs-lts -y

对于 macOS:

$ brew install node@8
  1. 验证 Node 的安装是否成功,执行node -v

  2. 验证 npm 的安装是否成功,执行npm -v

请注意,不要在 Windows 上使用npm install -g npm来升级 npm 版本,如第四章中所述,与 Angular 更新保持最新。强烈建议您使用npm-windows-upgrade npm 包。

npm 存储库包含许多有用且成熟的 CLI 命令,通常是跨平台的。以下是我经常依赖并选择全局安装以提高性能的命令:

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

  • rimraf:Unix 命令rm -rf,但在 Windows 上也可以使用。在删除node_modules文件夹时非常有用,特别是当 Windows 由于嵌套文件夹结构而无法执行此操作时。

  • npm-update:分析您的项目文件夹,并报告哪些包有更新版本,哪些没有,如果您愿意,可以更新所有这些包。

  • n:非常容易快速切换 Node 版本的工具,无需记住特定版本号。不幸的是,它只在 macOS/Linux 上运行。

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

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

Visual Studio CodeVS Code)是最好的代码编辑器/集成开发环境之一。它是免费的,而且跨平台。值得注意的是,VS Code 具有代码编辑器的极快性能,类似于 NotePad++或 Sublime Text,但具有昂贵的集成开发环境的功能集和便利性,例如 Visual Studio 或 WebStorm。对于 JavaScript 开发,这种速度是必不可少的,并且对于经常在不同项目之间频繁切换的开发人员来说,这是一项巨大的生活质量改善。VS Code 集成了终端、易于使用的扩展系统、透明的设置、出色的搜索和替换功能,以及在我看来存在的最好的 Node.js 调试器。

对于 Angular 开发,这本书将利用 VS Code。强烈建议您也使用 VS Code。

  1. 执行安装命令:

对于 Windows:

PS> choco install VisualStudioCode -y

对于 macOS:

$ brew cask install visual-studio-code

Visual Studio Code 的最佳功能之一是您还可以从 CLI 启动它。如果您想要编辑的文件夹中,只需执行code .或通过执行code ~/.bashrccode readme.md来执行特定文件。

  1. 通过启动 Visual Studio Code 来验证安装

  2. 转到一个文件夹并执行code .

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

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

在本章的开头,我宣称任何可以表示为 CLI 命令的东西也可以被自动化。在设置过程中,我们确保每个使用的工具都已设置并且通过 CLI 命令可验证其功能。这意味着我们可以轻松地创建一个 PowerShell 或 bash 脚本来串联这些命令,并简化设置和验证新环境的任务。事实上,我已经创建了这些脚本的一个基本实现,您可以从本书的 GitHub 存储库的第一章文件夹中下载:

  1. 导航至github.com/duluca/web-dev-environment-setup查找脚本

  2. 在 PowerShell 中执行install-windows-deps.ps1以安装和验证 Windows 上的依赖关系

  3. 在终端中执行install-mac-deps.sh以安装和验证 macOS 上的依赖关系

残酷的现实是,这些脚本并不代表一个非常有能力或弹性的解决方案。脚本无法远程执行或管理,并且它们无法轻松地从错误中恢复或在机器启动周期中生存。此外,您的 IT 需求可能超出了这里所涵盖的范围。

如果您处理大型团队和频繁的人员流动,自动化工具将大大地产生回报,而如果您是独自一人或是一个较小、稳定的团队的一部分,它将是极度过剩的。我鼓励您探索诸如 Puppet、Chef、Ansible 和 Vagrant 等工具,以帮助您决定哪一个最适合您的需求,或者一个简单的脚本是否足够好。

在这一章中,您掌握了基于 CLI 的软件包管理器在 Windows 和 macOS 上的使用,以加快和自动化开发环境的设置,为您和您的同事。通过减少开发人员环境之间的差异,您的团队可以更容易地克服任何个人配置问题,并更多地专注于手头的任务执行。通过对共同环境的集体理解,团队中没有一个人需要承担帮助排除其他人问题的负担。因此,您的团队将更加高效。通过利用更复杂和弹性的工具,中大型组织将能够在其 IT 预算中实现巨大的节省。

在下一章中,您将熟悉新的 Angular 平台,优化您的 Web 开发环境,利用 Waffle 和 GitHub 问题来使用看板,学习 Angular 基础知识以构建一个考虑全栈架构的简单 Web 应用,并介绍使用 RxJS 进行响应式编程。

Vishwas Parameshwarappa 的《自动化本地开发者机器设置》一文是使用 Vagrant 的绝佳起点。您可以在Red-gate.com/simple-talk/sysadmin/general/automating-setup-local-developer-machine找到这篇文章。

在本章中,我们将使用迭代开发方法设计和构建一个简单的本地天气应用程序,使用 Angular 和第三方 Web API。您将专注于首先提供价值,同时学习使用 Angular、TypeScript、Visual Studio Code、响应式编程和 RxJS 的微妙之处和最佳方式。在我们开始编码之前,我们将介绍 Angular 背后的哲学,并确保您的开发环境经过优化,可以实现协作和轻松的信息辐射。

本章的每个部分都将向您介绍新概念、最佳实践和利用这些技术的最佳方式,并涵盖关闭您可能对 Web 和现代 JavaScript 开发基础知识的任何知识空白的基础知识。

在本章中,您将学习 Angular 的基础知识,以构建一个简单的 Web 应用程序,并熟悉新的 Angular 平台和全栈架构。

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

  • 介绍 Angular 及其背后的哲学

  • 为全栈开发配置具有最佳文件夹结构的存储库

  • 使用 Angular CLI 生成您的 Angular Web 应用程序

  • 优化 Visual Code 以进行 Angular 和 TypeScript 开发

  • 使用 Waffle 作为与 GitHub 连接的看板板来规划您的路线图

  • 打造一个新的 UI 元素来显示当前天气信息,使用组件和接口

  • 使用 Angular 服务和 HttpClient 从 OpenWeatherMap API 检索数据

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

本书提供的代码示例需要使用 Angular 5 和 6 版本。Angular 5 的代码与 Angular 6 兼容。Angular 6 将在 LTS 中得到支持,直到 2019 年 10 月。代码存储库的最新版本可以在以下位置找到:

Angular 是由谷歌和一群开发者社区维护的开源项目。新的 Angular 平台与您过去可能使用过的遗留框架大不相同。与微软的合作使得 TypeScript 成为默认的开发语言,它是 JavaScript 的超集,使开发者能够针对旧版浏览器(如 Internet Explorer 11)编写现代 JavaScript 代码,同时在 Chrome、Firefox 和 Edge 等最新浏览器中得到支持。Angular 的遗留版本,即 1.x.x 范围内的版本,现在被称为 AngularJS。2.0.0 及更高版本简称为 Angular。AngularJS 是一个单页应用程序(SPA)框架,而 Angular 是一个能够针对浏览器、混合移动框架、桌面应用程序和服务器端渲染视图的平台。

在 AngularJS 中,每个次要版本增量都意味着风险更新,伴随着昂贵的废弃和不确定间隔的主要新功能。这导致了一个不可预测的、不断发展的框架,似乎没有指导手来推动代码库向前发展。如果你使用过 AngularJS,你可能会卡在一个特定的版本上,因为你的代码库的特定架构使得很难迁移到新版本。在 2018 年春/夏季,AngularJS 的最后一个主要更新将发布版本 1.7。这个发布将标志着这个遗留框架的终结,计划在 2021 年 7 月终止支持。

Angular 在各个方面都比 AngularJS 有所改进。该平台遵循语义版本控制,如semver.org/所定义,其中次要版本增量表示新功能添加和可能废弃通知的第二个下一个主要版本,但不会有破坏性的变化。此外,谷歌的 Angular 团队已经承诺了一个确定的发布计划,每 6 个月发布一次主要版本增量。从 Angular 4 开始,在这 6 个月的开发窗口之后,所有主要版本都将获得长期支持(LTS),为期 12 个月的错误修复和安全补丁。从发布到终止支持,每个主要版本都将获得 18 个月的支持。请参考以下图表,了解 AngularJS 和 Angular 的暂定发布和支持计划:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (4)暂定的 Angular 发布和支持计划

那么,这对你意味着什么呢?你可以放心,你在 Angular 中编写的代码将在大约 24 个月的时间范围内得到支持,并且向后兼容,即使你对其不做任何更改。因此,如果你在 2017 年 4 月编写了一个 Angular 4 版本的应用程序,你的代码现在可以在 Angular 5 中运行,而 Angular 5 本身将在 2019 年 4 月之前得到支持。要将你的 Angular 4 代码升级到 Angular 6,你需要确保你没有使用在 Angular 5 中宣布为废弃的任何 API。实际上,这些废弃的内容很少,除非你正在使用低级别的 API 来实现高度专业化的用户体验,否则更新代码库所需的时间和精力应该是最小的。然而,这是谷歌所做出的承诺,而不是一份合同。Angular 团队有很大的动力来确保向后兼容,因为谷歌在整个组织中运行着 600 多个 Angular 应用程序,每次只有一个版本的 Angular 处于活动状态。这意味着,当你阅读这篇文章时,所有这 600 多个应用程序都将在 Angular 6 中运行。你可能认为谷歌有无限的资源来实现这一点,但像任何其他组织一样,他们也有有限的资源,并非每个应用程序都有专门的团队进行积极维护。这意味着 Angular 团队必须通过自动化测试来确保兼容性,并尽可能地减少未来的主要版本更新所需的工作量。在 Angular 6 中,通过引入 ng update,更新过程变得更加简单。未来,团队将发布自动化的 CLI 工具,以使废弃功能的升级成为一个合理的努力。

这对开发人员和组织来说都是个好消息。现在,你不必永远停留在 Angular 的旧版本上,而是可以计划并分配必要的资源,将你的应用程序移向未来,而无需进行昂贵的重写。正如我在 2017 年的一篇博客文章中所写的那样,《Angular 4 的最佳新功能》,链接在 bit.ly/NgBestFeature,信息很明确:

对于开发人员和经理:Angular 会一直存在,所以你应该投入时间、注意力和金钱来学习它-即使你目前热爱其他框架。对于决策者(CIO,CTO 等):计划在接下来的 6 个月内开始过渡到 Angular。这将是一个可以向商业人士解释的投资,并且您的投资将在最初的 LTS 窗口到期后的多年内产生回报,具有优雅的升级路径到 Angular vNext 及更高版本。

那么,为什么谷歌(Angular)和微软(TypeScript,Visual Studio Code)免费提供这样的技术?有多种原因,其中一些包括展示技术证明以留住和吸引人才,通过与数百万开发人员一起验证和调试新的想法和工具,并最终使开发人员更容易地创建出色的网络体验,从而为谷歌和微软带来更多业务。我个人认为这里没有任何恶意意图,并且欢迎开放、成熟和高质量的工具,我可以随意摆弄并根据自己的意愿进行调整,如果必要的话,而不必为专有技术的支持合同付费。

注意,在网上寻找 Angular 帮助可能会有些棘手。您会注意到大多数时候,Angular 被称为 Angular 2 或 Angular 4。有时,Angular 和 AngularJS 都简称为 AngularJS。当然,这是不正确的。Angular 的文档在angular.io。如果您登陆angularjs.org,您将看到有关传统 AngularJS 框架的信息。有关即将发布的 Angular 版本的最新更新,请查看官方发布计划:Github.com/angular/angular/blob/master/docs/RELEASE_SCHEDULE.md

Angular 的哲学是在配置和约定之间犯错误。基于约定的框架,虽然从外部看起来可能很优雅,但对新手来说很难掌握框架。然而,基于配置的框架旨在通过显式配置元素和钩子公开其内部工作原理,您可以将自定义行为附加到框架上。实质上,Angular 试图不那么神奇,而 AngularJS 则有很多魔力。

这导致了大量冗长的编码。这是件好事。简洁的代码是可维护性的敌人,只有原始作者受益。然而,正如 Andy Hunt 和 David Thomas 在《实用程序员》中所说的,

请记住,你(以及之后的人)将会读取代码很多次,但只会写入几次。

冗长、解耦、内聚和封装的代码是未来保护你的代码的关键。Angular 通过其各种机制,实现了这些概念的正确执行。它摒弃了在 AngularJS 中发明的许多自定义约定,比如ng-click,并引入了一个更直观的语言,建立在现有的 HTML 元素和属性之上。因此,ng-click变成了(click),扩展了 HTML 而不是替换它。

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

之前v6 时
CLI1.76.0
Angular5.2.106.0
Material5.2.46.0

Angular CLI 6.0 带来了重大的新功能,比如ng updateng add命令;ng update使得更新 Angular 版本、npm 依赖、RxJS 和 Angular Material 变得更加容易,包括一些确定性的代码重写能力,以应用对 API 或函数的名称更改。关于更新 Angular 版本的主题在第四章中有详细介绍,与 Angular 更新保持最新ng add为 Angular CLI 带来了原理图支持。通过原理图,您可以编写自定义代码,为 Angular 应用添加新的功能,添加任何依赖项、样板配置代码或脚手架。一个很好的例子是通过执行ng add @angular/material来将 Angular Material 添加到您的项目中。关于将 Angular Material 添加到您的项目中的主题在第五章中有详细介绍,使用 Angular Material 增强 Angular 应用。一个独立的 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 Component Development Kit (CDK), Angular Material, Animations, and i18n 来实现的。Angular Universal 允许服务器端辅助快速启动时间,并且 Angular Progressive Web App (PWA)支持利用本机平台功能,如缓存和离线,因此在随后的访问中,您的应用程序保持快速。RxJS 6 支持可摇树的pipe命令,更频繁地减少捆绑包大小,并修复了throttle的行为,我在第六章中警告您,Reactive Forms and Component Interaction,以及众多的错误修复和性能改进。TypeScript 2.7 带来了更好的支持,可以导入不同类型的 JavaScript 包,并在构建时捕获编码错误的更高级功能。

自定义元素支持是 Web 组件规范的一部分,非常重要。使用 Angular Elements,您可以编写一个 Angular 组件,并在任何其他使用任何Web 技术的 Web 应用程序中重用该组件,从本质上来说,声明您自己的自定义 HTML 元素。这些自定义元素将与任何基于 HTML 的工具链兼容,包括其他 Web 应用程序库或框架。为了使其工作,整个 Angular 框架需要与您的新自定义元素一起打包。这在 Angular 6 中是不可行的,因为这意味着每次创建新用户控件都至少需要增加 65 KB。此外,在 2018 年初,只有 Chrome 支持自定义元素,而无需添加 polyfills 以使这些自定义元素工作。由于其实验性质,我在本书中不涉及自定义元素。Angular 的未来更新,可能在 2018 年底或 2019 年初,应该会引入 Ivy 渲染引擎,使基本捆绑包大小最小为 2.7 KB,从而实现闪电般快速的加载时间,并使得可以发布基于 Angular 的自定义元素。在这个时间范围内,构建这样的组件的工具和自定义元素的本地浏览器支持也将得到改进,包括 Firefox 和 Safari 的支持,使得 Microsoft Edge 成为最后一个实现该标准的浏览器。

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

尽管Angular.io已更新以演示自定义元素的可行性,但该文档网站每月吸引了 100 多万独立访问者,因此应该有助于解决一些难题,使其更加成熟。自定义元素是托管交互式代码示例的绝佳用例,可以与静态内容一起使用。在 2018 年初,Angular.io开始使用StackBlitz.io进行交互式代码示例。这是一个令人惊叹的网站,本质上是一个云中的 Visual Studio Code IDE,您可以在其中尝试不同的想法或运行 GitHub 存储库,而无需本地拉取或执行任何代码。

Angular 生态系统也欢迎 NgRx 库,它基于 RxJS 为 Angular 带来了类似 Redux 的状态管理。这种状态管理对于在 PWA 和移动环境中构建离线优先应用是必要的。然而,在 iOS 的 Safari 浏览器中,PWA 的支持并不好,并且在新的 IE6 浏览器决定加入之前,PWA 不会得到广泛的应用。此外,NgRx 是对已经令人困惑和复杂的工具如 RxJS 的抽象。鉴于我对最小化工具的积极态度,以及对 RxJS 在利基受众之外缺乏明确必要性,我不会涉及这个工具。RxJS 足够强大和有能力解锁复杂和可扩展的模式,帮助您构建出色的 Angular 应用,正如在第十章中所展示的,Angular 应用设计和配方

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

还发布了一些令人兴奋的新的辅助工具,可以实现高频率、高性能或大型企业用例。由前 Angular 团队成员开发的 Nx CLI 工具为 Angular 带来了一个有见地的开发环境设置,适用于顾问和必须确保一致环境的大型组织。这本书遵循类似的模式,旨在教育您建立一致的架构和设计模式,以应用于您的应用程序。Google 的 Bazel 构建工具实现了增量构建,因此未更改的应用程序部分无需重新构建,大大提高了大型项目的构建时间,并允许在 Angular 应用程序之间共享库的打包。

我希望您和我一样对 Angular 6 和它所解锁的未来可能性感到兴奋。现在,让我们把这一切放在一边,深入研究通过构建一个简单的 Angular 应用程序来完成事情。

在本章中,我们将为您的 Angular 项目设计、架构、创建一个待办事项,并建立文件夹结构,以便与 REST API 进行通信。这个应用程序将被设计来演示以下用途:

  • 角 CLI 工具(ng)

  • 角组件的 UI 重用

  • 角 HTTP 客户端

  • 角路由器

  • 角反应形式

  • 材料自动完成

  • 材料工具栏

  • 材料 Sidenav

无论您使用的是什么后端技术,我建议您的前端始终驻留在自己的存储库中,并且使用自己的 Web 服务器进行提供,而不依赖于您的 API 服务器。

首先,您需要一个愿景和一个路线图来行动。

有一些很棒的工具可以制作粗略的模型,以展示您的想法,并具有令人惊讶的丰富功能。如果您有专门的 UX 设计师,这些工具非常适合创建准原型。然而,作为全栈开发人员,我发现最好的工具是纸和笔。这样,您就不必学习另一个工具(YAL),而且没有设计要比有设计好得多。把东西写在纸上会让您避免在后续过程中进行昂贵的编码绕路,如果您能提前验证用户的线框设计,那就更好了。我将我的应用称为 LocalCast Weather,但请发挥创意,选择您自己的名称。以下是您天气应用的线框设计:

LocalCast 的线框。故意手绘。

线框不应该是什么花哨的东西。我建议从手绘设计开始,这样做非常快速,并且可以有效地传递粗略的轮廓。有很多很棒的线框工具,我将在本书中建议并使用其中的一些,但是在项目的最初几天,每个小时都很重要。可以肯定,这种粗糙的设计可能永远不会离开您团队的范围,但请知道,没有什么比将您的想法写在纸上或白板上更能获得即时的反馈和协作。

无论您的项目大小如何,坦率地说,大多数时候您都无法提前准确预测,从一个健壮的架构开始至关重要,如果需要,它可以扩展,但不会增加执行一个简单应用想法的工作量。关键是确保从一开始就进行适当的解耦。在我看来,有两种解耦方式,一种是软解耦,基本上是达成“绅士协议”,不混合关注点,尽量不搞乱代码库。这可以适用于您编写的代码,一直到基础设施级别的交互。如果您将前端代码保持在与后端代码相同的代码结构下,并且让您的 REST 服务器提供前端应用程序,那么您只是在练习软解耦。

相反,你应该练习硬解耦,这意味着前端代码存放在一个单独的存储库中,从不直接调用数据库,并且完全托管在自己的网络服务器上。这样,你可以确保在任何时候,你的 REST API 或前端代码是完全可以独立替换的。练习硬解耦也有经济和安全方面的好处。前端应用的服务和扩展需求肯定与后端不同,因此您将能够适当优化您的主机环境并节省金钱。如果您将对 REST API 的访问白名单限制为仅允许来自前端服务器的调用,您将大大提高安全性。请考虑下面我们 LocalCast Weather 应用的高级架构图:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (5)LocalCast 高级架构

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

我建议不要将前端和后端代码放在同一个代码存储库中。在同一个存储库中使用会导致奇怪的依赖关系,当你需要启用持续集成或将代码部署到生产环境时。为了获得集成的开发体验,并能够快速在存储库之间切换,您可以使用 IDE 功能,比如 VS Code Workspace,一次打开多个存储库在同一树状结构下。

如果必须使用单个存储库,为后端代码和前端代码创建单独的文件夹,分别命名为serverweb-app。这样做的好处至少是很大的,因为团队成员可以在不互相干扰的情况下开始在前端或后端上工作。

按照接下来的两个部分的说明正确设置您的应用程序。如果您已经有一个强大的开发目录设置,并且您是一个 Git 专家,那么跳过到生成您的 Angular 应用程序部分。

设置一个专门的dev目录是一个救命稻草。因为这个目录下的所有数据都将使用 GitHub 进行备份,您可以安全地配置您的防病毒软件、云同步或备份软件来忽略它。这将大大减少 CPU、磁盘和网络的利用率。作为一个全栈开发人员,您很可能会经常进行多任务处理,因此避免不必要的活动将对性能、功耗和数据消耗产生净正面影响,尤其是如果您的开发环境是一台资源匮乏的笔记本电脑,或者当您在移动时希望尽可能延长电池续航时间。

直接在c:\驱动器下创建一个dev文件夹非常重要,因为 Windows,或者说 NTFS,无法处理超过 260 个字符的文件路径。这一开始可能看起来足够,但当您在已经深层次的文件夹结构中安装 npm 包时,node_modules文件夹结构很容易达到这个限制。使用 npm 3+,引入了一种新的、更扁平的包安装策略,这有助于解决 npm 相关的问题,但尽可能靠近root文件夹将对任何工具都有很大帮助。在 2016 年末,有报道称微软可能会引入一个“启用 NTFS 长路径”的组策略来解决这个问题,但截至 2017 年底,这在 Windows 10 上还没有实现。

  1. 使用以下命令创建您的dev文件夹:

对于 Windows:

PS> mkdir c:\devPS> cd c:\dev

在基于 Unix 的操作系统中,~(读作波浪线)是当前用户home目录的快捷方式,位于/Users/your-user-name下。

对于 macOS:

$ mkdir ~/dev$ cd ~/dev

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

Angular CLI(Angular 命令行界面)是一个官方的 Angular 项目,以确保新创建的 Angular 应用程序具有统一的架构,遵循社区多年来完善的最佳实践。这意味着您今后遇到的任何 Angular 应用程序都应该具有相同的一般形状。Angular CLI 不仅限于初始代码生成。您将经常使用它来创建新的组件、指令、管道、服务、模块等。Angular CLI 还将在开发过程中帮助您进行实时重新加载,以便您可以快速查看更改的结果。Angular CLI 还可以测试、检查代码,并构建优化版本的代码以进行生产发布。此外,随着新版本的 Angular 发布,Angular CLI 将帮助您升级您的代码,自动重写部分代码,以使其与潜在的破坏性更改保持兼容。

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

下一节详细介绍的策略将使您的 Angular 项目的初始配置比必要的复杂一些;然而,如果您在几个月或一年后返回项目,您将能够使用您在该项目上最后使用的工具版本,而不是可能需要进行升级的未来版本。在下一节中,您将应用这一最佳实践来初始化您的 Angular 应用程序。

现在,我们将使用npx初始化应用程序进行开发,当您安装最新版本的 Node LTS 时,它已经安装在您的系统上:

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

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

... create local-weather-app/src/tsconfig.app.json (211 bytes) create local-weather-app/src/tsconfig.spec.json (283 bytes) create local-weather-app/src/typings.d.ts (104 bytes) create local-weather-app/src/app/app.module.ts (316 bytes) create local-weather-app/src/app/app.component.html (1141 bytes) create local-weather-app/src/app/app.component.spec.ts (986 bytes) create local-weather-app/src/app/app.component.ts (207 bytes) create local-weather-app/src/app/app.component.css (0 bytes)added 1273 packages from 1238 contributors in 60.594sProject 'local-weather-app' successfully created.

您的项目文件夹local-weather-app已经初始化为 Git 存储库,并使用了初始的文件和文件夹结构,应该看起来像这样:

local-weather-app├── angular.json├── .editorconfig├── .gitignore├── .gitkeep├── e2e├── karma.conf.js├── node_modules├── package-lock.json├── package.json├── protractor.conf.js├── README.md├── src├── 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 桌面允许您直接在应用程序中创建新存储库:

  1. 打开 GitHub 桌面

  2. 文件 | 添加本地存储库...

  3. 通过单击 Choose...来定位local-weather-app文件夹

  4. 单击添加存储库

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

  6. 最后,点击发布存储库,如图所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (6)GitHub 桌面

Package.json是您应该随时密切关注的最重要的配置文件。您的项目脚本、运行时和开发依赖项都存储在这个文件中。

  1. 打开package.json并找到nameversion属性:
package.json{ "name": "local-weather-app", "version": "0.0.0", "license": "MIT", **...**
  1. 将您的应用程序重命名为您希望的任何名称;我将使用localcast-weather

  2. 将您的版本号设置为1.0.0

npm使用语义化版本(semver),其中版本号数字表示主要.次要.补丁增量。Semver 从1.0.0开始为任何发布的 API 设置版本号,尽管它不会阻止 0.x.x 版本。作为 Web 应用程序的作者,您的应用程序的版本对您没有真正影响,除了内部工具、团队或公司沟通目的。但是,您的依赖项的版本对您的应用程序的可靠性非常关键。总之,补丁版本应该只是错误修复。次要版本增加功能而不会破坏现有功能,主要版本增量可以进行不兼容的 API 更改。然而,在现实中,任何更新都会对应用程序的测试行为构成风险。这就是为什么package-lock.json文件存储了应用程序的整个依赖树,以便其他开发人员或持续集成服务器可以复制应用程序的确切状态。欲了解更多信息,请访问: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

  1. 更新您的package.json文件,以便从一个不常用的端口(如5000)运行您的应用的开发版本作为新的默认行为:
package.json ... "start": "ng serve --port 5000", ...

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

package.json ... "dependencies": { "@angular/animations": "⁶.0.0", "@angular/common": "⁶.0.0", "@angular/compiler": "⁶.0.0", "@angular/core": "⁶.0.0", "@angular/forms": "⁶.0.0", "@angular/http": "⁶.0.0", "@angular/platform-browser": "⁶.0.0", "@angular/platform-browser-dynamic": "⁶.0.0", "@angular/router": "⁶.0.0", "core-js": "².5.4", "rxjs": "⁶.0.0", "zone.js": "⁰.8.26" }, ...

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

您的开发依赖项存储在devDependencies属性下。在向项目安装新工具时,您必须小心地在命令后面添加--save-dev,以便正确分类您的依赖关系。开发依赖项仅在开发过程中使用,不会发送到客户端浏览器。您应该熟悉每一个这些软件包及其具体目的。如果您对我们继续显示的软件包不熟悉,了解更多关于它们的最佳资源是www.npmjs.com/

package.json ... "devDependencies": { "@angular/compiler-cli": "⁶.0.0", "@angular-devkit/build-angular": "~0.6.1", "typescript": "~2.7.2", "@angular/cli": "~6.0.1", "@angular/language-service": "⁶.0.0", "@types/jasmine": "~2.8.6", "@types/jasminewd2": "~2.0.3", "@types/node": "~8.9.4", "codelyzer": "~4.2.1", "jasmine-core": "~2.99.1", "jasmine-spec-reporter": "~4.2.1", "karma": "~1.7.1", "karma-chrome-launcher": "~2.2.0", "karma-coverage-istanbul-reporter": "~1.4.2", 
 "karma-jasmine": "~1.1.1", "karma-jasmine-html-reporter": "⁰.2.2", "protractor": "~5.3.0", "ts-node": "~5.0.1", "tslint": "~5.9.1" } ...

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

  • 波浪号~在定义版本号的所有三个数字时启用波浪范围,允许自动应用补丁版本升级。

  • 上插字符^使插入范围生效,允许自动应用次要版本升级

  • 缺少任何字符会提示 npm 在您的计算机上安装该库的确切版本

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

  1. local-weather-app目录下,执行npm install dev-norms --save-dev --save-exact。请注意,"dev-norms": "1.3.6"或类似的内容已添加到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 所做的更改。

为了提交您的更改到 Git,然后将您的提交同步到 GitHub,您可以使用 VS Code。

  1. 切换到源代码控制窗格,在此处标记为 1:

Visual Studio Code 源代码控制窗格

  1. 在 2 中输入提交消息

  2. 单击 3 中的复选标记图标提交您的更改

  3. 最后,通过单击 4 中的刷新图标将您的更改与 GitHub 存储库同步。

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

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

  1. 执行npm start

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

  3. 您应该看到一个类似于此的呈现页面:

默认的 Angular CLI 登陆页面

  1. 通过在集成终端中按下Ctrl + C来停止应用程序。

一直保存文件可能会变得乏味。您可以通过以下方式启用自动保存:

  1. 打开 VS Code

  2. 切换到“文件”|“自动保存”下的设置。

您可以通过启动“首选项”来进一步自定义 VS Code 行为的许多方面。在 Windows 上启动首选项的键盘快捷键是Ctrl + ,在 macOS 上是⌘ +

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

以下是我用于实现最佳、节省电池寿命的 Angular 开发体验的自定义设置:

.vscode/settings.json{ "editor.tabSize": 2, "editor.rulers": [90, 140], "files.trimTrailingWhitespace": true, "files.autoSave": "onFocusChange", "editor.cursorBlinking": "solid", "workbench.iconTheme": "material-icon-theme", // Following setting requires Material Icon Theme Extension "git.enableSmartCommit": true, "editor.autoIndent": true, "debug.openExplorerOnEnd": true, "auto-close-tag.SublimeText3Mode": true, // Following setting requires Auto Close Tag Extension "explorer.openEditors.visible": 0, "editor.minimap.enabled": false, "html.autoClosingTags": false, "git.confirmSync": false, "editor.formatOnType": true, "editor.formatOnPaste": true, "editor.formatOnSave": true, "prettier.printWidth": 90, // Following setting requires Prettier Extension "prettier.semi": false, "prettier.singleQuote": true, "prettier.trailingComma": "es5", "typescriptHero.imports.insertSemicolons": false, // Following setting requires TypeScriptHero Extension "typescriptHero.imports.multiLineWrapThreshold": 90,}

此外,您还可以在 VS Code 中启用以下设置,以获得更丰富的开发体验:

"editor.codeActionsOnSave": { "source.organizeImports": true}, "npm.enableScriptExplorer": true

对于使用 VS Code 和 Angular 进行神奇开发体验,您应该安装由 John Papa 创建和策划的 Angular Essentials 扩展包。John Papa 是 Angular 社区中的领军者和思想领袖之一。他不断不懈地寻求最佳的开发体验,以便您作为开发人员更加高效和快乐。他是一个值得信赖并且非常认真对待的资源。我强烈建议您在 Twitter 上关注他@john_papa

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

.vscode/extensions.json{ "recommendations": [ "johnpapa.angular-essentials", "PKief.material-icon-theme", "formulahendry.auto-close-tag", "PeterJausovec.vscode-docker", "eamodio.gitlens", "WallabyJs.quokka-vscode", "rbbit.typescript-hero",
 "DSKWRK.vscode-generate-getter-setter", "esbenp.prettier-vscode" ]}

VS Code 还会建议您安装一些扩展。我建议不要安装太多扩展,因为这些扩展会明显地减慢 VS Code 的启动性能和最佳运行。

您可以在 VS Code 和 Angular CLI 中自定义编码风格执行和代码生成行为。在 JavaScript 方面,我更喜欢 StandardJS 设置,它规范了一种编写代码的最简化方法,同时保持了良好的可读性。这意味着使用 2 个空格作为制表符,而不使用分号。除了减少按键次数外,StandardJS 在水平方面也占用更少的空间,这在您的 IDE 只能利用屏幕的一半,另一半被浏览器占用时尤其有价值。您可以在以下网址了解更多关于 StandardJS 的信息:standardjs.com/

使用默认设置,您的代码将如下所示:

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

使用 StandardJS 设置,您的代码将如下所示:

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

最终,这对您来说是一个可选的步骤。但是,我的代码示例将遵循 StandardJS 风格。您可以通过以下步骤开始进行配置更改:

  1. 安装 Prettier - Code formatter 扩展

  2. 使用新的扩展更新.vscode/extensions.json文件

  3. 执行npm i -D prettier

可以使用i代替更冗长的--save-dev选项进行install,并使用-D代替。但是,如果你将-D误输入为-d,你最终会将该包保存为生产依赖项。

  1. 编辑package.json添加一个新的脚本,更新现有的脚本,并创建新的格式规则:
**package.json** ... "scripts": { ... "standardize": "prettier **/*.ts --write", "start": "npm run standardize && ng serve --port 5000", "build": "npm run standardize && ng build", ... }, ... "prettier": { "printWidth": 90, "semi": false, "singleQuote": true, "trailingComma": "es5", "parser": "typescript" } ... 

macOS 和 Linux 用户必须修改standardize脚本,为了正确遍历目录,必须在**/*.ts周围添加单引号。在 macOS 和 Linux 中,正确的脚本看起来像这样"standardize": "prettier '**/*.ts' --write"

  1. 类似地,使用新的格式规则更新tslint.json
tslint.json ... "quotemark": [ true, "single" ], ... "semicolon": [ true, "never" ], ... "max-line-length": [ true, 120 ],...
  1. 执行npm run standardize来更新所有文件到新的样式

  2. 观察 GitHub Desktop 中的所有文件更改

  3. 今后,每当你执行npm startnpm run build时,新的standardize脚本将自动运行并保持文件的格式。

  4. 提交并推送你的更改到你的存储库

当你输入新代码或使用 Angular CLI 生成新组件时,你会遇到双引号或分号被下划线标记为问题。在大多数情况下,问题旁边会出现一个黄色的灯泡图标。如果你点击灯泡,你会看到一个修复动作:不必要的分号或类似的消息。你可以利用这些自动修复程序,或者按下Shift + Alt + F来运行整个文件的 Prettier 格式文档命令。在下面的截图中,你可以看到自动修复程序的运行情况,有黄色的灯泡和相应的上下文菜单:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (7)VS Code 自动修复程序

在开始编码之前制定一个大致的行动计划非常重要,这样你和你的同事或客户就会意识到你计划执行的路线图。无论是为自己还是为他人构建应用程序,功能的活动积压总是会在你休息后回到项目时作为一个很好的提醒,或者作为一个信息辐射器,防止不断的状态更新请求。

在敏捷开发中,您可能已经使用了各种票务系统或工具,比如表面或看板。我的最爱工具是 Waffle.io,因为它直接与您的 GitHub 存储库的问题集成,并通过标签跟踪问题的状态。这样,您可以继续使用您选择的工具与存储库进行交互,并轻松地传递信息。在下一节中,您将设置一个 Waffle 项目来实现这个目标。

现在我们将设置我们的 Waffle 项目:

  1. 转到 Waffle.io waffle.io/

  2. 点击登录或免费开始。

  3. 选择公共和私有存储库以允许访问所有存储库。

  4. 点击创建项目。

  5. 搜索本地天气应用存储库并选择它。

  6. 点击继续。

您将获得两个起始布局模板,如下图所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (8)Waffle.io 默认看板布局

对于这个简单的项目,您将选择基本。但是,高级布局演示了如何修改 Waffle 的默认设置,例如添加额外的列,比如 Review,以考虑参与过程的测试人员或产品所有者。您可以进一步自定义任何看板以适应您现有的流程。

  1. 选择基本布局,然后点击创建项目。

  2. 您将看到为您创建的新看板。

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (9)空的 Waffle 看板

默认情况下,Waffle 将作为看板。允许您将任务从一个状态移动到另一个状态。但是,默认视图将显示存储库中存在的所有问题。要将 Waffle 用作 Scrum 板,您需要将问题分配给 GitHub 里程碑,这将代表冲刺。然后,您可以使用过滤功能仅显示来自该里程碑的问题,或者换句话说,来自当前冲刺的问题。

在 Waffle 上,您可以通过点击Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (10)比例图标将故事点附加到问题上。列将自动显示总数和卡片顺序,代表优先级,并且将从会话到会话保留。此外,您可以切换到指标视图以获取里程碑燃尽和吞吐量图表和统计信息。

我们现在将创建一个问题的积压,您将使用它来跟踪您实施应用程序设计的进展。在创建问题时,您应该专注于提供对用户有价值的功能迭代。您必须克服的技术障碍对您的用户或客户没有兴趣。

以下是我们计划在第一个发布版本中构建的功能:

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

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

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

  • 添加首选项窗格以存储用户的默认城市

  • 使用 Angular Material 改进应用程序的用户体验

继续在 Waffle 或 GitHub 上创建您的问题;无论您喜欢哪个都可以。在创建 Sprint 1 的范围时,我对功能有一些其他想法,所以我只是添加了这些问题,但我没有分配给任何人或者里程碑。我还继续为我打算处理的问题添加了故事点。以下是看板的样子,因为我要开始处理第一个故事:

看板的初始状态快照在waffle.io/duluca/local-weather-app。最终,Waffle 提供了一个易于使用的 GUI,以便非技术人员可以轻松地与 GitHub 问题进行交互。通过允许非技术人员参与 GitHub 上的开发过程,您可以解锁 GitHub 成为整个项目的唯一信息来源的好处。关于功能和问题的问题,答案和讨论都作为 GitHub 问题的一部分进行跟踪,而不是在电子邮件中丢失。您还可以在 GitHub 上存储维基类型的文档,因此通过在 GitHub 上集中所有与项目相关的信息,数据,对话和工件,您大大简化了可能需要持续维护的多个系统的复杂交互,成本高昂。对于私有存储库和本地企业安装,GitHub 的成本非常合理。如果您坚持使用开源,就像我们在本章中一样,所有这些工具都是免费的。作为奖励,我在我的存储库github.com/duluca/local-weather-app/wiki上创建了一个基本的维基页面。请注意,您无法将图像上传到README.md或维基页面。为了解决这个限制,您可以创建一个新问题,在评论中上传图像,并复制并粘贴其 URL 以将图像嵌入README.md或维基页面。在示例维基中,我遵循了这种技术将线框设计嵌入页面中。

有了具体的路线图,现在你可以开始实施你的应用程序了。

您将利用 Angular 组件,接口和服务以一种解耦的,内聚的和封装的方式构建当前天气功能。

Angular 应用程序的默认登陆页面位于app.component.html中。因此,首先通过编辑AppComponent的模板,使用基本的 HTML 来布置应用程序的初始登陆体验。

我们现在开始开发 Feature 1:显示当前位置的当天天气信息,所以你可以将卡片移动到 Waffle 的 In Progress 列。

我们将添加一个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>

此时,您应该运行npm start并在浏览器中导航到http://localhost:5000,以便您可以实时观察您所做的更改。

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

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

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (11)OpenWeatherMap 当前天气数据 API 文档

您将使用名为“按城市名称”的 API,该 API 允许您通过提供城市名称作为参数来获取当前天气数据。因此,您的网络请求将如下所示:

api.openweathermap.org/data/2.5/weather?q={city name},{country code}
  1. 在文档页面上,点击“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中,我们不会导出它,因为应用程序的其他部分不需要了解此类型。

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

  2. 新接口应该像这样:

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

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

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

创建单独组件的原因是架构最佳实践,这在Model-View-ViewModelMVVM)设计模式中得到了体现。你可能之前听说过Model-View-ControllerMVC)模式。大多数在 2005 年至 2015 年左右编写的基于 Web 的代码都是按照 MVC 模式编写的。MVVM 与 MVC 模式在重要方面有所不同。正如我在 2013 年的 DevPro 文章中所解释的:

[有效实现 MVVM]本质上强制执行关注点的正确分离。业务逻辑与展示逻辑清晰分离。因此,当一个视图被开发时,它就会保持开发状态,因为修复一个视图功能中的错误不会影响其他视图。另一方面,如果[你使用]视觉继承有效并[创建]可重用的用户控件,修复一个地方的错误可以解决整个应用程序中的问题。

Angular 提供了 MVVM 的有效实现。

ViewModels 清晰地封装任何展示逻辑,并通过作为模型的专业版本来简化 View 代码。View 和 ViewModel 之间的关系很直接,可以更自然地将 UI 行为包装在可重用的用户控件中。

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

接下来,你将创建你的第一个 Angular 组件,其中将包括 View 和 ViewModel,使用 Angular CLI 的ng generate命令:

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

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

  1. 观察在你的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,从服务中提取数据并执行任何必要的转换,以公开视图的合理绑定,如下所示:

src/app/current-weather/current-weather.component.tsimport { 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() {}}

如果您计划编写的组件很简单,可以使用内联样式和内联模板重写它,以简化代码结构。

  1. 使用内联模板和样式更新CurrentWeatherComponent
src/app/current-weather/current-weather.component.ts 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() {}}

当您执行生成命令时,除了创建组件,该命令还将您创建的新模块添加到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模板上显示我们的新组件,以便最终用户可以看到:

  1. 通过用<app-current-weather></app-current-weather>替换<div>current weather</div>,将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>
  1. 如果一切正常,您应该会看到这个:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (12)您的本地天气应用程序的初始渲染请注意浏览器窗口标签中的图标和名称。作为 Web 开发的规范,在index.html文件中,使用应用程序的名称和图标更新<title>标签和favicon.ico文件,以自定义浏览器标签信息。如果您的 favicon 没有更新,请在href属性后附加一个唯一的版本号,例如href="favicon.ico?v=2"。结果,您的应用程序将开始看起来像一个真正的 Web 应用程序,而不是一个由 CLI 生成的起始项目。

现在你的ViewViewModel已经就位,你需要定义你的Model。如果你回顾设计,你会发现组件需要显示:

  • 城市

  • 国家

  • 当前日期

  • 当前图片

  • 当前温度

  • 当前天气描述

你将首先创建一个代表这个数据结构的接口:

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

  2. 观察一个新生成的名为icurrent-weather.ts的文件,其中包含一个空的接口定义,看起来像这样:

src/app/icurrent-weather.tsexport interface ICurrentWeather { }

这不是一个理想的设置,因为我们可能会向我们的应用程序添加许多接口,追踪各种接口可能会变得乏味。随着时间的推移,当你将这些接口的具体实现作为类添加时,将把类和它们的接口放在自己的文件中是有意义的。

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

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

  2. 将接口名称的大写改正为ICurrentWeather

  3. 同时,按照以下方式实现接口:

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

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

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

  1. 导入ICurrentWeather

  2. 切换回templateUrlstyleUrls

  3. 定义一个名为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,你可以使用自动修复程序自动插入导入语句。

在构造函数中,你将临时用虚拟数据填充当前属性以测试你的绑定。

  1. 将虚拟数据实现为一个 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并将鼠标悬停在接口名称上来快速查看它们,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (13)Ctrl + 悬停在接口上

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

  1. 实现模板:
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'}}℉</span> </div> <div> {{current.description}} </div></div>

要更改current.date的显示格式,我们使用了上面的DatePipe,传入'fullDate'作为格式选项。在 Angular 中,各种内置和自定义管道|操作符可用于改变数据的外观,而不实际改变基础数据。这是一个非常强大、方便和灵活的系统,可以在不编写重复的样板代码的情况下共享用户界面逻辑。在上面的例子中,如果我们想以更紧凑的形式表示当前日期,我们可以传入'shortDate'。有关各种DatePipe选项的更多信息,请参阅angular.io/api/common/DatePipe上的文档。要格式化current.temperature,以便不显示小数值,可以使用DecimalPipe。文档在angular.io/api/common/DecimalPipe

请注意,你可以使用它们各自的 HTML 代码来渲染℃和℉:Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (14)代表℃,Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (15)代表℉。

  1. 如果一切正常,你的应用程序应该看起来类似于这个截图:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (16)绑定虚拟数据后的应用程序

恭喜,你已成功连接了你的第一个组件。

现在你需要将你的CurrentWeather组件连接到OpenWeatherMap的 API。在接下来的章节中,我们将介绍以下步骤来实现这个目标:

  1. 创建一个新的 Angular 服务

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

  3. 发现OpenWeatherMap API

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

  5. 编写一个get请求

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

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

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

任何触及组件边界之外的代码都应该存在于一个服务中;这包括组件间的通信,除非存在父子关系,以及任何缓存或从 cookie 或浏览器的 localStorage 中检索数据的代码。这是一个关键的架构模式,可以使您的应用在长期内易于维护。我在我的 DevPro MVVM 文章中对这个想法进行了扩展,网址为bit.ly/MVVMvsMVC

要创建一个 Angular 服务,执行以下操作:

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

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

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

生成的服务有两部分:

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

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

服务已经生成,但没有自动提供。要做到这一点,请按照以下步骤操作:

  1. 打开app.module.ts

  2. 在 providers 数组中输入WeatherService

  3. 使用自动修复程序为您导入类:

src/app/app.module.ts...import { WeatherService } from './weather/weather.service'...@NgModule({ ... providers: [WeatherService], ...

如果您安装了推荐的扩展程序 TypeScript Hero,导入语句将会自动添加。您不必使用自动修复程序来完成。今后,我将不再提到导入模块的需要。

为了进行 API 调用,您将在 Angular 中利用HttpClient模块。官方文档 (angular.io/guide/http) 简洁地解释了这个模块的好处:

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

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

  1. HttpClientModule添加到app.module.ts中,如下所示:
src/app/app.module.ts...import { HttpClientModule } from '@angular/common/http'...@NgModule({ ... imports: [ ... HttpClientModule, ...
  1. WeatherService中注入HttpClient,由HttpClientModule提供,如下所示:
src/app/weather/weather.service.tsimport { HttpClient } from '@angular/common/http'import { Injectable } from '@angular/core'@Injectable()export class WeatherService { constructor(private httpClient: HttpClient) {}}

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

很容易忽略,但是前几节中的示例 URL 包含一个必需的appid参数。您必须将此密钥存储在您的 Angular 应用程序中。您可以将其存储在天气服务中,但实际上,应用程序需要能够在从开发到测试、暂存和生产环境的不同资源集之间切换。Angular 提供了两个环境:一个是prod,另一个是默认的。

在继续之前,您需要注册一个免费的OpenWeatherMap帐户并检索您自己的appid。您可以阅读openweathermap.org/appid上的appid文档以获取更详细的信息。

  1. 复制您的appid,它将包含一长串字符和数字

  2. 将您的appid存储在environment.ts

  3. 为以后使用配置baseUrl

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

在代码中,我们使用驼峰命名法appId来保持我们的编码风格一致。由于 URL 参数不区分大小写,appIdappid都可以使用。

现在,我们可以在天气服务中实现 GET 调用:

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

  2. 导入environment对象

  3. 实现httpClient.get函数

  4. 返回 HTTP 调用的结果:

src/app/weather/weather.service.tsimport { 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。在反引号内,您可以有换行,并且还可以直接嵌入变量到字符串流中,方法是用${dollarbracket}语法将它们包裹起来。但是,当您在代码中引入换行时,它将被解释为字面换行—\n。为了在代码中断开字符串,您可以添加反斜杠\,但是接下来的代码行不能有缩进。更容易的方法是连接多个模板,就像前面的代码示例中所示的那样。请注意,在get函数中使用了 TypeScript 泛型,使用了尖括号语法,如<TypeName>。使用泛型是开发时的生活质量特性。通过向函数提供类型信息,该函数的输入和/或返回变量类型将在编写代码时显示并在开发和编译时进行验证。

为了能够在CurrentWeather组件中使用getCurrentWeather函数,您需要将服务注入到组件中:

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

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

src/app/current-weather/current-weather.component.tsconstructor(private weatherService: WeatherService) { }
  1. ngOnInit函数中调用getCurrentWeather函数:
src/app/current-weather/current-weather.component.tsngOnInit() { this.weatherService.getCurrentWeather('Bethesda', 'US') .subscribe((data) => this.current = data)}

公平警告,不要指望这段代码立即能够工作。您应该会看到一个错误,所以让我们在下一部分中了解发生了什么。

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>的对象。如官方文档所述,Observable 是 RxJS 的最基本构建块,表示事件发射器,它将以ICurrentWeatherData类型随时间发出接收到的任何数据。Observable对象本身是无害的,除非被监听,否则不会引发网络事件。您可以在reactivex.io/rxjs/class/es6/Observable.js~Observable.html上阅读更多关于 Observables 的信息。

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

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

注意,VS Code 和 CLI 有时会停止工作。如前所述,当您编写代码时,npm start 命令正在 VS Code 的集成终端中运行。Angular CLI 与 Angular Language Service 插件结合,不断监视代码更改并将您的 TypeScript 代码转译为 JavaScript,因此您可以在浏览器中实时查看您的更改。很棒的是,当您出现编码错误时,除了在 VS Code 中的红色下划线外,您还会在终端或甚至浏览器中看到一些红色文本,因为转译失败了。在大多数情况下,纠正错误后,红色下划线将消失,Angular CLI 将自动重新转译您的代码,一切都会正常工作。然而,在某些情况下,您会注意到 VS Code 无法在 IDE 中捕捉到输入更改,因此您将无法获得自动补全帮助,或者 CLI 工具会卡在显示“webpack: Failed to compile”的消息上。您有两种主要策略来从这种情况中恢复:

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

  2. 如果 #1 不起作用,请使用 Alt + F4(Windows)或 ⌘ + Q(macOS)退出 VS Code 并重新启动它

考虑到 Angular 和 VS Code 的每月发布周期,我相信随着时间的推移,工具只会变得更好。

RxJS 代表响应式扩展,这是一个模块化库,可以实现响应式编程,这本身是一种异步编程范式,并允许通过转换、过滤和控制函数来操作数据流。您可以将响应式编程视为事件驱动编程的演变。

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

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (17)事件驱动实现

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

发布-订阅(pub/sub)模式是一种不同类型的事件驱动编程。在这种情况下,我们可以编写多个处理程序来同时处理给定事件的结果。假设您的应用程序刚刚收到了一些更新的数据。发布者将遍历其订阅者列表,并将更新的数据传递给它们每一个。参考以下图表,更新的数据事件如何触发一个updateCache函数,该函数可以使用新数据更新本地缓存,一个fetchDetails函数,该函数可以从服务器检索有关数据的更多详细信息,以及一个showToastMessage函数,该函数可以通知用户应用程序刚刚收到了新数据。所有这些事件都可以异步发生;但是,fetchDetailsshowToastMessage函数将接收比它们实际需要的更多数据,并且尝试以不同方式组合这些事件以修改应用程序行为可能会变得非常复杂。

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (18)发布-订阅模式实现

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

在流的下游,我们可能只对包含两个或更多点击的事件感兴趣,因此我们可以使用filter函数仅对本质上是双击事件的事件进行操作。每当我们的过滤事件触发时,这意味着用户打算双击,您可以通过弹出警报来对此信息进行操作。流的真正力量来自于您可以选择在任何时候对事件进行操作,因为它通过各种控制、转换和过滤函数。您可以选择使用*ngFor和 Angular 的async管道在 HTML 列表上显示点击数据,以便用户可以每 250 毫秒监视被捕获的点击数据类型。

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (19)一个响应式数据流实现

为了避免将意外类型的数据从您的服务中返回,您需要更新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 管道,并在模板中应用它。管道也可以轻松地与计划中的切换按钮绑定。然而,此时,我们只需要以华氏度显示,我会倾向于过度设计解决方案。

  1. ICurrentWeather.date更新为number类型

在编写转换函数时,您会注意到 API 将日期返回为数字。这个数字代表自 UNIX 纪元(时间戳)以来的秒数,即 1970 年 1 月 1 日 00:00:00 UTC。然而,ICurrentWeather期望一个Date对象。通过将时间戳传递给Date对象的构造函数进行转换是很容易的,就像new Date(data.dt)。这样做没问题,但也是不必要的,因为 Angular 的DatePipe可以直接处理时间戳。为了追求简单和充分利用我们使用的框架的功能,我们将更新ICurrentWeather以使用number。如果您正在转换大量数据,这种方法还有性能和内存上的好处,但这个问题在这里并不适用。有一个例外——JavaScript 的时间戳是以毫秒为单位的,但服务器的值是以秒为单位的,所以在转换过程中仍然需要进行简单的乘法运算。

  1. 在其他导入语句下方导入 RxJS 的map操作符:
src/app/weather/weather.service.tsimport { map } from 'rxjs/operators'

手动导入map操作符可能看起来有点奇怪。RxJS 是一个非常强大的框架,具有广泛的 API 表面。仅 Observable 本身就有 200 多个附加方法。默认情况下包括所有这些方法会在开发时创建太多的函数选择问题,并且还会对最终交付的大小、应用程序性能和内存使用产生负面影响。因此,您必须单独添加您打算使用的每个操作符。

  1. 通过pipemap函数应用于httpClient.get方法返回的数据流。

  2. data对象传递给transformToICurrentWeather函数:

src/app/weather/weather.service.ts...return this.httpClient .get<ICurrentWeatherData>( `http://api.openweathermap.org/data/2.5/weather?q=${city},${country}&appid=${environment.appId}` ).pipe( map(data => this.transformToICurrentWeather(data) ) )...

现在,传入的数据可以在流经过程中进行转换,确保OpenWeatherMap当前天气 API 数据的格式正确,以便CurrentWeather组件可以使用。

  1. 确保您的应用成功编译

  2. 在浏览器中检查结果:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (20)从 OpenWeatherMap 显示实时数据

最后,您应该看到您的应用能够从OpenWeatherMap获取实时数据,并正确地将服务器数据转换为您期望的格式。

您已经完成了 Feature 1 的开发:显示当前位置的当天天气信息。提交您的代码并将卡片移动到 Waffle 的 Done 列。

  1. 最后,我们可以将此任务移动到 Done 列:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (21)Waffle.io 看板状态

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

您现在可以使用 Angular CLI 和优化的 VS Code 开发环境来帮助您减少需要编写的代码量。您可以利用 TypeScript 匿名类型和可观察流来准确地将复杂的 API 数据重塑为简单的格式,而无需创建一次性接口。

您学会了通过主动声明函数的输入和返回类型以及使用通用函数来避免编码错误。您使用日期和小数管道来确保数据按预期格式化,同时将与格式相关的问题大部分放在模板中,这种逻辑属于模板。

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

如果您没有发布它,那就从未发生过。在下一章中,我们将通过解决应用程序错误、确保自动化单元测试和端到端测试通过,并使用 Docker 将 Angular 应用程序容器化,以便可以在网络上发布。

如果你不发布它,它就没有发生过。在上一章中,您创建了一个可以检索当前天气数据的本地天气应用程序。您已经创造了一定价值;但是,如果您不将应用程序放在网络上,最终您将创造零价值。交付某物是困难的,将某物交付到生产中更加困难。您希望遵循一种能够产生可靠、高质量和灵活发布的策略。

我们在第二章中创建的应用程序,创建一个本地天气 Web 应用程序,是脆弱的,有失败的单元和端到端(e2e)测试,并且会发出控制台错误。我们需要修复单元测试并通过有意引入错误来加固应用程序,以便您可以使用调试工具看到真实条件的副作用。我们还需要能够单独交付前端应用程序和后端应用程序,这是保持能够推送单独的应用程序和服务器更新的灵活性非常重要的解耦。此外,解耦将确保随着应用程序堆栈中的各种工具和技术不可避免地不再受支持或受青睐,您将能够替换前端或后端,而无需完全重写系统。

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

  • 运行 Angular 单元和 e2e

  • 使用 Chrome 开发者工具排除常见的 Angular 错误

  • 防止空数据

  • 使用 Docker 将应用程序容器化

  • 使用 Zeit Now 将应用程序部署到网络上

所需软件如下所示:

  • Docker 社区版 17.12 版本

  • Zeit Now 账户

仅仅因为您的 Angular 应用程序使用npm start启动并且似乎工作正常,并不意味着它没有错误或准备好投入生产。如前面在第二章中所述,Angular CLI 在创建新组件和服务时会创建一个单元测试文件,例如current-weather.component.spec.tsweather.service.spec.ts

在最基本的层面上,这些默认单元测试确保您的新组件和服务可以在测试中正确实例化。看一下以下规范文件,并观察should create测试。该框架断言CurrentWeatherComponent类型的组件不是 null 或 undefined,而是真实的。

src/app/current-weather/current-weather.component.spec.tsdescribe('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规范包含了类似的测试。但是,您会注意到这两种类型的测试设置略有不同:

src/app/weather/weather.service.spec.tsdescribe('WeatherService', () => { beforeEach(() => { TestBed.configureTestingModule({ providers: [WeatherService], }) }) it('should be created', inject([WeatherService], (service: WeatherService) => { expect(service).toBeTruthy() }) )})

WeatherService规范的beforeEach函数中,正在将要测试的类配置为提供者,然后注入到测试中。另一方面,CurrentWeatherComponent规范有两个beforeEach函数。第一个beforeEach函数异步声明和编译了组件的依赖模块,而第二个beforeEach函数创建了一个测试装置,并开始监听组件的变化,一旦编译完成就准备运行测试。

Angular CLI 使用 Jasmine 单元测试库来定义单元测试,并使用 Karma 测试运行器来执行它们。最好的是,这些测试工具已经配置好可以直接运行。您可以使用以下命令执行单元测试:

$ npm test

测试将由 Karma 测试运行器在新的 Chrome 浏览器窗口中运行。Karma 的主要优点是它带来了类似于 Angular CLI 在开发应用程序时使用 WebPack 实现的实时重新加载功能。您应该观察终端上的最后一条消息为 Executed 5 of 5 (5 FAILED) ERROR。这是正常的,因为我们根本没有注意测试,所以让我们修复它们。

保持 Karma Runner 窗口与 VS Code 并排打开,这样您可以立即看到您的更改结果。

AppComponent 应该创建应用程序测试失败。如果您观察错误详情,您会发现AppComponent无法创建,因为'app-current-weather'不是一个已知的元素。此外,如果指出错误,错误会出现一个[ERROR ->]标签,最后一行为我们解释了事情,类似于 AppComponent.html 中的第 6 行出现的错误。

app.component.spec.ts的声明中包括CurrentWeatherComponent

src/app/app.component.spec.ts...TestBed.configureTestingModule({ declarations: [AppComponent, CurrentWeatherComponent],}).compileComponents()...

您会注意到错误数量并没有减少。相反,AppComponentCurrentWeatherComponent由于缺少WeatherService的提供者而无法创建。因此,让我们在这两个组件的规范文件中为WeatherService添加提供者。

  1. app.component.spec.ts的声明中提供WeatherService

  2. current-weather.component.spec.ts中应用相同的代码更改,如下所示:

src/app/app.component.spec.tssrc/app/current-weather/current-weather.component.spec.ts ... beforeEach( async(() => { TestBed.configureTestingModule({ declarations: [...], providers: [WeatherService], ...

你可能会想知道为什么AppComponent需要一个提供程序,因为组件构造函数没有注入WeatherService。这是因为CurrentWeatherComponentAppComponent的硬编码依赖项。可以通过两种方式进一步解耦这两个组件:一种方式是使用ng-container动态注入组件,另一种方式是利用 Angular Router 和router-outlet。后一种选项是你将会在大多数应用程序中使用的结构方式,并且将在后面的章节中进行介绍,而实现前一种选项以正确解耦组件则留给读者作为练习。

你仍然有剩余的错误。让我们首先修复WeatherService的错误,因为它是其他组件的依赖项。测试报告了一个缺少HttpClient提供程序的错误。然而,我们不希望我们的单元测试进行 HTTP 调用,所以我们不应该提供HttpClient,就像我们在上一节中所做的那样。Angular 为HttpClient提供了一个名为HttpClientTestingModule的测试替身。为了利用它,你必须导入它,然后它将自动为你提供给服务。

在提供程序下方导入HttpClientTestingModule

**src/app/weather/weather.service.spec.ts** import { HttpClientTestingModule } from '@angular/common/http/testing' ...describe('WeatherService', () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], ... 

类似于HttpClientTestingModule,还有一个RouterTestingModule和一个NoopAnimationsModule,它们是真实服务的模拟版本,因此单元测试可以专注于测试你编写的组件或服务代码。在后面的章节中,我们还将介绍如何编写自己的模拟。

现在你应该只看到与AppComponentCurrentWeatherComponent相关的错误。即使你已经提供了它们的依赖项,这些组件也失败了。要理解为什么会发生这种情况以及如何解决它,你还必须了解如何使用测试替身。

只有在受测试的类中的代码应该被执行。在CurrentWeatherComponent的情况下,我们需要确保服务代码不被执行。因此,你永远不应该提供服务的实际实现。这也是我们在上一节中使用HttpClientTestingModule的原因。由于这是我们的自定义服务,我们必须提供我们自己的测试替身的实现。

在这种情况下,我们将实现一个服务的虚假。由于WeatherService的虚假将用于多个组件的测试,您的实现应该在一个单独的文件中。为了保持代码库的可维护性和可发现性,一个文件一个类是一个很好的遵循的原则。将类放在单独的文件中将使您免受某些编码罪的困扰,比如错误地在两个类之间创建或共享全局状态或独立函数,从而在此过程中保持代码适当地解耦:

  1. 创建一个新文件weather/weather.service.fake.ts

我们需要确保实际实现和测试替身的 API 不会随着时间而不同步。我们可以通过为服务创建一个接口来实现这一点。

  1. 如下所示,将IWeatherService添加到weather.service.ts中:
src/app/weather/weather.service.tsexport interface IWeatherService { getCurrentWeather(city: string, country: string): Observable<ICurrentWeather>}
  1. 更新WeatherService以实现新接口:
src/app/weather/weather.service.tsexport class WeatherService implements IWeatherService
  1. weather.service.fake.ts中实现一个基本的虚假。
src/app/weather/weather.service.fake.tsimport { Observable, of } from 'rxjs'import { IWeatherService } from './weather.service'import { ICurrentWeather } from '../interfaces'export class WeatherServiceFake implements IWeatherService { private fakeWeather: ICurrentWeather = { city: 'Bursa', country: 'TR', date: 1485789600, image: '', temperature: 280.32, description: 'light intensity drizzle', }
 public getCurrentWeather(city: string, country: string): Observable<ICurrentWeather> { return of(this.fakeWeather) }}

我们正在利用现有的ICurrentWeather接口,以确保我们的虚假数据正确地构建,但我们还必须将其转换为Observable。这很容易通过使用of来实现,它会根据提供的参数创建一个可观察序列。

现在您已经准备好为AppComponentCurrentWeatherComponent提供虚假。

  1. 更新两个组件的提供者以使用WeatherServiceFake

以便虚假将被用于实际服务的替代品:

src/app/app.component.spec.tssrc/app/current-weather/current-weather.component.spec.ts ... beforeEach( async(() => { TestBed.configureTestingModule({ ... providers: [{ provide: WeatherService, useClass: WeatherServiceFake}], ...

随着您的服务和组件变得更加复杂,很容易提供一个不完整或不足的测试替身。您可能会看到诸如 NetworkError: Failed to execute 'send' on 'XMLHttpRequest',Can't resolve all parameters,或[object ErrorEvent] thrown 等错误。在后一种错误的情况下,点击 Karma 中的调试按钮以发现视图错误详情,可能会显示为 Timeout - Async callback was not invoked within timeout specified by jasmine。单元测试设计为在毫秒内运行,因此实际上应该不可能达到默认的 5 秒超时。问题几乎总是出现在测试设置或配置中。

我们已成功解决了所有与单元测试相关的配置和设置问题。现在,我们需要修复使用初始代码生成的单元测试。

有两个单元测试失败。在 Jasmine 术语中,单元测试称为规范,由it函数实现;it函数组织在包含可以在每个测试之前或之后执行的辅助方法的describe函数下,并处理规范的整体配置需求。您的应用程序为您生成了五个规范,其中两个现在失败了。

第一个是AppComponent 应该有标题'app';但是,我们从AppComponent中删除了这个属性,因为我们没有在使用它。在这种罕见情况下,我们需要这样做:

  1. 删除应该有标题'app'单元测试。

错误消息足够描述性,可以让您快速了解哪个测试失败了。这是因为提供给describe函数的描述是'AppComponent',而提供给it函数的描述是'应该有标题'app''。Jasmine 然后将任何父对象的描述附加到规范的描述中。当您编写新的测试时,您需要维护规范的可读描述。

接下来的错误,AppComponent 应该在 h1 标签中呈现标题,是我们必须修复的一个错误。我们现在在h1标签中呈现LocalCast Weather这几个词。

  1. 更新应该在 h1 标签中呈现标题测试如下所示:
src/app/app.component.spec.ts ...it( 'should render title in a h1 tag', ... expect(compiled.querySelector('h1').textContent).toContain('LocalCast Weather') ... 

所有单元测试现在都成功通过了。我们应该执行原子提交,所以让我们提交代码更改。

  1. 提交您的代码更改。

为了实现有效的单元测试覆盖率,您应该专注于测试包含业务逻辑的函数的正确性。这意味着您应该特别注意遵守单一职责和开闭原则,即 SOLID 原则中的 S 和 O。

除了单元测试外,Angular CLI 还为您的应用程序生成和配置 e2e 测试。虽然单元测试侧重于隔离被测试的类,e2e 测试则是关于集成测试。Angular CLI 利用 Protractor 和 WebDriver,因此您可以从用户在浏览器上与您的应用程序交互的角度编写自动接受测试AAT)。根据经验,您应该始终编写比 AAT 多一个数量级的单元测试,因为您的应用程序经常发生变化,因此与单元测试相比,AAT 更加脆弱且昂贵。

如果术语 Web 驱动程序听起来很熟悉,那是因为它是经典的 Selenium WebDriver 的演变。截至 2017 年 3 月 30 日,WebDriver 已被提议为 W3C 的官方 Web 标准。您可以在www.w3.org/TR/webdriver上阅读更多关于它的信息。如果您之前熟悉 Selenium,您会感到宾至如归,因为许多模式和实践几乎是相同的。

CLI 为初始的AppComponent提供了 e2e 测试,根据应用程序的复杂性和功能集,您可以遵循提供的模式来更好地组织您的测试。在e2e文件夹下为每个组件生成两个文件:

e2e/app.e2e-spec.tsimport { AppPage } from './app.po'describe('web-app App', () => { let page: AppPage beforeEach(() => { page = new AppPage() }) it('should display welcome message', () => { page.navigateTo() expect(page.getParagraphText()).toEqual('Welcome to app!') })})

app.e2e-spec.ts是用 Jasmine 编写的,实现了验收测试。该规范依赖于页面对象(po)文件,该文件定义在spec文件旁边:

e2e/app.po.tsimport { browser, by, element } from 'protractor'export class AppPage { navigateTo() { return browser.get('/') } getParagraphText() { return element(by.css('app-root h1')).getText() }}

页面对象文件封装了来自spec文件的 Web 驱动程序实现细节。 AATs 是最。这导致了易于维护、人类可读的规范文件。通过在这个级别分离关注点,您可以将 AAT 的脆弱性隔离到一个位置。通过利用类继承,您可以构建一个强大的页面对象集合,随着时间的推移更容易维护。

您可以在终端中使用以下命令执行 e2e 测试;确保npm test进程没有在运行:

$ npm run e2e

您会注意到测试执行与单元测试不同。虽然您可以配置一个观察者来不断执行 Karma 的单元测试,但由于 e2e 测试的用户驱动和有状态的特性,尝试使用类似的配置来执行 e2e 测试并不是一个好的做法。运行测试一次并停止测试工具确保每次运行都有一个干净的状态。

执行 e2e 测试后,您应该会看到类似于这里的错误消息:

*************************************************** Failures ***************************************************1) web-app App should display welcome message - Expected 'LocalCast Weather' to equal 'Welcome to app!'.Executed 1 of 1 spec (1 FAILED) in 1 sec.

这个错误类似于您之前修复的单元测试:

  1. 更新spec以期望正确的标题如下:
e2e/app.e2e-spec.ts expect(page.getParagraphText()).toEqual('LocalCast Weather')
  1. 重新运行测试,现在应该通过了:
Jasmine started web-app App √ should display welcome messageExecuted 1 of 1 spec SUCCESS in 1 sec.
  1. 提交您的代码更改。

我们的单元测试和 e2e 测试现在正在运行。在这一部分,您有意引入一个容易犯的错误,以便您可以熟悉在开发应用程序时可能发生的真实错误,并对使您成为一名有效的开发人员的工具有扎实的理解。

在 macOS 上按option + ⌘ + I,或在 Windows 上按F12Ctrl + Shift + I打开 Chrome 开发者工具(dev tools)。

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组件似乎根本没有渲染:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (22)带有实时重新加载的并排开发

要找出原因,你需要调试你的 Angular 应用。

作为开发人员,我使用谷歌 Chrome 浏览器,因为它具有跨平台和一致的开发者工具,还有有用的扩展。

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (23)CurrentWeather 未渲染

作为最佳实践,我会在 VS Code 和浏览器并排编码,同时在浏览器中也打开开发工具。有几个很好的理由来练习并排开发:

  • 快速反馈循环:通过实时重新加载,你可以很快看到你的更改的最终结果

  • 笔记本电脑:现在很多开发人员大部分时间都在笔记本电脑上进行开发,而第二个显示器是一种奢侈。

  • 注意响应式设计:由于我有限的空间可用,我不断关注移动优先开发,在事后修复桌面布局问题。观察一下并排开发是什么样子的:

  • 网络活动意识:为了让我能够快速看到任何 API 调用错误,并确保请求的数据量保持在我的预期范围内

  • 控制台错误意识:为了让我能够在引入新错误时快速做出反应和解决问题

假设我们在从OpenWeatherMap.org的 API 文档页面复制和粘贴 URL 时犯了一个无心的错误,并忘记在其前面添加http://。这是一个容易犯的错误:

最终,你应该做最适合你的事情。通过并排设置,我经常发现自己在打开和关闭 VS Code 的资源管理器,并根据手头的具体任务调整开发工具窗格的大小。要切换 VS Code 的资源管理器,请点击前面截图中圈出的资源管理器图标。

就像你可以使用npm start进行带有实时重新加载的并排开发一样,你也可以使用npm test进行单元测试,获得同样类型的快速反馈循环。

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (24)并排开发与单元测试

通过并排的单元测试设置,你可以在开发单元测试方面变得非常有效。

为了使并排开发和实时重新加载正常工作,你需要优化默认的开发工具体验。

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (25)优化的 Chrome 开发者工具

从前面的图中可以看出,有很多设置和信息显示器被突出显示:

  1. 默认打开网络选项卡,这样你就可以看到网络流量的流动。

  2. 点击Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (26)按钮打开开发工具设置。

  3. 点击右侧图标,使开发工具停靠在 Chrome 的右侧。这种布局可以提供更多的垂直空间,这样你就可以一次看到更多的网络流量和控制台事件。作为一个附带的好处,左侧的布局接近移动设备的大小和形状。

  4. 切换到大请求行,并关闭概览,以便查看每个请求的 URL 和参数,并获得更多的垂直空间。

  5. 勾选禁用缓存选项,这样当你在打开开发工具的情况下刷新页面时,将强制重新加载每个资源。这可以防止奇怪的缓存错误影响你的工作。

  6. 你主要会对各种 API 的 XHR 调用感兴趣,所以点击 XHR 来过滤结果。

  7. 请注意,你可以在右上角看到控制台错误的数量为 12。理想情况下,控制台错误的数量应该始终为 0。

  8. 请注意,请求行中的顶部项目表明状态码为 404 未找到的错误。

  9. 由于我们正在调试一个 Angular 应用程序,Augury 扩展已经加载。我将在第七章中更详细地介绍这个工具,*创建一个更复杂的应用程序时,你将会构建一个更复杂的应用程序。

有了优化的开发工具环境,你现在可以有效地排除之前的应用程序错误。

在这个状态下,应用程序有三个可见的问题:

  • 组件详情没有显示

  • 有很多控制台错误。

  • API 调用返回 404 未找到错误

首先检查任何网络错误,因为网络错误通常会引起连锁反应:

  1. 在网络选项卡中点击失败的 URL

  2. 在 URL 右侧打开的详细信息窗格中,点击预览选项卡

  3. 您应该看到这个:

Cannot GET /api.openweathermap.org/data/2.5/weather

仅仅观察这个错误消息,您很可能会忽略这样一个事实,即您忘记向 URL 添加http://前缀。这个错误很微妙,当然不是非常明显的。

  1. 将鼠标悬停在 URL 上,并观察完整的 URL,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (27)检查网络错误

正如您所看到的,现在这个错误非常明显。在这个视图中,我们可以看到完整的 URL,并且清楚地看到weather.service.ts中定义的 URL 没有完全合格,因此 Angular 尝试从其父服务器localhost:5000上加载资源,而不是通过网络到正确的服务器上。

在您修复此问题之前,值得了解 API 调用失败的连锁效应:

  1. 观察控制台错误:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (28)开发工具控制台错误上下文

这里需要注意的第一个元素是ERROR CONTEXT对象,它有一个名为DebugContext_的属性。DebugContext_包含了发生错误时您的 Angular 应用程序的当前状态的详细快照。DebugContext_中包含的信息远远超过了 AngularJS 生成的大部分不太有用的错误消息。

值为(...)的属性是属性获取器,您必须点击它们以加载其详细信息。例如,如果您点击 componentRenderElement 的省略号,它将被填充为 app-current-weather 元素。您可以展开该元素以检查组件的运行时条件。

  1. 现在滚动到控制台的顶部

  2. 观察第一个错误:

ERROR TypeError: Cannot read property 'city' of undefined

您可能之前遇到过TypeError。这个错误是由于尝试访问未定义对象的属性而引起的。在这种情况下,CurrentWeatherComponent.current没有分配给一个对象,因为 http 调用失败了。由于current没有初始化,模板盲目地尝试绑定其属性,比如{{current.city}},我们会得到一个消息,说无法读取未定义的属性'city'。这是一种连锁反应,可能会在您的应用程序中产生许多不可预测的副作用。您必须积极编码以防止这种情况发生。

当使用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 --sourcemaps=false", ...}
  1. 执行npm run test:debug

  2. 现在 Karma 运行器可能会揭示潜在的问题

  3. 如果有必要,跟踪堆栈以找到可能导致问题的子组件

如果这种策略不起作用,你可以通过断点调试单元测试来获取更多关于出错原因的信息。

你还可以直接在 Visual Studio Code 中调试你的 Angular 应用程序、Karma 和 Protractor 测试。首先,你需要配置调试器以与 Chrome 调试环境配合工作,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (29)VS Code 调试设置

  1. 点击调试窗格

  2. 展开“无配置”下拉菜单,然后点击“添加配置...”

  3. 在“选择环境”选择框中,选择 Chrome

这将在.vscode/launch.json文件中创建一个默认配置。我们将修改这个文件以添加三个单独的配置。

  1. 用以下配置替换launch.json的内容:
.vscode/launch.json{ "version": "0.2.0", "configurations": [ { "name": "npm start", "type": "chrome", "request": "launch", "url": "http://localhost:5000/#", "webRoot": "${workspaceRoot}", "runtimeArgs": [ "--remote-debugging-port=9222" ], "sourceMaps": true }, { "name": "npm test", "type": "chrome", "request": "launch", "url": "http://localhost:9876/debug.html", "webRoot": "${workspaceRoot}", "runtimeArgs": [ "--remote-debugging-port=9222" ], "sourceMaps": true }, { "name": "npm run e2e", "type": "node", "request": "launch", "program": "${workspaceRoot}/node_modules/protractor/bin/protractor", "protocol": "inspector", "args": ["${workspaceRoot}/protractor.conf.js"] } ]}
  1. 在开始调试之前,执行相关的 CLI 命令,如npm startnpm testnpm run e2e

  2. 在调试页面上,在调试下拉菜单中,选择 npm start,然后点击绿色播放图标

  3. 观察 Chrome 实例是否已启动

  4. .ts文件上设置断点

  5. 执行应用程序中的操作以触发断点

  6. 如果一切顺利,Chrome 将报告代码已在 Visual Studio Code 中暂停

在发布时,这种调试方法并不总是可靠的。我不得不在 Chrome Dev Tools | Sources 标签中手动设置断点,在webpack://.文件夹下找到相同的.ts文件,这样才能正确地触发 VS Code 中的断点。然而,这使得使用 VS Code 调试代码的整个好处变得毫无意义。有关更多信息,请在 GitHub 上查看 Angular CLI 部分关于 VS Code Recipes 的内容:github.com/Microsoft/vscode-recipes

在 JavaScript 中,undefinednull值是一个持久性问题,必须在每一步积极地处理。在 Angular 中,有多种方法可以防范null值:

  1. 属性初始化

  2. 安全导航操作符?.

  3. 使用*ngIf进行 null 防范

在诸如 Java 这样的静态类型语言中,你被灌输了正确的变量初始化/实例化是无错误操作的关键。所以让我们在CurrentWeatherComponent中尝试通过使用默认值来初始化当前值:

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

这些更改的结果将把控制台错误从 12 个减少到 3 个,此时您只会看到与 API 调用相关的错误。然而,应用本身仍然不是一个可以展示的状态,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (30)

属性初始化的结果

为了使这个视图对用户可见,我们必须在模板的每个属性上编写默认值的代码。因此,通过初始化来修复 null 防范问题,我们创建了一个默认值处理问题。对于开发人员来说,初始化和默认值处理都是O(n)规模的任务。在最好的情况下,这种策略将是烦人的实施,在最坏的情况下,高度无效且容易出错,每个属性至少需要O(2n)的工作量。

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 处理显示未定义的绑定。您会注意到,就像初始化修复一样,错误数量已经从 12 个减少到 3 个。应用本身的状态有所改善。不再显示混乱的数据;然而,它仍然不是一个可以展示的状态,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (31)安全导航操作符的结果

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

理想的策略是使用*ngIf,这是一个结构指令,意味着 Angular 将在假语句之后停止遍历 DOM 树元素。

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

  1. 更新顶层的div元素,使用*ngIf来检查current是否是一个对象,如下所示:
src/app/current-weather/current-weather.component.html <div *ngIf="current"> ...</div>

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

  1. 提交你的代码。

Docker docker.io 是一个用于开发、发布和运行应用程序的开放平台。Docker 结合了一个轻量级的容器虚拟化平台和工作流程以及工具,帮助管理和部署应用程序。虚拟机(VMs)和 Docker 容器之间最明显的区别是,VMs 通常有数十 GB 的大小,需要数 GB 的内存,而容器在磁盘和内存大小方面只有几 MB 的要求。此外,Docker 平台抽象了主机操作系统级别的配置设置,因此成功运行应用程序所需的每一部分配置都编码在人类可读的 Dockerfile 格式中,如下所示:

**Dockerfile**FROM duluca/minimal-node-web-server:8.11.1 WORKDIR /usr/src/app COPY dist public

前面的文件描述了一个新的容器,该容器继承自一个名为duluca/minimal-node-web-server的容器,将工作目录更改为/usr/src/app,然后将开发环境中dist文件夹的内容复制到容器的public文件夹中。在这种情况下,父镜像配置了一个 Express.js 服务器,充当 web 服务器,以提供public文件夹中的内容。请参考以下图表,以了解正在发生的情况的可视化表示:

Docker 镜像的上下文

在基础层是我们的主机操作系统,比如 Windows 或 macOS,它运行 Docker 运行时,将在下一节中安装。Docker 运行时能够运行自包含的 Docker 镜像,这是由上述的Dockerfile定义的。duluca/minimal-node-web-server基于轻量级的 Linux 操作系统 Alpine。Alpine 是 Linux 的一个完全精简版本,不带有任何图形界面,驱动程序,甚至大多数你可能期望从 Linux 系统中得到的 CLI 工具。因此,这个操作系统的大小只有大约 5MB。基础软件包然后安装了 Node.js,Node.js 本身的大小约为 10MB,以及我定制的基于 Node.js 的 Express.js web 服务器,结果是一个微小的约 15MB 的镜像。Express 服务器被配置为提供/usr/src/app文件夹的内容。在前面的Dockerfile中,我们只是将开发环境中/dist文件夹的内容复制到/usr/src/app文件夹中。我们稍后将构建并执行这个镜像,这将运行我们的 Express web 服务器,其中包含我们dist文件夹的输出。

Docker 的美妙之处在于你可以导航到hub.docker.com,搜索duluca/minimal-node-web-server,阅读它的Dockerfile,并追溯其源头直到作为 web 服务器基础的原始基础镜像。我鼓励你以这种方式审查你使用的每个 Docker 镜像,以了解它对你的需求到底带来了什么。你可能会发现它要么过度复杂,要么有你以前不知道的功能,可以让你的生活变得更加轻松。请注意,父镜像需要特定版本的duluca/minimal-node-web-server,为8.11.1。这是非常有意义的,作为读者,你应该选择你找到的 Docker 镜像的最新可用版本。然而,如果你不指定版本号,你将始终获得镜像的最新版本。随着镜像的发布更多版本,你可能会拉取一个未来版本,可能会破坏你的应用程序。因此,对于你依赖的镜像,总是指定一个版本号。

一个这样的案例是duluca/minimal-node-web-server中内置的 HTTPS 重定向支持。当你只需要在你的 Dockerfile 中添加以下行时,你可以花费无数小时尝试设置一个 nginx 代理来做同样的事情:

ENV ENFORCE_HTTPS=xProto

就像 npm 包一样,Docker 可以带来巨大的便利和价值,但你必须小心地理解你正在使用的工具。

在第十一章中,AWS 上高可用云基础设施,我提到了基于 Nginx 的低占用的 docker 镜像的使用。如果你熟悉配置nginx,你可以使用duluca/minimal-nginx-web-server作为你的基础镜像。

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

Windows 对 Docker 的支持可能具有挑战性。你必须拥有一个支持虚拟化扩展的 CPU 的 PC,这在笔记本电脑上并不是一定的。你还必须拥有启用了 Hyper-V 的 Windows 专业版。另一方面,Windows Server 2016 原生支持 Docker,这是微软向行业采用 Docker 和容器化倡议所表现出的前所未有的支持量。

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

对于 Windows:

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

对于 macOS:

$ brew install docker
  1. 执行docker -v来验证安装。

现在,让我们配置一些 Docker 脚本,您可以使用这些脚本来自动构建,测试和发布您的容器。我开发了一组名为npm Scripts for Docker的脚本,适用于 Windows 10 和 macOS。您可以在bit.ly/npmScriptsForDocker获取这些脚本的最新版本:

  1. hub.docker.com/上注册 Docker Hub 帐户

  2. 为您的应用程序创建一个公共(免费)存储库

不幸的是,在发布时,Zeit 不支持私有 Docker Hub 存储库,因此您的唯一选择是公开发布您的容器。如果您的图像必须保持私有,我建议您按照第十一章中描述的在 AWS ECS 环境中设置的方法进行操作,在 AWS 上构建高可用云基础设施。您可以通过访问 Zeit Now 的文档zeit.co/docs/deployment-types/docker来了解问题的最新情况。

  1. 更新package.json以添加一个新的配置属性,具有以下配置属性:
package.json ... "config": { "imageRepo": "[namespace]/[repository]", "imageName": "custom_app_name", "imagePort": "0000" }, ...

命名空间将是您的 DockerHub 用户名。您将在创建过程中定义您的存储库的名称。示例图像存储库变量应如duluca/localcast-weather。图像名称用于轻松识别您的容器,同时使用 Docker 命令,如docker ps。我将只称之为localcast-weather。端口将定义应从容器内部使用哪个端口来公开您的应用程序。由于我们在开发中使用5000,请选择另一个端口,如8080

  1. 通过从bit.ly/npmScriptsForDocker复制粘贴脚本将 Docker 脚本添加到package.json。以下是脚本的注释版本,解释了每个功能。

请注意,使用 npm 脚本时,prepost关键字分别用于在给定脚本的执行之前或之后执行辅助脚本,并且脚本故意分成较小的部分,以便更容易阅读和维护它们:

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中为图像打上版本号:

package.json ... "docker:tag": " cross-conf-env docker image tag $npm_package_config_imageRepo:$npm_package_version $npm_package_config_imageRepo:latest", ...

npm run docker:tag将使用package.json中的version属性的版本号和latest标签标记已构建的 Docker 镜像:

package.json ... "docker:run": "run-s -c docker:clean docker:runHelper", "docker:runHelper": "cross-conf-env docker run -e NODE_ENV=local --name $npm_package_config_imageName -d -p $npm_package_config_imagePort:3000 $npm_package_config_imageRepo", ...

npm run docker:run将删除任何现有的先前版本的镜像,并使用docker run命令运行已构建的镜像。请注意,imagePort属性用作 Docker 镜像的外部端口,该端口映射到 Node.js 服务器监听的图像的内部端口3000

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标签的镜像。

package.json ... "docker:clean": "cross-conf-env docker rm -f $npm_package_config_imageName", ...

npm run docker:clean将使用docker rm -f命令从系统中删除先前构建的镜像:

package.json ... "docker:taillogs": "cross-conf-env docker logs -f $npm_package_config_imageName", ...

运行npm run docker:taillogs将使用docker log -f命令显示正在运行的 Docker 实例的内部控制台日志,这是在调试 Docker 实例时非常有用的工具:

package.json ... "docker:open:win": "echo Trying to launch on Windows && timeout 2 && start http://localhost:%npm_package_config_imagePort%", "docker:open:mac": "echo Trying to launch on MacOS && sleep 2 && URL=http://localhost:$npm_package_config_imagePort && open $URL", ...

npm run docker:open:winnpm run docker:open:mac将等待 2 秒,然后使用imagePort属性以正确的 URL 启动浏览器到您的应用程序:

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中运行一个实例,打开浏览器,然后开始显示容器的内部日志。

  1. 安装两个开发依赖项,以确保脚本的跨平台功能:
$ npm i -D cross-conf-env npm-run-all
  1. 自定义预构建脚本以在构建图像之前执行单元测试和 e2e 测试:
package.json"predocker:build": "npm run build -- --prod --output-path dist && npm test -- --watch=false && npm run e2e",

请注意,npm run build提供了--prod参数,可以实现两个目标:

  1. 将约 2.5 MB 的开发时间负载优化为约 73kb 或更少

  2. src/environments/environment.prod.ts中定义的配置项在运行时使用

  3. 更新src/environments/environment.prod.ts,使用您自己的OpenWeatherappId

export const environment = { production: true, appId: '01ffxxxxxxxxxxxxxxxxxxxxxxxxxxxx', baseUrl: 'https://',}

我们正在修改npm test的执行方式,以便测试只运行一次,工具停止执行。提供--watch=false选项以实现此行为,而不是默认的持续执行行为。此外,npm run build提供了--output-path dist,以确保index.html发布在文件夹的根目录。

  1. 创建一个名为Dockerfile的新文件,没有文件扩展名

  2. 实现Dockerfile,如下所示:

DockerfileFROM duluca/minimal-node-web-server:8.11.1WORKDIR /usr/src/appCOPY dist public

确保检查dist文件夹的内容。确保index.html位于dist的根目录。否则,请确保您的Dockerfile复制具有index.html的文件夹。

  1. 执行npm run predocker:build以确保您的应用程序更改已成功

  2. 执行npm run docker:build以确保您的镜像成功构建

虽然您可以单独运行提供的任何脚本,但您实际上只需要记住其中两个:

  • npm run docker:debug将在新的浏览器窗口中测试、构建、标记、运行、追踪和启动您的容器化应用程序

  • npm run docker:publish将发布您刚刚构建和测试的图像到在线 Docker 存储库

  1. 在终端中执行docker:debug
$ npm run docker:debug

您会注意到脚本在终端窗口中显示错误。这些并不一定是失败的指标。脚本并不完善,因此它们会同时尝试 Windows 和 macOS 兼容的脚本,并且在第一次构建时,清理命令会失败,因为没有东西需要清理。在您阅读此文时,我可能已经发布了更好的脚本;如果没有,您可以随时提交拉取请求。

成功的docker:debug运行应该会在焦点中打开一个新的浏览器窗口,显示您的应用程序和服务器日志在终端中被追踪,如下所示:

Current Environment: local.Server listening on port 3000 inside the containerAttenion: To access server, use http://localhost:EXTERNAL_PORTEXTERNAL_PORT is specified with 'docker run -p EXTERNAL_PORT:3000'. See 'package.json->imagePort' for the default port.GET / 304 12.402 ms - -GET /styles.d41d8cd98f00b204e980.bundle.css 304 1.280 ms - -GET /inline.202587da3544bd761c81.bundle.js 304 11.117 ms - -GET /polyfills.67d068662b88f84493d2.bundle.js 304 9.269 ms - -GET /vendor.c0dc0caeb147ad273979.bundle.js 304 2.588 ms - -GET /main.9e7f6c5fdb72bb69bb94.bundle.js 304 3.712 ms - -

您应该始终运行docker ps来检查您的镜像是否正在运行,上次更新时间,或者它是否与声称相同端口的现有镜像发生冲突。

  1. 在终端中执行docker:publish
$ npm run docker:publish

您应该在终端窗口中观察到成功运行,如下所示:

The push refers to a repository [docker.io/duluca/localcast-weather]60f66aaaaa50: Pushed...latest: digest: sha256:b680970d76769cf12cc48f37391d8a542fe226b66d9a6f8a7ac81ad77be4f58b size: 2827

随着时间的推移,您的本地 Docker 缓存可能会增长到相当大的规模,在我的笔记本电脑上大约是两年 40GB。您可以使用docker image prunedocker container prune命令来减小缓存的大小。有关更详细的信息,请参阅docs.docker.com/config/pruning上的文档。

让我们来看一下与 Docker 互动的更简单的方法。

与 Docker 镜像和容器互动的另一种方式是通过 VS Code。如果您已经安装了PeterJausovec.vscode-docker Docker 扩展,如第二章创建本地天气 Web 应用程序中建议的那样,您将在 VS Code 的资源管理器窗格中看到一个名为 DOCKER 的可展开标题,如下截图中的箭头所指出的那样:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (32)VS Code 中的 Docker 扩展

让我们来看一下扩展提供的一些功能:

  1. 镜像包含系统上存在的所有容器快照的列表

  2. 右键单击 Docker 镜像会弹出上下文菜单,可以在其中运行各种操作,如运行、推送和标记

  3. 容器列出系统上存在的所有可执行 Docker 容器,您可以启动、停止或附加到它们

  4. 注册表显示您配置连接的注册表,如 DockerHub 或 AWS 弹性容器注册表

虽然该扩展使与 Docker 的交互变得更容易,npm 脚本用于 Docker可以自动化与构建、标记和测试镜像相关的许多琐事。它们是跨平台的,并且在持续集成环境中同样有效。

通过 CLI 与 npm 脚本进行交互可能会让您感到困惑。让我们接下来看一下 VS Code 的 npm 脚本支持。

VS Code 默认支持 npm 脚本。为了启用 npm 脚本资源管理器,打开 VS Code 设置,并确保存在"npm.enableScriptExplorer": true属性。一旦您这样做了,您将在资源管理器窗格中看到一个可展开的标题,名为 NPM SCRIPTS,如下箭头所指:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (33)VS Code 中的 NPM 脚本

您可以单击任何脚本来启动包含该脚本的行package.json,或者右键单击并选择运行来执行该脚本。

如果从编码的角度来看,将某些东西交付到生产环境是困难的,那么从基础架构的角度来看,要做到正确更是极其困难。在后面的章节中,我将介绍如何为您的应用程序配置世界一流的 AWS 弹性容器服务ECS)基础架构,但如果您需要快速展示一个想法,这是无济于事的。现在,Zeit Now 登场了。

Zeit Now,zeit.co/now,是一个多云服务,可以实现应用程序的实时全球部署,直接从 CLI 进行。Now 可以与正确实现package.jsonDockerfile的应用程序一起工作。尽管我们两者都做了,但我们更喜欢部署我们的 Docker 镜像,因为在幕后会应用更多的魔法来使package.json部署工作,而您的 Docker 镜像可以部署到任何地方,包括 AWS ECS。

现在,让我们配置 Zeit Now 来在您的存储库上工作:

  1. 通过执行npm i -g now来安装 Zeit Now

  2. 通过执行now -v来确保正确安装

  3. local-weather-app下创建一个名为now的新文件夹

  4. 在新的now文件夹下创建一个新的Dockerfile

  5. 实现从您刚刚发布的图像中提取文件:

now/DockerfileFROM duluca/localcast-weather:6.0.1
  1. 最后,在您的终端中执行now命令,并按照说明完成配置:
$ now> No existing credentials found. Please log in:> We sent an email to xxxxxxxx@gmail.com. Please follow the steps provided inside it and make sure the security code matches XXX XXXXX.√ Email confirmed√ Fetched your personal details> Ready! Authentication token and personal details saved in "~\.now"

在 Zeit Now 上部署非常容易:

  1. 将您的工作目录更改为now并执行命令:
$ now --docker --public
  1. 在终端窗口中,该工具将报告其进度和您可以访问您的已发布应用程序的 URL:
> Deploying C:\dev\local-weather-app\web-app\now under duluca> Ready! https://xxxxxxxxxxxxx.now.sh [3s]> Initializing...> Building> ▲ docker buildSending build context to Docker daemon 2.048 kBkB> Step 1 : FROM duluca/localcast-weather> latest: Pulling from duluca/localcast-weather...> Deployment complete!
  1. 导航到第二行列出的 URL,并验证您的应用程序的发布。

请注意,如果您在途中出现配置错误,您的浏览器可能会显示一个错误,指出此页面正在尝试加载不安全的脚本,请允许并重新加载以查看您的应用程序。

您可以探索 Zeit Now 的付费功能,这些功能允许为您的应用程序提供高级功能,例如自动扩展。

恭喜,您的应用程序已在互联网上发布!

在本章中,您掌握了单元测试和端到端测试的配置和设置。您优化了故障排除工具,并了解了在开发应用程序时可能遇到的常见 Angular 错误。您学会了如何通过防范空数据来最好地避免 Angular 控制台错误。您配置了系统以与 Docker 一起工作,并成功地为您的 Web 应用程序容器化了自己专用的 Web 服务器。您为 Docker 配置了项目的 npm 脚本,可以被任何团队成员利用。最后,您成功地在云中交付了一个 Web 应用程序。

现在您知道如何构建一个可靠、弹性和容器化的生产就绪的 Angular 应用程序,以实现灵活的部署策略。在下一章中,我们将改进应用程序的功能集,并使用 Angular Material 使其看起来更加出色。

在不同版本的几十种不同浏览器的数十种组合上提供安全、快速和一致的 Web 体验并不是一件容易的事。Angular 的存在就是为了实现这一点;然而,互联网是一个不断发展的竞争技术和供应商的领域。Angular 团队已经承诺定期更新平台,但是要靠您来跟上 Angular 的补丁、次要版本和主要版本的发布。

Angular 是一个旨在最大程度减少从一个版本升级到另一个版本的工作量的平台,提供了有用的工具和指南,最重要的是确定性的发布节奏和关于废弃功能的充分沟通,这允许进行适当的规划以保持最新。

您必须以一种深思熟虑和计划的方式保持与 Angular 的最新版本同步。这样的策略将最大程度地提高您使用 Angular 这样的平台所获得的好处,将错误和浏览器之间的不一致体验降至最低。在极端情况下,您有选择:要么保留数百名测试人员来测试您的 Web 应用程序在所有主要浏览器及其最新版本上的兼容性问题,要么保持您的 Angular 版本(或您选择的框架)保持最新。请记住,最终,确保您交付的产品质量是由您来决定的。

现在可以随意跳过本章,当 Angular 的一个次要或主要版本发布时再回来阅读,或者继续阅读以了解潜在的升级过程可能是什么样子。

在本章中,我们将讨论以下主题:

  • 更新节点

  • 更新 npm 和全局包

  • 更新 Angular

  • 解决安全漏洞

  • 更新您的 Web 服务器

首先,重要的是考虑为什么我们首先要使用 Angular 或 React 等框架?在 Angular 之前,有 AngularJS 和 Backbone,它们都严重依赖于普遍存在的 jQuery 之前的框架。在 jQuery 存在的早期,即 2006 年,它对 Web 开发人员的目的是非常明显的——创建一个一致的 API 表面来实现 DOM 操作。浏览器供应商应该实现各种 Web 技术,如 HTML、JavaScript/EcmaScript 和 CSS,这是由万维网联盟(W3C)标准化的。当时,绝大多数互联网用户依赖的唯一浏览器是 Internet Explorer,它作为推动专有技术和 API 以保持其作为首选浏览器的优势的工具。首先是 Mozilla 的 Firefox,然后是 Google 的 Chrome 浏览器成功地获得了重要市场份额。然而,新浏览器版本开始以惊人的速度发布,竞争利益和不同的实现草案和已批准标准的版本和名称的质量差异造成了开发人员无法在 Web 上提供一致的体验。因此,您可以使用 jQuery 而不是反复编写代码来检查浏览器版本,这样您就可以轻松地隐藏供应商特定实现的所有复杂性,通过优雅地填补空白来弥补缺失的功能。

在 jQuery 中创建丰富的用户体验仍然很繁琐,Backbone 和 AngularJS 等框架使构建具有本地感和速度的 Web 应用程序更具成本效益。然而,浏览器不断变化,jQuery 和早期设计决策的意想不到的影响随之而来,随着标准的不断发展,导致了在 Angular 和 React 中构建 Web 应用程序的两种新的不同方法。从 AngularJS 过渡到 Angular 对整个社区来说都是一个令人不适的经历,包括 Angular 开发团队,但这必须是一个重大发布,以创建一个可以不断发展的平台。现在,新的 Angular 平台致力于保持最新状态,定期发布增量版本,以避免过去的错误。

即使您不将 Node.js 用作 Web 服务器,您也已经在使用它通过 npm 安装您的依赖项,并通过基于 Node.js 的软件包(如 WebPack,Gulp 或 Grunt)执行构建和测试任务。Node.js 是一个轻量级的跨平台执行环境,可以使大多数现代开发工具无缝工作。由于其性质,Node 位于您的主机操作系统之外的技术堆栈的最底层。保持 Node 的版本最新以获得安全性、速度和功能更新的好处非常重要。

Node.js 有两个分支:长期支持LTS)版本和当前版本。奇数版本是一次性的、风险的发布,不计划进行 LTS 阶段。偶数版本首先作为当前版本发布,然后进入 LTS 阶段。

为了最大的稳定性和避免意外问题,我强烈建议坚持使用 Node 的 LTS 版本:

  1. 通过运行此命令检查您当前的版本:
node -vv8.9.0

您可以在nodejs.org上查看有关最新发布的更多信息。除了计划发布,这个网站通常会包含有关各种 Node.js 发布的临时关键安全补丁的信息。

  1. 如果您使用奇数或非 LTS 发布频道,请删除您现有的 Node 安装:

在 Windows 上,请确保您以管理员权限运行 PowerShell:

PS> choco uninstall node

在 macOS 上,如果您的环境设置正确,您不需要在命令中添加sudo

$ brew uninstall --ignore-dependencies node
  1. 在 Windows 上,要升级到最新的 LTS 版本,请执行以下命令:
PS> choco upgrade nodejs-lts
  1. 在 macOS 上,如果您还没有安装 Node 8,您首先需要执行以下操作:
$ brew install node@8
  1. 如果您已经在版本 8 上,则执行以下操作:
$ brew upgrade node@8

请注意,计划在 2018 年 10 月发布版本 10 作为下一个 LTS 版本,因此在运行 brew install 命令之前,您需要牢记这一点。

如果您在 macOS 上,请参考下一节,了解使用n工具更轻松地管理您的 Node 版本的方法。否则,请跳转到更新 Npm部分。

在 macOS 上,HomeBrew 没有 Node 的 LTS 特定频道,如果最新版本是奇数版本,您将发现自己处于一个不理想的位置。如果您错误地执行了brew upgrade node并升级到奇数版本,要从这个错误中恢复最好是很烦人的。这个过程包括通过运行类似于这样的命令来潜在地破坏其他 CLI 工具:

$ brew uninstall --ignore-dependencies node

在通过 brew 进行初始 Node 安装后,我强烈建议利用功能丰富、交互式的 Node 版本管理工具n,由前 Node 维护者 TJ Holowaychuk 创建:

  1. 安装n
$ npm install -g n
  1. 执行n,它将显示您计算机上先前下载的所有 Node 版本的列表,并标记当前版本:
$ n ... node/8.2.1 node/8.3.0 node/8.4.0 ο node/8.9.0
  1. 执行n lts以安装最新的 LTS 版本:
$ n lts install : node-v8.9.3 mkdir : /usr/local/n/versions/node/8.9.3 fetch : https://nodejs.org/dist/v8.9.3/node-v8.9.3-darwin-x64.tar.gz######################################################################## 100.0% installed : v8.9.3

使用n,您可以快速在不同的 Node 版本之间切换。

在本节中,我们将介绍如何保持 npm 的最新状态。

如果 Node 是您技术栈中最低级别的工具,那么 npm 和全局 npm 包将被视为坐落在 Angular 和 Node 之间的下一层。

每次更新 Node 版本时,您还会获得一个新版本的 npm,它与 Node 捆绑在一起。但是,npm 的发布计划与 Node 的不一致。有时,会有显著的性能和功能增益,需要特定升级您的 npm 版本,例如 npm v5.0.0 引入的数量级速度改进,或者 npm v5.2.0 引入的减少全局包需求的 npx 工具:

  • 在 Windows 上,您需要使用npm-windows-upgrade工具来升级您的 npm 版本:
  1. 安装npm-windows-upgrade
PS> npm install --global --production npm-windows-upgrade

如果在安装工具时遇到错误,请参考Npm fails to install a global tool on Windows部分,解决系统设置的任何问题。

  1. 在提升的 shell 中执行npm-windows-upgrade,您将获得一系列选项,如下所示:
PS> npm-windows-upgradenpm-windows-upgrade v4.1.0? Which version do you want to install? 6.0.1-next.0> 6.0.0 6.0.0-next.2 6.0.0-next.1 6.0.0-next.0 5.10.0-next.0 5.9.0-next.0(Move up and down to reveal more choices)
  1. 选择一个稳定的版本,例如6.0.0
PS>? Which version do you want to install? 6.0.0Checked system for npm installation:According to PowerShell: C:\Program Files\nodejsAccording to npm: C:\Users\duluc\AppData\Roaming\npmDecided that npm is installed in C:\Program Files\nodejsUpgrading npm... \Upgrade finished. Your new npm version is 6.0.0\. Have a nice day!
  1. 验证您的安装:
PS> npm -v6.0.0
  • 在 macOS 上,升级您的 npm 版本很简单:
  1. 执行npm install -g npm
$ npm install -g npm/usr/local/bin/npm -> /usr/local/lib/node_modules/npm/bin/npm-cli.js/usr/local/bin/npx -> /usr/local/lib/node_modules/npm/bin/npx-cli.js+ npm@6.0.0updated 1 package in 18.342s

请注意,安装全局包,如前面所示,不应需要使用sudo

  1. 如果需要sudo,执行以下操作:
$ which npm/usr/local/bin/npm
  1. 找到此文件夹的所有者和权限:
$ ls -ld /usr/local/bin/npmlrwxr-xr-x 1 youruser group 38 May 5 11:19 /usr/local/bin/npm -> ../lib/node_modules/npm/bin/npm-cli.js

如您所见,正确的配置看起来像您自己的用户,以粗体显示为youruser,对该文件夹具有读/写/执行权限,也以粗体显示为rwx,其中npm位于其中。如果不是这种情况,请使用sudo chown -R $USER /usr/local/bin/npm来拥有该文件夹,然后使用chmod -R o+rwx /usr/local/bin/npm来确保您的用户具有完全权限。

  1. 验证您的安装:
$ npm -v6.0.0

保持任何全局安装的软件包最新也很重要;请参考下一节,了解如何将全局安装保持在最低限度,并解决 Windows 上的安装问题。

如本节和第二章中所述,在设置 Angular 项目时,您应该避免将任何项目特定工具安装为全局包。这包括诸如typescriptwebpackgulpgrunt等工具。npx工具使您能够方便地运行 CLI 命令,例如使用特定版本的tsc,而对性能的影响很小。如第二章中所讨论的,全局安装项目特定工具会对您的开发环境产生不利影响。

我确实提到了一些我仍然继续全局安装的工具,比如来自升级 Node部分的n工具,或者rimraf,这是一个跨平台递归删除工具,在 Windows 10 不配合删除您的node_modules文件夹时非常方便。这些工具是非项目特定的,而且基本稳定,不需要频繁更新。

事实是,除非工具提醒您升级自己,否则您很可能永远不会主动这样做。我们在第三章中使用的 now CLI 工具,为生产发布准备 Angular 应用,以在云中发布我们的 Docker 容器,是一个很好的例子,它始终保持警惕,以确保自己与以下消息保持最新:

 ^(───────────────────────────────────────── │ Update available! 8.4.0 → 11.1.7 │ │ Changelog: https://github.com/zeit/now-cli/releases/tag/11.1.7 │ │ Please download binaries from https://zeit.co/download │ ─────────────────────────────────────────)

您可以通过执行以下操作升级全局工具:

$ npm install -g now@latest

请注意,@latest请求将升级到下一个主要版本,如果可用的话,而不会引起太多轰动。虽然主要版本包含令人兴奋和有用的新功能,但它们也有破坏旧功能的风险,而您可能正在依赖这些功能。

这应该完成您的升级。然而,特别是在 Windows 上,很容易使您的 Node 和 npm 安装处于破损状态。以下部分涵盖了常见的故障排除步骤和您可以采取的操作,以恢复您的 Windows 设置。

Npm 可能无法安装全局工具;请考虑以下讨论的症状、原因和解决方案:

症状:当您尝试安装全局工具时,您可能会收到一个包含拒绝删除消息的错误消息,类似于下面显示的消息:

PS C:\WINDOWS\system32> npm i -g nownpm ERR! path C:\Users\duluc\AppData\Roaming\npm\now.cmdnpm ERR! code EEXISTnpm ERR! Refusing to delete C:\Users\duluc\AppData\Roaming\npm\now.cmd: node_modules\now\download\dist\now symlink target is not controlled by npm C:\Users\duluc\AppData\Roaming\npm\node_modules\nownpm ERR! File exists: C:\Users\duluc\AppData\Roaming\npm\now.cmdnpm ERR! Move it away, and try again.npm ERR! A complete log of this run can be found in:npm ERR! C:\Users\duluc\AppData\Roaming\npm-cache\_logs\2017-11-11T21_30_28_382Z-debug.log

原因:在 Windows 上,如果您曾经执行过npm install -g npm或使用 choco 升级过您的 Node 版本,您的 npm 安装很可能已经损坏。

解决方案 1:使用npm-windows-upgrade工具恢复您的环境:

  1. 执行 npm 升级例程:
PS> npm install --global --production npm-windows-upgradePS> npm-windows-upgrade
  1. 使用rimraf删除有问题的文件和目录:
PS> npm i -g rimrafrimraf C:\Users\duluc\AppData\Roaming\npm\now.cmdrimraf C:\Users\duluc\AppData\Roaming\npm\now
  1. 尝试重新安装:
PS> npm i -g now@latest

如果这不能解决您的问题,请尝试解决方案 2。

解决方案 2:如果您安装了非 LTS nodejs 或者没有正确配置 npm,请尝试以下步骤:

  1. 卸载非 LTS nodejs 并重新安装它:
PS> choco uninstall nodejsPS> choco install nodejs-lts --force -y
  1. 按照github.com/npm/npm/wiki/Troubleshooting#upgrading-on-windows中的指南安装npm-windows-upgrade

  2. 在具有管理员权限的 Powershell 中执行此操作:

PS> Set-ExecutionPolicy Unrestricted -Scope CurrentUser -ForcePS> npm install --global --production npm-windows-upgradePS> npm-windows-upgrade
  1. 执行npm-windows-upgrade
PS> npm-windows-upgradenpm-windows-upgrade v4.1.0? Which version do you want to install? 5.5.1Checked system for npm installation:According to PowerShell: C:\Program Files\nodejsAccording to npm: C:\Users\duluc\AppData\Roaming\npmDecided that npm is installed in C:\Program Files\nodejsUpgrading npm... -Upgrade finished. Your new npm version is 5.5.1\. Have a nice day!
  1. 注意根据 npm 文件夹。

  2. 转到此文件夹,并确保此文件夹中不存在npmnpm.cmd

  3. 如果有,删除。

  4. 确保此文件夹在PATH中。

单击“开始”,搜索“环境变量”。单击“编辑系统环境变量”。在“系统属性”窗口中,单击“环境变量”。选择带有路径的行。单击“编辑”。

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (34)编辑环境变量对话框

  1. 尝试重新安装您的全局工具。

  2. 如果问题仍然存在,您可能需要使用 PowerShell 命令删除全局的npm文件夹,如下所示:

PS> cmd /C "rmdir /S /Q C:\Users\duluc\AppData\Roaming\npm"
  1. 转到该文件夹:
PS> dir C:\Users\duluc\AppData\Roaming\npm
  1. 执行 npm:
PS> npm@5.5.1 C:\Program Files\nodejs\node_modules\npm
  1. 重新执行npm-windows-upgrade例程:
PS> npm install --global --production npm-windows-upgradePS> npm-windows-upgrade
  1. 重新安装工具:
PS> npm i -g nowC:\Users\duluc\AppData\Roaming\npm\now -> C:\Users\duluc\AppData\Roaming\npm\node_modules\now\download\dist\now> now@8.4.0 postinstall C:\Users\duluc\AppData\Roaming\npm\node_modules\now> node download/install.js> For the source code, check out: https://github.com/zeit/now-cli> Downloading Now CLI 8.4.0 [====================] 100%+ now@8.4.0

将来不要运行npm i -g npm

使用 Node 和 npm 最新版本,您现在可以准备升级您的 Angular 版本了。Angular 生态系统经过精心设计,使您的版本更新尽可能轻松。次要版本更新应该是直接和快速的,从版本6.0.0开始;主要版本升级应该更容易,因为 Angular CLI 附带了新的ng update命令。配合update.angular.io上发布的更新指南和特定于您升级路径的各种辅助工具,更新 Angular 是直接的。在本节中,我们将介绍如何更新您的 Angular 应用程序,假设从版本 5.2 升级到 6.0 的情景。指南应该基本保持不变,任何变化或将来的更改都记录在update.angular.io/中。

请记住,Angular 不建议在升级时跳过主要版本号,因此如果您使用的是版本 4,则首先需要升级到 5,然后再升级到 6。不要延迟更新您的框架版本,认为可以通过跳跃到最新版本来获得一些效率。

按照这一步骤指南准备、执行和测试您的 Angular 版本升级过程。

让我们首先检查package.json,以便您了解您正在使用的各种依赖项的版本。所有@angular包应该是相同的次要版本,例如5.2,如图所示:

package.json "@angular/animations": "5.2.5", "@angular/cdk": "⁵.2.2", "@angular/common": "5.2.5", "@angular/compiler": "5.2.5", "@angular/core": "5.2.5", "@angular/flex-layout": "².0.0-beta.12", "@angular/forms": "5.2.5", "@angular/http": "5.2.5", "@angular/material": "⁵.2.2", "@angular/platform-browser": "5.2.5", "@angular/platform-browser-dynamic": "5.2.5", "@angular/router": "5.2.5", "core-js": "².4.1", ... "rxjs": "⁵.5.6", "ts-enum-util": "².0.0", "zone.js": "⁰.8.20" }, "devDependencies": { "@angular/cli": "1.7.0", "@angular/compiler-cli": "5.2.5", "@angular/language-service": "5.2.5",...

现在您已经了解了您当前的版本,可以使用更新指南了:

  1. 导航至update.angular.io

  2. 选择您的应用程序的复杂性:

  • 基本:没有动画,没有 HTTP 调用

  • 中级:如果您正在使用 Angular Material 或进行 HTTP 调用或使用 RxJS,通常作为 1-2 人开发团队并交付小型应用程序

  • 高级:多人团队,交付中大型应用程序

大多数应用程序将属于中等复杂性;我强烈建议选择此选项。如果您已经在文档中深入实现了 Angular 功能,通过利用文档中提到的功能来实现任何自定义行为,确保在 HTTP、渲染、路由等方面实现任何自定义行为——一定要先浏览高级列表,以确保您没有使用已弃用的功能。

  1. 在更新指南上,选择从哪个版本升级到哪个版本。在这种情况下,选择从 5.2 升级到 6.0,如图所示:

Angular 更新指南

  1. 点击“显示我如何更新!”

  2. 请注意屏幕上显示的指示,分为更新前、更新中和更新后三个不同的部分

现在是困难的部分,我们需要遵循说明并应用它们。

更新软件是有风险的。有几种策略可以减少您在更新应用程序时的风险。这是您在应用程序中构建大量自动化测试的主要原因;然而,随着时间的推移,您的实施,包括 CI 和 CD 系统,可能会恶化。版本更新是重新评估您的自动化系统的健壮性并进行必要投资的好时机。在开始更新过程之前,请考虑以下升级前清单。

以下是在开始升级之前要运行的一些方便的检查项目清单:

  1. 确保@angular版本一直匹配到最后一个补丁。

  2. 确保您的 CI 和 CD 管道正常运行,没有失败或禁用的测试。

  3. 在升级之前对应用程序进行烟雾测试。确保所有主要功能正常运行,没有控制台错误或警告。

  4. 在升级之前解决任何发现的质量问题。

  5. 按顺序和有条不紊地遵循更新指南。

  6. 准备好回滚更新。

让我们从更新前的活动开始更新过程。

Angular 更新指南建议在“更新前”部分采取特定步骤,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (35)Angular 更新指南 - 更新前

在尝试更新之前,您可能需要对代码进行几种不同的更新。

命名空间更改:上述列表中的第一项通知我们某些动画服务和工具的命名空间可能已经更改。这些更改应该是低风险的,并且可以通过在 VS Code 中使用全局搜索工具快速完成。让我们看看如何快速观察你的应用程序中所有'@angular/core'的用法。看下一张截图:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (36)'@angular/core'的搜索结果

在这种情况下,没有与动画相关的用法,所以我们可以继续。

重命名和替换更新:在版本 4 中,有一个要求,即将OpaqueTokens类型替换为InjectionTokens。对于这些类型的更改,再次使用全局搜索工具查找和替换必要的代码。

在使用全局搜索工具查找和替换代码时,确保您启用了匹配大小写(由 Aa 表示)和匹配整个单词(由 Ab|表示),以防止意外的替换。看一下以下截图,看看这两个选项处于启用状态时的情况:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (37)匹配大小写和匹配整个单词已启用

功能性更改:弃用的功能提前一个主要版本发出信号,需要重写应用程序代码中受影响部分。如果您一直在大量使用HttpModuleHttp,那么您的代码将需要进行严重的改造:

  1. 首先,使用全局搜索发现实际用法的实例。

  2. angular.io上搜索新引入的服务,例如HttpClientHttpClientModule

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (38)Angular.io 文档页面

  1. 单击标题下的相关链接,其中包含有关新服务的丰富和上下文的信息。

新的服务通常伴随着新的好处,比如改进的编码体验,更好的可测试性或性能。

  1. 重新实现必要的代码。

  2. 执行下一节中提到的后续更新检查表。

这些功能性变化可以同时成为巨大的生产力助推器,但也会极大地增加及时升级到新版本 Angular 的摩擦。然而,您可以通过提前准备来降低变更成本,并最大程度地获得这些变化的好处。

在这种情况下,LocalCast Weather 应用程序没有使用已弃用的模块,因为恰好是在发布HttpClient服务后不久开始开发该应用程序。然而,如果我没有关注 Angular 社区,我就不会知道这个变化。出于这个原因,我强烈建议关注blog.angular.io

此外,您可以定期检查 Angular 更新工具。该工具可能不会被迅速更新;然而,它是所有即将到来的变化的一个很好的摘要资源。

在更新工具中,如果您选择未来版本的 Angular,您将收到警告消息:

警告:当前主要版本之后的发布计划尚未最终确定,可能会更改。这些建议是基于计划的弃用。

这是保持领先并提前规划资源围绕 Angular 更新的一个很好的方法。

完成“更新前”阶段后,考虑在进入下一阶段之前查看后续更新检查表。

以下是指南中关于ng update工具的更新期间部分:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (39)Angular 更新指南-更新期间

相比之下,Angular 6 之前的升级看起来是这样的:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (40)Angular 更新指南-在 Angular 6 之前

如果你对手动更新更感兴趣,请参考手动更新部分。在这一部分,我详细介绍了你应该执行的步骤,以更全面地进行自动升级。在第二章中,创建一个本地天气 Web 应用程序,我们避免安装 Angular CLI,这就是这种策略的好处所在。你可以继续在现有的 Angular 4 或 Angular 5 项目上工作,而不必担心 CLI 向后兼容性问题:

  1. 确保你已经更新到了最新的 Node LTS 版本,就像本章前面展示的那样

  2. 确保你使用的是 npm 的最新版本,就像本章前面展示的那样

  3. 在你的终端中,cd进入项目文件夹

  4. 清理你的node_modules文件夹:

$ rimraf node_modules

重要的是要注意,Node 或 npm 的版本更改可能会影响你的node_modules依赖项在计算机上的安装或存储方式。在升级到更低级别的工具,比如 Node 或 npm 之后,最好清除node_modules并在你的项目中重新安装你的包。在你的持续集成(CI)服务器上,这意味着使现有的包缓存无效。

  1. 重新安装依赖项:
$ npm install
  1. 卸载全局安装的@angular/cliwebpackjasminetypescript的版本:
$ npm uninstall -g @angular/cli webpack jasmine typescript
  1. 在你的项目中更新到最新的 CLI 版本:
$ npm i -D @angular/cli@latest> @angular/cli@6.0.0 postinstall /Users/du/dev/local-weather-app/node_modules/@angular/cli> node ./bin/ng-update-message.js===================================================================The Angular CLI configuration format has been changed, and your existing configuration can be updated automatically by running the following command:ng update @angular/cli===================================================================
  1. 根据前面的消息建议更新项目配置:
$ npx ng update @angular/cli master! Updating karma configuration Updating configuration Removing old config file (.angular-cli.json) Writing config file (angular.json) Some configuration options have been changed, please make sure to update any npm scripts which you may have modified.DELETE .angular-cli.jsonCREATE angular.json (3644 bytes)UPDATE karma.conf.js (1007 bytes)UPDATE src/tsconfig.spec.json (324 bytes)UPDATE package.json (3874 bytes)UPDATE tslint.json (3024 bytes)...added 620 packages from 669 contributors in 24.956s
  1. 尝试执行ng update
$ npx ng updateWe analyzed your package.json, there are some packages to update:Name Version Command to update-------------------------------------------------------------------@angular/core 5.1.0 -> 6.0.0 ng update @angular/core@angular/material 5.0.0 -> 6.0.0 ng update @angular/materialrxjs 5.5.2 -> 6.1.0 ng update rxjsThere might be additional packages that are outdated.Or run ng update --all to try to update all at the same time.
  1. 尝试执行ng update --all
$ npx ng update --all

你可能会收到一个错误消息,说找到了不兼容的 peer 依赖。列出了一个或多个具体的问题。在解决所有问题之前,你将无法使用ng update

在下一节中,我将介绍解决 peer 依赖错误的策略。如果你没有这种错误,可以跳过这一节。

我将介绍一些在升级过程中遇到的不兼容的 peer 依赖错误,以及解决这些错误的不同策略。请注意,我将从简单的情况开始,并演示可能需要的研究量,因为你需要的依赖项可能不仅仅是你的包的最新发布版本。

  • karma-jasmine-html-reporter缺少 peer 依赖"jasmine" @ "³.0.0"

这是一个简单的错误,只需简单地更新到最新版本的jasmine即可解决:

$ npm i -D jasmine
  • @angular/flex-layout"rxjs"有不兼容的对等依赖关系(需要"⁵.5.0",将安装"6.1.0")。

这个错误需要一些对生态系统的研究和理解。截至 Angular 6,我们知道所有库都是版本同步的,因此我们需要这个库的 6.x 版本。让我们使用npm info来发现当前可用的版本:

$ npm info @angular/flex-layout ... dist-tags: latest: 5.0.0-beta.14 next: 6.0.0-beta.15published a month ago by angular <devops+npm@angular.io>

截至目前,该库仍处于 beta 版本,最新版本为 5.0.0,因此简单地更新到最新版本的@angular/flex-layout是行不通的。在这种情况下,我们需要安装包的@next版本,如下所示:

$ npm i @angular/flex-layout@next

您将收到一堆依赖警告,显示需要 Angular 6 包。一旦更新完成,这些错误将消失。

  • 包"@angular/compiler-cli"与"typescript"有不兼容的对等依赖关系(需要">=2.7.2 <2.8",将安装"2.8.3")。

Angular CLI 依赖于特定版本的 Typescript。如果执行npm info typescript,则最新版本的 Typescript 可能比所需版本更新。在这种情况下,正如前面的错误消息所报告的那样,它是2.8.3。错误消息确实向我们指出了具体需要的版本,如果你看一下 requires 语句。下限2.7.2似乎是正确的安装版本,所以让我们安装它,如下所示:

$ npm install -D typescript@2.7.2

理论上,我们所有的操作都应该解决所有对等依赖问题。实际上,我注意到这些错误有时会在使用npx ng update --all时仍然存在,因此我们将继续通过运行单独的更新命令来进行更新。

在非 macOS 操作系统上,您可能会持续遇到与 fsevents 相关的警告,例如npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.1.3。这是一个可选的包,仅在 macOS 上使用。避免看到这个错误的简单方法是运行npm install --no-optional命令。

我们将逐步更新 Angular:

  1. 让我们从 Angular Core 开始更新:
$ npx ng update @angular/coreUpdating package.json with dependency rxjs @ "6.1.0" (was "5.5.6")... Updating package.json with dependency @angular/language-service @ "6.0.0" (was "5.2.5")... Updating package.json with dependency @angular/compiler-cli @ "6.0.0" (was "5.2.5")... Updating package.json with dependency @angular/router @ "6.0.0" (was "5.2.5")... Updating package.json with dependency @angular/forms @ "6.0.0" (was "5.2.5")... Updating package.json with dependency @angular/platform-browser @ "6.0.0" (was "5.2.5")... Updating package.json with dependency @angular/animations @ "6.0.0" (was "5.2.5")... Updating package.json with dependency zone.js @ "0.8.26" (was "0.8.20")... Updating package.json with dependency @angular/platform-browser-dynamic @ "6.0.0" (was "5.2.5")... Updating package.json with dependency @angular/common @ "6.0.0" (was "5.2.5")... Updating package.json with dependency @angular/core @ "6.0.0" (was "5.2.5")... Updating package.json with dependency @angular/compiler @ "6.0.0" (was "5.2.5")... Updating package.json with dependency @angular/http @ "6.0.0" (was "5.2.5")... UPDATE package.json (5530 bytes) ... added 12 packages from 37 contributors and updated 14 packages in 54.204s

请注意,此命令还会更新rxjs

  1. 更新 Angular Material:
$ npx ng update @angular/materialUpdating package.json with dependency @angular/cdk @ "6.0.0" (was "5.2.2")... Updating package.json with dependency @angular/material @ "6.0.0" (was "5.2.2")... UPDATE package.json (5563 bytes) ...

确保您查看第五章中的 Material Update Tool 和手动更新 Angular Material 的策略,使用 Angular Material 增强 Angular 应用

  1. 更新其他依赖项,包括使用npm update更新类型:
$ npm update+ codelyzer@4.3.0+ karma-jasmine@1.1.2+ jsonwebtoken@8.2.1+ core-js@2.5.5+ prettier@1.12.1+ karma-coverage-istanbul-reporter@1.4.2+ typescript@2.8.3+ @types/jsonwebtoken@7.2.7+ ts-enum-util@2.0.2+ @types/node@6.0.108

请注意,typescript已更新到其最新版本2.8.3,这对于 Angular 6 来说是不可接受的,正如前一节所述。通过执行npm install -D typescript@2.7.2回滚到版本2.7.2

  1. 解决任何 npm 错误和警告。

你已经完成了主要的 Angular 依赖项更新。考虑在继续升级后部分之前执行“升级后检查清单”。

“升级后”阶段通知需要在主要 Angular 依赖项更新后进行的更改,并有时告诉我们在升级我们的 Angular 版本后可以获得的进一步好处。观察下一步:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (41)Angular 升级指南-升级后

在这种情况下,我们必须解决与我们升级到 RxJS 相关的弃用。幸运的是,Angular 团队知道这可能是一个痛苦的过程,因此他们建议使用一个自动化工具来帮助我们入门:

  1. 不要全局安装该工具

  2. 执行迁移工具,如下所示:

$ npx rxjs-tslint -p .\src\tsconfig.app.jsonRunning the automatic migrations. Please, be patient and wait until the execution completes.Found and fixed the following deprecations:Fixed 2 error(s) in C:/dev/lemon-mart/src/app/common/common.tsFixed 6 error(s) in C:/dev/lemon-mart/src/app/auth/auth.service.tsFixed 1 error(s) in C:/dev/lemon-mart/src/app/common/ui.service.ts...WARNING: C:/dev/lemon-mart/src/app/auth/auth-http-interceptor.ts[2, 1]: duplicate RxJS importWARNING: C:/dev/lemon-mart/src/app/auth/auth-http-interceptor.ts[4, 27]: outdated import path
WARNING: C:/dev/lemon-mart/src/app/auth/auth.service.fake.ts[2, 1]: duplicate RxJS import...
  1. 手动解决任何警告;考虑以下示例:
exampleimport { BehaviorSubject, Observable, of } from 'rxjs'import { ErrorObservable } from 'rxjs/observable/ErrorObservable'import { IfObservable } from 'rxjs/observable/IfObservable'import { catchError } from 'rxjs/operators'

在前面的示例中,我们只需要根据 RxJS 6 文档从'rxjs''rxjs/operators'导入,因此删除另外两个导入。此外,ErrorObservableIfObservable导入被任何一行代码引用,因此很容易识别并删除。

一些警告可能掩盖了与新的 RxJS 函数的错误或不兼容性,因此逐一检查它们非常重要。

  1. 移除rxjs-compat
$ npm uninstall rxjs-compat
  1. 构建和测试您的代码,以确保通过执行npm run predocker:build进行构建。

predocker:build以生产模式构建您的 Angular 应用程序,并通过执行以下命令运行您的单元测试和端到端测试:

$ npm run build -- --prod && npm test -- --watch=false && npm run e2e

解决任何错误。如果您遇到与您的代码无关的神秘错误,请尝试删除node_modules并重新安装软件包。

如果一切正常工作,恭喜你,你已经完成了升级!在你打开起泡酒之前,执行“升级后检查清单”。

更新后的清单在确保在进行大规模代码更改后没有引入任何退化的情况下非常有用。建议在更新过程的每个阶段之后执行此清单。可能并不总是可能或可行执行整个清单,但在对代码基进行重大更改后,如果有必要,更新你的单元测试,并逐步执行以下清单:

  1. 构建和烟雾测试你的 Angular 应用

  2. 提交你的更改

  3. 每次提交时,确保 CI 流水线保持正常

  4. 如果进行功能性更改,可能需要遵循你的组织的发布周期程序,其中可能包括由 QA 团队进行手动测试

  5. 建议逐个实施和部署这些更改,并将它们部署到生产环境

  6. 收集性能数据,如下一节所述

在一类更改后提交你的代码,这样可以在出现问题时回滚或挑选进一步的升级提交。

出于各种原因,你可能需要手动升级 Angular,这在下一节中有所涉及。

最好对手动升级的工作原理有一个大致的了解,因为你可能无法使用具有自动更新功能的 Angular CLI 版本;你可能需要完全退出你的项目或者工具可能包含错误。这里讨论的版本号是从更新指南中复制的示例。

为了举例,我将演示从 Angular 4 到 Angular 5 的潜在升级:

  1. 遵循指南和本章的更新说明

  2. 确保 Node 和 npm 是最新的

  3. 为了升级到版本 5.0.0,执行以下命令:

$ npm install @angular/animations@'⁵.0.0' @angular/common@'⁵.0.0' @angular/compiler@'⁵.0.0' @angular/compiler-cli@'⁵.0.0' @angular/core@'⁵.0.0' @angular/forms@'⁵.0.0' @angular/http@'⁵.0.0' @angular/platform-browser@'⁵.0.0' @angular/platform-browser-dynamic@'⁵.0.0' @angular/platform-server@'⁵.0.0' @angular/router@'⁵.0.0' typescript@2.4.2 rxjs@'⁵.5.2'
  1. 接着执行--save-exact命令,以防 TypeScript 被意外升级:
$ npm install typescript@2.4.2 --save-exact
  1. 确保你的 package.json 文件已经更新到正确的版本:
"dependencies": { "@angular/animations": "⁵.0.0", "@angular/common": "⁵.0.0", "@angular/compiler": "⁵.0.0", "@angular/core": "⁵.0.0", "@angular/forms": "⁵.0.0", "@angular/http": "⁵.0.0", "@angular/platform-browser": "⁵.0.0", "@angular/platform-browser-dynamic": "⁵.0.0", "@angular/platform-server": "⁵.0.0", "@angular/router": "⁵.0.0", "core-js": "².5.1", "rxjs": "⁵.5.2", "zone.js": "⁰.8.17" }, "devDependencies": { "@angular/cli": "¹.5.0", "@angular/compiler-cli": "⁵.0.0", "@angular/language-service": "⁴.4.3", ... "typescript": "2.4.2" },

注意,TypeScript 版本中的插入符号和波浪号已被移除,以防止任何意外的升级,因为 Angular 工具对任何给定 TypeScript 发布的特定功能非常敏感。

注意,@angular/cli@angular/compiler-cli 已经更新到它们的最新版本;然而,工具没有更新 @angular/language-service。这突显了手动检查的重要性,因为你的工具链中的每个工具都容易受到小错误的影响。

  1. 通过执行以下命令更新@angular/language-service
$ npm install @angular/language-service@⁵.0.0
  1. 验证package.json中是否有正确的文件版本:
"@angular/language-service": "⁵.0.0",

您已完成更新您的软件包。

  1. 按照指南和本章的更新说明进行操作。

在升级您的 Angular 应用程序后,测试您的更改对性能的影响是一个好主意。

在更新之前和之后测试您的 Angular 应用程序的性能,以确保您的性能数字保持预期。在下面的情况中,由于平台级别的改进,我们自动获得了性能优势。首先,让我们比较一下 Angular v4 和 v5:

类别**Angular 4****Angular 5****% 差异**
JavaScript Assets Delivered (gzipped)83.6 KB72.6 KB13% smaller
首页渲染时间(Fiber)0.57 秒0.54 秒5% 更快
首页渲染时间(快速 3G)1.27 秒1.18 秒7% 更快

Angular 4.4.3 vs 5.0.0

Angular 6 的改进趋势持续下去:

类别**Angular 5****Angular 6****% 差异**
JavaScript Assets Delivered (gzipped)72.6 KB64.1 KB12% 更小
首页渲染时间(Fiber)0.54 秒0.32 秒40% 更快
首页渲染时间(快速 3G)1.18 秒0.93 秒21% 更快

Angular 5.0.0 vs 6.0.0

这种趋势应该在未来的更新中继续,使用 Ivy 渲染引擎的目标大小为 3KB。我们将在第五章中介绍这些性能数字的重要性,使用 Angular Material 增强 Angular 应用程序

有时您会收到关于某些软件包的安全漏洞的通知,通过博客或者如果您使用 GitHub,您可能会在您的存储库上看到这样的警告:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (42)GitHub.com 漏洞扫描

这是一个特定的问题,当我的 Angular 应用程序版本为 5.0.0,我的 CLI 版本为 1.5.0 时出现的。如果您查看这个依赖项,您可以看到依赖的软件包,并获得更多关于这个问题的细节。

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (43)GitHub.com 安全公告

在这种情况下,handlebars 的易受攻击版本 1.3.0 是由 Angular 5.0 包之一引起的。

进一步研究 Angular 的 GitHub 问题表明,问题实际上是由@angular/cli 版本 1.5.0引起的。参考是github.com/angular/angular/issues/20654

这是尝试更新到 Angular、Material 或 CLI 的最新次要版本更新的好方法,在这种情况下是版本 5.1.0 和 1.6.0:

$ npm install @angular/animations@⁵.1.0 @angular/common@⁵.1.0 @angular/compiler@⁵.1.0 @angular/compiler-cli@⁵.1.0 @angular/core@⁵.1.0 @angular/forms@⁵.1.0 @angular/http@⁵.1.0 @angular/platform-browser@⁵.1.0 @angular/platform-browser-dynamic@⁵.1.0 @angular/platform-server@⁵.1.0 @angular/router@⁵.1.0 @angular/language-service@⁵.1.0 @angular/cli@¹.6.0

这次更新解决了 GitHub 显示的安全警告。如果你无法通过升级解决你的问题,请在 GitHub 上创建一个新问题,并密切关注 Angular 的即将发布的补丁或次要版本,直到问题得到解决。

你的堆栈顶部是你托管 Web 应用程序的 Web 服务器。这是一个实时的生产系统,很可能暴露在互联网上,因此风险最大。应该谨慎地保持最新状态。

理想情况下,你的发布流水线类似于第三章,为生产发布准备 Angular 应用程序中描述的流水线,其中你的前端应用程序由一个容器化的低配置实例提供。这可以是我发布和维护的minimal-node-web-server,也可以是基于 Nginx 的实例。在任何情况下,通过更改基础镜像旁边列出的版本号来升级是很简单的:

DockerfileFROM duluca/minimal-node-web-server:8.6.0WORKDIR /usr/src/appCOPY dist public

指定你正在使用的基础 Docker 镜像的版本号总是一个好主意。否则,它将默认为最新行为,这在这种情况下可能意味着一个不适合生产的奇数版本。也就是说,minimal-node-web-server遵循了最佳安全实践的层层叠加,减少了攻击面,使成功攻击你的 Web 应用程序变得非常困难。与这一安全最佳实践主题一致,minimal-node-web-server永远不会将奇数节点版本作为默认行为。

如果你的内容是通过 IIS、Apache 或 Tomcat 等 Web 服务器安装提供的,你必须遵循和跟踪这些技术的安全公告。然而,很可能另一个人或另一个部门将负责升级这台服务器,这可能会导致从几天到几个月的延迟,这在互联网时间中是永远的。

你处于最高风险,如果你通过同一应用服务器提供静态网页内容,比如你的 SPA,同时也实现了后端 API。即使你的架构可能是解耦的,如果在你的依赖树中升级任何工具或应用程序对你的应用的任何其他部分产生副作用,这意味着你在保护或改进前端应用性能方面存在重大摩擦。

一个真正解耦的架构还将允许前端以不同的速度扩展,而不同于你的后端基础设施,这可以带来巨大的成本效益。例如,假设你的前端提供大量静态信息,并且很少需要轮询后端。在高负载时,你可能需要三个前端服务器实例来处理所有请求,但只需要一个后端服务器实例,因为调用很少。

在升级应用程序及其依赖项或简单添加新功能后,您需要更新并发布新的 Docker 镜像。

  1. package.json中,将版本属性更新为1.1.0或将您的版本与当前的 Angular 版本匹配

  2. 执行npm run docker:debug来构建并验证您的更新是否正确工作

  3. 最后,执行npm run docker:publish将您的新镜像推送到存储库

在发布图像后,采取必要步骤将图像部署到服务器或云提供商,如第三章中所述,准备 Angular 应用程序进行生产发布,以及第十一章中所述,AWS 上高可用云基础设施

在本章中,我们讨论了保持整个依赖栈的最新状态的重要性,从 Node 和 npm 等开发工具到 Angular。我们看了看如何使用 ng update 和 Angular Update Guide 来尽可能地减少 Angular 更新的痛苦。我们还涵盖了手动更新、性能测试、处理超出安全漏洞和补丁的问题,包括保持 Web 服务器最新的必要性。保持相对最新的系统具有直接的成本效益。差距越小,维护的工作量就越小。然而,随着时间的推移,升级系统的成本呈指数级增长。作为非直接的好处,我们可以列举出由更好的性能带来的客户满意度,这是影响亚马逊等公司数百万美元的指标。工具中的新功能也对开发人员的生产力和幸福感产生深远影响,这有助于留住人才,减少新功能的成本,从而可能提高客户满意度。保持最新状态无疑是一个积极的反馈循环。

在下一章中,我们将讨论如何通过将 Angular Material 添加到项目中,使您的本地天气应用程序看起来更加出色。在这个过程中,您将了解用户控制或 UI 组件库可能对应用程序产生的负面性能影响,包括基本的 Material 组件、Angular Flex 布局、可访问性、排版、主题设置以及如何更新 Angular Material。

在第三章,为生产发布准备 Angular 应用中,我们提到了提供高质量应用程序的需求。目前,该应用程序的外观和感觉非常糟糕,只适用于上世纪 90 年代创建的网站。用户或客户对您的产品或工作的第一印象非常重要,因此我们必须能够创建一个外观出色且在移动和桌面浏览器上提供出色用户体验的应用程序。

作为全栈开发人员,很难专注于应用程序的完善。随着应用程序功能集的迅速增长,情况会变得更糟。编写支持视图的优秀且模块化的代码很有趣,但在匆忙中退回到 CSS hack 和内联样式来改进应用程序是没有乐趣的。

Angular Material 是一个与 Angular 密切协调开发的令人惊叹的库。如果您学会如何有效地利用 Angular Material,您创建的功能将从一开始就看起来和运行得很好,无论您是在小型还是大型应用程序上工作。Angular Material 将使您成为一个更有效的 Web 开发人员,因为它附带了各种您可以利用的用户控件,并且您不必担心浏览器兼容性。作为额外的奖励,编写自定义 CSS 将变得罕见。

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

  • 如何配置 Angular Material

  • 使用 Angular Material 升级 UX

Angular Material 项目的目标是提供一系列有用且标准的高质量用户界面(UI)组件。该库实现了谷歌的 Material Design 规范,在谷歌的移动应用程序、网络属性和 Android 操作系统中普遍存在。Material Design 确实具有特定的数字和盒状外观和感觉,但它不仅仅是另一个 CSS 库,就像 Bootstrap 一样。考虑在此处使用 Bootstrap 编码的登录体验:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (44)Bootstrap 登录体验

请注意,输入字段及其标签位于不同的行上,复选框是一个小目标,错误消息显示为短暂的弹出通知,提交按钮只是坐落在角落里。现在考虑给定的 Angular Material 示例:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (45)Angular Material 登录体验

输入字段及其标签最初是组合在一起的,以紧凑的形式吸引用户的注意力。复选框对触摸友好,提交按钮会拉伸以占用可用空间,以获得更好的默认响应式用户体验。一旦用户点击字段,标签就会收起到输入字段的左上角,如图所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (46)Angular Material 动画和错误

此外,验证错误消息会内联显示,并与标签颜色变化结合,使用户注意力集中在输入字段上。

Material Design 帮助您设计具有自己品牌和样式的模块化 UI,同时定义动画,使用户在使用您的应用程序时拥有更好的用户体验(UX)。人类大脑下意识地跟踪对象及其位置。任何帮助过渡或由人类输入引起的反应的动画都会减少用户的认知负担,因此允许用户专注于处理内容,而不是试图弄清您特定应用程序的怪癖。

模块化 UI 设计和流畅的动作的结合创造了出色的用户体验。看看 Angular Material 如何实现一个简单的按钮。

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (47)Angular Material 按钮动画

在上面的截图中,请注意按钮上的点击动画是从用户实际点击的位置开始的。然而微妙,这创造了一种连续的动作,导致了对给定动作的适当反应。当按钮在移动设备上使用时,这种特效变得更加明显,从而导致更加自然的人机交互。大多数用户无法表达什么使直观的用户体验实际上直观,设计和体验中的这些微妙但至关重要的线索在允许您为用户设计这样的体验方面取得了巨大进步。

Angular Material 还旨在成为 Angular 高质量 UI 组件的参考实现。如果您打算开发自定义控件,Angular Material 的源代码应该是您首要的资源。术语“高质量”经常被使用,量化其含义非常重要。Angular Material 团队在他们的网站上恰当地表达了这一点。

我们所说的“高质量”是什么意思?

国际化和可访问性,以便所有用户都可以使用它们。简单直观的 API,不会让开发人员困惑,并且在各种用例中表现如预期,没有错误。行为经过充分的单元测试和集成测试。在 Material Design 规范的范围内可定制。性能成本最小化。代码清晰,有文档,可以作为 Angular 开发人员的示例。浏览器和屏幕阅读器支持。

Angular Material 支持所有主要浏览器的最近两个版本:Chrome(包括 Android)、Firefox、Safari(包括 iOS)和 IE11 / Edge。

构建 Web 应用程序,特别是那些也兼容移动设备的应用程序,确实很困难。有很多细微之处需要注意。Angular Material 将这些细微之处抽象出来,包括支持所有主要浏览器,这样您就可以专注于创建您的应用程序。Angular Material 不是一时的潮流,也不应轻视。如果使用正确,您可以大大提高生产率和工作质量的感知。

在您的项目中,不一定总是能够使用 Angular Material。我建议使用 PrimeNG(www.primefaces.org/primeng)或 Clarity(vmware.github.io/clarity)作为组件工具包,可以满足您大部分,如果不是全部,用户控制需求。要避免的一件事是从不同来源获取大量用户控件,最终得到一个杂乱的库,其中有数百个怪癖和错误需要学习、维护或解决。

Angular Material 默认配置为优化最终交付的包大小。在 Angular.JS 和 Angular Material 1.x 中,将加载整个依赖库。然而,在 Angular Material 6 中,我们能够指定我们打算使用的组件,从而实现显著的性能改进。

在下表中,您可以看到典型的 Angular 1.x + Angular Material 1.x 与 Angular 6 + Material 6 应用程序在高速低延迟的光纤连接下性能特征的改进:

光纤网络Angular 6 + Material 6Angular 1.5 + Material 1.1.5% 差异
首页渲染时间*0.61 秒1.69 秒**~2.8 倍更快
基本级别资产交付*113 KB1,425 KB缩小 12.6 倍

*图像或其他媒体内容未包含在结果中,以进行公平比较

*平均值:较低质量的基础设施导致初始渲染时间为 0.9 到 2.5 秒

在高速低延迟连接的理想条件下,Angular 6 + Material 6 应用程序在一秒内加载。然而,当我们切换到更常见的中等速度和高延迟的快速 3G 移动网络时,差异变得更加明显,如下表所示:

快速 3G 移动网络Angular 6 + Material 6Angular 1.5 + Material 1.1.5**% 差异**
首页渲染时间*1.94 秒11.02 秒5.7 倍更快
基本级别资产交付*113 KB1,425 KB缩小 12.6 倍

*图像或其他媒体内容未包含在结果中,以进行公平比较

尽管应用程序的大小差异保持一致,但您可以看到移动网络引入的额外延迟导致传统的 Angular 应用程序速度显着下降到不可接受的水平。

将所有组件添加到 Material 6 将导致约 1.3 MB 的额外负载需要传递给用户。正如您可以从之前的比较中看到的,这必须以任何代价避免。为了提供可能最小的应用程序,尤其是在移动和与销售相关的场景中,每 100 毫秒的加载时间对用户保留都有影响,您可以逐个加载和包含模块。Webpack 的摇树过程将模块分成不同的文件,从而减少初始下载大小。在未来的构建中,预计 Angular 的大小将进一步缩小,可能会减少上表中提到的大小一半。

让我们开始任务,并使用 Angular Material 改进天气应用程序的用户体验。让我们将改进应用程序用户体验的任务移动到我们的 Waffle.io 看板上的进行中。在这里,您可以看到我的看板的状态:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (48)Waffle.io 看板

在 Angular 6 中,您可以自动将 Angular Material 添加到您的项目中,从而在过程中节省大量时间:

  1. 执行add命令,如下所示:
$ npx ng add @angular/materialInstalling packages for tooling via npm.+ @angular/material@6.0.1added 1 package in 15.644sInstalled packages for tooling via npm.UPDATE package.json (1381 bytes)UPDATE angular.json (3694 bytes)UPDATE src/app/app.module.ts (502 bytes)UPDATE src/index.html (474 bytes)UPDATE node_modules/@angular/material/prebuilt-themes/indigo-pink.css (56678 bytes)added 1 package in 13.031s

请注意,index.html文件已经被修改以添加图标库和默认字体,如下所示:

src/index.html<head> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" rel="stylesheet"> ...</head>

还要注意app.module.ts已更新以导入BrowserAnimationsModule,如下所示:

src/app/app.module.tsimport { BrowserAnimationsModule } from '@angular/platform-browser/animations';@NgModule({ declarations: [ AppComponent ], imports: [ ... BrowserAnimationsModule ],
  1. 启动您的应用程序并确保它能正常工作:
$ npm start

有了这个,你就完成了。您的应用程序应该已配置为使用 Angular Material。重要的是要了解组成 Angular Material 的各种组件;在接下来的章节中,我们将介绍手动安装和配置步骤。您可以跳到Angular Flex Layout部分,但我强烈建议浏览一下手动步骤,因为我介绍了创建一个 Angular 模块来组织您的 Material 模块的概念。

我们将从安装所有必需的库开始。从 Angular 5 开始,Angular Material 的主要版本应该与您的 Angular 安装版本匹配,而在 Angular 6 中,版本应该同步:

  1. 在终端中,执行npm install @angular/material @angular/cdk @angular/animationshammerjs

  2. 观察package.json版本:

package.json "dependencies": { "@angular/animations": "6.0.0", "@angular/cdk": "6.0.0", "@angular/material": "6.0.0", "hammerjs": "².0.8", ...

在这种情况下,所有库的主要和次要版本都是 5.0。如果您的主要和次要版本不匹配,您可以重新运行npm install命令以安装特定版本,或者选择通过将包的 semver 版本附加到安装命令来升级您的 Angular 版本:

$ npm install @angular/material@6.0.0 @angular/cdk@6.0.0 @angular/animations@6.0.0

如果您使用类似 Bash 的 shell,可以使用括号语法来节省一些输入,以避免重复命令的部分,比如npm install @angular/{material,cdk,animations}@6.0.0

如果您需要更新 Angular 的版本,请参考第四章中的更新 Angular部分,保持与 Angular 更新同步

让我们看看我们究竟安装了什么:

  • @angular/material是官方的 Material 2 库。

  • @angular/cdk是一个对等依赖项,除非您打算构建自己的组件,否则不会直接使用它。

  • @angular/animations启用了一些 Material 2 模块的动画。可以省略它以保持应用程序的大小最小。您可以使用NoopAnimationsModule来禁用需要此依赖项的模块中的动画。结果,您将失去一些 Angular Material 的 UX 优势。

  • hammerjs启用了手势支持;如果您的目标是任何触摸设备,不仅仅是手机和平板电脑,还包括混合式笔记本电脑,这一点非常重要。

现在依赖项已安装,让我们在 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中。

  1. 观察新创建的文件material.module.ts
src/app/material.module.tsimport { NgModule } from '@angular/core'import { CommonModule } from '@angular/common'@NgModule({ imports: [CommonModule], declarations: [],})export class MaterialModule {}
  1. 确保该模块已被导入到app.module.ts中:
src/app/app.module.tsimport { MaterialModule } from './material.module'... @NgModule({ ... imports: [..., MaterialModule],}
  1. 添加动画和手势支持(可选,但对移动设备支持必要):
src/app/app.module.tsimport 'hammerjs'import { BrowserAnimationsModule } from '@angular/platform-browser/animations'@NgModule({ ... imports: [..., MaterialModule, BrowserAnimationsModule],}
  1. 修改material.module.ts以导入按钮、工具栏和图标的基本组件

  2. 移除CommonModule

src/app/material.module.tsimport { MatButtonModule, MatToolbarModule, MatIconModule } from '@angular/material'import { NgModule } from '@angular/core'@NgModule({ imports: [MatButtonModule, MatToolbarModule, MatIconModule], exports: [MatButtonModule, MatToolbarModule, MatIconModule],})export class MaterialModule {}

Material 现在已导入到应用程序中,现在让我们配置一个主题并将必要的 CSS 添加到我们的应用程序中。

为了使用 Material 组件,需要一个基本主题。我们可以在angular.json中定义或更改默认主题:

angular.json... "styles": [ { "input": "node_modules/@angular/material/prebuilt-themes/indigo-pink.css" }, "src/styles.css"],...
  1. 从这里选择一个新选项:
  • deeppurple-amber.css

  • indigo-pink.css

  • pink-bluegrey.css

  • purple-green.css

  1. 更新angular.json以使用新的 Material 主题

您也可以创建自己的主题,这在本章的自定义主题部分有介绍。有关更多信息,请访问material.angular.io/guide/theming

请注意,styles.css中实现的任何 CSS 将在整个应用程序中全局可用。也就是说,不要在此文件中包含特定于视图的 CSS。每个组件都有自己的 CSS 文件用于此目的。

通过将 Material 图标 Web 字体添加到应用程序中,您可以访问一个很好的默认图标集。这个库大小为 48 kb,非常轻量级。

  • 对于图标支持,请在index.html中导入字体:
src/index.html<head> ... <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"></head>

www.google.com/design/icons/上发现并搜索图标。

要获得更丰富的图标集,请访问MaterialDesignIcons.com。这个图标集包含了 Material 图标的基本集,以及丰富的第三方图标,包括来自社交媒体网站的有用图像,以及涵盖了很多领域的丰富的操作。这个字体大小为 118 kb。

在您可以有效使用 Material 之前,您必须了解其布局引擎。如果您已经做了一段时间的 Web 开发,您可能遇到过 Bootstrap 的 12 列布局系统。这对我大脑以 100%的方式分配事物的数学障碍。Bootstrap 还要求严格遵守 div 列、div 行的层次结构,必须从顶层 HTML 精确管理到底部。这可能会导致非常沮丧的开发体验。在下面的截图中,您可以看到 Bootstrap 的 12 列方案是什么样子的:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (49)Bootstrap 的 12 列布局方案

Bootstrap 的自定义网格布局系统在当时是革命性的,但随后 CSS3 Flexbox 出现了。结合媒体查询,这两种技术允许创建响应式用户界面。然而,有效地利用这些技术是非常费力的。从 Angular v4.1 开始,Angular 团队推出了其 Flex 布局系统,它可以正常工作。

GitHub 上的 Angular Flex Layout 文档恰如其分地解释了如下内容:

Angular Flex Layout 提供了一个复杂的布局 API,使用 FlexBox CSS + mediaQuery。这个模块为 Angular(v4.1 及更高版本)开发人员提供了使用自定义布局 API、mediaQuery observables 和注入的 DOM flexbox-2016 CSS 样式的组件布局功能。

Angular 的出色实现使得使用 FlexBox 非常容易。正如文档进一步解释的那样:

布局引擎智能地自动应用适当的 FlexBox CSS 到浏览器视图层次结构。这种自动化还解决了许多传统的、手动的、仅使用 Flexbox CSS 的应用程序所遇到的复杂性和解决方法。

该库非常强大,可以容纳您能想象到的任何类型的网格布局,包括与您可能期望的所有 CSS 功能的集成,比如calc()函数。在下图中,您可以看到如何使用 CSS Flexbox 描述列:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (50)Angular Flex Layout 方案

令人振奋的消息是,Angular Flex 布局与 Angular Material 没有任何耦合,并且可以独立使用。这是非常重要的解耦,解决了使用 AngularJS 与 Material v1 时的一个主要痛点,其中 Material 的版本更新经常会导致布局中的错误。

更多详情,请查看:github.com/angular/flex-layout/wiki

在发布时,@angular/flex-layout还没有发布稳定版本。该项目的 GitHub 活动表明,稳定版本将与 Angular 6 的发布同步。此外,CSS Grid 有望取代 CSS Flexbox,因此,该库使用的基础技术可能会发生变化。我希望这个库作为布局引擎的抽象层。

您设计和构建的所有 UI 都应该是面向移动设备的 UI。这不仅仅是为了服务于手机浏览器,还包括笔记本电脑用户可能会将您的应用与其他应用并排使用的情况。要正确实现移动设备优先设计有许多微妙之处。

以下是Mozilla 圣杯布局,它演示了“根据不同屏幕分辨率动态更改布局的能力”,同时优化移动设备的显示内容。

您可以在mzl.la/2vvxj25了解有关 Flexbox 基本概念的更多信息。

这是大屏幕上 UI 外观的表示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (51)Mozilla 大屏幕上的圣杯布局

同样的布局在小屏幕上表示如下:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (52)Mozilla 小屏幕上的圣杯布局

Mozilla 的参考实现需要 85 行代码来完成这种响应式 UI。Angular Flex 布局只需一半的代码就能完成同样的任务。

让我们安装并将 Angular Flex 布局添加到我们的项目中:

  1. 在终端中,执行npm i @angular/flex-layout

在发布时,@angular/flex-layout的当前版本是5.0.0-beta.14,这会导致许多对等依赖错误。为了避免这些错误,请执行npm i @angular/flex-layout@next来安装版本6.0.0-beta.15,如第四章中所述,与 Angular 更新保持最新

  1. 更新app.module.ts,如下所示:
src/app.module.tsimport { FlexLayoutModule } from '@angular/flex-layout'imports: [... FlexLayoutModule,],

Bootstrap 和 CSS FlexBox 与 Angular Flex 布局是不同的东西。如果你学会了 Angular Flex 布局,你将编写更少的布局代码,因为 Angular Material 大多数时候会自动做正确的事情,但是一旦你意识到一旦你离开 Angular Flex 布局的保护茧,你将不得不写更多的代码来让事情运转起来,你会感到失望。然而,你的技能仍然会转化,因为概念基本上是相同的。

让我们在接下来的部分中回顾一下 Flex 布局 API。

这些指令可以用在诸如<div><span>之类的 DOM 容器上,比如<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="15px">...</div>

HTML API允许的值
fxLayout | Use:row | column | row-reverse | column-reverse

| fxLayoutAlign | main-axis:start |center | end | space-around | space-between

cross-axis:start | center | end | stretch |

fxLayoutGap% | px | vw | vh

这些指令影响 DOM 元素在其容器中的行为,比如<div fxLayout="column"><input fxFlex /></div>

HTML API允许的值
fxFlex"" | px | % | vw | vh |
fxFlexOrderint
fxFlexOffset% | px | vw | vh
fxFlexAlignstart | baseline | center | end
fxFlexFillnone

以下指令可以应用于任何 HTML 元素,以显示、隐藏或更改所述元素的外观和感觉,比如<div fxShow fxHide.lt-sm></div>,它会显示一个元素,除非屏幕尺寸小于小屏幕:

HTML API允许的值
fxHideTRUE | FALSE | 0 | ""
fxShowTRUE | FALSE | 0 | ""
ngClass@extendsngClasscore
ngStyle@extendsngStylecore

本节介绍了静态布局的基础知识。您可以在github.com/angular/flex-layout/wiki/Declarative-API-Overview上阅读更多关于静态 API 的信息。我们将在第十章,Angular 应用程序设计和技巧中介绍响应式 API。您可以在github.com/angular/flex-layout/wiki/Responsive-API上阅读更多关于响应式 API 的信息。

现在我们已经安装了各种依赖项,我们可以开始修改我们的 Angular 应用程序以添加 Material 组件。我们将添加一个工具栏,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 原理图承诺使向您的 Angular 应用程序添加各种 Material 模块和组件变得不那么繁琐;然而,如提供的那样,这些原理图并不适用于创建灵活、可扩展和良好架构的代码库,正如本书所追求的那样。

目前,我建议将这些原理图用于快速原型设计或实验目的。

现在,让我们开始手动向 LocalCast Weather 添加一些组件。

在我们开始对 app.component.ts 进行进一步更改之前,让我们将组件切换为使用内联模板和内联样式,这样我们就不必在相对简单的组件中来回切换文件。

  1. 更新 app.component.ts 以使用内联模板

  2. 移除 app.component.htmlapp.component.css

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 {}

让我们通过实现一个全局工具栏来改进我们的应用:

  1. 观察 app.component.ts 中的 h1 标签:
**src/app/app.component.ts**<h1> LocalCast Weather </h1>
  1. 使用 mat-toolbar 更新 h1 标签:
src/app/app.component.ts <mat-toolbar> <span>LocalCast Weather</span></mat-toolbar>
  1. 观察结果;您应该看到一个工具栏,如图所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (53) 本地天气工具栏

  1. 使用更引人注目的颜色更新 mat-toolbar
src/app/app.component.ts <mat-toolbar color="primary">

为了更加原生的感觉,工具栏与浏览器的边缘接触是很重要的。这在大屏和小屏格式上都很有效。此外,当您将可点击的元素(如汉堡菜单或帮助按钮)放在工具栏的最左侧或最右侧时,您将避免用户点击空白空间的可能性。这就是为什么 Material 按钮实际上具有比视觉表示更大的点击区域。这在打造无挫折的用户体验方面有很大的不同:

src/styles.cssbody { margin: 0;}

这对于这个应用来说并不适用,但是,如果您正在构建一个密集的应用程序,您会注意到您的内容将一直延伸到应用程序的边缘,这并不是一个理想的结果。考虑将您的内容区域包裹在一个 div 中,并使用 css 应用适当的边距,如下所示:

src/styles.css.content-margin { margin-left: 8px; margin-right: 8px;}

在下一个截图中,您可以看到应用了主色的边到边工具栏:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (54) 带有改进工具栏的本地天气

Material 卡片是一个很好的容器,用来表示当前的天气信息。卡片元素被一个投影阴影所包围,将内容与周围区域分隔开来:

  1. material.module 中导入 MatCardModule
src/app/material.module.tsimport { ..., MatCardModule} from '@angular/material'...@NgModule({ imports: [..., MatCardModule], exports: [..., MatCardModule],})
  1. app.component 中用 <mat-card> 包围 <app-current-weather>
src/app/app.component.ts <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>
  1. 观察如图所示的几乎无法区分的卡片元素:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (55) 带有不可区分卡片的本地天气

为了更好地布局屏幕,我们需要切换到 Flex 布局引擎。首先从组件模板中移除训练轮:

  1. 从周围的 <div> 中移除 style="text-align:center"

要在页面中心放置一个元素,我们需要创建一行,为中心元素分配一个宽度,并在两侧创建两个额外的列,这些列可以灵活地占据空白空间,如下所示:

src/app/app.component.ts<div fxLayout="row"> <div fxFlex></div> <div fxFlex="300px"> ... </div> <div fxFlex></div></div>
  1. 用前面的 HTML 包围<mat-card>

  2. 注意卡片元素已正确居中,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (56)带居中卡片的 LocalCast 天气

阅读卡片文档,并查看 Material 文档站点上的示例,您会注意到mat-card提供了容纳标题和内容的元素。我们将在接下来的部分中实现这一点。

material.angular.io上,您可以通过单击括号图标查看任何示例的源代码,或者通过单击箭头图标在 Plunker 中启动一个可工作的示例。

利用这样的 Material 特性可能会感觉不必要;然而,在设计应用程序时,您必须考虑响应性、样式、间距和可访问性问题。Material 团队已经付出了很多努力,以便您的代码在大多数情况下能够正确运行,并为尽可能多的用户群提供高质量的用户体验。这可能包括视力受损或键盘主导用户,他们必须依赖专门的软件或键盘功能(如标签)来浏览您的应用程序。利用 Material 元素为这些用户提供了关键的元数据,使他们能够浏览您的应用程序。

Material 声称支持以下屏幕阅读器软件:

  • Windows 上的 IE / FF / Chrome 上的 NVDA 和 JAWS

  • iOS 上的 Safari 和 Safari / Chrome 上的 VoiceOver

  • Android 上的 Chrome TalkBack

现在,让我们实现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> <mat-card fxFlex="300px"> <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 fxFlex></div></div>

在 Material 中,少即是多。您会注意到我们能够删除中心的div,并直接在中心卡上应用fxFlex。所有 Material 元素都原生支持 Flex 布局引擎,这在复杂的 UI 中具有巨大的积极维护影响。

应用mat-card-header后,您可以看到这个结果:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (57)带标题和内容的 LocalCast 天气卡

请注意,卡片内的字体现在与 Material 的 Roboto 字体匹配。然而,Current Weather 不再像以前那样引人注目。如果你在 mat-card-title 内部添加回 h2 标签,Current Weather 在视觉上会显得更大;然而,字体将不再与你的应用程序的其余部分匹配。要解决这个问题,你必须了解 Material 的排版特性。

Material 的文档恰如其分地将其表述如下:

排版是一种排列字体的方式,使文本在显示时易于辨认、可读和吸引人。

Material 提供了不同级别的排版,具有不同的字体大小、行高和字重特性,你可以应用到任何 HTML 元素上,而不仅仅是提供的组件。

在下表中是你可以使用的 CSS 类,用于应用 Material 的排版,比如 <div class="mat-display-4">Hello, Material world!</div>

类名用法
display-4, display-3, display-2display-1大的、一次性的标题,通常位于页面顶部(例如,主标题)
headline对应 <h1> 标签的章节标题
title对应 <h2> 标签的章节标题
subheading-2对应 <h3> 标签的章节标题
subheading-1对应 <h4> 标签的章节标题
body-1基本正文文本
body-2更粗的正文文本
caption较小的正文和提示文本
button按钮和锚点

你可以在 material.angular.io/guide/typography 阅读更多关于 Material 排版的信息。

有多种方式可以应用排版。一种方式是利用 mat-typography 类,并使用相应的 HTML 标签如 <h2>

src/app/app.component.ts <mat-card-header class="mat-typography"> <mat-card-title><h2>Current Weather</h2></mat-card-title></mat-card-header>

另一种方式是直接在元素上应用特定的排版,比如 class="mat-title"

src/app/app.component.ts <mat-card-title><div class="mat-title">Current Weather</div></mat-card-title>

请注意,class="mat-title" 可以应用到 divspan 或带有相同结果的 h2 上。

作为一个一般的经验法则,通常更好的做法是实现更具体和局部化的选项,即第二种实现方式。

我们可以使用 fxLayoutAlign 居中应用程序的标语,并给它一个柔和的 mat-caption 排版,如下所示:

  1. 实现布局更改和标题排版:
**src/app/app.component.ts** <div fxLayoutAlign="center"> <div class="mat-caption">Your city, your forecast, right now!</div></div>
  1. 观察结果,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (58)LocalCast 天气中心标语居中

仍然有更多工作要做,以使 UI 看起来像设计,特别是当前天气卡片的内容,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (59)

为了设计布局,我们将利用 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 <div> 3 <span>{{current.city}}, {{current.country}}</span> 4 <span>{{current.date | date:'fullDate'}}</span> 5 </div> 6 <div> 7 <img [src]='current.image'> 8 <span>{{current.temperature | number:'1.0-0'}}℉</span> 9 </div>10 <div>11 {{current.description}}12 </div>13 </div>

让我们逐步通过文件并更新它:

  1. 将第 3、4 和 8 行的<span>元素更新为<div>

  2. <div>包装<img>元素

  3. 在第 2 行和第 6 行有多个子元素的<div>元素上添加fxLayout="row"属性

  4. 城市和国家列大约占据了屏幕的 2/3,因此在第 3 行的<div>元素上添加fxFlex="66%"

  5. 在第 4 行的下一个<div>元素上添加fxFlex,以确保它占据其余的水平空间

  6. 在新的<div>元素周围添加fxFlex="66%",以包围<img>元素

  7. 在第 4 行的下一个<div>元素上添加fxFlex

模板的最终状态应该如下所示:

 src/app/current-weather/current-weather.component.html 1 <div *ngIf="current"> 2 <div fxLayout="row"> 3 <div fxFlex="66%">{{current.city}}, {{current.country}}</div> 4 <div fxFlex>{{current.date | date:'fullDate'}}</div> 5 </div> 6 <div fxLayout="row"> 7 <div fxFlex="66%"> 8 <img [src]='current.image'> 9 </div>10 <div fxFlex>{{current.temperature | number:'1.0-0'}}℉</div>11 </div>12 <div>13 {{current.description}}14 </div>15 </div>

您可以在添加 Angular Flex 属性时更详细; 但是,您写的代码越多,将来需要维护的内容就越多,这会使未来的更改变得更加困难。例如,第 12 行的<div>元素不需要fxLayout="row",因为<div>隐式地换行。同样,在第 4 行和第 7 行,右侧列不需要显式的fxFlex属性,因为它将自动被左侧元素挤压。

从网格放置的角度来看,所有元素现在都在正确的单元格中,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (60)带有布局脚手架的 LocalCast 天气

现在,我们需要对齐和样式化每个单独的单元格以匹配设计。日期和温度需要右对齐,描述需要居中:

  1. 要右对齐日期和温度,请在current-weather.component.css中创建一个名为.right的新 css 类:
src/app/current-weather/current-weather.component.css.right { text-align: right}
  1. 在第 4 行和第 10 行的<div>元素中添加class="right"

  2. 以与之前章节中应用标语居中的方式居中<div>元素的描述

  3. 观察元素是否正确对齐,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (61)具有正确对齐的 LocalCast 天气

最终确定元素的样式通常是前端开发中最耗时的部分。我建议进行多次尝试,首先以最小的努力实现设计的足够接近版本,然后让您的客户或团队决定是否值得额外投入更多时间来完善设计:

  1. 添加一个新的 css 属性:
src/app/current-weather/current-weather.component.css.no-margin { margin-bottom: 0}
  1. 对于城市名称,在第 3 行,添加'class="mat-title no-margin"'

  2. 对于日期,在第 4 行,添加"mat-subheading-2 no-margin"到'class="right"'

  3. 将日期格式从'fullDate'更改为'EEEE MMM d'以匹配设计

  4. 修改<img>,在第 8 行添加style="zoom: 175%"

  5. 对于温度,在第 10 行,追加"mat-display-3 no-margin"

  6. 对于描述,在第 12 行,添加'class="mat-caption"'

这是模板的最终状态:

src/app/current-weather/current-weather.component.html<div *ngIf="current"> <div fxLayout="row"> <div fxFlex="66%" class="mat-title no-margin">{{current.city}}, {{current.country}}</div> <div fxFlex class="right mat-subheading-2 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'}}℉</div> </div> <div fxLayoutAlign="center" class="mat-caption"> {{current.description}} </div></div>
  1. 观察您的代码的样式化输出如何改变,如图所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (62)带有样式的 LocalCast 天气

标语可以从顶部和底部边距中受益。这是我们可能会在整个应用程序中使用的常见 CSS,所以让我们把它放在'styles.css'中:

  1. 实现'vertical-margin':
src/styles.css.vertical-margin { margin-top: 16px; margin-bottom: 16px;}
  1. 应用'vertical-margin':
src/app/app.component.ts<div class="mat-caption vertical-margin">Your city, your forecast, right now!</div>

当前天气与城市名称具有相同的样式;我们需要区分这两者。

  1. 在'app.component.ts'中,使用'mat-headline'排版更新当前天气:
src/app/app.component.ts<mat-card-title><div class="mat-headline">Current Weather</div></mat-card-title>
  1. 图像和温度没有居中,因此在第 6 行的围绕这些元素的行中添加'fxLayoutAlign="center center"':
src/app/current-weather/current-weather.component.html<div fxLayout="row" fxLayoutAlign="center center">
  1. 观察您的应用程序的最终设计,应该是这样的:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (63)LocalCast 天气最终设计

这是一个你可能会花费大量时间的领域。如果我们遵循 80-20 原则,像素完美的微调通常最终成为需要花费 80%的时间来完成的最后 20%。让我们来看看我们的实现与设计之间的差异以及弥合差距需要付出的努力:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (64)

日期需要进一步定制。缺少数字序数th; 为了实现这一点,我们需要引入第三方库,如 moment,或者实现我们自己的解决方案,并将其绑定到模板上的日期旁边:

  1. 更新'current.date'以附加序数:
src/app/current-weather/current-weather.component.html{{current.date | date:'EEEE MMM d'}}{{getOrdinal(current.date)}}
  1. 实现一个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 &amp;&amp; n < 21) || n % 10 > 3 ? 0 : n % 10] : '' } ...}

请注意,getOrdinal的实现归结为一个复杂的一行代码,不太可读,很难维护。如果这样的函数对您的业务逻辑至关重要,应该进行大量的单元测试。

在撰写本文时,Angular 6 不支持日期模板中的新行换行;理想情况下,我们应该能够将日期格式指定为'EEEE\nMMM d',以确保换行始终保持一致。

温度的实现需要使用<span>元素将数字与单位分开,用<p>包围,以便可以将上标样式应用到单位,例如<span class="unit">℉</span>,其中 unit 是一个 CSS 类,使其看起来像一个上标元素。

  1. 实现一个unit CSS 类:
src/app/current-weather/current-weather.component.css.unit { vertical-align: super;}
  1. 应用unit
src/app/current-weather/current-weather.component.html... 7 <div fxFlex="55%">...10 <div fxFlex class="right no-margin">11 <p class="mat-display-3">{{current.temperature | number:'1.0-0'}}12 <span class="mat-display-1 unit">℉</span>13 </p>

我们需要通过调整第 7 行的fxFlex值来实验预报图像应该有多少空间。否则,温度会溢出到下一行,并且您的设置还会受到浏览器窗口大小的影响。例如,60%在小浏览器窗口下效果很好,但当最大化时会导致溢出。然而,55%似乎满足了两个条件:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (65)调整后的 LocalCast 天气

与往常一样,可以进一步调整边距和填充以进一步定制设计。然而,每一次偏离库都会在以后产生可维护性后果。除非您真的要围绕显示天气数据构建业务,否则应该在项目结束时推迟任何进一步的优化,如果时间允许,如果经验是任何指导,您将不会进行这种优化。

通过两个负的 margin-bottom hack,你可以获得一个与原始设计非常接近的设计,但我不会在这里包含这些 hack,而是留给读者在 GitHub 存储库中发现。这些 hack 有时是必要的恶,但总的来说,它们指向设计和实现现实之间的脱节。在调整部分之前的解决方案是甜蜜点,Angular Material 在这里蓬勃发展:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (66)调整和 hack 后的 LocalCast 天气

为了保持您的单元测试运行,您需要将MaterialModule导入到任何使用 Angular Material 的组件的spec文件中:

*.component.spec.ts... beforeEach( async(() => { TestBed.configureTestingModule({ ... imports: [..., MaterialModule, NoopAnimationsModule], }).compileComponents() }) )

你还需要更新任何测试,包括 e2e 测试,以搜索特定的 HTML 元素。

例如,由于应用程序的标题 LocalCast Weather 不再在h1标签中,你必须更新spec文件,以在span元素中查找它:

src/app/app.component.spec.tsexpect(compiled.querySelector('span').textContent).toContain('LocalCast Weather')

同样,在 e2e 测试中,你需要更新你的页面对象函数,以从正确的位置检索文本:

e2e/app.po.tsgetParagraphText() { return element(by.css('app-root mat-toolbar span')).getText()}

正如我们之前讨论的,Material 默认提供了一些默认主题,如深紫色-琥珀色、蓝紫色-粉色、粉色-蓝灰色和紫色-绿色。然而,你的公司或产品可能有自己的配色方案。为此,你可以创建一个自定义主题,改变你的应用程序的外观。

为了创建一个新的主题,你必须实现一个新的 scss 文件:

  1. src下创建一个名为localcast-theme.scss的新文件

  2. Material 主题指南,位于material.angular.io/guide/theming,包括一个最新的起始文件。我将进一步解释文件的内容

  3. 首先包含基础主题库:

src/localcast-theme.scss@import '~@angular/material/theming';
  1. 导入mat-core() mixin,其中包括各种 Material 组件使用的所有通用样式:
src/localcast-theme.scss@include mat-core();

mat-core()应该只在你的应用程序中包含一次;否则,你将在应用程序中引入不必要和重复的 css 负载。

mat-core()包含必要的 scss 函数,可以将自定义颜色注入到 Material 中,例如 mat-palette、mat-light-theme 和 mat-dark-theme。

至少,我们必须定义一个新的主色和一个强调色。然而,定义新的颜色并不是一个简单的过程。Material 需要定义一个调色板,mat-palette,它需要由一个复杂的颜色对象种子化,不能简单地被一个简单的十六进制值如#BFB900所覆盖。

要选择你的颜色,可以使用位于material.io/color的 Material Design Color Tool。这是工具的截图:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (67)Material.io 颜色工具

  1. 使用 Material Palette,选择一个主色和一个次要颜色:
  • 我的主要选择是红色,色调值为500

  • 我的次要选择是蓝紫色,色调值为A400

  1. 通过浏览左侧的 6 个预构建屏幕,观察你的选择如何应用到 Material 设计应用程序

  2. 评估你的选择对可访问性的影响,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (68)Material.io 颜色工具可访问性选项卡该工具警告我们,我们的选择导致不合格的文本,当白色文本用于主要颜色时。您应该注意避免在主要颜色上显示白色文本,或更改您的选择。

mat-palette的接口如下所示:

mat-palette($base-palette, $default: 500, $lighter: 100, $darker: 700)
  1. 使用工具的默认色调定义主要和次要的mat-palette对象:
src/localcast-theme.scss$localcast-primary: mat-palette($mat-red, 500);$localcast-accent: mat-palette($mat-indigo, A400);
  1. 创建一个新主题并应用它:
src/localcast-theme.scss$localcast-app-theme: mat-light-theme($localcast-primary, $localcast-accent);@include angular-material-theme($localcast-app-theme);
  1. angular.json中,找到apps.styles属性

  2. 在删除styles.input属性的同时,在列表前加上localcast-theme.scss

angular.json... "styles": [ "src/localcast-theme.scss", "src/styles.css"],...

即使您的主题是 scss,您仍然可以在应用程序的其余部分使用 css。Angular CLI 支持编译 scss 和 css。如果您想更改默认行为,可以通过将angular.json文件中的defaults.styleExt属性从 css 更改为 scss 来完全切换到 scss。

您还可以选择消除styles.css并将其内容与localcast-theme.scss合并,或者通过简单将其重命名为styles.scssstyles.css转换为 sass 文件。如果这样做,不要忘记更新angular.json

您的应用程序现在应该是这样的:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (69)带有自定义主题的 LocalCast 天气

我们现在可以将 UX 任务移动到已完成的列中:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (70)Waffle.io 看板状态

为了创建更多定制的主题,您应该考虑使用 Material Design 主题调色板生成器mcg.mbitson.com。这将生成定义自定义颜色调色板以创建真正独特主题所需的代码。

您还可以在meyerweb.com/eric/tools/color-blend找到颜色混合器,以找到两种颜色之间的中间点。

在第四章中,与 Angular 更新保持最新,我们利用了ng update进行自动升级体验,并介绍了手动和系统化的更新包方法。我们将在更新 Angular Material 时采用类似的策略。

您可以使用ng update来快速且无痛的升级体验,应该如下所示:

$ npx ng update @angular/material Updating package.json with dependency @angular/cdk @ "6.0.0" (was "5.2.2")... Updating package.json with dependency @angular/material @ "6.0.0" (was "5.2.2")...UPDATE package.json (5563 bytes)

此外,我发现了 Angular 团队在github.com/angular/material-update-tool发布的 material-update-tool。目前这个工具被宣传为一个特定的 Angular Material 5.x 到 6.0 的更新工具,因此它可能在未来成为 ng update 的一部分,就像 rxjs-tslint 工具一样。您可以按照下面的示例运行该工具:

$ npx angular-material-updater -p .\src\tsconfig.app.json√ Successfully migrated the project source files. Please check above output for issues that couldn't be automatically fixed.

如果您幸运并且一切顺利,可以随意跳过本节的其余部分。在本节的其余部分中,我将介绍我在开发此示例时遇到的涉及发布候选版本和 Beta 版本的特定情况,这突显了手动更新的必要性。首先,我们将了解当前版本,然后发现最新可用版本,最后,更新和测试升级,就像我们手动更新 Angular 时所做的那样。

观察 package.json 中的 Angular Material 包版本:

package.json"dependencies": { "@angular/core": "⁵.0.0", ... "@angular/animations": "⁵.0.0", "@angular/cdk": "⁵.0.0-rc0", "@angular/flex-layout": "².0.0-beta.10-4905443", "@angular/material": "⁵.0.0-rc0", "hammerjs": "².0.8",},

在这种特殊情况下,我在 RC 阶段安装了 Material 5.0.0。建议不要发布 Beta 或 RC 库。由于我们的 @angular/core 包指示我们使用的是 Angular 版本 5.0.0,我们将目标升级到最新的 Angular Material 5.x.x 版本。

我们将利用 npm CLI 工具来发现 Angular Material 的最新可用版本:

  1. 执行 npm info @angular/material 并观察输出:
{ name: '@angular/material', description: 'Angular Material', 'dist-tags': { latest: '5.0.0' }, versions: [ ... '5.0.0-rc.0', '5.0.0-rc.1', '5.0.0-rc.2', '5.0.0-rc.3', '5.0.0-rc0', '5.0.0' ],...time: { created: ... '5.0.0-rc0': '2017-11-06T20:15:29.863Z', '5.0.0-rc.1': '2017-11-21T00:38:56.394Z', '5.0.0-rc.0': '2017-11-27T19:21:19.781Z', '5.0.0-rc.2': '2017-11-28T00:13:13.487Z', '5.0.0-rc.3': '2017-12-05T21:20:42.674Z', '5.0.0': '2017-12-06T20:19:25.466Z' }

您可以观察到,结合输出中更深层的时间信息,自 5.0.0-rc0 发布以来已经推出了 5 个新版本,最终版本是库的主要版本 5.0.0 发布。

如果 Material 库有其他主要版本可用,比如 6.0.0,您仍应坚持使用 5.x.x 版本,因为我们的 @angular/core 版本是 5.x.x。一般来说,您应该保持 Angular 和 Material 的主要版本相同。

  1. 研究 @angular/core@angular/animations@angular/cdk@angular/flex-layout@angular/materialhammerjs 的最新可用版本。

  2. 为了减少您需要筛选的信息量,对每个包执行npm info <package-name> versions

  3. 将您的发现记录在类似以下的表中;我们将讨论如何确定您的目标版本:

当前最新目标
@angular/core5.0.05.1.05.0.0
@angular/animations5.0.05.1.05.0.0
@angular/cdk5.0.0-rc05.0.05.0.0
@angular/flex-layout2.0.0-beta.10-49054432.0.0-rc.12.x.x
@angular/material5.0.0-rc05.0.05.0.0
hammerjs2.0.82.0.82.x.x

研究结果表明,发布了新的 Angular 小版本,这是有用的信息。在确定目标版本时,要保守。遵循以下指导:

  • 在更新 Material 时不要更新 Angular 组件

  • 如果您打算同时更新 Angular 组件,请分阶段进行,并确保在每个单独阶段之后执行测试

  • 将任何 Beta 或 RC 软件包更新到其最新可用版本

  • 当软件包的新版本可用时,保持在软件包的相同主要版本中

  • 除非文档另有建议,否则遵循这些指南

现在我们知道要升级到哪个版本,让我们继续进行:

  1. 执行以下命令以将 Material 及其相关组件更新到其目标版本:
$ npm install @angular/material@⁵.0.0 @angular/cdk@⁵.0.0 @angular/animations@⁵.0.0 @angular/flex-layout@².0.0-rc.1
  1. 验证您的package.json以确保版本与预期版本匹配

  2. 解决任何 NPM 警告(详见第四章,与 Angular 更新保持最新更新 Angular部分)

在这种特定情况下,我收到了无法满足的@angular/flex-layout包的对等依赖警告。在 GitHub 上进一步调查(github.com/angular/flex-layout/issues/508)显示这是一个已知问题,通常可以从 Beta 或 RC 包中预期到。这意味着可以安全地忽略这些警告。

升级完成后,请确保执行“后续更新清单”,详见第四章,与 Angular 更新保持最新

在本章中,您了解了什么是 Angular Material,如何使用 Angular Flex 布局引擎,UI 库对性能的影响,以及如何将特定的 Angular Material 组件应用于您的应用程序。您意识到了过度优化 UI 设计的陷阱,以及如何向应用程序添加自定义主题。我们还讨论了如何保持 Angular Material 的最新状态。

在下一章中,我们将更新天气应用程序,以响应用户输入使用响应式表单,并保持我们的组件解耦,同时还使用BehaviorSubject在它们之间实现数据交换。在下一章之后,我们将完成天气应用程序,并将重点转移到构建更大的业务应用程序。

到目前为止,您一直在努力组合构成 Angular 应用程序的基本元素,比如模块、组件、管道、服务、RxJS、单元测试、环境变量,甚至更进一步地学习如何使用 Docker 交付您的 Web 应用程序,并使用 Angular Material 使其看起来更加精致。

为了构建真正动态的应用程序,我们需要构建能够实现丰富用户交互并利用现代网络功能的功能,比如LocalStorageGeoLocation。您还需要熟练掌握新的 Angular 语法,以有效地利用绑定、条件布局和重复元素。

您需要能够使用 Angular 表单来创建带有验证消息的输入字段,使用搜索即时输入功能创建引人入胜的搜索体验,为用户提供自定义其偏好的方式,并能够在本地和服务器上持久保存这些信息。您的应用程序可能会有多个共享数据的组件。

随着您的应用程序不断发展,并且有更多的人参与其中或者与同事交流您的想法,仅仅用手绘草图就变得越来越困难。这意味着我们需要一个更专业的模拟,最好是一个交互式的模拟,以最好地展示应用程序的计划用户体验。

在本章中,您将做以下事情:

  1. 了解这些:
  • 双向绑定

  • 模板驱动表单

  1. 熟练掌握组件之间的交互

  2. 能够创建这些:

  • 交互式原型

  • 使用 Angular 响应式表单进行输入字段和验证

外观确实很重要。无论您是在开发团队工作还是作为自由职业者,您的同事、老板或客户总是会更认真地对待一个精心准备的演示。在第二章中,创建本地天气 Web 应用程序,我提到了成为全栈开发人员的时间和信息管理挑战。我们必须选择一个可以在最少的工作量下取得最佳结果的工具。这通常意味着选择付费工具,但 UI/UX 设计工具很少是免费或便宜的。

原型工具将帮助您创建一个更好、更专业的应用程序模拟。无论您选择哪种工具,都应该支持您选择使用的 UI 框架,在这种情况下是 Material。

如果一张图片价值千言万语,那么你的应用的交互式原型价值千行代码。应用的交互式模型将帮助你在编写一行代码之前审查想法,并节省大量的代码编写。

我选择了 MockFlow WireFramePro,mockflow.com,作为一个易于使用、功能强大且在线支持 Material design UI 元素的工具,它允许你创建多个页面,然后将它们链接在一起,以创建一个工作应用程序的幻觉。

最重要的是,在发布时,MockFlow 允许永远免费使用一个完整功能集和功能。这将给你一个机会真正审查工具的有用性,而不受人为限制或者试用期的影响,试用期总是比你预期的要快得多。

Balsamiq 是更知名的线框工具。然而,balsamiq.com没有提供免费使用,但如果你正在寻找一个没有月费的工具,我强烈推荐 Balsamiq 的桌面应用 Mockups,它只需要一次购买费用。

我们首先添加一个新任务来创建一个交互式原型,在任务结束时,我会将所有工件附加到这个任务上,这样它们就存储在 GitHub 上,所有团队成员都可以访问,也可以从 Wiki 页面链接进行持久性文档化。让我们将这个新任务拉到进行中的列,并查看来自 Waffle.io 的看板板的状态:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (71)

WireframePro 作为一个拖放设计界面非常直观,所以我不会详细介绍工具的工作原理,但我会强调一些技巧:

  1. 创建你的项目

  2. 选择一个组件包,可以是手绘 UI 或者 Material design

  3. 将每个屏幕作为一个新页面添加,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (72)MockFlow.com WireFrame Pro

我建议坚持手绘 UI 的外观和感觉,因为它能够为你的观众设定正确的期望。如果你在与客户的第一次会议上展示了一个非常高质量的模型,你的第一个演示将是一个低调的陈述。你最多只能满足期望,最坏的情况下,会让你的观众感到失望。

这是主屏幕的新模型:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (73)LocalCast Weather Wireframe

您会注意到一些不同之处,比如应用工具栏与浏览器栏的混合以及重复元素的故意模糊。我做出这些选择是为了减少我需要在每个屏幕上花费的设计时间。我只是使用水平和垂直线对象来创建网格。

搜索屏幕同样故意保持模糊,以避免必须维护任何详细信息。令人惊讶的是,您的观众更有可能关注您的测试数据,而不是关注设计元素。

通过含糊不清,我们故意让观众的注意力集中在重要的事情上。以下是搜索屏幕的模拟:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (74)LocalCast 天气搜索线框图

设置窗格是一个单独的屏幕,其中包含从主屏幕复制并应用了 85%不透明度的元素,以创建类似模型的体验。设置窗格本身只是一个带有黑色边框和纯白背景的矩形。

看一下以下的模拟:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (75)LocalCast 天气设置线框图

能够点击模拟并了解导航工作流程的感觉是一个无法或缺的工具,可以获得早期用户反馈。这将为您和您的客户节省大量的沮丧、时间和金钱。

要将元素链接在一起,请按照以下步骤操作:

  1. 选择主屏幕上的可点击元素,如齿轮图标

  2. 在链接子标题下,点击选择页面

  3. 在弹出窗口中,选择设置

  4. 点击创建链接,如此截图所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (76)WireFrame Pro - 添加链接

现在,当您点击齿轮图标时,工具将显示设置页面,这将在同一页面上创建侧边栏实际显示的效果。要返回主屏幕,您可以将齿轮图标和侧边栏外部的部分链接回该页面,以便用户可以来回导航。

一旦您的原型完成,您可以将其导出为各种格式:

  1. 选择导出线框图按钮,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (77)WireFrame Pro - 导出线框图

  1. 现在选择您的文件格式,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (78)WireFrame Pro - 文件格式

我更喜欢 HTML 格式,因为它更灵活;然而,您的工作流程和需求会有所不同。

  1. 如果您选择了 HTML,您将获得一个 ZIP 捆绑包的所有资产。

  2. 解压捆绑包并使用浏览器导航到它;您应该会得到您线框的交互版本,如图所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (79)WireFrame Pro - 交互式线框交互元素在以下截图中以黄色突出显示。您可以使用屏幕左下角的“显示链接”选项启用或禁用此行为。

您甚至可以使用minimal-nginx-serverminimal-node-server对原型 HTML 项目进行容器化,并使用相同的技术在 Zeit Now 上进行托管,这与第三章中讨论的准备 Angular 应用程序进行生产发布的技术完全相同。

现在将所有资产添加到 GitHub 问题的评论中,包括 ZIP 捆绑包,我们准备继续下一个任务。让我们将“添加城市搜索卡…”移动到“进行中”,如我们看板中所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (80)Waffle.io 看板

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

首先,我们必须提供良好的基线用户体验,并实现增值功能,如地理位置功能。我们将实现搜索即时输入功能,同时向用户提供反馈,如果服务无法检索到预期的数据。

最初,实现类型搜索机制可能是直观的;然而,OpenWeatherMapAPI 并没有提供这样的端点。相反,它们提供昂贵且在兆字节范围内的大量数据下载。

我们需要实现自己的应用服务器来公开这样一个端点,以便我们的应用可以有效地查询,同时使用最少量的数据。

OpenWeatherMap 的免费端点确实带来了一个有趣的挑战,其中两位数的国家代码可能会伴随城市名称或邮政编码以获得最准确的结果。这是一个很好的机会,可以为用户实现反馈机制,如果对于给定的查询返回了多个结果。

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

我们将执行以下操作:

  1. 添加 Angular 表单控件

  2. 使用 Angular Material Input,如在material.angular.io/components/input中记录的那样。

  3. 将搜索栏创建为其自己的组件

  4. 扩展现有的端点以接受邮政编码,并使国家代码在weather.service中变为可选项

  5. 节流请求

您可能会想为什么我们要添加 Angular 表单,因为我们只添加了一个单个输入字段,而不是具有多个输入的表单。作为一个一般的经验法则,任何时候您添加任何输入字段,它都应该包装在<form>标签中。Forms模块包含FormControl,它使您能够编写支持输入字段背后的后备代码,以响应用户输入,并根据需要提供适当的数据、验证或响应消息。

Angular 中有两种类型的表单:

  • 模板驱动: 这些表单类似于您可能熟悉的 AngularJS 中的表单,其中表单逻辑主要在 HTML 模板中。我个人不喜欢这种方法,因为很难测试这些行为,而且庞大的 HTML 模板很快就难以维护。

  • 响应式: 响应式表单的行为由控制器中编写的 TypeScript 代码驱动。这意味着您的验证逻辑可以进行单元测试,并且更好的是可以在整个应用程序中重复使用。在angular.io/guide/reactive-forms中了解更多关于响应式表单的信息。

让我们首先将ReactiveFormsModule导入到我们的应用程序中:

src/app/app.module.ts...import { FormsModule, ReactiveFormsModule } from '@angular/forms'...@NgModule({ ... imports: [ ... FormsModule, ReactiveFormsModule, ...

响应式表单是使 Angular Material 团队能够编写更丰富的工具的核心技术,例如可以根据将来的 TypeScript 接口自动生成输入表单的工具。

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

  1. MatFormFieldModuleMatInputModule添加到material.module中,以便在应用程序中可用:
src/app/material.module.tsimport { ... MatFormFieldModule, MatInputModule,} from '@angular/material'...@NgModule({ imports: [ ... MatFormFieldModule, MatInputModule, ], exports: [ ... MatFormFieldModule, MatInputModule, ],})

我们正在添加MatFormFieldModule,因为每个输入字段都应该包装在<mat-form-field>标签中,以充分利用 Angular Material 的功能。在高层次上,<form>封装了键盘、屏幕阅读器和浏览器扩展用户的许多默认行为;<mat-form-field>实现了简单的双向数据绑定,这种技术应该适度使用,并且还允许优雅的标签、验证和错误消息显示。

  1. 创建新的citySearch组件:
$ npx ng g c citySearch --module=app.module

由于我们添加了material.module.ts文件,ng无法猜测应将城市搜索功能模块添加到哪里,导致出现错误,例如More than one module matches。因此,我们需要使用--module选项提供要将citySearch添加到的模块。使用--skip-import选项跳过将组件导入到任何模块中。

  1. 创建一个基本模板:
src/app/city-search/city-search.component.html<form> <mat-form-field> <mat-icon matPrefix>search</mat-icon> <input matInput placeholder="Enter city or zip" aria-label="City or Zip" [formControl]="search"> </mat-form-field></form>
  1. 导入并实例化FormControl的实例:
src/app/city-search/city-search.component.tsimport { FormControl } from '@angular/forms'...export class CitySearchComponent implements OnInit { search = new FormControl() ...

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

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

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

  • FormGroup用于将单独的FormControlFormArray对象注册为您向表单添加更多输入字段时

最后,FormBuilder对象用于更轻松地编排和维护FormGroup的操作,这将在第十章中进行介绍,Angular 应用设计和示例

  1. 在包含app-current-weather的外部行的标题之间,在app.component中添加app-city-search
src/app/app.component.ts... </div> <div fxLayoutAlign="center"> <app-city-search></app-city-search> </div> <div fxLayout="row">...
  1. 通过在浏览器中查看应用程序来测试组件的集成,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (81)带有搜索字段的 LocalWeather 应用

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

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

OpenWeatherMap 的 API 接受 URI 参数,因此我们可以使用 TypeScript 联合类型重构现有的getCurrentWeather函数,并使用类型守卫,我们可以提供不同的参数,同时保持类型检查:

  1. 重构weather.service中的getCurrentWeather函数以处理邮政编码和城市输入:
app/src/weather/weather.service.ts getCurrentWeather( search: string | number, country?: string ): Observable<ICurrentWeather> { let uriParams = '' if (typeof search === 'string') { uriParams = `q=${search}` } else { uriParams = `zip=${search}` } if (country) { uriParams = `${uriParams},${country}` } return this.getCurrentWeatherHelper(uriParams) }

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

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

  1. 将 HTTP 调用重构为getCurrentWeatherHelper

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

src/app/weather/weather.service.ts private getCurrentWeatherHelper(uriParams: string): Observable<ICurrentWeather> { return this.httpClient .get<ICurrentWeatherData>( `${environment.baseUrl}api.openweathermap.org/data/2.5/weather?` + `${uriParams}&appid=${environment.appId}` ) .pipe(map(data => this.transformToICurrentWeather(data))) }

作为积极的副作用,getCurrentWeatherHelper 遵循了开闭原则,因为我们可以通过提供不同的uriParams 来改变函数的行为,所以它对扩展是开放的,并且对修改是封闭的,因为它不需要经常被修改。

为了证明后一点,让我们实现一个新的函数,根据纬度和经度获取当前天气。

  1. 实现getCurrentWeatherByCoords
src/app/weather/weather.service.ts getCurrentWeatherByCoords(coords: Coordinates): Observable<ICurrentWeather> { const uriParams = `lat=${coords.latitude}&lon=${coords.longitude}` return this.getCurrentWeatherHelper(uriParams)}

如你所见,getCurrentWeatherHelper 可以在不做任何修改的情况下容易地进行扩展。

  1. 确保您更新IWeatherService和之前所做的更改保持一致。

作为遵循 SOLID 设计原则的结果,我们更容易地对流控制逻辑进行鲁棒的单元测试,最终编写出更具韧性、更便宜维护的代码。

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

  1. 更新citySearch以注入weatherService并订阅输入更改:
src/app/city-search/city-search.component.ts...export class CitySearchComponent implements OnInit { search = new FormControl() constructor(private weatherService: WeatherService) {} ... ngOnInit() { this.search.valueChanges .subscribe(...) } 

在此时,我们将所有输入都视为string。用户输入可以是城市、邮政编码,或用逗号分隔的城市和国家代码,或邮政编码和国家代码。而城市或邮政编码是必需的,国家代码是可选的。我们可以使用String.split函数来解析任何可能的逗号分隔输入,然后使用String.trim去除字符串的开头和结尾的任何空格。然后,我们通过遍历它们并使用Array.map来确保我们去除字符串的所有部分。

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

  1. 实现搜索处理程序:
src/app/city-search/city-search.component.tsthis.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))) } })
  1. 为用户添加有关可选国家功能的提示:
src/app/city-search/city-search.component.html... <mat-form-field> ... <mat-hint>Specify country code like 'Paris, US'</mat-hint> </mat-form-field>...

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

观察在 Chrome Dev Tools 中如何工作。注意search函数运行的频率以及我们未处理服务错误的情况。

如此,我们在每次按键输入时都向服务器发送请求。这不是期望的行为,因为它会导致糟糕的用户体验,耗尽电池寿命,造成浪费的网络请求,并在客户端和服务器端都引起性能问题。用户可能会打错字;他们可能会改变主意,然后很少有输入的前几个字符会产生有用的结果。

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

请注意,throttledebounce不是功能等效的,它们的行为会因框架而异。除了节流,我们希望捕获用户输入的最后一次输入。在lodash框架中,throttle 函数可以实现此需求,而在RxJS中,debounce 可以实现。请注意,此差异可能在将来的框架更新中得到修复。

可以很容易地使用RxJS/debounceTime将节流注入到可观察流中。

使用pipe实现debounceTime

src/app/city-search/city-search.component.tsimport { 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是高度可定制的。它允许您设置默认初始值,添加验证器,或在模糊、更改和提交事件上监听更改,如下所示:

examplenew FormControl('Bethesda', { updateOn: 'submit' })

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

  1. @angular/forms导入Validators
src/app/city-search/city-search.component.tsimport { FormControl, Validators } from '@angular/forms'
  1. 修改FormControl以添加最小长度验证器:
src/app/city-search/city-search.component.tssearch = new FormControl('', [Validators.minLength(2)])
  1. 修改模板以显示验证错误消息:
src/app/city-search/city-search.component.html... <form style="margin-bottom: 32px"> <mat-form-field> ... <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' : '';}
  1. 修改search函数以不使用无效输入执行搜索:
src/app/city-search/city-search.component.tsthis.search.valueChanges.pipe(debounceTime(1000)).subscribe((searchValue: string) => { if (!this.search.invalid) { ...

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

与响应式表单相对应的是模板驱动的表单。如果您熟悉 AngularJS 中的ng-model,您会发现新的ngModel指令是其 API 兼容的替代品。

在幕后,ngModel实现了一个自动将自身附加到FormGroupFormControlngModel可以在<form>级别或单个<input>级别使用。您可以在angular.io/api/forms/NgModel上了解更多关于ngModel的信息。

在本地天气应用中,我在app.component.ts中包含了一个名为app-city-search-tpldriven的组件的注释。您可以取消app.component中的注释以进行实验。让我们看看替代模板实现是什么样的:

src/app/city-search-tpldriven/city-search-tpldriven.component.html ... <input matInput placeholder="Enter city or zip" 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[()]的“香蕉箱”双向绑定语法的使用。

组件中的差异实现如下:

src/app/city-search-tpldriven/city-search-tpldriven.component.tsimport { NgModel, Validators} from '@angular/forms'...export class CitySearchTpldrivenComponent implements OnInit { model = { search: '', } ... 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 语法和概念。

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

  • 全局事件

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

  • 在模块内部工作的同级、父级或子级的组件,它们基于类似的数据流

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

这是从编程早期开始就一直被利用的技术。在 JavaScript 中,你可能通过全局函数委托或 jQuery 的事件系统来实现这一点。在 AngularJS 中,你可能创建了一个服务并在其中存储值。

在 Angular 中,你仍然可以创建一个根级别的服务,在其中存储值,使用 Angular 的EventEmitter类(实际上是为指令而设计的),或使用rxjs/Subscription来为自己创建一个复杂的消息总线。

作为模式,全局事件容易被滥用,而不是帮助维护一个解耦的应用架构,随着时间的推移,它会导致全局状态。全局状态甚至是在控制器级别的本地状态,函数读取和写入任何给定类的变量,都是编写可维护和可单元测试软件的头号敌人。

最终,如果你将所有应用程序数据存储或者路由所有事件都在一个服务中以启用组件交互,那么你只是在发明一个更好的捕鼠夹。这是一种应该尽量避免的反模式。在后面的章节中,您将发现本质上我们仍然会使用服务来实现组件间的交互;然而,我想指出的是在灵活的架构和全局或集中式解耦方法之间存在一个细微的界限,后者无法很好地扩展。

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

我们可以使用 app 组件作为父元素,实现城市搜索组件和当前天气组件之间的通信,让 app 模块控制器来协调数据。

让我们看看这个实现会是怎样的:

  1. city-search 组件通过 @Output 属性公开了一个 EventEmitter
src/app/city-search/city-search.component.tsimport { Component, Output, EventEmitter } from '@angular/core'export class CitySearchComponent implements OnInit { ... @Output() searchEvent = new EventEmitter<string>() ... this.search.valueChanges.debounceTime(1000).subscribe((searchValue: string) => { if (!this.search.invalid) { this.searchEvent.emit(this.searchValue) } }) ...}
  1. app 组件使用该信息,并调用 weatherService,设置 currentWeather 变量:
src/app/app.component.tstemplate: ` ... <app-city-search (searchEvent)="doSearch($event)"></app-city-search> ...`export class AppComponent { currentWeather: ICurrenWeather constructor() { } 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) }}

我们已经成功地向上传递了信息,现在我们必须能够将它传递给 current-weather 组件。

按照定义,父组件将意识到它正在使用哪些子组件。由于 currentWeather 属性与 current-weather 组件上的 current 属性绑定,结果传递下来并显示。这是通过创建一个 @Input 属性来实现的:

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

然后你可以更新 app 组件,将数据绑定到 current 天气上:

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

这种方式可能适用于创建耦合度较高的组件或用户控件,且不需要消耗外部数据的情况。一个很好的例子就是向 current-weather 组件添加预测信息,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (82)天气预报线框图

每周的每一天都可以作为一个组件来实现,使用 *ngFor 进行重复,并且将这些信息合理地绑定到 current-weather 的子组件上是非常合理的:

example<app-mini-forecast *ngFor="let dailyForecast of forecastArray [forecast]="dailyForecast"></app-mini-forecast>

通常,如果你在使用数据驱动的组件,父子或者子父通信模式将导致架构不够灵活,使得组件的重用或重新排列变得非常困难。考虑到不断变化的业务需求和设计,这是一个重要的教训需要牢记。

组件互动的主要原因是发送或接收用户提供或从服务器接收的数据更新。在 Angular 中,你的服务公开 RxJS.Observable 端点,这些是数据流,你的组件可以订阅它们。RxJS.Observer 补充了 RxJS.Observable 作为 Observable 发出的事件的消费者。RxJS.Subject 将这两套功能合并到一个易于使用的对象中。您可以使用主题来描述属于特定数据集的流,比如正在显示的当前天气数据:

src/app/weather/weather.service.tsimport { Subject } from 'rxjs'...export class WeatherService implements IWeatherService { currentWeather: Subject<ICurrentWeather> ...}

currentWeather 仍然是一个数据流,并不仅仅代表一个数据点。你可以通过订阅来订阅 currentWeather 数据的变化,或者可以按照以下方式发布对它的更改:

examplecurrentWeather.subscribe(data => (this.current = data))currentWeather.next(newData)

Subject 的默认行为非常类似于通用的发布-订阅机制,比如 jQuery 事件。但是,在组件以不可预知的方式加载或卸载的异步世界中,使用默认的 Subject 并不是很有用。

有三种不同类型的 Subject:

  • ReplaySubject: 它将记住和缓存数据流中发生的所有数据点,以便订阅者可以在任何给定时间重放所有事件

  • BehaviorSubject: 它只记住最后一个数据点,同时继续监听新的数据点

  • AsyncSubject: 这是一次性事件,不希望再次发生

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

  1. weatherService 中定义 BehaviorSubject 并设置默认值:
app/src/weather/weather.service.tsimport { BehaviorSubject } from 'rxjs'...export class WeatherService implements IWeatherService { currentWeather = new BehaviorSubject<ICurrentWeather>({ city: '--', country: '--', date: Date.now(), image: '', temperature: 0, description: '', }) ...}
  1. current-weather 组件更新为订阅新的 BehaviorSubject:
app/src/current-weather/current-weather.component.ts... ngOnInit() { this.weatherService.currentWeather.subscribe(data => (this.current = data))}...
  1. city-search 组件更新为发布其接收到的数据到 BehaviorSubject:
app/src/city-search/city-search.component.ts... this.weatherService .getCurrentWeather( userInput[0], userInput.length > 1 ? userInput[1] : undefined ) .subscribe(data => this.weatherService.currentWeather.next(data))...
  1. 在浏览器中测试您的应用程序;它应该如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (83)土耳其布尔萨的天气信息

当您输入一个新的城市时,组件应该更新为该城市的当前天气信息。

在应用程序首次加载时,默认体验看起来有些问题。至少有两种不同的处理方式。首先是在app组件级别隐藏整个组件,如果没有数据显示。为了使其工作,我们将不得不将weatherService注入到app组件中,最终导致不太灵活的解决方案。另一种方法是能够更好地处理current-weather组件中缺少的数据。

为了使应用程序更好,您可以在应用程序启动时实现地理位置功能,以获取用户当前位置的天气。您还可以利用window.localStorage来存储上次显示的城市或从window.geolocation在初始启动时检索的上次位置。

在继续之前,不要忘记执行npm testnpm run e2e。读者可以自行修复单元测试和端到端测试。

这一章完成了我们对本地天气应用程序的工作。我们可以将城市搜索功能任务移动到完成列,如我们看板中所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (84)Waffle.io 看板状态

在本章中,您学会了如何创建一个交互式原型,而不需要编写一行代码。然后,您使用MatInput、验证器、响应式表单和数据流驱动处理程序创建了一个搜索即时响应的功能。您还了解了不同的策略来实现组件间的交互和数据共享。最后,您了解了双向绑定和基于模板的表单。

LocalCast Weather 是一个简单的应用程序,我们用它来介绍 Angular 的基本概念。正如您所见,Angular 非常适合构建这样的小型和动态应用程序,同时向最终用户提供最少量的框架代码。您应该考虑利用 Angular 甚至用于快速而简单的项目,这在构建更大型的应用程序时也是一个很好的实践。在下一章中,您将使用路由器优先的方法来创建一个更复杂的业务线LOB)应用程序,设计和构建可扩展的 Angular 应用程序,其中包括一流的身份验证和授权、用户体验以及涵盖大多数 LOB 应用程序需求的众多技巧。

业务应用(LOB)是软件开发世界的基础。根据维基百科的定义,LOB 是一个通用术语,指的是为特定客户交易或业务需求提供产品或一组相关产品。LOB 应用程序提供了展示各种功能和功能的良好机会,而无需涉及大型企业应用程序通常需要的扭曲或专业化场景。在某种意义上,它们是 80-20 的学习经验。然而,我必须指出有关 LOB 应用程序的一个奇怪之处——如果您最终构建了一个半有用的 LOB 应用程序,对它的需求将不受控制地增长,您很快就会成为自己成功的受害者。这就是为什么您应该把每个新项目的开始视为一个机会,一个编码的机会,以便更好地创建更灵活的架构。

在本章和其余章节中,我们将建立一个具有丰富功能的新应用程序,可以满足可扩展架构和工程最佳实践的 LOB 应用程序的需求,这将帮助您在有需求时快速启动并迅速扩展解决方案。我们将遵循路由优先的设计模式,依赖可重用的组件来创建一个名为 LemonMart 的杂货店 LOB。

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

  • 有效使用 CLI 创建主要的 Angular 组件和 CLI 脚手架

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

  • 品牌、自定义和材料图标

  • 使用 Augury 调试复杂的应用程序

  • 启用延迟加载

  • 创建一个基本框架

本书提供的代码示例需要 Angular 版本 5 和 6。Angular 5 代码与 Angular 6 兼容。Angular 6 将在 LTS 中得到支持,直到 2019 年 10 月。代码存储库的最新版本可以在以下网址找到:

在我们深入创建 LOB 应用程序之前,我为您提供了一个速查表,让您熟悉常见的 Angular 语法和 CLI 命令,因为在接下来的过程中,这些语法和命令将被使用,而不会明确解释它们的目的。花些时间来审查和熟悉新的 Angular 语法、主要组件、CLI 脚手架和常见管道。如果您的背景是 AngularJS,您可能会发现这个列表特别有用,因为您需要放弃一些旧的语法。

绑定,或数据绑定,指的是代码中变量与 HTML 模板或其他组件中显示或输入的值之间的自动单向或双向连接:

类型语法数据方向

| 插值属性

属性

样式 | {{expression}}``[target]="expression"``bind-target="expression" | 从数据源单向

到视图目标 |

事件 | (目标)="语句" on-目标="语句" | 从视图目标单向

到数据源 |

双向[(target)]="expression" bindon-target="expression"双向

来源:angular.io/guide/template-syntax#binding-syntax-an-overview

指令封装了可以作为属性应用到 HTML 元素或其他组件的编码行为:

名称语法目的
结构指令*ngIf``*ngFor``*ngSwitch控制 HTML 的结构布局,以及元素是否从 DOM 中添加或移除
属性指令[class]``[style]``[(model)]监听并修改其他 HTML 元素、属性、属性和组件的行为,如 CSS 类、HTML 样式和 HTML 表单元素

结构指令来源:angular.io/guide/structural-directives

属性指令来源:angular.io/guide/template-syntax#built-in-attribute-directives

管道修改了数据绑定值在 HTML 模板中的显示方式。

名称目的用法
日期根据区域设置规则格式化日期{{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]]]}}

管道来源:angular.io/guide/pipes

启动命令帮助生成新项目或添加依赖项。Angular CLI 命令帮助创建主要组件,通过自动生成样板脚手架代码来轻松完成。有关完整命令列表,请访问github.com/angular/angular-cli/wiki

名称目的CLI 命令
新建创建一个新的 Angular 应用程序,并初始化 git 存储库,配置好 package.json 和路由。从父文件夹运行。npx @angular/cli new project-name --routing
更新更新 Angular,RxJS 和 Angular Material 依赖项。如有必要,重写代码以保持兼容性。npx ng update
添加材料安装和配置 Angular Material 依赖项。npx ng add @angular/material
模块创建一个新的@NgModule类。使用--routing来为子模块添加路由。可选地,使用--module将新模块导入到父模块中。ng g module new-module
组件创建一个新的@Component类。使用--module来指定父模块。可选地,使用--flat来跳过目录创建,-t用于内联模板,和-s用于内联样式。ng g component new-component
指令创建一个新的@Directive类。可选地,使用--module来为给定子模块范围内的指令。ng g directive new-directive
管道创建一个新的@Pipe类。可选地,使用--module来为给定子模块范围内的管道。ng g pipe new-pipe
服务创建一个新的@Injectable类。使用--module为给定子模块提供服务。服务不会自动导入到模块中。可选地使用--flat false 在目录下创建服务。ng g service new-service
Guard创建一个新的@Injectable类,实现路由生命周期钩子CanActivate。使用--module为给定的子模块提供守卫。守卫不会自动导入到模块中。ng g guard new-guard
Class创建一个简单的类。ng g class new-class
Interface创建一个简单的接口。ng g interface new-interface
Enum创建一个简单的枚举。ng g enum new-enum

为了正确地为自定义模块下列出的一些组件进行脚手架搭建,比如my-module,你可以在你打算生成的名称前面加上模块名称,例如ng g c my-module/my-new-component。Angular CLI 将正确地连接并将新组件放置在my-module文件夹下。

在使用 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

Angular 路由器,打包在@angular/router包中,是构建单页应用程序SPAs)的中心和关键部分,它的行为和操作方式类似于普通网站,可以使用浏览器控件或缩放或微缩放控件轻松导航。

Angular 路由器具有高级功能,如延迟加载、路由器出口、辅助路由、智能活动链接跟踪,并且可以表达为href,这使得使用 RxJS SubjectBehavior的无状态数据驱动组件的高度灵活的路由器优先应用程序架构成为可能。

大型团队可以针对单一代码库进行工作,每个团队负责一个模块的开发,而不会互相干扰,同时实现简单的持续集成。谷歌之所以选择针对数十亿行代码进行单一代码库的工作,是有很好的原因的。事后的集成非常昂贵。

小团队可以随时重新调整他们的 UI 布局,以快速响应变化,而无需重新设计他们的代码。很容易低估由于布局或导航的后期更改而浪费的时间。这样的变化对于大型团队来说更容易吸收,但对于小团队来说是一项昂贵的努力。

通过延迟加载,所有开发人员都可以从次秒级的首次有意义的绘制中受益,因为在构建时将传递给浏览器的核心用户体验文件大小保持在最低限度。模块的大小影响下载和加载速度,因为浏览器需要做的越多,用户看到应用程序的第一个屏幕就需要的时间就越长。通过定义延迟加载的模块,每个模块都可以打包为单独的文件,可以根据需要单独下载和加载。智能活动链接跟踪可以提供卓越的开发人员和用户体验,非常容易实现突出显示功能,以指示用户当前活动的选项卡或应用程序部分。辅助路由最大化了组件的重用,并帮助轻松实现复杂的状态转换。通过辅助路由,您可以仅使用单个外部模板呈现多个主视图和详细视图。您还可以控制路由在浏览器的 URL 栏中向用户显示的方式,并使用routerLink在模板中和Router.navigate在代码中组合路由,驱动复杂的场景。

为了实现一个以路由为先的实现,您需要这样做:

  1. 早期定义用户角色

  2. 设计时考虑延迟加载

  3. 实现一个骨架导航体验

  4. 围绕主要数据组件进行设计

  5. 执行一个解耦的组件架构

  6. 区分用户控件和组件

  7. 最大化代码重用

用户角色通常表示用户的工作职能,例如经理或数据录入专员。在技术术语中,它们可以被视为特定类别用户被允许执行的一组操作。定义用户角色有助于识别可以配置为延迟加载的子模块。毕竟,数据录入专员永远不会看到经理可以看到的大多数屏幕,那么为什么要将这些资产传递给这些用户并减慢他们的体验呢?延迟加载在创建可扩展的应用程序架构方面至关重要,不仅从应用程序的角度来看,而且从高质量和高效的开发角度来看。配置延迟加载可能会很棘手,这就是为什么及早确定骨架导航体验非常重要的原因。

识别用户将使用的主要数据组件,例如发票或人员对象,将帮助您避免过度设计您的应用程序。围绕主要数据组件进行设计将在早期确定 API 设计,并帮助定义BehaviorSubject数据锚点,以实现无状态、数据驱动的设计,确保解耦的组件架构,详见第六章,响应式表单和组件交互

最后,识别封装了您希望为应用程序创建的独特行为的自包含用户控件。用户控件可能会被创建为具有数据绑定属性和紧密耦合的控制器逻辑和模板的指令或组件。另一方面,组件将利用路由器生命周期事件来解析参数并对数据执行 CRUD 操作。在早期识别这些组件重用将导致创建更灵活的组件,可以在路由器协调下在多个上下文中重用,最大程度地实现代码重用。

LemonMart 将是一个中型的业务应用程序,拥有超过 90 个代码文件。我们将从创建一个新的 Angular 应用程序开始,其中包括路由和 Angular Material 的配置。

采用以路由为先的方法,我们将希望在应用程序早期启用路由:

  1. 您可以通过执行以下命令创建已经配置了路由的新应用程序:

确保未全局安装@angular/cli,否则可能会遇到错误:

$ npx @angular/cli new lemon-mart --routing
  1. 一个新的AppRoutingModule文件已经为我们创建了:
src/app/app-routing.modules.tsimport { NgModule } from '@angular/core';import { Routes, RouterModule } from '@angular/router';const routes: Routes = [];@NgModule({ imports: [RouterModule.forRoot(routes)], exports: [RouterModule]})export class AppRoutingModule { }

我们将在 routes 数组中定义路由。请注意,routes 数组被传入以配置为应用程序的根路由,默认的根路由为/

在配置您的RouterModule时,您可以传入其他选项来自定义路由器的默认行为,例如当您尝试加载已经显示的路由时,而不是不采取任何操作,您可以强制重新加载组件。要启用此行为,请创建您的路由器如下:RouterModule.forRoot(routes, { onSameUrlNavigation: 'reload' })

  1. 最后,AppRoutingModule被注册到AppModule中,如下所示:
src/app/app.module.ts ...import { AppRoutingModule } from './app-routing.module';@NgModule({ ... imports: [ AppRoutingModule ... ], ...

以下是第 2-6 章中涵盖的配置步骤的快速摘要。如果您对某个步骤不熟悉,请参考之前的章节。在继续之前,您应该完成这些步骤:

  1. 修改angular.jsontslint.json以强制执行您的设置和编码标准。

  2. 安装npm i -D prettier

  3. prettier设置添加到package.json

  4. 将开发服务器端口配置为除4200之外的其他端口,例如5000

  5. 添加standardize脚本并更新startbuild脚本

  6. 为 Docker 添加 npm 脚本到package.json

  7. 建立开发规范并在项目中记录,npm i -D dev-norms然后npx dev-norms create

  8. 如果您使用 VS Code,请设置extensions.jsonsettings.json文件

您可以配置 TypeScript Hero 扩展以自动组织和修剪导入语句,只需将"typescriptHero.imports.organizeOnSave": true添加到settings.json中。如果与设置"files.autoSave": "onFocusChange"结合使用,您可能会发现该工具在您尝试输入时会积极清除未使用的导入。确保此设置适用于您,并且不会与任何其他工具或 VS Code 自己的导入组织功能发生冲突。

  1. 执行npm run standardize

参考第三章,为生产发布准备 Angular 应用,以获取更多配置细节。

您可以在bit.ly/npmScriptsForDocker获取 Docker 的 npm 脚本,以及在bit.ly/npmScriptsForAWS获取 AWS 的 npm 脚本。

我们还需要设置 Angular Material 并配置要使用的主题,如第五章中所述,使用 Angular Material 增强 Angular 应用

  1. 安装 Angular Material:
$ npx ng add @angular/material$ npm i @angular/flex-layout hammerjs $ npx ng g m material --flat -m app
  1. 导入和导出MatButtonModuleMatToolbarModuleMatIconModule

  2. 配置默认主题并注册其他 Angular 依赖项

  3. 将通用 css 添加到styles.css中,如下所示,

src/styles.cssbody { margin: 0;}.margin-top { margin-top: 16px;}.horizontal-padding { margin-left: 16px; margin-right: 16px;}.flex-spacer { flex: 1 1 auto;}

有关更多配置详细信息,请参阅第五章,使用 Angular Material 增强 Angular 应用

在构建从数据库到前端的基本路线图的同时,避免过度工程化非常重要。这个初始设计阶段对项目的长期健康和成功至关重要,团队之间任何现有的隔离必须被打破,并且整体技术愿景必须被团队的所有成员充分理解。这并不是说起来容易做起来难,关于这个话题已经有大量的书籍写成。

在工程领域,没有一个问题有唯一正确的答案,因此重要的是要记住没有一个人可以拥有所有答案,也没有一个人可以有清晰的愿景。技术和非技术领导者之间创造一个安全的空间,提供开放讨论和实验的机会是文化的一部分,这一点非常重要。能够在团队中面对这种不确定性所带来的谦卑和同理心与任何单个团队成员的技术能力一样重要。每个团队成员都必须习惯于把自己的自我放在一边,因为我们的集体目标将是在开发周期内发展和演变应用程序以适应不断变化的需求。如果你能够知道你已经成功了,那么你所创建的软件的各个部分都可以很容易地被任何人替换。

我们设计的第一步是考虑您使用应用程序的原因。

我们为 LemonMart 设想了四种用户状态或角色:

  • 认证用户,任何经过认证的用户都可以访问他们的个人资料

  • 收银员,其唯一角色是为客户结账。

  • 店员,其唯一角色是执行与库存相关的功能

  • 经理,可以执行收银员和店员可以执行的所有操作,但也可以访问管理功能

有了这个想法,我们可以开始设计我们应用程序的高级设计。

制作应用程序的高级站点地图,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (85)用户的登陆页面我使用了 MockFlow.com 的 SiteMap 工具来创建站点地图

显示在sitemap.mockflow.com

在首次检查时,三个高级模块出现为延迟加载的候选项:

  1. 销售点(POS)

  2. 库存

  3. 经理

收银员只能访问 POS 模块和组件。店员只能访问库存模块,其中包括库存录入、产品和类别管理组件的额外屏幕。

库存页面

最后,管理者将能够通过管理模块访问所有三个模块,包括用户管理和收据查找组件。

管理页面

启用所有三个模块的延迟加载有很大好处,因为收银员和店员永远不会使用属于其他用户角色的组件,所以没有理由将这些字节发送到他们的设备上。这意味着当管理模块获得更多高级报告功能或新角色添加到应用程序时,POS 模块不会受到应用程序增长的带宽和内存影响。这意味着更少的支持电话,并且在同一硬件上保持一致的性能更长的时间。

现在我们已经定义了高级组件作为管理者、库存和 POS,我们可以将它们定义为模块。这些模块将与您迄今为止创建的模块不同,用于路由和 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

  1. 验证您是否没有 CLI 错误。

请注意,在 Windows 上使用npx可能会遇到错误,例如路径必须是字符串。收到未定义。这个错误似乎对命令的成功操作没有任何影响,这就是为什么始终要检查 CLI 工具生成的内容是至关重要的。

  1. 验证文件夹和文件是否已创建:
/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
  1. 检查ManagerModule的连接方式。

子模块实现了类似于app.module@NgModule。最大的区别是子模块不实现bootstrap属性,这是你的根模块所需的,用于初始化你的 Angular 应用程序:

src/app/manager/manager.module.tsimport { 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.tsimport { 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 命令时应用。

考虑以下模拟作为 LemonMart 的登陆体验:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (86)LemonMart 登陆体验

LocalCastWeather应用程序不同,我们不希望所有这些标记都在App组件中。App组件是整个应用程序的根元素;因此,它应该只包含将在整个应用程序中持续出现的元素。在下面的注释模拟中,标记为 1 的工具栏将在整个应用程序中持续存在。

标记为 2 的区域将容纳 home 组件,它本身将包含一个登录用户控件,标记为 3:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (87)LemonMart 布局结构

在 Angular 中,将默认或登陆组件创建为单独的元素是最佳实践。这有助于减少必须加载的代码量和在每个页面上执行的逻辑,但在利用路由器时也会导致更灵活的架构:

使用内联模板和样式生成home组件:

$ npx ng g c home -m app --inline-template --inline-style

现在,你已经准备好配置路由器了。

让我们开始为 LemonMart 设置一个简单的路由:

  1. 配置你的home路由:
src/app/app-routing.module.ts ...const routes: Routes = [ { path: '', redirectTo: '/home', pathMatch: 'full' }, { path: 'home', component: HomeComponent },]...

我们首先为'home'定义一个路径,并通过设置组件属性来告知路由渲染HomeComponent。然后,我们将应用的默认路径''重定向到'/home'。通过设置pathMatch属性,我们始终确保主页路由的这个非常特定的实例将作为着陆体验呈现。

  1. 创建一个带有内联模板的pageNotFound组件

  2. PageNotFoundComponent配置通配符路由:

src/app/app-routing.module.ts ...const routes: Routes = [ ... { path: '**', component: PageNotFoundComponent }]...

这样,任何未匹配的路由都将被重定向到PageNotFoundComponent

当用户登陆到PageNotFoundComponent时,我们希望他们通过RouterLink重定向到HomeComponent

  1. 实现一个内联模板,使用routerLink链接回主页:
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中定义的根路由的根元素,这使我们能够在此根元素内定义 outlets,以使用<router-outlet>元素动态加载任何我们希望的内容:

  1. 配置AppComponent以使用内联模板和样式

  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> `,

现在,主页的内容将在<router-outlet>内呈现。

为了构建一个吸引人且直观的工具栏,我们必须向应用引入一些图标和品牌,以便用户可以通过熟悉的图标轻松浏览应用。

在品牌方面,您应该确保您的 Web 应用程序具有自定义色板,并与桌面和移动浏览器功能集成,以展示您应用的名称和图标。

使用 Material Color 工具选择一个色板,如第五章中所讨论的,使用 Angular Material 增强 Angular 应用。这是我为 LemonMart 选择的色板:

https://material.io/color/#!/?view.left=0&view.right=0&primary.color=2E7D32&secondary.color=C6FF00

您需要确保浏览器在浏览器选项卡中显示正确的标题文本和图标。此外,应创建一个清单文件,为各种移动操作系统实现特定的图标,以便用户将您的网站固定在手机上时,会显示一个理想的图标,类似于手机上的其他应用图标。这将确保如果用户将您的 Web 应用添加到其移动设备的主屏幕上,他们将获得一个本地外观的应用图标:

  1. 从设计师或网站(如www.flaticon.com)获取您网站标志的 SVG 版本

  2. 在这种情况下,我将使用一个特定的柠檬图片:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (88) LemonMart 的标志性标志在使用互联网上找到的图像时,请注意适用的版权。在这种情况下,我已经购买了许可证以便发布这个柠檬标志,但是您可以在以下网址获取您自己的副本,前提是您提供图像作者所需的归属声明:www.flaticon.com/free-icon/lemon_605070

  1. 使用realfavicongenerator.net等工具生成favicon.ico和清单文件

  2. 根据您的喜好调整 iOS、Android、Windows Phone、macOS 和 Safari 的设置

  3. 确保设置一个版本号,favicons 可能会因缓存而臭名昭著;一个随机的版本号将确保用户始终获得最新版本

  4. 下载并提取生成的favicons.zip文件到您的src文件夹中。

  5. 编辑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" ]
  1. 将生成的代码插入到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">
  1. 确保您的新 favicon 显示正确

为了进一步推广您的品牌,请考虑配置自定义的 Material 主题并利用material.io/color,如第五章,使用 Angular Material 增强 Angular 应用中所讨论的那样。

现在,让我们在您的 Angular 应用程序中添加您的自定义品牌。您将需要用于创建 favicon 的 svg 图标:

  1. 将图像放在src/app/assets/img/icons下,命名为lemon.svg

  2. HttpClientModule导入AppComponent,以便可以通过 HTTP 请求.svg文件

  3. 更新AppComponent以注册新的 svg 文件作为图标:

src/app/app.component.ts import { DomSanitizer } from '@angular/platform-browser'...export class AppComponent { constructor(iconRegistry: MatIconRegistry, sanitizer: DomSanitizer) { iconRegistry.addSvgIcon( 'lemon', sanitizer.bypassSecurityTrustResourceUrl('assets/img/icons/lemon.svg') ) }}
  1. 将图标添加到工具栏:
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中将其作为 Web 字体导入到您的应用程序中。也可以自行托管字体;但是,如果您选择这条路,您也无法获得用户的浏览器在访问其他网站时已经缓存了字体的好处,从而节省了下载 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 图标非常容易。

  1. 更新工具栏,将菜单按钮放置在标题左侧。

  2. 添加一个fxFlex,以便将剩余的图标右对齐。

  3. 添加用户个人资料和注销图标:

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> `,
  1. 添加一个最小的登录模板:
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, Lemonite!</span> <button mat-raised-button color="primary">Login</button> </div> `

您的应用程序应该类似于这个屏幕截图:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (89)LemonMart with minimal login

在实现和显示/隐藏菜单、个人资料和注销图标方面还有一些工作要做,考虑到用户的身份验证状态。我们将在第九章中涵盖这些功能,设计身份验证和授权。现在您已经为应用程序设置了基本路由,需要学习如何在移动到设置带有子组件的延迟加载模块之前调试您的 Angular 应用程序。

Augury 是用于调试和分析 Angular 应用程序的 Chrome Dev Tools 扩展。这是一个专门为帮助开发人员直观地浏览组件树、检查路由状态并通过源映射在生成的 JavaScript 代码和开发人员编写的 TypeScript 代码之间启用断点调试的工具。您可以从augury.angular.io下载 Augury。安装后,当您为 Angular 应用程序打开 Chrome Dev Tools 时,您会注意到一个新的 Augury 标签,如下所示:

Chrome Dev Tools Augury

Augury 在理解您的 Angular 应用程序在运行时的行为方面提供了有用和关键的信息:

  1. 当前的 Angular 版本列出为版本 5.1.2

  2. 组件树

  3. 路由器树显示了应用程序中配置的所有路由

  4. NgModules 显示了AppModule和应用程序的子模块

组件树选项卡显示了所有应用程序组件之间的关系以及它们如何相互作用:

  1. 选择特定组件,如HomeComponent,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (90)Augury 组件树

右侧的属性选项卡将显示一个名为“查看源代码”的链接,您可以使用它来调试您的组件。在下面更深的地方,您将能够观察组件属性的状态,例如 displayLogin 布尔值,包括您注入到组件中的服务及其状态。

您可以通过双击值来更改任何属性的值。例如,如果您想将 displayLogin 的值更改为false,只需双击包含 true 值的蓝色框并输入 false。您将能够观察到您的更改在您的 Angular 应用程序中的影响。

为了观察HomeComponent的运行时组件层次结构,您可以观察注射器图。

  1. 单击注射器图选项卡,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (91)Augury 注射器图

该视图显示了您选择的组件是如何被渲染的。在这种情况下,我们可以观察到HomeComponentAppComponent内部被渲染。这种可视化在追踪陌生代码库中特定组件的实现或存在深层组件树的情况下非常有帮助。

让我再次重申,console.log语句绝对不应该提交到您的代码库中。一般来说,它们是浪费您的时间,因为它需要编辑代码,然后清理您的代码。此外,Augury 已经提供了您组件的状态,因此在简单的情况下,您应该能够利用它来观察或强制状态。

有一些特定用例,其中console.log语句可能会有用。这些大多是并行操作的异步工作流,并且依赖于及时的用户交互。在这些情况下,控制台日志可以帮助您更好地理解事件流和各个组件之间的交互。

Augury 目前还不够复杂,无法解决异步数据或通过函数返回的数据。还有其他常见情况,你可能希望观察属性的状态在设置时,甚至能够实时更改它们的值,以强制代码执行if-elseswitch语句中的分支逻辑。对于这些情况,你应该使用断点调试。

假设HomeComponent上存在一些基本逻辑,它根据从AuthService获取的isAuthenticated值设置了一个displayLogin布尔值,如下所示:

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. 点击HomeComponent上的查看源链接

  2. ngOnInit函数内的第一行上设置一个断点

  3. 刷新页面

  4. Chrome Dev Tools 将切换到源标签页,你会看到断点被触发,如蓝色所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (92)Chrome Dev Tools 断点调试

  1. 悬停在this.displayLogin上并观察其值设置为true

  2. 如果悬停在this.authService.isAuthenticated()上,你将无法观察到其值

当你的断点被触发时,你可以在控制台中访问当前状态的作用域,这意味着你可以执行函数并观察其值。

  1. 在控制台中执行isAuthenticated()
> !this.authService.isAuthenticated()true

你会注意到它返回了true,这就是this.displayLogin的设置值。你仍然可以在控制台中强制转换displayLogin的值。

  1. displayLogin设置为false
> this.displayLogin = falsefalse

如果你观察displayLogin的值,无论是悬停在上面还是从控制台中检索,你会发现值被设置为false

利用断点调试基础知识,你可以在不改变源代码的情况下调试复杂的场景。

路由树标签将显示路由的当前状态。这可以是一个非常有用的工具,可以帮助你可视化路由和组件之间的关系,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (93)Augury 路由树

前面的路由树展示了一个深度嵌套的路由结构,其中包含主细节视图。你可以通过点击圆形节点来查看渲染给定组件所需的绝对路径和参数。

如您所见,对于PersonDetailsComponent来说,确定需要渲染主细节视图中的详细部分所需的参数集可能会变得复杂。

NgModules 选项卡显示了当前加载到内存中的AppModule和任何其他子模块:

  1. 启动应用程序的/home路由

  2. 观察 NgModules 选项卡,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (94)Augury NgModules

您会注意到只有AppModule被加载。但是,由于我们的应用程序采用了延迟加载的架构,我们的其他模块尚未被加载。

  1. 导航到ManagerModule中的一个页面

  2. 然后,导航到UserModule中的一个页面

  3. 最后,导航回到/home路由

  4. 观察 NgModules 选项卡,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (95)Augury NgModules with Three Modules

  1. 现在,您会注意到已经加载了三个模块到内存中。

NgModules 是一个重要的工具,可以可视化设计和架构的影响。

延迟加载允许由 webpack 驱动的 Angular 构建过程将我们的 Web 应用程序分隔成不同的 JavaScript 文件,称为块。通过将应用程序的部分分离成单独的子模块,我们允许这些模块及其依赖项被捆绑到单独的块中,从而将初始 JavaScript 捆绑包大小保持在最小限度。随着应用程序的增长,首次有意义的绘制时间保持恒定,而不是随着时间的推移不断增加。延迟加载对于实现可扩展的应用程序架构至关重要。

现在我们将介绍如何设置具有组件和路由的子模块。我们还将使用 Augury 来观察我们各种路由配置的效果。

管理模块需要一个着陆页,如此模拟所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (96)Manager's Dashboard 让我们从为ManagerModule创建主屏幕开始:

  1. 创建ManagerHome组件:
$ npx ng g c manager/managerHome -m manager -s -t

为了在manager文件夹下创建新组件,我们必须在组件名称前面加上manager/前缀。此外,我们指定该组件应该被导入并在ManagerModule中声明。由于这是另一个着陆页,它不太可能复杂到需要单独的 HTML 和 CSS 文件。您可以使用--inline-style(别名-s)和/或--inline-template(别名-t)来避免创建额外的文件。

  1. 验证您的文件夹结构如下:
 /src ├───app │ │ │ ├───manager │ │ │ manager-routing.module.ts │ │ │ manager.module.ts │ │ │ │ │ └───manager-home │ │ manager-home.component.spec.ts │ │ manager-home.component.ts
  1. 使用manager-routing.module配置ManagerHome组件的路由,类似于我们如何使用app-route.module配置Home组件:
src/app/manager/manager-routing.module.tsimport { ManagerHomeComponent } from './manager-home/manager-home.component'import { ManagerComponent } from './manager.component'const routes: Routes = [ { path: '', component: ManagerComponent, children: [ { path: '', redirectTo: '/manager/home', pathMatch: 'full' }, { path: 'home', component: ManagerHomeComponent }, ], },]

您会注意到http://localhost:5000/manager实际上还没有解析到一个组件,因为我们的 Angular 应用程序不知道ManagerModule的存在。让我们首先尝试强制急加载的方法,导入manager.module并注册 manager 路由到我们的应用程序。

这一部分纯粹是为了演示我们迄今为止学到的导入和注册路由的概念,并不会产生可扩展的解决方案,无论是急加载还是懒加载组件:

  1. manager.module导入到app.module中:
 src/app/app.module.ts import { ManagerModule } from './manager/manager.module' ... imports: [ ... ManagerModule, ]

您会注意到http://localhost:5000/manager仍然没有渲染其主组件。

  1. 使用 Augury 调试路由状态,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (97)带有急加载的路由树

  1. 似乎/manager路径已经正确注册并指向正确的组件ManagerHomeComponent。问题在于app-routing.module中配置的rootRouter并不知道/manager路径,因此**路径优先,并渲染PageNotFoundComponent

  2. 作为最后的练习,在app-routing.module中实现'manager'路径,并像平常一样将ManagerHomeComponent分配给它:

src/app/app-routing.module.tsimport { ManagerHomeComponent } from './manager/manager-home/manager-home.component' ...const routes: Routes = [ ... { path: 'manager', component: ManagerHomeComponent }, { path: '**', component: PageNotFoundComponent },]

现在您会注意到http://localhost:5000/manager正确显示manager-home works!;然而,如果您通过 Augury 调试路由状态,您会注意到/manager注册了两次。

这个解决方案不太可扩展,因为它强制所有开发人员维护一个单一的主文件来导入和配置每个模块。它容易产生合并冲突和沮丧,希望团队成员不会多次注册相同的路由。

可以设计一个解决方案将模块分成多个文件。您可以在manager.module中实现 Route 数组并导出它,而不是标准的*-routing.module。考虑以下示例:

example/manager/manager.moduleexport const managerModuleRoutes: Routes = [ { path: '', component: ManagerHomeComponent }]

然后需要将这些文件单独导入到app-routing.module中,并使用children属性进行配置:

example/app-routing.moduleimport { managerModuleRoutes } from './manager/manager.module'...{ path: 'manager', children: managerModuleRoutes },

这个解决方案将起作用,这是一个正确的解决方案,正如 Augury 路由树所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (98)带有子路由的路由树

没有重复的注册,因为我们删除了manager-routing.module。此外,我们不必在manager.module之外导入ManagerHomeComponent,从而得到一个更好的可扩展解决方案。然而,随着应用程序的增长,我们仍然必须在app.module中注册模块,并且子模块仍然以潜在不可预测的方式耦合到父app.module中。此外,这段代码无法被分块,因为使用import导入的任何代码都被视为硬依赖。

现在您了解了模块的急加载如何工作,您将能够更好地理解我们即将编写的代码,否则这些代码可能看起来像黑魔法,而神奇(也就是被误解的)代码总是导致意大利面式架构。

我们现在将急加载解决方案演变为懒加载解决方案。为了从不同模块加载路由,我们知道不能简单地导入它们,否则它们将被急加载。答案在于在app-routing.module.ts中使用loadChildren属性配置路由,该属性使用字符串通知路由器如何加载子模块:

  1. 确保您打算懒加载的任何模块都被导入到app.module

  2. 删除添加到ManagerModule的任何路由

  3. 确保ManagerRoutingModule被导入到ManagerModule中。

  4. 使用loadChildren属性实现或更新管理器路径:

src/app/app-routing.module.tsimport { ... const routes: Routes = [ ... { path: 'manager', loadChildren: './manager/manager.module#ManagerModule' }, { path: '**', component: PageNotFoundComponent }, ] ...

懒加载是通过一个巧妙的技巧实现的,避免使用import语句。定义一个具有两部分的字符串文字,其中第一部分定义了模块文件的位置,例如app/manager/manager.module,第二部分定义了模块的类名。在构建过程和运行时可以解释字符串,以动态创建块,加载正确的模块并实例化正确的类。ManagerModule然后就像它自己的 Angular 应用程序一样,管理着所有子依赖项和路由。

  1. 更新manager-routing.module路由,考虑到 manager 现在是它们的根路由:
src/app/manager/manager-routing.module.tsconst routes: Routes = [ { path: '', redirectTo: '/manager/home', pathMatch: 'full' }, { path: 'home', component: ManagerHomeComponent },]

我们现在可以将ManagerHomeComponent的路由更新为更有意义的'home'路径。这个路径不会与app-routing.module中找到的路径冲突,因为在这个上下文中,'home'解析为'manager/home',同样,当路径为空时,URL 看起来像http://localhost:5000/manager

  1. 通过查看 Augury 来确认懒加载是否起作用,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (99)带有延迟加载的路由树ManagerHomeComponent的根节点现在命名为manager [Lazy]

使用我们在本章前面创建的 LemonMart 站点地图,我们需要完成应用程序的骨架导航体验。为了创建这种体验,我们需要创建一些按钮来链接所有模块和组件。我们将逐个模块进行:

  • 在开始之前,更新home.component上的登录按钮,链接到Manager模块:
src/app/home/home.component.ts ... <button mat-raised-button color="primary" routerLink="/manager">Login as Manager</button> ...

由于我们已经为ManagerModule启用了延迟加载,让我们继续完成它的其他导航元素。

在当前设置中,ManagerHomeComponentapp.component中定义的<router-outlet>中呈现,因此当用户从HomeComponent导航到ManagerHomeComponent时,app.component中实现的工具栏保持不变。如果我们在ManagerModule中实现类似的工具栏,我们可以为跨模块导航子页面创建一致的用户体验。

为了使这个工作,我们需要复制app.componenthome/home.component之间的父子关系,其中父级实现工具栏和<router-outlet>,以便子元素可以在其中呈现:

  1. 首先创建基本的manager组件:
$ npx ng g c manager/manager -m manager --flat -s -t

--flat选项跳过目录创建,直接将组件放在manager文件夹下,就像app.component直接放在app文件夹下一样。

  1. 使用activeLink跟踪实现导航工具栏:
src/app/manager/manager.component.tsstyles: [` 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中,这样做不会产生性能或内存开销。

  1. ManagerComponent应该被导入到ManagerModule中:
src/app/manager/manager.module.tsimport { MaterialModule } from '../material.module'import { ManagerComponent } from './manager.component'...imports: [... MaterialModule, ManagerComponent],
  1. 为子页面创建组件:
$ npx ng g c manager/userManagement -m manager$ npx ng g c manager/receiptLookup -m manager
  1. 创建父/子路由。我们知道我们需要以下路由才能导航到我们的子页面,如下所示:
example{ path: '', redirectTo: '/manager/home', pathMatch: 'full' },{ path: 'home', component: ManagerHomeComponent },{ path: 'users', component: UserManagementComponent },{ path: 'receipts', component: ReceiptLookupComponent },

为了定位在manager.component中定义的<router-outlet>,我们需要首先创建一个父路由,然后为子页面指定路由:

src/app/manager/manager-routing.module.ts...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 }, ] },]

现在您应该能够浏览应用程序。当您单击“登录为经理”按钮时,您将被带到此处显示的页面。可单击的目标已突出显示,如下所示:

带有可单击目标的经理仪表板

如果您单击 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
  1. 实现路由:

从在app-routing中实现懒加载开始:

src/app/app-routing.module.ts... { path: 'user', loadChildren: 'app/user/user.module#UserModule' },

确保app-routing.module中的PageNotFoundComponent路由始终是最后一个路由。

现在在user-routing中实现子路由:

src/app/user/user-routing.module.ts...const routes: Routes = [ { path: 'profile', component: ProfileComponent }, { path: 'logout', component: LogoutComponent },]

我们正在为NavigationMenuComponent实现路由,因为它将直接用作 HTML 元素。此外,由于userModule没有着陆页面,因此没有定义默认路径。

  1. 连接用户和注销图标:
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中导入MatTooltipModule。此外,确保为仅包含图标的按钮添加aria-label,以便依赖屏幕阅读器的残障用户仍然可以浏览您的 Web 应用程序。

  1. 确保应用程序正常运行。

请注意,两个按钮彼此之间距离太近,如下所示:

带图标的工具栏

  1. 您可以通过在<mat-toolbar>中添加fxLayoutGap="8px"来解决图标布局问题;然而,现在柠檬标志与应用程序名称相距太远,如图所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (100)带有填充图标的工具栏

  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>

如下截图所示,分组修复了布局问题:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (101)带有分组和填充元素的工具栏

从用户体验的角度来看,这更加理想;现在用户也可以通过点击柠檬返回到主页。

我们的基本框架假定经理的角色。为了能够访问我们即将创建的所有组件,我们需要使经理能够访问 pos 和 inventory 模块。

更新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中导航出去,因此工具栏消失是正常的。

现在,你需要实现剩下的两个模块。

POS 模块与用户模块非常相似,只是PosComponent将成为默认路由。这将是一个复杂的组件,带有一些子组件,因此请确保它是在一个目录中创建的:

  1. 创建PosComponent

  2. PosComponent注册为默认路由

  3. PosModule配置延迟加载

  4. 确保应用程序正常运行

库存模块与ManagerModule非常相似,如图所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (102)库存仪表盘模拟

  1. 创建基本的Inventory组件

  2. 注册MaterialModule

  3. 创建库存仪表盘、库存录入、产品和类别组件

  4. inventory-routing.module中配置父子路由

  5. InventoryModule配置延迟加载

  6. 确保应用程序正常运行,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (103)LemonMart 库存仪表盘

现在应用程序的基本框架已经完成,重要的是检查路由树,以确保延迟加载已经正确配置,并且模块没有意外地急加载。

导航到应用程序的基本路由,并使用 Augury 检查路由树,如图所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (104)急加载错误的路由树

除了最初需要的组件之外,其他所有内容都应该用[Lazy]属性标记。如果由于某种原因,路由没有用[Lazy]标记,那么它们很可能被错误地导入到app.module或其他组件中。

在上面的截图中,您可能会注意到ProfileComponentLogoutComponent是急加载的,而user模块被正确标记为[Lazy]。即使通过工具和代码库进行多次视觉检查,也可能让您寻找罪魁祸首。但是,如果您全局搜索UserModule,您很快就会发现它被导入到app.module中。

为了安全起见,请确保删除app.module中的模块导入语句,您的文件应该像下面这样:

src/app/app.module.tsimport { FlexLayoutModule } from '@angular/flex-layout'import { BrowserModule } from '@angular/platform-browser'import { NgModule } from '@angular/core'import { AppRoutingModule } from './app-routing.module'import { AppComponent } from './app.component'import { BrowserAnimationsModule } from '@angular/platform-browser/animations'import { MaterialModule } from './material.module'import { HomeComponent } from './home/home.component'import { PageNotFoundComponent } from './page-not-found/page-not-found.component'import { HttpClientModule } from '@angular/common/http'@NgModule({ declarations: [AppComponent, HomeComponent, PageNotFoundComponent], imports: [ BrowserModule, AppRoutingModule, BrowserAnimationsModule, MaterialModule, HttpClientModule, FlexLayoutModule, ], providers: [], bootstrap: [AppComponent],})export class AppModule {}

下一张截图显示了修正后的路由器树:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (105)带有延迟加载的路由器树确保在继续之前执行npm testnpm run e2e时没有错误。

现在我们有很多模块要处理,配置每个规范文件的导入和提供者变得很繁琐。为此,我建议创建一个通用测试模块,其中包含您可以在各个领域重复使用的通用配置。

首先创建一个新的.ts文件。

  1. 创建common/common.testing.ts

  2. 用通用测试提供者、虚拟和模块填充它,如下所示:

我已经提供了ObservableMediaMatIconRegistryDomSanitizer的虚拟实现,以及commonTestingProviderscommonTestingModules的数组。

src/app/common/common.testing.tsimport { HttpClientTestingModule } from '@angular/common/http/testing'import { MediaChange } from '@angular/flex-layout'import { FormsModule, ReactiveFormsModule } from '@angular/forms'import { SafeResourceUrl, SafeValue } from '@angular/platform-browser'import { NoopAnimationsModule } from '@angular/platform-browser/animations'// tslint:disable-next-line:max-line-lengthimport { SecurityContext } from '@angular/platform-browser/src/security/dom_sanitization_service'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 ObservableMediaFake { 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 { _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 { if (this._document || typeof document !== 'undefined') { 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 ? value.toString() : null }}export const commonTestingProviders: any[] = [ // intentionally left blank]export const commonTestingModules: any[] = [ FormsModule, ReactiveFormsModule, MaterialModule, NoopAnimationsModule, HttpClientTestingModule, RouterTestingModule,]

现在让我们看一下这个共享配置文件的示例用法:

src/app/app.component.spec.ts import { commonTestingModules, commonTestingProviders, MatIconRegistryFake, DomSanitizerFake, ObservableMediaFake,} from './common/common.testing'import { ObservableMedia } from '@angular/flex-layout'import { MatIconRegistry } from '@angular/material'import { DomSanitizer } from '@angular/platform-browser'...TestBed.configureTestingModule({ imports: commonTestingModules, providers: commonTestingProviders.concat([ { provide: ObservableMedia, useClass: ObservableMediaFake }, { provide: MatIconRegistry, useClass: MatIconRegistryFake }, { provide: DomSanitizer, useClass: DomSanitizerFake }, ]), declarations: [AppComponent],...

大多数其他模块只需要导入commonTestingModules

在所有测试通过之前不要继续前进!

在本章中,您学会了如何有效地使用 Angular CLI 来创建主要的 Angular 组件和脚手架。您创建了您的应用的品牌,利用了自定义和内置的 Material 图标。您学会了如何使用 Augury 调试复杂的 Angular 应用。最后,您开始构建基于路由器的应用程序,尽早定义用户角色,考虑懒加载的设计,并尽早确定行走骨架导航体验。

总结一下,为了实现基于路由器的实现,您需要这样做:

  1. 尽早定义用户角色

  2. 考虑懒加载的设计

  3. 实现一个行走骨架导航体验

  4. 围绕主要数据组件进行设计

  5. 强制执行解耦的组件架构

  6. 区分用户控件和组件

  7. 最大程度地重用代码

在这一章中,您执行了 1-3 步;在接下来的三章中,您将执行 4-7 步。在第八章中,《持续集成和 API 设计》,我们将讨论围绕主要数据组件进行设计,并启用持续集成以确保高质量的可交付成果。在第九章中,《设计身份验证和授权》,我们将深入探讨安全考虑,并设计有条件的导航体验。在第十章中,《Angular 应用设计和配方》,我们将通过坚持解耦的组件架构,巧妙选择创建用户控件与组件,并利用各种 TypeScript、RxJS 和 Angular 编码技术来最大程度地重用代码。

在我们开始为我们的 LOB 应用 LemonMart 构建更复杂的功能之前,我们需要确保我们创建的每个代码推送都通过了测试,符合编码标准,并且是团队成员可以运行测试的可执行构件,因为我们继续进一步开发我们的应用。同时,我们需要开始考虑我们的应用将如何与后端服务器进行通信。无论是您、您的团队还是其他团队将创建新的 API,都很重要的是 API 设计能够满足前端和后端架构的需求。为了确保开发过程顺利进行,需要一个强大的机制来为 API 创建一个可访问的、实时的文档。持续集成CI)可以解决第一个问题,而 Swagger 非常适合解决 API 设计、文档和测试需求。

持续集成对于确保质量可交付成果至关重要,它会在每次代码推送时构建和执行测试。建立 CI 环境可能会耗费时间,并需要对所使用的工具有专门的知识。CircleCI 是一个成熟的基于云的 CI 服务,拥有免费的套餐和有用的文章,可以让您尽可能少地进行配置就能开始使用。我们将介绍一种基于 Docker 的方法,可以在大多数 CI 服务上运行,使您的特定配置知识保持相关,并将 CI 服务知识降至最低。

全栈开发的另一个方面是,您可能会同时开发应用程序的前端和后端。无论您是独自工作,还是与团队或多个团队合作,建立数据契约都是至关重要的,以确保您不会在最后关头遇到集成挑战。我们将使用 Swagger 为 REST API 定义数据契约,然后创建一个模拟服务器,您的 Angular 应用程序可以向其发出 HTTP 调用。对于后端开发,Swagger 可以作为生成样板代码的良好起点,并且可以作为 API 的实时文档和测试 UI。

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

  • 使用 CircleCI 的 CI

  • 使用 Swagger 进行 API 设计

本章需要以下内容:

  • 一个免费的 CircleCI 账户

  • Docker

持续集成的目标是在每次代码推送时实现一致且可重复的环境,用于构建、测试和生成可部署的应用程序成果。在推送代码之前,开发人员应该合理地期望他们的构建会通过;因此,创建一个可靠的持续集成环境,自动化开发人员也可以在本地机器上运行的命令是至关重要的。

为了确保跨各种操作系统平台、开发者机器和持续集成环境的一致构建环境,您可以将构建环境容器化。请注意,目前至少有半打常见的持续集成工具在使用中。学习每个工具的细节几乎是一项不可能完成的任务。构建环境的容器化是一个高级概念,超出了当前持续集成工具的预期。然而,容器化是标准化您的构建基础设施的绝佳方式,几乎可以在任何持续集成环境中执行。通过这种方法,您学到的技能和创建的构建配置变得更有价值,因为您的知识和创建的工具都变得可转移和可重复使用。

有许多策略可以将构建环境容器化,具有不同的粒度和性能期望。对于本书的目的,我们将专注于可重用性和易用性。我们将专注于一个简单和直接的工作流程,而不是创建一个复杂的、相互依赖的一组 Docker 映像,这可能允许更有效的失败优先和恢复路径。较新版本的 Docker 具有一个很棒的功能,称为多阶段构建,它允许您以易于阅读的方式定义多个映像过程,并维护一个单一的Dockerfile

在流程结束时,您可以提取一个优化的容器映像作为我们的交付成果,摆脱先前流程中使用的映像的复杂性。

作为提醒,您的单个Dockerfile将类似于下面的示例:

DockerfileFROM duluca/minimal-node-web-server:8.11.1WORKDIR /usr/src/appCOPY dist public

多阶段工作是通过在单个Dockerfile中使用多个FROM语句来实现的,其中每个阶段可以执行一个任务,并使其实例内的任何资源可用于其他阶段。在构建环境中,我们可以将各种与构建相关的任务实现为它们自己的阶段,然后将最终结果,例如 Angular 构建的dist文件夹,复制到包含 Web 服务器的最终镜像中。在这种情况下,我们将实现三个阶段的镜像:

  • 构建器:用于构建 Angular 应用程序的生产版本

  • 测试器:用于对无头 Chrome 实例运行单元测试和端到端测试

  • Web 服务器:最终结果仅包含优化的生产位

多阶段构建需要 Docker 版本 17.05 或更高版本。要了解有关多阶段构建的更多信息,请阅读docs.docker.com/develop/develop-images/multistage-build/中的文档。

从创建一个新文件开始,以实现多阶段配置,命名为Dockerfile.integration,位于项目的根目录。

第一个阶段是构建器。我们需要一个轻量级的构建环境,可以确保一致的构建。为此,我创建了一个基于 Alpine 的 Node 构建环境示例,其中包含 npm、bash 和 git 工具。有关为什么我们使用 Alpine 和 Node 的更多信息,请参阅第三章准备 Angular 应用程序进行生产发布使用 Docker 容器化应用程序部分。

  1. 实现一个新的 npm 脚本来构建你的 Angular 应用程序:
"scripts": { "build:prod": "ng build --prod",}
  1. 从基于 Node.js 的构建环境继承,如node:10.1duluca/minimal-node-build-env:8.11.2

  2. 实现你的特定环境构建脚本,如下所示:

请注意,在发布时,低级 npm 工具中的一个错误阻止了基于node的镜像成功安装 Angular 依赖项。这意味着下面的示例Dockerfile基于较旧版本的 Node 和 npm,使用了duluca/minimal-node-build-env:8.9.4。在将来,当错误得到解决时,更新的构建环境将能够利用npm ci来安装依赖项,这将比npm install命令带来显著的速度提升。

Dockerfile.integrationFROM duluca/minimal-node-build-env:8.9.4 as builder# project variablesENV SRC_DIR /usr/srcENV GIT_REPO https://github.com/duluca/lemon-mart.gitENV SRC_CODE_LOCATION .ENV BUILD_SCRIPT build:prod# get source codeRUN mkdir -p $SRC_DIRWORKDIR $SRC_DIR# if necessary, do SSH setup here or copy source code from local or CI environmentRUN git clone $GIT_REPO .# COPY $SRC_CODE_LOCATION .RUN npm installRUN npm run $BUILD_SCRIPT

在上面的示例中,容器正在从 GitHub 拉取源代码。我选择这样做是为了保持示例简单,因为在本地和远程持续集成环境中它的工作方式是相同的。然而,您的持续集成服务器将已经有源代码的副本,您需要从持续集成环境中复制然后放入容器中。

您可以使用COPY $SRC_CODE_LOCATION .命令从持续集成服务器或本地计算机复制源代码,而不是使用RUN git clone $GIT_REPO .命令。如果这样做,您将需要实现一个.dockerignore文件,它与您的.gitignore文件有些相似,以确保不会泄露机密信息,不会复制node_modules,并且配置在其他环境中是可重复的。在持续集成环境中,您将需要覆盖环境变量$SRC_CODE_LOCATION,以便COPY命令的源目录是正确的。随时创建多个适合您各种需求的Dockerfile版本。

此外,我构建了一个基于node-alpine的最小 Node 构建环境duluca/minimal-node-build-env,您可以在 Docker Hub 上观察到它,网址为hub.docker.com/r/duluca/minimal-node-build-env。这个镜像比node小大约十倍。Docker 镜像的大小对构建时间有真正的影响,因为持续集成服务器或您的团队成员将花费额外的时间拉取更大的镜像。选择最适合您需求的环境。

根据您的特定需求,Dockerfile的构建部分的初始设置可能会令人沮丧。为了测试新命令或调试错误,您可能需要直接与构建环境进行交互。

为了在构建环境中进行交互实验和/或调试,执行以下操作:

$ docker run -it duluca/minimal-node-build-env:8.9.4 /bin/bash

在将命令嵌入您的Dockerfile之前,您可以在此临时环境中测试或调试命令。

第二阶段是tester。默认情况下,Angular CLI 生成了一个针对开发环境的测试要求。这在持续集成环境中不起作用;我们必须配置 Angular 以针对一个无需 GPU 辅助执行的无头浏览器,并且进一步,一个容器化环境来执行测试。

Angular 测试工具在第三章中有所涵盖,为生产发布准备 Angular 应用程序

Protractor 测试工具正式支持在无头模式下运行 Chrome。为了在持续集成环境中执行 Angular 测试,您需要配置您的测试运行器 Karma 以使用无头 Chrome 实例运行:

  1. 更新karma.conf.js以包括新的无头浏览器选项:
src/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自定义启动器封装了所有需要的配置元素,以便进行良好的默认设置。

  1. 更新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选项,如前所示,来更改浏览器设置。

  1. 更新package.json脚本以在生产构建场景中选择新的浏览器选项:
package.json"scripts": { ... "test:prod": "npm test -- --watch=false" ...}

请注意,test:prod不包括npm run e2e。e2e 测试是需要更长时间执行的集成测试,因此在包含它们作为关键构建流程的一部分时要三思。e2e 测试将不会在下一节提到的轻量级测试环境中运行,因此它们将需要更多的资源和时间来执行。

对于轻量级测试环境,我们将利用基于 Alpine 的 Chromium 浏览器安装:

  1. 继承自slapers/alpine-node-chromium

  2. 将以下配置附加到Docker.integration

Docker.integration...FROM slapers/alpine-node-chromium as testerENV BUILDER_SRC_DIR /usr/srcENV SRC_DIR /usr/srcENV TEST_SCRIPT test:prodRUN mkdir -p $SRC_DIRWORKDIR $SRC_DIRCOPY --from=builder $BUILDER_SRC_DIR $SRC_DIRCMD 'npm run $TEST_SCRIPT'

上述脚本将从builder阶段复制生产构建,并以可预测的方式执行您的测试脚本。

第三和最后阶段生成将成为您的 Web 服务器的容器。一旦完成此阶段,先前的阶段将被丢弃,最终结果将是一个优化的小于 10MB 的容器:

  1. 使用 Docker 将您的应用程序容器化,如第三章中所讨论的,为生产发布准备 Angular 应用程序

  2. 在文件末尾添加FROM语句

  3. builder中复制生产就绪代码,如下所示:

Docker.integration...FROM duluca/minimal-nginx-web-server:1.13.8-alpineENV BUILDER_SRC_DIR /usr/srcCOPY --from=builder $BUILDER_SRC_DIR/dist /var/wwwCMD 'nginx'
  1. 构建和测试您的多阶段Dockerfile
$ docker build -f Dockerfile.integration .

如果您从 GitHub 拉取代码,请确保在构建容器之前提交和推送您的代码,因为它将直接从存储库中拉取您的源代码。使用--no-cache选项确保拉取新的源代码。如果您从本地或 CI 环境复制代码,则不要使用--no-cache,因为您将无法从能够重用先前构建的容器层中获得速度提升。

  1. 将脚本保存为名为build:ci的新 npm 脚本,如下所示:
package.json"scripts": { ... "build:ci": "docker build -f Dockerfile.integration . -t $npm_package_config_imageRepo:latest", ...}

CircleCI 使得轻松开始使用免费套餐,并为初学者和专业人士提供了很好的文档。如果您有独特的企业需求,可以将 CircleCI 部署在企业内部,企业防火墙后,或作为云中的私有部署。

CircleCI 具有预先配置的构建环境,适用于免费设置的虚拟配置,但也可以使用 Docker 容器运行构建,这使得它成为一个可以根据用户技能和需求进行扩展的解决方案,正如“容器化构建环境”部分所述:

  1. circleci.com/上创建一个 CircleCI 帐户

  2. 使用 GitHub 注册:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (106)CircleCI 注册页面

  1. 添加新项目:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (107)CircleCI 项目页面

在下一个屏幕上,您可以选择 Linux 或 macOS 构建环境。macOS 构建环境非常适用于构建 iOS 或 macOS 应用程序。但是,这些环境没有免费套餐;只有具有 1x 并行性的 Linux 实例是免费的。

  1. 搜索 lemon-mart 并点击设置项目

  2. 选择 Linux

  3. 选择平台 2.0

  4. 选择语言为其他,因为我们将使用自定义容器化构建环境

  5. 在您的源代码中,创建一个名为.circleci的文件夹,并添加一个名为config.yml的文件:

.circleci/config.ymlversion: 2jobs: build: docker: - image: docker:17.12.0-ce-git working_directory: /usr/src steps: - checkout - setup_remote_docker: docker_layer_caching: false - run: name: Build Docker Image command: | npm run build:ci

在前面的文件中,定义了一个build作业,它基于 CircleCI 预先构建的docker:17.12.0-ce-git镜像,其中包含 Docker 和 git CLI 工具。然后我们定义了构建“步骤”,使用checkout从 GitHub 检出源代码,通知 CircleCI 使用setup_remote_docker命令设置 Docker-within-Docker 环境,然后执行docker build -f Dockerfile.integration .命令来启动我们的自定义构建过程。

为了优化构建,您应该尝试使用层缓存和从 CircleCI 中已经检出的源代码复制源代码。

  1. 将更改同步到 Github

  2. 在 CircleCI 上,点击创建您的项目

如果一切顺利,您将获得通过的绿色构建。如下截图所示,构建#4 成功:

CircleCI 上的绿色构建

目前,CI 服务器正在运行,在第 1 阶段构建应用程序,然后在第 2 阶段运行测试,然后在第 3 阶段构建 Web 服务器。请注意,我们没有对此 Web 服务器容器映像执行任何操作,例如将其部署到服务器。

为了部署您的映像,您需要实现一个部署步骤。在此步骤中,您可以部署到多个目标,如 Docker Hub,Zeit Now,Heroku 或 AWS ECS。与这些目标的集成将涉及多个步骤。在高层次上,这些步骤如下:

  1. 使用单独的运行步骤安装特定于目标的 CLI 工具

  2. 使用特定于目标环境的登录凭据配置 Docker,并将这些凭据存储为 CircleCI 环境变量

  3. 使用docker push将生成的 Web 服务器映像提交到目标的 Docker 注册表

  4. 执行特定于平台的deploy命令,指示目标运行刚刚推送的 Docker 映像。

如何从本地开发环境配置 AWS ECS 上的此类部署在第十一章中有所介绍,AWS 上高可用云基础设施

了解您的 Angular 项目的单元测试覆盖量和趋势的一个好方法是通过代码覆盖报告。

为了为您的应用程序生成报告,请从项目文件夹中执行以下命令:

$ npx ng test --browsers ChromiumNoSandbox --watch=false --code-coverage

生成的报告将以 HTML 形式创建在名为 coverage 的文件夹下;执行以下命令在浏览器中查看:

$ npx http-server -c-1 -o -p 9875 ./coverage

这是由istanbul.js生成的 LemonMart 的文件夹级示例覆盖报告:

LemonMart 的 Istanbul 代码覆盖报告

您可以深入研究特定文件夹,例如src/app/auth,并获得文件级报告,如下所示:

Istanbul 代码覆盖报告适用于 src/app/auth

您可以进一步深入了解给定文件的行级覆盖率,例如cache.service.ts,如下所示:

cache.service.ts 的 Istanbul 代码覆盖报告

在前面的图像中,您可以看到第 5、12、17-18 和 21-22 行没有被任何测试覆盖。图标表示 if 路径未被执行。我们可以通过实现对CacheService中包含的函数进行单元测试来增加我们的代码覆盖率。作为练习,读者应该尝试至少用一个新的单元测试覆盖其中一个函数,并观察代码覆盖报告的变化。

理想情况下,您的 CI 服务器配置应该以一种方便访问的方式生成和托管代码覆盖报告,并在每次测试运行时执行。将这些命令作为package.json中的脚本实现,并在 CI 流水线中执行它们。这个配置留给读者作为一个练习。

http-server安装为项目的开发依赖项。

在全栈开发中,早期确定 API 设计非常重要。API 设计本身与您的数据契约的外观密切相关。您可以创建 RESTful 端点,也可以使用下一代 GraphQL 技术。在设计 API 时,前端和后端开发人员应该密切合作,以实现共享的设计目标。一些高层目标列如下:

  • 最小化客户端和服务器之间传输的数据

  • 坚持使用成熟的设计模式(即分页)

  • 设计以减少客户端中存在的业务逻辑

  • 扁平化数据结构

  • 不要暴露数据库键或关系

  • 从一开始就版本化端点

  • 围绕主要数据组件进行设计

重要的是不要重复造轮子,并且在设计 API 时采取一种有纪律的,如果不是严格的方法是很重要的。API 设计错误的下游影响可能是深远的,一旦您的应用程序上线就无法纠正。

我将详细介绍围绕主要数据组件进行设计,并实现一个示例的 Swagger 端点。

围绕主要数据组件进行设计有助于组织您的 API。这将大致匹配您在 Angular 应用程序的各个组件中使用数据的方式。我们将首先通过创建一个粗略的数据实体图来定义我们的主要数据组件,然后使用 swagger 为用户数据实体实现一个示例 API。

让我们首先尝试一下您想要存储的实体类型以及这些实体之间可能的关系。

这是一个使用draw.io创建的 LemonMart 的样本设计:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (108) LemonMart 的数据实体图

此时,您的实体是存储在 SQL 还是 NoSQL 数据库中并不重要。我的建议是坚持你所知道的,但如果你是从零开始的,像 MongoDB 这样的 NoSQL 数据库将在您的实现和需求发展时提供最大的灵活性。

Swagger 将允许您设计您的 Web API。对于团队来说,它可以充当前端和后端团队之间的接口。此外,通过 API 模拟,您甚至可以在 API 的实现开始之前开发和完成 API 功能。

随着我们的进展,我们将实现一个示例用户 API,以演示 Swagger 的工作原理。

示例项目附带了 VS Code 的推荐扩展。Swagger Viewer 允许我们在不运行任何其他工具的情况下预览 YAML 文件。

components下,添加共享的parameters,使其易于重用常见模式,如分页端点:

示例代码存储库可以在github.com/duluca/lemon-mart-swagger-server找到。对于您的模拟 API 服务器,您应该创建一个单独的 git 存储库,以便前端和后端之间的这个契约可以分开维护。

  1. 创建一个名为lemon-mart-swagger-server的新 GitHub 存储库

  2. 开始定义一个带有一般信息和目标服务器的 YAML 文件:

swagger.oas3.yamlopenapi: 3.0.0info: title: LemonMart description: LemonMart API version: "1.0.0"servers: - url: http://localhost:3000 description: Local environment - url: https://mystagingserver.com/v1 description: Staging environment - url: https://myprodserver.com/v1 description: Production environment
  1. Swagger 规范的最广泛使用和支持的版本是swagger: '2.0'。下面的示例是使用更新的、基于标准的openapi: 3.0.0给出的。示例代码存储库包含了这两个示例。然而,在发布时,Swagger 生态系统中的大多数工具都依赖于 2.0 版本。在components下,定义共享数据schemas
swagger.oas3.yaml...components: schemas: Role: type: string enum: [clerk, cashier, manager] Name: type: object properties: first: type: string middle: type: string last: type: string User: type: object properties: id: type: string email: type: string name: $ref: '#/components/schemas/Name' picture: type: string role: $ref: '#/components/schemas/Role' userStatus: type: boolean lastModified: type: string format: date lastModifiedBy: type: string Users: type: object properties: total: type: number format: int32 items: $ref: '#/components/schemas/ArrayOfUser' ArrayOfUser: type: array items: $ref: '#/components/schemas/User'
  1. 定义一个 Swagger YAML 文件
swagger.oas3.yaml... parameters: offsetParam: # <-- Arbitrary name for the definition that will be used to refer to it. # Not necessarily the same as the parameter name. in: query name: offset required: false schema: type: integer minimum: 0 description: The number of items to skip before starting to collect the result set. limitParam: in: query name: limit required: false schema: type: integer minimum: 1 maximum: 50 default: 20 description: The numbers of items to return.
  1. paths下,为/users路径定义一个get端点:
...paths: /users: get: description: | Searches and returns `User` objects. Optional query params determines values of returned array parameters: - in: query name: search required: false schema: type: string description: Search text - $ref: '#/components/parameters/offsetParam' - $ref: '#/components/parameters/limitParam' responses: '200': # Response description: OK content: # Response body application/json: # Media type schema: $ref: '#/components/schemas/Users'
  1. paths下,添加get通过 ID 获取用户和update通过 ID 更新用户的端点:
swagger.oas3.yaml... /user/{id}: get: description: Gets a `User` object by id parameters: - in: path name: id required: true schema: type: string description: User's unique id responses: '200': # Response description: OK content: # Response body application/json: # Media type schema: $ref: '#/components/schemas/User' put: description: Updates a `User` object given id parameters: - in: query name: id required: true schema: type: string description: User's unique id - in: body name: userData schema: $ref: '#/components/schemas/User' style: form explode: false description: Updated user object responses: '200': description: OK content: # Response body application/json: # Media type schema: $ref: '#/components/schemas/User'

要验证你的 Swagger 文件,你可以使用在线编辑器editor.swagger.io。注意使用style: formexplode: false,这是配置期望基本表单数据的端点的最简单方式。要了解更多参数序列化选项或模拟认证端点和其他可能的配置,请参考swagger.io/docs/specification/上的文档。

使用你的 YAML 文件,你可以使用 Swagger Code Gen 工具生成一个模拟的 Node.js 服务器。

如前一节所述,本节将使用 YAML 文件的第 2 版,它可以使用官方工具生成服务器。然而,还有其他工具可以生成一些代码,但不完整到足够易于使用:

  1. 如果在项目文件夹中使用 OpenAPI 3.0,请执行以下命令:
$ npx swagger-node-codegen swagger.oas3.yaml -o ./server...Done! Check out your shiny new API at C:\dev\lemon-mart-swagger-server\server.

在一个名为server的新文件夹下,你现在应该有一个生成的 Node Express 服务器。

  1. 为服务器安装依赖项:
$ cd server$ npm install

然后,你必须手动实现缺失的存根来完成服务器的实现。

使用官方工具和 2.0 版本,你可以自动创建 API 和生成响应。一旦官方工具完全支持 OpenAPI 3.0,相同的指令应该适用:

  1. 将你的 YAML 文件发布到一个可以被你的机器访问的 URI 上:
https://raw.githubusercontent.com/duluca/lemon-mart-swagger-server/master/swagger.2.yaml
  1. 在你的项目文件夹中,执行以下命令,用你的 YAML 文件指向的 URI 替换<uri>
$ docker run --rm -v ${PWD}:/local swaggerapi/swagger-codegen-cli $ generate -i <uri> -l nodejs-server -o /local/server

与前一节类似,这将在服务器目录下创建一个 Node Express 服务器。为了执行这个服务器,继续以下步骤。

  1. npm install安装服务器的依赖项。

  2. 运行npm start。你的模拟服务器现在应该已经启动。

  3. 导航到http://localhost:3000/docs

  4. 尝试get /users的 API;你会注意到 items 属性是空的:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (109)Swagger UI - 用户端点

但是,你应该收到虚拟数据。我们将纠正这种行为。

  1. 尝试get /user/{id};你会看到你收到了一些虚拟数据:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (110)Swagger UI - 按用户 ID 端点

行为上的差异是因为,默认情况下,Node Express 服务器使用在 server/controllers/Default.js 下生成的控制器来读取在服务器创建期间从 server/service/DefaultService.js 生成的随机数据。然而,您可以禁用默认控制器,并强制 Swagger 进入更好的默认存根模式。

  1. 更新 index.js 以强制使用存根并注释掉控制器:
index.jsvar options = { swaggerUi: path.join(__dirname, '/swagger.json'), // controllers: path.join(__dirname, './controllers'), useStubs: true,}
  1. 再次尝试 /users 端点

正如您在这里所看到的,响应默认情况下具有更高的质量:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (111)Swagger UI - 使用虚拟数据的用户端点

在上述中,total 是一个整数,role 被正确定义,items 是一个有效的数组结构。

为了启用更好和更定制的数据模拟,您可以编辑 DefaultService.js。在这种情况下,您希望更新 usersGET 函数以返回一个定制用户数组。

在您能够从应用程序中使用您的服务器之前,您需要配置它以允许跨域资源共享CORS),以便您托管在 http://localhost:5000 上的 Angular 应用程序可以与您托管在 http://localhost:3000 上的模拟服务器进行通信:

  1. 安装 cors 包:
$ npm i cors
  1. 更新 index.js 以使用 cors
server/index.js...var cors = require('cors')...app.use(cors())// Initialize the Swagger middlewareswaggerTools.initializeMiddleware(swaggerDoc, function(middleware) {...

确保在 initializeMiddleware 之前调用 app.use(cors());否则,其他 Express 中间件可能会干扰 cors() 的功能。

您可以通过 SwaggerUI 验证您的 Swagger 服务器设置,SwaggerUI 将位于 http://localhost:3000/docs,或者您可以通过 VS Code 中的预览 Swagger 扩展实现更集成的环境。

我将演示如何使用这个扩展来从 VS Code 内部测试您的 API:

  1. 在资源管理器中选择 YAML 文件

  2. 按下 Shift + Alt + P 并执行预览 Swagger 命令

  3. 您将看到一个交互式窗口来测试您的配置,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (112)在 Visual Studio Code 中预览 Swagger 扩展

  1. 点击 /users 的 Get 按钮

  2. 点击 Try it out 查看结果

在 OpenAPI 3.0.0 中,您将看到一个服务器列表,包括本地和远程资源,而不是方案。这是一个非常方便的工具,可以在编写前端应用程序时探索各种数据源。

现在您已经验证了 Swagger 服务器,您可以发布服务器,使团队成员或需要可预测数据集才能成功执行的自动验收测试AAT)环境可以访问它。

执行以下步骤,如第三章中所述,为生产发布准备 Angular 应用程序

  1. 在根级别的package.json文件中为 Docker 添加 npm 脚本

  2. 添加一个Dockerfile

DockerfileFROM duluca/minimal-node-build-env:8.11.2RUN mkdir -p /usr/srcWORKDIR /usr/srcCOPY server .RUN npm ciCMD ["node", "index"]

构建容器后,您就可以部署它了。

我已经在 Docker Hub 上发布了一个示例服务器,网址为hub.docker.com/r/duluca/lemon-mart-swagger-server

在本章中,您学会了如何创建基于容器的持续集成环境。我们利用 CircleCI 作为基于云的 CI 服务,并强调您可以将构建结果部署到所有主要的云托管提供商。如果您启用了这样的自动化部署,您将实现持续部署CD)。通过 CI/CD 管道,您可以与客户和团队成员分享应用程序的每个迭代,并快速向最终用户交付错误修复或新功能。

我们还讨论了良好 API 设计的重要性,并确定 Swagger 作为一个有益于前端和后端开发人员的工具,用于定义和开发针对实时数据契约的应用。如果您创建了一个 Swagger 模拟服务器,您可以让团队成员拉取模拟服务器镜像,并在后端实现完成之前使用它来开发他们的前端应用程序。

CircleCI 和 Swagger 都是各自高度复杂的工具。本章提到的技术故意简单,但旨在实现复杂的工作流程,让您领略到这些工具的真正威力。您可以大大提高这种技术的效率和能力,但这些技术将取决于您的具体需求。

装备了 CI 和模拟的 API,我们可以发送真实的 HTTP 请求,我们准备快速迭代,同时确保高质量的可交付成果。在下一章中,我们将深入探讨如何使用基于令牌的身份验证和条件导航技术,为您的业务应用程序设计授权和身份验证体验,以实现平滑的用户体验,继续采用路由器优先的方法。

设计高质量的身份验证和授权系统而不会让最终用户感到沮丧是一个难题。身份验证是验证用户身份的行为,授权指定用户访问资源的特权。这两个过程,简称为 auth,必须无缝地协同工作,以满足具有不同角色、需求和工作职能的用户的需求。在今天的网络中,用户对通过浏览器遇到的任何 auth 系统都有很高的期望水平,因此这是您的应用程序中绝对需要第一次就完全正确的一个非常重要的部分。

用户应始终了解他们在应用程序中可以做什么和不能做什么。如果出现错误、失败或错误,用户应清楚地了解为什么会发生这样的错误。随着应用程序的增长,很容易忽略触发错误条件的所有方式。您的实现应易于扩展或维护,否则您的应用程序的基本骨架将需要大量的维护。在本章中,我们将介绍创建出色的 auth UX 的各种挑战,并实现一个坚实的基线体验。

我们将继续采用路由器优先方法来设计 SPA,通过实现 LemonMart 的身份验证和授权体验。在第七章中,创建基于路由器的企业应用程序,我们定义了用户角色,完成了所有主要路由的构建,并完成了 LemonMart 的粗略行走骨架导航体验,因此我们已经准备好实现基于角色的路由和拉取此类实现的细微差别。

在第八章中,持续集成和 API 设计,我们讨论了围绕主要数据组件进行设计的想法,因此您已经熟悉用户实体的外观,这将在实现基于令牌的登录体验中派上用场,包括在实体内缓存角色信息。

在深入研究 auth 之前,我们将讨论在开始实现各种条件导航元素之前,完成应用程序的高级模拟的重要性,这在设计阶段可能会发生重大变化。

在本章中,您将了解以下主题:

  • 高级 UX 设计的重要性

  • 基于令牌的身份验证

  • 条件导航

  • 侧边导航栏

  • 可重用的警报 UI 服务

  • 缓存数据

  • JSON Web Tokens

  • Angular HTTP 拦截器

  • 路由守卫

模型在确定我们在整个应用程序中需要哪种组件和用户控件方面非常重要。任何将在组件之间使用的用户控件或组件都需要在根级别定义,其他的则在其自己的模块中定义。

在第七章,创建一个以路由为首的业务应用程序中,我们已经确定了子模块并为它们设计了着陆页面,以完成行走的骨架。现在我们已经定义了主要的数据组件,我们可以为应用程序的其余部分完成模型。在高层次设计屏幕时,请牢记几件事:

  • 用户是否可以尽可能少地导航来完成其角色所需的常见任务?

  • 用户是否可以通过屏幕上可见的元素轻松访问应用程序的所有信息和功能?

  • 用户是否可以轻松搜索他们需要的数据?

  • 一旦用户找到感兴趣的记录,他们是否可以轻松地深入了解详细记录或查看相关记录?

  • 那个弹出警报真的有必要吗?您知道用户不会阅读它,对吧?

请记住,设计任何用户体验都没有一种正确的方式,这就是为什么在设计屏幕时,始终要牢记模块化和可重用性。

当您生成各种设计工件,如模型或设计决策时,请务必将它们发布在所有团队成员都可以访问的维基上:

  1. 在 GitHub 上,切换到 Wiki 选项卡

  2. 您可以查看我的示例维基,如下所示:Github.com/duluca/lemon-mart/wiki

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (113)GitHub.com LemonMart Wiki

  1. 创建维基页面时,请确保在任何其他可用文档之间进行交叉链接,例如 Readme

  2. 请注意,GitHub 在页面下显示维基上的子页面

  3. 然而,额外的摘要是有帮助的,比如设计工件部分,因为有些人可能会错过右侧的导航元素

  4. 完成模型后,请将其发布在维基上

您可以在这里看到维基的摘要视图:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (114)柠檬市场模型的摘要视图

  1. 可选地,将模型放在行走的骨架应用程序中,以便测试人员更好地设想尚未开发的功能

完成模拟后,我们现在可以继续使用身份验证和授权工作流来实现 LemonMart。

一个设计良好的身份验证工作流是无状态的,因此没有会话过期的概念。用户可以自由地与您的无状态 REST API 进行交互,无论他们希望同时或随后在多少设备和标签页上。JSON Web Token (JWT) 实现了基于分布式声明的身份验证,可以通过数字签名或集成保护和/或使用 消息认证码 (MAC) 进行加密。这意味着一旦用户的身份经过认证,比如说通过密码挑战,他们将收到一个编码的声明票据或令牌,然后可以使用它来对系统进行未来的请求,而无需重新验证用户的身份。服务器可以独立验证此声明的有效性并处理请求,而无需事先知道与该用户进行过互动。因此,我们不必存储有关用户的会话信息,使我们的解决方案无状态且易于扩展。每个令牌将在预定义的时间后过期,并且由于它们的分布式性质,无法远程或单独撤销;但是,我们可以通过插入自定义帐户和用户角色状态检查来加强实时安全性,以确保经过身份验证的用户有权访问服务器端资源。

JSON Web Tokens 实现了 IETF 行业标准 RFC7519,可以在 tools.ietf.org/html/rfc7519 找到。

良好的授权工作流程能够基于用户角色进行条件导航,以便用户自动进入最佳的登陆界面;他们不会看到不适合他们角色的路由或元素,如果他们错误地尝试访问一个授权的路径,他们将被阻止这样做。您必须记住,任何客户端角色导航仅仅是一种便利,而不是用于安全目的。这意味着每次向服务器发出的调用都应包含必要的头部信息,带有安全令牌,以便服务器可以重新验证用户,独立验证他们的角色,只有在这样做之后才允许检索安全数据。客户端身份验证是不可信的,这就是为什么密码重置屏幕必须使用服务器端渲染技术构建,以便用户和服务器都可以验证预期的用户正在与系统交互。

在接下来的部分中,我们将围绕用户数据实体设计一个完整的身份验证工作流程,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (115)用户实体

我们将首先创建一个具有真实和虚假登录提供程序的身份验证服务:

  1. 添加身份验证和授权服务:
$ npx ng g s auth -m app --flat false
  1. 确保服务在app.module中提供:
src/app/app.module.tsimport { AuthService } from './auth/auth.service'... providers: [AuthService],

为服务创建一个单独的文件夹将组织各种与身份验证和授权相关的组件,例如Roleenum定义。此外,我们还将能够在同一个文件夹中添加一个authService的伪造版本,这对于编写单元测试至关重要。

  1. 将用户角色定义为enum
src/app/auth/role.enum.tsexport enum Role { None = 'none', Clerk = 'clerk', Cashier = 'cashier', Manager = 'manager',}

现在,让我们构建一个本地身份验证服务,这将使我们能够演示一个强大的登录表单、缓存和基于身份验证状态和用户角色的条件导航概念:

  1. 首先安装一个 JWT 解码库,以及一个用于伪造身份验证的 JWT 编码库:
$ npm install jwt-decode fake-jwt-sign$ npm install -D @types/jwt-decode
  1. auth.service.ts定义导入项:
src/app/auth/auth.service.tsimport { HttpClient } from '@angular/common/http'import { Injectable } from '@angular/core'import { sign } from 'fake-jwt-sign' // For fakeAuthProvider onlyimport * as decode from 'jwt-decode'import { BehaviorSubject, Observable, of, throwError as observableThrowError } from 'rxjs'import { catchError, map } from 'rxjs/operators'import { environment } from '../../environments/environment'import { Role } from './role.enum'...
  1. 实现IAuthStatus接口来存储解码后的用户信息,一个辅助接口,以及默认安全的defaultAuthStatus
src/app/auth/auth.service.ts...export interface IAuthStatus { isAuthenticated: boolean userRole: Role userId: string}interface IServerAuthResponse { accessToken: string}const defaultAuthStatus = { isAuthenticated: false, userRole: Role.None, userId: null }...

IAuthUser是一个接口,代表了您可能从身份验证服务接收到的典型 JWT 的形状。它包含有关用户及其角色的最少信息,因此可以附加到服务器调用的header中,并且可以选择地缓存在localStorage中以记住用户的登录状态。在前面的实现中,我们假设了Manager的默认角色。

  1. 使用BehaviorSubject定义AuthService类来锚定用户当前的authStatus,并在构造函数中配置一个authProvider,该authProvider可以处理emailpassword并返回一个IServerAuthResponse
src/app/auth/auth.service.ts ...@Injectable({ providedIn: 'root'})export class AuthService { private readonly authProvider: ( email: string, password: string ) => Observable<IServerAuthResponse> authStatus = new BehaviorSubject<IAuthStatus>(defaultAuthStatus) constructor(private httpClient: HttpClient) { // Fake login function to simulate roles this.authProvider = this.fakeAuthProvider // Example of a real login call to server-side // this.authProvider = this.exampleAuthProvider } ...

请注意,fakeAuthProvider被配置为该服务的authProvider。真实的身份验证提供程序可能看起来像以下代码,其中用户的电子邮件和密码被发送到一个 POST 端点,该端点验证他们的信息,创建并返回一个 JWT 供我们的应用程序使用:

exampleprivate exampleAuthProvider( email: string, password: string): Observable<IServerAuthResponse> { return this.httpClient.post<IServerAuthResponse>(`${environment.baseUrl}/v1/login`, { email: email, password: password, })}

这很简单,因为大部分工作是在服务器端完成的。这个调用也可以发送给第三方。

请注意,URL 路径中的 API 版本v1是在服务中定义的,而不是作为baseUrl的一部分。这是因为每个 API 可以独立于其他 API 更改版本。登录可能长时间保持为v1,而其他 API 可能升级为v2v3等。

  1. 实现一个fakeAuthProvider,模拟身份验证过程,包括动态创建一个假的 JWT:
src/app/auth/auth.service.ts ... private fakeAuthProvider( email: string, password: string ): Observable<IServerAuthResponse> { if (!email.toLowerCase().endsWith('@test.com')) { return observableThrowError('Failed to login! Email needs to end with @test.com.') } const authStatus = { isAuthenticated: true, userId: 'e4d1bc2ab25c', userRole: email.toLowerCase().includes('cashier') ? Role.Cashier : email.toLowerCase().includes('clerk') ? Role.Clerk : email.toLowerCase().includes('manager') ? Role.Manager : Role.None, } as IAuthStatus const authResponse = { accessToken: sign(authStatus, 'secret', { expiresIn: '1h', algorithm: 'none', }), } as IServerAuthResponse return of(authResponse) } ...

fakeAuthProvider在服务中实现了本来应该是服务器端方法,因此您可以方便地在微调身份验证工作流程的同时实验代码。它使用临时的fake-jwt-sign库创建并签署了一个 JWT,以便我们还可以演示如何处理一个格式正确的 JWT。

不要将您的 Angular 应用程序与fake-jwt-sign依赖项一起发布,因为它是用于服务器端代码的。

  1. 在我们继续之前,实现一个transformError函数来处理在common/common.ts下的可观察流中混合的HttpErrorResponse和字符串错误:
src/app/common/common.tsimport { 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}` } return throwError(errorMessage)}
  1. 实现login函数,该函数将从LoginComponent中调用,如下一节所示

  2. 添加import { transformError } from '../common/common'

  3. 还要实现一个相应的logout函数,可以由顶部工具栏中的注销按钮调用,也可以由登录尝试失败或者如果路由器身份验证守卫检测到未经授权的访问尝试时调用,这是本章后面涵盖的一个主题:

src/app/auth/auth.service.ts ... login(email: string, password: string): Observable<IAuthStatus> { this.logout() const loginResponse = this.authProvider(email, password).pipe( map(value => { return decode(value.accessToken) as IAuthStatus }), catchError(transformError) ) loginResponse.subscribe( res => { this.authStatus.next(res) }, err => { this.logout() return observableThrowError(err) } ) return loginResponse } logout() { this.authStatus.next(defaultAuthStatus) }}

login方法通过调用logout方法,authProvideremailpassword信息,并在必要时抛出错误来封装正确的操作顺序。

login方法遵循 SOLID 设计中的开闭原则,通过对外部提供不同的 auth 提供程序来扩展,但对修改保持封闭,因为功能的差异被封装在 auth 提供程序中。

在下一节中,我们将实现LoginComponent,以便用户可以输入他们的用户名和密码信息并尝试登录。

login组件利用我们刚刚创建的authService并使用响应式表单实现验证错误。登录组件应该以一种独立于任何其他组件的方式进行设计,因为在路由事件期间,如果我们发现用户没有得到适当的身份验证或授权,我们将把他们导航到这个组件。我们可以将这个起源 URL 捕获为redirectUrl,这样一旦用户成功登录,我们就可以将他们导航回去。

  1. 让我们从实现到login组件的路由开始:
src/app/app-routing.modules.ts... { path: 'login', component: LoginComponent }, { path: 'login/:redirectUrl', component: LoginComponent },...
  1. 现在实现组件本身:
src/app/login/login.component.tsimport { Component, OnInit } from '@angular/core'import { FormBuilder, FormGroup, Validators, NgForm } from '@angular/forms'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 { loginForm: FormGroup loginError = '' redirectUrl constructor( private formBuilder: FormBuilder, private authService: AuthService, private router: Router, private route: ActivatedRoute ) { route.paramMap.subscribe(params => (this.redirectUrl = params.get('redirectUrl'))) } ngOnInit() { 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) .subscribe(authStatus => { if (authStatus.isAuthenticated) { this.router.navigate([this.redirectUrl || '/manager']) } }, error => (this.loginError = error)) }}

作为成功登录尝试的结果,我们利用路由器将经过身份验证的用户导航到他们的个人资料。在通过服务从服务器发送的错误的情况下,我们将将该错误分配给loginError

  1. 这里是一个用于捕获和验证用户的emailpassword的登录表单的实现,并且如果有任何服务器错误,显示它们:
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, Lemonite!</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组件,现在可以更新主屏幕以有条件地显示或隐藏我们创建的新组件。

  1. 更新home.component以在用户打开应用程序时显示登录:
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 implements OnInit { displayLogin = true ...

不要忘记将上面的代码中所需的依赖模块导入到您的 Angular 应用程序中。有意留给读者去找到并导入缺失的模块。

你的应用程序应该看起来类似于这个屏幕截图:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (116)带有登录的 LemonMart

在实现和显示/隐藏侧边栏菜单、个人资料和注销图标方面,还有一些工作要做,这取决于用户的认证状态。

有条件的导航在创建一个无挫折的用户体验方面是必要的。通过选择性地显示用户可以访问的元素并隐藏他们无法访问的元素,我们允许用户自信地浏览应用程序。

让我们从在用户登录到应用程序后隐藏登录组件开始:

  1. home组件中,导入authServicehome.component

  2. authStatus设置为名为displayLogin的本地变量:

src/app/home/home.component...import { AuthService } from '../auth/auth.service'...export class HomeComponent implements OnInit { private _displayLogin = true constructor(private authService: AuthService) {} ngOnInit() { this.authService.authStatus.subscribe( authStatus => (this._displayLogin = !authStatus.isAuthenticated) ) } get displayLogin() { return this._displayLogin }}

这里需要一个displayLogin的属性获取器,否则您可能会收到一个Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked的错误消息。这个错误是 Angular 组件生命周期和变化检测工作方式的副作用。这种行为很可能会在未来的 Angular 版本中发生变化。

  1. app组件上,订阅认证状态并将当前值存储在名为displayAccountIcons的本地变量中:
src/app/app.component.tsimport { Component, OnInit } from '@angular/core'import { AuthService } from './auth/auth.service'...export class AppComponent implements OnInit { displayAccountIcons = false constructor(..., private authService: AuthService) { ... ngOnInit() { this.authService.authStatus.subscribe( authStatus => (this.displayAccountIcons = authStatus.isAuthenticated) ) } ...}
  1. 使用*ngIf来隐藏所有针对已登录用户的按钮:
src/app/app.component.ts <button *ngIf="displayAccountIcons" ... >

现在,当用户登出时,您的工具栏应该看起来干净整洁,没有任何按钮,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (117)登录后的 LemonMart 工具栏

在我们继续之前,我们需要为loginForm实现验证。当我们在第十章中实现更多表单时,您会意识到在模板或响应式表单中重复输入表单验证会变得很繁琐。响应式表单的吸引力之一是它由代码驱动,因此我们可以轻松地将验证提取到一个共享类中,进行单元测试,并重复使用它们:

  1. common文件夹下创建一个validations.ts文件

  2. 实现电子邮件和密码验证:

src/app/common/validations.tsimport { Validators } from '@angular/forms'export const EmailValidation = [Validators.required, Validators.email]export const PasswordValidation = [ Validators.required, Validators.minLength(8), Validators.maxLength(50),]

根据您的密码验证需求,您可以使用RegEx模式和Validations.pattern()函数来强制密码复杂性规则,或者利用 OWASP npm 包owasp-password-strength-test来启用密码短语以及设置更灵活的密码要求。

  1. 使用新的验证更新login组件:
src/app/login/login.component.tsimport { EmailValidation, PasswordValidation } from '../common/validations' ... this.loginForm = this.formBuilder.group({ email: ['', EmailValidation], password: ['', PasswordValidation], })

当我们开始处理复杂的工作流程,比如身份验证工作流时,能够以编程方式为用户显示一个提示通知是很重要的。在其他情况下,我们可能希望在执行破坏性操作之前要求确认,这时需要一个更具侵入性的弹出通知。

无论您使用哪个组件库,都会变得很烦琐,因为您需要重复编写相同的样板代码,只是为了显示一个快速通知。UI 服务可以整洁地封装一个默认实现,也可以根据需要进行自定义:

  1. common下创建一个新的uiService

  2. 实现一个showToast函数:

src/app/common/ui.service.tsimport { Injectable, Component, Inject } from '@angular/core'import { MatSnackBar, MatSnackBarConfig, MatDialog, MatDialogConfig,} from '@angular/material'import { Observable } from 'rxjs'@Injectable()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, } ) }...}

对于showDialog函数,我们必须实现一个基本的对话框组件:

  1. app.module提供的common文件夹下添加一个新的simpleDialog,包括内联模板和样式
app/common/simple-dialog/simple-dialog.component.ts@Component({ 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一起使用它。从组件中删除此属性。

  1. 然后,实现一个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: title, content: content, okText: okText, cancelText: cancelText }, } ) return dialogRef.afterClosed() }}

ShowDialog返回一个Observable<boolean>,因此您可以根据用户的选择实施后续操作。单击“确定”将返回true,单击“取消”将返回false

SimpleDialogComponent中,使用@Inject,我们可以使用showDialog发送的所有变量来自定义对话框的内容。

不要忘记更新app.module.tsmaterial.module.ts,引入各种新的依赖项。

  1. 更新login组件以在登录后显示一个提示消息:
src/app/login/login.component.tsimport { UiService } from '../common/ui.service'...constructor(... , private uiService: UiService)... .subscribe(authStatus => { if (authStatus.isAuthenticated) { this.uiService.showToast(`Welcome! Role: ${authStatus.userRole}`) ...

用户登录后将显示一个提示消息,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (118)Material Snack barsnackBar将根据浏览器的大小占据整个屏幕或部分屏幕。

我们必须能够缓存已登录用户的身份验证状态。否则,每次刷新页面,用户都必须通过登录流程。我们需要更新AuthService以便持久保存身份验证状态。

有三种主要的数据存储方式:

  • Cookie

  • 本地存储

  • 会话存储

不应该使用 Cookie 来存储安全数据,因为它们可能会被不良行为者嗅探或窃取。此外,Cookie 最多可以存储 4KB 的数据,并且可以设置过期时间。

localStoragesessionStorage 在某种程度上是相似的。它们是受保护和隔离的浏览器端存储,允许存储大量应用程序数据。你不能在这两个存储上设置过期时间。当浏览器窗口关闭时,sessionStorage 的值会被移除。这些值会在页面重新加载和恢复时保留。

JSON Web Token 是加密的,并包括一个用于过期的时间戳,从本质上来说,它抵消了 cookielocalStorage 的弱点。任何选项都应该与 JWT 一起使用是安全的。

让我们首先实现一个缓存服务,可以将我们的身份验证信息的缓存方法抽象出来,AuthService 可以使用。

  1. 首先创建一个抽象的 cacheService,封装缓存方法:
src/app/auth/cache.service.tsexport abstract class CacheService { protected getItem<T>(key: string): T { const data = localStorage.getItem(key) if (data && data !== 'undefined') { 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() }}

这个缓存服务基类可以用来赋予任何服务缓存功能。这与创建一个注入到其他服务中的集中式缓存服务不同。通过避免集中式值存储,我们避免了各种服务之间的相互依赖。

  1. 更新 AuthService 以扩展 CacheService 并实现 authStatus 的缓存:
auth/auth.service...export class AuthService extends CacheService { authStatus = new BehaviorSubject<IAuthStatus>( this.getItem('authStatus') || defaultAuthStatus ) constructor(private httpClient: HttpClient) { super() this.authStatus.subscribe(authStatus => this.setItem('authStatus', authStatus)) ... } ...}

这里演示的技术可以用来持久化任何类型的数据,并有意地利用 RxJS 事件来更新缓存。正如你可能注意到的,我们不需要更新登录函数来调用 setItem,因为它已经调用了 this.authStatus.next,我们只是连接到数据流。这有助于保持无状态和避免副作用,通过将函数解耦。

在初始化 BehaviorSubject 时,要注意处理从缓存加载数据时的 undefined/null 情况,并提供默认实现。你可以在 setItemgetItem 函数中实现自己的自定义缓存过期方案,或者利用第三方创建的服务。

如果你正在开发一个高安全性的应用程序,你可能选择只缓存 JWT 以确保额外的安全层。在任何情况下,JWT 应该被单独缓存,因为令牌必须在每个请求的标头中发送到服务器。了解基于令牌的身份验证如何工作是很重要的,以避免泄露妥协的秘密。在下一节中,我们将介绍 JWT 的生命周期,以提高你的理解。

JSON Web Tokens 与基于状态的 REST API 架构相辅相成,具有加密令牌机制,允许方便、分布式和高性能的客户端请求的身份验证和授权。令牌身份验证方案有三个主要组件:

  • 客户端,捕获登录信息并隐藏不允许的操作,以获得良好的用户体验

  • 服务器端,验证每个请求既经过身份验证又具有适当的授权

  • Auth 服务,生成和验证加密令牌,独立验证用户请求的身份验证和授权状态,从数据存储中验证

安全系统假定在主要组件之间发送/接收的数据是在传输中加密的。这意味着您的 REST API 必须使用正确配置的 SSL 证书托管,通过 HTTPS 提供所有 API 调用,以便用户凭据在客户端和服务器之间永远不会暴露。同样,任何数据库或第三方服务调用都应该通过 HTTPS 进行。此外,存储密码的任何数据存储应该使用安全的单向哈希算法和良好的盐化实践。任何其他敏感用户信息应该使用安全的双向加密算法在静止状态下进行加密。遵循这种分层安全方法至关重要,因为攻击者需要同时攻破所有实施的安全层,才能对您的业务造成实质性的伤害。

下一个序列图突出了基于 JWT 的身份验证的生命周期:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (119)基于 JWT 的身份验证的生命周期

最初,用户通过提供其用户名和密码登录。一旦验证,用户的身份验证状态和角色将被加密为具有过期日期和时间的 JWT,并发送回浏览器。

您的 Angular(或任何其他 SPA)应用程序可以安全地将此令牌缓存在本地或会话存储中,以便用户不必在每个请求中登录,或者更糟糕的是,我们不会在浏览器中存储用户凭据。让我们更新身份验证服务,以便它可以缓存令牌。

  1. 更新服务以能够设置、获取、解码和清除令牌,如下所示:
src/app/auth/auth.service.ts... private setToken(jwt: string) { this.setItem('jwt', jwt) } private getDecodedToken(): IAuthStatus { return decode(this.getItem('jwt')) } getToken(): string { return this.getItem('jwt') || '' } private clearToken() { this.removeItem('jwt') }
  1. 在登录期间调用setToken,在注销期间调用clearToken,如下所示:
src/app/auth/auth.service.ts... login(email: string, password: string): Observable<IAuthStatus> { this.logout() const loginResponse = this.authProvider(email, password).pipe( map(value => { this.setToken(value.accessToken) return decode(value.accessToken) as IAuthStatus }), catchError(transformError) ) ... logout() { this.clearToken() this.authStatus.next(defaultAuthStatus) }

每个后续请求都将在请求头中包含 JWT。您应该保护每个 API 以检查并验证收到的令牌。例如,如果用户想要访问他们的个人资料,AuthService将验证令牌以检查用户是否经过身份验证,但还需要进一步的数据库调用来检查用户是否有权查看数据。这确保了对用户对系统的访问的独立确认,并防止对未过期令牌的滥用。

如果经过身份验证的用户调用 API,但他们没有适当的授权,比如说一个职员想要访问所有用户的列表,那么AuthService将返回一个虚假的状态,客户端将收到 403 Forbidden 的响应,这将显示为用户的错误消息。

用户可以使用过期的令牌发出请求;当这种情况发生时,将向客户端发送 401 未经授权的响应。作为良好的用户体验实践,我们应该自动提示用户重新登录,并让他们在没有任何数据丢失的情况下恢复他们的工作流程。

总之,真正的安全性是通过强大的服务器端实现来实现的,任何客户端实现主要是为了实现良好的安全实践周围的良好用户体验。

实现 HTTP 拦截器以将 JWT 注入到发送给用户的每个请求的头部,并通过要求用户登录来优雅地处理身份验证失败:

  1. auth下创建authHttpInterceptor
src/app/auth/auth-http-interceptor.tsimport { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest,} from '@angular/common/http'import { Injectable } from '@angular/core'import { Router } from '@angular/router'import { Observable, throwError as observableThrowError } 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(['/user/login'], { queryParams: { redirectUrl: this.router.routerState.snapshot.url }, }) } return observableThrowError(err) }) ) }}

请注意,AuthService被利用来检索令牌,并且在 401 错误后为登录组件设置redirectUrl

  1. 更新app模块以提供拦截器:
src/app/app.module.ts providers: [ ... { provide: HTTP_INTERCEPTORS, useClass: AuthHttpInterceptor, multi: true, }, ],

您可以在 Chrome Dev Tools | Network 选项卡中观察拦截器的操作,当应用程序正在获取lemon.svg文件时:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (120)lemon.svg 的请求头

启用移动优先工作流,并提供一个简单的导航机制,以便快速跳转到所需的功能。使用身份验证服务,根据用户当前的角色,只显示他们可以访问的功能链接。我们将按照以下方式实现侧边导航的模拟:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (121)侧边导航的模拟

让我们将侧边导航的代码实现为一个单独的组件,以便更容易维护:

  1. app.module中创建和声明NavigationMenuComponent
src/app/app.module.ts@NgModule({ declarations: [ ... NavigationMenuComponent, ],

在用户登录之后,其实并不需要侧边导航。但是,为了能够从工具栏启动侧边导航菜单,我们需要能够从app.component触发它。由于这个组件很简单,我们将急切加载它。要惰性加载它,Angular 确实有一个动态组件加载器模式,但这种模式的实现开销很大,只有在节省数百千字节的情况下才有意义。

SideNav将从工具栏触发,并且它带有一个<mat-sidenav-container>父容器,其中包含SideNav本身和应用程序的内容。因此,我们需要通过将<router-outlet>放置在<mat-sidenav-content>中来渲染所有应用程序内容。

  1. 在 material.module 中导入MatSidenavModuleMatListModule
src/app/material.module.ts@NgModule({ imports: [ ... MatSidenavModule, MatListModule, ], exports: [ ... MatSidenavModule, MatListModule, ]
  1. 定义一些样式,以确保 Web 应用程序将扩展到填满整个页面,并在桌面和移动设备情况下保持正确的可滚动性:
src/app/app.component.tsstyles: [ `.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; } ` ],
  1. AppComponent中导入ObservableMedia服务:
src/app/app.component.tsconstructor( ... public media: ObservableMedia ) { ...}
  1. 使用响应式SideNav更新模板,该模板将在移动设备上滑动到内容上方,或者在桌面情况下将内容推到一边:
src/app/app.component.ts...template: ` <div class="app-container"> <mat-toolbar color="primary" fxLayoutGap="8px" class="app-toolbar" [class.app-is-mobile]="media.isActive('xs')"> <button *ngIf="displayAccountIcons" mat-icon-button (click)="sidenav.toggle()"> <mat-icon>menu</mat-icon> </button> <a mat-icon-button routerLink="/home"> <mat-icon svgIcon="lemon"></mat-icon><span class="mat-h2">LemonMart</span> </a> <span class="flex-spacer"></span> <button *ngIf="displayAccountIcons" mat-mini-fab routerLink="/user/profile" matTooltip="Profile" aria-label="User Profile"><mat-icon>account_circle</mat-icon> </button> <button *ngIf="displayAccountIcons" mat-mini-fab routerLink="/user/logout" matTooltip="Logout" aria-label="Logout"><mat-icon>lock_open</mat-icon> </button> </mat-toolbar> <mat-sidenav-container class="app-sidenav-container" [style.marginTop.px]="media.isActive('xs') ? 56 : 0"> <mat-sidenav #sidenav [mode]="media.isActive('xs') ? 'over' : 'side'" [fixedInViewport]="media.isActive('xs')" fixedTopGap="56"> <app-navigation-menu></app-navigation-menu> </mat-sidenav> <mat-sidenav-content> <router-outlet class="app-container"></router-outlet> </mat-sidenav-content> </mat-sidenav-container> </div>`,

前面的模板利用了早期注入的 Angular Flex 布局媒体可观察对象,实现了响应式实现。

由于将显示在SiveNav内的链接长度不定,并且受到各种基于角色的业务规则的影响,最好将其实现为一个单独的组件。

  1. displayAccountIcons实现一个属性获取器,并使用setTimeout来避免出现ExpressionChangedAfterItHasBeenCheckedError等错误
src/app/app.component.ts export class AppComponent implements OnInit { _displayAccountIcons = false ... ngOnInit() { this.authService.authStatus.subscribe(authStatus => { setTimeout(() => { this._displayAccountIcons = authStatus.isAuthenticated }, 0) }) }
 get displayAccountIcons() { return this._displayAccountIcons }}
  1. 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>,因此您可以使用该组件的文档进行布局目的。观察这里的经理、库存和职员的子标题:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (122)在桌面上显示的经理仪表板上的收据查找

routerLinkActive="active-link"突出显示所选的收据路由,如前面的屏幕截图所示。

此外,您可以在移动设备上看到外观和行为的差异如下:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (123)在移动设备上显示的经理仪表板上的收据查找

现在我们正在缓存登录状态,我们需要实现一个登出体验:

  1. AuthService中实现logout函数:
src/app/auth/auth.service.ts... logout() { this.clearToken() this.authStatus.next(defaultAuthStatus) }
  1. 实现logout组件:
src/app/user/logout/logout.component.tsimport { 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> `, styles: [],})export class LogoutComponent implements OnInit { constructor(private router: Router, private authService: AuthService) {} ngOnInit() { this.authService.logout() this.router.navigate(['/']) }}

正如您所注意到的,注销后,用户将被导航回到主页。

这是您应用程序的最基本和重要的部分。通过延迟加载,我们确保只加载了最少量的资产,以使用户能够登录。

用户一旦登录,他们应该根据其用户角色被路由到适当的登陆屏幕,这样他们就不会猜测他们需要如何使用应用程序。例如,收银员只需要访问 POS 来结账,所以他们可以自动路由到该屏幕。

您可以找到 POS 屏幕的模拟如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (124)销售点屏幕模拟让我们通过更新LoginComponent来确保用户在登录后被路由到适当的页面:

  1. 更新login逻辑以根据角色路由:
app/src/login/login.component.ts async login(submittedForm: FormGroup) { ... this.router.navigate([ this.redirectUrl || this.homeRoutePerRole(authStatus.userRole) ]) ... } 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,允许在加载功能模块资产之前执行自定义逻辑

有关如何利用CanActivateCanLoad,请参考以下章节。Resolve守卫将在第十章 Angular App Design and Recipes中介绍。

身份验证守卫通过允许或禁止在加载任何数据请求到服务器之前意外导航到功能模块或组件,从而实现良好的用户体验。

例如,当经理登录时,他们会自动路由到/manager/home路径。浏览器将缓存此 URL,对于一个职员意外导航到相同的 URL 是完全合理的。Angular 不知道特定路由对用户是否可访问,没有AuthGuard,它将愉快地渲染经理的主页并触发最终失败的服务器请求。

不管你的前端实现有多健壮,你实现的每个 REST API 都应该在服务器端得到适当的保护。让我们更新路由器,这样ProfileComponent在没有经过身份验证的用户的情况下就无法激活,而ManagerModule在没有经过经理使用AuthGuard登录的情况下就不会加载:

  1. 实现一个AuthGuard服务:
src/app/auth/auth-guard.service.tsimport { Injectable } from '@angular/core'import { CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot, CanLoad, CanActivateChild,} from '@angular/router'import { AuthService, IAuthStatus } from './auth.service'import { Observable } from 'rxjs'import { Route } from '@angular/compiler/src/core'import { Role } from './role.enum'import { UiService } from '../common/ui.service'@Injectable({ providedIn: 'root'})export class AuthGuard implements CanActivate, CanActivateChild, CanLoad { protected currentAuthStatus: IAuthStatus constructor( protected authService: AuthService, protected router: Router, private uiService: UiService ) { this.authService.authStatus.subscribe( authStatus => (this.currentAuthStatus = authStatus) ) } 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) { let roleMatch = true let params: any if (route) { const expectedRole = route.data.expectedRole if (expectedRole) { roleMatch = this.currentAuthStatus.userRole === expectedRole } if (roleMatch) { params = { redirectUrl: route.pathFromRoot.map(r => r.url).join('/') } } } if (!this.currentAuthStatus.isAuthenticated || !roleMatch) { this.showAlert(this.currentAuthStatus.isAuthenticated, roleMatch) this.router.navigate(['login', params || {}]) return false } return true } 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') } }}
  1. 使用CanLoad守卫来阻止惰性加载模块,比如经理的模块:
src/app/app-routing.module.ts... { path: 'manager', loadChildren: './manager/manager.module#ManagerModule', canLoad: [AuthGuard], },...

在这种情况下,当ManagerModule正在加载时,AuthGuard将在canLoad事件期间被激活,并且checkLogin函数将验证用户的身份验证状态。如果守卫返回false,模块将不会被加载。在这一点上,我们没有元数据来检查用户的角色。

  1. 使用CanActivate守卫来阻止个别组件的激活,比如用户的profile
user/user-routing.module.ts...{ path: 'profile', component: ProfileComponent, canActivate: [AuthGuard] },...

user-routing.module的情况下,在canActivate事件期间激活AuthGuard,并且checkLogin函数控制这个路由可以导航到哪里。由于用户正在查看自己的个人资料,这里不需要检查用户的角色。

  1. 使用CanActivateCanActivateChildexpectedRole属性来阻止其他用户激活组件,比如ManagerHomeComponent
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,将其传递给checkLogin函数来实现这一点,该函数将通过canActivate事件来激活。如果用户经过身份验证但其角色与Role.Manager不匹配,AuthGuard将返回 false 并阻止导航。

  1. 确保AuthServiceAuthGuard都在app.modulemanager.module中提供,因为它们在两个上下文中都被使用。

一如既往,在继续之前,请确保通过执行npm testnpm run e2e来通过所有测试。

我们需要实现一个AuthServiceFake,以便我们的单元测试通过,并使用类似于第七章中提到的commonTestingModules模式,方便地在我们的规范文件中提供这个假数据。

为了确保我们的假数据具有与实际AuthService相同的公共函数和属性,让我们首先创建一个接口:

  1. IAuthService添加到auth.service.ts
src/app/auth/auth.service.ts export interface IAuthService { authStatus: BehaviorSubject<IAuthStatus> login(email: string, password: string): Observable<IAuthStatus> logout() getToken(): string}
  1. 确保AuthService实现了接口

  2. 导出defaultAuthStatus以便重复使用

src/app/auth/auth.service.tsexport const defaultAuthStatus = { isAuthenticated: false, userRole: Role.None, userId: null,}export class AuthService extends CacheService implements IAuthService 

现在我们可以创建一个实现相同接口的假数据,但提供的函数不依赖于任何外部认证系统。

  1. auth下创建一个名为auth.service.fake.ts的新文件。
src/app/auth/auth.service.fake.tsimport { Injectable } from '@angular/core'import { BehaviorSubject, Observable, of } from 'rxjs'import { IAuthService, IAuthStatus, defaultAuthStatus } from './auth.service'@Injectable()export class AuthServiceFake implements IAuthService { authStatus = new BehaviorSubject<IAuthStatus>(defaultAuthStatus) constructor() {} login(email: string, password: string): Observable<IAuthStatus> { return of(defaultAuthStatus) } logout() {} getToken(): string { return '' }}
  1. 使用commonTestingProviders更新common.testing.ts
src/app/common/common.testing.tsexport const commonTestingProviders: any[] = [ { provide: AuthService, useClass: AuthServiceFake }, UiService,]
  1. 观察在app.component.spec.ts中使用假数据:
src/app/app.component.spec.ts ... TestBed.configureTestingModule({ imports: commonTestingModules, providers: commonTestingProviders.concat([ { provide: ObservableMedia, useClass: ObservableMediaFake }, ...

我们之前创建的空commonTestingProviders数组正在与特定于app.component的假数据连接,因此我们的新AuthServiceFake应该自动应用。

  1. 更新AuthGuard的规范文件如下所示:
src/app/auth/auth-guard.service.spec.ts ... TestBed.configureTestingModule({ imports: commonTestingModules, providers: commonTestingProviders.concat(AuthGuard) })
  1. 继续将这种技术应用到所有依赖于AuthServiceUiService的规范文件中

  2. 值得注意的例外情况是在auth.service.spec.ts中,您不希望使用假数据,因为AuthService是被测试的类,请确保它配置如下所示:

src/app/auth/auth.service.spec.ts... TestBed.configureTestingModule({ imports: [HttpClientTestingModule], providers: [AuthService, UiService], })
  1. 此外,SimpleDialogComponent的测试需要对一些外部依赖进行存根处理,例如:
src/app/common/simple-dialog/simple-dialog.component.spec.ts ... providers: [{ provide: MatDialogRef, useValue: {} }, { provide: MAT_DIALOG_DATA, useValue: {} // Add any data you wish to test if it is passed/used correctly }], ...

记住,直到所有测试都通过之前不要继续!

现在您应该熟悉如何创建高质量的身份验证和授权体验了。我们首先讨论了完成和记录整个应用的高级 UX 设计的重要性,以便我们可以正确地设计出色的条件导航体验。我们创建了一个可重用的 UI 服务,以便我们可以方便地将警报注入到我们应用的流程控制逻辑中。

我们介绍了基于令牌的身份验证和 JWT 的基础知识,以便您不会泄漏任何关键用户信息。我们了解到缓存和 HTTP 拦截器是必要的,这样用户就不必在每个请求中输入他们的登录信息。最后,我们介绍了路由守卫,以防止用户意外进入他们未被授权使用的屏幕,并重申了应用程序的真正安全性应该在服务器端实现的观点。

在下一章中,我们将逐一介绍一份全面的 Angular 配方清单,以完成我们的企业应用程序 LemonMart 的实施。

在这一章中,我们将完成 LemonMart 的实现。作为路由器优先方法的一部分,我将演示创建可重用的可路由组件,这些组件还支持数据绑定 - 使用解析守卫来减少样板代码,并利用类、接口、枚举、验证器和管道来最大程度地重用代码。此外,我们将创建多步骤表单,实现带分页的数据表,并探索响应式设计。在本书中,我们将涉及 Angular 和 Angular Material 提供的大部分主要功能。

在这一章中,我们将放开训练轮。我会提供一般指导来帮助你开始实现;然而,完成实现将取决于你自己的努力。如果你需要帮助,你可以参考本书提供的完整源代码,或者参考 GitHub 上最新的示例:Github.com/duluca/lemon-mart

在这一章中,你将学习以下主题:

  • 面向对象类设计

  • 可路由复用组件

  • 缓存服务响应

  • HTTP POST 请求

  • 多步骤响应式表单

  • 解析守卫

  • 使用辅助路由的主/细节视图

  • 带分页的数据表

到目前为止,我们只使用接口来表示数据,并且我们仍然希望在各种组件和服务之间传递数据时继续使用接口。然而,需要创建一个默认对象来初始化BehaviorSubject。在面向对象编程OOP)中,让User对象拥有这个功能比让一个服务拥有更有意义。因此,让我们实现一个User类来实现这个目标。

user/user文件夹中,定义一个IUser接口和一个在UserModule中提供的User类:

src/app/user/user/user.tsimport { Role } from '../../auth/role.enum'export interface IUser { id: string email: string name: { first: string middle: string last: string } picture: string role: Role userStatus: boolean dateOfBirth: Date address: { line1: string line2: string city: string state: string zip: string } phones: IPhone[]}export interface IPhone { type: string number: string id: number}
export class User implements IUser { constructor( public id = '', public email = '', public name = { first: '', middle: '', last: '' }, public picture = '', public role = Role.None, public dateOfBirth = null, public userStatus = false, public address = { line1: '', line2: '', city: '', state: '', zip: '', }, public phones = [] ) {} static BuildUser(user: IUser) { return new User( user.id, user.email, user.name, user.picture, user.role, user.dateOfBirth, user.userStatus, user.address, user.phones ) }}

请注意,通过在构造函数中将所有属性定义为public属性并赋予默认值,我们一举两得;否则,我们将需要分别定义属性并初始化它们。这样,我们实现了简洁的实现。

你还可以为模板实现计算属性,比如方便地显示用户的fullName

src/app/user/user/user.ts get fullName() { return `${this.name.first} ${this.name.middle} ${this.name.last}`}

使用static BuildUser函数,您可以快速为对象填充从服务器接收到的数据。您还可以实现toJSON()函数,以在将数据发送到服务器之前自定义对象的序列化行为。

我们需要一个能够显示特定用户信息的组件。这些信息被呈现的自然位置是当用户导航到/user/profile时。您可以看到User配置文件的模拟。

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (125)用户配置文件模拟

用户信息还在应用程序的其他位置模拟显示,位于/manager/users

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (126)管理用户管理模拟

为了最大程度地重用代码,我们需要确保设计一个User组件,可以在两个上下文中使用。

例如,让我们完成两个与用户配置文件相关的屏幕的实现。

为了实现用户配置文件,我们必须首先实现一个可以对IUser执行 CRUD 操作的UserService。在创建服务之前,您需要运行lemon-mart-swagger-server,这样您就可以在开发过程中使用它来拉取虚假数据:

  1. package.json中添加一个名为mock:standalone的新脚本
package.json"mock:standalone": "docker run -p 3000:3000 -t duluca/lemon-mart-swagger-server",

请注意,此脚本假定您已经在本地计算机上独立构建了您的 swagger 服务器和/或从您可以拉取的存储库中发布了它。

  1. 执行脚本

  2. environment.tsenvironment.prod.ts中创建一个baseUrl属性,其中包含到您的模拟服务器的 url

src/environments/environment.tsexport const environment = { production: false, baseUrl: 'http://localhost:3000'}
  1. user/user下创建一个UserService,如下所示:
src/app/user/user/user.service.ts@Injectable({ providedIn: 'root'})export class UserService extends CacheService { currentUser = new BehaviorSubject<IUser>(this.getItem('user') || new User()) private currentAuthStatus: IAuthStatus constructor(private httpClient: HttpClient, private authService: AuthService) { super() this.currentUser.subscribe(user => this.setItem('user', user)) this.authService.authStatus.subscribe( authStatus => (this.currentAuthStatus = authStatus) ) } getCurrentUser(): Observable<IUser> { const userObservable = this.getUser(this.currentAuthStatus.userId).pipe( catchError(transformError) ) userObservable.subscribe( user => this.currentUser.next(user), err => Observable.throw(err) ) return userObservable }
 getUser(id): Observable<IUser> { return this.httpClient.get<IUser>(`${environment.baseUrl}/v1/user/${id}`) } updateUser(user: IUser): Observable<IUser> { this.setItem('draft-user', user) // cache user data in case of errors const updateResponse = this.httpClient .put<IUser>(`${environment.baseUrl}/v1/user/${user.id || 0}`, user) .pipe(catchError(transformError)) updateResponse.subscribe( res => { this.currentUser.next(res) this.removeItem('draft-user') }, err => Observable.throw(err) ) return updateResponse }}

UserService中,currentUser将作为锚定BehaviorSubject。为了保持我们的缓存最新,我们在constructor中订阅currentUser的变化。此外,我们订阅authStatus,因此当用户加载其自己的配置文件时,getProfile可以使用经过身份验证的用户的userId执行GET调用。

此外,我们单独提供了一个getUser函数,以便管理员可以加载其他用户配置文件的详细信息,这在我们稍后在本章实现主/细节视图时将会需要。最后,updateUser接受一个实现IUser接口的对象,因此数据可以发送到PUT端点。重要的是要强调,当传递数据时,您应始终坚持接口而不是像User这样的具体实现。这是 SOLID 原则中的 D-依赖反转原则。依赖具体实现会带来很多风险,因为它们经常变化,而像IUser这样的抽象很少会改变。毕竟,你会直接把灯焊接到墙上的电线吗?不,你会先把灯焊接到插头上,然后使用插头来获取你需要的电力。

UserService现在可以用于基本的 CRUD 操作。

现在,让我们实现一个多步输入表单,以捕获用户配置文件信息。我们还将使用媒体查询使这个多步表单对移动设备具有响应性。

  1. 让我们从添加一些辅助数据开始,这些数据将帮助我们显示一个带有选项的输入表单:
src/app/user/profile/data.tsexport 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 ) })}export enum PhoneType { Mobile, Home, Work,}const USStates = [ { code: 'AK', name: 'Alaska' }, { code: 'AL', name: 'Alabama' }, { code: 'AR', name: 'Arkansas' }, { code: 'AS', name: 'American Samoa' }, { code: 'AZ', name: 'Arizona' }, { code: 'CA', name: 'California' }, { code: 'CO', name: 'Colorado' }, { code: 'CT', name: 'Connecticut' }, { code: 'DC', name: 'District of Columbia' }, { code: 'DE', name: 'Delaware' }, { code: 'FL', name: 'Florida' }, { code: 'GA', name: 'Georgia' }, { code: 'GU', name: 'Guam' }, { code: 'HI', name: 'Hawaii' }, { code: 'IA', name: 'Iowa' }, { code: 'ID', name: 'Idaho' }, { code: 'IL', name: 'Illinois' }, { code: 'IN', name: 'Indiana' }, { code: 'KS', name: 'Kansas' }, { code: 'KY', name: 'Kentucky' }, { code: 'LA', name: 'Louisiana' }, { code: 'MA', name: 'Massachusetts' }, { code: 'MD', name: 'Maryland' }, { code: 'ME', name: 'Maine' }, { code: 'MI', name: 'Michigan' }, { code: 'MN', name: 'Minnesota' }, { code: 'MO', name: 'Missouri' }, { code: 'MS', name: 'Mississippi' }, { code: 'MT', name: 'Montana' }, { code: 'NC', name: 'North Carolina' }, { code: 'ND', name: 'North Dakota' }, { code: 'NE', name: 'Nebraska' }, { code: 'NH', name: 'New Hampshire' }, { code: 'NJ', name: 'New Jersey' }, { code: 'NM', name: 'New Mexico' }, { code: 'NV', name: 'Nevada' }, { code: 'NY', name: 'New York' }, { code: 'OH', name: 'Ohio' }, { code: 'OK', name: 'Oklahoma' }, { code: 'OR', name: 'Oregon' }, { code: 'PA', name: 'Pennsylvania' }, { code: 'PR', name: 'Puerto Rico' }, { code: 'RI', name: 'Rhode Island' }, { code: 'SC', name: 'South Carolina' }, { code: 'SD', name: 'South Dakota' }, { code: 'TN', name: 'Tennessee' }, { code: 'TX', name: 'Texas' }, { code: 'UT', name: 'Utah' }, { code: 'VA', name: 'Virginia' }, { code: 'VI', name: 'Virgin Islands' }, { code: 'VT', name: 'Vermont' }, { code: 'WA', name: 'Washington' }, { code: 'WI', name: 'Wisconsin' }, { code: 'WV', name: 'West Virginia' }, { code: 'WY', name: 'Wyoming' },]
  1. 安装一个辅助库以以编程方式访问 TypeScript 枚举值
$ npm i ts-enum-util
  1. 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 BirthDateValidation = [ Validators.required, Validators.min(new Date().getFullYear() - 100), Validators.max(new Date().getFullYear()),]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})$/),]
  1. 现在按照以下方式实现profile.component.ts
src/app/user/profile/profile.component.tsimport { Role as UserRole } from '../../auth/role.enum'import { $enum } from 'ts-enum-util'...@Component({ selector: 'app-profile', templateUrl: './profile.component.html', styleUrls: ['./profile.component.css'],})export class ProfileComponent implements OnInit { Role = UserRole PhoneTypes = $enum(PhoneType).getKeys() userForm: FormGroup states: Observable<IUSState[]> userError = '' currentUserRole = this.Role.None constructor( private formBuilder: FormBuilder, private router: Router, private userService: UserService, private authService: AuthService ) {} ngOnInit() { this.authService.authStatus.subscribe( authStatus => (this.currentUserRole = authStatus.userRole) ) this.userService.getCurrentUser().subscribe(user => { this.buildUserForm(user) }) this.buildUserForm() } ...}

在加载时,我们从userService请求当前用户,但这将需要一些时间,因此我们必须首先用this.buildUserForm()构建一个空表单。在这个函数中,您还可以实现一个解析守卫,如后面的部分所讨论的那样,根据路由提供的userId加载用户,并将该数据传递到buildUserForm(routeUser)中,跳过加载currentUser以增加此组件的可重用性。

我们的表单有许多输入字段,因此我们将使用FormGroup,由this.formBuilder.group创建,来容纳我们的各种FormControl对象。此外,子FormGroup对象将允许我们维护正确的数据结构形状。

按照以下方式开始构建buildUserForm函数:

src/app/user/profile/profile.component.ts... buildUserForm(user?: IUser) { this.userForm = this.formBuilder.group({ email: [ { value: (user && user.email) || '', disabled: this.currentUserRole !== this.Role.Manager, }, EmailValidation, ], name: this.formBuilder.group({ first: [(user && user.name.first) || '', RequiredTextValidation], middle: [(user && user.name.middle) || '', OneCharValidation], last: [(user && user.name.last) || '', RequiredTextValidation], }), role: [ { value: (user && user.role) || '', disabled: this.currentUserRole !== this.Role.Manager, }, [Validators.required], ], dateOfBirth: [(user && user.dateOfBirth) || '', BirthDateValidation], address: this.formBuilder.group({ line1: [ (user && user.address && user.address.line1) || '', RequiredTextValidation, ], line2: [ (user && user.address && user.address.line2) || '', OptionalTextValidation, ], city: [(user && user.address && user.address.city) || '', RequiredTextValidation], state: [ (user && user.address && user.address.state) || '', RequiredTextValidation, ], zip: [(user && user.address && user.address.zip) || '', USAZipCodeValidation], }), ... }) ... }...

buildUserForm 可选地接受一个 IUser 来预填表单,否则所有字段都设置为它们的默认值。userForm 本身是顶级 FormGroup。各种 FormControls 被添加到其中,例如 email,并根据需要附加验证器。请注意 nameaddress 是它们自己的 FormGroup 对象。这种父子关系确保了表单数据的正确结构,当序列化为 JSON 时,它符合 IUser 的结构,以便我们的应用程序和服务器端代码可以利用。

您将独立完成 userForm 的实现,按照本章提供的示例代码,并且我将在接下来的几节中逐步解释代码的某些关键功能。

Angular Material Stepper 需要使用 MatStepperModule。该步进器允许将表单输入分解为多个步骤,以便用户不会一次处理大量的输入字段。用户仍然可以跟踪他们在流程中的位置,并且作为开发人员,我们可以分解 <form> 实现并逐步强制执行验证规则,或者创建可选的工作流,其中某些步骤可以被跳过或必需的。与所有 Material 用户控件一样,步进器是根据响应式 UX 设计的。在接下来的几节中,我们将实现三个步骤,涵盖流程中的不同表单输入技术:

  1. 账户信息
  • 输入验证

  • 使用媒体查询的响应式布局

  • 计算属性

  • 日期选择器

  1. 联系信息
  • 类型提前支持

  • 动态表单数组

  1. 回顾
  • 只读视图

  • 保存和清除数据

让我们为用户模块准备一些新的 Material 模块:

  1. 创建一个 user-material.module,其中包含以下 Material 模块:
MatAutocompleteModule,MatDatepickerModule,MatDividerModule,MatLineModule,MatNativeDateModule,MatRadioModule,MatSelectModule,MatStepperModule,
  1. 确保 user.module 正确导入:

  2. 新的 user-material.module

  3. 基线 app-material.module

  4. 需要 FormsModuleReactiveFormsModuleFlexLayoutModule

当我们开始添加子 Material 模块时,将根 material.module.ts 文件重命名为 app-material.modules.ts 是有意义的,与 app-routing.module.ts 的命名方式一致。今后,我将使用后一种约定。

  1. 现在,开始实现账户信息步骤的第一行:
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]="userForm"> <form [formGroup]="userForm"> <ng-template matStepLabel>Account Information</ng-template> <div class="stepContent"> <div fxLayout="row" fxLayout.lt-sm="column" [formGroup]="userForm.get('name')" fxLayoutGap="10px"> <mat-form-field fxFlex="40%"> <input matInput placeholder="First Name" aria-label="First Name" formControlName="first"> <mat-error *ngIf="userForm.get('name').get('first').hasError('required')"> First Name is required </mat-error> <mat-error *ngIf="userForm.get('name').get('first').hasError('minLength')"> Must be at least 2 characters </mat-error> <mat-error *ngIf="userForm.get('name').get('first').hasError('maxLength')"> Can't exceed 50 characters </mat-error> </mat-form-field> <mat-form-field fxFlex="20%"> <input matInput placeholder="MI" aria-label="Middle Initial" formControlName="middle"> <mat-error *ngIf="userForm.get('name').get('middle').invalid"> Only inital </mat-error> </mat-form-field> <mat-form-field fxFlex="40%"> <input matInput placeholder="Last Name" aria-label="Last Name" formControlName="last"> <mat-error *ngIf="userForm.get('name').get('last').hasError('required')"> Last Name is required </mat-error> <mat-error *ngIf="userForm.get('name').get('last').hasError('minLength')"> Must be at least 2 characters </mat-error> <mat-error *ngIf="userForm.get('name').get('last').hasError('maxLength')"> Can't exceed 50 characters </mat-error> </mat-form-field> </div> ... </div> </form> </mat-step>...</mat-horizontal-stepper>
  1. 请注意了解步进器和表单配置的工作方式,到目前为止,您应该看到第一行呈现,提取模拟数据:

多步骤表单 - 步骤 1

  1. 为了完成表单的实现,请参考本章提供的示例代码或GitHub.com/duluca/lemon-mart上的参考实现

在您的实现过程中,您会注意到 Review 步骤使用了一个名为<app-view-user>的指令。这个组件的最小版本在下面的 ViewUser 组件部分中实现了。但是,现在可以随意实现内联功能,并在可重用组件与绑定和路由数据部分重构代码。

在下面的截图中,您可以看到桌面上多步骤表单的完成实现是什么样子的:

桌面上的多步骤表单

请注意,在带有fxLayout="row"的行上添加fxLayout.lt-sm="column"可以启用表单的响应式布局,如下所示:

移动设备上的多步骤表单让我们看看出生日期字段在下一节中是如何工作的。

如果您想根据用户输入显示计算属性,可以按照这里显示的模式进行操作:

src/app/user/profile/profile.component.ts ...get dateOfBirth() { return this.userForm.get('dateOfBirth').value || new Date()}get age() { return new Date().getFullYear() - this.dateOfBirth.getFullYear()}...

模板中计算属性的使用如下所示:

src/app/user/profile/profile.component ...<mat-form-field fxFlex="50%"> <input matInput placeholder="Date of Birth" aria-label="Date of Birth" formControlName="dateOfBirth" [matDatepicker]="dateOfBirthPicker"> <mat-hint *ngIf="userForm.get('dateOfBirth').touched">{{this.age}} year(s) old</mat-hint> <mat-datepicker-toggle matSuffix [for]="dateOfBirthPicker"></mat-datepicker-toggle> <mat-datepicker #dateOfBirthPicker></mat-datepicker> <mat-error *ngIf="userForm.get('dateOfBirth').invalid"> Date must be with the last 100 years </mat-error></mat-form-field>...

这就是它的作用:

使用日期选择器选择日期

选择日期后,将显示计算出的年龄,如下所示:

计算年龄属性

现在,让我们继续下一步,联系信息,并看看我们如何能够方便地显示和输入地址字段的州部分。

buildUserForm中,我们设置了一个监听器address.state来支持类型向前筛选下拉体验:

src/app/user/profile/profile.component.ts ...this.states = this.userForm .get('address') .get('state') .valueChanges.pipe(startWith(''), map(value => USStateFilter(value)))...

在模板上,使用mat-autocomplete实现绑定到过滤后的州数组的async管道:

src/app/user/profile/profile.component.html ...<mat-form-field fxFlex="30%"> <input type="text" placeholder="State" aria-label="State" matInput formControlName="state" [matAutocomplete]="stateAuto"> <mat-autocomplete #stateAuto="matAutocomplete"> <mat-option *ngFor="let state of states | async" [value]="state.name"> {{ state.name }} </mat-option> </mat-autocomplete> <mat-error *ngIf="userForm.get('address').get('state').hasError('required')"> State is required </mat-error></mat-form-field>...

当用户输入V字符时,它是什么样子:

带有输入提示支持的下拉菜单在下一节中,让我们启用多个电话号码的输入。

请注意,phones是一个数组,可能允许多个输入。我们可以通过使用this.formBuilder.array构建一个FormArray和几个辅助函数来实现这一点:

src/app/user/profile/profile.component.ts... phones: this.formBuilder.array(this.buildPhoneArray(user ? user.phones : [])),... private buildPhoneArray(phones: IPhone[]) { const groups = [] if (!phones || (phones && phones.length === 0)) { groups.push(this.buildPhoneFormControl(1)) } else { phones.forEach(p => { groups.push(this.buildPhoneFormControl(p.id, p.type, p.number)) }) } return groups } private buildPhoneFormControl(id, type?: string, number?: string) { return this.formBuilder.group({ id: [id], type: [type || '', Validators.required], number: [number || '', USAPhoneNumberValidation], }) }...

BuildPhoneArray支持使用单个电话输入初始化表单,或者使用现有数据填充它,与BuildPhoneFormControl协同工作。当用户点击添加按钮创建新的输入行时,后者函数非常有用:

src/app/user/profile/profile.component.ts... addPhone() { this.phonesArray.push( this.buildPhoneFormControl(this.userForm.get('phones').value.length + 1) ) } get phonesArray(): FormArray { return <FormArray>this.userForm.get('phones') }...

phonesArray属性的 getter 是一种常见的模式,可以更容易地访问某些表单属性。然而,在这种情况下,它也是必要的,因为必须将get('phones')强制转换为FormArray,这样我们才能在模板上访问它的length属性:

src/app/user/profile/profile.component.html...<mat-list formArrayName="phones"> <h2 mat-subheader>Phone Number(s)</h2> <button mat-button (click)="this.addPhone()"> <mat-icon>add</mat-icon> Add Phone </button> <mat-list-item *ngFor="let position of this.phonesArray.controls let i=index" [formGroupName]="i"> <mat-form-field fxFlex="100px"> <mat-select placeholder="Type" formControlName="type"> <mat-option *ngFor="let type of this.PhoneTypes" [value]="type"> {{ type }} </mat-option> </mat-select> </mat-form-field> <mat-form-field fxFlex fxFlexOffset="10px"> <input matInput type="text" placeholder="Number" formControlName="number"> <mat-error *ngIf="this.phonesArray.controls[i].invalid"> A valid phone number is required </mat-error> </mat-form-field> <button fxFlex="33px" mat-icon-button (click)="this.phonesArray.removeAt(i)"> <mat-icon>close</mat-icon> </button> </mat-list-item></mat-list>...

remove函数是内联实现的。

让我们看看它应该如何工作:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (127)使用 FormArray 的多个输入

现在我们已经完成了输入数据,可以继续进行步进器的最后一步,审查。然而,正如之前提到的,审查步骤使用app-view-user指令来显示其数据。让我们先构建那个视图。

以下是<app-view-user>指令的最小实现,这是审查步骤的先决条件。

按照下面的示例在user下创建一个新的viewUser组件:

src/app/user/view-user/view-user.component.tsimport { Component, OnInit, Input } from '@angular/core'import { IUser, User } from '../user/user'@Component({ selector: 'app-view-user', template: ` <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="!this.user"> <button mat-button mat-raised-button>Edit</button> </mat-card-actions> </mat-card> `, styles: [ ` .bold { font-weight: bold } `, ],})export class ViewUserComponent implements OnChanges { @Input() user: IUser currentUser = new User() constructor() {} ngOnChanges() { if (this.user) { this.currentUser = User.BuildUser(this.user) } }}

上面的组件使用输入绑定与@Input来获取用户数据,符合IUser接口,来自外部组件。我们实现了ngOnChanges事件,每当绑定的数据发生变化时就会触发。在这个事件中,我们使用User.BuildUser将存储在this.user中的简单 JSON 对象实例化为User类的实例,并将其赋值给this.currentUser。模板使用这个变量,因为像currentUser.fullName这样的计算属性只有在数据驻留在User类的实例中时才能工作。

现在,我们准备完成多步表单。

在多步表单的最后一步,用户应该能够审查然后保存表单数据。作为一个良好的实践,成功的POST请求将返回保存的数据到浏览器。然后我们可以使用从服务器接收到的信息重新加载表单:

src/app/user/profile/profile.component ...async save(form: FormGroup) { this.userService .updateUser(form.value) .subscribe(res => this.buildUserForm(res), err => (this.userError = err)) }...

如果有错误,它们将被设置为userError以供显示。在保存之前,我们将以紧凑的形式呈现数据,使用一个可重用的组件将表单数据绑定到其中:

src/app/user/profile/profile.component.html...<mat-step [stepControl]="userForm"> <form [formGroup]="userForm" (ngSubmit)="save(userForm)"> <ng-template matStepLabel>Review</ng-template> <div class="stepContent"> Review and update your user profile. <app-view-user [user]="this.userForm.value"></app-view-user> </div> <div fxLayout="row" class="margin-top"> <button mat-button matStepperPrevious color="accent">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]="this.userForm.invalid">Update</button> </div> </form></mat-step>...

这是最终产品应该是什么样子的:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (128)审查步骤请注意重置表单的选项。添加一个警报对话框来确认重置用户输入数据将是良好的用户体验。

现在用户配置文件输入已完成,我们已经完成了创建主/细节视图的最终目标的一半,其中经理可以点击用户并查看其配置文件详细信息。我们仍然有很多代码要添加,在这个过程中,我们已经陷入了添加大量样板代码来加载组件所需数据的模式。在下一节中,我们将学习解析守卫,以便简化我们的代码并减少样板代码。

解析守卫是一种路由守卫,如第九章中所述,设计身份验证和授权。解析守卫可以通过从路由参数中读取记录 ID 来加载组件所需的数据,异步加载数据,并在组件激活和初始化时准备好。

解析守卫的主要优势包括加载逻辑的可重用性、减少样板代码以及减少依赖性,因为组件可以接收到所需的数据而无需导入任何服务:

  1. user/user下创建一个新的user.resolve.ts类:
**src/app/user/user/user.resolve.ts**import { Injectable } from '@angular/core'import { Resolve, ActivatedRouteSnapshot } from '@angular/router'import { UserService } from './user.service'import { IUser } from './user'@Injectable()export class UserResolve implements Resolve<IUser> { constructor(private userService: UserService) {} resolve(route: ActivatedRouteSnapshot) { return this.userService.getUser(route.paramMap.get('userId')) }} 
  1. 您可以使用解析守卫,如下所示:
example{ path: 'user', component: ViewUserComponent, resolve: { user: UserResolve, },},
  1. routerLink将如下所示:
example['user', {userId: row.id}]
  1. 在目标组件的ngOnInit钩子中,您可以这样读取已解析的用户:
examplethis.route.snapshot.data['user']

您可以在接下来的两个部分中观察到这种行为,之后我们将更新ViewUserComponent和路由以利用解析守卫。

现在,让我们重构viewUser组件,以便我们可以在多个上下文中重用它。一个是可以使用解析守卫加载自己的数据,适用于主/细节视图,另一个是可以将当前用户绑定到它,就像我们在之前部分构建的多步输入表单的审阅步骤中所做的那样:

  1. 使用以下更改更新viewUser组件:
src/app/user/view-user/view-user.component.ts...import { ActivatedRoute } from '@angular/router'export class ViewUserComponent implements OnChanges, OnInit { ... constructor(private route: ActivatedRoute) {} ngOnInit() { if (this.route.snapshot && this.route.snapshot.data['user']) { this.currentUser = User.BuildUser(this.route.snapshot.data['user']) this.currentUser.dateOfBirth = Date.now() // for data mocking purposes only } } ...

现在我们有两个独立的事件。一个是ngOnChanges,它处理了如果this.user已经绑定,则将值分配给this.currentUserngOnInit只会在组件首次初始化或路由到达时触发。在这种情况下,如果路由的任何数据已经解析,它将被分配给this.currentUser

为了能够在多个惰性加载模块中使用该组件,我们必须将其包装在自己的模块中。

  1. app下创建一个新的shared-components.module.ts
src/app/shared-components.module.tsimport { NgModule } from '@angular/core'import { ViewUserComponent } from './user/view-user/view-user.component'import { FormsModule, ReactiveFormsModule } from '@angular/forms'import { FlexLayoutModule } from '@angular/flex-layout'import { CommonModule } from '@angular/common'import { MaterialModule } from './app-material.module'@NgModule({ imports: [ CommonModule, FormsModule, ReactiveFormsModule, FlexLayoutModule, MaterialModule, ], declarations: [ViewUserComponent], exports: [ViewUserComponent],})export class SharedComponentsModule {}
  1. 确保将SharedComponentsModule模块导入到您打算在其中使用ViewUserComponent的每个功能模块中。在我们的情况下,这将是UserManager模块。

  2. User模块声明中删除ViewUserComponent

我们现在已经准备好开始实现主/细节视图了。

路由器优先架构的真正力量在于使用辅助路由,我们可以仅通过路由器配置影响组件的布局,从而实现丰富的场景,我们可以将现有组件重新组合成不同的布局。辅助路由是彼此独立的路由,它们可以在已在标记中定义的命名出口中呈现内容,例如<router-outlet name="master"><router-outlet name="detail">。此外,辅助路由可以有自己的参数、浏览器历史、子级和嵌套辅助。

在以下示例中,我们将使用辅助路由实现基本的主/细节视图:

  1. 实现一个具有两个命名出口的简单组件:
src/app/manager/user-management/user-manager.component.tstemplate: ` <div class="horizontal-padding"> <router-outlet name="master"></router-outlet> <div style="min-height: 10px"></div> <router-outlet name="detail"></router-outlet> </div> `
  1. manager下创建一个userTable组件

  2. 更新manager-routing.module以定义辅助路由:

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,因为它是用default路径实现的。

  1. manager.module中提供UserResolve,因为viewUser依赖于它

  2. userTable中实现一个临时按钮

src/app/manager/user-table/user-table.component.html<a mat-button mat-icon-button [routerLink]="['/manager/users', { outlets: { detail: ['user', {userId: 'fakeid'}] } }]" skipLocationChange> <mat-icon>visibility</mat-icon></a>

考虑用户点击上面定义的查看详情按钮,然后ViewUserComponent将为具有给定userId的用户呈现。在下一个截图中,您可以看到在我们在下一节中实现数据表后,查看详情按钮将是什么样子:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (129)查看详情按钮您可以为主和细节定义许多组合和替代组件,从而允许无限可能的动态布局。然而,设置routerLink可能是一种令人沮丧的体验。根据确切的条件,您必须在链接中提供或不提供所有或一些出口。例如,对于前面的情况,如果链接是['/manager/users', { outlets: { master: [''], detail: ['user', {userId: row.id}] } }],路由将悄悄地无法加载。预计这些怪癖将在未来的 Angular 版本中得到解决。

现在,我们已经完成了对ViewUserComponent的解析守卫的实现,您可以使用 Chrome Dev Tools 来查看数据是否被正确加载。在调试之前,请确保我们在第八章中创建的模拟服务器正在运行。

  1. 确保模拟服务器正在运行,通过执行docker run -p 3000:3000 -t duluca/lemon-mart-swagger-servernpm run mock:standalone

  2. 在 Chrome Dev Tools 中,设置一个断点,就在this.currentUser被分配后,如下所示:

Dev Tools 调试 ViewUserComponent

您将观察到this.currentUserngOnInit函数内部正确设置,而无需加载数据的样板代码,显示了解析守卫的真正好处。ViewUserComponent是细节视图;现在让我们将主视图实现为带分页的数据表。

我们已经创建了用于布置主/细节视图的脚手架。在主输出中,我们将拥有一个用户的分页数据表,因此让我们实现UserTableComponent,其中将包含一个名为dataSourceMatTableDataSource属性。我们需要能够使用标准分页控件(如pageSizepagesToSkip)批量获取用户数据,并能够通过用户提供的searchText进一步缩小选择范围。

让我们从向UserService添加必要的功能开始。

  1. 实现一个新的接口IUsers来描述分页数据的数据结构
src/app/user/user/user.service.ts...export interface IUsers { items: IUser[] total: number}
  1. UserService中添加getUsers
src/app/user/user/user.service.ts...getUsers(pageSize: number, searchText = '', pagesToSkip = 0): Observable<IUsers> { return this.httpClient.get<IUsers>(`${environment.baseUrl}/v1/users`, { params: { search: searchText, offset: pagesToSkip.toString(), limit: pageSize.toString(), }, })}...
  1. 设置UserTable的分页、排序和过滤:
src/app/manager/user-table/user-table.componentimport { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core'import { FormControl } from '@angular/forms'import { MatPaginator, MatSort, MatTableDataSource } from '@angular/material'import { merge, of } from 'rxjs'import { catchError, debounceTime, map, startWith, switchMap } from 'rxjs/operators'import { OptionalTextValidation } from '../../common/validations'import { IUser } from '../../user/user/user'import { UserService } from '../../user/user/user.service'@Component({ selector: 'app-user-table', templateUrl: './user-table.component.html', styleUrls: ['./user-table.component.css'],})export class UserTableComponent implements OnInit, AfterViewInit { displayedColumns = ['name', 'email', 'role', 'status', 'id'] dataSource = new MatTableDataSource() resultsLength = 0 _isLoadingResults = true _hasError = false errorText = '' _skipLoading = false search = new FormControl('', OptionalTextValidation) @ViewChild(MatPaginator) paginator: MatPaginator @ViewChild(MatSort) sort: MatSort constructor(private userService: UserService) {} ngOnInit() {} ngAfterViewInit() { this.dataSource.paginator = this.paginator this.dataSource.sort = this.sort this.sort.sortChange.subscribe(() => (this.paginator.pageIndex = 0)) if (this._skipLoading) { return } merge( this.sort.sortChange, this.paginator.page, this.search.valueChanges.pipe(debounceTime(1000)) ) .pipe( startWith({}), switchMap(() => { this._isLoadingResults = true return this.userService.getUsers( this.paginator.pageSize, this.search.value, this.paginator.pageIndex ) }), map((data: { total: number; items: IUser[] }) => { this._isLoadingResults = false this._hasError = false this.resultsLength = data.total return data.items }), catchError(err => { this._isLoadingResults = false this._hasError = true this.errorText = err return of([]) }) ) .subscribe(data => (this.dataSource.data = data)) } get isLoadingResults() { return this._isLoadingResults } get hasError() { return this._hasError }}

初始化分页、排序和过滤属性后,我们使用merge方法来监听所有三个数据流的变化。如果有一个变化,整个pipe就会被触发,其中包含对this.userService.getUsers的调用。然后将结果映射到表格的datasource属性,否则会捕获和处理错误。

  1. 创建一个包含以下 Material 模块的manager-material.module
MatTableModule, MatSortModule, MatPaginatorModule, MatProgressSpinnerModule
  1. 确保manager.module正确导入:

  2. 新的manager-material.module

  3. 基线app-material.module

  4. 必需的FormsModuleReactiveFormsModuleFlexLayoutModule

  5. 最后,实现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="isLoadingResults"> <mat-spinner *ngIf="isLoadingResults"></mat-spinner> <div class="error" *ngIf="hasError"> {{errorText}} </div> </div> <mat-table [dataSource]="dataSource" matSort> <ng-container matColumnDef="name"> <mat-header-cell *matHeaderCellDef mat-sort-header> Name </mat-header-cell> <mat-cell *matCellDef="let row"> {{row.name.first}} {{row.name.last}} </mat-cell> </ng-container> <ng-container matColumnDef="email"> <mat-header-cell *matHeaderCellDef mat-sort-header> E-mail </mat-header-cell> <mat-cell *matCellDef="let row"> {{row.email}} </mat-cell> </ng-container> <ng-container matColumnDef="role"> <mat-header-cell *matHeaderCellDef mat-sort-header> Role </mat-header-cell> <mat-cell *matCellDef="let row"> {{row.role}} </mat-cell> </ng-container> <ng-container matColumnDef="status"> <mat-header-cell *matHeaderCellDef mat-sort-header> Status </mat-header-cell> <mat-cell *matCellDef="let row"> {{row.status}} </mat-cell> </ng-container> <ng-container matColumnDef="id"> <mat-header-cell *matHeaderCellDef fxLayoutAlign="end center">View Details</mat-header-cell> <mat-cell *matCellDef="let row" fxLayoutAlign="end center" 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> </mat-cell> </ng-container> <mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row> <mat-row *matRowDef="let row; columns: displayedColumns;"> </mat-row> </mat-table> <mat-paginator [pageSizeOptions]="[5, 10, 25, 100]"></mat-paginator></div>

只有主视图时,表格看起来像这个截图:

用户表

如果您点击查看图标,ViewUserComponent将在详细信息输出中呈现,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (130)主/细节视图

然后,您可以连接编辑按钮并将userId传递给UserProfile,以便可以编辑和更新数据。或者,您可以在详细信息输出中直接呈现UserProfile

带分页的数据表完成了 LemonMart 的实现,以便在本书中使用。现在让我们确保所有的测试都通过,然后再继续。

自从我们引入了新的userService,为它创建一个伪装实现,使用与authServicecommonTestingProviders相同的模式。

  1. UserService实现IUserService接口
src/app/user/user/user.service.tsexport interface IUserService { currentUser: BehaviorSubject<IUser> getCurrentUser(): Observable<IUser> getUser(id): Observable<IUser> updateUser(user: IUser): Observable<IUser> getUsers(pageSize: number, searchText: string, pagesToSkip: number): Observable<IUsers>}...export class UserService extends CacheService implements IUserService {
  1. 实现伪装用户服务
src/app/user/user/user.service.fake.tsimport { Injectable } from '@angular/core'import { BehaviorSubject, Observable, of } from 'rxjs'import { IUser, User } from './user'import { IUsers, IUserService } from './user.service'@Injectable()export class UserServiceFake implements IUserService { currentUser = new BehaviorSubject<IUser>(new User()) constructor() {}
 getCurrentUser(): Observable<IUser> { return of(new User()) } getUser(id): Observable<IUser> { return of(new User((id = id))) } updateUser(user: IUser): Observable<IUser> { return of(user) } getUsers(pageSize: number, searchText = '', pagesToSkip = 0): Observable<IUsers> { return of({ total: 1, items: [new User()], } as IUsers) }}
  1. 将用户服务的伪装添加到commonTestingProviders
src/app/common/common.testing.tsexport const commonTestingProviders: any[] = [ ... { provide: UserService, useClass: UserServiceFake },]
  1. SharedComponentsModule添加到commonTestingModules
src/app/common/common.testing.tsexport const commonTestingModules: any[] = [ ... SharedComponentsModule]
  1. UserTableComponent实例化默认数据

修复提供者和导入后,您会注意到UserTableComponent仍然无法创建。这是因为组件初始化逻辑需要定义dataSource。如果未定义,组件将无法创建。但是,我们可以在第二个beforeEach方法中轻松修改组件属性,该方法在TestBed将真实的、模拟的或伪装的依赖项注入到组件类之后执行。查看下面加粗的更改以进行测试数据设置:

src/app/manager/user-table/user-table.component.spec.ts ... beforeEach(() => { fixture = TestBed.createComponent(UserTableComponent) component = fixture.componentInstance component.dataSource = new MatTableDataSource() component.dataSource.data = [new User()] component._skipLoading = true fixture.detectChanges() })...

到目前为止,您可能已经注意到,只需更新一些我们中央配置,一些测试就通过了,其余的测试可以通过应用我们在整本书中一直在使用的各种模式来解决。例如user-management.component.spec.ts使用了我们创建的常用测试模块和提供者:

src/app/manager/user-management/user-management.component.spec.ts providers: commonTestingProviders,imports: commonTestingModules.concat([ManagerMaterialModule]),

当您使用提供者和伪装时,请记住正在测试的模块、组件、服务或类,并小心只提供依赖项的伪装。

ViewUserComponent是一个特殊情况,我们不能使用我们的常用测试模块和提供者,否则我们将最终创建循环依赖。在这种情况下,手动指定需要导入的模块。

  1. 继续修复单元测试配置,直到所有测试都通过!

在本书中,我们没有涉及任何功能单元测试,其中我们会测试一些业务逻辑以测试其正确性。相反,我们专注于保持自动生成的测试处于工作状态。我强烈建议使用 Angular 开箱即用提供的优秀框架来实现单元测试,以覆盖关键业务逻辑。

您始终可以选择进一步编写基本单元测试,使用 Jasmine 来隔离测试类和函数。Jasmine 具有丰富的测试双功能,能够模拟和监视依赖关系。编写和维护这种基本单元测试更容易、更便宜。然而,这个话题本身非常深入,超出了本书的范围。

在本章中,我们完成了所有主要的 Angular 应用程序设计考虑,以及配方,以便能够轻松实现一款业务应用程序。我们讨论了应用面向对象的类设计,以使数据的填充或序列化更容易。我们创建了可由路由器激活或嵌入到另一个具有数据绑定的组件中的可重用组件。我们展示了您可以将数据POST到服务器并缓存响应。我们还创建了一个响应屏幕尺寸变化的丰富多步输入表单。我们通过利用解析守卫从组件中删除样板代码来加载用户数据。然后,我们使用辅助路由实现了主/详细视图,并演示了如何构建带有分页的数据表。

总的来说,通过使用路由器优先设计、架构和实现方法,我们以对我们想要实现的内容有很好的高层次理解来处理我们应用程序的设计。此外,通过及早识别重用机会,我们能够优化我们的实现策略,提前实现可重用组件,而不会冒过度设计解决方案的风险。

在下一章中,我们将在 AWS 上设置一个高可用的基础架构来托管 LemonMart。我们将使用新的脚本更新项目,以实现无停机的蓝绿部署。

网络是一个充满敌意的环境。有好人和坏人。坏人可能会试图找到您安全漏洞,或者试图通过分布式拒绝服务DDoS)攻击来使您的网站崩溃。如果幸运的话,好人会喜欢您的网站并且不会停止使用它。他们会给您提出改进网站的建议,但也可能遇到错误,并且可能因为高流量而使您的网站变得非常缓慢。在网络上进行真实部署需要大量的专业知识才能做到正确。作为全栈开发人员,您只能了解硬件、软件和网络的许多微妙之处。幸运的是,随着云服务提供商的出现,许多这些专业知识已经被转化为软件配置,由提供商来处理困难的硬件和网络问题。

云服务提供商最好的特性之一是云可伸缩性,这指的是您的服务器在面对意外高流量时自动扩展,而在流量恢复到正常水平时自动缩减成本。亚马逊网络服务AWS)不仅具备基本的云可伸缩性,还引入了高可用性和容错概念,允许弹性的本地和全球部署。我选择介绍 AWS,是因为它的广泛功能远远超出了我在本书中所涉及的范围。通过 Route 53,您可以获得免费的 DDoS 防护;通过 API Gateway,您可以创建 API 密钥;通过 AWS Lambda,您可以以每月仅几美元的成本处理数百万次的交易;通过 CloudFront,您可以在世界主要城市周围的秘密边缘位置缓存您的内容。此外,蓝绿部署将允许您实现无停机部署您的软件。

总的来说,你将在本章学习的工具和技术适用于任何云服务提供商,并且正在迅速成为任何全栈开发人员的关键知识。我们将讨论以下主题:

  • 创建和保护 AWS 账户

  • 合适的基础设施规模

  • 简单的负载测试以优化实例

  • 配置和部署到 AWS ECS Fargate

  • 脚本化的蓝绿部署

  • 计费

帐户访问和控制在任何云服务中都至关重要,AWS 也不例外。在初始帐户创建后,您将拥有您的根凭据,即您的电子邮件和密码组合。

让我们从创建 AWS 帐户开始:

  1. 首先导航到https://console.aws.amazon.com

  2. 如果您没有帐户,请创建一个新帐户

  3. 如果您是 AWS 的新用户,您可以在此注册屏幕上获得 12 个月的免费服务访问权限:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (131)AWS 帐户注册

您的 AWS 计费与您的根凭据相关联。如果遭到破坏,您的帐户可能会受到很大的损害,而在您重新获得访问权限之前可能会发生很多损害。

  1. 确保您在根凭据上启用了双因素认证:

为了增加安全层,从现在开始,您需要停止使用根凭据登录到您的 AWS 帐户。您可以使用 AWS 身份和访问管理(IAM)模块创建用户帐户。如果这些帐户遭到破坏,与您的根帐户不同,您可以轻松快速地删除或替换它们。

  1. 导航到IAM模块

  2. 创建一个具有全局管理员权限的新用户帐户

  3. 使用这些凭据登录到 AWS 控制台

  4. 您还应该为这些凭据启用双因素认证

  5. 安全的帐户设置如下,每个状态都报告为绿色:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (132)安全设置后的 AWS IAM 模块

与用户帐户一起工作的主要好处是程序化访问。对于每个用户帐户,您可以创建一个公共访问 ID 和私有访问密钥对。当您与第三方合作,例如托管的持续集成服务、您自己的应用程序代码或 CLI 工具时,您使用您的程序化访问密钥连接到您的 AWS 资源。当访问密钥不可避免地泄漏时,快速方便地禁用对旧密钥的访问并创建新密钥。

此外,用户帐户访问可以通过非常细粒度的权限进行严格控制。您还可以创建具有一组权限的角色,并进一步控制 AWS 服务和一些外部服务之间的通信。

在创建用户帐户和角色时,始终要在最小权限方面犯错误。当与不熟悉 AWS 的客户、承包商或同事合作时,这可能是一种令人沮丧的练习,但这是一种值得的练习。

你的安全性和可靠性取决于最薄弱的环节,因此你必须计划应对故障,并且最重要的是,定期实践恢复计划。

密码和私钥泄漏比你想象的更常见。你的密钥可能会在不安全的公共 Wi-Fi 网络中被泄露;你可能会意外地将它们提交到你的代码仓库中,或者使用极不安全的通信方法,比如电子邮件。然而,意外的代码提交是最大的问题,因为大多数初级开发者并不意识到在源代码控制系统中删除并不是一个选项。

作为开发者,有一些值得注意的最佳实践可以遵循以保护你的秘密:

  1. 始终在公共 Wi-Fi 上使用 VPN 服务,比如tunnelbear.com

  2. 利用位于用户home文件夹下的.aws/credentials文件,创建配置文件并存储访问密钥

  3. 在项目的根目录中创建一个.env文件,并将其列入.gitignore,以存储你的 CI 服务器可能会后续注入的任何秘密作为团队规范

  4. 始终在推送之前审查提交

每次遵循这些惯例都会养成一个好习惯,永远不要将你的秘密提交到代码仓库中。在下一节中,我们将深入探讨云环境的资源考虑。

优化基础设施的目的是保护公司的收入,同时最大限度地减少基础设施的运营成本。你的目标应该是确保用户不会遇到高延迟,也就是不良性能,或者更糟糕的是未完成或丢弃的请求,同时使你的企业保持可持续的努力。

Web 应用程序性能的三大支柱如下:

  1. CPU 利用率

  2. 内存使用量

  3. 网络带宽

我故意将磁盘访问排除在关键考虑指标之外,因为只有在应用服务器或数据存储上执行特定工作负载时才会受到影响。只要应用程序资产由内容交付网络(CDN)提供,磁盘访问很少会影响提供 Web 应用程序的性能。也就是说,仍然要注意任何意外的磁盘访问,比如高频率创建临时和日志文件。例如,Docker 可能会输出日志,这些日志很容易填满驱动器。

在理想的情况下,CPU、内存和网络带宽的使用应该均匀地在可用容量的 60-80%左右。如果您遇到性能问题,由于诸如磁盘 I/O、慢的第三方服务或低效的代码等各种其他因素,很可能您的某个指标会接近或达到最大容量,而另外两个指标则处于空闲或严重未被充分利用的状态。这是一个利用更多 CPU、内存或带宽来补偿性能问题并均匀利用可用资源的机会。

将 60-80%的利用率作为目标的原因是为了为新实例(服务器或容器)提供一些时间来进行配置,并准备好为用户提供服务。在您预定义的阈值被突破后,当新实例被配置时,您可以继续为日益增长的用户提供服务,从而最小化未满足的请求。

在本书中,我已经反对过度设计或完美的解决方案。在当今复杂的 IT 环境中,几乎不可能预测您将遇到性能瓶颈的地方。您的工程师很容易花费 10 万美元以上的工程时间,而解决您的问题可能只需要几百美元的新硬件,无论是网络交换机、固态硬盘、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 statsCONTAINER ID CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS27d431e289c9 0.00% 1.797MiB / 1.952GiB 0.09% 13.7kB / 285kB 0B / 0B 2

以下是 Node 和基于 NGINX 的服务器在空闲时利用的系统资源的比较结果:

服务器**镜像大小****内存使用**
duluca/minimal-nginx-web-server16.8 MB1.8 MB
duluca/minimal-node-web-server71.8 MB37.0 MB

然而,空闲时的值只能讲述故事的一部分。为了更好地了解情况,我们必须进行简单的负载测试,以查看在负载下的内存和 CPU 利用率。

为了更好地了解我们服务器的性能特征,让我们对它们施加一些负载和压力:

  1. 使用 docker run 来启动您的容器:
$ docker run --name <imageName> -d -p 8080:<internal_port> <imageRepo>

如果您正在使用 npm Scripts for Docker,执行以下命令来启动您的容器:

$ npm run docker:debug
  1. 执行以下 bash 脚本来开始负载测试:
$ curl -L http://bit.ly/load-test-bash [](http://bit.ly/load-test-bash) | bash -s 100 "http://localhost:8080"

该脚本将向服务器发送 100 个请求/秒,直到您终止它。

  1. 执行 docker stats 来观察性能特征。

以下是 CPU 和内存利用的高级观察:

CPU 利用率统计**低****中****高****最大内存**
duluca/minimal-nginx-web-server2%15%60%2.4 MB
duluca/minimal-node-web-server20%45%130%75 MB

正如您所看到的,两个服务器提供完全相同内容之间存在显著的性能差异。请注意,基于每秒请求的这种测试适用于比较分析,并不一定反映实际使用情况。

很明显,我们的 NGINX 服务器将为我们带来最佳性价比。有了最佳解决方案,让我们在 AWS 上部署应用程序。

AWS 弹性容器服务ECS)Fargate 是一种在云中部署容器的成本效益高且易于配置的方式。

ECS 由四个主要部分组成:

  1. 容器仓库,弹性容器注册表ECR),您可以在其中发布 Docker 镜像

  2. 服务、任务和任务定义,您可以在其中定义容器的运行时参数和端口映射,作为服务运行的任务定义。

  3. 集群,一个包含 EC2 实例的集合,可以在其中配置和扩展任务

  4. Fargate 是一个托管的集群服务,它抽象了 EC2 实例、负载均衡器和安全组的问题

在发布时,Fargate 仅在 AWSus-east-1地区可用。

我们的目标是创建一个高可用的蓝绿部署,这意味着在服务器故障甚至部署期间,我们的应用程序至少会有一个实例在运行。这些概念在第十二章中进行了详细探讨,Google Analytics 和高级云运维,在可扩展环境中的每用户成本部分。

您可以在 AWS 服务菜单下访问 ECS 功能,选择弹性容器服务链接。

如果这是您第一次登录,您必须通过教程,其中您将被强制创建一个示例应用程序。我建议您完成教程后删除示例应用程序。为了删除服务,您需要将服务的任务数量更新为 0。此外,删除默认集群以避免任何意外费用。

让我们从配置 Fargate 集群开始,这将在配置其他 AWS 服务时充当锚点。我们的集群最终将运行一个集群服务,在接下来的章节中我们将逐渐构建起来。

在发布时,AWS Fargate 仅在 AWS 美国东部地区可用,支持更多地区和即将推出对 Amazon 弹性容器服务 Kubernetes(Amazon EKS)的支持。Kubernetes 是 AWS ECS 的广泛首选开源替代方案,具有更丰富的容器编排能力,可用于本地、云和混合云部署。

让我们创建集群:

  1. 转到弹性容器服务

  2. 单击集群|创建集群

  3. 选择仅网络...由 AWS Fargate 提供支持的模板

  4. 单击“下一步”,您将看到创建集群步骤,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (133)AWS ECS 创建集群

  1. 将集群名称输入为fargate-cluster

  2. 创建一个 VPC 来将您的资源与其他 AWS 资源隔离开来

  3. 单击创建集群以完成设置

您将看到您的操作摘要,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (134)AWS ECS Fargate Cluster

现在您已经在其自己的虚拟私有云VPC)中创建了一个集群,您可以在弹性容器服务 | 集群下查看它。

接下来,我们需要设置一个存储库,我们可以在其中发布我们在本地或 CI 环境中构建的容器映像:

  1. 转到弹性容器服务

  2. 单击 Repositories | Create Repository

  3. 将存储库名称输入为lemon-mart

  4. 复制屏幕上生成的存储库 URI

  5. 将 URI 粘贴到您的应用程序的package.json中作为新的imageRepo变量:

package.json ..."config": { “imageRepo”: “000000000000.dkr.ecr.us-east-1.amazonaws.com/lemon-mart”, ...}
  1. 单击创建存储库

  2. 单击下一步,然后单击完成以完成设置

在摘要屏幕上,您将获得有关如何在 Docker 中使用存储库的进一步说明。在本章的后面,我们将介绍将为我们处理此事的脚本。

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (135)AWS ECS 存储库

您可以在弹性容器服务 | 存储库下查看您的新存储库。我们将在即将到来的npm Scripts for AWS部分介绍如何发布您的镜像。

在我们的存储库中定义了一个容器目标后,我们可以定义一个任务定义,其中包含运行容器所需的元数据,例如端口映射、保留的 CPU 和内存分配:

  1. 转到弹性容器服务

  2. 单击任务定义 | 创建新任务定义

  3. 选择 Fargate 启动类型兼容性

  4. 将任务定义名称输入为lemon-mart-task

  5. 选择任务角色none(您可以稍后添加一个以启用访问其他 AWS 服务)

  6. 输入任务大小0.5 GB

  7. 输入任务 CPU 0.25 CPU

  8. 单击添加容器:

  9. 将容器名称输入为lemon-mart

  10. 对于 Image,粘贴之前的镜像存储库 URI,但是在末尾添加:latest标签,以便它始终拉取存储库中的最新镜像,例如000000000000.dkr.ecr.us-east-1.amazonaws.com/lemon-mart:latest

  11. 为 NGINX 设置128 MB的软限制,为 Node.js 设置256 MB

  12. 在端口映射下,指定 NGINX 的容器端口为80,Node.js 的端口为3000

  13. 接受其余默认值

  14. 单击添加;这是在创建之前查看任务定义的方式:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (136)AWS ECS 任务定义

  1. 点击“创建”完成设置

在 Elastic Container Service | 任务定义下查看您的新任务定义。

请注意,默认设置将启用 AWS CloudWatch 日志记录,这是您可以追溯访问容器实例控制台日志的一种方式。在这个例子中,将创建一个名为/ecs/lemon-mart-task的 CloudWatch 日志组。

在 Cloud Watch | 日志下查看您的新日志组。如果要添加需要持久数据的容器,任务定义允许您定义一个卷并挂载一个文件夹到您的 Docker 容器。我已经发布了一个指南,用于在 ECS 容器中配置 AWS 弹性文件系统(EFS)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 相关的配置。您可以在我发布的指南中找到与 SSL 相关的步骤bit.ly/setupAWSECSCluster

  1. 在可用区中,选择为您的 fargate-cluster 创建的 VPC

  2. 选择所有列出的可用区

  3. 展开标签并添加一个键/值对,以便能够识别 ALB,比如"App": "LemonMart"

  4. 点击“下一步”

  5. 选择默认 ELB 安全策略

  6. 点击“下一步”

  7. 创建一个新的集群特定安全组,lemon-mart-sg,只允许端口80入站,如果使用 HTTPS,则允许端口443

在下一节创建集群服务时,请确保此处创建的安全组是在服务创建期间选择的安全组。否则,您的 ALB 将无法连接到您的实例。

  1. 点击下一步

  2. 将新的目标组命名为lemon-mart-target-group

  3. 将协议类型从instance更改为ip

  4. 在健康检查下,保持默认路由/,如果在 HTTP 上提供网站

健康检查对于扩展和部署操作至关重要。这是 AWS 用来检查实例是否已成功创建的机制。

如果部署 API 和/或将所有 HTTP 调用重定向到 HTTPS,请确保您的应用程序定义了一个不会被重定向到 HTTPS 的自定义路由。在 HTTP 服务器 GET /healthCheck返回简单的 200 消息,说我很健康,并验证这不会重定向到 HTTPS。否则,您将经历很多痛苦和苦难,试图弄清楚问题出在何处,因为所有健康检查都失败,部署也莫名其妙地失败。duluca/minimal-node-web-server提供了 HTTPS 重定向,以及一个开箱即用的仅 HTTP 的/healthCheck端点。使用duluca/minimal-nginx-web-server,您将需要提供自己的配置。

  1. 点击下一步

  2. 不要注册任何目标或 IP 范围。ECS Fargate 将神奇地为您管理这一切,如果您自己这样做,您将提供一个半破碎的基础设施。

  3. 点击下一步:审查;您的 ALB 设置应该与所示的类似:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (137)AWS 应用负载均衡器设置

  1. 点击创建完成设置

在下一节创建集群服务时,您将使用 lemon-mart-alb。

现在,我们将通过使用任务定义和我们创建的 ALB 在我们的集群中创建一个服务来将所有内容整合在一起:

  1. 导航到弹性容器服务

  2. 点击集群| fargate-cluster

  3. 在服务选项卡下,点击创建

  4. 选择启动类型Fargate

  5. 选择您之前创建的任务定义

请注意,任务定义是有版本的,比如lemon-mart-task:1。如果您对任务定义进行更改,AWS 将创建lemon-mart-task:2。您需要使用这个新版本更新服务,以使更改生效。

  1. 输入服务名称lemon-mart-service

  2. 任务数量2

  3. 最小健康百分比50

  4. 最大百分比200

  5. 点击下一步

将最小健康百分比设置为 100,以确保在部署期间保持高可用性。Fargate 的定价是基于每秒的使用量,因此在部署应用程序时,您将额外收费用于额外实例,而旧实例正在被取消配置。

  1. 在配置网络下,选择与之前相同的 VPC 作为您的集群

  2. 选择所有可用的子网;至少应该有两个以实现高可用性

  3. 在上一节中创建的安全组中选择lemon-mart-sg

  4. 选择负载均衡器类型为应用程序负载均衡器

  5. 选择 lemon-mart-alb 选项

  6. 通过单击“添加到负载均衡器”按钮,将容器端口添加到 ALB,例如803000

  7. 选择您已经定义的侦听器端口

  8. 选择您已经定义的目标组

  9. 取消选中“启用服务发现集成”

  10. 单击“下一步”

  11. 如果您希望您的实例在达到一定限制时自动扩展和缩减,则设置自动扩展

我建议在服务的初始设置期间跳过自动扩展的设置,以便更容易排除任何潜在的配置问题。您可以随后返回并进行设置。自动任务扩展策略依赖于警报,例如 CPU 利用率。在第十二章 Google Analytics and Advanced Cloud Ops,中的可扩展环境中的每用户成本部分,您可以了解如何计算您的最佳目标服务器利用率,并根据此数字设置您的警报。

  1. 单击“下一步”并审查您的更改,如图所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (138)AWS Fargate 集群服务设置

  1. 最后,单击“保存”完成设置

在 Elastic Container Service |Clusters | fargate-cluster | lemon-mart-service 下观察您的新服务。在将图像发布到容器存储库之前,您的 AWS 服务将无法配置实例,因为健康检查将不断失败。发布图像后,您需要确保服务的事件选项卡中没有错误。

AWS 是一个复杂的系统,使用 Fargate 可以避免很多复杂性。但是,如果您有兴趣使用自己的 Ec2 实例设置自己的 ECS 集群,您可以获得 1-3 年预留实例的重大折扣。我有一个 75+设置指南可在bit.ly/setupAWSECSCluster上获得。

我们已经手动执行了很多步骤来创建我们的集群。AWS CloudFormation 通过提供配置模板来解决这个问题,您可以根据自己的需求进行自定义,或者从头开始编写自己的模板脚本。如果您想认真对待 AWS,这种代码即基础架构设置绝对是正确的方式。

对于生产部署,请确保您的配置由 CloudFormation 模板定义,这样它就可以很容易地重新配置,而不是在部署相关的失误发生时。

如果您使用 AWS Route 53 来管理您的域名,很容易将域名或子域分配给 ALB:

  1. 导航到 Route 53 | 托管区域

  2. 选择您的域名,如thejavascriptpromise.com

  3. 点击“创建记录集”

  4. 将名称输入为lemonmart

  5. 将别名设置为“是”

  6. 从负载均衡器列表中选择 lemon-mart-alb

  7. 点击“创建”完成设置

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (139)Route 53 - 创建记录集

现在,您的站点将可以通过您刚刚定义的子域访问,例如http://lemonmart.thejavascriptpromise.com

如果不使用 Route 53,请不要惊慌。在您的域名提供商的网站上,编辑“区域”文件以创建一个A记录到 ELB 的 DNS 地址,然后您就完成了。

为了获取负载均衡器的 DNS 地址,请执行以下步骤:

  1. 导航到 EC2 | 负载均衡器

  2. 选择 lemon-mart-alb

  3. 在“描述”选项卡中注意 DNS 名称;请参考以下示例:

DNS name:lemon-mart-alb-1871778644.us-east-1.elb.amazonaws.com (A Record)

本节假定您已经按照第三章中详细介绍的设置了 Docker 和“用于 Docker 的 npm 脚本”。您可以在bit.ly/npmScriptsForDocker获取这些脚本的最新版本。

实现优化的Dockerfile

Dockerfile FROM duluca/minimal-nginx-web-server:1.13.8-alpineCOPY dist /var/wwwCMD 'nginx'

请注意,如果您正在使用“用于 Docker 的 npm 脚本”,请将内部镜像端口从3000更新为80,如下所示:

"docker:runHelper": "cross-conf-env docker run -e NODE_ENV=local --name $npm_package_config_imageName -d -p $npm_package_config_imagePort:80 $npm_package_config_imageRepo",

就像“用于 Docker 的 npm 脚本”一样,我开发了一组脚本,称为“用于 AWS 的 npm 脚本”,可以在 Windows 10 和 macOS 上运行。这些脚本将允许您以惊人的、无停机的蓝绿色方式上传和发布您的 Docker 镜像。您可以在bit.ly/npmScriptsForAWS获取这些脚本的最新版本:

  1. 确保在您的项目上设置了bit.ly/npmScriptsForDocker

  2. 创建一个.env文件并设置AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY

.envAWS_ACCESS_KEY_ID=your_own_key_idAWS_SECRET_ACCESS_KEY=your_own_secret_key
  1. 确保您的.env文件在您的.gitignore文件中,以保护您的秘密信息。

  2. 安装或升级到最新的 AWS CLI:

  • 在 macOS 上brew install awscli

  • 在 Windows 上choco install awscli

  1. 使用您的凭据登录到 AWS CLI:

  2. 运行aws configure

  3. 您需要从配置 IAM 帐户时获取您的访问密钥 ID 和秘密访问密钥

  4. 设置默认区域名称为us-east-1

  5. 更新package.json,添加一个新的config属性,具有以下配置属性:

package.json ... "config": { ... "awsRegion": "us-east-1", "awsEcsCluster": "fargate-cluster", "awsService": "lemon-mart-service" }, ...

确保您更新了package.json,从您配置npm Scripts for Docker时,imageRepo属性中有您新的 ECS 存储库的地址。

  1. package.json中添加 AWS scripts,如下所示:
package.json..."scripts": { ... "aws:login": "run-p -cs aws:login:win aws:login:mac", "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)"}

npm run aws:login调用特定于平台的命令,自动执行从 AWS CLI 工具获取 Docker 登录命令的多步操作,如下所示:

example$ aws ecr get-login --no-include-email --region us-east-1docker login -u AWS -p eyJwYXl...3ODk1fQ== https://073020584345.dkr.ecr.us-east-1.amazonaws.com

您首先要执行aws ecr get-login,然后复制粘贴生成的docker login命令并执行它,以便您的本地 Docker 实例指向 AWS ECR:

package.json..."scripts": { ... "aws:deploy": "cross-conf-env docker run --env-file ./.env duluca/ecs-deploy-fargate -c $npm_package_config_awsEcsCluster -n $npm_package_config_awsService -i $npm_package_config_imageRepo:latest -r $npm_package_config_awsRegion --timeout 1000" }...

npm run aws:deploy拉取一个 Docker 容器,它本身执行蓝绿部署,使用您使用aws ecr命令提供的参数。这个工作原理的细节超出了本书的范围。要查看更多使用原生aws ecr命令的示例,请参考aws-samples存储库,网址为github.com/aws-samples/ecs-blue-green-deployment

请注意,duluca/ecs-deploy-fargate蓝绿部署脚本是原始silintl/ecs-deploy镜像的一个分支,经过修改以支持使用 PR https://github.com/silinternational/ecs-deploy/pull/129进行 AWS ECS Fargate。一旦silintl/ecs-deploy合并了这一更改,我建议您在蓝绿部署中使用silintl/ecs-deploy

package.json..."scripts": { ... "aws:release": "run-s -cs aws:login docker:publish aws:deploy"}...

最后,npm run aws:release简单地按正确顺序运行aws:logindocker:publishnpm Scripts for Dockeraws:deploy命令。

您的项目已配置为在 AWS 上部署。您主要需要使用我们创建的两个命令来构建和发布图像:

  1. 执行docker:debug来测试、构建、标记、运行、跟踪并在浏览器中启动您的应用程序以测试图像:
$ npm run docker:debug
  1. 执行aws:release以配置 Docker 登录到 AWS,发布您的最新图像构建,并在 ECS 上发布它:
 $ npm run aws:release
  1. 验证您的任务是否在服务级别上运行:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (140)AWS ECS 服务确保运行计数和期望计数相同。

  1. 验证您的实例是否在任务级别上运行:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (141)AWS ECS 任务实例

请注意公共 IP 地址并导航到它;例如,http://54.164.92.137,您应该看到您的应用程序或 LemonMart 正在运行。

  1. 验证负载均衡器设置在 DNS 级别上是否正确。

  2. 导航到 ALB DNS 地址,例如http://lemon-mart-alb-1871778644.us-east-1.elb.amazonaws.com,并确认应用程序呈现如下:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (142)在 AWS Fargate 上运行的 LemonMart

Et voilà!您的网站应该已经上线并运行。

在随后的发布中,您将能够观察蓝绿部署的实际操作,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (143)蓝绿部署期间的 AWS 服务

有两个正在运行的任务,正在提供两个新任务。在验证新任务的同时,运行计数将上升到四个任务。在验证新任务并且从旧任务中排出连接之后,运行计数将返回到两个。

您可以通过配置 CircleCI 与您的 AWS 凭据,使用安装了awscli工具并运行npm Scripts for 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
亚马逊路由 53$0.50
总计**$44.10**

请注意,账单非常详细,但确实准确列出了我们最终使用的所有 AWS 服务。主要成本是在EC2 容器服务ECS)上运行我们的 Web 服务器的两个实例,以及在弹性计算云EC2)上运行负载均衡器。客观地说,每月 45 美元似乎是托管一个 Web 应用程序的很多钱。如果愿意自己设置专用 EC2 服务器的集群,并且可以选择 1 年或 3 年的付款周期,最多可以节省 50%的费用。在 Heroku 上,类似的高可用部署以每月 50 美元起步,并提供其他丰富的功能。同样,在 Zeit Now 上,两个实例的成本为每月 30 美元。请注意,Heroku 和 Zeit Now 都不提供对物理上不同可用区的访问。另一方面,Digital Ocean 允许您在不同的数据中心中设置服务器;但是,您必须编写自己的基础设施。每月 15 美元,您可以在三台服务器上设置自己的高可用集群,并能够在上面托管多个站点。

在本章中,您了解了在正确保护您的 AWS 账户时的微妙之处和各种安全考虑因素。我们讨论了调整基础设施的概念。您以隔离的方式进行了简单的负载测试,以找出两个 Web 服务器之间性能的相对差异。拥有优化的 Web 服务器后,您配置了 AWS ECS Fargate 集群,以实现高可用的云基础设施。使用 AWS 的 npm 脚本,您学会了如何编写可重复且可靠的无停机蓝绿部署。最后,您了解了在 AWS 和其他云提供商(如 Heroku、Zeit Now 和 Digital Ocean)上运行基础设施的基本成本。

在下一章,我们将完成对全栈 Web 开发人员在部署 Web 应用程序时应该了解的各种主题的广度的覆盖。我们将向 LemonMart 添加 Google Analytics 以测量用户行为,利用高级负载测试来了解部署良好配置的可扩展基础设施的财务影响,并使用自定义分析事件来测量重要应用程序功能的实际使用情况。

您已经设计、开发并部署了一个世界级的 Web 应用程序;然而,这只是您应用程序故事的开始。网络是一个不断发展的、生机勃勃的环境,需要关注才能继续成功地作为一个业务。在第十一章中,AWS 上高可用云基础设施,我们已经介绍了云基础设施的基本概念和所有权成本。在本章中,我们将更深入地了解用户如何实际使用谷歌分析来创建真实的负载测试,以模拟实际用户行为,了解单个服务器实际容量。了解单个服务器的容量,我们可以微调我们的基础设施扩展以减少浪费,并讨论各种扩展策略的影响。最后,我们将介绍高级分析概念,如自定义事件,以获得对用户行为更细粒度的理解和跟踪。

在本章中,您将了解以下主题:

  • 谷歌分析

  • 谷歌标签管理器

  • 预算和扩展

  • 高级负载测试以预测容量

  • 自定义分析事件

在整个章节中,您将设置这些:

  • 谷歌分析账户

  • 谷歌标签管理器账户

  • OctoPerf 账户

现在我们的网站已经上线运行,我们需要开始收集指标来了解它的使用情况。指标是操作 Web 应用程序的关键。

谷歌分析有许多方面;主要的三个如下:

  1. 获取,衡量访问者如何到达您的网站

  2. 行为,衡量访问者如何与您的网站互动

  3. 转化,衡量访问者如何在您的网站上完成各种目标

让我们来看看我的网站TheJavaScriptPromise.com的行为|概述:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (144)谷歌分析行为概述

TheJavaScriptPromise.com是一个简单的单页面 HTML 网站,所以指标非常简单。让我们来看看屏幕上的各种指标:

  1. 页面浏览显示访问者数量

  2. 独立页面浏览显示独立访问者的数量

  3. 平均页面停留时间显示每个用户在网站上花费的时间

  4. 跳出率显示用户在不浏览子页面或以任何方式与站点进行交互的情况下离开站点,例如单击具有自定义事件的链接或按钮

  5. % 退出表示用户在查看特定页面或一组页面后离开站点的频率

在 2017 年,该网站大约有 1,090 名独立访客,平均每位访客在网站上花费约 2.5 分钟或 157 秒。鉴于这只是一个单页面站点,跳出率和%退出指标在任何有意义的方式上都不适用。稍后,我们将使用这些数字来计算每用户成本。

除了页面浏览之外,Google Analytics 还可以捕获特定事件,例如单击触发服务器请求的按钮。然后可以在事件|概述页面上查看这些事件,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (145)Google Analytics 事件概述

在服务器端也可以捕获指标,但这将提供请求随时间变化的统计数据。您将需要额外的代码和状态管理来跟踪特定用户的行为,以便计算用户随时间变化的统计数据。通过在客户端使用 Google Analytics 实施此类跟踪,您可以更详细地了解用户的来源、他们的行为、是否成功以及何时离开您的应用程序,而不会给后端添加不必要的代码复杂性和基础设施负载。

让我们开始在您的 Angular 应用程序中捕获分析数据。Google 正在逐步淘汰随 Google Analytics 一起提供的传统ga.jsanalytics.js产品,而是使用其新的、更灵活的全局站点标签gtag.js,该标签与 Google 标签管理器一起提供。这绝不是对 Google Analytics 的结束;相反,它是朝着更易于配置和管理的分析工具的转变。全局站点标签可以通过 Google 标签管理器远程配置和管理。标签是交付给客户端的 JavaScript 跟踪代码片段,它们可以启用对新指标的跟踪,并与多个分析工具集成,而无需更改已部署的代码。您仍然可以继续使用 Google Analytics 来分析和查看您的分析数据。Google 标签管理器的另一个主要优势是它是版本控制的,因此您可以在不害怕对分析配置造成任何不可逆转的损害的情况下尝试不同类型的标签,这些标签在各种条件下被触发。

让我们从为您的应用程序设置 Google 标签管理器帐户开始:

  1. 登录到GoogleTagManager.com的 Google 标签管理器

  2. 按照以下步骤添加一个带有 Web 容器的新帐户:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (146)Google 标签管理器

  1. 按照指示将生成的脚本粘贴到您的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="https://www.googletagmanager.com/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>

请注意,<noscript>标签仅在用户在其浏览器中禁用 JavaScript 执行时才会执行。这样,我们可以收集这些用户的指标,而不是对他们的存在一无所知。

  1. 提交并发布您的标签管理器容器

  2. 您应该看到您的标签管理器的初始设置已完成,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (147)已发布的标签

  1. 验证您的 Angular 应用程序是否没有任何错误运行。

请注意,如果您不发布您的标签管理器容器,您将在dev控制台或网络选项卡中看到 404 错误加载gtm.js

现在,让我们通过 Google Analytics 生成一个跟踪 ID:

  1. 登录到analytics.google.com的 Google Analytics

  2. 打开管理控制台,如下面截图中指出的齿轮图标:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (148)Google Analytics 管理控制台

  1. 创建一个新的分析帐户

  2. 使用图像中的箭头作为指南:

  3. 添加一个名为LemonMart的新属性

  4. 根据您的偏好配置属性

  5. 点击跟踪代码

  6. 复制以UA-xxxxxxxxxx-1开头的跟踪 ID

  7. 忽略提供的gtag.js代码

现在,让我们将我们的 Google Analytics ID 连接到 Google Tag Manager:

  1. tagmanager.google.com上,打开工作区选项卡

  2. 点击添加新标签

  3. 将其命名为Google Analytics

  4. 点击标签配置并选择通用分析

  5. 在 Google Analytics 设置下,添加一个新变量

  6. 在上一节中复制的跟踪 ID 粘贴

  7. 点击触发器并添加所有页面触发器

  8. 点击保存,如下截图所示:

创建 Google Analytics 标签

  1. 提交并发布您的更改,并观察版本摘要,其中显示了 1 个标签:

显示一个标签的版本摘要

  1. 现在刷新您的 Angular 应用程序,在/home路由上

  2. 在私人窗口中,打开您的 Angular 应用程序的新实例,并导航到/manager/home路由

  3. analytics.google.com上,打开实时|概览窗格,如下所示:

Google Analytics 实时概览

  1. 请注意,正在跟踪两个活跃用户

  2. 在活跃页面顶部,您应该看到用户所在的页面

通过同时利用 Google Tag Manager 和 Google Analytics,我们能够在不更改 Angular 应用程序内部任何代码的情况下完成页面跟踪。

搜索引擎优化SEO)是分析的重要部分。为了更好地了解爬虫如何感知您的 Angular 站点,请使用 Google 搜索控制台,网址为www.google.com/webmasters/tools,来识别优化。此外,考虑使用 Angular Universal 来在服务器端呈现某些动态内容,以便爬虫可以索引您的动态数据源并将更多流量带到您的站点。

在第十一章的 AWS 计费部分,《在 AWS 上构建高可用云基础设施》,我们涵盖了运行 Web 服务器的月度成本,从每月 5 美元到每月 45 美元,从单服务器实例方案到高可用基础设施。对于大多数需求,预算讨论将从这个月度数字开始并结束。您可以执行负载测试,如高级负载测试部分建议的那样,来预测每台服务器的用户容量,并大致了解您可能需要多少服务器。在一个动态扩展的云环境中,有数十台服务器全天候运行,这是计算预算的一种过于简单化的方式。

如果您经营规模相当大的网络资产,事情变得复杂。您将在不同技术堆栈上运行多个服务器,提供不同的用途。很难判断或证明为看似过剩的容量或不必要的高性能服务器留出多少预算。不知何故,您需要能够沟通您的基础设施的效率,考虑到您服务的用户数量,并确保您的基础设施经过调整,以便您不会因为应用程序无响应或因为使用的容量超出需要而失去用户或支付过多。因此,我们将采取以用户为中心的方法,并将我们的 IT 基础设施成本转化为业务和您组织的营销部门可以理解的每用户成本指标。

在下一节中,我们将调查计算基础设施每用户成本的含义,以及当云扩展应用时这些计算如何改变,以我的一个网站为例。

我们将利用来自 Google Analytics 的行为指标,目标是在一定时间内计算每个用户的成本:

每用户成本Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (149)

使用之前的TheJavaScriptPromise.com数据,让我们将数据代入公式计算perUserCost/month

这个网站部署在 DigitalOcean 的 Ubuntu 服务器上,所以包括每周备份在内的月度基础设施成本为每月 6 美元。从 Google Analytics 中,我们知道 2017 年有 1,090 名独立访客:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (150)

2017 年,我每个用户支付了 7 美分。花得值吗?每月 6 美元,我不介意。在 2017 年,TheJavaScriptPromise.com部署在传统的服务器设置上,作为一个静态站点,不会动态扩展或缩减。这些条件使得使用独立访客指标并找到每个用户成本非常简单。这种简单性不仅使得容易计算,也导致了基础设施的不佳。如果我在相同的基础设施上为 100 万用户提供服务,我的成本将达到每年 7 万美元。如果我通过 Google 广告每 1000 个用户赚取 100 美元,我的网站每年将赚取 10 万美元。税收、开发费用和不合理的托管费用后,该运营很可能会亏损。

如果您利用云扩展,其中实例可以根据当前用户需求动态扩展或缩减,那么前面的公式很快就会变得无用,因为您必须考虑到预配时间和目标服务器利用率。预配时间是您的云提供商从头开始启动新服务器所需的时间。目标服务器利用率是给定服务器的最大使用度量标准,当达到扩展警报时,必须发送新服务器准备就绪,以防当前服务器达到最大容量。为了计算这些变量,我们必须对我们的服务器执行一系列负载测试。

页面浏览是一种过于简单化的方式来确定 Angular 等单页应用程序中的用户行为,其中页面浏览不一定与请求相关联。如果我们仅基于页面浏览执行负载测试,我们将无法真实模拟您的平台在负载下的性能。

用户行为,或者用户实际使用您的应用程序的方式,可以极大地影响您的性能预测,并且会导致预算数字大幅波动。您可以使用 Google Analytics 自定义事件来捕获一系列复杂的操作,这些操作导致平台提供各种类型的请求。在本章的后面,我们将探讨如何在测量实际使用部分中测量实际使用情况。

最初,您将不会拥有任何上述指标,您可能拥有的任何指标都将在您对软件或硬件堆栈进行重大更改时无效。因此,必须定期执行负载测试,以模拟真实的用户负载。

为了能够预测容量,我们需要运行负载测试。在第十一章中,《AWS 上高可用云基础设施》,我讨论了一种简单的负载测试技术,即向服务器发送一堆网络请求。在相对比较的情况下,这对于测试原始功率效果很好。然而,实际用户以不同的间隔生成数十个请求,当他们浏览您的网站时,会导致对后端服务器的各种 API 调用。

我们必须能够模拟虚拟用户,并将大量用户释放到我们的服务器上,以找到服务器的瓶颈。 OctoPerf 是一个易于使用的服务,可执行此类负载测试,位于octoperf.com。 OctoPerf 提供了一个免费的套餐,允许 50 个并发用户/测试在无限次测试运行中使用两个负载生成器:

  1. 创建一个 OctoPerf 账户

  2. 登录并为 LemonMart 添加一个新项目,如图所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (151)OctoPerf 添加项目

OctoPerf 允许您创建具有不同使用特征的多个虚拟用户。由于它是基于 URL 的设置,任何基于点击的用户操作也可以通过直接调用应用程序服务器 URL 与测试参数来模拟。

  1. 创建两个虚拟用户:一个作为“经理”,导航到基于经理的页面,第二个作为POS用户,只能使用 POS 功能

  2. 单击“创建场景”:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (152)POS 用户场景

  1. 将场景命名为“晚高峰”

  2. 您可以添加一些经理和 POS 用户,如图所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (153)晚高峰场景

  1. 单击“启动 50 个 VUs”按钮开始负载测试

您可以实时观察到达到的用户数量和每秒点击数,如下面的屏幕截图所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (154)晚高峰负载测试进行中

  1. ECS 服务指标还给我们提供了实时利用率的高层次概念,如下面的屏幕截图所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (155)ECS 实时指标

  1. 分析负载测试结果。

您可以通过单击 ECS 服务指标中的 CPU 利用率链接或导航到 CloudWatch |指标部分来从 ECS 中获得更准确的结果,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (156)AWS CloudWatch 指标

如前图所示,CPU 利用率在持续 50 个用户负载的 10 分钟内保持在 1.3%左右。在此期间,没有请求错误,如 OctoPerf 的统计摘要所示:

OctoPerf 统计摘要

理想情况下,我们会测量每秒最大用户数,直到出现错误。然而,考虑到只有 50 个虚拟用户和我们已经拥有的信息,我们可以预测在 100%利用率下可以处理多少用户:

我们的负载测试结果显示,我们的基础设施可以处理每秒 3,846 个用户。根据这些信息,我们可以在下一节中计算可扩展环境中的每个用户成本。然而,性能和可靠性是相辅相成的。您选择如何设计基础设施也将提供重要的预算信息,因为您需要的可靠性水平将决定您必须始终保留的实例的最低数量。

可靠性可以用您组织的恢复点目标(RPO)和恢复时间目标(RTO)来表达。 RPO 代表您愿意丢失多少数据,而 RTO 代表在发生故障时您可以多快重建基础设施。

假设你经营一家电子商务网站。每个工作日中午左右,你的销售达到峰值。每当用户将商品添加到购物车时,你会将商品存储在服务器端缓存中,以便用户可以在家后继续他们的购物狂欢。此外,你每分钟处理数百笔交易。生意很好,你的基础设施扩展得很好,一切都运行顺利。与此同时,一只饥饿的老鼠或一个过度充电的闪电云决定袭击你的数据中心。最初,一个看似无害的电源单元停机了,但没关系,因为附近的电源单元可以接管工作。然而,这是午餐高峰期;数据中心上的其他网站也面临着高流量。结果,几个电源单元过热并失败。没有足够的电源单元来接管工作,因此,电源单元接连过热并逐个失败,引发了一系列故障,最终导致整个数据中心崩溃。与此同时,你的一些用户刚刚点击了“添加到购物车”,其他用户点击了“支付”按钮,还有一些用户正要到达你的网站。如果你的 RPO 是一小时,意味着你每小时持久化一次购物车缓存,那么你可能会失去那些夜间购物者的宝贵数据和潜在销售额。如果你的 RTO 是一小时,那么你需要最多一个小时才能让你的网站重新上线运行,你可以放心,那些刚刚点击购买按钮或到达无响应网站的客户大部分当天都不会在你的网站上购买商品。

深思熟虑的 RPO 和 RTO 是一个关键的业务需求,但它们也必须与合适的基础设施配合,以便以一种经济有效的方式实现你的目标。AWS 由全球两打以上的地区组成,每个地区至少包含它们的可用区(AZs)。每个 AZ 都是一个物理上分离的基础设施,不会受到另一个 AZ 故障的影响。

在 AWS 上的高可用配置意味着你的应用程序至少在两个 AZ 上运行,因此,如果一个服务器实例失败,甚至整个数据中心失败,你已经在一个物理上分离的数据中心上有另一个实例可以无缝接管传入的请求。

容错架构意味着您的应用部署在多个区域。即使整个区域因自然灾害、分布式拒绝服务(DDoS)攻击或糟糕的软件更新而崩溃,您的基础设施仍然可以保持稳定,并能够响应用户请求。通过层层安全和错位备份,您的数据得到了保护。

AWS 拥有出色的服务,如 Shield 用于保护针对您网站的 DDoS 攻击,Pilot Light 服务可在另一个区域保持最小基础设施处于休眠状态,如果需要,可以扩展到完整容量,同时保持运营成本低廉,以及 Glacier 服务以经济的方式存储大量数据长时间。

高可用配置将始终需要至少两个实例在多个可用区设置中。对于容错设置,您需要至少在两个区域中拥有两个高可用配置。大多数 AWS 云服务,如用于数据存储的 DynamoDB 或用于缓存的 Redis,默认情况下都是高可用的,包括无服务器技术,如 Lambda。Lambda 按使用量收费,并且可以以成本有效的方式扩展以满足任何需求。如果您可以将繁重的计算任务转移到 Lambda,您可以大大减少服务器利用率和扩展需求。在规划基础设施时,您应考虑所有这些变量,以建立适合您需求的可扩展环境。

在可扩展的环境中,你不能计划 100%的利用率。要为新服务器提供服务需要时间。利用率达到 100%的服务器无法及时处理额外的请求,这会导致用户视角下的请求丢失或错误。因此,相关服务器必须在达到 100%利用率之前发送触发器,以避免请求丢失。在本章的前面,我建议在扩展之前将目标利用率设定为 60-80%。确切的数字将高度依赖于您特定的软件和硬件堆栈选择。根据您的自定义利用率目标,我们可以计算出您的基础设施预计平均每个实例需要为多少用户提供服务。利用这些信息,您可以计算出更准确的每用户成本,这应该可以根据您的特定需求来正确规划您的 IT 预算。低于预算和超出预算一样糟糕。您可能会放弃增长、安全性、数据、可靠性和弹性,这是不可接受的。

在下一节中,我们将详细介绍如何计算最佳目标服务器利用率指标,以便您可以计算更准确的每用户成本;然后,我们将探讨在预定时间框架和软件部署期间可能发生的扩展。

首先,计算您的自定义服务器利用率目标,这是您的服务器承受增加负载并触发新服务器提供服务的点,以便原始服务器不会达到 100%的利用率并丢失请求。考虑这个公式:

目标利用率Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (157)

让我们通过一个具体的例子来演示这个公式是如何工作的:

  1. 对您的实例进行负载测试,以找出每个实例的用户容量:负载测试结果: 3,846 用户/秒

每秒请求和每秒用户并不相等,因为用户需要多次请求才能完成一个动作,可能每秒执行多个请求。高级负载测试工具如 OctoPerf 是必要的,以执行真实和多样化的工作负载,并测量用户容量和请求容量。

  1. 测量实例提供速度,从创建/冷启动到首次满足请求:测量实例提供速度: 60 秒

为了测量这个速度,你可以放下秒表。根据你的确切设置,AWS 在 ECS 服务事件选项卡、CloudWatch 和 CloudTrail 中提供事件和应用程序日志,以关联足够的信息来确定何时请求了一个新实例以及实例准备好满足请求需要多长时间。例如,在 ECS 服务事件选项卡中,将目标注册事件作为开始时间。一旦任务开始,点击任务 ID 查看创建时间。使用任务 ID,在 CloudWatch 中检查任务的日志,以查看任务为第一个网络请求提供服务的时间作为结束时间,然后计算持续时间。

  1. 测量 95 百分位数用户增长率,排除已知容量增加:95 百分位数用户增长率:每秒 10 个用户

如果你没有先前的指标,最初定义用户增长率将是最好的一个合理猜测。然而,一旦开始收集数据,你可以更新你的假设。此外,要以一种成本效益的方式运营一个可以应对任何想象得到的异常值的基础设施是不可能的。根据你的指标,应该有意识地做出一个商业决策,忽略哪个异常值百分位数作为可接受的商业风险。

  1. 让我们将数字代入公式中:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (158)

自定义目标利用率,向下取整,将是 84%。将扩展触发器设置为 84%将避免实例过度配置,同时避免丢弃用户请求。

有了这个自定义的目标利用率,让我们考虑扩展后更新每用户成本公式:

带有扩展的每用户成本

因此,如果我们的基础设施成本是每月 100 美元,为 150 个用户提供服务,在 100%的利用率下,你可以计算每用户成本为每月$0.67/用户。如果考虑到扩展,成本将如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (159)

在不丢弃请求的情况下进行扩展将使每用户每月的成本从原始的$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 标签管理器工作区,网址为tagmanager.google.com

  2. 在触发器菜单下,单击新建,如图所示:

创建checkout函数,在进行服务调用之前调用checkoutInitiated

  1. 命名您的触发器

  2. 单击空的触发器卡以选择事件类型

  3. 选择自定义事件

  4. 创建名为checkoutCompleted的自定义事件,如图所示:

现在,让我们编辑 Angular 代码来触发事件:

通过选择“一些自定义事件”选项,您可以限制或控制特定事件的收集,即仅当在特定页面或域上时,例如在lemonmart.com上。在下面的屏幕截图中,您可以看到一个自定义规则,该规则将过滤掉在lemonmart.com上未发生的任何结账事件,以清除开发或测试数据:

一些自定义事件

  1. 保存您的新事件

  2. 为名为checkoutInitiated的事件重复此过程

  3. 添加两个新的 Google Analytics 事件标签,如图所示:

新的自定义事件标签

  1. 配置事件并将您创建的相关触发器附加到其中,如图所示:

标签管理器工作区

  1. 提交并发布您的工作区

我们现在准备在我们的分析环境中接收自定义事件。

可选地,您可以直接在模板中添加onclick事件处理程序,例如在结账按钮上添加onclick="dataLayer.push({'event': 'checkoutInitiated'})"。这将checkoutInitiated事件推送到由gtm.js提供的dataLayer对象中。

  1. 观察带有结账按钮的 POS 模板:
src/app/pos/pos/pos.component.html... <button mat-icon-button (click)="checkout({amount: 12.25})"> <mat-icon>check_circle</mat-icon> </button>...

圆形结账按钮位于以下图表的左下角:

POS 页面与结账按钮

  1. 在 POS 组件中,声明您打算推送的dataLayer事件的接口:
src/app/pos/pos/pos.component.ts ...interface IEvent { event: 'checkoutCompleted' | 'checkoutInitiated'}declare let dataLayer: IEvent[]...export class PosComponent implements OnInit { ...
  1. 自定义结账事件

  2. 使用setTimeout模拟一个虚假交易,并在超时结束时调用checkoutCompleted事件:

src/app/pos/pos/pos.component.ts export class PosComponent implements OnInit {...checkout(transaction) { dataLayer.push({ event: 'checkoutInitiated', }) setTimeout(() => { dataLayer.push({ event: 'checkoutCompleted', }) }, 500) }}

在实际实现中,只有在服务调用成功时才会调用checkoutCompleted。为了不错过分析收集过程中的任何数据,还要考虑覆盖失败情况,例如添加多个覆盖各种失败情况的checkoutFailed事件。

现在,我们准备看分析结果。

  1. 在 POS 页面上,点击结账按钮

  2. 在 Google Analytics 中,观察实时|事件选项卡,以查看事件发生时的事件。

  3. 5-10 分钟后,这些事件也会显示在行为|事件选项卡下,如下所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (160)Google Analytics 顶级事件

使用自定义事件,您可以跟踪站点上发生的各种微妙的用户行为。通过收集checkoutInitiatedcheckoutCompleted事件,您可以计算有多少启动的结账最终完成的转化率。在销售点系统的情况下,该比率应该非常高;否则,这意味着您可能存在系统性问题。

在每个事件中收集额外的元数据是可能的,例如在启动结账时收集付款金额或类型,或在完成结账时收集transactionId

要使用这些更高级的功能,我建议您查看angulartics2,该工具可以在www.npmjs.com/package/angulartics2找到。angulartics2是一个供应商无关的 Angular 分析库,可以使用流行的供应商(如 Google Tag Manager、Google Analytics、Adobe、Facebook、百度等)实现独特和细粒度的事件跟踪需求,如该工具主页上所示:

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (161)Angulartics2

angulartics2与 Angular 路由器和 UI-Router 集成,可以根据每个路由实现自定义规则和异常。该库使实现自定义事件和启用数据绑定的元数据跟踪变得容易。查看以下示例:

example<div angulartics2On="click" angularticsEvent="DownloadClick" angularticsCategory="{{ song.name }}" [angularticsProperties]="{label: 'Fall Campaign'}"></div>

我们可以跟踪名为DownloadClick的点击事件,该事件将附加一个category和一个label,以便在 Google Analytics 中进行丰富的事件跟踪。

通过高级分析,您可以使用实际使用数据来指导您改进或托管您的应用程序。这个主题总结了从本书开始时创建铅笔草图模型的旅程,涵盖了今天的全栈 Web 开发人员必须熟悉的各种工具、技术和技术。我们深入研究了 Angular、Angular Material、Docker 和自动化,以使您成为最高效的开发人员,交付最高质量的 Web 应用程序,同时在这一过程中处理了许多复杂性。祝你好运!

在本章中,您已经丰富了开发 Web 应用程序的知识。您学会了如何使用 Google Tag Manager 和 Google Analytics 来捕获您的 Angular 应用程序的页面浏览量。使用高级指标,我们讨论了如何计算每个用户基础设施的成本。然后,我们调查了高可用性和扩展性对预算的影响的细微差别。我们涵盖了负载测试复杂用户工作流程,以估算任何给定服务器可以同时托管多少用户。利用这些信息,我们计算了目标服务器利用率,以微调您的扩展设置。

我们所有的预发布计算大多是估计和经过深思熟虑的猜测。我们讨论了您可以使用哪些指标和自定义事件来衡量应用程序的实际使用情况。当您的应用程序上线并开始收集这些指标时,您可以更新您的计算,以更好地了解您基础设施的可行性和负担能力。

在本书的过程中,我已经表明,Web 开发远不止是编写网站。在本书的前半部分,我们涵盖了从流程、设计、方法、架构到开发环境、您使用的库和工具的各种主题,包括基本的 Angular 平台和 Angular Material,最后使用 Zeit Now 在 Web 上部署您的应用程序。

在书的下半部分,我们采用了“路由器优先”方法来设计、架构和实现一个大型的业务应用程序,涵盖了你在现实生活中可能遇到的大多数主要设计模式。在这个过程中,我们涵盖了单元测试、Docker、使用 CircleCI 进行持续集成、使用 Swagger 设计 API、使用 Google Tag Manager 收集分析数据,以及在 AWS 上部署高可用性应用程序。当你掌握了这些各种技能和技术,你将成为一个真正的全栈 web 开发人员,能够利用 Angular 交付小型和大型 web 应用程序。

Angular6-面向企业级的-Web-开发-全- - 绝不原创的飞龙 - 博客园 (2024)
Top Articles
Latest Posts
Article information

Author: Sen. Ignacio Ratke

Last Updated:

Views: 6414

Rating: 4.6 / 5 (56 voted)

Reviews: 87% of readers found this page helpful

Author information

Name: Sen. Ignacio Ratke

Birthday: 1999-05-27

Address: Apt. 171 8116 Bailey Via, Roberthaven, GA 58289

Phone: +2585395768220

Job: Lead Liaison

Hobby: Lockpicking, LARPing, Lego building, Lapidary, Macrame, Book restoration, Bodybuilding

Introduction: My name is Sen. Ignacio Ratke, I am a adventurous, zealous, outstanding, agreeable, precious, excited, gifted person who loves writing and wants to share my knowledge and understanding with you.