Angular-入门指南-全-
Angular 入门指南(全)
原文:
zh.annas-archive.org/md5/0d9bf4790260519f93dbf9dab2197957译者:飞龙
前言
AngularJS 是一个使构建 Web 应用程序更简单的 JavaScript 开发框架。它被用于今天的大型、高流量网站,这些网站在性能和可移植性问题上以及 SEO 不友好和规模复杂性方面存在挑战。Angular 的新版本改变了这些。
它是您构建高性能和健壮 Web 应用程序所需的现代框架。"开始使用 Angular" 是快速掌握 Angular 的最佳途径;它将帮助您过渡到充满活力的 Angular 2 及其以后的全新世界。
到本书结束时,您将准备好开始构建快速高效的 Angular 应用程序,充分利用所有新功能。
本书涵盖的内容
第一章,开始使用 Angular,标志着我们进入 Angular 世界的旅程。它描述了框架设计决策背后的主要原因。我们将探讨框架形状背后的两个主要驱动力——Web 的当前状态和前端开发的演变。
第二章,Angular 应用程序的构建块,概述了 Angular 2 引入的核心概念。我们将探讨 AngularJS 为应用程序开发提供的基石与框架上一个主要版本中的基石有何不同。
第三章,TypeScript 快速入门,解释说尽管 Angular 是语言无关的,但 Google 的建议是利用 TypeScript 的静态类型。在本章中,您将学习到开发 TypeScript Angular 应用程序所需的所有基本语法。
第四章,开始使用 Angular 组件和指令,描述了开发我们应用程序用户界面的核心构建块——指令和组件。我们将深入研究诸如视图封装、内容投影、输入和输出、变更检测策略等概念。我们将讨论高级主题,例如模板引用和利用不可变数据加速我们的应用程序。
第五章,Angular 中的依赖注入,涵盖了框架中最强大的功能之一,该功能最初由 AngularJS 引入:其依赖注入机制。它允许我们编写更易于维护、测试和理解的可维护代码。在本章结束时,我们将了解如何在服务中定义业务逻辑,并通过 DI 机制将它们与 UI 粘合在一起。我们还将探讨一些更高级的概念,例如注入器层次结构、配置提供者等。
第六章,使用 Angular 路由和表单,探讨了在开发实际应用过程中管理表单的新模块。我们还将实现一个显示通过表单输入的数据的页面。最后,我们将使用基于组件的路由将各个页面粘合在一起形成一个应用。
第七章,解释管道和与 RESTful 服务通信,详细探讨了路由和表单模块。在这里,我们将探讨如何开发模型驱动的表单,定义参数化和子路由。我们还将解释 HTTP 模块,并查看如何开发纯管道和不纯管道。
第八章,工具和开发体验,探讨了 Angular 应用开发中的某些高级主题,例如即时编译、在 Web Worker 中运行应用和服务器端渲染。本章的第二部分,我们将探讨可以简化我们作为开发者日常生活的工具,例如 angular-cli、angular-seed 等。
你需要为这本书准备什么
为了完成本书中的大多数示例,你只需要一个简单的文本编辑器或 IDE、Node.js、已安装的 TypeScript、互联网接入和浏览器。
每章都介绍了运行提供的代码片段所需的软件要求。
这本书适合谁阅读
你是否想直接深入 Angular 的深处?也许你在迁移之前想评估一下变化?如果是这样,那么《Angular 入门》这本书就是为你准备的。为了最大限度地利用这本书,你需要熟悉 AngularJS,并对 JavaScript 有良好的理解。不需要了解对 Angular 2 及以后版本所做的更改,就可以跟随本书学习。
习惯用法
在这本书中,你会发现许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名如下所示:“你应该看到相同的结果,但不需要在磁盘上存储test.js文件。”代码块设置如下:
@Injectable()
class Socket {
 constructor(private buffer: Buffer) {}
}
let injector = ReflectiveInjector.resolveAndCreate([
  provide(BUFFER_SIZE, { useValue: 42 }),
  Buffer,
  Socket
]);
injector.get(Socket);
当我们希望引起你对代码块中特定部分的注意时,相关的行或项目将以粗体显示:
let injector = ReflectiveInjector.resolveAndCreate([
  provide(BUFFER_SIZE, { useValue: 42 }),
  Buffer,
  Socket
]);
存储在代码库中的每个代码片段,其代码均从本书开始,并带有相应的文件位置注释:
// ch5/ts/injector-basics/forward-ref.ts
@Injectable()
class Socket {
  constructor(private buffer: Buffer) {…}
}
新术语和重要词汇以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,以引号包围或在文本中如下所示:“当标记渲染到屏幕上时,用户将看到的只是标签:加载中…”
注意
警告或重要注意事项会出现在像这样的框中。
小贴士
小技巧和技巧会像这样显示。
读者反馈
我们欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢什么或不喜欢什么。读者的反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大价值的书籍。
要发送给我们一般性的反馈,请简单地发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书的标题。
如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在,您已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大价值。
下载示例代码
您可以从 GitHub 下载此书的示例代码文件,网址为github.com/mgechev/getting-started-with-angular。您可以通过以下步骤下载代码文件:
- 
在浏览器地址栏中输入 URL。 
- 
点击屏幕中间右侧位置的下载 ZIP按钮。 
您也可以从您的 Packt 账户下载此书的示例代码文件,网址为www.packtpub.com。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您也可以按照以下步骤下载代码文件:
- 
使用您的电子邮件地址和密码登录或注册我们的网站。 
- 
将鼠标指针悬停在顶部的支持标签上。 
- 
点击代码下载与勘误。 
- 
在搜索框中输入书的名称。 
- 
选择您想要下载代码文件的书籍。 
- 
从下拉菜单中选择您购买此书的来源。 
- 
点击代码下载。 
一旦文件下载完成,请确保您使用最新版本的以下软件解压缩或提取文件夹:
- 
WinRAR / 7-Zip for Windows 
- 
Zipeg / iZip / UnRarX for Mac 
- 
7-Zip / PeaZip for Linux 
第三章和第四章包含有关安装过程的更多信息。
勘误
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然会发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误表,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。
盗版
在互联网上对版权材料的盗版是所有媒体中持续存在的问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过 copyright@packtpub.com 联系我们,并提供涉嫌盗版材料的链接。
我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。
问题
如果您对本书的任何方面有问题,您可以通过 questions@packtpub.com 联系我们,我们将尽力解决问题。
第一章。开始使用 Angular
2014 年 9 月 18 日,谷歌将第一个公开提交推送到包含新版本 Angular 的存储库。几周后,在 ng-europe 上,核心团队中的 Igor 和 Tobias 给出了一个关于新版本框架预期内容的简要概述。当时的愿景远未最终确定;然而,有一点是确定的:新版本的框架将完全不同于 AngularJS。
这一公告引发了许多问题和争议。这些重大变化背后的原因非常明显:AngularJS 已无法充分利用演进的 Web,以及大规模 JavaScript 应用程序的需求需要得到完全满足。一个新的框架将让 Angular 开发者以更简单、更高效、更富有成效的方式利用 Web 技术的发展。然而,人们对此表示担忧。对于开发者来说,向后不兼容的最大噩梦之一是将他们当前的代码库迁移到他们使用的第三方软件的新版本。在 Angular 的情况下,在那次第一次公告之后,迁移看起来令人畏惧,甚至不可能。后来,在 2015 年的 ng-conf 和 ng-vegas 上,介绍了不同的迁移策略。Angular 社区团结起来,分享了额外的想法,预计新版本的框架将带来好处,同时保留从 AngularJS 中学到的经验。
本书是该项目的一部分。现在将 Angular 升级到新版本变得平滑且值得。Angular 2 和其缺乏向后兼容性的巨大变化背后的主要推动力是 Web 的发展以及从 AngularJS 的实际使用中吸取的教训。《Angular 入门》将帮助你通过了解我们是如何到达这里的以及为什么 Angular 的新特性对现代 Web 来说在构建高性能、可扩展的单页应用程序方面具有直观的意义来学习新框架。本章我们将讨论的一些主题包括:
- 
如何使用 TypeScript 以及它如何扩展 JavaScript。 
- 
使用基于组件架构构建 Angular 应用程序的用户界面。 
- 
使用 Angular 的依赖注入机制并将业务逻辑委托给服务。 
- 
我们将深入探讨 Angular 的路由和表单模块。 
- 
我们将探讨即时编译(Ahead-of-Time compilation)以构建闪电般快速的应用程序。 
Angular 采用了语义版本控制,因此在继续之前,让我们先概述一下这实际上意味着什么。
Angular 和 semver
AngularJS 从头开始重写,并替换为其继任者 Angular 2。我们中的许多人对此重大步骤感到烦恼,因为它不允许我们在这两个框架版本之间实现平滑过渡。在 Angular 2 稳定后,谷歌宣布他们希望遵循所谓的语义版本控制(也称为 semver)。
Semver 定义了给定软件项目的版本为X.Y.Z的三元组,其中 Z 被称为修补版本,Y 被称为次要版本,X 被称为主版本。修补版本的变化意味着同一项目的两个版本之间没有预期的破坏性变化,只有错误修复。当引入新功能且没有破坏性变化时,项目的次要版本将增加。最后,当 API 中引入不兼容的变化时,主版本将增加。
这意味着在 2.3.1 和 2.10.4 版本之间,没有引入破坏性变化,只有一些新增功能和错误修复。然而,如果我们有 2.10.4 版本,并且想要以向后不兼容的方式更改任何现有的公共 API(例如,更改方法接受的参数顺序),我们需要增加主版本号,并将修补版本和次要版本重置,这样我们将得到版本 3.0.0。
Angular 团队也遵循严格的计划。根据该计划,每个星期需要引入一个新的修补版本;在每次主要版本发布后,应有三次每月的次要版本发布,最后,每六个月发布一次主要版本。这意味着到 2018 年底,我们将至少拥有 Angular 6。然而,这并不意味着每六个月我们都必须经历像 AngularJS 和 Angular 2 之间的相同迁移路径。并非每个主要版本都会引入影响我们项目的破坏性变化。例如,对 TypeScript 新版本的支持或方法最后一个可选参数的改变将被视为破坏性变化。我们可以将这些破坏性变化视为类似于 AngularJS 1.2 和 AngularJS 1.3 之间发生的情况。
注意
由于本书中您将要阅读的内容将主要适用于不同的 Angular 版本,我们将 Angular 2 称为 Angular 2 或仅称 Angular。如果我们明确提到 Angular 2,这并不意味着给定的段落对于 Angular 4 或 Angular 5 将不适用;它很可能适用。如果您对框架不同版本之间的变化感兴趣,可以查看变更日志github.com/angular/angular/blob/master/CHANGELOG.md。如果我们讨论的是 AngularJS(即框架的 1.x 版本),我们将通过提及版本号或使用 AngularJS 代替 Angular 来更加明确。
现在我们已经介绍了 Angular 的语义版本和引用框架不同版本的约定,我们可以正式开始我们的旅程了!
网络的演变 - 是时候引入新的框架了
在过去几年里,Web 经历了巨大的发展。在实施 ECMAScript 5 的过程中,ECMAScript 6 标准开始了其开发(现在被称为ECMAScript 2015或ES2015)。ES2015 在 JavaScript 中引入了许多变化,例如添加内置语言支持模块、块作用域变量定义以及许多语法糖,如类和结构赋值。
同时,Web Components被发明了。Web Components 允许我们定义自定义的 HTML 元素并将行为附加到它们上。由于很难通过添加新的元素(如对话框、图表、网格等)来扩展现有的 HTML 元素集,这主要是因为它们 API 的整合和标准化所需的时间,因此一个更好的解决方案是允许开发者以他们想要的方式扩展现有的元素。Web Components 为我们提供了许多好处,包括更好的封装、我们产生的标记的更好语义、更好的模块化,以及开发者和设计师之间更易沟通。
我们知道 JavaScript 是一种单线程语言。最初,它是为了简单的客户端脚本开发而开发的,但随时间推移,它的角色发生了很大变化。现在,有了 HTML5,我们有不同的 API 允许音频和视频处理、通过双向通信通道与外部服务进行通信、传输和处理大量原始数据等。所有这些在主线程中的重计算可能会创建一个糟糕的用户体验。它们可能会在执行耗时计算时导致用户界面冻结。这导致了Web Workers的发展,它允许在后台执行脚本并通过消息传递与主线程通信。这样,多线程编程被带到了浏览器中。
一些这些 API 是在 AngularJS 开发开始之后引入的;这就是为什么框架在构建时没有考虑到它们中的大多数。利用这些 API 为开发者带来了许多好处,例如以下这些:
- 
显著的性能改进。 
- 
开发具有更好质量特性的软件。 
现在,让我们简要讨论这些技术是如何成为新 Angular 核心的一部分以及为什么。
ECMAScript 的演变
现在,浏览器厂商正在以短周期迭代的方式发布新功能,用户经常收到更新。这有助于开发者利用前沿的 Web 技术。ES2015 已经标准化。该语言最新版本的实现已经在主要浏览器中开始。学习新语法并利用它不仅会增加我们的开发效率,而且还会为我们为即将到来的所有浏览器都将完全支持它的近未来做好准备。这使得现在开始使用最新语法变得至关重要。
一些项目的需求可能迫使我们支持不支持任何 ES2015 特性的旧浏览器。在这种情况下,我们可以直接编写 ECMAScript 5,它具有不同的语法但与 ES2015 具有等效的语义。另一方面,更好的方法将是利用转译过程。在我们的构建过程中使用转译器允许我们通过编写 ES2015 并将其转换为浏览器支持的目标语言来利用新语法。
Angular 自 2009 年以来一直存在。当时,大多数网站的前端由 ECMAScript 3 提供支持,这是 ECMAScript 5 之前的最后一个主要版本。这自动意味着框架实现所使用的语言是 ECMAScript 3。利用语言的新版本需要将 AngularJS 的全部内容移植到 ES2015。
从一开始,Angular 2 就考虑了 Web 的当前状态,通过引入框架中的最新语法。虽然新的 Angular 是用 ES2016 的超集(TypeScript)编写的(我们将在第三章中查看 AngularJS 控制器的责任如何被新的组件和指令所取代。
范围
在 AngularJS 中,数据绑定是通过scope对象实现的。我们可以将其属性附加到它上,并在模板中显式声明我们想要绑定到这些属性(单向或双向)。尽管作用域的概念似乎很清晰,但它还有两个额外的职责,包括事件分发和与变更检测相关的行为。Angular 初学者很难理解作用域到底是什么以及应该如何使用它。AngularJS 1.2 引入了所谓的控制器作为语法。它允许我们在给定控制器内部添加属性到当前上下文(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 通过移除scope对象将这一概念进一步发扬光大。所有表达式都在给定的 UI 组件的上下文中进行评估。移除整个作用域 API 引入了更高的简洁性;我们不再需要显式地注入它,而是将属性添加到 UI 组件中,我们可以稍后将其绑定。这个 API 感觉更加简单和自然。
我们将在第四章开始使用 Angular 组件和指令中更详细地探讨 Angular 的组件和变更检测机制。
依赖注入
可能是市场上第一个在 JavaScript 世界中通过依赖注入(DI)实现控制反转(IoC)的框架是 AngularJS。DI 提供了一系列好处,例如更易于测试、更好的代码组织和模块化,以及简洁性。尽管该框架第一版本的 DI 功能强大,但 Angular 2 将这一概念进一步发扬光大。由于最新的 Angular 基于最新的 Web 标准,它使用 ECMAScript 2016 装饰器语法来注释使用 DI 的代码。装饰器与 Python 中的装饰器或 Java 中的注解非常相似。它们允许我们使用反射来装饰给定对象的行为,或为其添加元数据。由于装饰器尚未标准化且不被主流浏览器支持,其使用需要中间转译步骤;然而,如果您不想这样做,您可以直接使用 ECMAScript 5 语法编写更多冗长的代码,并达到相同的语义。
新的 DI 更加灵活且功能丰富。它还修复了 AngularJS 的一些缺陷,例如不同的 API;在框架的第一版本中,一些对象通过位置(例如指令的链接函数中的作用域、元素、属性和控制器)注入,而其他对象则通过名称(使用控制器、指令、服务和过滤器中的参数名称)注入。
我们将在第五章Angular 中的依赖注入中进一步探讨 Angular 的依赖注入 API。
服务器端渲染
网络需求越大,Web 应用就越复杂。构建一个真实的单页应用需要编写大量的 JavaScript,并且包含所有必需的外部库可能会使页面上的脚本大小增加到几兆字节。在移动设备上,直到从服务器获取所有资源、解析并执行 JavaScript、渲染页面以及应用所有样式,应用的初始化可能需要几秒钟甚至几十秒钟。在低端移动设备上,使用移动网络连接时,这个过程可能会让用户放弃访问我们的应用。尽管有一些做法可以加快这个过程,但在复杂的应用中,并没有一劳永逸的解决方案。
在尝试提升用户体验的过程中,开发者发现了一种称为服务器端渲染的技术。它允许我们在服务器上渲染单页应用的请求视图,并直接将页面的 HTML 提供给用户。一旦所有资源都处理完毕,脚本文件可以添加事件监听器和绑定。这听起来像是提升我们应用性能的好方法。在这方面,React 是一个先驱,它允许使用 Node.js DOM 实现来在服务器端预渲染用户界面。不幸的是,AngularJS 的架构不允许这样做。主要障碍是框架与浏览器 API 之间的强耦合,这正是我们在 Web Workers 中运行变更检测时遇到的问题。
服务器端渲染的另一个典型用例是构建搜索引擎优化(SEO)友好型应用。过去,为了使 AngularJS 应用可由搜索引擎索引,人们使用了一些技巧。例如,一种做法是使用无头浏览器遍历应用,在每个页面上执行脚本并将渲染输出缓存到 HTML 文件中,使其可被搜索引擎访问。
尽管构建 SEO 友好型应用的这种解决方案是可行的,但服务器端渲染解决了上述两个问题,提升了用户体验,并使我们能够更加轻松且优雅地构建 SEO 友好型应用。
将 Angular 与 DOM 解耦使我们能够在浏览器之外运行 Angular 应用。我们将在第八章工具和开发体验中进一步探讨这一点。
可扩展的应用
自从 Backbone.js 出现以来,MVW(Model-View-Whatever)一直是构建单页应用程序的默认选择。它通过将业务逻辑与视图隔离开来,使我们能够构建设计良好的应用程序。利用观察者模式,MVW 允许在视图中监听模型变化,并在检测到变化时更新它。然而,这些事件处理器之间存在一些显式和隐式的依赖关系,这使得我们应用程序中的数据流不明显,难以推理。在 AngularJS 中,我们允许不同观察者之间存在依赖关系,这要求消化循环迭代所有这些观察者几次,直到表达式的结果稳定。新的 Angular 使数据流单向;这带来了许多好处:
- 
更明确的数据流。 
- 
绑定之间没有依赖关系,因此没有生存时间(TTL)。 
- 
框架性能更好: - 
消化循环只运行一次。 
- 
我们可以创建对不可变或可观察模型友好的应用程序,这使我们能够进行进一步的优化。 
 
- 
数据流的变化在 AngularJS 架构中引入了另一个根本性的变化。
当我们需要维护一个用 JavaScript 编写的庞大代码库时,我们可能会对这个问题的看法有所不同。尽管 JavaScript 的鸭子类型使语言非常灵活,但它也使得 IDE 和文本编辑器对其分析和支持变得更加困难。大型项目的重构变得非常困难且容易出错,因为在大多数情况下,静态分析和类型推断是不可能的。缺少编译器使得打字错误变得过于容易,直到我们运行测试套件或运行应用程序,我们才难以注意到这些错误。
由于 TypeScript 提供了更好的工具和编译时类型检查,Angular 核心团队决定使用 TypeScript,这有助于我们提高生产效率并减少错误。正如以下图表所示,TypeScript 是 ECMAScript 的超集;它引入了显式的类型注解和编译器:

图 1
TypeScript 语言编译成普通 JavaScript,被今天的浏览器支持。自 1.6 版本以来,TypeScript 实现了 ECMAScript 2016 装饰器,使其成为 Angular 的完美选择。
TypeScript 的使用允许 IDE 和文本编辑器提供更好的支持,包括静态代码分析和类型检查。所有这些通过减少我们犯的错误并简化重构过程,极大地提高了我们的生产效率。TypeScript 的另一个重要好处是通过静态类型隐式获得的性能提升,这使得 JavaScript 虚拟机可以进行运行时优化。
我们将在第三章TypeScript 快速入门中详细讨论 TypeScript。
模板
模板是 AngularJS 的关键特性之一。它们是简单的 HTML,不需要任何中间转换,与大多数模板引擎(如 mustache)不同。Angular 的模板通过允许我们在其中创建一个内部领域特定语言(DSL),结合了自定义元素和属性,将简单性与强大功能相结合。
这也是 Web Components 的主要目的之一。我们已经提到了 Angular 如何以及为什么利用这项新技术。尽管 AngularJS 模板很棒,但它们仍然可以变得更好!新的 Angular 模板吸收了框架先前版本中模板的精华,并通过修复其中一些令人困惑的部分来增强它们。
例如,假设我们有一个指令,并希望允许用户通过属性传递给它。在 AngularJS 中,我们可以用以下三种不同的方式来处理这个问题:
<user name="literal"></user> 
<user name="expression"></user> 
<user name="{{interpolate}}"></user> 
在user指令中,我们通过三种不同的方法传递name属性。我们可以传递一个字面量(在这种情况下,字符串"literal"),一个字符串,它将被评估为一个表达式(在我们的案例中是"expression"),或者一个在{{ }}内的表达式。应该使用哪种语法完全取决于指令的实现,这使得它的 API 错综复杂,难以记忆。
每天处理大量具有不同设计决策的组件是一项令人沮丧的任务。通过引入一个共同约定,我们可以处理这些问题。然而,为了获得良好的结果和一致的 API,整个社区都需要达成一致。
新的 Angular 通过提供特殊语法来处理这个问题,这些属性的值需要在当前组件的上下文中进行评估,并且为传递字面量提供了不同的语法。
根据我们的 AngularJS 经验,我们还习惯于模板指令中的微语法,例如ng-if和ng-for。例如,如果我们想在 AngularJS 中遍历用户列表并显示他们的名字,我们可以使用:
<div ng-for="user in users">{{user.name}}</div> 
虽然这种语法对我们来说看起来直观,但它只提供了有限的工具支持。然而,Angular 2 通过引入更多明确的语法和更丰富的语义来解决这个问题:
<template ngFor let-user [ngForOf]="users"> 
  {{user.name}} 
</template> 
前面的代码片段明确定义了属性,这些属性必须在当前迭代(user)的上下文中创建,以及我们迭代的(users)。
由于这种语法在键入时过于冗长,开发者可以使用以下语法,它最终会被转换成更冗长的形式:
<li *ngFor="let user of users"> 
  {{user.name}} 
</li> 
新模板的改进也将允许更好的工具支持高级功能,例如文本编辑器和 IDE。我们将在第四章开始使用 Angular 组件和指令中讨论 Angular 的模板。
变更检测
在 Web Workers 部分,我们已经提到了在作为 Web Worker 实例化的不同线程上下文中运行消化循环的机会。然而,AngularJS 中消化循环的实现并不那么内存高效,并阻止 JavaScript 虚拟机进行进一步的代码优化,这允许实现显著的性能提升。其中一种优化是内联缓存(mrale.ph/blog/2012/06/03/explaining-js-vms-in-js-inline-caches.html)。
Angular 团队进行了大量研究,以发现提高变更检测性能和效率的不同方法。这导致了全新的变更检测机制的诞生。
因此,Angular 在框架直接从组件模板生成的代码中执行变更检测。代码由 Angular 编译器生成。有两种内置的代码生成(也称为编译)策略:
- 
即时编译 (JiT):在运行时,Angular 生成代码以在整个应用程序上执行变更检测。生成的代码针对 JavaScript 虚拟机进行了优化,从而提供了显著的性能提升。 
- 
提前编译 (AoT):与即时编译 (JiT) 类似,但不同之处在于代码作为应用程序构建过程的一部分被生成。它可以用于通过不在浏览器中执行编译来加速渲染,也可以用于不允许 eval()的环境,例如 内容安全策略 (CSP) 和 Chrome 扩展。我们将在本书的下一节中进一步讨论。
我们将在第四章 “开始使用 Angular 组件和指令” 中查看新的变更检测机制以及如何配置它们。
摘要
在本章中,我们考虑了 Angular 核心团队做出决策的主要原因以及框架最后两个主要版本之间缺乏向后兼容性的原因。我们看到,这些决策是由两件事推动的——Web 的演变和前端开发的演变,以及从 AngularJS 应用程序开发中学到的经验教训。
在第一部分,我们学习了为什么需要使用 JavaScript 语言的最新版本,为什么应该利用 Web 组件和 Web Workers,以及为什么在版本 1 中集成所有这些强大的工具并不值得。
我们观察了前端开发的当前方向和过去几年中吸取的教训。我们描述了为什么控制器和作用域被从 Angular 2 中移除,以及为什么为了允许 SEO 友好、高性能的单页应用程序进行服务器端渲染,AngularJS 的架构发生了变化。我们审视的另一个基本主题是构建大规模应用程序,以及它是如何激励框架中的单向数据流和选择静态类型语言 TypeScript 的。
在下一章中,我们将探讨 Angular 应用程序的主要构建块,它们的使用方法以及它们之间的关系。新的 Angular 重用了 AngularJS 中引入的一些概念名称,但通常完全改变了我们的单页应用程序的构建块。我们将简要了解新概念,并将它们与框架的前一个版本中的概念进行比较。我们将快速介绍模块、指令、组件、路由器、管道和服务,并描述如何将它们组合起来构建优雅的单页应用程序。
小贴士
下载示例代码
您可以从 www.packtpub.com 的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。
第二章。Angular 应用程序的构建块
在上一章中,我们探讨了新 Angular 背后的设计决策的驱动因素。我们描述了导致开发全新框架的主要原因;Angular 利用最新的 Web 标准,同时吸取过去的教训。尽管我们熟悉主要驱动因素,但我们还没有描述 Angular 的核心概念。框架的最后一个主要版本与 AngularJS 采取了不同的路径,并在用于开发单页应用程序的基本构建块中引入了许多变化。
本章的使命是描述框架的核心,并对其主要概念进行简要介绍。在接下来的几页中,我们还将概述如何将这些概念组合起来,以帮助我们为 Web 应用程序构建专业的用户界面。随后的章节将概述我们将在本书的后续部分更详细地学习的内容。
在本章中,我们将探讨以下主题:
- 
框架的概念概述,展示了不同概念之间的关系。 
- 
我们如何将用户界面构建为组件的组合。 
- 
在 Angular 的新版本中,指令采取了什么路径,以及它们的接口与框架的前一个主要版本相比发生了怎样的变化。 
- 
强制分离关注点的原因,这导致了指令分解为两个不同的概念。为了更好地理解它们,我们将演示它们定义的基本语法。 
- 
改进的变化检测概述,以及它如何涉及指令提供的上下文。 
- 
区域是什么,以及它们如何使我们的日常开发过程变得更简单。 
- 
管道是什么,以及它们与 AngularJS 过滤器的关系。 
- 
介绍 Angular 中全新的依赖注入(DI)机制及其与服务的关联。 
Angular 的概念概述
在我们深入研究 Angular 的不同部分之前,让我们先了解一个概念性的概述,看看所有这些是如何结合在一起的。让我们看一下以下图表:

图 1
图 1 到 图 4 展示了 Angular 的主要概念及其之间的联系。这些图表的主要目的是说明使用 Angular 构建单页应用程序的核心模块及其关系。
组件是我们将使用来创建 Angular 应用程序用户界面的主要构建块。组件是指令的直接后继者,而指令是用于将行为附加到 DOM 的原始形式。组件通过提供进一步的功能(如模板)来扩展指令,该模板可以用于渲染指令的组合。在视图的模板内部可以存在不同的表达式。

图 2
上述图表从概念上说明了 Angular 的变更检测机制。它执行脏检查,评估特定 UI 组件上下文中的注册表达式。由于作用域的概念已经从 Angular 中移除,因此表达式的执行上下文是与它们关联的组件控制器的实例。
可以使用Differs增强变更检测机制;这就是为什么这两个元素在图表中存在直接关系的原因。
管道是 Angular 的另一个组件。我们可以将管道视为 AngularJS 中的过滤器。管道可以与组件一起使用。我们可以在任何组件的上下文中定义的表达式中包含它们。

图 3
现在我们来看看前面的图表。指令和组件将业务逻辑委托给服务。这强制执行更好的关注点分离、可维护性和代码重用性。指令通过框架的 DI 机制接收声明为依赖关系的特定服务的实例引用,并将与业务相关的逻辑执行委托给它们。指令和组件都可以使用DI机制,不仅用于注入服务,还可以注入 DOM 元素和/或其他组件或指令。
模块(也称为NgModules)是一个核心概念,它将构建块组合成单独的、逻辑上相关的组。NgModules 与 AngularJS 模块非常相似,但在此基础上增加了更多的语义。请注意,NgModules 与我们第三章中描述的 ES2015 模块不同,TypeScript 快速入门。Angular 模块是一个框架特性,而 ES2015 模块是一个语言结构。
NgModules 有以下职责:
- 
提供 Angular 模板编译器的上下文。 
- 
提供一种封装级别,其中我们可以拥有仅限于给定模块边界内使用的组件或指令。 
- 
在 NgModules 中,我们可以配置框架的 DI 机制的提供者。 

图 4
最后,新的路由器用于定义我们应用程序中的路由。由于指令不拥有模板,只有组件可以被路由器渲染,代表我们应用程序中的不同视图。路由器还使用一组预定义的指令,允许我们在不同的视图和它们应该渲染的容器之间定义超链接。
现在,我们将更仔细地研究这些概念,看看它们是如何一起工作来构建 Angular 应用程序的,以及它们是如何从它们的 AngularJS 前辈中演变而来的。
更改指令
AngularJS 在开发单页应用程序的过程中引入了指令的概念。指令的目的是封装与 DOM 相关的逻辑,并允许我们通过组合它们来构建用户界面。这样,我们能够扩展 HTML 的语法和语义。最初,像大多数创新概念一样,指令受到了争议性的看法,因为它们使我们倾向于在不使用 data- 前缀的自定义元素或属性时编写无效的 HTML。然而,随着时间的推移,这个概念逐渐被接受,并证明它将长期存在。
AngularJS 中指令实现的另一个缺点是我们可以使用它们的不同方式。这需要理解属性值,这些值可以是字面量、表达式、回调或微语法。这使得工具基本上变得不可能。
Angular 的最新版本保留了指令的概念,但从中吸取了 AngularJS 的最佳部分,并添加了一些新的想法和语法。Angular 指令的主要目的是通过扩展到在 ES2015 类中定义的自定义逻辑来将行为附加到 DOM 上。我们可以将这些类视为与指令关联的控制器,并将它们的构造函数视为与 AngularJS 指令的链接函数类似。然而,新的指令具有有限的配置性。它们不允许将模板与它们关联,这使得大多数已知用于定义指令的属性变得不必要。API 的简单性并不限制指令的行为,但只强制执行更严格的关注点分离。为了补充这个更简单的 API,Angular 2 引入了一个更丰富的界面来定义 UI 元素,称为组件。组件通过允许它们拥有模板,通过 组件元数据 扩展了指令的功能。我们将在本书的后面进一步探讨组件。
用于 Angular 指令的语法涉及 ES2016 装饰器。我们可以使用 TypeScript、ES2015,甚至 ECMAScript 5 (ES5)来通过稍微多一点的输入实现相同的结果。此代码定义了一个简单的指令,使用 TypeScript 编写:
@Directive({ selector: '[tooltip]' })
export class Tooltip { 
  @Input() tooltip: string; 
  private overlay: Overlay;
  constructor(private el: ElementRef, manager: OverlayManager) { 
    this.overlay = manager.get(); 
  }
  @HostListener('mouseenter') onMouseEnter() { 
    this.overlay.open(this.el.nativeElement, this.tooltip); 
  }
  @HostListener('mouseleave') onMouseLeave() { 
    this.overlay.close(); 
  } 
} 
指令可以用以下标记在我们的模板中使用:
<div tooltip="42">Tell me the answer!</div> 
一旦用户将鼠标悬停在标签上,告诉我答案!,Angular 将会调用在指令定义中由 @HostListener 装饰器定义的方法。最终,覆盖管理器的 open 方法将被执行。
注意
由于我们可以在单个元素上使用多个指令,最佳实践建议我们应该使用一个属性作为选择器。
定义此指令的 ECMAScript 5 语法如下:
var Tooltip = ng.core.Directive({ 
  selector: '[tooltip]', 
  inputs: ['tooltip'], 
  host: { 
    '(mouseenter)': 'onMouseEnter()', 
    '(mouseleave)': 'onMouseLeave()' 
  } 
}) 
.Class({ 
  constructor: [ng.core.ElementRef, Overlay, function (tooltip, el, manager) { 
    this.el = el; 
    this.overlay = manager.get(); 
  }], 
  onMouseEnter() { 
    this.overlay.open(this.el.nativeElement, this.tooltip); 
  }, 
  onMouseLeave() { 
    this.overlay.close(); 
  } 
}); 
上述 ES5 语法展示了 Angular 提供的内部 JavaScript DSL,以便我们能够在现代浏览器尚未支持的语法下编写我们的代码。
我们可以总结说,Angular 通过保持将行为附加到 DOM 的想法来保留了指令的概念。与 AngularJS 的核心区别在于新的语法,以及通过引入组件带来的关注点分离的进一步分离。在第四章,开始使用 Angular 组件和指令中,我们将进一步探讨指令的 API。我们还将比较使用 ES2016 和 ES5 定义的指令定义语法。现在,让我们看看 Angular 组件的巨大变化。
了解 Angular 组件
模型-视图-控制器(MVC)是一种最初为用户界面实现引入的微架构模式。作为 Angular 开发者,我们每天都在使用这种模式的多种变体,最常见的是模型-视图-视图模型(MVVM)。在 MVC 中,我们有模型,它封装了应用程序的业务逻辑,以及视图,它负责渲染用户界面、接受用户输入并将用户交互逻辑委托给控制器。视图被表示为组件的组合,这正式称为组合设计模式。
让我们看一下以下的结构图,它展示了组合设计模式:

图 5
这里,我们有三个类:
- 
一个名为 Component的抽象类。
- 
两个具体的类,称为 Leaf和Composite。Leaf类是我们即将构建的组件树中的简单终端组件。
Component 类定义了一个名为 operation 的抽象操作。Leaf 和 Composite 都继承自 Component 类。然而,Composite 类还拥有对其的引用。我们可以更进一步,允许 Composite 拥有一个 Component 实例的引用列表,如图中所示。Composite 内部的组件列表可以持有对不同的 Composite 或 Leaf 实例,或扩展 Component 类或其任何后继类的其他类的实例的引用。我们可以在 Composite 的 operation 方法实现中调用单个 Component 实例的 operation 方法的不同行为。这是因为对象面向编程语言中实现多态所使用的后期绑定机制。
组件应用
理论就到这里!让我们根据前面图中展示的类层次结构构建一个组件树。这样,我们将展示如何利用组合模式通过简化的语法构建用户界面。我们将在第四章,开始使用 Angular 组件和指令的上下文中查看一个类似的例子:
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 的上下文中,我们可以将指令视为 Leaf 类的实例(因为它们不拥有视图,因此不能组合其他指令和组件),而组件则是 Composite 类的实例。
如果我们更抽象地考虑 AngularJS 的用户界面,我们可以注意到我们使用了相当类似的方法。我们视图的模板将不同的指令组合在一起,以便向我们的应用程序的最终用户提供一个完全功能化的用户界面。
Angular 中的组件
Angular 通过引入称为组件的新构建块采取了这种方法。组件扩展了我们之前章节中描述的指令概念,并提供了更广泛的功能。以下是一个基本的 "Hello world" 组件的定义:
@Component({ 
  selector: 'hello-world', 
  template: '<h1>Hello, {{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 应用程序启动,它将查看 DOM 树中的所有元素并处理它们。当它找到一个名为 hello-world 的元素时,它将调用与其定义相关的逻辑,这意味着组件的模板将被渲染,花括号之间的表达式将被评估。这将导致以下标记 <h1>Hello, world!</h1>。
因此,总结一下,Angular 核心团队将 AngularJS 中的指令分离成两个不同的部分——组件和指令。指令提供了一种简单的方法来将行为附加到 DOM 元素上,而无需定义视图。Angular 中的组件提供了一个强大且易于学习的 API,这使得定义我们应用程序的用户界面变得更加容易。Angular 组件允许我们像 AngularJS 指令一样做同样惊人的事情,但需要更少的输入和更少的学习内容。组件通过添加视图来扩展 Angular 指令的概念。我们可以将 Angular 组件和指令之间的关系视为与我们在 图 5 中看到的 "Composite" 和 "Leaf" 之间的关系相同。
从概念上讲,我们可以将指令和组件之间的关系表示为继承。第四章,开始使用 Angular 组件和指令,将这两个概念描述得更加详细。
Angular 模块
在 AngularJS 中,我们有模块的概念。那里的模块负责将相关功能分组并在引导过程中内部注册。不幸的是,它们没有提供诸如封装和懒加载等特性。
Angular 将 NgModules 作为框架第五个候选版本的组成部分引入。新模块的主要目的是为 Angular 编译器提供一个上下文,并实现良好的封装级别。例如,如果我们使用 NgModules 构建库,我们可以有一些内部使用但不是作为公共接口一部分的声明。让我们看看以下例子:
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {TabComponent} from './tab.component';
import {TabItemComponent} from './tab-item.component';
@NgModule({
  imports: [CommonModule],
  declarations: [TabComponent, TabItemComponent],
  exports: [TabComponent]
})
class TabModule { }
如果你对前面例子中的 TypeScript 语法不熟悉,请不要担心;我们将在下一章深入探讨这门语言。
在前面的代码片段中,使用 @NgModule 装饰器,我们声明了 TabModule。请注意,在 declarations 列表中,我们包括了 TabComponent 和 TabItemComponent,但在 exports 列表中,我们只有 TabComponent。这样,我们可以为我们的库实现一定程度的封装。模块的使用者只能使用 TabComponent,因此我们不必担心 TabItemComponent API 的向后兼容性,因为它只能在我们模块的内部访问,在我们的模块边界内。最后,通过设置传递给 @NgModule 的对象字面量的 imports 属性,我们可以列出我们想要在当前模块内部使用的模块。这样,我们将能够利用它们声明的所有 exports 和 providers(我们将在第五章依赖注入中讨论提供者),Angular 中的依赖注入。
引导 Angular 应用
与 AngularJS 类似,在我们应用渲染之前,它需要经过引导过程。在新的 Angular 中,我们可以根据所使用的平台(例如,Web、NativeScript、启用 JiT 或 AoT 编译等)以不同的方式引导应用。让我们来看一个简单的例子,了解我们如何引导一个 Web 应用,以便更好地理解新 Angular 模块在过程中的使用方法:
import {NgModule} from '@angular/core';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
import {BrowserModule} from '@angular/platform-browser';
import {AppComponent} from './app.component';
@NgModule({
  imports: [BrowserModule],
  bootstrap: [AppComponent],
  declarations: [AppComponent],
})
export class AppModule {}
platformBrowserDynamic().bootstrapModule(AppModule);
在前面的例子中,@NgModule 装饰器中,我们声明了 AppComponent 并导入了 BrowserModule。请注意,这次,我们为 bootstrap 属性提供了值,明确声明我们希望使用 AppComponent 来引导我们的应用。
在文件的最后一行,我们调用platformBrowserDynamic方法返回的对象的bootstrapModule方法,其中包含参数AppModule。
总结一下,Angular 中的模块扮演着重要的角色——它们不仅逻辑上组织了我们应用程序的构建块,还提供了一种我们可以实现封装的方法。最后但同样重要的是,NgModules 在应用程序的引导过程中被大量使用。
管道
在商业应用程序中,我们经常需要具有相同数据的不同视觉表示。例如,如果我们有数字 100,000,并且想将其格式化为货币,我们很可能不想显示为纯数据;更有可能的是,我们希望显示为$100,000。
在 AngularJS 中,格式化数据的责任被分配给了过滤器。另一个数据格式化需求示例是当我们使用项目集合时。例如,如果我们有一个项目列表,我们可能希望根据谓词(一个布尔函数)对其进行过滤;在一个数字列表中,我们可能只想显示素数。AngularJS 有一个名为filter的过滤器,允许我们这样做。然而,名称的重复经常导致混淆。这也是核心团队将过滤器组件重命名为管道的另一个原因。
新名称背后的动机是管道和过滤器所使用的语法:
{{expression | decimal | currency}} 
在前面的示例中,我们将管道decimal和currency应用于expression返回的值。大括号之间的整个表达式看起来像 Unix 管道语法。
定义管道
定义管道的语法类似于用于定义模块、指令和组件的语法。为了创建一个新的管道,我们可以使用 ES2015 装饰器@Pipe。它允许我们向类添加元数据,将其声明为管道。我们所需做的只是为管道提供一个名称并定义数据格式化逻辑。还有一个替代的 ES5 语法,如果我们想跳过转译过程,可以使用它。
在运行时,一旦 Angular 表达式解释器发现给定的表达式包含对管道的调用,它将从中检索出分配在组件内的管道集合,并使用适当的参数调用它。
以下示例说明了我们如何定义一个简单的名为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: 'lowercase1' 
  }) 
  .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 管道。我们将在下一章解释 TypeScript 接口。
现在,让我们演示如何在组件内部使用lowercase1管道:
@Component({ 
  selector: 'app', 
  template: '<h1>{{"SAMPLE" | lowercase1}}</h1>' 
}) 
class App {} 
@NgModule({
  declarations: [App, LowerCasePipe1],
  bootstrap: [App],
  imports: [BrowserModule]
})
class AppModule {}
platformBrowserDynamic().bootstrapModule(AppModule);
此外,此处的替代 ECMAScript 5 语法如下:
var App = ng.core.Component({ 
  selector: 'app', 
  template: '<h1>{{"SAMPLE" | lowercase1}}</h1>' 
}) 
.Class({ 
  constructor: function () {} 
}); 
var AppModule = ng.core.NgModule({
  declarations: [App, LowerCasePipe1],
  bootstrap: [App],
  imports: [BrowserModule]
})
.Class({
  constructor: function {}
});
ng.platformBrowserDynamic
 .platformBrowserDynamic()
 .bootstrapModule(AppModule);
我们可以使用以下标记使用 App 组件:
   <app></app> 
我们将在屏幕上看到的将是 h1 元素内的文本样本。请注意,我们在 @NgModule 装饰器的 declarations 属性中包含了 LowerCasePipe1 的引用。
通过将数据格式化逻辑作为一个单独的组件,Angular 保持了一直以来可以看到的强关注点分离。我们将在 第七章 解释管道和与 RESTful 服务通信 中看看我们如何为我们的应用程序定义有状态和无状态的管道。
改进变更检测
如我们之前看到的,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 类。这为发出和订阅事件提供了原语。
现在,让我们定义一个视图,该视图显示 User 类实例的 name,它作为参数传递给其 constructor:
class View { 
  constructor(user: User, el: Element /* a DOM element */) { 
    el.innerHTML = user.getName();
  } 
} 
我们可以通过以下方式初始化 view 元素:
let user = new User(); 
user.setName('foo'); 
let view = new View(user, document.getElementById('label')); 
最终结果,用户将看到一个包含内容 foo 的标签。然而,用户的更改不会在视图中反映出来。为了在用户 name 发生更改时更新视图,我们需要订阅 change 事件,然后更新 DOM 元素的内容。我们需要以下方式更新 View 定义:
class View { 
  constructor(user:User, el:any /* a DOM element */) { 
    el.innerHTML = user.getName(); 
    user.on('change', () => { 
      el.innerHTML = user.getName();
    }); 
  } 
} 
这就是大多数框架在 AngularJS 时代之前如何实现变更检测的方式。
AngularJS 的变更检测
大多数初学者都对 AngularJS 中的数据绑定机制着迷。基本的 "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 的主要卖点之一——实现数据绑定的极端简单性。我们在标记中添加了一些属性,插值指令,将 label 属性添加到一个神秘的对象 $scope 中,这个对象神奇地传递给我们定义的自定义函数,然后一切就简单地工作了!
经验更丰富的 Angular 开发者对幕后实际发生的事情有更好的理解。在前面的例子中,在指令内部,Angular 添加了与相同表达式label相关联的不同行为的观察者——ng-model和ng-bind(在我们的例子中,是插值指令{{}})。这些观察者与经典 MVC 模式中的观察者相当相似。在特定事件(在我们的例子中,是文本输入内容的变化)发生时,AngularJS 会遍历所有这样的观察者,在给定作用域的上下文中评估与它们相关联的表达式,并存储它们的结果。这个循环被称为消化循环。
在前面的例子中,在作用域的上下文中评估表达式label将返回文本,Hello world!。在每次迭代中,AngularJS 会将当前评估结果与上一个结果进行比较,并在值不同的情况下调用相关回调。例如,插值指令添加的回调会将元素的 内容设置为表达式评估的新结果。这是一个两个指令观察者回调之间依赖性的例子。由ng-model添加的观察者回调修改了与插值指令添加的观察者相关联的表达式的结果。
这种方法有其自身的缺点。我们说过,消化循环将在某些特定事件上被调用,但如果我们使用setTimeout,并在回调函数中(作为第一个参数传递),改变我们正在监视的作用域附加的属性,这些事件会发生在框架之外;例如,如果我们使用setTimeout,并在回调函数中(作为第一个参数传递),改变我们正在监视的作用域附加的属性,AngularJS 将不会意识到这种变化,并且不会调用消化循环,因此我们需要显式地使用$scope.$apply来做这件事。但是,如果框架知道浏览器中发生的所有异步事件,例如用户事件、XMLHttpRequest事件、与WebSocket相关的事件以及其他事件,会怎样呢?在这种情况下,Angular 将能够拦截事件处理,并可以在不强迫我们这样做的情况下调用消化循环!
在zone.js
在 Angular 的新版本中,这种情况正是如此。这个功能是通过使用zone.js来实现的。
在 2014 年的 ng-conf 上,Brian Ford 做了一次关于 zones 的演讲。Brian 将 zones 描述为浏览器 API 的元猴子补丁。Zone.js 是由 Angular 团队开发的一个库,它在 JavaScript 中实现了 zones。它们代表一个执行上下文,允许我们拦截异步浏览器调用。基本上,使用 zones,我们能够在给定的XMLHttpRequest完成或当我们收到新的WebSocket事件时调用一段逻辑。Angular 通过拦截异步浏览器事件并在正确的时间调用消化循环来利用zone.js。这完全消除了使用 Angular 的开发者显式调用消化循环的需要。
简化的数据流
跨观察者依赖关系可能会在我们的应用程序中创建复杂的数据流,难以追踪。这可能导致不可预测的行为和难以发现的错误。尽管 Angular 将脏检查作为一种实现变更检测的方式,但它强制执行单向数据流。这是通过不允许不同观察者之间的依赖关系来实现的,这允许仅运行一次消化循环。这种策略显著提高了我们应用程序的性能,并降低了数据流的复杂性。Angular 还对内存效率和消化循环的性能进行了改进。有关 Angular 的变更检测及其实现的不同策略的更多详细信息,请参阅第四章,开始使用 Angular 组件和指令。
提升 AngularJS 的变更检测
现在,让我们退一步,再次思考框架的变更检测机制。
我们说过,在消化循环内部,Angular 评估注册的表达式,并将评估的值与循环前一次迭代中与相同表达式关联的值进行比较。
用于比较的最优算法可能取决于从表达式评估返回的值的类型。例如,如果我们得到一个可变的项目列表,我们需要遍历整个集合,逐个比较集合中的项目,以验证是否有变化。然而,如果我们有一个不可变列表,我们只需通过比较引用来执行检查,就可以以恒定的复杂度进行检查。这是因为不可变数据结构的实例不能改变。而不是应用一个旨在修改这些实例的操作,我们将得到一个应用了修改的新引用。
在 AngularJS 中,我们可以使用几种方法添加观察者。其中两种是$watch(exp, fn, deep)和$watchCollection(exp, fn)。这些方法让我们在一定程度上控制变更检测将如何执行相等性检查。例如,使用$watch添加观察者并将false值作为第三个参数传递,将使 AngularJS 执行引用检查(即使用===比较当前值和前一个值)。然而,如果我们传递一个真值(任何true值),检查将是深层次的(即使用angular.equals)。这样,根据表达式值的预期类型,我们可以以最合适的方式添加监听器,以便让框架使用最优化算法执行相等性检查。此 API 有两个限制:
- 
它不允许你在运行时选择最合适的相等性检查算法。 
- 
它不允许你将变更检测扩展到第三方特定的数据结构。 
Angular 核心团队将这项责任分配给了 differs,使他们能够根据我们在应用程序中使用的数据扩展并优化更改检测机制。Angular 定义了两个基本类,我们可以扩展它们来定义自定义算法:
- 
KeyValueDiffer:这允许我们对基于键值的数据结构进行高级的 diffing 操作。
- 
IterableDiffer:这允许我们对类似列表的数据结构进行高级的 diffing 操作。
Angular 允许我们通过扩展自定义算法来完全控制更改检测机制,这在框架的先前版本中是不可能的。我们将在第四章入门 Angular 组件和指令中进一步探讨更改检测以及我们如何配置它。
服务
服务是 Angular 为定义我们应用程序的业务逻辑提供的构建块。在 AngularJS 中,我们有三种不同的方法来定义服务:
// 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 初学者来说,有三种不同的方法来定义服务确实很令人困惑。让我们思考一下,是什么促使引入这些方法来注册服务。为什么我们不能简单地使用 Angular 不会意识到的 JavaScript 构造函数、对象字面量或 ES2015 类呢?我们可以像这样将我们的业务逻辑封装在一个自定义 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 提供的一个关键特性:DI 机制。MainCtrl 函数使用构造函数 UserTransaction,该函数在其主体中可见。前述代码有两个主要缺陷:
- 
我们与服务实例化所使用的逻辑耦合在一起。 
- 
代码不可测试。为了模拟 UserTransactions,我们需要对其进行猴子补丁。
AngularJS 如何处理这两件事?当需要某个服务时,通过框架的 DI 机制,AngularJS 解析其所有依赖项,并通过传递给一个封装其创建逻辑的工厂函数来实例化它。工厂函数作为 factory 和 service 方法的第二个参数传递。provider 方法允许在较低级别上定义服务;那里的工厂方法是 $get 属性下的一个。
就像 AngularJS 一样,Angular 的新版本也容忍这种关注点的分离,因此核心团队保留了服务。与 AngularJS 相比,框架的最后一个主要版本通过允许我们使用纯 ES2015 类或 ES5 构造函数来定义它们的接口,提供了一个更简单的接口。我们无法逃避这样一个事实,即我们需要明确声明应可注入的服务,并 somehow 指定它们的实例化指令。与 AngularJS 相比,现在框架使用 ES2016 装饰器的语法来实现这一目的,而不是我们从 AngularJS 中熟悉的方法。这允许我们将应用程序中的服务定义为简单的 ES2015 类,并使用装饰器来配置 DI:
import {Injectable} from '@angular/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 机制来解决它们的所有依赖项。
与 AngularJS 中的服务相比,它们依赖项的解决和内部表示方式存在一个核心差异。AngularJS 使用字符串来标识不同的服务和用于其实例化的相关工厂。另一方面,现在 Angular 使用键。通常,键是不同服务的类型。在实例化方面的另一个核心差异是注入器的分层结构,它们封装了具有不同可见性的不同依赖项提供者。
在框架的最后两个主要版本中,服务之间的另一个区别是简化的语法。尽管 Angular 的新版本使用 ES2015 类来定义我们的业务逻辑,但我们也可以使用 ECMAScript 5 的constructor函数,或者使用框架提供的 DSL。Angular 最新版本的依赖注入(DI)具有完全不同的语法,并通过提供一种一致的方式来注入依赖项,从而改善了行为。前一个示例中使用的语法是 ES2016 装饰器,在第五章,Angular 中的依赖注入中,我们将探讨另一种语法,它使用 ECMAScript 5。你还可以在第五章,Angular 中的依赖注入中找到对 Angular 服务和 DI 的更详细解释。
新的 router
在传统的 Web 应用程序中,所有页面更改都与整个页面重新加载相关联,这会获取所有引用的资源和数据,并将整个页面渲染到屏幕上。然而,Web 应用程序的需求随着时间的推移而发展。
我们用 Angular 构建的单页应用程序(SPAs)模拟桌面用户体验。这通常涉及按需加载应用程序所需的资源和数据,并且在初始页面加载后没有全页刷新。在 SPAs 中,不同的页面或视图通常由不同的模板表示,这些模板异步加载并在屏幕上的特定位置渲染。稍后,当包含所有所需资源的模板加载并且路由更改时,与所选页面关联的逻辑被调用,并用数据填充模板。如果用户在我们的 SPA 中加载给定页面后按下刷新按钮,则在视图刷新完成后,需要重新渲染相同的页面。这涉及到类似的行为:找到请求的视图,获取所有引用资源的所需模板,并调用与该视图关联的逻辑。
需要获取的模板以及页面成功刷新后应调用的逻辑,取决于用户在按下刷新按钮之前选中的视图。框架通过解析包含当前选中页面标识符的页面 URL 来确定这一点,该标识符以分层结构表示。
所有与导航、更改 URL、加载适当的模板以及在视图加载时调用特定逻辑相关的责任都分配给了路由器组件。这些是一些相当具有挑战性的任务,并且支持不同导航 API 以实现跨浏览器兼容性,使得在现代 SPAs 中实现路由成为一个非平凡问题。
AngularJS 在其核心中引入了路由器,后来将其外部化为 ngRoute 模块。它允许通过为每个页面提供一个模板以及当页面被选中时需要调用的逻辑,以声明式的方式定义我们 SPA 中的不同视图。然而,路由器的功能有限。它不支持基本功能,例如嵌套视图路由。这就是为什么大多数开发者更愿意使用社区开发的 ui-router 的一个原因。AngularJS 的路由器和 ui-router 的路由定义都包含一个路由配置对象,该对象定义了一个与页面关联的模板和一个控制器。
如前几节所述,Angular 改变了它为开发 SPAs 提供的构建块。Angular 移除了浮动控制器,而是将视图表示为组件的组合。这需要开发一个全新的路由器,它赋予这些新概念能力。
AngularJS 路由器和新的 Angular 路由器之间的核心区别如下:
- 
新的 路由器是基于组件的, ngRoute不是。新的 Angular 路由器将组件与单个路由或模块关联,这在懒加载路由的情况下适用。
- 
现在支持嵌套视图。 
Angular 路由定义语法
让我们简要了解一下 Angular 路由器在应用中定义路由所使用的新语法:
import {Component, NgModule} from '@angular/core';
import {BrowserModule} from '@angular/platform-browser';
import {RouterModule, Routes} from '@angular/router';
import {HomeComponent} from './home/home.component';
import {AboutComponent} from './about/about.component';
import {AppComponent} from './app.component';
const routes: Routes = [
  { path: 'home', component: HomeComponent },
  { path: 'about', component: AboutComponent }
];
@NgModule({ 
  imports: [BrowserModule, RouterModule.forRoot(routes)],
  declarations: [AppComponent, HomeComponent, AboutComponent],
  bootstrap: [AppComponent]
})
export class AppModule {} 
由于第六章使用 Angular 路由器和表单和第七章解释管道和与 RESTful 服务通信专门介绍了新路由器,所以我们这里不会过多深入,但让我们提及前面代码片段中的主要点。
路由器位于@angular/router。由于AppModule是我们应用的根模块,我们使用RouterModule的forRoot方法来导入路由器导出的所有所需指令和服务。
传递给RouterModule.forRoot装饰器的参数显示了我们在应用中如何定义路由。我们使用一个对象数组,它定义了路由与其相关组件之间的映射。
摘要
在本章中,我们快速概述了 Angular 提供的用于开发单页应用(SPAs)的主要构建块。我们指出了与 AngularJS 核心概念的主要区别。
虽然我们可以使用 ES2015,甚至 ES5 来构建 Angular 应用,但谷歌的建议是利用用于框架开发的语言-TypeScript。这样我们就可以使用诸如预编译等高级功能,这些功能我们将在第八章工具和开发体验中描述。
在下一章中,我们将探讨 TypeScript 以及我们如何在下一个应用中开始使用它。我们还将解释如何通过环境类型定义利用用纯 JavaScript 编写的 JavaScript 库和框架中的静态类型。
第三章 TypeScript 快速入门
在本章中,我们将开始使用 TypeScript,这是谷歌推荐与 Angular 一起使用的语言。所有 ECMAScript 2015 和 ECMAScript 2016 提供的功能,如函数、类、模块和装饰器,都已经实现或添加到 TypeScript 的路线图中。由于额外的类型注解,与 JavaScript 相比,有一些语法上的添加。
为了更平滑地从当时由现代浏览器完全支持的编程语言(即 ES5)过渡,我们将从 ES2016 和 TypeScript 之间的共同特性开始。当 ES2016 语法和 TypeScript 之间存在差异时,我们将明确指出。在本章的后半部分,我们将为到目前为止所学的所有内容添加类型注解。
在本章的后面部分,我们将解释 TypeScript 提供的额外功能,例如静态类型和额外语法。我们将讨论基于这些功能的不同后果,这将帮助我们提高生产效率并减少错误。让我们开始吧!
TypeScript 简介
TypeScript 是由微软开发和维护的开源编程语言。它的首次公开发布是在 2012 年 10 月。TypeScript 是 ECMAScript 的超集,支持 JavaScript 的所有语法和语义,并在其基础上增加了额外的功能,例如静态类型和更丰富的语法。
图 1 展示了 ES5、ES2015、ES2016 和 TypeScript 之间的关系。

图 1
由于 TypeScript 是静态类型的,它可以为我们这些 JavaScript 开发者提供许多好处。现在让我们快速看一下这些好处。
编译时类型检查
在编写 JavaScript 代码时,我们最常见的错误之一是拼写属性或方法名错误。通常,当我们遇到运行时错误时,我们会发现这个错误。这可能在开发过程中发生,也可能在生产过程中发生。希望我们在将代码部署到生产环境之前就能知道这个错误,这并不是一个令人舒服的感觉!然而,这并不是 JavaScript 所特有的问题;这是所有动态语言的共同问题。即使有大量的单元测试,这些错误也可能被忽略。
TypeScript 提供了一个编译器,通过使用静态代码分析来处理这些错误。如果我们利用静态类型,TypeScript 将知道给定对象具有哪些现有属性,如果我们拼写错误,编译器将以编译时错误的形式警告我们。
TypeScript 的另一个巨大好处是,它允许大型团队协作,因为它提供了正式且可验证的命名。这样,它允许我们编写易于理解的代码。
更好的文本编辑器和 IDE 支持
有许多工具,如 Tern,试图在文本编辑器和 IDE 中为 JavaScript 提供更好的自动完成支持。然而,由于 JavaScript 是一种动态语言,IDE 和文本编辑器在没有元数据的情况下无法做出复杂的建议。例如,Google Closure Compiler 就使用 JSDoc 中提供的类型注解来为语言提供静态类型。
使用此类元数据注释代码是 TypeScript 中称为类型注解的内置功能。基于这些类型注解,文本编辑器和 IDE 可以对我们的代码进行更好的静态分析。这提供了更好的重构工具和自动完成功能,提高了我们的生产力,并允许我们在编写应用程序的源代码时犯更少的错误。
TypeScript 还有更多内容
TypeScript 本身还有其他一些好处:
- 
它是 JavaScript 的超集:所有 JavaScript 程序(例如,ES5 和 ES2015)已经是有效的 TypeScript 程序。本质上,您已经一直在编写 TypeScript 代码。由于它基于 ECMAScript 标准的最新版本,它允许我们利用语言提供的最新尖端语法。 
- 
支持可选类型检查:如果出于任何原因,我们决定不想显式地定义变量或方法的类型,我们可以直接跳过类型定义。然而,我们应该意识到这意味着我们不再利用静态类型的好处,因此我们放弃了之前提到的所有好处。 
- 
由微软开发和维护:该语言的实现质量非常高,不太可能意外地放弃支持。TypeScript 基于世界上一些最好的编程语言开发专家的工作。 
- 
它是开源的:这允许社区自由地为语言做出贡献,并提出功能建议,这些建议以开放的方式进行讨论。TypeScript 是开源的事实使得第三方扩展和工具的开发更加容易,这进一步扩展了其使用范围。 
由于现代浏览器不支持 TypeScript 的原生支持,存在一个编译器,它将我们编写的 TypeScript 代码转换成预定义的 ECMAScript 目标版本的可读 JavaScript。一旦代码被编译,所有的类型注解都会被移除。
使用 TypeScript
让我们开始编写一些 TypeScript 代码!
在以下章节中,我们将查看不同的代码片段,展示 TypeScript 的一些特性。为了能够运行这些代码片段并自行尝试,您需要在您的计算机上安装 TypeScript 编译器。让我们看看如何进行这一操作。
TypeScript 最好使用Node 包管理器(npm)安装。我建议您使用 npm 的 3.0.0 或更高版本。如果您还没有安装 node.js 和 npm,您可以访问nodejs.org并遵循那里的说明。
使用 npm 安装 TypeScript
一旦安装并运行了 npm,请通过打开终端窗口并运行以下命令来验证您是否拥有最新版本:
$ npm -v 
使用以下命令来安装 TypeScript 2.1.0 或更高版本:
$ npm install -g typescript@².1.0 
前面的命令将安装 TypeScript 编译器并将其可执行文件(tsc)添加为全局。
为了验证一切是否正常工作,您可以使用以下命令:
$ tsc -v
Version 2.1.1 
输出应该与前面的类似,尽管版本可能不同。
注意
注意,我们通过在版本号前加 caret 符号来安装 TypeScript。这意味着npm将下载 2.x.x 范围内的任何版本,但低于 3.0.0。
运行我们的第一个 TypeScript 程序
现在,让我们编译我们的第一个 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 -g ts-node 
现在,您可以运行:
$ ts-node hello.ts 
您应该看到相同的结果,但磁盘上没有存储ts-node文件。
提示
您可以在github.com/mgechev/getting-started-with-angular找到这本书的代码。大多数代码片段的第一行都有一个注释,它显示了您可以在样本存储库的目录结构中找到完整示例的位置。请注意,路径是相对于app目录的。
TypeScript 语法和 ES2015 及 ES2016 引入的特性
由于 TypeScript 是 JavaScript 的超集,在我们开始学习其语法之前,先介绍 ES2015 和 ES2016 的一些重大变化会更容易;为了理解 TypeScript,我们首先必须理解 ES2015 和 ES2016。在适当的时候,我们将对这些变化进行快速浏览,然后再深入 TypeScript。
ES2015 和 ES2016 的详细解释超出了本书的范围。为了熟悉所有新特性和语法,我强烈建议您阅读Dr. Axel Rauschmayer的Exploring ES6: Upgrade to the next version of JavaScript。
接下来的几页将介绍新的标准,并允许您在开发 Angular 应用程序时利用您将需要的几乎所有功能。
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); // 6
// example 2 
var even = [3, 1, 56, 7].filter(el => !(el % 2)); 
console.log(even); // [56]
// 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),在setTimeout的回调中,并在屏幕上打印43。
这在 Angular 中非常有用,因为给定组件的绑定上下文是其实例(即其this实例)。如果我们定义MyComponent为 Angular 组件,并且有一个绑定到age属性的绑定,前面的代码将是有效的,并且所有绑定都将工作(注意我们没有作用域,也没有对$digest循环的显式调用,尽管我们直接调用了setTimeout)。
使用 ES2015 和 ES2016 类
当新手 JavaScript 开发者听到这种语言支持面向对象(OO)范式时,当他们发现没有用于定义类的语法时通常会感到困惑。这种观念源于一些最流行的编程语言,如 Java、C#和 C++,它们都有用于构建对象的类的概念。然而,JavaScript 以不同的方式实现了 OO 范式。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的类,它为其实例化的对象添加了一个单个属性。它是通过将属性的值设置为传递给其构造函数的参数name的值来实现的。
现在,打开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 中常用。你可以使用它们来定义你的组件、指令、服务和管道。然而,你也可以使用替代的 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 中常用以定义组件、指令和管道,并利用框架的依赖注入机制。装饰器的多数用例涉及改变行为以符合预定义逻辑或向不同的结构添加一些元数据。
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)以及目标属性描述符的函数。在更改描述符后,我们需要返回它以应用修改。基本上,装饰器的应用可以按以下方式转换为 ECMAScript 5:
descriptor = nonenumerable(Person.prototype, 'kidCount', descriptor) || descriptor; 
Object.defineProperty(Person.prototype, 'kidCount', descriptor); 
使用可配置的装饰器
这里是一个使用 Angular 定义的装饰器的示例:
@Component({ 
  selector: 'app', 
  providers: [NamesList], 
  templateUrl: './app.html', 
}) 
export class App {} 
当装饰器接受参数(就像前面示例中的Component一样)时,它们需要定义为接受参数并返回实际装饰器的函数:
function Component(config) { 
  // validate properties 
  return (componentCtrl) => { 
    // apply decorator 
  }; 
} 
在这个例子中,我们定义了一个接受单个参数config并返回装饰器的函数Component。
使用 ES2015 编写模块化代码
多年来,JavaScript 专业人士一直面临的一个问题是语言中缺乏模块系统。最初,社区开发了不同的模式,旨在强制执行我们生产的软件的模块化和封装。这些模式包括模块模式,它利用了函数词法作用域和闭包的优势。另一个例子是命名空间模式,它将不同的命名空间表示为嵌套对象。AngularJS 引入了自己的模块系统,但遗憾的是,它不提供诸如懒加载模块等特性。然而,这些模式更像是一种权宜之计,而不是真正的解决方案。
CommonJS(用于 node.js)和AMD(异步模块定义)后来被发明。它们至今仍被广泛使用,并提供诸如处理循环依赖、异步模块加载(在 AMD 中)等功能。
TC39 从现有的模块系统中吸取了最好的部分,并在语言层面上引入了这个概念。ES2015 提供了两个 API 来定义和消费模块。它们如下所示:
- 
声明式 API。 
- 
使用模块加载器的命令式 API。 
Angular 充分利用了 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 实用模块。在其中,我们定义并导出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 模块语法具有隐式的异步行为。

图 2
在前面的图中,我们有模块 A、B 和 C。模块 A 使用模块 B 和 C,因此它依赖于它们。一旦用户需要模块 A,JavaScript 模块加载器就需要在能够调用模块 A 中驻留的任何逻辑之前加载模块 B 和 C,因为这些模块有依赖关系。模块 B 和 C 将异步加载。一旦它们完全加载,JavaScript 虚拟机将能够执行模块 A。
使用别名
另一个典型的情况是我们想为给定的导出使用别名。例如,如果我们使用第三方库,我们可能想要重命名其任何导出,以避免名称冲突或仅仅为了更方便的命名:
import {
  platformBrowserDynamic as platform
} from '@angular/platform-browser-dynamic';
导入所有模块导出
我们可以使用以下语法导入整个 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 返回一个承诺,它可以在成功时解析或因错误而拒绝。一旦承诺在传递给 then 的回调函数的第一个参数上解析,我们将得到模块实例。在拒绝的情况下注册的回调函数的第一个参数是一个表示发生错误的对象的错误。
上一个代码片段中的代码不存在于 GitHub 仓库中,因为它需要一些额外的配置。我们将在本书的后续章节中更明确地应用模块加载器,在 Angular 示例中。
ES2015 和 ES2016 回顾
恭喜!我们学习 TypeScript 已经超过了一半。我们刚才看到的所有功能都是 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 的类型推断,这段代码的语义将与显式类型定义的代码相同。我们将在本章末尾进一步探讨它。
any类型
TypeScript 中的所有类型都是称为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 应用程序时,用户定义的 Enum 类型也是非常有用的类型集。
枚举类型
枚举类型是原始用户定义类型,根据规范,它们是 Number 的子类。枚举的概念存在于 Java、C++ 和 C# 语言中,在 TypeScript 中,用户定义的类型由称为元素的命名值集组成,具有相同的语义。在 TypeScript 中,我们可以使用以下语法定义枚举:
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 = {})); 
我们可以使用如下方式使用枚举类型:
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 类型的值。
我们可以使用在 JavaScript 中熟悉的数组方法与所有 TypeScript 数组类型一起使用:
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 
}; 
如果我们想在对象字面量中定义一个方法,我们可以按以下方式操作:
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 服务的绝佳方式。为了更好地理解它,让我们看看一个使用之前定义的类层次结构的示例,并将其移植到 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; 
} 
我们可以在我们的类中将其定义为字段或获取器。
接口继承
接口也可以相互扩展。例如,我们可以将我们的 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 中,我们只能装饰类、属性、方法、获取器和设置器。TypeScript 进一步允许我们装饰函数或方法参数:
class Http { 
  // ... 
}
class GitHubApi { 
  constructor(@Inject(Http) http) { 
    // ... 
  } 
} 
请记住,参数装饰器不应该改变任何额外的行为。相反,它们用于生成元数据。这些装饰器的最典型用例是 Angular 的依赖注入机制。
通过使用类型参数编写泛型代码
在本节关于使用静态类型的内容的开头,我们提到了类型参数。为了更好地理解它们,让我们从一个例子开始。假设我们想要实现经典的数据结构 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]; 
由于Number类型是Null的子类型,因此x的类型将是number[]。
上下文类型推断
当表达式的类型从其位置暗示时发生上下文类型;让我们以这个例子为例:
document.body.addEventListener('mousedown', e => { 
  e.foo(); // Property "foo" does not exists on a type "MouseEvent" 
}, false); 
在这种情况下,回调函数e的参数类型是由编译器根据其使用的上下文来猜测的。编译器理解e的类型是基于addEventListener的调用和传递给方法的参数。如果我们使用键盘事件(例如keydown),TypeScript 就会知道e的类型是KeyboardEvent。
类型推断是一种机制,通过利用 TypeScript 执行的静态分析,我们可以编写更简洁的代码。根据上下文,TypeScript 的编译器能够猜测给定表达式的类型,而无需显式定义。
使用环境类型定义
虽然静态类型很神奇,但我们使用的多数前端库都是用 JavaScript 编写的,它是动态类型的。由于我们想在 Angular 中使用 TypeScript,没有在代码中使用外部 JavaScript 库的类型定义是一个大问题;它阻止了我们利用编译时类型检查的优势。
TypeScript 正是考虑到这些点而构建的。为了允许 TypeScript 编译器处理它最擅长的事情,我们可以使用所谓的环境类型定义。它们允许我们提供现有 JavaScript 库的外部类型定义。这样,它们为编译器提供提示。
使用预定义的环境类型定义
幸运的是,我们不必为我们使用的所有 JavaScript 库和框架创建环境类型定义。这些库的社区和/或作者已经在线发布了这样的定义;最大的存储库位于github.com/DefinitelyTyped/DefinitelyTyped。在过去的几个月里,社区开发了一些用于管理环境类型定义的工具,例如tsd和typings。
后来,微软引入了一种官方的方式来管理它们,通过在tsconfig.json中提供额外的配置来使用npm。现在,类型定义作为命名空间@types下的范围包进行分发,并安装到node_modules中。
让我们创建一个目录并向其中添加一个package.json文件:
$ mkdir types-test && cd types-test && npm init
在我们为npm询问的问题提供默认值后,位于types-test目录下的package.json应该看起来像这样:
{
  "name": "types-test",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo "Error: no test specified" && exit 1"
  },
  "author": "",
  "license": "ISC"
}
我们可以使用以下方式安装新的类型定义:
$ npm install @types/angular --save-dev
上述命令将下载 AngularJS 的类型定义并将它们保存到node_modules/@types/angular中。注意,我们向npm提供了--save-dev标志,以便将类型定义保存到package.json的devDependencies下。
小贴士
当安装环境类型定义时,我们通常会使用--save-dev而不是--save,因为定义主要在开发中使用。
运行上述命令后,您的package.json文件应类似于以下内容:
{
  "name": "types-test",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo "Error: no test specified" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@types/angular": "¹.5.20"
  }
}
现在,为了使用 TypeScript 中的 AngularJS,创建app.ts并输入以下内容:
/// <reference path="./node_modules/@types/angular/index.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。
注意
由于使用reference元素来包含类型定义被认为是不良实践,我们可以使用tsconfig.json文件代替。在那里,我们可以通过tsc配置需要包含在编译过程中的目录。有关更多信息,请访问www.typescriptlang.org/docs/handbook/tsconfig-json.html。
现在,让我们在同一目录下创建一个名为tsconfig.json的文件,内容如下:
{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "experimentalDecorators": true,
    "outDir": "./dist"
  },
  "files": [
    "./app.ts"
  ]
}
在此配置文件中,我们提供了compilerOptions属性,这样我们就不必将outDir和module格式等参数作为标志传递给tsc。注意,在files属性中,我们也列出了我们想要编译的文件。TypeScript 将编译所有这些文件以及它们的传递依赖项!
现在,让我们修改我们前面的简单片段:
var module = angular.module('module', []); 
module.controller('MainCtrl', 
  function MainCtrl($scope: angular.IScope) { 
 const set = new Set<any>();
  });
我们所做的唯一改变是在声明和初始化一个新常量时添加一行,该常量是通过调用Set构造函数函数并使用any作为类型参数返回的结果。通过在app.ts文件和node_modules相同的目录下有tsconfig.json,我们可以通过运行以下命令来编译项目:
$ tsc
然而,我们会得到以下错误:
demo.ts(4,22): error TS2304: Cannot find name 'Set'.
Set 实现了集合数据结构,并作为 ES2015 标准的一部分。由于在所有 TypeScript 项目中使用 ES2015 的环境类型定义是一种非常常见的做法,因此 Microsoft 将其作为 TypeScript 本身的一部分添加。在 tsconfig.json 中的 compilerOptions 属性内,添加以下 lib 属性:
{
 "compilerOptions": {
   "target": "es5",
   "module": "commonjs",
   "experimentalDecorators": true,
   "outDir": "./dist",
 "lib": ["es2015", "dom"]
 },
 "files": [
   "./demo.ts"
 ]
}
lib 包含一个数组,其中包含 "es2015" 和 "dom",因为我们需要 ES2015 Set,Angular 的类型定义需要 文档对象模型(DOM)的类型定义。现在,当你在你 tsconfig.json 文件所在的目录中运行 tsc 时,编译过程应该成功通过,输出文件应位于 ./dist/demo.js。
自定义环境类型定义
为了理解所有这些是如何协同工作的,让我们看一个例子。假设我们有一个 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 
} 
在我们有了库的接口之后,创建环境类型定义将变得容易;我们只需创建一个扩展名为 d.ts 的名为 dom 的文件,并输入以下内容:
// 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"/> 
前面的代码片段提示编译器在哪里找到环境类型定义。一种替代的、更好的方法是通过使用上面描述的 tsconfig.json 来提供对 d.ts 文件的引用。
摘要
在本章中,我们简要介绍了用于 Angular 实现的 TypeScript 语言。虽然我们可以使用 ECMAScript 5 开发我们的 Angular 应用程序,但 Google 的建议是使用 TypeScript 以利用它提供的静态类型。
在探索语言的过程中,我们查看了一些 ES2015 和 ES2016 的核心特性。我们解释了 ES2015 和 ES2016 的类、箭头函数、块作用域变量定义、解构和模块。由于 Angular 利用 ES2016 装饰器,以及更准确地说 TypeScript 对它们的扩展,因此我们专门用一节来介绍它们。
在此之后,我们探讨了如何利用静态类型通过显式类型定义来提高效率。我们描述了 TypeScript 中的一些内置类型,以及如何通过指定成员的访问修饰符来在语言中定义类。我们的下一站是接口。我们通过解释类型参数和全局类型定义来结束我们在 TypeScript 中的冒险之旅。
在下一章中,我们将开始深入探索 Angular,使用框架的组件和指令。
第四章:Angular 组件和指令入门
到目前为止,我们已经熟悉了 Angular 为开发单页应用程序提供的核心构建块及其之间的关系。然而,我们只是触及了表面,通过介绍 Angular 概念背后的基本思想和定义它们所使用的语法的基本语法。在本章中,我们将深入探讨 Angular 的组件和指令。
在以下章节中,我们将涵盖以下主题:
- 
强制分离 Angular 为开发应用程序提供的构建块的关注点。 
- 
与 DOM 交互时,适当使用指令或组件。 
- 
内置指令和自定义指令的开发。 
- 
深入了解组件及其模板。 
- 
内容投影。 
- 
视图子代与内容子代。 
- 
组件的生命周期。 
- 
使用模板引用。 
- 
配置 Angular 的变更检测。 
Angular 中的“Hello world!”应用程序
现在,让我们在 Angular 中构建我们的第一个“Hello world!”应用程序。为了尽可能轻松和快速地启动一切,对于我们的第一个应用程序,我们将使用 Angular 的转译包中的 ECMAScript 5 语法。首先,创建一个包含以下内容的index.html文件:
<!-- ch4/es5/hello-world/index.html --> 
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title></title>
</head>
<body>
  <script src="img/zone.js"></script>
  <script src="img/Reflect.js"></script>
  <script src="img/Rx.js"></script>
  <script src="img/core.umd.js"></script>
  <script src="img/common.umd.js"></script>
  <script src="img/compiler.umd.js"></script>
  <script src="img/platform-browser.umd.js"></script>
  <script src="img/platform-browser-dynamic.umd.js"></script>
  <script src="img/app.js"></script>
</body>
</html>
注意
注意,本书中的示例是用 Angular 2.2.0 构建的。如果您使用的是框架的新版本,可能会有细微的差异。有关更多信息,请查看github.com/angular/angular/blob/master/CHANGELOG.md的变更日志。
前面的 HTML 文件定义了我们页面的基本结构。在关闭body标签之前,我们有对几个脚本文件的引用:框架所需的 polyfills(zone.js 和 reflect-metadata)、RxJS、Angular 不同包的 ES5 包,以及包含我们将要构建的应用程序的文件。
注意
RxJS 被 Angular 的核心使用,以便我们能够在我们的应用程序中启用响应式编程范式。在以下内容中,我们将仅简要探讨我们如何利用观察者。有关更多信息,您可以访问 RxJS 的 GitHub 仓库github.com/ReactiveX/rxjs。
在您的index.html文件所在的同一目录下,创建一个名为app.js的文件,并在其中输入以下内容:
// ch4/es5/hello-world/app.js 
var App = ng.core.Component({
  selector: 'my-app',
  template: '<h1>Hello {{target}}!</h1>'
})
.Class({
  constructor: function () {
    this.target = 'world';
  }
});
var AppModule = ng.core.NgModule({
  imports: [ng.platformBrowser.BrowserModule],
  declarations: [App],
  bootstrap: [App]
})
.Class({
  constructor: function () {}
});
ng.platformBrowserDynamic
 .platformBrowserDynamic()
 .bootstrapModule(AppModule);
在前面的代码片段中,我们定义了一个名为App的组件,具有my-app选择器。该组件具有以下模板:
'<h1>Hello {{target}}!</h1>' 
这种语法应该已经从 AngularJS 中熟悉了。在给定组件的上下文中编译时,前面的代码片段将使用花括号内表达式的结果来插值模板。在我们的例子中,这个表达式仅仅是target变量。
我们向Class传递一个对象字面量,它有一个名为constructor的方法。这个DSL(领域特定语言)为在 ECMAScript 5 中定义类提供了一种替代方法。在constructor函数的主体中,我们添加一个名为target的属性,其值为字符串"world"。紧接着,我们定义我们的AppModule类。请注意,我们应用程序中的每个组件都必须与一个模块相关联。在模块内部,如第二章《Angular 应用程序的构建块》中所述,我们定义了声明、导入和引导组件。
在代码片段的最后一行,我们调用ng.platformBrowserDynamic()调用返回的对象的bootstrapModule方法。作为bootstrapModule的参数,我们传递我们刚刚定义的AppModule。
注意,bootstrapModule位于ng.platformBrowserDynamic命名空间下。这是因为框架是考虑到不同的平台而构建的,例如浏览器、NativeScript 等等。通过将不同平台使用的引导方法放在单独的命名空间下,Angular 可以实现不同的逻辑来初始化应用程序。
现在,如果你用浏览器打开index.html,你应该会看到一些错误,如下面的截图所示:

图 1
这是因为我们遗漏了一些相当重要的东西。我们在index.html内部任何地方都没有使用根组件。为了完成应用程序,在<body>元素的打开标签之后添加以下 HTML 元素:
<my-app></my-app> 
现在,你可以刷新你的浏览器来查看以下结果:

图 2
使用 TypeScript
尽管我们已经有了一个运行的 Angular 应用程序,但我们可以做得更好!我们没有使用任何包管理器或模块加载器。我们在第三章《TypeScript 快速入门》中花了很多时间讨论 TypeScript;然而,在前面的应用程序中我们没有写一行 TypeScript。虽然使用 TypeScript 与 Angular 不是必需的,但利用静态类型提供的所有好处会更方便。通过使用 TypeScript,我们还可以在 Angular 中使用提前编译。
设置我们的环境
Angular 的核心团队为 Angular 开发了一个全新的 CLI 工具,它允许我们通过几个命令来启动我们的应用程序。虽然我们将在最后一章介绍它,但在此之前,为了增强我们的学习体验,我们将使用位于 github.com/mgechev/getting-started-with-angular 的代码。这个仓库包含了本书中的所有示例,在一个大应用中。它声明了 package.json 中所有必需的依赖项,定义了基本 gulp 任务,如开发服务器、将您的 TypeScript 代码转换为 ECMAScript 5、实时重载等。
注意
包含本书所有示例的项目基于 angular-seed (github.com/mgechev/angular-seed),它允许我们快速启动 Angular 应用程序。
为了设置 getting-started-with-angular 项目,您需要在您的计算机上安装 Git、Node.js v6.x.x 和运行 npm。如果您安装了不同版本的 Node.js,我建议您查看 nvm(Node.js 版本管理器,可在 github.com/creationix/nvm 获取)或 n(www.npmjs.com/package/n)。使用这些工具,您可以在您的机器上拥有多个 Node.js 版本,并通过命令行使用单个命令在它们之间切换。
安装示例项目仓库
让我们从设置 getting-started-with-angular 项目开始。打开您的终端并输入以下命令:
# Will clone the repository and save it to directory called
# getting-started-with-angular
$ git clone https://github.com/mgechev/getting-started-with-angular.git
$ cd getting-started-with-angular
$ npm install
第一行将 getting-started-with-angular 项目克隆到名为 getting-started-with-angular 的目录中;之后我们进入该目录。
在能够运行种子项目之前,需要使用 npm 安装所有必需的依赖项。这一步骤可能需要一段时间,具体取决于您的网络连接,所以请耐心等待,不要中断它。如果您遇到任何问题,请不要犹豫,在 github.com/mgechev/getting-started-with-angular/issues 上提出问题。
最后一步是启动开发服务器:
$ npm start 
当转换过程完成后,您的浏览器将自动打开到 http://localhost:5555/dist/dev。现在您应该看到一个类似于以下截图的视图:

图 3
在 Angular 和 TypeScript 中玩耍
现在,让我们来玩一玩我们已有的文件。导航到 getting-started-with-angular 中的 app/ch4/ts/hello-world 目录。然后,打开 app.ts 并将其内容替换为以下片段:
// ch4/ts/hello-world/app.ts 
import {Component, NgModule} from '@angular/core';
import {BrowserModule} from '@angular/platform-browser';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
@Component({
  selector: 'my-app',
  templateUrl: './app.html'
})
class App {
  target: string;
  constructor() {
    this.target = 'world';
  }
}
@NgModule({
  declarations: [App],
  imports: [BrowserModule],
  bootstrap: [App],
})
class AppModule {}
platformBrowserDynamic().bootstrapModule(AppModule);
让我们逐行查看代码:
import {Component, NgModule} from '@angular/core';
import {BrowserModule} from '@angular/platform-browser';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
初始时,我们从 @angular/core 模块导入 @Component 和 @NgModule 装饰器,从 @angular/platform-browser 模块导入 BrowserModule,以及从 @angular/platform-browser-dynamic 模块导入 platformBrowserDynamic 对象。稍后,我们使用 @Component 装饰 App 类。向 @Component 装饰器传递与 ECMAScript 5 版本的应用程序中使用的相同对象字面量,但这次我们引用外部模板。
作为下一步,我们定义组件的视图。然而,请注意,在这种情况下,我们使用 templateUrl 而不是简单地内联组件的模板。
打开 app.html 并将文件内容替换为 <h1>Hello {{target}}!</h1>。app.html 的内容应与之前使用的内联模板相同。由于我们可以通过内联(使用 template)和设置其 URL(templateUrl)两种方式使用模板,从某种意义上说,组件的 API 与 AngularJS 指令 API 类似。
在代码片段的最后行,我们通过提供根模块来引导应用程序。
现在,让我们看一下 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> 
  <my-app>Loading...</my-app> 
  <!-- inject:js --> 
  <!-- endinject --> 
  <%= INIT %> 
</body> 
</html> 
注意,在页面主体内部,我们使用 my-app 元素,其内容是一个具有值 "Loading..." 的文本节点。这个 "Loading..." 标签将在应用程序启动并渲染主组件之前可见。
注意
存在模板占位符 <%= INIT %> 和 <-- inject:js...,它们注入针对单个演示特定内容。它们不是 Angular 特有的,而是旨在防止由于它们之间的共享结构而在书籍附带的代码示例中重复代码。为了查看这个特定的 HTML 文件是如何被转换的,请打开 /dist/dev/ch4/ts/hello-world/index.html。
使用 Angular 指令
我们已经构建了简单的 "Hello world!" 应用程序。现在,让我们开始构建更接近真实应用程序的东西。在本节结束时,我们将拥有一个简单的应用程序,该应用程序列出了我们需要完成的多个项目,并在页面的页眉中向我们打招呼。
首先,让我们开发我们的 app 组件。与前一个示例相比,我们需要进行的两个修改是将 target 属性重命名为 name,并将 todos 列表添加到组件的控制器定义中:
// ch4/ts/ng-for/detailed-syntax/app.ts 
import {Component, NgModule} from '@angular/core';
import {BrowserModule} from '@angular/platform-browser';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
@Component({
  selector: 'app',
  templateUrl: './app.html',
})
class App {
  todos: string[];
  name: string;
  constructor() {
    this.name = 'John';
    this.todos = ['Buy milk', 'Save the world'];
  }
}
@NgModule({
  declarations: [App],
  imports: [BrowserModule],
  bootstrap: [App],
})
class AppModule {}
platformBrowserDynamic().bootstrapModule(AppModule);
剩下的唯一事情就是更新模板以消费提供的数据。我们已经熟悉了 AngularJS 中的 ng-repeat 指令。它允许我们使用微语法遍历一个项目列表,该语法随后由 AngularJS 解释。然而,该指令的语义不够丰富,因此很难构建执行静态代码分析并帮助我们改进开发体验的工具。由于 ng-repeat 指令非常有用,Angular 保留了这一想法,并通过在其之上引入更多的语义来进一步改进它,以允许更复杂的工具。它允许 IDE 和文本编辑器执行更好的静态代码分析。这种支持将防止我们在编写的代码中犯拼写错误,并允许我们拥有更流畅的开发体验。
在 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 let-todo [ngForOf]="todos"> 
    <li>{{todo}}</li> 
  </template> 
</ul> 
注意
template 元素是我们可以存放标记并确保它不会被浏览器渲染的地方。如果我们需要将应用程序的模板直接嵌入到页面的标记中,并让我们在某个时刻使用模板引擎处理它们,这非常有用。在当前示例中,这意味着如果 Angular 的 DOM 编译器不处理 DOM 树,我们就不会在 ul 元素内看到列表项。
现在,在你刷新浏览器后,你应该看到以下结果:

图 4
到目前为止,一切顺利!前面片段中剩下的新东西只是我们不熟悉的 template 元素的属性,例如 ngFor、let-todo 和 [ngForOf]。让我们来看看它们。
ngFor 指令
ngFor 指令是一个允许我们遍历项目集合的指令,它确实做了 AngularJS 中 ng-repeat 的工作,但它带来了一些额外的语义。请注意,ngForOf 属性被括号包围。起初,这些括号可能看起来像是无效的 HTML。然而,根据 HTML 规范,它们在属性名称中的使用是允许的。W3C 验证器唯一会抱怨的是 template 元素没有这样的属性;然而,浏览器在处理标记时不会有问题。
这些括号背后的语义是,它们包围的属性值是一个需要评估的表达式。
指令语法的改进语义
在 第一章 中,开始使用 Angular,我们提到了在 Angular 中改进工具的机会。AngularJS 中的一个重大问题是我们可以使用指令的不同方式。这需要理解属性值,这些值可以是字面量、表达式、回调或微语法。从 Angular 2 开始,通过在框架中引入一些简单的约定来解决这个问题:
- 
propertyName="value"
- 
[propertyName]="expression"
- 
(eventName)="handler()"
在第一行中,propertyName 属性接受一个字符串字面量作为值。Angular 不会进一步处理属性值;它将按模板中设置的方式使用它。
第二种语法 [propertyName]="expression" 给 Angular 提供了一个提示,即属性值应被视为表达式。当 Angular 发现被括号包围的属性时,它将在与模板关联的组件的上下文中解释该表达式。简而言之,如果我们想设置非字符串值或表达式的结果作为给定属性的值,我们需要使用这种语法。
最后一个示例展示了我们如何绑定到事件。(eventName)="handler()" 的语义是,我们希望处理由给定组件触发的所有名为 eventName 的事件,使用 handler() 表达式。
我们将在本章后面讨论更多示例。
注意
Angular 提供了替代的规范语法,允许我们定义元素的绑定而不使用括号。例如,属性绑定可以用以下代码表达:<input [value]="foo">
它也可以用这种方式表达:<input bind-value="foo">
类似地,我们可以用以下代码表达事件绑定:<button (click)="handle()">点击我</button>
它们也可以用以下方式表达:<button on-click="handle()">点击我</button>
在模板内声明变量
前一个模板中剩下的最后一件事是 let-todo 属性。使用这种语法,我们告诉 Angular 我们想要声明一个名为 todo 的新变量,并将其绑定到 [ngForOf] 设置为值的表达式的评估结果中的单个项目。
在模板中使用语法糖
虽然模板语法为我们使用的 IDE 或文本编辑器提供了代码的更多意义,但它相当冗长。Angular 提供了一种替代语法,它将被转换为前面片段中显示的语法。
有一些 Angular 指令需要使用模板元素,例如 ngForOf、ngIf 和 ngSwitch。由于这些指令经常使用,因此存在它们的替代语法。我们不需要显式地输入整个模板元素,只需在指令前加上 * 前缀即可。这将允许我们将 ngForOf 指令语法的使用转换为以下形式:
<!-- ch4/ts/ng-for/syntax-sugar/app.html --> 
<ul> 
  <li *ngFor="let todo of todos">{{todo}}</li> 
</ul> 
之后,Angular 将将这个模板转换为前面描述的更冗长的语法。由于较简洁的语法更容易阅读和编写,因此其使用被认为是最佳实践。
注意
* 字符允许我们移除 template 元素,并将指令直接放在 template 的顶级子元素上(在先前的示例中,是列表项,li)。
定义 Angular 指令
现在我们已经构建了一个简单的 Angular 组件,让我们继续我们的旅程,了解 Angular 指令。
使用 Angular 指令,我们可以在 DOM 上应用不同的行为或结构变化。在这个例子中,我们将构建一个简单的 tooltip 指令。
与组件相比,指令没有视图和模板。这两个概念之间的另一个核心区别是,给定的 HTML 元素可能只有一个组件,但可以有多个指令。换句话说,与我们在视图中实际使用的组件相比,指令增强了元素。
Angular 的官方风格指南建议使用带有命名空间的前缀的指令作为属性。牢记这一点,我们将在以下方式中使用 tooltip 指令:
<div saTooltip="Hello world!"></div> 
在前面的代码片段中,我们在div元素上使用了 tooltip 指令。作为一个命名空间,它的选择器使用了sa字符串。
注意
由于本书的重点是高效直观地学习 Angular 的概念,代码片段可能不会完全符合 Angular 风格指南。然而,对于生产应用程序,遵循最佳实践是至关重要的。您可以在angular.io/styleguide找到官方的 Angular 风格指南。
现在,让我们开发一个 tooltip 指令!在实现它之前,我们需要从@angular/core导入几个符号。打开一个名为app.ts的新 TypeScript 文件,并输入以下内容;我们稍后会填充占位符:
import {Directive, ElementRef, HostListener...} from '@angular/core'; 
在前面的行中,我们导入了以下定义:
- 
ElementRef:这允许我们将元素引用(我们不仅限于 DOM)注入到宿主元素中。在前面的 tooltip 的示例使用中,我们得到了一个 Angular 的div元素包装器,它持有saTooltip属性。
- 
Directive:这个装饰器允许我们添加我们定义的新指令所需的元数据。
- 
HostListener(eventname):这是一个方法装饰器,它接受一个事件名称作为参数。在指令初始化期间,Angular 会将装饰的方法作为事件处理程序添加到由宿主元素触发的eventname事件。
让我们看看指令的实现:
// ch4/ts/tooltip/app.ts 
@Directive({
  selector: '[saTooltip]'
})
export class Tooltip {
  @Input() saTooltip:string;
  constructor(private el: ElementRef, private overlay: Overlay) {
    this.overlay.attach(el.nativeElement);
  }
  @HostListener('mouseenter')
  onMouseEnter() {
    this.overlay.open(this.el, this.saTooltip);
  }
  @HostListener('mouseleave')
  onMouseLeave() {
    this.overlay.close();
  }
}
设置指令的输入
在前面的示例中,我们声明了一个带有saTooltip选择器的指令。请注意,Angular 的 HTML 编译器是区分大小写的,这意味着它会区分[satooltip]和[saTooltip]选择器。之后,我们使用@Input装饰器在saTooltip属性上声明指令的输入。此代码背后的语义是,我们声明一个名为saTooltip的属性并将其绑定到传递给saTooltip属性的评估表达式的结果值。
@Input装饰器接受一个参数,即我们想要绑定的属性的名称。如果我们不传递参数,Angular 将创建一个与属性本身同名属性的绑定。我们将在本章后面详细解释输入和输出的概念。
理解指令的构造函数
构造函数声明了两个私有属性:el为ElementRef类型,overlay为Overlay类型。Overlay类实现了管理工具提示覆盖的逻辑,并将通过 Angular 的 DI 机制注入。为了将其声明为可注入的,我们需要以下方式声明顶级组件:
@Component({ 
  selector: 'app', 
  templateUrl: './app.html', 
  providers: [Overlay], 
  // ... 
}) 
class App {} 
注意
我们将在下一章中探讨 Angular 的依赖注入机制,我们将解释我们如何声明我们的服务、指令和组件的依赖关系。Overlay类的实现对于本章的目的并不重要。然而,如果你对此感兴趣,你可以在ch4/ts/tooltip/app.ts中找到实现。
使用 NgModules 更好地封装指令
为了使工具提示指令对 Angular 编译器可用,我们需要明确声明我们打算在哪里使用它。例如,查看AppModule类在ch4/ts/tooltip/app.ts中的内容:
@NgModule({
  declarations: [Tooltip, App],
  providers: [Overlay],
  imports: [BrowserModule],
  bootstrap: [App],
})
class AppModule {}
对于@NgModule装饰器,我们传递一个具有declarations属性的文本对象。此属性包含所有将在bootstrap数组中列出的组件的所有子组件中可用的指令列表。另一种扩展可用指令列表的方法是导入一个模块。例如,BrowserModule模块包含一些在浏览器环境中非常常用的指令。
起初,你可能觉得必须明确声明模块中使用的所有指令可能会很烦人;然而,这强制了更好的封装。在 AngularJS 中,所有指令都在全局命名空间中。这意味着应用程序中定义的所有指令都可以在所有模板中访问。这带来了一些问题,例如名称冲突。为了处理这个问题,我们引入了命名约定,例如,AngularJS 定义的所有指令的"ng-"前缀和随 Angular UI 一起提供的所有指令的"ui-"前缀。
目前,通过明确声明给定模块中使用的所有指令,我们为各个组件的子树创建了一个特定的命名空间(即,指令将只对给定的根组件及其所有后续组件可见)。防止名称冲突不是我们得到的唯一好处;它还帮助我们更好地理解我们产生的代码的语义,因为我们总是知道给定组件在哪个模块中声明时可以访问哪些指令。我们可以通过从组件到顶级模块的路径找到给定组件的所有可访问指令,并取所有declarations值和模块imports声明的并集。鉴于组件是从指令扩展的,我们需要明确声明所有使用的组件。
由于 Angular 定义了一套内置指令,BrowserModule通过导出包含它们的模块CommonModule来导出它们。这个预定义指令列表包括NgClass、NgFor、NgIf、NgStyle、NgSwitch、NgSwitchWhen和NgSwitchDefault。它们的名字相当直观;我们将在本章后面看看我们如何使用其中的一些。
使用 NgModules 声明可重用组件
使用 NgModules,我们可以实现良好的封装度。通过显式导出公共组件、指令、管道和服务,我们可以隐藏我们模块的一些实现细节。这样我们可以实现可重用模块,仅暴露它们的公共接口,并且不对模块的用户透露任何底层组件。
为了更好地理解,让我们看看以下示例:
@Component(...)
class ZippyHeader {
  @Input() header: string;
}
@Component(...)
class Zippy {
  @Input() header: string;
  visible = true;
}
@Component(...)
class App {}
在前面的代码片段中,我们声明了组件Zippy、ZippyHeader和App。Zippy是一个具有标题和内容的组件;我们可以通过点击标题来切换内容的可见性。在ZippyHeader组件中,我们可以实现一些处理点击事件和/或可视化标题的逻辑。在App组件中,我们通过传递文本给它的标题和内容来使用Zippy组件。
为了创建一个可工作的 Angular 应用程序,我们需要声明一个 NgModule,它以某种方式引用了所有三个组件。我们可以通过两种主要方式来处理:
- 
声明一个单独的 NgModule,并在其声明列表中包含所有三个组件。 
- 
声明两个 NgModules: - 
一个声明了 Zippy和ZippyHeader组件,称为ZippyModule。
- 
另一个声明了 App组件并导入了模块ZippyModule。
 
- 
第二种方法有几个优点:在ZippyModule中,我们可以声明Zippy和ZippyHeader,但我们只能导出Zippy,因为ZippyHeader是在Zippy内部使用的,我们不需要将其暴露给用户。通过声明模块ZippyModule,我们可以将其导入到我们应用程序中的其他模块,以便重用Zippy组件,或者我们甚至可以将其提取为单独的 npm 模块,并在多个应用程序中重用它。
第二种方法看起来是这样的:
// ch4/ts/zippy/app.ts
@Component(...)
class ZippyHeader {...}
@Component(...)
class Zippy {...}
@NgModule({
 declarations: [Zippy, ZippyHeader],
  exports: [Zippy]
  imports: [CommonModule],
})
class ZippyModule {}
@Component(...)
class App {...}
@NgModule({
  imports: [BrowserModule, ZippyModule],
  declarations: [App],
  bootstrap: [App]
})
class AppModule {}
platformBrowserDynamic().bootstrapModule(AppModule);
在前面的示例中,在模块ZippyModule中,我们声明了Zippy和ZippyHeader,但我们只导出了Zippy。我们还从@angular/common中导入了模块CommonModule,以便重用 Angular 的内置指令(例如,NgIf是由CommonModule导出的)。
在AppModule中,我们只需要导入ZippyModule,这样我们就能使用它的所有导出和提供者。我们将在下一章进一步讨论提供者。
注意
注意,良好的实践建议我们应该将每个单独的组件实现到单独的文件中。为了简化本书的示例,我们违反了这一实践。有关最佳实践的列表,请访问angular.io/styleguide。
使用自定义元素模式
现在,让我们假设我们想在页面上添加一个计时器,并重用我们已构建的 Web 组件。在这种情况下,我们的应用程序可能看起来像这样:
//  ch4/ts/custom-element/app.ts
import {Component, NgModule} from '@angular/core';
import {BrowserModule} from '@angular/platform-browser';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
@Component({
  selector: 'my-app',
  template: `
    <h1>Hello {{name}}</h1>
    The current timeout is <simple-timer></simple-timer>
  `
})
class App {
  name: string = 'John Doe';
}
@NgModule({
  imports: [BrowserModule],
  declarations: [App],
  bootstrap: [App]
})
class AppModule {}
platformBrowserDynamic().bootstrapModule(AppModule);
现在,如果我们运行我们的应用程序,我们将得到以下错误:

图 5
当 Angular 解析App组件的模板时,它将找到<simple-timer></simple-timer>。它不是由 HTML 规范定义的元素,也不匹配AppModule中声明或导入的任何指令的选择器,因此框架将抛出错误。
那么,我们如何使用 Angular 和自定义组件呢?解决方案是使用我们传递给@NgModule的对象字面量的schemas属性:
import {..., CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
//...
@NgModule({
  imports: [BrowserModule],
  declarations: [App],
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  bootstrap: [App]
})
class AppModule {}
这样我们就改变了 Angular 在解析元素及其属性时用于验证的默认模式。
默认情况下,如果框架发现一个不匹配任何导入或声明的指令的元素选择器,或者由 HTML5 规范定义的元素,它将抛出错误。
创建自定义 Angular 组件
现在,让我们构建一个简单的待办事项应用程序,以演示进一步定义组件的语法。
我们的待办事项将具有以下格式:
interface Todo { 
  completed: boolean; 
  label: string; 
} 
让我们先导入我们将需要的所有内容:
import {Component, NgModule, ViewEncapsulation} from '@angular/core'; 
//...
现在,让我们声明组件及其相关的元数据:
@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 受到了 Web Components 的启发,其核心特性是 shadow DOM。shadow DOM 允许我们在不使其泄漏到组件作用域之外的情况下封装我们的 Web 组件的样式;Angular 提供了这一特性。如果我们想让 Angular 的渲染器使用 shadow DOM,我们可以使用ViewEncapsulation.Native。然而,在撰写本书时,shadow DOM 并不被所有浏览器支持;如果我们想在不使用 shadow 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项的数组。
现在,让我们更新模板并渲染这些项。这是如何完成的:
<ul> 
  <li *ngFor="let todo of todos; let index = index" [class.completed]="todo.completed"> 
    <input type="checkbox" [checked]="todo.completed" 
      (change)="toggleCompletion(index)"> 
    {{todo.label}} 
  </li> 
</ul> 
在前面的模板中,我们遍历控制器中todos属性内的所有todo项。对于每个todo项,我们创建一个可以toggle项完成状态的复选框;我们还使用插值指令渲染todo项的标签。在这里,我们可以注意到一个之前解释过的语法:
- 
我们使用 (change)="statement"绑定到复选框的更改事件。
- 
我们使用 [checked]="expr"绑定到todo项的属性。
为了在已完成的todo项上有一条横线,我们绑定到元素的class.completed属性。由于我们希望将completed类应用于所有已完成的待办事项,我们使用[class.completed]="todo.completed"。这样,我们声明我们希望根据todo.completed表达式的值来应用completed类。现在,我们的应用程序看起来是这样的:

图 6
注意
与类绑定语法类似,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 应用程序,我们将展示如何利用指令的输入和输出:

图 7
我们可以将输入视为给定指令接受的属性(甚至参数)。输出可以被视为它触发的事件。当我们使用第三方库提供的指令时,我们主要关心其输入和输出,因为它们定义了其 API。
输入指的是参数化指令行为和/或视图的值。另一方面,输出指的是当发生特殊事件时指令引发的事件。
确定输入和输出
现在,让我们将我们的单体待办事项应用程序分解成相互通信的单独组件。在下面的屏幕截图中,您可以看到单个组件,当它们组合在一起时,实现了应用程序的功能:

图 8
外部矩形代表整个 Todo 应用程序。第一个嵌套矩形包含负责输入新待办事项标签的组件,下面的矩形列出了存储在根组件中的单个事项。
话虽如此,我们可以将这些三个组件定义为以下内容:
- 
TodoApp:负责维护待办事项列表(添加新事项和切换完成状态)。
- 
InputBox:负责输入新待办事项的标签。它具有以下输入和输出:- 
输入:文本框的占位符和提交按钮的标签。 
- 
输出:点击提交按钮后文本框的内容。 
 
- 
- 
TodoList:负责渲染单个待办事项。它具有以下输入和输出:- 
输入:待办事项列表。 
- 
输出:待办事项的完成状态。 
 
- 
现在,让我们开始实现吧!
定义输入和输出
让我们采用自下而上的方法,从 InputBox 组件开始。在此之前,我们需要从 Angular 的 @angular/core 包中导入一些内容:
import { 
  Component, 
  Input, 
  Output, 
  EventEmitter 
} from '@angular/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 装饰器对其进行装饰。
传递输入并消费输出
现在,让我们将前面定义的组件组合起来,实现我们的完整应用程序。
我们需要查看的最后一个组件是 TodoApp:
@Component({ 
  selector: 'todo-app',
  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 组件,我们需要将它们包含在声明 TodoApp 的模块装饰器的 declarations 属性中。这些组件之间协作的魔法发生在模板中:
<input-box inputPlaceholder="New todo..." 
  buttonLabel="Add" 
  (inputText)="addTodo($event)"> 
</input-box> 
首先,我们使用 InputBox 组件,并将值传递给输入 inputPlaceholder 和 buttonLabel。注意,就像我们之前看到的,如果我们想将这些输入中的任何一个作为值传递一个表达式,我们需要用括号将其包围(即 [inputPlaceholder]="expression")。在这种情况下,该表达式将在拥有模板的组件的上下文中进行评估,并将结果作为输入传递给拥有给定属性的组件。
在传递buttonLabel输入值之后,我们通过将(inputText)属性的值设置为addTodo($event)表达式来消费inputText输出。$event的值将等于我们传递给emit方法的inputText对象在emitText方法中的值(如果我们绑定到原生事件,事件对象的值将是原生事件对象本身)。
同样地,我们传递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> 
当用户点击模板中定义的按钮时,handleClick($event)表达式将被评估。
此外,handleClick的第一个参数的target属性将是按钮本身,但currentTarget属性将是input-box元素。如果我们不使用 Angular,事件将以相同的方式冒泡。在某个时刻,它将到达文档,除非在传播过程中有处理程序阻止其传播。
相比之下,如果我们有一个自定义的@Output,事件将不会冒泡,并且$event变量的值将是我们传递给输出 emit 方法的值,而不是 DOM 事件。
重命名输入和输出
现在,我们将探讨如何重命名指令的输入和输出。假设我们有以下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输出。
注意
注意,这样的重命名可能会造成混淆,并且不被视为最佳实践。有关最佳实践的完整集合,请访问 angular.io/styleguide 。
同样地,我们可以使用以下代码片段重命名组件的输入:
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<any>(); 
  @Input('inputAlias') inputName: any;
} 
虽然两者具有相同的语义,但根据最佳实践,我们应该使用后者,因为它更容易阅读和理解。
解释 Angular 的内容投影
内容投影是开发用户界面时的重要概念。它允许我们将内容片段投影到我们应用程序用户界面的不同位置。Web 组件通过content元素解决了这个问题。在 AngularJS 中,它通过臭名昭著的转包含实现。
Angular 受到现代 Web 标准的启发,特别是 Web 组件,这导致了在那里采用一些内容投影方法。在本节中,我们将使用ng-content指令在 Angular 的上下文中查看它们。
Angular 的基本内容投影
假设我们正在构建一个名为fancy-button的组件。此组件将使用标准的 HTML 按钮元素并添加一些额外的行为。以下是fancy-button组件的定义:
@Component({ 
  selector: 'fancy-button', 
  template: '<button>Click me</button>' 
}) 
class FancyButton { ... } 
在@Component装饰器内部,我们设置了组件的内联模板及其选择器。现在,我们可以使用以下标记使用该组件:
<fancy-button></fancy-button> 
在屏幕上,我们将看到一个带有点击我标签的标准 HTML 按钮。这不是定义可重用 UI 组件的非常灵活的方式。很可能会出现,花哨按钮的用户需要根据他们的应用程序更改标签的内容。
在 AngularJS 中,我们能够通过ng-transclude实现这个结果:
// AngularJS example 
app.directive('fancyButton', function () { 
  return { 
    restrict: 'E', 
    transclude: true, 
    template: '<button><ng-transclude></ng-transclude></button>' 
  }; 
}); 
在新的 Angular 中,我们有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 组件或 AngularJS 指令,并且我们希望将此内容的不同部分投影到模板的不同位置。
例如,假设我们有一个具有标题和正文的panel组件,我们可以按以下方式使用它:
<panel>
  <section class="panel-title">Sample title</section>
  <section class="panel-content">Content</section>
</panel>
我们的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 中如何定义这样的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的内容。
为了将具有类名panel-title的section元素的内容投影到标题应该出现的地方,我们需要使用ng-content元素。作为其selector属性,我们需要使用一个 CSS 选择器,该选择器匹配我们想要投影内容的元素(在这种情况下,选择器应该是.panel-title或section.panel-title)。
如果我们将selector属性的值设置为.panel-title,它将匹配所有位于目标panel元素内部的具有类.panel-title的元素。之后,ng-content将获取它们的内容并将它们设置为它自己的内容。
嵌套组件
我们已经构建了一些简单应用,它们是组件和指令的组合。我们看到了组件基本上是带有视图的指令,因此我们可以通过嵌套或组合其他指令和组件来实现它们。以下图示通过结构图说明了这一点:

图 9
通过在组件的模板内嵌套指令和组件,可以利用所使用的标记的嵌套特性来实现组合。例如,假设我们有一个具有sample-component选择器的组件,其定义如下:
@Component({ 
  selector: 'sample-component', 
  template: '<view-child></view-child>' 
}) 
class Sample {} 
Sample组件的模板有一个带有view-child标签名的单一子元素。
另一方面,我们可以在另一个组件的模板中使用Sample组件,并且由于它可以作为一个元素使用,我们也可以在其中嵌套其他组件或指令:
<sample-component> 
  <content-child1></content-child1> 
  <content-child2></content-child2> 
</sample-component> 
这样,sample-component组件有两种不同类型的后继者:
- 
在其模板内定义的后继者。 
- 
作为嵌套元素传递给其开标签和闭标签之间的后继者。 
在 Angular 的上下文中,组件模板内定义的直接子元素被称为视图子元素,而嵌套在其开标签和闭标签之间的元素被称为内容子元素。
使用 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 { 
  @Output() 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>'
}) 
class UserPanel {...} 
注意,UserPanel 视图的模板中只包含 UserBadge 组件的选择器。现在,让我们在我们的应用程序中使用 UserPanel 组件:
@Component({ 
  selector: 'app', 
  template: `<user-panel> 
    <user-rating></user-rating> 
  </user-panel>`
}) 
class App {} 
我们的主要 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 提供的生命周期钩子做一个完整的概述。
总结一下,我们可以这样说,给定组件的内容子组件是嵌套在组件宿主元素内的子元素。相比之下,给定组件的视图子组件指令是用于其模板中的元素。
注意
为了获取一个平台无关的 DOM 元素的引用,我们再次可以使用@ContentChild和@ViewChild。例如,如果我们有以下模板:<input #todo>,我们可以通过以下方式获取input的引用:@ViewChild('todo')。
由于我们已经熟悉了视图子组件和内容子组件之间的核心区别,我们可以继续进行我们的标签实现。
在标签组件中,我们不是使用@ContentChild装饰器,而是使用@ContentChildren。我们这样做是因为我们有多达多个内容子组件,我们想要获取它们全部:
@ContentChildren(TabTitle) 
tabTitles: QueryList<TabTitle>; 
@ContentChildren(TabContent) 
tabContents: QueryList<TabContent>; 
我们将注意到的另一个主要区别是,tabTitles和tabContents属性的类型是带有相应类型参数的QueryList,而不是组件本身的类型。我们可以将QueryList数据结构视为一个 JavaScript 数组——我们可以对其应用相同的高阶函数(map、filter、reduce等)并遍历其元素;然而,QueryList也是可观察的,也就是说,我们可以观察其变化。
作为我们的Tabs定义的最终步骤,让我们看看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实例的.emit()方法(即任何标签的tabSelected属性),传递给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 中的组件具有明确的生命周期,这使得我们可以挂钩到其不同阶段,并进一步控制我们的应用程序。我们可以通过在组件控制器中实现特定方法来实现这一点。为了更加明确,得益于 TypeScript 的表达能力,我们可以实现与生命周期阶段相关联的不同接口。每个接口都有一个方法,与该阶段本身相关联。
尽管使用显式接口实现编写的代码将具有更好的语义,但由于 Angular 也支持 ES5,在组件内部我们可以简单地定义与生命周期钩子相同名称的方法(但这次,前缀为ng),并利用鸭子类型。
以下图表显示了我们可以挂钩的所有阶段:

图 10
让我们看看不同的生命周期钩子:
- 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 '@angular/core'; 
@Component(...) 
class Panel implements OnChanges { 
  ngOnChanges(changes) {...} 
} 
表示各个生命周期钩子的所有接口都声明了一个以接口本身名称开头并前缀为ng的单个方法。在即将到来的列表中,我们将使用术语生命周期钩子,无论是接口还是/或方法,除非我们不会对其中任何一个具体说明。
- 
OnInit:这个钩子将在给定组件初始化时被调用。我们可以使用OnInit接口及其ngOnInit方法来实现它。
- 
DoCheck:当给定组件的变更检测器被调用时,这将会被调用。它允许我们为给定组件实现自己的变更检测算法。请注意,DoCheck和OnChanges不应在同一个指令上一起实现。
- 
OnDestroy:如果我们使用ngOnDestroy方法实现OnDestroy接口,我们可以挂钩到组件的销毁生命周期阶段。该方法将在组件从组件树中分离时被调用。
现在,让我们看看与组件的内容和视图子组件相关的生命周期钩子:
- 
AfterContentInit:如果我们实现了ngAfterContentInit生命周期钩子,当组件的内容完全初始化时,我们将被通知。这是装饰了ContentChild或ContentChildren属性将初始化的阶段。
- 
AfterContentChecked:通过实现这个钩子,每当给定组件的内容被 Angular 的变更检测机制检查时,我们都会被通知。
- 
AfterViewInit:如果我们使用ngAfterViewInit方法实现这个生命周期钩子,当组件的视图初始化时,我们将被通知。这是装饰了ViewChild或ViewChildren属性将初始化的阶段。
- 
AfterViewChecked:这与AfterContentChecked类似。AfterViewChecked钩子将在我们的组件视图被检查后调用。
生命周期钩子的执行顺序
为了追踪与每个钩子关联的回调函数的执行顺序,让我们看一下ch4/ts/life-cycle/app.ts的例子:
@Component({ 
  selector: 'panel', 
  template: '<ng-content></ng-content>' 
}) 
class Panel { 
  @Input() title: string;
  @Input() caption: string;
  ngOnChanges(changes) {...} 
  ngOnInit() {...} 
  ngDoCheck() {...} 
  ngOnDestroy() {...} 
  ngAfterContentInit() {...} 
  ngAfterContentChecked() {...} 
  ngAfterViewInit() {...} 
  ngAfterViewChecked() {...} 
} 
Panel组件实现了所有钩子,而没有明确实现与之相关的接口。
我们可以在以下模板中使用该组件:
<button (click)="toggle()">Toggle</button> 
<div *ngIf="counter % 2 == 0"> 
  <panel caption="Sample caption" >Hello world!</panel> 
</div> 
在前面的例子中,我们有一个面板和一个按钮。每次点击按钮时,面板将通过ngIf指令被移除或附加到视图中。
在应用程序初始化期间,如果"counter % 2 == 0"表达式的结果评估为true,将调用ngOnChanges方法。这是因为标题和标题属性值将首次被设置。
在此之后,将会调用ngOnInit方法,因为组件已经被初始化。一旦组件的初始化完成,变更检测将被触发,这将导致调用ngDoCheck方法,允许我们挂钩自定义逻辑以检测状态的变化。
注意
注意,你不应该为同一个组件同时实现ngDoCheck和ngOnChanges方法,因为当内部变更检测器检测到变化时,ngOnChanges方法将会持续被调用。这里给出的例子只是为了学习目的。
在ngDoCheck方法之后,变更检测器将对组件的内容进行检查(将按顺序调用ngAfterContentInit和ngAfterContentChecked)。在此之后,同样的事情也会发生在组件的视图中(将按顺序调用ngAfterViewInit和ngAfterViewChecked)。
一旦ngIf指令的表达式评估为false,整个组件将从视图中分离,这将导致调用ngOnDestroy钩子。
在下一次点击时,如果ngIf表达式的值为true,将执行与初始化阶段相同的生命周期钩子调用序列。
使用TemplateRef定义通用视图
我们已经熟悉了输入、内容子元素和视图子元素的概念,也知道在组件的生命周期中何时可以获取它们的引用。现在,我们将它们结合起来,介绍一个新概念——TemplateRef。
让我们回顾一下本章前面开发的最后一个待办事项应用程序。在下面的屏幕截图中,你可以看到它的 UI 界面:

图 11
如果我们查看其实现ch4/ts/inputs-outputs/app.ts,我们会看到用于渲染单个待办事项的模板是在整个待办事项应用程序的模板中定义的。
如果我们想要使用不同的布局来渲染待办事项呢?我们可以通过创建另一个名为Todo的组件来实现,该组件封装了渲染它们的职责。然后,我们可以为想要支持的每个布局定义单独的Todo组件。这样,即使我们只需要更改它们的模板,我们也需要为每个布局有n个不同的组件。
Angular 提供了一个更优雅的解决方案。在本章的早期部分,我们已经讨论了模板元素。我们提到,它允许我们定义一个块 HTML,该块不会被浏览器处理。Angular 允许我们引用这样的模板元素,并通过将它们作为内容子元素传递来使用它们。
这里展示的是如何将自定义布局传递给重构后的TodoApp组件:
// ch4/ts/template-ref/app.ts 
<todo-app> 
  <template let-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) 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="let todo of todos; template: itemsTemplate"></template> 
我们已经在本章的前几节中解释了ngForOf指令的扩展语法。前面的代码片段显示了我们可以设置的另一个属性:ngForTemplate属性。默认情况下,ngForOf指令的模板是它所使用的元素。通过指定模板引用到ngForTemplate属性,我们可以使用传递的TemplateRef。
理解和增强变化检测
我们已经简要描述了该框架的变化检测机制。我们提到,与在“作用域”上下文中运行的 AngularJS 相比,在 Angular 2 及以后的版本中,它是在单个组件的上下文中运行的。我们提到的另一个概念是区域,它基本上拦截了我们使用浏览器 API 所做的所有异步调用,并为框架的变化检测机制提供执行上下文。区域解决了 AngularJS 中令人烦恼的问题,即当我们使用 Angular 之外的外部 API 时,我们需要显式调用digest循环。
在第一章 开始使用 Angular 和 第二章 Angular 应用程序的构建块 中,我们讨论了执行组件变更检测的代码是生成的,要么是运行时(即时)要么是我们构建过程的一部分(提前)。由于禁用了 JavaScript 的动态评估,AoT 编译在具有严格CSP(内容安全策略)的环境中表现良好;它还提供了更好的性能,因为 Angular 不需要编译组件的模板。我们将在第八章 工具和开发体验 中详细解释。
在本节中,我们将探讨@Component装饰器的配置对象的另一个属性,通过改变其策略,我们可以进一步控制框架的变更检测机制。通过显式设置策略,我们能够防止变更检测机制在组件的子树中运行,在某些情况下这可以带来巨大的性能优势。
变更检测器的执行顺序
现在,让我们简要描述在给定的组件树中变更检测器被调用的顺序。
为了这个目的,我们将使用我们已有的待办应用的最后一个实现,但这次,我们将渲染单个待办事项的逻辑提取到一个单独的组件中,称为 TodoItem。在下面的图中,我们可以看到应用程序的结构:

图 12
在顶层是TodoApp组件,它有两个子组件:InputBox和TodoList。TodoList组件使用TodoItem组件来渲染单个待办事项。对于我们的目的来说,实现细节并不重要,所以我们将会忽略它们。
现在,我们需要意识到父组件的状态与其子组件之间存在隐式依赖关系。例如,TodoList组件的状态完全依赖于其父组件:TodoApp组件中的待办事项。TodoItem和TodoList之间也存在类似的依赖关系,因为TodoList组件将单个待办事项传递给TodoItem组件的一个单独实例。这意味着如果TodoList中的待办事项列表发生变化,这将自动反映一些TodoItem组件:

图 13
由于我们的最后一个观察结果,附加到各个组件的变更检测器的执行顺序类似于前面图示中的顺序。一旦变更检测机制运行,最初它将对TodoApp组件进行检查。紧接着,将检查InputBox组件的变更,然后是TodoList组件。最后,Angular 将调用TodoItem组件的变更检测器。
你可以在ch4/ts/change_detection_strategy_order/app.ts示例中追踪执行顺序,其中每个组件在其ngDoCheck方法被调用时都会记录一条消息。
注意
注意,只有组件才有附加变更检测器的实例;指令使用其父组件的变更检测器。
理解 Angular 的变更检测策略
Angular 提供的变更检测策略有Default和OnPush。我们将详细描述如何利用OnPush,因为它在处理不可变数据时非常强大。
现在,让我们导入 TypeScript 枚举,它可以用来配置用于各个组件的策略:
// ch4/ts/change_detection_strategy_broken/app.ts 
import {ChangeDetectionStrategy} from '@angular/core'; 
现在,我们可以配置TodoList组件使用OnPush策略:
@Component({ 
  selector: 'todo-list', 
  changeDetection: ChangeDetectionStrategy.OnPush, 
  template: `...`, 
  styles: [...] 
}) 
class TodoList { ... } 
这样,变更检测将跳过,直到组件不接收具有不同值的输入。请注意,比较使用的是相等检查,这意味着它将通过值比较基本类型,通过引用比较对象。你可以访问http://localhost:5555/dist/dev/ch4/ts/change_detection_strategy_broken/来查看TodoList组件的不一致行为。当你向输入添加一个新的待办事项并点击添加按钮时,它不会立即出现在列表中。
默认情况下,变更检测将始终检查变更。
引入不可变数据结构和 OnPush
现在,我们将描述OnPush变更检测策略。当给定组件产生的结果仅依赖于其输入时,它非常有用。在这种情况下,我们可以向其输入传递不可变数据,以确保它不会被任何组件修改。这样,通过拥有仅依赖于其不可变输入且不产生任何副作用组件,我们可以确保它仅在接收到不同的输入(即不同的引用)时产生不同的用户界面。
在本节中,我们将OnPush策略应用于TodoList组件。由于它只依赖于其输入(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。
该库已经是包含书籍代码的项目的一部分。你可以在项目的根目录下的package.json中找到对 Immutable.js 的引用。你可以查看ch4/ts/change_detection_strategy/app.ts来了解我们如何在 TypeScript 应用程序中包含不可变集合。
现在,是时候重构我们的待办事项应用程序,使其使用不可变数据。
在 Angular 中使用不可变数据
让我们看看我们目前如何在TodoApp组件中保持待办事项:
class TodoApp { 
  todos: Todo[] = [...]; 
  ... 
} 
我们使用一系列的Todo项目。JavaScript 数组是可变的,这意味着如果我们将其传递给使用OnPush策略的组件,在得到相同的输入引用时跳过变更检测是不安全的。例如,我们可能有使用相同待办事项列表的两个组件。由于列表是可变的,这两个组件都可以修改列表。如果它们的变更检测没有执行,这将导致任何组件的不一致状态。这就是为什么我们需要确保包含项目的列表是不可变的。为了确保TodoApp组件以不可变的数据结构持有其数据,我们只需要做以下操作:
// ch4/ts/change_detection_strategy/app.ts 
class TodoApp { 
  todos: Immutable.fromJS([{ 
    label: 'Buy milk', 
    completed: false 
  }, { 
    label: 'Save the world', 
    completed: false 
  }]);
  ... 
} 
这样,我们将todos属性构建为一个不可变列表,其中包含不可变对象;我们只需要调用由 Immutable.js 导出的函数fromJS。它将递归地将任何 JavaScript 对象转换为不可变对象。
接下来,由于不可变列表的变异操作返回一个新的列表,我们需要在addTodo和toggleTodoCompletion中做轻微的修改:
... 
addTodo(label: string) { 
  this.todos = this.todos.push(Immutable.fromJS({ 
    label, 
    completed: false 
  })); 
}
toggleCompletion(index: number) { 
  this.todos = this.todos.update(index, todo => { 
    return Immutable.fromJS({ 
      label: todo.label, 
      completed: !todo.completed 
    });
  }); 
} 
... 
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); 
  } 
} 
最后,我们需要修改在模板中访问不可变任务项属性的方式:
<ul>
  <li *ngFor="let todo of todos; let index = index"
      [class.completed]="todo.get('completed')">
     <input type="checkbox" [checked]="todo.get('completed')"
       (change)="toggleCompletion(index)">
    {{todo.get('label')}}
  </li>
</ul>
我们在这里所做的更改是,我们不是使用直接属性访问,例如todo.completed,而是通过传递我们想要获取的属性值作为字符串来调用对象的get方法,在这种情况下是"completed"。
在@Component装饰器内部,我们将changeDetection属性设置为OnPush策略的值。这意味着组件只有在任何输入获得新的引用时才会运行其变更检测器。除了属性访问之外,组件的模板中的其他所有内容都保持完全相同,因为ngForOf内部使用 ES2015 迭代器遍历提供的集合中的项。它们由 Immutable.js 支持,因此模板不需要任何更改。
由于我们需要更改项的索引而不是其引用(我们在TodoApp的todos集合的update方法中使用的那种),我们将组件输出的类型更改为EventEmitter<number>。在toggleCompletion中,我们发出更改的任务项的索引。
这就是我们通过防止在父组件没有推送新的引用的输入的情况下在整个右子树中运行变更检测机制来优化我们的简单任务应用程序的方式。
摘要
在本章中,我们介绍了 Angular 应用程序的核心构建块:指令和组件。我们构建了一些示例组件,这展示了定义这些基本概念时要使用的语法。我们还描述了每个指令的生命周期以及给定指令和组件的核心功能集。作为下一步,我们看到了如何使用不可变数据通过OnPush变更检测策略来提高我们应用程序的性能。
下一章完全致力于 Angular 服务和框架的依赖注入机制。我们将探讨如何定义和实例化自定义注入器,以及我们如何在指令和组件中利用依赖注入机制。
第五章。Angular 中的依赖注入
在本章中,我们将解释如何利用框架的依赖注入(DI)机制及其所有各种功能。
我们将探讨以下主题:
- 
配置和创建注入器。 
- 
使用注入器实例化对象。 
- 
将依赖项注入到指令和组件中——这样,我们就能重用服务中定义的业务逻辑,并将其与 UI 逻辑连接起来。 
- 
为 ES5 代码添加注解,以获得与使用 TypeScript 语法时相同的结果。 
为什么我需要 DI?
假设我们有一个依赖于Engine和Transmission类的Car类。我们如何实现这个系统?让我们看看:
class Engine {...} 
class Transmission {...}
class Car { 
  engine; 
  transmission;
  constructor() { 
    this.engine = new Engine(); 
    this.transmission = new Transmission(); 
  } 
} 
在前面的示例中,我们在Car类的构造函数内部创建了其依赖项。虽然看起来很简单,但它远非灵活。每次我们创建Car类的实例时,在其构造函数中,都会创建相同Engine和Transmission类的实例。这可能会因为以下原因而成为问题:
- 
由于我们无法独立于 engine和transmission依赖项测试Car类,因此Car类变得难以测试。
- 
我们将 Car类与其依赖项的实例化逻辑耦合起来。
Angular 中的 DI
另一种处理方式是利用 DI 模式。我们已经在 AngularJS 中熟悉它了;让我们演示一下如何在 Angular 的上下文中使用 DI 重构前面的代码:
class Engine {...} 
class Transmission {...} 
@Injectable() 
class Car { 
  engine; 
  transmission;
  constructor(engine: Engine, transmission: Transmission) { 
    this.engine = engine; 
    this.transmission = transmission; 
  } 
} 
在前面的代码片段中,我们只是在Car类的定义上方添加了@Injectable类装饰器,并为构造函数的参数提供了类型注解。
DI 的好处
还有一个步骤,我们将在下一节中探讨。在此之前,让我们看看这种方法的优点:
- 
我们可以轻松地传递 Car类依赖项的不同版本,用于测试环境或实例化不同的Car车型。
- 
我们没有与依赖项实例化的逻辑耦合。 
Car类只负责实现其自身的领域特定逻辑,而不是与额外的功能耦合,例如其依赖项的管理。我们的代码也更加声明式和易于阅读。
现在,我们已经意识到 DI 的一些好处,让我们看看为了使代码工作所缺少的部分。
配置注入器
在我们的 Angular 应用程序中,通过框架的 DI 机制实例化个体依赖项所使用的原始类型被称为注入器。注入器包含一组提供者,这些提供者封装了与令牌关联的已注册依赖项的实例化逻辑。我们可以将令牌视为注入器内注册的不同提供者的标识符。
让我们看看以下代码片段,它位于 ch5/ts/injector-basics/injector.ts:
import 'reflect-metadata';
import {
  ReflectiveInjector,
  Inject,
  Injectable,
  OpaqueToken
} from '@angular/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 = ReflectiveInjector.resolveAndCreate([
  { provide: BUFFER_SIZE, useValue: 42 },
  Buffer,
  Socket
]);
injector.get(Socket);
你可以使用以下命令运行文件:
$ cd app
$ ts-node ch5/ts/injector-basics/injector.ts
如果你还没有安装 ts-node,请参阅 第三章,TypeScript 快速入门,其中解释了如何进行操作,以便在您的计算机上运行。
然后,我们导入 ReflectiveInjector、Injectable、Inject、OpaqueToken 和 provide。
注入器代表一个用于不同依赖项实例化的抽象 容器;ReflectiveInjector 是一个具体类,它实现了这个抽象。使用声明的规则,将对象字面量作为 resolveAndCreate 的第一个参数传递,以及 TypeScript 编译器生成的元数据,ReflectiveInjector 就知道如何创建不同的依赖项。
在前面的代码片段中,我们最初定义了 BUFFER_SIZE 常量,并将其设置为 new OpaqueToken('buffer-size') 的值。我们可以将 BUFFER_SIZE 的值视为一个在应用程序中不能重复的唯一值(OpaqueToken 是 ES2015 中 Symbol 类的替代品,因为在编写这本书的时候,TypeScript 还不支持它)。
我们定义了两个类:Buffer 和 Socket。Buffer 类有一个只接受一个名为 size 的依赖项的构造函数,其类型为 Number。为了在依赖项解析过程中添加额外的元数据,我们使用 @Inject 参数装饰器。这个装饰器接受一个标识符(也称为 标记)作为我们想要注入的依赖项。通常,它是依赖项的类型(即类的引用),但在某些情况下,它可以是不同类型的值。例如,在我们的情况下,我们使用了 OpaqueToken 类的实例。
基于生成元数据的依赖解析
现在,让我们看看 Socket 类。我们用 @Injectable 装饰器来装饰它。这个装饰器应该被任何接受依赖并通过 Angular 的依赖注入机制注入的类使用。
@Injectable 装饰器强制 TypeScript 编译器为给定类接受的依赖类型生成额外的元数据。这意味着,如果我们省略 @Injectable 装饰器,Angular 的依赖注入机制将不会意识到它需要解析的依赖项关联的标记。
如果在类上没有使用任何装饰器,TypeScript 不会生成任何元数据,这主要是出于性能考虑。想象一下,如果为每个接受依赖的单独类生成这样的元数据——在这种情况下,输出将会因为额外的未使用类型元数据而变得臃肿。
使用 @Injectable 的一个替代方案是使用 @Inject 装饰器显式声明依赖项的标记。看看下面的例子:
class Socket {
  constructor(@Inject(Buffer) private buffer: Buffer) {}
}
这意味着前面的代码与之前提到的使用@Injectable的代码具有等效的语义。唯一的区别是,与使用@Injectable的情况相比,Angular 将明确(直接从@Inject装饰器添加的元数据)获取依赖项的类型(即与之关联的令牌),而在使用@Injectable的情况下,它将查看编译器生成的元数据。
实例化注入器
现在,让我们创建一个注入器的实例,以便使用它来实例化已注册的令牌:
let injector = ReflectiveInjector.resolveAndCreate([ 
  { provide: BUFFER_SIZE, useValue: 42 }, 
  Buffer, 
  Socket 
]); 
我们使用ReflectiveInjector的静态方法resolveAndCreate创建其实例。这是一个接受提供者数组作为参数并返回一个新的ReflectiveInjector的工厂方法。
resolve意味着提供者将经历一个解决过程,这包括一些内部处理(展平多个嵌套数组并将单个提供者转换为数组)。随后,注入器可以根据提供者封装的规则实例化我们已注册提供者的任何依赖项。
在我们的情况下,通过提供者的声明,我们明确告诉 Angular 的 DI 机制,当需要BUFFER_SIZE令牌时,使用值42。其他两个提供者是隐式的。Angular 将在所有依赖项都解决后,通过使用new运算符调用提供的类来实例化它们。
我们在Buffer类的构造函数中请求BUFFER_SIZE值:
class Buffer { 
  constructor(@Inject(BUFFER_SIZE) private size: Number) { 
    console.log(this.size); 
  } 
} 
在前面的示例中,我们使用了@Inject参数装饰器。它暗示了 DI 机制,即Buffer类构造函数的第一个参数应该使用与注入器传递的BUFFER_SIZE令牌关联的提供者进行实例化。
引入前向引用
Angular 引入了前向引用的概念。这是由于以下原因所必需的:
- 
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类的定义。请注意,Buffer标识符的值将在 JavaScript 虚拟机评估Buffer类的声明之前等于undefined。然而,Socket接受的依赖项类型的元数据将被生成并放置在Socket类定义之后。这意味着,在解释生成的 JavaScript 代码期间,Buffer标识符的值将等于undefined——也就是说,作为一个依赖项的类型(或在 Angular 的 DI 机制中,其 token),框架将获得一个无效的值。
运行前面的代码片段将导致以下形式的运行时错误:
错误:无法解析Socket(undefined)的所有参数。请确保它们都具有有效的类型或注解。
解决此问题的最佳方式是通过交换定义的正确顺序。另一种我们可以采取的方法是利用 Angular 提供的解决方案——前向引用:
... 
import {forwardRef} from '@angular/core'; 
...
@Injectable() 
class Socket { 
  constructor(@Inject(forwardRef(() => Buffer)) 
    private buffer: Buffer) {} 
}
class Buffer {...} 
前面的代码片段展示了我们如何利用前向引用的优势。我们只需要使用带有forwardRef函数调用结果的@Inject参数装饰器即可。forwardRef函数是一个高阶函数,它接受一个单一参数——另一个负责返回需要注入的依赖项(或更精确地说,其提供者)所关联的标记(token)的函数。这样,框架提供了一种延迟解析依赖项(token)类型的过程的方法。
依赖项的标记将在第一次需要实例化Socket时解析,这与默认行为不同,在默认行为中,标记在给定类的声明时就需要。
配置提供者
现在,让我们看看一个与之前使用的示例类似,但具有不同注入器配置的例子:
let injector = ReflectiveInjector.resolveAndCreate([ 
  { provide: BUFFER_SIZE, useValue: 42 }, 
  { provide: Buffer, useClass: Buffer }, 
  { provide: Socket, useClass: Socket } 
]); 
在这种情况下,在提供者内部,我们明确声明我们希望使用Buffer类来构建具有与Buffer类引用相等的 token 的依赖项。我们对与Sockettoken 关联的依赖项做完全相同的事情;但这次,我们提供了Socket类。这就是 Angular 在省略显式提供者声明并仅传递类引用时将如何操作。
明确声明用于创建同一类实例的类可能看起来毫无价值,并且鉴于我们迄今为止看到的示例,这将是完全正确的。然而,在某些情况下,我们可能希望为与给定类 token 关联的依赖项实例化提供不同的类。
例如,假设我们有一个名为UserService的服务中使用的Http服务:
class Http {...} 
@Injectable() 
class UserService { 
  constructor(private http: Http) {} 
} 
let injector = ReflectiveInjector.resolveAndCreate([ 
  UserService, 
  Http 
]); 
UserService 服务使用 Http 与 RESTful 服务进行通信。我们可以使用 injector.get(UserService) 来实例化 UserService。这样,由注入器的 get 方法调用的 UserService 的构造函数将接受一个 Http 服务实例作为参数。然而,如果我们想测试 UserService,我们实际上并不需要向 RESTful 服务发起 HTTP 请求。在单元测试的情况下,我们可以提供一个模拟实现,它只会模拟这些 HTTP 请求。为了将不同类的实例注入到 UserService 服务中,我们可以更改注入器的配置如下:
class DummyHttp {...} 
// ... 
let injector = ReflectiveInjector.resolveAndCreate([ 
  UserService, 
  { provide: Http, useClass: DummyHttp } 
]); 
现在,当我们实例化 UserService 时,它的构造函数将接收一个 DummyHttp 服务实例的引用。此代码位于 ch5/ts/configuring-providers/dummy-http.ts。
使用现有提供者
另一种进行的方式是使用提供者配置对象的 useExisting 属性:
// ch5/ts/configuring-providers/existing.ts 
let injector = ReflectiveInjector.resolveAndCreate([ 
  DummyService, 
  { provide: Http, useExisting: DummyService }, 
  UserService 
]); 
在前面的代码片段中,我们注册了三个令牌:DummyService、UserService 和 Http。我们声明我们希望将 Http 令牌绑定到现有的 DummyService 令牌。这意味着当请求 Http 服务时,注入器将找到作为 useExisting 属性值的令牌的提供者,并实例化它或获取与之关联的值。我们可以将 useExisting 视为为给定令牌创建一个别名:
let dummyHttp = { 
  get() {}, 
  post() {} 
}; 
let injector = ReflectiveInjector.resolveAndCreate([ 
  { provide: DummyService, useValue: dummyHttp }, 
  { provide: Http, useExisting: DummyService }, 
  UserService 
]); 
console.assert(injector.get(UserService).http === dummyHttp); 
前面的代码片段将为 Http 令牌创建一个 DummyHttp 令牌的别名。这意味着一旦请求 Http 令牌,调用将被转发到与 DummyHttp 令牌关联的提供者,它将被解析为 dummyHttp 的值。
定义用于实例化服务的工厂
现在,假设我们想要创建一个复杂对象,例如,代表传输层安全性(TLS)连接的对象。此类对象的一些属性包括套接字、一组加密协议和证书。在这个问题的背景下,我们迄今为止所查看的 Angular 的依赖注入(DI)机制的功能可能看起来有些有限。
例如,我们可能需要配置 TLSConnection 类的一些属性,而不将其实例化过程与所有配置细节耦合(选择合适的加密算法,打开我们将通过它建立安全连接的 TCP 套接字等)。
在这种情况下,我们可以利用提供者配置对象的 useFactory 属性:
let injector = ReflectiveInjector.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] 
  }, 
  { BUFFER_SIZE, useValue: 42 }, 
  Buffer, 
  Socket, 
  Certificate, 
  Crypto 
]); 
上述代码片段一开始可能看起来有些复杂,但让我们一步一步地来看。我们可以从我们已经熟悉的部分开始:
let injector = ReflectiveInjector.resolveAndCreate([ 
  ... 
  { 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 的依赖注入机制解析并注入到工厂函数中。您可以查看整个实现并在ch5/ts/configuring-providers/factory.ts中尝试它。
子注入器和可见性
在本节中,我们将探讨如何构建注入器的层次结构。这是 Angular 2 框架中引入的一个全新的概念。每个注入器可以有一个或没有父注入器,每个父注入器可以有零个或多个子注入器。与 AngularJS 中所有注册的提供者都存储在扁平结构中不同,在 Angular 2 及以后的版本中,它们被存储在树中。扁平结构更为有限;例如,它不支持令牌的命名空间;我们无法为相同的令牌声明不同的提供者,这在某些情况下可能是必需的。到目前为止,我们已经查看了一个没有子注入器或父注入器的注入器示例。现在,让我们构建一个注入器的层次结构。
为了更好地理解注入器的这种层次结构,让我们看一下以下图表:

图 1
在这里,我们看到一个树,每个节点都是一个注入器,这些注入器中的每一个都保留对其父注入器的引用。注入器房屋有三个子注入器:浴室、厨房和车库。
车库有两个子注入器:汽车和储藏室。我们可以将这些注入器视为内部注册有提供者的容器。
假设我们想要获取与令牌 Tire 关联的提供者的值。如果我们使用注入器 Car,这意味着 Angular 的 DI 机制将尝试在 Car 及其所有父级 Garage 和 House 中找到与该令牌关联的提供者,直到找到为止。
构建注入器层次结构
为了更好地理解段落,让我们看看这个简单的例子:
// ch5/ts/parent-child/simple-example.ts
class Http {} 
@Injectable() 
class UserService { 
  constructor(public http: Http) {} 
} 
let parentInjector = ReflectiveInjector.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 服务。
初始时,我们使用 ReflectiveInjector 类的 resolveAndCreate 静态方法创建一个注入器。我们向这个注入器传递一个隐式提供者,该提供者稍后将解析为一个具有 Http 令牌的提供者。使用 resolveAndCreateChild,我们解析传递的提供者并实例化一个注入器,该注入器指向 parentInjector(因此,我们得到与之前图中 Garage 和 House 之间的相同关系)。
现在,使用 childInjector.get(UserService),我们能够获取与 UserService 令牌关联的值。同样,使用 childInjector.get(Http) 和 parentInjector.get(Http),我们获取与 Http 令牌关联的相同值。这意味着 childInjector 向其父级请求与请求的令牌关联的值。
然而,如果我们尝试使用 parentInjector.get(UserService),我们将无法获取与令牌关联的值,因为它的提供者已在 childInjector 中注册。
配置依赖项
既然我们已经熟悉了注入器的层次结构,让我们看看我们如何从其中的适当注入器获取依赖项。
使用 @Self 装饰器
现在,假设我们有以下配置:
abstract class Channel {}
class Http extends Channel {}
class WebSocket extends Channel {} 
@Injectable() 
class UserService { 
  constructor(public channel: Channel) {} 
} 
let parentInjector = ReflectiveInjector.resolveAndCreate([ 
  { provide: Channel, useClass: Http } 
]);
let childInjector = parentInjector.resolveAndCreateChild([ 
  { provide: Channel, useClass: WebSocket }, 
  UserService 
]); 
我们可以使用以下方式实例化 UserService 令牌:
childInjector.get(UserService); 
在 UserService 中,我们可以声明我们想要从当前注入器(即 childInjector)使用 @Self 装饰器获取 Channel 依赖项:
@Injectable() 
class UserService { 
  constructor(@Self() public channel: Channel) {} 
} 
虽然这将是 UserService 实例化期间的默认行为,但使用 @Self,我们可以更加明确。假设我们更改 childInjector 的配置如下:
let parentInjector = ReflectiveInjector.resolveAndCreate([ 
  { provide: Channel, useClass: Http } 
]);
let childInjector = parentInjector.resolveAndCreateChild([ 
  UserService 
]); 
如果我们在 UserService 构造函数中保留 @Self 装饰器,并尝试使用 childInjector 实例化 UserService,我们将因为缺少 Channel 提供者而得到一个运行时错误。
跳过自身注入器
在某些情况下,我们可能想要使用父注入器中注册的提供者,而不是当前注入器中注册的提供者。我们可以通过利用 @SkipSelf 装饰器来实现这种行为。例如,假设我们有以下 Context 类的定义:
class Context { 
  constructor(public parentContext: Context) {} 
} 
Context 类的每个实例都有一个父级。现在,让我们构建一个包含两个注入器的层次结构,这将允许我们创建一个具有父上下文的上下文:
let parentInjector = ReflectiveInjector.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 = ReflectiveInjector.resolveAndCreate([ 
  Collection 
]); 
在这种情况下,我们定义了一个名为SortingAlgorithm的抽象类和一个名为Collection的类,它接受一个扩展SortingAlgorithm的具体类实例作为依赖项。在Collection构造函数中,我们将sort实例属性设置为传递的SortingAlgorithm类型依赖项或默认排序算法实现。
我们在配置的注入器中没有为SortingAlgorithm令牌定义任何提供者。因此,如果我们想使用injector.get(Collection)获取Collection类的实例,我们将得到一个运行时错误。这意味着如果我们想通过框架的 DI 机制获取Collection类的实例,我们必须为SortingAlgorithm令牌注册一个提供者,尽管我们可能希望回退到由getDefaultSort方法返回的默认排序算法。
Angular 通过@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 找不到任何提供者来处理其令牌,它将传递null值。
使用多提供者
多提供者(Multiproviders)是 Angular 在版本 2 中引入的另一个新概念。它们允许我们将多个提供者与同一个令牌(token)关联起来。如果我们正在开发一个带有一些默认服务实现的第三方库,但希望用户能够用自定义实现来扩展它,这将非常有用。它们还专门用于在 Angular 表单模块中声明对单个控件的多重验证。我们将在第六章使用 Angular 路由和表单和第七章解释管道和与 RESTful 服务通信中解释这个模块。
另一个多提供者(multiproviders)的适用用例是 Angular 在其 Web Workers 实现中用于事件管理。他们为事件管理插件创建了多提供者。每个提供者返回不同的策略,支持不同的事件集(触摸事件、键盘事件等)。一旦发生特定事件,他们可以选择处理该事件的适当插件。
让我们看看一个示例,它说明了多提供者(multiproviders)的典型用法:
// ch5/ts/configuring-providers/multi-providers.ts 
const VALIDATOR = new OpaqueToken('validator'); 
interface EmployeeValidator { 
  (person: Employee): string; 
} 
class Employee {...} 
let injector = ReflectiveInjector.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的类,它接受一个依赖项:一个EmployeeValidator数组。在validate方法中,我们对当前类的实例应用单个验证器,并过滤结果以仅获取返回错误消息的验证器。
注意,构造函数参数validators的类型是EmployeeValidator[]。由于我们不能使用“对象数组”作为提供者的令牌,因为它在 JavaScript 中不是一个有效的值,也不能用作令牌,因此我们需要使用@Inject参数装饰器。
使用 DI(依赖注入)与组件和指令
在第四章开始使用 Angular 组件和指令中,当我们开发我们的第一个 Angular 指令时,我们看到了如何利用 DI 机制将服务注入到我们的 UI 相关组件(即指令和组件)中。
让我们快速回顾一下之前所做的工作,但这次是从 DI(依赖注入)的角度来看:
// 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]
}) 
class App {} 
由于与我们的当前焦点无直接关系,因此省略了早期实现中的大部分代码。
注意,Tooltip 的构造函数接受两个依赖项:
- 
ElementRef类的一个实例。
- 
Overlay类的一个实例。
依赖项的类型是与它们的提供者关联的令牌,以及从提供者获得的相应值,这些值将通过 Angular 的依赖注入机制进行注入。
虽然 Tooltip 类的依赖项声明看起来与我们在前面的章节中做的完全一样,但既没有显式的配置,也没有注入器的实例化。在这种情况下,Angular 内部创建并配置了所谓的 元素注入器。我们稍后会稍作解释,但在那之前,让我们看看我们如何使用 NgModules 配置 DI 机制。
使用 NgModules 配置 DI
我们已经在 第二章、Angular 应用程序的构建块 和 第四章、开始使用 Angular 组件和指令 中讨论了 NgModules。我们提到它们帮助我们将应用程序划分为逻辑部分;我们还讨论了如何使用 NgModules 的导入和导出。在本节中,我们将简要概述我们如何使用它们来配置应用程序的提供者。
基于 Angular 在给定 NgModule 中声明的提供者,Angular 将实例化一个注入器。这个注入器将管理我们传递给 @NgModule 装饰器的对象字面量中 providers 属性列出的所有提供者:
class Markdown {...}
@Component(...)
class MarkdownPanel {...}
@Component(...)
class App {...}
@NgModule({
  declarations: [App, MarkdownPanel],
  providers: [Markdown],
  imports: [BrowserModule],
  bootstrap: [App],
})
class AppModule {}
platformBrowserDynamic().bootstrapModule(AppModule);
在前面的例子中,我们为 Markdown 服务声明了一个单独的提供者。因为它将可用于在 declarations 数组中列出的所有组件和指令,因为顶级组件使用的注入器将获得通过 NgModule 配置的注入器作为其父注入器。
现在,假设我们的模块导入另一个模块,该模块有提供者的声明:
// ch4/ts/directives-ngmodules/app.ts
// ...
@NgModule({
  declarations: [Button],
  exports: [Button],
  providers: [Markdown],
})
class ButtonModule {}
//...
@NgModule({
  declarations: [App, MarkdownPanel],
  imports: [BrowserModule, ButtonModule],
  bootstrap: [App],
})
class AppModule {}
platformBrowserDynamic().bootstrapModule(AppModule);
在这个例子中,我们有两个模块——我们之前看到的 AppModule 和 ButtonModule。在 AppModule 导入 ButtonModule 的片段中,这意味着 ButtonModule 的所有导出都将作为 AppModule 中的声明可用。除此之外,ButtonModule 中声明的提供者将与 AppModule 的提供者合并。基于所有这些提供者,Angular 将实例化一个注入器,并将其设置为启动组件 App 使用的注入器的父注入器。
现在,让我们讨论每个组件和指令关联的元素注入器。
介绍元素注入器
在幕后,Angular 将为所有指令和组件创建注入器,并将一组默认的提供者添加到它们中。这些被称为元素注入器,是框架自己处理的事情。与组件关联的注入器称为宿主注入器。每个元素注入器中的一个提供者与ElementRef令牌相关联;它将返回指令的主元素引用。然而,Overlay类的提供者在哪里声明呢?让我们看看顶级组件的实现:
@Component({ 
  // ... 
  providers: [Overlay]
})
class App {} 
我们通过在@Component装饰器内部声明providers属性来配置App组件的元素注入器。在这个阶段,注册的提供者将由指令或与相应元素注入器关联的组件以及组件的整个组件子树可见,除非它们在层次结构中的某个地方被覆盖。
声明元素注入器的提供者
将所有提供者的声明放在同一个地方可能相当不方便。例如,想象一下我们正在开发一个大型应用程序,该应用程序有数百个组件依赖于数千个服务。在这种情况下,在根组件中配置所有提供者不是一个实际的解决方案。当两个或多个提供者与同一个令牌相关联时,将发生名称冲突。配置将非常大,并且很难追踪不同的依赖项需要注入的位置。
正如我们提到的,Angular 的@Directive(以及@Component)装饰器允许我们使用providers属性引入特定于指令的提供者。以下是我们可以如何接近这个问题的方法:
@Directive({ 
  selector: '[saTooltip]', 
  providers: [{ provide: Overlay, useClass: OverlayMock }] 
}) 
export class Tooltip { 
 @Input() saTooltip: string; 
  constructor(private el: ElementRef, private overlay: Overlay) { 
    this.overlay.attach(el.nativeElement); 
  } 
  // ... 
} 
// ... 
platformBrowserDynamic().bootstrapModule(AppModule);
上述示例覆盖了Tooltip指令声明中的Overlay令牌的提供者。这样,Angular 在提示框实例化期间将注入OverlayMock实例而不是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); 
  } 
} 
在 @Component 装饰器中,我们使用 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> 
下面的 panel-content 元素中使用的 MarkdownButton 无法访问 Markdown 服务;然而,如果我们在这个组件的模板中使用按钮,它将是可访问的:
@Component({ 
  selector: 'markdown-panel', 
  viewProviders: [Markdown], 
  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 目录下的示例目录中找到这个例子。
注意
注意,对于任何组件或指令,我们可以使用传递给 @Component 或 @Directive 装饰器的对象字面量的 providers 属性覆盖在 NgModule 中声明的现有提供者。如果我们只想为给定组件的视图子组件覆盖特定提供者,我们可以使用 viewProviders。
使用 ES5 的 Angular DI
我们已经熟练地使用 TypeScript 的 Angular DI!正如我们所知,我们不仅限于 TypeScript 来开发 Angular 应用程序;我们还可以使用 ES5、ES2015 和 ES2016(以及 Dart,但这本书的范围之外)。
到目前为止,我们已经在它们的构造函数中使用标准的 TypeScript 类型注解声明了不同类的依赖关系。所有这些类都应该用 @Injectable 装饰器进行装饰。不幸的是,Angular 支持的其他一些语言缺少这些功能中的几个。在下面的表中,我们可以看到 ES5 不支持类型注解、类和装饰器:
| ES5 | ES2015 | ES2016 | |
|---|---|---|---|
| 类 | No | Yes | Yes | 
| 装饰器 | No | No | Yes (no parameter decorators) | 
| 类型注解 | No | No | No | 
我们如何利用这些语言的 DI 机制?Angular 提供了一个内部 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将返回一个具有对象字面量中constructor体部的 JavaScript 构造函数。所有在传递参数的边界内定义的其他方法都将添加到该函数的原型中。
一个问题得到了解决:我们现在可以模拟 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 组件和指令,我们已经熟悉了用于定义组件的 ES5 语法。现在,让我们看看MarkdownPanel的构造函数,以检查我们如何声明组件和类的一般依赖项。
从前面的代码片段中,我们应该注意到这次构造函数的值不是一个函数,而是一个数组。这可能会让你想起 AngularJS,在那里我们能够通过列出它们的名称来声明给定服务的依赖项:
Module.service('UserMapper', 
  ['User', '$http', function (User, $http) { 
    // ... 
  }]); 
虽然新语法看起来相似,但它带来了一些改进。例如,我们不再局限于使用字符串作为依赖项的令牌。
现在,假设我们想要将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 的静态类型。这样,我们拥有更清晰的语法,通过更少的输入获得更好的语义,并为我们提供强大的工具,包括直接的 AoT 编译过程(我们将在本书的最后一章探讨 Angular 的 AoT)。
摘要
在本章中,我们介绍了 Angular 的依赖注入(DI)机制。我们通过在框架的上下文中介绍 DI,简要讨论了在我们的项目中使用 DI 的优点。我们旅程的第二步是如何实例化和配置注入器;我们还解释了注入器的层次结构和已注册提供者的可见性。为了强制实现更好的关注点分离,我们提到了如何在我们的指令和组件中注入携带我们应用业务逻辑的服务。我们最后探讨的是如何使用 ES5 语法与 DI 机制结合使用。
在下一章中,我们将介绍框架的新路由机制。我们将解释如何配置基于组件的路由器并为我们应用添加多个视图。我们还将探讨另一个重要主题——新的表单模块。通过构建一个简单的应用,我们将演示如何创建和管理表单。
第六章。使用 Angular 路由器和表单
到目前为止,我们已经熟悉了框架的核心。我们知道如何定义组件和指令来开发我们应用程序的视图。我们还知道如何将业务逻辑封装到服务中,并使用 Angular 的依赖注入(DI)机制将一切连接起来。
在本章中,我们将解释一些更多概念,这将帮助我们构建实际的 Angular 应用程序。它们如下:
- 
框架的基于组件的路由器。 
- 
使用 Angular 的表单模块。 
- 
开发自定义表单验证器。 
- 
开发模板驱动表单。 
让我们开始吧!
开发 "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 特定的内容,所以我们不会深入细节。
现在,让我们通过探索新的路由器继续实现:
探索 Angular 路由器
如我们所知,为了启动任何 Angular 应用程序,我们需要开发一个根 NgModule 和一个启动组件。"Coders repository" 应用程序也不例外;在这个特定情况下,唯一的增加是我们将有多个页面需要通过 Angular 路由器连接在一起。
让我们从路由器配置所需的导入开始,并在之后定义根组件:
// ch6/ts/step-0/app.ts
import {
  APP_BASE_HREF,
  LocationStrategy,
  HashLocationStrategy
} from '@angular/common';
import {RouterModule} from '@angular/router';
在前面的代码片段中,我们直接从@angular/router导入RouterModule;正如我们所看到的,路由器被外部化到框架的核心之外。此模块声明了所有路由特定的指令以及所有与路由相关的提供者,这意味着如果我们导入它,我们将获得访问它们的权限。
LocationStrategy类是一个抽象类,它定义了HashLocationStrategy(用于基于哈希的路由)和PathLocationStrategy(通过利用历史 API 进行基于 HTML5 的路由)之间的共同逻辑。(即对路由器有意义)。
注意
HashLocationStrategy不支持服务器端渲染,因为页面的哈希值不会发送到服务器。由于哈希是应用程序的视图标识符,服务器将不会知道需要渲染的页面。幸运的是,除了 IE9 之外的所有现代浏览器都支持 HTML5 历史 API。你可以在本书的最后一章中找到更多关于服务器端渲染的信息。
现在,让我们定义一个引导组件并配置应用程序的根模块:
// ch6/ts/step-0/app.ts
@Component({
  selector: 'app',
  template: `...`,
  providers: [DeveloperCollection]
})
class App {}
const routeModule = RouterModule.forRoot([...]);
@NgModule({
  declarations: [App],
  bootstrap: [App],
  imports: [BrowserModule],
  providers: [{
    provide: LocationStrategy,
    useClass: HashLocationStrategy
  }]
})
class AppModule {}
platformBrowserDynamic().bootstrapModule(AppModule);
在前面的代码片段中,我们可以注意到一个我们已经在第四章和第五章中熟悉的语法,开始使用 Angular 组件和指令和Angular 中的依赖注入。我们定义了一个具有app选择器的组件,template,我们将在稍后查看它,以及提供者和指令的集合。
App组件声明了一个与DeveloperCollection令牌关联的单个提供者。这是包含应用程序存储的所有开发者的类。稍后,我们将调用RouterModule的forRoot方法;此方法允许我们通过声明应用程序的一些路由来配置路由器。
一旦我们导入了模块,该模块作为forRoot调用返回的结果,我们就可以访问一组指令。这些指令可以帮助我们链接到作为路由器配置一部分定义的其他路由(routerLink指令)并声明与不同路由关联的组件应该渲染的位置(router-outlet)。我们将在本节后面解释如何使用它们。
现在,让我们看看我们的AppModule的配置:
@NgModule({
  declarations: [App],
  bootstrap: [App],
  imports: [BrowserModule, routeModule],
  providers: [{
    provide: LocationStrategy,
    useClass: HashLocationStrategy
  }]
})
class AppModule {}
我们添加了一个单独的声明——用于引导应用程序的App组件。请注意,在这里,我们不仅导入了BrowserModule,还导入了从RouterModule的forRoot方法调用返回的结果。在providers数组中,我们配置了LocationStrategy的提供者。Angular 使用的默认LocationStrategy实现是PathLocationStrategy(即基于 HTML5 的)。然而,在这种情况下,我们将使用基于哈希的。
当我们不得不在两种位置策略之间做出选择时,我们应该记住,默认的位置策略(PathLocationStrategy)由 Angular 的服务器端渲染模块支持,并且应用程序的 URL 对最终用户来说看起来更自然(没有使用 #)。另一方面,如果我们使用 PathLocationStrategy,我们可能需要配置我们的应用程序服务器以与 HTML5 历史 API 一起工作,这对于 HashLocationStrategy 是不必要的。
使用 PathLocationStrategy
PathLocationStrategy 使用 APP_BASE_HREF,默认值为字符串 "/"。这意味着,如果我们的应用程序的基本路径名不同,我们必须明确设置它,以便位置策略能够正常工作。例如,在我们的情况下,配置应该如下所示:
import {APP_BASE_HREF} from '@angular/common'; 
//... 
@NgModule({
  ...
  providers: [{
      provide: APP_BASE_HREF,
      useValue: '/dist/dev/ch6/ts/multi-page-template-driven/'
    },
    { provide: LocationStrategy, useClass: HashLocationStrategy }
  ]
})
class AppModule {}
APP_BASE_HREF 表示应用程序的基本路径。例如,在我们的情况下,“Coders 仓库”将位于 /dist/dev/ch6/ts/multi-page-template-driven/ 目录下(或者,如果我们包括方案和主机,http://localhost:5555/dist/dev/ch6/ts/multi-page-template-driven/)。
我们需要提供 APP_BASE_HREF 的值,以便向 Angular 暗示路径的哪一部分是应用程序路由(即对路由器有意义的)。例如,对于 URL http://localhost:5555/dist/dev/ch6/ts/multi-page-template-driven/**home**,如果 APP_BASE_HREF 等于 /dist/dev/ch6/ts/multi-page-template-driven/,Angular 将知道它需要提供与 home 路径关联的组件,因为 URL 的其余部分与应用程序中声明的路由无关。
配置路由
作为下一步,让我们更新路由的声明。打开 ch6/ts/step-0/app.ts 并更新对 RouteModule 的 forRoot 方法的调用:
// ch6/ts/step-1/app.ts 
const routingModule = RouterModule.forRoot([
  {
    path: '',
    redirectTo: 'home',
    pathMatch: 'full'
  },
  {
    path: 'home',
    component: Home
  },
  {
    path: 'dev-add',
    component: AddDeveloper
  },
  {
    path: 'add-dev',
    redirectTo: 'dev-add'
  }
]);
如前文示例所示,forRoot 方法接受一个路由声明数组作为参数。我们定义了两个重定向和两个与它们关联的路由。
每个非懒加载的路由都必须定义以下属性:
- 
component:与给定路由关联的组件。
- 
path:用于路由的路径 – 它将在浏览器的地址栏中可见。
另一方面,重定向的定义应该包含:
- 
path:用于重定向的路径。
- 
redirectTo:用户将被重定向到的路径。
- 
pathMatch:定义匹配策略。
在前面的例子中,我们声明当用户导航到路径时,我们希望 /add-dev 被重定向到 /dev-add。正如我们提到的,pathMatch 定义了路径匹配策略。默认情况下,它的值是 "prefix",这意味着路由器将尝试将当前路由的开始部分与重定向中声明的 path 属性匹配。相比之下,当我们将 pathMatch 属性设置为 "full" 时,只有当整个路径匹配时,路由器才会重定向到 redirectTo 路径。在第一次重定向中显式设置 pathMatch 为 "full" 非常重要,否则在以前缀匹配的情况下,每个路由都会匹配到 "" 路径。
现在,为了使一切正常工作,我们需要定义 AddDeveloper 和 Home 组件,这些组件在路由配置中被引用。首先,我们将提供一个基本的实现,我们将在本章的整个过程中逐步扩展它。在 ch6/ts/step-0 中,创建一个名为 home.ts 的文件,并输入以下内容:
import {Component} from '@angular/core';
@Component({ 
  selector: 'home', 
  template: `Home` 
}) 
export class Home {} 
现在,打开名为 add_developer.ts 的文件,并在其中输入以下内容:
import {Component} from '@angular/core'; 
@Component({ 
  selector: 'dev-add', 
  template: `Add developer` 
}) 
export class AddDeveloper {} 
注意
不要忘记在 app.ts 中导入 Home 和 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]="['dev-add']">Add developer</a></li> 
      </ul> 
    </nav> 
    <router-outlet></router-outlet> 
  `, 
  //... 
}) 
在模板中,有两个 Angular 特定的指令:
- 
routerLink:这允许我们向特定路由添加链接。
- 
router-outlet:这定义了当前选定的路由相关组件应该渲染的容器。
让我们来看看 routerLink 指令。作为值,它接受一个路由路径和参数的数组。在我们的例子中,我们只提供了一个路由路径。请注意,routerLink 所使用的路由名称是由 forRoot 内部路由声明中的 path 属性声明的。在本书的后续内容中,我们将看到如何链接到嵌套路由并传递路由参数。
这个指令允许我们独立于我们配置的 LocationStrategy 声明链接。例如,假设我们正在使用 HashLocationStrategy;这意味着我们需要在我们的模板中为所有路由添加前缀 #。如果我们切换到 PathLocationStrategy,我们需要删除所有的哈希前缀。这是 routerLink 在路径引用之上创建的整洁抽象的部分好处。
从上一个模板中我们新接触到的下一个指令是 router-outlet。它具有与 AngularJS 中的 ng-view 指令类似的责任。基本上,它们都扮演相同的角色:指出目标组件应该渲染的位置。这意味着,根据定义,当用户导航到 / 时,Home 组件将在 router-outlet 指示的位置渲染,同样,当用户导航到 dev-add 时,AddDeveloper 组件也是如此。
现在,我们有两个路由正在运行!打开 http://localhost:5555/dist/dev/ch6/ts/step-0/,你应该看到一个类似于以下截图的页面:

图 2
如果你不这样做,只需查看包含最终结果的 ch6/ts/step-1。
使用 loadChildren 进行懒加载
AngularJS 模块允许我们将逻辑上相关的单元组合在一起。然而,默认情况下,它们需要在初始应用程序的引导期间可用,并且不允许延迟加载。这要求在初始页面加载时下载整个应用程序的代码库,这在大型单页应用的情况下可能是一个不可接受的性能打击。
在理想情况下,我们希望只加载用户当前查看的页面相关的代码,或者根据与用户行为相关的启发式方法预取捆绑模块,但这超出了本书的范围。例如,从我们的示例的第一个步骤打开应用程序,http://localhost:5555/dist/dev/ch6/ts/step-1/。一旦用户到达 /,我们只需要 Home 组件可用,一旦他们导航到 dev-add,我们希望加载 AddDeveloper 组件。
让我们在 Chrome DevTools 中检查实际发生的情况:

图 3
我们可以注意到,在初始页面加载期间,Angular 下载了与所有路由相关的组件,甚至包括不需要的 AddDeveloper。这是因为,在 app.ts 中,我们明确地要求了 Home 和 AddDeveloper 组件,并在路由声明中使用它们。
在这个特定情况下,加载这两个组件可能看起来不是什么大问题,因为在这个阶段,它们相当简单,没有依赖项。然而,在实际应用中,它们将导入其他指令、组件、管道、服务,甚至第三方库。一旦任何组件被要求,其整个依赖图都将被下载,即使此时该组件并不需要。
Angular 的路由器提供了一个解决方案来解决这个问题:
// ch6/ts/step-1-async/app.ts 
const routingModule = RouterModule.forRoot([
  {
    path: '',
    redirectTo: 'home',
    pathMatch: 'full'
  },
  {
    path: 'home',
    loadChildren: './home#HomeModule'
  },
  {
    path: 'dev-add',
    loadChildren: './add_developer#AddDeveloperModule'
  }
]);
懒加载路由的声明是一个具有以下属性的对象:
- 
loadChildren: 一个指向懒加载模块路径的字符串。
- 
path: 路由的路径。
当用户导航到与任何懒加载路由定义匹配的路由时,模块加载器(默认为 SystemJS)将从loadChildren提供的位置下载模块。当加载器返回的 promise 解析为目标模块的值时,该模块将被缓存,其引导组件将被渲染。下次用户导航到相同的路由时,将使用缓存的模块,因此路由模块不会下载相同的组件两次。
注意loadChildren属性值中的#符号。如果我们通过#符号分割字符串,其第一部分将是模块的 URL,其第二部分将是表示路由器将用于该路由的 Angular 模块的导出名称。如果我们不提供模块名称,Angular 将使用默认导出。
注意
之前的示例使用了loadChildren,它默认使用 SystemJS 加载模块。您可以使用更高级的配置和自定义模块加载器。有关更多信息,请参阅 Angular 文档angular.io。
预加载懒加载路由
如我们之前提到的,在理想情况下,我们希望下载用户在特定时间需要的最小资源集。例如,如果用户访问主页,我们只想下载对应于主页模块(即HomeModule)的包。
之后,当用户导航到dev-add时,路由器需要下载AddDeveloperModule。尽管这样,用户只为他们使用的资产消耗网络带宽,但由于导航到尚未访问的页面时发生的减速,用户体验将远非完美。
为了解决这个问题,我们可以添加一个路由预加载策略:
import {RouterModule, PreloadAllModules, ... } from '@angular/router';
...
export const appRoutes = RouterModule.forRoot(routes, {
  preloadingStrategy: PreloadAllModules
});
在前面的代码片段中,我们声明我们想要使用 Angular 提供的默认preloadingStrategy。因此,当用户打开home并且HomeModule成功下载后,路由器将自动开始预加载所有其他路由。因此,下次用户导航到不同的页面时,它很可能已经在内存中可用。这将几乎不花费任何成本地提高用户体验。
通过提供位于@angular/router包中的抽象类PreloadingStrategy的自定义实现,我们可以引入一个用于预加载懒加载模块的自定义机制。
RouterModule.forRoot 与 RouterModule.forChild
我们可以使用RouterModule调用两种方法来注册路由。
如果我们声明应用程序的最高级路由,我们需要使用RouterModule.forRoot。此方法将注册顶级路由并返回应由应用程序的根模块导入的路由模块。
如果我们想在懒加载的模块中定义路由并导入由forRoot方法调用返回的模块,我们将得到一个运行时错误。这是因为forRoot方法将返回一个包含提供者的模块,这个模块应该只由顶层模块导入一次。为了在懒加载的模块中注册嵌套路由,我们需要使用forChild方法。
我们将进一步探讨如何在第七章中定义嵌套路由,解释管道和与 RESTful 服务通信。
注意
作为一条经验法则,我们可以记住RouterModule.forRoot是用来注册顶级路由的,而RouterModule.forChild应该只用于在懒加载模块中注册嵌套路由。
使用 Angular 的表单模块
现在,让我们继续进行应用程序的实现。对于下一步,我们将处理AddDeveloper和Home组件。你可以通过扩展当前在ch6/ts/step-0中的内容来继续你的实现,或者如果你还没有达到步骤 1,你可以继续在ch6/ts/step-1中的文件上工作。
Angular 提供了两种开发带有验证的表单的方法:
- 
模板驱动方法:这提供了一个声明式 API,其中我们在组件的模板中声明验证。 
- 
模型驱动方法(也称为响应式表单):这提供了一个命令式、响应式的 API。 
现在,让我们先从模板驱动方法开始,并在下一章中探索模型驱动方法。
开发模板驱动表单
表单对于每个CRUD(创建、检索、更新和删除)应用程序都是必不可少的。在我们的案例中,我们想要构建一个表单来输入我们想要存储的开发者的详细信息。
到本节结束时,我们将有一个表单,允许我们输入给定开发者的真实姓名,添加他们偏好的技术,输入他们的电子邮件,并声明他们是否在社区中受欢迎或尚未。最终结果将如下所示:

图 4
在app.ts中添加以下导入:
import {FormsModule} from '@angular/forms';
下一步,我们需要在我们的AppModule中导入FormsModule。FormsModule包含一组预定义的指令,用于管理 Angular 表单,如form和ngModel指令。FormsModule还声明了一个数组,其中包含一组预定义的与表单相关的提供者,我们可以在应用程序中使用这些提供者。
在导入FormsModule之后,我们的app.ts将看起来如下:
// ch6/ts/step-2/add_developer.ts
@NgModule({
  imports: [BrowserModule, FormsModule, routingModule],
  declarations: [App, Home, AddDeveloper, ControlErrors],
  providers: [{
    provide: LocationStrategy,
    useClass: HashLocationStrategy
  }],
  bootstrap: [App]
})
class AppModule {}
现在,更新AddDeveloper实现如下:
// ch6/ts/step-2/add_developer.ts
@Component({ 
  selector: 'dev-add', 
  templateUrl: './add_developer.html', 
  styles: [...]
}) 
export class AddDeveloper { 
  developer = new Developer(); 
  errorMessage: string; 
  successMessage: string; 
  submitted = false; 
  // ...
  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" class="form col-md-4" [hidden]="submitted"
   (ngSubmit)="addDeveloper()">
  <div class="form-group">
    <label class="control-label" for="realNameInput">Real name</label>
    <div>
      <input id="realNameInput" class="form-control" 
          type="text" name="realName"
          [(ngModel)]="developer.realName" required>
    </div>
  </div>
  <!-- MORE CODE TO BE ADDED --> 
  <button class="btn btn-default" type="submit">Add</button> 
</form>  
我们使用 HTML form 标签声明一个新的表单。一旦 Angular 在模板中找到这样的标签,并且父组件中包含了一个表单指令,它将自动增强其功能,以便用作 Angular 表单。一旦表单被 Angular 处理,我们就可以应用表单验证和数据绑定。之后,使用 #f="ngForm",我们在模板中定义了一个局部变量,这允许我们使用标识符 f 来引用表单。表单元素最后剩下的东西是提交事件处理程序。我们使用我们已熟悉的语法,(ngSubmit)="expr";在这种情况下,表达式的值是调用组件控制器中定义的 addDeveloper 方法。
现在,让我们看一下具有类名 control-group 的 div 元素。
注意
注意,这并不是一个 Angular 特定的类;这是一个由 Bootstrap 定义的 CSS 类,我们使用它来为表单提供更好的外观和感觉。
在 div 元素内部,我们可以找到一个没有 Angular 特定标记的 label 元素和一个允许我们设置当前开发者真实姓名的输入元素。我们将控件设置为文本类型,并声明其标识符和名称等于 realNameInput。required 属性由 HTML5 规范定义,用于验证。通过在元素上使用它,我们声明此元素必须有值。尽管 required 属性不是 Angular 特定的,但 Angular 会通过包括 Angular 特定的验证行为来扩展其语义。这种行为包括在控件状态改变时设置特定的 CSS 类,并管理框架内部保持的状态。
当控件的值发生变化时,将运行验证以增强表单控件的行为,并在控件的生命周期中应用特定的类。您可能已经从 AngularJS 中熟悉了这一点,其中表单控件被装饰有 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 的上下文中,前面 CSS 的确切语义是,我们为所有值已更改且根据附加的验证器无效的输入元素使用红色边框。
现在,让我们探讨如何将验证行为附加到我们的控件上。
使用内置验证器
我们已经看到,我们可以使用 required 属性更改任何控件的验证行为。Angular 提供了两个更多内置验证器,如下所示:
- 
minlength:这允许我们指定给定控件应具有的最小值长度。
- 
maxlength:这允许我们指定给定控件应具有的最大值长度。
这些验证器使用 Angular 指令定义,并可以按以下方式使用:
<input id="realNameInput" class="form-control" 
       type="text" minlength="2" maxlength="30"> 
这样,我们指定输入值的范围在 2 到 30 个字符之间。
定义自定义验证器
在 Developer 类中定义的另一个数据属性是 email。让我们为它添加一个输入字段。在上一个表单中的 添加 按钮上方,添加以下标记:
<div class="form-group"> 
  <label class="control-label" for="emailInput">Email</label> 
  <div> 
    <input type="text" id="emailInput" class="form-control" name="emailInput"
           [(ngModel)]="developer.email"> 
  </div> 
</div> 
我们可以将 [(ngModel)] 视为 AngularJS 中的 ng-model 指令的替代方案。我们将在 使用 Angular 进行双向数据绑定 部分中详细解释。
虽然 Angular 提供了一套预定义的验证器,但它们不足以满足我们数据可能存在的所有各种格式。有时,我们需要为特定于应用程序的数据自定义验证逻辑。例如,在这种情况下,我们想要定义一个电子邮件验证器。一个典型的正则表达式,在一般情况下有效(但并不涵盖定义电子邮件地址格式的整个规范),如下所示:/^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/。
在 ch6/ts/step-1/email_validator.ts 中,定义一个函数,该函数接受 Angular 控件实例作为参数,如果控件值是空的或与前面提到的正则表达式匹配,则返回 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 }; 
  } 
} 
现在,从 @angular/common 和 @angular/core 模块导入 NG_VALIDATORS 和 Directive,并将此验证函数包装在以下指令中:
@Directive({ 
  selector: '[email-input]', 
  providers: [{
    provide: NG_VALIDATORS,
    multi: true,
    useValue: validateEmail
  }]
}) 
class EmailValidator {} 
在前面的代码中,我们为NG_VALIDATORS令牌定义了一个多提供者。一旦我们注入与该令牌关联的值,我们将得到一个包含所有附加到给定控件(参考第五章中的多提供者部分,Angular 中的依赖注入)的验证器的数组。
为了使我们的自定义验证正常工作,只剩下两个步骤。首先,将email-input属性添加到电子邮件控件中:
<input type="text" id="emailInput" class="form-control" email-input
       [(ngModel)]="developer.email"> 
接下来,将指令添加到AppModule的声明中:
// ...
import {EmailValidator} from './email_validator';
// ...
@NgModule({
  // ...
  declarations: [..., EmailValidator],
  // ...
})
class AppModule {}
注意
我们正在使用一个外部模板来定义AddDeveloper控件。关于给定的模板是否应该外部化或内联,没有最终的答案。最佳实践表明,我们应该内联短模板,并将长模板外部化。然而,没有具体的定义来确定哪些模板被认为是短的,哪些被认为是长的。模板是否应该内联使用或放入外部文件取决于开发者的个人偏好或组织内的通用惯例。
使用 Angular 的 select 输入
作为下一步,我们应该允许应用程序的用户输入输入开发者最擅长的技术。我们可以定义一个技术列表,并在表单中以 select 输入的形式显示它们。
在AddDeveloper类中,添加technologies属性:
class AddDeveloper { 
  ... 
  technologies: string[] = [ 
    'JavaScript', 
    'C', 
    'C#', 
    'Clojure' 
  ]; 
  ... 
} 
现在,在模板中,在添加按钮的正上方,添加以下标记:
<div class="form-group"> 
  <label class="control-label" 
         for="technologyInput">Technology</label> 
  <div> 
    <select class="form-control" name="technology" required 
            [(ngModel)]="developer.technology"> 
        <option *ngFor="let technology of technologies" [value]="technology">
          {{technology}}
        </option> 
    </select> 
  </div> 
</div> 
就像我们之前声明的输入元素一样,Angular 将根据 select 输入的状态添加相同的类。为了在 select 元素值无效时显示红色边框,我们需要修改 CSS 规则:
@Component({ 
  ... 
  styles: [ 
    `input.ng-touched.ng-invalid, 
     select.ng-touched.ng-invalid { 
      border: 1px solid red; 
    }` 
  ], 
  ... 
}) 
class AddDeveloper {...} 
注意
注意,将所有样式内联到我们的组件声明中可能是一种不良做法,因为这样它们就不会是可重用的。我们可以做的是将我们组件之间的所有通用样式提取到单独的文件中。@Component装饰器有一个名为styleUrls的属性,其类型为string[],我们可以添加一个引用,指向给定组件使用的提取样式。这样,如果需要,我们只可以内联组件特定的样式。
在此之后,我们使用name="technology"将控件的名称声明为"technology"。使用required属性,我们声明应用程序的用户必须指定当前开发者熟练掌握的技术。现在让我们跳过[(ngModel)]属性,看看我们如何定义 select 元素的选项。
在 select 元素内部,我们使用以下方式定义不同的选项:
<option *ngFor="let technology of technologies" [value]="technology">
  {{technology}}
</option> 
这是一个我们已经很熟悉的语法。我们只是遍历AddDeveloper类中定义的所有技术,并为每个技术显示一个具有技术名称值的option元素。
使用 NgForm 指令
我们已经提到,表单指令通过添加一些额外的 Angular 特定逻辑来增强 HTML5 表单的行为。现在,让我们退一步,看看围绕输入元素的表单:
<form #f="ngForm" (ngSubmit)="addDeveloper()" 
      class="form col-md-4" [hidden]="submitted"> 
  ... 
</form> 
在前面的代码片段中,我们定义了一个新的标识符 f,它引用表单。我们可以将表单视为控件的一个组合;我们可以通过表单的 controls 属性访问单个控件。在此基础上,表单还具有 touched、untouched、pristine、dirty、invalid 和 valid 属性,这些属性取决于表单中定义的各个控件。例如,如果表单中的控件都没有被触摸过,那么表单本身将显示为未触摸状态。然而,如果表单中的任何控件至少被触摸过一次,表单将显示为已触摸。同样,只有当所有控件都有效时,表单才是有效的。
为了说明 form 元素的用法,让我们定义一个具有 control-errors 选择器的组件,该组件显示给定控件的当前错误。我们可以按以下方式使用它:
<label class="control-label" for="realNameInput">Real name</label> 
<div> 
  <input id="realNameInput" class="form-control" type="text" 
         [(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:这创建了一个控制错误与错误消息之间的映射。
现在,创建一个名为 control_errors.ts 的新文件,并在其中添加以下导入:
import {Component, Host, Input} from '@angular/core';
import {NgForm} from '@angular/forms';
在这些导入中,NgForm 代表 Angular 表单,而 Host 是与 DI 机制相关的参数装饰器,我们已经在第五章中介绍过,即 Angular 中的依赖注入。
下面是组件定义的一部分:
@Component({ 
  template: '<div>{{currentError}}</div>', 
  selector: 'control-errors',
}) 
class ControlErrors { 
  @Input() errors: Object; 
  @Input() control: string; 
  constructor(@Host() private formDir: NgForm) {} 
  get currentError() {...} 
} 
ControlErrors 组件定义了两个输入:control,控件的名称(name 属性的值)和 errors,错误标识符与错误消息之间的映射。它们可以通过 control 和 errors 属性分别指定。
例如,假设我们有以下输入:
<input type="text" name="foobar" required> 
我们可以使用以下标记来声明其关联的 control-errors 组件:
<control-errors control="foobar" 
      [errors]="{ 
       'required': 'The value of foobar is required' 
      }"></control-errors> 
在 currentError 访问器内部,在上面的 ControlErrors 类声明中,我们需要做以下两件事:
- 
找到使用 control属性声明的组件的引用。
- 
返回与任何使当前控件无效的错误相关的错误消息。 
下面是实现此行为的代码片段:
@Component(...) 
class ControlErrors { 
  ... 
  get currentError() {
    let control = this.formDir.controls[this.control];
    let errorMessages = [];
    if (control && control.touched) {
      errorMessages = Object.keys(this.errors)
        .map(k => control.hasError(k) ? this.errors[k] : null)
        .filter(error => !!error);
    }
    return errorMessages.pop();
  }
} 
在currentError实现的第 一行,我们使用注入表单的controls属性获取目标控件。controls属性的类型是{[key: string]: AbstractControl},其中key是我们使用name属性声明的控件的名称。一旦我们有了目标控件实例的引用,我们可以检查其状态是否被触摸(即是否被聚焦),如果是,我们可以遍历ControlErrors实例的errors属性中的所有错误。map函数将返回一个包含错误消息或null值的数组。我们唯一要做的就是过滤掉所有的null值,只获取错误消息。一旦我们获取了每个错误的错误消息,我们将通过从errorMessages数组中弹出最后一个来返回它。
最终结果应该如下所示:

图 5
提示
如果你在实现ControlErrors组件的过程中遇到任何问题,你可以查看其实现,位置在ch6/ts/step-2/control_errors.ts。
每个控件的方法hasError接受一个错误消息标识符作为参数,该标识符由相应的验证器定义。例如,在前面的例子中,我们定义了自定义电子邮件验证器,当输入控件有无效值时,我们返回{ 'invalidEmail': true }对象字面量。如果我们将ControlErrors组件应用到电子邮件控件上,其声明应该如下所示:
  <control-errors control="email" 
    [errors]="{
      'invalidEmail': 'Invalid email address'
    }"></control-errors> 
使用 Angular 实现双向数据绑定
关于 Angular 2 最著名的谣言之一是双向数据绑定功能被移除,因为强制执行单向数据流。这并不完全正确;Angular 的形式模块实现了一个选择器为[(ngModel)]的指令(我们也将这个指令称为NgModel,因为它控制器的名字),它允许我们轻松实现双向数据绑定:从视图到模型,以及从模型到视图。
让我们看看以下简单的组件:
// ch6/ts/simple-two-way-data-binding/app.ts 
import {Component, NgModule} from '@angular/core';
import {BrowserModule} from '@angular/platform-browser';
import {FormsModule} from '@angular/forms';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
@Component({
 selector: 'app',
 template: `
  <input type="text" [(ngModel)]="name">
  <div>{{name}}</div>
 `
})
class App {
  name: string;
}
@NgModule({
  imports: [BrowserModule, FormsModule],
  declarations: [App],
  bootstrap: [App]
})
class AppModule {}
platformBrowserDynamic().bootstrapModule(AppModule);
在前面的例子中,我们从@angular/common包中导入FormsModule。稍后,在模板中,我们将属性[(ngModel)]设置为name。
最初,[(ngModel)]语法可能看起来有点不寻常。从第四章《使用 Angular 组件和指令入门》中,我们知道(eventName)语法用于绑定到由给定组件触发的事件(或输出)。另一方面,我们使用[propertyName]="foobar"语法通过设置名为propertyName的属性(或,在 Angular 组件的术语中,输入)的值为表达式foobar的评估结果来实现单向数据绑定。[(ngModel)]语法结合了这两者,以实现双向数据绑定。这就是为什么我们可以将其视为一种语法糖,而不是一个新概念。与 AngularJS 相比,这种语法的最大优点之一是,我们只需查看模板就可以知道哪些绑定是单向的,哪些是双向的。
注意
[(foo)]语法的另一个名称是“盒子里的香蕉”或“香蕉括号”语法。这个术语的来源是 Erik Meijer、Maarten Fokkinga 和 Ross Paterson 合著的论文《Bananas, Lenses, Envelopes and Barbed Wire: Functional Programming》(eprints.eemcs.utwente.nl/7281/01/db-utwente-40501F46.pdf)。
就像(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" 
       [(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的方法,该方法在表单提交时被调用。我们通过绑定到ngSubmit事件来声明这一点:
<!-- 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"获取了表单的引用,并将按钮的禁用属性绑定到!f.form.valid表达式。我们已经在上一节中描述了NgForm控件;一旦表单内的所有控件都具有有效的值,其valid属性将具有值true。
现在,假设我们已经为表单中的所有输入控件输入了有效值。这意味着其提交按钮将被启用。一旦我们按下Enter或点击提交按钮,addDeveloper方法将被调用。以下是这个方法的示例实现:
class AddDeveloper { 
  //... 
  addDeveloper() {
    // We can't remove developers so setting the id this way is safe 
    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属性的值。紧接着,我们将submitted属性设置为true,这将导致隐藏表单。
列出所有存储的数据
现在我们可以在开发者集合中添加一个新条目,让我们在“Coders 仓库”的前页上显示所有开发者的列表。
打开文件ch6/ts/step-1/home.ts(或根据上一节中的进度,可能是 step-2),并输入以下内容:
import {Component} from '@angular/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="let dev of getDevelopers()"> 
    <td>{{dev.email}}</td> 
    <td>{{dev.realName}}</td> 
    <td>{{dev.technology}}</td> 
    <td [ngSwitch]="dev.popular"> 
      <span *ngSwitchCase="true">Yes</span> 
      <span *ngSwitchCase="false">Not yet</span> 
    </td> 
  </tr> 
</table> 
<div *ngIf="getDevelopers().length == 0"> 
  There are no any developers yet 
</div> 
我们将所有开发者列在 HTML 表格的行中。对于每个开发者,我们检查其popular标志的状态。如果其值为true,则在热门列中显示带有文本Yes的 span,否则将文本设置为No。
当你在添加开发者页面输入几个开发者然后导航到主页时,你应该看到以下截图类似的结果:

图 7
注意
您可以在ch6/ts/multi-page-template-driven找到应用程序的完整功能。
摘要
到目前为止,我们已经解释了 Angular 中路由的基础。我们探讨了如何定义不同的路由以及实现与它们关联的组件,这些组件在路由变化时显示。为了链接到不同的路由,我们引入了routerLink,我们还使用了router-outlet指令来指出与单个路由关联的组件应该在哪里渲染。
我们还关注了 Angular 表单功能,包括内置和自定义验证。在此之后,我们解释了NgModel指令,它为我们提供了双向数据绑定。
在下一章中,我们将介绍如何开发模型驱动表单、子路由和参数化路由,使用Http模块进行 RESTful 调用,以及使用自定义管道转换数据。
第七章. 解释管道和与 RESTful 服务通信
在上一章中,我们介绍了框架的一些非常强大的功能。然而,我们可以更深入地了解 Angular 表单模块和路由的功能。在接下来的几节中,我们将解释我们如何:
- 
开发模型驱动表单。 
- 
定义参数化路由。 
- 
定义子路由。 
- 
使用 HTTP 模块与 RESTful API 进行通信。 
- 
使用自定义管道转换数据。 
我们将在扩展“Coders 仓库”应用程序功能的过程中探索所有这些概念。在前一章的开始,我们提到我们将允许从 GitHub 导入开发者。然而,在我们实现此功能之前,让我们扩展表单的功能。
在 Angular 中开发模型驱动表单
这些将是完成“Coders 仓库”的最后几步。您可以在ch6/ts/step-1/(或根据您之前的工作ch6/ts/step-2)提供的代码基础上构建,以扩展应用程序的功能,我们将介绍的新概念。完整的示例位于ch7/ts/multi-page-model-driven。
这是我们本节结束时将要实现的结果:

图 1
在前面的屏幕截图中,有两种形式:
- 
一个包含以下控件以从 GitHub 导入现有用户的形式: - 
GitHub 处理器的输入。 
- 
一个复选框,指出我们是否想从 GitHub 导入开发者或手动输入。 
 
- 
- 
一个用于手动输入新用户的形式。 
第二个表单看起来与我们上章离开时完全一样。然而,这次,它的定义看起来略有不同:
<form class="form col-md-4" [formGroup]="addDevForm" [hidden]="submitted">
  <!-- TODO --> 
</form> 
注意,这次我们没有submit处理程序或#f="ngForm"属性。相反,我们将[formGroup]属性绑定到组件控制器中定义的addDevForm。使用此属性,我们可以绑定到称为FormGroup的东西。正如其名称所示,FormGroup类由一组与它们关联的验证规则一起组合的控件列表组成。
我们需要在用于导入开发者的表单中使用类似的声明。然而,这次,我们将提供不同的[formGroup]属性值,因为我们将在组件控制器中定义不同的表单组。将以下片段放置在我们之前引入的表单上方:
<form class="form col-md-4" [formGroup]="importDevForm" [hidden]="submitted">
<!-- TODO --> 
</form> 
现在,让我们在组件控制器中声明importDevForm和addDevForm属性:
import {FormGroup} from '@angular/forms';
@Component(...) 
export class AddDeveloper { 
  importDevForm: FormGroup; 
  addDevForm: FormGroup; 
  ... 
  constructor(private developers: DeveloperCollection, 
    fb: FormBuilder) {...} 
  addDeveloper() {...} 
} 
初始时,我们从@angular/forms模块导入FormGroup类,稍后,在控制器中声明所需的属性。请注意,我们有一个额外的AddDeveloper构造函数参数,称为fb,它是FormBuilder类型。
FormBuilder提供了一个可编程 API,用于定义FormGroup,我们可以将验证行为附加到组中的每个控件。让我们使用FormBuilder实例初始化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。
使用控件验证器的组合
我们查看了一下如何将单个验证器应用于表单控件。使用模型驱动方法,我们以与上一章中使用模板驱动表单并添加required属性相同的方式应用了Validators.required验证器。然而,在某些应用程序中,领域可能需要更复杂的验证逻辑。例如,如果我们想将required和validateEmail验证器都应用于电子邮件控件,我们应该做以下操作:
this.addDevForm = fb.group({ 
  ... 
  email: ['', Validators.compose([ 
    Validators.required, 
    validateEmail] 
  )], 
  ... 
}); 
Validators对象的compose方法接受一个验证器数组作为参数,并返回一个新的验证器。新验证器的行为将是作为参数传递的各个验证器中定义的逻辑的组合,并且它们将按照在数组中引入的顺序应用。
传递给FormBuilder的group方法的对象字面量中的属性名应与我们在模板中设置的输入的formControlName属性值相匹配。这是importDevForm的完整模板:
<form class="form col-md-4" [formGroup]="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" formControlName="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" formControlName="fetchFromGitHub">
  </div>
</form>
在前面的模板中,我们可以注意到,一旦 submitted 标志的值为 true,表单将隐藏给用户。在第一个输入元素旁边,我们将 formControlName 属性的值设置为 githubHandle。formControlName 属性将模板中现有的表单输入与在 FormGroup 中声明的输入关联起来,对应于包含 HTML 输入的表单元素。这意味着我们传递给 FormBuilder 的 group 方法的对象字面量中与控件定义关联的键必须与模板中设置 formControlName 的相应控件名称匹配。
现在,我们想要实现以下行为:
- 
当 从 GitHub 获取 复选框被勾选时,禁用输入新开发者的表单,并启用从 GitHub 导入开发者的表单。 
- 
当当前活动(或启用)的表单无效时,禁用提交按钮。 
我们将探讨如何使用 Angular 的响应式表单(也称为模型驱动表单)API 实现此功能。
在 AddDeveloper 类内部,添加以下方法定义:
...
export class AddDeveloper {
  //...
  ngOnInit() {
    this.toggleControls(this.importDevForm.controls['fetchFromGitHub'].value);
    this.subscription = this.importDevForm.controls['fetchFromGitHub']
      .valueChanges.subscribe(this.toggleControls.bind(this));
  }
  ngOnDestroy() {
    this.subscription.unsubscribe();
  }
  private toggleControls(importEnabled: boolean) {
    const addDevControls = this.addDevForm.controls;
    if (importEnabled) {
      this.importDevForm.controls['githubHandle'].enable();
      Object.keys(addDevControls).forEach((c: string) =>
        addDevControls[c].disable());
    } else {
      this.importDevForm.controls['githubHandle'].disable();
      Object.keys(addDevControls).forEach((c: string) =>
        addDevControls[c].enable());
    }
  }
}
...
注意,在 ngOnInit 中,我们通过获取 importDevForm 中 controls 的 fetchFromGitHub 属性来引用表示复选框的 AbstractControl。我们可以通过传递一个回调给其 subscribe 方法来订阅复选框的 valueChange 事件。每次复选框的值改变时,我们传递给 subscribe 的回调将被调用。
之后,我们通过传递一个回调给其 subscribe 方法来订阅复选框的 valueChange 事件。每次复选框的值改变时,我们传递给 subscribe 的回调将被调用。
之后,在 ngOnDestroy 中,我们取消订阅 valueChange 订阅,以防止我们的代码出现内存泄漏。
最后,最有趣的事情发生在 toggleControls 方法中。我们向这个方法传递一个标志,用来指示我们是否想要启用 importDevForm。如果我们想要启用这个表单,我们只需要调用 githubHandle 控件的 enable 方法,并禁用 addDevForm 中的所有控件。我们可以通过遍历控件名称(即 addDevForm 的 controls 属性的键)来禁用 addDevForm 中的所有控件,获取每个名称对应的控件实例,并调用其 disable 方法。如果 importEnabled 标志的值为 false,我们将执行完全相反的操作,通过调用 addDevForm 中的控件的 enable 方法和 importDevForm 中控件的 disable 方法。
探索 Angular 的 HTTP 模块
现在,在我们已经开发了两个表单——用于导入现有开发者和添加新开发者之后,是时候在组件的控制器中实现它们背后的逻辑了。
为了这个目的,我们需要与 GitHub API 进行通信。虽然我们可以直接从组件的控制器中这样做,但通过这种方式解决问题,我们将组件与 GitHub 的 RESTful API 相耦合。为了更好地分离关注点,我们可以将用于与 GitHub 通信的逻辑提取到一个单独的服务中,称为 GitHubGateway。打开名为 github_gateway.ts 的文件,并输入以下内容:
import {Injectable} from '@angular/core'; 
import {Http} from '@angular/http'; 
@Injectable() 
export class GitHubGateway { 
  constructor(private http: Http) {}
  getUser(username: string) { 
    return this.http 
      .get(`https://api.github.com/users/${username}`); 
  } 
} 
初始时,我们从 @angular/http 模块导入 Http 类。所有与 HTTP 相关的功能都被外部化,并且位于 Angular 的核心之外。由于 GitHubGateway 接受一个依赖项,该依赖项需要通过框架的 DI 机制进行注入,因此我们将它装饰为 @Injectable。
我们使用的 GitHub API 的唯一功能是用于获取用户的功能,因此我们定义了一个名为 getUser 的单个方法。它接受开发者的 GitHub 处理符作为参数。
注意
注意,如果你每天向 GitHub 的 API 发起超过 60 次请求,你可能会遇到错误 GitHub API Rate limit exceeded。这是由于没有 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): 这将向指定的 URL 发起一个 patch 请求。请求头和其他选项可以使用第二个参数进行配置。
- 
delete(url: string, options?: RequestOptionsArgs): 这将向指定的 URL 发起一个 delete 请求。请求头和其他选项可以使用第二个参数进行配置。
- 
head(url: string, options?: RequestOptionsArgs): 这将向指定的 URL 发送一个头部请求。请求头和其他选项可以使用第二个参数进行配置。
使用 Angular 的 HTTP 模块
现在,让我们实现从 GitHub 导入现有开发者的逻辑。首先,我们需要在我们的 AppModule 中导入 HttpModule:
import {HttpModule} from '@angular/http';
...
@NgModule({
  imports: [..., HttpModule],
  declarations: [...],
  providers: [...],
  bootstrap: [...]
})
class AppModule {}
...
然后,打开 ch6/ts/step-2/add_developer.ts 文件并输入以下导入:
import {Response} from '@angular/http'; 
import {GitHubGateway} from './github_gateway'; 
将 GitHubGateway 添加到 AddDeveloper 组件提供者列表中:
@Component({ 
  ... 
  providers: [GitHubGateway] 
}) 
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) 
在 GitHubGateway 实例的 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 字符串,那么我们将得到一个错误。
注意
注意,为了减少 Angular 的包大小,谷歌团队在框架中只包含了 RxJS 的核心。为了使用 map 和 catch 方法,你需要在 add_developer.ts 文件中添加以下导入:import 'rxjs/add/operator/map'; import 'rxjs/add/operator/catch';
现在,让我们实现传递给 subscribe 方法的回调函数体:
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; });
定义参数化视图
作为下一步,让我们为每个开发者分配一个专门的页面。在这个页面上,我们将能够详细查看他们的资料。一旦用户点击应用程序主页上的任何开发者的名字,他们应该被重定向到一个显示所选开发者详细资料的页面。最终结果将如下所示:

图 2
为了做到这一点,我们需要将开发者的标识符传递给显示开发者详细资料的组件。打开app.ts,并添加以下导入:
import {DeveloperDetails} from './developer_details'; 
我们还没有开发DeveloperDetails组件,所以如果你运行应用程序,你会得到一个错误。我们将在下一段定义该组件,但在那之前,让我们修改app.ts中路由的定义:
const routingModule = RouterModule.forRoot([
  ...
  {
    component: DeveloperDetails,
    path: 'dev-details/:id',
    children: devDetailsRoutes
  }
]);
我们添加了一个带有dev-details/:id路径的单个路由,并将其与DeveloperDetails组件关联。
注意,在path属性中,我们声明该路由有一个名为id的单个参数,并将children属性设置为devDetailsRoutes。devDetailsRoutes包含嵌套路由,这些路由应该在DeveloperDetails组件中的router-outlet内渲染。
现在,让我们将当前开发者的id作为参数传递给routerLink指令。在你的工作目录中打开home.html,并将显示开发者realName属性的表格单元格替换为以下内容:
<td> 
  <a [routerLink]="['/dev-details', dev.id, 'dev-basic-info']"> 
    {{dev.realName}} 
  </a> 
</td> 
routerLink指令的值是一个包含以下三个元素的数组:
- 
'/dev-details':显示根路由的字符串。
- 
dev.id:我们想要查看其详细信息的开发者的 ID。
- 
'dev-basic-info':显示嵌套路由中应该渲染哪个组件的路由路径。
定义嵌套路由
现在,让我们跳转到DeveloperDetails的定义。在你的工作目录中,创建一个名为developer_details.ts的文件,并输入以下内容:
import {Component} from '@angular/core';
import {ActivatedRoute} from '@angular/router';
import {Developer} from './developer';
import {DeveloperCollection} from './developer_collection';
import {DeveloperBasicInfo} from './developer_basic_info';
import {DeveloperAdvancedInfo} from './developer_advanced_info';
import 'rxjs/add/operator/take';
@Component({
  selector: 'dev-details',
  template: `...`,
})
export class DeveloperDetails {
  public dev: Developer;
  constructor(private route: ActivatedRoute,
    private developers: DeveloperCollection) {}
  ngOnInit() {
    this.route.params.take(1)
     .subscribe((params: any) => {
       this.dev = this.developers.getUserById(parseInt(params['id']));
    });
  }
}
export const devDetailsRoutes = [...];
小贴士
为了简化起见,为了避免在本书的示例中引入复杂的目录/文件结构,我们在单个文件中有几个组件和路由声明。请记住,根据最佳实践,单个声明应该放置在单独的文件中。有关更多信息,请访问angular.io/styleguide。
在前面的代码片段中,我们定义了一个名为DeveloperDetails的控制器组件。注意,在控制器的构造函数中,通过 Angular 的依赖注入机制,我们注入了一个与ActivatedRoute令牌关联的参数。注入的参数为我们提供了访问当前路由可见参数的能力。在ngOnInit中,我们采用命令式方法,订阅路由params属性值的变化,获取第一组参数,并将dev属性赋值为调用this.developers.getUserById的结果,该结果以所选开发者的标识符作为参数。
小贴士
注意,更声明性和响应式的方法是利用 RxJS 提供的高阶函数,这样我们就能通过类似以下代码的方式访问所选的开发者:
... get dev$() { return this.route.params.map((params: any) =>     this.developers.getUserById(parseInt(params['id']))); } ...
之后,我们可以使用 Angular 的异步管道绑定到调用结果,我们将在本章稍后解释。
由于我们从routeParams.params['id']获取的参数是一个字符串,我们需要将其解析为数字,以便获取与给定路由关联的开发者。
现在,让我们定义子路由,这些路由将在DeveloperDetails的模板中渲染:
export const devDetailsRoutes = [
  { path: '', redirectTo: 'dev-basic-info', pathMatch: 'full' },
  { component: DeveloperBasicInfo, path: 'dev-basic-info' },
  { component: DeveloperAdvancedInfo, path: 'dev-details-advanced' }
];
在前面的代码中,对我们来说没有新的内容。路由定义遵循我们已熟悉的完全相同的规则。
现在,让我们向组件的模板中添加与单个嵌套路由关联的链接:
@Component({
 selector: 'dev-details',
 template: `
   <section class="col-md-4">
   <ul class="nav nav-tabs">
     <li><a [routerLink]="['./dev-basic-info']">Basic profile</a></li>
     <li><a [routerLink]="['./dev-details-advanced']">Advanced details</a></li>
   </ul>
   <router-outlet></router-outlet>
   </section>
 `
})
export class DeveloperDetails {...}
在模板中,我们声明了两个相对于当前路径的链接。第一个链接指向dev-basic-info,这是在devDetailsRoutes中定义的第一个路由的路径,第二个链接指向dev-details-advanced。
由于与两个路由关联的组件实现相当相似,让我们只看看DeveloperBasicInfo。作为练习,你可以开发第二个组件或查看其实现,位置在ch7/ts/multi-page-model-driven/developer_advanced_info.ts:
import {Component, Inject, forwardRef, Host} from '@angular/core';
import {DeveloperDetails} from './developer_details';
import {Developer} from './developer';
@Component({
  selector: 'dev-details-basic',
  styles: [`
    .avatar {
      border-radius: 150px;
    }`
  ],
  template: `
    <h2>{{dev.githubHandle | uppercase}}</h2>
    <img *ngIf="dev.avatarUrl == null" class="avatar"
     src="img/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参数装饰器注入父组件。在@Inject内部,我们使用forwardRef,因为我们有developer_basic_info和developer_details包(在developer_basic_info中导入developer_details,在developer_details中导入developer_basic_info)之间的循环依赖。
我们需要一个指向父组件实例的引用,以便获取与所选路由对应的当前开发者的实例。
使用管道转换数据
现在是时候看看 Angular 为我们开发应用程序提供的最后一个构建块了——管道,这是我们尚未详细讨论的。
就像 AngularJS 中的过滤器一样,管道旨在封装所有的数据转换逻辑。让我们看看我们刚刚开发的应用程序的首页模板:
... 
<td [ngSwitch]="dev.popular"> 
  <span *ngSwitchCase="true">Yes</span> 
  <span *ngSwitchCase="false">Not yet</span> 
</td> 
... 
在前面的代码片段中,根据 popular 属性的值,我们使用 NgSwitch 和 NgSwitchCase 指令显示不同的数据。虽然这可行,但它是多余的。
开发无状态管道
让我们开发一个管道,将 popular 属性的值进行转换,并用它来代替 NgSwitch 和 NgSwitchCase。这个管道将接受三个参数:一个需要转换的值,一个当值为真时应该显示的字符串,以及一个在值为假时应该显示的字符串。
使用 Angular 自定义管道,我们可以将模板简化为以下形式:
<td>{{dev.popular | boolean: 'Yes': 'No'}}</td> 
我们甚至可以使用表情符号,如下所示:
<td>{{dev.popular | boolean: '': ''}}</td>
我们将管道应用于值的方式与在 AngularJS 中做的方式相同。传递给管道的参数应该由冒号(:)符号分隔。
为了开发 Angular 管道,我们需要以下导入:
import {Pipe, PipeTransform} from '@angular/core'; 
Pipe 装饰器可以用来为实现数据转换逻辑的类添加元数据。PipeTransform 是一个接口,它有一个名为 transform 的单方法:
import {Pipe, PipeTransform} from '@angular/core'; 
@Pipe({ name: 'boolean' })
export class BooleanPipe implements PipeTransform {
  transform(flag: boolean, trueValue: any, falseValue: any): string {
    return flag ? trueValue : falseValue;
  }
}
前面的代码片段是 BooleanPipe 的整个实现。我们传递给 @Pipe 装饰器的 name 决定了我们在模板中如何引用它。
在能够使用 BooleanPipe 之前,我们需要将其添加到 AppModule 的声明列表中:
@NgModule({
  ...
  declarations: [..., BooleanPipe, ...],
  ...
})
class AppModule {}
使用 Angular 内置管道
Angular 提供以下内置管道集:
- CurrencyPipe:这个管道用于格式化货币数据。它接受的参数是货币类型的缩写(即- "EUR"、- "USD"等)。它可以按以下方式使用:
      {{ currencyValue | currency: 'USD' }} <!-- USD42 --> 
- DatePipe:这个管道用于日期的转换。它可以按以下方式使用:
      {{ dateValue | date: 'shortTime'  }} <!-- 12:00 AM --> 
- DecimalPipe:这个管道用于十进制数字的转换。它接受的参数形式如下:- "{minIntegerDigits}.{minFractionDigits}-{maxFractionDigits}"。它可以按以下方式使用:
      {{ 42.1618 | number: '3.1-2' }} <!-- 042.16 --> 
- JsonPipe:这个管道将 JavaScript 对象转换为 JSON 字符串。它可以按以下方式使用:
      {{ { foo: 42 } | json }} <!-- { "foo": 42 } --> 
- LowerCasePipe:这个管道将字符串转换为小写。它可以按以下方式使用:
      {{ FOO | lowercase }} <!-- foo --> 
- UpperCasePipe:这个管道将字符串转换为大写。它可以按以下方式使用:
      {{ 'foo' | uppercase }} <!-- FOO --> 
- PercentPipe:这个管道将数字转换为百分比。它可以按以下方式使用:
      {{ 42 | percent: '2.1-2' }}  <!-- 4,200.0% --> 
- SlicePipe:这个管道返回一个数组的切片。它接受切片的起始和结束索引。它可以按以下方式使用:
      {{ [1, 2, 3] | slice: 1: 2 }} <!-- 2 --> 
- AsyncPipe:这是一个- stateful管道,它接受一个可观察对象或一个承诺;我们将在本章末尾探讨它。
开发有状态的管道
所提到的所有管道中有一个共同点——每次我们将它们应用于相同的值并传递相同的参数集时,它们都会返回完全相同的结果。这样的管道,具有引用透明性属性,被称为 纯管道。
@Pipe 装饰器接受一个 { name: string, pure?: boolean } 类型的对象字面量,其中 pure 属性的默认值是 true。这意味着,当我们定义任何给定的管道时,我们可以声明它是有状态的还是无状态的。纯属性很重要,因为如果管道是无状态的(即,在应用相同的值和相同的参数集时返回相同的结果),则可以优化变更检测。
现在,让我们构建一个有状态的管道。我们的管道将向 JSON API 发起 HTTP get 请求。为此,我们将使用 @angular/http 模块。
注意
注意,在管道中包含业务逻辑不被视为最佳实践。这种类型的逻辑应该提取到服务中。这里的例子仅用于学习目的。
在这种情况下,管道需要根据请求的状态(即,它是挂起还是完成)保持不同的状态。我们将以下方式使用管道:
{{ "http://example.com/user.json" | fetchJson | json }} 
这样,我们就在 URL 上应用了 fetchJson 管道。一旦我们有了响应体,我们就可以在它上面应用 json 管道。这个例子还展示了我们如何使用 Angular 连接管道。
与无状态管道类似,为了开发有状态的管道,我们必须用 @Pipe 装饰实现管道逻辑的类,并实现 PipeTransform 接口。这次,由于 HTTP 请求功能,我们还需要从 @angular/http 模块导入 Http 和 Response 类:
import {Pipe, PipeTransform} from '@angular/core'; 
import {Http, Response} from '@angular/http'; 
import 'rxjs/add/operator/toPromise'; 
每次当我们将 fetchJson 管道应用于具有不同值的参数时,我们都需要发起一个新的 HTTP get 请求。这意味着,作为管道的状态,我们需要至少保留远程服务的响应值和最后一个 URL:
@Pipe({ 
  name: 'fetchJson', 
  pure: false 
}) 
export class FetchJsonPipe implements PipeTransform { 
  private data: any; 
  private prevUrl: string = null; 
  constructor(private http: Http) {} 
  transform(url: string): any {...} 
} 
我们需要实现的唯一逻辑部分是 transform 方法:
... 
transform(url: string): any { 
  if (this.prevUrl !== url) { 
    this.http.get(url).toPromise(Promise) 
      .then((data: Response) => data.json()) 
      .then(result => this.data = result); 
    this.prevUrl = url; 
  } 
  return this.data || {}; 
} 
... 
在其中,我们首先比较作为参数传递的 URL 与我们已有的 URL(默认值为 null)。如果它们不同,我们将使用传递给 constructor 函数的 Http 类的本地实例发起一个新的 HTTP get 请求。一旦请求完成,我们将响应解析为 JSON 并将 data 属性设置为结果。
现在,让我们假设管道已经启动了一个 Http get 请求,并且在它完成之前,变更检测机制再次调用管道。在这种情况下,我们将比较 prevUrl 属性与 url 参数。如果它们相同,我们不会执行新的 http 请求,并将立即返回 data 属性的值。如果 prevUrl 的值与 url 不同,我们将启动一个新的请求。
使用有状态的管道
现在,让我们使用我们开发的管道。我们将要实现的程序提供了一个文本输入和一个带有标签 获取头像 的按钮给用户。一旦用户在文本输入中输入一个值并按下按钮,GitHub 用户的头像将出现在文本输入下方,如下面的截图所示:

图 3
现在,让我们开发一个示例组件,它将允许我们输入 GitHub 用户的昵称:
// ch7/ts/statful_pipe/app.ts 
@Component({ 
  selector: 'app',
  template: ` 
    <input type="text" #input> 
    <button (click)="setUsername(input.value)">Get Avatar</button> 
  ` 
}) 
class App { 
  username: string; 
  setUsername(user: string) { 
    this.username = user; 
  } 
} 
剩下的唯一事情就是显示用户的 GitHub 头像。我们可以通过修改前面组件的以下 img 声明来实现这一点:
<img width="160"
  [src]="(('https://api.github.com/users/' + username) | fetchJson).avatar_url"> 
初始时,我们将 GitHub 昵称追加到用于从 API 获取用户的基 URL。稍后,我们将对其应用 fetchJson 过滤器,并从返回的结果中获取 avatar_url 属性。
使用 Angular 的 AsyncPipe
Angular 的 AsyncPipe transform 方法接受一个可观察对象或一个承诺作为参数。一旦参数推送一个值(即承诺已解决或可观察对象的 subscribe 回调被调用),AsyncPipe 将将其作为结果返回。让我们看看以下示例:
// ch7/ts/async-pipe/app.ts 
@Component({ 
  selector: 'greeting', 
  template: 'Hello {{ greetingPromise | async }}' 
}) 
class Greeting { 
  greetingPromise = new Promise<string>(resolve => this.resolve = resolve); 
  resolve: Function;
  constructor() { 
    setTimeout(_ => { 
      this.resolve('Foobar!'); 
    }, 3000); 
  } 
} 
在这里,我们定义了一个 Angular 组件,它有两个属性,即 greetingPromise 类型为 Promise<string> 和 resolve 类型为 Function。我们使用一个新的 Promise<string> 实例初始化了 greetingPromise 属性,并将 resolve 属性的 resolve 回调设置为 promise 的值。
在类的构造函数中,我们开始一个 3,000 毫秒的定时器,并在其回调中解决承诺。一旦承诺被解决,表达式 {{ greetingPromise | async }} 的值将被评估为字符串 Foobar!。用户最终将在屏幕上看到的最终结果是文本 "Hello Foobar!"。
当我们将 async 管道与 Http 请求或与推送一系列值的可观察对象结合使用时,它非常强大。
使用 AsyncPipe 和可观察对象
我们已经从前面的章节中熟悉了可观察的概念。我们可以这样说,一个可观察的对象允许我们订阅一系列值的发射,例如:
let observer = Observable.create(observer => { 
  setInterval(() => { 
    observer.next(new Date().getTime()); 
  }, 1000); 
}); 
observer.subscribe(date => console.log(date)); 
一旦我们订阅了可观察对象,它将开始每秒发射一个值,这些值将被打印在控制台上。让我们将这个片段与组件定义结合起来,实现一个简单的计时器:
// ch7/ts/async-pipe/app.ts
@Component({  selector: 'timer' }) 
class Timer { 
  username: string; 
  timer: Observable<number>;
  constructor() { 
    let counter = 0; 
    this.timer = new Observable<number>(observer => { 
      setInterval(() => { 
        observer.next(new Date().getTime()); 
      }, 1000); 
    }); 
  } 
} 
为了能够使用计时器组件,我们只需添加其模板。我们可以在模板中使用async管道直接订阅可观察对象:
{{ timer | async | date: "medium" }} 
这样,我们每秒都会接收到由可观察对象发出的新值,而date管道将把它转换成可读的形式。
摘要
在本章中,我们深入研究了 Angular 的表单模块,通过开发一个模型驱动(响应式)表单并将其与 HTTP 模块结合使用。我们查看了一些基于组件的新路由器的先进功能,并了解了我们如何使用和开发自定义的有状态和无状态管道。
下一章将专门介绍我们如何通过利用模块 Universal 提供的服务器端渲染功能,使我们的 Angular 应用程序对搜索引擎优化(SEO)友好。我们还将探讨一些其他工具,如 angular-cli,这些工具可以让我们作为开发者的体验变得更好。最后,我们将解释在 Angular 的上下文中,什么是即时编译(Ahead-of-Time compilation),以及为什么我们应该在我们的应用程序中利用它。
第八章. 工具和开发经验
我们已经熟悉了 Angular 的所有核心概念。我们知道如何开发基于组件的用户界面,利用框架提供的所有构建块——指令、组件、依赖注入、管道、表单以及全新的路由器。
在最后一步,我们将探讨当我们想要从头开始构建单页应用程序(SPA)时应该从哪里开始。本章描述了以下内容:
- 
使用 Web Workers 来提高性能敏感型应用程序。 
- 
使用服务器端渲染构建 SEO 友好的应用程序。 
- 
尽快地启动项目。 
- 
提升我们的开发者体验。 
- 
什么是即时编译(AoT)以及如何使用它。 
那么,让我们开始吧!
在 Web Worker 中运行应用程序
当谈论前端 Web 开发中的性能时,我们可能指的是网络、计算或渲染性能。在本节中,我们将专注于渲染和计算性能,它们非常紧密相关。
首先,让我们将 Web 应用程序与视频文件以及浏览器与视频播放器之间的平行关系进行比较。在浏览器中运行的 Web 应用程序与在视频播放器中播放的视频文件之间最大的区别是,网页需要动态生成,而视频已经被录制、编码和分发。然而,在这两种情况下,应用程序的用户都会看到一系列的帧;核心区别在于这些帧是如何生成的。在视频处理的世界中,当我们播放视频时,我们已经记录了它;视频解码器的责任是根据压缩算法提取单个帧。相比之下,在 Web 上,JavaScript、HTML 和 CSS 负责生成随后由浏览器渲染引擎渲染的帧。
在浏览器环境中,我们可以将每一帧视为给定时刻网页的快照。不同的帧快速渲染,一个接一个;因此,从理论上讲,应用程序的最终用户应该看到它们平滑地结合在一起,就像在视频播放器中播放的视频一样。
在 Web 上,我们试图达到 60 fps(每秒帧数),这意味着每一帧大约有 16 毫秒的时间来在屏幕上进行计算和渲染。这个持续时间包括浏览器进行布局和页面渲染(浏览器的内部计算)所需的时间,以及我们的 JavaScript 执行所需的时间。
最后,我们的 JavaScript 执行时间少于 16 毫秒(由于浏览器的内部计算),如果它不能在这个时间段内完成,帧率将减半。由于 JavaScript 是一种单线程语言,所有的计算都需要在主 UI 线程中发生,这可能导致由于帧率下降而导致的非常差的用户体验。
HTML5 引入了一个名为Web Workers的 API,它允许客户端代码在多个线程中执行。为了简化,标准不允许各个线程之间共享内存,而是允许通过消息传递进行通信。Web Workers 和主 UI 线程之间交换的消息必须是字符串,这通常需要 JSON 字符串的序列化和反序列化。
由于各个工作者之间以及工作者和主 UI 线程之间缺乏共享内存,这带来了一些限制,以下是一些例子:
- 
禁止工作者线程访问 DOM。 
- 
全局变量不能在各个计算单元(即工作者线程和主 UI 线程以及反之亦然)之间共享。 
Web Workers 和 Angular
由于 Angular 的平台无关设计,核心团队决定利用这个 API;在 2015 年夏天,Google 将 Web Workers 支持嵌入到框架中。这个特性允许大多数 Angular 应用程序在单独的线程上运行,使得主 UI 线程只负责渲染。这比在单个线程中运行整个应用程序更容易实现 60fps 的目标。
Web Workers 的支持默认是禁用的。当启用它时,我们需要记住一些事情——在一个为 Web Worker 准备的应用程序中,组件不会在主 UI 线程中运行,这不允许我们直接操作 DOM。在这种情况下,我们需要使用 Angular 提供的更高层次的抽象 API 来建立数据绑定或操作元素的属性。
在 Web Worker 中启动应用程序
让我们在第四章中开发的待办事项应用程序开始使用 Angular 组件和指令在 Web Worker 中运行。您可以在ch8/ts/todo_webworkers/找到我们将要探索的示例。
注意
注意,Web Worker 模块尚未最终确定,因此其 API 可能在 Angular 的未来版本中发生变化。另一方面,其概念理念和架构已经足够成熟,所以很可能不会有任何根本性的差异。
首先,让我们讨论我们需要做出的更改。查看ch4/ts/inputs-outputs/app.ts。注意,在app.ts内部,我们包含了来自@angular/platform-browser-dynamic模块的platformBrowserDynamic函数。这是我们首先需要修改的内容。在后台进程中运行的应用程序的引导过程是不同的。
在重构我们的代码之前,让我们看一下一个说明典型 Angular 应用程序在 Web Workers 中运行时引导过程的图示:

图 1
该图分为两部分:UI和WebWorker。UI展示了在主 UI 线程初始化期间执行的操作;图示的WebWorker部分展示了应用程序如何在后台线程中进行引导。现在,让我们一步一步地解释引导过程。
首先,用户打开index.html页面,这会触发以下两个文件的下载:
- 
Angular 用于在 Web Worker 中运行的应用程序的 UI 包。 
- 
system.js包(我们在第三章中讨论了全局对象System,TypeScript 快速入门。我们可以将system.js包视为模块加载器的 polyfill)。
使用system.js,我们下载用于初始化在主 UI 线程运行的应用程序部分的脚本(bootstrap.js)。此脚本在 Web Worker 中启动loader.js。这是在后台线程中运行的第一个脚本。一旦启动了工作线程,loader.js将下载system.js和 Angular 的包,这些包旨在在后台线程中运行。第一次请求通常会命中缓存,因为system.js已经被主线程请求。使用模块加载器,我们下载负责引导后台应用程序的脚本background_bootstrap.js,它最终将在 Web Worker 中启动我们的应用程序功能。
从现在起,我们构建的整个应用程序将在 Web Worker 中运行,并与主 UI 线程交换消息以响应用户事件和渲染指令。
现在我们已经了解了使用工作线程时初始化期间的基本事件流,让我们重构我们的待办事项应用程序以利用它们。
将应用程序迁移到 Web Worker
让我们展示如何使应用程序与 Web Workers 兼容。这样,我们可以在计算密集型应用程序中减少帧率下降,因为我们将释放主 UI 线程,让它只负责渲染。
在index.html内部,我们需要添加以下脚本:
  <!-- ch8/ts/todo_webworkers/index.html --> 
  ... 
  <script src="img/system.src.js"> 
  </script> 
  <script src="img/Reflect.js"></script> 
  <script src="img/zone.js"> 
  </script> 
  <!- 
    Contains some basic SystemJS configuration in order to 
    allow us to load Angular 
  --> 
  <script src="img/config.js"></script> 
  <script> 
  System.import('./bootstrap.js') 
    .catch(function () { 
      console.log('Report this error to https://github.com/mgechev/getting-started-with-angular/issues', e); 
    }); 
  </script> 
  ... 
在前面的代码片段中,我们包含了 system.js、zone.js 和 reflect-metadata 的引用。zone.js 是 Angular 独家使用的 zones 的 polyfill,我们在本书中之前提到过。reflect-metadata 包含了 Metadata Reflection API 的另一个 polyfill,在撰写本文时,该 API 在浏览器中尚不可用。
对于下一步,我们将显式导入 bootstrap.js 文件,该文件包含用于在 Web Worker 中启动 loader.js 脚本的逻辑。
让我们探索 bootstrap.ts,这是编译后的 bootstrap.js 的原始 TypeScript 版本:
// ch8/ts/todo_webworkers/bootstrap.ts 
//main entry point
import {bootstrapWorkerUi} from '@angular/platform-webworker';
bootstrapWorkerUi('loader.js');
我们将 'loader.js' 传递给 bootstrapWorkerUi 的调用。这样,Angular 就知道 loader.js 将在后台线程中运行。该脚本位于应用程序的根目录。
现在,我们可以转向 在 Web Worker 中启动应用程序 部分所示的图表的右侧。loader.ts(loader.js 的原始 TypeScript 版本)中的逻辑非常简单:
// ch8/ts/todo_webworkers/loader.ts
importScripts('/node_modules/systemjs/dist/system.src.js', 
      '/node_modules/reflect-metadata/Reflect.js', 
      '/node_modules/zone.js/dist/zone.js', 
      './config.js'); 
System.import('./background_app.js') 
.then(() => console.log('The application has started successfully'), 
  error => console.error('error loading background', error)); 
作为第一步,我们导入 SystemJS、ReflectMetadata polyfills、zone.js 以及 SystemJS 的配置。由于此脚本已经在 Web Workers 中运行,我们拥有 importScripts 函数,它允许我们同步加载列出的文件。作为最后一步,使用 System,我们导入包含我们应用程序的脚本。
现在,让我们探索如何在 Web Worker 内部启动应用程序:
// ch8/ts/todo_webworkers/background_app.ts
import {platformWorkerAppDynamic} 
  from '@angular/platform-webworker-dynamic';
// Logic for the application... 
platformWorkerAppDynamic().bootstrapModule(AppModule)
  .catch(err => console.error(err));
前面的过程与我们过去在主 UI 线程中启动 Angular 应用程序时所做的操作非常相似。我们导入 platformWorkerAppDynamic 函数,并使用应用程序的根模块作为其第一个参数来调用它。
使应用程序与 Web Workers 兼容
正如我们所说,在 Web Worker 上下文中运行的代码无法访问 DOM。让我们看看我们需要做出哪些更改来解决这个问题。
这是 InputBox 组件的原始实现:
// ch4/ts/inputs-outputs/app.ts
@Component({ 
  selector: 'input-box', 
  template: ` 
    <input #todoInput [placeholder]="inputPlaceholder"> 
    <button (click)="emitText(todoInput.value); 
      todoInput.value = '';"> 
      {{buttonLabel}} 
    </button> 
  ` 
}) 
class InputBox { 
  @Input() inputPlaceholder: string; 
  @Input() buttonLabel: string; 
  @Output() inputText = new EventEmitter<string>();
  emitText(text: string) { 
    this.inputText.emit(text); 
  } 
} 
注意,在模板内部,我们通过标识符 todoInput 引用输入元素,并使用引用在表达式集中作为点击事件的处理器。由于我们在模板中直接访问 DOM 元素,这段代码将无法在 Web Worker 中运行。为了解决这个问题,我们需要重构代码片段,使其使用 Angular 数据绑定而不是直接触摸任何元素。我们可以使用输入来处理单方向绑定,或者使用 NgModel 来实现双向数据绑定,这会稍微消耗更多的计算资源。
让我们使用 NgModel:
// ch8/ts/todo_webworkers/background_app.ts 
import {NgModel} from '@angular/common'; 
@Component({ 
  selector: 'input-box', 
  template: ` 
    <input [placeholder]="inputPlaceholder" [(ngModel)]="input"> 
    <button (click)="emitText()"> 
      {{buttonLabel}} 
    </button> 
  ` 
}) 
class InputBox { 
  @Input() inputPlaceholder: string; 
  @Input() buttonLabel: string; 
  @Output() inputText = new EventEmitter<string>(); 
  input: string;
  emitText() { 
    this.inputText.emit(this.input); 
    this.input = ''; 
  } 
} 
在这个版本的 InputBox 组件中,我们在输入元素和 InputBox 组件的 input 属性之间创建了一个双向数据绑定。一旦用户点击按钮,emitText 方法将被调用,这将触发由 inputText EventEmitter 发射的新事件。为了重置输入元素的值,我们利用 Angular 的双向数据绑定机制,将 input 属性的值设置为空字符串;这将自动更新 UI。
注意
将整个逻辑从组件的模板移动到它们的控制器中,带来了许多好处,例如提高了可测试性、可维护性、代码重用性和清晰度。
上述代码与 Web Worker 环境兼容,因为 NgModel 指令基于一个不直接操作 DOM 的抽象。相反,它将这项责任委托给另一个名为 Renderer 的抽象,当在 Web Worker 中运行时,它与主 UI 线程异步交换消息。
回顾一下,我们可以这样说,在 Web Workers 的上下文中运行应用程序时,我们需要牢记以下两点:
- 
我们需要使用不同的引导过程。 
- 
我们不应该直接访问 DOM。 
违反第二点的典型场景如下:
- 
通过选择一个元素并直接使用浏览器的原生 API 或第三方库来操作它,改变页面的 DOM。 
- 
通过 ElementRef注入的本地元素访问。
- 
在模板中创建一个元素的引用,并将其作为参数传递给方法。 
- 
直接操作模板中引用的元素。 
在所有这些场景中,我们都需要使用 Angular 提供的高级 API。如果我们根据这种做法构建我们的应用程序,我们不仅能从中受益于能够在 Web Workers 中运行它们,还能在想要在不同平台间使用它们时,增加代码的重用性。
记住这一点并遵循最佳实践,也将使我们能够利用服务器端渲染的优势。
单页应用程序的初始加载
在本节中,我们将探讨服务器端渲染是什么,为什么我们需要在我们的应用程序中使用它,以及我们如何使用 Angular 来实现它。
为了我们的目的,我们将解释当用户打开一个使用 Angular 实现的单页应用程序时,典型的事件流。首先,我们将追踪服务器端渲染禁用时的事件,然后我们将看到如何通过启用此功能来从中受益。我们的示例将在 HTTP 1.1 的上下文中进行说明:

图 2
图 2 显示了浏览器在加载典型单页应用程序时的第一个请求以及相应的服务器响应。客户端最初将看到的是 HTML 页面的内容,没有任何渲染的组件。
假设我们将我们在 第四章 中构建的待办事项应用程序 使用 Angular 组件和指令入门 部署到一个具有 example.com 域名的 Web 服务器上。
当用户导航到 https://example.com/ 时,浏览器将打开一个新的 HTTP GET 请求,获取根资源(/)。当服务器收到请求时,它将响应一个 HTML 文件,在我们的例子中,它看起来可能像这样:
<!DOCTYPE html> 
<html lang="en"> 
<head> 
  <title>...</title> 
  <link rel="stylesheet" href="bootstrap.min.css"> 
</head> 
<body> 
  <app>Loading...</app> 
  <script src="img/es6-shim.min.js"></script> 
  <script src="img/Reflect.js"></script> 
  <script src="img/system.src.js"></script> 
  <script src="img/angular-polyfills.js"></script> 
  <script src="img/Rx.min.js"></script> 
  <script src="img/angular.js"></script> 
  <script src="img/router.js"></script> 
  <script src="img/http.min.js"></script> 
  <script>...</script> 
</body> 
</html> 
浏览器将接收这些内容作为响应的主体。当标记渲染到屏幕上时,用户将看到的只是 加载中... 标签。
在下一步中,浏览器将找到 HTML 文件外部资源中的所有引用,例如样式和脚本,并开始下载它们。在我们的例子中,其中一些是 bootstrap.css、es6-shim.min.js、Reflect.js、system.src.js 和 angular-polyfills.js。
一旦所有引用的资源都可用,用户仍然不会看到任何显著的视觉进度(除非下载的 CSS 文件中的样式应用到页面上)。这不会改变,直到 JavaScript 虚拟机处理完与应用程序实现相关的所有引用脚本。此时,Angular 将根据当前 URL 和配置知道需要渲染哪个组件。
如果与页面关联的组件定义在我们主应用程序包之外的一个单独的文件中,框架需要下载它以及它的整个依赖图。
如果我们使用即时编译(JiT compilation),在组件的模板和样式被外部化时,Angular 需要下载它们才能渲染请求的页面。在此之后,框架将能够编译与目标组件关联的模板并渲染页面。
在这种情况下,有两个主要的陷阱:
- 
在大型应用程序和/或网络连接不佳的情况下,用户体验将会很差。 
- 
搜索引擎在索引由 JavaScript 生成的动态内容方面并不擅长;这意味着我们的 SPA 的 SEO 将会受到影响。 
在过去,我们通过不同的解决方案解决了使用 AngularJS 构建的应用程序中的 SEO 问题,例如使用无头浏览器渲染请求的页面,将其缓存到磁盘上,然后提供给搜索引擎。然而,有一个更优雅的解决方案。
服务器端渲染的 SPA 的初始加载
几年前,像Rendr、Derby、Meteor等库引入了同构JavaScript 应用程序的概念,后来将其更名为通用。本质上,通用应用程序可以在客户端和服务器上运行。这种可移植性只有在 SPA 和浏览器 API 之间耦合度低的情况下才可能实现。这种范式最大的好处是应用程序可以在服务器上渲染并发送到客户端。
通用应用程序不是框架特定的;我们可以在任何可以在浏览器环境之外运行的环境中利用它们。从概念上讲,服务器端渲染的实践在平台和库之间非常相似;只有其实施细节可能不同。例如,实现服务器端渲染的 Angular Universal 模块支持 node.js 以及 ASP.NET。

图 3
图 3 展示了服务器对初始浏览器 GET 请求的响应。这次,与加载 SPA 的典型场景不同,浏览器将接收到渲染页面的 HTML。
让我们跟踪启用服务器端渲染功能的应用程序中的事件流。在这种情况下,一旦服务器接收到浏览器发出的 HTTP GET 请求,它将在 node.js 环境中运行 SPA。所有的 DOM 调用都将被重定向到服务器端 DOM 实现并在使用平台的上下文中执行。同样,所有使用 Angular HTTP 模块的 AJAX 调用都将由模块的服务器端实现处理。这样,应用程序在浏览器或服务器上下文中运行时不会有任何区别。
一旦 SPA 的渲染版本可用,它可以被序列化为 HTML 并发送到浏览器。这次,在应用程序初始化期间,用户将立即看到他们请求的页面,而不是加载中...标签。
注意,此时,客户端将拥有应用程序的渲染版本,但所有引用的外部资源,如脚本和样式,仍然需要可用。这意味着,最初,外部文件中声明的所有 CSS 样式都不会应用,并且应用程序不会响应用户相关的交互,如鼠标和键盘事件。
注意
注意,如果脚本在服务器端渲染的页面上内联,应用程序将响应用户事件。然而,通常认为内联大量 JavaScript 是一种不良做法,因为它会极大地增加页面大小并阻止脚本缓存;这两者都会影响网络性能。
当 JavaScript 虚拟机处理页面相关的 JavaScript 时,我们的 SPA 将准备好使用。
使用 Angular 进行服务器端渲染
在 2015 年的前半段,帕特里克·斯塔普尔顿和杰夫·惠普利宣布他们已经开始开发模块Universal。Universal 是一个库,允许我们使用 Angular 构建通用(也称为同构)JavaScript 应用程序;换句话说,它提供了服务器端渲染支持。
使用 Angular Universal 并在服务器上渲染的应用程序,在请求页面的所有 JavaScript 被处理之前,不会响应用户交互。这是我们之前提到的一个缺点,适用于所有服务器端渲染的应用程序。为了处理这个问题,帕特里克和杰夫引入了preboot.js,这是一个轻量级的库,它将被内联在服务器渲染的页面上,并在初始客户端请求之后可用。
Preboot.js 在应用程序完全初始化之前对收到的客户端事件有几种管理策略;它们如下:
- 
记录和回放事件。 
- 
立即对事件做出响应。 
- 
在页面重新渲染时保持专注。 
- 
缓存客户端重新渲染以实现更平滑的过渡。 
- 
如果用户点击按钮,则冻结页面直到引导程序完成。 
在撰写本书时,通用模块仍在积极开发中。您可以使用 Angular 通用启动器尝试它,链接为github.com/angular/universal-starter。
提升我们的开发体验
我们作为开发者的经验可以通过提高生产力或在工作项目上工作时让我们更有趣来增强。这可以通过我们每天使用的所有工具、IDE、文本编辑器等来实现。在本节中,我们将简要介绍一些流行的 IDE 和文本编辑器,它们利用 Angular 提供的可静态分析的语法。我们还将提到 Angular 团队开发的语言服务。
文本编辑器和 IDE
如我们在本书开头所说,核心团队在 Angular 中投入了大量精力来增强工具支持。首先,该框架是用 TypeScript 构建的,这自然允许我们在开发过程中使用静态类型。以下是一些具有出色 TypeScript 支持的文本编辑器和 IDE:
- 
IntelliJ Idea:由 JetBrains 提供的一个通用 IDE。 
- 
WebStorm:由 JetBrains 专门为 Web 开发定制的 IDE。 
- 
VSCode:由微软开发的一个基于 TypeScript 的跨平台文本编辑器。 
- 
Sublime Text:一个跨平台文本编辑器。 
- 
Atom:一个基于 Electron 的跨平台文本编辑器,用 JavaScript 编写。 
最近,JetBrains 宣布在 IntelliJ Idea 和 WebStorm 中提供高级 Angular 支持,支持组件和绑定的自动完成。
尽管在撰写本书时,并非所有提到的 IDE 和文本编辑器都具有 Angular 特定功能,但该框架旨在提供工具。它允许我们对应用程序的代码库进行高级静态代码分析,以便在不久的将来开发复杂的重构和生产率工具。
Angular 语言服务
利用 Angular 的可分析性,Google 为该框架开发了一个 语言服务。我们可以将此服务视为一个服务器,它索引我们的项目并提供基于客户端请求的自动完成建议、模板中的类型检查。此客户端可以是我们的文本编辑器或 IDE 的插件。
语言服务可以跟踪您文本编辑器中当前聚焦的组件的上下文,并提供上下文特定的建议。例如,它可以基于组件树中给定部分的可用指令集提供指令选择器的自动完成建议。
语言服务的最好之处在于它不与任何特定的文本编辑器或 IDE 绑定,这意味着,通过一个轻量级的插件,它可以在任何开发环境中重用。
使用 angular-cli 启动项目
在 2015 年的 AngularConnect 大会上,Angular 团队成员 Brad Green 和 Igor Minar 宣布了 angular-cli——一个 CLI(命令行界面)工具,旨在简化 Angular 应用程序的启动和管理。对于那些使用过 Ruby on Rails 的人来说,CLI 工具背后的理念可能很熟悉。该工具的基本目的是允许快速设置新项目和构建新的指令、组件、管道和服务。
在撰写本文时,该工具基于 webpack,处于开发初期阶段,因此我们只演示其基本用法。
使用 angular-cli
为了安装 CLI 工具,请在您的终端运行以下命令:
$ npm install -g angular-cli
在此之后,ng 命令将出现在您的系统中作为全局可执行命令。要创建一个新的 Angular 项目,请使用以下命令:
# May take a while, depending on your Internet connection 
$ ng new angular-cli-project 
$ cd angular-cli-project 
$ ng serve
以下命令将执行以下操作:
- 
创建一个新的 Angular 项目并安装其所有的 node.js 依赖。 
- 
进入您项目的目录。 
- 
启动一个开发 Web 服务器,这将允许您在 Web 浏览器中打开您刚刚创建的应用程序。 
为了进一步了解,请查看位于 github.com/angular/angular-cli 的项目仓库。
Angular 快速入门
如果您不想与 CLI 工具绑定,社区已经开发了大量的入门项目,这些项目可以为您的下一个 Angular 项目提供一个很好的起点。
Angular Seed
如果您喜欢静态类型,您可以尝试 angular-seed 项目。该项目托管在 GitHub 上,地址为 github.com/mgechev/angular-seed。
Angular Seed 提供以下关键特性:
- 
易于扩展、模块化和静态类型构建系统。 
- 
支持 AoT 编译。 
- 
支持在单个种子实例中运行多个具有共享代码库的 Angular 应用程序。 
- 
生产环境和开发环境的构建。 
- 
使用 Jasmine 和 Karma 的示例单元测试。 
- 
使用 Protractor 进行端到端测试。 
- 
带有 LiveReload 的开发服务器。 
- 
使用 codelyzer 进行静态代码分析,这将在一定程度上验证您的项目是否遵循 Angular 风格指南。 
- 
遵循应用程序和文件组织的最佳实践。 
- 
TypeScript 相关类型定义的管理器。 
- 
为开发和生产环境提供完整的 Docker 支持。 
书中提供的代码基于此种子项目。
对于angular-seed,您需要安装 node.js、npm 和 git,并运行以下命令列表:
$ git clone --depth 1 https://github.com/mgechev/angular-seed.git 
$ cd angular-seed 
$ npm install 
$ npm start
在运行上述命令之后,您的浏览器将自动打开种子项目的首页。当您的项目中的任何文件发生变化时,应用程序将自动重新构建,并且您的浏览器将被刷新。
默认情况下,生产构建会生成一个包含应用程序和所有引用库的压缩版本的单一包。Angular Seed 也支持 AoT 编译和可扩展的构建系统。
Angular 2 Webpack 启动器
如果您更喜欢 Webpack,可以使用angular2-webpack-starter。这是一个由AngularClass开发的启动项目,托管在 GitHub 上。您可以在github.com/AngularClass/angular2-webpack-starter找到它。
此启动器提供了以下功能:
- 
Angular 文件和应用程序组织的最佳实践。 
- 
使用 Webpack 构建系统即可快速上手,适用于 TypeScript。 
- 
使用 Jasmine 和 Karma 测试 Angular 代码。 
- 
使用 Istanbul 和 Karma 进行覆盖率分析。 
- 
使用 Protractor 进行端到端 Angular 代码测试。 
- 
使用 Typings 的类型管理器。 
为了尝试它,您需要安装 node.js、npm 和 git,并运行以下命令:
$ git clone --depth 1 https://github.com/angularclass/angular2-webpack-starter.git 
$ cd angular2-webpack-starter 
$ npm install 
$ npm start
Angular 中的 AoT 编译
在本节中,我们将简要解释在 Angular 的上下文中 AoT 编译是什么,以及它可能对我们项目产生的影响,而不深入技术细节。
在 Angular 中,其关键焦点是它极其快速的变更检测机制。在探索执行变更检测的不同选项后,Angular 团队发现,AngularJS 中使用的变更检测机制可以通过代码生成进行显著改进。结果是,执行变更检测和渲染的代码,并且在此基础上对 JavaScript 虚拟机进行了非常好的优化,其运行速度比传统的(也称为动态的)变更检测机制快得多。
代码生成的工作原理
Angular 代码生成器也被称为 Angular 编译器。它所做的就是将 Angular 组件的模板编译成 JavaScript 或 TypeScript(取决于使用情况)。当我们把模板编译成 TypeScript 时,我们允许 TypeScript 编译器执行类型检查,不仅在我们的组件、指令、服务和管道的命令式逻辑中,而且在组件的模板中!在模板中执行类型检查有助于我们在应用程序中找到更多潜在的问题。
除了为模板生成代码之外,Angular 编译器还会为我们的应用程序中的注入器生成代码。这进一步提高了依赖注入机制的效率。
基于对模板中绑定的静态分析,生成的模板代码执行最有效的变更检测和最优化更新 DOM 树的操作,这取决于变化值。除此之外,生成的代码还利用了 JavaScript 虚拟机的内联缓存机制,从而带来额外的性能提升。
注意
想要了解更多关于内联缓存的信息,请查看位于 mrale.ph/blog/2012/06/03/explaining-js-vms-in-js-inline-caches.html 的文章《在 JavaScript 中解释 JavaScript 虚拟机 - 内联缓存》。
代码生成可以是运行时执行,称为 即时编译(JiT)或构建时执行,称为 提前编译(AoT)。由于 JiT 编译涉及在运行时评估代码,因此建议在 eval 不可用的严格 CSP(内容安全策略)环境中使用 AoT。
介绍 AoT 编译
从 AoT(Ahead-of-Time)编译中,我们可以获得一些改进。首先,如果我们将 Angular 应用程序作为构建过程的一部分进行编译,我们就不需要在运行时进行编译。这有两个影响。首先,我们避免了使用 JiT(Just-in-Time)时遇到的运行时性能损失。这样,AoT 提供了更快的应用程序初始渲染速度,因为 Angular 在初始化期间需要做的工作更少。
此外,由于我们不再需要在运行时执行编译,我们可以将整个 @angular/compiler 模块从最终的应用程序包中删除,从而减小包的大小。
最后,我们可以通过 tree-shaking 来执行更有效的死代码消除。Tree-shaking 意味着删除未使用的导出,这是 ES2015 模块的静态性质的一个优点。当依赖于 JiT 编译时,我们可以在模板内部通过选择器引用不同的组件。然而,模板是类似 HTML 的格式,现代的压缩器(如 uglifyjs、Google Closure Compiler 等)无法理解。这意味着它们不能消除所有未使用的导出(例如,未使用的组件),因为它们不确定模板中确切使用了什么。
注意
如果你感兴趣,可以查看关于 Angular 的 AoT 编译的文章 Angular 中的即时编译 goo.gl/eXieJl。
AoT 编译的限制
请记住,Angular 编译器需要类型元数据才能执行编译过程。这意味着如果你不使用 TypeScript,你将无法执行 AoT 编译。
AoT 编译是在运行时没有收集任何数据的情况下进行的。这意味着在某些情况下,如果你的代码在 JiT 下可以工作,但在 AoT 下由于动态结构(这些结构在构建时无法解析)可能无法工作。
注意
对于更详细的信息,请查看这个存储库goo.gl/F7cV1s。
最后,模板生成的代码本身不是组件控制器的一部分。这意味着我们不能绑定到非公共字段,因为在编译过程中,TypeScript 会抛出一个错误。
如何使用 Angular 的 AoT 编译
Angular 的 AoT 编译已经在最受欢迎的启动器和 CLI 中得到支持。
在撰写本文时,Angular CLI 默认不执行 AoT 编译。你可以使用 --aot 标志来启用它:
$ ng build --prod --aot
当 Angular 2.0.0-rc.5 发布时,Angular Seed 引入了 AoT 编译。有了这个种子,你可以通过执行以下操作来利用它带来的所有好处:
$ npm run build.prod.aot
通过运行上述命令,你将获得一个优化良好的应用程序生产构建。
注意
由于网络工具频繁变化,我们没有深入探讨 Angular Seed 或 Angular CLI 如何生成生产构建的细节。如果你对底层发生的事情感兴趣,可以查看这篇文章goo.gl/kAiJUJ。
摘要
我们通过介绍 Angular 重写的背后原因开始了我们的旅程,随后是一个概念概述,它给了我们关于框架提供的构建块的一般想法。在下一步,我们进行了一次 TypeScript 速成课程,为我们准备第四章,Angular 组件和指令入门,在那里我们深入探讨了 Angular 的指令、组件和变更检测。
在第五章,Angular 中的依赖注入中,我们解释了依赖注入机制,并看到了它与组件层次结构的关系。在接下来的章节中,我们看到了如何构建表单和管道,并利用 Angular 的路由器。
通过完成本章,我们完成了对框架的探索之旅。在撰写本文时,Angular 核心的设计决策和背后的理念已经稳固并最终确定。尽管框架仍然非常新,但在过去的几个月里,其生态系统已经达到了一个水平,我们可以开发出生产就绪、高性能、SEO 友好的应用程序,并且在此基础上,通过静态类型和 IDE 支持拥有出色的开发体验。

 
                    
                     
                    
                 
                    
                
 
                
            
         
         浙公网安备 33010602011771号
浙公网安备 33010602011771号