Angular 入门指南(一)
AngularJS 是一个使构建 Web 应用程序更简单的 JavaScript 开发框架。它被用于今天的大型、高流量网站,这些网站在性能和可移植性问题上以及 SEO 不友好和规模复杂性方面存在挑战。Angular 的新版本改变了这些。它是您构建高性能和健壮 Web 应用程序所需的现代框架。“开始使用 Angular” 是快速掌握 Angular 的最佳途径;它将帮助您过渡到充满活力的 Angul
原文:
zh.annas-archive.org/md5/0d9bf4790260519f93dbf9dab2197957译者:飞龙
前言
AngularJS 是一个使构建 Web 应用程序更简单的 JavaScript 开发框架。它被用于今天的大型、高流量网站,这些网站在性能和可移植性问题上以及 SEO 不友好和规模复杂性方面存在挑战。Angular 的新版本改变了这些。
它是您构建高性能和健壮 Web 应用程序所需的现代框架。“开始使用 Angular” 是快速掌握 Angular 的最佳途径;它将帮助您过渡到充满活力的 Angular 2 及其以后的全新世界。
到本书结束时,您将准备好开始构建快速高效的 Angular 应用程序,充分利用所有新功能。
本书涵盖的内容
第一章,开始使用 Angular,标志着我们进入 Angular 世界的旅程。它描述了框架设计决策背后的主要原因。我们将探讨框架形状背后的两个主要驱动力——Web 的当前状态和前端开发的演变。
第二章,Angular 应用程序的构建块,概述了 Angular 2 引入的核心概念。我们将探讨 AngularJS 为应用程序开发提供的基石与框架上一个主要版本中的基石有何不同。
第三章,TypeScript 快速入门,解释说尽管 Angular 是语言无关的,但 Google 的建议是利用 TypeScript 的静态类型。在本章中,您将学习到开发 TypeScript Angular 应用程序所需的所有基本语法。
第四章,开始使用 Angular 组件和指令,描述了开发我们应用程序用户界面的核心构建块——指令和组件。我们将深入研究诸如视图封装、内容投影、输入和输出、变更检测策略等概念。我们将讨论高级主题,例如模板引用和利用不可变数据加速我们的应用程序。
第五章,Angular 中的依赖注入,涵盖了框架中最强大的功能之一,该功能最初由 AngularJS 引入:其依赖注入机制。它允许我们编写更易于维护、测试和理解的可维护代码。在本章结束时,我们将了解如何在服务中定义业务逻辑,并通过 DI 机制将它们与 UI 粘合在一起。我们还将探讨一些更高级的概念,例如注入器层次结构、配置提供者等。
第六章,使用 Angular 路由和表单,探讨了在开发实际应用过程中管理表单的新模块。我们还将实现一个显示通过表单输入的数据的页面。最后,我们将使用基于组件的路由将各个页面粘合在一起形成一个应用。
第七章,解释管道和与 RESTful 服务通信,详细探讨了路由和表单模块。在这里,我们将探讨如何开发模型驱动的表单,定义参数化和子路由。我们还将解释 HTTP 模块,并查看如何开发纯管道和不纯管道。
第八章,工具和开发体验,探讨了 Angular 应用开发中的某些高级主题,例如即时编译、在 Web Worker 中运行应用和服务器端渲染。本章的第二部分,我们将探讨可以简化我们作为开发者日常生活的工具,例如 angular-cli、angular-seed 等。
你需要为这本书准备什么
为了完成本书中的大多数示例,你只需要一个简单的文本编辑器或 IDE、Node.js、已安装的 TypeScript、互联网接入和浏览器。
每章都介绍了运行提供的代码片段所需的软件要求。
这本书适合谁阅读
你是否想直接深入 Angular 的深处?也许你在迁移之前想评估一下变化?如果是这样,那么《Angular 入门》这本书就是为你准备的。为了最大限度地利用这本书,你需要熟悉 AngularJS,并对 JavaScript 有良好的理解。不需要了解对 Angular 2 及以后版本所做的更改,就可以跟随本书学习。
习惯用法
在这本书中,你会发现许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名如下所示:“你应该看到相同的结果,但不需要在磁盘上存储test.js文件。”代码块设置如下:
@Injectable()
class Socket {
constructor(private buffer: Buffer) {}
}
let injector = ReflectiveInjector.resolveAndCreate([
provide(BUFFER_SIZE, { useValue: 42 }),
Buffer,
Socket
]);
injector.get(Socket);
当我们希望引起你对代码块中特定部分的注意时,相关的行或项目将以粗体显示:
let injector = ReflectiveInjector.resolveAndCreate([
provide(BUFFER_SIZE, { useValue: 42 }),
Buffer,
Socket
]);
存储在代码库中的每个代码片段,其代码均从本书开始,并带有相应的文件位置注释:
// ch5/ts/injector-basics/forward-ref.ts
@Injectable()
class Socket {
constructor(private buffer: Buffer) {…}
}
新术语和重要词汇以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,以引号包围或在文本中如下所示:“当标记渲染到屏幕上时,用户将看到的只是标签:加载中…”
注意
警告或重要注意事项会出现在像这样的框中。
小贴士
小技巧和技巧会像这样显示。
读者反馈
我们欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢什么或不喜欢什么。读者的反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大价值的书籍。
要发送给我们一般性的反馈,请简单地发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书的标题。
如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在,您已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大价值。
下载示例代码
您可以从 GitHub 下载此书的示例代码文件,网址为github.com/mgechev/getting-started-with-angular。您可以通过以下步骤下载代码文件:
-
在浏览器地址栏中输入 URL。
-
点击屏幕中间右侧位置的下载 ZIP按钮。
您也可以从您的 Packt 账户下载此书的示例代码文件,网址为www.packtpub.com。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您也可以按照以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的支持标签上。
-
点击代码下载与勘误。
-
在搜索框中输入书的名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买此书的来源。
-
点击代码下载。
一旦文件下载完成,请确保您使用最新版本的以下软件解压缩或提取文件夹:
-
WinRAR / 7-Zip for Windows
-
Zipeg / iZip / UnRarX for Mac
-
7-Zip / PeaZip for Linux
第三章和第四章包含有关安装过程的更多信息。
勘误
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然会发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误表,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。
盗版
在互联网上对版权材料的盗版是所有媒体中持续存在的问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过 copyright@packtpub.com 联系我们,并提供涉嫌盗版材料的链接。
我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。
问题
如果您对本书的任何方面有问题,您可以通过 questions@packtpub.com 联系我们,我们将尽力解决问题。
第一章。开始使用 Angular
2014 年 9 月 18 日,谷歌将第一个公开提交推送到包含新版本 Angular 的存储库。几周后,在 ng-europe 上,核心团队中的 Igor 和 Tobias 给出了一个关于新版本框架预期内容的简要概述。当时的愿景远未最终确定;然而,有一点是确定的:新版本的框架将完全不同于 AngularJS。
这一公告引发了许多问题和争议。这些重大变化背后的原因非常明显:AngularJS 已无法充分利用演进的 Web,以及大规模 JavaScript 应用程序的需求需要得到完全满足。一个新的框架将让 Angular 开发者以更简单、更高效、更富有成效的方式利用 Web 技术的发展。然而,人们对此表示担忧。对于开发者来说,向后不兼容的最大噩梦之一是将他们当前的代码库迁移到他们使用的第三方软件的新版本。在 Angular 的情况下,在那次第一次公告之后,迁移看起来令人畏惧,甚至不可能。后来,在 2015 年的 ng-conf 和 ng-vegas 上,介绍了不同的迁移策略。Angular 社区团结起来,分享了额外的想法,预计新版本的框架将带来好处,同时保留从 AngularJS 中学到的经验。
本书是该项目的一部分。现在将 Angular 升级到新版本变得平滑且值得。Angular 2 和其缺乏向后兼容性的巨大变化背后的主要推动力是 Web 的发展以及从 AngularJS 的实际使用中吸取的教训。《Angular 入门》将帮助你通过了解我们是如何到达这里的以及为什么 Angular 的新特性对现代 Web 来说在构建高性能、可扩展的单页应用程序方面具有直观的意义来学习新框架。本章我们将讨论的一些主题包括:
-
如何使用 TypeScript 以及它如何扩展 JavaScript。
-
使用基于组件架构构建 Angular 应用程序的用户界面。
-
使用 Angular 的依赖注入机制并将业务逻辑委托给服务。
-
我们将深入探讨 Angular 的路由和表单模块。
-
我们将探讨即时编译(Ahead-of-Time compilation)以构建闪电般快速的应用程序。
Angular 采用了语义版本控制,因此在继续之前,让我们先概述一下这实际上意味着什么。
Angular 和 semver
AngularJS 从头开始重写,并替换为其继任者 Angular 2。我们中的许多人对此重大步骤感到烦恼,因为它不允许我们在这两个框架版本之间实现平滑过渡。在 Angular 2 稳定后,谷歌宣布他们希望遵循所谓的语义版本控制(也称为 semver)。
Semver 定义了给定软件项目的版本为X.Y.Z的三元组,其中 Z 被称为修补版本,Y 被称为次要版本,X 被称为主版本。修补版本的变化意味着同一项目的两个版本之间没有预期的破坏性变化,只有错误修复。当引入新功能且没有破坏性变化时,项目的次要版本将增加。最后,当 API 中引入不兼容的变化时,主版本将增加。
这意味着在 2.3.1 和 2.10.4 版本之间,没有引入破坏性变化,只有一些新增功能和错误修复。然而,如果我们有 2.10.4 版本,并且想要以向后不兼容的方式更改任何现有的公共 API(例如,更改方法接受的参数顺序),我们需要增加主版本号,并将修补版本和次要版本重置,这样我们将得到版本 3.0.0。
Angular 团队也遵循严格的计划。根据该计划,每个星期需要引入一个新的修补版本;在每次主要版本发布后,应有三次每月的次要版本发布,最后,每六个月发布一次主要版本。这意味着到 2018 年底,我们将至少拥有 Angular 6。然而,这并不意味着每六个月我们都必须经历像 AngularJS 和 Angular 2 之间的相同迁移路径。并非每个主要版本都会引入影响我们项目的破坏性变化。例如,对 TypeScript 新版本的支持或方法最后一个可选参数的改变将被视为破坏性变化。我们可以将这些破坏性变化视为类似于 AngularJS 1.2 和 AngularJS 1.3 之间发生的情况。
注意
由于本书中您将要阅读的内容将主要适用于不同的 Angular 版本,我们将 Angular 2 称为 Angular 2 或仅称 Angular。如果我们明确提到 Angular 2,这并不意味着给定的段落对于 Angular 4 或 Angular 5 将不适用;它很可能适用。如果您对框架不同版本之间的变化感兴趣,可以查看变更日志github.com/angular/angular/blob/master/CHANGELOG.md。如果我们讨论的是 AngularJS(即框架的 1.x 版本),我们将通过提及版本号或使用 AngularJS 代替 Angular 来更加明确。
现在我们已经介绍了 Angular 的语义版本和引用框架不同版本的约定,我们可以正式开始我们的旅程了!
网络的演变 - 是时候引入新的框架了
在过去几年里,Web 经历了巨大的发展。在实施 ECMAScript 5 的过程中,ECMAScript 6 标准开始了其开发(现在被称为ECMAScript 2015或ES2015)。ES2015 在 JavaScript 中引入了许多变化,例如添加内置语言支持模块、块作用域变量定义以及许多语法糖,如类和结构赋值。
同时,Web Components被发明了。Web Components 允许我们定义自定义的 HTML 元素并将行为附加到它们上。由于很难通过添加新的元素(如对话框、图表、网格等)来扩展现有的 HTML 元素集,这主要是因为它们 API 的整合和标准化所需的时间,因此一个更好的解决方案是允许开发者以他们想要的方式扩展现有的元素。Web Components 为我们提供了许多好处,包括更好的封装、我们产生的标记的更好语义、更好的模块化,以及开发者和设计师之间更易沟通。
我们知道 JavaScript 是一种单线程语言。最初,它是为了简单的客户端脚本开发而开发的,但随时间推移,它的角色发生了很大变化。现在,有了 HTML5,我们有不同的 API 允许音频和视频处理、通过双向通信通道与外部服务进行通信、传输和处理大量原始数据等。所有这些在主线程中的重计算可能会创建一个糟糕的用户体验。它们可能会在执行耗时计算时导致用户界面冻结。这导致了Web Workers的发展,它允许在后台执行脚本并通过消息传递与主线程通信。这样,多线程编程被带到了浏览器中。
一些这些 API 是在 AngularJS 开发开始之后引入的;这就是为什么框架在构建时没有考虑到它们中的大多数。利用这些 API 为开发者带来了许多好处,例如以下这些:
-
显著的性能改进。
-
开发具有更好质量特性的软件。
现在,让我们简要讨论这些技术是如何成为新 Angular 核心的一部分以及为什么。
ECMAScript 的演变
现在,浏览器厂商正在以短周期迭代的方式发布新功能,用户经常收到更新。这有助于开发者利用前沿的 Web 技术。ES2015 已经标准化。该语言最新版本的实现已经在主要浏览器中开始。学习新语法并利用它不仅会增加我们的开发效率,而且还会为我们为即将到来的所有浏览器都将完全支持它的近未来做好准备。这使得现在开始使用最新语法变得至关重要。
一些项目的需求可能迫使我们支持不支持任何 ES2015 特性的旧浏览器。在这种情况下,我们可以直接编写 ECMAScript 5,它具有不同的语法但与 ES2015 具有等效的语义。另一方面,更好的方法将是利用转译过程。在我们的构建过程中使用转译器允许我们通过编写 ES2015 并将其转换为浏览器支持的目标语言来利用新语法。
Angular 自 2009 年以来一直存在。当时,大多数网站的前端由 ECMAScript 3 提供支持,这是 ECMAScript 5 之前的最后一个主要版本。这自动意味着框架实现所使用的语言是 ECMAScript 3。利用语言的新版本需要将 AngularJS 的全部内容移植到 ES2015。
从一开始,Angular 2 就考虑了 Web 的当前状态,通过引入框架中的最新语法。虽然新的 Angular 是用 ES2016 的超集(TypeScript)编写的(我们将在第三章中查看 AngularJS 控制器的责任如何被新的组件和指令所取代。
范围
在 AngularJS 中,数据绑定是通过scope对象实现的。我们可以将其属性附加到它上,并在模板中显式声明我们想要绑定到这些属性(单向或双向)。尽管作用域的概念似乎很清晰,但它还有两个额外的职责,包括事件分发和与变更检测相关的行为。Angular 初学者很难理解作用域到底是什么以及应该如何使用它。AngularJS 1.2 引入了所谓的控制器作为语法。它允许我们在给定控制器内部添加属性到当前上下文(this),而不是显式注入scope对象并稍后添加属性。这种简化的语法可以通过以下代码片段进行演示:
<div ng-controller="MainCtrl as main">
<button ng-click="main.clicked()">Click</button>
</div>
function MainCtrl() {
this.name = 'Foobar';
}
MainCtrl.prototype.clicked = function () {
alert('You clicked me!');
};
最新版本的 Angular 通过移除scope对象将这一概念进一步发扬光大。所有表达式都在给定的 UI 组件的上下文中进行评估。移除整个作用域 API 引入了更高的简洁性;我们不再需要显式地注入它,而是将属性添加到 UI 组件中,我们可以稍后将其绑定。这个 API 感觉更加简单和自然。
我们将在第四章开始使用 Angular 组件和指令中更详细地探讨 Angular 的组件和变更检测机制。
依赖注入
可能是市场上第一个在 JavaScript 世界中通过依赖注入(DI)实现控制反转(IoC)的框架是 AngularJS。DI 提供了一系列好处,例如更易于测试、更好的代码组织和模块化,以及简洁性。尽管该框架第一版本的 DI 功能强大,但 Angular 2 将这一概念进一步发扬光大。由于最新的 Angular 基于最新的 Web 标准,它使用 ECMAScript 2016 装饰器语法来注释使用 DI 的代码。装饰器与 Python 中的装饰器或 Java 中的注解非常相似。它们允许我们使用反射来装饰给定对象的行为,或为其添加元数据。由于装饰器尚未标准化且不被主流浏览器支持,其使用需要中间转译步骤;然而,如果您不想这样做,您可以直接使用 ECMAScript 5 语法编写更多冗长的代码,并达到相同的语义。
新的 DI 更加灵活且功能丰富。它还修复了 AngularJS 的一些缺陷,例如不同的 API;在框架的第一版本中,一些对象通过位置(例如指令的链接函数中的作用域、元素、属性和控制器)注入,而其他对象则通过名称(使用控制器、指令、服务和过滤器中的参数名称)注入。
我们将在第五章Angular 中的依赖注入中进一步探讨 Angular 的依赖注入 API。
服务器端渲染
网络需求越大,Web 应用就越复杂。构建一个真实的单页应用需要编写大量的 JavaScript,并且包含所有必需的外部库可能会使页面上的脚本大小增加到几兆字节。在移动设备上,直到从服务器获取所有资源、解析并执行 JavaScript、渲染页面以及应用所有样式,应用的初始化可能需要几秒钟甚至几十秒钟。在低端移动设备上,使用移动网络连接时,这个过程可能会让用户放弃访问我们的应用。尽管有一些做法可以加快这个过程,但在复杂的应用中,并没有一劳永逸的解决方案。
在尝试提升用户体验的过程中,开发者发现了一种称为服务器端渲染的技术。它允许我们在服务器上渲染单页应用的请求视图,并直接将页面的 HTML 提供给用户。一旦所有资源都处理完毕,脚本文件可以添加事件监听器和绑定。这听起来像是提升我们应用性能的好方法。在这方面,React 是一个先驱,它允许使用 Node.js DOM 实现来在服务器端预渲染用户界面。不幸的是,AngularJS 的架构不允许这样做。主要障碍是框架与浏览器 API 之间的强耦合,这正是我们在 Web Workers 中运行变更检测时遇到的问题。
服务器端渲染的另一个典型用例是构建搜索引擎优化(SEO)友好型应用。过去,为了使 AngularJS 应用可由搜索引擎索引,人们使用了一些技巧。例如,一种做法是使用无头浏览器遍历应用,在每个页面上执行脚本并将渲染输出缓存到 HTML 文件中,使其可被搜索引擎访问。
尽管构建 SEO 友好型应用的这种解决方案是可行的,但服务器端渲染解决了上述两个问题,提升了用户体验,并使我们能够更加轻松且优雅地构建 SEO 友好型应用。
将 Angular 与 DOM 解耦使我们能够在浏览器之外运行 Angular 应用。我们将在第八章工具和开发体验中进一步探讨这一点。
可扩展的应用
自从 Backbone.js 出现以来,MVW(Model-View-Whatever)一直是构建单页应用程序的默认选择。它通过将业务逻辑与视图隔离开来,使我们能够构建设计良好的应用程序。利用观察者模式,MVW 允许在视图中监听模型变化,并在检测到变化时更新它。然而,这些事件处理器之间存在一些显式和隐式的依赖关系,这使得我们应用程序中的数据流不明显,难以推理。在 AngularJS 中,我们允许不同观察者之间存在依赖关系,这要求消化循环迭代所有这些观察者几次,直到表达式的结果稳定。新的 Angular 使数据流单向;这带来了许多好处:
-
更明确的数据流。
-
绑定之间没有依赖关系,因此没有生存时间(TTL)。
-
框架性能更好:
-
消化循环只运行一次。
-
我们可以创建对不可变或可观察模型友好的应用程序,这使我们能够进行进一步的优化。
-
数据流的变化在 AngularJS 架构中引入了另一个根本性的变化。
当我们需要维护一个用 JavaScript 编写的庞大代码库时,我们可能会对这个问题的看法有所不同。尽管 JavaScript 的鸭子类型使语言非常灵活,但它也使得 IDE 和文本编辑器对其分析和支持变得更加困难。大型项目的重构变得非常困难且容易出错,因为在大多数情况下,静态分析和类型推断是不可能的。缺少编译器使得打字错误变得过于容易,直到我们运行测试套件或运行应用程序,我们才难以注意到这些错误。
由于 TypeScript 提供了更好的工具和编译时类型检查,Angular 核心团队决定使用 TypeScript,这有助于我们提高生产效率并减少错误。正如以下图表所示,TypeScript 是 ECMAScript 的超集;它引入了显式的类型注解和编译器:
https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/gtst-ng-2e/img/5081_01_01.jpg
图 1
TypeScript 语言编译成普通 JavaScript,被今天的浏览器支持。自 1.6 版本以来,TypeScript 实现了 ECMAScript 2016 装饰器,使其成为 Angular 的完美选择。
TypeScript 的使用允许 IDE 和文本编辑器提供更好的支持,包括静态代码分析和类型检查。所有这些通过减少我们犯的错误并简化重构过程,极大地提高了我们的生产效率。TypeScript 的另一个重要好处是通过静态类型隐式获得的性能提升,这使得 JavaScript 虚拟机可以进行运行时优化。
我们将在第三章TypeScript 快速入门中详细讨论 TypeScript。
模板
模板是 AngularJS 的关键特性之一。它们是简单的 HTML,不需要任何中间转换,与大多数模板引擎(如 mustache)不同。Angular 的模板通过允许我们在其中创建一个内部领域特定语言(DSL),结合了自定义元素和属性,将简单性与强大功能相结合。
这也是 Web Components 的主要目的之一。我们已经提到了 Angular 如何以及为什么利用这项新技术。尽管 AngularJS 模板很棒,但它们仍然可以变得更好!新的 Angular 模板吸收了框架先前版本中模板的精华,并通过修复其中一些令人困惑的部分来增强它们。
例如,假设我们有一个指令,并希望允许用户通过属性传递给它。在 AngularJS 中,我们可以用以下三种不同的方式来处理这个问题:
<user name="literal"></user>
<user name="expression"></user>
<user name="{{interpolate}}"></user>
在user指令中,我们通过三种不同的方法传递name属性。我们可以传递一个字面量(在这种情况下,字符串"literal"),一个字符串,它将被评估为一个表达式(在我们的案例中是"expression"),或者一个在{{ }}内的表达式。应该使用哪种语法完全取决于指令的实现,这使得它的 API 错综复杂,难以记忆。
每天处理大量具有不同设计决策的组件是一项令人沮丧的任务。通过引入一个共同约定,我们可以处理这些问题。然而,为了获得良好的结果和一致的 API,整个社区都需要达成一致。
新的 Angular 通过提供特殊语法来处理这个问题,这些属性的值需要在当前组件的上下文中进行评估,并且为传递字面量提供了不同的语法。
根据我们的 AngularJS 经验,我们还习惯于模板指令中的微语法,例如ng-if和ng-for。例如,如果我们想在 AngularJS 中遍历用户列表并显示他们的名字,我们可以使用:
<div ng-for="user in users">{{user.name}}</div>
虽然这种语法对我们来说看起来直观,但它只提供了有限的工具支持。然而,Angular 2 通过引入更多明确的语法和更丰富的语义来解决这个问题:
<template ngFor let-user [ngForOf]="users">
{{user.name}}
</template>
前面的代码片段明确定义了属性,这些属性必须在当前迭代(user)的上下文中创建,以及我们迭代的(users)。
由于这种语法在键入时过于冗长,开发者可以使用以下语法,它最终会被转换成更冗长的形式:
<li *ngFor="let user of users">
{{user.name}}
</li>
新模板的改进也将允许更好的工具支持高级功能,例如文本编辑器和 IDE。我们将在第四章开始使用 Angular 组件和指令中讨论 Angular 的模板。
变更检测
在 Web Workers 部分,我们已经提到了在作为 Web Worker 实例化的不同线程上下文中运行消化循环的机会。然而,AngularJS 中消化循环的实现并不那么内存高效,并阻止 JavaScript 虚拟机进行进一步的代码优化,这允许实现显著的性能提升。其中一种优化是内联缓存(mrale.ph/blog/2012/06/03/explaining-js-vms-in-js-inline-caches.html)。
Angular 团队进行了大量研究,以发现提高变更检测性能和效率的不同方法。这导致了全新的变更检测机制的诞生。
因此,Angular 在框架直接从组件模板生成的代码中执行变更检测。代码由 Angular 编译器生成。有两种内置的代码生成(也称为编译)策略:
-
即时编译 (JiT):在运行时,Angular 生成代码以在整个应用程序上执行变更检测。生成的代码针对 JavaScript 虚拟机进行了优化,从而提供了显著的性能提升。
-
提前编译 (AoT):与即时编译 (JiT) 类似,但不同之处在于代码作为应用程序构建过程的一部分被生成。它可以用于通过不在浏览器中执行编译来加速渲染,也可以用于不允许
eval()的环境,例如 内容安全策略 (CSP) 和 Chrome 扩展。我们将在本书的下一节中进一步讨论。
我们将在第四章 “开始使用 Angular 组件和指令” 中查看新的变更检测机制以及如何配置它们。
摘要
在本章中,我们考虑了 Angular 核心团队做出决策的主要原因以及框架最后两个主要版本之间缺乏向后兼容性的原因。我们看到,这些决策是由两件事推动的——Web 的演变和前端开发的演变,以及从 AngularJS 应用程序开发中学到的经验教训。
在第一部分,我们学习了为什么需要使用 JavaScript 语言的最新版本,为什么应该利用 Web 组件和 Web Workers,以及为什么在版本 1 中集成所有这些强大的工具并不值得。
我们观察了前端开发的当前方向和过去几年中吸取的教训。我们描述了为什么控制器和作用域被从 Angular 2 中移除,以及为什么为了允许 SEO 友好、高性能的单页应用程序进行服务器端渲染,AngularJS 的架构发生了变化。我们审视的另一个基本主题是构建大规模应用程序,以及它是如何激励框架中的单向数据流和选择静态类型语言 TypeScript 的。
在下一章中,我们将探讨 Angular 应用程序的主要构建块,它们的使用方法以及它们之间的关系。新的 Angular 重用了 AngularJS 中引入的一些概念名称,但通常完全改变了我们的单页应用程序的构建块。我们将简要了解新概念,并将它们与框架的前一个版本中的概念进行比较。我们将快速介绍模块、指令、组件、路由器、管道和服务,并描述如何将它们组合起来构建优雅的单页应用程序。
小贴士
下载示例代码
您可以从 www.packtpub.com 的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。
第二章。Angular 应用程序的构建块
在上一章中,我们探讨了新 Angular 背后的设计决策的驱动因素。我们描述了导致开发全新框架的主要原因;Angular 利用最新的 Web 标准,同时吸取过去的教训。尽管我们熟悉主要驱动因素,但我们还没有描述 Angular 的核心概念。框架的最后一个主要版本与 AngularJS 采取了不同的路径,并在用于开发单页应用程序的基本构建块中引入了许多变化。
本章的使命是描述框架的核心,并对其主要概念进行简要介绍。在接下来的几页中,我们还将概述如何将这些概念组合起来,以帮助我们为 Web 应用程序构建专业的用户界面。随后的章节将概述我们将在本书的后续部分更详细地学习的内容。
在本章中,我们将探讨以下主题:
-
框架的概念概述,展示了不同概念之间的关系。
-
我们如何将用户界面构建为组件的组合。
-
在 Angular 的新版本中,指令采取了什么路径,以及它们的接口与框架的前一个主要版本相比发生了怎样的变化。
-
强制分离关注点的原因,这导致了指令分解为两个不同的概念。为了更好地理解它们,我们将演示它们定义的基本语法。
-
改进的变化检测概述,以及它如何涉及指令提供的上下文。
-
区域是什么,以及它们如何使我们的日常开发过程变得更简单。
-
管道是什么,以及它们与 AngularJS 过滤器的关系。
-
介绍 Angular 中全新的依赖注入(DI)机制及其与服务的关联。
Angular 的概念概述
在我们深入研究 Angular 的不同部分之前,让我们先了解一个概念性的概述,看看所有这些是如何结合在一起的。让我们看一下以下图表:
https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/gtst-ng-2e/img/5081_02_01.jpg
图 1
图 1 到 图 4 展示了 Angular 的主要概念及其之间的联系。这些图表的主要目的是说明使用 Angular 构建单页应用程序的核心模块及其关系。
组件是我们将使用来创建 Angular 应用程序用户界面的主要构建块。组件是指令的直接后继者,而指令是用于将行为附加到 DOM 的原始形式。组件通过提供进一步的功能(如模板)来扩展指令,该模板可以用于渲染指令的组合。在视图的模板内部可以存在不同的表达式。
https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/gtst-ng-2e/img/5081_02_02.jpg
图 2
上述图表从概念上说明了 Angular 的变更检测机制。它执行脏检查,评估特定 UI 组件上下文中的注册表达式。由于作用域的概念已经从 Angular 中移除,因此表达式的执行上下文是与它们关联的组件控制器的实例。
可以使用Differs增强变更检测机制;这就是为什么这两个元素在图表中存在直接关系的原因。
管道是 Angular 的另一个组件。我们可以将管道视为 AngularJS 中的过滤器。管道可以与组件一起使用。我们可以在任何组件的上下文中定义的表达式中包含它们。
https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/gtst-ng-2e/img/5081_02_03.jpg
图 3
现在我们来看看前面的图表。指令和组件将业务逻辑委托给服务。这强制执行更好的关注点分离、可维护性和代码重用性。指令通过框架的 DI 机制接收声明为依赖关系的特定服务的实例引用,并将与业务相关的逻辑执行委托给它们。指令和组件都可以使用DI机制,不仅用于注入服务,还可以注入 DOM 元素和/或其他组件或指令。
模块(也称为NgModules)是一个核心概念,它将构建块组合成单独的、逻辑上相关的组。NgModules 与 AngularJS 模块非常相似,但在此基础上增加了更多的语义。请注意,NgModules 与我们第三章中描述的 ES2015 模块不同,TypeScript 快速入门。Angular 模块是一个框架特性,而 ES2015 模块是一个语言结构。
NgModules 有以下职责:
-
提供 Angular 模板编译器的上下文。
-
提供一种封装级别,其中我们可以拥有仅限于给定模块边界内使用的组件或指令。
-
在 NgModules 中,我们可以配置框架的 DI 机制的提供者。
https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/gtst-ng-2e/img/5081_02_04.jpg
图 4
最后,新的路由器用于定义我们应用程序中的路由。由于指令不拥有模板,只有组件可以被路由器渲染,代表我们应用程序中的不同视图。路由器还使用一组预定义的指令,允许我们在不同的视图和它们应该渲染的容器之间定义超链接。
现在,我们将更仔细地研究这些概念,看看它们是如何一起工作来构建 Angular 应用程序的,以及它们是如何从它们的 AngularJS 前辈中演变而来的。
更改指令
AngularJS 在开发单页应用程序的过程中引入了指令的概念。指令的目的是封装与 DOM 相关的逻辑,并允许我们通过组合它们来构建用户界面。这样,我们能够扩展 HTML 的语法和语义。最初,像大多数创新概念一样,指令受到了争议性的看法,因为它们使我们倾向于在不使用 data- 前缀的自定义元素或属性时编写无效的 HTML。然而,随着时间的推移,这个概念逐渐被接受,并证明它将长期存在。
AngularJS 中指令实现的另一个缺点是我们可以使用它们的不同方式。这需要理解属性值,这些值可以是字面量、表达式、回调或微语法。这使得工具基本上变得不可能。
Angular 的最新版本保留了指令的概念,但从中吸取了 AngularJS 的最佳部分,并添加了一些新的想法和语法。Angular 指令的主要目的是通过扩展到在 ES2015 类中定义的自定义逻辑来将行为附加到 DOM 上。我们可以将这些类视为与指令关联的控制器,并将它们的构造函数视为与 AngularJS 指令的链接函数类似。然而,新的指令具有有限的配置性。它们不允许将模板与它们关联,这使得大多数已知用于定义指令的属性变得不必要。API 的简单性并不限制指令的行为,但只强制执行更严格的关注点分离。为了补充这个更简单的 API,Angular 2 引入了一个更丰富的界面来定义 UI 元素,称为组件。组件通过允许它们拥有模板,通过 组件元数据 扩展了指令的功能。我们将在本书的后面进一步探讨组件。
用于 Angular 指令的语法涉及 ES2016 装饰器。我们可以使用 TypeScript、ES2015,甚至 ECMAScript 5 (ES5)来通过稍微多一点的输入实现相同的结果。此代码定义了一个简单的指令,使用 TypeScript 编写:
@Directive({ selector: '[tooltip]' })
export class Tooltip {
@Input() tooltip: string;
private overlay: Overlay;
constructor(private el: ElementRef, manager: OverlayManager) {
this.overlay = manager.get();
}
@HostListener('mouseenter') onMouseEnter() {
this.overlay.open(this.el.nativeElement, this.tooltip);
}
@HostListener('mouseleave') onMouseLeave() {
this.overlay.close();
}
}
指令可以用以下标记在我们的模板中使用:
<div tooltip="42">Tell me the answer!</div>
一旦用户将鼠标悬停在标签上,告诉我答案!,Angular 将会调用在指令定义中由 @HostListener 装饰器定义的方法。最终,覆盖管理器的 open 方法将被执行。
注意
由于我们可以在单个元素上使用多个指令,最佳实践建议我们应该使用一个属性作为选择器。
定义此指令的 ECMAScript 5 语法如下:
var Tooltip = ng.core.Directive({
selector: '[tooltip]',
inputs: ['tooltip'],
host: {
'(mouseenter)': 'onMouseEnter()',
'(mouseleave)': 'onMouseLeave()'
}
})
.Class({
constructor: [ng.core.ElementRef, Overlay, function (tooltip, el, manager) {
this.el = el;
this.overlay = manager.get();
}],
onMouseEnter() {
this.overlay.open(this.el.nativeElement, this.tooltip);
},
onMouseLeave() {
this.overlay.close();
}
});
上述 ES5 语法展示了 Angular 提供的内部 JavaScript DSL,以便我们能够在现代浏览器尚未支持的语法下编写我们的代码。
我们可以总结说,Angular 通过保持将行为附加到 DOM 的想法来保留了指令的概念。与 AngularJS 的核心区别在于新的语法,以及通过引入组件带来的关注点分离的进一步分离。在第四章,开始使用 Angular 组件和指令中,我们将进一步探讨指令的 API。我们还将比较使用 ES2016 和 ES5 定义的指令定义语法。现在,让我们看看 Angular 组件的巨大变化。
了解 Angular 组件
模型-视图-控制器(MVC)是一种最初为用户界面实现引入的微架构模式。作为 Angular 开发者,我们每天都在使用这种模式的多种变体,最常见的是模型-视图-视图模型(MVVM)。在 MVC 中,我们有模型,它封装了应用程序的业务逻辑,以及视图,它负责渲染用户界面、接受用户输入并将用户交互逻辑委托给控制器。视图被表示为组件的组合,这正式称为组合设计模式。
让我们看一下以下的结构图,它展示了组合设计模式:
https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/gtst-ng-2e/img/5081_02_05.jpg
图 5
这里,我们有三个类:
-
一个名为
Component的抽象类。 -
两个具体的类,称为
Leaf和Composite。Leaf类是我们即将构建的组件树中的简单终端组件。
Component 类定义了一个名为 operation 的抽象操作。Leaf 和 Composite 都继承自 Component 类。然而,Composite 类还拥有对其的引用。我们可以更进一步,允许 Composite 拥有一个 Component 实例的引用列表,如图中所示。Composite 内部的组件列表可以持有对不同的 Composite 或 Leaf 实例,或扩展 Component 类或其任何后继类的其他类的实例的引用。我们可以在 Composite 的 operation 方法实现中调用单个 Component 实例的 operation 方法的不同行为。这是因为对象面向编程语言中实现多态所使用的后期绑定机制。
组件应用
理论就到这里!让我们根据前面图中展示的类层次结构构建一个组件树。这样,我们将展示如何利用组合模式通过简化的语法构建用户界面。我们将在第四章,开始使用 Angular 组件和指令的上下文中查看一个类似的例子:
Composite c1 = new Composite();
Composite c2 = new Composite();
Composite c3 = new Composite();
c1.components.push(c2);
c1.components.push(c3);
Leaf l1 = new Leaf();
Leaf l2 = new Leaf();
Leaf l3 = new Leaf();
c2.components.push(l1);
c2.components.push(l2);
c3.components.push(l3);
上述伪代码创建了三个 Composite 类的实例和三个 Leaf 类的实例。c1 实例在组件列表中持有对 c2 和 c3 的引用。c2 实例持有对 l1 和 l2 的引用,而 c3 持有对 l3 的引用:
https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/gtst-ng-2e/img/5081_02_06.jpg
图 6
上述图示是我们片段中构建的组件树的图形表示。这是现代 JavaScript 框架中视图类似的一个简化版本。然而,它说明了我们可以如何组合指令和组件的基础。例如,在 Angular 的上下文中,我们可以将指令视为 Leaf 类的实例(因为它们不拥有视图,因此不能组合其他指令和组件),而组件则是 Composite 类的实例。
如果我们更抽象地考虑 AngularJS 的用户界面,我们可以注意到我们使用了相当类似的方法。我们视图的模板将不同的指令组合在一起,以便向我们的应用程序的最终用户提供一个完全功能化的用户界面。
Angular 中的组件
Angular 通过引入称为组件的新构建块采取了这种方法。组件扩展了我们之前章节中描述的指令概念,并提供了更广泛的功能。以下是一个基本的 “Hello world” 组件的定义:
@Component({
selector: 'hello-world',
template: '<h1>Hello, {{target}}!</h1>'
})
class HelloWorld {
target: string;
constructor() {
this.target = 'world';
}
}
我们可以通过在视图中插入以下标记来使用它:
<hello-world></hello-world>
注意
根据最佳实践,我们应该为组件使用类型为元素的选择器,因为我们可能每个 DOM 元素只有一个组件。
Angular 使用 DSL 提供的替代 ES5 语法如下:
var HelloWorld = ng.core
.Component({
selector: 'hello-world',
template: '<h1>Hello, {{target}}!</h1>'
})
.Class({
constructor: function () {
this.target = 'world';
}
});
我们将在本书的后面部分更详细地探讨前面的语法。现在让我们简要描述该组件提供的功能。一旦 Angular 应用程序启动,它将查看 DOM 树中的所有元素并处理它们。当它找到一个名为 hello-world 的元素时,它将调用与其定义相关的逻辑,这意味着组件的模板将被渲染,花括号之间的表达式将被评估。这将导致以下标记 <h1>Hello, world!</h1>。
因此,总结一下,Angular 核心团队将 AngularJS 中的指令分离成两个不同的部分——组件和指令。指令提供了一种简单的方法来将行为附加到 DOM 元素上,而无需定义视图。Angular 中的组件提供了一个强大且易于学习的 API,这使得定义我们应用程序的用户界面变得更加容易。Angular 组件允许我们像 AngularJS 指令一样做同样惊人的事情,但需要更少的输入和更少的学习内容。组件通过添加视图来扩展 Angular 指令的概念。我们可以将 Angular 组件和指令之间的关系视为与我们在 图 5 中看到的 “Composite” 和 “Leaf” 之间的关系相同。
从概念上讲,我们可以将指令和组件之间的关系表示为继承。第四章,开始使用 Angular 组件和指令,将这两个概念描述得更加详细。
Angular 模块
在 AngularJS 中,我们有模块的概念。那里的模块负责将相关功能分组并在引导过程中内部注册。不幸的是,它们没有提供诸如封装和懒加载等特性。
Angular 将 NgModules 作为框架第五个候选版本的组成部分引入。新模块的主要目的是为 Angular 编译器提供一个上下文,并实现良好的封装级别。例如,如果我们使用 NgModules 构建库,我们可以有一些内部使用但不是作为公共接口一部分的声明。让我们看看以下例子:
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {TabComponent} from './tab.component';
import {TabItemComponent} from './tab-item.component';
@NgModule({
imports: [CommonModule],
declarations: [TabComponent, TabItemComponent],
exports: [TabComponent]
})
class TabModule { }
如果你对前面例子中的 TypeScript 语法不熟悉,请不要担心;我们将在下一章深入探讨这门语言。
在前面的代码片段中,使用 @NgModule 装饰器,我们声明了 TabModule。请注意,在 declarations 列表中,我们包括了 TabComponent 和 TabItemComponent,但在 exports 列表中,我们只有 TabComponent。这样,我们可以为我们的库实现一定程度的封装。模块的使用者只能使用 TabComponent,因此我们不必担心 TabItemComponent API 的向后兼容性,因为它只能在我们模块的内部访问,在我们的模块边界内。最后,通过设置传递给 @NgModule 的对象字面量的 imports 属性,我们可以列出我们想要在当前模块内部使用的模块。这样,我们将能够利用它们声明的所有 exports 和 providers(我们将在第五章依赖注入中讨论提供者),Angular 中的依赖注入。
引导 Angular 应用
与 AngularJS 类似,在我们应用渲染之前,它需要经过引导过程。在新的 Angular 中,我们可以根据所使用的平台(例如,Web、NativeScript、启用 JiT 或 AoT 编译等)以不同的方式引导应用。让我们来看一个简单的例子,了解我们如何引导一个 Web 应用,以便更好地理解新 Angular 模块在过程中的使用方法:
import {NgModule} from '@angular/core';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
import {BrowserModule} from '@angular/platform-browser';
import {AppComponent} from './app.component';
@NgModule({
imports: [BrowserModule],
bootstrap: [AppComponent],
declarations: [AppComponent],
})
export class AppModule {}
platformBrowserDynamic().bootstrapModule(AppModule);
在前面的例子中,@NgModule 装饰器中,我们声明了 AppComponent 并导入了 BrowserModule。请注意,这次,我们为 bootstrap 属性提供了值,明确声明我们希望使用 AppComponent 来引导我们的应用。
在文件的最后一行,我们调用platformBrowserDynamic方法返回的对象的bootstrapModule方法,其中包含参数AppModule。
总结一下,Angular 中的模块扮演着重要的角色——它们不仅逻辑上组织了我们应用程序的构建块,还提供了一种我们可以实现封装的方法。最后但同样重要的是,NgModules 在应用程序的引导过程中被大量使用。
管道
在商业应用程序中,我们经常需要具有相同数据的不同视觉表示。例如,如果我们有数字 100,000,并且想将其格式化为货币,我们很可能不想显示为纯数据;更有可能的是,我们希望显示为$100,000。
在 AngularJS 中,格式化数据的责任被分配给了过滤器。另一个数据格式化需求示例是当我们使用项目集合时。例如,如果我们有一个项目列表,我们可能希望根据谓词(一个布尔函数)对其进行过滤;在一个数字列表中,我们可能只想显示素数。AngularJS 有一个名为filter的过滤器,允许我们这样做。然而,名称的重复经常导致混淆。这也是核心团队将过滤器组件重命名为管道的另一个原因。
新名称背后的动机是管道和过滤器所使用的语法:
{{expression | decimal | currency}}
在前面的示例中,我们将管道decimal和currency应用于expression返回的值。大括号之间的整个表达式看起来像 Unix 管道语法。
定义管道
定义管道的语法类似于用于定义模块、指令和组件的语法。为了创建一个新的管道,我们可以使用 ES2015 装饰器@Pipe。它允许我们向类添加元数据,将其声明为管道。我们所需做的只是为管道提供一个名称并定义数据格式化逻辑。还有一个替代的 ES5 语法,如果我们想跳过转译过程,可以使用它。
在运行时,一旦 Angular 表达式解释器发现给定的表达式包含对管道的调用,它将从中检索出分配在组件内的管道集合,并使用适当的参数调用它。
以下示例说明了我们如何定义一个简单的名为lowercase1的管道,该管道将作为参数传递给它的字符串转换为小写表示:
@Pipe({ name: 'lowercase1' })
class LowerCasePipe1 implements PipeTransform {
transform(value: string): string {
if (!value) return value;
if (typeof value !== 'string') {
throw new Error('Invalid pipe value', value);
}
return value.toLowerCase();
}
}
为了保持一致性,让我们展示定义管道的 ECMAScript 5 语法:
var LowercasePipe1 = ng.core
.Pipe({
name: 'lowercase1'
})
.Class({
constructor: function () {},
transform: function (value) {
if (!value) return value;
if (typeof value === 'string') {
throw new Error('Invalid pipe value', value);
}
return value.toLowerCase();
}
});
使用 TypeScript 语法,我们实现PipeTransform接口并定义其中声明的transform方法。然而,在 ECMAScript 5 中,我们没有对接口的支持,但我们需要实现transform方法以定义一个有效的 Angular 管道。我们将在下一章解释 TypeScript 接口。
现在,让我们演示如何在组件内部使用lowercase1管道:
@Component({
selector: 'app',
template: '<h1>{{"SAMPLE" | lowercase1}}</h1>'
})
class App {}
@NgModule({
declarations: [App, LowerCasePipe1],
bootstrap: [App],
imports: [BrowserModule]
})
class AppModule {}
platformBrowserDynamic().bootstrapModule(AppModule);
此外,此处的替代 ECMAScript 5 语法如下:
var App = ng.core.Component({
selector: 'app',
template: '<h1>{{"SAMPLE" | lowercase1}}</h1>'
})
.Class({
constructor: function () {}
});
var AppModule = ng.core.NgModule({
declarations: [App, LowerCasePipe1],
bootstrap: [App],
imports: [BrowserModule]
})
.Class({
constructor: function {}
});
ng.platformBrowserDynamic
.platformBrowserDynamic()
.bootstrapModule(AppModule);
我们可以使用以下标记使用 App 组件:
<app></app>
我们将在屏幕上看到的将是 h1 元素内的文本样本。请注意,我们在 @NgModule 装饰器的 declarations 属性中包含了 LowerCasePipe1 的引用。
通过将数据格式化逻辑作为一个单独的组件,Angular 保持了一直以来可以看到的强关注点分离。我们将在 第七章 解释管道和与 RESTful 服务通信 中看看我们如何为我们的应用程序定义有状态和无状态的管道。
改进变更检测
如我们之前看到的,MVC 中的视图根据从模型接收到的变更事件来更新自己。许多 Model View Whatever (MVW) 框架采取了这种方法,并在其变更检测机制的核心中嵌入观察者模式。
经典的变更检测
让我们看看一个简单的例子,它没有使用任何框架。假设我们有一个名为 User 的模型,它有一个名为 name 的属性:
class User extends EventEmitter {
private name: string;
setName(name: string) {
this.name = name;
this.emit('change');
}
getName(): string {
return this.name;
}
}
前面的代码片段再次使用了 TypeScript。如果你对语法不熟悉,不要担心,我们将在下一章介绍这门语言。
user 类扩展了 EventEmitter 类。这为发出和订阅事件提供了原语。
现在,让我们定义一个视图,该视图显示 User 类实例的 name,它作为参数传递给其 constructor:
class View {
constructor(user: User, el: Element /* a DOM element */) {
el.innerHTML = user.getName();
}
}
我们可以通过以下方式初始化 view 元素:
let user = new User();
user.setName('foo');
let view = new View(user, document.getElementById('label'));
最终结果,用户将看到一个包含内容 foo 的标签。然而,用户的更改不会在视图中反映出来。为了在用户 name 发生更改时更新视图,我们需要订阅 change 事件,然后更新 DOM 元素的内容。我们需要以下方式更新 View 定义:
class View {
constructor(user:User, el:any /* a DOM element */) {
el.innerHTML = user.getName();
user.on('change', () => {
el.innerHTML = user.getName();
});
}
}
这就是大多数框架在 AngularJS 时代之前如何实现变更检测的方式。
AngularJS 的变更检测
大多数初学者都对 AngularJS 中的数据绑定机制着迷。基本的 “Hello world” 示例看起来像这样:
function MainCtrl($scope) {
$scope.label = 'Hello world!';
}
<body ng-app ng-controller="MainCtrl">
{{label}}
</body>
如果你运行这个程序,Hello world! 就会神奇地出现在屏幕上。然而,这并不是唯一令人印象深刻的事情!如果我们添加一个文本输入并将其绑定到作用域的 label 属性,每次更改都会反映在插值指令显示的内容中:
<body ng-controller="MainCtrl">
<input ng-model="label">
{{label}}
</body>
这有多么酷!这是 AngularJS 的主要卖点之一——实现数据绑定的极端简单性。我们在标记中添加了一些属性,插值指令,将 label 属性添加到一个神秘的对象 $scope 中,这个对象神奇地传递给我们定义的自定义函数,然后一切就简单地工作了!
经验更丰富的 Angular 开发者对幕后实际发生的事情有更好的理解。在前面的例子中,在指令内部,Angular 添加了与相同表达式label相关联的不同行为的观察者——ng-model和ng-bind(在我们的例子中,是插值指令{{}})。这些观察者与经典 MVC 模式中的观察者相当相似。在特定事件(在我们的例子中,是文本输入内容的变化)发生时,AngularJS 会遍历所有这样的观察者,在给定作用域的上下文中评估与它们相关联的表达式,并存储它们的结果。这个循环被称为消化循环。
在前面的例子中,在作用域的上下文中评估表达式label将返回文本,Hello world!。在每次迭代中,AngularJS 会将当前评估结果与上一个结果进行比较,并在值不同的情况下调用相关回调。例如,插值指令添加的回调会将元素的 内容设置为表达式评估的新结果。这是一个两个指令观察者回调之间依赖性的例子。由ng-model添加的观察者回调修改了与插值指令添加的观察者相关联的表达式的结果。
这种方法有其自身的缺点。我们说过,消化循环将在某些特定事件上被调用,但如果我们使用setTimeout,并在回调函数中(作为第一个参数传递),改变我们正在监视的作用域附加的属性,这些事件会发生在框架之外;例如,如果我们使用setTimeout,并在回调函数中(作为第一个参数传递),改变我们正在监视的作用域附加的属性,AngularJS 将不会意识到这种变化,并且不会调用消化循环,因此我们需要显式地使用$scope.$apply来做这件事。但是,如果框架知道浏览器中发生的所有异步事件,例如用户事件、XMLHttpRequest事件、与WebSocket相关的事件以及其他事件,会怎样呢?在这种情况下,Angular 将能够拦截事件处理,并可以在不强迫我们这样做的情况下调用消化循环!
在zone.js
在 Angular 的新版本中,这种情况正是如此。这个功能是通过使用zone.js来实现的。
在 2014 年的 ng-conf 上,Brian Ford 做了一次关于 zones 的演讲。Brian 将 zones 描述为浏览器 API 的元猴子补丁。Zone.js 是由 Angular 团队开发的一个库,它在 JavaScript 中实现了 zones。它们代表一个执行上下文,允许我们拦截异步浏览器调用。基本上,使用 zones,我们能够在给定的XMLHttpRequest完成或当我们收到新的WebSocket事件时调用一段逻辑。Angular 通过拦截异步浏览器事件并在正确的时间调用消化循环来利用zone.js。这完全消除了使用 Angular 的开发者显式调用消化循环的需要。
简化的数据流
跨观察者依赖关系可能会在我们的应用程序中创建复杂的数据流,难以追踪。这可能导致不可预测的行为和难以发现的错误。尽管 Angular 将脏检查作为一种实现变更检测的方式,但它强制执行单向数据流。这是通过不允许不同观察者之间的依赖关系来实现的,这允许仅运行一次消化循环。这种策略显著提高了我们应用程序的性能,并降低了数据流的复杂性。Angular 还对内存效率和消化循环的性能进行了改进。有关 Angular 的变更检测及其实现的不同策略的更多详细信息,请参阅第四章,开始使用 Angular 组件和指令。
提升 AngularJS 的变更检测
现在,让我们退一步,再次思考框架的变更检测机制。
我们说过,在消化循环内部,Angular 评估注册的表达式,并将评估的值与循环前一次迭代中与相同表达式关联的值进行比较。
用于比较的最优算法可能取决于从表达式评估返回的值的类型。例如,如果我们得到一个可变的项目列表,我们需要遍历整个集合,逐个比较集合中的项目,以验证是否有变化。然而,如果我们有一个不可变列表,我们只需通过比较引用来执行检查,就可以以恒定的复杂度进行检查。这是因为不可变数据结构的实例不能改变。而不是应用一个旨在修改这些实例的操作,我们将得到一个应用了修改的新引用。
在 AngularJS 中,我们可以使用几种方法添加观察者。其中两种是$watch(exp, fn, deep)和$watchCollection(exp, fn)。这些方法让我们在一定程度上控制变更检测将如何执行相等性检查。例如,使用$watch添加观察者并将false值作为第三个参数传递,将使 AngularJS 执行引用检查(即使用===比较当前值和前一个值)。然而,如果我们传递一个真值(任何true值),检查将是深层次的(即使用angular.equals)。这样,根据表达式值的预期类型,我们可以以最合适的方式添加监听器,以便让框架使用最优化算法执行相等性检查。此 API 有两个限制:
-
它不允许你在运行时选择最合适的相等性检查算法。
-
它不允许你将变更检测扩展到第三方特定的数据结构。
Angular 核心团队将这项责任分配给了 differs,使他们能够根据我们在应用程序中使用的数据扩展并优化更改检测机制。Angular 定义了两个基本类,我们可以扩展它们来定义自定义算法:
-
KeyValueDiffer:这允许我们对基于键值的数据结构进行高级的 diffing 操作。 -
IterableDiffer:这允许我们对类似列表的数据结构进行高级的 diffing 操作。
Angular 允许我们通过扩展自定义算法来完全控制更改检测机制,这在框架的先前版本中是不可能的。我们将在第四章入门 Angular 组件和指令中进一步探讨更改检测以及我们如何配置它。
服务
服务是 Angular 为定义我们应用程序的业务逻辑提供的构建块。在 AngularJS 中,我们有三种不同的方法来定义服务:
// The Factory method
module.factory('ServiceName', function (dep1, dep2, ...) {
return {
// public API
};
});
// The Service method
module.service('ServiceName', function (dep1, dep2, ...) {
// public API
this.publicProp = val;
});
// The Provider method
module.provider('ServiceName', function () {
return {
$get: function (dep1, dep2, ...) {
return {
// public API
};
}
};
});
虽然前两种语法变体提供了类似的功能,但它们在注册服务实例化的方式上有所不同。第三种语法允许在配置时间进一步配置注册提供者。
对于 AngularJS 初学者来说,有三种不同的方法来定义服务确实很令人困惑。让我们思考一下,是什么促使引入这些方法来注册服务。为什么我们不能简单地使用 Angular 不会意识到的 JavaScript 构造函数、对象字面量或 ES2015 类呢?我们可以像这样将我们的业务逻辑封装在一个自定义 JavaScript 构造函数中:
function UserTransactions(id) {
this.userId = id;
}
UserTransactions.prototype.makeTransaction = function (amount) {
// method logic
};
module.controller('MainCtrl', function () {
this.submitClick = function () {
new UserTransactions(this.userId).makeTransaction(this.amount);
};
});
这段代码完全有效。然而,它没有充分利用 AngularJS 提供的一个关键特性:DI 机制。MainCtrl 函数使用构造函数 UserTransaction,该函数在其主体中可见。前述代码有两个主要缺陷:
-
我们与服务实例化所使用的逻辑耦合在一起。
-
代码不可测试。为了模拟
UserTransactions,我们需要对其进行猴子补丁。
AngularJS 如何处理这两件事?当需要某个服务时,通过框架的 DI 机制,AngularJS 解析其所有依赖项,并通过传递给一个封装其创建逻辑的工厂函数来实例化它。工厂函数作为 factory 和 service 方法的第二个参数传递。provider 方法允许在较低级别上定义服务;那里的工厂方法是 $get 属性下的一个。
就像 AngularJS 一样,Angular 的新版本也容忍这种关注点的分离,因此核心团队保留了服务。与 AngularJS 相比,框架的最后一个主要版本通过允许我们使用纯 ES2015 类或 ES5 构造函数来定义它们的接口,提供了一个更简单的接口。我们无法逃避这样一个事实,即我们需要明确声明应可注入的服务,并 somehow 指定它们的实例化指令。与 AngularJS 相比,现在框架使用 ES2016 装饰器的语法来实现这一目的,而不是我们从 AngularJS 中熟悉的方法。这允许我们将应用程序中的服务定义为简单的 ES2015 类,并使用装饰器来配置 DI:
import {Injectable} from '@angular/core';
@Injectable()
class HttpService {
constructor() { /* ... */ }
}
@Injectable()
class User {
constructor(private service: HttpService) {}
save() {
return this.service.post('/users')
.then(res => {
this.id = res.id;
return this;
});
}
}
这是替代的 ECMAScript 5 语法:
var HttpService = ng.core.Class({
constructor: function () {}
});
var User = ng.core.Class({
constructor: [HttpService, function (service) {
this.service = service;
}],
save: function () {
return this.service.post('/users')
.then(function (res) {
this.id = res.id;
return this;
});
}
});
服务与前面章节中描述的组件和指令相关。为了开发高度一致和可重用的 UI 组件,我们需要将所有与业务相关的逻辑移动到我们的服务内部。此外,为了开发可测试的组件,我们需要利用 DI 机制来解决它们的所有依赖项。
与 AngularJS 中的服务相比,它们依赖项的解决和内部表示方式存在一个核心差异。AngularJS 使用字符串来标识不同的服务和用于其实例化的相关工厂。另一方面,现在 Angular 使用键。通常,键是不同服务的类型。在实例化方面的另一个核心差异是注入器的分层结构,它们封装了具有不同可见性的不同依赖项提供者。
在框架的最后两个主要版本中,服务之间的另一个区别是简化的语法。尽管 Angular 的新版本使用 ES2015 类来定义我们的业务逻辑,但我们也可以使用 ECMAScript 5 的constructor函数,或者使用框架提供的 DSL。Angular 最新版本的依赖注入(DI)具有完全不同的语法,并通过提供一种一致的方式来注入依赖项,从而改善了行为。前一个示例中使用的语法是 ES2016 装饰器,在第五章,Angular 中的依赖注入中,我们将探讨另一种语法,它使用 ECMAScript 5。你还可以在第五章,Angular 中的依赖注入中找到对 Angular 服务和 DI 的更详细解释。
新的 router
在传统的 Web 应用程序中,所有页面更改都与整个页面重新加载相关联,这会获取所有引用的资源和数据,并将整个页面渲染到屏幕上。然而,Web 应用程序的需求随着时间的推移而发展。
我们用 Angular 构建的单页应用程序(SPAs)模拟桌面用户体验。这通常涉及按需加载应用程序所需的资源和数据,并且在初始页面加载后没有全页刷新。在 SPAs 中,不同的页面或视图通常由不同的模板表示,这些模板异步加载并在屏幕上的特定位置渲染。稍后,当包含所有所需资源的模板加载并且路由更改时,与所选页面关联的逻辑被调用,并用数据填充模板。如果用户在我们的 SPA 中加载给定页面后按下刷新按钮,则在视图刷新完成后,需要重新渲染相同的页面。这涉及到类似的行为:找到请求的视图,获取所有引用资源的所需模板,并调用与该视图关联的逻辑。
需要获取的模板以及页面成功刷新后应调用的逻辑,取决于用户在按下刷新按钮之前选中的视图。框架通过解析包含当前选中页面标识符的页面 URL 来确定这一点,该标识符以分层结构表示。
所有与导航、更改 URL、加载适当的模板以及在视图加载时调用特定逻辑相关的责任都分配给了路由器组件。这些是一些相当具有挑战性的任务,并且支持不同导航 API 以实现跨浏览器兼容性,使得在现代 SPAs 中实现路由成为一个非平凡问题。
AngularJS 在其核心中引入了路由器,后来将其外部化为 ngRoute 模块。它允许通过为每个页面提供一个模板以及当页面被选中时需要调用的逻辑,以声明式的方式定义我们 SPA 中的不同视图。然而,路由器的功能有限。它不支持基本功能,例如嵌套视图路由。这就是为什么大多数开发者更愿意使用社区开发的 ui-router 的一个原因。AngularJS 的路由器和 ui-router 的路由定义都包含一个路由配置对象,该对象定义了一个与页面关联的模板和一个控制器。
如前几节所述,Angular 改变了它为开发 SPAs 提供的构建块。Angular 移除了浮动控制器,而是将视图表示为组件的组合。这需要开发一个全新的路由器,它赋予这些新概念能力。
AngularJS 路由器和新的 Angular 路由器之间的核心区别如下:
-
新的 路由器是基于组件的,
ngRoute不是。新的 Angular 路由器将组件与单个路由或模块关联,这在懒加载路由的情况下适用。 -
现在支持嵌套视图。
Angular 路由定义语法
让我们简要了解一下 Angular 路由器在应用中定义路由所使用的新语法:
import {Component, NgModule} from '@angular/core';
import {BrowserModule} from '@angular/platform-browser';
import {RouterModule, Routes} from '@angular/router';
import {HomeComponent} from './home/home.component';
import {AboutComponent} from './about/about.component';
import {AppComponent} from './app.component';
const routes: Routes = [
{ path: 'home', component: HomeComponent },
{ path: 'about', component: AboutComponent }
];
@NgModule({
imports: [BrowserModule, RouterModule.forRoot(routes)],
declarations: [AppComponent, HomeComponent, AboutComponent],
bootstrap: [AppComponent]
})
export class AppModule {}
由于第六章使用 Angular 路由器和表单和第七章解释管道和与 RESTful 服务通信专门介绍了新路由器,所以我们这里不会过多深入,但让我们提及前面代码片段中的主要点。
路由器位于@angular/router。由于AppModule是我们应用的根模块,我们使用RouterModule的forRoot方法来导入路由器导出的所有所需指令和服务。
传递给RouterModule.forRoot装饰器的参数显示了我们在应用中如何定义路由。我们使用一个对象数组,它定义了路由与其相关组件之间的映射。
摘要
在本章中,我们快速概述了 Angular 提供的用于开发单页应用(SPAs)的主要构建块。我们指出了与 AngularJS 核心概念的主要区别。
虽然我们可以使用 ES2015,甚至 ES5 来构建 Angular 应用,但谷歌的建议是利用用于框架开发的语言-TypeScript。这样我们就可以使用诸如预编译等高级功能,这些功能我们将在第八章工具和开发体验中描述。
在下一章中,我们将探讨 TypeScript 以及我们如何在下一个应用中开始使用它。我们还将解释如何通过环境类型定义利用用纯 JavaScript 编写的 JavaScript 库和框架中的静态类型。
第三章 TypeScript 快速入门
在本章中,我们将开始使用 TypeScript,这是谷歌推荐与 Angular 一起使用的语言。所有 ECMAScript 2015 和 ECMAScript 2016 提供的功能,如函数、类、模块和装饰器,都已经实现或添加到 TypeScript 的路线图中。由于额外的类型注解,与 JavaScript 相比,有一些语法上的添加。
为了更平滑地从当时由现代浏览器完全支持的编程语言(即 ES5)过渡,我们将从 ES2016 和 TypeScript 之间的共同特性开始。当 ES2016 语法和 TypeScript 之间存在差异时,我们将明确指出。在本章的后半部分,我们将为到目前为止所学的所有内容添加类型注解。
在本章的后面部分,我们将解释 TypeScript 提供的额外功能,例如静态类型和额外语法。我们将讨论基于这些功能的不同后果,这将帮助我们提高生产效率并减少错误。让我们开始吧!
TypeScript 简介
TypeScript 是由微软开发和维护的开源编程语言。它的首次公开发布是在 2012 年 10 月。TypeScript 是 ECMAScript 的超集,支持 JavaScript 的所有语法和语义,并在其基础上增加了额外的功能,例如静态类型和更丰富的语法。
图 1 展示了 ES5、ES2015、ES2016 和 TypeScript 之间的关系。
https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/gtst-ng-2e/img/5081_03_01.jpg
图 1
由于 TypeScript 是静态类型的,它可以为我们这些 JavaScript 开发者提供许多好处。现在让我们快速看一下这些好处。
编译时类型检查
在编写 JavaScript 代码时,我们最常见的错误之一是拼写属性或方法名错误。通常,当我们遇到运行时错误时,我们会发现这个错误。这可能在开发过程中发生,也可能在生产过程中发生。希望我们在将代码部署到生产环境之前就能知道这个错误,这并不是一个令人舒服的感觉!然而,这并不是 JavaScript 所特有的问题;这是所有动态语言的共同问题。即使有大量的单元测试,这些错误也可能被忽略。
TypeScript 提供了一个编译器,通过使用静态代码分析来处理这些错误。如果我们利用静态类型,TypeScript 将知道给定对象具有哪些现有属性,如果我们拼写错误,编译器将以编译时错误的形式警告我们。
TypeScript 的另一个巨大好处是,它允许大型团队协作,因为它提供了正式且可验证的命名。这样,它允许我们编写易于理解的代码。
更好的文本编辑器和 IDE 支持
有许多工具,如 Tern,试图在文本编辑器和 IDE 中为 JavaScript 提供更好的自动完成支持。然而,由于 JavaScript 是一种动态语言,IDE 和文本编辑器在没有元数据的情况下无法做出复杂的建议。例如,Google Closure Compiler 就使用 JSDoc 中提供的类型注解来为语言提供静态类型。
使用此类元数据注释代码是 TypeScript 中称为类型注解的内置功能。基于这些类型注解,文本编辑器和 IDE 可以对我们的代码进行更好的静态分析。这提供了更好的重构工具和自动完成功能,提高了我们的生产力,并允许我们在编写应用程序的源代码时犯更少的错误。
TypeScript 还有更多内容
TypeScript 本身还有其他一些好处:
-
它是 JavaScript 的超集:所有 JavaScript 程序(例如,ES5 和 ES2015)已经是有效的 TypeScript 程序。本质上,您已经一直在编写 TypeScript 代码。由于它基于 ECMAScript 标准的最新版本,它允许我们利用语言提供的最新尖端语法。
-
支持可选类型检查:如果出于任何原因,我们决定不想显式地定义变量或方法的类型,我们可以直接跳过类型定义。然而,我们应该意识到这意味着我们不再利用静态类型的好处,因此我们放弃了之前提到的所有好处。
-
由微软开发和维护:该语言的实现质量非常高,不太可能意外地放弃支持。TypeScript 基于世界上一些最好的编程语言开发专家的工作。
-
它是开源的:这允许社区自由地为语言做出贡献,并提出功能建议,这些建议以开放的方式进行讨论。TypeScript 是开源的事实使得第三方扩展和工具的开发更加容易,这进一步扩展了其使用范围。
由于现代浏览器不支持 TypeScript 的原生支持,存在一个编译器,它将我们编写的 TypeScript 代码转换成预定义的 ECMAScript 目标版本的可读 JavaScript。一旦代码被编译,所有的类型注解都会被移除。
使用 TypeScript
让我们开始编写一些 TypeScript 代码!
在以下章节中,我们将查看不同的代码片段,展示 TypeScript 的一些特性。为了能够运行这些代码片段并自行尝试,您需要在您的计算机上安装 TypeScript 编译器。让我们看看如何进行这一操作。
TypeScript 最好使用Node 包管理器(npm)安装。我建议您使用 npm 的 3.0.0 或更高版本。如果您还没有安装 node.js 和 npm,您可以访问nodejs.org并遵循那里的说明。
使用 npm 安装 TypeScript
一旦安装并运行了 npm,请通过打开终端窗口并运行以下命令来验证您是否拥有最新版本:
$ npm -v
使用以下命令来安装 TypeScript 2.1.0 或更高版本:
$ npm install -g typescript@².1.0
前面的命令将安装 TypeScript 编译器并将其可执行文件(tsc)添加为全局。
为了验证一切是否正常工作,您可以使用以下命令:
$ tsc -v
Version 2.1.1
输出应该与前面的类似,尽管版本可能不同。
注意
注意,我们通过在版本号前加 caret 符号来安装 TypeScript。这意味着npm将下载 2.x.x 范围内的任何版本,但低于 3.0.0。
运行我们的第一个 TypeScript 程序
现在,让我们编译我们的第一个 TypeScript 程序!创建一个名为hello.ts的文件,并输入以下内容:
// ch3/hello-world/hello-world.ts
console.log('Hello world!');
由于我们已经安装了 TypeScript 编译器,您应该有一个名为tsc的全局可执行命令。您可以使用它来编译文件:
$ tsc hello.ts
现在,您应该能在与hello.ts相同的目录中看到文件hello.js。hello.js是 TypeScript 编译器的输出;它包含了与您所写的 TypeScript 等价的 JavaScript。您可以使用以下命令运行此文件:
$ node hello.js
现在,您将在屏幕上看到字符串**Hello world!**被打印出来。为了将编译和运行程序的过程结合起来,您可以使用ts-node包:
$ npm install -g ts-node
现在,您可以运行:
$ ts-node hello.ts
您应该看到相同的结果,但磁盘上没有存储ts-node文件。
提示
您可以在github.com/mgechev/getting-started-with-angular找到这本书的代码。大多数代码片段的第一行都有一个注释,它显示了您可以在样本存储库的目录结构中找到完整示例的位置。请注意,路径是相对于app目录的。
TypeScript 语法和 ES2015 及 ES2016 引入的特性
由于 TypeScript 是 JavaScript 的超集,在我们开始学习其语法之前,先介绍 ES2015 和 ES2016 的一些重大变化会更容易;为了理解 TypeScript,我们首先必须理解 ES2015 和 ES2016。在适当的时候,我们将对这些变化进行快速浏览,然后再深入 TypeScript。
ES2015 和 ES2016 的详细解释超出了本书的范围。为了熟悉所有新特性和语法,我强烈建议您阅读Dr. Axel Rauschmayer的Exploring ES6: Upgrade to the next version of JavaScript。
接下来的几页将介绍新的标准,并允许您在开发 Angular 应用程序时利用您将需要的几乎所有功能。
ES2015 箭头函数
JavaScript 有第一类函数,这意味着它们可以像任何其他值一样传递:
// ch3/arrow-functions/simple-reduce.ts
var result = [1, 2, 3].reduce(function (total, current) {
return total + current;
}, 0); // 6
这种语法很棒;然而,它有点太冗长了。ES2015 引入了一种新的语法来定义匿名函数,称为箭头函数语法。使用它,我们可以创建匿名函数,如下面的例子所示:
// ch3/arrow-functions/arrow-functions.ts
// example 1
var result = [1, 2, 3]
.reduce((total, current) => total + current, 0);
console.log(result); // 6
// example 2
var even = [3, 1, 56, 7].filter(el => !(el % 2));
console.log(even); // [56]
// example 3
var sorted = data.sort((a, b) => {
var diff = a.price - b.price;
if (diff !== 0) {
return diff;
}
return a.total - b.total;
});
在第一个例子中,我们得到了数组[1, 2, 3]中元素的总和。在第二个例子中,我们得到了数组[3, 1, 56, 7]中的所有偶数。在第三个例子中,我们按属性price和total的升序排序了数组。
箭头函数有几个我们需要关注的特性。最重要的特性是它们保留了周围代码的上下文(this):
// ch3/arrow-functions/context-demo.ts
function MyComponent() {
this.age = 42;
setTimeout(() => {
this.age += 1;
console.log(this.age);
}, 100);
}
new MyComponent(); // 43 in 100ms.
例如,当我们用操作符new调用MyComponent函数时;这将指向由调用创建的新对象。箭头函数将保持上下文(this),在setTimeout的回调中,并在屏幕上打印43。
这在 Angular 中非常有用,因为给定组件的绑定上下文是其实例(即其this实例)。如果我们定义MyComponent为 Angular 组件,并且有一个绑定到age属性的绑定,前面的代码将是有效的,并且所有绑定都将工作(注意我们没有作用域,也没有对$digest循环的显式调用,尽管我们直接调用了setTimeout)。
使用 ES2015 和 ES2016 类
当新手 JavaScript 开发者听到这种语言支持面向对象(OO)范式时,当他们发现没有用于定义类的语法时通常会感到困惑。这种观念源于一些最流行的编程语言,如 Java、C#和 C++,它们都有用于构建对象的类的概念。然而,JavaScript 以不同的方式实现了 OO 范式。JavaScript 有一个基于原型的面向对象编程模型,我们可以使用对象字面量语法或函数(也称为构造函数)来实例化对象,并且我们可以利用所谓的原型链来利用继承。
虽然这是一种实现面向对象范式的有效方式,并且语义与经典面向对象模型相似,但对于不确定如何正确处理这些内容的经验不足的 JavaScript 开发者来说,这可能会令人困惑。这就是 TC39 决定提供一种替代语法来在语言中使用面向对象范式的原因之一。幕后,新语法具有与我们习惯相同的语义,例如使用构造函数和基于原型的继承。然而,它提供了一个更方便的语法,以更少的样板代码来启用面向对象范式的功能。
ES2016 为 ES2015 类添加了一些额外的语法,例如静态和实例属性声明。
下面是一个示例,演示了在 ES2016 中定义类的语法:
// ch3/es6-classes/sample-classes.ts
class Human {
static totalPeople = 0;
_name; // ES2016 property declaration syntax
constructor(name) {
this._name = name;
Human.totalPeople += 1;
}
get name() {
return this._name;
}
set name(val) {
this._name = val;
}
talk() {
return `Hi, I'm ${this.name}!`;
}
}
class Developer extends Human {
_languages; // ES2016 property declaration syntax
constructor(name, languages) {
super(name);
this._languages = languages;
}
get languages() {
return this._languages;
}
talk() {
return `${super.talk()} And I know ${this.languages.join(',')}.`;
}
}
在 ES2015 中,显式声明_name属性不是必需的;然而,由于 TypeScript 编译器在编译时应该知道给定类的实例的现有属性,我们需要将属性的声明添加到类声明本身中。
前面的代码片段既是有效的 TypeScript 也是 JavaScript 代码。在其中,我们定义了一个名为Human的类,它为其实例化的对象添加了一个单个属性。它是通过将属性的值设置为传递给其构造函数的参数name的值来实现的。
现在,打开ch3/es6-classes/sample-classes.ts文件并尝试一下!你可以以创建构造函数对象相同的方式创建类的不同实例:
var human = new Human('foobar');
var dev = new Developer('bar', ['JavaScript']);
console.log(dev.talk());
为了执行代码,请运行以下命令:
$ ts-node sample-classes.ts
类在 Angular 中常用。你可以使用它们来定义你的组件、指令、服务和管道。然而,你也可以使用替代的 ES5 语法,它利用了构造函数。在底层,一旦 TypeScript 代码被编译,这两种语法之间就不会有显著的差异,因为 ES2015 类无论如何都会被转换为构造函数。
定义具有块作用域的变量
对于有不同背景的开发者来说,JavaScript 的另一个令人困惑的点是该语言的变量作用域。例如,在 Java 和 C++中,我们习惯于块作用域。这意味着在特定块内部定义的变量将仅在块内部及其嵌套的所有块内部可见。
然而,在 JavaScript 中,事情略有不同。ECMAScript 定义了一个具有类似块作用域语法的功能词法作用域,但它使用函数而不是块。让我们看一下以下代码片段:
// ch3/let/var.ts
var fns = [];
for (var i = 0; i < 5; i += 1) {
fns.push(function() {
console.log(i);
});
}
fns.forEach(fn => fn());
这有一些奇怪的含义。一旦代码执行,它将记录数字5的五倍。
ES2015 添加了一种新的语法来定义具有块作用域可见性的变量。语法与当前语法类似,但是,它使用关键字let而不是var:
// ch3/let/let.ts
var fns = [];
for (let i = 0; i < 5; i += 1) {
fns.push(function() {
console.log(i);
});
}
fns.forEach(fn => fn());
使用 ES2016 装饰器进行元编程
JavaScript 是一种动态语言,它允许我们轻松地修改和/或改变行为以适应我们编写的程序。装饰器是 ES2016 的一个提案,根据设计文档github.com/wycats/javascript-decorators:
“…使设计时对类和属性的注释和修改成为可能。”
它们的语法与 Java 中的注释非常相似,甚至与 Python 中的装饰器更接近。ES2016 装饰器在 Angular 中常用以定义组件、指令和管道,并利用框架的依赖注入机制。装饰器的多数用例涉及改变行为以符合预定义逻辑或向不同的结构添加一些元数据。
ES2016 装饰器通过改变程序的行为,使我们能够执行许多复杂操作。典型的用例可能是注释给定方法或属性为已弃用或只读。可以在名为Jay Phelps的项目core-decorators.js中找到一组预定义的装饰器,这些装饰器可以提高我们生成的代码的可读性。另一个用例是利用基于代理的面向方面编程,使用声明性语法。提供此功能的库是aspect.js。
通常,ES2016 装饰器只是另一种语法糖,它转换为我们已经熟悉的来自语言先前版本的 JavaScript 代码。让我们看看提案草案中的一个简单示例:
// ch3/decorators/nonenumerable.ts
class Person {
@nonenumerable
get kidCount() {
return 42;
}
}
function nonenumerable(target, name, descriptor) {
descriptor.enumerable = false;
return descriptor;
}
var person = new Person();
for (let prop in person) {
console.log(prop);
}
在这种情况下,我们有一个名为Person的 ES2015 类,它有一个名为kidCount的单个 getter。在kidCount getter 上,我们应用了@nonenumerable装饰器。装饰器是一个接受目标(Person类)、我们打算装饰的目标属性名称(kidCount)以及目标属性描述符的函数。在更改描述符后,我们需要返回它以应用修改。基本上,装饰器的应用可以按以下方式转换为 ECMAScript 5:
descriptor = nonenumerable(Person.prototype, 'kidCount', descriptor) || descriptor;
Object.defineProperty(Person.prototype, 'kidCount', descriptor);
使用可配置的装饰器
这里是一个使用 Angular 定义的装饰器的示例:
@Component({
selector: 'app',
providers: [NamesList],
templateUrl: './app.html',
})
export class App {}
当装饰器接受参数(就像前面示例中的Component一样)时,它们需要定义为接受参数并返回实际装饰器的函数:
function Component(config) {
// validate properties
return (componentCtrl) => {
// apply decorator
};
}
在这个例子中,我们定义了一个接受单个参数config并返回装饰器的函数Component。
使用 ES2015 编写模块化代码
多年来,JavaScript 专业人士一直面临的一个问题是语言中缺乏模块系统。最初,社区开发了不同的模式,旨在强制执行我们生产的软件的模块化和封装。这些模式包括模块模式,它利用了函数词法作用域和闭包的优势。另一个例子是命名空间模式,它将不同的命名空间表示为嵌套对象。AngularJS 引入了自己的模块系统,但遗憾的是,它不提供诸如懒加载模块等特性。然而,这些模式更像是一种权宜之计,而不是真正的解决方案。
CommonJS(用于 node.js)和AMD(异步模块定义)后来被发明。它们至今仍被广泛使用,并提供诸如处理循环依赖、异步模块加载(在 AMD 中)等功能。
TC39 从现有的模块系统中吸取了最好的部分,并在语言层面上引入了这个概念。ES2015 提供了两个 API 来定义和消费模块。它们如下所示:
-
声明式 API。
-
使用模块加载器的命令式 API。
Angular 充分利用了 ES2015 模块系统,所以让我们深入探讨!在本节中,我们将查看用于声明性定义和消费模块的语法。我们还将窥视模块加载器的 API,以了解我们如何以显式异步的方式编程加载模块。
使用 ES2015 模块语法
让我们看看一个例子:
// ch3/modules/math.ts
export function square(x) {
return Math.pow(x, 2);
};
export function log10(x) {
return Math.log10(x);
};
export const PI = Math.PI;
在前面的代码片段中,我们在math.ts文件中定义了一个简单的 ES2015 模块。我们可以将其视为一个示例数学 Angular 实用模块。在其中,我们定义并导出square和log10函数以及常量PI。const关键字是 ES2015 带来的另一个关键字,用于定义常量。正如你所见,我们所做的就是用关键字export来前缀函数的定义。如果我们想在最后导出整个功能并跳过重复的显式export使用,我们可以使用以下方法:
// ch3/modules/math2.ts
function square(x) {
return Math.pow(x, 2);
};
function log10(x) {
return Math.log10(x);
};
const PI = Math.PI;
export { square, log10, PI };
最后那一行的语法不过是增强的对象字面量语法,由 ES2015 引入。现在,让我们看看我们如何使用这个模块:
// ch3/modules/app.ts
import {square, log10} from './math';
console.log(square(2)); // 4
console.log(log10(10)); // 1
作为模块的标识符,我们使用其相对于当前文件的相对路径。使用解构,我们导入所需的函数——在这种情况下,square和log10。
利用模块的隐式异步行为
需要注意的是,ES2015 模块语法具有隐式的异步行为。
https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/gtst-ng-2e/img/2-1.jpg
图 2
在前面的图中,我们有模块 A、B 和 C。模块 A 使用模块 B 和 C,因此它依赖于它们。一旦用户需要模块 A,JavaScript 模块加载器就需要在能够调用模块 A 中驻留的任何逻辑之前加载模块 B 和 C,因为这些模块有依赖关系。模块 B 和 C 将异步加载。一旦它们完全加载,JavaScript 虚拟机将能够执行模块 A。
使用别名
另一个典型的情况是我们想为给定的导出使用别名。例如,如果我们使用第三方库,我们可能想要重命名其任何导出,以避免名称冲突或仅仅为了更方便的命名:
import {
platformBrowserDynamic as platform
} from '@angular/platform-browser-dynamic';
导入所有模块导出
我们可以使用以下语法导入整个 math 模块:
// ch3/modules/app2.ts
import * as math from './math';
console.log(math.square(2)); // 4
console.log(math.log10(10)); // 1
console.log(math.PI); // 3.141592653589793
这个语法的语义与 CommonJS 非常相似,尽管在浏览器中,我们有隐式的异步行为。
默认导出
如果一个给定的模块定义了一个导出,这很可能会被其任何消费者模块使用,我们可以利用默认导出语法:
// ch3/modules/math3.ts
export default function cube(x) {
return Math.pow(x, 3);
};
export function square(x) {
return Math.pow(x, 2);
};
为了使用此模块,我们可以使用以下 app.ts 文件:
// ch3/modules/app3.ts
import cube from './math3';
console.log(cube(3)); // 27
或者,如果我们想导入默认导出并执行其他导出,我们可以使用:
// ch3/modules/app4.ts
import cube, { square } from './math3';
console.log(square(2)); // 4
console.log(cube(3)); // 27
通常,默认导出不过是一个用保留字 default 命名的命名导出:
// ch3/modules/app5.ts
import { default as cube } from './math3';
console.log(cube(3)); // 27
ES2015 模块加载器
标准的新版本定义了一个程序化 API 来处理模块。这就是所谓的模块加载器 API。它允许我们定义和导入模块,或者配置模块加载。
假设我们在文件 app.js 中有以下模块定义:
import { square } from './math';
export function main() {
console.log(square(2)); // 4
}
从 init.js 文件中,我们可以使用以下方式程序化地加载 app 模块并调用其 main 函数:
System.import('./app')
.then(app => {
app.main();
})
.catch(error => {
console.log('Terrible error happened', error);
});
全局对象 System 有一个名为 import 的方法,允许我们使用它们的标识符导入模块。在上面的代码片段中,我们导入了在 app.js 中定义的模块 app。System.import 返回一个承诺,它可以在成功时解析或因错误而拒绝。一旦承诺在传递给 then 的回调函数的第一个参数上解析,我们将得到模块实例。在拒绝的情况下注册的回调函数的第一个参数是一个表示发生错误的对象的错误。
上一个代码片段中的代码不存在于 GitHub 仓库中,因为它需要一些额外的配置。我们将在本书的后续章节中更明确地应用模块加载器,在 Angular 示例中。
ES2015 和 ES2016 回顾
恭喜!我们学习 TypeScript 已经超过了一半。我们刚才看到的所有功能都是 TypeScript 的一部分,因为 TypeScript 实现了 JavaScript 的超集;由于所有这些功能都是在当前语法之上的升级,所以对于经验丰富的 JavaScript 开发者来说很容易掌握。
在接下来的章节中,我们将描述 TypeScript 的所有令人惊叹的功能,这些功能与 ECMAScript 的交集之外。
利用静态类型定义的优势
静态类型定义可以为我们提供更好的开发工具。在编写 JavaScript 时,IDE 和文本编辑器能做的最多只是语法高亮,并基于对代码的复杂静态分析提供一些基本的自动补全建议。这意味着我们只能通过运行代码来验证我们没有犯任何错误。
在前面的章节中,我们只描述了 ECMAScript 预期将在不久的将来由浏览器实现的新的功能。在本节中,我们将探讨 TypeScript 提供的内容,以帮助我们减少错误并提高生产力。在撰写本书时,没有计划在浏览器中实现内置的静态类型支持。
TypeScript 代码会经过中间预处理,执行类型检查并丢弃所有类型注解,以便提供现代浏览器支持的合法 JavaScript。
使用显式类型定义
就像 Java 和 C++一样,TypeScript 允许我们显式地声明给定变量的类型:
let foo: number = 42;
上述行使用let语法在当前块中定义变量foo。我们明确声明我们希望foo是number类型,并将foo的值设置为42。
现在,让我们尝试改变foo的值:
let foo: number = 42;
foo = '42';
在这里,在声明foo之后,我们将将其值设置为字符串'42'。这是一段完全有效的 JavaScript 代码;然而,如果我们使用 TypeScript 编译器编译它,我们将得到:
$ tsc basic.ts
basic.ts(2,1): error TS2322: Type 'string' is not assignable to type 'number'.
一旦foo与给定的类型相关联,我们就不能将其分配给不同类型的值。这是我们可以在给定的变量赋值时跳过显式类型定义的一个原因:
let foo = 42;
foo = '42';
由于 TypeScript 的类型推断,这段代码的语义将与显式类型定义的代码相同。我们将在本章末尾进一步探讨它。
any类型
TypeScript 中的所有类型都是称为any的类型的一个子类型。我们可以使用any关键字声明属于any类型的变量。这样的变量可以持有任何类型的值:
let foo: any;
foo = {};
foo = 'bar ';
foo += 42;
console.log(foo); // "bar 42"
上述代码是有效的 TypeScript,编译或运行时不会抛出任何错误。如果我们为所有变量使用any类型,我们基本上就是在使用动态类型编写代码,这会丢弃 TypeScript 编译器的所有好处。这就是为什么我们必须小心使用any,并且只在必要时使用它。
TypeScript 中的其他类型属于以下类别之一:
-
原始类型:这些包括 Number、String、Boolean、Void、Null、Undefined 和 Enum 类型。
-
联合类型:这些类型超出了本书的范围。您可以在 TypeScript 的规范中查看它们。
-
对象类型:这些包括函数类型、类和接口类型引用、数组类型、元组类型、函数类型和构造函数类型。
-
类型参数:这些包括将在 使用类型参数编写泛型代码 部分中描述的泛型。
理解原始类型
TypeScript 中的大多数原始类型都是我们在 JavaScript 中已经熟悉的:Number(数字)、String(字符串)、Boolean(布尔)、Null(空值)和 Undefined(未定义)。因此,我们在这里将跳过它们的正式解释。在开发 Angular 应用程序时,用户定义的 Enum 类型也是非常有用的类型集。
枚举类型
枚举类型是原始用户定义类型,根据规范,它们是 Number 的子类。枚举的概念存在于 Java、C++ 和 C# 语言中,在 TypeScript 中,用户定义的类型由称为元素的命名值集组成,具有相同的语义。在 TypeScript 中,我们可以使用以下语法定义枚举:
enum STATES {
CONNECTING,
CONNECTED,
DISCONNECTING,
WAITING,
DISCONNECTED
};
这将被翻译成以下 JavaScript:
var STATES;
(function (STATES) {
STATES[STATES["CONNECTING"] = 0] = "CONNECTING";
STATES[STATES["CONNECTED"] = 1] = "CONNECTED";
STATES[STATES["DISCONNECTING"] = 2] = "DISCONNECTING";
STATES[STATES["WAITING"] = 3] = "WAITING";
STATES[STATES["DISCONNECTED"] = 4] = "DISCONNECTED";
})(STATES || (STATES = {}));
我们可以使用如下方式使用枚举类型:
if (this.state === STATES.CONNECTING) {
console.log('The system is connecting');
}
理解对象类型
在本节中,我们将探讨数组类型和函数类型,它们属于更通用的对象类型类别。我们还将探讨如何定义类和接口。元组类型是在 TypeScript 1.3 中引入的,其主要目的是允许语言开始对 ES2015 引入的新功能进行类型化,例如解构。我们不会在本书中描述它们。有关进一步阅读,您可以查看语言的规范www.typescriptlang.org。
数组类型
在 TypeScript 中,数组是具有公共元素类型的 JavaScript 数组。这意味着我们无法在给定的数组中包含不同类型的元素。我们为 TypeScript 中的所有内置类型以及我们定义的所有自定义类型提供了不同的数组类型。
我们可以这样定义一个数字数组:
let primes: number[] = [];
primes.push(2);
primes.push(3);
如果我们想要一个看起来异构的数组,类似于 JavaScript 中的数组,我们可以使用对 any 类型的类型引用:
let randomItems: any[] = [];
randomItems.push(1);
randomItems.push('foo');
randomItems.push([]);
randomItems.push({});
这是因为我们推送到数组中的所有值的类型都是 any 类型的子类型,并且我们声明的数组包含 any 类型的值。
我们可以使用在 JavaScript 中熟悉的数组方法与所有 TypeScript 数组类型一起使用:
let randomItems: any[] = [];
randomItems.push('foo');
randomItems.push('bar');
randomItems.join(''); // foobar
randomItems.splice(1, 0, 'baz');
randomItems.join(''); // foobazbar
我们还有一个方括号运算符,它允许我们随机访问数组的元素:
let randomItems: any[] = [];
randomItems.push('foo');
randomItems.push('bar');
randomItems[0] === 'foo'
randomItems[1] === 'bar'
函数类型
我们已经熟悉如何在 JavaScript 中定义一个新函数。我们可以使用函数表达式或函数声明:
// function expression
var isPrime = function (n) {
// body
};
// function declaration
function isPrime(n) {
// body
};
或者,我们可以使用新的箭头函数语法:
var isPrime = n => {
// body
};
TypeScript 改变的只是定义函数参数类型和返回结果类型(即函数签名)的功能。在语言编译器执行类型检查和转译之后,所有的类型注解都将被移除。如果我们使用函数表达式并将函数赋值给变量,我们可以按以下方式定义变量类型:
let variable: (arg1: type1, arg2: type2, ..., argn: typen) => returnType
考虑以下示例:
let isPrime: (n: number) => boolean = n => {
// body
};
如果我们想在对象字面量中定义一个方法,我们可以按以下方式操作:
let math = {
squareRoot(n: number): number {
// ...
}
};
在前面的示例中,我们使用 ES2015 语法定义了一个名为 squareRoot 的方法的对象字面量。
如果我们想要定义一个产生一些副作用而不是返回结果的函数,我们可以将其返回类型声明为 void:
let person = {
_name: null,
setName(name: string): void {
this._name = name;
}
};
定义类
TypeScript 类与 ES2015 提供的类似。然而,它改变了类型声明并添加了更多语法糖。例如,让我们以我们之前定义的 Human 类为例,将其转换为有效的 TypeScript 类:
class Human {
static totalPeople = 0;
_name: string;
constructor(name) {
this._name = name;
Human.totalPeople += 1;
}
get name() {
return this._name;
}
set name(val) {
this._name = val;
}
talk() {
return `Hi, I'm ${this.name}!`;
}
}
当前 TypeScript 的定义与我们之前介绍的定义之间没有区别;然而,在这种情况下, _name 属性的声明是强制性的。以下是我们可以如何使用这个类的方法:
let human = new Human('foo');
console.log(human._name);
使用访问修饰符
类似地,对于大多数支持类的传统面向对象语言,TypeScript 允许定义访问修饰符。为了防止在定义该属性的类外部直接访问 _name 属性,我们可以将其声明为私有:
class Human {
static totalPeople = 0;
private _name: string;
// ...
}
TypeScript 支持的访问修饰符如下:
-
公共的:所有声明为公共的属性和方法可以从任何地方访问。
-
私有的:所有声明为私有的属性和方法只能从定义该类本身的定义内部访问。
-
受保护的:所有声明为受保护的属性和方法都可以从类定义内部或任何扩展拥有该属性或方法的类的定义内部访问。
访问修饰符是实现具有良好封装性和明确接口的 Angular 服务的绝佳方式。为了更好地理解它,让我们看看一个使用之前定义的类层次结构的示例,并将其移植到 TypeScript 中:
class Human {
static totalPeople = 0;
constructor(protected name: string, private age: number) {
Human.totalPeople += 1;
}
talk() {
return `Hi, I'm ${this.name}!`;
}
}
class Developer extends Human {
constructor(name: string, private languages: string[], age: number) {
super(name, age);
}
talk() {
return `${super.talk()} And I know ${this.languages.join(', ')}.`;
}
}
就像 ES2015 一样,TypeScript 支持使用 extends 关键字,并将其转换为原型 JavaScript 继承。
在前面的例子中,我们在构造函数内部直接设置了name和age属性的访问修饰符。这个语法背后的语义与上一个例子中使用的不同。它的含义如下:定义一个名为name的受保护属性,类型为string,并将构造函数调用传递的第一个值分配给它。对于私有的age属性也是如此。这使我们免去了在构造函数中显式设置值的麻烦。如果我们查看Developer类的构造函数,我们可以看到我们可以使用这些语法的混合。我们可以在构造函数的签名中显式定义属性,或者我们只定义构造函数接受给定类型的参数。
现在,让我们创建一个Developer类的新实例:
let dev = new Developer('foo', ['JavaScript', 'Go'], 42);
dev.languages = ['Java'];
在编译期间,TypeScript 会抛出一个错误,告诉我们:属性 languages 是私有的,并且只能在类"Developer"内部访问。现在,让我们看看如果我们创建一个新的Human类并尝试从其定义外部访问其属性会发生什么:
let human = new Human('foo', 42);
human.age = 42;
human.name = 'bar';
在这种情况下,我们会得到以下两个错误:
属性 age 是私有的,并且只能在类"Human"内部访问,而属性 name 是受保护的,并且只能在类"Human"及其子类内部访问。
然而,如果我们尝试在Developer的定义内部访问_name属性,编译器不会抛出任何错误。
为了更好地理解 TypeScript 编译器将从一个类型注解的类中生成什么,让我们看看以下定义生成的 JavaScript 代码:
class Human {
constructor(private name: string) {}
}
生成的 ECMAScript 5 代码如下:
var Human = (function () {
function Human(name) {
this.name = name;
}
return Human;
})();
定义属性是直接添加到通过使用new运算符调用构造函数实例化的对象中的。这意味着一旦代码编译完成,我们就可以直接访问创建的对象的私有成员。为了总结这一点,语言中添加了访问修饰符,以便帮助我们强制更好的封装,并在违反封装时在编译时得到错误。
定义接口
在编程语言中,子类型化允许我们根据观察它们是通用对象的特殊版本,以相同的方式处理对象。这并不意味着它们必须是同一类对象的实例,或者它们在接口之间有完全的交集。这些对象可能只有几个共同的属性,但在特定上下文中仍然可以以相同的方式处理。在 JavaScript 中,我们通常使用鸭子类型。我们可能会根据假设这些方法存在,为传递给函数的所有对象调用特定的方法。然而,我们所有人都经历过 JavaScript 解释器抛出的undefined is not a function错误。
面向对象编程和 TypeScript 提供了一个解决方案。它们允许我们确保如果我们的对象实现了声明它们拥有的属性子集的接口,它们将具有相似的行为。
例如,我们可以定义我们的接口 Accountable:
interface Accountable {
getIncome(): number;
}
现在,我们可以通过执行以下操作来确保 Individual 和 Firm 都实现了这个接口:
class Firm implements Accountable {
getIncome(): number {
// ...
}
}
class Individual implements Accountable {
getIncome(): number {
// ...
}
}
如果我们实现了一个给定的接口,我们需要为其中定义的所有方法提供实现,否则 TypeScript 编译器将抛出错误。我们实现的方法必须与接口定义中声明的签名相同。
TypeScript 接口也支持属性。在 Accountable 接口中,我们可以包含一个名为 accountNumber 的字段,其类型为字符串:
interface Accountable {
accountNumber: string;
getIncome(): number;
}
我们可以在我们的类中将其定义为字段或获取器。
接口继承
接口也可以相互扩展。例如,我们可以将我们的 Individual 类转换为一个具有社会保险号的接口:
interface Accountable {
accountNumber: string;
getIncome(): number;
}
interface Individual extends Accountable {
ssn: string;
}
由于接口支持多重继承,Individual 类也可以扩展具有 name 和 age 属性的接口 Human:
interface Accountable {
accountNumber: string;
getIncome(): number;
}
interface Human {
age: number;
name: number;
}
interface Individual extends Accountable, Human {
ssn: string;
}
实现多个接口
如果类的行为是几个接口中定义的属性的并集,它可能实现所有这些接口:
class Person implements Human, Accountable {
age: number;
name: string;
accountNumber: string;
getIncome(): number {
// ...
}
}
在这种情况下,我们需要提供实现接口中声明的所有方法的实现,否则编译器将抛出编译时错误。
使用 TypeScript 装饰器进一步增强表达性
在 ES2015 中,我们只能装饰类、属性、方法、获取器和设置器。TypeScript 进一步允许我们装饰函数或方法参数:
class Http {
// ...
}
class GitHubApi {
constructor(@Inject(Http) http) {
// ...
}
}
请记住,参数装饰器不应该改变任何额外的行为。相反,它们用于生成元数据。这些装饰器的最典型用例是 Angular 的依赖注入机制。
通过使用类型参数编写泛型代码
在本节关于使用静态类型的内容的开头,我们提到了类型参数。为了更好地理解它们,让我们从一个例子开始。假设我们想要实现经典的数据结构 BinarySearchTree。让我们使用一个没有应用任何方法实现的类来定义它的接口:
class Node {
value: any;
left: Node;
right: Node;
}
class BinarySearchTree {
private root: Node;
insert(any: value): void { /* ... */ }
remove(any: value): void { /* ... */ }
exists(any: value): boolean { /* ... */ }
inorder(callback: {(value: any): void}): void { /* ... */ }
}
在前面的代码片段中,我们定义了一个名为 Node 的类。这个类的实例代表我们树中的单个节点。每个 node 都有一个左子节点和一个右子节点,以及一个 any 类型的值;我们使用 any 以便能够在我们的节点和 BinarySearchTree 中存储任何类型的数据。
虽然早期的实现看起来合理,但我们放弃了 TypeScript 提供的最重要特性之一,那就是静态类型。在 Node 类的值字段中使用 any 作为类型,我们无法充分利用编译时类型检查。这也限制了 IDE 和文本编辑器在我们访问 Node 类实例的 value 属性时提供的功能。
TypeScript 提供了一个优雅的解决方案,这种解决方案在静态类型的世界中已经非常流行——那就是类型参数。通过使用泛型,我们可以使用类型参数来参数化我们创建的类。例如,我们可以将我们的 Node 类转换为以下形式:
class Node<T> {
value: T;
left: Node<T>;
right: Node<T>;
}
Node<T> 表示这个类有一个名为 T 的单个类型参数,它在类的定义中被使用。我们可以通过以下代码片段使用 Node:
let numberNode = new Node<number>();
let stringNode = new Node<string>();
numberNode.right = new Node<number>();
numberNode.value = 42;
numberNode.value = '42'; // Type "string" is not assignable to type "number"
numberNode.left = stringNode; // Type Node<string> is not assignable to type Node<number>
在前面的代码片段中,我们创建了三个节点:numberNode、stringNode 和另一个类型为 Node<number> 的节点,将其值赋给 numberNode 的右子节点。请注意,由于 numberNode 的类型是 Node<number>,我们可以将其值设置为 42,但不能使用字符串 '42'。同样的规则也适用于其左子节点。在定义中,我们明确声明了左子和右子节点的类型应该是 Node<number>。这意味着我们不能将 Node<string> 类型的值赋给它们;这就是为什么我们得到了第二个编译时错误。
使用泛型函数
泛型的另一个典型用途是定义在一系列类型上操作的功能。例如,我们可能定义一个接受类型 T 参数并返回它的 identity 函数:
function identity<T>(arg: T) {
return arg;
}
然而,在某些情况下,我们可能只想使用具有某些特定属性的类型实例。为了实现这一点,我们可以使用一个扩展语法,允许我们声明用作类型参数的类型应该是给定类型的子类型:
interface Comparable {
compare(a: Comparable): number;
}
function sort<T extends Comparable>(arr: Comparable[]): Comparable[] {
// ...
}
例如,在这里,我们定义了一个名为 Comparable 的接口。它有一个名为 compare 的单个操作。实现 Comparable 接口的类需要实现 compare 操作。当 compare 被给定参数调用时,如果目标对象大于传递的参数,则返回 1;如果它们相等,则返回 0;如果目标对象小于传递的参数,则返回 -1。
具有多个类型参数
TypeScript 允许我们使用多个类型参数:
class Pair<K, V> {
key: K;
value: V;
}
在这种情况下,我们可以使用以下语法创建 Pair<K, V> 类的实例:
let pair = new Pair<string, number>();
pair.key = 'foo';
pair.value = 42;
使用 TypeScript 的类型推断编写更简洁的代码
静态类型有许多好处;然而,它通过添加所有类型注解,使我们编写的代码更加冗长。
在某些情况下,TypeScript 编译器能够猜测我们代码中表达式的类型;让我们考虑这个例子,例如:
let answer = 42;
answer = '42'; // Type "string" is not assignable to type "number"
在前面的例子中,我们定义了一个变量answer并将其值42赋给它。由于 TypeScript 是静态类型的,变量的类型一旦声明就不能改变,因此编译器足够智能,可以猜测answer的类型是number。
如果我们不在其定义内为变量赋值,编译器将将其类型设置为any:
let answer;
answer = 42;
answer = '42';
前面的代码片段将编译而不会出现任何编译时错误。
最佳公共类型
有时,类型推断可能是几个表达式的结果。这种情况发生在我们将异构数组赋值给一个变量时:
let x = ['42', 42];
在这种情况下,x的类型将是any[]。然而,假设我们有以下情况:
let x = [42, null, 32];
由于Number类型是Null的子类型,因此x的类型将是number[]。
上下文类型推断
当表达式的类型从其位置暗示时发生上下文类型;让我们以这个例子为例:
document.body.addEventListener('mousedown', e => {
e.foo(); // Property "foo" does not exists on a type "MouseEvent"
}, false);
在这种情况下,回调函数e的参数类型是由编译器根据其使用的上下文来猜测的。编译器理解e的类型是基于addEventListener的调用和传递给方法的参数。如果我们使用键盘事件(例如keydown),TypeScript 就会知道e的类型是KeyboardEvent。
类型推断是一种机制,通过利用 TypeScript 执行的静态分析,我们可以编写更简洁的代码。根据上下文,TypeScript 的编译器能够猜测给定表达式的类型,而无需显式定义。
使用环境类型定义
虽然静态类型很神奇,但我们使用的多数前端库都是用 JavaScript 编写的,它是动态类型的。由于我们想在 Angular 中使用 TypeScript,没有在代码中使用外部 JavaScript 库的类型定义是一个大问题;它阻止了我们利用编译时类型检查的优势。
TypeScript 正是考虑到这些点而构建的。为了允许 TypeScript 编译器处理它最擅长的事情,我们可以使用所谓的环境类型定义。它们允许我们提供现有 JavaScript 库的外部类型定义。这样,它们为编译器提供提示。
使用预定义的环境类型定义
幸运的是,我们不必为我们使用的所有 JavaScript 库和框架创建环境类型定义。这些库的社区和/或作者已经在线发布了这样的定义;最大的存储库位于github.com/DefinitelyTyped/DefinitelyTyped。在过去的几个月里,社区开发了一些用于管理环境类型定义的工具,例如tsd和typings。
后来,微软引入了一种官方的方式来管理它们,通过在tsconfig.json中提供额外的配置来使用**npm**。现在,类型定义作为命名空间@types下的范围包进行分发,并安装到node_modules中。
让我们创建一个目录并向其中添加一个package.json文件:
$ mkdir types-test && cd types-test && npm init
在我们为npm询问的问题提供默认值后,位于types-test目录下的package.json应该看起来像这样:
{
"name": "types-test",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo "Error: no test specified" && exit 1"
},
"author": "",
"license": "ISC"
}
我们可以使用以下方式安装新的类型定义:
$ npm install @types/angular --save-dev
上述命令将下载 AngularJS 的类型定义并将它们保存到node_modules/@types/angular中。注意,我们向npm提供了--save-dev标志,以便将类型定义保存到package.json的devDependencies下。
小贴士
当安装环境类型定义时,我们通常会使用--save-dev而不是--save,因为定义主要在开发中使用。
运行上述命令后,您的package.json文件应类似于以下内容:
{
"name": "types-test",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo "Error: no test specified" && exit 1"
},
"author": "",
"license": "ISC",
"devDependencies": {
"@types/angular": "¹.5.20"
}
}
现在,为了使用 TypeScript 中的 AngularJS,创建app.ts并输入以下内容:
/// <reference path="./node_modules/@types/angular/index.d.ts"/>
var module = angular.module('module', []);
module.controller('MainCtrl',
function MainCtrl($scope: angular.IScope) {
});
要编译app.ts,请使用:
$ tsc app.ts
TypeScript 编译器会将编译后的内容输出到app.js。为了添加额外的自动化并每次您更改项目中的任何文件时都调用 TypeScript 编译器,您可以使用任务运行器,如 gulp 或 grunt,或者将-w选项传递给tsc。
注意
由于使用reference元素来包含类型定义被认为是不良实践,我们可以使用tsconfig.json文件代替。在那里,我们可以通过tsc配置需要包含在编译过程中的目录。有关更多信息,请访问www.typescriptlang.org/docs/handbook/tsconfig-json.html。
现在,让我们在同一目录下创建一个名为tsconfig.json的文件,内容如下:
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"experimentalDecorators": true,
"outDir": "./dist"
},
"files": [
"./app.ts"
]
}
在此配置文件中,我们提供了compilerOptions属性,这样我们就不必将outDir和module格式等参数作为标志传递给tsc。注意,在files属性中,我们也列出了我们想要编译的文件。TypeScript 将编译所有这些文件以及它们的传递依赖项!
现在,让我们修改我们前面的简单片段:
var module = angular.module('module', []);
module.controller('MainCtrl',
function MainCtrl($scope: angular.IScope) {
const set = new Set<any>();
});
我们所做的唯一改变是在声明和初始化一个新常量时添加一行,该常量是通过调用Set构造函数函数并使用any作为类型参数返回的结果。通过在app.ts文件和node_modules相同的目录下有tsconfig.json,我们可以通过运行以下命令来编译项目:
$ tsc
然而,我们会得到以下错误:
demo.ts(4,22): error TS2304: Cannot find name ‘Set’.
Set 实现了集合数据结构,并作为 ES2015 标准的一部分。由于在所有 TypeScript 项目中使用 ES2015 的环境类型定义是一种非常常见的做法,因此 Microsoft 将其作为 TypeScript 本身的一部分添加。在 tsconfig.json 中的 compilerOptions 属性内,添加以下 lib 属性:
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"experimentalDecorators": true,
"outDir": "./dist",
"lib": ["es2015", "dom"]
},
"files": [
"./demo.ts"
]
}
lib 包含一个数组,其中包含 "es2015" 和 "dom",因为我们需要 ES2015 Set,Angular 的类型定义需要 文档对象模型(DOM)的类型定义。现在,当你在你 tsconfig.json 文件所在的目录中运行 tsc 时,编译过程应该成功通过,输出文件应位于 ./dist/demo.js。
自定义环境类型定义
为了理解所有这些是如何协同工作的,让我们看一个例子。假设我们有一个 JavaScript 库的以下接口:
var DOM = {
// Returns a set of elements which match the passed selector
selectElements: function (selector) {
// ...
},
hide: function (element) {
// ...
},
show: function (element) {
// ...
}
};
我们将一个对象字面量赋值给名为 DOM 的变量。该对象具有以下方法:
-
selectElements:这个方法接受一个类型为字符串的单个参数,并返回一个DOM元素集合。 -
hide:这个方法接受一个DOM节点作为参数,并返回空值。 -
show:这个方法接受一个DOM节点作为参数,并返回空值。
在 TypeScript 中,前面的定义如下所示:
var DOM = {
// Returns a set of elements which match the passed selector
selectElements: function (selector: string): HTMLElement[] {
//...
return [];
},
hide: function (element: HTMLElement): void {
element.hidden = true;
},
show: function (element: HTMLElement): void {
element.hidden = false;
}
};
这意味着我们可以定义我们库的接口如下:
interface LibraryInterface {
selectElements(selector: string): HTMLElement[]
hide(element: HTMLElement): void
show(element: HTMLElement): void
}
在我们有了库的接口之后,创建环境类型定义将变得容易;我们只需创建一个扩展名为 d.ts 的名为 dom 的文件,并输入以下内容:
// inside "dom.d.ts"
interface DOMLibraryInterface {
selectElements(selector: string): HTMLElement[]
hide(element: HTMLElement): void
show(element: HTMLElement): void
}
declare var DOM: DOMLibraryInterface;
在前面的代码片段中,我们定义了一个名为 DOMLibraryInterface 的接口,并声明了类型为 DOMLibraryInterface 的 DOM 变量。
在能够使用静态类型与我们的 JavaScript 库一起使用之前,我们唯一要做的就是将外部类型定义包含在我们想要使用库的脚本文件中。我们可以这样做:
/// <reference path="dom.d.ts"/>
前面的代码片段提示编译器在哪里找到环境类型定义。一种替代的、更好的方法是通过使用上面描述的 tsconfig.json 来提供对 d.ts 文件的引用。
摘要
在本章中,我们简要介绍了用于 Angular 实现的 TypeScript 语言。虽然我们可以使用 ECMAScript 5 开发我们的 Angular 应用程序,但 Google 的建议是使用 TypeScript 以利用它提供的静态类型。
在探索语言的过程中,我们查看了一些 ES2015 和 ES2016 的核心特性。我们解释了 ES2015 和 ES2016 的类、箭头函数、块作用域变量定义、解构和模块。由于 Angular 利用 ES2016 装饰器,以及更准确地说 TypeScript 对它们的扩展,因此我们专门用一节来介绍它们。
在此之后,我们探讨了如何利用静态类型通过显式类型定义来提高效率。我们描述了 TypeScript 中的一些内置类型,以及如何通过指定成员的访问修饰符来在语言中定义类。我们的下一站是接口。我们通过解释类型参数和全局类型定义来结束我们在 TypeScript 中的冒险之旅。
在下一章中,我们将开始深入探索 Angular,使用框架的组件和指令。
更多推荐

所有评论(0)