Angular2 切换指南(全)
原文:
zh.annas-archive.org/md5/AE0A0B893569467A0AAE20A9EA07809D
译者:飞龙
前言
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-cli
和angular2-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 是多么强大但也复杂。它包括许多属性,如postLink
、preLink
、compile
、restrict
、scope
、controller
等等,当然还有我们最喜欢的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-if
、ng-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 的不同部分之前,让我们先概述一下它们如何相互配合。让我们看一下下面的图表:
图 1
图 1至图 4显示了主要的 Angular 2 概念及它们之间的连接。这些图表的主要目的是说明使用 Angular 2 构建单页面应用程序的核心模块及其关系。
组件是我们将用来使用 Angular 2 创建应用程序用户界面的主要构建块。组件是指令的直接后继,指令是将行为附加到 DOM 的原始方法。组件通过提供进一步的功能(例如附加模板的视图)来扩展指令,该模板可用于呈现指令的组合。视图模板中可以包含不同的表达式。
图 2
上述图表概念上说明了 Angular 2 的变更检测机制。它运行digest
循环,评估特定 UI 组件上下文中注册的表达式。由于 Angular 2 中已经移除了作用域的概念,表达式的执行上下文是与其关联的组件的控制器。
变更检测机制可以通过Differs进行增强;这就是为什么在图表中这两个元素之间有直接关系的原因。
管道是 Angular 2 的另一个组件。我们可以将管道视为 AngularJS 1.x 中的过滤器。管道可以与组件一起使用。我们可以将它们包含在在任何组件上下文中定义的表达式中:
图 3
现在让我们看一下上述图表。指令和组件将业务逻辑委托给服务。这强化了关注点的分离、可维护性和代码的可重用性。指令使用框架的DI机制接收特定服务实例的引用,并将与它们相关的业务逻辑执行委托给它们。指令和组件都可以使用DI机制,不仅可以注入服务,还可以注入 DOM 元素和/或其他组件或指令。
图 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')
onm ouseEnter() {
this.overlay.open(this.el.nativeElement, this.tooltip);
}
@HostListener('mouseleave')
onm ouseLeave() {
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();
}],
onm ouseEnter() {
this.overlay.open(this.el.nativeElement, this.tooltip);
},
onm ouseLeave() {
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 中,我们有模型,它封装了我们应用程序的业务逻辑,以及视图,它负责呈现用户界面,接受用户输入,并将用户交互逻辑委托给控制器。视图被表示为组件的组合,这正式称为组合设计模式。
让我们看一下下面的结构图,它展示了组合设计模式:
图 5
这里有三个类:
-
一个名为
Component
的抽象类。 -
两个具体的类称为
Leaf
和Composite
。Leaf
类是我们即将构建的组件树中的简单终端组件。
Component
类定义了一个名为operation
的抽象操作。Leaf
和Composite
都继承自Component
类。然而,Composite
类还拥有对它的引用。我们甚至可以进一步允许Composite
拥有对Component
实例的引用列表,就像图示中所示。Composite
内部的组件列表可以持有对不同Composite
或Leaf
实例的引用,或者持有对扩展了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
在组件列表中持有对c2
和c3
的引用。实例c2
持有对l1
和l2
的引用,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中看到的Composite
和Leaf
之间的关系。
如果我们开始阐述 Angular 2 提供的构建块的概念模型,我们可以将指令和组件之间的关系呈现为继承。第四章开始使用 Angular 2 组件和指令更详细地描述了这两个概念。
管道
在业务应用中,我们经常需要对相同的数据进行不同的可视化表示。例如,如果我们有数字 100,000,并且想要将其格式化为货币,很可能我们不想将其显示为普通数据;更可能的是,我们想要类似$100,000 这样的东西。
在 AngularJS 1.x 中,格式化数据的责任被分配给了过滤器。另一个数据格式化需求的例子是当我们使用项目集合时。例如,如果我们有一个项目列表,我们可能想要根据谓词(布尔函数)对其进行过滤;在数字列表中,我们可能只想显示素数。AngularJS 1.x 有一个名为filter
的过滤器,允许我们这样做。然而,名称的重复经常导致混淆。这也是核心团队将过滤器组件重命名为管道的另一个原因。
新名称背后的动机是管道和过滤器所使用的语法:
{{expression | decimal | currency}}
在前面的例子中,我们将管道decimal
和currency
应用到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 Whatever(MVW)框架采用了这种方法,并将观察者模式嵌入到了它们的更改检测机制的核心中。
经典的更改检测
让我们看一个简单的例子,不使用任何框架。假设我们有一个名为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-controller
和ng-app
则为四个)属性,将属性添加到一个名为$scope
的神秘对象中,这个对象被神奇地传递给我们定义的自定义函数,一切都很简单!
然而,更有经验的 Angular 开发人员更好地理解了幕后实际发生的事情。在前面的例子中,在指令ng-model
和ng-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
函数作为factory
和service
方法的第二个参数传递。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 之间的关系。
图 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 Manager(npm)安装 TypeScript。我建议您使用 npm 版本 3.0.0 或更新版本。如果您尚未安装 node.js 和 npm,可以访问nodejs.org
并按照那里的说明进行操作。
使用 npm 安装 TypeScript
一旦您安装并运行了 npm,请通过打开终端窗口并运行以下命令来验证您是否拥有最新版本:
**$ npm –v**
要安装 TypeScript 1.8,请使用:
**$ npm install -g [email protected]**
上述命令将安装 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.js
。hello.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]
中所有的偶数。在第三个例子中,我们按照属性price
和total
的升序对数组进行了排序。
箭头函数还有一些我们需要看看的特性。其中最重要的一个是它们会保持周围代码的上下文(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 {}
当装饰器接受参数(就像前面示例中的Component
、RouteConfig
和View
一样),它们需要被定义为接受参数并返回实际装饰器的函数:
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 实用模块。在其中,我们定义并导出了函数square
和log10
,以及常量PI
。const
关键字是 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
作为模块的标识符,我们使用了相对于当前文件的路径。通过解构,我们导入了所需的函数——在这种情况下是square
和log10
。
利用隐式的异步行为
重要的是要注意,ES2015 模块语法具有隐式的异步行为。
假设我们有模块A
,B
和C
。模块A
使用模块B
和C
,所以它依赖于它们。一旦用户需要模块A
,JavaScript 模块加载器就需要在能够调用模块A
中的任何逻辑之前加载模块B
和C
,因为我们有依赖关系。然而,模块B
和C
将被异步加载。一旦它们完全加载,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 继承。
在前面的示例中,我们直接在构造函数内部设置了name
和age
属性的访问修饰符。这种语法背后的语义与前面示例中使用的语法不同。它的含义是:定义一个受保护的名为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;
}
现在,我们可以通过以下方式确保Individual
和Firm
都实现了这个接口:
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
也可以扩展具有name
和age
属性的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>
在前面的片段中,我们创建了三个节点:numberNode
,stringNode
和另一个类型为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.ts
和main/ambient/angular/angular.d.ts
中。
注意
拥有main/ambient
和browser/ambient
目录是为了防止类型冲突。例如,如果我们在项目的backend/build
和前端都使用 TypeScript,可能会引入类型定义的重复,这将导致编译时错误。通过为项目的各个部分的环境类型定义拥有两个目录,我们可以分别使用main.d.ts
和browser.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 shim
,zone.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
,您应该会看到一些错误,如下面的截图所示:
这是因为我们错过了一些非常重要的东西。我们没有在 index.html
中的任何地方使用根组件。为了完成应用程序,在 body 元素的开放标签之后添加以下 HTML 元素:
<app></app>
现在,您可以刷新浏览器以查看以下结果:
注意
使用 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 树,我们在屏幕上看到的只有h1
、p
元素和ul
元素,没有任何列表项。
现在,在刷新浏览器后,您应该看到以下结果:
到目前为止,一切都很好!在前面的片段中,唯一剩下的新事物是我们不熟悉的template
元素的属性,如ngFor
、var-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 指令需要使用模板元素,例如ngForOf
,ngIf
和ngSwitch
。由于这些指令经常被使用,因此有一种替代语法。我们可以简单地在指令前加上*
,而不是明确地输入整个模板元素。这将允许我们将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')
onm ouseEnter() {
this.overlay.open(this.el, this.saTooltip);
}
@HostListener('mouseleave')
onm ouseLeave() {
this.overlay.close();
}
}
设置指令的输入
在上面的例子中,我们使用了saTooltip
选择器声明了一个指令。请注意,Angular 的 HTML 编译器是区分大小写的,这意味着它将区分[satooltip]
和[saTooltip]
选择器。稍后,我们将使用@Input
装饰器声明指令的输入,放在saTooltip
属性上。这段代码背后的语义是:声明一个名为saTooltip
的属性,并将其绑定到我们从传递给saTooltip
属性的表达式的评估结果的值。
@Input
装饰器接受一个参数——我们想要绑定的属性的名称。如果我们不传递参数,Angular 将创建一个属性名称与属性本身相同的属性之间的绑定。我们将在本章后面详细解释输入和输出的概念。
理解指令的构造函数
构造函数声明了两个私有属性:el
是ElementRef
类型的,overlay
是Overlay
类型的。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
方法以类似的方式传递它们,以使它们在整个应用程序中可用,以防止我们重复编码。这些预定义指令的列表包括NgClass
、NgFor
、NgIf
、NgStyle
、NgSwitch
、NgSwitchWhen
和NgSwitchDefault
。它们的名称相当自明;我们将在本章后面看看如何使用其中一些。
使用 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
装饰器装饰它。请注意,在组件使用的指令列表中,我们包括了InputBox
和TodoList
。这些组件如何协同工作的魔法发生在模板中:
<input-box inputPlaceholder="New todo..."
buttonLabel="Add"
(inputText)="addTodo($event)">
</input-box>
首先,我们使用InputBox
组件并向输入传递值:inputPlaceholder
和buttonLabel
。请注意,就像我们之前看到的那样,如果我们想将表达式作为值传递给任何这些输入中的一个,我们需要用括号括起来(即[inputPlaceholder]="expression"
)。在这种情况下,表达式将在拥有模板的组件的上下文中进行评估,并作为输入传递给拥有给定属性的组件。
在为buttonLabel
输入传递值后,我们通过将(inputText)
属性的值设置为addTodo($event)
表达式来消耗inputText
输出。$event
的值将等于我们传递给InputBox
的inputText
对象的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 { }
我们已经描述了selector
和styles
属性,现在让我们来看一下组件的模板。我们有一个带有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
组件的实例中获取对所有TabTitle
和TabContent
实例的引用,并按照它们在标记中声明的顺序获取它们。还有一个叫做@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 {}
在这里,我们定义了两个组件:UserBadge
和UserRating
。让我们定义一个包含这两个组件的父组件:
@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
的第一个子组件的实例”。
现在,如果您运行此代码,您会注意到在控制器的构造函数内,badge
和rating
的值仍然等于undefined
。这是因为它们在组件生命周期的这个阶段仍然没有初始化。我们可以使用ngAfterViewInit
和ngAfterContentInit
生命周期钩子来获取对这些子组件的引用。我们可以通过向组件的控制器添加ngAfterViewInit
和ngAfterContentInit
方法的定义来简单地使用这些钩子。我们将很快对 Angular 2 提供的生命周期钩子进行全面概述。
总之,我们可以说给定组件的内容子代是嵌套在组件宿主元素内的子元素。相反,给定组件的视图子代指令是其模板中使用的元素。
注意
为了获得对 DOM 元素的平台无关引用,我们可以再次使用@ContentChildren
和@ViewChildren
。例如,如果我们有以下模板:<input #todo>
,我们可以通过使用@ViewChild('todo')
来获取对input
的引用。
既然我们已经熟悉了视图子代和内容子代之间的核心区别,现在我们可以继续实现我们的选项卡。
在标签组件中,我们使用的是@ContentChildren
而不是@ContentChild
装饰器。我们这样做是因为我们有多个内容子级,我们想要获取它们所有:
@ContentChildren(TabTitle)
tabTitles: QueryList<TabTitle>;
@ContentChildren(TabContent)
tabContents: QueryList<TabContent>;
我们可以注意到的另一个主要区别是,tabTitles
和tabContents
属性的类型是带有相应类型参数的QueryList
,而不是组件本身的类型。我们可以将QueryList
数据结构视为 JavaScript 数组——我们可以对其应用相同的高阶函数(map
、filter
、reduce
等),并循环遍历其元素;但是,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
:当给定组件的变更检测器被调用时,将调用此方法。它允许我们为给定组件实现自己的变更检测算法。请注意,DoCheck
和OnChanges
不应该在同一个指令上同时实现。 -
OnDestroy
:如果我们实现了OnDestroy
接口及其单个ngOnDestroy
方法,我们可以钩入组件销毁生命周期阶段。一旦组件从组件树中分离,将调用此方法。
现在,让我们来看一下与组件内容和视图子元素相关的生命周期钩子:
-
AfterContentInit
:如果我们实现了ngAfterContentInit
生命周期钩子,那么当组件的内容完全初始化时,我们将收到通知。这是使用ContentChild
或ContentChildren
装饰的属性将被初始化的阶段。 -
AfterContentChecked
:通过实现此钩子,我们将在每次 Angular 2 的变更检测机制检查给定组件的内容时收到通知。 -
AfterViewInit
:如果我们实现了ngAfterViewInit
生命周期钩子,那么当组件的视图完全初始化时,我们将收到通知。这是使用ViewChild
或ViewChildren
装饰的属性将被初始化的阶段。 -
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"
表达式的结果被评估为true
,ngOnChanges
方法将被调用。这是因为标题和说明属性的值将首次设置。
紧接着,ngOnInit
方法将被调用,因为组件已经初始化。一旦组件的初始化完成,将触发变更检测,这将导致调用ngDoCheck
方法,允许我们钩入自定义逻辑以检测状态的变化。
注意
请注意,您不应该为同一个组件同时实现ngDoCheck
和ngOnChanges
方法,因为它们是互斥的。这里的示例仅用于学习目的。
在ngDoCheck
方法之后,组件的内容将被检查(按顺序调用ngAfterContentInit
和ngAfterContentChecked
)。紧接着,组件的视图也将发生同样的情况(ngAfterViewInit
后跟ngAfterViewChecked
)。
一旦ngIf
指令的表达式被评估为false
,整个组件将从视图中分离,这将导致ngOnDestroy
钩子的调用。
在下一个按钮点击时,如果ngIf
表达式的值等于true
,则与初始化阶段相同的生命周期钩子调用顺序将被执行。
使用 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
循环。
在第一章和第二章中,我们讨论了变更检测的两种主要实现:DynamicChangeDetector
和JitChangeDetector
。第一种对于具有严格CSP(内容安全策略)的环境非常有效,因为它禁用了 JavaScript 的动态评估。第二种则充分利用了 JavaScript 虚拟机的内联缓存机制,因此带来了很好的性能!
在本节中,我们将探讨@Component
装饰器配置对象的另一个属性,它通过改变策略为我们提供了对框架的变更检测机制更进一步的控制。通过显式设置策略,我们能够阻止变更检测机制在组件的子树上运行,这在某些情况下可以带来很好的性能优势。
变更检测的执行顺序
现在,让我们简要描述一下变更检测在给定组件树中被调用的顺序。
为此,我们将使用我们拥有的待办事项应用程序的最后一个实现,但这次,我们将提取渲染单独待办事项的逻辑到一个名为 TodoItem
的单独组件中。在下图中,我们可以看到应用程序的结构:
顶层是 TodoApp
组件,它有两个子组件:InputBox
和 TodoList
。TodoList
组件在 TodoItem
组件中呈现单独的待办事项。实现细节对我们的目的不重要,所以我们将忽略它们。
现在,我们需要意识到父组件和其子组件之间存在隐含的依赖关系。例如,TodoList
组件的状态完全取决于其父级 TodoApp
组件中的待办事项。TodoItem
和 TodoList
之间也存在类似的依赖关系,因为 TodoList
组件将单独的待办事项传递给 TodoItem
组件的单独实例。
由于我们的最后观察,附加到各个组件的变更检测器的执行顺序如前图所示。一旦变更检测机制运行,它将首先对 TodoApp
组件进行检查。紧接着,将检查 InputBox
组件是否有变化,然后是 TodoList
组件。最后,Angular 将调用 TodoItem
组件的变更检测器。
您可以在 ch4/ts/change_detection_strategy_order/app.ts
示例中跟踪执行顺序,其中每个单独的组件在调用其 ngDoCheck
方法时记录一条消息。
注意
请注意,只有组件才有一个附加的变更检测器实例;指令使用其父组件的变更检测器。
变更检测策略
Angular 2 提供的变更检测策略有:CheckOnce
、Checked
、CheckAlways
、Detached
、Default
和 OnPush
。我们将详细描述如何充分利用 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
属性构造为不可变列表。由于不可变列表的变异操作会返回一个新列表,我们需要在addTodo
和toggleTodoCompletion
中进行轻微修改:
...
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 语法时相同的结果。
我为什么需要依赖注入?
假设我们有一个依赖于Engine
和Transmission
类的Car
类。我们如何实现这个系统?让我们看一下:
class Engine {…}
class Transmission {…}
class Car {
engine;
transmission;
constructor() {
this.engine = new Engine();
this.transmission = new Transmission();
}
}
在这个例子中,我们在Car
类的构造函数中创建了它的依赖关系。虽然看起来很简单,但远非灵活。每次我们创建Car
类的实例时,都会创建相同的Engine
和Transmission
类的实例。这可能会有问题,原因如下:
-
Car
类变得不太可测试,因为我们无法独立测试它的engine
和transmission
依赖关系。 -
将
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,了解如何继续在计算机上安装并运行它。
我们导入了Injector
、Injectable
、Inject
、OpaqueToken
和provide
。
注入器表示用于实例化不同依赖项的容器。使用provide
函数声明的规则和 TypeScript 编译器生成的元数据,它知道如何创建它们。
在前面的代码片段中,我们首先定义了BUFFER_SIZE
常量,并将其设置为new OpaqueToken('buffer-size')
的值。我们可以将BUFFER_SIZE
的值视为应用程序中无法复制的唯一值(OpaqueToken
是 ES2015 中Symbol
类的替代品,因为在撰写本文时,TypeScript 不支持Symbol
)。
我们定义了两个类:Buffer
和Socket
。Buffer
类有一个构造函数,只接受一个名为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 类不会被提升。
-
允许解析在声明依赖提供者之后声明的依赖项。
在本节中,我们将解释前向引用解决的问题以及我们可以利用它们的方式。
现在,假设我们已经以相反的顺序定义了Buffer
和Socket
类:
// 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
]);
在前面的片段中,我们注册了三个令牌:DummyService
、UserService
和Http
。我们声明要将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
]);
最初,我们注册了一些提供者:Buffer
、Socket
、Certificate
和Crypto
。就像前面的例子一样,我们还注册了BUFFER_SIZE
令牌,并将其与值42
关联起来。这意味着我们已经可以创建Buffer
、Socket
、Certificate
和Crypto
类型的对象:
// 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
的工厂函数。作为依赖项,我们声明了Socket
,Certificate
和Crypto
。这些依赖项由 Angular 2 的 DI 机制解析并注入到工厂函数中。您可以在ch5/ts/configuring-providers/factory.ts
中查看整个实现并进行操作。
子注入器和可见性
在本节中,我们将看看如何构建注入器的层次结构。这是 Angular 2 引入的一个全新概念。每个注入器可以有零个或一个父注入器,每个父注入器可以分别有零个或多个子注入器。与 AngularJS 1.x 相比,在 Angular 2 中,所有注册的提供者都存储在树中,而不是存储在一个扁平的结构中。扁平结构更为有限;例如,它不支持标记的命名空间;也就是说,我们不能为同一个标记声明不同的提供者,这在某些情况下可能是必需的。到目前为止,我们看了一个没有任何子节点或父节点的注入器的示例。现在让我们构建一个注入器的层次结构!
为了更好地理解这种注入器的层次结构,让我们看一下下图:
在这里,我们看到一个树,其中每个节点都是一个注入器,每个注入器都保留对其父级的引用。注入器House有三个子注入器:Bathroom,Kitchen和Garage。
Garage有两个子节点:Car和Storage。我们可以将这些注入器视为内部注册了提供者的容器。
假设我们想要获取与标记Tire相关联的提供程序的值。如果我们使用注射器Car,这意味着 Angular 2 的 DI 机制将尝试在Car及其所有父级Garage和House中查找与此标记相关联的提供程序,直到找到为止。
构建注射器的层次结构
为了更好地理解上一段,让我们看一个简单的例子:
// 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));
由于它们对于解释前面的片段并不重要,所以省略了导入部分。我们有两个服务,Http
和UserService
,其中UserService
依赖于Http
服务。
最初,我们使用Injector
类的resolveAndCreate
静态方法创建了一个注射器。我们向此注射器传递了一个隐式提供程序,稍后将解析为具有Http
标记的提供程序。使用resolveAndCreateChild
,我们解析了传递的提供程序并实例化了一个注射器,该注射器指向parentInjector
(因此我们得到与上图中Garage和House之间相同的关系)。
现在,使用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 头像(如果有)。
-
高级资料:显示开发人员已知的所有详细信息。
应用程序主页的最终结果将如下所示:
图 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
,它是一个装饰器,允许我们定义与给定组件关联的路由;以及Route
和Redirect
,分别允许我们定义单个路由和重定向。使用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
装饰器接受一个路由数组作为参数。在这个例子中,我们定义了两种类型的路由:使用Route
和Redirect
类。它们分别用于定义应用程序中的路由和重定向。
每个路由必须定义以下属性:
-
component
:与给定路由相关联的组件。 -
name
:用于在模板中引用的路由名称。 -
path
:用于路由的路径。它将显示在浏览器的位置栏中。
注意
Route
类还支持一个数据属性,其值可以通过使用RouteData
令牌注入到其关联组件的构造函数中。数据属性的一个示例用例可能是,如果我们想要根据包含@RouteConfig
声明的父组件的类型来注入不同的配置对象。
另一方面,重定向只包含两个属性:
-
path
:用于重定向的路径。 -
redirectTo
:用户被重定向到的路径。
在前面的例子中,我们声明希望用户打开路径/add-dev
的页面被重定向到['/dev-add']
。
现在,为了使一切正常运行,我们需要定义AddDeveloper
和Home
组件,这些组件在@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 {}
使用 routerLink 和 router-outlet
我们已经声明了路由和与各个路由相关联的所有组件。唯一剩下的就是定义根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/
,你应该会看到以下的截图:
图 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 中检查实际发生了什么:
图 3
我们可以注意到在初始页面加载期间,我们下载了与所有路由相关的组件,甚至不需要的AddDeveloper
。这是因为在app.ts
中,我们明确要求Home
和AddDeveloper
组件,并在@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 表单
现在让我们继续实现应用程序。在下一步中,我们将在AddDeveloper
和Home
组件上工作。您可以通过扩展ch6/ts/step-0
中当前的内容继续实现,或者如果您还没有达到步骤 1,您可以继续在ch6/ts/step-1
中的文件上工作。
Angular 2 提供了两种开发带有验证的表单的方式:
-
基于模板驱动的方法:提供了一个声明性的 API,我们可以在组件的模板中声明验证。
-
基于模型驱动的方法:使用
FormBuilder
提供了一个命令式的 API。
在下一章中,我们将探讨两种方法。让我们从模板驱动的方法开始。
开发模板驱动的表单
对于每个CRUD(创建检索更新和删除)应用程序,表单都是必不可少的。在我们的情况下,我们想要为输入我们想要存储的开发者的详细信息构建一个表单。
在本节结束时,我们将拥有一个表单,允许我们输入给定开发者的真实姓名,添加他或她喜欢的技术,输入电子邮件,并声明他或她是否在社区中受欢迎。最终结果将如下所示:
图 4
将以下导入添加到add_developer.ts
:
import {
FORM_DIRECTIVES,
FORM_PROVIDERS
} from 'angular2/common;
我们需要做的下一件事是将FORM_DIRECTIVES
添加到AddDeveloper
组件使用的指令列表中。FORM_DIRECTIVES
指令包含一组预定义指令,用于管理 Angular 2 表单,例如form
和ngModel
指令。
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
属性包含与当前要添加到表单中的开发者相关的信息。最后两个属性,errorMessage
和successMessage
,分别用于在成功将开发者成功添加到开发者集合中或发生错误时显示当前表单的错误或成功消息。
深入研究模板驱动表单的标记
作为下一步,让我们创建AddDeveloper
组件的模板(step-1/add_developer.html
)。将以下内容添加到文件中:
<span *ngIf="errorMessage"
class="alert alert-danger">{{errorMessage}}</span>
<span *ngIf="successMessage"
class="alert alert-success">{{successMessage}}</span>
这两个元素旨在在添加新开发人员时显示错误和成功消息。当errorMessage
和successMessage
分别具有非假值时(即,与空字符串、false
、undefined
、0
、NaN
或null
不同的值),它们将可见。
现在让我们开发实际的表单:
<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-group
的div
元素。
注意
请注意,这不是一个特定于 Angular 的类;这是 Bootstrap 定义的一个CSS
类,我们使用它来提供表单更好的外观和感觉。
在其中,我们可以找到一个没有任何 Angular 特定标记的label
元素和一个允许我们设置当前开发人员的真实姓名的输入元素。我们将控件设置为文本类型,并声明其标识符等于realNameInput
。required
属性由 HTML5 规范定义,并用于验证。通过在元素上使用它,我们声明这个元素需要有一个值。虽然这个属性不是特定于 Angular 的,但使用ngControl
属性,Angular 将通过包含验证行为来扩展required
属性的语义。这种行为包括在控件状态改变时设置特定的CSS
类,并管理框架内部保持的状态。
ngControl
指令是NgControlName
指令的选择器。它通过在值更改时对它们运行验证并在控件生命周期期间应用特定类来增强表单控件的行为。您可能熟悉这一点,因为在 AngularJS 1.x 中,表单控件在其生命周期的特定阶段装饰有ng-pristine
、ng-invalid
和ng-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">
通过这种方式,我们指定希望输入的值在2
和30
个字符之间。
定义自定义控件验证器
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/common
和angular2/core
导入NG_VALIDATORS
和Directive
,并将此验证函数包装在以下指令中:
@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
控件的外部模板。关于给定模板是否应该被外部化或内联在具有templateUrl
或template
的组件中,没有最终答案。最佳实践规定,我们应该内联短模板并外部化较长的模板,但没有具体定义哪些模板被认为是短的,哪些是长的。模板应该内联还是放入外部文件的决定取决于开发人员的个人偏好或组织内的常见惯例。
使用 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 属性访问各个控件。此外,表单还具有touched、untouched、pristine、dirty、invalid和valid属性,这些属性取决于表单中定义的各个控件。例如,如果表单中的控件都没有被触摸过,那么表单本身的状态就是 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
元素的control
和errors
属性指定。
例如,如果我们有控件:
<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
数组中弹出它来返回最后一个。
最终结果应如下所示:
图 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/
,你会看到以下结果:
图 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
。
这是我们在本节结束时要实现的结果:
在上面的截图中,有以下两种表单:
-
一个用于从 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>
现在,让我们在组件的控制器中声明importDevForm
和addDevForm
属性:
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
实例来初始化importDevForm
和addDevForm
属性:
…
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
有两个我们之前介绍的字段:githubHandle
和fetchFromGitHub
。我们声明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_PROVIDERS
和 GitHubGateway
添加到 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 的核心团队只包含了它的核心部分。为了使用 map
和 catch
方法,您需要在 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_info
和developer_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
属性的值,我们使用NgSwitch
和NgSwitchThen
指令显示了不同的数据。虽然这样可以工作,但是有些冗余。
开发无状态管道
让我们开发一个管道,转换popular
属性的值并在NgSwitch
和NgSwitchThen
的位置使用它。该管道将接受三个参数:应该被转换的值,当值为真时应该显示的字符串,以及在值为假时应该显示的另一个字符串。
通过使用 Angular 2 自定义管道,我们将能够简化模板为:
<td>{{dev.popular | boolean: 'Yes': 'No'}}</td>
我们甚至可以使用表情符号:
<td>{{dev.popular | boolean: '
标签:指南,ts,应用程序,我们,Angular2,切换,使用,组件,Angular
From: https://www.cnblogs.com/apachecn/p/18199193