Angular2-切换指南-全-

Angular2 切换指南(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

AngularJS 是一个使构建 Web 应用程序更容易的 JavaScript 开发框架。它如今被用于大规模、高流量的网站,这些网站在性能不佳、可移植性问题、SEO 不友好和规模复杂性方面存在困难。Angular 2 改变了这一切。

这是您构建高性能和健壮 Web 应用程序所需的现代框架。转向 Angular 2 是快速掌握 Angular 2 的最快途径,它将帮助您过渡到 Angular 2 的全新世界。

在本书结束时,您将准备好开始构建快速高效的 Angular 2 应用程序,充分利用提供的所有新功能。

本书涵盖了以下内容

第一章,“开始学习 Angular 2”,开启了我们进入 Angular 2 世界的旅程。它描述了框架设计决策背后的主要原因。我们将探讨框架形成的两个主要驱动因素——Web 的当前状态和前端开发的演变。

第二章,“Angular 2 应用程序的构建模块”,概述了 Angular 2 引入的核心概念。我们将探讨 AngularJS 1.x 提供的应用程序开发基础模块与框架最新主要版本中的区别。

第三章,“TypeScript Crash Course”,解释了虽然 Angular 2 是一种语言不可知的框架,但谷歌建议利用 TypeScript 的静态类型。在本章中,您将学习开发 Angular 2 应用程序所需的所有基本语法!

第四章《使用 Angular 2 组件和指令入门》描述了开发应用程序用户界面的核心构建模块——指令和组件。我们将深入探讨诸如视图封装、内容投影、输入和输出、变更检测策略等概念。我们还将讨论一些高级主题,如模板引用和使用不可变数据加速应用程序。

第五章《Angular 2 中的依赖注入》涵盖了框架中最强大的功能之一,这是由 AngularJS 1.x 最初引入的:其依赖注入机制。它使我们能够编写更易于维护、可测试和可理解的代码。在本章结束时,我们将了解如何在服务中定义业务逻辑,并通过 DI 机制将它们与 UI 粘合在一起。我们还将深入研究一些更高级的概念,如注入器层次结构、配置提供者等。

第六章《使用 Angular 2 路由器和表单》探讨了在开发实际应用程序过程中管理表单的新模块。我们还将实现一个显示通过表单输入的数据的页面。最后,我们将使用基于组件的路由器将各个页面粘合成一个应用程序。

第七章《管道解释和与 RESTful 服务通信》深入探讨了路由器和表单模块。在这里,我们将探索如何开发模型驱动的表单,定义参数化和子路由。我们还将解释 HTTP 模块,以及如何开发纯管道和不纯管道。

第八章, SEO 和 Angular 2 在现实世界中,探讨了 Angular 2 应用程序开发中的一些高级主题,例如在 Web Workers 和服务器端渲染中运行应用程序。在本章的第二部分,我们将探讨一些可以简化开发人员日常工作的工具,如angular-cliangular2-seed,解释热重载的概念等。

本书需要什么

在本书中,您需要的是一个简单的文本编辑器或 IDE,安装了 Node.js、TypeScript,有互联网访问权限和浏览器。

每一章都介绍了运行提供的代码片段所需的软件要求。

这本书是为谁准备的

您想要深入了解 Angular 2 吗?或者您有兴趣在转换之前评估这些更改吗?如果是这样,那么转换到 Angular 2就是适合您的书。

要充分利用本书,您需要对 AngularJS 1.x 有基本的了解,并且对 JavaScript 有很好的理解。不需要了解 Angular 2 的更改就可以跟上。

约定

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

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名显示如下: "您应该看到相同的结果,但没有存储在磁盘上的test.js文件。"

代码块设置如下:

@Injectable()
class Socket {
  constructor(private buffer: Buffer) {}
}

let injector = Injector.resolveAndCreate([
  provide(BUFFER_SIZE, { useValue: 42 }),
  Buffer,
  Socket
]);

injector.get(Socket);

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

let injector = Injector.resolveAndCreate([
  provide(**BUFFER_SIZE**, { useValue: 42 }),
  Buffer,
  Socket
]);

与本书中的代码一起存储在存储库中的每个代码片段都以注释开头,注释中包含相应的文件位置,相对于app目录:

// ch5/ts/injector-basics/forward-ref.ts

@Injectable()
class Socket {
  constructor(private buffer: Buffer) {…}
}

新术语重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会在文本中显示为: "当标记呈现到屏幕上时,用户将看到的只是标签:加载中...。"

注意

警告或重要提示以这样的框出现。

提示

技巧和窍门会以这种方式出现。

第一章:开始使用 Angular 2

2014 年 9 月 18 日,第一个公共提交被推送到 Angular 2 存储库。几周后,在 ng-europe 上,核心团队的 Igor 和 Tobias 简要概述了 Angular 2 的预期。当时的愿景远非最终;然而,有一件事是确定的——新版本的框架将与 AngularJS 1.x 完全不同。

这一公告引发了许多问题和争议。变化背后的原因非常明确——AngularJS 1.x 不再能充分利用发展中的 Web,并且无法完全满足大规模 JavaScript 应用程序的要求。一个新的框架将让 Angular 开发人员以更简单、更直接的方式利用 Web 技术的发展。然而,人们感到担忧。对于开发人员来说,与第三方软件的新版本进行迁移是最大的噩梦之一。在 Angular 的情况下,宣布后,迁移看起来令人生畏,甚至不可能。后来,在 ng-conf 2015 和 ng-vegas 上,引入了不同的迁移策略。Angular 社区汇聚在一起,分享额外的想法,预期 Angular 2 的好处,同时保留了从 AngularJS 1.x 中学到的东西。

这本书是该项目的一部分。升级到 Angular 2 并不容易,但是很值得。Angular 2 背后的主要驱动因素是 Web 的发展,以及从在野外使用 AngularJS 1.x 中所学到的经验。切换到 Angular 2 将帮助您通过了解我们是如何到达这里以及为什么 Angular 的新特性对于构建高性能、可扩展的单页应用程序在现代 Web 中具有直观意义来学习新框架。

Web 的发展——是时候使用新框架了

在过去的几年里,网络发展迅速。在实施 ECMAScript 5 的同时,ECMAScript 6 标准开始了开发(现在被称为 ECMAScript 2015 或 ES2015)。ES2015 在语言中引入了许多变化,例如为模块添加内置语言支持,块作用域变量定义,以及许多语法糖,如类和解构。

与此同时,Web Components被发明了。Web Components 允许我们定义自定义 HTML 元素并为其附加行为。由于扩展现有 HTML 元素(如对话框、图表、网格等)很难,主要是因为需要时间来巩固和标准化它们的 API,更好的解决方案是允许开发人员按照他们的意愿扩展现有元素。Web Components 为我们提供了许多好处,包括更好的封装性,我们生成的标记的更好语义,更好的模块化,以及开发人员和设计人员之间更容易的沟通。

我们知道 JavaScript 是一种单线程语言。最初,它是为了简单的客户端脚本而开发的,但随着时间的推移,它的作用发生了很大变化。现在有了 HTML5,我们有了不同的 API,允许音频和视频处理,通过双向通信渠道与外部服务通信,传输和处理大块原始数据等。主线程中的所有这些繁重计算可能会导致用户体验不佳。当执行耗时计算时,可能会导致用户界面冻结。这导致了WebWorkers的开发,它允许在后台执行脚本,并通过消息传递与主线程通信。这样,多线程编程被引入到了浏览器中。

其中一些 API 是在 AngularJS 1.x 的开发之后引入的;这就是为什么框架并没有考虑大部分 API。然而,利用这些 API 给开发人员带来了许多好处,比如:

  • 显著的性能改进。

  • 开发具有更好质量特征的软件。

现在让我们简要讨论这些技术如何成为新的 Angular 核心的一部分,以及原因。

ECMAScript 的发展

如今,浏览器供应商以短迭代的方式发布新功能,用户经常收到更新。这有助于推动 Web 前进,使开发人员能够利用尖端技术,旨在改进 Web。ES2015 已经标准化。最新版本的语言已经在主要浏览器中开始实现。学习新的语法并利用它不仅会提高我们作为开发人员的生产力,还会为我们在不久的将来当所有浏览器都完全支持它时做好准备。这使得现在开始使用最新的语法至关重要。

一些项目的要求可能要求我们支持不支持任何 ES2015 功能的旧浏览器。在这种情况下,我们可以直接编写 ECMAScript 5,它具有不同的语法,但与 ES2015 具有等效的语义。然而,我们可以利用转译的过程。在我们的构建过程中使用转译器可以让我们通过编写 ES2015 并将其转换为浏览器支持的目标语言来利用新的语法。

AngularJS 自 2009 年以来就存在。当时,大多数网站的前端都是由 ECMAScript 3 驱动的,这是 ECMAScript 5 之前的最后一个主要版本。这自动意味着框架实现所使用的语言是 ECMAScript 3。利用新版本的语言需要将整个 AngularJS 1.x 移植到 ES2015。

从一开始,Angular 2 就考虑到了 Web 的当前状态,引入了框架中的最新语法。虽然 Angular 2 是用 ES2016 的超集(TypeScript)编写的(我们马上会看一下),但它允许开发人员使用他们自己喜欢的语言。我们可以使用 ES2015,或者,如果我们不想对我们的代码进行任何中间预处理并简化构建过程,甚至可以使用 ECMAScript 5。

Web 组件

Web Components 的第一个公开草案于 2012 年 5 月 22 日发布,大约在发布 AngularJS 1.x 三年后。正如前面提到的,Web Components 标准允许我们创建自定义元素并为其附加行为。听起来很熟悉;我们已经在 AngularJS 1.x 应用程序的用户界面开发中使用了类似的概念。Web Components 听起来像是 Angular 指令的替代品;然而,它们具有更直观的 API、更丰富的功能和内置的浏览器支持。它们引入了一些其他好处,比如更好的封装,这在处理 CSS 样式冲突方面非常重要。

在 AngularJS 1.x 中添加 Web Components 支持的一种可能策略是改变指令的实现,并在 DOM 编译器中引入新标准的原语。作为 Angular 开发人员,我们知道指令 API 是多么强大但也复杂。它包括许多属性,如postLinkpreLinkcompilerestrictscopecontroller等等,当然还有我们最喜欢的transclude。作为标准,Web Components 将在浏览器中以更低的级别实现,这带来了许多好处,比如更好的性能和本机 API。

在实现 Web Components 时,许多网络专家遇到了与 Angular 团队在开发指令 API 时遇到的相同问题,并提出了类似的想法。Web Components 背后的良好设计决策包括content元素,它解决了 AngularJS 1.x 中臭名昭著的 transclusion 问题。由于指令 API 和 Web Components 以不同的方式解决了类似的问题,将指令 API 保留在 Web Components 之上将是多余的,并增加了不必要的复杂性。这就是为什么 Angular 核心团队决定从头开始,构建在 Web Components 之上,并充分利用新标准的原因。Web Components 涉及新功能,其中一些尚未被所有浏览器实现。如果我们的应用程序在不支持这些功能的浏览器中运行,Angular 2 会模拟它们。一个例子是使用指令ng-content来模拟 content 元素。

WebWorkers

JavaScript 以其事件循环而闻名。通常,JavaScript 程序在单个线程中执行,并且不同的事件被推送到队列中并按顺序依次处理,按照它们到达的顺序。然而,当计划的事件之一需要大量的计算时间时,这种计算策略就不够有效了。在这种情况下,事件的处理将阻塞主线程,并且直到耗时的计算完成并将执行传递给队列中的下一个事件之前,所有其他事件都不会被处理。一个简单的例子是鼠标点击触发一个事件,在回调中我们使用 HTML5 音频 API 进行一些音频处理。如果处理的音轨很大,算法运行的负担很重,这将影响用户体验,直到执行完成为止,界面会被冻结。

WebWorker API 的引入是为了防止这种陷阱。它允许在不同线程的上下文中执行重型计算,这样可以使主执行线程空闲,能够处理用户输入和渲染用户界面。

我们如何在 Angular 中利用这一点?为了回答这个问题,让我们想一想在 AngularJS 1.x 中的工作原理。假设我们有一个企业应用程序,需要处理大量数据,并且需要使用数据绑定在屏幕上呈现这些数据。对于每个绑定,都会添加一个新的观察者。一旦 digest 循环运行,它将遍历所有观察者,执行与它们相关的表达式,并将返回的结果与上一次迭代获得的结果进行比较。我们在这里有一些减速:

  • 对大量观察者进行迭代。

  • 在给定上下文中评估表达式。

  • 返回结果的副本。

  • 表达式评估的当前结果与先前结果之间的比较。

所有这些步骤可能会相当慢,具体取决于输入的大小。如果 digest 循环涉及重型计算,为什么不将其移动到 WebWorker 中呢?为什么不在 WebWorker 中运行 digest 循环,获取更改的绑定,并将其应用于 DOM?

社区进行了试验,旨在达到这一目标。然而,它们与框架的整合并不是简单的。令人不满意的结果背后的主要原因之一是框架与 DOM 的耦合。在监视器的回调函数中,Angular 经常直接操作 DOM,这使得将监视器移动到 WebWorkers 中变得不可能,因为 WebWorkers 在隔离的上下文中被调用,无法访问 DOM。在 AngularJS 1.x 中,我们可能存在不同监视器之间的隐式或显式依赖关系,这需要多次迭代 digest 循环才能获得稳定的结果。结合最后两点,很难在除执行主线程之外的线程中实现实际结果。

在 AngularJS 1.x 中修复这个问题会在内部实现中引入大量的复杂性。这个框架根本就没有考虑到这一点。由于 WebWorkers 是在 Angular 2 设计过程开始之前引入的,核心团队从一开始就考虑到了它们。

在野外学到的 AngularJS 1.x 的教训

尽管前一部分介绍了需要重新实现框架以响应最新趋势的许多论点,但重要的是要记住我们并不是完全从零开始。我们将从 AngularJS 1.x 中学到的东西带到了现在。自 2009 年以来,Web 不是唯一发展的东西。我们还开始构建越来越复杂的应用程序。如今,单页应用程序不再是什么奇特的东西,而更像是解决业务问题的所有 Web 应用程序的严格要求,它们旨在实现高性能和良好的用户体验。

AngularJS 1.x 帮助我们构建了高效和大规模的单页应用程序。然而,通过在各种用例中应用它,我们也发现了一些缺点。从社区的经验中学习,Angular 的核心团队致力于新的想法,旨在满足新的需求。当我们看着 Angular 2 的新特性时,让我们以 AngularJS 1.x 的当前实现为背景来考虑它们,并思考我们作为 Angular 开发人员在过去几年中所挣扎和修改的事情。

控制器

AngularJS 1.x 遵循模型视图控制器MVC)微架构模式。有人可能会认为它看起来更像模型视图视图模型MVVM),因为视图模型作为作用域或当前上下文附加到作用域或控制器的属性。如果我们使用模型视图呈现器模式MVP),它可能会以不同的方式进行处理。由于我们可以在应用程序中构造逻辑的不同变体,核心团队将 AngularJS 1.x 称为模型视图任何MVW)框架。

在任何 AngularJS 应用程序中,视图应该是指令的组合。指令共同协作,以提供完全功能的用户界面。服务负责封装应用程序的业务逻辑。这是我们应该与 RESTful 服务通过 HTTP 进行通信,与 WebSockets 进行实时通信甚至 WebRTC 的地方。服务是我们应该实现应用程序的领域模型和业务规则的构建模块。还有一个组件,主要负责处理用户输入并将执行委托给服务 - 控制器。

尽管服务和指令有明确定义的角色,但我们经常会看到大型视图控制器的反模式,这在 iOS 应用程序中很常见。偶尔,开发人员会尝试直接从他们的控制器访问甚至操作 DOM。最初,这是为了实现一些简单的事情,比如更改元素的大小,或者快速而肮脏地更改元素的样式。另一个明显的反模式是在控制器之间复制业务逻辑。开发人员经常倾向于复制和粘贴应该封装在服务中的逻辑。

构建 AngularJS 应用程序的最佳实践是,控制器不应该在任何情况下操作 DOM,而是所有 DOM 访问和操作应该在指令中进行隔离。如果在控制器之间有一些重复的逻辑,很可能我们希望将其封装到一个服务中,并使用 AngularJS 的依赖注入机制在所有需要该功能的控制器中注入该服务。

这是我们在 AngularJS 1.x 中的出发点。尽管如此,似乎控制器的功能可以移动到指令的控制器中。由于指令支持依赖注入 API,在接收用户输入后,我们可以直接将执行委托给特定的服务,已经注入。这是 Angular 2 使用不同方法的主要原因,通过使用ng-controller指令来阻止在任何地方放置控制器。我们将在第四章中看看如何从 Angular 2 组件和指令中取代 AngularJS 1.x 控制器的职责,开始使用 Angular 2 组件和指令

作用域

在 AngularJS 中,数据绑定是通过scope对象实现的。我们可以将属性附加到它,并在模板中明确声明我们要绑定到这些属性(单向或双向)。尽管 scope 的概念似乎很清晰,但 scope 还有两个额外的责任,包括事件分发和与变更检测相关的行为。Angular 初学者很难理解 scope 到底是什么,以及应该如何使用它。AngularJS 1.2 引入了controller as 语法。它允许我们向给定控制器内的当前上下文(this)添加属性,而不是显式注入scope对象,然后再向其添加属性。这种简化的语法可以从以下片段中演示:

<div ng-controller="MainCtrl as main">
  <button ng-click="main.clicked()">Click</button>
</div>

function MainCtrl() {
  this.name = 'Foobar';
}
MainCtrl.prototype.clicked = function () {
  alert('You clicked me!');
};

Angular 2 更进一步,通过移除scope对象来实现。所有表达式都在给定 UI 组件的上下文中进行评估。移除整个 scope API 引入了更高的简单性;我们不再需要显式注入它,而是将属性添加到 UI 组件中,以便稍后绑定。这个 API 感觉更简单和更自然。

我们将在第四章中更详细地了解 Angular 2 组件和变更检测机制,开始使用 Angular 2 组件和指令

依赖注入

也许在 JavaScript 世界中,市场上第一个包括控制反转IoC)和依赖注入DI)的框架是 AngularJS 1.x。DI 提供了许多好处,比如更容易进行测试,更好的代码组织和模块化,以及简单性。尽管 1.x 中的 DI 做得很出色,但 Angular 2 更进一步。由于 Angular 2 建立在最新的 web 标准之上,它使用 ECMAScript 2016 装饰器语法来注释代码以使用 DI。装饰器与 Python 中的装饰器或 Java 中的注解非常相似。它们允许我们通过反射来装饰给定对象的行为。由于装饰器尚未标准化并且得到主要浏览器的支持,它们的使用需要一个中间的转译步骤;但是,如果你不想这样做,你可以直接使用更加冗长的 ECMAScript 5 语法编写代码,并实现相同的语义。

新的 DI 更加灵活和功能丰富。它也修复了 AngularJS 1.x 的一些缺陷,比如不同的 API;在 1.x 中,一些对象是按位置注入的(比如在指令的链接函数中的作用域、元素、属性和控制器),而其他对象是按名称注入的(在控制器、指令、服务和过滤器中使用参数名称)。

我们将在第五章中进一步了解 Angular 2 的依赖注入 API,Angular 2 中的依赖注入

服务器端渲染

Web 的需求越大,web 应用程序就变得越复杂。构建一个真实的单页面应用程序需要编写大量的 JavaScript,并且包括所有必需的外部库可能会增加页面上脚本的大小达到几兆字节。应用程序的初始化可能需要几秒甚至几十秒,直到所有资源从服务器获取,JavaScript 被解析和执行,页面被渲染,所有样式被应用。在使用移动互联网连接的低端移动设备上,这个过程可能会让用户放弃访问我们的应用程序。尽管有一些加速这个过程的做法,在复杂的应用程序中,并没有一种万能的解决方案。

在努力改善用户体验的过程中,开发人员发现了一种称为服务器端渲染的东西。它允许我们在服务器上渲染单页应用程序的请求视图,并直接向用户提供页面的 HTML。稍后,一旦所有资源都被处理,事件监听器和绑定可以由脚本文件添加。这听起来像是提高应用程序性能的好方法。在这方面的先驱之一是 ReactJS,它允许使用 Node.js DOM 实现在服务器端预渲染用户界面。不幸的是,AngularJS 1.x 的架构不允许这样做。阻碍因素是框架与浏览器 API 之间的强耦合,这与在 WebWorkers 中运行变更检测时遇到的问题相同。

服务器端渲染的另一个典型用例是构建搜索引擎优化SEO)友好的应用程序。过去有一些技巧用于使 AngularJS 1.x 应用程序可以被搜索引擎索引。例如,一种做法是使用无头浏览器遍历应用程序,执行每个页面上的脚本并将渲染输出缓存到 HTML 文件中,使其可以被搜索引擎访问。

尽管构建 SEO 友好的应用程序的这种变通方法有效,但服务器端渲染解决了上述两个问题,改善了用户体验,并使我们能够更轻松、更优雅地构建 SEO 友好的应用程序。

Angular 2 与 DOM 的解耦使我们能够在浏览器之外运行我们的 Angular 2 应用程序。社区利用这一点构建了一个工具,允许我们在服务器端预渲染我们单页应用程序的视图并将其转发到浏览器。在撰写本文时,该工具仍处于早期开发阶段,不在框架的核心之内。我们将在第八章, 开发体验和服务器端渲染中进一步了解它。

可以扩展的应用程序。

自 Backbone.js 出现以来,MVW 一直是构建单页应用程序的默认选择。它通过将业务逻辑与视图隔离,允许我们构建设计良好的应用程序。利用观察者模式,MVW 允许在视图中监听模型的变化,并在检测到变化时进行更新。然而,这些事件处理程序之间存在一些显式和隐式的依赖关系,这使得我们应用程序中的数据流不明显且难以推理。在 AngularJS 1.x 中,我们允许在不同的监视器之间存在依赖关系,这要求摘要循环多次迭代,直到表达式的结果稳定。Angular 2 使数据流单向化,这带来了许多好处,包括:

  • 更明确的数据流。

  • 绑定之间没有依赖关系,因此没有摘要的生存时间TTL)。

  • 更好的性能:

  • 摘要循环仅运行一次。

  • 我们可以创建友好于不可变/可观察模型的应用程序,这使我们能够进行进一步的优化。

数据流的变化在 AngularJS 1.x 架构中引入了一个更根本的变化。

当我们需要维护用 JavaScript 编写的大型代码库时,我们可能会从另一个角度看待这个问题。尽管 JavaScript 的鸭子类型使语言非常灵活,但它也使得 IDE 和文本编辑器对其分析和支持更加困难。在大型项目中进行重构变得非常困难和容易出错,因为在大多数情况下,静态分析和类型推断是不可能的。缺乏编译器使得拼写错误变得非常容易,直到我们运行测试套件或运行应用程序之前都很难注意到。

可扩展的应用程序

Angular 核心团队决定使用 TypeScript,因为它具有更好的工具,并且具有编译时类型检查,这有助于我们更加高效和减少出错。正如前面的图所示,TypeScript 是 ECMAScript 的超集;它引入了显式类型注解和编译器。TypeScript 语言被编译为纯 JavaScript,受到今天浏览器的支持。自 1.6 版本以来,TypeScript 实现了 ECMAScript 2016 装饰器,这使其成为 Angular 2 的完美选择。

TypeScript 的使用允许更好的 IDE 和文本编辑器支持,具有静态代码分析和类型检查。所有这些都通过减少我们的错误和简化重构过程,显著提高了我们的生产力。TypeScript 的另一个重要好处是通过静态类型,我们隐含地获得了性能改进,这允许 JavaScript 虚拟机进行运行时优化。

我们将在第三章中详细讨论 TypeScript,TypeScript Crash Course

模板

模板是 AngularJS 1.x 中的关键特性之一。它们是简单的 HTML,不需要任何中间处理和编译,不像大多数模板引擎(如 mustache)。AngularJS 中的模板通过创建内部的领域特定语言DSL)来将简单性与强大性相结合,通过自定义元素和属性来扩展 HTML。

然而,这也是 Web 组件的主要目的之一。我们已经提到了 Angular 2 如何以及为什么利用了这项新技术。尽管 AngularJS 1.x 的模板很棒,但它们仍然可以变得更好!Angular 2 模板继承了框架先前版本中最好的部分,并通过修复其中一些令人困惑的部分来增强它们。

例如,假设我们构建了一个指令,并且我们希望允许用户通过使用属性将属性传递给它。在 AngularJS 1.x 中,我们可以以三种不同的方式来处理这个问题:

<user name="literal"></user>
<user name="expression"></user>
<user name="{{interpolate}}"></user>

如果我们有一个指令user,并且我们想传递name属性,我们可以以三种不同的方式来处理。我们可以传递一个字面量(在这种情况下是字符串"literal"),一个字符串,它将被评估为一个表达式(在我们的例子中是"expression"),或者一个在{{ }}中的表达式。应该使用哪种语法完全取决于指令的实现,这使得其 API 复杂且难以记忆。

每天处理大量具有不同设计决策的组件是一项令人沮丧的任务。通过引入一个共同的约定,我们可以解决这些问题。然而,为了取得良好的结果和一致的 API,整个社区都需要同意。

Angular 2 也解决了这个问题,提供了特殊的属性语法,其值需要在当前组件的上下文中进行评估,并为传递字面量提供了不同的语法。

我们还习惯于根据我们的 AngularJS 1.x 经验,在模板指令中使用微语法,比如ng-ifng-for。例如,如果我们想在 AngularJS 1.x 中遍历用户列表并显示他们的名字,我们可以使用:

<div ng-for="user in users">{{user.name}}</div>

尽管这种语法对我们来说看起来很直观,但它允许有限的工具支持。然而,Angular 2 通过引入更加显式的语法和更丰富的语义来处理这个问题:

<template ngFor var-user [ngForOf]="users">
  {{user.name}}
</template>

前面的代码片段明确定义了必须在当前迭代的上下文中创建的属性(user),以及我们要迭代的对象(users)。

然而,这种语法对于输入来说太冗长了。开发人员可以使用以下语法,稍后会被转换为更冗长的语法:

<li *ngFor="#user of users">
  {{user.name}}
</li>

新模板的改进也将允许文本编辑器和 IDE 更好地支持高级工具。我们将在第四章中讨论 Angular 2 的模板,开始使用 Angular 2 组件和指令

变更检测

WebWorkers部分,我们已经提到了在不同线程的上下文中运行 digest 循环的机会,即作为 WebWorker 实例化。然而,AngularJS 1.x 中 digest 循环的实现并不是非常节省内存,并且阻止了 JavaScript 虚拟机进行进一步的代码优化,这可以实现显著的性能改进。其中一种优化是内联缓存(mrale.ph/blog/2012/06/03/explaining-js-vms-in-js-inline-caches.html)。Angular 团队进行了大量研究,发现了改进 digest 循环的性能和效率的不同方法。这导致了全新的变更检测机制的开发。

为了进一步提高灵活性,Angular 团队将变更检测抽象化,并将其实现与框架的核心解耦。这使得可以开发不同的变更检测策略,从而在不同的环境中赋予不同的功能更多的权力。

因此,Angular 2 具有两种内置的变更检测机制:

  • 动态变更检测:这类似于 AngularJS 1.x 使用的变更检测机制。它用于不允许eval()的系统,如 CSP 和 Chrome 扩展程序。

  • JIT 变更检测:这会生成执行运行时变更检测的代码,允许 JavaScript 虚拟机执行进一步的代码优化。

我们将看看新的变更检测机制以及如何在第四章中配置它们,开始使用 Angular 2 组件和指令

总结

在本章中,我们考虑了 Angular 核心团队做出决定背后的主要原因,以及框架的最后两个主要版本之间缺乏向后兼容性。我们看到这些决定是由两个因素推动的——Web 的发展和前端开发的进化,以及从开发 AngularJS 1.x 应用程序中学到的经验教训。

在第一部分中,我们了解了为什么需要使用最新版本的 JavaScript 语言,为什么要利用 Web 组件和 WebWorkers,以及为什么不值得在 1.x 版本中集成所有这些强大的工具。

我们观察了前端开发的当前方向以及过去几年所学到的经验教训。我们描述了为什么在 Angular 2 中移除了控制器和作用域,以及为什么改变了 AngularJS 1.x 的架构,以便允许服务器端渲染,以便创建 SEO 友好、高性能的单页面应用程序。我们还研究了构建大型应用程序的基本主题,以及这如何激发了框架中的单向数据流和静态类型语言 TypeScript 的选择。

在下一章中,我们将看看 Angular 2 应用程序的主要构建模块——它们如何被使用以及它们之间的关系。Angular 2 重新使用了一些由 AngularJS 1.x 引入的组件的命名,但通常完全改变了我们单页面应用程序的构建模块。我们将窥探新组件,并将它们与框架先前版本中的组件进行比较。我们将快速介绍指令、组件、路由器、管道和服务,并描述它们如何结合起来构建优雅的单页面应用程序。

提示

下载示例代码

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

您可以按照以下步骤下载代码文件:

  • 使用您的电子邮件地址和密码登录或注册到我们的网站。

  • 将鼠标指针悬停在顶部的SUPPORT选项卡上。

  • 单击代码下载和勘误

  • 搜索框中输入书名。

  • 选择您要下载代码文件的书籍。

  • 从下拉菜单中选择您购买本书的地方。

  • 单击代码下载

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

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

第二章:Angular 2 应用程序的构建模块

在上一章中,我们看了 Angular 2 设计决策背后的驱动因素。我们描述了导致开发全新框架的主要原因;Angular 2 利用了 Web 标准,同时牢记过去的经验教训。尽管我们熟悉主要的驱动因素,但我们仍未描述核心 Angular 2 概念。框架的上一个主要版本与 AngularJS 1.x 走了不同的道路,并在用于开发单页面应用程序的基本构建模块中引入了许多变化。

在本章中,我们将研究框架的核心,并简要介绍 Angular 2 的主要组件。本章的另一个重要目的是概述这些概念如何组合在一起,以帮助我们为 Web 应用程序构建专业的用户界面。接下来的几节将概述我们将在本书后面更详细地研究的所有内容。

在本章中,我们将看到:

  • 一个框架的概念概述,展示不同概念之间的关系。

  • 我们如何将用户界面构建为组件的组合。

  • Angular 2 中指令的路径以及它们与框架先前主要版本相比的接口发生了怎样的变化。

  • 导致指令分解为两个不同组件的关注点分离的原因。为了更好地理解这两个概念,我们将演示它们定义的基本语法。

  • 改进的变化检测概述,以及它如何涉及指令提供的上下文。

  • 什么是 zone,以及为什么它们可以使我们的日常开发过程更容易。

  • 管道是什么,以及它们与 AngularJS 1.x 的过滤器有什么关系。

  • Angular 2 中全新的依赖注入DI)机制以及它与服务组件的关系。

Angular 2 的概念概述

在我们深入研究 Angular 2 的不同部分之前,让我们先概述一下它们如何相互配合。让我们看一下下面的图表:

Angular 2 的概念概述

图 1

图 1图 4显示了主要的 Angular 2 概念及它们之间的连接。这些图表的主要目的是说明使用 Angular 2 构建单页面应用程序的核心模块及其关系。

组件是我们将用来使用 Angular 2 创建应用程序用户界面的主要构建块。组件是指令的直接后继,指令是将行为附加到 DOM 的原始方法。组件通过提供进一步的功能(例如附加模板的视图)来扩展指令,该模板可用于呈现指令的组合。视图模板中可以包含不同的表达式。

Angular 2 的概念概述

图 2

上述图表概念上说明了 Angular 2 的变更检测机制。它运行digest循环,评估特定 UI 组件上下文中注册的表达式。由于 Angular 2 中已经移除了作用域的概念,表达式的执行上下文是与其关联的组件的控制器。

变更检测机制可以通过Differs进行增强;这就是为什么在图表中这两个元素之间有直接关系的原因。

管道是 Angular 2 的另一个组件。我们可以将管道视为 AngularJS 1.x 中的过滤器。管道可以与组件一起使用。我们可以将它们包含在在任何组件上下文中定义的表达式中:

Angular 2 的概念概述

图 3

现在让我们看一下上述图表。指令组件将业务逻辑委托给服务。这强化了关注点的分离、可维护性和代码的可重用性。指令使用框架的DI机制接收特定服务实例的引用,并将与它们相关的业务逻辑执行委托给它们。指令组件都可以使用DI机制,不仅可以注入服务,还可以注入 DOM 元素和/或其他组件指令

Angular 2 的概念概述

图 4

最后,基于组件的路由器用于定义应用程序中的路由。由于指令没有自己的模板,因此只有组件可以由路由器呈现,代表应用程序中的不同视图。路由器还使用预定义的指令,允许我们在不同视图和应该呈现它们的容器之间定义超链接。

现在我们将更仔细地看看这些概念,看看它们如何共同工作以创建 Angular 2 应用程序,以及它们与其 AngularJS 1.x 前身有何不同。

更改指令

AngularJS 1.x 在单页应用程序开发中引入了指令的概念。指令的目的是封装与 DOM 相关的逻辑,并允许我们通过扩展 HTML 的语法和语义来构建用户界面的组合。最初,像大多数创新概念一样,指令被认为是有争议的,因为当使用自定义元素或属性而没有data-前缀时,它们会使我们倾向于编写无效的 HTML。然而,随着时间的推移,这个概念逐渐被接受,并证明它是值得留下的。

AngularJS 1.x 中指令实现的另一个缺点是我们可以使用它们的不同方式。这需要理解属性值,它可以是文字,表达式,回调或微语法。这使得工具基本上不可能。

Angular 2 保留了指令的概念,但从 AngularJS 1.x 中吸取了精华,并增加了一些新的想法和语法。Angular 2 指令的主要目的是通过在 ES2015 类中定义自定义逻辑来将行为附加到 DOM。我们可以将这些类视为与指令关联的控制器,并将它们的构造函数视为类似于 AngularJS 1.x 中指令的链接函数。然而,新的指令具有有限的可配置性。它们不允许定义模板,这使得大多数用于定义指令的已知属性变得不必要。指令 API 的简单性并不限制它们的行为,而只是强化了更强的关注点分离。为了补充这种更简单的指令 API,Angular 2 引入了一个更丰富的界面来定义 UI 元素,称为组件。组件通过Component元数据扩展了指令的功能,允许它们拥有模板。我们稍后会更深入地研究组件。

Angular 2 指令的语法涉及 ES2016 装饰器。然而,我们也可以使用 TypeScript、ES2015 甚至ECMAScript 5 (ES5)来实现相同的结果,只是需要多打一些字。以下代码定义了一个简单的指令,使用 TypeScript 编写:

@Directive({
  selector: '[tooltip]'
})
export class Tooltip {
  private overlay: Overlay;
  @Input()
  private tooltip: string;
  constructor(private el: ElementRef, manager: OverlayManager) {
    this.overlay = manager.get();
  }
  @HostListener('mouseenter')
  onMouseEnter() {
    this.overlay.open(this.el.nativeElement, this.tooltip);
  }
  @HostListener('mouseleave')
  onMouseLeave() {
    this.overlay.close();
  }
}

指令可以在我们的模板中使用以下标记:

<div tooltip="42">Tell me the answer!</div>

一旦用户指向标签“告诉我答案!”,Angular 将调用指令定义中的@HostListener装饰器下定义的方法。最终,将执行覆盖管理器的 open 方法。由于我们可以在单个元素上有多个指令,最佳实践规定我们应该使用属性作为选择器。

用于定义此指令的替代 ECMAScript 5 语法是:

var Tooltip = ng.core.Directive({
  selector: '[tooltip]',
  inputs: ['tooltip'],
  host: {
    '(mouseenter)': 'onMouseEnter()',
    '(mouseleave)': 'onMouseLeave()'
  }
})
.Class({
  constructor: [ng.core.ElementRef, Overlay, function (tooltip, el, manager) {
    this.el = el;
    this.overlay = manager.get();
  }],
  onMouseEnter() {
    this.overlay.open(this.el.nativeElement, this.tooltip);
  },
  onMouseLeave() {
    this.overlay.close();
  }
});

前面的 ES5 语法演示了 Angular 2 提供的内部 JavaScript领域特定语言DSL),以便让我们编写代码而不需要语法,这些语法尚未得到现代浏览器的支持。

我们可以总结说,Angular 2 通过保持将行为附加到 DOM 的概念来保留了指令的概念。1.x 和 2 之间的核心区别是新的语法,以及通过引入组件引入的进一步关注点分离。在第四章中,了解 Angular 2 组件和指令的基础,我们将进一步查看指令的 API。我们还将比较使用 ES2016 和 ES5 定义语法的指令。现在让我们来看一下 Angular 2 组件的重大变化。

了解 Angular 2 组件

模型视图控制器MVC)是最初用于实现用户界面的微架构模式。作为 AngularJS 开发人员,我们每天都在使用此模式的不同变体,最常见的是模型视图视图模型MVVM)。在 MVC 中,我们有模型,它封装了我们应用程序的业务逻辑,以及视图,它负责呈现用户界面,接受用户输入,并将用户交互逻辑委托给控制器。视图被表示为组件的组合,这正式称为组合设计模式

让我们看一下下面的结构图,它展示了组合设计模式:

了解 Angular 2 组件

图 5

这里有三个类:

  • 一个名为Component的抽象类。

  • 两个具体的类称为LeafCompositeLeaf类是我们即将构建的组件树中的简单终端组件。

Component类定义了一个名为operation的抽象操作。LeafComposite都继承自Component类。然而,Composite类还拥有对它的引用。我们甚至可以进一步允许Composite拥有对Component实例的引用列表,就像图示中所示。Composite内部的组件列表可以持有对不同CompositeLeaf实例的引用,或者持有对扩展了Component类或其任何后继类的其他类的实例的引用。在Composite内部的operation方法的实现中,循环中不同实例的调用操作可能会有不同的行为。这是因为面向对象编程语言中多态性实现的后期绑定机制。

组件的作用

够了理论!让我们基于图示的类层次结构构建一个组件树。这样,我们将演示如何利用组合模式来使用简化的语法构建用户界面。我们将在第四章中看到一个类似的例子,开始使用 Angular 2 组件和指令

Composite c1 = new Composite();
Composite c2 = new Composite();
Composite c3 = new Composite();

c1.components.push(c2);
c1.components.push(c3);

Leaf l1 = new Leaf();
Leaf l2 = new Leaf();
Leaf l3 = new Leaf();

c2.components.push(l1);
c2.components.push(l2);

c3.components.push(l3);

上面的伪代码创建了三个Composite类的实例和三个Leaf类的实例。实例c1在组件列表中持有对c2c3的引用。实例c2持有对l1l2的引用,c3持有对l3的引用:

组件的作用

图 6

上面的图示是我们在片段中构建的组件树的图形表示。这是现代 JavaScript 框架中视图的一个相当简化的版本。然而,它说明了我们如何组合指令和组件的基本原理。例如,在 Angular 2 的上下文中,我们可以将指令视为上面Leaf类的实例(因为它们不拥有视图,因此不能组合其他指令和组件),将组件视为Composite类的实例。

如果我们更抽象地思考 AngularJS 1.x 中的用户界面,我们会注意到我们使用了相似的方法。我们的视图模板将不同的指令组合在一起,以便向我们应用程序的最终用户提供完全功能的用户界面。

Angular 2 中的组件

Angular 2 采用了这种方法,引入了称为组件的新构建块。组件扩展了我们在上一节中描述的指令概念,并提供了更广泛的功能。这是一个基本的hello-world组件的定义:

@Component({
  selector: 'hello-world',
  template: '<h1>Hello, {{this.target}}!</h1>'
})
class HelloWorld {
  target: string;
  constructor() {
    this.target = 'world';
  }
}

我们可以通过在视图中插入以下标记来使用它:

<hello-world></hello-world>

根据最佳实践,我们应该使用一个元素作为组件的选择器,因为我们可能每个 DOM 元素只有一个组件。

使用 Angular 提供的 DSL 的替代 ES5 语法是:

var HelloWorld = ng.core.
  Component({
    selector: 'hello-world',
    template: '<h1>Hello, {{target}}!</h1>'
  })
  .Class({
    constructor: function () {
      this.target = 'world';
    }
  });

我们将在本书的后面更详细地看一下前面的语法。然而,让我们简要描述一下这个组件提供的功能。一旦 Angular 2 应用程序已经启动,它将查看我们 DOM 树中的所有元素并处理它们。一旦找到名为hello-world的元素,它将调用与其定义相关联的逻辑,这意味着组件的模板将被呈现,并且花括号之间的表达式将被评估。这将导致标记<h1>Hello, world!</h1>

因此,Angular 核心团队将 AngularJS 1.x 中的指令分成了两个不同的部分——组件指令。指令提供了一种简单的方法来将行为附加到 DOM 元素而不定义视图。Angular 2 中的组件提供了一个强大而简单易学的 API,使我们更容易定义应用程序的用户界面。Angular 2 组件允许我们做与 AngularJS 1.x 指令相同的惊人的事情,但输入更少,学习更少。组件通过向其添加视图来扩展 Angular 2 指令概念。我们可以将 Angular 2 组件和指令之间的关系看作是我们在图 5中看到的CompositeLeaf之间的关系。

如果我们开始阐述 Angular 2 提供的构建块的概念模型,我们可以将指令和组件之间的关系呈现为继承。第四章开始使用 Angular 2 组件和指令更详细地描述了这两个概念。

管道

在业务应用中,我们经常需要对相同的数据进行不同的可视化表示。例如,如果我们有数字 100,000,并且想要将其格式化为货币,很可能我们不想将其显示为普通数据;更可能的是,我们想要类似$100,000 这样的东西。

在 AngularJS 1.x 中,格式化数据的责任被分配给了过滤器。另一个数据格式化需求的例子是当我们使用项目集合时。例如,如果我们有一个项目列表,我们可能想要根据谓词(布尔函数)对其进行过滤;在数字列表中,我们可能只想显示素数。AngularJS 1.x 有一个名为filter的过滤器,允许我们这样做。然而,名称的重复经常导致混淆。这也是核心团队将过滤器组件重命名为管道的另一个原因。

新名称背后的动机是管道和过滤器所使用的语法:

{{expression | decimal | currency}}

在前面的例子中,我们将管道decimalcurrency应用到expression返回的值上。花括号之间的整个表达式看起来像 Unix 管道语法。

定义管道

定义管道的语法类似于指令和组件的定义所使用的语法。为了创建一个新的管道,我们可以使用 ES2015 装饰器@Pipe。它允许我们向类添加元数据,声明它为管道。我们所需要做的就是为管道提供一个名称并定义数据格式化逻辑。还有一种替代的 ES5 语法,如果我们想跳过转译的过程,可以使用它。

在运行时,一旦 Angular 2 表达式解释器发现给定表达式包含对管道的调用,它将从组件内分配的管道集合中检索出它,并使用适当的参数调用它。

下面的例子说明了我们如何定义一个简单的管道叫做lowercase1,它将传递给它的字符串转换为小写表示:

@Pipe({ name: 'lowercase1' })
class LowerCasePipe1 implements PipeTransform {
  transform(value: string): string {
    if (!value) return value;
    if (typeof value !== 'string') {
      throw new Error('Invalid pipe value', value);
    }
    return value.toLowerCase();
  }
}

为了保持一致,让我们展示定义管道的 ECMAScript 5 语法:

var LowercasePipe1 = ng.core.
  Pipe({
    name: 'lowercase'
  })
  .Class({
    constructor: function () {},
    transform: function (value) {
      if (!value) return value;
      if (typeof value === 'string') {
        throw new Error('Invalid pipe value', value);
      }
      return value.toLowerCase();
    }
  });

在 TypeScript 语法中,我们实现了PipeTransform接口,并定义了其中声明的transform方法。然而,在 ECMAScript 5 中,我们不支持接口,但我们仍然需要实现transform方法以定义一个有效的 Angular 2 管道。我们将在下一章中解释 TypeScript 接口。

现在让我们演示如何在组件中使用lowercase1管道:

@Component({
  selector: 'app',
  pipes: [LowercasePipe1],
  template: '<h1>{{"SAMPLE" | lowercase1}}</h1>'
})
class App {}

而且,这个的 ECMAScript 5 的替代语法是:

var App = ng.core.Component({
  selector: 'app',
  pipes: [LowercasePipe1],
  template: '<h1>{{"SAMPLE" | lowercase1}}</h1>'
})
.Class({
  constructor: function () {}
});

我们可以使用以下标记来使用App组件:

   <app></app>

我们将在屏幕上看到的结果是h1元素中的文本示例。

通过将数据格式化逻辑保持为一个独立的组件,Angular 2 保持了强大的关注点分离。我们将在第七章中看看如何为我们的应用程序定义有状态和无状态管道,在探索管道和 http 的同时构建一个真实的应用程序

更改检测

正如我们之前所看到的,MVC 中的视图会根据从模型接收到的更改事件进行更新。许多Model View WhateverMVW)框架采用了这种方法,并将观察者模式嵌入到了它们的更改检测机制的核心中。

经典的更改检测

让我们看一个简单的例子,不使用任何框架。假设我们有一个名为User的模型,它有一个名为name的属性:

class User extends EventEmitter {
  private name: string;
  setName(name: string) {
    this.name = name;
    this.emit('change');
	}
  getName(): string {
    return this.name;}
}

前面的片段使用了 TypeScript。如果语法对你来说不太熟悉,不用担心,我们将在下一章中对这种语言进行介绍。

user类扩展了EventEmitter类。这提供了发出和订阅事件的基本功能。

现在让我们定义一个视图,显示作为其constructor参数传递的User类实例的名称:

class View {
  constructor(user: User, el: Element /* a DOM element */) {
    el.innerHTML = user.getName();
	}
}

我们可以通过以下方式初始化视图元素:

let user = new User();
user.setName('foo');
let view = new View(user, document.getElementById('label'));

最终结果是,用户将看到一个带有内容foo的标签。但是,用户的更改不会反映在视图中。为了在用户更改名称时更新视图,我们需要订阅更改事件,然后更新 DOM 元素的内容。我们需要以以下方式更新View定义:

class View {
  constructor(user:User, el:any /* a DOM element */) {
    el.innerHTML = user.getName();
    user.on('change', () => {
      el.innerHTML = user.getName();
	  });
  }
}

这是大多数框架在 AngularJS 1.x 时代实现它们的更改检测的方式。

AngularJS 1.x 更改检测

大多数初学者都对 AngularJS 1.x 中的数据绑定机制着迷。基本的 Hello World 示例看起来类似于这样:

function MainCtrl($scope) {
  $scope.label = 'Hello world!';
}

<body ng-app ng-controller="MainCtrl">
  {{label}}
</body>

如果你运行这个,Hello world!神奇地出现在屏幕上。然而,这甚至不是最令人印象深刻的事情!如果我们添加一个文本输入,并将它绑定到作用域的label属性,每次更改都会反映出插值指令显示的内容:

<body ng-controller="MainCtrl">
  <input ng-model="label">
  {{label}}
</body>

这是 AngularJS 1.x 的主要卖点之一——极其容易实现数据绑定。我们在标记中添加了两个(如果计算ng-controllerng-app则为四个)属性,将属性添加到一个名为$scope的神秘对象中,这个对象被神奇地传递给我们定义的自定义函数,一切都很简单!

然而,更有经验的 Angular 开发人员更好地理解了幕后实际发生的事情。在前面的例子中,在指令ng-modelng-bind(在我们的例子中,插值指令{{}})内部,Angular 添加了具有不同行为的观察者,关联到相同的表达式label。这些观察者与经典 MVC 模式中的观察者非常相似。在某些特定事件(在我们的例子中,文本输入内容的更改)上,AngularJS 将循环遍历所有这样的观察者,评估它们关联的表达式在给定作用域的上下文中的结果,并存储它们的结果。这个循环被称为digest循环。

在前面的例子中,表达式label在作用域的上下文中的评估将返回文本Hello world!。在每次迭代中,AngularJS 将当前评估结果与先前结果进行比较,并在值不同时调用关联的回调。例如,插值指令添加的回调将设置元素的内容为表达式评估的新结果。这是两个指令的观察者的回调之间的依赖关系的一个例子。ng-model添加的观察者的回调修改了插值指令添加的观察者关联的表达式的结果。

然而,这种方法也有其自身的缺点。我们说digest循环将在一些特定事件上被调用,但如果这些事件发生在框架之外呢?例如,如果我们使用setTimeout,并且在作为第一个参数传递的回调函数内部更改了我们正在监视的作用域附加的属性,那会怎么样?AngularJS 将不知道这个变化,并且不会调用digest循环,所以我们需要使用$scope.$apply来显式地做这件事。但是,如果框架知道浏览器中发生的所有异步事件,比如用户事件、XMLHttpRequest事件、WebSockets相关事件等,会怎样呢?在这种情况下,AngularJS 将能够拦截事件处理,并且可以在不强制我们这样做的情况下调用digest循环!

在 zone.js 中

在 Angular 2 中,情况确实如此。这种功能是通过使用zone.js来实现的。

在 2014 年的 ng-conf 上,Brian Ford 谈到了 zone。Brian 将 zone 呈现为浏览器 API 的元猴补丁。最近,Miško Hevery 向 TC39 提出了更成熟的 zone API 以供标准化。Zone.js是由 Angular 团队开发的一个库,它在 JavaScript 中实现了 zone。它们代表了一个执行上下文,允许我们拦截异步浏览器调用。基本上,通过使用 zone,我们能够在给定的XMLHttpRequest完成后或者当我们接收到新的WebSocket事件时立即调用一段逻辑。Angular 2 利用了zone.js,通过拦截异步浏览器事件,并在合适的时机调用digest循环。这完全消除了使用 Angular 的开发人员需要显式调用digest循环的需要。

简化的数据流

交叉观察者依赖关系可能在我们的应用程序中创建纠缠不清的数据流,难以跟踪。这可能导致不可预测的行为和难以发现的错误。尽管 Angular 2 保留了脏检查作为实现变更检测的一种方式,但它强制了单向数据流。这是通过不允许不同观察者之间的依赖关系,从而使digest循环只运行一次。这种策略极大地提高了我们应用程序的性能,并减少了数据流的复杂性。Angular 2 还改进了内存效率和digest循环的性能。有关 Angular 2 的变更检测和其实现所使用的不同策略的更多详细信息,可以在第四章中找到,《开始使用 Angular 2 组件和指令》。

增强 AngularJS 1.x 的变更检测

现在让我们退一步,再次思考一下框架的变更检测机制。

我们说在digest循环内,Angular 评估注册的表达式,并将评估的值与上一次循环中与相同表达式关联的值进行比较。

比较所使用的最优算法可能取决于表达式评估返回的值的类型。例如,如果我们得到一个可变的项目列表,我们需要循环遍历整个集合,并逐个比较集合中的项目,以验证是否有更改。然而,如果我们有一个不可变的列表,我们可以通过比较引用来执行具有恒定复杂度的检查。这是因为不可变数据结构的实例不能改变。我们不会应用意图修改这些实例的操作,而是会得到一个应用了修改的新引用。

在 AngularJS 1.x 中,我们可以使用几种方法添加监视器。其中两种是$watch(exp, fn, deep)$watchCollection(exp, fn)。这些方法让我们在改变检测的执行上有一定程度的控制。例如,使用$watch添加一个监视器,并将false值作为第三个参数传递将使 AngularJS 执行引用检查(即使用===比较当前值与先前值)。然而,如果我们传递一个真值(任何true值),检查将是深层的(即使用angular.equals)。这样,根据表达式值的预期类型,我们可以以最合适的方式添加监听器,以便允许框架使用最优化的算法执行相等性检查。这个 API 有两个限制:

  • 它不允许您在运行时选择最合适的相等性检查算法。

  • 它不允许您将改变检测扩展到第三方以适应其特定的数据结构。

Angular 核心团队将这一责任分配给了差异,使它们能够扩展改变检测机制并根据我们在应用程序中使用的数据进行优化。Angular 2 定义了两个基类,我们可以扩展以定义自定义算法:

  • KeyValueDiffer:这允许我们在基于键值的数据结构上执行高级差异。

  • IterableDiffer:这允许我们在类似列表的数据结构上执行高级差异。

Angular 2 允许我们通过扩展自定义算法来完全控制改变检测机制,而在框架的先前版本中是不可能的。我们将进一步研究改变检测以及如何在第四章中配置它,开始使用 Angular 2 组件和指令

理解服务

服务是 Angular 为定义应用程序的业务逻辑提供的构建块。在 AngularJS 1.x 中,我们有三种不同的方式来定义服务:

// The Factory method
module.factory('ServiceName', function (dep1, dep2, …) {
  return {
    // public API
  };
});

// The Service method
module.service('ServiceName', function (dep1, dep2, …) {
  // public API
  this.publicProp = val;
});

// The Provider method
module.provider('ServiceName', function () {
  return {
    $get: function (dep1, dep2, …) {
      return {
        // public API
      };
    }
  };
});

尽管前两种语法变体提供了类似的功能,但它们在注册指令实例化的方式上有所不同。第三种语法允许在配置时间进一步配置注册的提供者。

对于 AngularJS 1.x 的初学者来说,有三种不同的定义服务的方法是相当令人困惑的。让我们想一想是什么促使引入这些注册服务方法。为什么我们不能简单地使用 JavaScript 构造函数、对象文字或 ES2015 类,而 Angular 不会意识到呢?我们可以像这样在自定义 JavaScript 构造函数中封装我们的业务逻辑:

function UserTransactions(id) {
  this.userId = id;
}
UserTransactions.prototype.makeTransaction = function (amount) {
  // method logic
};

module.controller('MainCtrl', function () {
  this.submitClick = function () {
    new UserTransactions(this.userId).makeTransaction(this.amount);
  };
});

这段代码是完全有效的。然而,它没有利用 AngularJS 1.x 提供的一个关键特性——DI 机制。MainCtrl函数使用了构造函数UserTransaction,它在其主体中可见。上述代码有两个主要缺点:

  • 我们与服务实例化的逻辑耦合在一起。

  • 这段代码无法进行测试。为了模拟UserTransactions,我们需要对其进行 monkey patch。

AngularJS 如何处理这两个问题?当需要一个特定的服务时,通过框架的 DI 机制,AngularJS 解析所有的依赖关系,并通过将它们传递给factory函数来实例化它。factory函数作为factoryservice方法的第二个参数传递。provider方法允许在更低级别定义服务;在那里,factory方法是提供者的$get属性下的方法。

就像 AngularJS 1.x 一样,Angular 2 也容忍这种关注点的分离,所以核心团队保留了服务。与 AngularJS 1.x 相比,这个框架的最新主要版本通过允许我们使用纯粹的 ES2015 类或 ES5 构造函数来定义服务,提供了一个更简单的接口。我们无法逃避这样一个事实,即我们需要明确声明哪些服务应该可用于注入,并以某种方式指定它们的实例化指令。然而,Angular 2 使用 ES2016 装饰器的语法来实现这一目的,而不是我们从 AngularJS 1.x 熟悉的方法。这使我们能够像 ES2015 类一样简单地在我们的应用程序中定义服务,并使用装饰器来配置 DI:

import {Inject, Injectable} from 'angular2/core';

@Injectable()
class HttpService {
  constructor() { /* … */ }
}

@Injectable()
class User {
  constructor(private service: HttpService) {}
  save() {
    return this.service.post('/users')
      .then(res => {
        this.id = res.id;
        return this;
      });
  }
}

ECMAScript 5 的替代语法是:

var HttpService = ng.core.Class({
  constructor: function () {}
});
var User = ng.core.Class({
  constructor: [HttpService, function (service) {
    this.service = service;
  }],
  save: function () {
    return this.service.post('/users')
      .then(function (res) {
        this.id = res.id;
        return this;
      });
  }
});

服务与前面章节中描述的组件和指令相关联。为了开发高度一致和可重用的 UI 组件,我们需要将所有与业务相关的逻辑移动到我们的服务中。为了开发可测试的组件,我们需要利用 DI 机制来解决它们的所有依赖关系。

Angular 2 和 AngularJS 1.x 中服务之间的一个核心区别是它们的依赖项是如何被解析和内部表示的。AngularJS 1.x 使用字符串来标识不同的服务和用于实例化它们的相关工厂。然而,Angular 2 使用键。通常,这些键是不同服务的类型。在实例化中的另一个核心区别是注入器的分层结构,它封装了具有不同可见性的不同依赖项提供者。

Angular 2 和框架的最后两个主要版本之间的另一个区别是简化的语法。虽然 Angular 2 使用 ES2015 类来定义业务逻辑,但您也可以使用 ECMAScript 5 的constructor函数,或者使用框架提供的 DSL。Angular 2 中的 DI 具有完全不同的语法,并通过提供一种一致的方式来注入依赖项来改进行为。前面示例中使用的语法使用了 ES2016 装饰器,在第五章中,我们将看一下使用 ECMAScript 5 的替代语法。您还可以在第五章中找到有关 Angular 2 服务和 DI 的更详细解释,Angular 2 中的依赖注入

理解基于组件的新路由器

在传统的 Web 应用程序中,所有页面更改都与完整页面重新加载相关,这会获取所有引用的资源和数据,并将整个页面呈现到屏幕上。然而,随着时间的推移,Web 应用程序的要求已经发生了变化。

我们使用 Angular 构建的单页应用程序SPA)模拟桌面用户体验。这经常涉及按需加载应用程序所需的资源和数据,并且在初始页面加载后不进行完整的页面重新加载。通常,SPA 中的不同页面或视图由不同的模板表示,这些模板是异步加载并在屏幕上的特定位置呈现。稍后,当加载了所有所需资源的模板并且路由已更改时,将调用附加到所选页面的逻辑,并使用数据填充模板。如果用户在加载了我们的 SPA 中的给定页面后按下刷新按钮,则在视图完成刷新后需要重新呈现相同的页面。这涉及类似的行为——查找请求的视图,获取所有引用资源的所需模板,并调用与该视图相关的逻辑。

需要获取哪个模板,以及在页面成功重新加载后应调用的逻辑,取决于用户在按下刷新按钮之前选择的视图。框架通过解析页面 URL 来确定这一点,该 URL 包含当前选定页面的标识符,以分层结构表示。

与导航、更改 URL、加载适当模板和在视图加载时调用特定逻辑相关的所有责任都分配给了路由器组件。这些都是相当具有挑战性的任务,为了跨浏览器兼容性而需要支持不同的导航 API,使得在现代 SPA 中实现路由成为一个非平凡的问题。

AngularJS 1.x 在其核心中引入了路由器,后来将其外部化为ngRoute组件。它允许以声明方式定义 SPA 中的不同视图,为每个页面提供模板和需要在选择页面时调用的逻辑。然而,路由器的功能有限。它不支持诸如嵌套视图路由之类的基本功能。这是大多数开发人员更喜欢使用由社区开发的ui-router的原因之一。AngularJS 1.x 的路由器和ui-router的路由定义都包括路由配置对象,该对象定义了与页面关联的模板和控制器。

如前几节所述,Angular 2 改变了它为开发单页应用程序提供的构建模块。Angular 2 移除了浮动控制器,而是将视图表示为组件的组合。这需要开发一个全新的路由器,以赋予这些新概念力量。

AngularJS 1.x 路由器和 Angular 2 路由器之间的核心区别是:

  • Angular 2 路由器是基于组件的,而ngRoute不是。

  • 现在支持嵌套视图。

  • ES2016 装饰器赋予了不同的语法。

Angular 2 路由定义语法

让我们简要地看一下 Angular 2 路由器在我们应用程序中定义路由时使用的新语法:

import {Component} from 'angular2/core';
import {bootstrap} from 'angular2/platform/browser';
import {RouteConfig, ROUTER_DIRECTIVES, ROUTER_BINDINGS} from 'angular2/router';

import {Home} from './components/home/home';
import {About} from './components/about/about';

@Component({
  selector: 'app',
  templateUrl: './app.html',
  directives: [ROUTER_DIRECTIVES]
})
@RouteConfig([
  { path: '/', component: Home, name: 'home' },
  { path: '/about', component: About, name: 'about' }
])
class App {}

bootstrap(App, [ROUTER_PROVIDERS]);

我们不会在这里详细介绍,因为第六章、Angular 2 表单和基于组件的新路由器和第七章、在探索管道和 http 的同时构建一个真实的应用程序专门讨论了新路由器,但让我们提到前面代码片段中的主要要点。

路由器位于模块angular2/router中。在那里,我们可以找到它定义的指令,用于配置路由的装饰器和ROUTER_PROVIDERS

注意

我们将在第七章中进一步了解ROUTER_PROVIDERS在探索管道和 http 的同时构建一个真实的应用程序

@RouteConfig装饰器传递的参数显示了我们如何在应用程序中定义路由。我们使用一个包含对象的数组,它定义了路由和与其关联的组件之间的映射关系。在Component装饰器内部,我们明确说明我们要使用ROUTER_DIRECTIVES中包含的指令,这些指令与模板中的路由器使用相关。

总结

在本章中,我们快速概述了 Angular 2 提供的开发单页应用程序的主要构建模块。我们指出了 AngularJS 1.x 和 Angular 2 中这些组件之间的核心区别。

虽然我们可以使用 ES2015,甚至 ES5 来构建 Angular 2 应用程序,但 Google 的建议是利用用于开发框架的语言—TypeScript。

在下一章中,我们将看一下 TypeScript 以及如何在您的下一个应用程序中开始使用它。我们还将解释如何利用 JavaScript 库和框架中的静态类型,这些库和框架是用原生 JavaScript 编写的,带有环境类型注释。

第三章:TypeScript Crash Course

在本章中,我们将开始使用 TypeScript,这是 Angular 2 推荐的脚本语言。ECMAScript 2015 和 ECMAScript 2016 提供的所有功能,如函数、类、模块和装饰器,已经在 TypeScript 中实现或添加到路线图中。由于额外的类型注解,与 JavaScript 相比,有一些语法上的补充。

为了更顺畅地从我们已经了解的语言 ES5 过渡,我们将从 ES2016 和 TypeScript 之间的一些共同特性开始。在 ES 语法和 TypeScript 之间存在差异的地方,我们将明确提到。在本章的后半部分,我们将为我们到目前为止学到的所有内容添加类型注解。

在本章的后面,我们将解释 TypeScript 提供的额外功能,如静态类型和扩展语法。我们将讨论基于这些功能的不同后果,这将帮助我们更加高效和减少出错。让我们开始吧!

TypeScript 简介

TypeScript 是一种由微软开发和维护的开源编程语言。它最初是在 2012 年 10 月公开发布的。TypeScript 是 ECMAScript 的超集,支持 JavaScript 的所有语法和语义,还有一些额外的功能,如静态类型和更丰富的语法。

图 1 显示了 ES5、ES2015、ES2016 和 TypeScript 之间的关系。

TypeScript 简介

图 1

由于 TypeScript 是静态类型的,它可以为我们作为 JavaScript 开发人员提供许多好处。现在让我们快速看一下这些好处。

编译时类型检查

我们在编写 JavaScript 代码时常犯的一些常见错误是拼错属性或方法名。当我们遇到运行时错误时,我们会发现这个错误。这可能发生在开发过程中,也可能发生在生产环境中。希望在部署代码到生产环境之前我们能知道错误并不是一种舒适的感觉!然而,这不是 JavaScript 特有的问题;这是所有动态语言共有的问题。即使有很多单元测试,这些错误也可能会漏掉。

TypeScript 提供了一个编译器,通过静态代码分析来为我们处理这些错误。如果我们利用静态类型,TypeScript 将意识到给定对象具有的现有属性,如果我们拼错了其中任何一个,编译器将在编译时警告我们。

TypeScript 的另一个巨大好处是它允许大型团队合作,因为它提供了正式的、可验证的命名。这样,它允许我们编写易于理解的代码。

文本编辑器和集成开发环境提供更好的支持

有许多工具,如 Tern 或 Google Closure Compiler,它们试图为文本编辑器和集成开发环境提供更好的 JavaScript 自动补全支持。然而,由于 JavaScript 是一种动态语言,没有任何元数据,文本编辑器和集成开发环境无法提出复杂的建议。

用这些元数据注释代码是 TypeScript 的内置特性,称为类型注解。基于它们,文本编辑器和集成开发环境可以对我们的代码进行更好的静态分析。这提供了更好的重构工具和自动补全,这增加了我们的生产力,使我们在编写应用程序源代码时犯更少的错误。

TypeScript 甚至更多

TypeScript 本身还有许多其他好处:

  • 它是 JavaScript 的超集:所有 JavaScript(ES5 和 ES2015)程序已经是有效的 TypeScript 程序。实质上,您已经在编写 TypeScript 代码。由于它基于 ECMAScript 标准的最新版本,它允许我们利用语言提供的最新的前沿语法。

  • 支持可选类型检查:如果出于任何原因,我们决定不想明确定义变量或方法的类型,我们可以跳过类型定义。然而,我们应该意识到这意味着我们不再利用静态类型,因此放弃了前面提到的所有好处。

  • 由微软开发和维护:语言实现的质量非常高,不太可能会突然停止支持。TypeScript 基于世界上一些最优秀的编程语言开发专家的工作。

  • 它是开源的:这允许社区自由地为语言做出贡献并提出功能,这些功能是以开放的方式讨论的。TypeScript 是开源的事实使得第三方扩展和工具更容易开发,从而进一步扩展了其使用范围。

由于现代浏览器不支持 TypeScript 本地,因此有一个编译器将我们编写的 TypeScript 代码转换为预定义版本的 ECMAScript 可读的 JavaScript。一旦代码编译完成,所有类型注释都将被移除。

使用 TypeScript

让我们开始编写一些 TypeScript!

在接下来的章节中,我们将看一些展示 TypeScript 功能的不同片段。为了能够运行这些片段并自己玩耍,您需要在计算机上安装 TypeScript 编译器。让我们看看如何做到这一点。

最好使用Node Package Managernpm)安装 TypeScript。我建议您使用 npm 版本 3.0.0 或更新版本。如果您尚未安装 node.js 和 npm,可以访问nodejs.org并按照那里的说明进行操作。

使用 npm 安装 TypeScript

一旦您安装并运行了 npm,请通过打开终端窗口并运行以下命令来验证您是否拥有最新版本:

**$ npm –v**

要安装 TypeScript 1.8,请使用:

**$ npm install -g typescript@1.8**

上述命令将安装 TypeScript 编译器,并将其可执行文件(tsc)添加为全局路径。

为了验证一切是否正常工作,您可以使用:

**$ tsc –v**
**Version 1.8.0**

输出应该类似于上面的输出,尽管可能使用不同的版本。

运行我们的第一个 TypeScript 程序

注意

您可以在以下 URL 找到本书的代码:github.com/mgechev/switching-to-angular2。在大多数代码片段中,您会找到一个相对于app目录的文件路径,您可以在那里找到它们。

现在,让我们编译我们的第一个 TypeScript 程序!创建一个名为hello.ts的文件,并输入以下内容:

// ch3/hello-world/hello-world.ts
console.log('Hello world!');

由于您已经安装了 TypeScript 编译器,您应该有一个名为tsc的全局可执行命令。您可以使用它来编译文件:

**$ tsc hello.ts**

现在,你应该在hello.ts所在的同一目录中看到文件hello.jshello.js是 TypeScript 编译器的输出;它包含了你编写的 TypeScript 的 JavaScript 等价物。你可以使用以下命令运行这个文件:

**$ node hello.js**

现在,你会在屏幕上看到字符串Hello world!。为了结合编译和运行程序的过程,你可以使用ts-node包:

**$ npm install -t ts-node**

现在你可以运行:

**$ ts-node hello.ts**

你应该看到相同的结果,但是没有存储在磁盘上的ts-node文件。

TypeScript 语法和特性是由 ES2015 和 ES2016 引入的。

由于 TypeScript 是 JavaScript 的超集,在我们开始学习它的语法之前,先介绍 ES2015 和 ES2016 中的一些重大变化会更容易一些;要理解 TypeScript,我们首先必须理解 ES2015 和 ES2016。在深入学习 TypeScript 之前,我们将快速浏览这些变化。

本书不涵盖 ES2015 和 ES2016 的详细解释。为了熟悉所有新特性和语法,我强烈建议你阅读Exploring ES6: upgrade to the next version of JavaScript by Dr. Axel Rauschmayer

接下来的几页将介绍新的标准,并让你利用大部分你在开发 Angular 2 应用程序中需要的特性。

ES2015 箭头函数

JavaScript 具有一级函数,这意味着它们可以像其他值一样传递:

// ch3/arrow-functions/simple-reduce.ts
var result = [1, 2, 3].reduce(function (total, current) {
  return total + current;
}, 0); // 6

这种语法很棒;但是有点太啰嗦了。ES2015 引入了一种新的语法来定义匿名函数,称为箭头函数语法。使用它,我们可以创建匿名函数,就像下面的例子中所示:

// ch3/arrow-functions/arrow-functions.ts

// example 1
var result = [1, 2, 3]
  .reduce((total, current) => total + current, 0);

console.log(result);

// example 2
var even = [3, 1, 56, 7].filter(el => !(el % 2));

console.log(even);

// example 3
var sorted = data.sort((a, b) => {
  var diff = a.price - b.price;
  if (diff !== 0) {
    return diff;
  }
  return a.total - b.total;
});

在第一个例子中,我们得到了数组[1, 2, 3]中元素的总和。在第二个例子中,我们得到了数组[3, 1, 56, 7]中所有的偶数。在第三个例子中,我们按照属性pricetotal的升序对数组进行了排序。

箭头函数还有一些我们需要看看的特性。其中最重要的一个是它们会保持周围代码的上下文(this)。

// ch3/arrow-functions/context-demo.ts
function MyComponent() {
  this.age = 42;
  setTimeout(() => {
    this.age += 1;
    console.log(this.age);
  }, 100);
}
new MyComponent(); // 43 in 100ms.

例如,当我们使用new操作符调用函数MyComponent时,this将指向调用实例化的新对象。箭头函数将保持上下文(this),在setTimeout的回调中,屏幕上会打印43

这在 Angular 2 中非常有用,因为给定组件的绑定上下文是其实例(即其this)。如果我们将MyComponent定义为 Angular 2 组件,并且我们有一个绑定到age属性,前面的代码将是有效的,并且所有绑定将起作用(请注意,我们没有作用域,也没有显式调用$digest循环,尽管我们直接调用了setTimeout)。

使用 ES2015 和 ES2016 类

当初次接触 JavaScript 的开发人员听说语言赋予了面向对象OO)范式的能力时,当他们发现没有类的定义语法时,他们通常会感到困惑。这种看法是由于一些最流行的编程语言,如 Java、C#和 C++,具有用于构建对象的类的概念。然而,JavaScript 以不同的方式实现了面向对象范式。JavaScript 具有基于原型的面向对象编程模型,我们可以使用对象字面量语法或函数(也称为构造函数)来实例化对象,并且我们可以利用所谓的原型链来实现继承。

虽然这是一种实现面向对象范式的有效方式,语义与经典面向对象模型中的方式类似,但对于经验不足的 JavaScript 开发人员来说,他们不确定如何正确处理这一点,这是 TC39 决定提供一种替代语法来利用语言中的面向对象范式的原因之一。在幕后,新的语法与我们习惯的语法具有相同的语义,比如使用构造函数和基于原型的继承。然而,它提供了一种更方便的语法,以减少样板代码来增强面向对象范式的特性。

ES2016 为 ES2015 类添加了一些额外的语法,例如静态和实例属性声明。

以下是一个示例,演示了 ES2016 中用于定义类的语法:

// ch3/es6-classes/sample-classes.ts

class Human {
  static totalPeople = 0;
  _name; // ES2016 property declaration syntax
  constructor(name) {
    this._name = name;
    Human.totalPeople += 1;
  }
  get name() {
    return this._name;
  }
  set name(val) {
    this._name = val;
  }
  talk() {
    return `Hi, I'm ${this.name}!`;
  }
}

class Developer extends Human {
  _languages; // ES2016 property declaration syntax
  constructor(name, languages) {
    super(name);
    this._languages = languages;
  }
  get languages() {
    return this._languages;
  }
  talk() {
    return `${super.talk()} And I know
${this.languages.join(',')}.`;
  }
}

在 ES2015 中,不需要显式声明_name属性;然而,由于 TypeScript 编译器在编译时应该知道给定类的实例的现有属性,我们需要将属性的声明添加到类声明本身中。

前面的片段既是有效的 TypeScript 代码,也是 JavaScript 代码。 在其中,我们定义了一个名为Human的类,它向由它实例化的对象添加了一个属性。 它通过将其值设置为传递给其构造函数的参数名称来实现这一点。

现在,打开ch3/es6-classes/sample-classes.ts文件并进行操作! 您可以以与使用构造函数创建对象相同的方式创建类的不同实例:

var human = new Human("foobar");
var dev = new Developer("bar", ["JavaScript"]);
console.log(dev.talk());

为了执行代码,请运行以下命令:

**$ ts-node sample-classes.ts**

类通常在 Angular 2 中使用。 您可以使用它们来定义组件,指令,服务和管道。 但是,您还可以使用替代的 ES5 语法,该语法利用构造函数。 在幕后,一旦 TypeScript 代码被编译,两种语法之间将没有太大的区别,因为 ES2015 类最终被转译为构造函数。

使用块作用域定义变量

JavaScript 对具有不同背景的开发人员来说另一个令人困惑的地方是语言中的变量作用域。 例如,在 Java 和 C ++中,我们习惯于块词法作用域。 这意味着在特定块内定义的给定变量只在该块内以及其中的所有嵌套块内可见。

然而,在 JavaScript 中,情况有些不同。 ECMAScript 定义了一个具有类似语义的函数词法作用域,但它使用函数而不是块。 这意味着我们有以下内容:

// ch3/let/var.ts

var fns = [];
for (var i = 0; i < 5; i += 1) {
  fns.push(function() {
    console.log(i);
  })
}
fns.forEach(fn => fn());

这有一些奇怪的含义。 一旦代码被执行,它将记录五次数字5

ES2015 添加了一种新的语法来定义具有块作用域可见性的变量。 语法与当前的语法类似。 但是,它使用关键字let而不是var

// ch3/let/let.ts

var fns = [];
for (let i = 0; i < 5; i += 1) {
  fns.push(function() {
    console.log(i);
  })
}
fns.forEach(fn => fn());

使用 ES2016 装饰器进行元编程

JavaScript 是一种动态语言,允许我们轻松修改和/或改变行为以适应我们编写的程序。 装饰器是 ES2016 的一个提案,根据设计文档github.com/wycats/javascript-decorators

“…使注释和修改类和属性在设计时成为可能。”

它们的语法与 Java 中的注解非常相似,甚至更接近 Python 中的装饰器。ES2016 装饰器在 Angular 2 中通常用于定义组件、指令和管道,并利用框架的依赖注入机制。基本上,装饰器的大多数用例涉及改变行为以预定义逻辑或向不同的结构添加一些元数据。

ES2016 装饰器允许我们通过改变程序的行为来做很多花哨的事情。典型的用例可能是将给定的方法或属性标注为已弃用或只读。一组预定义的装饰器可以提高我们所生成的代码的可读性,可以在Jay Phelps的名为core-decorators.js的项目中找到。另一个用例是利用基于代理的面向方面编程,使用声明性语法。提供此功能的库是aspect.js

总的来说,ES2016 装饰器只是另一种语法糖,它转换成我们已经熟悉的来自 JavaScript 之前版本的代码。让我们看一个来自提案草案的简单示例:

// ch3/decorators/nonenumerable.ts

class Person {
  @nonenumerable
  get kidCount() {
    return 42;
  }
}

function nonenumerable(target, name, descriptor) {
  descriptor.enumerable = false;
  return descriptor;
}

var person = new Person();

for (let prop in person) {
  console.log(prop);
}

在这种情况下,我们有一个名为Person的 ES2015 类,其中有一个名为kidCount的单个 getter。在kidCount getter 上,我们应用了nonenumerable装饰器。装饰器是一个接受目标(Person类)、我们打算装饰的目标属性的名称(kidCount)和target属性的描述符的函数。在我们改变描述符之后,我们需要返回它以应用修改。基本上,装饰器的应用可以用以下方式转换成 ECMAScript 5:

descriptor = nonenumerable (Person.prototype, 'kidCount', descriptor) || descriptor;
Object.defineProperty(Person.prototype, 'kidCount', descriptor);

使用可配置的装饰器

以下是使用 Angular 2 定义的装饰器的示例:

@Component({
  selector: 'app',
  providers: [NamesList],
  templateUrl: './app.html',
  directives: [RouterOutlet, RouterLink]
})
@RouteConfig([
  { path: '/', component: Home, name: 'home' },
  { path: '/about', component: About, name: 'about' }
])
export class App {}

当装饰器接受参数(就像前面示例中的ComponentRouteConfigView一样),它们需要被定义为接受参数并返回实际装饰器的函数:

function Component(config) {
  // validate properties
  return (componentCtrl) => {
    // apply decorator
  };
}

在这个例子中,我们定义了一个可配置的装饰器,名为Component,它接受一个名为config的单个参数并返回一个装饰器。

使用 ES2015 编写模块化代码

JavaScript 专业人士多年来经历的另一个问题是语言中缺乏模块系统。最初,社区开发了不同的模式,旨在强制执行我们生产的软件的模块化和封装。这些模式包括模块模式,它利用了函数词法作用域和闭包。另一个例子是命名空间模式,它将不同的命名空间表示为嵌套对象。AngularJS 1.x 引入了自己的模块系统,不幸的是它不提供懒加载模块等功能。然而,这些模式更像是变通办法,而不是真正的解决方案。

CommonJS(在 node.js 中使用)和AMD异步模块定义)后来被发明。它们仍然广泛使用,并提供功能,如处理循环依赖,异步模块加载(在 AMD 中),等等。

TC39 吸收了现有模块系统的优点,并在语言级别引入了这个概念。ES2015 提供了两个 API 来定义和消费模块。它们如下:

  • 声明式 API。

  • 使用模块加载器的命令式 API。

Angular 2 充分利用了 ES2015 模块系统,让我们深入研究一下!在本节中,我们将看一下用于声明性定义和消费模块的语法。我们还将窥探模块加载器的 API,以便了解如何以显式异步方式编程加载模块。

使用 ES2015 模块语法

让我们来看一个例子:

// ch3/modules/math.ts

export function square(x) {
  return Math.pow(x, 2);
};
export function log10(x) {
  return Math.log10(x);
};
export const PI = Math.PI;

在上面的片段中,我们在文件math.ts中定义了一个简单的 ES2015 模块。我们可以将其视为一个样本数学 Angular 2 实用模块。在其中,我们定义并导出了函数squarelog10,以及常量PIconst关键字是 ES2015 带来的另一个关键字,用于定义常量。正如你所看到的,我们所做的不过是在函数定义前加上export关键字。如果我们最终想要导出整个功能并跳过重复显式使用export,我们可以:

// ch3/modules/math2.ts

function square(x) {
  return Math.pow(x, 2);
};
function log10(x) {
  return Math.log10(x);
};
const PI = Math.PI;
export { square, log10, PI };

最后一行的语法只不过是 ES2015 引入的增强对象文字语法。现在,让我们看看如何消费这个模块:

// ch3/modules/app.ts

import {square, log10} from './math';
console.log(square(2)); // 4
console.log(log10(10)); // 1

作为模块的标识符,我们使用了相对于当前文件的路径。通过解构,我们导入了所需的函数——在这种情况下是squarelog10

利用隐式的异步行为

重要的是要注意,ES2015 模块语法具有隐式的异步行为。

假设我们有模块ABC。模块A使用模块BC,所以它依赖于它们。一旦用户需要模块A,JavaScript 模块加载器就需要在能够调用模块A中的任何逻辑之前加载模块BC,因为我们有依赖关系。然而,模块BC将被异步加载。一旦它们完全加载,JavaScript 虚拟机将能够执行模块A

使用别名

另一种典型的情况是当我们想要为给定的导出使用别名。例如,如果我们使用第三方库,我们可能想要重命名其任何导出,以避免名称冲突或只是为了更方便的命名:

import {bootstrap as initialize} from 'angular2/platform/browser';

导入所有模块导出

我们可以使用以下方式导入整个math模块:

// ch3/modules/app2.ts

import * as math from './math';
console.log(math.square(2)); // 4
console.log(math.log10(10)); // 1
console.log(math.PI); // 3.141592653589793

这个语法背后的语义与 CommonJS 非常相似,尽管在浏览器中,我们有隐式的异步行为。

默认导出

如果给定模块定义了一个导出,这个导出很可能会被任何消费模块使用,我们可以利用默认导出语法:

// ch3/modules/math3.ts

export default function cube(x) {
  return Math.pow(x, 3);
};
export function square(x) {
  return Math.pow(x, 2);
};

为了使用这个模块,我们可以使用以下app.ts文件:

// ch3/modules/app3.ts

import cube from './math3';
console.log(cube(3)); // 27

或者,如果我们想要导入默认导出以及其他一些导出,我们可以使用:

// ch3/modules/app4.ts

import cube, { square } from './math3';
console.log(square(2)); // 4
console.log(cube(3)); // 27

一般来说,默认导出只是一个用保留字default命名的命名导出:

// ch3/modules/app5.ts

import { default as cube } from './math3';
console.log(cube(3)); // 27

ES2015 模块加载器

标准的新版本定义了一个用于处理模块的编程 API。这就是所谓的模块加载器 API。它允许我们定义和导入模块,或配置模块加载。

假设我们在文件app.js中有以下模块定义:

import { square } from './math';
export function main() {
  console.log(square(2)); // 4
}

从文件init.js中,我们可以以编程方式加载app模块,并使用以下方式调用其main函数:

System.import('./app')
  .then(app => {
    app.main();
  })
  .catch(error => {
    console.log('Terrible error happened', error);
  });

全局对象System有一个名为import的方法,允许我们使用它们的标识符导入模块。在前面的片段中,我们导入了在app.js中定义的app模块。System.import返回一个 promise,该 promise 在成功时可以解析,或在发生错误时被拒绝。一旦 promise 作为传递给then的回调的第一个参数解析,我们将得到模块本身。在拒绝的情况下注册的回调的第一个参数是发生的错误。

最后一段代码不存在于 GitHub 存储库中,因为它需要一些额外的配置。我们将在本书的下一章中更明确地应用模块加载器在 Angular 2 示例中。

ES2015 和 ES2016 回顾

恭喜!我们已经超过学习 TypeScript 的一半了。我们刚刚看到的所有功能都是 TypeScript 的一部分,因为它实现了 JavaScript 的超集,并且所有这些功能都是当前语法的升级,对于有经验的 JavaScript 开发人员来说很容易掌握。

在接下来的章节中,我们将描述 TypeScript 的所有令人惊奇的功能,这些功能超出了与 ECMAScript 的交集。

利用静态类型

静态类型是可以为我们的开发过程提供更好工具支持的。在编写 JavaScript 时,IDE 和文本编辑器所能做的最多就是语法高亮和基于我们代码的复杂静态分析提供一些基本的自动补全建议。这意味着我们只能通过运行代码来验证我们没有犯任何拼写错误。

在前面的章节中,我们只描述了 ECMAScript 提供的新功能,这些功能预计将在不久的将来由浏览器实现。在本节中,我们将看看 TypeScript 提供了什么来帮助我们减少错误,并提高生产力。在撰写本文时,尚无计划在浏览器中实现静态类型的内置支持。

TypeScript 代码经过中间预处理,进行类型检查并丢弃所有类型注释,以提供现代浏览器支持的有效 JavaScript。

使用显式类型定义

就像 Java 和 C++一样,TypeScript 允许我们明确声明给定变量的类型:

let foo: number = 42;

前一行使用let语法在当前块中定义变量foo。我们明确声明要将foo设置为number类型,并将foo的值设置为42

现在让我们尝试更改foo的值:

let foo: number = 42;
foo = '42';

在这里,在声明foo之后,我们将其值设置为字符串'42'。这是完全有效的 JavaScript 代码;然而,如果我们使用 TypeScript 的编译器编译它,我们将得到:

$ tsc basic.ts
basic.ts(2,1): error TS2322: Type 'string' is not assignable to type 'number'.

一旦foo与给定类型关联,我们就不能为其分配属于不同类型的值。这是我们可以跳过显式类型定义的原因之一,如果我们为给定变量分配一个值:

let foo = 42;
foo = '42';

这段代码背后的语义将与显式类型定义的代码相同,因为 TypeScript 的类型推断。我们将在本章末进一步研究它。

任意类型

TypeScript 中的所有类型都是称为 any 的类型的子类型。我们可以使用 any 关键字声明属于 any 类型的变量。这样的变量可以保存 any 类型的值:

let foo: any;
foo = {};
foo = 'bar ';
foo += 42;
console.log(foo); // "bar 42"

上述代码是有效的 TypeScript 代码,在编译或运行时不会抛出任何错误。如果我们对所有变量使用类型 any,基本上就是使用动态类型编写代码,这会丧失 TypeScript 编译器的所有优势。这就是为什么我们必须小心使用 any,只有在必要时才使用它。

TypeScript 中的所有其他类型都属于以下类别之一:

  • 原始类型:这包括 Number、String、Boolean、Void、Null、Undefined 和 Enum 类型。

  • 联合类型:联合类型超出了本书的范围。您可以在 TypeScript 规范中查看它们。

  • 对象类型:这包括函数类型、类和接口类型引用、数组类型、元组类型、函数类型和构造函数类型。

  • 类型参数:这包括将在 使用类型参数编写通用代码 部分中描述的泛型。

理解原始类型

TypeScript 中大多数原始类型都是我们在 JavaScript 中已经熟悉的类型:Number、String、Boolean、Null 和 Undefined。因此,我们将跳过它们的正式解释。另一组在开发 Angular 2 应用程序时很方便的类型是用户定义的枚举类型。

枚举类型

枚举类型是原始用户定义类型,根据规范,它们是 Number 的子类。enums 的概念存在于 Java、C++ 和 C# 语言中,在 TypeScript 中具有相同的语义——由一组命名值元素组成的用户定义类型。在 TypeScript 中,我们可以使用以下语法定义 enum

enum STATES {
  CONNECTING,
  CONNECTED,
  DISCONNECTING,
  WAITING,
  DISCONNECTED	
};

这将被翻译为以下 JavaScript:

var STATES;
(function (STATES) {
    STATES[STATES["CONNECTING"] = 0] = "CONNECTING";
    STATES[STATES["CONNECTED"] = 1] = "CONNECTED";
    STATES[STATES["DISCONNECTING"] = 2] = "DISCONNECTING";
    STATES[STATES["WAITING"] = 3] = "WAITING";
    STATES[STATES["DISCONNECTED"] = 4] = "DISCONNECTED";
})(STATES || (STATES = {}));

我们可以如下使用 enum 类型:

if (this.state === STATES.CONNECTING) {
  console.log('The system is connecting');
}

理解对象类型

在这一部分,我们将看一下数组类型和函数类型,它们属于更通用的对象类型类。我们还将探讨如何定义类和接口。元组类型是由 TypeScript 1.3 引入的,它们的主要目的是允许语言开始对 ES2015 引入的新功能进行类型化,比如解构。我们不会在本书中描述它们。想要进一步阅读可以查看语言规范www.typescriptlang.org

数组类型

在 TypeScript 中,数组是具有共同元素类型的 JavaScript 数组。这意味着我们不能在给定数组中有不同类型的元素。我们为 TypeScript 中的所有内置类型以及我们定义的所有自定义类型都有不同的数组类型。

我们可以定义一个数字数组如下:

let primes: number[] = [];
primes.push(2);
primes.push(3);

如果我们想要一个看起来杂种的数组,类似于 JavaScript 中的数组,我们可以使用类型引用any

let randomItems: any[] = [];
randomItems.push(1);
randomItems.push("foo");
randomItems.push([]);
randomItems.push({});

这是可能的,因为我们推送到数组的所有值的类型都是any类型的子类型,我们声明的数组包含类型为any的值。

我们可以在 TypeScript 数组类型中使用我们熟悉的 JavaScript 数组方法:

let randomItems: any[] = [];
randomItems.push("foo");
randomItems.push("bar");
randomItems.join(''); // foobar
randomItems.splice(1, 0, "baz");
randomItems.join(''); // foobazbar

我们还有方括号运算符,它给我们提供对数组元素的随机访问:

let randomItems: any[] = [];
randomItems.push("foo");
randomItems.push("bar");
randomItems[0] === "foo"
randomItems[1] === "bar"

函数类型

函数类型是一组具有不同签名的所有函数,包括不同数量的参数、不同参数类型或不同返回结果类型。

我们已经熟悉如何在 JavaScript 中创建新函数。我们可以使用函数表达式或函数声明:

// function expression
var isPrime = function (n) {
  // body
};
// function declaration
function isPrime(n) {
  // body
};

或者,我们可以使用新的箭头函数语法:

var isPrime = n => {
  // body
};

TypeScript 唯一改变的是定义函数参数类型和返回结果类型的功能。语言编译器执行类型检查和转译后,所有类型注释都将被移除。如果我们使用函数表达式并将函数分配给变量,我们可以按照以下方式定义变量类型:

let variable: (arg1: type1, arg2: type2, …, argn: typen) => returnType

例如:

let isPrime: (n: number) => boolean = n => {
  // body
};

函数声明的情况下,我们将有:

function isPrime(n: number): boolean {
  // body
}

如果我们想在对象字面量中定义一个方法,我们可以按照以下方式处理它:

let math = {
  squareRoot(n: number): number {
    // …
  },
};

在前面的例子中,我们使用了 ES2015 语法定义了一个对象字面量,其中定义了方法squareRoot

如果我们想定义一个产生一些副作用而不是返回结果的函数,我们可以将其定义为void函数:

let person = {
  _name: null,
  setName(name: string): void {
    this._name = name;
  }
};

定义类

TypeScript 类与 ES2015 提供的类似。然而,它改变了类型声明并创建了更多的语法糖。例如,让我们把之前定义的Human类变成一个有效的 TypeScript 类:

class Human {
  static totalPeople = 0;
  _name: string;
  constructor(name) {
    this._name = name;
    Human.totalPeople += 1;
  }
  get name() {
    return this._name;
  }
  set name(val) {
    this._name = val;
  }
  talk() {
    return `Hi, I'm ${this.name}!`;
  }
}

当前的 TypeScript 定义与我们已经介绍的定义没有区别,然而,在这种情况下,_name属性的声明是必需的。以下是如何使用这个类的方法:

let human = new Human('foo');
console.log(human._name);

使用访问修饰符

类似于大多数支持类的传统面向对象语言,TypeScript 允许定义访问修饰符。为了拒绝在类外部直接访问_name属性,我们可以将其声明为私有:

class Human {
  static totalPeople = 0;
  private _name: string;
  // …
}

TypeScript 支持的访问修饰符有:

  • 公共:所有声明为公共的属性和方法可以在任何地方访问。

  • 私有:所有声明为私有的属性和方法只能从类的定义内部访问。

  • 受保护:所有声明为受保护的属性和方法可以从类的定义内部或扩展拥有该属性或方法的任何其他类的定义中访问。

访问修饰符是实现具有良好封装和明确定义接口的 Angular 2 服务的好方法。为了更好地理解它,让我们看一个使用之前定义的类层次结构的示例,该类层次结构已转换为 TypeScript:

class Human {
  static totalPeople = 0;
  constructor(protected name: string, private age: number) {
    Human.totalPeople += 1;
  }
  talk() {
    return `Hi, I'm ${this.name}!`;
  }
}

class Developer extends Human {
  constructor(name: string, private languages: string[], age: number) {
    super(name, age);
  }
  talk() {
    return `${super.talk()} And I know ${this.languages.join(', ')}.`;
  }
}

就像 ES2015 一样,TypeScript 支持extends关键字,并将其解析为原型 JavaScript 继承。

在前面的示例中,我们直接在构造函数内部设置了nameage属性的访问修饰符。这种语法背后的语义与前面示例中使用的语法不同。它的含义是:定义一个受保护的名为name的属性,类型为string,并将传递给构造函数调用的第一个值赋给它。私有的age属性也是一样的。这样可以避免我们在构造函数中显式设置值。如果我们看一下Developer类的构造函数,我们可以看到我们可以在这些语法之间使用混合。我们可以在构造函数的签名中明确定义属性,或者只定义构造函数接受给定类型的参数。

现在,让我们创建Developer类的一个新实例:

let dev = new Developer("foo", ["JavaScript", "Go"], 42);
dev.languages = ["Java"];

在编译过程中,TypeScript 将抛出一个错误,告诉我们属性 languages 是私有的,只能在类"Developer"内部访问。现在,让我们看看如果创建一个新的Human类并尝试从其定义外部访问其属性会发生什么:

let human = new Human("foo", 42);
human.age = 42;
human.name = "bar";

在这种情况下,我们将得到以下两个错误:

属性 age 是私有的,只能在类"Human"内部访问属性 name 是受保护的,只能在类"Human"及其子类内部访问

然而,如果我们尝试在Developer的定义内部访问_name属性,编译器不会抛出任何错误。

为了更好地了解 TypeScript 编译器将从类型注释的类产生什么,让我们看一下以下定义产生的 JavaScript:

class Human {
  constructor(private name: string) {}
}

生成的 ECMAScript 5 将是:

var Human = (function () {
    function Human(name) {
        this.name = name;
    }
    return Human;
})();

通过使用new运算符调用构造函数实例化的对象直接添加了定义的属性。这意味着一旦代码编译完成,我们就可以直接访问创建的对象的私有成员。为了总结一下,访问修饰符被添加到语言中,以帮助我们强制实现更好的封装,并在我们违反封装时获得编译时错误。

定义接口

编程语言中的子类型允许我们根据它们是通用对象的专门化版本这一观察来以相同的方式对待对象。这并不意味着它们必须是相同类的实例,或者它们的接口之间有完全的交集。这些对象可能只有一些共同的属性,但在特定上下文中仍然可以以相同的方式对待。在 JavaScript 中,我们通常使用鸭子类型。我们可以根据这些方法的存在假设,在函数中为所有传递的对象调用特定的方法。然而,我们都曾经历过 JavaScript 解释器抛出的undefined is not a function错误。

面向对象编程和 TypeScript 提供了一个解决方案。它们允许我们确保如果它们实现了声明它们拥有属性子集的接口,那么我们的对象具有类似的行为。

例如,我们可以定义我们的接口Accountable

interface Accountable {
  getIncome(): number;
}

现在,我们可以通过以下方式确保IndividualFirm都实现了这个接口:

class Firm implements Accountable {
  getIncome(): number {
    // …
  }
}
class Individual implements Accountable {
  getIncome(): number {
    // …
  }
}

如果我们实现了一个给定的接口,我们需要为其定义的所有方法提供实现,否则 TypeScript 编译器将抛出错误。我们实现的方法必须与接口定义中声明的方法具有相同的签名。

TypeScript 接口还支持属性。在Accountable接口中,我们可以包含一个名为accountNumber的字段,类型为字符串:

interface Accountable {
  accountNumber: string;
  getIncome(): number;
}

我们可以在我们的类中定义它作为一个字段或一个 getter。

接口继承

接口也可以相互扩展。例如,我们可以将我们的Individual类转换为一个具有社会安全号码的接口:

interface Accountable {
  accountNumber: string;
  getIncome(): number;
}
interface Individual extends Accountable {
  ssn: string;
}

由于接口支持多重继承,Individual也可以扩展具有nameage属性的Human接口:

interface Accountable {
  accountNumber: string;
  getIncome(): number;
}
interface Human {
  age: number;
  name: number;
}
interface Individual extends Accountable, Human {
  ssn: string;
}

实现多个接口

如果类的行为是在几个接口中定义的属性的并集,它可以实现它们所有:

class Person implements Human, Accountable {
  age: number;
  name: string;
  accountNumber: string;
  getIncome(): number {
    // ...
  }
}

在这种情况下,我们需要提供类实现的所有方法的实现,否则编译器将抛出编译时错误。

使用 TypeScript 装饰器进一步增强表达能力

在 ES2015 中,我们只能装饰类、属性、方法、getter 和 setter。TypeScript 通过允许我们装饰函数或方法参数来进一步扩展了这一点:

class Http {
  // …
}
class GitHubApi {
  constructor(@Inject(Http) http) {
    // …
  }
}

然而,参数装饰器不应该改变任何额外的行为。相反,它们用于生成元数据。这些装饰器最典型的用例是 Angular 2 的依赖注入机制。

使用类型参数编写通用代码

在使用静态类型的部分开头,我们提到了类型参数。为了更好地理解它们,让我们从一个例子开始。假设我们想要实现经典的数据结构BinarySearchTree。让我们使用一个类来定义它的接口,而不应用任何方法实现:

class Node {
  value: any;
  left: Node;
  right: Node;
}

class BinarySearchTree {
  private root: Node;
  insert(any: value): void { /* … */ }
  remove(any: value): void { /* … */ }
  exists(any: value): boolean { /* … */ }
  inorder(callback: {(value: any): void}): void { /* … */ }
}

在前面的片段中,我们定义了一个名为Node的类。这个类的实例代表了我们树中的个别节点。每个node都有一个左子节点和一个右子节点,以及一个any类型的值;我们使用any来能够在我们的节点和相应的BinarySearchTree中存储任意类型的数据。

尽管先前的实现看起来是合理的,但我们放弃了 TypeScript 提供的最重要的特性——静态类型。通过将Node类内的值字段的类型设置为any,我们无法充分利用编译时类型检查。这也限制了 IDE 和文本编辑器在访问Node类的实例的value属性时提供的功能。

TypeScript 提供了一个优雅的解决方案,这在静态类型世界中已经广泛流行——类型参数。使用泛型,我们可以使用类型参数对我们创建的类进行参数化。例如,我们可以将我们的Node类转换为以下形式:

class Node<T> {
  value: T;
  left: Node<T>;
  right: Node<T>;
}

Node<T>表示这个类有一个名为T的单一类型参数,在类的定义中的某个地方使用。我们可以通过以下方式使用Node

let numberNode = new Node<number>();
let stringNode = new Node<string>();
numberNode.right = new Node<number>();
numberNode.value = 42;
numberNode.value = "42"; // Type "string" is not assignable to type "number"
numberNode.left = stringNode; // Type Node<string> is not assignable to type Node<number>

在前面的片段中,我们创建了三个节点:numberNodestringNode和另一个类型为Node<number>的节点,将其值分配给numberNode的右子节点。请注意,由于numberNode的类型是Node<number>,我们可以将其值设置为42,但不能使用字符串"42"。对其左子节点也是适用的。在定义中,我们明确声明了希望左右子节点的类型为Node<number>。这意味着我们不能将类型为Node<string>的值分配给它们;这就是为什么我们会得到第二个编译时错误。

使用泛型函数

泛型的另一个典型用途是定义操作一组类型的函数。例如,我们可以定义一个接受类型为T的参数并返回它的identity函数:

function identity<T>(arg: T) {
  return arg;
}

然而,在某些情况下,我们可能只想使用具有特定属性的类型的实例。为了实现这一点,我们可以使用扩展语法,允许我们声明应该是类型参数的类型的子类型:

interface Comparable {
  compare(a: Comparable): number;
}
function sort<T extends Comparable>(arr: Comparable[]): Comparable[] {
  // …
}

例如,在这里,我们定义了一个名为Comparable的接口。它有一个名为compare的操作。实现接口Comparable的类需要实现操作compare。当使用给定参数调用compare时,如果目标对象大于传递的参数,则返回1,如果它们相等,则返回0,如果目标对象小于传递的参数,则返回-1

具有多个类型参数

TypeScript 允许我们使用多个类型参数:

class Pair<K, V> {
  key: K;
  value: V;
}

在这种情况下,我们可以使用以下语法创建Pair<K, V>类的实例:

let pair = new Pair<string, number>();
pair.key = "foo";
pair.value = 42;

使用 TypeScript 的类型推断编写更简洁的代码

静态类型具有许多好处;然而,它使我们编写更冗长的代码,需要添加所有必需的类型注释。

在某些情况下,TypeScript 的编译器能够猜测我们代码中表达式的类型,例如:

let answer = 42;
answer = "42"; // Type "string" is not assignable to type "number"

在上面的例子中,我们定义了一个变量answer,并将值42赋给它。由于 TypeScript 是静态类型的,变量的类型一旦声明就不能改变,编译器足够聪明,能够猜测answer的类型是number

如果我们在定义变量时不给变量赋值,编译器将把它的类型设置为any

let answer;
answer = 42;
answer = "42";

上面的代码片段将在没有编译时错误的情况下编译。

最佳通用类型

有时,类型推断可能是多个表达式的结果。当我们将异构数组分配给一个变量时就是这种情况:

let x = ["42", 42];

在这种情况下,x的类型将是any[]。然而,假设我们有以下情况:

let x = [42, null, 32];

x的类型将是number[],因为Number类型是Null的子类型。

上下文类型推断

当表达式的类型是从其位置暗示出来时,就发生了上下文类型推断,例如:

document.body.addEventListener("mousedown", e => {
  e.foo(); // Property "foo" does not exists on a type "MouseEvent"
}, false);

在这种情况下,回调函数e的参数类型是根据编译器根据其使用上下文“猜测”的。编译器根据addEventListener的调用和传递给该方法的参数理解e的类型。如果我们使用键盘事件(例如keydown),TypeScript 会意识到e的类型是KeyboardEvent

类型推断是一种机制,使我们能够通过利用 TypeScript 执行的静态分析来编写更简洁的代码。根据上下文,TypeScript 的编译器能够猜测给定表达式的类型,而无需显式定义。

使用环境类型定义

尽管静态类型很棒,但我们使用的大多数前端库都是用 JavaScript 构建的,它是动态类型的。因此,我们希望在 Angular 2 中使用 TypeScript,但在使用外部库的代码中没有编译时类型检查是一个大问题;这会阻止我们利用编译时的类型检查。

TypeScript 是根据这些要点构建的。为了让 TypeScript 编译器处理它最擅长的事情,我们可以使用所谓的环境类型定义。它们允许我们提供现有 JavaScript 库的外部类型定义。这样,它们为编译器提供了提示。

使用预定义的环境类型定义

幸运的是,我们不必为我们使用的所有 JavaScript 库和框架创建环境类型定义。这些库的社区和/或作者已经在网上发布了这样的定义;最大的存储库位于:github.com/DefinitelyTyped/DefinitelyTyped。还有一个用于管理它们的工具叫做typings。我们可以使用以下命令通过npm安装它:

**npm install –g typings**

类型定义的配置在一个名为typings.json的文件中定义,默认情况下,所有已安装的环境类型定义将位于./typings目录中。

为了创建带有基本配置的typings.json文件,请使用:

**typings init**

我们可以使用以下命令安装新的类型定义:

**typings install angularjs --ambient**

上述命令将下载 AngularJS 1.x 的类型定义,并将它们保存在typings目录下的browser/ambient/angular/angular.d.tsmain/ambient/angular/angular.d.ts中。

注意

拥有main/ambientbrowser/ambient目录是为了防止类型冲突。例如,如果我们在项目的backend/build和前端都使用 TypeScript,可能会引入类型定义的重复,这将导致编译时错误。通过为项目的各个部分的环境类型定义拥有两个目录,我们可以分别使用main.d.tsbrowser.d.ts来包含其中一个。有关类型定义的更多信息,您可以访问 GitHub 上项目的官方存储库github.com/typings/typings

为了下载类型定义并在typings.json中添加条目,您可以使用:

**typings install angular --ambient --save**

运行上述命令后,您的typings.json文件应该类似于:

{
  "dependencies": {},
  "devDependencies": {},
  "ambientDependencies": {
    "angular": "github:DefinitelyTyped/DefinitelyTyped/angularjs/angular.d.ts#1c4a34873c9e70cce86edd0e61c559e43dfa5f75"
  }
}

现在,为了在 TypeScript 中使用 AngularJS 1.x,创建app.ts并输入以下内容:

/// <reference path="./typings/browser.d.ts"/>

var module = angular.module("module", []);
module.controller("MainCtrl",
  function MainCtrl($scope: angular.IScope) {

  });

要编译app.ts,请使用:

**tsc app.ts**

TypeScript 编译将把编译后的内容输出到app.js中。为了添加额外的自动化并在项目中的任何文件更改时调用 TypeScript 编译器,您可以使用像 gulp 或 grunt 这样的任务运行器,或者将-w选项传递给tsc

注意

由于使用引用元素来包含类型定义被认为是不良实践,我们可以使用tsconfig.json文件代替。在那里,我们可以配置哪些目录需要在编译过程中被tsc包含。更多信息请访问github.com/Microsoft/TypeScript/wiki/tsconfig.json

自定义环境类型定义

为了理解一切是如何协同工作的,让我们来看一个例子。假设我们有一个 JavaScript 库的以下接口:

var DOM = {
  // Returns a set of elements which match the passed selector
  selectElements: function (selector) {
    // …
  },
  hide: function (element) {
    // …
  },
  show: function (element) {
    // …
  }
};

我们有一个分配给名为DOM的变量的对象文字。该对象具有以下方法:

  • selectElements:接受一个类型为字符串的单个参数并返回一组 DOM 元素。

  • hide:接受一个 DOM 节点作为参数并返回空。

  • show:接受一个DOM节点作为参数并返回空。

在 TypeScript 中,前面的定义将如下所示:

var DOM = {
  // Returns a set of elements which match the passed selector
  selectElements: function (selector: string): HTMLElement[] {
    return [];
  },
  hide: function (element: HTMLElement): void {
    element.hidden = true;
  },
  show: function (element: HTMLElement): void {
    element.hidden = false;
  }
};

这意味着我们可以如下定义我们的库接口:

interface LibraryInterface {
  selectElements(selector: string): HTMLElement[]
  hide(element: HTMLElement): void
  show(element: HTMLElement): void
}

定义 ts.d 文件

在我们有了库的接口之后,创建环境类型定义将变得很容易;我们只需要创建一个名为dom的扩展名为ts.d的文件,并输入以下内容:

// inside "dom.d.ts"

interface DOMLibraryInterface {
  selectElements(selector: string): HTMLElement[]
  hide(element: HTMLElement): void
  show(element: HTMLElement): void
}

declare var DOM: DOMLibraryInterface;

在前面的片段中,我们定义了名为DOMLibraryInterface的接口,并声明了类型为DOMLibraryInterface的变量DOM

在能够利用静态类型的 JavaScript 库之前,唯一剩下的事情就是在我们想要使用我们的库的脚本文件中包含外部类型定义。我们可以这样做:

/// <reference path="dom.d.ts"/>

前面的片段提示编译器在哪里找到环境类型定义。

摘要

在本章中,我们窥探了用于实现 Angular 2 的 TypeScript 语言。虽然我们可以使用 ECMAScript 5 来开发我们的 Angular 2 应用程序,但谷歌建议使用 TypeScript 以利用其提供的静态类型。

在探索语言的过程中,我们看了一些 ES2015 和 ES2016 的核心特性。我们解释了 ES2015 和 ES2016 的类、箭头函数、块作用域变量定义、解构和模块。由于 Angular 2 利用了 ES2016 的装饰器,更准确地说是它们在 TypeScript 中的扩展,我们专门介绍了它们。

之后,我们看了一下如何通过使用显式类型定义来利用静态类型。我们描述了 TypeScript 中一些内置类型以及如何通过为类的成员指定访问修饰符来定义类。接下来我们介绍了接口。我们通过解释类型参数和环境类型定义来结束了我们在 TypeScript 中的冒险。

在下一章中,我们将开始深入探索 Angular 2,使用框架的组件和指令。

第四章:开始使用 Angular 2 组件和指令

到目前为止,您已经熟悉了 Angular 2 为单页应用程序开发提供的核心构建块以及它们之间的关系。然而,我们只是介绍了 Angular 概念背后的一般思想和用于定义它们的基本语法。在本章中,我们将深入研究 Angular 2 的组件和指令。

在接下来的章节中,我们将涵盖以下主题:

  • 强制分离 Angular 2 为开发应用程序提供的构建块的关注点。

  • 与 DOM 交互时指令或组件的适当使用。

  • 内置指令和开发自定义指令。

  • 深入了解组件及其模板。

  • 内容投影。

  • 视图子代与内容子代。

  • 组件的生命周期。

  • 使用模板引用。

  • 配置 Angular 的变更检测。

Angular 2 中的 Hello world!应用程序

现在,让我们在 Angular 2 中构建我们的第一个“Hello world!”应用程序!为了尽可能轻松快速地启动和运行一切,对于我们的第一个应用程序,我们将使用 ECMAScript 5 语法与 Angular 2 的转译捆绑包。首先,创建带有以下内容的index.html文件:

<!-- ch4/es5/hello-world/index.html -->

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title></title>
</head>
<body>
  <script src="https://code.angularjs.org/2.0.0-beta.9/angular2-polyfills.min.js"></script>
  <script src="https://code.angularjs.org/2.0.0-beta.9/Rx.umd.min.js"></script>
  <script src="https://code.angularjs.org/2.0.0-beta.9/angular2-all.umd.min.js"></script>
  <script src="./app.js"></script>
</body>
</html>

上面的 HTML 文件定义了我们页面的基本结构。在关闭body标签之前,我们引用了四个脚本文件:框架所需的polyfills(包括 ES2015 shimzone.js等),RxJS,Angular 2 的 ES5 捆绑包,以及包含我们将要构建的应用程序的文件。

注意

RxJS被 Angular 的核心使用,以便让我们在应用程序中实现响应式编程范式。在接下来的内容中,我们将浅显地了解如何利用可观察对象。有关更多信息,您可以访问RxJS的 GitHub 存储库github.com/Reactive-Extensions/RxJS

在您的index.html所在的同一目录中,创建一个名为app.js的文件,并在其中输入以下内容:

// ch4/es5/hello-world/app.js

var App = ng.core.Component({
  selector: 'app',
  template: '<h1>Hello {{target}}!</h1>'
})
.Class({
  constructor: function () {
    this.target = 'world';
  }
});

ng.platform.browser.bootstrap(App);

在上面的代码片段中,我们定义了一个名为App的组件,带有一个app选择器。此选择器将匹配应用程序范围内模板中的所有应用程序元素。组件具有以下模板:

'<h1>Hello {{target}}!</h1>'

这种语法在 AngularJS 1.x 中应该已经很熟悉了。在给定组件的上下文中编译时,前面的片段将使用花括号内表达式的结果插值模板。在我们的例子中,表达式只是 target 变量。

对于 Class,我们传递了一个对象字面量,其中包含一个名为 constructor 的方法。这个 DSL 提供了在 ECMAScript 5 中定义类的另一种方式。在 constructor 函数的主体中,我们添加了一个名为 target 的属性,其值为字符串 "world"。在片段的最后一行,我们调用 bootstrap 方法来使用 App 作为根组件初始化我们的应用程序。

请注意,bootstrap 位于 ng.platform.browser 下。这是因为该框架是针对不同平台构建的,比如浏览器、NativeScript 等。通过将不同平台使用的 bootstrap 方法放在单独的命名空间下,Angular 2 可以实现不同的逻辑来初始化应用程序,并包含特定于平台的不同提供者和指令集。

现在,如果您用浏览器打开 index.html,您应该会看到一些错误,如下面的截图所示:

Angular 2 中的 Hello world! 应用程序

这是因为我们错过了一些非常重要的东西。我们没有在 index.html 中的任何地方使用根组件。为了完成应用程序,在 body 元素的开放标签之后添加以下 HTML 元素:

<app></app>

现在,您可以刷新浏览器以查看以下结果:

Angular 2 中的 Hello world! 应用程序

注意

使用 TypeScript

虽然我们已经运行了一个 Angular 2 应用程序,但我们可以做得更好!我们没有使用任何包管理器或模块加载器。在 第三章 中,TypeScript Crash Course,我们讨论了 TypeScript;然而,在前面的应用程序中我们没有写一行 TypeScript 代码。虽然不要求您在 Angular 2 中使用 TypeScript,但利用静态类型提供的所有奖励会更方便。

设置我们的环境

Angular 的核心团队为 Angular 2 开发了一个全新的 CLI 工具,允许我们通过几个命令来“引导”我们的应用程序。尽管我们将在最后一章介绍它,但为了加快我们的学习体验,我们将使用位于github.com/mgechev/switching-to-angular2的代码。它包括本书中的所有示例,并允许我们快速“引导”我们的 Angular 2 应用程序(您可以在第五章中了解如何快速开始使用 Angular 2 开发 Web 应用程序,Angular 2 中的依赖注入)。它在package.json中声明了所有必需的依赖项,定义了基本的 gulp 任务,如开发服务器、将您的 TypeScript 代码转译为 ECMAScript 5、实时重新加载等。我们即将介绍的示例将基于它。

为了设置switching-to-angular2项目,您需要在计算机上安装 Git、Node.js v5.x.x 和 npm。如果您安装了不同版本的 Node.js,我建议您查看 nvm(Node.js 版本管理器,可在www.npmjs.com/package/nvm上找到)或 n(www.npmjs.com/package/n)。使用这些工具,您可以在计算机上拥有多个 Node.js 版本,并通过命令行轻松切换它们。

安装我们的项目存储库

让我们从设置switching-to-angular2项目开始。打开您的终端并输入以下命令:

**# Will clone the repository and save it to directory called**
**# switching-to-angular2**
**git clone https://github.com/mgechev/switching-to-angular2.git**
**cd switching-to-angular2**
**npm install**

第一行将把switching-to-angular2项目克隆到一个名为switching-to-angular2的目录中。

在能够运行种子项目之前的最后一步是使用 npm 安装所有必需的依赖项。这一步可能需要一些时间,取决于您的互联网连接速度,所以请耐心等待,不要中断它。如果遇到任何问题,请毫不犹豫地在github.com/mgechev/switching-to-angular2/issues上提出问题。

最后一步是启动开发服务器:

**npm start** 

当转译过程完成时,您的浏览器将自动打开此 URL:http://localhost:5555/dist/dev。现在,您应该看到与以下截图中显示的类似的视图:

安装我们的项目存储库

玩转 Angular 2 和 TypeScript

现在,让我们玩弄一下我们已经拥有的文件!转到switching-to-angular2内的app/ch4/ts/hello-world目录。然后,打开app.ts并用以下片段替换其内容:

// ch4/ts/hello-world/app.ts

import {Component} from 'angular2/core';
import {bootstrap} from 'angular2/platform/browser';

@Component({
  selector: 'app',
  templateUrl: './app.html'
})
class App {
  target: string;
  constructor() {
    this.target = 'world';
	}
}

bootstrap(App);

让我们逐行查看代码:

import {Component} from 'angular2/core';
import {bootstrap} from 'angular2/platform/browser';

最初,我们从angular2/core模块中导入@Component装饰器和从angular2/platform/browser中导入bootstrap函数。稍后,我们使用@Component来装饰App类。对于@Component装饰器,我们传递了几乎与应用程序的 ECMAScript 5 版本中使用的相同的对象文字,通过这种方式,我们定义了组件的 CSS 选择器。

作为下一步,我们定义组件的视图。但是,请注意,在这种情况下,我们使用templateUrl而不是简单地内联组件的模板。

打开app.html,并用<h1>Hello {{target}}!</h1>替换文件的内容。app.html的内容应与我们先前使用的内联模板相同。由于我们可以通过内联(使用template)和设置其 URL(templateUrl)来使用模板,因此组件的 API 与 AngularJS 1.x 指令 API 非常相似。

在片段的最后一行,我们通过提供根组件来bootstrap应用程序。

深入了解索引

现在,让我们来看一下index.html,以便了解启动应用程序时发生了什么:

<!-- ch4/ts/hello-world/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <title><%= TITLE %></title>
  <meta name="description" content="">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <!-- inject:css -->
  <!-- endinject -->
</head>
<body>
  <app>Loading...</app>
  <!-- inject:js -->
  <!-- endinject -->
  <%= INIT %>
</body>
</html>

请注意,在页面的主体中,我们使用app元素,并在其中使用文本节点的内容"Loading…""Loading…"标签将在应用程序启动并渲染主组件之前可见。

注意

有模板占位符<%= INIT %>和<!-- inject:js…,它们注入了特定于各个演示的内容。它们不是 Angular 特定的,而是旨在防止由于它们之间的共享结构而在附有书籍的代码示例中重复代码。为了查看此特定 HTML 文件已被转换的方式,请打开/dist/dev/ch4/ts/hello-world/index.html

使用 Angular 2 指令

我们已经构建了简单的“Hello world!”应用程序。现在,让我们开始构建更接近真实应用程序的东西。在本节结束时,我们将拥有一个简单的应用程序,列出我们需要做的一些项目,并在页面的标题处向我们问候。

让我们从开发我们的app组件开始。我们需要对上一个示例进行两个修改,将target属性重命名为name,并在组件的控制器定义中添加一个todos列表:

// ch4/ts/ng-for/detailed-syntax/app.ts

import {Component} from 'angular2/core';
import {bootstrap} from 'angular2/platform/browser';

@Component({
  selector: 'app',
  templateUrl: './app.html',
})
class App {
  todos: string[];
  name: string;
  constructor() {
    this.name = 'John';
    this.todos = ['Buy milk', 'Save the world'];
  }
}
bootstrap(App);

唯一剩下的事情就是改变模板以消耗提供的数据。我们已经熟悉了 AngularJS 1.x 中的ng-repeat指令。它允许我们使用微语法循环列表项,稍后由 AngularJS 1.x 解释。然而,该指令没有足够的语义,因此很难构建执行静态代码分析并帮助我们改进开发体验的工具。由于ng-repeat指令非常有用,Angular 2 进一步改进了这个想法,以允许更复杂的工具通过引入更多的语义来进行更好的静态代码分析。这种支持将防止我们在编写代码时出现拼写错误,并允许我们拥有更流畅的开发体验。

app.html中,添加以下内容:

<!-- ch4/ts/ng-for/detailed-syntax/app.html -->

<h1>Hello {{name}}!</h1>
<p>
  Here's a list of the things you need to do:
</p>
<ul>
  <template ngFor var-todo [ngForOf]="todos">
    <li>{{todo}}</li>
  </template>
</ul>

注意

template元素是一个我们可以放置标记的地方,并确保它不会被浏览器渲染。如果我们需要直接嵌入应用程序模板到页面标记中,并让我们使用的模板引擎稍后处理它们,这是非常有用的。在当前的例子中,这意味着如果 Angular 2 DOM 编译器不处理 DOM 树,我们在屏幕上看到的只有h1p元素和ul元素,没有任何列表项。

现在,在刷新浏览器后,您应该看到以下结果:

使用 Angular 2 指令

到目前为止,一切都很好!在前面的片段中,唯一剩下的新事物是我们不熟悉的template元素的属性,如ngForvar-todo[ngForOf]。让我们来看看它们。

ngFor 指令

ngFor 指令是一个允许我们循环遍历项目集合的指令,它与 AngularJS 1.x 中的 ng-repeat 做的事情完全一样,但它带来了一些额外的语义。请注意,ngForOf 属性被括号括起来。起初,这些括号可能看起来像无效的 HTML。然而,根据 HTML 规范,它们在属性名称中是允许使用的。唯一会引起 W3C 验证器抱怨的是 template 元素不拥有这样的属性;然而,浏览器不会在处理标记时出现问题。

这些括号背后的语义是,它们括起来的属性的值是一个表达式,需要进行评估。

指令语法的改进语义

在第一章中,开始使用 Angular 2,我们提到了 Angular 2 中改进工具的机会。在 AngularJS 1.x 中存在的一个大问题是我们可以使用指令的不同方式。这需要理解属性值,它可以是文字,表达式,回调或微语法。Angular 2 通过引入一些内置到框架中的简单约定来消除这个问题:

  • propertyName="value"

  • [propertyName]="expression"

  • (eventName)="handler()"

在第一行中,propertyName 属性接受一个字符串文字作为值。Angular 不会进一步处理属性的值;它将使用模板中设置的方式。

第二种语法 [propertyName]="expression" 给 Angular 2 提供了一个提示,即属性的值应该被处理为表达式。当 Angular 2 发现一个被括号括起来的属性时,它将在与模板相关联的组件的上下文中解释表达式。简而言之,如果我们想要将非字符串值或表达式的结果作为给定属性的值设置,我们需要使用这种语法。

最后一个例子展示了我们如何绑定事件。 (eventName)="handler()" 背后的语义是,我们想要处理由给定组件触发的名为 eventName 的所有事件,并使用 handler() 表达式。

我们将在本章后面讨论更多例子。

注意

Angular 提供了另一种规范的替代语法,允许我们定义元素的绑定而不使用括号。例如,可以使用以下代码表示属性绑定:

<input [value]="foo">

也可以用这种方式表达:

<input bind-value="foo">

同样,我们可以用以下代码表达事件绑定:

<button (click)="handle()">Click me</button>

它们也可以用这种方式表达:

<button on-click="handle()">Click me</button>

在模板中声明变量

从前面的模板中剩下的最后一件事是var-todo属性。使用这种语法告诉 Angular 的是,我们想要声明一个名为todo的新变量,并将其绑定到从评估设置为[ngForOf]值的表达式的个别项目。

在模板中使用语法糖

尽管模板语法很棒,并且为我们使用的 IDE 或文本编辑器提供了更多的代码含义,但它相当冗长。Angular 2 提供了一种替代语法,它将被解糖为前面显示的语法。例如,我们可以使用#todo来代替var-todo,它具有相同的语义。

有一些 Angular 2 指令需要使用模板元素,例如ngForOfngIfngSwitch。由于这些指令经常被使用,因此有一种替代语法。我们可以简单地在指令前加上*,而不是明确地输入整个模板元素。这将允许我们将ngForOf指令语法的使用转换为以下形式:

<!-- ch4/ts/ng-for/syntax-sugar/app.html -->

<ul>
  <li *ngFor="#todo of todos">{{todo}}</li>
</ul>

稍后,此模板将被 Angular 2 解糖为之前描述的更冗长的语法。由于较少冗长的语法更容易阅读和编写,因此其使用被视为最佳实践。

注意

*字符允许您删除template元素,并直接将指令放在template元素的根上(在前面的示例中,列表项li)。

定义 Angular 2 指令

现在我们已经构建了一个简单的 Angular 2 组件,让我们继续通过理解 Angular 2 指令来继续我们的旅程。

使用 Angular 2 指令,我们可以在 DOM 上应用不同的行为或结构变化。在这个例子中,我们将构建一个简单的工具提示指令。

与组件相比,指令没有视图和模板。这两个概念之间的另一个核心区别是,给定的 HTML 元素可能只有一个组件,但可以有多个指令。换句话说,指令增强了元素,而组件是视图中的实际元素。

Angular 核心团队的建议是将指令作为带有命名空间前缀的属性使用。记住这一点,我们将以以下方式使用工具提示指令:

<div saTooltip="Hello world!"></div>

在上面的片段中,我们在div元素上使用了 tooltip 指令。作为命名空间,它的选择器使用了sa字符串。

注意

为简单起见,在本书的其余部分中,我们可能不会给我们的组件和指令的所有选择器加前缀。然而,对于生产应用程序来说,遵循最佳实践是必不可少的。您可以在github.com/mgechev/angular2-style-guide找到一个指出这些实践的 Angular 2 风格指南。

在实现我们的 tooltip 之前,我们需要从angular2/core中导入一些东西。打开一个名为app.ts的新的 TypeScript 文件,并输入以下内容;稍后我们将填写占位符:

import {Directive, ElementRef, HostListener...} from 'angular2/core';

在上面的行中,我们导入了以下定义:

  • ElementRef:这允许我们将元素引用(我们不仅限于 DOM)注入到宿主元素中。在上面 tooltip 的示例用法中,我们得到了一个div元素的 Angular 包装器,其中包含了 tooltip 属性。

  • Directive:这个装饰器允许我们为我们定义的新指令添加所需的元数据。

  • HostListener(eventname):这是一个方法装饰器,接受一个事件名称作为参数。在指令初始化期间,Angular 2 将把装饰的方法添加为宿主元素的eventname事件的事件处理程序。

让我们来看看我们的实现;这是指令的定义看起来像什么:

// ch4/ts/tooltip/app.ts

@Directive({
  selector: '[saTooltip]'
})
export class Tooltip {
  @Input()
  saTooltip: string;

  constructor(private el: ElementRef, private overlay: Overlay) {
    this.overlay.attach(el.nativeElement);
  }
  @HostListener('mouseenter')
  onMouseEnter() {
    this.overlay.open(this.el, this.saTooltip);
  }
  @HostListener('mouseleave')
  onMouseLeave() {
    this.overlay.close();
  }
}

设置指令的输入

在上面的例子中,我们使用了saTooltip选择器声明了一个指令。请注意,Angular 的 HTML 编译器是区分大小写的,这意味着它将区分[satooltip][saTooltip]选择器。稍后,我们将使用@Input装饰器声明指令的输入,放在saTooltip属性上。这段代码背后的语义是:声明一个名为saTooltip的属性,并将其绑定到我们从传递给saTooltip属性的表达式的评估结果的值。

@Input装饰器接受一个参数——我们想要绑定的属性的名称。如果我们不传递参数,Angular 将创建一个属性名称与属性本身相同的属性之间的绑定。我们将在本章后面详细解释输入和输出的概念。

理解指令的构造函数

构造函数声明了两个私有属性:elElementRef类型的,overlayOverlay类型的。Overlay类实现了管理工具提示覆盖层的逻辑,并将使用 Angular 的 DI 机制进行注入。为了声明它可以用于注入,我们需要以以下方式声明顶层组件:

@Component({
  selector: 'app',
  templateUrl: './app.html',
  providers: [Overlay],
  // ...
})
class App {}

注意

在下一章中,我们将看一下 Angular 2 的依赖注入机制,我们将解释如何声明我们的服务、指令和组件的依赖关系。

Overlay类的实现对本章的目的并不重要。然而,如果你对此感兴趣,你可以在ch4/ts/tooltip/app.ts中找到实现。

指令更好的封装

为了使工具提示指令可用于 Angular 的编译器,我们需要明确声明我们打算在哪里使用它。例如,看一下ch4/ts/tooltip/app.ts中的App类;在那里,你可以注意到以下内容:

@Component({
  selector: 'app',
  templateUrl: './app.html',
  providers: [Overlay],
  directives: [Tooltip]
})
class App {}

对于@Component装饰器,我们传递了一个具有directives属性的对象字面量。该属性包含了整个组件子树中应该可用的所有指令的列表,根据给定组件的根。

起初,你可能会觉得很烦人,因为你需要明确声明你的组件使用的所有指令;然而,这强化了更好的封装。在 AngularJS 1.x 中,所有指令都在全局命名空间中。这意味着应用程序中定义的所有指令都可以在所有模板中访问。这带来了一些问题,例如名称冲突。为了解决这个问题,我们引入了命名约定,例如,AngularJS 1.x 定义的所有指令都带有"ng-"前缀,Angular UI 中的所有指令都带有"ui-"前缀。

通过显式声明组件在 Angular 2 中使用的所有指令,我们创建了一个特定于各个组件子树的命名空间(即,指令将对给定根组件及其所有后继组件可见)。防止名称冲突不是我们得到的唯一好处;它还有助于我们更好地语义化我们生成的代码,因为我们始终知道给定组件可访问的指令。我们可以通过从组件到组件树顶部的路径,并取@Component装饰器中设置的directives数组的所有值的并集来找到给定组件的所有可访问指令。鉴于组件是从指令扩展而来,我们还需要显式声明所有使用的组件。

由于 Angular 2 定义了一组内置指令,bootstrap方法以类似的方式传递它们,以使它们在整个应用程序中可用,以防止我们重复编码。这些预定义指令的列表包括NgClassNgForNgIfNgStyleNgSwitchNgSwitchWhenNgSwitchDefault。它们的名称相当自明;我们将在本章后面看看如何使用其中一些。

使用 Angular 2 的内置指令

现在,让我们构建一个简单的待办事项应用程序,以便进一步演示定义组件的语法!

我们的待办事项将具有以下格式:

interface Todo {
  completed: boolean;
  label: string;
}

让我们从导入我们将需要的一切开始:

import {Component, ViewEncapsulation} from 'angular2/core';
import {bootstrap} from 'angular2/platform/browser';

现在,让我们声明与组件相关的元数据:

@Component({
  selector: 'todo-app',
  templateUrl: './app.html',
  styles: [
    `ul li {
      list-style: none;
    }
    .completed {
      text-decoration: line-through;
    }`
  ],
  encapsulation: ViewEncapsulation.Emulated
})

在这里,我们指定Todo组件的选择器将是todo-app元素。稍后,我们添加模板 URL,指向app.html文件。之后,我们使用styles属性;这是我们第一次遇到它。从名称可以猜到,它用于设置组件的样式。

介绍组件的视图封装

正如我们所知,Angular 2 受到 Web 组件的启发,其核心功能是影子 DOM。影子 DOM 允许我们封装我们的 Web 组件的样式,而不允许它们泄漏到组件范围之外。Angular 2 提供了这个功能。如果我们希望 Angular 的渲染器使用影子 DOM,我们可以使用ViewEncapsulation.Native。然而,并非所有浏览器都支持影子 DOM;如果我们希望在不使用影子 DOM 的情况下具有相同级别的封装,我们可以使用ViewEncapsulation.Emulated。如果我们根本不想有任何封装,我们可以使用ViewEncapsulation.None。默认情况下,渲染器使用Emulated类型的封装。

实现组件的控制器

现在,让我们继续实现应用程序:

// ch4/ts/todo-app/app.ts
class TodoCtrl {
  todos: Todo[] = [{
    label: 'Buy milk',
    completed: false
  }, {
    label: 'Save the world',
    completed: false
  }];
  name: string = 'John';
  addTodo(label) { … }
  removeTodo(idx) { … }
  toggleCompletion(idx) { … }
}

这是与Todo应用程序模板相关的控制器实现的一部分。

在类声明内部,我们将todos属性初始化为一个包含两个todo项目的数组:

{
  label: 'Buy milk',
  completed: false
}, {
  label: 'Save the world',
  completed: false
}

现在,让我们更新模板并渲染这些项目!这是如何完成的:

<ul>
  <li *ngFor="#todo of todos; var index = index" [class.completed]="todo.completed">
    <input type="checkbox" [checked]="todo.completed"
      (change)="toggleCompletion(index)">
    {{todo.label}}
  </li>
</ul>

在前面的模板中,我们循环遍历了控制器的todos属性中的所有todo项目。对于每个todo项目,我们创建了一个复选框,可以切换项目的完成状态;我们还使用插值指令呈现了todo项目的标签。在这里,我们可以注意到之前解释过的语法:

  • 我们使用(change)="statement"绑定到复选框的 change 事件。

  • 我们使用[checked]="expr"绑定到todo项目的属性。

为了在已完成的todo项目上画一条线,我们绑定到元素的class.completed属性。由于我们想要将completed类应用于所有已完成的待办事项,我们使用[class.completed]="todo.completed"。这样,我们声明了我们想要根据todo.completed表达式的值应用completed类。现在我们的应用程序是这样的:

实现组件的控制器

注意

与类绑定语法类似,Angular 允许我们绑定元素的样式和属性。例如,我们可以使用以下代码绑定到td元素的colspan属性:

<td [attr.colspan]="colspanCount"></td>

同样,我们可以使用这行代码绑定到任何style属性:

<div [style.backgroundImage]="expression"></td>

处理用户操作

到目前为止,一切顺利!现在,让我们实现toggleCompletion方法。这个方法接受待办事项的索引作为单个参数:

  toggleCompletion(idx) {
    let todo = this.todos[idx];
    todo.completed = !todo.completed;
  }

toggleCompletion中,我们只是切换与当前待办事项相关联的completed布尔值,该值由传递给该方法的索引指定。

现在,让我们添加一个文本输入来添加新的待办事项:

<p>
  Add a new todo:
  <input #newtodo type="text">
  <button (click)="addTodo(newtodo.value); newtodo.value = ''">
    Add
  </button>
</p>

此处的输入定义了一个名为newtodo的新标识符。我们可以在模板中使用newtodo标识符引用输入。一旦用户点击按钮,控制器中定义的addTodo方法将以newtodo输入的值作为参数被调用。在传递给(click)属性的语句中,我们还通过将其设置为空字符串来重置newtodo输入的值。

注意

请注意,直接操作 DOM 元素不被视为最佳实践,因为它会阻止我们的组件在浏览器环境之外正常运行。我们将解释如何将此应用程序迁移到 Web Workers 中,详见第八章, 开发体验和服务器端渲染

现在,让我们定义addTodo方法:

  addTodo(label) {
    this.todos.push({
      label,
      completed: false
    });
  }

在其中,我们使用对象字面量语法创建一个新的待办事项。

我们应用程序中唯一剩下的事情是实现删除现有待办事项。由于它与用于切换待办事项完成情况的功能非常相似,我将把它的实现作为读者的简单练习留下。

使用指令的输入和输出

通过重构我们的todo应用程序,我们将演示如何利用指令的输入和输出:

使用指令的输入和输出

我们可以将输入视为给定指令接受的属性(甚至参数)。输出可以被视为它触发的事件。当我们使用第三方库提供的指令时,我们主要关心的是它的输入和输出,因为它们定义了它的 API。

输入是指参数化指令行为和/或视图的值。另一方面,输出是指指令在发生特殊事件时触发的事件。

查找指令的输入和输出

现在,让我们将我们的单体待办事项应用程序分成单独的组件,它们彼此通信。在下面的屏幕截图中,您可以看到单独的组件,当组合在一起时实现应用程序的功能:

查找指令的输入和输出

外部矩形代表整个Todo应用程序。第一个嵌套的矩形包含负责输入新待办事项标签的组件,下面的矩形列出了存储在根组件中的各个项目。

说到这一点,我们可以将这三个组件定义如下:

  • TodoApp:负责维护待办事项列表(添加新项目和切换完成状态)。

  • InputBox:负责输入新待办事项的标签。它具有以下输入和输出:

  • Input:文本框的占位符和提交按钮的标签。

  • Output:它应该在单击提交按钮时发出输入的内容。

  • TodoList:负责呈现各个待办事项。它具有以下输入和输出:

  • Input:待办事项列表。

  • Output:一旦任何待办事项的完成状态发生变化,该组件应该发出变化。

现在,让我们开始实施!

定义组件的输入和输出

让我们采用自下而上的方法,从InputBox组件开始。在此之前,我们需要从 Angular 的angular2/core包中导入一些内容:

import {
  Component,
  Input,
  Output,
  EventEmitter
} from 'angular2/core';

在前面的代码中,我们导入了@Component@Input@Output装饰器以及EventEmitter类。正如它们的名称所述,@Input@Output用于声明指令的输入和输出。EventEmitter是一个通用类(即接受类型参数),它与@Output装饰器结合使用,帮助我们发出输出。

作为下一步,让我们来看一下InputBox组件的声明:

// ch4/ts/inputs-outputs/app.ts

@Component({
  selector: 'text-input',
  template: `
    <input #todoInput [placeholder]="inputPlaceholder">
    <button (click)="emitText(todoInput.value);
                     todoInput.value = '';">
      {{buttonLabel}}
    </button>
  `
})
class InputBox {...}

请注意,在模板中,我们声明了一个名为todoInput的文本输入,并将其占位符属性设置为我们从inputPlaceholder表达式的评估中获得的值。表达式的值是组件控制器中定义的inputPlaceholder属性的值。这是我们需要定义的第一个输入:

class InputBox {
  @Input() inputPlaceholder: string;
  ...
}

同样,我们声明了buttonLabel组件的另一个输入,我们将其用作按钮标签的值:

class InputBox {
  @Input() inputPlaceholder: string;
  @Input() buttonLabel: string;
  ...
}

在前面的模板中,我们将按钮的点击事件绑定到这个表达式:emitText(todoInput.value); todoInput.value = '';emitText方法应该在组件的控制器中定义;一旦调用它,它应该发出文本输入的值。以下是我们可以实现这种行为的方法:

class InputBox {
  ...
  @Output() inputText = new EventEmitter<string>();
  emitText(text: string) {
    this.inputText.emit(text);
  }
}

最初,我们声明了一个名为inputText的输出。我们将其值设置为我们创建的EventEmitter<string>类型的新实例。

注意

请注意,所有组件的输出都需要是EventEmitter的实例。

emitText方法内部,我们使用inputText实例的 emit 方法,并传入文本输入的值作为参数。

现在,让我们以相同的方式定义TodoList组件:

@Component(...)
class TodoList {
  @Input() todos: Todo[];
  @Output() toggle = new EventEmitter<Todo>();
  toggleCompletion(index: number) {
    let todo = this.todos[index];
    this.toggle.emit(todo);
  }
}

由于传递给@Component装饰器的对象文字的值对于本节的目的并不重要,我们已经省略了它。这个例子的完整实现可以在ch4/ts/inputs-outputs/app.ts中找到。让我们来看一下TodoList类的主体。同样,对于InputBox组件,我们定义了todos输入。我们还通过声明toggle属性,将其值设置为EventEmitter<Todo>类型的新实例,并用@Output装饰器装饰它,定义了toggle输出。

传递输入和消耗输出

现在,让我们结合前面定义的组件并实现我们的完整应用程序!

我们需要查看的最后一个组件是TodoApp

@Component({
  selector: 'todo-app',
  directives: [TodoList, InputBox],
  template: `
    <h1>Hello {{name}}!</h1>

    <p>
      Add a new todo:
      <input-box inputPlaceholder="New todo..."
        buttonLabel="Add"
        (inputText)="addTodo($event)">
      </input-box>
    </p>

    <p>Here's the list of pending todo items:</p>
    <todo-list [todos]="todos" (toggle)="toggleCompletion($event)"></todo-list>
  `
})
class TodoApp {...}

首先,我们定义了TodoApp类,并用@Component装饰器装饰它。请注意,在组件使用的指令列表中,我们包括了InputBoxTodoList。这些组件如何协同工作的魔法发生在模板中:

<input-box inputPlaceholder="New todo..."
  buttonLabel="Add"
  (inputText)="addTodo($event)">
</input-box>

首先,我们使用InputBox组件并向输入传递值:inputPlaceholderbuttonLabel。请注意,就像我们之前看到的那样,如果我们想将表达式作为值传递给任何这些输入中的一个,我们需要用括号括起来(即[inputPlaceholder]="expression")。在这种情况下,表达式将在拥有模板的组件的上下文中进行评估,并作为输入传递给拥有给定属性的组件。

在为buttonLabel输入传递值后,我们通过将(inputText)属性的值设置为addTodo($event)表达式来消耗inputText输出。$event的值将等于我们传递给InputBoxinputText对象的emitText方法中的emit方法的值(如果我们绑定到原生事件,事件对象的值将是原生事件对象本身)。

同样,我们传递TodoList组件的输入并处理其切换输出。现在,让我们定义TodoApp组件的逻辑:

class TodoApp {
  todos: Todo[] = [];
  name: string = 'John';
  addTodo(label: string) {
    this.todos.push({
      label,
      completed: false
    });
  }
  toggleCompletion(todo: Todo) {
    todo.completed = !todo.completed;
  }
}

addTodo方法中,我们只是将一个新的待办事项推送到todos数组中。toggleCompletion的实现甚至更简单——我们切换作为参数传递给待办事项的完成标志的值。现在,我们熟悉了组件输入和输出的基础知识!

事件冒泡

在 Angular 中,我们有与 DOM 中相同的冒泡行为。例如,如果我们有以下模板:

<input-box inputPlaceholder="New todo..."
  buttonLabel="Add"
  (click)="handleClick($event)"
  (inputText)="addTodo($event)">
</input-box>

input-box的声明如下:

<input #todoInput [placeholder]="inputPlaceholder">
<button (click)="emitText(todoInput.value);
                 todoInput.value = '';">
  {{buttonLabel}}
</button>

用户一旦点击了模板中定义的input-box组件内的按钮,handleClick($event)表达式就会被评估。

此外,handleClick的第一个参数的target属性将是按钮本身,但currentTarget属性将是input-box元素。

注意

请注意,与原生事件不同,由EventEmitter触发的事件不会冒泡。

重命名指令的输入和输出

现在,我们将探讨如何重命名指令的输入和输出!假设我们有以下TodoList组件的定义:

class TodoList {
  ...
  @Output() toggle = new EventEmitter<Todo>();
  toggle(index: number) {
    ...
  }
}

组件的输出被称为toggle;负责切换个人待办事项完成状态的复选框的方法也被称为toggle。这段代码不会被编译,因为在TodoList控制器中,我们有两个同名的标识符。我们有两个选择:我们可以重命名方法或属性。如果我们重命名属性,这也会改变组件输出的名称。因此,以下代码将不再起作用:

<todo-list [toggle]="foobar($event)"...></todo-list>

我们可以做的是重命名toggle属性,并使用@Output装饰器显式设置输出的名称:

class TodoList {
  ...
  @Output('toggle') toggleEvent = new EventEmitter<Todo>();
  toggle(index: number) {
    ...
  }
}

这样,我们将能够使用toggleEvent属性触发toggle输出。

注意

请注意,这样的重命名可能会令人困惑,并且不被视为最佳实践。有关最佳实践的完整集合,请访问github.com/mgechev/angular2-style-guide

同样,我们可以使用以下代码片段来重命名组件的输入:

class TodoList {
  @Input('todos') todoList: Todo[];
  @Output('toggle') toggleEvent = new EventEmitter<Todo>();
  toggle(index: number) {
    ...
  }
}

现在,无论我们如何重命名TodoList的输入和输出属性,它仍然具有相同的公共接口:

<todo-list [todos]="todos"
  (toggle)="toggleCompletion($event)">
</todo-list>

定义输入和输出的另一种语法

@Input@Output装饰器是语法糖,用于更容易地声明指令的输入和输出。用于此目的的原始语法如下:

@Directive({
  outputs: ['outputName: outputAlias'],
  inputs: ['inputName: inputAlias']
})
class Dir {
  outputName = new EventEmitter();
}

使用@Input@Output,前面的语法等同于这样:

@Directive(...)
class Dir {
  @Output('outputAlias') outputName = new EventEmitter();
  @Input('inputAlias') inputName;
}

尽管两者语义相同,但根据最佳实践,我们应该使用后者,因为它更容易阅读和理解。

解释 Angular 2 的内容投影

内容投影是开发用户界面时的一个重要概念。它允许我们将内容的片段投影到应用程序的用户界面的不同位置。Web 组件使用content元素解决了这个问题。在 AngularJS 1.x 中,它是通过臭名昭著的转置来实现的。

Angular 2 受到现代 Web 标准的启发,特别是 Web 组件,这导致了采用了一些在那里使用的内容投影方法。在本节中,我们将在 Angular 2 的上下文中使用ng-content指令来查看它们。

Angular 2 中的基本内容投影

假设我们正在构建一个名为fancy-button的组件。该组件将使用标准的 HTML 按钮元素,并为其添加一些额外的行为。以下是fancy-button组件的定义:

@Component({
  selector: 'fancy-button',
  template: '<button>Click me</button>'
})
class FancyButton { … }

@Component装饰器内部,我们设置了组件的内联模板以及其选择器。现在,我们可以使用以下标记使用组件:

<fancy-button></fancy-button>

在屏幕上,我们将看到一个标准的 HTML 按钮,其标签中包含内容Click me。这不是一种定义可重用 UI 组件的非常灵活的方式。很可能,漂亮按钮的用户将需要根据他们的应用程序更改标签的内容。

在 AngularJS 1.x 中,我们可以使用ng-transclude来实现这个结果:

// AngularJS 1.x example
app.directive('fancyButton', function () {
  return {
    restrict: 'E',
    transclude: true,
    template: '<button><ng-transclude></ng-transclude></button>'
  };
});

在 Angular 2 中,我们有ng-content元素:

// ch4/ts/ng-content/app.ts
@Component({
  selector: 'fancy-button',
  template: '<button><ng-content></ng-content></button>'
})
class FancyButton { /* Extra behavior */ }

现在,我们可以通过执行以下操作将自定义内容传递给漂亮按钮:

<fancy-button>Click <i>me</i> now!</fancy-button>

因此,在fancy-button标签的开头和结尾之间的内容将放置在ng-content指令所在的位置。

投影多个内容块

内容投影的另一个典型用例是,当我们将内容传递给自定义的 Angular 2 组件或 AngularJS 1.x 指令时,我们希望将此内容的不同部分投影到模板中的不同位置。

例如,假设我们有一个panel组件,它有一个标题和一个正文:

<panel>
  <panel-title>Sample title</panel-title>
  <panel-content>Content</panel-content>
</panel>

我们的组件模板如下:

<div class="panel">
  <div class="panel-title">
 **<!-- Project the content of panel-title here -->**
  </div>
  <div class="panel-content">
 **<!-- Project the content of panel-content here -->**
  </div>
</div>`

在 AngularJS 1.5 中,我们可以使用多槽传输来实现这一点,这是为了让我们能够更顺利地过渡到 Angular 2 而实施的。让我们看看我们如何可以在 Angular 2 中进行,以定义这样一个panel组件:

// ch4/ts/ng-content/app.ts
@Component({
  selector: 'panel',
  styles: [ … ],
  template: `
    <div class="panel">
      <div class="panel-title">
        <ng-content select="panel-title"></ng-content>
      </div>
      <div class="panel-content">
        <ng-content select="panel-content"></ng-content>
      </div>
    </div>`
})
class Panel { }

我们已经描述了selectorstyles属性,现在让我们来看一下组件的模板。我们有一个带有panel类的div元素,它包裹了两个嵌套的div元素,分别用于panel的标题和内容。为了从panel-title元素中获取内容,并将其投影到渲染面板中panel标题应该在的位置,我们需要使用带有selector属性的ng-content元素,该属性具有panel-title值。selector属性的值是一个 CSS 选择器,在这种情况下,它将匹配位于目标panel元素内的所有panel-title元素。之后,ng-content将获取它们的内容并将其设置为自己的内容。

嵌套组件

我们已经构建了一些简单的应用程序,作为组件和指令的组合。我们看到组件基本上是带有视图的指令,因此我们可以通过嵌套/组合其他指令和组件来实现它们。以下图示说明了这一点:

嵌套组件

组合可以通过在组件模板中嵌套指令和组件来实现,利用所使用标记的嵌套特性。例如,假设我们有一个带有sample-component选择器的组件,其定义如下:

@Component({
  selector: 'sample-component',
  template: '<view-child></view-child>'
})
class Sample {}

sample-component选择器的模板有一个带有标签名view-child的子元素。

另一方面,我们可以在另一个组件的模板中使用sample-component选择器,由于它可以作为一个元素使用,我们可以在其中嵌套其他组件或指令:

<sample-component>
  <content-child1></content-child1>
  <content-child2></content-child2>
</sample-component>

这样,sample-component组件有两种不同类型的后继:

  • 在它的模板中定义的后继。

  • 作为嵌套元素传递的后继。

在 Angular 2 的上下文中,定义在组件模板中的直接子元素称为视图子组件,而在其开放和关闭标签之间嵌套的子元素称为内容子组件

使用 ViewChildren 和 ContentChildren

让我们来看一下使用以下结构的Tabs组件的实现:

    <tabs (changed)="tabChanged($event)">
      <tab-title>Tab 1</tab-title>
      <tab-content>Content 1</tab-content>
      <tab-title>Tab 2</tab-title>
      <tab-content>Content 2</tab-content>
    </tabs>

前面的结构由三个组件组成:

  • Tab组件。

  • TabTitle组件。

  • TabContent组件。

让我们来看一下TabTitle组件的实现:

@Component({
  selector: 'tab-title',
  styles: […],
  template: `
    <div class="tab-title" (click)="handleClick()">
      <ng-content></ng-content>
    </div>
  `
})
class TabTitle {
  tabSelected: EventEmitter<TabTitle> =
    new EventEmitter<TabTitle>();
  handleClick() {
    this.tabSelected.emit(this);
  }
}

在这个实现中没有什么新的。我们定义了一个TabTitle组件,它有一个叫做tabSelected的属性。它是EventEmitter类型的,一旦用户点击标签标题,它就会被触发。

现在,让我们来看一下TabContent组件:

@Component({
  selector: 'tab-content',
  styles: […],
  template: `
    <div class="tab-content" [hidden]="!isActive">
      <ng-content></ng-content>
    </div>
  `
})
class TabContent {
  isActive: boolean = false;
}

这个实现甚至更简单——我们所做的就是将传递给tab-content元素的 DOM 投影到ng-content中,并在isActive属性的值变为false时隐藏它。

实现中有趣的部分是Tabs组件本身:

// ch4/ts/basic-tab-content-children/app.ts
@Component({
  selector: 'tabs',
  styles: […],
  template: `
    <div class="tab">
      <div class="tab-nav">
        <ng-content select="tab-title"></ng-content>
      </div>
      <ng-content select="tab-content"></ng-content>
    </div>
  `
})
class Tabs {
  @Output('changed')
  tabChanged: EventEmitter<number> = new EventEmitter<number>();

  @ContentChildren(TabTitle)
  tabTitles: QueryList<TabTitle>;

  @ContentChildren(TabContent)
  tabContents: QueryList<TabContent>;

  active: number;
  select(index: number) {…}
  ngAfterViewInit() {…}
}

在这个实现中,我们有一个尚未使用的装饰器——@ContentChildren装饰器。@ContentChildren属性装饰器获取给定组件的内容子组件。这意味着我们可以从Tabs组件的实例中获取对所有TabTitleTabContent实例的引用,并按照它们在标记中声明的顺序获取它们。还有一个叫做@ViewChildren的替代装饰器,它获取给定元素的所有视图子组件。在我们进一步解释实现之前,让我们看看它们之间的区别。

ViewChild 与 ContentChild

虽然这两个概念听起来相似,但它们的语义有很大的不同。为了更好地理解它们,让我们来看一个例子:

// ch4/ts/view-child-content-child/app.ts
@Component({
  selector: 'user-badge',
  template: '…'
})
class UserBadge {}

@Component({
  selector: 'user-rating',
  template: '…'
})
class UserRating {}

在这里,我们定义了两个组件:UserBadgeUserRating。让我们定义一个包含这两个组件的父组件:

@Component({
  selector: 'user-panel',
  template: '<user-badge></user-badge>',
  directives: [UserBadge]
})
class UserPanel {…}

请注意,UserPanel视图的模板仅包含UserBadge组件的选择器。现在,让我们在我们的应用程序中使用UserPanel组件:

@Component({
  selector: 'app',
  template: `<user-panel>
    <user-rating></user-rating>
  </user-panel>`,
  directives: [CORE_DIRECTIVES, UserPanel, UserRating]
})
class App {
  constructor() {}
}

我们主要的App组件的模板使用UserPanel组件,并嵌套了UserRating组件。现在,假设我们想要获取对App组件中user-panel元素内使用的UserRating组件实例的引用,以及对UserPanel模板内使用的UserBadge组件的引用。为了做到这一点,我们可以向UserPanel控制器添加两个属性,并为它们添加@ContentChild@ViewChild装饰器,并使用适当的参数:

class UserPanel {
  @ViewChild(UserBadge)
  badge: UserBadge;

  @ContentChild(UserRating)
  rating: UserRating;
  constructor() {
    //
  }
}

badge属性声明的语义是:“获取在UserPanel模板内使用的类型为UserBadge的第一个子组件的实例”。相应地,rating属性声明的语义是:“获取在UserPanel宿主元素内嵌套的类型为UserRating的第一个子组件的实例”。

现在,如果您运行此代码,您会注意到在控制器的构造函数内,badgerating的值仍然等于undefined。这是因为它们在组件生命周期的这个阶段仍然没有初始化。我们可以使用ngAfterViewInitngAfterContentInit生命周期钩子来获取对这些子组件的引用。我们可以通过向组件的控制器添加ngAfterViewInitngAfterContentInit方法的定义来简单地使用这些钩子。我们将很快对 Angular 2 提供的生命周期钩子进行全面概述。

总之,我们可以说给定组件的内容子代是嵌套在组件宿主元素内的子元素。相反,给定组件的视图子代指令是其模板中使用的元素。

注意

为了获得对 DOM 元素的平台无关引用,我们可以再次使用@ContentChildren@ViewChildren。例如,如果我们有以下模板:<input #todo>,我们可以通过使用@ViewChild('todo')来获取对input的引用。

既然我们已经熟悉了视图子代和内容子代之间的核心区别,现在我们可以继续实现我们的选项卡。

在标签组件中,我们使用的是@ContentChildren而不是@ContentChild装饰器。我们这样做是因为我们有多个内容子级,我们想要获取它们所有:

@ContentChildren(TabTitle)
tabTitles: QueryList<TabTitle>;

@ContentChildren(TabContent)
tabContents: QueryList<TabContent>;

我们可以注意到的另一个主要区别是,tabTitlestabContents属性的类型是带有相应类型参数的QueryList,而不是组件本身的类型。我们可以将QueryList数据结构视为 JavaScript 数组——我们可以对其应用相同的高阶函数(mapfilterreduce等),并循环遍历其元素;但是,QueryList也是可观察的,也就是说,我们可以观察它进行更改。

作为我们“标签”定义的最后一步,让我们来看一下ngAfterContentInit和“select”方法的实现:

ngAfterContentInit() {
  this.tabTitles
    .map(t => t.tabSelected)
    .forEach((t, i) => {
      t.subscribe(_ => {
        this.select(i)
      });
    });
  this.active = 0;
  this.select(0);
}

在方法实现的第一行,我们循环所有tabTitles并获取可观察的引用。这些对象有一个名为subscribe的方法,它接受一个回调作为参数。一旦调用了任何选项卡的EventEmitter实例(即任何选项卡的tabSelected属性)的.emit()方法,将调用传递给subscribe方法的回调。

现在,让我们来看一下select方法的实现:

select(index: number) {
  let contents: TabContent[] = this.tabContents.toArray();
  contents[this.active].isActive = false;
  this.active = index;
  contents[this.active].isActive = true;
  this.tabChanged.emit(index);
}

在方法的第一行,我们获取了tabContents的数组表示形式,它的类型是QueryList<TabContent>。之后,我们将当前活动选项卡的isActive标志设置为false,并选择下一个活动选项卡。在select方法的实现的最后一行中,我们通过调用this.tabChanged.emit并传入当前选定选项卡的索引来触发Tabs组件的选定事件。

挂钩到组件的生命周期

Angular 2 中的组件具有明确定义的生命周期,这使我们能够挂钩到其不同阶段,并进一步控制我们的应用程序。我们可以通过在组件的控制器中实现特定方法来实现这一点。为了更加明确,由于 TypeScript 的表现力,我们可以实现与生命周期阶段相关的不同接口。这些接口中的每一个都有一个与阶段本身相关联的单个方法。

虽然使用显式接口实现的代码语义更好,因为 Angular 2 也支持组件内的 ES5,我们可以简单地定义与生命周期钩子相同名称的方法(但这次以ng为前缀),并利用鸭子类型。

以下图表显示了我们可以挂钩的所有阶段:

钩入组件的生命周期

让我们来看一下不同的生命周期钩子:

  • OnChanges:一旦检测到给定组件的输入属性发生变化,将调用此钩子。例如,让我们来看一下以下组件:
@Component({
  selector: 'panel',
  inputs: ['title']
})
class Panel {…}

我们可以这样使用:

<panel [title]="expression"></panel>

一旦与[title]属性关联的表达式的值发生变化,将调用ngOnChanges钩子。我们可以使用以下代码片段来实现它:

@Component(…)
class Panel {
  ngOnChanges(changes) {
    Object.keys(changes).forEach(prop => {
      console.log(prop, 'changed. Previous value', changes[prop].previousValue);
    });
  }
}

前面的片段将显示所有更改的绑定及其旧值。为了在钩子的实现中更加明确,我们可以使用接口:

import {Component, OnChanges} from 'angular2/core';
@Component(…)
class Panel implements OnChanges {
  ngOnChanges(changes) {…}
}

代表各个生命周期钩子的所有接口都定义了一个以ng为前缀的接口名称的单个方法。在即将到来的列表中,我们将使用生命周期钩子这个术语,无论是接口还是方法,除非我们不会特别指代其中的一个。

  • OnInit:一旦给定组件被初始化,将调用此钩子。我们可以使用OnInit接口及其ngOnInit方法来实现它。

  • DoCheck:当给定组件的变更检测器被调用时,将调用此方法。它允许我们为给定组件实现自己的变更检测算法。请注意,DoCheckOnChanges不应该在同一个指令上同时实现。

  • OnDestroy:如果我们实现了OnDestroy接口及其单个ngOnDestroy方法,我们可以钩入组件销毁生命周期阶段。一旦组件从组件树中分离,将调用此方法。

现在,让我们来看一下与组件内容和视图子元素相关的生命周期钩子:

  • AfterContentInit:如果我们实现了ngAfterContentInit生命周期钩子,那么当组件的内容完全初始化时,我们将收到通知。这是使用ContentChildContentChildren装饰的属性将被初始化的阶段。

  • AfterContentChecked:通过实现此钩子,我们将在每次 Angular 2 的变更检测机制检查给定组件的内容时收到通知。

  • AfterViewInit:如果我们实现了ngAfterViewInit生命周期钩子,那么当组件的视图完全初始化时,我们将收到通知。这是使用ViewChildViewChildren装饰的属性将被初始化的阶段。

  • AfterViewChecked:这类似于AfterContentChecked。一旦组件的视图被检查,AfterViewChecked钩子将被调用。

执行顺序

为了追踪与每个钩子相关的回调的执行顺序,让我们来看一下ch4/ts/life-cycle/app.ts示例:

@Component({
  selector: 'panel',
  inputs: ['title', 'caption'],
  template: '<ng-content></ng-content>'
})
class Panel {
  ngOnChanges(changes) {…}
  ngOnInit() {…}
  ngDoCheck() {…}
  ngOnDestroy() {…}
  ngAfterContentInit() {…}
  ngAfterContentChecked() {…}
  ngAfterViewInit() {…}
  ngAfterViewChecked() {…}
}

Panel组件实现了所有钩子,而没有显式实现与它们相关的接口。

我们可以在以下模板中使用该组件:

<button (click)="toggle()">Toggle</button>
<div *ngIf="counter % 2 == 0">
  <panel caption="Sample caption" title="Sample">Hello world!</panel>
</div>

在上面的示例中,我们有一个面板和一个按钮。每次点击按钮时,面板将通过ngIf指令被移除或附加到视图中。

在应用程序初始化期间,如果"counter % 2 == 0"表达式的结果被评估为truengOnChanges方法将被调用。这是因为标题和说明属性的值将首次设置。

紧接着,ngOnInit方法将被调用,因为组件已经初始化。一旦组件的初始化完成,将触发变更检测,这将导致调用ngDoCheck方法,允许我们钩入自定义逻辑以检测状态的变化。

注意

请注意,您不应该为同一个组件同时实现ngDoCheckngOnChanges方法,因为它们是互斥的。这里的示例仅用于学习目的。

ngDoCheck方法之后,组件的内容将被检查(按顺序调用ngAfterContentInitngAfterContentChecked)。紧接着,组件的视图也将发生同样的情况(ngAfterViewInit后跟ngAfterViewChecked)。

一旦ngIf指令的表达式被评估为false,整个组件将从视图中分离,这将导致ngOnDestroy钩子的调用。

在下一个按钮点击时,如果ngIf表达式的值等于true,则与初始化阶段相同的生命周期钩子调用顺序将被执行。

使用 TemplateRef 定义通用视图

我们已经熟悉了输入、内容和视图子项的概念,也知道在组件的生命周期中何时可以获取对它们的引用。现在,我们将把它们结合起来,并引入一个新概念:TemplateRef

让我们退一步,看一下本章早些时候开发的最后一个待办事项应用程序。在下面的屏幕截图中,你可以看到它的用户界面是什么样子的:

使用 TemplateRef 定义通用视图

如果我们看一下它在ch4/ts/inputs-outputs/app.ts中的实现,我们会看到用于渲染单个待办事项的模板是在整个待办事项应用程序的模板中定义的。

如果我们想要使用不同的布局来渲染待办事项呢?我们可以通过创建另一个名为Todo的组件来实现这一点,该组件封装了渲染它们的责任。然后,我们可以为我们想要支持的不同布局定义单独的Todo组件。这样,即使我们只使用它们的模板,我们也需要为n个不同的布局定义n个不同的组件。

Angular 2 提供了一个更加优雅的解决方案。在本章的早些时候,我们已经讨论了模板元素。我们说它允许我们定义一块不会被浏览器处理的 HTML。Angular 2 允许我们引用这样的模板元素,并通过将它们作为内容子元素传递来使用它们!

以下是如何将自定义布局传递给我们重构后的todo-app组件:

// ch4/ts/template-ref/app.ts
<todo-app>
  <template var-todo>
    <input type="checkbox" [checked]="todo.completed"
      (change)="todo.completed = !todo.completed;">
    <span [class.completed]="todo.completed">
      {{todo.label}}
    </span><br>
  </template>
</todo-app>

在模板中,我们声明了一个名为todo的变量。稍后在模板中,我们可以使用它来指定我们希望可视化内容的方式。

现在,让我们看看如何在TodoApp组件的控制器中获取对这个模板的引用:

// ch4/ts/template-ref/app.ts
class TodoApp {
  @ContentChild(TemplateRef)
  private itemsTemplate: TemplateRef;
  // …
}

我们在这里所做的就是定义一个名为itemsTemplate的属性,并用@ContentChild装饰它。在组件的生命周期中(更准确地说,在ngAfterContentInit中),itemsTemplate的值将被设置为我们作为todo-app元素的内容传递的模板的引用。

不过还有一个问题——我们需要在TodoList组件中的模板中使用模板,因为那是我们渲染单个待办事项的地方。我们可以做的是在TodoList组件中定义另一个输入,并直接从TodoApp中传递模板:

// ch4/ts/template-ref/app.ts
class TodoList {
  @Input() todos: Todo[];
  @Input() itemsTemplate: TemplateRef;
  @Output() toggle = new EventEmitter<Todo>();
}

我们需要从TodoApp的模板中将其作为输入传递:

...
<todo-list [todos]="todos"
  [itemsTemplate]="itemsTemplate">
</todo-list>

只剩下的事情就是在TodoList应用程序的模板中使用这个模板引用:

<!-- … -->
<template *ngFor="var todo of todos; template: itemsTemplate"></template>

在本章的前几节中,我们解释了ngForOf指令的扩展语法。这个片段展示了这个指令的另一个属性:ngForTemplate属性。默认情况下,ngForOf指令的模板是它所用的元素。通过将模板引用指定为ngForTemplate属性,我们可以使用传递的TemplateRef

理解和增强变更检测

我们已经简要描述了框架的变更检测机制。我们说过,与 AngularJS 1.x 相比,在 Angular 2 中,它在各个组件的上下文中运行。我们提到的另一个概念是 zone,它基本上拦截了我们使用浏览器 API 进行的所有异步调用,并为框架的变更检测机制提供执行上下文。Zone 解决了我们在 AngularJS 1.x 中遇到的烦人问题,即当我们在 Angular 之外使用 API 时,需要显式调用digest循环。

在第一章和第二章中,我们讨论了变更检测的两种主要实现:DynamicChangeDetectorJitChangeDetector。第一种对于具有严格CSP内容安全策略)的环境非常有效,因为它禁用了 JavaScript 的动态评估。第二种则充分利用了 JavaScript 虚拟机的内联缓存机制,因此带来了很好的性能!

在本节中,我们将探讨@Component装饰器配置对象的另一个属性,它通过改变策略为我们提供了对框架的变更检测机制更进一步的控制。通过显式设置策略,我们能够阻止变更检测机制在组件的子树上运行,这在某些情况下可以带来很好的性能优势。

变更检测的执行顺序

现在,让我们简要描述一下变更检测在给定组件树中被调用的顺序。

为此,我们将使用我们拥有的待办事项应用程序的最后一个实现,但这次,我们将提取渲染单独待办事项的逻辑到一个名为 TodoItem 的单独组件中。在下图中,我们可以看到应用程序的结构:

变更检测器的执行顺序

顶层是 TodoApp 组件,它有两个子组件:InputBoxTodoListTodoList 组件在 TodoItem 组件中呈现单独的待办事项。实现细节对我们的目的不重要,所以我们将忽略它们。

现在,我们需要意识到父组件和其子组件之间存在隐含的依赖关系。例如,TodoList 组件的状态完全取决于其父级 TodoApp 组件中的待办事项。TodoItemTodoList 之间也存在类似的依赖关系,因为 TodoList 组件将单独的待办事项传递给 TodoItem 组件的单独实例。

变更检测器的执行顺序

由于我们的最后观察,附加到各个组件的变更检测器的执行顺序如前图所示。一旦变更检测机制运行,它将首先对 TodoApp 组件进行检查。紧接着,将检查 InputBox 组件是否有变化,然后是 TodoList 组件。最后,Angular 将调用 TodoItem 组件的变更检测器。

您可以在 ch4/ts/change_detection_strategy_order/app.ts 示例中跟踪执行顺序,其中每个单独的组件在调用其 ngDoCheck 方法时记录一条消息。

注意

请注意,只有组件才有一个附加的变更检测器实例;指令使用其父组件的变更检测器。

变更检测策略

Angular 2 提供的变更检测策略有:CheckOnceCheckedCheckAlwaysDetachedDefaultOnPush。我们将详细描述如何充分利用 OnPush,因为在使用不可变数据时非常强大。在深入研究 OnPush 之前,让我们简要描述其他策略。

现在,让我们导入 TypeScript enum,它可以用于配置用于各个组件的策略:

// ch4/ts/change_detection_strategy_broken/app.ts
import {ChangeDetectionStrategy} from 'angular2/core';

现在,我们可以配置TodoList组件以使用Checked策略:

@Component({
  selector: 'todo-list',
  changeDetection: ChangeDetectionStrategy.Checked,
  template: `...`,
  styles: […]
})
class TodoList { … }

这样,变更检测将被跳过,直到其模式(策略)更改为CheckOnce。但是,阻止变更检测运行意味着什么?您可以转到http://localhost:5555/dist/dev/ch4/ts/change_detection_strategy_broken/,并查看TodoList组件的不一致行为。当您在输入中添加一个新的待办事项并单击按钮时,它不会立即出现在列表中。

现在,让我们尝试CheckOnce!在ch4/ts/change_detection_strategy_broken/app.ts中,将TodoList组件的变更检测策略更改为ChangeDetectionStrategy.CheckOnce。刷新浏览器后,尝试添加一个新的待办事项。变更不应立即反映出来,因为CheckOnce会指示变更检测器仅执行一次检查(在这种情况下,在初始化期间),之后将不会发生任何变化。

默认情况下,它在CheckAlways模式下使用,正如其名称所示,不会阻止变更检测器运行。

如果我们将给定组件的策略声明为Detached,则变更检测器子树将不被视为主树的一部分,并将被跳过。

使用不可变数据和 OnPush 来提高性能

我们将要描述的最后一个变更检测策略是OnPush。当给定组件产生的结果仅取决于其输入时,它非常有用。在这种情况下,我们可以将不可变数据传递给输入,以确保它不会被任何其他组件改变。通过这种方式,通过具有仅依赖于其不可变输入的组件,我们可以确保它仅在接收到不同输入时(即不同引用)产生不同的用户界面。

在本节中,我们将在TodoList组件上应用OnPush策略。由于它仅依赖于其输入(todos输入),我们希望确保它的变更检测仅在收到todos集合的新引用时执行。

不可变数据的本质是它不能改变。这意味着一旦我们向todos集合添加新的待办事项,我们就不能改变它;相反,add(或在我们的情况下,push)方法将返回一个新的集合——包含新项目的初始集合的副本。

这可能看起来像是一个巨大的开销-每次更改都要复制整个集合。在大型应用程序中,这可能会对性能产生很大影响。然而,我们不需要复制整个集合。有一些库使用更智能的算法来实现不可变数据结构:持久数据结构。持久数据结构超出了当前内容的范围。关于它们的更多信息可以在大多数计算机科学高级数据结构的教科书中找到。好消息是,我们不必深入了解它们的实现就可以使用它们!有一个名为Immutable.js的库,它实现了一些常用的不可变数据结构。在我们的情况下,我们将使用不可变列表。通常,不可变列表的行为就像普通列表一样,但在每个应该改变它的操作上,它会返回一个新的列表。

这意味着如果我们有一个名为foo的不可变列表,并且向列表添加一个新项,我们将得到一个新的引用:

let foo = List.of(1, 2, 3);
let changed = foo.push(4);
foo === changed // false
console.log(foo.toJS()); // [ 1, 2, 3 ]
console.log(changed.toJS()); // [ 1, 2, 3, 4 ]

为了利用不可变性,我们需要使用 npm 安装Immutable.js

我们已经在ch4/ts/change_detection_strategy/app.ts中做过这个。Immutable.js已经是package.json的一部分,它位于项目的根目录。

现在,是时候重构我们的待办事项应用程序,并使其使用不可变数据了!

在 Angular 中使用不可变数据结构

让我们看看我们目前如何在TodoApp组件中保存待办事项。

class TodoApp {
  todos: Todo[] = [...];
  ...
}

我们使用一个Todo项目的数组。JavaScript 数组是可变的,这意味着如果我们将其传递给使用OnPush策略的组件,如果我们得到相同的输入引用,跳过变更检测是不安全的。例如,我们可能有两个使用相同待办事项列表的组件。由于它是可变的,两个组件都可以修改列表。如果它们的变更检测没有执行,这将导致任何一个组件处于不一致的状态。这就是为什么我们需要确保保存项目的列表是不可变的。为了确保TodoApp组件将其数据保存在不可变数据结构中,我们需要做的就是这样:

// ch4/ts/change_detection_strategy/app.ts
class TodoApp {
  todos: ImmutableList<Todo> = ImmutableList.of({
    label: 'Buy milk',
    completed: false
  }, {
    label: 'Save the world',
    completed: false
  });
  ...
}

这样,我们将todos属性构造为不可变列表。由于不可变列表的变异操作会返回一个新列表,我们需要在addTodotoggleTodoCompletion中进行轻微修改:

...
addTodo(label: string) {
  this.todos = this.todos.push({
    label,
    completed: false
  });
}
toggleCompletion(index: number) {
  this.todos = this.todos.update(index, todo => {
    let newTodo = {
      label: todo.label,
      completed: !todo.completed
    };
    return newTodo;
  });
}
…

addTodo函数看起来与以前完全相同,只是我们将push方法的结果设置为todos属性的值。

toggleTodoCompletion中,我们使用不可变列表的update方法。作为第一个参数,我们传递要修改的待办事项的索引,第二个参数是执行实际修改的回调函数。请注意,由于在这种情况下我们使用的是不可变数据,所以我们复制了修改后的待办事项。这是必需的,因为它告诉update方法给定索引的项目已经更改(因为它是不可变的,只有当它具有新引用时才被认为已更改),这意味着整个列表已更改。

那就是复杂的部分!现在让我们来看一下TodoList组件的定义:

@Component({
  selector: 'todo-list',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `...`,
  styles: [...]
})
class TodoList {
  @Input() todos: ImmutableList<Todo>;
  @Output() toggle = new EventEmitter<number>();
  toggleCompletion(index: number) {
    this.toggle.emit(index);
  }
}

@Component装饰器内,我们将changeDetection属性设置为OnPush策略的值。这意味着组件只有在其任何输入获得新引用时才会运行其变更检测器。组件的模板保持完全相同,因为ngForOf在内部使用 ES2015 迭代器来循环提供的集合中的项目。它们受Immutable.js支持,因此不需要更改模板。

由于我们需要改变项目的索引(我们在TodoApp中的todos集合的update方法中使用的索引),我们将组件的输出类型更改为EventEmitter<number>。在toggleCompletion中,我们发出了更改的待办事项的索引。

这就是我们通过防止变更检测机制在父组件没有推送新引用的情况下运行整个右子树来优化我们的简单待办事项应用程序的方法。

总结

在本章中,我们介绍了 Angular 2 应用程序的核心构建模块:指令和组件。我们构建了一些示例组件,展示了用于定义这些基本概念的语法。我们还描述了每个指令的生命周期和给定指令和组件的核心功能集。作为下一步,我们看到了如何通过使用不可变数据和OnPush变更检测策略来增强应用程序的性能。

下一章完全致力于 Angular 2 服务和框架的依赖注入机制。我们将看看如何定义和实例化自定义注入器,以及如何利用依赖注入机制在我们的指令和组件中。

第五章:Angular 2 中的依赖注入

在本章中,我们将解释如何利用框架的依赖注入DI)机制及其各种特性。

我们将探讨以下主题:

  • 配置和创建注入器。

  • 使用注入器实例化对象。

  • 将依赖项注入到我们的指令和组件中。这样,我们将能够重用服务中定义的业务逻辑,并将其与 UI 逻辑连接起来。

  • 注释我们将编写的 ES5 代码,以便获得与使用 TypeScript 语法时相同的结果。

我为什么需要依赖注入?

假设我们有一个依赖于EngineTransmission类的Car类。我们如何实现这个系统?让我们看一下:

class Engine {…}
class Transmission {…}
class Car {
  engine;
  transmission;
  constructor() {
    this.engine = new Engine();
    this.transmission = new Transmission();
  }
}

在这个例子中,我们在Car类的构造函数中创建了它的依赖关系。虽然看起来很简单,但远非灵活。每次我们创建Car类的实例时,都会创建相同的EngineTransmission类的实例。这可能会有问题,原因如下:

  • Car类变得不太可测试,因为我们无法独立测试它的enginetransmission依赖关系。

  • Car类与用于实例化其依赖关系的逻辑耦合在一起。

Angular 2 中的依赖注入

我们可以采用的另一种方法是利用 DI 模式。我们已经从 AngularJS 1.x 中熟悉了它。让我们演示如何在 Angular 2 的上下文中使用 DI 重构前面的代码:

class Engine {…}
class Transmission {…}

@Injectable()
class Car {
  engine;
  transmission;
  constructor(engine: Engine, transmission: Transmission) {
    this.engine = engine;
    this.transmission = transmission;
  }
}

在前面的片段中,我们所做的只是在Car类的定义顶部添加了@Injectable类装饰器,并为其构造函数的参数提供了类型注解。

Angular 2 中 DI 的好处

还有一步剩下,我们将在下一节中看一下。但让我们看看所述方法的好处是什么:

  • 我们可以轻松地为测试环境传递Car类的不同版本的依赖关系。

  • 我们不再与依赖关系实例化周围的逻辑耦合在一起。

Car类只负责实现自己的领域特定逻辑,而不是与其他功能耦合,比如管理它的依赖关系。我们的代码也变得更加声明性和易于阅读。

现在,在我们意识到 DI 的一些好处之后,让我们看看为使这段代码工作所缺少的部分!

配置注入器

在我们的 Angular 2 应用程序中,通过框架的 DI 机制实例化各个依赖项的基本方法称为注入器。注入器包含一组提供者,封装了与token关联的已注册依赖项实例化的逻辑。我们可以将 token 视为注入器中注册的不同提供者的标识符。

让我们看一下下面的代码片段,它位于ch5/ts/injector-basics/injector.ts

import 'reflect-metadata';
import {
  Injector, Inject, Injectable,
  OpaqueToken, provide
} from 'angular2/core';

const BUFFER_SIZE = new OpaqueToken('buffer-size');

class Buffer {
  constructor(@Inject(BUFFER_SIZE) private size: Number) {
    console.log(this.size);
  }
}

@Injectable()
class Socket {
  constructor(private buffer: Buffer) {}
}

let injector = Injector.resolveAndCreate([
  provide(BUFFER_SIZE, { useValue: 42 }),
  Buffer,
  Socket
]);

injector.get(Socket);

您可以使用以下命令运行该文件:

**cd app**
**ts-node ch5/ts/injector-basics/injector.ts**

如果您还没有安装ts-node,请参阅第三章 TypeScript Crash Course,了解如何继续在计算机上安装并运行它。

我们导入了InjectorInjectableInjectOpaqueTokenprovide

注入器表示用于实例化不同依赖项的容器。使用provide函数声明的规则和 TypeScript 编译器生成的元数据,它知道如何创建它们。

在前面的代码片段中,我们首先定义了BUFFER_SIZE常量,并将其设置为new OpaqueToken('buffer-size')的值。我们可以将BUFFER_SIZE的值视为应用程序中无法复制的唯一值(OpaqueToken是 ES2015 中Symbol类的替代品,因为在撰写本文时,TypeScript 不支持Symbol)。

我们定义了两个类:BufferSocketBuffer类有一个构造函数,只接受一个名为size的依赖项,类型为Number。为了为依赖项解析过程添加额外的元数据,我们使用@Inject参数装饰器。这个装饰器接受一个标识符(也称为token),表示我们要注入的依赖项。通常情况下,它是依赖项的类型(即类的引用),但在某些情况下,它可以是不同类型的值。例如,在我们的例子中,我们使用了OpaqueToken类的实例。

使用生成的元数据进行依赖项解析

现在让我们看一下Socket类。我们用@Injectable装饰它。这个装饰器应该被任何接受依赖项的类使用,这些依赖项应该通过 Angular 2 的依赖注入机制注入。

@Injectable装饰器会强制 TypeScript 编译器为给定类接受的依赖项的类型生成额外的元数据。这意味着如果我们省略@Injectable装饰器,Angular 的 DI 机制将不会意识到与它需要解决的依赖项相关联的标记。

如果在类的顶部没有使用装饰器,TypeScript 不会生成任何元数据,这主要是出于性能方面的考虑。想象一下,如果为每个接受依赖项的类生成了这样的元数据,那么输出将充斥着未使用的额外类型元数据。

使用@Injectable的另一种方法是使用@Inject装饰器显式声明依赖项的类型。看一下下面的例子:

class Socket {
  constructor(@Inject(Buffer) private buffer: Buffer) {}
}

这意味着前面的代码与使用@Injectable的代码具有等效的语义,正如前面提到的。唯一的区别是,Angular 2 将会直接从@Injector装饰器添加的元数据中获取依赖项的类型(即与之关联的标记),而不是使用@Injectable时,它将查看编译器生成的元数据。

实例化注入器

现在,让我们创建一个注入器的实例,以便用它来实例化已注册的标记:

let injector = Injector.resolveAndCreate([
  provide(BUFFER_SIZE, { useValue: 42 }),
  Buffer,
  Socket
]);

我们使用resolveAndCreate的静态方法创建Injector的一个实例。这是一个工厂方法,接受一个提供者数组作为参数,并返回一个新的Injector

resolve意味着提供者将经过解析过程,其中包括一些内部处理(展平多个嵌套数组并将单个提供者转换为数组)。稍后,注入器可以根据提供者封装的规则实例化我们已注册提供者的任何依赖项。

在我们的例子中,我们使用provide方法明确告诉 Angular 2 DI 机制在需要BUFFER_SIZE标记时使用值42。另外两个提供者是隐式的。一旦它们的所有依赖项都得到解决,Angular 2 将通过使用new运算符调用提供的类来实例化它们。

我们在Buffer类的构造函数中请求BUFFER_SIZE的值:

class Buffer {
  constructor(@Inject(BUFFER_SIZE) private size: Number) {
    console.log(this.size);
  }
}

在前面的例子中,我们使用了@Inject参数装饰器。它提示 DI 机制,Buffer类的构造函数的第一个参数应该用与传递给注入器的BUFFER_SIZE标记相关联的提供者实例化。

引入前向引用

Angular 2 引入了前向引用的概念。这是由于以下原因所必需的:

  • ES2015 类不会被提升。

  • 允许解析在声明依赖提供者之后声明的依赖项。

在本节中,我们将解释前向引用解决的问题以及我们可以利用它们的方式。

现在,假设我们已经以相反的顺序定义了BufferSocket类:

// ch5/ts/injector-basics/forward-ref.ts

@Injectable()
class Socket {
  constructor(private buffer: Buffer) {…}
}

// undefined
console.log(Buffer);

class Buffer {
  constructor(@Inject(BUFFER_SIZE) private size: Number) {…}
}

// [Function: Buffer]
console.log(Buffer);

在这里,我们有与前面例子中相同的依赖关系,但在这种情况下,Socket类的定义在Buffer类的定义之前。请注意,直到 JavaScript 虚拟机评估Buffer类的声明之前,Buffer标识符的值将等于undefined。然而,Socket接受的依赖项类型的元数据将在Socket类定义之后生成并放置。这意味着除了解释生成的 JavaScript 之外,Buffer标记的值将等于undefined——也就是说,在 Angular 2 的 DI 机制的上下文中,框架将获得一个无效的值。

运行前面的代码片段将导致以下形式的运行时错误:

**Error: Cannot resolve all parameters for Socket(undefined). Make sure they all have valid type or annotations.**

解决这个问题的最佳方法是通过交换定义的顺序。我们可以继续的另一种方法是利用 Angular 2 提供的解决方案:前向引用:

…
import {forwardRef} from 'angular2/core';
…
@Injectable()
class Socket {
  constructor(@Inject(forwardRef(() => Buffer))
    private buffer: Buffer) {}
}
class Buffer {…}

前面的代码片段演示了我们如何利用前向引用。我们所需要做的就是使用@Inject参数装饰器,并将forwardRef函数的调用结果传递给它。forwardRef函数是一个高阶函数,接受一个参数——另一个负责返回与需要被注入的依赖项(或更准确地说是与其提供者相关联的)关联的标记的函数。这样,框架提供了一种推迟解析依赖项类型(标记)的过程的方式。

依赖项的标记将在首次需要实例化Socket时解析,而不是默认行为,在给定类的声明时需要标记。

配置提供程序

现在,让我们看一个类似于之前使用的示例,但注入器的配置不同的示例。

let injector = Injector.resolveAndCreate([
  provide(BUFFER_SIZE, { useValue: 42 }),
  provide(Buffer, { useClass: Buffer }),
  provide(Socket, { useClass: Socket })
]);

在这种情况下,在提供程序内部,我们明确声明了我们希望使用Buffer类来构建具有与Buffer类引用相等的标记的依赖项。对于与Socket标记关联的依赖项,我们做了完全相同的事情;但这次,我们提供了Socket类。这就是当我们省略provide函数的调用并只传递一个类的引用时,Angular 2 将如何进行。

明确声明用于实例化相同类的类可能看起来毫无价值,鉴于我们迄今为止看到的例子,这完全正确。然而,在某些情况下,我们可能希望为与给定类标记关联的依赖项的实例化提供不同的类。

例如,假设我们有一个名为Http的服务,它在一个名为UserService的服务中使用:

class Http {…}

@Injectable()
class UserService {
  constructor(private http: Http) {}
}

let injector = Injector.resolveAndCreate([
  UserService,
  Http
]);

UserService服务使用Http与 RESTful 服务进行通信。我们可以使用injector.get(UserService)来实例化UserService。这样,由注入器的get方法调用的UserService构造函数将接受Http服务的实例作为参数。然而,如果我们想要测试UserService,我们实际上并不需要对 RESTful 服务进行 HTTP 调用。在单元测试的情况下,我们可以提供一个虚拟实现,只会伪造这些 HTTP 调用。为了向UserService服务注入一个不同类的实例,我们可以将注入器的配置更改为以下内容:

class DummyHttp {…}

// ...

let injector = Injector.resolveAndCreate([
  UserService,
  provide(Http, { useClass: DummyHttp })
]);

现在,当我们实例化UserService时,它的构造函数将接收一个DummyHttp服务实例的引用。这段代码位于ch5/ts/configuring-providers/dummy-http.ts中。

使用现有的提供程序

另一种方法是使用提供程序配置对象的useExisting属性:

// ch5/ts/configuring-providers/existing.ts
let injector = Injector.resolveAndCreate([
  DummyService,
  provide(Http, { useExisting: DummyService }),
  UserService
]);

在前面的片段中,我们注册了三个令牌:DummyServiceUserServiceHttp。我们声明要将Http令牌绑定到现有令牌DummyService。这意味着当请求Http服务时,注入器将找到用作useExisting属性值的令牌的提供者并实例化它或获取与之关联的值。我们可以将useExisting视为创建给定令牌的别名:

let dummyHttp = {
  get() {},
  post() {}
};
let injector = Injector.resolveAndCreate([
  provide(DummyService, { useValue: dummyHttp }),
  provide(Http, { useExisting: DummyService }),
  UserService
]);
console.assert(injector.get(UserService).http === dummyHttp);

前面的片段将创建一个Http令牌到DummyHttp令牌的别名。这意味着一旦请求Http令牌,调用将转发到与DummyHttp令牌关联的提供者,该提供者将解析为值dummyHttp

定义实例化服务的工厂

现在,假设我们想创建一个复杂的对象,例如代表传输层安全TLS)连接的对象。这样一个对象的一些属性是套接字、一组加密协议和证书。在这个问题的背景下,我们迄今为止看到的 Angular 2 的 DI 机制的特性似乎有点有限。

例如,我们可能需要配置TLSConnection类的一些属性,而不将其实例化过程与所有配置细节耦合在一起(选择适当的加密算法,打开我们将建立安全连接的 TCP 套接字等)。

在这种情况下,我们可以利用提供者配置对象的useFactory属性:

let injector = Injector.resolveAndCreate([
  provide(TLSConnection, {
    useFactory: (socket: Socket, certificate: Certificate, crypto: Crypto) =>  {
      let connection = new TLSConnection();
      connection.certificate = certificate;
      connection.socket = socket;
      connection.crypto = crypto;
      socket.open();
      return connection;
    },
    deps: [Socket, Certificate, Crypto]
  }),
  provide(BUFFER_SIZE, { useValue: 42 }),
  Buffer,
  Socket,
  Certificate,
  Crypto
]);

前面的代码一开始似乎有点复杂,但让我们一步一步地来看看它。我们可以从我们已经熟悉的部分开始:

let injector = Injector.resolveAndCreate([
  ...
  provide(BUFFER_SIZE, { useValue: 42 }),
  Buffer,
  Socket,
  Certificate,
  Crypto
]);

最初,我们注册了一些提供者:BufferSocketCertificateCrypto。就像前面的例子一样,我们还注册了BUFFER_SIZE令牌,并将其与值42关联起来。这意味着我们已经可以创建BufferSocketCertificateCrypto类型的对象:

// buffer with size 42
console.log(injector.get(Buffer));
// socket with buffer with size 42
console.log(injector.get(Socket));

我们可以通过以下方式创建和配置TLSConnection对象的实例:

let connection = new TLSConnection();
connection.certificate = certificate;
connection.socket = socket;
connection.crypto = crypto;
socket.open();
return connection;

现在,如果我们注册一个具有TLSConnection标记作为依赖项的提供者,我们将阻止 Angular 的依赖注入机制处理依赖项解析过程。为了解决这个问题,我们可以使用提供者配置对象的useFactory属性。这样,我们可以指定一个函数,在这个函数中我们可以手动创建与提供者标记相关联的对象的实例。我们可以将useFactory属性与deps属性一起使用,以指定要传递给工厂的依赖项:

provide(TLSConnection, {
  useFactory: (socket: Socket, certificate: Certificate, crypto: Crypto) =>  {
    // ...
  },
  deps: [Socket, Certificate, Crypto]
})

在前面的片段中,我们定义了用于实例化TLSConnection的工厂函数。作为依赖项,我们声明了SocketCertificateCrypto。这些依赖项由 Angular 2 的 DI 机制解析并注入到工厂函数中。您可以在ch5/ts/configuring-providers/factory.ts中查看整个实现并进行操作。

子注入器和可见性

在本节中,我们将看看如何构建注入器的层次结构。这是 Angular 2 引入的一个全新概念。每个注入器可以有零个或一个父注入器,每个父注入器可以分别有零个或多个子注入器。与 AngularJS 1.x 相比,在 Angular 2 中,所有注册的提供者都存储在树中,而不是存储在一个扁平的结构中。扁平结构更为有限;例如,它不支持标记的命名空间;也就是说,我们不能为同一个标记声明不同的提供者,这在某些情况下可能是必需的。到目前为止,我们看了一个没有任何子节点或父节点的注入器的示例。现在让我们构建一个注入器的层次结构!

为了更好地理解这种注入器的层次结构,让我们看一下下图:

子注入器和可见性

在这里,我们看到一个树,其中每个节点都是一个注入器,每个注入器都保留对其父级的引用。注入器House有三个子注入器:BathroomKitchenGarage

Garage有两个子节点:CarStorage。我们可以将这些注入器视为内部注册了提供者的容器。

假设我们想要获取与标记Tire相关联的提供程序的值。如果我们使用注射器Car,这意味着 Angular 2 的 DI 机制将尝试在Car及其所有父级GarageHouse中查找与此标记相关联的提供程序,直到找到为止。

构建注射器的层次结构

为了更好地理解上一段,让我们看一个简单的例子:

// ch5/ts/parent-child/simple-example.ts
class Http {}

@Injectable()
class UserService {
  constructor(public http: Http) {}
}

let parentInjector = Injector.resolveAndCreate([
  Http
]);
let childInjector = parentInjector.resolveAndCreateChild([
  UserService
]);

// UserService { http: Http {} }
console.log(childInjector.get(UserService));
// true
console.log(childInjector.get(Http) === parentInjector.get(Http));

由于它们对于解释前面的片段并不重要,所以省略了导入部分。我们有两个服务,HttpUserService,其中UserService依赖于Http服务。

最初,我们使用Injector类的resolveAndCreate静态方法创建了一个注射器。我们向此注射器传递了一个隐式提供程序,稍后将解析为具有Http标记的提供程序。使用resolveAndCreateChild,我们解析了传递的提供程序并实例化了一个注射器,该注射器指向parentInjector(因此我们得到与上图中GarageHouse之间相同的关系)。

现在,使用childInjector.get(UserService),我们能够获取与UserService标记相关联的值。类似地,使用childInjector.get(Http)parentInjector.get(Http),我们得到与Http标记相关联的相同值。这意味着childInjector向其父级请求与请求的标记相关联的值。

然而,如果我们尝试使用parentInjector.get(UserService),我们将无法获取与该标记相关联的值,因为在此注射器中,我们没有注册具有此标记的提供程序。

配置依赖关系

现在我们熟悉了注射器的层次结构,让我们看看如何从其中获取适当注射器的依赖关系。

使用@Self 装饰器

现在假设我们有以下配置:

abstract class Channel {}
class Http extends Channel {}
class WebSocket extends Channel {}

@Injectable()
class UserService {
  constructor(public channel: Channel) {}
}

let parentInjector = Injector.resolveAndCreate([
  provide(Channel, { useClass: Http })
]);
let childInjector = parentInjector.resolveAndCreateChild([
  provide(Channel, { useClass: WebSocket }),
  UserService
]);

我们可以使用以下方法实例化UserService标记:

childInjector.get(UserService);

UserService中,我们可以声明我们要使用@Self装饰器从当前注射器(即childInjector)获取Channel依赖项的值:

@Injectable()
class UserService {
  constructor(@Self() public channel: Channel) {}
}

尽管在实例化UserService期间,这将是默认行为,但使用@Self,我们可以更加明确。假设我们将childInjector的配置更改为以下内容:

let parentInjector = Injector.resolveAndCreate([
  provide(Channel, { useClass: Http })
]);
let childInjector = parentInjector.resolveAndCreateChild([
  UserService
]);

如果我们在UserService构造函数中保留@Self装饰器,并尝试使用childInjector实例化UserService,由于缺少Channel的提供程序,我们将收到运行时错误。

跳过自注入器

在某些情况下,特别是在注入 UI 组件的依赖项时,我们可能希望使用父注入器中注册的提供者,而不是当前注入器中注册的提供者。我们可以通过利用@SkipSelf装饰器来实现这种行为。例如,假设我们有以下类Context的定义:

class Context {
  constructor(public parentContext: Context) {}
}

Context类的每个实例都有一个父级。现在让我们构建一个包含两个注入器的层次结构,这将允许我们创建一个具有父上下文的上下文:

let parentInjector = Injector.resolveAndCreate([
  provide(Context, { useValue: new Context(null) })
]);
let childInjector = parentInjector.resolveAndCreateChild([
  Context
]);

由于根上下文没有父级,我们将设置其提供者的值为new Context(null)

如果我们想要实例化子上下文,我们可以使用:

childInjector.get(Context);

对于子级的实例化,Context将由childInjector中注册的提供者使用。但是,作为一个依赖项,它接受一个Context类的实例对象。这些类存在于同一个注入器中,这意味着 Angular 将尝试实例化它,但它具有Context类型的依赖项。这个过程将导致一个无限循环,从而导致运行时错误。

为了防止这种情况发生,我们可以以以下方式更改Context的定义:

class Context {
  constructor(@SkipSelf() public parentContext: Context) {}
}

我们引入的唯一变化是参数装饰器@SkipSelf的添加。

具有可选依赖项

Angular 2 引入了@Optional装饰器,它允许我们处理没有与之关联的已注册提供者的依赖项。假设一个提供者的依赖项在负责其实例化的任何目标注入器中都不可用。如果我们使用@Optional装饰器,在实例化缺失依赖项的依赖提供者时,缺失依赖项的值将被传递为null

现在让我们看一个例子:

abstract class SortingAlgorithm {
  abstract sort(collection: BaseCollection): BaseCollection;
}

@Injectable()
class Collection extends BaseCollection {
  private sort: SortingAlgorithm;
  constructor(sort: SortingAlgorithm) {
    super();
    this.sort = sort || this.getDefaultSort();
  }
}

let injector = Injector.resolveAndCreate([
  Collection
]);

在这种情况下,我们定义了一个名为SortingAlgorithm的抽象类和一个名为Collection的类,它接受一个扩展SortingAlgorithm的具体类的实例作为依赖项。在Collection构造函数内,我们将sort实例属性设置为传递的SortingAlgorithm类型的依赖项或默认的排序算法实现。

我们没有在我们配置的注入器中为SortingAlgorithm令牌定义任何提供者。因此,如果我们想使用injector.get(Collection)来获取Collection类的实例,我们将会得到一个运行时错误。这意味着,如果我们想使用框架的 DI 机制获取Collection类的实例,我们必须为SortingAlgorithm令牌注册一个提供者,尽管我们可以回退到默认排序算法的实现。

Angular 2 通过@Optional装饰器为这个问题提供了解决方案。

这就是我们可以使用框架提供的@Optional装饰器来解决问题的方式。

// ch5/ts/decorators/optional.ts
@Injectable()
class Collection extends BaseCollection {
  private sort: SortingAlgorithm;
  constructor(@Optional() sort: SortingAlgorithm) {
    super();
    this.sort = sort || this.getDefaultSort();
  }
}

在前面的片段中,我们将sort依赖声明为可选的,这意味着如果 Angular 2 找不到其令牌的任何提供者,它将传递null值。

使用多提供者

多提供者是 Angular 2 DI 机制引入的另一个新概念。它们允许我们将多个提供者与相同的令牌关联起来。如果我们正在开发一个带有不同服务的默认实现的第三方库,但是你想允许用户使用自定义的实现来扩展它,这将非常有用。它们还专门用于在 Angular 2 表单模块中对单个控件进行多个验证。我们将在第六章和第七章中解释这个模块。

另一个适用于多提供者的用例示例是 Angular 2 在其 WebWorkers 实现中用于事件管理的。他们为事件管理插件创建了多提供者。每个提供者返回一个不同的策略,支持不同的事件集(触摸事件、键盘事件等)。一旦发生特定事件,他们可以选择处理它的适当插件。

让我们来看一个例子,说明了多提供者的典型用法:

// ch5/ts/configuring-providers/multi-providers.ts
const VALIDATOR = new OpaqueToken('validator');

interface EmployeeValidator {
  (person: Employee): boolean;
}

class Employee {...}

let injector = Injector.resolveAndCreate([
  provide(VALIDATOR, { multi: true,
    useValue: (person: Employee) => {
      if (!person.name) {
        return 'The name is required';
      }
    }
  }),
  provide(VALIDATOR, { multi: true,
    useValue: (person: Employee) => {
      if (!person.name || person.name.length < 1) {
        return 'The name should be more than 1 symbol long';
      }
    }
  }),
  Employee
]);

在前面的代码片段中,我们声明了一个名为VALIDATOR的常量,其中包含OpaqueToken的新实例。我们还创建了一个注入器,在那里我们注册了三个提供程序——其中两个被用作值函数,根据不同的标准,验证Employee类的实例。这些函数的类型是EmployeeValidator

为了声明我们希望注入器将所有注册的验证器传递给Employee类的构造函数,我们需要使用以下构造函数定义:

class Employee {
  name: string;
  constructor(@Inject(VALIDATOR) private validators: EmployeeValidator[]) {}
  validate() {
    return this.validators
      .map(v => v(this))
      .filter(value => !!value);
  }
}

在前面的示例中,我们声明了一个名为Employee的类,它接受一个依赖项——一个EmployeeValidators数组。在validate方法中,我们对当前类实例应用了各个验证器,并过滤结果,以便只获取返回错误消息的验证器。

请注意构造函数参数validators的类型是EmployeeValidator[]。由于我们不能将类型“对象数组”用作提供程序的标记,因为它不是有效的类型引用,所以我们需要使用@Inject参数装饰器。

在组件和指令中使用 DI

在第四章中,使用 Angular 2 组件和指令入门,当我们开发了我们的第一个 Angular 2 指令时,我们看到了如何利用 DI 机制将服务注入到我们的 UI 相关组件(即指令和组件)中。

让我们从依赖注入的角度快速看一下我们之前做的事情:

// ch4/ts/tooltip/app.ts
// ...
@Directive(...)
export class Tooltip {
  @Input()
  saTooltip:string;

  constructor(private el: ElementRef, private overlay: Overlay) {
    this.overlay.attach(el.nativeElement);
  }
  // ...
}
@Component({
  // ...
  providers: [Overlay],
  directives: [Tooltip]
})
class App {}

由于大部分早期实现的代码与我们当前的重点无直接关系,因此被省略。

请注意Tooltip的构造函数接受两个依赖项:

  • ElementRef类的一个实例。

  • Overlay类的一个实例。

依赖项的类型是与其提供程序关联的标记,来自提供程序的相应值将使用 Angular 2 的 DI 机制进行注入。

尽管Tooltip类的依赖项声明看起来与我们在之前的部分中所做的完全相同,但既没有任何显式配置,也没有任何注入器的实例化。

介绍元素注入器

在幕后,Angular 将为所有指令和组件创建注入器,并向其添加一组默认提供者。这就是所谓的元素注入器,是框架自己处理的事情。与组件关联的注入器称为宿主注入器。每个指令和组件注入器中的一个提供者与ElementRef令牌相关联;它将返回指令的宿主元素的引用。但是Overlay类的提供者在哪里声明?让我们看一下顶层组件的实现:

@Component({
  // ...
  providers: [Overlay],
  directives: [Tooltip]
})

class App {}

我们通过在@Component装饰器内声明providers属性来为App组件配置元素注入器。在这一点上,注册的提供者将被相应元素注入器关联的指令或组件以及组件的整个子树所看到,除非在层次结构的某个地方被覆盖。

声明元素注入器的提供者

将所有提供者的声明放在同一个地方可能会非常不方便。例如,想象一下,我们正在开发一个大型应用程序,其中有数百个组件依赖于成千上万的服务。在这种情况下,在根组件中配置所有提供者并不是一个实际的解决方案。当两个或更多提供者与相同的令牌相关联时,将会出现名称冲突。配置将会很庞大,很难追踪不同的令牌需要被注入的地方。

正如我们提到的,Angular 2 的@Directive(以及相应的@Component)装饰器允许我们使用providers属性引入特定于指令的提供者。以下是我们可以采用的方法:

@Directive({
  selector: '[saTooltip]',
  providers: [OverlayMock]
})
export class Tooltip {
  @Input()
  saTooltip: string;

  constructor(private el: ElementRef, private overlay: Overlay) {
    this.overlay.attach(el.nativeElement);
  }
  // ...
}

// ...

bootstrap(App);

前面的示例覆盖了Tooltip指令声明中Overlay令牌的提供者。这样,Angular 在实例化工具提示时将注入OverlayMock的实例,而不是Overlay

覆盖提供者的更好方法是使用bootstrap函数。我们可以这样做:

bootstrap(AppMock, [provide(Overlay, {
  useClass: OverlayMock
})]);

在前面的bootstrap调用中,我们为Overlay服务提供了一个不同的顶层组件和提供者,它将返回OverlayMock类的实例。这样,我们可以测试Tooltip指令,忽略Overlay的实现。

探索组件的依赖注入

由于组件通常是带有视图的指令,到目前为止我们所看到的关于 DI 机制如何与指令一起工作的一切对组件也是有效的。然而,由于组件提供的额外功能,我们可以对它们的提供程序有更多的控制。

正如我们所说,与每个组件关联的注入器将被标记为宿主注入器。有一个称为@Host的参数装饰器,它允许我们从任何注入器中检索给定的依赖项,直到达到最近的宿主注入器。这意味着通过在指令中使用@Host装饰器,我们可以声明我们要从当前注入器或任何父注入器中检索给定的依赖项,直到达到最近父组件的注入器。

添加到@Component装饰器的viewProviders属性负责实现更多的控制。

viewProviders 与 providers

让我们来看一个名为MarkdownPanel的组件的示例。这个组件将以以下方式使用:

<markdown-panel>
  <panel-title># Title</pane-title>
  <panel-content>
# Content of the panel
* First point
* Second point
  </panel-content>
</markdown-panel>

面板每个部分的内容将从 markdown 翻译成 HTML。我们可以将这个功能委托给一个名为Markdown的服务:

import * as markdown from 'markdown';
class Markdown {
  toHTML(md) {
    return markdown.toHTML(md);
  }
}

Markdown服务将 markdown 模块包装起来,以便通过 DI 机制进行注入。

现在让我们实现MarkdownPanel

在下面的代码片段中,我们可以找到组件实现的所有重要细节:

// ch5/ts/directives/app.ts
@Component({
  selector: 'markdown-panel',
  viewProviders: [Markdown],
  styles: [...],
  template: `
    <div class="panel">
      <div class="panel-title">
        <ng-content select="panel-title"></ng-content>
      </div>
      <div class="panel-content">
        <ng-content select="panel-content"></ng-content>
      </div>
    </div>`
})
class MarkdownPanel {
  constructor(private el: ElementRef, private md: Markdown) {}
  ngAfterContentInit() {
    let el = this.el.nativeElement;
    let title = el.querySelector('panel-title');
    let content = el.querySelector('panel-content');
    title.innerHTML = this.md.toHTML(title.innerHTML);
    content.innerHTML = this.md.toHTML(content.innerHTML);
  }
}

我们使用了markdown-panel选择器并设置了viewProviders属性。在这种情况下,只有一个单一的视图提供程序:Markdown服务的提供程序。通过设置这个属性,我们声明了所有在其中声明的提供程序将可以从组件本身和所有的视图子级中访问。

现在,假设我们有一个名为MarkdownButton的组件,并且我们希望以以下方式将其添加到我们的模板中:

    <markdown-panel>
      <panel-title>### Small title</panel-title>
      <panel-content>
      Some code
      </panel-content>
      <markdown-button>*Click to toggle*</markdown-button>
   </markdown-panel>

Markdown服务将无法被下面使用panel-content元素的MarkdownButton访问;但是,如果我们在组件的模板中使用按钮,它将是可访问的:

@Component({
  selector: 'markdown-panel',
  viewProviders: [Markdown],
  directives: [MarkdownButton],
  styles: […],
  template: `
    <div class="panel">
      <markdown-button>*Click to toggle*</markdown-button>
      <div class="panel-title">
        <ng-content select="panel-title"></ng-content>
      </div>
      <div class="panel-content">
        <ng-content select="panel-content"></ng-content>
      </div>
    </div>`
})

如果我们需要提供程序在所有内容和视图子级中可见,我们只需要将viewProviders属性的属性名更改为providers

你可以在ch5/ts/directives/app.ts目录下的文件中找到这个示例。

使用 ES5 的 Angular DI

我们已经熟练使用 TypeScript 进行 Angular 2 的依赖注入!正如我们所知,我们在开发 Angular 2 应用程序时并不局限于 TypeScript;我们也可以使用 ES5、ES2015 和 ES2016(以及 Dart,但这超出了本书的范围)。

到目前为止,我们在构造函数中使用标准的 TypeScript 类型注释声明了不同类的依赖关系。所有这些类都应该用@Injectable装饰器进行修饰。不幸的是,Angular 2 支持的其他一些语言缺少了一些这些特性。在下表中,我们可以看到 ES5 不支持类型注释、类和装饰器:

ES5 ES2015 ES2016
装饰器 是(没有参数装饰器)
类型注释

在这种情况下,我们如何利用这些语言中的 DI 机制?Angular 2 提供了一个内部 JavaScript领域特定语言DSL),允许我们利用 ES5 的整个框架功能。

现在,让我们将我们在上一节中看到的MarkdownPanel示例从 TypeScript 翻译成 ES5。首先,让我们从Markdown服务开始:

// ch5/es5/simple-example/app.js
var Markdown = ng.core.Class({
  constructor: function () {},
  toHTML: function (md) {
    return markdown.toHTML(md);
  }
});

我们定义了一个名为Markdown的变量,并将其值设置为从调用ng.core.Class返回的结果。这种构造允许我们使用 ES5 模拟 ES2015 类。ng.core.Class方法的参数是一个对象字面量,必须包含constructor函数的定义。因此,ng.core.Class将返回一个 JavaScript 构造函数,其中包含来自对象字面量的constructor的主体。传递参数边界内定义的所有其他方法将被添加到函数的原型中。

一个问题已经解决:我们现在可以在 ES5 中模拟类;还有两个问题没有解决!

现在,让我们看看如何定义MarkdownPanel组件:

// ch5/es5/simple-example/app.js

var MarkdownPanel = ng.core.Component({
  selector: 'markdown-panel',
  viewProviders: [Markdown],
  styles: [...],
  template: '...'
})
.Class({
  constructor: [Markdown, ng.core.ElementRef, function (md, el) {
    this.md = md;
    this.el = el;
  }],
  ngAfterContentInit: function () {
    …
  }
});

从第四章, 使用 Angular 2 组件和指令入门,我们已经熟悉了用于定义组件的 ES5 语法。现在,让我们看一下MarkdownPanel的构造函数,以确保我们如何声明我们组件甚至一般类的依赖关系。

从前面的片段中,我们可以注意到构造函数的值这次不是一个函数,而是一个数组。这可能让你觉得很熟悉,就像在 AngularJS 1.x 中一样,我们可以通过列出它们的名称来声明给定服务的依赖项:

Module.service('UserMapper',
  ['User', '$http', function (User, $http) {
    // …
  }]);

尽管 Angular 2 中的语法类似,但它带来了许多改进。例如,我们不再局限于使用字符串来表示依赖项的标记。

现在,假设我们想将Markdown服务作为可选依赖项。在这种情况下,我们可以通过传递装饰器数组来实现:

…
.Class({
  constructor: [[ng.core.Optional(), Markdown],
    ng.core.ElementRef, function (md, el) {
      this.md = md;
      this.el = el;
    }],
  ngAfterContentInit: function () {
    …
  }
});
…

通过嵌套数组,我们可以应用一系列装饰器:[[ng.core.Optional(), ng.core.Self(), Markdown], ...]。在这个例子中,@Optional@Self装饰器将按指定的顺序向类添加关联的元数据。

尽管使用 ES5 使我们的构建更简单,并允许我们跳过转译的中间步骤,这可能很诱人,但谷歌的建议是利用 TypeScript 的静态类型优势。这样,我们就有了更清晰的语法,更少的输入,更好的语义,并提供了强大的工具。

总结

在本章中,我们介绍了 Angular 2 的 DI 机制。我们简要讨论了在项目中使用依赖注入的优点,通过在框架的上下文中引入它。我们旅程的第二步是如何实例化和配置注入器;我们还解释了注入器的层次结构和已注册提供者的可见性。为了更好地分离关注点,我们提到了如何在指令和组件中注入承载应用程序业务逻辑的服务。我们最后关注的一点是如何使用 ES5 语法与 DI 机制配合使用。

在下一章中,我们将介绍框架的新路由机制。我们将解释如何配置基于组件的路由器,并向我们的应用程序添加多个视图。我们将要涵盖的另一个重要主题是新的表单模块。通过构建一个简单的应用程序,我们将演示如何创建和管理表单。

第六章:使用 Angular 2 路由器和表单

到目前为止,我们已经熟悉了框架的核心。我们知道如何定义组件和指令来开发我们应用程序的视图。我们还知道如何将与业务相关的逻辑封装到服务中,并使用 Angular 2 的依赖注入机制将所有内容连接起来。

在本章中,我们将解释一些概念,这些概念将帮助我们构建真实的 Angular 2 应用程序。它们如下:

  • 框架的基于组件的路由器。

  • 使用 Angular 2 表单。

  • 开发基于模板的表单。

  • 开发自定义表单验证器。

让我们开始吧!

开发“Coders repository”应用程序

在解释前面提到的概念的过程中,我们将开发一个包含开发人员存储库的示例应用程序。在我们开始编码之前,让我们解释一下应用程序的结构。

“Coders repository”将允许其用户通过填写有关他们的详细信息的表单或提供开发人员的 GitHub 句柄并从 GitHub 导入其个人资料来添加开发人员。

注意

为了本章的目的,我们将在内存中存储开发人员的信息,这意味着在刷新页面后,我们将丢失会话期间存储的所有数据。

应用程序将具有以下视图:

  • 所有开发人员的列表。

  • 一个添加或导入新开发人员的视图。

  • 显示给定开发人员详细信息的视图。此视图有两个子视图:

  • 基本详情:显示开发人员的姓名及其 GitHub 头像(如果有)。

  • 高级资料:显示开发人员已知的所有详细信息。

应用程序主页的最终结果将如下所示:

开发“Coders repository”应用程序

图 1

注意

在本章中,我们将只构建列出的视图中的一些。应用程序的其余部分将在第七章中解释,解释管道和与 RESTful 服务通信

每个开发人员将是以下类的实例:

// ch6/ts/multi-page-template-driven/developer.ts
export class Developer {
  public id: number;
  public githubHandle: string;
  public avatarUrl: string;
  public realName: string;
  public email: string;
  public technology: string;
  public popular: boolean;
}

所有开发人员将驻留在DeveloperCollection类中:

// ch6/ts/multi-page-template-driven/developer_collection.ts
class DeveloperCollection {
  private developers: Developer[] = [];
  getUserByGitHubHandle(username: string) {
    return this.developers
            .filter(u => u.githubHandle === username)
            .pop();
  }
  getUserById(id: number) {
    return this.developers
             .filter(u => u.id === id)
             .pop();
  }
  addDeveloper(dev: Developer) {
    this.developers.push(dev);
  }
  getAll() {
    return this.developers;
  }
}

这里提到的类封装了非常简单的逻辑,并没有任何特定于 Angular 2 的内容,因此我们不会深入讨论任何细节。

现在,让我们继续实现,通过探索新的路由器。

探索 Angular 2 路由器

正如我们已经知道的那样,为了引导任何 Angular 2 应用程序,我们需要开发一个根组件。 "Coders repository"应用程序并没有什么不同;在这种特定情况下唯一的额外之处是我们将有多个页面需要使用 Angular 2 路由连接在一起。

让我们从路由器配置所需的导入开始,并在此之后定义根组件:

// ch6/ts/step-0/app.ts
import {
  ROUTER_DIRECTIVES,
  ROUTER_PROVIDERS,
  Route,
  Redirect,
  RouteConfig,
  LocationStrategy,
  HashLocationStrategy
} from 'angular2/router';

在前面的片段中,我们直接从 Angular 2 路由器模块中导入了一些东西,这些东西是在框架的核心之外外部化的。

使用ROUTER_DIRECTIVES,路由器提供了一组常用的指令,我们可以将其添加到根组件使用的指令列表中。这样,我们将能够在模板中使用它们。

导入ROUTE_PROVIDERS包含一组与路由器相关的提供者,例如用于将RouteParams令牌注入组件构造函数的提供者。

RouteParams令牌提供了从路由 URL 中访问参数的能力,以便对给定页面关联的逻辑进行参数化。我们稍后将演示此提供程序的典型用例。

导入LocationStrategy类是一个抽象类,定义了HashLocationStrategy(用于基于哈希的路由)和PathLocationStrategy(利用历史 API 用于基于 HTML5 的路由)之间的公共逻辑。

注意

HashLocationStrategy不支持服务器端渲染。这是因为页面的哈希值不会发送到服务器,因此服务器无法找到与给定页面关联的组件。除了 IE9 之外,所有现代浏览器都支持 HTML5 历史 API。您可以在书的最后一章中找到有关服务器端渲染的更多信息。

我们没有看到的最后导入是RouteConfig,它是一个装饰器,允许我们定义与给定组件关联的路由;以及RouteRedirect,分别允许我们定义单个路由和重定向。使用RouteConfig,我们可以定义一组路由的层次结构,这意味着 Angular 2 的路由器支持嵌套路由,这与其前身 AngularJS 1.x 不同。

定义根组件并引导应用程序

现在,让我们定义一个根组件并配置应用程序的初始引导:

// ch6/ts/step-0/app.ts
@Component({
  selector: 'app',
  template: `…`,
  providers: [DeveloperCollection],
  directives: [ROUTER_DIRECTIVES]
})
@RouteConfig([…])
class App {}

bootstrap(…);

在前面的片段中,您可以注意到一个我们已经熟悉的语法,来自第四章,“开始使用 Angular 2 组件和指令”和第五章,“Angular 2 中的依赖注入”。我们定义了一个带有app选择器的组件,稍后我们将看一下template,以及提供者和指令的集合。

App组件使用了一个名为DeveloperCollection的单个提供者。这是一个包含应用程序存储的所有开发人员的类。您可以注意到我们添加了ROUTER_DIRECTIVES;它包含了 Angular 路由中定义的所有指令的数组。在这个数组中的一些指令允许我们链接到@RouteConfig装饰器中定义的其他路由(routerLink指令),并声明与不同路由相关联的组件应该呈现的位置(router-outlet)。我们将在本节后面解释如何使用它们。

现在让我们来看一下bootstrap函数的调用:

bootstrap(App, [
  ROUTER_PROVIDERS,
  provide(LocationStrategy, { useClass: HashLocationStrategy })
)]);

作为bootstrap的第一个参数,我们像往常一样传递应用程序的根组件。第二个参数是整个应用程序都可以访问的提供者列表。在提供者集中,我们添加了ROUTER_PROVIDERS,并且还配置了LocationStrategy令牌的提供者。Angular 2 使用的默认LocationStrategy令牌是PathLocationStrategy(即基于 HTML5 的令牌)。然而,在这种情况下,我们将使用基于哈希的令牌。

默认位置策略的两个最大优势是它得到了 Angular 2 的服务器渲染模块的支持,并且应用程序的 URL 对最终用户看起来更自然(没有使用#)。另一方面,如果我们使用PathLocationStrategy,我们可能需要配置我们的应用程序服务器,以便正确处理路由。

使用 PathLocationStrategy

如果我们想使用PathLocationStrategy,我们可能需要提供APP_BASE_HREF。例如,在我们的情况下,bootstrap配置应该如下所示:

import {APP_BASE_HREF} from 'angular2/router';
//...
bootstrap(App, [
  ROUTER_PROVIDERS,
  // The following line is optional, since it's
  // the default value for the LocationStrategy token
  provide(LocationStrategy, { useClass: PathLocationStrategy }),
  provide(APP_BASE_HREF, {
    useValue: '/dist/dev/ch6/ts/multi-page-template-driven/'
  }
)]);

默认情况下,与APP_BASE_HREF令牌关联的值是/;它表示应用程序内的基本路径名称。例如,在我们的情况下,“Coders repository”将位于/ch6/ts/multi-page-template-driven/目录下(即http://localhost:5555/dist/dev/ch6/ts/multi-page-template-driven/)。

使用@RouteConfig 配置路由

作为下一步,让我们来看看放置在@RouteConfig装饰器中的路由声明。

// ch6/ts/step-0/app.ts
@Component(…)
@RouteConfig([
  new Route({ component: Home, name: 'Home', path: '/' }),
  new Route({
    component: AddDeveloper,
    name: 'AddDeveloper',
    path: '/dev-add'
  }),
  //…
  new Redirect({
    path: '/add-dev',
    redirectTo: ['/dev-add']
  })
]) 
class App {}

正如前面的片段所示,@RouteConfig装饰器接受一个路由数组作为参数。在这个例子中,我们定义了两种类型的路由:使用RouteRedirect类。它们分别用于定义应用程序中的路由和重定向。

每个路由必须定义以下属性:

  • component:与给定路由相关联的组件。

  • name:用于在模板中引用的路由名称。

  • path:用于路由的路径。它将显示在浏览器的位置栏中。

注意

Route类还支持一个数据属性,其值可以通过使用RouteData令牌注入到其关联组件的构造函数中。数据属性的一个示例用例可能是,如果我们想要根据包含@RouteConfig声明的父组件的类型来注入不同的配置对象。

另一方面,重定向只包含两个属性:

  • path:用于重定向的路径。

  • redirectTo:用户被重定向到的路径。

在前面的例子中,我们声明希望用户打开路径/add-dev的页面被重定向到['/dev-add']

现在,为了使一切正常运行,我们需要定义AddDeveloperHome组件,这些组件在@RouteConfig中被引用。最初,我们将提供一个基本的实现,随着章节的进行逐步扩展。在ch6/ts/step-0中,创建一个名为home.ts的文件,并输入以下内容:

import {Component} from 'angular2/core';
@Component({
  selector: 'home',
  template: `Home`
})
export class Home {}

不要忘记在app.ts中导入Home组件。现在,打开名为add_developer.ts的文件,并输入以下内容:

import {Component} from 'angular2/core';

@Component({
  selector: 'dev-add',
  template: `Add developer`
})
export class AddDeveloper {}

我们已经声明了路由和与各个路由相关联的所有组件。唯一剩下的就是定义根App组件的模板,以便将所有内容链接在一起。

将以下内容添加到ch6/ts/step-0/app.ts@Component装饰器内的template属性中:

@Component({
  //…
  template: `
    <nav class="navbar navbar-default">
      <ul class="nav navbar-nav">
        <li><a [routerLink]="['/Home']">Home</a></li>
        <li><a [routerLink]="['/AddDeveloper']">Add developer</a></li>
      </ul>
    </nav>
    <router-outlet></router-outlet>
  `,
  //…
})

在上面的模板中有两个特定于 Angular 2 的指令:

  • routerLink:这允许我们添加到特定路由的链接。

  • router-outlet:这定义了当前选定路由相关的组件需要被渲染的容器。

让我们来看一下routerLink指令。它接受一个路由名称和参数的数组作为值。在我们的例子中,我们只提供了一个以斜杠为前缀的单个路由名称(因为这个路由在根级别)。注意,routerLink使用的路由名称是在@RouteConfig内部的路由声明的name属性声明的。在本章的后面,我们将看到如何链接到嵌套路由并传递路由参数。

这个指令允许我们独立于我们配置的LocationStrategy来声明链接。例如,假设我们正在使用HashLocationStrategy;这意味着我们需要在模板中的所有路由前加上#。如果我们切换到PathLocationStrategy,我们就需要移除所有的哈希前缀。routerLink的另一个巨大好处是它对我们透明地使用 HTML5 历史推送 API,这样就可以节省我们大量的样板代码。

上一个模板中的下一个对我们新的指令是router-outlet。它的责任类似于 AngularJS 1.x 中的ng-view指令。基本上,它们都有相同的作用:指出target组件应该被渲染的位置。这意味着根据定义,当用户导航到/时,Home组件将在router-outlet指出的位置被渲染,当用户导航到/dev-add时,AddDeveloper组件也是一样。

现在我们有这两条路线已经在运行了!打开http://localhost:5555/dist/dev/ch6/ts/step-0/,你应该会看到以下的截图:

使用 routerLink 和 router-outlet

图 2

如果没有,请看一下ch6/ts/step-1,里面包含了最终结果。

使用 AsyncRoute 进行懒加载

AngularJS 1.x 模块允许我们将应用程序中逻辑相关的单元分组在一起。然而,默认情况下,它们需要在初始应用程序的bootstrap期间可用,并且不允许延迟加载。这要求在初始页面加载期间下载整个应用程序的代码库,对于大型单页应用程序来说,这可能是无法接受的性能损失。

在一个完美的场景中,我们希望只加载与用户当前浏览页面相关的代码,或者根据与用户行为相关的启发式预取捆绑模块,这超出了本书的范围。例如,从我们示例的第一步打开应用程序:http://localhost:5555/dist/dev/ch6/ts/step-1/。一旦用户在/,我们只需要Home组件可用,一旦他或她导航到/dev-add,我们希望加载AddDeveloper组件。

让我们在 Chrome DevTools 中检查实际发生了什么:

使用 AsyncRoute 进行延迟加载

图 3

我们可以注意到在初始页面加载期间,我们下载了与所有路由相关的组件,甚至不需要的AddDeveloper。这是因为在app.ts中,我们明确要求HomeAddDeveloper组件,并在@RouteConfig声明中使用它们。

在这种特定情况下,加载这两个组件可能看起来不像是一个大问题,因为在这一步,它们非常简单,没有任何依赖关系。然而,在现实生活中的应用程序中,它们将导入其他指令、组件、管道、服务,甚至第三方库。一旦需要任何组件,它的整个依赖图将被下载,即使在那一点上并不需要该组件。

Angular 2 的路由器提供了解决这个问题的解决方案。我们只需要从angular2/router模块中导入AsyncRoute类,并在@RouteConfig中使用它,而不是使用Route

// ch6/ts/step-1-async/app.ts

import {AsyncRoute} from 'angular2/router';
@Component(…)
@RouteConfig([
  new AsyncRoute({
    loader: () =>
      System.import('./home')
        .then(m => m.Home),
      name: 'Home',
      path: '/'
    }),
  new AsyncRoute({
    loader: () =>
      System.import('./add_developer')
        .then(m => m.AddDeveloper),
      name: 'AddDeveloper',
      path: '/dev-add'
    }),
    new Redirect({ path: '/add-dev', redirectTo: ['/dev-add'] })
])
class App {}

AsyncRoute类的构造函数接受一个对象作为参数,该对象具有以下属性:

  • loader:返回一个需要用与给定路由相关联的组件解析的 promise 的函数。

  • name:路由的名称,可以在模板中使用它(通常在routerLink指令内部)。

  • path:路由的路径。

一旦用户导航到与@RouteConfig装饰器中的任何异步路由定义匹配的路由,其关联的加载程序将被调用。当加载程序返回的 promise 被解析为目标组件的值时,该组件将被缓存和渲染。下次用户导航到相同的路由时,将使用缓存的组件,因此路由模块不会下载相同的组件两次。

注意

请注意,前面的示例使用了 System,但是 Angular 的AsyncRoute实现并不与任何特定的模块加载器耦合。例如,可以使用 require.js 实现相同的结果。

使用 Angular 2 表单

现在让我们继续实现应用程序。在下一步中,我们将在AddDeveloperHome组件上工作。您可以通过扩展ch6/ts/step-0中当前的内容继续实现,或者如果您还没有达到步骤 1,您可以继续在ch6/ts/step-1中的文件上工作。

Angular 2 提供了两种开发带有验证的表单的方式:

  • 基于模板驱动的方法:提供了一个声明性的 API,我们可以在组件的模板中声明验证。

  • 基于模型驱动的方法:使用FormBuilder提供了一个命令式的 API。

在下一章中,我们将探讨两种方法。让我们从模板驱动的方法开始。

开发模板驱动的表单

对于每个CRUD创建检索更新和删除)应用程序,表单都是必不可少的。在我们的情况下,我们想要为输入我们想要存储的开发者的详细信息构建一个表单。

在本节结束时,我们将拥有一个表单,允许我们输入给定开发者的真实姓名,添加他或她喜欢的技术,输入电子邮件,并声明他或她是否在社区中受欢迎。最终结果将如下所示:

Developing template-driven forms

图 4

将以下导入添加到add_developer.ts

import {
  FORM_DIRECTIVES,
  FORM_PROVIDERS
} from 'angular2/common;

我们需要做的下一件事是将FORM_DIRECTIVES添加到AddDeveloper组件使用的指令列表中。FORM_DIRECTIVES指令包含一组预定义指令,用于管理 Angular 2 表单,例如formngModel指令。

FORM_PROVIDERS是一个包含一组预定义提供程序的数组,我们可以在应用程序的类中使用它们的令牌来注入与其关联的值。

现在将AddDeveloper的实现更新为以下内容:

@Component({
  selector: 'dev-add',
  templateUrl: './add_developer.html',
  styles: […],
  directives: [FORM_DIRECTIVES],
  providers: [FORM_PROVIDERS]
})
export class AddDeveloper {
  developer = new Developer();
  errorMessage: string;
  successMessage: string;
  submitted = false;
  technologies: string[] = [
    'JavaScript',
    'C',
    'C#',
    'Clojure'
  ];
  constructor(private developers: DeveloperCollection) {}
  addDeveloper() {}
}

developer属性包含与当前要添加到表单中的开发者相关的信息。最后两个属性,errorMessagesuccessMessage,分别用于在成功将开发者成功添加到开发者集合中或发生错误时显示当前表单的错误或成功消息。

深入研究模板驱动表单的标记

作为下一步,让我们创建AddDeveloper组件的模板(step-1/add_developer.html)。将以下内容添加到文件中:

<span *ngIf="errorMessage"
       class="alert alert-danger">{{errorMessage}}</span>
<span *ngIf="successMessage"
       class="alert alert-success">{{successMessage}}</span>

这两个元素旨在在添加新开发人员时显示错误和成功消息。当errorMessagesuccessMessage分别具有非假值时(即,与空字符串、falseundefined0NaNnull不同的值),它们将可见。

现在让我们开发实际的表单:

<form #f="ngForm" (ngSubmit)="addDeveloper()"
      class="form col-md-4" [hidden]="submitted">
  <div class="form-group">
    <label class="control-label"
           for="realNameInput">Real name</label>
    <div>
      <input id="realNameInput" class="form-control"
             type="text" ngControl="realName" required
             [(ngModel)]="developer.realName">
    </div>
  </div>
  <button class="btn btn-default"
          type="submit" [disabled]="!f.form.valid">Add</button>
  <!-- MORE CODE TO BE ADDED -->
</form> 

我们使用 HTML 的form标签声明一个新的表单。一旦 Angular 2 在父组件的模板中找到带有包含表单指令的这样的标签,它将自动增强其功能,以便用作 Angular 表单。一旦表单被 Angular 处理,我们可以应用表单验证和数据绑定。之后,使用#f="ngForm",我们将为模板定义一个名为f的局部变量,这允许我们引用当前的表单。表单元素中剩下的最后一件事是提交事件处理程序。我们使用一个我们已经熟悉的语法(ngSubmit)="expr",在这种情况下,表达式的值是附加到组件控制器的addDeveloper方法的调用。

现在,让我们来看一下类名为control-groupdiv元素。

注意

请注意,这不是一个特定于 Angular 的类;这是 Bootstrap 定义的一个CSS类,我们使用它来提供表单更好的外观和感觉。

在其中,我们可以找到一个没有任何 Angular 特定标记的label元素和一个允许我们设置当前开发人员的真实姓名的输入元素。我们将控件设置为文本类型,并声明其标识符等于realNameInputrequired属性由 HTML5 规范定义,并用于验证。通过在元素上使用它,我们声明这个元素需要有一个值。虽然这个属性不是特定于 Angular 的,但使用ngControl属性,Angular 将通过包含验证行为来扩展required属性的语义。这种行为包括在控件状态改变时设置特定的CSS类,并管理框架内部保持的状态。

ngControl指令是NgControlName指令的选择器。它通过在值更改时对它们运行验证并在控件生命周期期间应用特定类来增强表单控件的行为。您可能熟悉这一点,因为在 AngularJS 1.x 中,表单控件在其生命周期的特定阶段装饰有ng-pristineng-invalidng-valid类等。

以下表总结了框架在表单控件生命周期中添加的CSS类:

描述
ng-untouched 控件尚未被访问
ng-touched 控件已被访问
ng-pristine 控件的值尚未更改
ng-dirty 控件的值已更改
ng-valid 控件附加的所有验证器都返回true
ng-invalid 控件附加的任何验证器具有false

根据这个表,我们可以定义我们希望所有具有无效值的输入控件以以下方式具有红色边框:

input.ng-dirty.ng-invalid {
  border: 1px solid red;
}

在 Angular 2 的上下文中,前面的CSS的确切语义是对所有已更改且根据附加到它们的验证器无效的输入元素使用红色边框。

现在,让我们探讨如何将不同的验证行为附加到我们的控件上。

使用内置表单验证器

我们已经看到,我们可以使用required属性来改变任何控件的验证行为。Angular 2 提供了另外两个内置验证器,如下所示:

  • minlength:允许我们指定给定控件应具有的值的最小长度。

  • maxlength:允许我们指定给定控件应具有的值的最大长度。

这些验证器是用 Angular 2 指令定义的,可以以以下方式使用:

<input id="realNameInput" class="form-control"
       type="text" ngControl="realName"
       minlength="2"
       maxlength="30">

通过这种方式,我们指定希望输入的值在230个字符之间。

定义自定义控件验证器

Developer类中定义的另一个数据属性是email字段。让我们为这个属性添加一个输入字段。在前面表单的按钮上方,添加以下标记:

<div class="form-group">
  <label class="control-label" for="emailInput">Email</label>
  <div>
    <input id="emailInput"
           class="form-control"
           type="text" ngControl="email"
     [(ngModel)]="developer.email"/>
  </div>
</div>

我们可以将[(ngModel)]属性视为 AngularJS 1.x 中ng-model指令的替代方法。我们将在使用 Angular 2 进行双向数据绑定部分详细解释它。

尽管 Angular 2 提供了一组预定义的验证器,但它们并不足以满足我们的数据可能存在的各种格式。有时,我们需要为特定于应用程序的数据定义自定义验证逻辑。例如,在这种情况下,我们想要定义一个电子邮件验证器。一个典型的正则表达式,在一般情况下有效(但并不涵盖定义电子邮件地址格式的整个规范),如下所示:/^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/

ch6/ts/step-1/add_developer.ts中,定义一个函数,该函数接受 Angular 2 控件的实例作为参数,并在控件的值为空或与前面提到的正则表达式匹配时返回null,否则返回{ 'invalidEmail': true }

function validateEmail(emailControl) {
  if (!emailControl.value ||
    /^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/.test(emailControl.value)) {
    return null;
  } else {
    return { 'invalidEmail': true };
  }
}

现在,从模块angular2/commonangular2/core导入NG_VALIDATORSDirective,并将此验证函数包装在以下指令中:

@Directive({
  selector: '[email-input]',
  providers: [provide(NG_VALIDATORS, {
    useValue: validateEmail, multi: true
  })]
})
class EmailValidator {}

在上述代码中,我们为令牌NG_VALIDATORS定义了一个多提供者。一旦我们注入与该令牌关联的值,我们将获得一个包含所有附加到给定控件的验证器的数组(有关多提供者的部分,请参阅第五章, Angular 2 中的依赖注入)。

使我们的自定义验证工作的唯一两个步骤是首先将email-input属性添加到电子邮件控件中:

<input id="emailInput"
   class="form-control"
 **email-input**
   type="text" ngControl="email"
   [(ngModel)]="developer.email"/>

接下来,将指令添加到组件AddDeveloper指令使用的列表中:

@Component({
  selector: 'dev-add',
  templateUrl: './add_developer.html',
  styles: [`
    input.ng-touched.ng-invalid {
      border: 1px solid red;
    }
  `],
  directives: [FORM_DIRECTIVES, **EmailValidator**],
  providers: [FORM_PROVIDERS]
})
class AddDeveloper {…}

注意

我们正在使用AddDeveloper控件的外部模板。关于给定模板是否应该被外部化或内联在具有templateUrltemplate的组件中,没有最终答案。最佳实践规定,我们应该内联短模板并外部化较长的模板,但没有具体定义哪些模板被认为是短的,哪些是长的。模板应该内联还是放入外部文件的决定取决于开发人员的个人偏好或组织内的常见惯例。

使用 Angular 与选择输入

作为下一步,我们应该允许应用程序的用户输入开发人员最精通的技术。我们可以定义一个技术列表,并在表单中显示为选择输入。

AddDeveloper类中,添加technologies属性:

class AddDeveloper {
  …
  technologies: string[] = [
    'JavaScript',
    'C',
    'C#',
    'Clojure'
  ];
  …
}

现在在模板中,在submit按钮的上方,添加以下标记:

<div class="form-group">
  <label class="control-label"
         for="technologyInput">Technology</label>
  <div>
    <select class="form-control"
            ngControl="technology" required
            [(ngModel)]="developer.technology">
        <option *ngFor="#t of technologies"
                [value]="t">{{t}}</option>
    </select>
  </div>
</div>

就像我们之前声明的输入元素一样,Angular 2 将根据选择输入的状态添加相同的类。为了在选择元素的值无效时显示红色边框,我们需要修改CSS规则:

@Component({
  …
  styles: [
    `input.ng-touched.ng-invalid,
     select.ng-touched.ng-invalid {
      border: 1px solid red;
    }`
  ],
  …
})
class AddDeveloper {…}

注意

注意,将所有样式内联到组件声明中可能是一种不好的做法,因为这样它们就无法重复使用。我们可以将所有组件中的通用样式提取到单独的文件中。@Component装饰器有一个名为styleUrls的属性,类型为array,我们可以在其中添加对给定组件使用的提取样式的引用。这样,如果需要,我们可以仅内联特定于组件的样式。

在此之后,我们将使用ngControl="technology"声明控件的名称等于"technology"。通过使用required属性,我们将声明应用程序的用户必须指定当前开发人员精通的技术。让我们最后一次跳过[(ngModel)]属性,看看如何定义选择元素的选项。

select元素内部,我们将使用以下方式定义不同的选项:

<option *ngFor="#t of technologies"
        [value]="t">{{t}}</option>

这是我们已经熟悉的语法。我们将简单地遍历AddDeveloper类中定义的所有技术,并对于每种技术,我们将显示一个值为技术名称的选项元素。

使用 NgForm 指令

我们已经提到,表单指令通过添加一些额外的 Angular 2 特定逻辑来增强 HTML5 表单的行为。现在,让我们退一步,看看包围输入元素的表单:

<form #f="ngForm" (ngSubmit)="addDeveloper()"
      class="form col-md-4" [hidden]="submitted">
  …
</form>

在上面的片段中,我们定义了一个名为f的新标识符,它引用了表单。我们可以将表单视为控件的组合;我们可以通过表单的 controls 属性访问各个控件。此外,表单还具有toucheduntouchedpristinedirtyinvalidvalid属性,这些属性取决于表单中定义的各个控件。例如,如果表单中的控件都没有被触摸过,那么表单本身的状态就是 untouched。然而,如果表单中的任何控件至少被触摸过一次,那么表单的状态也将是 touched。同样,只有当表单中的所有控件都有效时,表单才会有效。

为了说明form元素的用法,让我们定义一个带有选择器control-errors的组件,该组件显示给定控件的当前错误。我们可以这样使用它:

<label class="control-label" for="realNameInput">Real name</label>
<div>
  <input id="realNameInput" class="form-control" type="text"
     ngControl="realName" [(ngModel)]="developer.realName"
         required maxlength="50">
  <control-errors control="realName"
    [errors]="{
      'required': 'Real name is required',
      'maxlength': 'The maximum length of the real name is 50 characters'
      }"
   />
</div>

请注意,我们还向realName控件添加了maxlength验证器。

control-errors元素具有以下属性:

  • control:声明我们想要显示错误的控件的名称。

  • errors:创建控制错误和错误消息之间的映射。

现在在add_developer.ts中添加以下导入:

import {NgControl, NgForm} from 'angular2/common';
import {Host} from 'angular2/core';

在这些导入中,NgControl类是表示单个表单组件的抽象类,NgForm表示 Angular 表单,Host是与依赖注入机制相关的参数装饰器,我们已经在第五章中介绍过,Angular 2 中的依赖注入

以下是组件定义的一部分:

@Component({
  template: '<div>{{currentError}}</div>',
  selector: 'control-errors',
  inputs: ['control', 'errors']
})
class ControlErrors {
  errors: Object;
  control: string;
  constructor(@Host() private formDir: NgForm) {}
  get currentError() {…}
}

ControlErrors组件定义了两个输入:control——使用ngControl指令声明的控件的名称(ngControl属性的值)——和errors——错误和错误消息之间的映射。它们可以分别由control-errors元素的controlerrors属性指定。

例如,如果我们有控件:

<input type="text" ngControl="foobar" required />

我们可以通过以下方式声明其关联的control-errors组件:

<control-errors control="foobar"
      [errors]="{
       'required': 'The value of foobar is required'
      }"></control-errors>

在上面片段中的currentError getter 中,我们需要做以下两件事:

  • 找到使用control属性声明的组件的引用。

  • 返回与使当前控件无效的任何错误相关联的错误消息。

以下是实现此行为的代码片段:

@Component(…)
class ControlErrors {
  …
  get currentError() {
    let control = this.formDir.controls[this.control];
    let errorsMessages = [];
    if (control && control.touched) {
      errorsMessages = Object.keys(this.errors)
        .map(k => control.hasError(k) ? this.errors[k] : null)
        .filter(error => !!error);
    }
    return errorsMessages.pop();
  }
}

currentError的实现的第一行中,我们使用注入表单的controls属性获取目标控件。它的类型是{[key: string]: AbstractControl},其中键是我们用ngControl指令声明的控件的名称。一旦我们获得了目标控件的实例引用,我们可以检查它的状态是否被触摸(即是否已聚焦),如果是,我们可以循环遍历ControlError实例的errors属性中的所有错误。map函数将返回一个包含错误消息或null值的数组。唯一剩下的事情就是过滤掉所有的null值,并且只获取错误消息。一旦我们获得了每个错误的错误消息,我们将通过从errorMessages数组中弹出它来返回最后一个。

最终结果应如下所示:

使用 NgForm 指令

图 5

如果在实现ControlErrors组件的过程中遇到任何问题,您可以查看ch6/ts/multi-page-template-driven/add_developer.ts中的实现。

每个控件的hasError方法接受一个错误消息标识符作为参数,该标识符由验证器定义。例如,在前面定义自定义电子邮件验证器的示例中,当输入控件具有无效值时,我们将返回以下对象字面量:{ 'invalidEmail': true }。如果我们将ControlErrors组件应用于电子邮件控件,则其声明应如下所示:

  <control-errors control="email"
    [errors]="{ 'invalidEmail': 'Invalid email address' }"/>

Angular 2 的双向数据绑定

关于 Angular 2 最著名的传言之一是,双向数据绑定功能被移除,因为强制的单向数据流。这并不完全正确;Angular 2 的表单模块实现了一个带有选择器[(ngModel)]的指令,它允许我们轻松地实现双向数据绑定——从视图到模型,以及从模型到视图。

让我们来看一个简单的组件:

// ch6/ts/simple-two-way-data-binding/app.ts

import {Component} from 'angular2/core';
import {bootstrap} from 'angular2/platform/browser';
import {NgModel} from 'angular2/common';

@Component({
  selector: 'app',
  directives: [NgModel],
  template: `
    <input type="text" [(ngModel)]="name"/>
    <div>{{name}}</div>
  `,
})
class App {
  name: string;
}

bootstrap(App, []);

在上面的示例中,我们从angular2/common包中导入了指令NgModel。稍后,在模板中,我们将属性[(ngModel)]设置为值name

起初,语法[(ngModel)]可能看起来有点不寻常。从第四章使用 Angular 2 组件和指令入门中,我们知道语法(eventName)用于绑定由给定组件触发的事件(或输出)。另一方面,我们使用语法[propertyName]="foobar"通过将属性(或在 Angular 2 组件术语中的输入)的值设置为表达式foobar的评估结果来实现单向数据绑定。NgModel语法将两者结合起来,以实现双向数据绑定。这就是为什么我们可以将其视为一种语法糖,而不是一个新概念。与 AngularJS 1.x 相比,这种语法的主要优势之一是我们可以通过查看模板来判断哪些绑定是单向的,哪些是双向的。

注意

就像(click)有其规范语法on-click[propertyName]bind-propertyName一样,[(ngModel)]的替代语法是bindon-ngModel

如果你打开http://localhost:5555/dist/dev/ch6/ts/simple-two-way-data-binding/,你会看到以下结果:

使用 Angular 2 进行双向数据绑定

图 6

一旦你改变输入框的值,它将自动更新以下标签。

我们已经在前面的模板中使用了NgModel指令。例如,我们绑定了开发人员的电子邮件:

<input id="emailInput"
       class="form-control" type="text"
       ngControl="email" [(ngModel)]="developer.email"
       email-input/>

这样,一旦我们改变文本输入的值,附加到AddDeveloper组件实例的开发人员对象的电子邮件属性的值就会被更新。

存储表单数据

让我们再次查看AddDeveloper组件控制器的接口:

export class AddDeveloper {
  submitted: false;
  successMessage: string;
  developer = new Developer();
  //…
  constructor(private developers: DeveloperCollection) {}
  addDeveloper(form) {…}
}

它有一个Developer类型的字段,我们使用NgModel指令将表单控件绑定到其属性。该类还有一个名为addDeveloper的方法,该方法在表单提交时被调用。我们通过绑定submit事件来声明这一点:

<!-- ch6/ts/multi-page-template-driven/add_developer.html -->
<form #f="form" (ngSubmit)="addDeveloper()"
      class="form col-md-4" [hidden]="submitted">
  …
  <button class="btn btn-default"
      type="submit" [disabled]="!f.form.valid">Add</button>
</form>

在上面的片段中,我们可以注意到两件事。我们使用#f="ngForm"引用了表单,并将按钮的 disabled 属性绑定到表达式!f.form.valid。我们已经在前一节中描述了NgForm控件;一旦表单中的所有控件都具有有效值,其 valid 属性将为 true。

现在,假设我们已经为表单中的所有输入控件输入了有效值。这意味着其submit按钮将被启用。一旦我们按下Enter或点击Add按钮,将调用addDeveloper方法。以下是此方法的示例实现:

class AddDeveloper {
  //…
addDeveloper() {
    this.developer.id = this.developers.getAll().length + 1;
    this.developers.addDeveloper(this.developer);
    this.successMessage = `Developer ${this.developer.realName} was successfully added`;
    this.submitted = true;
  }

最初,我们将当前开发人员的id属性设置为DeveloperCollection中开发人员总数加一。稍后,我们将开发人员添加到集合中,并设置successMessage属性的值。就在这之后,我们将提交属性设置为true,这将导致隐藏表单。

列出所有存储的开发人员

现在我们可以向开发人员集合添加新条目了,让我们在“Coders repository”的首页上显示所有开发人员的列表。

打开文件ch6/ts/step-1/home.ts并输入以下内容:

import {Component} from 'angular2/core';
import {DeveloperCollection} from './developer_collection';

@Component({
  selector: 'home',
  templateUrl: './home.html'
})
export class Home {
  constructor(private developers: DeveloperCollection) {}
  getDevelopers() {
    return this.developers.getAll();
  }
}

这对我们来说并不新鲜。我们通过提供外部模板并实现getDevelopers方法来扩展Home组件的功能,该方法将其调用委托给构造函数中注入的DeveloperCollection实例。

模板本身也是我们已经熟悉的东西:

<table class="table" *ngIf="getDevelopers().length > 0">
  <thead>
    <th>Email</th>
    <th>Real name</th>
    <th>Technology</th>
    <th>Popular</th>
  </thead>
  <tr *ngFor="#dev of getDevelopers()">
    <td>{{dev.email}}</td>
    <td>{{dev.realName}}</td>
    <td>{{dev.technology}}</td>
    <td [ngSwitch]="dev.popular">
      <span *ngSwitchWhen="true">Yes</span>
      <span *ngSwitchWhen="false">Not yet</span>
    </td>
  </tr>
</table>
<div *ngIf="getDevelopers().length == 0">
  There are no any developers yet
</div>

我们将所有开发人员列为 HTML 表格中的行。对于每个开发人员,我们检查其 popular 标志的状态。如果其值为true,那么在Popular列中,我们显示一个带有文本Yes的 span,否则我们将文本设置为No

当您在添加开发人员页面输入了一些开发人员,然后导航到主页时,您应该看到类似以下截图的结果:

列出所有存储的开发人员

图 7

注意

您可以在ch6/ts/multi-page-template-driven找到应用程序的完整功能。

摘要

到目前为止,我们已经解释了 Angular 2 中路由的基础知识。我们看了一下如何定义不同的路由,并实现与它们相关的组件,这些组件在路由更改时显示出来。为了链接到不同的路由,我们解释了routerLink,并且我们还使用了router-outlet指令来指出与各个路由相关的组件应该被渲染的位置。

我们还研究了 Angular 2 表单功能,包括内置和自定义验证。之后,我们解释了NgModel指令,它为我们提供了双向数据绑定。

在下一章中,我们将介绍如何开发基于模型的表单和子路由以及参数化路由,使用Http模块进行 RESTful 调用,并使用自定义管道转换数据。

第七章:解释管道和与 RESTful 服务通信

在上一章中,我们介绍了框架的一些非常强大的功能。然而,我们可以更深入地了解 Angular 的表单模块和路由器的功能。在接下来的章节中,我们将解释如何:

  • 开发模型驱动的表单。

  • 定义参数化路由。

  • 定义子路由。

  • 使用Http模块与 RESTful API 进行通信。

  • 使用自定义管道转换数据。

我们将在扩展“Coders repository”应用程序的功能过程中探索所有这些概念。在上一章的开头,我们提到我们将允许从 GitHub 导入开发者。但在我们实现这个功能之前,让我们扩展表单的功能。

在 Angular 2 中开发模型驱动的表单

这些将是完成“Coders repository”最后的步骤。您可以在ch6/ts/step-1/(或ch6/ts/step-2,具体取决于您之前的工作)的基础上构建,以便使用我们将要介绍的新概念扩展应用程序的功能。完整的示例位于ch7/ts/multi-page-model-driven

这是我们在本节结束时要实现的结果:

在 Angular 2 中开发模型驱动的表单

在上面的截图中,有以下两种表单:

  • 一个用于从 GitHub 导入现有用户的表单,其中包含:

  • GitHub 句柄的输入。

  • 一个指出我们是否要从 GitHub 导入开发者或手动输入的复选框。

  • 一个用于手动输入新用户的表单。

第二种形式看起来与我们在上一节中完成的方式完全一样。然而,这一次,它的定义看起来有点不同:

<form class="form col-md-4"
      [ngFormModel]="addDevForm" [hidden]="submitted">
  <!-- TODO -->
</form>

请注意,这一次,我们没有submit处理程序或#f="ngForm"属性。相反,我们使用[ngFormModel]属性来绑定到组件控制器内定义的属性。通过使用这个属性,我们可以绑定到一个叫做ControlGroup的东西。正如其名称所示,ControlGroup类包括一组控件以及与它们关联的验证规则集。

我们需要使用类似的声明来导入开发者表单。然而,这一次,我们将提供不同的[ngFormModel]属性值,因为我们将在组件控制器中定义一个不同的控件组。将以下片段放在我们之前介绍的表单上方:

<form class="form col-md-4"
   [ngFormModel]="importDevForm" [hidden]="submitted">
<!-- TODO -->
</form>

现在,让我们在组件的控制器中声明importDevFormaddDevForm属性:

import {ControlGroup} from 'angular2/common';
@Component(…)
export class AddDeveloper {
  importDevForm: ControlGroup;
  addDevForm: ControlGroup;
  …
  constructor(private developers: DeveloperCollection,
    fb: FormBuilder) {…}
  addDeveloper() {…}
}

最初,我们从angular2模块中导入了ControlGroup类,然后在控制器中声明了所需的属性。让我们还注意到AddDeveloper的构造函数有一个额外的参数叫做fb,类型为FormBuilder

FormBuilder提供了一个可编程的 API,用于定义ControlGroups,在这里我们可以为组中的每个控件附加验证行为。让我们使用FormBulder实例来初始化importDevFormaddDevForm属性:

…
constructor(private developers: DeveloperCollection,
  fb: FormBuilder) {
  this.importDevForm = fb.group({
    githubHandle: ['', Validators.required],
    fetchFromGitHub: [false]
  });
  this.addDevForm = fb.group({
    realName: ['', Validators.required],
    email: ['', validateEmail],
    technology: ['', Validators.required],
    popular: [false]
  });
}
…

FormBuilder实例有一个名为group的方法,允许我们定义给定表单中各个控件的默认值和验证器等属性。

根据前面的片段,importDevForm有两个我们之前介绍的字段:githubHandlefetchFromGitHub。我们声明githubHandle控件的值是必填的,并将fetchFromGitHub控件的默认值设置为false

在第二个表单addDevForm中,我们声明了四个控件。对于realName控件的默认值,我们将其设置为空字符串,并使用Validators.requred来引入验证行为(这正是我们为githubHandle控件所做的)。作为电子邮件输入的验证器,我们将使用validateEmail函数,并将其初始值设置为空字符串。用于验证的validateEmail函数是我们在上一章中定义的:

function validateEmail(emailControl) {
  if (!emailControl.value ||
     /^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/.test(emailControl.value)) {
    return null;
  } else {
    return { 'invalidEmail': true };
  }
}

我们在这里定义的最后两个控件是technology控件,其值是必填的,初始值为空字符串,以及popular控件,其初始值设置为false

使用控件验证器的组合

我们看了一下如何将单个验证器应用于表单控件。然而,在一些应用程序中,领域可能需要更复杂的验证逻辑。例如,如果我们想要将必填和validateEmail验证器都应用于电子邮件控件,我们应该这样做:

this.addDevForm = fb.group({
  …
  email: ['', Validators.compose([
    Validators.required,
    validateEmail]
  )],
  …
});

Validators对象的compose方法接受一个验证器数组作为参数,并返回一个新的验证器。新的验证器的行为将是由作为参数传递的各个验证器中定义的逻辑组成,并且它们将按照它们在数组中被引入的顺序应用。

传递给group方法的对象文字的属性名称应与我们在模板中为输入设置的ngControl属性的值相匹配。

这是importDevForm的完整模板:

<form class="form col-md-4"
   [ngFormModel]="importDevForm" [hidden]="submitted" >
  <div class="form-group">
    <label class="control-label"
           for="githubHandleInput">GitHub handle</label>
    <div>
      <input id="githubHandleInput"
             class="form-control" type="text"
             [disabled]="!fetchFromGitHub" 
             ngControl="githubHandle">
      <control-errors control="githubHandle"
        [errors]="{
          'required': 'The GitHub handle is required'
        }"></control-errors>
    </div>
  </div>
  <div class="form-group">
    <label class="control-label"
           for="fetchFromGitHubCheckbox">
       Fetch from GitHub
    </label>
    <input class="checkbox-inline" id="fetchFromGitHubCheckbox"
           type="checkbox" ngControl="fetchFromGitHub"
           [(ngModel)]="fetchFromGitHub">
  </div>
</form>

在前面的模板中,您可以注意到一旦提交的标志具有值true,表单将对用户隐藏。在第一个输入元素旁边,我们将ngControl属性的值设置为githubHandle

注意

请注意,给定输入元素的ngControl属性的值必须与我们在组件控制器中的ControlGroup定义中用于相应控件声明的名称相匹配。

关于githubHandle控件,我们还将disabled属性设置为等于表达式评估的结果:!fetchFromGitHub。这样,当fetchFromGitHub复选框未被选中时,githubHandle控件将被禁用。类似地,在前几节的示例中,我们使用了先前定义的ControlErrors组件。这次,我们设置了一个带有消息GitHub 句柄是必需的的单个错误。

addDevForm表单的标记看起来非常相似,因此我们不会在这里详细描述它。如果您对如何开发它的方法不是完全确定,可以查看ch7/ts/multi-page-model-driven/add_developer.html中的完整实现。

我们要查看的模板的最后部分是Submit按钮:

<button class="btn btn-default"
        (click)="addDeveloper()"
        [disabled]="(fetchFromGitHub && !importDevForm.valid) ||
                    (!fetchFromGitHub && !addDevForm.valid)">
  Add
</button>

单击按钮将调用组件控制器中定义的addDeveloper方法。在[disabled]属性的值设置为的表达式中,我们最初通过使用与复选框绑定的属性的值来检查选择了哪种表单,也就是说,我们验证用户是否想要添加新开发人员或从 GitHub 导入现有开发人员。如果选择了第一个选项(即,如果复选框未被选中),我们将验证添加新开发人员的ControlGroup是否有效。如果有效,则按钮将启用,否则将禁用。当用户选中复选框以从 GitHub 导入开发人员时,我们也会执行相同的操作。

探索 Angular 的 HTTP 模块

现在,在我们为导入现有开发人员和添加新开发人员开发表单之后,是时候在组件的控制器中实现其背后的逻辑了。

为此,我们需要与 GitHub API 进行通信。虽然我们可以直接从组件的控制器中进行此操作,但通过这种方式,我们可以将其与 GitHub 的 RESTful API 耦合在一起。为了进一步分离关注点,我们可以将与 GitHub 通信的逻辑提取到一个名为GitHubGateway的单独服务中。打开一个名为github_gateway.ts的文件,并输入以下内容:

import {Injectable} from 'angular2/core';
import {Http} from 'angular2/http';

@Injectable()
export class GitHubGateway {
  constructor(private http: Http) {}
  getUser(username: string) {
    return this.http
            .get(`https://api.github.com/users/${username}`);
  }
}

最初,我们从angular2/http模块导入了Http类。所有与 HTTP 相关的功能都是外部化的,并且在 Angular 的核心之外。由于GitHubGateway接受一个依赖项,需要通过框架的 DI 机制进行注入,因此我们将其装饰为@Injectable装饰器。

我们将要使用的 GitHub 的 API 中唯一的功能是用于获取用户的功能,因此我们将定义一个名为getUser的单个方法。作为参数,它接受开发者的 GitHub 句柄。

注意

请注意,如果您每天对 GitHub 的 API 发出超过 60 个请求,您可能会收到错误GitHub API 速率限制已超出。这是由于没有 GitHub API 令牌的请求的速率限制。有关更多信息,请访问github.com/blog/1509-personal-api-tokens

getUser方法中,我们使用了在constructor函数中收到的Http服务的实例。Http服务的 API 尽可能接近 HTML5 fetch API。但是,有一些区别。其中最重要的一个是,在撰写本内容时,Http实例的所有方法都返回Observables而不是Promises

Http服务实例具有以下 API:

  • request(url: string | Request, options: RequestOptionsArgs): 对指定的 URL 进行请求。可以使用RequestOptionsArgs配置请求:
http.request('http://example.com/', {
  method: 'get',
  search: 'foo=bar',
  headers: new Headers({
    'X-Custom-Header': 'Hello'
	})
});
  • get(url: string, options?: RequestOptionsArgs): 对指定的 URL 进行 get 请求。可以使用第二个参数配置请求头和其他选项。

  • post(url: string, options?: RequestOptionsArgs): 对指定的 URL 进行 post 请求。可以使用第二个参数配置请求体、头和其他选项。

  • put(url: string, options?: RequestOptionsArgs): 对指定的 URL 进行 put 请求。可以使用第二个参数配置请求头和其他选项。

  • patch(url: string, options?: RequestOptionsArgs): 发送一个 patch 请求到指定的 URL。请求头和其他选项可以使用第二个参数进行配置。

  • delete(url: string, options?: RequestOptionsArgs): 发送一个 delete 请求到指定的 URL。请求头和其他选项可以使用第二个参数进行配置。

  • head(url: string, options?: RequestOptionsArgs): 发送一个 head 请求到指定的 URL。请求头和其他选项可以使用第二个参数进行配置。

使用 Angular 的 HTTP 模块

现在,让我们实现从 GitHub 导入现有用户的逻辑!打开文件 ch6/ts/step-2/add_developer.ts 并输入以下导入:

import {Response, HTTP_PROVIDERS} from 'angular2/http';
import {GitHubGateway} from './github_gateway';

HTTP_PROVIDERSGitHubGateway 添加到 AddDeveloper 组件的提供者列表中:

@Component({
  …
  providers: [GitHubGateway, FORM_PROVIDERS, HTTP_PROVIDERS]
})
class AddDeveloper {…}

作为下一步,我们必须在类的构造函数中包含以下参数:

constructor(private githubAPI: GitHubGateway,
  private developers: DeveloperCollection,
  fb: FormBuilder) {
  //…
}

这样,AddDeveloper 类的实例将有一个名为 githubAPI 的私有属性。

唯一剩下的就是实现 addDeveloper 方法,并允许用户使用 GitHubGateway 实例导入现有的开发者。

用户按下 添加 按钮后,我们需要检查是否需要导入现有的 GitHub 用户或添加新的开发者。为此,我们可以使用 fetchFromGitHub 控件的值:

if (this.importDevForm.controls['fetchFromGitHub'].value) {
  // Import developer
} else {
  // Add new developer
}

如果它有一个真值,那么我们可以调用 githubAPI 属性的 getUser 方法,并将 githubHandle 控件的值作为参数传递:

this.githubAPI.getUser(model.githubHandle)

getUser 方法中,我们将调用 Http 服务的 get 方法,该方法返回一个可观察对象。为了获取可观察对象即将推送的结果,我们需要向其 subscribe 方法传递一个回调函数:

this.githubAPI.getUser(model.githubHandle)
  .map((r: Response) => r.json())
  .subscribe((res: any) => {
    // "res" contains the response of the GitHub's API 
  });

在上面的代码片段中,我们首先建立了 HTTP get 请求。之后,我们将得到一个可观察对象,通常会发出一系列的值(在这种情况下,只有一个值—请求的响应),并将它们映射到它们的主体的 JSON 表示。如果响应失败或其主体不是有效的 JSON 字符串,那么我们将得到一个错误。

注意

请注意,为了减小 RxJS 的体积,Angular 的核心团队只包含了它的核心部分。为了使用 mapcatch 方法,您需要在 add_developer.ts 中添加以下导入:

**import 'rxjs/add/operator/map';**
**import 'rxjs/add/operator/catch';**

现在让我们实现订阅回调的主体:

let dev = new Developer();
dev.githubHandle = res.login;
dev.email = res.email;
dev.popular = res.followers >= 1000;
dev.realName = res.name;
dev.id = res.id;
dev.avatarUrl = res.avatar_url;
this.developers.addDeveloper(dev);
this.successMessage = `Developer ${dev.githubHandle} successfully imported from GitHub`;

在前面的例子中,我们设置了一个新的Developer实例的属性。在这里,我们建立了从 GitHub 的 API 返回的对象与我们应用程序中开发者表示之间的映射。我们还认为如果开发者拥有超过 1,000 个粉丝,那么他或她就是受欢迎的。

addDeveloper方法的整个实现可以在ch7/ts/multi-page-model-driven/add_developer.ts中找到。

注意

为了处理失败的请求,我们可以使用可观察实例的catch方法:

 **this.githubAPI.getUser(model.githubHandle)**
 **.catch((error, source, caught) => {**
 **console.log(error)**
 **return error;**
 **})**

定义参数化视图

作为下一步,让我们为每个开发者专门创建一个页面。在这个页面内,我们将能够详细查看他或她的个人资料。一旦用户在应用程序的主页上点击任何开发者的名称,他或她应该被重定向到一个包含所选开发者详细资料的页面。最终结果将如下所示:

定义参数化视图

为了做到这一点,我们需要将开发者的标识符传递给显示开发者详细资料的组件。打开app.ts并添加以下导入:

import {DeveloperDetails} from './developer_details';

我们还没有开发DeveloperDetails组件,所以如果运行应用程序,你会得到一个错误。我们将在下一段定义组件,但在此之前,让我们修改App组件的@RouteConfig定义:

@RouteConfig([
  //…
  new Route({
    component: DeveloperDetails,
    name: 'DeveloperDetails',
    path: '/dev-details/:id/...'
  }),
  //…
])
class App {}

我们添加了一个单一路由,与DeveloperDetails组件相关联,并且作为别名,我们使用了字符串"DeveloperDetails"

component属性的值是对组件构造函数的引用,该构造函数应该处理给定的路由。一旦应用程序的源代码在生产中被压缩,组件名称可能会与我们输入的名称不同。这将在使用routerLink指令在模板中引用路由时创建问题。为了防止这种情况发生,核心团队引入了name属性,在这种情况下,它等于控制器的名称。

注意

尽管到目前为止的所有示例中,我们将路由的别名设置为与组件控制器的名称相同,但这并不是必需的。这个约定是为了简单起见,因为引入两个名称可能会令人困惑:一个用于指向路由,另一个用于与给定路由相关联的控制器。

path属性中,我们声明该路由有一个名为id的单个参数,并用"..."提示框架,这个路由将在其中有嵌套路由。

现在,让我们将当前开发人员的id作为参数传递给routerLink指令。在你的工作目录中打开home.html,并用以下内容替换我们显示开发人员的realName属性的表格单元格:

<td>
  <a [routerLink]="['/DeveloperDetails',
      { 'id': dev.id }, 'DeveloperBasicInfo']">
    {{dev.realName}}
  </a>
</td>

routerLink指令的值是一个包含以下三个元素的数组:

  • '/DeveloperDetails':显示根路由的字符串

  • { 'id': dev.id }:声明路由参数的对象文字

  • 'DeveloperBasicInfo':显示在组件别名为DeveloperDetails的嵌套路由中应该呈现的组件的路由名称

定义嵌套路由

现在让我们跳到DeveloperDetails的定义。在你的工作目录中,创建一个名为developer_details.ts的文件,并输入以下内容:

import {Component} from 'angular2/core';
import {
  ROUTER_DIRECTIVES,
  RouteConfig,
  RouteParams
} from 'angular2/router';
import {Developer} from './developer';
import {DeveloperCollection} from './developer_collection';

@Component({
  selector: 'dev-details',
  template: `…`,
})
@RouteConfig(…)
export class DeveloperDetails {
  public dev: Developer;
  constructor(routeParams: RouteParams,
    developers: DeveloperCollection) {
    this.dev = developers.getUserById(
      parseInt(routeParams.params['id'])
    );
  }
}

在上面的代码片段中,我们定义了一个带有控制器的组件DeveloperDetails。您可以注意到,在控制器的构造函数中,通过 Angular 2 的 DI 机制,我们注入了与RouteParams令牌相关联的参数。注入的参数为我们提供了访问当前路由可见参数的权限。我们可以使用注入对象的params属性访问它们,并使用参数的名称作为键来访问目标参数。

由于我们从routeParams.params['id']得到的参数是一个字符串,我们需要将其解析为数字,以便获取与给定路由相关联的开发人员。现在让我们定义与DeveloperDetails相关的路由:

@Component(…)
@RouteConfig([{
    component: DeveloperBasicInfo,
    name: 'DeveloperBasicInfo',
    path: '/'
  },
  {
    component: DeveloperAdvancedInfo,
    name: 'DeveloperAdvancedInfo',
    path: '/dev-details-advanced'
  }])
export class DeveloperDetails {…}

在上面的代码片段中,对我们来说没有什么新的。路由定义遵循我们已经熟悉的完全相同的规则。

现在,让我们在组件的模板中添加与各个嵌套路由相关的链接:

@Component({
  selector: 'dev-details',
  directives: [ROUTER_DIRECTIVES],
  template: `
    <section class="col-md-4">
      <ul class="nav nav-tabs">
        <li>
          <a [routerLink]="['./DeveloperBasicInfo']">
            Basic profile
          </a>
        </li>
        <li>
          <a [routerLink]="['./DeveloperAdvancedInfo']">
            Advanced details
          </a>
        </li>
      </ul>
      <router-outlet/>
    </section>
  `,
})
@RouteConfig(…)
export class DeveloperDetails {…}

在模板中,我们声明了两个相对于当前路径的链接。第一个指向DeveloperBaiscInfo,这是在DeveloperDetails组件的@RouteConfig中定义的第一个路由的名称,相应地,第二个指向DeveloperAdvancedInfo

由于这两个组件的实现非常相似,让我们只看一下DeveloperBasicInfo。作为练习,您可以开发第二个,或者查看ch7/ts/multi-page-model-driven/developer_advanced_info.ts中的实现:

import {
  Component,
  Inject,
  forwardRef,
  Host
} from 'angular2/core';
import {DeveloperDetails} from './developer_details';
import {Developer} from './developer';

@Component({
  selector: 'dev-details-basic',
  styles: […],
  template: `
    <h2>{{dev.realName}}</h2>
    <img *ngIf="dev.avatarUrl == null"
      class="avatar" src="./gravatar-60-grey.jpg" width="150">
    <img *ngIf="dev.avatarUrl != null"
      class="avatar" [src]="dev.avatarUrl" width="150">
  `
})
export class DeveloperBasicInfo {
  dev: Developer;
  constructor(@Inject(forwardRef(() => DeveloperDetails))
    @Host() parent: DeveloperDetails) {
    this.dev = parent.dev;
  }
}

在上述代码片段中,我们结合了@Inject参数装饰器和@Host来注入父组件。在@Inject内部,我们使用forwardRef,因为在developer_basic_infodeveloper_details之间存在循环依赖(在developer_basic_info中,我们导入developer_details,而在developer_details中,我们导入developer_basic_info)。

我们需要一个对父组件实例的引用,以便获取与所选路由对应的当前开发者的实例。

使用管道转换数据

现在是 Angular 2 为我们提供的最后一个构建块的时间,这是我们尚未详细介绍的管道。

就像 AngularJS 1.x 中的过滤器一样,管道旨在封装所有数据转换逻辑。让我们来看看我们刚刚开发的应用程序的主页模板:

…
<td [ngSwitch]="dev.popular">
  <span *ngSwitch-when="true">Yes</span>
  <span *ngSwitch-when="false">Not yet</span>
</td>
…

在上述代码片段中,根据popular属性的值,我们使用NgSwitchNgSwitchThen指令显示了不同的数据。虽然这样可以工作,但是有些冗余。

开发无状态管道

让我们开发一个管道,转换popular属性的值并在NgSwitchNgSwitchThen的位置使用它。该管道将接受三个参数:应该被转换的值,当值为真时应该显示的字符串,以及在值为假时应该显示的另一个字符串。

通过使用 Angular 2 自定义管道,我们将能够简化模板为:

<td>{{dev.popular | boolean: 'Yes': 'No'}}</td>

我们甚至可以使用表情符号:

<td>{{dev.popular | boolean: '👍': '👎'}}</td>
```ts

我们将管道应用到值上的方式与在 AngularJS 1.x 中的方式相同。我们传递给管道的参数应该用冒号(`:`)符号分隔。

为了开发一个 Angular 2 管道,我们需要以下导入:

import {Pipe, PipeTransform} from 'angular2/core';


`Pipe`装饰器可用于向实现数据转换逻辑的类添加元数据。`PipeTransform`是一个具有名为 transform 的单个方法的接口:

import {Pipe, PipeTransform} from 'angular2/core';

@Pipe({
name: 'boolean'
})
export class BooleanPipe implements PipeTransform {
constructor() {}
transform(flag: boolean, args: string[]): string {
return flag ? args[0] : args[1];
}
}


上述代码片段是`BooleanPipe`的整个实现。管道的名称决定了它在模板中的使用方式。

在能够使用管道之前,我们需要做的最后一件事是将`BooleanPipe`类添加到`Home`组件使用的管道列表中(`BooleanPipe`已经通过`@Pipe`装饰器附加了元数据,所以它的名称已经附加到它上面):

@Component({

pipes: [BooleanPipe],
})
export class Home {
constructor(private developers: DeveloperCollection) {}
getDevelopers() {…}
}


## 使用 Angular 内置的管道

Angular 2 提供了以下一组内置管道:

+   `CurrencyPipe`:此管道用于格式化货币数据。作为参数,它接受货币类型的缩写(即`"EUR"`,`"USD"`等)。可以按以下方式使用:

{{ currencyValue | currency: 'USD' }}


+   `DatePipe`:此管道用于日期转换。可以按以下方式使用:

{{ dateValue | date: 'shortTime' }}


+   `DecimalPipe`:此管道用于转换十进制数。它接受的参数形式为`"{minIntegerDigits}.{minFractionDigits}-{maxFractionDigits}"`。可以按以下方式使用:

{{ 42.1618 | number: '3.1-2' }}


+   `JsonPipe`:这将 JavaScript 对象转换为 JSON 字符串。可以按以下方式使用:

{{ { foo: 42 } | json }}


+   `LowerCasePipe`:将字符串转换为小写。可以按以下方式使用:

{{ FOO | lowercase }}


+   `UpperCasePipe`:将字符串转换为大写。可以按以下方式使用:

{{ 'foo' | uppercase }}


+   `PercentPipe`:这将数字转换为百分比。可以按以下方式使用:

{{ 42 | percent: '2.1-2' }}


+   `SlicePipe`:返回数组的一个切片。该管道接受切片的起始和结束索引。可以按以下方式使用:

{{ [1, 2, 3] | slice: 1: 2 }}


+   `AsyncPipe`:这是一个`有状态`管道,接受一个 observable 或一个 promise。我们将在本章末尾看一下它。

## 开发有状态的管道

之前提到的所有管道之间有一个共同点——每次将它们应用于相同的值并传递相同的参数集时,它们都会返回完全相同的结果。具有引用透明属性的这种管道称为纯管道。

`@Pipe`装饰器接受以下类型的对象文字:`{ name: string, pure?: boolean }`,其中`pure`属性的默认值为`true`。这意味着当我们使用`@Pipe`装饰器装饰给定的类时,我们可以声明我们希望管道实现的逻辑是有状态的还是无状态的。纯属性很重要,因为如果管道是无状态的(即,对于相同的值和相同的参数集合应用时返回相同的结果),则可以优化变更检测。

现在让我们构建一个有状态的管道!我们的管道将向 JSON API 发出 HTTP `get`请求。为此,我们将使用`angular2/http`模块。

### 注意

请注意,在管道中具有业务逻辑并不被认为是最佳实践。这种类型的逻辑应该被提取到一个服务中。这里的示例仅用于学习目的。

在这种情况下,管道需要根据请求的状态(即是否挂起或已完成)来保持不同的状态。我们将以以下方式使用管道:

{{ "http://example.com/user.json" | fetchJson | json }}


这样,我们就可以在 URL 上应用`fetchJson`管道,一旦我们从远程服务获得响应并且请求的承诺已经解决,我们就可以在响应中得到的对象上应用`json`管道。该示例还展示了如何在 Angular 2 中链式应用管道。

同样,在前面的示例中,为了开发一个无状态的管道,我们需要导入`Pipe`和`PipeTransform`。然而,这次,由于 HTTP 请求功能,我们还需要从`'angular2/http'`模块导入`Http`和`Response`类:

import {Pipe, PipeTransform} from 'angular2/core';
import {Http, Response} from 'angular2/http';
import 'rxjs/add/operator/toPromise';


每当将`fetchJson`管道应用于与上一次调用中获得的参数不同的参数时,我们需要发起新的 HTTP `get`请求。这意味着作为管道的状态,我们至少需要保留远程服务响应的值和最后的 URL:

@Pipe({
name: 'fetchJson',
pure: false
})
export class FetchJsonPipe implements PipeTransform {
private data: any;
private prevUrl: string;
constructor(private http: Http) {}
transform(url: string): any {…}
}


剩下的逻辑只有`transform`方法:


transform(url: string): any {
if (this.prevUrl !== url) {
this.http.get(url).toPromise(Promise)
.then((data: Response) => data.json())
.then(result => this.data = result);
this.prevUrl = url;
}
return this.data || {};
}


在其中,我们最初将作为参数传递的 URL 与我们当前保留引用的 URL 进行比较。如果它们不同,我们将使用传递给`constructor`函数的`Http`类的本地实例发起新的 HTTP `get`请求。一旦请求完成,我们将将响应解析为 JSON,并将`data`属性设置为结果。

现在,假设管道已经开始了`Http get`请求,在请求完成之前,变更检测机制再次调用了管道。在这种情况下,我们将比较`prevUrl`属性和`url`参数。如果它们相同,我们将不会执行新的`http`请求,并立即返回`data`属性的值。如果`prevUrl`的值与`url`不同,我们将开始一个新的请求。

## 使用有状态的管道

现在让我们使用我们开发的管道!我们将要实现的应用程序为用户提供了一个文本输入和一个按钮。一旦用户在文本输入中输入一个值并按下按钮,文本输入框下方将显示与 GitHub 用户对应的头像,如下面的屏幕截图所示:

![使用有状态的管道](https://gitee.com/OpenDocCN/freelearn-angular-zh/raw/master/docs/swc-ng2/img/00032.jpeg)

现在,让我们开发一个示例组件,允许我们输入 GitHub 用户的句柄:

// ch7/ts/statful_pipe/app.ts

@Component({
selector: 'app',
providers: [HTTP_PROVIDERS],
pipes: [FetchJsonPipe, ObjectGetPipe],
template: <input type="text" #input> <button (click)=" setUsername(input.value)">Get Avatar</button>
})
class App {
username: string;
setUsername(user: string) {
this.username = user;
}
}


在前面的例子中,我们添加了`FetchJsonPipe`用于`App`组件。唯一剩下的就是显示用户的 GitHub 头像。我们可以通过修改前面组件的模板来轻松实现这一点,使用以下`img`声明:

<img width="160" [src]="(('https://api.github.com/users/' + username) | fetchJson).avatar_url">


最初,我们将 GitHub 句柄附加到用于从 API 获取用户的基本 URL 上。后来,我们对其应用了`fetchJson`过滤器,并从返回的结果中得到了`avatar_url`属性。

### 注意

虽然前面的例子可以工作,但在管道中放入业务逻辑是不自然的。最好将与 GitHub API 通信的逻辑实现为一个服务,或者至少在组件中调用`Http`类的实例的`get`方法。

## 使用 Angular 的 AsyncPipe

Angular 的`AsyncPipe`转换方法接受 observable 或 promise 作为参数。一旦参数推送一个值(即 promise 已解析或 observable 的`subscribe`回调被调用并传递了一个值),`AsyncPipe`将返回它作为结果。让我们看看以下例子:

// ch7/ts/async-pipe/app.ts
@Component({
selector: 'greeting',
template: 'Hello {{ greetingPromise | async }}'
})
class Greeting {
greetingPromise = new Promise(resolve => this.resolve = resolve);
resolve: Function;
constructor() {
setTimeout(_ => {
this.resolve('Foobar!');
}, 3000);
}
}


在这里,我们定义了一个 Angular 2 组件,它有两个属性:`greetingPromise`的类型为`Promise<string>`,`resolve`的类型为`Function`。我们用一个新的`Promise<string>`实例初始化了`greetingPromise`属性,并将`resolve`属性的值设置为`promise`的`resolve`回调函数。

在类的构造函数中,我们启动了一个持续 3,000 毫秒的超时,在其回调函数中,我们解析了 promise。一旦 promise 被解析,表达式`{{ greetingPromise | async }}`的值将被评估为字符串`Foobar!`。用户在屏幕上看到的最终结果是文本**Hello Foobar!**。

当我们将`async`管道与`Http`请求或与推送值序列的 observable 结合使用时,`async`管道非常强大。

### 使用 observables 和 AsyncPipe

我们已经熟悉了前几章中的 observables 的概念。我们可以说,observable 对象允许我们订阅一系列值的发射,例如:

let observer = new Observable(observer => {
setInterval(() => {
observer.next(new Date().getTime());
}, 1000);
});
observer.subscribe(date => console.log(date));


一旦我们订阅了可观察对象,它将开始每秒发出值,这些值将被打印在控制台中。让我们将这段代码与组件的定义结合起来,实现一个简单的计时器:

// ch7/ts/async-pipe/app.ts
@Component({
selector: 'timer'
})
class Timer {
username: string;
timer: Observable;
constructor() {
let counter = 0;
this.timer = new Observable(observer => {
setInterval(() => {
observer.next(new Date().getTime());
}, 1000);
});
}
}


为了能够使用计时器组件,唯一剩下的事情就是添加它的模板。我们可以通过使用`async`管道直接在我们的模板中订阅可观察对象:

{{ timer | async | date: "medium" }}


这样,每秒我们将得到可观察对象发出的新值,并且`date`管道将把它转换成可读形式。

# 总结

在本章中,我们深入研究了 Angular 2 表单,通过开发一个模型驱动的表单,并将其与`http`模块结合起来,以便能够将开发人员添加到我们的存储库中。我们看了一些新的基于组件的路由的高级特性,并了解了如何使用和开发我们定制的有状态和无状态管道。

下一章将致力于我们如何使我们的 Angular 2 应用程序对 SEO 友好,通过利用模块 universal 提供的服务器端渲染。我们还将看看 angular-cli 和其他工具,这些工具使我们作为开发人员的体验更好。


# 第八章:开发体验和服务器端渲染

我们已经熟悉了 Angular 2 的所有核心概念。我们知道如何开发基于组件的用户界面,利用框架提供的所有构建模块——指令、组件、依赖注入、管道、表单和全新的基于组件的路由器。

接下来,我们将看看从头开始构建**单页应用程序**(**SPA**)时应该从哪里开始。本章描述了如何执行以下操作:

+   对于性能敏感的应用程序,请使用 Web Workers。

+   使用服务器端渲染构建友好的 SEO 应用程序。

+   尽快启动项目。

+   增强我们作为开发者的体验。

所以,让我们开始吧!

# 在 Web Workers 中运行应用程序

在谈论前端 Web 开发的性能时,我们可以指的是网络、计算或渲染性能。在本节中,我们将集中讨论渲染和计算性能。

首先,让我们将 Web 应用程序和视频,以及浏览器和视频播放器进行对比。在浏览器中运行的 Web 应用程序和视频播放器中播放的视频文件之间最大的区别是,Web 页面需要动态生成,而视频已经被录制、编码和分发。然而,在这两种情况下,应用程序的用户都会看到一系列帧;核心区别在于这些帧是如何生成的。在视频处理领域,当我们播放视频时,视频已经被录制;视频解码器的责任是根据压缩算法提取单个帧。与此相反,在 Web 上,JavaScript 和 CSS 负责生成由浏览器渲染引擎渲染的帧。

在浏览器的上下文中,我们可以将每一帧视为在给定时刻的网页快照。不同的帧快速地一个接一个地渲染,因此理论上,应用程序的最终用户应该看到它们平滑地结合在一起,就像在视频播放器中播放视频一样。

在 Web 上,我们试图达到 60 帧每秒(每秒帧数),这意味着每帧大约有 16 毫秒在屏幕上计算和渲染。这段时间包括浏览器进行布局和页面渲染所需的时间,以及我们的 JavaScript 需要执行的时间。

最后,我们只有不到 16 毫秒的时间(因为浏览器渲染功能需要时间,取决于它需要执行的计算)来完成 JavaScript 的执行。如果超过这个持续时间,帧速率将下降一半。由于 JavaScript 是单线程语言,所有计算都需要在主 UI 线程中进行,这在计算密集型应用程序(如图像或视频处理、大型 JSON 字符串的编组和解组等)的情况下,可能会导致用户体验非常差,因为帧会被丢弃。

HTML5 引入了一个名为**Web Workers**的 API,它允许在浏览器环境中执行客户端代码到多个线程中。简单起见,标准不允许个别线程之间共享内存,而是允许通过消息传递进行通信。Web Workers 和主 UI 线程之间交换的消息必须是字符串,这经常需要对 JSON 字符串进行序列化和反序列化。

个别工作线程之间以及工作线程和主 UI 线程之间缺乏共享内存带来了一些限制,比如:

+   工作线程无法访问 DOM。

+   全局变量不能在个别计算单元(即工作线程和主 UI 线程以及反之)之间共享。

## Web Workers 和 Angular 2

由于 Angular 2 的平台不可知设计,核心团队决定利用这个 API,在 2015 年夏天,谷歌将 Web Workers 支持嵌入到了框架中。这个特性使得大多数 Angular 2 应用程序可以在单独的线程上运行,使得主 UI 线程只负责渲染。这有助于我们更容易地实现 60 帧每秒的目标,而不是在单个线程中运行整个应用程序。

Web Workers 支持默认情况下是未启用的。启用它时,我们需要记住一些事情——在一个准备好使用 Web Workers 的应用程序中,组件不会在主 UI 线程中运行,这不允许我们直接操作 DOM。在这种情况下,我们需要使用绑定,比如输入、输出,以及`NgModel`的组合。

## 在 Web Worker 中引导运行应用程序。

让我们将我们在第四章中开发的待办事项应用程序,在 Web Workers 中运行。您可以在 `ch8/ts/todo_webworkers/` 找到我们将要探索的示例。

首先,让我们讨论需要进行的更改。看一下 `ch4/ts/inputs-outputs/app.ts`。注意,在 `app.ts` 中,我们包含了来自 `angular2/platform/browser` 模块的 `bootstrap` 函数。这是我们需要修改的第一件事!在后台进程中运行的应用程序的 `bootstrap` 过程是不同的。

在重构我们的代码之前,让我们看一下一张图表,说明了在 Web Workers 中运行的典型 Angular 2 应用程序的 `bootstrap` 过程:

![在 Web Worker 中运行应用程序的引导过程](https://gitee.com/OpenDocCN/freelearn-angular-zh/raw/master/docs/swc-ng2/img/00033.jpeg)

*Jason Teplitz* 在 *AngularConnect 2015* 上介绍了在 Angular 2 中实现 Web Worker 支持的这张图表。

该图分为两部分:**UI** 和 **Web Worker**。UI 显示了在主 UI 线程初始化期间执行的操作;图表的 **Web Worker** 部分显示了应用程序在后台线程中如何启动。现在,让我们逐步解释 `bootstrap` 过程。

首先,用户打开 `index.html` 页面,触发以下两个文件的下载:

+   用于在 Web Worker 中运行的 Angular 2 的 UI 捆绑包。

+   `system.js` 捆绑包(我们在第三章中讨论了全局对象 System,*TypeScript Crash Course*。我们可以将 `system.js` 捆绑包视为模块加载器的 polyfill)。

使用`system.js`,我们下载了用于初始化在主 UI 线程中运行的应用程序部分的脚本。此脚本在 Web Worker 中启动了`loader.js`。这是在后台线程中运行的第一个脚本。一旦工作线程启动,`loader.js`将下载`system.js`和 Angular 2 的捆绑包,这些捆绑包旨在在后台线程中运行。第一个请求通常会命中缓存,因为主线程已经请求了`system.js`。使用模块加载器,我们下载了负责引导后台应用程序`background_bootstrap.js`的脚本,最终将在后台启动我们应用程序的功能。

从现在开始,我们构建的整个应用程序将在 Web Worker 中运行,并将与主 UI 线程交换消息,以响应用户事件和渲染指令。

现在我们已经了解了在使用工作线程时初始化期间事件的基本流程,让我们重构我们的待办事项应用程序以利用它们。

## 将应用程序迁移到 Web Worker

在`index.html`中,我们需要添加以下脚本:

```ts
  <!-- ch8/ts/todo_webworkers/index.html -->
  …
  <script src="/node_modules/systemjs/dist/system.src.js">
  </script>
  <script src="/node_modules/angular2/bundles/angular2-polyfills.js"></script>
  <script src="/node_modules/angular2/bundles/web_worker/ui.dev.js">
  </script>
  <script>
  System.config({
    baseURL: '/dist/dev/ch8/ts/todo_webworkers/'
  });
  System.import('./bootstrap.js')
    .catch(function () {
      console.log('Report this error to https://github.com/mgechev/switching-to-angular2/issues', e);
    });
  </script>
  …

在上述片段中,我们包括了对system.jsangular2-polyfills(包括zone.js)和 Angular 库使用的其他文件的引用,以及需要在主 UI 线程中运行的捆绑包ui.dev.js

在此之后,我们将通过设置模块加载器的baseURL属性来配置system.js。接下来,我们将显式导入包含用于在 Web Worker 中启动loader.js脚本的逻辑的bootstrap.js文件。

让我们探索bootstrap.js,这是经过转译的bootstrap.js的原始文件:

// ch8/ts/todo_webworkers/bootstrap.ts
import {platform, Provider} from 'angular2/core';
import {
  WORKER_RENDER_APPLICATION,
  WORKER_RENDER_PLATFORM,
  WORKER_SCRIPT
} from 'angular2/platform/worker_render';

platform([WORKER_RENDER_PLATFORM])
  .application([WORKER_RENDER_APPLICATION,
     new Provider(WORKER_SCRIPT, {useValue: 'loader.js'})]);

在这个文件中,我们将平台设置为WORKER_RENDER_PLATFORM类型,将应用程序类型设置为WORKER_RENDER_APPLICATION。我们配置了用于注入WORKER_SCRIPT令牌的提供程序,以使用值'loader.js'。正如我们所说,loader.js将在后台线程中运行。该脚本位于应用程序的根目录中。

现在,我们可以移动到在 Web Worker 中运行应用程序的引导部分中给出的图表的右侧。loader.js中的逻辑非常简单:

// ch8/ts/todo_webworkers/loader.ts
importScripts("/node_modules/systemjs/dist/system.src.js",
      "/node_modules/angular2/bundles/web_worker/worker.dev.js",
   "/node_modules/angular2/bundles/angular2-polyfills.js");

System.config({
  baseURL: '/dist/dev/ch8/ts/todo_webworkers/',
});

System.import('./background_app.js')
.then(() => console.log('The application has started successfully'),
  error => console.error('Error loading background', error));

作为第一步,我们导入system.js,Angular 2 的 Web Workers 捆绑包(worker.dev.js)以及所有必需的polyfills。然后,我们配置模块加载器的后台实例并导入background_app文件,该文件包含我们应用的逻辑以及 Web Workers 的引导调用。

现在,让我们探讨如何在 Web Worker 中引导应用程序:

import {platform} from 'angular2/core';
import {
  WORKER_APP_PLATFORM,
  WORKER_APP_APPLICATION
} from 'angular2/platform/worker_app';

// Logic for the application…

platform([WORKER_APP_PLATFORM])
  .application([WORKER_APP_APPLICATION])
  .bootstrap(TodoApp);

就像在主 UI 线程中引导一样,我们指定平台的类型和我们要引导的应用程序的类型。在最后一步中,我们设置根组件,就像在标准引导过程中所做的那样。TodoApp组件在background_app文件的导入和初始化调用之间定义。

使应用程序与 Web Workers 兼容

正如我们所说,运行在 Web Worker 上下文中的代码无法访问 DOM。让我们看看我们需要做哪些更改来解决这个限制。

这是InputBox组件的原始实现:

// ch4/ts/inputs-outputs/app.ts
@Component({
  selector: 'input-box',
  template: `
    <input #todoInput [placeholder]="inputPlaceholder">
    <button (click)="emitText(todoInput.value);
      todoInput.value = '';">
      {{buttonLabel}}
    </button>
  `
})
class InputBox {
  @Input() inputPlaceholder: string;
  @Input() buttonLabel: string;
  @Output() inputText = new EventEmitter<string>();
  emitText(text: string) {
    this.inputText.emit(text);
  }
}

请注意,在模板内部,我们将输入元素命名为todoInput并在表达式集中使用它的引用作为单击事件的处理程序。由于我们直接在模板内部访问 DOM 元素,这段代码将无法在 Web Worker 中运行。为了解决这个问题,我们需要重构代码片段,使其使用 Angular 2 绑定而不是直接触摸任何元素。我们可以在单向绑定有意义时使用输入,或者使用NgModel来实现双向数据绑定,这需要更多的计算资源。

让我们使用NgModel

// ch8/ts/todo_webworkers/background_app.ts
import {NgModel} from 'angular2/common';
@Component({
  selector: 'input-box',
  template: `
    <input [placeholder]="inputPlaceholder" [(ngModel)]="input">
    <button (click)="emitText()">
      {{buttonLabel}}
    </button>
  `
})
class InputBox {
  @Input() inputPlaceholder: string;
  @Input() buttonLabel: string;
  @Output() inputText = new EventEmitter<string>();
  input: string;
  emitText() {
    this.inputText.emit(this.input);
    this.input = '';
  }
}

在这个版本的InputBox组件中,我们将在输入元素和InputBox组件的输入属性之间创建双向数据绑定。一旦用户点击按钮,将调用emitText方法,这将触发由inputText EventEmitter发出的新事件。为了重置输入元素的值,我们利用了我们声明的双向数据绑定,并将输入属性的值设置为空字符串。

注意

将组件模板中的整个逻辑移动到它们的控制器中带来了许多好处,比如改进了可测试性、可维护性、代码重用和清晰度。

前面的代码与 Web Workers 环境兼容,因为NgModel指令基于一个不直接操作 DOM 的抽象,在幕后与主 UI 线程异步交换消息。

总之,我们可以说,在 Web Workers 的上下文中运行应用程序时,我们需要牢记以下两点:

  • 我们需要使用不同的引导过程。

  • 我们不应直接访问 DOM。

违反第二点的典型情况如下:

  • 通过选择元素并直接使用浏览器的原生 API 或第三方库来操作页面的 DOM。

  • 访问使用ElementRef注入的原生元素。

  • 在模板中创建对元素的引用并将其作为参数传递给方法。

  • 直接操作模板中引用的元素。

在所有这些情况下,我们需要使用 Angular 提供的 API。如果我们根据这种做法构建我们的应用程序,我们不仅将从能够在 Web Workers 中运行它们中受益,而且在我们希望在不同平台上使用它们时,还将增加代码重用。

记住这一点将使我们能够利用服务器端渲染。

单页应用程序的初始加载

在本节中,我们将探讨服务器端渲染是什么,为什么我们需要在我们的应用程序中使用它,以及我们如何在 Angular 2 中使用它。

对于我们的目的,我们将解释用户打开在 Angular 2 中实现的 SPA 时的典型事件流程。首先,我们将跟踪禁用服务器端渲染时的事件,然后,我们将看到如何通过启用它来从这个功能中受益。我们的示例将在 HTTP 1.1 的上下文中进行说明。

单页应用程序的初始加载

这张图片展示了浏览器的第一个请求以及加载典型 SPA 时相应的服务器响应。客户端最初将看到的结果是 HTML 页面的初始内容,没有任何渲染的组件。

假设我们部署了我们在第四章构建的待办事项应用程序到一个具有https://example.com域关联的 Web 服务器上。

一旦用户导航到https://example.com/,浏览器将打开一个新的 HTTP GET 请求,获取根资源(/)。当服务器收到请求时,它将用一个 HTML 文件作为响应,而在我们的情况下,它将看起来像这样:

<!DOCTYPE html>
<html lang="en">
<head>
  <title>Switching to Angular 2</title>
  <link rel="stylesheet" href="bootstrap.min.css">
</head>
<body>
  <app>Loading...</app>
  <script src="es6-shim.min.js"></script>
  <script src="Reflect.js"></script>
  <script src="system.src.js"></script>
  <script src="angular2-polyfills.js"></script>
  <script src="Rx.min.js"></script>
  <script src="angular2.js"></script>
  <script src="router.js"></script>
  <script src="http.min.js"></script>
  <script>…</script>
</body>
</html>

浏览器将接收此内容作为响应的主体。当标记呈现到屏幕上时,用户将只看到标签:加载中...

接下来,浏览器将查找 HTML 文件中外部资源的所有引用,比如样式和脚本,并开始下载它们。在我们的情况下,其中一些是 bootstrap.css,es6-shim.min.js,Reflect.js,system.src.js 和 angular2-polyfills.js。

一旦所有引用的资源都可用,用户将看不到任何显著的视觉进展(除非已经将下载的 CSS 文件中的样式应用到页面上)。这种情况直到 JavaScript 虚拟机处理了与应用程序实现相关的所有引用脚本之后才会改变。在这一点上,Angular 将根据当前 URL 和引导程序的配置知道需要渲染哪个组件。

如果与页面相关联的组件在我们的主应用程序包之外的单独文件中定义,那么框架将需要下载它以及其整个依赖图。如果组件的模板和样式是外部化的,Angular 还需要下载它们,然后才能渲染请求的页面。

在此之后,框架将能够编译与目标组件相关联的模板并渲染页面。

在先前的情景中,存在以下两个主要问题:

  • 搜索引擎不擅长索引 JavaScript 生成的动态内容。这意味着我们的 SPA 的 SEO(搜索引擎优化)将受到影响。

  • 在大型应用程序和/或网络连接差的情况下,用户体验将很差。

在过去,我们通过不同的变通方法解决了使用 AngularJS 1.x 构建的应用程序中的 SEO 问题,比如使用无头浏览器来渲染请求的页面,将其缓存到磁盘上,然后提供给搜索引擎。然而,有一个更加优雅的解决方案。

使用服务器端渲染的 SPA 的初始加载

几年前,诸如RendrDerbyMeteor等库引入了同构 JavaScript 应用程序的概念,后来被重命名为通用应用程序。实质上,通用应用程序可以在客户端和服务器上运行。只有在 SPA 与浏览器 API 之间耦合较低的情况下,才能实现这种可移植性。这种范式的最大好处是应用程序可以在服务器上重新渲染,然后发送到客户端。

通用应用程序不是特定于框架的;我们可以在任何可以在浏览器环境之外运行的框架中利用它们。从概念上讲,服务器端渲染的实践在各个平台和库中都非常相似;只是其实现细节可能有所不同。例如,Angular 2 Universal 模块实现了服务器端渲染,支持 node.js 以及 ASP.NET,在我撰写本文时,后者仍在进行中。

使用服务器端渲染加载 SPA 的初始加载

前面的图片显示了服务器对初始浏览器GET请求的响应。这一次,与加载 SPA 的典型情景相比,浏览器将获得 HTML 页面的渲染内容。

让我们追踪具有启用服务器端渲染功能的相同应用程序中事件的流程。在这种情况下,一旦服务器接收到浏览器的 HTTP GET请求,它将在 node.js 环境中在服务器上运行 SPA。所有的 DOM 调用都将被重定向到服务器端的 DOM 实现,并在所使用的平台的上下文中执行。同样,所有使用http模块的 AJAX 调用都将由模块的服务器端实现处理。这样,应用程序无论是在浏览器还是服务器的上下文中运行,都不会有任何区别。

一旦 SPA 的渲染版本可用,就可以将其序列化为 HTML 并发送到浏览器。这一次,在应用程序初始化期间,用户将立即看到他们请求的页面,而不是加载中...标签。

请注意,在此时,客户端将拥有应用程序的渲染版本,但所有引用的外部资源,如脚本和样式,仍然需要可用。这意味着最初,外部文件中声明的 CSS 样式将不会应用,并且应用程序将不会对任何与用户相关的交互做出响应,如鼠标和键盘事件。

注意

请注意,如果脚本被内联到服务器端渲染的页面中,应用程序将对用户事件做出响应。然而,内联大块的 JavaScript 通常被认为是一种不良实践,因为它会大幅增加页面的大小,并阻止脚本缓存。这两者都会影响网络性能。

当 JavaScript 虚拟机处理与页面相关的 JavaScript 时,我们的 SPA 将准备就绪。

Angular 2 的服务器端渲染

在 2015 年上半年,Patrick Stapleton 和 Jeff Whelpley 宣布他们开始开发Universal模块。Universal 是一个库,允许我们使用 Angular 2 构建通用(也称为同构)JavaScript 应用程序;换句话说,它提供了服务器端渲染支持。

使用 Angular 2 和 Universal 构建的应用程序在处理完所请求页面的所有 JavaScript 之前将无法响应。这是一个我们已经提到过的缺点,对所有服务器端渲染的应用程序都适用。然而,Patrick 和 Jeff 引入了preboot.js,这是一个轻量级的库,将被内联到服务器渲染的页面中,并在初始客户端请求后可用。

Preboot.js 有几种策略来管理应用程序完全初始化之前接收到的客户端事件。它们如下:

  • 记录并回放事件。

  • 立即响应事件。

  • 在页面重新渲染时保持焦点。

  • 缓冲客户端重新渲染以实现更平滑的过渡。

  • 如果用户点击按钮,冻结页面直到引导程序完成。

在撰写本文时,Universal 模块仍在积极开发中。但是,您可以尝试使用 Angular 2 通用启动器github.com/angular/universal-starter

增强我们的开发体验

作为开发人员,我们的经验可以通过提高生产力或允许我们在项目上更有乐趣来增强。这可以通过我们日常使用的所有工具、IDE、文本编辑器等来实现。在本节中,我们将简要介绍一些流行的 IDE 和文本编辑器,以便利用 Angular 2 提供的静态代码分析功能。

在本节的第二部分,我们将看到热重载是什么,以及在开发 Angular 2 应用程序时如何利用它。

文本编辑器和 IDE

正如我们在本书开头已经提到的,核心团队在增强 Angular 2 的工具支持方面付出了很大的努力。首先,该框架是用 TypeScript 构建的,这自然地允许我们在开发过程中使用静态类型。一些具有很好 TypeScript 支持的文本编辑器和 IDE 如下:

  • IntelliJ Idea:由 JetBrains 开发的通用 IDE。

  • WebStorm:JetBrains 专门为 Web 开发开发的 IDE。

  • VSCode:一款由微软开发的跨平台文本编辑器,使用 TypeScript 编写。

  • Sublime Text:一款跨平台文本编辑器。

  • Atom:一款跨平台文本编辑器。

最近,JetBrains 宣布在 IntelliJ Idea 和 WebStorm 中提供了先进的 Angular 2 支持,支持组件和绑定的自动完成。

尽管在撰写本文时,并非所有提到的 IDE 和文本编辑器都具有针对 Angular 2 的支持,但 Angular 2 具有出色的设计。它允许我们对应用程序的代码库进行高级静态代码分析,以便在不久的将来开发复杂的重构和生产工具。在那之前,Angular 2 至少提供了与市场上任何其他 JavaScript 框架一样好的工具支持。

热重载

热重载(或热加载)是在纯函数式用户界面的世界中变得流行的一种实践,例如在 ClojureScript 中使用的 Om 和 React 中。

在开发单页应用程序时,每次对样式、视图甚至组件进行小的更改后都需要刷新浏览器是非常恼人的。这就是为什么几年前开发了一个叫做livereload的工具。Livereload 监视我们应用程序的文件,当它检测到任何文件的变化时,就会发送消息给浏览器以刷新页面。通常,livereload 服务器和客户端之间建立的连接是通过 WebSockets,因为服务器需要发送推送通知。尽管这个工具在某些情况下效果很好,但它有一个很大的缺点:一旦页面刷新,开发者交互期间收集的所有状态都将丢失。

举例来说,想象一种情况,你正在开发一个视图复杂的应用程序。你浏览了几个页面,填写表单,设置输入字段的值,然后突然发现了一个问题。你去你的文本编辑器或者 IDE 修复了这个问题;livereload 服务器检测到了项目根目录的变化,并发送通知给浏览器以刷新页面。现在,你回到了应用程序的初始状态,需要经过所有这些步骤才能达到刷新之前的同样状态。

与 livereloading 相比,在大多数情况下,热重载可以消除状态丢失。让我们简要看一下它是如何工作的。

热重载的典型实现有两个主要模块:客户端和服务器。与 livereloading 中的服务器相比,热重载服务器不仅监视文件系统的变化,还会获取变化文件的内容并发送给浏览器。一旦浏览器接收到服务器发送的消息,它就可以用新的实现替换之前的实现。之后,受到变化影响的视图可以重新渲染以直观地反映变化。由于应用程序不会丢失其状态,我们可以从已经达到的点继续使用变化后的代码单元的新版本。

不幸的是,并不总是可能使用这种策略动态交换所有组件的实现。如果你更新了保存应用程序状态的代码片段,可能需要手动刷新页面。

Angular 2 中的热重载

在撰写本文时,有一个可以在Angular 2 快速入门部分中使用的 angular2-seed 中测试的 Angular 2 热重载器的工作原型。该项目正在积极开发中,因此在路线图上有很多改进。但它已经提供了核心功能,可以显著简化开发体验。

使用 angular-cli 引导项目

在 AngularConnect 2015 期间,Angular 团队的 Brad Green 和 Igor Minar 宣布了angular-cli——一个 CLI(命令行界面)工具,用于简化启动和管理 Angular 2 应用程序。对于那些使用过 Ruby on Rails 的人来说,CLI 工具背后的想法可能很熟悉。该工具的基本目的是允许快速引导新项目和搭建新指令、组件、管道和服务。

在撰写本文时,该工具仍处于早期开发阶段,因此我们只会演示其基本用法。

使用 angular-cli

为了安装 CLI 工具,请在终端中运行以下命令:

**npm install -g angular-cli**

在此之后,全局的ng命令将出现在您的$PATH中。要创建一个新的 Angular 2 项目,请使用以下命令:

# May take a while, depending on your Internet connection
ng new angular-cli-project
cd angular-cli project
ng serve

上述命令将执行以下操作:

  • 创建一个新的 Angular 2 项目并安装其所有 node.js 依赖项。

  • 进入您的项目目录。

  • 启动开发 Web 服务器,让您在 Web 浏览器中打开刚创建的应用程序。

要进一步阅读,请查看项目的存储库,位于github.com/angular/angular-cli

Angular 2 快速入门

尽管 Angular 2 CLI 将会是令人惊叹的,但在撰写本文时,它仍处于早期开发阶段。它是构建工具不可知的,这意味着它不提供任何构建系统。幸运的是,社区开发了许多起始项目,可以为我们的下一个 Angular 2 项目提供一个很好的起点。

Angular 2 seed

如果你喜欢 Gulp 和静态类型,可以尝试 angular2-seed 项目。它托管在 GitHub 上的以下 URL:github.com/mgechev/angular2-seed

Angular 2 seed 提供以下关键功能:

  • 使用 Gulp 构建的高级、即插即用、易于扩展、模块化和静态类型的构建系统。

  • 生产和开发构建。

  • 使用 Jasmine 和 Karma 进行示例单元测试。

  • 使用 Protractor 进行端到端测试。

  • 带有 Livereload 的开发服务器。

  • 实验性的热重载支持。

  • 遵循应用程序和文件组织的最佳实践。

  • 与 TypeScript 相关的类型定义的管理器。

该书中分发的代码基于这个种子项目。

对于 angular2-seed,您需要安装 node.js、npm 和 Git,并且需要运行以下命令列表:

git clone --depth 1 https://github.com/mgechev/angular2-seed.git
cd angular2-seed
npm install
npm start

运行这些命令后,您的浏览器将自动打开种子的主页。在任何 TypeScript 文件发生更改时,代码将自动转译为 JavaScript,并且浏览器将被刷新。

生产构建是可配置的,默认情况下,它会生成一个包含应用程序的缩小版本和所有引用库的单个捆绑包。

Angular 2 Webpack 起始程序

如果您喜欢使用 Webpack 进行声明性和极简主义构建,您可以使用angular2-webpack-starter。这是一个由AngularClass开发并托管在 GitHub 上的起始项目。您可以在以下 URL 找到它:github.com/AngularClass/angular2-webpack-starter

该起始程序提供以下功能:

  • Angular 2 文件和应用程序组织的最佳实践。

  • 使用 Webpack 构建系统,用于处理 TypeScript。

  • 使用 Jasmine 和 Karma 测试 Angular 2 代码。

  • 使用 Istanbul 和 Karma 进行覆盖。

  • 使用 Protractor 进行端到端的 Angular 2 代码。

  • 带有 Typings 的类型管理器。

为了尝试一下,您需要安装 node.js、npm 和 git,并且需要运行以下命令:

git clone --depth 1 https://github.com/angularclass/angular2-webpack-starter.git
cd angular2-webpack-starter
npm install
./node_modules/.bin/typings install
npm start

摘要

我们通过介绍开发 Angular 2 的原因开始了这本书,接着是一个概念概述,让我们对框架为应用程序开发提供的构建块有了一个大致的了解。接下来,我们进行了一个 TypeScript 速成课程,为我们准备了第四章,开始使用 Angular 2 组件和指令,在这里我们深入研究了 Angular 的指令、组件和变更检测。

在第五章中,我们解释了 Angular 2 中的依赖注入机制,并看到了我们如何可以通过使用它来管理不同组件之间的关系。接下来的章节向我们解释了如何构建表单和管道,并利用 Angular 2 的基于组件的路由。

通过完成当前章节,我们完成了对这个框架的探索。在撰写本文时,Angular 2 核心背后的设计决策和思想已经稳固并最终确定。尽管这个框架仍然是全新的,但在过去几个月里,它的生态系统已经达到了一个水平,使我们能够开发出生产就绪、高性能、SEO 友好的应用,并且在此基础上,利用静态类型和热重载获得良好的开发体验。

posted @ 2024-05-18 12:02  绝不原创的飞龙  阅读(6)  评论(0编辑  收藏  举报