首页 > 其他分享 >Angular-2-示例-全-

Angular-2-示例-全-

时间:2024-05-18 12:46:16浏览次数:16  
标签:示例 视图 Angular 应用程序 使用 组件 我们

Angular 2 示例(全)

原文:zh.annas-archive.org/md5/529E3E7FE7FFE986F90814E2C501C746

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Angular 2 来了,我们非常兴奋!这本书让我们能够与您联系,并在您学习 Angular 2 的过程中伸出援手。

虽然 Angular 1 的增长是有机的,但 Angular 2 却不能这样说。它依赖于其前身的流行,并已经在开发者社区中引起了巨大的兴趣。每个人都期待一个超级棒的未来证明的框架!我们相信 Angular 2 已经朝着正确的方向迈出了步伐,这将使它成为 Web 和移动开发的普遍平台。

如果你是一个 Angular 1 开发者,那么有很多令人兴奋的东西可以学习,而对于刚开始的开发者来说,有一个全新的世界可以探索。

即使对于经验丰富的 Angular 1 开发者来说,开始使用 Angular 2 可能也会让人感到不知所措。会有太多术语被扔给你,比如 TypeScript、Transpiler、Shim、Observable、Immutable、Modules、Exports、Decorators、Components、Web Component、Shadow DOM 等等。但放松!我们正在努力拥抱现代网络,这里的一切新东西都是为了让我们的生活更轻松。其中许多概念并不特定于 Angular 本身,而是突出了 Web 平台开发的方向。我们将尽力以清晰简洁的方式呈现这些概念,帮助每个人理解这些组成部分如何融入这个大的生态系统。

通过示例学习有其优势;你可以立即看到概念在实践中的解释。这本书遵循与其前身相同的模式。使用自己动手的方法,我们使用 Angular 2 构建多个简单和复杂的应用程序。

来自之前版本的读者

Angular 2 是一个全新的框架,它与其前身唯一共享的是名称!很少有 Angular 1 的核心概念已经在 Angular 2 中实现。鉴于这一事实,这本书也是一本全新内容的完全重写。我们可能正在构建相同的应用程序,但这次我们使用的是 Angular 2。

为什么需要一个新版本的 Angular?

坦率地说,自从 Angular 2 在 2014 年 10 月的 ng-europe 大会上首次宣布以来,许多 Angular 开发者都问过这个问题。Angular 1 是一个非常受欢迎的 JavaScript 框架。全球有超过 100 万开发者使用过它。其中许多人贡献了增强和加强框架的附加组件/扩展。那么为什么需要一个不同的、新版本呢?

对于这个问题有几个答案。但从根本上讲,它们都围绕着 Angular 1 已经有六年的事实 - 这在网络技术方面是一个生命周期。例如,Angular 1 早于许多围绕移动技术发展的内容。此外,2015 年批准了 JavaScript 的新版本(ES2015),它彻底改变了 JavaScript 编程。最后,Angular1 并不是为了与 Web 组件等新兴网络标准一起使用而设计的。

随着像 Facebook 的 React 这样的新框架被设计为最大化性能并强调移动优先开发,改变的需求变得更加迫切。Angular 2 通过采用最新的网络技术并将其纳入现代浏览器的框架中来应对这一挑战。

Angular 2 设计

强调一些 Angular 1 中不存在的内容,逻辑上引出了 Angular 2 设计的关键点。Angular 2 具有移动优先的设计。因此,它被设计为小占用空间,这意味着从服务器到浏览器流动的数据尽可能少。框架本身已经被分解成一系列模块,因此只加载运行应用程序所需的代码。此外,简化和更一致的语法使学习更容易,同时也提供了更好的工具支持和自动化。

Angular 2 中使用的新兴技术为实现这些目标提供了关键要素。Web 组件使 Angular 2 应用程序能够由可重用的构建块构建,这些构建块封装了它们的内部逻辑。ES2015 提供了类和一个用于加载 Angular 模块的稳固系统。TypeScript 引入了类型,使得构建大规模应用程序的语法更简单、更健壮。

为什么使用 TypeScript?

本书中的示例都使用 TypeScript。正如前面提到的,Angular2 允许我们在 ES5(标准 JavaScript)和 ES2015 以及 TypeScript 中编写代码。我们选择 TypeScript 有几个原因。首先,Angular 2 团队本身就在使用 TypeScript 来构建框架。用 TypeScript 编写的 Angular 2 代码比其他替代方案更简洁。使用 TypeScript 还使 IDE 能够提供比 JavaScript 更好的智能感知和代码补全支持。

最后一点-我们认为使用 TypeScript 更容易学习 Angular 2。由于这本书是关于教授您这项新技术,似乎是最适合最广泛受众的最佳选择。作为 JavaScript 的超集,它为 JavaScript 开发人员提供了一个简单的迁移路径,以便在他们的 Angular 应用程序中使用类型。对于那些从更传统的面向对象语言转向 Angular 2 的开发人员来说,它提供了类型和类的熟悉感。

本书涵盖的内容

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

第二章,“构建我们的第一个应用程序-7 分钟锻炼”,教会我们如何构建我们的第一个真正的 Angular 应用程序。在这个过程中,我们更多地了解了 Angular 的主要构建块之一-组件。我们还介绍了 Angular 的模板构造、数据绑定能力和 Angular 服务。

第三章,“更多 Angular 2-SPA、路由和深入的数据流”,涵盖了框架中的路由构造,我们为 7 分钟锻炼构建了多个页面。本章还探讨了一些关于组件间通信的模式。

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

第五章,“支持服务器数据持久性”,涉及从服务器保存和检索锻炼数据。我们通过探索 Angular 的 HTTP 客户端库以及它如何使用 RxJS Observables 来增强个人教练的持久性能力。

第六章,“深入了解 Angular 2 指令”,深入探讨了 Angular 2 指令和组件的内部工作原理。我们构建了许多指令来支持个人教练。

第七章,“测试个人教练”,介绍了 Angular 中的测试世界。您将构建一套单元测试和端到端测试,以验证个人教练的工作情况。

第八章,“一些实际场景”,提供了一些关于在该框架上开发应用程序时可能遇到的实际提示和指导。我们涵盖了诸如身份验证和授权、本地化、性能以及最重要的情况,即将应用程序从 Angular 1 迁移到 Angular 2。

您需要为这本书做些什么

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

本书中列出的所有代码均为 Angular 2.0.0 编写和测试。

这本书是为谁准备的

这本书适合没有 Angular 经验的读者。我们从 Angular 2 的基础知识开始,并通过书中的多个练习逐渐建立您对该框架的理解。

要充分利用这本书,您应该具有使用 HTML、CSS、JavaScript 和少量 TypeScript 开发 Web 平台的经验。Angular 1 的经验可能是有利的,但对于这本书并非必需。

如果您缺乏 TypeScript 经验,我们强烈建议您访问 TypeScript 网站:www.typescriptlang.org,并查看教程、手册和示例。对于 JavaScript 开发人员来说,学习 TypeScript 并不需要太多时间。

约定

在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是这些样式的一些示例及其含义的解释。

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

代码块设置如下:

@Directive({
 selector: '[a2beBusyIndicator]',
})
export class BusyIndicator {
 constructor(private _control: NgControl) { }
}

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

<div class="panel-body">
 **{{description}}**
 </div>
 <div class="panel-body">
 **{{steps}}**
 </div>

任何命令行输入或输出都以以下方式编写:

**npm install -g angular2**

新术语重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会在文本中显示为:“本书中的快捷键基于Mac OS X 10.5+方案。”

注意

警告或重要提示显示在这样的框中。

提示

提示和技巧显示如下。

第一章:入门

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

图书馆,如 jQuery,非常擅长处理各种浏览器怪癖,并提供可以减少代码行数的构造。然而,这些库缺乏任何可以在代码库增长时帮助我们的结构指导。

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

然而,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 组件、ES2015(JavaScript 的新版本)和 TypeScript。让我们讨论一下这些技术各自带来的可能性。

Web 组件

Web 组件是一个总称,实际上涵盖了 Web 浏览器的四个新兴标准:

  • 自定义元素

  • 影子 DOM

  • 模板

  • HTML 导入

注意

有关 Web 组件的更多信息,请访问webcomponents.org

现在让我们详细讨论一下这些:

  • 自定义元素使得除了标准的 HTML 标签名(如<div><p>)之外,可以创建新类型的元素。添加自定义标签的能力提供了一个屏幕上可以保留用于绑定组件的位置。简而言之,这是将组件与页面其余部分分离并使其成为真正自包含的第一步。

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

  • 模板是可重复使用的 HTML 块,其中的标记可以在运行时使用 JavaScript 替换为动态内容。许多 JavaScript 框架已经支持某种形式的模板。Web 组件标准化了这种模板,并在浏览器中直接支持它。模板可以用于使我们组件中使用的 Shadow DOM 内的 HTML 和 CSS 变得动态。因此,这是使我们的组件的第三步。

  • 构成 Web 组件的最终标准是 HTML 导入。它们提供了一种加载 HTML、CSS 和 JavaScript 等资源的方式。Angular 不使用 HTML 导入。相反,它依赖于 JavaScript 模块加载,我们将在本章稍后讨论。

Angular 和 Web 组件

当前的 Web 浏览器并不完全支持 Web 组件。因此,Angular 组件并不严格属于 Web 组件。更准确地说,Angular 组件实现了 Web 组件背后的设计原则。它们还使得可以构建可以在今天的浏览器上运行的组件。

注意

在撰写本书时,Angular 支持最新版本的浏览器,如 Chrome、Firefox、Edge,以及 IE 7 及更高版本。它还支持 Android 4.1 及更高版本的移动设备。有关 Angular 支持的浏览器列表,请访问github.com/angular/angular

因此,在本书的其余部分,我们将专注于构建 Angular 组件而不是 Web 组件。尽管有这种区别,Angular 组件与 Web 组件紧密配合,甚至可以与它们互操作。随着浏览器开始更全面地支持 Web 组件,Angular 组件和 Web 组件之间的差异将开始消失。因此,如果您想开始采用未来的 Web 组件标准,Angular 为您提供了今天这样做的机会。

Angular 中的语言支持

您可以使用 ES5 开发组件,但 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/docs/ts/latest/guide/architecture.html。从这一点开始,我们将专注于 Angular 模块。

由于今天的浏览器并不完全支持 ES2015,我们需要将 ES2015 转换为 ES5,以便在我们的应用程序中使用类和模块加载等功能。我们通过一个称为转译的过程来实现这一点。

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

一旦 ES2015 被转译为 ES5,我们就可以使用诸如SystemJS之类的模块加载器来加载我们的模块。SystemJS 遵循 ES2015 模块加载的语法,并使我们能够在今天的浏览器中进行模块加载。

TypeScript

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

  • 类型

  • 装饰器

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

装饰器是我们可以使用@符号和函数添加到我们的类中的简单注释。它们为我们的类的使用提供指令(称为元数据)。在 Angular 的情况下,装饰器允许我们将我们的类标识为 Angular 组件。装饰器可以指定要与组件一起使用的模块,以及如何实现各种绑定和指令,包括将 HTML 视图附加到组件。随着我们在本书中的学习,我们将更多地涵盖装饰器的使用。

装饰器是 ES2017 提案的一部分,不是 ES2015 的一部分。它们作为微软和谷歌之间合作的一部分被添加到 TypeScript 中。正如前面提到的,TypeScript 编译成 ES5,因此我们可以在不完全支持 ES2015 或装饰器的浏览器中使用类型和装饰器。

注意:…

如前所述,使用 Angular 不需要使用 ES2015 或 TypeScript。然而,我们认为随着我们在本书中的示例的进行,您将看到使用它们的优势。

将所有内容整合在一起

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

我们希望您能在本书的示例中看到,Angular 使组件能够以直观和声明性的方式构建,这使得开发人员更容易实现它们。随着我们在本书中的示例的进行,我们将强调每种技术的使用位置。

Angular 模块

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

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

注意

Angular 本身是作为模块构建的,我们将其导入到我们的应用程序中。因此,当您构建 Angular 应用程序时,您将在各处看到模块的使用。

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

总之:在基本层面上,您将看到在 Angular 中开发应用程序时,您将执行以下操作:

  1. 创建组件。

  2. 将它们捆绑到模块中。

  3. 启动您的应用程序。

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

让我们开始做这件事。

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

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

这就是游戏的样子:

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

设置开发服务器

我们选择的开发 Web 服务器在很大程度上取决于我们所工作的平台和我们支持的后端。然而,由于本书中的应用程序纯粹针对客户端开发,任何 Web 服务器都可以。

我们建议使用live-server,这是一个带有实时重新加载功能的简单 HTTP 服务器。您可以使用 Node.js 安装它。由于 Node.js 可跨平台使用,您可以从nodejs.org/安装 Node.js。

注意

对于本书,我们使用的是 Node.js 版本 4.4.2 和 npm 版本 3.8.6。您可以在docs.npmjs.com/getting-started/installing-node找到有关安装 Node 和更新 npm 到最新版本的更多信息。

一旦安装了 Node.js,安装live-server模块并运行 HTTP 服务器就很容易。打开命令行并输入以下命令:

**npm install -g live-server**

这将在全局级别安装live-server

要运行服务器,我们只需导航到应用代码所在的文件夹,或者打开要提供静态文件的文件夹。然后输入这个:

**live-server**

就是这样!

我们在http://localhost:8080上运行着一个 HTTP 服务器。它可以从当前目录中提供文件。

注意

live-server模块确实支持一些启动配置。请查看github.com/tapio/live-server上的文档。

根据我们所在的平台,我们还可以尝试 Python 的SimpleHTTPServer模块,Mongoose 或任何类似的 Web 服务器。

现在让我们来构建猜数字游戏。

构建猜数字游戏!

构建用户界面的标准做法是自上而下地构建它们。首先设计 UI,然后根据需要插入数据和行为。采用这种方法,应用的 UI、数据和行为方面都是紧密耦合的,这是一个不太理想的情况!

使用基于组件的设计,我们的工作方式不同。我们首先查看 UI 和预期行为,然后将所有这些封装到我们称之为组件的构建块中。然后在我们的页面上托管这个组件。在组件内部,我们将 UI 分离为视图,将行为分离为一个类,具有支持行为所需的适当属性和方法。如果你不熟悉类,不用担心。随着我们在示例中的深入讨论,我们将详细讨论它们是什么。

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

设计我们的第一个组件

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

  • 生成随机数(original

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

  • 跟踪已经猜测的次数(noOfTries

  • 根据用户的输入提供提示以改进他们的猜测(deviation

  • 如果用户正确猜测了数字,则提供成功消息(deviation

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

注意

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

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

主机文件

让我们从为我们的组件创建文件开始。我们将首先创建一个目录用于我们的应用程序。我们将其命名为guessthenumber(但您可以根据需要命名)。我们将使用它来添加我们的文件。

一个 HTML 页面

首先,打开您喜欢的编辑器,并创建一个包含以下htmlscript标签的 HTML 页面:

<!DOCTYPE html> 
<html> 
  <head> 
    <title>Guess the Number!</title> 
    <link href="http://netdna.bootstrapcdn.com/bootstrap/
                3.1.1/css/bootstrap.min.css" rel="stylesheet"> 
    <script src="https://unpkg.com/core-js/client/shim.min.js"></script> 
    <script src="https://unpkg.com/[email protected]"></script> 
    <script src="https://unpkg.com/[email protected]?main=browser"></script> 
    <script src="https://unpkg.com/[email protected]/lib/typescript.js">
    </script> 
    <script src="https://unpkg.com/[email protected]/dist/system.js">
    </script> 
    <script src="systemjs.config.js"></script> 
    <script> 
      System.import('app').catch(function(err){ console.error(err); }); 
    </script> 
  </head> 
  <body> 
    <my-app>Loading...</my-app> 
  </body> 
</html> 

提示

下载示例代码

本书中的代码可以在 GitHub 上找到:github.com/chandermani/angular2byexample。它按照检查点进行组织,允许您一步一步地跟随我们在本书中构建示例项目。本章的下载分支是 GitHub 的分支:checkpoint1.1。在guessthenumber文件夹中查找我们在此处涵盖的代码。如果您不使用 Git,请从以下 GitHub 位置下载 Checkpoint 1.1 的快照(ZIP 文件):github.com/chandermani/angular2byexample/tree/checkpoint1.1。首次设置快照时,请参考guessthenumber文件夹中的readme.md文件。

脚本标签

在此文件中有几点需要注意:

  • 前五个<script>标签引用以下内容:

  • shim:这为旧版浏览器提供了 ES2015 功能

  • reflect-metadata:这添加了装饰器支持

  • zone.js:这管理变更检测

  • typescript.js:这是当前版本的 TypeScript

  • system.js:这加载我们的模块

  • 接下来的<script>标签引用了我们应用程序中的一个 JavaScript 文件:systemjs.config.js。确保从示例代码文件中添加该文件。我们稍后将讨论这个文件。基本上,它为 SystemJS 提供了指令,告诉它应该加载哪些模块,并指示它在运行时动态转译我们的 TypeScript 文件为 ES5。

  • 最后的<script>标签调用 SystemJS 来导入我们的组件目录:app

我们将在本章后面讨论后两个脚本标签如何一起工作以启用模块加载。

自定义元素

页面上还有一个更重要的标签:

<my-app>Loading...</my-app> 

这个标签是一个自定义元素。它指示 Angular 在哪里注入我们将要构建的组件。

应用程序的其余 HTML 代码是不言自明的。我们在<head>部分引用了 Twitter Bootstrap CSS,并为我们的页面添加了一个标题,即猜数字游戏!

注意

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

组件文件

现在让我们为我们的组件创建一个文件。

  1. 在我们的应用程序中添加一个名为app的子目录。

  2. 然后,使用您的编辑器,创建一个名为guess-the-number.component.ts的文件,并将其放在该子目录中。.ts扩展名将我们的文件标识为将在运行时编译为 ES5 的 TypeScript 文件。

导入语句

在页面顶部放置以下行:

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

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

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

这些桶组合了几个逻辑相关的模块。在这种情况下,我们指示要导入core桶模块,这将带入Component模块。这种命名约定与我们模块的加载相关联,我们将在本章后面更详细地讨论。

装饰器

接下来,在你的guess-the-number.component.ts文件中添加以下脚本块:

@Component({ 
  selector: 'my-app', 
  template: ` 
    <div class="container"> 
      <h2>Guess the Number !</h2> 
      <p class="well lead">Guess the computer generated random
         number between 1 and 1000.</p> 
      <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> 
      <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 页面中的<my-app>标签。这个设置告诉 Angular 将这个组件注入到 HTML 页面上的那个标签中。

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

定义类

现在,将以下代码块添加到您的guess-the-number.component.ts文件中:

export class GuessTheNumberComponent { 
  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)开发的人来说,这应该是熟悉的领域)。

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

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

当我们向下移动时,我们会看到三个脚本块,它们有名称,后面跟着括号,然后是花括号,里面包含几行脚本。这些是我们类的方法,它们包含了我们组件将支持的操作。它们很像标准的 JavaScript 函数。

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

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

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

模块文件

正如我们之前提到的,每个 Angular 组件必须包含在一个 Angular 模块中。这意味着至少我们必须在应用程序的根目录中添加至少一个 Angular 模块文件。我们称之为根模块。对于像 Guess the Number!这样的简单应用程序,根模块可能是我们唯一需要的模块。然而,随着 Angular 应用程序的规模增大,通常会有多个按功能拆分的 Angular 模块文件是有意义的。随着我们在本书的后续章节中构建更复杂的应用程序,我们将涵盖这种情况。

让我们继续创建我们的 Angular 模块文件。首先,在与guess-the-number.component.ts相同的目录中创建一个名为app.module.ts的新文件,并将以下代码添加到其中:

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

import { GuessTheNumberComponent }     
from './guess-the-number.component'; 

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

前两个语句导入了NgModuleBrowserModule。请注意,虽然NgModule是从@angular/core导入的,但BrowserModule是从不同的模块@angular/platform-browser导入的。这里重要的是导入不是来自@angular/core,而是来自一个特定于基于浏览器的应用程序的单独模块。这提醒我们 Angular 可以支持除浏览器之外的设备,比如移动设备,因此需要将BrowserModule放入一个单独的模块中。

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

接下来我们定义一个新的组件AppModule。这个组件看起来不同于我们刚刚定义的GuessTheNumberComponent。在类本身中除了一些导入和一个装饰器@ngModule之外没有任何东西。我们可以使用这个装饰器来配置我们应用程序中的模块。我们首先添加导入,这种情况下包括BrowserModule。顾名思义,这个模块将提供在浏览器中运行我们应用程序所需的功能。下一个属性是声明,通过这个属性,我们提供了一个将在我们应用程序中使用的组件数组。在这种情况下,我们只有一个组件:GuessTheNumberComponent

最后,我们设置了bootstrap属性。这表示我们应用程序启动时将加载的第一个组件。同样,这是GuessTheNumberComponent

有了这个配置,我们现在可以准备启动我们的组件了。

引导

GuessTheNumberComponent的类定义作为组件的蓝图,但其中的脚本在我们创建组件实例之前不会运行。因此,为了运行我们的应用程序,我们需要在应用程序中添加一些内容来创建这个实例。这个过程需要我们添加引导我们组件的代码。

app子目录中,创建另一个名为main.ts的文件,并添加以下代码:

import { platformBrowserDynamic }    
from '@angular/platform-browser-dynamic'; 
import { AppModule } from './app.module'; 
const platform = platformBrowserDynamic(); 
platform.bootstrapModule(AppModule); 

正如你所看到的,我们首先从@angular/platform-browser-dynamic中导入了platformBrowserDynamic模块。就像在appModule文件中导入BrowseModule一样,这个导入是专门用于基于浏览器的应用程序的。

接下来,我们添加了我们刚刚定义的AppModule的导入。

最后,我们将刚刚导入的platformBrowserDynamic()赋值给一个常量:platform。然后我们调用它的bootstrapModule方法,并将我们的AppModule作为参数。bootstrapModule方法然后创建了AppModule组件的一个新实例,然后初始化了我们的GuessTheNumberComponent,我们标记为要启动的组件。它通过调用组件的构造方法并设置我们游戏的初始值来实现这一点。

我们将在本章稍后更详细地讨论bootstrap方法如何适应整个应用程序初始化过程。

我们已经开始运行了!

好了,应用程序已经完成并准备好进行测试!转到文件所在的目录并输入以下内容:

**live-server**

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

注意

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

如果我们现在查看我们的组件文件,我们应该对我们用这 43 行代码实现的成果感到非常印象深刻。当应用程序正在运行时,我们没有编写任何代码来更新 UI。但一切都运行得很完美。

深入挖掘

要了解这个应用程序在 Angular 上下文中的功能,我们需要更深入地研究我们的组件。虽然组件中的类定义非常简单和直接,但我们需要更仔细地查看模板中的 HTML,该 HTML 位于@Component装饰器内,以了解 Angular 在这里的工作原理。它看起来像标准的 HTML,但有一些新的符号,比如[ ]( ){{}}

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

  • {{}}是插值符号

  • [ ]表示属性绑定

  • ( )表示事件绑定

显然,这些符号具有一些与它们相关的行为,并且似乎将视图 HTML 和组件代码联系起来。让我们试着理解这些符号实际上是做什么的。

插值

看一下猜数字游戏代码中的这个 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 }} 

如果安全导航运算符发现了一个空值(这里是 customer),它会停止处理路径,但让应用程序继续运行。如果没有它,应用程序将在到达第一个空值之后的任何地方崩溃(这里是 customer name),并且视图将不会显示。安全导航运算符在异步加载数据并且可能不会立即可用于视图的情况下特别有帮助。安全导航运算符将防止应用程序崩溃,然后在数据可用时加载数据。

数据绑定

学习插值和表达式很容易;现在让我们来看看另一个框架构造,即我们的示例应用程序数据绑定所使用的。我们将在接下来的章节中更详细地介绍数据绑定。在这一点上,我们只会简要涉及我们正在构建的示例应用程序中使用的绑定。

属性绑定

如果我们查看视图的 HTML,我们会看到有几个地方使用了方括号[ ]。这些是属性绑定

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

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

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

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

事件绑定

再次查看 HTML 视图,我们会发现括号 ( ) 出现在几个地方。这些是 事件绑定

让我们看一下我们为第一个事件绑定创建的 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 在幕后设置了直接绑定到我们组件方法的事件处理程序。当单击 Verify 按钮时,将调用 verifyGuess 方法,当单击 Restart 按钮时,将调用 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 在幕后扩展为 HTML5 <template> 的简化语法。在下一章中,我们将学习更多关于这种语法和结构指令的知识。

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

重新审视我们的应用

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

重新审视我们的应用

我们可以通过从输入框下面的段落中删除三个*ngIf指令和分配给它们的表达式,并在浏览器中刷新应用程序,来看到应用程序的未转换视图模板(即在前一个屏幕截图的左侧显示的内容)。

Angular 与其他模板框架不同,因为组件与其视图之间的这些绑定是实时的。对组件属性的更改会更新视图。Angular 从不重新生成 HTML;它只是处理 HTML 的相关部分,并且仅在组件属性更改时更新需要更改的 HTML 元素。这种数据绑定能力也使 Angular 成为一个出色的视图模板引擎。

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

如果我们回过头来看看我们类的代码,我们会发现类中的属性和方法并没有直接引用视图。相反,方法只是在类中的属性上操作。因此,我们类的代码更易读,因此更易维护(当然也更易测试)。

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

到目前为止,我们已经讨论了 Angular 如何根据组件属性的变化更新视图。这是一个重要的概念,因为它可以帮助我们节省无数小时的调试和挫折。接下来的部分将专门学习变化检测以及如何管理这些更新。

维护状态

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

组件作为状态的容器

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

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

template: ` 
............. 
  <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>
` 

变化检测

那么,Angular 在运行时如何跟踪组件的变化呢?到目前为止,似乎这一切都是由魔术完成的。我们只需设置组件的属性和方法,然后使用插值以及属性和事件绑定将它们绑定到视图中。Angular 会处理剩下的事情!

当然,这并不是魔术,为了有效地使用 Angular,你需要了解它如何在值发生变化时更新这些值。这被称为变化检测,Angular 在这方面的处理方式与以前存在的方式非常不同。

如果你在浏览器中使用调试工具来浏览应用程序,你会看到变更检测是如何工作的。在这里,我们使用 Chrome 的开发者工具,并设置了一个对noOfTries属性的监视。如果你在verifyGuess()方法的末尾设置一个断点,你会看到当你输入一个猜测时,noOfTries属性会在你触发断点时首先更新,就像这样:

变更检测

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

变更检测

实际上,在幕后发生的是 Angular 对应用程序中的事件做出反应,并使用变更检测器,它会遍历每个组件,以确定是否有任何影响视图的变化。在这种情况下,事件是按钮点击。按钮点击生成的事件调用了组件上的verifyGuess()方法,更新了noOfTries属性。

该事件触发了变更检测循环,该循环确定了在视图中使用的noOfTries属性已经发生了变化。因此,Angular 会使用该属性的新值更新与noOfTries绑定的视图中的元素。

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

这种方法使得 Angular 只需通过一次变更检测图。这被称为单向数据绑定,它极大地提高了 Angular 的性能。

注意

我们将在第八章一些实际场景中深入讨论 Angular 变更检测。有关 Angular 团队对此过程的描述,请访问vsavkin.com/two-phases-of-angular-2-applications-fda2517604be#.fabhc0ynb

初始化应用程序

我们之前在创建app.module.tsmain.ts文件时已经涉及了应用程序初始化。第一个文件将我们的应用程序封装在一个模块中,而第二个文件则引导该模块。现在我们将更详细地了解初始化过程。

加载应用程序所需的模块

让我们再次回到index.html页面,看一下该页面上的以下脚本标签:

<script src="https://unpkg.com/[email protected]/dist/system.js"></script> 
<script src="systemjs.config.js"></script> 

第一个标签表示我们将在我们的应用程序中使用一个名为 SystemJS 的 JavaScript 库。第二个标签引用了我们应用程序中设置 SystemJS 配置的文件。

正如我们之前提到的,ES2015 引入了一个新的模块加载语法。ES2015 模块加载语法的一个吸引人的特性是它允许模块被选择性地和异步地加载。我们将在整个应用程序中使用模块加载。

TypeScript 支持 ES2015 模块加载语法,而 SystemJS 允许我们向运行 ES5 的应用程序添加模块加载。将两者结合起来:

  • TypeScript 将我们应用程序的组件转译(编译)为 ES5

  • SystemJS 将它们作为 ES5 模块加载

作为这个过程的一部分,我们的每个 TypeScript 文件都将被编译为一个 SystemJS 模块。SystemJS 然后将加载所有相关的依赖项,并在请求模块本身时加载它们。

注意

还有其他模块加载器,包括 Webpack,因此您在选择模块加载器时并不受限于 SystemJS。有关在 Angular 中使用 Webpack 的更多信息,请参阅 Angular 网站上的以下文档:angular.io/docs/ts/latest/guide/webpack.html

打开systemjs.config.js。您会看到它包含的映射,指示 SystemJS 应该在哪里查找我们应用程序中需要的文件:

map : { 
    'app': 'app', 
    'rxjs': 'https://unpkg.com/[email protected]', 
    '@angular/common': 'https://unpkg.com/@angular/[email protected]', 
    '@angular/compiler': 'https://unpkg.com/@angular/[email protected]', 
    '@angular/core': 'https://unpkg.com/@angular/[email protected]', 
    '@angular/platform-browser': 'https://unpkg.com/@angular/
                                  [email protected]', 
    '@angular/platform-browser-dynamic': 'https://unpkg.com/@angular/
     [email protected]' 
}, 

首先,我们将我们自己的应用程序app映射到它所在的目录。然后我们为 RxJS 和几个 Angular 模块添加映射。它们是从内容传送网络CDN)的Node Package Managerunpkg.com/#/)CDN 下载的。正如您所看到的,我们在 Angular 模块中使用了@符号。正如我们之前提到的,这意味着我们将导入包含多个模块的 Angular 桶。

注意

Angular 标识为 barrels 的模块捆绑包在 NPM 中被称为作用域包。有关 Angular 如何使用 NPM 作用域包的描述,请访问www.reddit.com/r/Angular2/comments/4hk0f8/the_angular2_team_is_changing_the_repository_on/

接下来,我们确定要导入的包及其主要入口点。在我们的app包的情况下,我们指出main.ts将是入口点,ts将是默认扩展名,因为我们在其中使用 TypeScript。然后我们确定我们将在应用程序中使用的特定 Angular @barrels

packages:{ 
    'app':  { main: 'main.ts',  defaultExtension: 'ts' }, 
    '@angular/common': { main: 'bundles/common.umd.js',
                         defaultExtension: 'js' }, 
    '@angular/compiler': { main: 'bundles/compiler.umd.js',
                           defaultExtension: 'js' }, 
    '@angular/core': { main: 'bundles/core.umd.js',
                       defaultExtension: 'js' }, 
    '@angular/platform-browser': { main: 'bundles/platform-browser.umd.js',
                                   defaultExtension: 'js' }, 
    '@angular/platform-browser-dynamic': { main: 
    'bundles/platform-browser-dynamic.umd.js', defaultExtension: 'js' }, 
}, 

这些包将是我们运行应用程序所需的全部内容。当您转向更复杂的应用程序时,将需要更多的包。但您只需要添加所需的内容来运行应用程序,这就是我们在这里所做的。选择所需的包的灵活性有助于最小化下载大小,这对性能来说总是一件好事。

该文件还包含以下脚本,指示 SystemJS 转译我们的 TypeScript 文件:

transpiler: 'typescript', 
typescriptOptions: { 
    emitDecoratorMetadata: true 
} 

注意

这个脚本将在浏览器中转译我们的 TypeScript 文件,这在生产应用中通常是不会做的。另外,为了尽可能简化我们应用程序的设置,我们会在浏览器加载时动态下载 Angular 模块和其他库。在生产应用中,我们会将这两个步骤移到应用程序在浏览器中启动之前进行的构建过程中。这将减少应用程序的下载大小并提高性能。我们将在第二章中探讨设置这样一个构建过程,构建我们的第一个应用 - 7 分钟锻炼

然后,我们通过将以下脚本添加到我们的index.html文件来实现我们应用程序的模块加载:

system.import('app').catch(function(err){ console.error(err); }); 

传递给我们的导入语句app的参数是我们应用程序的位置,在这种情况下是app目录。因为我们在systemjs.config.js中指出main.ts将是我们应用程序的入口点,所以当应用程序首次启动时,SystemJS 将在app目录中查找该文件。

启动我们的应用程序

初始化过程的下一步是引导我们的应用程序。作为我们应用程序的入口点,main.ts通过执行以下代码来实现这一点:

platform.bootstrapModule(AppModule); 

毫不奇怪,我们正在引导AppModule。正如我们已经知道的那样,引导实例化GuessTheNumberComponent,因为它已被分配给AppModule中的 bootstrap 属性。

注意

这使GuessTheNumberComponent成为我们应用程序的根组件。虽然我们的应用程序只有一个组件,但每个 Angular 应用程序通常都从一个根组件开始。

我们使用以下语法在我们的index.html文件中确定这个组件将出现在哪里:

<my-app>Loading...</my-app>  

正如你可能记得的,我们组件的@Component装饰器标识了组件的选择器:

@Component({ 
  selector: 'my-app' 

综上所述,这些指示 Angular,当我们的页面加载时,需要将我们的组件绑定到index.html中的my-app标签。请注意,这里我们使用的是基于 Web 组件标准的自定义元素。

因此,这启动了引导过程,其继续如下进行:

  • Angular 扫描我们的组件定义,并导入我们在组件的import语句中标识的模块以及其相关的依赖项。这些是我们之前讨论过的模块。

  • 它编译 HTML 视图,从my-app标签声明的位置开始。在这个编译过程中,框架遍历模板,查找所有的插值,并建立视图和我们的类之间的绑定。

  • 编译后,它将视图和我们的组件类链接起来,其中更改会在模型中同步,并在我们与应用程序交互时实时显示。

工具

工具使我们的生活变得更加轻松,我们将分享一些工具,这些工具将帮助您处理 Angular 开发的不同方面,从编写代码到调试:

  • Augury (augury.angular.io/):这是一个用于调试 Angular 应用程序的 Chrome Dev Tools 扩展。虽然该工具的开发者 rangle.io 指出它应该被视为预览版本,但它支持 Angular 的最终版本。

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

  • JSFiddle 和 Plunker:JSFiddle(jsfiddle.net/)和 Plunker(plnkr.co/)是尝试 HTML、CSS 和 JavaScript 代码的绝佳平台。这些工具还具有出色的版本控制和共享功能,如果我们需要帮助,这些功能会很有用。

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

  • JetBrains WebStorm 2016:2.3www.jetbrains.com/webstorm/.

  • Sublime Text 的 Angular2 代码片段github.com/evanplaice/angular2-snippets.

  • Atom Angular 2 Snippets and Completions: github.com/d3viant0ne/angular2-atom-snippets.

  • Visual Studio Code:这是微软推出的全新 IDE(code.visualstudio.com/)。它为 Angular 和 TypeScript 提供了出色的 IntelliSense 和代码完成支持。Visual Studio 2015(www.visualstudio.com/)也包括对 Angular 和 TypeScript 的支持。

  • Angular 社区还在开发 CLI 工具(cli.angular.io/),旨在将开发人员从初始项目设置一直带到最终部署。我们将在第八章中介绍如何使用 Angular CLI,一些实际场景

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

资源

Angular 是一个新的框架,但已经开始形成一个充满活力的社区。除了本书之外,还有博客、文章、支持论坛和大量的帮助。以下是一些有用的主要资源的解释:

就是这样!本章已经完成,现在是时候总结一下你学到了什么。

总结

旅程已经开始,我们已经达到了第一个里程碑。尽管这一章被命名为“入门”,但我们涵盖了许多概念,这些概念您需要了解以理解更大的画面。您的学习源自我们在整个章节中构建和解剖的“猜数字!”应用程序。

您学习了 Angular 如何使用 Web 组件的新兴标准以及 JavaScript 和 TypeScript 的最新版本来实现组件设计模式。我们还回顾了 Angular 中使用的一些构造,比如插值、表达式和数据绑定语法。最后,我们还研究了变更检测和应用程序初始化。

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

第二章:构建我们的第一个应用程序-7 分钟锻炼

我希望第一章足够引人入胜,你想更多地了解 Angular。相信我,我们只是触及了表面!这个框架有很多可提供的,它致力于使使用 JavaScript 进行前端开发更有条理,因此更易管理。

跟着本书的主题,我们将在 Angular 中构建一个新的应用程序,并在此过程中更好地理解这个框架。这个应用程序还将帮助我们探索框架的一些新功能。

本章我们将涵盖的主题包括以下内容:

  • 7 分钟锻炼问题描述:我们详细介绍了本章中构建的应用程序的功能。

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

  • 设计模型:我们应用程序的构建块之一是其模型。我们根据应用程序的要求设计应用程序模型。

  • 理解数据绑定基础设施:在构建7 分钟锻炼视图时,我们将研究框架的数据绑定能力,包括属性属性样式事件绑定。

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

  • 使用输入属性进行跨组件通信:构建了一些子组件后,我们学习了如何使用输入属性将数据从父组件传递给其子组件。

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

  • Angular 管道:Angular 管道提供了一种格式化视图内容的机制。我们将探索一些标准的 Angular 管道,并构建我们自己的管道,以支持从秒转换为 hh:mm:ss。

让我们开始吧!我们要做的第一件事是定义我们的7 分钟锻炼应用程序的模型。

什么是 7 分钟锻炼?

我们希望每个阅读本书的人都能保持身体健康。因此,这本书应该具有双重目的;它不仅应该激发你的思维,还应该督促你关注自己的身体健康。有什么比构建一个针对身体健康的应用程序更好的方式呢!

7 分钟锻炼是一种锻炼/训练计划,要求我们在七分钟的时间内快速连续进行一组十二个练习。由于其益处和短暂的锻炼时间,7 分钟锻炼变得非常受欢迎。我们无法证实或否认这些说法,但进行任何形式的剧烈体育活动总比什么都不做要好。如果您对这项锻炼感兴趣,可以查看well.blogs.nytimes.com/2013/05/09/the-scientific-7-minute-workout/了解更多信息。

该应用程序的技术细节包括进行一组 12 个练习,每个练习需要 30 秒。然后是一个简短的休息时间,然后开始下一个练习。对于我们正在构建的应用程序,我们将每次休息 10 秒。因此,总持续时间略长于 7 分钟。

在本章的结尾,我们将准备好7 分钟锻炼应用程序,它将看起来像这样:

什么是 7 分钟锻炼?

下载代码库

该应用程序的代码可以从 GitHub 网站github.com/chandermani/angular2byexample下载,该网站专门为本书创建。由于我们正在逐步构建应用程序,我们创建了多个检查点,这些检查点映射到GitHub 分支,如checkpoint2.1checkpoint2.2等。在叙述过程中,我们将突出显示用于参考的分支。这些分支将包含截至该时间点的应用程序工作。

注意

7 分钟锻炼代码位于名为trainer的存储库文件夹中。

那么让我们开始吧!

建立构建

请记住,我们正在构建一个现代平台,对于这个平台,浏览器仍然缺乏支持。因此,在 HTML 中直接引用脚本文件是不可能的(虽然常见,但这是一种过时的方法,我们无论如何都应该避免)。当前的浏览器不理解TypeScript;事实上,甚至ES 2015(也称为 ES6)也不受支持。这意味着必须有一个过程将用 TypeScript 编写的代码转换为标准的JavaScript(ES5),以便浏览器可以使用。

因此,对于几乎任何 Angular 2 应用程序来说,建立构建过程变得至关重要。对于小型应用程序来说,构建过程可能看起来有些多余,但它也有其他一些优势。

如果你是一个在 Web 堆栈上工作的前端开发人员,你无法避免Node.js。这是最广泛使用的 Web/JavaScript 开发平台。因此,毫无疑问,Angular 2 的构建设置也支持 Node.js,使用诸如GruntGulpJSPMwebpack等工具。

注意

由于我们是在 Node.js 平台上构建的,请在开始之前安装 Node.js。

虽然在线上有相当复杂的构建设置选项,但我们选择使用Gulp进行最小化设置。原因是因为没有一个适合所有情况的解决方案。此外,这里的主要目的是学习 Angular 2,而不是过多担心设置和运行构建的复杂性。

社区创建的一些值得注意的起始站点和构建设置如下:

开始网站 位置
angular2-webpack-starter bit.ly/ng2webpack
angular2-seed bit.ly/ng2seed
angular-cli-我们在第一章中提到过这个工具,入门。它允许我们生成初始的代码设置,包括构建配置,并且还具有良好的脚手架能力。 bit.ly/ng2-cli

如果你对Node.js或整个构建过程非常陌生,自然会有一个问题:一个典型的 Angular 构建包括什么?这取决于情况!要了解这个过程,如果我们看一下为我们的应用定义的构建设置,会很有益。然后让我们在本地设置应用的构建。按照以下步骤让这个样板 Angular 2 应用运行起来:

  1. bit.ly/ng2be-base下载这个应用的基本版本,并解压到您机器上的一个位置。如果你熟悉 Git 的工作原理,你可以直接克隆存储库并检出base分支:
**git checkout base**

这段代码作为我们应用的起点。

  1. 从命令行导航到trainer文件夹并执行以下命令:
**npm i -g gulp typings**
**npm install**

第一个命令全局安装了 Gulp(和 typings),这样你就可以在任何地方调用Gulp命令行工具并执行Gulp 任务Gulp 任务是 Gulp 在构建执行期间执行的活动。如果我们查看 Gulp 构建脚本(我们很快就会做),我们会意识到它只是在构建发生时执行的一系列任务。第二个命令安装了应用程序的依赖项(以npm包的形式)。在 Node.js 世界中,是第三方库,它们要么被应用程序使用,要么支持应用程序的构建过程。例如,Gulp 本身就是一个 Node.js 包。npm是一个从中央存储库中拉取这些包的命令行工具。

  1. 一旦安装了 Gulp 并且 npm 从 npm 存储库中拉取了依赖项,我们就可以构建和运行应用程序了。从命令行输入以下命令:
**gulp play**

这将编译并运行应用程序。如果构建过程顺利进行,将会打开默认的浏览器窗口/标签,显示一个简单的 Hello World 页面(http://localhost:9000/index.html)。我们已经准备好开始在 Angular 2 中开发我们的应用程序了!

但在我们这样做之前,了解一下幕后发生了什么将会很有趣。

构建内部机制

即使你是 Gulp 的新手,看一下gulpfile.js也能让你对构建过程有一个大致的了解。Gulp 构建是按预定义顺序执行的一系列任务。这样一个过程的最终结果是一些形式的包代码,可以直接运行。如果我们使用TypeScript/ES2015或者其他浏览器不原生支持的类似语言来构建我们的应用程序,那么我们需要一个额外的构建步骤,称为转译

代码转译

截至 2016 年,浏览器仍然无法运行ES2015代码。虽然我们很快就会接受隐藏 JavaScript(ES5)不太好的部分的语言,但我们仍然受到浏览器能力的限制。在语言特性方面,ES5仍然是最安全的选择,因为所有浏览器都支持它。显然,我们需要一种机制将我们的TypeScript代码转换为纯 JavaScript(ES5)。微软有一个 TypeScript 编译器来完成这项工作。

TypeScript 编译器将 TypeScript 代码转换为可以在所有浏览器中运行的 ES5 格式代码。这个过程通常被称为转译,由于 TypeScript 编译器执行,所以它被称为转译器

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

  • 构建时转译:作为构建过程的一部分进行转译,将脚本文件(在我们的例子中是 TypeScript 的 .ts 文件)编译成普通的 JavaScript。我们的构建设置使用构建时转译。

  • 运行时转译:这发生在浏览器中的运行时。我们包括原始的特定语言脚本文件(在我们的例子中是 .ts 文件),以及 TypeScript 编译器,它在浏览器中预先加载,动态地编译这些脚本文件。虽然运行时转译简化了构建设置过程,但建议仅限于开发工作流程,考虑到加载转译器和动态转译代码所涉及的额外性能开销。第一章中的示例,入门,使用了运行时转译,因此不需要任何构建设置。再去看一遍吧!

转译的过程不仅限于 TypeScript。每种面向 Web 的语言,如 CoffeeScriptES2015 或任何其他浏览器本身无法理解的语言,都需要转译。大多数语言都有转译器,其中一些著名的(除了 TypeScript 之外)还有 tracuerbabel

要编译 TypeScript 文件,我们可以使用以下命令行手动安装 TypeScript 编译器:

**npm install -g typescript**

一旦安装,我们可以使用编译器(tsc.exe)将任何 TypeScript 文件编译成 ES5 格式。

但对于我们的构建设置,这个过程是通过 ts2js Gulp 任务自动化的(查看 gulpfile.js)。如果你想知道我们何时安装了 TypeScript... 好吧,我们是在第一次设置代码时作为 npm install 步骤的一部分来安装的。gulp-typescript 包会将 TypeScript 编译器作为依赖项下载。

有了对转译的基本理解,我们可以总结一下我们的构建设置发生了什么:

  • gulp play 命令启动构建过程。这个命令告诉 Gulp 通过调用 play 任务开始构建过程。

  • 由于 play 任务依赖于 ts2js 任务,ts2js 会首先执行。ts2js 编译位于 src 文件夹中的 TypeScript 文件(.ts)并将它们输出到根目录下的 dist 文件夹中。

  • 构建后,启动了一个静态文件服务器,用于提供所有应用程序文件,包括静态文件(图像、视频和 HTML)和脚本文件(检查gulp.play任务)。

  • 此后,构建过程将监视您所做的任何脚本文件更改(gulp.watch任务),并在飞行中重新编译代码。

注意

livereload 也已为应用程序设置。对代码的任何更改都会自动刷新运行应用程序的浏览器。如果自动浏览器刷新失败,我们总是可以手动刷新。

这是运行 Angular 应用所需的基本构建设置。对于复杂的构建需求,我们可以随时查看具有更完整和健壮的构建设置的起始/种子项目,或者构建我们自己的项目。

接下来让我们看一下已经存在的样板应用代码以及整体的代码组织。

组织代码

这是我们将组织应用程序的代码和其他资产的方式:

组织代码

trainer文件夹是应用程序的根文件夹,它有一个文件夹(static)用于静态内容(如图像、CSS、音频文件等),以及一个文件夹(src)用于应用程序的源代码。

应用程序源代码的组织受到 Angular 的设计和 Angular 团队发布的 Angular 风格指南(bit.ly/ng2-style-guide)的影响。正如我们在第一章中看到的,入门,在 Angular 2 的主要构建模块中包括组件components文件夹托管了我们创建的所有组件。我们将为应用程序的每个主要组件在这个文件夹中创建子文件夹。每个组件文件夹将包含与该组件相关的工件,包括其模板、实现和其他相关项目。随着我们构建应用程序,我们还将在src文件夹中添加更多的顶级文件夹。

如果我们现在看一下代码,components/app文件夹已经定义了一个根级组件 TrainerAppComponent 和根级模块 AppModulebootstrap.ts文件包含了用于引导/加载应用模块(AppModule)的代码。

注意

7 分钟锻炼使用即时(JIT)编译来编译 Angular 视图。这意味着视图在在浏览器中呈现之前才被编译。Angular 在浏览器中运行一个编译器来编译这些视图。

Angular 也支持提前编译(AoT)模型。通过 AoT,视图在服务器端使用 Angular 编译器的服务器版本进行编译。返回给浏览器的视图是预编译的,可以立即使用。

对于7 分钟锻炼,我们坚持使用 JIT 编译模型,因为与需要服务器端调整和包安装的 AoT 相比,它更容易设置。

我们强烈建议您在生产应用中使用 AoT 编译,因为它提供了许多好处。 AoT 可以改善应用程序的初始加载时间,也可以减小应用程序的大小。请查看 AoT 平台文档(食谱bit.ly/ng2-aot 了解 AoT 编译如何使您受益。

是时候开始我们的第一个重点领域了,那就是应用的模型!

7 分钟锻炼模型

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

  • 能够开始锻炼。

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

  • 提供当前锻炼的视觉描述。

  • 提供如何进行特定锻炼的逐步说明

  • 当前锻炼剩余的时间

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

我们将为这个应用添加一些其他有价值的功能,如下所示:

  • 暂停当前的锻炼。

  • 提供关于接下来要进行的锻炼的信息。

  • 提供音频提示,使用户可以在不断地盯着屏幕的情况下进行锻炼。这包括:

  • 计时器点击声

  • 下一个锻炼的细节

  • 发出锻炼即将开始的信号

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

正如我们所看到的,这个应用的中心主题是锻炼运动。在这里,锻炼是按特定顺序进行的一系列锻炼,持续一段特定时间。因此,让我们继续定义我们的锻炼和运动的模型。

根据刚才提到的要求,我们将需要关于一项锻炼的以下细节:

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

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

  • 锻炼的描述。

  • 关于如何进行锻炼的说明。

  • 锻炼的图片。

  • 锻炼的音频剪辑名称。

  • 相关视频。

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

src/components文件夹内创建一个名为workout-runner的文件夹,并将checkpoint2.1分支文件夹workout-runnerbit.ly/ng2be-2-1-model-ts)中的model.ts文件复制到相应的本地文件夹中。 model.ts包含了我们应用程序的模型定义。

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) { } 
} 

这三个类构成了我们的基本模型,我们将在将来决定是否需要在实现应用程序功能时扩展这个模型。

由于我们已经从预配置和基本的 Angular 应用程序开始,您只需要了解这个应用程序是如何引导的。

应用程序引导

第一章,“入门”,对应用程序引导过程进行了很好的介绍。 7 分钟锻炼的应用程序引导过程保持不变,查看src文件夹。有一个bootstrap.ts文件,除了imports之外只有执行位:

platformBrowserDynamic().bootstrapModule(AppModule); 

boostrapModule函数调用实际上通过加载根模块AppModule引导应用程序。这个过程是由index.html中的这个调用触发的:

System.import('app').catch(console.log.bind(console)); 

System.import语句通过从bootstrap.ts加载第一个模块来启动应用程序引导过程。

注意

在 Angular 2 的上下文中定义的模块(使用 @NgModule 装饰器)与 SystemJS 加载的模块是不同的。SystemJS 模块是 JavaScript 模块,可以采用符合 CommonJSAMDES2015 规范的不同格式。

Angular 模块是 Angular 用来分隔和组织其构件的构造。

除非讨论的上下文是 SystemJS,否则对 模块 的任何引用都意味着 Angular 模块

接下来的部分详细介绍了 SystemJS 如何加载我们的 Angular 应用程序。请记住,下一部分中对模块的所有引用都是 JavaScript 模块。如果你愿意,你可以跳过这一部分,因为它与 Angular 开发没有直接关系。

使用 SystemJS 加载应用程序

SystemJS 通过在 index.html 中调用 System.import('app') 来开始加载 JavaScript 模块。

SystemJS 首先加载 bootstrap.ts。在 bootstrap.ts 中定义的导入会导致 SystemJS 加载这些导入的模块。如果这些模块导入还有进一步的 import 语句,SystemJS 也会递归加载它们。

最后,一旦所有导入的模块都加载完毕,platformBrowserDynamic().bootstrapModule(AppModule); 函数就会被执行。

SystemJS import 函数需要知道模块的位置才能工作。我们在文件 systemjs.config.js 中定义这一点,并在 index.html 中引用它,在 System.import 脚本之前:

<script src="systemjs.config.js"></script> 

这个配置文件包含了 SystemJS 正确工作所需的所有配置。

打开 systemjs.config.jsSystem.import 函数的 app 参数指向一个名为 dist 的文件夹,如 map 对象中所定义的:

var map = { 
 **'app': 'dist',** 
    ... 
 } 

接下来的变量 packages 包含了一些设置,提示 SystemJS 在没有指定文件名/扩展名时如何加载一个包中的模块。对于 app,默认模块是 bootstrap.js

var packages = { 
 **'app': { main: 'bootstrap.js',  defaultExtension: 'js' },** 
    ... 
}; 

你是否想知道 dist 文件夹与我们的应用程序有什么关系?嗯,这就是我们转译后的脚本所在的地方。因为我们的应用程序是用 TypeScript 构建的,TypeScript 编译器将 src 文件夹中的 .ts 脚本文件转换为 JavaScript 模块,并将它们存放在 dist 文件夹中。然后 SystemJS 加载这些编译后的 JavaScript 模块。转译后的代码位置已经作为构建定义的一部分在 gulpfile.js 中配置好了。在 gulpfile.ts 中找到这一节:

return tsResult.js 
    .pipe(sourcemaps.write()) 
 **.pipe(gulp.dest('dist'))**

我们应用程序使用的模块规范可以在 gulpfile.js 中再次验证。看一下这一行:

noImplicitAny: true, 
**module: 'system',** 
target: 'ES5', 

这些是 TypeScript 编译器选项,其中一个是module,即目标模块定义格式。

注意

system模块类型是一种新的模块格式,旨在支持 ES5 中 ES2015 模块的确切语义。

一旦脚本被转译并且模块定义被创建(以目标格式),SystemJS 可以加载这些模块及其依赖项。

现在是时候投入行动了;让我们构建我们的第一个组件。

我们的第一个组件 - WorkoutRunnerComponent

要实现WorkoutRunnerComponent,我们需要概述应用程序的行为。

我们在WorkoutRunnerComponent实现中要做的是:

  1. 开始锻炼。

  2. 显示进行中的锻炼并显示进度指示器。

  3. 在锻炼时间结束后,显示下一个锻炼。

  4. 重复此过程,直到所有锻炼结束。

让我们从实现开始。我们将首先创建WorkoutRunnerComponent的实现。

src/components文件夹中打开workout-runner文件夹,并向其中添加一个名为workout-runner.component.ts的新代码文件。将以下代码块添加到文件中:

import {WorkoutPlan, ExercisePlan, Exercise, ExerciseProgressEvent, ExerciseChangedEvent} from '../../services/model';
export class WorkoutRunnerComponent { } 

import模块声明允许我们引用model.ts文件中定义的类在WorkoutRunnerComponent中。

我们首先需要设置锻炼数据。让我们通过向WorkoutRunnerComponent类添加构造函数和相关类属性来做到这一点:

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

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

buildWorkout函数是一个冗长的函数,所以最好是从 Git 分支 checkpoint2.1(bit.ly/ng2be-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对象并将锻炼数据推送到锻炼数组中(一个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; 
    let intervalId = setInterval(() => { 
      if (this.exerciseRunningDuration >=  
        this.currentExercise.duration) { 
          clearInterval(intervalId);  
      } 
      else { this.exerciseRunningDuration++;    } 
    }, 1000); 
} 

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

currentExercise: ExercisePlan; 
exerciseRunningDuration: number; 

我们使用setInterval JavaScript 函数,延迟 1 秒(1,000 毫秒)来通过递增exerciseRunningDuration来跟踪练习进度。setInterval每秒调用一次回调函数。clearInterval调用在练习持续时间结束后停止计时器。

提示

TypeScript 箭头函数传递给setInterval的回调参数(()=>{...})是一个lambda 函数(或 ES 2015 中的箭头函数)。Lambda 函数是匿名函数的简写形式,具有附加的好处。您可以在basarat.gitbooks.io/typescript/content/docs/arrow-functions.html了解更多信息。

到目前为止,我们有一个WorkoutRunnerComponent类。我们需要将其转换为Angular 组件并定义组件视图。我们在第一章中也做过类似的事情,入门

添加Component的导入和组件装饰器(高亮代码):

import {WorkoutPlan, ExercisePlan, Exercise} from './model'
**import {Component} from '@angular/core';**
**@Component({**
 **selector: 'workout-runner',**
 **template: `**
 **<pre>Current Exercise: {{currentExercise | json}}</pre>
    <pre>Time Left: {{currentExercise?.duration-exerciseRunningDuration}}</pre>`**
**})** 
export class WorkoutRunnerComponent { 

这里没有太多需要解释的,因为您已经知道如何创建一个 Angular 组件。您了解@Component装饰器的作用,selector的作用,以及如何使用template

注意

@Component装饰器生成的 JavaScript 包含有关组件的足够元数据。这使得 Angular 框架能够在运行时实例化正确的组件。

反引号 )括起来的字符串是 ES2015 的一个新添加。也称为模板文字,这样的字符串文字可以是多行的,并允许嵌入表达式(不要与 Angular 表达式混淆)。在这里查看 MDN 文章 bit.ly/template-literals 获取更多详情。

前面的模板 HTML 将呈现原始的ExercisePlan对象和剩余的锻炼时间。在第一个插值内有一个有趣的表达式:currentExercise | jsoncurrentExercise属性在WorkoutRunnerComponent中定义,但是|符号和其后的内容(json)呢?在 Angular 2 世界中,它被称为管道管道的唯一目的是转换/格式化模板数据。这里的json管道执行 JSON 数据格式化。在本章后面你会学到更多关于管道的知识,但是为了对json管道的作用有一个大致的了解,我们可以移除json管道和|符号并渲染模板;我们接下来要做这个。

正如我们在第一章中看到的,入门,在我们可以使用WorkoutRunnerComponent之前,我们需要在一个模块中声明它。由于我们的应用目前只有一个模块(AppModule),我们将WorkoutRunnerComponent的声明添加到其中。通过添加下面突出显示的代码来更新app.module.ts

**import {WorkoutRunnerComponent} from '../workout-runner/workout-runner.component';** 
@NgModule({ 
  imports: [BrowserModule], 
 **declarations: [TrainerAppComponent, WorkoutRunnerComponent],**

现在WorkoutRunnerComponent可以在根组件中引用,以便渲染它。按照下面的代码在src/components/app/app.component.ts中进行修改:

@Component({ 
... 
    template: `  
<div class="navbar ...> ... 
</div> 
<div class="container ...> 
**<workout-runner></workout-runner>** 
</div>` 
}) 

我们已经改变了根组件模板,并在其中添加了workout-runner元素。这将在我们的根组件中渲染WorkoutRunnerComponent

虽然实现看起来很完整,但关键部分缺失了。在代码中我们实际上并没有开始锻炼。锻炼应该在我们加载页面时立即开始。

组件生命周期钩子将拯救我们!

组件生命周期钩子

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

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

  • 当组件的输入属性发生变化时,Angular 会调用ngOnChanges

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

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

注意

Angular 为每个这些钩子都有 TypeScript 接口,可以应用到组件类中,以清晰地传达意图。例如:

class WorkoutRunnerComponent implements OnInit { 
  ngOnInit (){ 
    ... 
} 
... 

接口名称可以通过从函数名称中删除前缀ng来派生。

我们要在这里使用的钩子是ngOnInitngOnInit函数在组件的数据绑定属性初始化后但视图初始化开始之前触发。

ngOnInit函数添加到WorkoutRunnerComponent类中,并调用开始锻炼的方法:

ngOnInit() { 
    this.start(); 
} 

并在WorkoutRunnerComponent上实现OnInit接口;它定义了ngOnInit方法:

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

注意

还有许多其他生命周期钩子,包括ngOnDestroyngOnChangesngAfterViewInit,组件都支持;但我们不会在这里深入讨论它们中的任何一个。查看开发者指南(bit.ly/ng2-lifecycle)上的生命周期钩子,了解更多关于其他钩子的信息。

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

**gulp play**

如果没有编译错误,并且浏览器自动加载应用程序(http://localhost:9000/index.html),我们应该看到以下输出:

组件生命周期钩子

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

注意

现在也是一个尝试渲染currentExercise而不使用json管道(使用{{currentExercise}})的好时机,看看会渲染出什么。

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

更新setIntervalif条件内的代码:

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

if条件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; 
} 

WorkoutRunnerComponent.getNextExercise 返回需要执行的下一个练习。

注意

请注意,getNextExercise 返回的对象是一个 ExercisePlan 对象,其中包含了练习的细节和练习运行的持续时间。

实现相当不言自明。如果当前练习是休息,从 workoutPlan.exercises 数组中获取下一个练习(基于 currentExerciseIndex);否则,下一个练习是休息,前提是我们不在最后一个练习上(else if 条件检查)。

有了这些,我们已经准备好测试我们的实现了。所以继续刷新 index.html。每隔 10 或 30 秒后,练习应该会翻转。太棒了!

注意

当前的构建设置会在保存文件时自动编译对脚本文件所做的任何更改;它还会在这些更改后刷新浏览器。但是,如果 UI 没有更新或事情不如预期地工作,刷新浏览器窗口。

如果您在运行代码时遇到问题,请查看 Git 分支 checkpoint2.1,以获取到目前为止我们所做的工作的可工作版本。

或者,如果您不使用 Git,请从 bit.ly/ng2be-checkpoint2-1 下载 Checkpoint 2.1 的快照(ZIP 文件)。在首次设置快照时,请参考 trainer 文件夹中的 README.md 文件。

我们已经在控制器上做了足够的工作,现在让我们构建视图。

构建 7 分钟锻炼视图

在定义模型和实现组件时,大部分工作已经完成。现在我们只需要使用 Angular 的超级数据绑定功能来设计 HTML。这将会简单、甜美且优雅!

对于 7 分钟锻炼 视图,我们需要显示练习名称、练习图片、进度指示器和剩余时间。从 Git 分支 checkpoint2.2workout-runner 文件夹中复制 workout-runner.html 文件(或从 bit.ly/ng2be-2-2-workout-runner-html 下载),并将其保存到本地相应的文件夹中。视图 HTML 如下所示:

<div class="row"> 
  <div id="exercise-pane" class="col-sm-8 col-sm-offset-2"> 
    <div class="row workout-content"> 
      <div class="workout-display-div"> 
        <h1>{{currentExercise.exercise.title}}</h1> 
        <img class="img-responsive" [src]="'/static/images/' + 
         currentExercise.exercise.image" /> 
        <div class="progress time-progress"> 
          <div class="progress-bar" 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> 
  </div> 
</div> 

在我们深入研究视图 HTML 之前,我们仍然需要引用视图。WorkoutRunnerComponent目前使用内联模板。我们需要通过指向上述 HTML 文件来将其外部化。通过用templateUrl属性替换template属性来更新workout-runner.component.ts文件:

templateUrl: '/src/components/workout-runner/workout-runner.html' 

装饰器属性templateUrl允许我们引用视图 HTML 的外部文件。

提示

内联与外部模板

我们刚刚看到了定义视图模板的两种方式:使用template(内联)和使用templateUrl(外部)属性。哪种更受青睐?

嗯,考虑到在 Angular2 中构想组件的方式,作为独立的构建模块,对于我们的应用程序来说,内联模板是有意义的。组件所需的一切都在单个文件中。然而,内联模板有一个缺点;格式化 HTML 变得困难,IDE 支持非常有限,因为内容被视为字符串文字。当我们将 HTML 外部化时,我们可以像开发普通 HTML 文档一样开发模板。

允许我们同时拥有最佳解决方案的一种可能的解决方案是在开发过程中将 HTML 模板定义为单独的文件,并使用标准的templateUrl属性引用它们。但作为生产发布的一部分,配置构建过程以将模板 HTML 内联到组件定义中。

在我们了解视图中的 Angular 部分之前,让我们再次运行应用程序。保存workout-runner.component.ts中的更改。如果gulp play已经在运行,请刷新页面,看到完整的锻炼应用程序!

构建 7 分钟锻炼视图

我们的基本应用程序正在运行。锻炼图片和标题显示出来,进度指示器显示进度,并且当锻炼时间结束时进行锻炼过渡。这确实感觉很棒!

注意

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

如果我们回顾一下视图 HTML,除了使用bootstrap CSS 和一些自定义 CSS 进行样式设置之外,还有一些有趣的 Angular 片段需要我们的注意。而且,由于一切都与 Angular 绑定基础设施相关,现在是时候深入了解这个基础设施并探索其能力了。

Angular 2 绑定基础设施

任何现代 JavaScript 框架都具有强大的模型-视图绑定支持,Angular 也不例外。任何绑定基础设施的主要目标都是减少我们需要编写的样板代码,以保持视图和模型的同步。强大的绑定基础设施总是声明性和简洁的。

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

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

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

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

  • 从模型到视图的单向绑定:在模型到视图绑定中,对模型的更改与视图保持同步。插值、属性、属性、类和样式绑定属于这一类别。

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

  • 双向绑定:双向绑定,顾名思义,保持视图和模型同步。有一个专门用于双向绑定的特殊绑定构造,ngModel

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

  • 插值

  • 属性绑定

  • 属性绑定

  • 类绑定

  • 样式绑定

  • 事件绑定

7 分钟锻炼视图使用了一些这些构造,所以现在是学习它们的好时机。插值是第一个。

插值

插值很简单。插值符号({{ }})中的表达式在模型的上下文中进行评估,评估的结果嵌入到 HTML 中。我们在第一章 入门 和训练运行器视图中一直看到这些。我们使用插值来呈现练习标题和剩余练习时间:

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

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

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

属性绑定

看一下这个视图摘录:

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

看起来我们正在将imgsrc属性设置为在应用运行时评估的表达式。这是不正确的!

看起来是属性绑定的东西,实际上是属性绑定。而且,由于我们许多人不了解 HTML 元素的属性属性之间的区别,这个说法非常令人困惑。因此,在我们看属性绑定如何工作之前,让我们试着理解一下元素的属性属性之间的区别。

属性与属性的区别

拿任何 DOM 元素 API,你会发现属性属性函数事件。虽然事件和函数是不言自明的,但很难理解属性属性之间的区别。我们通常可以互换使用这些词,这也没有太大帮助。例如,这行代码:

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

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

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

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

input.value // value property 

value属性始终返回当前的输入内容,即"Angular2 rocks!"。而这个 DOM API 函数:

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

返回value属性,并且最初始终设置为Awesome Angular2

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

还有许多其他细微差别增加了这种混淆。这些包括以下内容:

  • 属性属性的同步在不同属性之间不一致。正如我们在前面的例子中看到的,对input上的value 属性的更改不会影响value 属性,但对于所有属性-值对来说并非如此。图像元素的src属性就是一个很好的例子;属性或属性值的更改始终保持同步。

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

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

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

本次讨论的目的是确保您了解 DOM 元素的属性和属性之间的区别。这种新的思维模式将在我们继续探索框架的属性和属性绑定能力时帮助您。让我们回到我们对属性绑定的讨论。

属性绑定继续...

既然我们了解了属性和属性之间的区别,让我们再次看一下绑定的例子:

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

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

属性绑定的一般语法如下:

[target]="sourceExpression"; 

在属性绑定的情况下,目标是 DOM 元素或组件上的属性。目标也可以是事件,我们将很快看到当我们执行事件绑定时。

绑定源和目标

提示

了解 Angular 绑定中源和目标之间的区别很重要。

出现在[]内的属性是目标,有时被称为绑定目标。目标是数据的消费者,始终指的是组件/元素上的属性。表达式构成了提供数据给目标的数据源。

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

提示

属性绑定、事件绑定和属性绑定不使用插值符号。以下是无效的:[src]="{{'/static/images/' + currentExercise.exercise.image}}" 如果您曾经使用过 Angular 1,将属性绑定到任何 DOM 属性允许 Angular 2 摆脱许多指令,如ng-disableng-srcng-key*ng-mouse*等。

属性绑定也适用于组件属性!组件可以定义输入和输出属性,这些属性可以绑定到视图,例如:

<workout-runner [exerciseRestDuration]="restDuration"></workout-runner> 

这个假设的片段将WorkoutRunnerComponent类上的exerciseRestDuration属性绑定到容器组件(父级)上定义的restDuration属性,允许我们将休息时间作为参数传递给WorkoutRunnerComponent。随着我们增强应用程序并开发新组件,您将学习如何在组件上定义自定义属性和事件。

注意

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

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

注意

当我们通过描述插值为属性绑定的语法糖来结束上一节时,意图是强调两者如何可以互换使用。

插值语法比属性绑定更简洁,因此非常有用。这是 Angular 解释插值的方式:<h3>主标题 - {{heading}}</h3> <h3 [text-content]="' 主标题 - '+heading"></h3> Angular 将第一个语句中的插值转换为textContent属性绑定(第二个语句)。

虽然属性绑定使我们能够将任何表达式绑定到目标属性,但我们在使用表达式时应该小心。这也是因为 Angular 的变更检测系统会在应用程序的生命周期中多次评估您的表达式绑定,而组件是活动的。因此,在将表达式绑定到属性目标时,请牢记这两个准则。

快速表达式评估

属性绑定表达式应该快速评估。当函数被用作表达式时,评估速度会变慢。考虑这个绑定:

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

这种插值将doLotsOfWork的返回值绑定到div上。这个函数在每次 Angular 执行变更检测运行时都会被调用,根据一些内部启发式算法,Angular 会经常执行变更检测。因此,我们使用的表达式必须快速评估。

无副作用的绑定表达式

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

<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 框架有两种模式,开发生产。如果我们在应用程序中启用生产模式,上述错误就不会出现。

查看 http://bit.ly/enableProdMode 的框架文档以获取更多详细信息。

底线是,在属性绑定中使用的表达式应该是无副作用的。

现在让我们看一些与用于属性绑定的方括号语法相关的有趣行为。[]中指定的目标不仅限于组件/元素属性。要理解目标选择,我们需要介绍一个新概念:指令

Angular 指令

作为一个框架,Angular 试图增强 HTML DSL特定领域语言的缩写)。

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

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

然后还有第三种,称为属性指令

虽然组件有自己的视图,但属性指令是用来增强现有元素/组件的外观和/或行为。在 workout-runner 的视图中使用的ngStyle指令就是一个很好的例子:

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

ngStyle指令没有自己的视图;相反,它允许我们使用绑定表达式在 HTML 元素上设置多个样式。我们将在本书的后面涵盖许多框架属性指令。

还有另一类指令,称为结构指令。同样,结构指令没有自己的视图;它们改变应用它们的元素的 DOM 布局。ngForngIf指令属于这一类。我们在本章后面专门介绍了解这些结构指令的完整部分。

提示

指令命名法

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

通过对 Angular 具有不同类型指令的理解,我们可以理解绑定的目标选择过程。

绑定的目标选择

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

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

首先搜索具有选择器ngStyle的指令。由于 Angular 已经有了ngStyle指令,它成为了目标。如果 Angular 没有内置的ngStyle指令,绑定引擎将会在底层组件上寻找一个名为ngStyle的属性。

如果没有匹配目标表达式,就会抛出未知指令错误。

这涵盖了大部分 Angular 的属性绑定能力。接下来,让我们看看属性绑定,并理解它的作用。

属性绑定

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

看起来我们可以使用标准的插值语法来设置属性,但那是行不通的!打开workout-runner.html,用这个高亮代码替换两个包含在[]中的 aria 属性attr.aria-valuenowattr.aria-valuemax

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

保存并刷新页面。然后,Angular 将抛出一个有趣的错误:

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

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

希望能传达这一点:要绑定属性,请使用属性绑定

提示

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,当expressionfalse时,移除它。一个简单的例子可以是这样的:

<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 提供的一些属性指令。

虽然下一节专门介绍了如何使用ngClassngStyle属性指令,但直到第六章,深入了解 Angular 2 指令,我们才学会如何创建自己的属性指令。

使用 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> 

inputRequired为 true 时,应用required类,并在评估为false时移除。

提示

指令(自定义或平台),像组件一样,必须在模块上注册后才能使用。

好了!这涵盖了我们新开发的视图所需探索的一切。

注意

正如前面所述,如果您在运行代码时遇到问题,请查看 Git 分支checkpoint2.2

如果不使用 Git,请从bit.ly/ng2be-checkpoint2-2下载checkpoint2.2的快照(zip 文件)。首次设置快照时,请参考trainer文件夹中的README.md文件。

是时候添加一些增强并更多地了解这个框架了!

首先,我们将创建一个专门用于锻炼运行的新模块。与锻炼运行相关的所有内容,包括WorkoutRunnerComponent,都放入此模块。这给了我们一个很好的机会,以极大的细节重新审视 Angular 模块。

探索 Angular 模块

随着7 分钟锻炼应用程序的增长,我们需要为其添加新的组件/指令/管道/其他构件,因此需要组织这些项目。每个项目都需要成为 Angular 模块的一部分。

一个天真的方法是在我们应用程序的根模块(AppModule)中声明所有东西,就像我们在WorkoutRunnerComponent中所做的那样,但这违背了 Angular 模块的整体目的。

要理解为什么单一模块方法永远不是一个好主意,让我们重新审视一下 Angular 模块。

理解 Angular 模块

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

Angular 模块主要定义:

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

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

  • 它依赖的其他模块

  • 模块希望在整个应用程序中提供的服务

任何规模较大的 Angular 应用程序都会有模块彼此相互关联:一些模块消耗其他模块的构件,一些模块向其他模块提供构件,一些模块两者都做。

作为标准做法,模块分离是基于特性的。将应用程序分成特性或子特性(对于大特性),并为每个特性创建模块。即使框架也遵循此准则,因为所有框架构造都分布在各个模块中:

  • 有无处不在的BrowserModule,它聚合了每个基于浏览器的 Angular 应用程序中使用的标准框架构造

  • 如果我们想使用 Angular 路由框架,就会有RouterModule

  • 如果我们的应用程序需要通过 HTTP 与服务器通信,就会有HtppModule

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

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

  • 外部依赖项(使用imports

  • 模块的构件(使用declarations

  • 模块的导出(使用exports

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

该图表突出显示了模块的内部以及它们之间的链接:

理解 Angular 模块

我们希望从所有这些讨论中有一件事是清楚的:创建一个单一的应用程序范围模块并不是正确使用 Angular 模块的方式,除非你正在构建一些基本的东西。

向 7 分钟锻炼添加新模块

我们也将向7 分钟锻炼添加更多模块(嘿,我们言传身教!)。首先,我们将创建一个新模块,WorkoutRunnerModule,并在其中声明WorkoutRunnerComponent。从现在开始,我们为锻炼执行专门创建的每个组件/指令/管道都放入WorkoutRunnerModule

workout-runner文件夹内创建一个新文件workout-runner.module.ts,并添加此模块定义:

import { NgModule }      from '@angular/core'; 
import { BrowserModule } from '@angular/platform-browser'; 
import { WorkoutRunnerComponent }  from './workout-runner.component'; 

@NgModule({ 
    imports: [BrowserModule], 
    declarations: [WorkoutRunnerComponent], 
    exports: [WorkoutRunnerComponent], 
}) 
export class WorkoutRunnerModule { } 

该模块看起来类似于AppModule。有趣的是,WorkoutRunnerComponent既是模块声明和导出的一部分。如果没有导出,我们就无法在WorkoutRunnerModule之外使用WorkoutRunnerComponent

提示

只有导出的组件/指令/管道才能在模块之间使用。

BrowserModule是我们经常导入的模块。BrowserModule模块声明了任何基于浏览器的应用程序所需的所有常见框架指令(如NgIfNgFor等),管道和服务。

现在我们有了WorkoutRunnerModule,我们需要在AppModule中引用此模块,并删除AppModule中对WorkoutRunnerComponent的任何直接引用。

打开app.module.ts,删除特定于WorkoutRunnerComponent的导入和声明。接下来,通过将其附加到现有模块导入并添加必要的导入引用来导入WorkoutRunnerModule。请参见此处的突出显示的代码:

**import {WorkoutRunnerModule}** 
**from '../workout-runner/workout-runner.module';** 
@NgModule({ 
 **imports: [BrowserModule, WorkoutRunnerModule],**

我们准备就绪了!行为上没有任何改变,但我们现在更有条理了。

你是否想知道如果我们也将WorkoutRunnerComponent声明留在AppModule中会发生什么?记住,WorkoutRunnerModule也声明了WorkoutRunnerComponent。让我们试试。继续吧,将WorkoutRunnerComponent添加回AppModuledeclarations部分并运行应用程序。

应用程序在浏览器控制台中抛出此错误:

类型 WorkoutRunnerComponent 是 2 个模块的声明的一部分:WorkoutRunnerModule 和 AppModule!请考虑将 WorkoutRunnerComponent 移动到导入 WorkoutRunnerModule 和 AppModule 的更高级模块中。您还可以创建一个新的 NgModule,导出并包含 WorkoutRunnerComponent,然后在 WorkoutRunnerModule 和 AppModule 中导入该 NgModule。

这导致了一个非常重要的结论:

一个组件/指令/管道只能属于一个模块。

一旦组件成为模块的一部分,我们就不允许重新声明它。要使用特定模块的组件,我们应该导入相应的模块,而不是重新声明组件。

通过模块导入依赖项也会带来另一个挑战。循环依赖是不允许的。如果ModuleB已经导入了ModuleA,那么ModuleA就不能导入ModuleB。如果ModuleA想要使用ModuleB中的某个组件,同时ModuleB又依赖于ModuleA中的某个组件,就会出现这种情况。

在这种情况下,正如前面的错误描述的那样,共同的依赖项应该移动到另一个 Angular 模块ModuleC中,并且两个模块ModuleAModuleB都应该引用它。

现在关于 Angular 模块的内容就够了。让我们为应用程序添加一些更多的增强功能。

了解更多关于练习的信息

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

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

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

添加描述和视频面板

Angular 应用程序只是一个组件层次结构,类似于树结构。目前,7 Minute Workout有两个组件,根组件TrainerAppComponent和其子组件WorkoutRunnerComponent,与 HTML 组件布局一致,现在看起来像这样:

<trainer-app> 
   <workout-runner></workout-runner> 
</trainer-app> 

我们将向WorkoutRunnerComponent添加两个新组件,一个用于支持练习描述,另一个用于支持练习视频。虽然我们可以直接向WorkoutRunnerComponent视图添加一些 HTML,但我们希望在这里学习一些关于跨组件通信的知识。让我们首先添加左侧的描述面板,并了解组件如何接受输入。

提供组件输入

创建一个名为exercise-description的文件夹(在components/workout-runner文件夹内),并在其中添加一个新文件exercise-description.component.ts。将以下代码添加到文件中:

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

@Component({ 
  selector: 'exercise-description', 
  templateUrl: '/src/components/workout-runner/
  exercise-description/exercise-description.html', 
}) 
export class ExerciseDescriptionComponent { 
  @Input() description: string; 
  @Input() steps: string; 
} 

在我们讨论@Input装饰器的作用之前,让我们将组件集成到WorkoutRunnerComponent中。

从 Git 分支checkpoint2.3的文件夹workout-runner/exercise-description(GitHub 位置:bit.ly/ng2be-2-3-exercise-description-html)中复制相应的视图 HTMLexercise-description.html。为了理解@Input的作用,让我们突出显示exercise-description HTML 的相关部分:

  <div class="panel-body"> 
 **{{description}}** 
  </div> 
   ...  
   <div class="panel-body"> 
 **{{steps}}** 
   </div> 

前面的插值是指ExerciseDescriptionComponent的输入属性:descriptionsteps@Input装饰器表示该组件属性可用于视图绑定。

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

WorkoutRunnerComponent只能使用ExerciseDescriptionComponent,如果:

  • 要么ExerciseDescriptionComponent已在WorkoutRunnerComponent所属的模块中注册

  • 或者ExerciseDescriptionComponent已经被导入(使用imports)到WorkoutRunnerComponent所属的另一个模块中

对于这种情况,我们将在WorkoutRunnerModule中注册ExerciseDescriptionComponent,该模块已经有WorkoutRunnerComponent

打开workout-runner.module.ts,并将ExerciseDescriptionComponent追加到declarations数组中。查看突出显示的代码:

**import {ExerciseDescriptionComponent} from './exercise-description/exercise-description.component';** 
... 
declarations: [WorkoutRunnerComponent, 
 **ExerciseDescriptionComponent],**

现在我们可以使用ExerciseDescriptionComponent。打开workout-runner.html并根据以下代码中的突出显示部分更新 HTML 片段。在exercise-pane之前添加一个名为description-panel的新 div,并调整exercise-pane div 上的一些样式,如下所示:

<div class="row"> 
 **<div id="description-panel" class="col-sm-2">**
 **<exercise-description** 
**[description]="currentExercise.exercise.description" [steps]="currentExercise.exercise.procedure">**
**</exercise-description>**
 **</div>** 
  <div id="exercise-pane" class="col-sm-7">  
... 

确保gulp play正在运行并刷新页面。描述面板应该显示在左侧,并显示相关的练习详情。

查看前面视图中的exercise-description声明。我们以与本章前面的 HTML 元素属性相同的方式引用descriptionsteps属性。简单,直观,非常优雅!

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

注意

@Input装饰可以接受属性别名作为参数,这意味着以下内容;考虑这样的属性声明:@Input("myAwesomeProperty") myProperty:string;

它可以在视图中如下引用:<my-component [myAwesomeProperty]="expression"....

Angular 绑定基础设施的强大之处在于,我们可以通过将@Input装饰器(还有@Output)附加到任何组件属性上,将其用作可绑定属性。我们不仅限于基本数据类型,如stringnumberboolean;也可以是复杂对象,接下来我们将在添加视频面板时看到。

trainer/src/components/workout-runner/video-player的 Git 分支checkpoint2.3文件夹中复制video-player.component.tsvideo-player.html(GitHub 位置:http://bit.ly/ng2be-2-3-video-player)。

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

export class VideoPlayerComponent implements 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="330" height="220" [src]="video"  
frameborder="0" allowfullscreen></iframe> 
</div> 

视图使用一个名为ngFor的新的 Angular 指令来绑定到safeVideoUrls数组。ngFor指令属于一类称为结构指令的指令。该指令的作用是根据绑定集合中的元素数量重新生成 HTML 片段。

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

ExerciseDescriptionComponent一样,我们还需要将VideoPlayerComponent添加到WorkoutRunnerModule中。我们将把这个练习留给读者。

接下来,在exercise-pane div 之后添加组件声明,更新WorkoutRunnerComponent视图。

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

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

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

注意

如果您在运行代码时遇到问题,请查看 Git 分支checkpoint2.3,以获取我们迄今为止所做的工作版本。

您还可以从bit.ly/ng2be-checkpoint2-3下载checkpoint2.3的快照(zip 文件)。首次设置快照时,请参考trainer文件夹中的README.md文件。

现在是时候回过头来看一下我们之前跳过的VideoPlayerComponent实现的部分。我们特别需要理解:

  • ngFor指令的工作原理

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

  • Angular 组件生命周期事件OnChanges的重要性

首先,让我们正式介绍ngFor和它所属的指令类:结构指令

结构指令

第三类指令结构指令如何在组件/元素上工作以操纵它们的布局。

Angular 文档简洁地描述了结构指令:

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

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

ngFor指令属于这一类。我们可以通过*前缀来识别这些指令。除了ngFor,Angular 还有一些其他结构指令,比如ngIfngSwitch

如此有用的 NgFor

每种模板语言都有构造,允许模板引擎生成 HTML(通过重复)。Angular 有ngFor。ngFor 指令是一个非常有用的指令,用于将 HTML 片段的一部分重复n次。

前面的代码针对每个练习视频(使用videos数组)重复了 div 片段。let video of videos字符串表达式被解释为:取出 videos 数组中的每个视频,并将其赋值给模板输入变量video。现在可以在ngFor模板 HTML 中引用这个输入变量,就像我们在设置src属性绑定时所做的那样。

为了提供更多关于迭代上下文的细节,ngFor指令提供了一个可选的index,它在每次迭代时从 0 增加到数组的长度,类似于我们熟悉的for循环。这个index也可以被捕获到模板输入变量中,并在模板内部使用:

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

它将special类应用于第一个视频div

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

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

注意

*前缀是表示结构指令的一种更简洁的格式。实际上,与前面的videos数组一起使用的ngFor指令扩展为:<template ngFor let-video [ngForOf]="videos"> <div> <iframe width="330" height="220" [src]="'//www.youtube.com/embed/' + video" ...> </iframe> </div> </template> template标签声明了ngFor,一个模板输入变量video),以及一个指向videos数组的属性(ngForOf)。

NgFor 性能

由于NgFor根据集合元素生成基于 HTML 的内容,因此以性能问题而闻名。但我们不能责怪这个指令。它做了它应该做的事情:迭代和生成元素!如果支持的集合很大或由于绑定的集合经常更改而导致 DOM 的重复重新渲染,UI 渲染性能可能会受到影响。

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

想象一种情况,我们经常从服务器获取一个对象数组,并使用NgFor将其绑定到视图上。NgFor的默认行为是每次刷新列表时重新生成 DOM(因为 Angular 进行标准对象相等性检查)。然而,作为开发人员,我们可能很清楚并没有太多改变。一些新对象可能已经被添加,一些被移除,也许有些被修改。但 Angular 只是重新生成完整的 DOM。

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

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

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

这就是我们在NgFor模板中使用前面的函数的方式:

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

NgFor现在将避免为已经渲染的用户重新创建 DOM。

请记住,如果用户的绑定属性发生了变化,Angular 仍然会更新现有的 DOM 元素。

关于ngFor指令就说这么多,让我们继续向前走。

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

Angular 2 安全性

理解为什么我们需要绑定到safeVideoUrls而不是videos输入属性的最简单方法是尝试一下。用这个替换现有的ngFor片段 HTML:

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

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

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

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

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

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

插值会转义我们绑定到它们的任何内容。

当我们使用 HTML 元素的innerHTML属性(属性绑定)时,HTML 内容被发出时,HTML 中嵌入的任何不安全内容(脚本)都会被剥离。我们很快将看到一个例子,当我们将练习步骤格式化为 HTML 而不是纯文本时。

但是Iframes呢?在我们之前的例子中,Angular 也阻止了对 Iframe 的src属性的属性绑定。这是对使用 Iframe 在我们自己的网站中嵌入第三方内容的警告。Angular 也阻止了这一点。

总而言之,该框架围绕内容消毒定义了四个安全上下文。这些包括:

  1. HTML 内容消毒,当使用innerHTML属性绑定 HTML 内容时。

  2. 样式消毒,当将 CSS 绑定到style属性时。

  3. URL 消毒,当 URL 与anchorimg等标签一起使用时。

  4. 在使用Iframesscript标签时的资源消毒。在这种情况下,内容无法被消毒,因此默认情况下被阻止。

Angular 正在尽力使我们远离危险。但有时,我们知道内容是安全的,因此希望规避默认的消毒行为。

信任安全内容

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

  • bypassSecurityTrustHtml

  • bypassSecurityTrustScript

  • bypassSecurityTrustStyle

  • bypassSecurityTrustUrl

  • bypassSecurityTrustResourceUrl

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

this.sanitizer.bypassSecurityTrustResourceUrl(this.youtubeUrlPrefix + v) 

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

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

注意

有关这个主题的全面处理可以在框架安全指南中找到,网址为 http://bit.ly/ng2-security。强烈推荐阅读!

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

OnChanges 生命周期事件在组件的输入发生变化时触发。在 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> 
 ... 

现在刷新训练页面。输出结果与我们的期望不符,如下所示:

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

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

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

<div class="panel-body" [innerHTML]="steps"> </div> 

刷新锻炼页面以确认。

提示

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

正如前面讨论的,Angular 默认在动态注入时对输入的 HTML 进行消毒。这可以保护我们的应用免受 XSS 攻击。但是,如果您想要动态地将样式/脚本注入 HTML 中,请使用DomSanitizer来绕过此消毒检查。

是时候进行另一个增强了!是时候了解 Angular 管道了。

使用管道显示剩余的锻炼持续时间

如果我们能告诉用户剩余完成锻炼的时间,而不仅仅是正在进行的锻炼的持续时间,那将会很好。我们可以在锻炼窗格的某个地方添加一个倒计时器,以显示剩余的总时间。

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

Angular 管道

管道的主要目的是格式化在视图中显示的表达式的值。该框架带有多个预定义的管道,例如datecurrencylowercaseuppercase等。这是我们在视图中使用管道的方法:

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

一些最常用的管道如下:

  • 日期:正如我们刚才看到的,日期过滤器用于以特定方式格式化日期。该过滤器支持许多格式,并且也支持区域设置。要了解日期管道支持的其他格式,请查看框架文档bit.ly/ng2-date

  • 大写小写:这两个管道,顾名思义,改变了字符串输入的大小写。

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

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

 **{{14.22|currency:"USD" }} <!-Renders USD 14.22 -->** 
        {{14.22|currency:"USD":true}}  <!-Renders $14.22 --> 

  • json:这是一个方便的用于调试的管道,可以使用JSON.stringify将任何输入转换为字符串。我们在本章开头很好地利用了它来呈现WorkoutPlan对象(参见 Checkpoint 2.1 的代码)。

管道的另一个非常强大的特性是它们可以被链接在一起,其中一个管道的输出可以作为另一个管道的输入。考虑这个例子:

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

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

既然我们已经了解了管道是什么以及如何使用它们,为什么不为7 分钟锻炼应用程序实现一个秒转时间管道呢?

实现自定义管道 - SecondsToTimePipe

SecondsToTimePipe将数值转换为hh:mm:ss格式。

workout-runner文件夹中创建一个名为seconds-to-time.pipe.ts的文件,并添加以下管道定义(也可以从 GitHub 网站的 Git 分支checkpoint.2.4上下载定义,网址为bit.ly/ng2be-2-4-seconds-to-time-pipe-ts):

export class SecondsToTimePipe implements PipeTransform { 
  transform(value: number): any { 
    if (!isNaN(value)) { 
      var hours = Math.floor(value / 3600); 
      var minutes = Math.floor((value - (hours * 3600)) / 60); 
      var 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..)传递。由于实现不需要管道参数,我们不使用任何管道参数。

实现非常简单,因为我们将秒数转换为小时、分钟和秒。然后我们将结果连接成一个字符串值并返回该值。对于hoursminutesseconds变量的每个值,在左边添加 0 是为了在小时、分钟或秒的计算值小于 10 时格式化该值。

我们刚刚创建的管道是一个标准的 TypeScript 类。除非我们对其应用@Pipe装饰器,否则它不符合 Angular 管道的要求。

在类定义之前,向seconds-to-time.pipe.ts文件添加必要的import语句和@Pipe装饰器。

import {Pipe, PipeTransform} from '@angular/core'; 

@Pipe({ 
  name: 'secondsToTime' 
}) 

管道定义已经完成,但在我们可以在WorkoutRunnerComponent中使用SecondsToTimePipe之前,需要在WorkoutRunnerModule中声明该管道。这是我们之前为ExerciseDescriptionComponentVideoPlayerComponent所做的事情。

打开workout-runner.module.ts,并添加以下代码:

import {VideoPlayerComponent} from  
'./video-player/video-player.component'; 
**import {SecondsToTimePipe} from './seconds-to-time.pipe';** 

... 
    declarations: [WorkoutRunnerComponent, ...  
**SecondsToTimePipe],**

最后,我们只需要在视图中添加管道。通过添加以下片段来更新workout-runner.html

<div class="workout-display-div"> 
 **<h4>Workout Remaining - {{workoutTimeRemaining | secondsToTime}}</h4>** 
  <h1>{{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 = setInterval(() => { 
      if (this.exerciseRunningDuration >=  
          this.currentExercise.duration) { 
        clearInterval(this.exerciseTrackingInterval); 
        let 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();替换它。我们已经准备好测试我们的实现。刷新浏览器并验证实现。

实现自定义管道 - SecondsToTimePipe

下一节是关于另一个内置的 Angular 指令ngIf,以及另一个小的增强。

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

在练习之间的短暂休息期间,告诉用户下一个练习是什么会很好。这将帮助他们为下一个练习做准备。所以让我们添加它。

要实现此功能,我们可以简单地从workoutPlan.exercises数组中输出下一个练习的标题。我们在Time Remaining倒计时部分旁边显示标题。更改训练 div(class="workout-display-div")以包含突出显示的内容,并删除Time Remaining h1

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

我们包裹现有的Time Remaining h1,并在一个新的div中添加另一个h3标签,以显示下一个练习,并进行一些样式更新。此外,第二个h3中还有一个新的指令ngIf*前缀意味着它属于与ngFor相同的一组指令:结构指令。

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/style 的显示或隐藏更高效,因为当ngIf表达式评估为false时,既不会创建 DOM,也不会设置数据绑定表达式。

这个联盟中还有另一个指令: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

回到我们的下一个练习实现,我们已经准备好验证实现,所以我们刷新页面并等待休息时间。在休息阶段应该提到下一个练习,如下所示:

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

应用程序正在很好地形成。如果你使用过这个应用程序并且进行了一些体育锻炼,你会非常想念练习暂停功能。锻炼直到结束才会停止。我们需要修复这个行为。

暂停练习

要暂停练习,我们需要停止计时器。我们还需要在视图中的某个位置添加一个按钮,允许我们暂停和恢复锻炼。我们计划通过在页面中心的练习区域上绘制一个按钮覆盖层来实现这一点。当点击时,它将在暂停和运行之间切换练习状态。我们还将添加键盘支持,使用键绑定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();    } 
  } 

暂停的实现很简单。我们首先取消现有的 setInterval 设置,调用 clearInterval(this.exerciseTrackingInterval);。在恢复时,我们再次调用 startExerciseTimeTracking,再次从我们离开的地方开始跟踪时间。

现在我们只需要为视图调用 pauseResumeToggle 函数。将以下内容添加到 workout-runner.html

<div id="exercise-pane" class="col-sm-7"> 
 **<div id="pause-overlay" (click)="pauseResumeToggle()">**
 **<span class="glyphicon pause absolute-center"** 
 **[ngClass]="{'glyphicon-pause' : !workoutPaused,** 
**'glyphicon-play' : workoutPaused}"></span>**
 **</div>** 
    <div class="row workout-content"> 

div 上的 click 事件处理程序切换了练习运行状态,ngClass 指令用于在 glyphicon-pauseglyphicon-play 之间切换类-标准的 Angular 东西。现在缺少的是能够在按下 pP 键时暂停和恢复。

我们可以在 div 上应用 keyup 事件处理程序:

 <div id="pause-overlay" (keyup)= "onKeyPressed($event)"> 

但是 div 元素没有焦点的概念,所以我们还需要在 div 上添加 tabIndex 属性才能使其工作。即使这样,它只有在我们至少点击了一次 div 之后才能工作。有一个更好的方法来实现这一点;将事件处理程序附加到全局 window 事件 keyup 上。现在事件绑定将如下所示:

<div id="pause-overlay" (window:keyup)= "onKeyPressed($event)"> 

请注意在 keyup 事件之前的特殊 window: 前缀。我们可以使用这种语法将事件附加到任何全局对象,比如 documentonKeyPressed 事件处理程序需要添加到 WorkoutRunnerComponent。将此函数添加到类中:

onKeyPressed = function(event:KeyboardEvent) { 
    if (event.which == 80 || event.which == 112) { 
      this.pauseResumeToggle(); 
    } 
} 

$event 对象是 Angular 提供的标准 DOM 事件对象,可用于操作。由于这是一个键盘事件,专门的类是 KeyboardEventwhich 属性匹配 ASCII 值为 pP。刷新页面,当鼠标悬停在练习图片上时,您应该会看到播放/暂停图标,就像这样:

暂停练习

当我们在事件绑定上时,这将是一个探索 Angular 事件绑定基础设施的好机会

Angular 事件绑定基础设施

Angular 事件绑定允许组件通过事件与其父组件通信。

如果我们回顾一下应用程序的实现,到目前为止我们遇到的是属性/属性绑定。这样的绑定允许组件/元素从外部接收输入。数据流入组件。

事件绑定是属性绑定的反向。它们允许组件/元素通知外部世界任何状态变化。

正如我们在暂停/恢复实现中看到的,事件绑定使用圆括号())来指定目标事件:

<div id="pause-overlay" (click)="pauseResumeToggle()"> 

这将为div附加一个click事件处理程序,当点击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都会调用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事件上设置了事件绑定,使双向同步工作。

我们将在第四章中更详细地讨论双向绑定,构建个人健身教练,在那里我们将构建自己的自定义训练。

我们已经创建了一个总结到目前为止我们讨论过的所有绑定的数据流模式的图表。一个方便的插图可以帮助您记忆每个绑定构造以及数据流的方式:

使用 ngModel 进行双向绑定

现在我们有一个完全功能的7 分钟锻炼,还有一些花里胡哨的东西,希望创建这个应用程序很有趣。现在是时候结束本章并总结所学的内容了。

注意

如果您在运行代码时遇到问题,请查看 Git 分支checkpoint2.4,以获取到目前为止我们所做的工作的可运行版本。

您还可以从此 GitHub 位置下载checkpoint2.4的快照(zip 文件):bit.ly/ng2be-checkpoint2-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 2-SPA,路由和深入的数据流

如果上一章是关于在 Angular 中构建我们的第一个有用的应用程序,那么这一章是关于为其添加大量的 Angular 功能。在学习曲线中,我们已经开始探索技术平台,现在我们可以使用 Angular 构建一些基本的应用程序。但这只是开始!在我们能够在一个相当大的应用程序中有效使用 Angular 之前,还有很多东西要学习。这一章让我们离实现这个目标更近了一步。

7 分钟锻炼应用程序仍然有一些不足之处/限制,我们可以在改善整体应用程序体验的同时解决这些问题。这一章就是关于添加这些增强和功能的。而且,像往常一样,这个应用程序构建过程为我们提供了足够的机会来增强我们对框架的理解,并学习关于它的新知识。

本章涵盖的主题包括:

  • 探索 Angular 单页应用程序(SPA):我们探索 Angular 的单页应用程序功能,包括路由导航、链接生成和路由事件。

  • 理解依赖注入:这是核心平台功能之一。在本章中,我们将学习 Angular 如何有效利用依赖注入,在整个应用程序中注入组件和服务。

  • Angular 纯(无状态)和不纯(有状态)管道:我们将更详细地探索 Angular 的主要数据转换构造,管道,同时构建一些新的管道。

  • 跨组件通信:由于 Angular 完全涉及组件及其交互,我们将看看如何在父子和同级组件设置中进行跨组件通信。我们将学习 Angular 模板变量和事件如何促进这种通信。

  • 创建和使用事件:我们将学习组件如何公开自己的事件,以及如何从模板 HTML 和其他组件绑定到这些事件。

作为一个旁注,我希望你经常使用7 分钟锻炼并关注你的身体健康。如果没有,请休息七分钟并进行锻炼。我坚持!

希望锻炼很有趣!现在让我们回到一些严肃的事情。让我们开始探索 Angular 单页应用程序(SPA)的功能。

注意

我们从第二章中离开的地方开始,构建我们的第一个应用程序-7 分钟锻炼。git 分支checkpoint2.4可以作为本章的基础。

该代码也可在 GitHub 上获取(github.com/chandermani/angular2byexample),供所有人下载。检查点在 GitHub 上作为分支实现。

如果您不使用 git,请从 GitHub 位置bit.ly/ng2be-checkpoint2-4下载checkpoint2.4的快照(ZIP 文件)。首次设置快照时,请参考trainer文件夹中的README.md文件。

探索单页应用程序的能力

7 分钟锻炼从加载应用程序开始,但以最后一次锻炼永久停留在屏幕上结束。这不是一个非常优雅的解决方案。为什么我们不在应用程序中添加开始和结束页面呢?这使应用程序更专业,并且可以让我们理解 AngularJS 的单页面命名法。

Angular SPA 基础设施

使用现代 Web 框架(如 Angular(Angular 1.x)和 Ember),我们现在习惯于不执行完整页面刷新的应用程序。但是,如果您是新手,值得一提的是这些单页应用程序(SPAs)是什么。

单页应用程序(SPAs)是基于浏览器的应用程序,不需要进行完整的页面刷新。在这种应用程序中,一旦加载了初始 HTML,任何未来的页面导航都是使用 AJAX 作为 HTML 片段检索并注入到已加载的视图中。谷歌邮件是 SPA 的一个很好的例子。SPAs 为用户提供了极佳的用户体验,因为用户可以获得类似桌面应用程序的感觉,而无需不断的后退和页面刷新,这通常与传统 Web 应用程序相关联。

与其前身一样,Angular 2 也为 SPA 实现提供了必要的构造。让我们了解它们并添加我们的应用程序页面。

Angular 路由

Angular 使用其路由基础设施支持 SPA 开发。该基础设施跟踪浏览器 URL,启用超链接生成,公开路由事件,并提供一组用于视图的指令/组件。

有四个主要的框架部分共同支持 Angular 路由基础设施:

  • 路由器(Router):实际提供组件导航的主要基础设施

  • 路由配置(Route):组件路由器依赖于路由配置来设置路由

  • RouterOutlet 组件RouterOutlet组件是路由特定视图加载的占位符容器(主机

  • RouterLink 指令:这会生成可以嵌入到锚标签中用于导航的超链接

以下图表突出显示了这些组件在路由设置中所扮演的角色:

Angular routing

我强烈鼓励每个人在为7 分钟锻炼设置路由时不断回顾这个图表。

路由器是这个完整设置的核心部分;因此,对路由器的快速概述将会很有帮助。

角度路由器

如果你曾经使用过带有 SPA 支持的任何 JavaScript 框架,这就是它的工作原理。框架监视浏览器 URL,并根据加载的 URL 提供视图。有专门的框架组件来完成这项工作。在 Angular 世界中,这种跟踪是由框架服务,即路由器来完成的。

注意

在 Angular 中,任何提供一些通用功能的类、对象或函数都被称为服务。Angular 没有提供任何特殊的构造来声明服务,就像它为组件、指令和管道所做的那样。任何可以被组件/指令/管道消耗的东西都可以被称为服务。路由器就是这样的一个服务。还有许多其他作为框架一部分的服务。

如果你来自 Angular 1 领域,这是一个令人愉快的惊喜-没有服务、工厂、提供者、值或常量!

提示

在构建组件时,尽量将尽可能多的功能委托给服务。组件应该只充当帮助同步组件模型和视图状态的中介者

角度路由器的作用是:

  • 在路由更改时在组件之间启用导航

  • 在组件视图之间传递路由数据

  • 使当前路由的状态对活动/加载的组件可用

  • 提供允许组件代码导航的 API

  • 跟踪导航历史,允许我们使用浏览器按钮在组件视图之间前进和后退

  • 提供生命周期事件和守卫条件,允许我们根据一些外部因素影响导航

注意

路由器还支持一些高级路由概念,如父子路由。这使我们能够在组件树的多个级别定义路由。父组件可以定义路由,子组件可以进一步添加更多的子路由到父路由定义中。这是我们在第四章中详细介绍的内容,构建个人教练

路由器不能单独工作。正如前面的图表所示,它依赖于其他框架部分来实现期望的结果。让我们添加一些应用页面,并与每个拼图的每个部分一起工作。

路由设置

为了使组件路由器工作,我们首先需要将其引用,因为路由器不是核心框架的一部分。

打开package.json并按照这里的提示向路由器添加一个包引用:

"@angular/platform-browser-dynamic": "2.0.0", 
**"@angular/router": "3.0.0",**

接下来,使用命令行安装包:

**npm install**

最后,在systemjs.config.js中引用该包。这样 SystemJS 就可以正确加载router模块了。将路由器包添加到ngPackageNames数组中以设置packages配置:

var ngPackageNames = [ 
... 
 **'router',** 
...];

如果不存在,还要在index.htmlhead部分中添加base引用(已高亮显示):

 <link rel="stylesheet" href="static/css/app.css" /> 
 **<base href="/">**

路由器需要设置base hrefhref值指定用于 HTML 文档中所有相对 URL 的基本 URL,包括链接到 CSS、脚本、图像和任何其他资源。

路由器使用pushstate机制进行 URL 导航。这使我们能够使用诸如:

  • localhost:9000/start

  • localhost:9000/workout

  • localhost:9000/finish

这可能看起来不是什么大不了的事,但请记住,我们正在进行客户端导航,而不是我们习惯的全页重定向。正如开发者指南所述:

现代 HTML 5 浏览器支持history.pushState,这是一种在不触发服务器页面请求的情况下更改浏览器位置和历史记录的技术。路由器可以组合一个与需要页面加载的 URL 无法区分的“自然”URL。

Pushstate API 和服务器端 URL 重写

路由器使用的 pushstate API 仅在我们点击视图中嵌入的链接(<a>标签)或使用路由器 API 时才起作用。路由器拦截任何导航事件,加载适当的组件视图,最后更新浏览器 URL。请求从不发送到服务器。

但是如果我们刷新浏览器会怎么样?

Angular 路由器无法拦截浏览器的刷新事件,因此会发生完整的页面刷新。在这种情况下,服务器需要响应仅存在于客户端的资源请求。典型的服务器响应是对于可能导致404(未找到)错误的任何任意请求发送应用主机文件(如index.html)。这就是我们所说的服务器URL 重写

即使我们的服务器设置也进行了 URL 重写。查看gulpfile.js中的突出显示行:

 connect.server({ 
    ... 
 **fallback: 'index.html'** 
  }); 

connect.server的最后一个配置参数设置了应用服务器的fallback URL 为index.html。这意味着对任何不存在的 URL 的请求,如/start/workout/finish或其他任何 URL,都会加载首页。

提示

每个服务器平台都有不同的机制来支持 URL 重写。我们建议您查看您使用的服务器堆栈的文档,以启用 Angular 应用程序的 URL 重写。

一旦我们为7 分钟锻炼添加了一些页面,我们就可以看到服务器端的重写。一旦新页面就位,尝试刷新应用程序并查看浏览器的网络日志;服务器每次都发送index.html内容,无论请求的 URL 是什么。

提示

回退路径和调试

为所有不存在的 URL 设置一个回退路径可能会在调试应用程序时产生不利影响。一旦回退机制就位,对于脚本/HTML/CSS 加载失败,就不会出现 404 错误。这可能会对任何缺失的引用产生意外结果,因为服务器总是返回index.html文件。每当您向应用程序添加新文件时,请注意浏览器网络日志和浏览器控制台中返回的内容是否有异常。

作为前面路由器设置的一部分,我们已经学会了如何包含路由器脚本,如何设置服务器端重定向以支持 HTML5 推送状态以及设置base href的需要。

在我们继续之前,我们需要为我们的应用程序添加一些其他页面并配置路由。

添加开始和完成页面

这里的计划是为7 分钟锻炼创建三个页面:

  • 开始页面:这将成为应用程序的登陆页面

  • 锻炼页面:我们目前拥有的内容

  • 完成页面:我们在锻炼完成后导航到这里

锻炼组件及其视图(workout-runner.component.tsworkout-runner.html)已经存在。因此,让我们创建StartComponentFinishComponent以及它们的视图。

从 git 分支checkpoint3.1复制以下文件。这些文件位于components文件夹下的startfinish文件夹中(从 GitHub 位置下载的链接是bit.ly/ng2be-3-1-components):

  • start.component.tsstart.htmlstart.module.ts:这包括StartComponent的实现和视图模板。一个标准的 HTML 视图,和一个基本的组件,使用routerLink指令生成超链接。

  • finish.component.tsfinish.htmlfinish.module.ts:这包括FinishComponent的实现和视图模板。它遵循与StartComponent相同的模式。

StartFinish组件都已经使用自己的模块进行了定义。我们将遵循的约定是每个顶级视图一个模块。

三个组件都已准备就绪。是时候定义路由配置了!

路由配置

为了设置7 分钟锻炼的路由,我们将创建一个路由定义文件。在components/app文件夹中创建一个名为app.routes.ts的文件,定义应用程序的顶级路由。添加以下路由设置:

import { ModuleWithProviders } 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'; 

export const routes: Routes= [ 
  { path: 'start', component: StartComponent }, 
  { path: 'workout', component: WorkoutRunnerComponent }, 
  { path: 'finish', component: FinishComponent }, 
  { path: '**', redirectTo:'/start'} 
]; 
export const routing: ModuleWithProviders = RouterModule.forRoot(routes); 

routes变量是Route对象的数组。每个Route定义了单个路由的配置,其中包含:

  • path:要匹配的目标路径

  • component:当路径被命中时要加载的组件

这样的路由定义可以解释为:“当用户导航到一个路径(在path中定义),加载component属性中定义的相应组件。”以第一个路由示例为例;导航到http://localhost:9000/start会加载StartComponent的组件视图。

您可能已经注意到最后一个Route定义看起来有点不同。path看起来很奇怪,而且也没有component属性。带有**的路径表示一个捕获所有路径或我们应用程序的通配符路由。任何不匹配前三个路由之一的导航都会匹配捕获所有路由,导致应用程序导航到起始页面(在redirectTo属性中定义)。

注意

一旦路由设置完成,我们可以尝试这个。输入任意随机路由,如http://localhost:9000/abcd,应用程序会自动重定向到http://localhost:9000/start

最后调用RouterModule.forRoot用于将此路由设置导出为模块。我们在 AppModule 中使用这个设置(导出为routing)来完成路由设置。打开app.module.ts并导入路由设置以及我们根据StartFinish页面创建的模块:

import {StartModule} from '../start/start.module'; 
import {FinishModule} from '../finish/finish.module'; 
import {routing} from './app.routes'; 
@NgModule({ 
  imports: [..., StartModule, FinishModule, routing], 

现在我们已经拥有了所有所需的组件和所有定义的路由,我们在路由更改时在哪里注入这些组件呢?我们只需要在宿主视图中为其定义一个占位符。

使用 router-outlet 渲染组件视图

如果我们检查当前的TrainerAppComponent模板,它有一个嵌入的WorkoutRunnerComponent

<workout-runner></workout-runner>

这需要改变。删除前面的声明并替换为:

<router-outlet></router-outlet>

RouterOutlet是一个 Angular 组件指令,作为一个占位符,在路由更改时加载子组件。它与路由器集成,根据当前浏览器 URL 和路由定义加载适当的组件。

以下图表帮助我们轻松地可视化了router-outlet的设置发生了什么:

使用 router-outlet 渲染组件视图

我们现在几乎完成了;是时候触发导航了。

路由导航

像标准浏览器导航一样,Angular 导航可以发生:

  • 当用户直接在浏览器中输入 URL 时

  • 单击锚标签上的链接

  • 使用脚本/代码进行导航

如果尚未启动,请启动应用程序并加载http://localhost:9000http://localhost:9000/start。应该加载开始页面。

单击页面上的开始按钮,训练视图应该加载到http://localhost:9000/workout

注意

Angular 路由器还支持旧式的基于哈希(#)的路由。启用基于哈希的路由时,路由如下所示:

  • localhost:9000/#/start

  • localhost:9000/#/workout

  • localhost:9000/#/finish

默认的路由选项是基于pushState的。要将其更改为基于哈希的路由,顶级路由的路由配置在路由设置期间更改,如本例所示:export const routing: ModuleWithProviders = RouterModule.forRoot(routes, **{ useHash: true }** );

有趣的是,StartComponent视图定义中的锚链接没有href属性。相反,有一个RouterLink指令:

<a [routerLink]="['/workout']">

这看起来像是属性绑定语法,RouterLink 指令接受一个数组类型的输入参数。这是一个路由链接参数数组(或链接参数数组)。

routerLink 指令与路由器一起使用这个链接参数数组来解析正确的 URL 路径。在前面的情况下,数组中唯一的元素是路由的名称。

注意

注意在前面的路由路径中的 / 前缀。/ 用于指定绝对路径。Angular 路由器还支持相对路径,这在处理子路由时非常有用。我们将在接下来的几章中探讨子路由的概念。

刷新应用并检查 StartComponent 的渲染 HTML;前面的锚标签被渲染为:

<a href="/workout">

提示

避免硬编码路由链接

虽然你可以直接使用 <a href="/workout">,但最好使用 routerLink 来避免硬编码路由。

链接参数数组

传递给 routerLink 指令的链接参数数组遵循特定的模式:

['routePath', param1, param2, {prop1:val1, prop2:val2} ....] 

第一个元素始终是路由路径,下一组参数用于替换路由模板中定义的占位符标记。

当前 7 分钟锻炼 的路由设置非常简单,不需要在链接生成中传递参数。但是对于需要动态参数的非平凡路由,可以使用这个功能。看看这个例子:

@RouteConfig([ 
 **{ path: '/users/:id', component: UserDetail },** 
  { path: '/users', component: UserList}, 
])

这是如何生成第一个路由的:

<a [routerLink]="['/users', 2]  // generates /users/2

注意

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私有变量中。它使用的魔法是依赖注入框架

现在只需要用调用navigation路由替换语句console.log("Workout complete!");

this.router.navigate( ['/finish'] );

navigate方法接受与RouterLink指令相同的链接参数数组。我们可以通过耐心等待锻炼完成来验证实现!

注意

如果您在运行代码时遇到问题,请查看 git 分支checkpoint3.1,了解我们迄今为止所做的工作的可工作版本。

或者如果您不使用 git,请从bit.ly/ng2be-checkpoint3-1下载checkpoint3.1的快照(ZIP 文件)。在首次设置快照时,请参考trainer文件夹中的README.md文件。

如果您仍然想知道如何访问当前路由的路由参数,我们有ActivatedRoute服务。

使用ActivatedRoute服务访问路由参数

有时,当前视图需要访问活动路由状态。在组件实现过程中,诸如当前 URL 片段、当前路由参数和其他与路由相关的数据可能会派上用场。

ActivatedRoute服务是所有当前路由相关查询的一站式商店。它有许多属性,包括urlparams,可以利用路由的当前状态。

让我们来看一个带参数的路由的例子,以及如何从组件中访问传递的参数。给定这个路由:

{ path: '/users/:id', component: UserDetail },

当用户导航到/user/5时,底层组件可以通过首先将ActivatedRoute注入到其构造函数中来访问:id参数值:

export class UsersComponent { 
  constructor( private route: ActivatedRoute ... 

然后从ActivatedRoute服务的params属性中查询id属性。看看这个例子:

this.route.params.forEach((params: Params) => { 
     let id = +params['id']; // (+) converts string 'id' to a number 
     var currentUser=this.getUser(id) 
});  

ActivatedObject上的params属性实际上是一个observable。我们将在本章后面学习更多关于 observables 的知识,但现在足够理解 observables 是可以触发事件并且可以被订阅的对象。

我们使用route.params observable 上的forEach函数来获取路由的参数。回调对象(params:Params)包含与每个路由参数对应的属性。看看我们如何检索id属性并使用它。

我们现在已经介绍了基本的 Angular 路由基础设施,但在后面的章节中还有更多内容可以探索。现在是时候集中精力讨论一个长期以来的话题:依赖注入

Angular 依赖注入

Angular 大量使用依赖注入来管理应用程序和框架的依赖关系。令人惊讶的是,我们可以忽略这个话题,直到我们开始讨论路由器,而不会影响我们对事物如何工作的理解。在此期间,Angular 依赖注入框架一直在支持我们的实现。一个好的依赖注入框架的特点是,消费者可以在不关心内部细节的情况下使用它,并且只需很少的仪式感。

如果你不确定什么是依赖注入,或者只是对它有一个模糊的概念,那么对 DI 的介绍肯定不会伤害任何人。

依赖注入 101

对于任何应用程序,其组件(不要与 Angular 组件混淆)并不是孤立工作的。它们之间存在依赖关系。一个组件可能使用其他组件来实现其所需的功能。依赖注入是一种管理这种依赖关系的模式。

DI 模式在许多编程语言中很受欢迎,因为它允许我们以松散耦合的方式管理依赖关系。有了这样一个框架,依赖对象由 DI 容器管理。这使得依赖关系可互换,并且整体代码更加解耦和可测试。

DI 背后的理念是一个对象不会创建\管理自己的依赖关系。相反,依赖关系是从外部提供的。这些依赖关系可以通过构造函数提供,这被称为构造函数注入(Angular 也这样做),或者直接设置对象属性,这被称为属性注入

这里是一个依赖注入实例的基本示例。考虑一个名为Tracker的类,它需要一个Logger来进行日志记录操作:

class Tracker() { 
  logger:Logger; 
  constructor() { 
    this.logger = new Logger();    
  } 
}

Logger的依赖关系在Tracker内部是硬编码的。如果我们将这种依赖关系外部化呢?所以类变成了:

class Tracker { 
  logger:Logger; 
  constructor(logger:Logger) { 
    this.logger = logger;    
  } 
}

这看似无害的改变产生了重大影响。通过添加提供外部依赖的能力,我们现在可以:

  • 解耦组件并实现可扩展性。DI 模式允许我们在不触及类本身的情况下改变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实现的情况下用它来记录。

  • 模拟依赖关系:模拟依赖关系的能力使我们的组件更易于测试。通过为 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实例注入。为了回答“什么”和“如何”部分,我们通过模块装饰器上的导入语句在应用模块(app.module.ts)中注册Router服务:

imports: [..., routing];

routing变量是一个模块,它导出了多个路由以及所有与 Angular 路由相关的服务(技术上它重新导出了RouterModule)。我们通过以下语句从app.routes.ts中导出这个变量:

export const routing: ModuleWithProviders = RouterModule.forRoot(routes);

“何时”和“何地”是根据需要依赖项的组件来决定的。WorkoutRunnerComponent的构造函数需要一个Router的依赖项。这通知注入器在路由导航的过程中创建WorkoutRunnerComponent时注入当前的Router实例。

注意

在内部,注入器根据从 TypeScript 转换为 ES5 代码时反映出的元数据来确定类的依赖关系(由 TypeScript 编译器完成)。只有在类上添加了@Component@RouteConfig等装饰器时才会生成元数据。

如果我们将Router注入到另一个类中会发生什么?答案是是。Angular 注入器会创建和缓存依赖项以供将来重用,因此这些服务在本质上是单例的。

注意

虽然注入器中的依赖项是单例的,但在任何给定时间,整个 Angular 应用程序中可能有多个活动的注入器。你很快就会了解注入器层次结构。

使用路由器,还有另一层复杂性。由于 Angular 支持子路由概念,每个路由都有自己的路由器实例。等到下一章涵盖子路由时,你就能理解其中的复杂性!

让我们创建一个 Angular 服务来跟踪训练历史。这个过程将帮助你理解如何使用 Angular DI 连接依赖项。

跟踪训练历史

如果我们能够跟踪训练历史,这将是我们应用的一个很好的补充。我们上次锻炼是什么时候?我们完成了吗?我们花了多少时间?

跟踪训练历史需要我们跟踪训练进度。我们需要以某种方式跟踪训练何时开始和何时结束。然后需要将这些跟踪数据持久化存储在某个地方。

实现这种历史跟踪的一种方法是通过扩展我们的WorkoutRunnerComponent来实现所需的功能。但这会给WorkoutRunnerComponent增加不必要的复杂性,这不是它的主要工作。我们需要一个专门的历史跟踪服务来完成这项工作,一个可以跟踪历史数据并在整个应用程序中共享的服务。让我们开始构建WorkoutHistoryTracker服务。

构建 WorkoutHistoryTracker 服务

通过WorkoutHistoryTracker服务,我们计划跟踪训练的执行。该服务还公开了一个接口,允许WorkoutRunnerComponent启动和停止训练跟踪。

如果没有,请在src文件夹内创建一个名为services的文件夹,并添加一个名为workout-history-tracker.ts的文件,其中包含以下代码:

import {ExercisePlan} from '../components/workout-runner/model'; 
export class WorkoutHistoryTracker { 
  private maxHistoryItems: number = 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) { } 
}

定义了两个类:WorkoutHistoryTrackerWorkoutLogEntry。顾名思义,WorkoutLogEntry定义了一个训练执行的日志数据。maxHistoryItems允许我们配置要存储在workoutHistory数组中的最大项目数,该数组包含历史数据。get tracking()方法在 TypeScript 中定义了workoutTracked的 getter 属性。在训练执行期间,workoutTracked被设置为true

让我们添加开始跟踪、停止跟踪和完成练习的功能:

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函数应该在完成训练中的每个练习时调用。

最后,添加一个返回完整历史数据的函数:

getHistory(): Array<WorkoutLogEntry> { 
  return this.workoutHistory; 
}

这完成了WorkoutHistoryTracker的实现;现在是将其整合到训练执行中的时候了。

WorkoutRunnerComponent整合

WorkoutRunnerComponent需要WorkoutHistoryTracker来跟踪训练历史记录;因此需要满足一个依赖关系。

为了使WorkoutHistoryTracker可发现,它需要在框架中注册。在这一点上,我们有很多选择。有很多种方法可以注册依赖项,也有很多地方可以注册!这种灵活性使得 DI 框架非常强大,尽管它也增加了混乱。

让我们首先尝试理解使用WorkoutHistoryTracker作为示例来注册依赖项的不同机制。

注册依赖项

注册依赖项的最简单方法是在根/全局级别注册它。这可以通过将依赖类型传递到模块装饰器中的provides属性(数组)来实现。

如本例所示,将WorkoutHistoryTracker添加到任何模块的providers数组中会全局注册该服务:

@NgModule({...**providers: [WorkoutHistoryTracker],**})

从技术上讲,当一个服务被添加到providers数组中时,它会被注册到应用程序的根注入器中,而不管它在哪个 Angular 模块中声明。因此,以后任何模块中的任何 Angular 构件都可以使用该服务(WorkoutHistoryTracker)。根本不需要任何模块导入。

注意

这种行为与组件/指令/管道的注册不同。这些构件必须从一个模块中导出,以便另一个模块使用它们。

当 Angular 注入器请求它们时,提供者会创建依赖项。这些提供者有创建这些依赖项的配方。虽然类似乎是可以注册的明显依赖项,但我们也可以注册:

  • 一个特定的对象/值

  • 一个工厂函数

直接使用类类型来注册依赖关系(如在bootstrap函数中所示)可能大多数情况下可以满足我们的需求,但有时我们需要在依赖注册中具有一些灵活性。提供者注册语法的扩展版本为我们提供了这种灵活性。

要了解这些变化,我们需要更详细地探讨提供者和依赖注册。

Angular 提供者

提供者创建由 DI 框架提供的依赖关系。

查看上一节中WorkoutHistoryTracker的依赖关系注册:

providers: [WorkoutHistoryTracker],

这种语法是以下版本的简写形式:

providers:({ provide: WorkoutHistoryTracker, useClass: WorkoutHistoryTracker })

第一个属性(provide)是一个令牌,充当注册依赖关系的键。这个键还允许我们在依赖注入期间定位依赖关系。

第二个属性(useClass)是一个提供者定义对象,定义了创建依赖值的方法。框架提供了许多创建这些依赖关系的方法,我们很快就会看到。

使用useClass,我们正在注册类provider。类provider通过实例化所请求的对象类型来创建依赖关系。

值提供者

provider创建类对象并满足依赖,但有时我们希望注册一个特定的对象/原始对象到 DI 提供者中。值提供者解决了这种用例。

以使用此技术注册的WorkoutHistoryTracker为例:

{provide: WorkoutHistoryTracker, useValue: new WorkoutHistoryTracker()};

注册的是我们创建的WorkoutHistoryTracker对象的实例,而不是让 Angular DI 创建一个。如果下游也需要手工创建的依赖关系,那么考虑这样手工创建的依赖关系(手动创建的依赖关系)。再次以WorkoutHistoryTracker为例。如果WorkoutHistoryTracker有一些依赖关系,那么这些依赖关系也需要通过手动注入来满足:

{provide: WorkoutHistoryTracker, useValue: new WorkoutHistoryTracker(new LocalStorage())});

值提供者在特定情况下非常有用。例如,我们可以使用值提供者注册一个常见的应用程序配置:

{provide: AppConfig, {useValue: {name:'Test App', gridSetting: {...} ...}}

或者在单元测试时注册一个模拟依赖:

{provide:WorkoutHistoryTracker, {useValue: new MockWorkoutHistoryTracker()}

工厂提供者

有时候注入并不是一件简单的事情。注入取决于外部因素。这些因素决定了创建和返回的对象或类实例。工厂提供者完成了这项繁重的工作。

举个例子,我们想要为开发和生产版本设置不同的配置。我们可以很好地使用工厂实现来选择正确的配置:

{provide: AppConfig, useFactory: () => { 
  if(PRODUCTION) { 
    return {name:'My App', gridSetting: {...} ...} 
  } 
  else { 
    return {name:'Test App', gridSetting: {...} ...} 
  }
}

工厂函数也可以有自己的依赖项。在这种情况下,语法会有一些变化:

{provide: WorkoutHistoryTracker, useFactory: (environment:Environment) => { 
  if(Environment.isTest) { 
    return new MockWorkoutHistoryTracker(); 
  } 
  else { 
    return new WorkoutHistoryTracker(); 
  }, 
    deps:[Environment]
}

依赖项作为参数传递给工厂函数,并在提供者定义对象属性deps上注册。

如果依赖项的构建复杂,并且在连接期间无法决定所有内容,可以使用UseFactory提供。

虽然我们有许多选项来声明依赖项,但消耗依赖项要简单得多。

注意

在继续之前,让我们在一个新的服务模块中注册WorkoutHistoryTracker服务。这个新模块(ServicesModule)将用于注册所有应用程序范围的服务。

将模块定义从 git 分支checkpoint3.2复制到本地的src/services文件夹中。您可以从此 GitHub 位置下载它:bit.ly/ng2be-3-2-services-module-ts。还要删除所有对LocalStorage服务的引用,因为我们计划在本章稍后添加它。最后,将该模块导入AppModuleapp.module.ts)。

注入依赖项

消耗依赖项很容易!往往我们使用构造函数注入来消耗依赖项。

构造函数注入

在顶部添加import语句,并更新WorkoutRunnerComponent的构造函数,如下所示:

import {WorkoutHistoryTracker} from  
'../../services/workout-history-tracker'; 
... 
constructor(private router: Router,   
**) {**

与路由器一样,当创建WorkoutRunnerComponent时,Angular 也会注入WorkoutHistoryTracker。简单!

在我们继续整合之前,让我们探索一下关于 Angular 的 DI 框架的其他事实。

使用注入器进行显式注入

我们甚至可以使用 Angular 的Injector服务进行显式注入。这是 Angular 用来支持 DI 的相同注入器。以下是如何使用Injector注入WorkoutHistoryTracker服务:

constructor(private router: Router, private injector:Injector) {
  this.tracker=injector.get(WorkoutHistoryTracker);

我们首先注入Injector,然后显式要求Injector获取WorkoutHistoryTracker实例。

什么时候有人想要这样做呢?嗯,几乎从不。避免这种模式,因为它会将 DI 容器暴露给您的实现,并且还会增加一些噪音。

消耗依赖项很容易,但 DI 框架如何定位这些依赖项呢?

依赖项标记

还记得之前显示的依赖项注册的扩展版本吗?

{ provide: WorkoutHistoryTracker, useClass: WorkoutHistoryTracker }

provide属性值是一个标记。此标记用于标识要注入的依赖项。每当 Angular 看到这个语句时:

constructor(tracker: WorkoutHistoryTracker)

它根据类类型注入正确的依赖项。这是一个类令牌的示例。类类型用于依赖项搜索/映射。Angular 还支持一些其他令牌。

字符串令牌

我们可以使用字符串文字而不是类来标识依赖项。我们可以使用字符串令牌注册WorkoutHistoryTracker依赖项,如下所示:

{provide:"MyHistoryTracker", useClass: WorkoutHistoryTracker })

如果我们现在这样做:

constructor(private tracker: WorkoutHistoryTracker)

Angular 一点也不喜欢它,并且无法注入依赖项。由于之前看到的WorkoutHistoryTracker是使用字符串令牌注册的,因此在注入时也需要提供令牌。

要注入使用字符串令牌注册的依赖项,我们需要使用@Inject装饰器。这样做非常完美:

constructor(@Inject("MyHistoryTracker")  
  private tracker: WorkoutHistoryTracker) 

提示

当不存在@Inject()时,注入器使用参数的类型名称(类令牌)。

在注册实例或对象时,字符串令牌非常有用。如果没有AppConfig这样的类,我们之前分享的应用程序配置注册示例可以使用字符串令牌进行重写:

{ provide: "AppConfiguration", useValue: {name:'Test App', gridSetting: {...} ...});

然后使用@Inject注入:

constructor(@Inject("AppConfiguration") config:any)

注意

虽然任何对象都可以充当令牌,但最常见的令牌类型是类和字符串令牌。在内部,提供程序将令牌参数转换为OpaqueToken类的实例。查看框架文档以了解有关OpaqueToken的更多信息:

angular.io/docs/ts/latest/api/core/index/OpaqueToken-class.html

虽然WorkoutHistoryTracker注入到WorkoutRunnerComponent中已完成,但其集成仍然不完整。

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中:

ngOnDestroy() { 
  this.tracker.endTracking(false); 
} 

虽然我们现在已经实现了锻炼历史跟踪,但我们没有检查历史的机制。迫切需要一个锻炼历史页面/组件。

添加锻炼历史页面

我们在锻炼执行过程中收集的锻炼历史数据现在可以在视图中呈现出来。让我们添加一个历史组件。该组件将位于/history位置,并且可以通过单击应用程序标题部分的链接来加载。

更新app.routes.ts中的路由定义以包括新路由和相关导入:

**import {WorkoutHistoryComponent}** 
**from '../workout-history/workout-history.component';** 
... 
export const routes: Routes = [ 
  ..., 
 **{ path: 'history', component: WorkoutHistoryComponent }** 
  { path: '**', redirectTo: '/start' } 
]) 

历史链接需要添加到应用程序标题部分。让我们将标题部分重构为自己的组件。更新app.component.ts模板navbar div为:

<div class="navbar navbar-default navbar-fixed-top top-navbar"> 
<div class="container app-container"> 
**<header></header>** 
</div> 
</div> 

这里有一个新的HeaderComponent。从 git 分支checkpoint3.2app文件夹中复制标题组件(header.component.ts)的定义(GitHub 位置:bit.ly/ng2be-3-2-header-component-ts)。还将该组件添加到app.module.ts的声明数组中,就像对任何 Angular 组件一样:

import {HeaderComponent} from './header.component'; 
... 
declarations: [TrainerAppComponent, HeaderComponent], 

如果查看HeaderComponent,现在已经有了历史链接。让我们添加锻炼历史组件。

WorkoutHistoryComponent的实现可在 git 分支checkpoint3.2中找到;文件夹是workout-history(GitHub 位置:bit.ly/ng2be-3-2-workout-history)。将文件夹中的所有三个文件复制到本地相应的文件夹中。记得在本地设置中保持相同的文件夹层次结构。请注意,WorkoutHistoryComponent已在一个单独的模块(WorkoutHistoryModule)中定义,并且需要导入到AppModuleapp.module.ts)中。在继续之前,将WorkoutHistoryModule导入到AppModule中。现在从WorkoutHistoryModule中删除对SharedModule的所有引用。

WorkoutHistoryComponent的视图代码可以说是微不足道的:一些 Angular 构造,包括ngForngIf。组件实现也非常简单。在WorkoutHistoryComponent初始化时注入WorkoutHistoryTracker服务依赖项并设置历史数据:

ngOnInit() { 
  this.history = this.tracker.getHistory(); 
}

这一次,我们使用Location服务而不是Router来从历史组件中导航离开:

goBack() { 
  this.location.back(); 
}

位置服务用于与浏览器 URL 交互。根据 URL 策略,可以使用 URL 路径(例如/start/workout)或 URL 哈希段(例如#/start#/workout)来跟踪位置更改。路由器服务也在内部使用位置服务来触发导航。

提示

路由器与位置

虽然Location服务允许我们执行导航,但使用Router是执行路由导航的首选方式。我们在这里使用位置服务,因为需要导航到最后一个路由,而不必担心如何构建路由。

我们准备测试我们的锻炼历史实现。加载起始页面,然后单击历史链接。历史页面加载时为空白。开始锻炼并让一个锻炼完成。再次检查历史页面;应该列出一个锻炼:

添加锻炼历史页面

看起来不错,除了这个列表中的一个痛点。如果历史数据按时间顺序排序,并且最新的数据在顶部,那将更好。如果我们也有过滤功能,那将更好。

使用管道对历史数据进行排序和过滤

在第二章,“构建我们的第一个应用程序-7 分钟锻炼”,我们探索了管道。我们甚至建立了自己的管道来将秒值格式化为 hh:mm:ss。由于管道的主要目的是转换数据,这可以与任何输入一起使用。对于数组,管道可以用于对数据进行排序和过滤。我们创建了两个管道,一个用于排序,一个用于过滤。

注意

Angular1 具有预构建的过滤器(在 Angular2 中是管道),orderByfilter,用于这个目的。目前,将这些过滤器移植到 Angular2 的工作已经停滞。请参阅此 GitHub 问题:bit.ly/ng2-issue-2340

让我们从orderBy管道开始。

orderBy 管道

我们实现的orderBy管道将根据对象的任何属性对对象数组进行排序。基于fieldName属性按升序排序项目的使用模式将是:

*ngFor="let item of items| orderBy:fieldName"

而对于按降序排序项目,使用模式是:

*ngFor="let item of items| orderBy:-fieldName"

注意在fieldName之前的额外连字符。

src/components中创建一个名为shared的文件夹,并复制位于 git 分支checkpoint3.2(GitHub 位置:bit.ly/ng2be-3-2-shared)相应位置的所有三个文件。此文件夹中有两个管道和一个新的模块定义(SharedModule)。SharedModule定义了在整个应用程序中共享的组件/指令/管道。

打开order-by.pipe.ts并查看管道实现。虽然我们不会深入讨论管道的实现细节,但有些相关部分需要被强调。查看这个管道概述:

@Pipe({ name: 'orderBy' }) 
export class OrderByPipe {
  transform(value: Array<any>, field:string): any { 
   ... 
  } 
}

前面的field变量接收需要排序的字段。查看下面的代码以了解如何传递field参数。

如果字段有-前缀,我们在对数组进行降序排序之前截断前缀。

注意

该管道还使用了扩展运算符,这可能对您来说是新的。在 MDN 上了解有关扩展运算符的更多信息:bit.ly/js-spread

要在锻炼历史视图中使用这个管道,将SharedModule导入WorkoutHistoryModule

更新模板 HTML:

<tr *ngFor="let historyItem of history|orderBy:'-startedOn'; let i = index"> 

历史数据现在将按startedOn降序排序。

注意

请注意管道参数周围的单引号('-startedOn')。我们将一个字面字符串传递给orderBy管道。相反,管道参数也可以绑定到组件属性。

这对于orderBy管道已经足够了。让我们实现过滤。

搜索管道

我们之前添加的SearchPipe只是进行基于相等性的基本过滤。没有什么特别的。

查看管道代码;管道接受两个参数,第一个是要搜索的字段,第二个是要搜索的值。我们使用数组的filter函数来过滤记录,进行严格的相等性检查。

让我们更新锻炼历史视图,并加入搜索管道。打开workout-history.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是被点击的单选按钮。我们不将completed=$event.target.value赋值,因为它的值是字符串类型。completed属性(在WorkoutHistoryComponent上)应该是boolean类型,以便与WorkoutLogEntry.completed属性进行相等比较。

search管道现在可以添加到ngFor指令表达式中。我们将链式使用searchorderBy管道。更新ngFor表达式为:

<tr *ngFor="let historyItem of history 
**|search:'completed':completed** 
|orderBy:'-startedOn';  
let i = index">

search管道首先过滤历史数据,然后orderBy管道重新排序。要特别注意search管道的参数:第一个参数是一个字符串字面量,表示要搜索的字段('completed'),而第二个参数是从组件属性completed派生的。能够将管道参数绑定到组件属性允许我们有很大的灵活性。

继续验证历史页面的搜索功能。根据单选按钮的选择,历史记录被过滤,当然它们根据锻炼开始日期的逆序排列。

虽然使用管道与数组看起来很简单,但如果我们不理解管道何时被评估,可能会出现一些意外情况。

数组的管道陷阱

要理解应用于数组的管道的问题,请重现问题。

打开search.pipe.ts并删除@Pipe装饰器属性pure。还要更改以下语句:

if (searchTerm == null) return [...value];

到以下内容:

if (searchTerm == null) return [value];

在单选按钮列表的末尾(在workout-history.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管道无状态,只需使用pure:false更新Pipe装饰器:

@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 的数据绑定基础设施确定需要更新哪些视图部分。每个数据绑定框架都需要解决这个问题,而这些框架用于跟踪更改的方法也不同。甚至从 Angular1 到 Angular2 也有所不同。

要理解 Angular 中的变更检测如何工作,有一些事情我们需要记住。

  • 首先,Angular 应用程序只是一个从根到叶的组件层次结构。

  • 其次,我们绑定到视图的组件属性没有任何特殊之处;因此,Angular 需要一种有效的机制来知道这些属性何时发生更改。它无法持续轮询这些属性的更改。

  • 最后,为了检测属性值的变化,Angular 对先前值和当前值进行严格比较===)。对于引用类型,这意味着只比较引用。不进行深层比较。

注意

正因为这个原因,我们不得不将我们的搜索管道标记为有状态。

向现有数组添加元素不会改变数组引用,因此 Angular 无法检测到数组的任何更改。一旦管道被标记为有状态,无论数组是否发生更改,管道都会被评估。

由于 Angular 无法自动知道何时更新任何绑定属性,因此在触发变更检测运行时,它会检查每个绑定属性。从组件树的根开始,Angular 检查每个绑定属性以查找组件层次结构中的更改。如果检测到更改,该组件将被标记为需要刷新。值得重申的是,绑定属性的更改不会立即更新视图。相反,变更检测运行分为两个阶段。

  • 在第一阶段,它执行组件树遍历并标记需要由于模型更新而刷新的组件

  • 在第二阶段,实际视图与底层模型同步

注意

在变更检测运行期间,模型更改和视图更新永远不会交错进行。

我们现在只需要回答另外两个问题。何时触发变更检测运行?它运行多少次?

当触发以下事件之一时,将触发 Angular 变更检测运行:

  • 用户输入/浏览器事件:我们点击按钮,输入一些文本,滚动内容。这些操作都可以更新视图(和底层模型)。

  • 远程 XHR 请求:这是视图更新的另一个常见原因。从远程服务器获取数据以显示在网格上,以及获取用户数据以渲染视图都是这种情况的例子。

  • setTimeout 和 setInterval:事实证明,我们可以使用setTimeoutsetInterval来异步执行一些代码,并在特定间隔内执行。这样的代码也可以更新模型。例如,setInterval计时器可以定期检查股票报价并更新 UI 上的股价。

最重要的是,每个组件模型只检查一次,以自顶向下的方式进行,从根组件到树叶。

注意

当 Angular 配置为运行在生产模式时,最后一句是正确的。在开发模式下,组件树会被遍历两次以进行更改。Angular 期望在第一次遍历树后模型是稳定的。如果不是这种情况,Angular 会在开发模式下抛出错误,并在生产模式下忽略更改。

我们可以通过在bootstrap函数调用之前调用enableProdMode函数来启用生产模式。import {enableProdMode} from '@angular/core' enableProdMode(); platformBrowserDynamic().bootstrapModule(AppModule);

让我们探索一些 Angular DI 框架的其他方面,从层次注入器开始,这是 Angular 的一个令人困惑但非常强大的特性。

层次注入器

在 Angular 中,注入器是一个负责存储依赖项并在需要时分发它们的依赖容器。之前在模块上展示的提供者注册示例实际上是在全局注入器中注册依赖项。

注册组件级别的依赖关系

到目前为止,我们所做的所有依赖注册都是在模块内完成的。Angular 更进一步,还允许在组件级别注册依赖关系。在@Component装饰器上有一个类似的 providers 属性,允许我们在组件级别注册依赖。

我们本来可以在WorkoutRunnerComponent上注册WorkoutHistoryTracker的依赖关系。类似这样的东西:

@Component({ 
  selector: 'workout-runner', 
  providers: [WorkoutRunnerComponent] 
})

但我们是否应该这样做,这是我们将在本节中讨论的事情。

在讨论分层注射器的情况下,重要的是要理解 Angular 为每个组件创建一个注射器(过于简化)。在组件级别进行的依赖项注册可在组件及其后代上使用。

我们还学到了依赖项是单例的。一旦创建,注射器每次都会返回相同的依赖项。这一特性在锻炼历史实现中非常明显。

WorkoutHistoryTracker已在ServicesModule中注册,然后注入到两个组件WorkoutRunnerComponentWorkoutHistoryComponent中。两个组件都获得相同的WorkoutHistoryTracker实例。下一个图表突出了这个注册和注入:

注册组件级别依赖项

要确认,只需在WorkoutHistoryTracker构造函数中添加一个console.log语句:

console.log("WorkoutHistoryTracker instance created.")

刷新应用程序并通过点击标题链接打开历史页面。无论我们运行锻炼多少次或打开历史页面,消息日志都会生成一次。

现在我们看到了一个新的交互/数据流模式!仔细想想;一个服务被用来在两个组件之间共享状态。WorkoutRunnerComponent生成数据,WorkoutHistoryComponent消耗数据。而且这一切都没有任何相互依赖。我们正在利用依赖项是单例的事实。这种数据共享/交互/数据流模式可以用来在任意数量的组件之间共享状态。事实上,这是我们武器库中非常强大的武器。下次需要在不相关的组件之间共享状态时,考虑使用服务。

但这与分层注射器有什么关系呢?好吧,让我们不拐弯抹角了;让我们直截了当地说。

虽然使用注射器注册的依赖项是单例的,但注射器本身不是!在任何给定的时间点,应用程序中都有多个活动的注射器。实际上,注射器是按照组件树的相同层次结构创建的。Angular 为组件树中的每个组件创建一个Injector实例(过于简化;请参阅下一个信息框)。

注意

Angular 并不是为每个组件都创建一个注射器。如 Angular 开发人员指南中所解释的:

每个组件都不需要自己的注射器,为了没有好处而大量创建注射器将是非常低效的。

但事实是每个组件都有一个注入器(即使它与另一个组件共享该注入器),并且可能有许多不同的注入器实例在组件树的不同级别运行。

假设每个组件都有自己的注入器是很有用的。

当进行锻炼时,组件和注入器树看起来像这样:

注册组件级别依赖项

插入文本框表示组件名称。根注入器是作为应用程序引导过程的一部分创建的注入器。

这种注入器层次结构的重要性是什么?要理解其影响,我们需要了解当组件请求依赖项时会发生什么。

Angular DI 依赖项遍历

每当请求依赖项时,Angular 首先尝试从组件自己的注入器满足依赖项。如果无法找到所请求的依赖项,则会查询父组件注入器以获取依赖项,如果再次失败,则查询其父级,依此类推,直到找到依赖项或达到根注入器。要点:任何依赖搜索都是基于层次结构的。

早些时候,当我们注册WorkoutHistoryTracker时,它是与根注入器一起注册的。WorkoutRunnerComponentWorkoutHistoryComponentWorkoutHistoryTracker的依赖请求是由根注入器满足的,而不是它们自己的组件注入器。

这种分层注入器结构带来了很大的灵活性。我们可以在不同的组件级别配置不同的提供者,并在子组件中覆盖父级提供者配置。这仅适用于在组件上注册的依赖项。如果依赖项添加在模块上,它将在根注入器上注册。

让我们尝试在使用它的组件中覆盖全局WorkoutHistoryTracker服务,以了解这种覆盖会发生什么。这将会很有趣,我们会学到很多!

打开workout-runner.component.ts,并在@Component装饰器中添加一个providers属性:

providers: [WorkoutHistoryTracker]

workout-history.component.ts中也这样做。现在,如果我们刷新应用程序,开始锻炼,然后加载历史页面,网格是空的。无论我们尝试运行锻炼的次数,历史网格始终为空。

原因是非常明显的。在每个WorkoutRunnerComponentWorkoutHistoryComponent上设置WorkoutHistoryTracker提供程序后,依赖关系由各自的组件注入器自行满足。当请求时,两个组件注入器都会创建自己的WorkoutHistoryTracker实例,因此历史跟踪被破坏。查看以下图表以了解在两种情况下请求是如何被满足的:

Angular DI dependency walk

一个快速的问题:如果我们在根组件TrainerAppComponent中注册依赖项,而不是在应用程序引导期间进行注册,会发生什么?类似于:

@Component({ 
  selector: 'trainer-app', 
 **providers:[WorkoutHistoryTracker]** 
} 
export class TrainerAppComponent { 

有趣的是,即使使用这种设置,事情也能完美地运行。这是非常明显的;TrainerAppComponentRouterOutlet的父组件,它在内部加载WorkoutRunnerComponentWorkoutHistoryComponent。因此,在这样的设置中,依赖关系由TrainerAppComponent的注入器满足。

注意

如果中间组件声明自己是宿主组件,那么在组件层次结构上进行的依赖查找可以被操纵。我们将在后面的章节中了解更多关于这个的内容。

分层注入器允许我们在组件级别注册依赖项,避免了全局注册所有依赖项的需要。

这个功能在构建 Angular 库组件时非常方便。这样的组件可以注册它们自己的依赖项,而不需要库的消费者注册特定于库的依赖项。

提示

记住:如果你在加载正确的服务/依赖项时遇到问题,请确保检查组件层次结构,看看是否在任何级别上进行了覆盖。

我们现在了解了组件中的依赖解析是如何工作的。但是如果一个服务有一个依赖项会发生什么呢?又是另一个未知的领域需要探索。

提示

在继续之前,删除我们在这两个组件中进行的provider注册。

使用@Injectable 进行依赖注入

WorkoutHistoryTracker有一个基本缺陷;历史记录没有被持久化。刷新应用程序,历史记录就丢失了。我们需要添加持久化逻辑来存储历史数据。为了避免任何复杂的设置,我们使用浏览器本地存储来存储历史数据。

services文件夹中添加一个local-storage.ts文件。并添加以下类定义:

export class LocalStorage { 
  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对象上的一个简单包装器。

继续在服务模块(services.module.ts)中注册 LocalStorage 服务。

像任何其他依赖项一样,在 WorkoutHistoryTracker 构造函数中注入它(workout-history-tracker.ts 文件)并进行必要的导入:

import {LocalStorage} from './local-storage'; 
... 
constructor(private storage: LocalStorage) {

这是标准的 DI 内容,只是它没有按预期工作。如果我们现在刷新应用程序,Angular 会抛出一个错误:

Cannot resolve all parameters for WorkoutHistoryTracker(?). Make sure they all have valid type or annotations. 

奇怪!这么棒的 DI 居然失败了,而且没有任何好的理由!其实不然;Angular 并没有进行任何魔法。它需要知道类的依赖关系,唯一的方法就是检查类的定义和构造函数参数。

WorkoutHistoryTracker 上添加一个名为 @Injectable() 的装饰器(记得加上括号),并添加模块导入语句:

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

刷新页面,DI 就能完美地工作了。是什么让它工作的?

通过添加 @Injectable 装饰器,我们强制 TypeScript 转译器为 WorkoutHistoryTracker 类生成元数据。这包括有关构造函数参数的详细信息。Angular DI 使用这些生成的元数据来确定服务的依赖类型,并在将来创建服务时满足这些依赖。

那些使用 WorkoutHistoryTracker 的组件呢?我们没有在那里使用 @Injectable,但是 DI 仍然起作用。我们不需要。任何装饰器都可以使用,而且所有组件已经应用了 @Component 装饰器。

提示

记住装饰器需要添加到调用类(或客户类)上。

LocalStorage 服务和 WorkoutHistoryTracker 之间的实际集成是一个平凡的过程。

更新 WorkoutHistoryTracker 的构造函数如下:

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: string = "workouts";

构造函数从本地存储中加载训练日志。map 函数调用是必要的,因为 localStorage 中存储的所有内容都是字符串。因此,在反序列化时,我们需要将字符串转换回日期值。

startTrackingexerciseCompleteendTracking 函数中最后添加这个声明:

this.storage.setItem(this.storageKey, this.workoutHistory);

我们每次历史数据发生变化时都会将训练记录保存到本地存储中。

就是这样!我们已经在 localStorage 上构建了训练历史记录跟踪。验证一下!

在我们继续处理音频支持这个大问题之前,还有一些小的修复需要进行,以提供更好的用户体验。第一个与 历史 链接有关。

使用路由器服务跟踪路由更改。

Header组件中的History链接对所有路由可见。如果在锻炼页面上隐藏该链接会更好。我们不希望因为意外点击History链接而丢失正在进行中的锻炼。此外,在进行锻炼时,没有人对锻炼历史感兴趣。

修复很容易。我们只需要确定当前路由是否是锻炼路由,并隐藏链接。Router服务将帮助我们完成这项工作。

打开header.component.ts并为路由添加必要的导入;更新Header类的定义为:

import {Router, Event } from '@angular/router'; 
... 
export class HeaderComponent { 
  showHistoryLink: boolean = true; 
  private subscription: any; 
  constructor(private router: Router) { 
    this.router.events.subscribe((data: Event) => { 
      this.showHistoryLink=!this.router.url.startsWith('/workout'); 
    }); 
  } 

showHistoryLink属性确定是否向用户显示历史链接。在构造函数中,我们注入了Router服务,并使用subscribe函数在events属性上注册了一个回调。

events属性是一个可观察对象。我们将在本章后面学习更多关于可观察对象的知识,但现在理解可观察对象是指能够触发事件并且可以被订阅的对象就足够了。subscribe函数注册一个回调函数,每当路由改变时就会被调用。

回调函数的实现只是根据当前路由名称切换showHistoryLink状态。我们从router对象的url属性中获取名称。

在视图中使用showHistoryLink,只需更新头部模板行的锚标签为:

<li *ngIf="showHistoryLink"><a [routerLink]="['History']" ...>...</a></li>

就是这样!History链接不会出现在锻炼页面上。

另一个修复/增强与锻炼页面上的视频面板有关。

修复视频播放体验

当前视频面板的实现最多可以称为业余。默认播放器的大小很小。当我们播放视频时,锻炼不会暂停。在锻炼转换时,视频播放会中断。此外,整体视频加载体验在每次锻炼例行程序开始时都会有明显的延迟。这清楚地表明了视频播放需要一些修复。

这就是我们要做的来修复视频面板的方法:

  • 为锻炼视频使用图像缩略图,而不是加载视频播放器本身

  • 当用户点击缩略图时,加载一个可以播放所选视频的更大的视频播放器的弹出窗口/对话框

  • 在视频播放时暂停锻炼

让我们开始工作吧!

使用视频缩略图

用这段代码替换video-player.html中的ngFor模板 html:

<div *ngFor="let video of videos" class="row video-image"> 
  <div class="col-sm-12"> 
    <div id="play-video-overlay"> 
      <span class="glyphicon glyphicon-play-circle video absolute-center">
      </span> 
    </div> 
    <img height="220" [src]="'//i.ytimg.com/vi/'+video+'/hqdefault.jpg'" /> 
  </div> 
</div> 

我们已经放弃了 iframe,而是加载了视频的缩略图图片(检查img标签)。这里显示的所有其他内容都是为了给图片设置样式。

注意

我们已经参考了 Stack Overflow 上的帖子bit.ly/so-yt-thumbnail来确定我们视频的缩略图图片 URL。

开始一个新的训练;图片应该显示出来,但是播放功能是坏的。我们需要添加视频播放对话框。

使用 angular2-modal 对话框库

Angular 框架没有预打包的 UI 库/控件。我们需要向外寻找社区解决方案来满足任何 UI 控件的需求。

我们将要使用的库是 angular2-modal,在 GitHub 上可以找到bit.ly/angular2-modal。让我们安装和配置这个库。

从命令行(在trainer文件夹内),运行以下命令来安装这个库:

**npm i [email protected] --save**

为了在我们的应用中集成 angular2-modal,我们需要在systemjs.config.js中添加 angular2-modal 的包引用。从 git 分支checkpoint3.2(GitHub 位置:bit.ly/ng2be-3-2-system-config-js)中复制更新后的systemjs.config.jstrainer文件夹,并覆盖本地配置文件。更新后的配置允许 SystemJS 在遇到库import语句时知道如何加载模态对话框库。

接下来的几步突出了在使用 angular2-modal 之前需要执行的配置仪式:

  • 在第一步中,我们要配置 angular2-modal 的根元素。打开app.component.ts并添加下面的代码:
        import {Component, **ViewContainerRef} from '@angular/core';** 
        ... 
        import { Overlay } from 'angular2-modal'; 
        ... 
        export class TrainerAppComponent { 
 **constructor(overlay: Overlay,** 
**viewContainer: ViewContainerRef) {** 
**overlay.defaultViewContainer = viewContainer;** 
          } 
        }

这一步是必不可少的,因为模态对话框需要一个容器组件来托管自己。通过传入TrainerAppComponentViewContainerRef,我们允许对话框在应用根内加载。

  • 下一步是将库中的两个模块添加到AppModule中。更新app.module.ts并添加以下代码:
        import { ModalModule } from 'angular2-modal'; 
        import { BootstrapModalModule }  
          from 'angular2-modal/plugins/bootstrap'; 
        ... 
        imports: [..., ModalModule.forRoot(), BootstrapModalModule] 

现在这个库已经准备好使用了。

虽然 angular2-modal 有许多预定义的标准对话框模板,比如警报、提示和确认,但这些对话框在外观和感觉方面提供了很少的定制。为了更好地控制对话框 UI,我们需要创建一个自定义对话框,幸运的是这个库支持。

使用 angular2-modal 创建自定义对话框

在 angular2-modal 中创建自定义对话框只是一些带有一些特殊库构造的 Angular 组件。

从 git 分支checkpoint3.2workout-runner/video-player文件夹中复制video-dialog.component.ts文件(GitHub 位置:bit.ly/ng2be-3-2-video-dialog-component-ts)到本地设置中。该文件包含了自定义对话框的实现。

接下来,更新workout-runner.module.ts,并在模块装饰器中添加一个新的entryComponents属性:

**import {VideoDialogComponent} from './video-player/video-dialog.component';** ...
declarations: [..., VideoDialogComponent], 
   **entryComponents:[VideoDialogComponent]** 

需要将VideoDialogComponent添加到entryComponents中,因为它在组件树中没有明确使用。

VideoDialogComponent是一个标准的 Angular 组件,具有一些模态对话框的特定实现,我们稍后会描述。

VideoDialogContext类已经被创建,用于将点击的 YouTube 视频的videoId传递给对话框实例。该类继承自BSModalContext,这是对话框库用于修改模态对话框行为和 UI 的配置类。

为了更好地了解VideoDialogContext的使用方式,让我们从锻炼运行器中调用前面的对话框。

更新video-player.html中的ngFor div,并添加一个click事件处理程序:

    <div *ngFor="let video of videos" (click)="playVideo(video)" 
    class="row video-image"> 

前面的处理程序调用playVideo方法,传入点击的视频。playVideo函数反过来打开相应的视频对话框。将playVideo的实现添加到video-player.component.ts中,如下所示:

 **import {Modal} from 'angular2-modal';** 
 **import { overlayConfigFactory } from 'angular2-modal'** 
 **import {VideoDialogComponent, VideoDialogContext}
    from './video-dialog.component';** 
    ... 
    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函数,传入要打开的对话框组件以及VideoDialogContext类的新实例,其中包含 YouTube 视频的videoId。在继续之前,也要从文件中删除ngOnChange函数。

video-dialog.component.ts中的对话框实现实现了ModalComponent<VideoDialogContext>接口,这是模态库所需的。看看如何将上下文(VideoDialogContext)传递给构造函数,以及如何从上下文中提取和分配videoId属性。然后只需要将videoId属性绑定到模板视图(查看模板 HTML)并渲染 YouTube 播放器。

我们已经准备就绪。加载应用程序并开始锻炼。然后点击任何锻炼视频图片。视频对话框应该加载,现在我们可以观看视频了!

在我们完成对话框实现之前,有一个小问题需要解决。当对话框打开时,锻炼应该暂停:目前并没有发生。我们将在下一节中使用 Angular 事件支持来解决这个问题。

注意

如果您在运行代码时遇到问题,请查看 git 分支checkpoint3.2,以获取我们迄今为止所做的工作的可工作版本。

或者,如果您不使用 git,请从bit.ly/ng2be-checkpoint3-2下载checkpoint3.2的快照(ZIP 文件)。在首次设置快照时,请参考trainer文件夹中的README.md文件。

在用 Angular 构建新的东西之前,我们计划在7 分钟锻炼中添加最后一个功能:音频支持。它还教会我们一些新的跨组件通信模式。

使用 Angular 事件进行跨组件通信

在上一章中,我们提到了事件,当学习 Angular 的绑定基础设施时。现在是时候更深入地了解事件了。让我们为7 分钟锻炼添加音频支持。

使用音频跟踪运动进展

对于7 分钟锻炼应用程序,添加声音支持至关重要。人们无法一直盯着屏幕做运动。音频提示有助于用户有效地进行锻炼,因为他/她可以只需跟随音频指示。

以下是我们将如何使用音频提示支持运动跟踪:

  • 滴答声跟踪运动进展

  • 半程指示器发出声音,表明练习已经进行了一半

  • 当练习即将结束时,会播放一个练习完成的音频片段

  • 在休息阶段播放音频片段,通知用户下一个练习

每种情况都会有一个音频片段。

现代浏览器对音频有很好的支持。HTML5 的<audio>标签提供了一种将音频片段嵌入到 html 内容中的机制。我们也将使用<audio>标签来播放我们的片段。

由于计划使用 HTML 的<audio>元素,我们需要创建一个包装指令,允许我们从 Angular 控制音频元素。请记住,指令是没有视图的 HTML 扩展。

注意

Git 的checkpoint3.3文件夹trainer/static/audio包含了所有用于播放的音频文件;首先复制它们。如果您不使用 Git,可以在bit.ly/ng2be-checkpoint3-3下载并解压内容并复制音频文件。

构建 Angular 指令来包装 HTML 音频

到目前为止,您可能还没有意识到,但我们有意避免直接访问 DOM 以实现任何组件。目前还没有这样的需求。Angular 的数据绑定基础设施,包括属性、属性和事件绑定,已经帮助我们在不触及 DOM 的情况下操作 HTML。

对于音频元素,访问模式也应该符合 Angular 的风格。让我们创建一个包装对音频元素访问的指令。

workout-runner文件夹内创建一个名为workout-audio的文件夹,并在其中添加一个名为my-audio.directive.ts的新文件。然后将此处概述的MyAudioDirective指令的实现添加到该文件中:

    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属性允许框架确定应用指令的位置。使用audio作为选择器使我们的指令在 html 中的每个<audio>标签中加载。

注意

在标准情况下,指令选择器是基于属性的,这有助于我们确定指令的应用位置。我们偏离了这个规范,使用了MyAudioDirective指令的元素选择器。

我们希望该指令加载到每个音频元素中,而逐个音频声明并添加指令特定属性变得繁琐。因此使用了元素选择器。

当我们在视图模板中使用该指令时,使用exportAs就变得清晰了。

在构造函数中注入的ElementRef对象是该指令加载的 Angular 元素。当 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)和三个 getter(currentTimeduration,以及一个名为playbackComplete的布尔属性)。这些函数和属性的实现只是包装了音频元素的函数。

注意

从 MDN 文档中了解这些音频功能:bit.ly/html-media-element

要了解我们如何使用音频指令,让我们创建一个新的组件来管理音频播放。

注意

在继续之前,请记得在WorkoutRunnerModule下注册MyAudioDirective

为音频支持创建 WorkoutAudioComponent

如果我们回过头来看一下所需的音频提示,有四个不同的音频提示,因此我们将创建一个带有五个嵌入式<audio>标签的组件(两个音频标签一起用于接下来的音频)。

打开workout-audio文件夹,并为组件模板创建一个名为workout-audio.html的文件。添加以下 HTML 片段:

    <audio #ticks="MyAudio" loop src="/static/audio/tick10s.mp3"></audio> 
    <audio #nextUp="MyAudio" src="/static/audio/nextup.mp3"></audio> 
    <audio #nextUpExercise="MyAudio" [src]= "'/static/audio/'
    + nextupSound">
    </audio> 
    <audio #halfway="MyAudio" src="/static/audio/15seconds.wav"></audio> 
    <audio #aboutToComplete="MyAudio" src="/static/audio/321.wav"></audio> 

五个<audio>标签,每个标签对应一个:

  • 滴答声音:此音频产生滴答声音,并在锻炼开始时立即开始。使用模板变量ticks引用。

  • 接下来的音频和锻炼音频:有两个一起工作的音频标签。第一个带有模板变量nextUp产生“接下来”声音。而实际的锻炼音频(nextUpExercise)。

  • 中途音频:中途音频在锻炼进行到一半时播放。

  • 即将完成的音频:播放此音频片段以表示完成一项锻炼(aboutToComplete)。

你有没有注意到视图中使用了#符号?有一些变量赋值以#为前缀。在 Angular 世界中,这些变量被称为模板引用变量,有时也称为模板变量。

平台开发人员指南这样描述它们:

模板引用变量是模板内的 DOM 元素或指令的引用。

注意

不要将它们与我们之前在ngFor指令中使用的模板输入变量混淆:“*ngFor="let video of videos"`”

模板输入变量(在本例中为video)允许我们从视图中访问模型对象。分配给video的值取决于ngFor指令循环的上下文。

看一下最后一节,我们在那里将MyAudioDirective指令的exportAs元数据设置为MyAudio。我们在前面的视图中分配模板引用变量时重复了相同的字符串:

#ticks="MyAudio"

exportAs的作用是定义可以在视图中用来将该指令分配给变量的名称。记住,单个元素/组件可以应用多个指令。exportAs允许我们选择应该分配给模板变量的指令。

一旦声明了模板变量,就可以从视图的其他部分访问它们。我们很快就会讨论这个问题。但在我们的情况下,我们将使用模板变量来引用父组件代码中的多个MyAudioDirective。让我们了解一下它是如何工作的。

workout-audio.compnent.ts文件添加到workout-audio文件夹,并按以下大纲进行编写:

    import {Component, ViewChild} from '@angular/core'; 
    import {MyAudioDirective} from './my-audio.directive' 
    import {WorkoutPlan, ExercisePlan, ExerciseProgressEvent, 
    ExerciseChangedEvent} from '../model'; 

    @Component({ 
      selector: 'workout-audio', 
      templateUrl: '/src/components/workout-runner/workout-audio/
      workout-audio.html' 
    }) 
    export class WorkoutAudioComponent { 
      @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; 
    } 

这里有趣的地方是对五个属性使用的@ViewChild装饰器。@ViewChild装饰器允许我们将子组件/指令/元素引用注入到其父组件中。传递的参数是模板变量名称,它帮助 DI 匹配要注入的元素/指令。当 Angular 实例化WorkoutAudioComponent时,它会根据@ViewChild装饰器注入相应的音频组件。在我们详细了解@ViewChild之前,让我们完成基本的类实现。

注意

MyAudioDirective指令上没有设置exportAs时,@ViewChild注入会注入相关的ElementRef实例,而不是MyAudioDirective实例。

剩下的任务就是在正确的时间播放正确的音频组件。将这些函数添加到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); 
      } 
    } 

在编写这些函数时遇到了困难吗?它们可以在 Git 分支checkpoint3.3中找到。

接下来,继续将WorkoutAudioComponent添加到WorkoutRunnerModuledeclarations数组中。

在上述代码中使用了两个新的模型类。将它们的声明添加到model.ts中,如下所示:

    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的实现会使用这些数据。随着我们的进展,数据是如何产生的就会变得清晰起来。

startresume函数在开始、暂停或完成训练时停止和恢复音频。在resume函数中的额外复杂性是为了处理当训练在下一个动作时被暂停,当即将完成时被暂停,或者在播放音频时半途而废的情况。我们只想从上次离开的地方继续。

onExerciseProgress 函数应该被调用以报告锻炼进度。它用于根据锻炼的状态播放中途音频和即将完成的音频。传递给它的参数是一个包含锻炼进度数据的对象。

当锻炼改变时,应该调用 onExerciseChanged 函数。输入参数包含当前和下一个锻炼,帮助 WorkoutAudioComponent 决定何时播放下一个锻炼的音频。

请注意,这两个函数是由组件的消费者(在本例中是 WorkoutRunnerComponent)调用的。我们不在内部调用它们。

在本节中,我们涉及了两个新概念:模板引用变量和将子元素/指令注入到父元素中。在继续实现之前,值得更详细地探讨这两个概念。我们将首先学习更多关于模板引用变量的知识。

理解模板引用变量

模板引用变量是在视图模板上创建的,并且大多数情况下是从视图中使用的。正如您已经学到的,这些变量可以通过使用 # 前缀来声明来识别。

模板变量的最大好处之一是它们在视图模板级别促进了跨组件通信。一旦声明,这些变量可以被同级元素/组件及其子元素引用。查看以下片段:

    <input #emailId type="email">Email to {{emailId.value}} 
    <button (click)= "MailUser(emaild.value)">Send</button> 

这个片段声明了一个模板变量 emailId,然后在插值和按钮的 click 表达式中引用它。

Angular 模板引擎将 input 的 DOM 对象(HTMLInputElement 的一个实例)分配给 emailId 变量。由于该变量在同级元素中可用,我们在按钮的 click 表达式中使用它。

模板变量也适用于组件。我们可以轻松地这样做:

    <trainer-app> 
     <workout-runner #runner></workout-runner> 
     <button (click)= "runner.start()">Start Workout</button> 
    </trainer-app> 

在这种情况下,runner 引用了 WorkoutRunnerComponent 对象,并且按钮用于开始锻炼。

注意

ref- 前缀是 # 的规范替代。#runner 变量也可以声明为 ref-runner

模板变量赋值

到目前为止,我们所见到的模板变量赋值有一些有趣的地方。回顾我们使用的例子:

    <audio #ticks="MyAudio" loop src="/static/audio/tick10s.mp3"></audio> 
    <input #emailId type="email">Email to {{emailId.value}}
    <workout-runner #runner></workout-runner> 

变量分配的内容取决于变量声明的位置。这由 Angular 中的规则所控制,如下所述:

  • 如果指令存在于元素上,例如在前面显示的第一个示例中的MyAudioDirective,则该指令设置该值。MyAudioDirective指令将ticks变量设置为MyAudioDirective的实例。

  • 如果没有指令存在,要么分配底层 HTML DOM 元素,要么分配组件对象(如emailworkout-runner示例中所示)。

我们将利用这种技术来实现训练音频组件与训练运行器组件的集成。这个介绍给了我们所需要的先发优势。

我们承诺要涵盖的另一个新概念是使用ViewChildViewChildren装饰器进行子元素/指令注入。

使用@ViewChild装饰器

@ViewChild装饰器通知 Angular DI 框架在组件树中搜索子组件/指令/元素并将它们注入到父组件中。在上面的代码中,音频元素指令(MyAudioDirective类)被注入到WorkoutAudioComponent代码中。

为了建立上下文,让我们重新检查WorkoutAudioComponent中的一个视图片段:

    <audio #ticks="MyAudio" loop src="/static/audio/tick10s.mp3"></audio> 

Angular 将指令(MyAudioDirective)注入到WorkoutAudioComponent属性ticks中。映射是基于传递给@ViewChild装饰器的选择器进行的。

ViewChild上的选择器参数可以是字符串值,在这种情况下,Angular 会搜索匹配的模板变量,就像以前一样。

或者它可以是一个类型。这是有效的:

    @ViewChild(MyAudioDirective) private ticks: MyAudioDirective; 

但在我们的情况下,这并不起作用。在WorkoutAudioComponent视图中加载了多个MyAudioDirective指令,每个<audio>标签对应一个。在这种情况下,只会注入第一个匹配项。并不是很有用。如果视图中只有一个<audio>标签,传递类型选择器将起作用。

提示

使用@ViewChild装饰的属性在调用组件的ngAfterViewInit事件钩子之前一定会被设置。这意味着如果在构造函数内部访问这些属性,它们将为null

@ViewChild类似,Angular 有一个装饰器来定位多个子组件/指令:@ViewChildren

@ViewChildren装饰器

@ViewChildren的工作方式与@ViewChild类似,只是当视图具有多个相同类型的子组件/指令时使用。使用@ViewChildren,我们可以获取WorkoutAudioComponent中所有MyAudioDirective指令的实例,如下所示:

    @ViewChildren(MyAudioDirective) allAudios: QueryList<MyAudioDirective>; 

仔细看,allAudios不是一个数组,而是一个自定义对象,QueryList<Type>QueryList是 Angular 能够定位的组件/指令的不可变集合。这个列表最好的地方是,Angular 将保持此列表与视图状态同步。当动态地从视图中添加/删除指令/组件时,此列表也会更新。使用ng-for生成的组件/指令是这种动态行为的一个主要例子。考虑前面的@ViewChildren用法和这个视图模板:

    <audio *ngFor="let clip of clips" src="/static/audio/ "
     +{{clip}}></audio> 

由 Angular 创建的MyAudioDirective指令的数量取决于clips的数量。当使用@ViewChildren时,Angular 会将正确数量的MyAudioDirective实例注入到allAudio属性中,并在从clips数组中添加或删除项目时保持同步。

虽然使用@ViewChildren允许我们获得所有MyAudioDirective指令,但它不能用于控制播放。你看,我们需要获得单独的MyAudioDirective实例,因为音频播放的时间不同。因此,我们将坚持使用@ViewChild实现。

一旦我们获得了附加到每个音频元素的MyAudioDirective指令,只需在正确的时间播放音频轨道。

集成 WorkoutAudioComponent

虽然我们已经将音频播放功能组件化为WorkoutAudioComponent,但它始终与WorkoutRunnerComponent实现紧密耦合。WorkoutAudioComponentWorkoutRunnerComponent获取其操作智能。因此,这两个组件需要互动。WorkoutRunnerComponent需要提供WorkoutAudioComponent的状态更改数据,包括训练开始时,练习进度,训练停止,暂停和恢复。

实现此集成的一种方法是使用当前公开的WorkoutAudioComponentAPI(停止,恢复和其他功能)从WorkoutRunnerComponent中。

可以通过将WorkoutAudioComponent注入到WorkoutRunnerComponent中来完成一些工作,就像我们之前将MyAudioDirective注入到WorkoutAudioComponent中一样。看看这段代码:

    @ViewChild(WorkoutAudioComponent) workoutAudioPlayer: 
    WorkoutAudioComponent; 

然后,WorkoutAudioComponent函数可以从代码中的不同位置调用WorkoutRunnerComponent。例如,这就是pause会如何改变的方式:

    pause() { 
      clearInterval(this.exerciseTrackingInterval); 
      this.workoutPaused = true; 
 **this.workoutAudioPlay.stop();** 
    }

要播放接下来的音频,我们需要更改startExerciseTimeTracking函数的部分内容:

    this.startExercise(next); 
    **this.workoutAudioPlayer.onExerciseChanged(
    new ExerciseChangedEvent(next, this.getNextExercise()));**

这是一个完全可行的选择,其中WorkoutAudioComponent成为由WorkoutRunnerComponent控制的哑组件。这种解决方案的唯一问题是,它给WorkoutRunnerComponent的实现增加了一些噪音。WorkoutRunnerComponent现在还需要管理音频播放。

然而,还有一种选择。WorkoutRunnerComponent可以在锻炼执行的不同时间触发事件,比如锻炼开始、练习开始、锻炼暂停等等。WorkoutRunnerComponent暴露事件的另一个优势是,它允许我们在未来将其他组件与WorkoutRunnerComponent集成,使用相同的事件。

暴露 WorkoutRunnerComponent 事件

Angular 允许组件和指令使用EventEmitter类来暴露自定义事件。在变量声明部分的末尾,将这些事件声明添加到WorkoutRunnerComponent中:

    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实现中,我们需要在适当的时机触发这些事件。

记得将ExerciseProgressEvent添加到模型模块中,并在顶部已经声明的模块中导入。并且将OutputEventEmitter导入到@angular/core中。

让我们试着理解@Output装饰器和EventEmitter类的作用。

@Output 装饰器

在第二章中,我们涵盖了相当多的 Angular 事件能力。具体来说,我们学会了如何使用bracketed ()语法在组件、指令或 DOM 元素上消耗任何事件。那么如何触发我们自己的事件呢?

在 Angular 中,我们可以创建和触发自己的事件,这些事件表示组件/指令中发生了值得注意的事情。使用@Output装饰器和EventEmitter类,我们可以定义和触发自定义事件。

现在是一个很好的时机,通过重新访问第二章中的Angular 事件绑定基础设施部分的Eventing 子部分来复习一下我们学到的关于事件的知识。

记住,正是通过事件,组件才能与外部世界进行通信。当我们声明:

    @Output() exercisePaused: EventEmitter<number> = 
    new EventEmitter<number>(); 

这表示 WorkoutRunnerComponent 公开了一个名为 exercisePaused 的事件(在锻炼暂停时触发)。

要订阅此事件,我们这样做:

    <workout-runner (exercisePaused)="onExercisePaused($event)">
    </workout-runner> 

这看起来与我们在锻炼运行器模板中订阅 DOM 事件的方式非常相似:

    <div id="pause-overlay" (click)="pauseResumeToggle()" 
    (window:keyup)="onKeyPressed($event)"> 

@Output 装饰器指示 Angular 使此事件可用于模板绑定。你可以创建一个没有 @Output 装饰器的事件,但这样的事件不能在 html 中引用。

注意

@Output 装饰器也可以带一个参数,表示事件的名称。如果没有提供,装饰器将使用属性名称:@Output("workoutPaused") exercisePaused: EventEmitter<number> = new EventEmitter<number>();

这声明了一个名为 workoutPaused 的事件,而不是 exercisePaused

像任何装饰器一样,@Output 装饰器也只是为了提供元数据,以便 Angular 框架使用。真正的重活是由 EventEmitter 类完成的。

使用 EventEmitter 进行事件处理

Angular 采用响应式编程(也称为Rx风格编程)来支持异步操作和事件。如果你第一次听到这个术语,或者对响应式编程不太了解,你并不孤单。

响应式编程就是针对异步数据流进行编程。这样的流就是基于它们发生的时间顺序排列的一系列持续事件。我们可以把流想象成一个生成数据(以某种方式)并将其推送给一个或多个订阅者的管道。由于这些事件被订阅者异步捕获,它们被称为异步数据流。

数据可以是任何东西,从浏览器/DOM 元素事件,到用户输入,再到使用 AJAX 加载的远程数据。使用 Rx 风格,我们统一消耗这些数据。

在 Rx 世界中,有观察者和可观察对象,这是从非常流行的观察者设计模式派生出来的概念。可观察对象是发出数据的流。观察者则订阅这些事件。

Angular 中的 EventEmitter 类主要负责提供事件支持。它既充当观察者又充当可观察对象。我们可以在其上触发事件,也可以用它来监听事件。

EventEmitter 上有两个函数对我们很有兴趣:

  • emit:顾名思义,使用这个函数来触发事件。它接受一个事件数据作为参数。emit 是可观察的一面。

  • subscribe:使用这个函数来订阅EventEmitter引发的事件。subscribe是观察者端。

让我们进行一些事件发布和订阅,以了解前面的函数是如何工作的。

从 WorkoutRunnerComponent 中引发事件

看一下EventEmitter的声明。这些已经声明了type参数。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。现在这两者只需要相互交流。

首先显而易见的选择是将WorkoutAudioComponent声明添加到WorkoutRunnerComponent视图中。因此,WorkoutAudioComponent成为WorkoutRunnerComponent的子组件。然而,在这样的设置中,它们之间的通信变得有点笨拙。记住,事件是组件与外部世界通信的机制。

如果父组件需要与其子组件通信,可以通过以下方式实现:

  • 属性绑定:父组件可以在子组件上设置属性绑定,将数据推送到子组件。例如:
        <workout-audio [stopped]="workoutPaused"></workout-audio> 

在这种情况下,属性绑定效果很好。当锻炼暂停时,音频也会停止。但并非所有情况都可以使用属性绑定来处理。播放下一个练习音频或中途音频需要更多的控制。

  • 在子组件上调用函数:如果父组件可以获取子组件,那么父组件也可以在子组件上调用函数。我们已经看到了如何在WorkoutAudioComponent的实现中使用@ViewChild@ViewChildren装饰器来实现这一点。

还有一个不太好的选择,即父组件实例可以被注入到子组件中。在这种情况下,子组件可以调用父组件函数或设置内部事件处理程序以处理父事件。

我们将尝试这种方法,然后放弃实现一个更好的方法!我们计划实现的不太理想的解决方案可以带来很多学习。

将父组件注入到子组件中

在最后一个闭合 div 之前将WorkoutAudioComponent添加到WorkoutRunnerComponent视图中:

    <workout-audio></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,我们创建了一个依赖循环。

循环依赖对于任何 DI 框架来说都是具有挑战性的。在创建具有循环依赖的组件时,框架必须以某种方式解决这个循环。在前面的例子中,我们通过使用@Inject装饰器并传入使用forwardRef()全局框架函数创建的标记来解决循环依赖问题。

在构造函数中,我们使用EventEmitter订阅函数将处理程序附加到事件上。传递给subscribe的箭头函数在特定事件发生时被调用。我们将所有订阅收集到一个数组subscription中。当我们需要取消订阅时,这个数组非常有用,以避免内存泄漏。

EventEmmiter的订阅(subscribe函数)有三个参数:

    subscribe(generatorOrNext?: any, error?: any, complete?: any) : any 

  • 第一个参数是一个回调函数,每当事件被触发时调用

  • 第二个参数是一个错误回调函数,当可观察对象(生成事件的部分)出现错误时调用

  • 最终的参数是一个回调函数,当可观察对象完成发布事件时调用

我们已经做了足够的工作来使音频集成工作。运行应用程序并开始锻炼。除了滴答声音之外,所有其他音频片段都会在正确的时间播放。您可能需要等一段时间才能听到其他音频片段。问题是什么?

事实证明,我们从未在锻炼开始时播放滴答声音。我们可以通过在ticks音频元素上设置autoplay属性或使用“组件生命周期事件”来触发滴答声音来修复它。让我们采取第二种方法。

使用组件生命周期事件

我们在WorkoutAudioComponent中进行了MyAudioDirective的注入:

    @ViewChild('ticks') private ticks: MyAudioDirective; 

在组件的视图被初始化之前,这将不可用:

我们可以通过在构造函数中访问ticks变量来验证它;它将为 null。Angular 仍然没有发挥其魔力,我们需要等待WorkoutAudioComponent的子级初始化。

组件的生命周期钩子可以帮助我们。一旦组件的视图被初始化,就会调用AfterViewInit事件钩子,因此从中访问组件的子指令/元素是一个安全的地方。让我们快速做一下。

通过添加接口实现和必要的导入来更新WorkoutAudioComponent,如下所示:

    import {..., AfterViewInit} from '@angular/core'; 
    ... 
      export class WorkoutAudioComponent implements AfterViewInit { 
        ngAfterViewInit() { 
          this.ticks.start(); 
        }

继续测试应用程序。应用程序已经具备了完整的音频反馈。不错!

虽然表面上一切看起来都很好,但现在应用程序中存在内存泄漏。如果在锻炼过程中我们从锻炼页面导航到开始或结束页面,然后再返回到锻炼页面,多个音频片段会在随机时间播放。

似乎WorkoutRunnerComponent在路由导航时没有被销毁,因此,包括WorkoutAudioComponent在内的子组件都没有被销毁。结果是什么?每次我们导航到锻炼页面时都会创建一个新的WorkoutRunnerComponent,但在导航离开时却从内存中永远不会被移除。

这个内存泄漏的主要原因是我们在WorkoutAudioComponent中添加的事件处理程序。当音频组件卸载时,我们需要取消订阅这些事件,否则WorkoutRunnerComponent的引用将不会被解除引用。

另一个组件生命周期事件在这里拯救我们:OnDestroy!将这个实现添加到WorkoutAudioComponent类中:

    ngOnDestroy() { 
      this.subscriptions.forEach((s) => s.unsubscribe()); 
    }

还记得像我们为AfterViewInit那样为OnDestroy事件接口添加引用吗?

希望我们在事件订阅期间创建的subscription数组现在有意义了。一次性取消订阅!

这个音频集成现在完成了。虽然这种方法并不是集成这两个组件的一个非常糟糕的方式,但我们可以做得更好。子组件引用父组件似乎是不可取的。

如果WorkoutRunnerComponentWorkoutAudioComponent组织为兄弟组件呢?

注意

在继续之前,删除我们从将父组件注入到子组件部分开始添加到workout-audio.component.ts的代码。

使用事件和模板变量进行兄弟组件交互

如果WorkoutAudioComponentWorkoutRunnerComponent变成兄弟组件,我们可以充分利用 Angular 的事件和模板引用变量。感到困惑?好吧,首先,组件应该布局如下:

    <workout-runner></workout-runner> 
    <workout-audio></workout-audio> 

有没有什么灵感?从这个模板开始,你能猜到最终的模板 HTML 会是什么样子吗?在继续之前先想一想。

还在挣扎吗?一旦我们将它们变成兄弟组件,Angular 模板引擎的威力就显现出来了。以下模板代码足以集成WorkoutRunnerComponentWorkoutAudioComponent

    <workout-runner (exercisePaused)="wa.stop()" 
    (exerciseResumed)="wa.resume()" (exerciseProgress)=
    "wa.onExerciseProgress($event)" (exerciseChanged)=
    "wa.onExerciseChanged($event)" (workoutComplete)="wa.stop()" 
    (workoutStarted)="wa.resume()"> 
    </workout-runner> 
    <workout-audio #wa></workout-audio> 

WorkoutAudioComponent的模板变量waWorkoutRunnerComponent的模板上被操作。相当优雅!在这种方法中,我们仍然需要解决最大的难题:前面的代码应该放在哪里?记住,WorkoutRunnerComponent是作为路由加载的一部分加载的。在代码中我们从来没有像这样的语句:

    <workout-runner></workout-runner> 

我们需要重新组织组件树,并引入一个容器组件,可以承载WorkoutRunnerComponentWorkoutAudioComponent。然后路由器加载此容器组件,而不是WorkoutRunnerComponent。让我们开始吧。

workout-runner文件夹内创建一个名为workout-container的文件夹,并添加两个新文件,workout-container.component.tsworkout-container.html

将带有前面描述的事件的 HTML 代码复制到模板文件中,并在workout-container.component.ts中添加以下声明:

    import {Component, Input} from '@angular/core'; 
    @Component({ 
      selector: 'workout-container',
      templateUrl: '/src/components/workout-runner/workout-container.html' 
    }) 
    export class WorkoutContainerComponent { } 

锻炼容器组件已准备就绪。将其添加到workout-runner.module.ts中的declarations部分,并导出它,而不是WorkoutRunnerComponent

接下来,我们只需要重新设置路由。打开app.routes.ts。更改锻炼页面的路由并添加必要的导入:

    import {WorkoutContainerComponent} from '../workout-runner/
            workout-container/workout-container.component'; 
    ..
    **{ path: '/workout', component: WorkoutContainerComponent },**

我们有一个清晰、简洁且令人愉悦的工作音频集成!

现在是时候结束本章了,但在解决早期部分引入的视频播放器对话框故障之前还不要结束。当视频播放器对话框打开时,锻炼不会停止/暂停。

我们不打算在这里详细说明修复方法,并敦促读者在不查看checkpoint3.3代码的情况下尝试一下。

这是一个明显的提示。使用事件基础设施!

另一个:从VideoPlayerComponent中触发事件,每个事件对应播放开始和结束。

最后的提示:对话框服务(Modal)上的open函数返回一个 promise,在对话框关闭时解析。

注意

如果您在运行代码时遇到问题,请查看 git 分支checkpoint3.3,以获取到目前为止我们所做的工作的可用版本。

或者,如果您不使用 git,请从bit.ly/ng2be-checkpoint3-3下载checkpoint3.2的快照(ZIP 文件)。在首次设置快照时,请参考trainer文件夹中的README.md文件。

总结

一点一点地,我们为7 分钟锻炼添加了许多增强功能,这对于任何专业应用程序都是必不可少的。仍然有新功能和改进的空间,但核心应用程序运行良好。

我们开始本章时探讨了 Angular 的单页面应用(SPA)功能。在这里,我们学习了基本的 Angular 路由,设置路由,使用路由配置,使用RouterLink指令生成链接,以及使用 Angular 的RouterLocation服务进行导航。

从应用程序的角度来看,我们为7 分钟锻炼添加了开始、完成和锻炼页面。

然后,我们构建了一个锻炼历史跟踪器服务,用于跟踪历史锻炼执行情况。在这个过程中,我们深入学习了 Angular 的依赖注入(DI)。我们学习了依赖项的注册方式,依赖项令牌是什么,以及依赖项是单例的特性。

最后,我们提到了一个重要的主题:跨组件通信,主要使用 Angular 事件。我们详细介绍了如何使用@Output装饰器和EventEmitter创建自定义事件。

@ViewChild@ViewChildren装饰器在本章中帮助我们理解了父组件如何获取子组件以供使用。Angular DI 也允许将父组件注入到子组件中。

我们通过构建一个WorkoutAudioComponent来结束了本章,并突出了如何使用 Angular 事件和模板变量进行兄弟组件通信。

接下来呢?我们将构建一个新的应用程序,个人教练。这个应用程序将允许我们构建自己的定制锻炼。一旦我们能够创建自己的锻炼,我们将把7 分钟锻炼应用程序改造成一个通用的锻炼运行器应用程序,可以运行我们使用个人教练构建的锻炼。

在下一章中,我们将展示 AngularJS 表单的功能,同时构建一个允许我们创建、更新和查看自定义锻炼/练习的 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 分钟锻炼应用程序时定义的。锻炼和练习的两个核心概念对个人教练也适用。

现有的锻炼模型的唯一问题是它位于锻炼运行者目录中。这意味着为了使用它,我们必须从该目录导入它。将模型移动到service文件夹中更有意义,这样可以清楚地表明它可以跨功能使用。

让我们了解如何在整个应用程序中共享模型。

分享锻炼模型

我们将分享锻炼模型作为服务。如前一章所述,服务没有特定的定义。基本上,它是一个保存功能的类,可能在我们的应用程序中的多个位置有用。由于它将在 Workout Runner 和 Workout Builder 中使用,我们的锻炼模型符合该定义。将我们的模型转换为服务并不需要太多仪式感 - 所以让我们开始做吧。

首先,从 GitHub 存储库中的checkpoint4.1下载新个人教练应用程序的基本版本。

注意

代码可在 GitHub 上下载github.com/chandermani/angular2byexample。检查点在 GitHub 中作为分支实现。要下载的分支如下:GitHub 分支:checkpoint4.1。如果您不使用 Git,请从以下 GitHub 位置下载 Checkpoint 4.1 的快照(ZIP 文件):github.com/chandermani/angular2byexample/archive/checkpoint4.1.zip。首次设置快照时,请参考trainer文件夹中的README.md文件。

此代码包含完整的7 分钟锻炼(锻炼运行者)应用程序。我们添加了一些内容来支持新的个人教练应用程序。一些相关的更新包括:

  • 添加新的WorkoutBuilder功能。此功能包含与个人教练相关的实现。

  • 更新应用程序的布局和样式。

  • trainer/src/components文件夹下的workout-builder文件夹中添加一些组件和带有个人教练占位内容的 HTML 模板。

  • 定义一个新的路由到WorkoutBuilder功能。我们将在接下来的部分中介绍如何设置这个路由。

让我们回到定义模型。

模型作为一个服务

在上一章中,我们专门学习了关于 Angular 服务的一个完整部分,我们发现服务对于在控制器和其他 Angular 构造之间共享数据非常有用。实际上,我们没有任何数据,只有描述数据形状的蓝图。因此,我们计划使用服务来公开模型结构。打开app文件夹下的services文件夹中的model.ts文件。

注意

model.ts文件已经移动到services文件夹中,因为该服务在Workout BuilderWorkout Runner应用程序之间共享。注意:在trainer/src/components文件夹下的workout-runner文件夹中,我们已经更新了workout-runner.component.tsworkout-audio.component.tsworkout-audio0.component.ts中的导入语句,以反映这一变化。

在第二章中,构建我们的第一个应用 - 7 分钟锻炼,我们回顾了模型文件中的类定义:ExerciseExercisePlanWorkoutPlan。正如我们之前提到的,这三个类构成了我们的基本模型。我们现在将开始在我们的新应用中使用这个基本模型。

这就是关于模型设计的全部内容。接下来,我们要做的是定义新应用的结构。

个人教练布局

个人教练的骨架结构如下:

个人教练布局

这有以下组件:

  • 顶部导航:这包含应用品牌标题和历史链接。

  • 子导航:这里有导航元素,根据活动组件的不同而变化。

  • 左侧导航:这包含依赖于活动组件的元素。

  • 内容区域:这是我们组件的主视图显示的地方。这里发生了大部分的动作。我们将在这里创建/编辑练习和锻炼,并显示练习和锻炼的列表。

查看源代码文件;在trainer/src/components下有一个新的文件夹workout-builder。它有我们之前描述的每个组件的文件,带有一些占位内容。我们将在本章节中逐步构建这些组件。

但是,我们首先需要在应用程序中连接这些组件。这要求我们定义 Workout Builder 应用程序的导航模式,并相应地定义应用程序路线。

带路由的私人教练

我们计划在应用程序中使用的导航模式是列表-详细信息模式。我们将为应用程序中可用的练习和锻炼创建列表页面。单击任何列表项将带我们到该项的详细视图,我们可以在那里执行所有 CRUD 操作(创建/读取/更新/删除)。以下路线符合此模式:

路线 描述
/builder 这只是重定向到builder/workouts
/builder/workouts 这列出了所有可用的锻炼。这是Workout Builder的登陆页面。
/builder/workout/new 这将创建一个新的锻炼。
/builder/workout/:id 这将编辑具有特定 ID 的现有锻炼。
/builder/exercises 这列出了所有可用的练习。
/builder/exercise/new 这将创建一个新的练习。
/builder/exercise/:id 这将编辑具有特定 ID 的现有练习。

开始

此时,如果您查看src/components/app文件夹中的app.routes.ts中的路由配置,您将找到一个新的路由定义 - builder :

export const routes: Routes = [ 
  ... 
{ path: 'builder', component: WorkoutBuilderComponent }, 
  ... 
]; 

如果您运行应用程序,您将看到启动屏幕显示另一个链接:创建一个锻炼:

入门

在幕后,我们为此链接添加了另一个路由器链接到start.html

<a [routerLink]="['/builder']"> 
  <span>Create a Workout</span> 
  <span class="glyphicon glyphicon-plus"></span> 
</a> 

如果您点击此链接,您将进入以下视图:

入门

再次在幕后,我们在trainer/src/components/workout-builder文件夹中添加了一个WorkoutBuilderComponent,并在workout-builder.component.html中添加了以下相关模板:

<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.ts的视图模板中使用路由器出口显示在屏幕上的标题下:

<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"; 

@NgModule({ 
    imports: [CommonModule], 
    declarations: [ 
        WorkoutBuilderComponent, 
    ], 
    exports: [WorkoutBuilderComponent], 
}) 
export class WorkoutBuilderModule { }

这里唯一可能与我们创建的其他模块不同的地方是,我们导入的是CommonModule而不是BrowserModule。这样可以避免第二次导入整个BrowserModule,这样在实现此模块的延迟加载时会生成错误。

最后,我们已经在app.module.ts中为这个模块添加了一个导入:

  ... 
@NgModule({ 
  imports: [ 
  ... 
 **WorkoutBuilderModule],** 
  ... 

所以这里没有什么令人惊讶的。这些是我们在前几章介绍的基本组件构建和路由模式。遵循这些模式,我们现在应该开始考虑为我们的新功能添加先前概述的额外导航。然而,在我们开始做这件事之前,有一些事情我们需要考虑。

首先,如果我们开始将路由添加到app.routes.ts文件中,那么存储在那里的路由数量将增加。Workout Builder的这些新路由也将与Workout Runner的路由混合在一起。虽然我们现在添加的路由数量似乎微不足道,但随着时间的推移,这可能会成为一个维护问题。

其次,我们需要考虑到我们的应用程序现在包括两个功能 - Workout RunnerWorkout Builder。我们应该考虑如何在应用程序中分离这些功能,以便它们可以独立开发。

换句话说,我们希望在构建的功能之间实现松耦合。使用这种模式允许我们在不影响其他功能的情况下替换应用程序中的功能。例如,将来我们可能希望将Workout Runner转换为移动应用程序,但保持Workout Builder作为基于 Web 的应用程序不变。

回到第一章,我们强调了将组件彼此分离的能力是 Angular 实现的组件设计模式的关键优势之一。幸运的是,Angular 的路由器使我们能够将我们的路由分离成逻辑组织良好的路由配置,这与我们应用程序中的功能密切匹配。

为了实现这种分离,Angular 允许我们使用子路由,在这里我们可以隔离每个功能的路由。在本章中,我们将使用子路由来分离Workout Builder的路由。

向 Workout Builder 引入子路由

Angular 支持我们隔离新的Workout Builder路由的目标,通过为我们提供在应用程序中创建路由组件层次结构的能力。目前,我们只有一个路由组件,它位于应用程序的根组件中。但是 Angular 允许我们在根组件下添加所谓的子路由组件。这意味着一个功能可以不知道另一个功能正在使用的路由,并且每个功能都可以自由地根据该功能内部的变化来调整其路由。

回到我们的应用程序,我们可以使用 Angular 中的子路由来匹配我们应用程序的两个功能的路由与将使用它们的代码。因此,在我们的应用程序中,我们可以将路由结构化为以下Workout Builder的路由层次结构(在这一点上,我们将Workout Runner保持不变,以显示之前和之后的比较):

介绍将子路由引入到 Workout Builder 中

通过这种方法,我们可以通过功能创建路由的逻辑分离,并使其更易于管理和维护。

所以让我们开始通过向我们的应用程序添加子路由来开始。

注意

从这一点开始,在本节中,我们将继续添加我们在本章早期下载的代码。如果您想查看本节的完整代码,可以从 GitHub 存储库的检查点 4.2 中下载。如果您想与我们一起构建本节的代码,请确保在trainer/static/css文件夹中添加app.css中的更改,因为我们不会在这里讨论它们。还要确保从存储库的trainer/src/components/workout-builder文件夹中添加 exercise(s)和 workout(s)的文件。在这个阶段,这些只是存根文件,我们将在本章后面实现它们。但是,您需要这些存根文件来实现Workout Builder模块的导航。该代码可供所有人在 GitHub 上下载:github.com/chandermani/angular2byexample。检查点在 GitHub 中作为分支实现。要下载的分支如下:GitHub 分支:checkpoint4.2。如果您不使用 Git,请从以下 GitHub 位置下载 Checkpoint 4.2 的快照(ZIP 文件):github.com/chandermani/angular2byexample/archive/checkpoint4.2.zip。首次设置快照时,请参考trainer文件夹中的README.md文件。

添加子路由组件

workout-builder目录中,添加一个名为workout-builder.routes.ts的新的 TypeScript 文件,其中包含以下导入:

import { ModuleWithProviders } from '@angular/core'; 
import { Routes, RouterModule } from '@angular/router'; 

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

正如你所看到的,我们正在导入我们刚刚提到的组件;它们将成为我们的Workout Builder(exercise,exercises,workout 和 workouts)的一部分。除了这些导入之外,我们还从 Angular 核心模块导入ModuleWithProviders,从 Angular 路由器模块导入RoutesRouterModule。这些导入将使我们能够添加和导出子路由。

然后将以下路由配置添加到文件中:

export const workoutBuilderRoutes: 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或者我们在这个文件中配置的其他路由。

最后,添加以下export语句:

export const workoutBuilderRouting: ModuleWithProviders = RouterModule.forChild(workoutBuilderRoutes);

这个导出将我们的子路由注册到路由器中,与 app.routes.ts 中的类似,只有一个区别:我们使用的是RouterModule.forChild而不是RouterModule.forRoot。这种差异的原因似乎很明显:我们正在创建子路由,而不是应用程序根目录中的路由,这是我们表示的方式。然而,在底层,这有着重大的区别。这是因为我们的应用程序中不能有多个路由器服务。forRoot创建路由器服务,但forChild不会。

更新 WorkoutBuilder 组件

接下来,我们需要更新WorkoutBuilder组件以支持我们的新子路由。为此,将 Workout Builder 的@Component装饰器更改为:

  • 移除selector

  • template引用替换对templateUrl的引用

  • 在模板中添加一个<sub-nav>自定义元素

  • 在模板中添加一个<router-outlet>标签

  • 装饰器现在应该如下所示:

        @Component({ 
            template: `<div class="navbar navbar-default 
                       navbar-fixed-top second-top-nav"> 
                  <sub-nav></sub-nav> 
               </div> 
               <div class="container body-content app-container"> 
                  <router-outlet></router-outlet> 
               </div>` 
        }) 

我们移除了选择器,因为WorkoutBuilderComponent不会嵌入在应用程序根目录app.component.ts中。相反,它将通过路由从app.routes.ts中到达。虽然它将处理来自app.routes.ts的入站路由请求,但它将进一步将它们路由到 Workout Builder 功能中包含的其他组件。

这些组件将使用我们刚刚添加到WorkoutBuilder模板中的<router-outlet>标签显示它们的视图。鉴于Workout BuilderComponent的模板将是简单的,我们还将内联模板替换为templateUrl

注意

通常,对于组件的视图,我们建议使用指向单独的 HTML 模板文件的templateUrl。当您预期视图将涉及多于几行 HTML 时,这一点尤为重要。在这种情况下,更容易使用单独的 HTML 文件来处理视图。单独的 HTML 文件允许您使用具有颜色编码和标记完成等功能的 HTML 编辑器。相比之下,内联模板只是 TypeScript 文件中的字符串,编辑器不会给您带来这些好处。

我们还将添加一个<sub-nav>元素,用于创建Workout Builder功能内部的次级顶级菜单。我们将在本章稍后讨论这一点。

更新 Workout Builder 模块

现在让我们更新WorkoutBuilderModule。这将涉及一些重大变化,因为我们将把这个模块转变为一个功能模块。因此,这个模块将导入我们用于构建锻炼的所有组件。我们不会在这里涵盖所有这些导入,但一定要从 GitHub 存储库的checkpoint 4.2中的trainer/src/components/workout-builder文件夹中的workout-builder.ts中添加它们。

值得一提的是以下导入:

import { workoutBuilderRouting } from './workout-builder.routes'; 

它导入了我们刚刚设置的子路由。

现在让我们将@NgModule装饰器更新为以下内容:

@NgModule({ 
    imports: [ 
        CommonModule, 
 **workoutBuilderRouting** 
    ], 
    declarations: [ 
       WorkoutBuilderComponent,
       **WorkoutComponent,**
 **WorkoutsComponent,**
 **ExerciseComponent,**
 **ExercisesComponent,**
 **SubNavComponent,**
 **LeftNavExercisesComponent,**
 **LeftNavMainComponent**
       ],
    exports: [WorkoutBuilderComponent], 
}) 

更新 app.routes

最后一步:返回到app.routes.ts,并从该文件中删除WorkoutBuilderComponent及其路由的导入。

把所有东西放在一起

从上一章,我们已经知道如何为我们的应用程序设置根路由。但现在,我们所拥有的不是根路由,而是包含子路由的区域或功能路由。我们已经能够实现我们之前讨论的关注点分离,这样与Workout Builder相关的所有路由现在都单独包含在它们自己的路由配置中。这意味着我们可以在WorkoutBuilderRoutes组件中管理Workout Builder的所有路由,而不会影响应用程序的其他部分。

我们可以看到路由器如何将app.routes.ts中的路由与workout-builder.routes.ts中的默认路由组合在一起,如果我们现在从起始页面导航到 Workout Builder。

把所有东西放在一起

如果我们在浏览器中查看 URL,它是/builder/workouts。您会记得起始页面上的路由链接是['/builder']。那么路由是如何将我们带到这个位置的呢?

它是这样做的:当链接被点击时,Angular 路由器首先查找app.routes.ts中的builder路径,因为该文件包含了我们应用程序中根路由的配置。路由器没有在该文件的路由中找到该路径,因为我们已经从该文件的路由中删除了它。

然而,WorkoutBuilderComponent已经被导入到我们的AppModule中,该组件反过来从workout-builder-routes.ts导入了workoutBuilderRouting。后者文件包含了我们刚刚配置的子路由。路由器发现builder是该文件中的父路由,因此它使用了该路由。它还发现了默认设置,即在builder路径以空字符串结尾时重定向到子路径workouts,在这种情况下就是这样。

把所有东西放在一起

路由解析的过程如下所示:

如果您看屏幕,您会发现它显示的是Workouts的视图(而不是之前的Workout Builder)。这意味着路由器已成功地将请求路由到了WorkoutsComponent,这是我们在workout-builder.routes.ts中设置的子路由配置的默认路由的组件。

关于子路由的最后一个想法。当您查看我们的子路由组件workout-builder.component.ts时,您会发现它没有引用其parent组件app.component.ts(正如我们之前提到的,<selector>标签已被移除,因此Workout Builder组件没有被嵌入到根组件中)。这意味着我们已成功地封装了Workout Builder(以及它导入的所有组件),这将使我们能够将其全部移动到应用程序的其他位置,甚至是到一个新的应用程序中。

现在是时候将我们的训练构建器的路由转换为使用延迟加载,并构建其导航菜单了。如果您想查看下一节的完成代码,可以从检查点 4.3的伴随代码库中下载。再次强调,如果您正在与我们一起构建应用程序,请确保更新app.css文件,这里我们不讨论。

注意

该代码也可供所有人在 GitHub 上下载:github.com/chandermani/angular2byexample。检查点在 GitHub 中作为分支实现。要下载的分支如下:GitHub 分支:checkpoint4.3(文件夹 - trainer)。如果您不使用 Git,请从以下 GitHub 位置下载检查点 4.3 的快照(ZIP 文件):github.com/chandermani/angular2byexample/archive/checkpoint4.3.zip。首次设置快照时,请参考trainer文件夹中的README.md文件。

路由的延迟加载

当我们推出我们的应用程序时,我们预计我们的用户每天都会访问训练运行器(我们知道这对你来说也是如此!)。但我们预计他们只会偶尔使用训练构建器来构建他们的练习和训练计划。因此,如果我们的用户只是在训练运行器中做练习时,我们最好能避免加载训练构建器的开销。相反,我们希望只在用户想要添加或更新他们的练习和训练计划时按需加载训练构建器。这种方法称为延迟加载

注意

在幕后,Angular 使用 SystemJS 来实现这种延迟加载。它允许我们在加载模块时采用异步方法。这意味着我们可以只加载启动应用程序所需的内容,然后根据需要加载其他模块。

在我们的个人教练应用程序中,我们希望改变应用程序,使其只在需要时加载训练构建器。Angular 路由器允许我们使用延迟加载来实现这一点。

但在开始实现懒加载之前,让我们先看看我们当前的应用程序以及它如何加载我们的模块。在Sources选项卡中打开开发者工具,启动应用程序;当应用程序的起始页面出现在您的浏览器中时,您会看到应用程序中的所有文件都已加载,包括Workout RunnerWorkout Builder文件:

路由的懒加载

因此,即使我们可能只想使用Workout Runner,我们也必须加载Workout Builder。从某种意义上讲,如果你把我们的应用程序看作是一个单页应用程序SPA),这是有道理的。为了避免与服务器的往返,SPA 通常会在用户首次启动应用程序时加载所有将需要使用应用程序的资源。但在我们的情况下,重要的是当应用程序首次加载时,我们并不需要 Workout Builder。相反,我们希望只在用户决定要添加或更改锻炼或练习时才加载这些资源。

因此,让我们开始实现这一点。

首先,修改app.routes.ts,添加以下单独的路由配置,用于我们的workoutBuilderRoutes

const workoutBuilderRoutes: Routes = [ 
  { 
    path: 'builder', 
    loadChildren: 'dist/components/workout-builder/workout-builder.module#Workout-BuilderModule' 
  } 
]; 

请注意,loadChildren属性是:

component:  file path + # + component name 

此配置提供了加载和实例化组件所需的信息。特别注意文件路径;它指向我们的代码在dist文件夹中的位置,当它部署为 JavaScript 文件时,而不是该文件的 TypeScript 版本所在的文件夹。

接下来,更新Routes配置以添加以下内容:

export const routes: Routes = [ 
  { path: 'start', component: StartComponent }, 
  { path: 'workout', component: WorkoutContainerCompnent }, 
  { path: 'finish', component: FinishComponent }, 
  { path: 'history', component: WorkoutHistoryComponent }, 
 **...workoutBuilderRoutes,** 
  { path: '**', redirectTo: '/start' } 
]; 

您会注意到我们已经添加了对WorkoutBuilderRoutes的引用,我们刚刚配置并用三个点添加了前缀。通过这三个点,我们使用 ES2015 扩展运算符来插入一个路由数组 - 具体来说是WorkoutBuilder功能的路由。这些路由将包含在WorkoutBuilderRoutes中,并将与我们应用程序根目录中的路由分开维护。最后,从该文件中删除对WorkoutBuilderComponent的导入。

接下来回到workout-builder.routes.ts,将path属性更改为空字符串:

export const workoutBuilderRoutes: Routes = [ 
    { 
 **path: '',** 
. . . 
    } 
]; 

我们进行此更改是因为我们现在正在将路径('builder')设置为app.routes.ts中添加的WorkoutBuilderRoutes的新配置。

最后返回app-module.ts,并在该文件的@NgModule配置中删除WorkoutBuilderModule的导入。这意味着我们不会在应用程序启动时加载锻炼构建器功能,而是只有在用户访问锻炼构建器路由时才加载它。

让我们返回并再次运行应用程序,保持 Chrome 开发者工具中的选项卡打开。当应用程序开始并加载起始页面时,只有与锻炼运行器相关的文件出现,而与锻炼构建器相关的文件不会出现,如下所示:

路由的延迟加载

然后,如果我们清除网络选项卡并单击创建锻炼链接,我们将只看到与锻炼构建器加载相关的文件:

路由的延迟加载

正如我们所看到的,现在加载的所有文件都与锻炼构建器相关。这意味着我们已经实现了新功能的封装,并且通过异步路由,我们能够使用延迟加载仅在需要时加载所有其组件。

子级和异步路由使我们能够轻松实现允许我们“既能拥有蛋糕,又能吃掉蛋糕”的应用程序。一方面,我们可以构建具有强大客户端导航的单页面应用程序,另一方面,我们还可以将功能封装在单独的子路由组件中,并仅在需要时加载它们。

Angular 路由器的这种强大和灵活性使我们能够通过密切映射应用程序的行为和响应性来满足用户的期望。在这种情况下,我们利用了这些能力来实现我们的目标:立即加载锻炼运行器,以便我们的用户可以立即开始锻炼,但避免加载锻炼构建器的开销,而只在用户想要构建锻炼时提供它。

现在我们已经在锻炼构建器中放置了路由配置,我们将把注意力转向创建子级和左侧导航;这将使我们能够使用这个路由。接下来的部分将涵盖实现这种导航。

集成子级和侧边导航

将子级和侧边导航集成到应用程序中的基本思想是提供基于活动视图而变化的上下文感知子视图。例如,当我们在列表页面而不是编辑项目时,我们可能希望在导航中显示不同的元素。电子商务网站是一个很好的例子。想象一下亚马逊的搜索结果页面和产品详细页面。随着上下文从产品列表变为特定产品,加载的导航元素也会改变。

子级导航

我们将首先向Workout Builder添加子级导航。我们已经将SubNavComponent导入到Workout Builder中。但目前它只显示占位内容:

子级导航

现在我们将用三个路由链接替换该内容:主页新锻炼新练习

打开sub-nav.component.html文件,并将其中的 HTML 更改为以下内容:

<div> 
    <a [routerLink]="['/builder/workouts']" class="btn btn-primary"> 
        <span class="glyphicon glyphicon-home"></span> Home 
    </a> 
    <a [routerLink]="['/builder/workout/new']" class="btn btn-primary"> 
        <span class="glyphicon glyphicon-plus"></span> New Workout 
    </a> 
    <a [routerLink]="['/builder/exercise/new']" class="btn btn-primary"> 
        <span class="glyphicon glyphicon-plus"></span> New Exercise 
    </a> 
</div> 

现在重新运行应用程序,您将看到三个导航链接。如果我们点击新练习链接按钮,我们将被路由到ExerciseComponent,并且其视图将出现在Workout Builder视图中的路由出口中:

子级导航

新锻炼链接按钮将以类似的方式工作;当点击时,它将带用户到WorkoutComponent并在路由出口显示其视图。点击主页链接按钮将把用户返回到WorkoutsComponent和视图。

侧边导航

Workout Builder内的侧边导航将根据我们导航到的子组件而变化。例如,当我们首次导航到Workout Builder时,我们会进入锻炼屏幕,因为WorkoutsComponent的路由是Workout Builder的默认路由。该组件将需要侧边导航;它将允许我们选择查看锻炼列表或练习列表。

Angular 的基于组件的特性为我们提供了一种实现这些上下文敏感菜单的简单方法。我们可以为每个菜单定义新的组件,然后将它们导入到需要它们的组件中。在这种情况下,我们有三个组件将需要侧边菜单:锻炼练习锻炼。前两个组件实际上可以使用相同的菜单,所以我们实际上只需要两个侧边菜单组件:LeftNavMainComponent,它将类似于前面的菜单,并将被ExercisesWorkouts组件使用,以及LeftNavExercisesComponent,它将包含现有练习列表,并将被Workouts组件使用。

我们已经为两个菜单组件准备了文件,包括模板文件,并将它们导入到WorkoutBuilderModule中。现在我们将把它们整合到需要它们的组件中。

首先,修改workouts.component.html模板以添加菜单的选择器:

div class="container-fluid"> 
    <div id="content-container" class="row"> 
 **<left-nav-main></left-nav-main>** 
        <h1 class="text-center">Workouts</h1> 
    </div> 
</div> 

然后,将left-nav-main.component.html中的占位文本替换为导航链接到WorkoutsComponentExercisesComponent

<div class="col-sm-2 left-nav-bar"> 
    <div class="list-group"> 
        <a [routerLink]="['/builder/workouts']" class="list-group-item list-group-item-info">Workouts</a> 
        <a [routerLink]="['/builder/exercises']" class="list-group-item list-group-item-info">Exercises</a> 
    </div> 
</div> 

运行应用程序,您应该会看到以下内容:

侧边导航

按照完全相同的步骤完成Exercises组件的侧边菜单。

注意

我们不会在这里展示这两个菜单的代码,但您可以在 GitHub 存储库的checkpoint 4.3中的trainer/src/components文件夹下的workout-builder/exercises文件夹中找到它们。

对于锻炼屏幕的菜单,步骤是相同的,只是您应该将left-nav-exercises.component.html更改为以下内容:

<div class="col-sm-2 left-nav-bar"> 
    <h3>Exercises</h3> 
</div> 

我们将使用这个模板作为构建出现在屏幕左侧的练习列表的起点,并可以选择包含在锻炼中的练习。

实现锻炼和练习列表

甚至在我们开始实现锻炼练习列表页面之前,我们需要一个练习和锻炼数据的数据存储。当前的计划是使用内存数据存储并使用 Angular 服务来公开它。在第五章中,支持服务器数据持久性,我们将把这些数据移到服务器存储以实现长期持久性。目前,内存存储就足够了。让我们添加存储实现。

WorkoutService 作为锻炼和练习存储库

这里的计划是创建一个负责在两个应用程序中公开练习和锻炼数据的WorkoutService实例。服务的主要职责包括:

  • 与 Exercise 相关的 CRUD 操作:获取所有练习,根据名称获取特定练习,创建练习,更新练习和删除练习

  • 与 Workout 相关的 CRUD 操作:这些类似于与 Exercise 相关的操作,但是针对 Workout 实体

注意

该代码可在 GitHub 上下载,网址为github.com/chandermani/angular2byexample。要下载的分支如下:GitHub 分支:checkpoint4.4(文件夹 - trainer)。如果您不使用 Git,请从以下 GitHub 位置下载 Checkpoint 4.4 的快照(ZIP 文件):github.com/chandermani/angular2byexample/archive/checkpoint4.4.zip。首次设置快照时,请参考trainer文件夹中的README.md文件。再次,如果您正在与我们一起构建应用程序,请确保更新app.css文件,这里我们不讨论。因为本节中的一些文件相当长,所以我们不会在这里显示代码,有时我们会建议您将文件简单复制到您的解决方案中。

trainer/src/services文件夹中找到workout-service.ts。该文件中的代码应该如下所示,除了两个方法setupInitialExercisessetupInitialWorkouts的实现,由于它们的长度,我们已经省略了:

import {Injectable} from '@angular/core'; 
import {ExercisePlan} from './model'; 
import {WorkoutPlan} from './model'; 
import {Exercise} from "./model"; 

@Injectable() 
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进行装饰,以支持在整个应用程序中注入它。在类定义中,我们首先创建了两个数组:一个用于Workouts,一个用于Exercises。这些数组分别是WorkoutPlanExercise类型的,因此我们需要从model.ts中导入WorkoutPlanExericse以获取它们的类型定义。

构造函数调用了两个方法来设置WorkoutsServices List。目前,我们只是使用一个内存存储来填充这些列表的数据。

这两个方法,getExercisesgetWorkouts,顾名思义,分别返回练习和锻炼的列表。由于我们计划使用内存存储来存储锻炼和练习数据,WorkoutsExercises数组存储了这些数据。随着我们的进行,我们将向服务添加更多的函数。

还有一件事情我们需要做,就是使服务可以在整个应用程序中被注入。

打开同一文件夹中的services.module.ts,然后导入WorkoutService并将其添加为提供者:

---- other imports ---- 
**import { WorkoutService } from "./workout-service";** 

@NgModule({ 
    imports: [], 
    declarations: [], 
    providers: [ 
        LocalStorage, 
        WorkoutHistoryTracker, 
 **WorkoutService],** 
}) 

这将WorkoutService注册为 Angular 的依赖注入框架的提供者。

是时候添加锻炼和练习列表的组件了!

锻炼和练习列表组件

首先,打开trainer/src/components/workout-builder/workouts文件夹中的workouts.component.ts文件,并按照以下方式更新导入:

import { Component, OnInit} from '@angular/core'; 
import { Router } from '@angular/router'; 

import { WorkoutPlan } from "../../../services/model"; 
import { WorkoutService } from "../../../services/workout-service"; 

这段新代码从 Angular 核心中导入了OnInit,以及RouterWorkoutServiceWorkoutPlan类型。

接下来,用以下代码替换类定义:

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] ); 
    } 
} 

这段代码添加了一个构造函数,我们在其中注入了WorkoutServiceRouter。然后ngOnInit方法调用WorkoutServicegetWorkouts方法,并用从该方法调用返回的WorkoutPlans列表填充了一个workoutList数组。我们将使用这个workoutList数组来填充在Workouts组件的视图中显示的锻炼计划列表。

您会注意到我们将调用WorkoutService的代码放入了一个ngOnInit方法中。我们希望避免将这段代码放入构造函数中。最终,我们将用外部数据存储的调用替换这个服务使用的内存存储,我们不希望我们组件的实例化受到这个调用的影响。将这些方法调用添加到构造函数中也会使组件的测试变得复杂。

为了避免这种意外的副作用,我们将代码放在ngOnInit方法中。这个方法实现了 Angular 的生命周期钩子之一,OnInit,Angular 在创建服务的实例后调用这个方法。这样我们就依赖于 Angular 以一种可预测的方式调用这个方法,不会影响组件的实例化。

接下来,我们将对Exercises组件进行几乎相同的更改。与Workouts组件一样,这段代码将锻炼服务注入到我们的组件中。这次,我们使用锻炼服务来检索练习。

注意

因为它与我们刚刚为Workouts组件展示的内容非常相似,所以我们不会在这里展示代码。只需从workout-builder/exercises文件夹的checkpoint 4.4中添加它。

锻炼和锻炼列表视图

现在我们需要实现到目前为空的列表视图!

注意

在本节中,我们将使用checkpoint 4.4中找到的代码更新checkpoint 4.3中的代码。因此,如果您正在与我们一起编码,只需按照本节中列出的步骤进行。如果您想查看完成的代码,只需将checkpoint 4.4中的文件复制到您的解决方案中。

锻炼列表视图

要使视图工作,打开workouts.component.html并添加以下标记:

<div class="container-fluid">
  <div id="content-container" class="row">
    <left-nav-main></left-nav-main>
    <h1 class="text-center">Workouts</h1>
    **<div class="workouts-container">
      <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" >
           <span class="glyphicon glyphicon-time"></span> - 
           {{workout.totalWorkoutDuration()|secondsToTime}}</span>
           <span class="length pull-right" >
           <span class="glyphicon glyphicon-th-list">
           </span> - {{workout.exercises.length}}</span>
                </div>
            </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 的末尾:

锻炼列表视图

锻炼列表视图

对于Exercises列表视图,我们将采用与Workouts列表视图相同的方法。只是在这种情况下,我们实际上将实现两个视图:一个用于Exercises组件(当用户导航到该组件时显示在主内容区域),另一个用于LeftNavExercisesComponent练习上下文菜单(当用户导航到Workouts组件创建/编辑锻炼时显示)。

对于Exercises组件,我们将采用几乎与在Workouts组件中显示锻炼列表时相同的方法。所以我们不会在这里展示那些代码。只需添加来自checkpoint 4.4exercise.conponent.tsexercise.component.html文件。

当你完成复制文件后,点击左侧导航中的练习链接,加载你已经在WorkoutService中配置好的 12 个练习。

Workouts列表一样,这设置了导航到练习详情页面。在练习列表中双击一个项目会带我们到练习详情页面。所选练习的名称作为路由/URL 的一部分传递到练习详情页面。

在最终的列表视图中,我们将添加一个练习列表,它将显示在Workout Builder屏幕的左侧上下文菜单中。当我们创建或编辑一个锻炼时,这个视图会在左侧导航中加载。使用 Angular 的基于组件的方法,我们将更新leftNavExercisesComponent及其相关视图,以提供这个功能。同样,我们不会在这里展示那些代码。只需添加来自checkpoint 4.4left-nav-exercises.component.tsleft-nav-exercises.component.html文件,它们位于trainer/src/components/navigation文件夹中。

一旦你完成了复制这些文件,点击Workout Builder子导航菜单中的新锻炼按钮,你将会看到一个练习列表,在左侧导航菜单中显示了我们已经在WorkoutService中配置好的练习。

是时候添加加载、保存和更新练习/锻炼数据的功能了!

构建锻炼

个人教练的核心功能围绕着锻炼和练习的建立。一切都是为了支持这两个功能。在这一部分,我们将专注于使用 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跟踪正在构建的锻炼的状态。它:

  • 跟踪当前锻炼

  • 创建新的锻炼

  • 加载现有的锻炼

  • 保存锻炼

checkpoint 4.5中的trainer/src/components文件夹下的workout-builder/builder-services文件夹中复制workout-builder-service.ts

注意

该代码也可供所有人在 GitHub 上下载github.com/chandermani/angular2byexample。检查点在 GitHub 中作为分支实现。要下载的分支如下:GitHub 分支:checkpoint4.5(文件夹-trainer)。如果您不使用 Git,请从以下 GitHub 位置下载 Checkpoint 4.5 的快照(ZIP 文件):github.com/chandermani/angular2byexample/archive/checkpoint4.5.zip。首次设置快照时,请参阅trainer文件夹中的README.md文件。再次,如果您正在与我们一起构建应用程序,请确保更新app.css文件,这里我们不讨论。

虽然我们通常会在整个应用程序中提供服务,但WorkoutBuilderService只会在Workout Builder功能中使用。因此,我们将在WorkoutBuilderModule的提供程序数组中注册它,而不是在AppModule中注册它(在文件顶部添加导入后):

providers: [ 
 **WorkoutBuilderService,** 
    . . . 
] 

在这里将其添加为提供者意味着只有在访问Workout Builder功能时才会加载它,并且无法在此模块之外访问。这意味着它可以独立于应用程序中的其他模块进行演变,并且可以在不影响应用程序其他部分的情况下进行修改。

让我们看一下服务的一些相关部分。

WorkoutBuilderService需要WorkoutPlanExerciseWorkoutService的类型定义,因此我们将其导入到组件中:

import { WorkoutPlan, Exercise } from '../../../services/model'; 
import { WorkoutService } from "../../../services/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)的一部分的锻炼列表。

WorkoutBuilderService正在调用WorkoutService上的一个新方法getWorkout,我们还没有添加。继续并从checkpoint 4.5trainer/src下的services文件夹中的workout-service.ts文件中复制getWorkout的实现。我们不会深入讨论新服务代码,因为实现非常简单。

让我们回到左侧导航栏并实现剩余的功能。

使用 ExerciseNav 添加锻炼

要将练习添加到我们正在构建的训练中,我们只需要将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来改变当前训练的练习列表。

由于服务是共享的,需要注意一些潜在的问题。由于服务可以通过系统注入,我们无法阻止任何组件依赖任何服务并以不一致的方式调用其函数,导致不良结果或错误。例如,WorkoutBuilderService需要在调用addExercise之前通过调用startBuilding进行初始化。如果一个组件在初始化之前调用addExercise会发生什么?

实现训练组件

Workout组件负责管理训练。这包括创建、编辑和查看训练。由于引入了WorkoutBuilderService,这个组件的整体复杂性将会降低。除了与模板视图集成、公开和交互的主要责任外,我们将把大部分其他工作委托给WorkoutBuilderService

Workout组件与两个路由/视图相关联,即/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。在这一点上,我们对第一个感兴趣:CanActivate

实现 CanActivate 路由守卫

CanActivate守卫允许导航继续进行或根据我们提供的实现中设置的条件停止它。在我们的情况下,我们要做的是使用CanActivate来检查传递给现有锻炼的任何 ID 的有效性。具体来说,我们将通过调用WorkoutService来检查该 ID,以检索锻炼计划并查看其是否存在。如果存在,我们将允许导航继续进行;如果不存在,我们将停止它。

checkpoint 4.5trainer/src/components下的workout-builder/workout文件夹中复制workout.guard.ts,您将看到以下代码:

import { Injectable } from '@angular/core'; 
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router'; 

import { WorkoutPlan } from "../../../services/model"; 
import { WorkoutService } from "../../../services/workout-service"; 

@Injectable() 
export class WorkoutGuard implements CanActivate { 
    publicworkout: WorkoutPlan; 

    constructor( 
        public workoutService: WorkoutService, 
        public router: Router) {} 

    canActivate( 
        route: ActivatedRouteSnapshot, 
        state: RouterStateSnapshot 
    ) { 
        this.workout = this.workoutService.getWorkout(route.params['id']); 
        if(this.workout){ return true; } 
        this.router.navigate(['/builder/workouts']); 
        return false; 
    } 
}  

如您所见,守卫是一个可注入的类,实现了CanActivate接口。我们使用CanActivate方法实现接口。CanActivate 方法接受两个参数;ActivatedRouteSnapshot 和 RouterStateSnapshot。在这种情况下,我们只对这两个参数中的第一个感兴趣。该参数包含一个 params 数组,我们从中提取路由的 id 参数。

CanActivate方法可以返回简单的boolean值或Observable<boolean>。如果我们需要在方法中进行异步调用,后者会很有用。如果我们返回Observable,则路由将等待异步调用解析后再继续导航。然而,在这种情况下,我们并没有进行这样的异步调用,因为我们使用的是本地内存数据存储。所以我们只是返回一个简单的 true/false boolean

注意

在下一章中,当我们开始使用 HTTP 模块向外部数据存储进行异步调用时,我们将重构此代码以返回Observable<boolean>

这段代码将WorkoutService注入到了守卫中。然后,CanActivate方法使用路由中提供的参数调用WorkoutServiceGetWorkout方法。如果锻炼存在,则canActivate返回 true 并进行导航;如果不存在,则重新将用户重定向到锻炼页面并返回 false。

实现WorkoutGuard的最后一步是将其添加到WorkoutComponent的路由配置中。因此,按照以下方式更新workout-builder.routes.ts

export const workoutBuilderRoutes: Routes = [ 
    { 
        path: '', 
        component: WorkoutBuilderComponent, 
        children: [ 
             {path:'', pathMatch: 'full', redirectTo: 'workouts'}, 
             {path:'workouts', component: WorkoutsComponent }, 
             {path:'workout/new',  component: WorkoutComponent }, 
 **{path:'workout/:id', component: WorkoutComponent, 
              canActivate: [WorkoutGuard] },** 
             {path:'exercises', component: ExercisesComponent}, 
             {path:'exercise/new', component: ExerciseComponent }, 
             {path:'exercise/:id', component: ExerciseComponent } 
        ] 
    } 
]; 

使用这个配置,我们将WorkoutGuard分配给WorkoutComponent路由的canActivate属性。这意味着在路由导航到WorkoutComponent之前将调用WorkoutGuard

继续实现 Workout 组件...

现在我们已经建立了将我们带到Workout组件的路由,让我们转而完成它的实现。因此,从checkpoint 4.5中的trainer/src/components文件夹下的workout-builder/workout文件夹中复制workout.component.ts文件。(还要复制workout-builder.module.ts文件夹中的workout-builder文件夹。当我们开始使用 Angular 表单时,稍后我们将讨论此文件中的更改。)

打开workout.component.ts,你会看到我们添加了一个构造函数,用于注入ActivatedRouteWorkoutBuilderService

    constructor( 
    public route: ActivatedRoute, 
    public workoutBuilderService:WorkoutBuilderService){ } 

此外,我们添加了以下ngOnInit方法:

ngOnInit() { 
    this.sub = this.route.params.subscribe(params => { 
        let workoutName = params['id']; 
        if (!workoutName) { 
            workoutName = ""; 
        } 
        this.workout = this.workoutBuilderService.startBuilding(
        workoutName); 
    }); 
} 

该方法订阅路由参数并提取锻炼的id参数。如果没有找到 ID,则我们将其视为新的锻炼,因为workout/new是唯一配置在WorkoutBuilderRoutes中允许在没有 ID 的情况下到达此屏幕的路径。在这种情况下,我们在调用WorkoutBuilderServiceStartBuilding方法时提供一个空字符串作为参数,这将导致它返回一个新的锻炼。

注意

我们订阅路由参数,因为它们是Observables,可以在组件的生命周期内发生变化。这使我们能够重用相同的组件实例,即使该组件的OnInit生命周期事件只被调用一次。我们将在下一章节详细介绍Observables

除了这段代码,我们还为Workout Component添加了一系列方法,用于添加、删除和移动训练。这些方法都调用了WorkoutBuilderService上对应的方法,我们不会在这里详细讨论它们。我们还添加了一个durations数组,用于填充持续时间下拉列表。

目前,这对于组件类的实现就足够了。让我们更新相关的Workout模板。

实现训练模板

现在从checkpoint 4.5trainer/src/components下的workout-builder/workout文件夹中复制workout.component.html文件。运行应用程序,导航到/builder/workouts,双击7 Minute Workout瓷砖。这应该加载7 Minute Workout的详细信息,视图类似于构建训练部分开头显示的视图。

注意

如果出现任何问题,您可以参考GitHub 存储库:分支:checkpoint4.5(文件夹 - trainer)中的checkpoint4.5代码。

我们将花费大量时间在这个视图上,所以让我们在这里了解一些具体情况。

练习列表 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 表单,我们必须首先添加一些额外的配置。首先,打开checkpoint 4.5trainer文件夹中的systemjs.config.js文件,并将表单添加到ngPackageNames数组中:

  var ngPackageNames = [ 
    'common', 
    'compiler', 
    'core', 
 **'forms',** 
    'http', 
    'platform-browser', 
    'platform-browser-dynamic', 
    'router', 
    'testing' 
  ]; 

有了这个,SystemJS 将下载这个模块供我们的应用程序使用。

接下来,打开checkpoint 4.5trainer/src/componentsworkout-builder文件夹中的workout-buider.module.ts的副本。您将看到它添加了以下突出显示的代码:

@NgModule({ 
    imports: [ 
        CommonModule, 
 **FormsModule,** 
        SharedModule, 
        workoutBuilderRouting 
    ], 

这表明我们将使用表单模块。一旦我们做出这个改变,我们将不必在Workout组件中进行与表单相关的进一步导入。

这引入了我们实现表单所需的所有指令,包括:

  • NgForm

  • ngModel

让我们开始使用这些来构建我们的表单。

使用 NgForm

在我们的模板中,我们添加了以下form标签:

<form #f="ngForm" class="row" name="formWorkout" (ngSubmit)="save(f.form)" novalidate>. . . 
</form> 

让我们看看我们这里有什么。一个有趣的事情是,我们仍然使用标准的<form>标签,而不是特殊的 Angular 标签。我们还使用#来定义一个本地变量#f,我们已经分配了ngForm。创建这个本地变量使我们能够在表单内的其他地方使用它进行与表单相关的活动。例如,您可以看到我们在开放的form标签的末尾使用它作为参数,f.form,它被传递给绑定到(ngSubmit)onSubmit事件。

最后绑定到(ngSubmit)的内容应该告诉我们这里发生了一些不同的事情。即使我们没有明确添加NgForm指令,我们的<form>现在有了额外的事件,比如ngSubmit,我们可以将动作绑定到这些事件上。这是怎么发生的呢?嗯,这并不是因为我们将ngForm分配给了一个本地变量。相反,这是自动发生的,因为我们在workout-builder.module.ts中导入了表单模块。

有了这个导入,Angular 扫描我们的模板,找到了一个<form>标签,并将该<form>标签包装在NgForm指令中。Angular 文档表明,组件中的<form>元素将升级为使用 Angular 表单系统。这很重要,因为这意味着NgForm指令的各种功能现在可以与表单一起使用。其中包括ngSubmit事件,该事件在用户触发表单提交时发出信号,并提供在提交之前验证整个表单的能力。

ngModel

模板驱动表单的基本构建块之一是ngModel,你会发现它在我们的表单中被广泛使用。ngModel的主要作用之一是支持用户输入和底层模型之间的双向绑定。有了这样的设置,模型中的更改会反映在视图中,视图的更新也会反映在模型上。到目前为止,我们所涵盖的大多数其他指令只支持从模型到视图的单向绑定。这也是因为ngModel仅应用于允许用户输入的元素。

正如你所知,我们已经有一个模型,我们正在用于Workout页面-WorkoutPlan。这是model.ts中的WorkoutPlan模型:

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

注意在description后面使用了?。这意味着它是我们模型中的一个可选属性,不需要创建WorkoutPlan。在我们的表单中,这意味着我们不需要输入描述,一切都可以正常工作。

WorkoutPlan模型中,我们还引用了由另一种类型的模型实例组成的数组:ExercisePlanExercisePlan又由一个数字(duration)和另一个模型(Exercise)组成,看起来像这样:

@Injectable() 
export class ExercisePlan { 
  constructor(public exercise: Exercise, public duration: any) { 
  } 
} 

请注意,我们已经用@Injectable装饰了两个模型类。这是为了让 TypeScript 为整个对象层次结构生成必要的元数据,即WorkoutPlan中的嵌套类ExercisePlanExercisePlan中的Exercise。这意味着我们可以创建复杂的模型层次结构,所有这些模型都可以在我们的表单中使用NgModel进行数据绑定。

因此,在整个表单中,每当我们需要更新WorkoutPlanExercisePlan中的一个值时,我们可以使用NgModel来实现(在以下示例中,WorkoutPlan模型将由一个名为workout的局部变量表示)。

使用 ngModel 与输入和文本区域。

打开workout-component.html并查找ngModel。这里,它只应用于允许用户输入数据的 HTML 元素。这些包括 input、textarea 和 select。练习名称输入设置如下:

<input type="text" name="workoutName" class="form-control" id="workout-name" placeholder="Enter workout name. Must be unique." [(ngModel)]="workout.name"> 

前面的[(ngModel)]指令建立了输入控件和workout.name模型属性之间的双向绑定。方括号和括号应该都很熟悉。以前,我们将它们分开使用:[]方括号用于属性绑定,()括号用于事件绑定。在后一种情况下,我们通常将事件绑定到与模板关联的组件中的一个方法的调用。您可以在表单中看到这种情况的一个例子,其中用户单击按钮以删除一个练习:

<div class="pull-right" (click)="removeExercise(exercisePlan)"><span class="glyphicon glyphicon-trash"></span></div> 

在这里,点击事件明确绑定到了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}} 

打开锻炼页面,在输入框中输入一些内容,看看插值是如何立即更新的。双向绑定的魔力!

使用 ngModel 与输入框和文本框

使用ngModel与选择

让我们看看选择是如何设置的:

<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。这是一个强大的功能,使我们能够创建具有嵌套模型的复杂表单,所有这些都可以使用ngModel进行数据绑定。

与输入框一样,选择也支持双向绑定。我们看到改变选择会更新模型,但是从模型到模板的绑定可能不太明显。为了验证模型到模板的绑定是否有效,请打开7 分钟锻炼应用程序并验证持续时间下拉框。每个下拉框的值都与模型值(30 秒)一致。

Angular 通过使用ngModel来保持模型和视图同步做得非常棒。改变模型,看到视图更新;改变视图,观察模型立即更新。

现在让我们给表单添加验证。

注意

该代码也可供所有人在 GitHub 上下载:github.com/chandermani/angular2byexample。检查点在 GitHub 上作为分支实现。要下载的分支如下:GitHub 分支:checkpoint4.6(文件夹 - trainer)。或者,如果您不使用 Git,请从以下 GitHub 位置下载 Checkpoint 4.6 的快照(ZIP 文件):github.com/chandermani/angular2byexample/archive/checkpoint4.5.zip。在首次设置快照时,请参考trainer文件夹中的README.md文件。再次强调,如果您正在与我们一起构建应用程序,请确保更新app.css文件,这里我们不讨论。

Angular 验证

俗话说,“不要相信用户输入”。Angular 支持验证,包括标准的 required、min、max 和 pattern,以及自定义验证器。

ngModel

ngModel是我们用来实现验证的基本组件。它为我们做了两件事:维护模型状态,并提供一种识别验证错误并显示验证消息的机制。

要开始,我们需要在所有需要验证的表单控件中将ngModel赋值给一个本地变量。在每种情况下,我们需要为这个本地变量使用一个唯一的名称。例如,对于锻炼名称,我们在该控件的input标签中添加#name="ngModel"。现在,锻炼名称的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分配给本地变量。还为所有必填字段添加required属性。

Angular 模型状态

每当我们使用NgForm时,表单中的每个元素,包括输入、文本区域和选择,都有与关联模型定义的一些状态。ngModel为我们跟踪这些状态。跟踪的状态有:

  • 原始的:只要用户不与输入交互,这个值就是true。对input字段进行任何更新,ng-pristine就会被设置为false

  • 脏的:这是ng-pristine的相反。当输入数据已经更新时,这个值就是true

  • 触摸的:如果控件曾经获得焦点,这个值就是true

  • 未触摸的:如果控件从未失去焦点,这个值就是true。这只是ng-touched的相反。

  • valid:如果在input元素上定义了验证且它们都没有失败,则为true

  • invalid:如果元素上定义的任何验证失败,则为true

pristine`dirtytouched\untouched`是有用的属性,可以帮助我们决定何时显示错误标签。

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> 

重新加载应用程序,然后单击Workout Builder中的New Workout链接。在屏幕上什么都不触摸之前,您将看到以下内容显示:

Angular CSS classes

Name输入框中添加一些内容并切换到其他地方。标签会变成这样:

Angular CSS classes

这里我们看到的是 Angular 随着用户与其交互而改变应用于该控件的 CSS 类。您还可以通过检查开发者控制台中的input元素来看到这些变化。

如果我们想要根据其状态向元素应用视觉提示,这些 CSS 类转换非常有用。例如,看一下这个片段:

input.ng-invalid {  border:2px solid red; } 

这会在任何具有无效数据的输入控件周围绘制红色边框。

当您向 Workout 页面添加更多验证时,您可以观察(在开发者控制台中)用户与input元素交互时这些类是如何添加和移除的。

现在我们已经了解了模型状态以及如何使用它们,让我们回到验证的讨论(在继续之前,删除您刚刚添加的变量名和标签)。

训练验证

需要对训练数据进行多种条件的验证。

在为我们的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'),但使用!name. valid也可以完美地工作。然而,使用更冗长的方法允许我们更具体地识别验证错误,这在我们开始向表单控件添加多个验证器时将是至关重要的。我们将在本章稍后看一下使用多个验证器。为了保持一致,我们将坚持使用更冗长的方法。

现在加载新的锻炼页面(/builder/workouts/new)。在名称输入框中输入一个值,然后删除它。错误标签将如下截图所示出现:

显示适当的验证消息

添加更多验证

Angular 提供了四个开箱即用的验证器:

  • required

  • minLength

  • maxLength

  • pattern

我们已经看到了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。这里没有涉及双向绑定。我们只对使用它进行自定义验证感兴趣。

打开新的 Workout 页面,添加一个练习,然后将其删除;我们应该看到错误:

练习的自定义验证消息

我们在这里所做的事情本来可以很容易地在不涉及任何模型验证基础设施的情况下完成。但是通过将我们的验证与该基础设施连接起来,我们确实获得了一些好处。现在,我们可以以一种一致和熟悉的方式确定特定模型的错误以及整个表单的错误。最重要的是,如果我们的验证在这里失败,整个表单将无效。

注意

实现自定义验证的方式通常不是您经常想要做的。相反,通常更合理的做法是在自定义指令中实现这种复杂逻辑。我们将在第六章中详细介绍创建自定义指令,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']); 
}  

我们使用其无效属性来检查表单的验证状态,然后如果表单状态有效,调用WorkoutBuilderService.save方法。

关于 NgForm 的更多信息

在 Angular 中,表单的角色与将数据提交到服务器的传统表单有所不同。如果我们回过头再看一下表单标签,我们会发现它缺少标准的 action 属性。表单指令上的novalidate属性告诉浏览器不要进行内置输入验证(这不是特定于 Angular 的,而是 HTML 5 属性)。

使用全页回传的标准表单行为在 Angular 这样的 SPA 框架中是没有意义的。在 Angular 中,所有服务器请求都是通过指令或服务发起的异步调用。

这里的表单扮演了不同的角色。当表单封装一组输入元素(例如输入、文本区域和选择)时,它提供了一个 API:

  • 确定表单的状态,例如基于其输入控件的脏或原始状态

  • 在表单或控件级别检查验证错误

注意

如果您仍希望使用标准表单行为,可以添加一个ngNoForm属性,但这肯定会导致整个页面刷新。当我们查看保存表单和实现验证时,我们将在本章稍后探讨NgForm API 的具体内容。

表单内的FormControl对象的状态由NgForm监视。如果其中任何一个无效,那么NgForm会将整个表单设置为无效。在这种情况下,我们已经能够使用NgForm确定一个或多个FormControl对象无效,因此整个表单的状态也是无效的。

在我们完成本章之前,让我们再看一个问题。

修复表单保存和验证消息

打开一个新的锻炼页面,直接单击保存按钮。由于表单无效,所以什么都没有保存,但是单个表单输入的验证根本不显示出来。现在很难知道是哪些元素导致了验证失败。这种行为背后的原因非常明显。如果我们看一下名称输入元素的错误消息绑定,它看起来是这样的:

*ngIf="name.control?.hasError('required') && name.touched"

请记住,在本章的早些时候,我们明确禁用了在用户触摸输入控件之前显示验证消息。同样的问题又回来找我们了,现在我们需要解决它。

我们没有办法明确地将控件的触摸状态更改为未触摸。相反,我们将采取一些小技巧来完成这项工作。我们将在Workout类定义的顶部引入一个名为submitted的新属性,并将其初始值设置为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)时,错误消息将被显示。现在,这个表达式修复现在必须应用于每个验证消息,其中出现了检查。

如果我们现在打开新的Workout页面并点击保存按钮,我们应该能够在输入控件上看到所有的验证消息:

修复表单保存和验证消息

模型驱动表单

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包含了我们构建模型驱动表单所需的内容。

接下来,从checkpoint 4.6trainer/src/components文件夹下的workout-builder/builder-services文件夹中复制exercise-builder-service.ts并将其导入到workout-builder.module.ts中:

import { ExerciseBuilderService } from "./builder-services/exercise-builder-service"; 

然后将其作为提供者添加到同一文件中的提供者数组中:

@NgModule({ 
   . . . 
    providers: [ 
 **ExerciseBuilderService,** 
        ExerciseGuard, 
        WorkoutBuilderService, 
        WorkoutGuard 
    ] 
}) 

注意

您会注意到我们还将ExerciseGuard添加为提供者。我们不会在这里涵盖它,但您也应该从exercise文件夹中复制它,并复制更新后的workout-builder.routes.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():any{ 
    this.sub = this.route.params.subscribe(params => { 
        let exerciseName = params['id']; 
        if (exerciseName === 'new') { 
            exerciseName = ""; 
        } 
        this.exercise = this.exerciseBuilderService.startBuilding(exerciseName); 
    }); 
    this.buildExerciseForm(); 
} 

ngOnInit触发时,它将调用一个用于构建我们的表单的方法(除了设置我们正在构建的练习)。因此,在组件生命周期的这个阶段,我们正在开始在代码中构建我们的表单的过程。

现在让我们通过添加以下代码来实现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,还可以添加包含其中的FormControlsFormControlGroupsFormControlArray。这意味着我们可以创建包含嵌套输入控件的复杂表单。在我们的情况下,正如我们已经提到的,我们需要考虑用户向练习添加多个视频的可能性。我们可以通过添加以下代码来实现这一点:

'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 [formGroup]="exerciseForm" (ngSubmit)="onSubmit(exerciseForm)" novalidate>  

在标签中,我们首先将我们刚刚在代码中构建的exerciseForm分配给formGroup。这建立了我们编码模型与视图中表单之间的连接。我们还将ngSubmit事件与我们代码中的onSubmit方法连接起来(稍后我们将讨论这个方法)。最后,我们使用novalidate关闭浏览器的表单验证。

向我们的表单输入添加表单控件

接下来,我们开始构建表单的输入。我们将从我们的练习名称输入开始:

<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-group">

在循环内部,我们做三件事。首先,我们添加一个按钮,允许用户删除视频:

<button type="button" (click)="deleteVideo(i)"  class="btn alert-danger pull-right"> 
    <span class="glyphicon glyphicon-trash text-danger"></span> 
</button>  

我们将组件类中的deleteVideo方法绑定到按钮的click事件,并将视频的索引传递给它。

接下来,我们为每个当前练习中的视频动态添加一个视频input字段:

<input type="text" class="form-control" [formControlName]="i" placeholder="Add a related youtube video identified."/> 

然后为每个视频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中的保存方法,并将用户路由回练习列表屏幕(请记住,任何新练习都不会显示在该列表中,因为我们尚未在应用程序中实现数据持久性)。

我们希望这一点很清楚;当我们试图构建更复杂的表单时,模型驱动的表单提供了许多优势。它们允许将编程逻辑从模板中移除。它们允许以编程方式向表单添加验证器。它们支持在运行时动态构建表单。

自定义验证器

现在在我们结束本章之前,让我们再看一件事。任何在构建 Web 表单(无论是在 Angular 还是其他 Web 技术中)上工作过的人都知道,我们经常被要求创建特定于我们正在构建的应用程序的验证。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 指令

总结

现在我们有了一个个人教练应用程序。将特定的7 分钟训练应用程序转换为通用的个人教练应用程序的过程帮助我们学习了许多新概念。

我们通过定义新的应用程序要求开始了本章。然后,我们将模型设计为一个共享服务。

我们为个人教练应用程序定义了一些新视图和相应的路由。我们还使用了子路由和异步路由,将训练构建器与应用程序的其余部分分开。

然后,我们将重点转向训练构建。本章的主要技术重点之一是 Angular 表单。训练构建器使用了许多表单输入元素,我们使用了模板驱动和模型驱动的形式实现了许多常见的表单场景。我们还深入探讨了 Angular 验证,并实现了自定义验证器。

接下来的章节将全面讨论客户端-服务器交互。我们创建的训练和练习需要被持久化。在下一章中,我们将构建一个持久化层,这将允许我们在服务器上保存训练和练习数据。

在结束本章之前,这里有一个友好的提醒。如果您还没有完成个人教练的练习构建例程,请继续完成。您可以随时将您的实现与伴随代码库中提供的内容进行比较。您还可以添加一些原始实现中没有的内容,比如为练习图片上传文件,以及一旦您更熟悉客户端-服务器交互,就可以进行远程检查,以确定 YouTube 视频是否真实存在。

第五章:支持服务器数据持久性

现在是时候和服务器交流了!创建锻炼,添加锻炼,并保存它,然后发现所有努力都白费,因为数据没有持久化,这样就没有乐趣了。我们需要解决这个问题。

很少有应用程序是自包含的。无论大小如何,任何消费者应用程序都有与其边界之外的元素交互的部分。对于基于 Web 的应用程序,交互主要是与服务器进行的。应用程序与服务器交互以进行身份验证、授权、存储/检索数据、验证数据以及执行其他此类操作。

本章探讨了 Angular 为客户端-服务器交互提供的构造。在这个过程中,我们为“个人健身教练”添加了一个持久层,用于将数据加载和保存到后端服务器。

本章涵盖的主题包括以下内容:

  • 配置后端以持久化锻炼数据:我们设置了一个 MongoLab 账户,并使用其数据 API 来访问和存储锻炼数据。

  • 理解 Angular HTTP 客户端库:HTTP 客户端库允许我们通过 HTTP 与服务器进行交互。您将学习如何使用 HTTP 客户端库的XMLHttpRequest类进行各种类型的GETPOSTPUTDELETE请求。

  • 实现锻炼数据的加载和保存:我们使用 HTTP 模块在 MongoLab 数据库中加载和存储锻炼数据。

  • 我们可以使用 HTTP 模块的 XMLHttpRequest 的两种方式:使用 Observables 或 promises。

  • 使用 RxJS 和 Observables:订阅和查询数据流。

  • 使用 promises:在本章中,我们将看到如何在 HTTP 调用和响应中使用 promises。

  • 处理跨域访问:由于我们与不同域中的 MongoLab 服务器进行交互,您将了解浏览器对跨域访问的限制。您还将了解 JSONP 和 CORS 如何帮助我们轻松实现跨域访问,以及关于 Angular JSONP 支持的内容。

让我们开始吧。

Angular 和服务器交互

任何客户端-服务器交互通常归结为向服务器发送 HTTP 请求并从服务器接收响应。对于重型 JavaScript 应用程序,我们依赖 AJAX 请求/响应机制与服务器进行通信。为了支持基于 AJAX 的通信,Angular 提供了 Angular HTTP 模块。在我们深入研究 HTTP 模块之前,我们需要设置存储数据并允许我们管理数据的服务器平台。

设置持久性存储

为了数据持久性,我们使用一个名为 MongoDB 的文档数据库(www.mongodb.org/),托管在 MongoLab 上(mongolab.com/),作为我们的数据存储。我们选择 MongoLab 的原因是它提供了一个直接与数据库交互的接口。这样可以省去我们设置服务器中间件来支持 MongoDB 交互的工作。

注意

直接将数据存储/数据库暴露给客户端从来都不是一个好主意。但在这种情况下,由于我们的主要目标是学习 Angular 和客户端-服务器交互,我们冒了这个风险并直接访问了托管在 MongoLab 中的 MongoDB 实例。还有一种新型应用程序是建立在noBackend解决方案之上的。在这样的设置中,前端开发人员构建应用程序而不知道确切的后端涉及。服务器交互仅限于向后端发出 API 调用。如果您对这些 noBackend 解决方案感兴趣,可以查看nobackend.org/

我们的第一个任务是在 MongoLab 上配置一个账户并创建一个数据库:

  1. 前往mongolab.com并按照网站上的说明注册一个 MongoLab 账户。

  2. 一旦账户被配置,登录并点击主页上的创建新按钮来创建一个新的 Mongo 数据库。

  3. 在数据库创建界面,您需要进行一些选择来配置数据库。请参考以下截图来选择免费的数据库层和其他选项:设置持久性存储

  4. 创建数据库并记下您创建的数据库名称。

  5. 一旦数据库被配置,打开数据库并从集合选项卡中向其中添加两个集合:

  • exercises:这个集合存储所有个人教练的练习

  • workouts:这个集合存储所有个人教练的锻炼

在 MongoDB 世界中,集合相当于数据库表。

注意

MongoDB 属于一类称为文档数据库的数据库。这里的中心概念是文档、属性和它们的链接。与传统数据库不同,模式并不是固定的。我们不会在本书中涵盖文档数据库是什么以及如何为基于文档的存储执行数据建模。个人健身教练有一个有限的存储需求,我们使用前面提到的两个文档集合来管理它。我们甚至可能并没有真正使用文档数据库。

一旦集合被添加,从用户选项卡将自己添加为数据库的用户。

下一步是确定 MongoLab 账户的 API 密钥。配置的 API 密钥必须附加到发送到 MongoLab 的每个请求中。要获取 API 密钥,请执行以下步骤:

  1. 点击右上角的用户名(而不是账户名)以打开用户配置文件。

  2. 在标题为API 密钥的部分,显示当前的 API 密钥;复制它。同时,点击 API 密钥下面的按钮以启用数据 API 访问。这是默认禁用的。

数据存储模式已经完成。现在我们需要种子化这些集合。

种子化数据库

个人健身教练应用程序已经有一个预定义的锻炼和一个包含 12 个练习的列表。我们需要用这些数据来种子化集合。

从伴随代码库的chapter5/checkpoint1/trainer/db中打开seed.js。它包含种子 JSON 脚本和有关如何将数据种子化到 MongoLab 数据库实例的详细说明。

数据库一旦被种子化,工作集合中将有一个锻炼,而练习集合中将有 12 个练习。在 MongoLab 网站上验证这一点;集合应该显示如下:

种子化数据库

现在一切都已设置好,让我们开始讨论 HTTP 模块,并为个人健身教练应用程序实现锻炼/练习持久化。

HTTP 模块的基础知识

HTTP 模块的核心是 HTTP 客户端。它使用XMLHttpRequest作为默认后端执行 HTTP 请求(本章后面我们将看到也可以使用 JSONP)。它支持诸如GETPOSTPUTDELETE等请求。在本章中,我们将使用 HTTP 客户端来进行所有这些类型的请求。正如我们将看到的,HTTP 客户端使得以最少的设置和复杂性轻松进行这些调用。如果之前有使用过 Angular 或构建过与后端数据存储通信的 JavaScript 应用程序的人,这些术语都不会让人感到意外。

然而,Angular 处理 HTTP 请求的方式发生了重大变化。现在调用请求会返回一个 HTTP 响应的 Observable。它通过使用RxJS库来实现,这是一个众所周知的异步 Observable 模式的开源实现。

注意

您可以在 GitHub 上找到 RxJS 项目,网址为github.com/Reactive-Extensions/RxJS。该网站显示该项目正在由微软与一群开源开发人员共同开发。我们在这里不会详细介绍异步 Observable 模式,并鼓励您访问该网站了解更多关于该模式以及 RxJS 如何实现它的信息。Angular 使用的 RxJS 版本是 beta 5。

简单来说,使用 Observables 允许开发人员将应用程序中流动的数据视为信息流,应用程序可以随时获取并使用。这些流随时间变化,这使得应用程序可以对这些变化做出反应。Observables 的这种特性为函数式响应式编程FRP)提供了基础,从而从命令式转变为响应式构建 Web 应用程序的范式。

RxJS库提供了操作符,允许您订阅和查询这些数据流。此外,您可以轻松地混合和组合它们,正如我们将在本章中看到的。Observables 的另一个优势是它很容易取消订阅,使得可以无缝处理内联错误。

虽然仍然可以使用 promises,但 Angular 的默认方法使用 Observables。本章中我们也会介绍 promises。

个人教练和服务器集成

如前一节所述,客户端-服务器交互完全是关于异步性。当我们修改我们的个人健身教练应用程序以从服务器加载数据时,这种模式变得不言自明。

在上一章中,锻炼和练习的初始集合是在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.mongolab.com/data-api

现在我们有能力开始实现练习/锻炼列表页面了。

注意

我们在本章开始时使用的代码是 GitHub 存储库中的checkpoint 4.6(文件夹:trainer)。它可以在 GitHub 上找到(github.com/chandermani/angular2byexample)。检查点在 GitHub 中作为分支实现。如果您不使用 Git,请从以下 GitHub 位置下载 Checkpoint 4.6 的快照(ZIP 文件):github.com/chandermani/angular2byexample/tree/checkpoint4.6。在第一次设置快照时,请参考trainer文件夹中的README.md文件。

从服务器加载练习和锻炼列表

为了从 MongoLab 数据库中获取练习和锻炼列表,我们必须重写我们的WorkoutService服务方法:getExercisesgetWorkouts。但在我们这样做之前,我们必须设置我们的服务以与 Angular 的 HTTP 模块一起工作。

将 HTTP 模块和 RxJS 添加到我们的项目中

Angular 的 HTTP 模块已经包含在您已经安装的 Angular bundles 中。要使用它,我们需要将其导入到app.module.ts中,如下所示:

import { HttpModule } from '@angular/http'; 
. . . 
@NgModule({ 
  imports: [ 
. . . 
    HttpModule, 
. . . 
})

我们还需要一个外部第三方库:JavaScript 的 Reactive ExtensionsRxJS)。RxJS 实现了 Observable 模式,并且被 Angular 与 HTTP 模块一起使用。它已经包含在我们项目中的 Angular bundles 中。

更新 workout-service 以使用 HTTP 模块和 RxJS

现在,从trainer/src/services中打开workout-service.ts。为了在WorkoutService中使用 HTTP 模块和 RxJS,我们需要在该文件中添加以下导入:

import { Http, Response } from '@angular/http'; 
import { Observable } from 'rxjs/Observable'; 
import 'rxjs/add/operator/map'; 
import 'rxjs/add/operator/catch';

我们从 HTTP 模块中导入 HTTP 和 Response,以及从 RxJS 导入Observable和另外两个 RxJS 操作符:mapcatch。当我们在本节中处理代码时,我们将看到这些操作符是如何使用的。

在类定义中,添加以下属性,其中包括一个锻炼属性和设置我们 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 密钥。

接下来,使用以下代码将 HTTP 模块注入到WorkoutServiceconstructor中:

constructor(public http: Http) {
}

然后将getExercises()方法更改为:

getExercises(){ 
    return this.http.get(this.collectionsUrl + '/exercises' + this.params)
        .map((res: Response) => <Exercise[]>res.json()) 
        .catch(WorkoutService.handleError); 
}

如果你习惯于使用 Promise 进行异步数据操作,你在这里看到的将会有所不同。我们没有一个 Promise,然后链接到它的then()调用,我们期望异步接收返回的数据,而是有一个map()方法。

根据 Angular 文档(angular.io/docs/ts/latest/guide/server-communication.html),这里发生的是http.get方法从 RxJS 库返回一个 HTTP 响应的 Observable(Observable<Response>)。

注意

当使用 HTTP 模块的get方法时,返回 Observable 是默认的响应。然而,Observable 可以转换为 Promise。并且,正如我们将在本章后面看到的,还存在返回 JSONP 的选项。

我们在代码中看到的map方法是我们之前导入的RxJS库中包含的 RxJS 操作符。这里需要它,因为从远程服务器检索到的内容不符合我们应用程序所需的形式。正如你所看到的,我们使用json()方法将响应体response转换为 JavaScript 对象。

注意

Angular 文档还明确指出,我们不应该将Observable<Response>响应对象返回给调用此方法的组件。相反,我们应该隐藏数据访问操作的细节,不让应用程序的其他部分知道。这样,我们可以在需要时更改这些操作,而不必修改应用程序中的其他代码。

在我们继续之前,这段代码还有一件事要提及。Observable 还有一个catch操作符,接受一个名为handleError的方法来处理失败的响应。handleError方法将失败的响应作为参数。目前,我们将遵循文档中为这个handleError方法制定的相同方法。我们将错误记录到控制台,并使用Observable.throw将 JSON 错误转换为用户友好的消息,然后返回:

static handleError (error: Response) { 
    console.error(error); 
    return Observable.throw(error.json().error || 'Server error'); 
}

明确一点,这不是生产代码,但它将给我们一个机会展示如何编写上游代码来处理作为数据访问的一部分生成的错误。

注意

重要的是要理解,在这个阶段,我们的 Observable 被称为。这意味着在订阅之前,没有数据流经它。如果你不小心添加订阅到你的 Observables 中,这可能会带来一些意外的情况,比如添加和更新。

修改getWorkouts()以使用 HTTP 模块

检索锻炼的代码变化几乎与练习的代码变化相同。

getWorkouts(){ 
    return this.http.get(this.collectionsUrl + '/workouts' + this.params) 
        .map((res:Response) => <WorkoutPlan[]>res.json()) 
        .catch(WorkoutService.handleError); 
}

现在getExercisesgetWorkouts方法已经更新,我们需要确保它们能够与上游调用方一起工作。

更新锻炼/练习列表页面

练习和锻炼列表页面(以及LeftNavExercises)调用model.ts中的getExercisesgetWorkouts方法。为了使它们能够与现在使用 HTTP 模块进行的远程调用一起工作,我们需要修改这些调用,订阅由 HTTP 模块返回的 Observable。因此,更新exercises.component.ts中的ngOnInit方法的代码如下:

ngOnInit() { 
    this.workoutService.getExercises() 
        .subscribe( 
            exerciseList=> { 
                this.exerciseList = exerciseList; 
            }, 
            (err: any) => console.error(err) 
        ); 
}

我们的方法现在订阅了getExercises方法返回的 Observable;在响应到达时,它将结果分配给exerciseList。如果有错误,它将分配给console.error调用,显示控制台中的错误。所有这些现在都是使用 HTTP 模块和 RxJS 异步处理的。

继续对workouts.component.tsleft-nav-exercises.component.ts中的ngOnInit方法进行类似的更改。

刷新锻炼/练习列表页面,锻炼和练习数据将从数据库服务器加载。

注意

如果在检索/显示数据时遇到困难,请查看 GitHub 存储库中检查点 5.1 的完整实现。请注意,在此检查点中,我们已禁用了导航链接到锻炼和练习屏幕,因为我们仍然需要为它们添加 Observable 实现。我们将在下一节中进行。在运行来自检查点 5.1 的代码之前,请记得替换数据库名称和 API 密钥。如果您不使用 Git,请从以下 GitHub 位置下载检查点 5.1 的快照(ZIP 文件):github.com/chandermani/angular2byexample/tree/checkpoint5.1。在首次设置快照时,请参考trainer文件夹中的README.md文件。

看起来不错,列表加载正常。嗯,几乎!锻炼列表页面有一个小故障。如果我们仔细观察任何列表项(实际上只有一个),我们可以很容易地发现它:

更新锻炼/练习列表页面

锻炼持续时间的计算不再起作用!原因是什么?我们需要回顾这些计算是如何实现的。WorkoutPlan服务(在model.ts中)定义了一个totalWorkoutDuration方法来进行这些计算。

区别在于绑定到视图的锻炼数组。在上一章中,我们使用WorkoutPlan服务创建了包含模型对象的数组。但是现在,由于我们正在从服务器检索数据,我们将一个简单的 JavaScript 对象数组绑定到视图,这是因为明显的原因没有计算逻辑。

我们可以通过将服务器响应映射到我们的模型类对象并将它们返回给任何上游调用者来解决这个问题。

将服务器数据映射到应用程序模型

如果模型和服务器存储定义匹配,那么将服务器数据映射到我们的模型和反之亦然可能是不必要的。如果我们查看Exercise模型类和我们在 MongoLab 中为练习添加的种子数据,我们会发现它们是匹配的,因此映射变得不必要。

如果以下情况成立,则将服务器响应映射到模型数据变得必要:

  • 我们的模型定义了任何方法

  • 存储的模型与其在代码中的表示不同

  • 相同的模型类用于表示来自不同来源的数据(这可能发生在混搭中,我们从不同的来源获取数据)

WorkoutPlan服务是模型表示和存储之间阻抗不匹配的一个典型例子。查看以下屏幕截图以了解这些差异:

将服务器数据映射到应用程序模型

模型和服务器数据之间的两个主要差异如下:

  • 模型定义了totalWorkoutDuration方法。

  • “练习”数组的表示也不同。模型的“练习”数组包含完整的Exercise对象,而服务器数据只存储练习标识符或名称。

这显然意味着加载和保存锻炼需要模型映射。

我们将通过添加第二个映射来实现这一点,进一步转换 Observable 响应对象。到目前为止,我们只是将响应转换为一个普通的 JavaScript 对象。好处是,我们刚刚使用的 map 请求也返回一个 Observable,这允许我们链接另一个 map 请求,将 JSON 对象转换为WorkoutPlan类型。

让我们在workout-service.ts文件中重写getWorkouts方法,以添加第二个映射:

getWorkouts() { 
    return this.http.get(this.collectionsUrl + '/workouts' + this.params) 
        .map((res:Response) => <WorkoutPlan[]>res.json()) 
        .map((workouts:Array<any>) => { 
            let result:Array<WorkoutPlan> = []; 
            if (workouts) { 
                workouts.forEach((workout) => { 
                    result.push( 
                        new WorkoutPlan( 
                            workout.name, 
                            workout.title, 
                            workout.restBetweenExercise, 
                            workout.exercises, 
                            workout.description 
                        )); 
                }); 
            } 

            return result; 
        }) 
        .catch(WorkoutService.handleError); 
}

与以前一样,我们的第一个映射将响应 Observable 转换为由 JavaScript 对象数组组成的 Observable。然后,第二个映射将这个 Observable 转换为由WorkoutPlan对象组成的 Observable。每个WorkoutPlan对象(目前我们只有一个)将具有我们需要的totalWorkoutDuration方法。

查看第二个映射的代码,您可以看到我们是如何操作第一个方法的 JSON 结果的,这就是为什么我们使用了<any>类型。然后我们创建了一个WorkoutPlans的类型数组,并使用一个箭头函数forEach来遍历第一个数组,将每个 JavaScript 对象分配给一个WorkoutPlan对象。

我们将这些映射的结果返回给订阅它们的调用者,在这种情况下是workouts.component.ts。调用者不需要对他们用来订阅我们的 workouts Observable 的代码进行任何更改。相反,模型映射可以在应用程序的一个地方进行,然后在整个应用程序中使用。

如果重新运行应用程序,您将看到总秒数现在正确显示了:

将服务器数据映射到应用程序模型

注意

GitHub 存储库中的检查点 5.2 包含了我们迄今为止所涵盖的工作实现。GitHub 分支是checkpoint5.2(文件夹:trainer)。

从服务器加载练习和锻炼数据

就像我们之前在WorkoutService中修复了getWorkouts的实现一样,我们可以为与练习和锻炼相关的其他获取操作实现WorkoutServicegetExercisegetWorkout方法。从检查点 5.2 中的trainer/src/components/workout-builder文件夹中的workout-builder.component.ts复制WorkoutService的服务实现。

注意

getWorkoutgetExercise方法使用训练/练习的名称来检索结果。每个 MongoLab 集合项都有一个_id属性,用于唯一标识该项/实体。对于我们的ExerciseWorkoutPlan对象,我们使用练习的名称进行唯一标识。因此,每个对象的name_id属性始终匹配。

在这一点上,我们需要在workout-service.ts中添加一个额外的导入:

import 'rxjs/add/observable/forkJoin';

这个导入引入了我们将很快讨论的forkJoin操作符。

特别注意getWorkout方法的实现,因为由于模型和数据存储格式不匹配,这里发生了相当多的数据转换。现在getWorkout方法的样子是这样的:

getWorkout(workoutName:string) { 
    return Observable.forkJoin( 
        this.http.get(this.collectionsUrl + '/exercises' + 
        this.params).map((res:Response) => <Exercise[]>res.json()), 
        this.http.get(this.collectionsUrl + '/workouts/' + 
        workoutName + this.params).map((res:Response) => 
       <WorkoutPlan>res.json()) 
    ).map( 
        (data:any) => { 
            let allExercises = data[0]; 
            let 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; 
        } 
    ) 
    .catch(WorkoutService.handleError); 
}

getWorkout内部发生了很多事情,我们需要理解。

getWorkout方法使用 Observable 及其forkJoin操作符返回两个 Observable 对象:一个用于检索Workout,另一个用于检索所有Exercises的列表。forkJoin操作符的有趣之处在于它不仅允许我们返回多个 Observable 流,而且还会等待两个 Observable 流都检索到数据后再进一步处理结果。换句话说,它使我们能够从多个并发的 HTTP 请求中流式传输响应,然后对组合结果进行操作。

一旦我们有了Workout的详细信息和完整的练习列表,我们就使用 map 操作符(我们之前在Workouts列表的代码中看到过)来更新workoutexercises数组,将其正确地设置为Exercise类对象。它通过在allExercises Observable 中搜索来自服务器的workout.exercises数组中的练习名称,然后将匹配的练习分配给 workout 服务数组来实现这一点。最终结果是我们得到了一个完整的WorkoutPlan对象,其中exercises数组被正确设置。

这些WorkoutService的更改也需要在上游调用者中进行修复。我们已经修复了LeftNavExercisesExercises组件中的练习列表,以及Workouts组件中的训练。

现在让我们以类似的方式修复WorkoutExercise组件。Workout服务中的getWorkoutgetExercise方法不是直接由这些组件调用的,而是由构建器服务调用的。现在让我们一起修复构建器服务以及WorkoutExercise组件。

修复构建服务

现在我们已经设置好了WorkoutService来从远程数据存储中检索锻炼,我们必须修改WorkoutBuilderService以能够将该锻炼作为 Observable 检索出来。提取Workout详情的方法是startBuilding。为了做到这一点,我们将当前的startBuilding方法分成两个方法,一个用于新的锻炼,一个用于我们从服务器检索到的现有锻炼。以下是新锻炼的代码:

startBuildingNew(name: string){ 
    let 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中做同样的修复。

修复锻炼和锻炼组件

接下来,我们将更新我们的WorkoutExercise组件,以便与我们从远程数据存储返回的 Observable 一起工作。我们将修复Workout组件,并留给你自己来修复Exercise组件,因为它遵循类似的模式。在锻炼详情页面导航渲染中使用的LeftNavExercises已经修复,所以让我们开始修复Workout组件。

Workout组件使用它的ngOnit生命周期钩子来加载新的或现有的锻炼。当路由成功解析到这个组件时,ngOnit使用注入的WorkoutBuilderService来加载锻炼。方法如下:

ngOnInit() { 
    this.sub = this.route.params.subscribe(params => { 
        if (!params['id']) { 
            this.workout = this.workoutBuilderService.startBuildingNew(); 
        } else { 
            let workoutName = params['id']; 
            this.workoutBuilderService.startBuildingExisting(workoutName) 
                .subscribe( 
                    (data:WorkoutPlan) => { 
                        this.workout = <WorkoutPlan>data; 
                        if (!this.workout) { 
                            this.router.navigate(['/builder/workouts']); 
                        } else { 
                            this.workoutBuilderService.buildingWorkout = 
                            this.workout; 
                        } 
                    }, 
                    (err:any) => { 
                        if (err.status === 404) { 
                            this.router.navigate(['/builder/workouts']) 
                        } else { 
                            console.error(err) 
                        } 
                    } 
                ); 
        } 
    }); 
}

首先,我们将新锻炼的方法更改为WorkoutBuilderService.startBuildingNew方法。这个方法创建一个新的WorkoutPlan对象,并将其分配给一个本地的WorkoutPlan实例,该实例将用于在屏幕上显示锻炼。

其次,我们更改检索现有WorkoutPlan的代码,以处理现在返回的是一个 Observable 的事实。因此,我们添加了订阅该 Observable 并将结果设置为本地的WorkoutPlan实例的代码。

为了测试实现,只需加载任何现有的锻炼详情页面,比如在/builder/workouts/下的7 分钟锻炼。锻炼数据应该成功加载。

锻炼详情页面也需要修复。Checkpoint 5.2文件包含了修复的ExerciseBuilderServiceExercise组件,你可以复制来加载锻炼详情;或者你可以自己做并比较实现。

更新路由守卫

随着我们开始使用 Observable 类型来访问数据,我们将不得不对我们为通往锻炼和练习屏幕创建的路由守卫进行一些调整。这是因为在使用 Observable 类型时会涉及一些时间考虑。简单地说,因为 Observable 是基于推送的,所以在我们创建订阅和 Observable 返回之间通常会有一些延迟。在我们的远程调用中,用于填充表单或显示项目列表,我们可以通过简单地添加一个检查项目或列表是否存在来管理该延迟。

然而,我们的守卫中并不存在这样的选项,它们会在订阅创建时立即运行检查。为了解决这个问题,我们需要在workout-guard.ts中添加一些代码,以确保 Observable 在我们运行检查之前得到解决。

首先从 RxJS 中导入Observable

import {Observable} from "rxjs/Rx";

接下来更新WorkoutGuard组件中的canActivate方法,如下所示:

canActivate(route:ActivatedRouteSnapshot, 
            state:RouterStateSnapshot):Observable<boolean> { 
    let workoutName = route.params['id']; 
    return this.workoutService.getWorkout(workoutName) 
        .take(1) 
        .map(workout => !!workout) 
        .do(workoutExists => { 
            if (!workoutExists) 
            this.router.navigate(['/builder/workouts']); 
        }) 
        .catch(error => { 
                if (error.status === 404) { 
                    this.router.navigate(['/builder/workouts']); 
                    return Observable.of(false) 
                } else { 
                    return Observable.throw(error); 
                } 
            } 
        )

我们在这里所做的是使用take操作符,并将其设置为1,以便在返回单个结果时停止 Observable 订阅。然后,我们使用map将锻炼对象映射到boolean(使用 JavaScript 的双非运算符)以确定其是否存在。最后,我们使用do操作符在它不存在的情况下将路由设置为 false,并将用户路由回锻炼屏幕。这给了我们我们正在寻找的即时结果。

注意

GitHub 存储库中的Checkpoint 5.2包含了我们迄今为止所涵盖的工作实现。如果您不使用 Git,请从以下 GitHub 位置下载 Checkpoint 5.2 的快照(ZIP 文件):github.com/chandermani/angular2byexample/tree/checkpoint5.2。在首次设置快照时,请参考trainer文件夹中的README.md文件。

现在是时候为练习和锻炼修复、创建和更新场景了。

在练习/锻炼上执行 CRUD

在创建、读取、更新和删除(CRUD)操作方面,所有保存、更新和删除方法都需要转换为 Observable 模式。

在本章的前面,我们详细介绍了对 MongoLab 集合中的 CRUD 操作的端点访问模式。回到加载锻炼和锻炼数据部分,重新审视访问模式。我们现在需要这个,因为我们计划创建/更新锻炼。

在开始实施之前,了解 MongoLab 如何识别集合项以及我们的 ID 生成策略非常重要。MongoDB 中的每个集合项都是通过_id属性在集合中唯一标识的。在创建新项目时,我们要么提供一个 ID,要么服务器自动生成一个 ID。一旦设置了_id,就不能更改。对于我们的模型,我们将使用锻炼/锻炼的name属性作为唯一 ID,并将名称复制到_id字段中(因此,没有自动生成_id)。还要记住,我们的模型类不包含这个_id字段;在第一次保存记录之前,必须创建它。

让我们先修复锻炼创建的场景。

创建新的锻炼

采用自下而上的方法,需要首先修复的是WorkoutService。按照下面的代码更新addWorkout方法:

addWorkout(workout:any) { 
  let workoutExercises:any = []; 
  workout.exercises.forEach((exercisePlan:any) => { 
    workoutExercises.push({name: exercisePlan.exercise.name, duration:exercisePlan.duration}) 
  }); 
  let 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) 
  .map((res:Response) => res.json())
  .catch(WorkoutService.handleError)
}

getWorkout中,我们需要将数据从服务器模型映射到我们的客户端模型;在这里需要做相反的操作。首先,我们为锻炼创建一个新的数组workoutExercises,然后向该数组添加一个更紧凑的版本的锻炼,以便更好地存储在服务器上。我们只想在服务器上的锻炼数组中存储锻炼名称和持续时间(该数组的类型为any,因为在其紧凑格式中,它不符合ExercisePlan类型)。

接下来,我们通过将这些更改映射到 JSON 对象来设置我们的帖子的主体。请注意,作为构造此对象的一部分,我们将_id属性设置为锻炼的名称,以在锻炼集合的数据库中唯一标识它。

注意

在 MongoDB 中使用锻炼/锻炼的名称作为记录标识符(或id)的简单方法将在任何规模较大的应用程序中失效。请记住,我们正在创建一个可以同时被许多用户访问的基于 Web 的应用程序。由于总是有两个用户可能会为锻炼/锻炼取相同的名称,我们需要一个强大的机制来确保名称不重复。

MongoLab REST API 的另一个问题是,如果有一个带有相同id字段的重复POST请求,一个将创建一个新文档,第二个将更新它,而不是第二个失败。这意味着客户端对id字段的任何重复检查仍然无法防止数据丢失。在这种情况下,最好分配id值的自动生成。

在通常情况下,我们创建实体时,唯一的 ID 生成是在服务器上完成的(主要是由数据库完成)。然后,当实体被创建时,响应包含生成的 ID。在这种情况下,我们需要在将数据返回给调用代码之前更新模型对象。

最后,我们调用 HTTP 模块的post方法,传递要连接的 URL,一个额外的查询字符串参数(apiKey),以及我们要发送的数据。

最后的返回语句应该看起来很熟悉,因为我们使用 Observables 作为 Observable 分辨率的一部分返回锻炼对象。您需要确保在 Observable 链中添加.subscribe以使其变热。我们将很快通过向WorkoutComponentsave方法添加订阅来做到这一点。

更新锻炼

为什么不尝试实现更新操作呢?updateWorkout方法可以以相同的方式进行修复,唯一的区别是需要使用 HTTP 模块的put方法:

updateWorkout(workout:WorkoutPlan) { 
  let workoutExercises:any = []; 
  workout.exercises.forEach((exercisePlan:any) => { 
    workoutExercises.push({name: exercisePlan.exercise.name, 
    duration:exercisePlan.duration}) 
  }); 
  let 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) 
  .map((res:Response) => res.json()) 
  .catch(WorkoutService.handleError); 
}

前面的请求 URL 现在包含一个额外的片段(workout.name),表示需要更新的集合项的标识符。

如果在集合中找不到文档,MongoLab 的PUT API 请求将创建传递的文档作为请求体。在进行PUT请求时,请确保原始记录存在。我们可以通过首先对相同文档进行GET请求并确认我们在更新之前获得文档来做到这一点。我们将把这个任务留给你来实现。

删除锻炼

需要修复的最后一个操作是删除锻炼。这里是一个简单的实现,我们调用 HTTP 模块的delete方法来删除特定 URL 引用的锻炼:

deleteWorkout(workoutName:string) { 
  return this.http.delete(this.collectionsUrl + '/workouts/' + 
  workoutName + this.params) 
  .map((res:Response) => res.json()) 
  .catch(WorkoutService.handleError) 
}

修复上游代码

有了这个,现在是时候修复WorkoutBuilderServiceWorkout组件了。WorkoutBuilderServicesave方法现在看起来像这样:

save(){ 
  let 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/angular2byexample/tree/checkpoint5.3。首次设置快照时,请参考trainer文件夹中的README.md文件。

在我们进行POSTPUT请求保存数据时,网络端会发生一些有趣的事情。打开浏览器的网络日志控制台(F12),看看正在进行的请求。日志看起来像这样:

修复上游代码

在实际进行PUT之前,会向相同的端点发出一个OPTIONS请求。我们在这里见证的行为被称为预检请求。这是因为我们正在向api.mongolab.com发出跨域请求。

使用 promises 进行 HTTP 请求

本章的大部分内容都集中在 Angular HTTP 客户端如何将 Observables 作为XMLHttpRequests的默认值。这代表了与以往工作方式的重大变化。许多开发人员习惯于使用 promises 进行异步 HTTP 请求。在这种情况下,Angular 仍然支持 promises,但不再是默认选择。开发人员必须选择在XMLHttpRequest中使用 promises 才能使用它们。

例如,如果我们想在WorkoutServicegetExercises方法中使用 promises,我们首先需要导入 RxJS 的toPromise操作符:

import 'rxjs/add/operator/toPromise';

然后我们将不得不重新构造命令如下:

getExercises() { 
  return this.http.get(this.collectionsUrl + '/exercises' + this.params) 
 **.toPromise().then((res:Response) => <Exercise[]>res.json())** 
  .catch(WorkoutService.handleError); 
}

为了将这个方法转换为使用 promises,我们只需要在方法链中添加.toPromise(),然后用 promise 的成功参数then替换对.map的调用。我们可以保留catch不变。

对于上游组件,我们只需将返回值处理为 promise 而不是 Observable。因此,在这种情况下使用 promises,我们需要更改Exercises.component.tsLeftNavExercises.component.ts中的代码,首先添加一个新的属性来存储错误消息。

errorMessage: any;

然后更改调用WorkoutService的方法如下:

ngOnInit() {
  this.workoutService.getExercises() 
 **.then(exerciseList => this.exerciseList = exerciseList,** 
**error => this.errorMessage = <any>error** 
);
}  

当然,我们可以在这个简单的例子中轻松地用 Promise 替代 Observables,并不意味着它们本质上是一样的。then promise 返回另一个 promise,这意味着你可以创建连续链接的 promise。在 Observable 的情况下,订阅本质上是终点,不能在那一点之后进行映射或订阅。

如果你熟悉 promises,也许在这个阶段坚持使用它们而不尝试 Observables 会很诱人。毕竟,我们在本章中使用 Observables 所做的大部分工作也可以用 promises 完成。例如,我们使用 Observable 的forkJoin操作符对getWorkouts的两个流进行映射,也可以用 promise 的q,all函数完成。

然而,如果你采取这种方法,你会卖自己短。Observables 开启了一种令人兴奋的新的网页开发方式,使用了所谓的函数式响应式编程。它们涉及一种基本的思维转变,将应用程序的数据视为一种持续的信息流,应用程序对其做出反应和响应。这种转变使应用程序能够以不同的架构构建,使其更快速和更具弹性。Observables 是 Angular 的核心,例如事件发射器和新版本的NgModel

虽然 promises 是你工具箱中有用的工具,但我们鼓励你在使用 Angular 进行开发时调查 Observables。它们是 Angular 前瞻性哲学的一部分,将有助于未来保护你的应用程序和技能。

注意

查看检查点 5.3文件,以获取包括我们之前介绍的与 promises 相关的代码的最新实现。如果您没有使用 Git,请从以下 GitHub 位置下载 Checkpoint 5.3 的快照(ZIP 文件):github.com/chandermani/angular2byexample/tree/checkpoint5.3。首次设置快照时,请参考trainer文件夹中的README.md文件。请注意,在下一节中,我们将重新使用 Observables 来处理此代码。此代码可以在检查点 5.4文件中找到。

异步管道

正如我们在本章中看到的许多数据操作一样,有一个相当常见的模式一遍又一遍地重复。当从 HTTP 请求返回一个 Observable 时,我们将响应转换为 JSON 并订阅它。然后订阅将 Observable 输出绑定到 UI 元素。如果我们能消除这种重复的编码,并用更简单的方法来实现我们想要做的事情,那不是挺好的吗?

毫不奇怪,Angular 为我们提供了正确的方法。它被称为异步管道,可以像其他管道一样用于绑定屏幕上的元素。但是,异步管道比其他管道更强大。它以 Observable 或 promise 作为输入,并自动订阅它。它还处理 Observable 的订阅取消,而无需任何进一步的代码。

让我们在我们的应用程序中看一个例子。让我们回到我们刚在上一节中与 promises 相关联的LeftNavExercises组件。请注意,我们已将此组件和Exercise组件从 promises 转换回使用 Observables。

注意

查看检查点 5.4文件,以获取包括将此代码转换回使用 Observables 的最新实现。如果您没有使用 Git,请从以下 GitHub 位置下载 Checkpoint 5.4 的快照(ZIP 文件):github.com/chandermani/angular2byexample/tree/checkpoint5.4。首次设置快照时,请参考trainer文件夹中的README.md文件。

然后在LeftNavExercises中进行以下更改。首先,将exerciseList从一个练习数组更改为相同类型的 Observable:

public exerciseList:Observable<Exercise[]>;

然后修改对WorkoutService的调用以获取练习,以消除订阅:

this.exerciseList = this.workoutService.getExercises();

最后,打开每个组件的模板文件,并在*ngFor循环中添加async管道,如下所示:

<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 请求。Plunker 链接,plnkr.co/edit/ZKAUYeOnlIXau27IWG6V?p=preview,突出了 JSONP 请求的制作方式。它使用Yahoo Stock API来获取任何股票符号的报价。

注意

Angular JSONP 服务仅支持 HTTP GET请求。使用任何其他 HTTP 请求,如POSTPUT,都会生成错误。

如果你看一下 Plunker,你会看到我们在整本书中一直遵循的组件创建的熟悉模式。我们不会再次介绍这个模式,但会强调一些与使用 Angular JSONP 服务相关的细节。

首先,除了标准导入之外,您还需要将JsonpModule导入到app.module.ts中,如下所示:

. . . 
import { JsonpModule }  from '@angular/http'; 
. . . 
@NgModule({ 
  imports: [  
    BrowserModule, 
    FormsModule, 
 **JsonpModule** 
  ], 
. . . 
}) 

接下来,我们需要将以下导入添加到get-quote-component.ts中。

import { Jsonp, URLSearchParams } from '@angular/http'; 
import {Observable} from 'rxjs/Observable'; 
import {Subject} from 'rxjs/Subject'; 
import 'rxjs/Rx';

我们从 HttpModule 中导入JsonpURLSearchParams,以及 RxJS Observable 以及rxjs/Rx。后者将引入我们在这个例子中需要的 RxJS 操作符。

注意

当你使用 Angular JSONP 时,重要的是要理解,默认情况下它返回使用 RxJS 的 Observables。这意味着我们将不得不遵循订阅这些 Observables 的模式,并使用 RxJS 操作符来操作结果。我们还可以使用 async 管道来简化这些操作。

下一步是将 JSONP 注入到构造函数中:

constructor(public jsonp: Jsonp) {} 

现在我们已经准备好了getQuote方法的所有内容。在 Plunker 中查看getQuote方法。我们首先定义几个变量,用于构造我们的请求:

getQuote (){ 
  let url = "https://query.yahooapis.com/v1/public/yql"; 
  let searchTerm ='select * from yahoo.finance.quote where symbol in 
  ("' + this.symbol + '")'; 
  let env = 'store://datatables.org/alltableswithkeys'; 
  let params = new URLSearchParams(); 
  params.set('q', searchTerm); // the user's search value 
  params.set('format', 'json'); 
  params.set('env', env); 
  params.set('callback', 'JSONP_CALLBACK'); 
  this.quote = this.jsonp.get(url, { search: params }) 
  .map(( res: Response) => res.json()); 
};  

我们正在使用 JSONP 的get方法来执行对 Yahoo!报价服务的远程调用。为了设置该方法,我们首先设置请求的 URL。URL 包含 Yahoo!服务的地址以及一个相当长的查询字符串。查询字符串包含了几个必需的名称-值对,这些对于成功调用 Yahoo!服务是必需的。这些包括q用于执行的查询,format用于响应的格式,以及env用于我们正在查询的特定环境。

Angular JSONP 服务为我们提供了一种方便的方式来创建这个查询字符串。我们可以逐个构造每个参数,然后将它们传递给get方法的数组。然后,Angular JSONP 将根据这些参数为我们构建查询字符串。

要进行 JSONP 请求,Angular JSONP 服务要求我们用额外的查询字符串参数callback=JSONP_ CALLBACK来扩展原始 URL。在内部,Angular JSONP 服务然后生成一个动态的script标签和一个函数。然后,它用生成的函数名替换JSONP_CALLBACK标记,并进行远程请求。

打开 Plunker 并输入诸如GOOGMSFTYHOO之类的符号,以查看股票报价服务的运行情况。请求的浏览器网络日志如下:

https://query.yahooapis.com/... & &callback=__ng_jsonp__.__req1.finished

在这里,__ng_jsonp__.__req1是动态生成的函数。响应看起来像这样:

__ng_jsonp__.__req1.finished({"query"  ...});

响应被包装在回调函数中。Angular 解析和评估这个响应,这导致调用__ng_jsonp__.__req1回调函数。然后,这个函数内部将数据路由到我们的finished函数回调。

我们希望这解释了 JSONP 的工作原理以及 JSONP 请求的基本机制。然而,JSONP 也有其局限性,如下所示:

  • 首先,我们只能进行GET请求(这是显而易见的,因为这些请求是由脚本标签发起的)

  • 其次,服务器还需要实现涉及将响应包装在一个名为callback的函数中的解决方案的部分,就像之前看到的那样

  • 总是存在安全风险,因为 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 HTTP 客户端调用可以毫无问题地工作。

CORS 需要在服务器上进行配置,MongoLab 服务器已经配置为允许跨域请求。因此,我们之前向 MongoLab 发出的POST请求来添加和更新ExerciseWorkout文档都引起了预检OPTIONS请求。

处理找不到的锻炼

您可能还记得在第四章 构建个人健身教练中,我们创建了WorkoutGuard来防止导航到WorkoutComponent,如果路由参数中不存在锻炼。现在我们想通过在锻炼屏幕上显示错误消息来增强这个功能,指示找不到锻炼。

为了做到这一点,我们将修改WorkoutGuard,以便在找不到锻炼时重新路由到锻炼屏幕。首先,在workoutBuilderRoutes中添加以下子路由(确保它位于现有的锻炼路由之前):

children: [ 
  {path: '', pathMatch: 'full', redirectTo: 'workouts'}, 
 **{path: 'workouts/workout-not-found', component: WorkoutsComponent'},** 
  {path: 'workouts', component: 'WorkoutsComponent'}, 
   *** other child routes *** 
  }, 
]

接下来,修改WorkoutGuard以便在找不到锻炼的情况下重定向到这个路由:

.do(workoutExists => {
  **if (!workoutExists)  this.router.navigate(['/builder/workouts/
  workout-not-found']);** 
}) 

然后在Workouts组件的变量中添加一个notFound布尔值设置为false

public workoutList:Array<WorkoutPlan> = []; 
**public notFound:boolean = false;** 
private subscription:any;

并且,在该组件的ngOnInit方法中,添加以下代码来检查找不到的锻炼路径,并将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模板中添加以下div标签,它将在notFound设置为true时显示在锻炼列表上方。

<div *ngIf="notFound" class="not-found-msgbox">Could not load the specific workout!</div>

如果在用户返回“锻炼”页面时在路径中找不到锻炼,则屏幕上会显示以下消息:

处理找不到的锻炼

我们已经修复了锻炼构建器页面的路由失败,但是锻炼构建器页面仍然未完成。同样,我们将把它留给你自己来修复。

另一个主要的(尚未实施)工作是修复7 分钟锻炼,因为它目前只适用于一个锻炼例程。

修复 7 分钟锻炼应用

目前,7 分钟锻炼(或锻炼运行器)应用程序只能播放一个特定的锻炼。它需要修复以支持使用个人教练构建的任何锻炼计划的执行。明显需要将这两个解决方案整合起来。我们已经做好了开始整合的准备工作。我们已经有了共享的模型服务和WorkoutService来加载数据,足以让我们开始。

修复7 分钟锻炼并将其转换为通用的锻炼运行器大致涉及以下步骤:

  • 删除7 分钟锻炼中使用的硬编码锻炼和练习。

  • 修复起始页面以显示所有可用的锻炼,并允许用户选择要运行的锻炼。

  • 修复锻炼路由配置,将所选的锻炼名称作为路由参数传递到锻炼页面。

  • 使用WorkoutService加载所选的锻炼数据并开始锻炼。

  • 当然,我们需要重命名应用程序的7 分钟锻炼部分;现在这个名字是错误的。我认为完整的应用程序可以称为个人教练。我们也可以从视图中删除所有关于7 分钟锻炼的引用。

一个很好的练习,你可以自己尝试!这就是为什么我们不会为你提供解决方案。相反,继续实施解决方案。将你的实现与检查点 5.4中提供的实现进行比较。

是时候结束这一章并总结你的学习了。

总结

我们现在有一个可以做很多事情的应用程序。它可以运行锻炼,加载锻炼,保存和更新它们,并跟踪历史记录。如果我们回顾一下,我们用了很少的代码就实现了这一切。我们敢打赌,如果我们尝试在标准的 jQuery 或其他框架中做这个,相比于 Angular,它需要更多的努力。

我们通过在MongoLab服务器上提供MongoDB数据库来开始这一章。由于 MongoLab 提供了一个 RESTful API 来访问数据库,我们节省了一些时间,不需要设置自己的服务器基础设施。

我们接触到的第一个 Angular 构造是 HTTP 客户端,它是连接到任何 HTTP 后端的主要服务。

你还学会了 HTTP 模块如何使用 Observables。在这一章中,我们第一次创建了自己的 Observable,并解释了如何订阅这些 Observables。

我们修复了我们的个人健身教练应用程序,使其使用 HTTP 模块来加载和保存锻炼数据。(请注意,关于锻炼数据的持久性留给你来完成。)在这个过程中,你还了解了围绕跨域资源访问的问题。你学会了 JSONP,这是一个绕过浏览器同源限制的解决方法,以及如何使用 Angular 发出 JSONP 请求。我们还涉及了 CORS,这在跨域通信方面已经成为了一个标准。

我们现在已经涵盖了大部分 Angular 的构建模块,除了一个重要的:Angular 指令。我们到处都使用了指令,但还没有创建一个。下一章将专门介绍 Angular 指令。我们将创建许多小指令,比如远程验证器、AJAX 按钮,以及锻炼构建器应用程序的验证提示指令。你还将学习如何将 jQuery 插件集成为 Angular 指令。

第六章:深入了解 Angular 2 指令

指令随处可见。它们是 Angular 2 的基本构建块。应用程序的每个扩展都导致我们创建新的组件指令。这些组件指令进一步使用了属性指令(如NgClassNgStyle)和结构指令(如NgIfNgFor)来扩展它们的行为。

虽然我们已经构建了许多组件指令和一个孤立的属性指令,但仍有一些指令构建的概念值得探索。这对于属性和结构指令尤为重要,我们尚未详细介绍。

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

  • 构建指令:我们构建多个指令,并学习指令的用途,它们与组件的区别,以及指令如何相互通信和/或与它们的宿主组件通信。我们探索所有指令类型,包括组件指令属性指令结构指令

  • 异步验证:Angular 使得验证需要与服务器交互并且是异步的规则变得容易。我们将在本章中构建我们的第一个异步验证器。

  • 使用渲染器进行视图操作:渲染器允许以平台无关的方式进行视图操作。我们利用渲染器来处理繁忙指示器指令,并学习其 API。

  • 宿主绑定:宿主绑定允许指令与它们的宿主元素进行通信。本章涵盖了如何利用这样的绑定来指令。

  • 指令注入:Angular DI 框架允许基于指令在 HTML 层次结构中声明的位置进行指令注入。我们将涵盖与此类注入相关的多种情景。

  • 与视图子组件和内容子组件一起工作:组件有能力将外部视图模板包含到它们自己的视图中。我们将在这里讨论如何处理注入的内容。

  • 理解 NgIf 平台指令:我们将深入了解NgIf平台指令,并尝试理解结构指令(如NgIf)的工作原理。

  • Angular 组件的视图封装:我们将学习 Angular 如何使用从web 组件派生的概念来支持视图和样式封装。

让我们通过重申指令的基本分类来开始本章。

指令分类

Angular 指令将 HTML 视图与应用程序状态集成。指令帮助我们在应用程序状态更改时操纵视图,并在几乎没有或没有与实际 DOM 的手动交互的情况下响应视图更新。

根据它们对视图的影响方式,这些指令进一步分为三种类型。

组件

组件指令或组件是具有封装视图的指令。在 Angular 中,当我们构建 UI 小部件时,我们正在构建组件。我们已经构建了很多,比如WorkoutRunnerWorkoutAudioWorkoutBuilder等等!

这里要意识到的一个重要点是,视图绑定到组件实现,并且只能使用在支持组件上定义的属性和事件。

属性指令

属性指令,另一方面,扩展了现有组件或 HTML 元素。将它们视为这些组件/元素的行为扩展。

由于指令是预定义元素的行为扩展,每个指令构建练习都涉及操纵应用这些指令的组件/元素的状态。在第三章中构建的MyAudioDirective更多 Angular 2-SPA,路由和深入数据流,也是如此。该指令包装了 HTML 5 audio元素(HTMLAudioElement)以便于使用。平台指令如ngStylengClass也以类似的方式工作。

结构指令

结构指令,像属性指令一样,不定义自己的视图。相反,它们在作为其一部分提供的视图模板(HTML 片段)上工作。往往,结构指令的目的是显示/隐藏或克隆提供给它的模板视图。平台指令如NgForNgIfNgSwitch就是这一类别中的主要例子。

希望这个关于指令的快速复习足以让我们开始。我们将通过扩展锻炼构建器验证并构建一个异步验证器指令来开始我们的追求。

注意

我们将从我们在第五章中停下的地方开始,支持服务器数据持久性。Git 分支checkpoint5.4可以作为本章的基础。

该代码也可以在 GitHub 上找到(github.com/chandermani/angular2byexample),供所有人下载。检查点在 GitHub 中作为分支实现。如果您不使用 Git,请从 GitHub 位置bit.ly/ng2be-checkpoint5-4下载checkpoint2.4的快照(一个 zip 文件)。首次设置快照时,请参考trainer文件夹中的README.md文件。

还记得在services/workout-service.ts中更新 API 密钥为您自己的 API 密钥。

构建一个远程验证器指令

我们在第五章结束了支持服务器数据持久性Workout Runner能够在 MongoDB 存储中管理训练。由于每个训练应该有一个唯一的名称,我们需要强制执行唯一性约束。因此,在创建/编辑训练时,每当用户更改训练名称时,我们可以查询 MongoDB 来验证该名称是否已经存在。

与任何远程调用一样,这个检查也是异步的,因此它需要一个远程验证器。我们将使用 Angular 的异步验证器支持来构建这个远程验证器。

异步验证器与标准自定义验证器类似,只是返回值不是键值对象映射或 null,而是验证检查的返回值是一个promise。这个 promise 最终会解析为设置的验证状态(如果有错误),否则为 null(验证成功时)。

我们将创建一个验证指令,用于进行训练名称检查。对于这样的指令,有两种可能的实现方法:

  • 我们可以创建一个专门用于唯一名称验证的指令

  • 我们可以创建一个通用指令,可以执行任何远程验证

提示

验证指令虽然我们在这里构建了一个验证指令,但我们也可以构建一个标准的自定义验证器类。创建指令的优势在于它允许我们将指令纳入模板驱动的表单方法中,在那里指令可以嵌入到视图 HTML 中。或者,如果表单是使用模型生成的(模型驱动方法),我们可以在创建Control对象时直接使用验证器类。

起初,检查重复名称是否来自数据源(mLab 数据库)似乎是一个太具体的要求,不能由通用验证器处理。但通过一些明智的假设和设计选择,我们仍然可以实现一个可以处理所有类型的远程验证的验证器,包括锻炼名称验证。

计划是创建一个将实际验证逻辑外部化的验证器。指令将接受验证函数作为输入。这意味着实际验证逻辑不是验证器的一部分,而是实际需要验证输入数据的组件的一部分。指令的工作只是调用函数并根据函数的返回值返回适当的错误键。

让我们把这个理论付诸实践,构建我们的远程验证指令,恰当地命名为RemoteValidatorDirective

注意

以下部分的伴随代码基于 Git 分支checkpoint6.1。您可以与我们一起工作,或者查看前述文件夹中可用的实现。或者,如果您不使用 Git,请从 GitHub 位置bit.ly/ng2be-checkpoint6-1下载checkpoint6.1的快照(zip 文件)。在首次设置快照时,请参考trainer文件夹中的README.md文件。

使用异步验证器验证锻炼名称

与自定义验证器一样,异步验证器也继承自相同的Validator类;但这次,异步验证器返回一个Promise而不是返回对象映射。

让我们看一下验证器的定义。在workout-builder/shared文件夹中创建一个文件remote-validator.directive.ts,并添加这个RemoteValidatorDirective实现:

import {provide, Directive, Input} from '@angular/core'; 
import { NG_ASYNC_VALIDATORS, Validators, Validator, FormControl }  
from '@angular/forms'; 

@Directive({ 
selector: `[a2beRemoteValidator][ngModel]`,   
   providers:[{ provide: NG_ASYNC_VALIDATORS, 
 useExisting: RemoteValidatorDirective,  
 multi: true }] 
  ] 
}) 

export class RemoteValidatorDirective implements Validator { 
  @Input("a2beRemoteValidator") validationKey: string; 
  @Input("validateFunction")  
execute: (value: string) => Promise<boolean>;  

  validate(control: FormControl): { [key: string]: any } { 
    let value: string = control.value; 
    return this.execute(value).then((result: boolean) => { 
      if (result) { return null; } 
      else { 
        let error: any = {}; 
        error[this.validationKey] = true; 
        return error; 
      }}); 
  } 
} 

由于我们将验证器注册为指令,而不是使用FormControl实例进行注册(通常在使用模型驱动方法构建表单时使用),因此我们需要额外的提供者配置设置(在前述@Directive元数据中添加)。使用这种语法:

    providers:[{ provide: NG_ASYNC_VALIDATORS, 
 useExisting: RemoteValidatorDirective,  
 multi: true }] 

这个语句注册了验证器与现有的异步验证器。

注意

在前面的代码中使用的奇怪指令选择器selector: [a2beRemoteValidator][ngModel]``将在下一节中进行讨论,我们将构建一个繁忙指示器指令。

在我们深入验证器的实现之前,让我们将其添加到锻炼名称输入中。这将帮助我们将验证器的行为与其使用相关联。

使用验证声明更新训练名称输入(workout.component.html):

<input type="text" name="workoutName" ... 
 **a2beRemoteValidator="workoutname"** 
 **[validateFunction]="validateWorkoutName">**

并在训练构建器模块(workout-builder.module.ts)中添加验证指令的习惯声明:

import { RemoteValidatorDirective } from "./shared/remote-validator.directive"; 
... 
declarations: [WorkoutBuilderComponent,...  
RemoteValidatorDirective], 

远程验证器在视图中被引用为a2beRemoteValidator

提示

为指令选择器添加前缀

始终使用标识符(如你刚才看到的a2be)为你的指令添加前缀,以将它们与框架指令和其他第三方指令区分开来。

指令实现接受两个输入:用于设置error key的验证键(validationKey)和用于验证控件值的验证函数。这两个输入都使用@Input装饰器进行了注释。

注意

输入参数@Input("validateFunction") execute: (value: string) => Promise<boolean>;,绑定到一个函数,而不是一个标准的组件属性。由于底层语言 TypeScript(以及 JavaScript)的特性,我们可以将函数视为属性。

当异步验证触发时(在input的更改上),Angular 调用validate函数,传入底层的control。作为第一步,我们提取当前的输入值,然后使用这个输入调用execute函数。execute函数返回一个 promise,最终应该解析为truefalse

  • 如果是true,则验证成功,promise 回调函数返回null

  • 如果是false,则验证失败,并返回一个错误的键值映射。这里的key是我们在使用验证器时设置的字符串字面量(a2beRemoteValidator="workoutname")。

当输入上声明了多个验证器时,这个key就派上了用场,允许我们识别哪些验证失败了。

让我们也为这个失败添加一个验证消息。在workout name的现有验证label之后添加这个标签声明:

<label *ngIf="name.control.hasError('workoutname')" class="alert alert-danger">A workout with this name already exists.</label> 

然后将这两个标签包裹在一个div中,就像我们为workout title的错误标签所做的那样。

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 "../../../services/workout-service"; 
... 
constructor(... private workoutService: WorkoutService) { 

让我们还更新ngOnInit函数,并将本地变量workoutName转换为类成员。在其他类成员中声明workoutName

private workoutName: string; 

else语句内的第一条语句更改为:

this.workoutName = params['id']; 

并在startBuildingExisting函数调用期间使用相同的变量。

validateWorkoutName中的第一个if条件是用于更新场景。显然,我们不希望验证现有的锻炼名称。return Promise.resolve(true);语句返回一个始终解析为true的 promise。

validateWorkoutName函数定义为实例函数(使用箭头运算符)而不是定义为标准函数(在原型上声明函数)的原因是this作用域问题。

查看RemoteValidatorDirective内部的验证函数调用(使用@Input("validateFunction") execute;声明):

    return this.execute(value).then((result: boolean) => { ... }); 

当调用函数(名为execute)时,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,在找到相同名称的锻炼时返回false(实际上返回一个promise)。

注意

WorkoutService上的getWorkout函数返回一个observable,但我们通过在 observable 上调用toPromise函数将其转换为一个promise。我们需要一个 promise 对象,因为RemoteValidatorDirective需要从验证器函数返回一个 promise。

现在可以测试验证指令。创建一个新的锻炼并输入一个现有的锻炼名称,比如7minworkout。看看验证错误消息最终会显示出来:

使用异步验证器验证锻炼名称

太棒了!看起来很不错,但还有一些东西缺失。用户并没有被告知我们正在验证锻炼名称。我们可以改善这个体验。

构建一个繁忙指示器指令

在远程验证锻炼名称时,我们希望用户意识到后台的活动。在远程验证发生时,围绕输入框的视觉提示应该能够达到目的。

仔细思考;有一个带有异步验证器(进行远程验证)的输入框,我们希望在验证过程中为输入框添加一个视觉提示。看起来像是一个常见的解决模式?确实是,所以让我们创建另一个指令!

但在开始之前,必须明白我们并不是孤军奋战。繁忙指示器指令需要另一个指令NgModel的帮助。我们已经在第四章中的构建个人教练中对input元素使用了NgModel指令。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来检查验证状态。

让我们从定义指令的大纲和装饰器元数据开始。在workout-builder/shared文件夹中创建一个busy-indicator.directive.ts文件,并添加以下代码:

import {Directive} from '@angular/core'; 
import {NgModel} from '@angular/form; 

@Directive({ 
  selector: '[a2beBusyIndicator]', 
}) 
export class BusyIndicatorDirective { 
  constructor(private model: NgModel) { } 
} 

指令选择器元数据指定繁忙指示器将应用于具有a2beBusyIndicator属性的元素/组件。

在继续之前,将此指令添加到锻炼构建器模块(workout-builder.module.ts)的声明部分。

在前面的代码中唯一值得关注的是 Angular DI 注入与input元素关联的NgModel指令。请记住,NgModel指令已经存在于inputworkoutname)上:

**<input... name="workoutName" #name="ngModel" [(ngModel)]="workout.name" ...>**

这足以将我们的新指令集成到锻炼视图中,所以让我们快速做一下。

workout-builder中打开workout.component.html并将繁忙指示器指令添加到锻炼名称input中:

<input type="text" name="workoutName" ... a2beBusyIndicator> 

创建一个新的锻炼或打开一个现有的锻炼,看看BusyIndicatorDirective是否被加载,并且NgModel注入是否正常工作。这可以通过在BusyIndicatorDirective构造函数内设置断点来轻松验证。

Angular 将相同的NgModel实例注入到BusyIndicatorDirective中,当它遇到输入 HTML 上的ngModel时创建了它。

您可能想知道,如果我们在没有ngModel属性的输入元素上应用此指令,或者实际上在任何 HTML 元素/组件上应用此指令,会发生什么,比如这样:

<div a2beBusyIndicator></div> 
<input type="text" a2beBusyIndicator> 

注入会起作用吗?

当然不是!我们可以在创建锻炼视图上尝试。打开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正常工作。因此,虽然我们学到了一些新东西,但在当前情况下并不是非常有用。

注意

在继续之前,请记住将workoutnameinput恢复到原始状态,应用a2beBusyIndicator

BusyIndicatorDirective只有在元素上已经存在NgModel指令时才能应用。

这次selector指令将拯救我们。将BusyIndicatorDirective的选择器更新为:

selector: `[a2beBusyIndicator][ngModel]` 

此选择器仅在元素上同时存在a2beBusyIndicatorngModel属性的组合时才创建BusyIndicatorDirective。问题解决了!

现在是时候添加实际的实现了。

实现 1-使用渲染器

为了使BusyIndicatorDirective起作用,它需要知道input上的异步验证何时触发以及何时结束。这些信息只能通过NgModel指令获得。NgModel有一个属性control,它是Control类的一个实例。正是这个Control类跟踪输入的当前状态,包括以下内容:

  • 当前分配的验证器(同步和异步)

  • 当前值

  • 输入元素状态,如pristinedirtytouched

  • 输入验证状态,可能是validinvalid或在异步执行验证时是pending

  • 跟踪值更改或验证状态更改的事件。

Control类似乎是一个有用的类,我们感兴趣的是pending状态!

让我们为BusyIndicatorDirective类添加第一个实现。使用以下代码更新类:

private subscriptions: Array<any> = []; 
ngAfterViewInit() { 
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: NgControl,  
 **private element: ElementRef,** 
 **private renderer: Renderer) { }**

还要在'@angular/core'中为ElementRefRenderer添加导入。

ElementRef是对底层 HTML 元素(在本例中为input)的包装对象。第三章中构建的MyAudioDirective指令,更多 Angular 2-SPA,路由和深入数据流,使用ElementRef来获取底层的Audio元素。

Renderer注入值值得一提。调用setElementStyleRenderer负责管理 DOM 的明显迹象。但在更深入地探讨Renderer的角色之前,让我们试着理解前面的代码在做什么。

在上述代码中,模型(NgModel实例)上的control属性定义了一个事件(一个Observable),statusChanges,我们可以订阅它以了解控件验证状态何时更改。可用的验证状态是validinvalidpending

订阅检查控件状态是否为pending,并相应地使用Renderer API 函数setElementStyle装饰底层元素。我们设置输入的border-widthborder-color

上述实现添加到ngAfterViewInit指令生命周期钩子中,在视图初始化后调用。

让我们试一下。打开创建锻炼页面或现有的7 分钟锻炼。一旦开始输入/编辑锻炼名称,input样式就会更改,并在锻炼名称的远程验证完成后恢复。不错!

实现 1-使用渲染器

在继续之前,还要将取消订阅代码添加到BusyIndicatorDirective中,以避免内存泄漏。将此函数添加到BusyIndicatorDirective中:

ngOnDestroy() { 
    this.subscriptions.forEach((s) => s.unsubscribe()); 
} 

提示

始终取消订阅可观察对象

始终记得取消对代码中已完成的任何Observable/EventEmitter订阅,以避免内存泄漏。

实现看起来不错。Renderer正在发挥作用。但还有一些未解答的问题。

为什么不直接获取底层 DOM 对象并使用标准 DOM API 来操作输入样式?为什么我们需要渲染器

Angular 渲染器,翻译层

Angular 2 的主要设计目标之一是使其在各种环境、框架和设备上运行。Angular 通过将核心框架实现分为应用程序层和渲染层来实现这一点。应用程序层具有我们与之交互的 API,而渲染层提供了一个抽象,应用程序层可以使用它而不必担心实际视图是如何以及在哪里被渲染的。

通过分离渲染层,Angular 理论上可以在各种设置中运行。这些包括(但不限于):

  • 浏览器

  • 浏览器主线程和 Web Worker 线程,出于明显的性能原因

  • 服务器端渲染

  • 原生应用程序框架;正在努力将 Angular 2 与NativeScriptReactNative集成

  • 测试,允许我们在 Web 浏览器之外测试应用程序 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时,当isbusy指令属性为true时,它将将inputreadOnly属性设置为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实现。

实现 2 - 具有宿主绑定的 BusyIndicatorDirective

你可能已经猜到了!我们将使用宿主属性绑定而不是Renderer来设置样式。想试试吗?继续!清除现有的实现,尝试为borderWidthborderColor样式属性设置宿主绑定,而不查看以下的实现。

这是主机绑定实现后指令的样子:

import {Directive, HostBinding} from '@angular/core'; 
import {NgModel} from '@angular/forms'; 

@Directive({ selector: `[a2beBusyIndicator][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/ng2be-checkpoint6-1下载checkpoint6.1的快照(zip 文件)。在首次设置快照时,请参考trainer文件夹中的README.md文件。

我们接下来要讨论的下一个主题是指令注入

指令注入

回到前面几页,看一下使用渲染器的BusyIndicatorDirective实现,特别是构造函数:

constructor(private model: NgModel ...) { } 

Angular 自动定位了为当前元素创建的NgModel指令,并将其注入到BusyIndicatorDirective中。这是可能的,因为这两个指令都声明在同一个主机元素上。

好消息是我们可以影响这种行为。在父 HTML 树或子树上创建的指令也可以被注入。接下来的几节将讨论如何在组件树中注入指令,这是一个非常方便的功能,允许具有共同血统(在视图中)的指令进行跨指令通信。

我们将使用 Plunker(bit.ly/ng2be-directive-tree)来演示这些概念。

首先,看一下 Plunker 文件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 示例中,第一个注入将Relation指令与 me 属性(relation="me")注入到 consumer 指令中:

constructor(private me:Relation ... 

从父级注入指令依赖项

使用@Host装饰器前缀的构造函数参数指示 Angular 在当前元素其父元素其父元素上搜索依赖项,直到达到组件边界(在其视图层次结构中某处存在该指令的组件)。检查第二个consumer注入:

constructor(..., @Host() private myAcquaintance:Acquaintance  

这个语句注入了在层次结构中声明的Acquaintance指令实例。

注意

就像之前描述的@Option装饰器一样,@Host()的使用也不仅限于指令。Angular 服务注入也遵循相同的模式。如果一个服务标记为@Host,搜索将停止在宿主组件处。它不会继续向上组件树。

@Skipself装饰器可用于跳过当前元素进行指令搜索。

从 plunk 示例中,这个注入将Relation指令与父属性(relation="parent")注入到 consumer 中:

@SkipSelf() private myParent:Relation 

注入子指令(或多个指令)

如果需要将在嵌套 HTML 中定义的指令注入到父指令/组件中,有四个装饰器可以帮助我们:

  • @ViewChild/@ViewChildren

  • @ContentChild/@ContentChildren

正如这些命名约定所暗示的,有装饰器可以注入单个子指令或多个子指令:

要理解@ViewChild/@ViewChildren@ContentChild/@ContentChildren的重要性,我们需要看一下视图和内容子级是什么,这是我们很快会讨论的一个话题。但现在,理解视图子级是组件自己视图的一部分,而内容子级是注入到组件视图中的外部 HTML 就足够了。

看看在 Plunket 中,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 搜索所有后代。

如果您查看 plunker 日志,前面的语句将所有四个后代注入进来。

虽然 Angular DI 看起来很简单,但它包含了很多功能。它管理我们的服务、组件和指令,并在正确的时间、正确的地方为我们提供正确的东西。组件和其他指令中的指令注入提供了指令之间进行通信的机制。这样的注入允许一个指令访问另一个指令的公共 API(公共函数/属性)。

现在是探索新东西的时候了。我们将构建一个 Ajax 按钮组件,允许我们将外部视图注入到组件中,这个过程也被称为内容转换

构建一个 Ajax 按钮组件

当我们保存/更新练习或锻炼时,总会存在重复提交(或重复的POST请求)的可能性。当前的实现没有提供任何关于保存/更新操作何时开始和何时完成的反馈。由于缺乏视觉线索,应用程序的用户可能会有意或无意地多次点击保存按钮。

让我们尝试通过创建一个专门的按钮来解决这个问题——一个Ajax 按钮,当点击时会给出一些视觉线索,并且还会阻止重复的 Ajax 提交。

按钮组件将按照以下方式工作。它接受一个函数作为输入。这个输入函数(输入参数)应该返回与远程请求相关的 promise。点击按钮时,按钮内部会进行远程调用(使用输入函数),跟踪底层的 promise,等待其完成,并在此过程中显示一些繁忙的线索。此外,按钮在远程调用完成之前保持禁用,以避免重复提交。

注意

以下部分的伴随代码基于 git 分支checkpoint6.2。您可以跟着我们一起工作,或者查看分支中提供的实现。或者如果您不使用 Git,可以从 GitHub 位置bit.ly/ng2be-checkpoint6-2下载checkpoint6.2的快照(zip 文件)。首次设置快照时,请参考trainer文件夹中的README.md文件。

让我们创建组件大纲以使事情更清晰。在workout-builder/shared文件夹中创建一个名为ajax-button.component.ts的文件,并添加以下组件大纲:

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

@Component({ 
  selector: 'ajax-button', 
  template: `<button [attr.disabled]="busy"  
                  class="btn btn-primary"> 
                <span [hidden]="!busy"> 
                <span class="glyphicon  
                     glyphicon-refresh spin"></span> 
                </span>  
                <span>Save</span> 
            </button>` 
}) 
export class AjaxButtonComponent { 
  busy: boolean = null; 
  @Input() execute: any; 
  @Input() parameter: any; 
} 

组件(AjaxButtonComponent)有两个属性绑定,executeparameterexecute属性指向在 Ajax 按钮点击时调用的函数。parameter是可以传递给此函数的数据。

查看视图中busy标志的使用。当设置了busy标志时,我们禁用按钮并显示旋转器。让我们添加使一切工作的实现。将以下代码添加到AjaxButtonComponent类中:

@HostListener('click', ['$event']) 
onClick(event: any) { 
    let 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。然后按钮等待 promise 解析,使用then函数。无论 promise 是以成功还是错误解析的,繁忙标志都将设置为null

注意

繁忙标志设置为null而不是false的原因是由于这个属性绑定[attr.disabled]="busy"。除非busynull,否则disabled属性不会被移除。请记住,在 HTML 中,disabled="false"不会启用按钮。在按钮再次可点击之前,需要移除该属性。

假设对这一行仍然存在困惑:

    let result: any = this.execute(this.parameter); 

然后需要查看组件的使用方式。打开workout.component.html,并用以下内容替换Save按钮的 HTML:

<ajax-button [execute]="save" [parameter]="f"></ajax-button> 

我们将Workout.save函数绑定到executeparameter;它接受FormControl对象f

我们需要更改Workout类中的save函数,以便返回一个AjaxButtonComponent可以工作的 promise。但在继续之前,打开workout.component.ts,添加AjaxButtonComponent,导入它,并在directives数组中引用它。

然后将save函数的实现更改为以下内容:

save = (formWorkout: any): Promise<any> => { 
         this.submitted = true; 
         if (!formWorkout.valid) return; 
         let savePromise =  
         this.workoutBuilderService.save().toPromise(); 
         savePromise.then( 
            (data) => this.router.navigate(['/builder/workouts']), 
            (err) => console.error(err) 
         ); 
            return savePromise; 
} 

save函数现在返回一个promise,我们通过调用workoutBuilderService.save()返回的observable上的toPromise函数来构建它。

还要将AjaxButtonComponent添加到健身教练模块的(workout-builder.module.ts)声明部分。

注意

请注意我们如何将save函数定义为实例函数(使用箭头运算符)以创建一个对this的闭包。这是我们在构建远程验证器指令时也做过的事情。

是时候测试我们的实现了!刷新应用程序并打开创建/编辑锻炼视图。单击保存按钮,看到 Ajax 按钮的效果:

构建 Ajax 按钮组件

我们开始这一节的目的是突出外部元素/组件如何被插入到一个组件中。现在让我们来做吧!

将外部组件/元素插入到组件中

从一开始,我们需要理解插入的含义。理解这个概念的最好方法是看一个例子。

到目前为止,我们构建的任何组件都没有从外部借用内容。不确定这是什么意思?

考虑前面的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 结构(使用template/templateUrl)是组件的视图子组件。然而,作为组件使用的一部分提供的 HTML 视图添加到宿主元素(如<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"> 
 **<span class="glyphicon glyphicon-refresh spin" data-animator>
   </span>****<span data-content>Save</span>** 
</ajax-button> 

带有data-animatorspan被注入到带有select=[data-animator]属性的ng-content中,另一个带有data-content属性的span被注入到第二个ng-content声明中。

再次刷新应用程序,尝试保存锻炼。虽然最终结果是相同的,但结果视图是多个视图片段的组合:一个部分用于组件定义(视图子项),另一个部分用于组件使用(内容子项)。

下图突出显示了渲染的AjaxButtonComponent的这种差异:

内容子项和视图子项

提示

ng-content可以在没有selector属性的情况下声明。在这种情况下,定义在组件标记内的完整内容被注入。

内容注入到现有组件视图中是一个非常强大的概念。它允许组件开发人员提供扩展点,组件使用者可以方便地消费和定制组件的行为,而且是在受控的方式下。

我们为AjaxButtonComponent定义的内容注入允许使用者更改繁忙指示器动画和按钮内容,同时保持按钮的行为不变。

Angular 的优势不仅在于此。它有能力将内容子项视图子项注入到组件代码/实现中。这使得组件能够与其内容/视图子项进行交互,并控制它们的行为。

使用@ViewChild 和@ViewChildren 注入视图子项

在第三章中,更多 Angular 2-SPA、路由和深入的数据流,我们使用了类似的东西,视图子项注入。为了复习我们做过的事情,让我们看一下WorkoutAudioComponent实现的相关部分。

视图定义如下:

<audio #ticks="MyAudio" loop  
  src="/static/audio/tick10s.mp3"></audio> 
<audio #nextUp="MyAudio"  
  src="/static/audio/nextup.mp3"></audio> 
<audio #nextUpExercise="MyAudio"  
  [src]="'/static/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,甚至子组件也可以被注入。例如,不使用MyAudioDirective,假设我们构建一个MyAudioComponent,就像这样:

@Component({ 
  selector: 'my-audio', 
  template: '<audio ...></audio>', 
}) 
export class MyAudioComponent { 
  ... 
} 

然后我们可以使用它来代替audio标签:

<my-audio #ticks loop  
  src="/static/audio/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的实例。因此,对于以下语句,all.length将是

@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 上添加模板变量来完成:

<span class="glyphicon glyphicon-refresh spin"  
**data-animator #spinner></span>**
**<span data-content #text>Save</span>**

虽然前面的注入是ElementRef,它也可以是一个组件。如果我们定义了一个名为 spinner 的组件:

<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] 

注意

为了避免混淆,我们现在讨论的是除了指令/组件对象之外的依赖注入。在可以使用装饰器提示(如@Query@ViewChild@ViewChildren等)注入之前,指令/组件需要在模块的declarations数组中注册。

在组件级别注册的依赖项可用于其视图子级内容子级及其后代。

注意

在我们继续之前,我们希望视图内容 子级之间的区别对每个人都非常清晰。如果有疑问,请再次参考内容子级和视图子级部分。

让我们从第四章中的示例中获取一个例子,构建个人健身教练WorkoutBuilderService服务在锻炼构建器模块(WorkoutBuilderModule)中以应用程序级别注册:

providers: [ExerciseBuilderService, ...  
 **WorkoutBuilderService]);**

这使我们能够在整个应用程序中注入WorkoutBuilderService以构建锻炼,并在运行锻炼时使用。相反,我们也可以在WorkoutBuilderComponent级别注册服务,因为它是所有锻炼/练习创建组件的父级,就像这样:

@Component({ 
    template: `...` 
 **providers:[ WorkoutBuilderService ]** 
}) 
export class WorkoutBuilderComponent { 

这个改变将禁止在WorkoutRunner或与锻炼执行相关的任何组件中注入WorkoutBuilder

注意

如果WorkoutBuilderService服务在应用程序级别和组件级别(如前面的示例所示)都注册了,那会怎么样?注入是如何发生的?根据我们的经验,我们知道 Angular 会将WorkoutBuilderService服务的不同实例注入到WorkoutBuilderComponent(及其后代)中,而应用程序的其他部分(Workout runner)将获得全局依赖。记住层次注入器

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],** 
... 

由于MyService已在AjaxButtonComponent中注册,因此MyDirective也可以添加到其内容子级中。因此,在spinner HTML上应用myDirective也会起作用(workout.component.html中的代码):

<span class="glyphicon glyphicon-refresh spin"  
  data-animator #spinner myDirective></span> 

但将providers属性更改为viewProviders

**viewProviders:[MyService]**

将会导致MyServiceAjaxButtonComponent的内容子级(前面代码中的span)中注入失败,并在控制台中出现 DI 错误。

注意

使用viewProviders注册的依赖项对其内容子级是不可见的。

视图内容子级的这种依赖范围在一开始可能看起来并不有用,但它确实有其好处。想象一下,我们正在构建一个可重用的组件,我们希望将其打包并交付给开发人员进行使用。如果组件具有预打包的服务依赖项,我们需要特别小心。如果这样的组件允许内容注入(内容子级),则如果在组件上使用基于提供程序的注册,依赖服务将被广泛暴露。任何内容子级都可以获取到服务依赖并使用它,导致不良后果。通过使用viewProvider注册依赖项,只有组件实现及其子视图才能访问依赖项,提供了必要的封装层。

我们再次对 DI 框架提供的灵活性和定制级别感到惊讶。虽然对于初学者来说可能有些吓人,但一旦我们开始使用 Angular 构建更多的组件/指令,我们总会发现这些概念让我们的实现变得更简单的地方。

让我们将注意力转向指令的第三种分类:结构指令

理解结构指令

虽然我们经常使用结构指令,比如NgIfNgFor,但很少需要创建结构指令。仔细考虑。如果我们需要一个新的视图,我们创建一个组件。如果我们需要扩展现有的元素/组件,我们使用指令。而结构指令最常见的用途是克隆视图的一部分(也称为模板视图),然后根据一些条件进行操作。

  • 将这些模板(NgIfNgSwitch)注入/销毁

  • 或者复制这些模板(NgFor

使用结构指令实现的任何行为都会无意中落入这两个类别之一。

鉴于这一事实,我们不必构建自己的结构指令,而是可以查看NgIf实现的源代码。

这是NgIf的完整实现:

@Directive({selector: '[ngIf]', inputs: ['ngIf']}) 
export class NgIf { 
  private _prevCondition: boolean = null; 

  constructor(private _viewContainer: ViewContainerRef,  
              private _templateRef: TemplateRef) {} 

  set ngIf(newCondition /* boolean */) { 
    if (newCondition && (isBlank(this._prevCondition)  
                     || !this._prevCondition)) { 
      this._prevCondition = true; 
      this._viewContainer 
          .createEmbeddedView(this._templateRef); 
    }  
    else if (!newCondition && (isBlank(this._prevCondition)  
                               || this._prevCondition)) { 
      this._prevCondition = false; 
      this._viewContainer.clear(); 
    } 
  } 
} 

这里没有魔法,只是简单的结构指令,用于检查布尔条件以创建/销毁视图!

prevCondition跟踪ngIf表达式的上一个值。与prevCondition的额外检查是为了确保只有在被监视的表达式(newCondition)实际翻转时,模板的添加/移除逻辑才会运行。当newConditionprevCondition都是truefalse时,什么也不会发生。

理解指令的工作原理并不困难。需要详细说明的是两个新的注入,ViewContainerRef(_viewContainer)TemplateRef(_TemplateRef)

TemplateRef

TemplateRef类(_templateRef)存储了结构指令所引用的模板的引用。还记得第二章中关于结构指令的讨论吗,构建我们的第一个应用程序 - 7 分钟锻炼?所有结构指令都使用模板 HTML。当我们使用诸如NgIf的指令时:

<h3 *ngIf="currentExercise.exercise.name=='rest'"> 
  ... 
</h3> 

Angular 在内部将此声明转换为以下内容:

<template [ngIf]="currentExercise.exercise.name=='rest'"> 
  <h3> ... </h3> 
</template> 

这是结构指令使用的模板,_templateRef指向这个模板。

注意

template是一个 HTML5 标签,用于保存不希望在浏览器中呈现的内容。template标签的默认样式设置为display:none

如果我们在 Angular 视图中手动添加模板标记,Angular 模板引擎将用空的<script></script>标记替换它们。

另一个注入是ViewContainerRef

ViewContainerRef

ViewContainerRef类指向模板呈现的容器。该类有许多方便的方法来管理视图。NgIf实现使用的两个函数createEmbeddedViewclear用于添加和移除模板 HTML。

createEmbeddedView函数获取模板引用(再次注入到指令中)并渲染视图。

clear函数销毁已注入的元素/组件并清除视图容器。由于模板(TemplateRef)中引用的每个组件及其子组件都被销毁,所有相关的绑定也将不存在。

结构指令有一个非常特定的应用领域。尽管如此,我们可以使用TemplateRefViewContainerRef类做很多巧妙的技巧。

我们可以实现一个结构指令,根据用户角色显示/隐藏视图模板。

考虑一个假设的结构指令forRoles的示例:

<button *forRoles="admin">Admin Save</button> 

如果用户不属于管理员角色,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类还有一些其他函数,允许我们注入组件,获取嵌入视图的数量,重新排序视图等等。查看ViewContainerRef的框架文档(bit.ly/view-container-ref)以获取更多详细信息。

这完成了我们对结构指令的讨论,现在是时候开始新的内容了!

到目前为止,我们构建的组件从公共bootstrap 样式表app.css中定义的一些自定义样式中获取其样式(CSS)。Angular 在这方面有更多的提供。一个真正可重用的组件应该在行为和用户界面方面完全自包含。

组件样式和视图封装

Web 应用程序开发长期存在的问题是在 DOM 元素行为和样式方面缺乏封装。我们无法通过任何机制将应用程序 HTML 的一部分与另一部分隔离开来。

事实上,我们拥有太多的权力。使用像 jQuery 和强大的CSS 选择器这样的库,我们可以控制任何 DOM 元素并改变其行为。在能够访问的方面,我们的代码和任何外部库的代码之间没有区别。每一行代码都可以操纵渲染的 DOM 的任何部分。因此,封装层被打破了。一个糟糕编写的库可能会导致一些难以调试的恶心问题。

对 CSS 样式也是如此。任何 UI 库实现都可以覆盖全局样式,如果库实现希望这样做的话。

这些都是任何库开发人员在构建可重用库时面临的真正挑战。一些新兴的 Web 标准尝试通过提出诸如web 组件等概念来解决这个问题。

Web 组件,简单来说,是可重用的用户界面小部件,它们封装了它们的状态样式用户界面行为。功能通过明确定义的 API 公开,用户界面部分也被封装起来。

web 组件概念是通过四个标准实现的:

  • HTML 模板

  • Shadow DOM

  • 自定义元素

  • HTML 导入

在这次讨论中,我们感兴趣的技术标准是Shadow DOM

Shadow DOM 概述

Shadow DOM就像是一个并行的 DOM 树,托管在组件内部(一个 HTML 元素, 不要与 Angular 组件混淆),隐藏在主 DOM 树之外。除了组件本身,应用程序的任何部分都无法访问这个影子 DOM。

Shadow DOM 标准的实现允许视图、样式和行为封装。理解 Shadow DOM 的最佳方法是查看 HTML5 的videoaudio标签。

你是否曾想过这个audio声明:

<audio src="/static/audio/nextup.mp3" controls></audio> 

产生以下结果?

Shadow DOM 概述

是浏览器生成了底层的 Shadow DOM 来渲染音频播放器。令人惊讶的是,我们甚至可以查看生成的 DOM!以下是我们的操作方法:

  • 取上述 HTML,创建一个虚拟 HTML 页面,并在 Chrome 中打开。

  • 然后打开开发者工具窗口(F12)。点击左上角的设置图标。

  • 常规设置中,点击复选框,如下面截图中所示,以启用对 Shadow DOM 的检查:

Shadow DOM 概述

刷新页面,如果现在检查生成的audio HTML,Shadow DOM 就会显示出来:

Shadow DOM 概述

shadow-root下,有一个全新的世界,页面和脚本的其他部分无法访问。

注意

在 Shadow DOM 领域,shadow-root(在上述代码中为#shadow-root)是生成的 DOM 的根节点,托管在shadow host(在本例中是audio标签)内。

当浏览器渲染此元素/组件时,渲染的是shadow root中的内容,而不是shadow host

从这次讨论中,我们可以得出结论,Shadow DOM 是浏览器创建的并封装了 HTML 元素的markupstylebehavior(DOM 操作)的并行 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 Runner7 分钟锻炼)应用程序,我们构建的所有组件都从bootstrap CSS和在app.css中定义的自定义样式中继承其样式。没有一个组件定义了自己的样式。

虽然这符合 Web 应用程序开发的标准实践,但有时我们确实需要偏离。特别是当我们构建自包含、打包和可重用的组件时。

Angular 允许我们通过在@Component装饰器上使用style(用于内联样式)和styleUrl(外部样式表)属性来定义特定于组件的样式。让我们尝试一下style属性,看看 Angular 会做什么。

我们将使用AjaxButtonComponent的实现作为下一个练习的操场。但在这之前,让我们看看AjaxButtonComponent的 HTML 目前是什么样子的。AjaxButtonComponent的 HTML 树如下所示:

Shadow DOM 和 Angular 组件

让我们使用styles属性覆盖一些样式:

@Component({ 
  ... 
  styles:[` 
    button { 
      background: green; 
    }`] 
}) 

上述CSS 选择器background属性设置为green,适用于所有 HTML 按钮。保存上述样式并刷新工作构建器页面。按钮样式已更新。没有惊喜?不是真的,还有一些!看一下生成的 HTML:

Shadow DOM 和 Angular 组件

一些新属性被添加到许多 HTML 元素中。而最近定义的样式又在哪里?就在head标签内的最顶部。

Shadow DOM 和 Angular 组件

head部分定义的样式具有额外的范围,带有_ngcontent-eaq-14属性(在您的情况下可能会有不同的属性名称)。这种范围允许我们独立地为AjaxButtonComponent设置样式,它不能覆盖任何全局样式。

根据定义,应该影响应用程序中所有按钮外观的样式没有产生影响。Angular 已经对这些样式进行了范围限制。

注意

这种范围确保组件样式不会干扰已定义的样式,但反之则不成立。全局样式仍然会影响组件,除非在组件本身中进行覆盖。即使我们使用styleUrls属性,Angular 也会执行相同的操作。假设我们将相同的 CSS 嵌入到外部 CSS 文件中并使用了这个:styleUrls:['static/css/ajax-button.css'],Angular 仍然会将样式内联到head部分,通过获取 CSS,解析它,然后注入它。

这种作用域样式是 Angular 试图模拟 Shadow DOM 范例的结果。在组件上定义的样式永远不会泄漏到全局样式中。所有这些都是无需任何努力的神奇效果!

提示

如果您正在构建定义自己样式并希望具有一定隔离性的组件,请使用组件的style/styleUrl属性,而不是使用旧式方法,即为所有样式使用一个公共 CSS 文件。

我们可以通过使用@Component装饰器属性encapsulation来进一步控制这种行为。关于这个属性的 API 文档提到:

封装:ViewEncapsulation

指定模板和样式应如何封装。如果视图有样式,则默认为ViewEncapsulation.Emulated,否则为ViewEncapsulation.None

正如我们所看到的,一旦在组件上设置样式,封装效果就是Emulated。否则,它是None

注意

如果我们将encapsulation明确设置为ViewEncapsulation.None,则范围属性将被移除,并且样式将像普通样式一样嵌入到 head 部分。

然后还有第三个选项,ViewEncapsulation.Native,在这种情况下,Angular 实际上为组件视图创建了 Shadow DOM。将AjaxButtonComponent实现上的encapsulation属性设置为ViewEncapsulation.Native,现在看一下渲染的 DOM:

Shadow DOM 和 Angular 组件

AjaxButtonComponent现在有了一个影子 DOM!这也意味着按钮的完整样式丢失了(样式来自 bootstrap CSS),按钮现在需要定义自己的样式。

Angular 非常努力地确保我们开发的组件可以独立工作并且可重用。每个组件已经有了自己的模板和行为。除此之外,我们还可以封装组件样式,从而创建健壮的、独立的组件。

这就是本章的结束,是时候总结一下我们学到了什么了。

总结

随着我们结束这一章,我们现在对指令的工作原理和如何有效使用它们有了更好的理解。

我们通过构建一个RemoteValidatorDirective开始了这一章,并且学到了很多关于 Angular 对异步验证的支持。

接下来是BusyIndicatorDirective,同样是一个很好的学习机会。我们探索了renderer服务,它允许以一种与平台无关的方式来操作组件视图。我们还学习了host bindings,它让我们能够绑定到宿主元素的事件属性属性

Angular 允许在视图谱系中声明的指令被注入到谱系内部。我们专门花了一些时间来理解这种行为。

我们创建的第三个指令(组件)是AjaxButtonComponent。它帮助我们理解了组件的内容子元素视图子元素之间的关键区别。

我们还涉及了结构性指令,其中我们探讨了NgIf平台指令。

最后,我们看了一下 Angular 在视图封装方面的能力。我们探讨了 Shadow DOM 的基础知识,并学习了框架如何运用 Shadow DOM 范式来提供视图加样式的封装。

下一章将全面讨论测试 Angular 应用程序,这是完整框架提供的一个关键部分。Angular 框架是以可测试性为目标构建的。框架构造和工具支持使得在 Angular 中进行自动化测试变得容易。关于这个内容我们会在下一章详细讨论……

第七章:测试个人教练

除非您是一个完美编码的超级英雄,否则您需要测试您构建的内容。此外,除非您有大量的空闲时间来一遍又一遍地测试您的应用程序,否则您需要一些测试自动化。

当我们说 Angular 是考虑到可测试性而构建的,我们是真的。它有一个强大的依赖注入DI)框架,一些很好的模拟构造,以及使在 Angular 应用程序中进行测试成为一项富有成效的工作的出色工具。

本章主要讨论测试,并致力于测试我们在本书中构建的内容。我们测试从组件到管道、服务和应用程序指令的所有内容。

本章涵盖的主题包括:

  • 理解全局视角:我们将尝试理解测试如何适应 Angular 应用程序开发的整体背景。我们还将讨论 Angular 支持的测试类型,包括单元测试和端到端E2E)测试。

  • 工具和框架概述:我们涵盖了帮助在 Angular 中进行单元测试和端到端测试的工具和框架。这些包括KarmaProtractor

  • 编写单元测试:您将学习如何使用 Jasmine 和 Karma 在浏览器中进行单元测试。我们将对前几章中构建的内容进行单元测试。本节还教会我们如何对各种 Angular 构造进行单元测试,包括管道、组件、服务和指令。

  • 创建端到端测试:自动化的端到端测试通过模拟浏览器自动化来模仿实际用户的行为。您将学习如何使用 Protractor 结合 WebDriver 执行端到端测试。

让测试开始吧!

注意

本章的代码可以在checkpoint7.1找到。它可以在 GitHub 上(github.com/chandermani/angular2byexample)下载。检查点在 GitHub 中作为分支实现。

如果您不使用 Git,请从此 GitHub 位置下载checkpoint7.1的快照(ZIP 文件):github.com/chandermani/angular2byexample/archive/checkpoint7.1.zip。首次设置快照时,请参考trainer文件夹中的README.md文件。

自动化的需求

随着每一天过去,为 Web 构建的应用程序的规模和复杂性正在增长。我们现在有太多的选项来构建 Web 应用程序,这简直令人难以置信。再加上产品/应用程序的发布周期已经从几个月缩短到几天,甚至一天内发布多个版本!这给软件测试带来了很大的负担。有太多需要测试的内容。多个浏览器,多个客户端和屏幕尺寸(桌面和移动),多种分辨率等等。

要在如此多样化的环境中发挥作用,自动化是关键。应该自动化一切可以自动化应该成为我们的口头禅。

在 Angular 中的测试

Angular 团队意识到了可测试性的重要性,因此创建了一个框架,允许在其上构建的应用程序进行轻松的(自动化)测试。使用 DI 构造进行依赖注入的设计选择有助于这一点。随着本章的进展,当我们为我们的应用程序构建大量测试时,这一点将变得清晰。然而,在此之前,让我们了解在构建此平台上的应用程序时,我们所针对的测试类型。

测试类型

对于典型的 Angular 应用程序,我们进行了两种广义上的测试:

  • 单元测试:单元测试完全是关于在孤立环境中测试组件,以验证其行为的正确性。需要用模拟实现替换被测试组件的大多数依赖项,以确保单元测试不会因为依赖组件的失败而失败。

  • 端到端测试:这种类型的测试完全是关于像真正的最终用户一样执行应用程序,并验证应用程序的行为。与单元测试不同,组件不是孤立测试的。测试是针对在真实浏览器中运行的系统进行的,并且基于用户界面的状态和显示的内容进行断言。

单元测试是防止错误的第一道防线,我们应该能够在单元测试期间解决大部分问题。但除非进行了 E2E 测试,否则我们无法确认软件是否正常工作。只有当系统中的所有组件以期望的方式进行交互时,我们才能确认软件正常工作;因此,E2E 测试成为必要。

谁编写单元测试和 E2E 测试,以及它们何时编写,这些都是重要的问题需要回答。

测试 - 谁进行测试以及何时进行测试?

传统上,E2E 测试是由质量保证(QA)团队进行的,开发人员负责在提交代码之前对其进行单元测试。开发人员也会进行一定量的 E2E 测试,但整个 E2E 测试过程是手动的。

随着技术环境的变化,现代测试工具,特别是在 Web 前端,已经允许开发人员自己编写自动化的 E2E 测试,并针对任何部署设置(如开发/阶段/生产)执行这些测试。像 Selenium 这样的工具,连同 WebDrivers,可以轻松进行浏览器自动化,从而便于编写和执行针对真实网络浏览器的 E2E 测试。

写 E2E 场景测试的好时机是在开发完成并准备部署时。

在单元测试方面,有不同的观点认为测试应该在何时编写。TDDer 在功能实现之前编写测试。其他人在实现完成后编写测试以确认行为。有些人在开发组件本身时编写测试。选择适合自己的风格,记住你写测试的越早,越好。

注意

我们不打算提出任何建议,也不打算就哪种更好进行争论。任何数量的单元测试都比没有好。

我们个人偏好使用“中间方法”。在 TDD 中,我们觉得测试创建的努力有时会因为规格/需求的变化而丧失。在开始时编写的测试容易因需求的变化而不断修复。

在最后编写单元测试的问题在于我们的目标是创建根据当前实现通过的测试。编写的测试是为了测试实现而不是规格。

在中间某个地方添加测试对我们来说效果最好。

现在让我们试着了解可用于 Angular 测试的工具和技术环境。

Angular 测试生态系统

看下面的图表以了解支持 Angular 测试的工具和框架:

Angular 测试生态系统

正如我们所看到的,我们使用 Jasmine 或 Mocha 等单元测试库编写我们的测试。

注意

目前,Angular 测试库默认使用 Jasmine。然而,Angular 团队表示他们已经将框架更加通用化,以便您可以使用其他测试库,比如 Mocha。Angular 文档尚未更新以包括如何做到这一点。

这些测试由 Karma 或 Protractor 执行,具体取决于我们是在编写单元测试还是集成测试。这些测试运行器又会在浏览器中运行我们的测试,比如 Chrome、Firefox 或 IE,或者无头浏览器,比如 PhantomJS。重要的是要强调的是,不仅 E2E 测试,单元测试也是在真实浏览器中执行的。

注意

除了浏览器,完整的 Angular 测试设置是由 Node.js 生态系统提供的出色库和框架支持的。一些库,比如 Jasmine,也有独立版本,但我们将坚持使用 Node.js 包。

本章中的所有测试都是使用 Jasmine 编写的(包括单元测试和集成测试)。Karma 将是我们的单元测试运行器,Protractor 将是我们的 E2E 测试运行器。

开始进行单元测试

单元测试的最终目的是测试特定的代码/组件,以确保组件按照规范工作。这减少了与软件其他部分集成时组件出现故障/错误的机会。在我们开始编写测试之前,有一些指导方针可以帮助我们编写良好且易于维护的测试:

  • 一个单元应该测试一个行为。出于显而易见的原因,每个单元测试一个行为是有意义的。失败的单元测试应该清楚地突出问题区域。如果多个行为一起测试,失败的测试需要更多的探究来确定违反了哪个行为。

  • 单元测试中的依赖项应该被模拟掉。单元测试应该测试单元本身,而不是它的依赖项。

  • 单元测试不应该永久改变被测试组件的状态。如果发生这种情况,其他测试可能会受到影响。

  • 单元测试的执行顺序应该是无关紧要的。一个单元测试不应该依赖于另一个单元测试在它之前执行。这是脆弱单元测试的迹象。这也可能意味着依赖项没有被模拟。

  • 单元测试应该快速。如果它们不够快,开发人员就不会运行它们。这是模拟所有依赖项的一个很好的理由,比如数据库访问、远程网络服务调用等。

  • 单元测试应该尝试覆盖所有代码路径。代码覆盖率是一个可以帮助我们评估单元测试效果的度量标准。如果在测试过程中覆盖了所有正面和负面情况,覆盖率确实会更高。这里需要注意:高代码覆盖率并不意味着代码是无错的,但低覆盖率明显突出了单元测试中未覆盖的区域。

  • 单元测试应该测试正面和负面情况。不要只集中在正面测试用例上;所有软件都可能出现故障,因此单元测试失败场景与成功场景一样重要。

这些准则并不特定于框架,但为我们提供了撰写良好测试所需的足够支持。让我们通过设置所需的组件开始单元测试的过程。

为单元测试设置 Karma

由于完整的测试自动化基础设施是使用 Node 支持的,因此首先需要安装 Node。请按照 Node 网站(nodejs.org)上的说明进行安装。

Node 自带一个名为Node Package ManagerNPM)的包管理器,用于安装测试所需的所有其他组件(Node 世界中的)。

从命令行开始安装 Karma。导航到应用程序代码库的根目录(在trainer文件夹中),并使用以下命令安装 Karma:

npm install karma --save-dev 

要从命令行使用 Karma,我们需要安装其命令行界面:

npm install -g karma-cli 

注意

代码被测试的 Karma 版本是 0.13.22。karma-cli版本是 1.0.1。

要安装特定版本的包,需要在包名后面加上@version,例如,npm install [email protected] --save-dev

这完成了 Karma 的安装,现在是时候配置测试运行器了。配置 Karma 就是设置其配置文件,以便其中包含足够的细节来运行我们的脚本并对其进行测试。在根目录(在trainer文件夹中)创建一个tests文件夹,导航到该文件夹,并使用以下命令开始 Karma 配置设置:

karma init 

这将启动一个命令行向导,指导我们选择可用的选项,包括测试框架、要监视的文件夹和其他设置。向导完成后,它会生成一个karma.conf.js文件。不要使用生成的配置文件,而是将伴随代码库中checkpoint7.1/testskarma.conf.js文件复制到本地的tests文件夹中。

注意

karma init向导根据我们的选择安装了一些包。如果我们跳过向导,那么karma-chromelauncherkarma-jasmine包需要手动安装以进行单元测试。

接下来,我们将安装 Jasmine。因此执行以下命令:

npm install jasmine-core --save-dev --save-exact 

Karma 配置文件

tests目录中打开karma.conf.js。它包含影响我们运行的测试的设置。我们不会在这里涵盖 Karma 支持的每个配置,但会专注于对我们的测试设置是独特和/或必需的配置。请参考 Karma 文档(karma-runner.github.io/1.0/config/configuration-file.html)了解更多关于各种 Karma 配置选项的信息。

首先,我们需要设置解析文件位置的基本路径。在我们的情况下,因为我们将 Karma 放在了应用程序根目录下方的tests文件夹中,我们将使用一个相对 URL 来指向该位置:

basePath: '../', 

然后我们将框架设置为 Jasmine:

frameworks: ['jasmine'], 

下一个 Karma 配置是files数组。文件可以通过文件路径或使用模式引用。在我们的情况下,我们使用文件路径引用前十个文件。这些是用于 polyfills、SystemJS 和 zone.js 的文件。当 Karma 首次启动时,它将使用<script>标签在浏览器中加载所有这些文件。

对于其余部分,我们使用模式,因为这种方法允许我们设置includedwatched属性。included属性确定 Karma 是否最初加载文件。如果设置为true,Karma 将在<script>标签中加载这些文件。如果不是,Karma 将不加载文件,因此必须找到其他机制来完成。watched属性指示文件是否将被监视以进行更改。

当您查看我们配置中的文件模式时,您会注意到一些您以前见过的用于构建我们的应用程序的文件,例如 RxJS 和 Angular 本身。我们已将included属性设置为false,除了其中一个文件。

included属性被设置为true的一个文件模式是我们以前没有见过的:karma-test-shim.js

{pattern: 'tests/karma-test-shim.js', included: true, watched: true}, 

这个模式引用了一个我们添加到测试文件夹中的额外文件,用于处理在 Karma 中运行 Angular 测试的特殊要求,并且在 Karma 首次启动时将被加载。我们将很快讨论该文件。

我们所有文件中(除了 RxJS 和支持调试的文件)的watched属性都设置为 true,这意味着 Karma 将监视这些文件,如果它们中的任何一个发生变化,将重新运行我们的测试。

karma-test-shim.js之后数组中的下一个文件模式与其他文件有些不同:

{pattern: 'dist/**/*.js', included: false, watched: true}, 

这个模式包含了我们应用程序文件的路径(我们在前几章中一直在构建的代码),以及我们将创建的测试文件的路径。

但请注意,它指向的是dist目录,该目录包含 TypeScript 转译器生成的 JavaScript 文件,而不是 TypeScript 文件本身。这是有道理的,因为 Karma 当然是一个 JavaScript 测试运行器(不会自己转译我们的 TypeScript 文件)。这反过来意味着我们需要一个初始的转译步骤来运行我们的测试。

回到我们将included属性设置为 false 的文件,如果我们不使用 Karma,我们如何加载它们?正如你所知,我们正在使用 SystemJS 来加载我们的模块,而 Karma 对 SystemJS 一无所知。如果 Karma 在相关模块加载之前运行我们的测试文件,我们的测试将失败。这意味着对于这些文件,我们需要在 Karma 运行之前运行 SystemJS 并加载我们的模块。将included属性设置为 false 意味着 Karma 不会自动运行这些文件。

但是,如果配置文件将这些文件的included属性设置为false,我们将如何运行这些文件中的测试?这个问题的答案将我们带到了 Karma 测试 shim 文件。

Karma 测试 shim 文件

tests目录中打开karma-test-shim.js。这个文件解决了使用 Karma 与 Angular 和 SystemJS 的挑战。

注意

Karma 测试 shim 文件目前使用 SystemJS 加载器,但根据 Angular 团队的说法,它可以适应其他东西,比如 Web Pack。

Karma 测试 shim 文件做了三件事,我们现在来讨论。

首先,它取消了 Karma 的同步启动。

__karma__.loaded = function() {}; 

然后它添加了 SystemJS 的配置:

System.config({ 
    baseURL: '/base' 
}); 

System.config( 
    { 
        paths: { 
            // paths serve as alias 
            'npm:': 'node_modules/' 
        }, 
        map: { 
            'app': 'dist', 
            '@angular/core': 'npm:@angular/core/bundles/core.umd.js', 
...[other Angular modules] ... 
            // angular testing umd bundles 
            '@angular/core/testing': 'npm:@angular/core/bundles/core-testing.umd.js', 
...[other Angular testing modules] ... 
            // other libraries 
            'rxjs': 'npm:rxjs', 
            'angular2-modal': 'npm:angular2-modal', 
        }, 
        packages: { 
            'app': { 
                defaultExtension: 'js' 
            }, 
            'rxjs': { 
                defaultExtension: 'js' 
            } 
        } 
    }); 

这应该已经很熟悉了。毕竟,我们已经在应用程序根目录的system.js.config文件中为 SystemJS 进行了这种配置。这个配置的作用是设置 Karma 将使用的模块的路径映射,包括 Angular 测试模块。我们已经修改了文件,使其指向我们应用程序中这个映射的正确位置。

注意

重要的是要理解,这个配置是用于在测试运行器中使用 SystemJS,而不是用于我们的基础应用程序。

最后,它使用 SystemJS 来导入我们的模块。如果承诺成功解析,它将启动 Karma,否则会抛出错误:

Promise.all([ 
    System.import('@angular/core/testing'), 
    System.import('@angular/platform-browser-dynamic/testing') 
]).then(function (providers) { 
    var testing = providers[0]; 
    var testingBrowser = providers[1]; 
    testing.TestBed.initTestEnvironment(testingBrowser.BrowserDynamicTestingModule, 
        testingBrowser.platformBrowserDynamicTesting()); 

}).then(function() { 
    // Finally, load all spec files. 
    // This will run the tests directly. 
    return Promise.all( 
        allSpecFiles.map(function (moduleName) { 
            return System.import(moduleName); 
        })); 
}).then(__karma__.start, __karma__.error); 

请注意,我们正在导入@angular/platform-browser-dynamic/testing并将特定于测试基于浏览器的应用程序的参数传递给TestBedinitTestEnvironment方法。我们将在本章后面详细讨论TestBed

有了这个文件,我们现在准备开始使用 Karma 进行 Angular 测试。虽然这个配置有点繁琐,但好消息是一旦我们完成了 Karma 的配置,Angular 会很容易地创建和运行单元测试,我们很快就会看到。

我们测试文件的组织和命名

为了对我们的应用进行单元测试,计划为项目中的每个 TypeScript 文件创建一个测试文件(例如workout-runner.spec.ts)。

注意

使用 Jasmine 进行测试的开发人员通常将测试文件命名为被测试文件的名称加上.spec,这是一种惯例。这也用于在我们之前概述的配置步骤中方便地将文件映射到测试中。

这个测试文件将包含相应组件的单元测试规范,如下面的截图所示(在运行我们的单元测试时在 Karma 调试器中拍摄):

我们测试文件的组织和命名

单元测试 Angular 应用程序

在本书的过程中,我们已经构建了覆盖 Angular 中的每个构造的组件。我们已经构建了组件、管道、一些服务,最后还有一些指令。所有这些都可以在单元测试中进行测试。

为了熟悉使用 Jasmine 进行单元测试,让我们先测试最小和最简单的组件:管道。

单元测试管道

管道是最容易测试的,因为它们对其他构造的依赖最少或者没有依赖。我们为Workout Runner7 Minute Workout应用程序)创建的SecondsToTimePipe没有依赖,可以很容易地进行单元测试。

注意

查看 Jasmine 框架文档,了解如何使用 Jasmine 编写单元测试。我们在单元测试中使用 Jasmine 2.0(jasmine.github.io/2.0/introduction.html)。

Jasmine 有一些最好的文档,并且整体框架非常直观易用。我们强烈建议您前往 Jasmine 网站,并在继续之前熟悉该框架。

trainer/src/components/shared文件夹中添加一个seconds-to-time.pipe.spec.ts文件,并将这个单元测试添加到其中:

import {SecondsToTimePipe} from "./seconds-to-time.pipe"; 
describe('SecondsToTime pipe', () => { 
    let pipe:SecondsToTimePipe; 
    beforeEach(() => { 
        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函数中包装测试,以标识测试。这个函数的第一个参数是测试的用户友好描述;在这种情况下,它是SecondsToTime pipe。对于第二个参数,我们传递一个 lambda(fat arrow)函数,它将包含我们的测试。在设置一个本地变量来保存管道之后,我们调用 Jasmine 的beforeEach函数,并使用它来注入我们的管道的一个实例。

注意

由于beforeEach函数在我们的describe函数中的每个测试之前运行,我们可以将其用于每个测试中运行的通用代码。在这种情况下,这并不是严格必要的,因为我们的describe函数中只有一个测试。但是,养成使用它进行常见设置场景的习惯是一个好主意,因为我们将会看到它在未来的使用中的好处。

接下来,我们调用 Jasmine 的it函数,并传递一个标题,以及对 Jasmine 的expect函数的三次调用(Jasmine 对断言的称呼)。这些都是不言自明的。

注意

在我们的测试中,不需要显式导入这些 Jasmine 函数。

运行我们的测试文件

现在是时候使用 Karma 运行我们的测试了。正如你记得的那样,为了做到这一点,我们首先必须将我们的文件从 TypeScript 转译成 JavaScript。为了做到这一点,我们将简单地在终端窗口中启动我们的应用程序本身,方法是调用:

gulp play 

应用程序的 gulp 文件将把我们的 TypeScript 文件转译成 JavaScript,然后监视这些文件的更改。

接下来,我们需要运行 Karma,我们通过在trainer文件夹中的一个单独的终端窗口中执行以下命令来实现:

karma start tests/karma.conf.js 

然后我们应该在终端窗口中看到这个输出:

运行我们的测试文件

最后一行显示我们的测试成功通过了。为了确保它报告了正确的通过/失败结果,让我们在测试中做出改变,导致其中一个期望失败。将第一个期望中的时间更改为 6 秒,而不是 5 秒,如下所示:

expect(pipe.transform(5, [])).toEqual('00:00:06'); 

我们得到以下错误消息:

运行我们的测试文件

这个错误消息的好处在于它将describeit的描述结合成一个完整的句子,提供了对错误的清晰摘要。这显示了 Jasmine 如何允许我们编写可读的测试,以便对我们的代码不熟悉的人可以快速理解可能出现的任何问题。下一行显示了哪个期望没有被满足,期望是什么,以及未满足这个期望的实际结果是什么。

此消息下面还有一个冗长的堆栈跟踪,以及一个显示我们测试整体结果的最后一行:

运行我们的测试文件

你会注意到的一件事是,当我们对测试进行更改时,我们不需要重新运行 Karma。相反,它会监视我们文件和相关测试的任何更改,并在我们进行更改时立即报告成功或失败。

非常酷!让我们撤消我们所做的最后一次更改,并将测试恢复到通过状态。

总之,我们将采取以下多步骤方法来执行所有我们的测试。首先,我们使用我们的 gulp 脚本将 TypeScript 文件转换为 JavaScript。然后我们调用 Karma 来针对这些 JavaScript 文件运行我们的测试。以下图表阐明了这些步骤:

运行我们的测试文件

我们将不会在我们覆盖的测试中重复这些步骤的描述。因此,请确保在我们在本节中探索的每个测试中都遵循这些步骤。现在让我们继续进行单元测试组件。

单元测试组件

测试 Angular 组件比测试简单的管道或服务更复杂。这是因为 Angular 组件与视图相关联,通常比服务、过滤器或指令具有更多的依赖关系。

Angular 测试工具

由于其复杂性,Angular 引入了一些工具,使我们能够更轻松地测试我们的组件。这些测试工具包括TestBed类(我们之前用来初始化测试)和@angular/core/testing中的几个辅助函数。

TestBed有一个createComponent方法,返回一个包含多个成员和方法的ComponentFixture

  • debugElement:用于调试组件

  • componentInstance:用于访问组件的属性和方法

  • nativeElement:用于访问视图的标记和其他 DOM 元素

  • detectChanges:用于触发组件的变化检测周期

ComnponentFixture还包含用于覆盖组件的视图、指令、绑定和提供者的方法。在接下来的测试中,我们将在整个测试过程中使用TestBed

TestBed有一个名为configureTestingModule的方法,我们可以使用它来设置我们的测试作为自己的模块。这意味着我们可以绕过初始的引导过程,并在我们的测试文件中编译我们要测试的组件。我们还可以使用TestBed来指定额外的依赖关系,并确定我们将需要的提供者。

注意

根据 Angular 文档,TestBed在每个规范文件运行之前都会重置基本状态。这个基本状态包括可声明的内容(组件、指令和管道)和提供者(其中一些是模拟的),几乎每个组件都需要运行。参见angular.io/docs/ts/latest/guide/testing.html#!#atu-intro

在我们的测试中管理依赖关系

Angular 中的组件将视图与其他所有内容集成在一起。因此,与任何服务、过滤器或指令相比,组件通常具有更多的依赖关系。

尽管我们的单元测试侧重于组件本身的代码,但我们仍需要在测试中考虑这些依赖关系,否则测试将失败(我们跳过了管道测试的依赖设置,因为它没有外部依赖)。

处理这些依赖项存在两种方法:将它们注入到我们的组件中,或者为它们创建一个我们可以在测试中使用的模拟或伪造品。如果一个依赖项足够简单,我们可以将其实例直接注入到我们的测试类中。然而,如果依赖项非常复杂,特别是如果它有自己的依赖项和/或进行远程服务器调用,那么我们应该对其进行模拟。Angular 测试库为我们提供了这样做的工具。

我们计划在本节中测试的组件是WorkoutRunner组件。位于trainer/src/components/workout-runner/内,这是运行特定锻炼的组件。

单元测试 WorkoutRunnerComponent

有了这个背景,让我们开始单元测试WorkoutRunnerComponent

首先,添加一个新文件workout-runner-component.spec.ts,其中包含以下导入:

import { inject, fakeAsync, async, tick, TestBed, ComponentFixture} from '@angular/core/testing'; 
import { NO_ERRORS_SCHEMA }          from '@angular/core'; 
import {Router} from '@angular/router'; 
import {Observable} from "rxjs/Rx"; 

import {WorkoutHistoryTracker} from '../../services/workout-history-tracker'; 
import {WorkoutRunnerComponent} from './workout-runner.component'; 
import {WorkoutService} from '../../services/workout-service'; 
import {Exercise, WorkoutPlan, ExercisePlan} from "../../services/model"; 
import {SecondsToTimePipe} from "../shared/seconds-to-time.pipe"; 

这些导入标识了我们在测试中将使用的测试工具(以及来自RxJSRouterObservable等内容),以及我们组件所需的类型和依赖项。我们将在一会儿讨论这些依赖关系。一个与其他不同的导入是从@angular/core导入NO_ERRORS_SCHEMA的导入。我们将使用这个导入来忽略我们不会测试的组件中的元素。同样,我们将在一会儿进一步讨论这一点。

另一个需要注意的是,导入中的@angular/core/testing是核心模块的一部分,而不是单独的测试模块。这是 Angular 测试导入的常见模式。例如,当我们开始测试 HTTP 时,您将看到我们从@angular/http/testing导入。

设置组件依赖项

接下来,我们需要建立组件的依赖关系,并确定我们是否需要注入或模拟它们。如果我们查看WorkoutRunner组件的代码,我们会发现有三个依赖项被注入到我们的组件中:

  • WorkoutHistoryTracker:这是一个附有一些行为的组件。因此,我们肯定希望对其进行模拟。

  • Router:我们也必须模拟这个,以便将WorkoutRunner与应用程序的其余部分隔离开,并防止我们的测试尝试从WorkoutRunner视图中导航离开。

  • WorkoutService:这是一个我们将使用来进行 HTTP 调用以检索我们的锻炼的服务。我们也将模拟这个服务,因为我们不希望在我们的测试中对外部系统进行调用。

模拟依赖 - workout history tracker

Angular 允许我们使用简单的类以直接的方式模拟我们的依赖关系。让我们从模拟WorkoutHistoryTracker开始。为此,请在导入之后添加以下类:

class MockWorkoutHistoryTracker { 
    startTracking() {} 
    endTracking() {} 
    exerciseComplete() {} 
} 

我们不需要模拟整个WorkoutHistoryTracker类,而只需要模拟WorkoutRunner将要调用的方法。在这种情况下,这些方法是startTracking()endTracking()exerciseComplete()。我们将这些方法设置为空,因为我们不需要从中返回任何东西来测试WorkoutRunner。现在我们可以将这个虚拟实现注入到WorkoutRunner中,无论它在哪里寻找WorkoutHistoryTracker

模拟依赖 - workout service

在第五章中,支持服务器数据持久性,我们扩展了 workout service 以进行远程调用以检索填充 workout 的数据。对于单元测试 workout runner,我们将希望用返回一些静态数据的模拟实现来替换该调用,以便我们可以用来运行测试。因此,我们将添加第三个模拟类,如下所示:

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 Observable.of(this.sampleWorkout); 
    } 
    totalWorkoutDuration(){ 
        return 180; 
    }; 
    export class MockRouter {
    navigate = jasmine.createSpy('navigate');
    }
} 

请注意,getWorkout方法返回一个Observable。否则,该类是不言自明的。

模拟依赖 - router

WorkoutHistoryTrackerWorkoutService一样,我们也将使用模拟来处理我们对 Angular 路由器的依赖。但是在这里,我们将采取稍微不同的方法。我们将在我们的模拟上分配一个 jasmine spy 给一个 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测试时一样。有关更多信息,请参阅angular.io/docs/ts/latest/guide/testing.html#!#async-in-before-each。我们很快会更详细地讨论这个问题。

让我们按照它们执行的顺序逐个讨论每个方法调用。第一个方法configureTestingModule允许我们在测试模块的基本配置上构建,并添加诸如导入、声明(我们将在测试中使用的组件、指令和管道)和提供者等内容。在我们的测试中,我们首先添加了 workout runner,我们的组件,和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方法(同步的)使用来创建我们的组件 fixture。这个组件 fixture 反过来包含了一个componentInstance-WorkoutRunner。然后我们将fixturecomponentInstance都分配给本地变量。

如前所述,我们正在使用的async函数创建了一个特殊的async测试区域,在这个区域中我们的测试将运行。您会注意到,这个函数与普通的async编程相比更简化,让我们可以做一些事情,比如使用.then操作符而不返回一个 promise。

注意

您也可以在单独的测试方法中编译和实例化测试组件。但是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.conf.js文件中的autoWatch属性为 true,保存这个测试会自动触发测试执行。但是这个测试失败了(查看 Karma 控制台)。奇怪!所有依赖项都已正确设置,但it块的第二个 expect 函数失败了,因为它是未定义的。

我们需要调试这个测试。

在 Karma 中调试单元测试

在 Karma 中调试单元测试很容易,因为测试是在浏览器中运行的。我们调试测试就像调试标准的 JavaScript 代码一样。而且由于我们的 Karma 配置已经将 TypeScript 文件映射到 JavaScript 文件,我们可以直接在 TypeScript 中调试。

Karma 启动时,它会打开一个特定的浏览器窗口来运行测试。要在 Karma 中调试任何测试,我们只需要点击浏览器窗口顶部的调试按钮。

注意

Karma 打开了一个窗口,当我们点击调试时会打开另一个窗口;我们也可以使用原始窗口进行测试,但原始窗口连接到 Karma 并进行实时重新加载。此外,原始窗口中的脚本文件是有时间戳的,每当我们更新测试时时间戳都会改变,因此需要再次设置断点进行测试。

一旦我们点击调试,一个新的标签/窗口将打开,加载了所有测试和其他应用程序脚本进行测试。这些脚本是在karma.conf.js文件部分的 Karma 配置设置期间定义的。

要调试前面的失败,我们需要在两个位置添加断点。一个应该在测试本身内部添加,另一个应该在Workoutcomponent内部添加,在那里它加载了锻炼并将数据分配给适当的本地变量。

执行以下步骤在 Google Chrome 中添加断点:

  1. 通过点击 Karma 启动时加载的窗口上的调试按钮打开 Karma 调试窗口/标签。

  2. 按下F12键打开开发者控制台。

  3. 转到Sources标签,您的应用程序的 TypeScript 文件将位于source文件夹中。

  4. 现在我们可以通过点击行号在需要的位置设置断点。这是调试任何脚本的标准机制。在这里突出显示的位置添加断点:在 Karma 中调试单元测试

  5. 刷新调试页面(我们点击调试按钮时打开的页面)。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确认了对第一个练习的调用已经执行。

这里有几点需要注意。首先,您必须小心在调用ngOnInit之前设置spyOn的设置。否则,当调用startExercise方法时,间谍将不会监听,并且方法调用将不会被捕获。

其次,由于间谍是一个模拟,我们通常无法验证startExercise方法内部的任何内容。这是因为方法本身被模拟了。这意味着我们实际上无法验证currentExercise属性是否已经设置,因为这是在模拟方法内部完成的。然而,Jasmine 允许我们将间谍与.and.callThrough链接,这意味着除了跟踪方法的调用之外,它还将委托给实际的实现。这样我们就可以测试startExercise方法内部currentExercise是否也已经被正确设置。

使用 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 分钟锻炼中,我们详细描述了事件发射器在 WorkoutRunner 中的使用。因此,我们对它们的作用有很好的理解。但是我们如何对我们的事件发射器进行单元测试,并确定它们是否按我们的预期触发事件呢?

实际上做起来相当容易。如果我们记得事件发射器是一个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/docs/ts/latest/guide/testing.html#!#async,标题为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); 
    TestHelper.advanceWorkout(7); 
    expect(runner.exerciseRunningDuration).toBe(10); 
    runner.ngOnDestroy(); 
})); 

除了注入WorkoutRunner之外,我们首先在fakeAsync中包装测试。然后我们调用WorkoutRunnerngOnit()方法。这会启动WorkoutRunner中的练习计时器。然后在测试中,我们使用tick()函数在不同的持续时间内测试计时器的运行情况,并确保它在我们预期的持续时间内继续运行。使用tick()允许我们快进代码,避免异步运行代码时需要等待几秒钟才能完成练习。

注意

你会注意到这里使用了一个辅助方法advanceWorkout。这个方法处理了一个异常,即如果传递给 tick 的参数不是1000,似乎会出现异常。

最后,我们调用WorkoutRunnerngOnDestroy()方法来清除任何未决的计时器。

让我们尝试另一个类似的测试。我们要确保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); 
    runner.ngOnDestroy();
})); 

再次在fakeAsync中包装测试,并调用runner.ngOnInit来启动计时器。然后我们获取第一个练习的持续时间,并使用tick()函数将计时器提前 1 秒超过该练习的持续时间。接下来,我们测试我们现在处于休息练习中的期望,因此已经从第一个练习过渡过来。

测试练习暂停和恢复

当我们暂停练习时,它应该停止,时间计数器不应该流逝。为了检查这一点,添加以下时间测试:

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); 
    runner.ngOnDestroy(); 
})); 

测试从验证工作状态不是暂停开始,时间提前 1 秒,暂停,然后验证暂停后workoutTimeRemaining的时间没有改变。

单元测试服务

服务的单元测试与组件的单元测试并没有太大的不同。一旦我们掌握了如何设置组件及其依赖项(主要是使用模拟对象),将这种学习应用到测试服务就成了例行公事。往往,挑战在于设置服务的依赖项,以便有效地进行测试。

对于进行远程请求的服务(使用httpjsonp),情况有些不同。在我们可以单独测试这些服务之前,需要进行一些设置。

我们将针对WorkoutService编写一些单元测试。由于这个服务会进行远程请求来加载锻炼数据,我们将探讨如何使用模拟 HTTP 后端来测试这样的服务。Angular 为我们提供了MockBackendMockConnection来实现这一点。

使用 MockBackend 模拟 HTTP 请求/响应

在测试服务(或者实际上是任何其他 Angular 构造)进行远程请求时,显然我们不希望实际向后端发出请求来检查行为。那甚至都不符合单元测试的资格。后端交互只需要被模拟掉。Angular 提供了一个专门用于此目的的服务:MockBackend!使用MockBackend,我们拦截 HTTP 请求,模拟来自服务器的实际响应,并断言端点的调用。

创建一个名为workout-service.spec.ts的新文件,并在文件顶部添加以下导入语句:

import {addProviders, fakeAsync, inject, tick} from '@angular/core/testing'; 
import {BaseRequestOptions, Http, Response, ResponseOptions} from '@angular/http'; 
import {MockBackend, MockConnection} from '@angular/http/testing'; 
import {WorkoutService} from './workout-service'; 
import {WorkoutPlan} from "./model"; 

除了从testing模块导入的内容,我们还从http模块和http/testing模块中导入了MockBackendMockConnection。我们还导入了将要进行测试的WorkoutServiceWorkoutPlan

一旦我们导入就位,我们将开始使用 Jasmine 的describe语句创建测试,同时设置几个本地变量:

describe('Workout Service', () => { 
    let collectionUrl:string = "...[mongo connnection url]..."; 
    let apiKey:string = "...[mongo key]..."; 
    let params:string = '?apiKey=' + apiKey; 
    let workoutService:WorkoutService; 
    let mockBackend:MockBackend; 

除了为WorkoutServiceMockBackend创建本地变量之外,您还会注意到我们为 Mongo 连接设置了本地变量。需要明确的是,我们设置这些变量不是为了对 Mongo 进行远程调用,而是为了测试连接属性是否被正确设置。

下一步是为我们的测试设置提供者和依赖注入。要处理提供者,请在测试文件中添加以下内容:

beforeEach(() => { 
    addProviders([ 
        MockBackend, 
        BaseRequestOptions, 
        { 
            provide: Http, 
            useFactory: (backend:MockBackend, 
            options:BaseRequestOptions) => { 
                return new Http(backend, options); 
            }, 
            deps: [MockBackend, BaseRequestOptions] 
        }, 
        WorkoutService 
    ]) 
}); 

毫不奇怪,我们正在添加MockBackEndWorkoutService作为提供者。与此同时,我们还从 http 模块中添加了BaseRequestOptions。然后我们添加了一个使用MockEndBaseRequestOptions的工厂的 HTTP 提供者。这个工厂将返回一个使用MockBackendHttp服务。因此,现在我们可以从我们的测试中发出一个 HTTP 调用,而不是远程调用,而是使用MockBackEnd来模拟这个调用。

为了完成我们测试的设置,我们添加以下内容来为每个测试注入依赖项:

beforeEach(inject([WorkoutService, MockBackend], (service:WorkoutService, backend:MockBackend) => { 
    workoutService = service; 
    mockBackend = backend 
})); 

有了这个设置,我们现在可以为 workout-service 创建测试,避免进行远程调用。我们将从一个简单的测试开始,确保workoutService加载:

it("should load Workout service", () => { 
    expect(workoutService).toBeDefined(); 
}); 

虽然这个测试看起来可能微不足道,但将其放在这里很重要,因为它作为一个检查,确保我们已经正确设置了配置。

接下来,我们将测试 workout-service 中的几种方法。首先,我们将确保在调用 get Workouts 方法时返回所有的锻炼。为此,添加以下测试:

it("should return all workout plans", fakeAsync(() => { 
    let result:any; 
    mockBackend.connections.subscribe((connection:MockConnection) => { 
      expect(connection.request.url).toBe(collectionUrl + "/workouts" + 
      params); 
      let response = new ResponseOptions({body: '[{ "name": "Workout1", 
      "title": "workout1" }, { "name": "Workout1", "title": "workout1" 
      }]'}); 
        connection.mockRespond(new Response(response)); 
    }); 
    workoutService.getWorkouts().subscribe((response:Response) => { 
        result = response; 
    }); 
    expect(result.length).toBe(2); 
    expect(result[0] instanceof WorkoutPlan).toBe(true); 
})); 

请注意,我们正在使用fakeAsync来同步运行本来应该是异步 HTTP 调用的代码。

注意

请注意,如果我们使用 XHR 进行真正的 HTTP 调用,这种方法可能会有问题。请参阅github.com/angular/angular/issues/8280。但在这里,我们并没有在我们的模拟中进行真正的 XHR 调用。

因为Http模块返回RxJS可观察对象,我们还使用了订阅这些可观察对象的模式。你应该已经习惯从我们对可观察对象的覆盖中看到这种模式了第五章,支持服务器数据持久性。具体来说,我们正在订阅mockBackEndconnections属性,并将MockConnection作为我们的连接传入。在确认我们的连接已经正确设置之后,我们构造一个包含两个workoutsresponse。然后我们在我们的connection上调用mockRespond方法,并返回我们构造的response。最终结果是,我们已经能够为我们的服务构造一个可测试的响应,避免进行远程调用。

该过程的最后一步是将 workout 的getWorkouts方法设置为订阅该response,然后添加适当的expect语句来确认我们在response中获取到了正确数量的workouts,并且第一个workout的类型是WorkoutPlan

我们将遵循相同的模式,使用fakeAsync和我们的mockBackend来构建额外的测试,以确认我们能够做到以下事情:

  • 返回具有特定名称的workout计划

  • getWorkout方法中正确映射exercises

您可以在检查点 7.1 的代码中查看这些测试。但需要注意的一点是,在这两个测试中,我们在connections.subscribe方法中有以下条件逻辑:

if (connection.request.url === collectionUrl + "/workouts/Workout1" + params) { 
    let response = new ResponseOptions({ 
        body: '{ "name" : "Workout1" , "title" : "Workout 1" , "exercises" : [ { "name" : "exercise1" , "duration" : 30}]}' 
    }); 
    connection.mockRespond(new Response(response)); 
} else { 
    connection.mockRespond(new Response( 
        new ResponseOptions({ 
            body: [{name: "exercise1", title: "exercise 1"}] 
        }))); 
} 

这一开始可能有点令人困惑,直到我们意识到,通过getWorkout方法,我们实际上进行了两次Http调用:一次是检索workout,一次是检索所有exercises。正如您从第五章中回忆的那样,我们这样做是为了创建包含在workout中的每个exercise的更完整描述。因此,我们在这里所做的是检查我们是否构建了一个由workout组成的response,用于检索workout的调用,以及由exercise组成的response,用于其他调用。

有了这些,我们已经完成了对服务的测试。

接下来,我们需要学习如何测试指令。下一节将专门讨论指令测试中的挑战以及如何克服这些挑战。

指令的单元测试

到目前为止,我们测试过的其他 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文件。请参考第 7.1 检查点中的文件进行导入,这一点我们暂时不会涉及。

在导入语句的下面,添加以下组件定义:

@Component({ 
  template: ` 
  <form> 
  <input type="text" name="workoutName" id="workout-name" 
  [(ngModel)]="workoutName" a2beBusyIndicator 
  a2beRemoteValidator="workoutname" 
  [validateFunction]="validateWorkoutName"> 
  </form> 
  ` 
}) 
export class TestComponent { 
    workoutName: string; 

    constructor() { 
        this.workoutName = '7MinWorkout'; 
    } 
    validateWorkoutName = (name: string): Promise<boolean> => { 
        return Promise.resolve(false); 
    } 
} 

这个组件看起来很像我们在其他测试中设置的组件来模拟依赖关系。然而,在这里,它的作用略有不同;它充当了我们将要测试的指令的宿主容器。使用这个最小的组件,让我们避免加载这个指令的实际宿主,即Workout组件。

这里需要注意的一点是,我们已经为validateWorkoutName设置了一个方法,该方法将被我们的指令调用。它本质上是一个存根,只返回一个解析为falsePromise。请记住,我们不关心这个方法如何处理其验证,而是验证指令是否调用它并返回正确的结果,即truefalse

接下来,我们通过添加以下代码来设置我们测试套件的描述语句,将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设置本地变量。我们还使用了by.css(我们将在端到端测试中看到更多)以及debugElement上的查询方法来提取我们组件中的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();
    let form: NgForm = debug.children[0].injector.get(NgForm);
    let 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();
    let form: NgForm = debug.children[0].injector.get(NgForm);
    let 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测试是一项非常宝贵的资产。随着应用程序规模的增长,自动化 E2E 测试可以节省大量手动工作。

没有自动化,确保应用程序功能正常就是一个永无止境的战斗。然而,请记住,在 E2E 设置中,并非所有事情都可以自动化;自动化可能需要大量的工作。通过尽职调查,我们可以减轻相当多的手动工作,但并非所有工作都可以。

Web 应用程序的 E2E 测试过程涉及在真实浏览器中运行应用程序,并根据用户界面状态断言应用程序的行为。这就是实际用户进行测试的方式。

浏览器自动化在这里起着关键作用,现代浏览器在支持自动化方面变得更加智能和更有能力。Selenium 工具是浏览器自动化的最流行选项。Selenium 具有 WebDriver (www.w3.org/TR/webdriver/) API,允许我们通过现代浏览器本地支持的自动化 API 来控制浏览器。

提到 Selenium WebDriver 的原因是 Angular E2E 测试框架/运行器Protractor也使用WebDriverJS,这是 Node 上 WebDriver 的 JavaScript 绑定。这些语言绑定(如前面的 JavaScript 绑定)允许我们使用我们选择的语言中的自动化 API。

在我们开始为应用程序编写一些集成测试之前,让我们讨论一下 Protractor。

介绍 Protractor

Protractor 是 Angular 中 E2E 测试的事实上的测试运行器。Protractor 使用 Selenium WebDriver 来控制浏览器并模拟用户操作。

注意

Protractor 取代了之前的 E2E 框架AngularJS Scenario Runner。Karma 有一个插件,允许 Karma 执行 E2E 测试。

典型的 Protractor 设置包括以下组件:

  • 一个测试运行器(Protractor)

  • 一个 Selenium 服务器

  • 一个浏览器

我们使用 Jasmine 编写我们的测试,并使用 Protractors(它是 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 支持 Jasmine 版本 2.5.2。

对于 Protractor 的基本理解,让我们为端到端测试设置 Protractor。

设置 Protractor 进行端到端测试

要全局安装 Protractor,请在控制台中运行此命令:

npm install -g protractor 

这将安装两个命令行工具:Protractor 和 webdriver-manager。运行以下命令以确保 Protractor 设置正确:

protractor --version 

注意

所有端到端测试都已针对 Protractor 4.0.9.web 进行验证。

webdriver-manager 是一个辅助工具,可以轻松获取运行中的 Selenium 服务器实例。在启动 Selenium 服务器之前,我们需要使用以下调用更新驱动程序二进制文件。

webdriver-manager update 

最后,运行此命令启动 Selenium 服务器:

webdriver-manager start 

注意

Protractor 测试向该服务器发送请求以控制本地浏览器。可以通过检查 http://localhost:4444/wd/hub(默认位置)的服务器状态来验证端到端测试。

Protractor 也需要进行配置,就像 Karma 一样,并且有一个配置文件。我们将protractor.config.js文件从chapter7/ checkpoint1/文件夹下的tests文件夹复制到我们本地的tests文件夹中。

我们刚刚添加的 Protractor 配置文件包含四个设置,我们要确保这些设置根据我们本地的应用程序设置进行配置,其中包括以下内容:

关键 描述
Specs 规范文件的位置(E2E 测试文件)。当前分配的值['e2e/*.js']应该可以工作。
baseUrl 应用程序运行的基本 URL。更改服务器名称和端口以匹配您的本地设置。导航到 URL 以确保应用程序正在运行。
seleniumAddress Selenium 服务器正在运行的基本 URL。除非您重新配置了 Selenium 服务器设置,否则默认值应该可以工作。
useAllAngular2AppRoots 将其设置为true。这让 Protractor 知道我们不再使用早期版本的 Angular。

注意

Protractor 网站上的配置文件文档(github.com/angular/protractor/blob/master/lib/config.ts)包含其他支持的配置的详细信息。

这就足够开始使用 Protractor 进行测试了。

TypeScript 配置

与本书中的所有其他示例一样,我们将使用 TypeScript 编写我们的测试。这需要进行一些额外的配置步骤。

首先,将checkpoint 7.1中的tsconfig.json文件复制到trainer文件夹中。您会注意到,这个文件现在具有一些额外的全局依赖项,用于 WebDriver 和 angular-protractor。

{ 
  "globalDependencies": { 
    "core-js": "registry:dt/core-js#0.0.0+20160602141332", 
    "jasmine": "registry:dt/jasmine#2.2.0+20160621224255", 
 **"angular-protractor": "registry:dt/angular-
     protractor#1.5.0+20160425143459",**
 **"selenium-webdriver":
     "registry:dt/selenium-webdriver#2.44.0+20160317120654"** 
  } 
} 

接下来,在trainer文件夹的命令行中运行以下命令:

typings install 

这将安装我们在测试中将要使用的 Protractor 和 web 驱动程序的类型定义。

现在让我们开始编写和执行一些测试。

为应用程序编写 E2E 测试

让我们以一种简单的方式开始测试我们的应用程序起始页面(#/start)。这个页面有一些静态内容,一个带有搜索功能的锻炼列表部分,以及通过点击任何锻炼瓷砖来开始锻炼的能力。

注意

我们所有的 E2E 测试将被添加到tests文件夹下的e2e文件夹中。

tests文件夹下的e2e文件夹中添加一个名为workout-runner.spec.ts的新文件。

在文件顶部添加以下引用:

/// <reference path="../../typings/index.d.ts"/> 

这引入了我们刚刚安装的类型定义。接下来添加以下代码:

describe("Workout Runner", () => { 
describe("Start Page", () => { 
    beforeEach(() => {t 
        browser.get(""); 
    }); 
    it("should load the start page.", () => { 
        expect(browser.getTitle()).toBe("Personal Trainer"); 
        expect(element(by.id("start")).getText()).toBe("Select Workout"); 
    }); 
}); 
}); 

由于我们在 TypeScript 中编写这个测试,我们必须在这里引入一个转译步骤。因此,在一个新的命令窗口中,导航到tests下的e2e文件夹,并输入以下内容:

tsc workout-runner.e2e.ts -w 

你应该看到以下消息:

message TS6042: Compilation complete. Watching for file changes. 

-w开关意味着 TypeScript 会在我们更改它们时重新编译这个文件中的测试。因此,我们将不需要再次运行这个命令。

执行我们的 E2E 测试

在执行我们的第一个测试之前,我们必须确保 Selenium 服务器正在运行(webdriver-manager start),应用正在运行(在trainer文件夹的命令行中运行gulp play)。

现在在trainer文件夹的命令行中运行以下命令,并看到浏览器按照你的指示跳舞:

protractor tests/protractor.conf.js 

Protractor 将打开浏览器。然后它将导航到起始页面;等待页面、脚本和框架加载;然后执行测试。最后在控制台中记录测试的结果。这太棒了!

让我们走一遍这个简单的测试。

第一个有趣的部分在beforeEach块内。浏览器对象是 Protractor 公开的全局对象,用于控制浏览器级别的操作。在底层,它只是 WebDriver 的一个包装器。browser.get("")方法在测试开始之前每次将浏览器导航到应用页面。

实际的测试验证了页面的标题是否正确。它还检查页面上是否存在一些随机内容。

前面的测试使用了两个新的全局变量elementby,这些变量是由 Protractor 提供的。

  • element:这个函数返回一个ElementFinder对象。ElementFinder的主要工作是与选定的元素进行交互。我们将在我们的测试中广泛使用element函数来选择ElementFinder

注意

请参考www.protractortest.org/#/locators#actions上的文档,了解更多关于元素操作 API 支持的信息。getText()等函数实际上是在WebElement上定义的,但总是使用ElementFinder访问。正如文档所建议的,ElementFinder在大多数情况下可以被视为WebElement。有关更多信息,您可以参考www.protractortest.org/#/locators#behind-the-scenes-elementfinders-versus-webelements

  • by:这个对象用于定位元素。它有创建定位器的函数。在前面的测试中,创建了一个定位器来搜索具有id=start的元素。可以使用多种定位器来搜索特定元素。这些包括按类、按 ID 和按 css。 (目前尚不支持按模型和按绑定。)请参考 Protractor 定位器文档 angular.github.io/protractor/#/locators 了解支持的定位器。

注意

再次强调我们之前讨论的,前面测试中的getTitle()getText()并不返回实际文本,而是一个 Promise;我们仍然可以对文本值进行断言。

这个简单的测试突出了 Protractor 的另一个显著特点。它会自动检测 Angular 应用程序何时加载以及何时可以进行测试。在标准的端到端测试场景中,通常需要使用丑陋的延迟测试(使用timeouts),而在 Protractor 中不需要。

请记住,这是一个SPA;全页浏览器刷新不会发生,因此确定页面何时加载以及渲染用于 AJAX 调用的数据何时可用并不那么简单。Protractor 使所有这些成为可能。

注意

Protractor 在尝试评估页面是否可用于测试时可能仍会超时。如果您在使用 Protractor 时遇到超时错误,Protractor 文档中的这篇文章可能会非常有帮助(www.protractortest.org/#/timeouts)来调试此类问题。

为端到端测试设置后端数据

为端到端测试设置后端数据是一个挑战,无论我们使用什么样的端到端框架进行测试。最终目标是对应用程序的行为进行断言,除非数据是固定的,否则我们无法验证涉及获取或设置数据的行为。

为端到端测试设置数据的一种方法是创建一个专门用于端到端测试的测试数据存储,并附带一些种子数据。一旦端到端测试结束,数据存储可以重置为其原始状态以供将来测试使用。对于个人教练,我们可以在 MongoLab 中创建一个专门用于端到端测试的新数据库。

这可能看起来很费力,但是这是必要的。谁说端到端测试很容易!事实上,即使我们进行手动测试,这个挑战也存在。对于一个真正的应用程序,我们总是需要为每个环境设置数据存储/数据库,无论是devtest还是production

在这种情况下,我们将继续使用我们现有的后端,但是继续添加另一个我们将用于测试的锻炼。将此锻炼命名为1minworkout,并将其标题设置为1 Minute Workout。为锻炼添加两个练习:开合跳和靠墙坐。将每个练习的持续时间设置为 15 秒,休息时间设置为 1 秒。

注意

我们故意将新的锻炼设置得很短,以便我们可以在 Protractor 提供的正常超时时间内完成对这个锻炼的端到端测试。

更多的端到端测试

让我们回到测试起始页面上的锻炼搜索功能。通过添加 1 分钟锻炼,我们现在有两个锻炼,我们可以对这些进行搜索行为断言。

注意

如果您已经向后端添加了其他锻炼,请相应地调整此测试中的数字。

workout-runner.spec.ts中的现有测试之后添加此测试:

it("should search workout with specific name.", () => { 
    var filteredWorkouts = element.all(by.css(".workout.tile")); 
    expect(filteredWorkouts.count()).toEqual(2); 
    var 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.tile CSS 类的所有元素进行多元素匹配。这给我们提供了一个锻炼列表,下一行对其进行断言,断言元素计数为 3。

然后,测试使用element函数和by.css定位器获取搜索输入,以便使用.form-contol CSS 类进行单个元素匹配。然后我们使用sendKeys函数模拟在搜索输入中输入数据。

最后两个 expect 操作检查了我们列表中元素的数量,在搜索之后应该是 1。此外,它们检查了基于title CSS 类的 div 标签过滤出的正确锻炼是否是包含我们锻炼的元素的子元素。这最后一个 expect 语句突出了我们如何链接元素过滤并获取 HTML 中的子元素。

与起始页面相关的另一个测试应该添加。它测试了从起始页面到锻炼运行器屏幕的导航。为此测试添加以下代码:

it("should navigate to workout runner.", () => { 
    var 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的 E2E 测试?好吧,我们可以尝试。

测试 WorkoutRunner

测试 WorkoutRunner 的一个主要挑战是一切都取决于时间。在单元测试中,至少我们能够模拟间隔,但现在不行了。测试锻炼转换和锻炼完成肯定是困难的。

然而,在我们解决这个问题或尝试找到可接受的解决方法之前,让我们偏离一下,学习一个重要的管理 E2E 测试的技术:页面对象!

使用页面对象管理 E2E 测试

页面对象的概念很简单。我们将页面元素的表示封装到一个对象中,这样我们就不必在 E2E 测试代码中使用ElementFinderlocators。如果任何页面元素移动,我们只需要修复页面对象。

这是我们如何表示我们的 Workout Runner 页面:

class WorkoutRunnerPage{ 
    pauseResume: any; 
    playButton: any; 
    pauseButton: any; 
    exerciseTitle: any; 
    exerciseDescription: any; 
    exerciseTimeRemaining; any; 

    constructor(){ 
        this.pauseResume =  element.all(by.id('pause-overlay')); 
        this.playButton = element.all(by.css('.glyphicon-play')); 
        this.pauseButton = element.all(by.css('.glyphicon-pause')); 
        this.exerciseTitle = element.all(by.css(
        '.workout-display-div h1')).getAttribute('value'); 
        this.exerciseDescription = element.all(by.id(
        'description-panel')).getAttribute('value'); 
        this.exerciseTimeRemaining = element.all(by.css(
        '.workout-display-div h4')).getAttribute('value'); 
    } 
}; 

这个页面对象现在封装了我们想要测试的许多元素。通过将元素选择代码组织在一个地方,我们增加了 E2E 测试的可读性和可维护性。

现在将 Workout Runner 页面对象添加到测试文件的顶部。我们将在锻炼运行器的测试中使用它。添加以下包含我们的第一个锻炼运行器测试的新描述块:

describe("Workout Runner page", () => { 
    beforeEach(() => { 
        browser.get("#/workout/1minworkout"); 
    }); 
    it("should load workout data", () => { 
        var 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", () => { 
    let page = new WorkoutRunnerPage(), 
        timeRemaining; 
    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); 
    }); 
}); 

有趣的是,我们在一个 promise 中使用browser.sleep函数来验证在按钮点击之前和之后,锻炼剩余时间是否相同。我们再次使用我们的WorkoutRunner页面对象来使测试更易读和易懂。

接下来,将以下测试添加到当前测试套件中:

it("should transition exercise when time lapses.", () => { 
    var page = new WorkoutRunnerPage(); 
    browser.sleep(15000); 
    page.pauseResume.click(); 
    expect(page.exerciseTitle).toBe["Relax!"]; 
    expect(page.exerciseDescription).toBe["Relax a bit!"]; 
    //expect(page.videos.count()).toBe(0); 
 }); 

这个测试检查了运动过渡是否发生。它通过添加一个browser.sleep函数来进行 15 秒的等待,然后从 UI 状态检查Rest的运动相关内容是否可见。这个测试的问题在于它并不是非常准确。它可以确认过渡正在发生,但无法确认它是否发生在正确的时间。

这种行为的一个合理解释在于 Protractor 的工作方式。在 Protractor 开始测试之前,它首先等待页面加载。如果测试涉及任何操作(如getText),它会再次等待直到 Angular 同步页面。在页面同步期间,Angular 会等待任何未决的 HTTP 请求或任何基于超时的操作完成,然后才开始测试。因此,当调用browser.sleep函数并且浏览器实际上进入睡眠状态的时间无法准确预测。

注意

我们可以通过将browser.ignoreSynchronization设置为true来禁用这种同步行为,但我们应尽量避免这样做。如果我们将其设置为true,那么我们就需要确定页面内容何时可以用于进行断言。

最重要的是Workout Runner应用程序的工作流确实很难测试。与Workflow Runner相比,其他事情要容易得多,就像我们在开始页面测试中看到的那样。

现在是时候结束本章并总结我们的学习了。

总结

我们不需要重申单元测试和端到端测试对于任何应用程序有多么重要。Angular 框架的设计方式使得测试 Angular 应用程序变得容易。在本章中,我们介绍了如何使用针对 Angular 的库和框架编写单元测试和端到端测试。

对于单元测试,我们使用 Jasmine 编写测试,并使用 Karma 执行它们。我们测试了Personal Trainer中的许多过滤器、组件、服务和指令。在这个过程中,您了解了有效测试这些类型所使用的挑战和技术。

对于端到端测试,我们选择的框架是 Protractor。我们仍然使用 Jasmine 编写测试,但这次的测试运行器是 Protractor。您学习了 Protractor 如何使用 Selenium WebDriver 自动化端到端测试,因为我们对StartWorkout Runner页面进行了一些场景测试。

如果你已经到达这一点,你离成为一个熟练的 Angular 开发者更近了。下一章将通过更多实际场景和使用 Angular 构建的实现来加强这一点。在本书的最后一章中,我们将涉及一些重要概念;这些包括多语言支持、认证和授权、通信模式、性能优化等等。你肯定不想错过它们!

第八章:一些实际场景

在我们的带领下已经有了七章,应该感觉不错。到目前为止,你所学到的是我们在过去几章中构建的应用程序的直接结果。我相信你现在对框架有了足够的了解,知道它是如何工作的,以及它支持什么。拥有这些知识,一旦我们开始构建一些相当大的应用程序,一些常见的问题/模式将不可避免地出现,比如这些:

  • 如何验证用户并控制他/她的访问(授权)?

  • 如何确保应用程序的性能足够?

  • 我的应用程序需要本地化内容。我该怎么办?

  • 我可以使用哪些工具来加快应用程序开发?

  • 我有一个 Angular 1 应用程序。我该如何迁移它?

还有一些!

在本章中,我们将尝试解决这些常见场景,并提供一些可行的解决方案和/或指导方针来处理这些用例。

本章我们将涵盖的主题包括:

  • Angular 种子项目:您将学习如何在开始新的项目时,一些 Angular 种子项目可以帮助我们。

  • 验证 Angular 应用程序:这是一个常见的要求。我们将看看如何在 Angular 中支持基于 cookie 和令牌的身份验证。

  • Angular 性能:作为我们试图详细说明 Angular 2 的性能以及您可以做些什么来使您的应用程序更快的必要性,一个习惯性能部分是必不可少的。

  • 将 Angular 1 应用程序迁移到 Angular 2:Angular 1 和 Angular 2 完全是不同的东西。在本章中,您将学习如何逐步将 Angular 1 应用程序迁移到 Angular 2。

让我们从头开始!

构建一个新的应用程序

想象这样一个场景:我们正在构建一个新的应用程序,并且由于 Angular 框架的超级强大,我们已经一致决定使用 Angular。太棒了!接下来呢?接下来是设置项目的平凡过程。

虽然这是一个平凡的活动,但仍然是任何项目的关键部分。设置新项目通常涉及:

  • 创建标准的文件夹结构。这有时受服务器框架的影响(如RoRASP.NetNode.js等)。

  • 将标准资产添加到特定文件夹。

  • 设置构建,如果我们正在开发基于 Angular 2 的 Web 应用程序,则包括:

  • 如果使用 TypeScript,编译/转译内容

  • 配置模块加载器

  • 依赖管理,包括框架和第三方组件

  • 设置单元/E2E 测试

  • 配置不同环境的构建,如开发、测试和生产。同样,这受到所涉及的服务器技术的影响。

  • 代码捆绑和最小化。

有很多事情要做。

如果我们能够简化整个设置过程会怎么样?这确实是可能的;我们只需要一个种子项目或者一个起始站点

种子项目

在我们写这本书的时候,Angular 2 刚刚出现。有许多种子项目可以让我们立即开始。一些种子项目将框架与特定的后端集成在一起,而另一些只提供 Angular 特定的内容。有些预先配置了特定供应商的库/框架(如LESSSASSBootstrapFontAwesome),而其他一些只提供了一个简单的设置。

一些值得探索的著名种子项目包括:

  • Angular 2 Webpack Starter (bit.ly/ng2webpack):这个种子存储库是一个 Angular 2 的起始项目,适用于任何想要快速上手使用 Angular 2 和 TypeScript 的人。它使用 Webpack(模块捆绑器)来构建我们的文件并帮助处理样板。它是一个完整的构建系统,具有大量的集成。

  • Angular 2 Seed (bit.ly/ng2seed):另一个类似于 Angular 2 Webpack starter 的种子项目。这个种子项目使用 gulp 进行构建自动化,模块捆绑系统不像 Webpack 那样先进。

  • angular-cli (bit.ly/ng2-cli):这是由 Angular 团队创建的命令行工具,不仅为我们设置了一个种子项目,还具有脚手架功能。我们可以生成样板组件、指令、管道和服务。

这些项目在使用 Angular 构建时提供了一个快速起步。

如果应用程序与特定的后端堆栈绑定,我们有两个选择,如下所示:

  • 使用其中一个种子项目并手动将其与后端集成。

  • 找到一个为我们做这些事情的种子项目/实现。Angular 2 相对较新,但很有可能随着时间的推移会出现这样的种子项目。

在谈论这些种子项目时,不能不提及更进一步的工具。它们不仅提供了种子实现,还具有脚手架功能,使我们的生活更加轻松。

种子和脚手架工具

在这个领域值得一提的两个是Yeomanangular-cli

Yeoman

Yeoman (yeoman.io/)是一套针对 Web 应用程序开发的工具。它定义了构建现代 Web 应用程序的工作流程。它包括:

  • yo:这是一个用于即时生成代码的脚手架工具

  • Grunt/Gulp:在 Node 上构建系统的事实标准选择

  • Bower/npm:Bower 是 Web 的包管理器,与 npm 类似

Yeoman 的脚手架组件非常有趣。yo,正如它的名字,使用了生成器的概念来实现脚手架。

注意

脚手架是生成可以构建的代码骨架的过程。使用脚手架,我们可以节省一些初始工作,并提供一些关于任何编码工件的整体结构应该如何看起来的指导。

Yeoman 中的生成器用于设置初始种子项目,以及后续用于生成单个脚本。由于 Yeoman 并不专门针对 Angular,因此有各种客户端和服务器堆栈的生成器。对于 Angular 2,有许多可用的生成器,具有不同的配置。

提示

查看bit.ly/yogenerators以获取 Yeoman 支持的生成器的详尽列表!您需要筛选出 Angular 2 生成器,并选择适合您的内容。随意尝试这些生成器,看看哪个最适合您的需求。

angular-cli

angular-cli是 Angular 团队正式认可的脚手架工具,与 Yeoman 的工作方式相同,但专门针对 Angular 2。让我们试一试。

要安装 angular-cli,从命令行运行:

**npm install -g angular-cli**

然后运行这组命令来生成初始工件并为新项目构建设置:

**ng new PROJECT_NAME**
**cd PROJECT_NAME**
**ng serve**

在浏览器中打开http://localhost:4200/,我们有一个正在运行的应用程序!

让我们也来检查它的脚手架能力。

要生成一个组件,我们只需要调用这个命令:

**ng generate component home**

它为我们的组件Home生成了一个样板实现。该工具在src/app文件夹内创建了一个home文件夹,并添加了一些与组件相关的文件。这不仅节省了我们一些鼠标点击和输入的工作,而且生成的代码符合 Angular 社区概述的最佳实践。一个不错的开始!去查看生成的代码,了解这个结构。

我们刚刚看到了使用 angular-cli 生成组件,但这个工具也可以生成指令管道服务路由。在 GitHub 网站的工具文档中查看bit.ly/ng2-cli

是时候看看 Angular 最被吹捧的领域了,性能。让我们了解是什么让 Angular 运行得如此快,以及 Angular 提供了哪些开关/旋钮来改善整体应用程序的性能。

Angular 2 性能

Angular 2 的设计考虑了性能。从框架占用空间、初始加载时间、内存利用率、变化检测加数据绑定,到 DOM 渲染,框架的每个部分都经过了调整或正在调整以获得更好的性能。

接下来的几节专门讨论了 Angular 的性能和它使用的技巧,以实现一些令人印象深刻的性能提升。

字节大小

框架的字节大小是性能优化的一个很好的起点。虽然世界正在向高速互联网发展,但我们中间有相当一部分人在使用较慢的连接,并且使用手机连接到网络。我们可能不会太在意这里或那里的几 KB,但这确实很重要!

虽然 Angular 2 的开箱即用的字节大小比 Angular 1 大,但有一些技术可以大大减少 Angular 2 捆绑包的大小。

首先,最小化压缩的标准技术可以大大减少这一差距。而且使用 Angular 2,我们可以通过模块捆绑器/加载器来做一些巧妙的技巧,进一步减少 Angular 2 捆绑包的大小。

摇树可能是一个古怪的名字,但它确实做到了!当我们使用 TypeScript(或 ES2015)构建应用程序时,包含模块导出,像Rolluprollupjs.org)这样的模块捆绑器可以对这样的代码进行静态代码分析,确定哪些代码部分从未被使用,并在捆绑发布版本之前将其删除。这样的模块捆绑器在添加到应用程序的构建过程中时,可以分析框架部分、任何第三方库和应用程序代码,以删除创建捆绑包之前的任何死代码。摇树可以导致巨大的大小减小,因为你不会捆绑你不使用的框架部分。

可以从框架捆绑包中删除的最大框架部分之一是编译器。是的,你没看错,就是编译器!

注意

对于好奇的读者来说,编译器是 Angular 捆绑包中贡献最大的框架部分,为500 KB+(在 Angular 2 v2.0.0中)。

使用树摇和预编译AoT)编译,我们可以完全摆脱 Angular 编译器(在浏览器中)。

通过 AoT 编译,视图模板(HTML)在服务器端预先编译。这种编译再次是作为应用程序构建过程的一部分进行的,其中 Angular 2 编译器的服务器版本(一个节点包)编译应用程序中的每个视图。

所有模板都已经编译,根本不需要将 Angular 编译器位发送到客户端。现在,树摇可以摆脱编译器,并创建一个更精简的框架包。

注意

bit.ly/ng2-aot提供的框架文档中了解更多关于 AoT 的信息。

初始加载时间和内存利用

任何具有成熟框架的 Web 应用程序的初始加载时间通常很慢。这种影响在移动设备上更为明显,因为 JavaScript 引擎可能没有桌面客户端那么强大。为了获得更好的用户体验,优化框架的初始加载时间尤为重要,特别是对于移动设备。

Angular 2 的初始加载时间和重新渲染视图速度是 Angular 1 的五倍。随着 Angular 团队不断发展框架,这些数字将会变得更好。

此外,AoT 编译也可以提高应用程序的初始加载时间,因为不需要耗时的活动(视图编译)。

内存利用也是如此。Angular 2 在这方面表现更好,未来的版本将会更好。

如果您计划切换到 Angular 2,这是您应该期待的事情:一个为未来构建的高性能框架。

我们将要讨论的下面三个性能改进之所以成为可能,是因为做出了一个单一的架构决策:创建一个单独的渲染器层

Angular 渲染引擎

Angular 1 最大的缺点是该框架与浏览器 DOM 绑定在一起。指令、绑定和插值都针对 DOM 工作。

在 Angular 2 中,最大的架构变化是引入了一个单独的渲染层。现在,Angular 2 应用程序有两个层:

  • 应用层:这是我们的代码所在的层。它使用在渲染层上构建的抽象与其进行交互。我们在第六章中看到的 Renderer 类,Angular 2 Directives in Depth,就是我们用来与渲染层交互的接口。

  • 渲染层:这一层负责将应用层的请求转换为渲染组件,并对用户输入和视图更新做出反应。

渲染器的默认实现是DomRenderer,它在浏览器内部运行。但也有其他渲染抽象,我们将在下一节中讨论它们。

服务器端渲染

服务器端的预渲染是改善 Angular 2 应用程序初始加载时间的另一种技术。这种技术在移动设备上非常有用,因为它显著提高了感知加载时间。

服务器端渲染负责在客户端渲染启动之前处理初始页面加载(并处理视图渲染)。

在这种情况下,当用户请求查看/页面时,服务器上的一款软件会生成一个完全实现的 HTML 页面,其中数据预先绑定到视图,并将其与一个小脚本一起发送到客户端。因此,应用视图立即被渲染,准备好进行交互。在后台加载框架的同时,第一次发送的小脚本会捕获所有用户输入,并使其可用于框架,从而允许在加载后重放交互。

Angular Universal 允许在服务器端和客户端上渲染和共享视图。

服务器端渲染之所以成为可能,是因为之前所述的渲染层的分离。初始视图由服务器上的渲染器实现生成,名为ServerDomRenderer。有一个 Node.js 插件可以在多个 Node web 框架中使用,例如ExpressHapiSail等。还在努力为其他流行的服务器平台(如.NET 和 PHP)开发渲染实现。

注意

查看 Angular 设计文档以了解有关服务器端渲染的更多信息。

性能不是服务器端渲染的唯一好处。事实证明,搜索索引器也喜欢预渲染的 HTML 内容。服务器端渲染在搜索引擎优化(SEO)和深度链接等领域非常有用,这使得内容共享变得更加容易。

将工作卸载到 Web Worker

将工作卸载到Web Worker是一个很好的主意,这也是由于渲染层与应用程序层的分离才变得可能。

Web Worker提供了在后台线程中运行脚本的机制。这些线程可以执行不涉及浏览器 DOM 的工作。无论是 CPU 密集型任务还是远程 XHR 调用,都可以委托给 Web Worker。

在当今世界,多核 CPU 是常态,但 JavaScript 执行仍然是单线程的。我们需要一种标准/机制来利用这些空闲核心来运行我们的应用程序。Web Worker 完全符合要求,由于大多数现代浏览器都支持它们,我们都应该编写利用 Web Worker 的代码。

遗憾的是,情况并非如此。Web Worker 仍然不是主流,这其中有很多充分的理由。Web Worker 对允许和不允许的事项施加了许多限制。这些限制包括:

  • 无法直接访问 DOM:Web Worker 无法直接操作 DOM。事实上,Web Worker 无法访问多个全局对象,如windowdocument,而其他一些在 Web Worker 线程上也不可用。这严重限制了 Web Worker 可以被利用的用例数量。

  • 浏览器支持:Web Worker 仅适用于现代/常青浏览器(IE 10+)。

  • 进程间通信:Web Worker 不与主浏览器进程共享内存,因此需要通过消息传递(序列化数据)与主线程(UI 线程)进行通信。此外,消息传递机制是异步的,增加了通信模型的另一层复杂性。

显然,Web Worker 很难使用。

Angular 2 试图通过将 Web Worker 的使用集成到框架本身中来缓解这些限制。它通过在 Web Worker 线程中运行完整的应用程序,除了渲染部分来实现这一点。

该框架负责处理应用程序代码在 Web Worker 内部运行和主 UI 线程内部运行的渲染器之间的通信。从开发者的角度来看,没有可见的区别。

这再次得益于 Angular 2 中渲染器层的分离。以下图表显示了在应用程序主线程上运行的层以及在 Web Worker 中运行的内容:

将工作分配给 Web Worker

注意

观看 Jason Teplitz 的演讲(bit.ly/yt-ng2-web-worker)了解 Web Worker 的优势。

高性能移动体验

Angular 的渲染抽象再次为我们打开了许多集成途径,特别是在移动平台上。与其在移动浏览器上运行应用程序,不如创建可以利用设备原生 UI 功能的 Angular 渲染器。

在此领域中两个值得注意的项目是平台的渲染器:

应用平台,如ReactNativeNativeScript,已经非常出色地为原生移动平台(iOS 和 Android)提供了基于 JavaScript 的 API,使我们能够利用单一的代码库和熟悉的语言。Angular 渲染器将事情推向了更高层次。通过与 Angular 集成,大量代码可以在浏览器和移动设备之间共享。只有视图模板和与视图相关的服务(如对话框、弹出窗口等)可能有所不同。

查看各自渲染器的文档,了解它们的工作原理和支持的功能。

接下来,我们将在变更检测方面进行框架改进。

改进变更检测

Angular 2 相对于 Angular 1 的主要性能改进之一是变更检测的工作方式。Angular 2 的变更检测开箱即用,速度非常快,而且可以进一步调整以获得更好的结果。

接下来的几节将深入讨论 Angular 变更检测。这是一个重要的主题,当构建大规模应用时需要了解。它还有助于我们调试一些看似变更检测不起作用的情况。

让我们从理解什么是变更检测以及为什么它很重要开始讨论。

变更检测

Angular 的数据绑定引擎很好地将视图与模型数据(组件数据)进行绑定。这些是实时绑定,Angular 会保持视图与模型更改同步。每当模型更改时,绑定引擎会重新渲染依赖于模型的视图部分。为了管理这种视图模型同步,Angular 需要知道模型何时发生了变化以及发生了什么变化。这就是变更检测的全部内容。在应用程序执行期间,Angular 经常进行我们所谓的变更检测运行,以确定发生了什么变化。

注意

如果您熟悉 Angular 1,变更检测运行大致相当于脏检查循环,只是在 Angular 2 中没有循环。

虽然保持模型和视图同步的问题听起来很简单,但实际上却很棘手。与组件树不同,多个模型之间的相互关联可能很复杂。一个组件模型的变化可能会触发多个组件模型的变化。此外,这些相互关联可能会形成循环。单个模型属性可能绑定到多个视图。所有这些复杂的情况都需要使用强大的变更检测基础设施来管理。

在接下来的几节中,我们将探讨 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 2 变化检测使用相同的技术在区域内执行我们的代码。这段代码可能是一个事件处理程序,在完成之前内部进行更多的同步和异步调用。或者它可能是一个setTimeout/setInterval操作,可能再次需要 UI 更新。

Angular 变化检测框架订阅执行区域的onMicrotaskEmpty可观察对象,并在每个轮次完成时启动变化检测。以下图表突出显示了当类似于刚才描述的代码在按钮点击时运行时会发生什么:

当变化检测启动时是什么时候?

注意

在代码块执行期间,如果区域库确定调用是异步的,它会生成一个新的微任务,具有自己的生命周期。这些微任务的完成也会触发onMicrotaskEmpty

如果你想知道在 Angular 内部变化检测触发器是什么样的,这里是从 Angular 源代码中摘录的一部分(进一步简化):

class ApplicationRef_ { 

  private _changeDetectorRefs:ChangeDetectorRef[] = []; 

  constructor(private zone: NgZone) { 
    this.zone.onMicrotaskEmpty 
      .subscribe(() => this.zone.run(() => this.tick()); 
  } 

  tick() { 

    this._changeDetectorRefs.forEach((detector) => { 
      detector.detectChanges(); 
    }); 
  } 
} 

ApplicationRef类跟踪整个应用程序中附加的所有变化检测器,并在应用程序级别的区域对象触发onMicrotaskEmpty事件时触发变化检测周期。我们很快将会讨论在这个变化检测期间发生了什么。

Zonejs能够跟踪任何异步调用的执行上下文,因为它覆盖了默认的浏览器 API。这种覆盖,也称为monkey patching,覆盖了事件订阅XHR 请求setTimeout/setIntervalAPI。在之前突出的示例中,我们调用的setTimeout是原始浏览器 API 的一个 monkey-patched 版本。

现在我们知道了变化检测器是如何设置的以及何时启动这一活动,我们可以看看它是如何工作的。

变化检测是如何工作的?

一旦变化检测器设置好并且浏览器 API 被修改以触发变化检测,真正的变化检测就开始了。这是一个非常简单的过程。

一旦触发了任何异步事件回调(事件处理程序的执行也是异步活动),Angular 首先执行我们附加到回调的应用程序代码。这段代码执行可能会导致一些模型更新。在回调执行后,Angular 需要通过触发变化检测运行来响应这些变化。

在变化检测运行中,从组件树顶部开始,每个变化检测器评估其相应组件的模板绑定,以查看绑定表达式的值是否发生了变化。

关于这种执行,有一些需要强调的事情:

  • Angular 使用严格的相等性检查(使用===)来检测变化。由于它不是深度比较,对于引用对象的绑定,Angular 只会在对象引用发生变化时更新视图。

  • 变化检测流是单向的(从根开始),从父级向子级以自顶向下的方式运行。父组件上的检测器在子检测器之前运行。

默认情况下,变化检测算法会导航整个树,无论变化是在树的哪个位置触发的。这意味着在每次变化检测运行时都会评估所有绑定。

变化检测是如何工作的?

注意

在每次运行时进行绑定评估可能看起来效率低下,但实际上并非如此。Angular 采用了一些先进的优化方法,使这种检查变得超快。但是,如果我们想要调整这种行为,我们确实有一些开关可以减少执行的检查次数。我们将很快涉及这个话题。

  • 变化检测器只跟踪模板绑定的属性,而不是完整的对象/组件属性。

  • 为了检测绑定值的变化,变化检测器需要跟踪上一次变化检测运行期间评估的表达式的先前值。显然,对于我们使用的每个模板绑定,都需要一定数量的簿记。

显而易见的下一个问题是:当变化检测器检测到变化时会发生什么?

由于已经完成了设置变更检测和识别更改的所有艰苦工作,这一步只涉及更新组件状态和同步组件 DOM。

这里还有一些值得注意的观察:

  • 首先,Angular 将模型更新步骤与 DOM 更新分开。

考虑以下代码片段,当有人点击按钮时调用:

        doWork() { 
           this.firstName="David"; 
           this.lastName="Ruiz"; 
        } 

假设firstNamelastName都绑定到了组件视图,对firstName的更改不会立即更新 DOM 绑定。相反,Angular 会等待doWork函数完成后再触发变更检测运行和 DOM 更新。

  • 其次,变更检测运行不会(也不应该)更新模型状态。这避免了任何循环和级联更新。变更检测运行只负责评估绑定并更新视图。这也意味着我们不应该在变更检测期间更新模型状态。如果在变更检测期间更新模型,Angular 会抛出错误。让我们看一个例子:
  1. 打开start.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 的生产模式时才启用。可以通过在引导应用程序(在bootstrap.ts中)之前调用enableProdMode()函数来启用 Angular 的生产模式

当启用时,Angular 的行为会有些不同。它会关闭框架内的断言和其他检查。生产模式还会影响变更检测行为。

在非生产模式下,Angular 会遍历组件树两次以检测更改。如果在第二次遍历中任何绑定表达式发生了变化,它会抛出错误。

相比之下,在生产模式下,变更检测树遍历只进行一次。如果启用生产模式,控制台中看到的变更检测错误将不会出现。这可能导致模型和视图状态不一致。这是我们应该注意的事情!

底线是,当变更检测正在进行时,我们不能改变组件的状态。一个直接的推论是:如果我们在绑定表达式中使用函数,函数执行应该是无状态的,没有任何副作用。

  • 最后,这种从根节点到叶节点的变更检测遍历只在变更检测运行期间执行一次。

注意

对于具有 Angular 1 背景的人来说,这是一个惊喜!Angular 2 中的 digest 循环计数为 1。Angular 2 开发人员永远不会遇到“digest 迭代超出异常!”更高性能的变更检测系统!

变更检测性能

让我们谈谈变更检测的性能。如果你认为每次检查完整的组件树以进行更改是低效的,你会惊讶地知道它有多快。由于在表达式评估和比较方面进行了一些优化,Angular 可以在几毫秒内执行数千次检查。

在幕后,对于视图绑定中涉及的每个表达式,Angular 都会生成一个特定于特定绑定的变更检测函数。虽然乍一看可能有些反直觉,但 Angular 并没有一个用于确定表达式是否发生变化的通用函数。相反,这就像为我们绑定的每个属性编写自己的变更检测函数。这使得 JavaScript 虚拟机能够优化代码,从而提高性能。

注意

想了解更多吗?查看 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的属性,突出显示这些组件操作的员工数据的哪一部分。每个这些组件的 summary、professional、personal 和 address 内部可能有其他子组件。这就是组件树的样子:

使用不可变数据结构

当我们更新员工的个人信息时会发生什么?对于标准对象(可变的),Angular 无法对数据的形状和所有更改做出任何假设;因此,它会进行完整的树遍历。

不可变性在这里如何帮助?当使用不可变的数据结构时,对对象属性的任何更改都会导致创建一个新对象。例如,如果我们使用一个流行的库immutablejs来创建一个不可变的对象:

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属性绑定到一个不可变对象,因此只有在model引用发生变化时,Angular 才需要进行变化检测。否则,完整的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函数将组件重新附加到变更检测树上。

ChangeDetectorRef类有两个可用于手动变更检测的函数:

  • markForCheck:这标记了从检测器到根的路径进行脏检查。请记住,实际的脏检查只有在所有应用程序代码执行完毕后才会执行,而不是在我们调用markForCheck时立即执行。这段代码展示了这个函数的使用:
        this._userService.getUserDetails() 
        .subscribe((user)=>  
        { this.user = user; ref.markForCheck();} 

  • detectChanges:此函数实际上在调用它的组件(及其子组件)上执行变更检测。使用detectChanges就像将树与应用程序的其余部分隔离开,并执行局部变更检测。

除非标准变更检测变得昂贵,我们很少需要禁用标准变更检测设置。

举个例子,一个公共聊天室应用程序,它接收来自连接到它的成千上万人的消息。如果我们不断地拉取消息并刷新 DOM,应用程序可能会变得无响应。在这种情况下,我们可以禁用聊天应用程序组件树的部分的变更检测,并在特定间隔手动触发变更检测以更新 UI。

虽然我们已经看到了三种调整变更检测行为的方法,但好处在于这些方法并不是互斥的。组件树的部分可以使用不可变数据结构,部分可以使用 observables,部分可以使用手动变更检测,其余部分仍然可以使用默认的变更检测。Angular 将乐意配合!

现在关于变更检测就说到这里。除非我们正在构建一些具有繁忙 UI 的大型视图,否则我们可能永远不需要它。这种情况需要我们从变更检测系统中挤出每一点性能,而系统已经为此做好了准备。

接下来,我们将看一下大多数应用程序不可避免地具有的另一个常见需求:对其用户进行身份验证。

处理身份验证和授权

大多数,如果不是所有的应用程序都有要求对其用户进行身份验证/授权。我们可以说身份验证和授权更多是服务器的问题而不是客户端的问题,这是正确的。但是,客户端需要适应并与服务器强加的身份验证和授权要求进行集成。

在典型的应用程序执行工作流程中,应用程序首先加载部分视图,然后从服务器获取数据,并最终将数据绑定到其视图。显然,视图和远程数据 API 是需要进行安全保护的两个资产。

为了保护这些资源,您需要了解典型应用程序在服务器上是如何安全的。保护任何 Web 应用程序主要有两种广泛的方法:基于 Cookie 的身份验证基于令牌的身份验证。它们每个都需要在客户端部分进行不同的处理。接下来的两节描述了我们如何与这两种方法中的任何一种集成。

如果服务器堆栈支持,这种身份验证机制是最容易实现的。它是非侵入性的,可能对 Angular 应用程序的更改要求最少。基于 Cookie 的身份验证涉及设置浏览器 cookie 以跟踪用户身份验证会话。下面的序列图解释了典型的基于 Cookie 的身份验证工作流程:

基于 Cookie 的身份验证

以下是典型的身份验证工作流程:

  • 当尝试从浏览器访问受保护的资源时,如果用户未经身份验证,服务器会发送 HTTP 401 未经授权状态码。正如我们将在后面看到的那样,如果请求中没有附加 cookie 或者 cookie 已过期/无效,用户请求就是未经授权的请求。

  • 这个未经授权的响应被服务器或者有时是客户端框架(在我们的情况下是 Angular)拦截,通常会导致 302 重定向(如果被服务器拦截)。重定向的位置是登录页面的 URL(登录页面允许匿名访问)。

  • 然后用户在登录页面上输入用户名和密码,并对登录端点进行 POST。

  • 服务器验证凭据,设置浏览器 cookie,并将用户重定向到原始请求的资源。

  • 因此,身份验证 cookie 是每个请求的一部分(由浏览器自动添加),服务器使用此 cookie 来确认用户的身份和用户是否已经通过身份验证。

注意

这种情况假设 HTML 和 API 存在于单个域下。

正如我们所看到的,采用这种方法,Angular 基础设施并不涉及;或者涉及很少。甚至登录页面可以是一个标准的 HTML 页面,只需将数据发送到登录端点进行身份验证。如果用户登陆到 Angular 应用程序,这意味着用户已经通过了身份验证。

注意

基于 cookie 的身份验证流程可能会根据服务器框架而有所不同,但设置一个 cookie 并在随后的每个请求中附加一个 cookie 的一般模式仍然是相同的。

在基于 cookie 的应用程序身份验证中,如果应用程序想要获取用户上下文,那么会暴露一个服务器端点(如/user/details),返回已登录用户的特定数据。然后客户端应用程序可以实现一个诸如UserService的服务,加载并缓存用户配置文件数据。

这里描述的情景假设 API 服务器(返回数据的服务器)和托管应用程序的站点位于单个域中。这并不总是情况。即使对于个人健身教练,数据存储在MongoLab服务器上,而应用程序存储在不同的服务器上(即使是本地)。我们已经知道这是一个跨域访问,并且它带来了自己一套挑战。

在这样的设置中,即使 API 服务器能够验证请求并向客户端发送一个 cookie,客户端应用程序仍然不会在随后的请求中发送身份验证 cookie。

为了解决这个问题,我们需要在 XHR 请求中将一个布尔变量withCredentials设置为true。这可以通过覆盖BaseRequestOptionswithCredentials属性)在全局级别启用。框架使用BaseRequestOptions类作为默认的 HTTP 请求选项。请参阅下一节,基于令牌的身份验证,了解如何覆盖BaseRequestOptions

这也可以通过在每个 HTTP 请求方法的最后一个参数中传递withCredentials:true标志来在每个请求级别上启用:

this.httpService.get(url,{withCredentials:true}); 

每个 HTTP 函数的最后一个参数,包括getpostput,都是一个RequestOptionsArgs对象。这允许我们覆盖正在进行的请求的一些属性。

一旦启用了这个标志,客户端浏览器将开始为跨域请求附加身份验证 cookie。

服务器也需要启用跨域资源共享CORS),并且需要以特定方式响应请求才能成功。它应该将access-control-allow-credentials标头设置为 true,并将access-control-allow-origin标头设置为发出请求的主机站点。

注意

查看 MDN 文档(bit.ly/http-cors)以详细了解这种情况。

基于 cookie 的身份验证在客户端方面确实更省事,但有时您必须返回到基于令牌的访问。这可能是因为:

  • Cookie 和跨域请求在各种浏览器中表现不佳。特别是,IE8 和 IE9 不支持它。

  • 服务器可能不支持生成 cookie,或者服务器只暴露基于令牌的身份验证。

  • 基于令牌的解决方案易于与原生移动应用程序和桌面客户端集成。

  • 令牌不容易受到跨站点请求伪造(CSRF)攻击的影响。

注意

要了解有关 CSRF 的更多信息,请查看这里的 CRSF Prevention cheat sheet bit.ly/csrf-cs

下一节将讨论支持基于令牌的身份验证。

基于令牌的身份验证

基于令牌的访问是指在每个请求中发送令牌(通常在 HTTP 标头中),而不是 cookie。简化的基于令牌的工作流程看起来像这样:

基于令牌的身份验证

许多公共 API(如FacebookTwitter)使用基于令牌的身份验证。令牌的格式、它的去向以及如何生成取决于所使用的协议和服务器实现。使用基于令牌的身份验证的流行服务实施OAuth 2.0协议进行令牌生成和交换。

在典型的基于令牌的身份验证设置中,视图是公开可用的,但 API 是受保护的。如果应用程序尝试通过 API 调用拉取数据而没有将适当的令牌附加到传出请求中,服务器将返回 HTTP 401 未经授权的状态代码。

与基于令牌的身份验证系统集成还需要在客户端方面进行相当多的设置。让我们以支持基于令牌的身份验证的人力资源(HR)系统为例,以便您了解身份验证工作流程如何与作为客户端的 Angular 应用程序一起工作。

HR 系统有一个显示员工列表和登录页面的页面。它还有用于获取员工列表和生成访问令牌的 API 端点。返回员工列表的 API 端点受基于令牌的访问控制。

工作流程从用户加载员工列表页面开始。视图加载了,但 API 调用失败,服务器返回 HTTP 401 未经授权

在收到 401 HTTP 错误代码时,应用程序应该通过将用户路由到登录视图(记住这是一个单页应用程序)或打开登录弹出窗口来做出响应。

这样的天真实现可能是:

this._http.get('/api/employees') 
.map(response => response.json()) 
   .catch((error:Response)=>{ 
      if(error.status == 401) { 
         this.router.navigate(['/login']); 
} 
}); 

前面实现的最大问题是我们需要为每个需要远程数据访问的组件添加 catch 块,因为调用可能会失败。不太聪明!

相反,我们必须将这个检查集中起来。鉴于 Angular 应用基本上是一个组件树,这样的检查可以添加到顶层组件中。canActivate 组件守卫钩子将是添加这个检查的正确位置。

因此,在应用根目录上给出了这个路由配置:

@const routes: Routes = [ 
    { path: 'home', component: HomeComponent }, 
    { path: 'login', component: LoginComponent } 
]; 

为了阻止对 /home 的未经授权访问,我们可以实现 canActivate 守卫类如下:

export class AuthGuard implements CanActivate { 
  canActivate() { 
    // Check if there is auth token and return true. 
    return true; 
  } 
} 

然后扩展 home 的路由定义以包括这个守卫:

{ path: 'home', component: HomeComponent,  
**canActivate:[AuthGuard] },**

要了解如何实现 canActivate 函数,我们需要知道在令牌身份验证期间客户端发生了什么。

使用基于令牌的身份验证,登录页面看起来类似于基于 cookie 的登录页面,但是在登录过程的一部分,服务器会返回一个令牌,而不是在服务器上设置 cookie。这个令牌需要附加到所有后续的安全 API 请求中。因此,这个令牌需要在浏览器的本地存储中持久化,并且这是在第一次接收到令牌时完成的。

身份验证服务可以执行这样的任务。服务的一个示例实现如下:

export class AuthService { 
  authenticate(username: string, password: string) { 
    return this.http.post('/login',  
          JSON.stringify({ u: username, p: password })) 
      .map((token: Response) => {  
        localstorage.setItem('token',token); 
        return true; 
      }); 
  } 
} 

服务对登录端点进行 HTTP post,并在接收到身份验证令牌时,将令牌存储在浏览器的本地存储中。

身份验证后,下一个任务是在访问受保护的 API 资源的后续请求中附加令牌。对于大多数 API 实现,这个令牌需要添加到请求头中。

所有http服务函数:getpost和其他-都需要一个额外的RequestOptionsArgs类型参数,用于传递一些额外的参数给请求。我们可以使用headers属性为需要授权的 HTTP 请求设置auth token。令牌的名称可能根据后端实现而有所不同,但这是一个典型的带有令牌的请求的样子:

this.http.get('secured/api/users',{ 
headers:{  
'Accept': 'application/json',  
**'Authorization': 'Bearer ' + localStorage.getItem('token')** 
}); 

尽管这种方法有效,但非常冗长和繁琐。现在每次调用安全 API 都需要设置这个额外的参数。我们需要一些更好的替代方案。

Angular 有一个BaseRequestOptions类,其中包含 HTTP 请求的默认选项。我们可以使用 Angular DI 将BaseRequestOptions替换为我们自己的选项。这可以通过继承BaseRequestOptions类并在引导期间覆盖BaseRequestOptions的 DI 提供程序来实现:

class MyOptions extends BaseRequestOptions { 
 header:Headers=new Header({ 
'Authorization': 'Bearer ' + localStorage.getItem('token') 
 }); 
} 

bootstrap(App, [HTTP_PROVIDERS, provide(RequestOptions, {useClass: MyOptions})]); 

可悲的是,这不起作用!因为在进行身份验证之前授权令牌不可用,所以在引导期间设置RequestOptions将导致所有未来的 HTTP 请求上设置一个空的Authorization头部。

提示

在引导期间覆盖RequestOptions只有在我们在那个阶段有必要的内容可用时才有用。

我们还有什么其他选择?与 Angular 1 不同,Angular 2 没有全局拦截器可以在请求发出之前用于注入头部。因此,唯一可行的替代方案是创建一个自定义的 HTTP 客户端服务来与安全 API 通信。当可用时,该服务可以透明地附加令牌。

我们已经为此创建了一个示例服务实现,该实现可在bit.ly/ng2-auth-svc上找到。我们将重点介绍这个AuthHttp服务的一些相关部分。

这个AuthHttp暴露了与 HTTP 服务相同的接口,它的函数在内部将请求委托给原始的http服务。下面的片段详细介绍了两个这样的 HTTP 函数,getpost

public get(url: string, options?: RequestOptionsArgs)  
:Rx.Observable<Response> { 
  return this._request(RequestMethod.Get, url, null,  
  options); 
    } 

public post(url: string, body: string, options?:  
RequestOptionsArgs) :Rx.Observable<Response> { 
  return this._request(RequestMethod.Post, url, body,  
  options); 
} 

每个 HTTP 函数包装器内部都调用_request私有方法。这个函数大部分工作都是设置 HTTP 授权头部,发出请求和接收响应。函数的实现看起来像这样:

private _request(method: RequestMethod, url: string,  
 body?: string, options?: RequestOptionsArgs):  
 Rx.Observable<Response> { 
let requestOptions = new RequestOptions(Object.assign({ 
            method: method, 
            url: url, 
            body: body 
    }, options)); 

   if(!requestOptions.headers) { 
      requestOptions.headers = new Headers(); 
   } 

requestOptions.headers.set("Authorization" 
,this._buildAuthHeader()) 

return Rx.Observable.create((observer) => { 
this.process.next(Action.QueryStart); 
this._http.request(new Request(requestOptions)) 
.map(res=> res.json()) 
.finally(() => {  
this.process.next(Action.QueryStop);}) 
            .subscribe( 
            (res) => { 
            observer.next(res); 
            observer.complete();}, 
            (err) => { 
               switch (err.status) { 
               case 401: 
                  //intercept 401 
                  this.authFailed.next(err); 
                  observer.error(err); 
                  break; 
               default: 
                  observer.error(err); 
                  break; 
} 
           }) 
        }) 
    } 

在合并请求选项并设置授权标头之后,该函数创建一个自定义可观察对象,使用http服务的request函数发出请求。在收到响应后,观察者发出响应并标记为完成(没有更多事件)。

在前面的函数中,401错误处理有点不同。该函数调用this.authFailed.next(err)并在AuthHttp类上定义的EventEmitter上引发事件:

authFailed: EventEmitter<any> = new EventEmitter<any>(); 

然后它使用observer.error(err)触发标准的错误触发机制;

这个事件的用处很快就会变得清楚。

_buildAuthHeader的实现很简单,它从浏览器的本地存储中获取授权令牌:

private _buildAuthHeader(): string { 
    return localStorage.getItem("authToken"); 
} 

现在可以注入此服务并用于调用任何安全 API,例如:

this.authHttp.get('/api/employees') 

此调用将在 API 请求中添加授权令牌(如果可用)。

到目前为止我们做了什么?视图已经使用@CanActivate装饰器进行了保护,API 端点也是安全的,但仍然有一个需要处理的情况。令牌过期时会发生什么?

当 API 令牌过期时,再次访问 API 端点会导致 401 错误。在这种情况下,应用程序应该要么重定向到登录页面,要么显示登录弹出窗口以继续。

要知道请求失败时,我们再次使用AuthHttp服务。可以订阅AuthHttp上的authFailed事件。订阅和对此事件做出反应的最佳位置将是根组件实现

在应用程序的根组件中,我们只需要做:

ngOnInit() { 
    this._authHttp.authFailed.subscribe((error)=>{ 
      this._router.navigate(['/login']); 
      // or 
      // this.showLoginDialog(); 
    }); 
} 

有了这一点,我们现在已经处理了大部分与基于令牌的身份验证相关的情况。

显然,基于令牌的身份验证,即使灵活,也需要在各种组件/服务之间进行相当数量的设置和协调。

本教程仅概述了向服务器发送令牌的一种机制,但该过程可能根据服务器堆栈而有所不同。在 Angular 中实现基于令牌的身份验证之前,始终参考后端/服务器文档。

注意

在编写本书时,并没有太多实现基于令牌的身份验证所需的标准任务的库。未来,在开始之前,请检查是否有相同的流行/成熟社区提供。

我们已经处理了身份验证,但授权呢?一旦用户上下文建立,我们仍然需要确保用户只能访问他/她被允许访问的部分。授权仍然缺失。

处理授权

与身份验证一样,授权支持也需要在服务器和客户端上实现,尤其是在服务器端而不是客户端。请记住,任何人都可以入侵 JavaScript 代码并规避完整的身份验证/授权设置。因此,无论客户端是否具有必要的检查,都应始终加强服务器基础设施。

这并不意味着我们在客户端不进行任何授权检查。对于标准用户来说,这是防止未经授权访问的第一道防线。

在处理任何应用程序的授权需求时,有三个基本元素是设置的一部分:

  • 需要进行安全/授权的资源

  • 角色和属于这些角色的用户列表

  • 资源和角色之间的映射,定义了谁可以访问什么

从 Angular 应用程序的角度来看,资源是需要限制到特定角色的页面,有时也包括页面的部分。如果用户处于特定角色,根据角色-资源的映射,他们可以访问一些页面;否则他们将被拒绝访问。

虽然在 Angular 应用程序中可以以多种方式实现授权,但我们将概述一种通用实现,可以根据未来的需求进行进一步定制。

添加授权支持

要启用授权,我们需要做的第一件事是在整个应用程序中公开已登录用户的数据,包括他/她的角色。

共享用户身份验证上下文

用户上下文可以使用 Angular 服务共享,然后注入到需要授权上下文的组件中。查看此服务接口:

class SessionContext { 
  currentUser():User { ... }; 
  isUserInRole(roles:Array<string>):boolean { ...}; 
  isAuthenticated:boolean; 
} 

SessionContext服务跟踪用户登录会话,并提供以下详细信息:

  • 已登录用户(currentUser

  • 用户是否经过身份验证(isAuthenticated

  • isUserInRole函数根据传入roles参数的角色,返回truefalse

有了这样一个服务,我们可以为路由添加授权,从而仅限制特定角色访问某些路由。

限制路由

像身份验证一样,canActivate守卫检查也可以用于授权。实现一个带有CanActivate接口的类,并将SessionContext服务注入到构造函数中;然后在canActivate函数中使用SessionContext服务检查用户是否属于特定角色。请查看以下代码片段:

export class AuthGuard implements CanActivate { 
  constructor(private session:SessionContext) { } 
  canActivate() { 
    return this.session.isAuthenticated &&  
      session.isUserInRole(['Contributor', 'Admin']); 
  } 
} 

现在只有ContributorAdmin角色的用户才能访问主页。

我们以与基于令牌的身份验证部分相同的方式在路由中注册前面的守卫类。

这就是我们授权访问我们的路由的方式。

但是当页面上的视图元素是根据用户的角色渲染时会发生什么?

基于角色有条件地渲染内容

有条件地渲染内容很容易实现。我们只需要根据用户角色显示/隐藏 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)来显示或隐藏片段的微不足道的实现。

这将我们带到了身份验证和授权实现的尽头。参考实现演练应该帮助我们将身份验证和授权构建到我们的应用中。有了这个基本的理解,任何设置都可以被调整以处理其他自定义的身份验证/授权场景。

现在是时候解决房间里的大象了:从 Angular 1 迁移到 Angular 2。如果您在 Angular 2 上重新开始,您可以很好地跳过下一节。

迁移 Angular 1 应用

如果您在 Angular 1 上进行了大量工作,那么 Angular 2 会提出一些相关的问题:

  • 我应该将我的 Angular 1 应用迁移到 Angular 2 吗?

  • 框架是否已经准备好投入使用?

  • 迁移应该何时发生?

  • 迁移是一次性的还是可以分阶段进行?

  • 涉及的工作量是多少?

  • 今天我能做些什么,以帮助未来的迁移?

  • 今天我要开始一个新的 Angular 1 应用。当 Angular 2 发布时,我应该怎么做才能使迁移无缝进行呢?

为了确保过渡尽可能顺利,每个这样的问题都需要得到解决。在接下来的章节中,我们将尝试回答许多这样的问题。作为学习的一部分,我们还将带您逐步将 Trainer 应用的 Angular 1 版本(为本书第一版开发)迁移到 Angular 2。这将帮助每个人做出一些明智的决定,决定何时以及如何迁移到 Angular 2。

“我应该迁移还是不迁移?”这是我们首先要解决的问题。

我应该迁移吗?

Angular 2 的出现并不意味着 Angular 1 消失。Angular 1 仍在与 Angular 2 并行进行积极开发。Google 承诺将长时间支持 Angular 1,并且在 Angular 1 中已经发布了一系列版本,其中包括 2016 年 2 月发布的 Angular 1.5。鉴于 Angular 1 短时间内不会消失,我们现在可以从我们应用的角度来考虑。

首先,Angular 2 相比于其前身有什么优势?

Angular 2 的优势

Angular 2 是为未来设计的,并克服了其前身的许多缺点。在本节中,我们强调了 Angular 2 比 Angular 1 更好的框架的特点。

在决定是否迁移到 Angular 2 时,您应该注意的事项:

  • 更好的行为封装:诚然,当我们开始学习 Angular 1 时,作用域似乎是上帝赐予的,但现在我们意识到了管理层次性质的作用域是多么困难。Angular 2 中的基于组件的开发在应用状态方面提供了更好的封装。组件管理自己的状态,接受输入,并触发事件:这是一种易于理解的责任清晰划分!

  • 应用代码中的框架减少:您不需要像作用域这样的特殊对象。DI 使用注解(在 TypeScript 中)。您不需要设置监视。总的来说,当阅读组件代码时,您不会在其中找到框架级别的构造。

  • 更小的框架 API 可供探索:Angular 1 有许多指令需要了解。而在 Angular 2 模板语法中,与浏览器事件相关的指令已经消失。这减少了需要了解的指令数量。

  • 性能:与其前身相比,Angular 2 更快。本书的一个完整部分专门讨论了什么使 Angular 2 成为高性能框架。

  • 移动友好:Angular 2 试图通过利用诸如服务器端渲染和 Web Workers 等技术来优化用户的移动体验。在移动设备上,Angular 2 应用程序比其前身更具性能。

  • 跨平台:Angular 2 的目标是在大多数设备和各种平台上运行。您可以使用 Angular 2 构建 Web 和移动应用程序。正如我们之前所了解的,渲染层的分离为 Angular 2 的利用提供了大量可能性。

从真正意义上讲,Angular 2 取代了其前身,在一个完美的世界中,每个人都应该在更好的框架/技术上工作。但是,如果您是谨慎的类型,并且希望在事情变得稳定之后尝试 Angular 2,我们建议您今天构建您的 Angular 1 应用程序的方式,以便轻松迁移到 Angular 2。

下一节讨论了今天遵循的 Angular 1 的原则和实践,以便未来方便迁移。

今天为易迁移开发 Angular 1 应用程序

Angular 2 是一个范式转变,我们在 Angular 2 中开发组件的方式与 Angular 1 完全不同。为了方便迁移,Angular 1 也应该采用基于组件的开发。如果我们在构建 Angular 1 应用程序时遵循一些准则/原则,就可以实现这一点。接下来的几节将详细介绍这些准则。

提示

即使您不打算迁移到 Angular 2,这里列出的建议也是强烈推荐的。这些建议将使 Angular 1 代码更加模块化、有组织和可测试。

一个文件一个组件

这可以是任何东西:一个 Angular 1 控制器指令过滤器服务。一个文件一个组件可以更好地组织代码并实现轻松迁移,使我们能够清楚地识别已经取得了多少进展。

避免内联匿名函数

使用命名函数而不是内联匿名函数来声明控制器、指令、过滤器和服务。这样的声明:

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 对象或直接使用作用域!

Angular 1 作用域最大的问题是它们的层次结构性质。从子作用域访问父作用域给了我们巨大的灵活性,但代价是很高的。这可能会不知不觉地创建不必要的依赖,使应用程序变得非常难以调试和迁移。相比之下,在 Angular 2 中,视图绑定到其组件实现,不能隐式地访问其边界之外的数据。因此,如果你计划迁移到 Angular 2,尽量避免使用作用域

有许多技术可以用来消除对$scope对象的依赖。接下来的几个小节详细阐述了一些可以帮助我们避免 Angular 1 作用域的技术。

在所有地方使用 controller as(控制器别名)语法

Angular 1.3+有controller as语法用于controllerdirectiveroutescontroller as允许 Angular 1 数据绑定表达式绑定到控制器实例属性,而不是当前作用域对象属性。有了控制器的范式,我们就不需要直接与作用域交互,因此未来的迁移变得容易。

注意

虽然控制器别名消除了作用域访问,但在 Angular 1 中仍然存在作用域。完整的 Angular 1 数据绑定基础设施依赖于作用域。控制器别名只是在我们的代码和作用域访问之间增加了一个间接。

在视图中考虑以下controller 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 as workoutList在当前作用域上为WorkoutListController创建了一个别名workoutList,因此允许我们绑定到控制器上定义的workouts属性。

路由定义也允许使用controllerAs属性在路由定义对象中进行控制器别名:

$routeProvider.when('/builder/workouts', { 
... 
   controller: 'WorkoutListController', 
 **controllerAs: 'workoutList'** 
 }); 

最后,指令也可以使用controllerAs,并且与指令定义对象上的bindToController属性一起,我们可以摆脱任何直接作用域访问。

注意

查看关于控制器、路由和指令的 Angular 文档,以对控制器别名语法有基本的了解。还可以查看以下帖子,了解更多关于这个主题的详细示例:bit.ly/ng1-controller-as

注意

bit.ly/ng1-bind-to

避免 ng-controller

如果可以避免作用域,那么控制器也可以避免!

这可能再次看起来违反直觉,但这种方法确实有实际好处。我们理想情况下要做的是在 Angular 1 中模拟组件行为。由于在 Angular 1 中最接近组件的东西是元素指令(带有restrict='E'),我们应该在任何地方都使用元素指令

具有自己模板和隔离作用域的 Angular 1 元素指令可以很好地像 Angular 2 组件一样工作,并且只依赖于其内部状态进行视图绑定。我们只是不需要ng-controller

考虑在个人健身教练应用程序的 Angular 1 版本中使用ng-controller进行音频跟踪:

<div id="exercise-pane" class="col-sm-7"> 
... 
  <span ng-controller="WorkoutAudioController"> 
    <audio media-player="ticksAudio" loop autoplay  
     src="content/tick10s.mp3"></audio> 
    <audio media-player="nextUpAudio"  
     src="content/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: '&' 
} 

Tero 在这两篇博客文章中广泛讨论了这个主题:

这些是必读的帖子,包含大量信息!

使用 Angular 1.5+组件 API 构建

Angular 1.5+有一个组件 API,允许我们创建指令,这些指令可以很容易地迁移到 Angular 2。组件 API 预先配置了合理的默认值,因此在构建真正隔离和可重用的指令时,可以合并最佳实践。

查看组件 API(bit.ly/ng1-dev-guide-components)和 Tod Motto 的这篇信息性文章(bit.ly/1MahwNs)来了解组件 API。

重申之前强调的内容,这些步骤不仅针对简化 Angular 2 迁移,也针对使 Angular 1 代码更好。基于组件的 UI 开发是比我们在 Angular 1 中习惯的更好的范例。

提示

我们强烈建议您阅读 Angular 1 风格指南(bit.ly/ng2-styleguide)。这个指南包含了大量的提示/模式,可以让我们构建更好的 Angular 1 应用程序,并且与之前提供的易于迁移到 Angular 2 的指南保持一致。

最后,如果您已经决定迁移,现在是时候决定迁移什么了。

迁移什么?

对于处于维护模式的应用程序,其中大部分开发活动都围绕着错误修复和一些增强,坚持使用 Angular 1 是明智的。记住那句老话“如果它没坏,就别修。”

如果应用程序正在积极开发并且有明确的长期路线图,考虑迁移到 Angular 2 是值得的。随着我们深入挖掘迁移的复杂性,我们将意识到这个过程所涉及的时间和精力。虽然 Angular 团队已经非常努力地使这次迁移顺利进行,但毫无疑问,这并不是一项琐碎的工作。实际迁移将需要大量的时间和精力。

这里的一线希望是我们不需要一次性迁移所有内容。我们可以慢慢地将 Angular 1 代码库的部分迁移到 Angular 2。两种框架可以共存,也可以相互依赖。这也使我们能够在 Angular 2 中开发应用程序的新部分。这是多么酷!

但是,这种灵活性也是有代价的-字节的代价。由于两种框架都被下载,页面字节会增加,这是我们应该注意的事情。

此外,虽然两种框架的共存使我们能够在不造成太多中断的情况下进行迁移,但我们不能让它成为一个永久性的活动。最终,Angular 1 必须离开,而且越早离开越好。

在迁移过程中,最好的做法是在现有应用程序中划分出新的 SPA。例如,我们可以完全使用 Angular 2 构建应用程序的管理区域,使用单独的主机页面,但仍然共享样式表、图像,甚至 Angular 1 服务的公共基础设施,如果我们稍微重构代码。正如我们将在后面学到的,将服务迁移到 Angular 2 是最容易的。

将应用程序分解为多个较小的应用程序会引入全页刷新,但在迁移时,这是一种更清晰的方法。

考虑到所有这些,如果您已经决定迁移并确定了迁移的区域,您需要为迁移做好准备工作。

为 Angular 2 迁移做准备

欢迎来到 Angular 2 迁移的大胆世界!成功的迁移策略包括确保我们事先做好准备,从而避免任何晚期的意外。

作为准备工作,第一步是从第三方库依赖的角度分析应用程序。

识别第三方依赖

任何 Angular 1 应用程序使用的第三方库也需要一个迁移策略。这些可能是基于 jQuery 的库或 Angular 1 库。

jQuery 库

在 Angular 1 中使用 jQuery 库是通过在其上创建一个指令包装器来消耗的。我们将不得不将这些指令迁移到 Angular 2。

Angular 1 库

迁移 Angular 1 库有点棘手。Angular 1 有一个庞大的生态系统,而 Angular 2 是一个新生事物。Angular 2 社区的成果要像 Angular 1 那样丰富还需要一些时间。因此,对于我们使用的每个 Angular 1 库,我们需要在 Angular 2 中找到一个替代品,或者创建一个,或者完全摆脱这个库。

以每个如此受欢迎的 UI 框架ui-bootstrapbit.ly/ng1-ui-bootstrap)为例。目前有多个项目正在重写这个库以适配 Angular 2,但都尚未完成。如果我们依赖ui-bootstrap

  • 我们可以使用端口,假设我们想要使用的组件已经迁移。这里值得注意的端口有ng-bootstrapgoo.gl/3dHkaU)和ng2-bootstrapgoo.gl/u4hOJn)。

  • 或者等待端口完成。

  • 或者采取更激进的方法,在 Angular 2 中构建我们自己的 bootstrap 库。

这些选择每一个在时间和复杂性方面都有权衡。

还需要做出的另一个选择是开发语言。我们应该使用 TypeSript、ES2015 还是普通的旧 JavaScript(ES5)?

语言的选择

我们肯定会推荐使用 TypeScript。这是一种非常棒的语言,与 Angular 2 集成非常好,大大减少了 Angular 2 声明的冗长。而且,它可以与 JavaScript 共存,使我们的生活更加轻松。即使没有 Angular 2,TypeScript 也是我们应该在 Web 平台上采用的一种语言。

在接下来的章节中,我们将把 Angular 1 个人教练应用迁移到 Angular 2。该应用当前可在GitHub上找到,网址为bit.ly/a1begit。该应用是本书第一版AngularJS by Example的一部分,是用 JavaScript 构建的。

注意

我们将再次遵循基于检查点的方法进行迁移。在迁移过程中我们突出的检查点已经实现为 GitHub 分支。只是这一次,没有伴随的代码库可供下载。

由于我们将与 v1 代码的git存储库进行交互,并使用 Node.js工具进行构建,请在继续之前在开发环境中设置gitnodejs

迁移 Angular 1 的个人教练

甚至在我们开始迁移过程之前,我们需要在本地设置 v1 个人教练

迁移后的应用代码可以从 GitHub 网站github.com/chandermani/angularjsbyexample下载。由于我们是分阶段迁移,我们创建了多个检查点,这些检查点对应于专门用于迁移的GitHub 分支。例如ng2-checkpoint8.1ng2-checkpoint8.2等分支突出了这一进展。在叙述过程中,我们将突出显示分支以供参考。这些分支将包含到目前为止在应用上完成的工作。

注意

7 分钟锻炼代码位于名为trainer的存储库文件夹中。

那么让我们开始吧!

在本地设置 Angular 1 的个人教练

按照以下步骤,您将很快就能上手:

  1. 从命令行中,克隆 v1 github 存储库:
**git clone https://github.com/chandermani/angularjsbyexample.git**

  1. 导航到新的 git 存储库并检出ng2-base分支以开始:
**cd angularjsbyexample**
**git checkout ng2-base**

  1. 由于该应用从mLab中托管的MongoDB中加载其锻炼数据(mlab.com/),您需要一个 mLab 账户来托管与锻炼相关的数据。通过注册 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 Personal Trainer所需的必要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 Personal Trainer的起始页面应该显示出来。玩转应用程序以验证应用程序是否正常工作。现在迁移可以开始了。

识别依赖关系

在我们开始迁移 v1 Personal Trainer之前的第一步是识别我们在 Personal Trainer 的 Angular 1 版本中使用的外部库。

我们在 v1 中使用的外部库有:

  • angular-media-player

  • angular-local-storage

  • angular-translate

  • angular-ui-bootstrap

  • owl. carousel

angular-media-playerangular-local-storage这样的库很容易迁移/替换。我们在本书的早期章节中已经做过这个。

angular-translate可以用ng2-translate替换,正如我们将在接下来的章节中看到的,这并不是一个非常具有挑战性的任务。

我们在Personal Trainer中使用angular-ui-bootstrap来进行模态对话框ng2-bootstrap (bit.ly/ng2-bootstrap)是一个值得接替的版本,并计划与旧版本的 bootstrap 具有 1-1 的对应关系,但在撰写本书时,模态对话框的实现还在进行中;因此我们不得不寻找替代方案。

还有另外两个专门针对模态对话框的库:angular2-modal (bit.ly/ng2-modal)和ng2-bs3-modal (bit.ly/ng2-bs3-modal)。我们可以选择其中一个并摆脱angular-ui-bootstrap库。

Owl. Carousel是一个 jQuery 库,我们可以编写一个 Angular 2 组件来包装这个插件。

现在我们已经解决了外部依赖关系,让我们决定要使用的语言。

虽然现有的代码库是 JavaScript,但我们喜欢 TypeScript。它的类型安全性,简洁的语法以及与 Angular 2 的良好兼容性使其成为我们的首选语言。因此,我们将全面采用 TypeScript。

另一个倾向于选择 TypeScript 的因素是我们不需要将现有的代码库迁移到 TypeScript。我们迁移/构建新的任何东西,都会用 TypeScript 构建。遗留代码仍然保留在 JavaScript 中。

让我们开始吧。作为第一个迁移任务,我们需要为我们的 v1 Personal Trainer 设置一个模块加载器。

设置模块加载器

由于我们将在许多小文件中创建许多新的 Angular 2 组件,直接添加脚本引用将变得繁琐且容易出错。我们需要一个模块加载器。模块加载器可以帮助我们:

  • 基于一些常见模块格式创建隔离/可重用模块

  • 根据依赖关系管理脚本加载顺序

  • 允许对模块进行捆绑/打包,并在开发/生产部署时进行按需加载

我们也使用SystemJS模块加载器进行此迁移。

使用命令行安装 SystemJS:

**npm i systemjs --save**

注意

所有命令都需要从trainer/app文件夹中执行。

我们打开index.html并删除所有应用程序脚本的脚本引用。应删除所有源为src='js/*.*'的脚本引用,除了angular-media-player.jsangular-local-storage.js,因为它们是外部库。

注意

注意:我们不会删除第三方库的脚本引用,只删除应用程序文件。

在所有第三方脚本引用之后添加 SystemJS 配置:

<script src="js/vendor/angular-local-storage.js"</script> 
**<script src="node_modules/systemjs/dist/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"> 

ng-app引导的方式必须消失,因为我们要切换到angular.bootstrap函数。手动引导有助于我们将 Angular 2 引入其中时。

前面的SystemJS.import调用通过加载js/app.js中定义的第一个应用程序模块来加载应用程序。我们将很快定义这个模块。

在与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'; 

angular.element(document).ready(function() { 
  angular.bootstrap(document.body, ['app'],  
{ strictDi: true }); 
}); 

我们已经在app.js中添加了ES6 导入语句。这些是之前在index.html中引用的相同脚本。现在 SystemJS 在加载app.js时会加载这些脚本文件。

将所有的 Angular 1 模块声明移到一个新文件app.module.js中,并首先在app.js中导入它,以确保在执行任何import语句之前定义了 Angular 1 模块。

提示

不要混淆ES6 模块和使用angular.module('name')定义/访问的Angular 2 模块。这两个概念完全不同。

最后几行使用angular.bootstrap函数引导了 Angular 1 应用程序。

模块加载现在已启用;让我们也启用 TypeScript。

启用 TypeScript

要启用 TypeScript,请使用npm安装 TypeScript 编译器:

**npm i typescript -g**

我们还可以安装 TypeScript类型定义管理器,允许我们为我们使用的库使用类型定义:

**npm i typings --save-dev**

注意

类型定义typings是定义 TypeScript/JavaScript 库的公共接口的文件。这些类型定义帮助 IDE 在库函数周围提供智能感知。大多数流行的 JavaScript 库和 TypeScript 编写的框架/库都有类型定义。

接下来,打开package.json并在脚本配置内添加以下行:

"scripts": { 
    "test": "echo "Error: no test specified" && exit 1" 
 **"tsc": "tsc -p . -w",** 
 **"typings": "typings"** 
  } 

注意

我们刚刚添加的两个scripts属性用于提供常用脚本的快捷方式。请参考以下示例,我们使用typings命令来安装typingsnpm run typings)。

为 Angular 1 和 jQuery 安装类型定义。从命令行运行:

**npm run typings install dt~jquery dt~angular dt~angular-route dt~angular-resource dt~angular-mocks dt~core-js dt~angular-ui-bootstrap -- --save --global** 

提示

如果安装typings时遇到问题,请确保安装的typings包是最新的,然后重试。我们可以使用此命令检查包的最新版本:

npm show typings version

提示

使用命令行更新package.json到最新版本,并调用npm install

现在我们需要为 TypeScript 编译器设置一些配置。创建一个名为tsconfig.json的文件(在trainer/app文件夹中),并从ng2-checkpoint8.1存储库分支(也可以远程获取bit.ly/ng2be-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,因为我们已经通过以下方式在 Angular 1 中启用了strict DI check

  angular.bootstrap(document.body, ['app'],  
**{ strictDi: true });**

ng2-checkpoint8.1中提供的更新内容或bit.ly/ng2be-8-1-configjs中提供的更新内容替换config.js的内容(并记得重新设置 API 密钥)。此更新修复了config函数,并使其适用于缩小。是时候测试应用程序了!

确保 TypeScript 编译器在一个控制台中运行;在新的控制台窗口上运行http-server -c-1

导航至http://localhost:8080;,应用程序的起始页面应该加载。

提交您的本地更改

提示

如果一切正常,甚至可以将您的本地更改提交到 git 存储库。这将帮助您跟踪随着我们逐步迁移应用程序而发生的变化。

到目前为止的实现可在ng2-checkpoint8.1 GitHub 分支中找到。

如果您遇到问题,请比较ng2-baseng2-checkpoint8.1分支,以了解所做的更改。由于代码托管在 GitHub 上,我们可以使用GitHub 比较界面来比较单个分支中的提交。请参阅此处的文档以了解如何操作:bit.ly/github-compare

bit.ly/ng2be-compare-base-8-1链接显示了ng2-baseng2-checkpoint8.1之间的比较。

您可以忽略作为 TypeScript 编译的一部分生成的app.jsapp.module.js的差异视图。

是时候介绍 Angular 2 了!

添加 Angular 2

我们首先要为我们的应用程序安装 Angular 2 和依赖的npm 模块。我们将首先使用必要的软件包更新package.json文件。

bit.ly/ng2be-8-2-package-json中复制所有更新的软件包文件到您的本地安装。

package.json现在引用了一些与 Angular 2 相关的新软件包。通过调用来安装引用的软件包:

**npm install**

提示

如果您在使用npm install安装软件包时遇到问题,请删除node_modules文件夹,然后再次运行npm install

然后在index.html中的system.src.js脚本引用之前(总共三个)添加 Angular 2 所依赖的一些库引用(并且不使用 SystemJS 加载):

**<script src="/node_modules/core-js/client/shim.min.js"></script>**
**<script src="/node_modules/zone.js/dist/zone.js"></script>**
**<script src="/node_modules/reflect-metadata/Reflect.js"></script>**<script src="/node_modules/systemjs/dist/system.src.js"></script> 

目前,SystemJS 配置存在于index.html文件本身中。由于 Angular 2 需要一定数量的配置,我们将创建一个单独的SystemJS 配置文件,并在index.html中引用它。

system.src.js引用之后添加这个脚本引用:

<script src="systemjs.config.js"></script> 

现在清除包含对System.config函数调用的脚本部分,并替换为:

<script>System.import('app');</script> 

bit.ly/ng2be-migrate-systemjs-config复制systemjs.config.js并将其放在与package.json相同的文件夹中。

注意

这个配置文件源自 Angular 的快速入门指南,可在bit.ly/ng2-qsguide上找到。您可以从在线指南中了解更多关于这个配置的信息。

还要更新tsconfig.json并在compilerOptions中添加一个名为moduleResolution的新属性:

  "removeComments": false, 
 **"moduleResolution": "node"**

这指示 TypeScript 在node_modules文件夹中查找类型定义。请记住,Angular 2 的类型定义已经作为 Angular 2 库的一部分捆绑在一起,因此不需要单独的类型定义导入。

现在已经添加了 Angular 2 特定的引用,我们需要修改现有的引导过程以加载 Angular 2。

Angular 团队提出了一个名为UpdateAdapter的 Angular 2 服务,它允许在 Angular 1 设置中引导 Angular 2。UpgradeAdapter服务实现了许多常见的迁移用例。使用UpgradeAdapter,我们可以:

  • 引导一个同时加载 Angular 1 和 Angular 2 框架的应用程序。这是我们要做的第一件事。

  • 在 Angular 1 视图中合并一个 Angular 2 组件。

  • 在 Angular 2 视图中合并一个 Angular 1 组件,尽管有一些限制。

  • 注册一个 Angular 1 服务到 Angular 2,反之亦然。

UpgradeAdpater服务的唯一目的是允许逐步将 Angular 1 的构件迁移到 Angular 2。随着我们迁移工作的进展,UpgradeAdpater的作用变得清晰起来。

让我们学习如何使用UpgradeAdpater引导混合的 Angular 1 和 Angular 2 应用程序。

引导混合应用程序

由于我们从 Angular 2 开始,我们需要定义一个根应用程序模块。

创建一个新文件app-ng1.module.js,并将app.module.ts的完整内容复制到新文件中。还要记得更新app.ts中的import语句:

import  './app-ng1.module.js'; 

现在让我们将 Angular 2 根模块定义(AppModule)添加到app.module.ts中。

用 Angular 2 模块定义替换app.module.ts的内容。从ng2-checkpoint8.2(GitHub 位置:bit.ly/ng2be-8-2-app-module-ts)复制新的定义。

接下来,在与app.ts相同的文件夹中创建一个名为upgrade-adapter.ts的文件,并为UpdateAdapter实例添加一个全局导出

import {UpgradeAdapter} from '@angular/upgrade'; 
import {AppModule} from './app.module'; 
export const upgradeAdapter = new UpgradeAdapter(AppModule); 

这个UpgradeAdpater实例(名为upgradeAdapter)现在全局可用,并且可以用来引导混合应用程序。

注意

导出UpgradeAdapter的实例允许我们在整个应用程序中重用相同的实例。这是框架之间互操作的强制要求。

将我们刚创建的文件导入到app.ts中。在app.ts的其他导入之后添加此导入语句:

import {upgradeAdapter} from './upgrade-adapter'; 

并用以下代码替换引导代码:

angular.element(document).ready(function() {     
    upgradeAdapter.bootstrap(document.body, ['app'], { strictDi: true }); 
}); 

刷新您的应用程序,并确保它像以前一样工作。确保在 TypeScript 编译器控制台窗口中注意错误。

恭喜!我们现在有一个混合应用程序正在运行。两个框架现在同时工作。

注意

如果您在升级到 Angular 2 时遇到问题,请查看ng2-checkpoint8.2分支。同样,您也可以比较这些 git 分支ng2-checkpoint8.1ng2- checkpoint8.2,以了解发生了什么变化(bit.ly/ng2be-compare-8-1-8-2)。

迁移过程现在可以开始。我们可以从将 Angular 1 视图/指令的一部分迁移到 Angular 2 开始。

将 Angular 2 组件注入到 Angular 1 视图中

最常见的迁移模式涉及将较低级别的 Angular 1 指令/视图迁移到 Angular 2 组件。如果我们将 Angular 1 HTML 视图结构可视化为指令树,我们从叶子开始。我们将指令/视图的部分迁移到 Angular 2 组件,并将该组件嵌入到 Angular 1 视图模板中。这个 Angular 2 组件被注入到 Angular 1 视图中作为元素指令

注意

Angular 1 最接近 Angular 2 组件的东西是元素指令。在迁移过程中,我们要么迁移元素指令,要么迁移控制器(ng-controller)-视图对。

这是将视图/指令逐步迁移到 Angular 2 组件的自下而上方法。以下图表突出显示了 Angular 1 视图层次结构如何逐渐转变为 Angular 2 组件树:

将 Angular 2 组件注入到 Angular 1 视图中

让我们迁移一些小的东西,感受一下事情是如何运作的。ExerciseNavController及其对应的视图符合要求。

将我们的第一个视图迁移到 Angular 2 组件

ExerciseNavController训练建造者的一部分,位于trainer/app/js/WorkoutBuilder/exercise.js内。相应的视图是从trainer/app/partials/workoutbuilder/left-nav-exercises.html提供的。

这个控制器-视图对的主要目的是在构建训练时显示可用的练习列表(可用用户路径http://localhost:8080/#/builder/workouts/new):

将我们的第一个视图迁移到 Angular 2 组件

点击任何这些练习名称都会将练习添加到正在构建的训练中。

让我们从为上述视图创建一个组件开始。

注意

在开始新组件之前,将一个新的训练建造者模块(WorkoutBuilderModule)添加到应用程序中。从WorkoutBuilder文件夹中的ng2-checkpoint8.3中复制模块定义(GitHub 位置:bit.ly/ng2be-8-3-workout-builder-module-ts)。还要在app.module.ts中导入新创建的模块。

我们建议不要在这里内联完整的代码,而是从 GitHub 分支ng2-checkpoint8.3中复制exercise-nav-component.ts文件(bit.ly/ng2be-8-3-exercisenavts),并将其添加到本地的WorkoutBuilder文件夹中。由于有相当多的样板代码,我们只会突出显示相关的部分。

对比模板语法

提示

Angular 团队已经发布了一个优秀的参考资料(bit.ly/ng2-a1-a2-quickref),详细介绍了 Angular 1 中的常见视图语法及其在 Angular 2 中的等价物。在迁移 Angular 1 应用程序时强烈推荐!

首先,如果你看一下exercise-nav-component.ts文件,组件模板类似于 Angular 1 中使用的left-nav-exercises.html,只是没有ng-controller,模板绑定是基于 Angular 2 的:

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 2 组件中的 Angular 1 服务。

不错!如果这是你的初步反应,我们也不怪你。这很酷!但故事还没有完全结束。这里没有发生魔法。Angular 2 不能访问 Angular 1 的服务,除非告诉它在哪里找。UpgradeAdapter 在这里承担了大部分的重活。

将 Angular 1 依赖注入到 Angular 2

UpgradeAdapter 有一个 API 方法,允许我们在 Angular 2 中注册一个 Angular 1 服务。打开 app.ts 并在 upgrade-adapter 导入后添加这些行:

upgradeAdapter.upgradeNg1Provider('WorkoutService'); 
upgradeAdapter.upgradeNg1Provider('WorkoutBuilderService'); 

updateNg1Provider,顾名思义,将一个 Angular 1 依赖注册到 Angular 2 根注入器。一旦注册,该依赖就可以在整个 Angular 2 应用程序中使用。

提示

与服务共享功能

UpgradeAdapter 还会确保服务的同一个实例在两个框架中共享。这使得服务成为在两个框架之间共享行为的首选候选者。而且,正如我们很快将看到的那样,它也可以反过来工作。

有时依赖关系还有其他依赖,因此最好一次性从 Angular 1 中引入所有服务依赖。将 Angular 1 依赖注册的列表(使用 upgradeNg1Provider 完成)从 bit.ly/ng2be-8-3-appts 复制到你的本地 app.ts。记得删除我们已经导入的两个多余的声明。

回到组件集成!由于 ExercisesNavComponent 在 Angular 1 视图中呈现,因此需要将其注册为 Angular 1 指令

将 Angular 2 组件注册为指令

ExercisesNavComponent 是一个 Angular 2 组件,但它可以转换为 Angular 1 指令。打开 app.ts 并添加以下突出显示的行:

**import {ExercisesNavComponent}** 
**from './WorkoutBuilder/exercise-nav-component'** 
import {upgradeAdapter} from './upgrade-adapter'; 
**angular.module('WorkoutBuilder').directive('exerciseNav', upgradeAdapter.downgradeNg2Component(ExercisesNavComponent) as angular.IDirectiveFactory);**

这次使用的 UpgradeAdapter 函数是 downgradeNg2Component。该函数返回一个包含 指令定义对象工厂函数。我们将组件注册为 Angular 1 指令,exerciseNav

注意

每个 Angular 2 组件在 Angular 1 中使用时都被注册为 元素指令

组件实现已经完成。现在我们需要清理旧代码,并在视图中注入新指令。

打开 app.ts 并添加导入语句以导入新创建的组件:

import './WorkoutBuilder/exercise-nav-component'; 

exercise.js 中删除 ExercisesNavController 的定义,并用 partials 文件夹中的 left-nav-exercises.html 替换其内容:

<exercise-nav></exercise-nav> 

我们已经准备就绪。

Angular 1 仍然将left-nav-exercises.html作为路由转换的一部分加载,但内部视图是一个 Angular 2 组件。

继续尝试新的实现。创建一个新的锻炼并尝试从左侧导航中添加练习。功能应该像以前一样工作。

注意

如果您在升级到 Angular 2 时遇到问题,请查看ng2-checkpoint8.3

您可以比较 git 分支ng2-checkpoint8.2ng2-checkpoint8.3来了解发生了什么变化(bit.ly/ng2be-compare-8-2-8-3)。

虽然我们只迁移了一个微不足道的组件,但这个练习突显了将 Angular 2 组件转换/降级为 Angular 1 指令并在 Angular 1 视图中使用是多么容易。Angular 2 组件的整体封装使得这个任务变得容易。

这个降级的组件甚至可以使用所有熟悉的 Angular 2 属性绑定语法从父作用域中获取输入:

<exercise-nav [exercises]='vm.exercises'></exercise-nav> 

此外,组件引发的事件也可以由 Angular 1 容器作用域订阅:

<exercise-nav (onExerciseClicked)='vm.add(exercise)'></exercise-nav> 

我们现在有一个 Angular 2 组件在最初设计用于 Angular 1 的服务中运行。这是我们迁移旅程的一个有希望的开始!

在我们继续之前,是时候强调这种协作是如何工作的和规则的参与了。

规则的参与

从 Angular 1 到 Angular 2 的迁移故事之所以可能,是因为这些框架可以共存,并可能共享数据。有一些接触点可以跨越边界。为了更好地了解混合应用程序的工作原理以及在这种设置中可以实现什么,我们需要了解两个框架之间的协作领域。

有三个需要讨论的领域:

  • DOM 中的模板交错

  • 依赖注入

  • 变更检测

由于 Angular 2 组件和 Angular 1 指令可以共存于 DOM 中,我们需要回答的问题是:谁拥有 DOM 的哪些部分?

Angular 1 指令和 Angular 2 组件

当涉及到 DOM 元素的所有权时,黄金法则是:

每个 DOM 元素都由 Angular 框架中的一个管理/拥有。

以前的迁移示例。ExercisesNavComponent的视图由 Angular 2 管理,而容器视图(left-nav-exercises.html)由 Angular 1 管理。

在这些指令和组件的边界处有些棘手的事情。考虑left-nav-exercises.html中的声明:

<exercise-nav></exercise-nav> 

谁拥有这个?简短的答案是 Angular 1。

虽然这是一个 Angular 2 组件,但宿主元素由 Angular 1 拥有。这意味着所有 Angular 1 模板语法都适用:

<exercise-nav ng-if='showNav'></exercise-nav> 
<exercise-nav ng-repeat='item in items'></exercise-nav> 

由于这些组件和指令共存于同一个视图中,它们经常需要进行通信。管理这种通信有两种方式:

  • 使用 Angular 1 和 Angular 2 的模板能力:

  • 嵌入在 Angular 1 视图中的 Angular 2 组件可以通过事件和属性绑定从父作用域获取输入

  • 类似地,如果一个指令被注入到 Angular 2 组件视图中,它也可以从父组件获取输入,并调用父组件函数(通过其隔离作用域)

  • 使用共享服务。我们之前看到了一个例子,我们将WorkoutServiceWorkoutBuilderService Angular 1 服务注入到ExercisesNavComponent中。

注意

将 Angular 1 指令注入到 Angular 2 中有点棘手。要能够将 Angular 1 指令注入到 Angular 2 模板中,该指令需要遵守一些规则。我们将在接下来的部分讨论这些规则。

使用服务共享功能比通过视图模板共享要灵活得多。在跨框架边界注入服务需要我们在两个框架中注册服务,并让 Angular 来处理其余部分。让我们学习一下跨边界的依赖注入是如何工作的。

资源共享和依赖注入

在混合应用中,依赖项的注册方式受这两个框架中 DI 的工作方式的驱动。对于 Angular 1,只有一个全局注入器,而 Angular 2 有一个分层注入器的概念。在混合环境中,最小公分母是两个框架都支持的全局注入器。

共享一个 Angular 1 服务

在 Angular 1 中定义的依赖项一旦在 Angular 2 的app 注入器(根注入器)中注册后,就可以在 Angular 2 中使用。UpgradeAdapter函数upgradeNg1Provider处理了这个问题:

UpdateAdapter.upgradeNg1Provider(name:string,  
  options?: {asToken: any;})) 

由于 Angular 1 中的依赖注入是基于字符串标记的,第一个参数是服务的名称(字符串标记)。第二个参数允许我们为 v1 服务注册一个自定义的 Angular 2 标记。

这是我们在之前迁移的nav组件中所做的练习:

upgradeAdapter.upgradeNg1Provider('WorkoutService'); 

当涉及在 Angular 2 中注入依赖时,我们需要使用Inject装饰器(带有字符串标记)进行注入:

constructor(  
  @Inject('WorkoutService') private workoutService: any,  

WorkoutService是一个通用的提供者,注册到 Angular 1 中。如果这是一个 TypeScript 类,注册可以使用额外的类令牌完成:

upgradeAdapter.upgradeNg1Provider('WorkoutService', 
  {asToken:WorkoutService}); 

并且可以使用熟悉的类型注入而无需装饰器:

constructor(private workoutService: WorkoutService,  

共享一个 Angular 2 服务

来自 Angular 2 的服务也可以被注入到 Angular 1 中。由于 Angular 1 只有一个全局注入器,所以依赖项是在全局级别注册的。执行此操作的UpgradeAdapter函数是:

UpgradeAdapter.downgradeNg2Provider(token:any):Function

downgradeNg2Provider创建了一个工厂函数,可以被 Angular 1 模块的factory API 使用:

angular.module('app').factory('MyService',  
  UpgradeAdapter.downgradeNg2Provider(MyService)) 

MyService现在可以像任何其他服务一样在整个 Angular 1 应用程序中被注入。

UpgradeAdapter确保只创建一个依赖项的单个实例,并在框架之间共享。

看一下下面的图表;它总结了我们讨论过的内容:

共享一个 Angular 2 服务

本次讨论的最后一个主题是变更检测

变更检测

在混合应用程序中,变更检测由 Angular 2 管理。如果你习惯在你的代码中调用$scope.$apply(),在混合应用程序中就不需要了。

我们已经讨论了 Angular 2 变更检测的工作原理。Angular 2 框架通过在标准触发点内部调用$scope.$apply()来触发 Angular 1 变更检测。

现在我们了解了参与规则,更容易理解事情是如何工作的,什么是可行的,什么是不可行的。

让我们设定一些更大/更丰富的目标,并迁移 v1 应用程序的起始页和完成页。

迁移起始页和完成页

完成页很容易迁移,我建议你自己来做。在js文件夹内创建一个名为finish的文件夹,并创建三个文件,finish-component.tsfinish-component.tpl.htmlfinish.module.ts,用于组件的代码和视图模板以及模块定义。实现这个组件。

将完成模块导入到AppModuleapp.module.ts)中。然后修复完成的路由以加载完成组件。

$routeProvider.when('/finish', { template: '<finish></finish>' }); 

最后,记得从partials/workout文件夹中删除完成的 html 模板。

注意

如果你在迁移完成页时遇到困难,请比较ng2-checkpoint8.3ng2-checkpoint8.4 git 分支,了解8.4分支中发生了什么变化(bit.ly/ng2be-compare-8-3-8-4)。

完成页面很容易,但起始页面不是!虽然起始页面似乎是一个容易的目标,但有一些挑战需要一些深思熟虑。

起始页面最大的问题是它使用了第三方库angular-translate来本地化页面的内容。由于我们将完整页面/视图迁移到 Angular 2,我们需要一种机制来处理这些 Angular 1 库的依赖关系。

angular-translate带有一个filter(在 Angular 2 世界中是pipe)和一个指令,都名为translate。它们的工作是将字符串标记翻译成本地化的字符串文字。

现在start页面变成了一个 Angular 2 组件,我们需要将过滤器转换为 Angular 2 管道,并以某种方式使translate指令在 Angular 2 中工作。在这种情况下,我们的迁移选择如下:

  • 创建一个新的过滤器,并使用UpgradeAdapter升级 v1 的translate指令。

  • 在 Angular 2 世界中找到angular-translate的合适替代品。

尽管第一个选择似乎是最简单的,但它有一些严重的限制。Angular 2 UpgradeApapter带有一个upgradeNg1Component函数,可以升级任何 Angular 1 指令。并非如此!在哪些指令可以升级到 Angular 2 周围有一些严格的要求。

注意

升级 Angular 1 组件并不意味着该组件已经迁移。相反,Angular 2 允许我们在 Angular 2 组件视图中直接使用 Angular 1 元素指令。

Angular 1 指令升级

有时,应用程序的部分可能以自上而下的方式迁移;一个高级别视图被转换为一个组件。在这种情况下,我们不是迁移 Angular 1 视图中的所有自定义指令,而是使用UpgradeAdpater函数upgradeNg1Component将它们升级为 Angular 2 组件。以下图表说明了这种迁移路径:

Angular 1 指令升级

Angular 2 框架对可以升级为 Angular 2 组件的内容施加了一些限制。以下是来自 Angular 2 迁移指南的摘录。

要使 Angular 1 组件指令与 Angular 2 兼容,应配置这些属性:restrict: 'E'。组件通常用作元素。

scope: {} - 一个隔离的作用域。在 Angular 2 中,组件总是与其周围隔离的,我们在 Angular 1 中也应该这样做。

bindToController: {}。组件的输入和输出应该绑定到控制器,而不是使用$scope。

controllercontrollerAs。组件有自己的控制器。template 或 templateUrl。组件有自己的模板。

组件指令也可以使用以下属性:

transclude: true,如果组件需要从其他地方传递内容。

require,如果组件需要与某个父组件的控制器通信。

组件指令不得使用以下属性:

compile。这在 Angular 2 中将不再受支持。

replace: true。Angular 2 永远不会用组件模板替换组件元素。这个属性在 Angular 1 中也已经被弃用。

priorityterminal。虽然 Angular 1 组件可以使用这些属性,但在 Angular 2 中不使用,最好不要编写依赖于它们的代码。

提示

唯一可以升级到 Angular 2 的 Angular 1 指令是元素指令,前提是满足所有其他条件。

有了这么多的待办事项清单,将 Angular 1 指令升级到 Angular 2 相对困难,而将 Angular 2 组件降级则更容易。往往情况下,如果父视图已经迁移到 Angular 2,我们必须对 Angular 1 指令进行实际的代码迁移。

查看angular-translate的源代码,我们意识到它使用了$compile服务;因此,升级选项被排除在外。我们需要找到一个替代库。

我们确实有一个针对 Angular 2 的国际化库,ng2-translate (bit.ly/ng2-translate)。

用 ng2-translate 替换 angular-translate

ng2-translate是一个针对 Angular 2 的国际化库。这个库可以替代 v1 angular-translate

安装ng2-translate的 npm 包:

**npm i ng2-translate --save**

更新systemjs.config.js以包括ng2-translate库。在mappackages属性中添加条目:

var map = {... 
 **'ng2-translate': '/node_modules/ng2-translate/bundles'** 
var packages = { ... 
 **'ng2-translate': { defaultExtension: 'js' }**

ng2-translate需要在模块级别进行配置,因此请使用突出显示的代码更新app.module.ts

@NgModule({
 **imports: [BrowserModule, ...,**
 **HttpModule, TranslateModule.forRoot(),],**
 **providers: [TranslateService,**
 **{**
 **provide: TranslateLoader,**
 **useFactory: (http: Http) => new TranslateStaticLoader(http, 'i18n', '.json'),**
 **deps: [Http]**
 **}]**
})

前面的提供者声明设置了一个加载器,从i18n文件夹加载翻译文件(.json)。HttpModule导入是必需的,以便翻译库从服务器加载翻译。

这些语句需要导入以使 TypeScript 编译器满意。将这些导入语句添加到app.module.ts

import {TranslateModule, TranslateService,                TranslateLoader, TranslateStaticLoader} from 'ng2-translate/ng2-translate';  

ng2-translate库现在已经准备好使用。

我们要做的第一件事就是在应用程序引导时设置默认的翻译语言。

使用一个用于初始化的 bootstrap-ready 回调

有了 Angular 2,幸运的是UpdateAdapter上的 bootstrap 函数有一个专门用于此目的的ready回调函数。它在两个框架的引导之后被调用。

app.ts中更新 bootstrap 函数,使用以下代码片段:

upgradeAdapter.bootstrap(document.body, ['app'],  
 { strictDi: true }) 
 .ready((updateApp: UpgradeAdapterRef) => { 
    var translateService =  
      updateApp.ng2Injector.get(TranslateService); 

    var userLang = navigator.language.split('-')[0];  
    userLang = /(fr|en)/gi.test(userLang) ? userLang : 'en'; 

    translateService.setDefaultLang('en'); 

    translateService.use(userLang); 
  }); 

并添加一个对TranslateService的导入:

import {TranslateService} from 'ng2-translate/ng2-translate'; 

代码尝试确定当前浏览器语言,并相应地设置翻译的当前语言。注意我们如何获取TranslateServiceUpgradeAdapterRef对象持有对 Angular 2 root injector的引用,后者又加载了ng2-translateTranslateService

接下来,从ng2-checkpoint8.4分支(bit.ly/ng2be-8-4-start)复制与开始页面实现相关的三个文件到一个新的文件夹app/js/start中。

现在开始组件在使用之前需要注册为 Angular 1 指令。将此语句添加到app.ts中:

import {StartComponent} from './start/start-component'; 
angular.module('start').directive('start', upgradeAdapter.downgradeNg2Component(StartComponent) as angular.IDirectiveFactory); 

现在开始模板文件使用translate pipe(pipe 的名称与 Angular 1 的 filter translate相同)。

页面还有一些 pipes,用于搜索和排序页面上显示的训练列表:

<a *ngFor="#workout of workouts|search:'name':searchContent|orderBy:'name'" href="#/workout/{{workout.name}}"> 

现在我们需要为orderBysearch的 pipes 添加实现。从ng2-checkpoint8.4bit.ly/ng2be-8-4-pipests)复制完整的代码,并将其添加到一个新文件js/shared/pipes.ts中。我们不会在这里深入讨论任何 pipe 的实现,因为我们在之前的章节中已经做过了。

再次创建一个新的 Angular 2 模块,以在整个应用程序中共享这些 pipes。从ng2-checkpoint8.4bit.ly/ng2be-shared-module-ts)复制模块定义到本地的js/shared文件夹,并将其导入app.module.ts

注意

我们之前已经将secondsToTime(在js/7MinWorkout/filters.js中可用)过滤器迁移到了 Angular 2,并且实现已经在pipes.ts文件中可用。

开始和结束组件的实现已经完成。让我们将它们集成到应用程序中。

集成开始和结束页面

开始/结束视图作为路由更改的一部分加载;因此我们需要修复路由定义。

打开app.ts并添加对 start 和 finish 的导入:

import './start/start-component'; 
import './finish/finish-component'; 

路由定义在config.js中。将开始和结束路由定义更新为:

$routeProvider.when('/start',  
**{ template: '<start></start>' });** 
$routeProvider.when('/finish',  
**{ template: '<finish></finish>' });**

路由模板 html 是 Angular 1 视图的一部分。由于我们已经将StartComponentFinishComponent注册为 Angular 1 指令,路由加载了正确的组件。

注意

如果您已经迁移了完成页面,那么您不需要按照描述重新导入和设置完成的路由。

在我们测试实现之前还有一些修复工作要做。

使用模块更新app-ng1.module.ts,包括startfinish

angular.module('app', ['ngRoute', ... ,  
**'start', 'finish']);** 
... 
**angular.module('start', []);angular.module('finish', []);**

最后,从ng2-checkpoint8.4文件夹i18n中复制翻译文件de.jsonen.jsonbit.ly/ng2-8-4-i18n)。现在我们准备测试我们开发的内容。

如果没有启动,请启动 TypeScript 编译器和HTTP 服务器,然后启动浏览器。开始和结束页面应该可以正常加载。但是翻译不起作用!点击顶部导航栏上的语言翻译链接没有任何影响。内容始终以英文呈现。

注意

如果您遇到困难,请比较 git 分支ng2-checkpoint8.3ng2-checkpoint8.4,了解发生了什么变化(bit.ly/ng2be-compare-8-3-8-4)。

翻译仍然不起作用,因为启用翻译的顶部导航栏代码(root.js)仍在使用旧库。我们需要摆脱 angular-translate(v1 库)。有两个库做同样的工作并不是我们想要的,但是移除它也不是那么简单。

摆脱 angular-translate

为了摆脱 angular-translate(v1)库,我们需要:

  • 从所有 Angular 1 视图中删除 angular-translate 的指令/过滤器引用。

  • 摆脱任何使用这个库的代码。

摆脱 v1 指令/过滤器是一项艰巨的任务。我们既不能在 Angular 1 视图中添加 v2 ng2-translate 管道,也不能一次性将使用 v1 指令/过滤器的每个视图迁移到 Angular 2。

为什么不编写一个新的 Angular 1 过滤器,使用 ng2-translate 的翻译服务(TranslateService)进行翻译,然后在所有地方使用新的过滤器?问题解决!

让我们称这个过滤器为ng2Translate。我们用ng2Translate替换 v1 视图中对translate过滤器的所有引用。所有 v1 translate指令引用也应该被替换为ng2Translate过滤器。

以下是过滤器实现的样子:

import {TranslateService} from 'ng2-translate'; 

export function ng2Translate(ng2TranslateService: TranslateService) { 
   function translate(input) { 
    if (input && ng2TranslateService.currentLang) { 
      return ng2TranslateService.instant(input); 
    } 
  } 
  translate['$stateful'] = true; 
  return translate; 
} 

ng2Translate.$inject = ['ng2TranslateService']; 
angular.module('app').filter("ng2Translate", ng2Translate); 

shared文件夹中创建一个名为filters.ts的文件,并添加上述实现。该过滤器使用TranslateService(在 Angular 1 中注册为ng2TranslateService)将字符串标记映射到本地化内容。为了测试这个实现,还需要进行一些步骤:

  • 在 Angular 1 视图中,用ng2Translate替换所有对translate(指令和过滤器)的引用。这些文件中有引用: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'|ng2Translate}}</h3> 

记得在插值中引用字符串标记('RUNNER.STEPS')。

  • 将过滤器导入到app.ts中:
        import './shared/filters' 

  • ng2Translate过滤器依赖于TranslateService;因此需要在 Angular 1 注入器中注册(在app.ts中):
        angular.module('app').factory('ng2TranslateService', 
        upgradeAdapter.downgradeNg2Provider(TranslateService)); 

在 Angular 1 中,TranslateService被注册为ng2TranslateService

  • 最后,从bit.ly/ng2-migrate-root-no-trasnlate复制更新的root.js。我们已经用ng2TranslateService替换了所有对$translate服务的引用,并重构了代码以使用新的服务。root.js包含了 v1 RootController的实现。

现在我们可以开始了。尝试新的实现,应用程序应该使用 ng2-translate 库加载翻译。

现在我们可以删除所有对 angular-translate 的引用。在index.htmlapp.module.tsconfig.js中都有引用。

开始和完成页面的迁移已经完成,现在是时候看看其他目标了。

注意

比较ng2-checkpoint8.4ng2-checkpoint8.5分支,了解ng2-checkpoint8.5中的新变化(bit.ly/ng2be-compare-8-4-8-5)。

在接下来的几个迁移中,我们将不像之前的迁移那样描述详细。我们强烈建议您查看angular2-migrate-ts分支上的相关提交,以了解事情是如何进行的。我们只会强调接下来的迁移的相关细节。

替换 ui-bootstrap 库

在将开始和完成页面迁移到 Angular 2 时,我们学到的一件事是迁移第三方依赖项是多么繁琐。迁移使用外部库的 Angular 1 视图而不迁移这些库本身是一个挑战。我们还学到,将 Angular 2 组件嵌入到 Angular 1 视图中要比反过来容易得多。

鉴于这些观察结果,迁移到 Angular 2 时,首先迁移/替换第三方库变得至关重要。

我们想要摆脱的一个这样的库是ui-bootstrapbit.ly/ng1-ui-bootstrap)库。虽然我们只从 ui-bootstrap 中使用模态对话框服务,但摆脱它将是一个挑战。

称这个模态对话框为服务($uibModal)将是一个误称。虽然它在 Angular 1 中像服务一样被注入,但它实际上操作 DOM,因此无法使用upgradeNg1Provider函数进行升级。

我们再次需要一个在 Angular 2 中模态对话框的替代实现。我们选择的库是 angular2-modal(bit.ly/ng2-modal)。

Personal Trainer 在两个地方使用 angular2-modal,在顶部导航中显示锻炼历史记录,以及在锻炼执行过程中显示与锻炼相关的视频。

作为迁移到 angular2-modal 的一部分,我们还迁移了顶部导航(在index.html中声明)和视频面板partials/workout/video-panel.html)。

注意

查看ng2-checkpoint8.6 GitHub 分支,了解在此迁移期间更改了哪些工件。我们只会突出使迁移具有挑战性的事项。

您还可以在 GitHub 上将此分支与之前的分支(ng2-checkpoint8.5)进行比较,网址为bit.ly/ng2be-compare-8-5-8-6,了解有哪些变化。

本节将详细介绍ng2-checkpoint8.6GitHub 分支上的代码。

迁移到我们的新模态对话框库angular2-modal的最大挑战是,它需要访问根组件才能正确地在屏幕中间呈现对话框。虽然这在标准的 Angular 2 应用程序中不是问题,但对于混合应用程序,该库无法定位根组件,因为根本就没有。

注意

查看第三章中的使用 angular2-modal 对话框库部分,更多 Angular 2 – SPA,路由和深入数据流,以了解如何安装和配置该库。您还可以比较ng2-checkpoint8.5ng2-checkpoint8.6分支,以确定与 angular2-modal 相关的更改。

为了解决这些限制,我们首先必须重构 Angular 1 应用程序,使得我们有一个 Angular 2 根组件。这样的组件然后包含完整的 Angular 1 视图。现在新的呈现的 html 结构看起来像这样:

替换 ui-bootstrap 库

打开index.htmlng2-rootNg2RootComponent)标签是一个 Angular 2 组件,它包装了完整的 Angular 1 视图。现有的 Angular 1 视图 html 现在被包装在一个指令(ng1Root)中,使用component API。查看文件ng1-root-component.tsng1-root-component.tpl.htmlng2-root-component.ts,以了解这些组件现在是如何结构化的,并在Ng2RootComponent的构造函数中提供 angular2-modal ng2-root容器引用。

根元素的重组还采用了另一种迁移模式。Angular 2 组件(Ng2RootComponent)转译了 Angular 1 元素指令(ng1Root)。查看Ng2RootComponent的视图模板:

@Component({ 
  selector: 'ng2-root', 
 **template: `<ng-content></ng-content>`** 
}) 

它在index.html中的使用:

<ng2-root> 
      <ng1-root></ng1-root> 
</ng2-root> 

在这样的设置中,ng1RootComponent嵌入在 Angular 2 Ng2RootComponent中,它从父级 Angular 1 视图派生其上下文,因此可以访问父级作用域。

在此迁移的过程中,还对应用程序进行了许多其他小的更改,将此分支与ng2-checkpoint8.5进行比较,可以帮助您了解发生了什么变化。

我们将在这里停下来,引导您前往与迁移相关的其他 GitHub 分支。所有以ng2-checkpoint*开头的分支都是迁移分支。尝试迁移未完成的视图,并将其与 GitHub 分支更改进行比较。请记住,Angular 2 已经开发了应用程序的工作版本,因此这是一个很好的参考点。查看每个分支的README.md文件,了解应用程序的哪个部分已迁移到 Angular 2。

与此同时,让我们总结一下我们从迁移中学到的东西。

学习

我们希望这次迁移练习已经为您提供了足够的洞察力。现在您可以评估从 Angular 1 迁移到 Angular 2 需要的复杂性、时间和精力。让我们将我们在这个过程中学到的知识作为亮点:

  • 迁移是耗时的:毫无疑问,迁移绝非一项琐事。每个页面/视图都提出了自己的挑战,我们需要克服。一些元素易于迁移,而一些则不是。如果您目前正在使用 Angular 1 进行开发,最好的做法是遵循“为轻松迁移今天开发的 Angular 1 应用程序”部分的建议。

  • 首先迁移第三方库:迁移第三方库可能非常具有挑战性。原因有多种:

  • 这些库在各个页面上都被使用

  • 它们可能无法升级到 Angular 2(使用UpgradeAdapter

  • 当库被广泛使用时,迁移使用该库的每个视图可能是不可行的。

提示

最好识别应用程序中的所有第三方依赖项,并在 Angular 2 世界中找到合适的替代方案。如果可能的话,使用新库开发一些概念验证(POC),以了解新库与现有实现有多大的不同。

  • 可能存在重叠的库:在迁移过程中,可能会出现 Angular 1 和 Angular 2 版本的库共存的情况。尽量缩短这段时间,并尽快迁移到更新的版本。

  • 将 Angular 2 组件集成到 Angular 1 中比反过来容易得多:在迁移过程中,将完整视图迁移到 Angular 2。由于 Angular 2 施加的限制,很难将具有嵌入式 Angular 1 元素指令的父级 Angular 2 组件。

在这种限制下,自下而上的迁移方法比自上而下的方法更有效。

  • 任何与 UI 无关的内容都很容易迁移:对于“个人健身教练”,我们最后迁移服务,因为它们可以很容易地迁移。

  • Angular 1 和 Angular 2 之间的功能对等性更好:Angular 2 可能没有 Angular 1 支持的每个功能。在这种情况下,我们需要解决方法来实现期望的行为。

这就完成了我们的迁移故事。有了这个,现在是时候结束这一章并总结我们从中学到的东西了。

总结

在本章中,我们对围绕 Angular 开发的一些实际问题有了一些有用的见解。这些建议/指南在使用该框架构建真实应用程序时非常有用。

我们从探讨“种子项目”的概念开始,以及这些项目如何让我们迅速启动和运行。我们看了一些流行的种子项目,可以作为任何新的 Angular 应用程序开发的基础。我们还涉及了Yeomanangular-cli,这是一套帮助我们快速启动新项目的工具。

尽管身为服务器端问题,身份验证和授权确实会影响客户端的实现。关于身份验证/授权的部分涵盖了如何在基于 cookie 和令牌的设置中处理身份验证。

我们讨论了性能这个非常重要的话题,您学会了优化 Angular 应用程序性能的方法。

最后,我们将 v1 的个人教练迁移到 Angular 2。逐步迁移的过程教会了我们迁移的复杂性,所面临的挑战以及所做的变通方法。

书即将结束,但对于每个阅读它的人来说,旅程才刚刚开始。是时候将理论付诸实践,磨练我们新获得的技能,用 Angular 构建一些有用的东西,并与世界分享。您在 Angular 上投入的越多,这个框架就越有回报。让我们开始吧!

标签:示例,视图,Angular,应用程序,使用,组件,我们
From: https://www.cnblogs.com/apachecn/p/18199194

相关文章

  • Angular2-切换指南-全-
    Angular2切换指南(全)原文:zh.annas-archive.org/md5/AE0A0B893569467A0AAE20A9EA07809D译者:飞龙协议:CCBY-NC-SA4.0前言AngularJS是一个使构建Web应用程序更容易的JavaScript开发框架。它如今被用于大规模、高流量的网站,这些网站在性能不佳、可移植性问题、SEO不友好......
  • Angular2-Bootstrap4-Web-开发-全-
    Angular2Bootstrap4Web开发(全)原文:zh.annas-archive.org/md5/1998a305c23fbffe24116fac6b321687译者:飞龙协议:CCBY-NC-SA4.0前言这本书是关于当代网页开发中两个巨大和最受欢迎的名字,Angular2和Bootstrap4。Angular2是AngularJS的继任者,但在许多方面都比前任更......
  • Angular-测试驱动开发-全-
    Angular测试驱动开发(全)原文:zh.annas-archive.org/md5/60F96C36D64CD0F22F8885CC69A834D2译者:飞龙协议:CCBY-NC-SA4.0前言本书将为读者提供一个关于JavaScript测试驱动开发(TDD)的完整指南,然后深入探讨Angular的方法。它将提供清晰的、逐步的示例,不断强调TDD的最佳实......
  • GO实名认证接口开发示例、接口集成、身份认证
    翔云身份证实名认证接口,通过核验身份证二要素、三要素、三要素+现场人像的方式,实时联网核验身份信息的真伪。想象一下,无需耗费大量的人力物力,只需简单几步,即可将翔云身份证实名认证接口集成到您的应用中。无论是用户注册还是支付验证,都无需再让用户手动输入身份信息,线上......
  • Angular | 理解数据绑定
    1.什么是数据绑定,怎么实现就是实现数据和html模板之前的联通,就叫数据绑定。数据绑定分为单向数据绑定和双向数据绑定:单向数据绑定和双向数据绑定可通过"[]","()"来实现分别实现绑定属性值和方法来实现单向数据绑定可通"([])"来实现双向数据绑定,一般应用于表单输入和其他用户输......
  • java netty 实现 websocket 服务端和客户端双向通信 实现心跳和断线重连 完整示例
    javanetty实现websocket服务端和客户端双向通信实现心跳和断线重连完整示例maven依赖<dependency><groupId>io.netty</groupId><artifactId>netty-all</artifactId><version>4.1.97.Final</version></dependency>服务端一个接口IGet......
  • 图神经网络入门示例:使用PyTorch Geometric 进行节点分类
    基于图的神经网络是强大的模型,可以学习网络中的复杂模式。在本文中,我们将介绍如何为同构图数据构造PyTorchData对象,然后训练不同类型的神经网络来预测节点所属的类。这种类型的预测问题通常被称为节点分类。我们将使用来自BenedekRozemberczki,CarlAllen和RikSarkar于2019......
  • Angular Material 17+ 高级教程 – Material Tooltip
        目录上一篇 AngularMaterial17+高级教程–CDKOverlay下一篇TODO想查看目录,请移步 Angular17+高级教程–目录......
  • Vue3-示例-全-
    Vue3示例(全)原文:zh.annas-archive.org/md5/84EBE0BE98F4DE483EBA9EF82A25ED12译者:飞龙协议:CCBY-NC-SA4.0前言Vue是主要框架之一,拥有庞大的生态系统,并因其在开发应用时的易用性以及能够帮助你快速实现令人印象深刻的开发结果而不断增加采用率。本书探讨了最新的Vue版本......
  • Linux tcpdump 命令详解与示例
    命令概要Linux作为网络服务器,特别是作为路由器和网关时,数据的采集和分析是不可少的。tcpdump是Linux中强大的网络数据采集分析工具之一。用简单的话来定义tcpdump,就是:dumpthetrafficonanetwork,根据使用者的定义对网络上的数据包进行截获的包分析工具。作为互联网上经典的......