TypeScript-函数式编程实用指南-全-
TypeScript 函数式编程实用指南(全)
原文:
zh.annas-archive.org/md5/602ad78348cc07aa1e51f0f718216844译者:飞龙
前言
函数式编程是一种将计算视为数学函数评估的编程范式,并避免改变状态和可变数据。函数式编程范式的起源可以追溯到 20 世纪 30 年代,当时阿隆佐·丘奇介绍了 Lambda 演算。Lambda 演算提供了一个描述函数及其评估的理论框架,是一种数学抽象,而不是编程语言。然而,Lambda 演算是大多数函数式编程语言的基础。
在 20 世纪 50 年代末,约翰·麦卡锡开发了 Lisp,这是最早的函数式编程语言之一。Lisp 引入了许多函数式编程范式特性,并成为其他流行函数式编程语言(如 Scheme 和 Clojure)的主要影响。
1973 年,罗宾·米尔纳在爱丁堡大学创建了 ML。ML 最终发展成了几种替代语言,其中最常见的是 OCaml 和标准 ML。1977 年,约翰·巴克斯以允许“程序代数”并遵循组合性原则的方式定义了函数程序。1985 年,研究软件有限公司发布了 Miranda,对惰性函数式编程语言的兴趣增长。几年后,存在十多种非严格、纯函数式编程语言。1987 年,在俄勒冈州波特兰举行的函数式编程语言和计算机架构会议上,形成了强烈的共识,即应成立一个委员会来定义此类语言的开放标准;Haskell 应运而生。
20 世纪 70 年代和 80 年代是函数式编程显著进步的年份。然而,在 90 年代和 21 世纪初,函数式编程在与面向对象编程语言(如 Java 和 C#)的竞争中失去了市场份额。
在 2010 年代,JavaScript 的采用呈指数增长,成为最受欢迎的编程语言。Scheme 编程语言是 JavaScript 的主要影响因素之一,因此 JavaScript 实现了许多函数式编程特性,例如支持高阶函数。JavaScript 成为许多年轻开发者接触函数式编程的第一步。然而,由于 JavaScript 是一种多范式编程语言,许多人忽略了其函数式编程能力。然而,在最近几年,随着受函数式编程原则高度影响的技术(如 React、RxJS 和 Redux)的出现,JavaScript 社区对函数式编程的兴趣显著增加。
随着 JavaScript 的普及,JavaScript 应用程序的复杂性也呈指数级增长。Web 用户界面变得更加复杂,JavaScript 开始被用于多种替代场景,例如后端应用程序。随后,TypeScript 编程语言被引入作为一种工具,它允许我们管理新的复杂级别。
TypeScript 旨在通过向 JavaScript 添加静态类型系统来减少系统的复杂性。静态类型系统可以在编译时检测错误,这是一种有益的代码文档形式。在函数式编程中,静态类型系统可以非常有用。大多数面向对象的编程语言,如 Java 和 C#,正在缓慢地采用函数式编程特性,而复杂的静态类型系统通常与纯函数式编程语言(如 Haskell)相关联。
本书不会鼓励你停止使用面向对象编程。相反,我们将尝试将函数式编程和面向对象编程范式视为解决同一问题的两种不同解决方案:管理复杂性:
"面向对象编程通过封装移动部分使代码易于理解。函数式编程通过最小化移动部分使代码易于理解。"
– 迈克尔·费瑟斯
随着云计算的采用持续增长,分布式系统的普及也在增加,因此,预计在接下来的十年中,函数式编程的受欢迎程度也将上升,因为它特别适合并发系统和分布式系统。函数式编程鼓励实现无状态组件,这些组件可以轻松扩展。由于分布式系统的复杂性通常很高,这仅仅是函数式编程如何作为对抗复杂性的武器的另一个例子。
将 TypeScript 与面向对象编程和函数式编程的原则和技术相结合,可以为我们提供更丰富的工具集,以对抗系统中的复杂性。本书将为你提供关于广泛的功能性编程原则、模式和技术的知识,这应该有助于你成为一个更全能的软件工程师,并为你应对现代 Web 应用程序中日益增长的复杂性做好准备。
本书面向对象
如果你是一名开发者,目标是首次学习函数式编程并提高你应用程序的质量,那么这本书就是为你准备的。不需要函数式编程的先验知识。然而,为了充分利用这本书,建议你具备 JavaScript 和 TypeScript 的基本理解。
本书涵盖内容
第一章,函数式编程基础,介绍了主要的函数式编程术语,如纯函数。
第二章,精通函数,深入探讨了函数式编程应用中的主要构建块——函数。本章还探讨了 TypeScript 中大多数与函数相关的特性。我们将学习如何在许多不同的场景下与函数一起工作,以及如何在处理函数时利用 TypeScript 的类型系统特性。
第三章,精通异步编程,深入探讨了 JavaScript 和 TypeScript 中的主要异步编程 API,包括回调、承诺、生成器和异步函数。这些 API 在函数式编程中相关,因为它们可以用来实现惰性求值。
第四章,运行时 – 事件循环和 this 操作符,是两章中第一章,专注于探索与许多函数式编程技术相关的运行时概念。例如,如果我们理解了事件循环,我们就能更好地理解递归。
第五章,运行时 – 闭包和原型,是第二章节,专注于探索与许多函数式编程技术相关的运行时概念。例如,理解闭包可以帮助我们理解一些高阶函数是如何工作的。
第六章,函数式编程技术,详细探讨了主要的函数式编程技术和模式。我们将探讨部分函数应用、函数组合和柯里化等概念。本章还探讨了其他许多函数式编程技术和模式,如无参数风格。
第七章,范畴论,探讨了范畴论。你将了解什么是代数数据类型以及它们之间的关系。然后,你将学习如何实现一些主要代数数据类型,包括函子和单子。
第八章,不可变性、光学和惰性,探讨了三个重要的函数式编程技术。你将了解什么是惰性求值,它的好处以及如何实现它。你还将了解不可变数据结构、它们的优点以及如何实现它们。最后,你将了解函数式光学以及它们如何帮助不可变数据结构。
第九章,函数式响应式编程,探讨了函数式响应式编程范式。我们将了解什么是可观察的,以及它们如何被用来简化我们的代码。我们还将学习如何使用 RxJS,这是 JavaScript 生态系统中最领先的响应式编程库。
第十章, 实际应用中的函数式编程,探讨了几个可用于生产环境的函数式编程库,如 Ramda 和 Funfix,以创建实际的函数式编程应用程序。
附录 A, 函数式编程学习路线图,这是为 Fantasyland 学习机构和 LambdaConf 会议开发的。它用于跟踪我们对函数式编程的知识水平。
附录 B, TypeScript 函数式编程库目录,在这个附录中,你可以找到一个与 TypeScript 兼容的函数式编程库列表。
为了充分利用这本书
阅读这本书不需要任何额外的材料。不需要函数式编程的先验知识。然而,建议对 JavaScript 和 TypeScript 有基本了解,以便充分利用这本书。
建议按顺序阅读章节。然而,如果你是函数式编程的新手,但已经对函数、异步编程和运行时有了高级知识,你可能可以跳过第二到五章。
如果你有一些 JavaScript 经验,但 TypeScript 对你来说是新的,你可以参考www.typescriptlang.org/docs/handbook/basic-types.html的 TypeScript 手册。如果 TypeScript 是你的第一门静态类型编程语言,这个资源可能特别有用。或者,你也可以参考由Remo H. Jansen和Packt Publishing出版的书籍Learning TypeScript 2.x, Second Edition。
如果你需要安装 Node.js 的帮助,可以参考nodejs.org/en/download/package-manager的官方文档。如果你需要安装 TypeScript 的帮助,可以参考www.typescriptlang.org/docs/handbook/typescript-in-5-minutes.html的官方文档。
下载示例代码文件
你可以从www.packt.com的账户下载这本书的示例代码文件。如果你在其他地方购买了这本书,你可以访问www.packt.com/support并注册,以便将文件直接通过电子邮件发送给你。
你可以通过以下步骤下载代码文件:
-
在www.packt.com登录或注册。
-
选择 SUPPORT 标签。
-
点击代码下载与勘误。
-
在搜索框中输入书名,并遵循屏幕上的说明。
文件下载完成后,请确保使用最新版本解压或提取文件夹:
-
WinRAR/7-Zip(适用于 Windows)
-
Zipeg/iZip/UnRarX(适用于 Mac)
-
7-Zip/PeaZip(适用于 Linux)
书籍的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-Functional-Programming-with-Typescript。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富的书籍和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
下载彩色图像
我们还提供了一个包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/9781788831437_ColorImages.pdf。
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg磁盘映像文件作为系统中的另一个磁盘挂载。”
代码块设置如下:
function find<T>(arr: T[], filter: (i: T) => boolean) {
return arr.filter(filter);
}
find(heroes, (h) => h.name === "Spiderman");
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
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.
任何命令行输入或输出都按照以下方式编写:
npm install ramda @types/ramda
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从管理面板中选择系统信息。”
警告或重要注意事项看起来像这样。
小贴士和技巧看起来像这样。
联系我们
我们欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并通过customercare@packtpub.com给我们发送邮件。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问www.packt.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
评论
请留下您的评价。一旦您阅读并使用过这本书,为何不在购买它的网站上留下评价呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,而我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解 Packt 的更多信息,请访问 packt.com.
第一章:函数式编程基础
自从 1995 年 JavaScript 诞生以来,JavaScript 一直是一种多范式编程语言。它允许我们利用面向对象编程(OOP)风格以及函数式编程风格。同样,TypeScript 也是如此。然而,对于函数式编程来说,TypeScript 比 JavaScript 更适合,因为正如我们将在本章中学习的,静态类型系统和类型推断是函数式编程语言(例如 ML 编程语言家族)中非常重要的特性。
在过去几年中,JavaScript 和 TypeScript 生态系统对函数式编程的兴趣显著增加。我相信这种兴趣的增加可以归因于 React 的成功。React 是由 Facebook 开发的一个用于构建用户界面的库,它深受一些核心函数式编程概念的影响。
在本章中,我们将专注于学习一些最基础的函数式编程概念和原则。
本章中,你将学习以下内容:
-
函数式编程的主要特征
-
函数式编程的主要好处
-
纯函数
-
副作用
-
不可变性
-
函数的阶数
-
高阶函数
-
惰性
TypeScript 是不是函数式编程语言?
这个问题的答案是肯定的,但只是部分。TypeScript 是一种多范式编程语言,因此它包含了来自面向对象语言和函数式编程范式的许多影响。
然而,如果我们把 TypeScript 视为一种函数式编程语言,我们会发现它并不是一种纯粹的函数式编程语言,因为例如,TypeScript 编译器并不强制我们的代码无副作用。
不是纯粹的函数式编程语言不应被视为负面因素。TypeScript 为我们提供了一套广泛的功能,使我们能够利用面向对象编程语言世界和函数式编程语言世界的最佳特性。这使得 TypeScript 类型系统在生产力与形式之间达到了一个非常好的平衡。
函数式编程的好处
使用函数式编程风格编写 TypeScript 代码有许多好处,其中我们可以突出以下内容:
-
我们的代码是可测试的:如果我们尝试将我们的函数编写为纯函数,我们将能够非常容易地编写单元测试。我们将在本章后面了解更多关于纯函数的内容。
-
我们的代码易于推理: 对于缺乏函数式编程经验的开发者来说,函数式编程可能难以理解。然而,当应用程序正确地使用函数式编程范式实现时,结果是非常小的函数(通常是单行函数)和非常声明性的 API,可以轻松地进行推理。此外,纯函数只与它们的参数一起工作,这意味着当我们想要了解一个函数的功能时,我们只需要检查该函数本身,而无需担心任何其他外部变量。
-
并发: 我们的大多数函数都是无状态的,我们的代码主要是无状态的。我们将状态从应用程序的核心中推出去,这使得我们的应用程序更有可能支持许多并发操作,并且将具有更好的可扩展性。我们将在本章的后面部分学习更多关于无状态代码的内容。
-
更简单的缓存: 当我们能根据函数的参数预测其输出时,缓存结果的缓存策略会变得简单得多。
介绍函数式编程
函数式编程(FP)是一种编程范式,其名称来源于我们使用它时构建应用程序的方式。在面向对象编程(OOP)这样的编程范式中,我们用来创建应用程序的主要构建块是对象(对象使用类声明)。然而,在 FP 中,我们将函数作为我们应用程序中的主要构建块。
每一种新的编程范式都会引入一系列与之相关的概念和思想。其中一些概念是通用的,在学习不同的编程范式时也很有兴趣。在面向对象编程(OOP)中,我们有诸如继承、封装和多态等概念。在函数式编程中,概念包括高阶函数、函数部分应用、不可变性和引用透明性。我们将在本章中探讨一些这些概念。
迈克尔·费思(Michael Feathers),SOLID 缩写词的作者以及许多其他著名的软件工程原则的作者,曾写下以下内容:
"面向对象编程通过封装移动部件使代码易于理解。函数式编程通过最小化移动部件使代码易于理解。"
– 迈克尔·费思
前面的引用提到了移动部件。我们应该将这些移动部件理解为状态变化(也称为状态突变)。在面向对象编程(OOP)中,我们使用封装来防止对象意识到其他对象的状态突变。在函数式编程中,我们试图避免处理状态突变,而不是封装它们。
函数式编程(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}');
}
}
现在我们已经了解了函数式编程如何通过避免状态突变来帮助我们编写更好的代码,我们可以学习副作用和引用透明度。
副作用
在前一个部分中,我们了解到纯函数返回的值可以使用仅传递给它的参数来计算。纯函数还避免修改其参数或任何其他未作为参数传递给函数的外部变量。在函数式编程术语中,通常说纯函数是没有副作用的函数,这意味着当我们调用纯函数时,我们可以期望该函数不会干扰(通过状态突变)我们应用程序中的任何其他组件。
某些编程语言,如 Haskell,可以使用它们的类型系统确保应用程序没有副作用。TypeScript 与 JavaScript 有出色的互操作性,但与像 Haskell 这样的更隔离的语言相比,其缺点是类型系统无法保证我们的应用程序没有副作用。然而,我们可以使用一些 FP 技术来提高我们的 TypeScript 应用程序的类型安全性。让我们来看一个例子:
interface User {
ageInMonths: number;
name: string;
}
function findUserAgeByName(users: User[], name: string): number {
if (users.length == 0) {
throw new Error("There are no users!");
}
const user = users.find(u => u.name === name);
if (!user) {
throw new Error("User not found!");
} else {
return user.ageInMonths;
}
}
前面的函数返回一个number。代码编译没有问题。问题是这个函数并不总是返回一个number。因此,我们可以这样使用该函数,并且代码会编译,但在运行时抛出异常:
const users = [
{ ageInMonths: 1, name: "Remo" },
{ ageInMonths: 2, name: "Leo" }
];
// The variable userAge1 is as number
const userAge1 = findUserAgeByName(users, "Remo");
console.log('Remo is ${userAge1 / 12} years old!');
// The variable userAge2 is a number but the function throws!
const userAge2 = findUserAgeByName([], "Leo"); // Error
console.log('Leo is ${userAge2 / 12} years old!');
以下示例展示了先前函数的新实现。这次,我们不再返回一个数字,而是明确地返回一个承诺。这个承诺迫使我们使用处理器。这个处理器只有在承诺得到履行时才会执行,这意味着如果函数返回错误,我们永远不会尝试将年龄转换为年:
function safeFindUserAgeByName(users: User[], name: string): Promise<number> {
if (users.length == 0) {
return Promise.reject(new Error("There are no users!"));
}
const user = users.find(u => u.name === name);
if (!user) {
return Promise.reject(new Error("User not found!"));
} else {
return Promise.resolve(user.ageInMonths);
}
}
safeFindUserAgeByName(users, "Remo")
.then(userAge1 => console.log('Remo is ${userAge1 / 12} years old!'));
safeFindUserAgeByName([], "Leo") // Error
.then(userAge1 => console.log('Leo is ${userAge1 / 12} years old!'));
Promise类型帮助我们防止错误,因为它以明确的方式表达潜在的错误。在像 Haskell 这样的编程语言中,这是类型系统的默认行为,但在像 TypeScript 这样的编程语言中,我们必须自己以更安全的方式使用类型。
我们将在第三章“掌握异步编程”中了解更多关于承诺的内容。我们还将了解如何在第八章“范畴论”中使用多个库来减少 TypeScript 应用程序中副作用的可能性。
如果你认为你的 JavaScript 应用程序没有副作用的想法很有吸引力,你可以尝试开源项目,例如github.com/bodil/eslint-config-cleanjs。该项目是一个 ESLint 配置,旨在限制你使用 JavaScript 的一个子集,使其尽可能接近理想化的纯函数式语言。不幸的是,在出版时,没有可用的类似工具是专门为 TypeScript 设计的。
引用透明性
引用透明性是与纯函数和副作用密切相关的一个概念。一个函数在它没有副作用时是纯的。当一个表达式可以被其对应值替换而不改变应用程序的行为时,它被称为引用透明的。例如,如果我们正在使用以下代码:
let result = isIndexPage("/");
我们知道isIndexPage函数是引用透明的,因为它可以安全地用其返回类型替换它。在这种情况下,我们知道当我们用/作为参数调用isIndexPage函数时,该函数总是会返回true,这意味着我们可以安全地执行以下操作:
let result = true;
纯函数是一个引用透明表达式。一个不是引用透明的表达式被称为引用不透明。
无状态与有状态
纯函数和引用透明表达式是无状态的。当代码的输出不受先前事件的影响时,该代码是无状态的。例如,isIndexPage函数的结果不会受到我们调用它的次数或调用它的具体时间的影响。
无状态代码的反面是有状态代码。无状态代码非常难以测试,当我们试图实现可扩展和具有弹性的系统时,它成为一个问题。具有弹性的系统是可以处理服务器故障的系统;通常有多个服务实例,如果一个实例崩溃,其他实例可以继续处理流量。此外,在某个实例崩溃后,会自动创建新的实例。如果我们的服务器是有状态的,这会变得非常困难,因为我们需要在崩溃前保存当前状态,并在启动新实例前恢复状态。当我们设计服务器为无状态时,整个过程会变得简单得多。
随着云计算革命的到来,这类系统变得更加普遍,这导致了对函数式编程语言和设计原则的兴趣,因为函数式编程鼓励我们编写无状态代码。对于 OOP 来说,情况则相反,因为类是 OOP 应用中的主要构造。类封装了状态属性,然后通过方法进行修改,这鼓励方法是有状态的,而不是纯的。
声明式与命令式编程
FP 范式的倡导者经常将声明式编程作为其主要好处之一。声明式编程不一定仅限于函数式编程,但 FP 确实鼓励或促进这种编程风格。在我们查看一些示例之前,我们将定义声明式编程和命令式编程:
-
命令式编程是一种使用语句改变程序状态的编程范式。与自然语言中的祈使语气表达命令的方式类似,命令式程序由计算机执行的一系列命令组成。命令式编程侧重于描述程序如何运行。
-
声明式编程是一种编程范式,它通过描述计算逻辑而不是描述其控制流来表达。许多采用这种风格的编程语言试图通过以问题域的术语描述程序必须完成的内容来最小化或消除副作用,而不是描述如何通过一系列步骤来完成它。
以下示例计算了一个包含学生 ID 和成绩列表的对象集合的考试平均结果。这个示例使用命令式编程风格,因为我们可以看到,它使用了控制流语句(for)。这个示例也很明显是命令式的,因为它改变了状态。total变量使用let关键字声明,因为它被修改了,就像results数组中的结果一样多次:
interface Result {
id: number;
result:number;
}
const results: Result[] = [
{ id: 1, result: 64 },
{ id: 2, result: 87 },
{ id: 3, result: 89 }
];
function avg(arr: Result[]) {
let total = 0;
for (var i = 0; i < arr.length; i++) {
total += arr[i].result;
}
return total / arr.length;
}
const resultsAvg = avg(results);
console.log(resultsAvg);
另一方面,以下示例是声明式的,因为没有控制流语句,也没有状态突变:
interface Result {
id: number;
result:number;
}
const results: Result[] = [
{ id: 1, result: 64 },
{ id: 2, result: 87 },
{ id: 3, result: 89 }
];
const add = (a: number, b: number) => a + b;
const division = (a: number, b: number) => a / b;
const avg = (arr: Result[]) =>
division(arr.map(a => a.result).reduce(add, 0), arr.length)
const resultsAvg = avg(results);
console.log(resultsAvg);
虽然前面的示例是声明式的,但它并不像可能的那样声明式。以下示例将声明式风格进一步推进,以便我们可以了解一段声明式代码可能的样子。如果你现在不理解这个示例中的所有内容,请不要担心。一旦我们在这本书的后面部分学习了更多关于函数式编程技术,我们就能理解它。注意,现在程序被定义为一组非常小的函数,这些函数不改变状态,也不使用控制流语句。这些函数是可重用的,因为它们独立于我们试图解决的问题。例如,avg函数可以计算平均值,但它不需要是结果的平均值:
const add = (a: number, b: number) => a + b;
const addMany = (...args: number[]) => args.reduce(add, 0);
const div = (a: number, b: number) => a / b;
const mapProp = <T>(k: keyof T, arr: T[]) => arr.map(a => a[k]);
const avg = (arr: number[]) => div(addMany(...arr), arr.length);
interface Result {
id: number;
result:number;
}
const results: Result[] = [
{ id: 1, result: 64 },
{ id: 2, result: 87 },
{ id: 3, result: 89 }
];
const resultsAvg = avg(mapProp("result", results));
console.log(resultsAvg);
我们试图解决的问题的特定代码非常小:
const resultsAvg = avg(mapProp("result", results));
这段代码不可重用,但add、addMany、div、mapProp和avg函数是可重用的。这展示了声明式编程如何比命令式编程产生更多可重用代码。
不可变性
不可变性指的是在将值赋给变量之后无法更改该变量的值。纯函数式编程语言包括常见数据结构的不可变实现。例如,当我们向数组添加一个元素时,我们正在修改原始数组。然而,如果我们使用不可变数组,并尝试向其中添加新元素,原始数组将不会被修改,我们将新项目添加到它的副本中。
以下代码片段声明了一个名为ImmutableList的类,展示了如何实现不可变数组:
class ImmutableList<T> {
private readonly _list: ReadonlyArray<T>;
private _deepCloneItem(item: T) {
return JSON.parse(JSON.stringify(item)) as T;
}
public constructor(initialValue?: Array<T>) {
this._list = initialValue || [];
}
public add(newItem: T) {
const clone = this._list.map(i => this._deepCloneItem(i));
const newList = [...clone, newItem];
const newInstance = new ImmutableList<T>(newList);
return newInstance;
}
public remove(
item: T,
areEqual: (a: T, b: T) => boolean = (a, b) => a === b
) {
const newList = this._list.filter(i => !areEqual(item, i))
.map(i => this._deepCloneItem(i));
const newInstance = new ImmutableList<T>(newList);
return newInstance;
}
public get(index: number): T | undefined {
const item = this._list[index];
return item ? this._deepCloneItem(item) : undefined;
}
public find(filter: (item: T) => boolean) {
const item = this._list.find(filter);
return item ? this._deepCloneItem(item) : undefined;
}
}
每次我们向不可变数组中添加或删除一个项目时,我们都会创建一个不可变数组的新的实例。这种实现非常低效,但它展示了基本思想。我们将创建一个快速测试来演示前面类的工作方式。我们将使用一些关于超级英雄的数据:
interface Hero {
name: string;
powers: string[];
}
const heroes = [
{
name: "Spiderman",
powers: [
"wall-crawling",
"enhanced strength",
"enhanced speed",
"spider-Sense"
]
},
{
name: "Superman",
powers: [
"flight",
"superhuman strength",
"x-ray vision",
"super-speed"
]
}
];
const hulk = {
name: "Hulk",
powers: [
"superhuman strength",
"superhuman speed",
"superhuman Stamina",
"superhuman durability"
]
};
现在我们可以使用前面的数据来创建一个新的不可变列表实例。当我们向列表中添加一个新的超级英雄时,会创建一个新的不可变列表。如果我们尝试在两个不可变列表中搜索超级英雄“浩克”,我们会观察到只有第二个列表包含它。我们还可以比较这两个列表,以观察到它们是两个不同的对象,如下所示:
const myList = new ImmutableList<Hero>(heroes);
const myList2 = myList.add(hulk);
const result1 = myList.find((h => h.name === "Hulk"));
const result2 = myList2.find((h => h.name === "Hulk"));
const areEqual = myList2 === myList;
console.log(result1); // undefined
console.log(result2); // { name: "Hulk", powers: Array(4) }
console.log(areEqual); // false
在大多数情况下,创建我们自己的不可变数据结构是不必要的。在实际应用中,我们可以使用Immutable.js等库来享受不可变数据结构。
函数作为一等公民
在 FP 文献中经常提到函数作为一等公民。当我们说一个函数是一等公民时,意味着它可以做任何变量能做的事情,这意味着函数可以作为参数传递给其他函数。例如,以下函数将其第二个参数作为函数:
function find<T>(arr: T[], filter: (i: T) => boolean) {
return arr.filter(filter);
}
find(heroes, (h) => h.name === "Spiderman");
或者,它被另一个函数返回。例如,以下函数只接受一个函数作为其唯一参数,并返回一个函数:
function find<T>(filter: (i: T) => boolean) {
return (arr: T[]) => {
return arr.filter(filter);
}
}
const findSpiderman = find((h: Hero) => h.name === "Spiderman");
const spiderman = findSpiderman(heroes);
函数也可以赋值给变量。例如,在前面的代码片段中,我们将 find 函数返回的函数赋值给名为findSpiderman的变量:
const findSpiderman = find((h: Hero) => h.name === "SPiderman");
JavaScript 和 TypeScript 都将函数视为一等公民。
Lambda 表达式
Lambda 表达式只是可以用来声明匿名函数(无名的函数)的表达式。在 ES6 规范之前,将函数作为值赋给变量的唯一方法是使用函数表达式:
const log = function(arg: any) { console.log(arg); };
ES6 规范引入了箭头函数语法:
const log = (arg: any) => console.log(arg);
请参阅第二章“掌握函数”、第四章“运行时 – 事件循环和 this 操作符”以及第五章“运行时 – 闭包和原型”,以了解更多关于箭头函数和函数表达式的信息。
函数元数
函数的元数是指函数所接受的参数数量。一元函数是指只接受单个参数的函数:
function isNull<T>(a: T|null) {
return (a === null);
}
一元函数在函数式编程中非常重要,因为它们促进了函数组合模式的应用。
我们将在第六章“函数式编程技术”中更深入地了解函数组合模式。
二元函数是指接受两个参数的函数:
function add(a: number, b: number) {
return a + b;
}
具有两个或更多参数的函数也很重要,因为一些最常用的 FP 模式和技巧(例如,部分应用和柯里化)已经被设计成将接受多个参数的函数转换为单参数函数。
还有一些具有三个(三元函数)或更多参数的函数。然而,接受可变数量参数的函数,称为可变参数函数,在函数式编程中特别有趣,如下代码片段所示:
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) {
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函数或其他函数对延迟功能的实现细节无感知。
懒惰性
许多函数式编程语言都提供了懒加载的 API。懒加载背后的想法是,只有在无法再推迟操作时才会进行计算。以下示例声明了一个函数,允许我们在数组中查找元素。当函数被调用时,我们不会过滤数组。相反,我们声明了一个proxy和一个handler:
function lazyFind<T>(arr: T[], filter: (i: T) => boolean): T {
let hero: T | null = null;
const proxy = new Proxy(
{},
{
get: (obj, prop) => {
console.log("Filtering...");
if (!hero) {
hero = arr.find(filter) || null;
}
return hero ? (hero as any)[prop] : null;
}
}
);
return proxy as any;
}
只有在访问结果中的某个属性之后,才会调用proxy handler并执行过滤操作:
const heroes = [
{
name: "Spiderman",
powers: [
"wall-crawling",
"enhanced strength",
"enhanced speed",
"spider-Sense"
]
},
{
name: "Superman",
powers: [
"flight",
"superhuman strength",
"x-ray vision",
"super-speed"
]
}
];
console.log("A");
const spiderman = lazyFind(heroes, (h) => h.name === "Spiderman");
console.log("B");
console.log(spiderman.name);
console.log("C");
/*
A
B
Filtering...
Spiderman
C
*/
如果我们检查控制台输出,我们会看到,只有在访问result对象的name属性时,Filtering...消息才会被记录到控制台。前面的实现是一个非常基础的实现,但它可以帮助我们理解懒加载是如何工作的。有时,懒惰性可以提高我们应用程序的整体性能。
我们将在第九章函数式响应式编程中了解更多关于函数组合模式的内容。
摘要
在本章中,我们探讨了函数式编程范式的某些最基本的原则和概念。
在接下来的四章中,我们将稍微偏离函数式编程,因为我们将要深入探讨函数、异步编程以及 TypeScript/JavaScript 运行时的某些方面,例如闭包和原型。在我们学习更多关于函数式编程技术实现之前,我们需要探索这些主题。然而,如果你已经非常自信地使用函数、闭包、this运算符和原型,那么你应该能够跳过接下来的四章。
第二章:掌握函数
在第一章《函数式编程基础
中,我们学习了函数式编程的一些最基本的概念。函数是任何 TypeScript 应用程序的基本构建块之一,它们足够强大,以至于需要整章内容来探讨它们的潜力。
在本章中,我们将掌握函数的使用方法。本章首先快速回顾了多个基本概念,然后转向一些不太常见的函数特性和用例:
-
函数类型:
-
函数声明和函数表达式
-
命名和匿名函数
-
-
处理参数:
-
带有可选参数的函数
-
带有默认参数的函数
-
带有剩余参数的函数
-
函数重载
-
专用重载签名
-
-
函数作用域
-
立即调用的函数
-
标签函数和标签模板
函数类型
我们已经知道,可以通过使用可选的类型注解显式地声明应用程序中元素的类型:
function greetNamed(name: string): string {
return 'Hi! ${name}';
}
在前面的函数中,我们指定了参数name(string)的类型及其返回类型(string)。有时,我们需要指定函数的类型,而不是指定其组件(参数或返回值)的类型。让我们看一个例子:
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变量的类型,并将其值设为一个函数。函数的类型可以从分配的函数中推断出来,因此没有必要添加冗余的类型注解。我们这样做是为了便于你理解这一部分,但重要的是要提到,添加冗余的类型注解可能会使我们的代码更难阅读,并且被认为是一种不良实践。
命名和匿名函数
正如 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 中,我们可以给每个参数以及函数本身添加类型,然后添加return类型。TypeScript 可以通过查看return语句来推断return类型,因此我们也可以在许多情况下省略它。
对于使用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运算符的工作方式。我们将在接下来的章节中了解更多关于这一点。
之前的代码片段展示了如何使用类型注解来强制一个变量成为一个具有特定签名的函数。这类注解在我们对callback(作为另一个函数参数使用的函数)进行注解时常用:
function add(
a: number,
b: number,
callback: (result: number) => void
) {
callback(a + b);
}
在前一个例子中,我们正在声明一个名为add的函数,它接受两个数字和一个作为函数的callback。类型注解将强制callback返回void并仅接受一个数字作为其唯一参数。
函数声明和函数表达式
在上一节中,我们介绍了声明带有(命名函数)或没有(未命名或匿名函数)显式指定其名称的函数的可能性,但我们没有提到我们也在使用两种不同类型的函数。
在下面的示例中,命名函数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 解释器可以在解析过程中评估函数声明。另一方面,function表达式是赋值语句的一部分,只有在赋值完成后才会被评估。
这些函数行为不同的主要原因是称为变量提升的过程。我们将在本章后面的函数作用域和提升部分了解更多关于变量提升过程的内容。
幸运的是,TypeScript 编译器可以检测到这个错误并在编译时抛出错误。然而,如果我们将前面的 TypeScript 代码片段编译成 JavaScript,忽略编译错误,并在网页浏览器中尝试执行它,我们会观察到第一个console.log调用是有效的。这是因为 JavaScript 了解声明函数,可以在程序执行之前解析它。
然而,第二个警告语句将抛出异常,以表明greetUnnamed不是一个函数。异常抛出是因为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
重要的是要注意,可选参数必须始终位于函数参数列表中的必需参数之后。
带有默认参数的函数
当一个函数有一些可选参数时,我们必须检查是否向函数传递了参数(就像我们在上一个示例中所做的那样),以防止潜在的错误。
有许多场景,在未提供参数时为参数提供默认值比将其作为可选参数更有用。让我们使用内联 if 结构重写前面的 add 函数(来自上一节):
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;
}
要表示一个 function 参数是可选的,我们需要在声明函数签名时使用 = 运算符提供一个默认值。在编译前面的示例后,TypeScript 编译器将在 JavaScript 输出中生成一个 if 语句,以设置 foobar 参数的默认值,如果它没有作为参数传递给 function:
function add(foo, bar, foobar) {
if (foobar === void 0) { foobar = 0; }
return foo + bar + foobar;
}
这很好,因为 TypeScript 编译器为我们生成了防止潜在运行时错误的代码。
TypeScript 编译器使用 void 0 参数来检查一个变量是否等于 undefined。虽然大多数开发者使用 undefined 变量来进行这种检查,但大多数编译器使用 void 0,因为它始终评估为 undefined。与 undefined 进行比较的安全性较低,因为其值可能已被修改,如下面的代码片段所示:
function test() {
var undefined = 2; // 2
console.log(undefined === 2); // true
}
就像可选参数一样,默认参数必须始终位于函数参数列表中的任何必需参数之后。
带有剩余参数的函数
我们已经学习了如何使用可选和默认参数来增加调用函数的方式。让我们再次回到前面的例子:
function add(foo: number, bar: number, foobar: number = 0): number {
return foo + bar + foobar;
}
我们已经学习了如何使用两个或三个参数调用 add 函数,但如果我们想允许其他开发者向我们的函数传递四个或五个参数怎么办?我们可能需要添加两个额外的 default 或 optional 参数。如果我们想允许他们传递所需的所有参数怎么办?解决这种可能场景的方法是使用 rest 参数。rest 参数语法允许我们将不定数量的参数表示为数组:
function add(...foo: number[]): number {
let result = 0;
for (let i = 0; i < foo.length; i++) {
result += foo[i];
}
return result;
}
如前述代码片段所示,我们将 function 参数 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 argument, but got 0.
add(2); // Error, '2' is not assignable to parameter of type 'number[]'.
add(2, 2); // Error, expected 1 argument, but got 2.
add(2, 2, 2); // Error, expected 1 argument, but got 3.
add([]); // returns 0
add([2]); // returns 2
add([2, 2]); // returns 4
add([2, 2, 2]); // returns 6
下表总结了本节中我们探索的与参数相关的功能:
| 名称 | 操作符 | 描述 |
|---|---|---|
| 末尾逗号 | , |
用于便于在以后的时间向现有函数添加额外的参数。 |
| 可选参数 | ? |
用于描述可选参数。当参数缺失时,参数的值是 undefined。 |
| 默认参数 | = |
用于描述可选参数。当参数缺失时,参数的值采用默认值。 |
| 可变参数 | ... |
用于描述具有未知数量参数的函数。 |
在下一节中,我们将学习关于函数重载的内容。
函数重载
函数,或方法,重载是创建具有相同名称但参数数量或类型不同的多个方法的能力。在 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!");
}
}
如前所述的示例所示,我们通过添加一个仅接受 string 作为其唯一参数的签名、另一个接受 number 的函数和一个最终接受 Boolean 作为其唯一参数的签名,三次重载了函数 test。重要的是要注意,所有函数签名都必须兼容;因此,例如,如果一个签名试图返回 number 而另一个试图返回 string,我们将得到编译错误:
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!");
}
}
请注意,这种限制可以通过使用专门的重载签名来克服,正如我们将在下一节中学习的那样。
实现签名必须与所有重载签名兼容,始终位于列表的末尾,并接受任何或联合类型作为其参数的类型。
通过提供与重载签名中声明的类型不匹配的参数来调用函数将导致编译错误:
test("Remo"); // returns "My name is Remo."
test(29); // returns "I'm 29 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函数时bar变量应该超出作用域。然而,如果我们调用foo函数,log函数将能够无错误地显示变量bar,因为函数内部的所有变量都将处于整个函数体的作用域内,即使它们位于另一个代码块内(除了函数块)。
下图显示了函数级别的词法作用域(左侧),以及块级别的词法作用域(右侧)。如图所示,只有一个函数,但有两个块:

前面的代码片段可能看起来有些令人困惑,但一旦我们知道在运行时,所有变量声明都会在函数执行之前移动到函数的顶部,这种行为就很容易理解了。这种行为被称为提升。
TypeScript 被编译成 JavaScript 然后执行——这意味着 TypeScript 应用程序在运行时是一个 JavaScript 应用程序,因此,当我们提到 TypeScript 运行时,我们实际上是在谈论 JavaScript 运行时。我们将在第四章,运行时——事件循环和 this 操作符和第五章,运行时——闭包和原型中深入探讨运行时。
在执行前面的代码片段之前,运行时会将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关键字声明的变量不能重新赋值,但不是不可变的。当我们说一个变量是不可变的,我们的意思是它不能被修改。我们将在第九章,函数式响应式编程中了解更多关于不可变性的内容。
立即调用的函数
立即调用的函数表达式(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属性,我们会得到一个错误,因为该变量是私有的。
如果我们编译前面的 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 组件使用,我们很少需要担心实现运行时私有属性。我们将在第四章运行时 – 事件循环和 this 操作符和第五章运行时 – 闭包和原型中深入探讨 TypeScript 的运行时。
标签函数和带标签的模板
在 TypeScript 中,我们可以使用如下模板字符串:
let name = "remo";
let surname = "jansen";
let html = '<h1>${name} ${surname}</h1>';
我们可以使用模板字符串创建一种特殊类型的函数,称为标签函数。
我们可以使用tag函数来扩展或修改模板字符串的标准行为。当我们将tag函数应用于模板字符串时,模板字符串就变成了一个带标签的模板。
我们将实现一个名为htmlEscape的tag函数。要使用tag函数,我们必须使用该函数的名称,后跟一个模板字符串:
let html = htmlEscape '<h1>${name} ${surname}</h1>';
一个tag模板必须返回一个字符串并接受以下参数:
- 一个
TemplateStringsArray,它包含模板字符串中的所有静态字面量(如前例中的<h1>和</h1>),作为第一个参数传递。
TemplateStringsArray类型由lib.d.ts文件声明。lib.d.ts文件包含原生 JavaScript 和浏览器 API 的类型声明。
- 一个
rest参数作为第二个参数传递。rest参数包含模板字符串中的所有值(前例中的姓名和姓氏)。
tag函数的签名如下所示:
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 和 JavaScript 异步编程模型中扮演着非常基础的角色。
第三章:掌握异步编程
在上一章中,我们学习了如何处理函数。在本章中,我们将探讨如何使用函数以及一些原生 API 来编写异步 TypeScript 代码。我们将重点关注 TypeScript 的异步编程能力,包括以下概念:
-
回调函数和高级函数
-
箭头函数
-
回调地狱
-
Promises
-
生成器
-
异步函数(
async和await)
回调函数和高级函数
在 TypeScript 中,函数可以作为参数传递给另一个函数。函数也可以由另一个函数返回。将函数作为参数传递的函数称为 回调。接受函数作为参数(回调)或返回函数的函数称为 高级函数。
回调通常是匿名函数。它们可以在传递给高级函数之前声明,如下面的例子所示:
var myCallback = function() { // callback
console.log("foo");
}
function bar(cb: () => void) { // higher order function
console.log("bar");
cb();
}
bar(myCallback); // prints "bar" then prints "foo"
回调也可以在行内声明,即在将它们传递给高级函数的同一位置,如下面的例子所示:
bar(() => {
console.log("foo");
}); // prints "bar" then prints "foo"
之前的代码片段声明了一个匿名函数并将其传递给名为 bar 的函数。匿名函数是使用一种称为箭头函数的替代语法声明的。我们将在下一节中了解更多关于箭头函数的内容。
箭头函数
在 TypeScript 中,我们可以使用函数表达式或箭头函数声明一个函数。箭头函数表达式比函数表达式语法更短,并且按词法绑定 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"
我们定义了一个名为 Person 的类,该类包含一个名为 name 的 string 类型属性。该类有一个构造函数和一个名为 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);
}
}
let person = new Person("Remo");
person.greetDelay(1000); // Error
在 greetDelay 方法中,我们执行的操作几乎与 greet 方法执行的操作相同。这次,函数接受一个名为 time 的参数,该参数用于延迟 greet 消息。
要延迟消息,我们使用 setTimeout 函数和回调。一旦我们定义了一个匿名函数(回调),this 关键字就会改变其值并开始指向匿名函数,这也解释了为什么 TypeScript 编译器会抛出错误。
如前所述,箭头函数表达式词法绑定 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 操作符》 中深入探讨 this 操作符,运行时 – 事件循环和 this 操作符。
回调地狱
我们已经了解到回调函数和高级函数是 JavaScript 和 TypeScript 中的两个强大且灵活的特性。然而,回调函数的使用可能会导致一个称为回调地狱的可维护性问题。
现在我们将编写一个示例来展示回调地狱。我们将编写三个具有相同行为的函数。
第一个函数被命名为 doSomethingAsync。该函数接受一个数字数组作为其参数之一,并向其中添加一个新数字。该函数使用 setTimeout 来模拟一些 I/O 操作,例如从数据库读取,以及使用 Math.random 来模拟潜在的异常,例如请求超时:
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);
}
第二个函数被命名为 doSomethingElseAsync,第三个也是最后一个函数被命名为 doSomethingMoreAsync。我们将在以下代码片段中跳过这两个函数的实现,因为这两个函数与我们在 doSomethingAsync 函数中使用的实现完全相同:
function doSomethingElseAsync(
arr: number[],
success: (arr: number[]) => void,
error: (e: Error) => void
) {
// ... Same implementation here...
}
function doSomethingMoreAsync(
arr: number[],
success: (arr: number[]) => void,
error: (e: Error) => void
) {
// Same imlementation here...
}
前面的函数通过使用 setTimeout 函数来模拟异步操作。每个函数都接受一个 success 回调,如果操作成功则调用该回调,以及一个 error 回调,如果发生错误则调用该回调。
在实际应用中,异步操作通常涉及与硬件的交互(例如,文件系统、网络等)。这些交互被称为 输入/输出 (I/O)操作。I/O 操作可能因许多不同的原因而失败;例如,当我们尝试与文件系统交互以保存新文件时,硬盘上没有足够的空间,我们会得到一个错误。
前面的函数生成一个随机数,如果该数字小于 25,则抛出错误;我们这样做是为了模拟潜在的 I/O 错误。然后,它们将随机数添加到作为每个函数参数传递的数组中。如果没有发生错误,最终函数(doSomethingMoreAsync)的结果应该是一个包含三个随机数的数组。
现在已经声明了三个函数,我们可以尝试按顺序调用它们。我们将使用回调函数来确保在doSomethingElseAsync之后调用doSomethingMoreAsync,并且在doSomethingAsync之后调用doSomethingElseAsync:
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));
上述示例使用了几个嵌套的回调函数。这类嵌套回调函数被称为回调地狱,因为它们可能导致一些可维护性问题,如下所示:
-
它们使代码更难以跟踪和理解
-
它们使代码更难以维护(重构、重用等)
-
它们使异常处理更困难
承诺
在看到回调的使用可能导致一些可维护性问题之后,我们现在将学习关于承诺以及如何使用它们来编写更好的异步代码。承诺背后的核心思想是,承诺表示异步操作的结果。承诺必须处于以下三种状态之一:
-
挂起:承诺的初始状态。
-
实现:也称为已解决,这是表示成功操作的承诺的状态。术语实现和已解决都常用来指代此状态。
-
已拒绝:表示操作失败的承诺的状态。
一旦承诺被实现或拒绝,其状态将无法再改变。让我们看看承诺的基本语法:
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 编译器将不会识别承诺,因为Promise API 是 ES6 的一部分。我们可以通过在tsconfig.json文件中使用lib选项启用es2015.promise类型来解决此问题。请注意,启用此选项将禁用默认包含的一些类型,从而破坏一些示例。您可以通过在tsconfig.json文件中使用lib选项包括dom和es5类型来解决这些问题:
"lib": [
"es2015.promise",
"dom",
"es5",
"es2015.generator", // new
"es2015.iterable" // new
]
现在,我们将重写我们在回调地狱部分编写的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);
});
}
再次,我们将跳过doSomethingElseAsync和doSomethingMoreAsync函数的实现细节,因为它们应该与doSomethingAsync函数的实现相同:
function doSomethingElseAsync(arr: number[]) {
// Same implementation here...
}
function doSomethingMoreAsync(arr: number[]) {
// Same implementation here...
}
我们可以使用 Promise 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));
上述代码片段比回调示例中使用的代码略好,因为我们只需要声明一个而不是三个异常处理程序。这是可能的,因为错误是通过承诺链传播的。然而,我们可以进一步改进代码,因为 Promise API 允许我们以更简洁的方式链式连接承诺:
doSomethingAsync([])
.then(doSomethingElseAsync)
.then(doSomethingMoreAsync)
.then((arr3) => {
console.log(
'
doSomethingAsync: ${arr3[0]}
doSomethingElseAsync: ${arr3[1]}
doSomethingMoreAsync: ${arr3[2]}
'
);
}).catch((e) => console.log(e));
之前的代码比回调示例中使用的代码更容易阅读和跟踪,但这并不是唯一青睐承诺而不是回调的原因。使用承诺也让我们能够更好地控制操作的执行流程。让我们看看几个例子。
Promise 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]
});
Promise 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示例所示) -
竞速: 任务并行执行,只返回最快承诺的结果
-
串联: 一组任务按顺序执行,但前面的任务不会将参数传递给下一个任务
-
瀑布: 一组任务按顺序执行,每个任务将参数传递给下一个任务(如示例所示)
-
复合: 这是指之前并发、串联和瀑布方法的任何组合
回调参数中的协变检查
TypeScript 2.4 改变了类型系统内部的行为方式,以改善嵌套回调和承诺的错误检测:
-
TypeScript 对回调参数的检查现在与即时签名检查是协变的。之前它是双变的,偶尔会允许错误类型通过。
-
基本上,这意味着回调参数和包含回调的类将受到更仔细的检查,因此 TypeScript 在这个版本中将需要更严格的类型。这尤其适用于承诺和观察者,因为它们的 API 指定方式。
在 TypeScript 2.4 版本之前的 TypeScript 中,以下示例被认为是有效的,并且没有抛出错误:
declare function someFunc(
callback: (
nestedCallback: (error: number, result: any) => void
) => void
): void;
someFunc(
(
nestedCallback: (e: number) => void // Error
) => {
nestedCallback(1);
}
);
在 TypeScript 2.4 版本之后的 TypeScript 中,我们将需要添加 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 类实例时忘记添加泛型参数 <number>。字符串错误随后会被视为 {} 的有效实例。
这前面的例子清楚地说明了为什么建议您定期升级 TypeScript。TypeScript 的每个新版本都引入了新的功能,能够为我们检测到新的错误。
生成器
如果我们在 TypeScript 中调用一个函数,我们可以假设一旦函数开始运行,它将始终运行到完成,然后任何其他代码才能运行。然而,一种称为生成器的函数类型可以在执行过程中暂停——一次或多次——并在稍后恢复,允许在这些暂停期间运行其他代码。
生成器代表一系列值。生成器对象的接口只是一个迭代器。迭代器实现了以下接口:
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,生成器需要一些额外的类型。您需要在tsconfig.json文件中添加es2015.generator和es2015.iterable,并启用downlevelIteration:
"lib": [
"es2015.promise",
"dom",
"es5",
"es2015.generator", // new
"es2015.iterable" // new
]
正如我们所见,前面的迭代器有五个步骤。第一次我们调用next方法时,函数将执行,直到它达到第一个yield语句,然后它将返回值1并停止函数的执行,直到我们再次调用生成器的next方法。正如我们所见,我们现在能够在一个给定的点上停止函数的执行。这允许我们编写无限循环而不会导致堆栈溢出异常,如下面的示例所示:
function* foo() {
let i = 1;
while (true) { // Infinite loop!
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}
生成器的 API 通过同步性打开了可能性,因为异步事件发生后,我们可以调用生成器的next方法。
异步函数 – async 和 await
异步函数是 TypeScript 1.6 版本中引入的功能。开发者可以使用await关键字等待异步操作完成,而不会阻塞程序的正常执行。
使用异步函数可以帮助提高代码的可读性,与使用承诺或回调相比,但技术上,我们可以使用两者都实现相同的功能。
让我们看看一个基本的async/await示例:
let p = Promise.resolve(3);
async function fn(): Promise<number> {
var i = await p; // 3
return 1 + i; // 4
}
fn().then((r) => console.log(r)); // 4
上述代码片段声明了一个名为p的承诺。这个承诺代表一个未来的结果。正如我们所见,fn函数前面有async关键字,它用于指示编译器这是一个异步函数。
在函数内部,await关键字用于暂停执行,直到承诺p得到解决或拒绝。正如我们所见,语法比使用Promise API 或回调要简洁得多。
一个异步函数,如 fn,在运行时会返回一个承诺。这应该解释了为什么我们需要在代码片段的末尾使用 then 方法的原因。
以下代码片段展示了如何声明一个名为 invokeTaskAsync 的异步函数。这个异步函数使用 await 关键字等待我们在承诺示例中声明的 doSomethingAsync、doSomethingElseAsync 和 doSomethingMoreAsync 函数的结果:
async function invokeTaskAsync() {
let arr1 = await doSomethingAsync([]);
let arr2 = await doSomethingElseAsync(arr1);
return await doSomethingMoreAsync(arr2);
}
invokeTaskAsync 函数是异步的。因此,它在运行时会返回一个承诺。这意味着我们可以使用 Promise 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);
}
})();
使用 async IIFE 非常有用,因为通常无法在函数外部使用 await 关键字,例如,在应用程序的入口点。我们可以使用 async IIFE 来克服这种限制:
(async () => {
await someAsyncFunction();
})();
异步生成器
我们已经学习了所有迭代器实现的接口:
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 方法时,异步迭代器都会返回一个承诺。以下代码片段展示了异步迭代器如何与异步函数结合使用,从而变得非常有用:
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: AsyncIterableIterator<number> = 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 并启用 downlevelIteration。我们还需要在 tsconfig.json 中启用一个额外的设置,以在针对 ES5 或 ES3 时提供对 for-of、展开和结构赋值的完整支持:
"lib": [
"es2015.promise",
"dom",
"es5",
"es2015.generator",
"es2015.iterable",
"esnext.asynciterable" // new
]
异步迭代(for await…of)
我们可以使用新的 await…of 表达式来迭代并等待异步迭代器返回的每个承诺:
function* g1() {
yield 2;
yield 3;
yield 4;
}
async function func() {
for await (const x of g1()) {
console.log(x);
}
}
(async () => {
await func();
})();
将迭代委托给另一个生成器(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}
请注意,前面的示例需要在 tsconfig.json 文件中设置一系列特定的设置。请参考本章前面的说明,了解更多关于所需设置的信息。
概述
在本章中,我们专注于使用回调、承诺和生成器来利用 TypeScript 的异步编程能力。在下一章中,我们将探讨运行时,了解事件循环和 this 操作符的工作原理。这些概念将帮助我们理解本书后面将要探索的一些函数式编程技术的实现。
第四章:运行时 —— 事件循环和 this 操作符
在接下来的两章中,我们将学习一些与 TypeScript 运行时密切相关的概念。TypeScript 仅在设计时使用;TypeScript 代码随后被编译成 JavaScript,并在运行时执行。JavaScript 运行时负责 JavaScript 代码的执行。我们必须理解,我们永远不会执行 TypeScript 代码,我们总是执行 JavaScript 代码;因此,当我们提到 TypeScript 运行时,我们实际上是在谈论 JavaScript 运行时。
理解运行时至关重要,因为它将帮助我们理解本书后面将要探索的许多函数式编程技术的实现。
在本章中,我们将涵盖以下主题:
-
环境
-
事件循环
-
this 操作符
让我们从了解环境开始。
环境
在我们可以开始开发 TypeScript 应用程序之前,我们必须首先考虑运行时环境。一旦我们将 TypeScript 代码编译成 JavaScript,它就可以在许多不同的环境中执行。虽然这些环境中的大多数将是 Web 浏览器的一部分,如 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 仅在 Web 浏览器中可用。如果我们想在 Web 浏览器中运行我们的应用程序,我们将能够访问 DOM 和 BOM。然而,在 Node.js 或 RingoJS 等环境中,这些 API 将不可用,因为它们是完全独立于 Web 浏览器的独立 JavaScript 环境。我们还可以在服务器端环境中找到其他对象(例如 Node.js 中的process.stdin),如果我们尝试在 Web 浏览器中执行我们的代码,这些对象将不可用。
我们还需要考虑到这些 JavaScript 环境存在多个版本。在某些情况下,我们必须支持多个浏览器和 Node.js 的各种版本。处理此问题时推荐的做法是使用条件语句来检查功能的可用性:
if (Promise && typeof Promise.all === "function") {
// User Promise.all here...
}
这是在检查环境或版本可用性之前执行的:
if (
navigator.userAgent.toLowerCase().indexOf('chrome') > -1 &&
navigator.vendor.toLowerCase().indexOf("google") > -1
) {
// Use Promise.all here...
}
有一个出色的库可以帮助我们在为浏览器开发时实现功能检测。这个库叫做Modernizr,可以在modernizr.com/下载。
理解事件循环
TypeScript 运行时(JavaScript)基于事件循环的并发模型。这个模型与其他语言(如 C 或 Java)中的模型相当不同。在专注于事件循环本身之前,我们必须首先理解一系列运行时概念。
以下是一些关键运行时概念的视觉表示:堆(HEAP),栈(STACK),队列(QUEUE)和帧(FRAME):

我们现在将探讨这些运行时概念各自的作用。
帧结构
帧是工作的一个顺序单元。在上面的图中,帧由栈内的块表示。
当在 JavaScript 中调用函数时,运行时会创建一个帧在栈中。该帧包含该函数的参数和局部变量。当函数返回时,该帧将从栈中移除。让我们看一个例子:
function foo(a: number): number {
const localFooValue = 12;
return localFooValue + a;
}
function bar(b: number): number {
const localBarValue = 4;
return foo(localBarValue * b);
}
在声明了foo和bar函数之后,我们调用bar函数:
bar(21);
当bar函数执行时,运行时将创建一个新的帧,包含bar的参数以及所有局部变量(b和localBarValue)。然后,该帧(在上面的图中表示为黑色方块)被添加到栈的顶部。
在内部,bar函数调用了foo函数。当foo被调用时,会创建一个新的帧并将其分配到栈的顶部。当foo的执行完成(foo已返回)时,顶部帧将从栈中移除。当bar的执行也完成时,它也将从栈中移除。
现在,让我们想象一下如果foo函数调用了bar函数会发生什么:
function foo(a: number): number {
const localFooValue = 12;
return bar(localFooValue + a);
}
function bar(b: number): number {
const localBarValue = 4;
return foo(localBarValue * b);
}
上述代码片段创建了一个永无止境的函数调用循环。每次函数调用都会向栈中添加一个新的帧,最终栈中将没有更多空间,并会抛出错误。大多数软件工程师都熟悉这种错误,称为栈溢出(stack overflow)错误。
栈
栈包含顺序步骤(帧)。栈是一种表示简单后进先出(Last-in-first-out,LIFO)对象集合的数据结构。因此,当帧被添加到栈中时,它总是被添加到栈的顶部。
由于堆栈是一个后进先出(LIFO)集合,事件循环从顶部到底部处理其中存储的帧。一个帧的依赖项被添加到堆栈的顶部,以确保每个帧的所有依赖项都得到满足。
队列
队列包含一个等待处理的列表。每个都与一个函数相关联。当堆栈为空时,从队列中取出一条消息进行处理。处理包括调用相关函数并将帧添加到堆栈中。当堆栈再次为空时,消息处理结束。
在之前的运行时图中,队列内的块代表消息。消息通常由用户或应用程序事件生成。例如,当用户在一个具有事件处理器的元素上点击时,会向队列中添加一条新消息。
堆
堆是一个不知道存储在其中的项目顺序的内存容器。堆包含当前正在使用的所有变量和对象。它还可能包含当前不在作用域内但尚未被垃圾回收器从内存中移除的帧。
事件循环
并发是同时执行两个或更多操作的能力。JavaScript 运行时在单个线程上执行,这意味着我们无法实现真正的并发。
事件循环遵循运行至完成(run-to-completion)的方法,这意味着它将在处理完任何其他消息之前,从开始到结束处理一条消息。
正如我们在第三章“掌握异步编程”中看到的,我们可以使用yield关键字和生成器来暂停函数的执行。
每次调用函数时,都会向队列中添加一条新消息。如果堆栈为空,则处理该函数(将帧添加到堆栈中)。
当所有帧都添加到堆栈中后,从顶部到底部清除堆栈。在处理过程结束时,堆栈为空,然后处理下一条消息。
Web 工作者可以在不同的线程中执行后台任务。它们有自己的队列、堆栈和堆。
事件循环的一个优点是执行顺序相当可预测且易于跟踪。这种方法的缺点是,如果一条消息处理时间过长,应用程序将变得无响应。遵循的一个好习惯是使消息处理尽可能短,如果可能的话,将一条消息拆分成几条消息。
Node.js 运行时结合了非阻塞 I/O 模型和单线程事件循环模型,这意味着当应用程序等待 I/O 操作完成时,它仍然可以处理其他事情,例如用户输入。
this操作符
在 JavaScript 中,this 操作符的行为与其他语言略有不同。this 操作符的值通常由函数的调用方式决定。它的值在执行期间不能通过赋值来设置,并且每次函数调用时可能不同。
当使用 strict 和 non-strict 模式时,this 操作符也有一些不同之处。ECMAScript 5 的严格模式是一种选择进入 JavaScript 限制变体的方式。您可以在 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 实现。如果启用了 strict 编译标志,TypeScript 中的前述代码将失败,因为 strict 标志启用了 noImplicitThis 标志,这阻止我们在值不明确的范围内使用 this 操作符,例如全局范围。
函数上下文中的 this 操作符
函数内部 this 的值取决于函数的调用方式。如果我们以非严格模式调用一个函数,函数内部的 this 值将指向全局对象:
function f1() {
return this;
}
f1() === window; // true
之前的示例应该使用 JavaScript 实现。如果启用了 strict 编译标志,TypeScript 中的前述代码将失败,因为它也启用了 noImplicitThis 标志。
然而,如果我们以严格模式调用一个函数,函数体内的 this 值将是 undefined:
console.log(this); // global (window)
function f2() {
"use strict";
return this; // undefined
}
console.log(f2()); // undefined
console.log(this); // window
之前的示例应该使用 JavaScript 实现。
然而,在作为实例方法调用的函数内部,this 操作符的值指向实例。换句话说,在类(一个方法)中的函数内部的 this 操作符的值指向类实例:
const person = {
age: 37,
getAge: function() {
return this.age; // this points to the instance (person)
}
};
console.log(person.getAge()); // 37
之前的示例应该使用 JavaScript 实现。
在前述示例中,我们使用了对象字面量表示法来定义一个名为 person 的对象,但使用类声明对象时也适用:
class Person {
public age: number;
public constructor(age: number) {
this.age = age;
}
public getAge() {
return this.age; // this points to the instance (person)
}
}
const person = new Person(37);
console.log(person.getAge()); // 37
之前的示例应该使用 TypeScript 实现。
在运行时,类(使用所谓的原型)作为原型链实现。如果您对原型了解不多,请不要担心,因为我们在下一章中会了解更多关于它们的内容。现在我们只需要知道,前述章节中描述的行为是在与原型一起工作时发生的:
function Person(age) {
this.age = age;
}
Person.prototype.getAge = function () {
return this.age; // this points to the instance (person)
};
var person = new Person(37);
console.log(person.getAge()); // 37
之前的示例应该使用 JavaScript 实现。
当一个函数用作构造函数(使用 new 关键字)时,this 操作符指向正在构造的对象:
function Person() { // function used as a constructor
this.age = 37;
}
const person = new Person();
console.log(person.age); // logs 37
之前的示例应该使用 JavaScript 实现。
call、apply 和 bind 方法
所有函数都从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.
除非你非常熟悉你在做什么,否则不建议使用apply、call和bind方法,因为它们可能会导致其他开发者遇到复杂且难以调试的运行时问题。
一旦我们使用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.
在 JavaScript 中,不建议使用bind、apply和call方法,因为这可能会导致混淆。修改this操作符的默认行为可能会导致意外结果。请记住,只有在绝对必要时才使用这些方法,并正确记录代码,以减少由潜在的可维护性问题引起的风险。然而,TypeScript 3.2.0 引入了一个新的编译标志,称为strictBindCallApply,这使得bind、apply和call方法更安全。
摘要
在本章中,我们学习了 TypeScript 和 JavaScript 运行时的基本方面。我们了解到,潜在的区别在于网络浏览器和 Node.js 等平台的执行环境。我们还学习了函数是如何通过event循环进行处理和执行的,以及this操作符的值在不同上下文中如何变化。
在下一章中,我们将学习更多关于运行时的知识,我们将探讨闭包和原型。然后我们将完全准备好深入研究各种函数式编程技术的实现。
第五章:运行时 —— 闭包和原型
在上一章中,我们学习了 TypeScript/JavaScript 运行时的某些方面,这将帮助我们理解在后续章节中实现某些函数式编程技术的方法。在本章中,我们将探索 TypeScript/JavaScript 运行的另外两个方面:
-
原型
-
闭包
探索了这两个概念之后,我们最终将准备好深入探讨主要函数式编程技术的实现和应用。
原型
当我们编译 TypeScript 程序时,所有类和对象都成为 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 是一种动态编程语言,我们可以在运行时向对象实例添加属性和方法;它们不需要是对象(类)本身的一部分:
const name = "Remo";
const surname = "Jansen";
function Person(name, surname) {
// instance properties
this.name = name;
this.surname = surname;
}
const person1 = new Person(name, surname);
person1.age = 27;
我们为名为Person的对象定义了一个构造函数,它接受两个变量(name和surname)作为参数。然后我们创建了一个Person对象的实例,并给它添加了一个名为age的新属性。我们可以使用for…in语句在运行时检查person1的属性:
for(let property in person1) {
console.log("property: " + property + ", value: '" +
person1[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);
}'
如我们所见,age已被添加为一个属性。所有这些属性都是实例属性,因为它们为每个新实例持有值。例如,如果我们创建Person的新实例,这两个实例都将持有它们自己的值:
let person2 = new Person("John", "Wick");
person2.name; // "John"
person1.name; // "Remo"
我们使用this运算符定义了这些实例属性,因为当一个函数用作构造函数(使用new关键字)时,this运算符绑定到所构造的对象实例。前面的内容也解释了为什么我们可以通过对象的原型来定义实例属性:
Person.prototype.name = name; // instance property
Person.prototype.surname = 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;
}
请注意,前面的代码片段在 JavaScript 中是有效的,但在 TypeScript 中会抛出编译错误。
在前面的示例中,我们从类方法(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
我们不应该从实例方法中访问类方法或属性,但有一种方法可以做到。我们可以通过使用原型的constructor属性来实现,如下面的示例所示:
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
我们可以通过原型对象的constructor属性从areaOfCircle实例方法访问PI类属性,因为这个属性返回一个指向对象构造函数的引用。
在areaOfCircle内部,this运算符返回对对象原型的引用:
this === MathHelper.prototype; // true
我们可以推断出this.constructor等于MathHelper.prototype.constructor,因此MathHelper.prototype.constructor等于MathHelper。
在 TypeScript 中,我们可以使用static关键字定义类属性:
class MathHelper {
// class property
public static PI = 3.14159265359;
// class method
public static areaOfCircle(radius: number) {
return radius * radius * MathHelper.PI;
}
}
原型继承
你可能想知道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)。
我们需要将之前的代码片段编译成 JavaScript 代码,以便我们可以检查继承在运行时是如何实现的。编译器将生成一个名为__extends的 polyfill 函数,该函数旨在作为与旧版 JavaScript 兼容的extends关键字的替代品:
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 的最新版本中稍微复杂一些。在这里我们将使用之前版本的代码,因为它包含的条件较少,更容易理解。
这段代码是由 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类的实例:
const derived = new Derived();
new运算符创建了一个从Base类继承的对象实例。
如果我们尝试访问名为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 语言规范中,并将在未来得到支持,但它仍然是一个应该避免的慢操作,如果性能是一个考虑因素的话。
闭包
闭包是 JavaScript 和 TypeScript 中最强大的特性之一,但它们也是最容易误解的特性之一。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 示例。
我们已经声明了一个名为makeArmy的函数。在函数内部,我们创建了一个名为shooters的函数数组。shooters数组中的每个函数都将显示一个数字,其值是从for语句内部的变量i设置的。现在我们将调用makeArmy函数:
const army = makeArmy();
army变量现在应该包含函数的shooters数组。然而,如果我们执行以下代码,我们会注意到一个问题:
army[0](); // 10 (expected 0)
army[5](); // 10 (expected 5)
前面的代码片段没有按预期工作,因为我们犯了与闭包相关的一个最常见的错误。当我们把shooter函数声明在makeArmy函数内部时,我们无意中创建了一个闭包。
这是因为分配给 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 示例。
这正如预期的那样工作。不是 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 示例。
前面的类包含一个名为 _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;
})();
请注意,前面的代码片段是 TypeScript 编译器生成的编译输出。
如我们所观察到的,TypeScript 编译器将静态变量声明为类属性(而不是实例属性)。编译器使用类属性,因为类属性在类的所有实例之间共享。问题是私有变量在运行时并不是私有的。
或者,我们可以编写一些 JavaScript(记住,所有有效的 JavaScript 都是有效的 TypeScript)代码来使用闭包模拟静态属性:
var Counter = (function() {
// closure context
let _COUNTER = 0;
function changeBy(val: number) {
_COUNTER += val;
}
interface Counter {
increment: () => void;
decrement: () => void;
value: () => number;
}
interface CounterConstructor {
new(): Counter;
}
function Counter() {};
// closure functions
Counter.prototype.increment = function() {
changeBy(1);
};
Counter.prototype.decrement = function() {
changeBy(-1);
};
Counter.prototype.value = function() {
return _COUNTER;
};
return (Counter as unknown) as CounterConstructor;
})();
请注意,前面的示例是一个 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)
如我们所见,前面的例子并没有按预期工作,因为 Counter 的两个实例共享内部计数器。我们将在下一节中学习如何解决这个问题。
由闭包支持的私有成员
在上一节中,我们了解到闭包可以访问超出它们创建的词法作用域的持久变量。这些变量不是函数的原型或主体的部分,但它们是函数上下文的一部分。
由于我们无法直接调用函数的上下文,上下文变量和方法可以用来模拟私有成员。使用闭包来模拟私有成员(而不是 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;
}
}
return new Counter();
}
请注意,前面的例子是一个 TypeScript 示例。
上述类几乎与我们之前声明的类相同,目的是为了演示如何使用闭包在运行时模拟静态变量。
这次,每次我们调用 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); // Error
console.log(counter1.changeBy(2)); // Error
摘要
在本章中,我们更好地理解了运行时,这使我们不仅能够轻松解决运行时问题,而且还能编写更好的 TypeScript 代码。对闭包和原型的深入理解将使我们能够理解在后续章节中某些函数式编程技术的实现。
在下一章中,我们将学习如何实现许多基本的函数式编程技术。
第六章:函数式编程技术
在详细学习了如何处理函数、掌握了异步编程以及了解了 JavaScript 运行时的主要特性之后,我们现在完全准备好专注于函数式编程。在本章中,我们将关注最基础的函数式编程技术和模式。
我们将尽量避免使用外部库,并从头实现一些这些技术和模式。这将稍微有些繁琐,但将帮助我们完全理解这些技术是如何在内部工作的。请注意,以下实现已经简化,并不涵盖所有潜在的边缘情况。在实际的生产系统中,建议使用经过良好测试的函数式编程库。
在本章中,我们将学习以下函数式编程技术和模式:
-
组合
-
部分应用
-
柯里化
-
管道
-
无参数风格
-
递归
-
模式匹配
组合技术
在本节中,我们将学习一些与函数组合非常紧密相关的函数式编程技术。我们将学习组合、部分应用、柯里化和管道。
组成
函数组合是一种技术或模式,它允许我们将多个函数组合成一个更复杂的函数。
以下代码片段声明了两个简单函数:
const trim = (s: string) => s.trim();
const capitalize = (s: string) => s.toUpperCase();
前面的代码片段中声明的两个简单函数如下:
-
用于修剪字符串的函数
-
用于将文本转换为上档的函数
我们可以通过以下方式组合前两个操作来创建一个执行这两个操作的函数:
const trimAndCapitalize = (s: string) => capitalize(trim(s));
trimAndCapitalize是一个函数,它调用trim函数(使用s作为其参数)并将返回值传递给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只能接受一个参数(它必须是一元函数)。f的唯一参数的类型必须与g函数的返回类型相匹配。这些限制可以用compose函数的更正确的定义来表示:
const compose = <T1, T2, T3>( f: (x: T2) => T3, g: (x: T1) => T2) => (x: T1) => f(g(x));
我们还可以使用compose函数生成的函数进行组合:
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 composed1 = composeMany(func1, func2, func3);
我们还可以创建一个辅助函数,允许我们组合无限数量的函数:
const composeMany = <T>(...functions: Array<(arg: T) => T>) =>
(arg: any) =>
functions.reduce((prev, curr) => {
return curr(prev);
}, arg);
我们可以按如下方式调用它:
const composed1 = composeMany(func1, func2, func3, func4);
const composed2 = composeMany(func1, func2, func3, func4, func5);
函数组合是一个非常强大的技术,但在某些场景中可能很难实施,例如,当我们的函数不是一元函数时。然而,有其他技术,如函数式部分应用,可以帮助在这些场景中,正如我们将在下一节中看到的那样。
部分应用
部分应用是一种函数式编程技术,它允许我们在不同时间点传递函数所需的参数。
这种技术在第一眼看起来可能像是一个奇怪的想法,因为大多数软件工程师习惯于在唯一的时间点应用或调用一个函数(完整应用),而不是在多个时间点应用一个函数(部分应用)。
以下代码片段实现了一个不支持部分应用的函数,并在一个时间点调用它(提供所有所需的参数):
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);
前面的函数可以用作替换给定字符串中的子串。不幸的是,由于它不是一元函数,因此该函数不能轻易与compose函数一起使用:
const trimCapitalizeAndReplace = compose(trimAndCapitalize, replace); // Error
然而,我们可以以允许我们在不同时间点应用函数参数的方式实现该函数:
const replace = (f: string, r: string) => (s: string) => s.split(f).join(r);
然后,我们可以无困难地使用compose函数:
const trimCapitalizeAndReplace = compose(trimAndCapitalize, replace("/", "-"));
trimCapitalizeAndReplace(" 13/feb/1989 "); // "13-FEB-1989"
由于我们掌握了函数式部分应用的知识,我们可以轻松地使用 compose 函数,而无需担心函数的 arity(元数)。然而,启用部分应用需要大量的手动样板代码。在下一节中,我们将学习一种名为 柯里化 的函数式编程技术,它可以帮助我们解决这个问题。
柯里化
柯里化是一种函数式编程技术,它允许我们在不担心函数实现方式的情况下部分应用一个函数。柯里化是将接受多个参数的函数转换为一系列一元函数的过程。以下函数允许我们将接受两个参数 a 和 b 的函数 fn 转换为接受一个参数 a 的函数,并返回另一个接受一个参数 b 的函数:
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 函数是一个高阶函数,可以与任何二元函数一起使用。例如,在前面的代码片段中,我们将 add 函数传递给 curry2 函数,但以下示例将 multiply 函数传递给 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
在前面关于函数式部分应用的章节中,我们学习了如何使用部分应用来与不是一元的函数一起使用 compose。我们声明了一个名为 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("/")("-")
);
请注意,整个示例包含在配套源代码中。
strictBindCallApply
在本章前面,我们探索了几种实现部分应用的方法。然而,我们避免了一种使用 Function.prototype.bind 方法的替代实现。我们这样做是因为在 TypeScript 3.2 版本之前的 TypeScript 中,bind 方法是不安全的。如果我们安装 TypeScript 3.2 或更高版本,并在 tsconfig.json 文件中启用 strictBindCallApply 编译标志,我们就可以像下面这样使用 bind:
const replace = (s: string, f: string, r: string) => s.split(f).join(r);
const replaceForwardSlash = replace.bind(replace, "/");
const replaceForwardSlashWithDash = replaceForwardSlash.bind(replaceForwardSlash, "-");
replaceForwardSlashWithDash("13/feb/1989");
如我们所见,bind 方法允许我们部分应用函数。我们可以重写本章前面实现的柯里化示例,并用 bind 方法代替柯里化函数:
const compose = <T1, T2, T3>( f: (x: T2) => T3, g: (x: T1) => T2) => (x: T1) => f(g(x));
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 replaceForwardSlashWithDash = replace.bind(replace, "/", "-");
const trimCapitalizeAndReplace = compose(trimAndCapitalize, replaceForwardSlashWithDash);
const result = trimCapitalizeAndReplace(" 13/feb/1989 ");
console.log(result); // "13-FEB-1989"
strictBindCallApply 编译标志确保调用 bind 方法返回的结果将具有正确的类型。在 TypeScript 3.2 版本之前的版本中,bind 方法的返回类型是 any。
管道
管道是一个函数或操作符,它允许我们将一个函数的输出作为另一个函数的输入。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)
);
我们可以使用pipe函数来声明一个新的函数:
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(),
});
之前的代码片段没有使用本章我们已经学到的任何函数式编程技术。以下代码片段使用部分应用等技术为相同问题实现了一个替代解决方案。此代码片段声明了两个函数,分别命名为both和either,可用于确定变量是否匹配由提供给这些函数的某些或所有函数指定的要求:
either和both函数是某些实际代数数据类型的简化实现。我们将在下一章中了解更多关于代数数据类型和范畴论的内容。
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;
// Point-free style
const isCitizen = either(wasBornInCountry, wasNaturalized);
const isEligibleToVote = both(isOver18, isCitizen);
isEligibleToVote({
age: 27,
birthCountry: "Ireland",
naturalizationDate: new Date(),
});
如我们所见,isCitizen和isElegibleToVote函数接受一些函数作为参数,但它们没有说明期望的参数数据类型。例如,我们不必写以下内容:
const isCitizen = (person: Person) => wasBornInCountry(person) || wasNaturalized(person);
我们可以写出以下内容:
const isCitizen = either(wasBornInCountry, wasNaturalized);
这种避免引用函数参数的风格被称为无参数风格,它比更传统的函数声明风格具有许多优点:
-
它使程序更简单、更简洁。这并不总是好事,但有时是。
-
它通过仅关注组合的函数来简化算法的理解。我们可以在数据参数干扰的情况下更好地理解正在发生的事情。
-
它迫使我们更多地思考数据的使用方式,而不是使用的数据类型。
-
它帮助我们以通用构建块的方式思考我们的函数,这些构建块可以与不同类型的数据一起工作,而不是将它们视为对一种数据类型的操作。
请注意,整个示例包含在配套源代码中。
递归
调用自身的函数被称为递归函数。以下是一个递归函数,它允许我们计算给定数字n的阶乘。阶乘是所有小于或等于给定数字n的正整数的乘积:
const factorial = (n: number): number => (n === 0) ? 1 : (n * factorial(n - 1));
我们可以如下调用前面的函数:
factorial(5); // 120
通常情况下,你应该尝试不使用递归来实现函数。使用递归应该仔细考虑,因为 JavaScript 运行时在处理递归时并不非常高效,因为在递归函数调用中,每次函数调用都会在栈上添加一个帧。
模式匹配
模式匹配允许你将一个值(或一个对象)与一些模式进行匹配,以选择代码的某个分支。在函数式语言中,模式匹配可以用来匹配标准原始值,如字符串。TypeScript 允许我们使用字面量类型和控制流分析来实现模式匹配。
例如,我们可以定义三个类型,分别命名为Circle、Square和Rectangle。然后,我们可以定义一个新的类型,命名为Shape,它是Circle、Square和Rectangle类型的并集:
const enum ShapeKind {
circle = "circle",
square = "square",
rectangle = "rectangle",
}
type Circle = { kind: ShapeKind.circle, radius: number };
type Square = { kind: ShapeKind.square, size: number };
type Rectangle = { kind: ShapeKind.rectangle, w: number, h: number };
type Shape = Circle | Square | Rectangle;
然后,我们可以实现接受Shape类型参数的函数,并使用模式匹配来识别Shape是Circle、Square还是Rectangle:
function area(shape: Shape) {
switch(shape.kind) {
case ShapeKind.circle:
return shape.radius ** 2;
case ShapeKind.square:
return shape.size ** 2;
case ShapeKind.rectangle:
return shape.w * shape.h;
default:
throw new Error("Invalid shape!");
}
}
在 TypeScript 2.0 之前的版本中,模式匹配是不可能的,因为控制流分析和字面量类型不可用。
摘要
在本章中,我们学习了一些主要的函数式编程技术和模式,包括函数式组合、函数式部分应用和柯里化。
在下一章中,我们将学习关于范畴论的内容。我们将学习如何处理一些代数数据类型以及它们如何帮助我们使 TypeScript 应用程序更加健壮。
第七章:类别论
在上一章中,我们学习了函数、异步编程以及运行时和函数式编程的原则和技术,包括纯函数和函数组合。
在本章中,我们将重点关注类别论和代数数据类型。我们将学习以下概念:
-
类别论
-
代数数据类型
-
函子
-
应用
-
可能
-
要么
-
摩纳哥
类别论
函数式编程因其数学背景而以难以学习和理解而闻名。函数式编程语言和设计模式受到源自不同数学领域的概念的影响。然而,我们可以将类别论作为最重要的一个影响因素。我们可以将类别论视为集合论的一种替代。它定义了一系列称为代数数据类型的数据结构或对象的背后理论。
代数数据类型有很多,理解它们必须实现的全部属性和规则需要大量的时间和努力。以下图表展示了某些最常见代数数据类型之间的关系:

图表中的箭头表示给定的代数数据类型必须实现某些其他代数数据类型的规范。例如,Monad 类型必须实现 Applicative 和 Chain 类型的规范。
开源项目 fantasy-land 声明了一些代数数据类型的规范。开源项目 ramda-fantasy 以与 Ramda 兼容的方式实现了这些规范,Ramda 是一个流行的函数式编程库,我们将在本书的后续部分探讨。
代数数据类型规范可以以多种方式实现。例如,Functor 规范可以通过 Maybe 或 Either 数据类型实现。这两种类型都实现了 Functor 规范,但也可以实现其他规范,如 Monad 或 Applicative 规范。
以下表格描述了在 fantasy-ramda 项目中,哪些规范(列在顶部行)由代数数据类型实现(左侧行):
| 名称 | Setoid | Semigroup | Functor | Applicative | Monad | Foldable | ChainRec |
|---|---|---|---|---|---|---|---|
| 要么 | ![]() |
✘ | ![]() |
![]() |
![]() |
✘ | ![]() |
| Future | ✘ | ✘ | ![]() |
![]() |
![]() |
✘ | ![]() |
| Identity | ![]() |
✘ | ![]() |
![]() |
![]() |
✘ | ![]() |
| IO | ✘ | ✘ | ![]() |
![]() |
![]() |
✘ | ![]() |
| Maybe | ![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
| Reader | ✘ | ✘ | ![]() |
![]() |
![]() |
✘ | ✘ |
| Tuple | ![]() |
![]() |
![]() |
✘ | ✘ | ✘ | ✘ |
| State | ✘ | ✘ | ![]() |
![]() |
![]() |
✘ | ![]() |
理解范畴论领域以及所有这些数据类型和规范超出了本书的范围。然而,在本章中,我们将学习关于两种最常见的代数数据类型的基础知识:Functors 和 Monads。
请参考 github.com/fantasyland/fantasy-land 上的 fantasy-land 项目和 github.com/ramda/ramda-fantasy 上的 fantasy-ramda 项目,以了解更多关于代数数据类型的信息。
Functors
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));
}
}
我们可以这样使用容器:
const double = (x: number) => x + x;
const container = new Container(3);
const container2 = container.map(double);
console.log(container2); // { _value: 6 }
到目前为止,你可能认为 Functor 类型不是很实用,因为我们已经实现了最基本版本。接下来的两个部分实现了两个称为 Maybe 和 Either 的 Functor。这两个 Functor 要有用得多,并将证明 Functors 是一个强大的工具。然而,在我们能够实现 Maybe 和 Either 类型之前,我们需要了解 Applicative 类型。
Applicative
Applicative 是一个实现了名为 of 的方法的 Functor。然而,Applicative 不仅仅是一个 Functor 类型;它也是一个 Apply 类型。为了使一个类型成为 Apply 的实现,它必须实现一个名为 ap 的方法,该方法接受一个作为参数的包装函数的 Functor。
以下代码片段实现了一个 Applicative,因此它有一个 of、一个 map 和一个 ap 方法:
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));
}
public ap<TMap>(c: Container<(val: T) => TMap>) {
return c.map(fn => this.map(fn));
}
}
我们可以使用 Applicative 来包装一个数字和一个函数,如下所示:
const double = (x: number) => x + x;
const numberContainer = Container.of(3);
const functionContainer = Container.of(double);
我们可以使用 map 方法使用映射函数映射 Functor 包装的值:
numberContainer.map(double); // Returns Container<number> with value 6
或者,我们可以使用 ap 函数来执行相同的操作,使用一个包装函数的 Functor 而不是函数:
numberContainer.ap(functionContainer); // Container<number> with value 6
请注意,整个示例包含在配套源代码中。
Maybe
以下 Maybe 数据类型是一个 Functor 和一个 Applicative,这意味着它包含一个值并实现了 map 方法。与前面实现的 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));
}
}
public ap<TMap>(c: MayBe<(val: T) => TMap>) {
return c.map(fn => this.map(fn));
}
}
如前所述的 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文件中的lib字段中添加dom。我们还使用了async关键字,这需要lib中的 es6。这将防止出现类似于“无法找到名称 fetch”的编译错误。
请注意,整个示例都包含在配套的源代码中。
或者
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可以在处理潜在失败的 I/O 操作(如 HTTP 请求)时提高类型安全性。
请注意,整个示例都包含在配套的源代码中。
模态
我们将通过学习模态来结束我们对代数数据类型的介绍。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 ap<TMap>(c: MayBe<(val: T) => TMap>) {
return c.map(fn => this.map(fn));
}
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两个操作的简化一步。
请注意,整个示例都包含在配套的源代码中。
摘要
在本章中,我们了解了许多代数数据类型,包括Functor、Nothing、Just、Maybe、Either和Monad数据类型。我们学习了这些类型如何帮助我们确保代码正确处理某些错误。
在下一章中,我们将学习其他函数式编程结构,称为 Optics,以及两种新的强大技术:懒评估和不可变性。
第八章:不可变性、光学和惰性
在前面的章节中,我们学习了最基础的函数式编程技术和模式,包括一些最著名的代数数据类型。
在本章中,我们将学习许多额外的函数式编程技术和模式,包括以下内容:
-
不可变性
-
光学
-
镜头
-
Prim
-
惰性评估
再次,我们将从头开始构建一切,尽量避免使用第三方库。我们的目标是查看这些技术和模式的一些内部实现,以便我们完全理解它们是如何工作的。让我们开始吧!
不可变性
在本节中,我们将学习关于不可变数据结构的内容。不可变数据结构是一个不允许我们更改其值的对象。在 TypeScript 中实现不可变数据结构的最简单方法就是使用类和 readonly 关键字:
class Person {
public readonly name: string;
public readonly age: number;
public constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
const person = new Person("Remo", 29);
person.age = 30; // Error
person.name = "Remo Jansen"; // Error
上述代码片段声明了一个名为 Person 的类。该类有两个公共属性,分别命名为 name 和 age。这两个属性已被标记为 readonly。正如代码片段所示,当我们尝试更新类的属性值时,会抛出一个编译错误。
readonly 属性可以使我们的代码更安全,因为它可以保护我们免受状态突变的影响。例如,如果我们将一些不可变对象作为参数传递给一个函数,该函数将无法修改原始对象。这意味着我们的函数更有可能是一个纯函数。然而,不可变对象并非全是优点。处理不可变对象有时可能会感到非常繁琐和冗长,尤其是当我们希望生成一个新的状态时。让我们来看一个例子:
class Street {
public readonly num: number;
public readonly name: string;
public constructor(num: number, name: string) {
this.num = num;
this.name = name;
}
}
class Address {
public readonly city: string;
public readonly street: Street;
public constructor(city: string, street: Street) {
this.city = city;
this.street = street;
}
}
class Company {
public readonly name: string;
public readonly addresses: Address[];
public constructor(name: string, addresses: Address[]) {
this.name = name;
this.addresses = addresses;
}
}
上述代码片段声明了三个名为 Street、Address 和 Company 的类。这三个类中的所有属性都是 readonly,这意味着这些类是不可变的。我们可以创建一个新的 Company 类实例,如下所示:
const company1 = new Company(
"Facebook",
[
new Address(
"London",
new Street(1, "rathbone square")
),
new Address(
"Dublin",
new Street(5, "grand canal square")
)
]
);
当我们说一个对象是不可变的,这意味着我们无法更改原始对象,但这并不意味着我们不希望创建其衍生版本。例如,如果我们尝试通过将街道名称转换为上标来创建一个新的 Company 版本,我们将得到一个错误,如下面的代码片段所示:
company1.addresses = company1.addresses.map(a => R.toUpper(a.street.name)); // Error
然而,我们可能需要生成一个带有大写街道名称的新版本。我们可以通过创建一个新的 Company 实例来生成 Company 实例的更新版本。为了创建一个新的副本,我们需要将原始实例的所有属性复制到一个新实例中,并使用新的值来替换我们希望修改的属性:
const company2 = new Company(
company1.name,
company1.addresses.map((a) =>
new Address(
a.city,
new Street(
a.street.num,
R.toUpper(a.street.name)
)
)
)
);
不可变数据结构可以帮助我们实现纯函数,并使我们的代码无副作用。修改外部变量是副作用最常见的原因之一,而使用不可变数据结构可以帮助我们防止这种修改。
请注意,您可以参考 第一章,函数式编程基础,以了解更多关于副作用的信息。
然而,正如我们可以在之前的代码片段中看到的那样,不可变数据结构也有其负面影响:它们可能导致冗长且繁琐的代码。好消息是,函数式编程范式的背后人士已经找到了一种解决方案,称为 光学。我们将在下一节中学习光学。
光学
光学是函数式编程的一个概念,可以帮助我们减少需要编写的代码量,并使操作更易于阅读。使用光学的好处在我们处理不可变数据结构时尤为明显。所有光学都是获取和设置对象属性的一种方式。实际上,我们可以将光学视为面向对象编程中获取器和设置器的替代品。
光学可以分为两大类——镜头和棱镜。正如我们在 第七章,范畴论 中学到的,代数数据类型可以用和类型和积类型来定义。镜头用于处理积类型(例如,元组和对象),棱镜用于处理和类型(例如,区分联合)。在本节的剩余部分,我们将重点关注镜头的使用。
镜头
镜头只是一对函数,允许我们在对象中获取和设置值。镜头的接口可以声明如下:
interface Lens<T1, T2> {
get(o: T1): T2;
set(o: T2, v: T1): T1;
}
正如我们在之前的代码片段中可以看到的,镜头泛型接口声明了两个方法。get 方法可以用来获取类型为 T2 的对象中属性值。set 方法可以用来设置类型为 T2 的对象中的属性值。以下代码片段实现了 Lens 接口:
const streetLens: Lens<Address, Street> = {
get: (o: Address) => o.street,
set: (v: Street, o: Address) => new Address(o.city, v)
};
之前实现的 Lens 接口被命名为 streetLens,它允许我们在 Object 类型的对象中设置 Street 类型的属性值。我们可以使用 streetLens 对象来获取 Address 实例中的 Street 实例:
const address = new Address(
"London",
new Street(1, "rathbone square")
);
const street = streetLens.get(address);
我们还可以使用 Lens 实现来设置 Address 实例中的 Street 实例:
const address2 = streetLens.set(
new Street(
address.street.num,
R.toUpper(address.street.name)
),
address
);
重要的是要注意,set 方法更新 Street 实例并返回一个新的 Address 实例,而不是修改原始的 Address 实例。现在我们知道了镜头如何工作的基础知识,我们将看看一些属性。
镜头的一个主要特征是它们可以被组合。正如我们在前面的章节中学到的,函数组合是函数式编程的主要技术之一,而镜头只是函数,因此它们可以以非常相似的方式组合。以下代码片段声明了一个高阶函数,允许我们组合两个镜头:
function composeLens<A, B, C>(
ab: Lens<A, B>,
bc: Lens<B, C>
): Lens<A, C> {
return {
get: (a: A) => bc.get(ab.get(a)),
set: (c: C, a: A) => ab.set(bc.set(c, ab.get(a)), a)
};
}
现在我们已经声明了一个允许我们组合透镜的高阶函数,我们将组合两个名为 streetLens 和 nameLens 的透镜:
const streetLens: Lens<Address, Street> = {
get: (o: Address) => o.street,
set: (v: Street, o: Address) => new Address(o.city, v)
};
const nameLens: Lens<Street, string> = {
get: (o: Street) => o.name,
set: (v: string, o: Street) => new Street(o.num, v)
};
const streetNameLens = composeLens(streetLens, nameLens);
composeLens 函数的返回值创建了一个名为 streetName 的新透镜。这个新透镜可以用来获取 Address 实例中 Street 实例的名称:
const address = new Address(
"London",
new Street(1, "rathbone square")
);
const streetName = streetNameLens.get(address);
透镜还可以用来创建一个带有更新后的 Street 名称的新 Address 实例:
const address2 = streetNameLens.set(R.toUpper(address.street.name), address);
许多函数式编程库也实现了一个函数,允许我们使用透镜将对象中的给定属性映射到新值。这个函数有时被命名为 over,但我们将将其命名为 overLens 以便更清晰:
function overLens<S, A>(
lens: Lens<S, A>,
func: (a: A) => A,
s: S
): S {
return lens.set(func(lens.get(s)), s)
}
前面的函数将其第一个参数作为透镜,第二个参数作为映射函数,第三个参数作为对象。该函数使用透镜通过映射函数聚焦和更新对象的某个属性:
const address = new Address(
"London",
new Street(1, "rathbone square")
);
const address2 = overLens(streetNameLens, R.toUpper, address);
如您所见,使用透镜生成不可变对象的新版本,比使用标准属性访问器和类构造函数要简洁得多。现在我们了解了透镜的基础知识,我们将再次实现一些透镜。之前的实现被简化以方便理解。然而,这次我们将以更接近一些流行库(如 Ramda)的实现方式来实现透镜。
这次,我们将声明两个函数,用作获取器和设置器。用作获取器的函数将实现一个名为 Prop 的接口。另一方面,用作设置器的函数将实现一个名为 Assoc 的接口。Prop 和 Assoc 接口的签名如下所示:
type Prop<T, K extends keyof T> = (o: T) => T[K];
type Assoc<T, K extends keyof T> = (v: T[K], o: T) => T;
以下代码片段声明了实现 Prop 和 Assoc 接口的函数。这两个实现都用于访问类型为 Address 的对象中的 street 属性:
const propStreet: Prop<Address, "street"> = (o: Address) => o.street;
const assocStreet: Assoc<Address, "street"> = (v: Address["street"], o: Address) => {
return new Address(o.city, v);
};
新实现中的一个主要区别是我们将声明一个高阶函数,命名为 lens,并使用它来生成透镜实例。lens 函数接受两个函数,一个获取器和一个设置器,它们分别实现了 Prop 和 Assoc 接口。然后,透镜函数返回 Lens 接口的实现:
const lens = <T1, K extends keyof T1>(
getter: Prop<T1, K>,
setter: Assoc<T1, K>,
): Lens<T1, T1[K]> => {
return {
get: (obj: T1) => getter(obj),
set: (val: T1[K], obj: T1) => setter(val, obj),
};
}
到目前为止,我们可以使用之前声明的获取器函数 propStreet 和设置器函数 assocStreet 来调用 lens 函数:
const streetLens = lens(propStreet, assocStreet);
另一个显著的区别是,新的实现使用了两个额外的函数,分别命名为 view 和 set,作为获取器和设置器。view 和 set 函数都接受一个透镜实例:
const view = <T1, T2>(lens: Lens<T1, T2>, obj: T1) => lens.get(obj);
const set = <T1, K extends keyof T1>(
lens: Lens<T1, T1[K]>,
val: T1[K],
obj: T1
) => lens.set(val, obj);
前面的函数在内部使用透镜的 get 和 set 方法。然而,我们将使用 view 和 set 函数。以下代码片段演示了如何使用 view 函数获取 Address 实例中的 Street 实例:
const address = new Address(
"London",
new Street(1, "rathbone square")
);
const street = view(streetLens, address);
以下代码片段演示了如何使用set函数在Address实例中设置Street实例的值:
const address2 = set(
streetLens,
new Street(
address.street.num,
R.toUpper(address.street.name)
),
address
);
在本节中,我们学习了有关透镜的基础知识。在下一节中,我们将学习另一种功能光学元件,称为棱镜。
棱镜
棱镜几乎与透镜相同。我们可以将棱镜视为一种透镜,它允许我们在对象中获取和设置可选属性。透镜和棱镜之间最显著的区别是棱镜可以与可选类型一起工作。
以下代码片段声明了Prism接口。正如我们所见,Prism接口与Lens接口非常相似。然而,get方法返回一个可选类型Maybe<T>:
type Maybe<T> = T | null;
interface Prism<T1, T2> {
get(o: T1): Maybe<T2>,
set(a: T2, o: T1): T1;
}
就像透镜一样,棱镜也可以组合。以下代码片段声明了一个高阶函数,允许我们组合两个棱镜:
function composePrism<A, B, C>(ab: Prism<A, B>, bc: Prism<B, C>): Prism<A, C> {
return {
get: (a: A) => {
const b = ab.get(a)
return b == null ? null : bc.get(b)
},
set: (c: C, a: A) => {
const b = ab.get(a)
return b == null ? a : ab.set(bc.set(c, b), a)
}
}
}
上述函数接受两个棱镜,ab类型为Prism<A, B>,和bc类型为Prism<B, C>,并返回两个棱镜的组合,类型为Prism<A, C>。
棱镜还允许我们实现一个函数,该函数可以将对象和棱镜给出的属性进行映射。在现实世界的库中,该函数通常命名为over,但就像我们在关于透镜的章节中所做的那样,我们将为了清晰起见将其命名为overPrism:
function overPrism<S, A>(
prism: Prism<S, A>,
func: (a: A) => A,
s: S
): S {
const a = prism.get(s)
return a ? prism.set(func(a), s) : s
}
在前面的代码片段中,我们声明了与棱镜一起工作的主要构建块,包括Prism接口和composePrism以及overPrism函数。在下一节中,我们将演示如何使用名为firstCharacterPrism的棱镜来关注可选字符串的第一个字符。代码片段还声明了一个棱镜来访问Address实例中的street属性和Street实例中的name属性。
然后,使用composePrism将三个firstCharacterPrism、streetPrism和namePrism棱镜组合成一个新的棱镜,命名为streetNameFirstCharater。最后,使用overPrism函数使用R.toUpper函数映射由streetNameFirstCharacter选定的值。结果是包含一个具有大写名称的新Street实例的新的Address实例。如果名称为null,新的Street实例将包含null作为其name:
const firstCharacterPrism: Prism<string, string> = {
get: s => s ? s.substring(0, 1) : null,
set: (a, s) => s.length ? a + s.substring(1) : ""
}
const streetPrism: Prism<Address, Street> = {
get: (o: Address) => o.street,
set: (v: Street, o: Address) => new Address(o.city, v)
};
const namePrism: Prism<Street, string> = {
get: (o: Street) => o.name,
set: (v: string, o: Street) => new Street(o.num, v)
};
const address = new Address(
"London",
new Street(1, "rathbone square")
);
const streetNameFirstCharacterPrism = composePrism(
composePrism(streetPrism, namePrism),
firstCharacterPrism
);
const address2 = overPrism(streetNameFirstCharacterPrism, R.toUpper, address);
当我们想要处理其他类型的可选类型时,棱镜也非常有用,例如像Either类型这样的区分联合:
type Either<T1, T2> = T1 | T2;
type Domicile = Either<
{ type: "office", address: Address },
{ type: "personal", address: string }
>;
const addressPrism: Prism<Domicile, Address> = {
get: d => d.type === "office" ? d.address : null,
set: (address, d) => d.type === "office" ? { type: "office", address } : d
}
上述代码片段声明了一个名为Either的可选类型和一个名为Domicile的类型,它使用Either类型来声明两种类型的联合。代码片段还声明了一个名为addressPrism的棱镜,允许我们在类型为Domicile的对象中获取或设置address属性。属性address可以是string或Address实例,addressPrism可以处理这两种情况,如下面的代码片段所示:
const address = new Address(
"London",
new Street(1, "rathbone square")
);
const domicile1: Domicile = { type: "office", address: address };
const domicile2: Domicile = { type: "personal", address: "23 high street" };
const address1 = addressPrism.get(domicile1);
const address2 = addressPrism.get(domicile2);
到目前为止,我们应该理解透镜和棱镜的主要特性。在本章中,我们创建了透镜和棱镜的自己的实现,因为我们的主要目标是理解它们是如何工作的。然而,对于专业的软件开发项目来说,不建议使用自定义实现。在第十章,现实世界函数式编程中,我们将学习如何使用 Ramda 的生产就绪透镜。
在下一节中,我们将学习懒加载(lazy evaluation)。
懒加载
懒加载是一种技术或模式,它将表达式的评估延迟到其值需要的时候。我们将首先查看一个不使用懒加载的例子,以便我们可以在稍后将其与使用懒加载的例子进行比较。
以下代码片段声明了一个名为 Dog 的接口和一个包含十个 Dog 实例的数组。Dog 实例有两个属性,分别命名为 size 和 name。代码片段还声明了两个函数,分别命名为 isLarge 和 isOld。isLarge 函数用于查找 size 等于 "L" 的 Dog 实例。isOld 函数用于查找年龄大于 5 的 Dog 实例:
interface Dog {
size: "L" | "S";
age: number;
name: string;
}
const dogs: Dog[] = [
{ size: "S", age: 4, name: "Alice" },
{ size: "L", age: 2, name: "Bob", },
{ size: "S", age: 7, name: "Carol" },
{ size: "L", age: 6, name: "Dan" },
{ size: "L", age: 2, name: "Eve" },
{ size: "S", age: 2, name: "Frank" },
{ size: "S", age: 1, name: "Grant" },
{ size: "S", age: 9, name: "Hans" },
{ size: "L", age: 8, name: "Inga" },
{ size: "L", age: 4, name: "Julia" }
];
const isLarge = (dog: Dog) => dog.size === "L";
const isOld = (dog: Dog) => dog.age > 5;
dogs.filter(isLarge).find(isOld); // Dan
上述代码片段使用了数组方法 filter 和 find。filter 方法遍历 dogs 数组中的所有元素。使用 isLarge 函数过滤所有 Dog 实例的结果是一个包含五个元素的新数组(所有 size 等于 "L" 的元素)。然后我们使用 find 方法通过 isOld 函数在新数组中搜索 Dog 实例。find 方法在找到第一个年龄大于 5 的元素之前迭代了两个项目。最终结果是,我们需要迭代 12 个项目才能找到一个同时符合 isLarge 和 isOld 约束条件的项目。
懒加载是一种技术,它将某些操作的执行延迟到它们不能再延迟的时候。懒加载有时可以带来性能提升。
以下代码片段实现了一个名为 filter 的函数和一个名为 find 的函数。这两个函数分别与数组的 filter 和 find 方法等价。然而,filter 函数使用生成器(function*),而 find 函数使用 for ... of 语句,该语句用于迭代由前面的生成器创建的迭代器返回的项目:
const filter = <T>(f: (item: T) => boolean) => {
return function* (arr: T[]) {
for (const item of arr) {
if (f(item)) {
yield item;
}
}
};
};
const find = <T>(f: (item: T) => boolean) =>(arr: IterableIterator<T>) => {
for (const item of arr) {
if (f(item)) {
return item;
}
}
};
请记住,使用迭代器需要在 tsconfig.json 文件中将编译设置 downlevelIteration 设置为 true。如果您需要关于生成器的额外帮助,请参阅第三章,精通异步编程。
代码片段使用了 Ramda 库中的 compose 函数来组合 find(isOld) 和 filter(isLarge) 的返回值。结果是一个名为 findLargeOldDog 的新函数。我们可以使用这个函数来查找 dogs 数组中同时满足 isLarge 和 isOld 约束的 Dog 实例:
const findLargeOldDog = R.compose(find(isOld), filter(isLarge));
findLargeOldDog(dogs);
这个函数的结果与未使用惰性评估的示例结果相同。然而,这个示例只迭代了四个项目,而不是十二个。这是因为当我们执行过滤函数时,过滤不会立即发生。我们通过返回一个迭代器来延迟其评估。评估将延迟到迭代器的 next 方法被 for ... of 语句调用时。find 函数逐个迭代项目,并逐个调用 isOld 和 isLarge 函数。当迭代器返回第四个项目时,它同时满足 isLarge 和 isOld 约束,因此不需要再迭代其他项目。因此,惰性评估版本要高效得多。
在前面的示例中,我们使用了生成器和迭代器来实现惰性评估,但这并不是实现惰性评估的唯一方法。惰性评估可以使用多个 JavaScript API 实现,例如代理或承诺。
摘要
在本章中,我们学习了如何利用函数式编程技术,如惰性评估和不可变性,来防止一些潜在问题。我们还学习了如何使用光学方法以更简洁、更不繁琐的方式处理不可变对象。
在下一章,我们将学习关于函数式响应式编程(FRP)的内容。我们将了解响应式编程是什么,以及它与函数式编程之间的联系。
第九章:函数-响应式编程
在前面的章节中,我们学习了函数式编程范式。我们探讨了函数式编程的主要概念、技术和模式。在本章中,我们将学习函数式响应式编程范式,包括以下主题:
-
响应式编程
-
函数式响应式编程
-
流
-
可观察对象
-
观察者模式
-
迭代器模式
-
操作符
我们将学习函数式响应式编程是什么以及它如何帮助我们开发易于扩展和维护的应用程序。
响应式编程
在本节中,我们将学习函数式编程和响应式编程之间的主要区别以及响应式编程的主要好处。
函数式编程与函数式响应式编程的比较
函数式编程和响应式编程应被视为两种不同的范式。函数式编程侧重于将函数解释为数学函数——无状态且无副作用。另一方面,响应式编程侧重于将变化传播为事件流。术语函数式响应式编程用于指代响应式编程的超集。函数式响应式编程试图利用函数式和响应式编程范式的好处。例如,在函数式响应式编程中,事件流可以被组合,我们被鼓励避免外部状态变更,并且许多函数式编程原则仍然适用。
函数式响应式编程的好处
函数式响应式编程深受函数式编程原则的影响,因此,许多函数式编程的好处也被函数式响应式编程所共享。函数式响应式应用程序更容易推理,因为它们倾向于避免状态变更和副作用,并促进声明式风格。它们特别适合基于事件的架构和并发系统。许多开发者认为,函数式响应式编程是一种易于扩展的编程风格,因为它遵循可组合性的原则。
与可观察对象一起工作
响应式编程要求我们改变对应用程序中事件思考的方式。响应式编程要求我们将事件视为值流。例如,鼠标点击事件可以表示为数据流。每次点击事件都会在数据流中生成一个新值。在响应式编程中,我们可以使用数据流来查询和操作流中的值。
我们将使用 JavaScript 的响应式扩展库(RxJS)。RxJS 为我们提供了一个可观察模式的实现,以及许多操作符和实用工具,使我们能够操作可观察对象。RxJS 还包括一些辅助工具,允许我们根据不同的数据类型创建可观察对象。
我们可以使用 npm 来安装 RxJS:
npm install rxjs
可观察模式也被称为 可观察序列模式,这是将两种其他流行的模式结合在一起的结果:观察者和迭代器模式。在本节中,我们将更深入地了解这些模式,以便更好地理解可观察对象是什么以及它们是如何在内部工作的。
观察者模式
在观察者模式的实现中,我们可以有许多已知的监听器实体订阅消息。以下代码片段包含了一个观察者模式中监听器实现的非常基础的示例:
class Listener<T> {
public update: (message: T) => void;
public constructor(fn: (message: T) => void) {
this.update = fn;
}
}
Listener 对象有一个名为 update 的方法,该方法在第二个实体,即 Producer,生成一条新消息时被调用。一个 Producer 实例管理多个 Listener 实例。可以通过 notify 方法生成一条 message。然后,这条 message 被传递给所有已订阅的监听器。以下代码片段包含了一个观察者模式中生产者实现的非常基础的示例:
class Producer<T> {
private _listeners: Listener<T>[] = [];
public add(listener: Listener<T>) {
this._listeners.push(listener);
}
public remove(listener: Listener<T>) {
this._listeners = this._listeners.filter(
l => l !== listener
);
}
public notify(message: T) {
this._listeners.forEach(
l => l.update(message)
);
}
}
以下代码片段声明了一些 Listener 实例和一个 Producer 实例。然后,它使用 add 方法将两个监听器都订阅到 Producer 的消息。稍后,我们使用 Producer 中的 notify 方法发送一条消息。这条消息将被所有已订阅的监听器接收。在这种情况下,两个监听器都将接收到这条消息:
const listerner1 = new Listener(
(msg: string) => console.log(`Listener 1: ${msg}`)
);
const listerner2 = new Listener(
(msg: string) => console.log(`Listener 2: ${msg}`)
);
const notify = new Producer<string>();
notify.add(listerner1);
notify.add(listerner2);
notify.notify("Hello World!");
现在我们已经学习了如何实现观察者模式,接下来我们将关注可观察序列模式使用的第二种模式——迭代器模式。
迭代器模式
要理解可观察序列模式是如何工作的,我们还需要了解迭代器模式。以下代码片段使用生成器创建了一个迭代器,该迭代器遍历数组中给定数字的倍数。只有数组中给定数字的倍数会被迭代:
function* iterateOnMultiples(arr: number[], divisor: number) {
for (let item of arr) {
if (item % divisor === 0) {
yield item;
}
}
}
要获取迭代器的一个实例,我们只需要调用该函数,并传递一个数组和数字作为其参数。该函数返回一个迭代器,它将返回数组中给定数字的倍数:3。我们可以调用迭代器的 next 方法来获取下一个元素。每个元素都有一个名为 done 的属性和一个名为 value 的属性。done 属性可以用来检查是否还有更多项目需要迭代。value 属性可以用来访问当前项的值:
const iterator1 = iterateOnMultiples([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 3);
const iteratorResult1 = iterator1.next();
console.log(iteratorResult1.value);
if (iteratorResult1.done === false) {
const iteratorResult2 = iterator1.next();
console.log(iteratorResult2.value);
}
我们也可以使用 for...of 语句迭代迭代器中的所有项目,而不是手动访问 done 属性:
const iterator2 = iterateOnMultiples([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 3);
for (let value of iterator2) {
console.log(value);
}
观察者和迭代器模式在许多不同场景中非常有用。我们可以将这两种模式结合在一个称为可观察序列或简单地称为可观察值的模式中。可观察值允许我们迭代并通知序列中的变化。现在我们了解了什么是可观察值,我们将学习如何使用 RxJS 创建可观察值的实例。
创建可观察值
可观察值是数据流,这也解释了为什么我们可以想象使用可观察值来表示像onClick事件这样的事件。然而,可观察值的用例比这要多样化得多。在本节中,我们将探讨如何根据不同类型创建可观察值。
从值创建可观察值
我们可以使用of函数根据值创建一个可观察值。在 RxJS 的老版本中,of函数是Observable类的一个静态方法,可用作Observable.of。这应该提醒我们使用范畴论中的Applicative类型的of方法,因为可观察值从范畴论中汲取了一些灵感。然而,在 RxJS 6.0 中,of方法作为一个独立的工厂函数可用:
import { of } from "rxjs";
const observable = of(1);
const subscription = observable.subscribe(
(value) => console.log(value),
(error: any) => console.log(error),
() => console.log("Done!")
);
subscription.unsubscribe();
前面的代码片段使用of函数声明了一个具有一个唯一值的可观察值。代码片段还展示了如何使用subscribe方法订阅可观察值。subscribe方法接受三个函数参数:
-
项目处理器:为序列中的每个项目调用一次。
-
错误处理器:如果序列中发生错误,则调用。此参数是可选的。
-
完成处理器:当序列中没有更多项目时被调用。此参数是可选的。
以下图表被称为弹珠图,用于以视觉方式表示可观察值。箭头代表时间,圆圈代表值。在这种情况下,我们只有一个值:

如我们所见,圆圈中间也有一条小的垂直线。这条线用来表示可观察值的最后一个元素。在这种情况下,订阅中的项目处理器只会被调用一次。
从数组创建可观察值
我们可以使用from函数根据现有的数组创建一个可观察值:
import { from } from "rxjs";
const observable = from([10, 20, 30]);
const subscription = observable.subscribe(
(value) => console.log(value),
(error: any) => console.log(error),
() => console.log("Done!")
);
subscription.unsubscribe();
前面的代码片段使用from函数声明了一个具有三个值的可观察值。代码片段还展示了如何再次进行订阅。
以下这个弹珠图以视觉方式表示了前面的例子。生成的可观察值有三个值(10、20和30),其中30是可观察值的最后一个元素:

我们还可以使用interval函数生成具有给定元素数量的数组:
import { interval } from "rxjs";
const observable = interval(10);
const subscription = observable.subscribe(
(value) => console.log(value),
(error: any) => console.log(error),
() => console.log("Done!")
);
subscription.unsubscribe();
前面的代码片段使用 interval 函数声明了一个包含十个值的可观察对象。代码片段还展示了我们可以再次订阅。在这种情况下,订阅中的项目处理程序将被调用十次。
下面的油管图以可视化的方式表示了前面的示例。生成可观察对象包含十个值,其中 9 是它包含的最后一个项目:

在这种情况下,订阅中的项目处理程序将被调用十次。
从事件创建可观察对象
也可以使用事件作为流中项目来源来创建一个可观察对象。我们可以使用 fromEvent 函数来完成此操作:
import { fromEvent } from "rxjs";
const observable = fromEvent(document, "click");
const subscription = observable.subscribe(
(value) => console.log(value)
);
subscription.unsubscribe();
在这种情况下,订阅中的项目处理程序将根据点击事件发生的次数被调用。
请注意,前面的示例只能在网页浏览器中执行。要在网页浏览器中执行前面的代码,您需要使用模块打包器,例如 Webpack。我们不会涉及这个主题,因为它超出了本书的范围。
从回调创建可观察对象
使用 bindCallback 函数也可以创建一个可观察对象,它会迭代回调的参数:
import { bindCallback } from "rxjs";
import fetch from "node-fetch";
function getJSON(url: string, cb: (response: unknown|null) => void) {
fetch(url)
.then(response => response.json())
.then(json => cb(json))
.catch(_ => cb(null));
}
const uri = "https://jsonplaceholder.typicode.com/todos/1";
const observableFactory = bindCallback(getJSON);
const observable = observableFactory(uri);
const subscription = observable.subscribe(
(value) => console.log(value)
);
subscription.unsubscribe();
前面的示例使用了 node-fetch 模块,因为 Node.js 中没有 fetch 函数。您可以使用以下 npm 命令安装 node-fetch 模块:
npm install node-fetch @types/node-fetch
getJSON 函数接受一个 URL 和一个回调作为其参数。当我们将其传递给 bindCallback 函数时,会返回一个新的函数。这个新函数只接受一个 URL 作为其唯一参数,并返回一个可观察对象,而不是接受一个回调。
在 Node.js 中,回调遵循一个明确的模式。Node.js 回调接受两个参数,error 和 result,并且不会抛出异常。我们必须使用 error 参数来检查是否出错,而不是使用 try/catch 语句。RxJS 还定义了一个名为 bindNodeCallback 的函数,允许我们与回调一起工作:
import { bindNodeCallback } from "rxjs";
import * as fs from "fs";
const observableFactory = bindNodeCallback(fs.readFile);
const observable = observableFactory("./roadNames.txt");
const subscription = observable.subscribe(
(value) => console.log(value.toString())
);
subscription.unsubscribe();
辅助函数 bindCallback 和 bindNodeCallback 有非常相似的行为,但第二个是专门设计来与 Node.js 回调一起工作的。
从承诺创建可观察对象
可观察对象序列的项目来源的另一个潜在来源是 Promise。RxJS 还允许我们使用 from 函数处理此用例。我们必须将 Promise 实例传递给 from 函数。在下面的示例中,我们使用 fetch 函数发送 HTTP 请求。fetch 函数返回一个承诺,该承诺被传递给 from 函数:
import { bindCallback } from "rxjs";
import fetch from "node-fetch";
const uri = "https://jsonplaceholder.typicode.com/todos/1";
const observable = from(fetch(uri)).pipe(map(x => x.json()));
const subscription = observable.subscribe(
(value) => console.log(value.toString())
);
subscription.unsubscribe();
生成的可观察对象将只包含承诺的结果作为其唯一项目。
冷和热可观察对象
官方 RxJS 文档如下探讨了冷和热可观察对象之间的差异:
"冷可观察对象在订阅时开始运行,也就是说,只有当调用 Subscribe 时,可观察对象序列才开始向观察者推送值。值也不会在订阅者之间共享。这与热可观察对象不同,例如鼠标移动事件或股票行情,它们在订阅活动之前就已经开始产生值。当观察者订阅一个热可观察对象序列时,它将获得订阅后发出的所有流值。热可观察对象序列在所有订阅者之间共享,并且每个订阅者都会收到序列中的下一个值。"
如果我们想要控制组件的执行流程,理解这些区别是很重要的。关键点是要记住冷可观察对象是惰性评估的。
使用操作符
在本节中,我们将学习如何使用一些称为操作符的函数,这些函数允许我们以许多不同的方式操作可观察对象。
Pipe
在 RxJS 中,可观察对象有一个名为pipe的方法,这与函数式编程中的管道操作符非常相似。当我们连接两个函数时,我们生成一个新的函数,该函数将第一个函数的返回值作为参数传递给管道中的第二个函数。
在响应式编程中,这个想法非常相似。当我们通过一个操作符将一个可观察对象管道化时,我们生成一个新的可观察对象。新的可观察对象将原始可观察对象中的每个项目传递给一个操作符,该操作符将它们转换为新序列中的项目。
我们在这里不会包含代码示例,因为在本章的剩余部分,我们将多次使用pipe方法。
Max
max操作符函数可以用来在一个可观察对象中找到最大值。我们必须使用pipe方法来应用max操作符:
import { from } from "rxjs";
import { max } from "rxjs/operators";
const observable = from<number>([2, 30, 22, 5, 60, 1]);
observable.pipe(max());
const subscription = observable.subscribe(
(value) => console.log(value)
);
subscription.unsubscribe();
下面的弹珠图展示了应用max操作符之前的初始序列和应用max操作符之后的结果序列:

结果序列只包含一个值(原始序列中的最大值)。
Every
every操作符函数可以用来测试一个可观察对象中的所有值是否都符合给定的要求:
import { from } from "rxjs";
import { every } from "rxjs/operators";
const observable = from<number>([1,2, 3, 4, 5]);
observable.pipe(every(x => x < 10));
const subscription = observable.subscribe(
(value) => console.log(value)
);
subscription.unsubscribe();
之前的代码片段使用了every操作符来测试一个可观察对象中的所有值是否都低于十。下面的弹珠图展示了应用every操作符之前的初始序列和应用every操作符之后的结果序列:

结果序列只包含一个值(true 或 false)。
Find
find操作符函数可以用来查找一个可观察对象中符合给定约束的第一个值:
import { from } from "rxjs";
import { find } from "rxjs/operators";
const observable = from<number>([2, 30, 22, 5, 60, 1]);
observable.pipe(find(x => x > 10));
const subscription = observable.subscribe(
(value) => console.log(value)
);
subscription.unsubscribe();
之前的代码片段使用了find操作符来查找一个可观察对象中大于十的第一个值。下面的弹珠图展示了应用find操作符之前的初始序列和应用find操作符之后的结果序列:

结果序列只包含一个值(符合给定约束的流中的第一个值)。
Filter
filter运算符函数可以用来找到符合给定约束的可观察对象中的值:
import { from } from "rxjs";
import { filter } from "rxjs/operators";
const observable = from<number>([2, 30, 22, 5, 60, 1]);
observable.pipe(filter(x => x > 10));
const subscription = observable.subscribe(
(value) => console.log(value)
);
subscription.unsubscribe();
前面的代码片段使用filter运算符来找到大于十的观察对象中的值。以下是大理石图,展示了应用filter运算符后的初始序列和结果序列:

结果序列只包含一些值(与给定约束匹配的流中的值)。
Map
map运算符函数可以用来将可观察对象中的值转换为派生值:
import { from } from "rxjs";
import { map } from "rxjs/operators";
const observable = from<number>([1, 2, 3]);
observable.pipe(map(x => 10 * x));
const subscription = observable.subscribe(
(value) => console.log(value)
);
subscription.unsubscribe();
前面的代码片段使用map运算符将观察对象中的值转换为新的值(原始值乘以十)。以下是大理石图,展示了应用map运算符后的初始序列和结果序列:

结果序列包含原始序列中每个值的新的映射值。
Reduce
reduce运算符函数可以用来将可观察对象中的所有值转换为一个单一值:
import { from } from "rxjs";
import { reduce } from "rxjs/operators";
const observable = from<number>([1, 2, 3, 3, 4, 5]);
observable.pipe(reduce((x, y) => x + y));
const subscription = observable.subscribe(
(value) => console.log(value)
);
subscription.unsubscribe();
前面的代码片段使用reduce运算符将观察对象中的值转换为一个新单一值(所有值的总和)。将多个值转换为一个单一值的函数称为累加器。以下是大理石图,展示了应用reduce运算符后的初始序列和结果序列:

结果序列只包含一个值(累加器的结果)。
Throttle
throttle运算符函数可以用来减少添加到可观察对象中的值的数量:
import { fromEvent, interval } from "rxjs";
import { throttle, mapTo, scan } from "rxjs/operators";
const observable = fromEvent(document, "click")
.pipe(mapTo(1))
.pipe(throttle(x => interval(100)))
.pipe(scan((acc, one) => acc + one, 0));
const subscription = observable.subscribe(
(value) => console.log(value)
);
subscription.unsubscribe();
前面的代码片段创建了一个用于click事件的观察对象。每次点击都会向序列中添加一个项目。示例还使用了pipe方法和mapTo函数将所有点击事件映射到数值1。然后我们使用throttle运算符来减少添加到序列中的值的数量。如果在小于声明的时间间隔内发生两个或多个点击事件,则只有第一个值将被添加到序列中。
请注意,前面的示例只能在网页浏览器中执行。要在网页浏览器中执行前面的代码,您需要使用模块打包器,例如 Webpack。我们不会涉及这个主题,因为它超出了本书的范围。
以下大理石图展示了应用reduce运算符后的初始序列和结果序列:

结果序列只包含一些值,因为时间上过于接近的值被忽略了。
Merge
可以使用 merge 操作符函数将两个可观察对象的值合并成值对:
import { from } from "rxjs";
import { merge } from "rxjs/operators";
const observableA = from<number>([20, 40, 60, 80, 100]);
const observableB = from<number>([1, 1]);
const observableC = observableA.pipe(merge<number, number>(observableB));
const subscription = observableC.subscribe(
(value) => console.log(value)
);
subscription.unsubscribe();
上一段代码片段使用了 merge 操作符将两个可观察对象的值合并到一个新的可观察对象中。这些值按时间顺序排列。以下的水晶图展示了应用 merge 操作符之前的初始序列和结果序列:

结果序列包含两个可观察对象的时间顺序中的值,并按其发生的时间顺序排列。
Zip
可以使用 zip 操作符函数将两个可观察对象的值合并成值对:
import { from } from "rxjs";
import { zip } from "rxjs/operators";
const observableA = from<number>([1, 2, 3, 3, 4, 5]);
const observableB = from<string>(["A", "B", "C", "D"]);
const observableC = observableA.pipe(zip<number, string>(observableB));
const subscription = observableC.subscribe(
(value) => console.log(value)
);
subscription.unsubscribe();
上一段代码片段使用了 zip 操作符将两个可观察对象的值合并到一个新的可观察对象中。新可观察对象中的值是包含来自第一个可观察对象的值和来自第二个可观察对象的值的值对,并且按其在序列中的索引分组。以下的水晶图展示了应用 zip 操作符之前的初始序列和结果序列:

结果序列包含合并成单个值对的两个可观察对象的值。
摘要
在本章中,我们学习了函数式反应式编程范式。我们了解到许多函数式编程思想,如纯函数和函数组合,可以应用于反应式编程。我们还学习了什么是可观察对象,以及我们如何创建它们并与之交互。
在下一章中,我们将学习一些生产就绪的函数式编程库,例如 Ramda 和 Immutable.js。
第十章:实际应用中的函数式编程
在本书的前一章中,我们学习了函数式编程和函数式响应式编程。我们试图避免使用外部库,因为我们的主要目标是理解函数式编程和函数式响应式编程范式的技术、模式和原则。
在本章中,我们将学习以下主题:
-
使用 Ramda 进行组合
-
使用 Ramda 进行柯里化和部分应用
-
使用 Ramda 的透镜
-
与 Immutable.js 一起工作
-
与 Immer 一起工作
-
与 Funfix 一起工作
我们将再次回顾本书中探索的一些主要概念。然而,这一次,我们的重点不是理解这些概念(假设我们已经做到了)。相反,我们将专注于一些生产就绪的函数式编程库的使用,例如 Ramda 或 Immutable.js。
与 Ramda 一起工作
Ramda 是一个开源的函数式编程库,它包含许多实用函数,可以帮助我们将一些主要的函数式编程技术付诸实践。Ramda 可以与其他库,如 Lodash 或 Underscore 相比较。然而,Ramda 的 API 受函数式编程原则的影响比这些其他库更大。例如,Ramda 的设计使得可组合性和不可变性成为其组件的两个主要特性。
我们可以使用以下 npm 命令安装 Ramda:
npm install ramda @types/ramda
在以下章节中,我们将学习如何使用 Ramda 实现函数组合和透镜。
组合
在前面的章节中,我们声明了一个名为 compose 的高阶函数,它允许我们组合两个函数:
const compose = <T>(f: (x: T) => T, g: (x: T) => T) => (x: T) => f(g(x));
compose 函数使我们能够展示函数组合是如何工作的,但它有一些限制。例如,compose 函数只接受一个通用类型参数 T,这意味着我们只能组合两个接受类型 T 参数的一元函数 f 和 g:
const trim = (s: string) => s.trim();
const capitalize = (s: string) => s.toUpperCase();
const trimAndCapitalize = compose(trim, capitalize);
const result = trimAndCapitalize(" hello world ");
console.log(result); // "HELLO WORLD"
在实际应用中,我们可能需要组合两个接受不同类型 T1 和 T2 参数的函数 f 和 g。以下代码片段使用 Ramda 的 compose 函数而不是我们之前声明的函数:
import R from "ramda";
const trim = (s: string) => s.trim();
const capitalize = (s: string) => s.toUpperCase();
const trimAndCapitalize = R.compose(trim, capitalize);
const result = trimAndCapitalize(" hello world ");
console.log(result); // "HELLO WORLD"
Ramda 的 compose 函数是实际应用中更好的替代方案,因为它已经在数百个项目中使用,并且与数千个函数进行了测试。
部分应用和柯里化
我们还了解到,函数组合仅适用于一元函数,如果我们希望组合非一元函数,例如二元函数,我们可以使用函数部分应用来调用其中一个函数,只传递部分参数,并生成新的函数,这些函数接受剩余的参数。最后,我们还了解到我们可以使用柯里化将给定的函数转换成可以部分应用的函数:
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);
}
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 = curry3(replace);
const trimCapitalizeAndReplace = compose(
trimAndCapitalize,
curriedReplace("/")("-")
);
const result = trimCapitalizeAndReplace(" 13/feb/1989 ");
console.log(result); // "13-FEB-1989"
前面的代码片段使用curry3函数将三元函数转换为一个可以部分应用的函数。在实际应用中,不建议创建自定义实现,而应尝试使用经过实战检验的库。幸运的是,我们不需要搜索很长时间,因为 Ramda 包括一个名为curry的函数,可以用来实现我们想要的功能:
import R from "ramda";
const trim = (s: string) => s.trim();
const capitalize = (s: string) => s.toUpperCase();
const replace = (s: string, f: string, r: string) => s.split(f).join(r);
const trimCapitalizeAndReplace = R.compose(
R.compose(trim, capitalize),
R.curry(replace)("/")("-")
);
const result = trimCapitalizeAndReplace(" 13/feb/1989 ");
console.log(result); // "13-FEB-1989"
前面的代码片段以完全与 Ramda 无关的方式声明了三个函数。然后我们使用 Ramda 的实用函数compose和curry来生成一个名为trimCapitalizeAndReplace的新函数:
透镜
在一些前面的章节中,我们学习了关于透镜的内容。在最后一个例子中,我们以一种非常接近 Ramda 提供的实现方式实现了透镜。
我们实现了一个名为lens的高阶函数,可以用来创建Lens实现。lens函数接受两个必须实现Prop和Assoc接口的函数:
interface Lens<T1, T2> {
get(o: T1): T2;
set(o: T2, v: T1): T1;
}
type Prop<T, K extends keyof T> = (o: T) => T[K];
type Assoc<T, K extends keyof T> = (v: T[K], o: T) => T;
const lens = <T1, K extends keyof T1>(
getter: Prop<T1, K>,
setter: Assoc<T1, K>,
): Lens<T1, T1[K]> => {
return {
get: (obj: T1) => getter(obj),
set: (val: T1[K], obj: T1) => setter(val, obj),
};
}
const view = <T1, T2>(lens: Lens<T1, T2>, obj: T1) => lens.get(obj);
const set = <T1, K extends keyof T1>(
lens: Lens<T1, T1[K]>,
val: T1[K],
obj: T1
) => lens.set(val, obj);
我们可以创建一个Lens实现,将Assoc和Prop实现传递给Lens函数:
class Street {
public readonly num: number;
public readonly name: string;
public constructor(num: number, name: string) {
this.num = num;
this.name = name;
}
}
class Address {
public readonly city: string;
public readonly street: Street;
public constructor(city: string, street: Street) {
this.city = city;
this.street = street;
}
}
const propStreet: Prop<Address, "street"> = (o: Address) => o.street;
const assocStreet: Assoc<Address, "street"> = (v: Address["street"], o: Address) => {
return new Address(o.city, v);
};
const streetLens = lens(propStreet, assocStreet);
一旦我们有一个Lens实例,我们可以使用set和view函数来读取和设置不可变对象中属性的值:
const address = new Address(
"London",
new Street(1, "rathbone square")
);
const street = view(streetLens, address);
const address2 = set(
streetLens,
new Street(
address.street.num,
R.toUpper(address.street.name)
),
address
);
现在我们已经了解了关于 Ramda 的基础知识,我们可以再次实现前面的代码片段,使用其中的一些实用函数。Ramda 包括以下实用函数,以及其他一些:
-
prop函数允许我们声明一个属性获取器。它期望一个属性名称作为参数。 -
assoc函数允许我们声明一个属性设置器。它期望一个属性名称作为参数。 -
lens函数允许我们声明一个透镜实例。它期望一个属性获取器(prop)和一个设置器(assoc)作为参数。 -
lensProp函数允许我们声明一个透镜实例。它期望一个属性名称作为参数。 -
view函数允许我们获取对象中属性的值。它期望一个透镜实例和一个对象作为参数。 -
set函数允许我们在对象中设置属性的值。它期望一个透镜实例、一个新值和一个对象作为参数。
在下面的代码片段中,我们使用了lensProp、view和set函数:
import R from "ramda";
class Street {
public readonly num: number;
public readonly name: string;
public constructor(num: number, name: string) {
this.num = num;
this.name = name;
}
}
class Address {
public readonly city: string;
public readonly street: Street;
public constructor(city: string, street: Street) {
this.city = city;
this.street = street;
}
}
const streetLens = R.lensProp("street");
const address = new Address(
"London",
new Street(1, "rathbone square")
);
const street = R.view<Address, Street>(streetLens, address);
const address2 = R.set<Address, Street>(
streetLens,
new Street(
address.street.num,
R.toUpper(address.street.name)
),
address
);
由于有lensProp而不是lens函数,大多数情况下不需要使用prop和assoc函数。
使用 Immutable.js
在前面的章节中,我们还学习了不可变性和使用不可变数据对象的优点。我们了解到我们可以使用readonly关键字来声明不可变对象:
class Street {
public readonly num: number;
public readonly name: string;
public constructor(num: number, name: string) {
this.num = num;
this.name = name;
}
}
class Address {
public readonly city: string;
public readonly street: Street;
public constructor(city: string, street: Street) {
this.city = city;
this.street = street;
}
}
我们还了解到,与不可变对象一起工作有时可能非常冗长且繁琐,而 lenses 可以帮助我们克服这些困难。现在我们将学习一个可以帮助我们声明不可变对象的库。这个库被称为 Immutable.js,它还包括一个类似于 lenses API 的 API。我们可以使用以下命令安装 Immutable.js:
npm install immutable
我们可以使用 Record 类型定义类型安全的不可变类,如下所示:
import { Record } from "immutable";
interface StreetInterface {
num: number;
name: string;
}
const StreetRecord = Record({
num: 0,
name: ""
});
class Street extends StreetRecord implements StreetInterface {
constructor(props: StreetInterface) {
super(props);
}
}
我们将定义一个名为 Address 的更多不可变类。Address 类包含 Street 类的一个实例:
interface AddressInterface {
city: string;
street: Street;
}
const AddressRecord = Record({
city: "",
street: new Street({
num: 0,
name: ""
})
});
class Address extends AddressRecord implements AddressInterface {
constructor(props: AddressInterface) {
super(props);
}
}
要创建一个不可变类的实例,我们需要将所有必需的属性作为普通对象传递:
const address = new Address({
city: "Lonson",
street: new Street({
num: 1,
name: "rathbone square"
})
});
当我们使用 Immutable.js 声明一个不可变类时,该类继承了一些类似 lenses 的方法。我们可以使用 get 方法获取属性的值,使用 set 方法通过更新值创建一个新的不可变实例:
const street = address.get("street");
const street2 = street.set("name", "Rathbone square");
const address2 = address.set("street", street2);
console.log(
address.toJS(),
address2.toJS()
);
使用 Immutable.js 声明不可变类比使用 readonly 访问修饰符声明它们更为繁琐,但作为交换,我们得到了内置的 lenses 功能。
使用 Immer
我们将查看另一个流行的不可变性库。这个库被称为 Immer,可以使用以下 npm 命令安装:
npm install immer
Immer 允许我们使用 readonly 访问修饰符定义不可变类。这意味着我们也可以使用标准的类构造函数创建我们类的实例:
import produce from "immer";
class Street {
public readonly num: number;
public readonly name: string;
public constructor(num: number, name: string) {
this.num = num;
this.name = name;
}
}
class Address {
public readonly city: string;
public readonly street: Street;
public constructor(city: string, street: Street) {
this.city = city;
this.street = street;
}
}
const address = new Address(
"London",
new Street(1, "rathbone square")
);
Immer 可以使用名为 produce 的方法生成不可变对象的新版本。produce 函数将当前不可变对象的版本作为其第一个参数。第二个参数是一个回调函数,它接受一个称为草稿状态的参数。草稿状态是初始版本的可变版本,在回调函数内部,我们可以尽可能多地修改它:
const address2 = produce(address, draftAddress => {
draftAddress.street.name = "Rathbone square";
});
produce 函数返回一个新不可变对象,而不修改原始对象。Immer API 可以被认为是优于 Immutable.js API 的,因为它在我们的类声明和构造函数中施加了更少的约束。Immer 不基于 lenses,并允许我们使用一种创新的内部使用代理的方法来与不可变对象一起工作。
使用 Funfix
Funfix 是一组函数式编程实用函数集合。Funfix 可以与 Ramda 相比。就像 Ramda 一样,Funfix 可以用来组合函数或部分应用函数。然而,在本节中,我们将关注一些与我们在第七章 范畴论 中之前探索的一些数据类型相关的 Funfix 功能的使用。
我们将首先安装 Funfix:
npm install funfix @types/funfix
我们将在本节中实现的示例将需要一些额外的npm模块。我们将使用node-fetch从 Node.js 应用程序发送 HTTP 请求。我们还将使用一些 Node.js 核心模块,这意味着我们还需要 Node.js 的类型定义:
npm install node-fetch @type/node-fetch @types/node
在我们的第一个 Funfix 示例中,我们将使用IO.of工厂方法定义一个名为argsIO的单子。正如我们在前面的章节中学到的,单子是一种函子,而函子是一种容器。在这种情况下,容器包含一个执行 I/O 操作(如读取命令行参数process.argv)的函数。IO类型用于存储描述某些带有副作用的计算(如从文件读取数据或修改文档对象模型(DOM)中的元素)的函数。以这种方式描述动作允许IO实例被组合并传递,同时保持函数的纯度和维护引用透明性。
我们还将声明两个名为readFile和stdoutWrite的函数。这两个函数都返回一个单子实例,并且这两个单子都包含 I/O 操作。第一个从文件系统中读取文件,第二个在标准输出中打印一些信息:
import * as R from "ramda";
import * as fs from "fs";
import { IO } from "funfix";
const argsIO = IO.of(() => R.tail(R.tail(process.argv))[0]);
const readFile = (filename: string) => IO.of(() => fs.readFileSync(filename, "utf8"));
const stdoutWrite = (data: string) => IO.of(() => process.stdout.write(data));
const loudCat = argsIO.chain(readFile)
.map(R.toUpper)
.chain(stdoutWrite);
try {
loudCat.run();
} catch(e) {
console.log(e);
}
前面的代码片段还使用chain方法声明了一个名为loudCat的单子,将命令行参数传递给文件读取操作,并使用map方法将文件内容转换为大写。最后,它再次使用chain方法将大写文本传递到标准输出。
Funfix 中单子的一个主要特征是它们是惰性求值的,并且所有前面的操作都不会发生,直到我们调用run方法。如果一切顺利,我们可以通过命令行界面传递文件名:
node example.js test.txt
文件的大写内容应显示在标准输出上。以下示例使用node-fetch模块发送 HTTP 请求。执行 HTTP 请求的函数包含在一个单子中。这次,单子不是由IO.of工厂函数创建的,而是由IO.async工厂函数创建的。我们使用IO.async工厂函数是因为 Funfix 在将异步操作包装在单子中时需要它。示例还使用了Either类型,它也是一种函子和单子。它可以用来包装可能有两个可能值的值:
import { IO, Success, Failure, Either, Left, Right } from "funfix";
import fetch from "node-fetch";
interface Todo {
userId: number;
id: number;
title: string;
completed: boolean;
}
const getTodos = IO.async<Either<Error, Todo[]>>((ec, cb) => {
fetch(
"https://jsonplaceholder.typicode.com/todos"
).then(response => {
return response.json().then(
(json: Todo[]) => cb(Success(Right(json)))
)
})
.catch(err => cb(Failure(Left(err))));
});
const logTodos = getTodos.map((either) => {
return either.map(todos => todos.map(t => console.log(t.title)));
});
logTodos.run();
错误处理逻辑非常简单,因为Either类型的map方法仅在它的值的类型不是错误时映射值。就像之前一样,整个逻辑是惰性求值的,直到我们在logTodos单子中调用run方法,实际上什么都不会发生。
摘要
在本章中,我们学习了如何使用一些实际的函数式编程库,包括 Ramda、Fundix、Immer 和 Immutable.js。贯穿整本书,我们了解了函数式编程和函数式响应式编程范式的核心特性、原则、模式和原则。这些概念为您提供了一套强大的工具,将帮助您使用更容易推理、更易测试和更易维护的应用程序。
我希望您喜欢这本书,并且渴望继续您的函数式编程学习之旅。在附录中,您将找到一份指南,可用于发现新的函数式库以及您可以自行探索的额外函数式编程概念,如果您想了解更多的话。
第十一章:函数式编程学习路线图
以下指南可用于跟踪我们对函数式编程的了解程度。该指南是为LambdaConf会议的Fantasyland学习机构开发的。它旨在为实现范畴论的静态类型函数式编程语言设计。
Haskell 等语言原生支持范畴论,但,如我们之前所学的,我们可以通过实现它或使用一些第三方库在 TypeScript 中利用范畴论。由于语言差异,列表中并非所有项目都 100%适用于 TypeScript,但大多数都是 100%适用的。
初学者
要达到初学者水平,你需要掌握以下概念和技能:
| 概念 | 技能 |
|---|
|
-
不可变数据
-
二阶函数
-
构造和析构
-
函数组合
-
首等函数和 lambda
|
-
在不可变数据结构上使用二阶函数(
map、filter、fold) -
解构值以访问其组件
-
使用数据类型来表示可选性
-
读取基本类型签名
-
将 lambda 传递给二阶函数
|
高级初学者
要达到高级初学者水平,你需要掌握以下概念和技能:
| 概念 | 技能 |
|---|
|
-
代数数据类型
-
模式匹配
-
参数多态
-
通用递归
-
类型类、实例和法律
-
低阶抽象(等价、半群、单群等)
-
引用透明性和完备性
-
高阶函数
-
部分应用、柯里化和无参数风格
|
-
无需 nulls、异常或类型转换解决问题
-
使用递归处理和转换递归数据结构
-
能够在小型中使用函数式编程
-
为具体单子编写基本的单子代码
-
为自定义数据类型创建类型类实例
-
使用抽象数据类型(ADTs)对业务领域进行建模
-
编写接受和返回函数的函数
-
可靠地识别和隔离纯代码与不纯代码
-
避免引入不必要的 lambda 和命名参数
|
中级
要达到中级水平,你需要掌握以下概念和技能:
| 概念 | 技能 |
|---|
|
-
泛化代数数据类型
-
高阶类型
-
Rank-N 类型
-
折叠和展开
-
高阶抽象(范畴、函子、单子)
-
基本透镜
-
实现高效的持久数据结构
-
存在类型
-
使用组合子嵌入领域特定语言
|
-
能够实现大型函数式编程应用程序
-
使用生成器和属性测试代码
-
通过单子以纯函数方式编写命令式代码
-
使用流行的纯函数库解决业务问题
-
将决策与效果分离
-
编写简单的自定义法律单子
-
编写生产中型项目
-
使用透镜和棱镜来操作数据
-
通过存在量词隐藏无关数据以简化类型
|
精通
要达到熟练水平,你需要掌握以下概念和技能:
| 概念 | 技能 |
|---|
|
-
Codata
-
(共)递归方案
-
高级光学
-
双重抽象(comonad)
-
单子变换器
-
自由单子和可扩展效应
-
函数架构
-
高级函子(指数, profunctors,逆变)
-
使用泛化代数数据类型(GADTs)嵌入领域特定语言(DSLs)
-
高级单子(延续,逻辑)
-
类型族,函数依赖(FDs)
|
-
设计一个最小化强大的单子变换器堆栈
-
编写并发和流程序
-
在测试中使用纯函数模拟
-
使用类型类以模块化方式建模不同的效应
-
识别类型模式并在其上抽象
-
以新颖的方式使用函数库
-
使用光学来操作状态
-
编写定制的合法单子变换器
-
使用自由单子/可扩展效应来分离关注点
-
在类型级别编码不变量。
-
有效使用 FDs/类型族来创建更安全的代码
|
专家
要达到专家水平,你需要掌握以下概念和技能:
| 概念 | 技能 |
|---|
|
-
高性能
-
类型多态
-
泛型编程
-
类型级编程
-
依赖类型,单例类型
-
类别理论
-
图归约
-
高阶抽象语法
-
函数语言的编译器设计
-
Profunctor optics
|
-
设计一个通用、合法的库,具有广泛的吸引力
-
使用等价推理手动证明属性
-
设计和实现一个新的函数式编程语言
-
使用法则创建新颖的抽象
-
编写具有某些保证的分布式系统
-
使用证明系统正式证明代码的性质
-
创建不允许无效状态的库。
-
使用依赖类型在编译时证明更多属性
-
理解不同概念之间的深层关系
-
使用最小牺牲对纯函数代码进行性能分析、调试和优化
|
摘要
这份指南应该是一个很好的资源,可以帮助你在未来的函数式编程学习上取得进步。在这本书中,我们没有任何函数式编程的先验知识,我们已经达到了中级水平。成为函数式编程的专家需要一些时间,但在这个阶段,我们对其了解足够,可以充分利用其功能并享受其主要好处。
第十二章:TypeScript 函数式编程库目录
在本附录中,您将找到按以下类别分组的与 TypeScript 兼容的函数式编程库列表:
-
函数式编程:通用函数式编程工具,包括
compose函数 -
类别论:提供代数数据类型实现的库
-
懒惰性:提供用于实现懒惰评估的库
-
不可变性:提供用于实现不可变数据结构的库
-
光学:提供函数光学和透镜实现的库。
-
函数式响应式编程:通用、函数式响应式编程工具,例如观察者
-
其他:不专注于函数式编程,但高度受其原则影响的库
函数式编程
以下库允许我们在 TypeScript 中利用不可变性:
| 库 | 描述 | 链接 |
|---|---|---|
| Ramda | 一个实用的 JavaScript 程序员纯函数式库。 | github.com/ramda/ramda |
fp-ts |
TypeScript 应用程序的纯函数式编程工具 | github.com/gcanti/fp-ts |
| Underscore | 包含一些函数式编程辅助函数的辅助函数集合 | github.com/jashkenas/underscore |
| Lodash | 包含一些函数式编程辅助函数的辅助函数集合 | github.com/lodash/lodash |
wu.js |
ES6 迭代器的更高阶函数。 | github.com/fitzgen/wu.js/ |
类别论
以下库允许我们在 TypeScript 中利用不可变性:
| 库 | 描述 | 链接 |
|---|---|---|
| Ramda-fantasy | 与 Fantasyland 规范兼容的代数数据类型,易于与 Ramda.js 集成。 | github.com/ramda/ramda-fantasy |
io-ts |
TypeScript 兼容的 IO 解码/编码的运行时类型系统 | github.com/gcanti/io-ts |
| Funfix | Funfix 是一个用于 JavaScript、TypeScript 和 Flow 的函数式编程类型类和数据类型的库。 | github.com/funfix/funfix |
懒惰性
以下库允许我们在 TypeScript 中利用不可变性:
| 库 | 描述 | 链接 |
|---|---|---|
Lazy.js |
Lazy.js 是一个 JavaScript 的函数式实用库,类似于 Underscore 和 Lodash,但底层有一个懒惰引擎,力求尽可能少地工作,同时尽可能灵活。 | github.com/dtao/lazy.js/ |
| Transducers-js | JavaScript 的高性能转换器实现。 | github.com/cognitect-labs/transducers-js |
不可变性
以下库让我们能够在 TypeScript 中利用不可变性:
| 库 | 描述 | 链接 |
|---|---|---|
Immutable.js |
为 JavaScript 提供的不可变持久数据集合,可提高效率和简洁性。 | github.com/facebook/immutable-js |
| Immer | Immer 是一个小巧的包,允许你以更方便的方式处理不可变状态。它基于写时复制机制。 | github.com/mweststrate/immer |
光学和镜头
以下库让我们能够在 TypeScript 中利用不可变性:
| 库 | 描述 | 链接 |
|---|---|---|
monocle-ts |
函数式光学:Scala monocle 到 TypeScript 的(部分)移植。 | github.com/gcanti/monocle-ts |
lens.ts |
一个带有属性代理的 TypeScript 镜头实现。 | github.com/utatti/lens.ts |
| Lenses | 一个小型函数式镜头库,旨在保持小巧,无依赖,类型强大且精确。它受到 F# 中的 Aether 的启发。 | github.com/atomicobject/lenses |
Lenticular.ts |
JavaScript/TypeScript 中功能镜头的实现。 | github.com/tomasdeml/lenticular.ts |
函数式响应式编程
以下库让我们能够在 TypeScript 中利用响应式编程:
| 库 | 描述 | 链接 |
|---|---|---|
| RxJS | JavaScript 的响应式编程库。 | github.com/ReactiveX/rxjs |
| Xstream | 一个直观、小巧且快速的 JavaScript 函数式响应式流库。 | github.com/x-stream/xstream |
Bacon.js |
一个小巧的 JavaScript 函数式响应式编程库。 | github.com/baconjs/bacon.js/ |
其他
以下库让我们能够在 TypeScript 中利用不可变性:
| 库 | 描述 | 链接 |
|---|---|---|
| React | 一个受函数式编程原则高度影响的用户界面开发库。 | github.com/facebook/react |
| Redux | Redux 是 JavaScript 应用程序的状态容器,并高度受到函数式编程原则的影响。 | github.com/reduxjs/redux |
Cycle.js |
一个受函数式响应式编程高度影响的用户界面开发库。 | github.com/cyclejs/cyclejs |
| Mobx | 一个受函数式响应式编程高度影响的用户界面开发库。 | github.com/mobxjs/mobx |
摘要
本附录为您提供了对一些流行的函数式编程和函数式响应式编程库的快速参考。这些库,连同我们在本书中之前学到的技术和原则,应该为您提供创建多个现实世界函数式编程应用所需的一切。



浙公网安备 33010602011771号