TypeScript-2-x-学习指南-全-
TypeScript 2.x 学习指南(全)
原文:
zh.annas-archive.org/md5/30977cdd12d2fd68062b20bc58beee42译者:飞龙
前言
在过去的十年中,平均 Web 应用程序的 JavaScript 代码库呈指数级增长。然而,当前版本的 JavaScript 是几年前设计的,缺乏一些应对现代 JavaScript 应用程序可能遇到的复杂程度所必需的特性。由于这些缺失的特性,维护性问题已经出现。
ECMAScript 2015规范旨在解决 JavaScript 的一些维护性问题,但其实现仍在进行中,许多不兼容的 Web 浏览器仍在使用。因此,广泛采用ECMAScript 2015规范预计将是一个缓慢的过程。
为了解决 JavaScript 的维护性和可扩展性问题,TypeScript 在 2012 年 10 月公开发布,这是在微软内部开发两年之后:
“我们设计 TypeScript 是为了满足构建和维护大型 JavaScript 程序的 JavaScript 编程团队的需求。TypeScript 帮助编程团队定义软件组件之间的接口,并深入了解现有 JavaScript 库的行为。TypeScript 还使团队能够通过将代码组织成动态可加载的模块来减少命名冲突。TypeScript 的可选类型系统使 JavaScript 程序员能够使用高度生产力的开发工具和实践:静态检查、基于符号的导航、语句完成和代码重构。”
——TypeScript 语言规范 1.0
一些在 Web 开发方面有多年经验的开发者可能会发现定义大型 JavaScript 应用程序具有挑战性。当我们提到这个术语时,我们将避免考虑应用程序中的代码行数。考虑应用程序中的模块和实体数量以及它们之间的依赖关系作为衡量应用程序规模的标准会更好。我们将大型应用程序定义为非平凡的应用程序,这些应用程序需要大量的开发者努力来维护。
《TypeScript 2.x 第二版学习指南》以简单易懂的格式介绍了 TypeScript 的许多特性。本书将教你如何使用 TypeScript 实现大型 JavaScript 应用程序所需的一切知识。它不仅教授 TypeScript 的核心特性,这些特性对于实现 Web 应用程序至关重要,而且还探讨了某些强大的开发工具、设计原则和良好实践,并展示了如何将它们应用于实际应用中。
第二版已经升级和扩展,增加了五个额外的章节,涵盖了诸如函数式编程、高级类型系统特性、使用 React 和 Angular 进行前端开发的介绍、Node.js 开发的介绍以及 TypeScript 编译器内部 API 的介绍。
新版包含总共 15 章。其中 7 章是完全新的,并未包含在第一版中。
这本书面向的对象
如果你是一名开发者,旨在学习 TypeScript 来构建吸引人的 Web 应用程序,这本书适合你。不需要先前的 TypeScript 知识。然而,对 JavaScript 的基本理解将是一个额外的优势。
要充分利用这本书
本书不需要任何外部资源。你只需遵循本书中提到的内容,就能充分利用本书。
下载示例代码文件
你可以从www.packtpub.com的账户下载这本书的示例代码文件。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。
你可以通过以下步骤下载代码文件:
-
在www.packtpub.com上登录或注册。
-
选择 SUPPORT 标签。
-
点击代码下载和勘误表。
-
在搜索框中输入书籍名称,并遵循屏幕上的说明。
下载文件后,请确保使用最新版本的软件解压缩或提取文件夹:
-
Windows 上的 WinRAR/7-Zip
-
Mac 上的 Zipeg/iZip/UnRarX
-
Linux 上的 7-Zip/PeaZip
书籍的代码包也托管在 GitHub 上,网址为github.com/remojansen/LearningTypeScript。如果代码有更新,它将在现有的 GitHub 仓库中更新。GitHub 仓库将包括有关如何运行每个示例的详细说明。
我们还有其他来自我们丰富图书和视频目录的代码包可供选择,网址为github.com/PacktPublishing/。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/LearningTypeScript2xSecondEdition_ColorImages.pdf。
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“如果我们尝试预测调用isIndexPage的结果,我们需要了解当前状态。”
代码块设置如下:
function addMany(...numbers: number[]) {
numbers.reduce((p, c) => p + c, 0);
}
任何命令行输入或输出都应如下编写:
npm install typescript -g
粗体:表示新术语、重要单词或你在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从管理面板中选择系统信息。”
警告或重要注意事项如下显示。
小贴士和技巧看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈: 发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过questions@packtpub.com给我们发送电子邮件。
勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告这一点,我们将不胜感激。请访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版: 如果您在互联网上以任何形式发现了我们作品的非法副本,如果您能向我们提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并附上材料的链接。
如果您有兴趣成为作者: 如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以看到并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
想了解更多关于 Packt 的信息,请访问 packtpub.com。
第一章:介绍 TypeScript
本书旨在为您提供 TypeScript 的功能、其限制和其生态系统的广泛概述。您将了解 TypeScript 语言、开发工具、设计模式和推荐实践。
本章将为您概述 TypeScript 的历史,并介绍其一些基础知识。
在本章中,您将了解以下概念:
-
TypeScript 架构
-
类型注释
-
变量和原始数据类型
-
运算符
-
流程控制语句
-
函数
-
类
-
接口
-
命名空间
TypeScript 架构
在本节中,我们将关注 TypeScript 的内部架构和其原始设计目标。
设计目标
以下列表描述了主要的设计目标和架构决策,这些决策塑造了 TypeScript 编程语言今天的外观:
-
静态识别可能产生错误的 JavaScript 构造:微软的工程师决定,识别和防止潜在运行时问题的最佳方式是创建一个强类型编程语言,并在编译时进行静态类型检查。工程师还设计了一个语言服务层,为开发者提供更好的工具。
-
与现有 JavaScript 代码高度兼容:TypeScript 是 JavaScript 的超集;这意味着任何有效的 JavaScript 程序也是有效的 TypeScript 程序(有一些小的例外)。
-
为较大的代码块提供结构化机制:TypeScript 添加了基于类的面向对象、接口、命名空间和模块等功能。这些特性将帮助我们以更好的方式结构化代码。我们还将通过遵循最佳面向对象原则和推荐实践来减少开发团队内部潜在集成问题,并使代码更容易维护和扩展。
-
不对输出程序产生运行时开销:在考虑 TypeScript 时,区分设计时间和执行时间是常见的。我们使用术语 设计时间 或 编译时间 来指代我们在设计应用程序时编写的 TypeScript 代码,而使用术语 执行时间 或 运行时 来指代编译某些 TypeScript 代码后执行的 JavaScript 代码。
TypeScript 为 JavaScript 添加了一些功能,但这些功能仅在设计时可用。例如,我们可以在 TypeScript 中声明接口,但由于 JavaScript 不支持接口,TypeScript 编译器在运行时(在输出 JavaScript 代码中)不会声明或尝试模拟此功能。
微软工程师为 TypeScript 编译器提供了一些机制,例如代码转换(将 TypeScript 特性转换为纯 JavaScript 实现)和类型擦除(移除静态类型注释),以生成干净的 JavaScript 代码。类型擦除不仅移除了类型注释,还移除了 TypeScript 独有的所有语言特性,如接口。
此外,生成的代码与网络浏览器高度兼容,因为它默认针对 ECMAScript 3 规范,但也支持 ECMAScript 5 和 ECMAScript 6。一般来说,我们可以在编译到任何可用的编译目标时使用 TypeScript 功能,但有时某些功能可能需要 ECMAScript 5 或更高版本作为编译目标。
-
与当前和未来的 ECMAScript 提案保持一致:TypeScript 不仅与现有的 JavaScript 代码兼容;它还与一些未来的 JavaScript 版本兼容。乍一看,我们可能会认为一些 TypeScript 功能使其与 JavaScript 相差甚远,但实际情况是,TypeScript 中可用的所有功能(除了类型系统功能)都遵循 ECMAScript 提案,这意味着许多 TypeScript 文件最终将成为原生 JavaScript 功能。
-
成为跨平台开发工具:微软在 Apache 许可下发布了 TypeScript,它可以在所有主要操作系统上安装和执行。
TypeScript 组件
TypeScript 语言有三个主要的内部层。每一层又依次分为子层或组件。在下面的图中,我们可以看到这三个层(三种不同灰度的层次)以及它们各自的内部组件(方框):

在前面的图中,缩写VS指的是微软的 Visual Studio,它是所有微软产品(包括 TypeScript)的官方集成开发环境(IDE)系列。我们将在第九章自动化您的开发工作流程中了解更多关于这个和其他 IDE 的信息。第九章。
每一个主要层都有不同的用途:
-
语言:包含 TypeScript 语言元素。
-
编译器负责解析、类型检查和将您的 TypeScript 代码转换为 JavaScript 代码。
-
语言服务:生成信息,帮助编辑器和其他工具提供更好的辅助功能,例如 IntelliSense 或自动化重构。
-
IDE 集成(VS Shim):IDE 和文本编辑器的开发者必须进行一些集成工作,以利用 TypeScript 功能。TypeScript 被设计用来促进开发帮助提高 JavaScript 开发者生产力的工具。由于这些努力,将 TypeScript 与 IDE 集成并不复杂。这一点的证明是,目前最流行的 IDE 都包括良好的 TypeScript 支持。
在其他书籍和在线资源中,您可能会找到将术语transpiler而不是compiler的引用。transpiler是一种编译器,它将编程语言的源代码作为输入,并将源代码输出为具有相似抽象级别的另一种编程语言的源代码。
我们将在第十五章 Chapter 15 中学习更多关于 TypeScript 语言服务和 TypeScript 编译器的内容,使用 TypeScript 编译器和语言服务。
TypeScript 语言特性
现在你已经了解了 TypeScript 的用途,是时候动手编写一些代码了。
在你开始学习如何使用一些基本的 TypeScript 构建块之前,你需要设置你的开发环境。开始编写一些 TypeScript 代码最简单、最快的方式是使用在线编辑器,它可以在 TypeScript 官方网站上找到,网址为 www.typescriptlang.org/play/index.html:

上一张截图显示了 TypeScript 操场的样子。如果你访问操场,你将能够使用屏幕左侧的文本编辑器编写 TypeScript 代码。然后代码将被自动编译成 JavaScript。输出代码将被插入到屏幕右侧的文本编辑器中。如果你的 TypeScript 代码无效,右侧的 JavaScript 代码将不会更新。
或者,如果你更喜欢能够离线工作,你可以下载并安装 TypeScript 编译器。如果你使用的是 Visual Studio 2015 之前的版本,你需要从 marketplace.visualstudio.com/ 下载官方 TypeScript 扩展。如果你使用的是 2015 版本之后的 Visual Studio 版本(或 Visual Studio Code),你不需要安装扩展,因为这些版本默认包含 TypeScript 支持。
许多流行的编辑器,如 Sublime (github.com/Microsoft/TypeScript-Sublime-Plugin) 或 Atom (atom.io/packages/atom-typescript),都提供了 TypeScript 插件。
你也可以通过将其作为 npm 模块下载来从命令行界面使用 TypeScript。如果你不熟悉 npm,不要担心。现在,你只需要知道它代表 node package manager,是 Node.js 的默认包管理器。Node.js 是一个开源的、跨平台的 JavaScript 运行时环境,用于在服务器端执行 JavaScript 代码。要能够使用 npm,你需要在你的开发环境中安装 Node.js。你可以在官方网站 nodejs.org/ 上找到 Node.js 的安装文件。
一旦你在你的开发环境中安装了 Node.js,你将能够在控制台或终端中运行以下命令:
npm install -g typescript
基于 Unix 的操作系统在安装全局(-g)npm 包时可能需要使用 sudo 命令。sudo 命令将提示用户输入凭证,并使用管理员权限安装包:
sudo npm install -g typescript
创建一个名为 test.ts 的新文件,并将以下代码添加到其中:
let myNumberVariable: number = 1;
console.log(myNumberVariable);
将文件保存到您选择的目录中,并打开命令行界面。导航到保存文件的目录,并执行以下命令:
tsc test.ts
如果一切顺利,您将在 test.ts 文件所在的同一目录中找到一个名为 test.js 的文件。现在您已经知道如何将 TypeScript 代码编译成 JavaScript 代码。
您可以使用 Node.js 执行输出 JavaScript 代码:
node test.js
现在我们已经了解了如何编译和执行 TypeScript 源代码,我们可以开始学习一些 TypeScript 的特性。
您将在 第九章自动化您的开发工作流程中了解更多关于编辑器、编译器选项和其他 TypeScript 工具的信息。
类型
如我们所知,TypeScript 是 JavaScript 的一个类型超集。TypeScript 通过添加静态类型系统和可选的静态类型注解到 JavaScript 中,将其转换为一个强类型编程语言。
TypeScript 的类型分析完全在编译时进行,不会给程序执行增加运行时开销。
类型推断和可选的静态类型注解
TypeScript 语言服务在自动检测变量的类型方面做得很好。然而,在某些情况下,它无法自动检测类型。
当类型推断系统无法识别变量的类型时,它使用一个称为 any 类型的类型。any 类型代表所有现有的类型,因此它过于灵活,无法检测大多数错误,这不是问题,因为 TypeScript 允许我们使用所谓的 可选的静态类型注解显式声明变量的类型。
可选的静态类型注解被用作对程序实体(如函数、变量和属性)的约束,以便编译器和开发工具在软件开发期间提供更好的验证和辅助(如 IntelliSense)。
强类型允许程序员在代码中表达他们的意图,既是对自己,也是对开发团队中的其他人。
对于一个变量,类型注解位于变量名称之后的冒号之前:
let counter; // unknown (any) type
let counter = 0; // number (inferred)
let counter: number; // number
let counter: number = 0; // number
我们使用了 let 关键字而不是 var 关键字。let 关键字是 TypeScript 提供的一个较新的 JavaScript 构造。我们稍后会讨论细节,但使用 let 可以解决 JavaScript 中的一些常见问题,因此,在可能的情况下,您应该使用 let 而不是 var。
如您所见,我们在变量名之后声明变量的类型;这种类型表示法风格基于类型理论,有助于加强类型是可选的观点。
当没有类型注解可用时,TypeScript 会通过检查分配的值来尝试猜测变量的类型。例如,在第二行,在前面代码片段中,我们可以看到变量 counter 被识别为数值变量,因为它的值是数值。有一个称为 类型推断 的过程可以自动检测并分配一个类型给变量。当类型推断系统无法检测其类型时,将使用 any 类型作为变量的类型。
请注意,配套源代码可能与章节中展示的代码略有不同。配套源代码使用命名空间来隔离每个演示与其他所有演示,有时还会在变量名后附加数字以防止命名冲突。例如,前面的代码包含在配套源代码中如下所示:
namespace type_inference_demo {
let counter1; // unknown (any) type
let counter2 = 0; // number (inferred)
let counter3: number; // number
let counter4: number = 0; // number
}
您将在 第二章,与类型一起工作 中了解更多关于 TypeScript 类型系统的信息。
变量、基本类型和运算符
基本类型包括布尔值、数字、字符串、数组、元组、对象、空对象、null、undefined、{}、void 和枚举。让我们了解这些基本类型中的每一个:
| 数据类型 | 描述 |
|---|
| 布尔值 | 与字符串和数字数据类型可以有几乎无限数量的不同值不同,布尔数据类型只能有两个。它们是文字:true 和 false。布尔值是一个真值;它指定条件是真是假:
let isDone: boolean = false;
|
| 数字 | 与 JavaScript 一样,TypeScript 中的所有数字都是浮点值。这些浮点数具有 number 类型:
let height: number = 6;
|
| 字符串 | 在 TypeScript 中,我们使用 string 数据类型来表示文本。您可以通过将字符串文字用单引号或双引号括起来来在脚本中包含字符串文字。双引号可以包含在单引号包围的字符串中,单引号可以包含在双引号包围的字符串中:
let name: string = "bob";
name = 'Smith';
|
| 数组 | 我们使用 array 数据类型来表示值的集合。array 类型可以使用两种不同的语法风格来编写。我们可以使用数组中元素的类型,后跟方括号 [] 来注释该元素类型的集合:
let list: number[] = [1, 2, 3];
第二种语法风格使用名为 Array<T> 的泛型数组类型:
let list: Array<number> = [1, 2, 3];
|
| 元组 | 元组类型可用于表示具有已知不同类型的固定数量元素的数组。例如,我们可以将一个值表示为一个字符串和数字的配对:
let x: [string, number];
x = ["hello", 10]; // OK
x = ["world", 20]; // OK
x = [10, "hello"]; // Error
x = [20, "world"]; // Error
|
| 枚举 | 我们使用枚举为值集添加更多意义。枚举可以是数字或基于文本的。默认情况下,数字枚举将值 0 分配给枚举中的第一个成员,并为枚举中的每个成员增加 1:
enum Color {Red, Green, Blue};
let c: Color = Color.Green;
|
| Any | TypeScript 中的所有类型都是单个顶级类型 **any** **type** 的子类型。any 关键字引用了这个类型。any 类型消除了大多数 TypeScript 类型检查,并代表所有可能的类型:
let notSure: any = 4; // OK
notSure = "maybe a string instead"; // OK
notSure = false; // OK
any 类型在将现有的 JavaScript 代码迁移到 TypeScript 或当我们对某个类型的某些细节有所了解但不知道所有细节时非常有用。例如,当我们知道一个类型是数组,但我们不知道数组中元素的类型时:
let list: any[] = [1, true, "free"];
list[1] = 100;
|
| 对象(小写) | object 类型代表任何非原始类型。以下类型在 JavaScript 中被认为是原始类型:布尔值、数字、字符串、符号、null 和 undefined。 |
|---|---|
| 对象(大写) | 在 JavaScript 中,所有对象都是 Object 类的派生。Object(大写)描述了所有 JavaScript 对象共有的功能。这包括 toString() 和 hasOwnProperty() 方法等。 |
| 空对象类型 {} | 这描述了一个没有任何自身成员的对象。当你尝试访问此类对象的任意属性时,TypeScript 会发出编译时错误:
const obj = {};
obj.prop = "value"; // Error
|
| Null 和 undefined | 在 TypeScript 中,undefined 和 null 都是类型。默认情况下,null 和 undefined 是所有其他类型的子类型。这意味着你可以将 null 和 undefined 赋值给类似数字的东西。然而,当使用 --strictNullChecks 标志时,null 和 undefined 只能赋值给 void 和它们各自的类型。 |
|---|
| Never 类型 | never 类型在以下两个地方使用:
-
作为永远不会返回的函数的返回类型
-
作为永远不会为真的类型守卫下的变量类型
function impossibleTypeGuard(value: any) {
if (
typeof value === "string" &&
typeof value === "number"
) {
value; // Type never
}
}
|
| Void | 在某些方面,any 的对立面是 void,即没有任何类型的缺失。你将看到这是不返回值的函数的返回类型:
function warnUser(): void {
console.log("This is my warning message");
}
|
在 TypeScript 和 JavaScript 中,undefined 是全局作用域中的一个属性,它被分配给已声明但尚未初始化的变量。值 null 是一个字面量(不是全局对象的属性),它可以被分配给变量,作为没有值的表示:
let testVar; // variable is declared but not initialized
consoe.log(testVar); // shows undefined
console.log(typeof testVar); // shows undefined
let testVar = null; // variable is declared, and null is assigned as its value
cosole.log(testVar); // shows null
console.log(typeof testVar); // shows object
变量作用域(var, let 和 const)
当我们在 TypeScript 中声明一个变量时,我们可以使用 var、let 或 const 关键字:
var myNumber: number = 1;
let isValid: boolean = true;
const apiKey: string = "0E5CE8BD-6341-4CC2-904D-C4A94ACD276E";
使用 var 声明的变量作用域是最近的函数块(或全局,如果不在函数块中)。
使用 let 声明的变量作用域是最近的封闭块(或全局,如果不在任何块中),这可以小于函数块。
const 关键字创建了一个可以是全局的或局部于其声明块的常量。这意味着常量是块作用域的。
你将在 第六章 中了解更多关于作用域的内容,理解运行时。
| 算术运算符
TypeScript 支持以下算术运算符。我们必须假设变量 A 包含 10,变量 B 包含 20,以理解以下示例:
| 运算符 | 描述 | 示例 |
|---|---|---|
- |
从第一个操作数中减去第二个操作数。 | A - B 将给出 -10 |
+ |
将两个操作数相加。 | A + B 将给出 30 |
* |
乘以两个操作数。 | A * B 将给出 200 |
** |
将第一个操作数乘以自身,次数由第二个操作数指示。 | A ** B 将给出 1e+20 |
% |
这是取模运算符,是整数除法后的余数。 | B % A 将给出 0 |
/ |
将分子除以分母。 | B / A 将给出 2 |
-- |
将整数值减一。 | A-- 将给出 9 |
++ |
将整数值加一。 | A++ 将给出 11 |
| 比较运算符
TypeScript 支持以下比较运算符。为了理解示例,你必须假设变量 A 的值为 10,变量 B 的值为 20:
| 运算符 | 描述 | 示例 |
|---|---|---|
== |
检查两个操作数的值是否相等。此运算符使用类型强制转换。如果相等,则条件变为 true。 |
(A == B) 是 false。A == "10" 是 true。 |
=== |
检查两个操作数的值和类型是否相等。此运算符不使用类型强制转换。如果相等,则条件变为 true。 |
A === B 是 false。A === "10" 是 false。 |
!= |
检查两个操作数的值是否相等。如果不相等,则条件变为 true。此运算符使用类型强制转换。 |
(A != B) 是 true。A != "10" 是 false。 |
!== |
检查两个操作数的值是否相等。如果不相等,则条件变为 true。此运算符不使用类型强制转换。 |
A !== B 是 true。A !== "10" 是 true。 |
> |
检查左操作数的值是否大于右操作数的值。如果是,则条件变为 true。 |
(A > B) 是 false。 |
< |
检查左操作数的值是否小于右操作数的值。如果是,则条件变为 true。 |
(A < B) 是 true。 |
>= |
检查左操作数的值是否大于或等于右操作数的值。如果是,则条件变为 true。 |
(A >= B) 是 false。 |
<= |
检查左操作数的值是否小于或等于右操作数的值。如果是,则条件变为 true。 |
(A <= B) 是 true。 |
| 逻辑运算符
TypeScript 支持以下逻辑运算符。为了理解示例,你必须假设变量 A 包含 10,变量 B 包含 20:
| 运算符 | 描述 | 示例 |
|---|---|---|
&& |
被称为逻辑“与”操作符。如果两个操作数都不为零,则条件变为“真”。 | (A && B) 是 true. |
|| |
被称为逻辑“或”操作符。如果两个操作数中的任何一个不为零,则条件变为“真”。 | (A || B) 是 true. |
! |
被称为逻辑“非”操作符。它用于反转其操作数的逻辑状态。如果条件为“真”,则逻辑“非”操作符将使其变为“假”。 | !(A && B) 是 false. |
位运算符
TypeScript 支持以下位运算符。要理解这些示例,你必须假设变量 A 的值为 2,变量 B 的值为 3:
| 运算符 | 描述 | 示例 |
|---|---|---|
& |
被称为位运算符的“与”操作符,它对其整数参数的每个位执行布尔“与”操作。 | (A & B) 是 2 |
| |
被称为位运算符的“或”操作符,它对其整数参数的每个位执行布尔“或”操作。 | (A | B) 是 3. |
^ |
被称为位运算符的“异或”操作符,它对其整数参数的每个位执行布尔独占“或”操作。独占“或”意味着操作数一为真或操作数二为真,但不能同时为真。 | (A ^ B) 是 1. |
~ |
被称为位运算符的“非”操作符,它是一个一元操作符,通过反转操作数中的所有位来操作。 | (~B) 是 -4 |
<< |
被称为位运算符左移操作符。它将其第一个操作数的所有位向左移动由第二个操作数指定的位数。新位用零填充。将值左移一位相当于乘以二,左移两位相当于乘以四,依此类推。 | (A << 1) 是 4 |
>> |
被称为带符号的位运算符右移操作符。它将其第一个操作数的所有位向右移动由第二个操作数指定的位数。 | (A >> 1) 是 1 |
>>> |
被称为带零的位运算符右移操作符。这个操作符与 >> 操作符类似,但左移出的位总是零。 |
(A >>> 1) 是 1 |
在 C++、Java 或 C# 等语言中使用位运算符的主要原因是它们非常快。然而,位运算符通常被认为在 TypeScript 和 JavaScript 中并不那么高效。JavaScript 中的位运算符效率较低,因为必须将浮点表示(JavaScript 存储所有数字的方式)转换为 32 位整数以执行位操作,然后再转换回来。
赋值运算符
TypeScript 支持以下赋值运算符:
| 运算符 | 描述 | 示例 |
|---|---|---|
= |
将右侧操作数的值赋给左侧操作数。 | C = A + B 将 A + B 的值赋给 C |
+= |
将右操作数加到左操作数上,并将结果赋值给左操作数。 | C += A 等价于 C = C + A |
-= |
从左侧操作数减去右侧操作数,并将结果赋值给左侧操作数。 | C -= A 等价于 C = C - A |
*= |
将右侧操作数乘以左侧操作数,并将结果赋值给左侧操作数。 | C *= A 等价于 C = C * A |
/= |
将左侧操作数除以右侧操作数,并将结果赋值给左侧操作数。 | C /= A 等价于 C = C / A |
%= |
使用两个操作数计算模数,并将结果赋值给左侧操作数。 | C %= A 等价于 C = C % A |
扩展运算符
扩展运算符可以用来从另一个数组或对象初始化数组和对象:
let originalArr1 = [ 1, 2, 3];
let originalArr2 = [ 4, 5, 6];
let copyArr = [...originalArr1];
let mergedArr = [...originalArr1, ...originalArr2];
let newObjArr = [...originalArr1, 7, 8];
以下代码片段展示了扩展运算符在数组上的使用,而以下代码片段展示了其在对象字面量上的使用:
let originalObj1 = {a: 1, b: 2, c: 3};
let originalObj2 = {d: 4, e: 5, f: 6};
let copyObj = {...originalObj1};
let mergedObj = {...originalObj1, ...originalObj2};
let newObjObj = {... originalObj1, g: 7, h: 8};
扩展运算符还可以用来将表达式扩展为多个参数(在函数调用中),但我们现在将跳过这个用例。
我们将在第三章 Chapter 3,与函数一起工作 和第四章 Chapter 4,使用 TypeScript 进行面向对象编程 中了解更多关于扩展运算符的内容。
流程控制语句
本节描述了 TypeScript 编程语言支持的决策语句、循环语句和分支语句。
单重选择结构(if)
以下代码片段声明了一个名为 isValid 的布尔类型变量。然后,一个 if 语句将检查 isValid 的值是否等于 true。如果该语句为 true,则将在屏幕上显示消息 Is valid!:
let isValid: boolean = true;
if (isValid) {
console.log("is valid!");
}
双重选择结构(if...else)
以下代码片段声明了一个名为 isValid 的布尔类型变量。然后,一个 if 语句将检查 isValid 的值是否等于 true。如果该语句为 true,则将在屏幕上显示消息 Is valid!。另一方面,如果该语句为 false,则将在屏幕上显示消息 Is NOT valid!:
let isValid: boolean = true;
if (isValid) {
console.log("Is valid!");
} else {
console.log("Is NOT valid!");
}
行内三元运算符 (?)
行内三元运算符只是声明双重选择结构的一种替代方式:
let isValid: boolean = true;
let message = isValid ? "Is valid!" : "Is NOT valid!";
console.log(message);
以下代码片段声明了一个名为 isValid 的布尔类型变量。然后,它检查操作符 ? 左侧的变量或表达式是否等于 true。
如果该语句为 true,则将在字符左侧执行表达式,并将消息 Is valid! 赋值给消息变量。
另一方面,如果该语句为 false,则操作符右侧的表达式将被执行,并将消息 Is NOT valid! 赋值给消息变量。
最后,消息变量的值将在屏幕上显示。
多重选择结构(switch)
switch语句评估一个表达式,将表达式的值与一个 case 子句匹配,并执行与该 case 关联的语句。switch 语句和枚举通常一起使用,以提高代码的可读性。
在以下示例中,我们声明了一个函数,该函数接受一个名为AlertLevel的枚举。
你将在第二章,“与类型一起工作”中了解更多关于枚举的内容。
在函数内部,我们将生成一个字符串数组来存储电子邮件地址,并执行一个switch结构。枚举的每个选项都是switch结构中的一个 case:
enum AlertLevel{
info,
warning,
error
}
function getAlertSubscribers(level: AlertLevel){
let emails = new Array<string>();
switch(level){
case AlertLevel.info:
emails.push("cst@domain.com");
break;
case AlertLevel.warning:
emails.push("development@domain.com");
emails.push("sysadmin@domain.com");
break;
case AlertLevel.error:
emails.push("development@domain.com");
emails.push("sysadmin@domain.com");
emails.push("management@domain.com");
break;
default:
throw new Error("Invalid argument!");
}
return emails;
}
getAlertSubscribers(AlertLevel.info); // ["cst@domain.com"]
getAlertSubscribers(AlertLevel.warning); //
["development@domain.com", "sysadmin@domain.com"]
level变量的值会在switch中的所有 case 中进行测试。如果变量与其中一个 case 匹配,则执行与该 case 关联的语句。一旦执行了case语句,变量将再次与下一个 case 进行测试。
一旦执行与匹配的 case 关联的语句完成,将评估下一个 case。如果存在break关键字,程序将不会继续执行后面的case语句。
如果没有找到匹配的 case 子句,程序将查找可选的default子句,如果找到,则将控制权转移到该子句并执行相关的语句。
如果没有找到default子句,程序将继续执行 switch 结束后的语句。按照惯例,default子句是最后一个子句,但不必总是如此。
表达式在循环的顶部(while)进行测试
while表达式用于在满足一定要求的情况下重复操作。例如,以下代码片段声明了一个名为i的数字变量。如果满足要求(i的值小于5),则执行操作(将i的值增加一并在浏览器控制台显示其值)。一旦操作完成,将再次检查是否满足要求:
let i: number = 0;
while (i < 5) {
i += 1;
console.log(i);
}
在while表达式中,只有当满足要求时才会执行操作。
表达式在循环的底部(do...while)进行测试
do...while表达式可用于重复指令,直到不满足某个要求。例如,以下代码片段声明了一个名为i的数字变量,并在满足要求(i的值小于five)的情况下重复操作(将i的值增加one并在浏览器控制台显示其值):
let i: number = 0;
do {
i += 1;
console.log(i);
} while (i < 5);
与while循环不同,do...while表达式至少会执行一次,无论测试的表达式如何,因为操作会在检查是否满足某些要求之前进行。
遍历每个对象的属性(for...in)
for...in语句本身并不是一种坏做法;然而,它可能会被误用,例如,用于迭代数组或类似数组的对象。for...in语句的目的是枚举对象属性:
let obj: any = { a: 1, b: 2, c: 3 };
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
console.log(key + " = " + obj[key]);
}
}
// Output:
// "a = 1"
// "b = 2"
// "c = 3"
以下代码片段将向上遍历原型链,同时枚举继承的属性。for...in语句迭代整个原型链,同时枚举继承的属性。当你只想枚举对象自身的属性而不是继承的属性时,你可以使用hasOwnProperty方法。
使用for...of循环迭代可迭代对象中的值
在 JavaScript 中,一些内置类型是具有默认迭代行为的内置可迭代类型。要成为可迭代对象,一个对象必须实现@@iterator方法,这意味着对象(或其原型链中的某个对象)必须有一个具有@@iterator键的属性,该属性通过Symbol.iterator常量可用。
for...of语句创建一个循环,迭代可迭代对象(包括数组、映射、集合、字符串、arguments 对象等):
let iterable = [10, 20, 30];
for (let value of iterable) {
value += 1;
console.log(value);
}
你可以在第四章,使用 TypeScript 进行面向对象编程中了解更多关于可迭代对象的内容。
计数控制的重复(for)
for语句创建一个包含三个可选表达式(用括号括起来,由分号分隔)的循环,后面跟着在循环中执行的语句或语句集:
for (let i: number = 0; i < 9; i++) {
console.log(i);
}
前面的代码片段包含一个for语句。它首先声明变量i并将其初始化为0。它检查i是否小于9,执行接下来的两个语句,并在每次循环迭代后递增i。
函数
正如 JavaScript 一样,TypeScript 函数可以创建为命名函数或匿名函数,这允许我们根据应用选择最合适的方法,无论是构建 API 中的函数列表还是传递给另一个函数的单次函数:
// named function
function greet(name?: string): string {
if(name){
return "Hi! " + name;
} else {
return "Hi!";
}
}
// anonymous function
let greet = function(name?: string): string {
if (name) {
return "Hi! " + name;
} else {
return "Hi!";
}
}
正如我们可以在前面的代码片段中看到的那样,在 TypeScript 中,我们可以为每个参数以及函数本身添加类型,然后添加返回类型。TypeScript 可以通过查看return语句来推断返回类型,因此我们也可以在许多情况下省略这一点。
对于使用返回类型后的=>运算符且不使用function关键字的函数,存在一种替代语法:
let greet = (name: string): string => {
if(name){
return "Hi! " + name;
}
else
{
return "Hi";
}
};
现在我们已经了解了这种替代语法,我们可以回到之前的例子,其中我们将匿名函数赋值给greet变量。现在我们可以为greet变量添加类型注解,以匹配匿名函数签名:
let greet: (name: string) => string = function(name: string):
string {
if (name) {
return "Hi! " + name;
} else {
return "Hi!";
}
};
请记住,箭头函数(=>)语法会改变在类中工作时this关键字的运作方式。我们将在接下来的章节中了解更多关于这一点。
现在,你已经知道了如何添加类型注解来强制一个变量成为一个具有特定签名的函数。这种注解的使用在调用回调(作为另一个函数的参数使用的函数)时非常常见:
function add(
a: number, b: number, callback: (result: number) => void
) {
callback(a + b);
}
在前面的示例中,我们声明了一个名为 add 的函数,它接受两个数字和一个 callback 作为函数。类型注解将强制回调返回 void 并接受一个数字作为其唯一参数。
我们将在第三章 函数的使用, 中关注函数。
类
ECMAScript 6,JavaScript 的下一个版本,为 JavaScript 添加了基于类的面向对象特性,由于 TypeScript 包含了 ES6 中所有可用的特性,因此开发者现在可以使用基于类的面向对象特性,并将它们编译成可以在所有主要浏览器和平台上运行的 JavaScript,而无需等待 JavaScript 的下一个版本。
让我们看看一个简单的 TypeScript 类定义示例:
class Character {
public fullname: string;
public constructor(firstname: string, lastname: string) {
this.fullname = `${firstname} ${lastname}`;
}
public greet(name?: string) {
if (name) {
return `Hi! ${name}! my name is ${this.fullname}`;
} else {
return `Hi! my name is ${this.fullname}`;
}
}
}
let spark = new Character("Jacob","Keyes");
let msg = spark.greet();
console.log(msg); // "Hi! my name is Jacob Keyes"
let msg1 = spark.greet("Dr. Halsey");
console.log(msg1); // "Hi! Dr. Halsey! my name is Jacob Keyes"
在前面的示例中,我们声明了一个新的类,Character。这个类有三个成员:一个名为 fullname 的属性、一个 constructor 和一个方法 greet。当我们使用 TypeScript 声明一个类时,所有方法和属性默认都是公共的。我们使用了 public 关键字来更加明确;明确类成员的可访问性是推荐的,但不是必需的。
你会注意到,当我们从类内部引用其成员时,我们会使用 this 操作符作为前缀。this 操作符表示这是一个成员访问。在最后一行,我们使用 new 操作符构造了一个 Character 类的实例。这调用我们之前定义的构造函数,创建了一个具有 Character 形状的新对象,并运行构造函数来初始化它。
TypeScript 类被编译成 JavaScript 函数,以实现与 ECMAScript 3 和 ECMAScript 5 的兼容性。
我们将在第四章 面向对象编程与 TypeScript, 中学习更多关于类和其他面向对象编程概念。
接口
在 TypeScript 中,我们可以使用接口来确保一个类遵循特定的规范:
interface LoggerInterface{
log(arg: any): void;
}
class Logger implements LoggerInterface {
log (arg: any){
if (typeof console.log === "function") {
console.log(arg);
} else {
console.log(arg);
}
}
}
在前面的示例中,我们定义了一个接口 LoggerInterface 和一个实现它的类 Logger。TypeScript 还允许你使用接口来声明对象的类型。这可以帮助我们预防许多潜在的问题,尤其是在处理对象字面量时:
interface UserInterface {
name: string;
password: string;
}
// Error property password is missing
let user: UserInterface = {
name: ""
};
我们将在第四章 面向对象编程与 TypeScript, 中学习更多关于接口和其他面向对象编程概念。
命名空间
命名空间,也称为内部模块,用于封装具有某种关系的特性和对象。命名空间将帮助你组织代码。要在 TypeScript 中声明一个命名空间,你将使用 namespace 和 export 关键字:
在 TypeScript 的旧版本中,定义内部模块的关键字是 module 而不是 namespace。
namespace geometry {
interface VectorInterface {
/* ... */
}
export interface Vector2DInterface {
/* ... */
}
export interface Vector3DInterface {
/* ... */
}
export class Vector2D
implements VectorInterface, Vector2dInterface {
/* ... */
}
export class Vector3D
implements VectorInterface, Vector3DInterface {
/* ... */
}
}
let vector2DInstance: geometry.Vector2DInterface = new
geometry.Vector2D();
let vector3DInstance: geometry.Vector3DInterface = new
geometry.Vector3d();
在前面的代码片段中,我们声明了一个包含类 vector2D 和 vector3D 以及接口 VectorInterface、Vector2DInterface 和 Vector3DInterface 的命名空间。请注意,第一个接口缺少关键字 export。因此,接口 VectorInterface 将无法从模块的作用域外部访问。
命名空间是组织代码的好方法;然而,在 TypeScript 应用程序中,它们并不是组织代码的推荐方式。我们现在不会深入探讨这个话题的更多细节,但我们将学习更多关于内部和外部模块的知识,我们将在第四章,使用 TypeScript 进行面向对象编程中讨论何时使用它们以及如何使用它们。
将一切整合起来
现在我们已经学会了如何单独使用基本的 TypeScript 构建块,让我们来看一个最终的例子,我们将为这些元素中的每一个使用模块、类、函数和类型注解:
namespace geometry_demo {
export interface Vector2DInterface {
toArray(callback: (x: number[]) => void): void;
length(): number;
normalize(): void;
}
export class Vector2D implements Vector2DInterface {
private _x: number;
private _y: number;
constructor(x: number, y: number) {
this._x = x;
this._y = y;
}
public toArray(callback: (x: number[]) => void): void {
callback([this._x, this._y]);
}
public length(): number {
return Math.sqrt(
this._x * this._x + this._y * this._y
);
}
public normalize() {
let len = 1 / this.length();
this._x *= len;
this._y *= len;
}
}
}
上述例子只是用 JavaScript 编写的基本 3D 引擎的一个小部分。在 3D 引擎中,有很多涉及矩阵和向量的数学计算。正如你所见,我们定义了一个包含一些实体的模块 Geometry;为了使示例简单,我们只添加了类 Vector2D。这个类在 2D 空间中存储两个坐标(x 和 y)并执行一些坐标上的操作。向量中最常用的操作之一是归一化,这是我们的 Vector2D 类中的方法之一。
3D 引擎是复杂的软件解决方案,作为开发者,你更有可能使用第三方 3D 引擎而不是自己创建。因此,了解 TypeScript 不仅可以帮助你开发大型应用程序,还可以与复杂的库交互是很重要的。
在下面的代码片段中,我们将使用之前声明的模块来创建一个 Vector2D 实例:
let vector: geometry_demo.Vector2DInterface = new geometry_demo.Vector2D(2,3);
vector.normalize();
vector.toArray(function(vectorAsArray: number[]){
console.log(`x: ${vectorAsArray[0]}, y: ${vectorAsArray[1]}`);
});
类型检查和智能感知功能将帮助我们创建一个 Vector2D 实例,归一化其值,并将其转换为数组,最后轻松地在屏幕上显示其值:

概述
在本章中,你了解了 TypeScript 的用途。你还了解了一些微软的 TypeScript 工程师做出的设计决策。
在本章的结尾,你学习了 TypeScript 应用程序的基本构建块,并且我们第一次开始编写 TypeScript 代码。
我们现在已经了解了类型注解、变量、原始数据类型、运算符、流程控制语句、函数、接口、类和命名空间的基础知识。
在下一章中,我们将学习更多关于 TypeScript 类型系统的内容。
第二章:与类型一起工作
在上一章中,我们学习了 TypeScript 类型系统的一些基本概念,包括类型推断系统的基础知识和可选的静态类型注解。
在本章中,你将学习 TypeScript 类型系统的主要功能,包括以下概念:
-
TypeScript 和 JavaScript 之间的界限
-
类型系统的功能
-
联合类型、交叉类型和区分联合
-
类型别名和局部类型
-
typeof和keyof操作符 -
控制流分析和类型守卫
-
非空类型
-
泛型类型
-
映射类型、查找类型和条件类型
TypeScript 类型系统的特点
在本节中,我们将学习 TypeScript 类型系统的主要特点,包括类型推断、可选类型注解以及名义类型系统和结构类型系统之间的差异等概念。
TypeScript 和 JavaScript 之间的界限
要成为一名优秀的 TypeScript 程序员,你需要掌握的最重要的事情之一是理解 TypeScript 和 JavaScript 之间的界限。了解我们的 TypeScript 代码在三个重要阶段发生的事情非常重要:
-
设计时间:这发生在我们编写 TypeScript 代码和设计应用程序时。
-
编译时间:这发生在我们将 TypeScript 编译成 JavaScript 代码时(可能会出现一些编译错误)。编译时间包含子阶段,例如解析 TypeScript 代码、创建抽象语法树(ATS)和生成 JavaScript 代码。
-
执行时间(也称为运行时):这发生在我们执行 TypeScript 编译器生成的输出 JavaScript 代码时。
TypeScript 类型在设计时声明或推断,并在编译时使用,但它们在执行时不可用,因为它们不是 JavaScript 的一部分。
在本章中,我们将学习 TypeScript 类型系统的许多功能。如果你熟悉 JavaScript,你将立即注意到差异,但如果你不熟悉 JavaScript,我建议你在编译本章中包含的代码示例后检查生成的 JavaScript 输出。随着时间的推移,你将逐渐获得对 TypeScript 和 JavaScript 之间界限的清晰视野。
请参阅第六章,理解运行时,了解更多关于执行时间阶段(JavaScript)的信息。
类型推断
TypeScript 尝试使用所谓的 类型推断 来查找我们应用程序中变量和对象的数据类型。当我们声明一个变量时,TypeScript 将尝试观察分配给应用程序中变量的值以确定其类型。让我们看看一些例子:
let myVariable1 = 3;
变量 myVariable1 的类型被推断为数字。
let myVariable2 = "Hello";
变量 myVariable2 的类型被推断为字符串。
let myVariable3 = {
name: "Remo",
surname: "Jansen",
age: 29
};
变量 myVariable3 的类型被推断为以下类型:
{ name: string; surname: string; age: number; }
当 TypeScript 无法识别变量类型时,会分配 any 类型。例如,给定以下函数:
function add(a, b) {
return a + b;
}
函数 add 的类型被推断为以下类型:
(a: any, b: any) => any;
类型 any 是一个问题,因为它阻止 TypeScript 编译器识别许多潜在的错误。幸运的是,TypeScript 具有可选的类型注释功能,可以用来解决这个问题。
可选静态类型注释
TypeScript 允许我们使用类型注释来克服类型推断系统不足以自动检测变量类型的情况。
让我们再次考虑 add 函数:
function add(a, b) {
return a + b;
}
函数 add 的类型被推断为以下类型:
(a: any, b: any) => any;
前面的类型是一个问题,因为 any 类型的使用实际上阻止了 TypeScript 编译器检测某些错误。例如,我们可能期望 add 函数添加两个数字:
let result1 = add(2, 3); // 5
然而,如果我们传递一个字符串作为输入,我们将遇到一个意外的结果:
let result2 = add("2", 3); // "23"
如果,例如,提供给 add 函数的参数是从 HTML 输入中提取的,而我们忘记将它们解析为数字,那么前面的错误可能会非常容易发生。
我们可以通过添加可选的类型注释来修复 add 函数:
function add(a: number, b: number): number {
return a + b;
}
我们可以在变量声明后添加一个冒号(:)来添加一个可选的类型注释:
let myVariable: string = "Hello";
在函数的情况下,我们可以为函数的参数及其返回值添加注释。
现在,由于 add 函数的参数类型是 number 而不是 any,TypeScript 编译器将能够检测如果我们提供 worn 类型的参数时可能出现的潜在问题:
let result1 = add(2, 3); // OK
let result2 = add("2", 3); // Error
通常,我们应该尝试利用类型推断系统,并且只在类型推断系统不足以自动检测变量类型时才使用可选的静态类型注释。
结构化类型系统
在编程语言的类型系统中,类型是一个具有名称和结构的对象。一些类型具有非常简单的数据结构(例如原始类型),而其他类型则使用复杂结构(例如类)。
类型系统可以使用两种不同的策略来验证给定的值是否与期望的类型匹配:
-
名义类型系统:在这个类型系统中,值是通过其名称与其类型匹配的
-
结构化类型系统:在这个类型系统中,值是通过其结构与其类型匹配的
TypeScript 类型系统是一个结构化类型系统,因为值是通过其结构与其类型匹配的,如下代码片段所示:
interface Person {
name: string;
surname: string;
}
function getFullName(person: Person) {
return `${person.name} ${person.surname}`;
}
class Employer {
constructor(
public name: string,
public surname: string
) {}
}
getFullName(new Employer("remo", "jansen")); // OK
const p1 = { name: "remo", surname: "jansen" };
getFullName(p1); // OK
const p2 = { name: "remo", familyName: "jansen" };
getFullName(p2); // Error
在前面的代码片段中,我们可以观察到前两次调用 getFullName 函数是成功的,因为 Employer 实例和对象字面量的结构(属性和类型)与 Person 接口的结构相匹配。
以下代码片段展示了如果 TypeScript 使用命名类型系统,它将如何工作:
interface Person {
name: string;
surname: string;
}
function getFullName(person: Person) {
return `${person.name} ${person.surname}`;
}
class Employer implements Person { // Named!
constructor(
public name: string,
public surname: string
) {}
}
getFullName(new Employer("remo", "jansen")); // OK
const p1: Person = { name: "remo", surname: "jansen" }; // Named!
getFullName(p1); // OK
const p2: Person = { name: "remo", familyName: "jansen" }; // Error
getFullName(p2); // OK
第一次调用 getFullName 是有效的,因为 Employer 类实现了 Person 接口,然后接口的类型名称可以与函数参数的类型名称相匹配。
TypeScript 团队目前正在调查可能添加对命名类型系统支持的可能性。您可以在 github.com/Microsoft/TypeScript/issues/202. 了解更多关于进展情况。
TypeScript 类型系统的核心功能
在本节中,我们将学习 TypeScript 类型系统的核心功能。这包括联合类型、交集守卫、类型守卫和类型别名等概念。
联合类型
TypeScript 允许你声明联合类型:
let path: string[]|string;
path = "/temp/log.xml";
path = ["/temp/log.xml", "/temp/errors.xml"];
path = 1; // Error
在前面的示例中,我们声明了一个名为 path 的变量,它可以包含单个路径(字符串)或路径集合(字符串数组)。在示例中,我们还设置了变量的值。我们分配了一个字符串和一个字符串数组而没有错误;然而,当我们尝试分配一个数值时,我们得到了编译错误,因为联合类型没有将数字声明为变量的有效类型之一。
联合类型用于声明一个可以存储两种或更多类型值的变量。只有存在于交集类型中的所有类型中可用的属性才被认为是有效的:

我们可以在以下示例中欣赏这种行为:
interface Supplier {
orderItems(): void;
getAddress(): void;
}
interface Customer {
sellItems(): void;
getAddress(): void;
}
declare let person: Supplier | Customer;
person.getAddress(); // OK
person.orderItems(); // Error
person.sellItems(); // Error
类型别名
TypeScript 允许我们使用 type 关键字声明类型别名:
type PrimitiveArray = Array<string|number|boolean>;
type MyNumber = number;
type Callback = () => void
类型别名与其原始类型完全相同;它们只是替代名称。类型别名可以帮助我们使代码更易于阅读,但也可能导致一些问题。
如果你作为大型团队的一部分工作,无差别地创建别名可能会导致可维护性问题。尼古拉斯·C·扎卡斯(Nicholas C. Zakas)的书籍《可维护的 JavaScript》建议你应 "避免修改你不拥有的对象". 尼古拉斯在谈论添加、删除或覆盖你未声明的对象(DOM 对象、BOM 对象、原始类型和第三方库)中的方法,但我们可以将此规则也应用于别名使用。
交集类型
当安德斯·海尔斯伯格(Anders Hejlsberg)首次将交集类型添加到 TypeScript 中时,他这样定义它们:
"交集类型是联合类型的逻辑补集。联合类型 A | B 表示一个实体具有类型 A 或类型 B,而交集类型 A & B 表示一个实体同时具有类型 A 和类型 B。"
以下示例声明了三个接口名为 A、B 和 C。然后它声明了一个名为 abc 的对象,其类型是接口 A、B 和 C 的交集类型。因此,abc 对象具有名为 a、b 和 c 的属性,但没有 d:
interface A { a: string }
interface B { b: string }
interface C { c: string }
declare let abc: A & B & C;
abc.a = "hello"; // OK
abc.b = "hello"; // OK
abc.c = "hello"; // OK
abc.d = "hello"; // Error
交集类型也可以应用于子属性:
interface X { x: A }
interface Y { x: B }
interface Z { x: C }
declare let xyz: X & Y & Z;
xyz.x.a = "hello"; // OK
xyz.x.b = "hello"; // OK
xyz.x.c = "hello"; // OK
xyz.x.d = "hello"; // Error
它们也可以应用于函数:
type F1 = (x: string) => string;
type F2 = (x: number) => number;
declare let f: F1 & F2;
let s = f("hello"); // OK
let n = f(42); // OK
let t = f(true); // Error
在交集类型中存在的一个或所有类型中的属性被认为是有效的:

我们可以在以下示例中欣赏这种行为:
interface Supplier {
orderItems(): void;
getAddress(): void;
}
interface Customer {
sellItems(): void;
getAddress(): void;
}
declare let person: Supplier & Customer;
person.getAddress(); // OK
person.orderItems(); // OK
person.sellItems(); // OK
非空类型
TypeScript 2.0 引入了所谓的非空类型。TypeScript 以前将 null 和 undefined 视为每种类型的有效值。
以下图表表示当禁用非空类型时可以分配给数字类型的值:

如前图所示,undefined 和 null 被允许作为数字类型的值,包括 NaN 值和所有可能的数字。
NaN,代表“不是一个数字”,是一种数值数据类型值,表示未定义或无法表示的值,尤其是在浮点数计算中。1985 年,IEEE 754 浮点标准引入了 NaN 的系统使用,以及表示其他非有限数量(如无穷大)的表示。
以下代码片段演示了当禁用非空类型时,undefined 和 null 被允许作为数字类型的值:
let name: string;
name = "Remo"; // OK
name = null; // OK
name = undefined; // OK
同样适用于所有其他类型:
let age: number;
age = 28; // OK
age = null; // OK
age = undefined; // OK
let person: { name: string, age: number};
person = { name: "Remo", age: 28 }; // OK
person = { name: null, age: null }; // OK
person = { name: undefined, age: undefined }; // OK
person = null; // OK
person = undefined; // OK
当启用非空类型时,值 null 和 undefined 被视为独立类型,并停止被视为数字类型的有效值:

以下代码片段演示了当启用非空类型时,undefined 和 null 不被允许作为数字类型的值:
let name: string;
name = "Remo"; // OK
name = null; // Error
name = undefined; // Error
同样适用于所有其他类型:
let age: number;
age = 28; // OK
age = null; // Error
age = undefined; // Error
let person: { name: string, age: number};
person = { name: "Remo", age: 28 }; // OK
person = { name: null, age: null }; // Error
person = { name: undefined, age: undefined }; // Error
person = null; // Error
person = undefined; // Error
我们可以通过使用 **--**strictNullChecks 编译标志来启用非空类型:
tsc -strictNullChecks file.ts
当启用非空类型时,我们可以使用联合类型来创建类型的可空版本:
type NullableNumber = number | null;
--严格模式
TypeScript 允许我们使用 --strict 编译标志来启用所有严格类型检查选项。启用 --strict 启用 --noImplicitAny、--noImplicitThis、--alwaysStrict、--strictPropertyInitialization 和 --strictNullChecks:
-
--strictNullChecks编译标志启用了非空类型。 -
--noImplicitAny标志强制我们在类型推断系统无法自动推断正确类型时显式声明变量的类型。 -
--alwaysStrict标志强制 TypeScript 解析器使用严格模式。 -
--noImplicitThis标志强制我们在类型推断系统无法自动推断正确类型时,在函数中显式声明this操作符的类型。 -
--strictPropertyInitialization标志强制类属性必须初始化。
我们将在第六章 理解运行时,理解运行时 中了解更多关于 JavaScript 严格模式和 this 操作符的内容。
使用 --strict 编译标志会使 TypeScript 编译器更加严格。在现有的大型 TypeScript 项目中启用此选项可能会导致发现许多可能需要大量努力修复的错误。因此,建议在绿色场 TypeScript 项目中启用 --strict 编译标志,并在现有的 TypeScript 项目中逐步启用单个标志(--noImplicitAny、--noImplicitThis、--alwaysStrict 和 --strictNullChecks)。
--noImplicitReturns 编译标志不是由 --strict 标志启用的标志之一。当函数中不是所有代码路径都返回值时,该标志会抛出错误。也建议在绿色场 TypeScript 项目中启用此标志或在可能的情况下在现有项目中启用。
typeof 操作符
typeof 操作符可以在运行时(JavaScript)使用:
let myNumber = 5;
console.log(typeof myNumber === "number");
重要的是要注意,它也可以在设计时(TypeScript)使用:
let myNumber = 5;
type NumberType = typeof myNumber;
类型守卫
我们可以通过使用 typeof 或 instanceof 操作符在运行时检查表达式的类型。TypeScript 语言服务会查找这些操作符,并在 if 块中使用时相应地缩小推断类型:
let x: any = { /* ... */ };
if(typeof x === 'string') {
console.log(x.splice(3, 1)); // Error, 'splice' does not exist
on 'string'
}
// x is still any
x.foo(); // OK
在前面的代码片段中,我们声明了一个名为 x 的 any 类型变量。稍后,我们通过使用 typeof 操作符在运行时检查 x 的类型。如果 x 的类型结果是字符串,我们将尝试调用 splice 方法,该方法应该是 x 变量的一个成员。TypeScript 语言服务可以理解在条件语句中使用 typeof 的用法。TypeScript 会自动假设 x 必须是字符串,并通知我们 splice 方法在字符串类型上不存在。这个特性被称为 类型守卫。
自定义类型守卫
我们可以通过声明一个具有特殊返回类型的函数来定义自定义类型守卫:
interface Supplier {
orderItems(): void;
getAddress(): void;
}
interface Customer {
sellItems(): void;
getAddress(): void;
}
function isSupplier(person: Supplier | Customer): person is Supplier {
return (<Supplier> person).orderItems !== undefined;
}
function handleItems(person: Supplier | Customer) {
if (isSupplier(person)) {
person.orderItems(); // OK
} else {
person.sellItems(); // OK
}
}
前面的代码片段声明了两种类型(Supplier 和 Customer);然后声明了一个自定义类型守卫函数。自定义类型守卫返回一个布尔值。当提供的值 person 有名为 orderItems 的属性时,函数返回 true,当属性缺失时返回 false。
函数通过检查值的属性来尝试在运行时识别类型。这种类型匹配称为 模式匹配。
我们将在第七章 使用 TypeScript 的函数式编程 中了解更多关于模式匹配的内容。
模式匹配不是我们用来识别值是否匹配类型的唯一技术。我们还可以使用 instanceof 运算符:
class Supplier {
public orderItems(): void {
// do something...
}
public getAddress(): void {
// do something...
}
}
class Customer {
public sellItems(): void {
// do something...
}
public getAddress(): void {
// do something...
}
}
function isSupplier(person: Supplier | Customer): person is Supplier {
return person instanceof Supplier;
}
function handleItems(person: Supplier | Customer) {
if (isSupplier(person)) {
person.orderItems(); // OK
} else {
person.sellItems(); // OK
}
}
我们可以使用 typeof 运算符作为识别值是否匹配类型的另一种技术:
function doSomething(x: number | string) {
if (typeof x === 'string') {
console.log(x.subtr(1)); // Error
console.log(x.substr(1)); // OK
}
x.substr(1); // Error
}
前面的代码片段在 if 块内抛出编译错误,因为 TypeScript 知道变量 x 在该块内必须是字符串。在 if 块外部也会抛出另一个错误,因为 TypeScript 无法保证在那个点变量 x 的类型是字符串。
自 TypeScript 2.7 版本以来,我们可以使用 in 运算符作为类型守卫来缩小给定类型,如下面的示例所示:
interface Cat {
meow(): void;
}
interface Dog {
woof(): void;
}
function doSomething(obj: Cat | Dog) {
if ("meow" in obj) {
obj.meow(); // OK
} else {
obj.woof(); // OK
}
}
控制流分析
TypeScript 包含一个称为控制流分析的功能,用于根据程序的执行流程识别变量的类型。此功能使 TypeScript 具有更精确的类型推断能力。
以下示例定义了一个函数,它接受两个参数,其中一个参数(命名为 value)的类型是数字和数字数组的联合类型:
function increment(
incrementBy: number, value: number | number[]
) {
if (Array.isArray(value)) {
// value must be an array of number
return value.map(value => value + incrementBy);
} else {
// value is a number
return value + incrementBy;
}
}
increment(2, 2); // 4
increment(2, [2, 4, 6]); // [4, 6, 8]
在函数体内部,我们使用 if 语句来确定值变量确实是一个数字数组还是仅仅是一个数字。类型推断系统将根据 if...else 语句的两个路径相应地更改参数的推断类型。
控制流分析提高了类型检查器对变量赋值和控制流语句的理解,从而大大减少了类型守卫的需求。
文字类型
文字类型允许我们声明一个字符串、布尔值或数字必须具有的确切值。当我们使用 let 关键字声明一个变量时,其值将被推断为原始类型:
let five = 5; // number
let falsy = false; // boolean
let shape = "rectangle"; // string
然而,如果我们使用 const 关键字,类型将被推断为实际分配的值:
const five = 5; // 5
const falsy = false; // false
const shape = "rectangle"; // rectangle
文字类型可以轻松与联合类型、类型守卫和类型别名结合使用:
type ShapeKind = "square" | "rectangle" | "circle";
文字类型可以与类型守卫和控制流分析的力量结合使用,通过称为区分联合的技术来缩小联合类型。
区分联合
区分联合(也称为标签联合或代数数据类型)是一种高级模式,它结合了字符串文字类型、联合类型、类型守卫和类型别名。
区分联合使用类型守卫根据判别属性(一个字符串文字类型)的测试来缩小联合类型,并且进一步扩展了该功能到 switch 语句。
以下代码片段声明了三个共享名为 kind 的字符串文字属性的类型:
interface Cube {
kind: "cube";
size: number;
}
interface Pyramid {
kind: "pyramid";
width: number;
length: number;
height: number;
}
interface Sphere {
kind: "sphere";
radius: number;
}
然后,我们声明前面代码片段中声明的三种类型的联合类型:
type Shape = Cube | Pyramid | Sphere;
function volume(shape: Shape) {
const PI = Math.PI;
switch (shape.kind) {
case "cube":
return shape.size ** 3;
case "pyramid":
return (shape.width * shape.height * shape.length) / 3;
case "sphere":
return (4 / 3) * PI * (shape.radius ** 3);
}
}
在前面的函数中,switch 语句充当类型守卫。根据判别属性 kind 的值,每个情况子句都会缩小 shape 的类型,从而允许访问该变体的其他属性,而无需类型断言。
never 类型
如 TypeScript 文档所述,never类型具有以下特性:
-
never类型是每个类型的子类型并可赋值给每个类型。 -
没有任何类型是
never的子类型或可赋值给never(除了never本身)。 -
在没有返回类型注解的函数表达式或箭头函数中,如果函数没有
return语句或只有类型为never的return语句,并且如果函数的终点不可达(由控制流分析确定),则函数的推断返回类型为never。 -
在具有显式 never 返回类型注解的函数中,所有
return语句(如果有)必须具有类型为never的表达式,并且函数的末尾必须不可达。
在 JavaScript 中,当一个函数没有显式返回值时,它隐式返回值undefined。在 TypeScript 中,此类函数的返回类型被推断为void。当一个函数没有完成其执行(它抛出错误或根本未完成运行),TypeScript 将其返回类型推断为never:
function error(message: string): never {
throw new Error(message);
}
// Type () => never
const sing = function() {
while (true) {
console.log("I will never return!");
}
};
我们还可能在区分联合中的不可能匹配时遇到never类型:
function area(shape: Shape) {
const PI = Math.PI;
switch (shape.kind) {
case "square": return shape.size * shape.size;
case "rectangle": return shape.width * shape.height;
case "circle": return PI * shape.radius * shape.radius;
default:
return shape; // never
}
}
在前面的代码片段中,默认情况永远不会被执行;因此,返回类型被推断为never类型。
枚举
枚举允许我们定义一组命名的常量。自 TypeScript 2.4 版本发布以来,这些命名常量值可以是字符串值。最初,它们只能是数值:
enum CardinalDirection {
Up,
Down,
Left,
Right
}
解决这种限制的一个常见方法是使用字面量类型的联合类型:
type CardinalDirection =
"North"
| "East"
| "South"
| "West";
function move(distance: number, direction: CardinalDirection) {
// ...
}
move(1,"North"); // Okay
move(1,"Nurth"); // Error!
自 TypeScript 2.4 版本发布以来,也支持具有字符串值的枚举:
enum CardinalDirection {
Red = "North",
Green = "East",
Blue = "South",
West = "West"
}
对象字面量
对象可以使用new Object()、Object.create()或使用对象字面量表示法(也称为初始化表示法)进行初始化。对象初始化器是一个逗号分隔的列表,包含零个或多个属性名和值的对,用花括号括起来:
let person = { name: "Remo", age: 28 };
类型推断系统可以自动推断对象字面量的类型。在前面代码片段中声明的变量person的类型推断为{ name: string, age: number }。或者,我们也可以显式声明对象字面量的类型:
interface User {
name: string;
age: number;
}
let person: User = { name: "Remo", age: 28 }; // OK
还可以声明可选属性:
interface User {
name: string;
age?: number;
}
let person1: User = { name: "Remo", age: 28 }; // OK
let person2: User = { name: "Remo" }; // OK
请参阅第一章,介绍 TypeScript,以了解更多关于空对象类型({})、对象(大写)类型和对象(小写)类型之间的区别。
弱类型
弱类型是一个对象字面量类型,其中所有属性都是可选的:
interface User {
name?: string;
age?: number;
}
TypeScript 允许我们向弱类型添加一些或所有已定义的属性值,但它不允许我们分配不属于弱类型的属性:
let user1: User = { name: "Remo", age: 28 }; // OK
let user2: User = { firstName: "Remo", yearBorn: 28 }; // Error
keyof运算符
keyof运算符可以用来生成对象属性的联合类型,作为字符串字面量类型:
interface User {
name: string;
age: number;
}
type userKeys = keyof User; // "name" | "age"
keyof 操作符可以与其它操作符结合使用,例如 typeof 操作符,例如:
let person = { name: "Remo", age: "28" };
interface User {
name: string;
age: number;
}
type userKeys = keyof typeof person; // "name" | "age"
我们将在本章后面学习查找类型时,了解更多关于 keyof 操作符的信息。
索引签名
在 JavaScript 中,我们可以使用对象名称后跟点号和属性名称来访问对象的属性:
let foo: any = {};
foo.hello = 'World';
console.log(foo.hello); // World
然而,也可以使用对象名称后跟属性名称作为字符串,用方括号括起来来访问对象的属性:
let foo: any = {};
foo['hello'] = 'World';
console.log(foo['hello']); // World
这种行为可以使用所谓的索引签名来声明:
interface StringArray {
[index: number]: string;
}
let myArray: StringArray = ["Bob", "Fred"];
let myStr: string = myArray[0];
如前述代码片段所示,索引签名允许我们指定使用方括号签名访问属性时返回的值的类型。
本地类型
TypeScript 类型系统允许我们在函数和方法的声明中声明类型(如类型别名、类和接口)。在 TypeScript 的早期版本中,这是不允许的:
interface Person {
name: string;
age: number;
}
function makePerson(name: string, age: number): Person {
// Local type
class Person implements Person {
constructor(
public name: string,
public age: number
) {}
}
return new Person(name, age);
}
let user = makePerson("Remo", 28);
类型转换
TypeScript 类型系统允许我们使用两种不同的语法来转换给定的类型:
var myObject: TypeA;
var otherObject: any;
myObject = <TypeA> otherObject; // Using <>
myObject = otherObject as TypeA; // Using as keyword
重要的是要理解 TypeScript 类型转换不会影响变量的运行时类型。
自 TypeScript 1.6 以来,默认为 as,因为在 .tsx 文件中 <> 是模糊的。我们将在第十一章 前端开发与 React 和 TypeScript* 中了解更多关于 .tsx 文件的信息*。
通常建议避免使用类型转换,而优先使用泛型类型。
TypeScript 类型系统的先进特性
在本节中,我们将学习一些高级类型系统特性,例如泛型类型、映射类型和查找类型。
泛型类型
泛型类型可以帮助我们避免使用类型转换,并通过允许我们在函数、类或方法被消费时(而不是在声明时)声明(T),来增加代码的可重用性:
function deserialize<T>(json: string): T {
return JSON.parse(json) as T;
}
interface User {
name: string;
age: number;
}
let user = deserialize<User>(`{"name":"Remo","age":28}`);
interface Rectangle {
width: number;
height: number;
}
let rectangle = deserialize<Rectangle>(`{"width":5,"height":8}`);
上述示例声明了一个名为 deserialize 的函数。该函数返回的类型 (T) 在函数声明点上是未知的。然后函数被调用了两次,类型 T 最终被确定(User 和 Rectangle)。
我们将在第四章 使用 TypeScript 的面向对象编程* 中了解更多关于泛型类型的信息*。
泛型约束
有时,我们不需要函数、类或方法所需的实际类型,但我们知道这种类型必须遵循一组特定的规则。
例如,以下代码片段声明了一个名为 isEquals 的泛型函数。然而,这次类型 T 有一个约束(T extends Comparable):
interface Comparable<T> {
equals(value: T): boolean;
}
function isEqual<TVal, T extends Comparable<TVal>>(comparable: T, value: TVal) {
return comparable.equals(value);
}
该约束用于确保提供给 isEqual 作为其泛型类型参数的所有类型都实现了 Comparable 接口:
interface RectangleInterface {
width: number;
height: number;
}
type ComparableRectangle = RectangleInterface & Comparable<RectangleInterface>;
class Rectangle implements ComparableRectangle {
public width: number;
public height: number;
public constructor(width: number, height: number) {
this.width = width;
this.height = height;
}
public equals(value: Rectangle) {
return value.width === this.width && value.height === this.height;
}
};
interface CircleInterface {
radious: number;
}
type ComparableCircle = CircleInterface & Comparable<CircleInterface>;
class Circle implements ComparableCircle {
public radious: number;
public constructor(radious: number) {
this.radious = radious
}
public equals(value: CircleInterface): boolean {
return value.radious === this.radious;
}
}
const circle = new Circle(5);
const rectangle = new Rectangle(5, 8);
isEqual<RectangleInterface, ComparableRectangle>(rectangle, { width: 5, height: 8 });
isEqual<CircleInterface, ComparableCircle>(circle, { radius: 5 });
映射类型
映射类型是一种高级类型特性,允许我们将类型的每个属性的值映射到不同的类型。例如,以下映射类型将给定类型的属性值转换为与属性名称匹配的字符串字面量:
type Keyify<T> = {
[P in keyof T]: P;
};
以下函数接受一个对象,并返回一个新的对象,其中所有属性具有相同的名称,但它们的值是属性的名称:
function getKeys<T>(obj: T): Keyify<T> {
const keysArr = Object.keys(obj);
const stringifyObj = keysArr.reduce((p, c, i, a) => {
return {
...p,
[c]: c
};
}, {});
return stringifyObj as Keyify<T>;
}
interface User {
name: string;
age: number;
}
let user: User = { name: "Remo", age: 28 };
let keys = getKeys<User>(user);
keys.name; // "name"
keys.age; // "age"
TypeScript declares some commonly used mapped types for us:
// Make all properties in T optional
type Partial<T> = {
[P in keyof T]?: T[P];
};
// Make all properties in T readonly
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
// From T pick a set of properties K
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
}
// Construct a type with a set of properties K of type T
type Record<K extends string, T> = {
[P in K]: T;
}
查找类型
查找类型是另一种高级类型系统特性,它允许我们将 keyof 运算符与泛型和对象字面量结合使用,以创建高级类型注解。让我们看一个例子:
function filterByProperty<T, K extends keyof T>(
property: K, entities: T[], value: T[K]
) {
return entities.filter(e => e[property] === value);
}
之前的功能接受两个泛型类型参数:
-
T是函数第一个参数传递的数组中项的类型。 -
K是T的属性名称。此要求通过泛型约束(extendskeyof T)强制执行。
函数还期望两个参数:
-
类型为
T的实体数组。 -
类型
T[K]的值。类型T[K]表示类型T中属性K的值,它被称为查找类型。
之前的功能可以用来通过类型 T 的一个属性来过滤类型为 T 的实体数组:
interface User {
surname: string;
age: number;
}
const users = [
{ surname: "Smith", age: 28 },
{ surname: "Johnson", age: 55 },
{ surname: "Williams", age: 14 }
];
filterByProperty<User, "age">("age", users, 21);
filterByProperty<User, "surname">("surname", users, "Smith");
映射类型修饰符
TypeScript 2.8 引入了一些运算符,允许我们对映射类型的定义有更高的控制级别:
-
我们可以使用
readonly修饰符将属性标记为不可变。 -
我们可以使用
?运算符将属性标记为可选。 -
我们可以使用
+运算符将修饰符,如readonly修饰符,应用于类型中的属性。我们还可以使用+运算符与?运算符一起使用。 -
我们可以使用
-运算符应用修饰符,例如将readonly修饰符应用于类型中的属性。我们还可以使用+运算符与?运算符一起使用。
我们现在将检查几个例子。代码片段声明了一个映射类型,该类型可用于将类型 T 转换为一个新的类型,该类型包含 T 中的所有属性,但被标记为既是 readonly 也是 optional:
type ReadonlyAndPartial1<T> = {
readonly [P in keyof T]?: T[P]
}
以下类型声明与前面代码片段中的类型声明相同:
type ReadonlyAndPartial2<T> = {
+readonly [P in keyof T]+?: T[P];
}
以下类型可用于从给定类型 T 的所有属性中删除 readonly 修饰符:
type Mutable<T> = {
-readonly [P in keyof T]: T[P]
}
我们可以将 Mutable 类型应用于以下接口以生成一个新的类型。abc 属性不再不可变,但 def 属性仍然是可选的:
interface Foo {
readonly abc: number;
def?: string;
}
type TotallyMutableFoo = Mutable<Foo>
最后,以下代码片段声明了一个映射类型,该类型可用于从给定的类型 T 中删除可选属性:
type Required<T> = {
[P in keyof T]-?: T[P];
}
条件类型
条件映射类型是 TypeScript 2.8 中引入的高级特性。在本章的前面部分,我们了解到我们可以使用extends关键字来声明泛型约束。当我们声明一个泛型约束时,我们实际上是在使用extends关键字作为一种运算符,它允许我们检查一个泛型类型(T)是否是给定类型的子类型。例如,以下代码片段声明了两个名为Animal和Dog的接口:
interface Animal {
live(): void;
}
interface Dog extends Animal {
woof(): void;
}
然后我们使用extends关键字作为条件运算符来生成一个新的类型:
type Foo1 = Dog extends Animal ? number : string; // number
type Bar1 = RegExp extends Dog ? number : string; // string
条件类型可以用来声明一些复杂类型。例如,Flatten函数是一个将多维数组([][])转换为一维数组([])的函数。Flatten函数的返回类型是一个条件类型,因为它在提供多维数组时返回一个数组,在提供一维数组时返回一个数字:
type Flatten<T> = T extends any[] ? T[number] : T;
type arr1 = number[];
type flattenArr1 = Flatten<arr1>; // number
type arr2 = number[][];
type flattenArr2 = Flatten<arr2>; // number[]
infer关键字
在前面的部分中,我们已经定义了Flatten类型。然而,这种行为是硬编码的,当提供一个一维数组时返回一个数字。这意味着flatten类型仅在数字数组上按预期工作。幸运的是,自从 TypeScript 2.8 发布以来,我们可以使用infer关键字来克服这一限制:
type TypedFlatten<T> = T extends Array<infer U> ? U : T;
infer关键字可以在其他场景中使用。例如,我们可以用它来推断函数的返回类型:
type ReturnType<T extends (...args: any[]) => any> = T extends (...args: any[]) => infer R ? R : any;
type func1 = () => number;
type returnOfFunc1 = ReturnType<func1>; // number
内置条件类型
在前面的部分中,我们使用了ReturnType类型来提取给定函数的返回类型。ReturnType类型是一个内置类型。TypeScript 2.8 包括许多其他类型:
// Exclude from T those types that are assignable to U
type Exclude<T, U> = T extends U ? never : T;
// Extract from T those types that are assignable to U
type Extract<T, U> = T extends U ? T : never;
// string[] | number[]
type Foo2 = Extract<boolean | string[] | number[], any[]>;
// boolean
type Bar2 = Exclude<boolean | string[] | number[], any[]>;
// Exclude null and undefined from T
type NonNullable<T> = T extends null | undefined ? never : T;
// Obtain the return type of a function type
type ReturnType<T extends (...args: any[]) => any> = T extends (...args: any[]) => infer R ? R : any;
// Obtain the return type of a constructor function type
type InstanceType<T extends new (...args: any[]) => any> = T extends new (...args: any[]) => infer R ? R : any;
多态this类型
在 JavaScript 中,this操作符的值取决于函数或方法被调用的方式。在方法中,this操作符通常指向类实例。
多态this类型是原始this操作符类型推断的改进版本,Anders Hejlsberg 在文档中记录了以下行为:
-
在非静态类或接口成员的表达式中,
this的类型是某个从包含类派生出来的类的实例,而不是简单地是包含类的实例。 -
在非静态类或接口成员的类型位置中,可以使用
this关键字来引用this的类型。 -
当一个类或接口被引用为类型时,类(包括从基类继承的)中所有
this类型的出现都被替换为该类型本身。
这个特性使得像流畅式接口(en.wikipedia.org/wiki/Fluent_interface)这样的模式更容易表达和实现,如下面的示例所示:
interface Person {
name?: string;
surname?: string;
age?: number;
}
class PersonBuilder<T extends Person> {
protected _details: T;
public constructor() {
this._details = {} as T;
}
public currentValue(): T {
return this._details;
}
public withName(name: string): this {
this._details.name = name;
return this;
}
public withSurname(surname: string): this {
this._details.surname = surname;
return this;
}
public withAge(age: number): this {
this._details.age = age;
return this;
}
}
流畅式接口允许我们通过使用点连接多个方法来在一个对象上调用多个方法,而不必每次都写对象名称。
由于类方法返回 this 类型,我们可以调用多个方法而无需多次编写类名:
let value1 = new PersonBuilder()
.withName("name")
.withSurname("surname")
.withAge(28)
.currentValue();
由于类使用了 this 类型,我们可以扩展它,然后新的类可以提供一个包含基方法在内的流畅接口:
interface Employee extends Person {
email: string;
department: string;
}
class EmployeeBuilder extends PersonBuilder<Employee> {
public withEmail(email: string) {
this._details.email = email;
return this;
}
public withDepartment(department: string) {
this._details.department = department;
return this;
}
}
let value2 = new EmployeeBuilder()
.withName("name")
.withSurname("surname")
.withAge(28)
.withEmail("name.surname@company.com")
.withDepartment("engineering")
.currentValue();
在 第六章 理解运行时 中,我们将学习更多关于 this 操作符的知识。
环境声明
环境声明允许你在 TypeScript 代码中创建一个变量,该变量在编译时不会被转换为 JavaScript。这个特性是为了使与现有 JavaScript 代码以及 文档对象模型 (DOM) 和 浏览器对象模型 (BOM) 的集成更加容易。让我们看一个例子:
customConsole.log("A log entry!"); // error
如果你尝试调用名为 customConsole 的对象的成员 log,TypeScript 将会告诉我们 customConsole 对象尚未声明:
// Cannot find name 'customConsole'
这并不令人惊讶。然而,有时我们想要调用尚未定义的对象,例如 console 或 window 对象:
console.log("Log Entry!");
const host = window.location.hostname;
当我们访问 DOM 或 BOM 对象时,不会出现错误,因为这些对象已经在名为 声明文件 的特殊 TypeScript 文件中声明过了。你可以使用 declare 操作符来创建一个环境声明。
在下面的代码片段中,我们将声明一个由 customConsole 对象实现的接口。然后我们使用 declare 操作符将 customConsole 对象添加到作用域中:
interface ICustomConsole {
log(arg: string) : void;
}
declare var customConsole : ICustomConsole;
接口在 第四章 使用 TypeScript 进行面向对象编程 中有更详细的解释。
然后,我们可以使用 customConsole 对象而不会出现编译错误:
customConsole.log("A log entry!"); // ok
TypeScript 默认包含一个名为 lib.d.ts 的文件,它提供了内置 JavaScript 库以及 DOM 的接口声明。
声明文件使用文件扩展名 .d.ts,用于增加 TypeScript 与第三方库和运行时环境(如 Node.js 或浏览器)的兼容性。
我们将在 第五章 与依赖项一起工作 中学习如何使用声明文件。
类型声明 – .d.ts
有时,我们需要消费一个现有的 JavaScript 文件,但我们将无法将其迁移到 TypeScript。这种场景的一个常见例子是我们消费第三方 JavaScript 库。
如果库是开源的,我们可以通过将其迁移到 TypeScript 来为其做出贡献。然而,有时使用 TypeScript 可能与库作者的偏好不符,或者迁移可能需要大量的工作。TypeScript 通过允许我们创建特殊类型的文件来解决此问题,这些文件被称为类型声明或类型定义。
在上一章中,我们了解到 TypeScript 默认包含一个 lib.d.ts 文件,该文件提供了内置 JavaScript 对象的接口声明,以及 DOM 和 BOM API。
类型定义文件包含了第三方库的类型声明。这些文件促进了现有 JavaScript 库与 TypeScript 之间的集成。
为了在消费 JavaScript 库的同时充分利用 TypeScript 的所有功能,我们需要安装该库的类型定义文件。幸运的是,我们不需要手动创建类型定义文件,因为有一个名为 DefinitelyTyped 的开源项目已经包含了大量现有 JavaScript 库的类型定义文件。
在 TypeScript 开发的早期阶段,开发者必须手动从 DefinitelyTyped 项目网站下载和安装类型定义文件,但那些日子已经一去不复返了,如今我们可以使用节点包管理器(npm)来安装和管理我们 TypeScript 应用程序所需的类型定义文件。
我们将在第九章 Automating Your Development Workflow* 中学习如何使用声明文件,该章节将介绍自动化您的开发工作流程。
摘要
在本章中,我们学习了 TypeScript 类型系统的许多功能。到目前为止,我们应该已经对诸如类型推断、非空类型、结构化类型和流程控制分析等概念有了很好的理解。
在下一章中,我们将学习更多关于 TypeScript 中函数的使用。
第三章:与函数一起工作
在第一章 介绍 TypeScript 中,我们学习了函数的基础知识。函数是 TypeScript 中任何应用程序的基本构建块,它们足够强大,值得用整整一章来探索它们的潜力。
在本章中,我们将深入学习如何与函数一起工作。本章分为两个主要部分。第一部分首先快速回顾一些基本概念,然后转向函数的一些不太常见的特性和用例。第一部分涵盖了以下概念:
-
函数声明和函数表达式
-
函数类型
-
带有可选参数的函数
-
带有默认参数的函数
-
带有可变参数的函数
-
函数重载
-
特殊化的重载签名
-
函数作用域
-
立即调用的函数
-
标签函数和标签模板
第二部分专注于 TypeScript 的异步编程能力,包括以下概念:
-
回调和高级函数
-
箭头函数
-
回调地狱
-
承诺
-
生成器
-
异步函数(
async和await)
在 TypeScript 中与函数一起工作
本节主要关注函数、参数和参数的声明和使用。
函数声明和函数表达式
在第一章中,我们介绍了显式声明带有(命名函数)或没有(未命名或匿名函数)名称的函数的可能性,但我们没有提到我们也在使用两种不同类型的函数。
在以下示例中,命名函数greetNamed是一个函数声明,而greetUnnamed是一个函数表达式。现在,请忽略前两行,它们包含两个console.log语句:
console.log(greetNamed("John")); // OK
console.log(greetUnnamed("John")); // Error
function greetNamed(name: string): string {
return `Hi! ${name}`;
}
let greetUnnamed = function(name: string): string {
return `Hi! ${name}`;
};
我们可能会认为前面的函数是相同的,但它们的运行方式不同。JavaScript 解释器可以在解析时评估函数声明。另一方面,函数表达式是赋值的一部分,只有在赋值完成后才会被评估。
这些函数行为不同的主要原因是一个称为变量提升的过程。我们将在本章后面更深入地了解变量提升过程。
如果我们将前面的 TypeScript 代码片段编译成 JavaScript 并在网页浏览器中尝试执行它,我们将观察到第一个console.log调用是有效的。这是因为 JavaScript 了解声明函数,可以在程序执行之前解析它。
然而,第二个警报语句将抛出异常,这表明greetUnnamed不是一个函数。异常抛出是因为greetUnnamed的赋值必须在函数可以评估之前完成。
函数类型
我们已经知道,可以通过使用可选类型注解显式地声明应用程序中元素的类型:
function greetNamed(name: string): string {
return `Hi! ${name}`;
}
在前面的函数中,我们已经指定了参数名称的类型(字符串)及其返回类型(字符串)。有时,我们不仅需要指定函数元素的类型,还需要指定函数本身。让我们看一个例子:
let greetUnnamed: (name: string) => string;
greetUnnamed = function(name: string): string {
return `Hi! ${name}`;
};
在前面的例子中,我们已经声明了greetUnnamed变量及其类型。greetUnnamed类型是一个函数类型,它接受一个名为name的字符串变量作为其唯一参数,并在调用后返回一个字符串。在声明变量之后,一个类型必须等于变量类型的函数被分配给它。
我们也可以在单行中声明greetUnnamed类型并将函数分配给它,而不是像上一个例子那样在两行中分别声明:
let greetUnnamed: (name: string) => string = function(name: string): string {
return `Hi! ${name}`;
};
就像上一个例子一样,前面的代码片段也声明了一个变量greetUnnamed及其类型。greetUnnamed类型是一个函数类型,它接受一个名为name的字符串变量作为其唯一参数,并在调用后返回一个字符串。我们将在声明变量的同一行中将函数分配给这个变量。分配的函数的类型必须与变量的类型匹配。
在前面的例子中,我们声明了greetUnnamed变量的类型,然后将其值分配给一个函数。函数的类型可以从分配的函数中推断出来,因此添加冗余的类型注解是不必要的。我们这样做是为了帮助您理解这一部分,但重要的是要提到,添加冗余的类型注解可能会使我们的代码更难阅读,并且被认为是不良的实践。
函数参数中的尾随逗号
尾随逗号是指在函数最后一个参数之后使用的逗号。在函数的最后一个参数后使用逗号可能很有用,因为程序员在通过添加额外的参数修改现有函数时,常常会忘记添加逗号。
例如,以下函数只接受一个参数,并且没有使用尾随逗号:
function greetWithoutTralingCommas(
name: string
): string {
return `Hi! ${name}`;
}
经过一段时间后,我们可能需要向前面的函数添加一个参数。一个常见的错误是在声明新参数时忘记在第一个参数后添加逗号:
function updatedGreetWithoutTralingCommas(
name: string
surname: string, // Error
): string {
return `Hi! ${name} ${surname}`;
}
在函数的第一个版本中使用尾随逗号可以帮助我们避免这个常见的错误:
function greetWithTralingCommas(
name: string,
): string {
return `Hi! ${name}`;
}
使用尾随逗号消除了在添加新参数时忘记添加逗号的可能性:
function updatedGreetWithTralingCommas(
name: string,
surname: string,
): string {
return `Hi! ${name} ${surname}`;
}
如果我们忘记添加逗号,TypeScript 将抛出错误,因此与使用 JavaScript 相比,尾随逗号不是那么必要。尾随逗号是可选的,但许多 JavaScript 和 TypeScript 工程师认为使用它们是一种良好的实践。
带有可选参数的函数
与 JavaScript 不同,如果尝试调用一个函数而没有提供其签名声明的确切参数数量和类型,TypeScript 编译器将抛出一个错误。让我们看一个代码示例来演示这一点:
function add(foo: number, bar: number, foobar: number): number {
return foo + bar + foobar;
}
前面的函数名为 add,它将接受三个数字作为参数,参数名称分别为 foo、bar 和 foobar。如果我们尝试调用此函数而不提供恰好三个数字,我们将得到一个编译错误,表明提供的参数与函数的签名不匹配:
add(); // Error, expected 3 arguments, but got 0\.
add(2, 2); // Error, expected 3 arguments, but got 2\.
add(2, 2, 2); // OK, returns 6
在某些情况下,我们可能希望能够在不提供所有参数的情况下调用函数。TypeScript 在函数中提供了可选参数,以帮助我们增加函数的灵活性并克服这些情况。
我们可以通过在参数名称后附加 ? 字符来向 TypeScript 编译器指示我们希望函数的参数是可选的。让我们更新前面的函数,将必需的 foobar 参数转换为可选参数:
function add(foo: number, bar: number, foobar?: number): number {
let result = foo + bar;
if (foobar !== undefined) {
result += foobar;
}
return result;
}
注意我们如何将 foobar 参数名称更改为 foobar?,以及我们如何在函数内部检查 foobar 的类型,以确定该参数是否作为参数传递给了函数。在实施这些更改后,TypeScript 编译器将允许我们在提供两个或三个参数时调用该函数而不会出错:
add(); // Error, expected 2-3 arguments, but got 0\.
add(2, 2); // OK, returns 4
add(2, 2, 2); // OK, returns 6
需要注意的是,可选参数必须始终位于函数参数列表中的必选参数之后。
带有默认参数的函数
当一个函数有一些可选参数时,我们必须检查是否向函数传递了参数(就像我们在前面的例子中所做的那样),以防止潜在的错误。
在某些情况下,为参数提供一个默认值(而不是将其作为可选参数)可能更有用。让我们重写 add 函数(来自上一节),使用内联 if 结构:
function add(foo: number, bar: number, foobar?: number): number {
return foo + bar + (foobar !== undefined ? foobar : 0);
}
前面的函数本身没有问题,但我们可以通过为 foobar 参数提供一个默认值来提高其可读性,而不是使用可选参数:
function add(foo: number, bar: number, foobar: number = 0): number {
return foo + bar + foobar;
}
要表示一个函数参数是可选的,我们需要在声明函数签名时使用 = 运算符提供默认值。在编译前面的代码示例后,TypeScript 编译器将在 JavaScript 输出中生成一个 if 结构,以设置 foobar 参数的默认值,如果它没有被作为函数的参数传递:
function add(foo, bar, foobar) {
if (foobar === void 0) { foobar = 0; }
return foo + bar + foobar;
}
这很棒,因为 TypeScript 编译器为我们生成了防止潜在运行时错误的代码。
void 0参数由 TypeScript 编译器用于检查变量是否未定义。虽然大多数开发者使用undefined变量来执行这种检查,但大多数编译器使用void 0,因为它始终评估为undefined。与未定义变量进行比较的安全性较低,因为其值可能已被修改,如下面的代码片段所示:
function test() {
var undefined = 2; // 2
console.log(undefined === 2); // true
}
就像可选参数一样,默认参数必须始终位于函数参数列表中的任何必需参数之后。
带有 REST 参数的函数
我们已经学会了如何使用可选和默认参数来增加调用函数的方式。让我们再次回到之前的例子:
function add(foo: number, bar: number, foobar: number = 0): number {
return foo + bar + foobar;
}
我们已经学会了如何使用两个或三个参数调用add函数,但如果我们想允许其他开发者向我们的函数传递四个或五个参数呢?我们就必须添加两个额外的默认或可选参数。如果我们想允许他们传递他们需要的任意数量的参数呢?解决这种可能场景的方法是使用 REST 参数。REST 参数语法允许我们将不定数量的参数表示为一个数组:
function add(...foo: number[]): number {
let result = 0;
for (let i = 0; i < foo.length; i++) {
result += foo[i];
}
return result;
}
正如前一个代码片段所示,我们将函数参数foo、bar和foobar替换为只有一个参数名为foo。请注意,参数foo的名称前面有一个省略号(一组三个点,而不是实际的省略号字符)。REST 参数必须是数组类型,否则我们将得到编译错误。现在我们可以使用我们需要的任意数量的参数来调用add函数:
add(); // 0
add(2); // 2
add(2, 2); // 4
add(2, 2, 2); // 6
add(2, 2, 2, 2); // 8
add(2, 2, 2, 2, 2); // 10
add(2, 2, 2, 2, 2, 2); // 12
虽然函数可以接受的理论最大参数数量没有具体限制,但当然存在实际限制。这些限制完全取决于实现,并且很可能会也取决于我们调用函数的确切方式。
JavaScript 函数有一个内置对象,称为arguments对象。该对象作为名为arguments的局部变量可用。arguments变量包含一个类似于数组的对象,其中包含函数被调用时使用的参数。
arguments对象暴露了一些标准数组提供的方法和属性,但并非全部。有关其特性的更多信息,请参阅developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/arguments文档。
如果我们检查 JavaScript 的输出,我们会注意到 TypeScript 会迭代arguments对象,将值添加到foo变量中:
function add() {
var foo = [];
for (var _i = 0; _i < arguments.length; _i++) {
foo[_i - 0] = arguments[_i];
}
var result = 0;
for (var i = 0; i < foo.length; i++) {
result += foo[i];
}
return result;
}
我们可以争辩说,这是对函数参数的额外、不必要的迭代。尽管很难想象这种额外的迭代会成为性能问题,但如果您认为这可能会影响您应用程序的性能,您可能希望考虑避免使用 REST 参数,而是使用数组作为函数的唯一参数:
function add(foo: number[]): number {
let result = 0;
for (let i = 0; i < foo.length; i++) {
result += foo[i];
}
return result;
}
之前的功能只接受一个数字数组作为其唯一参数。调用 API 将与 REST 参数略有不同,但我们将有效地避免在函数参数列表上额外的迭代:
add(); // Error, expected 1 arguments, but got 0\.
add(2); // Error, '2' is not assignable to parameter of type 'number[]'.
add(2, 2); // Error, expected 1 arguments, but got 2\.
add(2, 2, 2); // Error, expected 1 arguments, but got 3\.
add([]); // returns 0
add([2]); // returns 2
add([2, 2]); // returns 4
add([2, 2, 2]); // returns 6
函数重载
函数重载,或方法重载,是创建具有相同名称但参数数量或类型不同的多个方法的能力。在 TypeScript 中,我们可以通过指定一个函数的所有函数签名(称为重载签名),然后是一个签名(称为实现签名)来重载一个函数。让我们看一个例子:
function test(name: string): string; // overloaded signature
function test(age: number): string; // overloaded signature
function test(single: boolean): string; // overloaded signature
function test(value: (string|number|boolean)): string { // implementation signature
switch (typeof value) {
case "string":
return `My name is ${value}.`;
case "number":
return `I'm ${value} years old.`;
case "boolean":
return value ? "I'm single." : "I'm not single.";
default:
throw new Error("Invalid Operation!");
}
}
正如我们可以在前面的例子中看到的那样,我们通过添加一个只接受字符串作为唯一参数的签名、另一个接受数字的函数和一个最终接受布尔值作为唯一参数的签名,三次重载了函数 test。重要的是要注意,所有函数签名都必须兼容;因此,如果例如,一个签名试图返回一个数字,而另一个试图返回一个字符串,我们将得到一个编译错误:
function test(name: string): string;
function test(age: number): number; // Error
function test(single: boolean): string;
function test(value: (string|number|boolean)): string {
switch (typeof value) {
case "string":
return `My name is ${value}.`;
case "number":
return `I'm ${value} years old.`;
case "boolean":
return value ? "I'm single." : "I'm not single.";
default:
throw new Error("Invalid Operation!");
}
}
实现签名必须与所有重载签名兼容,始终是列表中的最后一个,并且其参数的类型必须是any类型或联合类型。
调用函数并提供不匹配重载签名中声明的任何类型的参数将导致我们得到一个编译错误:
test("Remo"); // returns "My name is Remo."
test(26); // returns "I'm 26 years old.";
test(false); // returns "I'm not single.";
test({ custom: "custom" }); // Error
专用重载签名
我们可以使用专用签名创建具有相同名称和参数数量但不同返回类型的多个方法。要创建专用签名,我们必须使用字符串指示函数参数的类型。字符串字面量用于识别调用了哪个函数重载:
interface Document {
createElement(tagName: "div"): HTMLDivElement; // specialized
createElement(tagName: "span"): HTMLSpanElement; // specialized
createElement(tagName: "canvas"): HTMLCanvasElement; // specialized
createElement(tagName: string): HTMLElement; // non-specialized
}
在前面的例子中,我们为名为createElement的函数声明了三个专用重载签名和一个非专用签名。
当我们在对象中声明一个专用签名时,它必须可以分配给该对象中至少一个非专用签名。这可以从前面的例子中观察到,因为createElement属性属于一个包含三个专用签名的类型,所有这些都可以分配给该类型的非专用签名。
当编写重载声明时,我们必须将非专用签名放在最后。
函数作用域
低级语言,如 C 语言,具有低级内存管理功能。在具有更高抽象级别的编程语言中,如 TypeScript,变量在创建时分配内存,当不再使用时自动从内存中清除。清理内存的过程称为 垃圾回收,由 JavaScript 运行时的垃圾回收器执行。
垃圾回收器做得很好,但认为它总能防止我们遇到内存泄漏是错误的。垃圾回收器会在变量超出作用域时从内存中清除变量。理解 TypeScript 作用域的工作方式很重要,因此我们现在将查看变量的生命周期。
一些编程语言使用程序源代码的结构来确定我们引用的是哪个变量(词法作用域),而另一些则使用程序调用栈的运行时状态来确定我们引用的是哪个变量(动态作用域)。大多数现代编程语言使用词法作用域(包括 TypeScript)。词法作用域通常比动态作用域更容易被人类和分析工具理解。
在大多数词法作用域编程语言中,变量的作用域限定在代码块(由花括号 {} 分隔的代码部分)内,而在 TypeScript(和 JavaScript)中,变量的作用域限定在函数内,如下面的代码片段所示:
function foo(): void {
if (true) {
var bar: number = 0;
}
console.log(bar);
}
foo(); // 0
前面的名为 foo 的函数包含一个 if 结构。我们在 if 结构内部声明了一个名为 bar 的数值变量,后来我们尝试使用 log 函数显示 bar 变量的值。
我们可能会认为前面的代码示例在第五行会抛出错误,因为当调用 log 函数时 var 变量应该超出作用域。然而,如果我们调用 foo 函数,log 函数将能够无错误地显示 bar 变量,因为函数内部的所有变量都将处于整个函数体的作用域内,即使它们位于另一个代码块内(除了函数块)。
这可能看起来有些令人困惑,但一旦我们知道在运行时,所有变量声明都会在函数执行之前移动到函数顶部,这种行为就很容易理解了。这种行为被称为 提升。
TypeScript 编译成 JavaScript 然后执行——这意味着 TypeScript 应用程序在运行时是一个 JavaScript 应用程序,因此,当我们提到 TypeScript 运行时,我们实际上是在谈论 JavaScript 运行时。我们将在第六章 理解运行时中深入探讨运行时。
在执行前面的代码片段之前,运行时会将 bar 变量的声明移动到我们的函数顶部:
function foo() {
var bar;
if (true) {
bar = 0;
}
console.log(bar);
}
foo(); // 0
这就是为什么可以在变量声明之前使用变量的原因。让我们来看一个例子:
function foo(): void {
bar = 0;
var bar: number;
console.log(bar);
}
foo(); // 0
在前面的代码片段中,我们声明了一个foo函数,并在其函数体内将值0赋给名为bar的变量。此时,该变量尚未声明。在第二行,我们声明了变量bar及其类型。在最后一行,我们使用 alert 函数显示bar的值。
因为在函数内部(除了另一个函数)的任何地方声明变量(等同于在函数顶部声明),foo函数在运行时会转换为以下形式:
function foo(): void {
var bar: number;
bar = 0;
console.log(bar);
}
foo(); // 0
由于具有像 Java 或 C#这样的具有块作用域的编程语言背景的开发者不习惯函数作用域,这是 JavaScript 最被批评的特性之一。ECMAScript 6 规范的开发负责人对此有所了解,因此他们引入了let和const关键字。
let关键字允许我们将变量的作用域设置为块(if、while、for等),而不是函数。我们可以更新本节中的第一个示例来展示let是如何工作的:
function foo(): void {
if (true) {
let bar: number = 0;
bar = 1;
}
console.log(bar); // Error
}
现在,bar变量使用let关键字声明,因此它只能在if块内部访问。变量不会被提升到foo函数的顶部,并且不能在if语句外部通过alert函数访问。
虽然const声明的变量遵循与let声明的变量相同的范围规则,但它们不能被重新赋值:
function foo(): void {
if (true) {
const bar: number = 0;
bar = 1; // Error
}
alert(bar); // Error
}
如果我们尝试编译前面的代码片段,我们会得到一个错误,因为bar变量在if语句外部不可访问(就像我们使用let关键字时一样),并且当我们尝试给bar变量赋新值时,会出现新的错误。第二个错误发生是因为一旦变量已经被初始化,就无法给常量变量赋新值。
使用const关键字声明的变量不能重新赋值,但不是不可变的。当我们说一个变量是不可变的,我们的意思是它不能被修改。我们将在第七章中了解更多关于不可变性的内容,使用 TypeScript 进行函数式编程。
立即调用的函数
立即调用的函数表达式(IIFE)是一种设计模式,它使用函数作用域创建词法作用域。IIFE 可以用来避免在块内部提升变量,或者防止我们污染全局作用域——例如:
let bar = 0; // global
(function() {
let foo: number = 0; // In scope of this function
bar = 1; // Access global scope
console.log(bar); // 1
console.log(foo); // 0
})();
console.log(bar); // 1
console.log(foo); // Error
在前面的例子中,我们用立即执行函数表达式(IIFE)包裹了变量的声明(foo)。foo变量的作用域限定在 IIFE 函数内部,并且不在全局作用域中,这解释了当我们尝试在最后一行访问它时抛出的错误。
bar 变量是全局的。因此,它可以从 IIFE 函数的内部和外部访问。
我们还可以将变量传递给 IIFE,以更好地控制其作用域外变量的创建:
let bar = 0; // global
let topScope = window;
(function(global: any) {
let foo: number = 0; // In scope of this function
console.log(global.bar); // 0
global.bar = 1; // Access global scope
console.log(global.bar); // 1
console.log(foo); // 0
})(topScope);
console.log(bar); // 1
console.log(foo); // Error
此外,IIFE 可以帮助我们同时允许对方法进行公共访问,同时保留函数内部定义的变量的隐私。让我们来看一个例子:
class Counter {
private _i: number;
public constructor() {
this._i = 0;
}
public get(): number {
return this._i;
}
public set(val: number): void {
this._i = val;
}
public increment(): void {
this._i++;
}
}
let counter = new Counter();
console.log(counter.get()); // 0
counter.set(2);
console.log(counter.get()); // 2
counter.increment();
console.log(counter.get()); // 3
console.log(counter._i); // Error: Property '_i' is private
我们定义了一个名为 Counter 的类,该类有一个名为 _i 的私有数值属性。该类还具有获取和设置私有 _i 属性值的函数。
按照惯例,TypeScript 和 JavaScript 开发者通常使用以下划线(_)开头命名的名称来命名私有变量。
我们还创建了一个 Counter 类的实例,并调用了 set、get 和 increment 方法来观察一切是否按预期工作。如果我们尝试访问 Counter 实例中的 _i 属性,我们将得到一个错误,因为该变量是 private。
如果我们将前面的 TypeScript 代码(仅类定义)编译并检查生成的 JavaScript 代码,我们将看到以下内容:
var Counter = (function() {
function Counter() {
this._i = 0;
}
Counter.prototype.get = function() {
return this._i;
};
Counter.prototype.set = function(val) {
this._i = val;
};
Counter.prototype.increment = function() {
this._i++;
};
return Counter;
})();
此生成的 JavaScript 代码在大多数场景下将完美运行,但如果我们在浏览器中执行它并尝试创建 Counter 的实例并访问其 _i 属性,我们将不会得到任何错误,因为 TypeScript 不会为我们生成运行时私有属性。有时我们需要以某种方式编写我们的类,以便某些属性在运行时是私有的——例如,如果我们发布一个将被 JavaScript 开发者使用的库。
我们也可以使用 IIFE 同时允许对方法进行公共访问,同时保留函数内部定义的变量的隐私:
var Counter = (function() {
var _i: number = 0;
function Counter() {
//
}
Counter.prototype.get = function() {
return _i;
};
Counter.prototype.set = function(val: number) {
_i = val;
};
Counter.prototype.increment = function() {
_i++;
};
return Counter;
})();
在前面的例子中,一切几乎与 TypeScript 生成的 JavaScript 相同,但现在 _i 变量是 Counter 闭包中的一个对象,而不是 Counter 类的一个属性。
闭包是引用独立(自由)变量的函数。换句话说,闭包中定义的函数记住了其创建时的环境(作用域内的变量)。我们将在第六章,理解运行时中了解更多关于闭包的内容。
如果我们在浏览器中运行生成的 JavaScript 输出并尝试直接调用 _i 属性,我们会注意到该属性现在在运行时是私有的:
let counter = new Counter();
console.log(counter.get()); // 0
counter.set(2);
console.log(counter.get()); // 2
counter.increment();
console.log(counter.get()); // 3
console.log(counter._i); // undefined
在某些情况下,我们需要对作用域和闭包有精确的控制,我们的代码最终会看起来更像 JavaScript。只要我们将应用程序组件(类、模块等)编写为供其他 TypeScript 组件使用,我们就很少需要担心实现运行时私有属性。我们将在第六章,理解运行时中深入探讨 TypeScript 的运行时。
标签函数和标签模板
在 TypeScript 中,我们可以使用如下模板字符串:
let name = "remo";
let surname = "jansen";
let html = `<h1>${name} ${surname}</h1>`;
我们可以使用模板字符串创建一种特殊类型的函数,称为标签函数。
我们可以使用标签函数来扩展或修改模板字符串的标准行为。当我们将标签函数应用于模板字符串时,模板字符串就变成了一个标签模板。
我们将实现一个名为 htmlEscape 的标签函数。要使用标签函数,我们必须使用函数名后跟一个模板字符串:
let html = htmlEscape `<h1>${name} ${surname}</h1>`;
标签模板必须返回一个字符串并接受以下参数:
- 包含模板字符串中所有静态字面量(在先前的示例中为
<h1>和</h1>)的TemplateStringsArray作为第一个参数传递。
TemplateStringsArray 类型由 lib.d.ts 文件声明。我们将在第九章自动化您的开发工作流程中了解更多关于 lib.d.ts 文件的内容。
- 可变参数作为第二个参数传递。可变参数包含模板字符串中的所有值(在先前的示例中为
name和surname)。
标签函数的签名如下所示:
tag(literals: TemplateStringsArray, ...placeholders: any[]): string;
让我们实现 htmlEscape 标签函数:
function htmlEscape(literals: TemplateStringsArray, ...placeholders: any[]) {
let result = "";
for (let i = 0; i < placeholders.length; i++) {
result += literals[i];
result += placeholders[i]
.replace(/&/g, "&")
.replace(/"/g, """)
.replace(/"/g, "'")
.replace(/</g, "<")
.replace(/>/g, ">");
}
result += literals[literals.length - 1];
return result;
}
然后,我们可以按如下方式调用该函数:
let html = htmlEscape `<h1>${name} ${surname}</h1>`;
模板字符串包含值和字面量。htmlEscape 函数遍历它们,并确保在值中转义 HTML 代码,以避免可能的代码注入攻击。
使用标签函数的主要好处是它允许我们创建自定义模板字符串处理器。
TypeScript 中的异步编程
现在我们已经了解了如何使用函数,我们将探讨如何结合一些原生 API 使用它们来编写异步应用程序。
回调与高阶函数
在 TypeScript 中,函数可以作为参数传递给另一个函数。函数也可以由另一个函数返回。传递给另一个函数作为参数的函数称为回调。接受函数作为参数(回调)或返回函数的函数称为高阶函数。
回调通常是匿名函数。它们可以在传递给高阶函数之前声明,如下面的示例所示:
var foo = function() { // callback
console.log("foo");
}
function bar(cb: () => void) { // higher order function
console.log("bar");
cb();
}
bar(foo); // prints "bar" then prints "foo"
然而,回调是在行内声明的,与传递给高阶函数的点相同,如下面的示例所示:
bar(() => {
console.log("foo");
}); // prints "bar" then prints "foo"
箭头函数
在 TypeScript 中,我们可以使用 function 表达式或箭头函数声明一个函数。箭头函数的语法比函数表达式更短,并且词法绑定 this 操作符的值。
在 TypeScript 和 JavaScript 中,this 操作符的行为与其他流行编程语言略有不同。当我们使用 TypeScript 定义一个类时,我们可以使用 this 操作符来引用该类。让我们看一个例子:
class Person {
private _name: string;
constructor(name: string) {
this._name = name;
}
public greet() {
console.log(`Hi! My name is ${this._name}`);
}
}
let person = new Person("Remo");
person.greet(); // "Hi! My name is Remo"
我们已经定义了一个包含一个名为 name 的字符串类型属性的 Person 类。该类有一个构造函数和一个 greet 方法。我们创建了一个名为 person 的实例并调用了 greet 方法,该方法内部使用 this 操作符来访问 _name 属性。在 greet 方法内部,this 操作符指向包含 greet 方法的对象。
在使用 this 操作符时,我们必须小心,因为在某些场景中,它可能指向错误值。让我们向之前的示例添加一个额外的方法:
class Person {
private _name: string;
constructor(name: string) {
this._name = name;
}
public greet() {
alert(`Hi! My name is ${this._name}`);
}
public greetDelay(time: number) {
setTimeout(function() {
alert(`Hi! My name is ${this._name}`); // Error
}, time);
}
}
在 greetDelay 方法中,我们执行的操作几乎与 greet 方法执行的操作相同。这次,函数接受一个名为 time 的参数,该参数用于延迟 greet 消息。
要延迟一个消息,我们使用 setTimeout 函数和一个回调。一旦我们定义了一个匿名函数(回调),this 关键字就会改变其值并开始指向匿名函数,这也解释了为什么 TypeScript 编译器会抛出错误。
如前所述,箭头函数表达式按词法绑定 this 操作符的值。这意味着它 允许 我们在不改变 this 操作符的值的情况下添加一个函数。让我们将之前示例中的函数表达式替换为箭头函数:
class Person {
private _name: string;
constructor(name: string) {
this._name = name;
}
public greet() {
alert(`Hi! My name is ${this._name}`);
}
public greetDelay(time: number) {
setTimeout(() => {
alert(`Hi! My name is ${this._name}`); // OK
}, time);
}
}
let person = new Person("Remo");
person.greet(); // "Hi! My name is Remo"
person.greetDelay(1000); // "Hi! My name is Remo"
通过使用箭头函数,我们可以确保 this 操作符仍然指向 Person 实例,而不是 setTimeout 回调。如果我们执行 greetDelay 函数,将按预期显示名称属性。
以下代码片段是由 TypeScript 编译器生成的。当编译箭头函数时,TypeScript 编译器将为 this 操作符生成一个名为 _this 的别名。该别名用于确保 this 操作符指向正确的对象:
Person.prototype.greetDelay = function (time) {
var _this = this;
setTimeout(function () {
alert("Hi! My name is " + _this._name);
}, time);
};
我们将在 第六章 中深入探讨 this 操作符,理解运行时。
回调地狱
我们已经了解到回调和高级函数是 JavaScript 和 TypeScript 中的两个强大且灵活的功能。然而,回调的使用可能导致一个称为 回调地狱 的可维护性问题。
我们现在将编写一个示例来展示回调地狱。我们将编写三个具有相同行为的函数,分别命名为 doSomethingAsync、doSomethingElseAsync 和 doSomethingMoreAsync:
function doSomethingAsync(
arr: number[],
success: (arr: number[]) => void,
error: (e: Error) => void
) {
setTimeout(() => {
try {
let n = Math.ceil(Math.random() * 100 + 1);
if (n < 25) {
throw new Error("n is < 25");
}
success([...arr, n]);
} catch (e) {
error(e);
}
}, 1000);
}
function doSomethingElseAsync(
arr: number[],
success: (arr: number[]) => void,
error: (e: Error) => void
) {
setTimeout(() => {
try {
let n = Math.ceil(Math.random() * 100 + 1);
if (n < 25) {
throw new Error("n is < 25");
}
success([...arr, n]);
} catch (e) {
error(e);
}
}, 1000);
}
function doSomethingMoreAsync(
arr: number[],
success: (arr: number[]) => void,
error: (e: Error) => void
) {
setTimeout(() => {
try {
let n = Math.ceil(Math.random() * 100 + 1);
if (n < 25) {
throw new Error("n is < 25");
}
success([...arr, n]);
} catch (e) {
error(e);
}
}, 1000);
}
之前的功能通过使用 setTimeout 函数模拟异步操作。每个函数都接受一个成功回调,如果操作成功则调用该回调,以及一个错误回调,如果发生错误则调用该回调。
在现实世界的应用中,异步操作通常涉及与硬件的一些交互(例如,文件系统、网络等)。这些交互被称为输入/输出(I/O)操作。I/O 操作可能因许多不同的原因而失败(例如,当我们尝试与文件系统交互以保存新文件时,硬盘上没有足够的空间)。
前面的函数生成一个随机数,如果数字小于 25 则抛出错误;我们这样做是为了模拟潜在的 I/O 错误。
前面的函数将随机数添加到一个数组中,该数组作为参数传递给每个函数。如果没有发生错误,最终函数(doSomethingMoreAsync)的结果应该是一个包含三个随机数的数组。
现在三个函数已经声明,我们可以尝试按顺序调用它们。我们将使用回调来确保doSomethingMoreAsync在doSomethingElseAsync之后被调用,而doSomethingElseAsync在doSomethingAsync之后被调用:
doSomethingAsync([], (arr1) => {
doSomethingElseAsync(arr1, (arr2) => {
doSomethingMoreAsync(arr2, (arr3) => {
console.log(
`
doSomethingAsync: ${arr3[0]}
doSomethingElseAsync: ${arr3[1]}
doSomethingMoreAsync: ${arr3[2]}
`
);
}, (e) => console.log(e));
}, (e) => console.log(e));
}, (e) => console.log(e));
前面的例子使用了几个嵌套的回调。这类嵌套回调的使用被称为回调地狱(callback hell),因为它们可能导致以下可维护性问题:
-
使代码更难以跟踪和理解
-
使代码更难以维护(重构、重用等)
-
使异常处理更加困难
承诺
在看到回调的使用如何导致一些可维护性问题之后,我们现在将学习关于承诺(promises)以及如何使用它们来编写更好的异步代码。承诺背后的核心思想是,承诺代表异步操作的结果。承诺必须处于以下三种状态之一:
-
挂起(Pending):承诺的初始状态。
-
已实现/已解决(Fulfilled/resolved):表示成功操作的承诺状态。术语“已实现”和“已解决”都常用来指代此状态。
-
已拒绝(Rejected):表示失败操作的承诺状态。
一旦承诺被实现或拒绝,其状态将无法再改变。让我们看看承诺的基本语法:
function foo() {
return new Promise<string>((fulfill, reject) => {
try {
// do something
fulfill("SomeValue");
} catch (e) {
reject(e);
}
});
}
foo().then((value) => {
console.log(value);
}).catch((e) => {
console.log(e);
});
这里使用try...catch语句来展示我们如何显式地实现或拒绝一个承诺。对于Promise函数来说,try...catch语句是不必要的,因为当在承诺回调中抛出错误时,承诺将自动被拒绝。
前面的代码片段声明了一个名为foo的函数,该函数返回一个承诺。承诺包含一个名为then的方法,该方法接受一个在承诺实现时将被调用的函数。承诺还提供了一个名为catch的方法,该方法在承诺被拒绝时被调用。
如果我们针对 ES5,TypeScript 编译器将不会识别承诺,因为承诺 API 是 ES6 的一部分。我们可以通过在 tsconfig.json 文件中使用 lib 选项启用 es2015.promise 类型来解决此问题。请注意,启用此选项将禁用默认包含的一些类型,因此会破坏一些示例。您可以通过包含 dom 和 es5 类型,以及使用 tsconfig.json 文件中的 lib 选项来解决这些问题:
"lib": [
"es2015.promise",
"dom",
"es5",
"es2015.generator", // new
"es2015.iterable" // new
]
我们将在第九章 自动化您的开发工作流程中了解更多关于 lib 设置的信息。
现在,我们将重写我们在回调地狱示例中编写的 doSomethingAsync、doSomethingElseAsync 和 doSomethingMoreAsync 函数,使用承诺而不是回调:
function doSomethingAsync(arr: number[]) {
return new Promise<number[]>((resolve, reject) => {
setTimeout(() => {
try {
let n = Math.ceil(Math.random() * 100 + 1);
if (n < 25) {
throw new Error("n is < 25");
}
resolve([...arr, n]);
} catch (e) {
reject(e);
}
}, 1000);
});
}
function doSomethingElseAsync(arr: number[]) {
return new Promise<number[]>((resolve, reject) => {
setTimeout(() => {
try {
let n = Math.ceil(Math.random() * 100 + 1);
if (n < 25) {
throw new Error("n is < 25");
}
resolve([...arr, n]);
} catch (e) {
reject(e);
}
}, 1000);
});
}
function doSomethingMoreAsync(arr: number[]) {
return new Promise<number[]>((resolve, reject) => {
setTimeout(() => {
try {
let n = Math.ceil(Math.random() * 100 + 1);
if (n < 25) {
throw new Error("n is < 25");
}
resolve([...arr, n]);
} catch (e) {
reject(e);
}
}, 1000);
});
}
我们可以使用承诺 API 链式调用每个前面函数返回的承诺:
doSomethingAsync([]).then((arr1) => {
doSomethingElseAsync(arr1).then((arr2) => {
doSomethingMoreAsync(arr2).then((arr3) => {
console.log(
`
doSomethingAsync: ${arr3[0]}
doSomethingElseAsync: ${arr3[1]}
doSomethingMoreAsync: ${arr3[2]}
`
);
});
});
}).catch((e) => console.log(e));
前面的代码片段比回调示例中使用的代码要好一些,因为我们只需要声明一个异常处理程序而不是三个异常处理程序。这是可能的,因为错误是通过承诺链传播的。
前面的例子介绍了一些改进。然而,承诺 API 允许我们以更简洁的方式链式调用承诺:
doSomethingAsync([])
.then(doSomethingElseAsync)
.then(doSomethingMoreAsync)
.then((arr3) => {
console.log(
`
doSomethingAsync: ${arr3[0]}
doSomethingElseAsync: ${arr3[1]}
doSomethingMoreAsync: ${arr3[2]}
`
);
}).catch((e) => console.log(e));
前面的代码比回调示例中使用的代码更容易阅读和跟踪,但这并不是唯一选择承诺而不是回调的原因。使用承诺还使我们能够更好地控制操作的执行流程。让我们看看几个例子。
承诺 API 包含一个名为 all 的方法,它允许我们并行执行一系列承诺,并一次性获取每个承诺的所有结果:
Promise.all([
new Promise<number>((resolve) => {
setTimeout(() => resolve(1), 1000);
}),
new Promise<number>((resolve) => {
setTimeout(() => resolve(2), 1000);
}),
new Promise<number>((resolve) => {
setTimeout(() => resolve(3), 1000);
})
]).then((values) => {
console.log(values); // [ 1 ,2, 3]
});
承诺 API 还包含一个名为 race 的方法,它允许我们并行执行一系列承诺,并获取第一个解决的承诺的结果:
Promise.race([
new Promise<number>((resolve) => {
setTimeout(() => resolve(1), 3000);
}),
new Promise<number>((resolve) => {
setTimeout(() => resolve(2), 2000);
}),
new Promise<number>((resolve) => {
setTimeout(() => resolve(3), 1000);
})
]).then((fastest) => {
console.log(fastest); // 3
});
当与承诺一起工作时,我们可以使用许多不同类型的异步流程控制:
-
并发:任务并行执行(就像在
Promise.all示例中) -
竞赛:任务并行执行,只返回最快承诺的结果
-
序列:一组任务按顺序执行,但前面的任务不会将参数传递给下一个任务
-
瀑布流:一组任务按顺序执行,每个任务将参数传递给下一个任务(就像在
Promise.all和Promise.race示例之前给出的例子) -
组合:这是前面并发、序列和瀑布方法的任何组合
回调参数中的协变检查
TypeScript 2.4 改变了类型系统内部的行为方式,以改善嵌套回调和承诺中的错误检测:
TypeScript 对回调参数的检查现在与立即签名检查是协变的。之前它是双变的,有时可能会让错误类型通过。基本上,这意味着回调参数和包含回调的类将更加仔细地检查,因此 TypeScript 在这个版本中将要求更严格的类型。这尤其适用于 Promises 和 Observables,因为它们的 API 指定方式。
在 TypeScript 2.4 之前的版本中,以下示例被认为是有效的,并且没有抛出错误:
declare function someFunc(
callback: (
nestedCallback: (error: number, result: any) => void
) => void
): void;
someFunc(
(
nestedCallback: (e: number) => void // Error
) => {
nestedCallback(1);
}
);
在 TypeScript 2.4 版本之后的版本中,我们需要添加nestedCallback的完整签名来解决这个错误:
someFunc(
(
nestedCallback: (e: number, result: any) => void // OK
) => {
nestedCallback(1, 1);
}
);
多亏了 TypeScript 类型系统的内部变化,以下错误也被检测到了:
let p: Promise<number> = new Promise((res, rej) => {
res("error"); // Error
});
在 TypeScript 2.4 之前,前面的 promise 会被推断为Promise<{}>,因为我们创建Promise类的实例时忘记了添加泛型<number>参数。然后error字符串会被认为是{}的有效实例。
这是一个清晰的例子,说明了为什么建议您定期升级 TypeScript。TypeScript 的每个新版本都引入了新的功能,能够为我们检测到新的错误。
生成器
如果我们在 TypeScript 中调用一个函数,我们可以假设一旦函数开始运行,它将始终运行到完成,然后其他代码才能运行。然而,一种新的函数类型最近被添加到 ECMAScript 规范中,这种函数可以在执行过程中暂停——一次或多次——并在稍后恢复,允许在这些暂停期间运行其他代码。这些新类型的函数被称为生成器。
生成器代表一系列值。generator对象的接口只是一个迭代器。迭代器实现了以下接口:
interface Iterator<T> {
next(value?: any): IteratorResult<T>;
return?(value?: any): IteratorResult<T>;
throw?(e?: any): IteratorResult<T>;
}
可以调用next()函数,直到它耗尽值。
我们可以通过使用function关键字后跟一个星号(*)来定义一个生成器。yield关键字用于停止函数的执行并返回一个值。让我们看一个例子:
function *foo() {
yield 1;
yield 2;
yield 3;
yield 4;
return 5;
}
let bar = foo();
bar.next(); // Object {value: 1, done: false}
bar.next(); // Object {value: 2, done: false}
bar.next(); // Object {value: 3, done: false}
bar.next(); // Object {value: 4, done: false}
bar.next(); // Object {value: 5, done: true}
bar.next(); // Object { done: true }
注意,如果您针对 ES5,生成器需要一些额外的类型。您需要将es2015.generator和es2015.iterable添加到您的tsconfig.json文件中:
"lib": [
"es2015.promise",
"dom",
"es5",
"es2015.generator", // new
"es2015.iterable" // new
]
我们将在第九章自动化您的开发工作流程中了解更多关于lib设置的内容。
如我们所见,前面的迭代器有五个步骤。第一次调用next时,函数将执行直到遇到第一个yield语句,然后它将返回值1并停止函数的执行,直到我们再次调用生成器的next方法。如我们所见,我们现在能够在给定点停止函数的执行。这允许我们编写不会导致栈溢出的无限循环,如下面的例子所示:
function* foo() {
let i = 1;
while (true) {
yield i++;
}
}
let bar = foo();
bar.next(); // Object {value: 1, done: false}
bar.next(); // Object {value: 2, done: false}
bar.next(); // Object {value: 3, done: false}
bar.next(); // Object {value: 4, done: false}
bar.next(); // Object {value: 5, done: false}
bar.next(); // Object {value: 6, done: false}
bar.next(); // Object {value: 7, done: false}
生成器将打开同步的可能性,因为我们可以在异步事件发生后调用生成器的next方法。
异步函数 – async 和 await
异步函数是 TypeScript 1.6 版本引入的一个特性。开发者可以使用await关键字等待函数结果,而不会阻塞程序的正常执行。
使用异步函数有助于提高代码的可读性,与使用 promises 或回调函数相比,但技术上我们可以使用两者都实现相同的功能。
让我们看看一个基本的async/await示例:
let p = Promise.resolve(3);
async function fn(): Promise<number> {
let i = await p; // 3
return 1 + i; // 4
}
fn().then((r) => console.log(r)); // 4
上述代码片段声明了一个名为p的 promise。这个 promise 是我们将要等待的代码片段的执行。在等待的过程中,程序执行不会被阻塞,因为 JavaScript 允许我们在不阻塞的情况下等待名为fn的异步函数。正如我们所见,fn函数前面有async关键字,它用来告诉编译器这是一个异步函数。
在函数内部,await关键字用于挂起执行,直到 promise p被实现或拒绝。正如我们所见,语法比使用 promises API 或回调函数时的语法更简洁。
fn函数在运行时返回一个 promise,因为它是一个async函数。这应该解释了为什么我们需要在代码片段的末尾使用then回调来调用它。
以下代码片段展示了我们如何声明一个名为invokeTaskAsync的异步函数。这个异步函数使用await关键字等待我们在 promise 示例中声明的doSomethingAsync、doSomethingElseAsync和doSomethingMoreAsync函数的结果:
async function invokeTaskAsync() {
let arr1 = await doSomethingAsync([]);
let arr2 = await doSomethingElseAsync(arr1);
return await doSomethingMoreAsync(arr2);
}
invokeTaskAsync函数是异步的,因此它在运行时会返回一个 promise。这意味着我们可以使用 promises API 来等待结果或分别捕获潜在的错误:
invokeTaskAsync().then((result) => {
console.log(
`
doSomethingAsync: ${result[0]}
doSomethingElseAsync: ${result[1]}
doSomethingMoreAsync: ${result[2]}
`
);
}).catch((e) => {
console.log(e);
});
我们还可以将异步 IIFE 定义为使用async和await关键字的一种方便方式:
(async () => {
try {
let arr1 = await doSomethingAsync([]);
let arr2 = await doSomethingElseAsync(arr1);
let arr3 = await doSomethingMoreAsync(arr2);
console.log(
`
doSomethingAsync: ${arr3[0]}
doSomethingElseAsync: ${arr3[1]}
doSomethingMoreAsync: ${arr3[2]}
`
);
} catch (e) {
console.log(e);
}
})();
使用异步 IIFE 非常有用,因为通常我们无法在函数之外使用await关键字——例如,在应用程序的入口点。我们可以使用异步 IIFE 来克服这个限制:
(async () => {
await main();
})();
异步生成器
我们已经学习了所有迭代器实现的接口:
interface Iterator<T> {
next(value?: any): IteratorResult<T>;
return?(value?: any): IteratorResult<T>;
throw?(e?: any): IteratorResult<T>;
}
然而,我们还没有学习到所有异步迭代器实现的接口:
interface AsyncIterator<T> {
next(value?: any): Promise<IteratorResult<T>>;
return?(value?: any): Promise<IteratorResult<T>>;
throw?(e?: any): Promise<IteratorResult<T>>;
}
每次我们调用next方法时,异步迭代器都会返回一个 promise。以下代码片段演示了异步迭代器如何与异步函数结合使用时非常有用:
let counter = 0;
function doSomethingAsync() {
return new Promise<number>((r) => {
setTimeout(() => {
counter += 1;
r(counter);
}, 1000);
});
}
async function* g1() {
yield await doSomethingAsync();
yield await doSomethingAsync();
yield await doSomethingAsync();
}
let i = g1();
i.next().then((n) => console.log(n)); // 1
i.next().then((n) => console.log(n)); // 2
i.next().then((n) => console.log(n)); // 3
如果我们针对 ES5,异步迭代器需要一些额外的类型。您需要在tsconfig.json文件中添加esnext.asynciterable。我们还需要在tsconfig.json中启用一个额外的设置,以提供对可迭代的全面支持(例如,使用for...of控制流语句、扩展运算符或对象解构)当针对 ES3 或 ES5 时:
"lib": [
"es2015.promise",
"dom",
"es5",
"es2015.generator",
"es2015.iterable",
"esnext.asynciterable" // new
]
我们将在第九章自动化你的开发工作流程中了解更多关于lib设置的内容。
异步迭代(for await...of)
我们可以使用新的for...await...of表达式来迭代并等待异步迭代器返回的每个承诺:
async function func() {
for await (const x of g1()) {
console.log(x);
}
}
将迭代委托给另一个生成器(yield*)
我们可以使用yield*表达式将一个生成器委托给另一个生成器。以下代码片段定义了两个名为g1和g2的生成器函数。g2生成器使用yield*表达式将迭代委托给由g1创建的迭代器:
function* g1() {
yield 2;
yield 3;
yield 4;
}
function* g2() {
yield 1;
yield* g1();
yield 5;
}
var iterator1 = g2();
console.log(iterator1.next()); // {value: 1, done: false}
console.log(iterator1.next()); // {value: 2, done: false}
console.log(iterator1.next()); // {value: 3, done: false}
console.log(iterator1.next()); // {value: 4, done: false}
console.log(iterator1.next()); // {value: 5, done: false}
console.log(iterator1.next()); // {value: undefined, done: true}
yield*表达式也可以用来将迭代委托给可迭代的对象,例如数组:
function* g2() {
yield 1;
yield* [2, 3, 4];
yield 5;
}
var iterator = g2();
console.log(iterator.next()); // {value: 1, done: false}
console.log(iterator.next()); // {value: 2, done: false}
console.log(iterator.next()); // {value: 3, done: false}
console.log(iterator.next()); // {value: 4, done: false}
console.log(iterator.next()); // {value: 5, done: false}
console.log(iterator.next()); // {value: undefined, done: true}
摘要
在本章中,我们深入学习了如何使用函数。我们从一个基本概念快速回顾开始,然后转向一些不太为人所知的函数特性和用例。
一旦我们学会了如何使用函数,我们就专注于回调函数、承诺和生成器在 TypeScript 异步编程能力中的应用。
在下一章中,我们将探讨如何使用 TypeScript 编程语言中的类、接口和其他面向对象编程特性。
第四章:使用 TypeScript 进行面向对象编程
在上一章中,我们学习了如何使用函数以及如何利用一些异步编程 API。在本章中,我们将学习如何使用面向对象编程(OOP)范式实现 TypeScript 应用程序。我们将学习以下主题:
-
类
-
关联、聚合和组合
-
继承
-
混合
-
泛型类
-
泛型约束
-
接口
-
SOLID 原则
类
我们应该已经熟悉了 TypeScript 类的基础知识,因为我们已经在之前的章节中声明了一些。现在,我们将通过示例来查看一些细节和 OOP 概念。让我们从一个简单的类开始声明:
class Person {
public name: string;
public surname: string;
public email: string;
public constructor(
name: string, surname: string, email: string
) {
this.email = email;
this.name = name;
this.surname = surname;
}
public greet() {
console.log("Hi!");
}
}
我们使用类来表示对象或实体的类型。一个类由一个名称、属性(也称为属性)和方法组成。方法和属性都称为成员。类属性用于描述对象的特征,而类方法用于描述其行为。
在前面的示例中,该类名为Person,包含三个属性或属性(name、surname和email)和两个方法(constructor和greet)。
构造函数是new关键字用来创建我们类实例(也称为对象)的特殊方法。我们声明了一个名为me的变量,它持有Person类的实例。new关键字使用Person类的构造函数来返回一个类型为Person的对象:
const person = new Person(
"Remo",
"Jansen",
"remo.jansen@wolksoftware.com"
);
严格的属性初始化
自 TypeScript 2.7 版本发布以来,如果启用了严格模式并且我们忘记初始化类的一个属性,将会抛出一个编译时错误。例如,以下类使用方法初始化名为height的属性,使用构造函数初始化名为width的属性。TypeScript 知道如果创建类的实例,width属性将被分配一个值。然而,它没有方法来确保height属性被分配一个值。如果启用了严格模式,将会抛出一个错误:
class Rectangle {
public width: number;
public height: number; // Error
public constructor(width: number) {
this.width = width;
}
public setHeight(height: number) {
this.height = height;
}
}
我们可以使用!运算符让 TypeScript 知道我们不想抛出错误:
class Rectangle {
public width: number;
public height!: number; // OK
public constructor(width: number) {
this.width = width;
}
public setHeight(height: number) {
this.height = height;
}
}
当我们定义一个没有构造函数的类时,遇到这种编译错误是非常常见的:
class Rectangle {
public width: number; // Error
public height: number; // Error
}
我们可以使用!运算符解决我们不希望定义构造函数时的编译时错误:
class Rectangle {
public width!: number; // OK
public height!: number; // OK
}
或者,我们可以使用默认值初始化属性:
class Rectangle6 {
public width: number = 0; // OK
public height: number = 0; // OK
}
继承
最基本的 OOP 特性之一是其扩展现有类的功能。这个特性被称为继承,它允许我们创建一个新的类(子类),该类从现有的类(父类)继承所有属性和方法。子类可以包含父类中不可用的额外属性和方法。
我们将使用在前一节中声明的Person类作为名为Teacher的子类的父类。我们可以通过使用保留关键字extends来扩展父类(Person):
class Teacher extends Person {
public teach() {
console.log("Welcome to class!");
}
}
Teacher类将继承其父类的所有属性和方法。然而,我们还在Teacher类中添加了一个名为teach的新方法。
如果我们创建Person和Teacher类的实例,我们将能够看到这两个实例共享相同的属性和方法,除了teach方法,这个方法仅对Teacher类的实例可用:
const person = new Person(
"Remo",
"Jansen",
"remo.jansen@wolksoftware.com"
);
const teacher = new Teacher(
"Remo",
"Jansen",
"remo.jansen@wolksoftware.com"
);
person.greet(); // "Hi!"
teacher.greet(); // "Hi!"
person.teach(); // Error
teacher.teach(); // "Welcome to class!"
继承树深度(DIT)
我们还可以声明一个新的类,它从已经从另一个类继承的类中继承。在以下代码片段中,我们声明了一个名为SchoolPrincipal的类,它extends了Teacher类,而Teacher类又继承自Person类:
class SchoolPrincipal extends Teacher {
public manageTeachers() {
return console.log(
`We need to help our students!`
);
}
}
如果我们创建SchoolPrincipal类的实例,我们将能够访问其父类(SchoolPrincipal、Teacher和Person)中的所有属性和方法:
const principal = new SchoolPrincipal(
"Remo",
"Jansen",
"remo.jansen@wolksoftware.com"
);
principal.greet(); // "Hi!"
principal.teach(); // "Welcome to class!"
principal.manageTeachers(); // "We need to help our students!"
不建议在继承树中拥有太多层级。位于继承树较深位置的类将相对复杂,难以开发、测试和维护。
不幸的是,当我们不确定是否应该增加继承树深度(DIT)时,我们没有一条具体的规则可以遵循。
我们应该以这种方式使用继承,以帮助我们减少应用程序的复杂性,而不是相反。我们应该尽量保持 DIT 在零到四之间,因为大于四的值将损害封装性并增加复杂性。
访问修饰符
TypeScript 允许我们使用public、private和protected关键字来限制对类属性和方法访问。
公共访问修饰符
如果我们使用public修饰符,方法或属性可以被其他对象访问。以下示例重新声明了我们在前一节中使用的Person和Teacher类。重要的是要注意,在类的所有属性中使用了public访问修饰符,但在这个例子中,我们将特别关注名为email的属性:
class Person {
public name: string;
public surname: string;
public email: string;
public constructor(
name: string, surname: string, email: string
) {
this.email = email;
this.name = name;
this.surname = surname;
}
public greet() {
console.log("Hi!");
}
}
class Teacher extends Person {
public teach() {
console.log("Welcome to class!");
}
}
如果我们创建Person和Teacher类的实例,我们将能够确认email属性可以被这两个实例以及像console对象这样的外部对象访问:
const person = new Person(
"Remo",
"Jansen",
"remo.jansen@wolksoftware.com"
);
const teacher = new Teacher(
"Remo",
"Jansen",
"remo.jansen@wolksoftware.com"
);
console.log(person.email); // ""remo.jansen@wolksoftware.com"
console.log(teacher.email); // ""remo.jansen@wolksoftware.com"
私有访问修饰符
如果我们使用private修饰符,方法或属性只能由拥有它们的对象访问。
以下示例再次重新声明了我们在前一个示例中声明的类,但使用的是private访问修饰符而不是public修饰符。该示例还向类中添加了一些额外的方法,以展示使用private访问修饰符的影响:
class Person {
public name: string;
public surname: string;
private _email: string;
public constructor(
name: string, surname: string, email: string
) {
this._email = email;
this.name = name;
this.surname = surname;
}
public greet() {
console.log("Hi!");
}
public getEmail() {
return this._email;
}
}
class Teacher extends Person {
public teach() {
console.log("Welcome to class!");
}
public shareEmail() {
console.log(`My email is ${this._email}`); // Error
}
}
如果我们创建Person和Teacher类的实例,我们将能够观察到属于Person实例的getEmail方法可以访问private属性。然而,private属性email不能从由派生类Teacher声明的名为shareEmail的方法中访问。此外,其他对象(如console对象)也不能访问private属性。此代码片段确认email属性只能由Person类的实例访问:
const person = new Person(
"Remo",
"Jansen",
"remo.jansen@wolksoftware.com"
);
const teacher = new Teacher(
"Remo",
"Jansen",
"remo.jansen@wolksoftware.com"
);
console.log(person._email); // Error
console.log(teacher._email); // Error
teacher.getEmail();
我们可以将Teacher类更新为使用公共的getEmail方法,而不是直接尝试访问private属性:
class Teacher extends Person {
public teach() {
console.log("Welcome to class!");
}
public shareEmail() {
console.log(`My email is ${this.getEmail()}`); // OK
}
}
protected访问修饰符
如果我们使用protected修饰符,方法或属性只能被拥有它们的对象或派生类的实例访问。
以下示例再次声明了我们在前面示例中声明的类,但使用的是protected访问修饰符而不是public修饰符:
class Person {
public name: string;
public surname: string;
protected _email: string;
public constructor(
name: string, surname: string, email: string
) {
this._email = email;
this.name = name;
this.surname = surname;
}
public greet() {
console.log("Hi!");
}
}
class Teacher extends Person {
public teach() {
console.log("Welcome to class!");
}
public shareEmail() {
console.log(`My email is ${this._email}`);
}
}
如果我们创建Person和Teacher类的实例,我们将能够观察到属于派生类Teacher的名为shareEmail的方法可以访问protected属性email。然而,其他对象(如console对象)不能访问private属性。此代码片段确认email属性只能由Person类或派生类的实例访问,但不能由其他对象访问:
const person = new Person(
"Remo",
"Jansen",
"remo.jansen@wolksoftware.com"
);
const teacher = new Teacher(
"Remo",
"Jansen",
"remo.jansen@wolksoftware.com"
);
console.log(person._email); // Error
console.log(teacher._email); // Error
teacher.shareEmail(); // "My email is remo.jansen@wolksoftware.com"
参数属性
在 TypeScript 中,当我们声明一个类时,我们可以定义其属性,并使用类构造函数初始化一些或所有属性:
class Vector {
private x: number;
private y: number;
public constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}
然而,我们可以使用一种替代语法,它允许我们以更简洁的方式使用类构造函数声明属性并初始化它们。我们只需要移除属性声明及其初始化,并给类构造函数的参数添加访问修饰符。
前面的代码片段声明了一个与以下类具有相同行为的类。然而,它使用了参数属性语法:
class Vector {
public constructor(
private x: number,
private y: number
) {}
}
类表达式
我们可以使用两种不同的 API 来声明一个类。第一个是我们前面部分使用的类声明语法。第二个是称为类表达式的替代语法。
以下代码片段重新声明了我们在前面部分声明的Person类,使用的是类表达式语法:
const Person = class {
public constructor(
public name: string,
public surname: string,
public email: string
) {}
public greet() {
console.log("Hi!");
}
};
使用类表达式语法声明的类实例的创建和使用类声明语法声明的类实例之间没有区别:
const person = new Person(
"Remo",
"Jansen",
"remo.jansen@wolksoftware.com"
);
静态成员
我们可以使用static关键字来启用类中无需创建其实例即可使用的属性和方法:
class TemperatureConverter {
public static CelsiusToFahrenheit(
celsiusTemperature: number
) {
return (celsiusTemperature * 9 / 5) + 32;
}
public static FahrenheitToCelsius(
fahrenheitTemperature: number
) {
return (fahrenheitTemperature - 32) * 5 / 9;
}
}
如前述代码片段所示,TemperatureConverter 类有两个名为 CelsiusToFahrenheit 和 FahrenheitToCelsius 的静态方法。我们可以不创建 TemperatureConverter 类的实例就调用这些方法:
let fahrenheit = 100;
let celsius = TemperatureConverter.FahrenheitToCelsius(fahrenheit);
fahrenheit = TemperatureConverter.CelsiusToFahrenheit(celsius);
当一个方法或属性不是静态的,我们将其称为实例方法或实例属性。可以声明同时具有静态和实例方法或属性的类:
class Vector3 {
public static GetDefault() {
return new Vector3(0, 0, 0);
}
public constructor(
private _x: number,
private _y: number,
private _z: number
) {}
public length() {
return Math.sqrt(
this._x * this._x +
this._y * this._y +
this._z * this._z
);
}
public normalize() {
let len = 1 / this.length();
this._x *= len;
this._y *= len;
this._z *= len;
}
}
前面的类在 3D 空间中声明了一个向量。向量类有实例方法来计算向量的长度并对其进行归一化(不改变其方向的情况下改变其长度为 1)。我们可以使用类构造函数和 new 关键字创建类的实例:
const vector2 = new Vector3(1, 1, 1);
vector2.normalize();
然而,该类还有一个名为 GetDefault 的静态方法,可以用来创建一个默认实例:
const vector1 = Vector3.GetDefault();
vector1.normalize();
可选成员
我们可以通过在属性或方法名称的末尾附加 ? 字符来定义可选类属性和方法。这种行为类似于我们在 第三章 “与函数一起工作” 中学习如何使用 ? 字符在函数中声明可选参数时所观察到的行为。
以下代码片段定义了一个名为 Vector 的类,其中有一个名为 z 的可选属性。当我们使用 x 和 y 属性的数值定义 Vector 实例时,Vector 有两个维度。当我们使用 x、y 和 z 属性的数值定义 Vector 实例时,Vector 有三个维度:
class Vector {
public constructor(
public x: number,
public y: number,
public z?: number
) {}
public is3D() {
return this.z !== undefined;
}
public is2D() {
return this.z === undefined;
}
}
以下代码片段仅使用两个构造函数参数声明了一个 Vector 实例。因此,可选属性 z 将是 undefined:
const vector2D = new Vector(0, 0);
vector2D.is2D(); // true
vector2D.is3D(); // false
const lenght1 = Math.sqrt(
vector2D.x * vector2D.x +
vector2D.y * vector2D.y +
vector2D.z * vector2D.z // Error
);
以下代码片段使用三个构造函数参数声明了一个 Vector 实例。因此,可选属性 z 将被定义:
const vector3D = new Vector(0, 0, 0);
vector3D.is2D(); // false
vector3D.is3D(); // true
const lenght2 = Math.sqrt(
vector3D.x * vector3D.x +
vector3D.y * vector3D.y +
((vector3D.z !== undefined) ? (vector3D.z * vector3D.z) : 0) // OK
);
只读属性
readonly 关键字是一个可以应用于类属性的修饰符。当属性声明包含 readonly 修饰符时,对该属性的赋值只能作为声明的一部分或在同一类中的构造函数中进行。
以下示例展示了如何使用 readonly 修饰符防止对 x、y 和 z 属性进行赋值:
class Vector3 {
public constructor(
public readonly x: number,
public readonly y: number,
public readonly z: number
) {}
public length() {
return Math.sqrt(
this.x * this.x +
this.y * this.y +
this.z * this.z
);
}
public normalize() {
let len = 1 / this.length();
this.x *= len; // Error
this.y *= len; // Error
this.z *= len; // Error
}
}
我们可以通过修改 normalize 方法来修复前面代码片段中的编译错误,使其返回一个新的向量(而不是修改原始向量):
class Vector3 {
public constructor(
public readonly x: number,
public readonly y: number,
public readonly z: number
) {}
public length() {
return Math.sqrt(
this.x * this.x +
this.y * this.y +
this.z * this.z
);
}
public normalize() {
let len = 1 / this.length();
return new Vector3(
this.x * len, // OK
this.y * len, // OK
this.z * len // OK
);
}
}
方法重写
有时,我们需要子类为其父类已提供的特定方法提供特定的实现。我们可以使用保留关键字 super 来实现这个目的。
我们将再次使用本章 继承 部分中声明的 Person 和 Teacher 类。
假设我们想要添加一个新的属性来列出教师的科目,并且我们希望能够通过教师构造函数初始化这个属性。我们将使用super关键字在子类构造函数中显式引用父类构造函数:
class Person {
public constructor(
public name: string,
public surname: string,
public email: string
) {}
public greet() {
console.log("Hi!");
}
}
class Teacher extends Person {
public constructor(
name: string,
surname: string,
email: string,
public subjects: string[]
) {
super(name, surname, email);
this.subjects = subjects;
}
public greet() {
super.greet();
console.log("I teach " + this.subjects.join(" & "));
}
public teach() {
console.log("Welcome to class!");
}
}
我们还使用了super关键字来扩展一个现有方法,例如greet。这种面向对象编程语言特性允许子类或子类提供其父类已经提供的方法的特定实现,这被称为方法重写。
到目前为止,我们可以创建Person和Teacher类的实例来观察它们之间的差异:
const person = new Person(
"Remo",
"Jansen",
"remo.jansen@wolksoftware.com"
);
const teacher = new Teacher(
"Remo",
"Jansen",
"remo.jansen@wolksoftware.com",
["math", "physics"]
);
person.greet(); // "Hi!"
teacher.greet(); // "Hi! I teach math & physics"
person.teach(); // Error
teacher.teach(); // "Welcome to class!"
泛型类
在上一章中,我们学习了如何使用泛型函数。现在,我们将探讨如何使用泛型类。
就像通用函数一样,通用类可以帮助我们避免代码的重复。让我们看看一个例子:
class User {
public name!: string;
public surname!: string;
}
我们已经声明了一个名为User的类,它有两个属性名为name和password。我们现在将声明一个名为UserQueue的类。队列是一种我们可以用来存储项目列表的数据结构。项目可以添加到列表的末尾,并从列表的开头删除。因此,队列被认为是一种先进先出(FIFO)的数据结构。UserQueue类不使用泛型:
class UserQueue {
private _items: User[] = [];
public push(item: User) {
this._items.push(item);
}
public pop() {
return this._items.shift();
}
public size() {
return this._items.length;
}
}
请注意,数组移位方法从数组中删除第一个元素并返回该删除的元素。
一旦我们完成了UserQueue类的声明,我们就可以创建一个实例并调用push和pop方法来分别添加和删除项目:
const userQueue = new UserQueue();
userQueue.push({ name: "Remo", surname: "Jansen" });
userQueue.push({ name: "John", surname: "Smith" });
const remo = userQueue.pop();
const john = userQueue.pop();
如果我们还需要创建一个包含不同类型项目的新的队列,我们可能会重复大量看起来几乎相同的代码:
class Car {
public manufacturer!: string;
public model!: string;
}
class CarQueue {
private _items: Car[] = [];
public push(item: Car) {
this._items.push(item);
}
public pop() {
return this._items.shift();
}
public size() {
return this._items.length;
}
}
const carQueue = new CarQueue();
carQueue.push({ manufacturer: "BMW", model: "M3" });
carQueue.push({ manufacturer: "Tesla", model: "S" });
const bmw = carQueue.pop();
const tesla = carQueue.pop();
如果实体的数量增加,我们将继续重复复制代码。我们可以使用any类型来避免这个问题,但这样我们就会在编译时丢失类型检查:
class Queue {
private _items: any[] = [];
public push(item: any) {
this._items.push(item);
}
public pop() {
return this._items.shift();
}
public size() {
return this._items.length;
}
}
一个更好的解决方案是创建一个泛型队列:
class Queue<T> {
private _items: T[] = [];
public push(item: T) {
this._items.push(item);
}
public pop() {
return this._items.shift();
}
public size() {
return this._items.length;
}
}
泛型队列代码与UserQueue和CarQueue相同,除了items属性的类型。我们用硬编码的User和Car实体替换了它们,并用泛型类型T替换了它们。我们现在可以声明我们可能需要的任何种类的队列,而不需要重复任何一行代码:
class User {
public name!: string;
public surname!: string;
}
const userQueue = new Queue<User>();
userQueue.push({ name: "Remo", surname: "Jansen" });
userQueue.push({ name: "John", surname: "Smith" });
const remo = userQueue.pop();
const john = userQueue.pop();
class Car {
public manufacturer!: string;
public model!: string;
}
const carQueue = new Queue<Car>();
carQueue.push({ manufacturer: "BMW", model: "M3" });
carQueue.push({ manufacturer: "Tesla", model: "S" });
const bmw = carQueue.pop();
const tesla = carQueue.pop();
通用约束
有时,我们可能需要限制通用类的使用。例如,我们可以向通用队列添加一个新功能。这个新功能将在实体被添加到队列之前进行验证。
一个可能的解决方案是使用typeof运算符来识别泛型类或函数中泛型类型参数T的类型:
class User {
public name!: string;
public surname!: string;
}
class Car {
public manufacturer!: string;
public model!: string;
}
class Queue<T> {
private _items: T[] = [];
public push(item: T) {
if (item instanceof User) {
if (
item.name === "" ||
item.surname === ""
) {
throw new Error("Invalid user");
}
}
if (item instanceof Car) {
if (
item.manufacturer === "" ||
item.model === ""
) {
throw new Error("Invalid car");
}
}
this._items.push(item);
}
public pop() {
return this._items.shift();
}
public size() {
return this._items.length;
}
}
const userQueue = new Queue<User>();
userQueue.push({ name: "", surname: "" }); // Runtime Error
userQueue.push({ name: "Remo", surname: "" }); // Runtime Error
userQueue.push({ name: "", surname: "Jansen" }); // Runtime Error
const carQueue = new Queue<Car>();
carQueue.push({ manufacturer: "", model: "" }); // Runtime Error
carQueue.push({ manufacturer: "BMW", model: "" }); // Runtime Error
carQueue.push({ manufacturer: "", model: "M3" }); // Runtime Error
问题在于,我们将不得不修改我们的Queue类,为每种新的实体添加额外的逻辑。我们不会将验证规则添加到Queue类中,因为一个通用类不应该知道用作通用类型的类型。
一个更好的解决方案是为实体添加一个名为 validate 的方法。如果实体无效,该方法将抛出一个异常:
class Queue<T> {
private _items: T[] = [];
public push(item: T) {
item.validate(); // Error
this._items.push(item);
}
public pop() {
return this._items.shift();
}
public size() {
return this._items.length;
}
}
上述代码片段抛出一个编译错误,因为我们可以使用任何类型的通用仓库,但并非所有类型都有一个名为 validate 的方法。幸运的是,这个问题可以通过使用通用约束轻松解决。约束将限制我们可以用作泛型类型参数 T 的类型。我们将声明一个约束,因此只有实现了名为 Validatable 的接口的类型才能与泛型方法一起使用。让我们首先声明 Validatable 接口:
interface Validatable {
validate(): void;
}
现在,我们可以继续实现接口。在这种情况下,我们必须实现 validate 方法:
class User implements Validatable {
public constructor(
public name: string,
public surname: string
) {}
public validate() {
if (
this.name === "" ||
this.surname === ""
) {
throw new Error("Invalid user");
}
}
}
class Car implements Validatable {
public constructor(
public manufacturer: string,
public model: string
) {}
public validate() {
if (
this.manufacturer === "" ||
this.model === ""
) {
throw new Error("Invalid car");
}
}
}
现在,让我们声明一个泛型仓库并添加一个类型约束,以便只接受实现了 Validatable 接口类型的类型:
class Queue<T extends Validatable> {
private _items: T[] = [];
public push(item: T) {
item.validate();
this._items.push(item);
}
public pop() {
return this._items.shift();
}
public size() {
return this._items.length;
}
}
尽管我们已经使用了一个接口,但在前面的例子中,我们使用了 extends 关键字而不是 implements 关键字来声明约束,这并没有特殊的原因。这只是 TypeScript 约束语法的常规方式。
到这一点,我们应该准备好看到新的验证功能在实际中的应用:
const userQueue = new Queue<User>();
userQueue.push(new User("", "")); // Error
userQueue.push(new User("Remo", "")); // Error
userQueue.push(new User("", "Jansen")); // Error
const carQueue = new Queue<Car>();
carQueue.push(new Car("", "")); // Error
carQueue.push(new Car("BMW", "")); // Error
carQueue.push(new Car("", "M3")); // Error
如果我们尝试使用未实现 Validatable 的类作为泛型参数 T,我们将得到一个编译错误。
泛型类型约束中的多个类型
在声明泛型类型约束时,我们只能引用一个类型。让我们想象我们需要一个受约束的泛型类,它只允许扩展以下两个接口的类型:
interface Foo {
doSomething(): void;
}
interface Bar {
doSomethingElse(): void;
}
我们可能会认为我们可以如下定义所需的泛型约束:
class Example1<T extends Foo, Bar> {
private prop!: T;
public doEverything() {
this.prop.doSomething();
this.prop.doSomethingElse(); // error
}
}
然而,这个代码片段将抛出一个编译错误。在声明泛型类型约束时,我们不能指定多个类型。然而,我们可以通过使用 Foo 和 Bar 作为超接口来解决这个问题:
interface FooBar extends Foo, Bar {}
Foo 和 Bar 现在是超接口,因为它们是 FooBar 接口的父接口。然后我们可以使用新的 FooBar 接口声明约束:
class Example2<T extends FooBar> {
private prop!: T;
public doEverything() {
this.prop.doSomething();
this.prop.doSomethingElse();
}
}
泛型类型中的新操作符
在泛型代码块中创建新对象时,我们需要使用类型的构造函数。这意味着我们不能再像这样使用 t: T:
function factory<T>(t: T) {
return new t(); // Error
}
我们应该使用 t: { new(): T;},如下所示:
function factory<T>(t: { new(): T }) {
return new t();
}
class Foo {
public name!: "foo";
}
class Bar {
public name!: "bar";
}
const foo = factory<Foo>(Foo);
const bar = factory<Bar>(Bar);
关联、聚合和组合
在面向对象编程(OOP)中,类可以相互关联。在本节中,我们将讨论类之间三种不同类型的关系。
关联
我们称那些对象具有独立生命周期且没有对象所有权的那些关系为关联。让我们看看教师和学生的例子。多个学生可以与一个教师相关联,一个学生也可以与多个教师相关联,但它们都有独立的生命周期(它们都可以独立创建和删除)。因此,当教师离开学校时,我们不需要删除任何学生,当学生离开学校时,我们也不需要删除任何教师:

聚合
我们称那些对象具有独立生命周期,但存在所有权的那些关系为聚合。以手机和手机电池为例。单个电池可以属于一部手机,但如果手机停止工作,我们从数据库中删除它,手机电池不会删除,因为它可能仍然可以使用。因此,在聚合中,虽然存在所有权,但对象有自己的生命周期:

组合
我们使用术语组合来指代那些对象没有独立生命周期的关系,如果父对象被删除,所有子对象也将被删除。
让我们以问题和答案之间的关系为例。单个问题可以有多个答案,答案不能属于多个问题。如果我们删除问题,答案将自动被删除。
具有依赖生命周期的对象(例如示例中的答案)被称为弱实体。

有时,决定是否使用关联、聚合或组合可能是一个复杂的过程。这种困难部分是由于聚合和组合是关联的子集,这意味着它们是关联的特定情况:

还需要提到的是,一般来说,我们应该尽可能使用组合而非继承。继承将派生类紧密耦合到其相应的基类,这可能会随着时间的推移成为一个可维护性问题。组合可以导致比继承更松散耦合的代码。
混入(多继承)
有时,我们会遇到一些场景,在这些场景中,能够声明一个同时从两个或更多类继承的类(称为多继承)会有用。
我们将创建一个示例来演示多继承是如何工作的。在这个例子中,我们将避免向方法中添加任何代码,因为我们想避免被细节分散注意力。我们应该关注继承树。
我们将首先声明一个名为Animal的类,它只有一个名为eat的方法:
class Animal {
eat() {
// ...
}
}
在声明Animal类之后,我们将声明两个新的类,分别命名为WingedAnimal和Mammal。这两个类都继承自Animal类:
class Mammal extends Animal {
public breath() {
// ...
}
}
class WingedAnimal extends Animal {
public fly() {
// ...
}
}
现在我们有了准备好的类,我们将尝试实现一个名为Bat的类。蝙蝠是哺乳动物,有翅膀。这意味着我们需要创建一个新的类Bat,它将继承自Mammal和WingedAnimal类。我们可能会认为这看起来很合理,然而,如果我们尝试这样做,我们将遇到一个编译错误:
// Error: Classes can only extend a single class.
class Bat extends WingedAnimal, Mammal {
// ...
}
这个错误被抛出是因为 TypeScript 不支持多重继承。这意味着一个类只能继承一个类。大多数面向对象的语言,如 C#或 TypeScript,都不支持多重继承,因为它可能会增加应用程序的复杂性,并导致一个被称为菱形问题的明确问题。
菱形问题
有时,一个类继承图可能呈现出类似菱形的形状(如下面的图所示)。这种类继承图可能会使我们面临一个称为菱形问题的设计问题:

如果允许多重继承,并且我们遇到一个具有菱形形状的继承树,我们在使用仅属于继承树中一个类的独有方法时不会遇到任何问题:
var bat = new Bat();
bat.fly(); // OK
bat.eat();// OK
bat.breath();// OK
当我们尝试调用Bat类的一个父方法时,会出现问题,不清楚或模糊地知道应该调用父类中该方法的哪个实现。例如,如果我们能够在Mammal和WingedAnimal类中添加一个名为move的方法,并尝试从Bat的一个实例中调用它,我们将会得到一个模糊调用错误。
实现混入
既然我们知道多重继承可能存在潜在的危险,我们将介绍一个称为混入的功能。混入是多重继承的一种替代方案,但有一些限制。
我们将重新声明Mammal和WingedAnimal类,以展示如何使用混入:
class Mammal {
public breath(): string {
return "I'm alive!";
}
}
class WingedAnimal {
public fly(): string {
return "I can fly!";
}
}
在前一个示例中展示的两个类与我们之前章节中声明的类并没有太大的区别。我们在breath和fly方法中添加了一些逻辑,以便我们有一些值来帮助我们理解这个演示。同时,值得注意的是,Mammal和WingedAnimal类不再继承自Animal类。
Bat类需要一些重要的添加。我们将使用reserved关键字implements来表示Bat将实现Mammal和WingedAnimal类中声明的功能。我们还将添加Bat类将实现的方法的签名:
class Bat implements Mammal, WingedAnimal {
public eat!: () => string;
public breath!: () => string;
public fly!: () => string;
}
我们需要将以下函数复制到我们的代码中的某个地方,以便能够应用混入:
function applyMixins(derived: any, bases: any[]) {
bases.forEach(base => {
const props = Object.getOwnPropertyNames(base.prototype);
props.forEach(name => {
if (name !== "constructor") {
derived.prototype[name] = base.prototype[name];
}
});
});
}
前面的函数是一个众所周知的模式,可以在许多书籍和在线参考资料中找到,包括官方 TypeScript 手册。如果你现在还没有完全理解它,不要担心,因为它使用了一些概念(例如,迭代器是一种行为设计模式,它
原型(prototypes)等概念,这些内容将在 第六章 理解运行时 中介绍。
这个函数遍历父类(包含在名为 bases 的数组中)的每个属性,并将实现复制到子类(derived)中。我们只需要在我们的整个应用程序中声明这个函数一次。一旦我们完成了它,我们就可以如下使用它:
applyMixins(Bat, [Mammal, WingedAnimal]);
子类(Bat)将包含两个父类(WingedAnimal 和 Mammal)的每个属性和方法:
const bat = new Bat();
bat.breath(); // "I'm alive!"
bat.fly(); // "I can fly!"
如本节开头所述,混入有一些限制:
-
第一个限制是我们只能从继承树中的一级继承属性和方法。这解释了为什么我们在应用混入之前移除了
Animal类。 -
第二个限制是,如果两个或多个父类包含具有相同名称的方法,将要继承的方法将是从传递给
applyMixins函数的bases数组中最后一个类中获取的。
我们现在将看到一个示例,展示这两个限制:
为了展示第一个限制,我们将重新声明 Animal 类:
class Animal {
public eat(): string {
return "I need food!";
}
}
然后,我们将声明 Mammal 和 WingedAnimal 类,但这次,它们将扩展 Animal 类:
class Mammal extends Animal {
public breath() {
return "I'm alive!";
}
public move() {
return "I can move like a mammal!";
}
}
class WingedAnimal extends Animal {
public fly() {
return "I can fly!";
}
public move() {
return "I can move like a bird!";
}
}
然后,我们将再次声明 Bat 类。这个类将实现 Mammal 和 WindgedAnimal 类:
class Bat implements Mammal, WingedAnimal {
public eat!: () => string;
public breath!: () => string;
public fly!: () => string;
public move!: () => string;
}
我们现在准备调用 applyMixins 函数。注意我们如何在数组中先传递 Mammal 再传递 WingedAnimal:
applyMixins(Bat, [Mammal, WingedAnimal]);
我们现在可以创建一个 Bat 的实例,我们将能够观察到由于第一个限制,eat 方法没有从 Animal 类继承:
const bat = new Bat();
bat.eat(); // Error: bat.eat is not a function
父类中的每个方法都无问题地继承了:
bat.breath(); // I'm alive!
bat.fly(); // I can fly!"
move 方法存在问题,因为根据第二个限制,只有传递给 applyMixins 方法的最后一个父类的实现将被实现。在这种情况下,实现是从 WingedAnimal 类继承的:
bat.move(); // I can move like a bird
最后,我们将看到在调用 applyMixins 方法时改变父类顺序的影响。注意我们如何在数组中先传递 WingedAnimal 再传递 Mammal:
applyMixins(Bat2, [WingedAnimal, Mammal]);
const bat = new Bat2();
bat.eat(); // Error: not a function
bat.breathe(); // I'm alive!
bat.fly(); // I can fly!
bat.move() // I can move like a mammal
迭代器
迭代器是一种在面向对象编程中常见的行性行为设计模式。迭代器是一个实现如下接口的对象:
interface Iterator<T> {
next(value?: any): IteratorResult<T>;
return?(value?: any): IteratorResult<T>;
throw?(e?: any): IteratorResult<T>;
}
前面的接口允许我们检索集合中可用的项目。迭代器结果允许我们知道我们是否已到达集合中的最后一个项目,并访问集合中的值:
interface IteratorResult<T> {
done: boolean;
value: T;
}
我们可以通过实现 IterableIterator 接口来创建自定义迭代器。我们需要实现 next 方法以及一个名为 Symbol.iterator 的方法:
class Fib implements IterableIterator<number> {
protected fn1 = 0;
protected fn2 = 1;
public constructor(protected maxValue?: number) {}
public next(): IteratorResult<number> {
let current = this.fn1;
this.fn1 = this.fn2;
this.fn2 = current + this.fn1;
if (this.maxValue && current <= this.maxValue) {
return {
done: false,
value: current
};
} else {
return {
done: true,
value: 0
};
}
}
public [Symbol.iterator](): IterableIterator<number> {
return this;
}
}
我们可以使用方括号来定义属性或方法的名称,使用变量的值作为方法或属性的名称。在这种情况下,Symbol.iterator 被用作方法的名称。Symbol 迭代器包含唯一的字符串 @@iterator。这个名称是一个特殊的方法名称,因为每当一个对象需要被迭代(例如在 for...of 循环的开始时),它的 @@iterator 方法会被调用,并且不带任何参数,返回的迭代器被用来获取要迭代的值。
在声明类之后,我们可以创建实例并迭代它们的值:
let fib = new Fib(5);
fib.next(); // { done: false, value: 0 }
fib.next(); // { done: false, value: 1 }
fib.next(); // { done: false, value: 1 }
fib.next(); // { done: false, value: 2 }
fib.next(); // { done: false, value: 3 }
fib.next(); // { done: false, value: 5 }
前面的可迭代对象永远不会停止返回值,但我们可以声明一个具有固定项目数的实例,并使用 for...of 循环迭代这些项目:
let fibMax21 = new Fib(21);
for (let num of fibMax21) {
console.log(num); // Prints fibonacci sequence 0 to 21
}
注意,如果我们针对 ES5 或 ES3,异步迭代器可能需要一些额外的类型。你需要将 esnext.asynciterable 添加到你的 tsconfig.json 文件中。我们还需要在 tsconfig.json 中启用一个额外的设置,以提供对可迭代对象的全面支持(例如,使用 for...of 控制流语句、扩展运算符或对象解构)当针对 ES3 或 ES5:
"lib": [
"es2015.promise",
"dom",
"es5",
"es5",
"es2015.generator",
"es2015.iterable",
"esnext.asynciterable" // new
]
你可能还需要一个较新的 Node.js 版本,因为前面的示例在旧版本的 Node.js 中将不会工作。我们将在第九章 自动化你的开发工作流程 中学习更多关于 lib 设置的内容。链接。
抽象类
抽象类是基类,可以被其他类扩展。abstract 关键字用于定义抽象类以及抽象类中的抽象方法:
abstract class Department {
constructor(public name: string) {
}
public printName(): void {
console.log("Department name: " + this.name);
}
public abstract printMeeting(): void; // must be implemented in derived classes
}
在抽象类中,由 abstract 关键字引导的方法不能包含实现,必须由派生类实现。
抽象方法可能看起来像接口方法。然而,一个抽象类可能包含其成员的一些实现细节:
class AccountingDepartment extends Department {
public constructor() {
super("Accounting and Auditing"); // constructors in derived classes must call super()
}
public printMeeting(): void {
console.log("The Accounting Department meets each Monday at 10 am.");
}
public generateReports(): void {
console.log("Generating accounting reports...");
}
}
无法创建抽象类的实例:
// OK: Create a reference to an abstract type
let department: Department;
// Error: cannot create an instance of an abstract class
department = new Department();
// OK: Create and assign a non-abstract subclass
department = new AccountingDepartment();
department.printName();
department.printMeeting();
// Error: Method doesn't exist on declared abstract type
department.generateReports();
接口
如果你具有 Java 或 C# 等面向对象静态类型编程语言的背景,那么在用 JavaScript 开发大型 Web 应用程序时,接口可能是你最怀念的特性。
传统上,在面向对象编程(OOP)中,我们说一个类只能扩展一个类并实现一个或多个接口。一个接口可以实现一个或多个接口,但不能扩展另一个类或接口。维基百科对面向对象编程中接口的定义如下:
“在面向对象的语言中,术语接口通常用来定义一个不包含数据或代码的抽象类型,但定义行为作为方法签名。”
在 TypeScript 中,接口并不严格遵循这个定义。TypeScript 中的两个主要区别是:
-
接口可以扩展一个类
-
接口可以定义数据和行为,而不仅仅是行为
例如,我们可以定义一个名为 Weapon 的接口:
interface Weapon {
tryHit(fromDistance: number): boolean;
}
Weapon 接口定义了所有武器共享的行为(武器可以用来尝试击中敌人,但每种武器都有不同的射程)。然而,接口并没有定义其实现细节(每种武器的具体射程)。
实现接口可以理解为签订一份合同。接口是一份合同,当我们签订它(实现它)时,我们必须遵循其规则。接口规则是方法的签名和属性,我们必须实现它们:
class Katana implements Weapon {
public tryHit(fromDistance: number) {
return fromDistance <= 2;
}
}
class Shuriken implements Weapon {
public tryHit(fromDistance: number) {
return fromDistance <= 15;
}
}
前两个类实现了由 Weapon 接口定义的方法。这两个类共享相同的公共 API,但具有不同的内部实现。我们将在本章的其余部分看到更多接口的例子。
SOLID 原则、封装和多态
在软件开发初期,开发者通常使用过程式编程语言编写代码。在过程式编程语言中,程序遵循自顶向下的方法,逻辑被封装在函数中。
当开发者意识到过程式计算机程序无法提供他们所需的抽象级别、可维护性和可重用性时,出现了新的计算机编程风格,如模块化编程或结构化编程。
开发社区创建了一系列推荐的最佳实践和设计模式,以提高过程式编程语言的抽象级别和可重用性,但其中一些指南需要一定的专业知识。为了便于遵循这些指南,创建了一种新的计算机编程风格,称为面向对象编程(OOP)。
开发者很快注意到一些常见的面向对象错误,并提出了五条每名面向对象开发者都应该遵循的规则,以创建一个易于维护和随时间扩展的系统。这五条规则被称为 SOLID 原则。SOLID 是由 Michael Feathers 提出的一个缩写,每个字母代表以下原则之一:
-
单一职责原则(SRP):该原则指出,软件组件(函数、类或模块)应专注于一个独特的任务(只有一个职责)。
-
开闭原则(OCP):这个原则指出,软件实体应该考虑到应用的增长(新代码)(易于扩展),但应用的增长应尽可能少地改变现有代码(对修改封闭)。
-
里氏替换原则(LSP):这个原则指出,如果两个类都实现了相同的接口,我们应该能够用一个类替换程序中的另一个类。在替换类之后,不应需要其他更改,程序应继续按原样工作。
-
接口隔离原则(ISP):这个原则指出,我们应该将非常大的接口(通用接口)拆分成更小、更具体的接口(许多客户端特定的接口),这样客户端就只需要了解对他们感兴趣的方法。
-
依赖倒置原则(DIP):这个原则指出,实体应该依赖于抽象(接口),而不是依赖于具体(类)。
在本章中,我们将学习如何编写遵循这些原则的 TypeScript 代码,以便我们的应用程序易于维护和随时间扩展。
SOLID – 单一职责原则
我们的所有类都应该遵循单一职责原则(SRP)。在本章的第一个示例中声明的Person类代表一个人,包括他们的所有特征(属性)和行为(方法)。我们将通过添加一个email作为验证逻辑来修改前面的类:
class Person {
public name: string;
public surname: string;
public email: string;
public constructor(
name: string, surname: string, email: string
) {
this.surname = surname;
this.name = name;
if (this.validateEmail(email)) {
this.email = email;
} else {
throw new Error("Invalid email!");
}
}
public validateEmail(email: string) {
const re = /S+@S+.S+/;
return re.test(email);
}
public greet() {
console.log(
`Hi! I'm ${this.name},
you can reach me at ${this.email}`
);
}
}
当一个对象不遵循 SRP(单一职责原则),知道太多(有太多属性)或做得太多(有太多方法)时,我们称这个对象为God对象。这里的Person类就是一个God对象,因为我们添加了一个名为validateEmail的方法,这个方法与Person类的行为无关。
决定哪些属性和方法应该或不应该成为类的一部分是一个相对主观的决定。如果我们花些时间分析我们的选项,我们应该能够识别出改进我们类的设计的方法。
我们可以通过声明一个负责电子邮件验证的Email类来重构Person类,并将其作为Person类的一个属性使用:
class Email {
public static validateEmail(email: string) {
const re = /S+@S+.S+/;
return re.test(email);
}
}
现在我们有了Email类,我们可以从Person类中移除验证电子邮件的责任,并更新其email属性以使用Email类型而不是string:
class Person {
public name: string;
public surname: string;
public email: string;
public constructor(
name: string, surname: string, email: string
) {
if (Email.validateEmail(email) === false) {
throw new Error("Invalid email!");
}
this.email = email;
this.name = name;
this.surname = surname;
}
public greet() {
console.log(
`Hi! I'm ${this.name},
you can reach me at ${this.email.toString()}`
);
}
}
确保一个类有单一职责使其更容易看到它做什么以及我们如何扩展/改进它。
封装
我们可以通过提高类的抽象级别来进一步改进上一节中声明的 Person 和 Email 类。例如,当我们使用 Email 类时,我们不需要意识到 validateEmail 方法的存在;这个方法可以从 Email 类的外部不可见。因此,Email 类将更容易理解。
当我们提高对象的抽象级别时,我们可以说是我们正在封装一些逻辑。封装也被称为信息隐藏。例如,Email 类允许我们使用电子邮件而无需担心电子邮件验证,因为类会为我们处理它。我们可以通过使用访问修饰符(public 或 private)来标记所有我们希望从 Email 类的使用中抽象出来的类属性和方法,使这一点更加清晰:
class Email {
private _email: string;
public constructor(email: string) {
if (this._validateEmail(email)) {
this._email = email;
} else {
throw new Error("Invalid email!");
}
}
public toString(): string {
return this._email;
}
private _validateEmail(email: string) {
const re = /S+@S+.S+/;
return re.test(email);
}
}
class Person {
public name: string;
public surname: string;
public email: Email;
public constructor(
name: string, surname: string, email: Email
) {
this.email = email;
this.name = name;
this.surname = surname;
}
public greet() {
console.log(
`Hi! I'm ${this.name},
you can reach me at ${this.email.toString()}`
);
}
}
然后,我们可以简单地使用 Email 类,而无需显式执行任何类型的验证:
let person: Person = new Person(
"Remo",
"Jansen",
new Email("remo.jansen@wolksoftware.com")
);
SOLID – 开闭原则
开闭原则(OCP)建议我们以使我们在未来能够扩展它们的行为(对扩展开放)而不修改它们当前的行为(对修改封闭)的方式设计我们的类和方法。
以下代码片段不太好,因为它没有遵循开闭原则:
class Rectangle {
public width!: number;
public height!: number;
}
class AreaCalculator {
public area(shapes: Rectangle[] ) {
return shapes.reduce(
(p, c) => {
return p + (c.height * c.width);
},
0
);
}
}
之前的代码没有遵循开闭原则,因为如果我们需要扩展我们的程序以支持圆形,我们将需要修改现有的 AreaCalculator 类:
class Circle {
public radius!: number;
}
class AreaCalculator {
public area(shapes: Array<Rectangle|Circle>) {
return shapes.reduce(
(p, c) => {
if (c instanceof Rectangle) {
return p + (c.width * c.height);
} else {
return p + (c.radius * c.radius * Math.PI);
}
},
0
);
}
}
一个更好的解决方案是将面积计算作为形状的方法添加,这样当我们添加一个新的形状(扩展)时,我们不需要更改现有的 AreaCalculator 类(修改):
abstract class Shape {
public abstract area(): number;
}
class Rectangle extends Shape {
public width!: number;
public height!: number;
public area() {
return this.width * this.height;
}
}
class Circle implements Shape {
public radius!: number;
public area() {
return (this.radius * this.radius * Math.PI);
}
}
class AreaCalculator {
public area(shapes: Shape[]) {
return shapes.reduce(
(p, c) => p + c.area(),
0
);
}
}
第二种方法遵循第二个 SOLID 原则,即开闭原则,因为我们可以创建新的实体,通用的仓库将继续工作(对扩展开放),但不需要对其进行任何额外的更改(对修改封闭)。
多态性
多态性是呈现不同底层形式(数据类型)相同接口的能力。多态性通常被称为面向对象编程的第三根支柱,在封装和继承之后。
多态性使我们能够在前一节中实现 LSP:
class AreaCalculator {
public area(shapes: Shape[]) {
return shapes.reduce(
(p, c) => p + c.area(),
0
);
}
}
派生类的对象(Circle 和 Rectangle)可以在方法参数(如 area 方法)等地方被视为基类(Shape)的对象。基类可以定义和实现抽象方法,派生类可以覆盖它们,这意味着它们提供了它们的定义和实现。
SOLID – 李斯克夫替换原则
李斯克夫替换原则(LSP)指出,子类型必须可替换为其基类型。让我们通过一个例子来理解这意味着什么。
我们将声明一个名为 PersistanceService 的类,其责任是将某些对象持久化到某种存储中。我们将从声明以下接口开始:
interface PersistanceServiceInterface {
save(value: string): string;
}
在声明 PersistanceServiceInterface 接口之后,我们可以实现它。我们将使用 cookie 作为应用程序数据的存储:
function getUniqueId() {
return Math.random().toString(36).substr(2, 9);
}
class CookiePersitanceService implements PersistanceServiceInterface {
public save(value: string): string {
let id = getUniqueId();
document.cookie = `${id}=${value}`;
return id;
}
}
我们将继续通过声明一个名为 FavouritesController 的类,该类依赖于 PersistanceServiceInterface 来继续操作:
class FavouritesController {
private _persistanceService: PersistanceServiceInterface;
public constructor(persistanceService: PersistanceServiceInterface) {
this._persistanceService = persistanceService;
}
public saveAsFavourite(articleId: string) {
return this._persistanceService.save(articleId);
}
}
我们最终可以创建一个 FavouritesController 的实例,并通过其构造函数传递一个 CookiePersitanceService 的实例:
const favController1 = new FavouritesController(
new CookiePersitanceService()
);
LSP 允许我们在两个基于相同基类型的实现之间替换依赖项;因此,如果我们决定停止使用 cookie 作为存储并改用 HTML5 本地存储 API,我们可以声明一个新的实现:
class LocalStoragePersitanceService implements PersistanceServiceInterface {
public save(value: string): string {
const id = getUniqueId();
localStorage.setItem(`${id}`, value);
return id;
}
}
然后,我们可以替换它,而无需对 FavouritesController 控制器类进行任何更改:
const favController = new FavouritesController(
new LocalStoragePersitanceService()
);
SOLID – 接口隔离原则
接口用于声明两个或多个软件组件如何合作以及如何相互交换信息。这种声明被称为 应用程序编程接口(API)。在先前的例子中,我们的接口是 PersistanceServiceInterface,它由 LocalStoragePersitanceService 和 CookiePersitanceService 类实现。该接口被 FavouritesController 类消费,因此我们说这个类是 PersistanceServiceInterface API 的客户端。
接口隔离原则(ISP)指出,没有任何客户端应该被迫依赖于它不使用的方法。为了遵循 ISP,我们需要记住,当我们声明应用程序组件的 API(两个或多个软件组件如何合作以及如何相互交换信息)时,声明许多特定于客户端的接口比声明一个通用接口更好。让我们看看一个例子。
如果我们设计一个 API 来控制车辆中的所有元素(引擎、收音机、加热、导航和灯光),我们可以有一个通用接口,它允许我们控制车辆的每一个单独元素:
interface VehicleInterface {
getSpeed(): number;
getVehicleType(): string;
isTaxPayed(): boolean;
isLightsOn(): boolean;
isLightsOff(): boolean;
startEngine(): void;
accelerate(): number;
stopEngine(): void;
startRadio(): void;
playCd(): void;
stopRadio(): void;
}
如果一个类在 VehicleInterface 接口中有一个依赖(客户端),但它只想使用收音机方法,我们将面临 ISP 的违反,因为我们已经看到,客户端不应该被迫依赖于它不使用的方法。
解决方案是将 VehicleInterface 接口拆分为许多特定于客户端的接口,这样我们的类就可以通过仅依赖于 RadioInterface 接口来遵循 ISP:
interface VehicleInterface {
getSpeed(): number;
getVehicleType(): string;
isTaxPayed(): boolean;
isLightsOn(): boolean;
}
interface LightsInterface {
isLightsOn(): boolean;
isLightsOff(): boolean;
}
interface RadioInterface {
startRadio(): void;
playCd(): void;
stopRadio(): void;
}
interface EngineInterface {
startEngine(): void;
accelerate(): number;
stopEngine(): void;
}
SOLID – 依赖倒置原则
依赖倒置(DI)原则指出,依赖于抽象。不要依赖于具体实现。在 LSP 中,我们实现了一个名为FavouritesController的类。在示例中,我们可以替换PersistanceServiceInterface的实现,而无需对FavouritesController进行任何额外的修改。
我们遵循了 DI 原则,因为FavouritesController依赖于PersistanceServiceInterface(抽象):

上述内容可以按照以下方式实现:
class FavouritesController {
private _persistanceService: PersistanceServiceInterface;
public constructor(persistanceService: PersistanceServiceInterface) {
this._persistanceService = persistanceService;
}
public saveAsFavourite(articleId: string) {
return this._persistanceService.save(articleId);
}
}
而不是让FavouritesController直接依赖于LocalStoragePersitanceService或CookiePersitanceService(具体实现):

上述内容可以按照以下方式实现:
class FavouritesController {
private _persistanceService: CookiePersitanceService;
public constructor(persistanceService: CookiePersitanceService) {
this._persistanceService = persistanceService;
}
public saveAsFavourite(articleId: string) {
return this._persistanceService.save(articleId);
}
}
如果我们比较这两个图,我们会注意到,由于引入了接口(抽象),连接依赖和依赖关系的方向箭头已经被反转。这应该有助于我们理解为什么这个原则被称为依赖倒置原则。
摘要
在本章中,我们学习了如何深入地处理类和接口。通过使用封装和依赖倒置等技术,我们使我们的应用程序更加易于维护。在下一章中,我们将学习如何处理依赖关系。
第五章:处理依赖
在上一章中,我们学习了如何使用类和接口,还了解了 SOLID 原则和其他面向对象编程的最佳实践。在本章中,我们将学习如何处理依赖关系。本章的第一部分将专注于模块的使用。第二部分将专注于面向对象编程中依赖关系的管理。
我们将涵盖以下主题:
-
第三方依赖
-
内部模块
-
外部模块
-
异步模块定义 (AMD)
-
CommonJS 模块
-
ES6 模块
-
Browserify 和通用模块定义 (UMD)
-
循环依赖
-
依赖注入
第三方依赖
第三方依赖通常是第三方组织或独立软件工程师创建的开源库。第三方依赖是外部模块,可以使用其名称而不是相对或绝对路径来导入。
包管理工具
包管理工具用于依赖管理,这样我们就不必手动下载和管理我们的应用程序依赖项。
包管理工具的兴衰
在过去几年中,TypeScript 生态系统经历了许多包管理器的兴衰。这在 TypeScript 的早期阶段造成了一些混乱,但幸运的是,今天我们有一个更加稳定的生态系统。
在 TypeScript 历史中一些值得注意的包管理器包括 tsd、typings、npm、bower、yarn 和 turbo。一些包管理器,如 tsd、typings 和 bower,现在不再推荐使用,而其他如 yarn 或 turbo 则相对较新,并且不如 npm 广泛采用。撰写本文时推荐的包管理器是 npm。
npm
Node 包管理器(npm)最初是作为 Node.js 的默认包管理工具开发的,但如今它被许多其他工具使用。
npm 使用一个名为 package.json 的配置文件来存储我们项目中安装的所有依赖项的引用。最初,npm 通常仅用于安装后端依赖项,但如今它用于安装任何依赖项。这包括以下内容:
-
后端依赖
-
前端依赖
-
开发工具
-
TypeScript 类型定义
在安装任何包之前,我们应该在我们的项目中添加一个 package.json 文件。我们可以通过执行以下命令来完成此操作:
npm init
请注意,我们必须安装 Node.js 才能使用 npm 命令。
npm init 命令将要求我们提供有关项目的一些基本信息,包括其名称、版本、描述、入口点、测试命令、Git 仓库、关键词、作者和许可证。
如果您对之前提到的某些 package.json 字段的目的不确定,请参阅官方 npm 文档:docs.npmjs.com/files/package.json。
npm命令将显示即将生成的package.json文件的预览,并要求我们进行最终确认。
如果你希望跳过问题并使用默认设置生成文件,你可以使用带有--yes标志的npm init命令:
npm init --yes
在创建项目的package.json文件后,我们将使用npm install命令安装我们的第一个依赖项。
npm install命令将一个或多个依赖项的名称作为参数,这些依赖项由单个空格分隔,并指定安装范围。
范围可以是以下几种:
-
全局依赖项
-
开发时依赖项(例如,测试框架、编译器等)
-
运行时依赖项(例如,Web 框架、数据库 ORM 等)
我们将使用tslint npm 包来检查我们的 TypeScript 代码的风格,所以让我们将其作为开发依赖项安装(使用--save-dev参数):
npm install tslint --save-dev
要安装全局依赖项,我们将使用-g参数:
npm install webpack-dev-server -g
我们可能需要在我们的开发环境中具有管理员权限来安装全局范围的包。
还要注意,当使用全局范围的包安装包时,npm 不会向我们的package.json添加任何条目,但手动将正确的依赖项添加到package.json中的devDependencies部分是很重要的,以确保持续集成构建服务器能够正确解析我们项目的所有依赖项。
要安装运行时依赖项,请使用--save参数:
npm install react --save
请注意,react是一个可以用来创建用户界面的模块。
一旦我们在package.json文件中安装了一些依赖项,内容应该看起来像以下这样:
{
"name": "repository-name",
"version": "1.0.0",
"description": "example",
"main": "index.html",
"scripts": {
"test": "echo "Error: no test specified" && exit 1"
},
"repository": {
"type": "git",
"url": "https://github.com/username/repository-name.git"
},
"keywords": [
"typescript",
"demo",
"example"
],
"author": "Name Surname",
"contributors": [],
"license": "MIT",
"bugs": {
"url": "https://github.com/username/repository-name/issues"
},
"homepage": "https://github.com/username/repository-name",
"engines": {},
"dependencies": {
"react": "16.2.0"
},
"devDependencies": {
"tslint": "5.9.1"
}
}
package.json文件中的一些字段必须手动配置。要了解更多关于可用的package.json配置字段的信息,请访问docs.npmjs.com/files/package.json。
本书所使用的 npm 包版本可能自出版以来已经更新。请参阅npmjs.com上的包文档,以了解潜在的不兼容性和了解新功能。
如果你正在使用 npm 的现代版本之一,安装模块还会生成一个名为package-lock.json的文件。此文件描述了生成的确切依赖项树,以便后续安装可以生成相同的树,无论中间的依赖项更新如何。
所有的 npm 包都将保存在node_modules目录中。建议不要将node_modules目录放入源代码控制(例如,Git 仓库)中。
下次我们设置开发环境时,我们需要再次下载所有依赖项,但要做到这一点,我们只需要执行npm install命令,无需任何附加参数:
npm install
包管理器(npm)将随后搜索 package.json 文件并安装其中列出的所有依赖项。
我们可以使用 www.npmjs.com 上的 npm 搜索引擎来查找可能对我们应用程序有用的潜在模块。
我们可以使用 npm outdated 命令检查我们项目的依赖项是否过时。
我们已经学习了如何使用 npm 来管理项目的依赖项。然而,npm 不仅仅是一个包管理器,因为它还允许我们创建命令来执行一些自定义自动化任务,例如发布我们应用程序的版本或运行一些自动化测试。我们将在第九章 自动化您的开发工作流程中了解更多关于此功能的内容。
类型定义
TypeScript 对现有 JavaScript 库的支持是通过声明库的公共接口或 API 来实现的。第三方模块公共接口的声明被称为 类型定义。
当我们安装一个 npm 模块时,我们可能会遇到几种不同的场景。
具有对 TypeScript 本地支持的模块
一些第三方依赖项具有内置对 TypeScript 的支持。例如,具有 TypeScript 本地支持的模块示例是 InversifyJS 模块。在这种情况下,仅安装 npm 模块就足够了,因为该模块包括所需的类型定义:
npm install inversify --save
具有对 TypeScript 外部支持的模块
一些第三方依赖项没有内置对 TypeScript 的支持,但类型定义可以在单独的 npm 模块中找到。一个具有对 TypeScript 外部支持的模块示例是 react 模块。在这种情况下,仅安装 npm 模块是不够的,因为它不包括所需的类型定义。我们可以通过安装包含缺失类型定义的 npm 模块来解决这个问题:
npm install react --save
npm install @types/react --save-dev
TypeScript 团队开发了一个自动化流程,该流程在 npm 公共注册表下以一个独特的组织名称发布所有可用的开源类型定义。该组织名为 @types,类型定义使用它们所定义的模块名称。
没有对 TypeScript 提供支持的模块
一些第三方依赖项没有内置对 TypeScript 的支持,并且类型定义不在单独的 npm 中提供。在这种情况下,仅安装 npm 模块是不够的,因为它不包括所需的类型定义。我们可以通过创建我们的类型定义来解决这个问题。
不幸的是,创建我们的类型定义的过程不是可以系统描述的,需要一些经验,但我们将尝试解释围绕它的主要复杂性。
让我们假设我们需要为 react-side-effect npm 模块编写自定义类型定义,尽管实际上并不需要,因为类型定义已经可用,但我们将用它作为示例。
我们需要做的第一件事是安装缺少类型定义的包:
npm install react-side-effect --save
然后,我们需要打开 react-side-effect 模块内部的 package.json 文件。每个 npm 模块都包含一个 package.json 文件,因此它应该位于以下路径:
/node_modules/react-side-effect/package.json
如果我们检查 package.json 文件,我们应该能够找到 main 字段。main 字段描述了 npm 模块的入口点。react-side-effect npm 模块的 package.json 中的 main 字段如下所示:
"main": "lib/index.js"
我们需要打开那个文件,找出模块导出了哪些元素以及它们是如何导出的。这是复杂的一部分:我们需要在源代码中导航并识别导出的元素和导出类型。lib/index.js 文件只导出了一个名为 withSideEffect 的函数:
module.exports = function withSideEffect // ...
在这一点上,我们可以创建一个名为 external.d.ts 的文件,并添加以下类型定义:
declare module "react-side-effect" {
declare const withSideEffect: any;
export = withSideEffect;
}
请注意,我们使用了以下方式:
export = withSideEffect;
我们使用这种类型的模块导出,因为这是我们可以在库的源代码中看到的导出类型。查看详情
有时我们会看到类似以下的模块导出:
export default withSideEffect;
或者像以下这样的:
export { withSideEffect };
我们需要确保我们的类型定义文件使用与库中相同的导出类型。我们将在本章的后面部分了解更多关于不同类型导出的内容。
前面的代码片段声明了一个名为 react-side-effect 的模块。该模块导出一个名为 withSideEffect 的实体,其类型为 any。前面的类型定义应该足够让我们能够导入该模块:
import * as withSideEffect from "react-side-effect";
但如果我们这样做,我们会得到一个错误:
Module '"react-side-effect"' resolves to a non-module entity and cannot be imported using this construct.
不幸的是,修复这个问题的唯一方法是通过添加一个额外的命名空间,正如在github.com/Microsoft/TypeScript/issues/5073中描述的那样:
declare module "react-side-effect" {
declare const withSideEffect: any;
namespace withSideEffect {};
export = withSideEffect;
}
在这一点上,我们可以导入模块而不会出错,但 withSideEffect 的类型是 any。我们可以通过检查源代码并花一些时间来尝试确定函数的签名来解决此问题。如果我们这样做,我们最终会得到以下类似的内容:
declare module "react-side-effect" {
import React = __React;
function withSideEffect(
reducePropsToState: (propsList: any[]) => any,
handleStateChangeOnClient: (state: any) => any,
mapStateOnServer?: (state: any) => any
): ClassDecorator;
class ElementClass extends React.Component<any, any> {}
interface ClassDecorator {
<T extends (typeof ElementClass)>(component:T): T;
}
namespace withSideEffect {};
export = withSideEffect;
}
请注意,强烈建议您在github.com/DefinitelyTyped/DefinitelyTyped上与 TypeScript 社区分享您的类型定义。
ECMAScript 规范类型定义(lib.d.ts)
TypeScript 编译器会自动包含我们针对的 ECMAScript 版本的类型定义。例如,如果我们针对 ES5,我们将无法访问 Promise API,因为它属于 ES6 规范的一部分:
const p = Promise.resolve(1); // Error
然而,TypeScript 允许我们导入一个提案的类型定义,而不是整个 ECMAScript 规范。例如,我们可以针对 ES5 并通过向我们的 tsconfig.json 文件中添加以下内容来使用 Promise API:
"lib": ["es5", "dom", "es2015.promise"]
前面的设置是在指示 TypeScript 编译器,我们希望它导入整个 ECMAScript 5 规范、文档对象模型(DOM)和 Promise API 的类型定义。
外部 TypeScript 辅助函数(tslib)
如我们所知,TypeScript 允许我们使用即将到来的 ECMAScript 规范的一些特性。TypeScript 使用一系列辅助函数在运行时实现这些特性中的某些功能。以下是由 TypeScript 生成的某些辅助函数:
-
__extends用于继承 -
__assign用于对象展开属性 -
__rest用于对象剩余属性 -
__decorate、__param和__metadata用于装饰器 -
__awaiter和__generator用于async/await
如果需要这些辅助函数之一,TypeScript 将在编译时生成它。然而,这可能会成为一个问题,因为辅助函数会在所有需要它的文件中生成,这可能导致大量代码重复。
我们可以使用以下编译设置来解决这个问题:
-
使用
noEmitHelpers标志可以防止 TypeScript 生成辅助函数 -
使用
importHelpers标志将生成从tslibnpm 模块导入辅助函数所需的代码,而不是生成辅助函数。
tslib 模块包含了所有必需的 TypeScript 辅助函数的声明。我们可以使用 npm 安装 tslib 模块,如下所示:
npm install tslib --save
这样,辅助函数只声明一次(由 tslib 模块声明)。
内部模块(模块和命名空间)
我们可以使用 module 和 namespace 关键字来定义内部模块。TypeScript 最初允许我们使用 module 关键字来声明内部模块,但后来为了 namespace 关键字而弃用了它。
内部模块(namespaces)可以用来封装应用程序的某些元素,并为我们的代码提供更好的组织。然而,我们应该尽量避免使用它们,并优先考虑外部模块而不是命名空间。外部模块应该是我们的首选选项,因为某些工具需要它们来优化我们应用程序的某些方面。外部模块将在本章的后面部分详细讨论。
我们可以使用命名空间来分组接口、类和对象,它们在某种程度上是相关的。例如,我们可以在名为 Models 的内部模块中包装我们所有的应用程序模型:
namespace Models {
export class UserModel {
// ...
}
}
默认情况下,namespace 包含的所有实体都是私有的。我们可以使用 export 关键字来声明我们希望公开的 namespace 的哪些部分。
嵌套内部模块
我们可以这样嵌套一个 namespace:
namespace App {
export namespace Models {
export class UserModel {
// ...
}
export class TalkModel {
// ...
}
}
}
在前面的例子中,我们声明了一个名为App的命名空间,并在其中声明了一个名为Models的公共命名空间,它包含两个公共类:UserModel和TalkModel。
然后,我们可以通过指定完整的命名空间名称来访问命名空间实体:
const user = new App.Models.UserModel();
const talk = new App.Models.TalkModel();
跨文件内部模块
TypeScript 允许我们在多个文件中声明内部模块。如果一个内部模块变得太大,它可以被分成多个文件以提高其可维护性。如果我们以先前的例子为例,我们可以在一个新文件中通过引用它来向名为App的内部模块添加更多内容。
让我们创建一个新的文件,命名为validation.ts,并将以下代码添加到其中:
namespace App {
export namespace Validation {
export class UserValidator {
// ...
}
export class TalkValidator {
// ...
}
}
}
然后,我们可以通过指定完整的命名空间名称来访问两个文件中声明的命名空间实体:
const userModel = new App.Models.UserModel();
const talkModel = new App.Models.TalkModel();
const userValidator = new App.Validation.UserValidator();
const talkValidator = new App.Validation.TalkValidator();
尽管命名空间Models和Validation在不同的文件中声明,我们仍然可以通过App命名空间来访问它们。
命名空间名称可以包含点号。例如,我们可以在validation和models内部模块名称中使用点号,而不是在app模块内部嵌套内部模块(验证和模型):
namespace App.Validation {
// ...
}
namespace App.Models {
// ...
}
内部模块别名
在内部模块中可以使用import关键字为另一个模块提供一个别名,如下所示:
import TalkValidator = app.validation.TalkValidator;
const talkValidator2 = new TalkValidator();
编译内部模块
一旦我们完成了内部模块的声明,我们就可以决定是否要将每个模块编译成 JavaScript,或者我们是否更倾向于将所有文件连接成一个单一的文件。
我们可以使用--out标志将所有输入文件编译成一个单一的 JavaScript 输出文件:
tsc --out output.js input.ts
编译器将自动根据文件中存在的引用标签对输出文件进行排序。然后,我们可以使用 HTML <script>标签导入我们的文件或文件。
外部模块
TypeScript 也有外部模块的概念。在 ECMAScript 6(ES6)之前的 JavaScript 版本中,没有对外部模块的原生支持。开发者被迫开发自定义模块加载器,开源社区在多年中尝试提出改进的解决方案。因此,今天有几种类型的模块加载器,每种至少支持一种模块定义语法。
通常,当我们提到一个模块时,如果没有明确指出是内部模块还是外部模块,我们可以假设他们指的是外部模块。
模块加载器和模块定义语法
使用模块(而不是命名空间或内部模块)与使用命名空间或内部模块的主要区别是,在声明了所有模块之后,我们不会使用 HTML <script>标签来导入它们。使用<script>标签是不推荐的,因为当网络浏览器遇到并加载<script>标签的内容时,它们会“暂停”(或“冻结”)页面的同时下载和渲染。
我们将在第十三章应用性能中了解更多关于网络性能的内容。
外部模块避免使用 <script> 加载我们的应用程序模块,而是使用模块加载器。模块加载器是一种工具,它允许我们更好地控制模块加载过程。这使我们能够执行异步加载文件或合并多个模块到一个高度优化的单个文件等任务。
由于 JavaScript 中外部模块缺乏原生支持,多年来不同的开源项目提出了多种模块定义语法:
| 模块定义语法 | 注意事项 |
|---|---|
| AMD | 由 RequireJS 模块加载器引入 |
| CommonJS | 由 Node.js 引入 |
| UMD | 支持 AMD 和 UMD |
| SystemJS | 由 Angular 2 引入,支持多种语法 |
| ES6 模块 | 由 ECMAScript 6 规范引入 |
我们还可能遇到各种各样的模块加载器:
| 模块加载器 | 模块定义语法 | 注意事项 |
|---|---|---|
| RequireJS | AMD | RequireJS 是 JavaScript 应用程序的第一个主流模块加载器 |
| Browserify | CommonJS | CommonJS 模块最初由 Node.js 的原始模块系统引入,但如今 Node.js 原生支持 ES6 模块 |
| SystemJS | 支持多种模块定义语法 | 支持多种模块定义语法 |
| 本地 | ES6 | 现代 JavaScript 引擎可以原生支持 ES6 模块 |
设计时和运行时的外部模块
TypeScript 增加了一层选择,因为它允许我们在设计时选择我们想要使用的模块定义语法,以及我们想要在运行时使用的语法。在如此多的选项中,很容易感到不知所措和困惑。
幸运的是,在 TypeScript 中,设计时只有两种模块定义语法可用,其中一种被认为是过时的:
-
传统的外部模块语法(已过时)
-
ES6 模块语法(推荐)
在运行时,也可以使用其他模块定义语法,例如 AMD 或 UMD 语法,但 TypeScript 编译器不会尝试将它们编译成选定的模块定义输出。
TypeScript 允许我们选择在运行时使用哪种类型的模块定义语法:
-
ES6
-
CommonJS
-
AMD
-
SystemJS
-
UMD
我们可以通过在编译时使用 --module 标志来表示我们的偏好,如下所示:
tsc --module commonjs main.ts // use CommonJS
tsc --module amd main.ts // use AMD
tsc --module umd main.ts // use UMD
tsc --module system main.ts // use SytemJS
重要的是要理解,设计时和运行时使用的模块定义语法可能不同。
自 TypeScript 1.5 版本发布以来,建议使用 ECMAScript 6 模块定义语法,因为它基于 ECMAScript 规范,这被认为是一个标准。此外,如果我们计划在现代 JavaScript 引擎中运行我们的 TypeScript 应用程序,我们将在设计时和运行时都能使用 ECMAScript 6 模块定义语法。
我们现在将查看每种可用的模块定义语法。
ES6 模块(运行时和设计时)
TypeScript 1.5 引入了对 ES6 模块语法的支持。以下代码片段使用 ES6 模块语法定义了一个外部模块:
class UserModel {
// ...
}
export { UserModel };
我们不需要使用 namespace 关键字来声明 ES6 模块,但我们必须使用 export 关键字。我们可以在模块底部或实体声明时使用 export 关键字:
export class TalkModel {
// ...
}
我们还可以使用别名导出实体:
class UserModel {
// ...
}
export { UserModel as User }; // UserModel exported as User
导出声明导出名称的所有含义:
interface UserModel {
// ...
}
class UserModel {
// ...
}
export { UserModel }; // Exports both interface and function
要从另一个模块导入模块,我们必须使用 import 关键字,如下所示:
import { UserModel } from "./models";
import 关键字为每个导入的组件创建一个变量。在前面的代码片段中,声明了一个名为 UserModel 的新变量,其值包含对导入模块(model.ts 文件)中声明的 UserModel 类的引用。
我们可以使用 export 关键字导出多个实体,如下所示:
class UserValidator {
// ...
}
class TalkValidator {
// ...
}
export { UserValidator, TalkValidator };
此外,我们还可以使用 import 关键字从单个模块导入多个实体,如下所示:
import { UserValidator, TalkValidator } from "./validation.ts"
最后,我们还可以使用 default 关键字来声明在没有明确导入实体时将被导入的默认实体:
export default UserValidator;
然后,我们可以如下导入默认导出:
import UserValidator from "./validation.ts"
ES6 模块在现代 JavaScript 引擎上原生支持,但如果你的目标是那些不支持 ES6 模块的 JavaScript 引擎,你需要使用像 webpack 这样的工具来启用与先前 JavaScript 引擎的向后兼容性。
旧版外部模块(仅设计时使用)
在 TypeScript 1.5 之前,外部模块使用特定的设计时语法声明。然而,一旦编译成 JavaScript,模块会被转换成 AMD、CommonJS、UMD 或 SystemJS 模块。
我们应该尽量避免使用旧版外部模块语法,而使用新的 ES6 语法。然而,我们将快速浏览外部模块语法,因为有时在旧应用程序和文档中仍然可能会遇到它。
要使用旧版外部模块语法导出模块,我们需要使用 export 关键字。我们可以直接将 export 关键字应用于类或接口,如下所示:
export class User {
// ...
}
我们还可以通过将其值赋给 export 来单独使用 export 关键字,我们希望导出该值:
class User {
// ...
}
export = User;
外部模块可以被编译成任何可用的模块定义语法(AMD、CommonJS、SystemJS 或 UMD)。
旧版外部模块的 import 语句如下所示:
import User = require("./user_class");
AMD 模块(仅运行时使用)
如果我们将 ES6 模块部分中定义的外部模块编译成 AMD 模块(使用标志 --compile amd),我们将生成以下 AMD 模块:
define(["require", "exports"], function (require, exports) {
var UserModel = (function () {
function UserModel() {
}
return UserModel;
})();
return UserModel;
});
define函数将其第一个参数作为一个数组。这个数组包含模块依赖项的名称列表。第二个参数是一个回调函数,一旦所有模块依赖项都已加载,它就会被调用。回调函数将每个模块依赖项作为其参数,并包含我们 TypeScript 组件的所有逻辑。注意回调函数的返回类型与使用export关键字声明的公共组件相匹配。
TypeScript 会因为define函数未声明而抛出编译错误。我们可以通过安装 RequireJS 类型定义来解决此问题:
npm install --save @types/requirejs
然后,可以使用 RequireJS 模块加载器按如下方式加载 AMD 模块:
require(["./models"], function(models) {
const user = new models.UserModel();
});
如我们所观察到的,AMD 模块使用两个不同的函数来定义模块(define)和消费模块(require)。通常,应用程序的入口点使用require函数来加载所有必需的依赖项。
在本书中,我们将不会进一步讨论 AMD 和 RequireJS,但如果你想要了解更多,你可以通过访问requirejs.org/docs/start.html来学习。
CommonJS 模块(仅运行时)
如果我们将 ES6 模块部分中定义的外部模块编译成 CommonJS 模块(使用标志--compile commonjs),我们将获得以下 CommonJS 模块:
var UserModel = (function () {
function UserModel() {
//...
}
return UserModel;
})();
module.exports = UserModel;
正如我们可以在前面的代码片段中看到的那样,CommonJS 模块定义语法几乎与遗留的外部模块语法相同。主要区别在于使用module对象及其exports属性而不是exports关键字。
前面的 CommonJS 模块可以通过 Node.js 应用程序使用import关键字和require函数原生加载:
import UserModel = require('./UserModel');
const user = new UserModel();
然而,如果我们尝试在浏览器中使用require函数,将会抛出异常,因为require函数未定义。我们可以通过使用 Browserify 轻松解决这个问题。Browserify 是一个模块加载器,它允许我们在浏览器中使用 CommonJS 模块。
如果你需要更多关于 Browserify 的信息,请访问官方文档github.com/substack/node-browserify#usage。
UMD 模块(仅运行时)
如果我们想要发布一个 JavaScript 库或框架,我们需要将我们的 TypeScript 应用程序编译成 CommonJS 和 AMD 模块,并且以开发者不使用模块加载器也能使用的方式编译。
网络开发社区已经开发了一个代码片段来帮助我们实现 UMD 支持:
(function (root, factory) {
if (typeof exports === 'object') {
// CommonJS
module.exports = factory(require('b'));
} else if (typeof define === 'function' && define.amd) {
// AMD
define(['b'], function (b) {
return (root.returnExportsGlobal = factory(b));
});
} else {
// Global Variables
root.returnExportsGlobal = factory(root.b);
}
}(this, function (b) {
// Your actual module
return {};
}));
前面的代码片段很棒,但我们希望避免手动将其添加到应用程序的每个模块中。幸运的是,有一些选项可以轻松实现 UMD 支持。
第一种选择是使用标志 --compile umd 为我们应用程序中的每个模块生成一个 UMD 模块。第二种选择是创建一个包含应用程序中所有模块的单个 UMD 模块,使用模块加载器(如 Browserify)。
有关 Browserify 的更多信息,请参阅官方项目网站 browserify.org/。有关生成单个优化文件的 Browserify-standalone 选项的更多信息,请参阅。
SystemJS 模块(仅运行时)
虽然 UMD 提供了一种在 AMD 和 CommonJS 中都工作的单个模块的输出方式,但 SystemJS 将允许你更接近其原生语义地使用 ES6 模块,而无需使用兼容 ES6 的浏览器引擎。
SystemJS 是由 Angular 2.0 引入的,它是一个流行的 Web 应用程序开发框架。
有关 SystemJS 的更多信息,请参阅官方项目网站 github.com/systemjs/systemjs。
在线有免费的常见模块错误列表,可在 www.typescriptlang.org/Handbook#modules-pitfalls-of-modules 查找。
模块总结
我们可以使用以下比较表总结所有前面的细节:
| Module syntax | 设计时支持 | 模块加载器运行时支持 | 原生运行时支持 | 优化工具支持 | 推荐 |
|---|---|---|---|---|---|
| Legacy internal modules | Yes | No | Yes (via closures) | No | No |
| Namespaces | Yes | No | Yes (via closures) | No | No |
| ES6 | Yes | Yes | Yes | Yes | Yes |
| Legacy external modules | Yes | Yes | No | Yes | No |
| AMD | No | Yes | No | Yes | No |
| CommonJS | No | Yes | No | Yes | No |
| UMD | No | Yes | No | Yes | No |
| SystemJS | No | Yes | No | Yes | No |
正如我们所见,未来推荐的做法是使用 ES6 模块。如果你针对的是不支持 ES6 模块的 JavaScript 引擎,你需要使用像 webpack 这样的工具来实现与先前 JavaScript 引擎的向后兼容性。
我们将在第九章 自动化您的开发工作流程 中学习更多关于 Webpack 的知识。
在面向对象编程中管理依赖
我们已经学习了如何处理应用程序依赖和第三方依赖。现在,我们将学习依赖倒置,并扩展前一章中关于依赖倒置原则的知识。
依赖注入与依赖倒置
许多文章将 依赖注入 和 依赖倒置 两个术语混为一谈,好像它们的含义相同,但实际上它们是两个非常不同的概念。
以下示例声明了一个名为 Ninja 的类和一个名为 Katana 的类。Ninja 类依赖于 Katana 类:
class Katana {
public tryHit(fromDistance: number) {
return fromDistance <= 2;
}
}
class Ninja {
public constructor(
private _katana: Katana
) {}
public fight(fromDistance: number) {
return this._katana.tryHit(fromDistance);
}
}
在声明上述类之后,我们可以将刀剑类的实例注入到忍者类中:
const ninja = new Ninja(new Katana());
ninja.fight(2); // true
ninja.fight(5); // false
上述代码片段实现了依赖注入设计模式,因为我们正在将依赖项(刀剑)注入到忍者类中。然而,我们没有实现依赖倒置原则,因为忍者类直接依赖于刀剑类。
忍者类和刀剑类之间的关系可以用以下图表表示:
忍者 → 刀剑
以下代码片段声明了一个名为 Weapon 的接口,然后由 刀剑 类实现。这次,忍者 类依赖于 Weapon 接口而不是 刀剑 类:
interface Weapon {
try Hit(fromDistance: number): boolean;
}
class Katana implements Weapon {
public tryHit(fromDistance: number) {
return fromDistance <= 2;
}
}
class Ninja {
public constructor(
private _weapon: Weapon
) {}
public fight(fromDistance: number) {
return this._weapon.tryHit(fromDistance);
}
}
在声明上述类之后,我们可以将刀剑类的实例注入到忍者类中:
const ninja = new Ninja(new Katana());
ninja.fight(2); // true
ninja.fight(5); // false
上述代码片段实现了依赖注入设计模式,因为我们正在将依赖项(刀剑)注入到忍者类中。它还实现了依赖倒置原则,因为忍者类不直接依赖于刀剑类。
这次,类之间的关系可以用以下方式表示:
忍者 → 武器 ← 刀剑
如我们所见,表示忍者和刀剑类之间关系的箭头已经被反转。这解释了依赖倒置原则名称的由来。
依赖倒置原则很重要,因为它通过减少我们应用程序中实体之间的耦合程度,使我们的代码更容易维护。例如,如果我们重命名刀剑类,我们就不需要更改忍者类。这意味着刀剑和忍者类之间是完全独立的。
控制反转容器
控制反转(inversion of control,IoC)容器是一种充当智能工厂的工具。IoC 容器可以用来创建类的实例。如果类有一些依赖项,IoC 容器将能够使用依赖注入来满足这些需求。我们说工厂是智能的,因为它可以根据执行上下文中的匹配条件创建依赖项,并且还可以控制它所创建的实例的生命周期。
当我们使用 IoC 容器时,我们正在失去对类实例创建的控制,依赖注入和 IoC 容器将接管这些方面的应用控制。这一事实应该解释了术语控制反转的含义。
InversifyJS 基础
InversifyJS 是一个用于 TypeScript 应用程序的 IoC 容器。InversifyJS 可以用来实现依赖倒置原则。
要使用 InversifyJS,我们需要使用 npm 安装它,如下所示:
npm install inversify reflect-metadata --save
然后,我们可以导入由 inversify 和 reflect-metadata 声明的一些实体,如下所示:
import { Container, inject, injectable } from "inversify";
import "reflect-metadata";
以下代码片段使用 inject 装饰器在 忍者 类上添加了一个注解:
interface Weapon {
tryHit(fromDistance: number): boolean;
}
@injectable()
class Katana implements Weapon {
public tryHit(fromDistance: number) {
return fromDistance <= 2;
}
}
@injectable()
class Ninja {
public constructor(
@inject("Weapon") private _weapon: Weapon
) {}
public fight(fromDistance: number) {
return this._weapon.tryHit(fromDistance);
}
}
要使用 InversifyJS 创建Ninja类的实例,我们需要创建Container类的实例并声明所谓的类型绑定。类型绑定是类型与其实现之间的链接。以下代码片段声明了两个类型绑定。第一个类型绑定将类型Weapon与实现Katana关联起来。第二个类型绑定将类型Ninja与其自身关联:
const container = new Container();
container.bind<Weapon>("Weapon").to(Katana);
container.bind<Ninja>("Ninja").to(Ninja);
然后,我们可以使用容器创建Ninja类的实例。容器使用注解来识别Ninja类依赖于Weapon类型。然后容器创建Katana类的实例,并将其注入到Ninja类中,因为它知道Katana类是Weapon接口的有效实现:
const ninja = container.get<Ninja>("Ninja");
ninja.fight(2); // true
ninja.fight(5); // false
InversifyJS 还允许我们控制依赖项的生命周期。例如,我们可以配置Katana类型绑定,使所有实例都成为单个共享实例(单例):
container.bind<Weapon>("Weapon").to(Katana).inSingletonScope();
我们还可以配置复杂的运行时约束,这将决定依赖项如何解析。例如,我们可能有两种不同的Weapon实现,它们在不同的环境下被注入:
container.bind<Weapon>("Weapon").to(Katana)
.whenInjectedInto(Samurai);
container.bind<Weapon>("Weapon").to(Shuriken)
.whenInjectedInto(Ninja);
循环依赖
当我们与多个组件和依赖项一起工作时,可能会遇到循环依赖的问题。有时可能会达到一个点,其中一个组件(A)依赖于第二个组件(B),而这个第二个组件(B)又依赖于第一个组件(A)。在以下图中,每个节点是一个组件,我们可以观察到节点circular1.ts和circular2.ts(用红色表示)存在循环依赖。没有依赖的节点以绿色显示,有依赖但没有问题的节点以蓝色显示:

循环依赖不一定只涉及两个组件。我们可能会遇到这样的情况:一个组件依赖于另一个组件,而这个被依赖的组件又依赖于其他组件,依赖树中的一些组件最终会指向树中的某个父组件。如果 InversifyJS 检测到循环依赖,它将抛出一个运行时异常。
摘要
在本章中,我们学习了管理第三方依赖项的基础知识。我们还学习了内部模块和外部模块之间的区别,以及每个类别中的主要模块类型。
我们还学习了如何在面向对象编程中处理依赖项。最后,我们学习了如何实现依赖注入以及如何与 IoC 容器一起工作。
在下一章中,我们将学习 TypeScript/JavaScript 的运行时。
第六章:理解运行时
在阅读这本书之后,你可能会渴望开始一个新的项目,将你迄今为止所学的一切付诸实践。在这个时候,你应该能够编写一个使用 TypeScript 的小型网络应用程序,并解决你可能会遇到的潜在设计时问题。
然而,随着你的新项目不断发展,你开发了更多复杂的功能,你可能会遇到一些运行时问题。本章应该为你提供解决运行时问题的缺失知识。
我们在前面的章节中只是简要地提到了 TypeScript 运行时,但根据你的背景,你可能已经对它有了很多了解,因为 TypeScript 运行时是 JavaScript 运行时。
TypeScript 仅用于设计时;TypeScript 代码随后被编译成 JavaScript,并在运行时执行。JavaScript 运行时负责监督 JavaScript 代码的执行。重要的是要理解我们永远不会执行 TypeScript 代码,我们总是执行 JavaScript 代码;因此,当我们提到 TypeScript 运行时,我们实际上是在谈论 JavaScript 运行时。
当我们编译 TypeScript 代码时,我们将生成 JavaScript 代码,该代码将在服务器端或客户端执行。那时,我们可能会遇到一些具有挑战性的运行时问题。
在本章中,我们将涵盖以下主题:
-
执行环境
-
事件循环
-
this操作符 -
原型
-
闭包
让我们从了解执行环境开始。
执行环境
执行环境是我们开始开发 TypeScript 应用程序之前必须考虑的第一件事之一。一旦我们将 TypeScript 代码编译成 JavaScript,它就可以在许多不同的环境中执行。虽然这些环境中的大多数将是网络浏览器的一部分,如 Chrome、Internet Explorer 或 Firefox,但我们可能还希望能够在 Node.js、RingoJS 或 Electron 等环境中的服务器端或桌面应用程序上运行我们的代码。
重要的是要记住,在运行时有一些变量和对象是特定于环境的。例如,我们可以创建一个库并访问 document.layers 变量。虽然 document 是 W3C 文档对象模型(DOM)标准的一部分,但 layers 属性仅在 Internet Explorer 中可用,并且不是 W3C DOM 标准的一部分。
W3C 如下定义 DOM:
“文档对象模型是一个平台和语言中立的接口,它将允许程序和脚本动态地访问和更新文档的内容、结构和样式。文档可以进一步处理,处理结果可以合并回显示的页面”。
类似地,我们也可以从浏览器运行时环境中访问一组称为浏览器对象模型(BOM)的对象。BOM 包括 navigator、history、screen、location 和 document 这些对象,它们是 window 对象的属性。
我们需要记住,DOM 只在网页浏览器中可用。如果我们想在网页浏览器中运行我们的应用程序,我们将能够访问 DOM 和 BOM。然而,在 Node.js 或 RingoJS 等环境中,这些 API 将不可用,因为它们是完全独立于网页浏览器的独立 JavaScript 环境。我们还可以在服务器端环境中找到其他对象(如 Node.js 中的 process.stdin),如果我们尝试在网页浏览器中执行我们的代码,这些对象将不可用。
我们还需要牢记这些 JavaScript 环境存在多个版本。在某些情况下,我们可能需要支持多个浏览器和多个版本的 Node.js。处理此类问题时,建议的做法是添加条件语句来检查功能的可用性,而不是检查环境或版本的可用性。
有一个非常好的库可以帮助我们在为网页浏览器开发时实现功能检测。这个库叫做 Modernizr,可以在 modernizr.com/ 下载。
理解事件循环
TypeScript 运行时(JavaScript)基于事件循环的并发模型。这种模型与其他语言(如 C 或 Java)中的模型相当不同。在我们专注于事件循环本身之前,我们必须了解一些运行时概念。
下面的图示是一些重要的运行时概念:堆、栈、队列和帧:

我们现在将探讨每个这些概念的作用。
帧结构
一个帧是工作的一个连续单元。在上面的图中,帧由栈内的块表示。
当在 JavaScript 中调用一个函数时,运行时会创建一个帧在栈中。帧包含该函数的参数和局部变量。当函数返回时,帧从栈中移除。让我们看一个例子:
function foo(a: number): number {
const value = 12;
return value + a;
}
function bar(b: number): number {
const value = 4;
return foo(value * b);
}
在声明 foo 和 bar 函数之后,我们将调用 bar 函数:
bar(21);
当 bar 执行时,运行时会创建一个新的帧,包含 bar 的参数以及所有其局部变量(值)。然后,这个帧(在栈内表示为块)被添加到栈顶。
在内部,bar 调用 foo。当 foo 被调用时,会创建一个新的帧并将其分配到栈顶。当 foo 的执行完成(foo 已返回)时,栈顶的帧被移除。当 bar 的执行也完成时,它也会从栈中移除。
现在,让我们尝试想象如果 foo 函数调用了 bar 函数会发生什么:
function foo(a: number): number {
const value = 12;
return bar(value + a);
}
function bar(b: number): number {
const value = 4;
return foo(value * b);
}
我们会创建一个永无止境的函数调用循环。每次函数调用都会在栈中添加一个新的帧,最终栈中可能没有更多空间,并抛出错误。大多数软件工程师都熟悉这种错误,称为 栈溢出 错误。
栈
栈包含一系列步骤(帧)。栈是一种表示简单 后进先出 (LIFO)对象集合的数据结构。因此,当帧添加到栈中时,它总是添加到栈顶。
由于栈是一个 LIFO 集合,事件循环从栈顶到底部处理其中存储的帧。帧的依赖项被添加到栈顶,以确保每个帧的所有依赖项都得到满足。
队列
队列包含一个等待处理的消息列表。每个消息都与一个函数相关联。当栈为空时,从队列中取出一条消息并处理。处理包括调用相关函数并将帧添加到栈中。当栈再次变为空时,消息处理结束。
在之前的运行时图中,队列内的块代表消息。
堆
堆是一个不知道存储在其中的项目顺序的内存容器。堆包含当前正在使用的所有变量和对象。它还可能包含当前不在作用域内但尚未被垃圾收集器从内存中移除的帧。
事件循环
并发是指两个或更多操作可以同时执行的能力。运行时执行在一个单独的线程上发生,这意味着我们无法实现真正的并发。
事件循环遵循运行至完成的方法,这意味着它会在处理任何其他消息之前,从开始到结束处理一条消息。
每次调用函数时,都会将一条新消息添加到队列中。如果栈为空,则处理该函数(将帧添加到栈中)。
当所有帧都添加到栈中后,从栈顶到底部清除栈。在处理过程的末尾,栈为空,并处理下一条消息。
Web 工作者可以在不同的线程中执行后台任务。它们使用一个分离的队列、堆和栈。
事件循环 的一大优点是执行顺序非常可预测且易于跟踪。事件循环 方法的重要优点之一是它具有非阻塞 I/O。这意味着当应用程序等待输入和输出(I/O)操作完成时,它仍然可以处理其他事情,例如用户输入。
这种方法的缺点是,如果一条消息(函数)执行时间过长,应用程序就会变得无响应。遵循的良好实践是使消息处理尽可能短,如果可能的话,将一条消息拆分成几条消息。
this 操作符
在 JavaScript 中,this 操作符的行为与其他语言略有不同。this 操作符的值通常由函数的调用方式决定。它的值在执行过程中不能通过赋值来设置,并且每次调用函数时可能不同。
当使用严格模式和非严格模式时,this 操作符也有一些不同之处。要了解更多关于严格模式的信息,请参阅 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Strict_mode。
全局上下文中的 this 操作符
在全局上下文中,this 操作符始终指向全局对象。在网页浏览器中,window 对象就是全局对象:
console.log(this === window); // true
this.a = 37;
console.log(window.a); // 37
console.log(window.document === this.document); // true
console.log(this.document === document); // true
console.log(window.document === document); // true
前面的例子是一个 JavaScript 示例,而不是 TypeScript 示例。
函数上下文中的 this 操作符
函数内部 this 的值取决于函数的调用方式。如果我们以非严格模式简单地调用一个函数,函数内部的 this 值将指向全局对象,如下所示:
function f1() {
return this;
}
f1() === window; // true
本节中的所有示例(即函数上下文中的 this 操作符)都是 JavaScript 示例,而不是 TypeScript 示例。
然而,如果我们以严格模式调用一个函数,函数体内的 this 值将指向 undefined,如下所示:
console.log(this); // global (window)
function f2() {
"use strict";
return this; // undefined
}
console.log(f2()); // undefined
console.log(this); // window
ECMAScript 5 的严格模式是一种选择进入 JavaScript 限制变体的方式。你可以在 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Strict_mode 了解更多关于严格模式的信息。
然而,将函数作为实例方法调用时,函数内部的 this 操作符的值指向实例。换句话说,函数上下文中的 this 操作符(该函数是类的一部分)指向那个类:
const person = {
age: 37,
getAge: function() {
return this.age; // this points to the instance (person)
}
};
console.log(person.getAge()); // 37
在前面的例子中,我们使用了对象字面量表示法来定义一个名为 p 的对象,但使用原型声明对象时也适用同样的规则:
function Person() {}
Person.prototype.age = 37;
Person .prototype.getAge = function () {
return this.age;
}
const person = new Person();
person.age; // 37
person.getAge(); // 37
当一个函数用作构造函数(使用 new 关键字)时,this 操作符指向正在构造的对象:
function Person() { // function used as a constructor
this.age = 37;
}
const person = new Person();
console.log(person.age); // logs 37
调用、应用和绑定方法
所有函数都从 Function.prototype 继承了 call、apply 和 bind 方法。我们可以使用这些方法来设置 this 的值。
call 和 apply 方法几乎相同;两种方法都允许我们调用一个函数并设置函数内部的 this 操作符的值。call 和 apply 之间的主要区别在于,虽然 apply 允许我们将参数作为数组传递给函数,但 call 需要显式列出函数参数。
一个有用的记忆法是:A (apply) 代表数组,C (call) 代表逗号*。
让我们来看一个例子。我们首先声明一个名为 Person 的类。这个类有两个属性(name 和 surname)和一个方法(greet)。greet 方法使用 this 操作符来访问 name 和 surname 实例属性:
class Person {
public name: string;
public surname: string;
public constructor(name: string, surname: string) {
this.name = name;
this.surname = surname;
}
public greet(city: string, country: string) {
// we use the "this" operator to access name and surname
let msg = `Hi, my name is ${this.name} ${this.surname}.`;
msg += `I'm from ${city} (${country}).`;
console.log(msg);
}
}
在声明了 Person 类之后,我们将创建一个实例:
const person = new Person("remo", "Jansen");
如果我们调用 greet 方法,它将按预期工作:
person.greet("Seville", "Spain");
或者,我们可以使用 call 和 apply 函数来调用该方法。我们在两个函数中提供了 person 对象作为第一个参数,因为我们想让 this 操作符(在 greet 方法内部)的值是 person:
person.greet.call(person, "Seville", "Spain");
person.greet.apply(person, ["Seville", "Spain"]);
如果我们提供一个不同的值作为 this 的值,我们就无法在 greet 函数内访问 name 和 surname 属性:
person.greet.call(null, "Seville", "Spain");
person.greet.apply(null, ["Seville", "Spain"]);
前两个例子可能看起来没有用,因为第一个直接调用了函数,而第二个导致了意外的行为。apply 和 call 方法只有在我们需要在函数调用时改变 this 操作符的值时才有意义:
const valueOfThis = { name : "Anakin", surname : "Skywalker" };
person.greet.call(valueOfThis, "Mos espa", "Tatooine");
person.greet.apply(valueOfThis, ["Mos espa", "Tatooine"]);
bind 方法可以用来设置 this 操作符(在函数内部)的值,无论它是如何被调用的。
当我们调用一个函数的 bind 方法时,它返回一个与原始函数具有相同体和作用域的新函数,但 this 操作符(在函数体内部)将永久绑定到 bind 的第一个参数,无论函数是如何被使用的。
让我们来看一个例子。我们首先创建一个 Person 类的实例,这个类是我们之前声明的:
const person = new Person("Remo", "Jansen");
然后,我们可以使用 bind 将 greet 函数设置为具有相同作用域和体的新函数:
const greet = person.greet.bind(person);
如果我们尝试使用 bind 和 apply 来调用 greet 函数,就像我们在上一个示例中所做的那样,我们将能够观察到,这次,this 操作符将始终指向对象实例,无论函数是如何被调用的:
greet.call(person, "Seville", "Spain");
greet.apply(person, ["Seville", "Spain"]);
// Hi, my name is Remo Jansen. I'm from Seville Spain.
greet.call(null, "Seville", "Spain");
greet.apply(null, ["Seville", "Spain"]);
// Hi, my name is Remo Jansen. I'm from Seville Spain.
const valueOfThis = { name: "Anakin", surname: "Skywalker" };
greet.call(valueOfThis, "Mos espa", "Tatooine");
greet.apply(valueOfThis, ["Mos espa", "Tatooine"]);
// Hi, my name is Remo Jansen. I'm from Mos espa Tatooine.
一旦我们使用 bind 将一个对象绑定到一个函数上,我们就无法覆盖它:
const valueOfThis = { name: "Anakin", surname: "Skywalker" };
const greet = person.greet.bind(valueOfThis);
greet.call(valueOfThis, "Mos espa", "Tatooine");
greet.apply(valueOfThis, ["Mos espa", "Tatooine"]);
// Hi, my name is Remo Jansen. I'm from Mos espa Tatooine.
使用 bind、apply 和 call 方法通常是不被推荐的,因为这可能会导致混淆。修改 this 操作符的默认行为可能会导致意外的结果。请记住,仅在绝对必要时使用这些方法,并适当地记录代码以减少潜在的可维护性问题带来的风险。
原型
当我们编译 TypeScript 程序时,所有的类和对象都变成了 JavaScript 对象。然而,有时即使编译没有错误,我们可能在运行时遇到意外的行为。为了能够识别和理解这种行为的起因,我们需要对 JavaScript 运行时有一个很好的理解。我们需要理解的主要概念之一是运行时的继承是如何工作的。
运行时继承系统使用原型继承模型。在原型继承模型中,对象从对象继承,没有类可用。然而,我们可以使用原型来模拟类。让我们看看它是如何工作的。
在运行时,每个 JavaScript 对象都有一个名为prototype的内部属性。prototype属性的值是一个对象,它包含一些属性(数据)和方法(行为)。
在 TypeScript 中,我们可以使用基于类的继承系统:
class Person {
public name: string;
public surname: string;
public age: number = 0;
public constructor(name: string, surname: string) {
this.name = name;
this.surname = surname;
}
public greet() {
let msg = `Hi! my name is ${this.name} ${this.surname}`;
msg += `I'm ${this.age}`;
}
}
我们定义了一个名为Person的类。在运行时,这个类使用原型而不是类来声明:
var Person = (function() {
function Person(name, surname) {
this.age = 0;
this.name = name;
this.surname = surname;
}
Person.prototype.greet = function() {
let msg = "Hi! my name is " + this.name +
" " + this.surname;
msg += "I'm " + this.age;
};
return Person;
})();
上述代码是 TypeScript 在针对 ES5 时输出的。class关键字在 ES6 运行时得到支持,但它只是语法糖。在计算机科学中,语法糖是编程语言中设计来使事物更容易阅读或表达的语言。这意味着class关键字只是一个帮助我们作为软件工程师更容易生活的助手,并且内部始终使用原型。
TypeScript 编译器将对象定义(我们不会将其称为类定义,因为技术上它不是类)用立即执行函数表达式(IIFE)包装起来。在 IIFE 内部,我们可以找到一个名为Person的函数。如果我们检查这个函数并与 TypeScript 类进行比较,我们会注意到它接受与 TypeScript 类构造函数相同的参数。这个函数用于创建Person类的新实例。
在构造函数之后,我们可以看到greet方法的定义。正如我们所见,prototype属性被用来将greet方法附加到Person类上。
实例属性与类属性
因为 JavaScript 是一种动态编程语言,我们可以在运行时向对象的实例添加属性和方法,它们不需要是对象(类)本身的一部分。让我们看看一个例子:
function Person(name, surname) {
// instance properties
this.name = name;
this.surname = surname;
}
const person = new Person("Remo", "Jansen");
person.age = 27;
我们为名为person的对象定义了一个构造函数,它接受两个变量(name和surname)作为参数。然后,我们创建了一个Person对象的实例,并给它添加了一个名为age的新属性。我们可以使用for...in语句在运行时检查person的属性:
for(let property in person) {
console.log("property: " + property + ", value: '" +
person[property] + "'");
}
// property: name, value: 'remo'
// property: surname, value: 'jansen'
// property: age, value: 27
// property: greet, value: 'function (city, country) {
// let msg = "Hi, my name is " + this.name + " " +
//this.surname;
// msg += "nI'm from " + city + " " + country;
// console.log(msg);
// }'
所有这些属性都是实例属性,因为它们为每个新实例持有值。例如,如果我们创建一个Person的新实例,这两个实例都将持有它们自己的值:
let person2 = new Person("John", "Wick");
person2.name; // "John"
person1.name; // "Remo"
我们使用this运算符定义了这些实例属性,因为在函数用作构造函数(使用new关键字)时,this运算符绑定到正在构造的新对象上。
这也解释了为什么我们可以通过对象的原型来定义实例属性:
Person.prototype.name = name; // instance property
Person.prototype.name = surname; // instance property
我们也可以声明类级别的属性和方法(也称为静态属性)。主要区别是类属性和方法的值在对象的各个实例之间是共享的。
类属性通常用于存储静态值:
function MathHelper() {
/* ... */
}
// class property
MathHelper.PI = 3.14159265359;
类方法也经常被用作执行计算并返回结果的实用函数:
function MathHelper() { /* ... */ }
// class property
MathHelper.PI = 3.14159265359;
// class method
MathHelper.areaOfCircle = function(radius) {
return radius * radius * MathHelper.PI;
}
在前面的例子中,我们从类方法(areaOfCircle)中访问了一个类属性(PI)。我们可以从实例方法中访问类属性,但不能从类属性或方法中访问实例属性或方法。我们可以通过将 PI 声明为实例属性而不是类属性来演示这一点:
function MathHelper() {
// instance property
this.PI = 3.14159265359;
}
如果我们尝试从一个类方法中访问 PI,它将是未定义的:
// class method
MathHelper.areaOfCircle = function(radius) {
return radius * radius * this.PI; // this.PI is undefined
}
MathHelper.areaOfCircle(5); // NaN
我们不应该从实例方法中访问类方法或属性,但有一种方法可以做到。我们可以通过使用原型构造函数属性,如以下示例所示来实现它:
function MathHelper () { /* ... */ }
// class property
MathHelper.PI = 3.14159265359;
// instance method
MathHelper.prototype.areaOfCircle = function(radius) {
return radius * radius * this.constructor.PI;
}
const math = new MathHelper ();
console.log(MathHelper.areaOfCircle(5)); // 78.53981633975
我们可以使用原型构造函数属性从 areaOfCircle 实例方法中访问 PI 类属性,因为这个属性返回对对象构造函数的引用。
在 areaOfCircle 内部,this 操作符返回对对象原型的引用:
this === MathHelper.prototype
this.constructor 的值等于 MathHelper.prototype.constructor,因此 MathHelper.prototype.constructor 等于 MathHelper。
原型继承
我们可能会想知道 extends 关键字是如何工作的。让我们创建一个新的 TypeScript 类,它从 Person 类继承,以理解它:
class SuperHero extends Person {
public superpower: string;
public constructor(
name: string,
surname: string,
superpower: string
) {
super(name, surname);
this.superpower = superpower;
}
public userSuperPower() {
return `I'm using my ${this.superpower}`;
}
}
前一个类被命名为 SuperHero 并扩展了 Person 类。它有一个额外的属性(superpower)和一个方法(useSuperPower)。如果我们编译代码,我们会注意到以下代码片段:
var __extends = this.__extends || function (d, b) {
for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p];
function __() { this.constructor = d; }
__.prototype = b.prototype;
d.prototype = new __();
};
请注意,前面的代码片段在 TypeScript 2.8 中稍微复杂一些。在这里我们将使用之前版本的代码,因为它包含的条件较少,更容易理解。
这段代码是由 TypeScript 生成的。尽管它是一小段代码,但它展示了本章几乎包含的每一个概念,理解它可能相当具有挑战性。
在函数表达式第一次评估之前,this 操作符指向全局对象,它不包含名为 __extends 的方法。这意味着 __extends 变量是未定义的。
当函数表达式第一次评估时,函数表达式的值(一个匿名函数)被分配给全局作用域中的 __extends 属性。
TypeScript 为包含 extends 关键字的每个 TypeScript 文件生成一次函数表达式。然而,函数表达式只评估一次(当 __extends 变量未定义时)。这种行为是通过第一行的条件语句实现的:
var __extends = this.__extends || function (d, b) { // ...
当这一行代码第一次执行时,函数表达式会被评估。函数表达式的值是一个匿名函数,它被分配给全局作用域中的 __extends 变量。因为我们在全局作用域中,var __extends 和 this. __extends 在这个点上指向同一个变量。
当一个新文件执行时,__extends 变量已经在全局作用域中可用,函数表达式不会被评估。这意味着函数表达式的值只被分配给 __extends 变量一次,即使代码片段被多次执行。
现在,让我们关注一下 __extends 变量(匿名函数):
function (d, b) {
for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p];
function __() { this.constructor = d; }
__.prototype = b.prototype;
d.prototype = new __();
}
此函数接受两个参数,分别命名为 d 和 b。当我们调用它时,我们应该传递一个派生对象构造函数(d)和一个基对象构造函数(b)。
匿名函数内的第一行遍历基类的每个属性和方法,并在派生类中创建它们的副本:
for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p];
当我们使用 for...in 语句迭代对象的实例到 a 时,它将迭代对象的实例属性。然而,如果我们使用 for...in 语句迭代对象构造函数的属性,该语句将迭代其类属性。在前面的例子中,for...in 语句被用来继承对象的类属性和方法。为了继承实例属性,我们将复制对象的原型。
第二行声明了一个新的构造函数 __,并在其中使用 this 操作符来访问其原型:
function __() { this.constructor = d; }
原型包含一个名为 constructor 的特殊属性,它返回对对象构造函数的引用。在这个点上,名为 __ 的函数和 this.constructor 指向同一个变量。然后,派生对象构造函数(d)的值被分配给 __ 构造函数。
在第三行,基对象构造函数的原型对象的值被分配给 __ 对象构造函数的原型:
__.prototype = b.prototype;
在最后一行,调用了 new __(),并将结果分配给派生类(d)的原型。通过执行所有这些步骤,我们可以调用以下内容:
var instance = new d():
这样做之后,我们将得到一个包含派生类(d)和基类(b)所有属性的对象。此外,由派生构造函数(d)构建的任何实例对象都将作为派生类的实例,同时继承自基类(b)的类和实例属性和方法。
我们可以通过检查定义 SuperHero 类的运行时代码来查看这个函数的实际效果:
var SuperHero = (function (_super) {
__extends(SuperHero, _super);
function SuperHero(name, surname, superpower) {
_super.call(this, name, surname);
this.superpower = superpower;
}
SuperHero.prototype.userSuperPower = function () {
return "I'm using my " + superpower;
};
return SuperHero;
})(Person);
我们在这里再次看到了立即执行函数表达式(IIFE)。这次,IIFE 将 Person 对象构造函数作为参数。在函数内部,我们将使用 _super 这个名字来引用这个参数。在 IIFE 内部,调用了 __extends 函数,并将 SuperHero(派生类)和 _super(基类)参数传递给它。
在下一行,我们可以找到 SuperHero 对象构造函数和 useSuperPower 函数的声明。我们可以在声明之前使用 SuperHero 作为 __extend 的参数,因为函数声明会被提升到作用域的顶部。
函数表达式不会被提升。当我们在一个函数表达式中将函数赋给一个变量时,变量会被提升,但它的值(函数本身)不会被提升。
在 SuperHero 构造函数内部,使用 call 方法调用基类(Person)的构造函数:
_super.call(this, name, surname);
正如我们在本章前面所发现的,我们可以使用 call 来设置函数上下文中 this 操作符的值。在这种情况下,我们传递了 this 操作符,它指向正在创建的 SuperHero 实例:
function Person(name, surname) {
// this points to the instance of SuperHero being created
this.name = name;
this.surname = surname;
}
原型链和属性遮蔽
当我们尝试访问一个对象的属性或方法时,运行时会搜索该对象属性和方法。如果找不到,运行时会继续通过遍历整个继承树来搜索对象的继承属性。因为派生对象通过 prototype 属性与其基对象链接,所以我们称这个继承树为 原型链。
让我们看一个例子。我们将声明两个简单的 TypeScript 类,分别命名为 Base 和 Derived:
class Base {
public method1() { return 1; }
public method2() { return 2; }
}
class Derived extends Base {
public method2() { return 3; }
public method3() { return 4; }
}
现在我们将检查 TypeScript 生成的 JavaScript 代码:
var Base = (function () {
function Base() {
}
Base.prototype.method1 = function () { return 1; };
Base.prototype.method2 = function () { return 2; };
return Base;
})();
var Derived = (function (_super) {
__extends(Derived, _super);
function Derived() {
_super.apply(this, arguments);
}
Derived.prototype.method2 = function () { return 3; };
Derived.prototype.method3 = function () { return 4; };
return Derived;
})(Base);
然后,我们可以创建 Derived 类的一个实例:
var derived = new Derived();
如果我们尝试访问名为 method1 的方法,运行时会在实例属性中找到它:
console.log(derived.method1); // 1
实例还有一个名为 method2 的自有属性(值为 2),但还有一个继承的属性名为 method2(值为 3)。对象的属性(值为 3 的 method2)阻止了对原型属性(值为 2 的 method2)的访问。这被称为 属性遮蔽:
console.log(derived.method2); // 3
实例没有名为 method3 的自有属性,但在其原型中有一个名为 method3 的属性:
console.log(derived.method3); // 4
实例或原型链中的对象(Base 类)都没有名为 method4 的属性:
console.log(derived.method4); // error
访问对象的原型
原型可以通过三种不同的方式访问:
-
Person.prototype -
Object.getPrototypeOf(person) -
person.__proto__
使用 __proto__ 是有争议的,并且被许多经验丰富的软件工程师所反对。它从未被最初包含在 ECMAScript 语言规范中,但现代浏览器决定无论如何都要实现它。今天,__proto__ 属性已经被标准化在 ECMAScript 6 语言规范中,并将得到未来的支持,但它仍然是一个应该避免的慢操作,如果性能是一个关注点的话。
新操作符
我们可以使用 new 操作符来生成 Person 的一个实例:
const person = new Person("remote", "Jansen");
当我们使用新操作符时,运行时会创建一个新的对象,该对象继承自 Person 类的原型。
闭包
闭包是运行时可用功能中最强大之一,但它们也是最容易误解的。Mozilla 开发者网络将闭包定义为如下:
“闭包是引用独立(自由)变量的函数。换句话说,闭包中定义的函数‘记得’它被创建的环境”。
我们将独立(自由)变量理解为在创建它们的词法作用域之外持续存在的变量。让我们看一个例子:
function makeArmy() {
const shooters = [];
for (let i = 0; i < 10; i++) {
const shooter = () => { // a shooter is a function
console.log(i); // which should display it's number
};
shooters.push(shooter);
}
return shooters;
}
上述示例是一个 JavaScript 示例,而不是 TypeScript 示例。
我们声明了一个名为makeArmy的函数。在函数内部,我们创建了一个名为shooters的函数数组。shooters数组中的每个函数将显示一个数字,其值是从for语句内部的变量i设置的。现在我们将调用makeArmy函数:
const army = makeArmy();
变量army现在应该包含函数的shooters数组。然而,如果我们执行以下代码,我们会注意到一个问题:
army[0](); // 10 (expected 0)
army[5](); // 10 (expected 5)
上述代码片段没有按预期工作,因为我们犯了一个与闭包相关的最常见错误。当我们将在makeArmy函数内部声明的shooter函数时,我们没有意识到我们创建了一个闭包。
这种情况的原因是分配给shooter的函数是闭包;它们由函数定义和从makeArmy函数作用域捕获的环境组成。我们创建了十个闭包,但它们都具有相同的环境。当shooter函数执行时,循环已经完成,共享于所有闭包的i变量(指向最后一个条目10)。
在这种情况下,一个解决方案是使用更多的闭包:
function makeArmy() {
const shooters = [];
for (let i = 0; i < 10; i++) {
((index: number) => {
const shooter = () => {
console.log(index);
};
shooters.push(shooter);
})(i);
}
return shooters;
}
const army = makeArmy();
army[0](); // 0
army[5](); // 5
上述示例是一个 TypeScript 示例,而不是 JavaScript 示例。
这正如预期的那样工作。而不是让shooter函数共享一个单一的环境,立即调用的函数为每个函数创建一个新的环境,其中i指向相应的值。
由闭包驱动的静态变量
在上一节中,我们了解到当闭包上下文中的变量在类的多个实例之间共享时,该变量表现得像静态变量。
现在,我们将看到如何创建表现像静态变量一样的变量和方法。让我们首先声明一个名为Counter的 TypeScript 类:
class Counter {
private static _COUNTER = 0;
public increment() {
this._changeBy(1);
}
public decrement() {
this._changeBy(-1);
}
public value() {
return Counter._COUNTER;
}
private _changeBy(val: number) {
Counter._COUNTER += val;
}
}
上述示例是一个 TypeScript 示例,而不是 JavaScript 示例。
上述类包含一个名为_COUNTER的静态成员。TypeScript 编译器将其转换为以下代码:
var Counter = (function () {
function Counter() {
}
Counter.prototype._changeBy = function (val) {
Counter._COUNTER += val;
};
Counter.prototype.increment = function () {
this._changeBy(1);
};
Counter.prototype.decrement = function () {
this._changeBy(-1);
};
Counter.prototype.value = function () {
return Counter._COUNTER;
};
Counter._COUNTER = 0;
return Counter;
})();
上述示例是一个 JavaScript 示例,而不是 TypeScript 示例。
如我们所观察到的,TypeScript 编译器将静态变量声明为类属性(而不是instance属性)。编译器使用类属性,因为类属性在类的所有实例之间共享。问题是私有变量在运行时并不是私有的。
或者,我们可以编写一些 JavaScript(记住,所有有效的 JavaScript 都是有效的 TypeScript)代码来使用闭包在运行时模拟静态属性:
var Counter = (function() {
// closure context
let _COUNTER = 0;
function changeBy(val) {
_COUNTER += val;
}
function Counter() {};
// closure functions
Counter.prototype.increment = function() {
changeBy(1);
};
Counter.prototype.decrement = function() {
changeBy(-1);
};
Counter.prototype.value = function() {
return _COUNTER;
};
return Counter;
})();
上述示例是一个 JavaScript 示例,而不是 TypeScript 示例。
上述代码片段声明了一个名为 Counter 的类。该类有一些用于增加、减少和读取名为 _COUNTER 的变量的方法。_COUNTER 变量本身不是对象原型的部分。
Counter 类的所有实例将共享相同的上下文,这意味着上下文(变量 counter 和函数 changeBy)将表现得像一个单例。
单例模式要求将对象声明为静态变量以避免在需要时创建其实例。因此,对象实例由应用程序的所有组件共享。单例模式在不需要类唯一实例的情况下经常被使用,在不必要的情况下引入全局状态到应用程序中。
现在我们知道了如何使用闭包来模拟静态变量:
let counter1 = new Counter();
let counter2 = new Counter();
console.log(counter1.value()); // 0
console.log(counter2.value()); // 0
counter1.increment();
counter1.increment();
console.log(counter1.value()); // 2
console.log(counter2.value()); // 2 (expected 0)
counter1.decrement();
console.log(counter1.value()); // 1
console.log(counter2.value()); // 1 (expected 0)
由闭包驱动的私有成员
在上一节中,我们了解到闭包可以访问它们创建的词法作用域之外持续存在的变量。这些变量不是函数原型或主体的部分,但它们是函数上下文的一部分。
由于我们无法直接调用函数上下文,上下文变量和方法可以用来在运行时模拟私有成员。使用闭包来模拟私有成员(而不是 TypeScript 私有访问修饰符)的主要优势是闭包将在运行时阻止对私有成员的访问。
TypeScript 避免在运行时模拟私有属性,因为如果我们在编译时尝试访问私有成员,编译器将抛出错误。TypeScript 避免使用闭包来模拟私有成员以提高应用程序性能。如果我们向我们的类之一添加或删除访问修饰符,生成的 JavaScript 代码将完全不会改变。这意味着类(在设计时)的私有成员在运行时成为公共成员。
然而,可以在运行时使用闭包来模拟私有属性。让我们看一个例子:
function makeCounter() {
// closure context
let _COUNTER = 0;
function changeBy(val: number) {
_COUNTER += val;
}
class Counter {
public increment() {
changeBy(1);
}
public decrement() {
changeBy(-1);
}
public value() {
return _COUNTER;
}
}
}
上述示例是一个 TypeScript 示例,而不是 JavaScript 示例。
上述类几乎与我们之前声明的类相同,以演示如何使用闭包在运行时模拟静态变量。
这次,每次我们调用 makeCounter 函数时,都会创建一个新的闭包上下文,因此每个新的 Counter 实例都将记住一个独立上下文(counter 和 changeBy):
let counter1 = makeCounter();
let counter2 = makeCounter();
console.log(counter1.value()); // 0
console.log(counter2.value()); // 0
counter1.increment();
counter1.increment();
console.log(counter1.value()); // 2
console.log(counter2.value()); // 0 (expected 0)
counter1.decrement();
console.log(counter1.value()); // 1
console.log(counter2.value()); // 0 (expected 0)
由于上下文不能直接访问,我们可以说变量 counter 和函数 changeBy 是私有成员:
console.log(counter1.counter); // undefined
counter1.changeBy(2); // changeBy is not a function
console.log(counter1.value()); // 1
摘要
在本章中,我们对运行时有了更深入的理解,这使我们不仅能够轻松解决运行时问题,而且能够编写更好的 TypeScript 代码。对闭包和原型的深入理解将使我们能够开发一些没有这种知识就无法实现的复杂功能。
在下一章中,我们将学习函数式编程(FP)范式。
第七章:使用 TypeScript 进行函数式编程
自 1995 年问世以来,JavaScript 就是一种多范式编程语言。它允许我们利用面向对象编程风格的优势,同时也允许我们利用函数式编程(FP)风格的优势。同样,TypeScript 也是如此。然而,TypeScript 比 JavaScript 更适合 FP(函数式编程),因为,正如我们将在本章中学习的,静态类型系统和类型推断在 FP(函数式编程)语言(如 ML 编程语言家族)中非常重要。
在过去 3 或 4 年中,JavaScript 和 TypeScript 生态系统对 FP(函数式编程)的兴趣显著增加。我相信这种兴趣增加的原因是 React 的成功。React 是由 Facebook 开发的一个用于构建用户界面的库,它深受一些核心 FP(函数式编程)概念的影响。
我们将在本书的末尾更多地了解 React,但就目前而言,我们将专注于学习如何仅使用 TypeScript 以及一些小的 FP(函数式编程)库(如 Immutable.js 和 Ramda)来使用一些基本的 FP(函数式编程)技术。
在本章中,你将学习以下内容:
-
纯函数
-
副作用
-
不可变性
-
函数阶数
-
高阶函数
-
函数组合
-
函数部分应用
-
柯里化和无点风格
-
管道和序列
-
类别论
FP 概念
当我们将函数式编程作为首选编程范式时,FP(函数式编程)的名字来源于我们构建应用程序的方式。
在面向对象编程等编程范式中,我们用来创建应用程序的主要构建块是对象(对象使用类声明)。然而,在 FP(函数式编程)中,我们使用函数作为应用程序中的主要构建块。
每种新的编程范式都会引入一系列与之相关的概念和思想。其中一些概念是通用的,在学习不同的编程范式时也很有兴趣。在面向对象编程中,我们有诸如继承、封装和多态等概念。在 FP(函数式编程)中,我们有高阶函数、函数部分应用、不可变性和引用透明性等概念。我们将尝试在本章中了解一些这些概念。
迈克尔·费思,SOLID 简称的作者以及许多其他著名的软件工程原则的作者,曾写道:
"面向对象编程通过封装移动部分使代码易于理解。函数式编程通过最小化移动部分使代码易于理解。"
前面的引用提到了移动部件
;我们应该将这些移动部件理解为状态变化(也称为状态突变)。在面向对象编程中,我们使用封装来防止对象意识到其他对象的状态变化。在 FP 中,我们试图避免处理可变状态,而不是封装它。FP 减少了应用程序中状态变化发生的地方的数量,并试图将这些地方移动到应用程序的边界内,以尝试保持应用程序的核心无状态。
可变状态是坏的,因为它使我们的代码的行为更难以预测。以以下函数为例:
function isIndexPage() {
return window.location.pathname === "/";
}
前面的代码片段声明了一个名为isIndexPage的函数。这个函数可以用来检查当前页面是否是 Web 应用程序中的根页面,基于当前路径。
路径是不断变化的数据,因此我们可以将其视为状态的一部分。如果我们尝试预测调用isIndexPage的结果,我们需要知道当前状态。问题是,我们可能会错误地假设状态自上次已知状态以来没有改变。我们可以通过将函数转换为纯函数来解决这个问题,正如我们将在下一节中学习的那样。
纯函数
FP 引入了一些概念和原则,这将帮助我们提高代码的可预测性。让我们看看这些核心概念之一:纯函数。
当一个函数仅使用传递给它的参数计算返回值时,可以认为它是纯函数。此外,纯函数避免修改其参数或任何其他外部变量。因此,纯函数在给定相同的参数时,无论何时调用,总是返回相同的值。
在上一节中声明的isIndexPage函数不是一个纯函数,因为它访问了pathname变量,并且它没有被作为参数传递给函数。我们可以通过以下方式重写前面的函数,将其转换为纯函数:
function isIndexPage(pathname: string) {
return pathname === "/";
}
尽管这是一个基本的例子,我们很容易就能感知到新版本要容易预测得多。纯函数帮助我们使代码更容易理解、维护和测试。
假设我们想要为isIndexPage函数的不纯版本编写单元测试。在尝试测试时,我们会遇到一些问题,因为该函数使用了window.location对象。我们可以使用模拟框架来克服这个问题,但这会为我们的单元测试增加很多复杂性,仅仅因为我们没有使用纯函数。
另一方面,测试isIndexPage函数的纯版本将会简单直接:
function shouldReturnTrueWhenPathIsIndex(){
let expected = true;
let result = isIndexPage("/");
if (expected !== result) {
throw new Error(`Expected ${expected} to equals ${result}`);
}
}
function shouldReturnFalseWhenPathIsNotIndex() {
let expected = false;
let result = isIndexPage("/someotherpage");
if (expected !== result) {
throw new Error(`Expected ${expected} to equals ${result}`);
}
}
现在我们已经了解了 FP 如何通过避免状态突变来帮助我们编写更好的代码,我们可以学习关于副作用和引用透明度。
副作用
在上一节中,我们了解到一个纯函数只返回一个值,这个值可以通过传递给它的参数来计算得出。纯函数还避免了修改其参数或任何未作为参数传递给函数的外部变量。在函数式编程(FP)术语中,通常说一个纯函数是一个没有副作用(side effects)的函数。这意味着当我们调用一个纯函数时,我们可以预期该函数不会通过状态修改来干扰我们应用程序中的任何其他组件。
一些编程语言,如 Haskell,可以通过其类型系统确保应用程序没有副作用。TypeScript 与 JavaScript 的互操作性非常好,但与像 Haskell 这样更隔离的语言相比,其缺点是类型系统无法保证我们的应用程序没有副作用。
如果你喜欢你的 JavaScript 应用程序没有副作用的想法,你可以尝试开源项目,如 github.com/bodil/eslint-config-cleanjs。该项目是一个 ESLint 配置,旨在限制你使用尽可能接近理想化纯函数式语言的 JavaScript 子集。不幸的是,在撰写本文时,没有可用的类似工具专门为 TypeScript 设计。
引用透明性
引用透明性是另一个与纯函数和副作用密切相关的概念。一个函数在无副作用时是纯的。当一个表达式可以被其对应值替换而不改变应用程序的行为时,我们说它是引用透明的。
纯函数是一个引用透明(referentially transparent)的表达式。一个非引用透明的表达式被称为引用不透明(referentially opaque)。
不可变性
不可变性指的是在给变量赋值后无法更改其值的能力。纯函数式语言包括常见数据结构的不可变实现。例如,当我们向数组添加一个元素时,我们正在修改原始数组。然而,如果我们使用不可变数组,并尝试向其中添加新元素,原始数组将不会被修改,我们将新项目添加到其副本中。
在 JavaScript 和 TypeScript 中,我们可以使用 Immutable.js 库来享受不可变的数据结构。
函数作为一等公民
在函数式编程(FP)文献中,经常提到函数作为 一等公民
的概念。当我们说一个函数是 "一等公民"
时,意味着它可以做任何变量能做的事情。这意味着函数可以作为参数传递给其他函数,或者由其他函数返回。函数也可以被分配给变量。JavaScript 和 TypeScript 都将函数视为 "一等公民"
。
Lambda 表达式
Lambda 表达式只是可以用来声明匿名函数(无名称的函数)的表达式。在 ES6 规范之前,将函数作为值赋给变量的唯一方法是使用函数表达式:
const log = function(arg: any) { console.log(arg); };
ES6 规范引入了箭头函数语法:
const log = (arg: any) => console.log(arg);
请参阅第三章,与函数一起工作,和第六章,理解运行时,以了解更多关于箭头函数和函数表达式的信息。
函数的元数
函数的元数是指函数接受的参数数量。单一函数是只接受一个参数的函数:
function isNull<T>(a: T|null) {
return (a === null);
}
单一函数在函数式编程(FP)中非常重要,因为它们促进了函数组合模式的运用。
我们将在本章后面更深入地了解函数组合模式。
二元函数是接受两个参数的函数:
function add(a: number, b: number) {
return a + b;
}
接受两个或更多参数的函数也很重要,因为一些最常用的函数式编程模式和技巧(例如,部分应用和柯里化)被设计成将接受多个参数的函数转换为单一参数函数。
我们将在本章后面更深入地了解部分应用和柯里化。
同样也存在接受三个(三元函数)或更多参数的函数。然而,在函数式编程中,接受可变数量参数的函数(称为可变参数函数)尤其有趣:
function addMany(...numbers: number[]) {
numbers.reduce((p, c) => p + c, 0);
}
高阶函数
高阶函数至少执行以下操作之一:
-
接受一个或多个函数作为参数
-
返回一个函数作为其结果
高阶函数是我们可以使用来以函数式编程风格编写 JavaScript 的最强大的工具之一。让我们看看一些例子。
以下代码片段声明了一个名为 addDelay 的函数。该函数创建了一个新的函数,该函数在控制台打印消息之前等待给定数量的微秒。该函数被认为是一个高阶函数,因为它将其参数之一作为一个函数:
function addDelay(msg: string, ms: number) {
return () => {
setTimeout(() => {
console.log(msg);
}, ms);
};
}
const delayedSayHello = addDelay("Hello world!", 500);
delayedSayHello(); // Prints "Hello world!" (after 500 ms)
以下代码片段声明了一个名为 addDelay 的函数。该函数将延迟微秒数添加到作为参数传递的另一个函数的执行中。该函数被认为是一个高阶函数,因为它返回一个函数:
function addDelay(func: () => void, ms: number) {
setTimeout(() => {
func();
}, ms);
}
function sayHello() {
console.log("Hello world!");
}
addDelay(sayHello, 500); // Prints "Hello world!" (after 500 ms)
以下代码片段声明了一个名为 addDelay 的函数。该函数创建了一个新的函数,该函数将延迟微秒数添加到作为参数传递的另一个函数的执行中。该函数被认为是一个高阶函数,因为它接受一个函数作为参数并返回一个函数:
function addDelay(func: () => void, ms: number) {
return () => {
setTimeout(() => {
func();
}, ms);
};
}
function sayHello() {
console.log("Hello world!");
}
const delayedSayHello = addDelay(sayHello, 500);
delayedSayHello(); // Prints "Hello world!" (after 500 ms)
高阶函数是抽象常见问题解决方案的有效技术。前面的例子演示了我们可以如何使用高阶函数(addDelay)向另一个函数(sayHello)添加延迟。这允许我们抽象延迟功能,并保持sayHello函数或其他函数对延迟功能的实现细节无感知。
函数式编程(FP)的好处
使用 FP 风格编写 TypeScript 代码有许多好处,其中我们可以强调以下几点:
-
可测试的代码:如果我们尝试编写纯函数,我们将能够非常容易地编写单元测试。
-
代码易于推理:对于缺乏 FP 经验的开发者来说,FP 可能很难理解。然而,当应用程序正确地使用 FP 范式实现时,结果是非常小的函数(通常是单行函数)和非常声明性的 API,这些 API 可以轻松地进行推理。
-
并发:我们的大多数函数都是无状态的,我们的实体也大多是状态无关的。我们将状态推离应用程序的核心,这使得我们的应用程序更有可能支持许多并发操作,并且更具可扩展性。
-
缓存:当我们可以预测给定函数的输出时,缓存结果的策略会变得简单得多。
TypeScript 是一种 FP 语言吗?
这个问题的答案是肯定的,但只是部分如此。TypeScript 是一种多范式编程语言,因此它包括了来自面向对象编程语言和 FP 语言的许多影响。
然而,如果我们将 TypeScript 视为一种 FP 语言,我们可以观察到它并不是一种纯粹的 FP 语言,因为例如,TypeScript 编译器并不强制我们的代码无副作用。
不是一个纯粹的 FP 语言不应被解读为负面的事情。TypeScript 为我们提供了一套广泛的功能,并允许我们利用面向对象编程语言和 FP 语言世界中的最佳功能。
FP 技术
现在我们已经了解了最常见的 FP 概念,是时候学习最常见的 FP 技术和模式了。
组合
功能组合是一种技术或模式,它允许我们将多个函数组合起来创建一个更复杂的函数。
以下代码片段声明了一个用于修剪字符串的函数和一个用于将文本转换为上档文本的函数:
const trim = (s: string) => s.trim();
const capitalize = (s: string) => s.toUpperCase();
我们可以创建一个函数,通过组合执行前面的操作:
const trimAndCapitalize = (s: string) => capitalize(trim(s));
变量trimAndCapitalize是一个函数,它使用s作为其参数调用trim函数,并将返回值传递给capitalize函数。我们可以如下调用trimAndCapitalize函数:
trimAndCapitalize(" hello world "); // "HELLO WORLD"
两个函数 f(x) 和 g(x) 的组合定义为 f(g(x)),这正是我们在 trimAndCapitalize 函数中所做的。然而,这种行为可以通过高阶函数来抽象化:
const compose = <T>(f: (x: T) => T, g: (x: T) => T) => (x: T) => f(g(x));
然后,我们可以使用上述函数来组合两个给定的函数:
const trimAndCapitalize = compose(trim, capitalize);
我们可以这样调用 trimAndCapitalize 函数:
trimAndCapitalize(" hello world "); // "HELLO WORLD"
有一个重要的事情需要注意,那就是 g 函数的结果被传递为 f 函数的参数。这意味着 f 只能接受一个参数(它必须是一元函数),并且其类型必须与 g 函数的返回类型相匹配。compose 函数的一个更正确的定义可能如下所示:
const compose = <T1, T2, T3>(
f: (x: T2) => T3,
g: (x: T1) => T2
) => (x: T1) => f(g(x));
我们还可以组合 composed 函数:
const composed1 = compose(func1, func2);
const composed2 = compose(func1, func2);
const composed3 = compose(composed1, composed2);
或者,我们可以在一次调用中声明一个高阶函数来组合三个函数:
const compose3 = <T1, T2, T3, T4>(
f: (x: T3) => T4,
g: (x: T2) => T3,
h: (x: T1) => T2
) => (x: T1) => f(g(h(x)));
我们还可以创建一个辅助函数,允许我们组合无限数量的函数:
const compose = (...functions: Array<(arg: any) => any>) =>
(arg: any) =>
functions.reduce((prev, curr) => {
return curr(prev);
}, arg);
函数组合是一种极其强大的技术,但在某些场景中可能难以实施,例如,当我们的函数不是一元函数时。然而,还有一些其他技术可以帮助在这些场景中。
请注意,整个示例都包含在配套源代码中。
部分应用
部分应用 是一种函数式编程技术,允许我们在不同的时间点传递函数所需的参数。
这种技术在第一眼看起来可能像是一个奇怪的想法,因为大多数软件工程师都习惯了在单一独特的时间点应用(也称为调用)一个函数(完整应用)的想法,而不是在多个时间点应用一个函数(部分应用)。
以下代码片段实现了一个不支持部分应用的功能,并在一个单一的时间点调用它(提供所有所需的参数):
function add(a: number, b: number) {
return a + b;
}
const result = add(5, 5); // All arguments are provided at the same time
console.log(result); // 10
以下代码片段实现了上述函数作为一个高阶函数,以便我们可以在不同的时间点提供所需的参数:
function add(a: number) {
return (b: number) => {
return a + b;
};
}
const add5 = add(5); // The 1st argument is provided
const result = add5(5); // The 2nd argument is provided later
console.log(result); // 10
如前述代码片段所示,第一个和第二个参数是在不同的时间点提供的。然而,这不能被视为函数部分应用的例子,因为这两个函数是一元函数,我们一次提供了一个参数。
我们还可以编写一个允许其完全应用和部分应用的功能:
function add(a: number, b?: number) {
if (b !== undefined) {
return a + b;
} else {
return (b2: number) => {
return a + b2;
};
}
}
const result1 = add(5, 5); // All arguments are
console.log(result1); // 10
const add5 = add(5) as (b: number) => number; // The 1st passed
const result2 = add5(5); // The 2nd argument is passed later
console.log(result2); // 10
上述示例可以被视为部分应用的例子,因为我们既可以应用带有所有参数的函数(完整应用),也可以应用其中的一些参数(部分应用)。
现在我们已经了解了函数部分应用的工作原理,让我们来关注一下它为什么是有用的。在先前的 组合 部分,我们学习了如何将名为 trim 和 capitalize 的两个函数组合成一个名为 trimAndCapitalize 的第三个函数:
const trim = (s: string) => s.trim();
const capitalize = (s: string) => s.toUpperCase();
const trimAndCapitalize = compose(trim, capitalize);
函数组合与一元函数配合得很好,但与二元或三元函数配合得不是很好。我们将声明以下函数来演示这一点:
const replace = (s: string, f: string, r: string) => s.split(f).join(r);
上述函数可以用来替换给定字符串中的子串。不幸的是,该函数不能与组合操作轻松地一起使用,因为它不是一个一元函数:
const trimCapitalizeAndReplace = compose(trimAndCapitalize, replace); // Error
然而,我们可以以允许我们部分应用函数的方式实现该函数:
const replace = (f: string, r: string) =>
(s: string) =>
s.split(f).join(r);
然后,我们可以无问题地使用组合函数。
const trimCapitalizeAndReplace = compose(
trimAndCapitalize,
replace("/", "-")
);
trimCapitalizeAndReplace(" 13/feb/1989 "); <// "13-FEB-1989"
多亏了我们对函数部分应用的知识,我们可以轻松地使用组合,而无需担心函数的 arity。然而,启用部分应用需要大量的手动样板代码。在下一节中,我们将学习一种称为 currying 的 FP 技术如何帮助我们解决这个问题。
请注意,整个示例包含在配套源代码中。
Currying
Currying 是一种 FP 技术,它允许我们在编写函数时无需担心部分应用。Currying 是将接受多个参数的函数转换成一系列一元函数的过程:
function curry2<T1, T2, T3>(fn: (a: T1, b: T2) => T3) {
return (a: T1) => (b: T2) => fn(a, b);
}
上述函数是一个高阶函数,它允许我们抽象出函数的部分应用功能:
function add(a: number, b: number) {
return a + b;
}
const curriedAdd = curry2(add);
const add5 = curriedAdd(5);
const addResult = add5(5);
console.log(addResult); // 10
curry2 函数允许我们将二元函数转换成两个一元函数。curry2 函数是一个高阶函数,并且可以与任何二元函数一起使用。例如,在先前的代码片段中,我们将加法函数传递给了 curry2 函数,但下面的例子中将乘法函数传递给了 curry2 函数:
function multiply(a: number, b: number) {
return a * b;
}
const curriedMultiply = curry2(multiply);
const multiplyBy5 = curriedMultiply(5);
const multiplyResult = multiplyBy5(5);
console.log(multiplyResult); // 25
在先前的 部分应用 部分,我们学习了如何使用部分应用来使用组合函数,这些函数不是一元的。我们声明了以下名为 replace 的函数,并将其传递给了 compose 函数:
const replace = (f: string, r: string) =>
(s: string) =>
s.split(f).join(r);
const trimCapitalizeAndReplace = compose(
trimAndCapitalize,
replace("/", "-")
);
我们可以声明一个名为 curry3 的函数,该函数将三元函数转换成一系列三个一元函数:
function curry3<T1, T2, T3, T4>(fn: (a: T1, b: T2, c: T3) => T4) {
return (a: T1) => (b: T2) => (c: T3) => fn(a, b, c);
}
然后,我们可以使用 curry3 函数以不关心函数部分应用实现细节的方式重写 replace 函数:
const replace = (s: string, f: string, r: string) =>
s.split(f).join(r);
const curriedReplace = curry3(replace);
const trimCapitalizeAndReplace = compose(
trimAndCapitalize,
curriedReplace("/")("-")
);
请注意,整个示例包含在配套源代码中。
管道
管道是一个函数或运算符,允许我们将一个函数的输出作为另一个函数的输入。JavaScript 和 TypeScript 不原生支持管道(作为一个运算符),但我们可以使用以下函数来实现我们的管道:
const pipe = <T>(...fns: Array<(arg: T) => T>) =>
(value: T) => fns.reduce((acc, fn) => fn(acc), value);
我们将使用在本章中先前声明的 curry3、trim、capitalize 和 replace 函数:
const trim = (s: string) => s.trim();
const capitalize = (s: string) => s.toUpperCase();
const replace = curry3(
(s: string, f: string, r: string) => s.split(f).join(r)
);
然后,我们可以使用管道函数声明一个新的函数:
const trimCapitalizeAndReplace = pipe(
trim,
capitalize,
replace("/")("-")
);
trimCapitalizeAndReplace(" 13/feb/1989 "); // "13-FEB-1989"
pipe 函数确保 trim 函数的输出被传递给 capitalize 函数。然后,capitalize 函数的返回值被传递给已经部分应用的 replace 函数。
有一个官方提案要为 JavaScript 添加一个名为管道操作符(|>)的新操作符。此操作符将允许我们像以下这样实现管道:
const result = " 13/feb/1989 "
|> trim
|> capitalize
|> replace("/")("-");
请参阅管道操作符提案:github.com/tc39/proposal-pipeline-operator。请注意,整个示例包含在配套源代码中。
无点风格
隐式编程,也称为无点风格,是一种编程风格,其中函数声明不声明它们操作的参数(或点
)。
以下代码片段声明了一些函数,这些函数用于确定一个人是否有资格参加选举:
interface Person {
age: number;
birthCountry: string;
naturalizationDate: Date;
}
const OUR_COUNTRY = "Ireland";
const wasBornInCountry = (person: Person) =>
person.birthCountry === OUR_COUNTRY;
const wasNaturalized = (person: Person) =>
Boolean(person.naturalizationDate);
const isOver18 = (person: Person) =>
person.age >= 18;
const isCitizen = (person: Person) =>
wasBornInCountry(person) || wasNaturalized(person);
const isEligibleToVote = (person: Person) =>
isOver18(person) && isCitizen(person);
isEligibleToVote({
age: 27,
birthCountry: "Ireland",
naturalizationDate: new Date(),
});
前面的代码片段没有使用我们在本章中学到的任何 FP 技术。下面的代码片段实现了相同问题的替代解决方案,使用了诸如偏应用等技巧。此代码片段声明了两个名为both和either的函数,可以用来确定一个变量是否匹配由提供给这些函数的某些或所有函数指定的要求:
const either = <T1>(
funcA: (a: T1) => boolean,
funcB: (a: T1) => boolean
) => (arg: T1) => funcA(arg) || funcB(arg);
const both = <T1>(
funcA: (a: T1) => boolean,
funcB: (a: T1) => boolean
) => (arg: T1) => funcA(arg) && funcB(arg);
interface Person {
age: number;
birthCountry: string;
naturalizationDate: Date;
}
const OUR_COUNTRY = "Ireland";
const wasBornInCountry = (person: Person) =>
person.birthCountry === OUR_COUNTRY;
const wasNaturalized = (person: Person) =>
Boolean(person.naturalizationDate);
const isOver18 = (person: Person) =>
person.age >= 18;
// Pointfree style
const isCitizen = either(wasBornInCountry, wasNaturalized);
const isEligibleToVote = both(isOver18, isCitizen);
isEligibleToVote({
age: 27,
birthCountry: "Ireland",
naturalizationDate: new Date(),
});
如我们所见,函数isCitizen和isEligibleToVote接受一些函数作为参数,但它们没有提及它们期望哪些数据作为参数。例如,我们可以编写以下代码:
const isCitizen = (person: Person) =>
wasBornInCountry(person) || wasNaturalized(person);
然而,我们可以这样写:
const isEligibleToVote = both(isOver18, isCitizen);
这种避免引用函数参数的风格被称为无点风格,它比更传统的函数声明风格有一些优势:
-
它使程序更简单、更简洁。这并不总是好事,但有时是。
-
它通过仅关注组合的函数来简化算法的理解;我们可以在数据参数干扰的情况下更好地理解正在发生的事情。
-
它迫使我们更多地思考数据的使用方式,而不是使用哪些数据。
-
它帮助我们将函数视为通用的构建块,可以与不同类型的数据一起工作,而不是将它们视为对某种数据进行的操作。
请注意,整个示例包含在配套源代码中。
递归
自调用的函数被称为递归函数。以下是一个递归函数,它允许我们计算给定数字n的阶乘。阶乘是所有小于或等于n的正整数的乘积:
const factorial = (n: number): number =>
(n === 0) ? 1 : (n * factorial(n - 1));
我们可以像以下这样调用前面的函数:
factorial(5); // 120
类别论
函数式编程(FP)因其数学背景而以难以学习和理解而闻名。FP 语言和设计模式受到源于许多不同数学领域的概念的影响。然而,我们可以将范畴论视为影响最大的领域之一。我们可以将范畴论视为集合论的一种替代品,它定义了一系列称为代数数据类型的数据结构或对象的背后理论。
代数数据类型有很多,理解它们的所有属性和必须实现的规则需要大量的时间和努力。以下图表说明了某些最常见代数数据类型之间的关系:

图表中的箭头表示给定的代数数据类型必须实现某些其他代数数据类型的规范。例如,Monad类型必须实现Applicative和Chain类型的规范。
开源项目fantasy – land声明了某些代数数据类型的规范,而开源项目ramda – fantasy则以与Ramda兼容的方式实现了这些规范,Ramda是一个我们将在本章后面探索的流行的 FP 库。
代数数据类型规范可以以多种方式实现。例如,fnctor 规范可以通过Maybe或Either数据类型实现。这两种类型都实现了Functor规范,但也可以实现其他规范,如 monad 或 applicative 规范。
以下表格描述了ramda – fantasy项目中哪些规范(列在顶部行)由代数数据类型实现(左侧行)实现:
| Name | Setoid | Semigroup | Functor | Applicative | Monad | Foldable | ChainRec |
|---|---|---|---|---|---|---|---|
| Either | ✓ | ✕ | ✓ | ✓ | ✓ | ✕ | ✓ |
| Future | ✕ | ✕ | ✓ | ✓ | ✓ | ✕ | ✓ |
| Identity | ✓ | ✕ | ✓ | ✓ | ✓ | ✕ | ✓ |
| IO | ✕ | ✕ | ✓ | ✓ | ✓ | ✕ | ✓ |
| Maybe | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Reader | ✕ | ✕ | ✓ | ✓ | ✓ | ✕ | ✕ |
| Tuple | ✓ | ✓ | ✓ | ✕ | ✕ | ✕ | ✕ |
| State | ✕ | ✕ | ✓ | ✓ | ✓ | ✕ | ✓ |
理解范畴论以及所有这些数据类型和规范超出了本书的范围。然而,在本章中,我们将学习两种最常见的代数数据类型的基础知识:Functor 和 Monad。
请参阅github.com/fantasyland/fantasy-land上的fantasy – land项目以及github.com/ramda/ramda-fantasy上的ramda – fantasy项目,以了解更多关于代数数据类型的信息。
Functor
Functor 是一个包含值并实现名为 map 的方法的对象。以下代码片段声明了一个名为 Container 的类。这个类可以被认为是一个 Functor:
class Container<T> {
private _value: T;
public constructor(val: T) {
this._value = val;
}
public map<TMap>(fn: (val: T) => TMap) {
return new Container<TMap>(fn(this._value));
}
}
我们可以这样使用容器:
let double = (x: number) => x + x;
let container = new Container(3);
let container2 = container.map(double);
console.log(container2); // { _value: 6 }
到目前为止,你可能觉得 Functor 并不是非常有用,因为我们已经实现了最基本版本。接下来的两个部分将实现两个名为 Maybe 和 Either 的已知 Functor。这两个 Functor 要有用得多,应该能证明 Functor 是一个强大的工具。
Applicative
Applicative 是一个实现名为 of 的方法的 Functor:
class Container<T> {
public static of<TVal>(val: TVal) {
return new Container(val);
}
private _value!: T;
public constructor(val: T) {
this._value = val;
}
public map<TMap>(fn: (val: T) => TMap) {
return new Container<TMap>(fn(this._value));
}
}
我们可以这样使用 Applicative:
let double = (x: number) => x + x;
let container = Container.of(3);
let container2 = container.map(double);
console.log(container2); // { _value: 6 }
请注意,整个示例包含在配套源代码中。
Maybe
下面的 Maybe 数据类型是一个 Functor 和一个 Applicative,这意味着它包含一个值并实现了 map 方法。与前面实现的 Functor 的主要区别在于,在 Maybe Functor 中,数据类型包含的值是可选的:
class MayBe<T> {
public static of<TVal>(val?: TVal) {
return new MayBe(val);
}
private _value!: T;
public constructor(val?: T) {
if (val) {
this._value = val;
}
}
public isNothing() {
return (this._value === null || this._value === undefined);
}
public map<TMap>(fn: (val: T) => TMap) {
if (this.isNothing()) {
return new MayBe<TMap>();
} else {
return new MayBe<TMap>(fn(this._value));
}
}
}
正如我们在前面 map 方法的实现中看到的,只有当 Maybe 数据类型包含值时,映射函数才会应用。
为了演示如何使用 Maybe 类型以及为什么它是有用的,我们将声明一个函数来获取 www.reddit.com 上的最新 TypeScript 新闻:
interface New {
subreddit: string;
id: string;
title: string;
score: number;
over_18: boolean;
url: string;
author: string;
ups: number;
num_comments: number;
created_utc: number;
}
interface Response {
kind: string;
data: {
modhash: string;
whitelist_status: boolean|null;
children: Array<{ kind: string, data: New }>;
after: string|null;
before: string|null;
};
}
async function fetchNews() {
return new Promise<MayBe<Response>>((resolve, reject) => {
const url = "https://www.reddit.com/r/typescript/new.json";
fetch(url)
.then((response) => {
return response.json();
}).then((json) => {
resolve(new MayBe(json));
}).catch(() => {
resolve(new MayBe());
});
});
}
前面的代码片段使用 fetch API 发送 HTTP 请求。这是一个异步操作,这也是为什么代码片段创建了一个 promise 实例。当操作成功完成时,响应以 Maybe 实例的形式返回。当操作未成功完成时,返回一个空的 Maybe 实例。
以下代码片段演示了如何使用 fetchNews 函数:
(async () => {
const maybeOfResponse = await fetchNews();
const maybeOfNews = maybeOfResponse
.map(r => r.data)
.map(d => d.children)
.map(children => children.map(c => c.data));
maybeOfNews.map((news) => {
news.forEach((n) => console.log(`${n.title} - ${n.url}`));
return news;
});
})();
前面的代码片段使用 fetchNews 从 Reddit 获取 TypeScript 相关的帖子列表。如果请求成功完成,fetchNews 函数将返回一个包装在 MayBe 实例中的 HTTP 响应。然后我们使用 map 方法来查找响应中的帖子列表。使用 MayBe 实例的好处是,只有当实际有响应时,映射逻辑才会执行,所以我们不需要担心潜在的 null 或 undefined 错误。
请注意,前面的例子使用了一些浏览器 API,这意味着我们需要在我们的 tsconfig.json 文件中将 dom 添加到 lib 字段中。这将防止出现无法找到名称 console 的编译错误。
请注意,整个示例包含在配套源代码中。
Either
Either 代数数据类型是 Just 和 Nothing 类型的并集:
type Either<T1, T2> = Just<T1> | Nothing<T2>;
Just 类型是一个 Functor,用于表示非空值:
class Nothing<T> {
public static of<TVal>(val?: TVal) {
return new Nothing(val);
}
private _value: T|undefined;
public constructor(val?: T) {
this._value = val;
}
public map<TMap>(fn: (val: T) => TMap) {
if (this._value !== undefined) {
return new Nothing<TMap>(fn(this._value));
} else {
return new Nothing<TMap>(this._value as any);
}
}
}
Nothing 类型表示值的缺失:
class Just<T> {
public static of<TVal>(val: TVal) {
return new Just(val);
}
private _value: T;
public constructor(val: T) {
this._value = val;
}
public map<TMap>(fn: (val: T) => TMap) {
return new Just<TMap>(fn(this._value));
}
}
以下代码片段是我们之前章节中声明的fetchNews函数的实现。这次的主要区别是,如果 HTTP 请求成功完成,我们将返回一个Just实例,如果 HTTP 请求没有成功完成,我们将返回一个Nothing实例:
interface New {
subreddit: string;
id: string;
title: string;
score: number;
over_18: boolean;
url: string;
author: string;
ups: number;
num_comments: number;
created_utc: number;
}
interface Response {
kind: string;
data: {
modhash: string;
whitelist_status: boolean|null;
children: Array<{ kind: string, data: New }>;
after: string|null;
before: string|null;
};
}
async function fetchNews() {
return new Promise<Either<Response, Error>>((resolve, reject) => {
const url = "https://www.reddit.com/r/typescript/new.json";
fetch(url)
.then((response) => {
return response.json();
}).then((json) => {
resolve(new Just(json));
}).catch((e) => {
resolve(new Nothing(e));
});
});
}
如果我们尝试在一个Either实例上使用map,我们将得到一个编译错误:
(async () => {
const maybeOfResponse = await fetchNews();
maybeOfResponse.map(r => r.message)
// Error:
// Cannot invoke an expression whose type lacks a call signature.
// Type
// (<TMap>(fn: (val: Response) => TMap) => Just<TMap>) |
// (<TMap>(fn: (val: Error) => TMap) => Nothin<TMap>'
// has no compatible call signatures.
})();
我们可以使用类型守卫来确保在请求失败时访问一个Nothing实例,如果请求无错误完成,则访问一个Just实例:
(async () => {
const maybeOfResponse = await fetchNews();
if (maybeOfResponse instanceof Nothing) {
maybeOfResponse
.map(r => r.message)
.map(msg => {
console.log(`Error: ${msg}`);
return msg;
});
} else {
const maybeOfNews = maybeOfResponse.map(r => r.data)
.map(d => d.children)
.map(children => children.map(c => c.data));
maybeOfNews.map((news) => {
news.forEach((n) => console.log(`${n.title} - ${n.url}`));
return news;
});
}
})();
使用Either的好处是编译器强制我们使用类型守卫。这意味着使用Either可以在处理操作中的潜在失败时提高类型安全性。
请注意,整个示例都包含在配套的源代码中。
模态
我们将通过学习关于模态的内容来完成对代数数据类型的介绍。Monad是一个Functor,但它还实现了Applicative和Chain规范。
我们可以通过添加两个额外的方法join和chain将之前声明的Maybe数据类型转换为Monad:
class MayBe<T> {
public static of<TVal>(val?: TVal) {
return new MayBe(val);
}
private _value!: T;
public constructor(val?: T) {
if (val) {
this._value = val;
}
}
public isNothing() {
return (this._value === null || this._value === undefined);
}
public map<TMap>(fn: (val: T) => TMap) {
if (this.isNothing()) {
return new MayBe<TMap>();
} else {
return new MayBe<TMap>(fn(this._value));
}
}
public join() {
return this.isNothing() ? Nothing.of(this._value) : this._value;
}
public chain<TMap>(fn: (val: T) => TMap) {
return this.map(fn).join();
}
}
Maybe数据类型已经是一个Functor和一个Applicative,但现在它也是一个Monad。以下代码片段展示了我们如何使用它:
let maybeOfNumber = MayBe.of(5);
maybeOfNumber.map((a) => a * 2);
// MayBe { value: 10 }
maybeOfNumber.join();
// 5
maybeOfNumber.chain((a) => a * 2);
// 10
let maybeOfMaybeOfNumber = MayBe.of(MayBe.of(5));
// MayBe { value: MayBe { value: 5 } }
maybeOfMaybeOfNumber.map((a) => a.map(v => v * 2));
// MayBe { value: MayBe { value: 10 } }
maybeOfMaybeOfNumber.join();
// MayBe { value: 5 }
maybeOfMaybeOfNumber.chain((a) => a.map(v => v * 2));
// MayBe { value: 10 }
上述代码片段展示了join和chain方法的工作方式。正如我们所见,当我们在一个Functor的Functor中,并且想要访问包含的值时,它们非常有用。chain方法只是join和map这两个操作的简化一步。
请注意,整个示例都包含在配套的源代码中。
实际世界的 FP
在本节中,我们将探讨一些在实际的 FP 应用程序中可能有用的开源库。
Immutable.js
正如我们在本章所学,函数式编程(FP)中的一个主要思想是在我们的应用程序中尽量减少状态发生变异的地方的数量。然而,在 JavaScript 中,对象不是不可变的,这可能会让我们不小心变异应用程序的状态。例如,我们可以尝试使用以下函数对数组进行排序:
function sort(arr: number[]) {
return arr.sort((a, b) => b - a);
}
前面的函数可能会导致问题,因为sort方法会变异原始数组。这个例子是关于所谓的隐式变异的演示。我们变异了应用程序的状态,但没有明确地这样做。Immutable.js帮助我们使应用程序中的所有变异都变得明确。
我们可以使用npm安装immutable:
npm install --save immutable
不需要类型定义,因为它们已经包含在immutable包中。
以下代码片段展示了如何将对象转换为不可变对象以及如何使用它们。不可变 API 包括set、mergeDeep或updateIn等方法,允许我们处理基本对象和嵌套对象:
Import * as immutable from "immutable";
const map1 = immutable.Map({ a: 1, b: 2, c: 3 });
const map2 = map1.set("b", 50);
console.log(`${map1.get("b")} vs.${map2.get("b")}`);
// 2 vs. 50
const nested = immutable.fromJS({ a: { b: { c: [ 3, 4, 5 ] } } });
const nested2 = nested.mergeDeep({ a: { b: { d: 6 } } });
// Map { a: Map { b: Map { c: List [ 3, 4, 5 ], d: 6 } } }
console.log(nested2.getIn([ "a", "b", "d" ]));
// 6
const nested3 = nested2.updateIn(
[ "a", "b", "d" ],
(value: string) => value + 1
);
console.log(nested3);
// Map { a: Map { b: Map { c: List [ 3, 4, 5 ], d: 7 } } }
const nested4 = nested3.updateIn(
[ "a", "b", "c" ],
(list: number[]) => list.push(6)
);
console.log(nested4);
// Map { a: Map { b: Map { c: List [ 3, 4, 5, 6 ], d: 7 } } }
请注意,整个示例都包含在配套的源代码中。
这些不可变对象不能被修改。方法返回原始对象的新副本,而不是修改它们。不可变使用一些智能算法和数据结构在对象之间共享一些内存,并在比较它们时尽可能高效。
Ramda
在实际应用中,我们不需要创建自己的函数式编程工具(例如,组合或柯里化函数)。我们可以使用已经实现这些辅助函数和许多其他函数的现有 JavaScript 库。其中之一就是 Ramda。
我们可以使用 npm 安装 Ramda:
npm install --save ramda
npm install --save-dev @types/ramda
Ramda 包含用于实现函数组合、柯里化和许多其他函数式编程技术的辅助函数,并且其 API 受到无参数风格的影响。
以下代码片段重新实现了本章 柯里化 部分中使用的示例,但使用了 Ramda 的组合和柯里化实现,而不是使用自定义实现:
import * as R from "ramda";const trim = (s: string) => s.trim();
const capitalize = (s: string) => s.toUpperCase();
const trimAndCapitalize = R.compose(trim, capitalize);
const replace = (s: string, f: string, r: string) =>
s.split(f).join(r);
const curriedReplace = R.curry(replace);
const trimCapitalizeAndReplace = R.compose(
trimAndCapitalize,
curriedReplace("/")("-")
);
trimAndCapitalizeReplace(" 13/feb/1989 "); // "13-FEB-1989"
请注意,整个示例包含在配套源代码中。
React 和 MobX
在本章的早期,我们了解到函数式编程减少了应用程序中状态变化发生的位置数量,并试图将这些位置移动到应用程序的边界内,以尝试保持应用程序的核心状态无状态。
React 和 MobX 是两个流行的开源库,可用于构建用户界面。这些库深受函数式编程的影响,并试图通过使用纯函数和不可变对象(由如 Immutable.js 这样的库提供支持)来防止状态修改。然而,状态修改必须在某个时刻发生。这就是 MobX 的主要作用,这是一个允许我们在 React 应用程序中管理状态的库。
在 MobX 应用程序中,新的状态应该只在一个称为 Store 的应用程序组件内生成。这是一个非常清晰的函数式编程架构示例,因为它将整个应用程序中的所有状态修改推到了一个唯一的位置。
请参阅第十一章,使用 React 和 TypeScript 进行前端开发,以了解更多关于 React 和 MobX 的信息。
摘要
我们通过学习一些主要的函数式编程(FP)概念开始本章,包括纯函数、高阶函数和不可变性等概念。
我们还学习了主要的函数式编程技术,包括函数组合、函数部分应用和柯里化等技术。
之后,我们学习了范畴论是什么以及如何处理一些代数数据类型。
最后,我们了解了一些可以帮助我们在实际应用中实现这些技术和概念的函数式编程库。
在下一章中,我们将学习如何使用装饰器。
第八章:使用装饰器
在本章中,我们将学习关于注解和装饰器的内容——这两个基于未来 ECMAScript 7 规范的新特性,但我们可以使用 TypeScript 1.5 或更高版本来使用它们。你将学习以下主题:
-
注解与装饰器
-
反射元数据 API
-
装饰器工厂
前置条件
本章中的 TypeScript 特性需要 TypeScript 1.5 或更高版本,并在tsconfig.json文件中启用以下选项:
"experimentalDecorators": true,
"emitDecoratorMetadata": true
如实验装饰器编译标志所示,装饰器的 API 被认为是实验性的。这并不意味着它不适合生产使用。这意味着装饰器 API 可能会在未来面临潜在的破坏性更改。
我们还需要一个reflect–metadata API 的 polyfill。我们需要 polyfill 是因为大多数 JavaScript 引擎目前还不支持这个 API。我们可以预期,从长远来看,这个 polyfill 将不再需要,但当前,我们可以使用reflect–metadata npm 模块:
npm install reflect-metadata
reflect-metadata的版本在撰写时为 0.1.12。请注意,示例包含在配套源代码中。这些示例可以使用 ts node 执行。例如,配套源代码中的第一个示例可以按以下方式执行:
ts-node chapters/chapter_08/01_class_decorator.ts
注解与装饰器的比较
注解是一种向类声明添加元数据的方式。然后,库和其他开发工具,如控制反转容器,可以使用这些元数据。注解 API 最初由 Google AtScript 团队提出,但注解不是标准。然而,装饰器是 ECMAScript 规范的一个提议标准,用于在设计时注解和修改类和属性。注解和装饰器基本上是相同的:
“注解和装饰器几乎是同一件事。从消费者角度来看,我们有完全相同的语法。唯一不同的是,我们无法控制如何将注解作为元数据添加到我们的代码中。装饰器更像是一个接口,用于构建最终成为注解的东西。然而,从长远来看,我们只需关注装饰器,因为那些是真正的提议标准。AtScript 是 TypeScript,TypeScript 实现了装饰器”。
– 帕斯卡尔·普雷希特
,《注解与装饰器的区别》
我们将使用以下类来展示如何使用装饰器:
class Person {
public name: string;
public surname: string;
public constructor(name: string, surname: string) {
this.name = name;
this.surname = surname;
}
public saySomething(something: string): string {
return `${this.name} ${this.surname} says: ${something}`;
}
}
可以使用四种装饰器来注解:类、属性、方法和参数。
类装饰器
TypeScript 官方装饰器提案定义类装饰器如下:
类装饰器函数是一个接受构造函数作为其参数的函数,并返回 undefined、提供的构造函数或一个新的构造函数。返回 undefined 等同于返回提供的构造函数。 – 朗·巴克顿,TypeScript 装饰器提案
类装饰器用于以某种方式修改类的构造函数。如果类装饰器返回undefined,则原始构造函数保持不变。如果装饰器返回值,则该返回值将用于覆盖原始类构造函数。以下类型声明了类装饰器的签名:
declare type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;
请注意,此签名可能在 TypeScript 的将来版本中发生变化。请参考 TypeScript 源代码中的lib.d.ts文件,以找到当前签名:github.com/Microsoft/TypeScript/blob/master/lib/lib.d.ts
我们将要创建一个名为logClass的类装饰器。我们可以从定义装饰器开始,如下所示:
function logClass(target: any) {
// ...
}
前面的类装饰器还没有任何逻辑,但我们已经可以将其应用于一个类。要应用装饰器,我们需要使用 at(@)符号:
@logClass
class Person {
public name: string;
public surname: string;
//...
如果我们将前面的代码片段编译成 JavaScript,TypeScript 编译器将生成一个名为__decorate的函数。我们不会检查__decorate函数的内部实现,但我们需要了解它用于在运行时应用装饰器,因为 JavaScript 本身不支持装饰器语法。我们可以通过检查编译前面提到的装饰过的Person类时生成的 JavaScript 代码来看到它的实际效果:
var Person = /** @class */ (function () {
function Person(name, surname) {
this.name = name;
this.surname = surname;
}
Person.prototype.saySomething = function (something) {
return this.name + " " + this.surname + " says: " + something;
};
Person = __decorate([
logClass
], Person);
return Person;
}());
如前述代码片段所示,Person类被声明,但它随后被传递给__decorate函数。__decorate函数返回的值被重新分配给Person类。现在我们知道了类装饰器是如何被调用的,让我们来实现它:
function logClass<TFunction extends Function>(target: TFunction) {
// save a reference to the original constructor
const originalConstructor = target;
function logClassName(func: TFunction) {
console.log("New: " + func.name);
}
// a utility function to generate instances of a class
function instanciate(constructor: any, ...args: any[]) {
return new constructor(...args);
}
// the new constructor behaviour
const newConstructor = function(...args: any[]) {
logClassName(originalConstructor);
return instanciate(originalConstructor, ...args);
};
// copy prototype so instanceof operator still works
newConstructor.prototype = originalConstructor.prototype;
// return new constructor (will override original)
return newConstructor as any;
}
类装饰器接受被装饰的类的构造函数作为其唯一参数。这意味着参数(命名为 target)是 Person 类的构造函数。装饰器首先创建类构造函数的副本,然后定义一个名为 instanciate 的实用函数,该函数可以用于生成类的实例。装饰器用于向装饰元素添加一些额外的逻辑或元数据。当我们尝试扩展函数的功能(方法或构造函数)时,我们需要用一个新的函数包装原始函数,该函数包含额外的逻辑并调用原始函数。在前面的装饰器中,我们添加了额外的逻辑来在控制台中记录创建新实例时的类名。为此,声明了一个新的类构造函数(命名为 newConstructor)。新的构造函数调用一个名为 logClassName 的函数,该函数实现了额外的逻辑并使用 instanciate 函数调用原始类构造函数。在装饰器的末尾,将原始构造函数的原型复制到新的构造函数中,以确保当 instanceof 操作符应用于装饰类的实例时,它仍然可以正常工作。最后,返回新的构造函数,并使用它来覆盖原始类构造函数。在装饰类构造函数后,创建了一个新的实例:
const me = new Person("Remo", "Jansen");
执行此操作后,以下文本将在控制台中显示:
"New: Person"
方法装饰器
TypeScript 官方装饰器提案将方法装饰器定义为如下:
"方法装饰器函数是一个接受三个参数的函数:拥有属性的实体、属性的键(一个字符串或符号),以及可选的属性描述符。该函数必须返回 undefined、提供的属性描述符,或者一个新的属性描述符。返回 undefined 等同于返回提供的属性描述符"。
— Ron Buckton,装饰器提案 - TypeScript
方法装饰器类似于类装饰器,但它用于覆盖方法,而不是用于覆盖类的构造函数。以下类型声明了方法装饰器的签名:
declare type MethodDecorator = <T>(target: Object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescriptor<T> | void;
请注意,此签名可能在 TypeScript 的未来版本中发生变化。请参阅 TypeScript 源代码中的 lib.d.ts 文件,以找到当前的签名:github.com/Microsoft/TypeScript/blob/master/lib/lib.d.ts
方法装饰器接受三个参数:被装饰的类(目标)、被装饰的方法的名称,以及被装饰属性的 TypePropertyDescriptor。属性描述符是一个用于描述类属性的对象。属性描述符包含以下属性:
interface TypedPropertyDescriptor<T> {
enumerable?: boolean;
configurable?: boolean;
writable?: boolean;
value?: T;
get?: () => T;
set?: (value: T) => void;
}
注意,属性描述符是一个可以通过调用 Object.getOwnPropertyDescriptor() 方法获得的对象。您可以在 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/getOwnPropertyDescriptor 上了解更多信息。
如果方法装饰器返回一个属性描述符,则返回的值将用于覆盖方法的属性描述符。现在让我们声明一个名为 logMethod 的方法装饰器,暂时不添加任何行为:
function logMethod(target: any, key: string, descriptor: any) {
// ...
}
我们可以将装饰器应用于 Person 类中的某个方法:
class Person {
public name: string;
public surname: string;
public constructor(name: string, surname: string) {
this.name = name;
this.surname = surname;
}
@logMethod
public saySomething(something: string): string {
return `${this.name} ${this.surname} says: ${something}`;
}
}
如果我们将前面的代码片段编译成 JavaScript,我们将能够观察到方法装饰器使用以下参数被调用:
-
包含被装饰方法的类的原型(
Person.prototype) -
被装饰的方法的名称(
saySomething) -
被装饰方法的属性描述符是
Object.getOwnPropertyDescriptor(Person.prototype, saySomething)
现在我们知道了装饰器参数的值,我们可以继续实现它:
function logMethod(
target: any,
key: string,
descriptor: TypedPropertyDescriptor<any>
) {
// save a reference to the original method
const originalMethod = descriptor.value;
function logFunctionCall(method: string, args: string, result: string) {
console.log(`Call: ${method}(${args}) => ${result}`);
}
// editing the descriptor/value parameter
descriptor.value = function(this: any, ...args: any[]) {
// convert method arguments to string
const argsStr = args.map((a: any) => {
return JSON.stringify(a);
}).join();
// invoke method and get its return value
const result = originalMethod.apply(this, args);
// convert result to string
const resultStr = JSON.stringify(result);
// display in console the function call details
console.log();
console.log(`Call: ${key}(${argsStr}) => ${resultStr}`);
// return the result of invoking the method
return result;
};
// return edited descriptor
return descriptor;
}
就像我们在实现类装饰器时做的那样,我们首先创建被装饰元素的副本。而不是通过类原型(target[key])访问方法,我们将通过属性描述符(descriptor.value)来访问它。然后我们创建一个新的函数来替换被装饰的方法。新函数调用原始方法,同时包含一些额外的逻辑,用于在每次调用时在控制台记录方法名称和其参数的值。在将装饰器应用于方法后,当方法被调用时,方法名称和参数将在控制台中被记录:
const person = new Person("Michael", "Jackson");
person.saySomething("Annie, are you ok?");
这样做后,以下文本将出现在控制台:
Call: saySomething("Annie, are you ok?") => "Michael Jackson says: Annie, are you ok?"
属性装饰器
TypeScript 官方装饰器提案定义方法属性如下:
属性装饰器函数是一个接受两个参数的函数:拥有属性的对象和属性的键(一个字符串或一个符号)。属性装饰器不返回。
—Ron Buckton, TypeScript 装饰器提案
以下类型声明了属性装饰器的签名:
declare type PropertyDecorator = (target: Object, propertyKey: string | symbol) => void;
请注意,此签名可能在 TypeScript 的未来版本中发生变化。请参阅 TypeScript 源代码中的 lib.d.ts 文件以获取当前签名:github.com/Microsoft/TypeScript/blob/master/lib/lib.d.ts
属性装饰器实际上就像方法装饰器。主要区别是属性装饰器不返回值,并且第三个参数(属性描述符缺失)不会传递给属性装饰器。让我们创建一个名为 logProperty 的属性装饰器来查看它是如何工作的:
function logProperty(target: any, key: string) {
// ...
}
我们可以在 Person 类的属性中使用它,如下所示:
class Person {
@logProperty
public name: string;
@logProperty
public surname: string;
public constructor(name: string, surname: string) {
this.name = name;
this.surname = surname;
}
public saySomething(something: string): string {
return `${this.name} ${this.surname} says: ${something}`;
}
}
正如我们迄今为止所做的那样,我们将实现一个装饰器,该装饰器将用一个新的属性覆盖被装饰的属性,这个新属性将表现得与原始属性完全一样,但会执行一个额外的任务——每当属性值发生变化时,在控制台中记录属性值:
function logProperty(target: any, key: string) {
// property value
let _val = target[key];
function logPropertyAccess(acces: "Set" | "Get", k: string, v: any) {
console.log(`${acces}: ${k} => ${v}`);
}
// property getter
const getter = function() {
logPropertyAccess("Get", key, _val);
return _val;
};
// property setter
const setter = function(newVal: any) {
logPropertyAccess("Set", key, newVal);
_val = newVal;
};
// Delete property. The delete operator throws
// in strict mode if the property is an own
// non-configurable property and returns
// false in non-strict mode.
if (delete target[key]) {
Object.defineProperty(target, key, {
get: getter,
set: setter,
enumerable: true,
configurable: true
});
}
}
在前面的装饰器中,我们创建了一个原始属性值的副本,并声明了两个函数:getter(当改变属性的值时被调用)和setter(当读取属性的值时被调用)分别。方法装饰器返回一个值,用于覆盖被装饰的元素。因为属性装饰器不返回值,所以我们不能覆盖被装饰的属性,但我们可以替换它。我们手动删除了原始属性(使用delete关键字),并使用Object.defineProperty函数以及之前声明的 getter 和 setter 函数创建了一个新的属性。在将装饰器应用于name属性后,我们将在控制台中观察到其值的变化:
const person = new Person("Michael", "Jackson");
// Set: name => Michael
// Set: surname => Jackson
person.saySomething("Annie, are you ok?");
// Get: name => Michael
// Get: surname => Jackson
参数装饰器
官方的装饰器提案定义参数装饰器如下:
"参数装饰器函数是一个接受三个参数的函数:拥有包含装饰参数的方法的对象、属性键(对于构造函数的参数为undefined)、以及参数的序号索引。这个装饰器的返回值被忽略"。
- Ron Buckton,装饰器提案 - TypeScript
以下类型声明了参数装饰器的签名:
declare type ParameterDecorator = (target: Object, propertyKey: string | symbol, parameterIndex: number) => void;
请注意,这个签名在 TypeScript 的未来版本中可能会发生变化。请参考 TypeScript 源代码中的lib.d.ts文件以找到当前的签名:github.com/Microsoft/TypeScript/blob/master/lib/lib.d.ts。
前面的装饰器和参数装饰器之间的主要区别是我们不能使用参数装饰器来扩展给定类的功能。让我们创建一个名为addMetadata的参数装饰器来查看它是如何工作的:
function addMetadata(target: any, key: string, index: number) {
// ...
}
我们可以将参数装饰器应用于一个参数,如下所示:
@logMethod
public saySomething(@addMetadata something: string): string {
return `${this.name} ${this.surname} says: ${something}`;
}
参数装饰器不返回值,这意味着我们无法覆盖作为参数传递给装饰器的原始方法。我们可以使用参数装饰器将一些元数据链接到被装饰的类。在下面的实现中,我们将添加一个名为log_${key}_parameters的数组作为类属性,其中key是包含被装饰参数的方法的名称:
function addMetadata(target: any, key: string, index: number) {
const metadataKey = `_log_${key}_parameters`;
if (Array.isArray(target[metadataKey])) {
target[metadataKey].push(index);
} else {
target[metadataKey] = [index];
}
}
为了允许装饰多个参数,我们检查新字段是否为数组。如果新字段不是数组,我们创建并初始化新字段为一个包含被装饰参数索引的新数组。如果新字段是数组,则将被装饰参数的索引添加到数组中。参数装饰器本身没有用处;它需要与方法装饰器一起使用,因此参数装饰器添加元数据,而方法装饰器读取它:
@readMetadata
public saySomething(@addMetadata something: string): string {
return `${this.name} ${this.surname} says: ${something}`;
}
以下方法装饰器的工作方式与本章先前实现的方法装饰器类似,但它将读取参数装饰器添加的元数据,并且在方法被调用时,它不会在控制台显示传递给方法的全部参数,而只会记录那些被装饰的参数:
function readMetadata(target: any, key: string, descriptor: any) {
const originalMethod = descriptor.value;
descriptor.value = function(...args: any[]) {
const metadataKey = `_log_${key}_parameters`;
const indices = target[metadataKey];
if (Array.isArray(indices)) {
for (let i = 0; i < args.length; i++) {
if (indices.indexOf(i) !== -1) {
const arg = args[i];
const argStr = JSON.stringify(arg);
console.log(`${key} arg[${i}]: ${argStr}`);
}
}
return originalMethod.apply(this, args);
}
};
return descriptor;
}
如果我们应用saySomething方法:
const person = new Person("Remo", "Jansen");
person.saySomething("hello!");
readMetadata装饰器将通过addMetadata装饰器在控制台显示参数的值以及哪些索引被添加到元数据(类属性名为_log_saySomething_parameters)中:
saySomething arg[0]: "hello!"
注意,在先前的例子中,我们使用类属性来存储一些元数据。然而,这不是推荐的做法。在本章的后面部分,你将学习如何使用reflection-metadata API;这个 API 专门设计用来生成和读取元数据,因此,当我们需要与装饰器和元数据一起工作时,推荐使用它。
带参数的装饰器
我们可以使用一种特殊的装饰器工厂来允许开发者配置装饰器的行为。例如,我们可以将一个字符串传递给类装饰器,如下所示:
@logClass("option")
class Person {
// ...
要能够向装饰器传递一些参数,我们需要用函数包装装饰器。包装函数接受我们选择的选项,并返回一个装饰器:
function logClass(option: string) {
return function(target: any) {
// class decorator logic goes here
// we have access to the decorator parameters
console.log(target, option);
};
}
这可以应用于本章中学到的所有类型的装饰器。
非常重要的是避免在内部函数中使用箭头函数,以防止运行时this操作符可能出现的潜在问题。
反射元数据 API
我们已经了解到装饰器可以用来修改和扩展类的方法或属性的行为。虽然这是一种深入了解装饰器的好方法,但不推荐使用装饰器来修改和扩展类的行为。相反,我们应该尝试使用装饰器向被装饰的类添加元数据。然后,其他工具可以消费这些元数据。
如果 TypeScript 团队实现了名为“装饰器突变”的未来功能,那么避免使用装饰器修改和扩展类行为的建议可能会在未来被推翻。你可以在github.com/Microsoft/TypeScript/issues/4881了解更多关于装饰器突变提案的状态。
在类中添加元数据的可能性可能看起来并不有用或令人兴奋,但在我看来,这是过去几年 JavaScript 发生的最伟大的事情之一。正如我们已知的那样,TypeScript 仅在设计时使用类型。然而,当运行时没有类型信息时,一些功能,如依赖注入、运行时类型断言、反射和测试期间的自动模拟都是不可能的。运行时缺少类型信息不再是问题,因为我们可以使用装饰器来生成元数据,而这些元数据可以包含所需类型信息。然后可以在运行时处理这些元数据。当 TypeScript 团队开始思考允许开发者生成类型信息元数据的最佳方式时,他们为这个目的保留了几个特殊的装饰器名称。想法是,当使用这些保留的装饰器装饰元素时,编译器会自动将类型信息添加到被装饰的元素中。保留的装饰器如下:
"TypeScript 编译器将尊重特殊的装饰器名称,并将额外的信息流入由这些装饰器注解的装饰器工厂参数中。
@type - 装饰器目标的类型的序列化形式
@returnType - 如果装饰器目标是函数类型,则为其返回类型的序列化形式;否则为 undefined
@parameterTypes - 如果装饰器目标是函数类型,则为其参数的序列化类型列表;否则为 undefined
@name - 装饰器目标的名称 "
– Jonathan Turner 的装饰器头脑风暴
稍后,TypeScript 团队决定使用反射元数据 API(ES7 提议的功能之一)而不是保留装饰器。想法几乎相同,但我们将使用一些保留的元数据键,而不是使用保留的装饰器名称,通过反射元数据 API 来检索元数据。TypeScript 文档定义了三个保留的元数据键:
-
类型元数据使用元数据键设计:type.
-
参数类型元数据使用元数据键设计:paramtypes.
-
返回类型元数据使用元数据键设计:returntype.
- Issue #2577 - TypeScript 官方仓库位于 GitHub.com
我们现在将学习如何使用反射元数据 API。我们需要首先安装reflect-metadatanpm 模块:
npm install reflect-metadata
我们不需要为reflect-metadatanpm 模块安装类型定义,因为它包含了类型定义。
我们可以按照以下方式导入reflect-metadatanpm 模块:
import "reflect-metadata";
reflect-metadata模块应该在整个应用程序中只导入一次,因为Reflect对象旨在成为全局单例。
如果你尝试在未导入reflect-metadata模块的 TypeScript 中使用reflect-metadata API,你需要在你的tsconfig.json文件中添加以下选项:
"types": [
"reflect-metadata"
]
然后,我们可以创建一个用于测试的类。我们将获取类属性之一在运行时的类型。我们将使用名为logType的property装饰器来装饰这个类:
class Demo1 {
@logType
public attr1: string;
public constructor(attr1: string) {
this.attr1 = attr1;
}
}
我们需要使用design:type作为元数据键来调用Reflect.getMetadata()方法。元数据值将返回为一个函数。例如,对于字符串类型,返回function String(){}函数。我们可以使用function.name属性来获取类型作为字符串:
function logType(target: any, key: string) {
const type = Reflect.getMetadata("design:type", target, key);
console.log(`${key} type: ${type.name}`);
}
如果我们编译前面的代码,并在浏览器中运行生成的 JavaScript 代码,我们将在控制台看到attr1属性的类型:
'attr1 type: String'
记住,要运行此示例,必须按照以下方式导入reflect-metadata库:
import "reflect-metadata";
我们可以类似地应用其他保留的元数据键。让我们创建一个具有许多参数的方法,并使用design:paramtypes保留的元数据键来检索参数的类型:
class Foo {}
interface FooInterface {}
class Demo2 {
@logParamTypes
public doSomething(
param1: string,
param2: number,
param3: Foo,
param4: { test: string },
param5: FooInterface,
param6: Function,
param7: (a: number) => void
): number {
return 1;
}
}
这次,我们将使用design:paramtypes保留的元数据键。我们正在查询多个参数的类型,因此Reflect.getMetadata()函数将返回一个数组:
function logParamTypes(target: any, key: string) {
const types = Reflect.getMetadata(
"design:paramtypes",
target,
key
);
const s = types.map((a: any) => a.name).join();
console.log(`${key} param types: ${s}`);
}
如果我们在浏览器中编译并运行前面的代码,我们将在控制台看到参数的类型:
'doSomething param types: String, Number, Foo, Object, Object,
Function, Function'
类型序列化遵循一些规则。我们可以看到函数被序列化为function,对象字面量({test : string})和接口被序列化为object。下表展示了不同类型的序列化方式:
| Type | Serialized |
|---|---|
| void | Undefined |
| string | String |
| number | Number |
| boolean | Boolean |
| symbol | Symbol |
| any | Object |
| enum | Number |
| Class C{} | C |
| Object literal {} | Object |
| interface | Object |
注意,一些开发者要求通过元数据访问接口的类型和类的继承树。这个特性被称为复杂类型序列化,在撰写本文时不可用。
为了总结,我们将创建一个具有返回类型的方法,并使用design:returntype保留的元数据键来检索返回类型的类型:
class Demo3 {
@logReturntype
public doSomething2(): string {
return "test";
}
}
就像在前两个装饰器中一样,我们需要调用Reflect.getMetadata()函数,并传递design:returntype保留的元数据键:
function logReturntype(target: any, key: string) {
const returnType = Reflect.getMetadata(
"design:returntype",
target,
key
);
console.log(`${key} return type: ${returnType.name}`);
}
如果我们在浏览器中编译并运行前面的代码,我们将在控制台看到返回类型的类型:
'doSomething2 return type: String'
装饰器工厂
官方的装饰器提案定义装饰器工厂如下:
装饰器工厂是一个可以接受任意数量参数的函数,并且必须返回装饰器函数的类型之一。
- Ron Buckton, TypeScript 装饰器提案
我们已经学会了实现类、属性、方法和参数装饰器。然而,在大多数情况下,我们将消费装饰器,而不是实现它们。例如,在InversifyJS中,我们使用@injectable装饰器来声明一个类将被注入到其他类中,但我们不需要实现@injectable装饰器。我们可以使用装饰器工厂使装饰器更容易消费。让我们考虑以下代码片段:
@logClass
class Person {
@logProperty
public name: string;
@logProperty
public surname: string;
public constructor(name: string, surname: string) {
this.name = name;
this.surname = surname;
}
@readMetadata
public saySomething(@addMetadata something: string): string {
return `${this.name} ${this.surname} says: ${something}`;
}
}
前述代码的问题是我们作为开发者需要知道logMethod装饰器只能应用于方法。这可能会显得微不足道,因为使用的装饰器名称(logMethod)使得它更容易识别。更好的解决方案是允许开发者使用名为@log的装饰器,而无需担心使用正确的装饰器类型:
@log
class Person {
@log
public name: string;
@log
public surname: string;
public constructor(name: string, surname: string) {
this.name = name;
this.surname = surname;
}
@log
public saySomething(@log something: string): string {
return `${this.name} ${this.surname} says: ${something}`;
}
}
我们可以通过创建一个装饰器工厂来实现这一点。装饰器工厂是一个函数,它可以识别所需的装饰器类型并返回它:
function decoratorFactory(
classDecorator: Function,
propertyDecorator: Function,
methodDecorator: Function,
parameterDecorator: Function
) {
return function (this: any, ...args: any[]) {
const nonNullableArgs = args.filter(a => a !== undefined);
switch (nonNullableArgs.length) {
case 1:
return classDecorator.apply(this, args);
case 2:
// break instead of return as property
// decorators don't have a return
propertyDecorator.apply(this, args);
break;
case 3:
if (typeof args[2] === "number") {
parameterDecorator.apply(this, args);
} else {
return methodDecorator.apply(this, args);
}
break;
default:
throw new Error("Decorators are not valid here!");
}
};
}
如前述代码片段所示,装饰器工厂是一个装饰器的工厂。生成的装饰器使用传递给装饰器的参数的数量和类型来识别适合每种情况的所需装饰器类型。装饰器工厂可以用来创建通用装饰器如下:
const log = decoratorFactory(
logClass,
logProperty,
readMetadata,
addMetadata
);
摘要
在本章中,我们学习了如何消费和实现四种可用的装饰器类型(类、方法、属性和参数)。我们使用装饰器来修改原始类以理解它们的工作原理,但也了解到我们应该避免使用装饰器来修改类的原型。我们还学习了如何创建装饰器工厂,以便在消费时抽象开发者对装饰器类型的关注,如何向装饰器传递配置,以及如何使用反射元数据 API 在运行时访问类型信息。正如我们已经提到的,TypeScript 中的装饰器仍然是一个实验性特性,这并不意味着它们不适合在生产系统中使用,但它们的公共 API 可能在将来可能会发生破坏性变化。请注意,未来的 TypeScript 版本将记录如何应对这些潜在的破坏性变化,如果它们最终发生的话。在下一章中,我们将学习如何配置高级 TypeScript 开发工作流程。
第九章:自动化开发工作流程
在前面的章节中,我们学习了 TypeScript 语法的主要元素及其类型系统的主要功能。在接下来的几章中,我们将关注 TypeScript 工具及其生态系统中的其他元素。
在本章中,我们将学习如何使用一些工具来自动化我们的开发工作流程。这些工具将帮助我们减少我们在一些琐碎和重复性任务上通常花费的时间。
在本章中,我们将学习以下主题:
-
版本控制工具
-
软件包管理工具
-
任务运行器
-
模块打包器
-
测试自动化和测试覆盖率
-
集成工具
-
脚手架工具
现代化开发工作流程
以高标准开发网络应用程序已经成为一项耗时的工作。如果我们想要实现出色的用户体验,我们需要确保我们的应用程序能够在许多不同的网页浏览器、设备、互联网连接速度和屏幕分辨率上尽可能顺畅地运行。此外,我们还需要花费大量时间在质量保证和性能优化任务上。
作为软件工程师,我们应该尽量减少我们在琐碎和重复性任务上花费的时间。这听起来可能很熟悉,因为我们已经这样做了很多年。我们最初是通过编写构建脚本(如 makefiles)或自动化测试开始的,而如今,在现代网页开发工作流程中,我们使用许多工具来尽可能地自动化尽可能多的任务。这些工具可以分为以下几类:
-
版本控制工具
-
软件包管理器工具
-
任务运行器
-
模块打包器
-
测试运行器
-
持续集成(CI)工具
-
脚手架工具
前置条件
我们即将学习如何在开发工作流程中自动化许多任务;然而,在此之前,我们需要在我们的开发环境中安装一些工具。
Node.js
Node.js 是一个基于 V8(谷歌的开源 JavaScript 引擎)构建的平台。Node.js 允许我们在网页浏览器之外运行 JavaScript。我们可以使用 TypeScript 和 Node.js 编写后端和桌面应用程序。
即使我们的计划是编写后端应用程序,我们也需要 Node.js,因为本章中使用的许多工具都是 Node.js 应用程序。
如果您在前面的章节中没有安装 Node.js,您可以从 nodejs.org/en/download/ 下载适用于您操作系统的安装程序。
Visual Studio Code
Visual Studio Code 是由微软开发的开源编辑器。围绕这个编辑器的开源社区非常活跃,已经开发了众多插件和主题。我们可以从 code.visualstudio.com/download 下载 Visual Studio Code。
我们还可以访问 Visual Studio 扩展面板(屏幕左侧的第五个图标)来浏览和安装扩展或主题。
Visual Studio Code 是开源的,并且适用于 Linux、OS X 和 Windows,因此它将适合大多数读者。如果你想在 Visual Studio 中工作,你将能够在 visualstudiogallery.msdn.microsoft.com/2d42d8dc-e085-45eb-a30b-3f7d50d55304 找到启用 TypeScript 支持的 Visual Studio 扩展。
Git 和 GitHub
在本章的结尾,我们将学习如何配置 CI 服务。CI 服务将观察我们应用程序代码中的更改,并确保这些更改不会破坏应用程序。
为了能够观察代码中的更改,我们需要使用源代码控制系统。目前有几种源代码控制系统可供选择。其中一些最广泛使用的包括 Subversion、Mercurial 和 Git。
源代码控制系统有许多好处,其中我们可以强调以下几点:
-
源代码控制工具使多个开发者能够在不丢失任何工作的情况下,通过一个开发者覆盖之前的更改来共同工作在一个源文件上。
-
源代码控制工具允许我们跟踪和审计源代码中的更改。这些功能非常有用,例如,在试图找出何时引入了新的错误时。
在处理本章的示例时,我们将对源代码进行一些更改。我们将使用 Git 和 GitHub 来管理这些更改。
我们需要访问 git-scm.com/downloads 下载 Git 安装程序。然后我们可以访问 github.com/ 创建一个 GitHub 账户。
GitHub 账户将提供几种不同的订阅计划。免费计划提供了我们跟随本书示例所需的一切。
伴随源代码
本书的相关源代码可以在网上找到,地址为 github.com/remojansen/LearningTypeScript。本章的源代码包括一个具有以下目录结构的小示例:

有两个主要文件,分别命名为 main_server.ts 和 main_browser.ts。这两个文件都位于 src 目录下。这些文件创建了一个名为 Calculator 的类的实例,并使用它执行一些操作。操作的结果被记录在控制台:
import chalk from "chalk";
import { Calculator } from "./calculator";
const calculator = new Calculator();
const addResult = calculator.calculate("add", 2, 3);
console.log(chalk.green(`2 + 3 = ${addResult}`));
const powResult = calculator.calculate("pow", 2, 3);
console.log(chalk.green(`2 + 3 = ${powResult}`));
main_browser.ts 文件在 HTML 元素内显示结果,而不是在控制台显示。main_browser.ts 文件还导入一个 .scss 文件,以展示我们如何使用 Webpack 与 .css 和 .scss 文件一起工作。
Calculator 类可以执行不同种类的数学运算,并在 src 目录下的 calculator.ts 文件中定义:
import { add } from "./operations/add";
import { pow } from "./operations/pow";
interface Operation {
name: string;
operation(a: number, b: number): number;
}
export class Calculator {
private readonly _operations: Operation[];
public constructor() {
this._operations = [
{ name: "add", operation: add },
{ name: "pow", operation: pow }
];
}
public calculate(operation: string, a: number, b: number) {
const opt = this._operations.filter((o) => o.name === operation)[0];
if (opt === undefined) {
throw new Error(`The operation ${operation} is not available!`);
} else {
return opt.operation(a, b);
}
}
}
Calculator 类只能执行两种操作。每个操作都在 operations 目录下的单独文件中定义。加法操作在 add.ts 文件中定义,如下所示:
import { isNumber } from "./validation";
export function add(a: number, b: number) {
isNumber(a);
isNumber(b);
return a + b;
}
pow 操作定义在 pow.ts 文件中,其形式如下:
import { isNumber } from "./validation";
export function pow(base: number, exponent: number) {
isNumber(base);
isNumber(exponent);
let result = base;
for (let i = 1; i < exponent; i++) {
result = result * base;
}
return result;
}
最后,isNumber 验证函数的形式如下:
export function isNumber(a: number) {
if (typeof a !== "number") {
throw new Error(`${a} must be a number!`);
}
}
我们将在本章的剩余部分使用这些文件,这意味着我们可能需要稍后返回来完全理解本章的其余内容。
版本控制工具
现在我们已经安装了 Git 并创建了 GitHub 账户,我们将使用 GitHub 来创建一个新的代码仓库。仓库是一个中央文件存储位置。源代码控制系统使用它来存储文件的多个版本。虽然仓库可以在本地机器上为单个用户配置,但它通常存储在服务器上,可以被多个用户访问。
要在 GitHub 上创建一个新的仓库,请登录到你的 GitHub 账户,然后在屏幕右上角点击创建新仓库的链接:

屏幕上会显示与以下截图相似的表单:

此表单包含一些字段,允许我们设置仓库的名称、描述和一些隐私设置。
请注意,如果你想要使用私有仓库,你需要一个付费的 GitHub 账户。
我们还可以添加一个 README.md 文件,它使用 Markdown 语法,并用于将我们想要添加到 GitHub 仓库主页上的任何文本。此外,我们还可以添加一个默认的 .gitignore 文件,该文件用于指定我们希望 Git 忽略的文件,因此这些文件不会被保存到仓库中。
默认的 .gitignore 文件推荐选项是 Node。本书中我们将使用 GitHub。然而,如果你想要使用本地仓库,你可以使用 Git 的 init 命令来创建一个空仓库。有关 git init 命令和本地仓库操作的更多信息,请参考 Git 文档git-scm.com/docs/git-init。
最后,但同样重要的是,我们还可以选择一个软件许可来覆盖我们的源代码。
一旦我们创建了仓库,我们将访问 GitHub 上的个人资料页面,找到我们刚刚创建的仓库,并访问它。在仓库的主页上,我们可以在页面的右上角找到克隆 URL:

克隆 URL
我们需要复制仓库的克隆 URL,打开控制台,并将 URL 作为以下命令的参数:
git clone https://github.com/user-name/repository-name.git
有时 Windows 的命令行界面(CLI)无法找到 Git 和 Node 命令。绕过这个问题的最简单方法是用 Git 控制台(与 Git 一起安装)而不是使用 Windows 命令行。如果您想使用 Windows 控制台,您需要手动将 Git 和 Node 的安装路径添加到 Windows 的PATH环境变量中。如果您使用的是 OS X 或 Linux,默认的 CLI 应该可以正常工作。此外,请注意,在所有示例中,我们将使用 Unix 路径语法。
命令的输出应该类似于以下内容:
Cloning into 'repository-name'...
remote: Counting objects: 3, done.
remote: Compressing objects: 100% (3/3), done.
remote: Total 3 (delta 2), reused 0 (delta 0), pack-reused 0
Unpacking objects: 100% (3/3), done.
Checking connectivity... done.
然后,我们可以使用更改目录命令(cd)进入仓库目录,并使用git status命令来检查本地仓库状态。命令的输出应该类似于以下内容:
cd repository-name
git status
On branch master
Your branch is up-to-date with 'origin/master'.
nothing to commit, working directory clean
git status命令告诉我们工作目录中没有更改。现在,让我们在 Visual Studio Code 中打开仓库文件夹,创建一个名为gulpfile.js的新文件。现在,再次运行git status命令,我们应该看到一些新的未跟踪文件:
On branch master
Your branch is up-to-date with 'origin/master'.
Untracked files:
(use "git add <file>..." to include in what will be committed)
gulpfile.js
nothing added to commit but untracked files present (use "git add" to
track)
Visual Studio Code 中的项目资源管理器使用颜色代码显示文件,以帮助我们识别自上次提交以来文件是否是新添加的(绿色)、已被删除(红色)或已更改(黄色)。
当我们进行一些更改,例如添加新文件或更改现有文件时,我们需要执行git add命令来指示我们想要将此更改添加到快照中:
git add gulpfile.js
git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)
new file: gulpfile.js
现在我们已经将想要快照的内容放入暂存区,我们必须运行git commit命令来实际记录快照。记录快照需要注释字段,可以使用git commit命令与-m参数一起提供:
git commit -m "added the new gulpfile.js"
要运行上述命令,您需要在终端上配置您的 GitHub 电子邮件/用户名。您可以使用以下任一命令在终端上配置 GitHub 账户:
git config --global user.email "you@example.com"
git config --global user.name "Your Name"
如果一切顺利,命令输出应该类似于以下内容:
[master 2a62321] added the new file gulpfile.js
1 file changed, 1 insertions(+)
create mode 100644 gulpfile.js
要与其他开发者共享提交,我们需要将我们的更改推送到远程仓库。我们可以通过执行git push命令来完成:
git push
git push命令将要求我们输入 GitHub 用户名和密码,然后将更改发送到远程仓库。如果我们访问 GitHub 上的仓库页面,我们将能够找到最近创建的文件。我们将在本章的后面部分回到 GitHub 以配置我们的 CI 服务器。
如果我们与一个大型团队一起工作,在尝试将一些更改推送到远程仓库时可能会遇到一些文件冲突。解决文件冲突超出了本书的范围;然而,如果您需要有关 Git 的更多信息,您将在www.kernel.org/pub/software/scm/git/docs/user-manual.html找到一个详尽的用户手册。
包管理工具
包管理工具用于依赖管理,这样我们就不必手动下载和管理应用程序的依赖项。在本章中,我们不会涵盖包管理工具,因为我们已经在第五章与依赖项一起工作中介绍过它们。
TypeScript 编译器
现在我们已经学会了如何使用npm,我们可以使用以下命令安装 TypeScript:
npm install typescript -g
TypeScript 编译器将在我们的 CLI 中以名为tsc的命令的形式可用。我们可以使用以下命令检查机器上安装的 TypeScript 版本:
tsc -v
TypeScript 编译器接受许多其他选项。例如,我们可以使用--target或-t选项来选择我们希望作为编译输出的 JavaScript 版本:
tsc --target es6
或者,我们可以创建一个tsconfig.json文件来设置所需的编译设置。我们还可以使用 TypeScript 编译器使用以下命令自动生成具有默认设置的tsconfig.json文件:
tsc --init
在创建tsconfig.json文件后,您可以使用--project或-p选项将其传递给 TypeScript 编译器:
tsc -p tsconfig.json
如果您想了解更多关于可用编译选项的信息,请参阅官方 TypeScript 文档www.typescriptlang.org/docs/handbook/compiler-options.html。
单元测试和测试覆盖率
单元测试是指测试我们代码中某些函数和区域(单元)的实践。这使我们能够验证我们的函数是否按预期工作。
预期读者对单元测试过程有一定了解,但这里所涉及的内容将在第十四章应用测试中更详细地介绍。
在本章的开头,我们包括了本章配套源代码中包含的应用程序最重要的部分。源代码定义了一个支持两个操作pow和add的计算器。
pow操作期望两个数字作为其参数,并有两个可能的执行路径:
-
如果提供的两个参数中有一个不是数字,
pow函数将抛出异常 -
如果两个参数都是有效的数字,
pow函数将返回一个数字
理想情况下,我们将为函数的每个执行路径编写一个单元测试。
以下代码片段使用两个测试库mocha和chai为pow操作声明了几个单元测试:
import { expect } from "chai";
import { pow } from "../src/operations/pow";
describe("Operation: pow", () => {
it ("Should be able to calculate operation", () => {
const result = pow(2, 3);
expect(result).to.eql(8);
});
it ("Should throw if an invalid argument is provided", () => {
const a: any = "2";
const b: any = 3;
const throws = () => pow(a, b);
expect(throws).to.throw();
});
});
上述代码片段将所有与pow操作相关的测试组合在所谓的测试夹具中。测试夹具可以使用describe函数定义,它只是一组测试用例。测试用例可以使用it函数定义,可以包含一个或多个测试断言。
我们的测试断言是通过 assert 函数定义的,它是 Chai 库的一部分。
describe 和 it 函数都是 Mocha 声明的全局函数。我们不需要导入 Mocha,因为它是通过我们的测试运行器配置作为全局导入的。
我们可以使用以下命令运行测试并生成测试覆盖率报告:
nyc -x **/*.js --clean --all --require ts-node/register --extension .ts -- mocha --timeout 5000 **/*.test.ts
前面的命令使用了 nyc,这是一个用于生成测试覆盖率报告的工具。该工具使用 ts-node 和 mocha 来运行测试。这也解释了为什么我们可以不编译测试代码就运行测试,以及为什么我们可以使用 Mocha 而不必显式导入。
这个命令不是很方便,但我们可以声明一个 npm 命令来解决这个问题:
"scripts": {
"nyc": "nyc -x **/*.js --clean --all --require ts-node/register --extension .ts -- mocha --timeout 5000 **/*.test.ts"
}
请注意,我们需要使用 npm 安装所有这些依赖项及其类型定义(当适用时)。然后我们可以使用以下 npm 命令:
npm run nyc
伴随的源代码包含更多的测试。现有的测试覆盖了应用程序中所有现有函数的大多数可能的执行路径,但并没有完全覆盖 main_server.ts 文件。这可以在 nyc 生成的测试覆盖率报告中观察到:

代码风格检查工具
以下工具是一个代码风格检查工具。代码风格检查工具帮助我们强制执行代码库中的某些代码风格规则。例如,在一个大型开发团队中,关于代码风格的长时间讨论是非常常见的。
术语 代码风格 指的是我们代码的某些外观元素,例如使用空格或制表符。然而,有时代码风格涉及某些规则,这些规则不仅仅是外观上的,而是旨在使我们的代码更易于维护。一个很好的例子是强制使用尾随逗号的代码风格规则。
代码风格指南和规则很好,但执行它们可能需要大量的人工努力。我们必须审查每一个代码更改,以确保代码贡献符合我们的代码风格规则。
代码风格检查工具的主要目标是自动化执行代码风格规则的实施。
在 TypeScript 世界中,主要的代码风格检查工具是 tslint。我们可以使用 npm 安装 tslint:
npm install -g tslint
然后,我们需要创建一个 tslint.json 文件。该文件包含允许我们启用和禁用某些风格规则的配置。以下是一个 tslint.json 文件的代码片段示例:
{
"extends": "tslint:all",
"rules": {
"array-type": [true, "array"],
"ban-types": false,
"comment-format": false,
"completed-docs": false,
"cyclomatic-complexity": false,
"interface-name": false,
"linebreak-style": false,
"max-classes-per-file": false,
"max-file-line-count": false,
"max-line-length": [true, 140],
"member-ordering": false,
"newline-before-return": false,
"no-any": false,
"no-empty-interface": false,
"no-floating-promises": false,
"no-import-side-effect": false,
"no-inferred-empty-object-type": false,
"no-magic-numbers": false,
"no-namespace": false,
"no-null-keyword": false,
"no-parameter-properties": false,
"no-submodule-imports": false,
"no-unbound-method": false,
"no-unnecessary-class": false,
"no-unnecessary-qualifier": false,
"no-unsafe-any": false,
"no-reference": false,
"no-void-expression": false,
"only-arrow-functions": false,
"prefer-function-over-method": false,
"prefer-template": false,
"promise-function-async": false,
"space-before-function-paren": false,
"strict-boolean-expressions": false,
"strict-type-predicates": false,
"switch-default": false,
"trailing-comma": false,
"typedef": false,
"variable-name": false
}
}
在创建 tslint.json 文件后,我们可以使用以下命令检查我们的源代码:
tslint --project tsconfig.json -c tslint.json ./**/*.ts
前面的命令并不太方便。我们可以使用 npm 脚本来创建一个更方便的命令,命名为 lint:
"scripts": {
"lint": "tslint --project tsconfig.json -c tslint.json ./**/*.ts"
}
然后,我们可以使用以下 npm 命令运行 tslint:
npm run lint
使用 npm 脚本
package.json 文件包含一个名为 scripts 的字段。该字段可以包含多个条目,每个条目用于创建一个命令。一个命令可以执行任何类型的自定义逻辑。
当我们使用npm init命令创建package.json文件时,默认命令尚未实现:
"scripts": {
"test": "echo "Error: no test specified" && exit 1"
},
在实际场景中,我们会有多个命令,如下面的示例所示:
{
"name": "repository-name",
"version": "1.0.0",
"description": "example",
"main": "index.html",
"scripts": {
"start": "node ./src/index.js",
"test": "gulp test",
"lint": "tslint -c tslint.json ./**/*.ts"
},
"repository": {
"type": "git",
"url": "https://github.com/username/repository-name.git"
},
一些命令,例如test、install或start命令,被视为标准命令。您可以通过使用npm命令后跟标准命令的名称来执行标准命令:
npm test
npm start
对于不被视为标准的命令,我们需要使用npm命令后跟run命令和命令的名称:
npm run lint
请参阅docs.npmjs.com上的 npm 文档,了解更多关于所有 npm 功能的信息。
Gulp
两个最受欢迎的 JavaScript 任务运行器是 Grunt 和 Gulp。Gulp 与 Grunt 的主要区别在于,在 Grunt 中,我们使用文件作为任务输入和输出,而在 Gulp 中,我们使用流和管道。
Grunt 使用一些配置字段和值进行配置。然而,Gulp 更倾向于代码而非配置。这种方法使 Gulp 配置在某些方面更加简约且易于阅读。
在这本书中,我们将使用 Gulp;然而,如果您想了解更多关于 Grunt 的信息,可以在gruntjs.com/学习。
为了更好地理解 Gulp,我们将配置一些任务。
让我们先使用npm安装gulp:
npm install gulp -g
然后,让我们在我们的项目根目录内创建一个名为gulpfile.js的 JavaScript 文件,它应该包含以下代码片段:
let gulp = require("gulp");
gulp.task("hello", function() {
console.log("Hello Gulp!");
});
最后,运行gulp:
gulp hello
请注意,我们使用-g标志安装了 Gulp,因为我们打算直接从命令行界面调用gulp命令。然而,如果我们打算使用npm scripts,我们应该使用--save-dev标志将 Gulp 和任何其他依赖项作为项目依赖项安装。请注意,使用全局(-g)依赖项是不推荐的。另外,请注意,我们必须从gulpfile.js文件所在的目录执行此命令。
我们已经创建了我们第一个 Gulp 任务,该任务命名为hello。当我们运行gulp命令时,它将自动尝试在当前目录中搜索gulpfile.js,一旦找到,它将尝试找到hello任务。如果一切按预期进行,我们应该在我们的 CLI 中看到以下类似的输出:
Using gulpfile
Starting 'hello'...
Hello Gulp!
Finished 'hello' after 255 μs
现在,我们将添加第二个任务,该任务将使用gulp-tslint插件来检查我们的 TypeScript 代码是否遵循一系列推荐的最佳实践。
我们需要使用npm安装该插件:
npm install tslint gulp-tslint -g
我们可以在我们的gulpfile.js文件中加载该插件并添加一个新任务:
let tslint = require("tslint");
let gulpTslint = require("gulp-tslint");
gulp.task("lint", function() {
let program = tslint.Linter.createProgram("./tsconfig.json");
return gulp.src([
"src/**/**.ts",
"test/**/**.test.ts"
])
.pipe(gulpTslint({
formatter: "stylish",
program
}))
.pipe(gulpTslint.report());
});
我们将新任务命名为lint。让我们一步一步地查看lint任务执行的操作:
-
gulp src函数将读取位于src目录及其子目录中具有.ts文件扩展名的文件。我们还在test目录及其子目录中检索所有具有.test.ts文件扩展名的文件。 -
许多插件允许我们通过在路径前添加感叹号(
!)来指示要忽略的文件。例如,路径!path/*.d.ts将忽略所有扩展名为.d.ts的文件。 -
pipe函数用于将src的输出流作为gulpTslint函数的输入。 -
最后,我们将
tslint函数的输出作为report函数的输入。
现在我们已经添加了lint任务,我们将修改gulpfile.js文件以添加一个名为default的额外任务:
gulp.task("default", ["hello", "lint"]);
默认任务可以用来调用hello和lint任务。当我们定义一个gulp任务时,我们使用两个参数调用task函数。第一个参数是任务名称。第二个参数可以是一个定义任务的函数或包含子任务列表的数组。
控制 Gulp 任务执行顺序
我们现在将学习如何控制任务执行的顺序。如果我们尝试执行默认任务,hello和lint任务将并行执行,因为子任务默认是并行执行的。
有时我们需要按照特定的顺序运行我们的任务。在 Gulp 中,所有任务默认都是异步的,因此控制任务执行顺序可能会很具挑战性。
然而,有三种方法可以使任务变为同步。
将回调函数传递给任务定义函数
我们需要做的就是将一个回调函数传递给任务定义函数,如下所示:
gulp.task("sync", function (cb) {
// We used setTimeout here to illustrate an async operation
setTimeout(function () {
console.log("Hello Gulp!");
cb(); // note the cb usage here
}, 1000);
});
返回一个承诺
我们需要做的就是使用承诺作为任务定义函数的返回值,如下所示:
gulp.task("sync", function () {
return new Promise((resolve) => {
setTimeout(function () {
console.log("Hello Gulp!");
resolve();
}, 1000);
});
});
返回一个流
我们需要做的就是使用流作为任务定义函数的返回值。这很简单,因为管道操作符返回一个流:
gulp.task("sync", function () {
return gulp.src([
"src/**/**.ts"
])
.pipe(somePlugin({}))
.pipe(somePlugin ());
});
现在我们已经有了一些同步任务,我们可以将它们作为名为async的新任务的子任务使用:
gulp.task("async", ["sync1", "sync2"], function () {
// This task will not start until
// the sync tasks are completed!
console.log("Done!");
});
如前述代码片段所示,我们也可以定义一个包含一些子任务的任务。然而,如果我们的构建过程变得复杂,我们可能会得到一个非常难以追踪的任务依赖图。幸运的是,我们可以通过npm安装run-sequence Gulp 插件,这将使我们能够更好地控制任务执行顺序:
let runSequence = require('run-sequence');
gulp.task('default', function(cb) {
runSequence(
'lint', // lint
['tsc', 'tsc-tests'], // compile
['bundle-js','bundle-test'], // optimize
'karma' // test
'browser-sync', // serve
cb // callback
);
});
上述代码片段将按以下顺序运行:
-
lint -
并行执行
tsc和tsc-tests -
并行执行
bundle-js和bundle-test -
karma -
browser-sync
Gulp 开发团队宣布了改进任务执行顺序管理的计划,无需外部插件。请参阅 Gulp 文档github.com/gulpjs/gulp/以了解更多信息。
Webpack
如我们所知,当我们编译 TypeScript 代码时,编译器将为每个现有的 TypeScript 文件生成一个 JavaScript 文件。如果我们在一个网页浏览器中运行应用程序,这些文件本身并不是非常有用,因为使用它们唯一的方法是为每个文件创建一个单独的 HTML script 标签。
然而,这将非常不方便且效率低下,因为每个 script 标签都会在浏览器和提供 JavaScript 文件的服务器之间产生往返。使用 script 标签也比使用 Ajax 调用慢,因为 script 标签可以阻止浏览器渲染。
请注意,在现代浏览器中,我们可以使一些 script 标签异步,但这并不总是可行的选项。请参阅developers.google.com/web/fundamentals/performance/critical-rendering-path/adding-interactivity-with-javascript 了解有关异步脚本的更多信息。
幸运的是,有几个潜在的解决方案可以解决这个问题:
-
使用 AJAX 调用加载文件:我们可以使用一个工具按需使用 AJAX 调用加载每个 JavaScript 文件。这种方法被称为异步模块加载。为了遵循这种方法,我们需要使用 Require.js 并更改 TypeScript 编译器的配置以使用异步模块定义(AMD)语法。
-
将所有文件打包成一个唯一的文件并使用脚本标签加载:我们可以使用一个工具跟踪应用程序模块和依赖项,并生成一个高度优化的单个文件,该文件将包含所有应用程序模块。为了遵循这种方法,我们需要使用工具,如 Webpack 或 Browserify,并更改 TypeScript 编译器的配置以使用正确的模块语法(通常是 CommonJS)。
-
混合方法:我们可以通过创建一个高度优化的包,其中包含应用程序运行所需的最少文件数,来遵循混合方法。当应用程序用户需要时,额外的文件将按需使用 AJAX 调用加载。
请参阅第五章,与依赖项一起工作,以了解更多关于模块的信息。
在本章中,我们将关注第二种方法。我们将使用 Webpack 创建一个高度优化的模块打包器。创建一个高度优化的应用程序包通常涉及多个步骤;Webpack 可以执行这些任务中的每一个,但它并不是真正设计用来作为任务运行器的。我们可以使用 npm 安装 Webpack:
npm install webpack --save-dev
我们还将安装一些由本例所需的额外模块:
npm install awesome-typescript-loader css-loader extract-text-webpack-plugin node-sass resolve-url-loader sass-loader style-loader --save-dev
大多数这些模块都是 Webpack 插件及其依赖项。我们需要它们,因为我们将要在我们的 Webpack 配置文件中使用它们。
我们可以通过在项目的根目录中创建一个名为 webpack.config.js 的文件来配置 Webpack。以下代码部分显示了本章配套源代码中使用的整个 Webpack 配置文件的内容。
webpack.config.js 文件正在导入一些依赖项:
const { CheckerPlugin, TsConfigPathsPlugin } = require("awesome-typescript-loader");
const webpack = require("webpack");
const ExtractTextPlugin = require("extract-text-webpack-plugin");
然后,我们声明三个变量:
-
corePlugins变量是一个数组,它包含用于开发构建和生产构建的 Webpack 插件的配置 -
devPlugins变量是一个数组,它只包含开发构建中使用的 Webpack 插件的配置 -
prodPlugins变量是一个数组,它只包含生产构建中使用的 Webpack 插件的配置
每个插件都需要一些特定的配置。例如,ExtractTextPlugin用于从主应用包中提取我们的 CSS 代码:
const corePlugins = [
new CheckerPlugin(),
new webpack.DefinePlugin({
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development")
}),
new ExtractTextPlugin({
filename: "[name]main.css",
allChunks: true
})
];
const devPlugins = [];
const prodPlugins = [
new webpack.optimize.UglifyJsPlugin({
output: { comments: false }
})
];
const isProduction = process.env.NODE_ENV === "production";
const plugins = isProduction ? corePlugins.concat(prodPlugins) : corePlugins.concat(devPlugins);
配置文件使用环境变量NODE_ENV来确定我们是在运行开发构建还是生产构建。生产构建使用uglify插件,但在开发构建中并不使用。
我们随后使用uglify插件来最小化输出文件的大小。文件大小的减小将减少应用加载时间,但会使调试变得更加困难。幸运的是,我们可以生成源映射来简化调试过程。源映射是由source-map-loader插件生成的。
Uglify 移除所有换行符和空白字符,并缩短一些变量名的长度。源映射文件允许我们在调试时将压缩文件的源代码映射到其原始代码。源映射提供了一种将压缩文件中的代码映射回源文件中原始位置的方法。这意味着我们可以在优化资产之后轻松地调试应用。Chrome 和 Firefox 开发者工具都内置了对源映射的支持。
在这一点上,我们定义了应用的入口点。我们使用一个对象作为映射来定义一个入口点,这意味着我们可以定义多个入口点。每个入口点都会转换成一个名为bundle.js的文件,该文件将被存储在名为public的目录下。
正如我们在webpack.config.js文件的其余部分所看到的,我们在应用的入口点名称后附加了一个正斜杠。我们还使用了一种特殊语法,将入口点的名称作为输出文件名称的一部分(例如,[name]bundle.js)。这是一个我们可以用来为每个输出包生成唯一文件夹的技巧。
最后,该文件声明了一些插件的配置,例如 TypeScript 插件或sass插件:
module.exports = {
entry: {
"calculator_app/": "./src/main_browser.ts"
},
devServer: {
inline: true
},
output: {
filename: "[name]bundle.js",
path: __dirname + "/public",
publicPath: "/public"
},
devtool: isProduction ? "source-map" : "eval-source-map",
resolve: {
extensions: [".webpack.js", ".ts", ".tsx", ".js"],
plugins: [
new TsConfigPathsPlugin({
configFileName: "tsconfig.json"
})
]
},
module: {
rules: [
{
enforce: "pre",
test: /.js$/,
loader: "source-map-loader",
exclude: [/node_modules/, /experimental/]
},
{
test: /.(ts|tsx)$/,
loader: "awesome-typescript-loader",
exclude: [/node_modules/, /experimental/]
},
{
test: /.scss$/,
use: ExtractTextPlugin.extract({
fallback: "style-loader",
use: ["css-loader", "resolve-url-loader", "sass-loader"]
})
}
]
},
plugins: plugins
};
我们可以使用以下命令执行 Webpack 打包过程:
webpack
请注意,我们必须从gulpfile.js文件所在的目录执行此命令。
如果一切顺利,我们应该能看到一个名为public的新文件夹。public文件夹应包含以下文件:

伴随源代码还包括一个名为index.html的文件。此文件是我们刚刚创建的前端应用的入口点:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="./public/calculator_app/main.css">
<title>Calculator</title>
</head>
<body>
<div id="main"><!-- Content created by JavaScript --></div>
<script src="img/bundle.js"></script>
</body>
</html>
这个示例应该能给我们一个关于如何使用 Webpack 的良好第一印象。下一节将描述我们如何使用 Webpack 开发服务器作为 Web 服务器来从 Web 浏览器访问此应用程序。
如果您需要关于 webpack 的更多信息,请参阅 webpack.js.org/concepts/ 的文档。还建议参考每个插件的文档,以了解更多关于可用配置选项的信息。某些 npm 模块可能需要一些额外的工具。这种情况并不常见,但某些模块可能需要安装 gcc/g++ 编译器和 Python 解释器等工具到您的开发环境中。如果您遇到此类问题,请查阅您操作系统的在线文档,了解如何安装 gcc/g++ 编译器和 Python 解释器。
Webpack 开发服务器
Webpack 开发服务器是一个命令行工具,它监视我们的文件系统以查找变化,并触发 Webpack 打包过程。
我们可以使用 npm 安装 Webpack 开发服务器:
npm install -g webpack-dev-server
我们可以使用以下命令执行 webpack 开发服务器:
webpack-dev-server
Webpack 开发服务器将开始监视我们的文件系统以查找变化。如果检测到变化,它将自动使用现有的 webpack.config.js 文件运行 Webpack 构建过程。
Webpack 开发服务器还会启动一个 Web 服务器。默认情况下,服务器运行在端口 8080 上。
请参阅 github.com/webpack/webpack-dev-server 上的文档,以了解更多关于 Webpack 开发服务器的信息。
Visual Studio Code
Visual Studio 是一个轻量级但功能强大的代码编辑器,具有众多功能。学习所有这些功能超出了本书的范围,因为我们完全可以写一本书来涵盖它们。然而,我们将花一点时间来了解这个代码编辑器中两个最好的功能:快速修复和代码调试器。
建议阅读 Visual Studio Code 用户指南 code.visualstudio.com/docs/editor/codebasics,以了解如何充分利用此 IDE。
快速修复
Visual Studio Code 可以检测一些错误,并使用一组称为 快速修复 的功能自动修复它们。
Visual Studio Code 将在我们的代码中某些错误附近左侧显示一个灯泡图标。如果我们点击灯泡图标,Visual Studio Code 将显示所有可用的快速修复。如果我们选择其中一个可用的快速修复,Visual Studio Code 将自动执行必要的更改以解决问题:

调试工具
在我们能够使用 Node.js 调试我们的应用程序之前,我们需要在 Visual Studio Code 中配置一个调试任务。我们需要选择调试面板并添加一个新的配置:

然后将显示一个带有几个选项的面板。我们需要选择 Node.js。如果您想使用 Docker 运行和调试应用程序,您也可以这样做,但这本书的范围之外:

选择 Node.js 将会创建一个名为.vscode的文件夹和一个名为launch.json的文件。这个文件允许我们定义我们可能需要的任意数量的调试任务。一个调试任务声明了调试我们的单元测试所需的指令。
调试任务的配置对于每个测试工具都是不同的。在示例应用中,我们使用了mocha,这意味着我们的调试任务将需要使用mocha二进制文件和一些参数来启动调试会话。
以下配置可以用于调试示例应用程序中的测试:
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Mocha Tests",
"program": "${workspaceFolder}/node_modules/mocha/bin/_mocha",
"args": [
"--require",
"ts-node/register",
"-u",
"tdd",
"--timeout",
"999999",
"--colors",
"${workspaceFolder}/test/**/*.test.ts"
],
"internalConsoleOptions": "openOnSessionStart",
"sourceMaps": true
}
]
}
请注意,.vscode文件夹必须位于项目的根目录下。配套源代码在章节文件夹下包含此文件夹,而不是在根目录下。如果您想尝试,您需要使用 Visual Studio Code 将章节文件夹作为根目录打开。
创建并配置launch.json文件后,我们可以在 DEBUG 面板下选择我们刚刚定义的任务,然后点击播放按钮:

当达到断点时,测试执行将暂停。我们可以在源代码的左侧单击一行来设置断点:

Visual Studio Code 的调试面板允许我们使用屏幕左侧可用的调试面板检查当前执行上下文。此面板包含几个子面板:
-
变量面板允许我们查看当前执行上下文中声明的所有变量。
-
观察面板允许我们创建一个观察者。观察者只是一个过滤器,允许我们显示我们选择的变量的值。
-
调用堆栈面板允许我们查看函数调用堆栈。我们可以在调用堆栈中单击函数以导航到所选函数。
-
断点面板允许我们启用和禁用我们创建的断点,以及启用一些通用的断点(例如,未捕获的异常):

屏幕顶部的执行面板允许我们以自己的节奏控制测试的执行。
源代码管理工具
Visual Studio Code 还允许我们通过图形用户界面与 Git 进行交互。要访问 Git 功能,我们需要点击屏幕左侧的 Git 面板。
Git 面板允许我们查看当前的变化。我们可以选择要提交(暂存)或回滚的变化。然后,我们可以通过输入一条消息并点击 Git 面板右上角的批准图标来提交这些变化:

ts-node
TypeScript 社区已经开发了一个 Node.js 的替代版本,它能够像原生支持一样处理 TypeScript 文件。这个 Node.js 的替代版本被称为ts-node。
ts-node命令允许我们在不先编译的情况下执行 TypeScript 文件。我们可以使用以下命令执行 TypeScript 文件:
ts-node ./src/main_server.ts
示例应用程序使用 npm scripts创建了这个命令的更方便版本:
"scripts": {
"ts-node": "ts-node ./src/main_server.ts"
}
npm命令可以按照以下方式执行:
npm run ts-node
默认情况下,ts-node命令会尝试在tsconfig.json文件中查找编译设置,并期望它位于当前目录。
当我们想要尝试某事而不必花费太多时间配置像 Webpack 这样的工具时,ts-node命令是一个非常方便的工具。
可能会感觉ts-node可以原生执行 TypeScript,但这并不是事实。我们的 TypeScript 代码首先被编译,然后使用 Node.js 二进制文件执行。这意味着在 Node.js 生产应用程序中使用ts-node是不推荐的,因为我们将会付出性能代价(编译时间)。
持续集成(CI)工具
CI 是一种开发实践,有助于防止潜在的集成问题。软件集成问题是指在将单独测试的软件组件组合成一个整体的过程中可能出现的困难。当组件组合成子系统或子系统组合成产品时,软件就实现了集成。
组件可以在所有实现和测试完成后进行集成,就像瀑布模型或大爆炸方法一样。另一方面,CI 要求开发者每天将代码提交到远程代码仓库。然后,每个提交都会通过自动化流程进行验证,使团队能够更早地发现集成问题。
在本章中,我们学习了如何在 GitHub 上创建代码仓库,以及如何使用单元测试和代码检查工具来验证我们的应用程序,但我们还没有配置 CI 服务器来观察我们的提交并相应地运行这些验证。
我们将使用 Travis CI 作为我们的 CI 服务器,因为它与 GitHub 高度集成,并且对开源项目和学习教育项目免费。在选择 CI 服务器时还有许多其他选项,但这些选项超出了本书的范围。
要配置 Travis CI,我们需要访问travis-ci.org并使用我们的 GitHub 凭据登录。登录后,我们将能够看到我们公开的 GitHub 仓库列表,并且还可以启用 CI:

为了完成配置,我们需要在我们的应用程序根目录中添加一个名为travis.yml的文件,其中包含 Travis CI 配置:
language: node_js
node_js:
- stable
还有许多其他可用的 Travis CI 配置选项。请参阅docs.travis-ci.com/了解有关可用选项的更多信息。
完成这两个小配置步骤后,Travis CI 将准备好观察我们远程代码仓库的提交。
我们已经使用配置来向 Travis CI 指示我们的应用程序是一个 Node.js 应用程序。无论其技术栈如何,每种潜在类型的应用程序的 CI 构建都可以高度定制。
然而,在大多数情况下,我们只需使用为给定类型的应用程序设置的默认值。在 Node.js 的情况下,Travis CI 使用npm install和npm test作为默认命令。
如果构建在本地开发环境中工作,但在 CI 服务器上失败,我们将不得不检查构建错误日志并尝试找出出了什么问题。可能性是,我们环境中的软件版本将领先于 CI 服务器中的版本,我们需要向 Travis CI 指示需要安装或更新依赖项。我们可以在docs.travis-ci.com/user/build-configuration/找到 Travis CI 文档,了解如何解决这类问题。
框架工具
框架工具用于自动生成项目结构、构建脚本等。以下是一些流行的框架工具的示例:
-
Angular CLI
-
React CLI (create-react-app-typescript)
-
Yeoman
这些工具旨在支持许多类型的项目。框架工具将通过为我们自动生成一些内容来节省我们的时间,例如 webpack 配置或package.json文件。
强烈建议花些时间阅读这些工具的文档,以了解更多有关它们现有自定义选项的信息。
请注意,您可以在cli.angular.io、github.com/wmonk/create-react-app-typescript和yeoman.io分别了解有关 Angular CLI、React CLI 和 Yeoman 的更多信息。
请注意,如果我们不了解代码的功能,让工具为我们生成代码从来不是一个好主意。虽然在未来,你应该考虑使用工具来生成新项目,但在使用框架工具之前,建议先深入了解任务和测试运行器。
为什么命令行会获胜?
你可能已经注意到,在本章中,我们大量使用了 CLI 而不是可视化工具。我们使用命令行终端执行了许多任务:
-
与源代码管理协同工作
-
安装依赖
-
运行任务
-
测试我们的代码
-
调试我们的代码
-
创建项目
命令行界面(CLI)一直很受欢迎,但我记得,几年前,我使用一些可视化工具来做这些事情。例如,我记得使用 NUnit 的可视化测试运行器在 .NET 应用程序中运行单元测试(NUnit 是 .NET 应用程序的单元测试库)。你可能想知道为什么我们停止使用可视化工具,又回到了像早期那样的命令行?
我认为 CLI 胜出的主要原因有两个:
-
我们的软件开发团队变得更加多元化
-
我们软件开发的方法论已经向自动化方向发展
我们的软件团队现在更加多元化,因此,遇到由 DevOps 工程师、使用 Linux 发行版的移动软件工程师和 Windows 上工作的网络工程师组成的团队是很常见的。
团队成员可能会使用不同的操作系统,但他们都遵循相同的流程,并且共享一个独特的发展流程。例如,如果团队成员之一想要执行单元测试,可以通过在操作系统控制台中执行命令来实现。我们可以更进一步,也可以将相同的命令作为我们持续集成构建的一部分。
命令行界面(CLI)胜出,因为它使我们的开发团队能够共享一套独特的发展流程和实践。
摘要
在本章中,我们学习了如何使用一系列不同的开发工具。在一章中深入探讨这么多工具是不可能的,但现在我们已经了解了基础知识,逐步深入到更高级的使用案例应该会容易得多。
在下一章中,我们将学习使用 Node.js 和 TypeScript 开发后端应用程序。
第十章:使用 TypeScript 进行 Node.js 开发
在本书的前几章中,我们一直在使用 Node.js 及其生态系统的一些工具,但尚未开发一个 Node.js 应用程序。在本章中,我们将学习如何使用 Node.js 开发应用程序。我们将涵盖以下主题:
-
Node.js 的主要特性
-
Node.js 核心 API 的主要特性
-
使用 Node.js 进行服务器端开发
-
使用 Node.js 开发 REST API
理解 Node.js
Node.js 是基于 Chrome 的 V8 JavaScript 引擎构建的 JavaScript 运行时。Node.js 是单线程的,并使用事件驱动的、非阻塞的 I/O 模型,这使得它轻量级且高效。
理解非阻塞 I/O
输入或输出操作(I/O)是需要从物理源写入或读取的操作。这包括将文件保存到硬盘驱动器或将文件通过网络发送等操作。
在过去,操作系统只允许我们在所谓的阻塞模型中执行 I/O 操作。在阻塞模型中,我们可以在一个线程中运行一个应用程序,但当发生 I/O 请求时,该线程会被阻塞,直到请求完成。
使用阻塞 I/O 实现的 Web 服务器无法使用相同的线程处理多个同时连接。例如,当一个 HTTP 请求到达 Web 服务器时,它可能需要进行一些 I/O 操作(例如,从数据库中读取或通过网络与另一个服务器通信)以向请求的创建者提供响应。如果 Web 服务器使用一个唯一的线程,它将一直被阻塞,直到 I/O 操作完成。因此,如果第二个 HTTP 请求击中服务器,服务器将无法处理它。解决这个问题的方法是为每个 HTTP 请求创建一个新的线程,但这种解决方案不可扩展,因为单个 CPU 无法处理大量线程,而 CPU 是服务器中最昂贵的组件之一。
以下图表展示了阻塞 I/O 模型:

非阻塞 I/O 模型是解决阻塞 I/O 模型限制的方案。在非阻塞模型中,I/O 请求不会阻塞主线程。相反,I/O 事件由一个称为事件分配器的组件收集和排队。Node.js 实现了一种称为反应器模式的模式,它将 JavaScript 事件循环与事件分配器结合起来。
以下图表展示了事件循环与事件分配器之间的交互:

其背后的主要思想是为每个 I/O 操作关联一个处理程序(在 Node.js 中由回调函数表示)。当 I/O 操作完成时,会产生一个事件,并由 JavaScript 事件循环消费,从而调用处理程序。
请参阅第六章,理解运行时,以了解更多关于 JavaScript 事件循环和事件处理器工作方式的信息。
如果 Web 服务器使用一个唯一的线程,它将不会在 I/O 操作完成之前被阻塞。因此,如果第二个 HTTP 请求击中服务器,服务器将能够处理它,而无需更多线程。每个 HTTP 请求都会创建 I/O 事件和事件处理器,这些事件处理器存储在内存中,如果服务器被成千上万的 HTTP 请求击中,它仍然可以达到一个极限。然而,并发 HTTP 请求的水平比之前由阻塞 I/O 模型强加的限制要高得多:

Node.js 充分利用了非阻塞 I/O 模型,并在其之上构建了根本,正如我们将在下一节中看到的那样。
Node.js 的主要组件
现在我们已经了解了非阻塞 I/O 模型的工作原理,我们处于更好的位置来理解 Node.js 的每个内部组件:

V8
V8 是最初为 Google 的 Chrome 开发的 JavaScript 引擎。它负责解析、解释和执行 JavaScript。
如果你想了解更多关于 V8 的信息,请参阅 V8 文档:github.com/v8/v8/wiki。
Libuv
每个操作系统都有自己的事件解复用器接口,并且每个 I/O 操作的行为可能会根据资源类型的不同而大相径庭,即使在同一操作系统内部。Libuv 是一个 C 库,使 Node.js 与所有主要平台兼容,并规范了不同类型资源的非阻塞行为;如今,libuv 代表了 Node.js 的低级 I/O 引擎。
如果你想了解更多信息,请参阅官方 libuv 文档:libuv.org/ 和 docs.libuv.org/en/v1.x/。
绑定
绑定是一组库,它以允许我们使用 JavaScript 而不是 C 或 C++代码的方式包装了 V8 和 libuv 的公共 API。
Node.js 核心 API(node-core)
Node.js 包含一组核心 API 来执行常见操作,例如读取文件、发送 HTTP 请求或加密文本文件。这些 API 在底层使用 V8 和 libuv,但它们并不直接与之通信,而是通过绑定来实现。
请注意,我们将在本章的后面部分学习更多关于 Node.js 核心 API 的内容。
Node.js 环境与浏览器环境
Node.js 环境和浏览器环境并不相同。例如,浏览器环境包括一个称为 文档对象模型(DOM)的 API 和一个称为 浏览器对象模型(BOM)的 API。这些 API 定义了诸如窗口对象或历史 API 等 API。然而,这些 API 在 Node.js 环境中不可用。以下表格突出了 Node.js 环境和 Web 浏览器之间的一些最显著差异:
| 特性 | Node.js | Web 浏览器 |
|---|---|---|
| DOM | 否 | 是 |
| BOM | 否 | 是 |
| 全局变量命名为 window | 否 | 是 |
| 全局变量命名为 global | 是 | 否 |
| require 函数 | 是 | 否 |
| Common JS 模块 | 是 | 否 |
| 访问敏感资源(例如,文件系统) | 是 | 否 |
Node.js 生态系统
在本节中,我们将探索 Node.js 生态系统。我们将了解 Node.js 能为我们提供什么,以及其整个生态系统遵循的一些重要代码约定。
Node.js 核心 API
Node.js 核心 API,也称为 node-core,是一组库,它是 Node.js 的一部分,因此当我们安装 Node.js 时,它们会安装到我们的操作系统上。Node.js 核心 API 包括以下模块:
-
assert -
async_hooks -
buffer -
child_process -
cluster -
crypto -
dgram -
dns -
domain -
events -
fs -
http -
http2 -
https -
net -
os -
path -
perf_hooks -
punycode -
querystring -
readline -
repl -
stream -
string_decoder -
tls -
tty -
url -
util -
v8 -
vm -
zlib
如前所述的列表所示,有模块可以与 域名服务器(DNS)一起工作,处理 HTTP 请求(http),或读取和写入硬盘上的文件(fs)。涵盖所有这些模块超出了本书的范围。然而,我们将在本章后面使用其中的一些模块。
请注意,您可以通过访问官方 Node.js 文档 nodejs.org/docs/ 来了解每个模块中每个功能的所有详细信息。
Node.js 核心 API 的风格
在本章的早期,我们学习了反应器模式和异步 I/O 模型是 Node.js 最基本的特点之一。这应该有助于我们理解为什么回调在 Node.js 核心 API 中被广泛使用。正如我们可以想象的那样,核心 API 对所有其他模块都有直接影响。因此,整个 Node.js 生态系统广泛使用回调。
Node.js 不仅广泛使用回调,而且使用方式非常一致:
-
Node.js 中的回调总是函数的最后一个参数
-
Node.js 中的回调总是将错误作为第一个参数
以下代码片段使用文件系统 API 读取文本文件。readFile 函数展示了前面两个规则的实际应用:
import { readFile } from "fs";
readFile("./hello.txt", (err, buffer) => {
console.log(buffer.toString());
});
关于 Node.js 回调有一些新的规则:
-
函数永远不会抛出错误。错误应该传递给回调。
-
当我们遇到嵌套回调时,如果发生错误,应该将错误传递给顶层回调。
以下代码片段展示了上述规则的实际应用:
function readJson(
fileName: string,
callback: (err: Error|null, json?: any) => void
) {
readFile(fileName, "utf-8", (err, buffer) => {
if (err) {
callback(err);
}
try {
const parsed = JSON.parse(buffer);
callback(null, parsed);
} catch (innerErr) {
callback(err);
}
});
}
Node.js 使用回调是因为当它最初实现时,promises、generators 和async/await在 V8 中是不可用的。这似乎很不幸,因为作为 TypeScript 用户,我们知道使用async/await而不是回调和 promises 要方便得多。
多亏了 Node.js API 的一致性,我们可以编写一个辅助函数,它接受一个使用基于回调的 API 实现的函数,并返回一个使用基于 promise 的 API 实现的相同函数。实际上,这个函数是util核心模块的一部分,可以按照以下方式导入:
import * as util from "util";
const promisify = util.promisify;
我们可以使用前面的辅助函数将我们在上一个示例中使用的readFile函数转换成一个返回 promise 的函数。新函数被命名为readFileAsync。现在函数返回 promise,我们可以使用async/await:
import { readFile } from "fs";
const readFileAsync = promisify(readFile);
(async () => {
const buffer = await readFileAsync("./hello.txt", "utf-8");
console.log(buffer.toString());
})();
以下代码片段展示了如何将第二个示例转换为async/await风格:
import { readFile } from "fs";
const { promisify } = require("util");
const readFileAsync = promisify(readFile);
async function readJson(fileName: string) {
try {
const buffer = await readFileAsync(fileName, "utf-8");
const parsed = JSON.parse(buffer.toString());
return parsed;
} catch (err) {
return err;
}
}
在未来,Node.js 将原生支持其核心 API 中的 promises,但到目前为止,使用promisify辅助函数是一个不错的选择。
npm 生态系统
在这本书中我们之前已经使用了 npm,到现在我们应该已经知道如何很好地使用它。npm 生态系统由数千个外部模块组成。我们可以使用官方 npm 网站www.npmjs.com/来搜索可能帮助我们完成特定任务的包。与作为 Node.js 核心 API 一部分的模块不同,外部npm模块需要使用npm进行安装:
npm install react
如果模块不被 TypeScript 识别,我们还需要安装其类型定义(如果可用):
npm install @types/react
请参阅第十三章,应用性能,以及第九章,自动化你的开发工作流程,以了解 Node.js 生态系统中的其他工具,例如 Node.js 检查器,这是一个允许我们调试和分析我们的 Node.js 应用程序性能的工具。
设置 Node.js
要在你的机器上设置 Node.js,你需要访问官方下载页面nodejs.org/en/download并遵循你操作系统的说明。
如果你是一个 OS X 或 Linux 用户,你可以按照 github.com/creationix/nvm 上的说明额外安装 node 版本管理器 (nvm)。这个工具允许我们在同一台机器上安装多个 Node.js 版本,并在几秒钟内切换它们。如果你是一个 Windows 用户,你需要安装 github.com/coreybutler/nvm-windows 代替。
如果我们想使用 Node.js 的一个核心 API 模块,我们只需要导入它。我们不需要安装额外的 npm 模块。例如,文件系统模块可以用来读取和写入文件以及管理目录。如果我们想使用文件系统 API,我们只需要按照以下方式导入模块:
import * as fs from "fs";
然而,TypeScript 默认不会识别该模块,因为它不是 JavaScript 规范的一部分。我们可以通过安装 Node.js 类型定义来解决此问题:
npm install @types/node
最后,你可能想安装 ts-node 以能够在不先编译的情况下执行使用 TypeScript 实现的 Node.js 应用程序。我们可以使用以下命令分别运行 Node.js 应用程序或 TypeScript 应用程序:
node app.js
ts-node app.ts
Node.js 开发
在本节中,我们将探讨几个 Node.js 的实际应用的小型示例。
与文件系统一起工作
我们将实现一个非常小的 Node.js 应用程序。这个应用程序可以用作搜索和替换工具。结果可以用作命令行应用程序,并可以使用以下命令执行:
ts-node app.ts --files ./**/*.txt --find SOMETHING --replace SOMETHING_ELSE
该应用程序将在所有匹配的文件中将一个单词替换为另一个单词。我们将使用核心文件系统 API (fs) 和两个外部 npm 模块:
-
glob用于查找与给定模式匹配的文件 -
yargs用于解析命令行参数
我们需要安装这两个包以及相应的类型定义文件:
npm install glob yargs --save
npm install @types/glob @types/yargs @types/node -save-dev
让我们看看源代码:
import * as fs from "fs";
import * as yargs from "yargs";
import glob from "glob";
const { promisify } = require("util");
我们将使用 promisify 函数将一些基于回调的 API 转换为基于 Promise 的 API:
const globAsync = promisify(glob);
const readFileAsync = promisify(fs.readFile);
const writeFileAsync = promisify(fs.writeFile);
以下函数从命令行读取参数:
function getCommandLineArguments() {
const files = yargs.argv.files;
if (!files) {
throw new Error("Missing argument --files");
}
const find = yargs.argv.find;
if (!find) {
throw new Error("Missing argument --find");
}
const replace = yargs.argv.replace;
if (!replace) {
throw new Error("Missing argument --replace");
}
return {
pattern: files,
find: find,
replace: replace
};
};
以下函数验证命令行参数:
function validateCommandLineArguments(args: any) {
if (args.pattern === undefined) {
throw new Error(`Invalid pattern ${args.pattern}`);
}
if (args.find === undefined) {
throw new Error(`Invalid find ${args.find}`);
}
if (args.replace === undefined) {
throw new Error(`Invalid replace ${args.replace}`);
}
}
以下函数查找与提供的 glob 模式匹配的文件路径:
async function findMatchingFilesAsync(pattern: string) {
const files = await globAsync(pattern);
// We need to let TypeScript that files are an array
return files as string[];
}
以下函数用于在文件中查找一个单词并将其替换为另一个单词:
async function findAndReplaceAsync(
file: string,
find: string,
replace: string
) {
const buffer = await readFileAsync(file);
const originalText = buffer.toString();
// This is a quick way to replace a word in JavaScript
const newText = originalText.split(find).join(replace);
await writeFileAsync(file, newText, "utf8");
}
这个函数是应用程序中的主函数,也是应用程序的入口点。它将工作委托给所有前面的函数:
async function runAsync() {
// Read arguments
const args = getCommandLineArguments();
// Validate arguments
validateCommandLineArguments(args);
// Find matching files
const files = await findMatchingFilesAsync(args.pattern);
// Find and replace
files.forEach(async (file) => {
await findAndReplaceAsync(file, args.find, args.replace);
});
}
到目前为止,我们可以调用应用程序的入口点:
(async () => {
await runAsync();
})();
与数据库一起工作
在本节中,我们将学习如何使用 TypeScript 库 TypeORM 从 Node.js 应用程序与数据库交互。TypeORM 是一个对象关系映射(ORM)库。ORM 是一个工具,它允许我们使用对象和方法与数据库交互,而不是使用 SQL 编程语言的任何一种变体。
在我们能够实现一个示例之前,我们需要在我们的开发环境中运行一个 Postgres 数据库服务器。有多种方法可以让服务器启动并运行,但我们将使用 Docker。Docker 是一种虚拟化服务,它允许我们在称为容器的独立虚拟机中运行软件。Docker 容器是 Docker 镜像的一个实例。我们将首先按照官方 Docker 安装指南中的说明安装 Docker 社区版,该指南可以在docs.docker.com/install找到,然后我们将运行以下命令来下载 Docker Postgres 镜像:
docker pull postgres:9.5
我们可以使用以下命令来查看所有已安装的镜像:
docker images
我们还需要设置一些环境变量。以下命令应该适用于使用 Bash 作为命令行的情况:
export DATABASE_USER=postgres
export DATABASE_PASSWORD=secret
export DATABASE_HOST=localhost
export DATABASE_PORT=5432
export DATABASE_DB=demo
请注意,如果你使用的是 Windows,你需要使用setx命令而不是export命令来声明环境变量。你可以在docs.microsoft.com/en-us/windows-server/administration/windows-commands/setx了解更多关于setx命令的信息。
到目前为止,我们可以使用 Postgres 镜像运行一个容器。以下命令使用我们在前一步中声明的环境变量来运行容器:
docker run --name POSTGRES_USER -p "$DATABASE_PORT":"$DATABASE_PORT"
-e POSTGRES_PASSWORD="$DATABASE_PASSWORD"
-e POSTGRES_USER="$DATABASE_USER"
-e POSTGRES_DB="$DATABASE_DB"
-d postgres
我们可以运行以下命令来查看我们机器上的所有 Docker 容器:
docker ps -a
如果一切顺利,你应该能在你的控制台看到以下类似的内容:

如果你需要重新开始,你可以使用以下命令分别停止和删除 Docker 容器:
docker stop $containerId
docker rm $containerId
请注意,我们不会深入探讨 Docker 的细节,因为这超出了本书的范围。如果你需要更多帮助,请参考官方 Docker 文档docs.docker.com。
到目前为止,我们应该有一个作为 Docker 容器运行的 Postgres 服务器。我们还需要安装一些npm模块:
npm install reflect-metadata pg typeorm
pg模块被 TypeORM 用来连接到 Postgres 数据库。reflect-metadata被 TypeORM 用来读取和写入元数据。在我们的整个应用程序中,只导入一次reflect-metadata模块非常重要。
以下代码片段使用 TypeORM 声明了一个名为Movie的实体。Movie实体将通过 TypeORM 映射到数据库表中。示例还创建了一个数据库连接和一个Movie存储库。我们最终使用存储库实例将一部新电影插入电影表,并读取表中匹配 1977 年的电影:
import "reflect-metadata";
import {
Entity,
getConnection,
createConnection,
PrimaryGeneratedColumn,
Column
} from "typeorm";
@Entity()
class Movie {
@PrimaryGeneratedColumn()
public id!: number;
@Column()
public title!: string;
@Column()
public year!: number;
}
const entities = [
Movie
];
const DATABASE_HOST = process.env.DATABASE_HOST || "localhost";
const DATABASE_USER = process.env.DATABASE_USER || "";
const DATABASE_PORT = 5432;
const DATABASE_PASSWORD = process.env.DATABASE_PASSWORD || "";
const DATABASE_DB = "demo";
(async () => {
const conn = await createConnection({
type: "postgres",
host: DATABASE_HOST,
port: DATABASE_PORT,
username: DATABASE_USER,
password: DATABASE_PASSWORD,
database: DATABASE_DB,
entities: entities,
synchronize: true
});
const getRepository = (entity: Function) => conn.getRepository(entity);
const movieRepository = conn.getRepository(Movie);
// INSERT INTO movies
// VALUES ('Star Wars: Episode IV - A New Hope', 1977)
await movieRepository.save({
title: "Star Wars: Episode IV - A New Hope",
year: 1977
});
// SELECT * FROM movies WHERE year=1977
const aMovieFrom1977 = await movieRepository.findOne({
year: 1977
});
if (aMovieFrom1977) {
console.log(aMovieFrom1977.title);
}
})();
存储库设计模式使用类来封装数据访问逻辑以及数据库与域实体之间的映射。您可以在msdn.microsoft.com/en-us/library/ff649690.aspx了解更多关于存储库模式的信息。
最后,我们可以使用ts-node运行示例:
ts-node app.ts
请注意,为了使此示例正常工作,环境变量和 Postgres 服务器必须正确配置。
与 REST API 一起工作
在本节中,我们将学习如何使用 Node.js 实现一些 REST API。
Hello world (http)
http模块允许我们执行与 HTTP 协议相关的任务。以下代码片段展示了我们如何使用http模块实现一个非常基础的 Web 服务器实现:
import * as http from "http";
const hostname = "127.0.0.1";
const port = 3000;
const server = http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader("Content-Types", "text/plain");
res.end("Hello world!");
});
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`);
});
我们创建了一个将监听所有 HTTP 请求的 Web 服务器。http模块允许我们实现我们的 Web HTTP 服务器,但它的抽象级别非常低。在实际应用中,我们更倾向于使用更高抽象级别的工具,例如,不需要我们手动设置响应状态码。Node.js 有多种框架可以为我们提供对 HTTP 协议的更高抽象级别。在下一节中,我们将学习如何使用其中之一:Express.js。
Hello world (Express.js)
Express.js 是一个为服务器端 Web 应用程序实现而设计的框架。要使用 TypeScript 与 Express 一起使用,我们需要以下npm模块:
npm install @types/node @types/express -save-dev
npm install express --save
以下示例实现了一个与前面章节中实现的应用程序行为几乎相同的应用程序,但这次我们使用 Express.js 而不是http core模块:
import express from "express";
const port = 3000;
const app = express();
app.get("/", (req, res) => {
res.send("Hello World!");
});
app.listen(port, () => {
console.log(`Server running at http://127.0.0.1:${port}/`);
});
如我们所见,即使在前面提到的简单示例中,使用 Express,有时我们不需要处理一些低级细节,例如设置响应状态码。
使用 Express 进行路由
在前面的章节中,我们学习了如何声明一个路由;然而,随着我们的应用程序增长,我们将需要实现一些路由组织策略。Express 允许我们创建多个路由器实例并将它们作为树状数据结构嵌套。以下代码片段演示了如何创建两个处理不同类型实体(movies和directors)的路由器;然后这两个路由器被 Express 应用程序使用:
import express from "express";
const moviesRouter = express.Router();
// URL "/api/v1/movies" + "/"
moviesRouter.get("/", (req, res) => {
res.send("Hello from movies!");
});
const directorsRouter = express.Router();
// URL "/api/v1/directors" + "/"
directorsRouter.get("/", (req, res) => {
res.send("Hello from directors!");
});
const port = 3000;
const app = express();
app.use("/api/v1/movies", moviesRouter);
app.use("/api/v1/directors", directorsRouter);
app.listen(port, () => {
console.log(`Server running at http://127.0.0.1:${port}/`);
});
当我们运行前面的示例时,有两个路由可用:
-
http://localhost:3000/api/v1/directors -
http://localhost:3000/api/v1/directors
Express 中间件
Express 还允许我们声明一个中间件函数。中间件函数允许我们实现横切关注点。横切关注点是一个影响整个应用程序或其子集的要求。横切关注点的常见例子是日志记录和授权。
中间件函数接受当前请求和响应作为参数,以及一个名为next的函数:
const middlewareFunction = (
req: express.Request,
res: express.Response,
next: express.NextFunction
) => {
next();
};
我们可以链式调用中间件函数,而next函数则是用来通知 Express 中间件已完成其任务,可以调用下一个中间件。当没有更多的中间件函数可用时,路由处理程序将被调用。
以下代码片段声明了两个中间件函数。第一个中间件函数(timerMiddleware)在每次 HTTP 请求击中服务器时被调用一次。第二个中间件函数(loggerMiddleware)在每次 HTTP 请求击中http://localhost:3000端点时被调用一次:
import express from "express";
const port = 3000;
const app = express();
const timerMiddleware = (
req: express.Request,
res: express.Response,
next: express.NextFunction
) => {
console.log(`Time: ${Date.now()}`);
next();
};
const loggerMiddleware = (
req: express.Request,
res: express.Response,
next: express.NextFunction
) => {
console.log(`URL: ${req.url}`);
next();
};
// Application level middleware
app.use(timerMiddleware);
// Route level middleware
app.get("/", loggerMiddleware, (req, res) => {
res.send("Hello World!");
});
app.listen(port, () => {
console.log(`Server running at http://127.0.0.1:${port}/`);
});
当我们执行前面的示例时,以下路由变得可用:http://localhost:3000。当 HTTP 请求击中前面的 URL 时,响应是Hello world!,控制台显示之前声明的两个中间件函数生成的输出:
URL: /
Time: 1520354128960
构建 Node.js 应用程序——MVC 设计模式
构建 Node.js 应用程序是一个非常广泛的话题,可能需要一本整本书来单独讲述。然而,我们将介绍最常用的设计模式之一,即 MSDN 定义的模型-视图-控制器(MVC)设计模式,如下所示:
MVC 框架包括以下组件:
-
模型:模型对象是应用程序中实现应用程序数据域逻辑的部分。通常,模型对象从数据库检索和存储模型状态。例如,一个Product对象可能会从数据库检索信息,对其进行操作,然后将更新后的信息写回到数据库中的Products表。在小型应用程序中,模型通常是概念上的分离,而不是物理上的分离。例如,如果应用程序只读取数据集并将其发送到视图,则应用程序没有物理模型层和相关类。在这种情况下,数据集承担了模型对象的角色。 -
视图:视图是显示应用程序的用户界面(UI)的组件。通常,此 UI 由模型数据创建。一个例子是显示文本框、下拉列表和复选框的Products表的编辑视图,这些控件基于Product对象当前的状态。 -
控制器:控制器是处理用户交互、与模型协同工作并最终选择一个视图以显示 UI 的组件。在 MVC 应用中,视图仅显示信息;控制器处理并响应用户输入和交互。例如,控制器处理查询字符串值并将这些值传递给模型,模型随后可能使用这些值来查询数据库。
MVC 设计模式可以在后端和前端中实现。然而,在本节中,我们将它在后端实现。
请注意,为了能够无问题地运行此示例,您必须首先使用 npm install 从配套源代码中的 chapter_10 文件夹安装所有依赖项。然后您可以打开 09_mvc。
模型
我们的模式将包含两层。我们将实现一个实体和一个仓库。实体是使用 TypeORM 实现的:
import {
Entity,
PrimaryGeneratedColumn,
Column
} from "typeorm";
@Entity()
export class Movie {
@PrimaryGeneratedColumn()
public id!: number;
@Column()
public title!: string;
@Column()
public year!: number;
}
仓库
仓库也是使用 TypeORM 实现的:
import { getConnection } from "typeorm";
import { Movie } from "../entities/movie";
export function getRepository() {
const conn = getConnection();
const movieRepository = conn.getRepository(Movie);
return movieRepository;
}
控制器
控制器使用模型(实体 + 仓库),并且它是使用我们在本章早期探索的 Express 路由技术实现的。
我们将声明路由以获取所有电影、按年份过滤电影以及创建一部新电影。示例还使用 req.params 来访问请求参数年份。
重要的是要注意,我们可以使用 get 方法来声明 HTTP GET 请求的路由处理程序,而我们可以使用 post 方法来声明 HTTP POST 请求的路由处理程序:
import { Router } from "express";
import { getRepository } from "../repositories/movie_repository";
const movieRouter = Router();
movieRouter.get("/", function (req, res) {
const movieRepository = getRepository();
movieRepository.find().then((movies) => {
res.json(movies);
}).catch((e: Error) => {
res.status(500);
res.send(e.message);
});
});
movieRouter.get("/:year", function (req, res) {
const movieRepository = getRepository();
movieRepository.find({
year: req.params.year
}).then((movies) => {
res.json(movies);
}).catch((e: Error) => {
res.status(500);
res.send(e.message);
});
});
movieRouter.post("/", function (req, res) {
const movieRepository = getRepository();
const newMovie = req.body;
if (
typeof newMovie.title !== "string" ||
typeof newMovie.year !=== "number"
) {
res.status(400);
res.send(`Invalid Movie!`);
}
movieRepository.find(newMovie).then((movie) => {
res.json(movie);
}).catch((e: Error) => {
res.status(500);
res.send(e.message);
});
});
export { movieRouter };
数据库
在我们的仓库中,我们使用了 TypeORM 的 getConnection 函数。在我们调用此函数之前,我们需要确保已创建了一个连接。以下函数用于稍后创建数据库连接:
import { createConnection } from "typeorm";
import { Movie } from "./entities/movie";
export async function getDbConnection() {
const DATABASE_HOST = process.env.DATABASE_HOST || "localhost";
const DATABASE_USER = process.env.DATABASE_USER || "";
const DATABASE_PORT = 5432;
const DATABASE_PASSWORD = process.env.DATABASE_PASSWORD || "";
const DATABASE_DB = "demo";
const entities = [
Movie
];
const conn = await createConnection({
type: "postgres",
host: DATABASE_HOST,
port: DATABASE_PORT,
username: DATABASE_USER,
password: DATABASE_PASSWORD,
database: DATABASE_DB,
entities: entities,
synchronize: true
});
return conn;
}
视图
因为我们在实现 REST 服务,我们没有数据表示层。REST API 可以完全与网络用户界面解耦。我们不会在本章学习如何实现表示层,因为这个主题将在接下来的章节中介绍。
索引
索引文件是应用程序的入口点。入口点创建一个新的 Express 应用和一个数据库连接。然后它将控制器路由连接到 Express 应用并启动网络服务器:
import "reflect-metadata";
import express from "express";
import { getDbConnection } from "./db";
import { movieRouter } from "./controllers/movie_controller";
(async () => {
await getDbConnection();
const port = 3000;
const app = express();
app.use("/api/v1/movies", movieRouter);
app.listen(port, () => {
console.log(`Server running at http://127.0.0.1:${port}/`)
});
})();
使用 inversify-express-utils 的控制器和路由
在本书的早期部分,我们学习了使用 InversifyJS 进行依赖注入和依赖反转。在本节中,我们将学习如何结合使用 InversifyJS 和 Express,利用一个名为 inversify-express-utils 的辅助 npm 模块。我们需要安装 inversify 和 inversify-express-utils:
npm install inversify inversify-express-utils
请注意,为了能够无问题地运行此示例,您必须首先使用 npm install 从配套源代码中的 chapter_10 文件夹安装所有依赖项。然后您可以打开 10_inversify_express_utils 文件夹。
模型、仓库、数据库和视图
我们可以重用前面示例中用于模型、仓库、数据库和视图的 100% 的代码。
类型
我们需要声明一些用于 InversifyJS 类型绑定的标识符。如果你不知道类型绑定是什么,你应该回到第五章,与依赖项一起工作,以了解 InversifyJS 的基础知识。我们将为 MovieRepository 类声明一个绑定:
export const TYPE = {
MovieRepository: Symbol("MovieRepository")
};
控制器
InversifyJS Express 工具允许我们使用所谓的声明式路由来声明控制器。我们不需要声明 Router 实例,而是可以使用一些装饰器来注解控制器类。装饰器生成的元数据随后将由 InversifyJS express 工具用于为我们生成 Router 实例。
以下示例使用了以下装饰器:
-
@controller(path): 它用于声明路由的路径 -
@inject(type): 它用于将依赖项注入到类中 -
@httpGet(subpath): 它用于声明 HTTP GET 请求的路由处理器 -
@httpPost(subpath): 它用于声明 HTTP POST 请求的路由处理器 -
@response(): 它用于将响应对象作为参数传递给路由处理器 -
@requestParam(paramName): 它用于将请求参数作为参数传递给路由处理器 -
@requestBody(): 它用于将请求体作为参数传递给路由处理器
InversifyJS Express 工具的另一个有趣特性是我们可以使用 async 方法。InversifyJS 将自动检测我们的方法是否是 async,并在需要时使用 await:
import express from "express";
import { inject } from "inversify";
import {
controller,
httpGet,
httpPost,
response,
requestParam,
requestBody
} from "inversify-express-utils";
import { Repository } from "typeorm";
import { Movie } from "../entities/movie";
import { TYPE } from "../constants/types";
@controller("/api/v1/movies")
export class MovieController {
private readonly _movieRepository: Repository<Movie>;
public constructor(
@inject(TYPE.MovieRepository)movieRepository: Repository<Movie>
) {
this._movieRepository = movieRepository;
}
@httpGet("/")
public async get(
@response() res: express.Response
) {
try {
return await this._movieRepository.find();
} catch(e) {
res.status(500);
res.send(e.message);
}
}
@httpGet("/:year")
public async getByYear(
@response() res: express.Response,
@requestParam("year") yearParam: string
) {
try {
const year = parseInt(yearParam);
return await this._movieRepository.find({
year
});
} catch(e) {
res.status(500);
res.send(e.message);
}
}
@httpPost("/")
public async post(
@response() res: express.Response,
@requestBody() newMovie: Movie
) {
if (
typeof newMovie.title !== "string" |
typeof newMovie.year !== "number"
) {
res.status(400);
res.send(`Invalid Movie!`);
}
try {
return await this._movieRepository.save(newMovie);
} catch(e) {
res.status(500);
res.send(e.message);
}
}
}
InversifyJS 配置
我们将声明一些类型绑定。我们将使用 AsyncContainerModule 来声明类型绑定,因为我们需要等待数据库连接就绪。我们将为 MovieRepository 声明一个绑定。我们不需要为 MovieController 声明绑定,因为 @controller(path) 注解会为我们创建它。然而,我们需要导入控制器以确保装饰器被执行,从而声明绑定:
import { AsyncContainerModule } from "inversify";
import { Repository, Connection } from "typeorm";
import { Movie } from "./entities/movie";
import { getDbConnection } from "./db";
import { getRepository } from "./repositories/movie_repository";
import { TYPE } from "./constants/types";
export const bindings = new AsyncContainerModule(async (bind) => {
await getDbConnection();
await require("./controllers/movie_controller");
bind<Repository<Movie>>(TYPE.MovieRepository).toDynamicValue(() => {
return getRepository();
}).inRequestScope();
});
索引
索引文件也有所不同。我们不需要创建一个新的 Express 应用,而是需要创建一个新的 InversifyExpressServer 应用。InversifyExpressServer 构造函数需要一个 Container 实例。容器的类型绑定在绑定对象中声明,该对象是 AsyncContainerModule 的一个实例:
import "reflect-metadata";
import { Container } from "inversify";
import { InversifyExpressServer } from "inversify-express-utils";
import { bindings } from "./inversify.config";
(async () => {
const port = 3000;
const container = new Container();
await container.loadAsync(bindings);
const app = new InversifyExpressServer(container);
const server = app.build();
server.listen(port, () => {
console.log(`Server running at http://127.0.0.1:${port}/`)
});
})();
请参阅第五章,与依赖项一起工作,以了解更多关于 InversifyJS 的依赖注入和依赖反转。
请参阅github.com/inversify/inversify-express-utils 上的官方 InversifyJS express 工具以了解更多信息。
Node.js 的其他应用
开发命令行应用程序或 REST API 并不是 Node.js 的唯一实际应用。例如,我们可以使用 Electron 开发桌面应用程序。Node.js 也常被用作反向代理,并驱动许多 Web 开发工具。
概述
在本章中,我们学习了 Node.js 作为平台的主要特性和其主要组件。我们还了解了一些关于 Node.js 生态系统以及我们可以使用它创建的应用类型。
在本章的结尾,我们实现了一个非常小的 REST API。在接下来的两个章节中,我们将学习如何使用 TypeScript 创建基于 Web 的用户界面,以消费这些 API。
第十一章:使用 React 和 TypeScript 进行前端开发
在上一章中,我们学习了如何使用 Node.js 实现 Web 服务。在本章中,我们将学习如何实现一个单页应用程序(SPA),该应用程序消费我们在上一章中创建的 Web 服务。Web 服务在本章中可能会有所变化,但这些变化应该足够小,不需要在本章中详细说明。
伴随的源代码包括我们在上一章中实现的后端应用程序的更新版本。新的升级版本包含新的控制器和 Web 服务,使我们能够管理电影和演员的目录,而不仅仅是电影。
在本章中,我们将学习如何从 React 应用程序中消费 Web 服务,以及我们如何使用 React 组件在 Web 用户界面中显示从后端获取的数据。我们还将学习如何使用react-router实现客户端路由,以及如何使用 MobX 实现智能组件。
与 React 一起工作
React 是一个允许我们实现 Web 用户界面的库。在本章中,我们将使用 React 和 MobX 创建一个小型前端应用程序。前端 Web 应用程序与 Node.js 后端 Web 应用程序有很大不同。确实,Web 浏览器和 Node.js 都可以原生理解 JavaScript,但环境却大不相同。例如,在 Node.js 环境中,我们可以访问系统资源,如文件系统,并且可以原生使用 CommonJS 模块。另一方面,在 Web 浏览器中,我们无法访问像文件系统这样的资源,并且 CommonJS 模块不支持原生。此外,前端 Web 应用程序的性能极其受其加载时间的影响。这意味着在 Web 前端应用程序中,我们必须特别注意 HTTP 请求的数量和通过网络加载的内容的大小。
当我们在前端 Web 应用程序上工作时,由于原生模块支持不足和需要大小优化,因此需要一个模块打包器。在整个本书中,我们一直在使用 Webpack 作为我们的模块打包器,在本章中我们也将再次这样做。
我们将使用以下 Webpack 配置:
const { CheckerPlugin } = require("awesome-typescript-loader");
const webpack = require("webpack");
const ExtractTextPlugin = require("extract-text-webpack-plugin");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const corePlugins = [
new CheckerPlugin(),
new webpack.DefinePlugin({
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development")
}),
new ExtractTextPlugin({
filename: "main.css",
allChunks: true
}),
new CopyWebpackPlugin([
{ from: "./web/frontend/index.html", to: "index.html" }
])
];
const devPlugins = [];
const prodPlugins = [
new webpack.optimize.UglifyJsPlugin({ output: { comments: false } })
];
const isProduction = process.env.NODE_ENV === "production";
const plugins = isProduction ? corePlugins.concat(prodPlugins) : corePlugins.concat(devPlugins);
module.exports = {
entry: "./web/frontend/index.tsx",
devServer: {
inline: true
},
output: {
filename: "bundle.js",
path: __dirname + "/public",
publicPath: "/public"
},
devtool: isProduction ? "source-map" : "eval-source-map",
resolve: {
extensions: [".webpack.js", ".ts", ".tsx", ".js"]
},
module: {
rules: [
{
enforce: "pre",
test: /.js$/,
loader: "source-map-loader",
exclude: [/node_modules/]
},
{
test: /.(ts|tsx)$/,
loader: "awesome-typescript-loader",
exclude: [/node_modules/]
},
{
test: /.scss$/,
use: ExtractTextPlugin.extract({
fallback: "style-loader",
use: ["css-loader", "resolve-url-loader", "sass-loader"]
})
}
]
},
plugins: plugins
};
我们使用了一些插件将 SCSS 文件编译成一个唯一的 CSS 文件,并将 HTML 文件复制到构建输出目录(public目录)。如果构建成功,我们应该在public目录下得到三个文件:index.html、bundle.js和main.css。
请参阅第九章,自动化你的开发工作流程,以了解更多关于 Webpack 的信息。
关于示例应用程序
伴随源代码中包含的相同应用程序是一个非常小的 Web 应用程序,允许我们管理电影和演员的数据库。应用程序将被分为两个主要单元:页面和组件。在本节中,我们将了解应用程序中使用的每个页面和组件。
主页 允许我们访问 Movies 和 Actors 页面。主页使用 Layout、Header、Container、Row、Column、Card、Card image 和 Link 组件:

Movies 页面允许我们查看数据库中现有的电影列表,以及添加和删除电影,并使用 Layout、Header、Container、Row、Column、Modal、Button、Text field 和 List Group 组件:

以下截图显示了当 Movie 编辑器处于活动状态时的页面外观:

以下截图显示了当我们尝试删除一部电影时的页面外观:

Actors 页面允许我们查看数据库中现有的演员列表,以及添加和删除演员,并使用 Layout、Header、Container、Row、Column、Modal、Button、Text field 和 List Group 组件:

创建或删除一个演员几乎与创建或删除一部电影相同。以下截图显示了当 Actor 编辑器处于活动状态时的页面外观:

如您在前面的截图中所见,多个页面可以消耗相同的 React 组件。组件的可复用性是 React 作为用户界面开发库的主要优势之一。React 允许我们开发可复用的 工作单元,称为组件,这些组件可以在多个应用程序中重复使用。
伴随源代码中包含的示例应用程序实现了一个后端和前端 Web 应用程序,并使用了以下依赖项:
"dependencies": {
"body-parser": "1.18.2",
"bootstrap": "4.0.0",
"express": "4.16.2",
"inversify": "4.11.1",
"inversify-binding-decorators": "3.2.0",
"inversify-express-utils": "5.2.1",
"inversify-inject-decorators": "3.1.0",
"mobx": "4.1.0",
"mobx-react": "5.0.0",
"pg": "7.4.1",
"react": "16.2.0",
"react-dom": "16.2.0",
"react-router-dom": "4.2.2",
"reflect-metadata": "0.1.12",
"typeorm": "0.1.14"
},
"devDependencies": {
"@types/body-parser": "1.16.8",
"@types/express": "4.11.1",
"@types/node": "9.4.6",
"@types/react": "16.0.40",
"@types/react-dom": "16.0.4",
"@types/react-router-dom": "4.2.5",
"awesome-typescript-loader": "3.4.1",
"copy-webpack-plugin": "4.5.1",
"css-loader": "0.28.8",
"extract-text-webpack-plugin": "3.0.2",
"node-sass": "4.7.2",
"resolve-url-loader": "2.2.1",
"sass-loader": "6.0.6",
"style-loader": "0.19.1",
"ts-node": "5.0.1",
"tslint": "5.9.1",
"typescript": "2.8.1",
"webpack": "3.10.0",
"webpack-dev-server": "2.11.0"
}
我们还将使用以下 npm 脚本命令实现一个非常基本的编译管道:
"scripts": {
"start": "ts-node ./web/backend/index.ts",
"build": "webpack",
"lint": "tslint --project tsconfig.json -c tslint.json ./web/**/*.ts ./web/**/*.tsx"
},
我们可以使用以下 npm 命令使用前面的命令:
npm run start
npm run build
npm run lint
要运行应用程序,我们首先必须运行构建命令,这将构建我们的前端应用程序并将其转换为位于名为 public 的目录下的捆绑 JavaScript 和 CSS 文件。然后,我们可以使用 npm run start 命令运行应用程序。Node.js 服务器将开始监听我们的 API 调用。Node.js 还将提供 public 目录下的文件,正如我们将在下一节中看到的那样。
使用 Node.js 提供 React 应用程序
在本章中,我们将尽量避免对现有的 Node.js 后端进行修改。控制器声明的一些网络服务已经引入了变化。我们不会在本章中花费时间详细说明这些变化,因为它们是微不足道的。
然而,我们将关注在 Express.js 配置中的一些变化,这些变化是必要的,以便在 public 目录下提供 JavaScript 和 CSS 文件。如果我们想让我们的 Express.js 应用程序提供前端应用程序的静态文件,我们需要配置所谓的静态中间件。
以下代码片段包含了应用程序入口点的全部源代码:
import "reflect-metadata";
import * as express from "express";
import { Container } from "inversify";
import * as bodyParser from "body-parser";
import * as path from "path";
import { InversifyExpressServer } from "inversify-express-utils";
import { bindings } from "./inversify.config";
(async () => {
try {
const port = 3000;
const container = new Container();
await container.loadAsync(bindings);
const app = new InversifyExpressServer(container);
app.setConfig((a) => {
a.use(bodyParser.json());
a.use(bodyParser.urlencoded({ extended: true }));
const appPath = path.join(__dirname, "../../public");
a.use("/", express.static(appPath));
});
const server = app.build();
server.listen(port, () => {
console.log(`Server running at http://127.0.0.1:${port}/`); // tslint:disable-line
});
} catch (e) {
console.log(e); // tslint:disable-line
}
})();
上述代码片段创建了一个新的 Express.js 应用程序。这里的关键点是关注对setConfig方法的调用。我们已经配置了 Express.js 静态中间件,以便在调用默认路径("/")时提供位于public目录下的所有文件。这意味着如果我们使用npm start运行应用程序,并访问http://127.0.0.1:3000/,则位于public目录下的index.html文件将被提供。然后,index.html文件将请求 JavaScript 和 CSS 文件;这些文件也位于public目录下,可以通过http://127.0.0.1:3000/main.css和http://127.0.0.1:3000/bundle.js分别访问。
请注意,我们还配置了应用程序使用 body-parser 中间件。这是为了能够解析 HTTP 请求体中包含的 JSON 数据。如果您需要关于 Node.js 和 Express.js 的额外帮助,请参阅第十章,使用 TypeScript 进行 Node.js 开发。
与 react-dom 和 JSX 一起工作
现在我们知道了如何使用 Webpack 构建我们的前端应用程序,以及如何使用 Express.js 提供其静态文件,我们可以专注于 React 应用程序的代码。我们将从检查前端应用程序的入口点开始。应用程序的入口点位于/web/frontend/index.tsx。
如您所见,我们使用了.tsx 文件扩展名而不是.ts 扩展名。这是因为我们将使用一个名为 JSX 的模板系统。TypeScript 原生支持 JSX,但它要求我们使用.tsx 文件扩展名,并在我们的tsconfig.json文件中配置 JSX 设置:
"jsx": "react"
JSX 设置决定了 TypeScript 是否应该将 JSX 代码编译成 JavaScript,或者由外部工具编译。在这种情况下,我们正在努力将 JSX 设置设置为react,这意味着 JSX 代码将由 TypeScript 编译成 JavaScript。
应用程序的入口点使用 react-dom 模块来渲染应用程序的根组件。在此阶段,我们还不了解 React 组件是什么。然而,我们目前不需要深入了解。我们只需要理解的是,Layout 变量是一个 React 组件,并且它使用 react-dom 库的 render 方法渲染到一个 HTML 元素中。
我们使用 querySelector 在 index.html 文件中查找现有的 DOM 元素,然后使用 render 函数将 Layout 组件的输出渲染到选定的 DOM 元素中:
import "reflect-metadata";
import * as React from "react";
import * as ReacDOM from "react-dom";
import "../../node_modules/bootstrap/scss/bootstrap.scss";
import { Layout } from "./config/layout";
const selector = "#root";
const $element = document.querySelector(selector);
if (!$element) {
throw new Error(`Node ${selector} not found!`);
} else {
ReacDOM.render(
<Layout/>,
$element
);
}
render 函数的第一个参数是一个 JSX 元素。正如你所见,JSX 语法与 HTML 语法非常相似,然而它有一些我们在本章余下的部分将探讨的不同之处。
还值得一提的是,前面的文件也导入了整个前端应用程序所需的某些文件。例如,该文件导入了 bootstrap.css 和 reflect-metadata 模块。第一个文件包含应用程序中 React 组件所需的 CSS,而 reflect-metadata 模块声明了实现某些组件中依赖注入所需的 polyfill。
使用 react-router
在上一节中,我们学习了如何使用来自 react-dom 模块的 render 方法来渲染 Layout 组件。在本节中,我们将更深入地研究 Layout 组件。
如下面的代码片段所示,Layout 组件是一个返回一系列嵌套 JSX 元素的函数;其中一些 JSX 元素是其他 React 组件(例如 BrowserRouter、Header 和 Switch 组件)。这是我们第一次看到 React 组件的实际实现。在这种情况下,一个 React 组件是一个返回一些 JSX 的函数。然而,重要的是要提到这并不总是如此,因为组件也可以是一个类。
React 是一个基于组件的前端开发框架。这意味着在 React 中,一切都是组件。我们的应用程序是一个组件,应用程序内的页面是组件,每个页面中的元素也是组件。
Layout 组件是应用程序的根组件。Layout 组件始终在屏幕上渲染。然而,应用程序内的页面作为 Layout 组件的子组件有条件地渲染。
Layout 组件使用 react-router 模块在我们的 React 应用程序中实现路由。react-router 模块包括以下 React 组件:
-
BrowserRouter模块用于为其他组件提供访问实现客户端导航所需的某些浏览器 API(例如 History API)的权限。 -
Switch组件允许我们定义应用程序中可用的路由。 -
Route组件允许我们在应用程序中定义路由。Route组件接受path和component作为属性。当浏览器 URL 与其中一个路由匹配时,相应的组件将被渲染。 -
Link组件不是由Layout组件直接使用的。然而,它是用来声明指向现有路由之一的链接的组件。
以下代码片段展示了 Layout 组件如何声明三个不同的路由:
import { Route, Switch, BrowserRouter } from "react-router-dom";
import * as React from "react";
import { Header } from "../components/header_component";
import { HomePage } from "../pages/home_page";
import { MoviePage } from "../pages/movies_page";
import { ActorPage } from "../pages/actors_page";
import "../stores/movie_store";
import "../stores/actor_store";
export const Layout = () => (
<BrowserRouter>
<div>
<Header
bg="primary"
title="TsMovies"
rootPath="/"
links={[
{ path: "/movies", text: "Movies"},
{ path: "/actors", text: "Actors"}
]}
/>
<main style={{ paddingTop: "60px" }}>
<Switch>
<Route exact path="/" component={HomePage}/>
<Route path="/movies" component={MoviePage}/>
<Route path="/actors" component={ActorPage}/>
</Switch>
</main>
</div>
</BrowserRouter>
);
正如我们已经提到的,Link 组件不是由 Layout 组件使用的,但它可以如下使用:
<Link className="btn btn-primary" to={props.linkPath} >
{props.linkText}
</Link>
正如我们已经提到的,JSX 语法几乎与 HTML 语法相同。但是,一些属性不可用或名称不同(例如,className)。另一个显著的区别是我们必须使用特殊语法({})将变量的值绑定到组件的一个属性上。
与 React 组件一起工作
在本节中,我们将仔细研究一些在配套源代码中包含的应用程序使用的组件。我们将使用多个组件来展示多个概念。
组件作为类
以下代码片段声明了三个名为 Container、Row 和 Column 的组件。这些组件扩展了从 React 模块导入的 Component 类。在扩展 Component 类的类中,我们可以实现一些方法,但至少必须实现 render 方法。
Container、Row 和 Column 组件用于控制页面的布局。这些组件使用来自 Bootstrap(一个允许我们轻松为应用程序添加样式的库)的 CSS 类网格系统。在 Bootstrap 中,布局最多有 12 列,可以为特定屏幕尺寸声明不同的尺寸:
import * as React from "react";
export class Container extends React.Component {
public render() {
return (
<div className="container">
{this.props.children}
</div>
);
}
}
export class Row extends React.Component {
public render() {
return (
<div className="row">
{this.props.children}
</div>
);
}
}
type ColumnWidth = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
type DeviceSize = "s" | "m" | "l" | "xl";
interface ColumnProps {
width: ColumnWidth;
size?: DeviceSize;
style?: React.CSSProperties;
}
export class Column extends React.Component<ColumnProps> {
public render() {
return (
<div className={this._getClass()} style={this.props.style ? this.props.style : {}}>
{this.props.children}
</div>
);
}
private _getClass() {
if (this.props.size !== undefined) {
return `col-${this.props.size}-${this.props.width}`;
} else {
return `col-${this.props.width}`;
}
}
}
属性和状态
如前述代码片段所示,Component 类是一个泛型类,具有两个可选的泛型类型:Component<TProps, TState>。这两个泛型类型允许我们指定在 React 组件中使用的属性和状态类型。
如您所见,Container 和 Row 组件没有任何属性或状态。然而,Column 组件定义了其属性的类型,因为我们需要消费者提供一些额外的数据。例如,当我们声明 Column 组件时,我们不知道消费者是否会将其大小设置为 1 或 12。
属性通过组件的构造函数由组件的消费者传递。例如,以下代码片段演示了我们可以如何将属性 width 传递给 Column 组件。代码片段还演示了如何将其他属性传递给 Card 组件:
import * as React from "react";
import { Card } from "../components/card_component";
import { Container, Row, Column } from "../components/grid_component";
export const HomePage = () => (
<Container>
<Row>
<Column width={6}>
<Card
title="Movies"
description="Explore our database of movies"
linkPath="/movies"
linkText="Movies"
img={null}
/>
</Column>
<Column width={6}>
<Card
title="Actors"
description="Explore our actors of movies"
linkPath="/actors"
linkText="Actors"
img={null}
/>
</Column>
</Row>
</Container>
);
组件也可以有内部状态。属性和状态之间的主要区别是属性是不可变的。换句话说,组件属性的值在组件实例化后不能改变(被变异)。另一方面,组件的状态通过setState函数进行变异。例如,以下代码片段声明了一个使用属性和状态的组件。该组件显示了一个基本的数字计数器,当用户点击按钮时计数器会增加。组件属性用于设置组件的初始状态(计数器的值)。然后,当用户点击按钮时,通过setState函数对状态进行变异:
import * as React from "react";
import { Button } from "./button_component";
interface CounterProps {
initialValue: number;
}
interface CounterState {
value: number;
}
export class Component extends React.Component<CounterProps, CounterState> {
public constructor(props: CounterProps) {
super(props);
this.state = { value: this.props.initialValue };
}
public render() {
return (
<div>
The value is: {this.state.value}
<Button onClick={() => this._increment()}>
Increment
</Button>
</div>
);
}
private _increment() {
this.setState({ value: this.state.value + 1 })
}
}
前一个组件可以按以下方式使用:
<Counter initialValue={1} />
组件的状态只能由组件本身更改。一般来说,属性比内部状态更受欢迎,因为它可能导致在非常大的项目中出现可维护性问题。这主要是因为当我们使用内部状态时,跟踪状态变更和当前状态稍微复杂一些。我们将在本章的智能组件和傻瓜组件部分中更详细地了解这个话题。
功能性无状态组件
一个功能性无状态组件(FSC)是一个不使用内部状态的组件,它是一个简单的函数,与扩展Component类的类相对。例如,Header组件就是一个 FSC:
import { Link } from "react-router-dom";
import * as React from "react";
type BgColor = "primary" | "secondary" | "success" |
"danger" | "warning" | "info" | "light" |
"dark" | "white";
interface HeaderProps {
bg: BgColor;
title: string;
rootPath: string;
links: { path: string; text: string }[];
}
export const Header = (props: HeaderProps) => (
<nav className={`navbar navbar-expand-lg navbar-light bg-${props.bg}`}>
<Link className="navbar-brand" to={props.rootPath}>
{props.title}
</Link>
<ul className="navbar-nav">
{
props.links.map((link, linkIndex) => (
<Link
className="navbar-brand"
to={link.path}
key={linkIndex}
>
{link.text}
</Link>
))
}
</ul>
</nav>
);
React 组件生命周期
当一个组件扩展Component类时,可以实现一些组件生命周期钩子。配套源代码中包含一个名为MoviePage的组件,它声明了一个名为componentWillMount的组件生命周期钩子:
class MoviePage extends React.Component {
// ...
public componentWillMount() {
this.movieStore.getAll();
}
public render() {
// ...
React 允许我们声明多个组件生命周期钩子。涵盖所有可用的 React 组件生命周期钩子超出了本书的范围。然而,了解事件被组织成三个主要阶段是很重要的:
-
安装阶段发生在组件渲染之前
-
更新阶段包括渲染以及组件渲染前后的瞬间
-
卸载阶段发生在组件即将停止渲染时
下面的图展示了主事件执行的顺序:

请参阅reactjs.org/docs/react-component.html的官方 React 文档,了解更多关于组件生命周期事件的信息。
智能组件和傻瓜组件
在实际的 React 应用程序中,我们将有许多无状态 React 组件,但在我们组件树中的某个地方,必须有一个组件负责管理应用程序的状态。这意味着我们可以将它们分为两大类:
-
无状态组件也被称为展示组件,因为它们的唯一责任是将某些内容呈现到 DOM 中。无状态组件可能是函数式无状态组件,也可能不是。
-
智能组件也被称为容器组件,因为它们是跟踪状态并关注应用程序如何工作的组件。
智能组件和无状态组件之间的分离可以通过多种方式实现,有时它与某些实现细节相关联。最简单的方法是拥有一个使用内部状态和 setState 函数的智能组件,以及一些将父组件的内部状态作为其属性的无状态组件。然而,使用外部状态容器库(如 Redux 或 MobX)来实现智能组件也非常常见。
伴随源代码中的应用程序包含许多无状态组件(位于 components 目录下)和一些智能组件(位于 pages 目录下)。智能组件是负责管理应用程序状态的组件。然而,我们不是使用 setState 函数,而是使用 MobX 和一些设计模式来确保我们的应用程序可以以可预测和可维护的方式扩展。
使用 MobX
MobX 是一个库,帮助我们管理并修改 React 应用程序中的状态。在本节中,我们将了解 MobX 架构。我们还将学习如何安装和配置它,了解其主要组件以及其 API。
理解 MobX 架构
MobX 架构引入了一个名为 Store 的实体。Store 是一个包含一些状态并提供访问一些动作的对象,这些动作允许我们修改其内部状态:
-
状态是可观察的;这意味着当其值发生变化时,会发出一个事件,并且应用程序的其他部分可以订阅状态变化
-
动作允许我们修改当前状态
使用动作和可观察对象
在本节中,我们将学习如何使用可观察对象和动作。以下代码片段声明了一个名为 ActorStore 的 Store:
import { ActorInterface } from "../../universal/entities/actor";
import * as mobx from "mobx";
import { provide } from "../config/ioc";
import { TYPE } from "../contants/types";
import * as interfaces from "../interfaces";
const { observable, action, runInAction, configure } = mobx;
configure({ enforceActions: true });
@provide(TYPE.ActorStore)
export class ActorStore implements interfaces.ActorStore {
ActorStore 是一个被 @provide 装饰器装饰的类。这个装饰器用于允许我们将 Store 注入到应用程序的其他元素中。
请注意,我们将在本章的 MobX 中的依赖注入 部分后面学习更多关于 @provide 装饰器的知识。
Store 类还声明了一些被 @observable 装饰器装饰的属性。这个装饰器允许我们的应用程序中的其他元素订阅属性的变化:
// Contains the actors that have been already loaded from the server
@observable public actors: ActorInterface[] = [];
// Used to represent the status of the HTTP GET calls
@observable public loadStatus: interfaces.Status = "pending";
// Used to represent the status of the HTTP DELETE call
@observable public deleteStatus: interfaces.Status = "idle";
// Used to represent the status of the HTTP POST and HTTP PUT calls
@observable public saveStatus: interfaces.Status = "idle";
// Used to display the confirmation dialog before deleting an actor
// null hides the modal and number display the modal
@observable public deleteActorId: null | number = null;
// Used to hold the values of the actor editor or null
// when nothing is being edited
@observable public editorValue: null | Partial<ActorInterface> = null;
在声明 Store 的属性之后,我们将声明其动作。正如您在以下代码片段中可以看到的,动作是一个被 @action 装饰器装饰的方法:
@action
public focusEditor() {
runInAction(() => {
this.editorValue = {};
});
}
@action
public focusOutEditor() {
runInAction(() => {
this.editorValue = null;
});
}
@action
public focusDeleteDialog(id: number) {
runInAction(() => {
this.deleteActorId = id;
});
}
@action
public focusOutDeleteDialog() {
runInAction(() => {
this.deleteActorId = null;
});
}
@action
public edit<T extends ActorInterface, K extends keyof T>(
key: K, val: T[K]
) {
runInAction(() => {
const actor = {...(this.editorValue || {}), ...{[key]: val}};
this.editorValue = actor;
});
}
@action装饰器可以用来装饰执行状态变更的方法,如前面的方法,但我们不仅限于这种操作。以下方法执行状态变更,同时也使用 Fetch API 向服务器发送一些 HTTP 请求。有一个动作是从演员 REST API 获取所有演员:
@action
public async getAll() {
try {
const response = await fetch(
"/api/v1/actors/",
{ method: "GET" }
);
const actors: ActorInterface[] = await response.json();
// We use setTimeout to simulate a slow request
// this should allow us to see the loading component
setTimeout(
() => {
runInAction(() => {
this.loadStatus = "done";
this.actors = actors;
});
},
1500
);
} catch (error) {
runInAction(() => {
this.loadStatus = "error";
});
}
}
还有一个动作可以创建新的演员:
@action
public async create(actor: Partial<ActorInterface>) {
try {
const response = await fetch(
"/api/v1/actors/",
{
body: JSON.stringify(actor),
headers: {
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json"
},
method: "POST"
}
);
const newActor: ActorInterface = await response.json();
runInAction(() => {
this.loadStatus = "done";
this.actors.push(newActor);
this.editorValue = null;
});
} catch (error) {
runInAction(() => {
this.loadStatus = "error";
});
}
}
还有一个动作可以删除演员:
@action
public async delete(id: number) {
try {
const response = await fetch(
`/api/v1/actors/${id}`,
{ method: "DELETE" }
);
await response.json();
runInAction(() => {
this.deleteStatus = "done";
this.actors = this.actors.filter((m) => m.id !== id);
this.deleteActorId = null;
});
} catch (error) {
runInAction(() => {
this.deleteStatus = "error";
});
}
}
}
我们使用runInAction函数来包装状态变更。使用runInAction函数是必需的,因为我们之前配置了 MobX,强制要求状态变更只能在动作中发生:
configure({ enforceActions: true });
到目前为止,我们的Store已经准备好使用@lazyInject装饰器注入到我们的 React 智能组件中。
请注意,我们将在本章的依赖注入在 MobX部分中学习更多关于@lazyInject装饰器的知识。
以下代码片段声明了一个名为MoviePages的智能组件。在我们的 React 应用程序中,页面是智能组件,而组件本身是简单的哑组件:
import * as React from "react";
import { observer } from "mobx-react";
import { MovieInterface } from "../../universal/entities/movie";
import { Container, Row, Column } from "../components/grid_component";
import { ListGroup } from "../components/list_group_component";
import { Modal } from "../components/modal_component";
import { TextField } from "../components/textfield_component";
import { Button } from "../components/button_component";
import { lazyInject } from "../config/ioc";
import { TYPE } from "../contants/types";
import * as interfaces from "../interfaces";
function isValidNewMovie(o: any) {
if (
o === null ||
o === undefined ||
// new movies don't have ID
o.id !== undefined ||
typeof o.title !== "string" ||
isNaN(o.year)
) {
return false;
}
return true;
}
这个智能组件是通过一个扩展Component类并带有@observer装饰器的类实现的。@observer装饰器将 React 组件绑定到Store中的状态变更:
@observer
export class MoviePage extends React.Component {
在 React 创建Component实例之后,将MovieStore注入到该组件中。现在我们可以忽略这方面的细节,因为将在下一节中解释:
@lazyInject(TYPE.MovieStore) public movieStore!: interfaces.MovieStore;
我们使用componentWillMount事件钩子来触发初始数据获取操作:
public componentWillMount() {
this.movieStore.getAll();
}
最后,我们渲染页面。render方法访问Store的一些属性(@observables)。由于我们的组件是观察者(@observer),如果触发了一个动作(@action),我们的组件将被重新渲染。该组件渲染电影列表:
public render() {
const error = this.movieStore.loadStatus === "error" ? new Error("Movies could not be loaded!") : null;
const movies = this.movieStore.loadStatus === "pending" ? null : this.movieStore.movies;
return (
<Container>
<Row>
<Column width={12} style={{ textAlign: "right", marginBottom: "10px" }}>
<Button
onClick={() => {
this.movieStore.focusEditor();
}}
>
Add Movie
</Button>
</Column>
</Row>
<Row>
<Column width={12}>
<ListGroup
error={error}
items={movies}
itemComponent={(movie: MovieInterface) => (
<Row>
<Column width={8}>
<h5>{movie.title}</h5>
<p>{movie.year}</p>
</Column>
<Column width={4} style={{ textAlign: "right" }}>
<Button
kind="danger"
onClick={() => {
this.movieStore.focusDeleteDialog(movie.id);
}}
>
Delete
</Button>
</Column>
</Row>
)}
/>
</Column>
</Row>
此组件还渲染一个模态窗口,允许我们创建电影:
<Modal
title="Movie Editor"
isVisible={this.movieStore.editorValue !== null}
onAcceptLabel="Save"
onAccept={() => {
if (isValidNewMovie(this.movieStore.editorValue)) {
const movie: any = this.movieStore.editorValue;
this.movieStore.create(movie);
}
}}
onCancelLabel="Cancel"
onCancel={() => {
this.movieStore.focusOutEditor();
}}
error={this.movieStore.saveStatus === "error" ? new Error("Something went wrong") : undefined}
>
<form>
<TextField
id="movie_title"
value={this.movieStore.editorValue ? this.movieStore.editorValue.title : ""}
title="Title"
placeholder="Title"
isValid={(val) => val !== undefined && val !== ""}
onChange={(val) => {
this.movieStore.edit("title", val);
}}
/>
<TextField
id="movie_year"
value={this.movieStore.editorValue ? this.movieStore.editorValue.year : 2018}
title="Year"
placeholder="Year"
isValid={(val) => !isNaN(val as any)}
onChange={(val) => {
const n = parseInt(val);
if (!isNaN(n)) {
this.movieStore.edit("year", n);
}
}}
/>
</form>
</Modal>
此组件还渲染一个模态窗口,允许我们确认是否要从数据库中删除电影:
<Modal
title="Are you sure?"
isVisible={this.movieStore.deleteMovieId !== null}
onAcceptLabel="Delete"
onAccept={() => {
if (this.movieStore.deleteMovieId) {
this.movieStore.delete(this.movieStore.deleteMovieId);
}
}}
onCancelLabel="Cancel"
onCancel={() => {
this.movieStore.focusOutDeleteDialog();
}}
error={this.movieStore.deleteStatus === "error" ? new Error("Something went wrong") : undefined}
>
The movie will be deleted permanently!
</Modal>
</Container>
);
}
}
MobX 中的依赖注入
在上一节中,我们使用@provide装饰器对ActorStore类进行了装饰:
@provide(TYPE.ActorStore)
export class ActorStore implements interfaces.ActorStore {
此装饰器是 InversifyJS 绑定语法的替代方案,等同于以下内容:
container.bind<ActorStore>(TYPE.ActorStore).to(ActorStore);
@provide装饰器不是必需的,但比绑定 API 更方便。可以使用inversify-binding-decorators模块创建@provide装饰器,如下所示:
import { Container } from "inversify";
import { makeProvideDecorator } from "inversify-binding-decorators";
const container = new Container();
const provide = makeProvideDecorator(container);
export { provide };
注意,示例使用了inversify-binding-decorators模块的 3.2.0 版本,即将推出的 4.0.0 版本将引入一些破坏性更改。您可以参考github.com/inversify/inversify-binding-decorators上的文档来了解有关新 API 的更多信息。
@provider 装饰器在执行时会自动为我们声明绑定,而装饰器在类声明时会被执行。这意味着我们需要在我们的应用程序中至少导入一次使用 @provider 装饰器的文件来触发类声明,否则将不会声明任何绑定:
import "../stores/movie_store";
import "../stores/actor_store";
在声明绑定之后,Store 被注入到 React 组件中。然而,我们不能像在前几章中做的那样使用 @injectable 和 @inject 注解,因为 React 组件是由 React 实例化的。这意味着我们的 IoC 容器将无法创建我们的 React 组件实例,因此无法执行任何构造函数注入。我们可以通过使用 @lazyInject 装饰器来克服这种限制:
@lazyInject(TYPE.MovieStore) public movieStore!: interfaces.MovieStore;
@lazyInject 装饰器在依赖项被使用之前立即注入它,而不是在其依赖项的实例创建时注入。可以使用 inversify-inject-decorators 模块创建 @lazyInject 装饰器,如下所示:
import { Container } from "inversify";
import getDecorators from "inversify-inject-decorators";
const container = new Container();
const { lazyInject } = getDecorators(container);
export { lazyInject };
请参阅 第五章,与依赖项一起工作,以了解更多关于依赖注入和 InversifyJS 的信息。
在 MobX 中使用依赖注入是有用的,因为我们可以注入一个具有硬编码结果的不同的存储库来执行单元测试。这允许我们在完全隔离的情况下测试组件。
MobX 替代方案
我们一直在使用 MobX 来管理我们应用程序的状态和所需的状态变更(动作)。MobX 是一个优秀的库,并且它对 TypeScript 有很好的支持。然而,它并不是唯一的选择。
React 最好的特性之一是我们有选择许多不同状态管理工具和架构的自由。选择自由可能会导致困惑,并且是初级工程师的问题,因为他们没有足够的经验来判断一个库是否比另一个库更好。另一方面,自由可以导致更多的创新和更好的解决方案。
MobX 的两个最受欢迎的替代方案是 Redux 和 Flux。您可以在 redux.js.org 上了解更多关于 Redux 的信息。请参考 facebook.github.io/flux 了解更多关于 Flux 的信息。
开发工具
我们可以为 Google Chrome 安装 React 开发者工具扩展来帮助我们调试我们的前端 React 应用程序。该扩展可以从 chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi 下载。
也有一个可用的 Google Chrome 扩展程序可以帮助我们调试 MobX 应用程序。我们可以在官方安装页面下载此扩展程序:chrome.google.com/webstore/detail/mobx-developer-tools/pfgnfdagidkfgccljigdamigbcnndkod.
这些工具允许我们查看正在渲染的组件、它们的属性和状态,以及 MobX 动作:

摘要
在本章中,我们学习了基于组件的 Web 开发的基本原则以及如何使用 React。我们还了解了诸如无状态函数组件和哑组件等概念。
在下一章中,我们将再次实现相同的应用程序。然而,我们将使用 Angular 而不是 React,并将 MobX 作为我们的应用程序开发框架。我们将尝试实现尽可能接近的应用程序副本,以便我们能够比较这两个框架。
第十二章:使用 Angular 和 TypeScript 进行前端开发
在本章中,我们将学习如何使用 Angular 和 TypeScript 开发前端网页应用程序。就像在上一章一样,我们将尝试使用我们之前实现的后端 Node.js 应用程序。我们将开发的应用程序是上一章中开发的前端应用程序的克隆。
应用程序的功能和样式将保持一致。然而,实现方式将呈现一些显著差异,因为我们打算使用 Angular 而不是 React 作为我们的前端应用程序开发框架。
我们不会在本章中详细介绍我们将要实现的应用程序的需求,因为我们已经在上一章中讨论过了。
使用 Angular 进行工作
Angular 是一个库,允许我们实现网页用户界面。在本章中,我们将使用 Angular 创建一个小型前端应用程序。
如我们在上一章所学,Node.js 中的 JavaScript 环境和网页浏览器环境相当不同。浏览器环境不支持原生的模块,加载时间也是影响前端应用程序性能的主要因素之一,这就是为什么我们在开发前端网页应用程序时需要像 webpack 这样的模块打包器。
在本章中,我们将像本书中一直做的那样使用 Webpack。我们将使用以下 Webpack 配置。它与上一章中使用的配置几乎相同,但我们做了一些修改:
const { CheckerPlugin } = require("awesome-typescript-loader");
const webpack = require("webpack");
const ExtractTextPlugin = require("extract-text-webpack-plugin");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const path = require ("path");
const corePlugins = [
new CheckerPlugin(),
new webpack.DefinePlugin({
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development")
}),
new ExtractTextPlugin({
filename: "[name].css",
allChunks: true
}),
new CopyWebpackPlugin([
{ from: "./web/frontend/index.html", to: "index.html" }
]),
我们介绍了CommonChunkPlugin。此插件用于识别重复或匹配给定规则的代码片段。当代码片段满足条件时,它将从主应用程序包中提取并添加到名为vendor的第二个包中。在这种情况下,我们将所有位于node_modules文件夹下的代码片段移动到供应商包中,这意味着我们将最终得到两个包,一个用于应用程序,一个用于第三方库:
new webpack.optimize.CommonsChunkPlugin({
name: "vendor",
minChunks: (module) => {
return module.context && module.context.includes("node_modules");
}
})
];
const devPlugins = [];
const prodPlugins = [
new webpack.optimize.UglifyJsPlugin({ output: { comments: false } })
];
const isProduction = process.env.NODE_ENV === "production";
const plugins = isProduction ? corePlugins.concat(prodPlugins) : corePlugins.concat(devPlugins);
我们还引入了一个额外的应用程序入口点。我们导入zone.js模块。这个模块是 Zones API 的 polyfill,它是一种拦截和跟踪异步工作的机制。在 Zone.js 文档中,区域被定义为如下:
“区域是一个全局对象,它配置了如何拦截和跟踪异步回调的规则。区域有以下职责:
拦截异步任务调度
在异步操作中包装回调以进行错误处理和区域跟踪
提供将数据附加到区域的方法
提供特定上下文的最后帧错误处理
(拦截阻塞方法)
在其最简单的形式中,一个 Zone 允许拦截异步操作的调度和调用,并在异步任务之前以及之后执行额外的代码。
我们需要 Zone.js,因为它是 Angular 的依赖之一。Webpack 配置的其余部分没有其他重大差异:
module.exports = {
entry: [
"zone.js/dist/zone",
"./web/frontend/main.ts"
],
devServer: {
inline: true
},
output: {
filename: "[name].js",
chunkFilename: "[name]-chunk.js",
publicPath: "/public/",
path: path.resolve(__dirname, "public")
},
devtool: isProduction ? "source-map" : "eval-source-map",
resolve: {
extensions: [".webpack.js", ".ts", ".tsx", ".js"]
},
module: {
rules: [
{
enforce: "pre",
test: /.js$/,
loader: "source-map-loader",
exclude: [/node_modules/]
},
{
test: /.(ts|tsx)$/,
loader: "awesome-typescript-loader",
exclude: [/node_modules/]
},
{
test: /.scss$/,
use: ExtractTextPlugin.extract({
fallback: "style-loader",
use: ["css-loader", "resolve-url-loader", "sass-loader"]
})
}
]
},
plugins: plugins
};
请参阅第九章,自动化你的开发工作流程,以了解更多关于 webpack 的信息。
关于示例应用程序
在本章中,我们将再次实现前一章中实现的应用程序。然而,我们将使用 Angular 而不是 React 和 MobX 作为我们的应用程序开发框架。我们将尝试尽可能接近地实现应用程序的副本,以便我们能够比较这两个框架。
应用程序包含在配套源代码中,它是一个非常小的 Web 应用程序,允许我们管理电影和演员的数据库。我们在这里不会详细解释应用程序的功能,因为我们已经在前一章中解释过了。
请参阅第十一章,使用 React 和 TypeScript 进行前端开发,以了解更多关于应用程序的功能和需求。
请参阅配套源代码,以获取应用程序的完整源代码及其配置,包括整个package.json文件等。
使用 Node.js 提供 Angular 应用程序
就像我们在前一章中所做的那样,我们需要配置 Node.js 来提供我们的前端 Web 应用程序的文件。我们使用 Express 静态中间件来实现这一功能,就像我们在前一章中所做的那样。
请参阅第十一章,使用 React 和 TypeScript 进行前端开发,以了解更多关于 Express 静态中间件的信息。特别是,请参阅使用 Node.js 提供 React 应用程序部分。
引导 Angular 应用程序
前端应用程序的入口点在web/frontend/main.ts文件中:
import { platformBrowserDynamic } from "@angular/platform-browser-dynamic";
import { AppModule } from "./app.module";
platformBrowserDynamic().bootstrapModule(AppModule).catch((err) => {
console.error(err); // tslint:disable-line
});
我们使用platformBrowserDynamic模块来引导我们的应用程序。我们通过调用bootstrapModule方法并传递我们的应用程序中的主模块作为参数来实现这一点。我们将在下一节中了解更多关于模块的内容。
在本节中,我们将重点关注platformBrowserDynamic模块。可以使用以下方式使用npm安装platform-browser-dynamic模块:
npm install --save platform-browser-dynamic
我们使用platformBrowserDynamic是因为我们期望我们的应用程序在浏览器环境中执行。Angular 应用程序也可以在 Node.js 这样的服务器端环境中执行,但这需要稍微不同的引导配置。在 Node.js 中执行 Angular 应用程序可以用来提高应用程序的初始加载时间。我们不会在本章中介绍这个主题,因为它是一个高级特性,而本章的目的只是介绍 Angular。
如果您想了解更多关于 Angular 服务器端渲染的信息,请参阅angular.io/guide/universal文档。
与 NgModules 一起工作
在前面的章节中,我们使用了一个名为AppModule的模块来引导我们的 Angular 应用程序。AppModule位于web/frontend/app.module.ts文件中,其内容如下:
import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { CommonModule } from "@angular/common";
import { AppRoutingModule } from "./app-routing.module";
import { AppComponent } from "./app.component";
import { LayoutModule } from "./config/layout.module";
import "../../node_modules/bootstrap/scss/bootstrap.scss";
import "./app.scss";
@NgModule({
bootstrap: [AppComponent],
declarations: [AppComponent],
imports: [
BrowserModule,
CommonModule,
AppRoutingModule,
LayoutModule
]
})
export class AppModule {
}
AppModule是应用程序的入口点,它提供了访问应用程序中其他所有元素的方法。Angular 文档将模块定义为如下:
@NgModule装饰器标记了一个类。@NgModule 接受一个元数据对象,该对象描述了如何编译组件的模板以及如何在运行时创建注入器。它识别模块自己的组件、指令和管道,通过exports属性使其中一些公开,以便外部组件可以使用它们。@NgModule 还可以向应用程序依赖注入器添加服务提供者。
模块是组织应用程序和通过外部库扩展其功能的一种很好的方式。Angular 库是 NgModules,例如 FormsModule、HttpClientModule 和 RouterModule。许多第三方库也作为 NgModules 提供。NgModules 将组件、指令和管道整合成功能块,每个块都专注于一个功能区域、应用程序业务领域、工作流程或常见的实用工具集合。
模块允许我们将功能分组;我们可以将某些功能所需的所有元素(组件、服务等)组合到一个模块中。
@NgModule装饰器允许我们设置某些设置。以下列表定义了伴随源代码中包含的应用程序使用的某些字段的用途:
-
bootstrap字段用于声明在引导过程中必须作为根组件的组件 -
declarations字段用于声明在 Angular 模板中将使用哪些组件 -
imports字段用于在模块内部使其他组件可用 -
exports字段用于使组件对其他模块可用 -
providers字段用于配置依赖注入绑定
需要明确指出,Angular 的@NgModule导入/导出和 ES6 导入/导出模块是完全不同的概念。
请参阅angular.io/guide/ngmodules中的文档,以了解更多关于模块的信息。
与 Angular 组件一起工作
在本节中,我们将学习如何使用组件。我们将学习如何实现组件和路由,以及其他概念,例如如何在 Angular 应用程序中实现依赖注入。
我们的第一个组件
在本节中,我们将查看我们的第一个 Angular 组件。在本章的早期部分,我们学习了如何引导 Angular 应用程序,并使用了 AppModule。后来,我们了解到 AppModule 使用 AppComponent 作为我们应用程序的根组件。现在,我们将查看 AppComponent:
import { Component } from "@angular/core";
@Component({
selector: "app-root",
template: `
<app-layout></app-layout>`,
})
export class AppComponent {
}
如您所见,在 Angular 中,组件是一个带有 @Component 装饰器的类。@Component 装饰器接受一些设置。
在这种情况下,我们使用 selector 设置来声明在模板中引用此组件时使用的名称。AppComponent 是我们应用程序的根组件;这意味着它必须作为我们的 index.html 页面的根元素显示。我们可以通过在我们的 index.html 页面中添加对组件选择器的引用来实现这一点:
<body>
<app-root>Loading...</app-root>
<script src="img/vendor.js"></script>
<script src="img/main.js"></script>
</body>
当页面加载时,它将在 <app-root> DOM 元素中首先显示“加载中...”。一旦加载了供应商和主包文件,Angular 应用程序将被执行,bootstrap 函数将在 <app-root> DOM 元素中渲染 AppComponent 的模板,这将导致“加载中...”标签消失。
我们使用 template 设置来定义当组件渲染时希望生成的输出。在这种情况下,模板正在渲染另一个使用 app-layout 作为其选择器的组件。我们内联定义了模板,但也可以在单独的 HTML 文件中定义模板,并通过使用 templateUrl 设置来引用它。
需要注意的是,我们只能在模板中使用组件,前提是这两个组件都在 NgModule 中正确配置,正如前文所述。
有时组件将使用额外的设置;我们不会在本章中解释所有可用的设置,因为我们的目标只是提供一个基本的 Angular 介绍。
还值得一提的是,在 Angular 中,组件始终是一个类。在 React 中,可以以函数或类的形式实现组件,但在 Angular 中组件始终是类,这意味着 Angular 中没有无状态的函数式组件。
请参阅angular.io/guide/architecture-components中的文档,以了解更多关于 Angular 组件的信息。
组件和指令
现有的 Angular 文献中包含许多关于被称为指令的内容的引用。有时,指令被提及为可以与组件一起使用,好像它们是同一件事。事实是,组件是一种指令。以下内容摘自官方 Angular 文档:
Angular 中有三种类型的指令:
-
组件:具有模板的指令。
-
结构指令:通过添加和删除 DOM 元素来改变 DOM 布局。
-
属性指令:改变元素、组件或另一个指令的外观或行为。
我们在本章中不会学习如何创建自定义属性指令。然而,了解组件是一种指令是很重要的。
请参阅angular.io/guide/attribute-directives中的文档以了解更多关于指令的信息。
数据绑定
在 Angular 中,我们使用数据绑定来协调应用程序的状态与屏幕上渲染的内容。Angular 支持三种类型的绑定,根据数据流的方向进行区分:
| 数据方向 | 语法 | 类型 |
|---|
| 从数据源单向
到视图目标 | {{expression}} [target]="expression" |
-
插值
-
属性
-
属性
-
类
-
样式
|
| 从视图目标单向
到数据源 | (target)="statement" | 事件 |
| 双向 | [(target)]="expression" |
双向 |
|---|
除了插值之外的其他绑定类型在等号左侧有一个目标名称,周围有标点符号([]和())。
请参阅angular.io/guide/template-syntax中的文档以了解更多关于数据绑定语法的知识。
使用@Attribute 和@Input 一起工作
在上一节中,我们了解到AppComponent渲染了AppLayout组件。在本节中,我们将探讨AppLayout:
import { Component, OnInit } from "@angular/core";
import { Route } from "../components/header.component";
@Component({
selector: "app-layout",
template: `
<div>
<app-header
bg="primary"
title="TsMovies"
rootPath=""
[links]="appRoutes"
></app-header>
<main>
<router-outlet></router-outlet>
</main>
</div>
`
})
export class LayoutComponent {
public appRoutes: Route[] = [
{ label: "Movies", path: "movies" },
{ label: "Actors", path: "actors" }
];
}
Layout组件使用app-layout选择器并声明了一个内联模板。该模板使用了具有app-header和router-outlet选择器的另外两个组件。我们现在将忽略具有router-outlet选择器的组件,因为我们将在后面的使用 Angular 路由部分中了解更多关于它的内容。
让我们暂时关注具有选择器app-header的组件。正如我们可以在前面的代码片段中看到的那样,一些参数被传递给了HeaderComponent。然而,并非所有参数都以相同的方式传递。
我们有一些按以下方式传递的参数(从数据源到视图目标的单向数据绑定):
bg="primary"
在这种情况下,我们将字符串值 primary 作为属性传递。我们还有一些按以下方式传递的参数:
[links]="appRoutes"
在这种情况下,我们将属性appRoutes的值绑定并传递给AppHeader组件。这意味着appRoutes值的任何更改都将触发AppHeader组件的重新渲染。
现在,我们将查看 AppHeader 组件,看看如何定义属性和输入:
import { Component, Input, Attribute } from "@angular/core";
type BgColor = "primary" | "secondary" | "success" |
"danger" | "warning" | "info" | "light" |
"dark" | "white";
export interface Route {
label: string;
path: string;
}
@Component({
selector: "app-header",
template: `
<nav [ngClass]="navClass">
<a class="navbar-brand" [routerLink]="rootPath" routerLinkActive="active">
{{title}}
</a>
<ul class="navbar-nav">
<li *ngFor="let link of links">
<a class="navbar-brand" [routerLink]="link.path" routerLinkActive="active">
{{link.label}}
</a>
</li>
</ul>
</nav>`
})
export class HeaderComponent {
public navClass!: string;
public title!: string;
public rootPath!: string;
@Input() public links!: Route[];
public constructor(
@Attribute("bg") bg: BgColor,
@Attribute("title") title: string,
@Attribute("rootPath") rootPath: string,
) {
this.navClass = `navbar navbar-expand-lg navbar-light bg-${bg}`;
this.title = title;
this.rootPath = rootPath;
}
}
HeaderComponent 接受一些属性,这些属性使用 @Attribute 装饰器定义。HeaderComponent 还接受一个输入,该输入使用 @Input 装饰器定义:
-
@Input装饰器用于将值传递到组件中,或将数据从一个组件传递到另一个组件(通常是父到子) -
@Attribute目录用于检索组件主元素中可用的属性的常量值,并且它必须与组件构造函数的参数一起使用
使用结构化指令
在前面的章节中,我们讨论了 HeaderComponent。然而,我们跳过了一些关于其模板的细节。HeaderComponent 使用所谓的结构化指令:
<li *ngFor="let link of links">
// ...
</li>
结构化指令负责 HTML 布局。它们塑造或重塑 DOM 的结构,通常是通过添加、删除或操作元素。
请参阅angular.io/guide/structural-directives 上的文档,了解结构化指令的更多信息。
使用 <ng-content> 指令
我们可以使用 ng-content 指令来 RowComponent 时,我们不知道哪些内容将被放入行中。我们使用 ng-content 指令来引用尚未知的子组件:
@Component({
selector: "app-row",
template: `
<div class="row">
<ng-content></ng-content>
</div>
`
})
export class RowComponent {}
然后,RowComponent 可以在模板中使用,如下所示:
<app-row>
<h1>Title</h1>
</app-row>
<app-row>
<h2>Subtitle</h2>
</app-row>
与 @Output 和 EventEmitter 一起工作
在 Angular 中,我们可以使用一个值是事件发射器的属性来处理用户事件。该属性必须使用 @Output 装饰器进行装饰,如下面的代码片段所示:
import { Component, EventEmitter, Input, Output } from "@angular/core";
@Component({
selector: "app-text-field",
template: `
<input
type="text"
className="form-control"
[id]="id"
[placeholder]="placeholder"
(input)="onEdit($event)"
/>
`
})
export class TextFieldComponent {
@Input() public id!: string;
@Input() public placeholder!: string;
@Output() public onChange = new EventEmitter<{k: string; v: string}>();
public onEdit(event: any) {
const value = (event.target as any).value;
const key = (event.target as any).id;
this.onChange.emit({ v: value, k: key });
}
}
在我们的模板中,我们已将一个事件与组件中的一个方法链接起来,如下所示(从视图目标到数据源的单向数据绑定):
(input)="onEdit($event)"
onEdit 方法将接收一个事件对象,该对象允许访问目标(触发事件的 DOM 元素)。我们可以使用事件目标来访问 DOM 元素的属性。
最后,我们调用输出的 emit 方法来将一些数据作为输出传递给父组件:
public onEdit(event: any) {
const value = (event.target as any).value;
const key = (event.target as any).id;
this.onChange.emit({ v: value, k: key });
}
最后,父组件可以将其方法之一设置为 onChange 输出的事件处理器,如下所示:
<app-text-field
[id]="'title'"
[placeholder]="'Title'"
(onChange)="edit($event)"
></app-text-field>
请参阅angular.io/guide/component-interaction 上的文档,了解 Angular 中事件处理器的更多信息。
与组件的主元素一起工作
在本节中,我们将演示如何使用组件中的host设置来控制组件宿主的渲染方式。当组件被渲染时,Angular 将始终创建一个与组件选择器名称匹配的 DOM 元素。这个 DOM 元素被称为宿主。例如,看一下以下组件:
@Component({
selector: "app-row",
template: `
<div class="row">
<ng-content></ng-content>
</div>
`
})
export class RowComponent {}
它可以被其他组件消费,如下所示:
<app-row>
Hello!
</app-row>
然而,它将被渲染为:
<app-row>
<div class="row">
Hello!
</div>
</app-row>
如您所见,有一个额外的 DOM 节点。有时,额外的节点可能会导致一些布局问题,如果我们能够控制宿主渲染以实现以下输出,那就更好了:
<app-row class="row">
Hello!
</app-row>
我们可以通过在声明组件时使用host设置来实现这一点:
@Component({
host: {
"[class]": "'row'"
},
selector: "app-row",
template: `
<ng-content></ng-content>
`
})
export class RowComponent {}
与 Angular 路由一起工作
在本章的早期部分,我们提到了一个名为router-outlet的组件。该组件被Layout组件如下使用:
<main>
<router-outlet></router-outlet>
</main>
然而,这个组件不是我们定义的,因为它是由@angular/router npm 模块定义的。为了使用该模块,我们必须导入它并声明一个导出RouteModule的NgModule。我们还必须声明路由的配置。配置是一个将给定路径与给定组件关联的映射或字典:
import { NgModule } from "@angular/core";
import { Routes, RouterModule } from "@angular/router";
import { HomePageComponent } from "./pages/homepage.component";
import { MoviesPageComponent } from "./pages/moviespage.component";
import { ActorsPageComponent } from "./pages/actorspage.component";
export const appRoutes: Routes = [
{ path: "", component: HomePageComponent },
{ path: "movies", component: MoviesPageComponent },
{ path: "actors", component: ActorsPageComponent }
];
@NgModule({
exports: [RouterModule],
imports: [
RouterModule.forRoot(
appRoutes,
{ useHash: false }
)
]
})
export class AppRoutingModule {}
当浏览器 URL 与路由配置中的路径之一匹配时,相应的组件将被渲染到router-outlet组件中:
<main>
<router-outlet></router-outlet>
</main>
我们可以使用routerLink触发 URL 的变化:
<a class="navbar-brand" [routerLink]="link.path" routerLinkActive="active">
{{link.label}}
</a>
我们已经提供了我们希望导航到的路径以及当链接处于活动状态时要使用的 CSS 类。当我们点击路由链接时,浏览器 URL 将会改变,并且路由器将在router-outlet组件下渲染匹配的组件。
Angular 组件生命周期钩子
Angular 允许我们声明多个组件生命周期钩子。例如,在伴随源代码中,Movie组件扩展了OnInit接口,该接口声明了ngOnInit方法。ngOnInit方法是 Angular 中可用的组件生命周期钩子之一:
-
组件类的构造函数在调用任何其他组件生命周期钩子之前被调用。构造函数是注入依赖的最佳位置。
-
ngOnInit方法在构造函数之后以及ngOnChange第一次触发后立即被调用,这是初始化工作的完美时机。 -
当绑定属性的值发生变化时,
ngOnChanges方法首先被调用。它会在输入属性的值每次变化时执行。 -
在组件实例最终被销毁之前,将调用
ngDestroy方法。这是清理组件(例如,取消后台任务)的完美位置。
还有更多的生命周期钩子可用,但它们超出了本书的范围。
请参阅 Angular 文档angular.io/guide/lifecycle-hooks,以了解所有可用的生命周期钩子。
与服务一起工作
在 Angular 中,使用服务与 REST API 或其他资源(如 localStorage API)交互是一种常见的做法。下面的类定义了一个名为MovieService的服务,它可以用来向后端 Node.js 应用程序发送 HTTP 请求。
服务只是一个类,它不需要任何特殊的装饰器。然而,以下代码片段使用了@Injectable装饰器,因为它将被注入到MovieComponent中。我们将在Angular 中的依赖注入部分学习更多关于依赖注入的内容。
以下方法使用 Fetch API 对服务器执行一些 HTTP 请求。有一个方法可以从电影 REST API 获取所有电影:
import { Injectable } from "@angular/core";
import { MovieInterface } from "../../universal/entities/movie";
import * as interfaces from "../interfaces";
@Injectable()
export class MovieService implements interfaces.MovieService {
public async getAll() {
return new Promise<MovieInterface[]>(async (res, rej) => {
try {
const response = await fetch("/api/v1/movies/", { method: "GET" });
const movs: MovieInterface[] = await response.json();
// We use setTimeout to simulate a slow request
// this should allow us to see the loading component
setTimeout(
() => {
res(movs);
},
1500
);
} catch (error) {
rej(error);
}
});
}
此外,还有一个方法可以创建一个新的电影:
public async create(movie: Partial<MovieInterface>) {
const response = await fetch(
"/api/v1/movies/",
{
body: JSON.stringify(movie),
headers: {
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json"
},
method: "POST"
}
);
const newMovie: MovieInterface = await response.json();
return newMovie;
}
此外,还有一个方法可以删除电影:
public async delete(id: number) {
const response = await fetch(`/api/v1/movies/${id}`, { method: "DELETE" });
await response.json();
}
}
在下一节中,我们将学习电影服务是如何被Movie组件消费的。
智能组件和哑组件
Angular 组件不像 React 那样在属性和状态之间划清界限,但我们可以仍然使用相同的思维模型。一个组件渲染一些数据。如果数据被组件修改,我们谈论的是智能组件。如果组件只读取数据,我们谈论的是哑组件。
就像我们在 React 示例中所做的那样,我们通过使用多个目录组织我们的项目,以便我们可以非常清晰地区分智能组件和哑组件。components目录只包含哑组件,而pages目录包含智能组件。
在 Angular 中,大多数情况下,哑组件不需要生命周期钩子,它们也不需要服务。另一方面,智能组件很可能需要一些服务。
以下代码片段声明了一个名为MoviePages的智能组件。在我们的 Angular 应用程序中,页面是智能组件,而组件仅仅是哑组件:
import { Component, OnInit, Inject } from "@angular/core";
import { MovieInterface } from "../../universal/entities/movie";
import * as interfaces from "../interfaces";
import { MOVIE_SERVICE } from "../config/types";
function isValidNewMovie(o: any) {
if (
o === null ||
o === undefined ||
// new movies don't have ID
o.id !== undefined ||
typeof o.title !== "string" ||
isNaN(o.year)
) {
return false;
}
return true;
}
该组件渲染电影列表:
@Component({
selector: "movies-page",
template: `
<app-container>
<app-row>
<app-column width="12">
<div style="text-align: right; margin-bottom: 10px">
<app-button (clicked)="focusEditor()">
Add Movie
</app-button>
</div>
</app-column>
</app-row>
<app-row>
<app-column width="12">
<app-list-group [isLoaded]="isLoaded" [errorMsg]="fetchErrorMsg">
<app-list-group-item *ngFor="let movie of movies">
<app-row>
<app-column width="8">
<h5>{{movie.title}}</h5>
<p>{{movie.year}}</p>
</app-column>
<app-column width="4" style="text-align: right">
<app-button kind="danger" (clicked)="focusDeleteDialog(movie.id)">
Delete
</app-button>
</app-column>
</app-row>
</app-list-group-item>
</app-list-group>
</app-column>
</app-row>
此组件还渲染一个模态窗口,允许我们创建一个电影:
<div *ngIf="editorValue">
<app-modal
[title]="'Movie Editor'"
[acceptLabel]="'Save'"
[cancelLabel]="'Cancel'"
[error]="saveStatus"
(onCancel)="focusOutEditor()"
(onAccept)="saveMovie()"
>
<form>
<app-text-field
[id]="'title'"
[title]="'Title'"
[placeholder]="'Title'"
[errorMsg]="isValidTitle"
(onChange)="edit($event)"
></app-text-field>
<app-text-field
[id]="'year'"
[title]="'Year'"
[placeholder]="'Year'"
[errorMsg]="isValidYear"
(onChange)="edit($event)"
></app-text-field>
</form>
</app-modal>
</div>
此组件还渲染一个模态窗口,允许我们确认我们希望从数据库中删除电影:
<div *ngIf="deleteMovieId !== null">
<app-modal
[title]="'Delete?'"
[acceptLabel]="'Delete'"
[cancelLabel]="'Cancel'"
[error]="deleteStatus"
(onCancel)="focusOutDeleteDialog()"
(onAccept)="deleteMovie()"
>
Are you sure?
</app-modal>
</div>
</app-container>
`
})
MoviesPageComponent是一个智能组件。正如我们可以在以下代码片段中看到的那样,它持有并管理在其模板中使用的所有哑组件所需的所有状态:
export class MoviesPageComponent implements OnInit {
// Contains the movies that have been already loaded from the server
public movies: MovieInterface[];
// Used to represent the status of the HTTP GET calls
public isLoaded!: boolean;
// Display error if loading fails
public fetchErrorMsg: null | string;
// Used to represent the status of the HTTP DELETE call
public deleteStatus: null | string;
// Used to represent the status of the HTTP POST and HTTP PUT calls
public saveStatus: null | string;
// Used to display the confirmation dialog before deleting a movie
// null hides the modal and number displays the modal
public deleteMovieId: null | number;
// Used to hold the values of the movie editor or null when nothing is being edited
public editorValue: null | Partial<MovieInterface>;
public isValidTitle!: null | string;
public isValidYear!: null | string;
public movieService!: interfaces.MovieService;
MovieService被注入到组件中。我们现在可以忽略这方面的细节,因为将在下一节中解释:
public constructor(
@Inject(MOVIE_SERVICE) movieService: interfaces.MovieService
) {
this.movieService = movieService;
this.movies = [];
this.fetchErrorMsg = null;
this.isLoaded = false;
this.deleteStatus = null;
this.saveStatus = null;
this.deleteMovieId = null;
this.editorValue = null;
this.isValidTitle = null;
this.isValidYear = null;
}
然后,我们使用ngOnInit事件钩子来触发初始数据获取:
public async ngOnInit() {
this.isLoaded = false;
try {
this.movies = await this.movieService.getAll();
this.isLoaded = true;
this.fetchErrorMsg = null;
} catch (err) {
this.isLoaded = true;
this.fetchErrorMsg = "Loading failed!";
}
}
在声明属性、构造函数和组件的ngOnInit事件之后,我们将声明一些方法。正如您可以在以下代码片段中看到的那样,这些方法用于修改应用程序的状态:
public focusEditor() {
this.editorValue = {};
}
public focusOutEditor() {
this.editorValue = null;
}
public focusDeleteDialog(id: number) {
this.deleteMovieId = id;
}
public focusOutDeleteDialog() {
this.deleteMovieId = null;
}
public edit(keyVal: any) {
const movie = {
...(this.editorValue || {}),
...{[keyVal.k]: keyVal.v}
};
if (movie.title) {
this.isValidTitle = (movie.title && movie.title.length) > 0 ? null : "Title cannot be empty!";
}
if (movie.year) {
this.isValidYear = isNaN(movie.year) === false ? null : "Year must be a number!";
}
this.editorValue = movie;
}
在上一章中,我们学习了关于 MobX 架构的基本知识。MobX 架构与 Angular 架构之间存在一些显著差异:
-
在 MobX 中,应用程序状态属于
Store。智能组件通过动作与Store通信。Store是最终修改状态的实体,而不是智能组件。 -
在 Angular 中,应用程序状态属于智能组件,这是最终修改状态的实体。
在 Angular 中,我们使用服务来执行 Ajax 调用;另一方面,在 MobX 中,我们在Store内部执行 Ajax 调用。这个区别很小,因为我们可以在 MobX 中创建一个服务来执行 Ajax 调用。然后Store可以与该服务通信。这里的关键点是状态管理的差异。
以下方法使用MovieService执行一些 Ajax 调用并修改MovieComponent的状态:
public async saveMovie() {
if (isValidNewMovie(this.editorValue)) {
const newMovie = await this.movieService.create(this.editorValue as any);
this.movies.push(newMovie);
this.saveStatus = null;
this.editorValue = null;
} else {
this.saveStatus = "Invalid movie!";
}
}
public async deleteMovie() {
try {
if (this.deleteMovieId) {
await this.movieService.delete(this.deleteMovieId);
this.movies = this.movies.filter((m) => m.id !== this.deleteMovieId);
this.deleteStatus = null;
this.deleteMovieId = null;
}
} catch (err) {
this.deleteStatus = "Cannot delete movie!";
}
}
}
Angular 中的依赖注入
Angular 中的依赖注入要求我们使用InjectionToken类定义一些唯一的标识符。注入令牌是用于在运行时表示类型的唯一标识符。Angular 中InjectionToken的概念与 InversifyJS 中的符号概念非常相似:
import { InjectionToken } from "@angular/core";
import { MovieService, ActorService } from "../interfaces";
export const ACTOR_SERVICE = new InjectionToken<MovieService>("ActorService");
export const MOVIE_SERVICE = new InjectionToken<MovieService>("MovieService");
在创建InjectionToken之后,我们必须使用@injectable装饰器装饰我们希望注入的类,如下面的代码片段所示:
import { InjectionToken } from "@angular/core";
// ...
@Injectable()
export class MovieService implements interfaces.MovieService {
// ...
我们还必须在InjectionToken和它所代表的类型实现之间声明一个绑定。这可以通过在声明NgModule时使用providers设置来实现,如下所示:
import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { MoviesPageComponent } from "./moviespage.component";
import { ComponentsModule } from "../components/components.module";
import { MovieService } from "../services/movie_service";
import { MOVIE_SERVICE } from "../config/types";
@NgModule({
declarations: [
MoviesPageComponent
],
exports: [
MoviesPageComponent
],
imports: [CommonModule, ComponentsModule],
providers: [
{ provide: MOVIE_SERVICE, useClass: MovieService }
]
})
export class MoviesPageModule {
}
前面的代码片段将InjectionToken MOVIE_SERVICE绑定到MovieService实现。最后,我们可以使用@Inject装饰器在我们的一些 Angular 组件中声明一个依赖项:
import { Component, Inject } from "@angular/core";
// ...
public constructor(
@Inject(MOVIE_SERVICE) movieService: interfaces.MovieService
) {
this.movieService = movieService;
// ...
}
请参阅angular.io/guide/dependency-injection中的文档,以了解更多关于 Angular 中依赖注入的信息。
摘要
在本章中,我们学习了基于组件的 Web 开发的基本原则以及如何使用 Angular。我们学习了如何启动 Angular 应用程序,如何实现路由,以及如何创建组件。
我们还学习了如何实现哑组件和智能组件,以及如何与服务和实现依赖注入一起工作。
在下一章中,我们将学习关于应用程序性能的内容。
第十三章:应用性能
在计算机科学中,系统资源,或简单地称为资源,是计算机系统内有限可用性的任何物理或虚拟组件。连接到计算机系统的每个设备都是资源。每个内部系统组件也是资源。
在本章中,我们将学习如何有效地管理系统的可用资源以实现卓越的应用性能。我们将了解不同种类的资源、性能因素和性能分析技术。
本章首先介绍了一些核心性能概念,例如延迟或带宽,然后继续展示如何作为持续集成过程的一部分来衡量和监控性能。
正如我们在前面的章节中学到的,我们可以使用 TypeScript 生成可以在许多不同环境中执行 JavaScript 代码。在本章中,我们将学习关于性能分析和优化技术,这些技术主要适用于 Web 应用程序的开发。我们将涵盖以下主题:
-
性能和资源
-
性能方面
-
内存分析
-
网络分析
-
CPU 和 GPU 分析
-
性能测试
-
性能建议
-
性能自动化
前提条件
我们即将学习如何执行一些性能分析任务;然而,在此之前,我们需要在我们的开发环境中安装一些工具。
Google Chrome
在我们开始之前,我们需要安装 Google Chrome。我们可以在www.google.com/chrome/browser/desktop/index.html下载它。我们将学习如何使用 Google Chrome 开发者工具来执行一些性能分析任务。
Node.js
如果你在前面的章节中没有安装 Node.js,你可以访问nodejs.org/en/download/下载适用于你操作系统的安装程序。Node.js 有两种主要版本可供选择:长期支持(LTS)和当前。我们建议使用 LTS 版本。
性能和资源
在我们开始进行性能分析之前,我们必须首先花些时间了解一些核心概念和性能的方面。
一个好的应用程序具有一系列期望的特性,包括:
-
功能性
-
可靠性
-
可用性
-
可重用性
-
效率
-
可维护性
-
可移植性
在本书到目前为止的篇幅中,我们已经学到了很多关于可维护性和可重用性的知识。在本章中,我们将重点关注性能,它与可靠性和可维护性密切相关。
性能是指与所使用的时间和资源相比完成的有用工作的量。资源是一个具有有限可用性的物理(如 CPU、RAM、GPU、HDD 等)或虚拟(如 CPU 时间、RAM 区域、文件等)组件。由于资源的可用性有限,每个资源在进程之间共享。当一个进程完成使用资源后,它必须在其他任何进程可以使用它之前释放它。以有效的方式管理可用资源将有助于减少其他进程等待资源变得可用的时间。
当我们开发 Web 应用时,我们需要记住以下资源将具有有限的可用性:
-
中央处理器(CPU):通过执行由指令指定的基本算术、逻辑、控制和输入/输出(I/O)操作来执行计算机程序的指令。
-
图形处理器单元(GPU):这是一种用于在帧缓冲区中创建图像时加速内存操作和修改的专用处理器。帧缓冲区是 RAM 中用于存储不断发送到屏幕的数据帧的区域。当创建使用 WebGL API 的应用程序或使用 CSS 动画时,我们会使用 GPU。
-
随机存取存储器(RAM):这允许以大约相同的时间读取和写入数据项,无论访问数据项的顺序如何。当我们声明一个变量时,它将被存储在 RAM 中;当变量超出作用域时,它将通过垃圾收集器从 RAM 中删除。
-
硬盘驱动器(HDD)和固态驱动器(SSD):这两种资源都是用于存储和检索数据的存储设备。前端 Web 应用通常不会大量使用持久数据存储。然而,我们应该记住,无论何时以持久方式存储对象(如 cookies、本地存储、IndexedDB 等),我们的应用程序的性能都会受到硬盘或固态硬盘可用性的影响。
-
网络吞吐量:这决定了在单位时间内通过网络可以发送多少实际数据。网络吞吐量由网络延迟或带宽等因素决定(我们将在本章后面发现更多关于这些因素的信息)。
常见性能指标
性能可能会受到多种物理和虚拟设备的可用性的影响。这解释了为什么存在多个性能指标(用于衡量性能的因素)。一些流行的性能指标包括可用性、响应时间、处理速度、延迟、带宽和可伸缩性。这些测量机制通常与前面列出的某个通用资源(如 CPU、网络吞吐量等)直接相关。现在我们将详细查看每个这些性能指标。
可用性
如果系统在某些阶段不可用,即使只是部分不可用,我们也会认为其性能不佳。通过提高系统的可靠性、可维护性和可测试性,可以提高系统的可用性。如果系统易于测试和维护,那么提高其可靠性也将变得容易。
响应时间
响应时间是指响应服务请求所需的时间。这里的“服务”并不指代网络服务;服务可以是任何工作单元。响应时间受网络吞吐量的影响,可以分为三个部分:
-
等待时间:请求将花费的时间等待之前发生并完成的其他请求。
-
服务时间:服务(工作单元)完成所需的时间。
-
传输时间:一旦工作单元完成,响应将被发送回请求者。响应传输所需的时间称为传输时间。
处理速度
处理速度(也称为时钟频率)是指处理单元(CPU 或 GPU)运行的频率。一个应用程序包含许多工作单元。每个工作单元由处理器的指令组成;通常,处理器可以在每个时钟周期内执行一条指令。由于操作完成需要几个时钟周期,因此时钟频率(处理速度)越高,完成的指令就越多。
带宽
在本章中提到带宽时,我们将指的是网络带宽。带宽,或数据传输率,是指在给定时间内可以从一个点传输到另一个点的数据量。网络带宽通常以每秒比特数来表示。
延迟
延迟是一个可以应用于系统许多元素的术语;然而,当我们在处理 Web 应用程序时,我们将使用这个术语来指代网络延迟。网络延迟表示在网络数据通信中发生的任何延迟。
高延迟会在通信带宽中造成瓶颈。延迟对网络带宽的影响可能是暂时的或持续的,这取决于延迟的原因。高延迟可能由介质(电缆或无线信号)的问题、路由器和网关的问题以及防病毒软件等问题引起。
网络性能可能受到许多因素的影响。其中一些因素可能会降低网络吞吐量。例如,高数据包丢失、延迟和抖动会降低网络吞吐量,而高带宽则会提高它。
可扩展性
可扩展性是指系统处理不断增长的工作量的能力。具有良好可扩展性的系统将能够通过一些性能测试,例如峰值或压力测试。我们将在本章后面了解更多关于性能测试(如峰值和压力测试)的内容。
性能分析
性能分析(也称为性能分析)是观察和研究中应用程序使用的可用系统资源。我们将进行性能分析以识别应用程序中的性能问题。对于每种类型的资源,将执行不同的性能分析过程。例如,可以使用我们操作系统的系统监视器进行 CPU 分析。
现在,我们将学习如何使用 Google Chrome 的开发者工具执行一些网络分析任务。
网络性能分析
我们将首先分析网络性能。不久前,为了能够分析应用程序的网络性能,我们必须从头开始编写一个小型的网络日志应用程序。幸运的是,今天,由于性能计时 API 的出现,事情变得容易多了。
性能计时 API 允许我们访问每个加载资源的详细网络计时数据。您可以在www.w3.org/TR/resource-timing/了解更多信息。
以下图表说明了 API 提供的网络计时数据点:

网络计时数据点
我们可以通过window对象访问性能计时 API:
window.performance
window对象中的性能属性包含一些属性(memory、navigation和timing)和方法(clearMarks、clearMeasures和getEntries)。我们可以使用getEntries函数来访问一个包含每个请求的驯服数据点的数组:
window.performance.getEntries()
数组中的每个实体都是PerformanceResourceTiming的一个实例,它包含以下信息:
{
connectEnd: 1354.525000002468
connectStart: 1354.525000002468
domainLookupEnd: 1354.525000002468
domainLookupStart: 1354.525000002468
duration: 179.89400000078604
entryType: "resource"
fetchStart: 1354.525000002468
initiatorType: "link"
name: "https://developer.chrome.com/static/css/out/site.css"
redirectEnd: 0
redirectStart: 0
requestStart: 1380.8379999827594
responseEnd: 1534.419000003254
responseStart: 1533.6550000065472
secureConnectionStart: 0
startTime: 1354.525000002468
}
不幸的是,如果这些时间数据点不以可视化的方式呈现,那么它们可能没有用。幸运的是,有一些工具可以帮助我们轻松分析它。现在,我们将了解一些这些工具。
第一款工具是一个名为performance-bookmarklet的浏览器扩展。这个扩展是开源的,适用于 Chrome 和 Firefox。扩展下载链接可以在github.com/micmro/performance-bookmarklet找到。
在以下屏幕截图中,我们可以看到由扩展生成的图表之一。图表以可视化的方式显示性能计时 API 信息,使我们能够轻松地发现性能问题:

或者,我们可以使用 Chrome 开发者工具中的网络面板来执行网络性能分析。要访问网络面板,请打开 Google Chrome,导航到视图 | 开发者,然后导航到开发者工具:

Windows 和 Linux 用户可以通过按F12键访问开发者工具。OS X 用户可以使用Alt + Cmd + I快捷键访问它。
一旦开发者工具可见,我们可以通过点击它来访问网络标签:

网络标签
点击网络标签将带我们到一个类似于这里看到的屏幕:

如我们所观察到的,信息以表格的形式呈现,其中每个加载的文件都显示为一行。在右侧,我们可以看到时间线列。时间线以可视化的方式显示性能时间 API,就像性能-bookmarklet 扩展一样。
时间线列中有两个非常重要的元素是红色和蓝色的垂直线。这些线条让我们知道何时触发DOMContentLoaded事件(蓝色线条),之后触发onLoad事件(红色线条):
-
蓝线表示
DOMContentLoaded事件被触发的时间。当引擎完成对主文档的解析时,会触发DOMContentLoaded事件。 -
红线表示
onLoad事件被触发的时间。当页面上所有资源都加载完毕时,会触发onLoad事件:

我们可以通过检查这些事件触发时完成的请求,来了解整体页面的响应性和加载时间。
如果我们将鼠标悬停在时间列的任何一个单元格上,我们将能够看到每个性能时间 API 数据点:

有趣的是,这个开发者工具使用性能时间 API 读取这些信息。我们现在将更深入地了解每个数据点的含义:
| 性能时间 API 数据点 | 描述 |
|---|---|
| 阻塞/停滞 | 请求在发送之前等待的时间;对于源有一个最大数量的开放 TCP 连接。当达到限制时,一些请求将显示阻塞时间,而不是停滞时间。同一源(域名地址)的最大 TCP 连接数为 6 个。 |
| 代理协商 | 与代理服务器协商连接所花费的时间。 |
| DNS 查找 | 解析 DNS 地址所花费的时间;解析 DNS 需要每个页面上每个域的 DNS 服务器进行完整往返。 |
| 初始连接/连接中 | 建立连接所花费的时间。 |
| SSL | 建立 SSL 连接所花费的时间。 |
| 请求发送/发送中 | 发出网络请求所花费的时间;通常,是毫秒的一部分。 |
| 等待(TTFB) | 接收初始字节数据所花费的时间——首次字节时间(TTFB);TTFB 可以用来找出往返服务器的延迟,以及等待服务器发送响应所花费的时间。 |
| 内容下载/下载中 | 等待响应数据接收所花费的时间。 |
请参阅官方 Google Chrome 文档developers.google.com/web/tools/chrome-devtools/network-performance/reference#timing-explanation,了解更多关于时间 API 的信息。
网络性能和用户体验
现在我们知道了如何分析网络性能,是时候确定我们应该追求的性能目标了。许多研究表明,为了实现良好的用户体验(UX),保持加载时间尽可能低是很重要的。Akamai 在 2009 年 9 月发表的一项研究,对 1,048 名在线购物者进行了调查,发现:
-
47%的人期望网页在两秒或更短的时间内加载完成
-
如果页面加载超过三秒,40%的人会放弃网页
-
52%的在线购物者声称快速页面加载对他们对网站的忠诚度很重要
-
如果页面加载速度慢,14%的人会开始在不同的网站上购物
-
如果页面加载速度慢,23%的人会停止购物甚至离开电脑
-
对网站访问不满意的有 64%的购物者下次购物时会去其他地方
您可以在www.akamai.com/us/en/about/news/press/2009-press/akamai-reveals-2-seconds-as-the-new-threshold-of-acceptability-for-ecommerce-web-page-response-times.jsp阅读完整的 Akamai 研究。
从前面的研究结论中,我们应该假设网络性能很重要。我们的首要任务是尝试提高我们应用程序的加载速度。
如果我们试图提高网站的性能以确保它在两秒内加载完成,我们可能会犯一个常见的错误:试图在两秒内触发onLoad事件。
尽管尽早触发onLoad事件可能会提高应用程序的网络性能,但这并不意味着用户体验会同样得到改善。onLoad事件不足以确定性能。我们可以通过比较 Twitter 和 Amazon 网站的加载性能来证明这一点。正如以下截图所示,用户可以比在 Twitter 上更快地与 Amazon 互动。尽管两个网站上的onLoad事件相同,但用户体验却截然不同:

Twitter 和 Amazon 网站
上述例子说明了为什么尝试以尽可能早的方式加载网页内容以开始用户互动是很重要的。实现这一目标的一种方法是通过确保我们在初始页面加载时只加载必要的最小资产。然后我们可以异步加载所有次要资产。
参考第三章[82486ffc-fd37-49ec-938f-0e2aec26ebf8.xhtml],与函数一起工作,了解使用 TypeScript 进行异步编程的更多信息。
网络性能最佳实践和规则
分析 Web 应用性能的另一种方法是使用网络性能最佳实践工具,例如Google PageSpeed Insights应用或Yahoo YSlow应用。
Google PageSpeed Insights 可以在线使用或作为 Google Chrome 扩展使用。要尝试此工具,我们可以访问在线版本developers.google.com/speed/pagespeed/insights/并插入我们想要分析的 Web 应用的 URL。几秒钟后,我们将得到如下截图所示的报告:

报告包含一些有效的建议,将帮助我们提高 Web 应用的网络性能和整体用户体验。Google PageSpeed Insights 使用以下规则来评估 Web 应用的速度:
-
避免落地页重定向
-
启用压缩
-
提高服务器响应时间
-
利用浏览器缓存
-
压缩资源
-
优化图片
-
优化 CSS 交付
-
优先处理可见内容
-
移除渲染阻塞的 JavaScript
-
使用异步脚本
如果我们点击某条规则的分数,我们将能够看到推荐和详细信息,这将帮助我们了解哪里出了问题以及我们需要做什么来提高得分。
另一方面,Yahoo YSlow 可以作为浏览器扩展、Node.js 模块和 PhantomJS 插件等多种方式使用。我们可以在yslow.org/找到适合我们需求的正确版本。YSlow 生成一份报告,将为我们提供网站的一般得分和详细得分,如下面的截图所示:

YSlow 使用以下规则集来评估 Web 应用的速度:
-
最小化 HTTP 请求
-
使用内容分发网络
-
避免空的
src或href -
添加过期或缓存控制头
-
GZIP 组件
-
将样式表放在顶部
-
将脚本放在底部
-
避免 CSS 表达式
-
将 JavaScript 和 CSS 外部化
-
减少 DNS 查找
-
压缩 JavaScript 和 CSS
-
避免重定向
-
移除重复的脚本
-
配置 ETags
-
使 Ajax 可缓存
-
使用 GET 进行 Ajax 请求
-
减少 DOM 元素数量
-
防止 404 错误
-
减少 cookie 大小
-
为组件使用无 cookie 域名
-
避免过滤器
-
不要在 HTML 中缩放图片
-
使
favicon.ico文件小且可缓存
如果我们点击某条规则的分数,我们将看到一些推荐和详细信息,这将帮助我们了解哪里出了问题以及我们需要做什么来提高特定规则的得分。
如果你想了解更多关于网络性能优化的信息,请参阅由Ilya Grigorik撰写的书籍《高性能浏览器网络》。
GPU 性能分析
在 Web 应用程序中,渲染元素有时会通过 GPU 加速。GPU 专门处理与图形相关的指令,因此在图形方面,它能够提供比 CPU 更好的性能。例如,在现代 Web 浏览器中,CSS3 动画是通过 GPU 加速的,而 CPU 执行 JavaScript 动画。在过去,实现某些动画的唯一方法是通过 JavaScript。但今天,我们应该尽可能避免使用它,而使用 CSS3。
近年来,由于 WebGL API 的出现,从 Web 浏览器直接访问 GPU 已成为可能。这个 API 允许 Web 开发者利用 GPU 创建 3D 游戏和其他高度可视化的 3D 应用程序。
每秒帧数(FPS)
我们不会过多地讨论 3D 应用程序的性能,因为这是一个广泛的领域,我们甚至可以为此写一本书。然而,我们将了解一个可以应用于任何 Web 应用程序的重要概念:每秒帧数(FPS),或帧率。当 Web 应用程序在屏幕上显示时,它是以每秒几帧的图像(帧)来完成的。如果用户感知到帧率低,可能会对整体用户体验产生不利影响。关于这个主题已经进行了大量的研究,60 帧每秒似乎是最适合良好用户体验的帧率。值得一提的是,即使帧率是 30 FPS 这样的低值,保持一个恒定的帧率也被认为比不稳定的帧率要好。
无论何时我们开发 Web 应用程序,我们都应该关注帧率,并尝试防止其低于 40 FPS,这对于动画和用户操作尤其重要。
我们可以使用 Google Chrome 来监控我们的 Web 应用程序中的 FPS。我们需要打开开发工具(Ctrl + Shift + I),点击右上角与X图标相邻的图标,其工具提示为自定义和控制开发工具。然后我们可以选择更多工具 | 渲染:

上述指令将显示一个标题为渲染的新面板。然后我们必须启用 FPS 计数值:

FPS 计数值应显示在屏幕的右上角:

FPS 计数器使我们能够看到每秒的帧数和正在消耗的 GPU 内存。
一些高级 WebGL 应用程序可能需要深入的性能分析。对于这种情况,Chrome 提供了 Trace Event Profiling Tool。如果您想了解更多关于这个工具的信息,请访问官方页面www.chromium.org/developers/how-tos/trace-event-profiling-tool。
CPU 性能分析
为了分析处理时间的使用情况,我们将检查应用程序的调用栈。我们将检查每个调用的函数及其执行完成所需的时间。我们可以通过在 Google Chrome 开发者工具中打开“性能”选项卡来访问所有这些信息:

在此选项卡中,我们可以选择收集 JavaScript CPU 性能报告,然后点击“开始”按钮以开始记录 CPU 使用情况。能够选择何时开始和停止记录 CPU 使用情况有助于我们选择要分析的具体函数。例如,如果我们想分析名为foo的函数,我们只需要开始记录 CPU 使用情况,调用foo函数,然后停止记录。随后将显示如下截图所示的时间线:

时间线
时间线按时间顺序显示调用的函数(水平轴)。时间线还显示了这些函数的调用栈(垂直轴)。当我们悬停在其中一个函数上时,我们将在时间线的左下角看到其详细信息:

详细信息包括以下信息:
-
名称:函数的名称
-
自定义时间:完成当前函数调用所花费的时间;我们将考虑函数内部语句的执行时间,不包括它调用的任何函数
-
总时间:完成当前函数调用的总时间;我们将考虑函数内部语句的执行时间,包括它调用的函数
-
聚合自定义时间:在整个录制过程中,函数所有调用的总时间,不包括由该函数调用的函数
-
聚合总时间:在整个录制过程中,函数所有调用的总时间,包括由该函数调用的函数
如我们在前几章所学,所有 JavaScript 代码在运行时都在一个单独的线程中执行;因此,当一个函数正在执行时,没有其他函数可以执行。当函数的执行时间过长时,应用程序会变得无响应。
我们可以通过减少长时间运行函数所需的时间来解决此问题。我们可以使用 CPU 性能报告来识别哪些函数消耗了过多的处理时间。一旦我们确定了这些函数,我们可以重构它们以尝试提高应用程序的响应性。一些常见的改进包括在可能的情况下使用异步执行流程,并减小函数的大小。
内存性能分析
当我们声明一个变量时,它会在 RAM 中分配。有时之后,变量超出作用域;它会被垃圾回收器从内存中清除。有时,我们可以生成一个变量永远不会超出作用域的场景。如果变量永远不会超出作用域,它将永远不会从内存中清除。这最终可能导致一些严重的内存泄漏问题。内存泄漏是指可用内存的持续损失。
在处理内存泄漏时,我们可以利用 Google Chrome 开发者工具轻松地确定问题的原因。
我们可能会首先想知道我们的应用程序是否存在内存泄漏。我们可以通过访问时间轴并点击左上角的图标来开始记录资源使用情况来找出答案。一旦我们停止记录,就会显示一个类似于以下截图的时间轴图:

在时间轴中,我们可以选择“内存”来查看随时间变化的内存使用情况(截图中的蓝色线条表示“已用 JS 堆”)。在先前的例子中,我们可以看到在时间轴的末端有一个显著的下降。这是一个好兆头,因为它表明当页面加载完成后,大部分已使用的内存已经被清除。
内存泄漏也可能在加载后发生;在这种情况下,我们可以使用应用程序一段时间,并观察图中内存使用情况的变化,以确定泄漏的原因。
检测内存泄漏的另一种方法是观察内存分配。我们可以通过在“内存”标签页中记录堆分配来访问这些信息:

在记录了一些资源使用情况后,报告将显示。我们可以通过选择“记录分配时间轴”并点击“快照”按钮来做到这一点。然后我们需要通过点击开发工具左上角显示的红点来停止记录。
内存分配报告将显示一个类似于以下截图的时间轴。每一条蓝色线条都表示在记录期间发生的内存分配。线条的高度表示使用的内存量。正如我们所见,内存在大约第八秒时几乎被完全清除:

如果我们点击其中一条蓝色线条,我们将能够导航到在分配发生时存储在内存中的所有变量,并检查它们的值。从“配置文件”标签页也可以在任何给定点获取内存快照:

这个功能在我们调试并希望看到断点处的内存使用情况时特别有用。内存快照的工作方式类似于之前解释的分配视图中的详细视图:

如前一个屏幕截图所示,内存快照允许我们在快照被捕获时导航到存储在内存中的所有变量,并检查它们的值。
垃圾回收器
有着低抽象级别的编程语言具有低级内存管理机制。另一方面,在具有更高抽象级别的语言中,如 C#或 JavaScript,内存通过称为垃圾回收器的过程自动分配和释放。
当涉及到内存管理时,JavaScript 垃圾回收器做得很好,但这并不意味着我们不需要关心内存管理。
无论我们使用哪种编程语言,内存生命周期基本上遵循相同的模式:
-
分配你需要的内存
-
使用内存(读取/写入)
-
当不再需要时释放分配的内存
当不再需要分配的内存时,垃圾回收器将尝试释放它,使用一种称为标记-清除算法的算法变体。垃圾回收器执行周期性扫描以识别超出作用域的对象,这些对象可以从内存中释放。扫描分为两个阶段:第一个阶段被称为标记,因为垃圾回收器将标记可以释放内存的项目。在第二个阶段,称为清除,垃圾回收器将释放前一个阶段标记的项目所消耗的内存。
垃圾回收器通常能够识别何时可以清除内存中的项目;但作为开发者,我们必须尝试确保当不再需要对象时,对象能够超出作用域。如果一个变量从未超出作用域,它将永远分配在内存中,这可能导致严重的内存泄漏问题。
指向内存中项目的引用数量将阻止其从内存中释放。因此,大多数内存泄漏案例可以通过确保没有对变量的永久引用来修复。以下是一些可以帮助我们防止潜在内存泄漏问题的规则:
-
记得在不再需要时清除间隔
-
记得在不再需要时清除事件监听器
-
记住,当你创建闭包时,内部函数将记住其声明的上下文,这意味着将分配一些额外的内存项。
-
记住,当使用对象组合时,如果创建了循环引用,你可能会遇到一些变量将永远不会从内存中清除。
重要的是要提到,Node.js 进程假定至少有 1.5 GB 的 RAM 可用,当系统可用 RAM 低于 1.5 GB 时可能会引起一些问题,因为垃圾回收器不会尝试释放任何未使用的内存,直到进程消耗了几乎 1.5 GB 的 RAM。如果只有 1 GB 可用,进程将会崩溃,因为我们将在垃圾回收器尝试清理未使用内存之前耗尽内存。我们可以使用 max_old_space_size 标志来解决这个问题:
node --max_old_space_size=1024 server.js --production
Node.js 应用程序的性能分析
我们已经学会了如何使用 Google Chrome 开发工具来分析前端应用程序。然而,相同的工具也可以用来分析由 Node.js 驱动的后端应用程序。
要使用 Google Chrome 开发工具分析 Node.js 应用程序,我们需要使用 --inspect 标志启动 Node.js 应用程序:
ts-node --inspect main.ts
然后,我们需要使用 Google Chrome 访问 chrome://inspect URL。
如果一切顺利,我们应该能看到以下屏幕:

然后,我们需要点击“检查”链接,该链接应在“远程目标”部分下可用。随后应该会打开一个新窗口。该窗口将显示 Google Chrome 开发者工具,准备分析 Node.js 应用程序。
或者,我们可以使用 Google Chrome 的 Node.js V8 --inspector Manager (NiM)扩展程序,它允许我们更轻松地访问 Node.js 检查器。您可以通过访问 chrome.google.com/webstore/detail/nodejs-v8-inspector-manag/gnhhdgbaldcilmgcpfddgdbkhjohddkj 下载此扩展程序。
性能自动化
在本节中,我们将了解如何自动化许多性能优化任务,从内容的连接和压缩到性能监控和性能测试过程的自动化。
性能优化自动化
在分析完我们应用程序的性能后,我们将开始着手进行一些性能优化。许多这些优化涉及应用程序某些组件的连接和压缩。
每当原始组件(未连接和未压缩)之一发生变化时,我们还需要创建一个新的连接和压缩内容的版本。因为这些包括许多高度重复的任务,我们可以使用 Gulp 或 Webpack 等工具为我们执行许多这些任务。
我们可以使用这些工具来连接和压缩组件,优化图像,生成缓存清单文件,以及执行许多其他性能优化任务。
如果您想了解更多关于 Gulp 和 Webpack 的信息,请参阅第九章,自动化您的开发工作流程。
性能监控自动化
我们已经看到,我们可以使用 Gulp 任务运行器自动化许多性能优化任务。同样,我们也可以自动化性能监控过程。
为了监控现有应用程序的性能,我们需要收集一些数据,这将使我们能够比较应用程序随时间的变化。根据我们收集数据的方式,我们可以识别三种不同的性能监控类型:
-
真实用户监控 (RUM):这是一种用于从真实用户访问中捕获性能数据的解决方案。数据收集是通过在浏览器中加载的小段 JavaScript 代码完成的。此类解决方案可以帮助我们收集数据并发现性能趋势和模式。
-
模拟浏览器:此类解决方案用于从模拟浏览器中捕获性能数据,这是更经济的选择,但它有限制,因为模拟浏览器无法提供与真实用户体验一样准确的表示。
-
真实浏览器监控:这是用于捕获真实浏览器性能数据的方法。这些信息提供了对真实用户体验的更准确表示,因为数据是使用用户访问网站时看到的 exactly what a user would see if he or she visited the site with the given environment (browser, geographic location, and network throughput).
Web 浏览器可以被配置为生成HTTP Archive (HAR)文件。HAR 文件使用一个通用的格式来记录 HTTP 跟踪信息。此文件包含各种信息,但,就我们的目的而言,它记录了浏览器加载的每个对象。
在线上有多个脚本展示了如何收集数据。其中一个例子,netsniff.js,导出网络流量为 HAR 格式。netsniff.js文件(和其他示例)可以在github.com/ariya/phantomjs/blob/master/examples/netsniff.js找到。
一旦我们生成了 HAR 文件,我们可以使用另一个应用程序以可视时间线查看收集到的性能信息。这个应用程序叫做 HAR Viewer,可以在github.com/janodvarko/harviewer找到。
或者,我们可以编写一个自定义脚本或 Gulp 任务来读取 HAR 文件,并在应用程序性能不符合我们的需求时中断自动化构建。
还可以运行 YSlow 性能分析报告并将其与自动化构建集成。
如果你正在考虑使用 RUM,请查看 New Relic 的解决方案,网址为newrelic.com/,或者 Google Analytics,网址为www.google.com/analytics/.
性能测试自动化
提高应用程序性能的另一种方法是编写自动性能测试。这些测试可以用来确保系统满足一系列性能目标。性能测试有多种类型,但其中一些最常见的是以下几种:
-
负载测试:这是最基本的性能测试形式。我们可以使用负载测试来了解系统在特定预期负载(并发用户数、事务数和持续时间)下的行为。
-
压力测试:这种测试通常用于了解应用程序的最大容量限制。这种测试确定应用程序是否能够处理极端数量的请求。当在客户端应用程序上工作时,压力测试并不适用。然而,当在 Node.js 应用程序上工作时,它可能很有帮助,因为 Node.js 应用程序可以有多个并发用户。
-
** soak 测试**:也称为耐久性测试。这种测试类似于压力测试,但不是使用极端负载,而是使用持续一段时间的预期负载。在测试期间收集内存使用数据是一种常见做法,用于检测潜在的内存泄漏。这种测试有助于我们判断在持续一段时间后性能是否有所下降。
-
峰值测试:这也类似于压力测试,但不是在持续期间使用极端时间负载,而是使用突然的极端和预期负载间隔。这种测试有助于我们确定应用程序是否能够处理负载的剧烈变化。
-
配置测试:这种测试用于确定配置更改对应用程序性能和行为的影响。一个常见的例子是尝试不同的负载均衡方法。
异常处理
了解如何有效地使用可用资源将帮助我们创建更好的应用程序。同样,了解如何处理运行时错误将帮助我们提高应用程序的整体质量。TypeScript 中的异常处理涉及三个主要语言元素。
Error 类
当发生运行时错误时,会抛出一个 Error 类的实例:
throw new Error();
我们可以通过几种不同的方式创建自定义错误。实现它的最简单方法是向 Error 类构造函数传递一个字符串作为参数:
throw new Error("My basic custom error");
如果我们需要对自定义异常有更多可定制和高级的控制,我们可以使用继承来实现:
export class Exception extends Error {
public constructor(public message: string) {
super(message);
// Set the prototype explicitly.
Object.setPrototypeOf(this, Exception.prototype);
}
public sayHello() {
return `hello ${this.message}`;
}
}
在前面的代码片段中,我们声明了一个名为 Exception 的类,它继承自 Error 类。在类构造函数中,我们显式地设置了原型。这是由于一些限制,从 TypeScript 2.1 开始的要求。你可以在 github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work 了解更多关于这个限制的详细信息。
try...catch 语句和 throw 语句
catch 子句包含在 try 块中抛出异常时要执行的语句。我们应该在 try 块中执行一些操作,如果它们失败,程序执行流程将从 try 块移动到 catch 块。此外,还有一个可选的 finally 块,它在 try 和 catch 块(如果 catch 块中有异常)之后执行:
try {
// code that we want to work
throw new Error("Oops!");
}
catch (e) {
// code executed if expected to work fails
console.log(e);
}
finally {
// code always executed after try or try and catch (when
errors)
console.log("finally!");
}
还需要提到的是,在包括 TypeScript 在内的绝大多数编程语言中,抛出和捕获异常是一项资源消耗较大的操作。如果我们需要使用这些语句,就应该使用它们,但有时为了避免它们可能对应用程序性能产生负面影响,有时有必要避免使用它们。
摘要
在本章中,我们学习了性能是什么以及资源可用性如何影响它。
我们还学习了如何使用一些工具来分析 TypeScript 应用程序使用可用资源的方式。这些工具使我们能够发现一些可能的问题,例如帧率低、内存泄漏和高加载时间。我们还发现,我们可以自动化许多性能优化任务,以及性能监控和测试过程。
在下一章中,我们将学习如何自动化我们的 TypeScript 应用程序的测试过程,以实现出色的应用程序可维护性和可靠性。
第十四章:应用程序测试
在第九章,自动化你的开发工作流程中,我们学习了如何编写单元测试和生成测试覆盖率报告。然而,应用程序测试是一个非常广泛的主题,我们只是触及了其表面。
在本章中,我们将学习如何为 TypeScript 应用程序编写多种类型的自动化测试。我们将涵盖以下主题:
-
测试术语
-
测试规划和方法论
-
编写单元测试
-
在测试期间隔离组件
-
编写集成测试
-
编写端到端(e2e)测试
我们将从学习软件测试领域核心术语开始。
测试术语
在本章中,我们将使用一些可能对没有软件测试领域经验读者不熟悉的概念。因此,在我们开始之前,我们将快速浏览一些软件测试中最流行的概念。
断言
断言是一个必须被测试的条件,以确认某段代码的行为是否符合预期,或者换句话说,以确认符合要求。
让我们假设我们正在作为谷歌 Chrome 开发团队的一部分工作,并且我们必须实现 JavaScript Math API。如果我们正在处理pow方法,需求可能如下,Math.pow(底数,指数)函数应该返回底数(底数数字)的指数次幂——即底数^指数。
基于这些信息,我们可以创建以下实现:
class MathAPI {
public static pow(base: number, exponent: number) {
let result = base;
for (var i = 1; i < exponent; i++) {
result = result * base;
}
return result;
}
}
请注意,在这个例子中,我们使用MathAPI而不是Math,因为Math变量已经被真实的 JavaScript Math API 声明了。
为了确保方法被正确实现,我们必须测试其是否符合要求。如果我们仔细分析需求,我们应该至少识别出两个必要的断言:
- 函数应该返回底数的指数:
const actual1 = MathApi.pow(3, 5);
const expected1 = 243;
const asertion1 = actual1 === expected1;
if (asertion1 === false) {
throw new Error(
`Expected 'actual1' to be ${expected1} ` +
`but got ${actual1}!`
);
}
- 指数没有被用作底数(或者底数没有被用作指数):
const actual2 = MathApi.pow(5, 3);
const expected2 = 125;
const asertion2 = actual2 === expected2;
if (asertion2 === false) {
throw new Error(
`Expected 'actual2' to be ${expected2} ` +
`but got ${actual2}!`
);
}
如果两个断言都有效,那么我们的代码符合要求,我们知道它将按预期工作。
规范
规范是软件开发工程师用来指代测试规范的一个术语。测试规范(不要与测试计划混淆)是一个详细列出所有应该被测试的场景以及如何测试它们的清单,以及其他详细信息。
测试覆盖率
测试覆盖率这个术语指的是一个度量单位,用来说明应用程序中通过自动化测试测试过的代码部分的数量。可以通过自动生成测试覆盖率报告来获得测试覆盖率。
参考第九章,自动化你的开发工作流程,了解如何生成测试覆盖率报告。
测试用例
测试用例是一组条件,用于确定应用程序的一个功能是否按原意工作。我们可能会想知道测试断言和测试用例之间的区别是什么。虽然测试断言是一个单一条件,但测试用例是一组条件。
套件
套件是一组测试用例。虽然测试用例应该只关注一个测试场景,但测试套件可以包含多个测试场景的测试用例。
我们将在本章的“使用 Mocha 的单元测试和集成测试”部分学习如何定义断言、测试用例和测试套件。
间谍
间谍是某些测试框架提供的一个功能。它们允许我们包装一个方法或函数并记录其使用情况。我们可以记录诸如方法或函数的参数、它们的返回类型或它们被调用的次数等信息。当我们用间谍包装一个方法或函数时,底层方法的功能不会改变。
占位符
占位符对象是在测试执行过程中传递的对象,但它实际上从未被使用。
模拟器
模拟器是某些测试框架提供的一个功能。与间谍类似,模拟器也允许我们包装一个方法或函数来记录其使用情况。与间谍的情况不同,当我们用模拟器包装一个函数时,底层方法的功能被替换为新的行为。
模拟器
模拟器经常与模拟器混淆。马丁·福勒曾在题为《模拟器不是占位符》的文章中写道:
“特别是,我经常看到它们(模拟器)与模拟器混淆——测试环境的一个常见辅助工具。我理解这种混淆——我一开始也认为它们很相似,但与模拟器开发者的对话逐渐让我对模拟器有了更深入的理解。这种差异实际上是两个不同的差异。一方面,测试结果验证的方式有所不同:状态验证和行为验证之间的区别。另一方面,是对测试和设计如何协同工作的一种完全不同的哲学,我将其称为经典和模拟风格测试驱动开发。”
模拟器和模拟器都为测试用例提供了一些输入,但尽管它们相似,每个的信息流都非常不同:
-
模拟器为正在测试的应用程序提供输入,以便测试可以在其他事物上进行。模拟器用于替代行为。
-
模拟器为测试提供输入以决定测试是否应该通过或失败。模拟器用于声明期望。
随着我们接近本章的结尾,模拟器和模拟器之间的区别将变得更加清晰。
先决条件
在本章中,我们将使用第三方工具。在本节中,我们将学习如何安装这些工具。然而,在我们开始之前,我们需要使用 npm 在我们将要用于实现本章示例的文件夹中创建一个 package.json 文件。
让我们创建一个新的文件夹,并进入它以使用 npm init 命令生成一个新的 package.json 文件:
npm init
请参考第五章,与依赖项一起工作,以获取有关 npm 的更多信息。
Mocha
Mocha 是一个流行的 JavaScript 测试库,它简化了测试套件、测试用例和测试规范的创建。Mocha 可以用于前端和后端测试 TypeScript,识别性能问题,并生成不同类型的测试报告,以及其他许多功能。我们可以使用以下命令安装 Mocha:
npm install --save-dev mocha @types/mocha
Chai
Chai 是一个支持测试驱动开发(TDD)和行为驱动开发(BDD)测试风格的测试断言库。Chai 的主要目标是减少创建测试断言所需的工作量,并使测试更易于阅读。我们可以使用以下命令安装 Chai:
npm install --save-dev chai @types/chai
请注意,我们将在本章的 测试方法 部分后面学习更多关于 TDD 和 BDD 的内容。
Sinon.JS
Sinon.JS 是一个库,它提供了一套 API,可以帮助我们通过使用间谍、存根和模拟来隔离测试组件。当软件组件之间存在高度耦合时,测试软件组件可能非常困难。然而,像 Sinon.JS 这样的库可以帮助我们隔离组件以测试其功能。我们可以使用以下命令安装 Sinon.JS:
npm install --save-dev sinon @types/sinon
nyc
正如我们在第九章中已经学到的,自动化开发工作流程,我们可以使用 nyc 为我们的应用程序生成测试覆盖率报告。我们可以使用以下命令安装 nyc:
npm install --save-dev nyc
Webpack
正如我们在第九章中已经学到的,自动化开发工作流程,我们可以使用 nyc 为我们的应用程序生成测试覆盖率报告。我们可以使用以下命令安装 Webpack 和一些额外的插件:
npm install --save-dev webpack css-loader extract-text-webpack-plugin node-sass sass-loader style-loader
Enzyme
Enzyme 是由 Airbnb 开发的开源测试库,可以帮助我们测试 React 组件。我们可以使用以下命令安装 enzyme:
npm install --save-dev enzyme enzyme-adapter-react-16 @types/enzyme @types/ enzyme-adapter-react-16
SuperTest
SuperTest 是一个库,可以帮助我们测试使用 Node.js 和 Express.js 开发的 HTTP 网络服务。我们可以使用以下命令安装 SuperTest:
npm install supertest @types/supertest
PM2
PM2 是一个内置负载均衡器的 Node.js 应用程序的生产进程管理器。PM2 允许我们将 Node.js 应用程序作为后台进程运行,这是我们运行端到端测试所必需的。我们可以使用以下命令安装 PM2:
npm install pm2
Nightwatch.js 和 ChromeDriver
Nightwatch.js 是一个库,帮助我们实现 端到端(e2e)测试。我们还需要一个名为 ChromeDriver 的工具。Nightwatch.js 可以在多个网络浏览器中运行我们的测试,但在我们的例子中,我们将使用 Google Chrome。chromedriver 库是一个适配器,允许 Nightwatch.js 在测试执行期间与 Google Chrome 通信。我们可以使用以下命令安装 Nightwatch.js 和 ChromeDriver:
npm install chromedriver nightwatch @types/nightwatch
请参考配套源代码以检查 package.json 文件中使用的确切版本。如果你使用 npm install,默认将安装最新版本。这些示例中使用的版本可能会随着时间的推移而变得过时,这可能导致一些配置问题。如果你想使用最新版本(这是推荐的),你必须检查每个模块的文档,以了解潜在的重大更改。
测试方法
每次我们开发一个新的应用程序时,我们都需要做出很多决定。例如,我们需要选择数据库类型、架构、库和框架。然而,我们的选择并不全是关于技术的,我们还可以选择软件开发方法,如极限编程或敏捷。当涉及到测试时,有两种主要风格或方法:TDD 和 BDD。
测试驱动开发(TDD)
测试驱动开发(TDD)是一种测试方法,它侧重于鼓励开发者在编写应用程序代码之前编写测试。通常,TDD 中编写代码的过程包括以下基本步骤:
-
编写一个失败的测试。目前还没有应用程序代码,所以测试应该失败。
-
运行测试并确保它失败。
-
编写代码以通过测试。
-
运行测试并确保它工作。
-
运行所有其他现有测试,以确保应用程序的其他部分没有因为更改而损坏。
-
对于每个新的功能或错误修复,重复此过程。
这个过程通常表示为“红色-绿色-重构”图:

是否使用测试驱动开发(TDD)取决于你希望采取的心态。许多开发者不喜欢编写测试,所以如果我们将他们的实现作为开发流程中的最后一项任务,测试可能永远不会被实现,或者应用程序只是部分测试。也有可能应用程序的实现方式更难进行测试。如果我们计划编写测试,提前进行可以降低实现成本。
推荐使用 TDD,因为它有效地帮助你和你团队增加应用程序的测试覆盖率,从而显著减少潜在问题的数量,最终节省资金。
行为驱动开发(BDD)
行为驱动开发是在 TDD 之后出现的,其使命是成为 TDD 的改进版。BDD 关注测试的描述方式(规范),并声称测试应该关注应用程序需求而不是测试需求。理想情况下,这将鼓励开发者更多地考虑整个应用程序,而不是仅仅关注测试本身。
由丹·诺斯引入 BDD 原则的原始文章可在dannorth.net/introducing-bdd/找到。
正如我们已经学到的,Mocha 和 Chai 为 TDD 和 BDD 方法都提供了 API。在本章的后面部分,我们将进一步探讨这两种方法。
推荐这些方法之一并不简单,因为 TDD 和 BDD 都是优秀的测试方法。然而,BDD 是在 TDD 之后开发的,目的是改进它,因此我们可以认为 BDD 相对于 TDD 有一些额外的优势。在 BDD 中,测试的描述重点在于应用程序应该做什么,而不是测试代码在测试什么。这可以帮助开发者识别出反映客户期望行为的测试。BDD 测试可以用来以可被开发者和客户理解和验证的方式记录系统的需求。这是相对于 TDD 测试的一个明显优势,因为 TDD 测试不能轻易为客户所理解。
测试计划和测试类型
“测试计划”这个术语有时被错误地用来指代测试规范。虽然测试规范定义了将要测试的场景以及如何测试,但测试计划是一个给定区域的全部测试规范的集合。
建议您创建一个实际的规划文档,因为测试计划可能涉及许多流程、文档和实践。测试计划的主要目标之一是确定和定义适用于应用程序中某个组件或一组组件的适当测试类型。以下是最常用的测试类型。
单元测试
这些用于测试一个独立的组件。如果组件没有隔离——也就是说,如果组件有一些依赖关系——我们就必须使用一些工具和实践,例如存根或依赖注入,尽量在测试期间将其隔离。如果无法操作组件的依赖关系,我们将使用间谍来帮助创建单元测试。我们的主要目标应该是实现组件在测试时的完全隔离。单元测试也应该快速,我们应该尽量避免输入/输出、网络使用以及可能影响测试速度的任何其他操作。
集成测试
集成测试用于测试一组组件(部分集成测试)或整个应用程序(完整集成测试)。在集成测试中,我们通常会使用已知测试数据向后端提供信息,这些信息将在前端显示。然后我们将断言显示的信息是正确的。
回归测试
回归测试用于验证问题已被修复。如果我们正在使用 TDD 或 BDD,每次遇到问题时,我们应该在修复问题之前创建一个单元测试来重现该问题。通过这样做,我们将能够重现过去的问题,并确保我们不再需要处理相同的问题。
性能和负载测试
性能和负载测试用于验证应用程序是否符合我们的性能预期。我们可以使用性能测试来验证我们的应用程序是否能够处理许多并发用户或活动峰值。要了解更多关于此类测试的信息,请参阅第十三章,应用程序性能。
端到端(e2e)测试
端到端测试与完整集成测试没有太大区别。主要区别在于,在 e2e 测试会话中,我们将尝试模拟一个几乎与真实用户环境相同的环境。我们将使用 Nightwatch.js 和 ChromeDriver 来实现这一目的。
用户验收测试(UAT)
用户验收测试(UAT)帮助我们确保系统满足最终用户的所有要求。
示例应用程序
在本章中,我们将开发一个完整的 Web 应用程序。该应用程序本身不是一个非常真实的应用示例,但应该足够真实,足以展示许多种测试实践和技术。我们将开发一个可以进行 pow 操作的计算器。计算器应用程序由以下组件组成:
-
使用 React 实现的图形用户界面,并使用 HTTP 客户端从 Web 服务获取
pow操作的结果 -
使用 Node.js 和 Express.js 实现的 Web 服务,并使用一个小型数学库来查找
pow操作的结果
应用程序的图形用户界面如下所示:

我们将使用 npm 脚本定义许多不同的自动化任务。每个任务使用不同的工具,有些任务必须在其他任务之前执行。我们可以使用更复杂的设置来并行运行一些任务,从而减少整个过程的执行时间,或者使用更真实的应用程序,但我们希望尽可能保持简单,以便专注于测试技术和工具。
我们将在 package.json 文件中定义以下任务:
"scripts": {
"all": "npm run clean && npm install && npm run lint && npm run build && npm test",
"clean": "rimraf ./dist ./public",
"start": "./node_modules/.bin/pm2 start ./dist/src/backend/main.js",
"kill": "./node_modules/.bin/pm2 kill",
"lint": "tslint --project tsconfig.json -c tslint.json ./src/**/*.ts ./test/**/*.ts",
"build": "npm run build-frontend && npm run build-e2e",
"build-frontend": "webpack",
"build-e2e": "tsc -p tsconfig.e2e.json",
"test": "npm run nyc && npm run e2e",
"nyc": "nyc --clean --all -x webpack.config.js -x test/*.e2e.ts -x public -x dist -x globals.js --require ./jsdom.js --require isomorphic-fetch --require ts-node/register --extension .ts -- mocha --timeout 5000 **/*.test.ts **/*.test.tsx",
"e2e": "npm run start && npm run nw && npm run kill",
"nw": "nightwatch --config nightwatch.json",
"coverage": "nyc report --reporter=text --reporter=lcov"
},
如果你使用的是 Windows,前面代码中定义的命令将失败,因为它们使用了 Unix 格式的相对路径。你可以通过安装 Git 并从 git-scm.com/downloads 安装 Git Bash 来解决这个问题,然后使用以下命令设置 npm 以使用 Git Bash:
npm config set script-shell "C:Program FilesGitbinbash.exe"
你可能还需要安装 Python 和 C++ 编译工具,因为这两个都是 node-sass 模块所必需的。
请记住,整个源代码都包含在配套源代码中。
该过程被设计成可以通过使用 npm run all 命令完全运行。此命令将按以下图中描述的顺序执行所有其他任务:

上述图表使我们能够可视化由父任务初始化的任务。例如,clean、install、lint、build 和 test 任务都是由 all 任务启动的。该图表还帮助我们可视化任务的执行顺序。例如,我们可以看到第一个任务是 all 任务,最后一个任务是 kill 任务。
我们现在将检查每个这些任务的目的:
-
all 任务是根任务,用于启动其他任务。
-
clean 任务移除一些之前的输出,以确保结果不受任何缓存问题的影响。
-
install 任务下载所有必需的依赖项。
-
lint 任务强制执行一些代码风格规则。
-
build 任务启动前端和端到端测试的编译任务。对于后端和单元测试不需要编译,因为使用的工具(nyc 和 ts-node)不需要它。
-
build_e2e 任务使用 tsc 编译 e2e 测试。
-
build_frontend 任务使用 Webpack 编译前端应用程序。
-
test 任务运行带有 nyc 的单元测试和带有 Nightwatch.js 的 e2e 测试。
-
nyc 任务运行单元测试和集成测试,并生成测试覆盖率报告。
-
e2e 任务运行 e2e 测试。在我们运行 e2e 测试之前,我们需要使用网络服务器来提供服务应用程序,完成测试后我们还需要停止服务。
-
start 任务使用 PM2 启动服务应用程序的 Node.js 进程。
-
nw 任务代表 Nightwatch.js,用于执行 e2e 测试。
-
kill 任务使用 PM2 停止服务应用程序的 Node.js 进程。
如果我们不理解前面列表中提到的每个任务或工具的使命,我们不必过于担心,因为我们将在本章的剩余部分详细学习它们,除了 clean、install、lint 和 build 任务,因为我们已经在之前的章节中学习了这些任务。
请参阅第九章,自动化您的开发工作流程,了解如何生成测试覆盖率报告。
使用 Mocha 进行单元测试和集成测试
在第九章,自动化您的开发工作流程中,我们学习了使用 nyc、ts-node、Mocha 和 Chai 进行单元测试和测试覆盖率报告的基本细节。在本章中,我们将学习如何使用 Mocha 测试异步 API,以及如何将 Mocha 与其他强大的工具结合使用,例如 Sinon.JS、SuperTest 和 Enzyme:
-
我们将学习如何为应用程序的每一层编写测试。
-
我们将首先测试一个用于后端的后台数学库。
-
然后,我们将测试一个消耗数学库的 Web 服务和一个消耗 Web 服务的客户端。
-
我们将通过编写图形用户界面的测试和创建一些端到端测试来完成本节。
回归基础
配套源代码包括一个名为 MathDemo 的类。这个类允许我们以几种不同的方式执行 pow 计算。其中之一是同步的 pow 函数:
public pow(base: number, exponent: number) {
let result = base;
for (let i = 1; i < exponent; i++) {
result = result * base;
}
return result;
}
正如我们在第九章,自动化您的开发工作流程中学习的,我们可以使用以下测试用例测试前面函数中声明的函数:
it("Should return the correct numeric value for pow", () => {
const math = new MathDemo();
const result = math.pow(2, 3);
const expected = 8;
expect(result).to.be.a("number");
expect(result).to.equal(expected);
});
然后,我们可以使用 nyc 命令与 ts-node 和 Mocha 运行我们的测试并生成测试覆盖率报告。在配套源代码中,这被 npm 脚本包装如下命令,以方便使用:
npm run nyc
如果一切按计划进行,我们应该看到已执行的所有测试的列表。包含在配套源代码中的测试生成的结果应如下所示:

命令还应生成如下截图所示的测试覆盖率报告:

一旦我们使用 nyc 命令执行了测试,我们可以通过运行以下命令生成测试覆盖率报告:
nyc report --reporter=text --reporter=lcov
这将在当前目录下生成一个名为 coverage 的文件夹。该覆盖率文件夹包含一些我们可以使用网页浏览器打开的 HTML 文件:

如果我们点击其中一个文件,我们将能够看到所选文件的逐行测试覆盖率报告:

测试异步代码
MathDemo 类还包括相同方法的异步版本:
public powAsync(base: number, exponent: number) {
return new Promise<number>((resolve) => {
setTimeout(
() => {
const result = this.pow(base, exponent);
resolve(result);
},
0
);
});
}
如果我们尝试测试此方法,并且我们没有等待其结果,我们的测试将毫无用处。然而,如果我们等待结果,使用 Promise.then 方法,我们的测试也将失败,除非我们向测试用例处理器传递一个回调函数(在示例中命名为 done):
it("Should return the correct numeric value for pow", (done) => {
const math = new MathDemo();
math.powAsync(2, 3).then((result: number) => {
const expected = 8;
expect(result).to.be.a("number");
expect(result).to.equal(expected);
done();
});
});
或者,我们可以使用 async 和 await,如下面的代码片段所示:
it("Should return the correct numeric value for pow", async () => {
const math = new MathDemo();
const result = await math.powAsync(2, 3);
const expected = 8;
expect(result).to.be.a("number");
expect(result).to.equal(expected);
});
当测试异步代码时,如果调用 done 函数的时间超过 2,000 毫秒,Mocha 将认为测试失败(超时)。在超时之前的时间限制是可以配置的,同样,对于慢速函数的警告也可以配置。默认情况下,当测试时间超过 40 毫秒时,会显示警告。警告建议我们的测试可能有些慢。如果测试执行时间超过 100 毫秒,警告将建议我们的测试相当慢。我们可以使用 mocha 命令的 --timeout 命令行参数来更改此配置。
伴随的源代码包含了每种警告和失败的示例。
断言异常
在前面的示例中,我们学习了如何断言变量的类型和值:
const expected = 8;
expect(result).to.be.a("number");
expect(result).to.equal(expected);
然而,有一个场景可能不如前面的场景直观——测试异常。
MathDemo 类还包含一个名为 bad 的方法,它被添加的唯一目的是说明如何测试异常。当使用 null 参数调用时,bad 方法会抛出异常:
public bad(foo: any) {
if (foo === null) {
throw new Error("Error!");
} else {
return this.pow(5, 5);
}
}
在下面的测试中,我们可以看到如何使用 expect API 断言抛出异常:
it("Should throw an exception when no parameters passed", () => {
const math = new MathDemo();
expect(math.bad).to.throw(Error);
});
如果你想了解更多关于断言的信息,请访问位于 chaijs.com/api/bdd/ 的 Chai 官方文档。
使用 SuperTest 测试 web 服务
伴随源代码中的演示应用程序声明了一个允许我们获取 pow 计算结果的 web 服务:
import * as express from "express";
import * as path from "path";
import { MathDemo } from "./math_demo";
export function getApp() {
const app = express();
// ...
app.get("/api/math/pow/:base/:exponent", (req, res) => {
const mathDemo = new MathDemo();
const base = parseInt(req.params.base, 10);
const exponent = parseInt(req.params.exponent, 10);
const result = mathDemo.pow(base, exponent);
res.json({ result });
});
return app;
}
应用程序的初始化被分为两个文件:main.ts 和 server.ts。server.ts 文件定义了我们在前面的代码片段中检查过的 getApp 函数。main.ts 文件使用 getApp 函数来启动服务器:
import { getApp } from "./server";
const app = getApp();
const port = 3000;
app.listen(port, () => {
console.log(`App listening at http://localhost:${port}`); // tslint:disable-line
});
有时候,将整个 web 服务作为一个整体进行测试,这被称为集成测试,是一个好主意。正如我们在本章前面所学到的,集成测试用于测试一组组件。在这种情况下,我们将测试服务器端的路由处理程序及其对 MathDemo 类的使用。我们可以定义一个针对 pow 服务的测试如下:
import { expect } from "chai";
import * as request from "supertest";
import { getApp } from "../src/backend/server";
describe("Math Service", function() {
it("HTTP GET /api/math/pow/:base/:exponent", async () => {
const app = getApp();
return request(app).get("/api/math/pow/2/3")
.set("Accept", "application/json")
.expect("Content-Type", /json/)
.expect(200)
.then((response) =>
expect(response.body.result).eql(8)
);
});
});
如前述代码片段所示,我们使用了一个名为 getApp 的函数来获取 Express.js 应用的实例。一旦我们有了应用实例,我们可以使用 supertest 模块中的请求方法向服务发送请求。我们可以使用 SuperTest 与 Chai 一起断言请求的响应与预期的结果匹配。重要的是要提到,getApp 函数创建了一个应用,但它不会启动应用。或者换句话说,getApp 函数避免了调用 app.listen 方法。
与测试套件一起工作
测试套件是一组测试用例。我们已经了解到我们可以使用 Mocha 的 describe 函数来定义测试套件,以及使用 it 函数来定义测试用例。然而,我们还没有学习到我们可以定义在测试套件中的所有测试之前和之后可以调用的事件处理器。以下代码片段展示了我们如何定义这些事件处理器:
describe("My test suite", () => {
before(() => {
// Invoked once before ALL tests
});
after(() => {
// Invoked once after ALL tests
});
beforeEach(() => {
// Invoked once before EACH test
});
afterEach(() => {
// Invoked once before EACH test
});
it(() => {
// Test case
});
});
如果我们想在多个测试用例之间重用一些初始化逻辑,这可能会很有用。例如,我们可以将前面章节中用来测试 Web 服务的示例重写,以便我们可以在多个测试用例之间共享 Express.js 应用程序实例:
import { expect } from "chai";
import * as express from "express";
import * as request from "supertest";
import { getApp } from "../src/backend/server";
describe("Math Service", function() {
let app: express.Application | null;
before(() => {
app = getApp();
});
after(() => {
app = null;
});
it("HTTP GET /api/math/pow/:base/:exponent", async () =>
request(app).get("/api/math/pow/2/3")
.set("Accept", "application/json")
.expect("Content-Type", /json/)
.expect(200)
.then((response) =>
expect(response.body.result).eql(8)
)
);
});
通常,测试框架(无论我们使用的是哪种语言)都不会允许我们控制单元测试和测试套件的执行顺序。测试甚至可以通过使用多个线程并行执行。因此,确保我们测试套件中的单元测试彼此独立是很重要的。
使用 Sinon.JS 隔离组件
我们已经了解到单元测试用于测试单个组件,集成测试用于测试一组组件及其交互。当我们编写单元测试时,如果组件依赖于另一个组件,我们需要提供存根、模拟或占位符来代替真实依赖,以确保组件是在隔离的情况下进行测试的。然而,有时这比听起来要复杂得多。幸运的是,Sinon.JS 可以帮助我们确保我们的组件是在隔离的情况下进行测试的。
以下代码片段用于测试本章前面描述的 pow Web 服务的 Web 客户端。我们使用 Sinon.JS 为名为 fetch 的全局对象定义了一个存根。fetch 全局对象是一个允许我们从前端向后端发送 AJAX 请求的函数。用存根替换 fetch 对象是一个好主意,因为它将帮助我们确保客户端类没有与后端交互,因此可以在完全隔离的情况下进行测试:
import { expect } from "chai";
import { stub } from "sinon";
import { MathClient } from "../src/frontend/math_client";
describe("MathDemo", () => {
it("Should return result of pow calculation", async () => {
const expectedResult = "8";
const response = {
json: () => Promise.resolve({
result: expectedResult
})
};
const stubedFetch = stub(global, "fetch" as any);
stubedFetch.returns(Promise.resolve(response));
const mathClient = new MathClient();
const actualResult = await mathClient.pow(2, 3);
expect(expectedResult).to.eq(actualResult);
expect(stubedFetch.callCount).to.eq(1);
});
});
使用全局变量是一个坏主意,因为它违反了依赖倒置原则,并使得我们的应用程序更难测试。幸运的是,Sinon.JS 可以帮助我们克服这种困难。
请参阅第五章,与依赖项一起工作,了解更多关于依赖倒置及其基本原理。
还值得一提的是,存根为我们提供了一个 API,可以帮助我们检查一些事情,例如存根被使用的次数或传递给它的参数。这在上面的代码片段的最后一条断言中得到了演示。
jsdom
一些测试工具,例如 Enzyme(我们将在下一节中了解它),期望在网页浏览器中使用。在我们的例子中,应用程序使用 nyc 和 ts-node 来执行所有单元测试,这意味着我们没有使用网页浏览器。有时,通过使用 jsdom 可以克服这个问题,其创造者是这样描述的:
"jsdom 是许多网页标准的纯 JavaScript 实现,特别是 WHATWG DOM 和 HTML 标准,用于 Node.js。一般来说,项目的目标是模拟足够多的网页浏览器子集,以便在测试和抓取现实世界网页应用程序时有用。"
如果我们检查伴随源代码中包含的 package.json 文件中的 nyc 命令,我们会看到提供给 nyc 二进制的参数之一是 --require ./jsdom.js。这将强制 Mocha 在执行任何测试之前需要 jsdom.js 文件。jsdom.js 文件用于初始化 jsdom,其外观如下:
const { JSDOM } = require("jsdom" );
const jsdom = new JSDOM(" <!doctype html><html><body></body></html>" );
const { window } = jsdom;
function copyProps(src, target) {
const props = Object.getOwnPropertyNames(src)
.filter(prop => typeof target[prop] === " undefined" )
.reduce((result, prop) => ({
...result,
[prop]: Object.getOwnPropertyDescriptor(src, prop),
}), {});
Object.defineProperties(target, props);
}
global.window = window;
global.document = window.document;
global.navigator = {
userAgent: " node.js",
};
copyProps(window, global);
上述文件创建了一些全局变量,使我们能够运行原本设计为在网页浏览器中执行的后端代码。在 Node.js 执行环境中,我们没有某些变量,例如 window 变量。上述代码片段初始化所有必需的变量,以便在我们的后端执行环境中(Node.js)执行前端代码。这很有用,例如,当我们想要为前端组件编写测试时,因为我们可以在不需要网页浏览器的情况下执行我们的测试。
参考 Enzyme 文档github.com/airbnb/enzyme/blob/master/docs/guides/jsdom.md,了解更多关于 jsdom 配置的信息。
使用 Enzyme 测试 React 网页组件
到目前为止,我们已经使用单元测试和集成测试(使用 SuperTest)测试了应用程序的后端。我们还利用 Sinon.JS 的使用在完全隔离的情况下测试了我们的客户端。然而,如果表示层(图形用户界面)出现问题,我们的应用程序仍然可能失败。在本节中,我们将学习如何使用一些库来帮助我们测试图形用户界面的每个组件。
参考第十一章,使用 React 和 TypeScript 进行前端开发,了解更多关于 React 的信息。
伴随源代码包括以下 React 组件:
import * as React from "react";
import { MathClient } from "./math_client";
import { NumericInput } from "./numeric_input_component";
const ids = {
base: "#base",
exponent: "#exponent",
result: "#result",
submit: "#submit"
};
interface CalculatorProps {
client: MathClient;
}
interface CalculatorState {
base: string;
exponent: string;
result: string;
}
export class Calculator extends React.Component<CalculatorProps, CalculatorState> {
public constructor(props: CalculatorProps) {
super(props);
this.state = {
base: "1",
exponent: "1",
result: "1"
};
}
public render() {
return (
<div className="well">
<div className="row">
<div className="col">
<NumericInput
id="base"
name="Base"
value={this.state.base}
onChangeHandler={(v) => this.setState({ base: v })}
/>
</div>
<div className="col">
<NumericInput
id="exponent"
name="Exponent"
value={this.state.exponent}
onChangeHandler={(v) => this.setState({
exponent: v
})}
/>
</div>
<div className="col">
<div className="form-group">
<label>Result</label>
<div id="result">{this.state.result}</div>
</div>
</div>
<div className="col">
<button
id="submit_btn"
type="Submit"
className="btn btn-primary"
onClick={() => this._onSubmit()}
>
Submit
</button>
</div>
</div>
</div>
);
}
private _onSubmit() {
(async () => {
const result = await this.props.client.pow(
parseFloat(this.state.base),
parseFloat(this.state.exponent)
);
this.setState({ result });
})();
}
}
正如我们在本章前面所学,前面的组件将在屏幕上显示一个网页表单。应用程序的用户需要提供两个数字(基数和指数)作为输入。然后输入被发送到后端的一个网络服务,并将响应显示为结果。以下代码片段演示了我们可以如何使用存根将前面的组件从 HTTP 客户端隔离出来。代码片段还演示了我们可以如何配置 Enzyme 以与 React 的第 16 版一起工作,然后使用它来模拟用户事件,例如点击元素或输入文本:
import { expect } from "chai";
import * as Enzyme from "enzyme";
import * as Adapter from "enzyme-adapter-react-16";
import * as React from "react";
import { stub } from "sinon";
import { Calculator } from "../src/frontend/calculator_component";
import { MathClient } from "../src/frontend/math_client";
Enzyme.configure({ adapter: new Adapter() });
describe("Calculator Component", () => {
it("Should invoke client #submit is clicked", (done) => {
const mathClient = new MathClient();
const mathClientStub = stub(mathClient, "pow");
mathClientStub.returns(Promise.resolve(8));
mathClientStub.callsFake((base: number, exponent: number) => {
expect(base).to.equal(2);
expect(exponent).to.equal(3);
done();
});
const wrapper = Enzyme.mount(<Calculator client={mathClient} />);
expect(wrapper.find("input#base")).to.have.length(1);
expect(wrapper.find("input#exponent")).to.have.length(1);
expect(wrapper.find("button#submit_btn")).to.have.length(1);
wrapper.find("input#base").simulate("change", { target: { value: "2" } });
wrapper.find("input#exponent").simulate("change", { target: { value: "3" } });
wrapper.find("button#submit_btn").simulate("click");
});
});
前面的测试为基数和指数输入插入一个值,然后点击提交按钮。这将调用被存根替换的客户端。
Enzyme 已经被设计成与 React 一起工作。然而,React 的每个主要版本都需要一个特定的适配器。其他框架可能需要其他库。例如,在 Angular 中,我们可以使用@angular/core/testing模块提供的实用工具执行如下操作:
import {TestBed, ComponentFixture, inject, async} from "@angular/core/testing";
import {LoginComponent, User} from "./login.component";
import {Component, DebugElement} from "@angular/core";
import {By} from "@angular/platform-browser";
// Refine the test module by declaring the test component
TestBed.configureTestingModule({
declarations: [LoginComponent]
});
// Access the component
let fixture: ComponentFixture<LoginComponent> = TestBed.createComponent(LoginComponent);
let component: LoginComponent = fixture.componentInstance;
// Access an element
let submitEl: DebugElement = fixture.debugElement.query(By.css("button"));
submitEl.triggerEventHandler("click", null);
请参阅angular.io/api/core/testing中的文档以了解更多关于 Angular 中测试的信息。
使用 Mocha 和 Chai 的 TDD 与 BDD
正如我们已经看到的,TDD 和 BDD 遵循许多相同的原则,但在风格上有所不同。虽然这两种风格提供了相同的功能,但许多开发者认为 BDD 更易于阅读。
下表比较了 TDD 和 BDD 风格使用的套件、测试和断言的命名和风格:
| TDD | BDD |
|---|---|
suite |
describe |
setup |
before |
teardown |
after |
suiteSetup |
beforeEach |
suiteTeardown |
afterEach |
test |
it |
assert.equal(math.PI, 3.14159265359); |
expect(math.PI).to.equals(3.14159265359); |
使用 Nightwatch.js 的端到端测试
使用 Nightwatch.js 编写端到端测试非常简单,因为它的 API 非常易于阅读。我们应该能够阅读一个端到端测试并理解它,即使这是我们第一次看到它。例如,以下是一个用于测试配套源代码中包含的应用程序的端到端测试示例:
import { NightwatchBrowser } from "nightwatch";
const test = {
"Calculator pow e2e test example": (browser: NightwatchBrowser) => {
browser
.url("http://localhost:3000/")
.waitForElementVisible("body", 1000)
.assert.title("Calculator")
.assert.visible("#base")
.assert.visible("#exponent")
.clearValue("#base")
.setValue("#base", "2")
.clearValue("#exponent")
.setValue("#exponent", "3")
.click("#submit_btn")
.pause(500)
.assert.containsText("#result", "8")
.end();
}
};
export = test;
我们使用NightwatchBrowser实例导航到 URL,等待几个元素可见,设置几个输入的值,然后点击提交按钮。
虽然端到端测试 API 非常简单,但其背后的过程并不简单。如果我们检查包含在配套源代码中的package.json文件中的npm script命令,我们将能够观察到该命令触发了四个其他命令:
-
编译端到端测试
-
运行应用程序
-
运行端到端测试
-
终止应用程序
第一个命令使用 PM2 和 ts-node 来运行应用程序。PM2 是一个非常强大的进程管理工具,它允许我们将 Node.js 应用程序作为集群运行并对其进行监控。然而,这并不是我们在这里使用它的原因。我们使用 PM2 是因为它是一种非常简单的方式来以后台进程运行应用程序。在我们执行 e2e 测试之前,我们需要运行整个应用程序。问题是当应用程序开始等待 HTTP 请求时,它会阻塞所有后续的命令。PM2 通过允许我们在后台进程中运行应用程序来解决此问题。
另一个值得注意的事情是我们的 e2e 测试是浏览器无关的。这解释了为什么 Nightwatch.js 要求我们配置一个驱动器。驱动器提供了对将执行 e2e 测试的 Web 浏览器的原生访问。
在演示应用程序中,我们使用 ChromeDriver。我们创建了一个名为globals.js的文件,用于定义一些全局事件,这些事件将在我们的 e2e 测试之前和之后执行。事件处理器与我们之前在本章中定义测试套件时了解的非常相似。
我们使用在globals.js文件中声明的事件处理器来创建和销毁chromedriver的实例:
const chromedriver = require("chromedriver");
module.exports = {
before: (done) => {
chromedriver.start();
done();
},
after: (done) => {
chromedriver.stop();
done();
},
reporter: function(results) {
if (
(typeof(results.failed) === "undefined" || results.failed === 0) &&
(typeof(results.error) === "undefined" || results.error === 0)
) {
process.exit(0);
} else {
process.exit(1);
}
}
};
在本例中使用的chromedriver npm 模块的版本是 2.36.0。这个版本已经与 Google Chrome 65.0 进行了测试。如果您使用的是 Google Chrome 的较新版本,请确保您也升级了chromedriver模块。
然后我们创建一个名为 Nightwatch.js 的文件,其中包含以下配置:
{
"src_folders": [
"dist/test"
],
"output_folder": "reports",
"custom_commands_path": "",
"custom_assertions_path": "",
"page_objects_path": "",
"globals_path": "./globals.js",
"selenium": {
"start_process": false
},
"test_settings": {
"default": {
"selenium_port": 9515,
"selenium_host": "localhost",
"default_path_prefix": "",
"desiredCapabilities": {
"browserName": "chrome",
"chromeOptions": {
"args": [
"--no-sandbox"
]
},
"acceptSslCerts": true
}
},
"chrome": {
"desiredCapabilities": {
"browserName": "chrome"
}
}
}
}
如前述代码片段所示,我们正在配置 ChromeDriver 以使用globals.js事件和 Google Chrome 作为运行测试时要使用的 Web 浏览器。我们还配置了 Nightwatch.js 以在dist文件夹中查找我们的测试。Nightwatch.js 无法原生理解 TypeScript,这就是为什么我们需要在运行测试之前将测试编译到dist文件夹中。
我们需要定义一个名为tsconfig.e2e.json的第二个tsconfig.json文件,并添加一些额外的选项以确保我们只编译所需的文件:
{
"compilerOptions": {
"outDir": "./dist/"
},
"extends": "./tsconfig",
"include": [
"test/*.e2e.ts",
"src/backend/*.ts"
],
"exclude": [
"node_modules"
]
}
请注意,tsconfig.json文件中的extends字段允许我们从先前声明的tsconfig.json文件继承所有设置。
如果一切顺利,我们应该能够在控制台看到以下结果:

摘要
在本章中,我们讨论了一些核心测试概念,例如存根、套件等。我们还探讨了测试驱动开发和行为驱动开发方法,以及如何与一些主要的 JavaScript 测试框架一起工作,例如 Mocha、Chai、Sinon.JS、Enzyme、SuperTest 和 Nightwatch.js。
在下一章中,我们将学习如何使用 TypeScript 语言服务来创建我们的开发工具。
第十五章:使用 TypeScript 编译器和语言服务
在本章中,我们将学习 TypeScript 编译器和 TypeScript 语言服务的内部机制。
这些主题可能看起来非常高级,并不是每个人都觉得有用。虽然它确实是高级用户的话题,但事实是,每个人都可以从理解 TypeScript 语言服务中受益。理解 TypeScript 编译器的编译器 API 可以帮助我们开发许多种类的开发工具,并自动化我们开发工作流程的某些方面。
本章的目标不是让你成为编译器内部或 TypeScript 工具开发的专家,而是温和地引导你了解这个非常广泛的主题。在本章中,我们将涵盖以下内容:
-
TypeScript 编译器的内部架构
-
使用编译器 API 进行编程
-
使用
ts-simple-ast进行操作 -
实现自定义代码分析工具
TypeScript 编译器的内部架构
在本节中,我们将学习 TypeScript 编译器中的主要组件。我们将学习每个组件的主要职责,以及它们的预期输入和输出。
以下图表描述了 TypeScript 架构的主要组件:

TypeScript 架构的组成部分
TypeScript 核心 API 是所有事物的基石,由扫描器、解析器、绑定器、类型检查器和发射器等元素组成。
语言服务和独立编译器(tsc 命令行工具)位于核心编译器 API 之上。最后,Visual Studio 适配器和 TypeScript 独立服务器(tsserver)被设计用来促进 TypeScript 与 Visual Studio 和其他源代码编辑器的集成。
TypeScript 的官方文档将 TypeScript 独立服务器定义为以下内容:
"TypeScript 独立服务器(又称 tsserver)是一个封装 TypeScript 编译器和语言服务并通过 JSON 协议暴露它们的 Node 可执行文件。tsserver 非常适合编辑器和 IDE 支持。"
扫描器
扫描器将源代码文件转换成标记流。扫描器在其他关于编译器的资源中也被称为词法分析器。扫描器被解析器使用。
词素和标记
词素是源程序中匹配标记模式的字符序列。我们可以这样说,标记有一个模式,而在某些情况下,模式可以由许多词素匹配。因此,在编程语言中,存在无限多的潜在词素和有限的标记数量。
理解词素和标记之间的区别的最简单方法是通过查看以下代码片段的例子:
while (y >= t) y = y - 3;
以下代码片段将被解析成以下词素和标记:
| 词素 | 标记 |
|---|---|
while |
WhileKeyword |
( |
OpenParenToken |
y |
Identifier |
>= |
GreaterThanEqualsToken |
t |
Identifier |
) |
CloseParenToken |
y |
Identifier |
= |
EqualsToken |
y |
Identifier |
- |
MinusToken |
3 |
NumericLiteral |
; |
SemicolonToken |
EndOfFileToken |
在 TypeScript 中,标记在SyntaxKind枚举中定义:
export const enum SyntaxKind {
Unknown,
EndOfFileToken,
SingleLineCommentTrivia,
MultiLineCommentTrivia,
NewLineTrivia,
WhitespaceTrivia,
ShebangTrivia,
ConflictMarkerTrivia,
NumericLiteral,
StringLiteral,
JsxText,
//...
SyntaxtKind枚举在 TypeScript 源代码的/src/compiler/types.ts文件中定义。
如果您想探索 TypeScript 项目的整个源代码,请参阅 GitHub 上的官方 TypeScript 仓库github.com/Microsoft/TypeScript。
解析器
TypeScript 解析器使用扫描器遍历我们的源代码文件并将它们转换成标记流。
TypeScript 解析器然后将标记流转换成一个称为抽象语法树(AST)的树状数据结构。这个树状数据结构中的每个元素都称为节点。节点是 AST 的基本构建块。
AST
抽象语法树(AST)是由解析器创建的树状数据结构。这种数据结构允许 TypeScript 编译器遍历我们的源代码以执行许多核心任务,例如生成输出 JavaScript 代码。我们将在本章后面了解更多关于 AST 的内容。
符号
Basarat Ali Syed 编写的 TypeScript 教科书将符号描述如下:
"符号将 AST 中的声明节点与其他贡献同一实体的声明连接起来。符号是语义系统的基本构建块。"
符号类在 TypeScript 源代码中定义如下:
function Symbol(this: Symbol, flags: SymbolFlags, name: __String) {
this.flags = flags;
this.escapedName = name;
this.declarations = undefined;
this.valueDeclaration = undefined;
this.id = undefined;
this.mergeId = undefined;
this.parent = undefined;
}
一个符号包含对类型声明的引用和一些帮助我们识别其某些特性的标志。
绑定器
Basarat Ali Syed 编写的 TypeScript 教科书将绑定器描述如下:
"绑定器用于将源代码的各个部分连接成一个连贯的类型系统,然后该系统可以被检查器使用。绑定器的主要责任是创建符号。"
TypeScript 支持一个称为声明合并的功能,允许我们将使用相同名称声明的两个单独的声明合并为一个单一的定义。例如,以下代码片段声明了两个名为Person的接口和一个名为person的变量:
interface Person {
name: string;
}
interface Person {
surname: string;
}
const person: Person = { name: "Remo", surname: "Jansen" };
变量的类型是Person,正如我们所见,该类型包含在先前声明的接口中声明的属性。这是因为声明合并机制将这两个声明合并为一个唯一的类型。这与绑定器的行为直接相关。
类型检查器
类型检查器可能是 TypeScript 编译器中最重要的组件。类型检查器使用抽象语法树(每个文件一个)和符号作为输入,并负责监督源代码中类型错误的识别。
发射器
发射器是负责生成输出代码的组件。输出通常是遵循支持的规范之一(ES3、ES5 或 ES6)的 JavaScript,但它也可以是类型定义或源映射文件。
语言服务
TypeScript 编译器包括一个专门设计来为开发者提供良好开发体验的附加组件。以下段落是从官方 TypeScript 文档中提取的:
“语言服务”在核心编译器管道周围提供了一个额外的层,非常适合类似编辑器的应用程序。语言服务支持典型的编辑器操作集,如语句完成、签名帮助、代码格式化和大纲、着色等。基本的重构,如重命名,调试接口辅助,如验证断点,以及 TypeScript 特定的功能,如支持增量编译(命令行上的--watch 等效)。语言服务旨在高效地处理在长期编译上下文中随时间变化的文件场景;从这个意义上说,语言服务从其他编译器接口的角度来看,对与程序和源文件的工作提供了略微不同的视角。”
理解抽象语法树(AST)
正如我们已经学到的,抽象语法树(AST)是一种树形数据结构,用于表示用编程语言编写的源代码的抽象句法结构。AST 的每个节点都代表源代码中发生的一个构造。
现在我们将查看一个小型的 TypeScript 代码片段,以详细了解 AST。以下代码片段没有什么特别之处——它只是声明了一个名为Weapon的接口和几个类,名为Katana和Ninja。然后它创建了一个Ninja类的实例并调用了它的一种方法:
interface Weapon {
tryHit(fromDistance: number): boolean;
}
class Katana implements Weapon {
public tryHit(fromDistance: number) {
return fromDistance <= 2;
}
}
class Ninja {
private _weapon: Weapon;
public constructor(weapon: Weapon) {
this._weapon = weapon;
}
public fight(fromDistance: number) {
return this._weapon.tryHit(fromDistance);
}
}
const ninja = new Ninja(new Katana());
ninja.fight("5");
TypeScript 编译器生成的先前代码片段的 AST 如下所示:

为了便于理解,已经删除了先前 AST 的一些节点。我们可以看到 AST 从SourceFile节点开始,以EndOfFileToken节点结束。在这两个节点之间,我们有一个接口声明节点(InterfaceDeclaration),两个类声明节点(ClassDeclaration),一个变量声明节点(VariableStatement),以及最终的方法调用节点(ExpressionStatement)。
我们现在将关注这些节点中的一个:表示接口声明的节点(InterfaceDeclaration)。正如我们之前看到的,接口声明看起来如下:
interface Weapon {
tryHit(fromDistance: number): boolean;
}
前面代码片段的 AST 中的 InterfaceDeclaration 节点如下所示:

在前面 AST 的表示中,我们可以看到组成接口声明 AST 且具有唯一方法签名的每个节点的名称。例如,我们可以看到声明从表示 interface 关键字的节点(InterfaceKeyword)开始,并且其后是接口的名称(Identifier)。我们还可以看到 tryHit 方法接受一个数字(NumberKeyword)作为参数,并返回一个布尔值(BooleanKeyword)。
AST 节点具有某些属性。例如,前面示例中的 NumberKeyword 具有以下属性:
NumberKeyword
pos:43
start:44
end:50
flags:0
kind:133
这些属性使我们能够识别节点的类型(kind 属性)及其在源代码中的位置(pos 和 end 属性)。kind 是对 Token 的引用。值 133 是 SyntaxKind 枚举中 NumberKeyword 属性的值。
现在我们已经知道了 TypeScript AST 是什么以及它的样子。在接下来的部分,我们将学习一个可以帮助我们可视化 AST 的工具。
TypeScript AST 查看器
TypeScript AST 查看器是一个开源应用程序,允许我们探索给定 TypeScript 代码片段的 AST。此应用程序可在 ts-ast-viewer.com 上在线使用:

示例应用程序
伴随源代码包括一个非常小的应用程序,我们将在本章的其余部分使用它。以下小节描述了示例应用程序中的每个组件。
interfaces.ts
interfaces.ts 文件声明并导出了一些接口:
export interface Weapon {
tryHit(fromDistance: number): boolean;
}
export interface Named {
name: string;
}
katana.ts
katana.ts 文件声明了一个名为 BaseWeapon 的基类和一个名为 Katana 的派生类:
import { Weapon, Named } from "./interfaces";
export class BaseWeapon {
damage = 25;
}
export class Katana extends BaseWeapon implements Weapon, Named {
name = "Katana";
public tryHit(fromDistance: number) {
return fromDistance <= 2;
}
}
ninja.ts
ninja.ts 文件声明了一个名为 Ninja 的类:
import { Weapon } from "./interfaces";
export class Ninja {
private _weapon: Weapon;
public constructor(weapon: Weapon) {
this._weapon = weapon;
}
public fight(fromDistance: number) {
return this._weapon.tryHit(fromDistance);
}
}
main.ts
main.ts 文件是应用程序的入口点。它创建了一个 Katana 实例和一个 Ninja 实例,然后调用 Ninja 实例的一个方法:
import { Ninja } from "./ninja";
import { Katana } from "./katana";
const ninja = new Ninja(new Katana());
ninja.fight(5);
broken.ts
伴随源代码还包括一个名为 broken.ts 的文件。此文件故意包含一些编译错误,因为它用于演示如何执行错误诊断:
import { Ninja } from "./ninja";
import { Katana } from "./katana";
const ninja = new Ninja(new Katana());
ninja.fight("5");
遍历 TypeScript AST
我们已经学习了如何使用在线 TypeScript AST 查看器可视化 TypeScript AST。在这个时候,我们自然会问自己所有这些信息是从哪里来的。在本节中,我们将演示如何使用 TypeScript 编译器 API 访问 AST。
在创建 package.json 文件并使用 npm 安装 TypeScript 之后,我们首先需要做的是创建一个新的 TypeScript 文件并将 TypeScript 作为模块导入:
import * as ts from "typescript";
然后我们需要使用对象字面量声明 TypeScript 编译器的配置:
const options = {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES5,
};
接下来,我们需要创建一个新的程序:
const program = ts.createProgram(
[
"./app/interfaces.ts",
"./app/ninja.ts",
"./app/katana.ts",
"./app/main.ts"
],
options
);
程序是一组源文件和一组表示编译单元的编译选项的集合。程序是类型系统和代码生成系统的主入口点。
然后我们需要创建 TypeScript 类型检查器的实例:
const checker = program.getTypeChecker();
到目前为止,我们可以编写一些代码来遍历程序中的源文件。以下代码片段遍历给定源文件的 AST,并返回每个文件中声明的类和接口列表。
它使用程序实例的 getSourceFiles 方法来访问程序中的源文件。lib.d.ts 文件和 node_modules 目录下的文件将被忽略。
代码片段使用名为 visit 的递归函数来遍历 AST 中的节点。递归函数将每个节点与我们要查找的标记(ClassDeclaration 和 InterfaceDeclaration)进行比较,以识别类和接口:
interface Result {
fileName: string;
classes: string[];
interfaces: string[];
}
const entities = program.getSourceFiles().map(file => {
if (
file.fileName.indexOf("lib.d.ts") !== -1 ||
file.fileName.indexOf("node_modules") !== -1
) {
return null;
}
const result = {
fileName: file.fileName,
classes: [] as string[],
interfaces: [] as string[]
};
const visit = (node: ts.Node) => {
if (node.kind === ts.SyntaxKind.ClassDeclaration) {
// Find class identifier
node.getChildren().forEach(n => {
if (n.kind === ts.SyntaxKind.Identifier) {
const name = (n as ts.Identifier).getFullText();
result.classes.push(name);
}
});
} else if (node.kind === ts.SyntaxKind.InterfaceDeclaration) {
// Find interface identifier
node.getChildren().forEach(n => {
if (n.kind === ts.SyntaxKind.Identifier) {
const name = (n as ts.Identifier).getFullText();
result.interfaces.push(name);
}
});
} else if (node.kind === ts.SyntaxKind.ModuleDeclaration) {
// Iterate module nodes
ts.forEachChild(node, visit);
}
};
ts.forEachChild(file, visit);
return result;
}).filter(e => e !== null) as Result[];
如果找到的标记是模块声明,我们将再次调用递归函数 visit。一旦我们成功找到源代码中的所有类和接口,我们可以使用简单的 forEach 循环在控制台中显示它们:
entities.forEach(e => {
console.log(chalk.cyan(`
FILE: ${e.fileName}n
CLASSES: ${e.classes.length > 0 ? e.classes : "N/A"}n
INTERFACES: ${e.interfaces.length > 0 ? e.interfaces : "N/A"}n
`));
});
注意,chalk 模块可以通过 npm 安装,并用于在控制台输出中显示彩色文本。
现在我们知道了如何访问和遍历 TypeScript AST。正如我们所见,创建此类任务所需的过程相当繁琐。然而,有一个开源工具可以帮助我们轻松地遍历 TypeScript AST:ts-simple-ast。
使用 ts-simple-ast
正如我们在上一节中学到的,当涉及到与 TypeScript 一起工作时,AST 并不是很复杂。然而,有一个名为 ts-simple-ast 的开源 npm 模块可以使处理 TypeScript AST 更加容易!在本节中,我们将查看多个示例,以了解如何使用 ts-simple-ast。
使用 ts-simple-ast 遍历 AST
以下代码片段实现了一个几乎与上一节中实现的程序完全相同的应用程序。最显著的区别是,我们不是使用核心 TypeScript 编译器 API,而是将使用 ts-simple-ast 辅助工具:
import chalk from "chalk";
import Ast, { DiagnosticMessageChain } from "ts-simple-ast";
以下函数在本章的许多示例中使用,用于根据某些文件和所需的编译器设置获取 TypeScript AST 的实例:
function getAst(tsConfigPath: string, sourceFilesPath: string) {
const ast = new Ast({
tsConfigFilePath: tsConfigPath,
addFilesFromTsConfig: false
});
ast.addExistingSourceFiles(sourceFilesPath);
return ast;
}
我们可以使用 getAst 函数来访问 ts-simple-ast AST,然后使用 getSourceFiles 方法来访问程序中的源文件:
const myAst = getAst("./tsconfig.json", "./app/*.ts");
const files = myAst.getSourceFiles();
到目前为止,我们可以使用 getFilePath 来获取源文件的路径,以及使用 getClasses 和 getInterfaces 方法来访问源文件中的类和接口声明:
const entities = files.map(f => {
return {
fileName: f.getFilePath(),
classes: f.getClasses().map(c => c.getName()),
interfaces: f.getInterfaces().map(i => i.getName())
};
});
如我们所见,ts-simple-ast 辅助工具可以极大地简化遍历 TypeScript AST 或搜索特定类型的实体。
最后,我们可以在命令行界面显示类的名称:
entities.forEach(e => {
console.log(
chalk.cyan(`
FILE: ${e.fileName}n
CLASSES: ${e.classes.length > 0 ? e.classes : "N/A"}n
INTERFACES: ${e.interfaces.length > 0 ? e.interfaces : "N/A"}n
`)
);
});
使用 ts-simple-ast 进行诊断
以下代码片段实现了一个非常小的应用程序,该应用程序使用 ts-simple-ast 通过错误诊断 API 在 TypeScript 文件中查找错误。该应用程序使用 chalk npm 模块在命令行界面以红色字体显示错误:
import chalk from "chalk";
import Ast, { DiagnosticMessageChain } from "ts-simple-ast";
以下函数是我们在前面部分使用的相同的 getAst 函数:
function getAst(tsConfigPath: string, sourceFilesPath: string) {
const ast = new Ast({
tsConfigFilePath: tsConfigPath,
addFilesFromTsConfig: false
});
ast.addExistingSourceFiles(sourceFilesPath);
return ast;
}
ts-simple-ast 提供的 AST 包含一个名为 getDiagnostics 的方法,它允许我们访问检测到的编译错误。getErrors 函数展示了如何遍历每个诊断以及如何访问底层的 DiagnosticMessageChain。
方法 diagnostic.getMessageText 返回一个字符串或一个 DiagnosticMessageChain。DiagnosticMessageChain 实现了迭代器模式,这就是为什么我们使用 DiagnosticMessageChain.getNext 方法的原因:
function getErrors(ast: Ast) {
const diagnostics = ast.getDiagnostics();
function dmcToString(dmc: DiagnosticMessageChain, msg: string = ""): string {
const messageText = dmc.getMessageText();
const code = dmc.getCode();
msg += `${code} ${messageText}n`;
const next = dmc.getNext();
return next ? dmcToString(next, msg) : msg;
}
const errors = diagnostics.map(diagnostic => {
const code = diagnostic.getCode();
const sourceOrUndefined = diagnostic.getSourceFile();
const source = sourceOrUndefined ? sourceOrUndefined.getFilePath() : "";
const line = sourceOrUndefined
? sourceOrUndefined.getLineNumberFromPos(diagnostic.getStart() || 0)
: "";
const stringOrDMC = diagnostic.getMessageText();
const messageText =
typeof stringOrDMC === "string" ? stringOrDMC : dmcToString(stringOrDMC);
return `
ERROR CODE: ${code}
DESCRIPTION: ${messageText}
FILE: ${source}
LINE: ${line}
`;
});
return errors;
}
const myAst = getAst("./tsconfig.json", "./app/broken.ts");
getErrors(myAst).forEach(err => console.log(chalk.red(err)));
使用 ts-simple-ast 访问类详情
以下代码片段演示了我们可以如何使用 ts-simple-ast API 来访问和操作类声明。
注意,前面的示例不是用来执行的。它展示了 ts-simple-ast API 中可用的方法,但它不是一个可执行的演示。
就像前面的示例一样,我们将使用 getAst、getSourceFiles 和 getClasses 方法来找到我们源代码中的所有类声明。
然后,我们将使用一些工具来访问类声明的详细信息,包括方法、派生类和属性等。我们还将演示如何通过添加基类或新方法等操作来修改类声明:
import chalk from "chalk";
import Ast, { DiagnosticMessageChain } from "ts-simple-ast";
function getAst(tsConfigPath: string, sourceFilesPath: string) {
const ast = new Ast({
tsConfigFilePath: tsConfigPath,
addFilesFromTsConfig: false
});
ast.addExistingSourceFiles(sourceFilesPath);
return ast;
}
const myAst = getAst("./tsconfig.json", "./app/*.ts");
const files = myAst.getSourceFiles();
files.forEach(file => {
// Find all classes
const classes = file.getClasses();
// Find class by name
const class1 = file.getClass("Katana");
// Find class with no constructors
const firstClassWithConstructor = file.getClass(
c => c.getConstructors().length > 0
);
// Add a class
const classDeclaration = file.addClass({
name: "ClassName"
});
// Get extends
const extendsExpression = classDeclaration.getExtends();
// Set extends
classDeclaration.setExtends("BaseClass");
// Remove extends
classDeclaration.removeExtends();
// Get derived classes
const derivedClasses = classDeclaration.getDerivedClasses();
// Remove one class
if (classDeclaration) {
classDeclaration.remove();
}
// Get instance methods
const instanceMethods = classDeclaration.getInstanceMethods();
// Get static methods
const staticMethods = classDeclaration.getStaticMethods();
// Add method
const method = classDeclaration.addMethod(
{ isStatic: true, name: "myMethod", returnType: "string" }
);
// Remove method
method.remove();
// Get instance properties
const instanceProperties = classDeclaration.getInstanceProperties();
// Get static properties
const staticProperties = classDeclaration.getStaticProperties();
// Add a property
const property = classDeclaration.addProperty({ isStatic: true, name: "prop", type: "string" });
// Remove property
property.remove();
});
使用 ts-simple-ast 访问模块详情
以下代码片段演示了我们可以如何使用 ts-simple-ast API 来访问和操作模块 import 和 export 声明。
注意,前面的示例不是用来执行的。它展示了 ts-simple-ast API 中可用的方法,但它不是一个可执行的演示。
就像前面的示例一样,我们将使用 getAst 和 getSourceFiles 方法来访问源代码的对象:
import chalk from "chalk";
import Ast, { DiagnosticMessageChain } from "ts-simple-ast";
function getAst(tsConfigPath: string, sourceFilesPath: string) {
const ast = new Ast({
tsConfigFilePath: tsConfigPath,
addFilesFromTsConfig: false
});
ast.addExistingSourceFiles(sourceFilesPath);
return ast;
}
const myAst = getAst("./tsconfig.json", "./app/*.ts");
const files = myAst.getSourceFiles();
然后,我们将使用一些方法来访问模块 import 和 export 声明的详细信息。我们还演示了如何通过添加默认的 export 等操作来添加和删除模块 import 和 export 声明:
files.forEach(file => {
const functionDeclaration = file.getFunction("someFunction");
if (functionDeclaration) {
// Is exported
functionDeclaration.isExported();
functionDeclaration.isNamedExport();
functionDeclaration.isDefaultExport();
// Has export keyword
functionDeclaration.hasExportKeyword();
functionDeclaration.hasDefaultKeyword();
// Access export keywords
functionDeclaration.getExportKeyword();
functionDeclaration.getDefaultKeyword();
// Set is export
functionDeclaration.setIsDefaultExport(true);
functionDeclaration.setIsDefaultExport(false);
// Set is exported
functionDeclaration.setIsExported(true);
functionDeclaration.setIsExported(false);
}
// Get all imports
const imports = file.getImportDeclarations();
// Add import
const importDeclaration = file.addImportDeclaration({
defaultImport: "MyClass",
moduleSpecifier: "./file"
});
// Remove import
importDeclaration.remove();
// Get default import
const defaultImport = importDeclaration.getDefaultImport();
// Get named imports
const namedImports = importDeclaration.getNamedImports();
// Add named import
const namedImport = importDeclaration.addNamedImport({
name: "MyClass",
alias: "MyAliasName" // alias is optional
});
// Remove named import
namedImport.remove();
});
访问语言服务 API
语言服务 API 建立在核心编译器 API 之上,它被设计用来为软件工程师提供独立于他们选择的 IDE 或代码编辑器的优秀开发体验。
我们将使用ts-simple-ast来访问语言服务 API。我们可以在 AST 实例中使用getLanguageService方法来访问语言服务 API:
myAst.getLanguageService();
语言服务 API 实现了允许我们执行常见编辑任务的方法,例如重命名变量或自动实现接口。以下截图显示了语言服务 API 中的一些可用方法:

现在,我们将创建一个非常小的应用程序,该应用程序使用语言服务 API 在配套源代码中包含的示例应用程序中查找接口。该应用程序将显示每个接口及其实现的名称:
import chalk from "chalk";
import { flatten, join } from "lodash";
import Ast, { DiagnosticMessageChain } from "ts-simple-ast";
import * as ts from "typescript";
function getAst(tsConfigPath: string, sourceFilesPath: string) {
const ast = new Ast({
tsConfigFilePath: tsConfigPath,
addFilesFromTsConfig: false
});
ast.addExistingSourceFiles(sourceFilesPath);
return ast;
}
const myAst = getAst("./tsconfig.json", "./app/*.ts");
const languageService = myAst.getLanguageService();
const files = myAst.getSourceFiles();
const interfaceDeclarations = flatten(files.map(f => f.getInterfaces()));
然后,我们需要使用getName方法找到每个接口声明的名称。
我们也将尝试使用getImplementations方法找到它们的每个实现,这个方法是语言服务 API 的一部分。该方法期望我们传递声明接口的节点。节点可以通过interfaceDeclaration.getNameNode方法访问。
一旦我们找到了实现,我们需要找到它们的名称。我们通过搜索称为Identifier类型的节点的名称来完成此操作:
const result = interfaceDeclarations.map(interfaceDeclaration => {
const interfaceName = interfaceDeclaration.getName();
const implementations = languageService.getImplementations(
interfaceDeclaration.getNameNode()
);
const implementationNames = implementations.map(implementation => {
const children = implementation.getNode().getChildren();
const identifier = children.filter(
child => child.getKind() === ts.SyntaxKind.Identifier
)[0];
const implementationName = identifier.getText();
return implementationName;
});
return {
interface: interfaceName,
implementations: implementationNames
};
});
最后,我们在控制台中显示结果:
console.log(
result.forEach(
o => console.log(
`- ${o.interface} is implemented by ${join(o.implementations, ",")}`
)
)
);
如果我们执行应用程序,我们应该能够在我们的控制台中看到以下内容显示:
- Weapon is implemented by Katana
- Named is implemented by Katana
自从 TypeScript 2.2 以来,可以编写语言服务插件来扩展语言服务 API。自定义语言服务可以帮助开发者获得更好的开发体验。例如,有一个语言服务在开发者处理 GraphQL 查询时提供自动完成和错误诊断功能。在此之前,GraphQL 查询只是文本,因此它们在实现上有些繁琐。实现我们的语言服务插件超出了本书的范围,但如果你希望了解更多,你可以在github.com/Microsoft/TypeScript/wiki/Writing-a-Language-Service-Plugin上学习。
实现一个 yUML 编译器
在本节中,我们将把在本章中学到的所有内容整合起来,创建一个自定义的开发工具。我们将编写一个工具,它接受 TypeScript 源代码作为输入,并生成一个统一建模语言(UML)类图。类图通过显示系统的类、它们的属性、方法和对象之间的关系来描述系统的结构。
类图看起来如下:

我们将使用 TypeScript 编译器 API 和 ts-simple-ast 来遍历由配套源代码中包含的示例应用程序生成的 AST。然后,我们将以一个称为 yUML 的领域特定语言(DSL)的形式生成一些代码。最后,我们将 yUML DSL 发布到在线服务以生成图像。我们将从 TypeScript 转换为 yUML,这意味着我们可以将此视为创建一个 yUML 编译器。
我们将首先导入一些必需的模块:
import * as fs from "fs";
import { flatten, join } from "lodash";
import * as path from "path";
import * as request from "request";
import Ast, * as SimpleAST from "ts-simple-ast";
import * as ts from "typescript";
fs 和 path 模块是 Node.js 的原生核心模块,不需要安装。但是,我们需要 Node.js 的类型定义(@types/node)。
注意,flatten 函数是 lodash npm 模块的一部分。此函数允许我们将多维数组(例如,数组数组)转换为只有一个维度的数组。
然后,我们将声明两个接口,用于表示类、方法或属性的详细信息。在这个例子中,我们只将使用方法或属性的名称:
interface MethodDetails {
name: string;
}
interface PropertyDetails {
name: string;
}
以下代码片段声明了一个名为 templates 的常量变量。模板是接受名称并返回包含 yUML DSL 片段的字符串的函数:
const templates = {
url: (dsl: string) => `http://yuml.me/diagram/scruffy/class/${dsl}`,
composition: "+->",
implementsOrExtends: (abstraction: string, implementation: string) => {
return (
`${templates.plainClassOrInterface(abstraction)}` +
`^-${templates.plainClassOrInterface(implementation)}`
);
},
plainClassOrInterface: (name: string) => `[${name}]`,
colorClass: (name: string) => `[${name}{bg:skyblue}]`,
colorInterface: (name: string) => `[${name}{bg:palegreen}]`,
class: (name: string, props: PropertyDetails[], methods: MethodDetails[]) => {
const pTemplate = (property: PropertyDetails) => `${property.name};`;
const mTemplate = (method: MethodDetails) => `${method.name}();`;
return (
`${templates.colorClass(name)}` +
`[${name}|${props.map(pTemplate)}|${methods.map(mTemplate)}]`
);
},
interface: (
name: string,
props: PropertyDetails[],
methods: MethodDetails[]
) => {
const pTemplate = (property: PropertyDetails) => `${property.name};`;
const mTemplate = (method: MethodDetails) => `${method.name}();`;
return (
`${templates.colorInterface(name)}` +
`[${name}|${props.map(pTemplate)}|${methods.map(mTemplate)}]`
);
}
};
以下函数用于获取给定源文件的 AST:
function getAst(tsConfigPath: string, sourceFilesPaths?: string[]) {
const ast = new Ast({
tsConfigFilePath: tsConfigPath,
addFilesFromTsConfig: !Array.isArray(sourceFilesPaths)
});
if (sourceFilesPaths) {
ast.addExistingSourceFiles(sourceFilesPaths);
}
return ast;
}
以下函数生成类声明的 yUML DSL。我们遍历 AST,寻找属性和方法:
function emitClass(classDeclaration: SimpleAST.ClassDeclaration) {
const className = classDeclaration.getSymbol()!.getName();
const propertyDeclarations = classDeclaration.getProperties();
const methodDeclarations = classDeclaration.getMethods();
const properties = propertyDeclarations.map(property => {
const sym = property.getSymbol();
if (sym) {
return {
name: sym.getName()
};
}
}).filter((p) => p !== undefined) as PropertyDetails[];
const methods = methodDeclarations.map(method => {
const sym = method.getSymbol();
if (sym) {
return {
name: sym.getName()
}
}
}).filter((p) => p !== undefined) as MethodDetails[];
return templates.class(className, properties, methods);
}
以下函数生成接口声明的 yUML DSL。我们遍历 AST,寻找属性和方法:
function emitInterface(interfaceDeclaration: SimpleAST.InterfaceDeclaration) {
const interfaceName = interfaceDeclaration.getSymbol()!.getName();
const propertyDeclarations = interfaceDeclaration.getProperties();
const methodDeclarations = interfaceDeclaration.getMethods();
const properties = propertyDeclarations.map(property => {
const sym = property.getSymbol();
if (sym) {
return {
name: sym.getName()
}
}
}).filter((p) => p !== undefined) as PropertyDetails[];
const methods = methodDeclarations.map(method => {
const sym = method.getSymbol();
if (sym) {
return {
name: sym.getName()
}
}
}).filter((p) => p !== undefined) as MethodDetails[];
return templates.interface(interfaceName, properties, methods);
}
以下函数生成继承子句的 yUML DSL。这包括通过使用 extends 和 implements 关键字在源代码中定义的关系:
function emitInheritanceRelationships(
classDeclaration: SimpleAST.ClassDeclaration
) {
const className = classDeclaration.getSymbol()!.getName();
const extended = classDeclaration.getExtends();
const implemented = classDeclaration.getImplements();
let heritageClauses: HeritageClause[] = [];
if (extended) {
const identifier = extended.getChildrenOfKind(ts.SyntaxKind.Identifier)[0];
if (identifier) {
const sym = identifier.getSymbol();
if (sym) {
heritageClauses.push(
{
clause: sym.getName(),
className
}
);
}
}
}
if (implemented) {
implemented.forEach(i => {
const identifier = i.getChildrenOfKind(ts.SyntaxKind.Identifier)[0];
if (identifier) {
const sym = identifier.getSymbol();
if (sym) {
heritageClauses.push(
{
clause: sym.getName(),
className
}
);
}
}
});
}
return flatten(heritageClauses).map((c: HeritageClause) =>
templates.implementsOrExtends(c.clause, c.className)
);
}
以下函数使用 yUML 网络服务将 UML 图表渲染到 .png 文件。我们使用 request npm 模块调用该网络服务:
request 模块用于下载渲染后的图像。然后使用 Node.js 文件系统 API 将图像保存到当前目录:
function render(dsl: string) {
const download = (uri: string, filename: string, callback: () => void) => {
request.head(uri, (err, res, body) => {
request(uri)
.pipe(fs.createWriteStream(filename))
.on("close", callback);
});
};
const url = templates.url(dsl);
const file = `uml_diagram_${new Date().getTime()}.png`;
const absolutePath = path.join(__dirname, file);
download(url, file, () =>
console.log(`Saved UML diagram available at ${absolutePath}`)
);
}
以下函数为给定的 TypeScript 文件生成 yUML DSL。此函数将工作委托给先前定义的 getAst、emitClass、emitInterface 和 emitInheritanceRelationships 函数:
function yUML(tsConfigPath: string, sourceFilesPaths: string[]) {
const ast = getAst(tsConfigPath, sourceFilesPaths);
const files = ast.getSourceFiles();
const declarations = files.map(f => {
return {
fileName: f.getFilePath(),
classes: f.getClasses(),
interfaces: f.getInterfaces()
};
});
const entities = declarations.map(d => {
const classes = d.classes.map(emitClass);
const interfaces = d.interfaces.map(emitInterface);
const inheritanceRelationships = d.classes.map(
emitInheritanceRelationships
);
return [...classes, ...interfaces, ...inheritanceRelationships];
});
return join(flatten(entities), ",");
}
到目前为止,我们已经实现了整个应用程序,并且可以调用 yUML 函数来为给定的文件生成 yUML DSL:
const yuml = yUML("./tsconfig.json", [
"./app/interfaces.ts",
"./app/ninja.ts",
"./app/katana.ts",
"./app/main.ts"
]);
最后,我们可以调用 render 函数:
render(yuml);
请参阅官方 ts-simple-ast 文档 dsherret.github.io/ts-simple-ast/ 了解更多关于可用 API 的信息。
VS Code 扩展
开发 Visual Studio 扩展超出了本书的范围。然而,值得一提的是,可以使用 TypeScript 开发 VS Code 扩展。这意味着我们可以将我们的自定义命令行 TypeScript 工具,如 UML 图生成器,转换成 VS Code 扩展,而不会遇到太多复杂的问题。
请参考关于在 code.visualstudio.com/docs/extensions/overview 开发扩展的官方 VS Code 文档以获取更多信息。
摘要
在本章中,我们学习了 TypeScript 编译器的内部组件。我们还学习了如何使用编译器 API 以及如何利用这些特性来开发我们的 TypeScript 开发工具。
我希望这一章能够激发你对 TypeScript 编译器内部结构和由 TypeScript 驱动的软件开发工具开发的兴趣。它们的潜力和日益增长的受欢迎程度将把 JavaScript 生态系统提升到新的水平。


浙公网安备 33010602011771号