原文:
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 编程语言中的类、接口和其他面向对象编程特性。
1333



被折叠的 条评论
为什么被折叠?



