Angular6-示例第三版-全-

Angular6 示例第三版(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Angular 6 已经到来,我们非常兴奋!这本书让我们有机会向你伸出援手,帮助你学习 Angular。Angular 已经变得主流,并已成为网络和移动开发的通用平台。

如果你是一名 AngularJS 开发者,那么有许多令人兴奋的内容可以学习,对于刚开始的开发者来说,有一个全新的世界可以探索。即使是经验丰富的 AngularJS 开发者,开始学习 Angular 也可能感到不知所措。会有很多术语被抛向你:例如 TypeScript、Transpiler、Shim、Observable、Immutable、Modules、Exports、Decorators、Components、Web Component 和 Shadow DOM。放松!我们正在努力拥抱现代网络,这里的新事物都是为了使我们的生活更简单。许多这些概念并非特定于 Angular 本身,而是突出了网络平台开发的发展方向。我们将尽力以清晰简洁的方式呈现这些概念,帮助每个人理解这些部分如何融入这个庞大的生态系统。通过示例学习有其优点,例如,你将立即看到概念在实际中的应用。这本书遵循其前辈的相同模式。使用自己动手做DIY)的方法,我们使用 Angular 构建了多个简单和复杂的应用程序。

这本书面向谁

Angular 可以帮助你更快、更高效、更灵活地构建跨平台应用程序。Angular 目前处于第 6 个版本,其之前的版本中进行了重大变更。这是一本独特的网络开发书籍,将帮助你掌握 Angular 并探索开发单页应用程序的强大解决方案。

这本书涵盖了什么内容

第一章,入门,介绍了 Angular 框架。我们在 Angular 中创建了一个超级简单的应用程序,突出了框架的一些核心功能。

第二章,构建我们的第一个应用程序 – 7 分钟健身,教我们如何构建我们的第一个真正的 Angular 应用程序。在这个过程中,我们将了解更多关于 Angular 的主要构建块之一——组件。我们还将介绍 Angular 的模板结构、数据绑定能力和服务。

第三章,更多 Angular – SPA 和路由,介绍了框架中的路由结构,我们在其中为7 分钟健身构建了多个页面。本章还探讨了组件间通信的多种模式。

第四章,私人教练,介绍了一种新的锻炼,我们将7 分钟健身转变为通用的私人教练应用程序。这个应用程序具有创建除原始 7 分钟健身之外的新锻炼计划的能力。本章涵盖了 Angular 的表单功能以及我们如何使用它们来构建自定义锻炼。

第五章,支持服务器数据持久性,处理从服务器保存和检索锻炼数据。当我们探索 Angular 的 http 客户端库及其如何使用 RxJS Observables 时,我们增强了个人教练的持久性功能。

第六章,深入 Angular 指令,深入探讨了 Angular 指令和组件的内部工作原理。我们构建了一系列指令来支持个人教练。

第七章测试个人教练,向您介绍了 Angular 的测试世界。您构建了一系列单元测试和端到端测试,以验证个人教练的功能。

第八章,一些实用场景,提供了一些在开发此框架上的应用可能遇到的场景的实用技巧和指导。我们涵盖了诸如身份验证和授权、性能以及最重要的案例,即从 AngularJS 迁移到 Angular 最新版本的应用。

要充分利用本书

我们将使用 TypeScript 语言构建我们的应用程序;因此,如果您有一个使 TypeScript 开发变得容易的 IDE,那就更好了。Atom、Sublime、WebStorm 和 Visual Studio(或 VS Code)都是这个目的的出色工具。

下载示例代码文件

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

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

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

  2. 选择“支持”标签。

  3. 点击“代码下载与勘误”。

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

下载文件后,请确保您使用最新版本解压缩或提取文件夹,使用以下工具:

  • Windows 版的 WinRAR/7-Zip

  • Mac 版的 Zipeg/iZip/UnRarX

  • Linux 版的 7-Zip/PeaZip

本书代码包也托管在 GitHub 上,网址为github.com/chandermani/angular6byexample。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富的图书和视频目录的代码包可供选择,网址为github.com/PacktPublishing/。查看它们吧!

使用的约定

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

CodeInText: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“我们可以看到路由器如何将app.routes.ts中的路由与workout-builder.routes.ts中的默认路由组合在一起”。

代码块如下设置:

"styles": [
   "node_modules/bootstrap/dist/css/bootstrap.min.css",
   "src/styles.css"
],

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

const routes: Routes = [
    ...
    { path: 'builder', loadChildren: './workout-builder/workout-builder.module#WorkoutBuilderModule'},
    { path: '**', redirectTo: '/start' }
];

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

ng new guessthenumber --inlineTemplate

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中如下所示。以下是一个示例:“在Sources标签页中打开Developer工具”。

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

小技巧和技巧如下所示。

联系我们

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

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

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

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

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

评论

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

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

第一章:入门

在 JavaScript 中开发应用程序始终是一个挑战。由于其可塑性和缺乏类型检查,构建一个相当大的 JavaScript 应用程序是困难的。此外,我们使用 JavaScript 进行各种过程,如用户界面(UI)操作、客户端-服务器交互以及业务处理/验证。结果,我们得到了难以维护和测试的意大利面代码。

类似于 jQuery 这样的库在处理各种浏览器怪癖和提供可以减少代码行数的结构方面做得很好。然而,这些库缺乏任何结构性的指导,这在我们代码库/增长时能帮助我们。

近年来,JavaScript 框架已经出现以管理这种复杂性。许多这些框架,包括 Angular 的早期版本,使用一种称为模型-视图-控制器(MVC)的设计模式来将应用程序的元素分割成更易于管理的部分。这些框架的成功以及它们在开发者社区中的流行确立了使用这种模式的价值。

Web 开发,然而,一直在不断演变,自从 2009 年 Angular 首次推出以来已经发生了很大变化。诸如 Web 组件、JavaScript 的新版本(ES2015)和 TypeScript 等技术都已经出现。综合来看,它们为我们提供了构建一个新、前瞻性框架的机会。而随着这个新框架的出现,也带来了一种新的设计模式——组件模式。

本章致力于理解组件模式,以及如何在构建一个简单的 Angular 应用时将其付诸实践。

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

  • Angular 基础知识:我们将简要介绍用于构建 Angular 应用的组件模式。

  • 构建我们的第一个 Angular 应用:我们将使用 Angular 构建一个小游戏——“猜数字!”

  • 一些 Angular 构造的介绍:我们将回顾 Angular 中使用的一些构造,例如插值、表达式和数据绑定语法。

  • 变更检测:我们将讨论在 Angular 应用中如何管理变更检测。

  • 工具和资源:最后,我们将提供一些在 Angular 开发和调试过程中会很有用的资源和工具。

Angular 基础知识

让我们从了解 Angular 如何实现组件模式开始。

组件模式

Angular 应用使用组件模式。你可能没有听说过这种模式,但它无处不在。它不仅用于软件开发,还用于制造、建筑和其他领域。简单来说,它涉及将较小的、离散的构建块组合成更大的成品。例如,电池是汽车的一个组件。

在软件开发中,组件是逻辑单元,可以组合成更大的应用程序。组件通常具有内部逻辑和属性,这些逻辑和属性被屏蔽或隐藏在更大的应用程序中。然后,更大的应用程序通过称为接口的特定网关消耗这些构建块,这些接口仅公开使用组件所需的内容。这样,只要接口没有改变,就可以修改组件的内部逻辑,而不会影响更大的应用程序。

回到我们的电池例子,汽车通过一系列连接器消耗电池。然而,如果电池耗尽,只要新电池有相同的连接器,就可以完全更换。这意味着汽车制造商不必担心电池的内部结构,这简化了汽车制造的过程。更重要的是,车主不必每次电池耗尽时都更换他们的汽车。

为了扩展这个类比,电池制造商可以将它们推广到各种不同的车辆上,例如 ATV、船只或雪地摩托车。因此,组件模式使它们能够实现更大的规模经济。

在 Web 应用程序中使用组件模式

随着 Web 应用程序变得越来越复杂,能够用更小、更离散的组件构建它们的必要性变得更加迫切。组件允许以防止应用程序变成一团乱麻代码的方式构建应用程序。相反,基于组件的设计允许我们独立于其他部分对应用程序的特定部分进行推理,然后我们可以通过约定的连接点将应用程序缝合成一个完整的成品。

此外,维护成本较低,因为每个组件的内部逻辑可以单独管理,而不会影响应用程序的其他部分。并且使用自描述组件组装应用程序使得在更高层次上理解应用程序变得更加容易。

为什么以前在 Angular 中没有使用组件?

如果这个想法如此合理,为什么组件模式没有被早期版本的 Angular 采用?答案是,Angular 最初发布时存在的技术并没有完全支持在 Web 应用程序中实现这种模式。

然而,Angular 的早期版本在实现更智能的 Web 应用程序设计和组织方向上迈出了实质性的步伐。例如,它们实现了 MVC 模式,将应用程序分为模型、视图和控制(你将在我们将在 Angular 中构建的组件中看到 MVC 模式的持续使用)。

使用 MVC 模式,模型是数据,视图是一个网页(或移动应用屏幕,甚至 Flash 页面),控制器从模型中填充视图的数据。通过这种方式,实现了关注点的分离。遵循这个模式并智能地使用指令,可以使你非常接近组件。

因此,Angular 的早期版本允许以更逻辑的方式设计和构建应用程序。然而,这种方法受到所使用的技术并非真正隔离的限制。相反,它们最终都被渲染,没有任何真正的分离,与其他屏幕上的元素混合在一起。

什么新特性使得 Angular 能够使用组件模式?

相比之下,Angular 的最新版本拥抱了最近兴起的科技,这使得更全面地实现组件模式成为可能。这些技术包括 Web Components、ES2015(JavaScript 的新版本)和 TypeScript。让我们讨论一下这些技术各自为这个可能带来的贡献。

Web Components

Web Components 是一个总称,实际上涵盖了四个针对网络浏览器的正在兴起的标准化:

  • 自定义元素

  • 阴影 DOM

  • 模板

  • HTML 导入

更多关于 Web Components 的信息可以在www.webcomponents.org/introduction找到

现在我们将详细讨论这些内容:

  • 自定义元素允许创建除标准 HTML 标签(如<div><p>)之外的新类型的 DOM 元素。你将在本书的各个部分看到这些自定义元素的使用。例如,我们在本章构建的应用程序将有一个名为<app-root>的根元素,但你也可以给它任何你喜欢的名字。单个组件也将使用自定义元素。例如,在接下来的章节中,我们将构建一个更复杂的应用程序,将屏幕分解成组件。页面的头部将使用自定义元素<abe-header>来显示其内容(前缀abe仅属于我们的应用程序,有助于避免与原生 HTML 元素或其他应用程序中的自定义元素发生命名冲突)。添加自定义标签的能力提供了一个屏幕上的位置,可以用于绑定组件。简而言之,这是将组件从页面其余部分分离出来并使其真正自包含的第一步。

  • 阴影 DOM在页面上提供了一个隐藏区域,用于脚本、CSS 和 HTML。这个隐藏区域内的标记和样式不会影响页面的其余部分,同样重要的是,它们也不会受到页面其他部分标记和样式的影响。我们的组件可以使用这个隐藏区域来渲染其显示。因此,这是使我们的组件自包含的第二步。

  • 模板是 HTML 片段,最初不会在网页中渲染,但可以在运行时通过 JavaScript 激活。许多 JavaScript 框架已经支持某种形式的模板。Web Components 标准化了这种模板,并在浏览器中直接提供支持。模板可以用来使我们的组件使用的 Shadow DOM 中的 HTML 和 CSS 动态化。因此,这是制作我们组件的第三步。

  • 构成 Web Components 的最后一个标准是HTML 导入。它们提供了一种在单个包中加载资源(如 HTML、CSS 和 JavaScript)的方法。Angular 不使用 HTML 导入。相反,它依赖于 JavaScript 模块加载,我们将在本章稍后讨论这一点。

Angular 和 Web Components

Web Components 在当前的网络浏览器中并未得到完全支持。因此,Angular 组件并不是严格意义上的 Web Components。可能更准确的说法是,Angular 组件实现了 Web Components 背后的设计原则。它们还使得构建能在当今浏览器中运行的组件成为可能。

在撰写本文时,Angular 支持 Chrome、Firefox、Safari 和 Edge 等 evergreen 浏览器,以及 IE 9 及以上版本。它还支持 Android 和 iOS。要查看 Angular 支持的浏览器列表,请访问angular.io/guide/browser-support.

因此,在本书的剩余部分,我们将专注于构建 Angular 组件而不是 Web Components。尽管有这种区别,Angular 组件与 Web Components 非常接近,甚至可以与它们交互。随着浏览器开始更全面地支持 Web Components,Angular 组件与 Web Components 之间的差异将开始消失。所以,如果你想要开始采用未来的 Web Component 标准,Angular 为你提供了这样的机会。

Angular 中的语言支持

你可以使用 ES5(所有当前浏览器支持的 JavaScript 版本)开发组件,但 Angular 通过添加对最新语言(如 ES2015 和 TypeScript)中找到的关键特性的支持,增强了开发组件的能力。

ES2015

ES2015 是 JavaScript 的新版本;它在 2015 年 6 月获得批准。它为语言添加了许多改进,我们将在本书的整个过程中看到这些改进,但在此阶段最吸引我们注意的两个是以下内容:

  • 模块加载

在 JavaScript 中之前并不存在。现在它们存在后,使用它们的关键优势是它们提供了一个简单、清晰的语法,我们可以用它来创建方便的容器来存放组件中的代码。正如你将在本书中的应用程序开发中找到的那样。类还为我们的组件提供了一个方便的简写名称,这使得通过诸如依赖注入之类的手段将它们拼接在一起变得更加容易。

为了明确,JavaScript 类并没有引入完全新的东西。Mozilla 开发者网络MDN)将它们描述为在 JavaScript 现有的基于原型的继承之上的主要语法糖。更多信息请访问developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes

我们将在本书的示例中探索类。如果你没有使用过面向对象的语言,你可能对类不太熟悉,因此我们将随着本章的示例逐步介绍它们。

ES2015 还引入了一种新的模块加载方法。模块提供了一种封装 JavaScript 文件的方式。当它们被封装时,它们不会污染全局命名空间,并且可以以受控的方式与其他模块交互。

一旦我们定义了我们的模块,我们需要一种方法将它们加载到我们的应用程序中以执行。模块加载允许我们从构成 Angular 和我们创建或使用的其他组件的模块中选择我们需要的部分。

目前,存在一系列方法和库来支持 JavaScript 中的模块加载。ES2015 为加载模块添加了一种新的、一致的语法,作为语言的一部分。这种语法简单明了,涉及在模块前加上 export 关键字(或使用默认导出),然后在应用程序的其他地方使用 import 来消费它们。

ES 2015 模块加载使我们能够将组件组合成有用的包或功能,这些包或功能可以在我们的应用程序中导入或导出。实际上,模块是 Angular 本身的核心。我们将看到模块在 Angular 本身以及本书中构建的应用程序中都被广泛使用。

重要的是要理解,虽然 Angular 使用与 ES2015 模块加载语法相似的语法,但 Angular 模块(我们将在本章稍后讨论)并不等同于 JavaScript 模块。有关这些差异的更多详细信息,请参阅 Angular 文档angular.io/guide/architecture#ngmodules-vs-javascript-modules

由于 ES2015 并非今天所有浏览器都完全支持,因此我们需要将 ES2015 转换为 ES5,以便在我们的应用程序中使用类和模块加载等功能。我们通过一个称为转译的过程来完成这项工作。

转译类似于编译,但与编译将我们的代码转换为机器语言不同,转译将一种源代码转换为另一种源代码。在这种情况下,它将 ES2015 转换为 ES5。有几个称为转译器的工具使我们能够做到这一点。常见的转译器包括 Traceur 和 Babel。TypeScript(我们将在下一节讨论)也是一个转译器,我们将使用它来本书中的示例。

一旦 ES2015 被转换为 ES5,我们就可以使用像SystemJS这样的模块加载器来加载我们的模块。SystemJS遵循 ES2015 的模块加载语法,并使我们能够在今天的浏览器中执行模块加载。或者,我们可以使用像webpack这样的模块打包器来加载和组合我们的模块。对于本书中的项目,我们将使用webpack来加载、打包和部署我们的应用程序中的模块。

自从 ES2015 发布以来,ECMAScript(JavaScript 的官方名称)新版本的发布周期已经变为每年一次——因此我们现在有了 ES2016 和 ES2017,很快我们还将有 ES2018。由于本书中强调的功能最初是在 ES2015 中引入的,因此我们将引用 ES2015 而不是任何更新的版本。然而,更新的版本与本书中强调的语言功能完全兼容。

TypeScript

TypeScript 是由微软创建的,它是 JavaScript 的超集,这意味着它包含了 ES2015 的功能(如类和模块加载)并添加了以下内容:

  • 类型

  • 装饰器

类型允许我们在类中标记变量、属性和参数,以指示它们是数字、字符串、布尔值或各种结构,如数组和对象。这使得我们能够在设计时执行类型检查,以确保在我们的应用程序中使用正确的类型。

装饰器是我们可以使用@符号和函数添加到我们的类中的简单注释。它们为我们的类的使用提供了指令(称为元数据)。在 Angular 的情况下,装饰器允许我们将我们的类标识为 Angular 组件。装饰器还使我们能够指定一个自定义元素来绑定我们的组件,并识别一个模板,该模板为我们的组件添加 HTML 视图。随着我们通过本书的学习,我们将详细介绍装饰器的使用。

装饰器不是 ES2015 的一部分,但它是将它们包含在 JavaScript 语言未来版本中的一个提案的一部分。它们作为微软和谷歌合作的一部分被添加到 TypeScript 中。如前所述,TypeScript 编译成 ES5,因此我们能够在不完全支持 ES2015 或装饰器提议标准的浏览器中使用类型和装饰器。

如前所述,使用 ES2015 或 TypeScript 与 Angular 一起使用并非必需。然而,我们认为,随着我们通过本书中的示例进行,你会看到使用它们的优点。

将所有内容整合在一起

通过遵循 Web 组件标准并添加对 ES2015 和 TypeScript 的支持,Angular 使我们能够创建实现组件设计模式的 Web 应用程序。这些组件有助于实现通过自描述和自包含的构建块构建大型应用程序的愿景。

我们希望你在本书的示例中看到,Angular 使组件能够以简单和声明性的方式构建,这使得开发者更容易实现它们。随着我们通过本书中的示例进行,我们将突出显示这些技术被使用的位置。

Angular 模块

组件是 Angular 应用程序的基本构建块。但我们是如何将这些构建块组织成完整的应用程序的呢?Angular 模块为这个问题提供了答案。它们使我们能够将我们的组件组合成可重用的功能组,这些组可以在整个应用程序中导出和导入。例如,在一个更复杂的应用程序中,我们可能希望有用于认证、通用工具和外部服务调用的模块。同时,模块使我们能够以允许按需加载的方式对应用程序中的功能进行分组。这被称为懒加载,我们将在第四章“构建个人教练”中介绍这个主题。

每个 Angular 应用程序将有一个或多个包含其组件的模块。Angular 引入了NgModule作为方便指定构成模块的组件的方式。每个 Angular 应用程序都必须至少有一个这样的模块——根模块。

Angular 本身是作为模块构建的,我们将它们导入到我们的应用程序中。所以,当你构建 Angular 应用程序时,你将看到模块的广泛应用。

构建 Angular 应用程序的基本步骤

总结一下:在基本层面上,你会发现,要在 Angular 中开发应用程序,你需要做以下事情:

  1. 创建组件

  2. 将它们打包成模块

  3. 引导你的应用程序

通过看到 Angular 和组件设计模式在实际中的应用来理解 Angular 和组件设计模式是最佳方式。因此,我们将使用 Angular 构建我们的第一个 Hello World 应用程序。这个应用程序将帮助你熟悉 Angular 框架,并看到组件设计模式在实际中的应用。

让我们开始做吧。

传统的 Hello Angular 应用程序 - 猜数字!

作为我们的第一个练习,我们希望保持简单,但仍然展示框架的能力。因此,我们将构建一个非常简单的游戏,名为猜数字!。游戏的目标是用尽可能少的尝试次数猜出一个随机生成的计算机数字。

这就是这个游戏的外观:

图片

让我们现在构建“猜数字”!

构建“猜数字”!

在构建用户界面时,通常的做法是从上到下构建。首先设计 UI,然后根据需要插入数据和行为。采用这种方法,应用程序的 UI、数据和行为方面都紧密耦合,这并不是一个理想的情况!

基于组件的设计,我们的工作方式有所不同。我们首先查看 UI 和预期的行为,然后将其封装到一个我们称之为组件的构建块中。然后,这个组件被托管在我们的页面上。在组件内部,我们将 UI 分为视图,将行为分为类,并包含支持行为的适当属性和方法。如果您不熟悉类,请不要担心。随着我们通过示例的进展,我们将详细讨论它们是什么。

好的,那么让我们确定我们应用程序需要的 UI 和行为。

设计我们的第一个组件

为了确定我们的组件需要包含什么,我们将首先详细说明我们希望应用程序支持的功能:

  • 生成随机数字(original

  • 为用户提供猜测值的输入(guess

  • 跟踪已经做出的猜测数量(noOfTries

  • 根据用户的输入(偏差)提供提示以改善他们的猜测

  • 如果用户猜对了数字,显示成功消息(偏差

现在我们有了我们的功能,我们可以确定我们需要向用户显示什么,以及我们需要跟踪哪些数据。对于前面的功能集,括号中的元素表示将支持这些功能的属性,并将需要包含在我们的组件中。

设计组件是一个非常关键的过程。如果做得正确,我们可以逻辑地组织我们的应用程序,使其易于理解和维护。

在构建任何应用程序时,我们强烈建议您首先考虑您想要提供的功能,然后是支持该功能的数据和行为。最后,考虑如何为它构建用户界面。这是一个无论您使用什么库或框架来构建应用程序都是良好的实践。

开发我们的第一个组件

现在我们已经为我们的第一个组件设计了方案,我们将开始使用Angular 命令行界面Angular CLI)来开发它。Angular CLI 使我们能够通过一系列控制台命令开始构建 Angular 应用程序并将它们部署出去。我们将在未来的章节中更详细地介绍Angular CLI。目前,我们将安装它并使用它来生成一个基本的应用程序,作为我们第一个组件的起点。

要使用 Angular CLI,您必须首先安装 Node.jsnpmNode 的包管理器)。Node 可跨平台使用,您可以从 nodejs.org 下载它。安装 Node 也会安装 npm。对于本书,我们使用 Node.js 版本 8.9.4 和 npm 版本 5.6.0。您可以在 docs.npmjs.com/getting-started/installing-node 找到有关安装 Node 和将 npm 更新到最新版本的更多信息。

一旦安装了 Nodenpm,打开命令提示符并输入以下内容:

npm install -g @angular/cli

这将安装我们将用于启动应用程序的 Angular CLI。现在,从您的本地机器上的一个目录中,输入以下命令:

ng new guessthenumber --inlineTemplate
cd guessthenumber
ng serve

第一个命令将使用 Angular CLI 在您的本地机器上创建一个新的 Angular 项目(--inlineTemplate 标志在组件内部创建模板,这对于我们本章要展示的内容非常合适)。第二个命令将您导航到 Angular CLI 为您的新项目创建的目录。第三个命令启动应用程序,您可以在 http://localhost:4200/ 上查看。如果您这样做,您应该在浏览器中看到一个标准的默认 Angular 页面。

安装 Bootstrap

在我们构建应用程序的具体细节之前,还有一步。让我们添加 Bootstrap 库来增强应用程序的外观和感觉。首先,通过在启动应用程序的终端中输入 Ctrl + C 来停止应用程序,并在被询问是否要终止批处理作业时输入 Y。然后从 guessthenumber 目录,输入以下命令:

npm install bootstrap --save

这将安装 Bootstrap 的最新版本(在撰写本文时为版本 4.0.0)。您可能会看到一些关于未满足依赖项的警告消息。您可以忽略它们。

接下来配置您的项目以包含 Bootstrap 样式表:

  1. guessthenumber 目录中找到并打开文件 angular.json

  2. 在该文件中找到 projects 属性,它包含我们新项目的设置

  3. 然后找到 architect.build.options 中的 styles 属性,您会看到它包含一个数组,该数组包含 styles.css,这是新项目的默认样式表。

  4. bootstrap.min.css 样式表的路径添加到该数组中:

"styles": [
   "node_modules/bootstrap/dist/css/bootstrap.min.css",
   "src/styles.css"
],

使用 Angular CLI 包含 Bootstrap 的说明可以在 github.com/angular/angular-cli/wiki/stories-include-bootstrap 找到。

我们目前有什么?

如果你查看guessthenumber目录,你会看到Angular CLI已经创建,你会看到大量的文件。一开始这可能会让你感到不知所措,但重要的是要理解Angular CLI只通过几个命令行语句就为我们生成了所有这些文件。这样,它使得开始使用 Angular 应用程序变得更加顺畅和容易。它从过程中移除了繁琐的工作,使我们能够以最小的努力构建和提供我们的应用程序。在本章中,我们将专注于我们为了创建应用程序需要接触的几个文件。

如果你正在使用 Internet Explorer 运行应用程序,你需要查看一个文件——polyfill.ts。这个文件添加了运行应用程序在 Internet Explorer 中所需的其他文件。你需要取消注释该文件中的几个部分以添加这些必要的文件。有关如何操作的说明包含在该文件本身中。

在转向构建我们应用程序的具体细节之前,让我们先看看一个关键的文件,这个文件将用于使我们的应用程序启动和运行。

下载示例代码

本书中的代码可在 GitHub 上找到,网址为github.com/chandermani/angular6byexample。它组织成检查点,允许你跟随我们的步骤逐步构建本书中的示例项目。本章要下载的分支是 GitHub 的分支:checkpoint1.1。在guessthenumber文件夹中查找我们在这里覆盖的代码。如果你不使用 Git,请从以下 GitHub 位置下载 Checkpoint 1.1 的快照(ZIP 文件):github.com/chandermani/angular6byexample/tree/checkpoint1.1。在设置快照时,请参考guessthenumber文件夹中的readme.md文件。

主文件 - index.html

导航到guessthenumber目录中的src文件夹并打开index.html。你会看到以下内容:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Guessthenumber</title>
  <base href="/">

  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
  <app-root></app-root>
</body>
</html>

index.html是我们应用程序的主文件。当应用程序首次运行时,浏览器将启动它,并托管我们的应用程序组件。如果你有任何接触过 Web 开发,这个文件中的大部分 HTML 代码应该看起来很熟悉。它有标准的htmlheadbody标签,以及几个可选标签,其中一个是一个 viewport 的 meta 标签,它配置了应用程序在移动设备上的显示方式,另一个是链接到 Angular favicon 图像的链接,该图像将在加载应用程序的浏览器标签中显示。

自定义元素

然而,页面上还有一个可能对你来说不那么熟悉的标签:

<app-root></app-root> 

这个标签是一个自定义元素。它指示 Angular 将我们构建的组件注入到何处。

猜数字游戏! 以及本书中的所有其他应用程序都已针对 Angular 6 最终版本进行了测试。

组件文件

现在,让我们转向构建我们应用程序的具体细节。鉴于之前对组件模式的讨论,你不会对这样做需要构建一个组件感到惊讶。在这种情况下,我们的应用程序足够简单,我们只需要一个组件(在本书的后面部分,你将看到在构建更复杂的应用程序时使用多个组件)。Angular CLI 已经为我们生成了一个组件文件。当然,该文件不包含我们应用程序的任何特定细节,因此我们需要对其进行修改。为此,导航到 app 目录下的 src 文件夹并打开 app.component.ts

导入语句

在页面顶部,你会找到以下行:

import { Component } from '@angular/core';

这是一个导入语句。它告诉我们将在组件中加载和使用哪些模块。在这种情况下,我们正在选择从 Angular 加载的一个模块:Component。Angular 有许多其他模块,但我们只加载所需的模块。

你会注意到,我们导入的位置并没有被标识为应用程序中的路径或目录。相反,它被标识为 @angular/core。Angular 已经被划分为以 @angular 为前缀的桶模块。

这些桶组合了逻辑上相关的几个模块。在这种情况下,我们表示我们想要导入 core 桶模块,它反过来又引入了 Component 模块。

Angular 文档将桶描述为一种将多个 ES2015 模块的导出汇总到一个方便的 ES2015 模块中的方法。桶本身是一个 ES2015 模块文件,它重新导出其他 ES2015 模块的选定导出。

更多关于桶(barrel)的信息,请参阅 angular.io/guide/glossary#barrel

装饰器

接下来,将开始于 @Component 的代码块替换为以下内容:

@Component({
 selector: 'app-root',
 template: `
  <div class="container">
      <h2>Guess the Number !</h2>
        <div class="card bg-light mb-3">
           <div class="card-body">
              <p class="card-text">Guess the computer generated random number between 1 
                                                                          and 1000.</p>
           </div>
        </div>
       <div>
         <label>Your Guess: </label>
         <input type="number" [value]="guess" (input)="guess = $event.target.value" />
         <button (click)="verifyGuess()" class="btn btn-primary btn-sm">Verify</button>
         <button (click)="initializeGame()" class="btn btn-warning btn-
                                                               sm">Restart</button>
       </div>
      <div>
         <p *ngIf="deviation<0" class="alert alert-warning">Your guess is higher.</p>
         <p *ngIf="deviation>0" class="alert alert-warning">Your guess is lower.</p>
         <p *ngIf="deviation===0" class="alert alert-success">Yes! That's it.</p>
      </div>
      <p class="text-info">No of guesses :
        <span class="badge">{{noOfTries}}</span>
      </p>
  </div> 
  `
})

这是我们的组件装饰器,它直接放置在类定义之上,我们将在稍后讨论。@ 符号用于标识装饰器。@Component 装饰器有一个名为 selector 的属性,你可能会惊讶地看到它被设置为我们的 HTML 页面中的 <app-root> 标签。这个设置告诉 Angular 将此组件注入到 HTML 页面中的该标签。

装饰器还有一个名为 template 的属性,这个属性用于标识我们组件的 HTML 标记。注意这里使用了反引号(由 ES2015 引入)来在多行中渲染模板字符串。或者,我们也可以设置一个 templateUrl 属性,它将指向一个单独的文件。

定义类

现在,将开始于 export class AppComponent 的代码块替换为以下内容:

export class AppComponent {
  deviation: number;
  noOfTries: number;
  original: number;
  guess: number;

  constructor() {
      this.initializeGame();
  }
  initializeGame() {
      this.noOfTries = 0;
      this.original = Math.floor((Math.random() * 1000) + 1);
      this.guess = null;
      this.deviation = null;
  }
  verifyGuess() {
      this.deviation = this.original - this.guess;
      this.noOfTries = this.noOfTries + 1;
  }
}

如果你一直在使用 ES5 开发,这是所有当前浏览器都支持的 JavaScript 版本,你可能不熟悉这里类的使用。因此,我们将花几分钟时间来解释一下类由什么组成(对于那些使用面向对象编程语言(如 C# 或 Java)进行开发的你们来说,这应该是一个熟悉的地方)。

类文件包含我们将用于运行组件的代码。在顶部,我们给类一个名字,它是 AppComponent。然后,在括号内,我们有四行声明了我们类的属性。这些类似于 ES5 变量,我们将使用它们来保存我们需要运行应用程序的值(你会注意到这些是我们设计组件时确定的四个值)。

这些属性与标准 JavaScript 变量不同的地方在于,每个属性名称后面跟着 : 和一个数字。这些设置了属性的类型。在这种情况下,我们表明这四个属性将被设置为数字类型,这意味着我们期望所有这些属性的值都是数字。为我们的属性指定类型的能力是由 TypeScript 提供的,而在标准 JavaScript 中不可用。

随着我们向下移动,我们将看到三个带有名称的脚本块,后面跟着括号,然后是包含多行脚本的括号。这些是我们类的函数,它们包含我们的组件将支持的运算。它们与标准的 JavaScript 函数非常相似。

这些方法中的第一个是 constructor(),这是一个特殊的方法,当我们的组件实例首次创建时将会运行。在我们的例子中,当类被创建时,构造函数只做了一件事;它调用我们类中的另一个方法,称为 initializeGame()

initializeGame() 方法使用赋值运算符 = 设置类中四个属性的起始值。我们将这些值设置为 nullzero,除了 original,我们使用随机数生成器来创建要猜测的数字。

该类还包含一个名为 verifyGuess() 的方法,该方法更新 deviationnoOfTries 属性。这个方法不是在组件类内部被调用的;相反,它将从视图中被调用,正如我们稍后更仔细地检查视图时将会看到的。你也会注意到,我们的方法通过在它们前面添加 this 来引用同一类中的属性。

模块文件

如我们之前提到的,每个 Angular 组件都必须包含在一个 Angular 模块中。这意味着我们至少必须在应用程序的根目录中添加至少一个 Angular 模块文件。我们称这个为根模块。对于像“猜数字!”这样的简单应用程序,根模块可能就是我们所需要的唯一模块。然而,随着 Angular 应用程序规模的增加,通常有多个按功能划分的 Angular 模块文件是有意义的。随着我们在本书后面的章节中构建更复杂的应用程序,我们将讨论这种情况。

让我们继续查看我们的 Angular 模块文件。同样,Angular CLI 已经为我们创建了此文件。在src文件夹中的app目录下打开app.module.ts,你会看到以下内容:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

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

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

前两个语句导入了BrowserModuleNgModule。注意,虽然NgModule是从@angular/core导入的,但BrowserModule是从一个不同的模块导入的:@angular/platform-browser。这里重要的是,导入不是来自@angular/core,而是来自一个专门针对基于浏览器的应用程序的独立模块。这是一个提醒,Angular 可以支持除浏览器以外的设备,因此需要将BrowserModule放入一个单独的模块中。

此文件中的另一个导入是我们自己的组件AppComponent。如果你回到那个组件,你会注意到在类定义前添加了export,这意味着我们正在使用我们自己的应用程序中的模块加载。

接下来,我们定义一个新的组件AppModule。在类本身中除了几个导入和一个装饰器@ngModule之外,没有其他内容。我们可以使用这个装饰器来配置我们的应用程序模块。第一个属性是声明,通过这个属性我们提供了一个数组,其中包含了将在我们的应用程序中使用的组件。在这种情况下,我们只有一个组件:AppComponent

接下来,我们添加了导入,其中包含BrowserModule。正如其名所示,这个模块将提供在浏览器中运行我们的应用程序所需的功能。下一个属性是providers。这个属性用于注册提供者(如服务和其他对象),这些提供者将通过依赖注入在整个应用程序中可用。在我们构建的简单应用程序中,我们没有必要使用提供者,因此这个属性是空的。我们将在第三章“更多 Angular – SPA,路由”中详细讨论提供者和依赖注入。

最后,我们设置了bootstrap属性。这表示当我们的应用程序启动时将首先加载的第一个组件。再次强调,这是AppComponent

在此配置就绪后,我们现在可以引导我们的组件了。

引导启动

AppComponent的类定义作为组件的蓝图,但其中的脚本在创建组件实例之前不会运行。为了运行我们的应用,我们需要在应用中添加一些代码来启动组件。完成这个过程需要我们添加启动组件的代码。

src文件夹中,寻找一个名为main.ts的文件。打开它,您将看到以下代码:

import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { AppModule } from './app/app.module';
import { environment } from './environments/environment';

if (environment.production) {
  enableProdMode();
}

platformBrowserDynamic().bootstrapModule(AppModule)
  .catch(err => console.log(err));

如您所见,我们正在从@angular/core导入enableProdMode,以及从@angular/platform-browser-dynamic导入platformBrowserDynamic模块。就像在appModule文件中导入BrowseModule一样,这个后者的导入专门用于基于浏览器的应用。接下来,我们添加了对我们的AppModule和一个位于应用environments目录中的名为environment的文件的导入。

在接下来的代码行中,我们检查environment文件中的常量environment是否将其production属性设置为true,如果是,则调用enableProdMode(),正如其名称所暗示的,这将启用生产模式。environment.production的默认设置是false,这对于我们的目的来说很合适,因为我们不是在生产模式下运行应用。

如果您打开environments.ts,您将看到一些注释,这些注释提供了在构建过程中覆盖此文件设置的指导。我们不会在第二章“构建我们的第一个应用 – 7 分钟健身”中涵盖 Angular 的构建过程;因此,我们在这里不会涉及该材料。

最后,我们使用platformBrowserDynamic().boostrapModule方法并传入我们的AppModule作为参数。然后,bootstrapModule方法创建了一个新的AppModule组件实例,它反过来初始化我们的AppComponent,这是我们标记为启动组件的。它是通过调用我们组件的构造函数方法来做到这一点的。

我们已经启动并运行了!

好吧,应用已经完成,准备进行测试!再次从guessthenumber目录中输入以下命令:

    ng serve

应用应该会出现在您的浏览器上。

如果你在运行应用时遇到困难,可以在 GitHub 上查看可用的工作版本,链接为github.com/chandermani/angular6byexample。如果你不使用 Git,可以从以下 GitHub 位置下载 Checkpoint 1.1 的快照(ZIP 文件):github.com/chandermani/angular6byexample/tree/checkpoint1.1。在首次设置快照时,请参考guessthenumber文件夹中的readme.md文件。

如果我们浏览一下组件文件和模板,我们应该会对我们所取得的成就感到非常震撼。我们在应用运行时并没有编写任何代码来更新 UI。尽管如此,一切运行得都非常完美。

深入挖掘

要理解这个应用程序在 Angular 环境中的工作方式,我们需要更深入地了解我们的组件。虽然组件中的类定义非常简单直接,但我们需要更仔细地查看模板中的 HTML,以了解 Angular 在这里是如何工作的。它看起来像标准的 HTML,但有一些新的符号,如 [ ]( ){{}}

在 Angular 的世界里,这些符号的含义如下:

  • {{}} 是插值符号

  • [ ] 表示属性绑定

  • ( ) 表示事件绑定

显然,这些符号有一些附加的行为,并且似乎将视图 HTML 和组件代码连接起来。让我们尝试理解这些符号实际上做什么。

插值

请查看 app.component.ts 模板中的此 HTML 片段:

<p class="text-info">No of guesses :  
  <span class="badge">{{noOfTries}}</span> 
</p> 

术语 noOfTries 被夹在两个插值符号之间。插值通过将插值标记的内容替换为插值符号内表达式的值(noOfTries)来实现。在这种情况下,noOfTries 是一个组件属性的名称。因此,组件属性的值将显示为插值标签内的内容。

插值使用以下语法声明:{{expression}}。这个表达式看起来类似于 JavaScript 表达式,但总是在组件的上下文中进行评估。请注意,我们没有做任何事情来传递属性的值到视图中。相反,插值标签直接从组件中读取属性的值,而不需要任何额外的代码。

跟踪尝试次数的变化

插值的一个有趣方面是,对组件属性的更改会自动与视图同步。运行应用程序并做出一些猜测;每次猜测后,noOfTries 的值都会改变,视图内容也会相应改变:

在我们需要查看模型状态的场景中,插值是一个出色的调试工具。有了插值,我们不必在代码中设置断点,只需知道组件属性的值。由于插值可以接受表达式,我们可以传递组件的方法调用或属性并查看其值。

表达式

在继续之前,我们需要花几分钟时间来理解 Angular 中的模板表达式是什么。

Angular 中的模板表达式不过是用于在它们使用的模板上下文中评估的纯 JavaScript 代码片段。但正如angular.io/docs/ts/latest/guide/template-syntax.html#template-expressions文档所明确指出的,有一些差异:

  • 赋值是被禁止的

  • new 运算符是被禁止的

  • 按位运算符 |& 不受支持

  • 增量和减量运算符 ++-- 不受支持

  • 模板表达式运算符,如 |?.,添加了新的含义

在基于组件的设计讨论中,您可能不会对文档也明确了一些事情感到惊讶;模板表达式不能:

  • 引用全局命名空间中的任何内容

  • 引用窗口或文档

  • 调用 console.log

相反,这些表达式被限制在表达式上下文中,这通常是支持特定模板的组件实例。

然而,这些限制并不会阻止我们用表达式做一些巧妙的事情。正如我们可以在以下示例中看到,这些都是有效的表达式:

// outputs the value of a component property 
{{property}} 

// adds two values 
{{ 7 + 9 }} 

//outputs the result of boolean comparison. Ternary operator 
{{property1 >=0?'positive': 'negative'}} 

//call a component's testMethod and outputs the return value 
{{testMethod()}} 

在了解了表达式之后,我们强烈建议您保持表达式简单,这样可以使 HTML 更易于阅读。*ngIf="formHasErrors()" 表达式总是比 *ng-if="name==null || email==null || emailformatInValid(email) || age < 18" 更好。因此,当表达式开始变得复杂时,请将其移动到组件中的方法中。

安全导航操作符

在我们继续之前,还有一个其他表达式我们应该讨论一下:Angular 安全导航操作符 (?.)。这个操作符提供了一个方便的方式来检查长属性路径中的空值,如下所示:

{{customer?.firstName }} 

如果安全导航操作符发现空值(这里指客户),它会停止处理路径,但允许应用程序继续运行。如果没有它,应用程序在到达第一个空值(这里指客户名称)之后将会崩溃,视图将不会显示。安全导航操作符在您异步加载数据且数据可能无法立即提供给视图的情况下特别有用。安全导航操作符将防止应用程序崩溃,并在数据可用时加载数据。

数据绑定

学习插值和表达式很容易。现在让我们看看另一个框架结构,即我们示例应用中使用的数据绑定。我们将在接下来的章节中更详细地介绍数据绑定。在此阶段,我们只是简要地讨论我们正在构建的示例应用中使用的绑定。

属性绑定

如果我们查看 app.component.ts 中的模板,我们会看到几个使用方括号 [ ] 的地方。这些都是属性绑定

让我们看看我们创建的第一个绑定:

<input type="number" [value]="guess" (input)="guess = $event.target.value" />

这种绑定通过将组件类中 guess 属性的值与视图中的输入字段的 value 相链接来实现。绑定是动态的;因此,当 guess 属性的值发生变化时,输入字段的 value 也会同步到相同的值;我们不需要编写任何代码来做这件事。

在一开始,当我们初始化游戏时,这个属性在组件类的初始化方法中被设置为 null,所以我们不会在输入字段中看到任何内容。然而,随着游戏的进行,这个数字将随着猜测值的变化而更新。

事件绑定

再次查看 app.component.ts 中的模板,我们发现有几个地方出现了括号 ( )。这些是 事件绑定

让我们看看我们为这些事件绑定中的第一个创建的 HTML 代码行。它应该很熟悉,因为事件绑定是在我们最初查看属性绑定时相同的标签上:input 标签:

<input type="number" [value]="guess" (input)="guess = $event.target.value" />

在这种情况下,输入元素的 input 事件绑定到一个表达式上。该表达式将我们的组件类中的 guess 属性设置为 $event.target.value,这是用户输入的值。在幕后,当我们使用这种语法时,Angular 为我们绑定的事件设置了一个事件处理器。在这种情况下,每当用户在 input 字段中输入一个数字时,处理器就会更新我们的组件类中的 guess 属性。

在我们的代码中还有其他几个地方出现了 ( ) 括号:

<button (click)="verifyGuess()" class="btn btn-primary btn-sm">Verify</button>
<button (click)="initializeGame()" class="btn btn-warning    btn-sm">Restart</button>

这两个事件绑定将屏幕上按钮的 click 事件绑定到我们的组件中的方法。因此在这种情况下,在幕后,Angular 设置了直接绑定到我们的组件中方法的处理器。当点击验证按钮时,会调用 verifyGuess 方法,而当点击重启按钮时,会调用 initializeGame 方法。

在你通读这本书的示例时,你会看到很多将属性绑定中的 [] 标签与事件绑定中的 () 标签结合的地方。事实上,这种配对如此常见,以至于我们稍后将会看到,Angular 已经想出了一个简写语法来将这些标签合并为一个。

结构指令

接下来,我们将检查一些看起来类似于数据绑定但结合了之前我们没有见过的 Angular 功能的东西:结构指令

<div>
  <p *ngIf="deviation<0" class="alert alert-warning"> Your guess is higher.</p> 
  <p *ngIf="deviation>0" class="alert alert-warning"> Your guess is lower.</p>
  <p *ngIf="deviation===0" class="alert alert-success"> Yes! That's it.</p> 
</div> 

<p> 标签内的 *ngIfNgIf 结构指令。结构指令允许我们操作 DOM 元素的结构。NgIf 指令根据分配给它的表达式的结果来删除或添加 DOM 元素。

前面的 ngIf 中的星号 * 是 Angular 在底层扩展为 ng-template 的简化语法,而 ng-template 是 Angular 对我们之前讨论过的 Web Components 模板的实现。我们将在下一章中学习更多关于这种语法和结构指令的内容。

在这种情况下,我们使用 NgIf 与一个简单的表达式,类似于我们看到的插值表达式。该表达式根据猜测的值及其与正确数字的关系(更高、更低或相等)解析为 truefalse。然后它将该结果分配给 NgIf,这将根据结果为 true 时添加 DOM 元素,为 false 时删除它。

重新审视我们的应用

现在我们已经更仔细地研究了构成我们视图的内容,让我们再次看看我们的应用程序在运行时的样子。当我们运行应用程序时,Angular 绑定会在浏览器渲染我们视图中的原始 HTML 后启动。然后,框架编译这个视图模板,在这个过程中设置必要的绑定。接下来,它在我们组件类和视图模板之间进行必要的同步,以生成最终的渲染输出。以下截图展示了在为我们应用程序完成数据绑定后视图模板所发生的转换:

图片

我们可以通过从输入框下方的段落中移除三个*ngIf指令及其分配的表达式,并在浏览器中刷新应用程序,来查看应用程序未转换的视图模板(如前一个截图左侧所示)。

Angular 与其他模板框架不同,其组件与其视图之间的绑定是动态的。对组件属性的更改会更新视图。Angular 永远不会重新生成 HTML;它只是在 HTML 的相关部分上工作,并仅更新需要根据组件属性更改而更改的 HTML 元素。这种数据绑定能力使 Angular 成为一个卓越的视图模板引擎。

查看我们的代码如何处理更新

如果我们回顾一下我们类的代码,我们会看到类中的属性和方法并不直接引用视图。相反,方法只是对类中的属性进行操作。因此,我们类的代码更易于阅读,因此更易于维护(当然,可测试):

图片

到目前为止,我们已经讨论了 Angular 如何根据组件属性的更改来更新视图。这是一个重要的概念,因为它可以让我们避免无数小时的调试和挫败感。下一节将专门介绍变更检测以及这些更新是如何管理的。

维护状态

首先,让我们看看如何在我们的 Angular 应用程序中维护状态。由于 Angular 应用程序是动态的而不是静态的,我们需要了解确保这些动态值随着应用程序数据的更新而保持更新的机制。例如,在我们的应用程序中,屏幕上的猜测次数是如何更新的?应用程序是如何根据用户输入决定显示关于猜测是否正确的正确消息的?

组件作为状态的容器

由于我们之前一直在强调 Angular 使用组件设计模式,因此你可能不会对知道应用程序状态的基本容器是组件本身而感到惊讶。这意味着当我们有一个组件实例时,组件中的所有属性及其值都可用于组件中引用的模板实例。在实践层面,这意味着我们可以在模板中直接使用这些值,而不需要编写任何管道代码来连接它们。

例如,在示例应用中,为了确定要显示的消息,我们可以在模板表达式中直接使用deviation。Angular 会扫描我们的组件以找到具有该名称的属性并使用其值。对于noOfTries也是如此;Angular 将在我们的组件内查找该属性的值,然后将其用于在模板的插值中设置其值。我们不需要编写任何其他代码:

 <div>
    <p *ngIf="deviation<0" class="alert alert-warning"> Your guess is higher.</p>
    <p *ngIf="deviation>0" class="alert alert-warning"> Your guess is lower.</p>
    <p *ngIf="deviation===0" class="alert alert-success"&gt; Yes! That's it.</p></div> 
    <p class="text-info">No of guesses : 
      <span class="badge">{{noOfTries}}</span> 
    </p> 
</div>

变更检测

那么,Angular 是如何在运行时跟踪我们组件中的变更的呢?到目前为止,这似乎都是通过魔法完成的。我们只是设置了组件属性和方法,然后通过插值以及属性和事件绑定将它们绑定到视图上。Angular 完成其余的工作!

但这当然不是魔法发生的,为了有效地使用 Angular,你需要了解它是如何随着这些值的变化而更新它们的。这被称为变更检测,Angular 在执行这一操作时与之前的方法非常不同。

如果你使用浏览器中的调试工具遍历应用程序,你会看到变更检测是如何工作的。在这里,我们使用 Chrome 的开发者工具并监视noOfTries属性。如果你在verifyGuess()方法的末尾设置断点,你会看到当你输入一个猜测时,noOfTries属性会立即在到达断点时更新,如下所示:

图片 2

一旦你越过断点,屏幕上的显示将更新为正确的猜测次数,如下面的截图所示:

图片 1

在引擎盖下真正发生的事情是,Angular 正在响应应用程序中的事件,并使用变更检测器,这些检测器遍历每个组件,以确定是否有任何影响视图的变更发生。在这种情况下,事件是一个按钮点击。按钮点击生成的事件在组件上调用verifyGuess()方法,以更新noOfTries属性。

该事件触发了变更检测周期,该周期识别出在视图中使用的noOfTries属性已更改。因此,Angular 使用该属性的新值更新绑定到noOfTries的视图中的元素。

如您所见,这是一个多步骤的过程,其中 Angular 首先根据事件更新组件和域对象,然后运行变更检测,最后重新渲染已更改的视图元素。而且,它会在每个浏览器事件(以及其他异步事件,如 XHR 请求和定时器)上执行此操作。Angular 的变更检测是响应式的且单向的。

这种方法允许 Angular 只通过一次遍历变更检测图。这被称为 单向数据绑定,并且极大地提高了 Angular 的性能。

我们将在第八章“一些实用场景”中深入探讨 Angular 的变更检测。要了解 Angular 团队对这一过程的描述,请访问 vsavkin.com/two-phases-of-angular-2-applications-fda2517604be#.fabhc0ynb

工具

工具使我们的生活变得简单,我们将分享一些工具,这些工具将帮助你在 Angular 开发的不同方面,从代码编写到调试:

  • Visual Studio Code: 这是 Microsoft 开发的新 IDE (code.visualstudio.com/)。它为 Angular 和 TypeScript 提供了出色的 IntelliSense 和代码补全支持。Visual Studio 2017 (www.visualstudio.com/) 也包括对 Angular 和 TypeScript 的支持。

  • IDE 扩展: 市面上许多流行的 IDE 都有插件/扩展,使 Angular 开发变得容易。例如:

  • Angular 语言服务:这是一款令人兴奋的新开发者工具,它为放置在组件装饰器或外部 HTML 文件中的 Angular 模板提供自动补全、错误检查和 F12 导航。您可以在 angular.io/guide/language-service 找到安装说明和有关该服务的更多信息。

  • 浏览器开发者控制台:所有当前浏览器在 JavaScript 调试方面都有出色的功能。由于我们使用 JavaScript,我们可以设置断点、添加监视,并执行所有其他使用 JavaScript 可能执行的操作。请记住,许多代码错误仅通过查看浏览器的控制台窗口就可以检测到。

  • Augury (augury.angular.io/):这是一个用于调试 Angular 应用程序的 Chrome Dev Tools 扩展。

  • 组件供应商也开始提供对 Angular 的支持。例如,Telerik 发布了 Kendo UI for Angular:www.telerik.com/kendo-angular-ui/.

资源

Angular 是一个新的框架,但已经有一个充满活力的社区开始围绕它形成。与这本书一起,还有博客、文章、支持论坛以及大量的帮助。以下是一些有用的突出资源:

就这样!这一章已经完成,现在是时候总结您所学的内容了。

摘要

旅程已经开始,我们已经达到了第一个里程碑。尽管这一章名为“入门”,但我们已经涵盖了您为了理解整体概念所需了解的大量概念。您的学习来自于我们构建并剖析整章的“猜数字”应用程序。

您学习了 Angular 如何使用 Web Components 的兴起标准和 JavaScript 和 TypeScript 的最新版本来实现组件设计模式。我们还回顾了 Angular 中使用的一些结构,例如插值、表达式和数据绑定语法。最后,我们探讨了变更检测以及一些有助于您开始 Angular 开发的实用工具和资源。

基础工作已经完成,现在我们准备在 Angular 框架上进行一些严肃的应用程序开发。在下一章中,我们将开始处理一个更复杂的练习,并接触许多新的 Angular 构造。

第二章:构建我们的第一个应用 – 7 分钟健身

我希望第一章足够吸引人,让你想要了解更多关于 Angular 的知识——相信我,我们只是触及了表面!这个框架有很多东西可以提供,与 TypeScript 一起,它致力于使前端开发更加有组织,因此更容易管理。

按照本书的主题,我们将使用 Angular 构建一个新的应用,在这个过程中,我们将更加熟悉这个框架。这个应用也将帮助我们探索 Angular 的一些新功能。

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

  • 7 分钟健身问题描述:我们详细说明了本章中构建的应用的功能。

  • 代码组织:对于我们的第一个真实应用,我们将尝试解释如何组织代码,特别是 Angular 代码。

  • 设计模型:我们应用的一个构建块是其模型。我们根据应用的需求设计应用模型。

  • 理解数据绑定基础设施:在构建 7 分钟健身 视图时,我们将查看框架的数据绑定功能,包括 属性属性样式事件 绑定。

  • 探索 Angular 平台指令:我们将涵盖的一些指令包括 ngForngIfngClassngStylengSwitch

  • 使用输入属性进行跨组件通信:当我们构建嵌套组件时,我们学习如何使用输入属性将数据从父组件传递到其子组件。

  • 使用事件进行跨组件通信:Angular 组件可以订阅和触发事件。我们了解了 Angular 中的事件绑定支持。

  • Angular 管道:Angular 管道提供了一种格式化视图内容的方式。我们探讨了几个标准的 Angular 管道,并构建了自己的管道以支持秒到 hh:mm:ss 的转换。

让我们开始吧!我们首先要做的是定义我们的 7 分钟健身 应用。

什么是 7 分钟健身?

我们希望所有阅读这本书的人都身体健康。因此,这本书应该具有双重目的;它不仅应该刺激你的大脑,还应该敦促你关注你的身体健康。还有什么比构建一个针对身体健康的应用更好的方式呢!

7 分钟健身 是一个锻炼/健身应用,要求我们在七分钟的时间范围内快速连续完成一组 12 个练习。由于其短小精悍的长度和巨大的益处,7 分钟健身 已经变得相当受欢迎。我们无法证实或反驳这些说法,但进行任何形式的剧烈体育活动都比什么都不做要好。如果你想了解更多关于这个健身的信息,那么请查看 well.blogs.nytimes.com/2013/05/09/the-scientific-7-minute-workout/

该应用的技术细节包括执行一组 12 个练习,每个练习分配 30 秒。在开始下一个练习之前,会有一个简短的休息期。对于我们要构建的应用,我们将休息 10 秒。因此,总时长略超过七分钟。

在本章结束时,我们将拥有一个准备好的7 分钟健身应用,其外观可能如下所示:

7 分钟健身应用

下载代码库

该应用的代码可以从专门为此书建立的 GitHub 网站(github.com/chandermani/angular6byexample)下载。由于我们是逐步构建应用,我们创建了多个检查点,这些检查点对应于GitHub 分支,如checkpoint2.1checkpoint2.2等。在叙述过程中,我们将突出显示这些分支以供参考。这些分支将包含到那个时间点为止在应用上所做的所有工作。

7 分钟健身的代码位于名为trainer的仓库文件夹中。

那么,让我们开始吧!

设置构建环境

记住,我们正在构建一个现代平台,而浏览器对此仍缺乏支持。因此,直接在 HTML 中引用脚本文件是不可能的(虽然常见,但这是一个过时的方法,我们本应避免)。浏览器不理解TypeScript;这意味着必须有一个将用 TypeScript 编写的代码转换为标准JavaScript (ES5)的过程。因此,为任何 Angular 应用设置构建环境变得至关重要。而且,由于 Angular 日益流行,我们从不缺乏选择。

如果你是一名从事 Web 栈前端开发的开发者,你无法避免Node.js。这是最广泛使用的 Web/JavaScript 开发平台。因此,无需猜测,大多数 Angular 构建解决方案都由 Node 支持。例如GruntGulpJSPMwebpack是任何构建系统的最常见构建模块。

由于我们也是在 Node.js 平台上进行开发,因此在开始之前请先安装 Node.js。

对于本书和这个示例应用,我们推荐Angular CLI(bit.ly/ng6be-angular-cli)。这是一个命令行工具,它具有构建系统和脚手架工具,极大地简化了 Angular 的开发工作流程。它很受欢迎,易于设置,易于管理,并支持现代构建系统应该拥有的几乎所有功能。关于它的更多信息将在后面介绍。

就像任何成熟的框架一样,Angular CLI 并不是网络上的唯一选择。社区创建的一些值得注意的起始网站和构建设置如下:

起始网站 位置
angular2-webpack-starter bit.ly/ng2webpack
angular-seed github.com/mgechev/angular-seed

让我们从安装 Angular CLI 开始。在命令行中,输入以下内容:

npm i -g @angular/cli

安装完成后,Angular CLI 将在我们的执行环境中添加一个新的命令 ng。要从命令行创建一个新的 Angular 项目,请运行以下命令:

ng new PROJECT-NAME

这将生成一个包含大量文件、一个模板化的 Angular 应用和一个预配置的构建系统的文件夹结构。要从命令行运行应用程序,请执行以下操作:

ng serve --open

你可以看到一个基本的 Angular 应用正在运行!

对于我们的 7 分钟健身 应用,我们不会从头开始,而是从一个基于 ng new 生成的项目结构稍作修改的版本开始。以下是一些步骤:

好奇默认项目包含什么吗?运行 ng new 项目名称。查看生成的内容结构和 Angular CLI 文档,以了解默认设置中包含的内容。

  1. bit.ly/ngbe-base 下载此应用的基版,并将其解压到您的机器上的某个位置。如果您熟悉 Git 的工作方式,您可以直接克隆存储库并切换到 base 分支:
git checkout base

这段代码是我们应用的开端。

  1. 使用命令行导航到 trainer 文件夹,并执行命令 npm install 以安装我们应用的 包依赖

在 Node.js 世界中,是用于应用或支持应用构建过程的第三方库(例如,我们的应用中使用 Angular)。npm 是一个用于从远程仓库拉取这些包的命令行工具。

  1. 一旦 npm 从 npm 存储库中拉取了应用依赖,我们就可以构建和运行应用了。从命令行输入以下命令:
    ng serve --open

这会编译并运行应用。如果构建过程顺利,默认的浏览器窗口/标签页将打开一个基本的网页(http://localhost:4200/)。我们现在可以开始使用 Angular 开发我们的应用了!

但在我们这样做之前,了解一些关于 Angular CLI 和我们对默认项目模板所做的自定义将很有趣。

Angular CLI

Angular CLI 的创建目的是为了标准化和简化 Angular 应用的开发和部署工作流程。正如文档所建议的:

"Angular CLI 使得创建一个即开即用的应用变得简单。它已经遵循我们的最佳实践!"

它包含:

  • 基于 webpack 的构建系统

  • 一个用于生成所有标准 Angular 艺术品的 脚手架工具,包括模块、指令、组件和管道

  • 遵循 Angular 风格指南 (bit.ly/ngbe-styleguide),确保我们使用社区驱动的标准来处理各种规模的项目

你可能从未听说过风格指南这个术语,或者可能不理解它的意义。在任何技术中,风格指南都是一系列指导原则,帮助我们组织编写易于开发、维护和扩展的代码。要理解和欣赏 Angular 自己的风格指南,对框架本身有一定的熟悉度是可取的,我们已经开始了这段旅程。

  • 一个目标式代码检查器;Angular CLI 集成了codelyzer(bit.ly/ngbe-codelyzer),这是一个静态代码分析工具,它将我们的 Angular 代码与一系列规则进行验证,以确保我们编写的代码遵循 Angular 风格指南中规定的标准。

  • 预配置的单元测试端到端测试e2e)框架

以及更多!

想象一下,如果我们必须手动完成所有这些工作!陡峭的学习曲线会很快让我们感到不知所措。幸运的是,我们不必处理它,Angular CLI 会为我们处理。

Angular CLI 的构建设置基于 webpack,但它不暴露底层的 webpack 配置;这是故意的。Angular 团队希望保护开发者免受 webpack 的复杂性和内部工作原理的影响。Angular CLI 的最终目标是消除任何入门级障碍,并使设置和运行 Angular 代码变得简单。

这并不意味着 Angular CLI 不可配置。有一个配置文件angular.json),我们可以用它来修改构建设置。我们在这里不会介绍这个。请检查 7 分钟健身计划的配置文件,并在此处阅读文档:bit.ly/ng6be-angular-cli-config

我们对默认生成的项目模板所做的调整包括:

  • style.css文件中引用了 Bootstrap CSS。

  • 升级了一些 npm 库的版本。

  • 将生成的代码的前缀配置更改为使用abe(代表 Angular By Example),而不是app。这个更改意味着所有我们的组件和指令选择器都将使用abe作为前缀,而不是app。检查app.component.ts;选择器是abe-root而不是app-root

在讨论 Angular CLI 和构建时,我们在继续之前应该了解一些事情。

我们编写的 TypeScript 代码会发生什么?

代码转译

浏览器,众所周知,只支持 JavaScript,它们不理解 TypeScript。因此,我们需要一个机制将我们的 TypeScript 代码转换为纯 JavaScript(ES5是我们的最佳选择)。TypeScript 编译器负责这项工作。编译器将 TypeScript 代码转换为 JavaScript。这个过程通常被称为转译,由于 TypeScript 编译器执行这个操作,因此被称为转译器

JavaScript 语言在过去的几年中不断发展,每个新版本都为语言添加了新的功能/能力。最新的版本 ES2015 继承了 ES5,是语言的一次重大更新。尽管于 2015 年 6 月发布,但一些较旧的浏览器仍然不支持 ES2015 版本的 JavaScript,这使得其采用成为一项挑战。

当将 TypeScript 代码转换为 JavaScript 代码时,我们可以指定要使用的 JavaScript 版本。如前所述,ES5 是我们的最佳选择,但如果我们打算只与最新的浏览器合作,可以选择 ES2015。对于 7 分钟健身应用,我们将代码转换为 ES5 格式。我们在 tsconfig.json 中设置了此 TypeScript 编译器配置(请参阅 target 属性)。

有趣的是,转换可以在构建/编译时和运行时发生:

  • 构建时转换:构建过程中的转换将脚本文件(在我们的例子中是 TypeScript .ts 文件)编译成纯 JavaScript。Angular CLI 执行构建时转换。

  • 运行时转换:这发生在浏览器运行时。我们直接引用 TypeScript 文件(在我们的例子中是 .ts 文件),并且 TypeScript 编译器,在浏览器中预先加载,会即时编译这些脚本文件。这种设置仅适用于小型示例/代码片段,因为加载转换器和即时转换代码会带来额外的性能开销。

转换过程不仅限于 TypeScript。所有针对 Web 的语言,如 CoffeeScriptES2015(是的,JavaScript 本身!)或任何浏览器本身无法理解的任何其他语言,都需要转换。大多数语言都有转换器,其中最著名的是(除了 TypeScript 之外)tracuerbabel

Angular CLI 构建系统负责设置 TypeScript 编译器,并设置文件监视器,每次我们更改 TypeScript 文件时都会重新编译代码。

如果你刚接触 TypeScript,请记住 TypeScript 不依赖于 Angular;实际上,Angular 是基于 TypeScript 构建的。我强烈建议你查看 TypeScript 的官方文档(www.typescriptlang.org/)并在 Angular 的范畴之外学习这门语言。

让我们回到我们正在构建的应用程序,并开始探索代码设置。

代码组织

Angular CLI 的优势在于它规定了适用于所有规模应用程序的代码组织结构。以下是当前代码组织结构的样子:

图片

  • trainer 是应用程序的根文件夹。

  • trainer 文件夹内的文件是配置文件和一些标准文件,它们是每个标准 Node 应用程序的一部分。

  • e2e 文件夹将包含应用程序的端到端测试。

  • src 是所有开发发生的主要文件夹。所有应用程序的工件都放入 src

  • src 文件夹内的 assets 文件夹托管静态内容(例如图片、CSS、音频文件等)。

  • app 文件夹包含应用程序的源代码。

  • environments 文件夹用于为不同的部署环境(如 dev、qa、production)设置配置。

为了在 app 文件夹内组织 Angular 代码,我们借鉴了 Angular 团队发布的 Angular 风格指南(bit.ly/ng6be-style-guide)。

功能文件夹

风格指南建议使用功能文件夹来组织代码。使用功能文件夹,将链接到单个功能的文件放在一起。如果一个功能增长,我们将它进一步拆分为子功能,并将代码放入子文件夹中。将 app 文件夹视为我们的第一个功能文件夹!随着应用程序的增长,app 将添加子功能以更好地组织代码。

直接进入构建应用程序。我们的第一个重点领域,应用程序的模型!

7 分钟健身模型

设计该应用程序的模型需要我们首先详细说明 7 分钟健身 应用程序的功能方面,然后推导出一个满足这些要求的模型。根据之前定义的问题陈述,一些明显的要求如下:

  • 能够开始锻炼。

  • 提供关于当前练习及其进度的视觉提示。这包括以下内容:

    • 提供当前练习的视觉表示

    • 提供如何进行特定练习的逐步说明

    • 当前练习剩余时间

  • 当锻炼结束时通知用户。

我们将添加到该应用程序的一些其他有价值的功能如下:

  • 暂停当前锻炼的能力。

  • 提供关于接下来要进行的练习的信息。

  • 提供音频提示,以便用户可以在不经常查看屏幕的情况下进行锻炼。这包括:

    • 计时器点击声音

    • 关于下一项练习的详细信息

    • 信号表示练习即将开始

  • 显示正在进行中的练习的相关视频,并能够播放它们。

如我们所见,该应用的核心主题是锻炼练习。在这里,一个锻炼是一组按照特定顺序进行、持续特定时间的练习。因此,让我们继续定义我们的锻炼和练习模型。

根据刚刚提到的要求,我们将需要以下关于一项练习的详细信息:

  • 名称。这应该是唯一的。

  • 标题。这会显示给用户。

  • 练习的描述。

  • 如何执行练习的说明。

  • 练习的图片。

  • 练习音频片段的名称。

  • 相关视频。

使用 TypeScript,我们可以为我们的模型定义类。

Exercise 类如下所示:

export class Exercise { 
  constructor( 
    public name: string,
    public title: string,
    public description: string, 
    public image: string,
    public nameSound?: string,
    public procedure?: string,
    public videos?: Array<string>) { }
} 

TypeScript 小贴士

使用 publicprivate 声明构造函数参数是一种创建和初始化类成员的快捷方式。nameSoundprocedurevideos 后的 ? 后缀表示这些是可选参数。

对于锻炼,我们需要跟踪以下属性:

  • 名称。这应该是唯一的。

  • 标题。这会显示给用户。

  • 组成锻炼的练习。

  • 每个练习的时长。

  • 两次练习之间的休息时间。

用于跟踪锻炼进度的模型类 (WorkoutPlan) 如下所示:

export class WorkoutPlan { 
  constructor( 
    public name: string, 
    public title: string, 
    public restBetweenExercise: number, 
 public exercises: ExercisePlan[], 
    public description?: string) { } 

  totalWorkoutDuration(): number { ... } 
} 

totalWorkoutDuration 函数返回锻炼的总时长(以秒为单位)。

WorkoutPlan 在前面的定义中引用了另一个类,ExercisePlan。它跟踪锻炼和锻炼中的持续时间,一旦我们查看 ExercisePlan 的定义,这一点就非常明显:

export class ExercisePlan { 
  constructor( 
    public exercise: Exercise, 
    public duration: number) { } 
} 

让我为您节省一些输入,并告诉您在哪里获取模型类,但在那之前,我们需要决定在哪里添加它们。我们已经准备好进行第一个功能。

第一个功能模块

7 分钟锻炼 的主要功能是执行预定义的练习集。因此,我们现在将创建一个功能模块,稍后会将功能实现添加到该模块中。我们称此模块为 workout-runner。让我们使用 Angular CLI 的脚手架功能初始化功能。

从命令行导航到 trainer/src/app 文件夹并运行以下命令:

ng generate module workout-runner --module app.module.ts

跟踪控制台日志以了解生成的文件。该命令本质上:

  • 在新的 workout-runner 文件夹内创建一个新的 Angular WorkoutRunnerModule 模块

  • 将新创建的模块导入主应用程序模块 app (app.module.ts)

我们现在有一个新的 功能模块

给每个功能创建自己的模块。

注意 Angular CLI 在构建 Angular 实体时遵循的约定。从前面的示例中,通过命令行提供的模块名称是 workout-runner。虽然生成的文件夹和文件名使用相同的名称,但生成的模块的类名是 WorkoutRunnerModule(Pascal 大写并带有 Module 后缀)。

打开新生成的模块定义文件 (workout-runner.module.ts) 并查看生成的内容。WorkoutRunnerModule 导入 CommonModule,这是一个包含常见 Angular 指令(如 ngIfngFor)的模块,允许我们在 WorkoutRunnerModule 中定义的任何组件/指令中使用这些常见指令。

模块是 Angular 组织代码的方式。我们将在稍后讨论 Angular 模块。

model.ts 文件从 bit.ly/ng6be-2-1-model-ts 复制到 workout-runner 文件夹。不久我们将看到这些模型类是如何被利用的。

由于我们从一个预配置的 Angular 应用程序开始,我们只需要了解应用程序是如何启动的。

应用程序启动

第一章,入门,对应用程序启动过程进行了良好的介绍。7 分钟锻炼 的应用程序启动过程保持不变;查看 src 文件夹。有一个 main.ts 文件通过调用以下内容启动应用程序:

platformBrowserDynamic().bootstrapModule(AppModule)
    .catch(err => console.log(err));

重量级的工作由 Angular CLI 完成,它编译应用程序,将脚本和 CSS 引用包含到index.html中,并运行应用程序。我们不需要进行任何配置。这些配置是默认 Angular CLI 配置的一部分(.angular-cli.json)。

我们已经创建了一个新模块,并将一些模型类添加到module文件夹中。在我们进一步实施功能之前,让我们先谈谈Angular 模块

探索 Angular 模块

随着 7 分钟健身 应用程序的增长,我们向其中添加了新的组件/指令/管道/其他工件,这就需要对这些项目进行组织。每个项目都需要成为 Angular 模块的一部分。

一种天真方法是在我们应用程序的根模块(AppModule)中声明一切,就像我们对WorkoutRunnerComponent所做的那样,但这违背了 Angular 模块的全部目的。

为了了解为什么单模块方法永远不会是一个好主意,让我们来探索 Angular 模块。

理解 Angular 模块

在 Angular 中,模块是将代码组织成属于一起并作为一个统一单元工作的块的方式。模块是 Angular 对代码分组和组织的方式。

一个 Angular 模块主要定义:

  • 它拥有的组件/指令/管道

  • 它公开的组件/指令/管道,供其他模块消费

  • 它所依赖的其他模块

  • 模块想要在应用程序范围内提供的服务

任何相当规模的 Angular 应用都将有模块相互链接:一些模块消费来自其他模块的工件,一些向其他模块提供工件,还有一些模块两者都做。

作为一项标准实践,模块隔离是基于功能的。人们将应用分为功能或子功能(对于大型功能),并为每个功能创建模块。甚至框架也遵循此指南,因为所有框架构造都分布在模块中:

  • 存在CommonModule,它聚合了在所有基于浏览器的 Angular 应用中使用的标准框架构造。

  • 如果我们想使用 Angular 路由框架,则存在RouterModule

  • 如果我们的应用需要通过 HTTP 与服务器通信,则存在HtppModule

Angular 模块是通过将@NgModule装饰器应用于 TypeScript 类来创建的,这是我们已经在第一章 入门 中学到的。装饰器定义暴露了足够的元数据,使 Angular 能够加载模块引用的所有内容。

装饰器有多个属性,允许我们定义:

  • 外部依赖(使用imports)。

  • 模块工件(使用declarations)。

  • 模块导出(使用exports)。

  • 在模块内部定义并需要全局注册的服务(使用providers)。

  • 主要应用程序视图,称为根组件,它托管所有其他应用程序视图。只有根模块应该使用bootstrap属性设置此属性。

此图突出了模块的内部结构以及它们是如何相互链接的:

在 Angular 的上下文中定义的模块(使用@NgModule装饰器)与我们通过 TypeScript 文件中的import语句导入的模块不同。通过import语句导入的模块是JavaScript 模块,可以是遵循CommonJSAMDES2015规范的任何格式,而 Angular 模块是 Angular 用于隔离和组织其工件的结构。除非讨论的上下文是特定的 JavaScript 模块,否则任何关于模块的提及都指的是 Angular 模块。我们可以在这里了解更多信息:bit.ly/ng2be6-module-vs-ngmodule

我们希望从所有这些讨论中可以清楚地看出:除非你正在构建一些基础的东西,否则创建单个应用程序范围的模块不是正确使用 Angular 模块的方式。

是时候深入到行动的核心了;让我们构建我们的第一个组件。

我们的第一个组件 - WorkoutRunnerComponent

WorkoutRunnerComponent是我们7 分钟锻炼应用的核心部分,它将包含执行锻炼的逻辑。

WorkoutRunnerComponent实现中,我们将做以下事情:

  1. 开始锻炼

  2. 展示正在进行的锻炼过程并显示进度指示器

  3. 练习时间结束后,显示下一个练习

  4. 重复此过程,直到所有练习结束

我们准备好创建(或搭建)我们的组件。

从命令行导航到src/app文件夹并执行以下ng命令:

ng generate component workout-runner -is

生成器在workout-runner文件夹中生成一些文件(三个),并更新WorkoutRunnerModule中的模块声明,以包括新创建的WorkoutRunnerComponent

-is标志用于停止为组件生成单独的 CSS 文件。由于我们使用全局样式,我们不需要组件特定的样式。

记住要从src/app文件夹运行此命令,而不是从src/app/workout-runner文件夹运行。如果我们从src/app/workout-runner运行前面的命令,Angular CLI 将为workout-runner组件定义创建一个新的子文件夹。

前面的ng generate命令为组件生成以下三个文件:

  • <component-name>.component.html:这是组件的视图 HTML。

  • <component-name>.component.spec.ts:用于单元测试的测试规范文件。我们将用完整的一章来介绍 Angular 应用程序的单元测试。

  • <component-name>.component.ts:包含组件实现的主体组件文件。

再次,我们将鼓励您查看生成的代码,以了解生成了什么。Angular CLI 组件生成器为我们节省了一些按键,一旦生成,样板代码可以按需演变。

在第一章“入门”中,我们提到了组件装饰器(@Component),这里应用的装饰器也没有什么不同。虽然我们只看到了四个装饰器元数据属性(例如templateUrl),但组件装饰器还支持一些其他有用的属性。查看 Angular 文档中的组件部分,了解更多关于这些属性及其应用的信息。在接下来的章节中,我们将利用一些除了每个组件上使用的标准属性之外的其他元数据属性。

一个细心的读者可能会注意到生成的selector属性值有一个前缀abe;这是故意的。由于我们正在扩展 HTML领域特定语言DSL)以包含一个新元素,前缀abe帮助我们区分我们开发的 HTML 扩展。因此,我们不是在 HTML 中使用<workout-runner></workout-runner>,而是使用<abe-workout-runner></abe-workout-runner>。前缀值已在angular.json中配置,请参阅prefix属性。

总是为你的组件选择器添加一个前缀。

我们现在有了WorkoutRunnerComponent的模板;让我们开始添加实现,首先添加模型引用。

workout-runner.component.ts中,导入所有锻炼模型:

import {WorkoutPlan, ExercisePlan, Exercise} from '../model';

接下来,我们需要设置锻炼数据。让我们通过在生成的ngOnInit函数和相关类属性中添加一些代码到WorkoutRunnerComponent类中来实现这一点:

workoutPlan: WorkoutPlan; 
restExercise: ExercisePlan; 
ngOnInit() { 
   this.workoutPlan = this.buildWorkout(); 
   this.restExercise = new ExercisePlan( 
     new Exercise('rest', 'Relax!', 'Relax a bit', 'rest.png'),  
     this.workoutPlan.restBetweenExercise);   
} 

ngOnInit是一个 Angular 在组件初始化时调用的特殊函数。我们很快就会讨论ngOnInit

WorkoutRunnerComponent上的buildWorkout设置完整的锻炼,正如我们很快就会定义的。我们还初始化了一个restExercise变量来跟踪休息时间(注意restExerciseExercisePlan类型的一个对象)。

buildWorkout函数是一个较长的函数,所以最好从 Git 分支 checkpoint2.1 中可用的锻炼运行器的实现中复制实现(bit.ly/ng6be-2-1-workout-runner-component-ts)。buildWorkout代码如下:

buildWorkout(): WorkoutPlan { 
let workout = new WorkoutPlan('7MinWorkout',  
"7 Minute Workout", 10, []); 
   workout.exercises.push( 
      new ExercisePlan( 
        new Exercise( 
          'jumpingJacks', 
          'Jumping Jacks', 
          'A jumping jack or star jump, also called side-straddle hop
           is a physical jumping exercise.', 
          'JumpingJacks.png', 
          'jumpingjacks.wav', 
          `Assume an erect position, with feet together and 
           arms at your side. ...`, 
          ['dmYwZH_BNd0', 'BABOdJ-2Z6o', 'c4DAnQ6DtF8']), 
        30)); 
   // (TRUNCATED) Other 11 workout exercise data. 
   return workout; 
} 

这段代码构建了WorkoutPlan对象,并将锻炼数据推入exercises数组(ExercisePlan对象数组),返回新构建的锻炼。

初始化已完成;现在,是时候实际实现开始锻炼了。在WorkoutRunnerComponent实现中添加一个start函数,如下所示:

start() { 
   this.workoutTimeRemaining =  
   this.workoutPlan.totalWorkoutDuration(); 
   this.currentExerciseIndex = 0;  
   this.startExercise(this.workoutPlan.exercises[this.currentExerciseIndex]); 
} 

然后在函数顶部声明在函数中使用的新的变量,以及其他变量声明:

workoutTimeRemaining: number; 
currentExerciseIndex: number; 

workoutTimeRemaining变量跟踪锻炼剩余的总时间,currentExerciseIndex跟踪当前执行的锻炼索引。对startExercise的调用实际上启动了一个锻炼。这就是startExercise代码的样子:

startExercise(exercisePlan: ExercisePlan) { 
    this.currentExercise = exercisePlan; 
    this.exerciseRunningDuration = 0; 
    const intervalId = setInterval(() => { 
      if (this.exerciseRunningDuration >=  this.currentExercise.duration) { 
          clearInterval(intervalId);  
      } 
      else { this.exerciseRunningDuration++; } 
    }, 1000); 
} 

我们首先初始化currentExerciseexerciseRunningDurationcurrentExercise变量跟踪正在进行的锻炼,exerciseRunningDuration跟踪其时长。这两个变量也需要在顶部声明:

currentExercise: ExercisePlan; 
exerciseRunningDuration: number; 

我们使用延迟为一秒(1,000 毫秒)的setInterval JavaScript 函数来使进度。在setInterval回调函数内部,exerciseRunningDuration随着每一秒的过去而递增。嵌套的clearInterval调用一旦锻炼时长结束就停止计时器。

TypeScript 箭头函数

传递给setInterval的回调参数(()=>{...})是一个 lambda 函数(或 ES 2015 中的箭头函数)。Lambda 函数是匿名函数的简写形式,具有额外的优势。你可以在bit.ly/ng2be-ts-arrow-functions了解更多关于它们的信息。

组件的第一版几乎完成,但当前它有一个静态视图(UI),因此我们无法验证其实现。我们可以通过添加一个基本的视图定义来快速纠正这种情况。打开workout-runner.component.ts,注释掉templateUrl属性,并添加一个内联模板属性(template),并将其设置为以下内容:

template: `<pre>Current Exercise: {{currentExercise | json}}</pre>
<pre>Time Left: {{currentExercise.duration - exerciseRunningDuration}}</pre>`,

用反引号(` `)括起来的字符串是 ES2015 的新增功能。也称为模板字面量,这种字符串字面量可以是多行的,并允许在内部嵌入表达式(不要与 Angular 表达式混淆)。查看 MDN 文章bit.ly/template-literals以获取更多详细信息。

内联视图与外部视图模板(使用templateUrl指定的external template file)用于详细视图。Angular CLI 默认生成外部模板引用,但我们可以通过将--inline-template标志传递给ng组件生成命令(如--inline-template true)来影响这种行为。

前面的模板 HTML 将渲染原始的ExercisePlan对象和剩余的锻炼时间。在第一个插值表达式内部有一个有趣的表达式:currentExercise | jsoncurrentExercise属性在WorkoutRunnerComponent中定义,但关于|符号以及其后的内容(json)是什么?在 Angular 世界中,它被称为管道。管道的唯一目的是转换/格式化模板数据。

这里的json管道用于 JSON 数据格式化。你将在本章后面了解更多关于管道的内容,但为了对json管道的作用有一个大致的了解,我们可以移除json管道以及|符号,并渲染模板;我们将在下一部分做这件事。

为了渲染新的WorkoutRunnerComponent实现,必须将其添加到根组件的视图中。修改src/components/app/app.component.html,将h3标签替换为以下代码:

<div class="container body-content app-container">
      <abe-workout-runner></abe-workout-runner>
</div>

虽然实现看起来已经完整,但还缺少一个关键部分。代码中没有任何地方真正开始锻炼。锻炼应该在页面加载后立即开始。

组件生命周期钩子将帮助我们解决问题!

组件生命周期钩子

Angular 组件的生命周期是充满事件的。组件被创建,在其生命周期中改变状态,最终被销毁。Angular 提供了一些生命周期钩子/函数,当发生此类事件时,框架会调用(在组件上)。考虑以下示例:

  • 当组件初始化时,Angular 会调用ngOnInit

  • 当组件的数据绑定属性发生变化时,Angular 会调用ngOnChanges

  • 当组件被销毁时,Angular 会调用ngOnDestroy

作为开发者,我们可以利用这些关键时刻,并在相应的组件中执行一些自定义逻辑。

我们将要利用的钩子是ngOnInitngOnInit函数在组件的数据绑定属性首次初始化时触发,但在视图初始化开始之前。

虽然ngOnInit和类构造函数看起来很相似,但它们有不同的用途。构造函数是一种语言特性,用于初始化类成员。另一方面,ngOnInit用于在组件准备好后执行一些初始化操作。避免将构造函数用于除成员初始化之外的其他任何用途。

ngOnInit函数更新为WorkoutRunnerComponent类,并调用开始锻炼:

ngOnInit() { 
    ...
    this.start(); 
} 

Angular CLI 作为组件脚手架的一部分已经生成了ngOnInit的签名。ngOnInit函数在OnInit接口上声明,该接口是 Angular 核心框架的一部分。我们可以通过查看WorkoutRunnerComponent的导入部分来确认这一点:

import {Component,OnInit} from '@angular/core'; 
... 
export class WorkoutRunnerComponent implements OnInit {

组件支持许多其他生命周期钩子,包括ngOnDestroyngOnChangesngAfterViewInit,但在这里我们不会深入探讨它们。有关其他此类钩子的更多信息,请参阅开发者指南(angular.io/guide/lifecycle-hooks)。

实现接口(如前例中的OnInit)是可选的。只要函数名匹配,这些生命周期钩子就会正常工作。我们仍然建议您使用接口来清楚地传达意图。

是时候运行我们的应用了!打开命令行,导航到trainer文件夹,并输入以下行:

ng serve --open

代码编译通过了,但没有渲染 UI。是什么出了问题?让我们查看浏览器控制台中的错误。

打开浏览器的开发者工具(常见快捷键F12),查看控制台标签页以查找错误。存在模板解析错误。Angular 无法定位到abe-workout-runner组件。让我们做一些合理性检查来验证我们的设置:

  • WorkoutRunnerComponent实现完成 - 检查

  • WorkoutRunnerModule中声明的组件 - 检查

  • 已将 WorkoutRunnerModule 导入到 AppModule 中 - 检查

尽管如此,AppComponent 模板找不到 WorkoutRunnerComponent。这是否是因为 WorkoutRunnerComponentAppComponent 在不同的模块中?确实如此!这是问题所在!虽然 WorkoutRunnerModule 已被导入到 AppModule 中,但 WorkoutRunnerModule 仍然没有导出新的 WorkoutRunnerComponent,这将允许 AppComponent 使用它。

记住,将组件/指令/管道添加到模块的 声明 部分会使它们在模块内部可用。只有在我们导出组件/指令/管道之后,它才能在模块之间使用。

让我们通过更新 WorkoutRunnerModule 声明的导出数组来导出 WorkoutRunnerComponent

declarations: [WorkoutRunnerComponent],
exports:[WorkoutRunnerComponent]

这次,我们应该看到以下输出:

图片

如果你想在其他模块中使用在 Angular 模块内部定义的工件,请始终导出这些工件。

模型数据每秒都会更新!现在你将理解为什么插值 ({{ }}) 是一个很好的调试工具。

这也是一个尝试不带 json 管道渲染 currentExercise 的好时机,看看会渲染什么。

我们还没有完成!在页面上等待足够长的时间,我们会意识到计时器在 30 秒后停止。应用程序没有加载下一个锻炼数据。是时候修复它了!

更新 setInterval 函数内部的代码:

if (this.exerciseRunningDuration >=  this.currentExercise.duration) { 
   clearInterval(intervalId); 
 const next: ExercisePlan = this.getNextExercise(); if (next) { if (next !== this.restExercise) { this.currentExerciseIndex++; } this.startExercise(next);}
 else { console.log('Workout complete!'); } 
} 

使用 if (this.exerciseRunningDuration >= this.currentExercise.duration) 条件语句,一旦当前锻炼的时间耗尽,就切换到下一个锻炼。我们使用 getNextExercise 获取下一个锻炼,并再次调用 startExercise 以重复此过程。如果 getNextExercise 调用没有返回任何锻炼,则认为锻炼已完成。

在锻炼切换期间,只有当下一个锻炼不是休息锻炼时,我们才增加 currentExerciseIndex。记住,原始锻炼计划中没有休息锻炼。为了保持一致性,我们创建了一个休息锻炼,现在在休息锻炼和锻炼计划中的标准锻炼之间切换。因此,当下一个锻炼是休息时,currentExerciseIndex 不会改变。

让我们快速添加 getNextExercise 函数。将函数添加到 WorkoutRunnerComponent 类中:

getNextExercise(): ExercisePlan { 
    let nextExercise: ExercisePlan = null; 
    if (this.currentExercise === this.restExercise) { 
      nextExercise = this.workoutPlan.exercises[this.currentExerciseIndex + 1]; 
    } 
    else if (this.currentExerciseIndex < this.workoutPlan.exercises.length - 1) { 
      nextExercise = this.restExercise; 
    } 
    return nextExercise; 
} 

getNextExercise 函数返回需要执行的下个锻炼。

注意,getNextExercise 返回的对象是一个 ExercisePlan 对象,它内部包含锻炼的详细信息以及锻炼的持续时间。

实现相当直观。如果当前锻炼是休息,则从 workoutPlan.exercises 数组中获取下一个锻炼(基于 currentExerciseIndex);否则,如果不在最后一个锻炼上(else if 条件检查),下一个锻炼是休息。

这样,我们就准备好测试我们的实现了。练习应该在每 10 秒或 30 秒后翻转。太棒了!

当前的构建设置会在保存脚本文件时自动编译对脚本文件所做的任何更改;它也会在这些更改后刷新浏览器。但以防万一 UI 没有更新或事情没有按预期工作,请刷新浏览器窗口。如果你在运行代码时遇到问题,请查看 Git 分支checkpoint2.1以获取我们迄今为止所做工作的一个工作版本。或者如果你不使用 Git,可以从bit.ly/ng6be-checkpoint2-1下载 Checkpoint 2.1 的快照(一个 ZIP 文件)。在第一次设置快照时,请参考trainer文件夹中的README.md文件。

目前我们对组件的工作已经足够,让我们来构建视图。

构建 7 分钟健身视图

在定义模型和实现组件时,大部分艰苦的工作已经完成。现在,我们只需要使用 Angular 超棒的数据绑定功能来“皮肤”HTML。这将简单、甜蜜且优雅!

对于7 分钟健身视图,我们需要显示练习名称、练习图像、进度指示器和剩余时间。将workout-runner.component.html文件的本地内容替换为 Git 分支checkpoint2.2中的文件内容(或从bit.ly/ng6be-2-2-workout-runner-component-html下载)。视图 HTML 看起来如下:

<div class="row">
  <div id="exercise-pane" class="col-sm">
    <h1 class="text-center">{{currentExercise.exercise.title}}</h1>
    <div class="image-container row">
      <img class="img-fluid col-sm" [src]="'/assets/images/' +  
                                      currentExercise.exercise.image" />
    </div>
    <div class="progress time-progress row">
      <div class="progress-bar col-sm" 
            role="progressbar" 
            [attr.aria-valuenow]="exerciseRunningDuration" 
            aria-valuemin="0" 
            [attr.aria-valuemax]="currentExercise.duration"
            [ngStyle]="{'width':(exerciseRunningDuration/currentExercise.duration) * 
                                                                100 + '%'}">
      </div>
    </div>
    <h1>Time Remaining: {{currentExercise.duration-exerciseRunningDuration}}</h1>
  </div>
</div>

WorkoutRunnerComponent目前使用内联模板;相反,我们需要恢复使用外部模板。更新workout-runner.component.ts文件,删除template属性,然后取消注释我们之前注释掉的templateUrl

在我们理解视图中的 Angular 组件之前,让我们再次运行应用程序。保存workout-runner.component.html中的更改,如果一切顺利,我们将看到健身应用程序的全貌:

图片

基本应用现在已经上线并运行。练习图像和标题显示出来,进度指示器显示进度,当练习时间结束时发生练习转换。这感觉真是太棒了!

如果你运行代码时遇到问题,请查看 Git 分支checkpoint2.2以获取我们迄今为止所做工作的一个工作版本。你还可以从这个 GitHub 位置下载checkpoint2.2的快照(一个 ZIP 文件):bit.ly/ng6be-checkpoint-2-2。在第一次设置快照时,请参考trainer文件夹中的README.md文件。

观察视图 HTML,除了一些 Bootstrap 样式外,还有一些需要我们注意的有趣的 Angular 组件。在我们详细研究这些视图结构之前,让我们分解这些元素并提供一个快速总结:

  • <h1 ...>{{currentExercise.exercise.title}}</h1>: 使用 插值

  • <img ... [src]="'/assets/images/' + currentExercise.exercise.image" .../>: 使用 属性绑定 将图像的 src 属性绑定到组件模型属性 currentExercise.exercise.image

  • <div ... [attr.aria-valuenow]="exerciseRunningDuration" ... >: 使用 属性绑定div 上的 aria 属性绑定到 exerciseRunningDuration

  • <div ... [ngStyle]="{'width':(exerciseRunningDuration/currentExercise.duration) * 100 + '%'}">: 使用 指令 ngStyle 将进度条 div 上的 style 属性绑定到一个评估练习进度的表达式

呼吸!这里涉及了很多绑定。让我们更深入地了解绑定基础设施。

Angular 绑定基础设施

今天,大多数现代 JavaScript 框架都提供了强大的模型-视图绑定支持,Angular 也不例外。任何绑定基础设施的主要目标是减少开发者需要编写的用于保持模型和视图同步的样板代码。一个健壮的绑定基础设施总是声明性和简洁的。

Angular 绑定基础设施允许我们将模板(原始)HTML 转换为与模型数据绑定的实时视图。根据使用的绑定构造,数据可以双向流动和同步:从模型到视图和从视图到模型。

组件的模型与其视图之间的链接是通过 @Component 装饰器的 templatetemplateUrl 属性建立的。除了 script 标签外,几乎任何 HTML 片段都可以作为 Angular 绑定基础设施的模板。

要使这种绑定魔法生效,Angular 需要获取视图模板,编译它,将其链接到模型数据,并在无需任何自定义同步代码的情况下保持与模型更新的同步。

根据数据流方向,这些绑定可以分为三种类型:

  • 从模型到视图的单向绑定:在模型到视图绑定中,模型的变化与视图保持同步。插值、属性、属性、类和样式绑定属于此类。

  • 从视图到模型的单向绑定:在这个类别中,视图变化流向模型。事件绑定属于此类。

  • 双向/双向绑定:正如其名所示,双向绑定保持视图和模型同步。用于双向绑定的有一个特殊的绑定构造 ngModel,以及一些标准的 HTML 数据输入元素,如 inputselect 支持双向绑定。

让我们了解如何利用 Angular 的绑定能力来支持视图模板化。Angular 提供了以下绑定构造:

  • 插值

  • 属性绑定

  • 属性绑定

  • 类绑定

  • 样式绑定

  • 事件绑定

我们已经在第一章“入门”中提到了许多绑定功能,所以在这里我们力求减少重复,并建立在上一章获得的知识之上。

现在是学习所有这些绑定结构的好时机。插值是第一个。

插值

插值非常简单。插值符号({{ }})内的表达式(通常称为模板表达式)在模型(或组件类成员)的上下文中被评估,评估结果(字符串)被嵌入到 HTML 中。这是一个方便的框架结构,用于显示组件的数据/属性。我们在第一章“入门”中一直看到这些,也在我们刚刚添加的视图中看到。我们使用插值来渲染练习标题和剩余练习时间:

<h1>{{currentExercise.exercise.title}}</h1>
... 
<h1>Time Remaining: {{currentExercise.duration?-exerciseRunningDuration}}</h1> 

记住,插值同步模型变化与视图。插值是从模型到视图绑定的一种方式。

Angular 中的视图绑定始终在组件的作用域上下文中进行评估。

实际上,插值是属性绑定的一种特殊情况,它允许我们将任何 HTML 元素/组件属性绑定到模型。我们将很快讨论如何使用属性绑定语法编写插值。将插值视为属性绑定的语法糖。

属性绑定

如在第一章“入门”中讨论的那样,属性绑定使我们能够将原生 HTML/组件属性绑定到组件模型并保持它们同步(从模型到视图)。让我们从不同的角度来探讨属性绑定。

看看 7 分钟健身组件视图(workout-runner.component.html)的这段视图摘录:

<img class="img-responsive" [src]="'/static/images/' + currentExercise.exercise.image" /> 

看起来我们正在将imgsrc属性设置为在运行时评估的表达式。但我们真的是绑定到属性吗?或者这是一个属性?属性和属性是否不同?

在 Angular 领域,虽然前面的语法看起来像是在设置 HTML 元素的属性,但实际上它正在进行属性绑定。此外,由于我们很多人没有意识到 HTML 元素的属性和其属性之间的区别,这个声明非常令人困惑。因此,在我们查看属性绑定的工作方式之前,让我们先尝试理解元素属性和其属性之间的区别。

属性与属性的区别

选取任何 DOM 元素 API,你都会发现属性、属性、函数和事件。虽然事件和函数是自解释的,但理解属性和属性之间的区别却很困难。在日常使用中,我们经常互换使用这些词,这也没有太大帮助。以以下这段代码为例:

<input type="text" value="Awesome Angular"> 

当浏览器为这个输入文本框创建 DOM 元素(确切地说是 HTMLInputElement)时,它使用 input 上的 value 属性来设置 inputvalue 属性的初始状态为 Awesome Angular

在此初始化之后,对 inputvalue 属性的任何更改都不会反映在 value 属性上;属性始终是 Awesome Angular(除非再次明确设置)。这可以通过查询 input 状态来确认。

假设我们将 input 数据更改为 Angular rocks! 并查询 input 元素状态:

input.value // value property 

value 属性始终返回当前输入内容,即 Angular rocks!。而此 DOM API 函数:

input.getAttribute('value')  // value attribute 

返回 value 属性,并且始终是最初设置的 Awesome Angular

元素属性的主要作用是在创建相应的 DOM 对象时初始化元素的状态。

还有许多其他细微差别增加了这种混淆。以下是一些例子:

  • 属性和属性同步在属性之间并不一致。正如我们在前面的例子中所看到的,对 input 上的 value 属性的更改不会影响 value 属性,但这并不是所有属性值对的普遍情况。图像元素的 src 属性是这种情况的一个主要例子;属性或属性值的更改始终保持同步。

  • 令人惊讶的是,属性和属性之间的映射也不是一对一的。有许多属性没有任何后置属性(例如 innerHTML),也有属性在 DOM 上没有定义相应的属性(例如 colspan)。

  • 属性和属性映射也增加了这种混淆,因为它们并不遵循一个一致的模式。Angular 开发者指南中有一个很好的例子,我们将在这里逐字复制:

disabled 属性是另一个独特的例子。按钮的 disabled 属性默认为 false,因此按钮是启用的。当我们添加禁用属性时,其存在本身就会将按钮的 disabled 属性初始化为 true,从而使按钮被禁用。添加和删除禁用属性会启用和禁用按钮。属性值无关紧要,这就是为什么我们不能通过编写 <button disabled="false">仍被禁用</button> 来启用按钮。

这次讨论的目的是确保我们理解 DOM 元素的属性和属性之间的区别。这个新的思维模型将帮助我们继续探索框架的属性和属性绑定功能。让我们回到我们对属性绑定的讨论。

属性绑定继续...

现在我们已经了解了属性和属性之间的区别,让我们再次看看绑定示例:

<img class="img-responsive" [src]="'/static/images/' + currentExercise.exercise.image" /> 

使用 [propertName] 方括号语法将 img.src 属性绑定到 Angular 表达式。

属性绑定的通用语法如下:

[target]="sourceExpression"; 

在属性绑定的案例中,target 是 DOM 元素或组件上的一个属性。使用属性绑定,我们可以直接绑定到元素 DOM 上的任何属性。img 元素上的 src 属性就是我们使用的;这种绑定适用于任何 HTML 元素及其上的所有属性。

表达式目标也可以是一个事件,正如我们将在探索事件绑定时很快看到的。

绑定源和目标理解 Angular 绑定中的源和目标之间的区别很重要。出现在 [] 内部的属性是一个目标,有时被称为绑定目标。目标是数据的消费者,始终指向组件/元素上的一个属性。表达式构成了为目标提供数据的源。

在运行时,表达式在组件的/元素的属性上下文中被评估(在前面的案例中是 WorkoutRunnerComponent.currentExercise.exercise.image 属性)。

总是要记得在目标周围添加方括号 []。如果我们不这样做,Angular 会将表达式视为一个字符串常量,并且目标简单地被分配了字符串值。

属性绑定、事件绑定和属性绑定都不使用插值符号。以下是不合法的:[src]="{{'/static/images/' + currentExercise.exercise.image}}"

如果你曾经使用过 AngularJS,属性绑定与事件绑定一起使用可以让 Angular 摆脱许多指令,例如 ng-disableng-srcng-key*ng-mouse* 以及一些其他指令。

从数据绑定的角度来看,Angular 以与原生元素相同的方式处理组件。因此,属性绑定也适用于组件属性!组件可以定义输入输出属性,这些属性可以绑定到视图,例如:

<workout-runner [exerciseRestDuration]="restDuration"></workout-runner> 
exerciseRestDuration property on the WorkoutRunnerComponent class to the restDuration property defined on the container component (parent), allowing us to pass the rest duration as a parameter to the WorkoutRunnerComponent. As we enhance our app and develop new components, you will learn how to define custom properties and events on a component.

我们可以使用 bind- 语法启用属性绑定,这是属性绑定的规范形式。这意味着 [src]="'/assets/images/' + currentExercise.exercise.image" 等同于以下:bind-src="img/' + currentExercise.exercise.image"

属性绑定,就像插值一样,是单向的,从组件/元素源到视图。模型数据的变化与视图保持同步。

我们刚刚创建的模板视图中只有一个属性绑定(在 [src] 上)。其他带有方括号的绑定不是属性绑定。我们将在稍后介绍它们。

插值是属性绑定的语法糖

我们通过将插值描述为属性绑定的语法糖来结束关于插值的章节。目的是强调两者可以互换使用。插值语法比属性绑定更简洁,因此非常有用。这就是 Angular 解释插值的方式:

<h3>Main heading - {{heading}}</h3> 
<h3 [text-content]="' Main heading - '+ heading"></h3>

Angular 将第一个语句中的插值转换为 textContent 属性绑定(第二个语句)。

插值可以在你想象不到的更多地方使用。以下示例对比了使用插值和属性绑定执行相同绑定的例子:

<img [src]="'/assets/images/' + currentExercise.exercise.image" />
<img src="img/{{currentExercise.exercise.image}}" />      // interpolation on attribute

<span [text-content]="helpText"></span>
<span>{{helpText}}</span>

虽然属性绑定(和插值)使我们能够轻松地将任何表达式绑定到目标属性,但我们应该小心使用的表达式。Angular 的变更检测系统将在应用的整个生命周期中多次评估你的表达式绑定,只要我们的组件是活跃的。因此,在将表达式绑定到属性目标时,请记住以下两个准则。

快速表达式评估

属性绑定表达式应该快速评估。慢速表达式评估会杀死你的应用性能。这种情况发生在执行 CPU 密集型工作的函数是表达式的一部分时。考虑这个绑定:

<div>{{doLotsOfWork()}}</div> 

Angular 每次执行变更检测运行时都会评估前面的doLotsOfWork()表达式。这些变更检测运行比我们想象的要频繁,并且基于一些内部启发式算法,因此我们使用的表达式必须快速评估。

无副作用绑定表达式

如果在绑定表达式中使用函数,则该函数应该是无副作用的。考虑另一个绑定:

<div [innerHTML]="getContent()"></div> 

以及其底层函数,getContent

getContent() { 
  var content=buildContent(); 
  this.timesContentRequested +=1; 
  return content; 
} 

getContent调用通过每次调用时更新timesContentRequested属性来改变组件的状态。如果这个属性在如下视图中使用:

<div>{{timesContentRequested}}</div> 

Angular 会抛出如下错误:

Expression '{{getContent()}}' in AppComponent@0:4' has changed after it was checked. Previous value: '1'. Current value: '2'

Angular 框架以两种模式运行,开发模式和产品模式。如果我们启用了应用程序的产品模式,前面的错误就不会显示。查看框架文档bit.ly/enableProdMode以获取更多详细信息。

核心问题是你在属性绑定中使用的表达式应该是无副作用的。

现在我们来看一个有趣的东西,[ngStyle],它看起来像属性绑定,但实际上不是。在[]中指定的目标不是一个组件/元素属性(div没有ngStyle属性),而是一个指令。

需要介绍两个新概念,目标选择指令

Angular 指令

作为一款框架,Angular 试图增强 HTML DSL(即领域特定语言):

  • 在 HTML 中使用自定义标签如<abe-workout-runner></abe-workout-runner>(不是标准 HTML 结构的一部分)来引用组件。这突出了第一个扩展点。

  • 使用[]()进行属性和事件绑定定义了第二个。

  • 然后是指令,这是第三个扩展点,它们进一步分为属性指令结构指令,以及组件(组件也是指令!)。

虽然组件自带视图,但属性指令旨在增强现有元素/组件的外观和/或行为。

结构指令没有自己的视图;它们改变它们所应用元素的 DOM 布局。我们将在本章后面部分用完整章节来理解这些结构指令。

workout-runner视图中使用的ngStyle指令实际上是一个属性指令:

<div class="progress-bar" role="progressbar"  
 [ngStyle] = "{'width':(exerciseRunningDuration/currentExercise.duration) * 100 + '%'}"></div>  

ngStyle指令没有自己的视图;相反,它允许我们通过绑定表达式在 HTML 元素上设置多个样式(在这种情况下是width)。我们将在本书的后面部分介绍许多框架属性指令。

指令命名法

指令是一个总称,用于指代组件指令(也称为组件)、属性指令和结构指令。在本书中,当我们使用“指令”一词时,我们将根据上下文指代属性指令或结构指令。组件指令始终被称为组件。

在对 Angular 具有基本指令类型理解的基础上,我们可以理解绑定目标选择的过程。

绑定目标选择

[]中指定的目标不仅限于组件/元素属性。虽然属性名是一个常见的目标,但 Angular 模板引擎实际上会进行启发式搜索以决定目标类型。Angular 首先搜索具有匹配选择器的已注册已知指令(属性或结构),然后再寻找与目标表达式匹配的属性。考虑以下视图片段:

<div [ngStyle]='expression'></div> 

目标搜索从框架查看所有具有匹配选择器(ngStyle)的内部和自定义指令开始。由于 Angular 已经有一个NgStyle指令,因此它成为目标(指令类名为NgStyle,而选择器为ngStyle)。如果 Angular 没有内置的NgStyle指令,绑定引擎就会在底层组件上查找名为ngStyle的属性。

如果没有匹配目标表达式的内容,则会抛出未知指令错误.

这样就完成了我们对目标选择的讨论。下一节将关于属性绑定。

属性绑定

在 Angular 中,属性绑定存在的唯一原因是存在一些 HTML 属性没有对应的 DOM 属性。colspanaria属性就是一些没有对应属性的属性的例子。我们视图中的进度条 div 使用了属性绑定。

如果属性指令还在让你感到困惑,我无法责怪你,这可能会变得有点复杂。从根本上说,它们是不同的。属性指令(如[ngStyle])会改变 DOM 元素的外观或行为,正如其名称所暗示的那样,它们是指令。任何 HTML 元素上都没有名为ngStyle的属性或属性。另一方面,属性绑定完全是关于绑定到没有 DOM 属性支持的 HTML 属性。

7 分钟锻炼 在两个地方使用了属性绑定,[attr.aria-valuenow][attr.aria-valuemax]。我们可能会问一个问题:我们可以使用标准插值语法来设置属性吗?不,这不起作用!让我们试试:打开 workout-runner.component.html 并将两个被突出显示的 aria 属性 attr.aria-valuenowattr.aria-valuemax 包围在 [] 中替换为以下代码:

<div class="progress-bar" role="progressbar"  
    aria-valuenow = "{{exerciseRunningDuration}}"  
    aria-valuemin="0"  
    aria-valuemax= "{{currentExercise.duration}}"  ...> </div> 

保存视图,如果应用没有运行,请运行它。此错误将在浏览器控制台中弹出:

Can't bind to 'ariaValuenow' since it isn't a known native property in WorkoutRunnerComponent ... 

Angular 正在尝试在不存在 ariaValuenow 属性的 div 中查找一个名为 ariaValuenow 的属性!记住,插值实际上是属性绑定。

我们希望这能说明问题:要绑定到 HTML 属性,请使用属性绑定。

Angular 默认绑定到属性,而不是绑定到属性。

为了支持属性绑定,Angular 在 [] 内使用前缀表示法 attr。属性绑定看起来如下所示:

[attr.attribute-name]="expression" 

恢复原始的 aria 设置以使属性绑定生效:

<div ... [attr.aria-valuenow]="exerciseRunningDuration" 
    [attr.aria-valuemax]="currentExercise.duration" ...> 

记住,除非显式附加了 attr. 前缀,否则属性绑定不会生效。

尽管我们在我们的锻炼视图中没有使用样式和基于类的绑定,但这些是一些可能派上用场的绑定能力。因此,它们值得探索。

样式和类绑定

我们使用 类绑定 根据组件状态设置和移除特定的类,如下所示:

[class.class-name]="expression" 

expressiontrue 时添加 class-name,当它为 false 时移除它。一个简单的例子可以如下所示:

<div [class.highlight]="isPreferred">Jim</div> // Toggles the highlight class 

使用样式绑定根据组件状态设置内联样式:

[style.style-name]="expression";

虽然我们已经使用了 ngStyle 指令来处理锻炼视图,但我们也可以轻松地使用样式绑定,因为我们只处理一个样式。使用样式绑定,相同的 ngStyle 表达式将变为以下内容:

[style.width.%]="(exerciseRunningDuration/currentExercise.duration) * 100" 

width 是一个样式,因为它也接受单位,所以我们扩展我们的目标表达式以包括 % 符号。

记住,style.class. 是设置单个类或样式的便捷绑定。为了获得更多灵活性,有相应的属性指令:ngClassngStyle

在本章的早期部分,我们正式介绍了指令及其分类。其中一种指令类型,属性指令(再次提醒,不要与我们在上一节中介绍的属性绑定混淆)将是下一节关注的焦点。

属性指令

属性指令是 HTML 扩展,可以改变组件/元素的外观、感觉或行为。如 Angular 指令部分所述,这些指令不定义自己的视图。

除了 ngStylengClass 指令之外,还有一些属性指令是核心框架的一部分。ngValuengModelngSelectOptionsngControlngFormControl 是 Angular 提供的一些属性指令。

由于 7 分钟健身法 使用了 ngStyle 指令,因此深入了解这个指令及其紧密相关的 ngClass 是明智的。

虽然下一节是关于学习如何使用 ngClassngStyle 属性指令,但我们直到第六章 Angular 指令深入 才学习如何创建自己的属性指令。

使用 ngClass 和 ngStyle 样式化 HTML

Angular 有两个优秀的指令,允许我们动态地为任何元素设置样式并切换 CSS 类。对于 Bootstrap 进度条,我们使用 ngStyle 指令动态设置元素的样式,width,随着练习的进行:

<div class="progress-bar" role="progressbar" ... 
    [ngStyle]="{'width':(exerciseRunningDuration/currentExercise.duration) * 100 + '%'}"> </div> 

ngStyle 允许我们一次性将一个或多个样式绑定到组件的属性上。它接受一个对象作为参数。对象上的每个属性名是样式名,值是绑定到该属性的 Angular 表达式,如下例所示:

<div [ngStyle]= "{ 
'width':componentWidth,  
'height':componentHeight,  
'font-size': 'larger',  
'font-weight': ifRequired ? 'bold': 'normal' }"></div> 

样式不仅可以绑定到组件属性(componentWidthcomponentHeight),还可以设置为常量值('larger')。表达式解析器还允许使用三元运算符(?:);查看 isRequired

如果在 HTML 中样式变得难以管理,我们也有选择在组件中编写一个函数,该函数返回对象哈希,并将其设置为表达式:

<div [ngStyle]= "getStyles()"></div> 

此外,组件上的 getStyles 看起来如下:

getStyles () { 
    return { 
      'width':componentWidth, 
      ... 
    } 
} 

ngClass 的工作原理也类似,只不过它用于切换一个或多个类。例如,查看以下代码:

<div [ngClass]= "{'required':inputRequired, 'email':whenEmail}"></div> 

inputRequiredtrue 时应用 required 类,当它评估为 false 时移除。

指令(自定义或平台)就像任何其他 Angular 艺术品一样,始终属于一个模块。要跨模块使用它们,模块需要被导入。想知道 ngStyle 在哪里定义吗?ngStyle 是核心框架模块 CommonModule 的一部分,并在 workout-runner.module.ts 模块定义中导入。CommonModule 定义了多个有用的指令,这些指令在 Angular 中被广泛使用。

好吧!这就涵盖了我们需要了解的关于我们新开发视图的所有内容。

如前所述,如果您在运行代码时遇到问题,请查看 Git 分支 checkpoint2.2。如果不使用 Git,请从 bit.ly/ng2be-checkpoint2-2 下载 checkpoint2.2 的快照(ZIP 文件)。在首次设置快照时,请参考 trainer 文件夹中的 README.md 文件。

是时候添加一些增强功能并学习更多关于框架的知识了!

了解更多关于练习的信息

对于第一次做这个练习的人来说,详细说明每个练习涉及的步骤会很好。我们还可以为每个练习添加一些 YouTube 视频的引用,以帮助用户更好地理解练习。

我们将在左侧面板添加练习描述和说明,并将其称为描述面板。我们还将添加 YouTube 视频的引用,这是视频播放面板。为了使事情更加模块化并学习一些新概念,我们将为每个描述面板和视频面板创建独立的组件。

此模型的已有数据。Exercise类中的descriptionprocedure属性(见model.ts)提供了关于练习的必要细节。videos数组包含一些相关的 YouTube 视频 ID,这些视频将被用于获取这些视频。

添加描述和视频面板

一个 Angular 应用不过是一个组件的层次结构,类似于树结构。到目前为止,7 Minute Workout有两个组件,根组件AppComponent及其子组件WorkoutRunnerComponent,与 HTML 组件布局一致,现在看起来如下所示:

<abe-root>
    ...
    <abe-workout-runner>...</abe-workout-runner>
</abe-root>

运行应用并查看源代码以验证此层次结构。随着我们在应用程序中添加更多组件以实现新功能,此组件树会增长并分支。

我们将在WorkoutRunnerComponent中添加两个子组件,每个子组件分别支持练习描述和练习视频。虽然我们可以在WorkoutRunnerComponent视图中直接添加一些 HTML,但我们在这里希望学习更多关于跨组件通信的知识。让我们从添加左侧的描述面板开始,并了解组件如何接受输入。

带有输入的组件

导航到workour-runner文件夹,生成一个模板练习描述组件:

ng generate component exercise-description -is

在生成的exercise-description.component.ts文件中,添加突出显示的代码:

import { Component, OnInit, Input } from '@angular/core';
...
export class ExerciseDescriptionComponent { 
 @Input() description: string; 
  @Input() steps: string; } 

@Input装饰器表示组件属性可用于数据绑定。在我们深入研究@Input装饰器之前,让我们完成视图并将其与WorkoutRunnerComponent集成。

从 Git 分支checkpoint2.3中的workout-runner/exercise-description文件夹(GitHub 位置:bit.ly/ng6be-2-3-exercise-description-component-html)复制练习描述的视图定义,exercise-description.component.html。查看突出显示的练习描述 HTML:

<div class="card-body">
    <div class="card-text">{{description}}</div>
</div> 
...  
<div class="card-text">
    {{steps}}
</div> 

前面的插值引用了ExerciseDescriptionComponent的输入属性:descriptionsteps

组件定义已完成。现在,我们只需在WorkoutRunnerComponent中引用ExerciseDescriptionComponent,并为ExerciseDescriptionComponent视图提供descriptionsteps的值,以便正确渲染。

打开workout-runner.component.html,并更新以下代码中突出显示的 HTML 片段。在exercise-pane div 之前添加一个新的 div,名为description-panel,并对exercise-pane div 进行调整一些样式,如下所示:

<div class="row">
    <div id="description-panel" class="col-sm-3">
 <abe-exercise-description 
            [description]="currentExercise.exercise.description"
 [steps]="currentExercise.exercise.procedure"></abe-exercise-description>
 </div>
   <div id="exercise-pane" class="col-sm-6">  
   ... 

如果应用程序正在运行,描述面板应显示在左侧,并显示相关的练习详情。

WorkoutRunnerComponent能够使用ExerciseDescriptionComponent是因为它已经在WorkoutRunnerModule上声明过(参见workout-runner.module.ts声明属性)。Angular CLI 组件生成器为我们完成了这项工作。

回顾前一个视图中的abe-exercise-description声明。我们以与本章早期使用 HTML 元素属性相同的方式引用descriptionsteps属性(<img [src]='expression' ...>)。简单、直观且非常优雅!

Angular 数据绑定基础设施确保每当WorkoutRunnerComponent上的currentExercise.exercise.descriptioncurrentExercise.exercise.procedure属性发生变化时,ExerciseDescriptionComponentdescriptionsteps上的绑定属性也会更新。

@Input装饰器可以接受一个属性别名作为参数,这意味着以下内容:考虑一个属性声明,例如:@Input("myAwesomeProperty") myProperty:string。它可以在视图中如下引用:<my-component [myAwesomeProperty]="expression"....>

Angular 绑定基础设施的力量允许我们通过将其附加@Input装饰器(以及@Output)来使用任何组件属性作为可绑定属性。我们不仅限于基本数据类型,如stringnumberboolean;还可以是复杂对象,我们将在添加视频播放器时看到这一点:

@Input装饰器也可以应用于复杂对象。

workout-runner目录下生成一个新的组件用于视频播放器:

ng generate component video-player -is

通过从video-player.component.tsvideo-player.component.html(位于 Git 分支checkpoint2.3中的trainer/src/components/workout-runner/video-player文件夹,GitHub 位置:bit.ly/ng6be-2-3-video-player)复制实现来更新生成的模板代码。

让我们看看视频播放器的实现。打开video-player.component.ts并查看VideoPlayerComponent类:

export class VideoPlayerComponent implements OnInit, OnChanges { 
  private youtubeUrlPrefix = '//www.youtube.com/embed/'; 

  @Input() videos: Array<string>; 
  safeVideoUrls: Array<SafeResourceUrl>; 

  constructor(private sanitizer: DomSanitizationService) { } 

  ngOnChanges() { 
    this.safeVideoUrls = this.videos ? 
        this.videos 
            .map(v => this.sanitizer.bypassSecurityTrustResourceUrl(this.youtubeUrlPrefix + v)) 
    : this.videos; 
  } 
} 

此处的videos输入属性接受一个字符串数组(YouTube 视频代码)。虽然我们将videos数组作为输入,但我们不会直接在视频播放器视图中使用此数组;相反,我们将输入数组转换成一个新的safeVideoUrls数组并绑定它。这可以通过查看视图实现来确认:

<div *ngFor="let video of safeVideoUrls"> 
   <iframe width="198" height="132" [src]="video" frameborder="0" allowfullscreen></iframe> 
</div> 

视图还使用了一个新的 Angular 指令ngFor来绑定到safeVideoUrls数组。ngFor指令属于一类称为结构指令的指令。该指令的职责是接受一个 HTML 片段,并根据绑定集合中的元素数量重新生成它。

如果你对于 ngFor 指令如何与 safeVideoUrls 一起工作以及为什么我们需要生成 safeVideoUrls 而不是使用 videos 输入数组感到困惑,请稍等,我们很快就会解答这些问题。但首先,让我们完成 VideoPlayerComponentWorkoutRunnerComponent 的集成,以查看最终结果。

通过在 exercise-pane div 后添加组件声明来更新 WorkoutRunnerComponent 视图:

<div id="video-panel" class="col-sm-3">
    <abe-video-player [videos]="currentExercise.exercise.videos"></abe-video-player>
</div> 

VideoPlayerComponentvideos 属性绑定到练习的视频集合。

启动/刷新应用程序,视频缩略图应该显示在右侧。

如果你在运行代码时遇到问题,请查看 Git 分支 checkpoint2.3 以获取我们迄今为止所做工作的有效版本。您还可以从 bit.ly/ng6be-checkpoint-2-3 下载 checkpoint2.3 的快照(ZIP 文件)。在首次设置快照时,请参考 trainer 文件夹中的 README.md 文件。

现在,是时候回顾 VideoPlayerComponent 的实现部分了。我们特别需要理解:

  • ngFor 指令是如何工作的

  • 为什么需要将输入 videos 数组转换为 safeVideoUrls

  • Angular 组件生命周期事件 OnChanges(在视频播放器中使用)的重要性

首先,是时候正式介绍 ngFor 以及它所属的指令类别:结构化指令类别了。

结构化指令

指令的第三种分类,结构化指令,通过操作组件/元素来操纵它们的布局。

Angular 文档以简洁的方式描述了结构化指令:

"与组件指令定义和控制视图,或者像属性指令一样修改元素的外观和行为不同,结构化指令通过添加和删除整个元素子树来操纵布局。"

由于我们已经讨论了组件指令(如 workout-runnerexercise-description)和属性指令(如 ngClassngStyle),我们可以很好地将它们的行为与结构化指令进行对比。

ngFor 指令属于此类。我们可以通过 * 前缀轻松识别此类指令。除了 ngFor 之外,Angular 还提供了一些其他结构化指令,例如 ngIfngSwitch

非常有用的 NgForOf

每种模板语言都有允许模板引擎生成 HTML(通过重复)的构造。Angular 有 NgForOfNgForOf 指令是一个非常实用的指令,用于将 HTML 片段重复 n 次以上。让我们再次看看我们在视频播放器中是如何使用 NgForOf 的:

<div *ngFor="let video of safeVideoUrls"> 
   <iframe width="198" height="132" [src]="video" frameborder="0" allowfullscreen></iframe> 
</div>

NgForOf 的指令选择器为 {selector: '[ngFor][ngForOf]'},因此我们可以在视图模板中使用 ngForngForOf。我们有时也把此指令称为 ngFor

前面的代码会为每个练习视频(使用 safeVideoUrls 数组)重复 div 片段。let video of safeVideoUrls 字符串表达式被解释如下:取 safeVideoUrls 数组中的每个视频并将其分配给模板输入变量 video

现在,这个输入变量可以在 ngFor 模板 HTML 中被引用,就像我们设置 src 属性绑定时做的那样。

有趣的是,分配给 ngFor 指令的字符串不是一个典型的 Angular 表达式。相反,它是一个 微语法——一种微语言,Angular 引擎可以解析。

你可以在 Angular 的开发者指南中了解更多关于微语法的知识:bit.ly/ng6be-micro-syntax

这种微语法公开了一些迭代上下文属性,我们可以将它们分配给模板输入变量并在 ngFor HTML 块中使用它们。

其中一个例子是 indexindex 在每次迭代中从 0 增加到数组的长度,类似于任何编程语言中的 for 循环。以下示例显示了如何捕获它:

<div *ngFor="let video of videos; let i=index"> 
     <div>This is video - {{i}}</div> 
</div> 

除了 index 之外,还有一些其他的迭代上下文变量;这些包括 firstlastevenodd。这些上下文数据允许我们做一些巧妙的事情。考虑以下示例:

<div *ngFor="let video of videos; let i=index; let f=first"> 
     <div [class.special]="f">This is video - {{i}}</div> 
</div> 

它将一个 特殊 类应用到第一个视频 div 上。

NgForOf 指令可以应用于 HTML 元素以及我们的自定义组件。这是 NgForOf 的有效用法:

<user-profile *ngFor="let userDetail of users" [user]= "userDetail"></user-profile>

总是要记得在 ngFor(以及其他结构化指令)之前添加一个星号(*)。* 有其重要性。

结构化指令中的星号(*)

* 前缀是一种更简洁的结构化指令表示格式。例如,视频播放器使用 ngFor 的用法。ngFor 模板:

<div *ngFor="let video of safeVideoUrls"> 
   <iframe width="198" height="132" [src]="video" frameborder="0" allowfullscreen></iframe> 
</div>

实际上扩展为以下内容:

<ng-template ngFor let-video [ngForOf]="safeVideoUrls">  
    <div>
        <iframe width="198" height="132"  [src]="video" ...></iframe>  
    </div> 
</ng-template>  

ng-template 标签是一个 Angular 元素,它声明了 ngFor,一个模板输入变量(video),以及一个属性(ngForOf),该属性指向 safeVideoUrls 数组。前两个声明都是 ngFor 的有效用法。

不确定你是否这样,但我更喜欢 ngFor 的简洁格式!

NgForOf 性能

由于 NgForOf 根据集合元素生成 HTML,因此它因导致性能问题而臭名昭著。但我们不能责怪这个指令。它做了它应该做的事情:迭代并生成元素!如果底层集合很大,UI 渲染可能会受到性能影响,尤其是如果集合变化过于频繁。对变化集合不断销毁和创建元素的成本可能会迅速变得难以承受。

NgForOf 的性能调整之一允许我们改变 ngForOf 在创建和销毁 DOM 元素(当底层集合元素被添加或删除时)时的行为。

想象一个场景,我们经常从服务器获取一个对象数组,并使用 NgForOf 将其绑定到视图中。NgForOf 的默认行为是在每次刷新列表时重新生成 DOM(因为 Angular 进行标准的对象相等性检查)。然而,作为开发者,我们可能非常清楚变化不大。可能添加了一些新对象,删除了一些,也许还有一些被修改了。但 Angular 只是重新生成了完整的 DOM。

为了缓解这种情况,Angular 允许我们指定一个自定义的跟踪函数,这样 Angular 就知道当比较的两个对象相等时。看看下面的函数:

trackByUserId(index: number, hero: User) { return user.id; } 

可以使用这样的函数在 NgForOf 模板中告诉 Angular 根据其 id 属性比较 user 对象,而不是进行引用相等性检查。

这就是我们如何在 NgForOf 模板中使用前面提到的函数:

<div *ngFor="let user of users; trackBy: trackByUserId">{{user.name}}</div> 

NgForOf 现在将避免为已经渲染的具有 ID 的用户重新创建 DOM。

记住,如果用户的绑定属性已更改,Angular 可能仍然会更新现有的 DOM 元素。

关于 ngFor 指令就讲到这里;让我们继续前进。

我们仍然需要理解 safeVideoUrlsOnChange 生命周期事件在 VideoPlayerComponent 实现中的作用。让我们先解决前者,并理解 safeVideoUrls 的必要性。

Angular 安全性

通过尝试 videos 数组,我们可以最容易地理解为什么我们需要绑定到 safeVideoUrls 而不是 videos 输入属性。用以下内容替换现有的 ngFor 片段 HTML:

<div *ngFor="let video of videos"> 
    <iframe width="198" height="132"  
        [src]="'//www.youtube.com/embed/' + video"  frameborder="0" allowfullscreen></iframe> 
</div>

然后看看浏览器的控制台日志(可能需要刷新页面)。框架抛出了一大堆错误,例如:

Error: 在资源 URL 上下文中使用了不安全的值(请参阅 http://g.co/ng/security#xss)

不用猜测正在发生什么!Angular 正在尝试保护我们的应用程序免受 跨站脚本攻击(XSS)。

这种攻击使攻击者能够将恶意代码注入我们的网页中。一旦注入,恶意代码可以读取当前站点上下文中的数据。这使得它能够窃取机密数据,并冒充已登录的用户,从而获得对受保护资源的访问权限。

Angular 已经被设计用来通过清理任何注入到 Angular 视图中的外部代码/脚本来阻止这些攻击。记住,内容可以通过多种机制注入到视图中,包括属性/属性/样式绑定或插值。

考虑一个通过组件模型将 HTML 标记绑定到 HTML 元素的 innerHTML 属性(属性绑定)的例子:

this.htmlContent = '<span>HTML content.</span>'    // Component

<div [innerHTML]="htmlContent"> <!-- View -->

当 HTML 内容被发出时,任何不安全的内容(如 script)如果存在,将被移除。

但关于 Iframes 呢?在我们前面的例子中,Angular 也阻止了对 Iframe 的 src 属性进行属性绑定。这是对使用 Iframe 在我们自己的网站上嵌入第三方内容的警告。Angular 也阻止了这种行为。

总的来说,该框架定义了四个关于内容清理的安全上下文。这些包括:

  1. HTML 内容清理,当使用 innerHTML 属性绑定 HTML 内容时

  2. 样式清理,当将 CSS 绑定到 style 属性时

  3. URL 清理,当使用 anchorimg 等标签时

  4. 资源清理,当使用 Iframesscript 标签时;在这种情况下,内容无法清理,因此默认情况下会被阻止

Angular 正尽力让我们远离危险。但有时,我们知道内容是安全的,因此想要绕过默认的清理行为。

信任安全内容

为了让 Angular 知道正在绑定的内容是安全的,我们使用 DomSanitizer 并根据刚才描述的安全上下文调用适当的方法。可用的函数如下:

  • bypassSecurityTrustHtml

  • bypassSecurityTrustScript

  • bypassSecurityTrustStyle

  • bypassSecurityTrustUrl

  • bypassSecurityTrustResourceUrl

在我们的视频播放器实现中,我们使用 bypassSecurityTrustResourceUrl;它将视频 URL 转换为可信的 SafeResourceUrl 对象:

this.videos.map(v => this.sanitizer.bypassSecurityTrustResourceUrl(this.youtubeUrlPrefix + v)) 

map 方法将视频数组转换为 SafeResourceUrl 对象集合,并将其分配给 safeVideoUrls

列出的每个方法都接受一个字符串参数。这是我们希望 Angular 知道是安全的内容。返回的对象可以是 SafeStyleSafeHtmlSafeScriptSafeUrlSafeResourceUrl 中的任何一个,然后可以将其绑定到视图中。

有关这个主题的全面介绍可以在框架安全指南中找到,该指南可在 bit.ly/ng6be-security 获取。强烈推荐阅读!

最后一个问题是要回答为什么在 OnChanges Angular 生命周期事件中这样做?

OnChange 生命周期事件

OnChanges 生命周期事件在组件的输入(s)更改时触发。对于 VideoPlayerComponent 来说,当加载新的练习时,videos 数组输入属性会更改。我们使用这个生命周期事件来重新创建 safeVideoUrls 数组并将其重新绑定到视图中。简单!

视频面板实现现已完成。让我们添加一些更多的小增强,并在 Angular 中进一步探索它。

使用 innerHTML 绑定格式化练习步骤

当前应用程序的一个痛点是练习步骤的格式化。这些步骤读起来有点困难。

步骤应该有换行符 (<br>) 或格式化为 HTML list,以便易于阅读。这似乎是一个简单的任务,我们可以直接更改绑定到步骤插值的绑定数据,或者编写一个管道,使用行分隔约定(.)添加一些 HTML 格式化。为了快速验证,让我们通过在 workout-runner.component.ts 中的第一个练习步骤后添加一个换行符(<br>)来更新它:

`Assume an erect position, with feet together and arms at your side. <br> 
 Slightly bend your knees, and propel yourself a few inches into the air. <br> 
 While in air, bring your legs out to the side about shoulder width or slightly wider. <br> 
 ... 

当锻炼重新开始时,看看第一个锻炼步骤。输出与我们的预期不符,如下所示:

换行标签在浏览器中被直接渲染。Angular 没有将插值作为 HTML 渲染;相反,它转义了 HTML 字符,我们知道为什么,安全!

如何修复它?很简单!将插值替换为属性绑定,将步骤数据绑定到元素的innerHTML属性(在exercise-description.html中),然后你就完成了!

<div class="card-text" [innerHTML]="steps"> 

刷新锻炼页面以确认。

防止跨站脚本(XSS)安全问题

通过使用innerHTML,我们指示 Angular 不要转义 HTML,但 Angular 仍然会像前面安全部分中描述的那样对输入 HTML 进行清理。它会移除如<script>标签和其他 JavaScript,以防止 XSS 攻击。如果你想在 HTML 中动态注入样式/脚本,请使用DomSanitizer来绕过这个清理检查。

是时候进行另一个增强了!现在是学习 Angular 管道的时候了。

使用管道显示剩余锻炼时长

如果我们能在锻炼过程中告诉用户剩余时间而不是仅仅显示正在进行的锻炼时长,那就太好了。我们可以在锻炼面板中添加一个倒计时计时器来显示剩余的总时间。

我们在这里将要采取的方法是定义一个名为workoutTimeRemaining的组件属性。这个属性将在锻炼开始时初始化为总时间,并且每过一秒就会减少,直到达到零。由于workoutTimeRemaining是一个数值,但我们想以hh:mm:ss格式显示计时器,我们需要在秒数据和时间格式之间进行转换。Angular 管道是实现此功能的一个很好的选择。

Angular 管道

管道的主要目的是格式化视图中显示的数据。管道允许我们将这种内容转换逻辑(格式化)封装为一个可重用的元素。框架本身提供了多个预定义的管道,例如datecurrencylowercaseuppercaseslice等。

这就是我们如何在视图中使用管道。

{{expression | pipeName:inputParam1}} 

表达式后面跟着管道符号(|),然后是管道名称,然后是一个可选的参数(inputParam1),参数之间用冒号(:)分隔。如果管道需要多个输入,它们可以一个接一个地放置,用冒号分隔,例如内置的slice管道,它可以切割数组或字符串:

{{fullName | slice:0:20}} //renders first 20 characters  

传递给管道的参数可以是一个常量或组件属性,这意味着我们可以使用管道参数的模板表达式。请看以下示例:

{{fullName | slice:0:truncateAt}} //renders based on value truncateAt 

这里有一些使用date管道的示例,如 Angular date文档中所述。假设dateObj初始化为2015 年 6 月 15 日,时间为21:43:11,地区为en-US

{{ dateObj | date }}               // output is 'Jun 15, 2015        ' 
{{ dateObj | date:'medium' }}      // output is 'Jun 15, 2015, 9:43:11 PM' 
{{ dateObj | date:'shortTime' }}   // output is '9:43 PM            ' 
{{ dateObj | date:'mmss' }}        // output is '43:11'     

最常用的管道如下:

  • 日期(date):正如我们刚才看到的,日期过滤器用于以特定方式格式化日期。这个过滤器支持相当多的格式,并且也是区域感知的。要了解日期管道支持的其他格式,请查看框架文档,网址为bit.ly/ng2-date

  • 大写(uppercase)小写(lowercase):这两个管道,正如其名所示,会改变字符串输入的大小写。

  • 小数(decimal)百分比(percent)decimalpercent管道用于根据当前浏览器区域设置格式化小数和百分比值。

  • 货币(currency):这用于根据当前浏览器区域设置将数值格式化为货币:

 {{14.22|currency:"USD" }} <!-Renders USD 14.22 --> 
    {{14.22|currency:"USD":'symbol'}}  <!-Renders $14.22 -->
  • JSON:这是一个方便的管道,用于调试,可以将任何输入转换为字符串,使用JSON.stringify。我们在本章开头就很好地使用了它来渲染WorkoutPlan对象(参见 Checkpoint 2.1 代码)。

  • 切片(slice):这个管道允许我们将列表或字符串值分割成更小的、经过裁剪的列表/字符串。我们已经在前面的代码中看到了一个例子。

我们不会详细介绍前面的管道。从开发角度来看,只要我们知道有哪些管道以及它们有什么用途,我们就可以始终参考平台文档以获取确切的使用说明。

管道链

管道的强大功能之一是它们可以被链式调用,其中一个管道的输出可以作为另一个管道的输入。考虑以下示例:

{{fullName | slice:0:20 | uppercase}} 

第一个管道将fullName的前 20 个字符切片,第二个管道将它们转换为大写。

现在我们已经看到了管道是什么以及如何使用它们,为什么不实现一个用于7 分钟锻炼应用的管道:一个秒到时间管道?

实现自定义管道 - SecondsToTimePipe

SecondsToTimePipe,正如其名所示,应该将数值转换为hh:mm:ss格式。

workout-runner文件夹中创建一个名为shared的文件夹,并从共享文件夹中调用以下 CLI 命令以生成管道模板:

ng generate pipe seconds-to-time

shared文件夹已经创建,用于添加在workout-runner模块中可以使用的公共组件/指令/管道。这是我们遵循的将共享代码组织在不同级别的约定。在未来,我们可以在应用模块级别创建一个共享文件夹,其中包含全局共享的工件。实际上,如果需要将时间到秒管道用于其他应用模块,也可以将其移动到应用模块中。

将以下transform函数实现复制到seconds-to-time.pipe.ts文件中(定义也可以从 GitHub 网站上的 Git 分支checkpoint.2.4下载:bit.ly/nng6be-2-4-seconds-to-time-pipe-ts):

export class SecondsToTimePipe implements PipeTransform { 
  transform(value: number): any { 
    if (!isNaN(value)) { 
      const hours = Math.floor(value / 3600);
      const minutes = Math.floor((value - (hours * 3600)) / 60);
      const seconds = value - (hours * 3600) - (minutes * 60);

      return ('0' + hours).substr(-2) + ':'
        + ('0' + minutes).substr(-2) + ':'
        + ('0' + seconds).substr(-2);
    } 
    return; 
  } 
} 

在 Angular 管道中,实现逻辑放入 transform 函数中。作为 PipeTransform 接口的一部分定义,前面的 transform 函数将输入的秒数值转换成 hh:mm:ss 字符串。transform 函数的第一个参数是管道输入。后续的参数(如果提供),是传递给管道的参数,使用冒号分隔符(pipe:argument1:arugment2..)从视图中传递。

对于 SecondsToTimePipe,虽然 Angular CLI 生成了一个样板参数(args?:any),但我们没有使用任何管道参数,因为实现不需要它。

管道实现相当简单,因为我们把秒数转换成小时、分钟和秒。然后,我们将结果连接成一个字符串值并返回该值。在每个 hoursminutesseconds 变量左侧添加 0 是为了格式化值,如果计算出的小时、分钟或秒的值小于 10,则前面会有一个前导 0。

我们刚刚创建的管道只是一个标准的 TypeScript 类。是管道装饰器(@Pipe)指示 Angular 将此类视为管道:

@Pipe({ 
  name: 'secondsToTime' 
}) 

管道定义已完成,但要使用管道在 WorkoutRunnerComponent 中,必须在 WorkoutRunnerModule. 中声明管道。Angular CLI 已经为我们完成了这部分工作,作为样板生成的一部分(参见 workout-runner.module.ts 中的 declaration 部分)。

现在我们只需要在视图中添加管道。通过添加高亮片段更新 workout-runner.component.html

<div class="exercise-pane" class="col-sm-6"> 
    <h4 class="text-center">Workout Remaining - {{workoutTimeRemaining | secondsToTime}}</h4>
    <h1 class="text-center">{{currentExercise.exercise.title}}</h1> 

惊讶的是,实现仍然没有完成!我们还有一个步骤。我们有一个管道定义,并且我们在视图中引用了它,但 workoutTimeRemaining 需要随着每一秒的过去更新,以便 SecondsToTimePipe 能够有效。

我们已经在 start 函数中初始化了 WorkoutRunnerComponentworkoutTimeRemaining 属性,设置为总训练时间:

start() { 
    this.workoutTimeRemaining = this.workoutPlan.totalWorkoutDuration(); 
    ... 
} 

现在的问题是:如何随着每一秒的过去更新 workoutTimeRemaining 变量?记住,我们已经有了一个 setInterval 设置来更新 exerciseRunningDuration。虽然我们可以为 workoutTimeRemaining 写另一个 setInterval 实现,但如果一个 setInterval 设置可以同时处理这两个要求会更好。

WorkoutRunnerComponent 添加一个名为 startExerciseTimeTracking 的函数;它看起来如下:

startExerciseTimeTracking() {
    this.exerciseTrackingInterval = window.setInterval(() => {
      if (this.exerciseRunningDuration >= this.currentExercise.duration) {
        clearInterval(this.exerciseTrackingInterval);
        const next: ExercisePlan = this.getNextExercise();
        if (next) {
          if (next !== this.restExercise) {
            this.currentExerciseIndex++;
          }
          this.startExercise(next);
        }
        else {
          console.log('Workout complete!');
        }
        return;
      }
      ++this.exerciseRunningDuration;
      --this.workoutTimeRemaining;
    }, 1000);
  }  

如您所见,该函数的主要目的是跟踪运动进度并在运动完成后翻转运动。然而,它也跟踪 workoutTimeRemaining(它会递减这个计数器)。第一个 if 条件设置只是确保一旦所有运动完成,就清除计时器。内部 if 条件用于保持 currentExerciseIndex 与正在进行的运动同步。

这个函数使用一个名为exerciseTrackingInterval的数字实例变量。将其添加到类声明部分。我们稍后将要使用这个变量来实现练习暂停功能。

startExercise中移除完整的setInterval设置,并用对this.startExerciseTimeTracking();的调用替换它。我们已经准备好测试我们的实现。如果需要,刷新浏览器并验证实现:

下一个部分将介绍另一个内置的 Angular 指令ngIf以及一些小的增强。

使用 ngIf 添加下一个练习指示器

在练习之间的短暂休息期间,如果用户能被告知下一个练习的内容,那将是一件很棒的事情。这将帮助他们为下一个练习做好准备。所以,让我们添加这个功能。

要实现这个功能,我们可以简单地从workoutPlan.exercises数组中输出下一个练习的标题。我们在Time Remaining倒计时部分旁边显示标题。

将锻炼区域(class="exercise-pane")改为包含高亮内容,并移除现有的Time Remaining h1

<div class="exercise-pane"> 
<!-- Exiting html --> 
   <div class="progress time-progress"> 
       <!-- Exiting html --> 
   </div> 
 <div class="row">
 <h4 class="col-sm-6 text-left">Time Remaining:
 <strong>{{currentExercise.duration-exerciseRunningDuration}}</strong>
 </h4>
 <h4 class="col-sm-6 text-right" *ngIf="currentExercise.exercise.name=='rest'">Next up:
 <strong>{{workoutPlan.exercises[currentExerciseIndex + 1].exercise.title}}</strong>
 </h4>
 </div>
</div> 

我们将现有的Time Remaining h1包装起来,并添加另一个h3标签来显示新div中的下一个练习,同时进行一些样式更新。此外,第二个h3中有一个新的指令ngIf*前缀表示它属于与ngFor相同的指令集:结构指令。让我们简单谈谈ngIf

ngIf指令用于根据提供给它的表达式返回truefalse来添加或删除 DOM 中的特定部分。当表达式评估为true时,DOM 元素被添加,否则被销毁。将ngIf声明从前面的视图中隔离出来:

ngIf="currentExercise.details.name=='rest'" 

指令表达式检查我们是否目前处于休息阶段,并根据此显示或隐藏链接的h3

同样在这个h3中,有一个插值表达式显示了来自workoutPlan.exercises数组的练习名称。

这里有一个注意事项:ngIf会添加和销毁 DOM 元素,因此它与我们用来显示和隐藏元素的可见性构造不同。虽然styledisplay:none的最终结果与ngIf相同,但机制完全不同:

<div [style.display]="isAdmin" ? 'block' : 'none'">Welcome Admin</div> 

与此行:

<div *ngIf="isAdmin" ? 'block' : 'none'">Welcome Admin</div> 

使用 ngIf,每当表达式从 false 变为 true 时,内容将进行完整的重新初始化。递归地,从父元素到子元素创建新元素/组件,并设置数据绑定,当表达式从 true 变为 false 时,发生相反的操作:所有这些都被销毁。因此,如果 ngIf 包裹了大量的内容,并且附加的表达式经常改变,那么使用 ngIf 有时可能成为一个昂贵的操作。但除此之外,将视图包裹在 ngIf 中比使用基于 CSS/样式的显示或隐藏更高效,因为当 ngIf 表达式评估为 false 时,既不会创建 DOM 也不会设置数据绑定表达式。

新版本的 Angular 也支持分支结构。这允许我们在视图 HTML 中实现 if then else 流程。以下示例直接来自 ngIf 的平台文档:

<div *ngIf="show; else elseBlock">Text to show</div>
<ng-template #elseBlock>Alternate text while primary text is hidden</ng-template>

else 绑定指向一个带有模板变量 #elseBlockng-template

这里还有一个属于这个系列的指令:ngSwitch。当在父 HTML 中定义时,它可以根据 ngSwitch 表达式交换子 HTML 元素。考虑以下示例:

<div id="parent" [ngSwitch] ="userType"> 
<div *ngSwitchCase="'admin'">I am the Admin!</div> 
<div *ngSwitchCase="'powerUser'">I am the Power User!</div> 
<div *ngSwitchDefault>I am a normal user!</div> 
</div> 

我们将 userType 表达式绑定到 ngSwitch。根据 userType 的值(adminpowerUser 或任何其他 userType),将渲染一个内部 div 元素。ngSwitchDefault 指令是一个通配符匹配/后备匹配,当 userType 既不是 admin 也不是 powerUser 时,它将被渲染。

如果你还没有意识到,请注意,这里有三个指令在这里协同工作,以实现类似 switch-case 的行为:

  • ngSwitch

  • ngSwitchCase

  • ngSwitchDefault

回到我们的下一个练习实现,我们准备验证实现,启动应用程序,并等待休息期。在休息阶段应该提到下一个练习,如下所示:

应用程序正在成形。如果你已经使用过该应用程序,并且与它一起进行了一些身体锻炼,你将非常怀念暂停锻炼功能。锻炼直到结束才会停止。我们需要修复这种行为。

暂停练习

要暂停锻炼,我们需要停止计时器。我们还需要在视图中添加一个按钮,允许我们暂停和恢复锻炼。我们计划通过在页面中心的锻炼区域绘制按钮覆盖层来实现这一点。点击时,它将在暂停和运行之间切换锻炼状态。我们还将添加键盘支持,使用键绑定 pP 来暂停和恢复锻炼。让我们更新组件。

更新 WorkoutRunnerComponent 类,添加这三个函数,并为 workoutPaused 变量添加声明:

workoutPaused: boolean; 
...
pause() { 
    clearInterval(this.exerciseTrackingInterval); 
    this.workoutPaused = true; 
} 

resume() { 
    this.startExerciseTimeTracking(); 
    this.workoutPaused = false; 
} 

pauseResumeToggle() { 
    if (this.workoutPaused) { this.resume();    } 
    else {      this.pause();    } 
} 

暂停的实现很简单。我们首先通过调用 clearInterval(this.exerciseTrackingInterval); 来取消现有的 setInterval 设置。在恢复时,我们再次调用 startExerciseTimeTracking,这再次从我们离开的地方开始跟踪时间。

现在我们只需要为视图调用 pauseResumeToggle 函数。将以下内容添加到 workout-runner.html

<div id="exercise-pane" class="col-sm-6"> 
 <div id="pause-overlay" (click)="pauseResumeToggle()"><span class="pause absolute-center" 
            [ngClass]="{'ion-md-pause' : !workoutPaused, 'ion-md-play' : workoutPaused}">
        </span> </div> 
    <div class="row workout-content"> 

div 上的 click 事件处理器切换锻炼的运行状态,并使用 ngClass 指令在 ion-md-pauseion-md-play 之间切换 - 这是标准的 Angular 东西。现在缺少的是在按 P 键时暂停和恢复的能力。

一种方法是在 div 上应用一个 keyup 事件处理器:

 <div id="pause-overlay" (keyup)= "onKeyPressed($event)"> 

但这种方法有一些不足之处:

  • div 元素没有焦点概念,因此我们还需要在 div 上添加 tabIndex 属性来使其工作

  • 即使如此,它也只有在至少点击过 div 一次的情况下才会工作

有一种更好的方法来实现这一点;将事件处理器附加到全局 window 事件 keyup。这就是事件绑定应该在 div 上应用的方式:

<div id="pause-overlay" (window:keyup)= "onKeyPressed($event)">

注意在 keyup 事件之前的特殊 window: 前缀。我们可以使用这种语法将事件附加到任何全局对象,例如 document。Angular 绑定基础设施的一个方便且非常强大的功能!需要在 WorkoutRunnerComponent 中添加 onKeyPressed 事件处理器。将此函数添加到类中:

onKeyPressed(event: KeyboardEvent) {
    if (event.which === 80 || event.which === 112) {
      this.pauseResumeToggle();
    }
  }

$event 对象是 Angular 提供的标准 DOM 事件对象,用于操作。由于这是一个键盘事件,所以专门的类是 KeyboardEventwhich 属性与 pP 的 ASCII 值相匹配。刷新页面后,你应该会在鼠标悬停在锻炼图像上时看到播放/暂停图标,如下所示:

当我们谈论到 事件绑定 时,这是一个探索 Angular 事件绑定基础设施的好机会

Angular 事件绑定基础设施

Angular 事件绑定允许组件通过事件与其父组件通信。

如果我们回顾一下应用实现,到目前为止我们所遇到的是属性/属性绑定。这些绑定允许组件/元素从外部世界获取输入。数据流入组件。

事件绑定是属性绑定的逆过程。它们允许组件/元素通知外部世界任何状态变化。

正如我们在暂停/恢复实现中所看到的,事件绑定使用圆括号 (()) 来指定目标事件:

<div id="pause-overlay" (click)="pauseResumeToggle()"> 

这会将一个 click 事件处理器附加到 div 上,当点击 div 时调用表达式 pauseResumeToggle()

与属性一样,事件也有一个规范形式。而不是使用圆括号,可以使用 on- 前缀:on-click="pauseResumeToggle()"

Angular 支持所有类型的事件。与键盘输入、鼠标移动、按钮点击和触摸相关的事件。该框架甚至允许我们为我们创建的组件定义自己的事件,例如:

<workout-runner (paused)= "stopAudio()"></workout-runner> 

我们将在下一章中介绍自定义组件事件,其中我们将为7 分钟健身操添加音频支持。

预期事件会有副作用;换句话说,事件处理器可能会改变组件的状态,这反过来又可能触发一系列反应,其中多个组件对状态变化做出反应并改变它们自己的状态。这与属性绑定表达式不同,它应该是无副作用的。即使在我们的实现中,点击div元素也会切换练习运行状态。

事件冒泡

当 Angular 将事件处理器附加到标准 HTML 元素事件时,事件传播的方式与标准 DOM 事件传播的方式相同。这也被称为事件冒泡。子元素上的事件会向上传播,因此父元素上也可以进行事件绑定,如下所示:

<div id="parent " (click)="doWork($event)"> Try 
  <div id="child ">me!</div> 
</div> 

点击任何一个 div 都会在父div上调用doWork函数。此外,$event.target包含触发事件的div的引用。

在 Angular 组件上创建的自定义事件不支持事件冒泡。

如果分配给目标的表达式评估为falsey值(例如voidfalse),则事件冒泡会停止。因此,为了继续传播,表达式应该评估为true

<div id="parent" (click)="doWork($event) || true"> 

在这里,$event对象也值得特别注意。

绑定$event 对象的事件

当目标事件被触发时,Angular 会提供一个$event对象。这个$event包含了发生事件的详细信息。

这里需要注意的是,$event对象的结构是根据事件类型决定的。对于 HTML 元素,它是一个 DOM 事件对象(developer.mozilla.org/en-US/docs/Web/Events),它可能根据实际事件而变化。

但如果是自定义组件事件,$event对象中传递的内容由组件实现决定。我们将在下一章再次讨论这个问题。

我们现在已经涵盖了 Angular 的大部分数据绑定功能,除了双向绑定。在我们结束本章之前,有必要对双向绑定构造进行简要介绍。

使用 ngModel 的双向绑定

双向绑定帮助我们保持模型和视图的一致性。模型的变化会更新视图,视图的变化会更新模型。双向绑定适用的明显领域是表单输入。让我们看看一个简单的例子:

<input [(ngModel)]="workout.name"> 

这里的ngModel指令在inputvalue属性和底层组件上的workout.name属性之间设置了一个双向绑定。用户在前面input中输入的任何内容都会与workout.name同步,而workout.name的任何更改都会反映在前面input上。

有趣的是,我们也可以不使用ngModel指令达到相同的效果,通过结合属性和事件绑定语法。考虑下一个示例;它的工作方式与之前的input相同:

<input [value]="workout.name"  
    (input)="workout.name=$event.target.value" > 

value属性上设置了一个属性绑定,在input事件上设置了一个事件绑定,使得双向同步得以实现。

我们将在第四章个人教练中更详细地介绍双向绑定,在那里我们将构建自己的自定义训练。

我们创建了一个图表,总结了迄今为止讨论的所有绑定的数据流模式。这是一个方便的图表,可以帮助你记住每个绑定构造以及数据如何流动:

现在我们有一个功能齐全的7 分钟训练应用,还有一些额外的功能,希望你在创建应用的过程中玩得开心。现在是时候结束本章并总结所学内容了。

如果你在运行代码时遇到问题,请查看 Git 分支checkpoint2.4以获取我们迄今为止所做的工作的版本。您还可以从以下 GitHub 位置下载checkpoint2.4(ZIP 文件):bit.ly/ng6be-checkpoint-2-4。在首次设置快照时,请参考trainer文件夹中的README.md文件。

摘要

我们以创建一个比第一章中创建的示例更复杂的 Angular 应用为目标开始本章。7 分钟训练应用符合要求,你在构建这个应用的过程中也学到了很多关于 Angular 框架的知识。

我们首先定义了7 分钟训练应用的功能规范。然后,我们集中精力定义应用代码结构。

为了构建应用,我们首先定义了应用的模型。一旦模型就位,我们就通过构建一个Angular 组件来开始实际的实现。Angular 组件不过是装饰了框架特定装饰器@Component的类。

我们还了解了Angular 模块以及 Angular 如何使用它们来组织代码元素。

一旦我们创建了一个功能齐全的组件,我们就为该应用创建了一个支持视图。我们还探索了框架的数据绑定功能,包括属性属性样式事件绑定。此外,我们还强调了插值是属性绑定的一种特殊情况。

组件是一类特殊的指令,它们附加了一个视图。我们提到了指令是什么,以及特殊类指令,包括属性结构化指令

我们学习了如何使用输入属性进行跨组件通信。我们组合的两个子组件(ExerciseDescriptionComponentVideoPlayerComponent)通过输入属性从父组件WorkoutRunnerComponent获取输入。

接着,我们介绍了 Angular 的另一个核心结构,管道。我们看到了如何使用日期管道以及如何创建我们自己的管道。

在本章中,我们讨论了多个 Angular 指令,包括以下内容:

  • ngClass/ngStyle:用于使用 Angular 绑定能力应用多个样式和类

  • ngFor:用于使用循环结构生成动态 HTML 内容

  • ngIf:用于条件性地创建/销毁 DOM 元素

  • ngSwitch:用于使用 switch-case 结构创建/销毁 DOM 元素

我们现在有一个基本的7 分钟健身应用。为了提供更好的用户体验,我们还对其添加了一些小改进,但我们仍然缺少一些使我们的应用更易用的功能。从框架的角度来看,我们有意忽略了某些核心/高级概念,例如变更检测依赖注入组件 路由和数据流模式,这些内容我们计划在下一章中介绍。

第三章:更多 Angular – 单页应用和路由

上一章我们讲述了在 Angular 中构建我们的第一个有用应用,然后这一章将介绍如何为它添加大量的 Angular 优点。在学习曲线中,我们已经开始了探索一个技术平台,现在我们可以使用 Angular 构建一些基本的应用。但这只是开始!在我们能够有效地在一个中等规模的应用中使用 Angular 之前,还有很多东西要学习。这一章让我们更接近实现这一目标。

7 分钟锻炼应用还有一些粗糙的边缘,我们可以在改进整体应用体验的同时修复它们。这一章完全是关于添加这些增强功能和特性。而且,像往常一样,这个应用构建过程为我们提供了足够的机会来增强我们对框架的理解,并学习关于它的新事物。

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

  • 探索 Angular 的单页应用SPA):我们探索 Angular 的 SPA 功能,包括路由导航、链接生成和路由事件。

  • 理解依赖注入:这是平台的核心功能之一。在这一章中,我们学习 Angular 如何有效地使用依赖注入来注入组件和服务到应用程序中。

  • Angular 纯(无状态)和纯(有状态)管道:当我们构建一些新的管道时,我们将更详细地探索 Angular 的主要数据转换结构——管道。

  • 跨组件通信:由于 Angular 的一切都是关于组件及其交互,我们来看看如何在父组件-子组件和兄弟组件设置中进行跨组件通信。我们学习 Angular 的模板变量事件如何促进这种通信。

  • 创建和消费事件:我们学习一个组件如何公开其自己的事件,以及如何从 HTML 模板和其他组件中绑定到这些事件。

作为旁注,我期望你正在定期使用7 分钟锻炼来锻炼你的身体。如果不是,现在就进行七分钟的锻炼休息吧。我坚持这样做!

希望锻炼很有趣!现在让我们回到一些严肃的业务。让我们从探索 Angular 的单页应用SPA)功能开始。

我们从第二章构建我们的第一个应用 - 7 分钟锻炼中继续进行。checkpoint2.4 Git 分支可以作为本章的基础。代码也已在 GitHub (github.com/chandermani/angular6byexample) 上提供,供大家下载。检查点作为 GitHub 中的分支实现。如果你不使用 Git,可以从 GitHub 位置下载checkpoint2.4的快照(ZIP 文件):bit.ly/ng6be-checkpoint-2-4。在首次设置快照时,请参考trainer文件夹中的README.md文件。

探索单页应用功能

7 分钟健身法在加载应用时开始,但最后一个练习会永久地停留在屏幕上。这不是一个非常优雅的解决方案。我们为什么不给应用添加一个开始和结束页面呢?这会让应用看起来更专业,并允许我们理解 Angular 的单页命名空间。

Angular SPA 基础设施

随着现代 Web 框架,如 Angular 和 Vue.js,我们现在已经习惯了不执行全页刷新的应用。但如果你是新手,那么提到SPAs是值得的。

单页应用(SPAs)是基于浏览器的应用,没有全页刷新。在这样的应用中,一旦初始 HTML 加载完毕,未来的页面导航将通过 AJAX 和 HTML 片段检索,并注入到已加载的视图中。Google Mail 是 SPA 的一个很好的例子。SPAs 提供了极佳的用户体验,因为用户会得到类似桌面应用的感觉,没有常规的回发和页面刷新,这些都是传统 Web 应用通常具有的。

就像任何现代 JavaScript 框架一样,Angular 也提供了 SPA 实现所需的必要结构。让我们了解它们,并添加我们的应用页面。

Angular 路由

Angular 支持使用其路由基础设施进行 SPA 开发。这个基础设施跟踪浏览器 URL,启用超链接生成,公开路由事件,并为响应 URL 变化的视图提供一组指令/组件。

有四个主要的框架组件协同工作,以支持 Angular 的路由基础设施:

  • 路由器(Router):实际上是提供组件导航的主要基础设施组件。

  • 路由配置(Route):组件路由依赖于路由配置来设置路由。

  • 路由器组件RouterOutlet组件是用于加载特定路由视图的占位符容器(宿主)。

  • 路由器链接指令:这个指令生成可以在锚标签中嵌入的超链接,用于导航。

下面的图示突出了这些组件在路由设置中的作用:

图片

我强烈建议大家在设置7 分钟健身法的路由时,不断回顾这张图。

路由器是这个完整设置中的核心组件;因此,快速了解路由器将很有帮助。

Angular 路由

如果你曾经使用过任何支持 SPA 的 JavaScript 框架,那么工作原理是这样的。框架会监视浏览器 URL,并根据加载的 URL 提供视图。为此,有专门的框架组件。在 Angular 的世界里,这种跟踪是通过一个框架服务路由器来完成的。

在 Angular 中,任何提供一些通用功能的类、对象或函数都被称为服务Angular 没有为组件、指令和管道提供任何特殊的声明服务结构,就像它对它们所做的那样。任何可以被组件/指令/管道消费的东西都可以称为服务。路由器就是这样一种服务。框架中还有许多其他服务。如果你来自 Angular 1 领域,这会是一个令人愉快的惊喜——没有服务、工厂、提供者、值或常量!*

Angular 路由器的作用是:

  • 在路由更改时启用组件之间的导航

  • 在组件视图之间传递路由数据

  • 使当前路由的状态对活动/加载的组件可用

  • 提供 API,允许从组件代码中进行导航

  • 跟踪导航历史,使我们能够使用浏览器按钮在组件视图之间前后移动

  • 提供生命周期事件和守卫条件,使我们能够根据某些外部因素影响导航

路由器还支持一些高级路由概念,例如父子路由。这使我们能够在组件树内部定义多级路由。父组件可以定义路由,子组件可以进一步向父路由定义中添加更多子路由。这是我们在第四章“构建个人教练”中详细讨论的内容。

路由器不能单独工作。如前图所示,它依赖于其他框架组件以实现预期结果。让我们添加一些应用页面并处理每个拼图的一部分。

路由设置

Angular 路由器不是 Angular 核心框架的一部分。它有一个独立的 Angular 模块和自己的 npm 包。Angular CLI 已经作为项目设置的一部分安装了此包。查看package.json以确认这一点:

"@angular/platform-browser-dynamic": "6.0.0", "@angular/router": "6.0.0",

由于路由器已经安装,我们只需将其集成到7 分钟锻炼中。

我们可以先向index.htmlhead部分添加base引用(突出显示),如果尚未存在:

<title>Trainer</title>
<base href="/">

路由器需要设置base hrefhref值指定了用于 HTML 文档中所有相对 URL 的基本 URL,包括链接到 CSS、脚本、图像以及其他资源。此设置有助于路由器创建导航 URL

添加开始和结束页面

此处的计划是为7 分钟锻炼提供三个页面:

  • 开始页面:这成为应用的着陆页

  • 锻炼页面:我们目前拥有的

  • 完成页面:当锻炼完成后,我们将导航到这个页面

锻炼组件及其视图(workout-runner.component.tsworkout-runner.component.html)已经存在。因此,让我们创建StartComponentFinishComponent

再次使用 Angular CLI 生成开始和结束组件的样板代码。导航到trainer/src/app文件夹并执行组件生成命令:

ng generate component start -is
ng generate component finish -is

接下来,从checkpoint3.1 Git 分支(下载位置为bit.ly/ng6be-3-1-app)复制startfinish组件的视图。

startfinish组件的实现都是空的。有趣的部分在视图中。开始组件视图有一个链接可以导航到锻炼运行者组件(<a routerLink="/workout" ...),结束组件也是如此。我们还没有定义路由。

startfinish组件已经被添加到app module中,因为它们是基本的视图,与拥有自己WorkoutRunnerModule模块的锻炼运行者不同。

所有三个组件都已准备就绪。现在是定义路由配置的时候了!

路由配置

要设置7-Minute Workout的路由,我们将创建一个route definition module file。在trainer/src/app文件夹中创建一个名为app-routing.module.ts的文件,定义应用的顶级路由。添加以下路由设置或从checkpoint3.1 Git 分支复制:

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { WorkoutRunnerComponent } from './workout-runner/workout-runner.component';
import { StartComponent } from './start/start.component';
import { FinishComponent } from './finish/finish.component';

const routes: Routes = [
    { path: 'start', component: StartComponent },
    { path: 'workout', component: WorkoutRunnerComponent },
    { path: 'finish', component: FinishComponent },
    { path: '**', redirectTo: '/start' }
];

@NgModule({
    imports: [RouterModule.forRoot(routes, { enableTracing: true })],
    exports: [RouterModule]
})
export class AppRoutingModule { }

Angular CLI 还支持为模块生成样板路由。我们没有使用该功能。我们可以从 CLI 文档中了解它,文档地址为bit.ly/ng-cli-routing

routes变量是一个Route对象的数组。每个Route定义了一个单独路由的配置,它包含:

  • path:要匹配的目标路径

  • component:当路径被访问时需要加载的组件

这样的路由定义可以解释为:“当用户导航到 URL(在path中定义)时,加载component属性中定义的相应组件。”以第一个路由示例为例;导航到http://localhost:9000/start将加载StartComponent的组件视图。

你可能已经注意到最后一个Route定义看起来有点不同。path看起来很奇怪,它也没有component属性。带有**的路径表示一个通配符路径或应用的通配符路由。任何不匹配前三个路由的导航都将匹配通配符路由,导致应用导航到起始页面(在redirectTo属性中定义)。

路由设置完成后,我们可以尝试一下。输入任何随机的路由,例如http://localhost:9000/abcd,应用会自动重定向到http://localhost:9000/start

我们最终通过调用RouterModule.forRoot创建并导入一个新的模块到AppRoutingModule中。通过重新导出 Angular 的RouterModule,我们可以导入AppRoutingModule而不是RouterModule,并访问所有路由构造以及AppModule中可用的应用路由。

forRoot 函数参数上的 enableTracing: true 属性允许我们在导航发生并正确解析路由时监控 路由事件(例如 NavigationStartNavigationEndNavigationCancel)。日志在浏览器的调试控制台中可见。仅用于调试目的,请从生产构建中移除

前面的路由设置是否可以在 AppModule 内完成?是的,这绝对可能,但我们建议不要这样做。随着路由数量的增加和路由设置的复杂性增加,有一个独立的路由模块有助于我们更好地组织代码。

这里要强调的一个重要事项是:路由定义中的路由顺序很重要。由于路由匹配是自顶向下的,它会在定义你的特定路由之前停止,在任何一个通用的捕获所有路由之前,例如在我们的定义中声明的 ** 通配符路由,这是在最后声明的。

默认的路由设置使用 pushstate 机制进行 URL 导航。在这种设置中,URL 看起来像:

  • localhost:4200/start

  • localhost:4200/workout

  • localhost:4200/finish

这可能看起来不是什么大问题,但请记住,我们正在进行客户端导航,而不是我们如此习惯的全页重定向。正如 开发者指南 所述:

现代 HTML 5 浏览器支持 history.pushState,这是一种在不触发服务器页面请求的情况下改变浏览器位置和历史的技巧。路由器可以组合一个“自然”的 URL,这个 URL 与需要页面加载的 URL 无法区分。

Pushstate API 和服务器端 URL 重写

路由器在两种情况下使用 pushstate API:

  • 当我们点击视图中嵌入的链接(<a> 标签)

  • 当我们使用路由器 API

在这两种情况下,路由器拦截任何导航事件,加载适当的组件视图,并最终更新浏览器 URL。请求永远不会发送到服务器。

但如果我们刷新浏览器窗口会怎样呢?

Angular 路由无法拦截浏览器的刷新事件,因此会发生完整的页面刷新。在这种情况下,服务器需要响应仅存在于客户端的资源请求(URL)。典型的服务器响应是发送应用入口文件(例如 index.html)以响应任何可能导致 404 (Not Found) 错误的任意请求。这就是我们所说的服务器 URL 重写。这意味着对任何不存在的 URL 的请求,例如 /start/workout/finish,都会加载索引页面。

每个服务器平台都有不同的机制来支持 URL 重写。我们建议您查看您使用的服务器堆栈文档,以启用 Angular 应用的 URL 重写。

当应用路由完成后,我们可以看到服务器端重写的实际效果。完成后,尝试刷新应用并查看浏览器的网络日志;服务器每次都会发送相同的 生成的 *index.html 内容,无论请求的 URL 是什么。

路由模块定义现在已完成。在继续之前,打开 app.module.ts 并导入 AppRoutingModule

import { FinishComponent } from './finish/finish.component';
import { AppRoutingModule } from './app-routing.module';
 @NgModule({ 
  imports: [..., StartModule, FinishModule, AppRoutingModule], 

现在我们已经拥有了所有必需的组件和所有已定义的路由,在路由更改时我们在哪里注入这些组件?我们只需要在宿主视图中定义一个占位符即可。

使用 router-outlet 渲染组件视图

查看当前的 AppComponent 模板(app.component.html),它包含一个内嵌的 WorkoutRunnerComponent

<abe-workout-runner></abe-workout-runner>

这需要改变,因为我们需要根据 URL(/start/workout,或 /finish)渲染不同的组件。删除前面的声明,并用一个 router 指令 替换它:

<router-outlet></router-outlet>

RouterOutlet 是 Angular 组件指令,它在路由更改时充当加载特定路由组件的占位符。它与路由服务集成,根据当前浏览器 URL 和路由定义加载适当的组件。

以下图表帮助我们轻松地可视化 router-outlet 设置中正在发生的事情:

图片

我们几乎完成了;现在是时候触发导航。

路由导航

与标准浏览器导航一样,Angular 导航发生:

  • 当用户直接在浏览器地址栏中输入 URL

  • 在点击锚点标签上的链接

  • 在使用脚本/代码进行导航时

如果尚未启动,请启动应用程序并加载 http://localhost:4200 或 http://localhost:4200/start。启动页面应该被加载。

点击 Start 按钮,锻炼应该在 http://localhost:4200/workout URL 下开始。

Angular 路由器还支持基于 hash (#)-based routing 的旧式路由。当启用基于 hash 的路由时,路由如下所示:

  • localhost:9000/#/start

  • localhost:9000/#/workout

  • localhost:9000/#/finish

要将其更改为基于 hash 的路由,顶级路由的路由配置应在 RouterModule.forRoot 函数(第二个参数)中增加一个额外的 useHash:true 属性。

有趣的是,在 StartComponent 视图定义中的锚点链接没有 href 属性。相反,有一个 RouterLink 指令(指令名称为 RouterLink,选择器为 routerLink):

<a routerLink="/workout">

在前面的例子中,由于路由是固定的,指令采用了一个常量表达式("/workout")。我们在这里不使用标准的方括号表示法([]),而是将指令分配一个固定值。这被称为 一次性绑定。对于动态路由,我们可以使用模板表达式和链接参数数组。我们很快就会涉及到动态路由和链接参数数组。

注意在先前的路由路径中的 / 前缀。/ 用于指定绝对路径。Angular 路由器也支持相对路径,这在处理子路由时非常有用。我们将在接下来的几章中探讨子路由的概念。

刷新应用并检查渲染的 HTML 中的StartComponent;前面的锚标签被渲染为正确的href值:

<a ... href="/workout">

避免硬编码路由链接

虽然你可以直接使用<a href="/workout">,但为了避免硬编码路由,建议使用routerLink

链接参数数组

当前7 分钟锻炼的路由设置相当简单,不需要在生成链接时传递参数。但非平凡路由需要动态参数时,这个功能是存在的。看看这个例子:

@RouteConfig([ 
 { path: '/users/:id', component: UserDetail }, 
  { path: '/users', component: UserList}, 
])

这就是如何生成第一个路由的方法:

<a [routerLink]="['/users', 2]  // generates /users/2

分配给RouterLink指令的数组就是我们所说的链接参数数组。该数组遵循特定的模式:

['routePath', param1, param2, {prop1:val1, prop2:val2} ....] 

第一个元素始终是路由路径,下一组参数用于替换在路由模板中定义的占位符标记。

Angular 路由器非常强大,几乎支持我们从现代路由库中期望的所有功能。它支持子路由、异步路由、生命周期钩子、次要路由和一些其他高级场景。我们将推迟对这些主题的讨论,直到后面的章节。本章只是让我们开始使用 Angular 路由,但还有更多内容要介绍!

路由链接参数也可以是一个对象。这样的对象用于向路由提供可选参数。看看这个例子:

<a [routerLink]="['/users', {id:2}]  // generates /users;id=2

注意,生成的链接中包含分号,用于将可选参数与路由和其他参数分开。

实现的最后缺失部分是在锻炼完成后路由到完成页面。

使用路由服务进行组件导航

从锻炼页面到完成页面的导航不是手动触发的,而是在锻炼完成后触发的。WorkoutRunnerComponent需要触发这个转换。

为了这个目的,WorkoutRunnerComponent需要获取路由并对其调用navigate方法。

WorkoutRunnerComponent是如何获取路由实例的?使用 Angular 的依赖注入框架。我们至今一直对这个话题有所回避。我们取得了很多成就,甚至不知道一直都有依赖注入框架在发挥作用。让我们稍等片刻,首先集中精力解决导航问题。

为了让WorkoutRunnerComponent获取路由服务实例,它只需要在构造函数中声明该服务。更新WorkoutRunnerComponent构造函数并添加导入:

import {Router} from '@angular/router'; 
...
constructor(private router: Router) {

WorkoutRunnerComponent实例化时,Angular 神奇地将当前路由注入到router私有变量中。这个魔法是通过依赖注入框架完成的。

现在只需要将console.log("Workout complete!");语句替换为对navigation路由的调用即可:

this.router.navigate( ['/finish'] );

navigate方法接受与RouterLink指令相同的链接参数数组。我们可以通过耐心等待锻炼完成来验证实现。

如果你在运行代码时遇到问题,请查看checkpoint3.1Git 分支,以获取我们迄今为止所做工作的一个可工作版本。或者,如果您不使用 Git,可以从bit.ly/ng6be-checkpoint-3-1下载checkpoint3.1的快照(ZIP 文件)。在首次设置快照时,请参考训练文件夹中的README.md文件。

在我们定义的7 分钟健身法中的路线是标准的简单路线。但如果存在需要参数的动态路线,我们如何使这些参数在我们的组件中可用?Angular 有一个服务可以做到这一点,那就是ActivatedRoute服务。

使用 ActivatedRoute 服务访问路由参数

有时候,应用程序需要访问活动路由状态。在组件实现过程中,有关当前 URL 片段、当前路由参数和其他与路由相关的数据等信息可能会派上用场。

ActivatedRoute服务是所有当前路由相关查询的一站式商店。它有几个属性,包括urlparamMap,可以用来查询路由的当前状态。

让我们看看一个参数化路由的例子以及如何访问从组件传递过来的参数,给定这个路由:

{ path: '/users/:id', component: UserDetailComponent },

当用户导航到/user/5时,底层组件可以通过首先将其构造函数中的ActivatedRoute注入来访问:id参数值:

export class UserDetailComponent { 
  constructor( private route: ActivatedRoute ... 

然后,在代码中需要参数的任何地方,调用ActivatedRoute.paramMap属性的get方法:

ngOnInit() {
    let id = +this.route.paramMap.get('id'); // (+) converts string 'id' to a number
    var currentUser=this.getUser(id) 
}  

ActivatedObject上的paramMap属性实际上是一个可观察对象。我们将在本章后面部分了解更多关于可观察对象的内容,但就目前而言,理解可观察对象是对象,通过引发其他人可以监听的事件来让外界了解其状态变化就足够了。

我们将在后面的章节中使用这个路由器功能,在那里我们将构建一个新的应用程序,可以创建健身计划并编辑现有的健身计划。在即将到来的章节中,我们还将探讨一些高级路由概念,包括子路由懒加载路由守卫条件

我们已经涵盖了 Angular 路由的基础知识,现在是时候集中精力在一个久违的话题上了:依赖注入

Angular 依赖注入

Angular 大量使用依赖注入来管理应用程序和框架的依赖。令人惊讶的是,我们可以在开始讨论路由器之前忽略这个话题。在这段时间里,Angular 依赖注入框架一直在支持我们的实现。一个好的依赖注入框架的标志是,消费者可以在不太多关注内部结构和很少的仪式的情况下使用它。

如果你不确定依赖注入是什么,或者只是对它有一个模糊的概念,那么 DI 的介绍肯定不会伤害任何人。

依赖注入 101

对于任何应用程序,其组件(不要与 Angular 组件混淆)并不是独立工作的。它们之间存在依赖关系。一个组件可能使用其他组件来实现其所需的功能。"依赖注入"是一种管理此类依赖的模式。

依赖注入模式在许多编程语言中都很流行,因为它允许我们以松耦合的方式管理依赖。有了这样的框架,依赖对象由 DI 容器管理。这使得依赖可交换,整体代码更加解耦和可测试。

依赖注入背后的理念是,一个对象不创建/管理自己的依赖。相反,依赖由外部提供。这些依赖可以通过构造函数提供,这被称为构造函数注入(Angular 也这样做)或者通过直接设置对象属性,这被称为属性注入

这里是一个依赖注入操作的初步示例。考虑一个名为Tracker的类,它需要一个Logger来进行日志操作:

class Tracker() { 
  logger:Logger; 
  constructor() { 
    this.logger = new Logger();    
  } 
}

Logger类的依赖在Tracker内部硬编码,因为Tracker本身实例化了Logger实例。如果我们外部化这个依赖呢?所以这个类变成了:

class Tracker { 
  logger:Logger; 
  constructor(logger:Logger) { 
    this.logger = logger;    
  } 
}

这个看似无害的更改产生了重大影响。通过添加提供依赖外部的能力,我们现在可以:

  • 解耦这些组件并启用可扩展性。依赖注入模式允许我们修改Tracker类的日志行为,而不需要触及该类本身。以下是一个示例:
        var trackerWithDBLog=new Tracker(new DBLogger()); 
        var trackerWithMemoryLog=new Tracker(new MemoryLogger()); 

我们刚才看到的两个Tracker对象具有相同的Tracker类实现的不同日志功能。"trackerWithDBLog"将日志记录到数据库,而"trackerWithMemoryLog"则记录到内存中(假设DBLoggerMemoryLogger都派生自Logger类)。由于Tracker不依赖于Logger的具体实现(DBLoggerMemoryLogger),这表明LoggerTracker是松耦合的。在未来,我们可以派生一个新的Logger类实现,并用于日志记录,而无需更改Tracker的实现。

  • 模拟依赖:模拟依赖的能力使得我们的组件更容易测试。Tracker实现可以通过提供Logger的模拟实现(如MockLogger)或使用可以轻松模拟Logger接口的模拟框架来独立(单元测试)测试。

我们现在可以理解 DI 有多强大。

仔细思考:一旦 DI 就位,解决依赖的责任现在落在调用/消费代码上。在先前的示例中,之前实例化Tracker的类现在需要创建一个Logger派生类并将其注入到Tracker中,然后再使用它。

显然,这种在组件内部交换依赖项的灵活性是有代价的。调用代码的实现可能会变得过于复杂,因为它现在还必须管理子依赖项。这看起来可能很简单,但考虑到依赖组件本身可能也有依赖项,我们正在处理的是一个复杂的依赖树结构。

这就是 DI 容器/框架增加价值的地方。它们使调用代码管理依赖项变得更加简单。然后这些容器构建/管理依赖项,并将其提供给我们的客户端/消费者代码。

Angular DI 框架负责管理我们的 Angular 组件、指令、管道和服务的依赖项。

探索 Angular 中的依赖注入

Angular 使用其自己的 DI 框架来管理应用程序中的依赖项。第一个可见的依赖注入示例是将组件路由注入到WorkoutRunnerComponent中:

constructor(private router: Router) { 

WorkoutRunnerComponent类被实例化时,DI 框架内部定位/创建正确的路由实例,并将其注入到调用者(在我们的例子中是WorkoutRunnerComponent)。

虽然 Angular 在隐藏 DI 基础设施方面做得很好,但了解 Angular DI 的工作方式至关重要。否则,一切可能看起来都很神奇。

DI 是关于创建和管理依赖项的,执行此操作的是被称为注入器的框架组件。为了管理依赖项,注入器需要理解以下内容:

  • 是什么:依赖项是什么?依赖项可以是类、对象、工厂函数或值。每个依赖项在注入之前都需要在 DI 框架中注册。

  • 在哪里/何时:DI 框架需要知道在哪里注入依赖项以及何时注入。

  • 如何:DI 框架还需要知道在请求时创建依赖项的配方。

任何注入的依赖项都需要回答这些问题,无论它是框架构造还是我们创建的工件。

WorkoutRunnerComponent中完成的Router实例注入为例。为了回答“是什么”和“如何做”的问题,我们在AppRoutingModule中通过导入RouterModule来注册Router服务:

imports: [..., AppRoutingModule];

AppRoutingModule是一个模块,它导出多个路由以及所有与 Angular-router 相关的服务(技术上它重新导出RouterModule)。

“在哪里”和“何时”是由需要依赖项的组件决定的。WorkoutRunnerComponent的构造函数接受一个Router依赖项。这会通知注入器在创建WorkoutRunnerComponent作为路由导航的一部分时注入当前的Router实例。

在内部,注入器根据从 TypeScript 转换为 ES5 代码(由 TypeScript 编译器完成)时反射的元数据来确定类的依赖关系。只有当我们向类添加装饰器,如 @Component@Pipe 时,才会生成元数据。

如果我们将 Router 注入到另一个类中会发生什么?是否使用相同的 Router 实例?简短的答案是是的。Angular 注入器创建和缓存依赖关系以供将来重用,因此这些服务本质上是单例的。

虽然注入器中的依赖关系是单例的,但在任何给定时间,Angular 应用程序中可能有多个注入器处于活动状态。你很快就会了解注入器层次结构。与路由器一样,还有另一层复杂性。由于 Angular 支持子路由概念,这些子路由中的每一个都有自己的路由实例。等到我们下一章介绍子路由时,你就可以理解其中的复杂性了!

让我们创建一个 Angular 服务来跟踪锻炼历史记录。这个过程将帮助你了解如何使用 Angular DI 连接依赖关系。

跟踪锻炼历史记录

如果我们能跟踪我们的锻炼历史记录,那将是一个很好的补充。我们上次是什么时候锻炼的?我们完成了吗?我们花了多少时间?

为了回答这些问题,我们需要跟踪锻炼开始和结束的时间。然后需要将这些跟踪数据持久化到某个地方。

一种可能的解决方案是将所需的函数扩展到我们的 WorkoutRunnerComponent 中。但这会给 WorkoutRunnerComponent 增加不必要的复杂性,这不是它的主要任务。

我们需要为这项工作创建一个专门的历史跟踪服务,一个跟踪历史数据并在整个应用程序中共享的服务。让我们开始构建 workout-history-tracker 服务。

构建 workout-history-tracker 服务

workout-history-tracker 服务将跟踪锻炼进度。该服务还将公开一个接口,允许 WorkoutRunnerComponent 开始和停止锻炼跟踪。

再次受到 Angular 风格指南 的启发,我们将创建一个新的模块,核心模块,并将服务添加到这个模块中。核心模块的作用是托管应用程序中可用的服务。这也是添加在应用程序启动时所需的单次使用组件的好地方。导航栏和忙碌指示器就是这样的组件示例。

在命令行中,导航到 trainer/src/app 文件夹并生成一个新的模块:

ng generate module core --module app

这将创建一个新的 CoreModule 模块并将其导入到 AppModule 中。接下来,在 trainer/src/app/core 文件夹中创建一个新的服务,再次使用 Angular CLI:

ng generate service workout-history-tracker

生成的代码相当简单。生成器创建了一个新的类 WorkoutHistoryTrackerService (workout-history-tracker.service.ts),并在类上应用了 @Injectable 装饰器:

@Injectable({
  providedIn: 'root'
})
export class WorkoutHistoryTrackerService {
    ...
}

Injectable 上的 providedIn:'root' 属性指示 Angular 使用 root injector 创建 一个提供者。这个提供者的唯一任务是创建 WorkoutHistoryTrackerService 服务并在 Angular 的 DI 注入器需要时返回它。我们创建/使用的任何服务都需要在注入器上注册。正如 Angular 关于 providers 的文档所描述的,

提供者告诉注入器如何创建服务。没有提供者,注入器将不知道它负责注入服务,也无法创建服务

在 Angular 中,服务只是一个已注册到 Angular DI 框架的类。它们没有什么特别之处!

有时,将服务作为模块的一部分而不是与根注入器注册可能是希望的。在这种情况下,服务可以在模块级别注册。有两种方法可以实现这一点:

  • 选项 1:使用 providedIn 属性引用模块:
@Injectable({
  providedIn: CoreModule
})
export class WorkoutHistoryTrackerService {
  • 选项 2:在模块上注册服务,使用 providers 数组:
@NgModule({
  providers: [WorkoutHistoryTrackerService],
})
export class CoreModule { }

在模块级别注册服务在模块是懒加载的场景中具有优势。

使用 Injectable选项 1)注册服务还有另一个优点。它使 Angular CLI 构建能够执行代码捆绑的高级优化,省略任何已声明但从未使用的服务(这个过程称为 tree shaking)。

不论我们使用哪两种选项,服务仍然通过提供者(provider)与根注入器注册。

我们将使用 Injectable 方法在本书中注册依赖项,除非另有说明。打开 workout-history-tracker.service.ts 并添加以下实现:

import { ExercisePlan } from '../workout-runner/model';
import { CoreModule } from './core.module';
import { Injectable } from '@angular/core';
@Injectable({
  providedIn: CoreModule
})
export class WorkoutHistoryTrackerService { 
  private maxHistoryItems = 20;   //Tracking last 20 exercises 
  private currentWorkoutLog: WorkoutLogEntry = null; 
  private workoutHistory: Array<WorkoutLogEntry> = []; 
  private  workoutTracked: boolean;
  constructor() { } 

  get tracking(): boolean { 
    return this. workoutTracked; 
  } 
}

export class WorkoutLogEntry { 
  constructor( 
    public startedOn: Date, 
    public completed: boolean = false, 
    public exercisesDone: number = 0, 
    public lastExercise?: string, 
    public endedOn?: Date) { } 
}

定义了两个类:WorkoutHistoryTrackerServiceWorkoutLogEntry。正如其名称所暗示的,WorkoutLogEntry 定义了单个锻炼执行的日志数据。maxHistoryItems 允许我们配置要存储在包含历史数据的 workoutHistory 数组中的最大项目数。get tracking() 方法在 TypeScript 中定义了 workoutTracked 的 getter 属性。在锻炼执行期间,workoutTracked 被设置为 true

让我们向 WorkoutHistoryTrackerService 添加开始跟踪、停止跟踪和完成锻炼的函数:

startTracking() { 
  this.workoutTracked = true; 
  this.currentWorkoutLog = new WorkoutLogEntry(new Date()); 
  if (this.workoutHistory.length >= this.maxHistoryItems) { 
    this.workoutHistory.shift(); 
  } 
    this.workoutHistory.push(this.currentWorkoutLog); 
} 

exerciseComplete(exercise: ExercisePlan) { 
  this.currentWorkoutLog.lastExercise = exercise.exercise.title; 
  ++this.currentWorkoutLog.exercisesDone; 
} 

endTracking(completed: boolean) { 
  this.currentWorkoutLog.completed = completed; 
  this.currentWorkoutLog.endedOn = new Date(); 
  this.currentWorkoutLog = null; 
  this.workoutTracked = false; 
}

startTracking 函数创建一个 WorkoutLogEntry 并将其添加到 workoutHistory 数组中。通过将 currentWorkoutLog 设置为新创建的日志条目,我们可以在锻炼执行过程中稍后对其进行操作。endTracking 函数和 exerciseComplete 函数仅修改 currentWorkoutLogexerciseComplete 函数应在完成每个作为锻炼一部分的锻炼时调用。为了节省您的按键次数,您可以从此 gist 获取到目前为止的实现完整代码:bit.ly/ng6be-gist-workout-history-tracker-v1-ts

服务实现现在还包括一个获取锻炼历史数据的函数:

getHistory(): Array<WorkoutLogEntry> { 
  return this.workoutHistory; 
}

这就完成了WorkoutHistoryTrackerService的实现;现在,是时候将其集成到锻炼执行中了。

WorkoutRunnerComponent集成

WorkoutRunnerComponent需要WorkoutHistoryTrackerService来跟踪锻炼历史;因此有一个依赖项需要满足。我们已经在 Angular 的 DI 框架中使用Injectable装饰器注册了WorkoutHistoryTrackerService,现在是时候使用构造函数注入来消费这个服务了。

使用构造函数注入注入依赖项

消费依赖项很容易!通常情况下,我们使用构造函数注入来消费依赖项

在顶部添加import语句,并更新WorkoutRunnerComponent构造函数,如下所示:

import { WorkoutHistoryTrackerService } from '../core/workout-history-tracker.service';
... 
constructor(private router: Router,
    private tracker: WorkoutHistoryTrackerService   
) {

router一样,当创建WorkoutRunnerComponent时,Angular 也会注入WorkoutHistoryTrackerService。很简单!

一旦服务被注入并可供WorkoutRunnerComponent使用,当锻炼开始、一项锻炼完成以及锻炼结束时,需要调用服务实例(tracker)。

将此作为start函数中的第一个语句:

this.tracker.startTracking();

startExerciseTimeTracking函数中,在clearInterval调用之后添加高亮代码:

clearInterval(this.exerciseTrackingInterval); 
if (this.currentExercise !== this.restExercise) { this.tracker.exerciseComplete(this.workoutPlan.exercises[this.currentExerciseIndex]); }

并且在锻炼中高亮显示的代码,以完成同一函数中的else条件:

this.tracker.endTracking(true); 
this.router.navigate(['/finish']); 

历史跟踪几乎完成了,除了一个特殊情况。如果用户手动导航离开锻炼页面怎么办?我们如何停止跟踪?

当这种情况发生时,我们总能依赖组件的生命周期钩子/事件来帮助我们。当NgOnDestroy事件被触发时,可以停止锻炼跟踪。在组件从组件树中移除之前执行任何清理工作的合适位置。让我们这么做。

将此函数和相应的生活周期事件接口添加到workout-runner.component.ts中:

export class WorkoutRunnerComponent implements OnInit, OnDestroy {
...
ngOnDestroy() { 
  this.tracker.endTracking(false); 
} 

锻炼历史跟踪实现已完成。我们渴望开始锻炼历史页面/组件的实现,但在完成我们对 Angular DI 能力的讨论之前,我们不会这么做。

如果你想保持应用构建速度,现在可以自由跳过下一节。用清新和放松的心态回到这一节。在下一节中,我们将分享一些非常重要的核心概念。

深入了解依赖注入

让我们先尝试理解我们可以使用WorkoutHistoryTrackerService作为示例来注册依赖项的不同位置。

注册依赖项

注册依赖的标准方式是在根/全局级别注册。这可以通过在NgModule装饰器的provides属性(数组)中传递依赖类型,或者使用Injectable服务装饰器的providedIn属性来完成。

记得我们的WorkoutHistoryTrackerService注册吗?请检查以下内容:

@Injectable({
  providedIn: CoreModule
})
export class WorkoutHistoryTrackerService {

同样的事情也可以在模块声明中完成,如下所示:

@NgModule({...providers: [WorkoutHistoryTrackerService],})

从技术上讲,当使用上述任何一种机制时,服务都会注册到应用的根注入器,而不管它在哪个 Angular 模块中声明。从今往后,任何跨模块的 Angular 工件都可以使用该服务(WorkoutHistoryTrackerService)。根本不需要任何模块导入。

这种行为与组件/指令/管道注册不同。这样的工件必须从一个模块导出,以便另一个模块可以使用它们。

依赖项可以注册的另一个地方是在组件上@Component 装饰器有一个 providers 数组参数来注册依赖项。有了这两个依赖项注册级别,我们需要回答的明显问题是,哪一个该使用?

显然,如果依赖项仅由组件及其子组件使用,它应该注册在 @Component 装饰器级别。但并非如此!在我们可以回答这个问题之前,我们还需要了解很多。需要介绍一个全新的分层注入器世界。让我们等待,并通过继续讨论提供者来学习注册依赖项的其他方法。

当 Angular 注入器请求时,提供者创建依赖项这些提供者有创建这些依赖项的配方。虽然类似乎是可以注册的明显依赖项,但我们也可以注册:

  • 一个特定的对象/值

  • 工厂函数

使用 Injectable 装饰器注册 WorkoutHistoryTrackerService 是最常见的注册模式。但有时我们需要在依赖项注册方面有一定的灵活性。要注册一个对象或工厂函数,我们需要使用在 NgModule 上可用的提供者注册的扩展版本。

要了解这些变化,我们需要更详细地探索提供者和依赖项注册。

Angular 提供者

提供者创建由 DI 框架提供的依赖项。

看看在 NgModule 上完成的这个 WorkoutHistoryTrackerService 依赖项注册:

providers: [WorkoutHistoryTrackerService],

这种语法是以下版本的简写形式:

providers:({ provide: WorkoutHistoryTrackerService, useClass: WorkoutHistoryTrackerService })

第一个属性(provide)是一个用作注册依赖项键的令牌。这个键也允许我们在依赖注入期间定位依赖项。

第二个属性(useClass)是一个提供者定义对象,它定义了创建依赖项值的配方。

使用 useClass,我们正在注册一个类提供者类提供者通过实例化请求的对象类型来创建依赖项。还有一些其他的提供者类型。

值提供者

类提供者创建类对象并满足依赖项,但有时我们想在 DI 提供者中注册一个特定的对象/原始值值提供者解决了这个用例。

如果我们使用这种技术注册 WorkoutHistoryTrackerService,注册将看起来像这样:

{provide: WorkoutHistoryTrackerService, useValue: new WorkoutHistoryTrackerService()};

使用值提供者,我们有责任为 Angular DI 提供一个服务/对象/原始实例。

使用value provider,由于我们手动创建依赖项,因此我们也负责构建任何子依赖项(如果存在)。再次以WorkoutHistoryTrackerService为例。如果WorkoutHistoryTrackerService有一些依赖项,这些依赖项也需要通过手动注入来满足:

{provide: WorkoutHistoryTrackerService, useValue: new WorkoutHistoryTrackerService(new LocalStorage())});

在先前的例子中,我们不仅要创建WorkoutHistoryTrackerService的实例,还要创建LocalStorage服务的实例。对于具有复杂依赖图的服务,使用值提供者设置该服务变得具有挑战性。

在可能的情况下,优先选择class provider而不是value provider

在特定的场景中,值提供者仍然很有用。例如,我们可以使用值提供者注册一个通用的应用程序配置:

{provide: AppConfig, {useValue: {name:'Test App', gridSetting: {...} ...}}

或者,在单元测试时注册一个模拟依赖项:

{provide:WorkoutHistoryTrackerService, {useValue: new MockWorkoutHistoryTracker()}

工厂提供者

有时候,依赖项构建并不是一件简单的事情。构建取决于外部因素。这些因素决定了创建和返回哪些对象或类实例。工厂提供者做这项繁重的工作。

以一个例子为例,我们想要为开发和生产发布提供不同的配置。我们可以很好地使用工厂实现来选择正确的配置:

{provide: AppConfig, useFactory: () => { 
  if(PRODUCTION) { 
    return {name:'Production App', gridSetting: {...} ...} 
  } 
  else { 
    return {name:'Test App', gridSetting: {...} ...} 
  }
}

工厂函数也可以有自己的依赖。在这种情况下,语法略有变化:

{provide: WorkoutHistoryTrackerService, useFactory: (environment:Environment) => { 
  if(Environment.isTest) { 
    return new MockWorkoutHistoryTracker(); 
  } 
  else { 
    return new WorkoutHistoryTrackerService(); 
  }, 
    deps:[Environment]
}

依赖作为参数传递给工厂函数,并在提供者定义对象的属性deps上注册(在先前的例子中Environment是注入的依赖)。

如果依赖项的构建复杂,且在依赖项连接期间不能决定所有内容,请使用UseFactory提供。

虽然我们有多种选项来声明依赖,但消费依赖要简单得多。我们在“使用构造函数注入注入依赖”这一节中看到了一种方法。

使用注入器进行显式注入

我们甚至可以使用 Angular 的Injector 服务进行显式注入。这是 Angular 用来支持 DI 的同一个注入器。以下是使用Injector注入WorkoutHistoryTrackerService服务的方法:

constructor(private router: Router, private injector:Injector) {
  this.tracker=injector.get(WorkoutHistoryTrackerService);

我们注入Injector服务,然后明确请求WorkoutHistoryTrackerService实例。

什么时候有人会想这样做呢?嗯,几乎从不会。避免这种模式,因为它将 DI 容器暴露给实现,并增加了一些噪音。

我们现在知道如何注册依赖和如何消费它,但 DI 框架是如何定位这些依赖的呢?

依赖项令牌

记住之前展示的依赖注册的扩展版本:

{ provide: WorkoutHistoryTrackerService, useClass: WorkoutHistoryTrackerService }

provide 属性值是一个 令牌。此令牌用于标识要注入的依赖。在先前的示例中,我们使用类名或类型来标识依赖,因此该令牌被称为 类令牌

根据 前面的注册,每当 Angular 看到类似以下语句时,它将根据类类型注入正确的依赖,这里 WorkoutHistoryTrackerService

constructor(tracker: WorkoutHistoryTrackerService)

Angular 还支持一些其他的令牌。

使用 InjectionToken

有时候,我们定义的依赖项要么是原始数据类型、对象或函数。在这种情况下,类令牌不能使用,因为没有类。Angular 通过使用 InjectionToken (或我们稍后将看到的字符串令牌)来解决这个问题。如果不存在 AppConfig 类,我们之前分享的应用配置注册示例可以使用字符串令牌重写。

要使用 InjectionToken 注册依赖,我们首先需要创建 InjectionToken 类实例:

export const APP_CONFIG = new InjectionToken('Application Configuration');

然后,使用令牌来注册依赖:

{ provide: APP_CONFIG, useValue: {name:'Test App', gridSetting: {...} ...});

最后,使用 @Inject 装饰器在任何地方注入依赖:

constructor(@Inject(APP_CONFIG) config) { }

有趣的是,当 @Inject() 不存在时,注入器使用参数的类型/类名(类令牌)来定位依赖。

使用字符串令牌

Angular 也支持 字符串令牌,允许我们使用字符串字面量来标识和注入依赖。使用字符串令牌的前一个示例变为:

{ provide: 'appconfig', useValue: {name:'Test App', gridSetting: {...} ...});
...
constructor(@Inject('appconfig') config) { }

字符串令牌的一个缺点是,你可能会在声明和注入过程中拼写错误令牌。

呼吁!这是关于 Angular 依赖注入的一个非常长的部分,还有很多内容要介绍。现在,让我们回到正轨,添加锻炼历史页面。

添加锻炼历史页面

在执行锻炼过程中收集的锻炼历史数据现在可以在视图中呈现。让我们添加一个 History 组件。该组件将在 /history 位置可用,可以通过点击应用页眉部分中的链接来加载。

在 app.routes.ts 中更新路由定义以包含新的路由和相关导入:

import { WorkoutHistoryComponent } from './workout-history/workout-history.component'; 
... 
export const routes: Routes = [ 
  ..., 
 { path: 'history', component: WorkoutHistoryComponent } ,
  { path: '**', redirectTo: '/start' } 
]) 

需要将 History 链接添加到应用页眉部分。让我们将页眉部分重构为其自己的组件。更新 app.component.html 模板,并用以下代码替换 nav 元素:

<div id="header">
    <abe-header></abe-header>
</div> 

将 nav 元素移动到页眉组件中,我们仍然需要创建它。在 trainer/src/app/core 文件夹内运行命令来使用 ng generate 生成一个新的 HeaderComponent 组件:

ng generate component header -is

此语句创建了一个新的页眉组件,并在核心模块中声明了它。接下来,从 checkpoint3.2 Git 分支(GitHub 位置:bit.ly/ng6be-3-2-header)更新页眉组件的定义(header.component.ts)及其视图(header.component.html)。

虽然我们已经向app.component.html添加了标题元素,但除非我们导入核心模块并从核心模块导出组件,否则标题组件不会渲染。Angular CLI 为我们完成了第一部分,对于第二部分,更新core.module.ts如下:

imports: [ CommonModule, RouterModule],
declarations: [HeaderComponent],    
exports: [HeaderComponent]

如果你查看HeaderComponent的视图,历史链接现在就在那里。我们必须导入RouterModule,因为以下链接是使用RouterLink指令生成的,而RouterLinkRouterModule的一部分:

<a class="nav-link" routerLink="/history" title="Workout History">History</a>

让我们先通过生成组件的模板来添加锻炼历史组件。从命令行导航到trainer/src/app并运行:

ng generate component workout-history -is

WorkoutHistoryComponent的实现可以在checkpoint3.2Git 分支中找到;文件夹是workout-history(GitHub 位置:bit.ly/ng6be-3-2-workout-history)。

至少可以说,WorkoutHistoryComponent的视图代码很简单:一些 Angular 构造,包括ngForngIf。组件实现也很简单。注入WorkoutHistoryTrackerService服务依赖项,并在WorkoutHistoryComponent初始化时加载历史数据:

ngOnInit() { 
  this.history = this.tracker.getHistory(); 
}

这次,我们使用Location服务而不是Router来从history组件导航离开:

goBack() { 
  this.location.back(); 
}

位置服务用于与浏览器 URL 交互。根据 URL 策略,要么使用 URL 路径(例如/start/workout),要么使用 URL 哈希段(例如#/start#/workout)来跟踪位置变化。路由器服务内部使用位置服务来触发导航。

路由器与位置

虽然Location服务允许我们执行导航,但使用Router是执行路由导航的首选方式。我们在这里使用位置服务是因为需要导航到最后一个路由,而不必担心如何构建路由。

我们已经准备好测试我们的锻炼历史实现。加载起始页面并点击“历史”链接。历史页面加载了一个空网格。返回,开始锻炼,并完成一项练习。再次检查历史页面;应该有一个锻炼被列出:

图片

看起来不错!如果我们多次运行锻炼并让历史记录列表累积,我们会发现这个列表中有一个痛点。历史数据没有按倒序时间排序,最新数据在顶部。此外,如果我们有一些过滤功能那就太好了。

使用管道排序和过滤历史数据

在第二章,“构建我们的第一个应用 - 7 分钟锻炼”,我们探讨了管道。我们甚至构建了自己的管道来格式化秒数值为 hh:mm:ss。管道的主要目的是转换数据,而且令人惊讶的是,它们也可以在数组上工作!对于数组,管道可以排序和过滤数据。我们创建了两个管道,一个用于排序,一个用于过滤。

AngularJS 预建了用于此目的的过滤器(在 Angular 中,过滤器是管道),orderByfilter。Angular 并不自带这些管道,这有一个很好的原因。这些管道容易导致性能不佳。在框架文档中了解关于管道的决策背后的理由:bit.ly/ng-no-filter-orderby-pipe

让我们从 orderBy 管道开始。

排序管道

我们实现的 orderBy 管道将根据对象的任何属性对对象数组进行排序。根据 fieldName 属性按升序排序的项目使用模式如下:

*ngFor="let item of items| orderBy:fieldName"

对于按降序排序项目,使用模式如下:

*ngFor="let item of items| orderBy:-fieldName"

注意 fieldName 前面的额外连字符(-)。

我们计划在新的共享模块中添加 OrderByPipe。你在想,为什么不添加到核心模块中呢?按照惯例,核心模块包含全局服务和一次性使用的组件。每个应用程序只有一个核心模块。另一方面,共享模块包含跨模块共享的组件/指令/管道。这样的共享模块也可以在多个级别上定义,跨越父模块和子模块。在这种情况下,我们将在 AppModule 内定义共享模块。

通过在 trainer/src/app 目录中运行此命令来创建新的 SharedModule 模块:

ng generate module shared --module app

从命令行导航到 trainer/src/app/shared 文件夹并生成排序管道模板:

ng generate pipe order-by

打开 order-by.pipe.ts 并更新定义,从 checkpoint3.2 代码(GitHub 位置:bit.ly/ng6be-3-2-order-by-pipe)中获取。虽然我们不会深入探讨管道的实现细节,但一些相关部分需要突出显示。看看这个管道概要:

@Pipe({ name: 'orderBy' }) 
export class OrderByPipe {
  transform(value: Array<any>, field:string): any { 
   ... 
  } 
}

前面的 field 变量接收需要排序的字段。如果字段有 - 前缀,我们在按降序排序数组之前截断前缀。

该管道还使用了扩展运算符 ...,这可能对你来说很新。在 MDN 上了解更多关于扩展运算符的信息:bit.ly/js-spread

要使用 OrderByPipe,更新 workout 历史视图的模板:

<tr *ngFor="let historyItem of history|orderBy:'-startedOn'; let i = index"> 

再次,我们需要从共享模块导出管道,允许 WorkoutHistoryComponent 使用它。在 SharedModule 上添加一个 exports 属性并将其设置为 OrderByPipe

declarations:[...],
exports:[OrderByPipe]

历史数据现在将根据 startedOn 字段按降序排序。

注意管道参数('-startedOn')周围的单引号。我们正在将一个字面字符串传递给 orderBy 管道。管道参数支持数据绑定,也可以绑定到组件属性。

对于 orderBy 管道来说,这就足够了。让我们来实现过滤功能。

与搜索管道的管道链

我们首先通过在 trainer/src/app/shared 文件夹中运行以下命令来创建搜索管道模板:

ng generate pipe search

实现现在可以从 checkpoint3.2(GitHub 位置:bit.ly/ng6be-3-2-search-pipe)复制。SearchPipe执行基于基本相等性的过滤。没有什么特别的。

看看管道代码;管道接受两个参数,第一个是要搜索的字段,第二个是要搜索的值。我们使用 JavaScript 数组的filter函数来过滤记录,进行严格的相等性检查。关于Pipe装饰器上的pure属性有什么疑问吗?这将是下一节讨论的主题。

让我们更新锻炼历史视图并包含搜索管道。打开workout-history.component.html并取消注释包含单选按钮的 div。这些单选按钮根据是否完成来过滤锻炼。这是 HTML 过滤器选择看起来像这样:

<label><input type="radio" name="searchFilter" value=""  
    (change)="completed = null" checked="">All </label> 
<label><input type="radio" name="searchFilter" value="true"  
    (change)="completed = $event.target.value=='true'"> Completed </label> 
<label><input type="radio" name="searchFilter" value="false"  
    (change)="completed = $event.target.value=='true'"> Incomplete </label> 

我们定义了三个过滤器:allcompletedincomplete工作集。通过使用change事件表达式,单选按钮选择设置组件的completed属性。$event.target是点击的单选按钮。

现在可以将search管道添加到ngFor指令表达式中。我们将链式连接searchorderBy管道。更新ngFor表达式为:

<tr *ngFor="let historyItem of history |search:'completed':completed |orderBy:'-startedOn';  
    let i = index">

这是 Angular 管道链式功能的一个很好的例子!

正如我们在OrderByPipe中所做的那样,SearchPipe也需要在使用之前从共享模块中导出。

search管道首先过滤历史数据,然后由orderBy管道重新排序。请特别注意search管道的参数:第一个参数是一个表示要搜索字段的字符串字面量(historyItem.completed),而第二个参数是从组件的completed属性派生出来的。能够将管道参数绑定到组件属性使我们具有很大的灵活性。

继续验证历史页面的搜索功能。根据单选按钮的选择,历史记录被过滤,当然,它们根据锻炼开始日期按逆时间顺序排序。

虽然使用数组的管道看起来很简单,但如果我们不了解管道何时被评估,它可能会带来一些惊喜。

数组管道的注意事项

为了理解应用于数组的管道的问题,让我们重现这个问题。

打开search.pipe.ts并移除@Pipe装饰器的pure属性。同时,取以下语句:

if (searchTerm == null) return [...value];

然后将其更改为这样:

if (searchTerm == null) return [value];

workout-history.component.html的收尾处添加一个按钮,该按钮将新的日志条目添加到history数组中:

<button (click)="addLog()">Add Log</button>

WorkoutHistoryComponent添加一个函数:

addLog() { 
  this.history.push(Object.assign({}, this.history[this.history.length-1])); 
}

前面的函数复制了第一个历史条目并将其添加回history数组。如果我们加载页面并点击按钮,则会在历史数组中添加一个新的日志条目,但除非我们更改过滤器(通过点击其他单选按钮),否则它不会显示在视图中。有趣!

在调用 addLog 之前,确保至少已经存在一个历史日志;否则,addLog 函数将失败。

我们迄今为止构建的管道在本质上是无状态的(也称为)。它们只是将输入数据转换为输出。无状态****管道仅在管道输入更改(管道符号左侧的表达式)或任何管道参数更新时重新评估。

对于数组,这发生在数组赋值/引用更改时,而不是在添加或删除元素时。切换过滤器条件有效,因为它会导致搜索管道再次评估,因为搜索参数(completed 状态)已更改。这种行为是需要注意的。

修复方法是什么?首先,我们可以使历史数组不可变,这意味着一旦创建后就不能更改。要添加一个新元素,我们需要创建一个新的数组,并包含新的值,类似于:

this.history = [...this.history,  Object.assign({}, this.history[0])];

这工作得很好,但我们正在更改我们的实现,使其与管道一起工作,这是不正确的。相反,我们可以更改管道。该管道应该被标记为有状态的。

无状态管道和有状态管道之间的区别在于,有状态管道在 Angular 每次进行变更检测运行时都会被评估,这涉及到检查整个应用程序的变化。因此,在有状态管道中,检查不仅限于管道输入/参数的变化。

要使 search 管道无状态,只需撤销我们做的第一个更改,并在 Pipe 装饰器上添加 pure: false

@Pipe({ 
  name: 'search', 
 pure:false 
}) 

这仍然不起作用!search 管道还有一个需要修复的怪癖。全选单选按钮并不完美。添加一个新的锻炼日志,它仍然不会显示,除非我们切换过滤器。

这里的修复是撤销第二个更改。在 search 管道中隔离这一行:

if (searchTerm == null) return value;

并将其更改为以下内容:

if (searchTerm == null) return [...value];

我们将 if 条件更改为每次都返回一个新的数组(使用展开运算符),即使 searchTermnull。如果我们返回相同的数组引用,Angular 不会检查数组的大小变化,因此不会更新 UI。

这就完成了我们的历史页面实现。你现在可能想知道管道的最后几个修复与变更检测的工作方式有什么关系。或者你可能想知道什么是变更检测。让我们消除所有这些疑虑,并向大家介绍 Angular 的变更检测系统

Angular 的变更检测将在第八章,一些实际场景中详细介绍。下一节的目标是介绍变更检测的概念以及 Angular 如何执行此过程。

Angular 变更检测概述

简而言之,变更检测就是跟踪应用执行期间对组件模型的更改。这有助于 Angular 的数据绑定基础设施确定哪些视图部分需要更新。每个数据绑定框架都需要解决这个问题,并且这些框架跟踪更改的方法各不相同。甚至从 AngularJS 到 Angular 也有所不同。

要理解 Angular 中的变更检测是如何工作的,我们需要注意以下几点。

  • 一个 Angular 应用不过是由组件组成的层次结构,从根到叶。

  • 我们绑定到视图的组件属性并没有什么特殊之处;因此,Angular 需要一个高效的机制来知道这些属性何时发生变化。它不能持续轮询这些属性的变化。

  • 为了检测属性值的变化,Angular 在先前值和当前值之间进行严格比较===)。对于引用类型,这意味着只比较引用。不进行深度比较。

正是因为这个原因,我们不得不将我们的搜索管道标记为有状态的。向现有数组添加元素不会改变数组引用,因此 Angular 无法检测到数组中的任何变化。一旦管道被标记为有状态的,无论数组是否已更改,管道都会被评估。

由于 Angular 无法知道任何绑定属性何时自动更新,因此它会在触发变更检测运行时检查每个绑定属性。从组件树的根开始,Angular 在遍历组件层次结构时检查每个绑定属性的变化。如果检测到变化,则标记该组件为刷新。值得重申的是,绑定属性的变化并不会立即更新视图。相反,变更检测运行分为两个阶段。

  • 第一阶段,它遍历组件树,并标记因模型更新需要刷新的组件。

  • 第二阶段,实际视图与底层模型同步。

在变更检测运行期间,模型更改和视图更新永远不会交织在一起。

我们现在只需要回答两个更多的问题:

  • 变更检测运行何时被触发?

  • 它运行了多少次?

当以下任何事件被触发时,Angular 的变更检测运行会被触发:

  • 用户输入/浏览器事件:我们点击按钮、输入文本、滚动内容。这些操作中的每一个都可以更新视图(以及底层模型)。

  • 远程 XHR 请求:这是视图更新的另一个常见原因。从远程服务器获取数据以显示在网格上,以及获取用户数据以渲染视图都是这种情况的例子。

  • setTimeout 和 setInterval:实际上,我们可以使用setTimeoutsetInterval来异步执行一些代码,并在特定的时间间隔内执行。这样的代码也可以更新模型。例如,一个setInterval计时器可能会定期检查股票报价,并在 UI 上更新股票价格。

为了回答“多少次”,答案是 1 次。每个组件模型只检查一次,以自顶向下的方式,从根组件到树叶子。

当 Angular 配置为生产模式运行时,上述说法是正确的。在开发模式下,组件树会被遍历两次以检测更改。Angular 期望在第一次树遍历后模型是稳定的。如果情况不是这样,Angular 会在开发模式下抛出错误,并在生产模式下忽略更改。我们可以在调用bootstrap函数之前通过调用enableProdMode函数来启用生产模式。

现在是时候选择另一个与 Angular 的依赖注入相关的主题了。分层注入器的概念将成为我们接下来讨论的主题。这是一个非常强大的功能,在我们使用 Angular 构建更大更好的应用时非常有用。

分层注入器

在 Angular 的依赖注入设置中,注入器是一个容器,负责存储依赖项并在需要时提供它们。之前分享的提供者注册示例实际上是将依赖项注册到一个全局注入器中。

注册组件级别的依赖

我们迄今为止所做的所有依赖项注册都是在模块上完成的。Angular 更进一步,允许在组件级别注册依赖项。在@Component装饰器上也有类似的providers属性,允许我们在组件级别注册依赖项。

我们完全可以在WorkoutRunnerComponent上注册WorkoutHistoryTrackerService依赖项。大致如下:

@Component({ 
  selector: 'abe-workout-runner', 
  providers: [WorkoutHistoryTrackerService] 
  ...
})

但我们是否应该这样做是我们在这里讨论的问题。

在关于分层注入器的讨论背景下,重要的是要理解 Angular 为每个组件创建一个注入器(简化说明)。在组件级别完成的依赖注册在组件及其后代中都是可用的。

我们还了解到依赖项本质上是单例的。一旦创建,注入器将始终在每次请求时返回相同的依赖项。这一特性在锻炼历史实现中很明显。

WorkoutHistoryTrackerServiceCoreModule注册,然后注入到两个组件中:WorkoutRunnerComponentWorkoutHistoryComponent。这两个组件都获得WorkoutHistoryTrackerService的相同实例。下个图例突出了这个注册和注入:

图片

为了确认,只需在WorkoutHistoryTrackerService构造函数中添加一个console.log语句:

console.log("WorkoutHistoryTrackerService instance created.")

通过点击页眉链接刷新应用并打开历史页面。无论我们运行锻炼或打开历史页面多少次,消息日志只生成一次。

这也是一种新的交互/数据流模式!

仔细思考;正在使用一个服务在两个组件之间共享状态。WorkoutRunnerComponent正在生成数据,而WorkoutHistoryComponent正在消费它。并且没有任何相互依赖。我们正在利用依赖本质上是单例的事实。这种数据共享/交互/数据流模式可以用来在任意数量的组件之间共享状态。实际上,这是我们武器库中非常强大的武器。下次需要在不相关的组件之间共享状态时,想想服务。

但这一切与分层注入器有什么关系?好吧,我们不再绕弯子;让我们直接进入正题。

虽然与注入器注册的依赖项是单例的,但Injector本身不是!在任何给定的时间点,应用程序中都有多个活动注入器。实际上,注入器是在与组件树相同的层次结构中创建的。Angular 为组件树中的每个组件创建一个Injector实例(这是一个简化的说法;请参见下一个信息框)。

Angular 并不是为每个组件都创建一个注入器。正如 Angular 开发者指南中解释的那样:每个组件不需要自己的注入器,为没有好处的目的创建大量的注入器将会非常低效。但确实,每个组件都有一个注入器(即使它与其他组件共享该注入器),并且可能存在许多不同的注入器实例在不同的组件树层级上运行。假装每个组件都有自己的注入器是有用的。

当一个锻炼正在进行时,组件和注入器树看起来大致如下:

插入文本框表示组件名称。根注入器是在应用程序引导过程中创建的注入器。

这个注入器层次结构的意义是什么?为了理解其影响,我们需要了解当组件请求依赖项时会发生什么。

Angular DI 依赖项遍历

当请求一个依赖项时,Angular 首先尝试从组件自己的注入器中满足依赖项。如果它找不到请求的依赖项,它会查询父组件注入器以获取依赖项,如果探测失败,它会查询父组件的父组件,以此类推,直到找到依赖项或达到根注入器。要点:任何依赖项搜索都是基于层次的。

在我们之前注册WorkoutHistoryTrackerService时,它是与根注入器一起注册的。来自WorkoutRunnerComponentWorkoutHistoryComponentWorkoutHistoryTrackerService依赖项请求由根注入器满足,而不是由它们自己的组件注入器满足。

这种层次注入器结构带来了很多灵活性。我们可以在不同的组件级别配置不同的提供者,并在子组件中覆盖父提供者配置。这仅适用于在组件上注册的依赖项。如果依赖项被添加到模块中,它将在根注入器上注册。

此外,如果依赖项在组件级别注册,其生命周期将与组件的生命周期绑定。每次组件加载时都会创建依赖项,当组件被销毁时销毁。与仅在第一次请求时创建的模块级别依赖项不同。

让我们在使用它的组件中尝试覆盖全局的WorkoutHistoryTrackerService服务,以了解在这样覆盖时会发生什么。这将很有趣,我们将学到很多!

打开workout-runner.component.ts并为@Component装饰器添加一个providers属性:

providers: [WorkoutHistoryTrackerService]

workout-history.component.ts中也这样做。现在如果我们刷新应用,开始锻炼,然后加载历史页面,网格是空的。无论我们尝试运行锻炼多少次,历史网格总是空的。

原因很明显。在为每个WorkoutRunnerComponentWorkoutHistoryComponent设置WorkoutHistoryTrackerService提供者之后,依赖项由各自的组件注入器本身来满足。当请求时,两个组件注入器都会创建自己的WorkoutHistoryTrackerService实例,因此历史跟踪被破坏。查看以下图表以了解在两种情况下请求是如何被满足的:

图片

一个快速问题:如果我们不在模块上而是在根组件TrainerAppComponent中注册依赖项,会发生什么?类似于以下这样:

@Component({ 
  selector: 'abe-root', 
 providers:[WorkoutHistoryTrackerService] 
} 
export class AppComponent { 

有趣的是,在这种设置下,一切工作得都很完美。这一点很明显;TrainerAppComponentRouterOutlet的父组件,它内部加载WorkoutRunnerComponentWorkoutHistoryComponent。因此,在这种设置中,依赖项由TrainerAppComponent注入器来满足。

如果中间组件已声明自己为宿主组件,则可以在组件层次结构上操作依赖项查找。我们将在后面的章节中了解更多关于它的内容。

层次注入器允许我们在组件级别注册依赖项,避免需要全局注册所有依赖项。

此功能的典型用例是在构建 Angular 库组件时。此类组件可以注册自己的依赖项,而无需要求库的消费者注册库特定的依赖项。

记住:如果你在加载正确的服务/依赖项时遇到麻烦,请确保检查任何级别的组件层次结构中是否进行了覆盖。

我们现在理解了组件中依赖项解析的工作方式。但是,如果一个服务有依赖项会发生什么?还有更多未知的领域要探索。让我们进一步扩展我们的应用程序。

在继续进一步之前,移除在组件上完成的任何provider注册。

使用@Injectable进行依赖注入

WorkoutHistoryTrackerService有一个基本缺陷:历史数据没有持久化。刷新应用程序,历史数据就会丢失。我们需要添加持久化逻辑来存储历史数据。为了避免任何复杂的设置,我们将使用浏览器本地存储来存储历史数据。

通过从trainer/src/app/core文件夹调用此 CLI 命令添加一个新的LocalStorageService服务:

ng generate service local-storage

将以下两个函数复制到生成的类中,或者从checkpoint3.2GitHub 分支复制它们:

getItem<T>(key: string): T {
    if (localStorage[key]) {
      return <T>JSON.parse(localStorage[key]);
    }
    return null;
}

setItem(key: string, item: any) {
    localStorage[key] = JSON.stringify(item);
}

这是一个简单的浏览器localStorage对象的包装器。

就像任何其他依赖项一样,将其注入到WorkoutHistoryTrackerService构造函数(workout-history-tracker.ts文件)中,并使用必要的导入:

import {LocalStorage} from './local-storage'; 
... 
constructor(private storage: LocalStorageService) {

建议在服务上应用默认的Injectable装饰器,即使我们在模块上注册了依赖项(NgModule提供者注册语法)。特别是当服务本身有依赖项时,就像前面的WorkoutHistoryTrackerService示例一样。在使用基于模块的服务注册时,不要使用InjectableprovidedIn装饰器属性。

通过添加@Injectable装饰器,我们迫使 TypeScript 编译器为WorkoutHistoryTrackerService类生成元数据。这包括有关构造函数参数的详细信息。Angular DI(依赖注入)消耗这些生成的元数据以确定服务具有的依赖项类型,并在服务创建时满足这些依赖项。

那么,使用WorkoutHistoryTrackerServiceWorkoutRunnerComponent呢?我们没有在那里使用@Injectable,但仍然,DI(依赖注入)工作。我们不需要。任何装饰器都行,并且已经应用了@Component装饰器到所有组件上。

实际上,LocalStorage服务与WorkoutHistoryTrackerService之间的集成是一个平凡的过程。

按照以下方式更新WorkoutHistoryTrackerService的构造函数:

constructor(private storage: LocalStorage) { 
   this.workoutHistory = (storage.getItem<Array<WorkoutLogEntry>>(this.storageKey) || [])
      .map((item: WorkoutLogEntry) => {
        item.startedOn = new Date(item.startedOn.toString());
        item.endedOn = item.endedOn == null ? null : new Date(item.endedOn.toString());
        return item;
      }); 
} 

并添加一个storageKey的声明:

private storageKey = 'workouts';

构造函数从本地存储中加载锻炼日志。调用map函数是必要的,因为存储在localStorage中的所有内容都是字符串。因此,在反序列化时,我们需要将字符串转换回日期值。

startTrackingexerciseCompleteendTracking函数中最后添加以下语句:

this.storage.setItem(this.storageKey, this.workoutHistory);

每当历史数据发生变化时,我们都会将锻炼历史保存到本地存储中。

就这样!我们已经通过localStorage构建了锻炼历史跟踪。验证一下!

在我们继续到重要项目——音频支持之前,需要做一些小的修复以提供更好的用户体验。第一个修复与“历史”链接有关。

使用路由服务跟踪路由更改

Header 组件中,History 链接对所有路由都是可见的,除了当锻炼正在进行时。我们不希望用户不小心点击历史链接而丢失正在进行的锻炼。此外,在锻炼时,没有人对了解锻炼历史感兴趣。

修复很简单。我们只需要确定当前路由是否是锻炼路由,并隐藏链接。Router 服务将帮助我们完成这项工作。

打开 header.component.ts 并查看高亮显示的实现:

import { Router, NavigationEnd } from '@angular/router';
import 'rxjs/add/operator/filter'; ... 
export class HeaderComponent { 
  private showHistoryLink= true; 
  constructor(private router: Router) { 
    this.router.events.pipe( 
 filter(e => e instanceof NavigationEnd))
 .subscribe((e: NavigationEnd) => {
 this.showHistoryLink = !e.url.startsWith('/workout');
 }); 
  } 

showHistoryLink 属性绑定到视图,并决定是否向用户显示历史链接。在构造函数中,我们注入 Router 服务并使用 subscribe 函数订阅 events 可观察对象。

我们将在本章后面学习更多关于可观察对象的知识,但就目前而言,了解可观察对象是引发事件的对象,并且可以被订阅就足够了。由于路由器在整个组件生命周期中引发了许多事件,filter 操作符允许我们过滤我们感兴趣的事件,而 subscribe 函数注册了一个回调函数,该函数在每次路由更改时被调用。

要了解其他路由器事件,包括 NavigationStartNavigationEndNavigationCancelNavigationError,请查看路由器文档 (bit.ly/ng-router-events) 以了解事件何时被引发。

回调实现只是根据当前路由 URL 切换 showHistoryLink 状态。要在视图中使用 showHistoryLink,只需更新标题模板行中的锚点标签为:

<li *ngIf="showHistoryLink"><a routerLink="/history" ...>...</a></li>

就这样!在锻炼页面上不会显示 History 链接。

如果你在运行代码时遇到问题,请查看 checkpoint3.2 Git 分支以获取我们迄今为止所做的工作的版本。或者如果你不使用 Git,请从 bit.ly/ng6be-checkpoint-3-2 下载 checkpoint3.2 的快照(ZIP 文件)。在首次设置快照时,请参考 trainer 文件夹中的 README.md 文件。

另一个修复/增强与锻炼页面上的视频面板有关。

修复视频播放体验

当前视频面板的实现最多只能称为业余水平。默认播放器的尺寸很小。当我们播放视频时,锻炼不会暂停。在锻炼转换时,视频播放会被中断。此外,整体视频加载体验在每次锻炼程序开始时都会增加明显的延迟。这清楚地表明,这种视频播放方法需要一些修复。

这是我们将要做的来修复视频面板:

  • 显示锻炼视频的缩略图而不是加载视频播放器本身

  • 当用户点击缩略图时,加载一个包含更大视频播放器的弹出/对话框,该播放器可以播放所选视频

  • 在视频播放时暂停锻炼

让我们开始工作吧!

使用视频缩略图

video-player.component.html中的ngForHTML 模板替换为以下片段:

<div *ngFor="let video of videos" class="row">
      <div class="col-sm-12 p-2">
        <img class="video-image" [src]="'//i.ytimg.com/vi/'+video+'/hqdefault.jpg'" />
      </div>
</div> 

我们已经放弃了 iframe,而是加载了视频的缩略图图像(检查img标签)。这里显示的所有其他内容都是为了样式化图像。

我们参考了 Stack Overflow 帖子(bit.ly/so-yt-thumbnail)来确定我们视频的缩略图图像 URL。

开始一个新的锻炼;图像应该会显示出来,但播放功能是损坏的。我们需要添加一个视频播放对话框。

使用 ngx-modialog 库

要在对话框中显示视频,我们将集成一个第三方库,ngx-modialog,可在 GitHub 上找到,bit.ly/ngx-modialog。让我们安装和配置这个库。

在命令行(在trainer文件夹内),运行以下命令来安装库:

npm i ngx-modialog@5.0.0 --save

正在进行的 Angular v6 兼容的ngx-modialog库工作(github.com/shlomiassaf/ngx-modialog/issues/426)。要使用依赖于较旧版本的 RxJS 的版本 5 库,在继续之前,从命令行安装rxjs-compat包,npm i rxjs-compat --save

接下来在核心模块中导入和配置库。打开core.module.ts并添加以下高亮配置:

import { RouterModule } from '@angular/router';
import { ModalModule } from 'ngx-modialog';
import { BootstrapModalModule } from 'ngx-modialog/plugins/bootstrap';
...
imports: [
   ...
 ModalModule.forRoot(),
 BootstrapModalModule
  ],

现在库已经准备好使用。

虽然ngx-modialog提供了一些预定义的模板用于标准对话框,如警告、提示和确认,但这些对话框在外观和感觉方面提供的定制很少。为了更好地控制对话框 UI,我们需要创建一个自定义对话框,幸运的是,这个库支持这样做。

创建自定义对话框

ngx-modialog中的自定义对话框不过是包含了一些特殊库结构的 Angular 组件。

让我们从构建一个显示 YouTube 视频的弹出对话框的视频对话框组件开始。通过导航到trainer/src/app/workout-runner/video-player并运行以下命令来生成组件的模板:

ng generate component video-dialog -is

checkpoint3.3Git 分支(GitHub 位置:bit.ly/ng6be-3-3-video-dialog)中的workout-runner/video-player/video-dialog文件夹复制视频对话框实现到您的本地设置中。您需要更新组件实现和视图。

接下来,更新workout-runner.module.ts并在模块装饰器中添加一个新的entryComponents属性:

...
declarations: [..., VideoDialogComponent], 
entryComponents:[VideoDialogComponent] 

新创建的VideoDialogComponent需要添加到entryComponents中,因为它在组件树中没有被明确使用。

VideoDialogComponent 是一个标准的 Angular 组件,包含一些模态对话框和特定的实现,我们将在后面进行描述。在 VideoDialogComponent 内部声明的 VideoDialogContext 类是为了将点击的 YouTube 视频的 videoId 传递给对话框实例而创建的。库使用这个上下文类在调用代码和模态对话框之间传递数据。VideoDialogContext 类继承了一个配置类,该配置类是对话框库用来改变模态对话框的行为和 UI 的 BSModalContext

为了更好地了解 VideoDialogContext 的使用方法,让我们在点击视频图片时从锻炼运行器调用前面的对话框。

更新 video-player.component.html 中的 ngFor div 并添加一个 click 事件处理器:

<div *ngFor="let video of videos" (click)="playVideo(video)"     ...> 

前面的处理器调用 playVideo 方法,传入点击的视频。playVideo 函数反过来打开相应的视频对话框。将 playVideo 实现添加到 video-player.component.ts 中,如下所示:

import { Modal } from 'ngx-modialog/plugins/bootstrap';
import { VideoDialogComponent, VideoDialogContext } from './video-dialog/video-dialog.component';
import { overlayConfigFactory } from 'ngx-modialog';
... 
export class VideoPlayerComponent { 
      @Input() videos: Array<string>;

constructor(private modal: Modal) { } playVideo(videoId: string) { this.modal.open(VideoDialogComponent, 
                            overlayConfigFactory(new VideoDialogContext(videoId))); }
}

playVideo 函数调用 Modal 类的 open 方法,传入要打开的对话框组件和一个包含 YouTube 视频的 videoId 的新实例的 VideoDialogContext 类。在继续之前,删除 ngOnChange 函数和接口声明。

回到 VideoDialogComponent 的实现,该组件实现了模态库所需的 ModalComponent<VideoDialogContext> 接口。看看上下文(VideoDialogContext)是如何传递给构造函数的,以及我们是如何从上下文中提取并分配 videoId 属性的。然后只需将 videoId 属性绑定到模板视图(见 HTML 模板)并渲染 YouTube 播放器即可。

我们就可以开始了。加载应用并开始锻炼。然后点击任何锻炼视频图片。视频对话框应该会加载,现在我们可以观看视频了!

在我们调用对话框实现完成之前,还有一个小问题需要修复。当对话框打开时,锻炼应该暂停:目前还没有这样做。我们将在下一节的末尾使用 Angular 的事件基础设施帮助你修复它。

如果你在运行代码时遇到问题,请查看 checkpoint3.3 Git 分支,那里有我们到目前为止所做的工作的可用版本。或者如果你不使用 Git,可以从 bit.ly/ng6be-checkpoint-3-3 下载 checkpoint3.3 的快照(ZIP 文件)。在第一次设置快照时,请参考 trainer 文件夹中的 README.md 文件。

在完成应用并使用 Angular 构建新的东西之前,我们计划向 7-Minute Workout 添加最后一个功能:音频支持。这也教会了我们一些新的跨组件通信模式。

使用 Angular 事件进行跨组件通信

在上一章学习 Angular 的绑定基础设施时,我们提到了事件。现在是时候更深入地研究事件了。让我们为7 分钟健身法添加音频支持。

使用音频跟踪锻炼进度

对于7 分钟健身法应用程序,添加声音支持至关重要。一个人不能在一直盯着屏幕的同时进行锻炼。音频线索帮助用户有效地完成锻炼,因为他们只需遵循音频指示。

这里是我们将如何使用音频线索支持练习跟踪的方法:

  • 一个滴答作响的时钟音轨在锻炼期间显示进度

  • 中途指示器发出声音,表示锻炼已进行了一半

  • 当练习即将结束时,会播放一个完成练习的音频剪辑

  • 在休息阶段播放音频剪辑,并通知用户下一个练习

每种情况都会有音频剪辑。

现代浏览器对音频有很好的支持。HTML5 <audio> 标签提供了一个将音频剪辑嵌入 HTML 内容的方法。我们也将使用<audio>标签来播放我们的剪辑。

由于计划使用 HTML <audio> 元素,我们需要创建一个包装指令,以便我们从 Angular 控制音频元素。记住,指令是 HTML 扩展,但没有视图。

checkpoint3.4 Git 和trainer/static/audio文件夹包含所有用于播放的音频文件;首先复制它们。如果你不使用 Git,本章代码的快照可在bit.ly/ng6be-checkpoint-3-4找到。下载并解压缩内容,然后复制音频文件。

构建 Angular 指令以包装 HTML 音频

如果你与 JavaScript 和 jQuery 有很多工作,你可能已经意识到我们故意避免直接访问 DOM 来进行任何组件实现。我们没有这个必要。Angular 的数据绑定基础设施,包括属性、属性和事件绑定,帮助我们操作 HTML 而不接触 DOM。

对于音频元素,访问模式也应该是 Angular 风格的。在 Angular 中,唯一可以接受并实践直接 DOM 操作的地方是在指令内部。让我们创建一个包装音频元素访问的指令。

导航到trainer/src/app/shared并运行此命令以生成模板指令:

ng generate directive my-audio

由于这是我们第一次创建指令,我们鼓励您查看生成的代码。

由于指令添加到共享模块中,因此也需要导出。在exports数组中添加MyAudioDirective引用(shared.module.ts)。然后使用以下代码更新指令定义:

    import {Directive, ElementRef} from '@angular/core'; 

    @Directive({ 
      selector: 'audio', 
      exportAs: 'MyAudio' 
    }) 
    export class MyAudioDirective { 
      private audioPlayer: HTMLAudioElement; 
      constructor(element: ElementRef) { 
        this.audioPlayer = element.nativeElement; 
      } 
    } 

MyAudioDirective类用@Directive装饰。@Directive装饰器与@Component装饰器类似,但我们不能有附加的视图。因此,不允许templatetemplateUrl

之前的 selector 属性允许框架识别应用指令的位置。我们将生成的 [abeMyAudioDirective] 属性选择器替换为 audio。使用 audio 作为选择器使得我们的指令为 HTML 中使用的每个 <audio> 标签加载。新的选择器作为一个元素选择器工作。

在标准场景中,指令选择器是基于属性的(例如,[abeMyAudioDirective] 用于生成的代码),这有助于我们识别指令被应用的位置。我们偏离了这个规范,并为 MyAudioDirective 指令使用了一个元素选择器。我们希望这个指令为每个音频元素加载,因此逐个音频声明添加特定指令的属性变得繁琐。因此,我们使用了元素选择器。

当我们在视图模板中使用此指令时,exportAs 的使用变得清晰。

构造函数中注入的 ElementRef 对象是 Angular 元素(在这种情况下是 audio),该指令被加载。Angular 在编译和执行 HTML 模板时为每个组件和指令创建 ElementRef 实例。当在构造函数中请求时,DI 框架定位相应的 ElementRef 并将其注入。我们使用 ElementRef 在代码中获取底层音频元素(HTMLAudioElement 的实例)。audioPlayer 属性持有这个引用。

现在指令需要公开一个 API 来操作音频播放器。将这些函数添加到 MyAudioDirective 指令中:

    stop() { 
      this.audioPlayer.pause(); 
    }

    start() { 
      this.audioPlayer.play();
    }
    get currentTime(): number { 
      return this.audioPlayer.currentTime; 
    }

    get duration(): number { 
      return this.audioPlayer.duration; 
    }

    get playbackComplete() { 
      return this.duration == this.currentTime; 
    }

MyAudioDirective API 有两个函数(startstop)和三个获取器(currentTimeduration 和一个名为 playbackComplete 的布尔属性)。这些函数和属性的实现只是封装了音频元素函数。

在此处了解这些音频函数的 MDN 文档:bit.ly/html-media-element

要了解我们如何使用音频指令,让我们创建一个新的组件来管理音频播放。

为音频支持创建 WorkoutAudioComponent

如果我们回顾一下所需的音频提示,有四个不同的音频提示,因此我们将创建一个包含五个嵌入 <audio> 标签的组件(两个音频标签一起用于下一个音频)。

从命令行进入 trainer/src/app/workout-runner 文件夹,并使用 Angular CLI 添加一个新的 WorkoutAudioComponent 组件。

打开 workout-audio.component.html 并将现有的视图模板替换为以下 HTML 片段:

<audio #ticks="MyAudio" loop src="img/tick10s.mp3"></audio>
<audio #nextUp="MyAudio" src="img/nextup.mp3"></audio>
<audio #nextUpExercise="MyAudio" [src]="'/assets/audio/' + nextupSound"></audio>
<audio #halfway="MyAudio" src="img/15seconds.wav"></audio>
<audio #aboutToComplete="MyAudio" src="img/321.wav"></audio> 

有五个 <audio> 标签,每个标签对应以下内容:

  • 滴答音频:第一个音频标签产生滴答声,一旦锻炼开始就立即启动。

  • 下一个音频和练习音频:接下来的两个音频标签一起工作。第一个标签产生“下一个”声音。实际的练习音频由第三个标签(在之前的代码片段中)处理。

  • ** halfway 音频**:第四个音频标签在练习进行到一半时播放。

  • 即将完成音频:最后的音频标签播放一段音乐以表示练习的完成。

你注意到每个audio标签中使用的#符号了吗?有一些变量赋值以#开头。在 Angular 世界中,这些变量被称为模板引用变量或有时称为模板变量

根据平台指南定义:

模板引用变量通常是对模板中的 DOM 元素或指令的引用。

不要将它们与我们在之前的ngFor指令中使用的模板输入变量混淆,即*ngFor="let video of videos"。模板输入变量(在这种情况下为video)的作用域在其声明的 HTML 片段内,而模板引用变量可以在整个模板中访问。

看看定义MyAudioDirective的最后部分。exportAs元数据设置为MyAudio。我们在为每个音频标签分配template reference variable时重复相同的MyAudio字符串:

#ticks="MyAudio"

exportAs的作用是定义可以在视图中使用的名称,以便将此指令分配给变量。记住,单个元素/组件可以应用多个指令。exportAs允许我们根据等号右侧的内容选择将哪个指令分配给模板引用变量。

通常,一旦声明,模板变量就可以访问它们附加到的视图元素/组件,以及其他视图部分,我们将在稍后讨论这一点。但在我们的情况下,我们将使用模板变量来引用父组件代码中的多个MyAudioDirective。让我们了解如何使用它们。

使用以下大纲更新生成的workout-audio.compnent.ts

import { Component, OnInit, ViewChild } from '@angular/core';
import { MyAudioDirective } from '../../shared/my-audio.directive';

@Component({
 ...
})
export class WorkoutAudioComponent implements OnInit {
 @ViewChild('ticks') private ticks: MyAudioDirective;
 @ViewChild('nextUp') private nextUp: MyAudioDirective;
 @ViewChild('nextUpExercise') private nextUpExercise: MyAudioDirective;
 @ViewChild('halfway') private halfway: MyAudioDirective;
 @ViewChild('aboutToComplete') private aboutToComplete: MyAudioDirective;
 private nextupSound: string;

  constructor() { } 
  ...
}

这个大纲中有趣的部分是对五个属性的反向@ViewChild装饰器。@ViewChild装饰器允许我们将子组件/指令/元素引用注入其父组件。传递给装饰器的参数是模板变量名称,这有助于 DI 匹配要注入的元素/指令。当 Angular 实例化主WorkoutAudioComponent时,它根据@ViewChild装饰器和传递的模板引用变量名称注入相应的音频指令。在我们详细查看@ViewChild之前,让我们完成基本类实现。

MyAudioDirective指令上未设置exportAs的情况下,@ViewChild注入将相关ElementRef实例注入,而不是MyAudioDirective实例。我们可以通过从myAudioDirective中移除exportAs属性,然后在WorkoutAudioComponent中查看注入的依赖项来确认这一点。

剩下的任务只是正确地在正确的时间播放正确的音频组件。将这些函数添加到WorkoutAudioComponent

stop() {
    this.ticks.stop();
    this.nextUp.stop();
    this.halfway.stop();
    this.aboutToComplete.stop();
    this.nextUpExercise.stop();
  }
  resume() {
    this.ticks.start();
    if (this.nextUp.currentTime > 0 && !this.nextUp.playbackComplete) 
        { this.nextUp.start(); }
    else if (this.nextUpExercise.currentTime > 0 && !this.nextUpExercise.playbackComplete)
         { this.nextUpExercise.start(); }
    else if (this.halfway.currentTime > 0 && !this.halfway.playbackComplete) 
        { this.halfway.start(); }
    else if (this.aboutToComplete.currentTime > 0 && !this.aboutToComplete.playbackComplete) 
        { this.aboutToComplete.start(); }
  }

  onExerciseProgress(progress: ExerciseProgressEvent) {
    if (progress.runningFor === Math.floor(progress.exercise.duration / 2)
      && progress.exercise.exercise.name != 'rest') {
      this.halfway.start();
    }
    else if (progress.timeRemaining === 3) {
      this.aboutToComplete.start();
    }
  }

  onExerciseChanged(state: ExerciseChangedEvent) {
    if (state.current.exercise.name === 'rest') {
      this.nextupSound = state.next.exercise.nameSound;
      setTimeout(() => this.nextUp.start(), 2000);
      setTimeout(() => this.nextUpExercise.start(), 3000);
    }
  } 

写这些函数有困难吗?它们在 checkpoint3.3 Git 分支中可用。

在前面的代码中使用了两个新的模型类。将它们的声明添加到 model.ts 中,如下所示(同样在 checkpoint3.3 中可用):

export class ExerciseProgressEvent {
    constructor(
        public exercise: ExercisePlan,
        public runningFor: number,
        public timeRemaining: number,
        public workoutTimeRemaining: number) { }
}

export class ExerciseChangedEvent {
    constructor(
        public current: ExercisePlan,
        public next: ExercisePlan) { }
} 

这些是用于跟踪进度事件的模型类。WorkoutAudioComponent 实现消费这些数据。请记住在 workout-audio.component.ts 中导入 ExerciseProgressEventExerciseProgressEvent 的引用。

再次强调,音频组件通过定义两个事件处理器来消费事件:onExerciseProgressonExerciseChanged。随着我们的深入,事件是如何生成的将变得清晰。

startresume 函数在锻炼开始、暂停或完成时停止和恢复音频。resume 函数的额外复杂性在于处理在下一个即将完成或音频播放中途暂停的锻炼情况。我们只想从我们离开的地方继续。

应该调用 onExerciseProgress 函数来报告锻炼进度。它用于根据锻炼的状态播放中途音频和即将完成的音频。传递给它的参数是一个包含锻炼进度数据的对象。

当锻炼改变时,应该调用 onExerciseChanged 函数。输入参数包含当前和下一个即将进行的锻炼,并帮助 WorkoutAudioComponent 决定何时播放下一个即将进行的锻炼音频。

在本节中,我们提到了两个新的概念:模板引用变量和将子元素/指令注入父元素。在我们继续实施之前,值得更详细地探索这两个概念。我们将从学习更多关于模板引用变量开始。

理解模板引用变量

模板引用变量是在视图模板上创建的,并且主要从视图中被消费。正如你已经学到的,这些变量可以通过用于声明它们的 # 前缀来识别。

模板变量的最大好处之一是它们在视图模板级别促进了跨组件通信。一旦声明,这些变量就可以被兄弟元素/组件及其子元素引用。查看以下片段:

    <input #emailId type="email">Email to {{emailId.value}} 
    <button (click)= "MailUser(emaild.value)">Send</button> 
emailId, and then references it in the interpolation and the button click expression.

Angular 模板引擎将 input 的 DOM 对象(HTMLInputElement 的实例)分配给 emailId 变量。由于该变量在兄弟元素中可用,我们在按钮的 click 表达式中使用它。

模板变量也可以与组件一起工作。我们可以轻松地做到这一点:

    <trainer-app> 
     <workout-runner #runner></workout-runner> 
     <button (click)= "runner.start()">Start Workout</button> 
    </trainer-app> 

在这种情况下,runnerWorkoutRunnerComponent 对象的引用,按钮用于启动锻炼。

ref- 前缀是 # 的规范替代品。#runner 变量也可以声明为 ref-runner

模板变量赋值

你可能没有注意到,但在最后几节中描述的模板变量赋值中有些有趣的事情。为了回顾,我们使用的三个示例是:

<audio #ticks="MyAudio" loop src="img/tick10s.mp3"></audio> 

<input #emailId type="email">Email to {{emailId.value}}

<workout-runner #runner></workout-runner> 

分配给变量的内容取决于变量在哪里声明。这由 Angular 的规则控制:

  • 如果元素上存在指令,例如在前面示例中显示的 MyAudioDirective,则指令设置值。MyAudioDirective 指令将 ticks 变量设置为 MyAudioDirective 的一个实例。

  • 如果没有指令存在,则将分配底层的 HTML DOM 元素或组件对象(如 inputworkout-runner 示例中所示)。

我们将使用这项技术来实现锻炼音频组件与锻炼运行组件的集成。这种介绍为我们提供了我们需要的先发优势。

我们承诺要介绍的其他新概念是使用 ViewChildViewChildren 装饰器进行子元素/指令注入。

使用 @ViewChild 装饰器

@ViewChild 装饰器指示 Angular DI 框架在组件树中搜索一些特定的子组件/指令/元素,并将其注入到父组件中。这允许父组件通过子组件的引用与子组件/元素进行交互,这是一种新的通信模式!

在前面的代码中,音频元素指令(MyAudioDirective 类)被注入到 WorkoutAudioComponent 代码中。

为了建立上下文,让我们重新检查 WorkoutAudioComponent 的一个视图片段:

    <audio #ticks="MyAudio" loop src="img/tick10s.mp3"></audio> 

Angular 将指令(MyAudioDirective)注入到 WorkoutAudioComponent 的属性 ticks 中。搜索是基于传递给 @ViewChild 装饰器的选择器进行的。让我们再次看看音频示例:

 @ViewChild('ticks') private ticks: MyAudioDirective;

ViewChild 上的选择器参数可以是一个字符串值,在这种情况下,Angular 会搜索匹配的模板变量,就像之前一样。

或者它可以是类型。这是有效的,应该注入 MyAudioDirective 的一个实例:

@ViewChild(MyAudioDirective) private ticks: MyAudioDirective; 

然而,在我们的情况下它不起作用。为什么?因为 WorkoutAudioComponent 视图中声明了多个 MyAudioDirective 指令,每个 <audio> 标签一个。在这种情况下,第一个匹配项被注入。这并不很有用。如果视图中只有一个 <audio> 标签,传递类型选择器就会起作用!

@ViewChild 装饰的属性在组件的 ngAfterViewInit 事件钩子被调用之前一定会被设置。这意味着如果在这个构造函数内部访问这些属性,它们将是 null

Angular 还有一个装饰器可以定位和注入多个子组件/指令:@ViewChildren

@ViewChildren 装饰器

@ViewChildren@ViewChild 的工作方式类似,但它可以用来将多个子类型注入父组件。再次以之前的音频组件为例,使用 @ViewChildren,我们可以获取 WorkoutAudioComponent 中的所有 MyAudioDirective 指令实例,如下所示:

@ViewChildren(MyAudioDirective) allAudios: QueryList<MyAudioDirective>; 

仔细观察;allAudios 不是一个标准的 JavaScript 数组,而是一个自定义类,QueryList<Type>QueryList 类是一个不可变集合,包含 Angular 根据传递给 @ViewChildren 装饰器的筛选标准所能定位到的组件/指令的引用。这个列表最好的地方是 Angular 将会保持这个列表与视图状态同步。当指令/组件在视图中动态添加/删除时,这个列表也会更新。使用 ng-for 生成的组件/指令是这种动态行为的典型例子。考虑之前的 @ViewChildren 使用和这个视图模板:

<audio *ngFor="let clip of clips" src="img/ "+{{clip}}></audio> 

Angular 创建的 MyAudioDirective 指令的数量取决于 clips 的数量。当使用 @ViewChildren 时,Angular 将正确的 MyAudioDirective 实例数量注入到 allAudio 属性中,并在向 clips 数组添加或删除项目时保持同步。

虽然 @ViewChildren 的使用允许我们获取所有 MyAudioDirective 指令,但它不能用来控制播放。你看,我们需要获取单个 MyAudioDirective 实例,因为音频播放的时间不同。因此,有独特的 @ViewChild 实现。

一旦我们掌握了每个音频元素附加的 MyAudioDirective 指令,就只需在正确的时间播放音频轨道。

集成 WorkoutAudioComponent

虽然我们已经将音频播放功能组件化到 WorkoutAudioComponent 中,但它始终与 WorkoutRunnerComponent 的实现紧密耦合。WorkoutAudioComponentWorkoutRunnerComponent 中获取其操作智能。因此,这两个组件需要交互。WorkoutRunnerComponent 需要提供 WorkoutAudioComponent 状态变化数据,包括锻炼开始、运动进度、锻炼停止、暂停和恢复。

实现这种集成的一种方法是在 WorkoutRunnerComponent 中使用当前公开的 WorkoutAudioComponent API(停止、恢复和其他函数)。

可以通过将 WorkoutAudioComponent 注入到 WorkoutRunnerComponent 中来完成某些操作,就像我们之前将 MyAudioDirective 注入到 WorkoutAudioComponent 中一样。

WorkoutRunnerComponent's 视图中声明 WorkoutAudioComponent,例如:

<div class="row pt-4">...</div>
<abe-workout-audio></abe-workout-audio>

这样做给了我们 WorkoutRunnerComponent 实现内部的 WorkoutAudioComponent 引用:

@ViewChild(WorkoutAudioComponent) workoutAudioPlayer: WorkoutAudioComponent; 

然后,可以从代码的不同位置调用 WorkoutAudioComponent 的功能,从 WorkoutRunnerComponent 中调用。例如,这是 pause 如何改变的方式:

    pause() { 
      clearInterval(this.exerciseTrackingInterval); 
      this.workoutPaused = true; 
 this.workoutAudioPlayer.stop(); 
    }

要播放下一个音频,我们需要更改startExerciseTimeTracking函数的部分:

this.startExercise(next); 
this.workoutAudioPlayer.onExerciseChanged(new ExerciseChangedEvent(next, this.getNextExercise()));

这是一个完全可行的选项,其中WorkoutAudioComponent成为由WorkoutRunnerComponent控制的哑组件。这个解决方案的唯一问题是它给WorkoutRunnerComponent的实现增加了一些噪音。WorkoutRunnerComponent现在还需要管理音频播放。

然而,有一个替代方案。

WorkoutRunnerComponent可以暴露在健身执行的不同时间触发的事件,例如健身开始、练习开始和健身暂停。拥有WorkoutRunnerComponent暴露事件的优点是,它允许我们使用相同的事件将其他组件/指令与WorkoutRunnerComponent集成。无论是WorkoutAudioComponent还是我们未来创建的组件。

暴露WorkoutRunnerComponent事件

到目前为止,我们只探讨了如何消费事件。Angular 还允许我们触发事件。Angular 组件和指令可以使用EventEmitter类和@Output装饰器来暴露自定义事件。

在变量声明部分的末尾添加这些事件声明:

workoutPaused: boolean; 
@Output() exercisePaused: EventEmitter<number> = 
    new EventEmitter<number>(); @Output() exerciseResumed: EventEmitter<number> = 
    new EventEmitter<number>() @Output() exerciseProgress:EventEmitter<ExerciseProgressEvent> = 
    new EventEmitter<ExerciseProgressEvent>(); @Output() exerciseChanged: EventEmitter<ExerciseChangedEvent> = 
    new EventEmitter<ExerciseChangedEvent>(); @Output() workoutStarted: EventEmitter<WorkoutPlan> = 
    new EventEmitter<WorkoutPlan>(); @Output() workoutComplete: EventEmitter<WorkoutPlan> = 
    new EventEmitter<WorkoutPlan>();

事件名称是自解释的,在我们的WorkoutRunnerComponent实现中,我们需要在适当的时候触发它们。

记得将ExerciseProgressEventExerciseChangeEvent导入到已经声明的model中。并将OutputEventEmitter导入到@angular/core中。

让我们尝试理解@Output装饰器和EventEmitter类的作用。

@Output装饰器

在第二章“构建我们的第一个应用 - 7 分钟健身”中,我们介绍了相当多的 Angular 事件处理能力。具体来说,我们学习了如何使用括号()语法在组件、指令或 DOM 元素上消费任何事件。那么,我们自己触发事件呢?

在 Angular 中,我们可以创建和触发我们自己的事件,这些事件表示在我们的组件/指令中发生了值得注意的事情。使用@Output装饰器和EventEmitter类,我们可以定义和触发自定义事件。

这也是回顾我们在第二章“构建我们的第一个应用 - 7 分钟健身”中学习的关于事件知识的好时机,重新查看“事件处理子节”和“Angular 事件绑定基础设施”部分。

记住这一点:组件通过事件与外部世界进行通信。当我们声明:

@Output() exercisePaused: EventEmitter<number> = new EventEmitter<number>(); 

这表示WorkoutRunnerComponent暴露了一个事件exercisePaused(在健身暂停时触发)。

要订阅此事件,我们可以执行以下操作:

<abe-workout-runner (exercisePaused)="onExercisePaused($event)"></abe-workout-runner>

这看起来与我们在健身运行者模板中进行的 DOM 事件订阅非常相似。看看这个从健身运行者视图摘录的示例:

<div id="pause-overlay" (click)="pauseResumeToggle()" (window:keyup)="onKeyPressed($event)"> 

@Output 装饰器指示 Angular 使此事件可用于模板绑定。没有 @Output 装饰器创建的事件不能在 HTML 中引用。

@Output 装饰器还可以接受一个参数,表示事件的名称。如果没有提供,装饰器将使用属性名称:@Output("workoutPaused") exercisePaused: EventEmitter<number> ...。这声明了一个 workoutPaused 事件而不是 exercisePaused

就像任何装饰器一样,@Output 装饰器只是为了向 Angular 框架提供元数据。真正的重活是由 EventEmitter 类完成的。

使用 EventEmitter 的事件处理

Angular 采用 reactive programming(也称为 Rx-style programming)来支持使用事件进行异步操作。如果你第一次听到这个术语或者对反应式编程不太了解,你并不孤单。

反应式编程完全是关于针对 异步数据流 进行编程。这样的流不过是基于它们发生的时间顺序排列的一系列持续事件。我们可以想象一个流是一个生成数据(以某种方式)并将其推送到一个或多个订阅者的管道。由于这些事件是由订阅者异步捕获的,因此它们被称为异步数据流。

数据可以是任何东西,从浏览器/DOM 元素事件到用户输入,再到使用 AJAX 加载远程数据。使用 Rx 风格,我们统一消费这些数据。

在 Rx 世界中,有观察者和可观察者,这是一个来自非常流行的 观察者设计模式 的概念。可观察者是发出数据的流。另一方面,观察者订阅这些事件。

Angular 中的 EventEmitter 类主要负责提供事件支持。它既充当 观察者 又充当 可观察者。我们可以在它上面触发事件,它也可以监听事件。

EventEmitter 上有两个对我们有意义的函数:

  • emit:正如其名所示,使用此函数来触发事件。它接受一个单一参数,即事件数据。"emit" 是 可观察者端

  • subscribe:使用此函数来订阅由 EventEmitter 引发的事件。"subscribe" 是观察者端。

让我们进行一些事件发布和订阅,以了解前面函数的工作方式。

从 WorkoutRunnerComponent 中引发事件

看一下 EventEmitter 的声明。这些已经使用 type 参数声明。EventEmitter 上的 type 参数表示发出的数据类型。

让我们在 workout-runner.component.ts 文件中添加事件实现,从文件顶部开始向下移动。

start 函数的末尾添加此语句:

this.workoutStarted.emit(this.workoutPlan);

我们使用 EventEmitteremit 函数来使用当前锻炼计划作为参数触发 workoutStarted 事件。

pause,添加此行来引发 exercisePaused 事件:

this.exercisePaused.emit(this.currentExerciseIndex); 

resume,添加以下行:

this.exerciseResumed.emit(this.currentExerciseIndex); 

每次在触发exercisePausedexerciseResumed事件时,我们都会将当前练习索引作为参数传递给emit

startExerciseTimeTracking函数中,在调用startExercise之后添加以下高亮代码:

this.startExercise(next); 
this.exerciseChanged.emit(new ExerciseChangedEvent(next, this.getNextExercise()));

传递的参数包含即将开始的练习(next)和下一个练习(this.getNextExercise())。

向相同的功能添加以下高亮代码:

this.tracker.endTracking(true); 
this.workoutComplete.emit(this.workoutPlan); 
this.router.navigate(['finish']); 

当锻炼完成时,会触发事件。

在同一个函数中,我们触发一个事件来传达锻炼进度。添加以下语句:

--this.workoutTimeRemaining; 
this.exerciseProgress.emit(new ExerciseProgressEvent( this.currentExercise, this.exerciseRunningDuration, this.currentExercise.duration -this.exerciseRunningDuration, this.workoutTimeRemaining));

这就完成了我们的事件实现。

如你所猜,WorkoutAudioComponent现在需要消费这些事件。这里的挑战是如何组织这些组件,以便它们可以以最小的相互依赖进行通信。

组件通信模式

按照目前的实现,我们有:

  • 基本的WorkoutAudioComponent实现

  • 通过暴露锻炼生命周期事件增强WorkoutRunnerComponent

这两个组件现在只需要相互通信。

如果父组件需要与其子组件通信,它可以这样做:

  • 属性绑定:父组件可以设置子组件的属性绑定,以便将数据推送到子组件。例如,这种属性绑定可以在锻炼暂停时停止音频播放:
        <workout-audio [stopped]="workoutPaused"></workout-audio>

在这种情况下,属性绑定工作得很好。当锻炼暂停时,音频也会停止。但并不是所有情况都可以使用属性绑定来处理。播放下一个练习音频或半程音频需要更多的控制。

  • 在子组件上调用函数:如果父组件能够获取到子组件,它也可以在子组件上调用函数。我们已经在WorkoutAudioComponent实现中看到了如何使用@ViewChild@ViewChildren装饰器来实现这一点。这种方法及其不足之处也在集成 WorkoutAudioComponent部分中简要讨论过。

还有一个不太好的选择。不是父组件引用子组件,而是子组件引用父组件。这允许子组件调用父组件的公共函数或订阅父组件的事件。

我们将尝试这种方法,然后废弃实现,寻找更好的方案!从我们计划实施的不是很理想的解决方案中可以学到很多。

将父组件注入到子组件中

WorkoutRunnerComponent视图中的最后一个关闭div之前添加WorkoutAudioComponent

 <abe-workout-audio></abe-workout-audio> 

接下来,将WorkoutRunnerComponent注入到WorkoutAudioComponent中。打开workout-audio.component.ts并添加以下声明并更新构造函数:

private subscriptions: Array<any>; 

constructor( @Inject(forwardRef(() => WorkoutRunnerComponent)) 
    private runner: WorkoutRunnerComponent) { 
    this.subscriptions = [ 
      this.runner.exercisePaused.subscribe((exercise: ExercisePlan) => 
          this.stop()), 
      this.runner.workoutComplete.subscribe((exercise: ExercisePlan) => 
          this.stop()), 
      this.runner.exerciseResumed.subscribe((exercise: ExercisePlan) => 
          this.resume()), 
      this.runner.exerciseProgress.subscribe((progress: ExerciseProgressEvent) => 
          this.onExerciseProgress(progress)),

      this.runner.exerciseChanged.subscribe((state: ExerciseChangedEvent) =>  
          this.onExerciseChanged(state))]; 
    } 

并且记得添加以下导入:

    import {Component, ViewChild, Inject, forwardRef} from '@angular/core'; 
    import {WorkoutRunnerComponent} from '../workout-runner.component'  

在运行应用程序之前,让我们尝试理解我们所做的一切。在构造注入中涉及一些技巧。如果我们直接尝试将WorkoutRunnerComponent注入到WorkoutAudioComponent中,它将失败,Angular 会抱怨无法找到所有依赖项。阅读代码并仔细思考;有一个微妙的依赖循环问题潜伏其中。WorkoutRunnerComponent已经依赖于WorkoutAudioComponent,因为我们已经在WorkoutRunnerComponent视图中引用了WorkoutAudioComponent。现在通过在WorkoutAudioComponent中注入WorkoutRunnerComponent,我们创建了一个依赖循环。

循环依赖对于任何依赖注入框架来说都是一项挑战。当创建具有循环依赖的组件时,框架必须以某种方式解决这个循环。在先前的例子中,我们通过使用@Inject装饰器和传递使用forwardRef()全局框架函数创建的令牌来解决循环依赖问题。

一旦正确完成注入,在构造函数中,我们使用EventEmittersubscribe函数将处理程序附加到WorkoutRunnerComponent事件。传递给subscribe的箭头函数在事件发生时(带有特定的事件参数)被调用。我们将所有订阅收集到一个subscription数组中。当我们需要取消订阅以避免内存泄漏时,这个数组非常有用。

关于EventEmitter的一些信息:EventEmitter的订阅(subscribe函数)接受三个参数:

    subscribe(generatorOrNext?: any, error?: any, complete?: any) : any 
  • 第一个参数是一个回调,它在事件发出时被调用

  • 第二个参数是一个错误回调函数,当可观察者(生成事件的那个部分)出错时被调用

  • 最后一个参数接受一个回调函数,当可观察者完成发布事件时会被调用

我们已经做了足够的事情来使音频集成工作。运行应用程序并开始锻炼。除了滴答声之外,所有的\音频剪辑都在正确的时间播放。你可能需要等待一段时间才能听到其他音频剪辑。问题是什么?

结果表明,我们在锻炼开始时从未启动滴答声剪辑。我们可以通过在ticks音频元素上设置autoplay属性或使用组件生命周期事件来触发滴答声来修复它。让我们采取第二种方法。

使用组件生命周期事件

WorkoutAudioComponent中注入的MyAudioDirective,如下所示,在视图初始化之前不可用:

<audio #ticks="MyAudio" loop src="img/tick10s.mp3"></audio>
<audio #nextUp="MyAudio" src="img/nextup.mp3"></audio>
...

我们可以通过在构造函数中访问ticks变量来验证它;它将是 null。Angular 还没有完成它的魔法,我们需要等待WorkoutAudioComponent的子组件初始化。

组件的生命周期钩子可以帮助我们在这里。当组件的视图初始化完成后,会调用AfterViewInit事件钩子,因此这是一个安全的地方,可以从中访问组件的子指令/元素。让我们快速完成它。

通过添加接口实现和必要的导入来更新WorkoutAudioComponent,如高亮所示:

import {..., AfterViewInit} from '@angular/core'; 
... 
export class WorkoutAudioComponent implements OnInit, AfterViewInit { 
    ngAfterViewInit() { 
          this.ticks.start(); 
    }

好吧,去测试一下应用。应用现在有了完整的音频反馈,很棒!

虽然表面上看起来一切都很正常,但现在应用程序中存在内存泄漏。如果在锻炼过程中我们离开锻炼页面(到开始或结束页面),然后再返回到锻炼页面,多个音频剪辑会在随机时间播放。

看起来WorkoutRunnerComponent在路由导航时没有被销毁,因此,包括WorkoutAudioComponent在内的所有子组件都没有被销毁。最终结果是什么?每次我们导航到锻炼页面时都会创建一个新的WorkoutRunnerComponent,但在离开导航时却从未从内存中移除。

这种内存泄漏的主要原因是我们添加到WorkoutAudioComponent中的事件处理器。当音频组件卸载时,我们需要从这些事件中取消订阅,否则WorkoutRunnerComponent引用将永远不会被解除引用。

另一个组件生命周期事件在这里发挥了救星的作用:OnDestroy。将以下实现添加到WorkoutAudioComponent类中:

    ngOnDestroy() { 
      this.subscriptions.forEach((s) => s.unsubscribe()); 
    }

还记得要添加对OnDestroy事件接口的引用,就像我们为AfterViewInit所做的那样。

希望我们在事件订阅期间创建的subscription数组现在看起来是有意义的。一次性取消订阅!

这种音频集成现在已完成。虽然这种方法不是整合两个组件的糟糕方式,但我们能做得更好。子组件引用父组件似乎是不受欢迎的。

在继续之前,请删除从workout-audio.component.ts的`将父组件注入到子组件中部分开始添加的代码。

使用事件和模板变量进行兄弟组件交互

如果WorkoutRunnerComponentWorkoutAudioComponent被组织为兄弟组件会怎样?

如果WorkoutAudioComponentWorkoutRunnerComponent成为兄弟组件,我们可以充分利用 Angular 的事件模板引用变量。困惑吗?好吧,首先,组件应该这样布局:

    <workout-runner></workout-runner> 
    <workout-audio></workout-audio> 

这让你想起什么吗?从这个模板开始,你能猜出最终的 HTML 模板会是什么样子吗?在你继续之前先想想。

仍然感到困惑?一旦我们将它们作为兄弟组件,Angular 模板引擎的力量就显现出来了。以下模板代码足以整合WorkoutRunnerComponentWorkoutAudioComponent

<abe-workout-runner (exercisePaused)="wa.stop()" 
    (exerciseResumed)="wa.resume()" 
    (exerciseProgress)= "wa.onExerciseProgress($event)" 
    (exerciseChanged)= "wa.onExerciseChanged($event)" 
    (workoutComplete)="wa.stop()" 
    (workoutStarted)="wa.resume()"> 
</abe-workout-runner> 
<abe-workout-audio #wa></abe-workout-audio> 

WorkoutAudioComponent模板变量wa通过在WorkoutRunnerComponent的事件处理器表达式中引用变量来被操作。相当优雅!我们仍然需要解决这个方法中最大的难题:前面的代码去哪里了?记住,WorkoutRunnerComponent是作为路由加载的一部分加载的。在代码的任何地方都没有这样的语句:

    <workout-runner></workout-runner> 

我们需要重新组织组件树,引入一个可以托管WorkoutRunnerComponentWorkoutAudioComponent的容器组件。然后路由器加载这个容器组件而不是WorkoutRunnerComponent。让我们开始吧。

通过导航到trainer/src/app/workout-runner并在命令行中执行来生成新的组件代码:

ng generate component workout-container -is

将描述事件的 HTML 代码复制到模板文件中。锻炼容器组件就准备好了。

我们只需要重新配置路由设置。打开app-routing.module.ts。更改锻炼运行者的路由并添加必要的导入:

import {WorkoutContainerComponent} 
 from './workout-runner/workout-container/workout-container.component'; 
..
{ path: '/workout', component: WorkoutContainerComponent },

我们有一个清晰、简洁且令人愉悦的音频集成工作!

现在是结束本章的时候了,但在结束之前,我们需要解决在早期部分引入的视频播放器对话框故障。当视频播放器对话框打开时,锻炼不会停止/暂停。

我们不会在这里详细说明修复方法,并敦促读者在没有查阅checkpoint3.4代码的情况下尝试修复。

这里有一个明显的提示。使用事件基础设施!

另一个提示:从VideoPlayerComponent引发事件,每个播放开始和结束时引发一个。

最后一个提示:对话框服务(Modal)上的open函数返回一个承诺,当对话框关闭时该承诺被解决。

如果你在运行代码时遇到问题,请查看checkpoint3.4Git 分支,以获取我们迄今为止所做工作的一个工作版本。或者如果你不使用 Git,请从bit.ly/ng6be-checkpoint-3-4下载checkpoint3.4的快照(ZIP 文件)。在首次设置快照时,请参考trainer文件夹中的README.md文件。

摘要

逐步地,一点一滴地,我们已经为7 分钟锻炼应用添加了许多对任何专业应用至关重要的增强功能。仍然有空间添加新功能和改进,但核心应用运行得很好。

我们以探索 Angular 的单页应用程序(SPA)功能开始本章。在这里,我们了解了基本的 Angular 路由、设置路由、使用路由配置、使用RouterLink指令生成链接,以及使用 Angular 的RouterLocation服务进行导航。

从应用的角度来看,我们在7 分钟锻炼中添加了开始、完成和工作页面。

我们随后构建了一个锻炼历史追踪服务,用于追踪历史锻炼执行情况。在这个过程中,我们深入了解了 Angular 的依赖注入(DI)。我们涵盖了如何注册依赖项,依赖项令牌是什么,以及依赖项在本质上是如何单例的。我们还了解了注入器以及层次注入如何影响依赖项探测。

最后,我们触及了一个重要话题:跨组件通信,主要使用 Angular 事件。我们详细介绍了如何使用@Output装饰器和EventEmitter创建自定义事件。

本章中我们提到的 @ViewChild@ViewChildren 装饰器帮助我们理解父组件如何获取子组件以供使用。Angular DI 还允许将父组件注入到子组件中。

我们通过构建一个 WorkoutAudioComponent 来结束本章,并强调了如何使用 Angular 事件和模板变量实现兄弟组件之间的通信。

接下来是什么?我们将构建一个新的应用程序,个人教练。这个应用程序将允许我们构建自己的自定义训练。一旦我们能够创建自己的训练,我们就将 7 分钟训练 应用程序转变为一个通用的 训练运行者 应用程序,该应用程序可以运行我们使用 个人教练 构建的训练。

在下一章中,我们将展示 Angular 的表单功能,同时构建一个 UI,使我们能够创建、更新和查看我们自己的自定义训练/练习。

第四章:个人教练

7 分钟训练应用程序为我们了解 Angular 提供了一个极好的机会。在处理应用程序的过程中,我们已经覆盖了许多 Angular 结构。然而,像 Angular 表单支持和客户端-服务器通信这样的领域仍然未被探索。这部分原因是,从功能角度来看,7 分钟训练与最终用户的接触点有限。交互仅限于开始、停止和暂停训练。此外,应用程序既不消耗也不产生任何数据(除了训练历史)。

在本章中,我们计划深入探讨上述两个领域之一,即 Angular 表单支持。保持健康和健身主题(无意中打趣),我们计划构建一个个人教练应用程序。新应用程序将是7 分钟训练的扩展,允许我们构建自己的定制训练计划,这些计划不仅限于我们已有的7 分钟训练计划。

本章致力于理解 Angular 表单以及如何在构建个人教练应用程序时使用它们。

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

  • 定义个人教练需求:由于我们在本章中构建了一个新应用程序,因此我们首先定义应用程序需求。

  • 定义个人教练模型:任何应用程序设计都始于定义其模型。我们为个人教练定义了模型,这与之前构建的7 分钟训练应用程序类似。

  • 定义个人教练布局和导航:我们定义了新应用程序的布局、导航模式和视图。我们还设置了一个与 Angular 路由和主视图集成的导航系统。

  • 添加支持页面:在我们专注于表单功能并构建训练组件之前,我们构建了一些用于训练和运动列表的支持组件。

  • 定义训练构建器组件结构:我们规划出我们将使用的训练构建器组件来管理训练计划。

  • 构建表单:我们广泛使用 HTML 表单和输入元素来创建自定义训练计划。在这个过程中,我们将学习更多关于 Angular 表单的知识。我们涵盖的概念包括:

    • 表单类型:可以使用 Angular 构建两种类型的表单:模板驱动和响应式。在本章中,我们正在使用模板驱动和响应式表单。

    • ngModel:这为模板驱动的表单提供了双向数据绑定,并允许我们跟踪更改和验证表单输入。

    • 响应式表单控件:这些包括表单构建器、表单控件、表单组和表单数组。这些用于以编程方式构建表单。

    • 数据格式化:这些是允许我们为用户反馈添加样式的 CSS 类。

    • 输入验证:我们将了解 Angular 表单的验证功能。

个人教练应用程序 - 问题范围

7 分钟锻炼应用程序很好,但如果我们能创建一个允许我们构建更多此类锻炼程序的应用程序,这些程序根据我们的健身水平和强度需求定制,会怎样呢?有了这种灵活性,我们可以构建任何类型的锻炼,无论是 7 分钟、8 分钟、15 分钟还是其他任何变化。机会是无限的。

在这个前提下,让我们开始构建自己的个人教练应用程序的旅程,这个应用程序可以帮助我们根据我们的具体需求创建和管理训练/锻炼计划。让我们从定义应用程序的需求开始。

新的个人教练应用程序将包括现有的7 分钟锻炼应用程序。支持锻炼创建的组件将被称为锻炼构建器。7 分钟锻炼应用程序本身也将被称为锻炼运行器。在接下来的章节中,我们将修复锻炼运行器,使其能够运行使用锻炼构建器创建的任何锻炼。

个人教练需求

基于管理和锻炼的概念,以下是我们个人教练应用程序应该满足的一些需求:

  • 列出所有可用锻炼的能力

  • 创建和编辑锻炼的能力。在创建和编辑锻炼时,它应该具备:

    • 添加锻炼属性的能力,包括名称、标题、描述和休息时长

    • 为锻炼添加/删除多个锻炼的能力

    • 在锻炼中排列锻炼的能力

    • 保存锻炼数据的能力

  • 列出所有可用锻炼的能力

  • 创建和编辑锻炼的能力。在创建和编辑锻炼时,它应该具备:

    • 添加锻炼属性的能力,如名称、标题、描述和程序

    • 为锻炼添加图片的能力

    • 为锻炼添加相关视频的能力

    • 为锻炼添加音频提示的能力

所有要求似乎都很直观,所以让我们从应用程序的设计开始。按照惯例,我们首先需要考虑可以支持这些需求的模型。

个人教练模型

没有惊喜!个人教练模型本身是在创建7 分钟锻炼应用程序时定义的。锻炼和锻炼的两个核心概念对个人教练同样适用。

现有的锻炼模型唯一的问题是它位于workout-runner目录中。这意味着为了使用它,我们必须从该目录导入它。将模型移动到core文件夹中更有意义,这样就可以清楚地知道它可以跨功能使用。我们将在本章中这样做。

开始编写个人教练的代码

首先,从 GitHub 仓库中书的checkpoint4.1下载新的个人教练应用程序的基础版本。

代码可在 GitHub github.com/chandermani/angular6byexample 上供大家下载。检查点作为 GitHub 上的分支实现。要下载的分支如下:GitHub Branch: checkpoint4.1。如果你不使用 Git,可以从以下 GitHub 位置下载 Checkpoint 4.1 的快照(ZIP 文件):github.com/chandermani/angular6byexample/archive/checkpoint4.1.zip。在首次设置快照时,请参考 trainer 文件夹中的 README.md 文件。

这段代码包含了完整的 7 分钟健身 (Workout Runner) 应用。我们添加了一些更多内容来支持新的 个人教练 应用。一些相关的更新包括:

  • 添加新的 WorkoutBuilder 功能。这个功能包含与 个人教练 相关的实现。

  • 更新应用布局和样式。

  • trainer/src/app 目录下的 workout-builder 文件夹中添加一些组件和带有占位符内容的 HTML 模板,用于 个人教练

  • 定义一个新的路由到 WorkoutBuilder 功能。我们将在下一节中介绍如何在应用中设置此路由。

  • 正如我们刚才提到的,将现有的 model.ts 文件移动到 core 文件夹。

让我们讨论我们将如何使用模型。

trainer/src/app 目录下的 workout-builder 文件夹中使用个人教练模型。

在最后一章,我们专门用一节来介绍学习 Angular 服务,我们发现服务对于在控制器和其他 Angular 构造之间共享数据很有用。打开位于 app 目录下 core 文件夹中的 model.ts 文件。在这个类中,我们实际上没有任何数据,而是一个描述数据形状的蓝图。计划使用服务来公开这个模型结构。我们已经在 Workout Runner 中做到了这一点。现在,我们将在 Workout Builder 中做同样的事情。

model.ts 文件已被移动到 core 文件夹,因为它在 Workout BuilderWorkout Runner 应用之间是共享的。注意:在 checkpoint4.1 中,我们已经更新了 workout-runner.component.tsworkout-audio.component.tsworkout-history-tracker-service.ts 中的导入语句,以反映这一变化。

在第二章,“构建我们的第一个应用 - 7 分钟健身”,我们回顾了模型文件中的类定义:ExerciseExercisePlanWorkoutPlan 正如我们当时提到的,这三个类构成了我们的基础模型。我们现在将开始在新的应用中使用这个基础模型。

在模型设计方面就这些了。接下来我们要做的是为新应用定义结构。

个人教练布局

个人教练 的骨架结构如下所示:

这包含以下组件:

  • 顶部导航:这包含应用品牌标题和历史链接。

  • 子导航:这包含根据活动组件变化的导航元素。

  • 左侧导航:这包含依赖于活动组件的元素。

  • 内容区域:这是我们的组件主视图将显示的地方。这是大多数动作发生的地方。我们将创建/编辑练习和锻炼,并在这里显示练习和锻炼的列表。

查看源代码文件;在 trainer/src/app 下有一个新的文件夹 workout-builder。它为之前描述的每个组件都有文件,其中包含一些占位符内容。我们将随着本章的进行构建这些组件。

然而,我们首先需要在应用程序中链接这些组件。这需要我们定义锻炼构建器应用程序的导航模式,并相应地定义应用程序路由。

带有路由的个人教练导航

我们计划为应用程序使用的导航模式是列表-详情模式。我们将为应用程序中可用的练习和锻炼创建列表页面。点击任何列表项将带我们到项目的详细视图,在那里我们可以执行所有 CRUD 操作(创建/读取/更新/删除)。以下路由遵循此模式:

路由 描述
/builder 这只是重定向到 builder/workouts
/builder/workouts 这列出了所有可用的锻炼。这是 锻炼构建器 的着陆页
/builder/workout/new 这创建一个新的锻炼
/builder/workout/:id 这编辑具有特定 ID 的现有锻炼
/builder/exercises 这列出了所有可用的练习
/builder/exercise/new 这创建一个新的练习
/builder/exercise/:id 这编辑具有特定 ID 的现有练习

开始使用个人教练导航

在这一点上,如果您查看 src/app 文件夹中的 app-routing.module.ts 中的路由配置,您将找到一个新路由定义,builder

const routes: Routes = [
    ...
    { path: 'builder', component: WorkoutBuilderComponent },
    ...
];

如果您运行应用程序,您将看到启动屏幕显示了另一个链接,创建一个锻炼:

在幕后,我们已将另一个路由链接添加到 start.component.html

<a routerLink="/builder" class="btn btn-primary btn-lg btn-block" role="button" aria-pressed="true">
   <span>Create a Workout</span>
   <span class="ion-md-add"></span>
</a>

如果您点击此链接,您将被带到以下视图:

再次,在幕后,我们已将 workout-builder.component.ts 添加到 trainer/src/app/workout-builder 文件夹中,并具有以下内联模板:

  template: `
    <div class="row">
      <div class="col-sm-3"></div>
      <div class="col-sm-6">
          <h1 class="text-center">Workout Builder</h1>
      </div>
      <div class="col-sm-3"></div>
    </div>
  `

并且这个视图在标题下通过我们的 app.component.html 模板中的路由出口显示:

<div class="container body-content app-container"> 
    <router-outlet></router-outlet> 
</div>` 

我们将此组件(以及我们为此功能伪造的其他文件)包裹在一个名为 workout-builder.module.ts 的新模块中:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';

import { WorkoutBuilderComponent } from './workout-builder.component';
import { ExerciseComponent } from './exercise/exercise.component';
import { ExercisesComponent } from './exercises/exercises.component';
import { WorkoutComponent } from './workout/workout.component';
import { WorkoutsComponent } from './workouts/workouts.component';

@NgModule({
  imports: [
    CommonModule
  ],
  declarations: [WorkoutBuilderComponent, ExerciseComponent, ExercisesComponent, WorkoutComponent, WorkoutsComponent]
})
export class WorkoutBuilderModule { }

在这里,与其他我们创建的模块相比,唯一可能看起来不同的地方是,我们导入的是 CommonModule 而不是 BrowserModule。这避免了第二次导入整个 BrowserModule,这在我们实现此模块的懒加载时会产生错误。

最后,我们在 app.module.ts 中添加了对该模块的导入:

  ... 
@NgModule({ 
  imports: [ 
  ... 
 WorkoutBuilderModule], 
  ... 

所以,这里没有什么令人惊讶的。这些都是我们在前几章中介绍的基本组件构建和路由模式。遵循这些模式,我们现在应该开始考虑为我们的新功能添加之前概述的附加导航。然而,在我们着手做这件事之前,还有一些事情我们需要考虑。

首先,如果我们开始将我们的路由添加到 app.routing-module.ts 文件中,那么存储在那里的路由数量将会增加。这些新的 Workout Builder 路由也将与 Workout Runner 的路由混合在一起**。虽然我们现在添加的路由数量可能看起来微不足道,但长期来看,这可能会成为一个维护问题。

其次,我们需要考虑的是,我们的应用程序现在由两个功能组成——Workout RunnerWorkout Builder。我们应该思考如何在我们的应用程序中分离这些功能,以便它们可以独立于彼此开发。

换句话说,我们希望我们构建的功能之间有松散耦合。使用这种模式允许我们在不影响其他功能的情况下,在我们的应用程序中替换掉一个功能。例如,在某个时候,我们可能希望将 Workout Runner 转换为移动应用,但保留 Workout Builder 作为基于网络的程序。

回到第一章,我们强调了这种将我们的组件彼此分离的能力是使用 Angular 实现的 组件设计模式 的关键优势之一。幸运的是,Angular 的路由器为我们提供了将我们的路由分离成逻辑上组织良好的 路由配置 的能力,这些配置与应用程序中的功能紧密匹配。

为了实现这种分离,Angular 允许我们使用 子路由,这样我们就可以隔离我们每个功能的路由。在本章中,我们将使用 子路由 来分离 Workout Builder 的路由。

Workout Builder 引入子路由

Angular 通过提供在应用程序中创建路由组件层次结构的能力来支持我们隔离新 Workout Builder 路由的目标。我们目前只有一个路由组件,它位于我们应用程序的根组件中。但 Angular 允许我们在根组件下添加所谓的 子路由组件。这意味着一个功能可以无视另一个功能使用的路由,每个功能都可以自由地根据该功能内部的变化调整其路由。

回到我们的应用程序,我们可以使用 Angular 的 子路由 来匹配我们应用程序两个功能的路由与将使用它们的代码。因此,在我们的应用程序中,我们可以将路由结构化为以下路由层次结构,用于我们的 Workout Builder(在这个阶段,我们将 Workout Runner 保持原样,以展示前后对比):

图片

采用这种方法,我们可以通过功能对路由进行逻辑分离,使它们更容易管理和维护。

因此,让我们开始通过向我们的应用程序添加子路由来启动。

从本节此点开始,我们将添加本章之前下载的代码。如果您想查看下一节的完整代码,可以从 GitHub 仓库中的checkpoint 4.2下载。如果您想在我们构建本节代码时一起工作,请确保添加trainer/src文件夹中包含此检查点的styles.css中的更改,因为我们在这里不会讨论它们。同时,请确保添加来自仓库中trainer/src/app/workout-builder文件夹的练习(exercise)、锻炼(workout)和导航文件。在这个阶段,这些只是占位符文件,我们将在本章的后面实现它们。然而,您需要这些占位符文件来实现锻炼构建器模块的导航。代码可在 GitHub 上供所有人下载,网址为github.com/chandermani/angular6byexample。检查点作为 GitHub 上的分支实现。要下载的分支如下:GitHub Branch: checkpoint4.2。如果您不使用 Git,可以从以下 GitHub 位置下载Checkpoint 4.2的快照(ZIP 文件):github.com/chandermani/angular6byexample/archive/checkpoint4.2.zip。在首次设置快照时,请参阅trainer文件夹中的README.md文件。

添加子路由组件

workout-builder目录下,添加一个名为workout-builder.routing.module.ts的新 TypeScript 文件,并包含以下导入:

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { WorkoutBuilderComponent } from './workout-builder.component';
import { WorkoutsComponent } from './workouts/workouts.component';
import { WorkoutComponent } from './workout/workout.component';
import { ExercisesComponent } from './exercises/exercises.component';
import { ExerciseComponent } from './exercise/exercise.component';

如您所见,我们正在导入刚才提到的组件;它们将成为我们锻炼构建器(exercise、exercises、workout 和 workouts)的一部分。与这些导入一起,我们还从 Angular 核心模块中导入NgModule,从 Angular 路由模块中导入RoutesRouterModule。这些导入将使我们能够添加和导出子路由。

我们在这里没有使用 Angular CLI,因为它没有创建路由模块的独立蓝图。然而,您可以在创建模块时使用--routing选项让 CLI 创建路由模块。在这种情况下,我们已经有了一个现有的模块创建,所以不能使用该标志。有关如何操作的更多详细信息,请参阅github.com/angular/angular-cli/blob/master/docs/documentation/stories/routing.md

然后,将以下路由配置添加到文件中:

const routes: Routes = [
  {
    path: 'builder',
    component: WorkoutBuilderComponent,
    children: [
         {path: '', pathMatch: 'full', redirectTo: 'workouts'},
         {path: 'workouts', component: WorkoutsComponent },
         {path: 'workout/new', component: WorkoutComponent },
         {path: 'workout/:id', component: WorkoutComponent },
         {path: 'exercises', component: ExercisesComponent},
         {path: 'exercise/new', component: ExerciseComponent },
         {path: 'exercise/:id', component: ExerciseComponent }
    ]
  },
];

第一个配置,path: 'builder',设置了子路由的基本 URL,以便每个子路由都将其作为前缀。下一个配置将WorkoutBuilder组件标识为该文件中子组件的特征区域根组件。这意味着它将是使用router-outlet显示每个子组件的组件。最后的配置是一个或多个子组件的列表,它定义了子组件的路由。

这里需要注意的一点是,我们已经使用以下配置将Workouts设置为子路由的默认值:

{path:'', pathMatch: 'full', redirectTo: 'workouts'}, 

此配置表示,如果有人导航到builder,他们将被重定向到builder/workouts路由。pathMatch: 'full'设置意味着只有当 workout/builder 之后的路径是一个空字符串时,才会进行匹配。这防止了如果路由是其他内容(如workout/builder/exercises或我们在该文件中配置的其他任何路由)时发生重定向。

最后,添加以下类声明,前面加上@NgModule装饰器,该装饰器定义了模块的导入和导出:

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule]
})
export class WorkoutBuilderRoutingModule { }

这个导入与app.routing-module.ts中的导入非常相似,只有一个区别:我们使用的是RouterModule.forChild而不是RouterModule.forRoot。这种差异的原因可能看起来很明显:我们正在创建子路由,而不是应用程序根目录中的路由,这就是我们表示的方式。然而,在底层,有一个显著的区别。这是因为我们的应用程序中不能有多个活动的路由服务。forRoot创建路由服务,但forChild不创建。

更新 WorkoutBuilder 组件

接下来,我们需要更新WorkoutBuilder组件以支持我们新的子路由。为此,将Workout Builder@Component装饰器更改为:

  1. 移除selector

  2. 在模板中添加一个<abe-sub-nav-main>自定义元素

  3. 在模板中添加一个<router-outlet>标签

装饰器现在应该看起来像以下这样:

@Component({
  template: `<div class="container-fluid fixed-top mt-5">
                <div class="row mt-5">
                  <abe-sub-nav-main></abe-sub-nav-main>
                </div>
                <div class="row mt-2">
                  <div class="col-sm-12">
                    <router-outlet></router-outlet>
                  </div>
                </div>
            <div>`
})

我们正在移除选择器,因为WorkoutBuilderComponent将不会嵌入到应用程序根目录app.component.ts中。相反,它将通过路由从app.routing-module.ts访问。虽然它将处理来自app.routes.ts的传入路由请求,但它将反过来将它们路由到 Workout Builder 功能中包含的其他组件。

这些组件将使用我们刚刚添加到WorkoutBuilder模板中的<router-outlet>标签来显示它们的视图。鉴于Workout BuilderComponent的模板将是简单的,我们使用内联template而不是templateUrl

通常,对于组件的视图,我们建议使用指向单独 HTML 模板文件的templateUrl。当你预计视图将涉及超过几行 HTML 时,这一点尤其正确。在这种情况下,在它自己的 HTML 文件中处理视图要容易得多。

我们还添加了一个<abe-sub-nav-main>元素,它将被用来创建一个用于在Workout Builder功能中导航的二级顶级菜单。我们将在本章稍后讨论这一点。

更新 Workout Builder 模块

现在,让我们更新WorkoutBuilderModule。首先,将以下导入添加到文件中:

import { WorkoutBuilderRoutingModule } from './workout-builder-routing.module';

它导入了我们刚刚设置的子路由。

接下来,更新@NgModule装饰器以添加workoutBuilderRoutingModule

...
@NgModule({
  imports: [
    CommonModule,
    WorkoutBuilderRoutingModule
  ],
...
}) 

最后,添加可在checkpoint4.2中找到的新导航组件的导入和声明:

import { LeftNavExercisesComponent } from './navigation/left-nav-exercises.component';
import { LeftNavMainComponent } from './navigation/left-nav-main.component';
import { SubNavMainComponent } from './navigation/sub-nav-main.component';
...
  declarations: [
    ...
    LeftNavExercisesComponent,
    LeftNavMainComponent,
    SubNavMainComponent]

更新 App 路由模块

最后一步:回到app.routing-module.ts,移除对WorkoutBuilderComponent的导入以及指向构建器的路由定义:{ path: 'builder', component: WorkoutBuilderComponent },

请确保在app.module.ts中保持对WorkoutBuilderModule的导入不变。我们将在下一节讨论懒加载时讨论移除它。

整合所有内容

从上一章,我们已经知道如何设置应用程序的根路由。但现在,我们有的不是根路由,而是包含子路由的区域或功能路由。我们已经能够实现之前讨论的关注点分离,因此现在所有与Workout Builder相关的路由都分别包含在其自己的路由配置中。这意味着我们可以在WorkoutBuilderRoutes组件中管理所有与Workout Builder相关的路由,而不会影响应用程序的其他部分。

如果我们现在从起始页面导航到 Workout Builder,我们可以看到路由器是如何将app.routes.ts中的路由与workout-builder.routes.ts中的默认路由组合起来的。

图片

如果我们在浏览器中查看 URL,它是/builder/workouts。你可能还记得,起始页面上的路由链接是['/builder']。那么路由器是如何带我们到这个位置的?

它这样做:当点击链接时,Angular 路由器首先在app-routing.module.ts中查找builder路径,因为该文件包含我们应用程序根路由的配置。路由器找不到该路径,因为我们已经从该文件的路由中移除了它。

然而,WorkoutBuilderModule已被导入到我们的AppModule中,而该模块又导入workoutBuilderRoutingModule。后一个文件包含我们刚刚配置的子路由。路由器发现该文件中的builder是父路由,因此它使用该路由。它还发现默认设置,在builder路径以空字符串结束的情况下(在本例中就是这样),将重定向到子路径workouts

如果您查看屏幕,您会看到它正在显示Workouts视图(而不是之前的Workout Builder)。这意味着路由器已成功将请求路由到WorkoutsComponent,这是我们在workoutBuilderRoutingModule中设置的子路由配置中的默认路由组件。

这里展示了路由解析的过程:

图片

最后关于子路由的一点思考。当您查看我们的子路由组件workout-builder.component.ts时,您会看到它没有对其父组件app.component.ts的引用(正如我们之前提到的,<selector>标签已被移除,因此WorkoutBuilderComponent没有被嵌入到根组件中)。这意味着我们已经成功地将WorkoutBuilderComponent(以及所有在WorkoutBuilderModule中导入的相关组件)封装起来,这样我们就可以将其移动到应用程序的任何其他位置,甚至可以移动到一个新的应用程序中。

现在,是我们将 Workout Builder 的路由转换为使用懒加载并构建其导航菜单的时候了。如果您想查看下一节完成的代码,可以从checkpoint 4.3的配套代码库中下载。再次提醒,如果您在我们构建应用程序的同时工作,请确保更新styles.css文件,这里我们未对其进行讨论。

代码也已在 GitHub 上供所有人下载,链接为github.com/chandermani/angular6byexample。检查点作为 GitHub 上的分支实现。要下载的分支如下:GitHub Branch: checkpoint4.3(文件夹 - trainer)。如果您不使用 Git,可以从以下 GitHub 位置下载Checkpoint 4.3的快照(ZIP 文件):github.com/chandermani/angular6byexample/archive/checkpoint4.3.zip。在首次设置快照时,请参考trainer文件夹中的README.md文件。

路由的懒加载

当我们推出我们的应用程序时,我们预计我们的用户将每天访问Workout Runner(我们知道这对你来说也是这样!)。但是,我们预计他们只会偶尔使用Workout Builder来构建他们的锻炼和训练计划。因此,如果我们能在用户只在Workout Runner中做锻炼时避免加载Workout Builder的开销,那就太好了。相反,我们更希望用户在想要添加或更新他们的锻炼和训练计划时才按需加载 Workout Builder。这种方法被称为懒加载。懒加载允许我们在加载模块时采用异步方法。这意味着我们可以只加载启动应用程序所需的资源,然后根据需要加载其他模块。

在幕后,当我们使用 Angular CLI 构建和提供我们的应用程序时,它使用 WebPack 的打包和分块功能来实现懒加载。我们将随着在应用程序中实现懒加载的过程来讨论这些功能。

因此,在我们的个人教练应用程序中,我们希望改变应用程序,使其仅在需要时才加载Workout Builder。Angular 路由器允许我们通过懒加载来实现这一点。

在我们开始实现懒加载之前,让我们先看看我们的当前应用程序以及它是如何加载我们的模块的。在“源”标签页中打开开发者工具,启动应用程序;当启动页面出现在你的浏览器中时,如果你在源树中的 webpack 节点下查看,你会看到应用程序中的所有文件都已加载,包括Workout RunnerWorkout Builder文件:

图片

因此,即使我们可能只想使用Workout Runner,我们也必须加载Workout Builder。从某种意义上说,如果你将我们的应用程序视为单页应用程序(SPA),这就有道理了。为了避免往返服务器,SPA 通常会在用户首次启动应用程序时加载所有将需要的资源。但在我们的情况下,重要的点是我们在应用程序首次加载时不需要Workout Builder。相反,我们希望在用户决定添加或更改锻炼或练习时才加载这些资源。

那么,让我们开始实现这一目标。

首先,修改app.routing-module.ts以添加以下路由配置WorkoutBuilderModule

const routes: Routes = [
    ...
    { path: 'builder', loadChildren: './workout-builder/workout-builder.module#WorkoutBuilderModule'},
    { path: '**', redirectTo: '/start' }
];

注意到loadChildren属性是:

module file path + # + module name 

此配置提供了加载和实例化WorkoutBuilderModule所需的信息。

接下来回到workout-builder-routing.module.ts,将path属性更改为空字符串:

export const Routes: Routes = [ 
    { 
 path: '', 
. . . 
    } 
]; 

我们进行此更改是因为我们现在将路径(builder)设置为WorkoutBuilderRoutes,这是我们在app.routing-module.ts中添加的新配置。

最后,回到app-module.ts文件,并从该文件的@NgModule配置中移除WorkoutBuilderModule导入。这意味着,我们不是在应用程序首次启动时加载锻炼构建者功能,而是在用户访问锻炼构建者路由时才加载它。

让我们再次使用ng serve构建和运行应用程序。在终端窗口中,你应该会看到以下类似的输出:

图片

这里有趣的是最后一行,它显示了名为workout.builder.module的单独文件,即workout-builder.module.chunk.jsWebPack使用了所谓的代码拆分,将我们的锻炼构建者模块分割成单独的块。这个块将在需要时(即,当路由导航到WorkoutBuilderModule时)才被加载到我们的应用程序中。

现在,在 Chrome 开发者工具中保持“源”标签页打开,再次在浏览器中打开应用程序。当起始页面加载时,只有与锻炼运行者相关的文件出现,而没有与锻炼构建者相关的文件,如图所示:

图片

然后,如果我们清除“网络”标签页并点击“创建锻炼”链接,我们将看到workout-builder.module块被加载:

图片

这意味着我们已经实现了新功能的封装,并且通过异步路由,我们能够使用懒加载仅在需要时加载所有组件。

儿童和异步路由使得实现既能拥有蛋糕又能吃掉蛋糕的应用程序变得简单。一方面,我们可以构建具有强大客户端导航的单页应用(SPAs),另一方面,我们还可以将功能封装在独立的子路由组件中,并在需要时才加载它们。

这种 Angular 路由的强大和灵活性使我们能够通过将应用程序的行为和响应性紧密映射到用户使用应用程序的方式,来满足用户期望。在这种情况下,我们利用了这些能力来实现我们的目标:立即加载锻炼运行者,以便我们的用户可以立即开始锻炼,同时避免加载锻炼构建者的开销,并且只在用户想要构建锻炼时才提供它。

现在,我们已经将路由配置设置在锻炼构建者中,我们将把注意力转向构建子级和左侧导航;这将使我们能够使用这种路由。接下来的几节将介绍如何实现这种导航。

集成子级和侧级导航

将子级和侧级导航集成到应用程序中的基本思想是提供上下文感知的子视图,这些视图根据活动视图而变化。例如,当我们处于列表页面而不是编辑项目时,我们可能希望在导航中显示不同的元素。电子商务网站是这种情况的一个很好的例子。想象一下亚马逊的搜索结果页面和产品详情页面。当上下文从产品列表变为特定产品时,加载的导航元素也会发生变化。

子级导航

我们首先将在 Workout Builder 中添加子级导航。我们已将 SubNavMainComponent 导入到 Workout Builder 中。但,目前它只是显示占位符内容:

我们现在将替换该内容为三个路由链接:主页、新建锻炼和新建练习。

打开 sub-nav-main.component.html 文件,将其中的 HTML 更改为以下内容:

<nav class="navbar fixed-top navbar-dark bg-primary mt-5">
    <div>
        <a [routerLink]="['/builder/workouts']" class="btn btn-primary">
        <span class="ion-md-home"></span> Home
        </a>
        <a [routerLink]="['/builder/workout/new']" class="btn btn-primary">
        <span class="ion-md-add"></span> New Workout
        </a>
        <a [routerLink]="['/builder/exercise/new']" class="btn btn-primary">
        <span class="ion-md-add"></span> New Exercise
        </a>
    </div>
</nav>

现在,重新运行应用程序,您将看到三个导航链接。如果我们点击“新建练习”链接按钮,我们将被路由到 ExerciseComponent,其视图将在 Workout Builder 视图的 Router Outlet 中显示:

新建锻炼链接按钮将以类似的方式工作;当点击时,它将用户带到 WorkoutComponent 并在路由出口中显示其视图。点击主页链接按钮将用户返回到 WorkoutsComponent 并查看。

侧边导航

Workout Builder 中的侧级导航将根据我们导航到的子组件而有所不同。例如,当我们第一次导航到 Workout Builder 时,我们将被带到 Workouts 屏幕,因为 WorkoutsComponent 路由是 Workout Builder 的默认路由。该组件将需要侧边导航;它将允许我们选择查看锻炼列表或练习列表。

Angular 的组件化特性为我们提供了一个简单的方法来实现这些上下文敏感的菜单。我们可以为每个菜单定义新的组件,然后将它们导入到需要它们的组件中。在这种情况下,我们有三个组件需要侧边菜单:WorkoutsExercisesWorkout。其中前两个组件实际上可以使用相同的菜单,所以我们实际上只需要两个侧边菜单组件:LeftNavMainComponent,它将类似于前面的菜单,将被 ExercisesWorkouts 组件使用,以及 LeftNavExercisesComponent,它将包含现有练习的列表,并将被 Workouts 组件使用。

我们已经有了两个菜单组件的文件,包括模板文件,并将它们导入到 WorkoutBuilderModule 中。我们现在将它们集成到需要它们的组件中。

首先,修改 workouts.component.html 模板以添加菜单的选择器:

<div class="row">
    <div>
        <abe-left-nav-main></abe-left-nav-main>
    </div>
    <div class="col-sm-10 builder-content">
        <h1 class="text-center">Workouts</h1>
    </div>
  </div>

然后,将left-nav-main.component.html中的占位文本替换为指向WorkoutsComponentExercisesComponent的导航链接:

<div class="left-nav-bar">
    <div class="list-group">
        <a [routerLink]="['/builder/workouts']" class="list-group-item list-group-item-action">Workouts</a>
        <a [routerLink]="['/builder/exercises']" class="list-group-item list-group-item-action">Exercises</a>
    </div>
</div>

运行应用程序,你应该会看到以下内容:

按照完全相同的步骤完成Exercises组件的侧菜单。

我们在这里不会展示这个菜单的代码,但你可以在 GitHub 仓库的checkpoint 4.3中的trainer/src/app目录下的workout-builder/exercises文件夹中找到它。

对于锻炼屏幕的菜单,步骤相同,但你应该将left-nav-exercises.component.html更改为以下内容:

<div class="left-nav-bar">
  <h3>Exercises</h3>
</div> 

我们将使用此模板作为构建屏幕左侧将出现并可以选择包含在锻炼中的锻炼列表的起点。

实现锻炼和锻炼列表

在我们开始实现锻炼和锻炼列表页面之前,我们需要一个用于锻炼和锻炼数据的存储库。当前的计划是使用内存中的存储库并通过 Angular 服务公开它。在第五章支持服务器数据持久性中,我们将讨论服务器交互,我们将把此数据移动到服务器存储库以实现长期持久性。目前,内存存储库就足够了。让我们添加存储库实现。

将 WorkoutService 作为锻炼和锻炼仓库

此处的计划是创建一个WorkoutService实例,该实例负责在两个应用程序之间公开锻炼和锻炼数据。该服务的主要职责包括:

  • 与锻炼相关的 CRUD 操作:获取所有锻炼,根据名称获取特定锻炼,创建锻炼,更新锻炼,以及删除它

  • 与锻炼相关的 CRUD 操作:这些操作与与锻炼相关的操作类似,但针对的是锻炼实体

代码可在 GitHub 上下载,网址为github.com/chandermani/angular6byexample。要下载的分支如下:GitHub 分支:checkpoint4.4(文件夹—trainer)。如果你不使用 Git,可以从以下 GitHub 位置下载Checkpoint 4.4的快照(ZIP 文件):github.com/chandermani/angular6byexample/archive/checkpoint4.4.zip。在首次设置快照时,请参考trainer文件夹中的README.md文件。再次提醒,如果你在我们构建应用程序的同时工作,请确保更新styles.css文件,这里我们不做讨论。因为本节中的一些文件相当长,我们有时会建议你直接将文件复制到你的解决方案中。

trainer/src/core文件夹中定位workout-service.ts。该文件中的代码应该如下所示,除了省略了长度较长的两个方法setupInitialExercisessetupInitialWorkouts

import {Injectable} from '@angular/core'; 
import {ExercisePlan} from './model'; 
import {WorkoutPlan} from './model'; 
import {Exercise} from "./model";
import { CoreModule } from './core.module'; 

@Injectable({
  providedIn: CoreModule
})
export class WorkoutService { 
    workouts: Array<WorkoutPlan> = []; 
    exercises: Array<Exercise> = []; 

    constructor() { 
        this.setupInitialExercises(); 
        this.setupInitialWorkouts(); 
    } 

    getExercises(){ 
        return this.exercises; 
    } 

    getWorkouts(){ 
        return this.workouts; 
    } 
    setupInitialExercises(){ 
     // implementation of in-memory store. 
    } 

    setupInitialWorkouts(){ 
     // implementation of in-memory store. 
    } 
}} 

正如我们之前提到的,Angular 服务的实现很简单。在这里,我们声明了一个名为WorkoutService的类,并用@Injectable装饰它。在@Injectable装饰器中,我们将provided-in属性设置为CoreModule。这会将WorkoutService注册为 Angular 依赖注入框架的一个提供者,并使其在整个应用程序中可用。

在类定义中,我们首先创建两个数组:一个用于Workouts,一个用于Exercises。这两个数组分别是WorkoutPlanExercise类型,因此我们需要从model.ts导入WorkoutPlanExercise以获取它们的类型定义。

构造函数调用两个方法来设置训练和服务的列表。目前,我们只是使用一个内存存储,用数据填充这些列表。

如其名称所示,两个方法getExercisesgetWorkouts分别返回一个锻炼和训练列表。由于我们计划使用内存存储来存储训练和锻炼数据,WorkoutsExercises数组存储这些数据。随着我们的进行,我们将在服务中添加更多功能。

是时候构建训练和锻炼列表的组件了!

训练和锻炼列表组件

首先,在trainer/src/app/workout-builder/workouts文件夹中打开workouts.component.ts文件,并按如下方式更新导入:

import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';

import { WorkoutPlan } from '../../core/model';
import { WorkoutService } from '../../core/workout.service';; 

这段新代码导入了 Angular 的RouterWorkoutService以及WorkoutPlan类型。

接下来,用以下代码替换类定义:

export class WorkoutsComponent implements OnInit { 
    workoutList:Array<WorkoutPlan> = []; 

    constructor( 
        public router:Router, 
        public workoutService:WorkoutService) {} 

    ngOnInit() { 
        this.workoutList = this.workoutService.getWorkouts(); 
    } 

    onSelect(workout: WorkoutPlan) { 
        this.router.navigate( ['./builder/workout', workout.name] ); 
    } 
} 

这段代码在构造函数中添加了RouterWorkoutService的注入。然后ngOnInit方法调用WorkoutServicegetWorkouts方法,并用从该方法调用返回的WorkoutPlans列表填充workoutList数组。我们将使用这个workoutList数组来填充将在Workouts组件视图中显示的训练计划列表。

你会注意到我们将调用WorkoutService的代码放入了ngOnInit方法中。我们不想在构造函数中放置这段代码。最终,我们将用对外部数据存储的调用替换掉这个服务使用的内存存储,我们不希望组件的实例化受到这个调用的影响。将这些方法调用添加到构造函数中也会使组件的测试变得复杂。

为了避免这种意外的副作用,我们将其代码放置在ngOnInit方法中。该方法实现了 Angular 的生命周期钩子之一OnInit,Angular 在创建服务实例后调用此方法。这样,我们依赖 Angular 以可预测的方式调用此方法,而不会影响组件的实例化。

接下来,我们将对Exercises组件进行几乎相同的更改。与Workouts组件一样,这段代码将锻炼服务注入到我们的组件中。这次,我们使用锻炼服务来检索锻炼内容。

由于它与我们在Workouts组件中刚刚展示的内容非常相似,所以我们在这里不会展示那段代码。只需从checkpoint 4.4中的workout-builder/exercises文件夹添加即可。

锻炼和锻炼列表视图

现在,我们需要实现迄今为止一直为空的列表视图!

在本节中,我们将更新checkpoint 4.3中的代码,以包含checkpoint 4.4中的内容。所以如果你正在与我们一起编码,只需遵循本节中概述的步骤。如果你想查看完成的代码,只需将checkpoint 4.4中的文件复制到你的解决方案中即可。

锻炼列表视图

为了使视图工作,打开workouts.component.html并添加以下标记:

<div class="row">
    <div>
        <abe-left-nav-main></abe-left-nav-main>
    </div>
    <div class="col-sm-10 builder-content">
        <h1 class="text-center">Workouts</h1>
        <div *ngFor="let workout of workoutList|orderBy:'title'" class="workout tile" (click)="onSelect(workout)">
          <div class="title">{{workout.title}}</div>
          <div class="stats">
              <span class="duration" title="Duration"><span class="ion-md-time"></span> - {{(workout.totalWorkoutDuration? workout.totalWorkoutDuration(): 0)|secondsToTime}}</span>
              <span class="float-right" title="Exercise Count"><span class="ion-md-list"></span> - {{workout.exercises.length}}</span>
          </div>
      </div>
    </div>
  </div>

我们正在使用 Angular 核心指令之一ngFor来遍历锻炼列表并在页面上显示它们。我们在ngFor前面添加*符号来标识它为 Angular 指令。使用let语句,我们将workout分配为本地变量,我们使用它来遍历锻炼列表并识别每个锻炼要显示的值(例如,workout.title)。然后,我们使用我们的自定义管道之一orderBy来按标题字母顺序显示锻炼列表。我们还使用另一个自定义管道secondsToTime来格式化显示的总锻炼时长。

如果你正在与我们一起编码,你需要将secondsToTime管道移动到共享文件夹中,并将其包含在SharedModule中。然后,将SharedModule添加到WorkoutBuilderModule中作为额外的导入。这个更改已经在 GitHub 仓库中的checkpoint 4.4中完成。

最后,我们将点击事件绑定到我们添加到组件中的以下onSelect方法:

 onSelect(workout: WorkoutPlan) { 
     this.router.navigate( ['/builder/workout', workout.name] ); 
 }  

这设置了导航到锻炼详情页。当我们在锻炼列表中点击一个项目时发生此导航。选定的锻炼名称作为路由/URL的一部分传递到锻炼详情页。

好吧,刷新一下构建页面(/builder/workouts);有一个锻炼列表,7 分钟锻炼。点击该锻炼的磁贴。你将被带到锻炼屏幕,并且锻炼名称7MinWorkout将出现在 URL 的末尾:

图片

锻炼屏幕

锻炼列表视图

我们将遵循与Workouts列表视图相同的方法来处理Exercises列表视图,只是在这种情况下,我们将实际实现两个视图:一个用于Exercises组件(当用户导航到该组件时将在主要内容区域显示)和一个用于LeftNavExercisesComponent练习上下文菜单(当用户导航到Workouts组件以创建或编辑锻炼时将显示)。

对于Exercises组件,我们将遵循与我们在Workouts组件中显示锻炼列表几乎相同的方法。所以我们不会在这里展示那段代码。只需从checkpoint 4.4添加exercises.component.tsexercises.component.html文件即可。

当你完成文件复制后,点击左侧导航中的“练习”链接来加载你在WorkoutService中已经配置的 12 个练习。

Workouts列表一样,这设置了导航到锻炼详情页的设置。点击练习列表中的项目将带我们到锻炼详情页。选定的练习名称作为路由/URL的一部分传递到锻炼详情页。

在最终列表视图中,我们将添加一个将在Workout Builder屏幕的左侧上下文菜单中显示的练习列表。当我们创建或编辑锻炼时,此视图将在左侧导航中加载。使用 Angular 的基于组件的方法,我们将更新leftNavExercisesComponent及其相关视图以提供此功能。我们在这里不会展示那段代码。只需从trainer/src/app/navigation文件夹中的checkpoint 4.4添加left-nav-exercises.component.tsleft-nav-exercises.component.html文件即可。

完成那些文件的复制后,点击Workout Builder中的子导航菜单上的“新建锻炼”按钮,你现在将看到在左侧导航菜单中显示的练习列表——这些是我们已经在WorkoutService中配置好的练习。

是时候添加加载、保存和更新锻炼/锻炼数据的能力了!

建立锻炼

Personal Trainer的核心功能围绕着锻炼和锻炼构建。所有这些都是为了支持这两个功能。在本节中,我们专注于使用 Angular 构建和编辑锻炼。

WorkoutPlan模型已经定义,因此我们了解构成锻炼的元素。Workout Builder页面简化了用户输入,并允许我们构建/持久化锻炼数据。

完成后,Workout Builder页面将看起来像这样:

图片

页面有一个左侧导航,列出了可以添加到锻炼中的所有练习。点击右侧的箭头图标将练习添加到锻炼的末尾。

中心区域被指定为健身建筑区域。它由从上到下排列的锻炼瓷砖和一个允许用户提供有关锻炼的其他详细信息(如名称、标题、描述和休息时长)的表单组成。

此页面以两种模式运行:

  • 创建/新建:此模式用于创建新的锻炼。URL 是#/builder/workout/new

  • 编辑:此模式用于编辑现有的锻炼。URL 是#/builder/workout/:id,其中:id映射到锻炼的名称。

在理解了页面元素和布局之后,现在是时候构建这些元素中的每一个了。我们将从左侧导航(导航)开始。

完成左侧导航

在上一节结束时,我们更新了Workout组件的左侧导航视图,以显示锻炼列表。我们的意图是让用户点击一个练习旁边的箭头将其添加到锻炼中。当时,我们推迟了在LeftNavExercisesComponent中实现与该点击事件绑定的addExercise方法。现在,我们将继续这样做。

我们有几个选择。LeftNavExercisesComponentWorkoutComponent的子组件,因此我们可以实现子/父组件间通信来完成这个任务。我们在上一章中讨论了这项技术,当时我们在处理7 分钟锻炼

然而,将练习添加到锻炼是构建锻炼的更大过程的一部分,使用子/父组件间通信会使AddExercise方法的实现与其他我们将要添加的功能有所不同。

因此,遵循另一种数据共享方法更有意义,这种方法我们可以一致地用于构建锻炼的整个过程中。这种方法涉及使用服务。当我们开始添加创建实际锻炼的其他功能,例如保存/更新逻辑和实现其他相关组件时,走服务路线的好处将越来越明显。

因此,我们引入了一个新的服务:WorkoutBuilderServiceWorkoutBuilderService服务的最终目标是协调在构建锻炼过程中WorkoutService(检索和持久化锻炼)和组件(如LeftNavExercisesComponent以及我们稍后将要添加的其他组件)之间的关系,从而将WorkoutComponent中的代码量减少到最低。

添加WorkoutBuilderService

WorkoutBuilderService监控应用程序用户正在构建的锻炼状态。它:

  • 跟踪当前锻炼

  • 创建新的锻炼

  • 加载现有锻炼

  • 保存锻炼

trainer/src/app下的workout-builder/builder-services文件夹中的checkpoint 4.5复制workout-builder-service.ts

代码也可在 GitHub 上供所有人下载,链接为github.com/chandermani/angular6byexample。检查点作为 GitHub 上的分支实现。要下载的分支如下:GitHub Branch: checkpoint4.5(文件夹—trainer)。如果您不使用 Git,可以从以下 GitHub 位置下载Checkpoint 4.5的快照(ZIP 文件):github.com/chandermani/angular6byexample/archive/checkpoint4.5.zip。在首次设置快照时,请参考trainer文件夹中的README.md文件。再次提醒,如果您在我们构建应用程序的同时工作,请确保更新styles.css文件,这里我们不做讨论。

虽然我们通常使服务在应用程序范围内可用,但WorkoutBuilderService将仅用于Workout Builder功能。因此,我们不是在AppModule的提供者中注册它,而是在WorkoutBuilderModule的提供者数组中注册它,如下所示(在文件顶部添加导入之后):

@NgModule({
....
  providers: [WorkoutBuilderService]
})

将其作为提供者在这里意味着它仅在访问Workout Builder功能时加载,并且不能从该模块外部访问。这意味着它可以独立于应用程序中的其他模块进行发展,并且可以修改而不会影响应用程序的其他部分。

让我们看看服务的一些相关部分。

WorkoutBuilderService需要WorkoutPlanExercisePlanWorkoutService的类型定义,因此我们将这些导入到组件中:

import { WorkoutPlan, ExercisePlan } from '../../core/model';
import { WorkoutService } from '../../core/workout.service';

WorkoutBuilderService依赖于WorkoutService以提供持久性和查询功能。我们通过将WorkoutService注入到WorkoutBuilderService的构造函数中解决这个依赖关系**:

 constructor(public workoutService: WorkoutService) {}

WorkoutBuilderService还需要跟踪正在构建的锻炼。我们使用buildingWorkout属性来完成这项工作。跟踪从我们在服务上调用startBuilding方法时开始:

startBuilding(name: string){ 
    if(name){ 
        this.buildingWorkout = this.workoutService.getWorkout(name) 
        this.newWorkout = false; 
    }else{ 
        this.buildingWorkout = new WorkoutPlan("", "", 30, []); 
        this.newWorkout = true; 
    } 
    return this.buildingWorkout; 
} 

此跟踪功能背后的基本思想是设置一个WorkoutPlan对象(buildingWorkout),该对象将被提供给组件以操作锻炼细节。startBuilding方法接受锻炼名称作为参数。如果没有提供名称,则表示我们正在创建一个新的锻炼,因此创建一个新的WorkoutPlan对象并分配给它;如果没有,我们通过调用WorkoutService.getWorkout(name)来加载锻炼细节。在任何情况下,buildingWorkout对象都包含正在进行的锻炼。

newWorkout对象表示锻炼是新的还是现有的。它用于在调用此服务上的save方法时区分保存和更新情况。

其余的方法,removeExerciseaddExercisemoveExerciseTo,都是一目了然的,并且会影响锻炼计划中的一部分锻炼列表(buildingWorkout)。

WorkoutBuilderServiceWorkoutService 上调用一个新的方法 getWorkout,我们还没有添加。请从 trainer/src/services 文件夹下的 workout-service.ts 文件中复制 getWorkout 的实现。由于实现相当简单,我们不会过多关注新的服务代码。

让我们回到左侧导航并实现剩余的功能。

使用锻炼导航添加锻炼

要将锻炼添加到我们正在构建的锻炼计划中,我们只需导入 WorkoutBuilderServiceExercisePlan,将 WorkoutBuilderService 注入到 LeftNavExercisesComponent 中,并调用其 addExercise 方法,传递所选的锻炼作为参数:

constructor( 
    public workoutService:WorkoutService, 
 public workoutBuilderService:WorkoutBuilderService) {} 
. . . 
addExercise(exercise:Exercise) { 
 this.workoutBuilderService.addExercise(new ExercisePlan(exercise, 30)); 
} 

在内部,WorkoutBuilderService.addExercise 通过新的锻炼更新 buildingWorkout 模型数据。

上述实现是独立组件之间共享数据的一个典型例子。共享服务以受控的方式向请求它的任何组件公开数据。在共享数据时,始终是一个好习惯使用方法而不是直接公开数据对象来公开状态/数据。我们可以在我们的组件和服务实现中看到这一点。LeftNavExercisesComponent 并不是直接更新锻炼数据;实际上,它没有直接访问正在构建的锻炼。相反,它依赖于服务方法 addExercise 来更改当前锻炼的锻炼列表。

由于服务是共享的,需要注意一些陷阱。由于服务可以通过系统注入,我们无法阻止任何组件依赖任何服务并以不一致的方式调用其函数,从而导致不期望的结果或错误。例如,在调用 addExercise 之前,WorkoutBuilderService 需要通过调用 startBuilding 来初始化。如果在初始化之前组件调用了 addExercise 会发生什么?

实现 Workout 组件

WorkoutComponent 负责管理锻炼。这包括创建、编辑和查看锻炼。由于引入了 WorkoutBuilderService,该组件的整体复杂性将降低。除了与模板视图集成、暴露和交互的主要责任外,我们将大部分其他工作委托给 WorkoutBuilderService

WorkoutComponent 与两个 routes/views 相关联,即 /builder/workout/new/builder/workout/:id。这些路由处理创建和编辑锻炼计划的情况。组件的第一项任务是加载或创建它需要操作的锻炼计划。

路由参数

在我们构建 WorkoutComponent 及其相关视图之前,我们需要简要介绍将用户带到该组件屏幕的导航。此组件处理创建和编辑锻炼场景。组件的第一个任务是加载或创建它需要操作的锻炼。我们计划使用 Angular 的路由框架将必要的数据传递给组件,以便它知道它是在编辑现有的锻炼还是创建一个新的锻炼,在现有锻炼的情况下,它应该编辑哪个组件。

这是如何完成的?WorkoutComponent 与两个路由相关联,即 /builder/workout/new/builder/workout/:id。这两个路由之间的区别在于这些路由的末尾是什么;在一种情况下,它是 /new,在另一种情况下,是 /:id。这些被称为 路由参数。第二个路由中的 :id 是一个路由参数的占位符。路由器将占位符转换为锻炼组件的 ID。正如我们之前所看到的,这意味着在 7 分钟锻炼 的情况下,将传递给组件的 URL 将是 /builder/workout/7MinuteWorkout

我们如何知道这个锻炼名称是正确的 ID 参数?如您所回忆的,当我们设置处理锻炼屏幕上锻炼瓷砖点击事件的程序时,该事件会将我们带到锻炼屏幕,我们指定锻炼名称作为 ID 的参数,如下所示:

 onSelect(workout: WorkoutPlan) { 
     this.router.navigate( ['./builder/workout', workout.name] ); 
 }  

在这里,我们正在使用路由器的程序化接口构建路由(我们已经在上一章详细介绍了路由,所以这里不再重复)。router.navigate 方法接受一个数组。这被称为 链接参数数组。数组中的第一个元素是路由的路径,第二个是一个指定锻炼 ID 的路由参数。在这种情况下,我们将 id 参数设置为锻炼名称。根据我们在上一章对路由的讨论,我们知道我们也可以将相同类型的 URL 作为路由链接的一部分构建,或者简单地将其输入浏览器以到达锻炼屏幕并编辑特定的锻炼。

两条路径中的另一条以 /new 结尾。由于此路径没有 token 参数,路由器将直接将未修改的 URL 传递给 WorkoutComponent。然后 WorkoutComponent 需要解析传入的 URL 以识别它应该创建一个新的组件。

路由守卫

但在链接将用户带到 WorkoutComponent 之前,还有另一个步骤需要我们考虑。始终存在一种可能性,即用于编辑锻炼的 URL 中传递的 ID 可能是错误的或缺失的。在这些情况下,我们不希望组件加载,而是希望将用户重定向到另一个页面或返回他们来的地方。

Angular 提供了一种使用 路由守卫 来实现此结果的方法。正如其名所示,路由守卫 提供了一种防止导航到路由 的方式。路由守卫可以用来注入自定义逻辑,可以执行诸如检查授权、加载数据和进行其他验证以确定是否需要取消导航到组件等操作。而且所有这些都是在组件加载之前完成的,所以如果路由被取消,它永远不会被看到。

Angular 提供了多个路由守卫,包括 CanActivateCanActivateChildCanDeActivateResolveCanLoad. 在这一点上,我们感兴趣的是 Resolve 路由守卫**. **Resolve 守卫将允许我们不仅检查是否存在一个锻炼项目,而且在加载 WorkoutComponent 之前加载与该锻炼项目相关的数据。这样做的好处是,我们避免了在 WorkoutComponent 中检查数据是否已加载的必要性,并且消除了在其组件模板中添加条件逻辑以确保数据在渲染时存在的需求。  这在下一章我们将开始使用 observables 时将特别有用,我们必须等待可观察对象完成,才能确保获得它将提供的数据。Resolve 守卫将处理等待可观察对象完成,这意味着 WorkoutComponent 在加载之前将确保拥有所需的数据。

实现 resolve 路由守卫

Resolve 守卫允许我们预取一个锻炼项目的数据。在我们的情况下,我们想要做的是使用 Resolve 来检查传递给现有锻炼项目的任何 ID 的有效性。具体来说,我们将通过调用 WorkoutBuilderService 来运行对该 ID 的检查,以检索锻炼计划并查看它是否存在。如果存在,我们将加载与锻炼计划相关的数据,以便它对 WorkoutComponent 可用;如果不存在,我们将重定向回锻炼项目屏幕。

workout.resolver.tstrainer/src/app/workout 下的 workout-builder/workout 文件夹复制到 checkpoint 4.5,你将看到以下代码:

import 'rxjs/add/operator/map';
import 'rxjs/add/operator/take';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { Router, Resolve, RouterStateSnapshot,
         ActivatedRouteSnapshot } from '@angular/router';
import { WorkoutPlan } from '../../core/model';
import { WorkoutBuilderService } from '../builder-services/workout-builder.service';

@Injectable()
export class WorkoutResolver implements Resolve<WorkoutPlan> {
  public workout: WorkoutPlan;

  constructor(
    public workoutBuilderService: WorkoutBuilderService,
    public router: Router) {}

  resolve(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): WorkoutPlan {
    let workoutName = route.paramMap.get('id');

    if (!workoutName) {
        workoutName = '';
    }

    this.workout = this.workoutBuilderService.startBuilding(workoutName);

    if (this.workout) {
        return this.workout;
    } else { // workoutName not found
        this.router.navigate(['/builder/workouts']);
        return null;
    }
  }
}

如您所见,WorkoutResolver 是一个可注入的类,它实现了 Resolve 接口。  代码将 WorkoutBuilderServiceRouter 注入到类中,并通过 resolve 方法实现接口。resolve 方法接受两个参数;ActivatedRouteSnapshotRouterStateSnapshot。在这种情况下,我们只对这两个参数中的第一个感兴趣,即 ActivatedRouteSnapshot。它包含一个 paramMap,从中我们提取出路由的 ID 参数。

然后,resolve方法使用路由中提供的参数调用WorkoutBuildingServicestartBuilding方法。如果工作存在,则resolve返回数据并继续导航;如果不存在,它将用户重定向到工作页面并返回 false。如果传递new作为 ID,WorkoutBuilderService将加载一个新的工作,并且Resolve守卫将允许导航继续到WorkoutComponent

resolve方法可以返回一个Promise、一个Observable或同步值。如果我们返回一个Observable,我们需要确保在导航之前Observable已经完成。然而,在这种情况下,我们正在对本地内存数据存储进行同步调用,所以我们只是返回一个值。

要完成WorkoutResolver的实现,首先确保将其导入并作为提供者添加到WorkoutBuilderModule中:

....
import { WorkoutResolver } from './workout/workout.resolver';

@NgModule({
....
  providers: [WorkoutBuilderService, WorkoutResolver]
})
....

然后,通过更新workout-builder-routing.module.ts将其添加到WorkoutComponent的路由配置中,如下所示:

....
import { WorkoutResolver } from './workout/workout.resolver';
....
const routes: Routes = [
  {
    path: '',
    component: WorkoutBuilderComponent,
    children: [
         {path: '', pathMatch: 'full', redirectTo: 'workouts'},
         {path: 'workouts', component: WorkoutsComponent },
 {path: 'workout/new', component: WorkoutComponent, resolve: { workout: WorkoutResolver} },
 {path: 'workout/:id', component: WorkoutComponent, resolve: { workout: WorkoutResolver} },
         {path: 'exercises', component: ExercisesComponent},
         {path: 'exercise/new', component: ExerciseComponent },
         {path: 'exercise/:id', component: ExerciseComponent }
    ]
  },
];

如你所见,我们将WorkoutResolver添加到路由模块的导入中。然后,我们将resolve { workout: WorkoutResolver }添加到workout/newworkout/:id路由配置的末尾。这指示路由器使用WorkoutResolver的解析方法,并将返回值分配给路由数据中的workout。这种配置意味着在路由器导航到WorkoutComponent之前,WorkoutResolver将被调用,并且当WorkoutComponent加载时,工作数据将可用。我们将在下一节中看到如何在WorkoutComponent中提取这些数据。

实现 Workout 组件继续...

现在我们已经建立了通往Workout组件的路由,让我们转向完成其实现。因此,从trainer/src/app下的workout-builder/workout文件夹中的checkpoint 4.5复制workout.component.ts文件。(同时,从workout-builder文件夹复制workout-builder.module.ts。我们将在讨论 Angular 表单时稍后讨论该文件中的更改。)

打开workout.component.ts,你会看到我们添加了一个构造函数,该构造函数注入了ActivatedRouteWorkoutBuilderService

    constructor( 
    public route: ActivatedRoute, 
    public workoutBuilderService:WorkoutBuilderService){ } 

此外,我们还添加了以下ngOnInit方法:

  ngOnInit() {
      this.sub = this.route.data
          .subscribe(
            (data: { workout: WorkoutPlan }) => {
              this.workout = data.workout;
            }
          );
  }

该方法订阅了route并从route.data中提取了workout。没有必要检查工作是否存在,因为我们已经在WorkoutResolver中做了这个检查。

我们订阅了route.data,因为作为一个ActivatedRouteroute将其data暴露为一个Observable,它可以在组件的生命周期内发生变化。这使我们能够使用相同的组件实例以不同的参数重用,尽管该组件的OnInit生命周期事件只被调用一次。我们将在下一章中详细介绍Observables

除了这段代码,我们还向Workout 组件添加了一系列方法,用于添加、删除和移动锻炼。这些方法都调用了WorkoutBuilderService上的相应方法,我们在这里不会详细审查它们。我们还添加了一个durations数组,用于填充持续时间下拉列表。

目前,这已经足够用于组件类的实现了。让我们更新相关的Workout模板。

实现 Workout 模板

现在,从trainer/src/app下的workout-builder/workout文件夹中的checkpoint 4.5复制workout.component.html文件。运行应用程序,导航到/builder/workouts,然后双击7 分钟锻炼磁贴。这应该会加载7 分钟锻炼的详细信息,视图类似于构建锻炼部分开始时所示。

如果有任何问题,您可以参考GitHub 仓库中的checkpoint4.5代码:分支:checkpoint4.5(文件夹 - trainer)。

我们将在这个视图中投入大量时间,所以让我们了解一些具体细节。

练习列表 div(id="exercise-list")按顺序列出构成锻炼的练习。我们在内容区域的左侧以自上而下的磁贴形式显示它们。从功能上讲,这个模板具有:

  • 删除按钮用于删除练习

  • 重排按钮用于将练习在列表中上下移动,以及移动到顶部和底部

我们使用ngFor遍历练习列表并显示它们:

<div *ngFor="let exercisePlan of workout.exercises; let i=index" class="exercise-item"> 

您会注意到我们在ngFor前面使用了*星号,它是<template>标签的简写。我们还使用let设置两个局部变量:exerisePlan用于识别练习列表中的一个条目,i用于设置一个索引值,我们将使用这个索引值来显示屏幕上显示的练习编号。我们还将使用索引值来管理列表中的重排和删除练习。

第二个 div 元素用于锻炼数据(id="workout-data"),其中包含 HTML 输入元素,用于详细说明名称、标题和休息持续时间,以及一个保存锻炼更改的按钮。

完整的列表被包裹在 HTML 表单元素中,这样我们就可以利用 Angular 提供的表单相关功能。那么,这些功能是什么呢?

Angular 表单

表单是 HTML 开发的一个基本组成部分,任何针对客户端开发的框架都无法忽视它们。Angular 提供了一组小型但定义良好的结构,使得标准表单操作更加容易。

如果我们仔细思考,任何形式的交互都可以归结为:

  • 允许用户输入

  • 验证这些输入是否符合业务规则

  • 将数据提交到后端服务器

Angular 为所有上述用例都提供了解决方案。

对于用户输入,它允许我们在表单输入元素和底层模型之间创建双向绑定,从而避免编写任何可能需要的模型输入同步的样板代码。

它还提供了在提交之前验证输入的结构。

最后,Angular 提供了客户端-服务器交互和将数据持久化到服务器的 HTTP 服务。我们将在第五章“支持服务器数据持久性”中介绍这些服务。

由于前两个用例是本章的主要关注点,让我们更多地了解 Angular 用户输入和数据验证支持。

模板驱动和响应式表单

Angular 提供了两种表单类型:模板驱动响应式。在本章中,我们将讨论这两种表单类型。因为 Angular 团队指出,我们中的许多人将主要使用模板驱动表单,所以我们将从本章开始介绍这种类型。

模板驱动表单

正如名称所示,模板驱动表单强调在 HTML 模板中开发表单,并在该模板内处理表单输入、数据验证、保存和更新的大部分逻辑。结果是,与表单模板关联的组件类中几乎不需要任何与表单相关的代码。

模板驱动表单大量使用ngModel表单指令。我们将在下一节中讨论它。它为表单控件提供双向数据绑定,这确实是一个很好的功能。它允许我们编写更少的样板代码来实现表单。它还帮助我们管理表单的状态(例如,表单控件是否已更改以及这些更改是否已保存)。此外,它还使我们能够轻松构建显示在表单控件验证要求未满足时的消息(例如,必填字段未提供,电子邮件格式不正确等)。

入门

为了在我们的Workout组件中使用 Angular 表单,我们必须首先添加一些额外的配置。打开workout-buider.module.ts文件,该文件位于trainer/src/app下的workout-builder文件夹中,在checkpoint 4.5中。你会看到它导入了FormsModule

....
import { FormsModule } from '@angular/forms';
....
@NgModule({ 
    imports: [ 
        CommonModule, 
 FormsModule, 
        SharedModule, 
        workoutBuilderRouting 
    ], 

这将包括我们实现表单所需的所有内容,包括:

  • NgForm

  • ngModel

让我们开始使用这些来构建我们的表单。

使用 NgForm

在我们的模板(workout.component.html)中,我们添加了以下form标签:

<form #f="ngForm" class="row" name="formWorkout" (ngSubmit)="save(f.form)">. . . 
</form> 

让我们来看看这里有什么。一个有趣的事情是我们仍在使用标准的 <form> 标签,而不是特殊的 Angular 标签。我们还使用了 # 来定义一个局部变量 f,并将其分配给 ngForm。创建这个局部变量为我们提供了便利,可以在表单的其他地方使用它来进行与表单相关的活动。例如,你可以看到我们在打开 form 标签的末尾使用它,作为一个参数 f.form,它被传递到绑定到 (ngSubmit) 事件的 onSubmit 事件中。

最后绑定到 (ngSubmit) 应该告诉我们这里正在发生一些不同的事情。尽管我们没有明确添加 NgForm 指令,但我们的 <form> 现在有了额外的 ngSubmit 等事件,我们可以绑定操作。这是怎么发生的呢?嗯,这并不是因为我们将 ngForm 分配给一个局部变量而触发的。相反,它是因为我们自动地将表单模块导入到 workout-builder.module.ts 中。

在设置好这个导入之后,Angular 检查了我们的模板中的 <form> 标签,并将其包裹在 NgForm 指令中。Angular 文档指出,组件中的 <form> 元素将被升级以使用 Angular 表单系统。这很重要,因为它意味着 NgForm 的各种功能现在都可以与表单一起使用。这包括 ngSubmit 事件,它表示用户已触发表单提交,并提供在提交之前验证整个表单的能力。

ngModel

模板驱动的表单的一个基本构建块是 ngModel,你会在我们的整个表单中找到它的使用。ngModel 的一个主要作用是支持用户输入和底层模型之间的双向绑定。在这种设置下,模型的变化会在视图中反映出来,视图的更新也会反映回模型。我们之前提到的其他大多数指令只支持从模型到视图的单向绑定。ngModel 是双向的。但是,请注意,它仅在 NgForm 内部可用,用于允许用户输入的元素。

如你所知,我们已经有了一个用于 Workout 页面的模型,WorkoutPlan。以下是来自 model.tsWorkoutPlan 模型:

export class WorkoutPlan { 
  constructor( 
    public name: string, 
    public title: string, 
    public restBetweenExercise: number, 
    public exercises: ExercisePlan[], 
    public description?: string) { 
  } 
totalWorkoutDuration(): number{ 
 . . . [code calculating the total duration of the workout]. . . 
} 

注意在 description 后面使用的 ?。这意味着它是我们模型中的一个可选属性,并且不是创建 WorkoutPlan 所必需的。在我们的表单中,这意味着我们不需要输入描述,并且即使没有它,一切也会正常工作。

WorkoutPlan 模型中,我们还有一个指向由另一种类型模型的实例组成的数组的引用:ExercisePlanExercisePlan 又由一个数字(duration)和另一个模型(Exercise)组成,看起来像这样:

export class Exercise {
    constructor(
        public name: string,
        public title: string,
        public description: string,
        public image: string,
        public nameSound?: string,
        public procedure?: string,
        public videos?: Array<string>) { }
}

这些嵌套类的使用表明,我们可以创建复杂的模型层次结构,所有这些都可以在我们的表单中使用NgModel进行数据绑定。因此,在整个表单中,每当我们需要更新WorkoutPlanExercisePlan中的任何一个值时,我们都可以使用NgModel来完成(在以下示例中,WorkoutPlan模型将由名为workout的局部变量表示)。

使用 ngModel 与 input 和 textarea 绑定

打开workout-component.html并查找ngModel.。它已经应用于允许用户数据输入的表单元素。这些包括输入、文本区域和选择。锻炼名称输入的设置如下:

<input type="text" name="workoutName" class="form-control" id="workout-name" placeholder="Enter workout name. Must be unique." [(ngModel)]="workout.name">

前面的[(ngModel)]指令在输入控件和workout.name模型属性之间建立了一个双向绑定。方括号和圆括号都应该看起来很熟悉。之前,我们分别使用它们:方括号[]用于属性绑定,圆括号()用于事件绑定。在后一种情况下,我们通常将事件绑定到与模板关联的组件中的方法调用。您可以在用户点击以删除练习的按钮表单中看到这个例子:

<span class="btn float-right trashcan" (click)="removeExercise(exercisePlan)"><span class="ion-ios-trash-outline"></span></span>

在这里,点击事件被明确地绑定到我们Workout组件类中名为removeExercise的方法。但对于workout.name输入,我们没有将方法显式绑定到组件上。那么这里发生了什么,我们为什么不需要在组件上调用方法就能更新模型呢?这个问题的答案是,组合[( )]既是将模型属性绑定到输入元素,也是连接一个更新模型的事件的简写。

换句话说,如果我们在我们的表单中引用一个模型元素,ngModel足够智能,知道我们想要在用户输入或更改绑定到输入字段的数据时更新该元素(在这里是workout.name)。在底层,Angular 创建了一个类似于我们通常必须自己编写的更新方法。太棒了!这种方法让我们不必编写重复的代码来更新我们的模型。

Angular 支持大多数 HTML5 输入类型,包括文本、数字、选择、单选和复选框。这意味着模型与这些输入类型之间的绑定是直接工作的。

textarea元素的工作方式与输入相同:

<textarea name="description" . . . [(ngModel)]="workout.description"></textarea> 

在这里,我们将textarea绑定到workout.description。在底层,ngModel会随着我们在文本区域中输入的每个更改更新我们的模型中的锻炼描述。

为了测试这个功能是如何工作的,我们为什么不验证这个绑定呢?在任何一个链接输入的末尾添加一个模型插值表达式,例如这个:

<input type="text". . . [(ngModel)]="workout.name">{{workout.name}} 

打开“Workout”页面,在输入框中输入一些内容,看看插值是如何即时更新的。双向绑定的魔法!

图片

使用 ngModel 与 select 绑定

让我们看看select是如何设置的:

<select . . . name="duration" [(ngModel)]="exercisePlan.duration"> 
    <option *ngFor="let duration of durations" [value]="duration.value">{{duration.title}}</option> 
</select> 

我们在这里使用 ngFor 来绑定到一个数组,durations,它位于 Workout 组件类中。数组看起来是这样的:

 [{ title: "15 seconds", value: 15 }, 
  { title: "30 seconds", value: 30 }, ...] 

ngFor 组件将遍历数组,并将数组中的对应值填充到下拉菜单中,每个项目的标题使用插值 {{duration.title}} 显示。然后 [(ngModel)] 将下拉选择绑定到模型中的 exercisePlan.duration

注意这里,我们正在绑定到嵌套的模型:ExercisePlan。并且,我们可能有多项练习需要应用此绑定。在这种情况下,我们必须使用另一个 Angular 表单指令——ngModelGroup——来处理这些绑定。ngModelGroup 将允许我们在模型中创建一个嵌套组,该组将包含锻炼中包含的练习列表,然后依次遍历每个练习,将其持续时间绑定到模型上。

首先,我们将 ngModelGroup 添加到我们在表单中创建的 div 标签中,以保存我们的练习列表:

<div id="exercises-list" class="col-sm-2 exercise-list" ngModelGroup="exercises">

这样我们就完成了创建嵌套的练习列表。现在,我们必须处理列表中的单个练习,我们可以通过为包含每个练习的单独 div 添加另一个 ngModelGroup 来实现这一点:

<div class="exercise tile" [ngModelGroup]="i">

在这里,我们使用循环中的索引来动态为我们的每个练习创建一个单独的模型组。这些模型组将嵌套在我们最初创建的第一个模型组内部。暂时在表单底部添加标签 <pre>{{ f.value | json }}</pre>,你将能够看到这个嵌套模型的结构:

{
  "exercises": {
    "0": {
      "duration": 15
    },
    "1": {
      "duration": 60
    },
    "2": {
      "duration": 45
    },
    "exerciseCount": 3
  },
  "workoutName": "1minworkout",
  "title": "1 Minute Workout",
  "description": "desc",
  "restBetweenExercise": 30
}

这是一种强大的功能,使我们能够创建具有嵌套模型的复杂表单,所有这些都可以使用 ngModel 进行数据绑定

你可能已经注意到了我们刚刚引入的两个 ngModelGroup 指令标签之间的细微差别。第二个标签被括号 <[]> 包围,而第一个则不是。这是因为,在第一个标签中,我们只是命名我们的模型组,而在第二个标签中,我们使用循环的索引动态地将它绑定到每个练习的 div 标签上。

与输入类似,选择也支持双向绑定。我们看到了如何通过更改选择来更新模型,但模型到模板的绑定可能并不明显。为了验证模型到模板的绑定是否工作,请打开 7 分钟健身 应用并验证持续时间下拉菜单。每个下拉菜单都有一个与模型值(30 秒)一致的值。

Angular 使用 ngModel 做得非常好,能够保持模型和视图同步。更改模型,查看视图是否更新;更改视图,并观察模型是否立即更新。

现在,让我们给我们的表单添加验证。

下一个部分的代码也可以在 GitHub 上供每个人下载,网址为github.com/chandermani/angular6byexample。检查点作为 GitHub 上的分支实现。要下载的分支如下:GitHub Branch: checkpoint4.6(文件夹—trainer)。或者如果您不使用 Git,可以从以下 GitHub 位置下载检查点 4.6 的快照(ZIP 文件):github.com/chandermani/angular6byexample/archive/checkpoint4.6.zip。在首次设置快照时,请参考trainer文件夹中的README.md文件。再次提醒,如果您在我们构建应用程序的同时工作,请确保更新styles.css文件,这里我们不做讨论。

Angular 验证

正如俗话所说,永远不要相信用户输入。Angular 支持验证,包括标准的必填、最小值、最大值和模式,以及自定义验证器。

ngModel

ngModel是我们将用于实现验证的基本构建块。它为我们做两件事:维护模型状态并提供识别验证错误和显示验证消息的机制。

要开始,我们需要将ngModel分配给所有我们将要验证的表单控件中的局部变量。在每种情况下,我们需要为这个局部变量使用一个唯一名称。例如,对于锻炼名称,我们在该控件的input标签内添加#name="ngModel",同时添加 HTML 5 的required属性。现在,锻炼名称的input标签应该看起来像这样:

<input type="text" name="workoutName" #name="ngModel" class="form-control" id="workout-name" placeholder="Enter workout name. Must be unique." [(ngModel)]="workout.name" required> 

继续遍历表单,将ngModel分配给每个输入的局部变量。同时,为所有必填字段添加所需的属性。

Angular 模型状态

每当我们使用NgForm时,我们表单中的每个元素,包括输入、文本区域和选择,都在关联的模型上定义了一些状态。ngModel为我们跟踪这些状态。跟踪的状态包括:

  • pristine: 只要用户没有与输入交互,此值就为true。任何对input字段和ng-pristine的更新都将ng-pristine设置为false

  • dirty: 这是ng-pristine的反面。当输入数据被更新时,此值为true

  • touched: 如果控件曾经获得过焦点,则此值为true

  • untouched: 如果控件从未失去焦点,则此值为true。这是ng-touched的反面。

  • valid: 如果在input元素上定义了验证,并且没有失败的验证,则此值为true

  • invalid: 如果在元素上定义的任何验证失败,则此值为true

pristinedirtytoucheduntouched是有用的属性,可以帮助我们决定何时显示错误标签。

Angular CSS 类

根据模型状态,Angular 向输入元素添加一些 CSS 类。以下是一些包括的内容:

  • ng-valid: 如果模型有效时使用。

  • ng-invalid: 如果模型无效时使用。

  • ng-pristine:如果模型是原始的,则使用

  • ng-dirty:如果模型是脏的,则使用

  • ng-untouched:当输入从未被访问时使用

  • ng-touched:当输入有焦点时使用

为了验证这一点,请返回到workoutName输入标签,并在input标签内添加一个名为spy的模板引用变量:

<input type="text" name="workoutName" #name="ngModel" class="form-control" id="workout-name" placeholder="Enter workout name. Must be unique." [(ngModel)]="workout.name" required #spy> 

然后,在标签下方添加以下标签:

<label>{{spy.className}}</label> 

重新加载应用程序并点击锻炼构建器中的“新建锻炼”链接。在触摸屏幕上的任何内容之前,您将看到以下内容显示:

在名称输入框中添加一些内容,然后从它那里切换标签。标签变为如下:

我们看到的是 Angular 根据用户与该控件交互更改应用于此控件的 CSS 类。您也可以通过在开发者控制台中检查input元素来看到这些更改。

如果我们想根据元素的状态应用视觉提示,这些 CSS 类转换非常有用。例如,看看这个片段:

input.ng-invalid {  border:2px solid red; } 

这会在任何具有无效数据的输入控件周围绘制红色边框。

随着您向锻炼页面添加更多验证,您可以在开发者控制台观察到,随着用户与input元素交互,这些类是如何被添加和删除的。

现在我们已经了解了模型状态及其使用方法,让我们回到对验证的讨论(在继续之前,请移除您刚刚添加的变量名和标签)。

锻炼验证

锻炼数据需要验证多个条件。

在为ngModel和必需属性添加本地变量引用之后,我们已经能够看到ngModel如何跟踪这些控件的状态变化以及如何切换 CSS 样式。

显示适当的验证消息

现在,输入需要有一个值;否则,验证将失败。但我们如何知道验证是否失败呢?ngModel在这里帮了我们大忙。它可以提供特定输入的验证状态。这为我们提供了显示适当验证消息所需的内容。

让我们回到锻炼名称的输入控件。为了显示验证消息,我们必须首先将输入标签修改如下:

<input type="text" name="workoutName" #name="ngModel" class="form-control" id="workout-name" placeholder="Enter workout name. Must be unique." [(ngModel)]="workout.name" required> 

我们添加了一个名为#name的本地变量,并将其分配给ngModel。这被称为模板引用变量,我们可以使用以下标签与它一起显示输入的验证消息:

<label *ngIf="name.control.hasError('required') && (name.touched)" class="alert alert-danger validation-message">Name is required</label>  

当未提供名称且控件已被触摸时,我们将显示验证消息。为了检查第一个条件,我们检索控件的hasError属性,并查看错误类型是否为required。我们检查名称输入是否已被touched,因为我们不希望在表单首次加载新锻炼时显示消息。

你会注意到,我们使用了一种相对冗长的风格来识别验证错误,这比这种情况所需的更为冗长。我们本可以使用name.control.hasError('required'),但这同样可以完美工作。然而,使用更冗长的方法可以让我们更具体地识别验证错误,这在开始向表单控件添加多个验证器时将变得至关重要。我们将在本章稍后探讨使用多个验证器。为了保持一致性,我们将坚持使用更冗长的方法。

现在加载新的锻炼页面(/builder/workouts/new)。在名称输入框中输入一个值,然后删除它。错误标签将如以下截图所示出现:

图片

添加更多验证

Angular 提供了几个内置验证器,包括:

  • required

  • minLength

  • maxLength

  • email

  • pattern

要查看所有内置验证器的完整列表,请参阅Validators类的文档,链接为angular.io/api/forms/Validators.

我们已经看到了required验证器的工作方式。现在,让我们看看另外两个内置验证器:minLengthmaxLength。除了使其成为必填项外,我们还想让锻炼的标题长度在 5 到 20 个字符之间(我们将在本章稍后探讨pattern验证器)。

因此,除了之前添加到标题输入框的required属性外,我们还将添加minLength属性并将其设置为5,并添加maxLength属性并将其设置为20,如下所示:

<input type="text" . . . minlength="5" maxlength="20" required> 

然后,我们添加另一个标签,其中包含一个消息,当此验证未满足时将显示:

<label *ngIf="(title.control.hasError('minlength') || title.control.hasError('maxlength')) && workout.title.length > 0" class="alert alert-danger validation-message">Title should be between 5 and 20 characters long.</label>  

管理多个验证消息

你会看到现在显示消息的条件是测试长度不为零。这可以防止在控件被触摸但留空的情况下显示消息。在这种情况下,应该显示标题必填消息。此消息仅在字段中未输入任何内容时显示,我们通过显式检查控件hasError类型是否为required来实现这一点:

<label *ngIf="title.control.hasError('required')" class="alert alert-danger validation-message">Title is required.</label>

由于我们将两个验证器附加到这个输入字段,我们可以通过将两个验证器包裹在一个检查该条件是否满足的 div 标签中来合并检查输入是否被触摸:

<div *ngIf="title.touched"> 
  . . . [the two validators] . . . 
</div> 

我们刚才所做的是展示了如何将多个验证附加到单个输入控件上,并在验证条件之一未满足的情况下显示适当的消息。然而,很明显,这种方法在更复杂的情况下不会扩展。一些输入包含很多验证,控制验证消息何时显示可能会变得复杂。随着处理各种显示的表达式变得更加复杂,我们可能想要重构并将它们移动到自定义指令中。创建自定义指令将在第六章“深入探讨 Angular 2 指令”中详细讲解。

对运动项目的自定义验证消息

没有任何运动项目的锻炼是没有用的。锻炼中至少应该包含一个运动项目,我们应该验证这个限制。

运动项目数量验证的问题在于,这不是用户直接输入并由框架验证的内容。尽管如此,我们仍然希望有一个机制以类似于本表单上其他验证的方式验证运动项目数量。

我们将要做的就是在表单中添加一个包含运动项目数量的隐藏输入框。然后我们将它绑定到ngModel,并添加一个模式验证器,以确保有超过一个运动项目。我们将输入框的值设置为运动项目的数量:

<input type="hidden" name="exerciseCount" #exerciseCount="ngModel" ngControl="exerciseCount" class="form-control" id="exercise-count" [(ngModel)]="workout.exercises.length" pattern="[1-9][0-9]*"> 

然后,我们将像我们刚刚对其他验证器所做的那样,给它附加一个验证消息:

<label *ngIf="exerciseCount.control.hasError('pattern')" class="alert alert-danger extended-validation-message">The workout should have at least one exercise!</label>  

在这里,我们并没有真正使用ngModel。这里没有双向绑定。我们只对用它来进行自定义验证感兴趣。

打开新的锻炼页面,添加一个运动项目,然后删除它;我们应该看到这个错误:

我们在这里所做的事情可以很容易地完成,而不涉及任何模型验证基础设施。但是,通过将我们的验证钩入该基础设施,我们确实获得了一些好处。现在我们可以以一致和熟悉的方式确定特定模型和整个表单的错误。最重要的是,如果这里的验证失败,整个表单将被无效化。

正如我们刚才所做的那样实施自定义验证并不是你经常想做的事情。相反,通常在自定义指令内部实现这种复杂的逻辑会更有意义。我们将在第六章“深入探讨 Angular 2 指令”中详细讲解创建自定义指令。

我们新实施的Exercise Count验证的一个麻烦是,它会在新Workout屏幕首次出现时显示。有了这个消息,我们就无法使用ng-touched来隐藏显示。这是因为运动项目是通过程序添加的,而我们用来跟踪它们数量的隐藏输入在添加或删除运动项目时永远不会改变,始终处于未触摸状态。

为了解决这个问题,我们需要一个额外的值来检查当锻炼列表的状态减少到零时的情况,除非表单是首次加载。这种情况唯一可能发生的方式是,如果用户添加并从锻炼中移除项目,直到没有更多的锻炼为止。因此,我们将向我们的组件添加另一个属性,我们可以用它来跟踪是否调用了移除方法。我们称这个值为removeTouched并将其初始值设置为false

removeTouched: boolean = false; 

然后,在移除方法中,我们将该值设置为true

removeExercise(exercisePlan: ExercisePlan) { 
    this.removeTouched = true; 
    this.workoutBuilderService.removeExercise(exercisePlan); 
} 

接下来,我们将向我们的验证消息条件中添加removeTouched,如下所示:

<label *ngIf="exerciseCount.control.hasError('pattern') && (removeTouched)" 

现在,当我们打开一个新的锻炼屏幕时,验证消息将不会显示。但是,如果用户添加并移除所有锻炼,那么它将显示。

为了理解模型验证如何汇总到表单验证中,我们需要了解表单级验证能提供什么。然而,在这一点之前,我们需要实现保存锻炼,并从锻炼表单中调用它。

保存锻炼

我们正在构建的锻炼需要被持久化(仅限于内存)。我们需要做的第一件事是扩展WorkoutServiceWorkoutBuilderService

WorkoutService需要两个新方法,addWorkoutupdateWorkout

addWorkout(workout: WorkoutPlan){ 
    if (workout.name){ 
        this.workouts.push(workout); 
        return workout; 
    } 
} 

updateWorkout(workout: WorkoutPlan){ 
    for (var i = 0; i < this.workouts.length; i++) { 
        if (this.workouts[i].name === workout.name) { 
            this.workouts[i] = workout; 
            break; 
        } 
    } 
} 

addWorkout方法对锻炼名称进行基本检查,然后将锻炼推入锻炼数组。由于没有涉及后端存储,如果我们刷新页面,数据就会丢失。我们将在下一章中修复这个问题,我们将数据持久化到服务器。

updateWorkout方法在现有的锻炼数组中查找具有相同名称的锻炼,如果找到,则更新并替换它。

由于我们已经在跟踪锻炼构建的上下文,所以我们只向WorkoutBuilderService添加一个保存方法:

save(){ 
    let workout = this.newWorkout ? 
        this._workoutService.addWorkout(this.buildingWorkout) : 
        this._workoutService.updateWorkout(this.buildingWorkout); 
    this.newWorkout = false; 
    return workout; 
} 

save方法根据是否正在创建新的锻炼或编辑现有的锻炼,在Workout服务中调用addWorkoutupdateWorkout

从服务角度来说,这应该足够了。现在是时候将保存锻炼的能力集成到Workout组件中,并了解更多关于表单指令的信息!

在我们更详细地查看NgForm之前,让我们将保存方法添加到Workout中,以便在点击保存按钮时保存锻炼。将此代码添加到Workout组件中:

save(formWorkout:any){ 
    if (!formWorkout.valid) return; 
    this.workoutBuilderService.save(); 
    this.router.navigate(['/builder/workouts']); 
}  

我们使用表单的 invalid 属性检查验证状态,然后如果表单状态有效,就调用WorkoutBuilderService.save方法。

更多关于 NgForm 的内容

与将数据发送到服务器的传统表单相比,Angular 中的表单扮演着不同的角色。如果我们回顾一下表单标签,我们会看到它缺少标准的 action 属性。使用像 Angular 这样的 SPA 框架,使用完整页面的 post-back 来发送数据到服务器没有意义。在 Angular 中,所有服务器请求都是通过来自指令或服务的异步调用发起的。

在底层,Angular 也在关闭浏览器的内置验证。正如你在本章中看到的,我们仍在使用类似于原生 HTML 验证属性的验证属性,如required。然而,正如 Angular 文档所解释的,在 Angular 表单中,“Angular 使用指令将属性与框架中的验证函数相匹配。”请参阅angular.io/guide/form-validation#template-driven-validation

此处的表单扮演着不同的角色。当表单封装了一组输入元素(如输入、文本区域和选择)时,它提供了一个 API 用于:

  • 根据表单上的输入控件确定表单的状态,例如根据输入控件确定表单是否为脏或纯净

  • 在表单或控件级别检查验证错误

如果你仍然想要标准表单行为,你可以向form元素添加ngNoForm属性,但这将肯定导致整个页面刷新。你还可以通过添加ngNativeValidate属性来开启浏览器的内置验证。我们将在本章稍后探讨NgForm API 的细节,当我们查看保存表单和实现验证时。

NgForm正在监控表单内FormControl对象的状态。如果其中任何一个无效,那么NgForm将整个表单设置为无效。在这种情况下,我们已经能够使用NgForm来确定一个或多个FormControl对象无效,因此整个表单的状态也是无效的。

在我们完成本章之前,让我们再看看一个问题。

修复表单的保存和验证消息

打开一个新的锻炼页面并直接点击保存按钮。由于表单无效,所以没有任何内容被保存,但单个表单输入的验证并不会显示出来。现在很难知道哪些元素导致了验证失败。这种行为背后的原因相当明显。如果我们查看名称输入元素的错误信息绑定,它看起来像这样:

*ngIf="name.control?.hasError('required') && name.touched"

记住,在本章的早期,我们明确禁用了显示验证消息,直到用户触摸输入控件。同样的问题又回来了,我们现在需要修复它。

我们没有方法可以显式地改变我们控件的被触摸状态为未触摸。相反,我们将求助于一些小技巧来完成这项工作。我们将引入一个新的属性,称为submitted。在Workout类定义的顶部添加它,并将其初始值设置为false,如下所示:

submitted: boolean = false;

变量将在点击保存按钮时设置为true。通过添加高亮代码来更新保存实现:

save(formWorkout){ 
 this.submitted = true; 
    if (!formWorkout.valid) return; 
    this._workoutBuilderService.save(); 
    this.router.navigate(['/builder/workouts']); 
} 

然而,这有什么帮助呢?好吧,这个修复还有一个部分需要我们更改我们正在验证的每个控件的错误信息。现在表达式变为:

*ngIf="name.control.hasError('required') && (name.touched || submitted)"   

通过这个修复,当控件被触摸或表单提交按钮被按下时(submittedtrue),将显示错误消息。现在必须将这个表达式修复应用到每个出现检查的验证消息。

如果我们现在打开新的锻炼页面并点击保存按钮,我们应该在输入控件上看到所有验证消息:

图片

响应式表单

Angular 支持的其他类型的表单称为响应式表单响应式表单从一个在组件类中构建的模型开始。使用这种方法,我们使用表单构建器 API在代码中创建一个表单并将其与模型关联。

考虑到我们编写的最小代码就能让模板驱动表单工作,为什么和什么时候我们应该考虑使用响应式表单?在几种情况下,我们可能希望使用它们。这包括我们想要以编程方式控制创建表单的情况。正如我们将看到的,当我们试图根据从服务器检索的数据动态创建表单控件时,这特别有益。

如果我们的验证变得复杂,通常在代码中处理它更容易。使用响应式表单,我们可以将这种复杂的逻辑从 HTML 模板中移除,使模板语法更简单。

响应式表单的另一个显著优点是,它们使得对表单进行单元测试成为可能,这在模板驱动表单中是不可能的。我们可以在测试中简单地实例化我们的表单控件,然后在页面的标记外测试它们。

响应式表单使用了三个之前未曾讨论过的新表单指令:FormGroupFormControlFormArray。这些指令允许在代码中构建的表单对象直接与模板中的 HTML 标记绑定。在组件类中创建的表单控件随后可以直接在表单中使用。从技术上讲,这意味着我们不需要在响应式表单中使用ngModel(它是模板驱动表单的核心),尽管它可以被使用。整体方法是一个更干净、更简洁的模板,更专注于驱动表单的代码。让我们开始构建一个响应式表单。

开始使用响应式表单

我们将使用响应式表单来构建添加和编辑练习的表单。这个表单将允许用户添加 YouTube 上的练习视频链接。由于他们可以添加任意数量的视频链接,因此我们需要能够动态地添加这些视频链接的控件。这个挑战将很好地检验响应式表单在开发更复杂表单中的有效性。以下是表单的外观:

图片

要开始,打开workout-builder.module.ts并添加以下import

import { FormsModule, ReactiveFormsModule }   from '@angular/forms'; 
 ... 
@NgModule({ 
    imports: [ 
        CommonModule, 
        FormsModule, 
 ReactiveFormsModule, 
        SharedModule, 
        workoutBuilderRouting 
    ],

ReactiveFormsModule包含了我们构建响应式表单所需的所有内容。

接下来,从 trainer/src/app 下的 workout-builder/builder-services 文件夹中的 checkpoint 4.6 复制 exercise-builder-service.ts 并将其导入到 workout-builder.module.ts

import { ExerciseBuilderService } from "./builder-services/exercise-builder-service"; 

然后,将其作为额外的提供者添加到该文件的提供者数组中:

@NgModule({ 
   . . . 
  providers: [
    WorkoutBuilderService,
    WorkoutResolver,
    ExerciseBuilderService,
    ExerciseResolver
   ]
}) 

你会注意到这里我们还添加了 ExerciseResolver 作为提供者。我们在这里不会详细讲解,但你应该从 exercise 文件夹中复制它,并且也要复制更新后的 workout-builder-routing.module.ts,它将 ExerciseComponent 的导航添加为路由守卫。

现在,打开 exercise.component.ts 并添加以下导入语句:

import { Validators, FormArray, FormGroup, FormControl, FormBuilder } from '@angular/forms';

这引入了以下内容,我们将使用它来构建我们的表单:

  • FormBuilder

  • FormGroup

  • FormControl

  • FormArray

最后,我们将 FormBuilder(以及 RouterActivatedRouteExerciseBuilderService)注入到我们类的构造函数中:

  constructor(
      public route: ActivatedRoute,
      public router: Router,
      public exerciseBuilderService: ExerciseBuilderService,
      public formBuilder: FormBuilder
  ) {}

在完成这些初步步骤后,我们现在可以开始构建我们的表单。

使用 FormBuilder API

FormBuilder API 是响应式表单的基础。你可以将其视为我们代码中构建的表单的工厂。请继续在你的类中添加 ngOnInit 生命周期钩子,如下所示:

  ngOnInit() {
    this.sub = this.route.data
        .subscribe(
          (data: { exercise: Exercise }) => {
            this.exercise = data.exercise;
          }
        );

      this.buildExerciseForm();
  } 

ngOnInit 触发时,它将从 ExerciseResolver 检索并返回的路由数据中提取现有或新的 exercise 数据。这与我们初始化 Workout 组件时遵循的模式相同。

现在,让我们通过添加以下代码来实现 buildExerciseForm 方法:

buildExerciseForm(){ 
    this.exerciseForm = this.formBuilder.group({ 
        'name': [this.exercise.name, [Validators.required, AlphaNumericValidator.invalidAlphaNumeric]], 
        'title': [this.exercise.title, Validators.required], 
        'description': [this.exercise.description, Validators.required], 
        'image': [this.exercise.image, Validators.required], 
        'nameSound': [this.exercise.nameSound], 
        'procedure': [this.exercise.procedure], 
        'videos': this.addVideoArray() 
    }) 
}  

让我们检查这段代码。首先,我们使用注入的 FormBuilder 实例来构建表单,并将其分配给一个局部变量 exerciseForm。使用 formBuilder.group,我们向表单中添加了几个表单控件。我们通过简单的键/值映射添加每个控件:

'name': [this.exercise.name, Validators.required], 

映射的左侧是 FormControl 的名称,右侧是一个包含控制值(在我们的情况下,是练习模型上的相应元素)的数组,第二个是一个验证器(在这种情况下,是现成的必填验证器)。整洁且清晰!通过在模板外设置它们,我们确实更容易看到和推理我们的表单控件。

我们不仅可以通过这种方式在我们的表单中构建 FormControls,还可以添加 FormControlGroupsFormControlArray,它们包含内部的 FormControls。这意味着我们可以创建包含嵌套输入控件的复杂表单。正如我们提到的,在我们的情况下,我们需要为用户添加多个视频到练习的可能性做好准备。我们可以通过添加以下代码来实现这一点:

'videos': this.addVideoArray() 

我们在这里所做的是将 FormArray 分配给视频,这意味着我们可以在这种映射中分配多个控件。为了构建这个新的 FormArray,我们向我们的类中添加以下 addVideoArray 方法:

addVideoArray(){ 
    if(this.exercise.videos){ 
        this.exercise.videos.forEach((video : any) => { 
            this.videoArray.push(new FormControl(video, Validators.required)); 
        }); 
    } 
    return this.videoArray; 
} 

此方法为每个视频构建一个FormControl;然后每个都添加到分配给我们的表单中视频控件的FormArray中。

将表单模型添加到我们的 HTML 视图中

到目前为止,我们一直在我们的类中幕后工作,构建我们的表单。下一步是将我们的表单连接到视图。为此,我们使用与我们在代码中构建表单相同的控件:formGroupformControlformArray

打开exercise.component.html,并按照以下方式添加一个form标签:

<form class="row" [formGroup]="exerciseForm" (ngSubmit)="onSubmit(exerciseForm)">  

在标签内,我们首先将代码中刚刚构建的exerciseForm分配给formGroup。这建立了我们的编码模型与视图中的表单之间的连接。我们还把ngSubmit事件连接到我们代码中的onSubmit方法(我们稍后会讨论这个方法)。

将表单控件添加到我们的表单输入项中

接下来,我们开始构建我们表单的输入项。我们将从练习名称的输入项开始:

<input name="name" formControlName="name" class="form-control" id="name" placeholder="Enter exercise name. Must be unique.">  

我们将编码表单控件的名称指定为formControlName。这建立了我们代码中的控件与标记中的input字段之间的链接。这里另一个值得注意的点是,我们并没有使用required属性。

添加验证

接下来,我们为将要在验证错误事件中显示的控件添加一个验证消息:

<label *ngIf="exerciseForm.controls['name'].hasError('required') && (exerciseForm.controls['name'].touched || submitted)" class="alert alert-danger validation-message">Name is required</label>

注意,这个标记与我们用于模板驱动的表单验证中使用的标记非常相似,只是用于识别控件的语法稍微有些冗长。再次强调,它检查控件的hasError属性的状态,以确保其有效性。

但是等等!我们是如何验证这个输入的?我们没有从我们的标签中移除required属性吗?这正是我们在代码中添加的控制映射发挥作用的地方。如果你回顾一下表单模型的代码,你可以看到name控件的以下映射:

'name': [this.exercise.name, Validators.required], 

映射数组中的第二个元素将必需验证器分配给名称表单控件。这意味着我们不需要在我们的模板中添加任何内容;相反,表单控件本身通过必需验证器附加到模板。在我们的代码中添加验证器的功能使我们能够方便地在模板外添加验证器。这在编写具有复杂逻辑的自定义验证器时尤其有用。

添加动态表单控件

如我们之前提到的,我们正在构建的练习表单要求我们允许用户为练习添加一个或多个视频。由于我们不知道用户可能想要添加多少个视频,我们必须在用户点击添加视频按钮时动态构建这些视频的input字段。下面是它的样子:

我们已经看到了我们组件类中用于执行此操作的代码。现在,让我们看看它在模板中的实现方式。

我们首先使用ngFor遍历我们的视频列表。然后,我们将视频中的索引分配给一个局部变量,i。到目前为止,没有惊喜:

<div *ngFor="let video of videoArray.controls; let i=index" class="form-row align-items-center">

在循环内部,我们做三件事。首先,我们为当前在练习中的每个视频动态添加一个视频input字段:

<div class="col-sm-10">
    <input type="text" class="form-control" [formControlName]="i" placeholder="Add a related youtube video identified."/>
</div>

接下来,我们添加一个按钮,允许用户删除一个视频:

<span class="btn alert-danger" title="Delete this video." (click)="deleteVideo(i)">
    <span class="ion-ios-trash-outline"></span>
</span> 

我们将组件类中的deleteVideo方法绑定到按钮的click事件,并将被删除视频的索引传递给它。

我们然后为每个视频input字段添加一个验证消息:

<label *ngIf="exerciseForm.controls['videos'].controls[i].hasError('required') && (exerciseForm.controls['videos'].controls[i].touched || submitted)" class="alert alert-danger validation-message">Video identifier is required</label>

验证消息遵循我们在本章其他地方使用的相同模式来显示消息。我们深入到exerciseFormControls组中,通过索引找到特定的控件。再次强调,语法可能有些冗长,但足够容易理解。

保存表单

构建我们的响应式表单的最终步骤是处理表单的保存。当我们之前构建表单标签时,我们将ngSubmit事件绑定到我们代码中的以下onSubmit方法:

  onSubmit(formExercise: FormGroup) {
      this.submitted = true;
      if (!formExercise.valid) { return; }
      this.mapFormValues(formExercise);
      this.exerciseBuilderService.save();
      this.router.navigate(['/builder/exercises']);
  }

此方法将submitted设置为true,这将触发显示任何可能因为表单未被触摸而之前隐藏的验证消息。如果表单上有任何验证错误,它将返回而不保存。如果没有,它将调用以下mapFormValues方法,该方法将表单的值分配给将要保存的exercise

  mapFormValues(form: FormGroup) {
      this.exercise.name = form.controls['name'].value;
      this.exercise.title = form.controls['title'].value;
      this.exercise.description = form.controls['description'].value;
      this.exercise.image = form.controls['image'].value;
      this.exercise.nameSound = form.controls['nameSound'].value;
      this.exercise.procedure = form.controls['procedure'].value;
      this.exercise.videos = form.controls['videos'].value;
  }

然后,它调用ExerciseBuilderService中的保存方法,并将用户路由回练习列表屏幕(记住,任何新的练习都不会显示在该列表中,因为我们尚未在我们的应用程序中实现数据持久性)。

我们希望这使它变得清晰;当我们试图构建更复杂的表单时,响应式表单提供了许多优势。它们允许将编程逻辑从模板中移除。它们允许以编程方式向表单添加验证器。而且,它们支持在运行时动态构建表单。

自定义验证器

现在,在我们结束这一章之前,我们再来看一件事情。正如任何参与构建网页表单(无论是 Angular 还是任何其他网络技术)的人所知,我们经常被要求创建适用于我们正在构建的应用的独特验证。Angular 为我们提供了灵活性,通过构建自定义验证器来增强我们的响应式表单验证。

在构建我们的练习表单时,我们需要确保输入的内容,因为名称只包含字母数字字符,没有空格。这是因为当我们到达将练习存储在远程数据存储时,我们将使用练习的名称作为其键。因此,除了标准的必填字段验证器之外,让我们构建另一个验证器,以确保输入的名称仅是字母数字形式。

创建自定义控件相当简单。在其最简单的形式中,Angular 自定义验证器是一个函数,它接受一个控件作为输入参数,运行验证检查,并返回 true 或 false。因此,让我们首先添加一个名为alphanumeric-validator.ts的 TypeScript 文件。在该文件中,首先从@angular/forms导入FormControl,然后向该文件添加以下类:

export class AlphaNumericValidator {
    static invalidAlphaNumeric(control: FormControl): { [key: string]: boolean } {
        if ( control.value.length && !control.value.match(/^[a-z0-9]+$/i) ) {
            return {invalidAlphaNumeric: true };
        }
        return null;
    }
}

代码遵循我们刚才提到的创建验证器的模式。唯一可能让人有点惊讶的是,当验证失败时它返回 true!只要你对这个特点有清晰的认识,你应该没有问题编写自己的自定义验证器。

将自定义验证器集成到我们的表单中

那么,我们如何将我们的自定义验证器集成到我们的表单中呢?如果我们使用响应式表单,答案相当简单。我们在代码中构建表单时,就像添加内置验证器一样添加它。让我们这样做。打开exercise.component.ts,首先添加对我们自定义验证器的导入:

import { AlphaNumericValidator } from '../alphanumeric-validator'; 

然后,修改表单构建器代码,将验证器添加到name控件:

buildExerciseForm(){ 
    this.exerciseForm = this._formBuilder.group({ 
'name': [this.exercise.name, [Validators.required, AlphaNumericValidator.invalidAlphaNumeric]], 
  . . . [other form controls] . . . 
    }); 
} 

由于名称控件已经有一个必需的验证器,我们通过使用包含两个验证器的数组将AlphaNumericValidator作为第二个验证器添加。数组可以用来向控件添加任何数量的验证器。

最后一步是将适当的验证消息集成到我们的模板中。打开workout.component.html,并在显示必需验证器消息的标签下方添加以下标签:

<label *ngIf="exerciseForm.controls['name'].hasError('invalidAlphaNumeric') && (exerciseForm.controls['name'].touched || submitted)" class="alert alert-danger validation-message">Name must be alphanumeric</label> 

当在名称输入框中输入非字母数字值时,现在将显示验证消息:

图片

如我们所希望看到的,响应式表单使我们能够以简单的方式将自定义验证器添加到我们的表单中,这允许我们在代码中维护验证逻辑,并轻松将其集成到我们的模板中。

您可能已经注意到,在本章中,我们没有介绍如何在模板驱动的表单中使用自定义验证器。这是因为实现它们需要额外的步骤来构建自定义指令。我们将在第六章“深入 Angular 2 指令”中介绍这一点。

运行验证的配置选项

在我们继续讨论验证之前,还有一个话题需要介绍,那就是运行验证的配置选项。到目前为止,我们一直在使用默认选项,该选项在每次输入事件上运行验证检查。然而,您可以选择将它们配置为在“blur”(即用户离开输入控件时)或表单提交时运行。您可以在表单级别或按控件逐个设置此配置。

例如,我们可能会决定为了避免在锻炼表单中处理缺失练习的复杂性,我们将该表单设置为仅在提交时进行验证。我们可以通过向表单标签添加以下高亮的NgFormOptions赋值来实现这一点:

<form #f="ngForm" name="formWorkout" (ngSubmit)="save(f.form)" [ngFormOptions]="{updateOn: 'submit'}" class="row">

这指示 Angular 仅在submit时运行我们的验证。尝试一下,你会发现当你输入表单时不会出现任何验证。留空表单并按下保存按钮,你将看到验证消息出现。当然,采取这种方法意味着在用户按下保存按钮之前,没有关于验证的视觉提示。

此外,使用这种方法在我们的表单中还有一些其他意想不到的副作用。第一个是,当我们输入标题到标题输入框时,标题不再在屏幕顶部更新。该值只有在按下保存按钮时才会更新。其次,如果你添加了一个或多个锻炼并删除了所有这些,你将看到验证消息出现。这是因为我们为这个控件设置了特殊条件,导致它在正常的验证流程之外触发。

因此,我们可能应该采取不同的方法。Angular 提供了通过允许我们在控件级别使用ngModelOptions来实施更精细的验证流程控制的选项。例如,让我们从表单标签中移除ngFormOptions赋值,并修改标题输入控件以添加ngModelOptions,如下所示:

<input type="text" name="title" class="form-control" #title="ngModel" id="workout-title" placeholder="What would be the workout title?" [(ngModel)]="workout.title" [ngModelOptions]="{updateOn: 'blur'}" minlength="5" maxlength="20" required>

你会注意到,当你将标题输入到输入框中时,它不会在屏幕上更新标题,直到你离开它(这会触发updateOn事件):

如你所记,默认选项会在每次按键时更新标题。这是一个假设的例子,但它说明了这些配置之间的差异是如何工作的。

你可能在这里看不到使用 on blur 设置的必要性。但是,如果你可能通过调用外部数据存储来进行验证,这种方法可以帮助限制所进行的调用次数。而进行这样的远程调用正是我们在第六章,“深入理解 Angular 指令”中将要做的,当我们实现一个自定义指令时。该指令将检查远程数据存储中是否已存在重复的名称。因此,让我们从标题输入控件中移除此配置,并将其放置在名称输入控件上,如下所示:

<input type="text" name="workoutName" #name="ngModel" class="form-control" id="workout-name" placeholder="Enter workout name. Must be unique." [(ngModel)]="workout.name" [ngModelOptions]="{updateOn: 'blur'}" required>

我们还可以在响应式表单中设置验证时间选项。根据我们已学到的关于响应式表单的知识,你不会对我们在代码中而不是模板中应用这些设置感到惊讶。例如,为了设置表单组的语法如下:

new FormGroup(value, {updateOn: 'blur'}));

我们还可以将它们应用于单个表单控件,这正是我们在练习表单中的做法。就像锻炼表单一样,我们希望能够通过远程调用验证名称的唯一性。因此,我们希望以类似的方式限制验证检查。我们将通过向创建名称表单控件的代码中添加以下内容来实现这一点:

  buildExerciseForm() {
      this.exerciseForm = this.formBuilder.group({
          'name': [
            this.exercise.name,
 {
 updateOn: 'blur',
 validators: [Validators.required, AlphaNumericValidator.invalidAlphaNumeric]
 }
          ],
        ....
      });
  }

注意,我们将设置以及validators数组放在一个花括号内的options对象中。

摘要

现在我们有一个个人教练应用程序。将特定的7 分钟锻炼应用程序转换为通用的个人教练应用程序的过程帮助我们学习了许多新概念。

我们以定义新的应用程序需求开始本章。然后,我们将模型设计为一个共享服务。

我们为个人教练应用程序定义了一些新的视图和相应的路由。我们还使用了子路由和异步路由来将锻炼构建器从应用程序的其他部分分离出来。

然后,我们将注意力转向锻炼构建。本章的一个主要技术重点是 Angular 表单。锻炼构建器使用了多个表单输入元素,我们使用模板驱动和响应式表单实现了许多常见的表单场景。我们还深入探讨了 Angular 验证,并实现了一个自定义验证器。我们还介绍了配置运行验证的时间选项。

下一章全部关于客户端-服务器交互。我们创建的锻炼和练习需要持久化。在下一章中,我们将构建一个持久化层,这将允许我们在服务器上保存锻炼和练习数据。

在我们结束本章之前,这里有一个友好的提醒。如果您还没有完成个人教练的练习构建流程,请继续进行。您始终可以将您的实现与配套代码库中提供的内容进行比较。您还可以向原始实现添加一些内容,例如上传练习图片的文件,以及一旦您更熟悉客户端-服务器交互,可以进行远程检查以确定 YouTube 视频实际上是否存在。

第五章:支持服务器数据持久性

现在是时候与服务器通信了!创建锻炼、添加练习并将其保存,然后意识到所有努力都白费,因为数据没有在任何地方持久化,这毫无乐趣。我们需要解决这个问题。

应用程序很少是自包含的。任何消费者应用程序,无论其大小如何,都有与边界之外的元素交互的部分。对于基于 Web 的应用程序,交互主要是与服务器。应用程序通过服务器进行身份验证、授权、存储/检索数据、验证数据以及执行其他此类操作。

本章探讨了 Angular 提供的客户端-服务器交互的构造。在这个过程中,我们在个人教练中添加了一个持久层,用于从后端服务器加载数据和保存数据。

本章涵盖的主题包括以下内容:

  • 提供后端以持久化锻炼数据:我们设置了一个 MongoLab 账户,并使用其数据 API 访问和存储锻炼数据。

  • 理解 Angular HttpClientHttpClient允许我们通过 HTTP 与服务器交互。你将学习如何使用HttpClient进行所有类型的GETPOSTPUTDELETE请求。

  • 实现锻炼数据的加载和保存:我们使用HTTPClient从 MongoLab 数据库中加载数据和存储锻炼数据。

  • 我们可以使用 HttpClient 的 XMLHttpRequest 的两种方式:要么是可观察对象,要么是承诺。

  • 使用 RxJS 和可观察对象:用于订阅和查询数据流。

  • 使用承诺:在本章中,我们将了解如何将承诺作为 HTTP 调用和响应的一部分来使用。

  • 处理跨域访问:由于我们正在与不同域的 MongoLab 服务器交互,你将了解浏览器对跨域访问的限制。你还将了解 JSONP 和 CORS 如何帮助我们轻松实现跨域访问,以及 Angular 对 JSONP 的支持。

让我们开始吧。

Angular 和服务器交互

任何客户端-服务器交互通常都归结为向服务器发送 HTTP 请求并从服务器接收响应。对于重型 JavaScript 应用程序,我们依赖于 AJAX 请求/响应机制与服务器通信。为了支持基于 AJAX 的通信,Angular 提供了 Angular HttpClient模块。在我们深入研究HttpClient模块之前,我们需要设置我们的服务器平台,该平台存储数据并允许我们管理它。

设置持久存储

对于数据持久性,我们使用一个名为 MongoDB 的文档数据库(www.mongodb.com/),它托管在 MongoLab(www.mlab.com/)上,作为我们的数据存储。我们之所以选择 MongoLab,是因为它提供了一个直接与数据库交互的接口。这节省了我们设置支持 MongoDB 交互的服务器中间件的精力。

直接将数据存储/数据库暴露给客户端从来不是一个好主意。但在这种情况下,由于我们的主要目标是学习 Angular 和客户端-服务器交互,我们采取了这个自由,并直接访问 MongoLab 上托管的 MongoDB 实例。还有一种新的应用类型,它们是基于无后端解决方案构建的。在这种配置中,前端开发者构建应用时不需要了解确切的后端。服务器交互仅限于向后端发出 API 调用。如果您想了解更多关于这些无后端解决方案的信息,请查看nobackend.org/

我们的首要任务是配置 MongoLab 上的账户并创建一个数据库:

  1. 前往mlab.com,按照网站上的说明注册一个 mLab 账户

  2. 一旦账户配置完成,登录并点击主页上的“创建新数据库”按钮来创建一个新的 Mongo 数据库

  3. 在数据库创建屏幕上,您需要做一些选择以配置数据库。请参阅以下截图以选择免费数据库层和其他选项:

图片

  1. 创建数据库并记下您创建的数据库名称

  2. 数据库配置完成后,打开数据库,并从“集合”标签页添加两个集合:

    • exercises:这个存储所有个人训练师的锻炼内容

    • workouts:这个存储所有个人训练师的锻炼内容

在 MongoDB 世界中,集合等同于数据库表。

MongoDB 属于一种称为文档数据库的数据库类型。这里的核心概念是文档、属性及其链接。与传统数据库不同,模式不是刚性的。我们不会在本书中介绍文档数据库是什么以及如何为基于文档的存储执行数据建模。个人训练师有有限的存储需求,我们使用前面提到的两个文档集合来管理它。我们甚至可能没有真正使用文档数据库。

集合添加后,从“用户”标签页将您自己添加到数据库中。

下一步是确定 MongoLab 账户的 API 密钥。配置的 API 密钥必须附加到对 MongoLab 发出的每个请求中。要获取 API 密钥,请执行以下步骤:

  1. 点击右上角的用户名(而不是账户名称)以打开用户资料。

  2. 在标题为“API 密钥”的部分,显示了当前的 API 密钥;复制它。同时,点击 API 密钥下方的按钮以启用数据 API 访问。默认情况下,这是禁用的。

数据存储模式已完整。我们现在需要为这些集合进行初始化。

数据库初始化

个人训练师应用已经预定义了一个锻炼计划和 12 个锻炼的列表。我们需要用这些数据初始化集合。

打开 seed.js 文件,位于 trainer/db 文件夹中,这是从配套代码库中 5.1 检查点的文件。它包含种子 JSON 脚本以及如何将数据种子到 MongoLab 数据库实例的详细说明。

一旦初始化,数据库将在 workouts 集合中有一个锻炼项目,在 exercises 集合中有 12 个练习项目。请在 MongoLab 网站上验证这一点;集合应显示以下内容:

现在一切都已经设置好了,让我们开始讨论 HttpClient 模块,并为 Personal Trainer 应用程序实现锻炼/练习的持久化。

HTTPClient 模块的基础知识

HTTPClient 模块的核心是 HttpClient。它使用 XMLHttpRequest 作为默认的后端(JSONP 也可用,我们将在本章后面看到)。它支持 GETPOSTPUTDELETE 等请求。在本章中,我们将使用 HttpClient 来执行所有这些类型的请求。正如我们将看到的,HttpClient 使得以最小的设置和复杂性进行这些调用变得容易。对于之前使用过 Angular 或构建与后端数据存储通信的 JavaScript 应用程序的人来说,这些术语都不会感到惊讶。

然而,Angular 处理 HTTP 请求的方式发生了重大变化。现在调用请求会返回一个 HTTP 响应的 Observable。它是通过使用 RxJS 库来实现的,RxJS 是一个众所周知的异步 Observable 模式的开源实现。

您可以在 GitHub 上找到 RxJS 项目,网址为 github.com/Reactive-Extensions/RxJS。网站表明,该项目正在由微软与一群开源开发者合作积极开发。我们在此不会详细介绍异步 Observable 模式,并鼓励您访问该网站以了解更多关于该模式以及 RxJS 如何实现它的信息。Angular 使用的 RxJS 版本是 beta 5。

简而言之,使用 Observables 允许开发者将应用程序中流动的数据视为信息流,应用程序可以随时从中抽取和使用。这些流随时间变化,这使得应用程序能够对这些变化做出反应。Observables 的这种特性为 函数式响应式编程FRP)提供了基础,这从根本上改变了构建 Web 应用程序的模式,从命令式转变为响应式。

RxJS 库提供了允许您订阅和查询这些数据流的操作符。此外,您可以轻松混合和组合它们,正如我们将在本章中看到的。Observables 的另一个优点是取消或取消订阅它们很容易,这使得可以无缝地在线处理错误。

虽然在 Angular 中仍然可以使用 Promise,但默认的方法是使用 Observables。我们也将在本章中介绍 Promise。

个人训练师和服务器集成

如前一章所述,客户端-服务器交互完全是关于异步的。当我们修改我们的个人训练师应用程序以从服务器加载数据时,这种模式变得显而易见。

在前一章中,初始的锻炼和练习集合被硬编码在WorkoutService实现中。让我们看看如何首先从服务器加载数据。

加载锻炼和训练数据

在本章早期,我们使用数据表单,即seed.js文件,对数据库进行了初始化。我们现在需要在我们的视图中呈现这些数据。MongoLab 数据 API 将帮助我们在这里。

MongoLab 数据 API 使用 API 密钥来验证访问请求。对 MongoLab 端点发出的每个请求都需要有一个查询字符串参数,apikey=<key>,其中key是我们在本章早期提供的 API 密钥。请记住,密钥始终提供给用户并与他们的账户相关联。避免与他人共享您的 API 密钥。

该 API 遵循可预测的模式来查询和更新数据。对于任何 MongoDB 集合,典型的端点访问模式如下(以下给出的是基本 URL:api.mongolab.com/api/1/databases):

  • /<dbname>/collections/<name>?apiKey=<key>:以下请求如下:

    • GET:此操作获取给定集合名称中的所有对象。

    • POST:此操作向集合名称添加一个新的对象。MongoLab 有一个_id属性,该属性唯一标识文档(对象)。如果未在提交的数据中提供,则自动生成。

  • /<dbname>/collections/<name>/<id>?apiKey=<key>:以下请求如下:

    • GET:此操作从集合中获取具有特定 ID(在_id属性上执行匹配)的特定文档/集合项。

    • PUT:此操作更新集合名称中的特定项(id)。

    • DELETE:此操作从集合名称中删除具有特定 ID 的项目。

关于数据 API 接口的更多详细信息,请访问 MongoLab 数据 API 文档,网址为docs.mlab.com/data-api

现在我们已经准备好开始实现锻炼/训练列表页面。

我们在本章开始时使用的代码是 GitHub 上本书的checkpoint 4.6(文件夹:trainer)。它在 GitHub 上可用(github.com/chandermani/angular6byexample)。检查点作为 GitHub 上的分支实现。如果您不使用 Git,请从以下 GitHub 位置下载checkpoint 4.6的快照(ZIP 文件):github.com/chandermani/angular6byexample/tree/checkpoint4.6。在第一次设置快照时,请参考trainer文件夹中的README.md文件。

从服务器加载锻炼和训练列表

要从 MongoLab 数据库中拉取锻炼和训练列表,我们必须重写我们的WorkoutService服务方法:getExercisesgetWorkouts。但在我们能够这样做之前,我们必须设置我们的服务以使用 Angular 的 HTTPClient 模块。

将 HTTPClient 模块和 RxJS 添加到我们的项目中

Angular HTTPClient 模块包含在你已经安装的 Angular 包中。为了使用它,我们需要将其导入到app.module.ts中,如下所示(确保导入遵循BrowserModule):

import { HttpClientModule } from '@angular/common/http';
. . . 
@NgModule({ 
  imports: [ 
    BrowserModule,
    HttpClientModule, 
. . . 
})

我们还需要一个外部第三方库:JavaScript 的响应式扩展(RxJS)。RxJS 实现了可观察模式,并且与 HTTPClient 模块一起由 Angular 使用。它包含在我们项目中的 Angular 包中。

更新 workout-service 以使用 HTTPClient 模块和 RxJS

trainer/src/app/core打开workout.service.ts。为了在WorkoutService中使用 HTTPClient 和 RxJS,我们需要将该文件中添加以下导入:

import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
import { catchError } from 'rxjs/operators';

我们正在导入HTTPClient模块以及来自 RxJS 的Observable和一个额外的 RxJS 操作符:catchError。我们将看到这个操作符是如何在本节中使用的。

在类定义中,添加以下属性,包括一个锻炼属性以及设置我们 Mongo 数据库中集合的 URL 和数据库的键的属性,以及另一个属性:params,它设置 API 密钥作为 API 访问的查询字符串:

workout: WorkoutPlan; 
collectionsUrl = "https://api.mongolab.com/api/1/ databases/<dbname>/collections"; 
apiKey = <key> 
params = '?apiKey=' + this._apiKey; 

<dbname><key>令牌替换为我们在本章前面配置的数据库的名称和 API 密钥。

接下来,使用以下代码行将 HTTPClient 模块注入到WorkoutService构造函数中:

constructor(public http: HttpClient) {
}

然后将getExercises()方法更改为以下内容:

getExercises() {
    return this.http.get<ExercisePlan>(this.collectionsUrl + '/exercises' + this.params)
        .pipe(catchError(WorkoutService.handleError));
}

如果你习惯于使用承诺(promises)进行异步数据操作,这里看到的内容将看起来不同。这里发生的情况不是将一个调用then()的承诺链在一起,而是http.get方法返回一个来自 RxJS 库的可观察对象。注意,我们还在将响应设置为<ExercisePlan>类型,以便明确告诉我们的上游调用者从我们的 HTTP GET 调用返回的是哪种类型的可观察对象。

使用HTTPClient模块的get方法时,返回一个可观察对象(Observable)是默认响应。然而,可观察对象可以被转换为一个承诺(promise)。而且,正如我们将在本章后面看到的那样,返回 JSONP 的选项也存在。

在我们继续之前,还有一件事情需要提及。注意,我们正在使用一个管道方法来添加一个catchError操作符。这个操作符接受一个方法handleError来处理失败的响应。handleError方法接受失败的响应作为参数。我们将错误记录到控制台,并使用Observable.throw将错误返回给消费者:

static handleError (error: Response) { 
    console.error(error); 
    return Observable.throw(error || 'Server error');
}

明确一点,这并不是生产代码,但它将给我们机会展示如何在上游编写代码来处理数据访问过程中生成的错误。

需要理解的是,在这个阶段,如果没有对 Observable 进行订阅,那么 Observable 中不会有数据流动。如果你没有仔细添加订阅到你的 Observables,这可能会导致添加和更新等操作出现意外情况。

getWorkouts() 方法修改为使用 HTTPClient 模块

获取锻炼数据的代码更改几乎与获取练习数据的代码相同:

getWorkouts() {
    return this.http.get<WorkoutPlan[]>(this.collectionsUrl + '/workouts' + this.params)
        .pipe(catchError(WorkoutService.handleError));
}

再次明确,我们指定了 Observable 的类型——在这个例子中是 <WorkoutPlan[]>——它将由我们的 HTTP GET 调用返回,并使用 pipe 添加一个 catchError 操作符。

现在,getExercisesgetWorkouts 方法已经更新,我们需要确保它们与上游调用者兼容。

更新锻炼/练习列表页面

练习和锻炼列表页面(以及 LeftNavExercises)在 model.ts 中调用 getExercisesgetWorkouts 方法。为了使这些调用与现在使用 HTTPClient 模块进行的远程调用兼容,我们需要修改这些调用以订阅由 HTTPClient 模块返回的 Observable。因此,将 exercises.component.ts 中的 ngOnInit 方法代码更新如下:

  ngOnInit() {
    this.workoutService.getExercises()
    .subscribe(
        exercises => this.exerciseList = exercises,
        (err: any) => console.error
    );

我们的方法现在订阅了由 getExercises 方法返回的 Observable;当响应到达时,它将结果分配给 exerciseList。如果有错误,它将错误分配给一个 console.error 调用,在控制台中显示错误。所有这些现在都是使用 HTTPClient 模块和 RxJS 异步处理的。

接下来,对 workouts.component.tsleft-nav-exercises.component.ts 中的 ngOnInit 方法进行类似的修改。

刷新锻炼/练习列表页面,锻炼和练习数据将从数据库服务器加载。

如果你在 GitHub 仓库中难以检索/显示数据,请查看第 5.1 个检查点的完整实现。注意,在这个检查点中,我们已禁用导航链接到锻炼和练习屏幕,因为我们还需要向它们添加 Observable 实现。我们将在下一节中完成这项工作。此外,记得在运行 Checkpoint 5.1 中的代码之前替换数据库名称和 API 密钥。如果你不使用 Git,可以从以下 GitHub 位置下载 Checkpoint 5.1 的快照(ZIP 文件):github.com/chandermani/angular6byexample/tree/checkpoint5.1。在设置快照时,请参考 trainer 文件夹中的 README.md 文件。

这看起来不错,列表加载也正常。嗯,几乎是这样!在锻炼列表页面上有一个小故障。如果我们仔细查看任何列表项(实际上只有一个项),就可以轻松地发现它:

锻炼时长计算不再起作用了!可能的原因是什么?我们需要回顾一下这些计算是如何实现的。WorkoutPlan服务(在model.ts中)定义了一个totalWorkoutDuration方法来完成这个计算。

差异在于绑定到视图的锻炼数组。在前一章中,我们使用WorkoutPlan服务创建了一个包含模型对象的数组。但现在,由于我们从服务器检索数据,我们绑定了一个简单的 JavaScript 对象数组到视图,这显然没有计算逻辑。

我们可以通过将服务器响应映射到我们的模型类对象并将它们返回给任何上游调用者来解决这个问题。

将服务器数据映射到应用程序模型

如果模型和服务器存储定义匹配,则将服务器数据映射到我们的模型以及反之亦然可能是不必要的。如果我们查看Exercise模型类和我们在 MongoLab 中为锻炼添加的种子数据,我们会看到它们是匹配的,因此映射变得不必要。

将服务器响应映射到模型数据变得至关重要,如果:

  • 我们模型定义了任何方法

  • 存储的模型与其代码表示不同

  • 使用相同的模型类来表示来自不同来源的数据(这可能在混合应用中发生,我们从不同的来源获取数据)

WorkoutPlan服务是一个模型表示与其存储之间阻抗不匹配的典型例子。查看以下截图以了解这些差异:

模型和服务器数据之间的两个主要差异如下:

  • 模型定义了totalWorkoutDuration方法。

  • exercises数组表示也各不相同。模型的exercises数组包含完整的Exercise对象,而服务器数据只存储锻炼标识符或名称。

这显然意味着加载和保存锻炼需要模型映射。

我们将通过添加另一个操作符来转换 Observable 响应对象来实现这一点。到目前为止,我们只返回了一个普通的 JavaScript 对象作为响应。好事是,我们用来添加错误处理的 pipe 方法还允许我们添加额外的操作符,我们可以使用这些操作符将 JavaScript 对象转换为我们模型中的WorkoutPlan类型。

让我们在workout-service.ts文件中重写getWorkouts方法如下:

    getWorkouts(): Observable<WorkoutPlan[]> {
        return this.http.get<WorkoutPlan[]>(this.collectionsUrl + '/workouts' + this.params)
            .pipe(
                map((workouts: Array<any>) => {
                  const result: Array<WorkoutPlan> = [];
                  if (workouts) {
                      workouts.forEach((workout) => {
                          result.push(
                              new WorkoutPlan(
                                  workout.name,
                                  workout.title,
                                  workout.restBetweenExercise,
                                  workout.exercises,
                                  workout.description
                              ));
                      });
                  }
                  return result;
                }),
                catchError(this.handleError<WorkoutPlan[]>('getWorkouts', []))
            );
    }

我们添加了一个map操作符,将这个 Observable 转换成一个由WorkoutPlan对象组成的 Observable。然后,每个WorkoutPlan对象(目前我们只有一个)都将拥有我们需要的totalWorkoutDuration方法。

查看代码,你可以看到我们操作的是 HTTPClient 响应的 JSON 结果,这就是为什么我们使用<any>类型。然后我们创建一个WorkoutPlans的类型化数组,并使用箭头函数forEach遍历第一个数组,将每个 JavaScript 对象分配给一个WorkoutPlan对象。

我们将这些映射的结果返回给订阅它们的调用者,在这种情况下是workouts.component.ts。我们还更新了catchError操作符,使用了一个新的handleError方法,你可以在Checkpoint 5.2中找到它。调用者不需要对其用于订阅我们的锻炼 Observable 的代码进行任何更改。相反,模型映射可以在应用程序的一个位置进行,然后在整个应用程序中使用。

如果你重新运行应用程序,你会看到现在总秒数显示正确:

图片

GitHub 仓库中的 Checkpoint 5.2 包含了我们到目前为止所涵盖的完整实现。GitHub 分支是checkpoint5.2(文件夹:trainer)。

从服务器加载锻炼和锻炼数据

正如我们在之前的WorkoutService中修复了getWorkouts实现一样,我们可以为与锻炼和锻炼相关的其他 get 操作实现。从checkpoint 5.2中的trainer/src/app/core文件夹中的workout.service.ts复制getExercisegetWorkout方法的实现。

getWorkoutgetExercise方法使用锻炼/锻炼的名称来检索结果。每个 MongoLab 集合项都有一个_id属性,该属性唯一标识了项/实体。在我们的ExerciseWorkoutPlan对象的情况下,我们使用锻炼的名称进行唯一标识。因此,每个对象的name_id属性总是匹配的。

在这一点上,我们需要在workout.service.ts中添加一个额外的导入:

import { forkJoin } from 'rxjs/observable/forkJoin';

这个导入引入了forkJoin操作符,我们将在稍后讨论。

请特别注意getWorkout方法的实现,因为由于模型和数据存储格式不匹配,这里会发生相当数量的数据转换。这就是getWorkout方法现在的样子:

    getWorkout(workoutName: string): Observable<WorkoutPlan> {
      return forkJoin (
          this.http.get(this.collectionsUrl + '/exercises' + this.params),
          this.http.get(this.collectionsUrl + '/workouts/' + workoutName + this.params))
          .pipe(
               map(
                  (data: any) => {
                      const allExercises = data[0];
                      const workout = new WorkoutPlan(
                          data[1].name,
                          data[1].title,
                          data[1].restBetweenExercise,
                          data[1].exercises,
                          data[1].description
                      );
                      workout.exercises.forEach(
                          (exercisePlan: any) => exercisePlan.exercise = allExercises.find(
                              (x: any) => x.name === exercisePlan.name
                          )
                      );
                      return workout;
                  }
              ),
              catchError(this.handleError<WorkoutPlan>(`getWorkout id=${workoutName}`))
        );
      }

getWorkout方法内部发生了很多事情,我们需要理解。

getWorkout方法使用 Observable 及其forkJoin操作符来返回两个 Observable 对象:一个用于检索Workout,另一个用于检索所有Exercises的列表。forkJoin操作符有趣的地方在于,它不仅允许我们返回多个 Observable 流,而且在进一步处理结果之前,它还会等待两个 Observable 流都检索到它们的数据。换句话说,它使我们能够从多个并发 HTTP 请求中流式传输响应,然后对组合结果进行操作。

一旦我们有了 Workout 详细信息和完整的锻炼列表,我们就将结果通过 pipe 传输到 map 操作符(我们之前在 Workouts 列表代码中看到过),我们使用它来将锻炼的 exercises 数组转换为正确的 Exercise 类对象。我们通过在 allExercises Observable 中搜索从服务器返回的 workout.exercises 数组中的锻炼名称来实现这一点,然后将匹配的锻炼分配给锻炼服务数组。最终结果是,我们有一个完整的 WorkoutPlan 对象,其 exercises 数组已正确设置。

这些对 WorkoutService 的更改也要求上游调用者进行修复。我们已经修复了 LeftNavExercisesExercises 组件中的锻炼列表以及 Workouts 组件中的锻炼。现在让我们按照类似的方式修复 WorkoutExercise 组件。锻炼服务中的 getWorkoutgetExercise 方法不是直接由这些组件调用,而是由构建服务调用。因此,我们将不得不与 WorkoutExercise 组件以及两个解析器——WorkoutResolverExerciseResolver——一起修复,我们将这些解析器添加到这些组件的路由中。

修复构建服务

现在我们已经设置了 WorkoutService 来从我们的远程数据存储中检索锻炼,我们必须修改 WorkoutBuilderService 以能够将那个锻炼作为一个 Observable 检索。提取 Workout 详细信息的方法是 startBuilding。为了做到这一点,我们将当前的 startBuilding 方法拆分为两个方法,一个用于新的锻炼,另一个用于我们从服务器检索到的现有锻炼。以下是新锻炼的代码:

    startBuildingNew() {
      const exerciseArray: ExercisePlan[] = [];
      this.buildingWorkout = new WorkoutPlan('', '', 30, exerciseArray);
      this.newWorkout = true;
      return this.buildingWorkout;
    }

对于现有锻炼,我们添加以下代码:

    startBuildingExisting(name: string) {
      this.newWorkout = false;
      return this.workoutService.getWorkout(name);
    }

我们将让您在 ExerciseBuilderService 中进行相同的修复。

更新解析器

当我们开始使用 Observable 类型与我们的数据访问一起使用时,我们将不得不对我们为通往锻炼和锻炼屏幕的路由创建的解析器进行一些调整。我们从 workout-resolver.ts 中的 WorkoutResolver 开始,该文件位于 workout 文件夹中。

首先添加以下来自 RxJs 的导入:

import { Observable } from 'rxjs/Observable';
import { of } from 'rxjs/observable/of';
import { map, catchError } from 'rxjs/operators';

接下来更新 resolve 方法如下:

  resolve(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<WorkoutPlan> {
    const workoutName = route.paramMap.get('id');

    if (!workoutName) {
        return this.workoutBuilderService.startBuildingNew();
    } else {
        return this.workoutBuilderService.startBuildingExisting(workoutName)
        .pipe(
          map(workout => {
            if (workout) {
              this.workoutBuilderService.buildingWorkout = workout;
              return workout;
            } else {
              this.router.navigate(['/builder/workouts']);
              return null;
            }
          }),
          catchError(error => {
            console.log('An error occurred!');
            this.router.navigate(['/builder/workouts']);
            return of(null);
          })
        );
    }

如您所见,我们已经将新锻炼的行为(在 URL 参数中没有传递锻炼名称的情况)和现有锻炼的行为分开。在前一种情况下,我们调用 workoutBuilderService.startBuildingExisting,这将返回一个新的 WorkoutPlan。在后一种情况下,我们调用 workoutBuilderService.startBuildingExisting 并将结果通过管道传输,然后映射以返回 workout,除非找不到,在这种情况下,我们将用户路由回 Workouts 屏幕。

修复 WorkoutExercise 组件

一旦我们修复了WorkoutBuilderServiceWorkoutResolver,实际上在WorkoutComponent中就不再需要进一步的修复。处理 Observables 的所有工作已经在更下游完成,我们现在需要做的只是订阅路由数据并检索锻炼项目,就像我们之前所做的那样:

  ngOnInit() {
      this.sub = this.route.data
          .subscribe(
            (data: { workout: WorkoutPlan }) => {
              this.workout = data.workout;
            }
          );
  }

为了测试实现,取消注释以下在workouts.component.ts中的onSelect方法内高亮的代码:

  onSelect(workout: WorkoutPlan) {
      this.router.navigate( ['./builder/workout', workout.name] );
  }

然后点击列表中显示在/builder/workouts/的任何现有锻炼项目,例如7 分钟锻炼。锻炼数据应该能够成功加载。

ExerciseBuilderServiceExerciseResolver也需要修复。Checkpoint 5.2包含了这些修复。你可以复制这些文件或自己进行修复,并比较实现。别忘了取消注释exercises.component.ts中的onSelect方法中的代码。

GitHub 仓库中的Checkpoint 5.2包含了到目前为止我们所涵盖的完整实现。如果你不使用 Git,可以从以下 GitHub 位置下载 Checkpoint 5.2 的快照(ZIP 文件):github.com/chandermani/angular6byexample/tree/checkpoint5.2。在首次设置快照时,请参考trainer文件夹中的README.md文件。

现在是时候修复、创建和更新锻炼项目的场景了。

对锻炼/项目执行 CRUD 操作

当涉及到创建、读取、更新和删除(CRUD)操作时,所有保存、更新和删除方法都需要转换为 Observable 模式。

在本章的早期部分,我们详细介绍了在 MongoLab 集合中 CRUD 操作的端点访问模式。回到加载锻炼和锻炼数据部分,重新审视访问模式。我们现在需要这些信息,因为我们计划创建/更新锻炼项目。

在开始实现之前,了解 MongoLab 如何识别集合项以及我们的 ID 生成策略非常重要。在 MongoDB 中,每个集合项都通过_id属性在集合中唯一标识。在创建新项时,我们提供 ID 或服务器自动生成一个。一旦_id被设置,就不能更改。对于我们的模型,我们将使用锻炼/项目的name属性作为唯一 ID,并将名称复制到_id字段(因此,没有自动生成_id)。还要记住,我们的模型类不包含这个_id字段;它必须在第一次保存记录之前创建。

让我们先修复锻炼创建场景。

创建新的锻炼项目

采用自下而上的方法,首先需要修复的是WorkoutService。按照以下代码更新addWorkout方法:

    addWorkout(workout: WorkoutPlan) {
      const workoutExercises: any = [];
      workout.exercises.forEach(
          (exercisePlan: any) => {
              workoutExercises.push({name: exercisePlan.exercise.name, duration: exercisePlan.duration});
          }
      );

      const body = {
          '_id': workout.name,
          'exercises': workoutExercises,
          'name': workout.name,
          'title': workout.title,
          'description': workout.description,
          'restBetweenExercise': workout.restBetweenExercise
      };

      return this.http.post(this.collectionsUrl + '/workouts' + this.params, body)
        .pipe(
          catchError(this.handleError<WorkoutPlan>())
        );
    }

getWorkout 中,我们必须将服务器模型中的数据映射到我们的客户端模型中;这里需要做的是反向操作。首先,我们为锻炼创建一个新的数组 workoutExercises,然后向该数组添加一个更紧凑的版本,以便在服务器上存储。我们只想在服务器上的锻炼数组中存储锻炼名称和持续时间(此数组为 any 类型,因为在其紧凑格式中它不符合 ExercisePlan 类型)。

接下来,我们将这些更改映射到一个 JSON 对象中,以此设置我们帖子的主体。请注意,在构建此对象的过程中,我们将 _id 属性设置为锻炼的名称,以便在锻炼集合的数据库中唯一标识它。

使用锻炼/练习的 名称 作为记录标识符(或 id)在 MongoDB 中的简单方法对于任何中等规模的应用程序都会失效。请记住,我们正在创建一个可以由许多用户同时访问的基于 Web 的应用程序。由于总有可能有两个用户为锻炼/练习想出相同的名称,我们需要一个强大的机制来确保名称不会重复。MongoLab REST API 的另一个问题是,如果有重复的 POST 请求具有相同的 id 字段,第一个将创建一个新的文档,第二个将更新它,而不是第二个失败。这意味着在客户端对 id 字段的任何重复检查都无法防止数据丢失。在这种情况下,分配自动生成 id 值是更好的选择。在标准情况下,当我们创建实体时,唯一 ID 的生成是在服务器上完成的(通常由数据库完成)。当实体创建时,响应将包含生成的 ID。在这种情况下,在将数据返回给调用代码之前,我们需要更新模型对象。

最后,我们调用 HTTPClient 模块的 post 方法,传递要连接的 URL、额外的查询字符串参数(apiKey)以及我们正在发送的数据。

最后一个返回语句应该看起来很熟悉,因为我们使用 Observables 将锻炼对象作为 Observable 解析的一部分返回。您需要确保在 Observable 链中添加 .subscribe 以使其工作。我们将通过向 WorkoutComponentsave 方法添加订阅来实现这一点。我们将在稍后这样做。

更新锻炼

为什么不尝试实现更新操作呢?updateWorkout 方法可以以相同的方式修复,唯一的区别是需要 HTTPClient 模块的 put 方法:

    updateWorkout(workout: WorkoutPlan) {
      const workoutExercises: any = [];
      workout.exercises.forEach(
          (exercisePlan: any) => {
              workoutExercises.push({name: exercisePlan.exercise.name, duration: exercisePlan.duration});
          }
      );

      const body = {
          '_id': workout.name,
          'exercises': workoutExercises,
          'name': workout.name,
          'title': workout.title,
          'description': workout.description,
          'restBetweenExercise': workout.restBetweenExercise
      };

      return this.http.put(this.collectionsUrl + '/workouts/' + workout.name + this.params, body)
        .pipe(
          catchError(this.handleError<WorkoutPlan>())
        );
    }

前面的请求 URL 现在包含一个额外的片段(workout.name),表示需要更新的集合项的标识符。

MongoLab 的PUT API 请求在集合中找不到文档时,会创建作为请求体传递的文档。在执行PUT请求时,请确保原始记录存在。我们可以通过首先对该文档执行GET请求并确认在更新之前我们得到了一个文档来实现这一点。我们将把这个留给你来实现。

删除锻炼

需要修复的最后一个操作是删除锻炼。这里有一个简单的实现,我们调用HTTPClient模块的delete方法来删除由特定 URL 引用的锻炼:

    deleteWorkout(workoutName: string) {
        return this.http.delete(this.collectionsUrl + '/workouts/' + workoutName + this.params)
          .pipe(
            catchError(this.handleError<WorkoutPlan>())
          );
    }

修复上游代码

现在是时候修复WorkoutBuilderServiceWorkout组件了。WorkoutBuilderServicesave方法现在看起来如下:

    save() {
      const workout = this.newWorkout ?
          this.workoutService.addWorkout(this.buildingWorkout) :
          this.workoutService.updateWorkout(this.buildingWorkout);
      this.newWorkout = false;
      return workout;
   }

大部分看起来和之前一样,因为它们确实是相同的!我们不需要更新这段代码,因为我们有效地在WorkoutService组件中隔离了与外部服务器的交互。

最后,这里展示了Workout组件的保存代码:

  save(formWorkout: any) {
    this.submitted = true;
    if (!formWorkout.valid) { return; }
    this.workoutBuilderService.save().subscribe(
      success => this.router.navigate(['/builder/workouts']),
      err => console.error(err)
    );
  }

我们已经做出了一些更改,现在我们订阅了保存。如您从我们之前的讨论中回忆起来,subscribe使 Observable 变得活跃,这样我们就可以完成保存。

就这样!我们现在可以创建新的锻炼并更新现有的锻炼(我们将删除锻炼的完成留给你)。这并不太难!

让我们试试看。打开新的Workout Builder页面,创建一个锻炼并保存它。也尝试编辑一个现有的锻炼。这两种情况都应该无缝工作。

如果你在运行本地副本时遇到问题,请查看checkpoint 5.3的最新实现。如果你不使用 Git,请从以下 GitHub 位置下载 Checkpoint 5.3 的快照(ZIP 文件):github.com/chandermani/angular6byexample/tree/checkpoint5.3。在首次设置快照时,请参考trainer文件夹中的README.md文件。

当我们进行POSTPUT请求保存数据时,网络端会发生一些有趣的事情。打开浏览器的网络日志控制台(F12)查看正在进行的请求。日志看起来可能如下所示:

网络日志

在实际执行POSTPUT之前,会向同一端点发出一个OPTIONS请求。我们在这里观察到的行为被称为预战斗请求。这是因为我们正在向api.mongolab.com发起跨域请求。

使用承诺进行 HTTP 请求

本章的大部分内容都集中在 Angular HTTPClient 如何使用观察者(Observables)作为 XMLHttpRequests 的默认值。这代表了一个重大的变化,与过去的工作方式相比。许多开发者熟悉使用承诺进行异步 HTTP 请求。在这种情况下,Angular 继续支持承诺,但不是作为默认选择。开发者必须选择在 XMLHttpRequest 中使用承诺才能使用它们。

例如,如果我们想在 WorkoutService 中的 getExercises 方法中使用承诺,我们必须将命令重构如下:

    getExercises(): Promise<Exercise[]> {
        return this.http.get<Exercise[]>(this.collectionsUrl + '/exercises' + this.params)
        .toPromise()
        .then(res => res)
        .catch(err => {
            return Promise.reject(this.handleError('getExercises', []));
        });
    }

为了将此方法转换为使用承诺(promises),我们只需在方法链中添加 .toPromise(),为承诺添加一个成功参数 then,以及一个使用指向现有 handleError 方法的 Promise.rejectcatch

对于上游组件,我们只需将处理返回值从观察者(Observable)更改为承诺。因此,在这种情况下使用承诺,我们需要在 Exercises.component.tsLeftNavExercises.component.ts 中的代码中首先添加一个新的错误消息属性(我们将如何显示屏幕上的错误消息留给你):

errorMessage: any;

然后将调用 WorkoutServicengOnInit 方法更改为以下内容:

  ngOnInit() {
    this.workoutService.getExercises()
 .then(exerciseList => this.exerciseList = exerciseList,
 error => this.errorMessage = <any>error
    );
  }  

当然,我们在这个简单示例中用承诺替换观察者(Observables)的便捷性并不意味着它们本质上相同。一个 then 承诺返回另一个承诺,这意味着你可以创建连续链式的承诺。而在观察者(Observable)的情况下,订阅基本上是终点,并且在该点之后不能映射或订阅。

如果你熟悉承诺(promises),在这个阶段可能会倾向于继续使用它们而不尝试观察者(Observables)。毕竟,我们在本章中使用观察者(Observables)所做的许多事情也可以用承诺来完成。例如,我们使用 ObservableforkJoin 操作符通过 getWorkouts 实现的两个观察者流映射也可以使用承诺的 q,all 函数来完成。

然而,如果你采取那种方法,你就是在贬低自己。观察者(Observables)为使用所谓的函数式响应式编程(functional reactive programming)进行网页开发开辟了令人兴奋的新途径。它们涉及一种基本思维方式的转变,将应用程序的数据视为一个持续的信息流,应用程序对其做出反应和响应。这种转变允许以不同的架构构建应用程序,使它们更快、更健壮。观察者(Observables)是 Angular 的核心,例如事件发射器和 NgModel 的新版本。

虽然承诺是工具箱中的一个有用工具,但我们鼓励你在使用 Angular 进行开发时调查观察者(Observables)。它们是 Angular 向前看哲学的一部分,并将有助于确保你的应用程序和技能集在未来具有前瞻性。

查看 checkpoint 5.3 文件以获取包含我们之前涵盖的与承诺相关的代码的最新实现。如果您不使用 Git,请从以下 GitHub 位置下载 Checkpoint 5.3 的快照(ZIP 文件):github.com/chandermani/angular6byexample/tree/checkpoint5.3。在首次设置快照时,请参考 trainer 文件夹中的 README.md 文件。请注意,在下一节中,我们将恢复使用 Observables 来处理此代码。此代码可在 checkpoint 5.4 文件中找到。

async pipe

正如我们在本章中涵盖的许多数据操作中看到的那样,有一个相当常见的模式被反复重复。当一个 Observable 从 HTTP 请求返回时,我们将响应转换为 JSON 并订阅它。然后订阅将 Observable 输出绑定到 UI 元素。如果我们能够消除这种重复的编码并替换为一种更简单的方式来完成我们想要做的事情,那岂不是很好?

毫不奇怪,Angular 为我们提供了正确的方式来做到这一点。它被称为 async pipe,它可以像任何其他管道一样用于绑定到屏幕上的元素。然而,async pipe 是比其他管道更强大的机制。它接受一个 Observable 或承诺作为输入并自动订阅它。它还处理 Observable 订阅的拆卸,而无需任何额外的代码行。

让我们看看我们应用中的一个例子。让我们回到上一节中与承诺相关的 LeftNavExercises 组件。请注意,我们已经将此组件和 Exercises 组件从承诺转换回使用 Observables。

查看 checkpoint 5.4 文件以获取最新的实现,该实现包括将此代码转换为使用 Observables。如果您不使用 Git,请从以下 GitHub 位置下载 Checkpoint 5.4 的快照(ZIP 文件):github.com/chandermani/angular6byexample/tree/checkpoint5.4。在首次设置快照时,请参考 trainer 文件夹中的 README.md 文件。

然后在 LeftNavExercises 中进行以下更改。首先,从 RxJs 导入 Observable:

import { Observable } from 'rxjs/Observable';

然后将 exerciseList 从练习数组更改为相同类型的 Observable:

public exerciseList:Observable<Exercise[]>;

接下来修改调用 WorkoutService 获取练习的代码,以消除订阅:

this.exerciseList = this.workoutService.getExercises();

最后,打开 left-nav-exercises.component.html 并将 async 管道添加到 *ngFor 循环中,如下所示:

<div *ngFor="let exercise of exerciseList|async|orderBy:'title'">

刷新页面后,你仍然会看到显示的练习列表。但这次,我们使用了 async 管道来消除设置对 Observable 订阅的需要。非常酷!这是 Angular 添加的一个很好的便利功能,因为我们已经在这个章节中花费时间理解 Observables 与订阅的工作方式,所以我们现在对 async 管道在幕后为我们处理什么有了清晰的认识。

我们将把这个相同的更改在 Exercises 组件中的实现留给你。

理解 HTTP 请求的跨域行为以及 Angular 提供用于进行跨域请求的结构非常重要。

跨域访问和 Angular

跨域请求是对不同域中资源的请求。当从 JavaScript 发起时,这些请求会受到浏览器的一些限制;这些限制被称为 同源策略 限制。这种限制阻止浏览器向与脚本原始源不同的域发送 AJAX 请求。源匹配是严格基于协议、主机和端口的组合进行的。

对于我们自己的应用,对 https://api.mongolab.com 的调用是跨域调用,因为我们的源代码托管在不同的域中(最可能是类似 http://localhost/.... 的东西)。

有一些解决方案和一些标准有助于放宽/控制跨域访问。我们将探讨这两种技术,因为它们是最常用的。它们如下:

  • 填充 JSONJSONP

  • 跨源资源共享CORS

一种绕过相同源策略的常见方法是使用 JSONP 技术。

使用 JSONP 进行跨域请求

远程调用的 JSONP 机制依赖于浏览器可以从任何域执行 JavaScript 文件的事实,无论其来源如何,只要脚本是通过 <script> 标签包含的。

在 JSONP 中,不是直接向服务器发送请求,而是生成一个动态的 <script> 标签,其 src 属性设置为需要调用的服务器端点。当这个 <script> 标签附加到浏览器的 DOM 上时,会导致向目标服务器发送请求。

服务器随后需要以特定格式发送响应,将响应内容包裹在函数调用代码中(这额外的填充围绕响应数据使得这项技术被称为 JSONP)。

Angular JSONP 服务隐藏了这种复杂性,并提供了一个简单的 API 来进行 JSONP 请求。StackBlitz 链接 stackblitz.com/edit/angular-nxeuxo 突出了如何进行 JSONP 请求。它使用 IEX Free Stock API([iextrading.com/developer/](https://iextrading.com/developer/))来获取任何股票代码的报价。

Angular JSONP 服务仅支持 HTTP GET 请求。使用任何其他 HTTP 请求,例如 POSTPUT,将生成错误。

如果你查看 StackBlitz 项目,你会看到我们在这本书中一直遵循的组件创建的熟悉模式。我们不会再次介绍这个模式,但会突出一些与使用 Angular JSONP 服务相关的细节。

首先,除了导入 FormsModuleHttpClientModule,你还需要将 HttpClientJsonpModule 导入到 app.module.ts 中,如下所示:

. . . 
import { HttpClientModule, HttpClientJsonpModule } from '@angular/common/http';
import { FormsModule } from '@angular/forms';
. . . 
@NgModule({
. . . 
  imports: [
    BrowserModule,
    FormsModule,
    HttpClientModule,
 HttpClientJsonpModule
  ],
. . . 
}) 

接下来,我们需要将以下导入添加到 get-quote.component.ts

import { Component }from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
import { map } from 'rxjs/operators';

我们正在导入 HttpClient,它包含我们将要使用的 JSONP 方法,以及 RxJS 的 Observablemap 操作符。这些导入对你来说应该很熟悉,因为我们已经在本章中构建过。

当你使用 Angular JSONP 时,重要的是要理解默认情况下,它使用 RxJS 返回 Observables。这意味着我们将必须遵循订阅这些 Observables 的模式,并使用 RxJS 操作符来操作结果。我们还可以使用异步管道来简化这些操作。

然后将 HttpClient 注入到构造函数中:

constructor(public http: HttpClient) {}

接下来,我们添加几个将在我们的 JSONP 调用中使用的变量:

   symbol: string;
   quote: Observable<string>;
   url: string = 'https://api.iextrading.com/1.0/stock/';

symbol 变量将保存用户提供的搜索字符串。quote 变量将用于在我们的模板中显示从 JSONP 调用返回的值。而 url 变量是我们将要调用服务的基准 URL。

现在我们已经为我们的 getQuote 方法准备好了所有东西。让我们来看看它:

   getQuote (){ 
      let searchUrl = `${this.url}${this.symbol}/quote`;
      this.quote = this.http.jsonp(searchUrl, 'callback')
          .pipe(
          map( (res: string) => res)
        ); 
    }; 

我们首先通过连接 urlsymbol 并添加 /quote 来构造我们的 searchUrl。最后一部分 quote 是我们需要传递给报价服务以返回股票报价的内容。

然后,我们使用 HTTPClient 的 jsonp 方法执行对报价服务的远程调用。我们将 searchUrl 作为该方法的第一个参数传递,并将字符串 'callback' 作为我们的第二个参数。后一个参数由 Angular 用于在 searchUrl 中添加一个额外的查询字符串参数 callback。内部,Angular JSONP 服务生成一个动态的 script 标签和一个回调函数,并执行远程请求。

打开 StackBlitz 并输入符号,如 GOOGMSFTFB,以查看股票报价服务的作用。浏览器网络日志中的请求看起来如下:

https://api.iextrading.com/1.0/stock/MSFT/quote?callback=ng_jsonp_callback_0

在这里,ng_jsonp_callback_0 是动态生成的函数。响应看起来如下:

typeof ng_jsonp_callback_0 === 'function' && ng_jsonp_callback_0({"quote"::{"symbol":"MSFT"..});

响应被包裹在回调函数中。Angular 解析并评估这个响应,这导致调用 __ng_jsonp__.__req1 回调函数。然后,这个函数内部将数据路由到我们的函数回调。

我们希望这解释了 JSONP 的工作原理以及 JSONP 请求的底层机制。然而,JSONP 有其局限性:

  • 首先,我们只能进行 GET 请求(这是显而易见的,因为这些请求是由于脚本标签而发起的)

  • 其次,服务器还需要实现解决方案中涉及将响应包装在函数回调中的部分

  • 第三,始终存在安全风险,因为 JSONP 依赖于动态脚本生成和注入

  • 第四,错误处理也不可靠,因为很难确定脚本加载失败的原因

最终,我们必须认识到 JSONP 更像是一种权宜之计,而不是一种解决方案。随着我们迈向 Web 2.0,其中混搭变得司空见惯,越来越多的服务提供商决定通过 Web 公开他们的 API,一种更好的解决方案/标准已经出现:CORS。

跨域资源共享

跨域资源共享CORS)为 Web 服务器提供了一种支持跨站访问控制的方法,允许浏览器从脚本中发起跨域请求。根据这一标准,消费者应用程序(如私人教练)被允许进行某些类型的请求,称为简单请求,而无需任何特殊设置要求。这些简单请求限于GETPOST(具有特定的 MIME 类型)和HEAD。所有其他类型的请求都称为复杂请求

对于复杂请求,CORS 强制要求请求之前必须有一个 HTTP OPTIONS请求(也称为预检请求),该请求查询服务器以确定允许跨域请求的 HTTP 方法。只有在成功探测后,才会发出实际请求。

你可以从 MDN 文档中了解更多关于 CORS 的信息,该文档可在developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS找到。

CORS 最好的部分是客户端不需要像 JSONP 那样进行调整。完整的握手机制对调用代码是透明的,我们的 Angular HTTPClient调用工作得非常顺利。

CORS 需要在服务器上配置,MongoLab 服务器已经配置为允许跨域请求。因此,我们之前向 MongoLab 发送的添加和更新ExerciseWorkout文档的POSTPUT请求都导致了预检OPTIONS请求。

处理找不到的锻炼项目

你可能还记得,在第四章“私人教练”中,我们创建了WorkoutResolver,不仅用于在导航到WorkoutComponent之前检索锻炼项目,而且还防止导航到该组件,如果路由参数中存在不存在的锻炼项目。现在我们希望通过在锻炼屏幕上显示错误消息来增强这一功能,指出找不到锻炼项目。

为了实现这一点,我们需要修改WorkoutResolver,使其在找不到锻炼项目时重定向到锻炼屏幕。首先,将以下子路由添加到WorkoutBuilderRoutingModule中(确保它位于现有的锻炼路由之前):

children: [ 
  {path: '', pathMatch: 'full', redirectTo: 'workouts'}, 
 {path: 'workouts/workout-not-found', component: WorkoutsComponent'}, 
  {path: 'workouts', component: 'WorkoutsComponent'}, 
   *** other child routes *** 
  }, 
]

接下来,修改 WorkoutResolver 中的 resolve 方法,在找不到锻炼的情况下重定向到该路由。

resolve(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<WorkoutPlan> {
    const workoutName = route.paramMap.get('id');

    if (!workoutName) {
        return this.workoutBuilderService.startBuildingNew();
    } else {
        this.isExistingWorkout = true;
        return this.workoutBuilderService.startBuildingExisting(workoutName)
        .pipe(
          map(workout => {
            if (workout) {
              this.workoutBuilderService.buildingWorkout = workout;
              return workout;
            } else {
              this.router.navigate(['/builder/workouts/workout-not-found']);
              return null;
            }
          }),
          catchError(error => {
            console.log('An error occurred!');
            this.router.navigate(['/builder/workouts']);
            return of(null);
          })
        );
    }

然后在 Workouts 组件的变量中添加一个设置为 falsenotFound 布尔值:

  workoutList: Array<WorkoutPlan> = [];
  public notFound = false;

在该组件的 ngOnInit 方法中,添加以下代码以检查 workout-not-found 路径并将 notFound 值设置为 true

ngOnInit() {
  if(this.route.snapshot.url[1] && this.route.snapshot.url[1].path === 
  'workout-not-found') this.notFound = true; 
  this.subscription = this.workoutService.getWorkouts() 
  .subscribe( 
    workoutList => this.workoutList = workoutList, 
    (err:any) => console.error(err) 
  ); 
}

最后,在 Workouts.component.html 模板中,在显示 notFound 设置为 true 的锻炼列表上方添加以下 div 标签:

<div *ngIf="notFound" class="not-found-msgbox">Could not load the specific workout!</div>

如果当用户返回到 Workouts 页面时,在路径中找到 workout-not-found,那么屏幕上会显示以下信息:

我们已经修复了 Workout Builder 页面的路由失败,但练习构建器页面仍然待定。我们再次将修复工作留给你自己。

另一个主要(且待定)的实现是修复 7 分钟健身,因为它目前只针对一个锻炼方案。

修复 7 分钟健身应用

目前,7 分钟健身(或锻炼跑步者)应用只能播放一个特定的锻炼。它需要修复以支持使用 个人教练 构建的任何锻炼计划的执行。显然,需要整合这两个解决方案。我们已经完成了这项整合的基础工作。我们有了共享模型服务和 WorkoutService 来加载数据,这足以让我们开始。

修复 7 分钟健身 并将其转换为通用的 锻炼跑步者 大致涉及以下步骤:

  • 移除在 7 分钟健身 中使用的硬编码的锻炼和练习。

  • 修复起始页面以显示所有可用的锻炼,并允许用户选择要运行的锻炼。

  • 修复锻炼路由配置以将选定的锻炼名称作为路由参数传递到锻炼页面。

  • 使用 WorkoutService 加载选定的锻炼数据并开始锻炼。

  • 当然,我们需要重命名应用中的“7 分钟健身”部分;现在的名字已经不准确了。我认为整个应用可以命名为“个人教练”。我们还可以从视图中移除所有关于“7 分钟健身”的引用。

这是一个值得你亲自尝试的优秀练习!这就是为什么我们不会带你通过解决方案。相反,你可以继续实现解决方案。将你的实现与 checkpoint 5.4 中可用的解决方案进行比较。

是时候结束这一章节并总结你的学习了。

摘要

我们现在有一个可以进行很多操作的应用。它可以运行锻炼、加载锻炼、保存和更新它们,并跟踪历史记录。如果我们回顾一下,我们用最少的代码实现了这一点。我们打赌,如果我们尝试在标准的 jQuery 或其他框架中做这件事,与 Angular 相比,它将需要更多的努力。

我们在章节开始时在 MongoLab 服务器上提供了一个 MongoDB 数据库。由于 MongoLab 提供了 RESTful API 来访问数据库,我们没有设置自己的服务器基础设施,从而节省了一些时间。

我们首先接触到的 Angular 结构是HTTPClient,这是连接到任何 HTTP 后端的主要服务。

你还学习了HTTPClient模块如何使用 Observables。在本章中,我们第一次创建了自己的 Observable,并解释了如何为这些 Observable 创建订阅。

我们修复了我们的个人训练师应用程序,使其使用HTTPClient模块来加载和保存锻炼数据(请注意,锻炼数据持久化留给你来完成)。在这个过程中,你也了解了关于跨域资源访问的问题。你学习了 JSONP,这是一种绕过浏览器同源限制的解决方案,以及如何使用 Angular 发起 JSONP 请求。我们还提到了 CORS,当涉及到跨域通信时,它已成为一种标准。

我们现在已经涵盖了 Angular 的大部分构建块,除了最大的一个:Angular 指令。我们在各个地方都使用了指令,但还没有创建一个。下一章将专门介绍 Angular 指令。我们将创建一些小的指令,例如远程验证器、AJAX 按钮以及为锻炼构建器应用程序的验证提示指令。

第六章:深入了解 Angular 指令

指令无处不在。它们是 Angular 的基本构建块。每个应用扩展都导致我们创建了新的组件指令。这些组件指令进一步消耗了属性指令(如NgClassNgStyle)和结构指令(如NgIfNgFor)来扩展它们的行为。

虽然我们已经构建了许多组件指令和一个单独的属性指令,但仍有一些指令构建的概念值得探索。这对于属性和结构指令尤其如此,我们尚未详细涵盖。

本章将涵盖以下主题:

  • 构建指令:我们构建多个指令,并学习指令在哪里有用,它们与组件有何不同,以及指令如何相互通信以及/或与宿主组件通信。我们探讨了所有指令类型,包括组件指令属性指令结构指令

  • 异步验证:Angular 使得验证需要服务器交互且本质上是异步的规则变得容易。我们将在本章构建我们的第一个异步验证器。

  • 使用渲染器进行视图操作:渲染器允许以平台无关的方式操作视图。我们将利用渲染器为忙碌指示器指令,并了解其 API。

  • 宿主绑定:宿主绑定允许指令与其宿主元素进行通信。本章将介绍如何利用此类绑定来处理指令。

  • 指令注入:Angular DI 框架允许根据指令在 HTML 层次结构中的声明位置进行指令注入。我们将涵盖与这种注入相关的多个场景。

  • 处理视图子元素和内容子元素:组件具有将外部视图模板包含到其自身视图中的能力。如何处理注入的内容是我们将在此处涵盖的内容。

  • 理解 NgIf 平台指令:我们将深入了解NgIf平台指令,并尝试理解结构指令(如NgIf)的工作原理。

  • Angular 组件的视图封装:我们将学习 Angular 如何使用来自Web 组件的概念来支持视图和样式的封装。

让我们通过重申指令的基本分类来开始本章。

指令分类

Angular 指令将 HTML 视图与应用程序状态集成。指令帮助我们随着应用程序状态的变化来操作视图,并以最少或没有与实际 DOM 的交互来响应视图更新。

根据它们对视图的影响,这些指令进一步分为三种类型。

组件

组件指令组件是具有封装视图的指令。在 Angular 中,当我们构建 UI 小部件时,我们实际上是在构建组件。我们已经构建了很多,例如WorkoutRunnerComponentWorkoutAudioComponentVideoPlayerComponent以及更多!

在这里要认识到的一个重要观点是,视图绑定到组件实现,并且它只能与在支持组件上定义的属性和事件一起工作。

属性指令

属性指令,另一方面,扩展现有的组件或 HTML 元素。将它们视为对这些组件/元素的行为扩展。

由于指令是预定义元素的扩展行为,因此每个构建指令的练习都涉及操作应用这些指令的组件/元素的状态。第三章,更多 Angular 2 – 深入 SPA、路由和数据流中构建的MyAudioDirective也是如此。该指令包装了 HTML5 的audio元素(HTMLAudioElement),以便于使用。ngStylengClass等平台指令也以类似的方式工作。

结构指令

结构指令,例如属性指令,不定义自己的视图。相反,它们在其使用过程中作为其一部分工作的视图模板(HTML 片段)。通常,结构指令的目的是显示/隐藏或克隆提供给它的模板视图。NgForNgIfNgSwitch等平台指令是这个类别的典型例子。

我希望这个关于指令的快速复习足以让我们开始。我们将通过扩展锻炼构建验证来开始我们的追求,并构建一个异步验证指令。

我们将从第五章,支持服务器数据持久性中我们离开的地方开始。Git 分支checkpoint5.4可以作为本章的基础。代码也已在 GitHub 上提供(github.com/chandermani/angular6byexample)供每个人下载。检查点作为 GitHub 上的分支实现。如果您不使用 Git,可以从 GitHub 位置bit.ly/ng6be-checkpoint-5-4下载checkpoint5.4(ZIP 文件)。在首次设置快照时,请参阅trainer文件夹中的README.md文件。此外,请记住将services/workout-service.ts中的 API 密钥更新为您自己的 API 密钥。

构建远程验证指令

我们在第五章,支持服务器数据持久性中结束了,其中锻炼运行器能够管理 MongoDB 存储中的锻炼。由于每个锻炼都应该有一个独特的名称,我们需要强制实施唯一性约束。因此,在创建/编辑锻炼时,每次用户更改锻炼名称,我们都可以查询 MongoDB 以验证该名称是否已存在。

就像任何远程调用一样,这个检查也是异步的,因此需要一个远程验证器。我们将使用 Angular 的异步验证支持来构建这个远程验证器。

异步验证器与标准自定义验证器类似,除了验证检查的返回值是一个promise,而不是键值对象映射或 null。这个 promise 最终会解析为设置验证状态(如果有错误),或者 null(在验证成功的情况下)。

我们将创建一个执行锻炼名称检查的验证指令。对于这样的指令,有两种可能的实现方法:

  • 我们可以创建一个专门用于唯一名称验证的指令。

  • 我们可以创建一个通用的指令,可以执行任何远程验证。

验证指令

当我们在构建验证指令时,我们本可以构建一个标准的自定义验证器类。创建指令的优势在于,它允许我们将指令集成到模板驱动的表单方法中,其中指令可以嵌入到视图 HTML 中。或者,如果表单是使用模型(响应式方法)生成的,我们可以在创建Control对象时直接使用验证器类。

起初,检查数据源(mLab数据库)中重复名称的要求似乎过于具体,无法由通用验证器处理。但通过一些合理的假设和设计选择,我们仍然可以实施一个可以处理所有类型远程验证的验证器,包括锻炼名称验证。

计划创建一个外部化实际验证逻辑的验证器。该指令将验证函数作为输入。这意味着实际验证逻辑不是验证器的一部分,而是需要验证输入数据的组件的一部分。指令的职责仅仅是调用函数,并根据函数的返回值返回适当的错误键。

让我们将这个理论付诸实践,并构建我们的远程验证指令,命名为RemoteValidatorDirective

下一个部分的配套代码库是 Git 分支checkpoint6.1。您可以与我们同步工作,或者检查上述文件夹中可用的实现。或者如果您不使用 Git,可以从 GitHub 位置bit.ly/ng2be-checkpoint6-1下载checkpoint6.1(ZIP 文件)。在首次设置快照时,请参考trainer文件夹中的README.md文件。

使用异步验证器验证锻炼名称

与自定义验证器类似,异步验证器也继承自相同的Validator类;但这次,异步验证器返回的是一个Promise,而不是对象映射。

让我们看看验证器的定义。从 GitHub (bit.ly/ng6be-6-1-remote-validator-directive-ts) 文件夹中复制验证器的定义,并将其添加到 shared 模块文件夹中。验证器的定义如下:

import { Directive, Input } from '@angular/core';
import { NG_ASYNC_VALIDATORS, FormControl } from '@angular/forms';

@Directive({
  selector: '[abeRemoteValidator][ngModel]',
  providers: [{ provide: NG_ASYNC_VALIDATORS, useExisting: RemoteValidatorDirective, multi: true }]
})
export class RemoteValidatorDirective {

  @Input() abeRemoteValidator: string;
  @Input() validateFunction: (value: string) => Promise<boolean>;

  validate(control: FormControl): { [key: string]: any } {
    const value: string = control.value;
    return this.validateFunction(value).then((result: boolean) => {
      if (result) {
        return null;
      }
      else {
        const error: any = {};
        error[this.abeRemoteValidator] = true;
        return error;
      }
    });
  }
} 

请务必从共享模块中导出此指令,以便我们可以在锻炼构建模块中使用它。

由于我们将验证器注册为指令而不是使用 FormControl 实例(通常在构建使用 响应式方法 的表单时使用),我们需要额外的提供者配置设置(在先前的 @Directive 元数据中添加),使用此语法:

 providers:[{ provide: NG_ASYNC_VALIDATORS, useExisting: RemoteValidatorDirective,  multi: true }] 

这条语句将验证器注册到现有的异步验证器中。

在前面的代码中使用的奇怪指令选择器 selector: `[abeRemoteValidator][ngModel]` 将在下一节中介绍,我们将构建一个忙碌指示器指令。

在我们深入研究验证器实现之前,让我们将其添加到锻炼名称输入中。这将帮助我们关联验证器的行为与其使用。

使用验证器声明更新锻炼名称输入 (workout.component.html):

<input type="text" name="workoutName" ... 
 abeRemoteValidator="workoutname"[validateFunction]="validateWorkoutName"> 

前缀指令选择器

总是在你的指令前加上一个标识符(如你刚才看到的 abe),以区分它们与框架指令和其他第三方指令。

注意:如果 ngModelOptionsupdateOn 设置为 submit,则将其更改为 blur

指令实现接受两个输入:通过指令属性 abeRemoveValidator 传递的 验证键,用于设置 错误键,以及 验证函数 (validateFunction),用于验证控件值。这两个输入都带有 @Input 装饰器。

输入参数 @Input("validateFunction") validateFunction: (value: string) => Promise<boolean>; 绑定到一个函数,而不是一个标准组件属性。由于底层语言 TypeScript(以及 JavaScript)的特性,我们可以将函数视为属性。

当异步验证触发(在 input 的变化上)时,Angular 会调用该函数,传入底层的 control。作为第一步,我们拉取当前的输入值,然后使用这个输入调用 validateFunction 函数。validateFunction 返回一个承诺,该承诺最终应该解析为 truefalse

  • 如果承诺解析为 true,则验证成功,承诺回调函数返回 null

  • 如果它是 false,则验证失败,并返回一个错误键值映射。这里的 是我们在使用验证器时设置的字符串字面量(a2beRemoteValidator="workoutname")。

当输入中声明了多个验证器时,这个 非常有用,它允许我们识别出失败的验证。

接下来,向锻炼组件添加一个针对此失败的验证消息。在现有的 锻炼名称 验证 label 之后添加此标签声明:

<label *ngIf="name.control.hasError('workoutname')" class="alert alert-danger validation-message">A workout with this name already exists.</label> 

然后将这两个标签包裹在一个 div 中,就像我们对 锻炼标题 错误标签所做的那样。

hasError 函数检查 'workoutname' 验证键是否存在。

此实现中缺失的最后一块是我们在应用指令时分配的实际验证函数([validateFunction]="**validateWorkoutName**"),但我们从未实现它。

validateWorkoutName 函数添加到 workout.component.ts

validateWorkoutName = (name: string): Promise<boolean> => {
    if (this.workoutName === name) { return Promise.resolve(true); }
    return this.workoutService.getWorkout(name).toPromise()
      .then((workout: WorkoutPlan) => {
        return !workout;
      }, error => {
        return true;
      });
  }  

在我们探索前面的函数之前,我们需要对 WorkoutComponent 类做一些更多的修复。validateWorkoutName 函数依赖于 WorkoutService 来获取具有特定名称的锻炼。让我们在构造函数中注入该服务,并在导入部分添加必要的导入:

import { WorkoutService }  from "../../core/workout.service"; 
... 
constructor(... , private workoutService: WorkoutService) { 

然后声明变量 workoutNamequeryParamsSub

private workoutName: string;
queryParamsSub: Subscription

将此语句添加到 ngOnInit

this.queryParamsSub = this.route.params.subscribe(params => this.workoutName = params['id']); 

前面的语句通过监视(订阅)route.params 服务来设置当前锻炼名称。如果使用原始锻炼名称,则使用 workoutName 来跳过现有锻炼的名称验证。

之前创建的订阅需要清除以避免内存泄漏,因此将此行添加到 ngDestroy 函数中:

this.queryParamsSub.unsubscribe();

validateWorkoutName 函数定义为 实例函数(使用 箭头操作符)而不是定义为标准函数(在 原型 上声明函数)的原因是 'this' 作用域问题。

查看 RemoteValidatorDirective 内部的验证函数调用(使用 @Input("validateFunction") validateFunction; 声明):

return this.validationFunction(value).then((result: boolean) => { ... }); 

当调用(名为 validateFunction)函数时,this 引用绑定到 RemoteValidatorDirective 而不是 WorkoutComponent。由于 execute 在前面的设置中引用了 validateWorkoutName 函数,因此 validateWorkoutName 内部的任何对 this 的访问都是问题性的。

这导致 validateWorkoutName 函数内部的 if (this.workoutName === name) 语句失败,因为 RemoteValiatorDirective 没有名为 workoutName 的实例成员。通过将 validateWorkoutName 定义为一个实例函数,TypeScript 编译器在函数定义时会在 this 的值周围创建一个闭包。

使用新的声明,validateWorkoutName 函数内部的 this 无论函数如何调用,始终指向 WorkoutComponent

我们还可以查看 WorkoutComponent 的编译后的 JavaScript 来了解闭包是如何与 validateWorkoutName 一起工作的。我们感兴趣的生成代码的部分如下:

function WorkoutComponent(...) { 
 var _this = this; 
  ... 
  this.validateWorkoutName = function (name) { 
 if (_this.workoutName === name) 
      return Promise.resolve(true); 

如果我们查看验证函数的实现,我们会看到它涉及到查询mLab以获取特定的锻炼名称。当没有找到具有相同名称的锻炼时,validateWorkoutName函数返回true,当找到具有相同名称的锻炼时(实际上返回的是一个promise)返回false

WorkoutService上的getWorkout函数返回一个observable,但我们通过在可观察对象上调用toPromise函数将其转换为promise

现在可以测试验证指令了。创建一个新的锻炼并输入一个现有的锻炼名称,例如7minworkout。看看验证错误消息最终是如何显示出来的:

太棒了!看起来很棒,但仍然有些不足。用户没有得到我们正在验证锻炼名称的通知。我们可以改善这种体验。

构建忙碌指示器指令

当远程验证锻炼名称时,我们希望用户意识到后台的活动。在远程验证发生时,输入框周围的视觉线索应该起到这个作用。

仔细思考;有一个带有异步验证器(执行远程验证)的输入框,我们希望在验证过程中用视觉线索装饰输入框。这似乎是一个常见的模式来解决?确实如此,所以让我们创建另一个指令!

但在我们开始实现之前,我们必须理解我们并不孤单。忙碌指示器指令需要另一个指令NgModel的帮助。我们已经在第四章构建个人教练中使用了NgModel指令在input元素上。NgModel帮助我们跟踪输入元素的状态。以下示例取自第四章构建个人教练,并突出了NgModel如何帮助我们验证输入:

<input type="text" name="workoutName" #name="ngModel"  class="form-control" id="workout-name" ... [(ngModel)]="workout.name" required> 
... 
<label *ngIf="name.control.hasError('required') && (name.touched || submitted)" class="alert alert-danger">Name is required</label>  

即使在上一节中完成的唯一锻炼名称验证也采用了相同的技巧,即使用NgModel来检查验证状态。

让我们从定义指令的大纲开始。在src/app/shared文件夹中使用 CLI 生成器创建一个busy-indicator.directive.ts文件:

ng generate directive busy-indicator

此外,通过在共享模块文件shared.module.ts中的exports数组中添加指令来导出它。

接下来,更新指令的构造函数以进行NgModel注入,并从@angular/forms导入NgModel引用:

constructor(private model: NgModel) { }

这指示 Angular 向声明的元素注入NgModel实例。记住,NgModel指令已经在inputworkoutname)上存在:

<input... name="workoutName" #name="ngModel" [(ngModel)]="workout.name" ...>

这就足够将我们的新指令集成到锻炼视图中了,所以让我们快速完成它。

workout-builder打开workout.component.html,并将忙碌指示器指令添加到锻炼名称input

<input type="text" name="workoutName" ... abeBusyIndicator> 

创建一个新的锻炼或打开一个现有的锻炼,以查看BusyIndicatorDirective是否已加载,以及NgModel注入是否正常工作。这可以通过在BusyIndicatorDirective构造函数中设置断点来轻松验证。

当 Angular 在输入 HTML 中遇到 ngModel 时,它将相同的 NgModel 实例注入到 BusyIndicatorDirective 中。

你可能会想知道,如果我们将此指令应用于没有 ngModel 属性的输入元素,或者实际上应用于任何 HTML 元素/组件,例如以下这样:

<div abeBusyIndicator></div> 
<input type="text" abeBusyIndicator> 

注入会起作用吗?

当然不是!我们可以在创建锻炼视图时尝试它。打开 workout.component.html 并在锻炼名称 input 上方添加以下 input。刷新应用:

<input type="text" name="workoutName1" a2beBusyIndicator> 

Angular 抛出异常,如下:

 EXCEPTION: No provider for NgModel! (BusyIndicatorDirective -> NgModel)

如何避免这种情况?嗯,Angular 的依赖注入(DI)可以在这里帮助我们,因为它允许我们声明一个可选依赖。

在进一步操作之前,请移除之前添加的 input 控制器。

使用 @Optional 装饰器注入可选依赖项

Angular 有一个 @Optional 装饰器,当应用于构造函数参数时,指示 Angular 注入器如果找不到依赖项则注入 null

因此,忙碌指示器构造函数可以写成如下:

constructor(@Optional() private model: NgModel) { } 

问题解决了吗?实际上并没有;如前所述,我们需要 NgModel 指令才能使 BusyIndicatorDirective 工作。所以,虽然我们学到了一些新东西,但在当前场景中并不十分有用。

在进一步操作之前,请记住将 workoutname input 恢复到原始状态,并应用 abeBusyIndicator

只有在元素上已经存在 NgModel 指令时,才应该应用 BusyIndicatorDirective

selector 指令这次将拯救我们的日子。将 BusyIndicatorDirective 的选择器更新如下:

selector: `[abeBusyIndicator][ngModel]` 

这个选择器仅在元素上存在 a2beBusyIndicatorngModel 属性的组合时创建 BusyIndicatorDirective。问题解决了!

现在是时候添加实际实现了。

实现一 - 使用渲染器

为了使 BusyIndicatorDirective 能够工作,它需要知道异步验证在 input 上何时触发以及何时结束。这个信息只有通过 NgModel 指令才能获得。NgModel 有一个属性 control,它是 Control 类的一个实例。正是这个 Control 类跟踪输入的当前状态,包括以下内容:

  • 目前分配的验证器(同步和异步)

  • 当前值

  • 输入元素的状态,例如 pristinedirtytouched

  • 输入验证状态,可能是 validinvalidpending 中的任何一个,具体取决于是否正在执行异步验证

  • 跟踪值变化或验证状态变化的操作

Control 看起来是一个有用的类,而我们感兴趣的是它的 pending 状态!

让我们为 BusyIndicatorDirective 类添加第一个实现。用以下代码更新类:

private subscriptions: Array<any> = []; 
ngAfterViewInit(): void {
    this.subscriptions.push(
      this.model.control.statusChanges.subscribe((status: any) => {
        if (this.model.control.pending) {
          this.renderer.setElementStyle(this.element.nativeElement, 'border-width', '3px');
          this.renderer.setElementStyle(this.element.nativeElement, 'border-color', 'gray');
        }
        else {
          this.renderer.setElementStyle(this.element.nativeElement, 'border-width', null);
          this.renderer.setElementStyle(this.element.nativeElement, 'border-color', null);
        }
      }));
  }  

需要在构造函数中添加两个新的依赖项,因为我们将在 ngAfterViewInit 函数中使用它们。将 BusyIndicatorDirective 的构造函数更新如下:

constructor(private model: NgModel,  
 private element: ElementRef, private renderer: Renderer) { }

还需要在'@angular/core'中添加对ElementRefRenderer的导入。

ElementRef是对底层 HTML 元素(在这种情况下是input)的包装对象。在第三章更深入的 Angular 2 - 单页应用、路由和数据流中构建的MyAudioDirective指令使用了ElementRef来获取底层的Audio元素。

Renderer注入值得注意。调用setElementStyle是一个明显的迹象表明Renderer负责管理 DOM。但在我们深入探讨Renderer的角色之前,让我们先尝试理解前面的代码在做什么。

在前面的代码中,模型(NgModel实例)上的control属性定义了一个事件(一个Observable),statusChanges,我们可以订阅它以了解控制验证状态何时改变。可用的验证状态有validinvalidpending

订阅检查控制状态是否为pending,并相应地使用Renderer API 函数setElementStyle装饰底层元素。我们设置了输入的border-widthborder-color

前面的实现被添加到ngAfterViewInit指令生命周期钩子中,该钩子在视图初始化后调用。

让我们试试。打开创建锻炼页面或现有的7 分钟锻炼。当我们离开锻炼名称输入时,input样式会改变,并在远程验证锻炼名称完成后恢复。不错!

图片

在继续前进之前,还要在BusyIndicatorDirective中添加取消订阅的代码以避免内存泄漏。将此函数(生命周期钩子)添加到BusyIndicatorDirective

ngOnDestroy() { 
    this.subscriptions.forEach((s) => s.unsubscribe()); 
} 

总是取消订阅观察者

总是要记得在代码中取消任何Observable/EventEmitter订阅,以避免内存泄漏。

实现看起来不错。Renderer正在做它的工作。但还有一些未解决的问题。

为什么不直接获取底层 DOM 对象并使用标准的 DOM API 来操作输入样式呢?为什么我们需要renderer

Angular 渲染器,翻译层

Angular 2 的主要设计目标之一是使其能够在各种环境中、框架和设备上运行。Angular 通过将核心框架实现分为应用层渲染层来实现这一点。应用层拥有我们与之交互的 API,而渲染层提供了一个抽象层,应用层可以使用它而无需担心实际视图是如何和在哪里被渲染的。

通过分离渲染层,Angular 理论上可以在各种设置中运行。这包括但不限于:

  • 浏览器

  • 浏览器主线程和 Web Worker 线程,出于明显的性能原因

  • 服务器端渲染

  • 原生应用框架;目前正在努力将 Angular 与NativeScriptReactNative集成

  • 测试,允许我们在浏览器外测试应用 UI

Angular 在我们浏览器内部使用的Renderer实现是DOMRenderer。它负责将我们的 API 调用转换为浏览器 DOM 更新。实际上,我们可以在BusyIndicatorDirective的构造函数中添加一个断点,查看renderer的值来验证渲染器类型。

正是因为这个原因,我们避免在BusyIndicatorDirective内部直接操作 DOM 元素。你永远不知道代码会运行到哪里。我们本可以轻松做到这一点:

this.element.nativeElement.style.borderWidth="3px"; 

相反,我们使用了Renderer以平台无关的方式完成同样的操作。

看一下Renderer API 函数,setElementStyle

this.renderer.setElementStyle( 
             this.element.nativeElement, "border-width", "3px"); 

设置样式需要元素、要更新的样式属性以及要设置的值。element引用的是注入到BusyIndicatorDirective中的input元素。

重置样式

通过调用setElementStyle设置的样式可以通过在第三个参数中传递null值来重置。查看前面代码中的else条件。

Renderer API 有其他一些方法可以用来设置属性、设置属性、监听事件,甚至创建新视图。每次你构建一个新的指令时,记得评估Renderer API 以进行 DOM 操作。

关于Renderer及其应用的更详细解释可以作为 Angular 设计文档的一部分在此处找到:bit.ly/ng2-render

我们还没有完成!借助 Angular 的强大功能,我们可以改进实现。Angular 允许我们在指令实现中进行宿主绑定,帮助我们避免大量的样板代码。

指令中的宿主绑定

在 Angular 领域,指令附加到的组件/元素被称为宿主元素:一个承载我们的指令/组件的容器。对于BusyIndicatorDirectiveinput元素是宿主

虽然我们可以使用Renderer来操作宿主(我们也确实这样做了),但 Angular 的数据绑定基础设施可以进一步减少代码。它提供了一种声明式的方式来管理指令-宿主交互。使用宿主绑定概念,我们可以操作元素的性质属性事件

让我们了解每个宿主绑定能力,最后我们将修复我们的BusyIndicatorDirective实现。

使用@HostBinding 进行属性绑定

使用宿主属性绑定指令属性绑定到宿主元素属性。任何对指令属性的更改都会在变更检测阶段与链接的宿主属性同步。

我们只需要在想要同步的指令属性上使用@HostBinding装饰器。例如,考虑以下绑定:

@HostBinding("readOnly") get busy() {return this.isbusy}; 

当应用于input时,它将inputreadOnly属性设置为true,当isbusy指令属性为true时。

注意,readonly也是input上的一个属性。这里我们指的是输入属性 readOnly

属性绑定

属性绑定将指令属性绑定到宿主组件属性。例如,考虑以下绑定方式的指令:

@HostBinding("attr.disabled") get canEdit(): string  
  { return !this.isAdmin ? "disabled" : null }; 

如果应用于输入,当isAdmin标志为false时,它将在input上添加disabled属性,否则清除它。在这里,我们也遵循了 HTML 模板中使用的相同的属性绑定符号。属性名以字符串字面量attr为前缀。

我们也可以用样式绑定做类似的事情。考虑以下行:

@HostBinding('class.valid')  
   get valid { return this.control.valid; } 

这行代码设置了一个类绑定,下一行创建了一个样式绑定:

@HostBinding("style.borderWidth")  
   get focus(): string { return this.focus?"3px": "1px"}; 

事件绑定

最后,事件绑定用于订阅宿主组件/元素引发的事件。考虑以下示例:

@Directive({ selector: 'button, div, span, input' }) 
class ClickTracker { 
  @HostListener('click', ['$event.target']) 
  onClick(element: any) { 
    console.log("button", element, "was clicked"); 
  } 
} 

这将在宿主事件click上设置一个监听器。Angular 将为视图中的每个buttondivspaninput实例化前面的指令,并使用onClick函数设置宿主绑定。$event变量包含引发事件的的事件数据,而target指的是被点击的元素/组件。

事件绑定也适用于组件。考虑以下示例:

@Directive({ selector: 'workout-runner' }) 
class WorkoutTracker { 
  @HostListener('workoutStarted', ['$event']) 
  onWorkoutStarted(workout: any) { 
    console.log("Workout has started!"); 
  } 
} 

使用这个指令,我们跟踪在WorkoutRunner组件上定义的workoutStarted事件。当锻炼开始时,会调用onWorkoutStarted函数,并带有开始锻炼的详细信息。

现在我们已经了解了这些绑定的工作原理,我们可以改进我们的BusyIndicatorDirective实现。

实现二 - 带有宿主绑定的 BusyIndicatorDirective

你可能已经猜到了!我们将使用宿主属性绑定而不是Renderer来设置样式。想要试试吗?请继续!清除现有的实现,并尝试为borderWidthborderColor样式属性设置宿主绑定,而不查看以下实现。

这就是宿主绑定实现后指令的外观:

import {Directive, HostBinding} from '@angular/core'; 
import {NgModel} from '@angular/forms'; 

@Directive({ selector: `[abeBusyIndicator][ngModel]`}) 
export class BusyIndicatorDirective {
  private get validating(): boolean {
    return this.model.control != null && this.model.control.pending;
  }
  @HostBinding('style.borderWidth') get controlBorderWidth():
        string { return this.validating ? '3px' : null; }
  @HostBinding('style.borderColor') get controlBorderColor():
        string { return this.validating ? 'gray' : null; }

  constructor(private model: NgModel) { }
}

我们将pending状态检查移动到了一个名为validating的指令属性中,然后使用了controlBorderWidthcontrolBorderColor属性进行样式绑定。这绝对比我们之前的方法更简洁!去测试一下吧。

如果我们告诉你这可以不使用自定义指令就能完成,请不要感到惊讶!我们就是这样做的,只需在锻炼名称input上使用样式绑定即可:

<input type="text" name="workoutName" ... 
[style.borderColor]="name.control.pending ? 'gray' : null" [style.borderWidth]="name.control.pending ? '3px' : null">

我们得到了相同的效果!

不,我们的努力并没有白费。我们确实学到了渲染器宿主绑定的概念。在构建提供复杂行为扩展而不是仅设置元素样式的指令时,这些概念将非常有用。

如果你在运行代码时遇到问题,请查看 Git 分支checkpoint6.1以获取我们迄今为止所做的工作的版本。或者如果你不使用 Git,可以从bit.ly/ng6be-checkpoint-6-1下载checkpoint6.1的快照(ZIP 文件)。在首次设置快照时,请参考trainer文件夹中的README.md文件。

我们接下来要讨论的主题是,指令注入

指令注入

回到几页前的BusyIndicatorDirective实现,它使用了渲染器,特别是构造函数:

constructor(private model: NgModel ...) { } 

Angular 自动定位为指令元素创建的NgModel指令,并将其注入到BusyIndicatorDirective中。这是因为这两个指令都是在同一个宿主元素上声明的。

好消息是我们可以影响这种行为。在父 HTML 树或子树上创建的指令也可以注入。接下来的几节将讨论如何在组件树中注入指令,这是一个非常实用的功能,允许具有共同血统(在视图中)的指令进行跨指令通信。

我们将使用 StackBlitz (stackblitz.com/edit/angular-pzljm3) 来演示这些概念。StackBlitz 是一个在线 IDE,可以运行 Angular 应用程序!

首先,查看文件app.component.ts。它有三个指令:RelationAcquaintanceConsumer,这个视图层次结构定义如下:

<div relation="grand-parent" acquaintance="jack"> 
    <div relation="parent"> 
 <div relation="me" consumer> 
        <div relation="child-1"> 
          <div relation="grandchild-1"></div> 
        </div> 
        <div relation="child-2"></div> 
      </div> 
    </div> 
</div> 

在接下来的几节中,我们将描述我们可以将不同的relationAcquaintance指令注入到consumer指令中的各种方法。检查浏览器控制台,查看在ngAfterViewInit生命周期钩子中记录的注入依赖项。

注入同一元素上定义的指令

默认情况下,构造函数注入支持注入同一元素上定义的指令。构造函数只需要声明我们想要注入的指令类型变量:

variable:DirectiveType 

BusyIndicatorDirective中我们进行的NgModel注入属于这一类别。如果指令在当前元素上找不到,Angular DI 将抛出错误,除非我们将依赖项标记为@Optional

可选依赖项

@Optional装饰器不仅限于指令注入。它存在于标记任何类型的依赖项为可选。

从 plunk 示例中,第一次注入(在Consumer指令实现中)将带有me属性的Relation指令(relation="me")注入到消费者指令中:

constructor(private me:Relation ... 

从父元素注入指令依赖项

在构造函数参数前加上@Host装饰器指示 Angular 在当前元素其父元素或其父元素上搜索依赖项,直到达到组件边界(一个在其视图层次结构中某处有指令的组件)。检查第二个consumer注入:

constructor(..., @Host() private myAcquaintance:Acquaintance  

此语句注入了在层次结构上方两级声明的Acquaintance指令实例。

就像之前描述的@Option装饰器一样,@Host()的使用不仅限于指令。Angular 服务注入也遵循相同的模式。如果一个服务被标记为@Host,搜索将停止在宿主组件上。它不会继续向上到组件树。

@Skipself装饰器可以用来跳过当前元素以进行指令搜索。

从 StackBlitz 示例中,这次注入将具有relation属性值parentrelation="parent")的Relation指令注入到consumer中:

@SkipSelf() private myParent:Relation 

注入子指令(或指令集)

如果需要将嵌套 HTML 中定义的指令(或指令集)注入到父指令/组件中,有四个装饰器可以帮助我们:

  • @ViewChild/@ViewChildren

  • @ContentChild/@ContentChildren

如这些命名约定所暗示的,有装饰器可以注入单个子指令或多个子指令:

要理解@ViewChild/@ViewChildren@ContentChild/@ContentChildren的重要性,我们需要看看视图和内容子代是什么,这是一个我们将很快讨论的话题。但就现在而言,理解视图子代是组件自身视图的一部分,而内容子代是注入到组件视图的外部 HTML 就足够了。

看看在 StackBlitz 示例中,ContentChildren装饰器是如何用来将子Relation指令注入到Consumer中的:

@ContentChildren(Relation) private children:QueryList<Relation>; 

令人惊讶的是,变量children的数据类型不是一个数组,而是一个自定义类-QueryListQueryList类不是一个典型的数组,而是一个 Angular 在添加或删除依赖项时保持更新的集合。这可能会在使用结构指令如NgIfNgFor创建/销毁 DOM 树时发生。我们将在接下来的章节中更多地讨论QueryList

你可能已经注意到前面的注入不是像前两个示例那样的构造函数注入。这是有原因的。注入的指令将在底层组件/元素的内容初始化之前不可用。正因为如此,我们在ngAfterViewInit生命周期钩子内部有console.log语句。我们应在生命周期钩子执行后仅访问内容子代。

前面的示例代码将所有三个子relation对象注入到consumer指令中。

注入后代指令(或指令集)

标准的@ContentChildren装饰器(或者实际上@ViewChildren也是如此)仅注入指令/组件的直接子代,而不是其后代。要包括所有后代,我们需要向Query提供一个参数:

@ContentChildren(Relation, {descendants: true}) private 
allDescendents:QueryList<Relation>; 

传递descendants: true参数将指示 Angular 搜索所有后代。

如果你查看控制台日志,前面的语句注入了所有四个后代。

Angular DI(依赖注入)虽然看起来使用简单,但包含了大量的功能。它管理我们的服务、组件和指令,并在正确的时间、正确的位置为我们提供所需的内容。组件和其他指令中的指令注入提供了一种机制,使指令能够相互通信。这种注入允许一个指令访问另一个指令的公共 API(公共函数/属性)。

现在是时候探索一些新内容了。我们将构建一个 Ajax 按钮组件,允许我们将外部视图注入到组件中,这个过程也被称为内容****转译

构建 Ajax 按钮组件

当我们保存/更新一个练习或锻炼时,总是存在重复提交(或重复POST请求)的可能性。当前的实现没有提供有关保存/更新操作何时开始和何时完成的任何反馈。由于缺乏视觉提示,应用程序的用户可能会有意或无意地多次点击保存按钮。

让我们尝试通过创建一个专门的按钮——一个Ajax 按钮来解决这个问题,当点击时提供一些视觉提示,并阻止重复的 Ajax 提交。

按钮组件将按照以下方式工作。它接受一个函数作为输入。这个输入函数(输入参数)应该返回一个与远程请求相关的 promise。点击按钮时,按钮内部会调用远程函数(使用输入函数),跟踪底层的 promise,等待其完成,并在这一过程中显示一些忙碌的提示。此外,按钮在远程调用完成之前保持禁用状态,以避免重复提交。

下一个部分的配套代码库是 Git 分支checkpoint6.2。您可以与我们一同工作,或者检查分支中可用的实现。如果您不使用 Git,可以从 GitHub 位置bit.ly/ng6be-checkpoint-6-2下载checkpoint6.2(ZIP 文件)。在首次设置快照时,请参考trainer文件夹中的README.md文件。

让我们创建组件轮廓以使事情更清晰。使用以下命令在应用程序的共享模块(src/app/shared)下创建一个ajax-button组件,然后从SharedModule导出组件:

ng generate component ajax-button -is

更新组件定义,并从@angular/core导入它们:

export class AjaxButtonComponent implements OnInit { 
  busy: boolean = null; 
  @Input() execute: any; 
  @Input() parameter: any; 
} 

并将以下 HTML 模板添加到ajax-button.component.html

<button [attr.disabled]="busy" class="btn btn-primary"> 
    <span [hidden]="!busy">
        <div class="ion-md-cloud-upload spin"></div>
    </span>
    <span>Save</span> 
</button> 

组件(AjaxButtonComponent)接受两个属性绑定,executeparameterexecute属性指向在 Ajax 按钮点击时调用的函数。parameter是可以传递给此函数的数据。

看看视图中 busy 标志的使用。当 busy 标志被设置时,我们禁用按钮并显示旋转器。让我们添加使一切工作的实现。将以下代码添加到 AjaxButtonComponent 类中:

@HostListener('click', ['$event'])
onClick(event: any) {
    const result: any = this.execute(this.parameter);
    if (result instanceof Promise) {
      this.busy = true;
      result.then(
        () => { this.busy = null; },
        (error: any) => { this.busy = null; });
    }
}

我们将 主机事件绑定 设置到 AjaxButtonComponent 的点击事件上。每当点击 AjaxButtonComponent 组件时,都会调用 onClick 函数。

需要将 HostListener 导入添加到 '@angular/core' 模块中。

onClick 实现调用输入函数,以单个参数作为 parameter。调用结果存储在 result 变量中。

if 条件检查 result 是否是 Promise 对象。如果是,则将 busy 指示器设置为 true。然后按钮等待使用 then 函数解决的承诺。无论承诺是以 成功 还是 错误 解决,busy 标志都会设置为 null

busy 标志设置为 null 而不是 false 的原因是这个属性绑定 [attr.disabled]="busy"。除非 busynull,否则 disabled 属性不会被移除。记住,在 HTML 中,disabled="false" 不会启用按钮。在按钮再次可点击之前,需要移除该属性。

如果我们对这一行感到困惑:

    const result: any = this.execute(this.parameter); 

然后你需要看看组件是如何使用的。打开 workout.component.html 并将 Save 按钮的 HTML 替换为以下内容:

<abe-ajax-button [execute]="save" [parameter]="f"></abe-ajax-button> 

Workout.save 函数绑定到 execute,而 parameter 接收 FormControl 对象 f

我们需要将 Workout 类中的 save 函数更改为返回一个承诺,以便 AjaxButtonComponent 可以工作。将 save 函数实现更改为以下内容:

save = (formWorkout: any): Promise<Object | WorkoutPlan> => {
    this.submitted = true;
    if (!formWorkout.valid) { return; }
    const savePromise = this.workoutBuilderService.save().toPromise();

    savePromise.then(
      result => this.router.navigate(['/builder/workouts']),
      err => console.error(err)
    );
    return savePromise;
  } 

save 函数现在返回一个 promise,我们通过在 workoutBuilderService.save() 调用返回的 observable 上调用 toPromise 函数来构建它。

注意我们如何将 save 函数定义为 实例函数(使用箭头操作符)来创建对 this 的闭包。这是我们之前在构建 远程验证指令 时所做的一件事。

是时候测试我们的实现了!刷新应用程序并打开创建/编辑锻炼视图。点击保存按钮,看看 Ajax 按钮如何工作:

图片

在保存后返回锻炼列表页面时,前面的动画可能不会持续很久。我们可以暂时禁用导航来查看新的更改。

我们开始本节的目标是突出外部元素/组件如何被包含到组件中。现在就让我们来做吧!

将外部组件/元素包含到组件中

从一开始,我们就需要理解 transclusion 是什么意思。而理解这个概念最好的方法就是看看一个例子。

我们迄今为止构建的任何组件都没有从外部借用内容。不确定这是什么意思?

考虑 workout.component.html 中的先前 AjaxButtonComponent 示例:

<ajax-button [execute]="save" [parameter]="f"></ajax-button> 

如果我们将ajax-button的使用方式改为以下内容?

<ajax-button [execute]="save" [parameter]="f">Save Me!</ajax-button> 

Save Me!文本会显示在按钮上吗?不会尝试它!

AjaxButtonComponent组件已经有了模板,并且它拒绝了我们之前提供的内容。如果我们能以某种方式让内容(前例中的Save Me!)加载到AjaxButtonComponent中会怎样?将外部视图片段注入到组件视图中的这一行为就是我们所说的转译,框架提供了必要的结构来启用转译。

是时候介绍两个新概念了,内容子元素视图子元素

内容子元素和视图子元素

简单来说,组件内部定义的 HTML 结构(使用templatetemplateUrl)是组件的视图子元素。然而,作为组件使用的一部分提供的 HTML 视图(例如<ajax-button>**Save Me!**</ajax-button>),定义了组件的内容子元素

默认情况下,Angular 不允许像之前看到的那样将内容子元素嵌入。Save Me!文本从未被发出。我们需要明确告诉 Angular 在组件视图模板中何处发出内容子元素。为了理解这个概念,让我们修复AjaxButtonComponent视图。打开ajax-button.component.ts并更新视图模板定义如下:

<button [attr.disabled]="busy" class="btn btn-primary"> 
    <span [hidden]="!busy"> 
        <ng-content select="[data-animator]"></ng-content> 
   </span> 
 <ng-content select="[data-content]"></ng-content> 
</button>

前一个视图中的两个ng-content元素定义了内容注入位置,内容子元素可以注入/转译。selector属性定义了在注入到主宿主时应该使用的CSS 选择器

一旦我们在workout.component.html中修复了AjaxButtonComponent的使用方式,将其改为以下内容,它就会开始变得更有意义:

<ajax-button [execute]="save" [parameter]="f">
    <div class="ion-md-cloud-upload spin" data-animator></div>
 <span data-content>Save</span>
</ajax-button> 

带有data-animator属性的span被注入到带有select=[data-animator]属性的ng-content中,另一个带有data-content属性的span被注入到第二个ng-content声明中。

再次刷新应用程序并尝试保存一个锻炼。虽然最终结果相同,但生成的视图是多个视图片段的组合:一部分是组件定义(视图子元素),另一部分是组件使用(内容子元素)。

下面的图示突出了渲染的AjaxButtonComponent之间的这一差异:

图片

ng-content可以声明而不带selector属性。在这种情况下,组件标签内部定义的全部内容将被注入。

内容注入到现有组件视图中是一个非常强大的概念。它允许组件开发者提供扩展点,组件消费者可以轻松消费并自定义组件的行为,而且是在受控的方式下。

我们为AjaxButtonComponent定义的内容注入允许消费者更改忙碌指示器动画和按钮内容,同时保持按钮的行为不变。

Angular 的优势不仅于此。它具有将内容子项视图子项注入到组件代码/实现的能力。这允许组件与其内容/视图子项交互并控制它们的行为。

使用@ViewChild 和@ViewChildren 注入视图子项

在第三章,“更深入的了解 Angular 2 - 单页应用、路由和数据流”,我们使用了类似的方法,视图子注入。为了回忆我们做了什么,让我们看看WorkoutAudioComponent实现的相关部分。

视图定义看起来如下:

<audio #ticks="MyAudio" loop src="img/tick10s.mp3"></audio>
<audio #nextUp="MyAudio" src="img/nextup.mp3"></audio>
<audio #nextUpExercise="MyAudio" [src]="'/assets/audio/' + nextupSound"></audio>
// Some other audio elements 

注入看起来如下:

@ViewChild('ticks') private _ticks: MyAudioDirective; 
@ViewChild('nextUp') private _nextUp: MyAudioDirective; 
@ViewChild('nextUpExercise') private _nextUpExercise: MyAudioDirective; 

audio标签关联的指令(MyAudioDirective)是通过@ViewChild装饰器注入到WorkoutAudio实现的。传递给@ViewChild的参数是用于在视图定义中定位元素的模板变量名称(例如tick)。然后WorkoutAudio组件使用这些音频指令来控制7 分钟锻炼的音频播放。

虽然前面的实现注入了MyAudioDirective,甚至子组件也可以被注入。例如,如果我们构建一个MyAudioComponent,它可能如下所示:

@Component({ 
  selector: 'my-audio', 
  template: '<audio ...></audio>', 
}) 
export class MyAudioComponent { 
  ... 
} 

我们可以使用它而不是audio标签:

<my-audio #ticks loop  
  src="img/tick10s.mp3"></my-audio> 

注入仍然会起作用。

如果在组件视图中定义了多个同类型的指令/组件会发生什么?使用@ViewChildren装饰器。它允许你查询一种类型的注入。使用@ViewChildren的语法如下:

@ViewChildren(directiveType) children: QueryList<directiveType>; 

这将注入类型为directiveType的所有视图子项。对于前面提到的WorkoutAudio组件示例,我们可以使用以下语句来获取所有MyAudioDirective

@ViewChildren(MyAudioDirectives) private all: QueryList<MyAudioDirectives>; 

ViewChildren装饰器也可以接受一个由逗号分隔的选择器列表(模板变量名称)而不是类型。例如,为了在WorkoutAudio组件中选择多个MyAudioDirective实例,我们可以使用以下方法:

 @ViewChildren('ticks, nextUp, nextUpExercise, halfway, aboutToComplete') private all: QueryList<MyAudioDirective>; 

QueryList类是 Angular 提供的一个特殊类。我们在本章前面的注入子指令部分介绍了QueryList。让我们进一步探索QueryList

使用 QueryList 跟踪注入的依赖项

对于需要注入多个组件/指令(使用@ViewChildren@ContentChildren)的组件,注入的是QueryList对象。

QueryList类是一个只读的组件/指令集合。Angular 根据用户界面的当前状态保持此集合的同步。

例如,考虑WorkoutAudio指令视图。它有五个MyAudioDirective实例。因此,对于以下集合,我们将有五个元素:

@ViewChildren(MyAudioDirective) private all: QueryList<MyAudioDirective>; 

虽然前面的例子没有突出同步部分,但 Angular 可以跟踪组件/指令被添加或从视图中移除。这发生在我们使用内容生成指令,如ngFor时。

以这个假设的模板为例:

<div *ngFor="let audioData of allAudios"> 
  <audio [src]="audioData.url"></audio> 
</div> 

这里注入的MyAudioDirective指令的数量等于allAudios数组的大小。在程序执行期间,如果向allAudios数组添加或从中删除元素,框架也会保持指令集合的同步。

虽然QueryList类不是一个数组,但它可以通过for (var item in queryListObject)语法进行迭代(因为它实现了ES6 可迭代接口)。它还有一些其他有用的属性,如lengthfirstlast,这些属性可能会很有用。有关更多详细信息,请查看框架文档(bit.ly/ng2-querylist-class)。

从前面的讨论中,我们可以得出结论,QueryList为组件开发者节省了大量手动跟踪所需的样板代码。

视图子元素访问时机

当组件/指令初始化时,视图子元素注入不可用。Angular 确保视图子元素注入在ngAfterViewInit生命周期事件之前对组件可用。确保您只在(或之后)ngAfterViewInit事件触发时访问注入的组件/指令。

现在我们来看内容子元素注入,它与之前几乎相同,只是有一些细微的差别。

使用@ContentChild@ContentChildren注入内容子元素

Angular 允许我们使用一组并行的属性注入内容子元素@ContentChild用于注入特定的内容子元素,@ContentChildren用于注入特定类型的内容子元素。

如果我们回顾AjaxButtonComponent的使用,其内容子元素 span 可以通过以下方式注入到AjaxButtonComponent实现中:

@ContentChild('spinner') spinner:ElementRef; 
@ContentChild('text') text:ElementRef; 

workout.component.html中添加模板变量到相应的 span 标签:

<div class="ion-md-cloud-upload spin" data-animator #spinner></div>
<span data-content #text>Save</span>

虽然前面的注入是ElementRef,但它也可以是一个组件。如果我们为旋转器定义了一个组件,例如:

<ajax-button> 
    <busy-spinner></busy-spinner> 
    ... 
</ajax-button> 

我们也可以使用以下方式注入它:

@ContentChild(BusySpinner) spinner: BusySpinner; 

对于指令也是如此。在AjaxButtonComponent上声明的任何指令都可以注入到AjaxButtonComponent实现中。对于前面的情况,由于转义元素是标准 HTML 元素,我们注入了ElementRef,这是 Angular 为任何 HTML 元素创建的包装器。

就像视图子元素一样,Angular 确保内容子元素引用绑定到在ngAfterContentInit生命周期事件之前注入的变量。

在讨论注入依赖项时,让我们谈谈关于将服务注入到组件中的一些变体。

使用 viewProvider 进行依赖注入

我们已经熟悉 Angular 中 DI 注册的机制,其中通过将依赖项添加到任何模块声明中,在全局级别注册依赖项。

或者我们可以在组件级别使用 @Component 装饰器上的 providers 属性来完成:

providers:[WorkoutHistoryTracker, LocalStorage] 

为了避免混淆,我们现在正在讨论注入除了指令/组件对象之外的依赖项。指令/组件在模块的 declarations 数组中注册,在可以使用装饰器提示(如 @Query@ViewChild@ViewChildren 以及其他几个)注入之前。

在组件级别注册的依赖项对其 视图子组件内容子组件 及其后代都是可用的。

在我们继续之前,我们希望每个人都对 视图内容子组件 之间的区别有清晰的认识。如果有疑问,请再次参考 内容子组件和视图子组件 部分。

让我们从第四章,构建个人教练 中举一个例子。WorkoutBuilderService 服务在 WorkoutBuilderModule(锻炼构建模块)中在应用级别进行了注册:

providers: [ExerciseBuilderService, ...  
 WorkoutBuilderService]);

这允许我们在整个应用中注入 WorkoutBuilderService 以构建锻炼并在锻炼运行时使用。相反,我们可以在 WorkoutBuilderComponent 级别注册该服务,因为它是一切锻炼/锻炼创建组件的父组件,如下所示:

@Component({ 
    template: `...` 
 providers:[ WorkoutBuilderService ] 
}) 
export class WorkoutBuilderComponent { 

此更改将不允许在 WorkoutRunner 或任何与锻炼执行相关的组件中注入 WorkoutBuilderService

如果 WorkoutBuilderService 服务同时在应用级别和组件级别注册(如前例所示),注入将如何发生?根据我们的经验,我们知道 Angular 将将 WorkoutBuilderService 服务的不同实例注入到 WorkoutBuilderComponent(及其后代),而应用的其他部分(锻炼运行器)将获得全局依赖项。记住 分层注入器

Angular 并不止步于此。它还通过 viewProviders 属性提供进一步的范围依赖。viewProviders 属性在 @Component 装饰器上可用,允许注册只能在视图子组件中注入的依赖项。

让我们再次考虑 AjaxButtonComponent 示例,以及一个简单的指令实现 MyDirective,以进一步阐述我们的讨论:

@Directive({ 
  selector: '[myDirective]', 
}) 
export class MyDirective { 
  constructor(service:MyService) { } 
  ... 
} 

MyDirective 类依赖于一个服务,MyService

要将此指令应用于 AjaxButtonComponent 模板中的 *button 元素*,我们还需要注册 MyService 依赖项(假设 MyService 没有在全局范围内注册):

@Component({ 
  selector: 'ajax-button', 
  template:` <button [attr.disabled]="busy" ... 
 myDirective> 
                ... 
             <button>` 
 providers:[MyService], 
... 

由于 MyServiceAjaxButtonComponent 注册,因此也可以将其添加到其内容子组件中。因此,在 spinner HTML 上的 myDirective 应用也将生效(workout.component.html 中的代码):

<div class="ion-md-cloud-upload spin" data-animator myDirective></div>

但将 providers 属性更改为 viewProviders

viewProviders:[MyService]

将导致AjaxButtonComponent的内容子组件(前述代码中的div)的MyService注入失败,并在控制台出现 DI 错误。

使用viewProviders注册的依赖项对其内容子组件是不可见的。

这种对视图和内容子组件的依赖作用域可能一开始看起来并不实用,但它确实有其好处。想象一下,我们正在构建一个可重用的组件,我们希望将其打包并交付给开发者使用。如果该组件有一个预先打包的服务依赖项,我们需要格外小心。如果这样的组件允许内容注入(内容子组件),当在组件上使用基于提供者的注册时,依赖的服务会被广泛暴露。任何内容子组件都可以获取服务依赖项并使用它,从而导致不希望的结果。通过使用viewProvider注册依赖项,只有组件实现及其子视图可以访问依赖项,提供了必要的封装层。

再次,我们被 DI 框架提供的灵活性和定制化水平所折服。虽然对于初学者来说可能有些令人畏惧,但一旦我们开始用 Angular 构建越来越多的组件/指令,我们总会发现这些概念使我们的实现变得更加简单。

让我们把注意力转移到指令的第三类:结构指令

理解结构指令

虽然我们经常会使用结构指令,如NgIfNgFor,但很少需要创建结构指令。仔细思考一下。如果我们需要一个新视图,我们创建一个组件。如果我们需要扩展现有的元素/组件,我们使用一个指令。而结构指令最常见的使用是克隆视图的一部分(也称为模板视图),然后根据某些条件:

  • 要么注入/销毁这些模板(NgIfNgSwitch

  • 或者复制这些模板(NgFor

使用结构指令实现的任何行为都会无意中落入这两个类别之一。

基于这个事实,我们不如看看NgIf实现的源代码,而不是构建我们自己的结构指令。

以下是从NgIf指令中摘录的,对我们感兴趣的部分。我们故意忽略了ngIfElse部分:

@Directive({selector: '[ngIf]'})
export class NgIf {
 constructor(private _viewContainer: ViewContainerRef, templateRef: TemplateRef<NgIfContext>) {
    this._thenTemplateRef = templateRef;
 }

 @Input()
  set ngIf(condition: any) {
    this._context.$implicit = this._context.ngIf = condition;
    this._updateView();
 }
 private _updateView() {
    if (this._context.$implicit) {
      if (!this._thenViewRef) {
        this._viewContainer.clear();
        this._elseViewRef = null;
        if (this._thenTemplateRef) {
          this._thenViewRef =
              this._viewContainer.createEmbeddedView(this._thenTemplateRef, this._context);
        }
      }
    }
    ...
}

没有魔法,只是一个简单的结构指令,它检查一个布尔条件(this._context.$implicit)来创建/销毁视图!

上面的第一个 if 条件检查,如果条件 this._context.$implicittrue。下一个条件确保视图尚未渲染,通过检查变量 _thenViewRef。我们只想在 this._context.$implicitfalse 转换为 true 时切换视图。如果两个 if 条件都为真,则清除现有视图(this._viewContainer.clear()),并清除对 else 视图的引用。最内层的 if 条件确保 if 的模板引用可用。最后,代码调用 _viewContainer.createEmbeddedView 来渲染(或重新渲染)视图。

理解这个指令的工作方式并不困难。需要详细说明的是两个新的注入,ViewContainerRef (_viewContainer)TemplateRef (_templateRef)

TemplateRef

TemplateRef 类(_templateRef)存储结构化指令所引用的模板的引用。记得第二章中关于结构化指令的讨论,构建我们的第一个应用 - 7 分钟锻炼?所有结构化指令都接受一个它们工作的模板 HTML。当我们使用像 NgIf 这样的指令时:

<h3 *ngIf="currentExercise.exercise.name=='rest'"> 
  ... 
</h3> 

Angular 内部将这个声明转换为以下形式:

<ng-template [ngIf]="currentExercise.exercise.name=='rest'"> 
  <h3> ... </h3> 
</ng-template> 

这是结构化指令工作的模板,_templateRef 指向这个模板。

另一个注入是 ViewContainerRef

ViewContainerRef

ViewContainerRef 类指向模板渲染的容器。这个类提供了一些方便的方法来管理视图。NgIf 实现使用的两个函数,createEmbeddedViewclear,用于添加和移除模板 HTML。

createEmbeddedView 函数接受模板引用(再次注入到指令中)并渲染视图。

clear 函数销毁已注入的元素/组件,并清除视图容器。由于模板(TemplateRef)内部引用的每个组件及其子组件都被销毁,所有相关的绑定也停止存在。

结构化指令有一个非常特定的应用领域。尽管如此,我们可以使用 TemplateRefViewContainerRef 类做很多巧妙的事情。

我们可以实现一个结构化指令,根据用户角色显示/隐藏视图模板。

考虑以下假设的结构化指令示例,forRoles

<button *forRoles="admin">Admin Save</button> 

如果用户不属于 admin 角色,forRoles 指令将不会渲染按钮。核心逻辑可能如下所示:

if(this.loggedInUser.roles.indexOf(this.forRole) >=0){ 
      this.viewContainer.createEmbeddedView(this.templateRef); 
} 
else { 
      this.viewContainer.clear(); 
}  

指令实现需要某种服务,该服务返回已登录用户的详细信息。我们将把这个指令的实现留给读者。

forRoles 指令所做的事情也可以使用 NgIf 来完成:

<button *ngIf="loggedInUser.roles.indexOf('admin')>=0">Admin Save</button> 

forRoles 指令只是通过清晰的意图增加了模板的可读性。

结构指令的一个有趣的应用可能涉及创建一个仅仅复制传递给它的模板的指令。这相当容易构建;我们只需要调用createEmbeddedView两次:

ngOnInit() {       
 this.viewContainer.createEmbeddedView(this._templateRef);        
 this.viewContainer.createEmbeddedView(this._templateRef); 
}  

另一个有趣的练习!

ViewContainerRef类还有一些其他功能,允许我们注入组件、获取嵌入视图的数量、重新排序视图等等。查看框架文档中的ViewContainerRefbit.ly/view-container-ref)以获取更多详细信息。

这就完成了我们对结构指令的讨论,现在是时候开始新的内容了!

我们迄今为止构建的组件从共同的bootstrap 样式表和一些在app.css中定义的自定义样式那里获取它们的样式(CSS)。Angular 在这个领域提供了更多。一个真正可重用的组件在行为和用户界面方面都应该是完全自包含的。

组件样式和视图封装

Web 应用开发的一个长期问题是 DOM 元素行为和样式的封装不足。我们无法通过任何机制将应用程序的一部分 HTML 与另一部分隔离开来。

实际上,我们手中的权力过大。有了像 jQuery 这样的库和强大的CSS 选择器,我们可以获取任何 DOM 元素并改变其行为。在它可以访问的内容方面,我们的代码和任何外部库代码之间没有区别。每一块代码都可以操作渲染 DOM 的任何部分。因此,封装层被破坏了。一个编写不良的库可能会引起一些难以调试的糟糕问题。

CSS 样式也是如此。任何 UI 库实现都可以覆盖全局样式,如果库实现想要这样做的话。

这些是任何库开发者在构建可重用库时面临的真正挑战。一些新兴的 Web 标准试图通过提出诸如Web 组件等概念来解决此问题。

Web 组件,简单来说,是封装了它们的状态样式用户界面行为的可重用用户界面小部件。功能通过定义良好的 API 公开,用户界面部分也被封装。

Web 组件 概念由四个标准启用:

  • HTML 模板

  • 阴影 DOM

  • 自定义元素

  • HTML 导入

对于这次讨论,我们感兴趣的技术标准是阴影 DOM

阴影 DOM 概述

阴影 DOM就像一个在组件内部(一个 HTML 元素,不要与 Angular 组件混淆)托管并隐藏在主 DOM 树之外的并行 DOM 树。除了组件本身之外,应用程序的任何部分都无法访问这个阴影 DOM。

阴影 DOM 的实现允许视图、样式和行为封装。理解阴影 DOM 的最好方式是看看 HTML5 的videoaudio标签。

你是否曾经想过这个audio声明:

<audio src="img/nextup.mp3" controls></audio> 

产生以下结果?

图片

是浏览器生成底层的 Shadow DOM 来渲染音频播放器。令人惊讶的是,我们甚至可以查看生成的 DOM!以下是我们的操作方法:

  • 将前面的 HTML 创建一个虚拟 HTML 页面,并在 Chrome 中打开它。

  • 然后打开开发者工具窗口(F12)。点击左上角的设置图标。

  • 在常规设置中,点击以下截图中突出显示的复选框,以启用 Shadow DOM 的检查:

图片

刷新页面,如果我们现在检查生成的audio HTML,Shadow DOM 就会出现:

图片

shadow-root下,有一个其他页面部分和脚本都无法访问的全新世界。

在 Shadow DOM 领域,shadow-root(前述代码中的#shadow-root)是生成 DOM 的根节点,位于shadow host(在这种情况下是audio标签)内部。当浏览器渲染这个元素/组件时,渲染的是来自shadow root的内容,而不是shadow host

从这次讨论中,我们可以得出结论,Shadow DOM 是由浏览器创建的并行 DOM,它封装了 HTML 元素的标记样式行为(DOM 操作)。

这是对 Shadow DOM 的温和介绍。要了解更多关于 Shadow DOM 如何工作的信息,我们推荐 Rob Dodson 的这篇系列文章:bit.ly/shadow-dom-intro

但这一切与 Angular 有什么关系呢?实际上,Angular 组件也支持某种视图封装!这使我们也能为 Angular 组件隔离样式。

Shadow DOM 和 Angular 组件

要理解 Angular 如何使用 Shadow DOM 的概念,我们首先需要了解如何为 Angular 组件进行样式设计。

当涉及到为本书中构建的应用程序进行样式设计时,我们采取了保守的方法。无论是Workout Builder还是Workout Runner(7 分钟健身)应用程序,我们构建的所有组件都从bootstrap CSSapp.css中定义的定制样式获取样式。没有组件定义了自己的样式。

虽然这遵循了网络应用程序开发的常规做法,但有时我们确实需要偏离。当我们构建自包含、打包和可重用组件时,这一点尤其正确。

Angular 允许我们通过在@Component装饰器上使用style(用于内联样式)和styleUrl(外部样式表)属性来定义特定于组件的样式。让我们玩一下style属性,看看 Angular 会做什么。

我们将使用AjaxButtonComponent实现作为下一个练习的游乐场。但在做之前,让我们看看现在的AjaxButtonComponent HTML。AjaxButtonComponent的 HTML 树如下所示:

图片

让我们使用styles属性覆盖一些样式:

@Component({ 
  ... 
  styles:[` 
    button { 
      background: green; 
    }`] 
}) 

上述CSS 选择器将所有 HTML 按钮的background属性设置为green。保存上述样式并刷新工作构建页面。按钮样式已更新。这里没有惊喜吗?不,事实并非如此,还有一些惊喜!看看生成的 HTML:

已向多个 HTML 元素添加了一些新属性。最近定义的样式又落在哪里呢?就在head标签的顶部:

head部分定义的样式具有额外的范围,带有_ngcontent-c1属性(在您的案例中属性名可能不同)。这种范围定义允许我们独立地样式化AjaxButtonComponent,并且它不能覆盖任何全局样式。

即使使用styleUrls属性,Angular 也会做同样的事情。假设我们已经在外部 CSS 文件中嵌入相同的 CSS,并使用以下方式:styleUrls:['static/css/ajax-button.css'],Angular 仍然会将样式内联到head部分,通过获取 CSS,解析它,然后注入。

按照定义,应该影响应用程序中所有按钮外观的样式,却没有产生任何效果。Angular 对这些样式进行了范围定义。

这种范围定义确保组件样式不会与已定义的样式混淆,但反之则不然。全局样式仍然会影响组件,除非在组件本身中覆盖。

这种范围定义的样式是 Angular 尝试模拟 Shadow DOM 范式的结果。组件上定义的样式永远不会泄漏到全局样式。这一切的奇妙之处都不需要任何努力!

如果您正在构建定义自己样式的组件并希望有一定程度的隔离,请使用组件的style/styleUrl属性,而不是使用传统的所有样式共享一个 CSS 文件的方法。

我们可以通过使用名为@Component的装饰器属性encapsulation进一步控制这种行为。该属性的 API 文档提到:

encapsulation: ViewEncapsulation 指定模板和样式应该如何封装。如果视图有样式,默认值为ViewEncapsulation.Emulated,否则为ViewEncapsulation.None

如我们所见,一旦我们在组件上设置样式,封装效果就是Emulated。否则,它是None

如果我们明确地将encapsulation设置为ViewEncapsulation.None,则移除范围属性,并将样式嵌入到head部分作为正常样式。

然后还有一个第三种选项,ViewEncapsulation.Native,其中 Angular 实际上为组件视图创建了 Shadow DOM。将AjaxButtonComponent实现上的encapsulation属性设置为ViewEncapsulation.Native,现在看看渲染的 DOM:

AjaxButtonComponent现在有了阴影 DOM!这也意味着按钮的完整样式已经丢失(从 bootstrap CSS 派生的样式),按钮现在需要定义自己的样式。

Angular 不遗余力地确保我们开发的组件可以独立工作并且可重用。每个组件都有自己的模板和行为。除此之外,我们还可以封装组件样式,使我们能够创建健壮的、独立的组件。

这就带我们结束了本章,现在是时候总结本章所学的内容了。

摘要

随着本章的结束,我们现在对指令的工作原理以及如何有效地使用它们有了更好的理解。

我们本章开始时构建了一个RemoteValidatorDirective,并了解了 Angular 对异步验证的支持。

接下来是BusyIndicatorDirective,同样是一个极好的学习场所。我们探索了渲染器服务,它允许以平台无关的方式操作组件视图。我们还学习了宿主绑定,它让我们能够绑定到宿主元素的事件属性属性

Angular 允许跨视图层次声明的指令注入到层次结构中。我们专门用几个部分来理解这种行为。

我们创建的第三个指令(组件)是AjaxButtonComponent。它帮助我们理解了组件中内容子元素视图子元素之间的关键区别。

我们还简要提到了结构指令,其中我们探讨了NgIf平台指令。

最后,我们探讨了 Angular 在视图封装方面的能力。我们研究了 Shadow DOM 的基础,并学习了框架如何采用 Shadow DOM 范式来提供视图加样式封装。

下一章全部关于测试 Angular 应用,这是完整框架提供中的关键部分。Angular 框架是考虑到可测试性而构建的。框架构造和工具支持使得在 Angular 中进行自动化测试变得容易。更多内容将在下一章中介绍……

第七章:测试个人教练

除非您是一位编写代码完美的超级英雄,否则您需要测试您所构建的内容。此外,除非您有大量空闲时间不断测试您的应用程序,否则您需要一些测试自动化。

当我们说 Angular 是以可测试性为设计理念时,我们确实是认真的。它有一个强大的 依赖注入DI)框架,一些良好的模拟构造,以及使在 Angular 应用中进行测试变得富有成效的出色工具。

本章全部关于测试,致力于测试本书过程中我们所构建的内容。我们从组件到管道、服务以及我们的应用指令,测试了所有内容。

本章涵盖的主题包括:

  • 理解大局:我们将尝试理解测试如何融入 Angular 应用开发的整体背景。我们还将讨论 Angular 支持的测试类型,包括单元测试和 端到端E2E)测试。

  • 工具和框架概述:我们将介绍帮助使用 Angular 进行单元测试和端到端测试的工具和框架。这些包括 KarmaProtractor

  • 编写单元测试:您将学习如何在浏览器中使用 JasmineKarma 进行 Angular 的单元测试。我们将对上一章构建的内容进行单元测试。本节还将教会我们如何对各种 Angular 构造进行单元测试,包括管道、组件、服务和指令。

  • 创建端到端测试:自动化的端到端测试通过模拟实际用户的行为并通过浏览器自动化来实现。您将学习如何使用 Protractor 结合 WebDriver 进行端到端测试。

让测试开始吧!

当您开始阅读本章时,我们建议您下载 checkpoint 7.1 的代码。它可以在 GitHub 上供所有人下载(github.com/chandermani/angular6byexample)。检查点作为 GitHub 上的分支实现。如果您不使用 Git,可以从此 GitHub 位置下载 checkpoint7.1 的快照(ZIP 文件):github.com/chandermani/angular2byexample/archive/checkpoint7.1.zip。在首次设置快照时,请参考 trainer 文件夹中的 README.md 文件。

此检查点包含在前面章节创建组件、服务、管道和指令时由 Angular CLI 生成的测试。我们对这些测试进行了细微的修改,以确保它们都能通过。大部分这些测试都是基本的“Hello World”测试,用于确认组件或其他 Angular 构造的创建。我们将在本章中不涉及这些测试,但鼓励您进行回顾。

自动化的需求

随着时间的推移,为网络构建的应用的大小和复杂性都在增长。我们现在构建网络应用的可选方案繁多,令人眼花缭乱。再加上产品/应用的发布周期已经从几个月缩短到几天,甚至每天有多个版本发布!这给软件测试带来了很大的负担。有太多东西需要测试。多个浏览器、多个客户端和屏幕尺寸(桌面和移动)、多个分辨率等等。

在这样一个多样化的环境中要有效,自动化是关键。“自动化一切可以自动化的内容”应该是我们的座右铭。

Angular 中的测试

Angular 团队意识到了可测试性的重要性,因此创建了一个框架,使得基于该框架构建的应用可以轻松进行测试(自动化)。使用 DI 构造来注入依赖的设计选择有助于这一点。随着章节的推进,我们将为我们的应用构建多个测试,这一点将会变得清晰。然而,在那之前,让我们了解在构建该平台上的应用时,我们针对哪些类型的测试。

测试类型

对于典型的 Angular 应用,我们主要进行两种形式的测试:

  • 单元测试:单元测试完全是针对组件进行隔离测试,以验证其行为的正确性。被测试组件的大多数依赖项需要用模拟实现来替换,以确保单元测试不会因为依赖组件的失败而失败。

  • 端到端测试:这种测试类型完全模拟真实用户的操作,并验证应用的行为。与单元测试不同,组件不是单独测试的。测试是在真实浏览器中针对运行中的系统进行的,断言是基于用户界面状态和显示的内容进行的。

单元测试是防止错误的第一个防线,我们应该能够在单元测试期间用代码解决大多数问题。但除非进行了端到端测试,否则我们无法确认软件是否正确运行。只有当系统中的所有组件以期望的方式交互时,我们才能确认软件是正常工作的;因此,端到端测试成为了一种必需。

你可以将这两种测试类型看作是一个金字塔,端到端测试位于顶部,单元测试位于底部。金字塔表明,你编写的单元测试数量应该远多于端到端测试的数量。原因是,通过单元测试,你将应用分解成小的可测试单元,而通过集成测试,你跨越了从 UI 到后端的多个组件。此外,设置端到端测试通常比单元测试更复杂。

谁编写单元测试和端到端测试,以及何时编写,都是需要回答的重要问题。

测试 – 谁来做,何时做?

传统上,端到端测试(E2E testing)是由质量保证QA)团队执行的,而开发人员则负责在提交代码前进行单元测试。开发人员也会进行一定程度的端到端测试,但总体来说,端到端测试过程是手动的。

随着形势的变化,现代测试工具,尤其是在网络前端,已经允许开发人员自己编写自动化的端到端测试,并针对任何部署设置(如开发/测试/生产)执行它们。例如,Selenium 与 WebDriver 一起使用,可以轻松实现浏览器自动化,从而使得编写和执行针对真实网络浏览器的端到端测试变得容易。

在开发完成并准备部署时是编写端到端场景测试的好时机。

当涉及到单元测试时,关于何时编写测试存在不同的观点。测试驱动开发者在功能实现之前编写测试。其他人则在实现完成后编写测试以确认行为。有些人则在开发组件的同时编写测试。选择一种适合你的风格,同时记住,你编写测试的时间越早,效果越好。

我们不会给出任何建议,也不会就哪种方法更好而争论。任何数量的单元测试都比没有好。我们个人的偏好是采用中间方法。在使用测试驱动开发(TDD)时,我们有时觉得测试创建的努力因为规格/需求的变化而白费。一开始编写的测试容易因为需求变化而需要不断修正。在最后编写单元测试的问题在于,我们的目标是创建符合当前实现的测试。编写的测试是为了测试实现,而不是测试规格。在中间某个地方添加测试对我们来说效果最好。

现在我们来了解一下可用于 Angular 测试的工具和技术环境。

Angular 测试生态系统

看一下以下图表,了解支持 Angular 测试的工具和框架:

图片

支持 Angular 测试的工具和框架

如我们所见,我们使用单元测试库,如JasmineMocha来编写测试。

目前,Angular 测试库默认与Jasmine一起工作。然而,Angular 团队已经表明,他们已经使框架更加通用,这样你就可以使用其他测试库,如 Mocha。Angular 文档尚未更新以包含如何做到这一点。有关使用 Mocha 与 Angular CLI 测试命令的讨论,请参阅github.com/angular/angular-cli/issues/4071

这些测试根据我们是否编写单元测试或集成测试,由 Karma 或 Protractor 执行。这些测试运行器反过来在浏览器(如 Chrome、Firefox、IE)或无头浏览器(如 PhantomJS)中运行我们的测试。重要的是要强调,不仅端到端测试,单元测试也是在真实浏览器中执行的。

本章中的所有测试都是使用 Jasmine 编写的(包括单元测试和集成测试)。Karma 将作为单元测试的测试运行器,而 Protractor 将用于端到端测试。

开始使用单元测试

单元测试的最终目的是在隔离状态下测试特定的代码/组件,以确保组件按照规范工作。这减少了组件与其他软件部分集成时出现失败/错误的机会。在我们开始编写测试之前,有一些指导原则可以帮助我们编写良好且可维护的测试:

  • 一个单元应该测试一个行为。出于明显的原因,每个单元测试测试一个行为是有意义的。失败的单元测试应清楚地突出问题区域。如果一起测试多个行为,失败的测试需要更多的调查来确定违反了哪个行为。

  • 单元测试中的依赖项应使用测试替身(如模拟、模拟或 st)来模拟。正如其名所示,单元测试应该测试单元,而不是其依赖项。

  • 单元测试不应该永久改变被测试组件的状态。如果发生了这种情况,其他测试可能会受到影响。

  • 单元测试的执行顺序应该是无关紧要的。一个单元测试不应该依赖于另一个单元测试在它之前执行。这是脆弱单元测试的迹象。这也可能意味着依赖项没有被模拟。

  • 单元测试应该快速。如果它们不够快,开发者就不会运行它们。这是一个在单元测试中模拟所有依赖项(如数据库访问、远程 Web 服务调用等)的好理由。

  • 单元测试应尝试覆盖所有代码路径。代码覆盖率是一个可以帮助我们评估单元测试有效性的指标。如果在测试期间覆盖了所有正面和负面场景,覆盖率确实会更高。在此提醒一点:高代码覆盖率并不意味着代码没有错误,但低覆盖率明显表明单元测试中未覆盖的区域。

  • 单元测试应测试正面和负面场景。只是不要只关注正面测试用例;所有软件都可能失败,因此单元测试失败场景与成功场景一样重要。

这些指导原则不是框架特定的,但为我们编写良好测试提供了足够的弹药。让我们通过设置单元测试所需的组件来开始单元测试的过程。

为单元测试设置 Karma 和 Jasmine

当我们使用 Angular CLI 创建项目时,CLI 会配置使用 Karma 和 Jasmine 对我们的代码进行单元测试的设置。它是通过向我们的项目中添加几个 Karma 和 Jasmine 模块来实现的。它还在应用程序的根目录 trainer/ 中添加了一个名为 karma.config.js 的 Karma 配置文件,并在 trainer/src 目录中添加了一个名为 tests.ts 的文件。CLI 在运行时使用这些文件来创建执行我们的测试的配置。这意味着我们可以通过简单地使用以下命令来运行我们的测试:

ng test

CLI 还会监视我们的测试以检测更改,并自动重新运行它们。

我们在这里不会详细讲解配置文件。默认设置对我们的目的来说已经足够了。有关各种 Karma 配置选项的更多信息,请参阅 Karma 文档(karma-runner.github.io/1.0/config/configuration-file.html)。

我们测试文件的组织和命名

要对应用程序进行单元测试,我们应该为项目中计划测试的每个 TypeScript 文件有一个测试文件(例如 workout-runner.spec.ts)。这正是 Angular CLI 为我们做的事情。当我们使用 CLI 创建组件、服务、管道或指令时,CLI 将生成相应的测试并将其放置在相同的文件目录中。

使用被测试文件名称加上 .spec 来命名测试文件是使用 Jasmine 进行测试的开发者所采用的一种约定。它也用于便于我们在之前概述的配置步骤中映射文件到测试。

此测试文件包含对应组件的单元测试规范,如下面的截图所示(在运行单元测试时在 Karma 调试器中捕获):

图片

单元测试 Angular 应用程序

在本书的整个过程中,我们构建了涵盖 Angular 中每个构造的组件。我们构建了组件、管道、一些服务,最后还有一些指令。所有这些都可以在单元测试中进行测试。

本章剩余的代码可以在 checkpoint 7.2 中找到。它可以在 GitHub 上供每个人下载(github.com/chandermani/angular6byexample)。检查点作为 GitHub 上的分支实现。如果您不使用 Git,可以从以下 GitHub 位置下载 checkpoint7.2 的快照(ZIP 文件):github.com/chandermani/angular2byexample/archive/checkpoint7.1.zip。在首次设置快照时,请参考 trainer 文件夹中的 README.md 文件。

为了熟悉使用 Jasmine 进行单元测试,让我们首先测试最小且最简单的组件:管道。

单元测试管道

管道是最容易测试的,因为它们对其他构造的依赖最小或为零。我们为Workout Runner7 分钟锻炼应用程序)创建的SecondsToTimePipe没有依赖关系,可以轻松地进行单元测试。

查看 Jasmine 框架文档,了解如何使用 Jasmine 编写单元测试。CLI 正在使用 Jasmine 2.6 进行我们的单元测试(jasmine.github.io/2.6/introduction.html)。Jasmine 拥有一些最好的文档,并且整个框架非常直观易用。我们强烈建议您访问 Jasmine 网站,在继续之前熟悉这个框架。

trainer/src/app/shared文件夹中打开seconds-to-time.pipe.spec.ts文件,并按照以下方式更新那里的单元测试:

import { SecondsToTimePipe } from './seconds-to-time.pipe';
describe('SecondsToTimePipe', () => {
  const pipe = new SecondsToTimePipe();
  it('should convert integer to time format', () => {
      expect(pipe.transform(5)).toEqual('00:00:05');
      expect(pipe.transform(65)).toEqual('00:01:05');
      expect(pipe.transform(3610)).toEqual('01:00:10');
  });
});

让我们看看在我们的测试文件中我们正在做什么。

毫不奇怪,我们导入了SecondsToTimePipe,这是我们将要测试的。这就像我们在 TypeScript 类中其他地方使用的导入一样。请注意,我们使用了一个相对路径来指向该文件的位置 './seconds-to-time.pipe'。在 Angular 中,这意味着在测试本身所在的目录中查找要测试的组件。如您所回忆的,这是我们设置文件结构的方式:将我们的测试放在与被测试文件相同的目录中。

在下一行,我们开始使用 Jasmine 语法。首先,我们用describe函数包裹测试以标识测试。这个函数的第一个参数是对测试的用户友好描述;在这种情况下,它是SecondsToTimePipe。对于第二个参数,我们传递一个 lambda(胖箭头)函数,它将包含我们的测试。在设置一个本地变量来保存管道后,我们调用 Jasmine 的beforeEach函数,并使用它来注入我们的管道实例。

由于beforeEach函数在describe函数中的每个测试之前运行,我们可以用它来运行每个测试中都会运行的公共代码。在这种情况下,它不是严格必要的,因为我们的describe函数中只有一个测试。但养成使用它的习惯是一个好主意,正如我们将看到的那样。

接下来,我们调用 Jasmine 的it函数,并传递一个标题,以及三个对 Jasmine 的expect函数(Jasmine 对断言的称呼)的调用。这些都是不言自明的。

在我们的测试中不需要显式导入这些 Jasmine 函数。

运行我们的测试文件

现在是时候使用以下命令运行我们的测试了:

ng test

Angular CLI 将把我们的 TypeScript 文件转换为 JavaScript,并监视这些文件的变化。

我们应该在终端窗口中看到这个输出(对于您来说,测试的总数可能不同):

最后一行显示我们的测试成功通过(以及我们所有的其他测试)。

您还可以在 Karma 运行我们的测试时它启动的浏览器窗口中查看测试结果:

图片

你会注意到这里,Karma 显示了用于我们管道测试的 describe 语句(SecondsToTimePipe),并且在其下嵌套了 it 语句(应将整数转换为时间格式),以展示我们创建的测试的预期结果。以显示的格式读取结果使得理解测试结果变得非常容易。

为了确保它报告正确的通过/失败结果,让我们在测试中做一个更改,导致其中一个期望失败。将第一个期望中的时间从五秒更改为六秒,如下所示:

expect(pipe.transform(5, [])).toEqual('00:00:06'); 

我们得到以下错误消息:

图片

这个错误消息的优点是它将 describeit 描述合并成一个完整的句子,提供了对错误的清晰总结。这显示了 Jasmine 如何允许我们编写可读的测试,以便新接触我们代码的人可以快速理解其中可能出现的任何问题。下一行显示了哪个期望未满足,期望的是什么,以及实际结果是什么,这些结果没有满足这个期望。

在此消息下方,我们还得到一个堆栈跟踪和一个显示我们测试总体结果的最后一条线:

图片

在浏览器中,我们看到以下内容:

图片

你会注意到,当我们更改测试时,我们不必重新运行 Karma。相反,它监视我们文件和相关测试的任何更改,并在我们做出更改时立即报告成功或失败。

非常酷!让我们撤销我们做的最后一个更改,将测试恢复到通过状态。

单元测试组件

测试 Angular 组件比测试简单的管道或服务更复杂。这是因为 Angular 组件与视图相关联,并且通常比服务、过滤器或指令有更多的依赖项。

Angular 测试实用工具

由于它们的复杂性,Angular 引入了使我们可以更容易地测试组件的实用工具。这些测试实用工具包括 TestBed 类(我们之前用来初始化测试的)和 @angular/core/testing 中的几个辅助函数。

TestBed 有一个 createComponent 方法,它返回一个包含多个成员和方法的 ComponentFixture,包括:

  • debugElement:用于调试组件

  • componentInstance:用于访问组件属性和方法

  • nativeElement:用于访问视图的标记和其它 DOM 元素

  • detectChanges:用于触发组件的变更检测周期

ComnponentFixture 还包含用于覆盖组件视图、指令、绑定和提供者的方法。从现在开始,我们将在剩余的测试中使用 TestBed

TestBed 有一个名为 configureTestingModule 的方法,我们可以使用它来设置我们的测试作为一个单独的模块。这意味着我们可以绕过初始引导过程,并在我们的测试文件中编译要测试的组件。我们还可以使用 TestBed 来指定额外的依赖项并识别我们需要的提供者。

根据 Angular 文档(https://angular.io/guide/testing#testbed-class-summary),在 beforeEach 中调用 TestBed 方法对于确保每个单独测试前的全新开始非常重要。

在我们的测试中管理依赖项

Angular 中的组件将视图与所有其他内容集成。因此,与任何服务、过滤器或指令相比,组件通常有更多的依赖项。

尽管我们的单元测试专注于组件内部的代码,但我们仍然需要在测试中考虑这些依赖项,否则测试将失败(我们跳过了管道测试的依赖项设置,因为它没有外部依赖)。

处理这些依赖项有两种方法:将它们注入到我们的组件中或为它们创建一个模拟或伪造,我们可以在测试中使用它。如果一个依赖项足够简单,我们只需将其实例注入到测试类中即可。然而,如果一个依赖项非常复杂,特别是如果它有自己的依赖项并且/或者进行远程服务器调用,那么我们应该模拟它。Angular 测试库为我们提供了进行此操作的工具。

我们在本节计划测试的组件是 WorkoutRunner 组件。位于 trainer/src/components/workout-runner/ 中,这是运行特定锻炼的组件。

单元测试 WorkoutRunnerComponent

在这个背景下,让我们开始对 WorkoutRunnerComponent 进行单元测试。

首先,打开 workout-runner-component.spec.ts 并更新导入如下:

import { inject, fakeAsync, async, tick, TestBed, discardPeriodicTasks } from '@angular/core/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { Router } from '@angular/router';
import { of } from 'rxjs/observable/of';

import { WorkoutPlan, ExercisePlan, Exercise } from '../core/model';
import { WorkoutRunnerComponent } from './workout-runner.component';
import { SecondsToTimePipe } from '../shared/seconds-to-time.pipe';
import { WorkoutService } from '../core/workout.service';
import { WorkoutHistoryTrackerService } from '../core/workout-history-tracker.service';

这些导入标识了我们在测试中将使用的测试工具(以及来自 RxJSRouterof 等东西),以及我们的组件所需的类型和依赖项。我们稍后会讨论这些依赖项。其中一个与其他导入不同的导入是导入 @angular/core 中的 NO_ERRORS_SCHEMA。我们将使用这个导入来忽略我们不会测试的组件中的元素。同样,我们稍后会进一步讨论这一点。

关于导入还有一点需要注意,即 @angular/core/testing 是核心模块的一部分,而不是单独的测试模块。这是 Angular 测试导入的常见模式。例如,当我们到达 HTTP 测试时,你会看到我们是从 @angular/http/testing 导入的。

设置组件依赖项

接下来,我们需要确定我们的组件依赖项,并确定我们是否需要注入或模拟它们。如果我们查看 WorkoutRunner 组件的代码,我们会看到有三个依赖项被注入到我们的组件中:

  • WorkoutHistoryTracker:这是一个附加了一些行为的组件。因此,我们肯定想要模拟它。

  • Router:我们也必须模拟这个,以便将WorkoutRunner与应用程序的其余部分隔离开来,并防止我们的测试尝试从WorkoutRunner视图中导航离开。

  • WorkoutService:这是一个我们将用它来发起 HTTP 调用以检索我们的锻炼的服务。我们也将模拟这个服务,因为我们不希望在测试中向外部系统发起调用。

模拟依赖 - 锻炼历史跟踪器

Angular 允许我们使用简单的类以直接的方式模拟我们的依赖。让我们从模拟WorkoutHistoryTracker开始。为此,在导入之后添加以下类:

class MockWorkoutHistoryTracker { 
    startTracking() {} 
    endTracking() {} 
    exerciseComplete() {} 
} 

我们不需要模拟整个WorkoutHistoryTracker类,而只需要模拟WorkoutRunner将要调用的方法。在这种情况下,这些方法包括startTracking()endTracking()exerciseComplete()。我们已经将这些方法设置为空,因为我们不需要从它们那里返回任何内容来测试WorkoutRunner。现在我们可以将这个虚拟实现注入到WorkoutRunner中,无论它在何处寻找WorkoutHistoryTracker

模拟依赖 - 锻炼服务

在第五章“支持服务器数据持久性”中,我们扩展了锻炼服务以进行远程调用以检索填充锻炼的数据。为了对锻炼运行器进行单元测试,我们希望用返回一些静态数据的模拟实现来替换这个调用,这样我们就可以使用这些数据来运行测试。因此,我们将添加第三个模拟类,如下所示:

class MockWorkoutService {

    sampleWorkout = new WorkoutPlan(
         'testworkout',
         'Test Workout',
          40,
          [
              new ExercisePlan(new Exercise( 'exercise1', 'Exercise 1', 'Exercise 1 description', 
                                               '/image1/path', 'audio1/path'), 50),
              new ExercisePlan(new Exercise( 'exercise1', 'Exercise 2', 'Exercise 2 description', 
                                               '/image2/path', 'audio2/path'), 30),
              new ExercisePlan(new Exercise( 'exercise1', 'Exercise 3', 'Exercise 3 description', 
                                               '/image3/path', 'audio3/path'), 20)
          ],
          'This is a test workout'
    );

    getWorkout(name: string) {
        return of(this.sampleWorkout);
    }
    totalWorkoutDuration() {
        return 180;
    }
} 

注意,getWorkout方法返回了一个Observable,正如使用of操作符所示。否则,这个类是自解释的。

模拟依赖 - 路由

就像WorkoutHistoryTrackerWorkoutService一样,我们也将使用模拟来处理我们对 Angular 路由的依赖。但在这里,我们将采取一种稍微不同的方法。我们将把一个 Jasmine spy 分配给我们的模拟上的navigate方法:

export class MockRouter {
    navigate = jasmine.createSpy('navigate');
}

这对于我们的目的来说已经足够了,因为我们只想确保路由器的navigate方法是以适当的路由(finished)作为参数被调用的。Jasmine 的 spy 将允许我们做到这一点,就像我们稍后将要看到的那样。

使用 TestBed 配置我们的测试

现在我们已经处理好了导入和依赖,让我们开始进行测试本身。我们首先添加一个 Jasmine describe函数来包装我们的测试,然后使用let设置两个局部变量:一个用于fixture,另一个用于runner

describe('Workout Runner', () =>{ 
    let fixture:any; 
    let runner:any; 

接下来,我们将添加一个beforeEach函数来设置我们的测试配置:

beforeEach( async(() =>{ 
    TestBed 
        .configureTestingModule({ 
            declarations: [ WorkoutRunnerComponent, SecondsToTimePipe ], 
            providers: [ 
                {provide: Router, useClass: MockRouter}, 
                {provide: WorkoutHistoryTracker ,useClass: 
                MockWorkoutHistoryTracker}, 
                {provide: WorkoutService ,useClass: MockWorkoutService} 
            ], 
            schemas: [ NO_ERRORS_SCHEMA ] 
        }) 
        .compileComponents() 
        .then(() => { 
            fixture = TestBed.createComponent(WorkoutRunnerComponent); 
            runner = fixture.componentInstance; 
        }); 
}));  

beforeEach方法在每个测试之前执行,这意味着我们只需要在我们的测试文件中设置一次。在beforeEach内部,我们添加一个async调用。这是必需的,因为我们正在调用异步的compileComponents方法。

Angular 文档表明,async函数安排测试者的代码在一个特殊的async测试区域内运行,这个区域隐藏了异步执行的机制,就像它传递给it测试时一样。有关更多信息,请参阅https://angular.io/docs/ts/latest/guide/testing.html#!#async-in-before-each。我们将在稍后详细讨论这一点。

让我们按照它们执行的顺序逐一查看每个方法调用。第一个方法是configureTestingModule,它允许我们在测试模块的基本配置上构建,并添加诸如导入、声明(我们将在测试中使用的组件、指令和管道)和提供者等。在我们的测试中,我们首先添加了关于锻炼运行者、我们正在测试的组件以及SecondsToTimePipe的声明:

declarations: [ WorkoutRunnerComponent, SecondsToTimePipe ], 

然后我们为我们的RouterWorkoutHistoryTrackerWorkoutService添加了三个提供者:

providers: [ 
    {provide: Router, useClass: MockRouter}, 
    {provide: WorkoutHistoryTracker ,useClass: MockWorkoutHistoryTracker}, 
    {provide: WorkoutService ,useClass: MockWorkoutService} 
], 

对于这些提供者中的每一个,我们将useClass属性设置为我们的模拟而不是实际组件。现在,在我们的测试中,当WorkoutRunner需要这些组件中的任何一个时,将使用模拟。

下一个配置可能看起来有点神秘:

schemas: [ NO_ERRORS_SCHEMA ] 

这个设置允许我们绕过与我们在组件模板中使用的两个组件(ExerciseDescriptionComponentVideoPlayerComponent)相关的自定义元素可能产生的错误。在这个阶段,我们不想在WorkoutRunnerComponent的测试中测试这些组件。相反,我们应该单独测试它们。然而,需要注意的是,当你使用这个设置时,它将抑制与测试组件模板中的元素和属性相关的所有模式错误;因此,它可能会隐藏你希望看到的其他错误。

当你使用NO_ERRORS_SCHEMA设置测试时,你正在创建一个所谓的浅测试,它不会深入到你正在测试的组件。浅测试允许你减少你正在测试的组件模板中的复杂性,并减少对模拟依赖的需求。

我们测试配置的最后一步是编译和实例化我们的组件:

.compileComponents() 
.then(() => { 
    fixture = TestBed.createComponent(WorkoutRunnerComponent); 
    runner = fixture.componentInstance; 
}); 

如前所述,我们在beforeEach方法中使用了一个async函数,因为当我们调用compileComponents方法时这是必需的。这个方法调用是异步的,我们需要在这里使用它,因为我们的组件有一个外部模板,该模板在templateUrl中指定。此方法编译该外部模板,然后将其内联,以便它可以由createComponent方法(它是同步的)使用来创建我们的组件固定装置。这个组件固定装置反过来包含一个componentInstance-WorkoutRunner。然后我们将fixturecomponentInstance都分配给局部变量。

如前所述,我们正在使用的async函数创建了一个特殊的async测试区域,我们的测试将在其中运行。你会注意到这个函数是从正常的async编程中简化的,并允许我们做一些事情,比如使用.then运算符而不返回一个承诺。

你也可以在单个测试方法内编译和实例化测试组件。但beforeEach方法允许我们为所有测试执行一次操作。

现在我们已经配置了测试,让我们继续对WorkoutRunner进行单元测试。

开始单元测试

从加载数据到过渡到练习、暂停锻炼和运行练习视频,WorkoutRunner有许多方面我们可以进行测试。workout.spec.ts文件(位于trainer/src/components/workout-runner文件夹下)包含了一系列单元测试,覆盖了上述场景。我们将选择其中一些测试并逐一进行。

首先,让我们添加一个测试用例来验证一旦组件加载,锻炼就开始运行:

it('should start the workout', () => { 
    expect(runner.workoutTimeRemaining).toEqual(runner.workoutPlan.totalWorkoutDuration()); 
    expect(runner.workoutPaused).toBeFalsy(); 
});  

此测试断言锻炼的总时长正确,锻炼处于运行状态(即,没有暂停)。

因此,让我们执行测试。它失败了(检查 Karma 控制台)。奇怪!所有依赖项都已正确设置,但第二个expect函数在it块中仍然失败,因为它未定义。

我们需要调试这个测试。

在 Karma 中调试单元测试

在 Karma 中调试单元测试很容易,因为测试是在浏览器中运行的。我们像调试标准 JavaScript 代码一样调试测试。由于我们的 Karma 配置已将我们的 TypeScript 文件映射到我们的 JavaScript 文件,我们可以直接在 TypeScript 中进行调试。

当 Karma 启动时,它会打开一个特定的浏览器窗口来运行测试。要调试 Karma 中的任何测试,我们只需点击浏览器窗口顶部的调试按钮。

Karma 打开了一个窗口,当我们点击调试时又打开了一个窗口;我们也可以使用原始窗口进行测试,但原始窗口连接到 Karma 并执行实时刷新。此外,原始窗口中的脚本文件带有时间戳,每次更新测试时都会改变,因此我们需要再次设置断点来测试。

一旦我们点击调试,就会打开一个新的标签页/窗口,其中加载了所有测试和其他应用程序脚本以供测试。这些是在karma.conf.js文件配置设置期间定义的脚本。

为了调试前面的失败,我们需要在两个位置添加断点。一个应该添加在测试本身内部,另一个应该添加在WorkoutComponent内部,在那里它加载锻炼并将数据分配给适当的局部变量。

执行以下步骤在 Google Chrome 中添加断点:

  1. 通过点击 Karma 启动时加载的窗口上的调试按钮,打开 Karma 调试窗口/标签页。

  2. 按下 F12 键打开开发者控制台。

  3. 前往“源”标签页,你的应用程序的 TypeScript 文件将位于source文件夹中。

  4. 我们现在可以通过单击行号来在所需位置设置断点。这是调试任何脚本的常规机制。在以下突出显示的位置添加断点:

图片

  1. 我们刷新调试页面(我们在点击调试按钮时打开的页面)。workout-runner.ts中的断点从未被触发,导致测试失败。

我们忽略的是,我们试图访问的代码位于workout-runnerstart方法中,而start方法并没有在构造函数中被调用。相反,它在通过在ngOnInit中调用getWorkout方法加载锻炼数据之后,在ngDoCheck中被调用。在你的测试中添加对ngOnInitngDoCheck的调用,如下所示:

        it('should start the workout', () => { 
 runner.ngOnInit(); runner.ngDoCheck(); 
            expect(runner.workoutTimeRemaining).toEqual(
                   runner.workoutPlan.totalWorkoutDuration()); 
            expect(runner.workoutPaused).toBeFalsy(); 
        }); 
  1. 保存更改后,Karma 将再次运行测试。这次它将通过。

随着测试数量的增加,单元测试可能需要我们专注于特定的测试或特定的测试套件。Karma 允许我们通过在现有的it块前添加f来定位一个或多个测试;也就是说,it变成了fit。如果 Karma 发现带有fit的测试,它只会执行这些测试。同样,可以通过在现有的describe块前添加f来定位特定的测试套件:fdescribe。此外,如果你在it块前添加x,使其变为xit,那么该块将被跳过。

让我们继续对组件进行单元测试!

单元测试 WorkoutRunner 继续...

我们还能测试哪些有趣的事情呢?我们可以测试是否开始了第一个练习。我们在刚刚添加的测试之后,将这个测试添加到workout.spec.ts中:

it('should start the first exercise', () => { 
    spyOn(runner, 'startExercise').and.callThrough(); 
    runner.ngOnInit(); 
    runner.ngDoCheck(); 
    expect(runner.currentExerciseIndex).toEqual(0); 
    expect(runner.startExercise).toHaveBeenCalledWith(
    runner.workoutPlan.exercises[runner.currentExerciseIndex]); 
    expect(runner.currentExercise).toEqual(
    runner.workoutPlan.exercises[0]); 
}); 

这个测试中的第二个expect函数很有趣。它使用了 Jasmine 的一个特性:间谍。间谍可以用来验证方法调用和依赖关系。

使用 Jasmine 间谍来验证方法调用

间谍是一个拦截它所监视的函数每个调用的对象。一旦调用被拦截,它可以选择返回固定数据或将调用传递给实际被调用的函数。它还记录了调用调用详情,这些详情可以在之后的expect中使用,就像我们在前面的测试中所做的那样。

间谍非常强大,可以在单元测试期间以多种方式使用。查看有关间谍的文档jasmine.github.io/2.0/introduction.html#section-Spies,了解更多信息。

第二个expect函数验证了当锻炼开始时调用了startExercise方法(toHaveBeenCalledWith)。它还断言了传递给函数的参数的正确性。第二个expect语句使用间谍断言行为,但我们需要首先设置间谍以使这个断言生效。

在这种情况下,我们使用间谍来模拟对startExercise方法的调用。我们可以使用间谍来确定方法是否被调用以及调用时使用了什么参数,使用 Jasmine 的toHaveBeenCalledWith函数。

查看 Jasmine 文档中的toHaveBeenCalledtoHaveBeenCalledWith函数,了解更多关于这些断言函数的信息。

在这里,方法是以当前Exercise作为参数被调用的。由于之前的expect确认这是第一个练习,这个expect确认启动第一个练习的调用已经执行。

在这里有几个需要注意的事项。首先,你必须小心地将spyOn的设置放在调用ngOnInit之前。否则,当调用startExercise方法时,间谍将不会进行监视,并且方法调用不会被捕获。

第二,由于间谍是一个模拟,我们通常无法在startExercise方法内部进行验证。这是因为该方法本身正在被模拟。这意味着我们实际上无法验证currentExercise属性是否已经设置,因为这是在模拟的方法内部完成的。然而,Jasmine 允许我们使用and.callThrough将间谍链式调用,这意味着除了跟踪方法的调用外,它还会委托到实际实现。这样我们就可以测试currentExercise是否在startExercise方法内部也正确设置了。

使用 Jasmine 间谍验证依赖项

虽然我们只是使用间谍来验证我们类内部的方法调用,但 Jasmine 间谍在模拟外部依赖的调用时也非常有用。但为什么要测试对我们外部依赖的调用呢?毕竟,我们试图将测试限制在组件本身上!

答案是我们模拟一个依赖项以确保依赖项不会对测试中的组件产生不利影响。从单元测试的角度来看,我们仍然需要确保这些依赖项在正确的时间以正确的输入被测试组件调用。在 Jasmine 的世界里,间谍帮助我们断言依赖项是否被正确调用。

如果我们查看WorkoutRunner的实现,每当锻炼开始时,我们都会发出一个包含锻炼详情的消息。外部依赖项WorkoutHistoryTracker订阅了这个消息/事件。所以让我们创建一个间谍并确认当锻炼开始时WorkoutHistoryTracker也开始了。

在前一个it块之后添加这个it块:

it("should start history tracking", inject([WorkoutHistoryTracker], (tracker: WorkoutHistoryTracker) => { 
     spyOn(tracker, 'startTracking'); 
     runner.ngOnInit(); 
     runner.ngDoCheck(); 
     expect(tracker.startTracking).toHaveBeenCalled(); 
 })); 

it块内部,我们添加了对tracker的监视,它是WorkoutHistoryTracker的一个本地实例。然后我们使用这个间谍来验证那个依赖项的startTracking方法已经被调用。简单且表达清晰!

你可能记得我们在这里使用的是MockHistoryWorkoutTracker;它包含一个模拟,一个空的startTracking方法,它不返回任何内容。这是可以的,因为我们不是在测试WorkoutHistoryTracker本身,而是在测试WorkoutRunner对其的调用方法。这个测试展示了能够将模拟与间谍结合使用来完全测试WorkoutRunner的内部工作方式,独立于其依赖项是多么有用。

测试事件发射器

检查WorkoutRunner的代码,我们发现它设置了几个事件发射器,其中一个是用于workoutStarted的如下所示:

@Output() workoutStarted: EventEmitter<WorkoutPlan> = new EventEmitter<WorkoutPlan>(); 

Angular 文档将事件发射器描述为一个输出属性,它触发我们可以通过事件绑定来订阅的事件。在第二章构建我们的第一个应用 - 7 分钟锻炼中,我们详细描述了在 Workout Runner 中使用事件发射器的方式。因此,我们对它们的作用有很好的理解。但我们如何对事件发射器进行单元测试,并确定它们是否以我们期望的方式触发事件呢?

实际上做起来相当简单。如果我们记得事件发射器是一个我们可以订阅的 Observable Subject,我们就会意识到我们可以在单元测试中简单地订阅它。让我们回顾一下验证锻炼开始的那个测试,并向其中添加高亮代码:

it('should start the workout', () => { 
 runner.workoutStarted.subscribe((w: any) => { expect(w).toEqual(runner.workoutPlan); }); 
    runner.ngOnInit(); 
    runner.ngDoCheck(); 
    expect(runner.workoutTimeRemaining).toEqual(
    runner.workoutPlan.totalWorkoutDuration()); 
    expect(runner.workoutPaused).toBeFalsy(); 
}); 

我们注入了WorkoutService,并添加了对WorkoutStarted事件发射器的订阅和一个期望检查,以查看当事件被触发时属性是否正在发射WorkoutPlan。订阅被放置在ngOnInit之前,因为这个方法会导致workoutStarted事件被触发,我们需要在它发生之前设置我们的订阅。

测试间隔和超时实现

我们面临的一个有趣的挑战是验证锻炼随着时间的流逝而进展。Workout组件使用setInterval来随着时间推进。我们如何在不实际等待的情况下模拟时间呢?

答案是 Angular 测试库的fakeAsync函数,它允许我们以同步的方式运行本应异步执行的代码。它是通过将待执行的函数包裹在fakeAsync区域中实现的。然后它支持在该区域内使用同步计时器,并允许我们使用tick()函数模拟异步时间的流逝。

更多关于fakeAsync的信息,请参阅 Angular 文档中的angular.io/guide/testing#async-test-with-fakeasync.

让我们看看如何使用fakeAsync函数来测试我们代码中的超时和间隔实现。将以下测试添加到workout-runner.spec.ts中:

    it('should increase current exercise duration with time', fakeAsync(() => {
        runner.ngOnInit();
        runner.ngDoCheck();
        expect(runner.exerciseRunningDuration).toBe(0);
        tick(1000);
        expect(runner.exerciseRunningDuration).toBe(1);
        tick(1000);
        expect(runner.exerciseRunningDuration).toBe(2);
        tick(8000);
        expect(runner.exerciseRunningDuration).toBe(10);
        discardPeriodicTasks();
    })); 

除了注入WorkoutRunner之外,我们首先使用fakeAsync包装测试。然后我们调用WorkoutRunnerngOnInit方法。这将在WorkoutRunner内部启动练习的计时器。然后在测试中,我们使用设置在不同时间段的tick()函数来测试练习计时器的操作,并确保它以我们期望的持续时间继续运行。使用tick()允许我们快速前进通过代码,避免异步运行代码时需要等待几秒钟才能完成练习。

最后,我们调用discardPeriodicTasks()。这是 Angular 测试实用工具之一,它可以与fakeAsync一起使用来清除任务队列中可能存在的任何挂起的计时器。

更多关于这些和其他 Angular 测试实用工具的信息可以在angular.io/guide/testing#testing-utility-apis找到。

让我们尝试另一个类似的测试。我们想要确保WorkoutRunner能够正确地从一项练习过渡到下一项练习。请将以下测试添加到workout-runner.ts中:

it("should transition to next exercise on one exercise complete", fakeAsync(() => { 
    runner.ngOnInit(); 
    runner.ngDoCheck(); 
    let exerciseDuration = runner.workoutPlan.exercises[0].duration; 
    TestHelper.advanceWorkout(exerciseDuration); 
    expect(runner.currentExercise.exercise.name).toBe('rest'); 
    expect(runner.currentExercise.duration).toBe(
    runner.workoutPlan.restBetweenExercise); 
    discardPeriodicTasks();
})); 

我们再次使用fakeAsync包装测试并调用runner.ngOnInit来启动计时器。然后我们获取第一项练习的持续时间,并在随后的TestHelper方法中使用tick()函数将计时器推进超过该练习持续时间的一秒。

class TestHelper {
    static advanceWorkout(duration: number) {
        for (let i = 0; i <= duration; i++) {tick(1000);
    }
}

接下来,我们测试期望我们现在处于rest练习中,因此已经从第一项练习过渡过来。

测试锻炼暂停和恢复

当我们暂停锻炼时,它应该停止,时间计数器不应中断。为了检查这一点,请添加以下时间测试:

it("should not update workoutTimeRemaining for paused workout on 
    interval lapse", fakeAsync(() => { 
    runner.ngOnInit(); 
    runner.ngDoCheck(); 
    expect(runner.workoutPaused).toBeFalsy(); 
    tick(1000); 
    expect(runner.workoutTimeRemaining).toBe(
    runner.workoutPlan.totalWorkoutDuration() - 1); 
    runner.pause(); 
    expect(runner.workoutPaused).toBe(true); 
    tick(1000); 
    expect(runner.workoutTimeRemaining).toBe(
    runner.workoutPlan.totalWorkoutDuration() - 1); 
    discardPeriodicTasks();
})); 

测试从验证锻炼状态未暂停开始,将时间推进一秒,暂停它,然后验证在暂停后workoutTimeRemaining的时间没有变化。

单元测试服务

单元测试服务与单元测试组件没有太大区别。一旦我们掌握了如何设置组件及其依赖项(主要使用模拟),将这种学习应用到测试服务上就变成了一件例行公事。通常情况下,挑战在于设置服务的依赖项,以便能够有效地进行测试。

对于进行远程请求(使用httpjsonp)的服务来说,情况略有不同。在我们可以单独测试此类服务之前,需要进行一些设置。

我们将针对WorkoutService编写一些单元测试。由于此服务会向远程请求加载锻炼数据,我们将探讨如何使用模拟 HTTP 后端测试此类服务。Angular 为我们提供了HttpTestingController来执行此操作。

使用 HttpTestingController 模拟 HTTP 请求/响应

当测试进行远程请求的服务(或者实际上,任何其他 Angular 构造)时,我们显然不希望实际向后端发送请求来检查行为。这甚至都不符合单元测试的标准。后端交互只需要被模拟。Angular 正好提供了这样的功能。使用 HttpTestingController,我们拦截 HTTP 请求,模拟来自服务器的实际响应,并断言端点调用。

打开 workout-service.spec.ts 并在文件顶部添加以下导入语句:

import { TestBed, inject, async, fakeAsync } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { HttpClient, HttpResponse } from '@angular/common/http';

import { WorkoutService } from './workout.service';
import { WorkoutPlan, Exercise } from './model';

除了从 core/testing 模块导入之外,我们还从 http/testing 模块导入了 HttpClientTestingModuleHttpTestingController。我们还导入了我们将要测试的 WorkoutServiceWorkoutPlan

一旦我们设置了导入,我们将开始使用 Jasmine 的 describe 语句创建测试,该语句封装了我们的测试,并设置了一些局部变量:

describe('Workout Service', () => { 
  const collectionUrl = '...[mongo connnection url]...';
  const apiKey = '...[mongo key]...';
  const params = '?apiKey=' + apiKey;
  let httpClient: HttpClient;
  let httpTestingController: HttpTestingController;
  let workoutService: WorkoutService;

除了为 HttpClientHttpTestingControllerWorkoutService 创建局部变量之外,你还会注意到我们为我们的 MongoDB 连接设置了局部变量。为了明确,我们不是设置这些变量以向 MongoDB 发送远程调用,而是为了测试连接属性是否被正确设置。

下一步是设置测试的提供者和依赖注入。为了处理提供者,将以下内容添加到测试文件中:

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [ WorkoutService ],
    });
    httpClient = TestBed.get(HttpClient);
    httpTestingController = TestBed.get(HttpTestingController);
    workoutService = TestBed.get(WorkoutService);
  });

首先,我们调用 TestBed.configureTestingModule 来导入 HttpClientTestingModule 并添加 WorkoutService。根据 Angular 文档(angular.io/api/common/http/testing/HttpClientTestingModule),HttpClientTestingModuleHttpClient 设置为使用 HttpClientTestingBackend 作为 HttpBackend。这里的好处是,这种设置完全隐藏在我们的测试设置中,所以我们不需要编写代码来连接它。

接下来,我们使用 TestBed.get 方法填充我们的局部变量—httpClienthttpTestingControllerworkoutService。我们还将添加以下 afterEach 方法以确保在每个测试完成后没有更多挂起的请求:

 afterEach(() => {
   httpTestingController.verify();
 });

在设置好这一切之后,我们现在可以创建针对 WorkoutService 的测试,以避免我们进行远程调用。我们将从一个简单的测试开始,确保 workoutService 被加载:

it('should be created', inject([WorkoutService], (service: WorkoutService) => {
   expect(service).toBeTruthy();
 }));

虽然这个测试可能看起来微不足道,但将其放在这里很重要,因为它作为一个检查,确保我们已经正确设置了配置。

接下来,我们将添加以下测试以确保我们能够在实例化 WorkoutService 时注入 HttpClient

it('can instantiate service with "new"', inject([HttpClient], (http: HttpClient) => {
    expect(http).not.toBeNull('http should be provided');
    const service = new WorkoutService(http);
    expect(service instanceof WorkoutService).toBe(true, 'new service should be ok');
}));

现在,我们将转向测试 workout-service 中的几个方法。首先,我们将确保当调用 getWorkouts 方法时,它返回所有锻炼项目。为此,添加以下测试:

  it('should should return all workout plans', () => {
    let expectedWorkouts: WorkoutPlan[];
    let actualWorkouts: WorkoutPlan[];

    expectedWorkouts = [
      { name: 'Workout1', title: 'workout1' },
      { name: 'Workout2', title: 'workout2' },
      { name: 'Workout3', title: 'workout3' },
      { name: 'Workout4', title: 'workout4' }
    ] as WorkoutPlan[];

     workoutService.getWorkouts().subscribe(
      workouts => actualWorkouts = workouts,
      fail
    );
    const req = httpTestingController.expectOne(workoutService.collectionsUrl + '/workouts' + params );
    expect(req.request.method).toEqual('GET');
    req.flush(expectedWorkouts);
    expect(actualWorkouts === expectedWorkouts);
  });

我们将首先声明两个WorkoutPlans数组——expectedWorkoutsactualWorkouts。然后我们将expectedWorkouts填充为四个WorkoutPlans。因为我们正在测试WorkoutPlans的检索而不是其内容,所以我们创建了这些最小的工作计划。

由于Http模块返回RxJS Observables,我们接下来使用订阅这些 Observables 的模式。你应该已经习惯了从第五章支持服务器数据持久性中我们关于 Observables 的介绍中看到这个模式。注意,我们使用fail作为第二个参数,如果订阅 Observable 存在问题,这将导致测试失败。

接下来,我们在HttpTestingController上调用一个名为expectOne的方法,并传递我们的请求 URL。根据 Angular 文档(angular.io/api/common/http/testing/HttpTestingController#expectone),此方法执行两个操作:它期望一个请求已被发出,该请求与方法调用中提供的 URL 匹配,并返回一个模拟请求。在下一行中,我们确保模拟请求是一个 HTTP GET。最后,我们使用expectedWorkouts刷新请求,并确认actualWorkouts等于expectedWorkouts

我们将遵循相同的模式来构建额外的测试,以确认我们能够做到以下事情:

  • 返回一个具有特定名称的workout计划

  • getWorkout方法中正确映射exercises

你可以在checkpoint 7.2的代码中查看这些测试。但要注意的一点是,在这两个测试中,我们都在测试两个 HTTP 调用。例如,以下是这两个测试中的第二个测试的代码:

const req1 = httpTestingController.expectOne(workoutService.collectionsUrl + '/exercises' + params);
expect(req1.request.method).toEqual('GET');
req1.flush(allExercises);

const req2 = httpTestingController.expectOne(workoutService.collectionsUrl + '/workouts/Workout1' + params);
expect(req2.request.method).toEqual('GET');
req2.flush(expectedWorkout);

这可能一开始看起来有些令人困惑,直到我们意识到,实际上,使用getWorkout方法,我们实际上进行了两个Http调用:一个用于检索workout,另一个用于检索所有exercises。如您从第五章支持服务器数据持久性中回忆的那样,我们这样做是为了创建每个包含在workout中的exercise的更完整描述。

有了这些,我们就完成了对服务的测试。

接下来,我们需要学习如何测试指令。下一节将专门介绍指令测试的挑战以及如何克服它们。

单元测试指令

到目前为止,我们测试过的所有其他 Angular 构建项都不涉及任何 UI 交互。但正如我们所知,指令是另一回事。指令全部关于增强组件的视图和扩展 HTML 元素的行为。在测试指令时,我们不能忽视 UI 连接,因此指令测试可能并不严格符合单元测试的定义。

指令测试的好处是它的设置过程不像服务或组件那么复杂。在单元测试指令时应该遵循以下模式:

  1. 取一个包含指令标记的 HTML 片段

  2. 编译并将其链接到一个模拟组件

  3. 验证生成的 HTML 是否具有所需的属性

  4. 验证指令创建的更改是否改变了状态

TestBed 类

如前所述,Angular 提供了 TestBed 类来简化此类 UI 测试。我们可以使用它来深入查看组件视图中的标记,并检查由事件触发的 DOM 变化。有了这个工具,让我们开始对指令进行测试。在本节中,我们将测试 remoteValidator

这将是回顾我们在上一章中构建的指令的好时机。同时,保留我们将要在以下部分创建的测试的代码。

测试远程验证器

让我们从单元测试 remoteValidatorDirective 开始。为了刷新我们的记忆,remoteValidatorDirective 通过调用返回一个 promise 的组件方法来验证输入与远程规则。如果 promise 成功解析,则验证通过;否则,验证失败。[validateFunction] 属性提供了 DOM 和组件中检查重复的方法之间的链接。

与我们的其他测试文件类似,我们在共享文件夹中有一个 remote-validator.directive.spec.ts 文件。请参考 checkpoint 7.2 中的文件以获取导入,我们在此处不会涉及。

在导入语句下方,添加以下组件定义:

@Component({
    template: `
      <form>
      <input type="text" name="workoutName"
      id="workout-name" [(ngModel)]="workoutName"
      abeRemoteValidator="workoutname" [validateFunction]="validateWorkoutName">
      </form>
    `
}) 
export class TestComponent { 
    workoutName: string; 

    constructor() { 
        this.workoutName = '7MinWorkout'; 
    } 
    validateWorkoutName = (name: string): Promise<boolean> => { 
        return Promise.resolve(false); 
    } 
} 

这个组件看起来很像我们在其他测试中设置的组件,用于模拟依赖项。然而,在这里,它起着略微不同的作用;它充当我们将要测试的指令的主容器。使用这个最小组件,我们可以避免加载此指令的实际宿主组件,即 Workout 组件。

这里需要注意的一点是,我们为 validateWorkoutName 方法设置了一个方法,它将由我们的指令调用。它本质上是一个返回已解析的 Promisefalse 的存根。记住,我们并不关心这个方法如何处理其验证,而是要验证指令调用了它,并返回了正确的结果,即 truefalse

接下来,我们通过添加以下代码来设置测试套件的 describe 语句,该代码将 RemoteValidatorDirective 注入到我们的测试中:

describe('RemoteValidator', () => { 
    let fixture: any; 
    let comp: any; 
    let debug: any; 
    let input: any; 

    beforeEach(async(() => { 
        TestBed.configureTestingModule({ 
            imports: [ FormsModule ], 
            declarations: [ TestComponent, RemoteValidatorDirective ] 
        }); 
        fixture = TestBed.createComponent(TestComponent); 
        comp = fixture.componentInstance; 
        debug = fixture.debugElement; 
        input = debug.query(By.css('[name=workoutName]')); 
    }));  

如您所见,我们正在为 fixture、其 componentInstancedebugElement 设置局部变量。我们还在 debugElement 上使用 by.css(我们将在端到端测试中了解更多)以及查询方法来从我们的组件中提取 workoutName 输入。我们将使用这些来深入查看指令中渲染的 HTML。

现在,我们已经准备好编写我们的单个测试。首先,我们将编写一个测试来确认我们已经能够加载 RemoteValidatorDirective。因此,添加以下代码:

it("should load the directive without error", fakeAsync(() => {
    expect(input.attributes.a2beRemoteValidator).toBe('workoutname',  'remote validator directive should be loaded.')
}));

这个测试有趣的地方在于,使用debugElement,我们已经能够深入挖掘我们宿主组件中输入标签的属性,并找到我们的验证器,确认它确实已经被加载。同时注意fakeAsync的使用,我们在单元测试中讨论过。使用它使得我们能够以同步的方式编写测试,并避免在尝试管理宿主组件的异步渲染时可能出现的复杂性。接下来,我们将编写两个测试来确认我们的验证器是否正常工作。第一个测试将确保如果远程验证失败(即找到与我们所使用的相同名称的锻炼),将创建一个错误。为此测试添加以下代码:

    it('should create error if remote validation fails', fakeAsync(() => {
        spyOn(comp, 'validateWorkoutName').and.callThrough();
        fixture.detectChanges();
        input.nativeElement.value = '6MinWorkout';
        tick();

        const form: NgForm = debug.children[0].injector.get(NgForm);
        const control = form.control.get('workoutName');

        expect(comp.validateWorkoutName).toHaveBeenCalled();
        expect(control.hasError('workoutname')).toBe(true);
        expect(control.valid).toBe(false);
        expect(form.valid).toEqual(false);
        expect(form.control.valid).toEqual(false);
        expect(form.control.hasError('workoutname', ['workoutName'])).toEqual(true);
    }));

再次,我们使用fakeAsync来消除我们可能面临的与我们的remoteValidatorDirective渲染和执行相关的异步行为带来的挑战。接下来,我们添加一个间谍来跟踪validateWorkoutName方法的调用。我们还设置间谍调用我们的方法,因为在这种情况下,我们期望它返回false。间谍被用来验证我们的方法确实被调用。接下来,我们设置fixture.detectChanges,这会触发一个变更检测周期。然后我们设置输入的值并调用 tick,这将,我们希望,触发我们期望从远程验证器那里得到的响应。然后我们使用从调试元素的子元素数组中可用的注入器获取包含我们的输入标签的表单。从那里,我们提取我们的输入框的表单控件。然后我们运行几个期望,确认错误已经添加到我们的控件和表单中,并且它们现在都处于无效状态。下一个测试是这个测试的镜像相反,并检查一个积极的:

    it('should not create error if remote validation succeeds', fakeAsync(() => {
        spyOn(comp, 'validateWorkoutName').and.returnValue(Promise.resolve(true));
        fixture.detectChanges();
        input.nativeElement.value = '6MinWorkout';
        tick();

        const form: NgForm = debug.children[0].injector.get(NgForm);
        const control = form.control.get('workoutName');

        expect(comp.validateWorkoutName).toHaveBeenCalled();
        expect(control.hasError('workoutname')).toBe(false);
        expect(control.valid).toBe(true);
        expect(form.control.valid).toEqual(true);
        expect(form.valid).toEqual(true);
        expect(form.control.hasError('workoutname', ['workoutName'])).toEqual(false);
    }));

除了改变期望之外,我们从上一个测试中做出的唯一改变是设置我们的间谍返回true的值。对remoteValidatorDirective进行单元测试展示了TestBed工具在测试我们的 UI 及其相关元素和行为时的强大功能。

开始进行端到端测试

如果底层框架支持,自动化的端到端(E2E)测试是一项无价的资产。随着应用程序规模的扩大,自动化的端到端测试可以节省大量的手动工作。

没有自动化,确保应用程序功能正常只是一场永无止境的战斗。然而,记住在一个端到端(E2E)的设置中,并非所有事情都可以自动化;自动化可能需要大量的努力。经过尽职调查,我们可以减少大量的手动工作,但并非所有。

基于用户界面状态对基于 Web 的应用程序进行端到端测试的过程是在真实浏览器中运行应用程序,并根据用户界面状态断言应用程序的行为。这就是实际用户进行测试的方式。

浏览器自动化是这里的关键,现代浏览器在支持自动化方面已经变得更加智能和强大。Selenium 浏览器自动化工具是当前最受欢迎的选项。Selenium 拥有 WebDriver API(www.w3.org/TR/webdriver/),它允许我们通过现代浏览器原生支持的自动化 API 来控制浏览器。

提出 Selenium WebDriver 的原因在于,Angular 端到端测试框架/运行器Protractor也使用了WebDriverJS,这是 WebDriver 在 Node 上的 JavaScript 绑定。这些语言绑定(如前面的 JavaScript 绑定)允许我们使用我们选择的语言的自动化 API。

在我们开始为我们的应用程序编写一些集成测试之前,让我们先讨论一下 Protractor。

介绍 Protractor

Protractor是 Angular 端到端测试的默认测试运行器。Protractor 使用 Selenium WebDriver 来控制浏览器并模拟用户操作。

一个典型的 Protractor 设置包含以下组件:

  • 测试运行器(Protractor)

  • Selenium 服务器

  • 浏览器

我们使用 Jasmine 编写测试,并使用 Protractor(它是 WebDriverJS 的包装器)公开的一些对象来控制浏览器。

当这些测试运行时,Protractor 会向 Selenium 服务器发送命令。这种交互主要发生在 HTTP 上。

Selenium 服务器反过来,使用WebDriver Wire Protocol与浏览器通信,并且内部浏览器使用浏览器驱动程序(例如 Chrome 中的ChromeDriver)来解释操作命令。

理解这种通信的技术细节并不那么重要,但我们应该了解端到端测试的设置。查看 Protractor 文档中的文章angular.github.io/protractor/#/infrastructure,了解更多关于这个流程的信息。

在使用 Protractor 时,还有一个重要的事情需要意识到,那就是与浏览器或浏览器控制流的整体交互本质上是异步的,基于 Promise 的。任何 HTML 元素操作,无论是sendKeysgetTextclicksubmit还是其他任何操作,都不会在调用时执行;相反,该操作会被排队到控制流队列中。正因为如此,每个操作语句的返回值都是一个 Promise,当操作完成时得到解决。

为了处理 Jasmine 测试中的这种异步性,Protractor 对 Jasmine 进行了修补,因此像这样的断言是有效的:

expect(element(by.id("start")).getText()).toBe("Select Workout"); 

尽管getText函数返回一个 Promise 而不是元素内容,它们仍然可以正常工作。

在我们对 Protractor 的工作原理有了基本的了解之后,让我们设置 Protractor 进行端到端测试。

设置 Protractor 进行端到端测试

Angular CLI 已经为我们设置了项目,以便我们可以使用 Protractor。该设置的配置可以在 trainer 文件夹中的 protractor.config.js 文件中找到。在大多数情况下,你应该能够使用这些配置而不做任何更改来运行你的端到端测试。然而,我们在该配置文件中做了一项更改。我们将 defaultTimeoutInterval 在该文件中扩展到 60000 毫秒,以便给运行锻炼的测试更多的时间来完成:

const { SpecReporter } = require('jasmine-spec-reporter');

exports.config = {
    . . .
  jasmineNodeOpts: {
    showColors: true,
    defaultTimeoutInterval: 60000,
    print: function() {}
  },
    . . .
};

Protractor 网站上的配置文件文档(github.com/angular/protractor/blob/master/lib/config.ts)包含有关其他支持的配置的详细信息。

这就足够开始使用 Protractor 进行测试了。为了运行我们的测试,我们只需在 trainer 文件夹中执行以下命令:

ng e2e

现在,让我们开始编写一些端到端测试。

为应用编写端到端测试

让我们从简单的方式开始,测试我们的应用启动页面(#/start)。这个页面包含一些静态内容,一个带有搜索功能的锻炼列表部分,以及通过点击任何锻炼文件来开始锻炼的能力。

我们所有的端到端测试都将添加到 trainer 文件夹下的 e2e 文件夹中。

打开位于 trainer 文件夹下的 e2e 文件夹中的 app.e2e-spec.ts 文件,其中包含以下代码:

import { AppPage } from './app.po';

describe('Personal Trainer App', () => {
  let page: AppPage;

  beforeEach(() => {
    page = new AppPage();
  });

  it('should display welcome message', () => {
    page.navigateTo();
    expect(page.getParagraphText()).toEqual('Ready for a Workout?');
  });
})

让我们一步步走过这个简单的测试。

最有趣的部分是页面顶部的导入——import { AppPage } from './app.po';。这指的是同一目录下包含所谓的页面对象的文件。这个页面对象包含以下内容:

import { browser, by, element } from 'protractor';

export class AppPage {
  navigateTo() {
    return browser.get('/');
  }

  getParagraphText() {
    return element(by.css('abe-root h1')).getText();
  }
}

页面对象的使用允许我们简化测试中的代码,使其更易于阅读。因此,我们不是直接在我们的测试中调用 browser.get ('/'),而是从我们的页面对象中调用 navigateTo() 方法。

在我们的页面对象中提到的浏览器对象是 Protractor 提供的全球对象,它用于控制浏览器级别的操作。在底层,它只是 WebDriver 的包装器。browser.get("") 方法在测试开始之前每次都会导航到浏览器以启动应用页面。

对于 getParagraphText() 方法也是如此——它允许我们在测试中调用该方法,并在屏幕上查找一些文本,而无需确定该文本将在页面上出现的确切位置。随着我们进入更复杂的端到端测试,我们将更详细地讨论页面对象。在我们的页面对象中,getParagraphText() 也使用了两个新的全局变量,elementby,这些变量由 Protractor 提供:

  • element:这个函数返回一个 ElementFinder 对象。ElementFinder 的主要任务是交互所选元素。我们将使用 element 函数在我们的测试中广泛选择 ElementFinder

请参考http://www.protractortest.org/#/locators#actions文档了解有关元素操作 API 支持的更多信息。例如,getText()函数实际上是在WebElement上定义的,但总是通过ElementFinder来访问。正如文档所建议的,在大多数情况下可以将ElementFinder视为WebElement。更多详细信息,您可以参考http://www.protractortest.org/#/locators#behind-the-scenes-elementfinders-versus-webelements

  • by:此对象用于定位元素。它具有创建locators的函数。在先前的测试中,创建了一个定位器来搜索具有等于abe-root h1的 CSS 标签的元素。如果您熟悉 CSS 选择器,您将知道这标识了我们正在寻找的h1标签,它位于自定义元素abe-root内部。有几种定位器可以用来搜索特定元素,包括按类、按 ID 和按 CSS。有关支持的定位器的详细信息,请参阅 Protractor 文档中的定位器部分angular.github.io/protractor/#/locators

只是为了重申我们之前讨论的内容,页面对象中的getText()并不返回实际的文本,而是一个 Promise;我们仍然可以对文本值进行断言。

回到实际的测试,它使用页面对象中的方法来验证某些内容(“Ready for a Workout?”)是否出现在页面上。

这个简单的测试突出了 Protractor 的另一个显著特点。它自动检测 Angular 应用何时加载以及数据何时可用于测试。在标准 E2E 测试场景中,不需要使用timeouts等丑陋的技巧来延迟测试。

记住,这是一个SPA;页面不会进行全页刷新,因此确定页面何时加载以及为 AJAX 调用渲染的数据何时可用并不简单。Protractor 使这一切成为可能。

在尝试评估页面是否可用于测试时,Protractor 可能会超时。如果您在使用 Protractor 时遇到超时错误,这篇来自 Protractor 文档的文章可能会对调试此类问题非常有帮助(www.protractortest.org/#/timeouts)。

设置 E2E 测试的后端数据

不论我们使用哪个 E2E 框架进行测试,设置 E2E 测试的后端数据都是一个挑战。最终目标是针对某些数据断言应用程序的行为,除非数据是固定的,否则我们无法验证涉及获取或设置数据的行为。

设置 E2E 测试数据的一个方法是为 E2E 测试创建一个专门的数据存储库,并包含一些种子数据。一旦 E2E 测试完成,数据存储库可以重置到原始状态,以便未来的测试。对于Personal Trainer,我们可以在 MongoLab 中创建一个专门用于 E2E 测试的新数据库。

这可能看起来需要很多努力,但这是必要的。谁说端到端测试容易!实际上,即使我们进行手动测试,这个挑战也存在。对于一个真正的应用程序,我们总是必须为每个环境设置数据存储/数据库,无论是devtest还是production

在这种情况下,我们将继续使用现有的后端,但添加另一个我们将用于测试的锻炼。将此锻炼命名为1minworkout,并给它一个标题1 Minute Workout。添加两个练习到锻炼中:跳绳和墙坐。将每个练习的持续时间设置为 15 秒,休息时间为 1 秒。

我们故意将新的锻炼保持简短,这样我们就可以在 Protractor 提供的正常超时时间内完成此锻炼的端到端测试。

更多端到端测试

让我们回到测试首页上的锻炼搜索功能。随着1 Minute Workout的添加,我们现在有两个锻炼,我们可以对这些进行断言。

如果你已经在后端添加了其他锻炼,只需相应地调整此测试中的数字。

workout-runner.spec.ts中现有测试之后添加此测试:

it('should search workout with specific name.', () => {
    const filteredWorkouts = element.all(by.css('.workout.tile'));
    expect(filteredWorkouts.count()).toEqual(5);

    const searchInput = element(by.css('.form-control'));
    searchInput.sendKeys('1 Minute Workout');

    expect(filteredWorkouts.count()).toEqual(1);
    expect(filteredWorkouts.first().element(by.css('.title')).getText()).toBe('1 Minute Workout');
});

测试使用ElementFinderLocator API在页面上查找元素。检查测试的第二行。我们正在使用element.all函数和by.css定位器对屏幕上使用.workout.tileCSS 类的所有元素进行多元素匹配。这给我们一个锻炼列表,针对这个列表,下一行断言元素个数为 3。

测试随后使用element函数和by.css定位器获取搜索输入,以对使用.form-contolCSS 类的元素进行单元素匹配。然后我们使用sendKeys函数来模拟搜索输入中的数据输入。

最后两个期望操作检查列表中的元素数量,搜索后应为 1。它们还检查是否根据div标签使用titleCSS 类正确过滤了正确的锻炼,该标签是包含我们的锻炼的元素的子元素。这个最后的期望语句突出了我们可以如何链式过滤元素并获取 HTML 中的子元素。

我们应该添加与首页相关的一个附加测试。它测试从首页导航到锻炼运行器屏幕。为此测试添加以下代码:

it('should navigate to workout runner.', () => {
    const filteredWorkouts = element.all(by.css('.workout.tile'));
    filteredWorkouts.first().click();
    expect(browser.getCurrentUrl()).toContain('/workout/1minworkout');
}) 

此测试使用click函数来模拟点击锻炼瓷砖,然后我们使用browser.getCurrentUrl函数来确认导航是否正确。

再次运行测试(protractor tests/protractor.conf.js),再次观察浏览器自动化的魔力,因为测试一个接一个地运行。

我们能否自动化Workout Runner的端到端测试?嗯,我们可以试试。

测试 Workout Runner

测试 Workout Runner 的主要挑战之一是所有内容都是时间依赖的。在单元测试中,我们至少能够模拟间隔,但现在不行了。测试锻炼转换和工作完成确实很困难。

然而,在我们解决这个问题或尝试找到一个可接受的解决方案之前,让我们暂时偏离一下,来了解一下管理端到端测试的一个重要技术:页面对象!

使用页面对象来管理端到端测试

我们之前提到了页面对象的概念。页面对象的简单概念是将页面元素的表示封装到一个对象中,这样我们就不需要在端到端测试代码中充斥着ElementFinderlocators。如果任何页面元素移动,我们只需要修复页面对象。

这里是我们如何表示我们的 Workout Runner 页面:

import { browser, by, element } from 'protractor';

export class WorkoutRunnerPage {
  pauseResume: any;
  playButton: any;
  pauseButton: any;
  exerciseTitle: any;
  exerciseDescription: any;
  exerciseTimeRemaining; any;

  constructor() {
      this.pauseResume = element(by.id('pause-overlay'));
      this.playButton = element.all(by.css('.ion-md-play'));
      this.pauseButton = element.all(by.css('.ion-md-pause'));
      this.exerciseTitle = element(by.id('exercise-pane')).element(by.tagName('h1')).getText();
      this.exerciseDescription = element.all(by.className('card-text')).first().getText();
      this.exerciseTimeRemaining = element(by.id('exercise-pane')).all(by.tagName('h4')).first().getText();
  }
}

这个页面对象现在封装了我们想要测试的许多元素。通过在一个地方组织元素选择代码,我们提高了端到端测试的可读性和可维护性。

现在将 Workout Runner 页面对象添加到测试文件顶部。我们将在针对锻炼运行者的测试中使用它。添加以下新的 describe 块,包含我们的第一个锻炼运行者测试:

describe('Workout Runner page', () => {
    beforeEach(() => {
        browser.get('/workout/1minworkout');
    });

    it('should load workout data', () => {
        browser.waitForAngularEnabled(false);
        const page = new WorkoutRunnerPage();
        page.pauseResume.click();
        expect(page.exerciseTitle).toBe('Jumping Jacks');
        expect(page.exerciseDescription)
          .toBe('A jumping jack or star jump, also called side-straddle hop is a physical jumping exercise.');
    });

测试验证了锻炼已加载并且显示了正确的数据。我们充分利用了我们之前定义的页面对象。运行测试并验证它是否通过。

让我们回到基于intervaltimeout测试代码的挑战。让我们添加一个测试来确认当按下暂停按钮时屏幕上的点击事件:

it('should pause workout when paused button clicked', () => {
    const page = new WorkoutRunnerPage();
    let timeRemaining;
    browser.waitForAngularEnabled(false);

    page.pauseResume.click();
    expect(page.playButton.count()).toBe(1);
    expect(page.pauseButton.count()).toBe(0);

    page.exerciseTimeRemaining.then((time) => {
        timeRemaining = time;
        browser.sleep(3000);
    });
    page.exerciseTimeRemaining.then((time) => {
        expect(page.exerciseTimeRemaining).toBe(timeRemaining);
    });
});

这里有趣的是,我们在承诺中使用browser.sleep函数来验证在按钮点击前后剩余的练习时间是否相同。我们再次使用我们的WorkoutRunner页面对象来使测试更加可读和易懂。

现在是时候总结本章内容并总结我们的学习了。

摘要

我们不需要重复说明单元测试和端到端测试对任何应用程序的重要性。Angular 框架的设计方式使得测试 Angular 应用程序变得容易。在本章中,我们介绍了如何使用针对 Angular 的库和框架编写单元测试和端到端测试。

对于单元测试,我们使用 Jasmine 编写测试,并使用 Karma 执行它们。我们测试了来自Personal Trainer的管道、组件、服务和指令。在这个过程中,我们了解了测试这些类型的挑战和所使用的技巧。

对于端到端测试,我们选择的是 Protractor 框架。我们仍然使用 Jasmine 编写测试,但这次测试运行者是 Protractor。我们学习了如何使用 Selenium WebDriver 自动化端到端测试,就像我们为StartWorkout Runner页面进行了一些场景测试一样。

如果你已经到达这个阶段,你正越来越接近成为一名熟练的 Angular 开发者。下一章通过更多使用 Angular 构建的实用场景和实现来加强这一点。我们将在本书的最后一章涉及一些重要概念;这些包括多语言支持、身份验证和授权、通信模式、性能优化以及一些其他内容。你当然不希望错过它们!

第八章:一些实际场景

在掌握七个章节之后,你应该感觉相当不错。到目前为止你所学的都是直接来源于我们在前几章中构建的应用。我们相信你现在应该对框架、其工作原理以及它支持的内容有了足够的了解。有了这些知识,当我们开始构建一些规模较大的应用时,一些常见的问题/模式不可避免地会出现,例如这些:

  • 我们如何验证用户并控制其访问权限(授权)?

  • 我们如何确保应用性能足够好?

  • 我的应用需要本地化内容。我该怎么办?

  • 我可以使用哪些工具来加速应用开发?

  • 我有一个 AngularJS 应用。我该如何迁移它?

以及更多!

在本章中,我们将尝试解决这些常见场景,并提供一些可行的解决方案和/或指导性建议来处理这些用例。

本章我们将涵盖的主题包括:

  • Angular 种子项目:你将学习到一些 Angular 种子项目如何在开始新的项目时帮助我们。

  • 验证 Angular 应用:这是一个常见需求。我们来看如何在 Angular 中支持基于 cookie 和 token 的认证。

  • Angular 性能:在尝试详细说明是什么使 Angular 性能良好以及你可以做些什么来使你的应用更快时,一个常规的性能部分是必须的。

  • 将 AngularJS 应用迁移到 Angular:AngularJS 和 Angular 是完全不同的东西。在本章中,你将学习如何逐步将 AngularJS 应用迁移到 Angular。

让我们从一开始吧!

构建新的应用

想象一个场景:我们正在构建一个新的应用,鉴于 Angular 框架的超级强大,我们一致决定使用 Angular。太好了!接下来是什么?接下来是设置项目的平凡过程。

虽然这是一个平凡的活动,但它仍然是任何合作的关键部分。设置新项目通常涉及:

  • 创建标准文件夹结构。有时这会受到服务器框架(如RORASP.NetNode.js等)的影响。

  • 将标准资产添加到特定文件夹中。

  • 设置构建,如果我们正在开发基于 Angular 的 Web 应用,这包括:

    • 如果使用 TypeScript,则编译/转换内容

    • 配置模块加载器

    • 在框架和第三方组件方面的依赖管理

    • 设置单元/E2E 测试

  • 配置不同环境(如开发、测试和生产)的构建。这再次受到所涉及的服务器技术的影响。

  • 代码打包和压缩。

有很多工作要做。

如果我们可以缩短整体设置过程会怎样?这确实可能;我们只需要一个种子项目启动站点

种子项目

Angular CLI 作为构建和脚手架工具非常棒!但是,它并不是唯一的选择。有许多种子项目/入门网站可以在短时间内帮助我们开始。一些种子项目将框架与特定的后端集成,而另一些则仅规定/提供 Angular 特定的内容。一些预先配置了供应商特定的库/框架(如LESSSASSBootstrap等),而另一些则只提供纯 vanilla 设置。

一些值得探索的知名种子项目如下:

  • Angular Starter (bit.ly/ng-starter):这个种子仓库为那些希望快速开始使用 Angular 和 TypeScript 的人提供了一个 Angular 入门。它使用 Webpack(模块打包器)来构建我们的文件并帮助处理样板代码。它是一个完整的构建系统,具有大量的集成。

  • Angular Seed (bit.ly/ng-starter-seed):与 Angular Starter 类似的另一个种子项目。这个种子项目使用 gulp 进行构建自动化,其模块打包系统不如 Webpack 先进。

这些项目以及 Angular CLI 为使用 Angular 进行构建提供了良好的起点。

如果应用程序与特定的后端堆栈相关联,我们有两种选择,如下所示:

  • 使用这些种子项目之一,并手动将其与后端集成。

  • 找到一个为我们完成这项工作的种子项目/实现。有很大机会你会找到这样的种子项目。

Angular 性能

Angular 是以性能为设计理念的。框架的每个部分,从框架占用空间、初始加载时间、内存使用、变更检测加上数据绑定,到 DOM 渲染,都经过了调整或正在调整以实现更好的性能。

接下来的几节将致力于了解 Angular 的性能以及它用来实现一些令人印象深刻的性能提升的技巧。

字节大小

框架的字节大小是性能优化的良好起点。虽然世界正在向高速互联网发展,但我们中相当一部分人仍然在使用慢速连接,并通过手机连接到网络。我们可能不会太在意这里或那里的几 KB,但这确实很重要!

虽然 Angular 的默认字节大小比 AngularJS 大,但有一些技术可以大幅减少 Angular 打包的大小。

首先,标准的压缩和 gzip 压缩技术可以显著减少这个差距。并且,在 Angular 中,我们可以通过使用模块打包器/加载器来执行一些巧妙的技巧,进一步减少 Angular 打包的大小。

摇树可能是一个古怪的过程名称,但它确实做到了它所说的!当我们使用 TypeScript(或 ES2015)构建应用程序,包含 模块导出 时,模块打包器(如 Rollup (rollupjs.org))可以对这样的代码进行静态代码分析,确定代码中哪些部分从未使用过,并在打包发布前将其删除。这样的模块打包器,当添加到应用程序的构建过程中时,可以分析框架代码、任何第三方库和应用程序代码,在创建包之前删除任何死代码。摇树可以导致巨大的尺寸缩减,因为你不会打包你不使用的框架代码。

可以从框架包中移除的最大框架组件之一是 编译器。是的,你没听错,就是编译器!

对于好奇的读者,编译器是最大的框架组件,大约占 Angular 包大小的 50%(在 Angular v2.0.0 中)。

结合使用摇树和 提前编译AoT),我们可以完全去除浏览器中的 Angular 编译器。

使用 AoT 编译,视图模板(HTML)在服务器端预先编译。这次编译作为应用程序构建过程的一部分进行,其中服务器版本的 Angular 编译器(一个节点包)编译应用程序中的每个视图。

在所有模板编译完成后,就无需将 Angular 编译器代码发送到客户端。现在,摇树可以去除编译器,创建一个更精简的框架包。Angular CLI 支持 AoT 编译,可用于生产构建。

更多关于 AoT 的信息,请参阅框架文档,可在 bit.ly/ngx-aot 找到。

初始加载时间和内存使用

任何具有完整 JavaScript 框架的 Web 应用程序的初始加载时间通常较慢。在移动设备上,这种影响更为明显,因为 JavaScript 引擎可能没有桌面客户端强大。为了提供更好的用户体验,优化框架的初始加载时间变得至关重要,尤其是在移动设备上。

默认情况下,Angular 2 在初始加载时间和视图重渲染方面比 AngularJS 快五倍。随着 Angular 团队对框架的改进,这些数字正在变得更好。

此外,AoT 编译也可以提高应用程序的初始加载时间,因为不需要进行耗时活动(视图编译)。

对于内存使用,这也同样适用。Angular 在这里表现也更好,未来的版本将使事情变得更好。

如果你计划切换到 Angular,这是你应该期待的事情:一个为未来构建的高性能框架。

我们接下来要讨论的下一个性能改进是由于一个单一架构决策而成为可能的:创建一个单独的渲染层

Angular 渲染引擎

AngularJS 最大的缺点是框架绑定到浏览器 DOM 上。指令、绑定和插值都是针对 DOM 工作的。

使用 Angular,最大的架构变化是引入了一个独立的渲染层。现在,一个 Angular 应用有两个层:

  • 应用层:这是我们的代码所在的一层。它通过在渲染层之上构建的抽象来与它交互。我们在第六章“深入理解 Angular 指令”中看到的 Renderer 类是我们用来与渲染层交互的接口。

  • 渲染层:这一层负责将应用层的请求转换为渲染组件,并对用户输入和视图更新做出反应。

默认的渲染器实现是DomRenderer,它在浏览器内部运行。但还有其他渲染抽象,我们将在下一节中讨论。

服务器端渲染

服务器端预渲染是提高 Angular 应用初始加载时间的另一种技术。这项技术在移动设备上特别有帮助,因为它显著提高了感知的加载时间。

服务器端渲染负责在客户端渲染启动之前处理初始页面加载(并从此处理视图渲染)。

在这种情况下,当用户请求一个视图/页面时,服务器上的一块软件生成一个完全实例化的 HTML 页面,其中数据预先绑定到视图,并将其与一小段脚本一起发送到客户端。因此,应用视图可以立即渲染,准备交互。当框架在后台加载时,第一次发送的小脚本捕获所有用户输入,并使它们对框架可用,允许它在加载后重放这些交互。

Angular Universal,正如其名,允许在服务器和客户端上渲染和共享视图。

服务器端渲染之所以成为可能,是因为之前描述的渲染层的分离。初始视图由服务器上的渲染器实现生成,名为ServerDomRenderer。有一个 Node.js 插件(bit.ly/ng-universal-node),可以在多个 Node.js Web 框架中使用,如ExpressHapiSail等。

查看 Angular Universal 的设计文档(bit.ly/ng-universal-design)和设计文档顶部的嵌入 YouTube 视频,以了解更多关于服务器端渲染的信息。

服务器端渲染不仅带来了性能上的好处。实际上,搜索引擎索引器也喜欢预渲染的 HTML 内容。服务器端渲染在搜索引擎优化(SEO)和深度链接等领域非常有用,这允许轻松的内容共享。

将工作卸载到 Web Worker

将工作委托给web worker是一个很好的想法,这再次得益于渲染层与应用层之间的分离。

Web workers提供了一种在后台线程中运行脚本的方法。这些线程可以执行不涉及浏览器 DOM 的工作。无论是 CPU 密集型任务还是远程 XHR 调用,都可以委托给 web workers。

在当今世界,多核 CPU 已成为常态,但 JavaScript 执行仍然是单线程的。我们需要一个标准/机制来利用这些空闲核心为我们的应用程序服务。Web workers 完美地满足了这一需求,并且由于大多数现代浏览器都支持它们,我们都应该编写利用 web workers 的代码。

可惜,这种情况并没有发生。Web workers 仍然不是主流,这有很好的原因。Web workers 对允许和不允许的内容施加了许多限制。这些限制包括:

  • 无法直接访问 DOM:Web workers 不能直接操作 DOM。实际上,web workers 无法访问多个全局变量,如windowdocument,以及其他在 web worker 线程上不可用。这严重限制了 web worker 可以使用的用例数量。

  • 浏览器支持:Web workers 仅适用于现代/持续更新的浏览器(IE 10+)。

  • 进程间通信:Web workers 不与你的主浏览器进程共享内存,因此只能通过消息传递(序列化数据)与主线程(UI 线程)通信。此外,消息传递机制本质上是异步的,这给通信模型增加了另一层复杂性。

显然,web workers 难以使用。

Angular 试图通过将 web worker 的使用集成到框架本身中来减轻这些限制。它是通过在 web worker 线程中运行整个应用程序来实现的,除了渲染部分。

框架负责在 web worker 内部运行的应用代码与主 UI 线程内运行的渲染器之间的通信。从开发者的角度来看,没有明显的区别。

这同样得益于 Angular 中渲染层的分离。以下图表显示了在应用主线程上运行的层以及在内置 web worker 中运行的层:

通过观看 Jason Teplitz 的这次演讲(bit.ly/yt-ng-web-worker)来了解 web workers 能提供什么。

高效的移动体验

Angular 的渲染抽象再次开辟了许多集成途径,尤其是在移动平台上。而不是在移动浏览器上运行应用程序,Angular 渲染器可以被创建出来,可以调用设备的原生 UI 功能。

在这个领域有两个值得注意的项目:平台渲染器:

ReactNativeNativeScript这样的应用平台已经出色地提供了针对原生移动平台(iOS 和 Android)的基于 JavaScript 的 API,使我们能够使用熟悉的语言利用单一代码库。Angular 渲染器更进一步。通过 Angular 集成,大量代码可以在浏览器和移动设备之间共享。可能只有视图模板和与视图相关的服务(如对话框、弹出窗口等)有所不同。

请查看相应渲染器的文档,以了解它们的工作原理和支持的功能。

接下来,我们将讨论框架在变更检测方面的改进。

变更检测改进

在 Angular 相对于 AngularJS 的几个主要性能改进中,Angular 中变更检测的工作方式是一个亮点。Angular 的变更检测默认情况下非常快,并且可以进一步调整以获得更好的结果。

接下来的几节将深入讨论 Angular 的变更检测。在构建任何大规模应用时,这是一个重要的理解主题。它还有助于我们调试那些看起来变更检测没有按预期工作的场景。

让我们从了解什么是变更检测以及为什么它很重要开始讨论。

变更检测

Angular 的数据绑定引擎在将视图与模型数据(组件数据)绑定方面做得很好。这些是实时绑定,Angular 会保持视图与模型变化同步。每当模型发生变化时,绑定引擎都会重新渲染依赖于模型的视图部分。为了管理这种视图-模型同步,Angular 需要知道模型何时发生变化以及具体发生了什么变化。这就是变更检测的全部内容。在应用执行期间,Angular 会频繁地进行我们所说的变更检测运行,以确定发生了什么变化。

如果你来自 AngularJS,一个变更检测运行大致相当于消化周期,只不过在 Angular 中没有循环

虽然保持模型和视图同步的问题听起来很简单,但实际上是一个难题。与组件树不同,多个模型之间的相互连接可能很复杂。一个组件模型的变化可能会触发多个组件模型的变化。此外,这些相互连接可能存在循环。单个模型属性可能绑定到多个视图。所有这些复杂场景都需要使用强大的变更检测基础设施来管理。

在接下来的几节中,我们将探讨 Angular 变更检测基础设施的工作原理、变更检测何时触发,以及我们如何影响 Angular 中的变更检测行为。

变更检测设置

所有这一切都始于 Angular 为视图上渲染的每个组件设置变更检测器。由于每个 Angular 应用都是组件的层次结构,这些变更检测器也在相同的层次结构中设置。以下图表突出显示了在某个时间点Workout Builder应用的变更检测器层次结构

图片

附属于组件的变更检测器负责检测组件中的变更。它是通过解析组件模板 HTML 中的绑定并设置必要的变更检测监视来做到这一点的。

记住,检测器只为模板中使用的模型属性设置监视,而不是所有组件属性。

另一个值得强调的重要观点是,变更检测是从模型到视图单向设置的。Angular 没有双向数据绑定的概念,因此前面的图是一个没有环的有向树。这也使得变更检测更加可预测。不允许模型和视图更新交织。

变更检测何时启动?

Angular 是否始终检查模型数据中的变更?考虑到我们绑定的视图组件属性不继承任何特殊类,Angular 无法知道哪个属性发生了变化。Angular 的唯一出路是不断查询每个数据绑定属性以了解其当前值,并将其与旧值进行比较以检测变更。至少可以说,这是非常低效的!

Angular 的表现优于这一点,因为变更检测只在应用执行过程中的特定时间执行。仔细思考任何 Web 应用;是什么原因导致视图更新?

视图可以因为以下原因而更新:

  • 用户输入/浏览器事件:我们点击按钮、输入一些文本或滚动内容。这些操作中的每一个都可以更新视图(以及底层的模型)。

  • 远程 XHR 请求:这是视图更新的另一个常见原因。从远程服务器获取数据以显示在网格上,以及获取用户数据以渲染视图都是这种情况的例子。

  • setTimeout 和 setInterval 计时器:实际上,我们可以使用setTimeoutsetInterval来异步执行某些代码并在特定间隔执行。这样的代码也可以更新模型。例如,一个setInterval计时器可能会定期检查股票报价,并在 UI 上更新股票价格。

由于显而易见的原因,Angular 的变更检测也只有在以下任何条件发生时才会启动。

这里有趣的部分不是 Angular 的变更检测何时启动,而是 Angular 如何能够拦截所有浏览器事件XHR 请求以及setTimeoutsetInterval函数。

在 Angular 中完成这一壮举的是一个名为zone.js的库。正如文档所描述的:

"区域是一个在异步任务中持续存在的执行上下文。"

这个库的一项基本能力是它可以钩入一段代码,并在代码执行开始和结束时触发回调。被监控的代码可能是一系列既同步又异步的调用。考虑以下示例,它突出了使用情况:

let zone = new NgZone({ enableLongStackTrace: false });     
let doWork = function () { 
  console.log('Working'); 
}; 

zone.onMicrotaskEmpty.subscribe((data:any) => { 
  console.log("Done!"); 
}); 

zone.run(() => { 
  doWork(); 
    setTimeout(() => { 
        console.log('Hard'); 
        doWork(); 
    }, 200); 
    doWork(); 
}); 

我们将一段代码包裹在zone.run调用中。这段代码同步地调用doWork函数两次,并在两次调用之间插入一个setTimeout调用,该调用在 200 毫秒后再次调用相同的函数。

通过将这个序列包裹在zone.run中,我们可以知道调用执行何时完成。在区域术语中,这些是轮次zone.run之前的代码设置了一个订阅者,当执行完成时会被调用,使用zone.onMicrotaskEmpty函数:

如果我们执行前面的代码,日志看起来如下:

Working  // sync call 
Working  // sync call 
Done!   // main execution complete  
Hard     // timeout callback 
Working  // async call 
Done!   // async execution complete

onMicrotaskEmpty订阅执行了两次,一次是在顺序执行完成后(定义在run回调中),另一次是在异步setTimeout执行完成后。

Angular 的变更检测使用相同的技巧在区域中执行我们的代码。这段代码可能是一个事件处理器,它在完成之前内部进行更多的同步和异步调用,或者它可能是一个setTimeout/setInterval操作,可能还需要更新 UI。

Angular 变更检测框架订阅执行区域的onMicrotaskEmpty可观察对象,并在每次轮次完成后启动变更检测。以下图示突出了当运行类似前面描述的代码在按钮点击时发生的情况:

在代码块执行过程中,如果区域库确定该调用是异步的,它将产生一个新的微任务,该任务有自己的生命周期。这些微任务的完成也会触发onMicrotaskEmpty

如果你想知道 Angular 内部变更检测触发器是如何工作的,以下是 Angular 源代码的摘录(进一步简化):

class ApplicationRef { 

  constructor(private zone: NgZone) { 
    this._zone.onMicrotaskEmpty.subscribe(
        {next: () => { this._zone.run(() => { this.tick(); }); }}); 
  } 

  tick() { 
    this._views.forEach((view) => view.detectChanges());
  } 
} 

ApplicationRef类跟踪整个应用程序中附加的所有变更检测器,并在应用程序级别的区域对象触发onMicrotaskEmpty事件时触发变更检测周期。我们很快就会涉及到这个变更检测期间发生的事情。

Zone.js 通过覆盖默认浏览器 API 获得了跟踪任何异步调用执行上下文的能力。这种覆盖,也称为猴子补丁,覆盖了事件订阅XHR 请求以及setTimeout/setInterval API。在前面突出显示的例子中,我们调用的setTimeout是原始浏览器 API 的猴子补丁版本。

现在我们知道了变更检测器的设置方式和何时启动这项活动,我们可以看看它是如何工作的。

如何进行变更检测?

一旦变化检测器设置完成并且浏览器 API 被猴子补丁以触发变化检测,真正的变化检测就开始了。这是一个相当简单的过程。

一旦任何异步事件回调被触发(事件处理器的执行也是一种异步活动),Angular 首先执行我们附加到回调的应用程序代码。这段代码的执行可能会导致一些模型更新。在回调执行之后,Angular 需要通过触发一个 变化检测运行 来响应这些变化。

在变化检测运行中,从组件树的顶部开始,每个变化检测器都会评估其相应组件的模板绑定,以查看绑定表达式的值是否已更改。

关于这个执行,我们需要强调以下几点:

  • Angular 使用严格的相等性检查(使用 ===)来检测变化。由于它不是深度比较,对于引用对象的绑定,Angular 只在对象引用发生变化时更新视图。

  • 变化检测流程是单向的(从根开始),以自顶向下的方式从父到子。父组件上的检测器在子组件检测器之前运行。

默认情况下,变化检测算法遍历整个树,而不管变化是在树的哪个位置触发的。这意味着每次变化检测运行时都会评估所有的绑定:

每次运行都进行绑定评估可能看起来效率不高,但实际上并非如此。Angular 采用一些高级优化来使这个检查非常快。尽管如此,如果我们想调整这种行为,我们确实有一些开关可以减少执行的检查次数。我们很快就会涉及到这个话题。

  • 变化检测器只跟踪模板绑定中的属性,而不是完整的对象/组件属性。

  • 为了检测绑定值的更改,变化检测器需要跟踪在上次变化检测运行期间评估的表达式的上一个值。显然,对于每个我们使用的模板绑定,都需要进行一些记录。

显然,接下来的问题会是当检测到变化(由变化检测器检测)时会发生什么?

由于设置变化检测和识别更改的所有艰苦工作已经完成,这一步只是涉及更新组件状态和同步组件 DOM。

这里还有一些值得强调的观察结果:

  • 首要的是,Angular 将模型更新步骤与 DOM 更新步骤分离。考虑以下代码片段,它在有人点击按钮时被调用:
        doWork() { 
           this.firstName="David"; 
           this.lastName="Ruiz"; 
        } 

假设 firstNamelastName 都绑定到组件视图,对 firstName 的更改不会立即更新 DOM 绑定。相反,Angular 会等待 doWork 函数完成,然后触发变化检测运行和 DOM 更新。

  • 其次,一个变更检测运行不会(也不应该)更新模型状态。这避免了任何循环和级联更新。变更检测运行只负责评估绑定和更新视图。这也意味着我们不应该在变更检测期间更新模型状态。如果我们更新模型,Angular 会抛出一个错误。

让我们看看这个行为的例子:

  1. 打开 7 分钟健身应用中的 start.component.html,并将最后一个 div 更新为:
            <div class="col-sm-3"> 
            Change detection done {{changeDetectionDone()}}
            </div> 
  1. 然后,在组件实现(start.component.ts)中添加一个 changeDetectionDone 函数,其看起来如下:
            times: number = 0; 
            changeDetectionDone(): number { 
                this.times++; 
                return this.times; 
            } 
  1. 运行应用,加载起始页面,然后查看浏览器控制台。Angular 记录了许多看起来如下所示的错误:
        EXCEPTION: Expression has changed after it was checked.
        Previous value: 'Change 
        detection done 1'. Current value: 'Change detection done 2' ... 

当调用 changeDetectionDone 函数(在一个插值内)时,我们正在改变组件的状态,Angular 会抛出一个错误,因为它没有预料到组件状态会更新。

这种变更检测行为仅在 Angular 的生产模式被禁用时启用。生产模式可以通过在启动应用之前调用 enableProdMode() 函数来启用(在 bootstrap.ts 中)。启用后,Angular 的行为会有所不同。它会关闭框架内的断言和其他检查。生产模式也会影响变更检测行为。在非生产模式下,Angular 会遍历组件树两次以检测变更。如果在第二次遍历中任何绑定表达式已更改,它会抛出一个错误。相比之下,在生产模式下,变更检测树遍历只执行一次。我们在控制台上看到的变更检测错误如果启用生产模式将不会显示。这可能导致模型和视图状态之间不一致。这是我们应意识到的事情!底线是,我们不能在变更检测进行时改变组件的状态。一个直接的推论是:如果我们在一个绑定表达式中使用函数,函数执行应该是无状态的,没有副作用。

  • 最后,这个从根节点到叶节点的变更检测遍历在变更检测运行期间只执行一次。

对于有 AngularJS 背景的人来说,这是一个令人愉快的惊喜!Angular 中的消化周期计数为 1。Angular 开发者永远不会遇到“消化迭代超出异常”!一个性能更优的变更检测系统!

变更检测性能

让我们谈谈变更检测性能。如果你认为每次检查整个组件树以查找变更是不高效的,你将会惊讶地知道它是多么快。由于一些关于表达式评估和比较的优化,Angular 可以在几毫秒内执行数千次检查。

在底层,对于每个涉及视图绑定的表达式,Angular 都会生成一个专门针对特定绑定的变更检测函数。虽然一开始可能看起来有些反直觉,但 Angular 并没有用于确定表达式是否已更改的通用函数。相反,它就像为每个我们绑定的属性编写我们自己的变更检测函数。这允许 JavaScript VM 优化代码,从而提高性能。

想了解更多关于它的信息?查看维克多·萨金(Victor Savkin)的这段视频:youtu.be/jvKGQSFQf10

尽管进行了所有这些优化,但仍可能存在一些情况,遍历完整的组件树可能不足以高效。这尤其在我们需要在视图中渲染大量数据集并保持绑定完整时更为明显。好消息是,Angular 的变更检测机制可以进行微调。

Angular 需要遍历整个树的原因是,一个地方的模式变化可能会在其他地方触发模式变化。换句话说,模式变化可能产生级联效应,相互连接的模式对象也会被更新。由于 Angular 无法知道确切发生了什么变化,它检查完整的组件树和相关模式。

如果我们可以帮助 Angular 确定应用程序状态中哪些部分被更新,Angular 就可以非常智能地决定遍历组件树以检测更改的部分。我们通过将应用程序数据存储在某些特殊的数据结构中来实现这一点,这些数据结构帮助 Angular 决定哪些组件需要检查更改。

我们有三种方法可以使 Angular 的变更检测更智能。

使用不可变数据结构

不可变对象/集合是创建后不能更改的对象。任何属性更改都会导致创建一个新的对象。这是 immutable.js,一个流行的不可变数据结构库,对此所说的话:

不可变数据一旦创建就不能更改,这导致应用程序开发更加简单,无需进行防御性复制,并允许使用简单的逻辑实现高级记忆化和变更检测技术。

让我们通过一个例子来尝试理解不可变数据结构如何在 Angular 上下文中提供帮助。

假设我们正在构建一组组件来收集人力资源(HR)软件的员工信息。员工组件视图看起来可能像这样:

<Employee> 
    <summary [model]="employee"></employee> 
    <personal [model]="employee.personal"></personal> 
    <professional [model]="employee.professional"></professional> 
    <address [model]="employee.home"></address> 
    <address [model]="employee.work"></address> 
</Employee> 

它有用于收集个人、专业和地址信息的部分。summary 组件提供了正在输入的员工数据的只读视图。每个组件都有一个名为 model 的属性,突出显示这些组件操作员工数据的哪些部分。这些组件的摘要、专业、个人和地址内部可能还有其他子组件。这就是组件树的外观:

当我们更新员工的个人信息时会发生什么?使用标准对象(可变)时,Angular 无法对数据的形状和变化做出任何假设;因此,它执行完整的树遍历。

不可变性在这里是如何帮助的?当使用不可变数据结构时,对对象属性任何的更改都会导致创建一个新的对象。例如,如果我们使用流行的库Immutable.js创建不可变对象:

personalInfo = Immutable.Map({ name: 'David', 'age': '40' }); 

personalInfonameage属性的变化会创建一个新的对象:

newPersonalInfo = personalInfo.set('name', 'Dan'); 

如果每个员工模型属性(personalprofessionalhomework)都是不可变的,这种不可变性将非常有用。

PersonalInfo组件定义为例,它绑定到个人信息数据:

 @Component({ 
  selector:'personal', 
  template: ` 
    <h2>{{model.name}}</h2> 
    <span>{{model.age}}</span>`, 
 changeDetection: ChangeDetectionStrategy.OnPush 
}) 
class PersonalInfo { 
  @Input() model; 
} 

由于PersonalInfo仅依赖于model属性,且model属性绑定到一个不可变对象,因此 Angular 只有在model引用发生变化时才需要进行变更检查。否则,可以跳过完整的PersonalInfo组件子树。

通过将PersonalInfo组件的changeDetection属性设置为ChangeDetectionStrategy.OnPush,我们指示 Angular 仅在组件的输入属性发生变化时触发变更检测。

如果我们将 Employee 组件子代中的每个组件的变更检测策略更改为OnPush并更新员工的个人信息,则只有PersonalInfo组件子树会检查变更:

对于大型组件树,这种优化将大大提高应用/视图性能。

当设置为OnPush时,Angular 仅在组件的输入属性发生变化或组件内部或其子代中引发事件时触发变更检测。

使用不可变数据结构开发应用程序与完全可变的传统应用程序状态开发范式不同。本节中我们强调的是 Angular 如何利用不可变数据结构来优化变更检测过程。

可观察对象是另一种可以帮助我们优化 Angular 变更检测的数据结构。

使用可观察对象

可观察对象是当其内部状态发生变化时触发事件的 数据结构。Angular 广泛使用可观察对象来将组件的内部状态与外部世界进行通信。

尽管我们使用了可观察的输出属性(EventEmitter类)来触发事件,但输入属性也可以接受可观察对象。这种可观察的输入可以帮助 Angular 优化变更检测。

当使用可观察对象时,变更检测开关仍然保持为ChangeDetectionStrategy.OnPush。但这次,只有当组件输入触发事件(因为它们是可观察的)时,Angular 才会执行脏检查。当输入触发事件时,从受影响的组件到根组件的完整组件树路径将被标记为待验证。

在执行视图更新时,Angular 只会同步受影响的路径,并忽略树中的其余部分。

手动更改检测

实际上,我们可以完全禁用组件上的更改检测,并在需要时手动触发更改检测。要禁用更改检测,我们只需将特定于组件的更改检测器(ChangeDetectorRef类实例)注入到组件中,并调用detach函数:

constructor(private ref: ChangeDetectorRef) { 
    ref.detach(); 
} 

现在,责任在我们身上,我们需要通知 Angular 何时应该检查组件的更改。

我们可以通过在ChangeDetectorRef上使用reattach函数将组件重新附加到更改检测树。

我们很少需要禁用标准更改检测设置,除非有情况使得标准更改检测变得代价高昂。

以一个公共聊天室应用程序为例,它正在接收来自连接到它的数千人的消息。如果我们不断拉取消息并刷新 DOM,应用程序可能会变得无响应。在这种情况下,我们可以禁用聊天应用程序组件树的部分更改检测,并手动在特定间隔触发更改检测以更新 UI。

虽然我们已经看到了三种调整更改检测行为的方法,但好事是这些方法并不是互斥的。组件树的部分可以使用不可变数据结构,部分可以使用 Observables,部分可以采用手动更改检测,其余部分仍然可以使用默认的更改检测。Angular 会欣然从命!

关于更改检测就先说这么多。除非我们正在构建一些具有嘈杂 UI 的大型视图,否则我们可能永远不需要它。这些场景要求我们从更改检测系统中挤出每一分性能,系统已经为此做好了准备。

接下来,我们将探讨大多数应用程序不可避免地具有的另一个常见要求:认证用户。

处理认证和授权

大多数,如果不是所有应用程序,都有对用户进行认证/授权的要求。我们可能会认为认证和授权更多地是服务器的问题,而不是客户端的问题,这是正确的。尽管如此,客户端仍需要适应并与服务器强加的认证和授权要求集成。

在典型的应用程序执行工作流程中,应用程序首先加载一些部分视图,然后调用服务器以获取数据,最后将数据绑定到其视图上。显然,视图远程数据 API是两种需要被保护的资源。

为了保护这些资源,我们需要了解典型应用程序在服务器上的安全保护方式。主要有两种广泛的方法来保护任何 Web 应用程序:基于 cookie 的认证基于令牌的认证。每种方法在客户端部分都需要不同的处理。接下来的两个部分将描述我们如何集成这两种方法之一。

基于 cookie 的认证

如果服务器堆栈支持它,这种身份验证机制是最容易实现的。它不会干扰应用,并且可能只需要对 Angular 应用进行最小程度的修改。基于 Cookie 的身份验证涉及设置浏览器 Cookie 以跟踪用户身份验证会话。以下序列图解释了一个典型的基于 Cookie 的身份验证工作流程:

图片

下面是一个典型的身份验证工作流程是如何工作的:

  • 当尝试从浏览器访问受保护资源时,如果用户未认证,服务器会发送 HTTP 401 未授权状态码。如果请求中没有附加 Cookie 或 Cookie 已过期/无效,则用户请求是一个未经授权的请求。

  • 这种未经授权的响应被服务器拦截,有时甚至被客户端框架(在我们的案例中是 Angular)拦截,通常会导致 302 重定向(如果由服务器拦截)。重定向位置是登录页面的 URL(登录页面允许匿名访问)。

  • 然后,用户在登录页面上输入用户名和密码,并对登录端点进行 POST 请求。

  • 服务器验证凭据,设置浏览器 Cookie,并将用户重定向到最初请求的资源。

  • 从此以后,身份验证 Cookie 成为每个请求的一部分(由浏览器自动添加),服务器使用此 Cookie 来确认其身份以及用户是否已认证。

如我们所见,使用这种方法,Angular 基础设施不参与,或者参与度很小。甚至登录页面也可以是一个标准的 HTML 页面,它只是将数据发送到登录端点进行身份验证。如果用户进入 Angular 应用,这隐含地意味着用户已经通过认证。

基于 Cookie 的身份验证流程可能因服务器框架而异,但设置 Cookie 并在随后的每个请求中附加 Cookie 的一般模式保持不变。

在基于 Cookie 的应用程序身份验证中,如果应用程序想要获取用户上下文,则会暴露一个服务器端点(例如 /user/details),该端点返回已登录用户的特定数据。然后客户端应用程序可以实现一个服务,如 UserService,用于加载和缓存用户配置文件数据。

这里描述的场景假设 API 服务器(返回数据的服务器)和应用程序托管网站位于单个域中。这并不总是情况。即使是 Personal Trainer,数据也驻留在 MongoLab 服务器上,而应用程序驻留在不同的服务器上(即使它是本地的)。我们已经知道这是一个跨域访问,并且它带来了一组自己的挑战。

在这种设置中,即使 API 服务器能够认证请求并将 Cookie 发送回客户端,客户端应用程序在随后的请求中仍然不会发送身份验证 Cookie。

为了解决这个问题,我们需要在每个 XHR 请求中将变量withCredentials设置为true。这可以通过覆盖BaseRequestOptionswithCredentials属性)在全局级别启用。框架使用BaseRequestOptions类为每个 HTTP 请求设置默认值。

这也可以通过在每个 HTTP 请求方法中将withCredentials:true标志作为最后一个参数传递到每个请求级别来启用:

this.httpService.get(url,{withCredentials:true}); 

每个 HTTP 函数的最后一个参数,包括getpostput,都是一个选项对象。这允许我们覆盖正在进行的请求的选项。

一旦启用此标志,客户端浏览器将开始为跨域请求附加认证 Cookie。

服务器也需要启用跨源资源共享CORS),并且需要以特定的方式响应才能使请求成功。它应该将access-control-allow-credentials头部设置为 true,并将access-control-allow-origin头部设置为发出请求的主机站点。

查看 MDN 文档(bit.ly/http-cors)以详细了解此场景。

基于 Cookie 的认证在客户端确实减少了工作量,但有时您不得不回退到基于令牌的访问。这可能是因为:

  • Cookie 和跨域请求在浏览器中表现不佳。特别是,IE8 和 IE9 不支持它们。

  • 服务器可能不支持生成 Cookie,或者服务器只公开基于令牌的认证。

  • 基于令牌的解决方案很容易与原生移动应用程序和桌面客户端集成。

  • 令牌不易受到跨站请求伪造(CSRF)攻击。

要了解更多关于 CSRF 的信息,请查看bit.ly/csrf-cs的 CRSF 预防 cheat sheet。

下一节将讨论支持基于令牌的认证。

基于令牌的认证

基于令牌的访问完全是关于在每个请求中发送一个令牌(通常在 HTTP 头部),而不是一个 Cookie。简化的基于令牌的工作流程看起来大致如下:

许多公共 API(如FacebookTwitter)使用基于令牌的认证。令牌的格式、位置以及如何生成取决于所使用的协议和服务器实现。使用基于令牌的认证的流行服务实现了用于令牌生成和交换的OAuth 2.0协议。

在典型的基于令牌的认证设置中,视图是公开的,但 API 是受保护的。如果应用程序尝试通过不将适当的令牌附加到发出的请求的 API 调用来获取数据,服务器将返回一个HTTP 401 未授权状态码。

在 Angular 中启用 token 认证是一项相当多的工作。它包括设置登录页面/组件、守卫条件、认证服务和跨应用共享的认证上下文。如果你开始这段旅程,确保寻找使这种集成更简单的 Angular 库/模块。ngx-auth库(bit.ly/ngx-auth)可以是一个好的起点。

这就是关于认证的所有内容,但关于授权呢?一旦建立了用户上下文,我们仍然需要确保用户只能访问他/她被允许访问的部分。授权仍然缺失。

处理授权

与认证一样,授权支持也需要在服务器和客户端上实现,尤其是在服务器端。记住,任何人都可以破解 JavaScript 代码并绕过完整的认证/授权设置。所以,无论客户端是否设置了必要的检查,都要始终加强你的服务器基础设施。

这并不意味着我们在客户端不做任何授权检查。对于标准用户来说,这是防止未经授权访问的第一道防线。

在为任何应用程序设置授权需求时,有三个基本元素是设置的一部分:

  • 需要受到保护/授权的资源

  • 属于这些角色的角色和用户列表

  • 资源与定义谁可以访问什么的角色之间的映射

从 Angular 应用程序的角度来看,资源是需要限制到特定角色的页面和,有时,页面的部分。如果用户属于特定角色,根据角色-资源映射,他们可以访问某些页面;否则,他们将无法访问。

虽然在 Angular 应用程序中授权可以通过多种方式实现,但我们将概述一个通用的实现,该实现可以进一步定制以满足未来的需求。

添加授权支持

要启用授权,我们首先需要做的是在整个应用程序中公开登录用户数据,包括他的/她的角色。

共享用户认证上下文

可以使用 Angular 服务共享用户上下文,然后可以将该服务注入到需要授权上下文的组件中。看看这个服务接口:

class SessionContext { 
  currentUser():User { ... }; 
  isUserInRole(roles:Array<string>):boolean { ...}; 
  isAuthenticated:boolean; 
} 

SessionContext服务跟踪用户登录会话并提供如下详细信息:

  • 登录用户(currentUser

  • 用户是否已认证(isAuthenticated

  • isUserInRole函数,根据用户是否是传递给roles参数的任何角色的成员,返回truefalse

在这样的服务到位后,我们可以为路由添加授权,从而仅将某些路由的访问限制到特定角色。

限制路由

就像认证一样,canActivate守卫检查也可以用于授权。实现一个具有CanActivate接口的类,并在构造函数中注入SessionContext服务;然后,使用SessionContext服务在canActivate函数中检查用户是否属于特定角色。查看以下代码片段:

export class AuthGuard implements CanActivate { 
  constructor(private session:SessionContext) { } 
  canActivate() { 
    return this.session.isAuthenticated &amp;&amp;  
      session.isUserInRole(['Contributor', 'Admin']); 
  } 
} 

现在,只有具有贡献者管理员角色的用户才能访问具有此守卫条件的路由。

但当页面有基于用户角色的渲染视图元素时会发生什么?

根据角色条件渲染内容

条件渲染内容易于实现。我们只需根据用户角色显示/隐藏 HTML 元素。我们可以构建一个如ng-if这样的结构指令,在渲染内容之前验证用户是否属于该角色。指令的使用方式如下:

<div id='header'> 
    <div> Welcome, {{userName}}</div> 
    <div><a href='#/setting/my'>Settings</a></div> 
    <div *a2beRolesAllowed='["admin"])'> 
        <a href='#/setting/site'>Site Settings</a> 
    </div> 
</div> 

上述代码在渲染网站设置超链接之前检查用户是否处于管理员角色。

指令实现模仿了ng-if的工作方式,但我们的显示/隐藏逻辑依赖于SessionContext服务。以下是a2beRolesAllowed指令的一个示例实现:

@Directive({ selector: '[a2beRolesAllowed]' }) 
export class RolesAllowedDirective { 
  private _prevCondition: boolean = null; 
  constructor(private _viewContainer: ViewContainerRef, 
    private _templateRef: TemplateRef, private SessionContext _session) { } 

  @Input() set a2beRolesAllowed(roles: Array<string>) { 
    if (this._session.isUserInRole(roles)) { 
      this._viewContainer 
        .createEmbeddedView(this._templateRef); 
    } 
    else { 
      this._viewContainer.clear(); 
    } 
  } 
} 

这是一个使用SessionContext和作为输入传递的角色(a2beRolesAllowed)来显示/隐藏片段的简单实现。

这就带我们来到了认证和授权实现的终点。参考实现指南应该能帮助我们将认证和授权集成到我们的应用中。有了这个基本理解,任何设置都可以进行调整以处理其他自定义认证/授权场景。

现在是时候解决房间里的大象了:从AngularJS迁移到Angular。如果你是全新开始使用 Angular,你可以很好地跳过下一节。

从 Angular 的迁移指南中可以看出:"Angular 是今天和明天的 Angular。AngularJS 是所有 v1.x 版本的 Angular 的名称。"

迁移 AngularJS 应用

如果你一直在大量使用 AngularJS,Angular 会提出一些相关的问题:

  • 我应该将我的旧 AngularJS 应用迁移到最新的 Angular 版本吗?

  • 迁移应该在什么时候进行?

  • 迁移是一次性的还是可以分步骤进行?

  • 需要付出的努力有哪些?

  • 我今天能做些什么来帮助未来的迁移?

  • 我今天开始一个新的 AngularJS 应用。为了将来迁移顺利,我应该做什么?

每个这样的查询都需要得到解决,以确保过渡尽可能平滑。没有人喜欢在游戏中出现意外!在接下来的章节中,我们将尝试回答许多此类问题。作为学习的一部分,我们还将向您展示如何将(为本书的第一版开发的)Trainer应用的 AngularJS 版本迁移到 Angular。这将帮助每个人在何时以及如何迁移到 Angular 方面做出一些明智的决定。

“我应该迁移还是不迁移”是我们首先需要解决的问题。

我应该迁移吗?

只因 Angular 存在并不意味着 AngularJS 已经消失。AngularJS 仍在开发中,尽管其速度不如 Angular。Google 仍然承诺在相当长的时间内支持 AngularJS。AngularJS 团队目前正在开发 1.7 版本,并计划在 2018 年 6 月之前发布。之后,1.7 将进入 长期支持LTS)期,届时重点将仅限于修复错误。我们可以从他们的博客文章中了解更多信息,请参阅bit.ly/ng1-lte-support

显然,AngularJS 将继续得到支持,因此迁移不应成为主要担忧。迁移到 Angular 的决定可以根据 Angular 相比前辈所提供的优势来做出。

Angular 的优势

Angular 是面向未来的,并克服了其前辈的许多缺点。在本节中,我们强调是什么让 Angular 比 AngularJS 成为一个更好的框架。

在决定迁移到 Angular 时,您应该注意的事项:

  • 更好的行为封装:诚然,当我们刚开始学习 AngularJS 时,AngularJS 的 scopes 似乎是从天而降的神物,但我们现在已经意识到管理 scopes 层级性质是多么困难。基于组件的开发在 Angular 中提供了更好的应用状态封装。组件管理自己的状态,接收输入,并触发事件;这是一个责任划分清晰、易于推理的明确界限!

  • 应用代码中框架的减少:您不需要像 scope 这样的特殊对象。依赖注入(DI)与注解(在 TypeScript 中)一起工作。您不需要设置观察者。总的来说,当阅读组件代码时,您不会在其中找到框架级别的结构。

  • 更小的框架 API 需要探索:AngularJS 有许多指令需要了解。随着 Angular 模板语法的引入,与浏览器事件相关的指令已经消失。这减少了需要了解的指令数量。

  • 性能:与前辈相比,Angular 的性能更快。本书的一个完整章节都致力于理解是什么让 Angular 成为一个高性能框架。

  • 移动友好:Angular 通过利用服务器端渲染和 Web Workers 等技术来优化用户的移动体验。Angular 在移动设备上的应用程序性能优于其前辈。

  • 跨平台:Angular 面向在大多数设备和平台上运行。您可以使用 Angular 来构建 Web 和移动应用程序。正如我们之前所学的,渲染层的分离为 Angular 可以被利用的地方开辟了大量的可能性。

在真正意义上,Angular 取代了其前辈,在理想的世界里,每个人都应该致力于更好的框架/技术。但迁移从来都不容易,尤其是在这种两个框架差异很大的情况下。

我们推荐的是,即使你今天不打算迁移到 Angular,也要以允许将来易于迁移到 Angular 的方式构建你的 AngularJS 应用。

下一节将讨论今天应遵循的原则和实践,以便将来易于迁移。

为了便于迁移而开发 AngularJS 应用

新的 Angular 是一个范式转变,我们在 Angular 中开发组件的方式与 AngularJS 大不相同。为了便于迁移,Angular 也应该采用基于组件的开发方式。如果我们遵循一些在构建 AngularJS 应用时的指南/原则,就可以实现这一点。接下来的几节将详细说明这些指南。

即使你计划不迁移到 Angular,这里列出的建议也非常推荐。这些推荐将使 AngularJS 代码更加模块化、组织化,并且易于测试。

每个文件一个组件

这可以是任何东西:AngularJS 的控制器指令过滤器服务。每个文件一个组件允许更好的代码组织,并便于迁移,使我们能够清楚地识别已经取得了多少进展。

避免使用内联匿名函数

使用命名函数来声明控制器、指令、过滤器和服务。如下所示的一个声明:

angular.module('7minWorkout') 
  .controller('WorkoutController',[...]) 

angular.module('app') 
.directive('remoteValidator', [...]) 

angular.module('7minWorkout') 
.filter('secondsToTime', function () { ... } 

angular.module('7minWorkout') 
.factory('workoutHistoryTracker', [...]) 

应该转换为以下形式:

function WorkoutController($scope, ...) { ... } 
WorkoutController.$inject = ['$scope', ...]; 

function remoteValidator($parse) {...} 
remoteValidator.$inject=[$parse]; 

function secondsToTime() {...} 

function workoutHistoryTracker($rootScope, ...) { ...} 
workoutHistoryTracker.$inject = ['$rootScope',...]; 

使用命名函数的优点是易于调试和易于迁移到 TypeScript。使用命名函数还要求使用$inject函数属性注册依赖。

基于$inject的依赖声明可以防止压缩并增加函数的可读性。

为了避免使用这种方法暴露全局命名函数,建议将函数包裹在一个立即调用的函数表达式IIFE)中:

(function() { 
    function WorkoutController($scope, ...) { ... } 
    WorkoutController.$inject = ['$scope', ...]; 

    angular 
        .module('7minWorkout') 
        .controller('WorkoutController', WorkoutController); 

})(); 

避免使用$scope

是的,你没看错;避免使用$scope/$rootScope对象或直接使用作用域!

AngularJS 作用域的最大问题是它们的层次结构。从子作用域访问父作用域为我们提供了巨大的灵活性,但这也带来了代价。这可能会无意中创建不必要的依赖,使得应用难以调试和迁移。相比之下,在 Angular 中,视图绑定到其组件实现,并且不能隐式地访问其边界之外的数据。因此,如果你计划迁移到 Angular,请务必避免使用作用域

有许多技术可以用来移除对$scope对象的依赖。接下来的几小节将详细阐述一些可以帮助我们避免 AngularJS 作用域的技术。

在所有地方使用控制器作为(控制器别名)语法

AngularJS 1.3+提供了控制器 as语法,用于控制器指令路由控制器 as语法允许 AngularJS 数据绑定表达式针对控制器实例属性而不是当前作用域的对象属性进行操作。在控制器 as 范式下,我们无需直接与作用域交互,因此未来的迁移变得容易。

虽然控制器别名消除了作用域访问,但在 AngularJS 中作用域仍然存在。完整的 AngularJS 数据绑定基础设施依赖于作用域。控制器别名只是在我们的代码和作用域访问之间添加了一个间接层。

考虑以下语法在视图中使用控制器 as

<div ng-controller="WorkoutListController as workoutList"> 
   <a ng-repeat="workout in workoutList.workouts"  href="#/workout/{{workout.name}}"> 
</div> 

以及相应的控制器实现:

function WorkoutListController($scope, ...) { 
  this.workouts=[]; 
} 

WorkoutListController作为workoutList在当前作用域上为WorkoutListController创建了一个别名workoutList,因此允许我们绑定到控制器上定义的workouts属性。

路由定义同样允许使用路由定义对象中的controllerAs属性进行控制器别名设置:

$routeProvider.when('/builder/workouts', { 
... 
   controller: 'WorkoutListController', 
 controllerAs: 'workoutList' 
 }); 

最后,指令也可以使用controllerAs,结合指令定义对象上的bindToController属性,我们可以消除任何直接的作用域访问。

查看 Angular 文档中的控制器、路由和指令部分,以获得对控制器 as 语法的初步理解。同时,查看以下帖子以获取更多关于此主题的详细示例:bit.ly/ng1-controller-asbit.ly/ng1-bind-to

避免使用 ng-controller

如果可以避免作用域,那么也可以避免控制器!

这可能看起来有些反直觉,但这种方法确实有实际的好处。我们理想的做法是在 AngularJS 中模拟组件行为。由于 AngularJS 中最接近组件的是元素指令(具有restrict='E'),我们应该在所有地方使用元素指令

一个具有自己的模板和独立作用域的 AngularJS 元素指令可以很好地表现得像一个 Angular 组件,并且只依赖于其内部状态进行视图绑定。我们只需要ng-controller

考虑在个人教练应用的 AngularJS 版本中使用ng-controller进行音频跟踪:

<div id="exercise-pane" class="col-sm-7"> 
... 
  <span ng-controller="WorkoutAudioController"> 
    <audio media-player="ticksAudio" loop autoplay src="img/tick10s.mp3"></audio> 
    <audio media-player="nextUpAudio"  src="img/nextup.mp3"></audio> 
    ... 
  </span>

而不是使用WorkoutAudioController,一个元素指令可以封装锻炼音频的视图和行为。这样的指令可以替换完整的ng-controller声明及其视图:

<div id="exercise-pane" class="col-sm-7"> 
... 
<workout-audio-component></workout-audio-component> 

当用元素指令替换ng-controller时,控制器所依赖的作用域变量应通过指令定义对象上的bindToController属性传递给指令——类似于以下这样:

bindToController: { 
   name: '=', 
   title: '&amp;' 
} 

这个主题在 Tero 的以下两篇博客文章中得到了广泛讨论:bit.ly/ng2-no-controllersbit.ly/ng2-refactor-to-component。必读的帖子,信息量丰富!

使用 AngularJS 1.5+ 组件 API 构建

AngularJS 1.5+ 有一个 组件 API,允许我们创建可以轻松迁移到 Angular 的指令。组件 API 预配置了合理的默认值,因此在构建真正隔离和可重用的指令时,它包含了最佳实践。

查看组件 API 在 bit.ly/ng1-dev-guide-components,以及 Tod Motto 在 bit.ly/1MahwNs 发表的这篇信息丰富的帖子,以了解组件 API。

再次强调之前已经强调过的,这些步骤不仅针对简单的 Angular 迁移,而且也针对使 AngularJS 代码更加优秀。基于组件的 UI 开发比我们习惯的 AngularJS 更好的范式。

我们强烈建议您阅读 AngularJS 风格指南(bit.ly/ng1-style-guide)。这份指南包含了许多技巧/模式,使我们能够构建更好的 AngularJS 应用程序,并且与之前提供的易于 Angular 迁移的指南保持一致。

最后,如果我们已经决定迁移,那么是时候决定要迁移什么了。

要迁移什么?

对于处于维护模式的应用程序,其中大部分开发活动都围绕着错误修复和一些增强,坚持使用 AngularJS 是明智的。记住那句老话:如果它没有坏,就不要修理它

如果应用程序正在积极开发,并且有一个明确的长期路线图,那么考虑迁移到 Angular 是值得的。随着我们深入了解迁移的复杂性,我们将意识到这个过程所涉及的时间和精力。虽然 Angular 团队已经非常努力地使这次迁移变得顺利,但无论如何想象,这都不是一个简单的工作。实际的迁移将需要相当多的时间和精力。

这里的亮点是我们不需要一次性迁移所有内容。我们可以慢慢地将 AngularJS 代码库的部分迁移到 Angular。这两个框架可以共存,并且可以相互依赖。这也允许我们在 Angular 中开发应用程序的新部分。这有多酷?

但再次强调,这种灵活性是有代价的——字节代价。由于两个框架都会被下载,页面字节会增加,这是我们应当注意的。

此外,尽管这两个框架共存使我们能够在很大程度上避免中断进行迁移,但我们不能让它成为一种永久的活动。最终,AngularJS 必须退出,越早越好。

在迁移过程中,能做的事情最好的是,在现有应用程序中划分出新的单页应用(SPA)。例如,我们可以完全使用 Angular 构建应用程序的 Admin 区域,有一个独立的主页,但如果我们稍微重构代码,仍然可以共享样式表、图像甚至 AngularJS 服务的基础设施。正如我们稍后将学习的,将服务迁移到 Angular 是最简单的。

将应用程序分解成多个较小的应用程序会引入全页刷新,但在迁移方面这是一个更干净的方法。

考虑到所有这些,如果我们已经决定迁移并确定了迁移区域,你需要为迁移做准备工作。

准备迁移到 Angular

欢迎来到 Angular 迁移的广阔勇敢的世界!一个成功的迁移策略涉及确保我们在事先做好基础工作,避免任何意外的惊喜。

作为准备工作,第一步是从第三方库依赖的角度分析应用程序。

识别第三方依赖

任何 AngularJS 应用程序使用的第三方库都需要一个迁移策略。这些库可能是基于 jQuery 的库或者 AngularJS 库。

jQuery 库

在 AngularJS 中,jQuery 库是通过在其上创建指令包装器来使用的。我们将不得不将这些指令迁移到 Angular。

AngularJS 库

迁移 AngularJS 库是一件有点棘手的事情。AngularJS 有一个庞大的生态系统,Angular 也已经存在一段时间,现在有一个健康的社区。在迁移时,每个 AngularJS 库都需要用 Angular 的替代品来替换。

如果我们没有找到特定库的完美升级路径,我们可以选择以下之一:

  • 定制现有的类似组件/库

  • 采取更激进的从零开始构建自己的 Angular 库的方法

在时间和复杂性方面,每个选择都有其权衡。

另一个需要做出的选择是开发语言。我们应该使用 TypeScript、ES2015 还是传统的 JavaScript(ES5)?

语言选择

我们肯定会推荐 TypeScript。这是一种非常棒的语言,它与 Angular 集成得非常好,大大减少了 Angular 声明的冗长性。此外,由于它可以与 JavaScript 共存,这使我们的工作变得更简单。即使没有 Angular,TypeScript 也是我们应该拥抱的 Web 平台的一种语言。

在接下来的章节中,我们将把 AngularJS 个人教练应用程序迁移到 Angular。该应用程序目前在GitHub上可用,地址为bit.ly/a1begit。这个应用程序是本书第一版 AngularJS by Example 的一部分,并使用 JavaScript 构建。

我们将再次采用基于检查点的迁移方法。在迁移过程中,我们强调的检查点已经实现为 GitHub 分支。由于我们将与 v1 代码的 Git 仓库交互,并使用 Node.js 工具进行构建,请在继续之前在您的开发机器上设置 Git 和 Node.js。

迁移 AngularJS 的 Personal Trainer

在我们开始迁移过程之前,我们需要在本地设置 v1 个人教练

已迁移应用程序的代码可以从 GitHub 网站下载,网址为 github.com/chandermani/angularjsbyexample。由于我们是分块迁移的,因此我们创建了多个检查点,这些检查点映射到专门用于迁移的 GitHub 分支。例如 ng6-checkpoint8.1ng6-checkpoint8.2 等分支突出了这一进展。在叙述过程中,我们将突出显示这些分支以供参考。这些分支将包含到那时为止在应用程序上所做的所有工作。

7 分钟锻炼 代码位于名为 trainer 的仓库文件夹内。

那么,让我们开始吧!

在本地设置 AngularJS 的 Personal Trainer

按照以下步骤操作,您将很快就能启动并运行:

  1. 从命令行,克隆 v1 GitHub 仓库:
    git clone https://github.com/chandermani/angularjsbyexample.git
  1. 导航到新的 Git 仓库并检出 ng6-base 分支以开始:
    cd angularjsbyexample
    git checkout ng6-base
  1. 由于应用程序从托管在 mLab (mlab.com/) 上的 MongoDB 加载锻炼数据,因此您需要一个 mLab 账户来托管与锻炼相关的数据。通过注册他们来设置 mLab 账户。一旦您有了 mLab 账户,您需要从 mLab 的管理门户中检索您的 API 密钥。按照 API 文档中提供的说明 (bit.ly/mlab-docs) 获取您的 API 密钥。

  2. 一旦您有了 API 密钥,请更新 app/js/config.js 中的这一行,使用您的 API 密钥:

   ApiKeyAppenderInterceptorProvider.setApiKey("<yourapikey>"); 
  1. 将一些种子锻炼数据添加到您的 mLab 实例中。将种子数据添加到 mLab 的说明可在源代码文件 app/js/seed.js 中找到。

  2. 接下来,安装 v1 个人教练 所需的必要 npm 包

    cd trainer/app
    npm install
  1. 安装 http-server;它将作为我们的 v1 应用程序的开发服务器:
npm i http-server -g

通过从 app 文件夹启动 http-server 来验证设置是否完成:

    http-server -c-1

并打开浏览器位置 http://localhost:8080

v1 个人教练 的起始页面应该会出现。玩一下这个应用程序以验证应用程序运行正常。现在,迁移可以开始了。

识别依赖项

在我们开始迁移 v1 个人教练 之前的第一步是确定我们在 AngularJS 版本的 Personal Trainer 中使用的外部库。

我们在 v1 中使用的库有:

  • angular-media-player

  • angular-local-storage

  • angular-translate

  • angular-ui-bootstrap

  • owl. carousel

像这样的库 angular-media-playerangular-local-storage 容易迁移/替换。我们已经在本书的早期章节中完成了这项工作。

angular-translate 库可以被 ngx-translate 替换,正如我们将在接下来的章节中看到的,这不是一个很有挑战性的任务。

我们在 Personal Trainer v1 中使用 angular-ui-bootstrap 进行 模态对话框。我们用 ngx-modialog (bit.ly/ngx-modialog) 替换它,因为我们只使用了 angular-ui-bootstrap 中的对话框控件。

现在我们已经整理好了外部依赖,让我们决定要使用的语言。

虽然现有的代码库是 JavaScript,但我们更喜欢 TypeScript。它的类型安全、简洁的语法以及与 Angular 的良好兼容性使其成为我们的首选语言。因此,我们将全程使用 TypeScript。

另一个使决策倾向于 TypeScript 的因素是,我们不需要将现有代码库迁移到 TypeScript。我们迁移/构建的任何新内容都将使用 TypeScript 构建。遗留代码仍然保持在 JavaScript 中。

让我们开始吧。作为一个迁移任务的第一步,我们需要为我们的 v1 个人教练设置一个模块加载器。

设置模块加载器

由于我们将创建许多新的 Angular 组件,分布在众多小文件中,直接添加脚本引用将会很繁琐且容易出错。Angular CLI 在这里也不是很有帮助,因为它无法管理用 JavaScript 实现的现有代码库。

我们需要一个 模块加载器。一个模块加载器(ES6 模块,而不是 Angular)可以帮助我们:

  • 基于某些常见的模块格式创建隔离/可重用模块

  • 根据依赖关系管理脚本加载顺序

  • 允许模块的捆绑/打包以及开发/生产部署时的按需加载

我们使用 SystemJS 模块加载器进行这次迁移。

使用以下命令从命令行安装 SystemJS:

npm i systemjs --save

所有命令都需要从 trainer/app 文件夹中执行。

我们打开 index.html 并移除我们应用脚本的全部脚本引用。所有以 src='js/*.*' 为源的脚本引用都应该被移除,除了 angular-media-player.jsangular-local-storage.js,因为它们是外部库。

注意:我们不会移除第三方库的脚本引用,但只会移除应用文件。

在所有第三方脚本引用之后添加 SystemJS 配置:

<script src="img/angular-local-storage.js"</script> 
<script src="img/system.src.js">
</script>
<script>
 System.config({ packages: {'js': {defaultExtension: 'js'}}});
 System.import('js/app.js');
</script>

body 标签上移除 ng-app 属性,保持 ng-controller 声明不变:

<body ng-controller="RootController">

当我们切换到 angular.bootstrap 函数进行手动引导时,ng-app 的引导方式必须放弃。手动引导在将 Angular 引入其中时非常有帮助。

前面的 SystemJS.import 调用通过加载 js/app.js 中定义的第一个应用模块(JavaScript)来加载应用程序。我们很快就会定义这个 JavaScript 模块。

在与 app.js 相同的文件夹中创建一个名为 app.module.js 的新文件,并将 app.js 的全部内容复制到 app.module.js 中。

记得移除 use strict 语句。TypeScript 编译器不喜欢它。

所有应用模块定义现在都在 app.module.js 中。

接下来,清空 app.js 并添加以下导入和引导代码:

import  './app.module.js'; 
import  './config.js'; 
import  './root.js'; 
import './shared/directives.js'; 
import './shared/model.js'; 
import './shared/services.js'; 
import './7MinWorkout/services.js'; 
import './7MinWorkout/directives.js'; 
import './7MinWorkout/filters.js'; 
import './7MinWorkout/workout.js'; 
import './7MinWorkout/workoutvideos.js'; 
import './WorkoutBuilder/services.js'; 
import './WorkoutBuilder/directives.js'; 
import './WorkoutBuilder/exercise.js'; 
import './WorkoutBuilder/workout.js'; 

import * as angular from "angular";

angular.element(document).ready(function() { 
  angular.bootstrap(document.body, ['app'],  
{ strictDi: true }); 
});

我们已经在 app.js 中添加了 ES6 导入语句。这些脚本与之前在 index.html 中引用的脚本相同。SystemJS 现在在加载 app.js 时会加载这些脚本文件。

将所有的 AngularJS 模块声明移动到一个新的文件app.module.js中,并首先将其导入到app.js中,确保在执行任何import语句之前定义了 AngularJS 模块。

不要混淆使用angular.module('name')定义/访问的ES6 模块AngularJS 模块。这两个概念完全不同。

最后几行使用angular.bootstrap函数引导 AngularJS 应用程序。

模块加载现在已启用;让我们也启用 TypeScript。

启用 TypeScript

要启用 TypeScript,使用npm安装 TypeScript 编译器:

npm i typescript -g

接下来,打开package.json并在脚本配置中添加以下行:

"scripts": { 
    "test": "echo "Error: no test specified" &amp;&amp; exit 1" 
 "tsc": "tsc -p . -w"
  } 

我们刚刚添加的新脚本属性为常用命令提供了快捷方式。

要启用 JavaScript 库的 IDE IntelliSense,我们需要安装它们的类型定义。类型定义typings是定义 TypeScript/JavaScript 库公共接口的文件。这些类型定义帮助 IDE 为库函数提供 IntelliSense。大多数流行的 JavaScript 库和用 TypeScript 编写的框架/库都有类型定义。

让我们安装我们使用的库的类型定义。从命令行执行:

npm install @types/angular @types/angular-route @types/angular-resource @types/angular-mocks  --save-dev

接下来,将overrides.d.tscheckpoint8.1复制到本地的app文件夹。这有助于 TypeScript 编译器处理在app.js和其他地方使用的全局angular对象。

我们现在需要为 TypeScript 编译器设置一些配置。在trainer/app文件夹中创建一个名为tsconfig.json的文件,并从ng6-checkpoint8.1仓库分支复制配置(也远程可用于bit.ly/ng6be-8-1-tsconfig)。使用以下命令运行编译器:

npm run tsc

这应该会启动 TypeScript 编译器,并且不应该报告任何错误。

在开发期间,始终在单独的控制台窗口中运行此命令。编译器将连续监视 TypeScript 文件的变化,并在检测到变化时重新构建代码。

app.jsapp.module.js文件的扩展名更改为app.tsapp.module.ts。TypeScript 编译器会检测这些更改并编译 TypeScript 文件。编译后,编译器为每个 TypeScript 文件生成两个文件。一个是编译后的 JavaScript 文件(如app.js),另一个是用于调试的映射文件(app.js.map)。

我们没有为这个练习设置复杂的构建,因为我们的主要重点是迁移。对于您自己的应用程序,初始设置步骤可能因构建的设置方式而异。

在我们测试新的更改之前,config.js需要修复,因为我们已经通过以下方式在 AngularJS 中启用了严格的 DI 检查:

  angular.bootstrap(document.body, ['app'],  { strictDi: true });

config.js 的内容替换为在 ng6-checkpoint8.1bit.ly/ng6be-8-1-configjs 中可用的更新内容(并记得再次设置 API 密钥)。这次更新修复了 config 函数,使其更适合压缩。是时候测试应用了!

确保 TypeScript 编译器在一个控制台中运行;在新控制台窗口中运行 http-server -c-1

导航到 http://localhost:8080 应用启动页面应该加载。

提交/保存你的本地更改。

如果一切顺利,你甚至可以将你的本地更改提交到你的 git 仓库。这将帮助我们跟踪随着时间的推移应用迁移过程中发生了哪些变化。到这一点的实现可以在 ng6-checkpoint8.1 GitHub 分支中找到。如果你遇到问题,比较 ng6-baseng6-checkpoint8.1 分支以了解所做的更改。由于代码托管在 GitHub 上,我们可以使用 GitHub compare 接口来比较单个分支的提交。请参阅如何操作的文档:bit.ly/github-comparebit.ly/ng6be-compare-base-8-1 中的链接显示了 ng6-baseng6-checkpoint8.1 之间的比较。你可以忽略作为 TypeScript 编译部分生成的 app.jsapp.module.js 的 diff 视图。

是时候介绍 Angular 了!

添加 Angular

我们首先安装 Angular 和应用依赖的 npm 模块。首先,我们将更新 package.json 文件中的必要包。

bit.ly/ng6be-8-2-package-json 复制更新的包文件到你的本地安装。

package.json 现在引用了一些与 Angular 相关的新包。通过调用以下命令安装引用的包:

npm install

如果你使用 npm install 安装包时遇到问题,删除 node_modules 文件夹并再次运行 npm install

然后,在 index.htmlsystem.src.js 脚本引用之前添加一些 Angular 依赖的库引用(不使用 SystemJS 加载),总共两个:

<script src="img/shim.min.js"></script> <script src="img/zone.js"></script><script src="img/system.src.js"></script> 

目前,SystemJS 配置已经在 index.html 文件中设置。由于 Angular 需要相当数量的配置,我们将创建一个单独的 SystemJS 配置文件,并在 index.html 中引用它。

system.src.js 引用之后添加此脚本引用:

<script src="img/systemjs.config.js"></script> 

现在,清除包含调用 System.config 函数的 script 部分,并用以下内容替换:

<script>System.import('app');</script> 

bit.ly/ng6be-8-2-system-js-config 复制 systemjs.config.js 并将其放置在 package.json 相同的文件夹中。

此外,更新 tsconfig.json 并在 compilerOptions 中添加一个名为 moduleResolution 的新属性:

  "removeComments": false, 
 "moduleResolution": "node"

这指示 TypeScript 在node_modules文件夹中查找类型定义。记住,Angular 类型定义捆绑在 Angular 库本身中,因此不需要单独的类型定义导入。

现在已经添加了 Angular 特定的引用,我们需要修改现有的引导过程,以便也加载 Angular。

The ngUpgrade Library

为了支持从 AngularJS 到 Angular 的逐步迁移,Angular 团队发布了一个库,ngUpgrade。该库包含一组服务,允许 AngularJS 和 Angular 同时加载并良好协作。此库包含以下服务可以帮助:

  • 引导同时加载 AngularJS 和 Angular 框架的应用程序。这是我们即将要做的事情。

  • 在 AngularJS 视图中包含一个 Angular 组件。

  • 在 Angular 视图中包含一个 AngularJS 组件,尽管有一些限制。

  • 在框架之间共享依赖项。

该库中的主要工具是 UpgradeModule。正如平台文档总结的那样:

"这是一个包含用于引导和管理支持 Angular 和 AngularJS 代码的混合应用程序的实用工具的模块。"

随着我们迁移工作的进展,UpgradeModule的作用变得更加清晰。

让我们学习如何使用UpgradeModule引导混合的 AngularJS 和 Angular 应用程序。

引导混合应用程序

要引导混合应用程序,我们必须引导应用程序的 Angular 和 AngularJS 部分。这个过程涉及以下步骤:

  1. 引导 Angular 应用程序

  2. 然后,使用UpgradeModule引导 AngularJS 应用程序

由于刚刚添加了 Angular,我们需要定义 Angular 的根应用程序模块。

创建一个新文件,app-ng1.module.js,并将app.module.ts的完整内容复制到新文件中。我们将使用app.module.ts文件来定义 Angular 模块,因此现有的 AngularJS 模块已被转移到新文件中。

还要记得根据更改更新app.ts中的import语句:

import  './app-ng1.module.js'; 

现在我们将 Angular 根模块定义添加到app.module.ts中。

app.module.ts的内容替换为 Angular 模块定义。从ng6-checkpoint8.2(GitHub 位置:bit.ly/ng6be-8-2-app-module-ts)复制新的定义。

AppModule实现定义了一个函数,ngDoBootstrap

constructor(private upgrade: UpgradeModule) { }
ngDoBootstrap() {
    this.upgrade.bootstrap(document.documentElement, ['app']);
}

Angular 框架在应用程序引导过程中调用此函数。此函数内部使用UpgradeModulebootstrap函数来引导 AngularJS 应用程序。此函数接受与angular.bootstrap函数相同的参数。

虽然我们已经定义了 Angular 的根模块,但我们还没有定义 Angular 应用程序的入口点。在 app 文件夹中创建一个新的文件,名为main.ts,并添加以下代码:

import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app.module';
import './app';
platformBrowserDynamic().bootstrapModule(AppModule);

当这段代码执行时,它指示 Angular 框架使用 AppModule 启动应用。语句 import './app' 有助于加载 app.ts 中的所有脚本引用。在继续之前,请记住从 app.ts(完整的 angular.element 块)中删除 AngularJS 应用启动代码。

刷新你的应用并确保它像以前一样工作。同时请注意 TypeScript 编译器控制台窗口或浏览器控制台日志中的错误。

恭喜!我们现在有一个混合应用正在运行。两个框架现在协同工作。

如果你在升级到 Angular 时遇到问题,请查看 ng6-checkpoint8.2 分支。再次提醒,你也可以比较 git 分支 ng6-checkpoint8.1ng6-checkpoint8.2 来了解发生了哪些变化 (bit.ly/ng6be-compare-8-1-8-2)。

迁移过程现在可以开始了。我们可以从将 AngularJS 视图/指令的一部分迁移到 Angular 开始。

将 Angular 组件注入到 AngularJS 视图中

最常见的迁移模式是将低级别的 AngularJS 指令/视图迁移到 Angular 组件。如果我们将 AngularJS HTML 视图结构可视化为指令树,我们则从叶子开始。我们将指令/视图的一部分迁移到 Angular 组件,然后将组件嵌入到 AngularJS 视图模板中。这个 Angular 组件作为 元素指令 注入到 AngularJS 视图中。

AngularJS 中最接近 Angular 组件 的是 element directives。在迁移过程中,我们要么迁移元素指令,要么迁移控制器-视图对。

这是一种自下而上的方法,将视图/指令迁移到 Angular 组件。以下图表突出了 AngularJS 视图层次结构如何逐渐转换为 Angular 组件树:

让我们迁移一些小东西,感受一下事情是如何工作的。ExerciseNavController 及其相应的视图符合要求。

将第一个视图迁移到 Angular 组件

ExerciseNavControllerWorkout Builder 的一部分,位于 trainer/app/js/WorkoutBuilder/exercise.js 内。相应的视图由 trainer/app/partials/workoutbuilder/left-nav-exercises.html 提供。

这对控制器-视图的主要目的是在构建锻炼时显示可用的练习列表(可用用户路径 http://localhost:8080/#/builder/workouts/new):

点击任何这些练习名称都会将其添加到正在构建的锻炼中。

让我们从创建前一个视图的组件开始。

在开始新的组件之前,向应用中添加一个新的 Workout Builder 模块(WorkoutBuilderModule)。从 WorkoutBuilder 文件夹中的 ng6-checkpoint8.3 复制模块定义(GitHub 位置:bit.ly/ng6be-8-3-workout-builder-module-ts)。此外,在 app.module.ts 中导入新创建的模块。

我们建议不要在这里内联完整的代码,而是从 GitHub 分支 ng6-checkpoint8.3bit.ly/ng6be-8-3-exercise-nav-component-ts)复制 exercise-nav.component.ts 文件,并将其添加到本地的 WorkoutBuilder 文件夹中。该文件已在 WorkoutBuilderModule 中被引用。我们还把 ExerciseNavComponent 添加到 entryComponents 数组中,因为该组件将从 AngularJS 模块直接使用。

从实现的角度来看,让我们看看一些相关的部分。

对比模板语法 Angular 团队发布了一份优秀的参考文档(bit.ly/ng2-a1-a2-quickref),详细介绍了 AngularJS 中的常见视图语法及其在 Angular 中的等效语法。在迁移 AngularJS 应用时强烈推荐!

首先,如果你查看 exercise-nav.component.ts 文件,组件模板与 AngularJS 中使用的 left-nav-exercises.html 类似,只是没有 ng-controller,并且模板绑定是基于 Angular 的:

template: `<div id="left-nav-exercises"> 
           <h4>Exercises</h4> 
           <div *ngFor="let exercise of exercises" class="row"> 
           ... 

           </div>` 

如果我们关注组件实现(ExercisesNavComponent),第一眼令人印象深刻的是组件的依赖项:

constructor(  
@Inject('WorkoutService') private workoutService: any,  
@Inject('WorkoutBuilderService') private workoutBuilderService: any) 

WorkoutServiceWorkoutBuilderService 是注入到 Angular 组件中的 AngularJS 服务。

太棒了!如果你有这样的初始反应,我们不会责怪你。我们能够轻松地将 AngularJS 服务注入到 Angular 中的便利性真的很酷!但故事还没有结束。这里并没有发生魔法。除非告诉 Angular 去哪里寻找,否则 Angular 无法访问 AngularJS 服务。为了帮助 Angular,我们需要为 AngularJS 服务创建 工厂提供者 包装器。

将 AngularJS 依赖注入到 Angular 中

当我们允许 AngularJS 服务在 Angular 中使用时,我们是在 升级 该服务。这样做的方式是通过在现有的 AngularJS 服务上创建一个 工厂提供者包装器,并将其注册到一个 Angular 模块或组件中。

让我们为上一节中使用的两个 AngularJS 服务,WorkoutServiceWorkoutBuilderService,创建包装器。

可以使用 Angular 的依赖注入 API 创建 AngularJS 服务的工厂提供者。以下是一个 WorkoutService 的工厂提供者示例:

export function workoutServiceFactory(injector: any) {
 return injector.get('WorkoutService');
}

export const workoutServiceProvider = {
 provide: 'WorkoutService',
 useFactory: workoutServiceFactory,
 deps: ['$injector']
};

在此代码中,$injector 是 AngularJS 的 注入服务,我们在 Angular 执行上下文中引用了这个注入器。在前一个工厂函数中对 injector.get('WorkoutService'); 的调用从 AngularJS 领域检索了该服务。

创建的提供者可以随后注册到应用模块中:

providers:[workoutServiceProvider]

这种方法的缺点是我们必须为每个服务定义工厂函数(workoutServiceFactory)和提供者(workoutServiceProvider)。这太多了!

相反,我们可以创建一个通用的工厂提供者和工厂函数实现,它可以接受任何 AngularJS 服务,并在 Angular 中以相同的名称(字符串标记)注册它。我们已经完成了艰苦的工作,并创建了一个新的类,UpgradeHelperService。从ng6-checkpoint8.3的代码库中下载它(bit.ly/ng6be-upgrade-helper-service-ts)。

实现提供了一个函数,upgradeService,它接受一个参数,即 AngularJS 服务的名称,并返回一个工厂提供者实例。工厂提供者实现内部使用一个字符串标记来注册依赖项。要为 AngularJS 服务创建一个工厂提供者,我们只需调用:

UpgradeHelperService.upgradeService('WorkoutService')

服务依赖项有时还有其他依赖项,因此最好一次性将所有 AngularJS 服务依赖项引入。在app.module.ts中将所有现有的 AngularJS 依赖项注册为工厂提供者:

providers: [
        UpgradeHelperService.upgradeService('ExercisePlan'),
        UpgradeHelperService.upgradeService('WorkoutPlan'),
        UpgradeHelperService.upgradeService('WorkoutService'),
        UpgradeHelperService.upgradeService('WorkoutBuilderService'),
        UpgradeHelperService.upgradeService('ExerciseBuilderService'),
        UpgradeHelperService.upgradeService('ApiKeyAppenderInterceptor'),
        UpgradeHelperService.upgradeService('appEvents'),
        UpgradeHelperService.upgradeService('workoutHistoryTracker'),
    ]

回到组件集成!由于新的ExercisesNavComponent是在 AngularJS 视图中渲染的,因此它需要注册为AngularJS 指令

注册 Angular 组件为指令

使用ngUpgrade库函数downgradeComponent可以将ExercisesNavComponent转换为 AngularJS 指令。正如函数名所暗示的,我们正在将 Angular 组件降级为 AngularJS 元素指令。

打开app.ts并添加高亮行:

import {ExercisesNavComponent} from './WorkoutBuilder/exercise-nav-component' 
import { downgradeComponent } from '@angular/upgrade/static';
...
angular.module('WorkoutBuilder')
    .directive('exerciseNav', downgradeComponent({ component: ExercisesNavComponent }) as angular.IDirectiveFactory);

downgradeComponent函数返回一个包含指令定义对象工厂函数。我们将组件注册为 AngularJS 指令,exerciseNav

每个 Angular 组件在 AngularJS 中使用时都会注册为元素指令

组件实现已完成。我们现在需要清理旧代码并将新指令注入到视图中。

exercise.js中删除ExercisesNavController的定义。

left-nav-exercises.html(位于partials文件夹中)的内容替换为:

<exercise-nav></exercise-nav>

然后,我们就可以开始了。

注意,我们没有删除left-nav-exercises.html,因为 AngularJS 仍然将left-nav-exercises.html作为路由转换的一部分加载,但其中的视图是一个 Angular 组件。

好吧,尝试一下新的实现。创建一个新的锻炼,并尝试从左侧导航中添加练习。功能应该和以前一样。

如果你在升级到 Angular 时遇到问题,请查看ng6-checkpoint8.3。你可以比较 git 分支ng6-checkpoint8.2ng6-checkpoint8.3来了解发生了什么变化(bit.ly/ng6be-compare-8-2-8-3)。

尽管我们只迁移了一个微不足道的组件,但这个练习突出了将 Angular 组件转换为 AngularJS 指令并用于 AngularJS 视图是多么容易。Angular 组件的整体封装使得这项工作变得容易。

这个降级组件甚至可以使用熟悉的 Angular 属性绑定语法从父作用域获取输入:

<exercise-nav [exercises]='vm.exercises'></exercise-nav> 

此外,组件引发的事件也可以由 AngularJS 容器作用域订阅:

<exercise-nav (onExerciseClicked)='vm.add(exercise)'></exercise-nav> 

现在我们有一个 Angular 组件在 AngularJS 中运行,使用的是最初为 AngularJS 设计的服务。这是我们迁移之旅的一个有希望的起点!

在我们继续前进之前,是时候强调这种协作是如何工作的以及参与规则了。

参与规则

从 AngularJS 迁移到 Angular 的迁移故事之所以可能,仅仅是因为这些框架可以共存,并且可能共享数据。有一些接触点,边界可以跨越。为了更好地理解混合应用程序的工作原理以及在这种设置中可以实现什么,我们需要了解两个框架之间的协作区域。

有三个方面需要讨论:

  • DOM 中的模板交错

  • 依赖注入

  • 变更检测

由于 Angular 组件和 AngularJS 指令可以在 DOM 中共存,我们需要回答的问题是 DOM 的哪些部分属于谁?

AngularJS 指令和 Angular 组件

当涉及到 DOM 元素的拥有权时,黄金规则是:

每个 DOM 元素恰好由 Angular 框架中的一个拥有/管理。

以我们之前的迁移示例为例。ExercisesNavComponent的一部分视图由 Angular 管理,而容器视图(left-nav-exercises.html)则由 AngularJS 管理。

在这些指令和组件的边界处,事情会变得有些棘手。考虑left-nav-exercises.html内部的声明:

<exercise-nav></exercise-nav> 

这属于谁?简短的答案是 AngularJS。

虽然这是一个 Angular 组件,但宿主元素属于 AngularJS。这意味着所有 AngularJS 模板语法都适用:

<exercise-nav ng-if='showNav'></exercise-nav> 
<exercise-nav ng-repeat='item in items'></exercise-nav> 

由于这些组件和指令在同一个视图中共存,它们通常需要通信。有两种方式来管理这种通信:

  • 使用 AngularJS 和 Angular 的模板功能:

    • 一个嵌入在 AngularJS 视图中的 Angular 组件可以使用事件和属性绑定从父作用域获取输入

    • 以类似的方式,如果指令被注入到 Angular 组件视图中,它也可以从父组件获取输入并调用父组件函数(通过其隔离作用域)

  • 使用共享服务;我们之前已经看到了一个例子,我们将WorkoutServiceWorkoutBuilderService AngularJS 服务注入到ExercisesNavComponent

将 AngularJS 指令注入到 Angular 中有些棘手。为了能够将 AngularJS 指令注入到 Angular 模板中,该指令需要遵守一些规则。我们将在接下来的章节中讨论这些规则。

使用服务共享功能比通过视图模板共享要灵活得多。在框架边界之间注入服务需要我们在两个框架中注册该服务,并让 Angular 处理其余部分。让我们学习如何跨边界进行依赖注入。

资源共享和依赖注入

在混合应用中,依赖是如何注册的由这两个框架中的 DI(依赖注入)工作方式驱动。对于 AngularJS,只有一个全局注入器,而 Angular 有一个分层注入器的概念。在混合环境中,两个框架都支持的最低共同点是全局注入器。

共享 AngularJS 服务

就像我们在之前的工厂提供者示例中看到的那样,AngularJS 服务可以通过创建包装工厂提供者来注册到 Angular 中。

由于 AngularJS 中的依赖注入基于字符串标记,相应的提供者也使用字符串标记在 Angular 中定位依赖项。

回顾一下之前提到的依赖注册共享示例,依赖是通过一个辅助类来注册的:

UpgradeHelperService.upgradeService('WorkoutService') 

它是通过Inject装饰器(带字符串标记)注入的:

constructor(@Inject('WorkoutService') private workoutService: any...

共享 Angular 服务

来自 Angular 的服务也可以注入到 AngularJS 中。由于 AngularJS 只有一个全局注入器,依赖在全局级别注册。ngUpgrade库有一个downgradeInjectable函数用于此。downgradeInjectable函数创建一个可以被 AngularJS 模块的factory API 消费的工厂函数:

angular.module('app').factory('MyService',  
  downgradeInjectable(MyService)) 

MyService现在可以像任何其他服务一样注入到 AngularJS 应用中。

看看下面的图表;它总结了我们所讨论的内容:

图片

本讨论的最后一个主题是变更检测。

变更检测

在混合应用中,变更检测由 Angular 管理。如果我们习惯在代码中调用$scope.$apply(),那么在混合应用中我们就不需要这样做。

我们已经讨论了 Angular 的变更检测是如何工作的。Angular 框架通过在标准触发点上内部调用$rootScope.$apply()来处理 AngularJS 的变更检测。

现在我们已经了解了参与规则,理解事物是如何运作的、什么是可行的以及什么不可行就变得容易多了。

让我们设定一些更大/更有实质性的目标,并迁移 v1 应用的起始和结束页面。

迁移起始和结束页面

结束页面的迁移很容易做,我建议你自己来做。在js文件夹内创建一个名为finish的文件夹,并创建两个文件,finish.component.tsfinish.component.html。通过查看现有实现(或从ng6-checkpoint8.4复制)来实现组件。

将组件添加到AppModuleapp.module.ts)的declarationsentryComponents数组中。然后,修复路由以加载结束组件。

记得使用downgradeComponent函数(检查app.ts)降级FinishComponent,并修复 AngularJS 的finish 路由以使用新的指令:

$routeProvider.when('/finish', { template: '<finish></finish>' }); 

最后,记得从partials/workout文件夹中删除完成 HTML 模板。

如果你卡在迁移完成页面上,比较ng6-checkpoint8.3ng6-checkpoint8.4的 git 分支,以了解8.4分支中发生了什么变化(bit.ly/ng6be-compare-8-3-8-4)。

完成页面很容易,起始页面则不然!虽然起始页面看起来是一个容易的目标,但有一些挑战需要一些思考。

查看起始页面模板(partials/workout/start.html);起始页面的最大问题是它使用第三方库angular-translate来本地化页面内容。由于我们将整个页面/视图迁移到 Angular,我们需要一种机制来处理这些 AngularJS 库依赖。

angular-translate包含一个过滤器(在 Angular 世界中称为pipe)和一个指令,两者都命名为translate。它们的工作是将字符串标记转换为本地化的字符串字面量。

如果start页面成为 Angular 组件,我们需要将过滤器转换为 Angular 管道,并以某种方式使translate指令在 Angular 中工作。

我们至少有这两种选择来处理这种迁移场景:

  • 创建一个新的过滤器,同时使用UpgradeModule升级 v1 版本的translate指令

  • 在 Angular 世界中找到一个合适的angular-translate替代品

虽然第一个选择看起来似乎是最简单的,但它有一些严重的限制。Angular 对指令如何升级到 Angular 有一些严格的要求。

升级 AngularJS 指令并不意味着指令已经迁移。Angular 允许我们在 Angular 组件视图中直接使用 AngularJS 元素指令。

AngularJS 指令升级

有时,应用程序的某些部分可能以自顶向下的方式迁移;一个高阶视图被转换成组件。在这种情况下,我们不需要迁移 AngularJS 视图中所有自定义指令,而是使用在UpgradeModule中定义的一些接口和函数将它们升级为 Angular 组件。以下图表展示了这个迁移路径:

Angular 框架对可以升级为 Angular 组件的内容有一些限制。以下是 Angular 迁移指南的摘录。

为了与 Angular 兼容,AngularJS 组件指令应该配置以下属性:

  • restrict: 'E'。组件通常用作元素。

  • scope: {} - 一个隔离作用域。在 Angular 中,组件总是与其周围环境隔离,你应在 AngularJS 中也这样做。

  • bindToController: {}。组件输入和输出应该绑定到控制器而不是使用$scope

  • controller and controllerAs。组件有自己的控制器。

  • template or templateUrl。组件有自己的模板。

组件指令也可以使用以下属性:

  • transclude: true/{},如果组件需要从其他地方转译内容

  • require,如果组件需要与某些父组件的控制器通信

组件指令不应使用以下属性:

  • compile。这将在 Angular 中不被支持。

  • replace:True。Angular 永远不会用组件模板替换组件元素。此属性在 AngularJS 中也已弃用。

  • priorityterminal。虽然 AngularJS 组件可能使用这些,但在 Angular 中并不使用,最好不要编写依赖于它们的代码。

在满足所有其他条件的情况下,只有 AngularJS 的元素指令可以被升级到 Angular。

在这个庞大的清单中,与 Angular 组件降级相比,将 AngularJS 指令升级到 Angular 是困难的。通常情况下,如果父视图已迁移到 Angular,我们必须对 AngularJS 指令进行实际的代码迁移。

查看angular-translate源代码,我们发现它使用了$compile服务;因此,升级选项被排除。我们需要找到一个替代库。

我们确实有一个针对 Angular 的国际化库,ngx-translatewww.ngx-translate.com/)。

ngx-translate替换angular-translate

*ngx-translate*是一个针对 Angular 的国际化库,它可以替代 v1 的*angular-translate*

安装ngx-translatengx-translate/http-loader的 npm 包:

npm install @ngx-translate/core --save
npm install @ngx-translate/http-loader --save 

*http-loader*从服务器加载本地化文件。

更新systemjs.config.js以包含*ngx-translate*库。向map属性添加条目:

var map = {... 
    '@ngx-translate/core':'npm:@ngx-translate/core/bundles/core.umd.js',
    '@ngx-translate/http-loader':'npm:@ngx-translate/http-loader/bundles/http-loader.umd.js'
}

ngx-translate文档所述,我们需要配置翻译模块和 HTTP 加载器。打开app.module.ts并添加高亮代码:

export function HttpLoaderFactory(http: HttpClient) {
 return new TranslateHttpLoader(http,'/i18n/');
}

@NgModule({
    imports: [...
        HttpClientModule,
 FormsModule,
 TranslateModule.forRoot({
 loader: {
 provide: TranslateLoader,
 useFactory: HttpLoaderFactory,
 deps: [HttpClient]
 }
 })
    ],...

前面的提供者声明设置了一个加载器,从i18n文件夹加载翻译文件(.json)。为了从服务器加载翻译,需要导入HttpClientModule。请记住从 git 分支ng6-checkpoint8.4bit.ly/ng6be-8-4-i18n)复制翻译文件(*.json)。

将以下导入语句添加到app.module.ts以使 TypeScript 编译器满意:

import { HttpClient, HttpClientModule } from '@angular/common/http';
import { TranslateModule, TranslateLoader, TranslateService } from '@ngx-translate/core';
import { TranslateHttpLoader } from '@ngx-translate/http-loader';

ngx-translate库现在已准备好使用。我们将要做的第一件事是在应用程序启动时设置默认翻译语言。

使用ngDoBootstrap进行初始化

使用 Angular,幸运的是AppModule已经定义了一个函数,ngDoBootstrap,当框架启动 AngularJS 应用程序时会调用该函数——这是一个进行ngx-translate初始化的完美位置。在app.module.ts中更新ngDoBootstrap函数,如下代码片段所示:

ngDoBootstrap() {
        this.upgrade.bootstrap(document.documentElement, ['app']);

 var translateService = this.upgrade.injector.get(TranslateService);
 // determine the current locale.
 var userLang = navigator.language.split('-')[0];
 userLang = /(fr|en)/gi.test(userLang) ? userLang : 'en';

 translateService.setDefaultLang('en');
 translateService.use(userLang);  }); 

代码尝试确定当前浏览器的语言,并根据此设置当前翻译语言。注意我们是如何获取TranslateService的。UpgradeModule对象持有 Angular 的root injector的引用,它反过来从ngx-translate加载TranslateService

在完成起始组件的背景工作后,将ng6-checkpoint8.4分支中的起始页实现复制到新的文件夹app/js/start中。

然后,将起始组件声明添加到app.module.ts中。

如同之前注册为 AngularJS 指令的其他组件一样,将此语句添加到app.ts中:

import {StartComponent} from './start/start-component'; 
angular.module('start').directive('start', upgradeAdapter.downgradeng6Component(StartComponent) as angular.IDirectiveFactory); 

起始模板文件现在使用translate管道(管道的名称与 AngularJS 过滤器translate的名称相同)。

起始页也有三个管道,searchorderBysecondsToTime

<a *ngFor="let workout of workouts|search:'name':searchContent|orderBy:'name'" ...>
...
{{workout.totalWorkoutDuration()|secondsToTime}}

将这些管道的实现以及共享模块的定义(shared.module.ts)从ng6-checkpoint8.4bit.ly/ng6be-8-4-shared)复制到本地js/shared文件夹中。同时,记得将共享模块(shared.module.ts)导入到应用模块(app.module.ts)。在这里,我们不会详细讨论管道的实现,因为我们已经在前面的章节中讨论过了。

起始和结束组件实现已完成。让我们将它们集成到应用中。

整合起始页和结束页

起始/结束视图作为路由更改的一部分加载,因此我们需要修复config.js中的路由定义。更新起始和结束路由定义如下:

$routeProvider.when('/start',  { template: '<start></start>' }); 
$routeProvider.when('/finish',  { template: '<finish></finish>' });

路由模板 HTML 是 AngularJS 视图的一部分。由于我们已经将StartComponentFinishComponent注册为 AngularJS 指令,所以路由会加载正确的组件。

如果你已经迁移了结束页,你不需要重新执行结束页的路由设置,如描述的那样。

在我们可以测试实现之前,还有一些修复工作要做。

记得将翻译文件de.jsonen.jsonng6-checkpoint8.4文件夹的i18nbit.ly/ng6-8-4-i18n)复制过来。现在,我们准备好测试我们所开发的内容了。

如果尚未启动,启动 TypeScript 编译器和 HTTP 服务器,然后打开浏览器。起始页和结束页应该可以正常加载。但是翻译没有工作!点击顶部导航的语言翻译链接没有任何效果。内容始终以英语渲染。

我们现在处于ng6-checkpoint8.4检查点。如果你遇到了困难,比较 git 分支ng6-checkpoint8.3ng6-checkpoint8.4来了解发生了什么变化(bit.ly/ng6be-compare-8-3-8-4)。

翻译仍然不起作用,因为启用翻译的顶部导航代码(root.js)仍在使用较旧的库。我们需要完全去除 angular-translate(v1 库)。有两个库做相同的工作不是我们想要的,但移除它也不是那么简单。

去除 angular-translate

要去除 angular-translate(v1)库,我们需要:

  • 从所有 AngularJS 视图中移除 angular-translate 的指令/过滤器引用

  • 去除任何使用此库的代码

完全去除 v1 指令/过滤器的任务很困难。我们也不能在 AngularJS 视图中使用 v2 ngx-translate 管道。此外,一次性将所有使用 v1 翻译指令/过滤器的视图迁移到 Angular 也是可行的。肯定有更好的解决方案!而且确实有!

为什么不编写一个新的 AngularJS 过滤器,该过滤器使用 ngx-translate 的翻译服务(TranslateService)进行翻译,然后在整个地方使用新的过滤器?问题解决了!

我们把这个过滤器称为 ngxTranslate。我们将 v1 视图中所有对 translate 过滤器的引用替换为 ngxTranslate。所有 v1 translate 指令的引用也替换为 ngxTranslate 过滤器。

过滤器的实现如下:

import { TranslateService } from '@ngx-translate/core'; 

export function ngxTranslate(ngxTranslateService: TranslateService) { 
   function translate(input) { 
    if (input &amp;&amp; ngxTranslateService.currentLang) { 
      return ngxTranslateService.instant(input); 
    } 
  } 
  translate['$stateful'] = true; 
  return translate; 
} 

ngxTranslate.$inject = ['TranslateService']; 
angular.module('app').filter("ngxTranslate", ngxTranslate); 

shared 文件夹中创建一个名为 ngx-translate.filter.ts 的文件,并添加前面的实现。过滤器使用 TranslateService 来本地化内容。为了使服务在 AngularJS 中可发现,需要使用 ngUpgrade 模块的 downgradeInjectable 方法降级。打开 app.ts 并添加以下行:

import './shared/ngx-translate.filter';
...
import { downgradeComponent, downgradeInjectable } from '@angular/upgrade/static';
...
import { TranslateService } from '@ngx-translate/core';
...
angular.module('app')
 .factory('TranslateService', downgradeInjectable(TranslateService)); 

这段代码使用 AngularJS 中的字符串标记 'TranslateService' 注册 TranslateService。第一个导入语句也在运行时加载了新的过滤器。

为了测试这个实现,还需要进行几个额外的步骤。

首先,将 AngularJS 视图中所有对 translate(指令和过滤器)的引用替换为 ngxTranslate。这些文件中有引用:description-panel.htmlvideo-panel.htmlworkout.html(在 partials/workout 文件夹中),以及 index.html。在插值中替换过滤器是一个简单的练习。对于 translate 指令,用插值替换它。例如,在 partials/workout/description-panel.html 中,代码行如下:

<h3 class="panel-title" translate>RUNNER.STEPS</h3> 

然后,它变成以下形式:

<h3 class="panel-title">{{'RUNNER.STEPS'|ngxTranslate}}</h3> 

记得在插值中引用字符串标记('RUNNER.STEPS')时加上引号。

最后,从 bit.ly/ng6be-8-5-root-js 复制更新的 root.js。我们已经将所有对 $translate 服务的引用替换为 TranslateService,并将代码重构为使用新的服务。root.js 包含 v1 RootController 的实现。

现在我们已经准备好了。尝试新的实现;应用应该会使用 ngx-translate 库加载翻译。

现在我们可以删除所有对 angular-translate 的引用。在 index.htmlapp-ng1.module.tsconfig.js 中都有引用。

起始和结束页面的迁移已完成。

比较分支ng6-checkpoint8.4ng6-checkpoint8.5,以了解ng6-checkpoint8.5中的新更改(bit.ly/ng6be-compare-8-4-8-5)。

我们在这里停止,并将您引导到其他与迁移相关的 GitHub 分支。所有以ng6-checkpoint*开头的分支都是迁移分支。尝试迁移挂起的视图,并将它们与 GitHub 分支的更改进行比较。记住,已经在 Angular 中开发了一个应用程序的工作版本,因此有一个很好的参考点。查看每个分支的README.md文件,以了解应用程序的哪个部分被迁移到了 Angular。

同时,让我们总结我们从迁移中学到的经验。

学习

我们希望这次迁移练习已经提供了足够的过程洞察。你现在可以评估从 AngularJS 迁移到 Angular 所需复杂性、时间和努力。让我们强调在这个过程中我们学到了什么:

  • 迁移耗时:从想象中,迁移绝对不是一项微不足道的练习。每个页面/视图都提出了我们需要克服的挑战。一些元素容易迁移,而一些则不然。如果你正在开发 AngularJS,今天你能做的最好的事情就是遵循今天开发 AngularJS 应用程序以方便迁移部分的建议。

  • 首先迁移第三方库:迁移第三方库可能相当具有挑战性。原因有很多:

    • 这样的库被跨页面使用

    • 它们可能无法升级到 Angular(使用UpgradeAdapter

    • 当库被广泛使用时,迁移使用此类库的每个视图可能不可行

最好识别您应用程序中的所有第三方依赖项,并在 Angular 世界中找到它们的合适替代品。如果可能,使用新库开发一些概念验证POC),以了解新库与现有实现的不同之处。

  • 存在重叠的库:在迁移过程中,可能会出现 AngularJS 和 Angular 版本的库共存的情况。尽量缩短这个时间段,并尽快迁移到新版本。

  • 将 Angular 组件集成到 AngularJS 中比反过来更容易:在迁移过程中,将完整视图迁移到 Angular。由于 Angular 的限制,拥有一个包含 AngularJS 元素指令的父 Angular 组件变得非常困难。在这种限制下,自下而上的迁移方法比自上而下的方法更有效。

  • 与 UI 无关的内容迁移起来很容易:对于Personal Trainer,我们最后迁移服务,因为它们可以很容易地迁移。

  • 保持 AngularJS 和 Angular 的功能一致性:Angular 可能没有 AngularJS 支持的所有功能。在这种情况下,我们需要找到解决方案来实现所需的行为。

这就完成了我们的迁移故事。有了这个,是时候结束这一章节并总结我们从中学到的经验教训。

摘要

在这一章中,我们对围绕 Angular 开发的一些实际问题获得了有用的见解。这些提示/指南在构建使用该框架的实际应用程序时非常有用。

我们从探索种子项目的概念开始这一章节,并了解了这些项目如何让我们迅速启动。我们查看了一些可以作为任何新 Angular 应用程序开发基础的流行种子项目。

尽管认证和授权是服务器端的问题,但它们确实会影响客户端实现。关于认证/授权的部分涵盖了如何在基于 cookie 和基于 token 的设置中处理认证。

我们探讨了至关重要的性能主题,你学习了优化 Angular 应用程序性能的方法。

最后,我们将 v1 版本的个人教练迁移到了 Angular。逐步迁移的过程教会了我们迁移的复杂性、面临的挑战以及采取的解决方案。

书籍即将结束,但对于每一位阅读它的人来说,旅程才刚刚开始。现在是时候将理论付诸实践,磨练我们新获得的技术,用 Angular 构建一些有用的东西,并与世界分享。你投入 Angular 越多,这个框架就越有回报。让我们开始吧!

posted @ 2025-09-05 09:24  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报