Angular-切换指南第三版-全-
Angular 切换指南第三版(全)
原文:
zh.annas-archive.org/md5/77474cce19d591591d4c31d9b073c017译者:飞龙
前言
AngularJS 是一个 JavaScript 开发框架,它使得构建 Web 应用变得更加容易。如今,它被用于数百万个高流量 Web 应用中。尽管如此,AngularJS 在过去几年中很好地完成了其使命,但随着 Web 的发展,该框架在性能和可扩展性方面遇到了挑战。
2016 年,谷歌发布了Angular——一个用于构建可扩展、高性能应用的全新框架,灵感来源于 AngularJS。本书允许你与 Angular 专家Minko Gechev一起,将你的工作与 Angular 版本 5 及更高版本的稳定 API 保持一致。在《Switching to Angular》第三版中,我们将经历一段旅程,帮助我们利用谷歌对 Angular 的长期愿景来指导我们的未来发展。本书提供了详细的洞察和知识,帮助我们清晰地了解如何自信地切换到框架的稳定 API。
到本书结束时,你将能够自信地与谷歌对 Angular 5 及更高版本的愿景保持一致。但首先,你将学习如何使用 Angular 和 TypeScript 构建高性能、可扩展且 SEO 友好的企业级应用。
本书涵盖内容
第一章,转向单一 Angular,介绍了 Angular 是什么。你将了解到 Angular 和 AngularJS 是完全不同的框架。之后,我们将探讨框架结构的基础——拥有一个最小化和不可变的 API 核心,并在其之上构建不同的模块,以提供最佳的开发体验。最后,我们将了解 Angular 如何遵循语义化版本控制,这有助于我们理解为什么以及何时在 Angular 中应该期待不兼容的 API 更改。
第二章,开始使用 Angular,解释了为什么我们应该使用 JavaScript 语言的最新版本,为什么应该利用 Web Workers,以及 Angular 如何利用所有这些强大的技术。我们将观察前端开发的当前趋势和过去几年中吸取的教训。本章描述了为什么 AngularJS 的架构需要改变,以便开发 SEO 友好、高性能的单页应用。
第三章,Angular 应用的基本构建块,简要概述了 Angular 为开发单页应用提供的核心构建块。我们将指出与 AngularJS 核心概念的主要区别。
第四章,《TypeScript 快速入门》为您介绍了用于 Angular 实现的 TypeScript 语言。在探索这门语言的过程中,我们将探讨 ES2015 和 ES2016 的一些核心特性。我们将解释 ES2015 和 ES2016 的类、箭头函数、块作用域变量定义、解构和模块。由于 Angular 利用 ES2016 装饰器,以及 TypeScript 中的扩展,这里将专门讨论它们。在此之后,我们将探讨如何利用显式类型定义来利用静态类型。我们将描述 TypeScript 中的内置类型以及如何通过指定成员的访问修饰符来定义语言中的类。最后,我们将解释结构化类型是什么,以及 TypeScript 的类型系统如何利用它。
第五章,《Angular 组件和指令入门》带我们了解了 Angular 应用程序的核心构建块:指令和组件。我们将构建一些示例组件,以展示定义这些基本构建块所使用的语法。您将了解每个指令的生命周期以及给定指令和组件的核心功能集。在下一步中,我们将看到如何使用 Angular 的变更检测策略和不可变数据结构来提高我们应用程序的性能。
第六章,《Angular 中的依赖注入》涵盖了 Angular 的依赖注入(DI)机制。我们将通过在框架的上下文中介绍它,简要讨论在项目中使用 DI 的优点。我们旅程的第二步是如何使用 Angular 模块配置注入器;我们还将解释注入器层次结构和已注册提供者的可见性。为了强制实现更好的关注点分离,我们将探讨如何在指令和组件中注入携带应用程序业务逻辑的服务。
第七章,《使用 Angular 路由和表单》解释了 Angular 中路由的基础知识。我们将探讨如何定义不同的路由以及实现与它们相关联的组件,这些组件在路由变化时显示。之后,我们将解释如何开发模板驱动的表单,包括内置和自定义验证。最后,我们将解释如何在 Angular 中使用双向数据绑定。
第八章,解释管道和与 RESTful 服务通信,通过开发一个模型驱动的(响应式)表单,结合 HTTP 模块,帮助我们深入探索 Angular 的表单模块。我们将查看新组件化路由的一些高级功能,并了解我们如何使用和开发自定义的有状态和无状态管道。
第九章,工具和开发经验,更进一步,描述了如何使用 Web Workers 来处理性能敏感的应用程序。然后,我们将学习如何使用服务器端渲染构建 SEO 友好的应用程序。之后,我们将探讨如何尽可能快速地启动一个项目。最后,我们将学习即时编译(AOT)是什么以及如何使用它。
你需要这本书的什么内容
为了完成这本书中的大多数示例,你只需要一个文本编辑器或 IDE、Node.js、互联网接入和浏览器。
每一章都介绍了运行提供的代码片段所需的软件要求。
这本书面向谁
- 
有 AngularJS 经验的开发者,希望将他们的思维方式转移到现代 Angular 引入的概念 
- 
希望熟悉 Angular 的基础构建块和模块的软件工程师 
- 
对评估 Angular 变化并准备向 Angular 版本 5 及更高版本进行战略迁移感兴趣的 IT 专业人士 
习惯用法
在这本书中,你会发现许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“接下来的代码行读取链接并将其分配给BeautifulSoup函数。”代码块如下设置:
var result = [1, 2, 3].reduce(function (total, current) { 
  return total + current; 
}, 0);
当我们希望将您的注意力引向代码块的一个特定部分时,相关的行或项目将以粗体显示:
var result = [1, 2, 3].reduce(function (total, current) { 
  return total + current; 
}, 0);
任何命令行输入或输出都应如下编写:
$ tsc hello-world.ts
新术语和重要词汇以粗体显示。屏幕上显示的单词,例如在菜单或对话框中,在文本中显示如下:“当你添加一个新的待办事项到输入并点击添加按钮时,它不会立即出现在列表中。”
警告或重要注意事项如下所示。
技巧和窍门如下所示。
读者反馈
我们读者的反馈总是受欢迎的。请告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大价值的标题。要发送一般反馈,请简单地将电子邮件发送到feedback@packtpub.com,并在邮件主题中提及书的标题。如果您在某个主题领域有专业知识,并且有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大价值。
下载示例代码
您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。您可以通过以下步骤下载代码文件:
- 
使用您的电子邮件地址和密码登录或注册我们的网站。 
- 
将鼠标指针悬停在顶部的 SUPPORT 标签上。 
- 
点击代码下载 & 错误清单。 
- 
在搜索框中输入书的名称。 
- 
选择您想要下载代码文件的书籍。 
- 
从下拉菜单中选择您购买此书的来源。 
- 
点击代码下载。 
文件下载完成后,请确保您使用最新版本的以下软件解压或提取文件夹:
- 
WinRAR / 7-Zip for Windows 
- 
Zipeg / iZip / UnRarX for Mac 
- 
7-Zip / PeaZip for Linux 
本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Switching-to-Angular-Third-Edition。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
下载本书的彩色图像
我们还为您提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。彩色图像将帮助您更好地理解输出中的变化。您可以从www.packtpub.com/sites/default/files/downloads/SwitchingtoAngularThirdEdition_ColorImages.pdf下载此文件。
错误清单
尽管我们已经尽一切努力确保我们内容的准确性,错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者感到沮丧,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。
海盗行为
在互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在网上发现任何形式的我们作品的非法副本,请立即向我们提供位置地址或网站名称,以便我们可以寻求补救措施。请通过发送链接到疑似盗版材料的方式与我们联系copyright@packtpub.com。我们感谢您在保护我们的作者和我们为您提供有价值内容的能力方面的帮助。
问题
如果您对本书的任何方面有问题,您可以通过questions@packtpub.com联系我们,我们将尽力解决问题。
第一章:转向 One Angular
虽然这本书的内容可以在没有任何先前的 AngularJS 或 Angular 经验的情况下被正确消化,但如果你熟悉 AngularJS 的基础知识并且愿意 转向 Angular,你将获得这本书的大部分内容。
在互联网上,经常将 AngularJS 和 Angular 1 互换使用来指代 AngularJS 框架。这种误解让 AngularJS 专家对 Angular 版本感到困惑。在许多社区活动中,我经常收到类似的问题:
我刚刚学习了 Angular X,但听说 Angular X+1 将在 6 个月后推出。这意味着我需要学习一个新的框架吗?
在这里,你可以将 X 替换为大于 2 的任何数字。
对这个问题的简短回答是:当发布新主要版本时,你不需要学习一个新的框架。 事实上,Angular 2 和 Angular 5 之间的 API 废弃可以列在几行之内。
这种混淆主要是由错误地将 AngularJS 称为 Angular 1 所引起,这使得开发者认为每个新的 Angular 版本都将与旧版本有如 Angular 与 AngularJS 之间的不同。
在本章剩余部分和 第二章,开始使用 Angular 中,我们将解释 Angular 与 AngularJS 的不同之处以及为什么需要开发一个新的框架。
介绍 One Angular
Angular 是一个用于应用开发的框架。它的主要关注点是为我们应用的用户界面开发提供一个坚实的基础。
框架提供的核心原语之一是其强大的依赖注入机制,这使得我们能够轻松控制代码各个构建块之间的关系。Angular 的 显然快速 的变更检测提供了一种将应用视图与数据模型的当前状态同步的方法。对开发者完全透明,变更检测能够精确地知道模型何时发生变化,并执行最小化操作以在视图中反映更新。
能够扩展有效的 HTML,Angular 的模板通过自定义元素允许我们使用声明式的 领域特定语言(DSL)来表示我们应用用户界面的结构及其与模型的绑定。
Angular 吸收了所有这些想法以及许多其他想法,灵感来源于其前身 AngularJS。
从 AngularJS 到 Angular
AngularJS 的最初发布是在 2010 年 10 月 20 日。如今,这个框架被全球数百万个应用所使用。AngularJS 的流行程度非常高,以至于许多技术都受到了它的启发,甚至开始将其作为基础。
这样一个用于开发移动应用的平台是 Ionic。随着时间的推移,该框架不断进化,试图提供一个尽可能人体工程学化和简化的 API,同时仍然足够强大和表达力丰富,以便我们能够轻松地开发复杂的 Web 应用。API 在不同版本之间的变化很小,通常通过弃用过程引入。这样,我们:作为开发者:有足够的时间通过过渡过程并适应最新的变化。
然而,与此同时,Web 也在进化,出现了数十种新技术,其中一些直接影响了 AngularJS 本身,或者为框架提供了在性能或人体工程学方面实现大跳跃的机会。例如,通过 Web Worker 标准或甚至新的语言如 TypeScript 引入了这些新的 API。
这样,尽管 AngularJS 在 2010 年是应用开发的最佳技术,但由于 Web 的不断变化,它难以保持竞争力和完美无缺。这就是一个新框架想法的诞生,这个框架受 AngularJS 的启发,但具有更强大的 API 和更好的性能!由于与 AngularJS 的概念相似性,谷歌将其称为 Angular。
新的开始
谷歌团队,愿意利用 Angular 中最先进的技术,决定从 TypeScript 的坚实、静态类型基础开始。在此基础上,他们考虑了多种不同的方法来提高 Angular 的性能,以帮助开发者向我们的应用程序用户提供闪电般的体验。
鉴于从 AngularJS 中学到的关于浏览器 API 不断进化的经验教训,Angular 团队开发了一个以小型核心和大量围绕它的不同库为特点的框架,提供了额外的功能。这样,框架的基础 API 将能够尽可能保持不变,Angular 核心周围的整个基础设施将遵循语义版本控制的明确发布流程进行进化。您可以在以下图中看到围绕 Angular 核心开发的某些模块:

图 1
我们将在接下来的章节中描述一些这些模块。
在继续前进之前,让我们先概述一下语义版本控制实际上意味着什么。
Angular 和 SemVer
AngularJS 是从头开始重写的,并替换为其继任者 Angular 2。我们中的许多人对此大步前进感到困扰,因为它不允许我们在这两个框架版本之间实现平滑过渡。Angular 2 稳定后不久,谷歌宣布他们希望遵循所谓的语义版本控制规范(也称为SemVer)。
SemVer 定义了一个给定软件项目的版本为X.Y.Z的三元组,其中Z称为补丁版本,Y称为次要版本,X称为主版本。补丁版本的变化意味着在同一个项目的两个版本之间,没有影响公共 API 表面的有意破坏性变更,只有错误修复。当引入新功能且没有破坏性变更时,项目的次要版本将增加。最后,当 API 中引入不兼容的变更时,主版本将增加。
这意味着在 2.3.1 和 2.10.4 版本之间,没有引入破坏性变更,只有一些新增功能和错误修复。然而,如果我们有 2.10.4 版本,并且想要以向后不兼容的方式更改任何现有的公共 API(例如,更改方法接受的参数顺序),我们需要增加主版本号,并将补丁和次要版本号重置;因此我们将得到 3.0.0 版本。
Angular 团队还遵循一个严格、可预测的时间表。根据它,每周需要引入一个新的补丁版本;每个主要版本发布后,应有三个月度次要版本发布,最后,每 6 个月发布一个主要版本。这意味着到 2018 年底,将会有 Angular 7。然而,这并不意味着每 6 个月我们都要经历像 AngularJS 和 Angular 2 之间的相同迁移路径。并非每个主要版本都会引入影响我们项目的破坏性变更。例如,对 TypeScript 新版本的支持或方法最后一个可选参数的改变将被视为破坏性变更。我们可以将这些破坏性变更视为类似于 AngularJS 1.2 和 AngularJS 1.3 之间发生的情况。
由于本书中您将阅读的内容将主要适用于不同的 Angular 版本,我们将仅将框架称为 Angular。如果我们在某处明确提到一个版本,这并不意味着给定的段落对 Angular 4 或 Angular 5 无效;很可能有效。如果某个 API 仅适用于特定的 Angular 版本,这将明确指出。如果您想了解框架不同版本之间的变化,您可以查看变更日志github.com/angular/angular/blob/master/CHANGELOG.md。
现在我们已经介绍了 Angular 的语义版本和引用框架不同版本的约定,我们可以正式开始我们的旅程了!
摘要
在本章中,我们介绍了 Angular 是什么。我们解释了 Angular 和 AngularJS 是完全不同的框架。
之后,我们探讨了框架结构的基础:拥有一个核心,该核心具有最小化和不可变的 API,并在其之上构建不同的模块,以提供尽可能最佳的开发体验。
最后,我们解释了 Angular 如何遵循语义化版本控制,这有助于我们理解为什么以及何时我们应该期待在 Angular 中发生不兼容的 API 变更。
在下一章中,我们将关注 AngularJS 为何无法跟上网络的持续发展,Angular 如何利用新的、强大的浏览器 API,以及它是如何使大规模应用程序的开发变得更加容易的。
第二章:开始使用 Angular
2014 年 9 月 18 日,Google 将第一个公开提交推送到包含 Angular 的存储库。几周后,在 ng-europe 上,核心团队的 Igor 和 Tobias 给出了一个简短的概述,介绍了新框架预期的功能。当时的愿景远未最终确定;然而,有一点是肯定的:Angular 将与 AngularJS 完全不同的框架。
这项公告引发了许多疑问和争议。这些巨大变化的背后原因相当明确:AngularJS 已无法充分利用演进的 Web 技术以及大规模 JavaScript 应用程序的需求需要得到完全满足。一个新的框架将让 Angular 开发者以更简单、更高效、更富有成效的方式利用 Web 技术的发展。然而,人们仍然担忧。对于开发者来说,向后不兼容的最大噩梦之一就是将他们当前的代码库迁移到他们使用的第三方软件的新版本。在 Angular 的情况下,在那次首次公告之后,迁移看起来令人畏惧,甚至不可能。后来,在 2015 年的 ng-conf 和 ng-vegas 上,介绍了不同的迁移策略。Angular 社区团结起来,分享了额外的想法,预计新版本的框架将带来好处,同时保留从 AngularJS 中学到的经验。
本书是该项目的一部分。将升级到 Angular 现在变得顺利且值得。Angular 2 中巨大变化及其向后不兼容的主要驱动因素是 Web 的演变以及从野外的 AngularJS 使用中学到的经验教训。本书将帮助你通过让你了解我们是如何到达这里的以及为什么 Angular 的新功能对现代 Web 来说在构建高性能、可扩展的单页应用程序方面具有直观的意义来学习新框架。
本章我们将讨论的一些主题如下:
- 
使用 TypeScript 并了解它如何扩展 JavaScript 
- 
使用基于组件的架构构建 Angular 应用程序的用户界面 
- 
使用 Angular 的依赖注入机制并将业务逻辑委托给服务 
- 
深入探索 Angular 的路由器和表单模块 
- 
查看即时编译以构建闪电般快速的应用程序 
Web 的演变——是时候推出新框架了
在过去几年中,Web 技术经历了巨大的演变。在实施 ECMAScript 5 的过程中,ECMAScript 6 标准开始了其开发(现在称为 ECMAScript 2015 或 ES2015)。ES2015 在 JavaScript 中引入了许多变化,例如添加内置语言支持模块、块作用域变量定义以及许多语法糖,如类和结构赋值。
同时,Web 组件被发明了。Web 组件允许我们定义自定义 HTML 元素并将行为附加到它们上。由于很难通过添加新元素(如对话框、图表和网格)来扩展现有的 HTML 元素集,这主要是因为它们 API 的整合和标准化需要时间,因此一个更好的解决方案是允许开发者以他们想要的方式扩展现有元素。Web 组件为我们提供了许多好处,包括更好的封装、我们生成的标记的更好语义、更好的模块化以及开发者与设计师之间更易沟通。
如我们所知,JavaScript 是一种单线程语言。最初,它是为了简单的客户端脚本而开发的,但随时间推移,它的角色发生了很大变化。现在,随着 HTML5 的出现,我们有不同的 API 允许音频和视频处理、通过双向通信通道与外部服务通信、传输和处理大量原始数据等。所有这些在主线程中的重计算可能会创建一个糟糕的用户体验。它们可能会在执行耗时计算时导致用户界面冻结。这导致了Web Workers的发展,它允许在后台执行与主线程通过消息传递通信的脚本。这样,多线程编程就被带到了浏览器中。
其中一些 API 是在 AngularJS 开发开始之后引入的;这就是为什么框架没有考虑到大多数这些 API。利用这些 API 为开发者带来了许多好处,例如以下内容:
- 
显著的性能提升 
- 
开发具有更好质量特性的软件 
现在,让我们简要讨论这些技术是如何成为 Angular 核心的一部分以及为什么。
ECMAScript 的演变
现在,浏览器厂商正以短周期迭代的方式发布新功能,用户经常收到更新。这有助于开发者利用前沿的 Web 技术。ES2015 已经标准化。该语言最新版本的实现已经在主要浏览器中开始。学习新语法并利用它不仅能提高我们的开发效率,而且还能为不久的将来所有浏览器都将完全支持它做好准备。这使得现在开始使用最新语法变得至关重要。
一些项目的需求可能迫使我们支持旧版浏览器,这些浏览器不支持任何 ES2015 功能。在这种情况下,我们可以直接编写 ECMAScript 5,它具有不同的语法,但其语义与 ES2015 等效。另一方面,一个更好的方法将是利用转译过程。在我们的构建过程中使用转译器允许我们通过编写 ES2015 并将其转换为浏览器支持的目标语言来利用新语法。
Angular 自 2009 年以来一直存在。当时,大多数网站的前端由 ECMAScript 3 提供动力——ECMAScript 5 之前的最后一个主要版本。这自动意味着用于框架实现的编程语言是 ECMAScript 3。利用语言的新版本需要将 AngularJS 的全部内容移植到 ES2015。
从一开始,Angular 就通过引入框架中的最新语法来考虑网络的当前状态。Angular 是用 ES2016 的超集 TypeScript 编写的,我们将在第四章“TypeScript 快速入门”中对其进行探讨。类型注解使我们能够获得额外的性能提升,因为它们为称为即时编译(AOT)的过程打开了大门,该过程作为构建过程的一部分,为变更检测、渲染和依赖注入生成高效的代码。你可以在第九章“工具和开发体验”中找到更多关于 AOT 编译的信息。
网页组件
网页组件的第一个公开草案于 2012 年 5 月 22 日发布,距离 AngularJS 发布大约 3 年后。正如所述,网页组件标准允许我们创建自定义元素并将行为附加到它们上。这听起来很熟悉,因为我们已经在 AngularJS 开发用户界面时使用过类似的概念。网页组件听起来像是 Angular 指令的替代品;然而,它们具有更直观的 API 和内置浏览器支持。它们引入了一些其他好处,例如更好的封装,这在处理 CSS 样式冲突时非常重要。
在 AngularJS 中添加网页组件支持的可能策略是更改指令实现,并在 DOM 编译器中引入新标准的基本元素。作为 Angular 开发者,我们知道指令 API 是多么强大和复杂。它包括许多属性,如postLink、preLink、compile、restrict、scope和controller,当然还有我们最喜欢的transclude。一旦被批准为标准,网页组件将在浏览器中实现得更加底层,这带来了许多好处,如更好的性能和原生 API。
在实现 Web 组件的过程中,许多 Web 专家遇到了 AngularJS 团队在开发指令 API 时遇到的问题,并提出了类似的想法。Web 组件背后的良好设计决策包括内容元素,它解决了 AngularJS 中臭名昭著的转译问题。由于指令 API 和 Web 组件以不同的方式解决了类似的问题,因此在 Web 组件之上保留指令 API 将是冗余的,并增加了不必要的复杂性。这就是为什么 Angular 核心团队决定从头开始,构建一个与 Web 组件兼容的框架,并充分利用新的标准。Web 组件涉及新的功能;其中一些功能尚未被所有浏览器实现。如果我们的应用程序在不支持这些功能的浏览器中运行,Angular 将模拟它们。一个例子是使用ng-content指令填充的content元素。
Web Workers
JavaScript 以其事件循环而闻名。通常,JavaScript 程序在单个线程中执行,不同的事件通过被推入队列并按到达顺序依次处理来安排。然而,当计划中的某个事件需要大量的计算时间时,这种计算策略并不有效。在这种情况下,事件的处理将阻塞主线程,并且所有其他事件将不会处理,直到耗时的计算完成并将执行传递到队列中的下一个事件。一个简单的例子是鼠标点击触发的事件,其中回调使用 HTML5 音频 API 进行一些音频处理。如果处理的音频轨道很大,并且运行在其上的算法很重,这将通过冻结 UI 直到执行完成来影响用户体验。
Web Workers API 的引入是为了防止这样的陷阱。它允许在另一个线程的上下文中执行重计算,从而释放主线程的执行,使其能够处理用户输入和渲染用户界面。
我们如何在 Angular 中利用这一点呢?为了回答这个问题,让我们思考一下 AngularJS 中的工作方式。如果我们有一个企业应用程序,它处理大量需要通过数据绑定在屏幕上渲染的数据,会怎样呢?对于每个绑定,框架将创建一个新的观察者。一旦运行了消化循环,它将遍历所有观察者,执行与它们相关的表达式,并将返回的结果与上一次迭代中获得的结果进行比较。在这里我们有几个减速点:
- 
遍历大量观察者 
- 
在给定上下文中表达式的评估 
- 
返回结果的副本 
- 
表达式评估的当前结果与之前的结果之间的比较 
所有这些步骤可能会相当慢,具体取决于输入的大小。如果消化循环涉及到繁重的计算,为什么不将其移动到 Web Worker 中呢?为什么不在 Web Worker 中运行消化循环,获取已更改的绑定,然后将它们应用到 DOM 上?
社区进行了一些实验,旨在实现这一结果。然而,将它们集成到框架中并不简单。
导致不满意结果的主要原因之一是框架与 DOM 的耦合。通常,在观察者的回调中,AngularJS 直接操作 DOM,这使得无法将观察者移动到 Web Worker 中,因为 Web Worker 在一个隔离的上下文中执行,没有访问 DOM 的权限。在 AngularJS 中,我们可能在不同观察者之间存在隐式或显式的依赖关系,这需要多次迭代消化循环才能得到稳定的结果。结合最后两点,在其他执行线程(除了主线程)中计算变化是非常困难的。
在 AngularJS 中修复这个问题给内部实现引入了大量的复杂性。框架根本就没有考虑到这一点。由于 Web Worker 在 Angular 设计过程开始之前就已经引入,核心团队从一开始就考虑了它们。
从野外的 AngularJS 中学到的经验
虽然上一节列出了很多关于响应最新趋势所需重新实现框架的论点,但重要的是要记住,我们并不是从头开始。我们带着从 AngularJS 中学到的知识前进。自 2009 年以来,不仅仅是网络在发展。我们还开始构建越来越多地复杂的应用程序。如今,单页应用程序不再是某种异类,而是所有解决业务问题的网络应用程序的严格要求,它们追求高性能和良好的用户体验。
AngularJS 帮助我们高效地构建大规模的单页应用程序。然而,通过在各种用例中应用它,我们也发现了其中的一些陷阱。从社区的经验中学习,Angular 的核心团队致力于新的想法,旨在满足新的需求。
控制器
AngularJS 遵循 模型-视图-控制器(MVC)的微架构模式。有些人可能会认为它更像 模型-视图-视图模型(MVVM),因为视图模型作为属性附加到作用域或当前上下文中,在 控制器语法 的情况下。如果我们使用 模型-视图-表示者(MVP),它可能又有不同的处理方式。由于我们可以在应用程序中结构化逻辑的不同变体,核心团队将 AngularJS 称为 模型-视图-任何(MVW)框架。
在任何 AngularJS 应用程序中,视图应该是由指令组成的。这些指令协同工作,以提供功能齐全的用户界面。服务负责封装应用程序的业务逻辑。这就是我们在其中放置通过 HTTP 与 RESTful 服务通信、通过 WebSockets 进行实时通信以及甚至 WebRTC 的地方。服务是我们应该实现应用程序领域模型和业务规则的基本构建块。还有一个组件,它主要负责处理用户输入并将执行委托给服务:控制器。
尽管服务和指令有明确的角色,但我们经常可以看到反模式“大量视图控制器”,这在 iOS 应用程序中很常见。有时,开发者会诱使从他们的控制器直接访问或甚至操作 DOM。最初,这发生在你想要实现一些简单的事情时,比如改变元素的大小,或者快速而肮脏地改变元素样式。另一个值得注意的反模式是在控制器之间重复业务逻辑。通常,开发者倾向于复制和粘贴应该封装在服务内部的逻辑。
构建 AngularJS 应用程序的最佳实践指出,控制器不应操纵 DOM;相反,所有 DOM 访问和操作都应隔离在指令中。如果我们有一些在控制器之间的重复逻辑,我们很可能希望将其封装到服务中,并通过 Angular 的依赖注入机制将此服务注入到所有需要该功能的所有控制器中。
这是我们从 AngularJS 出发的。所有这些话,似乎控制器的功能可以被移动到指令的控制器中。由于指令支持依赖注入 API,在接收到用户输入后,我们可以直接将执行委托给已经注入的特定服务。这就是为什么 Angular 现在采用不同方法的主要原因,通过移除使用 ng-controller 指令将控制器放在任何地方的能力。我们将在第五章“使用 Angular 组件和指令入门”中查看如何从新的组件和指令中提取 AngularJS 控制器的职责。第五章。
作用域
AngularJS 中的数据绑定是通过使用 scope 对象实现的。我们可以将其属性附加到它上面,并在模板中明确声明我们想要绑定到这些属性(单向或双向)。尽管作用域的概念似乎很清晰,但它还有两个额外的职责,包括事件分派和与变更检测相关的行为。
Angular 初学者很难理解作用域(scope)究竟是什么以及应该如何使用。AngularJS 1.2 引入了一种称为控制器作为语法(controller as syntax)的概念。它允许我们在给定的控制器(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 提供了一系列好处,如更易于测试、更好的代码组织和模块化,以及简洁性。尽管 AngularJS 中的 DI 做得非常出色,但 Angular 更进一步。由于 Angular 基于最新的网络标准,它使用 ECMAScript 2016 装饰器的语法来注释使用 DI 的代码。装饰器与 Python 中的装饰器或 Java 中的注解非常相似。它们允许我们使用反射来装饰给定对象的行为,或向其添加元数据。由于装饰器尚未标准化且不被主流浏览器支持,它们的用法需要中间的转译步骤。
新的依赖注入(DI)机制更加灵活且功能丰富。它还修复了 AngularJS 的一些缺陷,例如不同的 API;在框架的第一版本中,一些对象是通过位置注入的(例如,在指令的链接函数中注入的 scope、element、attributes 和 controller),而其他对象则是通过名称注入的(在控制器、指令、服务和过滤器中使用参数名称)。
我们将在第六章,《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(模型-视图-控制器)一直是构建单页应用的首选。它通过将业务逻辑与视图隔离来分离关注点,使我们能够构建设计良好的应用。利用观察者模式,MVW 允许在视图中监听模型变化,并在检测到变化时更新它。然而,这些事件处理器之间存在一些显式和隐式的依赖关系,这使得我们应用中的数据流不明显且难以推理。在 AngularJS 中,我们允许不同观察者之间存在依赖关系,这要求消化循环迭代它们几次,直到表达式结果稳定。Angular 使数据流向一个方向;这带来了一系列好处:
- 
更明确的数据流 
- 
绑定之间没有依赖关系,因此没有存活时间(TTL)的消化 
- 
框架的性能更好 
- 
消化循环只运行一次 
- 
我们可以创建对不可变或可观察模型友好的应用,这使我们能够进行进一步的优化 
数据流的变化在 AngularJS 架构中引入了另一个根本性的变化。
当我们需要维护用 JavaScript 编写的庞大代码库时,我们可能对可扩展性问题有另一种看法。尽管 JavaScript 的鸭子类型使语言非常灵活,但它也使得 IDE 和文本编辑器的分析和支持变得更加困难。大型项目的重构变得非常困难且容易出错,因为在大多数情况下,静态分析和类型推断是不可能的。缺乏编译器使得错误拼写变得过于容易,直到我们运行测试套件或运行应用程序,这些错误才难以察觉。
Angular 核心团队决定使用 TypeScript,因为它提供了更好的工具和编译时类型检查,这有助于我们提高生产力并减少错误。如图所示,TypeScript 是 ECMAScript 的超集;它引入了显式类型注解和编译器:

图 1
TypeScript 语言被编译成普通 JavaScript,受到今天浏览器的支持。自 1.6 版本以来,TypeScript 实现了 ECMAScript 2016 装饰器,这使得它成为 Angular 的完美选择。
TypeScript 的使用允许 IDE 和文本编辑器提供更好的支持,包括静态代码分析和类型检查。所有这些通过减少我们犯的错误和简化重构过程,显著提高了我们的生产力。TypeScript 的另一个重要好处是通过静态类型,我们隐式地获得了性能提升,这允许 JavaScript 虚拟机进行运行时优化。
我们将在第四章“TypeScript 快速入门”中详细讨论 TypeScript。
模板
模板是 AngularJS 的关键特性之一。它们是简单的 HTML,并且不需要任何中间转换,与大多数模板引擎,如 mustache 不同。AngularJS 中的模板通过允许我们在其中创建内部 领域特定语言(DSL),结合了简单性和强大功能,这些内部元素和属性是自定义的。
这也是 Web 组件的主要目的之一。我们已经提到了 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 通过引入更丰富的语义的更多显式语法来采取了不同的方法:
<ng-template ngFor let-user [ngForOf]="users"> 
  {{user.name}} 
</ng-template> 
前面的代码片段明确定义了属性,该属性必须在当前迭代(user)的上下文中创建,以及我们迭代的(users)。
由于这种语法在键入时过于冗长,开发者可以使用以下语法,它后来会被转换成更冗长的形式:
<li *ngFor="let user of users"> 
  {{user.name}} 
</li> 
新模板的改进也将允许更好的工具支持,这些工具支持高级功能。我们将在 第五章 中讨论 Angular 的模板,开始使用 Angular 组件和指令。
变更检测
在“Web Workers”部分,我们已经提到了在作为 Web Worker 实例化的不同线程上下文中运行 digest 循环的机会。然而,AngularJS 中 digest 循环的实现并不那么内存高效,并阻止 JavaScript 虚拟机进行进一步的代码优化,这允许实现显著的性能提升。其中一种优化是内联缓存(mrale.ph/blog/2012/06/03/explaining-js-vms-in-js-inline-caches.html)。
Angular 团队进行了大量研究,以发现提高变更检测性能和效率的不同方法。这导致了全新的变更检测机制的诞生。
因此,Angular 在框架直接从组件模板生成的代码中执行变更检测;该代码由Angular 编译器生成。Angular 编译器是框架的一个模块,它可以处理 Angular 应用程序,然后在其之上执行不同的优化,最终生成比我们编写的源代码性能更好的代码。
有两种内置的代码生成(也称为编译)策略:
- 
即时编译(JIT):在运行时,Angular 生成在整个应用程序上执行变更检测的代码。生成的代码针对 JavaScript 虚拟机进行了优化,提供了巨大的性能提升。 
- 
预编译(AOT):这与 JIT 类似,不同之处在于代码是作为应用程序构建过程的一部分生成的。它可以用于通过不在浏览器中执行编译来加快渲染速度,也可以用于不允许 eval()的环境,例如具有严格内容安全策略(CSP)和 Chrome 扩展的环境。我们将在本书的下一节中进一步讨论。
我们将在第五章“使用 Angular 组件和指令入门”中查看新的变更检测机制以及如何配置它。
摘要
在本章中,我们考虑了 AngularJS 和 Angular 之间缺乏向后兼容性的主要原因。我们了解到,Angular 中实施的设计决策是由两件事推动的:Web 的发展以及前端开发的发展,以及从 AngularJS 应用程序开发中学到的经验教训。
在第一部分,你学习了为什么我们需要使用 JavaScript 语言的最新版本,为什么利用 Web 组件和 Web Workers,以及为什么在 AngularJS 中集成所有这些强大的工具不值得。
我们观察了前端开发的当前方向和过去几年中吸取的教训。我们描述了为什么控制器和作用域被从 Angular 中移除,以及为什么为了允许 SEO 友好的高性能单页应用进行服务器端渲染,AngularJS 的架构发生了变化。我们还关注了一个基本主题,即构建大规模应用,以及这是如何激励框架中的单向数据流以及选择静态类型语言 TypeScript 的。
在下一章中,我们将探讨 Angular 应用的主要构建块,它们如何被使用,以及它们之间的关系。Angular 重用了 AngularJS 引入的一些概念名称,但通常完全改变了我们的单页应用的构建块。我们将快速介绍模块、指令、组件、新的路由器、管道和服务,并描述它们如何结合用于构建优雅的单页应用。
下载示例代码
您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
第三章:Angular 应用程序的构建模块
在上一章中,我们探讨了 Angular 设计决策背后的驱动因素。我们描述了导致开发全新框架的主要理由;Angular 利用最新的网络标准,同时吸取过去的教训。尽管我们熟悉主要驱动因素,但我们还没有描述新框架的核心概念。Angular 与 AngularJS 走了不同的道路,并在用于开发单页应用程序的基本构建模块中引入了许多变化。
本章的使命是描述框架的核心,并对其主要概念进行简要介绍。在接下来的几页中,我们还将概述如何将这些概念组合起来,以帮助我们为网络应用程序构建专业的用户界面。随后的章节将概述我们将在本书的后续部分更详细学习的所有内容。
在本章中,我们将探讨以下主题:
- 
框架的概念概述,展示了不同概念之间的相互关系。 
- 
将用户界面构建为组件的组合。 
- 
Angular 中指令所走的路径,以及与 AngularJS 相比其接口的变化。 
- 
导致指令分解为两个不同概念并强制分离关注点的理由。为了更好地理解它们,我们将演示它们定义的基本语法。 
- 
改进后的变更检测概述,以及它如何涉及指令提供的上下文。 
- 
区域(zones)是什么,以及它们如何使我们的日常开发过程变得更简单。 
- 
管道(pipes)是什么,以及它们与 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 与我们在 第四章,“TypeScript 快速入门”中描述的 ES2015 模块不同。Angular 模块是框架功能,而 ES2015 模块是语言结构。
NgModules 承担以下职责:
- 
提供 Angular 模板编译器的上下文 
- 
提供一种封装级别,其中我们可以拥有仅用于给定模块边界内的组件或指令 
- 
在 NgModules 中,我们可以配置框架的 DI 机制的提供者: 

图 4
最后,我们使用新的路由器来定义应用中的路由。由于指令不拥有模板,因此只有组件可以被路由器渲染,代表应用中的不同视图。路由器还使用一组预定义的指令,这允许我们在不同的视图和它们应该渲染的容器之间定义超链接。
现在,我们将更仔细地研究这些概念,看看它们是如何一起工作以创建 Angular 应用程序,以及它们是如何从它们的 AngularJS 前身中变化的。
更改指令
AngularJS 在开发单页应用程序时引入了指令的概念。指令的目的是封装与 DOM 相关的逻辑,并允许我们通过组合它们来构建用户界面。这样,我们能够扩展 HTML 的语法和语义。最初,像大多数创新概念一样,指令受到了争议性的看法,因为它们使我们倾向于在不使用data-前缀的情况下编写无效的 HTML,当使用自定义元素或属性时。然而,随着时间的推移,这个概念逐渐被接受,并证明它将长期存在。
AngularJS 中指令实现的另一个缺点是我们可以使用它们的不同方式。这需要理解属性值,这些值可以是字面量、表达式、回调或微语法。这使得工具基本上变得不可能。
Angular 保留了指令的概念,但从中吸取了 AngularJS 的精华,并添加了一些新的想法和语法。Angular 指令的主要目的是通过扩展 ES2015 类中定义的自定义逻辑来将行为附加到 DOM 上。我们可以将这些类视为与指令关联的控制器,并将它们的构造函数视为与 AngularJS 指令的链接函数类似。然而,新的指令具有有限的配置性。它们不允许将模板与它们关联,这使得大多数已知用于定义指令的属性变得不必要。API 的简单性并不限制指令的行为,但只强制执行更严格的关注点分离。为了补充这个更简单的 API,Angular 引入了一个更丰富的界面来定义 UI 元素,称为组件。组件通过允许它们拥有模板(通过组件元数据)来扩展指令的功能。我们将在本书的后面进一步探讨组件。
用于 Angular 指令的语法涉及 ES2016 装饰器;请记住,TypeScript 是 ES2016 的超集,因此它也有装饰器。以下是一个定义简单指令的代码片段:
@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方法。
由于我们可以在单个元素上使用多个指令,最佳实践建议我们应该使用属性作为选择器。
我们可以总结说,Angular 通过保持将行为附加到 DOM 的想法来保留了指令的概念。与 AngularJS 的核心区别在于新的语法,以及通过引入组件带来的关注点分离的进一步深化。在第五章,开始使用 Angular 组件和指令中,我们将进一步探讨指令 API。现在,让我们看看 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实例在components列表中持有对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 应用程序启动,它将查看 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和提供者(我们将在第六章,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装饰器,我们声明了AppModule,并且还导入了BrowserModule。请注意,这次,我们向bootstrap属性提供了一个值,其中我们明确声明我们希望使用AppComponent来引导我们的应用程序。
在文件的最后一行,我们调用platformBrowserDynamic调用的返回对象上的bootstrapModule方法,并传递AppModule参数。
总结来说,Angular 中的模块扮演着重要的角色:它们不仅逻辑上组织了我们的应用程序的构建块,还提供了一种实现封装的方法。最后但同样重要的是,NgModules 在应用程序的引导过程中被大量使用。
管道
在业务应用程序中,我们经常需要具有相同数据的不同视觉表示。例如,如果我们有一个数字 100,000,我们希望将其格式化为货币,我们很可能不想以纯数据的形式显示它;更有可能的是,我们希望显示为$100,000。
在 AngularJS 中,格式化数据的责任被分配给了过滤器。另一个数据格式化需求示例是当我们使用项目集合时。例如,如果我们有一个项目列表,我们可能希望根据谓词(一个布尔函数)来过滤它;在一个数字列表中,我们可能只想显示素数。AngularJS 有一个名为filter的过滤器,允许我们这样做。然而,名称的重复经常导致混淆。这也是核心团队将过滤器组件重命名为pipe的原因之一。此外,单词filter使我们倾向于认为过滤器只负责过滤数据,而它们实际上有更通用的格式化数据的责任。
新名称背后的动机是管道和过滤器使用的语法:
{{expression | decimal | currency}} 
在前面的示例中,我们将decimal和currency管道应用于expression返回的值。大括号之间的整个表达式看起来像 Unix 管道语法。
定义管道
定义管道的语法与用于定义模块、指令和组件的语法类似。为了创建一个新的管道,我们可以使用 ES2015 装饰器@Pipe。它允许我们向类添加元数据,将其声明为管道。我们所需做的就是为管道提供一个名称并定义数据格式化逻辑。
在运行时,一旦 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(); 
  } 
} 
使用 TypeScript 语法,我们实现PipeTransform接口并定义其中声明的transform方法。我们将在下一章解释 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);
我们可以使用以下标记使用App组件:
<app></app> 
我们将在屏幕上看到的sample文本是在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 的标签。然而,user 的变化不会在视图中反映出来。为了在用户 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 开发者对幕后实际发生的事情有更好的理解。在前面的例子中,在ng-model和ng-bind指令(在我们的案例中,是插值指令{{}})内部,Angular 添加了具有与同一表达式相关联的不同行为的 watchers:label。这些 watchers 与经典 MVC 模式中的观察者相当相似。在特定事件(在我们的案例中,是文本输入内容的变化)上,AngularJS 将遍历所有这样的 watchers,在给定作用域的上下文中评估与它们相关联的表达式,并存储它们的结果。这个循环被称为digest 循环。
在前面的例子中,在作用域的上下文中评估label表达式将返回文本,Hello world!。在每次迭代中,AngularJS 将比较评估的当前结果与之前的结果,并在值不同的情况下调用相关的回调。例如,由插值指令添加的回调将设置元素的 内容为新表达式评估的结果。这是两个指令的 watchers 回调之间依赖关系的一个例子。由ng-model添加的 watcher 的回调修改了与插值指令添加的 watcher 相关联的表达式的结果。
这种方法有其自身的缺点。我们说 digest 循环将在某些特定事件上被调用,但如果我们使用setTimeout,并在回调中(作为第一个参数传递),改变我们正在监视的作用域附加的属性,这些事件会发生在框架之外;例如,会发生什么?AngularJS 将不会意识到这种变化,并且不会调用 digest 循环,因此我们需要显式使用$scope.$apply来做这件事。但是,如果框架知道浏览器中发生的所有异步事件,例如用户事件、XMLHttpRequest事件和与WebSocket相关的事件,会怎样呢?在这种情况下,Angular 将能够拦截事件处理并调用 digest 循环,而无需强迫我们这样做!
在 zone.js 中
这正是 Angular 的情况。这个功能是通过使用zone.js中的 zones 实现的。
在 2014 年的 ng-conf 会议上,布赖恩·福特(Brian Ford)做了一场关于 zones 的演讲。布赖恩将 zones 描述为浏览器 API 的元猴子补丁(meta-monkey patching)。zone.js是由 Angular 团队开发的一个库,它在 JavaScript 中实现了 zones。zones 代表一个执行上下文,它允许我们拦截异步浏览器调用。基本上,使用 zones,我们能够在给定的XMLHttpRequest对象完成或当我们接收到新的WebSocket事件时,调用一段逻辑。Angular 通过拦截异步浏览器事件并在正确的时间调用 digest 循环来利用zone.js。这完全消除了使用 Angular 的开发者显式调用 digest 循环的需求。
简化的数据流
跨观察者依赖关系可能会在我们的应用程序中创建复杂的数据流,难以追踪。这可能导致不可预测的行为和错误,难以找到。尽管 Angular 保留了脏检查作为实现更改检测的方式,但框架强制执行单向数据流。这是通过禁止不同观察者之间的依赖关系实现的,这允许消化循环只运行一次。这种策略显著提高了我们应用程序的性能,并降低了数据流的复杂性。Angular 还对内存效率和消化循环的性能进行了改进。有关 Angular 的更改检测及其实现的不同策略的更多详细信息,请参阅第五章,《开始使用 Angular 组件和指令》。
提升 AngularJS 的更改检测
现在,让我们退一步,再次思考框架的更改检测机制。
我们说在消化循环中,Angular 评估注册的表达式,并将评估的值与循环前一次迭代中相同表达式的值进行比较。
用于比较的最优算法可能因表达式评估返回的值类型而异。例如,如果我们得到一个可变列表项,我们需要遍历整个集合,逐个比较集合中的项,以验证是否有变化。然而,如果我们有一个不可变列表,我们只需通过比较引用即可执行检查,其复杂度保持不变。这是因为不可变数据结构的实例不能改变:而不是修改实例,我们将获得一个应用了修改的新引用。
在 AngularJS 中,我们可以使用几种方法添加观察者。其中两种是$watch(exp, fn, deep)和$watchCollection(exp, fn)。这些方法让我们在一定程度上控制更改检测将如何执行等性检查。例如,使用$watch添加观察者并将false值作为第三个参数传递将使 AngularJS 执行引用检查(即使用===比较当前值和前一个值)。然而,如果我们传递一个真值(任何true值),检查将是深层次的(即使用angular.equals)。这样,根据表达式返回的预期类型,我们可以以最合适的方式添加监听器,以便框架使用最优化算法执行等性检查。此 API 有两个限制:
- 
它不允许你在运行时选择最合适的等性检查算法 
- 
它不允许你将更改检测扩展到第三方特定的数据结构 
Angular 核心团队将这一责任分配给了比对器,允许它们根据我们在应用程序中使用的数据进行扩展,优化变更检测机制。Angular 定义了两个基类,我们可以扩展它们来定义自定义算法:
- 
KeyValueDiffer:这允许我们对基于键值的数据结构进行高级的比对操作
- 
IterableDiffer:这允许我们对类似列表的数据结构进行高级的比对操作
Angular 允许我们通过扩展自定义算法或适当配置来完全控制变更检测机制,这在 AngularJS 中是不可能的。我们将在第五章,开始使用 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 初学者来说相当混乱。让我们思考一下,是什么促使引入这些方法来注册服务。为什么我们不能简单地使用 JavaScript 构造函数、对象字面量或 ES2015 类,Angular 将不会意识到这些?我们可以像这样将我们的业务逻辑封装在一个自定义 JavaScript 构造函数中:
function UserTransactions(id) { 
  this.userId = id; 
}
UserTransactions.prototype.makeTransaction = function (amount) { 
  // method logic 
}; 
module.controller('MainCtrl', function () { 
  this.submitClick = function () { 
    new UserTransactions(this.userId).makeTransaction(this.amount); 
  }; 
}); 
这段代码是完全有效的。然而,它没有利用 AngularJS 提供的关键特性之一:依赖注入(DI)机制。《MainCtrl》函数使用了可见于其体内的 UserTransaction 构造函数。前面的代码有两个主要缺陷:
- 
我们与服务实例化所使用的逻辑耦合在一起。 
- 
代码不可测试。为了模拟 UserTransactions,我们需要对其进行猴子补丁。
AngularJS 如何处理这两件事呢?当需要某个服务时,通过框架的依赖注入(DI)机制,AngularJS 解决其所有依赖关系,并通过传递给一个 factory 函数来实例化它,该函数封装了其创建的逻辑。factory 函数作为 factory 和 service 方法的第二个参数传递。provider 方法允许在较低级别上定义服务;对于 factory 方法,有一个位于 $get 属性下的方法。
就像 AngularJS 一样,Angular 的新版本也容忍这种关注点的分离,因此核心团队保留了服务。与 AngularJS 相比,Angular 通过允许我们使用纯 ES2015 类来定义它们的接口,提供了一个更简单的接口。我们无法回避这样一个事实,即我们需要明确声明应可注入的服务,并指定它们的实例化指令。与 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; 
      }); 
  } 
} 
服务与前面章节中描述的组件和指令相关。为了开发高度一致和可重用的 UI 组件,我们需要将所有与业务相关的逻辑移动到我们的服务内部。此外,为了开发可测试的组件,我们需要利用 DI 机制来解决它们的所有依赖。
AngularJS 和 Angular 中服务之间的另一个关键区别是框架内部表示它们的方式。AngularJS 使用字符串来标识不同的服务及其用于实例化的相关工厂。另一方面,现在 Angular 使用键。通常,键是不同服务的类型。在实例化方面的另一个核心区别是注入器的层次结构,它们封装了具有不同可见性的不同依赖提供者。
AngularJS 和 Angular 中服务之间的另一个区别是简化的语法。Angular 中的 DI 具有完全不同的语法,并通过提供一种一致的方式来注入依赖项而改进了行为。你还可以在第六章中找到关于 Angular 服务和 DI 的更详细解释,Angular 中的依赖注入。
新的路由
在传统的 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 核心概念的主要区别。
在下一章中,我们将探讨 TypeScript 以及我们如何在下一个应用中开始使用它。我们还将解释如何通过环境类型定义,在编写纯 JavaScript 的库和框架中使用静态类型。
第四章:TypeScript 快速入门
在本章中,我们将开始使用 TypeScript:Angular 所使用的语言。所有 ECMAScript 2015 和 ECMAScript 2016 提供的功能,如函数、类、模块和装饰器,都已经实现或添加到 TypeScript 的路线图中。由于额外的类型注解,与 JavaScript 相比,有一些语法上的添加。
由于已经熟悉 AngularJS 和随后的 ES5,我们将经历一个平滑过渡到 TypeScript 的过程。我们将从介绍 ES2015 和 ES2016 开始。正如我们之前提到的,TypeScript 是这些新 ECMAScript 标准的超集,这意味着通过学习 JavaScript 的新版本,你也将学习到 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)。我建议你使用 5.0.0 或更高版本的 npm。如果你还没有安装 node.js 和 npm,你可以访问 nodejs.org 并按照那里的说明操作。
使用 npm 安装 TypeScript
一旦安装并运行了 npm,你可以通过打开终端窗口并运行以下命令来验证你是否拥有最新版本:
$ npm -v 
使用以下命令来安装 TypeScript 2.5.0 或更高版本:
$ npm install -g typescript@².5.0 
前面的命令将安装 TypeScript 编译器并将其可执行文件(tsc)添加为全局。
为了验证一切是否正常工作,你可以使用以下命令:
$ tsc -v
Version 2.5.2 
输出应该类似于前面的示例,尽管可能包含不同的次要或补丁版本。
注意,我们通过在版本号前加 caret 来安装 TypeScript。这意味着 npm 将下载 2.y.z 范围内的任何版本,但低于 3.0.0。
运行我们的第一个 TypeScript 程序
现在,让我们编译我们的第一个 TypeScript 程序!创建一个名为 hello-world.ts 的文件,并输入以下内容:
// ch4/hello-world/hello-world.ts
console.log('Hello world!');
由于我们已经安装了 TypeScript 编译器,我们应该有一个名为 tsc 的全局可执行命令。你可以用它来编译文件:
$ tsc hello-world.ts 
现在,你应该在 hello-world.ts 所在的目录中看到 hello-world.js 文件。hello-world.js 文件是 TypeScript 编译器的输出;它包含与我们编写的 TypeScript 相等的 JavaScript 代码。我们可以使用以下命令运行此文件:
$ node hello-world.js 
现在,你会在屏幕上看到打印的 Hello world! 字符串。为了结合编译和运行程序的过程,你可以使用 ts-node 包:
$ npm install -g ts-node 
现在,你可以运行以下命令:
$ ts-node hello-world.ts 
你应该看到相同的结果,但不需要在磁盘上存储 hello-world.js 文件。
你可以在 github.com/mgechev/switching-to-angular 找到这本书的代码。书中大部分代码片段的第一行都有一个注释,显示你可以在样本存储库的目录结构中找到完整的示例。请注意,路径是相对于 app 目录的。
由 ES2015 和 ES2016 引入的 TypeScript 语法和功能
由于 TypeScript 是 JavaScript 的超集,在我们开始学习其语法之前,先介绍一些 ES2015 和 ES2016 中的较大变化会更容易;为了理解 TypeScript,我们首先必须理解 ES2015 和 ES2016。在深入 TypeScript 之前,我们将对这些变化进行快速浏览。
对 ES2015 和 ES2016 的详细解释超出了本书的范围。为了熟悉所有新特性和语法,我强烈建议你阅读 Exploring ES6: Upgrade to the next version of JavaScript,作者是 Dr. Axel Rauschmayer。
接下来的几页将介绍新的标准,并使我们能够在开发 Angular 应用程序的过程中充分利用我们所需的大部分功能。
ES2015 箭头函数
JavaScript 有第一类函数,这意味着它们可以像任何其他值一样传递:
// ch4/arrow-functions/simple-reduce.ts
var result = [1, 2, 3].reduce(function (total, current) { 
  return total + current; 
}, 0); // 6 
这种语法很棒;然而,它有点过于冗长。ES2015 引入了一种新的语法来定义匿名函数,称为箭头函数语法。使用它,我们可以创建匿名函数,如下面的例子所示:
// ch4/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):
// ch4/arrow-functions/context-demo.ts
function MyComponent() { 
  this.age = 42; 
  setTimeout(() => { 
    this.age += 1; 
    console.log(this.age); 
  }, 100); 
}
new MyComponent(); // 43 in 100ms. 
例如,当我们使用 new 操作符调用 MyComponent 函数时,this 将指向由调用实例化的新对象。箭头函数将保持上下文(this),在 setTimeout 的回调中,并在屏幕上打印 43。
在 Angular 中,这非常有用,因为给定组件的绑定上下文是其实例(即其 this 引用)。如果我们定义 MyComponent 为 Angular 组件,并且有一个绑定到 age 属性,前面的代码将是有效的,并且所有绑定都将工作(注意,我们没有作用域,我们也没有对 $digest 循环的显式调用,尽管我们直接调用了 setTimeout)。
使用 ES2015 和 ES2016 类
当 JavaScript 新手听说该语言赋予 面向对象(OO)范式时,他们通常会在发现没有用于定义类的语法时感到困惑。这种观念源于一些最受欢迎的编程语言,如 Java、C# 和 C++,它们都有用于构建对象的类的概念。然而,JavaScript 以不同的方式实现了面向对象范式。JavaScript 有一个基于原型的面向对象编程模型,我们可以使用对象字面量语法或函数(也称为构造函数)来实例化对象,并且我们可以利用所谓的原型链来利用继承。
虽然这是一种实现面向对象范式的有效方式,并且语义与经典面向对象模型相似,但对于不确定如何正确处理此问题的经验不足的 JavaScript 开发者来说,这可能会令人困惑。这就是 TC39 决定提供一种替代语法来在语言中使用面向对象范式的原因之一。幕后,新语法具有与我们熟悉的相同的语义,例如使用构造函数和基于原型的继承。然而,它提供了一个更方便的语法,以更少的样板代码来启用面向对象范式的功能。
ES2016 为 ES2015 类添加了一些额外的语法,例如静态和实例属性声明。
这里有一个示例,演示了在 ES2016 中定义类的语法:
// ch4/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 的值来实现的。
后来,Developer 类通过在 extends 子句中指定对它的引用来扩展 Human 类。这样,Developer 类的实例将拥有 Human 类提供的全部功能,以及 Developer 类引入的额外功能。
现在,打开 ch4/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 中被广泛使用。你可以使用它们来定义你的组件、指令、服务和管道。
定义具有块作用域的变量
对于具有不同背景的开发者来说,JavaScript 的变量作用域是一个令人困惑的点。例如,在 Java 和 C++ 中,我们习惯于块词法作用域。这意味着在特定块内部定义的变量将仅在块内部及其嵌套的所有块内部可见。
然而,在 JavaScript 中,情况略有不同。ECMAScript 定义了一个具有类似语义的功能词法作用域,但它使用函数而不是代码块。让我们看看下面的代码片段:
// ch4/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:
// ch4/let/let.ts 
var fns = []; 
for (let i = 0; i < 5; i += 1) { 
  fns.push(function() { 
    console.log(i); 
  });
} 
fns.forEach(fn => fn()); 
现在的语义将如预期的那样:我们将在控制台中看到从0到4的数字。
使用 ES2016 装饰器进行元编程
JavaScript 是一种动态语言,它允许我们轻松地修改和/或改变行为以适应我们编写的程序。装饰器是 ES2016 的一个提议,设计文档在github.com/wycats/javascript-decorators中描述了它们的工作如下:
"...使设计时对类和属性进行注释和修改成为可能。"
它们的语法与 Java 中的注解非常相似,甚至比 Python 中的装饰器更接近。ES2016 装饰器在 Angular 中常用以定义组件、指令和管道,并利用框架的依赖注入机制。装饰器的多数用例涉及改变行为以符合预定义逻辑或向不同的结构添加一些元数据。
ES2016 装饰器通过改变程序的行为,使我们能够执行许多复杂操作。典型的用例可以是注释给定的方法或属性为已弃用或只读。可以在名为core-decorators.js的项目中找到一组预定义的装饰器,这些装饰器可以提高我们生成的代码的可读性。另一个用例是利用基于代理的面向方面编程,使用声明性语法。提供此功能的库是aspect.js。
通常,ES2016 装饰器只是新的语法糖,它转换为我们从语言的前几个版本中已经熟悉的 JavaScript 代码。让我们看看提议草案中的一个简单示例:
// ch4/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 
  }; 
} 
在这个例子中,我们定义了一个名为Component的函数,它接受一个名为config的单个参数并返回一个装饰器。另一方面,装饰器接受构造函数作为参数,这是类的去糖版本。
使用 ES2015 编写模块化代码
多年来,JavaScript 专业人士遇到的一个问题是语言中缺乏模块系统。最初,社区开发了不同的模式,旨在强制执行我们生产的软件的模块化和封装。这些模式包括模块模式,它利用了函数词法作用域和闭包的优势。另一个例子是命名空间模式,它将不同的命名空间表示为嵌套对象。AngularJS 引入了自己的模块系统,但遗憾的是,它不提供诸如懒加载模块等特性。然而,这些模式更像是一种权宜之计,而不是真正的解决方案。
CommonJS(在 node.js 中使用)和AMD(异步模块定义的缩写)后来被发明。它们至今仍被广泛使用,并提供处理循环依赖和异步模块加载(在 AMD 中)等功能。
TC39 取了现有模块系统的最佳之处,并在语言级别上引入了这个概念。ES2015 提供了两个 API 来定义和消费模块。它们如下所示:
- 
声明式 API 
- 
使用模块加载器的命令式 API 
Angular 充分利用了 ES2015 模块系统,所以让我们深入探讨!在本节中,我们将查看用于声明式定义和消费模块的语法。我们还将窥视模块加载器的 API,以了解我们如何以显式异步的方式编程加载模块。
使用 ES2015 模块语法
让我们看看一个例子:
// ch4/modules/math.ts 
export function square(x) { 
  return Math.pow(x, 2); 
};
export function log(x) { 
  return Math.log(x); 
};
export const PI = Math.PI; 
export const E = Math.E;
在前面的代码片段中,我们在math.ts文件中定义了一个简单的 ES2015 模块。我们可以将其视为一个示例数学 Angular 实用模块。在其中,我们定义并导出square和log函数以及常量E和PI。const关键字是 ES2015 带来的另一个关键字,用于定义常量。正如你所见,我们所做的一切不过是给函数的定义前加上export关键字。如果我们想最终导出整个功能并跳过重复的显式export使用,我们可以使用以下方法:
// ch4/modules/math2.ts 
function square(x) { 
  return Math.pow(x, 2); 
};
function log(x) { 
  return Math.log(x); 
};
const PI = Math.PI;
const E = Math.E;
export { square, log, PI, E }; 
最后一行的语法是增强的对象字面量语法,由 ES2015 引入。现在,让我们看看我们如何消费这个模块:
// ch4/modules/app.ts 
import {square, log, E} from './math';
console.log(square(2)); // 4 
console.log(log(E)); // 1 
作为模块的标识符,我们使用其相对于当前文件的相对路径。使用解构,我们导入所需的符号:在这种情况下,square、log和E。现在使用ts-node运行app.ts文件:ts-node app.ts。屏幕上的输出应该是:
4
1
利用模块的隐式异步行为
需要注意的一个重要问题是,ES2015 模块语法具有隐式的异步行为。

图 2
在前面的图(图 2)中,我们有A、B和C模块。A模块使用B和C模块,因此它依赖于它们。一旦用户需要A模块,JavaScript 模块加载器需要先加载B和C模块,然后才能调用A模块中驻留的任何逻辑,因为这些依赖项。B和C模块将异步加载。一旦它们完全加载,JavaScript 虚拟机将能够执行A模块。
通常,现代打包器会将A、B和C合并到一个文件中,以减少浏览器在解析A的依赖项时需要执行的额外 HTTP 请求的开销。
使用别名
另一个典型的情况是我们想为给定的导出使用别名。例如,如果我们使用第三方库,我们可能想重命名其中的一些导出,以避免名称冲突或仅仅为了更方便的命名:
import {
  platformBrowserDynamic as platform
} from '@angular/platform-browser-dynamic';
导入所有模块导出
我们可以使用以下语法导入整个math模块:
// ch4/modules/app2.ts 
import * as math from './math';
console.log(math.square(2)); // 4 
console.log(math.log(math.E)); // 1 
console.log(math.PI); // 3.141592653589793 
这种语法的语义与 CommonJS 非常相似,尽管在浏览器中,我们具有隐式的异步行为。
请记住,import *语法通常不推荐使用,因为它将从目标模块获取所有导出的符号,即使我们不需要它们。这可能导致我们应用程序的生产构建大小的无谓增加。
默认导出
如果一个模块定义了一个export,这很可能被其任何消费者模块使用,我们可以利用默认的export语法:
// ch4/modules/math3.ts 
export default function cube(x) { 
  return Math.pow(x, 3); 
};
export function square(x) { 
  return Math.pow(x, 2); 
};
为了消费此模块,我们可以使用以下app.ts文件:
// ch4/modules/app3.ts 
import cube from './math3';
console.log(cube(3)); // 27 
或者,如果我们想导入默认导出以及额外的导出,我们可以使用以下方法:
// ch4/modules/app4.ts 
import cube, { square } from './math3';
console.log(square(2)); // 4 
console.log(cube(3)); // 27 
通常,默认导出不过是一个用default保留字命名的命名导出:
// ch4/modules/app5.ts 
import { default as cube } from './math3';
console.log(cube(3)); // 27 
ES2015 模块加载器
标准的新版本定义了一个用于处理模块的程序化 API。这就是所谓的模块加载器 API。它允许我们定义和导入模块,或配置模块加载。
假设我们在app.js文件中有以下模块定义:
import { square } from './math';
export function main() { 
  console.log(square(2)); // 4 
} 
从init.js文件中,我们可以程序化地加载app模块并调用其main函数:
System.import('./app') 
  .then(app => { 
    app.main(); 
  }) 
  .catch(error => { 
    console.log('Terrible error happened', error); 
  }); 
全局对象System有一个名为import的方法,允许我们使用它们的标识符导入模块。在前面的代码片段中,我们导入了在app.js中定义的app模块。此外,System.import返回一个 promise,可以在成功时解析或在出错时拒绝。一旦 promise 作为传递给then的回调函数的第一个参数解析,我们将得到模块实例。在拒绝的情况下注册的回调函数的第一个参数是一个表示发生错误的对象。
上一段代码中的代码在 GitHub 仓库中不存在,因为它需要额外的配置,并且对我们来说不是至关重要的。
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 的类型推断,此代码的语义将与具有显式类型定义的代码相同。我们将在本章末尾进一步探讨它。
任何类型
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 中的大多数原始类型是我们已经在 JavaScript 中熟悉的:Number、String、Boolean、Null 和 Undefined。因此,我们在这里将跳过它们的正式解释。另一组在开发 Angular 应用程序时很有用的类型是由用户定义的 enum 类型。
枚举类型
枚举类型是原始用户定义类型,根据规范,它们是 Number 的子类。枚举的概念存在于 Java、C++ 和 C# 语言中,在 TypeScript 中具有相同的语义:由称为元素的一组命名值组成的用户定义类型。在 TypeScript 中,我们可以使用以下语法定义一个 enum:
enum STATES { 
  CONNECTING, 
  CONNECTED, 
  DISCONNECTING, 
  WAITING, 
  DISCONNECTED  
}; 
这将被翻译成以下 JavaScript:
var STATES; 
(function (STATES) { 
    STATES[STATES["CONNECTING"] = 0] = "CONNECTING"; 
    STATES[STATES["CONNECTED"] = 1] = "CONNECTED"; 
    STATES[STATES["DISCONNECTING"] = 2] = "DISCONNECTING"; 
    STATES[STATES["WAITING"] = 3] = "WAITING"; 
    STATES[STATES["DISCONNECTED"] = 4] = "DISCONNECTED"; 
})(STATES || (STATES = {})); 
我们可以使用 enum 类型如下:
if (this.state === STATES.CONNECTING) { 
  console.log('The system is connecting'); 
} 
理解对象类型
在本节中,我们将探讨数组类型和函数类型,它们属于更通用的对象类型类别。我们还将探讨如何定义类和接口。元组类型是在 TypeScript 1.3 中引入的,其主要目的是允许语言开始对 ES2015 引入的新功能进行类型化,例如解构。我们不会在本书中描述它们。有关进一步阅读,您可以查看语言规范www.typescriptlang.org。
数组类型
在 TypeScript 中,数组是具有公共元素类型的 JavaScript 数组。这意味着在给定的数组中我们不能有来自不同类型的元素。我们为 TypeScript 中的所有内置类型以及我们定义的所有自定义类型提供了不同的数组类型。
我们可以这样定义一个数字数组:
let primes: number[] = []; 
primes.push(2); 
primes.push(3); 
如果我们想要一个类似 JavaScript 数组的异构数组,我们可以使用 any[] 类型:
let randomItems: any[] = []; 
randomItems.push(1); 
randomItems.push('foo'); 
randomItems.push([]); 
randomItems.push({}); 
这是因为我们推送到数组中的所有值的类型都是 any 类型的子类型,并且我们声明的数组包含 any 类型的值。
我们可以使用在 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 提供的类似。然而,TypeScript 改变了类型声明并添加了更多的语法糖。例如,让我们看看我们之前定义的 Human 类:
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; 
} 
我们可以在类中将其定义为字段或 getter。
接口继承
接口也可以相互扩展。例如,我们可以将我们的Individual类转换为一个包含社会保险号的接口:
interface Accountable { 
  accountNumber: string; 
  getIncome(): number; 
}
interface Individual extends Accountable { 
  ssn: string; 
} 
由于接口支持多重继承,Individual也可以扩展具有name和age属性的Human接口:
interface Accountable { 
  accountNumber: string; 
  getIncome(): number; 
}
interface Human { 
  age: number; 
  name: number; 
}
interface Individual extends Accountable, Human { 
  ssn: string; 
} 
实现多个接口
如果类的行为是几个接口中定义的属性的并集,它可以实现所有这些接口:
class Person implements Human, Accountable { 
  age: number; 
  name: string; 
  accountNumber: string;
  getIncome(): number { 
    // ... 
  } 
} 
在这种情况下,我们需要为类实现的接口中声明的所有方法和属性提供实现,否则编译器将抛出一个编译时错误。
使用 TypeScript 装饰器进一步增强表达力
在 ES2015 中,我们只能装饰类、属性、方法、getter 和 setter。TypeScript 进一步允许我们装饰函数或方法参数:
class Http { 
  // ... 
}
class GitHubApi { 
  constructor(@Inject(Http) http) { 
    // ... 
  } 
} 
请记住,参数装饰器不应改变任何额外的行为。相反,它们用于生成元数据。这些装饰器的最典型用例是 Angular 的依赖注入机制。
使用类型参数编写泛型代码
在使用静态类型的部分的开头,我们提到了类型参数。为了更好地理解它们,让我们从一个例子开始。假设我们想要实现经典的数据结构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的类。这个类的实例代表我们树中的单个节点。每个节点都有一个left和right子节点以及一个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>类型的值赋给它们;这就是为什么我们得到第二个编译时错误的原因。
使用泛型函数
泛型的一个典型用途是定义在一系列类型上操作的功能。例如,我们可能定义一个identity函数,它接受类型为T的参数并返回它:
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 的编译器能够根据给定的表达式推断其类型,而无需显式定义。
引入结构化类型
TypeScript 的类型系统的一个重要特性是它是结构化类型。形式上这意味着类型的等价性和兼容性是由类型的结构决定的,而不是由名称或其他特征(如继承层次结构)决定的。
这可能一开始听起来相当抽象,所以让我们看看一个具体的例子:
// ch4/structural-typing/example.ts
interface Namable {
  name: string;
}
class Cat {
  name: string;
}
class Castle {
  name: string;
  capacity: string;
}
const formatName = (obj: Namable) => `The name is ${obj.name}`;
上述代码片段定义了一个名为Namable的接口,它有一个名为name的单个属性声明。我们还定义了Cat和Castle类:它们都有name属性,但都没有实现Namable接口。
如果我们用Cat或Castle的新实例调用formatName,代码将正常编译:
const cat = new Cat();
cat.name = 'Kitty';
formatName(cat);
const castle = new Castle();
castle.name = 'Hogwarts';
formatName(castle);
在这种情况下,我们不会得到任何编译类型错误,因为Namable接口声明的属性是Cat和Castle声明的属性的子集。尽管在这种情况下,显式接口实现可能看起来是不必要的,但它更加方便,因为在开发特定类时,如果接口实现不正确,我们将在文本编辑器或 IDE 中得到内联错误。对于一些读者来说,这可能会让人联想到 Go 中的隐式接口实现,然而,我们可以更进一步。
现在让我们看看formatName的签名如下:
const formatName = (obj: Cat) => `The name is ${obj.name}`;
现在我们可以执行以下操作:
formatName(new Castle());
如果我们使用 Castle 实例调用 formatName,代码将再次编译无误!这是由于 Cat 和 Castle 类的兼容性:Cat 类具有由 Castle 声明的属性子集。对于习惯于命名类型开发的开发者来说,这可能会显得不自然,因为在这种情况下,Cat 和 Castle 不在同一个继承链中。
现在,让我们将 Cat 类定义的说明改为以下内容:
class Cat {
  name: string;
  breed: string;
}
如果我们保持 formatName 函数相同的签名,那么使用 Castle 实例调用 formatName 将会产生错误:
const formatName = (obj: Cat) => `The name is ${obj.name}`;
formatName(new Castle());
// Argument of type 'Castle' is not assignable to parameter of type 'Cat'.
//  Property 'breed' is missing in type 'Castle'.
正如编译错误所说,我们不能传递缺少某些属性或方法的对象实例,这些属性或方法是给定函数或方法签名参数的类型。
既然我们已经熟悉了结构化类型,让我们继续我们的下一站:环境类型定义。
使用环境类型定义
虽然静态类型很神奇,但我们使用的许多前端库都是用 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作为类型参数。由于tsconfig.json文件位于我们的app.ts文件和node_modules目录中,我们可以通过运行以下命令来编译项目:
$ 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的接口,并声明了DOM变量为DOMLibraryInterface类型。
在能够使用静态类型与我们的 JavaScript 库一起使用之前,我们唯一剩下的事情是将外部类型定义包含到我们想要使用库的脚本文件中。我们可以这样做:
/// <reference path="dom.d.ts"/> 
前面的代码片段提示编译器在哪里可以找到环境类型定义。一种替代的、更好的方法是通过使用前面描述的tsconfig.json来提供对d.ts文件的引用。
摘要
在本章中,我们介绍了用于 Angular 实现的 TypeScript 语言。
在探索语言的过程中,我们查看了一些 ES2015 和 ES2016 的核心特性。我们解释了 ES2015 和 ES2016 的类、箭头函数、块作用域变量定义、解构和模块。由于 Angular 利用了 ES2016 装饰器,以及更准确地说,TypeScript 中对它们的扩展,因此我们专门用一节来介绍它们。
在此之后,我们查看了我们如何利用显式类型定义来利用静态类型。我们描述了 TypeScript 中的一些内置类型以及我们如何通过指定成员的访问修饰符来在语言中定义类。我们的下一个目的地是接口。我们通过解释类型参数、结构化类型和环境类型定义来结束我们在 TypeScript 中的冒险。
在接下来的章节中,我们将深入探讨 Angular,从框架的组件和指令开始。
第五章:Angular 组件和指令入门
到目前为止,我们已经熟悉了 Angular 为单页应用开发提供的核心构建块及其之间的关系。然而,我们只是触及了表面,通过介绍 Angular 概念背后的基本思想和它们定义的基本语法。在本章中,我们将深入探讨 Angular 的组件和指令。
在以下章节中,我们将涵盖以下主题:
- 
使用 Angular 提供的构建块强制执行关注点分离 
- 
与 DOM 交互时适当使用指令或组件 
- 
探索内置指令并开发自定义指令 
- 
深入了解组件及其模板 
- 
内容投影 
- 
视图子元素与内容子元素 
- 
组件的生命周期 
- 
使用模板引用 
- 
配置 Angular 的变更检测 
Angular 中的 "Hello world!" 应用程序
现在,让我们在 Angular 和 TypeScript 中构建我们的第一个 "Hello world!" 应用程序。在我们开始之前,让我们设置我们的环境!
设置我们的环境
Angular 的核心团队为 Angular 开发了一个全新的 CLI 工具,它允许我们通过几个命令启动我们的应用程序。尽管我们将在最后一章介绍它,但在那之前,为了增强我们的学习体验,我们将使用位于 github.com/mgechev/switching-to-angular 的代码。这个仓库包含本书中的所有示例,在一个大应用中。它声明了所有必需的依赖项在 package.json 中,定义了基本的 gulp 任务,如开发服务器、将我们的 TypeScript 代码转换为 ECMAScript 5 的转换,以及实时重新加载。
本书中的代码基于 Angular Seed (github.com/mgechev/angular-seed)。尽管该项目提供了一个坚实的基础,但它可能为 Angular 和 TypeScript 初学者引入了工具开销。一旦我们熟悉了 Angular 的基础知识,我强烈建议您使用官方的 Angular CLI 作为您第一个项目的起点,可在 github.com/angular/angular-cli 找到。
为了设置 switching-to-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 版本,并通过命令行使用单个命令在它们之间切换。
安装示例项目仓库
让我们先设置switching-to-angular项目。打开你的终端并输入以下命令:
# Will clone the repository and save it to directory called
# switching-to-angular
$ git clone https://github.com/mgechev/switching-to-angular.git
$ cd switching-to-angular
$ npm install
第一行将switching-to-angular项目克隆到名为switching-to-angular的目录中;之后,我们进入该目录。
在能够运行种子项目之前,最后一步是使用npm安装所有必需的依赖项。这一步可能需要一段时间,这取决于你的网络连接,所以请耐心等待,不要中断它。如果你遇到任何问题,请不要犹豫,在github.com/mgechev/switching-to-angular/issues提出问题。
最后一步是启动开发服务器。为此,运行以下命令:
$ npm start 
当转换过程完成后,你的浏览器将自动打开http://localhost:5555/dist/dev URL。你现在应该看到一个类似于以下截图的视图:

图 1
使用 Angular 和 TypeScript
现在,让我们玩一玩我们已有的文件。导航到switching-to-angular中的app/ch5/hello-world目录。然后,打开app.ts并将它的内容替换为以下片段:
// ch5/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装饰器,其中引用了一个外部模板。
作为下一步,我们定义组件的视图。然而,请注意,在这种情况下,我们使用templateUrl而不是简单地内联组件的模板。
打开app.html并将文件的内容替换为<h1>Hello {{target}}!</h1>。由于我们可以通过内联(使用template)和设置其 URL(templateUrl)两种方式使用模板,从某种意义上说,组件的 API 与 AngularJS 指令的 API 相似。
在代码片段的最后一行,我们通过提供根模块来启动应用程序。
现在,让我们来看看index.html,以便了解当我们启动应用程序时会发生什么:
<!-- ch5/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> 
注意,在页面的主体中,我们使用带有内容和值为"Loading..."的文本节点的my-app元素。在应用程序初始化并渲染主组件之前,"Loading..."标签将是可见的。
<%= INIT %>和<-- inject:js...模板占位符注入针对个别演示的内容。它们不是 Angular 特定的,而是旨在防止由于它们之间的共享结构而在书籍附带的代码示例中重复代码。为了了解这个特定的 HTML 文件是如何被转换的,请打开/dist/dev/ch5/hello-world/index.html。
使用 Angular 指令
我们已经构建了简单的"Hello world!"应用。现在,让我们开始构建更接近真实应用的东西。在本节结束时,我们将拥有一个简单的应用,列出我们需要完成的任务,并在页面标题处向我们问候。
首先,让我们开发根组件。与前一个示例相比,我们需要进行的两个修改是将target属性重命名为name,并将todos列表添加到组件的控制定义中:
// ch5/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中添加以下内容:
<!-- ch5/ng-for/detailed-syntax/app.html --> 
<h1>Hello {{name}}!</h1> 
<p> 
  Here's a list of the things you need to do: 
</p> 
<ul> 
  <ng-template ngFor let-todo [ngForOf]="todos"> 
    <li>{{todo}}</li> 
  </ng-template> 
</ul> 
注意,在 Angular 2.x.y 版本中,框架使用的是template而不是ng-template。由于template元素在 Angular 的上下文中的语义与其在 HTML 标准上下文中的语义不同,在 Angular 的第四版中,template元素被弃用并重命名为ng-template。
现在,当你刷新浏览器后,你应该看到以下结果:

图 2
到目前为止,一切顺利!在最后的代码片段中,唯一剩下要解释的新特性是ng-template元素的一些我们不熟悉的属性,例如ngFor、let-todo和[ngForOf]。让我们来看看它们。
ngFor 指令
ngFor指令允许我们遍历一个项目集合,不仅完全实现了 AngularJS 中的ng-repeat的功能,还带来了一些额外的语义。请注意,ngForOf属性被括号包围。起初,这些括号可能看起来像是无效的 HTML。然而,根据 HTML 规范,它们在属性名称中使用是允许的。W3C 验证器唯一会抱怨的是,ng-template元素并不拥有这样的属性;然而,浏览器在处理标记时不会有问题。
这些括号背后的语义是,它们包围的属性的值是一个表达式,需要被评估。
指令语法语义的改进
在第二章“开始使用 Angular”中,我们提到了在 Angular 中改进工具的机会。AngularJS 的一个大问题是我们可以使用指令的不同方式。这需要理解属性值,这些值可以是字面量、表达式、回调或微语法。在 Angular 中,通过引入一些简单且内置于框架中的约定来解决这个问题:
- 
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()">Click me</button>。它们也可以用以下方式表示:<button on-click="handle()">Click me</button>。
在模板内部声明变量
前一个模板留下的最后一件事是let-todo属性。使用这种语法,我们告诉 Angular 我们想要声明一个名为todo的新变量,并将其绑定到从评估设置为[ngForOf]值的表达式所得到的集合中的各个项。
在模板中使用语法糖
虽然模板语法给我们所使用的 IDE 或文本编辑器提供了代码的意义,但它相当冗长。Angular 提供了一个替代语法,它将被转换为前面片段中显示的语法。
有一些 Angular 指令需要ng-template元素,例如ngForOf、ngIf和ngSwitch。由于这些指令经常使用,因此存在它们的替代语法。我们不需要显式地键入整个ng-template元素,只需在指令前加上*前缀即可。这将允许我们更改ngForOf指令的语法使用,如下所示:
<!-- ch5/ng-for/syntax-sugar/app.html --> 
<ul> 
  <li *ngFor="let todo of todos">{{todo}}</li> 
</ul> 
之后,Angular 将这个模板转换为前面描述的更冗长的语法。由于更简洁的语法更容易阅读和编写,其使用被认为是最佳实践。
* 字符允许我们移除 ng-template 元素,并将指令直接放在 ng-template 元素的顶级子元素上(在先前的示例中,是 li 列表项)。
定义 Angular 指令
现在我们已经构建了一个简单的 Angular 组件,让我们继续我们的旅程,通过理解 Angular 指令来继续前进。
使用 Angular 指令,我们可以在 DOM 上应用不同的行为或结构变化。在这个例子中,我们将构建一个简单的提示指令。
与组件相比,指令没有模板。这两个概念之间的另一个核心区别是,给定的 HTML 元素可能只有一个组件,但可以有多个指令。换句话说,与实际元素相比,指令增强了元素。
Angular 的官方风格指南建议使用带有命名空间的指令作为属性。考虑到这一点,我们将在以下方式中使用提示指令:
<div saTooltip="Hello world!"></div> 
在先前的片段中,我们在 div 元素上使用了提示指令。作为一个命名空间,其选择器使用 sa 字符串。
由于本书的重点是高效直观地学习 Angular 的概念,代码片段可能不完全符合 Angular 风格指南。然而,对于生产应用程序,遵循最佳实践是至关重要的。您可以在 angular.io/styleguide 找到官方 Angular 风格指南。
现在,让我们开发一个提示指令!在实现它之前,我们需要从 @angular/core 中导入几个符号。打开一个名为 app.ts 的新 TypeScript 文件,并输入以下内容;我们稍后会填充占位符:
import {Directive, ElementRef, HostListener...} from '@angular/core'; 
在上一行中,我们导入了以下定义:
- 
ElementRef: 这允许我们将元素引用(我们不仅限于 DOM)注入到指令的宿主元素中。在先前的示例用法中,作为ElementRef,我们将得到一个 Angular 包装的div元素,它包含saTooltip属性。
- 
Directive: 这个装饰器允许我们添加我们定义的新指令所需的元数据。
- 
HostListener(eventname): 这是一个方法装饰器,它接受一个事件名称作为参数。在指令初始化期间,Angular 会将装饰的方法添加为对宿主元素发出的eventname事件的处理器。
让我们看看指令的实现:
// ch5/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] 作为不同的选择器区分开来。之后,我们通过在 saTooltip 属性上使用 @Input 装饰器来声明指令的输入。此代码背后的语义是,我们声明了一个名为 saTooltip 的属性,并将其绑定到传递给 saTooltip 属性的表达式评估结果值。
@Input 装饰器接受一个单一参数,即我们想要绑定的属性的名称。如果我们没有传递参数,Angular 将在属性本身具有相同名称的属性之间创建绑定。我们将在本章的后面详细解释输入和输出概念。
理解指令的构造函数
构造函数声明了两个私有属性:el 是 ElementRef 类型,overlay 是 Overlay 类型。Overlay 类实现了管理提示框覆盖的逻辑,并将通过 Angular 的 DI 机制进行注入。为了将其声明为可注入的,我们需要以下方式声明顶级组件:
@Component({ 
  selector: 'app', 
  templateUrl: './app.html', 
  providers: [Overlay], 
  // ... 
}) 
class App {} 
我们将在下一章中探讨 Angular 的依赖注入机制,我们将解释如何声明我们的服务、指令和组件的依赖关系。对于本章的目的而言,Overlay 类的实现并不重要。然而,如果你对此感兴趣,可以在 ch5/tooltip/app.ts 中找到它。
使用 NgModules 更好地封装指令
为了使提示指令对 Angular 编译器可用,我们需要显式声明我们打算在哪里使用它。例如,查看 AppModule 类在 ch5/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声明的并集。鉴于组件是从指令扩展的,我们需要显式声明所有使用的组件。
BrowserModule模块通过导出包含它们的CommonModule模块来导出 Angular 提供的内置指令。这个预定义指令列表包括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组件,如下所示:
<zippy header="Header">
  <content>Lorem Ipsum</content>
</zippy>
为了创建一个可工作的 Angular 应用程序,我们需要声明一个NgModule装饰器,它以某种方式引用所有三个组件。我们可以通过两种主要方式来处理这个问题:
- 
声明一个单独的 NgModule装饰器,并将所有三个组件包含在其声明列表中。
- 
声明两个 NgModules: 
- 
声明 Zippy和ZippyHeader组件,称为ZippyModule
- 
另一个声明 App组件并导入ZippyModule模块的例子
第二种方法有几个优点:在ZippyModule中,我们可以声明Zippy和ZippyHeader,但只能导出Zippy,因为ZippyHeader是在Zippy内部使用的,我们不需要将其暴露给用户。通过声明ZippyModule模块,我们可以将其导入到我们的应用程序中的其他模块,以便重用Zippy组件,或者甚至将其提取为单独的npm模块,在多个应用程序中重用。
第二种方法看起来是这样的:
// ch5/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 组件。在这种情况下,我们的应用程序可能看起来像这样:
//  ch5/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);
现在,如果我们运行我们的应用程序,我们会得到以下错误:

图 3
当 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 属性;这是我们第一次遇到它。从其名称可以猜测,它用于设置组件的样式。最后,我们添加 encapsulation。这是 Angular 的一项功能,它允许我们控制定义的样式的范围。
介绍组件的视图封装
如我们所知,Angular 受到 Web 组件的启发,其核心特性是 Shadow DOM。Shadow DOM 允许我们在不使其泄漏到组件范围之外的情况下封装我们的 Web 组件的样式;Angular 提供了这一功能。如果我们希望 Angular 使用 Shadow DOM,我们可以使用 ViewEncapsulation.Native。然而,在撰写本书时,并非所有浏览器都支持 Shadow DOM;如果我们想在不使用 Shadow DOM 的情况下达到相同的封装级别,我们可以将封装属性的值设置为 ViewEncapsulation.Emulated。
如果我们不想有任何封装,我们可以使用 ViewEncapsulation.None。默认情况下,渲染器使用 Emulated 类型的封装。
实现组件的控制器
现在,让我们继续实现应用程序:
// ch5/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 项,我们创建一个可以切换项完成状态的复选框;我们还使用插值指令渲染 todo 项的标签。在这里,我们可以注意到之前解释过的语法:
- 
我们使用 (change)="statement"绑定到复选框的更改事件。
- 
我们使用 [checked]="expr"绑定到todo项的属性。
为了在完成的 todo 项上有一条横线,我们绑定到元素的 class.completed 属性。由于我们希望将 completed 类应用到所有完成的待办事项上,我们使用 [class.completed]="todo.completed"。这样,我们声明我们希望根据 todo.completed 表达式的值来应用 completed 类。现在,我们的应用程序看起来是这样的:

图 4
与类绑定语法类似,Angular 允许我们绑定到元素的样式和属性。例如,我们可以使用以下代码行绑定到 td 元素的 colspan 属性:<td [attr.colspan]="colspanCount"></td>。同样,我们可以使用 <div [style.backgroundImage]="expression"></td> 绑定到任何 style 属性。
处理用户操作
到目前为止,一切顺利!现在,让我们实现 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 应用程序,我们将展示如何利用指令的输入和输出:

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

图 6
外部矩形表示整个 Todo 应用程序。第一个嵌套矩形包含负责输入新待办事项标签的组件,下面的矩形列出了存储在根组件中的单个项目。
说了这么多,我们可以将这些三个组件定义为以下内容:
- 
TodoApp:负责维护待办事项列表(添加新项目和切换完成状态)。
- 
InputBox:这个组件负责输入新的待办事项的标签。它有以下输入和输出:- 
输入:文本框的占位符和提交按钮的标签 
- 
输出:提交按钮点击后文本框的内容 
 
- 
- 
TodoList:这个组件负责渲染单个待办事项。它有以下输入和输出:- 
输入:这是一个待办事项列表 
- 
输出:这是待办事项的完成状态 
 
- 
现在,让我们开始实现!
定义输入和输出
让我们采用自下而上的方法,从InputBox组件开始。在此之前,我们需要从 Angular 的@angular/core包中导入几个组件:
import { 
  Component, 
  Input, 
  Output, 
  EventEmitter 
} from '@angular/core'; 
在这个片段中,我们导入了@Component、@Input、@Output装饰器和EventEmitter类。正如它们的名称所表明的,@Input和@Output用于声明指令的输入和输出。EventEmitter是一个泛型类(即接受类型参数),当与用@Output装饰的属性结合使用时,允许它们emit值。
作为下一步,让我们看看InputBox组件的声明:
// ch5/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标识符评估得到的值。这是我们需要声明的第一个输入:
class InputBox { 
  @Input() inputPlaceholder: string; 
  ... 
} 
同样,我们声明buttonLabel组件的其他输入,我们将其用作按钮标签的值:
class InputBox { 
  @Input() inputPlaceholder: string; 
  @Input() buttonLabel: string; 
  ... 
} 
在前面的模板中,我们将按钮的点击事件绑定到emitText(todoInput.value); todoInput.value = '';语句。emitText方法应该在组件的控制器中定义;一旦调用,它应该emit文本输入的值。以下是实现此行为的方法:
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装饰器的对象字面量的值对于本节的目的不是必需的,所以我们省略了它。这个示例的完整实现可以在ch5/inputs-outputs/app.ts中找到。让我们看看TodoList类的主体。同样,与InputBox组件一样,我们定义了todos输入。我们还通过声明toggle属性,将其值设置为EventEmitter<Todo>类型的新实例,并用@Output装饰器装饰它来定义toggle输出。
传递输入和消费输出
现在,让我们将上一节中定义的组件组合起来,实现完整的应用程序。
我们需要查看的最后一个组件是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的值将等于我们在InputBox的emitText方法内部传递给emit方法的inputText对象的值(如果我们绑定到原生事件,事件对象的值将是原生事件对象本身)。
同样地,我们传递TodoList组件的输入并处理其切换输出。现在,让我们定义TodoApp组件背后的逻辑:
class TodoApp { 
  todos: Todo[] = []; 
  name: string = 'John';
  addTodo(label: string) { 
    this.todos.push({ 
      label, 
      completed: false 
    }); 
  }
  toggleCompletion(todo: Todo) { 
    todo.completed = !todo.completed; 
  } 
} 
在addTodo方法中,我们只是简单地将一个新的待办事项推送到todos数组。toggleCompletion的实现甚至更简单:我们切换作为待办事项参数传递的完成标志的值。现在,我们已经熟悉了组件的输入和输出的基础知识。
事件冒泡
在 Angular 中,我们有与 DOM 中相同的冒泡行为。例如,假设我们有以下模板:
<input-box inputPlaceholder="New todo..." 
  buttonLabel="Add" 
  (click)="handleClick($event)" 
  (inputText)="addTodo($event)"> 
</input-box> 
input-box组件的模板声明看起来是这样的:
<input #todoInput [placeholder]="inputPlaceholder"> 
<button (click)="emitText(todoInput.value); 
                 todoInput.value = '';"> 
  {{buttonLabel}} 
</button> 
当用户点击input-box组件模板中定义的按钮时,将评估handleClick($event)表达式。
此外,handleClick的第一个参数的target属性将是按钮本身,但currentTarget属性将是input-box元素。如果不使用 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 装饰器是为了更方便地声明指令的 inputs 和 outputs 而提供的语法糖。用于此目的的原生语法如下:
@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 的内容投影
内容投影是开发用户界面时的重要概念。它允许我们将内容片段投影到我们应用程序用户界面的不同位置。网络组件通过 content 元素解决这个问题。在 AngularJS 中,它通过臭名昭著的转包含实现。
Angular 受到现代网络标准的启发,特别是网络组件,这导致了那里使用的一些内容投影方法的采用。在本节中,我们将使用 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> 
在屏幕上,我们将看到一个带有 Click me 内容标签的标准 HTML 按钮。这不是定义可重用 UI 组件的非常灵活的方式。很可能会看到,fancy 按钮的用户需要根据他们的应用程序更改标签的内容。
在 AngularJS 中,我们能够通过 ng-transclude 实现此结果:
// AngularJS example 
app.directive('fancyButton', function () { 
  return { 
    restrict: 'E', 
    transclude: true, 
    template: '<button><ng-transclude></ng-transclude></button>' 
  }; 
}); 
在 Angular 中,我们有ng-content元素:
// ch5/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。让我们看看我们如何在 Angular 中定义这样的panel组件:
// ch5/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 { } 
在模板中,我们有一个具有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将获取它们的内容并将它们设置为它自己的内容。
嵌套组件
我们已经构建了一些简单的应用程序,这些应用程序是由组件和指令组成的。我们了解到组件基本上是带有视图的指令,因此我们可以通过嵌套或组合其他指令和组件来实现它们。
下图通过结构图说明了这一点:

图 7
通过在组件模板内嵌套指令和组件,利用所使用的标记的嵌套特性,我们可以实现组合。例如,假设我们有一个具有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 的上下文中,组件模板内定义的直接子元素被称为 视图子元素,而嵌套在其开闭标签之间的元素被称为 内容子元素。
使用视图子元素和内容子元素
让我们看看一个示例 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 组件本身:
// ch5/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 的替代装饰器,它获取给定元素的所有视图子元素。在我们进一步解释实现之前,让我们看看视图子元素和内容子元素之间的区别。
视图子元素与内容子元素
虽然这两个概念听起来很相似,但它们的语义却相当不同。为了更好地理解它们,让我们看看以下示例:
// ch5/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 组件:
// ch5/view-child-content-child/app.ts
@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 装饰器添加到它们上,并使用适当的参数:
// ch5/view-child-content-child/app.ts
class UserPanel { 
  @ViewChild(UserBadge) 
  badge: UserBadge; 
  @ContentChild(UserRating) 
  rating: UserRating;
  constructor() { 
    // 
  } 
} 
badge属性声明的语义是获取UserBadge类型的第一个子组件的实例,该类型在UserPanel 模板中使用。相应地,rating属性声明的语义是获取嵌套在UserPanel* *宿主元素中的**UserRating**类型的第一个子组件的实例。
现在,如果你运行这段代码,你会注意到badge和rating属性的值在控制器构造函数内仍然是undefined值。这是因为在这个组件生命周期的这个阶段,它们还没有被初始化。我们可以用来获取这些子组件引用的生命周期钩子是ngAfterViewInit和ngAfterContentInit。我们可以通过在组件控制器中添加ngAfterViewInit和ngAfterContentInit方法的定义来使用这些钩子。我们将在不久的将来对 Angular 提供的生命周期钩子做一个全面的概述。
为了回顾,我们可以这样说,给定组件的内容子元素是嵌套在组件宿主元素内的子元素。相比之下,给定组件的视图子元素指令是其模板中使用的元素。
为了获取一个平台无关的 DOM 元素的引用,同样,我们可以使用@ContentChild和@ViewChild。例如,如果我们有以下模板:<input #todo>,我们可以使用@ViewChild('todo')来获取input的引用。
由于我们已经熟悉了视图子元素和内容子元素之间的核心区别,现在我们可以继续我们的标签实现。
在tabs组件中,我们不是使用@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 的表达性,我们可以实现与生命周期阶段相关的不同接口。每个接口都有一个与阶段本身相关联的单个方法。
以下图表显示了我们可以挂钩的所有阶段:

图 8
让我们看看不同的生命周期挂钩:
- 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:如果我们实现了OnDestroy接口及其ngOnDestroy方法,我们就可以挂钩到组件的销毁生命周期阶段。该方法将在组件从组件树中分离时被调用。
现在,让我们看看与组件的内容和视图子组件相关的生命周期挂钩:
- 
AfterContentInit:如果我们实现了ngAfterContentInit生命周期挂钩,我们将被通知组件的内容已完全初始化。这是当带有ContentChild或ContentChildren装饰的属性被初始化的阶段。
- 
AfterContentChecked:通过实现此钩子,每当给定组件的内容被 Angular 的变更检测机制检查时,我们都会收到通知。
- 
AfterViewInit:如果我们通过ngAfterViewInit方法实现此生命周期钩子,我们将在组件的视图初始化时收到通知。这是装饰了ViewChild或ViewChildren的属性初始化的阶段。
- 
AfterViewChecked:这与AfterContentChecked类似。当我们的组件视图被检查后,AfterViewChecked钩子将被调用。
生命周期钩子的执行顺序
为了追踪与每个钩子关联的回调的执行顺序,让我们看一下 ch5/life-cycle/app.ts 示例:
// ch5/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 方法之后,变更检测器将对组件的内容进行检查(将按顺序调用 ngAfterContentInit 和 ngAfterContentChecked)。紧接着,同样的事情也会发生在组件的视图中(ngAfterViewInit 后跟 ngAfterViewChecked)。
一旦 ngIf 指令的表达式评估为 false,整个组件将从视图中分离,这将导致调用 ngOnDestroy 钩子。
在下一次点击时,如果 ngIf 表达式的值为 true,则将执行与初始化阶段相同的生命周期钩子调用序列。
使用 TemplateRef 定义通用视图
我们已经熟悉了输入、内容子项和视图子项的概念,也知道在组件的生命周期中何时可以获取它们的引用。现在,我们将它们结合起来,并介绍一个新概念:TemplateRef。
让我们退后一步,回顾一下本章前面开发的最后一个待办事项应用程序。在下面的屏幕截图中,您可以查看其 UI 的外观:

图 9
如果我们查看其在ch5/inputs-outputs/app.ts中的实现,我们会看到用于渲染单个待办事项的模板是在整个待办应用模板内部定义的。
如果我们想要使用不同的布局来渲染待办事项,我们可以通过创建另一个名为Todo的组件来实现,该组件封装了渲染它们的职责。然后,我们可以为想要支持的每个布局定义单独的Todo组件。这样,我们只需要为每个布局定义不同的组件,尽管我们只需要更改它们的模板。
Angular 提供了一个更优雅的解决方案。在本章的早期,我们已经讨论了ng-template元素。Angular 允许我们引用这样的ng-template元素,并通过将它们作为内容子元素传递来使用它们。
这里是如何将自定义布局传递给我们的重构TodoApp组件的:
// ch5/template-ref/app.ts 
<todo-app> 
  <ng-template let-todo> 
    <input type="checkbox" [checked]="todo.completed" 
      (change)="todo.completed = !todo.completed;"> 
    <span [class.completed]="todo.completed"> 
      {{todo.label}} 
    </span><br> 
  </ng-template> 
</todo-app> 
在模板中,我们声明了一个名为todo的变量;在模板的后面部分,我们可以用它来引用当前的待办事项对象。
现在,让我们看看如何在TodoApp组件的控制器中获取这个模板的引用:
// ch5/template-ref/app.ts 
class TodoApp { 
  @ContentChild(TemplateRef) itemsTemplate: TemplateRef; 
  // ... 
} 
我们在这里所做的只是定义一个名为itemsTemplate的属性,并用@ContentChild装饰器装饰它。在组件的生命周期中(更准确地说,在ngAfterContentInit中),itemsTemplate的值将被设置为传递给todo-app元素内容的模板的引用。
但是还有一个问题:我们需要在TodoList组件中使用模板,因为那是渲染单个待办事项的地方。我们可以做的是定义TodoList组件的另一个输入,并直接从TodoApp传递模板:
// ch5/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应用的模板中使用这个模板引用:
<!-- ... --> 
<ng-template *ngFor="let todo of todos; template: itemsTemplate"></ng-template> 
我们在本章的前几节中已经解释了ngForOf指令的扩展语法。前面的代码片段展示了这个指令的一个更多属性,我们可以设置:template属性。默认情况下,ngForOf指令的模板是它所使用的元素。通过指定一个模板引用,我们可以使用传递的TemplateRef类。
让我们进一步简化前面的语法:
<ng-template ngFor let-todo [ngForOf]="todos" [ngForTemplate]="itemsTemplate">
</ng-template>
我们通过设置NgForOf指令的[ngForTemplate]输入来传递我们想要使用的模板,我们将它放置在ng-template元素之上。
另一方面,模板声明看起来是这样的:
<ng-template let-todo>
  ...
</ng-template>
有一些看起来相似的东西——我们在这两个地方都有 ng-template,以及 let-todo 绑定。正如我们所期望的,let-todo 属性将当前待办事项绑定到 todo 变量。请注意,我们没有为 let-todo 属性设置任何值;这将隐式地将 todo 变量绑定到 NgForOf 指令上下文的 $implicit 属性(即 NgForOfContext)。实际上,我们不需要保持属性名称相同,例如,在传递给 NgForOf 的外部模板中,我们可以使用 let-current,我们将得到完全相同的行为。一旦我们不设置此属性的值,Angular 将将其分配给上下文的 $implicit 属性。
然而,上下文还提供了更多属性:index(正在迭代的当前元素的索引)、even(如果当前索引是偶数)、odd(如果当前索引是奇数)以及其他属性。如果我们想在 TemplateRef 类中访问它们,我们需要将其声明更改为以下内容:
... 
 <ng-template let-todo let-i="index"> 
    Index: {{i}}
    <input type="checkbox" [checked]="todo.completed" 
      (change)="todo.completed = !todo.completed;"> 
    <span [class.completed]="todo.completed"> 
      {{todo.label}} 
    </span><br> 
  </ng-template> 
...
这样,我们就可以将 NgForOfContext 提供的 index 属性关联到我们的 TemplateRef 中的 i 变量。
现在,让我们继续我们的旅程,通过探索 Angular 的变更检测机制来继续前进!
理解和增强变更检测
我们已经简要描述了框架的变更检测机制。我们说,与在 scope 上下文中运行的 AngularJS 相比,在 Angular 中,它是运行在单个组件的上下文中的。我们提到的另一个概念是区域,它基本上拦截我们使用浏览器 API 所做的所有异步调用,并为框架的变更检测机制提供执行上下文。区域解决了 AngularJS 中令人烦恼的问题,即当我们使用 Angular 之外的 API 时,我们需要显式调用 digest 循环。
在第二章《开始使用 Angular》和第三章《Angular 应用程序的构建块》中,我们讨论了执行组件变更检测的代码是在运行时(即时)还是作为我们的构建过程(提前)的一部分被生成的。AOT 编译在具有严格 CSP(即 内容安全策略)的环境下表现良好,因为禁用了 JavaScript 的动态评估;它还提供了更好的性能,因为 Angular 不必编译组件的模板。我们将在第九章《工具和开发体验》中详细解释。
在本节中,我们将探讨@Component装饰器的配置对象另一个属性,它通过更改策略提供进一步的控制,从而对框架的变更检测机制进行控制。通过显式设置策略,我们能够防止变更检测机制在组件的子树中运行,在某些情况下可以带来极大的性能提升。
变更检测器的执行顺序
现在,让我们简要描述在给定组件树中调用变更检测器的顺序。
为了这个目的,我们将使用我们已有的待办应用最后的一个实现,但这次,我们将渲染单个待办事项的逻辑提取到一个名为TodoItem的单独组件中。在下面的图中,我们可以看到应用程序的结构,也称为组件树:

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

图 11
由于我们之前的观察,附加到各个组件上的变更检测器的执行顺序类似于前面图示中的顺序。一旦变更检测机制运行,最初它将对TodoApp组件进行检查。紧接着,将检查InputBox组件的变更,然后是TodoList组件。最后,Angular 将调用TodoItem组件的变更检测器。
您可以在ch5/change_detection_strategy_order/app.ts示例中追踪执行顺序,其中每个组件在其ngDoCheck方法被调用时都会记录一条消息。
注意,只有组件才有附加变更检测器的实例;指令使用其父组件的变更检测器。
理解 Angular 的变更检测策略
Angular 提供的变更检测策略有Default和OnPush。我们将详细描述如何利用OnPush,因为它在处理不可变数据时非常强大。
现在,让我们导入 TypeScript 枚举,它可以用来配置用于单个组件的策略:
// ch5/change_detection_strategy_broken/app.ts 
import {ChangeDetectionStrategy} from '@angular/core'; 
现在,我们可以配置TodoList组件使用OnPush策略:
@Component({ 
  selector: 'todo-list', 
  changeDetection: ChangeDetectionStrategy.OnPush, 
  template: `...`, 
  styles: [...] 
}) 
class TodoList { ... } 
这样,变更检测将跳过,直到组件接收到具有不同值的输入。请注意,比较使用的是相等检查,这意味着它将通过值比较基本类型,通过引用比较对象。您可以访问localhost:5555/dist/dev/ch5/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 的引用。你可以查看 ch5/change_detection_strategy/app.ts 来了解我们如何在 TypeScript 应用程序中包含不可变集合。
现在,是时候重构我们的待办事项应用程序并使其使用不可变数据了。
在 Angular 中使用不可变数据
让我们看看我们目前如何在 TodoApp 组件中保持待办事项:
class TodoApp { 
  todos: Todo[] = [...]; 
  ... 
} 
我们使用 Todo 项的数组。JavaScript 数组是可变的,这意味着如果我们将其传递给使用 OnPush 策略的组件,在得到相同的输入引用时跳过更改检测是不安全的。例如,我们可能有使用相同待办事项列表的两个组件。由于列表是可变的,这两个组件都可以修改列表。这将在不执行更改检测的情况下导致任何组件的不一致状态。这就是为什么我们需要确保包含项目的列表是不可变的。为了确保 TodoApp 组件以不可变数据结构持有其数据,我们只需在 TodoApp 组件中做以下操作:
// ch5/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)机制及其所有各种功能。
我们将探讨以下主题:
- 
配置和创建提供者 
- 
注入由声明提供者实例化的依赖项 
- 
创建和配置注入器的底层 API 
- 
在我们的 UI 组件之间重用服务中定义的业务逻辑 
为什么我需要依赖注入?
假设我们有一个依赖于Engine和Transmission类的Car类。我们如何实现这个系统?让我们看看:
class Engine {...} 
class Transmission {...}
class Car { 
  engine; 
  transmission;
  constructor() { 
    this.engine = new Engine(); 
    this.transmission = new Transmission(); 
  } 
} 
在前面的示例中,我们在Car类的构造函数中创建了Car类的依赖项。尽管看起来很简单,但它远非灵活。每次我们创建Car类的实例时,在其构造函数中,都会创建相同Engine和Transmission类的实例。这可能会因为以下原因而成为问题:
- 
由于我们无法独立于其 engine和transmission依赖项对其进行测试,Car类变得难以测试。
- 
我们将 Car类与其依赖项的实例化逻辑耦合在一起。
Angular 中的依赖注入
我们还可以通过利用依赖注入模式来解决这个问题。我们已经从 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类装饰器,并为构造函数的参数提供了类型注解。
使用依赖注入的优点
还有一个步骤尚未完成,我们将在下一节中探讨。在此之前,让我们看看这种方法的优点:
- 
我们可以轻松地为测试环境或为实例化不同的 Car模型传递Car类的不同依赖项版本。
- 
我们与依赖项实例化的逻辑没有耦合。 
Car类只负责实现其自身的领域特定逻辑,而不是与额外的功能耦合,例如其依赖项的管理。我们的代码也变得更加声明性,更容易阅读。
既然我们已经意识到 DI 的一些好处,让我们看看为了让这段代码工作所缺少的部分。
声明提供者
在我们的 Angular 应用程序中,通过框架的 DI 机制实例化单个依赖项所使用的原始数据类型称为注入器。注入器包含一组提供者,它们封装了与令牌关联的已注册依赖项的实例化逻辑。我们可以将令牌视为注入器内注册的不同提供者的标识符。
在 Angular 中,我们可以使用@NgModule声明单个依赖的提供者。内部,Angular 将根据我们在模块中声明的提供者创建一个注入器。
让我们看看以下代码片段,它位于ch6/injector-basics/basics/app.ts:
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import {
  NgModule,
  Component,
  Inject,
  InjectionToken,
  Injectable
} from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
const BUFFER_SIZE = new InjectionToken<number>('buffer-size');
class Buffer {
  constructor(@Inject(BUFFER_SIZE) private size: Number) {
    console.log(this.size);
  }
}
@Injectable()
class Socket {
  constructor(private buffer: Buffer) {}
}
@Component({
  selector: 'app',
  template: ''
})
class AppComponent {
  constructor(private socket: Socket) {
    console.log(socket);
  }
}
@NgModule({
  providers: [{ provide: BUFFER_SIZE, useValue: 42 }, Buffer, Socket],
  declarations: [AppComponent],
  bootstrap: [AppComponent],
  imports: [BrowserModule]
})
class AppModule {}
platformBrowserDynamic().bootstrapModule(AppModule);
一旦你为本书设置了代码(有关说明,请参阅第五章,开始使用 Angular 组件和指令)并运行npm start,你就可以在http://localhost:5555/dist/dev/ch6/injector-basics/basics/地址上看到执行结果。当你打开浏览器的控制台时,你会看到以下这些行:
42
 Socket {buffer: Buffer}
在图 1中,我们可以看到AppComponent依赖于Socket类,而Socket类依赖于Buffer类,Buffer类又依赖于BUFFER_SIZE类:

图 1
我们将BUFFER_SIZE类常量的值设置为new InjectionToken<number>('buffer-size')。我们可以把BUFFER_SIZE的值看作是一个在应用程序中不能重复的唯一标识符。"InjectionToken"是 ES2015 中的Symbol类的替代品,因为在编写本书的时候,TypeScript 不支持它。"InjectionToken"提供了一个Symbol没有的额外功能:更好的类型检查;Angular 和 TypeScript 可以使用我们传递给InjectionToken的类型参数(在前面的例子中是number)来执行更复杂的类型检查算法。
我们定义了两个类:Buffer和Socket。Buffer类有一个构造函数,它只接受一个名为size的单个依赖项,其类型为number。为了在依赖项解析过程中添加额外的元数据(即提示 Angular 应该注入与BUFFER_SIZE令牌关联的值),我们使用@Inject参数装饰器。这个装饰器接受我们想要注入的依赖项的令牌。通常,这个令牌是依赖项的类型(即类的引用),但在某些情况下,它可以是不同类型的值。例如,在我们的案例中,我们使用了InjectionToken类的实例。
使用@Injectable装饰器
现在,让我们来看看Socket类。我们用@Injectable装饰器来装饰它。这个装饰器应该被任何接受依赖并通过 Angular 的 DI 机制注入依赖的类使用。
@Injectable装饰器向 Angular 暗示,一个给定的类接受应该通过框架的依赖注入机制注入的参数。这意味着如果我们省略@Injectable装饰器,Angular 的 DI 机制将不知道在实例化类之前需要解决类的依赖。
在 Angular 5 版本之前,@Injectable 装饰器与 TypeScript 编译器生成带有类型信息的元数据语义不同。尽管这是一个重要的细节,但它对我们使用框架的依赖注入机制或特定的 @Injectable 装饰器方式没有任何影响。
作为一条经验法则,当给定的类接受需要通过 Angular 的依赖注入机制注入的依赖项时,始终使用 @Injectable 装饰器。
引入前向引用
Angular 引入了前向引用的概念。这是由于以下原因所必需的:
- 
ES2015 类不会被提升 
- 
允许在声明依赖提供者之后解决声明的依赖项 
在本节中,我们将解释前向引用解决的问题以及我们如何利用它们。
现在,假设我们以相反的顺序定义了 Buffer 和 Socket 类:
// ch6/injector-basics/forward-ref/app.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 类声明之前,Buffer 标识符的值将是 undefined。这意味着在生成的 JavaScript 代码的解释过程中,Buffer 标识符的值将是 undefined:也就是说,作为依赖项的标记,框架将获得一个无效的值。
运行前面的代码片段将导致以下形式的运行时错误:
Error: Cannot resolve all parameters for Socket(?). Make sure they all have valid type or annotations.
解决这个问题的最佳方式是通过交换定义以正确的顺序。我们还可以利用 Angular 提供的解决方案:前向引用:
... 
import {forwardRef} from '@angular/core'; 
...
@Injectable() 
class Socket { 
  constructor(@Inject(forwardRef(() => Buffer)) 
    private buffer: Buffer) {} 
}
class Buffer {...} 
之前的代码片段展示了我们如何利用前向引用。我们只需要使用 @Inject 参数装饰器,并将 forwardRef 函数的调用结果作为参数。forwardRef 函数是一个高阶函数,它接受一个参数:另一个函数,该函数负责返回需要注入的依赖项(或更精确地说,与其提供者关联的依赖项)的标记。这样,框架提供了一种延迟解决依赖项(标记)类型的过程的方法。
依赖项的标记将在 Socket 需要实例化的第一次被解决,这与默认行为不同,在默认行为中,标记在给定类的声明时就需要。
配置提供者
现在,让我们看看一个与之前使用的示例类似,但具有不同语法配置的注入器示例:
@NgModule({
  // ...
  providers: [ 
    { provide: BUFFER_SIZE, useValue: 42 }, 
    { provide: Buffer, useClass: Buffer }, 
    { provide: Socket, useClass: Socket } 
  ]
  // ...
})
class AppModule {}
在这种情况下,在提供者内部,我们明确声明我们希望使用Buffer类来构建与Buffer标识符引用相等的令牌的依赖项。我们为与Socket令牌关联的依赖项做完全相同的事情;然而,这次我们提供了Socket类。这就是 Angular 在我们省略显式提供者声明并仅传递类引用时将如何操作。
明确声明用于创建同一类实例的类可能看起来毫无价值,并且鉴于我们迄今为止看到的示例,这将是完全正确的。然而,在某些情况下,我们可能希望为与给定令牌关联的依赖项的实例化提供不同的类。
例如,假设我们有一个在名为UserService的服务中使用的Http服务:
class Http {...} 
@Injectable() 
class UserService { 
  constructor(private http: Http) {} 
} 
@NgModule({
  // ...
  providers: [ 
    UserService, 
    Http 
  ]
})
class AppModule {}
现在,让我们追踪UserService服务实例化的过程,以防我们想在应用程序的某个地方注入它。
内部,Angular 将根据传递给@NgModule的提供者创建一个注入器:这就是 Angular 将用于实例化UserService服务的注入器。最初,提供者会发现UserService服务接受一个带有Http令牌的依赖项,因此提供者会尝试找到与该令牌关联的提供者。由于在同一注入器中存在这样的提供者,它将创建一个Http服务的实例并将其传递给UserService。
到目前为止一切顺利;然而,如果我们想测试UserService服务,我们实际上并不需要通过网络进行 HTTP 调用。在单元测试的情况下,我们可以提供一个模拟实现,它只会伪造这些 HTTP 调用。为了向UserService服务注入不同类的实例,我们可以将提供者的配置更改如下:
class DummyHttp {...} 
// ... 
@NgModule({
  // ...
  providers: [ 
    UserService, 
    { provide: Http, useClass: DummyHttp }
  ]
})
class TestingModule {}
在这种情况下,Angular 将再次根据传递给@NgModule的提供者创建一个注入器。这次的不同之处在于,我们用DummyHttp服务关联了Http令牌。现在当注入器实例化UserService时,它会在它维护的提供者列表中寻找与Http令牌关联的提供者,并发现它需要使用DummyHttp服务来创建所需的依赖项。当 Angular 发现我们已声明一个useClass提供者时,它将使用new DummyHttp()创建DummyHttp服务的实例。
此代码位于ch6/configuring-providers/dummy-http/app.ts。
使用现有提供者
另一种进行的方式是使用提供者配置对象的useExisting属性:
@NgModule({
  // ...
  providers: [ 
    DummyHttp, 
    { provide: Http, useExisting: DummyHttp }, 
    UserService 
  ]
})
class TestingModule {}
在前面的片段中,我们为三个令牌注册了提供者:DummyHttp、UserService和Http。我们声明我们想要将Http令牌绑定到现有的令牌DummyHttp。这意味着当请求Http服务时,注入器将找到用作useExisting属性值的令牌的提供者,并实例化它或获取与之关联的值(如果它已经被实例化)。我们可以将useExisting视为创建给定令牌的别名:
// ch6/configuring-providers/existing/app.ts
// ...
const dummyHttp = new DummyHttp();
@Component(...)
class AppComponent {
  constructor(private service: UserService) {
    console.log(service.http === dummyHttp);
  }
}
@NgModule({
  providers: [
    { provide: DummyHttp, useValue: dummyHttp },
    { provide: Http, useExisting: DummyHttp },
    UserService
  ],
  // ...
})
class AppModule {}
上述片段将创建Http令牌到DummyHttp令牌的别名。这意味着一旦请求Http令牌,调用将被转发到与DummyHttp令牌关联的提供者,它将被解析为dummyHttp的值。
useValue提供者返回设置到提供者声明中useValue属性的值。
定义用于实例化服务的工厂
现在,假设我们想要创建一个复杂对象,例如,一个代表传输层安全性(TLS)连接的对象。此类对象的一些属性包括套接字、一组加密协议和证书。在这个问题的背景下,我们迄今为止所查看的 Angular 的 DI 机制的功能可能看起来有点有限。
例如,我们可能需要配置TLSConnection类的一些属性,而不将其实例化过程与所有配置细节耦合(选择合适的加密算法、打开我们将通过它建立安全连接的 TCP 套接字等)。
在这种情况下,我们可以利用提供者配置对象的useFactory属性:
@NgModule({
  // ...
  providers: [
    { 
      provide: TLSConnection, 
      useFactory: function(
        socket: Socket, certificate: Certificate, crypto: Crypto) { 
          let connection = new TLSConnection(); 
          connection.certificate = certificate; 
          connection.socket = socket; 
          connection.crypto = crypto; 
          socket.open(); 
          return connection; 
        }, 
        deps: [Socket, Certificate, Crypto] 
    }, 
    { provide: BUFFER_SIZE, useValue: 42 },
    Buffer, 
    Socket, 
    Certificate, 
    Crypto 
  ]
})
class AppModule {}
最初的片段看起来可能有点复杂,但让我们一步一步地来看。我们可以从我们已经熟悉的部分开始:
@NgModule({
  // ...
  providers: [
    // ...
    { provide: BUFFER_SIZE, useValue: 42 },
    Buffer, 
    Socket, 
    Certificate, 
    Crypto 
  ]
})
class AppModule {} 
初始时,我们注册了多个提供者:Buffer、Socket、Certificate和Crypto。就像在之前的例子中一样,我们也注册了BUFFER_SIZE令牌并将其关联到值42。这意味着我们已经在我们的应用程序中类的构造函数中注入了Buffer、Socket、Certificate和Crypto类型的依赖项。
我们可以通过以下方式创建和配置TLSConnection对象的一个实例:
let connection = new TLSConnection();
connection.certificate = certificate;
connection.socket = socket;
connection.crypto = crypto;
socket.open();
return connection;
现在,为了允许 Angular 使用前面的片段来实例化TLSConnection,我们可以使用提供者配置对象的useFactory属性。这样,我们可以指定一个函数,在其中我们可以手动创建与提供者令牌关联的对象的实例。
我们可以使用useFactory属性与deps属性一起指定要传递给工厂的依赖项:
{
  provide: TLSConnection,
  useFactory: function (socket: Socket, certificate: Certificate, crypto: Crypto) { 
    // ... 
  }, 
  deps: [Socket, Certificate, Crypto] 
}
在前面的代码片段中,我们定义了用于 TLSConnection 实例化的工厂函数。作为依赖项,我们声明了 Socket、Certificate 和 Crypto。这些依赖项由 Angular 的 DI 机制解析并注入到工厂函数中。您可以查看整个实现并在 ch6/configuring-providers/factory/app.ts 中尝试它。
值得注意的是,内部,Angular 将 useClass 提供者转换为 useFactory。Angular 在 deps 数组中列出类的依赖项,并使用 new 操作符调用该类,将工厂接收到的依赖项作为参数传递。
声明可选依赖项
Angular 引入了 @Optional 装饰器,它允许我们处理没有与它们关联已注册提供者的依赖项。假设一个提供者的依赖项在任何负责其实例化的目标注入器中都不可用。如果我们使用 @Optional 装饰器,在依赖提供者的实例化过程中,缺失的依赖项的值将被传递为 null。
现在,让我们看一下以下示例:
abstract class SortingAlgorithm {
  abstract sort(collection: BaseCollection): Collection;
}
class BaseCollection {
  getDefaultSort(): SortingAlgorithm {
    // get some generic sorting algorithm...
    return null;
  }
}
class Collection extends BaseCollection {
  public sort: SortingAlgorithm;
  constructor(sort: SortingAlgorithm) {
    super();
    this.sort = sort || this.getDefaultSort();
  }
}
@Component({
  selector: 'app',
  template: "Open your browser's console"
})
class AppComponent {
  constructor(private collection: Collection) {
    console.log(collection);
  }
}
@NgModule({
  providers: [Collection],
  declarations: [AppComponent],
  bootstrap: [AppComponent],
  imports: [BrowserModule]
})
class AppModule { }
在这种情况下,我们定义了一个名为 SortingAlgorithm 的抽象类和一个名为 Collection 的类,它作为依赖项接受一个扩展 SortingAlgorithm 的具体类的实例。在 Collection 构造函数内部,我们将 sort 实例属性设置为传递的依赖项或默认排序算法实现。
我们在声明的 @NgModule 提供者中没有为 SortingAlgorithm 标记定义任何提供者。因此,如果我们想在 AppComponent 中注入 Collection 类的实例,我们将得到一个运行时错误。这意味着,如果我们想使用框架的 DI 机制获取 Collection 类的实例,我们必须为 SortingAlgorithm 标记注册一个提供者,尽管我们可能希望回退到由 getDefaultSort 方法返回的默认排序算法。
Angular 通过 @Optional 装饰器提供了这个问题的解决方案。以下是我们可以如何使用它来解决这个问题:
// ch6/decorators/optional/app.ts
@Injectable() 
class Collection extends BaseCollection { 
  private sort: SortingAlgorithm;
  constructor(@Optional() sort: SortingAlgorithm) { 
    super(); 
    this.sort = sort || this.getDefaultSort(); 
  } 
} 
在前面的代码片段中,我们将 sort 依赖项声明为可选的,这意味着如果 Angular 没有找到任何为其标记提供提供者的,它将传递 null 值。
理解多提供者
多提供者(Multiproviders)是 Angular 依赖注入(DI)机制中引入的另一个新概念。它们允许我们将多个提供者与同一个令牌关联起来。如果我们正在开发一个带有一些默认服务实现的第三方库,但希望用户能够用自定义实现来扩展它,这将非常有用。例如,在 Angular 的表单模块中,多提供者专门用于对单个控件声明多个验证。我们将在 第七章,使用 Angular 路由和表单,和 第八章,解释管道和与 RESTful 服务通信中解释这个模块。
多提供者的另一个适用用例示例是 Angular 在其 Web Workers 实现中用于事件管理。用户为事件管理插件创建多提供者。每个提供者返回不同的策略,支持不同的事件集(触摸事件、键盘事件等)。一旦发生特定事件,Angular 可以选择处理该事件的适当插件。
让我们看看一个示例,它说明了多提供者(multiproviders)的典型用法:
// ch6/configuring-providers/multi-providers/app.ts
const VALIDATOR = new InjectionToken('validator'); 
interface EmployeeValidator { 
  (person: Employee): string; 
} 
class Employee {...} 
@NgModule({
  providers: [
    {
      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
  ],
  declarations: [AppComponent],
  bootstrap: [AppComponent],
  imports: [BrowserModule]
})
class AppModule { }
在前面的代码片段中,我们声明了一个名为 VALIDATOR 的常量,并将其值设置为 InjectionToken 的新实例。我们还创建了一个 @NgModule,在其中注册了三个提供者:其中两个提供者提供基于不同标准的函数,这些函数用于验证 Employee 类的实例。这些函数的类型是 EmployeeValidator。
为了声明我们希望注入器将所有注册的验证器传递给 Employee 类的构造函数,我们需要使用以下 constructor 定义:
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 参数装饰器。
之后,我们可以像往常一样注入 Employee 类型的实例:
@;Component({
  selector: 'app',
  template: '...'
})
class AppComponent {
  constructor(private employee: Employee) {
    console.log(employee);
  }
}
子注入器和可见性
在本节中,我们将探讨如何构建注入器的层次结构。在 AngularJS 中,这个概念没有替代方案。每个注入器可以有零个或一个父注入器,每个父注入器可以有一个或多个子注入器。与 AngularJS 中所有注册的提供者都存储在扁平结构中不同,在 Angular 中它们存储在树形结构中。扁平结构更为有限;例如,它不支持令牌的命名空间;我们不能为相同的令牌声明不同的提供者。到目前为止,我们已经看到了一个没有子注入器或父注入器的注入器示例。现在,让我们构建一个注入器的层次结构。
为了更好地理解注入器的这种分层结构,让我们看一下下面的图示:

图 2
在这里,我们看到一个树,每个节点都是一个注入器,这些注入器都保留对其父注入器的引用。House 注入器有三个子注入器:Bathroom、Kitchen 和 Garage。
Garage 有两个子注入器:Car 和 Storage。我们可以将这些注入器视为内部注册了提供者的容器。
假设我们想要获取与 Tire 令牌关联的提供者的值。如果我们使用 Car 注入器,这意味着 Angular 的依赖注入机制将尝试在 Car 以及其所有父注入器 Garage 和 House 中找到与该令牌关联的提供者,直到达到根注入器。
实例化注入器
内部,Angular 构建了这个注入器的层次结构,但所有操作都是隐式的。为了我们自己实现这一点,我们将不得不使用较低级别的 API,这对于我们的日常开发过程来说是不寻常的。
首先,让我们创建一个注入器的实例,以便使用它来实例化注册的令牌:
// ch6/manual-injector/instantiate.ts
import { Injector } from '@angular/core';
// ...
const injector = Injector.create([
  { provide: BUFFER_SIZE, useValue: 42 },
  {
    provide: Buffer,
    deps: [BUFFER_SIZE],
    useFactory: function (size: number) {
      return new Buffer(size);
    }
  },
  {
    provide: Socket,
    deps: [Buffer],
    useFactory: function (buffer: Buffer) {
      return new Socket(buffer);
    }
  }
]);
在这里,我们首先从 @angular/core 中导入 Injector。这个抽象类有一个名为 create 的静态方法,用于注入器的实例化。在 create 方法内部,我们传递一个提供者数组作为参数。我们可以看到从 配置提供者 部分已经熟悉的语法。
我们声明一个提供者用于 BUFFER_SIZE,使用值 42;我们声明一个 Buffer 的工厂,并列出其所有依赖项(在这种情况下,只有 BUFFER_SIZE);最后,我们还声明了一个 Socket 的工厂提供者。create 方法将创建一个 StaticInjector 的实例,我们可以使用它来获取单个 tokens 的实例。提醒一下,注入器是包含单个提供者的抽象,并且知道如何实例化与它们关联的依赖项。
在前面的例子中,一个重要的细节是,在StaticInjector中,我们只能使用有限类型的提供者,例如,我们不能使用useClass提供者。这是因为 Angular 使用StaticInjector与提供者的标准化版本一起使用,而useClass的标准化版本是useFactory。内部,Angular 会收集传递给@NgModule的提供者,将它们转换为它们的标准化版本,并实例化StaticInjector。
构建注入器层次结构
为了更好地理解段落,让我们看看这个简单的例子:
// ch6/manual-injector/simple-example.ts
class Http { }
class UserService {
  constructor(public http: Http) { }
}
const parentInjector = Injector.create([{
  provide: Http,
  deps: [],
  useFactory() {
    return new Http();
  }
}]);
const childInjector = Injector.create([{
  provide: UserService,
  deps: [Http],
  useFactory(http) {
    return new UserService(http);
  }
}], parentInjector);
console.log(childInjector.get(UserService));
console.log(childInjector.get(Http) === parentInjector.get(Http));
省略了导入,因为它们对于解释代码不是必需的。我们有两个服务,Http和UserService,其中UserService依赖于Http服务。
初始时,我们使用Injector类的create静态方法创建一个注入器。我们向这个注入器传递一个带有Http令牌的工厂提供者。稍后,再次使用create,我们通过传递包含UserService提供者的数组来实例化子注入器。请注意,作为第二个参数,我们传递了parentInjector常量,因此我们得到了与前面图中Garage和House之间相同的关系:parentInjector是childInjector的父级。
现在,使用childInjector.get(UserService),我们能够获取与UserService令牌关联的值。同样,使用childInjector.get(Http)和parentInjector.get(Http),我们获取与Http令牌关联的相同值。这意味着childInjector会向其父级请求请求令牌关联的值。
然而,如果我们尝试使用parentInjector.get(UserService),由于它的提供者在childInjector中注册,我们将无法获取与令牌关联的值。
使用组件和指令进行依赖注入
在第五章“使用 Angular 组件和指令入门”中,当我们开发我们的第一个 Angular 指令时,我们看到了如何利用 DI 机制将服务注入到我们的 UI 相关构建块(即指令和组件)中。
让我们快速回顾一下我们之前做了什么,但从一个 DI 的角度来看:
// ch5/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 的 DI 机制注入。
Tooltip 类的依赖声明与我们在前面的章节中所做的一样:我们只是将它们作为类的构造函数的参数列出。然而,请注意,在这种情况下,我们没有为 ElementRef 标记进行任何显式的提供者声明,我们只有一个为 Overlay 标记的提供者,它在 App 组件的元数据中声明。在这种情况下,Angular 内部创建并配置了所谓的 元素注入器。
介绍元素注入器
在底层,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装饰器,我们可以声明我们想要从当前注入器或任何父注入器中检索给定的依赖项,直到我们达到最近父组件的注入器。
此外,Angular 的 API 允许我们通过viewProviders属性,它是@Component装饰器的配置对象的一部分,在组件树中更具体地指定提供者的可见性。
视图提供者与提供者
让我们看看一个名为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。
在以下代码片段中,我们可以找到组件实现的所有重要细节:
// ch6/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> 
以下Markdown服务将无法通过位于panel-content元素下方的MarkdownButton访问;然而,如果我们使用组件模板中的按钮,它将是可访问的:
@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即可。
你可以在ch6/directives/app.ts目录下的示例目录中找到这个例子。
注意,对于任何组件或指令,我们可以使用传递给@Component或@Directive装饰器的对象字面量的providers属性来覆盖在@NgModule中声明的现有提供者。如果我们只想为给定组件的视图子项覆盖特定的提供者,我们可以使用viewProviders。
使用@SkipSelf装饰器
有时候在层次结构中,我们在不同的注入器中为相同的令牌定义了提供者。例如,假设我们有前面的示例,但具有以下注入器配置:
@Component({
  selector: 'markdown-panel',
  viewProviders: [{ provide: Markdown, useValue: null }],
  // ...
})
class MarkdownPanel {
  constructor(private el: ElementRef, private md: Markdown) { }
  // ...
}
@Component({
  selector: 'app',
  providers: [Markdown],
  // ...
})
class App {
  constructor() { }
}
在前面的例子中,如果我们尝试在MarkdownPanel的构造函数中注入Markdown服务,我们会得到null,因为这是与组件元数据中viewProviders声明中的Markdown令牌关联的值。
然而,请注意,在App组件中,我们还有另一个提供者声明,它将被用于实例化App组件的ElementInjector。我们如何使用在App组件元数据中声明的Markdown提供者而不是在MarkdownPanel元数据中声明的提供者?我们只需要在MarkdownPanel的构造函数中添加@SkipSelf()装饰器。这将提示 Angular 跳过当前注入器,并在层次结构中向上查找与所需令牌关联的提供者:
@Component(...)
class MarkdownPanel {
  constructor(private el: ElementRef, @SkipSelf() private md: Markdown) { }
}
Angular 还提供了@Self装饰器,它向框架提示从当前注入器获取给定令牌的提供者。在这种情况下,如果 Angular 在当前注入器中找不到提供者,它将抛出一个错误。
摘要
在本章中,我们介绍了 Angular 的依赖注入(DI)机制。我们通过在框架的上下文中介绍它,简要讨论了在我们的项目中使用 DI 的优点。我们旅程的第二步是如何使用@NgModule配置注入器;我们还解释了注入器的层次结构和注册提供者的可见性。为了强制更好的关注点分离,我们提到了如何在我们的指令和组件中注入携带我们应用程序业务逻辑的服务。
在下一章中,我们将介绍框架的新路由机制。我们将解释如何配置基于组件的路由器并将多个视图添加到我们的应用程序中。我们还将涵盖另一个重要主题,即新的表单模块。通过构建一个简单的应用程序,我们将演示如何创建和管理表单。
第七章:使用 Angular 路由和表单进行工作
到目前为止,我们已经熟悉了框架的核心。我们知道如何定义组件和指令来开发我们应用程序的视图。我们还知道如何将业务逻辑封装到服务中,并使用 Angular 的 DI 机制将一切连接起来。
在本章中,我们将解释一些将帮助我们构建真实 Angular 应用程序的概念。它们如下:
- 
框架的基于组件的路由 
- 
使用 Angular 的表单模块 
- 
开发自定义表单验证器 
- 
开发模板驱动表单 
让我们开始吧!
开发 "Coders repository" 应用程序
在解释列出的概念的过程中,我们将开发一个包含开发者库的示例应用程序。在我们开始编码之前,让我们讨论应用程序的结构。
"Coders repository" 将允许其用户通过填写包含他们详细信息的表单或提供开发者的 GitHub 处理程序并从 GitHub 导入他们的资料来添加开发者。
为了本章的目的,我们将把开发者的信息存储在内存中,这意味着在页面刷新后,我们将丢失会话期间存储的所有数据。
应用程序将具有以下视图:
- 
所有开发者的列表 
- 
用于添加或导入新开发者的视图 
一个显示给定开发者详细信息的视图。这个视图有两个子视图:
- 
基本详情:显示开发者的姓名以及如果有的话他们的 GitHub 头像 
- 
高级资料:显示开发者所知的所有详细信息 
应用程序主页的最终结果将如下所示:

图 1
在本章中,我们将只构建列出的几个视图。应用程序的其余部分将在 第八章 中解释,解释管道和与 RESTful 服务通信。
每个开发者都将是一个以下类的实例:
// ch7/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 类中:
// ch7/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 路由连接在一起。
让我们从路由配置所需的导入开始,并在下面定义根组件:
// ch7/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。你可以在本书的最后一章中找到更多关于服务器端渲染的信息。
现在,让我们定义一个启动组件并配置应用程序的根模块:
// ch7/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/ch7/multi-page-template-driven/'
    },
    { provide: LocationStrategy, useClass: HashLocationStrategy }
  ]
})
class AppModule {}
在这里,APP_BASE_HREF代表应用程序的基本路径。例如,在我们的情况下,“Coders repository”将位于/dist/dev/ch7/multi-page-template-driven/目录下(或者,如果我们包括方案和主机,http://localhost:5555/dist/dev/ch7/multi-page-template-driven/)。
我们需要提供APP_BASE_HREF的值,以便向 Angular 提示路径的哪一部分是应用程序路由(即对路由器有意义的)。例如,对于http://localhost:5555/dist/dev/ch7/multi-page-template-driven/home URL,如果APP_BASE_HREF等于/dist/dev/ch7/multi-page-template-driven/,Angular 将知道它需要提供与home路径关联的组件,因为 URL 的其余部分与应用程序中声明的路由无关。
配置路由
作为下一步,让我们更新路由的声明。打开ch7/step-0/app.ts并更新RouteModule的forRoot方法的调用:
// ch7/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组件,这些组件在路由器的配置中被引用。首先,我们将提供一个基本的实现,我们将在本章的进程中逐步扩展它。在ch7/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组件的模板,以便将一切连接起来。
将以下内容添加到ch7/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/ch7/step-0/,你应该会看到一个类似于以下截图的页面:

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

图 3
我们可以注意到,在初始页面加载期间,Angular 下载了与所有路由关联的组件,甚至包括不需要的AddDeveloper组件。这是因为,在app.ts中,我们明确地要求了Home和AddDeveloper组件,并在路由的声明中使用它们。
在这个特定情况下,加载这两个组件可能看起来不是什么大问题,因为在这个阶段,它们非常精简,没有任何依赖。然而,在实际应用中,它们将导入其他指令、组件、管道、服务,甚至第三方库。一旦任何组件被要求,它的整个依赖图都将被下载,即使此时该组件并不需要。
Angular 的路由器提供了一个解决方案来解决这个问题:
// ch7/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 提供的位置下载模块。当加载器返回的承诺解析为目标模块的值时,模块将被缓存,其引导组件将被渲染。下次用户导航到相同的路由时,将使用缓存的模块,因此路由模块不会下载相同的组件两次。
注意 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 成功下载后,路由器将自动开始预取所有其他路由。所以,下次用户导航到不同的页面时,它很可能已经在内存中可用。这将几乎不花费任何成本地提高用户体验。
通过提供 PreloadingStrategy 抽象类(位于 @angular/router 包中)的自定义实现,我们可以为懒加载模块的预取引入自定义机制。
RouterModule.forRoot 与 RouterModule.forChild
我们可以使用 RouterModule 调用两种方法来注册路由。
如果我们声明应用程序的顶级路由,我们需要使用 RouterModule.forRoot。此方法将注册顶级路由,并返回应该由应用程序的根模块导入的路由模块。
如果我们想在懒加载模块中定义路由并导入 forRoot 方法调用的模块,我们将得到一个运行时错误。这是因为 forRoot 方法将返回一个包含提供者的模块,这些提供者应该只由顶级模块导入一次。为了在懒加载模块中注册嵌套路由,我们需要使用 forChild 方法。
我们将在第八章 解释管道和与 RESTful 服务通信中进一步探讨如何定义嵌套路由。
作为一项经验法则,我们可以记住 RouterModule.forRoot 是用于注册顶级路由的,而 RouterModule.forChild 应仅用于在懒加载模块中注册嵌套路由。
使用 Angular 的表单模块
现在,让我们继续应用程序的实现。对于下一步,我们将处理 AddDeveloper 和 Home 组件。你可以通过扩展当前在 ch7/step-0 中的内容来继续你的实现,或者如果你还没有达到步骤 1,你可以继续在 ch7/step-1 中的文件上工作。
Angular 提供了两种带有验证的开发表单的方式:
- 
模板驱动方法:这提供了一个声明式 API,其中我们将验证声明到组件的模板中 
- 
模型驱动方法(也称为响应式表单):这提供了一个命令式、响应式的 API 
让我们先从模板驱动方法开始,并在下一章中探索模型驱动方法。
开发模板驱动表单
表单对于每个 创建、检索、更新和删除(CRUD)应用程序都是必不可少的。在我们的案例中,我们想要构建一个表单来输入我们想要存储的开发者的详细信息。
到本节结束时,我们将有一个表单,允许我们输入指定开发者的真实姓名,添加他们偏好的技术,输入他们的电子邮件,并声明他们是否在社区中受欢迎或尚未。最终结果如下所示:

图 4
将以下导入添加到 app.ts 文件中:
import {FormsModule} from '@angular/forms';
我们接下来需要做的是在 AppModule 类中导入 FormsModule。FormsModule 类包含一组用于管理 Angular 表单的预定义指令,例如 form 和 ngModel 指令。FormsModule 类还声明了一个数组,其中包含一组预定义的与表单相关的提供者,我们可以在应用程序中使用这些提供者。
在导入 FormsModule 类之后,我们的 app.ts 文件将看起来像这样:
// ch7/step-2/app.ts
@NgModule({
  imports: [BrowserModule, FormsModule, routingModule],
  declarations: [App, Home, AddDeveloper, ControlErrors],
  providers: [{
    provide: LocationStrategy,
    useClass: HashLocationStrategy
  }],
  bootstrap: [App]
})
class AppModule {}
现在,更新 AddDeveloper 的实现如下:
// ch7/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 | 控件上附加的所有验证器都检测到值是有效的 | 
| ng-invalid | 控件上附加的任何验证器都检测到值是无效的 | 
根据此表,我们可以定义我们希望所有具有无效值的输入控件在以下方式下具有红色边框:
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-.]+$/。
在ch7/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 令牌定义了一个多提供者。一旦我们注入与该令牌关联的值,我们将得到一个包含所有附加到给定控件(作为参考,请参阅第六章[3ad730a8-f797-4fc1-b908-5a20eeffac23.xhtml],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 选择输入
作为下一步,我们应该允许应用程序的用户输入输入开发者最擅长的技术。我们可以定义一个技术列表,并在表单中以选择输入的形式显示它们。
在 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 会根据选择输入的状态添加相同的类。为了在选择元素值无效时显示红色边框,我们需要修改 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 属性,这些属性取决于表单内定义的各个控制。例如,如果表单内的控制都没有被 touched,那么表单本身将显示为 untouched 状态。然而,如果表单中的任何控制至少被 touched 一次,表单将显示其状态为 touched。同样,只有当表单的所有控制都有效时,表单才是有效的。
为了说明 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 元素的 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属性声明的控件的名称。一旦我们有了目标控件实例的引用,我们可以检查其状态是否为touched(即是否已被聚焦),如果是,我们可以遍历ControlErrors实例的errors属性中的所有错误。map函数将返回一个包含错误消息或null值的数组。剩下要做的就是过滤掉所有的null值,只获取错误消息。一旦我们获取了每个错误的错误消息,我们将通过从errorMessages数组中弹出最后一个来返回它。
最终结果应该如下所示:

图 5
如果你在实现ControlErrors组件的过程中遇到任何问题,你可以查看其实现,位置在ch7/step-2/control_errors.ts。
每个控件的hasError方法接受一个错误消息标识符作为参数,该标识符由相应的验证器定义。例如,在我们的例子中,我们定义了自定义的电子邮件验证器,当输入控件具有无效值时,我们返回{ 'invalidEmail': true }对象字面量。如果我们将ControlErrors组件应用于电子邮件控件,其声明应该如下所示:
  <control-errors control="email" 
    [errors]="{
      'invalidEmail': 'Invalid email address'
    }"></control-errors> 
Angular 的双向数据绑定
关于 Angular 最著名的谣言之一是双向数据绑定功能被移除,因为强制单向数据流。这并不完全正确;Angular 的表单模块实现了一个带有[(ngModel)]选择器的指令(我们也将这个指令称为NgModel,因为它控制器的名字),这使我们能够轻松实现双向数据绑定:从视图到模型,以及从模型到视图。
让我们看看以下简单的组件:
// ch7/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名称与foobar表达式的评估结果设置到属性(或,在 Angular 组件的术语中,输入)的值来实现单向数据绑定。[(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/ch7/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事件来声明它:
<!-- ch7/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值。
现在,假设我们已经为表单中的所有输入控件输入了有效的值。这意味着其submit按钮将被启用。一旦我们按下Enter或点击submit按钮,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 repository”的前页上展示所有开发者的列表。
打开ch7/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
你可以在ch7/multi-page-template-driven中找到应用程序的完整实现。
摘要
到目前为止,我们已经解释了 Angular 中路由的基础。我们查看如何定义不同的路由并实现与之关联的组件,这些组件在路由更改时显示。为了链接到不同的路由,我们引入了routerLink,我们还使用了router-outlet指令来指出与单个路由关联的组件应该在哪里渲染。
我们还查看了一下 Angular 表单的内置和自定义验证功能。在此之后,我们解释了NgModel指令,它为我们提供了双向数据绑定。
在下一章中,我们将介绍如何开发模型驱动的表单和子以及参数化路由,使用Http模块,以及使用自定义管道转换数据。
第八章:解释管道和与 RESTful 服务通信
在前一章中,我们介绍了框架的一些非常强大的功能。然而,我们可以进一步深入到 Angular 表单模块和路由的功能。在本章中,我们将解释我们如何执行以下操作:
- 
开发模型驱动表单 
- 
定义参数化路由 
- 
定义子路由 
- 
使用 HTTP 模块与 RESTful API 通信 
- 
使用自定义管道转换数据 
我们将在扩展“Coders repository”应用程序功能的过程中探讨所有这些概念。在前一章的开始,我们提到我们将允许从 GitHub 导入开发者。然而,在我们实现这个功能之前,让我们扩展表单的功能。
在 Angular 中开发模型驱动表单
这些将是完成“Coders repository”的最后几步。您可以在ch7/step-1/(或ch7/step-2,取决于您之前的工作)提供的代码基础上构建,以扩展应用程序的功能,我们将介绍新的概念。完整的示例位于ch8/multi-page-model-driven。
这就是我们将在本节结束时实现的结果:

图 1
在前面的屏幕截图中,有两个表单:
- 
该表单包含以下控件: - 
用于 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的constructor中有一个额外的参数,名为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 导入开发者的表单 
- 
当当前活动(或启用)的表单无效时,禁用 submit按钮
我们将探讨如何使用 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 中,我们使用当前 fetchFromGitHub 复选框的值调用 toggleControls 方法。我们可以通过获取 importDevForm 中 controls 的 fetchFromGitHub 属性来获取表示复选框的 AbstractControl 的引用。
之后,我们通过传递一个回调给其 subscribe 方法来订阅复选框的 valueChange 事件。每次复选框的值发生变化时,我们传递给 subscribe 的回调将被调用。
之后,在 ngOnDestroy 中,我们取消订阅 valueChange 订阅,以防止我们的代码出现内存泄漏。
最后,最有趣的事情发生在 toggleControls 方法中。我们将一个标志传递给此方法,该标志指示我们是否希望 importDevForm 被启用。如果我们希望表单被启用,我们只需要调用 githubHandle 控件的 enable 方法,并禁用 addDevForm 中的所有 controls。我们可以通过遍历控制名称(即 addDevForm 的 controls 属性的键)来禁用 addDevForm 中的所有 controls,获取每个单独名称的相应控件实例,并调用其 disable 方法。如果 importEnabled 标志的值为 false,我们将执行完全相反的操作,通过调用 addDevForm 中的 controls 的 enable 方法和 importDevForm 中控件的 disable 方法。
探索 Angular 的 HTTP 模块
现在,在我们开发了两个表单——导入现有开发者和添加新开发者之后,是时候在组件的控制器中实现它们背后的逻辑了。
为了这个目的,我们需要与 GitHub API 进行通信。虽然我们可以直接从组件的控制器中这样做,但通过这种方式解决问题,我们会将视图与 GitHub 的 RESTful API 耦合起来。为了强制更好的关注点分离,我们可以将用于与 GitHub 通信的逻辑提取到一个单独的服务中,称为GitHubGateway。打开名为github_gateway.ts的文件,并输入以下内容:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
export interface GitHubUser {
  id: number;
  login: string;
  email: string;
  followers: number;
  name: string;
  avatar_url: string;
}
@Injectable()
export class GitHubGateway {
  constructor(private http: HttpClient) { }
  getUser(username: string): Observable<GitHubUser> {
    return this.http.get<GitHubUser>(`https://api.github.com/users/${username}`);
  }
}
初始时,我们从@angular/common/http模块导入HttpClient类。所有与 HTTP 相关的功能都被外部化到 Angular 的核心之外。
在此之后,我们声明GitHubUser接口。我们使用它来描述 GitHub 返回的预期响应类型。在这种情况下,我们手动创建接口声明;然而,通常这样的接口是通过 API 模式(例如 OpenAPI)生成的,这些模式在前端和后端之间共享。
在接受GitHubGateway依赖后,需要通过框架的 DI 机制注入,我们将使用@Injectable装饰器装饰类。
我们使用的 GitHub API 的唯一功能是用于获取用户的功能,因此我们定义了一个名为getUser的单个方法。它接受开发者的 GitHub 处理程序作为参数。
注意,如果你每天向 GitHub 的 API 发送超过 60 个请求,你可能会遇到这个错误:GitHub API 速率限制超出。这是由于没有 GitHub API 令牌的请求速率限制。有关更多信息,请访问github.com/blog/1509-personal-api-tokens。
在getUser方法内部,我们使用在constructor中接收到的HttpClient服务实例。请注意,客户端的get方法有一个类型参数,并返回Observable。类型参数的目的是指示响应的类型,该响应被包裹在Observable中。作为预期类型,我们设置了GitHubUser接口。使用可观察对象而不是承诺来为HttpClient提供一些好处;例如,考虑以下好处:
- 
可观察对象按设计是可取消的 
- 
可观察对象可以轻松重试 
- 
我们可以映射和过滤从给定请求接收到的响应 
HttpClient服务的完整 API 可以在angular.io/api/common/http/HttpClient找到。
使用 Angular 的 HTTP 模块
现在,让我们实现从 GitHub 导入现有开发者的逻辑。首先,我们需要在我们的AppModule类中导入HttpClientModule:
import {HttpClientModule} from '@angular/common/http';
...
@NgModule({
  imports: [..., HttpClientModule],
  declarations: [...],
  providers: [...],
  bootstrap: [...]
})
class AppModule {}
...
然后,打开ch7/step-2/add_developer.ts文件,并输入以下导入:
import {GitHubGateway} from './github_gateway'; 
将GitHubGateway添加到AddDeveloper组件提供者的列表中:
@Component({ 
  ... 
  providers: [GitHubGateway] 
}) 
class AddDeveloper {...} 
作为下一步,我们必须在类的constructor中包含以下参数:
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方法中,我们将调用委托给HttpClient服务的get方法,该方法返回Observable。为了获取Observable将要推送的结果,我们需要将其subscribe方法传递一个回调函数:
this.githubAPI.getUser(model.githubHandle) 
  .subscribe((res: GitHubUser) => { 
    // "res" contains the response of the GitHub's API.
  }); 
在前面的代码片段中,我们首先执行一个 HTTP GET请求。之后,我们得到相应的Observable实例,在一般情况下,将发出一系列值;在这种情况下,只有一个——响应的主体,解析为 JSON。如果请求失败,那么我们将得到一个错误。
注意,为了减少 Angular 的包大小,谷歌团队只在框架中包含了 RxJS 的核心。为了使用map和catch方法,你需要在add_developer.ts中添加以下导入:import 'rxjs/add/operator/map'; 和 import 'rxjs/add/operator/catch';。
请记住,RxJS 版本 5.5 引入了所谓的可订阅操作符,这允许我们使用命名导入导入操作符,与前面的示例相比,它使用具有副作用导入。这是一个向前的重大步骤,提高了类型安全性。更多关于可订阅操作符的信息可以在这里找到:
github.com/ReactiveX/rxjs/blob/master/doc/lettable-operators.md
现在,让我们实现传递给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 返回的对象与我们在应用程序中代表开发者的映射。我们认为如果一个开发者拥有超过1000个关注者,那么他就是受欢迎的。
addDeveloper方法的整个实现可以在ch8/multi-page-model-driven/add_developer.ts中找到。
为了处理失败的请求,我们可以使用 Observable 实例的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的组件,并在控制器中调用。注意,在控制器的constructor函数中,通过 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的实现。第二个组件的实现可以在ch8/multi-page-model-driven/developer_advanced_info.ts中找到:
import {Component, Inject, OnInit, 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 implements OnInit {
  dev: Developer;
  constructor(@Inject(forwardRef(() => DeveloperDetails))
     @Host() private parent: DeveloperDetails) {
  }
  ngOnInit() {
    this.dev = this.parent.dev;
  }
}
在前面的代码片段中,我们使用@Inject参数装饰器注入父组件。在@Inject内部,我们使用forwardRef,因为我们有developer_basic_info和developer_details包之间的循环依赖(在developer_basic_info中,我们导入developer_details,在developer_details中,我们导入developer_basic_info)。
我们需要一个对父组件实例的引用,以便获取与所选路由对应的当前开发者的实例。我们在ngOnInit生命周期钩子中获取这个引用。
使用管道转换数据
现在是时候看看 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:这是一个具有状态的管道,它接受一个- Observable对象或一个承诺;我们将在本章末尾探讨它。
开发具有状态的管道
所提到的所有管道中有一个共同点——每次我们将它们应用于相同的值并传递相同的参数集时,它们都返回完全相同的结果。这样的管道,具有引用透明性属性,被称为 纯管道。
@Pipe 装饰器接受一个 { name: string, pure?: boolean } 类型的对象字面量,其中 pure 属性的默认值是 true。这意味着当我们定义任何给定的管道时,我们可以声明它是具有状态的还是无状态的。纯属性很重要,因为如果管道不产生副作用并且在应用相同的参数集时返回相同的结果,则可以优化变更检测。
现在,让我们构建一个具有状态的管道。我们的管道将向一个 JSON API 发送 HTTP get 请求。为此,我们将使用 @angular/common/http 模块。
注意,在管道中包含业务逻辑不被认为是最佳实践。这种类型的逻辑应该提取到服务中。这里的例子仅用于学习目的。
在这种情况下,管道需要根据请求的状态(即它是否挂起或完成)保持一个状态。我们将以下这种方式使用管道:
{{ "http://example.com/user.json" | fetchJson | json }} 
这样,我们在 URL 上应用了fetchJson管道。一旦我们有了响应体,我们就可以在它上面应用json管道。这个例子还展示了我们如何使用 Angular 链式管道。
与无状态管道类似,对于有状态管道的开发,我们必须用@Pipe装饰实现管道逻辑的类,并实现PipeTransform接口。这次,由于 HTTP 请求功能,我们还需要从@angular/common/http模块导入HttpClient类:
import {Pipe, PipeTransform} from '@angular/core'; 
import {HttpClient} from '@angular/common/http'; 
import 'rxjs/add/operator/toPromise'; 
每次将fetchJson管道应用于具有不同值的参数时,我们都需要发起一个新的 HTTPget请求。这意味着作为管道的状态,我们需要至少保留远程服务的响应值和最后一个 URL 的值:
@Pipe({ 
  name: 'fetchJson', 
  pure: false 
}) 
export class FetchJsonPipe implements PipeTransform { 
  private data: any; 
  private prevUrl: string = null; 
  constructor(private http: HttpClient) {} 
  transform(url: string): any {...} 
} 
我们需要实现的唯一逻辑是transform方法:
... 
transform(url: string): any { 
  if (this.prevUrl !== url) { 
    this.http.get(url).toPromise(Promise) 
      .then(result => this.data = result); 
    this.prevUrl = url; 
  } 
  return this.data || {}; 
} 
... 
在其中,我们最初比较作为参数传递的 URL 与我们已有的一个(默认情况下,其值将为null)。如果它们不同,我们将使用传递给constructor函数的本地HttpClient类的实例发起一个新的 HTTPget请求。一旦请求完成,我们将data属性设置为结果。
现在,假设管道已经开始了一个get请求,并且在它完成之前,变更检测机制再次调用了管道。在这种情况下,我们将比较prevUrl属性和url参数。如果它们相同,我们不会执行新的请求,并将立即返回data属性的值。如果prevUrl的值与url不同,我们将发起一个新的请求。
使用有状态管道
现在,让我们使用我们开发的管道。我们将实现的程序提供了一个文本输入和一个带有“获取头像”标签的按钮。一旦用户在文本输入中输入值并点击按钮,GitHub 用户的头像将出现在文本输入下方,如下面的截图所示:

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

图 1
此图分为两部分:UI和web worker。UI显示了在主 UI 线程中初始化期间执行的操作;图中的web worker部分显示了应用程序如何在后台线程中启动。现在,让我们一步一步地解释启动过程。
首先,用户打开index.html页面,这将触发以下两个文件的下载:
- 
用于在 Web Worker 中运行的应用程序的 Angular UI 包 
- 
system.js包(我们在第四章,TypeScript 入门课程中讨论了全局对象System。我们可以将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 Workers
让我们看看我们如何使应用程序与 Web Workers 兼容。这样,我们可以在计算密集型应用程序中减少帧率下降,因为我们将释放主 UI 线程,让它只负责渲染。
在index.html内部,我们需要添加以下脚本:
  <!-- ch9/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/switching-to-angular/issues', e); 
    }); 
  </script> 
  ... 
在前面的代码片段中,我们包含了system.js、zone.js和reflect-metadata的引用。zone.js是 Angular 独家使用的区域的 polyfill,我们在本书中之前提到过。此外,reflect-metadata包含了对 Metadata Reflection API 的另一个 polyfill,在撰写本文时,该 API 在浏览器中尚不可用。
在下一步中,我们将显式导入bootstrap.js文件,该文件包含用于在 Web Worker 中启动loader.js脚本的逻辑。
让我们探索bootstrap.ts,这是已转换的bootstrap.js文件的原始 TypeScript 版本:
// ch9/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 版本)中的逻辑非常简单:
// ch9/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 Worker 中运行,我们有importScripts函数,它允许我们同步加载列出的文件。作为最后一步,使用System导入包含我们应用程序的脚本。
现在,让我们探索如何在 Web Worker 内部启动应用程序:
// ch9/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函数,并调用其调用的结果中的bootstrapModule方法。我们将应用程序的根模块作为参数传递给bootstrapModule。
使应用程序与 Web Worker 兼容
正如我们所说的,在 Web Worker 上下文中运行的代码无法访问 DOM。让我们看看我们需要做出哪些更改来解决这个问题。
这是InputBox组件的原始实现:
// ch5/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标识符引用输入元素,并在表达式集中使用该引用作为点击事件的处理器。这段代码无法在 Web Worker 中运行,因为我们直接在模板内部访问 DOM 元素。为了解决这个问题,我们需要重构代码片段,使其使用 Angular 数据绑定而不是直接触摸任何元素。我们可以使用 Angular 输入,当单方向绑定有意义时,或者使用NgModel来实现双向数据绑定,这会稍微消耗更多的计算资源。
让我们使用NgModel:
// ch9/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 worker 的上下文中运行应用程序时,我们需要记住以下两点:
- 
我们需要使用不同的引导过程 
- 
我们不应该直接访问 DOM 
违反第二点的典型场景如下:
- 
通过选择一个元素并直接使用浏览器的原生 API 或第三方库来操作它,改变页面的 DOM 
- 
使用 ElementRef注入的本地元素进行访问
- 
在模板中创建一个元素的引用并将其作为参数传递给方法 
- 
直接操作模板中引用的元素 
在所有这些场景中,我们都需要使用 Angular 提供的高级 API。如果我们根据这种做法构建我们的应用程序,我们不仅能从中受益于能够在 web workers 中运行它们,还能在想要在不同平台上使用它们时,增加代码的重用性。
记住这一点并遵循最佳实践也将使我们能够利用服务器端渲染。
单页应用程序的初始加载
在本节中,我们将探讨服务器端渲染的概念,为什么我们需要在我们的应用程序中使用它,以及我们如何使用 Angular 来实现它。
对于我们的目的,我们将解释当用户打开一个使用 Angular 实现的单页应用程序时的典型事件流程。首先,我们将跟踪服务器端渲染禁用时的事件,然后我们将看到如何通过启用此功能来从中受益。我们的示例将在 HTTP 1.1 的上下文中进行说明:

图 2
图 2 展示了浏览器在加载典型 SPA 时的第一次请求以及相应服务器的响应。客户端最初将看到的是 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>...</script> 
</body> 
</html> 
浏览器将把此内容作为响应的主体。当标记渲染到屏幕上时,用户将看到的只是 Loading... 标签。
在下一步中,浏览器将找到 HTML 文件中所有外部资源的引用,例如样式和脚本,并开始下载它们。在我们的例子中,其中一些是 bootstrap.css、es6-shim.min.js、Reflect.js、system.src.js 和 angular-polyfills.js。
一旦所有引用的资源都可用,用户仍然不会看到任何显著的视觉进展(除非下载的 CSS 文件中的样式应用到页面上)。这不会改变,直到 JavaScript 虚拟机处理完与应用程序实现相关的所有引用脚本。到这时,Angular 将知道根据当前 URL 和配置需要渲染哪个组件。
如果与页面关联的组件定义在我们的主应用程序包之外的其他文件中,框架将需要下载它以及它的整个依赖图。
如果我们使用 JIT 编译,在组件的模板和样式外部化时,Angular 需要在能够渲染请求的页面之前下载它们。在这之后,框架将能够编译与目标组件相关的模板并渲染页面。
在这个场景中,有两个主要的陷阱:
- 
在大型应用程序和/或慢速互联网连接的情况下,用户体验将会很差 
- 
搜索引擎在索引由 JavaScript 生成的动态内容方面并不出色;这意味着我们的 SPA 的 SEO 将会受到影响 
在过去,我们使用 AngularJS 构建的应用程序中,通过不同的解决方案解决了 SEO 问题,例如使用无头浏览器渲染请求的页面,将其缓存到磁盘上,然后提供给搜索引擎。然而,有一个更优雅的解决方案。
使用服务器端渲染的单页应用程序的初始加载
几年前,像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 年上半年,Angular 社区成员宣布他们已经开始开发模块,Universal。Universal 是一个库,允许我们使用 Angular 构建通用(也称为同构)JavaScript 应用程序;换句话说,它提供了服务器端渲染支持。后来,该项目被转移到 Angular 核心仓库,目前由 Google 维护。
使用 Angular Universal 并服务器端渲染的应用程序,在请求页面的所有 JavaScript 处理完毕之前,不会响应用户交互。这是我们之前提到的一个缺点,适用于所有服务器端渲染的应用程序。为了处理这个问题,Angular Universal 引入了preboot.js,这是一个轻量级的库,它将被内联在服务器渲染的页面上,并在初始响应中可用。
Preboot.js 在应用程序完全初始化之前对接收到的客户端事件有几种管理策略;如下所示:
- 
记录和回放事件 
- 
立即响应用件 
- 
在页面重新渲染时保持焦点 
- 
缓存客户端重新渲染以实现更平滑的过渡 
- 
在引导完成前冻结页面 
在撰写本书时,Universal 模块仍在积极开发中。您可以使用 Angular Universal Starter 在 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 编写 
尽管在撰写本书时,并非所有提到的 IDE 和文本编辑器都具有 Angular 特定功能,但该框架在设计时就考虑了工具支持。它允许对应用程序的代码进行高级静态代码分析,这可以提供复杂的重构和生产率功能。一个能够启用 Angular 这一功能并为 IDE 和文本编辑器开发者提供通用接口的工具是语言服务。
Angular 语言服务
利用 Angular 的可分析特性,Google 为该框架开发了一个 语言服务。我们可以将此服务视为一个服务器,它索引我们的项目,并根据客户端请求提供自动完成建议、模板中的类型检查和其他功能。这个客户端可以是我们的文本编辑器或 IDE 的插件。
语言服务可以跟踪文本编辑器中当前聚焦的组件的上下文,并提供上下文相关的建议。例如,它可以基于组件树中给定部分的可用指令集提供自动完成建议。
语言服务的最好之处在于它不依赖于任何特定的文本编辑器或 IDE,这意味着,通过一个轻量级的插件,它可以在任何开发环境中重用。
使用 angular-cli 引导项目
在 AngularConnect 2015 期间,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 项目并安装其所有的 npm依赖项
- 
进入项目目录 
- 
启动一个开发 Web 服务器,该服务器将监视项目目录中的更改,并在我们更改其中的任何文件时向浏览器发送刷新命令 
对于进一步阅读,请查看位于github.com/angular/angular-cli的项目仓库。
Angular CLI 提供了一个高度封装的构建,因此默认情况下,它不会暴露任何底层的工具。对于更高级的情况,当我们想在构建中引入自定义逻辑时,我们可以使用以下方法从 CLI 项目中退出:
$ ng eject
此命令将导出内部 webpack 配置,并允许我们手动更新它。
除非组织设置了限制或我们有不同的偏好,否则使用 Angular CLI 为新项目是一个好主意。虽然 Angular CLI 提供的构建可以被视为一个黑盒,修改起来并不简单,但通常不需要修改,因为它涵盖了小型和中型项目的大部分用例。
Angular 快速入门
如果您更喜欢不那么封装的构建,社区已经开发了许多入门项目,可以为您的下一个 Angular 项目提供一个良好的起点。
Angular 种子
如果您喜欢静态类型,您可以尝试Angular Seed项目。该项目托管在 GitHub 上,网址为github.com/mgechev/angular-seed。
Angular Seed 提供了以下关键特性:
- 
易于扩展、模块化和静态类型化的构建系统 
- 
AOT 编译支持 
- 
在单个种子实例中支持多个 Angular 应用程序,具有共享的代码库 
- 
生产环境和开发构建 
- 
使用 Jasmine 和 Karma 的示例单元测试 
- 
使用 Protractor 进行端到端测试 
- 
带有 LiveReload 的开发服务器 
- 
使用 codelyzer 进行静态代码分析,这验证了项目遵循 Angular 风格指南中的实践 
- 
遵循应用程序和文件组织的最佳实践 
- 
为开发和生产环境提供完整的 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 webpack 启动器
如果您更喜欢 webpack,可以使用angular-starter。这是一个由AngularClass开发的启动器项目,托管在 GitHub 上。您可以在github.com/AngularClass/angular-starter找到它。
此启动器提供了以下功能:
- 
Angular 在文件和应用程序组织方面的最佳实践 
- 
使用 webpack 的现成构建系统来处理 TypeScript 
- 
使用 Jasmine 和 Karma 测试 Angular 代码 
- 
使用 Istanbul 和 Karma 进行覆盖率测试 
- 
使用 Protractor 进行端到端 Angular 代码 
为了尝试它,您需要在您的计算机上安装node.js、npm和git,并运行以下命令:
$ git clone --depth 1 https://github.com/angularclass/angular-starter.git 
$ cd angular-starter 
# WINDOWS only. In terminal as administrator
$ npm install -g node-pre-gyp
$ npm install 
$ npm start 
Angular 的预编译
在本节中,我们将简要解释在 Angular 的上下文中 AOT 编译是什么,以及它可能对我们的项目产生什么影响。
Angular 的关键焦点是其极快的变更检测机制。在探索了在视图模型中检测变更的不同选项之后,Angular 团队发现,AngularJS 中使用的变更检测机制可以通过代码生成进行显著改进。结果是,可以为 JavaScript 虚拟机生成优化的代码,该虚拟机执行变更检测和高效渲染。经过大量的基准测试,谷歌发现这种策略比传统的(也称为动态的)变更检测机制性能要好得多。
代码生成是如何工作的
Angular 代码生成器被称为 Angular 编译器。它将 Angular 组件的模板编译成 JavaScript 或 TypeScript(取决于编译目标)。当我们把模板编译成 TypeScript 时,我们允许 TypeScript 编译器执行类型检查,不仅在我们的组件、指令、服务和管道的命令式逻辑中,而且在组件的模板中!在模板中执行类型检查有助于我们在应用程序中找到更多潜在的问题。
除了为模板生成代码之外,Angular 编译器还为我们应用程序中的注入器生成代码。这进一步提高了依赖注入机制的效率。
基于对模板中绑定进行的静态分析,生成的模板代码执行了最有效的变更检测和最优化更新 DOM 树的操作,这取决于变化值。除此之外,生成的代码还利用了 JavaScript 虚拟机的内联缓存机制,这带来了额外的性能提升。
关于内联缓存的进一步阅读,请参阅这篇文章:在 JavaScript 中解释 JavaScript 虚拟机 - 内联缓存,位于mrale.ph/blog/2012/06/03/explaining-js-vms-in-js-inline-caches.html。
代码生成可以是运行时执行,称为即时(JIT)编译,或者构建时执行,称为 AOT 编译。由于 JIT 编译涉及在运行时评估代码,因此建议你在具有严格内容安全策略(CSP)且eval不可用的环境中使用 AOT。
介绍提前编译的概念
从 AOT 中,我们获得了一些改进。首先,如果我们将 Angular 应用程序作为构建过程的一部分进行编译,我们就不需要在运行时编译它。这意味着我们不会遇到使用 JIT 时遇到的运行时性能下降。这样,AOT 提供了更快的应用程序初始渲染,因为 Angular 在初始化期间需要做的工作更少。
此外,由于我们不再需要在运行时执行编译,我们可以将整个@angular/compiler模块从最终的应用程序包中删除,从而减小包的大小。
最后,我们可以通过树摇进行更高效的无效代码消除。树摇意味着删除未使用的导出,这是 ES2015 模块静态性质的一个伟大特性。当我们依赖于 JIT 编译时,我们可以在模板内部通过选择器引用不同的组件。然而,模板是类似 HTML 的格式,现代的压缩器(如 UglifyJS 和 Google Closure Compiler)并不理解。这意味着它们不能消除所有未使用的导出(例如,未使用的组件),因为它们不确定模板中确切使用了什么。一旦在构建时,Angular 编译器将模板转换为 TypeScript 或 JavaScript;使用静态 ES2015 导入,打包器可以应用传统的无效代码消除技术,从而进一步减小包的大小!
如果你对进一步阅读感兴趣,可以查看位于goo.gl/eXieJl的 Angular 的提前编译文章。
提前编译的限制
请记住,Angular 编译器需要类型元数据才能执行编译过程。这意味着如果你不使用 TypeScript,你将无法执行 AOT 编译。
AOT 编译是在构建时收集元数据来执行的。这意味着在某些情况下,我们的代码可能与 JIT 一起工作,但如果我们在构建时无法解析动态结构,则可能无法在 AOT 中工作。
关于哪些语法结构可能不适用于 AOT 编译的更多详细信息,请查看以下存储库goo.gl/F7cV1s。
最后,模板生成的代码不是组件控制器本身的一部分。这意味着我们无法绑定到非公共字段,因为在编译过程中,TypeScript 会抛出一个错误。
如何使用 Angular 的即时编译
Angular 的 AOT 编译已经在最受欢迎的入门项目中得到支持。
在撰写本文时,Angular CLI 默认在生产构建中执行 AOT 编译。要获取优化的生产资源,请运行以下命令:
$ ng build --prod
当 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号